Amélioration des performances du décodeur vidéo rav1d
(ohadravid.github.io)- Le décodeur AV1 rav1d, basé sur Rust, était environ 6 secondes plus lent, soit 9 %, que dav1d, basé sur C, sur la même entrée, et deux petites optimisations ont réduit le temps d’exécution de 73,914 s à 72,182 s
- L’analyse a comparé les deux binaires dans les mêmes conditions avec
samply, en prenant comme ancre les fonctions assembleur Arm communes pour suivre les différences entre les wrappers Rust et l’implémentation des fonctions - La première amélioration évite l’initialisation à 0 d’un buffer temporaire sur le chemin Arm avec
MaybeUninitet déplace l’emplacement d’initialisation delr_bak, ce qui réduit le temps d’exécution total d’environ 1,6 % - La deuxième amélioration remplace la comparaison inefficace générée par le
PartialEqpar défaut d’une petitestructnumérique par une comparaison basée suras_bytes()dezerocopy, économisant environ 0,5 seconde supplémentaire - Les deux PR apportent au total un gain de 2,3 % sans nouveau
unsafe, mais les mesures sont limitées à une puce M3 sous macOS, en mono-thread et sur une entrée de benchmark spécifique, et il reste encore environ 4,2 secondes d’écart avec dav1d
Performances de référence et environnement de mesure
rav1dest un portage Rust dedav1d- conversion de
dav1davecc2rust - intégration des fonctions optimisées en assembleur de
dav1d - travail de refactorisation pour rendre le code plus idiomatique Rust et plus sûr
- conversion de
- memorysafety.org a lancé un concours d’amélioration des performances de
rav1d, et dans l’état de référence,rav1den Rust était environ 5 % plus lent quedav1den C - Les mesures locales ont été effectuées sur un MacBook Air M3 à 8 cœurs
rav1d: commita654c1e82adb2d9a33ae50d2a82a7a747102cbb6rustc 1.88.0-nightly, LLVM20.1.2dav1d:1.5.1- Homebrew clang
20.1.4 - fichier d’entrée :
Chimera-AV1-8bit-1920x1080-6736kbps.ivf - options d’exécution :
--threads 1, sortie vers/dev/null
- Le résultat initial de
hyperfineétait de 73,914 s pour rav1d et 67,912 s pour dav1d- sur le même fichier d’exemple,
rav1détait environ 6 secondes, soit 9 %, plus lent - les versions LLVM de
clangetrustcne différaient que par le numéro de patch
- sur le même fichier d’exemple,
Méthode de profiling
- Le profiling a été réalisé avec
samply- la fréquence d’échantillonnage par défaut est de 1000 Hz
- un écart de 500 échantillons dans une fonction correspond grossièrement à une différence d’environ 0,5 seconde de temps d’exécution
- Comme les deux binaires sont similaires et se comportent de manière déterministe, il était plus efficace de comparer les écarts d’échantillons fonction par fonction que de réanalyser tout le décodeur vidéo
- Les appels assembleur optimisés utilisés en commun ont servi d’ancre
dav1dappellecdef_filter_8x8_neonetcdef_filter_4x4_neon, qui dispatchent chacune vers les fonctions assembleur correspondantesrav1dutilisecdef_filter_neon_erased, qui gère le dispatch de toutes les fonctions assembleur
- Le nombre d’échantillons de
cdef_filter8_pri_sec_edged_8bpc_neonétait presque identique dans les deux snapshots, confirmant que l’orientation de la comparaison était correcte - Les écarts de
cdef_filter_neon_erasedetrav1d_cdef_browreprésentent ensemble environ 1 % du temps d’exécution total derav1d- la somme des échantillons Self de
cdef_filter_{8x8,4x4}_neondansdav1dest d’environ 400 - les échantillons Self de
cdef_filter_neon_eraseddansrav1dsont d’environ 670 dav1d_cdef_brow_8bpca 1790 échantillons,rav1d_cdef_browen a 2350
- la somme des échantillons Self de
Amélioration 1 : suppression de l’initialisation à 0 du buffer temporaire
cdef_filter_neon_erasedcrée un buffer temporaire avecAlign16([0u16; TMP_LEN])TMP_LENvaut dans le pire des cas12 * 16 + 8 = 200- cela revient à remplir de zéros un buffer temporaire équivalent à
[u16; 200]
- Le code C correspondant dans
dav1dcrée un buffer de pile de la formeuint16_t tmp_buf[200] __attribute__((aligned(16))), mais sans l’initialiser- ce buffer sert de cible d’écriture à la fonction assembleur
padding - la fonction assembleur
filterréutilise ensuite directement ces valeurs
- ce buffer sert de cible d’écriture à la fonction assembleur
- Dans l’IR LLVM de
rav1d, on voit unllvm.memsetqui remplit 400 octets de zéros- le compilateur Rust ne pouvait pas savoir que cette initialisation pouvait être supprimée
- L’utilisation de
MaybeUninitpermet d’éviter cette initialisation à 0 du buffer temporaireAlign16([0u16; TMP_LEN])devientAlign16([MaybeUninit::<u16>::uninit(); TMP_LEN])- les signatures des fonctions internes sont ajustées en
tmp: *mut MaybeUninit<u16>ettmp: &[MaybeUninit<u16>] - comme cela se fait dans un chemin de code déjà
unsafe, aucun nouveau blocunsafen’est ajouté
- Après ce changement, les échantillons Self de
cdef_filter_neon_erasedtombent de 670 à 274- ils deviennent légèrement inférieurs à la somme des échantillons Self de
cdef_filter_{8x8,4x4}_neondansdav1d
- ils deviennent légèrement inférieurs à la somme des échantillons Self de
Extension de l’amélioration 1 : réduire les initialisations dans la boucle
- En cherchant d’autres gros buffers
Align16, une initialisation delr_baka été repérée dansrav1d_cdef_brow- le code précédent réinitialisait
lr_bakà zéro à chaque itération de boucle - le code correspondant dans
dav1dn’initialise pas ce buffer
- le code précédent réinitialisait
- Ici, le passage à
MaybeUninitétait plus difficile, donc la création delr_baka été déplacée hors de la boucle- l’initialisation n’a plus lieu à chaque itération, mais une seule fois
- le gain est modeste, mais il supprime le même type de travail inutile
- Avec ce changement inclus, le benchmark complet donne 72,644 s pour
rav1d- soit un gain de 1,2 seconde par rapport aux 73,914 s initiaux
- environ 1,5 % d’amélioration sur le temps d’exécution total
- un écart subsiste encore avec les 67,912 s de
dav1d
Amélioration 2 : optimisation de la comparaison d’égalité de petites structures
- Un nouveau profiling en vue de pile inversée a mis en évidence un écart notable dans
add_temporal_candidate- la différence entre les versions Rust et C est d’environ 400 échantillons, soit environ 0,5 seconde
- la fonction elle-même est composée d’une cinquantaine de lignes avec des
if, desforet de petits appels utilitaires
- Une reconstruction en profil
release-with-debuga permis d’observer la répartition des échantillons ligne par ligneif cand.mv.mv[0] == mv {if cand.mv == mvp {- à elles deux, ces lignes représentent environ 600 échantillons
- Le type
Mvde Rust est une petite structure utilisant#[derive(PartialEq)]#[repr(C)]y: i16,x: i16
- Dans
dav1d,mvest défini comme uneunionstruct { int16_t y, x; }uint32_t n- lors de la comparaison, on utilise
mvstack[n].mv.n == mvp.n, c’est-à-dire une comparaison sur 32 bits
- Utiliser une
unionen Rust rendrait l’accès aux champsunsafe, avec un impact potentiel sur tous les usages deMv- à la place,
AsBytesdezerocopysert à comparer la représentation binaire impl PartialEq for Mvutiliseself.as_bytes() == other.as_bytes()- une vérification sur Godbolt montre que cela génère le même assembleur optimisé qu’une approche basée sur
transmute
- à la place,
- Une optimisation similaire a aussi été appliquée à
RefMvs{Mv,Ref}Pair- le benchmark donne alors 72,182 s
- soit environ 0,5 seconde de mieux que les 72,644 s précédents
- et 2,3 % de gain par rapport au point de départ à 73,914 s
PartialEq par défaut de Rust et limites de génération de code
- La raison pour laquelle le
PartialEqpar défaut de petites structures produit un code inefficace est liée à l’issue Rust#140167 - En C, une structure
struct { int16_t y, x; }peut avoiryinitialisé alors quexne l’est pas- si la comparaison est
this.y == other.y && this.x == other.xet que toutes les valeurs deydiffèrent, il n’est pas nécessaire de lirex - dans ce cas, optimiser en un seul chargement mémoire n’est valable que si tous les champs sont garantis initialisés
- si la comparaison est
- La discussion associée explique qu’LLVM n’a pas de moyen d’exprimer la propriété selon laquelle « une lecture via ce pointeur lit toujours des octets initialisés »
zerocopypeut vérifier statiquement les conditions de sûreté permettant de représenter une structure comme une tranche d’octets, ce qui a permis d’implémenter une comparaison optimisée sans nouveauunsafe
Résultat final et écart de performances restant
- La première PR évite une coûteuse initialisation à 0 sur un hot path Arm
- PR #1397
- 1,2 seconde gagnée
- environ -1,6 %
- La deuxième PR remplace l’implémentation
PartialEqpar défaut de petites structures numériques par une comparaison basée sur les octets- PR #1400
- 0,5 seconde gagnée
- environ -0,7 %
- Ensemble, ces deux changements représentent quelques dizaines de lignes et n’introduisent aucun nouveau
unsafedans la base de code - Le temps d’exécution final de
rav1dest de 72,182 s, soit 2,3 % plus rapide qu’au départ- il reste environ 4,2 secondes d’écart avec les 67,912 s de
dav1d - cela réduit d’environ 30 % l’écart de performance observé au départ
- il reste environ 4,2 secondes d’écart avec les 67,912 s de
- Un écart d’environ 6 % subsiste encore entre les deux implémentations, et la comparaison des snapshots du profiler entre
dav1detrav1dpeut continuer à servir pour explorer d’autres optimisations
1 commentaires
Avis sur Hacker News
Le problème lié à la comparaison de deux u16 est intéressant
https://github.com/rust-lang/rust/issues/140167
La génération de code avec
-O3est étrange, mais la sortie en-O2est raisonnable. Si une structure vient d’être calculée, tenter de la lire via un unique chargement 32 bits peut provoquer un échec de store forwarding, ce qui annule l’avantage de la fusion des chargements. Sans inlining ni PGO, le compilateur manque d’informations pour déterminer si cette optimisation est appropriéeEn tant que développeur web, je trouve que les issues GitHub sont souvent assez médiocres
Je ne suis pas certain qu’un compilateur C puisse mieux gérer ce problème dans le cas général
C’est probablement à cause de ce genre de choses que le compte Twitter de ffmpeg prend position contre Rust
https://x.com/ffmpeg/status/1924137645988356437?s=46
https://github.com/memorysafety/rav1d/issues/1294
Comme je ne suis pas connecté, je ne vois que le tweet original ; je me demande si certains points ont été expliqués dans les réponses
C’est dommage qu’il n’y ait pas de vraie alternative, et les développeurs semblent assez agressifs. Si l’on contrôle toute la pipeline, les meilleures performances sont probablement souhaitables ; mais si l’on reçoit des données non fiables d’utilisateurs quelconques, ffmpeg a au moins cinq ou six CVE exploitables à distance chaque année. Mieux vaut avoir un sandboxing solide
https://ffmpeg.org/security.html
Au-delà des positions que chacun défend ici, il doit exister un compromis vers lequel tout le monde peut avancer : une solution à la fois sûre et rapide
Si l’on affine les critères de mesure des records olympiques pour réviser rétroactivement le 100 m de Bolt de 9,63 s à 9,64 s, personne ne s’en soucie. Mais si quelqu’un court réellement 100 m en 9 s, cela attire l’attention. À condition que ce soit un humain ; si c’est une autruche, ce n’est pas impressionnant, mais en général les autruches ne participent pas au 100 m olympique
Il est intéressant de voir un article sur les gains de performance obtenus en évitant d’initialiser un buffer à zéro apparaître deux jours après celui-ci
https://news.ycombinator.com/item?id=44032680
Le titre sous-estime l’article
En réalité, il est 2,3 % plus rapide grâce à deux bonnes optimisations
Si l’on considère qu’Arm et x86 représenteront la majeure partie des déploiements à l’avenir, il serait probablement plus juste de compter environ la moitié
Bon article, et j’ai trouvé intéressant le passage où du code inefficace a été repéré dans la comparaison de paires d’entiers 16 bits
Rust peut disposer d’informations bien plus précises sur l’état d’initialisation de la mémoire
À conditions égales, je pense qu’un codec devrait être écrit en WUFFS plutôt qu’en Rust
Cela dit, réécrire en WUFFS quelque chose d’aussi complexe que dav1d pourrait être un chantier bien plus vaste que nettoyer le résultat d’une conversion c2rust. Je pourrais croire que c’est mille fois plus difficile. Malgré tout, à l’échelle de la civilisation, je pense que cela vaudrait la peine
Je parle de WUFFS ou d’un langage spécialisé équivalent, et WUFFS existe déjà
Sans allocation mémoire dynamique, il est difficile de gérer des données dynamiques. Un codec vidéo ne se contente pas de parser un fichier pour en extraire des données : il doit gérer beaucoup d’état très dynamique
Quand un article commence par un mème drôle, on sait que c’est un bon article
Cela semble aussi lié à une discussion récente : $20K Bounty Offered for Optimizing Rust Code in Rav1d AV1 Decoder (memorysafety.org) | 108 comments | https://news.ycombinator.com/item?id=43982238
Honnêtement, je suis un peu surpris que la première optimisation ait été assez évidente avec le seul outil perf
Il me semble que le problème d’initialisation du buffer à zéro avait déjà été discuté dans le premier article ; la seconde optimisation était clairement plus complexe et plus intéressante, mais perf l’a quand même indiquée. Il ne faut pas sous-estimer cet outil
perf diffexiste, mais il ne sait pas faire correspondre des noms de symboles différents, et il ne semble pas être très utiliséJe vois souvent des personnes venues d’horizons différents repérer des angles morts qui paraissent « évidents avec le recul »
C’est vraiment amusant
Je me demandais ce qui empêchait rustc d’effectuer cette astuce avec transmute, mais si j’avais lu le paragraphe suivant, j’aurais découvert cette issue avant de commenter
https://github.com/rust-lang/rust/issues/140167