it-swarm.com.de

C ++ - Code zum Testen der Collatz-Vermutung schneller als handgeschriebene Assembly - warum?

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 Assembly-Lösung wurde mit zusammengebaut

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

Das C++ wurde mit kompiliert

g++ p14.cpp -o p14

Montage, 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, um die Geschwindigkeit und alles zu verbessern, aber ich sehe nicht viele Möglichkeiten, um meine Assembly-Lösung weiter zu optimieren (programmatisch und nicht mathematisch).

Der C++ - Code hat Modul für jeden Term und Division für jeden geraden Term, wobei 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 vor allem aus Neugier.

Ausführungszeiten

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

793
jeffer son

Wenn Sie der Meinung sind, dass ein 64-Bit-DIV-Befehl ein guter Weg ist, um durch zwei zu teilen, ist es kein Wunder, dass die asm-Ausgabe des Compilers Ihren handgeschriebenen Code übertrifft, auch mit -O0 (schnell kompilieren, keine zusätzliche Optimierung, und Speichern/Laden in den Speicher nach/vor jeder C-Anweisung, damit ein Debugger Variablen ändern kann.

In Agner Fog's Optimizing Assembly Guide erfahren Sie, wie Sie effizient asm schreiben. Er hat auch Anweisungstabellen und eine Mikroarchitekturanleitung für spezifische Details für spezifische CPUs. Siehe auch das x86 Tag-Wiki für weiterführende Links.

Siehe auch diese allgemeinere Frage zum Beenden 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 sich effizient kompilieren lässt . Siehe auch ist Assembly schneller als kompilierte Sprachen? . Eine der Antworten verweist auf diese netten Folien , die zeigen, wie verschiedene C-Compiler einige wirklich einfache Funktionen mit coolen Tricks optimieren.


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

Unter Intel Haswell beträgt div r64 36 Uops mit einer Latenz von 32-96 Zyklen und einem Durchsatz von 1 pro 21-74 Zyklen. (Plus die 2 Uops zum Einrichten von RBX und Zero RDX, aber eine Ausführung außerhalb der Reihenfolge kann dazu führen, dass diese vorzeitig ausgeführt werden). High-Uop-Count-Befehle 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 Schleife ist Abhängigkeitskette.

shr rax, 1 führt dieselbe vorzeichenlose Division durch: Es ist 1 uop mit 1 c Latenz und kann 2 pro Taktzyklus ausführen.

Zum Vergleich: Die 32-Bit-Division ist schneller, aber immer noch fürchterlich im Vergleich zu Verschiebungen. idiv r32 ist 9 uops, 22-29c Latenz und einer pro 8-11c Durchsatz bei Haswell.


Wie Sie an der -O0 asm-Ausgabe von gcc sehen können ( Godbolt-Compiler-Explorer ), werden nur Verschiebungsanweisungen verwendet . clang -O0 kompiliert naiv wie Sie dachten, selbst wenn Sie zweimal 64-Bit-IDIV verwenden. (Bei der Optimierung verwenden Compiler beide Ausgaben von IDIV, 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 transformiert sich immer durch GIMPLE, was bedeutet, dass einige "Optimierungen" nicht deaktiviert werden können . Dies beinhaltet das Erkennen der Division durch Konstante und die Verwendung von Verschiebungen (Potenz von 2) oder eines multiplikativen Inversen mit festem Punkt (nicht Potenz von 2), um IDIV zu vermeiden (siehe div_by_13 im obigen Godbolt-Link).

gcc -Os (für Größe optimieren) tut IDIV für Division ohne Zweierpotenz verwenden, leider auch in Fällen, in denen der multiplikative inverse Code nur geringfügig größer ist aber viel schneller.


Dem Compiler helfen

(Zusammenfassung für diesen Fall: Verwenden Sie uint64_t n)

Zunächst ist es nur interessant, die optimierte Compiler-Ausgabe zu betrachten. (-O3). -O0 Geschwindigkeit ist grundsätzlich bedeutungslos.

Schauen Sie sich Ihre ASM-Ausgabe an (auf Godbolt, oder sehen Sie , wie Sie "Rauschen" aus der GCC/Clang-Assembly-Ausgabe entfernen ). Wenn der Compiler nicht an erster Stelle optimalen Code erstellt: Das Schreiben Ihrer C/C++ - Quelle in einer Weise, die den Compiler dazu anleitet, 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 mit der Hand dazu bewegen, dasselbe zu tun: Sehen Sie diese Antwort und was ich mit der getan habe nicht entrollte Schleife in @ Veedracs Code unten.)

Dieser Ansatz ist portabel, und in 20 Jahren kann ein zukünftiger Compiler ihn auf eine beliebige Art und Weise kompilieren, die auf künftiger Hardware (x86 oder nicht) effizient ist. Möglicherweise wird eine neue ISA Erweiterung oder automatische Vektorisierung verwendet. Handgeschrieben x86-64-Asm von vor 15 Jahren wäre für Skylake normalerweise nicht optimal abgestimmt. ZB gab es damals keine Compare & Branch-Makrofusion. Was jetzt für handgefertigtes Asm für eine Mikroarchitektur optimal ist, ist möglicherweise nicht optimal Andere aktuelle und zukünftige CPUs. In den Kommentaren zu @ johnfounds Antwort werden die Hauptunterschiede zwischen AMD Bulldozer und Intel Haswell erörtert, die einen großen Einfluss auf diesen Code haben , g++ -O3 -march=bdver3 und g++ -O3 -march=skylake werden das Richtige tun. (Oder -march=native.) Oder -mtune=..., um nur zu stimmen, ohne Anweisungen zu verwenden, die andere CPUs werden möglicherweise nicht unterstützt.

Mein Gefühl ist, dass es für zukünftige Compiler kein Problem sein sollte, den Compiler so zu führen, dass er für eine aktuelle CPU, die Ihnen am Herzen liegt, gut ist. Sie sind hoffentlich besser als aktuelle Compiler darin, Wege zu finden, um Code zu transformieren, und können Wege finden, die für zukünftige CPUs funktionieren. Unabhängig davon wird das zukünftige x86 wahrscheinlich in nichts schlechtem sein, was auf dem aktuellen x86 gut ist, und der zukünftige Compiler wird asm-spezifische Fallstricke vermeiden, während er so etwas wie die Datenverschiebung aus Ihrer C-Quelle implementiert, wenn er nichts Besseres sieht.

Handgeschriebenes asm ist eine Blackbox für das Optimierungsprogramm, daher funktioniert die Konstantenausbreitung nicht, wenn Inlining eine Eingabe zu einer Konstanten 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 den Speicher durchlaufen , wodurch sich der Overhead erhöht .)

In diesem Fall : Ihr n hat einen vorzeichenbehafteten Typ, und gcc verwendet die SAR/SHR/ADD-Sequenz, die die richtige Rundung ergibt. (IDIV und arithmetische Verschiebung "runden" für negative Eingaben unterschiedlich, siehe SAR insn set ref manuelle Eingabe ). (IDK, wenn gcc versucht hat und nicht beweisen konnte, dass n nicht negativ sein kann, oder was. Signed-Overflow ist undefiniertes Verhalten, also hätte es können.)

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


Übrigens, gccs optimiert asm-Ausgabe sieht ziemlich gut aus (unter Verwendung von unsigned long n) : Die innere Schleife, in die es sich einfügt, erfüllt main() diese:

 # 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, Latenzengpass . Parallel dazu erledigt die Out-of-Order-Ausführung alles andere (theoretisch: Ich habe noch nicht mit Leistungsindikatoren getestet, ob es wirklich mit 5c/iter läuft).

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

In ähnlicher Weise liegt das MOV-> SHR, das die RDI-Eingabe von CMOV erzeugt, außerhalb des kritischen Pfads, da es auch schneller als das LEA ist. MOV auf IvyBridge und höher hat keine Latenz (wird beim Umbenennen des Registers verarbeitet). (Es braucht immer noch ein UOP und einen Slot in der Pipeline, es ist also nicht frei, nur keine Latenz). Das 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 durch eine Schleife übertragen, da Steuerabhängigkeiten mit Verzweigungsvorhersage und spekulativer Ausführung behandelt werden, im Gegensatz zu Datenabhängigkeiten auf dem kritischen Pfad.


Den Compiler schlagen

GCC hat hier einen ziemlich guten Job gemacht. Es könnte ein Code-Byte sparen, indem inc edx anstelle von add edx, 1 verwendet wird, da sich niemand um P4 und seine falschen Abhängigkeiten für partielle Anweisungen zum Ändern der Flagge.

Es könnte auch alle MOV-Anweisungen speichern, und TEST: SHR setzt CF = das verschobene Bit, sodass wir cmovc anstelle von test/cmovz verwenden können.

 ### 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 die Antwort von @ johnfound für einen weiteren cleveren Trick: Entfernen Sie das CMP, indem Sie auf das Flag-Ergebnis von SHR verzweigen und es für CMOV verwenden: Null nur, wenn n zu Beginn 1 (oder 0) war. (Unterhaltsame Tatsache: SHR mit count! = 1 auf Nehalem oder früher verursacht einen Stillstand, wenn Sie die Flag-Ergebnisse lesen . 1 spezielle Kodierung ist jedoch in Ordnung.)

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

Sogar auf Haswell kann diese Version Abhilfe schaffen, indem gelegentliche Verzögerungen vermieden werden, bei denen ein unkritischer UOP einen Ausführungsport von einem auf dem kritischen Pfad stiehlt und die Ausführung um einen Zyklus verzögert. (Dies wird als Ressourcenkonflikt bezeichnet.) Es speichert auch ein Register, was hilfreich sein kann, wenn mehrere n Werte 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 ([base+idx+const], für die zwei separate Additionen erforderlich sind), aber nur 1c mit 2 oder weniger Komponenten (eine Addition). 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 die Latenzen, sodass es keine 2c-Ups gibt, sonst wäre 3-Komponenten-LEA nur 2c wie Bulldozer. (3-Komponenten-LEA ist bei AMD ebenfalls langsamer, nur nicht so stark).

Daher ist lea rcx, [rax + rax*2]/inc rcx bei CPUs der Intel SnB-Familie wie Haswell nur eine Latenz von 2 c, also schneller als lea rcx, [rax + rax*2 + 1]. Breakeven auf BD und schlechter auf Core2. Es kostet einen zusätzlichen Durchsatz, der sich normalerweise nicht lohnt, um 1 Cent Latenz zu sparen. Die Latenz ist jedoch der größte Engpass, und Haswell verfügt über eine ausreichend breite Pipeline, um den zusätzlichen Durchsatz zu bewältigen.

Weder gcc, icc noch clang (bei godbolt) verwendeten die CF-Ausgabe von SHR, immer mit AND oder TEST . Dumme Compiler. : P Sie sind große Teile komplexer Maschinerie, aber ein kluger Mensch kann sie oft bei kleinen Problemen besiegen. (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 eine Menge inline Code optimiert würde Sie modellieren die Pipeline auch nicht in der Zielmikroarchitektur, zumindest nicht im gleichen Detail wie [~ # ~] iaca [~ # ~] oder andere statische Analyse-Tools, sie verwenden nur einige Heuristiken.)


Einfaches Abrollen der Schleife hilft nicht ; Diese Schleife hat Engpässe bei der Latenz einer durch die Schleife übertragenen Abhängigkeitskette und nicht beim Overhead/Durchsatz der Schleife. Dies bedeutet, dass es sich gut für Hyperthreading (oder jede andere Art von SMT) eignet, da die CPU viel Zeit zum Verschachteln von Anweisungen aus zwei Threads hat. Dies würde bedeuten, dass die Schleife in main parallelisiert wird, aber das ist in Ordnung, da jeder Thread nur einen Bereich von n Werten prüfen und als Ergebnis ein Paar von ganzen Zahlen 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 belegt und alle das gleiche max/maxi aktualisieren können. Dies erzeugt mehr Parallelität auf Befehlsebene .

Der Trick besteht darin, zu entscheiden, ob gewartet werden soll, bis alle n Werte 1 erreicht haben, bevor ein weiteres Paar von n Startwerten abgerufen wird, oder ob ein neuer Start ausgeführt werden soll Punkt für nur einen, 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 packed-compare stuff tun, um den Zähler für Vektorelemente, bei denen n noch nicht 1 erreicht hat, bedingt zu erhöhen. Und Um die noch längere Latenz einer SIMD-Implementierung mit bedingten Inkrementen zu verbergen, müssten Sie mehr Vektoren mit n Werten in der Luft halten. Vielleicht nur mit 256b-Vektoren (4x uint64_t).

Ich denke, die beste Strategie, um die Erkennung eines 1 "sticky" zu machen, besteht darin, den Vektor aller Einsen zu maskieren, die Sie hinzufügen, um den Zähler zu erhöhen. Nachdem Sie also einen 1 in einem Element gesehen haben, hat der Inkrementvektor 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

    # There may be a better way to do this blend, avoiding the bypass delay for an FP blend between integer insns, not sure.  Probably worth it
    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 nach Möglichkeiten, die Logik zu vereinfachen oder redundante Arbeit zu vermeiden, und implementieren Sie nicht nur dieselbe Logik mit effizienterem asm. z.B. Merken Sie sich, um gemeinsame Enden von Sequenzen zu erkennen. Oder noch besser, sehen Sie sich 8 nachgestellte Bits auf einmal an (gnashers Antwort)

@EOF weist darauf hin, dass tzcnt (oder bsf) verwendet werden kann, um mehrere n/=2 -Iterationen in einem Schritt auszuführen. Das ist wahrscheinlich besser als das Vektorisieren mit SIMD, da dies mit keinem SSE oder AVX-Befehl möglich ist. Es ist jedoch weiterhin kompatibel, mehrere skalare ns in verschiedenen Ganzzahlregistern parallel auszuführen.

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 deutlich weniger Iterationen, bei CPUs der Intel SnB-Familie ohne BMI2 ist die Verschiebung der Variablenanzahl jedoch langsam. 3 Ups, 2C Latenz. (Sie haben eine Eingangsabhängigkeit von den FLAGS, da count = 0 bedeutet, dass die Flags unverändert sind. Sie behandeln dies als Datenabhängigkeit und nehmen mehrere Uops entgegen, da ein Uop nur zwei Eingänge haben kann (ohnehin vor HSW/BDW).) Auf diese Art beziehen sich Leute, die sich über das verrückte CISC-Design von x86 beschweren. Dadurch werden x86-CPUs langsamer, als dies der Fall wäre, wenn die ISA) heute von Grund auf neu entwickelt wurde, und zwar auch auf weitgehend ähnliche Weise (dh dies ist Teil der "x86-Steuer", die Geschwindigkeit kostet). Leistung.) SHRX/SHLX/SARX (BMI2) sind ein großer Gewinn (1 UOP/1C Latenz).

Außerdem wird tzcnt (3c bei Haswell und höher) in den kritischen Pfad versetzt, wodurch die Gesamtlatenz der durch die Schleife übertragenen Abhängigkeitskette erheblich verlängert wird. Die Notwendigkeit einer CMOV oder der Vorbereitung eines Registers, das n>>1 enthält, entfällt jedoch. @ Veedrac's Antwort überwindet all dies, indem sie die tzcnt/shift für mehrere Iterationen verschiebt, was sehr effektiv ist (siehe unten).

Wir können [~ # ~] bsf [~ # ~] oder [~ # ~] tzcnt [~ # ~] austauschbar, da n 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, sodass REP BSF als BSF ausgeführt wird.).

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

Sie verhalten sich bei Intel-CPUs genauso, speichern Sie also einfach das Byte, wenn das alles ist, was zählt. TZCNT unter Intel (vor Skylake) ist genau wie BSF immer noch falsch vom angeblich schreibgeschützten Ausgabeoperanden abhängig, um das undokumentierte Verhalten zu unterstützen, bei dem BSF mit input = 0 das Ziel unverändert lässt. Sie müssen das also umgehen, es sei denn, Sie optimieren nur für Skylake, sodass das zusätzliche REP-Byte nichts bringt. (Intel geht oft über das hinaus, was das x86 ISA Handbuch erfordert, um zu vermeiden, dass häufig verwendeter Code beschädigt wird, der von etwas abhängt, das nicht verwendet werden sollte oder das rückwirkend nicht zulässig ist. ZB Windows 9x geht davon aus, dass keine spekulativen Vorablesezugriffe auf TLB-Einträge durchgeführt wurden, die beim Schreiben des Codes sicher waren , bevor Intel die TLB-Verwaltungsregeln aktualisiert hat .

Wie auch immer, LZCNT/TZCNT auf Haswell haben die gleiche falsche Abhängigkeit wie POPCNT: siehe dieses Q & A . Dies ist der Grund, warum Sie in gccs asm-Ausgabe für @ Veedrac's Code sehen, dass es die Dep-Kette mit xor-zeroing auf dem Register unterbricht, das es als TZCNT-Ziel verwenden soll, wenn es dies nicht tut. ' t benutze dst = src. Da TZCNT/LZCNT/POPCNT ihr Ziel niemals undefiniert oder unverändert lassen, ist diese falsche Abhängigkeit von der Ausgabe auf Intel-CPUs lediglich 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, der von der Software gesehen werden kann, besteht in der Interaktion mit einer anderen Einschränkung der Mikroarchitektur: Sie können einen Speicheroperanden mit einem indizierten Adressierungsmodus in Haswell mikro-fusionieren, in Skylake jedoch, wo Intel das entfernt hat Falsche Abhängigkeit für LZCNT/TZCNT. Indizierte Adressierungsmodi werden "unlaminiert", während POPCNT weiterhin jeden ADR-Modus mikrofusionieren kann.


Verbesserungen an Ideen/Code aus anderen Antworten:

@ hidefromkgbs Antwort hat eine nette Beobachtung, dass Sie nach 3n + 1 garantiert eine richtige Schicht 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 Zählung> 1 undefiniert ist) und langsam: ROR rdi,2 ist schneller als SHRD rdi,rdi,2 und Die Verwendung von zwei CMOV-Anweisungen auf dem kritischen Pfad ist langsamer als ein zusätzlicher TEST, der parallel ausgeführt werden kann.

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

Verbesserte auch das Drucken der Ausgabe, um sie in einen String umzuwandeln und eine write() zu erzeugen, anstatt jeweils ein Zeichen zu schreiben. Dies minimiert die Auswirkung auf das Timing des gesamten Programms mit perf stat ./collatz (um Leistungsindikatoren aufzuzeichnen), und ich habe einige der nicht kritischen asm unscharf gemacht.


@ Veedrac's Code

Ich habe eine sehr kleine Beschleunigung durch Rechtsverschiebung, so viel wir wissen tun müssen, und überprüfe, um die Schleife fortzusetzen. Von 7,5s für Limit = 1e8 bis zu 7,275s auf Core2Duo (Merom) mit einem Abrollfaktor von 16.

code + Kommentare zu Godbolt . Verwenden Sie diese Version nicht mit clang. es macht etwas albernes mit der Verzögerungsschleife. Die Verwendung eines tmp-Zählers k und das anschließende Hinzufügen zu count ändert später die Funktion des Klangs, aber das leicht schadet gcc.

Siehe Diskussion in Kommentaren: Veedrac's Code ist excellent auf CPUs mit BMI1 (d. H. Nicht Celeron/Pentium)

1834
Peter Cordes

Zu behaupten, der C++ - Compiler könne optimaleren Code erzeugen 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 Assembly-Code in der Frage in den inneren Schleifen weit davon entfernt ist, optimal zu sein.

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

Beispielsweise 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 ganze Code sieht so aus:

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 (AMD A4-1200-Prozessor mit 1 GHz) ist der obige Code ungefähr viermal schneller als der C++ - Code aus der Frage (beim Kompilieren mit -O0: 430 ms gegenüber 1900 ms) und mehr als zweimal schneller (430 ms gegenüber 830 ms), wenn der C++ - Code mit -O3 kompiliert wird.

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

97
johnfound

Für mehr Leistung: Eine einfache Änderung stellt fest, dass n nach n = 3n + 1 gerade ist, sodass Sie sofort durch 2 teilen können. Und n wird nicht 1 sein, daher müssen Sie nicht darauf testen. Sie könnten also ein paar 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;
    }
}

Ist hier ein großes Gewinn: Wenn Sie die niedrigsten 8 Bits von n betrachten, werden alle Schritte, bis Sie durch 2 achtmal geteilt werden, vollständig durch diese acht Bits bestimmt. Wenn zum Beispiel die letzten acht Bits 0x01 sind, ist Ihre Zahl in binärer Form ???? 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 werden durch 81k + 1 ersetzt. Ähnliches wird für alle Kombinationen geschehen. 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 n an diesem Punkt zu 1 mit weniger als acht Divisionen durch zwei werden kann, und wenn Sie acht oder mehr Schritte gleichzeitig ausführen, können Sie den Punkt verfehlen, an dem Sie zum ersten Mal 1 erreichen. Setzen Sie dann die "normale" Schleife fort - oder lassen Sie eine Tabelle erstellen, die Ihnen sagt, wie viele weitere Schritte erforderlich sind, um 1 zu erreichen.

PS. Ich vermute sehr, dass Peter Cordes 'Vorschlag 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. Also wäre der Code 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 zu einem Zeitpunkt schneller wäre. Für jedes Bit würde sich die Anzahl der Einträge in der Tabelle verdoppeln, und ich gehe davon aus, dass 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 Divisionen durch zwei und eine variable Anzahl von (3n + 1) Operationen durch. Eine naheliegende Methode zum Zählen der Operationen wäre daher ein anderes Array. Aber wir können 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 das 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. Nimmt man den Logarithmus, so ergibt sich r = (s + log2 (n '/ n))/log2 (3).

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

21
gnasher729

Ziemlich unabhängig: mehr Performance-Hacks!

  • [die erste "Vermutung" wurde schließlich von @ShreevatsaR entlarvt; entfernt]

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

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

    An diesen beiden Elementen vorbeizuspringen bedeutet, (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1 bzw. N >> 2 zu berechnen.

    Wir wollen beweisen, dass es für beide Fälle (1) und (2) möglich ist, die erste Formel (N >> 1) + N + 1 zu verwenden.

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

    (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.

    Q.E.D .: (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. Weitere 2-fache Zeitersparnis.

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 > 2, weil der Prozess bei einer ungeraden Gesamtlänge der Sequenz bei 2 statt bei 1 anhalten kann.

[BEARBEITEN:]

Lassen Sie es uns in Assembly ü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/fehlerbehebte Version des Asms von Peter Cordes auf Godbolt . (Anmerkung der Redaktion: Entschuldigen Sie, dass Sie meine Informationen in Ihre Antwort aufgenommen haben, aber meine Antwort hat das 30-KB-Zeichen-Limit von Godbolt-Links + Text überschritten!)

18
hidefromkgb

C++ - Programme werden während der Generierung des Maschinencodes aus dem Quellcode in Assembly-Programme übersetzt. Es wäre praktisch falsch zu behaupten, Assembly sei langsamer als C++. Darüber hinaus unterscheidet sich der generierte Binärcode von Compiler zu Compiler. Ein intelligenter C++ - Compiler kann 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-/Ruhezustand befindet. Beenden 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.

Beim Collatz-Problem können Sie die Leistung erheblich steigern, indem Sie die "Tails" zwischenspeichern. Dies ist ein Zeit/Speicher-Kompromiss. Siehe: memoization ( https://en.wikipedia.org/wiki/Memoization ). Sie könnten auch nach dynamischen Programmierlösungen für andere Zeit-/Speicher-Kompromisse suchen.

Beispiel 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 __== "__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))
5

Selbst ohne Assembly zu betrachten, ist der offensichtlichste Grund, dass /= 2 wahrscheinlich als >>=1 optimiert ist und viele Prozessoren einen sehr schnellen Schichtbetrieb haben. Aber selbst wenn ein Prozessor keine Verschiebeoperation hat, ist die Ganzzahldivision schneller als die Gleitkommadivision.

Edit: Ihre Laufleistung kann bei der obigen Anweisung "Ganzzahlige Division ist schneller als Gleitkommadivision" variieren. Die folgenden Kommentare zeigen, dass die modernen Prozessoren die Optimierung der fp-Division der Ganzzahldivision vorgezogen haben. Wenn also jemand nach dem wahrscheinlichsten Grund für die Beschleunigung sucht, nach der in diesem Thread gefragt wird, ist es am besten, wenn der Compiler /=2 als >>=1 optimiert.


Wenn n ungerade ist, ist der Ausdruck n*3+1 in einer nicht verwandten Note immer gerade. Es ist also keine Überprüfung erforderlich. 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;
}
4

Aus Kommentaren:

Aber dieser Code hört nie auf (wegen Integer-Überlauf)!?! Yves Daoust

Für viele Zahlen wird es nicht überlaufen.

Wenn es zu einem Überlauf kommt - für einen dieser unglücklichen Anfangssamen, wird die übergelaufene Zahl sehr wahrscheinlich zu 1 konvergieren, ohne dass ein weiterer Überlauf stattfindet.

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

Jede einfache letzte 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 beendet, da shr rax ZF = 1 erzeugt.

Können wir 2 ^ 64 produzieren? Wenn die Startnummer 0x5555555555555555 ist, ist sie ungerade, die nächste Nummer ist dann 3n + 1, was 0xFFFFFFFFFFFFFFFF + 1 = 0 ist. Theoretisch ist der Algorithmus nicht definiert, aber die optimierte Antwort von johnfound wird wiederhergestellt, wenn ZF = 1 beendet wird. Der cmp rax,1 von Peter Cordes endet in einer Endlosschleife (QED-Variante 1, "cheapo" durch undefinierte 0 -Nummer).

Wie wäre es mit einer komplexeren Zahl, die einen Zyklus ohne 0 erzeugt? Ehrlich gesagt bin ich mir nicht sicher, ob meine Mathematiktheorie zu trübe 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 zu 1 konvergiert: 0 <Zahl, da die 3n + 1-Formel jeden Nicht-2-Primfaktor der ursprünglichen Zahl (oder Zwischenstufe) früher oder später langsam in eine Zweierpotenz umwandelt . Wir müssen uns also nicht um Endlosschleifen für Originalserien kümmern, nur ein Überlauf kann uns behindern.

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

Es gibt drei Werte, die zu 0 überlaufen: 227, 170 und 85 (85 gehen direkt zu 0, andere zwei gehen zu 85).

Aber es gibt keinen Wert, um einen zyklischen Überlauf zu erzeugen.

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

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

Leider kann ich diese Serie nicht richtig verstehen, warum konvergiert sie und was bedeutet es, sie auf k Bits zu kürzen, aber mit cmp number,1 Abbruchbedingung Es ist sicherlich möglich, den Algorithmus in eine Endlosschleife zu versetzen, wobei ein bestimmter Eingabewert nach dem Abschneiden mit 0 endet.

Aber der Wert 27, der für 8-Bit-Groß- und Kleinschreibung überläuft, ist eine Art Warnung. Wenn Sie die Anzahl der Schritte zählen, um den Wert 1 zu erreichen, erhalten Sie für die Mehrheit der Zahlen aus der Summe k ein falsches Ergebnis -Bit Menge von ganzen Zahlen. Bei den 8-Bit-Ganzzahlen haben die 146 von 256 Zahlen die Serie durch Abschneiden beeinflusst (einige von ihnen treffen möglicherweise aus Versehen immer noch die richtige Anzahl von Schritten, ich bin zu faul, um das zu überprüfen).

4
Ped7g

Sie haben den vom Compiler generierten Code nicht gepostet, daher gibt es hier einige Vermutungen, aber auch ohne es 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 der div/mod eine ziemlich lange Latenzzeit hat, so dass das Multiplizieren-Addieren "frei" ist) und schließt sich an eine CMOV an. Was natürlich eine Wahrscheinlichkeit von Null Prozent hat, falsch vorhergesagt zu werden.

4
Damon

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. Wie das einmalige statt mehrfache Berechnen von Daten, vollständiges Vermeiden von unnötiger Arbeit, optimales Verwenden von Caches und so weiter. Diese Dinge sind in einer höheren Sprache viel einfacher zu tun.

Wenn Sie Assembler-Code schreiben, ist es möglich, die Arbeit eines optimierenden Compilers zu verbessern, aber es ist harte Arbeit. Und sobald 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 höheren Sprache nicht verwenden können. In diesem Fall ist Inline-Assembly häufig hilfreich, und Sie können dennoch eine höhere Sprache verwenden.

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

3
gnasher729