ctJpeg schreibt Byte für Byte dieselben JPEG-Dateien wie Googles jpegli
und kodiert ein 39-Megapixel-Foto 6,9-mal so schnell. Anders als ein Audio-Encoder ist
es kein Strom von Frames, sondern eine Folge von Stufen: Jede parallele Stufe verteilt
einige hundert unabhängige Jobs auf je einen Worker pro Thread, und die nächste Stufe
beginnt erst, wenn alle fertig sind. Die neun Bilder zeigen, wie sich die Stufen auf
ThinkMeta ConcurrentTasks nacheinander auffächerten.
Bild 1 von 9
Die Referenz: jpegli
jpegli ist der JPEG-Encoder des JPEG-XL-Teams: Bei gleicher Bildqualität
sind seine Dateien kleiner als die von mozjpeg und libjpeg-turbo –
aber er kodiert auf einem Kern. Lesen, Kodieren und Schreiben folgen
nacheinander auf einem Thread.
Diese Bytes sind die Messlatte: Jedes weitere Bild muss
genau dieselbe JPEG-Datei schreiben.
Wohin die Zeit geht
Bei einem 39-Megapixel-Foto braucht die Tokenisierung der
progressiven Scans 44 % der Zeit, die Pixelphase (Farbe,
adaptive Quantisierung, DCT, Quantisierung) 34 %, das Schreiben
des Bitstroms 6 % und Huffman 1 %. Für einen echten
Gewinn müssen mindestens Pixelphase und Tokenisierung parallel
laufen.
Laufzeit · ein Foto, 39 MP · Entwicklung
jpegli: 0,75 s
Bild 2 von 9
Asynchrones Lesen und Schreiben
Lesen und Schreiben wandern aus dem Encoder heraus: Ein Lese-Task auf einem
I/O-Scheduler holt das Foto mit großen Lesevorgängen voraus, und der Encoder
rechnet schon, während der Rest noch unterwegs ist – jeder Bildstreifen
beginnt, sobald seine eigenen Zeilen da sind. Ein Schreib-Task schreibt das
JPEG, während der Encoder aufräumt.
In der Praxis kommt ein Foto meist von der Platte, nicht aus dem Datei-Cache.
Von der SSD wird das 39-Megapixel-Foto 28 % schneller kodiert.
Gemessen
Laufzeit im Verhältnis zum vorherigen Einlesen der ganzen Datei, Median abwechselnder Paare, Datei von der NVMe-SSD:
39 MP: 0,72 mit 16 Threads, 0,75 mit 4, 0,92 auf einem Thread
11 MP: 0,90 / 0,67 / 0,92
Liegt die Datei schon im Cache, wird sie einmal mehr kopiert: mit allen
Threads 1 bis 23 % langsamer, mit 4 Threads oder auf einem Thread
gleich. Alle Varianten waren bit-identisch.
Echte Überlappung
Lesen und Rechnen überlappen erst, wenn jeder Bildstreifen nur auf
seine eigenen Zeilen wartet und der Lese-Task jeden Block sofort
weitergibt.
Schreiben nebenher
Die Huffman-Tabellen stehen vor den Scans, die Datei kann also erst
entstehen, wenn das ganze JPEG zusammengesetzt ist. Das Schreiben läuft
dann auf dem I/O-Thread, während der Encoder seinen Speicher freigibt
– beim großen Foto spart das weitere 2 bis 3 %.
Laufzeit · 39-MP-Foto von der SSD, 16 Threads
0,185 → 0,133 s
Bild 3 von 9
Der Port: derselbe Ablauf, eigener Code
Erst Bit-Identität mit einem lesbaren Port, dann Parallelität. jpegli wird
von Hand nach C++ portiert und in Phasen zerlegt: Setup, Pixelphase,
Entropiekodierung. Dazwischen liegt ein Puffer mit den Koeffizienten. Die
Pixelphase arbeitet schon in Bändern von Blockzeilen und rechnet die
Kontextzeilen, die sie braucht, selbst nach – bereit für parallele
Arbeit, aber noch auf einem Thread.
Vorerst etwa 1,7-mal langsamer als jpegli, aber in allen Prüffällen bit-identisch.
Zweierlei identisch
jpegli schreibt nicht auf jeder CPU dieselben Bytes: Seine
SIMD-Bibliothek nutzt auf AVX2 fusioniertes Multiply-Add, auf SSE
nicht, und summiert Vektorspuren in einer Reihenfolge, die von der
Vektorbreite abhängt. Der Port bildet beide Klassen nach –
Byte für Byte jpegli auf SSE2 und Byte für Byte jpegli auf AVX2.
Bis heute im Einsatz
Dieser Ablauf steckt noch im Code: ctJpeg --sequential
führt den ganzen Encoder als einen Task aus – der Referenzpfad
für Vergleiche.
Laufzeit · ein Foto, 39 MP · Entwicklung
1,27 s · jpegli 0,75 s
Bild 4 von 9
Die Pixelphase wird parallel
Die Bänder der Pixelphase werden zu Jobs, etwa vier je Worker. Jeder
Worker holt sich das nächste Band über einen gemeinsamen Zähler; der
Haupt-Task wartet, bis alle fertig sind. Die Pixelphase sinkt von 0,76 auf
0,12 s – mit 16 Threads 6,4-mal so schnell.
Die Grenze jetzt: die serielle Entropiekodierung mit 0,48 s.
Warum die Bänder unabhängig sind
Die adaptive Quantisierung schaut nur auf kleine, lokale Fenster
um jeden Block. Rechnet ein Band ein paar Kontextzeilen selbst nach,
hängen seine Koeffizienten von keinem anderen Band mehr ab.
Laufzeit · ein Foto, 39 MP · Entwicklung
0,70 s · 1,07× jpegli
Bild 5 von 9
Tokenisieren und Schreiben werden parallel
Der Entropie-Block zerfällt. Die progressiven Scans sind voneinander
unabhängig, je ein Job; die großen Scans werden zusätzlich in Bänder
geteilt. Ein kurzer serieller Stitch fügt die Bänder genau
so zusammen, wie jpegli sie am Stück kodiert hätte. Huffman bleibt seriell:
Die Tabellen brauchen die Symbolzählung aller Scans.
Jeder Scan endet auf einer Bytegrenze, also schreibt jeder parallel in
seinen eigenen Puffer; Assemble setzt sie zusammen.
Was der Stitch näht
Innerhalb eines Scans werden Läufe leerer Blöcke als ein
End-of-Band-Lauf kodiert, der über Blockzeilen reicht – und
jpegli begrenzt einen Lauf auf 32.767 Blöcke und teilt
Refinement-Läufe nach 255 Korrekturbits. Ein Band beginnt mit
frischem Zustand und meldet seinen Anfang kompakt; der Stitch spielt
ihn mit dem echten Zustand nach.
Nur halb richtig
Der Profiler sagte damals: begrenzt durch die 8 physischen Kerne,
nicht durch den Speicher. Bild 7 zeigt, dass das nur halb stimmte
– der Speicher wurde zur Grenze, sobald jeder Thread schneller
wurde.
Laufzeit · ein Foto, 39 MP · Entwicklung
0,31 s · 2,4× jpegli
Bild 6 von 9
Weniger Arbeit je Knoten
Der Graph bleibt gleich; jeder Knoten wird schneller. SIMD-Kernels der
Pixelphase folgen jpeglis Arithmetik genau, und der Tokenizer arbeitet mit Bitmasken pro Block statt einer Schleife über alle Koeffizienten.
Auf einem Thread ist ctJpeg jetzt 1,4- bis 1,5-mal so schnell wie jpegli.
Aber die Pixelphase skaliert nur noch etwa dreifach.
Nichts Messbares
Ringpuffer allein, die die Arbeitsmenge je Worker von 8,5 auf
2 MB senkten, brachten nichts Messbares. Warum die Pixelphase
nicht mehr skalierte, zeigte erst das nächste Bild.
Laufzeit · ein Foto, 39 MP · Entwicklung
0,24 s · 3,1× jpegli
Bild 7 von 9
Kleinere Jobs, die in den Cache passen
Bei einem Fork-Join bestimmt der letzte Job einer Stufe,
wann sie endet. Ein Job-Trace zeigte für jede Stufe, welcher das war: Die
Arbeit war richtig verteilt, aber einzelne Jobs waren zu groß oder liefen
gegen den Speicher. Also wurden die Jobs kleiner: Die Pixelphase arbeitet
auf Kacheln von etwa 1.024 Pixeln Breite, der DC-Scan wird in Bänder
geteilt, und große Scans werden in Chunks geschrieben –
Count ermittelt, wo jeder Chunk beginnt,
Splice fügt die Chunks bitgenau zusammen.
Die Gesamtzeit sinkt jetzt bis 16 Threads; vorher lag das Optimum bei 6 bis 8.
Was der Job-Trace fand
Der DC-Scan lief als ein Job von 32 ms, und zwar als letzter → DC-Bänder: Tokenisierung 69 → 56 ms.
Große Refinement-Scans schrieb je ein Worker → Chunks: Schreiben 28 → 10–13 ms.
16 Worker × 2 MB überliefen den L3-Cache von 16 MB → Kacheln: Pixelphase 89 → 38–46 ms.
Die letzten AC-Bänder bestimmten das Ende → feinere Bänder: Tokenisierung 39 → 33 ms.
Falsche Fährte
Vor den Kacheln waren Bänder von einer einzigen Blockzeile mit 8
Threads schneller. Das sah nach einem Lastausgleichsproblem aus, war
aber ein Cache-Effekt – erst dasselbe Bild in einem Viertel der
Breite machte das sichtbar.
Laufzeit · dasselbe Foto · Tuning-Messung
0,14 s · 5,4× jpegli
Bild 8 von 9
Auch das Aufräumen wird parallel
Mit 16 Threads lagen noch etwa 0,05 s außerhalb der Stufen –
vor allem die Rückgabe benutzten Speichers an das Betriebssystem. Das
geschieht jetzt in Jobs und so früh wie möglich: das Eingabebild als erster
Job von Tokenize, die Koeffizienten während Write (niemand liest sie mehr),
der Rest in einer neuen Stufe, Release.
Die Freigabe sinkt von 26 auf 7–8 ms. Über 14 Testfotos ist ctJpeg jetzt 4,8- bis 5-mal so schnell wie jpegli.
Warum nicht einfach weglassen?
Ohne Freigabe wandern die Kosten nur ans Prozessende –
gemessen: gleiche Gesamtzeit. Auf die Worker verteilt werden sie
kleiner. Nebenbei sank der belegte Speicher von 1.241 auf
408 MB.
Ausprobiert und verworfen
Den Koeffizienten-Puffer in Stücken parallel freizugeben: Die
Stücke einer Speicherregion bremsen sich gegenseitig, die Freigabe
stieg von 11 auf 14 ms.
Laufzeit · dasselbe Foto · Tuning-Messung
0,143 → 0,109 s · 6,9× jpegli
Bild 9 von 9
Der vollständige Graph
Zehn Stufen, drei davon seriell und kurz: Setup, Huffman, Assemble. Jede
andere Stufe fächert sich auf alle Kerne auf und läuft wieder zusammen,
bevor die nächste beginnt. Das 39-Megapixel-Foto braucht 0,108 s statt
0,750 s – bit-identisch zu jpegli und bei großen Bildern 3,4- bis
4,7-mal so schnell wie libjpeg-turbo, mit den kleineren Dateien von jpegli.
Warum hier Tasks
Im Kern ist ctJpeg Fork-Join: Kein Job wartet mitten in seiner
Arbeit auf einen anderen. Der Encoder selbst läuft als Task und
wartet auf jede Stufe; auf ThinkMeta ConcurrentTasks hält dieses Warten eine Fiber an,
statt einen Thread zu blockieren, und der Encoder liest sich wie
einfacher sequenzieller Code.
Andere Rechner
Sechs Fotos, zusammen 99 Megapixel:
Intel Core i9-11900K, 8 Kerne: 5,3×
Intel Xeon E3-1275 v6, 4 Kerne: 5,6×
Intel Core Ultra 7 155H, Notebook: 4,2×
Auf jedem Rechner bit-identisch zu jpegli.
Struktur zum Nulltarif
Der Graph steht jetzt an einer Stelle im Code. Gemessen gegen den
Stand davor: 0,96 bis 1,04 in jedem Fall – Rauschen. Die
Struktur kostet nichts.
Faktor gegenüber jpegli · Benchmark-Paket, 39-MP-Foto
6,9×
Selbst ausprobieren
ctJpeg, die jpegli-Referenz und der Benchmark für eigene Fotos liegen auf
GitHub.
Sie haben eine Arbeitslast, die jeden Kern nutzen soll?
Nehmen Sie Kontakt auf.