ctDeflate schreibt Byte für Byte dieselben .gz-Dateien wie gzip aus
libdeflate 1.26 und komprimiert eine einzelne Datei auf allen Kernen – auf einer
8-Kern-CPU bis zu 4,9-mal so schnell. Der Kompressor von libdeflate arbeitet von Grund auf
sequenziell: Wo ein Block endet und wie eine Stelle kodiert wird, hängt von allem davor ab.
Die acht Bilder zeigen, wie aus einem einzigen Kompressor Schritt für Schritt ein
Task-Graph auf ThinkMeta ConcurrentTasks wurde, der alle Kerne auslastet – und warum jeder Schritt
nötig war.
Bild 1 von 8
Die Referenz: gzip aus libdeflate
gzip aus libdeflate bildet die Datei per File Mapping in den Adressraum ab – das Betriebssystem lädt die Teile, auf die zugegriffen wird –, komprimiert sie in einem Aufruf und baut die ganze Ausgabe im Speicher auf, bevor es sie schreibt – alles auf einem Thread, nacheinander. Jede Stufe
hängt vom Vorherigen ab: wo ein Block endet, wie eine Stelle kodiert wird, an
welchem Bit der nächste Block beginnt. Ein Kern arbeitet, die anderen warten.
Diese Bits sind die Messlatte: Jedes weitere Bild muss Byte
für Byte dieselbe .gz-Datei schreiben – bei jeder Thread-Zahl und jeder
Chunk-Größe.
Dreifach sequenziell
Wo ein Block endet, hängt von der Symbolstatistik seit Blockbeginn ab.
Die minimale Matchlänge (3 bis 9) wird aus den ersten 4 KiB jedes
Blocks bestimmt und bei den Lazy-Stufen laufend neu berechnet. Dieselbe
Stelle wird also je nach Blockbeginn anders kodiert.
Jeder Block beginnt an dem Bit, an dem der vorige endete, und ob er
unkomprimiert geschrieben wird, hängt von diesem Bit-Offset ab.
Die Referenz
gzip.exe aus dem offiziellen Windows-Build von libdeflate 1.26. Auf
enwik9, 1 GB Wikipedia-Text, schafft es bei den Stufen 1, 6 und 9
217, 106 und 61 MB/s, gemessen über den ganzen Programmlauf.
Durchsatz · enwik9, Stufe 9, ganzer Programmlauf
61 MB/s
Bild 2 von 8
Der Port: ein Kompressor-Task zwischen asynchronem Lesen und Schreiben
Der Kompressor von libdeflate wird in C++ nachgebaut, vom Matchfinder bis
zum Bitstrom, und läuft als ein Task auf dem Rechen-Scheduler. Lesen und Schreiben werden eigene Tasks mit überlappender I/O auf einem I/O-Scheduler.
Dazwischen liegen begrenzte Fenster: Der Kompressor lässt den Lese-Task höchstens
8 MiB vor dem aktuellen Block lesen und gibt die Eingabe hinter seinem
32-KiB-Suchfenster wieder frei; der Schreib-Task schreibt jedes fertige MiB und gibt es frei. Der Port ist so geschnitten, dass die Matchsuche an jeder beliebigen
Stelle beginnen kann, wenn man ihm den vollständigen Zustand mitgibt
– das braucht jedes spätere Bild.
Lesen und Schreiben laufen jetzt während der Kompression, nicht
davor und danach. Auf enwik9 braucht der Port 17 MB statt der
1.329 MB von gzip – der Speicher hängt nicht mehr von der
Dateigröße ab. Über den ganzen Programmlauf ist er gleich schnell wie
derselbe Port mit der ganzen Datei im Speicher oder bis zu 7 %
schneller, bit-identisch auf allen Stufen.
Erst bit-identisch
Nichts durfte parallel laufen, bevor der Port dieselben Bytes schrieb
wie libdeflate: Stufen 0 bis 9 über 23 echte Dateien bis
163 MB.
Jedes Prozent zählt
Die Geschwindigkeit auf einem Kern ist die Grundlage jedes späteren
Faktors. Gemessen im Speicher auf einem Thread, brachten CRC-32 mit
Carry-less-Multiplikation, Prefetch in den Matchfindern und
konsequentes Inlining den Port von 79–91 % auf
90–107 % von libdeflate, Median etwa 95 %. Der Rest
ist Compiler: Mit demselben Compiler gebaut, ist libdeflate nicht
schneller als der Port.
Speicher des Ports
Höchster Speicherbedarf: enwik9 (1 GB) bei
Stufe 1 / 6 17 / 17 MB statt 1.354 / 1.329 MB mit gzip; die
Datenbankdatei (163 MB) bei Stufe 6 21 statt 191 MB.
Ausprobiert und verworfen: ein I/O-Thread
Ein Schreibvorgang, der die Datei verlängert, läuft in NTFS synchron.
Mit nur einem I/O-Thread hielt er diesen fest und damit auch das
Lesen; bei Stufe 1 warteten bis zu 47 MB fertige Ausgabe auf das
Schreiben. Mit zwei I/O-Threads blockieren sich Lesen und Schreiben nicht mehr.
Bis heute im Einsatz
Dieser Graph steckt noch im Code: ctDeflate --sequential
führt ihn aus – der Referenzweg, an dem jedes spätere Bild
gemessen wird.
Spitzenspeicher · enwik9, Stufe 6, gzip → Port
1.329 → 17 MB
Bild 3 von 8
Spekulativ suchen, exakt abgleichen
Die Eingabe wird in Chunks à 1 MiB geteilt. Für jeden Chunk läuft die Matchsuche parallel, als ob an seinem Anfang ein Block begänne. Ein serieller Resolver spielt danach die Blocklogik von libdeflate in Reihenfolge nach: Wo die Suche eines Chunks das wahre Ergebnis trifft, übernimmt er sie; wo keine passt, sucht er diesen Abschnitt selbst exakt nach (gestrichelt).
Die teure Arbeit, die Matchsuche, hängt fast nur von den Daten ab und läuft
jetzt auf allen Kernen. Der Zustand, der libdeflate sequenziell macht, ist
klein und lässt sich nachspielen. Auf einer Binärdatenbank: 3,6-mal so
schnell wie libdeflate bei Stufe 6, 8,2-mal bei Stufe 9.
Übernehmen oder nachsuchen
Jeder Chunk baut den Matchfinder aus den 32 KiB davor auf.
Erreicht seine Suche dieselbe Stelle mit derselben minimalen Matchlänge wie die wahre, laufen beide ab dort gleich, und der Resolver übernimmt die Arbeit des Chunks. Nur wo keine Suche passt, sucht er nach – nur diesen Abschnitt.
Unkomprimierte Blöcke
Ob ein Block unkomprimiert geschrieben wird, hängt vom Bit-Offset ab,
an dem er beginnt. Ein solcher Block wird vermerkt und erst beim
Anhängen mit dem echten Offset entschieden.
Vier Testdateien
Faktor gegenüber libdeflate bei Stufe 1 / 6 / 9, 16 Threads, ganze Datei im Speicher:
Textprotokoll, 26 MB: 2,4× / 1,5× / 3,1×
Quelltext, 12 MB: 2,0× / 0,9× / 1,7×
Binärdatenbank, 163 MB: 2,3× / 3,6× / 8,2×
vorkompilierter Header, 77 MB: 2,2× / 2,6× / 6,8×
Die Schwachstellen: Wo die minimale Matchlänge von der Annahme des Chunks abweicht, muss der Resolver seriell reparieren – beim
Textprotokoll auf Stufe 6 sind das 19 % der Datei. Und Kodieren und Zusammensetzen (Assemble) laufen erst nach dem Resolver.
Durchsatz · Datenbankdatei, Stufe 6, im Speicher
802 MB/s · 3,6× libdeflate
Bild 4 von 8
Mehrere Spuren in einer Suche
Der Graph bleibt gleich, aber die Matchsuche wird von innen umgebaut: Ein Chunk durchsucht mehrere Spuren gemeinsam – eine je möglicher minimaler
Matchlänge und eine Verlaufsspur (History track) mit eigener Blocklogik. Die erste Suche
jedes Schritts ist für alle Spuren dieselbe; die Spuren trennen sich nur, wo
die Entscheidung für einen Match abweicht, und finden an einem gemeinsamen
Startpunkt wieder zusammen.
Reparaturen sind seriell – jede ist Zeit, in der nur ein Kern arbeitet.
Mit mehreren Spuren findet der Resolver fast immer eine passende: Beim
Textprotokoll sinken die Reparaturen von 4,9 MB auf 21 KB. Quelltext
auf Stufe 6 steigt von 411 auf 700 MB/s.
Exakt dieselbe Suche
Wie auch immer sich die Spuren teilen und vereinigen: Jede Suche sieht
genau den Matchfinder-Zustand, den libdeflate an dieser Stelle sähe.
Das hält jede Spur bit-identisch zur echten Suche.
Der Preis
Mit nur einer Spur war die neue Suche etwa 20 % langsamer als die
alte; die Datenbankdatei verlor deshalb zunächst (Stufe 6: 802 →
616 MB/s). Profilgeführte Arbeit danach brachte sie auf 1.479 /
750 / 346 MB/s bei Stufe 1 / 6 / 9.
Durchsatz · Textprotokoll, Stufe 6, im Speicher
420 → 630 MB/s
Bild 5 von 8
Kodieren und Zusammensetzen laufen mit
Matchsuche und Huffman-Kodierung laufen in denselben Worker-Tasks, einer je Thread. Ein Worker
kodiert zuerst den nächsten Block, den der Resolver freigegeben hat; sonst durchsucht er den nächsten Chunk. Wer einen Block fertig kodiert hat, hängt alle
Blöcke an, die in Reihenfolge fertig sind – das Zusammensetzen (Assemble) braucht keinen eigenen Task.
Vorher begann das Kodieren erst nach dem Resolver: ein zweiter serieller Abschnitt
am Ende, der bei Stufe 1 die Laufzeit bestimmte. Jetzt bleibt nach dem Resolver fast nichts zu tun – bei der Datenbankdatei weniger als eine
Millisekunde. Stufe 1 wird um 13 bis 24 % schneller; bei Stufe 6 und 9
begrenzen Resolver und Matchsuche.
Warum zuerst kodiert wird
Kodieren vor Suchen lässt abgeglichene Blöcke nur kurz warten, und das Zusammensetzen läuft genau dort, wo ein Block gerade fertig wurde.
Keine Übergabe geht verloren
Es hängt immer nur ein Worker an. Ist er fertig, prüft er noch einmal,
ob der nächste Block inzwischen bereitsteht – so wartet kein
Block auf ein Anhängen, das nie kommt.
Durchsatz · Datenbankdatei, Stufe 1, im Speicher
1.479 → 1.828 MB/s
Bild 6 von 8
Begrenztes Fenster: der Speicher hängt nicht mehr an der Dateigröße
Ohne Grenze laufen die Suchen beliebig weit voraus, und Eingabe und
Suchergebnisse wachsen mit der Datei. Jetzt werden Chunks höchstens einige
Plätze vor dem Chunk durchsucht, auf den der Resolver wartet (Standard:
Thread-Zahl plus 4), und der Lese-Task liest nur, was dieses Fenster brauchen kann.
Das Zusammensetzen (Assemble) gibt hinter dem Bitstrom Eingabe und Suchergebnisse
wieder frei, der Schreib-Task die geschriebene Ausgabe. Der Block-Ring fasst 4096
Plätze.
Neu sind das begrenzte Fenster und das Freigeben – Lesen und Schreiben
neben der Kompression gibt es seit dem Port. Der Speicher hängt jetzt von
den Chunks in Arbeit ab, nicht von der Dateigröße.
Eine 5,2-GB-Datei
Stufe 6 mit 144 MB Speicher in 7,3 s statt 28,8 s mit gzip
(4,0×) – bei derselben Ausgabe.
Speicher im Vergleich
Höchster Speicherbedarf beim Komprimieren von enwik9 (1 GB), Core i9-11900K, Stufe 1 / 6 / 9:
gzip aus libdeflate: 1.354 / 1.329 / 1.326 MB – die gelesenen Seiten bleiben im Speicher, dazu die ganze Ausgabe
der Port als ein Kompressor-Task: 17 / 17 MB bei Stufe 1 / 6 (auf der 163-MB-Datenbankdatei: 21 MB bei Stufe 6)
Die Eingabe liegt an ihrer Dateiposition in einem reservierten
Adressbereich; Speicher belegt nur, was das Suchfenster braucht, und
der Kompressionscode bleibt unverändert. Die Ausgabe wird in
1-MiB-Segmenten geschrieben und gleich wieder freigegeben.
Erste Messung des ganzen Programms
Über die vier Testdateien (zusammen 278 MB), gemessen über den
ganzen Programmlauf: 3,19× / 3,05× / 6,02× so schnell wie gzip bei
Stufe 1 / 6 / 9.
Die Spuren kosten mehr, als ihre zusätzlichen Suchen erklären, und auf
gleichförmigen Daten braucht man sie fast nie. Ob sie gebraucht werden, weiß nur der Resolver – also fließt dieses Wissen zurück (gestrichelt): Nach
8 Chunks in Folge ohne Fehltreffer durchsuchen neue Chunks nur noch die Verlaufsspur (History track), mit einer schnelleren Suche für eine Spur. Beim nächsten
Fehltreffer gibt es wieder die volle Auswahl.
Das kleine Suchfenster sorgt dafür, dass die Rückmeldung rechtzeitig
ankommt: Nur wenige Chunks sind schon durchsucht, wenn sie sich ändert.
Reparaturen bleiben gleich, die Bits auch.
Was eine Spur kostet
Datenbankdatei, Stufe 6, ein Thread: 89 MB/s mit einer Spur,
63 MB/s mit dreien. Auch die minimalen Matchlängen der letzten
Blöcke gehen in die Wahl der Spuren ein.
Ausprobiert und verworfen
Ein großes Suchfenster mit 64 Chunks war auf der Datenbankdatei
6 % schneller (733 statt 690 MB/s), brauchte aber mehr als
doppelt so viel Speicher (197 statt 84 MB) und verzögerte die
Rückmeldung. Der Standard bleibt klein. Die Spuren nur aus den Werten
der letzten Blöcke zu wählen, führte auf Datenbanken zu vielen
Reparaturen.
Bei 16 Threads übernehmen 16 Worker auf dem Rechen-Scheduler Matchsuche und
Huffman-Kodierung; Lesen und Schreiben laufen wie beim Port auf dem
I/O-Scheduler. Der Resolver bleibt auf dem aufrufenden Task, der neben den
Workern einen eigenen Thread hat. Das Zusammensetzen (Assemble) läuft im Worker, der
den nächsten Block fertig kodiert. Seit dem letzten Umbau ist das Schreiben ein eigener Task neben dem Zusammensetzen, statt aus ihm heraus zu schreiben
– gleich schnell oder leicht schneller.
Auf enwik9, 1 GB Wikipedia-Text, war ctDeflate bei Stufe 1 4,3-mal so
schnell wie gzip (217 → 928 MB/s), bei Stufe 6 3,7-mal (106 →
392 MB/s) und bei Stufe 9 4,9-mal – gemessen vor dem Umbau über
den ganzen Programmlauf mit Lesen und Schreiben, jede Datei bit-identisch.
Warum Fibers den Unterschied machen
Der Resolver wartet mitten in seiner Schleife auf die nächste Chunk-Suche und, wenn der Ring voll ist, auf einen freien Platz. Eine Suche wartet auf ihre Eingabe, Lesen und Schreiben auf überlappende I/O, das Schreiben auch auf fertige Ausgabe. Auf ThinkMeta ConcurrentTasks hält ein solches Warten nur den Task an; sein Thread sucht oder kodiert derweil, und jeder Knoten bleibt eine einfache
Schleife in Reihenfolge.
Der Umbau gemessen
Ganzer Programmlauf, Median abwechselnder Paare, gegen den Stand
davor: 0,97–1,06 bei 16 Threads, 1,00–1,07 bei 2 Threads.
Der höchste Speicherbedarf auf enwik9 bei 16 Threads bleibt bei
Stufe 6 bei 135–142 MB und bei Stufe 9 bei
129–136 MB; bei Stufe 1 steigt er von 98–109 auf
135–165 MB. Dort entsteht die Ausgabe mit über
300 MB/s: Vorher bremste das Schreiben im Zusammensetzen die ganze
Pipeline, jetzt läuft das Schreiben daneben, und bis zu 16 MiB fertige
Ausgabe dürfen auf das Schreiben warten.
Ausprobiert und verworfen: der Resolver als Scheduler-Task
Als Task auf dem Rechen-Scheduler war der Resolver bei 16 Threads
gleich schnell und mit 2 Threads bis zu 9 % langsamer. Auf dem
aufrufenden Task hat er einen eigenen Thread und wartet nie auf einen
freien Worker-Thread.
Viele kleinere Dateien
Auf dem Silesia-Korpus (12 Dateien, 212 MB) war ctDeflate bei
Stufe 1 / 6 / 9 2,4- / 2,2- / 3,6-mal so schnell wie gzip. Kleine
Dateien haben weniger Chunks, als die CPU Threads hat, und
Programmstart und I/O wiegen schwerer. Gemessen vor dem Umbau.
Andere Rechner
Veröffentlichte Messungen, vor dem Umbau, Stufe 1 / 6 / 9:
Durchsatz · enwik9, Stufe 9, ganzer Programmlauf, Stand vor dem Umbau
61 → 303 MB/s (4,9×)
Selbst ausprobieren
ctDeflate, die Referenz gzip.exe und der Benchmark für eigene Dateien liegen auf
GitHub.
Sie haben eine Arbeitslast, die jeden Kern nutzen soll?
Nehmen Sie Kontakt auf.