1 Punkte von GN⁺ 2025-05-23 | 1 Kommentare | Auf WhatsApp teilen
  • 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 samply und 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 MaybeUninit und verschiebt die Initialisierung von lr_bak; dadurch sank die Gesamtlaufzeit um etwa 1,6 %
  • Die zweite Verbesserung ersetzt den ineffizienten Vergleich, den das standardmäßige PartialEq für kleine numerische structs erzeugte, durch einen Vergleich auf Basis von as_bytes() aus zerocopy, 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

  • rav1d ist ein Rust-Port von dav1d
    • dav1d wurde mit c2rust konvertiert
    • Die Assembly-optimierten Funktionen von dav1d wurden 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-basierte rav1d etwa 5 % langsamer als der C-basierte dav1d
  • Lokale Messungen wurden auf einem MacBook Air M3 mit 8 Kernen durchgeführt
    • rav1d: Commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
    • rustc 1.88.0-nightly, LLVM 20.1.2
    • dav1d: 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 rav1d etwa 6 Sekunden bzw. 9 % langsamer
    • Die LLVM-Versionen von clang und rustc unterschieden sich nur in der Patch-Version

Profiling-Ansatz

  • Für das Profiling wurde samply verwendet
    • 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
    • dav1d ruft cdef_filter_8x8_neon und cdef_filter_4x4_neon auf und dispatcht jeweils die zugehörigen Assembly-Funktionen
    • Bei rav1d übernimmt cdef_filter_neon_erased das Dispatching aller Assembly-Funktionen
  • Die Sample-Zahl von cdef_filter8_pri_sec_edged_8bpc_neon war in beiden Snapshots nahezu gleich, was bestätigte, dass die Vergleichsrichtung stimmte
  • Die Differenz bei cdef_filter_neon_erased und rav1d_cdef_brow entspricht zusammengenommen etwa 1 % der Gesamtlaufzeit von rav1d
    • Die Self-Samples von cdef_filter_{8x8,4x4}_neon in dav1d summieren sich auf etwa 400
    • cdef_filter_neon_erased in rav1d kommt auf etwa 670 Self-Samples
    • dav1d_cdef_brow_8bpc hat 1790 Samples, rav1d_cdef_brow 2350 Samples

Verbesserung 1: Nullinitialisierung des temporären Puffers entfernen

  • cdef_filter_neon_erased erzeugt einen temporären Puffer als Align16([0u16; TMP_LEN])
    • TMP_LEN ist im Worst Case 12 * 16 + 8 = 200
    • Dadurch wird ein temporärer Puffer, entsprechend [u16; 200], mit Nullen gefüllt
  • Der entsprechende C-Code in dav1d erzeugt einen Stack-Puffer der Form uint16_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
  • Im LLVM IR von rav1d erscheint Code, der per llvm.memset 400 Byte mit Nullen füllt
    • Der Rust-Compiler konnte nicht erkennen, dass diese Initialisierung entfernt werden darf
  • Mit MaybeUninit wird die Nullinitialisierung des temporären Puffers vermieden
    • Align16([0u16; TMP_LEN]) wurde zu Align16([MaybeUninit::<u16>::uninit(); TMP_LEN]) geändert
    • Die Signaturen interner Funktionen wurden auf Formen wie tmp: *mut MaybeUninit<u16> und tmp: &[MaybeUninit<u16>] angepasst
    • Da dies innerhalb eines bereits unsafe Codepfads behandelt wurde, kam kein neuer unsafe-Block hinzu
  • Nach der Änderung sanken die Self-Samples von cdef_filter_neon_erased von 670 auf 274
    • Das lag leicht unter der Summe der Self-Samples von cdef_filter_{8x8,4x4}_neon in dav1d

Erweiterung von Verbesserung 1: Initialisierung innerhalb von Schleifen reduzieren

  • Bei der Suche nach weiteren großen Align16-Puffern wurde die Initialisierung von lr_bak in rav1d_cdef_brow entdeckt
    • Der vorhandene Code initialisierte lr_bak bei jeder Iteration innerhalb der Schleife mit Nullen
    • Der entsprechende dav1d-Code initialisiert diesen Puffer nicht
  • Hier war die Umstellung auf MaybeUninit schwieriger, daher wurde die Erzeugung von lr_bak aus 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 rav1d 72,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 dav1d blieb 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, for und kurzen Utility-Aufrufen
  • Mit einem release-with-debug-Profil wurde erneut gebaut, um die Sample-Verteilung auf Zeilenebene zu prüfen
    • if cand.mv.mv[0] == mv {
    • if cand.mv == mvp {
    • Diese zwei Zeilen machten zusammen etwa 600 Samples aus
  • Mv in Rust ist eine kleine Struktur, die #[derive(PartialEq)] verwendet
    • #[repr(C)]
    • y: i16, x: i16
  • mv in dav1d ist als union definiert
    • struct { 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 union verwendet, wird der Feldzugriff unsafe, was alle Verwendungsstellen von Mv betreffen könnte
    • Stattdessen wurde AsBytes aus zerocopy genutzt, um die Byte-Repräsentation zu vergleichen
    • In impl PartialEq for Mv wird self.as_bytes() == other.as_bytes() verwendet
    • Eine Prüfung mit Godbolt zeigte, dass dies dieselbe optimierte Assembly erzeugt wie ein transmute-basierter Ansatz
  • Eine ähnliche Optimierung wurde auch auf RefMvs{Mv,Ref}Pair angewendet
    • 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 PartialEq kleiner Structs ineffiziente Codegenerierung verursacht, hängt mit dem Rust-Issue #140167 zusammen
  • In C kann bei struct { int16_t y, x; } nur y initialisiert sein, während x uninitialisiert bleibt
    • Wenn der Vergleich this.y == other.y && this.x == other.x lautet und alle y unterschiedlich sind, muss x nicht 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
  • Die zugehörige Diskussion behandelt, dass LLVM keine Möglichkeit hat, die Eigenschaft „Loads über diesen Pointer lesen immer initialisierte Bytes“ auszudrücken
  • zerocopy kann die Sicherheitsbedingungen dafür, eine Struktur als Byte-Slice darzustellen, statisch prüfen; so ließ sich ein optimierter Vergleich ohne neues unsafe implementieren

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 unsafe in die Codebasis ein
  • Die endgültige Laufzeit von rav1d beträgt 72,182 Sekunden und ist damit 2,3 % schneller als der Ausgangspunkt
    • Zu den 67,912 Sekunden von dav1d bleibt ein Abstand von etwa 4,2 Sekunden
    • Rund 30 % des anfangs beobachteten Performance-Abstands wurden geschlossen
  • Zwischen den beiden Implementierungen bleibt weiterhin ein Abstand von etwa 6 %; der Vergleich der Profiler-Snapshots von dav1d und rav1d kann weiter zur Suche nach zusätzlichen Optimierungen genutzt werden

1 Kommentare

 
GN⁺ 2025-05-23
Hacker-News-Meinungen
  • Das Thema rund um den Vergleich zweier u16-Werte ist interessant
    https://github.com/rust-lang/rust/issues/140167

    • Überraschend, dass in der Diskussion Store Forwarding nicht erwähnt wurde
      Die Codegenerierung mit -O3 ist seltsam, aber die Ausgabe mit -O2 ist 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 ist
    • Schön, dass diese Diskussion nicht aus 14 Seiten Kommentaren wie „Ich habe das Problem auch“ und „Wann wird das behoben?“ besteht
      Als Webentwickler finde ich GitHub Issues oft ziemlich dürftig
    • Dieser Fall scheint die Komplexität des Compilerbaus zu zeigen
      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

    • Normalerweise vertraue ich den Benchmarks von rbultje, aber im rav1d-Tracking-Issue gibt es Multithread-Zahlen für mehrere Plattformen, und die zeigen keinen so großen Unterschied
      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
    • Schon das Lesen des ffmpeg-Twitter-Accounts reicht, um einem die Lust auf ffmpeg zu nehmen
      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
    • Eine gesündere Reaktion wäre wohl gewesen, dav1d schneller zu machen
      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

    • Die Optimierung mit 1,5 % ist aarch64-spezifisch, daher wirkt es etwas unfair, die Gesamtzahl unverändert zu beanspruchen
      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

    • Ich frage mich, ob man den Compiler auf Rust/LLVM-Seite so verbessern kann, dass diese Optimierung angewendet wird, wann immer sie möglich ist
      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

    • WUFFS wäre großartig für das Parsen von Containerdateien wie Matroska, webm oder mp4, scheint aber für einen Videodecoder überhaupt nicht geeignet
      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

    • Es sieht nicht so aus, als wäre einfach nur perf verwendet worden, sondern eher nach differenziellem Profiling der C- und Rust-Version mit manuellem Abgleich
      perf diff gibt es zwar, aber es kann unterschiedliche Symbolnamen nicht zuordnen, und es scheint auch nicht besonders häufig genutzt zu werden
    • Das war wohl möglich, weil man auf Apple-Geräten aus einer aarch64-Perspektive herangegangen ist
      Ich 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