Die Zeit einer Datenpipeline verteilt sich auf drei Orte: Lesen und Dekodieren der Daten, die Arbeit an jeder Zeile und Wartezeit (auf Speicher, auf eine Sperre oder auf einen ausgelasteten Thread). Das Dekodieren ist normalerweise bereits in den Bibliotheken optimiert. Die Arbeit an jeder Zeile ist das, was Sie schreiben, und dort kostet eine nachlässige Schleife das 2–3-fache.
Der Großteil dieser Datei handelt von dem Code, der die Batches eines Scans konsumiert. Für den darunterliegenden Leser, Schreiber oder Codec und für die Ermittlung einer vertrauenswürdigen Zahl siehe die letzten beiden Abschnitte und deren Referenzen.
Zuerst messen, wo die Zeit bleibt
Sie haben selten eine zweite Implementierung (Ray, DuckDB, pyarrow) zum Vergleich. Nutzen Sie stattdessen den Scan selbst als Maßstab:
- Zeitmessung des Scans allein. Führen Sie denselben Scan mit denselben Spalten, demselben Filter und denselben
ScanOptionsaus und verwerfen Sie die Batches. Dies ist die reine Dekodier-Baseline: Keine Pipeline kann schneller sein als sie. - Zeitmessung Ihrer Arbeit allein. Wickeln Sie den pro-Batch-Aufruf (
fold,addoder was auch immer den Batch konsumiert) inperf_counter_ns()ein und addieren Sie die Zeiten. - Vergleichen Sie sie. Auf einem Thread bedeutet eine Gesamtzeit innerhalb des etwa 1,5-fachen der reinen Dekodierzeit, dass die Arbeit billig ist; darüber hinaus ist die Arbeit der Engpass und eine Optimierung wert. Prüfen Sie bei vielen Threads auch, ob die Arbeit auf einem Thread läuft, während die Dekodierung auf vielen läuft.
Das gemessene Beispiel: Eine Gruppierung über 77M Fahrten dauerte 4,6 s auf einem Thread, davon 2,1 s für die pro-Zeile-Arbeit (45%). Auf 8 Threads dauerte es 2,6 s, davon dieselben 2,1 s für die Arbeit (82%), weil ein Thread jeden Batch konsumierte. references/profiling.md enthält das Rezept und seine Fallstricke.
Regeln für die pro-Zeile-Schleife
Jede Regel wird mit dem geliefert, was sie im dista-Shuffle-Benchmark (77M Zeilen, lokale Tabelle, ein Thread) eingespart hat. Der Code befindet sich in references/hot-loops.md.
| Regel | Gemessener Effekt |
|---|---|
Holen Sie den rohen Zeiger jeder Spalte einmal pro Batch, dann indizieren Sie den Zeiger. Indizieren Sie List oder Tuple[List, List] nicht innerhalb der Schleife. | 1,40 → 0,78 s für die gesamte pro-Zeile-Arbeit |
| Halten Sie alles, was ein Schlüssel akkumuliert, nebeneinander in einem Slot (Array of Structs), nicht in parallelen Arrays, sodass ein Update eine Cache-Zeile berührt. | 2,09 → 1,40 s, zusammen mit der nächsten Regel |
Filtern Sie ohne Verzweigungen: Berechnen Sie jede Zeile, schreiben Sie sie an einen kept-Index und erhöhen Sie kept um Int(keep). Fügen Sie dann die behaltenen Zeilen in einer zweiten, engen Schleife hinzu. | zusammen mit der Regel oben gemessen |
Wenn Schlüssel kleine Ganzzahlen sind (zone × zone × hour ist 1,7M), indizieren Sie ein dichtes Array nach Schlüssel, anstatt ein Dict zu verwenden. | nicht allein gemessen; beide Versionen verwendeten ein dichtes Array |
| Ändern Sie nicht die Reihenfolge von Gleitkomma-Additionen während der Optimierung. Dann muss das Ergebnis bitweise mit dem alten übereinstimmen, was eine kostenlose Korrektheitsprüfung ist. | Jeder Schritt oben stimmte exakt überein |
Regeln für die gesamte Pipeline
| Regel | Gemessener Effekt |
|---|---|
| Lassen Sie nicht einen Thread konsumieren, was viele Threads dekodieren. Geben Sie jedem Arbeiter seinen eigenen Akkumulator und führen Sie sie am Ende zusammen, wie es die Map-Tasks von dista tun. | 16 Threads brachten eine Ein-Prozess-Gruppierung von 202 s auf 126 s auf AWS |
Halten Sie mehrere Lesevorgänge gleichzeitig in der Luft, selbst mit einem Dekodier-Thread: ScanOptions.files_at_once (und overlap_io) in iceberg.mojo. | 6,6–6,9× auf einem Thread beim Lesen von S3 |
| Laden Sie eine gemeinsame C-Bibliothek einmal pro Prozess und behalten Sie das Handle, niemals einmal pro Aufruf. | Bei jeder Öffnung von zstd pro Aufruf verbrachte ein Scan 52% seiner Zeit in dyld; behoben: 2.687 → 262 ms |
| Cachen Sie alles, was Sie parsen und sich Byte für Byte wiederholt, wie Avro-Schemata in Manifesten. | Die fixen Kosten der Manifest-Planung fielen um das 3,6-fache (113,7 → 31,9 µs) |
| Allokieren Sie nicht pro Datensatz: Dekodieren Sie in wiederverwendete Slots. | Der Avro-Leser allokierte etwa 60 Mal pro Datensatz vor seinem Cursor |
| Kennen Sie die Obergrenze: Ab einigen Kernen ist ein Scan durch die Speicherbandbreite begrenzt, und mehr Threads helfen nicht. Lesen Sie stattdessen weniger Bytes. | Ein Iceberg-Scan dauerte 35,8 ms auf einem Kern und 12,1 ms auf vier, etwa das 3-fache |
references/case-studies.md erzählt jede dieser Geschichten mit dem, was das Problem verraten hat.
Leser, Schreiber und Codecs
Wenn das Dekodieren selbst der langsame Teil ist, geht es bei den Regeln hauptsächlich darum, keine Arbeit zu leisten, die später verworfen wird. Die Details und ihre Zahlen finden Sie in references/readers-and-codecs.md.
- Überprüfen Sie, dass das Tor jedes schnellen Pfades von den Aufrufern erreicht werden kann, die es erreichen, und beweisen Sie durch Instrumentierung, dass es läuft. Ein vektorisierter Pfad, dessen Tor keine Listenspalte erreichen konnte, ließ eine 9 KiB-Spalte bei 43% eines Lesevorgangs zurück.
- Berechnen Sie nicht, was eine spätere Entscheidung normalerweise verwirft. Das Erstellen eines Wörterbuchs nur um dann eine einfache Kodierung zu wählen, machte 67% eines int64-Spaltenschreibens aus; dessen Entfernung machte das Schreiben 3,3× schneller bei byteidentischer Ausgabe.
- Entleihen Sie anstatt zu kopieren, und dekodieren Sie direkt in das endgültige Layout, wie Definitionsstufen in die Gültigkeits-Bitmap.
- Arbeiten Sie in Läufen, wo die Daten sie haben: Bei 1% Nulls ersetzen etwa 250
memmoves etwa 25.000 Pro-Slot-Verzweigungen. - Wissen Sie, wann eine Stufe bereits an der Speicherbandbreite ist: Eine, die 18,3 MB in 1,2 ms ein- und auslagerte, etwa 30 GB/s, konnte nur übersprungen werden.
- Finden Sie das langsamste Element, bevor Sie Threads hinzufügen. Die Aufteilung nach Spalten-Chunk begrenzte einen Lesevorgang auf das 1,39-fache, weil eine dicke Spalte die ganze Zeit lief.
Testen Sie die Optimierung ebenso wie das Ergebnis: Unterbrechen Sie sie absichtlich und prüfen Sie, dass ein Test fehlschlägt (references/testing-optimizations.md). Mojos eigene Fallstricke, wie Werte, die bei ihrer letzten Verwendung sterben, und MutUntrackedOrigin, finden Sie in references/mojo-hazards.md.
Eine Zahl ermitteln, der Sie vertrauen können
references/measurement.md enthält die vollständigen Regeln. Diejenigen, die Schlussfolgerungen in magmalake geändert haben:
- Geben Sie den Median (p50) mit p90 daneben an, niemals den Mittelwert allein. Ein langsamer erster Aufruf verschob einen Mittelwert um 30% und den p50 überhaupt nicht.
- Erstellen Sie den Benchmark im Voraus; messen Sie ihn niemals unter
mojo run. Der JIT überhöhte einen Benchmark um das 1,9-fache. - Überprüfen Sie, dass die Maschine stillgehalten hat. Derselbe Benchmark dauerte 31 ms im Leerlauf und 89 ms neben 16 ausgelasteten Threads.
- Nennen Sie die API und die Thread-Anzahl der Referenz.
pq.read_tableist der Dataset-Scanner von pyarrow, nicht der Lesepfad von parquet-cpp, und die Thread-Einstellung von pyarrow verschob das Lesen einer Datei um das 2,9-fache. - Wenn zwei Zahlen nicht übereinstimmen, überprüfen Sie, ob sie auf die gleiche Weise erzeugt wurden, bevor Sie den Unterschied erklären.
Vor der Behauptung eines Speedups
- Vergleichen Sie die Ergebnisse vorher und nachher, wo immer möglich (siehe die letzte Schleifenregel). Eine schnellere falsche Antwort ist schlimmer als eine langsame richtige.
- Geben Sie beide Zahlen an sowie das Setup: Zeilen, Threads, lokale Festplatte oder S3 und ob der Cache warm war.
- Sagen Sie, was übrig bleibt. Nach der Shuffle-Korrektur entfallen 325 ms der 777 ms immer noch auf das Kopieren von Spalten, und bei vielen Threads läuft die Arbeit immer noch auf einem Thread.