Ich arbeite an Java-Code, der stark optimiert werden muss, da er in heißen Funktionen ausgeführt wird, die an vielen Stellen in meiner Hauptprogrammlogik aufgerufen werden. Ein Teil dieses Codes beinhaltet das Multiplizieren von double
Variablen durch 10
Erhöhen auf beliebige nicht negative int
exponent
s. Eine schnelle Art und Weise (edit: aber nicht die schnellsten möglich, siehe Update 2 unten) zu bekommen den multiplizierte Wert ist switch
auf dem exponent
:
double multiplyByPowerOfTen(final double d, final int exponent) {
switch (exponent) {
case 0:
return d;
case 1:
return d*10;
case 2:
return d*100;
// ... same pattern
case 9:
return d*1000000000;
case 10:
return d*10000000000L;
// ... same pattern with long literals
case 18:
return d*1000000000000000000L;
default:
throw new ParseException("Unhandled power of ten " + power, 0);
}
}
Die oben kommentierten Ellipsen zeigen an, dass die case
int
Konstanten weiterhin um 1 erhöht werden, sodass case
das obige Codefragment tatsächlich 19 Sekunden enthält. Da ich nicht sicher war, ob ich tatsächlich alle Potenzen von 10 in case
Anweisungen 10
durch benötigen würde 18
, habe ich einige Mikrobenchmarks ausgeführt, in denen die Zeit für die switch
Ausführung von 10 Millionen Operationen mit dieser Anweisung mit einer switch
mit nur case
s 0
bis verglichen wurde 9
(wobei die exponent
auf 9 oder weniger begrenzt ist) Vermeiden Sie es, das reduzierte zu brechen switch
. Ich habe das ziemlich überraschende (zumindest für mich!) Ergebnis erhalten, dass das längere switch
mit mehr case
Aussagen tatsächlich schneller lief.
Aus Versehen habe ich versucht, noch mehr case
s hinzuzufügen, die nur Dummy-Werte zurückgegeben haben, und festgestellt, dass ich den Switch mit etwa 22 bis 27 deklarierten case
s noch schneller ausführen kann (obwohl diese Dummy-Fälle während der Ausführung des Codes nie tatsächlich getroffen werden ). (Wiederum wurden case
s in zusammenhängender Weise hinzugefügt, indem die vorherige case
Konstante um erhöht wurde 1
.) Diese Unterschiede in der Ausführungszeit sind nicht sehr signifikant: für eine zufällige exponent
Zwischen- 0
und10
die mit Dummy gepolsterte switch
Anweisung 10 Millionen Ausführungen in 1,49 Sekunden gegenüber 1,54 Sekunden für die ungepolsterte Version, für eine Gesamteinsparung von 5 ns pro Ausführung. Also nicht die Art von Dingen, die dazu führen, dass man besessen davon ist, aswitch
Aussage, die sich vom Standpunkt der Optimierung aus lohnt. Aber ich finde es immer noch merkwürdig und kontraintuitiv, dass a switch
nicht langsamer wird (oder bestenfalls die konstante O (1) -Zeit beibehält ), wenn mehr case
s hinzugefügt werden.
Dies sind die Ergebnisse, die ich beim Laufen mit verschiedenen Grenzen für die zufällig generierten exponent
Werte erhalten habe. Ich habe die Ergebnisse nicht bis 1
zum exponent
Limit berücksichtigt, aber die allgemeine Form der Kurve bleibt gleich, mit einem Kamm um die 12-17-Fallmarke und einem Tal zwischen 18-28. Alle Tests wurden in JUnitBenchmarks unter Verwendung gemeinsam genutzter Container für die Zufallswerte ausgeführt, um identische Testeingaben sicherzustellen. Ich habe die Tests auch sowohl in der Reihenfolge von der längsten switch
bis zur kürzesten Aussage als auch umgekehrt durchgeführt, um zu versuchen, die Möglichkeit von auftragsbezogenen Testproblemen auszuschließen. Ich habe meinen Testcode auf einem Github-Repo abgelegt, wenn jemand versuchen möchte, diese Ergebnisse zu reproduzieren.
Also, was ist hier los? Einige Unklarheiten meiner Architektur oder meiner Micro-Benchmark-Konstruktion? Oder ist Java switch
im Bereich 18
to wirklich etwas schneller auszuführen 28
case
als 11
bis 17
?
Github Test Repo "Switch-Experiment"
UPDATE: Ich habe die Benchmarking-Bibliothek ziemlich aufgeräumt und eine Textdatei in / results mit einigen Ausgaben über einen größeren Bereich möglicher exponent
Werte hinzugefügt . Ich habe auch eine Option im Testcode nicht zu werfen ein zusätzlicher Exception
aus default
, aber dies scheint nicht um die Ergebnisse zu beeinflussen.
UPDATE 2: Eine ziemlich gute Diskussion zu diesem Thema aus dem Jahr 2009 im xkcd-Forum finden Sie hier: http://forums.xkcd.com/viewtopic.php?f=11&t=33524 . Die Diskussion des OP über die Verwendung Array.binarySearch()
brachte mich auf die Idee einer einfachen Array-basierten Implementierung des oben genannten Exponentiationsmusters. Die binäre Suche ist nicht erforderlich, da ich die Einträge in der Liste kenne array
. Es scheint ungefähr dreimal schneller zu laufen als die Verwendung switch
, offensichtlich auf Kosten eines Teils des Kontrollflusses, der sich switch
ergibt. Dieser Code wurde auch dem Github-Repo hinzugefügt.
quelle
switch
Aussagen, da dies eindeutig die optimalste Lösung ist. : D (Zeigen Sie dies bitte nicht meiner Führung.)lookupswitch
zu a wechselntableswitch
. Das Zerlegen Ihres Codes mitjavap
würde Ihnen sicher zeigen.Antworten:
Wie in der anderen Antwort hervorgehoben , verwendet der für Ihre verschiedenen Tests generierte Bytecode eine Switch-Tabelle (Bytecode-Anweisung
tableswitch
) , da die Fallwerte zusammenhängend sind (im Gegensatz zu Sparse ).Sobald die JIT ihren Job startet und den Bytecode in Assembly kompiliert, führt die
tableswitch
Anweisung jedoch nicht immer zu einem Array von Zeigern: Manchmal wird die Switch-Tabelle in einelookupswitch
(ähnlich einerif
/else if
Struktur) aussehende Tabelle umgewandelt .Das Dekompilieren der von der JIT generierten Assembly (Hotspot JDK 1.7) zeigt, dass eine Folge von if / else verwendet wird, wenn 17 Fälle oder weniger vorhanden sind, ein Array von Zeigern, wenn mehr als 18 vorhanden sind (effizienter).
Der Grund, warum diese magische Zahl von 18 verwendet wird, scheint auf den Standardwert des
MinJumpTableSize
JVM-Flags (um Zeile 352 im Code) zurückzuführen zu sein.Ich habe das Problem auf der Hotspot-Compiler-Liste angesprochen und es scheint ein Erbe früherer Tests zu sein . Beachten Sie, dass dieser Standardwert in JDK 8 entfernt wurde, nachdem mehr Benchmarking durchgeführt wurde .
Wenn die Methode zu lang wird (> 25 Fälle in meinen Tests), werden die Standard-JVM-Einstellungen nicht mehr verwendet - dies ist die wahrscheinlichste Ursache für den Leistungsabfall zu diesem Zeitpunkt.
In 5 Fällen sieht der dekompilierte Code folgendermaßen aus (beachten Sie die Anweisungen cmp / je / jg / jmp, die Assembly für if / goto):
In 18 Fällen sieht die Assembly folgendermaßen aus (beachten Sie das verwendete Zeigerarray, das die Notwendigkeit aller Vergleiche unterdrückt:
jmp QWORD PTR [r8+r10*1]
Springt direkt zur richtigen Multiplikation) - das ist der wahrscheinliche Grund für die Leistungsverbesserung:Und schließlich ähnelt die Baugruppe mit 30 Fällen (unten) 18 Fällen, mit Ausnahme der zusätzlichen Fälle, die
movapd xmm0,xmm1
in der Mitte des Codes angezeigt werden, wie von @cHao festgestellt. Der wahrscheinlichste Grund für den Leistungsabfall ist jedoch, dass die Methode ebenfalls vorhanden ist lange, um mit den Standard-JVM-Einstellungen eingebunden zu werden:quelle
Switch-Case ist schneller, wenn die Case-Werte in einem engen Bereich liegen.
In diesem Fall kann der Compiler vermeiden, einen Vergleich für jeden Fallabschnitt in der switch-Anweisung durchzuführen. Der Compiler erstellt eine Sprungtabelle, die Adressen der Aktionen enthält, die auf verschiedenen Beinen ausgeführt werden sollen. Der Wert, für den der Wechsel ausgeführt wird, wird manipuliert, um ihn in einen Index in den zu konvertieren
jump table
. In dieser Implementierung ist die in der switch-Anweisung benötigte Zeit viel kürzer als die in einer äquivalenten if-else-if-Anweisungskaskade benötigte Zeit. Auch die in der switch-Anweisung benötigte Zeit ist unabhängig von der Anzahl der Fallbeine in der switch-Anweisung.Wie in Wikipedia über die switch-Anweisung im Abschnitt "Kompilierung" angegeben.
quelle
Die Antwort liegt im Bytecode:
SwitchTest10.java
Entsprechender Bytecode; nur relevante Teile gezeigt:
SwitchTest22.java:
Entsprechender Bytecode; Auch hier werden nur relevante Teile angezeigt:
Im ersten Fall verwendet der kompilierte Bytecode bei engen Bereichen a
tableswitch
. Im zweiten Fall verwendet der kompilierte Bytecode alookupswitch
.In
tableswitch
wird der ganzzahlige Wert oben im Stapel verwendet, um in die Tabelle zu indizieren und das Verzweigungs- / Sprungziel zu finden. Dieser Sprung / Zweig wird dann sofort ausgeführt. Daher ist dies eineO(1)
Operation.A
lookupswitch
ist komplizierter. In diesem Fall muss der ganzzahlige Wert mit allen Schlüsseln in der Tabelle verglichen werden, bis der richtige Schlüssel gefunden wurde. Nachdem der Schlüssel gefunden wurde, wird das Verzweigungs- / Sprungziel (dem dieser Schlüssel zugeordnet ist) für den Sprung verwendet. Die Tabelle, in der verwendet wird,lookupswitch
ist sortiert und ein binärer Suchalgorithmus kann verwendet werden, um den richtigen Schlüssel zu finden. Leistung für eine binäre Suche istO(log n)
, und der gesamte Prozess ist auchO(log n)
, weil der Sprung noch istO(1)
. Der Grund für die geringere Leistung bei Bereichen mit geringer Dichte ist, dass zuerst der richtige Schlüssel gesucht werden muss, da Sie nicht direkt in die Tabelle indizieren können.Wenn es spärliche Werte gibt und Sie nur einen
tableswitch
verwenden müssen, enthält die Tabelle im Wesentlichen Dummy-Einträge, die auf diedefault
Option verweisen . Angenommen, der letzte Eintrag inSwitchTest10.java
war21
anstelle von10
, erhalten Sie:Der Compiler erstellt also im Grunde genommen diese riesige Tabelle mit Dummy-Einträgen zwischen den Lücken, die auf das Verzweigungsziel der
default
Anweisung zeigen. Auch wenn es keine gibtdefault
, enthält es Einträge, die auf die Anweisung nach dem Schalterblock verweisen . Ich habe einige grundlegende Tests durchgeführt und festgestellt, dass, wenn die Lücke zwischen dem letzten Index und dem vorherigen (9
) größer als ist35
, alookupswitch
anstelle von a verwendet wirdtableswitch
.Das Verhalten der
switch
Anweisung ist in der Java Virtual Machine Specification (§3.10) definiert :quelle
lookupswitch
?Da die Frage bereits beantwortet ist (mehr oder weniger), hier ein Tipp. Verwenden
Dieser Code verwendet deutlich weniger IC (Anweisungscache) und wird immer inline gesetzt. Das Array befindet sich im L1-Datencache, wenn der Code heiß ist. Die Nachschlagetabelle ist fast immer ein Gewinn. (insbesondere auf Mikrobenchmarks: D)
Bearbeiten: Wenn Sie möchten, dass die Methode Hot-Inlined ist, sollten Sie die nicht schnellen Pfade
throw new ParseException()
als so kurz wie möglich betrachten oder sie in eine separate statische Methode verschieben (wodurch sie so kurz wie möglich sind). Das istthrow new ParseException("Unhandled power of ten " + power, 0);
eine schwache Idee, da sie einen Großteil des Inlining-Budgets für Code verbraucht, der nur interpretiert werden kann - die Verkettung von Zeichenfolgen ist im Bytecode ziemlich ausführlich. Weitere Infos und ein realer Fall mit ArrayListquelle