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.
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
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
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
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×)
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
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
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×
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×
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.