Warum bekomme ich keine Leistungsverbesserung mit get_unchecked ()?

9

Ich habe versucht, get_unchecked() anstelle des [] -Indexoperators zu verwenden, um eine Leistungsverbesserung in der s() Funktion meiner des crate.

Es führt jedoch nicht zu einer sichtbaren Leistungsverbesserung, auch wenn die Funktion [] (oder get_unchecked() ) in meinem Benchmark 4,8 Milliarden Mal aufgerufen wird. Ich hätte gedacht, dass das Anrufen von get_unchecked() 4,8 Milliarden anstatt von [] zu einer Verbesserung von 2 Sekunden Zeit auf meinem Intel Core 2 Duo 2,4 GHz Prozessor führt.

Ich habe diesen kleinen Benchmark gemacht, um Ihnen einen kleinen Code zu zeigen:

%Vor%

Als ich diesen Benchmark zum ersten Mal mit [] durchführte, dauerte es weniger Zeit als die Version mit get_unchecked() (obwohl die get_unchecked() -Version im Durchschnitt ein wenig schneller scheint). Ich bin mir nicht sicher, ob es wirklich meinen realen Benchmark widerspiegelt (der darin besteht, eine große Datei zu verschlüsseln), aber es gibt eine Idee.

Ich habe die Assembly überprüft, um sicher zu sein, dass der Compiler die gebundene Überprüfung nicht optimiert hat.

Hier ist die Version mit get_unchecked() :

%Vor%

Und hier ist die Version mit [] :

%Vor%

Wir können sehen, dass die get_unchecked() Version kleiner ist und dass die Version mit [] Grenzen überprüft.

Beide Versionen werden im Freigabemodus kompiliert.

Warum ist die get_unchecked() version nicht schneller? Ich denke, es sollte mindestens ein paar Sekunden schneller als die [] Version sein, wenn get_unchecked() / [] 4,8 Milliarden mal aufgerufen wird.

Bearbeiten: Ich habe den Code mit valgrind profiliert.

Die Version mit [] zeigt für die Zeile mit der Array-Indexierung Kosten von 10 an, während die Version mit get_unchecked() weniger als 1 kostet (siehe Bilder unten). Die Kosten der Funktion (siehe links auf den Bildern) blieben jedoch gleich. Das ist merkwürdig. Hat jemand eine Erklärung?

Version mit get_unchecked() .

Version mit []

    
antoyo 28.08.2016, 23:08
quelle

1 Antwort

3

Ich habe Rust (noch) nicht viel gelernt, aber ich denke, ich kann den Performance-Teil noch beantworten.

Sind Sie sicher, dass Ihr Benchmark tatsächlich die Standalone-Version der Funktion ausführt? Es kann die Funktion in die Aufruf-Site einbinden, wobei box_id eine Kompilierzeitkonstante ist. Neben dem Entfernen des Nebenaufrufs / ret Overhead würde die Tabellenindexberechnung asm erheblich vereinfacht werden. Die Überprüfung der Grenzen kann auch weggelassen werden, wenn zur Kompilierungszeit bekannt ist, dass sie nicht überschritten werden.

Wenn jemand zeigt, wie man das Beispiel des OPs ändert, um es zu einer tatsächlichen asm zu kompilieren auf dem Godbolt compiler explorer würde ich in der lage sein, einen blick zu werfen und mehr zu sagen. Wenn wir es auf godbolt aufstellen, gibt es etwas, das sich zu leerem asm ausgibt. Ich könnte mich entscheiden, genug Rust zu lernen, um das selbst zu machen, aber wahrscheinlich nicht bald.

Schauen Sie sich die Standalone-Version der Funktion an:

Die zusätzlichen Anweisungen in der geprüften Version sind nur die ersten 4:

%Vor%

Und das letzte pop %rcx , um 8% zu addieren.

Das sind 4 Ups am Anfang der Funktion. (Core2Duo kann nicht im 64-Bit-Modus fusionieren. Core2Duo kann jedoch cmp / ja im 32-Bit-Code fusionieren. (Siehe Agner Fogs microarch pdf ) Wenn der Compiler schlauer wäre, wären es nur zwei zusätzliche Befehle / ups total auf dem schnellen Pfad durch die Funktion (cmp / ja), wobei die Stapelausrichtung für einen anderen Funktionsaufruf nur in der Zweig, der tatsächlich den Anruf macht.

Man könnte denken, dass das Problem mit diesen vier Fehlern als erste Gruppe der Funktion ein Problem wäre, weil es die CPU daran hindern würde, die Anweisungen auf dem kritischen Pfad zu bekommen. Aber das ist nicht der Fall, weil Ihr Code offenbar nicht zu Engpässen im Frontend führt. (Sie haben das Asm für den Code, der das in einer Schleife aufruft, nicht angezeigt). Daher werden Befehle in den Out-of-Order-Core vor den tatsächlich ausgeführten Anweisungen ausgegeben.

Wahrscheinlich hat der Scheduler bereits zu dem Zeitpunkt, zu dem die Funktionseingabe in% rdi bereit ist, die Anweisungen für den kritischen Pfad darauf gewartet. Wenn der Benchmark tatsächlich die Standalone-Version ausführt, verzögern die 4 zusätzlichen Anweisungen am Anfang der Funktion den kritischen Pfad überhaupt nicht. Vermutlich ist der kritische Pfad durch die Latenz und nicht durch den Durchsatz eingeschränkt. (Ist die Ausgabe eines Aufrufs der Index für die nächste Suche? Wenn ja, würde das verhindern, dass mehrere Aufrufe der Funktion zur gleichen Zeit für verschiedene Eingaben ausgeführt werden.) L1 Lade-Nutzungs-Latenz ist 3 Zyklen auf Kern 2 (gemäß Agner Fogs Mikroarchiv pdf ).

In den Anweisungen, die den Tabellenindex aus der Eingabe berechnen, gibt es jedoch eine gewisse Parallelität auf Befehlsebene. Es gibt ein paar mov Anweisungen, die eine Kopie erstellen, und dann machen die Anweisungen verschiedene Dinge für die Kopie und das Original. Und die beiden Argumente sind bereits getrennt.Ich denke, es gibt wahrscheinlich genug Parallelität, um 3 Anweisungen parallel zu betreiben, zwischen dem Zeitpunkt, wenn die Eingabe bereit ist und wenn der Tabellenindex bereit ist. Wenn also die Ausführung für eine Tabellensuche 3 Zyklen warten muss, bevor eine andere Eingabe erfolgt, ist dies Zeit für die zusätzlichen UPs. (Der Scheduler führt ups auf der Basis "first-ready" aus, allerdings nicht "critical-path-first". Sie erwarten daher manchmal eine Verlängerung des kritischen Pfads mit Ressourcenkonflikten (Stehlen der Ausführungsports vom kritischen Pfad).

TL: DR Die L1-Last-Verwendungs-Latenz könnte immer noch der Engpass sein, nicht der UOP-Durchsatz, wenn die Ausgabe eines Anrufs die Eingabe für den nächsten ist. Andernfalls muss es einen anderen Engpass geben, der die 5 zusätzlichen Ups Zeit zum Laufen gibt, ohne die "echte Arbeit" zu verzögern. Sonst wird die Überprüfung in dem Code, der tatsächlich ausgeführt wird, optimiert.

    
Peter Cordes 30.08.2016 10:43
quelle