Warum wird eine Java-Objektreferenz so viel langsamer zurückgegeben als ein Grundelement?

75

Wir arbeiten an einer latenzempfindlichen Anwendung und haben alle Arten von Methoden (mit jmh ) mikrobenchmarkiert . Nachdem ich eine Suchmethode mit einem Mikrobenchmarking versehen und mit den Ergebnissen zufrieden war, implementierte ich die endgültige Version und stellte fest, dass die endgültige Version dreimal langsamer war als das, was ich gerade bewertet hatte.

Der Schuldige war, dass die implementierte Methode ein enumObjekt anstelle eines zurückgab int. Hier ist eine vereinfachte Version des Benchmark-Codes:

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class ReturnEnumObjectVersusPrimitiveBenchmark {

    enum Category {
        CATEGORY1,
        CATEGORY2,
    }

    @Param( {"3", "2", "1" })
    String value;

    int param;

    @Setup
    public void setUp() {
        param = Integer.parseInt(value);
    }

    @Benchmark
    public int benchmarkReturnOrdinal() {
        if (param < 2) {
            return Category.CATEGORY1.ordinal();
        }
        return Category.CATEGORY2.ordinal();        
    }


    @Benchmark
    public Category benchmarkReturnReference() {
        if (param < 2) {
            return Category.CATEGORY1;
        }
        return Category.CATEGORY2;      
    }


    public static void main(String[] args) throws RunnerException {
            Options opt = new OptionsBuilder().include(ReturnEnumObjectVersusPrimitiveBenchmark.class.getName()).warmupIterations(5)
                .measurementIterations(4).forks(1).build();
        new Runner(opt).run();
    }

}

Die Benchmark-Ergebnisse für oben:

# VM invoker: C:\Program Files\Java\jdk1.7.0_40\jre\bin\java.exe
# VM options: -Dfile.encoding=UTF-8

Benchmark                   (value)   Mode  Samples     Score     Error   Units
benchmarkReturnOrdinal            3  thrpt        4  1059.898 ±  71.749  ops/us
benchmarkReturnOrdinal            2  thrpt        4  1051.122 ±  61.238  ops/us
benchmarkReturnOrdinal            1  thrpt        4  1064.067 ±  90.057  ops/us
benchmarkReturnReference          3  thrpt        4   353.197 ±  25.946  ops/us
benchmarkReturnReference          2  thrpt        4   350.902 ±  19.487  ops/us
benchmarkReturnReference          1  thrpt        4   339.578 ± 144.093  ops/us

Durch einfaches Ändern des Rückgabetyps der Funktion wurde die Leistung um den Faktor 3 geändert.

Ich dachte, dass der einzige Unterschied zwischen der Rückgabe eines Enum-Objekts und einer Ganzzahl darin besteht, dass einer einen 64-Bit-Wert (Referenz) und der andere einen 32-Bit-Wert zurückgibt. Einer meiner Kollegen vermutete, dass die Rückgabe der Aufzählung zusätzlichen Aufwand verursacht, da die Referenz für potenzielle GC nachverfolgt werden muss. (Angesichts der Tatsache, dass Enum-Objekte statische Endreferenzen sind, erscheint es seltsam, dass dies erforderlich wäre.)

Was ist die Erklärung für den Leistungsunterschied?


AKTUALISIEREN

Ich habe das Maven-Projekt hier geteilt, damit jeder es klonen und den Benchmark ausführen kann. Wenn jemand die Zeit / das Interesse hat, wäre es hilfreich zu sehen, ob andere die gleichen Ergebnisse replizieren können. (Ich habe auf zwei verschiedenen Computern, Windows 64 und Linux 64, repliziert, beide mit Oracle Java 1.7-JVMs). @ ZhekaKozlov sagt, er habe keinen Unterschied zwischen den Methoden gesehen.

So führen Sie Folgendes aus: (nach dem Klonen des Repositorys)

mvn clean install
java -jar .\target\microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1
Sam Goldberg
quelle
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben .
Flexo

Antworten:

155

TL; DR: Sie sollten BLIND kein Vertrauen in irgendetwas setzen.

Das Wichtigste zuerst: Es ist wichtig, die experimentellen Daten zu überprüfen, bevor Sie zu den daraus resultierenden Schlussfolgerungen gelangen. Nur zu behaupten, etwas sei dreimal schneller / langsamer, ist seltsam, weil Sie den Grund für den Leistungsunterschied wirklich nachverfolgen müssen und nicht nur den Zahlen vertrauen müssen. Dies ist besonders wichtig für Nano-Benchmarks wie Sie.

Zweitens sollten die Experimentatoren klar verstehen, was sie kontrollieren und was nicht. In Ihrem speziellen Beispiel geben Sie den Wert von @BenchmarkMethoden zurück. Können Sie jedoch ziemlich sicher sein, dass die Aufrufer außerhalb dasselbe für das Grundelement und die Referenz tun? Wenn Sie sich diese Frage stellen, werden Sie feststellen, dass Sie im Grunde die Testinfrastruktur messen.

Auf den Punkt gebracht. Auf meinem Computer (i5-4210U, Linux x86_64, JDK 8u40) ergibt der Test:

Benchmark                    (value)   Mode  Samples  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt        5  0.876 ± 0.023  ops/ns
...benchmarkReturnOrdinal          2  thrpt        5  0.876 ± 0.009  ops/ns
...benchmarkReturnOrdinal          1  thrpt        5  0.832 ± 0.048  ops/ns
...benchmarkReturnReference        3  thrpt        5  0.292 ± 0.006  ops/ns
...benchmarkReturnReference        2  thrpt        5  0.286 ± 0.024  ops/ns
...benchmarkReturnReference        1  thrpt        5  0.293 ± 0.008  ops/ns

Okay, Referenztests erscheinen also dreimal langsamer. Aber warten Sie, es wird ein altes JMH (1.1.1) verwendet. Lassen Sie uns auf das neueste Update (1.7.1) aktualisieren:

Benchmark                    (value)   Mode  Cnt  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt    5  0.326 ± 0.010  ops/ns
...benchmarkReturnOrdinal          2  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnOrdinal          1  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnReference        3  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        2  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        1  thrpt    5  0.288 ± 0.002  ops/ns

Ups, jetzt sind sie kaum noch langsamer. Übrigens sagt uns dies auch, dass der Test an die Infrastruktur gebunden ist. Okay, können wir sehen, was wirklich passiert?

Wenn Sie die Benchmarks erstellen und sich umschauen, was genau Ihre @BenchmarkMethoden nennt , sehen Sie Folgendes:

public void benchmarkReturnOrdinal_thrpt_jmhStub(InfraControl control, RawResults result, ReturnEnumObjectVersusPrimitiveBenchmark_jmh l_returnenumobjectversusprimitivebenchmark0_0, Blackhole_jmh l_blackhole1_1) throws Throwable {
    long operations = 0;
    long realTime = 0;
    result.startTime = System.nanoTime();
    do {
        l_blackhole1_1.consume(l_longname.benchmarkReturnOrdinal());
        operations++;
    } while(!control.isDone);
    result.stopTime = System.nanoTime();
    result.realTime = realTime;
    result.measuredOps = operations;
}

Das l_blackhole1_1hat eine consumeMethode, die die Werte "verbraucht" (siehe BlackholeBegründung). Blackhole.consumehat Überladungen für Referenzen und Grundelemente , und das allein reicht aus, um den Leistungsunterschied zu rechtfertigen.

Es gibt einen Grund, warum diese Methoden anders aussehen: Sie versuchen, für ihre Argumentationstypen so schnell wie möglich zu sein. Sie weisen nicht unbedingt die gleichen Leistungsmerkmale auf, obwohl wir versuchen, sie anzupassen, daher das symmetrischere Ergebnis mit neueren JMH. Jetzt können Sie sogar -prof perfasmden generierten Code für Ihre Tests anzeigen und sehen, warum die Leistung unterschiedlich ist, aber das geht hier über den Punkt hinaus.

Wenn Sie wirklich verstehen möchten , wie sich die Rückgabe des Grundelements und / oder der Referenz in Bezug auf die Leistung unterscheidet, müssen Sie eine große, beängstigende Grauzone für nuanciertes Leistungsbenchmarking betreten . ZB so etwas wie dieser Test:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(5)
public class PrimVsRef {

    @Benchmark
    public void prim() {
        doPrim();
    }

    @Benchmark
    public void ref() {
        doRef();
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private int doPrim() {
        return 42;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private Object doRef() {
        return this;
    }

}

... was für Primitive und Referenzen das gleiche Ergebnis liefert:

Benchmark       Mode  Cnt  Score   Error  Units
PrimVsRef.prim  avgt   25  2.637 ± 0.017  ns/op
PrimVsRef.ref   avgt   25  2.634 ± 0.005  ns/op

Wie ich bereits sagte, diese Tests erfordern für die Ergebnisse zu den Gründen Verfolgung. In diesem Fall ist der generierte Code für beide fast gleich, und das erklärt das Ergebnis.

prim:

                  [Verified Entry Point]
 12.69%    1.81%    0x00007f5724aec100: mov    %eax,-0x14000(%rsp)
  0.90%    0.74%    0x00007f5724aec107: push   %rbp
  0.01%    0.01%    0x00007f5724aec108: sub    $0x30,%rsp         
 12.23%   16.00%    0x00007f5724aec10c: mov    $0x2a,%eax   ; load "42"
  0.95%    0.97%    0x00007f5724aec111: add    $0x30,%rsp
           0.02%    0x00007f5724aec115: pop    %rbp
 37.94%   54.70%    0x00007f5724aec116: test   %eax,0x10d1aee4(%rip)        
  0.04%    0.02%    0x00007f5724aec11c: retq  

ref:

                  [Verified Entry Point]
 13.52%    1.45%    0x00007f1887e66700: mov    %eax,-0x14000(%rsp)
  0.60%    0.37%    0x00007f1887e66707: push   %rbp
           0.02%    0x00007f1887e66708: sub    $0x30,%rsp         
 13.63%   16.91%    0x00007f1887e6670c: mov    %rsi,%rax     ; load "this"
  0.50%    0.49%    0x00007f1887e6670f: add    $0x30,%rsp
  0.01%             0x00007f1887e66713: pop    %rbp
 39.18%   57.65%    0x00007f1887e66714: test   %eax,0xe3e78e6(%rip)
  0.02%             0x00007f1887e6671a: retq   

[Sarkasmus] Sehen Sie, wie einfach es ist! [/Sarkasmus]

Das Muster lautet: Je einfacher die Frage, desto mehr müssen Sie herausfinden, um eine plausible und zuverlässige Antwort zu erhalten.

Aleksey Shipilev
quelle
2
Kompetente Antwort. Was empfehlen Sie, um einen gültigen Benchmark durchzuführen? Ich würde vorschlagen, eine Schleife und einen nicht optimierbaren Verbraucher in die Benchmark-Methode selbst einzubauen. Auf diese Weise verschwindet die Leistung des Testframeworks im Rauschen.
usr
6
Äh, lassen Sie mich zuerst allgemein antworten. Ich empfehle, von etwas zu beginnen, zu sehen, was es schließlich tut, und es zu reparieren, bis es tut, was Sie wollen.
Aleksey Shipilev
2
Nun eine konkretere Antwort. JMH generiert die Schleife selbst und ruft das "nicht optimierbare" Blackhole.consumefür den Benutzer auf. Sie können es wahrscheinlich in die @BenchmarkMethode ziehen und die nicht inlinierbare Methode verwenden, um die Ergebnisse zu verarbeiten, aber das funktioniert nur, bis Sie einen intelligenteren Optimierer treffen ... Während wir überdenken können, was JMH in diesem Fall verdeckt tut, würde der Benutzer hacken unvermeidlich zurückbleiben. Dann werden Benutzer, die mehr Vertrauen in ihren heiligen Code als in ein heiliges Benchmarking-Framework setzen, in der Hölle brennen!
Aleksey Shipilev
3
@usr Mit Hotspot können Sie verwenden -Xint, und die meisten Ihrer Benchmarking-Probleme verschwinden . (Bitte nicht) "Energieversorger hassen mich dafür." (c)
Aleksey Shipilev
4
Gute Antwort. Persönlich würde ich mir auf jeden Fall nur die Baugruppe für die gegebenen Tests ansehen, da diese Art von Mikro-Mikro-Tests selbst mit jmh furchtbar leicht falsch zu machen sind, wie diese Frage gut zeigt. Wenn Sie sich Sorgen um die Leistung machen, ist das Verständnis der Montage ein unglaublich nützliches Werkzeug. @usr Wenn ich jemals genug Zeit habe, möchte ich tatsächlich so etwas wie jmh für .NET implementieren (ich habe ungefähr ein Drittel erledigt) und es ist ach so sehr, sehr viel einfacher als im Vergleich zu Java / HotSpot;)
Voo
6

Um das Missverständnis von Referenz und Speicher zu beseitigen, in das einige geraten sind (@Mzf), gehen wir auf die Java Virtual Machine-Spezifikation ein. Aber bevor man dorthin geht, muss eines geklärt werden: Ein Objekt kann niemals aus dem Speicher abgerufen werden, nur seine Felder können es . Tatsächlich gibt es keinen Opcode, der eine derart umfangreiche Operation ausführen würde.

In diesem Dokument wird die Referenz als Stapeltyp (so dass es sich um ein Ergebnis oder ein Argument für Anweisungen handeln kann, die Operationen am Stapel ausführen) der 1. Kategorie definiert - der Kategorie von Typen, die ein einzelnes Stapelwort (32 Bit) verwenden. Siehe Tabelle 2.3 Eine Liste der Java-Stapeltypen.

Wenn der Methodenaufruf gemäß der Spezifikation normal abgeschlossen wird, wird ein Wert, der von der Oberseite des Stapels angezeigt wird, auf den Stapel des Methodenaufrufers verschoben (Abschnitt 2.6.4).

Ihre Frage ist, was den Unterschied der Ausführungszeiten verursacht. Kapitel 2 Vorwortantworten:

Implementierungsdetails, die nicht Teil der Spezifikation der Java Virtual Machine sind, würden die Kreativität der Implementierer unnötig einschränken. Das Speicherlayout von Laufzeitdatenbereichen, der verwendete Garbage-Collection-Algorithmus und jede interne Optimierung der Java Virtual Machine-Anweisungen (z. B. deren Übersetzung in Maschinencode) liegen im Ermessen des Implementierers.

Mit anderen Worten, da im Dokument aus logischen Gründen keine Leistungsstrafe für die Verwendung von Referenzen angegeben ist (es handelt sich letztendlich nur um ein Stapelwort, wie intoder wie floates ist), müssen Sie den Quellcode Ihrer Implementierung suchen oder nie überhaupt herausfinden.

In gewissem Umfang sollten wir die Implementierung nicht immer beschuldigen. Es gibt einige Hinweise, die Sie bei der Suche nach Ihren Antworten verwenden können. Java definiert separate Anweisungen zum Bearbeiten von Zahlen und Referenzen. Referenz-Manipulation Anweisungen beginnen mit a(zB astore, aloadoder areturn) und sind die einzigen zur Arbeit mit Referenzen erlaubt Anweisungen. Insbesondere könnten Sie daran interessiert sein, areturndie Implementierung zu betrachten.

user35443
quelle
1
Nehmen Referenzen notwendigerweise 32 Bit ein?
2
Es ist nicht sofort ersichtlich, wie dies die Frage beantwortet.
Oleg Estekhin
2
Wenn Sie über Java-Bytecode sprechen, wenn Sie sich über die Leistung eines Java-Programms wundern , kommt dies immer zu kurz . "Schauen Sie sich die Implementierung von an areturn" macht keinen Sinn - so funktionieren moderne Compiler nicht (selbst der HotSpot-Interpreter interpretiert aus Leistungsgründen nicht mehr wirklich jeweils eine Anweisung)
Voo