1 points par GN⁺ 2025-05-23 | 1 commentaires | Partager sur WhatsApp
  • 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 MaybeUninit et déplace l’emplacement d’initialisation de lr_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 PartialEq par défaut d’une petite struct numérique par une comparaison basée sur as_bytes() de zerocopy, é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

  • rav1d est un portage Rust de dav1d
    • conversion de dav1d avec c2rust
    • intégration des fonctions optimisées en assembleur de dav1d
    • travail de refactorisation pour rendre le code plus idiomatique Rust et plus sûr
  • memorysafety.org a lancé un concours d’amélioration des performances de rav1d, et dans l’état de référence, rav1d en Rust était environ 5 % plus lent que dav1d en C
  • Les mesures locales ont été effectuées sur un MacBook Air M3 à 8 cœurs
    • rav1d : commit a654c1e82adb2d9a33ae50d2a82a7a747102cbb6
    • rustc 1.88.0-nightly, LLVM 20.1.2
    • dav1d : 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 clang et rustc ne différaient que par le numéro de patch

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
    • dav1d appelle cdef_filter_8x8_neon et cdef_filter_4x4_neon, qui dispatchent chacune vers les fonctions assembleur correspondantes
    • rav1d utilise cdef_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_erased et rav1d_cdef_brow représentent ensemble environ 1 % du temps d’exécution total de rav1d
    • la somme des échantillons Self de cdef_filter_{8x8,4x4}_neon dans dav1d est d’environ 400
    • les échantillons Self de cdef_filter_neon_erased dans rav1d sont d’environ 670
    • dav1d_cdef_brow_8bpc a 1790 échantillons, rav1d_cdef_brow en a 2350

Amélioration 1 : suppression de l’initialisation à 0 du buffer temporaire

  • cdef_filter_neon_erased crée un buffer temporaire avec Align16([0u16; TMP_LEN])
    • TMP_LEN vaut dans le pire des cas 12 * 16 + 8 = 200
    • cela revient à remplir de zéros un buffer temporaire équivalent à [u16; 200]
  • Le code C correspondant dans dav1d crée un buffer de pile de la forme uint16_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 filter réutilise ensuite directement ces valeurs
  • Dans l’IR LLVM de rav1d, on voit un llvm.memset qui remplit 400 octets de zéros
    • le compilateur Rust ne pouvait pas savoir que cette initialisation pouvait être supprimée
  • L’utilisation de MaybeUninit permet d’éviter cette initialisation à 0 du buffer temporaire
    • Align16([0u16; TMP_LEN]) devient Align16([MaybeUninit::<u16>::uninit(); TMP_LEN])
    • les signatures des fonctions internes sont ajustées en tmp: *mut MaybeUninit<u16> et tmp: &[MaybeUninit<u16>]
    • comme cela se fait dans un chemin de code déjà unsafe, aucun nouveau bloc unsafe n’est ajouté
  • Après ce changement, les échantillons Self de cdef_filter_neon_erased tombent de 670 à 274
    • ils deviennent légèrement inférieurs à la somme des échantillons Self de cdef_filter_{8x8,4x4}_neon dans dav1d

Extension de l’amélioration 1 : réduire les initialisations dans la boucle

  • En cherchant d’autres gros buffers Align16, une initialisation de lr_bak a été repérée dans rav1d_cdef_brow
    • le code précédent réinitialisait lr_bak à zéro à chaque itération de boucle
    • le code correspondant dans dav1d n’initialise pas ce buffer
  • Ici, le passage à MaybeUninit était plus difficile, donc la création de lr_bak a é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, des for et de petits appels utilitaires
  • Une reconstruction en profil release-with-debug a permis d’observer la répartition des échantillons ligne par ligne
    • if cand.mv.mv[0] == mv {
    • if cand.mv == mvp {
    • à elles deux, ces lignes représentent environ 600 échantillons
  • Le type Mv de Rust est une petite structure utilisant #[derive(PartialEq)]
    • #[repr(C)]
    • y: i16, x: i16
  • Dans dav1d, mv est défini comme une union
    • struct { 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 union en Rust rendrait l’accès aux champs unsafe, avec un impact potentiel sur tous les usages de Mv
    • à la place, AsBytes de zerocopy sert à comparer la représentation binaire
    • impl PartialEq for Mv utilise self.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
  • 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 PartialEq par 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 avoir y initialisé alors que x ne l’est pas
    • si la comparaison est this.y == other.y && this.x == other.x et que toutes les valeurs de y diffèrent, il n’est pas nécessaire de lire x
    • dans ce cas, optimiser en un seul chargement mémoire n’est valable que si tous les champs sont garantis initialisés
  • 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 »
  • zerocopy peut 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 nouveau unsafe

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 PartialEq par 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 unsafe dans la base de code
  • Le temps d’exécution final de rav1d est 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
  • Un écart d’environ 6 % subsiste encore entre les deux implémentations, et la comparaison des snapshots du profiler entre dav1d et rav1d peut continuer à servir pour explorer d’autres optimisations

1 commentaires

 
GN⁺ 2025-05-23
Avis sur Hacker News
  • Le problème lié à la comparaison de deux u16 est intéressant
    https://github.com/rust-lang/rust/issues/140167

    • Je suis surpris que le store forwarding n’ait pas été mentionné dans cette discussion
      La génération de code avec -O3 est étrange, mais la sortie en -O2 est 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ée
    • J’apprécie que cette discussion ne soit pas 14 pages de commentaires du genre « moi aussi j’ai ce problème » ou « quand est-ce que ce sera corrigé ? »
      En tant que développeur web, je trouve que les issues GitHub sont souvent assez médiocres
    • Ce cas semble illustrer la complexité de l’écriture de compilateurs
      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

    • D’ordinaire, je fais confiance aux benchmarks de rbultje, mais l’issue de suivi rav1d contient des chiffres multithread sur plusieurs plateformes, et ils ne montrent pas un écart aussi important
      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
    • Rien qu’en lisant le compte Twitter de ffmpeg, ça me donne envie de ne pas utiliser ffmpeg
      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
    • Une réaction plus saine aurait sans doute été de travailler à rendre dav1d plus 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

    • L’optimisation de 1,5 % est spécifique à aarch64, donc revendiquer le chiffre global tel quel paraît un peu injuste
      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

    • Je me demande s’il serait possible d’améliorer le compilateur côté Rust/LLVM pour appliquer cette optimisation chaque fois que c’est possible
      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à

    • WUFFS serait excellent pour le parsing de fichiers conteneurs comme Matroska, webm ou mp4, mais ne semble pas du tout adapté à un décodeur vidéo
      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

    • À première vue, ce n’était pas simplement perf : cela ressemblait plutôt à du profilage différentiel entre les versions C et Rust, avec une mise en correspondance manuelle
      perf diff existe, mais il ne sait pas faire correspondre des noms de symboles différents, et il ne semble pas être très utilisé
    • Cela a sans doute été possible parce que l’approche venait du point de vue aarch64 sur des appareils Apple
      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