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 diex86 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 r64
es 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, 1
macht 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 r32
beträgt 9 Uops, 22-29c Latenz und einen pro 8-11c Durchsatz bei Haswell.
Wie Sie aus der -O0
asm-Ausgabe von gcc ( Godbolt-Compiler-Explorer ) ersehen können , werden nur Verschiebungsanweisungen verwendet . clang -O0
kompiliert 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_13
im 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
). -O0
Geschwindigkeit 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=bdver3
und g++ -O3 -march=skylake
wird 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 n
hat 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 n
dies 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 long
nur 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 edx
anstelle 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 cmovc
anstelle 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 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 (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 rcx
ist 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 n
und 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 n
Werte erreicht sind, 1
bevor Sie ein weiteres Paar von n
Startwerten 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 n
noch 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 n
in 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 1
in 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/=2
Iterationen 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 n
parallelen 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 n
es 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_ctzll
sogar 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,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 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 k
und ihn count
spä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).