Amélioration des performances du décodeur vidéo rav1d
(ohadravid.github.io)- Il a été constaté que le décodeur AV1 rav1d, écrit en Rust, était environ 9 % plus lent que dav1d, basé sur C
- Des optimisations de l’initialisation des buffers et de la logique de comparaison des structures ont permis des gains de vitesse individuels de 1,5 % et 0,7 % respectivement
- L’outil de profilage samply a été utilisé pour identifier précisément les causes de l’écart de performances entre les deux versions
- À la place de l’implémentation par défaut de PartialEq en Rust, une comparaison au niveau des octets a été utilisée pour améliorer l’efficacité
- Cette optimisation a permis de réduire d’environ 30 % l’écart global de performances, même s’il reste encore de la marge pour d’autres optimisations
Contexte et approche
- rav1d est un projet qui porte le décodeur AV1 dav1d en Rust via c2rust, tout en intégrant des fonctions optimisées en asm et des améliorations de sûreté propres au langage Rust
- Une référence de performance publique a été définie, et rav1d en Rust reste environ 5 % plus lent que dav1d en C
- L’analyse s’est concentrée non pas sur la structure globale d’un décodeur vidéo complexe, mais sur les différences de temps d’exécution binaire avec une entrée identique
- Une comparaison systématique a été menée à l’aide d’outils de mesure de performances (hyperfine) et d’un profiler (samply)
- L’environnement ciblé était une puce macOS M3, avec une exécution en mono-thread pour simplifier l’analyse
Mesure des performances : comparaison de base
- Les builds et benchmarks ont été réalisés avec le même fichier de test (Chimera-AV1-8bit-1920x1080-6736kbps.ivf)
- rav1d : environ 73,9 secondes, dav1d : environ 67,9 secondes, soit un écart d’environ 6 secondes (9 %) sur le temps d’exécution
- Chaque compilateur (Clang, Rustc) utilisait pratiquement la même version de LLVM
Analyse de profilage
- Le profiler samply a servi à comparer, pour chaque exécutable, le nombre d’échantillons par fonction
- Les chemins d’appel et la distribution des échantillons dans les fonctions assembleur basées sur NEON (ARM SIMD) ont été examinés en priorité
- dav1d sépare les appels asm via des fonctions de filtre distinctes, tandis que rav1d gère l’ensemble via une seule fonction de dispatch
- La fonction
cdef_filter_neon_erasedprésentait environ 270 échantillons Self de plus que la somme des deux fonctions équivalentes dans dav1d (soit environ 1 % du total) - L’analyse a montré qu’une zone d’initialisation d’un buffer temporaire (zero-initialized buffer) était inutilement trop grande
Optimisation par suppression de l’initialisation des buffers
- Pour des raisons de sûreté, Rust effectue automatiquement un zeroing avec des écritures comme [0u16; LEN]
- En revanche, C (dav1d) ne zero-initialise pas explicitement le buffer et n’écrit que dans la zone réellement utilisée
- En Rust, std::mem::MaybeUninit a permis d’éliminer le coût de cette initialisation inutile
- Les échantillons Self de
cdef_filter_neon_erasedsont tombés de 670 à 274 - Un autre gros buffer
Align16a lui aussi vu son initialisation déplacée hors de la boucle, réduisant ce coût à une seule fois - Après optimisation, le benchmark est passé à environ 72,6 secondes, soit un gain de 1,2 seconde (1,5 %)
Optimisation de la comparaison de structures
- L’analyse des piles inversées du profiler a révélé que la fonction add_temporal_candidate fonctionnait de manière plus inefficace que prévu
- La comparaison des champs de la structure Mv dans cette fonction (implémentation automatique de
PartialEq) générait du code inutilement lent - En C, une
unionest utilisée pour effectuer une comparaison efficace sur des unités de typeuint32_t - En Rust, sans recourir à
unsafe, une comparaison par tranches d’octets a été implémentée avec le trait zerocopy::AsBytes - Cette optimisation a apporté un nouveau gain de 0,5 seconde (environ 0,7 %)
Résultats et synthèse
- Deux optimisations simples (suppression de l’initialisation des buffers, comparaison de structures par octets) ont permis de réduire le temps d’exécution de plus de 2 %
- Il reste encore environ 6 % d’écart de performances, ce qui laisse une marge importante pour d’autres optimisations
- L’approche par comparaison entre instantanés du profiler s’est révélée efficace
- Le potentiel d’optimisations supplémentaires fondées sur l’analyse des instantanés de rav1d et dav1d est élevé
- Grâce aux retours actifs et à la collaboration des mainteneurs du projet, des améliorations ont pu être mises en œuvre sans compromettre la sûreté
Résumé
- Les outils de profilage (samply) et de benchmark (hyperfine) ont permis d’analyser finement l’écart de 6 secondes (9 %) entre rav1d et dav1d
- Deux optimisations principales :
- suppression d’un zeroing inutile de buffer dans du code spécifique ARM (1,2 seconde, -1,6 %)
- remplacement de l’implémentation
PartialEqd’une petite structure numérique par une comparaison rapide d’octets (0,5 seconde, -0,7 %)
- Chaque optimisation reste concise, en quelques dizaines de lignes, sans nouveau code
unsafe - La collaboration avec les mainteneurs et la revue de PR ont permis d’améliorer à la fois la fiabilité et la qualité
- Il reste encore environ 6 % d’écart de performances, ce qui laisse largement de quoi poursuivre les optimisations guidées par le profiler
Go ahead and give this a try! Maybe rav1d can eventually become faster than dav1d 👀🦀.
1 commentaires
Réactions sur Hacker News
u16est un sujet intéressant, avec un lien vers l’issue correspondante https://github.com/rust-lang/rust/issues/140167-O3semble excessif, mais en-O2cela paraît raisonnable ; explication concrète que si l’une des structures vient juste d’être manipulée, tenter un chargement 32 bits peut faire échouer le store forwarding et rendre le gain de performance nul ; remarque qu’en situation non inline / sans PGO, le compilateur manque d’informations pour juger si l’optimisation est appropriéeperf; l’auteur pensait que le problème de zeroing avait déjà été abordé dans le premier billet ; la deuxième optimisation était plus complexe et plus intéressante, mais elle aussi a été guidée parperf, d’où le conseil de ne pas sous-estimer l’utilité de l’outilperfperf, mais aussi d’un processus de profilage différentiel entre la version C et la version Rust, puis d’un appariement manuel pour identifier le problème ; mention de la fonctionperf diff, mais aussi de sa limite puisque les noms de symboles diffèrent, ce qui rend l’appariement automatique difficile