- Das bei großen Ganzzahloperationen auftretende Carry-Problem (Übertrag) ist ein Hauptgrund dafür, dass sich Operationen nur schwer parallelisieren lassen
- In der x86-Architektur ist die
adc-Instruktion zur Carry-Verarbeitung langsamer als die normale add-Instruktion, und aufeinanderfolgende Carry-Verarbeitung begrenzt die parallele Ausführung
- Mit der Radix-2^51-Darstellung lässt sich die Carry-Weitergabe verzögern, sodass mehr Additionen schnell ausgeführt werden können
- Jedem Limb (Teilwert) werden nur 51 oder 52 Bit zugewiesen, sodass der verbleibende obere Bitbereich als temporärer Carry-Speicher genutzt werden kann
- Diese Technik liefert trotz zusätzlicher Registerverwendung und Umwandlungskosten in der Praxis eine bessere Performance als die Radix 2^64
Schnelle Addition und Subtraktion: das Carry-Problem
- Bei der Addition großer Ganzzahlen ist es wie beim händischen Rechnen mit Überträgen: Auch für Computer erschwert Carry die Parallelisierung des Additionsalgorithmus
- Grundsätzlich addieren wir von rechts (niedrigwertige Stellen) nach links, wobei ein an jeder Stelle entstehender Carry an die nächste höhere Stelle weitergegeben wird
- Würde man stattdessen von links mit der Addition beginnen, beeinflusst der vorherige Carry die Berechnung der nächsten Stelle, sodass sich die Reihenfolge der Operationen nicht parallelisieren lässt
Carry-Verarbeitung im Computer
- Computer führen Additionen in Einheiten von 64-Bit-Ganzzahlen aus
- Eine 256-Bit-Ganzzahl lässt sich zwar scheinbar in vier 64-Bit-Limbs zerlegen und parallel addieren, tatsächlich muss aber Overflow (Carry) verarbeitet werden, damit das Ergebnis korrekt ist
- x86 besitzt mit
adc (add with carry) eine Instruktion, die die Carry-Verarbeitung automatisch übernimmt
Ursache des Performance-Verlusts
- Die
adc-Instruktion benötigt mit dem Carry-Flag einen zusätzlichen Eingabewert und ist daher im Vergleich zu einfachem add weniger performant
- In der Haswell-Architektur kann
add parallel über mehrere Ports ausgeführt werden, während adc zwangsläufig seriell ausgeführt werden muss
- Besonders bei SIMD-Instruktionen (
vpaddq usw.) ist parallele Addition ohne Carry deutlich schneller
Die Idee, Carry zu verzögern (Beispiel auf Papier)
- Um Carry zu reduzieren, kann man den Stellenbereich erweitern (z. B. von 0-9 auf insgesamt 37 Stellen mit 0-9, A-Z und *), sodass sich vorübergehend mehrere Zahlen ohne Carry addieren lassen
- So können mehrere Additionen ohne Carry-Weitergabe durchgeführt und der Carry erst am Ende in einem Schritt bereinigt (normalisiert) werden
- Dieses Konzept trennt die Akkumulation und Weitergabe von Carry und sorgt dafür, dass Carry erst im Endschritt verarbeitet wird
- Wenn in der tatsächlichen Berechnung Werte oberhalb der Stellenbasis entstehen, wird der Carry anschließend von rechts nach links der Reihe nach übernommen
Anwendung der Carry-Verzögerung im Computer (Radix-2^51-Trick)
- Um die Carry-Weitergabe im Computer zu verringern, wird eine Radix-2^51-Darstellung verwendet
- 256 Bit werden nicht in vier 64-Bit-Limbs, sondern in fünf Limbs zu je 51 bis 52 Bit aufgeteilt
- Die oberen 12 bis 13 Bit jedes Limb dienen als temporärer Carry-Speicher
- Bei dieser Methode bleibt pro Limb zwar der Wertebereich von 2^64 erhalten, in der eigentlichen Berechnung tritt Carry aber nicht so leicht auf, sodass mehrere Operationen ohne Carry parallel ausgeführt werden können
- Nach etwa 2^13 aufeinanderfolgenden Operationen ist einmalig eine Normalisierung (
normalization) erforderlich
- Auf Haswell-CPUs verbessert die Anwendung von Radix 2^51 die Performance gegenüber gewöhnlicher Radix 2^64 deutlich, weil mehrfach nur einfache Additionen ohne Carry ausgeführt werden
- Die Carry-Propagation zur Normalisierung wird nur einmal am Ende durchgeführt
Codebeispiel
- Werte werden auf fünf Register verteilt, sodass Additionen ohne Carry möglich sind
- Bei der Normalisierung werden die oberen Bits jedes Limb extrahiert, zum benachbarten Limb addiert und der eigene Carry-Wert dabei wieder auf 0 gesetzt
Erweiterung auf Subtraktion
- Dasselbe Verfahren lässt sich ähnlich auch auf Subtraktion anwenden
- Dabei kann Carry auch negativ werden, weshalb Limbs als signed integer behandelt werden
- Das höchstwertige Bit des Limb dient als Vorzeichenbit, wodurch sich die Anzahl der in einem Schritt verarbeitbaren Operationen gegenüber der Addition leicht verringert
Fazit
- Die Technik der Carry-Resistenz bzw. Carry-Verzögerung verbessert die Gesamtperformance real deutlich, obwohl sie mehr Limbs und zusätzliche Umwandlungsschritte erfordert
- Der Radix-2^51-Trick wird breit in Bereichen mit hohen Performance-Anforderungen wie großen Ganzzahloperationen und Kryptografie eingesetzt
- Diese Technik ist eine wichtige Idee zur Optimierung der realen Performance von Hardware und Algorithmen
1 Kommentare
Hacker-News-Kommentare
Als ich die Zahl 2^51 sah, dachte ich zunächst, es gehe um das Speichern von Ganzzahlen im
double-Typ, bemerkte dann aber, dass sich Integer tatsächlich bis2^53-1exakt indoubledarstellen lassenAVX512 (und zu einem gewissen Grad auch AVX2) bietet ein Umfeld, in dem sich 256-Bit-Additionen recht effizient implementieren lassen, mit dem zusätzlichen Vorteil, mehr Zahlen in Registern unterbringen zu können
Ein direktes Beispiel funktioniert wie im folgenden Code
Es zeigt sich sogar eine Verbesserung des Durchsatzes; ein reales Codebeispiel ist auf godbolt.org zu sehen
Diese Logik auf 512-Bit-Addition zu erweitern, ist ebenfalls sehr einfach
Besonders auf bestimmten Intel-CPU-Architekturen kann schon die bloße Verwendung von AVX512-Befehlen den gesamten Prozessortakt senken, was zu inkonsistenter oder sogar insgesamt schlechterer Performance führen kann
Einen relevanten Hinweis dazu gibt es im Stackoverflow-Link
Auf die Frage „Warum nicht 12 Bit statt 13 Bit?“: Hier wird die Carry-Behandlung des höchstwertigen Bits (Limb) ignoriert, sodass es bei Overflow als Wraparound arbeitet
Dadurch erhält das höchstwertige Limb 52 Bit, was den Nachteil hat, dass ihm früher als den anderen Limbs der Platz ausgeht, aber es verhält sich ähnlich wie die Addition vorzeichenloser Integer in C
Dann kam der Vorschlag: Warum nicht dem obersten Limb 64 Bit und den übrigen vier Limbs jeweils 48 Bit zuweisen?
Damit könnte man vor der Normalisierung mehr Operationen ansammeln und hätte Vorteile wie Wortausrichtung
Das Overflow-Verhalten wäre dasselbe
Wenn nur dem obersten Limb 64 Bit zugewiesen werden, tritt beim Addieren der Limbs zweier Zahlen zu schnell Overflow auf
Sind zum Beispiel beide
2^63, kommt es sofort zum OverflowBei Wraparound-Arithmetik ist das egal, im allgemeinen Fall aber nicht praktikabel
Mit einer solchen Struktur wären statt wie im OP 5 eher 6 Wörter nötig
Außerdem wären mehr Befehle erforderlich
Das Ziel ist, 256-Bit-Arithmetik mit 5 64-Bit-Registern zu lösen
Also wäre eine Verteilung von
256/5 = 51.2Bit pro Wort idealFür genau 256 Bit ist das in Ordnung, für eine allgemeine Big-Int-Bibliothek aber nicht optimal
Früher gab es den Hintergrund, für ein Carry genau 1 Byte zu verwenden, und in Zeiten ohne Barrel Shifter zog man es vor, zur Ausrichtung nur 56 von 64 Bit zu nutzen
Bei RISC-V ist diese Diskussion noch wichtiger, weil es dort hardwareseitig keine Flags gibt
Auf modernen x86-CPUs (z. B. Intel Broadwell, AMD Ryzen) kann die Verwendung von Intel ADX-Befehlen sogar dort schneller sein, wo die Darstellung in Radix
2^51traditionell stark war, etwa bei Curve25519Als verwandtes Diskussionsmaterial
Die zentrale Lehre ist: Wenn Operationen voneinander unabhängig sind, kann es schneller sein, mehr davon parallel auszuführen
Umgekehrt können bei Abhängigkeiten, die eine serielle Ausführung erzwingen, sogar weniger Operationen langsamer sein
Diese Idee lässt sich nicht nur auf lange Integer-Arithmetik, sondern auch auf viele andere Bereiche anwenden
Es wurde vorgeschlagen, in 64-Bit-Chunks zu zerlegen und je nach möglichem Carry zwei Fälle vorab parallel auszuführen, um danach anhand des tatsächlichen Carry-Ergebnisses die richtige Rechnung auszuwählen
Dabei verdoppelt sich die Anzahl der Additionen, aber die Ausbreitungsgeschwindigkeit sinkt auf
log(bits)statt linearDer Punkt, den ich an dieser Technik zunächst nicht gut verstanden hatte, ist, dass sie das Ripple-Carry, das beim Addieren von
NWerten eigentlichN-1Mal ausgeführt wird, im Wesentlichen auf ein einziges Mal reduziertDie Carry-Behandlung selbst wird komplexer, aber die Additionen lassen sich parallelisieren
Allerdings muss auch das Aufteilen der Eingabezahlen in Blöcke zu je 5 Registern parallelisierbar sein, damit die Gesamteffizienz wirklich zählt
Diese Regel lässt sich bis auf Supercomputer oder Cloud-Umgebungen mit zehntausenden Knoten ausdehnen
Wenn viele Kerne verfügbar sind, ist der Overhead vernachlässigbar
Auch NVidia interessiert sich für diese Idee, und sie liefert in vielen Bereichen gute Ergebnisse
Es gibt zwar HN-Richtlinien dagegen, der Überschrift Meinungen hinzuzufügen, aber ich mag übertrieben reißerische Clickbait-Titel auch nicht
Etwas wie „Radix-
2^51-Trick für parallele 64-Bit-Integer-Addition ohne Carry-Abhängigkeit auf einigen x86-Architekturen“ wäre meines Erachtens präziserSchade, dass ich diesen Beitrag nicht schon ein paar Monate früher gelesen habe
Beim Kodieren/Dekodieren eines Puffers in eine beliebige Basis habe ich erlebt, wie sich Carry über den gesamten Puffer ausbreitet und den Algorithmus stark verlangsamt
Am Ende habe ich die Chunks mit etwas „Luft“ dazwischen aufgeteilt, um Carry zu behandeln; das scheint dieser Technik zu ähneln
Praktisch habe ich mich dafür entschieden, einige Bits zu verschwenden, um Rechenaufwand oder Netzwerkbandbreite zu sparen
Ich frage mich, ob sich eine solche Carry-Behandlung ebenfalls als Nachbearbeitung bündeln lässt
Die Hoffnung wäre eine Struktur, die praktisch alle Vorteile mitnimmt
Nach meinen Erfahrungen ausschließlich auf x86_64 zeigt das sehr klar, dass das Fehlen eines Carry-Flags bei RISC-V nicht zwangsläufig ein schlechter Ansatz ist
uint64_t-Variablen carry-sichere Addition machtDer Ablauf sieht etwa so aus
Entscheidend ist: Wenn das Additionsergebnis (Limb) nicht vollständig aus Einsen besteht, hängt sein Carry-out nicht vom Carry-in ab, sondern nur vom Ergebnis der ursprünglichen Addition
Wenn der Wert dagegen nur aus Einsen besteht, dann gilt Carry-out = Carry-in
Bei einer Verzweigungsstruktur, die fast keine Vorhersage benötigt, ist das vollständig parallel ausführbar
Statistisch verlangsamt es nur in
1/2^64der Fälle, bringt aber auf 4-fach breiten Maschinen keinen großen VorteilAuf 8-fach breiten Maschinen oder bei einer Struktur mit 8 Limbs ist ein sinnvoller Performancegewinn möglich
Für x86_64 passt es nicht, aber auf 8-fach breiten Maschinen wie Apples M*-Serie könnte es nützlich sein
Bei Tenstorrents 8-fach breitem RISC-V-Ascalon-Prozessor, Ventana, Rivos, XiangShan usw. gibt es dafür Potenzial in der Zukunft
Auf SIMD-Architekturen oder Architekturen mit schnellen
1-lane shift(slideup)-Befehlen ist der Effekt am größtenCarry-Save-Addition ist nicht immer besser als Add-with-Carry
Die beiden Arten von Multi-Word-Additionsalgorithmen sind nicht gegenseitig austauschbar und haben jeweils eigene Vor- und Nachteile
Deshalb gehören
ADC/SBB-Befehle zur Grundausstattung einer ISA, und Flags können auch registerbasiert gespeichert werdenEin schwerwiegenderer Nachteil bei RISC-V ist das Fehlen eines Integer-Overflow-Flags
Wenn zur Overflow-Erkennung Software-Workarounds nötig sind, ist der Performanceverlust deutlich größer als bei einem Workaround für das Carry-Bit
Dass RISC-V kein Carry-Flag hat, leitet sich daraus ab, dass C ein binäres Carry-Flag ignoriert
Tatsächlich wird ein Carry-Flag viel seltener genutzt
Ich war also nicht der Einzige, der dachte: „Wenn das Carry-Flag ohnehin langsam ist, warum gab es dann diese risc5-gmp-Kontroverse?“
Der „Radix-Trick“ lässt sich auch auf Datenstrukturen anwenden
In Okasakis Buch Purely Functional Data Structures gibt es ebenfalls interessante Beispiele