Die Herausforderung mit einer Milliarde Zeilen
(morling.dev)- Die One Billion Row Challenge (1BRC), die den ganzen Januar 2024 lief, ist eine Performance-Challenge, bei der gemessen wird, wie schnell Java bei der Verarbeitung einer Textdatei mit einer Milliarde Zeilen werden kann.
- Die Eingabe ist einfacher Text im Format
station;temperature, aber es müssen für jede Messstation die Minimal-, Durchschnitts- und Maximaltemperatur berechnet und korrekt nach Namen sortiert ausgegeben werden. - Für die Implementierung ist nur Java erlaubt; die über SDKMan bereitgestellten Distributionen und Early-Access-Builds von openjdk.net dürfen verwendet werden, externe Abhängigkeiten sind jedoch verboten.
- Teilnehmende reichen ihren Beitrag per Pull Request im GitHub-1brc-Repository ein und können die bereitgestellte Referenzimplementierung zum Vergleich von Ausgabeformat und Performance nutzen.
- Die Bewertung erfolgt in derselben Hetzner-Cloud-CCX33-Umgebung: fünf Durchläufe, dann werden Best- und Schlechtestwert gestrichen und aus den übrigen drei Läufen der Durchschnitt für das Leaderboard gebildet.
Die Java-Aufgabe, die eine Milliarde Zeilen am schnellsten aggregiert
- Die One Billion Row Challenge ist eine Java-Performance-Challenge, die vom 1. bis 31. Januar 2024 stattfand.
- Teilnehmende schreiben ein Java-Programm, das Temperaturmesswerte aus einer Textdatei liest und für jede Wetterstation Minimal-, Durchschnitts- und Maximaltemperatur berechnet.
- Der Kern der Schwierigkeit liegt darin, dass die Eingabedatei 1.000.000.000 Zeilen umfasst.
- Die Eingabe hat eine einfache Struktur mit genau einem Messwert pro Zeile.
- Beispiel:
Hamburg;12.0 - Beispiel:
Bulawayo;8.9 - Beispiel:
Palembang;38.8
- Beispiel:
- Die Ausgabe muss die Stationsnamen alphabetisch sortieren und für jede Station die Werte
min/mean/maxanzeigen.- Beispiel:
{Abha=5.0/18.0/27.4, Abidjan=15.7/26.0/34.1, ...}
- Beispiel:
Einreichungsregeln und Ausführungsumgebung
- Ziel ist es, die schnellste Java-Implementierung für dieselbe Aufgabe zu erstellen.
- Für Optimierungen können virtuelle Threads, die Vector API und SIMD, GC-Optimierung, AOT-Kompilierung und Ähnliches genutzt werden.
- Die grundlegenden Regeln sind wie folgt.
- Der Beitrag muss in Java geschrieben sein.
- Java-Distributionen von SDKMan sowie Early-Access-Builds von openjdk.net dürfen verwendet werden.
- Auch EA-Builds von OpenJDK-Projekten wie Valhalla sind erlaubt.
- Externe Abhängigkeiten dürfen nicht verwendet werden.
- Teilnehmende klonen das 1brc-Repository und reichen ihre Implementierung gemäß den Anweisungen in der README ein.
- Die Referenzimplementierung dient als Vergleichsmaßstab und zur Prüfung des korrekten Ausgabeformats.
- Eingereicht wird durch das Öffnen eines Pull Requests im Upstream-Repository.
Berechnung des Leaderboards und Community-Austausch
- Die Bewertung erfolgt auf einer Instanz von Hetzner Cloud CCX33.
- Spezifikation: 8 dedizierte vCPU, 32 GB RAM
- Die End-to-End-Laufzeit wird mit dem Programm
timegemessen. - Jeder Beitrag wird fünfmal hintereinander ausgeführt.
- Der langsamste und der schnellste Lauf werden ausgeschlossen.
- Aus den verbleibenden drei Laufzeiten wird der Durchschnitt als Ergebnis des Beitrags gebildet.
- Das Ergebnis wird dem Leaderboard hinzugefügt.
- Diskussionen über Optimierungstechniken werden in den Discussions des GitHub-Repositories fortgeführt.
- Es gibt außerdem ein Show & Tell zum Teilen von Implementierungen in anderen Sprachen als Java; dort werden etwa 1BRC-Umsetzungen in Rust, Go und C++ vorgestellt.
2 Kommentare
Meinungen auf Hacker News
Die Lösung, die derzeit die beste Performance zu haben scheint [0], berücksichtigt Hash-Kollisionen nicht; wenn es im Datensatz genügend unterschiedliche Städte gibt, dürfte sie falsche Ergebnisse liefern
Ich frage mich, ob ich da etwas übersehe
[0] https://github.com/gunnarmorling/1brc/blob/main/src/main/jav...
Vorerst wurden die Einträge aus der Rangliste entfernt, und die beiden Autoren korrigieren ihre Einreichungen, sodass sie später wieder hinzugefügt werden sollen
[0] https://twitter.com/mtopolnik/status/1742652716919251052
Mit folgendem Ansatz sollte sich das Ganze meiner Ansicht nach in 0,3 Sekunden verarbeiten lassen
Da Temperaturen eine Nachkommastelle haben, reichen im Normalfall etwa 400 Werte; auch Ortsnamen sind mit etwa 400 endlich, sodass man eine Lookup-Tabelle mit rund 160.000 Kombinationen aus Temperatur × Ortsname erstellen kann
Man generiert automatisch eine Zustandsmaschine, die jede dieser 160.000 Kombinationen, egal an welcher Rotationsposition innerhalb eines 4-Byte-Registers sie liegt, auf einen eindeutigen Bucket in einer Hash-Tabelle abbildet, und führt in einem 32-Bit-Zustandsregister pro Zyklus einen Lookup in der Zustandsübergangstabelle sowie ein XOR mit den nächsten 4 Bytes aus
Man läuft mit Speichergeschwindigkeit über den gesamten Datensatz und erhöht die Zähler pro Zustand; da es nur 65K Zustände gibt, passen die Zähler in den Cache
Mit AVX512 könnte man pro Core 512 solcher 32-Bit-Zustandsmaschinen parallel laufen lassen, sodass die Berechnung nicht der Bottleneck wäre
Hohe/niedrige Temperaturen oder unbekannte Ortsnamen, die nicht auf gültige Buckets abgebildet werden, leitet man an langsameren Code weiter; auch die Behandlung von Minimum/Maximum lässt sich über einen solchen Escape abwickeln, was nur ein paar tausend Mal vorkommt
Dieser Ansatz kann schon mit einem einzelnen AVX512-Core mit Speichergeschwindigkeit laufen, daher sehe ich keinen Vorteil darin, ihn auf mehrere Cores aufzuteilen
Benötigt werden lediglich eine Hash-Tabelle mit 400 Einträgen, drei Fließkommawerte für laufendes Minimum, Mittelwert und Maximum sowie ein Integer-Zähler zum Aktualisieren des Mittelwerts
Selbst mit 16 Bytes für den Namen passt das Ganze in 16 KB
Die Laufzeit wird von Ein-/Ausgabe dominiert, danach vermutlich vom JSON-Parsing
Die meisten aktuellen x86-Serverchips können zwei SIMD-Loads pro Takt retire'n, sodass mit AVX2 bei 1 GHz etwa 32 GB/s möglich sind; AVX-512 ist also nicht zwingend nötig, um die Bandbreite pro Core zu maximieren
Wenn man aber aus DRAM liest, wird man viel früher ausgebremst, auf typischen Servern wahrscheinlich irgendwo um 10–16 GB/s
Solange der Großteil der Daten in den RAM ausweicht, bricht der Durchsatz eines einzelnen Cores deutlich ein, und bei großen Streaming-Aufgaben lohnt sich Multi-Core-Parallelität fast immer
Das lässt sich leicht überprüfen, indem man einen Speicherblock allokiert, der deutlich größer als der L3-Cache ist, die Page Faults vorab auslöst und dann in einer engen Schleife entrollte Vektor-Loads (AVX2/AVX-512) ausführt
Außerdem ist fraglich, wie das Zustandsregister interpretiert werden soll. Wenn man es mit den 4 Eingabebytes XORt, kann bei unerwarteten Ortsnamen im Grunde jeder von 4,7 Milliarden möglichen Werten herauskommen
Selbst bei erwarteten Ortsnamen braucht man, wenn sie länger als 4 Bytes sind, nicht jeweils mehrere Zustände, um sie von anderen Namen mit gemeinsamem Präfix zu unterscheiden?
Die Regeln sagen, dass jede Lösung für beliebige UTF-8-Messstationsnamen funktionieren muss, auch wenn der Datengenerator eine feste Menge von Stationsnamen verwendet
Statt den langsamsten und den schnellsten Lauf zu verwerfen und den Durchschnitt der übrigen drei zu nehmen, fände ich es besser, die zwei langsamen Läufe zu verwerfen oder einfach den schnellsten Wert anzuerkennen
Ich sehe keinen stichhaltigen Grund, ein gutes Ergebnis zu verwerfen
Gibt es im Programm aber auch nur eine kleine Quelle von Nichtdeterminismus – was häufiger vorkommt, als man denkt –, ist die Bestzeit wahrscheinlich wenig repräsentativ
Dazu ist https://tratt.net/laurie/blog/2019/minimum_times_tend_to_mis... ein guter Artikel
Wer es mit den Regeln genau nimmt, möchte beim ersten Lauf einen Hintergrund-Daemon starten, die gesamte Datei in den Speicher laden und dort fixieren und dann sogar den Cache vorwärmen, sodass spätere Läufe praktisch nur noch einen linearen Scan machen
Je nachdem, wie weit man die Regeln auslegt, scheint es auch möglich, die Ergebnisse schon beim ersten Lauf vorab zu berechnen; ebenso könnte man die Zahlen vorher in ein dichteres Format parsen und sie in späteren Läufen direkt als laufende Summe einlesen
Das entspricht überhaupt nicht dem Geist des Wettbewerbs, scheint nach den sichtbaren Regeln aber nicht ausdrücklich verboten zu sein
Wenn man Vorabberechnungen nicht mag, wären auch Tricks wie das vorherige Sortieren der Eingabe, Vorab-Parsing oder Komprimieren, Sortieren und eine sortierte Speicheranordnung möglich
Im Extremfall könnte man sogar das Skript
calculate_timepatchen, sodass es 0 Sekunden zurückgibt und für Konkurrenten 9999Zwischen dem Hartcodieren der Antwort in einer Zeile, ohne die Eingabe überhaupt zu lesen, und einer Verarbeitung unter der Annahme, den Dateiinhalt nicht zu kennen, gibt es ungefähr eine Milliarde Stufen in der Grauzone der Vorabberechnung
Am Ende könnte es ein Wettbewerb darüber werden, was faire Vorabberechnung ist und was nicht
Deshalb zeigen Machine-Learning-Wettbewerbe den Teilnehmenden die finalen Daten nicht
Dort steht, dass die Berechnung zur Laufzeit der Anwendung erfolgen muss und dass die Messdatei nicht zur Build-Zeit verarbeitet und das Ergebnis in das Binary eingebrannt werden darf
Ich frage mich, ob das nicht schlicht durch die Festplattengeschwindigkeit begrenzt ist. Ich bezweifle, dass Optimierungen wie SIMD oder Multithreading viel bringen
Es hängt zwar von der Zahl der unterschiedlichen Stationen und der Art der Hash-Lookups ab, aber im Vergleich zu I/O bin ich skeptisch, ob das überhaupt messbar ist
Systeme, die für moderne Hardware entworfen wurden, nutzen genau das aus; redpanda.com, wo ich arbeite, ist ein solches Beispiel
Parsing macht einen großen Teil der Rechenzeit aus, und SIMD-Techniken wie SWAR zum Finden von Trennzeichen können helfen
Wenn man eine saubere Implementierung solcher Algorithmen sehen möchte, ist Stringzilla gut: https://github.com/ashvardanian/StringZilla
Auf den Punkt, dass die Datei nach dem ersten Lauf vollständig im Speicher gecacht ist, habe ich hier geantwortet: https://news.ycombinator.com/item?id=38864034
Typische Server haben genug PCIe-Lanes für 15 solcher SSDs, sodass die I/O-Bandbreite eines Servers in etwa auf dem Niveau der Speicherbandbreite liegt
Teurere Server haben schnellere und mehr Lanes, etwa PCIe 5.0
Diese Datei hat eine Milliarde Zeilen und ist komprimiert etwa 1 GB groß; nach dem ersten, verworfenen Lauf liegt sie im Speicher, daher spielt die I/O-Bandbreite in diesem Szenario keine Rolle
Im GitHub-Repository steht, sie sei unkomprimiert 12 GB groß, aber auch das bestätigt, dass die I/O-Bandbreite nicht entscheidend ist
Die Kernaussage ist, dass die Festplatte nur selten der Engpass ist
Unter Linux mit ext2 zum Beispiel ist es wahrscheinlich, dass nach dem ersten Lauf die ganze Datei gecacht ist; bei ZFS muss das nicht der Fall sein
Dann kommen die Ziffern von der niedrigsten zur höchsten Stelle, danach das Trennzeichen und der String, und man läuft weiter, bis man EOF oder einen Zeilenumbruch erreicht
Laut Regeln müssen Einreichungen für alle Eingaben korrekt funktionieren, aber offenbar dürfen und sollten sie wohl auf die spezifischen Eingaben abgestimmt werden, die von
create_measurements.sherzeugt werdenMan kann sich zum Beispiel eine Einreichung vorstellen, die eine auf die gegebene Stationsmenge zugeschnittene perfekte Hash-Funktion verwendet
So ließen sich überangepasste Optimierungen verhindern
Ein Byte größer als 127 bedeutet ein mehrbyteiges UTF-8-Zeichen
Aus Spaß habe ich einen Geschwindigkeitsvergleich awk gegen Java gemacht
Es ist ein Skript, das mit
awk -F';'pro Station Summe, Anzahl, Minimum und Maximum akkumuliert und am Ende den Durchschnitt berechnet und ausgibtDabei würde man mit
file_fdwdie CSV-Datei als externe Tabelle anlegen und perGROUP BY station_nameMIN,AVGundMAXberechnenIn
clickhouse localwirdfile('measurements.txt', 'CSV', 'station String, t Float32')gelesen, nach Stationen gruppiert undmin,max,avgberechnet, mitmax_threads = 8Der Großteil der Zeit wird für das Parsen der Datei aufgewendet
sumrecht groß werden kann, sollte man besser einen Streaming-Mittelwert verwendenZum Beispiel nach dem Muster
new_mean = ((n*old_mean)+temp)/(n+1)Eine interessante Challenge, aber schade, dass sie nur für Java ist. Ich freue mich auf den Moment, in dem Leute anfangen, JVM-Bytecode direkt von Hand zu erstellen
[0] https://github.com/gunnarmorling/1brc/discussions
Macht Spaß. Fühlt sich an wie die Nachbereitung zu Advent of Code
Wenn es ein fairer Vergleich zwischen Sprachen sein soll, müssten auch make und die Build-Zeit einbezogen werden. Ich habe Java/Maven seit ein paar Jahren nicht mehr benutzt, aber als ich sah, dass der Download bei
./mvnw clean verifynach zwei Minuten immer noch lief, fiel mir wieder ein, warumUnd als Build-Tool für inkrementelle Kompilierung ist Gradle schneller
Außerdem müsste man einen angemessenen Anteil der Zeit hinzurechnen, die es braucht, Programmieren zu lernen
Bei solchen Challenges würde wahrscheinlich eine sehr naive Version gewinnen; das ist meiner Meinung nach nicht nur unrealistisch, sondern widerspricht auch dem Sinn der Challenge
cleanausführtDas ist, als würde man den Cache wegwerfen und sich dann beschweren, dass es langsam ist
Es heißt, dass keine externen Abhängigkeiten verwendet werden dürfen
An der Technischen Universität Tschechien gab es in einem C-Kurs eine sehr ähnliche Aufgabe
Alle Einreichungen der Studierenden wurden fortlaufend auf einer Rangliste bewertet, und viele Studierende steckten Dutzende Stunden in Optimierungen, um Zusatzpunkte für bessere Ergebnisse zu bekommen – praktisch Statuspunkte
Der Erstplatzierte schafft es in 6 Sekunden … beeindruckend.