ctOpus schreibt Byte für Byte dieselben Opus-Dateien wie opusenc und
verteilt eine einzige Datei auf alle Kerne. Mit maximaler Komplexität ist ctOpus doppelt
so schnell wie opusenc mit minimaler; auf 45 Stunden Musik ist es 4,4-mal so schnell wie
opusenc mit denselben Einstellungen. Opus ist von Grund auf sequenziell: Jeder Frame setzt
den Bitstrom, die Energievorhersage und die Filterspeicher des vorigen fort. Die elf
Bilder zeigen, wie daraus Schritt für Schritt ein Task-Graph auf ThinkMeta ConcurrentTasks wurde – bis
hin zu Tasks, die künftige Frames spekulativ vorausrechnen.
Bild 1 von 11
Die Referenz: opusenc
opusenc liest die WAV-Datei, rechnet sie von 44,1 auf 48 kHz um,
kodiert sie und schreibt Ogg-Seiten – alles auf einem Thread,
nacheinander. Während er liest oder schreibt, wartet der Thread auf die
Datei. Jede Stufe trägt Zustand von Frame zu Frame: die Filterspeicher, die
Energievorhersage, das Bit-Reservoir und den Bitstrom. Ein Kern arbeitet,
die anderen warten.
Diese Bits sind die Messlatte: Jedes weitere Bild muss Byte
für Byte dieselbe .opus-Datei schreiben. ctOpus beginnt mit einer Portierung
des Encoders, Funktion für Funktion: Auf einem Thread ist sie gleich schnell
wie opusenc (2,09 s) und bit-identisch.
Wo die Zeit liegt
Ein Profil von opusenc bei Komplexität 10: Der Resampler braucht
34,5 %, die Quantisierung der Frequenzbänder 25,1 %, die
Tonalitäts-Analyse 8,2 %, der Vorfilter 6,6 %, die
Transformation und die Zeit-Frequenz-Entscheidungen zusammen etwa
9 %. Die Testmusik ist durchweg 44,1-kHz-Stereo, der Resampler
läuft also bei jeder Datei mit.
Komplexität
Opus tauscht Geschwindigkeit gegen Qualität, von Komplexität 0
(am schnellsten) bis 10 (am besten, die Voreinstellung). Für die
Testdatei braucht opusenc bei Komplexität 10 2,16 s, bei
Komplexität 0 0,97 s. Das Ziel von ctOpus: Komplexität 10
schneller als opusenc mit Komplexität 0.
Laufzeit · eine Datei, 254 s Musik, comp 10
2,16 s
Bild 2 von 11
Lesen und Schreiben asynchron
Die Datei-Ein- und -Ausgabe wird vom Kodieren getrennt. Lesen
und Schreiben werden Tasks auf dem I/O-Scheduler; ein
einziger Task kodiert und macht alles, was opusenc macht. Lesen liest mit fünf überlappenden Anfragen zu 1 MB in einen Lesepuffer voraus, der Encode-Task nimmt sich
nur noch fertige Blöcke. Die Ogg-Seiten kopiert der Encode-Task in einen
Schreibpuffer, und Schreiben bringt volle Blöcke in die Datei, zwei Anfragen
gleichzeitig.
Warten Lesen oder Schreiben auf die Platte, belegen sie keinen Thread: Der
I/O-Scheduler führt in der Zeit den anderen Task aus. Gemessen mit der Datei frisch von der Platte ist das gleich schnell oder schneller – und bit-identisch in jeder Prüfung. Mit einem einzigen Encode-Task dauern beide Wege gleich lang; mit dem ganzen Graphen von Bild 10 spart derselbe Schritt 2 bis 3 %, weil dort der Encode-Task die Ogg-Seiten auf dem kritischen Pfad schrieb: 16 Threads 0,486 → 0,474 s, 4 Threads 0,780 → 0,760 s.
Lesepuffer und Schreibpuffer
Der Lesepuffer fasst 8 Blöcke zu 1 MB, der Schreibpuffer 8
Blöcke zu 64 KB. Ein voller Puffer hält den Task an, der ihn füllt,
ein leerer den, der ihn leert. opusenc dagegen schreibt jede Ogg-Seite mit einem
eigenen Aufruf – bei der Testdatei mehrere tausend Mal.
Kalt gemessen
Vor jedem Lauf wurde die Eingabe frisch und ungepuffert kopiert, kam
also wirklich von der Platte. Ein Encode-Task: 1,891 s synchron, 1,876 s asynchron – im Rauschen; er verbraucht seine Eingabe mit etwa 25 MB/s, ein Bruchteil dessen, was die Platte liefert. Blockgröße und Zahl der
gleichzeitigen Anfragen spielen keine Rolle (±3 %).
Laufzeit · Datei frisch von der Platte, ein Encode-Task
1,891 → 1,876 s · opusenc 2,083 s
Bild 3 von 11
Resampeln parallel
Verteilen (Distribute) entpackt die WAV-Daten in die Samples – auf dem Rechen-Scheduler mit hoher Priorität (dicker Rahmen), weil alle Resampler auf seine Blöcke warten. Der Resampler berechnet jedes Ausgabesample
allein aus der Eingabe, seine Filterphase folgt aus der Position –
Blöcke lassen sich deshalb unabhängig rechnen, auf allen Kernen, in
beliebiger Reihenfolge, mit exakt denselben Werten. Der Encode-Task nimmt die
Blöcke in Reihenfolge aus dem Blockring.
Der Resampler ist aus dem kritischen Pfad verschwunden: 1,86 s bei
Komplexität 10, bei Komplexität 0 0,70 s statt 0,97 s (1,38×).
Übrig bleibt fast nur noch der serielle Encoder.
Samples und Blockring
Die ganze Eingabe liegt in reserviertem Speicher, gefüllt beim
Verteilen und freigegeben, sobald alle Blöcke sie gelesen haben.
Verteilen gibt einen Block frei, sobald seine Eingabe vollständig
ist und im Ring ein Platz frei ist; ein Platz fasst 16.384
resampelte Samples je Kanal.
Warum Verteilen rechnet
Seit das Lesen asynchron ist, wartet Verteilen nicht mehr auf die
Platte, nur noch auf andere Tasks – und es rechnet nur: Samples
umwandeln und nach Kanälen aufteilen. Es gehört deshalb auf den
Rechen-Scheduler, der weit von einer Sättigung entfernt ist. Gemessen
gegen den I/O-Scheduler: 16 Threads 0,474 → 0,466 s, 4 Threads
0,760 → 0,744 s, bit-identisch.
Gleiche Summen, gleiche Bits
Die parallelen Blöcke summieren in derselben Reihenfolge wie
opusenc, und am Dateiende hängt Verteilen dieselbe Verlängerung des
Signals an: eine Fortsetzung der letzten 480 Samples, dann Nullen.
Der Encoder sieht exakt dieselben Zahlen.
Laufzeit · eine Datei, 254 s Musik, comp 10
2,16 → 1,86 s (1,16×)
Bild 4 von 11
Die Analyse läuft voraus
Die Tonalitäts-Analyse eines Frames hängt nur vom Signal ab, nicht von den
Entscheidungen des Encoders. Sie wird ein eigener Task, der dem Encode-Task
vorausläuft und sein Ergebnis für jeden Frame in einen Ring legt. Der
Blockring hat jetzt zwei Leser: Ein Platz ist erst wieder frei, wenn beide
ihn haben. Der Encode-Task liest die Blöcke weiterhin selbst (der Pfeil rechts).
Dazwischen wurde der Encoder selbst nach C++ portiert: Das allein senkte
die Laufzeit von 1,86 auf 1,73 s.
Zeitstempel zeigen: Der Encode-Task wartet jetzt weder auf Blöcke
(1 ms) noch auf die Analyse (2 ms). Die verbleibenden 1,5 s
sind reine serielle Arbeit, davon etwa 0,67 s die Quantisierung der
Frequenzbänder.
Vorsprung mit Bremse
Die Analyse spielt genau dieselbe Folge von Aufrufen nach wie
opusenc und hält eine Historie von 512 Einträgen, damit sie nichts
überschreibt, was der Encode-Task noch liest. Sie läuft höchstens 200
Frames voraus; ist sie so weit vorn, wartet sie, bis der Abstand auf
die Hälfte geschrumpft ist – ohne diese Bremse weckte der
Encode-Task sie bei jedem Frame, das kostete bei 16 Threads
0,13 s.
Laufzeit · eine Datei, 254 s Musik, comp 10
1,86 → 1,52 s
Bild 5 von 11
Die Signalstufe wandert in die Analyse
Auch ein Teil des Encoders hängt nur vom Signal ab, nicht von früheren
Entscheidungen: Vorverzerrung, Ton- und Transientenerkennung, die
Tonhöhensuche. Der Analyse-Task rechnet diese Signalstufe voraus und gibt
Signal-Frames mit seinen Ergebnissen weiter.
Der Encode-Task vergleicht die komplette Eingabe dieser Stufe – etwa
16 KB je Frame – und übernimmt einen Frame nur bei exakter
Gleichheit. Läuft die Analyse einmal auseinander, wird es langsamer, nie
falsch. Gemessen: 12.725 von 12.725 Frames übernommen. ctOpus
braucht jetzt 0,62 der Zeit von opusenc im selben Lauf, vorher 0,74.
Eine neue Messreihe
Ab hier laufen alle Varianten abwechselnd im selben Lauf, opusenc
eingeschlossen: An einzelnen Tagen war die Maschine 10 bis 15 %
langsamer. Die Zeiten sind deshalb nicht direkt mit den Bildern davor
vergleichbar.
Wenn die Analyse abweicht
Nach einem Reset, im Sprachmodus oder während einer Blende passt die
Analyse womöglich nicht mehr zum Encoder. Der Encode-Task bemerkt es
beim Vergleich und rechnet den Frame selbst.
Laufzeit · dieselbe Datei, alle Varianten im selben Lauf
1,47 s · opusenc 2,38 s
Bild 6 von 11
Das Front-End wird vorausberechnet
Nach der Signalstufe rechnet die Analyse jetzt auch das Front-End des
nächsten Frames: den Vorfilter, die Transformation in den Frequenzbereich,
die Bandenergien und, ab 5 ms langen Frames, Normierung,
Bit-Verteilung und Zeit-Frequenz-Entscheidungen. Sie hängen nur von wenigen
Werten des seriellen Encoders ab, und die Analyse nimmt sie einfach an
– „wie beim letzten Abgleich“.
Der Encode-Task übernimmt ein Front-End nur, wenn diese Werte stimmen.
Nach einem Fehlschuss schickt er seine echten Werte zurück (gestrichelt),
und die Analyse rechnet ab dort neu. Trefferquote: 12.724 von
12.725 Frames. Komplexität 10 ist jetzt etwa so schnell wie opusenc
mit Komplexität 0 (0,97 s im selben Lauf).
Beim Bau gefunden
Ein halb geschriebenes Ergebnis, das zu früh als fertig galt, und
eine Verklemmung zwischen Korrektur und Warten auf Blöcke –
beides gefunden und behoben, bevor der Schritt fertig war.
Laufzeit · dieselbe Datei, alle Varianten im selben Lauf
1,21 → 1,00 s
Bild 7 von 11
Das Front-End wird ein Task, ein Helfer rundet mit
Das Front-End wird ein eigener Task: Die Analyse muss für eine Korrektur
nicht mehr unterbrochen werden, und der Encode-Task wartet nicht mehr auf
sie.
Bei hoher Komplexität quantisiert der Encoder jedes Stereo-Band zweimal,
vom selben Zustand aus, mit ab- und mit aufgerundetem Stereowinkel, und
behält den günstigeren Versuch. Den zweiten Versuch rechnet jetzt ein
Helfer, gleichzeitig mit dem ersten im Encode-Task. Ein
Versuch dauert nur 1 bis 2 µs, deshalb warten beide Seiten zuerst aktiv
und schlafen erst danach.
Ziel erreicht: Komplexität 10 ist 19 % schneller als
opusenc mit Komplexität 0 und 2,7-mal so schnell wie opusenc mit
Komplexität 10.
Die Grenze: der Turbo-Takt
Die Quantisierung der Bänder allein braucht 0,72 s; mit zwei
weiteren ausgelasteten Kernen 0,76 s, mit sechs 0,80 s. Jeder
zusätzlich arbeitende Kern senkt den Turbo-Takt aller Kerne, auch des
seriellen.
Laufzeit · dieselbe Datei, alle Varianten im selben Lauf
0,80 s · opusenc comp 0: 0,99 s
Bild 8 von 11
Der Encode-Task wird weiter entlastet
Der Graph bleibt gleich, die Arbeit wandert. Die Eingangsstufe –
Gleichanteil-Filter, Stille-Erkennung, Frame-Energie, Stereobreite –
geht in die Analyse, die Stereowinkel der obersten Ebene gehen in das
Front-End. Nach dem letzten Rundungsversuch lässt der Encode-Task Arbeit
weg, die nur den Versuchen diente, und er kopiert weniger.
Was der Encode-Task nicht selbst rechnen muss, verkürzt die serielle Kette,
und jede Übernahme ist an einen exakten Vergleich gebunden. Im selben Lauf
braucht opusenc 1,02 s bei Komplexität 0 und 2,11 s bei
Komplexität 10.
Was übrig bleibt
Der Encode-Task braucht jetzt etwa 0,68 s je Datei, davon
0,31 s für seinen eigenen Rundungsversuch. Dieses serielle
Rückgrat ist das Ziel des nächsten Schritts.
Laufzeit · dieselbe Datei, alle Varianten im selben Lauf
0,80 → 0,70 s
Bild 9 von 11
Spekulation: Frame N+1 auf einer Kopie
Innerhalb eines Frames sind die Bänder seriell, zwischen zwei Frames gehen
aber nur zwei kleine Dinge über: ein paar übrige Bits am Ende des Frames und
der Zustand des Bitstrom-Kodierers, der erst spät gebraucht wird, wenn
überhaupt. Der Encode-Task kopiert deshalb seinen Zustand, bevor er Frame N
quantisiert, setzt für die übrigen Bits einen Schätzwert ein, und die
Spekulation quantisiert gleichzeitig Frame N+1 auf der
Kopie und protokolliert dabei jedes geschriebene Symbol.
Kommt der Encode-Task bei Frame N+1 an, prüft er jede Annahme. Stimmen alle,
spielt er nur das Protokoll ab, statt den Frame zu rechnen; sonst bricht die
Spekulation an der nächsten Bandgrenze ab. Etwa 96 % der spekulierten
Frames werden übernommen – rund 20 % schneller. Für ihren
Rundungsversuch hat die Spekulation einen eigenen Helfer.
Die Signalstufe wird ein Task
Mit der Spekulation wurde die Analyse zum Engpass: Frame N+1 fehlte
bei 45 % der Abfragen. Mit der Signalstufe auf einem eigenen Task
fehlte er nur noch bei etwa 100 von 12.725.
Wann nicht spekuliert wird
Encode-Task und Spekulation wechseln sich ab: Übernimmt der Encode-Task
Frame N+1, rechnet er selbst N+2 und spekuliert auf N+3. Ist das
echte Ergebnis schon da, bevor die Spekulation starten könnte,
unterbleibt sie. Und unter 7 Threads gibt es mehr arbeitende Tasks
als Threads – dann kostet die Spekulation mehr, als sie bringt,
und bleibt aus.
Laufzeit · Testdatei, 8 Threads, ohne → mit einer Spekulationsstufe
0,70 → 0,56 s
Bild 10 von 11
Die Kaskade: drei Frames voraus
Mit genug Threads arbeitet der serielle Encoder mehrere Frames voraus: Die
Kopie der ersten Spekulation baut die Kopie für die zweite, diese die für die
dritte. Bis zu drei Spekulationen rechnen die Frames N+1 bis N+3, während der
Encode-Task Frame N quantisiert, ab 12 Threads jede mit einem eigenen
Helfer. Das ist der heutige Graph.
Für die Testdatei braucht ctOpus bei Komplexität 10 0,50 s, bei
Komplexität 0 0,31 s – opusenc 2,21 s und 0,99 s.
ctOpus mit maximaler Komplexität ist doppelt so schnell wie opusenc
mit minimaler. Für 45 Stunden Musik braucht es 289,5 Sekunden statt
21 Minuten, 561-fache Echtzeit – alle 343 Dateien bit-identisch.
Wie viele Stufen sich lohnen
Komplexität 10, die Testdatei:
8 Threads: 0,71 s ohne Spekulation, 0,56 s mit einer Stufe, 0,53 s mit zwei
10 Threads: 0,71 s, 0,56 s, 0,46 s
drei Stufen mit eigenen Helfern, 12 Threads: 0,455 s
Daraus die Regel: keine Spekulation unter 7 Threads, eine Stufe bei
7, drei ab 8, die Helfer der Stufen erst ab 12 – darunter
kosten sie mehr Takt, als sie bringen.
Warum Fibers den Unterschied machen
Rund ein Dutzend Stufen laufen gleichzeitig, und jede ist eine
einfache Schleife, die auf ihren nächsten Frame wartet. Auf ThinkMeta ConcurrentTasks
hält ein solches Warten nur den Task an; sein Thread rechnet derweil
eine andere Stufe, und alle Stufen teilen sich einen Thread je
logischem Prozessor. Eine falsche Spekulation wird nicht von außen
abgebrochen: Der Task prüft an jedem Band und wartet wieder.
Andere Rechner
Intel Core i9-11900K, 8 Kerne: 4,38×
Intel Xeon E3-1275 v6, 4 Kerne: 4,12×
Intel Core Ultra 7 155H, Notebook: 2,52×
Auf dem Notebook erreichen einzelne Titel 3,53×, die album-langen
Dateien am Ende des Laufs nur 1,89× – wahrscheinlich seine
Grenze unter Dauerlast. Auf jedem Rechner bit-identisch zu opusenc.
Gemessen wurde vor dem asynchronen Lesen und Schreiben (Bild 2).
Faktor gegenüber opusenc · 343 Dateien, 45 h Musik · noch mit synchronem Lesen und Schreiben
4,38×
Bild 11 von 11
Der Graph im Code
Bis hierher war der Graph über den Code verteilt: Alle Tasks griffen in eine
gemeinsame Datenstruktur. Jetzt steht der Graph an einer Stelle im Code, so
wie er gezeichnet ist: Er besitzt jede Kante und jeden Knoten in der
Reihenfolge des Datenflusses, startet die Tasks mit ihren Prioritäten und
wartet, bis alle fertig sind. An keiner Rechnung ändert sich etwas.
Der Weg mit einem einzigen Encode-Task bleibt als Referenz – etwa für
Eingaben mit 48 kHz, die nichts zu resampeln haben. Mit dem asynchronen
Lesen und Schreiben ist er genau der Graph von Bild 2.
Struktur zum Nulltarif
Gemessen gegen den Stand davor, alt und neu abwechselnd, 11 Paare:
alle Threads 0,438 → 0,440 s, 4 Threads 0,747 →
0,757 s, ein Thread 1,836 → 1,841 s. Die einzelnen
Paare streuen von −8 % bis +9 % – Rauschen. Die
Struktur kostet nichts, und jede Prüfung bleibt bit-identisch.
Laufzeit · Testdatei, alle Threads, vor → nach dem Umbau im selben Lauf
0,438 → 0,440 s
Selbst ausprobieren
ctOpus, die Referenz-Version von opusenc und der Benchmark für Ihre eigene Musik liegen auf
GitHub.
Sie haben eine Arbeitslast, die jeden Kern nutzen soll?
Nehmen Sie Kontakt auf.