- Der Engpass bei 1BRC bestand darin, eine Milliarde CSV-Temperaturwerte extrem schnell zu parsen; Quân Anh Mais merykitty-SWAR-Code erregte Aufmerksamkeit, weil er Temperaturen ohne
if mit festen ALU-Operationen in Ganzzahlen umwandelt
- Der Code nutzt SWAR (SIMD Within A Register): Er verarbeitet die 8 Bytes in einem einzelnen
long auf einmal und behandelt mehrere Zeichen in normalen CPU-Registern quasi parallel
- Der Ablauf besteht aus Erkennen des Minuszeichens, Entfernen des Vorzeichens, Finden der Dezimalpunktposition, Ausrichten auf
XY.Z, Umwandeln von ASCII-Ziffern, Magic Multiplication und Anwenden des Vorzeichens
- Das Eingabeformat kann
-XX.X, -X.X, X.X oder XX.X sein; anhand der Position des Dezimalpunkts werden Bytes verschoben, sodass unterschiedlich lange Eingaben in dasselbe Bitlayout passen
- Statt Verzweigungen und Schleifen nutzt die Implementierung die Eigenschaften von ASCII-Codes, Zweierkomplement, Bitmasken und die Shift-Add-Eigenschaft von Multiplikation sehr dicht aus, um High-Performance-Parsing zu erreichen
Temperatur-Parsing als Engpass bei 1BRC
- Bei der One Billion Row Challenge (1BRC) wurde das extrem schnelle Parsen von Temperaturwerten aus einer CSV-Datei zum zentralen Engpass
- Schon mit früheren Optimierungen wurde idiomatischer paralleler Java-Code von 71 Sekunden auf 1,7 Sekunden beschleunigt
- Das Temperaturformat ist einfach, aber wenn eine Milliarde Werte in unter einer Sekunde geparst werden sollen, summieren sich selbst kleine Kosten stark
- Mögliche Formate sind
-XX.X, -X.X, X.X, XX.X
- Frühe Teilnehmer verwendeten
Double.parseDouble(), später erschienen jedoch eigene Parser ohne Schleifen
- Ein Teil der Lösung von Quân Anh Mai @merykitty verarbeitete die Daten ohne
if und mit einem einzigen Dateilesedurchlauf und verbreitete sich wie ein Standardbaustein der besten 1BRC-Lösungen
- Der Gewinner Thomas Wuerthinger nennt Quân Anh ausdrücklich als Teil des Teams, das zu seiner Lösung beigetragen hat
Was der merykitty-Code macht
- Der Code nimmt ein
long mit 8 Bytes CSV-Eingabe entgegen und gibt den ganzzahligen Temperaturwert zurück, also die tatsächliche Temperatur mal 10
- Die Eingabe kommt über direkte Native-Memory-Lesezugriffe aus einer per
mmap gemappten CSV-Datei; dieser Teil ist als separates Thema ausgeklammert
- Die Berechnung besteht aus 18 ALU-Operationen in fester Reihenfolge
- Bit-Shifts, AND, NOT, XOR
- Addition, Subtraktion, Multiplikation
Long.numberOfTrailingZeros()
numberOfTrailingZeros() verwendet über ein JDK-Compiler-Intrinsic spezielle CPU-Instruktionen
- Da mehrere Bytes nicht mit speziellen SIMD-Instruktionen, sondern mit normalen CPU-Registern und Befehlen verarbeitet werden, handelt es sich um SWAR
- Der Beispielcode ist gegenüber dem Original leicht angepasst, um ihn lesbarer zu machen; das Original findet sich in CalculateAverage_merykitty.java
Gesamter Ablauf
- Der Code parst die Temperatur in folgender Reihenfolge
- Prüfen, ob das erste Zeichen
- ist, um einen negativen Wert zu erkennen
- Falls ein Vorzeichen vorhanden ist, dieses Byte auf 0 setzen
- Position des Dezimalpunkts
. finden
- Bits innerhalb des
long so verschieben, dass die Ziffern zum Template XY.Z passen
- ASCII-Zeichen in tatsächliche Zahlenwerte umwandeln
- Die einzelnen Stellen mit den Gewichten
1x, 10x, 100x multiplizieren und aufsummieren
- Am Ende das Vorzeichen anwenden
- Oberflächlich betrachtet ist es ein High-Level-Parsing-Problem, aber jeder Schritt wird ausschließlich mit ALU-Operationen umgesetzt
Schritt 1: Minuszeichen erkennen
- Die Vorzeichenerkennung beginnt mit folgendem Code
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
- Wenn man die Reihenfolge zur Erklärung umstellt, kann man es sich wie
( ~(inputData << 59) ) >> 63 vorstellen
- Ausgenutzt wird die Eigenschaft, dass das Minuszeichen
- in ASCII Bit 4 auf 0 hat, während Ziffern an dieser Stelle eine 1 haben
- Wird die Eingabe um 59 Bit nach links verschoben, wandert dieses Unterscheidungsbit des ersten Zeichens in das höchstwertige Bit
- Nach dem NOT und einem arithmetischen Rechts-Shift um 63 Bit wird das höchstwertige Bit über das gesamte
long verteilt
- Das Ergebnis
broadcastSign besteht bei einem Minuszeichen nur aus Einsen, andernfalls nur aus Nullen
Schritt 2: Vorzeichenzeichen entfernen
- Da die Information über ein negatives Vorzeichen in
broadcastSign gespeichert ist, wird das Vorzeichenzeichen aus den Eingabedaten entfernt
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
- Wenn
broadcastSign nur aus Einsen besteht, setzt broadcastSign & 0xFF nur die niedrigsten 8 Bit auf 1
- Das NOT daraus erzeugt eine Maske, bei der nur die niedrigsten 8 Bit 0 sind
- Per AND mit
inputData wird das - im niedrigstwertigen Byte entfernt
- Gibt es kein Minuszeichen, ist
broadcastSign 0; die Maske besteht dann vollständig aus Einsen, sodass die Ziffernbytes erhalten bleiben
Schritt 3: Position des Dezimalpunkts finden
- Die Position des Dezimalpunkts wird mit folgendem Code berechnet
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
- Auch das Zeichen
. hat wie das Minuszeichen die Eigenschaft, dass Bit 4 auf 0 steht
- Um nur Bit 4 an den möglichen Positionen des Dezimalpunkts zu prüfen, wird die Maske
DOT_DETECTOR = 0x10101000 verwendet
- In der invertierten Originaleingabe
negatedInput ist dieses Bit an der Dezimalpunktposition auf 1 gesetzt
Long.numberOfTrailingZeros() gibt die Position dieses 1-Bits zurück
- Im Beispiel
-10.8 liegt der Dezimalpunkt an Bitposition 28, also wird dotPos = 28
Schritt 4: Auf ein festes Template ausrichten
- Anhand der Position des Dezimalpunkts wird die Eingabe nach links verschoben, damit sie immer zum selben Template passt
long alignedToTemplate = withSignRemoved << (28 - dotPos);
- Das Ziel-Template sieht so aus
0 0 0 Z . Y X 0
- Dabei ist
X die Zehnerstelle, Y die Einerstelle und Z die erste Nachkommastelle
0 bedeutet hier nicht das ASCII-Zeichen "0", sondern ein Byte mit dem Wert 0
- Nach Entfernen des Vorzeichens kann die Eingabe in einer von vier Anordnungen vorliegen
0 0 0 Z . Y X 0
0 0 0 0 Z . Y 0
0 0 0 0 Z . Y X
0 0 0 0 0 Z . Y
-10.8 hat bereits dotPos = 28, daher beträgt die Verschiebung 0
- Bei
-7.7 liegt der Dezimalpunkt an Bit 20, daher wird um 8 Bit, also ein Byte, nach links verschoben, sodass an der Position X eine 0 steht
Schritt 5: ASCII-Ziffern in Werte umwandeln
- Nach dem Ausrichten bleiben aus den ASCII-Zeichen nur die Ziffernwerte übrig
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
- Die ASCII-Ziffern
0 bis 9 reichen hexadezimal von 0x30 bis 0x39
- Wenn nur die unteren 4 Bit erhalten bleiben, wird aus dem Zeichencode der tatsächliche Zahlenwert
- Dafür wird eine Maske angewendet, die nur an den Ziffernpositionen des Templates
F enthält
0 0 0 Z . Y X 0
000000F000F0F00
- Im Beispiel
-10.8 bleiben nach Anwendung der Maske nur die Werte übrig, die Z=8, Y=0, X=1 darstellen
Schritt 6: Stellenwerte per Magic Multiplication aufsummieren
- Der endgültige Absolutwert muss als
100 * X + 10 * Y + Z berechnet werden
- Ausgenutzt wird die Eigenschaft, dass Multiplikation eine Kombination aus Shifts und Additionen ist, um die Gewichtung mehrerer Stellen mit einer einzigen Multiplikation zu erledigen
- Betrachtet man zunächst
X + Y + Z, kann man durch Addieren von um 0, 16 und 24 Bit verschobenen Varianten von digits die Summe in einem bestimmten Bitbereich sammeln
- Diese Shift-Add-Kombination lässt sich als folgende Multiplikation ausdrücken
0x1 + 0x10000 + 0x1000000
- Tatsächlich haben die einzelnen Stellen unterschiedliche Gewichte, daher ist
MAGIC_MULTIPLIER so aufgebaut
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
0x3FF ist eine Maske, die nur das 10 Bit breite Ergebnis isoliert
100 * X kann bis zu 10 Bit groß werden und sich mit benachbarten Bits überlappen, aber weil die unteren zwei Bits von Y * 100 0 sind, steht der nötige Bitraum zur Verfügung
- merykitty hinterließ an dieser Stelle den Kommentar
// That was close :)
Schritt 7: Vorzeichen ohne Verzweigung anwenden
- Zu diesem Zeitpunkt liegen der Absolutwert
absValue und die Vorzeicheninformation broadcastSign vor
broadcastSign verhält sich bei positiven Zahlen wie 0 und bei negativen wie -1
- Im Zweierkomplement wird eine negative Zahl so dargestellt
-n = NOT(n) + 1
- XOR kann wie ein bedingtes NOT verwendet werden
n XOR -1 ist NOT(n)
n XOR 0 ist n
- Das optionale
+1 wird durch -broadcastSign erledigt
temperature = (absValue ^ broadcastSign) - broadcastSign;
- Dadurch bleibt ein positiver Wert ohne
if unverändert, während ein negativer Wert in seinen negativen Zweierkomplementwert umgewandelt wird
Bonus: Startposition der nächsten CSV-Zeile berechnen
- In der vollständigen 1BRC-Lösung muss auch die Startposition der nächsten CSV-Zeile günstig berechnet werden
- Auf den Dezimalpunkt folgen immer eine Nachkommastelle und ein Zeilenumbruch, daher lässt sich die Startposition der nächsten Zeile aus der Position des Dezimalpunkts ableiten
- Da
dotPos eine Bitposition ist, wird zum Teilen durch 8 ein Rechts-Shift um 3 Bit verwendet
nextLineStart = (dotPos >>> 3) + 3;
+3 ist der Wert, der auf das erste Byte nach Dezimalpunkt, einer Nachkommastelle und Zeilenumbruch zeigt
Fazit
- merykittys SWAR-Code vereinheitlicht und parst vier Temperatur-Stringformate allein mit festen Bitoperationen
- Kernpunkte sind die Biteigenschaften von ASCII-Codes, das Ausrichten anhand der Dezimalpunktposition, das Extrahieren der Ziffern per Maske, das Aufsummieren der Stellenwerte per Multiplikation und die Vorzeichenanwendung auf Basis des Zweierkomplements
- Zerlegt man ihn in einzelne Schritte, lässt sich die Funktionsweise nachvollziehen; beeindruckend bleibt jedoch, dass diese Kombination innerhalb weniger Tage eines Online-Challenges entstand
1 Kommentare
Hacker-News-Kommentare
Vor über zwei Jahren habe ich festgestellt, dass byte array view var handle in Java/Scala ziemlich gut geeignet ist, um effiziente SWAR-Routinen zu erstellen.
Beispiele für den Einsatz von SWAR gibt es auch hier viele, etwa beim Parsen von Base16/64-Strings,
java.time.*oder beim direkten Parsen numerischer Werte aus Byte-Arrays: https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...Der große Wert eines praxiserprobten Parsers liegt in effizienter Fehlerprüfung und -wiederherstellung.
Und ich frage mich auch, wie viel Aufwand nötig wäre, um das so zu erkennen, dass ein Sentinel-Fehlerwert zurückgegeben wird, wie es zum aktuellen Code-Stil passt.
Nicht interessant genug, um es selbst auszuprobieren, aber ;-)
MULzu verschieben/zu addieren, ist recht bekannt.Siehe Lemires Artikel: https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
Paper: https://arxiv.org/abs/1902.08318
Github: https://github.com/simdjson/simdjson
Außerdem wurde beim offiziellen 1BRC ausdrücklich festgelegt, dass die Ergebnisse von einer RAM-Disk bewertet werden, um die I/O-Geschwindigkeit vollständig auszuklammern: https://github.com/gunnarmorling/1brc?tab=readme-ov-file#eva...
„Programs are run from a RAM disk (i.o. the IO overhead for loading the file from disk is not relevant)“
Soweit ich es grob verstehe, wird eine große Textdatei sequenziell in L1 geholt und jeder Wert einmal gelesen. Auf den meisten Prozessoren kann man solche Lesezugriffe zweimal pro Zyklus ausführen. Der langsame Teil wäre, die Daten aus dem RAM nach L1 zu holen, aber sequenzielles Lesen ist ziemlich schnell.
Danach wird jeder gelesene Wert verarbeitet. Auf den ersten Blick dürften es in einer optimierten Version ungefähr 4 Zyklen sein. Anschließend muss das Ergebnis irgendwohin geschrieben werden, und vermutlich braucht es davor ein oder zwei zufällige Lesezugriffe. Siehst du diesen Teil als I/O-Flaschenhals?
Ich will nicht sagen, dass klar ist, dass die CPU begrenzt; aber es scheint auch nicht offensichtlich, dass sie es nicht ist.
Edit: Ich habe nicht bedacht, dass „Festplatten-I/O“ gemeint gewesen sein könnte. Wie andere gesagt haben, spielt das hier faktisch keine Rolle.
Das heißt, alle Daten liegen im RAM, genauer gesagt im Page Cache.
Wenn ich mich richtig erinnere, war die Behandlung von Overflows knifflig. Dieser Artikel gefällt mir wirklich sehr.
Es gibt immer noch Leute, die CPUs tatsächlich programmieren können und verstehen, was sie tun.
Das eigentliche Rätsel ist, dass der großen Mehrheit der Menschen, die sich Programmierer nennen, offenbar ein tiefes Verständnis fehlt – und dass sie nicht einmal zu merken scheinen, wie gravierend dieser Mangel ist.
Dass sie in der Praxis gut funktioniert, sieht man an der C#-Lösung, die unter den bisher veröffentlichten 1BRC-Beiträgen offenbar die schnellste ist: https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
Die Frage ist, ob die Kosten für den Aufbau des Anfangsvektors und das Extrahieren der Ergebnisse nicht zu hoch sind.
Allerdings bezweifle ich, dass HotSpot das von selbst hinbekommt; außerdem wurden die meisten 1BRC-Einreichungen mit Graal ausgeführt, um den Start-Overhead zu reduzieren.
In einfachem SSE2 gibt es keine 32-Bit- oder 64-Bit-Multiplikation, daher ist 32×32→64-Bit-Multiplikation ein Problem. SSE4.1 fügt mit
pmuldqgenau das hinzu, was man braucht. Da das Ergebnis aber 64 Bit breit ist, braucht man zwei solcher Operationen, um einen ganzen Vektor aus 32-Bit-Integern zu verarbeiten.Außerdem ist das Temperaturfeld variabel lang; selbst wenn es spaltenweise gespeichert wäre, würde es vermutlich keinen Vorteil bringen.
Beim Finden des Trennzeichens zwischen Name und Temperatur wurde SSE allerdings erfolgreich eingesetzt.