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.

Read, Write, cjpegliReadsynchronousWritesynchronouscjpegliall phases in turn Read, Write, cjpegli1 thread · one thing after anotherReadsynchronousWritesynchronouscjpegliall phases in turn

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

Read, Write, EncodeReadasynchronousWriteasynchronousEncodestrip by strip Read, Write, EncodeI/O schedulercompute schedulerI/O schedulerReadasynchronousWriteasynchronousEncodestrip by strip

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

Read, Write, Setup, Pixel phase, coefficients, EntropyReadasynchronousWriteasynchronousSetuptables, scan scriptPixel phasein bands, serialcoefficientsEntropytokens, Huffman, bits Read, Write, Setup, Pixel phase, coefficients, EntropyI/O scheduler1 thread · split into phasesI/O schedulerReadasynchronousWriteasynchronousSetuptables, scan scriptPixel phasein bands, serialcoefficientsEntropytokens, Huffman, bits

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

Read, Write, Setup, Pixel, coefficients, Entropyjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelbandsPixelbandsPixelbandscoefficientsEntropystill serial Read, Write, Setup, Pixel, coefficients, EntropyI/O schedulerscheduler · 1 worker per threadI/O schedulerjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelbandsPixelbandsPixelbandscoefficientsEntropystill serial

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

Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Write, Assemblejob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelbandsPixelbandsPixelbandscoefficientsTokenizescans + AC bandsTokenizescans + AC bandsTokenizescans + AC bandsTokensStitchper scanHuffmanWriteper scanWriteper scanWriteper scanAssemble Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Write, AssembleI/O schedulerscheduler · 1 worker per threadI/O schedulerjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelbandsPixelbandsPixelbandscoefficientsTokenizescans + AC bandsTokenizescans + AC bandsTokenizescans + AC bandsTokensStitchper scanHuffmanWriteper scanWriteper scanWriteper scanAssemble

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

Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Write, Assemblejob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixel+ SIMDPixel+ SIMDPixel+ SIMDcoefficientsTokenize+ bit masksTokenize+ bit masksTokenize+ bit masksTokensStitchHuffmanWriteWriteWriteAssemble Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Write, AssembleI/O schedulerscheduler · 1 worker per threadI/O schedulerjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixel+ SIMDPixel+ SIMDPixel+ SIMDcoefficientsTokenize+ bit masksTokenize+ bit masksTokenize+ bit masksTokensStitchHuffmanWriteWriteWriteAssemble

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

Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Count, cursors, Write, chunk bits, Splice, Assemblejob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixel+ tilesPixel+ tilesPixel+ tilescoefficientsTokenize+ DC bandsTokenize+ DC bandsTokenize+ DC bandsTokensStitchStitchStitchHuffmanCount+ newCount+ newCount+ newcursorsWrite+ in chunksWrite+ in chunksWrite+ in chunkschunk bitsSplice+ newSplice+ newSplice+ newAssemble Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Count, cursors, Write, chunk bits, Splice, AssembleI/O schedulerscheduler · 1 worker per threadI/O schedulerjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixel+ tilesPixel+ tilesPixel+ tilescoefficientsTokenize+ DC bandsTokenize+ DC bandsTokenize+ DC bandsTokensStitchStitchStitchHuffmanCount+ newCount+ newCount+ newcursorsWrite+ in chunksWrite+ in chunksWrite+ in chunkschunk bitsSplice+ newSplice+ newSplice+ newAssemble

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

Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Count, cursors, Write, chunk bits, Splice, Assemble, Releasejob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelPixelPixelcoefficientsTokenize+ frees the imageTokenize+ frees the imageTokenize+ frees the imageTokensStitchStitchStitchHuffmanCountCountCountcursorsWrite+ frees coefficientsWrite+ frees coefficientsWrite+ frees coefficientschunk bitsSpliceSpliceSpliceAssembleRelease+ newRelease+ newRelease+ new Read, Write, Setup, Pixel, coefficients, Tokenize, Tokens, Stitch, Huffman, Count, cursors, Write, chunk bits, Splice, Assemble, ReleaseI/O schedulerscheduler · 1 worker per threadI/O schedulerjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelPixelPixelcoefficientsTokenize+ frees the imageTokenize+ frees the imageTokenize+ frees the imageTokensStitchStitchStitchHuffmanCountCountCountcursorsWrite+ frees coefficientsWrite+ frees coefficientsWrite+ frees coefficientschunk bitsSpliceSpliceSpliceAssembleRelease+ newRelease+ newRelease+ new

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

Read, Write, Setup, Pixel, coefficients + DC plane, Tokenize, Tokens, Stitch, Huffman, Count, cursors, Write, chunk bits, Splice, Assemble, Releasejob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelPixelPixelcoefficients + DC planeTokenizeTokenizeTokenizeTokensStitchStitchStitchHuffmanCountCountCountcursorsWriteWriteWritechunk bitsSpliceSpliceSpliceAssembleReleaseReleaseRelease Read, Write, Setup, Pixel, coefficients + DC plane, Tokenize, Tokens, Stitch, Huffman, Count, cursors, Write, chunk bits, Splice, Assemble, ReleaseI/O schedulerscheduler · 1 worker per threadI/O schedulerjob k−1job kjob k+1ReadasynchronousWriteasynchronousSetupPixelPixelPixelcoefficients + DC planeTokenizeTokenizeTokenizeTokensStitchStitchStitchHuffmanCountCountCountcursorsWriteWriteWritechunk bitsSpliceSpliceSpliceAssembleReleaseReleaseRelease

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.

An unhandled error has occurred. Reload