C ++ - Code zum schnelleren Testen der Collatz-Vermutung als handgeschriebene Assemblierung - warum?

833

Ich habe diese beiden Lösungen für Project Euler Q14 in Assembly und in C ++ geschrieben. Sie sind der gleiche identische Brute-Force-Ansatz zum Testen der Collatz-Vermutung . Die Montagelösung wurde mit zusammengebaut

nasm -felf64 p14.asm && gcc p14.o -o p14

Das C ++ wurde mit kompiliert

g++ p14.cpp -o p14

Versammlung, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Ich kenne die Compiler-Optimierungen zur Verbesserung der Geschwindigkeit und alles, aber ich sehe nicht viele Möglichkeiten, meine Assembly-Lösung weiter zu optimieren (programmatisch nicht mathematisch gesprochen).

Der C ++ - Code hat einen Modul für jeden Term und eine Division für jeden geraden Term, wobei die Assembly nur eine Division pro geraden Term ist.

Die Assembly dauert jedoch durchschnittlich 1 Sekunde länger als die C ++ - Lösung. Warum ist das? Ich frage hauptsächlich aus Neugier.

Ausführungszeiten

Mein System: 64-Bit-Linux auf 1,4 GHz Intel Celeron 2955U (Haswell-Mikroarchitektur).

jeffer sohn
quelle
232
Haben Sie den Assembler-Code untersucht, den GCC für Ihr C ++ - Programm generiert?
Ruakh
69
Kompilieren Sie mit -S, um die vom Compiler generierte Assembly abzurufen. Der Compiler ist intelligent genug, um zu erkennen, dass der Modul gleichzeitig die Division durchführt.
user3386109
267
Ich denke, Ihre Optionen sind 1. Ihre Messtechnik ist fehlerhaft, 2. Der Compiler schreibt eine bessere Assemblierung als Sie oder 3. Der Compiler verwendet Magie.
Galik
18
@ jefferson Der Compiler kann schnellere Brute Force anwenden. Zum Beispiel vielleicht mit SSE-Anweisungen.
user253751

Antworten:

1896

Wenn Sie der Meinung sind, dass ein 64-Bit-DIV-Befehl eine gute Möglichkeit ist, durch zwei zu teilen, ist es kein Wunder, dass die ASM-Ausgabe des Compilers Ihren handgeschriebenen Code übertrifft, selbst mit -O0(schnell kompilieren, keine zusätzliche Optimierung und Speichern / Neuladen in den Speicher nach / vor jeder C-Anweisung, damit ein Debugger Variablen ändern kann).

In Agner Fogs Handbuch zur Optimierung der Baugruppe erfahren Sie, wie Sie effizientes asm schreiben. Er hat auch Anweisungstabellen und eine Mikroarchivanleitung für spezifische Details für bestimmte CPUs. Siehe auch die Tag Wiki für mehr Perf Links.

Siehe auch diese allgemeinere Frage zum Schlagen des Compilers mit handgeschriebenem asm: Ist die Inline-Assemblersprache langsamer als nativer C ++ - Code? . TL: DR: Ja, wenn Sie es falsch machen (wie diese Frage).

Normalerweise ist es in Ordnung, den Compiler seine Sache machen zu lassen, besonders wenn Sie versuchen, C ++ zu schreiben, das effizient kompiliert werden kann . Sehen Sie auch, ist Assemblierung schneller als kompilierte Sprachen? . Eine der Antworten enthält Links zu diesen übersichtlichen Folien, die zeigen, wie verschiedene C-Compiler einige wirklich einfache Funktionen mit coolen Tricks optimieren. Matt Godbolts CppCon2017-Vortrag „ Was hat mein Compiler in letzter Zeit für mich getan? Das Lösen des Compilerdeckels “ist ähnlich.


even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

Bei Intel Haswell sind div r64es 36 Uops mit einer Latenz von 32-96 Zyklen und einem Durchsatz von einem pro 21-74 Zyklen. (Plus die 2 Uops, um RBX und Null-RDX einzurichten, aber die Ausführung außerhalb der Reihenfolge kann diese früh ausführen). High-Uop-Count-Anweisungen wie DIV sind mikrocodiert, was auch zu Front-End-Engpässen führen kann. In diesem Fall ist die Latenz der wichtigste Faktor, da sie Teil einer durch Schleifen übertragenen Abhängigkeitskette ist.

shr rax, 1macht die gleiche vorzeichenlose Division: Es ist 1 uop mit 1c Latenz und kann 2 pro Taktzyklus ausführen.

Zum Vergleich: Die 32-Bit-Division ist schneller, aber im Vergleich zu Verschiebungen immer noch schrecklich. idiv r32beträgt 9 Uops, 22-29c Latenz und einen pro 8-11c Durchsatz bei Haswell.


Wie Sie aus der -O0asm-Ausgabe von gcc ( Godbolt-Compiler-Explorer ) ersehen können , werden nur Verschiebungsanweisungen verwendet . clang -O0kompiliert naiv, wie Sie gedacht haben, selbst wenn Sie 64-Bit-IDIV zweimal verwenden. (Bei der Optimierung verwenden Compiler beide IDIV-Ausgänge, wenn die Quelle eine Division und einen Modul mit denselben Operanden ausführt, wenn sie überhaupt IDIV verwenden.)

GCC hat keinen völlig naiven Modus. Es wird immer durch GIMPLE transformiert, was bedeutet, dass einige "Optimierungen" nicht deaktiviert werden können . Dies beinhaltet das Erkennen der Division durch Konstante und das Verwenden von Verschiebungen (Potenz von 2) oder einer multiplikativen Festkomma-Inverse (Nicht-Potenz von 2), um IDIV zu vermeiden (siehe div_by_13im obigen Godbolt-Link).

gcc -Os(Optimale Größe) macht Gebrauch IDIV für Nicht-Power-of-2 - Abteilung, leider auch in Fällen , in denen der multiplikative Inverse - Code ist nur etwas größer , aber viel schneller.


Hilfe für den Compiler

(Zusammenfassung für diesen Fall: Verwendung uint64_t n)

Zunächst ist es nur interessant, die optimierte Compilerausgabe zu betrachten. ( -O3). -O0Geschwindigkeit ist grundsätzlich bedeutungslos.

Sehen Sie sich Ihre ASM-Ausgabe an (auf Godbolt oder sehen Sie, wie Sie "Rauschen" von der Ausgabe der GCC / Clang-Baugruppe entfernen? ). Wenn der Compiler überhaupt keinen optimalen Code erstellt: Das Schreiben Ihrer C / C ++ - Quelle auf eine Weise, die den Compiler dazu führt, besseren Code zu erstellen, ist normalerweise der beste Ansatz . Sie müssen asm kennen und wissen, was effizient ist, aber Sie wenden dieses Wissen indirekt an. Compiler sind auch eine gute Quelle für Ideen: Manchmal macht Clang etwas Cooles, und Sie können gcc dazu bringen, dasselbe zu tun: Sehen Sie sich diese Antwort an und was ich mit der nicht abgewickelten Schleife in @ Veedracs Code unten gemacht habe.)

Dieser Ansatz ist portabel, und in 20 Jahren kann ein zukünftiger Compiler ihn zu allem kompilieren, was auf zukünftiger Hardware (x86 oder nicht) effizient ist, möglicherweise mithilfe einer neuen ISA-Erweiterung oder einer automatischen Vektorisierung. Handgeschriebene x86-64 asm von vor 15 Jahren wären normalerweise nicht optimal auf Skylake abgestimmt. zB Vergleich & Verzweigung Makro-Fusion gab es damals noch nicht. Was jetzt für handgefertigte asm für eine Mikroarchitektur optimal ist, ist für andere aktuelle und zukünftige CPUs möglicherweise nicht optimal. In den Kommentaren zu @ johnfounds Antwort werden wichtige Unterschiede zwischen AMD Bulldozer und Intel Haswell erörtert , die einen großen Einfluss auf diesen Code haben. Aber theoretisch g++ -O3 -march=bdver3und g++ -O3 -march=skylakewird das Richtige tun. (Or -march=native.) Oder -mtune=...um einfach zu optimieren, ohne Anweisungen zu verwenden, die andere CPUs möglicherweise nicht unterstützen.

Meiner Meinung nach sollte es für zukünftige Compiler kein Problem sein, den Compiler zu einem ASM zu führen, der für eine aktuelle CPU, die Ihnen wichtig ist, gut ist. Sie sind hoffentlich besser als aktuelle Compiler darin, Wege zur Transformation von Code zu finden, und können einen Weg finden, der für zukünftige CPUs funktioniert. Unabhängig davon wird zukünftiges x86 bei nichts, was auf aktuellem x86 gut ist, wahrscheinlich schrecklich sein, und der zukünftige Compiler wird asm-spezifische Fallstricke vermeiden, während er so etwas wie die Datenbewegung von Ihrer C-Quelle implementiert, wenn er nichts Besseres sieht.

Handgeschriebener ASM ist eine Blackbox für den Optimierer, sodass die Konstantenausbreitung nicht funktioniert, wenn Inlining eine Eingabe zu einer Konstante für die Kompilierungszeit macht. Andere Optimierungen sind ebenfalls betroffen. Lesen Sie https://gcc.gnu.org/wiki/DontUseInlineAsm, bevor Sie asm verwenden. (Und vermeiden Sie Inline-Asm im MSVC-Stil: Ein- / Ausgänge müssen durch den Speicher gehen, was den Overhead erhöht .)

In diesem Fall : Ihr nhat einen vorzeichenbehafteten Typ, und gcc verwendet die SAR / SHR / ADD-Sequenz, die die richtige Rundung ergibt. (IDIV und Arithmetikverschiebung "rund" für negative Eingaben unterschiedlich, siehe den manuellen Eintrag SAR insn set ref ). (IDK, wenn gcc versucht hat und nicht beweisen konnte, dass ndies nicht negativ sein kann, oder was. Signed-Overflow ist ein undefiniertes Verhalten, daher hätte es möglich sein müssen.)

Sie sollten verwendet haben uint64_t n, damit es nur SHR kann. Und so ist es auf Systeme portierbar, auf denen longnur 32-Bit verfügbar ist (z. B. x86-64 Windows).


Übrigens, die optimierte ASM-Ausgabe von gcc sieht ziemlich gut aus (mit unsigned long n) : Die innere Schleife, in die sie inline ist, main()macht dies:

 # from gcc5.4 -O3  plus my comments

 # edx= count=1
 # rax= uint64_t n

.L9:                   # do{
    lea    rcx, [rax+1+rax*2]   # rcx = 3*n + 1
    mov    rdi, rax
    shr    rdi         # rdi = n>>1;
    test   al, 1       # set flags based on n%2 (aka n&1)
    mov    rax, rcx
    cmove  rax, rdi    # n= (n%2) ? 3*n+1 : n/2;
    add    edx, 1      # ++count;
    cmp    rax, 1
    jne   .L9          #}while(n!=1)

  cmp/branch to update max and maxi, and then do the next n

Die innere Schleife ist verzweigungslos, und der kritische Pfad der schleifengetragenen Abhängigkeitskette lautet:

  • 3-Komponenten-LEA (3 Zyklen)
  • cmov (2 Zyklen bei Haswell, 1c bei Broadwell oder später).

Gesamt: 5 Zyklen pro Iteration, Latenzzeitengpass . Die Ausführung außerhalb der Reihenfolge kümmert sich parallel dazu um alles andere (theoretisch: Ich habe nicht mit Perf-Zählern getestet, um festzustellen, ob es wirklich mit 5 c / iter läuft).

Der FLAGS-Eingang von cmov(von TEST erzeugt) ist schneller zu erzeugen als der RAX-Eingang (von LEA-> MOV), befindet sich also nicht auf dem kritischen Pfad.

In ähnlicher Weise befindet sich der MOV-> SHR, der den RDI-Eingang des CMOV erzeugt, außerhalb des kritischen Pfads, da er auch schneller als der LEA ist. MOV auf IvyBridge und höher hat keine Latenz (wird beim Umbenennen des Registers behandelt). (Es braucht immer noch ein UOP und einen Slot in der Pipeline, also ist es nicht frei, nur keine Latenz). Der zusätzliche MOV in der LEA-Dep-Kette ist Teil des Engpasses bei anderen CPUs.

Das cmp / jne ist auch nicht Teil des kritischen Pfads: Es wird nicht in einer Schleife übertragen, da Steuerungsabhängigkeiten im Gegensatz zu Datenabhängigkeiten auf dem kritischen Pfad mit Verzweigungsvorhersage + spekulativer Ausführung behandelt werden.


Den Compiler schlagen

GCC hat hier ziemlich gute Arbeit geleistet. Es könnte ein Codebyte speichern, indem es inc edxanstelle von verwendet wirdadd edx, 1 , da sich niemand um P4 und seine falschen Abhängigkeiten für Anweisungen zum Ändern von Teilflags kümmert.

Es könnten auch alle MOV-Anweisungen gespeichert werden, und TEST: SHR setzt CF = das herausgeschobene Bit, sodass wir cmovcanstelle von test/ verwenden können cmovz.

 ### Hand-optimized version of what gcc does
.L9:                       #do{
    lea     rcx, [rax+1+rax*2] # rcx = 3*n + 1
    shr     rax, 1         # n>>=1;    CF = n&1 = n%2
    cmovc   rax, rcx       # n= (n&1) ? 3*n+1 : n/2;
    inc     edx            # ++count;
    cmp     rax, 1
    jne     .L9            #}while(n!=1)

Siehe @ johnfounds Antwort für einen weiteren cleveren Trick: Entfernen Sie das CMP, indem Sie das SHR-Flag-Ergebnis verzweigen und es für CMOV: Null verwenden, nur wenn n zu Beginn 1 (oder 0) war. (Unterhaltsame Tatsache: SHR mit count! = 1 bei Nehalem oder früher führt zu einem Stillstand, wenn Sie die Flag-Ergebnisse lesen .

Das Vermeiden von MOV hilft bei der Latenz bei Haswell überhaupt nicht ( Kann der MOV von x86 wirklich "kostenlos" sein? Warum kann ich das überhaupt nicht reproduzieren? ). Es hilft erheblich bei CPUs wie Intel Pre-IvB und der AMD Bulldozer-Familie, bei denen MOV keine Latenz von Null aufweist. Die verschwendeten MOV-Anweisungen des Compilers wirken sich auf den kritischen Pfad aus. Die komplexe LEA und CMOV von BD weisen beide eine geringere Latenz auf (2c bzw. 1c), sodass sie einen größeren Teil der Latenz ausmacht. Durchsatzengpässe werden ebenfalls zu einem Problem, da nur zwei ganzzahlige ALU-Pipes vorhanden sind. Siehe @ johnfounds Antwort , in der er Timing-Ergebnisse von einer AMD-CPU hat.

Selbst auf Haswell kann diese Version ein wenig helfen, indem sie gelegentliche Verzögerungen vermeidet, bei denen ein unkritischer UOP einen Ausführungsport von einem auf dem kritischen Pfad stiehlt und die Ausführung um 1 Zyklus verzögert. (Dies wird als Ressourcenkonflikt bezeichnet.) Außerdem wird ein Register gespeichert, was hilfreich sein kann, wenn mehrere nWerte in einer verschachtelten Schleife parallel ausgeführt werden (siehe unten).

Die Latenz von LEA hängt vom Adressierungsmodus der CPUs der Intel SnB-Familie ab. 3c für 3 Komponenten (für [base+idx+const]die zwei separate Adds erforderlich sind), aber nur 1c für 2 oder weniger Komponenten (eine Add). Einige CPUs (wie Core2) führen sogar eine 3-Komponenten-LEA in einem einzigen Zyklus durch, die SnB-Familie jedoch nicht. Schlimmer noch, die Intel SnB-Familie standardisiert Latenzen, sodass es keine 2c-Uops gibt , andernfalls wäre 3-Komponenten-LEA nur 2c wie Bulldozer. (3-Komponenten-LEA ist auch bei AMD langsamer, nur nicht so viel).

So lea rcx, [rax + rax*2]/ inc rcxist nur 2c Latenz, schneller als lea rcx, [rax + rax*2 + 1]auf Intel SnB-Familie CPUs wie Haswell. Break-Even bei BD und noch schlimmer bei Core2. Es kostet einen zusätzlichen UOP, was sich normalerweise nicht lohnt, um 1c Latenz zu sparen, aber die Latenz ist hier der größte Engpass, und Haswell verfügt über eine ausreichend breite Pipeline, um den zusätzlichen UOP-Durchsatz zu bewältigen.

Weder gcc, icc noch clang (auf godbolt) verwendeten die CF-Ausgabe von SHR, immer mit einem UND oder TEST . Dumme Compiler. : P Sie sind großartige Teile komplexer Maschinen, aber ein kluger Mensch kann sie oft bei kleinen Problemen schlagen. (Natürlich Tausende bis Millionen Mal länger, um darüber nachzudenken! Compiler verwenden keine erschöpfenden Algorithmen, um nach allen möglichen Methoden zu suchen, da dies zu lange dauern würde, wenn viel Inline-Code optimiert wird Sie modellieren die Pipeline auch nicht in der Zielmikroarchitektur, zumindest nicht im gleichen Detail wie IACA oder andere statische Analysewerkzeuge. Sie verwenden lediglich einige Heuristiken.)


Ein einfaches Abrollen der Schleife hilft nicht weiter . Diese Schleifenengpässe wirken sich auf die Latenz einer von Schleifen übertragenen Abhängigkeitskette aus, nicht auf den Schleifen-Overhead / Durchsatz. Dies bedeutet, dass es gut für Hyperthreading (oder jede andere Art von SMT) geeignet ist, da die CPU viel Zeit hat, um Anweisungen von zwei Threads zu verschachteln. Dies würde bedeuten main, dass die Schleife parallelisiert wird , aber das ist in Ordnung, da jeder Thread nur einen Wertebereich überprüfen nund als Ergebnis ein Paar von Ganzzahlen erzeugen kann.

Das Verschachteln von Hand innerhalb eines einzelnen Threads kann ebenfalls sinnvoll sein . Berechnen Sie möglicherweise die Sequenz für ein Zahlenpaar parallel, da jedes nur ein paar Register benötigt und alle das gleiche max/ aktualisieren können maxi. Dies schafft mehr Parallelität auf Befehlsebene .

Der Trick besteht darin, zu entscheiden, ob Sie warten sollen, bis alle nWerte erreicht sind, 1bevor Sie ein weiteres Paar von nStartwerten erhalten, oder ob Sie ausbrechen und einen neuen Startpunkt für nur einen erhalten, der die Endbedingung erreicht hat, ohne die Register für die andere Sequenz zu berühren. Wahrscheinlich ist es am besten, jede Kette an nützlichen Daten zu arbeiten, sonst müssten Sie ihren Zähler bedingt erhöhen.


Sie könnten dies vielleicht sogar mit SSE-gepackten Vergleichsdaten tun, um den Zähler für Vektorelemente, die nnoch nicht erreicht wurden , bedingt zu erhöhen 1. Und um die noch längere Latenz einer SIMD-Implementierung mit bedingtem Inkrement zu verbergen, müssten Sie mehr Wertevektoren nin der Luft halten. Vielleicht nur mit 256b Vektor (4x uint64_t) wert .

Ich denke, die beste Strategie, um ein 1"klebriges" zu erkennen, besteht darin, den Vektor aller Einsen zu maskieren, die Sie hinzufügen, um den Zähler zu erhöhen. Nachdem Sie ein 1in einem Element gesehen haben, hat der Inkrement-Vektor eine Null und + = 0 ist ein No-Op.

Ungetestete Idee zur manuellen Vektorisierung

# starting with YMM0 = [ n_d, n_c, n_b, n_a ]  (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1):  increment vector
# ymm5 = all-zeros:  count vector

.inner_loop:
    vpaddq    ymm1, ymm0, xmm0
    vpaddq    ymm1, ymm1, xmm0
    vpaddq    ymm1, ymm1, set1_epi64(1)     # ymm1= 3*n + 1.  Maybe could do this more efficiently?

    vprllq    ymm3, ymm0, 63                # shift bit 1 to the sign bit

    vpsrlq    ymm0, ymm0, 1                 # n /= 2

    # FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
    vpblendvpd ymm0, ymm0, ymm1, ymm3       # variable blend controlled by the sign bit of each 64-bit element.  I might have the source operands backwards, I always have to look this up.

    # ymm0 = updated n  in each element.

    vpcmpeqq ymm1, ymm0, set1_epi64(1)
    vpandn   ymm4, ymm1, ymm4         # zero out elements of ymm4 where the compare was true

    vpaddq   ymm5, ymm5, ymm4         # count++ in elements where n has never been == 1

    vptest   ymm4, ymm4
    jnz  .inner_loop
    # Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero

    vextracti128 ymm0, ymm5, 1
    vpmaxq .... crap this doesn't exist
    # Actually just delay doing a horizontal max until the very very end.  But you need some way to record max and maxi.

Sie können und sollten dies mit Intrinsics anstelle von handgeschriebenem ASM implementieren.


Verbesserung des Algorithmus / der Implementierung:

Suchen Sie nicht nur nach der Implementierung derselben Logik mit effizienterem asm, sondern auch nach Möglichkeiten, die Logik zu vereinfachen oder redundante Arbeiten zu vermeiden. zB merken, um gemeinsame Endungen von Sequenzen zu erkennen. Oder noch besser, schauen Sie sich 8 nachfolgende Bits gleichzeitig an (Gnashers Antwort)

@EOF weist darauf hin, dass tzcnt(oder bsf) verwendet werden können, um mehrere n/=2Iterationen in einem Schritt durchzuführen . Das ist wahrscheinlich besser als SIMD-Vektorisierung. Das kann kein SSE- oder AVX-Befehl. Es ist jedoch immer noch kompatibel mit der nparallelen Ausführung mehrerer Skalare in verschiedenen Ganzzahlregistern.

Die Schleife könnte also so aussehen:

goto loop_entry;  // C++ structured like the asm, for illustration only
do {
   n = n*3 + 1;
  loop_entry:
   shift = _tzcnt_u64(n);
   n >>= shift;
   count += shift;
} while(n != 1);

Dies führt möglicherweise zu erheblich weniger Iterationen, aber bei CPUs der Intel SnB-Familie ohne BMI2 sind Verschiebungen mit variabler Anzahl langsam. 3 Uops, 2c Latenz. (Sie haben eine Eingabeabhängigkeit von den FLAGS, da count = 0 bedeutet, dass die Flags unverändert sind. Sie behandeln dies als Datenabhängigkeit und nehmen mehrere Uops, da ein UOP nur 2 Eingänge haben kann (ohnehin vor HSW / BDW).) Dies ist die Art, auf die sich Leute beziehen, die sich über das verrückte CISC-Design von x86 beschweren. Dadurch werden x86-CPUs langsamer als wenn der ISA heute von Grund auf neu entwickelt würde, auch wenn dies größtenteils ähnlich ist. (dh dies ist Teil der "x86-Steuer", die Geschwindigkeit / Leistung kostet.) SHRX / SHLX / SARX (BMI2) sind ein großer Gewinn (1 uop / 1c Latenz).

Außerdem wird tzcnt (3c in Haswell und höher) auf den kritischen Pfad gesetzt, sodass die Gesamtlatenz der schleifengetragenen Abhängigkeitskette erheblich verlängert wird. Es ist jedoch keine CMOV oder Vorbereitung eines Registerbestands erforderlich n>>1. Die Antwort von @ Veedrac überwindet all dies, indem die tzcnt / shift für mehrere Iterationen verschoben wird, was sehr effektiv ist (siehe unten).

Wir können BSF oder TZCNT sicher austauschbar verwenden, da nes zu diesem Zeitpunkt niemals Null sein kann. Der Maschinencode von TZCNT wird auf CPUs, die BMI1 nicht unterstützen, als BSF dekodiert. (Bedeutungslose Präfixe werden ignoriert, daher wird REP BSF als BSF ausgeführt.)

TZCNT bietet auf AMD-CPUs, die es unterstützen, eine viel bessere Leistung als BSF. Daher kann es eine gute Idee sein, es zu verwenden REP BSF, auch wenn Sie ZF nicht einstellen möchten, wenn der Eingang Null und nicht der Ausgang ist. Einige Compiler tun dies, wenn Sie __builtin_ctzllsogar mit verwenden -mno-bmi.

Sie arbeiten auf Intel-CPUs gleich, speichern Sie also nur das Byte, wenn das alles ist, was zählt. TZCNT unter Intel (vor Skylake) ist wie BSF immer noch falsch vom angeblich schreibgeschützten Ausgabeoperanden abhängig, um das undokumentierte Verhalten zu unterstützen, dass BSF mit input = 0 sein Ziel unverändert lässt. Sie müssen das also umgehen, es sei denn, Sie optimieren nur für Skylake. Das zusätzliche REP-Byte bietet also nichts. (Intel geht oft über das hinaus, was das x86 ISA-Handbuch verlangt, um zu vermeiden, dass weit verbreiteter Code beschädigt wird, der von etwas abhängt, das es nicht sollte, oder das rückwirkend nicht zulässig ist. Beispielsweise geht Windows 9x nicht davon aus, dass TLB-Einträge spekulativ vorab abgerufen werden , was sicher war als der Code geschrieben wurde, bevor Intel die TLB-Verwaltungsregeln aktualisierte .)

Wie auch immer, LZCNT / TZCNT auf Haswell haben die gleiche falsche Dep wie POPCNT: siehe diese Fragen und Antworten . Aus diesem Grund sehen Sie in der asm-Ausgabe von gcc für den Code von @ Veedrac, dass die dep- Kette durch xor-zeroing in dem Register unterbrochen wird, das als Ziel von TZCNT verwendet werden soll, wenn dst = src nicht verwendet wird. Da TZCNT / LZCNT / POPCNT ihr Ziel niemals undefiniert oder unverändert lassen, ist diese falsche Abhängigkeit von der Ausgabe auf Intel-CPUs ein Leistungsfehler / eine Leistungsbeschränkung. Vermutlich ist es einige Transistoren / Leistung wert, wenn sie sich wie andere Uops verhalten, die zur gleichen Ausführungseinheit gehen. Der einzige Vorteil ist die Interaktion mit einer anderen Uarch-Einschränkung: Sie können einen Speicheroperanden mit einem indizierten Adressierungsmodus mikroverschmelzen auf Haswell, aber auf Skylake, wo Intel die falsche Dep für LZCNT / TZCNT entfernt hat, "laminieren" sie indizierte Adressierungsmodi, während POPCNT weiterhin jeden Adr-Modus mikroverschmelzen kann.


Verbesserungen an Ideen / Code aus anderen Antworten:

Die Antwort von @ hidefromkgb hat eine nette Beobachtung, dass Sie nach 3n + 1 garantiert eine Rechtsschicht machen können. Sie können dies noch effizienter berechnen, als nur die Überprüfungen zwischen den Schritten wegzulassen. Die asm-Implementierung in dieser Antwort ist jedoch fehlerhaft (dies hängt von OF ab, das nach SHRD mit einer Anzahl> 1 undefiniert ist) und langsam: ROR rdi,2ist schneller als SHRD rdi,rdi,2und die Verwendung von zwei CMOV-Anweisungen auf dem kritischen Pfad ist langsamer als ein zusätzlicher TEST das kann parallel laufen.

Ich habe aufgeräumtes / verbessertes C (das den Compiler dazu anleitet, besseres asm zu erzeugen) und Godbolt getestet + schnelleres asm (in Kommentaren unter dem C) getestet: siehe den Link in der Antwort von @ hidefromkgb . (Diese Antwort hat das 30.000-Zeichen-Limit der großen Godbolt-URLs erreicht, aber Shortlinks können verrotten und waren für goo.gl sowieso zu lang.)

Außerdem wurde der Ausgabedruck verbessert, um ihn in einen String zu konvertieren und einen zu erstellen, write()anstatt jeweils ein Zeichen zu schreiben. Dies minimiert die Auswirkungen auf das Timing des gesamten Programms mit perf stat ./collatz(um Leistungsindikatoren aufzuzeichnen), und ich habe einige der unkritischen Aspekte verschleiert.


@ Veedrac Code

Ich habe eine geringfügige Beschleunigung erhalten, weil ich so viel nach rechts verschoben habe, wie wir wissen , und überprüft habe, ob die Schleife fortgesetzt werden soll. Von 7,5 s für Limit = 1e8 bis 7,275 s bei Core2Duo (Merom) mit einem Abrollfaktor von 16.

Code + Kommentare zu Godbolt . Verwenden Sie diese Version nicht mit Clang. es macht etwas Dummes mit der Defer-Schleife. Wenn Sie einen tmp-Zähler verwenden kund ihn countspäter hinzufügen, ändert sich die Funktion von clang, aber das tut gcc leicht weh.

Siehe Diskussion in den Kommentaren: Der Code von Veedrac ist hervorragend auf CPUs mit BMI1 (dh nicht Celeron / Pentium).

Peter Cordes
quelle
4
Ich habe den vektorisierten Ansatz vor einiger Zeit ausprobiert, er hat nicht geholfen (weil Sie mit skalarem Code viel besser umgehen können tzcntund im vektorisierten Fall an die am längsten laufende Sequenz unter Ihren Vektorelementen gebunden sind).
EOF
3
@EOF: Nein, ich meinte, aus der inneren Schleife auszubrechen, wenn eines der Vektorelemente trifft 1, anstatt wenn alle (leicht mit PCMPEQ / PMOVMSK erkennbar). Dann verwenden Sie PINSRQ und andere Dinge, um mit dem einen Element (und seinen Zählern) zu experimentieren und zurück in die Schleife zu springen. Das kann leicht zu einem Verlust werden, wenn Sie zu oft aus der inneren Schleife ausbrechen, aber es bedeutet, dass Sie bei jeder Iteration der inneren Schleife immer 2 oder 4 Elemente nützlicher Arbeit erledigen. Guter Punkt zum Auswendiglernen.
Peter Cordes
4
@ jefferson Das Beste, was ich geschafft habe, ist godbolt.org/g/1N70Ib . Ich hatte gehofft, ich könnte etwas Klügeres tun, aber es scheint nicht.
Veedrac
87
Das, was mich an unglaublichen Antworten wie diesen überrascht, ist das Wissen, das bis ins kleinste Detail gezeigt wird. Ich werde niemals eine Sprache oder ein System auf diesem Niveau kennen und ich würde nicht wissen wie. Gut gemacht, Sir.
camden_kid
8
Legendäre Antwort !!
Sumit Jain
104

Die Behauptung, dass der C ++ - Compiler optimaleren Code erzeugen kann als ein kompetenter Assembler-Programmierer, ist ein sehr schwerer Fehler. Und vor allem in diesem Fall. Der Mensch kann den Code immer besser machen als der Compiler, und diese besondere Situation ist ein gutes Beispiel für diese Behauptung.

Der Zeitunterschied, den Sie sehen, liegt darin, dass der Assembler-Code in der Frage in den inneren Schleifen bei weitem nicht optimal ist.

(Der folgende Code ist 32-Bit, kann aber problemlos in 64-Bit konvertiert werden.)

Zum Beispiel kann die Sequenzfunktion auf nur 5 Anweisungen optimiert werden:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Der gesamte Code sieht aus wie:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Um diesen Code zu kompilieren, wird FreshLib benötigt.

In meinen Tests (1-GHz-AMD-A4-1200-Prozessor) ist der obige Code ungefähr viermal schneller als der C ++ - Code aus der Frage (kompiliert mit -O0: 430 ms gegenüber 1900 ms) und mehr als zweimal schneller (430) ms vs. 830 ms), wenn der C ++ - Code mit kompiliert wird -O3.

Die Ausgabe beider Programme ist gleich: max sequence = 525 on i = 837799.

Johnfound
quelle
6
Huh, das ist klug. SHR setzt ZF nur, wenn EAX 1 (oder 0) war. Ich habe das bei der Optimierung der -O3Ausgabe von gcc verpasst , aber ich habe alle anderen Optimierungen festgestellt, die Sie an der inneren Schleife vorgenommen haben. (Aber warum verwenden Sie LEA für das Zählerinkrement anstelle von INC? Es ist in Ordnung, an diesem Punkt Flags zu blockieren und zu einer Verlangsamung von allem außer vielleicht P4 zu führen (falsche Abhängigkeit von alten Flags für INC und SHR). LEA kann ' t läuft auf so vielen Ports und kann zu Ressourcenkonflikten führen, die den kritischen Pfad häufiger verzögern.)
Peter Cordes
4
Oh, tatsächlich könnte Bulldozer einen Engpass beim Durchsatz mit der Compilerausgabe haben. Es hat CMOV mit geringerer Latenz und 3-Komponenten-LEA als Haswell (was ich in Betracht gezogen habe), sodass die schleifenübertragene Dep-Kette in Ihrem Code nur 3 Zyklen umfasst. Es gibt auch keine MOV-Befehle ohne Latenz für ganzzahlige Register, daher erhöhen die verschwendeten MOV-Befehle von g ++ tatsächlich die Latenz des kritischen Pfads und sind eine große Sache für Bulldozer. Ja, die Handoptimierung schlägt den Compiler für CPUs, die nicht hochmodern genug sind, um die nutzlosen Anweisungen durchzukauen, wirklich erheblich.
Peter Cordes
95
" Den C ++ - Compiler besser zu beanspruchen, ist ein sehr schlimmer Fehler. Und besonders in diesem Fall. Der Mensch kann den Code immer besser machen als das und dieses spezielle Problem ist ein gutes Beispiel für diese Behauptung. " Sie können es umkehren und es wäre genauso gültig . "Zu behaupten, ein Mensch sei besser, ist ein sehr schlimmer Fehler. Und besonders in diesem Fall. Der Mensch kann den Code immer noch schlimmer machen , als die und diese spezielle Frage ein gutes Beispiel für diese Behauptung sind. " Ich glaube, Sie haben hier keinen Sinn sind solche Verallgemeinerungen falsch.
luk32
5
@ luk32 - Aber der Autor der Frage kann überhaupt kein Argument sein, da seine Kenntnisse der Assemblersprache nahe Null sind. Alle Argumente über Mensch gegen Compiler setzen implizit Menschen mit mindestens einem mittleren Kenntnisstand voraus. Mehr: Der Satz "Der vom Menschen geschriebene Code wird immer besser oder der gleiche sein wie der vom Compiler generierte Code" ist sehr einfach formal zu beweisen.
Johnfound
30
@ luk32: Ein erfahrener Mensch kann (und sollte normalerweise) mit der Compilerausgabe beginnen. Solange Sie Ihre Versuche vergleichen, um sicherzustellen, dass sie tatsächlich schneller sind (auf der Zielhardware, auf die Sie sich einstellen), können Sie nichts Schlimmeres tun als den Compiler. Aber ja, ich muss zustimmen, dass es eine starke Aussage ist. Compiler sind normalerweise viel besser als Anfänger. Normalerweise ist es jedoch möglich, ein oder zwei Anweisungen zu speichern, verglichen mit den Compilern. (Je nach Uarch jedoch nicht immer auf dem kritischen Pfad). Sie sind sehr nützliche Teile komplexer Maschinen, aber sie sind nicht "klug".
Peter Cordes
24

Für mehr Leistung: Bei einer einfachen Änderung wird beobachtet, dass nach n = 3n + 1 n gerade ist, sodass Sie sofort durch 2 teilen können. Und n wird nicht 1 sein, sodass Sie nicht darauf testen müssen. Sie können also einige if-Anweisungen speichern und schreiben:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Hier ist ein großer Gewinn: Wenn Sie sich die niedrigsten 8 Bits von n ansehen, werden alle Schritte, bis Sie acht Mal durch 2 geteilt haben, vollständig durch diese acht Bits bestimmt. Wenn zum Beispiel die letzten acht Bits 0x01 sind, ist Ihre Zahl binär ???? 0000 0001 dann sind die nächsten Schritte:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Alle diese Schritte können also vorhergesagt werden, und 256k + 1 wird durch 81k + 1 ersetzt. Ähnliches passiert für alle Kombinationen. Sie können also eine Schleife mit einer großen switch-Anweisung erstellen:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Führen Sie die Schleife aus, bis n ≤ 128 ist, da an diesem Punkt n mit weniger als acht Teilungen durch 2 zu 1 werden kann. Wenn Sie acht oder mehr Schritte gleichzeitig ausführen, verpassen Sie den Punkt, an dem Sie zum ersten Mal 1 erreichen. Setzen Sie dann die "normale" Schleife fort - oder lassen Sie eine Tabelle erstellen, aus der hervorgeht, wie viele weitere Schritte erforderlich sind, um 1 zu erreichen.

PS. Ich vermute sehr, dass der Vorschlag von Peter Cordes es noch schneller machen würde. Es gibt überhaupt keine bedingten Verzweigungen außer einer, und diese wird korrekt vorhergesagt, außer wenn die Schleife tatsächlich endet. Der Code wäre also so etwas wie

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

In der Praxis würden Sie messen, ob die Verarbeitung der letzten 9, 10, 11, 12 Bits von n gleichzeitig schneller wäre. Für jedes Bit würde sich die Anzahl der Einträge in der Tabelle verdoppeln, und ich erwarte eine Verlangsamung, wenn die Tabellen nicht mehr in den L1-Cache passen.

PPS. Wenn Sie die Anzahl der Operationen benötigen: In jeder Iteration führen wir genau acht Teilungen durch zwei und eine variable Anzahl von (3n + 1) Operationen durch. Eine naheliegende Methode zum Zählen der Operationen wäre also ein anderes Array. Wir können jedoch tatsächlich die Anzahl der Schritte berechnen (basierend auf der Anzahl der Iterationen der Schleife).

Wir könnten das Problem leicht neu definieren: Ersetzen Sie n durch (3n + 1) / 2, wenn ungerade, und ersetzen Sie n durch n / 2, wenn gerade. Dann macht jede Iteration genau 8 Schritte, aber Sie könnten dieses Betrügen in Betracht ziehen :-) Nehmen wir also an, es gab r Operationen n <- 3n + 1 und s Operationen n <- n / 2. Das Ergebnis ist ziemlich genau n '= n * 3 ^ r / 2 ^ s, weil n <- 3n + 1 n <- 3n * (1 + 1 / 3n) bedeutet. Aus dem Logarithmus ergibt sich r = (s + log2 (n '/ n)) / log2 (3).

Wenn wir die Schleife bis n ≤ 1.000.000 durchführen und eine vorberechnete Tabelle haben, wie viele Iterationen von einem Startpunkt n ≤ 1.000.000 benötigt werden, ergibt die Berechnung von r wie oben, auf die nächste ganze Zahl gerundet, das richtige Ergebnis, es sei denn, s ist wirklich groß.

gnasher729
quelle
2
Oder erstellen Sie Daten-Nachschlagetabellen für die Multiplikation und fügen Sie anstelle eines Schalters Konstanten hinzu. Das Indizieren von zwei Tabellen mit 256 Einträgen ist schneller als eine Sprungtabelle, und Compiler suchen wahrscheinlich nicht nach dieser Transformation.
Peter Cordes
1
Hmm, ich dachte für eine Minute, diese Beobachtung könnte die Collatz-Vermutung beweisen, aber nein, natürlich nicht. Für jede mögliche nachfolgende 8 Bit gibt es eine endliche Anzahl von Schritten, bis sie alle weg sind. Einige dieser nachfolgenden 8-Bit-Muster verlängern jedoch den Rest des Bitstrings um mehr als 8, sodass ein unbegrenztes Wachstum oder ein sich wiederholender Zyklus nicht ausgeschlossen werden kann.
Peter Cordes
Zum Aktualisieren countbenötigen Sie ein drittes Array, oder? adders[]sagt dir nicht, wie viele Rechtsschichten gemacht wurden.
Peter Cordes
Bei größeren Tabellen lohnt es sich, schmalere Typen zu verwenden, um die Cache-Dichte zu erhöhen. Bei den meisten Architekturen ist eine Last ohne Ausdehnung von a uint16_tsehr billig. Auf x86 ist es genauso günstig wie eine Null-Erweiterung von 32-Bit unsigned intauf uint64_t. (MOVZX aus dem Speicher auf Intel-CPUs benötigt nur einen Load-Port, aber AMD-CPUs benötigen auch die ALU.) Übrigens, warum verwenden Sie size_tfür lastBits? Es ist ein 32-Bit-Typ mit -m32und sogar -mx32(langer Modus mit 32-Bit-Zeigern). Es ist definitiv der falsche Typ für n. Einfach benutzen unsigned.
Peter Cordes
20

Ganz unabhängig: mehr Performance-Hacks!

  • [Die erste «Vermutung» wurde schließlich von @ShreevatsaR entlarvt. entfernt]

  • Beim Durchlaufen der Sequenz können nur 3 mögliche Fälle in der 2-Nachbarschaft des aktuellen Elements N(zuerst gezeigt) erhalten werden:

    1. [gerade ungerade]
    2. [ungerade gerade]
    3. [gerade] [gerade]

    LEAP Vergangenheit dieser Elemente 2 Mittel zu berechnen (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1und N >> 2, respectively.

    Beweisen wir, dass es in beiden Fällen (1) und (2) möglich ist, die erste Formel zu verwenden (N >> 1) + N + 1.

    Fall (1) ist offensichtlich. Fall (2) impliziert (N & 1) == 1also, wenn wir also (ohne Verlust der Allgemeinheit) annehmen, dass N 2 Bit lang ist und seine Bits bavon höchst bis niedrigstwert sind, dann a = 1gilt Folgendes:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    wo B = !b. Wenn Sie das erste Ergebnis nach rechts verschieben, erhalten Sie genau das, was wir wollen.

    QED : (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Wie bewiesen, können wir die Elemente der Sequenz 2 gleichzeitig mit einer einzigen ternären Operation durchlaufen. Eine weitere 2-fache Zeitreduzierung.

Der resultierende Algorithmus sieht folgendermaßen aus:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Hier vergleichen wir, n > 2weil der Prozess bei 2 statt 1 anhalten kann, wenn die Gesamtlänge der Sequenz ungerade ist.

[BEARBEITEN:]

Lassen Sie uns dies in Montage übersetzen!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Verwenden Sie diese Befehle zum Kompilieren:

nasm -f elf64 file.asm
ld -o file file.o

Siehe das C und eine verbesserte / Bugfixed-Version des Asms von Peter Cordes auf Godbolt . (Anmerkung des Herausgebers: Tut mir leid, dass ich meine Daten in Ihre Antwort aufgenommen habe, aber meine Antwort hat das 30.000-Zeichen-Limit von Godbolt-Links + Text erreicht!)

hidefromkgb
quelle
2
Es gibt kein Qsolches Integral 12 = 3Q + 1. Ihr erster Punkt ist nicht richtig, denkt.
Veedrac
1
@Veedrac: Ich habe damit herumgespielt: Es kann mit besserem asm als der Implementierung in dieser Antwort implementiert werden, mit ROR / TEST und nur einem CMOV. Dieser asm - Code unendlich-Schleifen auf meiner CPU, da es anscheinend auf OF beruht, die mit Zahl nach SHRD oder ROR nicht definiert ist> 1. Es geht auch auf große Längen zu vermeiden , um zu versuchen mov reg, imm32, scheinbar Bytes zu speichern, aber dann verwendet es die 64-Bit-Version des Registers überall, auch für xor rax, rax, so dass es viele unnötige REX-Präfixe hat. Wir brauchen offensichtlich nur REX für die Regs, die nin der inneren Schleife gehalten werden, um einen Überlauf zu vermeiden.
Peter Cordes
1
Timing-Ergebnisse (von einem Core2Duo E6600: Merom 2,4 GHz. Complex-LEA = 1c Latenz, CMOV = 2c) . Die beste einstufige asm-Implementierung innerhalb der Schleife (von Johnfound): 111 ms pro Lauf dieser @ main-Schleife. Compiler-Ausgabe von meiner entdeckten Version dieses C (mit einigen tmp-Vars): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108 ms. Aus meiner verbesserten Version von Clangs Asm-Innenschleife: 92 ms (sollte eine viel größere Verbesserung gegenüber der SnB-Familie sehen, bei der die komplexe LEA 3c und nicht 1c beträgt). Aus meiner verbesserten + funktionierenden Version dieser ASM-Schleife (mit ROR + TEST, nicht SHRD): 87 ms. Gemessen mit 5 Wiederholungen vor dem Drucken
Peter Cordes
2
Hier sind die ersten 66 Rekordhalter (A006877 bei OEIS); Ich habe die geraden fett markiert: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 626331, 837799, 1117065, 15013 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR
1
@hidefromkgb Großartig! Und ich schätze Ihren anderen Punkt jetzt auch besser: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1 und 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. Schöne Beobachtung!
ShreevatsaR
6

C ++ - Programme werden während der Generierung von Maschinencode aus dem Quellcode in Assembly-Programme übersetzt. Es wäre praktisch falsch zu sagen, dass die Assembly langsamer als C ++ ist. Darüber hinaus unterscheidet sich der generierte Binärcode von Compiler zu Compiler. Ein intelligenter C ++ - Compiler kann also Binärcode erzeugen, der optimaler und effizienter ist als der Code eines dummen Assemblers.

Ich glaube jedoch, dass Ihre Profilierungsmethode bestimmte Mängel aufweist. Im Folgenden finden Sie allgemeine Richtlinien für die Profilerstellung:

  1. Stellen Sie sicher, dass sich Ihr System im Normal- / Leerlaufzustand befindet. Stoppen Sie alle laufenden Prozesse (Anwendungen), die Sie gestartet haben oder die die CPU intensiv nutzen (oder über das Netzwerk abfragen).
  2. Ihre Datengröße muss größer sein.
  3. Ihr Test muss länger als 5-10 Sekunden dauern.
  4. Verlassen Sie sich nicht nur auf eine Probe. Führen Sie Ihren Test N-mal durch. Sammeln Sie die Ergebnisse und berechnen Sie den Mittelwert oder Median des Ergebnisses.
Mangu Singh Rajpurohit
quelle
Ja, ich habe keine formale Profilerstellung durchgeführt, aber ich habe beide einige Male ausgeführt und kann 2 Sekunden von 3 Sekunden unterscheiden. Trotzdem danke für die Antwort. Ich habe hier schon viele Informationen
gesammelt
9
Es ist wahrscheinlich nicht nur ein Messfehler, der handgeschriebene ASM-Code verwendet einen 64-Bit-DIV-Befehl anstelle einer Rechtsverschiebung. Siehe meine Antwort. Aber ja, richtig zu messen ist auch wichtig.
Peter Cordes
7
Aufzählungszeichen sind besser geeignet als ein Codeblock. Bitte hören Sie auf, Ihren Text in einen Codeblock einzufügen, da dieser kein Code ist und nicht von einer monospaced Schriftart profitiert.
Peter Cordes
16
Ich sehe nicht wirklich, wie dies die Frage beantwortet. Dies ist keine vage Frage, ob Assembler-Code oder C ++ - Code möglicherweise schneller ist. Es handelt sich um eine sehr spezifische Frage zum tatsächlichen Code , die er in der Frage selbst hilfreich zur Verfügung stellt. In Ihrer Antwort wird nicht einmal dieser Code erwähnt oder ein Vergleich durchgeführt. Sicher, Ihre Tipps zum Benchmarking sind grundsätzlich korrekt, reichen jedoch nicht aus, um eine tatsächliche Antwort zu geben.
Cody Gray
6

Für das Collatz-Problem können Sie die Leistung erheblich steigern, indem Sie die "Schwänze" zwischenspeichern. Dies ist ein Kompromiss zwischen Zeit und Speicher. Siehe: Memoization ( https://en.wikipedia.org/wiki/Memoization ). Sie können sich auch dynamische Programmierlösungen für andere Zeit- / Speicherkompromisse ansehen.

Beispiel für eine Python-Implementierung:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))
Emanuel Landeholm
quelle
1
Die Antwort von gnasher zeigt, dass Sie viel mehr tun können, als nur die Schwänze zwischenzuspeichern: Hohe Bits haben keinen Einfluss darauf, was als nächstes passiert, und add / mul propagiert nur den Übertrag nach links, sodass hohe Bits keinen Einfluss darauf haben, was mit den niedrigen Bits passiert. Das heißt, Sie können LUT-Lookups verwenden, um 8 (oder eine beliebige Anzahl) Bits gleichzeitig zu bearbeiten, indem Sie multiplizieren und Konstanten hinzufügen, die auf den Rest der Bits angewendet werden. Das Auswendiglernen der Schwänze ist sicherlich bei vielen Problemen wie diesem hilfreich, und bei diesem Problem, wenn Sie noch nicht an den besseren Ansatz gedacht oder ihn nicht als richtig erwiesen haben.
Peter Cordes
2
Wenn ich die obige Idee von Gnasher richtig verstehe, denke ich, dass die Schwanzmemoisierung eine orthogonale Optimierung ist. Sie könnten also möglicherweise beides tun. Es wäre interessant zu untersuchen, wie viel Sie durch das Hinzufügen von Memoization zum Gnasher-Algorithmus gewinnen könnten.
Emanuel Landeholm
2
Wir können das Auswendiglernen vielleicht billiger machen, indem wir nur den dichten Teil der Ergebnisse speichern. Stellen Sie eine Obergrenze für N ein und überprüfen Sie darüber hinaus nicht einmal den Speicher. Verwenden Sie darunter Hash (N) -> N als Hash-Funktion, also key = position im Array und muss nicht gespeichert werden. Ein Eintrag von 0Mitteln noch nicht vorhanden. Wir können weiter optimieren, indem wir nur ungerade N in der Tabelle speichern. Die Hash-Funktion ist also n>>1, die 1 zu verwerfen. Schreiben Sie den Schrittcode so, dass er immer mit einem n>>tzcnt(n)oder etwas endet , um sicherzustellen, dass er ungerade ist.
Peter Cordes
1
Das basiert auf meiner (ungetesteten) Idee, dass sehr große N-Werte in der Mitte einer Sequenz weniger wahrscheinlich für mehrere Sequenzen gleich sind, sodass wir nicht zu viel verpassen, wenn wir sie nicht auswendig lernen. Außerdem ist ein N mit angemessener Größe Teil vieler langer Sequenzen, auch solcher, die mit einem sehr großen N beginnen. (Dies kann ein Wunschdenken sein. Wenn es falsch ist, kann nur das Zwischenspeichern eines dichten Bereichs aufeinanderfolgender N gegenüber einem Hash verloren gehen Tabelle, in der beliebige Schlüssel gespeichert werden können.) Haben Sie irgendeine Art von Trefferquantentest durchgeführt, um festzustellen, ob das Start-N in der Nähe Ähnlichkeiten in den Sequenzwerten aufweist?
Peter Cordes
2
Sie können einfach vorberechnete Ergebnisse für alle n <N für einige große N speichern. Sie benötigen also nicht den Overhead einer Hash-Tabelle. Die Daten in dieser Tabelle werden schließlich für jeden Startwert verwendet. Wenn Sie nur bestätigen möchten, dass die Collatz-Sequenz immer mit (1, 4, 2, 1, 4, 2, ...) endet: Dies kann nachweislich dem Nachweis entsprechen, dass für n> 1 die Sequenz schließlich endet kleiner sein als das Original n. Und dafür hilft das Zwischenspeichern von Schwänzen nicht.
Gnasher729
5

Aus Kommentaren:

Dieser Code hört jedoch nie auf (wegen eines Ganzzahlüberlaufs)!?! Yves Daoust

Bei vielen Zahlen läuft es nicht über.

Wenn es wird überlaufen - für ein diese unglücklichen Anfang Samt, wird die überflogenen Zahl sehr wahrscheinlich konvergieren in Richtung 1 ohne einen weiteren Überlauf.

Trotzdem wirft dies eine interessante Frage auf: Gibt es eine überlaufzyklische Keimzahl?

Jede einfache endgültige konvergierende Reihe beginnt mit einer Potenz von zwei Werten (offensichtlich genug?).

2 ^ 64 wird auf Null überlaufen, was laut Algorithmus eine undefinierte Endlosschleife ist (endet nur mit 1), aber die optimalste Antwortlösung wird aufgrund der shr raxErzeugung von ZF = 1 beendet.

Können wir 2 ^ 64 produzieren? Wenn die Startnummer ist 0x5555555555555555, ist es eine ungerade Nummer, die nächste Nummer ist dann 3n + 1, was 0xFFFFFFFFFFFFFFFF + 1= ist 0. Theoretisch im undefinierten Zustand des Algorithmus, aber die optimierte Antwort von Johnfound wird durch Beenden von ZF = 1 wiederhergestellt. Das cmp rax,1von Peter Cordes endet in einer Endlosschleife (QED-Variante 1, "cheapo" durch undefinierte 0Zahl).

Wie wäre es mit einer komplexeren Zahl, die einen Zyklus ohne erzeugt 0? Ehrlich gesagt bin ich mir nicht sicher, ob meine Mathe-Theorie zu verschwommen ist, um eine ernsthafte Vorstellung davon zu bekommen, wie man ernsthaft damit umgeht. Aber intuitiv würde ich sagen, dass die Reihe für jede Zahl gegen 1 konvergiert: 0 <Zahl, da die 3n + 1-Formel früher oder später langsam jeden Nicht-2-Primfaktor der ursprünglichen Zahl (oder Zwischenstufe) in eine Zweierpotenz umwandelt . Wir müssen uns also keine Sorgen um die Endlosschleife für Originalserien machen, nur ein Überlauf kann uns behindern.

Also habe ich nur ein paar Zahlen in ein Blatt geschrieben und mir 8-Bit-Zahlen abgeschnitten.

Es gibt drei Werte überfüllt zu 0: 227, 170und 85( 85geht direkt an 0, beiden anderen voran in Richtung 85).

Es gibt jedoch keinen Wert, der einen zyklischen Überlauf erzeugt.

Lustigerweise habe ich einen Check durchgeführt, der die erste Zahl ist, die unter 8-Bit-Kürzung leidet und bereits 27betroffen ist! Es erreicht den Wert 9232in der richtigen nicht abgeschnittenen Reihe (der erste abgeschnittene Wert befindet sich 322im 12. Schritt), und der maximale Wert, der für eine der 2-255 Eingangsnummern auf nicht abgeschnittene Weise erreicht wird, ist 13120(für sich 255selbst) die maximale Anzahl von Schritten zu konvergieren 1ist ungefähr 128(+ -2, nicht sicher, ob "1" zählen soll, etc ...).

Interessanterweise ist (für mich) die Anzahl 9232für viele andere Quellennummern maximal. Was ist das Besondere daran? : -O 9232= 0x2410... hmmm .. keine Ahnung.

Leider kann ich kein tiefes Verständnis dieser Serie erhalten, warum es konvergieren und welche Auswirkungen sie von Kürzen k Bits, aber mit cmp number,1Endbedingung ist es sicherlich möglich , den Algorithmus in Endlosschleife mit bestimmtem Eingangswert endet zu setzen , da 0nach Kürzung.

Der Wert, der 27für den 8-Bit-Fall überläuft, ist jedoch eine Art Warnung. Wenn Sie die Anzahl der Schritte zählen, um den Wert zu erreichen 1, erhalten Sie für die Mehrheit der Zahlen aus der gesamten k-Bit-Menge von Ganzzahlen ein falsches Ergebnis. Für die 8-Bit-Ganzzahlen haben die 146 von 256 Zahlen die Serie durch Abschneiden beeinflusst (einige von ihnen treffen möglicherweise versehentlich immer noch die richtige Anzahl von Schritten, ich bin zu faul, um dies zu überprüfen).

Ped7g
quelle
"Die übergelaufene Zahl wird sehr wahrscheinlich ohne einen weiteren Überlauf gegen 1 konvergieren": Der Code hört nie auf. (Das ist eine Vermutung, da ich nicht bis zum Ende der Zeiten warten kann, um sicher zu sein ...)
Yves Daoust
@YvesDaoust oh, aber das tut es? ... zum Beispiel 27sieht die Serie mit 8b-Kürzung folgendermaßen aus: 82 41 124 62 31 94 47 142 71 214 107 66 (abgeschnitten) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (der Rest funktioniert ohne Kürzung). Ich verstehe dich nicht, sorry. Es würde niemals aufhören, wenn der abgeschnittene Wert einigen der zuvor in derzeit laufenden Reihen erreichten Werte entsprechen würde, und ich kann keinen solchen Wert gegenüber der k-Bit-Kürzung finden (aber ich kann auch die dahinter stehende mathematische Theorie nicht herausfinden, warum Dies gilt für das Abschneiden von 8/16/32/64 Bit, nur intuitiv denke ich, dass es funktioniert.
Ped7g
1
Ich hätte die ursprüngliche Problembeschreibung früher überprüfen sollen: "Obwohl es noch nicht bewiesen wurde (Collatz-Problem), wird angenommen, dass alle Startnummern bei 1 enden." ... ok, ich kein Wunder , nicht begreift es mit meinem begrenzten dunstig Math Wissen bekommen ...: D Und von meinem Blatt Experiment kann ich Ihnen versichern , dass es konvergiert für jeden 2- 255Zahl, entweder ohne Abschneiden (zu 1), oder mit 8-Bit-Kürzung (entweder erwartet 1oder 0für drei Zahlen).
Ped7g
Hem, wenn ich sage, dass es nie aufhört, meine ich ... dass es nicht aufhört. Der angegebene Code wird für immer ausgeführt, wenn Sie dies bevorzugen.
Yves Daoust
1
Upvoted für die Analyse dessen, was beim Überlauf passiert. Die CMP-basierte Schleife könnte cmp rax,1 / jna(dh do{}while(n>1)) verwenden, um auch auf Null zu enden. Ich dachte darüber nach, eine instrumentierte Version der Schleife zu erstellen, die das maximal ngesehene Maß aufzeichnet , um eine Vorstellung davon zu bekommen, wie nahe wir dem Überlauf kommen.
Peter Cordes
5

Sie haben den vom Compiler generierten Code nicht veröffentlicht, daher gibt es hier einige Vermutungen, aber auch ohne ihn gesehen zu haben, kann man Folgendes sagen:

test rax, 1
jpe even

... hat eine 50% ige Chance, die Branche falsch vorherzusagen, und das wird teuer.

Der Compiler führt mit ziemlicher Sicherheit beide Berechnungen durch (was vernachlässigbar mehr kostet, da div / mod eine ziemlich lange Latenz hat, so dass das Multiplikationsaddieren "frei" ist) und führt anschließend eine CMOV durch. Was natürlich eine Null- Prozent-Chance hat, falsch vorhergesagt zu werden.

Damon
quelle
1
Die Verzweigung hat ein Muster. Auf eine ungerade Zahl folgt immer eine gerade Zahl. Aber manchmal hinterlässt 3n + 1 mehrere nachfolgende Nullbits, und dann wird dies falsch vorhergesagt. Ich fing an, in meiner Antwort über Teilung zu schreiben, und sprach diese andere große rote Fahne im OP-Code nicht an. (Beachten Sie auch, dass die Verwendung einer Paritätsbedingung im Vergleich zu nur JZ oder CMOVZ wirklich seltsam ist. Dies ist auch für die CPU schlimmer, da Intel-CPUs TEST / JZ, aber nicht TEST / JPE makroverschmelzen können. Laut Agner Fog kann AMD jeden verschmelzen TEST / CMP mit jedem JCC, in diesem Fall ist es nur für menschliche Leser schlimmer)
Peter Cordes
5

Selbst ohne Blick auf die Montage ist der offensichtlichste Grund, dass /= 2wahrscheinlich optimiert wird >>=1und viele Prozessoren einen sehr schnellen Schaltvorgang haben. Aber selbst wenn ein Prozessor keine Verschiebungsoperation hat, ist die Ganzzahldivision schneller als die Gleitkommadivision.

Bearbeiten: Ihre Laufleistung kann in der obigen Anweisung "Ganzzahldivision ist schneller als Gleitkommadivision" variieren. Die folgenden Kommentare zeigen, dass die modernen Prozessoren der Optimierung der fp-Division Vorrang vor der ganzzahligen Division eingeräumt haben. Also , wenn jemand sucht der wahrscheinlichste Grund für die Beschleunigung , die dieser Frage Thread etwa fragt, dann Compiler Optimierung /=2als >>=1der beste Platz 1 zu sehen wäre.


In einem anderen Zusammenhangn ist der Ausdruck n*3+1immer gerade , wenn er ungerade ist . Es besteht also keine Notwendigkeit zu überprüfen. Sie können diesen Zweig in ändern

{
   n = (n*3+1) >> 1;
   count += 2;
}

Also wäre die ganze Aussage dann

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}
Dmitry Rubanovich
quelle
4
Die Ganzzahldivision ist auf modernen x86-CPUs nicht schneller als die FP-Division. Ich denke, das liegt daran, dass Intel / AMD mehr Transistoren für ihre FP-Teiler ausgeben, weil es eine wichtigere Operation ist. (Die ganzzahlige Division durch Konstanten kann so optimiert werden, dass sie mit einer modularen Inversen multipliziert wird.) Überprüfen Sie die Insn-Tabellen von Agner Fog und vergleichen Sie DIVSD (Float mit doppelter Genauigkeit) mit DIV r32(32-Bit-Ganzzahl ohne Vorzeichen) oder DIV r64(viel langsamer 64-Bit-Ganzzahl ohne Vorzeichen). Insbesondere für den Durchsatz ist die FP-Teilung viel schneller (Single UOP anstelle von Mikrocodierung und teilweise Pipeline), aber auch die Latenz ist besser.
Peter Cordes
1
zB auf der Haswell-CPU des OP: DIVSD ist 1 UOP, 10-20 Zyklen Latenz, einer pro 8-14c Durchsatz. div r64beträgt 36 Uops, 32-96c Latenz und einen pro 21-74c Durchsatz. Skylake hat einen noch schnelleren FP-Divisionsdurchsatz (Pipelined bei eins pro 4c mit nicht viel besserer Latenz), aber nicht viel schnelleren Integer-Div. Bei der AMD Bulldozer-Familie ist es ähnlich: DIVSD ist 1M-op, 9-27c Latenz, eine pro 4,5-11c Durchsatz. div r64ist 16M-ops, 16-75c Latenz, eine pro 16-75c Durchsatz.
Peter Cordes
1
Ist die FP-Division nicht im Grunde dasselbe wie Exponenten mit ganzzahligen Subtraktionen, Mantissen mit ganzzahligen Divisionen, die Denormale erkennen? Und diese 3 Schritte können parallel ausgeführt werden.
MSalters
2
@MSalters: Ja, das klingt richtig, aber mit einem Normalisierungsschritt am Ende, um Bits zwischen Exponent und Mantiss zu verschieben. doublehat eine 53-Bit-Mantisse, ist aber immer noch deutlich langsamer als div r32bei Haswell. Es geht also definitiv nur darum, wie viel Hardware Intel / AMD auf das Problem wirft, da sie nicht die gleichen Transistoren für Ganzzahl- und FTP-Teiler verwenden. Die Ganzzahl ist skalar (es gibt keine Ganzzahl-SIMD-Teilung), und der Vektor behandelt 128b-Vektoren (nicht 256b wie andere Vektor-ALUs). Die große Sache ist, dass Integer Div viele Uops sind, große Auswirkungen auf den umgebenden Code.
Peter Cordes
Err, nicht Bits zwischen Mantisse und Exponent verschieben, sondern die Mantisse mit einer Verschiebung normalisieren und den Verschiebungsbetrag zum Exponenten addieren.
Peter Cordes
4

Als allgemeine Antwort, die nicht speziell auf diese Aufgabe ausgerichtet ist: In vielen Fällen können Sie jedes Programm erheblich beschleunigen, indem Sie Verbesserungen auf hohem Niveau vornehmen. B. einmal statt mehrmals Daten berechnen, unnötige Arbeit vollständig vermeiden, Caches optimal nutzen und so weiter. Diese Dinge sind in einer Hochsprache viel einfacher zu tun.

Schreiben Assembler Code ist es möglich , zu verbessern, was eine Optimierung der Compiler tun, aber es ist harte Arbeit. Und wenn dies erledigt ist, ist es viel schwieriger, Ihren Code zu ändern, sodass es viel schwieriger ist, algorithmische Verbesserungen hinzuzufügen. Manchmal verfügt der Prozessor über Funktionen, die Sie in einer Hochsprache nicht verwenden können. In diesen Fällen ist die Inline-Assemblierung häufig hilfreich und ermöglicht die Verwendung einer Hochsprache.

Bei den Euler-Problemen gelingt es Ihnen meistens, etwas zu bauen, herauszufinden, warum es langsam ist, etwas Besseres zu bauen, herauszufinden, warum es langsam ist und so weiter und so fort. Das ist sehr, sehr schwer mit Assembler. Ein besserer Algorithmus mit der halben möglichen Geschwindigkeit schlägt normalerweise einen schlechteren Algorithmus mit voller Geschwindigkeit, und es ist nicht trivial, die volle Geschwindigkeit im Assembler zu erreichen.

gnasher729
quelle
2
Stimme dem voll und ganz zu. gcc -O3Für genau diesen Algorithmus wurde Code erstellt, der innerhalb von 20% des Optimums von Haswell lag. (Das Erhalten dieser Beschleunigungen war das Hauptaugenmerk meiner Antwort, nur weil dies die Frage war und eine interessante Antwort hat, nicht weil es der richtige Ansatz ist.) Viel größere Beschleunigungen wurden durch Transformationen erzielt, nach denen der Compiler höchstwahrscheinlich nicht suchen würde B. wie das Verschieben von Rechtsschichten oder das gleichzeitige Ausführen von zwei Schritten. Weitaus größere Beschleunigungen als diese können aus Memoization / Lookup-Tabellen erzielt werden. Noch erschöpfende Tests, aber keine reine rohe Gewalt.
Peter Cordes
2
Eine einfache Implementierung, die offensichtlich korrekt ist, ist jedoch äußerst nützlich, um andere Implementierungen zu testen. Was ich tun würde, ist wahrscheinlich nur die asm-Ausgabe zu betrachten, um zu sehen, ob gcc es verzweigt gemacht hat, wie ich es erwartet hatte (meistens aus Neugier), und dann zu algorithmischen Verbesserungen überzugehen.
Peter Cordes
-2

Die einfache Antwort:

  • MOV RBX, 3 und MUL RBX zu machen ist teuer; nur RBX hinzufügen, RBX zweimal

  • ADD 1 ist hier wahrscheinlich schneller als INC

  • MOV 2 und DIV sind sehr teuer; einfach nach rechts verschieben

  • 64-Bit-Code ist normalerweise merklich langsamer als 32-Bit-Code, und die Ausrichtungsprobleme sind komplizierter. Bei kleinen Programmen wie diesem müssen Sie sie packen, damit Sie parallel rechnen können, um schneller als 32-Bit-Code zu sein

Wenn Sie die Assembly-Liste für Ihr C ++ - Programm generieren, können Sie sehen, wie sie sich von Ihrer Assembly unterscheidet.

Tyler Durden
quelle
4
1): 3-maliges Hinzufügen wäre im Vergleich zu LEA dumm. Ebenfalls mul rbxauf der Haswell-CPU des OP befinden sich 2 Uops mit 3c Latenz (und 1 pro Takt Durchsatz). imul rcx, rbx, 3ist nur 1 uop, mit der gleichen 3c Latenz. Zwei ADD-Anweisungen wären 2 Uops mit 2c Latenz.
Peter Cordes
5
2) ADD 1 ist hier wahrscheinlich schneller als INC . Nein, das OP verwendet keinen Pentium4 . Ihr Punkt 3) ist der einzig richtige Teil dieser Antwort.
Peter Cordes
5
4) klingt nach totalem Unsinn. 64-Bit-Code kann bei zeigerlastigen Datenstrukturen langsamer sein, da größere Zeiger einen größeren Cache-Footprint bedeuten. Dieser Code funktioniert jedoch nur in Registern, und die Probleme bei der Codeausrichtung sind im 32- und 64-Bit-Modus gleich. (Probleme mit der Datenausrichtung sind also keine Ahnung, wovon Sie sprechen, da die Ausrichtung für x86-64 ein größeres Problem darstellt.) Auf jeden Fall berührt der Code nicht einmal den Speicher innerhalb der Schleife.
Peter Cordes
Der Kommentator hat keine Ahnung, wovon spricht. Ein MOV + MUL auf einer 64-Bit-CPU ist ungefähr dreimal langsamer als das zweimalige Hinzufügen eines Registers. Seine anderen Bemerkungen sind ebenso falsch.
Tyler Durden
6
Nun, MOV + MUL ist definitiv dumm, aber MOV + ADD + ADD ist immer noch albern (tatsächlich ADD RBX, RBX zweimal zu würde mit 4 multiplizieren, nicht mit 3). Bei weitem der beste Weg ist lea rax, [rbx + rbx*2]. Oder machen Sie auf Kosten einer 3-Komponenten-LEA auch die +1 mit lea rax, [rbx + rbx*2 + 1] (3c-Latenz bei HSW anstelle von 1, wie ich in meiner Antwort erklärt habe). Mein Punkt war, dass 64-Bit-Multiplikation bei nicht sehr teuer ist Neuere Intel-CPUs, weil sie wahnsinnig schnelle Ganzzahl-Multiplikationseinheiten haben (sogar im Vergleich zu AMD, wo die gleiche MUL r646c-Latenz mit einer pro 4c-Durchsatz gilt: nicht einmal vollständig per Pipeline.
Peter Cordes