Réécrire du code C en Ruby pour accélérer Ruby
(jpcamara.com)- Dans un petit benchmark de langages, CRuby arrivait troisième en partant de la fin, mais cela montre que le goulot d’étranglement ne vient pas du langage dans son ensemble, plutôt d’une implémentation des boucles que YJIT ne peut pas inspecter
- Sur un MacBook Pro M3, Ruby 3.3.6 prenait 12,17 s pour Fibonacci et 28,80 s pour Loops, tandis que node.js était autour d’une seconde pour les deux ; sur un MacBook Air M2, Ruby était encore plus lent avec respectivement 16,33 s et 33,43 s
- En activant simplement
ruby --yjit, Fibonacci passe fortement de 16,88 s à 2,06 s, mais Loops ne passe que de 33,43 s à 25,57 s : Range#each reste le goulot d’étranglement - Les méthodes dont l’implémentation en C a été déplacée côté Ruby, comme Integer#times dans Ruby 3.3 et Array#each dans Ruby 3.4, peuvent être optimisées par YJIT, ce qui améliore le benchmark de boucles jusqu’à environ 13 à 14 secondes
- Avec le hook
with_yjit, CRuby peut utiliser une implémentation Ruby quand YJIT est activé, tout en conservant l’implémentation C quand il ne l’est pas ; cela ouvre une tendance consistant à transformer les bibliothèques cœur en code Ruby compatible avec YJIT
La position de Ruby révélée par les benchmarks
- Le language comparison repo, récemment partagé, est un dépôt collaboratif qui construit de petits benchmarks pour plusieurs langages
- Dans ce dépôt, le résultat de CRuby était troisième en partant de la fin, seuls R et Python étant plus lents que Ruby
- Le benchmark examine deux axes
- Loops : met l’accent sur les performances des boucles, des conditions et des opérations mathématiques de base
- Fibonacci : met en évidence le coût des appels de fonction et de la récursion
- L’exemple Loops effectue des boucles imbriquées pour un total de 10 000 × 100 000 itérations, soit 1 milliard d’itérations
- L’exemple Fibonacci n’utilise pas une implémentation optimisée, mais volontairement un Fibonacci naïf
Mesures initiales et effet de YJIT
- Sur un MacBook Pro M3, Ruby 3.3.6 était au niveau suivant
- Fibonacci : 12,17 s
- Loops : 28,80 s
- node.js : un peu plus d’une seconde pour les deux exemples
- Sur un MacBook Air M2, le même benchmark était plus lent
- Ruby Fibonacci : 16,33 s
- Ruby Loops : 33,43 s
- node.js Fibonacci : 1,36 s
- node.js Loops : 2,07 s
- La commande d’exécution du dépôt d’origine était de la forme
ruby ./code.rb 40, sans YJIT - En exécutant avec
ruby --yjit ./code.rb 40, les résultats changent- Fibonacci : 2,06 s
- Loops : 25,57 s
- YJIT a eu un effet important sur Fibonacci, mais l’amélioration reste limitée sur Loops
Pourquoi Range#each est défavorable à YJIT
- Le cœur des boucles du code Loops prend la forme Range#each avec
(0...10_000).eachet(0...100_000).each - Dans Ruby 3.4,
Range#eachest toujours écrit en C - Dans CRuby,
range.crelie la classeRangeet la méthodeeachà la fonction Crange_each range_eachbifurque vers différents chemins afin de gérer plusieurs formes de plages(0...).each(0...100).each("a"..."z").each
- La fonction C elle-même peut être rapide, mais YJIT ne peut pas voir son intérieur
- L’optimisation s’arrête au point d’appel de la fonction, puis reprend après le retour de la fonction C
- YJIT peut produire des optimisations spécialisées pour les chemins d’exécution chauds, mais une implémentation C limite cet avantage
Ce qui change avec Integer#times
- Dans Ruby 3.3,
Integer#timesa été converti d’une fonction C en méthode Ruby - Sa structure de base est une simple boucle Ruby composée de
while i < self,yield ieti = i.succ - Comme elle est écrite en Ruby, YJIT peut analyser et optimiser son intérieur
- En remplaçant les itérations sur des plages par
10_000.timeset100_000.times, le temps de Loops diminue fortementRange#each: 25,57 sInteger#times: 13,66 s
- Dans un autre environnement de mesure,
Integer#timesest descendu à 9 s, et un résultat de 8 s a également été obtenu avec Ruby 3.4
Integer#succ et optimisations au niveau de la VM
- L’implémentation de
Integer#timesutilisei.succpour l’incrémentation au lieu dei += 1 Integer#succest une méthode qui renvoie la valeur entière suivante- Dans le bytecode de la VM Ruby,
i.succest représenté par une seule étape,opt_succ - À l’inverse,
i += 1est divisé en deux étapesputobject_INT2FIX_1_: place l’entier 1 sur la pile de la VMopt_plus: exécute l’opération+
- Dans un programme Ruby ordinaire, il n’est presque jamais nécessaire de s’en soucier, mais au niveau du JIT et de la VM, lorsqu’une opération est répétée des millions à des milliards de fois, même une étape de différence peut affecter les performances
Array#each passe aussi à une implémentation Ruby dans Ruby 3.4
- Dans Ruby 3.4,
Array#eacha lui aussi été converti d’une implémentation C à une implémentation côté Ruby - La première tentative était un simple code Ruby, mais elle présentait une race condition liée aux internes de CRuby
- L’implémentation finale utilise
Primitivedans le code RubyPrimitive.attr! :inline_block, :c_tracePrimitive.cexpr!ary_fetch_next
- Ce n’est pas du Ruby pur complet, mais plutôt un mélange d’évaluation de code C et de structure Ruby ; YJIT peut néanmoins effectuer des optimisations importantes
- En créant un tableau à l’avance puis en itérant avec
Array#each, les performances sont proches de celles deInteger#timesRange#each: 25,57 sInteger#times: 13,66 sArray#each: 13,96 s
Mesures Ruby Microbench
- Un dépôt séparé, ruby_microbench, compare l’exemple d’origine et plusieurs formes de boucles Ruby
- Avec Ruby 3.4 et YJIT activé, les résultats sont les suivants
- Fibonacci : 2,19 s
array#each: 14,02 srange#each: 26,61 stimes: 13,12 sfor: 14,91 swhile: 37,10 sloop do: 13,95 s
- Avec Ruby 3.4 et YJIT désactivé, la plupart deviennent plus lents
- Fibonacci : 16,49 s
array#each: 34,29 srange#each: 33,88 stimes: 33,18 sfor: 36,32 swhile: 37,14 sloop do: 50,65 s
- L’exemple
whileétait plus lent que prévu, ce qui pourrait venir de son mode d’implémentation for inetarray#eachsont presque identiques au niveau du bytecode de la VM Ruby, et leurs performances sont donc similairesfor inest globalement proche d’un sucre syntaxique transformé par la VM en appel à#each
Comparaison avec d’autres implémentations de Ruby
- Le même benchmark a été exécuté sur plusieurs implémentations de Ruby
- Voici une partie des résultats
- TruffleRuby 24.1
- Fibonacci : 0,92 s
array#each: 0,97 srange#each: 0,92 stimes: 2,39 sfor: 2,06 swhile: 3,90 sloop do: 0,77 s
- MRuby 3.3
- Fibonacci : 28,83 s
array#each: 144,65 srange#each: 126,40 stimes: 128,22 s
- Artichoke
- Fibonacci : 19,71 s
array#each: 236,10 srange#each: 214,55 stimes: 214,51 s
- TruffleRuby 24.1
Expérience de monkey patch de Range#each en Ruby
- En appliquant un monkey patch à
Range#eachavec une implémentation Ruby simple, les performances s’améliorent fortement - L’implémentation est une forme simple utilisant
begin,end,loop,yieldeti.succ - Les mesures sont les suivantes
- Implémentation C de
Range#each: 25,57 s - Implémentation Ruby de
Range#each: 16,64 s
- Implémentation C de
- Cette implémentation est une version excessivement simplifiée qui ne gère pas tous les cas de
Range - Elle montre néanmoins qu’en déplaçant du C vers Ruby, YJIT peut optimiser, et permettre des gains de performance difficiles, voire impossibles, à reproduire avec du C générique
La bibliothèque standard YJIT et with_yjit
- Ruby Outperforms C d’Aaron Patterson décrit un cas où une extension C pour le parsing GraphQL a été réécrite en Ruby, et où le code Ruby était plus rapide que le C grâce aux optimisations de YJIT
- L’équipe cœur de YJIT dans CRuby supprime le code C pour certaines fonctionnalités clés, ou applique une approche qui n’utilise l’implémentation Ruby que lorsque YJIT est activé
- Le bloc
with_yjitapplique l’implémentation Ruby concernée uniquement lorsque YJIT est activé- Si YJIT est désactivé, l’implémentation C continue de s’exécuter
- Si YJIT est activé, la version Ruby optimisable par YJIT est utilisée
- Depuis Ruby 3.3, YJIT peut être initialisé paresseusement, et le code
with_yjitapplique la version de méthode appropriée au moment où YJIT est activé with_yjitest un hook YJIT, supprimé à l’exécution viaundef :with_yjitaprès son appel
Comment voir le code machine généré par YJIT
- En compilant CRuby avec l’option
--enable-yjit=dev, on peut voir le désassemblage du code machine généré par YJIT - Exemple de build
./configure --enable-yjit=devmake install
- À l’exécution, on utilise le flag
--yjit-dump-disasm./ruby --yjit --yjit-dump-disasm test.rb 40
- Le
i.succdeInteger#timesapparaît sous forme deopt_succdans le bytecode de la VM - L’implémentation Rust de YJIT effectue les opérations suivantes pour
opt_succ- Vérifier que le receveur est un Fixnum
- Si ce n’est pas un Fixnum, sortir vers un autre chemin d’exécution
- Si c’est un Fixnum, ajouter en réalité 2 pour additionner 1, à cause de la représentation interne par tags
- En cas d’overflow, sortir vers un autre chemin
- Cet exemple montre que les optimisations JIT fonctionnent en traversant plusieurs couches : code Ruby, code C, bytecode de VM, implémentation Rust et code machine
La direction de l’optimisation de CRuby
- Le travail d’implémentation de Ruby se fait largement dans des langages de plus bas niveau que Ruby ; dans CRuby, il s’agit surtout de C et d’un peu de Rust
- Des couches comme YJIT ouvrent la possibilité de déplacer davantage de fonctionnalités du langage vers du code Ruby ordinaire
- Si davantage de fonctionnalités cœur sont écrites en Ruby, il pourrait devenir plus facile pour les développeurs Ruby de contribuer à CRuby
- Comme en Java, une structure où la majeure partie du langage est écrite dans lui-même au-dessus d’un petit cœur bas niveau est évoquée comme l’un des futurs possibles de CRuby
- Dans la tendance actuelle, plutôt que de conserver telles quelles les implémentations C, il reste important d’introduire sur les chemins critiques des implémentations Ruby que YJIT peut optimiser
1 commentaires
Avis sur Hacker News
L’exemple de boucle ressemble à un benchmark étrange qui exécute un milliard de boucles imbriquées, et avec une optimisation à la main, plus de 99 % du temps d’exécution devrait se concentrer au début
Avec une analyse de vivacité (liveness analysis) élément par élément du tableau, il semble possible de supprimer toute la boucle externe ; je me demande s’il existe des compilateurs qui font réellement ce genre d’analyse
Même si
un’est pas connu à la compilation, la boucle interne pourrait elle aussi être remplacée par quelques instructions, et cela ressemble à une optimisation plus standard que des outils commeclangpourraient bientôt faireIl y a trop de données à suivre, et cela n’a probablement d’intérêt que pour du code erroné de ce genre
À l’époque où je travaillais sur un compilateur IA, l’analyse de vivacité des éléments individuels de tenseurs aurait réellement été utile, mais nous ne l’avons pas faite tellement le temps de compilation et les besoins mémoire auraient été absurdes
result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)L’article parle de versions de Ruby à venir, et par curiosité j’ai regardé : ruby 3.4.0 devrait sortir à Noël cette année, et ruby 3.5.0 à Noël prochain
Je me demande aussi quel effet le JIT minimal de Python pourrait avoir sur ce genre de boucles
Python 3.13 doit être compilé avec le JIT activé, donc ce serait intéressant que quelqu’un lance le benchmark dans un environnement construit ainsi : https://drew.silcock.dev/blog/everything-you-need-to-know-ab...
Il me semble que des améliorations de performance pouvaient aussi être intégrées dans des versions de maintenance
Integer#succest souvent utilisé non seulement pour les performances, mais aussi pour la lisibilitéPar exemple, dans la méthode
#bytesd’une bibliothèque UUID, je l’ai utilisé deux fois pour garder mon cerveau en « mode découpage de bits » pendant la lecture du codeLa boucle sur 16 itérations est exprimée avec
0xF.succ, et la division par 256 dans la boucle avec0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succte semble préférable à0x10Je contribue à https://github.com/bddicken/languages, et après avoir tenté l’approche avec Lua, j’ai aussi pensé à TruffleRuby, mentionné quelque part, mais en exécutant
main.rb, il n’y avait pratiquement pas de grande différence entre TruffleRuby et Ruby classiqueParfois, Ruby classique était même plus rapide que TruffleRuby
J’aimerais vérifier après quel changement le benchmark de vitesse de TruffleRuby dans l’article a été obtenu, et si c’est vérifiable, l’ajouter au dépôt principal sous forme de commit
Si l’implémentation TruffleRuby est réellement plus rapide que Node.js et proche de Bun ou Go, ce serait assez surprenant
Pour plus de détails, voir https://github.com/oracle/truffleruby
Ruby est vraiment devenu rapide, et TruffleRuby est particulièrement impressionnant
C’est dommage, car c’est probablement la combinaison qui aurait le plus d’impact sur les performances de Ruby
Je ne savais pas que YJIT était écrit en Rust, c’est vraiment intéressant
L’inconvénient est qu’il peut ne pas être activé à la compilation si la chaîne d’outils ou la plateforme appropriée n’est pas disponible, mais cela paraît être un compromis raisonnable
Il existe aussi un dépôt de comparaison de langages plus ancien et couvrant davantage de langages : https://github.com/niklas-heer/speed-comparison
Les libellés des axes et des barres du graphique se chevauchent, et il n’y a pas non plus de lignes de grille verticales
J’aimerais bien un simple tableau HTML
Il est intéressant de constater que Python était le langage le plus lent du benchmark, tout en étant le langage le plus utilisé sur GitHub en octobre 2024
On dirait qu’il existe une corrélation selon laquelle plus un langage est lent, plus il est populaire
Même si j’aime beaucoup Rust, chaque langage et runtime, interpréteur ou compilateur reste un outil
Selon le problème et l’approche choisie pour le résoudre, il faut le bon ensemble d’outils, et si un programme ne s’exécute que quelques fois, une vitesse d’exécution lente n’a pas beaucoup d’importance
C’est souvent le cas des programmes en Python ou en R
Ruby est globalement lent, mais il est vraiment agréable pour coder, ce qui le rend plus attirant pour les débutants
Mes solutions à Advent of Code ressemblent étonnamment à ça, donc cela ressemble à un changement qui pourrait rebattre les cartes