ctLame schreibt Byte für Byte dieselben MP3-Dateien wie LAME und ist auf einer 8-Kern-CPU 14-mal so schnell. LAME arbeitet von Grund auf sequenziell: Jeder Frame hängt vom Zustand ab, den der vorherige hinterlassen hat. Die neun Bilder zeigen, wie aus einem einzigen Encoder Schritt für Schritt ein Task-Graph auf ThinkMeta ConcurrentTasks wurde, der alle Kerne auslastet – und warum jeder Schritt nötig war.

Read, LAME, Writeframe N−1frame Nframe N+1bufferbufferReadsynchronousReadsynchronousReadsynchronousLAMEall stagesLAMEall stagesLAMEall stagesWritesynchronousWritesynchronousWritesynchronous Read, LAME, Write1 thread · one thing after anotherframe N−1frame Nframe N+1bufferbufferReadsynchronousReadsynchronousReadsynchronousLAMEall stagesLAMEall stagesLAMEall stagesWritesynchronousWritesynchronousWritesynchronous

Bild 1 von 9

Die Referenz: LAME

LAME liest einen Block, kodiert ihn und schreibt ihn – alles auf einem Thread, nacheinander. Jede Stufe trägt Zustand von Frame zu Frame: die Psychoakustik, die Filterbank, das Bit-Reservoir. Ein Kern arbeitet, die anderen warten.

Diese Bits sind die Messlatte: Jedes weitere Bild muss Byte für Byte dieselbe MP3-Datei schreiben.

Bit-Reservoir

Ein MP3-Frame darf Bits nutzen, die frühere Frames übrig gelassen haben. Wie ein Frame kodiert wird, hängt deshalb von allen Frames davor ab – der Hauptgrund, warum MP3-Encoder sequenziell arbeiten.

Die Referenz

LAME 4.0, reduziert auf das, was ctLame umsetzt: MPEG-1 Layer III mit der „neuen“ VBR-Routine von LAME, aus WAV-Dateien. Für diese Einstellungen schreibt sie dieselben Bytes wie das unveränderte LAME.

Laufzeit · Testdatei, 4 Minuten Musik

1,9 s

Read, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousEncodeall stages, one taskEncodeall stages, one taskEncodeall stages, one taskWriteasynchronousWriteasynchronousWriteasynchronous Read, Encode, WriteI/O schedulercompute schedulerI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousEncodeall stages, one taskEncodeall stages, one taskEncodeall stages, one taskWriteasynchronousWriteasynchronousWriteasynchronous

Bild 2 von 9

Lesen und Schreiben laufen nebenher

fread und fwrite werden eigene Tasks mit asynchroner Ein-/Ausgabe auf einem I/O-Scheduler; der ganze Encoder ist ein Task auf dem Rechen-Scheduler. Lesen und Schreiben laufen jetzt während des Kodierens, mit bis zu vier Aufträgen gleichzeitig.

Die I/O-Kette schafft etwa 600 MB/s – rund 27-mal so viel, wie der Encoder auf einem Kern verarbeitet. Sie wird auch später kein Engpass. Noch rechnet nur ein Kern: gleich schnell wie LAME, bit-identisch.

Pipes und Priorität

Die Pipes zwischen den Tasks sind begrenzt. Ihr Füllstand steuert die Priorität der I/O-Tasks: Läuft die Eingangs-Pipe leer, liest der Reader mit hoher Priorität. So wartet der Encoder nie auf Daten, und der Speicherbedarf bleibt klein.

Der LAME-Tag

Der LAME-Tag mit Prüfsumme und Inhaltsverzeichnis steht erst nach dem letzten Frame fest. Der Schreib-Task schreibt ihn ganz am Ende an den Dateianfang.

Bis heute im Einsatz

Dieser Graph steckt noch im Code: ctLame --sequential führt darin den fertigen Encoder auf einem Thread aus – der Referenzpfad für Vergleiche.

Laufzeit · Testdatei, 4 Minuten Musik

2,0 s

Read, Input, Psychoacoustics, MDCT, Stereo, Quantization, Bitstream, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousInputPsychoacousticsMDCTStereoQuantizationBitstreamWriteasynchronousWriteasynchronousWriteasynchronous Read, Input, Psychoacoustics, MDCT, Stereo, Quantization, Bitstream, WriteI/O schedulerEncode · one taskI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousInputPsychoacousticsMDCTStereoQuantizationBitstreamWriteasynchronousWriteasynchronousWriteasynchronous

Bild 3 von 9

Den Zustand entflechten

Der Graph bleibt gleich, aber der Encoder wird von innen umgebaut: sechs Stufen, und alles, was zu einem Frame gehört, liegt in einem eigenen Datensatz. Was von Frame zu Frame getragen wird, bleibt im Zustand seiner Stufe (die Schleifen): Psychoakustik, Filterbank, Bit-Reservoir.

Nur eine Abhängigkeit kreuzt Stufen und Frames (gestrichelt): Der Bitstream von Frame N bestimmt, wie viele Bits die Quantisierung von Frame N+1 nutzen darf. Parallelisieren kann man nur, was man auseinandernehmen kann.

Die Landkarte

Ein Frame dauert etwa 200 µs. Davon brauchen die Psychoakustik 38 %, die MDCT 8 %, die Quantisierung 49 % und der Bitstream 5 %. Dieses Profil leitete jeden weiteren Schritt.

Falscher Alarm

Zwei Werte in LAME sahen aus, als hinge ein Frame von einem späteren ab. Beide ließen sich vorwärts auflösen.

Laufzeit · Testdatei, 4 Minuten Musik

2,0 s

Read, Analysis, frame ring, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTframe ringEncodequantizationEncodequantizationEncodequantizationWriteasynchronousWriteasynchronousWriteasynchronous Read, Analysis, frame ring, Encode, WriteI/O schedulercompute schedulerI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTframe ringEncodequantizationEncodequantizationEncodequantizationWriteasynchronousWriteasynchronousWriteasynchronous

Bild 4 von 9

Zwei Tasks, ein Ring

Der Encoder wird zwischen Analyse und Quantisierung in zwei Tasks geschnitten. Beide Hälften sind etwa gleich groß; als Pipeline wird Frame N quantisiert, während Frame N+1 analysiert wird. Dazwischen liegt ein Ring von Frames: Ein Platz ist wieder frei, sobald sein Frame fertig ist.

Eine Übergabe an einen anderen Thread kostet etwa 0,4 µs, ein Frame rund 200 µs Arbeit – deshalb wird jeder Frame einzeln übergeben, ohne Batches. Der Engpass jetzt: Encode.

Sample-Stream

Jeder Kanal ist ein Strom von Samples in reserviertem virtuellem Speicher. Ein Frame zeigt auf sein Fenster, statt es zu kopieren; Speicher belegt nur, was gerade im Ring ist.

Warum Encode dick umrandet ist

Der dicke Rahmen kennzeichnet einen Task mit hoher Priorität. Encode, die spätere Stufe, kommt vor der Analyse dran: So leert sich die Pipeline zuerst, bevor die Analyse weiter vorausrechnet. Später bekommt Resolve aus demselben Grund hohe Priorität.

Pipeline-Zeit · dieselbe Testdatei

2,12 → 1,27 s (1,68×)

Read, Analysis, frame ring, Search, frame ring, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTframe ringSearchscalefactor searchSearchscalefactor searchSearchscalefactor searchframe ringEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous Read, Analysis, frame ring, Search, frame ring, Encode, WriteI/O schedulercompute schedulerI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTAnalysispsychoacoustics, MDCTframe ringSearchscalefactor searchSearchscalefactor searchSearchscalefactor searchframe ringEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous

Bild 5 von 9

Die Suche wird frame-parallel

Das Profil zeigte: Etwa 95 % der Quantisierung hängen gar nicht vom Bit-Reservoir ab. Die Suche nach Skalenfaktoren läuft deshalb für viele Frames gleichzeitig, ein Task je Rechen-Thread. Nur der Commit – Bit-Budget, Bitraten-Wahl – bleibt seriell und dauert wenige Mikrosekunden.

Die schwerste Stufe ist auf alle Kerne verteilt. Der Engpass jetzt: die Analyse, seriell mit ihrem Zustand.

Auch der Scheduler lernt

Unterwegs zeigte ctLame, dass der Scheduler bei jedem neuen Task alle schlafenden Threads weckte. Er weckt jetzt höchstens einen – danach hing die Laufzeit nicht mehr von der Thread-Zahl ab. Jeder Encoder macht auch ThinkMeta ConcurrentTasks selbst besser.

Pipeline-Zeit · dieselbe Testdatei

1,27 → 1,05 s

Read, Dispatch, frame ring, Prepare, frame ring, Resolve, frame ring, Search, frame ring, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchframes into the ringDispatchframes into the ringDispatchframes into the ringframe ringPrepareFFTs, spreading, energiesPrepareFFTs, spreading, energiesPrepareFFTs, spreading, energiesframe ringResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHframe ringSearchMDCT, stereo, searchSearchMDCT, stereo, searchSearchMDCT, stereo, searchframe ringEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous Read, Dispatch, frame ring, Prepare, frame ring, Resolve, frame ring, Search, frame ring, Encode, WriteI/O schedulercompute schedulerI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchframes into the ringDispatchframes into the ringDispatchframes into the ringframe ringPrepareFFTs, spreading, energiesPrepareFFTs, spreading, energiesPrepareFFTs, spreading, energiesframe ringResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHframe ringSearchMDCT, stereo, searchSearchMDCT, stereo, searchSearchMDCT, stereo, searchframe ringEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous

Bild 6 von 9

Der Graph bekommt seine Form

Etwa 90 % der Psychoakustik brauchen keinen Zustand: FFTs, Spreading, Energien. Sie laufen jetzt als Prepare für viele Frames gleichzeitig. Seriell bleibt nur Resolve: Attack-Erkennung, Blocktyp, Pre-Echo-Kontrolle. Auch die MDCT erweist sich als zustandslos.

Jeder Frame durchläuft fünf Stufen: seriell, parallel, seriell, parallel, seriell. Seriell sind nur noch drei kurze Glieder: Dispatch, Resolve und Encode.

Ein Worker-Pool

Prepare und Search sind dieselben Worker-Tasks, einer je Thread. Sie nehmen die Suche zuerst: Sie gehört zu älteren Frames und gibt Plätze im Ring früher frei.

Pipeline-Zeit · dieselbe Testdatei

1,05 → 0,29 s · etwa 8× LAME

Read, Dispatch, frame ring, Prepare, frame ring, Resolve, frame ring, Search, frame ring, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchframes into the ringDispatchframes into the ringDispatchframes into the ringframe ringPrepare+ filterbankPrepare+ filterbankPrepare+ filterbankframe ringResolve+ filterbank carryResolve+ filterbank carryResolve+ filterbank carryframe ringSearch+ Huffman codingSearch+ Huffman codingSearch+ Huffman codingframe ringEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous Read, Dispatch, frame ring, Prepare, frame ring, Resolve, frame ring, Search, frame ring, Encode, WriteI/O schedulercompute schedulerI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchframes into the ringDispatchframes into the ringDispatchframes into the ringframe ringPrepare+ filterbankPrepare+ filterbankPrepare+ filterbankframe ringResolve+ filterbank carryResolve+ filterbank carryResolve+ filterbank carryframe ringSearch+ Huffman codingSearch+ Huffman codingSearch+ Huffman codingframe ringEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous

Bild 7 von 9

Die seriellen Glieder werden dünn

Jede Mikrosekunde, die ein serielles Glied je Frame braucht, begrenzt alle Kerne zusammen. Deshalb wandert Arbeit aus den seriellen in die parallelen Knoten: Die Huffman-Kodierung läuft jetzt in der Suche, der serielle Bitstream hängt sie nur noch an (17 → 3,8 µs je Frame). Die Filterbank wird in Prepare berechnet; Resolve reicht nur ihren Übertrag in Reihenfolge weiter.

Ab hier wird das Ergebnis über ganze Programmläufe auf einem Korpus von Musikdateien gemessen.

Ausprobiert und verworfen

Resolve weiter zu teilen war bit-identisch und verkürzte die serielle Kette von 13,7 auf 4,9 µs je Frame – auf 8 Kernen brachte das nichts, weil die Worker schon ausgelastet waren. Es liegt bereit für Rechner mit mehr Kernen. Vorausschau, andere Prioritäten, ein größerer Ring: im Rauschen.

Faktor gegenüber LAME · ganze Laufzeit

7,76× → 8,33×

Read, Dispatch, frame ring · 4 slots per thread, Prepare, frame ring · 4 slots per thread, Resolve, frame ring · 4 slots per thread, Search, frame ring · 4 slots per thread, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchframes into the ringDispatchframes into the ringDispatchframes into the ringframe ring · 4 slots per threadPrepare+ SSE4.1 / AVX2Prepare+ SSE4.1 / AVX2Prepare+ SSE4.1 / AVX2frame ring · 4 slots per threadResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHframe ring · 4 slots per threadSearch+ SSE4.1 / AVX2Search+ SSE4.1 / AVX2Search+ SSE4.1 / AVX2frame ring · 4 slots per threadEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous Read, Dispatch, frame ring · 4 slots per thread, Prepare, frame ring · 4 slots per thread, Resolve, frame ring · 4 slots per thread, Search, frame ring · 4 slots per thread, Encode, WriteI/O schedulercompute schedulerI/O schedulerframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchframes into the ringDispatchframes into the ringDispatchframes into the ringframe ring · 4 slots per threadPrepare+ SSE4.1 / AVX2Prepare+ SSE4.1 / AVX2Prepare+ SSE4.1 / AVX2frame ring · 4 slots per threadResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHResolveblock type, pre-echo, ATHframe ring · 4 slots per threadSearch+ SSE4.1 / AVX2Search+ SSE4.1 / AVX2Search+ SSE4.1 / AVX2frame ring · 4 slots per threadEncodereservoir, bitstreamEncodereservoir, bitstreamEncodereservoir, bitstreamWriteasynchronousWriteasynchronousWriteasynchronous

Bild 8 von 9

Weniger Arbeit je Knoten

Der Graph ist auf diesem Rechner am Optimum; Gewinne kommen jetzt aus weniger Arbeit je Frame. SIMD-Kernels (SSE4.1, AVX2) in den parallelen Knoten rechnen dieselben Operationen in derselben Reihenfolge wie der skalare Code – und liefern deshalb dieselben Bits.

Der Ring wächst mit der Thread-Zahl: 4 Plätze je Thread, 16 bis 128. Auf einem Thread ist ctLame jetzt 1,7-mal so schnell wie LAME.

Warum der Ring wuchs

Mit 16 Threads waren die Stufen nur zu 60 bis 70 % beschäftigt: Der feste Ring mit 32 Plätzen ließ zu wenige Frames gleichzeitig zu. Mit der Thread-Zahl mitzuwachsen brachte den Korpus von 12,75× auf 14,08×.

Faktor gegenüber LAME · ganze Laufzeit

8,33× → 14,08×

Read, Dispatch, frame ring, Prepare, frame ring, Resolve, frame ring, Search, frame ring, Encode, Writeframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchserialDispatchserialDispatchserialframe ringPrepareparallelPrepareparallelPrepareparallelframe ringResolveserialResolveserialResolveserialframe ringSearchparallelSearchparallelSearchparallelframe ringEncodeserialEncodeserialEncodeserialWriteasynchronousWriteasynchronousWriteasynchronous Read, Dispatch, frame ring, Prepare, frame ring, Resolve, frame ring, Search, frame ring, Encode, WriteI/O · 1 threadcompute scheduler · 1 thread per coreI/O · 1 threadframe N−1frame Nframe N+1PCM-PipeMP3-PipeReadasynchronousReadasynchronousReadasynchronousDispatchserialDispatchserialDispatchserialframe ringPrepareparallelPrepareparallelPrepareparallelframe ringResolveserialResolveserialResolveserialframe ringSearchparallelSearchparallelSearchparallelframe ringEncodeserialEncodeserialEncodeserialWriteasynchronousWriteasynchronousWriteasynchronous

Bild 9 von 9

Der vollständige Graph

Sieben Arten von Knoten, bei 16 Threads 21 Tasks auf zwei Schedulern. Etwa alle 13 µs wird ein Frame fertig. Für 45 Stunden Musik braucht ctLame 83 Sekunden statt 19½ Minuten – jede Datei bit-identisch zu LAME.

Warum Fibers den Unterschied machen

Die seriellen Knoten warten mitten in ihrer Arbeit: Dispatch auf einen freien Platz, Resolve auf den Zustand des Vorgänger-Frames, Encode auf die Bits, die er übrig ließ. Auf ThinkMeta ConcurrentTasks hält ein solches Warten nur den Task an; sein Thread rechnet derweil andere Frames, und jeder Knoten bleibt eine einfache Schleife über die Frames in Reihenfolge.

Andere Rechner

  • Intel Core i9-11900K, 8 Kerne: 14,08×
  • Intel Xeon E3-1275 v6, 4 Kerne: 8,14×
  • Intel Core Ultra 7 155H, Notebook: 7,54×

Auf jedem Rechner bit-identisch zu LAME.

Faktor gegenüber LAME · 45 h Musik

14,08×

Selbst ausprobieren

Die Referenz-LAME und der Benchmark liegen auf GitHub. Sie haben eine Arbeitslast, die jeden Kern nutzen soll? Nehmen Sie Kontakt auf.

An unhandled error has occurred. Reload