Schön oder Schnell II

Vor einiger Zeit entschloss ich, dass die Zeit gekommen ist sich mit den neuen Features der von mir für verschiedene Toolentwicklungen favorisierten Sprache Java zu befassen. Es betraf die inzwischen hinreichend weit verbreitete Version Java 8, und insbesondere auf die Lambda-Ausdrücke war ich neugierig.

Ein konkreter Anwendungsfall war schnell gefunden. Bei Last- und Performancetests fallen oft Daten zu Zeitintervallen an, typisch in der Form eines Timestamps (ts), einer Dauer (t) und mehrere zusätzlicher Attribute wie Bezeichner(label) oder Returncodes. Nun entspricht dabei der Zeitstempel manchmal dem Beginn des Zeitintervalls, manchmal auch dem Ende. In Auswertungen und Darstellungen benötigt man mal das eine, mal das andere, sodass meine Toolsammlung Optionen zur Umrechnung benötigt. Der erste ad-hoc Entwurf dazu war derart hässlich, dass ich mich spontan entschloss, an diesem Beispiel mit den Lambdas warm zu werden.

Fangen wir mit dem hässlichen „dahingeschmierten“ Designversuch an:

public class UglyFirst {
  //* hier relevante Attribute eines ResponseTime-Objektes:
  public interface ResponseTime{
    long getTs();  // timestamp, Anfang oder Ende
    long getT();         // Dauer desIntervals
    String getLabel(); // Bezeichner 
  }
  //* enum für die Justiermethoden:
  public enum TimeStampSelector {
    TS,TS_minus_t,TS_plus_t;  
  }
  //* Lieferant für ResponseTime Daten (test data provider)
  public interface ResponseTimeSource {
    int size();
    ResponseTime getResponseTime(int idx);
  }
  //* Berechne etwas fuer alle Responsetimeobjekte mit angepasstem Timestamp
  public void calc(ResponseTimeSource rts, TimeStampSelector tss) {
    for(int i=0;i<rts.size();i++) {
      ResponseTime rt=rts.getResponseTime(i);
      long ts=0L;
      switch(tss) {
        case TS        : ts=rt.getTs();break;
        case TS_minus_t: ts=rt.getTs()-rt.getT();break;
        case TS_plus_t : ts=rt.getTs()+rt.getT();break;
      }
      add(ts,rt.getLabel());
    }
  }
  private void add(long tsAdjusted, String label) {
    // tue irgendwas ...
  }
}

Als Erklärung:

  • Eine Enumeration dient dazu, die Justierungsmethode anzugeben.
  • Die Umrechnung erfolgt hier für jedes ResponseTime Objekt wiederholt(!) in einem Switch-Case Block
  • Und diese Switch-Blöcke werden über kurz oder lang an immer mehr Stellen im Code auftauchen!

Das sollte doch eigentlich mit Lambdas besser gehen! Der nächste Versuch sah dann in etwa wie folgt aus:

//* Functional Interface 
public interface ITimestampExtractor {
  public long getAdjustedTs(T rt);
  }
  //* enum für die Justiermethoden:
  public enum TimeStampSelector {
    TS    (rt->(rt.getTs())),
    TS_minus_t    (rt->(rt.getTs()-rt.getT())),
    TS_plus_t     (rt->(rt.getTs()+rt.getT()));
    private ITimestampExtractor te;
    private TimeStampSelector(ITimestampExtractor te) {
      this.te=te;
    };
    public ITimestampExtractor getTsAdjuster(){
      return te;
    }
}
//* Berechne etwas fuer alle ResponseTime-Objekte mit angepasstem Timestamp
public void calc2(ResponseTimeSource rts, TimeStampSelector tss) {
  ITimestampExtractor adjustTS=tss.getTsAdjuster();
  for(int i=0;i<rts.size();i++) {
    ResponseTime rt=rts.getResponseTime(i);
    add(adjustTS.getAdjustedTs(rt),rt.getLabel());
  }
}

Im Gegensatz zur ersten Version bedeutet das

  • die Enum liefert auch gleich die Umrechnungsfunktionen, die Logik  ist also kann einer Stelle konzentriert
  • es ist nirgends mehr (insbesondere im den Schleifen von calc*()) ein switch-Konstrukt notwendig
  • es je Aufruf von calc() nur einmal die notwendige Auswahl des Adjust-Verfahrens ermittelt anstatt für jedes Runtime-Objekt

Die letzte Eigenschaft ließ mich vermuten, dass der Code damit nicht nur kompakter und besser wartbar, sondern auch schneller ist.  Um Gewissheit zu erlangen, wurde der oben skizzierte Code ausprogrammiert und einem „Micro Benchmark“ unterworfen. Das Ergebnis  hat mich überrascht:

  • Der Benchmark ließ nach einem kleinen „Warmup“  5 mal jedes der Justierungsverfahren gegen einen künstlichen Testdatensatz von 2E7 „ResponseTime-Objekte die calc() Methode aufrufen, wobei die Aggregationsmethode „add()“ leer war,
  • Die „Switch-Variante“ brauchte im Mittel für jeden calc() Aufruf ca. 180 ms
  • Die Lambda-Variante benötigte dafür ca. 570 ms, die 3fache Zeit!

Auf der Suche nach dem Grund hierfür konnte einer der „üblichen Verdächtigen“ schnell ausgeschlossen werden: GarbageCollectorMXBean zeigtem schnell, dass alle GCs während der Initialisierung der Testdaten und des Warmups auftraten. Als nächstes waren Läufe mit aktivierten Profilier an der Reihe. Hier verfälschte der Profilier-Code jedoch die Ergebnisse massiv. Statt mittels Filter für die zu instrumentieremdem Klassen doch noch eine Aussage zu gewinnen, entschied ich mich für eine Untersuchung des eigentlich ausgeführten Codes im Eclipse Java Debugger. Ein Breakpoint im der Methode ResponseTime.getTs() lieferte als Aufruf-Stack:

Thread [main] (Suspended (breakpoint at line 22 in ResponseTime3))  
  ResponseTime3.getTs() line: 22  
  LambdaValueExtractorBench4b.test5a(TimeStampExtractor4Combined) line: 148

für die “Switch-Variante”, und

Thread [main] (Suspended (breakpoint at line 22 in ResponseTime3))  
  ResponseTime3.getTs() line: 22  
  TimeStampExtractor4Combined.lambda$0(ResponseTime3) line: 10  
  1873653341.getAdjustedTs(ResponseTime3) line: not available  
  LambdaValueExtractorBench4b.test5c(TimeStampExtractor4Combined) line: 174

für den Code mit Lambdas.

Zwischen dem test5*() Aufruf (entspricht dem obigen call() liegen für die Lambdas 2 Stackframes, es finden also 2 zusätzliche Methodeaufrufe statt, die auch noch in verschiedenen Klassen liegen. Kann das schon die Ursache für den Unterschied sein? Kurze Überschlagsrechnung: Die gemessene Zeitdifferenz von 380 ms gilt für 3*2E7 Justierungen des Zeitstempels, macht ca. 3 Nanosekunden pro Umrechnung. Das liegt in der Größenordnung von einer Handvoll Taktzyklen. Hängt man 2 „Hilfsobjekte“ mit entsprechenden Methoden vor das ResponseTime Objekt erhält man eine Verzögerung in derselben Größenordnung. Somit ist der Grund für den Unterschied gefunden.

Obiger Testcode hat noch an ein für schnelle Micro-Benchmarks nicht unüblichen: Die eigentliche Analyse-Funktion, hier „add(long tsAdjusted, String label)“ ist leer. Das betont zwar den Unterschied in den Code-Varianten, entspricht aber kaum einer realen Nutzung. Ändert man die „Aggregationsfunktion“ in

class MinMax {
  long tsMin=Long.MAX_VALUE;
  long tsMax=Long.MIN_VALUE;
  public void add(long ts) {
    if(ts<tsMin) tsMin=ts;
    if(ts>tsMax) tsMax=ts;
  }
  public void reset() {
    tsMin=Long.MAX_VALUE;
    tsMax=Long.MIN_VALUE;
  }
}
private final MinMax minMax=new MinMax();
private Set  labelSet=new HashSet<>();
private void add(long adjustedTs,String label) {
  if(opMinMax)    minMax.add(adjustedTs);
  if(opLabelVals) labelSet.add(label);
}

(Berechnung des minimalen und maximalen justierten Zeitstempel und Ermittlung aller auftretenden Werte für „label“) so erhält man Testlaufzeiten wie im folgenden Diagramm dargestellt:

Statt Faktor 3 reduziert sich die Laufzeit um ca 15%, wenn beide Aggregationen ausgeführt werden. Für das noch verbleibende Problem, dass der schnellste Code leider zu vielen Switch-Case Anweisungen führen wird, gibt es übrigens auch eine Lösung  - man kann ohne messbaren Zeitverlust den Switch-Case-Block in die Enum verschieben.

public enum TimeStampExtractor2 {
  TS,  TS_minus_t , TS_plus_t; 
 
  public long extractTS(ResponseTime3 rt) {
    switch(this) {
    case TS         : return rt.getTs();
    case TS_minus_t : return rt.getTs() - rt.getT();
    case TS_plus_t  : return rt.getTs() + rt.getT();
    }
    return -1;
  }
}

Die Nutzung geschieht dann wie folgt

private void test5e(TimeStampExtractor2 tse) {
  for(int i=0;i<rttd.size();i++) {
    ResponseTime3 rt=rttd.getResponseTime(i);
    add(tse.extractTS(rt),rt.getLabel());
  }
}

Es bleibt ein Fazit zu ziehen: Erstmal ein Klassiker: Je kürzer ein häufig wiederholt ausgeführter Code-Block ist, desto mehr lohnt es sich, auf unnötige Methoden/Funktionsaufrufe zu achten – ohne dabei aber in das Anti-Pattern „premature optimization“ (siehe dieser Link)  zu verfallen. Performance-Verbesserungen durch Codeänderungen sollten immer im Kontext realer Anwendungen bewertet werden. Ferner hat die Arbeit an der hier beschriebenen Untersuchung  nicht nur Spass gemacht, sondern mir auch geholfen, mit den Lambdas warm zu werden. Auch wenn sich die Hoffnung auf Laufzeitbeschleunigung in Luft aufgelöst hat, bleibt immer noch eine knappe und prägnante Ausdrucksform. Und das war erst ein kleiner Teil der neuen Syntax.

Autor: Dr. Thorsten WenzelWebsite: /index.php/prof/profile-wnz