Leistungsverbesserungen am rav1d-Videodecoder
(ohadravid.github.io)- Der Rust-basierte AV1-Decoder rav1d war bei derselben Eingabe etwa 6 Sekunden bzw. 9 % langsamer als der C-basierte dav1d; zwei kleine Optimierungen reduzierten die Laufzeit von 73,914 auf 72,182 Sekunden
- Die Analyse verglich die beiden Binaries unter gleichen Bedingungen mit
samplyund nutzte gemeinsam verwendete Arm-Assembly-Funktionen als Anker, um Unterschiede zwischen Rust-Wrappern und Funktionsimplementierungen nachzuverfolgen - Die erste Verbesserung vermeidet die Nullinitialisierung eines temporären Puffers im Arm-Pfad mit
MaybeUninitund verschiebt die Initialisierung vonlr_bak; dadurch sank die Gesamtlaufzeit um etwa 1,6 % - Die zweite Verbesserung ersetzt den ineffizienten Vergleich, den das standardmäßige
PartialEqfür kleine numerischestructs erzeugte, durch einen Vergleich auf Basis vonas_bytes()auszerocopy, was weitere rund 0,5 Sekunden einspart - Die beiden PRs brachten zusammen 2,3 % Verbesserung ohne neues
unsafe; die Messung ist jedoch auf macOS mit M3-Chip, Single-Thread-Ausführung und eine bestimmte Benchmark-Eingabe beschränkt, und zu dav1d bleibt weiterhin ein Abstand von etwa 4,2 Sekunden
Basisleistung und Messumgebung
rav1dist ein Rust-Port vondav1ddav1dwurde mitc2rustkonvertiert- Die Assembly-optimierten Funktionen von
dav1dwurden integriert - Enthält Arbeiten daran, den Code idiomatischer für Rust und sicherer zu machen
- memorysafety.org veranstaltete einen Wettbewerb zur Leistungsverbesserung von
rav1d; im Ausgangszustand war der Rust-basierterav1detwa 5 % langsamer als der C-basiertedav1d - Lokale Messungen wurden auf einem MacBook Air M3 mit 8 Kernen durchgeführt
rav1d: Commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - Eingabedatei:
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - Ausführungsoptionen:
--threads 1, Ausgabe nach/dev/null
- Die anfänglichen
hyperfine-Ergebnisse lagen bei rav1d 73,914 Sekunden und dav1d 67,912 Sekunden- Mit derselben Beispieldatei war
rav1detwa 6 Sekunden bzw. 9 % langsamer - Die LLVM-Versionen von
clangundrustcunterschieden sich nur in der Patch-Version
- Mit derselben Beispieldatei war
Profiling-Ansatz
- Für das Profiling wurde
samplyverwendet- Die standardmäßige Sampling-Rate beträgt 1000 Hz
- Eine Differenz von 500 Samples in einer bestimmten Funktion entspricht grob etwa 0,5 Sekunden Laufzeitunterschied
- Da die beiden Binaries ähnlich sind und deterministisch arbeiten, war es sinnvoll, statt den gesamten Videodecoder neu zu verstehen, die Sample-Differenzen pro Funktion zu vergleichen
- Gemeinsam genutzte optimierte Assembly-Aufrufe wurden als Anker verwendet
dav1druftcdef_filter_8x8_neonundcdef_filter_4x4_neonauf und dispatcht jeweils die zugehörigen Assembly-Funktionen- Bei
rav1dübernimmtcdef_filter_neon_eraseddas Dispatching aller Assembly-Funktionen
- Die Sample-Zahl von
cdef_filter8_pri_sec_edged_8bpc_neonwar in beiden Snapshots nahezu gleich, was bestätigte, dass die Vergleichsrichtung stimmte - Die Differenz bei
cdef_filter_neon_erasedundrav1d_cdef_browentspricht zusammengenommen etwa 1 % der Gesamtlaufzeit vonrav1d- Die Self-Samples von
cdef_filter_{8x8,4x4}_neonindav1dsummieren sich auf etwa 400 cdef_filter_neon_erasedinrav1dkommt auf etwa 670 Self-Samplesdav1d_cdef_brow_8bpchat 1790 Samples,rav1d_cdef_brow2350 Samples
- Die Self-Samples von
Verbesserung 1: Nullinitialisierung des temporären Puffers entfernen
cdef_filter_neon_erasederzeugt einen temporären Puffer alsAlign16([0u16; TMP_LEN])TMP_LENist im Worst Case12 * 16 + 8 = 200- Dadurch wird ein temporärer Puffer, entsprechend
[u16; 200], mit Nullen gefüllt
- Der entsprechende C-Code in
dav1derzeugt einen Stack-Puffer der Formuint16_t tmp_buf[200] __attribute__((aligned(16))), initialisiert ihn aber nicht- Dieser Puffer ist Ziel von Schreibvorgängen der
padding-Assembly-Funktion - Anschließend verwendet die
filter-Assembly-Funktion diese Werte unverändert
- Dieser Puffer ist Ziel von Schreibvorgängen der
- Im LLVM IR von
rav1derscheint Code, der perllvm.memset400 Byte mit Nullen füllt- Der Rust-Compiler konnte nicht erkennen, dass diese Initialisierung entfernt werden darf
- Mit
MaybeUninitwird die Nullinitialisierung des temporären Puffers vermiedenAlign16([0u16; TMP_LEN])wurde zuAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN])geändert- Die Signaturen interner Funktionen wurden auf Formen wie
tmp: *mut MaybeUninit<u16>undtmp: &[MaybeUninit<u16>]angepasst - Da dies innerhalb eines bereits
unsafeCodepfads behandelt wurde, kam kein neuerunsafe-Block hinzu
- Nach der Änderung sanken die Self-Samples von
cdef_filter_neon_erasedvon 670 auf 274- Das lag leicht unter der Summe der Self-Samples von
cdef_filter_{8x8,4x4}_neonindav1d
- Das lag leicht unter der Summe der Self-Samples von
Erweiterung von Verbesserung 1: Initialisierung innerhalb von Schleifen reduzieren
- Bei der Suche nach weiteren großen
Align16-Puffern wurde die Initialisierung vonlr_bakinrav1d_cdef_browentdeckt- Der vorhandene Code initialisierte
lr_bakbei jeder Iteration innerhalb der Schleife mit Nullen - Der entsprechende
dav1d-Code initialisiert diesen Puffer nicht
- Der vorhandene Code initialisierte
- Hier war die Umstellung auf
MaybeUninitschwieriger, daher wurde die Erzeugung vonlr_bakaus der Schleife heraus verschoben- Die Initialisierung erfolgt nicht mehr bei jeder Iteration, sondern nur einmal
- Die Einsparung ist klein, reduziert aber unnötige Arbeit derselben Art
- Im Gesamt-Benchmark einschließlich dieser Änderung erreichte
rav1d72,644 Sekunden- 1,2 Sekunden Verbesserung gegenüber den bisherigen 73,914 Sekunden
- Etwa 1,5 % Verbesserung bezogen auf die Gesamtlaufzeit
- Zu den 67,912 Sekunden von
dav1dblieb weiterhin ein Abstand
Verbesserung 2: Gleichheitsvergleich kleiner Structs optimieren
- Beim erneuten Profiling in der Inverted-Stack-Ansicht zeigte sich ein auffälliger Unterschied in
add_temporal_candidate- Der Unterschied zwischen der Rust- und der C-Version betrug etwa 400 Samples, also rund 0,5 Sekunden
- Die Funktion selbst besteht aus etwa 50 Zeilen mit
if,forund kurzen Utility-Aufrufen
- Mit einem
release-with-debug-Profil wurde erneut gebaut, um die Sample-Verteilung auf Zeilenebene zu prüfenif cand.mv.mv[0] == mv {if cand.mv == mvp {- Diese zwei Zeilen machten zusammen etwa 600 Samples aus
Mvin Rust ist eine kleine Struktur, die#[derive(PartialEq)]verwendet#[repr(C)]y: i16,x: i16
mvindav1dist alsuniondefiniertstruct { int16_t y, x; }uint32_t n- Beim Vergleich wird ein 32-Bit-Wert verglichen, etwa
mvstack[n].mv.n == mvp.n
- Wenn man in Rust eine
unionverwendet, wird der Feldzugriffunsafe, was alle Verwendungsstellen vonMvbetreffen könnte- Stattdessen wurde
AsBytesauszerocopygenutzt, um die Byte-Repräsentation zu vergleichen - In
impl PartialEq for Mvwirdself.as_bytes() == other.as_bytes()verwendet - Eine Prüfung mit Godbolt zeigte, dass dies dieselbe optimierte Assembly erzeugt wie ein
transmute-basierter Ansatz
- Stattdessen wurde
- Eine ähnliche Optimierung wurde auch auf
RefMvs{Mv,Ref}Pairangewendet- Das Benchmark-Ergebnis lag bei 72,182 Sekunden
- Etwa 0,5 Sekunden Verbesserung gegenüber dem vorherigen Ergebnis von 72,644 Sekunden
- 2,3 % Verbesserung gegenüber der anfänglichen Basis von 73,914 Sekunden
Rusts standardmäßiges PartialEq und Grenzen der Codegenerierung
- Dass das standardmäßige
PartialEqkleiner Structs ineffiziente Codegenerierung verursacht, hängt mit dem Rust-Issue#140167zusammen - In C kann bei
struct { int16_t y, x; }nuryinitialisiert sein, währendxuninitialisiert bleibt- Wenn der Vergleich
this.y == other.y && this.x == other.xlautet und alleyunterschiedlich sind, mussxnicht gelesen werden - Berücksichtigt man solche Fälle, ist eine Optimierung zu einem einzelnen Speicher-Load nur dann gültig, wenn garantiert ist, dass alle Felder stets initialisiert sind
- Wenn der Vergleich
- Die zugehörige Diskussion behandelt, dass LLVM keine Möglichkeit hat, die Eigenschaft „Loads über diesen Pointer lesen immer initialisierte Bytes“ auszudrücken
zerocopykann die Sicherheitsbedingungen dafür, eine Struktur als Byte-Slice darzustellen, statisch prüfen; so ließ sich ein optimierter Vergleich ohne neuesunsafeimplementieren
Endergebnis und verbleibender Performance-Abstand
- Der erste PR vermeidet teure Nullinitialisierung in einem heißen Arm-spezifischen Pfad
- PR #1397
- 1,2 Sekunden Laufzeitverbesserung
- Etwa -1,6 %
- Der zweite PR ersetzt die standardmäßige
PartialEq-Implementierung kleiner numerischer Structs durch einen bytebasierten Vergleich- PR #1400
- 0,5 Sekunden Laufzeitverbesserung
- Etwa -0,7 %
- Die beiden Änderungen umfassen zusammen nur einige Dutzend Zeilen und führen kein neues
unsafein die Codebasis ein - Die endgültige Laufzeit von
rav1dbeträgt 72,182 Sekunden und ist damit 2,3 % schneller als der Ausgangspunkt- Zu den 67,912 Sekunden von
dav1dbleibt ein Abstand von etwa 4,2 Sekunden - Rund 30 % des anfangs beobachteten Performance-Abstands wurden geschlossen
- Zu den 67,912 Sekunden von
- Zwischen den beiden Implementierungen bleibt weiterhin ein Abstand von etwa 6 %; der Vergleich der Profiler-Snapshots von
dav1dundrav1dkann weiter zur Suche nach zusätzlichen Optimierungen genutzt werden
1 Kommentare
Hacker-News-Meinungen
Das Thema rund um den Vergleich zweier u16-Werte ist interessant
https://github.com/rust-lang/rust/issues/140167
Die Codegenerierung mit
-O3ist seltsam, aber die Ausgabe mit-O2ist plausibel. Wenn eine Struktur gerade erst berechnet wurde, kann der Versuch, sie mit einem einzelnen 32-Bit-Load zu lesen, zu einem Store-Forwarding-Fehlschlag führen, wodurch der Vorteil der Load-Zusammenführung verpuffen kann. Ohne Inlining und ohne PGO fehlen dem Compiler die Informationen, um zu entscheiden, ob diese Optimierung angemessen istAls Webentwickler finde ich GitHub Issues oft ziemlich dürftig
Ich bin nicht sicher, ob C-Compiler diesen Fall allgemein besser behandeln könnten
Wegen solcher Dinge scheint der Twitter-Account von ffmpeg eine ablehnende Haltung gegenüber Rust einzunehmen
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Da ich nicht eingeloggt bin, sehe ich nur den ursprünglichen Tweet; ich frage mich, ob in den Antworten etwas dazu erklärt wurde
Schade, dass es keine vernünftige Alternative gibt, und die Entwickler wirken ziemlich aggressiv. Wenn man die gesamte Pipeline kontrolliert, ist maximale Performance sicher gut; wenn man aber nicht vertrauenswürdige Daten beliebiger Nutzer entgegennimmt, gibt es bei ffmpeg jedes Jahr mindestens fünf oder sechs remote ausnutzbare CVEs. Man sollte das Sandboxing gut absichern
https://ffmpeg.org/security.html
Jenseits der jeweils festgefahrenen Positionen dürfte es einen Mittelweg geben, auf dem alle auf eine sichere und schnelle Lösung hinarbeiten
Wenn man die Kriterien für olympische Rekorde verfeinert und Bolts 100-m-Zeit rückwirkend von 9,63 auf 9,64 Sekunden ändert, interessiert das niemanden. Wenn aber jemand tatsächlich 100 m in 9 Sekunden läuft, bekommt das Aufmerksamkeit. Allerdings nur, wenn es ein Mensch ist; bei einem Strauß wäre es nicht beeindruckend, aber in der Regel treten Strauße nicht über 100 m bei Olympia an
Interessant, dass zwei Tage nach diesem Artikel ein Beitrag über den Performance-Vorteil auftaucht, den man erzielt, wenn man einen Puffer nicht mit Nullen initialisiert
https://news.ycombinator.com/item?id=44032680
Der Titel untertreibt den Artikel
Tatsächlich wird er durch zwei gute Optimierungen um 2,3 % schneller
Wenn man davon ausgeht, dass Arm und x86 künftig den Großteil der Auslieferungen ausmachen, wäre es wohl angemessener, etwa die Hälfte anzusetzen
Ein guter Artikel, und interessant war die Stelle, an der beim Vergleich von Paaren aus 16-Bit-Integern ineffizienter Code gefunden wurde
Rust kann deutlich genauere Informationen darüber haben, ob Speicher initialisiert ist
Unter gleichen Bedingungen sollte ein Codec meiner Meinung nach eher in WUFFS als in Rust geschrieben werden
Allerdings könnte es viel größer sein, etwas so Komplexes wie dav1d neu in WUFFS zu schreiben, als das Ergebnis einer c2rust-Konvertierung aufzuräumen. Ich würde sogar glauben, dass es tausendmal schwieriger ist. Trotzdem denke ich, dass es sich für die Zivilisation insgesamt lohnen würde
Ich meine WUFFS oder eine gleichwertige Spezialzwecksprache, und WUFFS gibt es bereits
Ohne dynamische Speicherallokation ist es schwierig, dynamische Daten zu verarbeiten. Ein Videocodec parst nicht einfach nur eine Datei, um Daten zu erhalten, sondern muss sehr viel ziemlich dynamischen Zustand verwalten
Wenn ein Artikel mit einem lustigen Meme beginnt, weiß man, dass er gut ist
Scheint auch mit der aktuellen Diskussion zusammenzuhängen: $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
Ehrlich gesagt war ich etwas überrascht, dass die erste Optimierung schon mit perf ziemlich klar sichtbar war
Ich glaube, im ersten Beitrag wurde das Problem der Nullinitialisierung des Puffers bereits diskutiert; die zweite Optimierung war definitiv komplexer und interessanter, aber auch sie wurde von perf angezeigt. Dieses Tool sollte man nicht unterschätzen
perf diffgibt es zwar, aber es kann unterschiedliche Symbolnamen nicht zuordnen, und es scheint auch nicht besonders häufig genutzt zu werdenIch sehe oft, dass Menschen mit anderem Hintergrund Lücken finden, die „im Nachhinein offensichtlich“ sind
Das ist wirklich interessant
Ich fragte mich, ob es etwas gibt, das rustc daran hindert, diesen transmute-Trick auszuführen; hätte ich den nächsten Absatz gelesen, hätte ich vor dem Kommentieren von diesem Issue gewusst
https://github.com/rust-lang/rust/issues/140167