De minuscules JIT pour accélérer FFI
(railsatscale.com)- En Ruby, FFI est pratique pour appeler des fonctions natives, mais dans un benchmark de
strlen, son surcoût d’appel est supérieur à celui d’une extension C et devient un goulet d’étranglement de performance - Dans le benchmark de référence, l’appel direct à
String#bytesizeatteint 39,879M i/s, l’extension C 30,661M i/s, l’appel indirect Ruby 28,697M i/s, et FFI est le plus lent avec 15,682M i/s - L’idée clé consiste à utiliser, au moment de
attach_function, le nom de fonction, les types d’arguments et le type de retour déjà connus pour générer à l’exécution du code machine destiné à appeler une fonction externe - La preuve de concept FJIT, dans un environnement Ruby 3.5.0dev ARM64, porte les appels à
strlenà 32,508M i/s, soit plus de deux fois plus vite que FFI et même légèrement plus vite qu’une extension C - Pour l’instant, des limites subsistent avant un usage réel : ARM64, un seul argument et une seule valeur de retour, types restreints, dépendance à
--rjit --rjit-disableet à un commit précis de Ruby head
Là où FFI ralentit en Ruby
- L’approche générale consiste à écrire autant de code Ruby que possible, et à n’appeler du code natif qu’en cas de nécessité
- YJIT peut optimiser le code Ruby, mais ne peut pas optimiser le code C
- Si une bibliothèque native est nécessaire, l’idéal est de placer une fine extension C wrapper autour de la fonction réelle, puis de traiter l’essentiel du travail en Ruby
- Ce type d’API simple convient bien à FFI, mais FFI existant n’atteint pas les mêmes performances qu’une extension C
Benchmark de référence avec strlen
- Quatre éléments sont comparés
- Appel à
strlenen C via FFI - Appel indirect à
x.bytesizedans la méthode RubyB.strlen - Appel à une extension C créée avec le Ruby Gem
strlen - Appel direct à
str.bytesize
- Appel à
- Résultats avec Ruby 3.5.0dev sur ARM64 :
ruby-direct: 39,879M i/s, 25,08 ns/istrlen-cext: 30,661M i/s, 32,61 ns/i, 1,30× plus lent que l’appel directstrlen-ruby: 28,697M i/s, 34,85 ns/i, 1,39× plus lent que l’appel directstrlen-ffi: 15,682M i/s, 63,77 ns/i, 2,54× plus lent que l’appel direct
- L’appel direct à
String#bytesizeest le plus rapide, et tout appel indirect supplémentaire ajoute du surcoût - L’écart entre
ruby-directetstrlen-rubymontre le coût du push/pop de la frame de pile, et l’élimination de ce type de surcoût est un domaine où les compilateurs JIT comme YJIT excellent - L’écart entre
strlen-cextetstrlen-ffimontre que l’appel d’une fonction native via FFI entraîne un coût supplémentaire important
Remplacer les appels FFI par du JIT
- Au moment de l’appel
attach_function :strlen, [:string], :int, les informations nécessaires sont déjà connues- Nom de la fonction à appeler :
strlen - Type d’argument : string
- Type de retour : int
- Nom de la fonction à appeler :
- Avec ces informations, il est possible de générer du code machine qui déballe les valeurs Ruby en types natifs, appelle la fonction externe, puis réemballe la valeur de retour dans un objet Ruby
- Trois composants sont nécessaires
- AArch64 gem : génération de code machine ARM64
- Fisk gem : génération de code machine x86_64
- JITBuffer gem : allocation de mémoire exécutable
- Générer du code machine ne suffit pas : Ruby doit pouvoir sauter vers ce code machine pour contourner le surcoût de FFI
La voie via RJIT
- RJIT est un compilateur JIT pour Ruby écrit en Ruby, distribué avec Ruby
- Sa structure interne ressemble à celle de YJIT, mais comme il n’est pas destiné à un usage en production, il est moins connu que YJIT
- Kokubun a soumis une demande de fonctionnalité pour extraire RJIT sous forme de Gem
- Cette proposition fournit deux bases pour faciliter la création de compilateurs JIT Ruby tiers
- Séparer RJIT dans un Gem
- Générer les types internes de Ruby sous forme de structures de données Ruby, afin que les JIT tiers puissent obtenir les informations nécessaires pour emballer et déballer les types de données Ruby
- Un autre changement consiste à toujours exécuter un pointeur de fonction d’entrée JIT lorsqu’il existe
- Lorsqu’un JIT tiers enregistre du code machine, Ruby peut automatiquement sauter vers ce code
- Avec ces deux éléments, il devient possible de créer un petit compilateur JIT à objectif unique jouant le rôle d’interface FFI
Preuve de concept FJIT
- La preuve de concept FJIT, abréviation de « FFI JIT », génère à l’exécution du code machine qui appelle des fonctions externes
- L’exemple attache
strlenavec une interface similaire à FFImodule Cextend FJITattach_function :strlen, [:string], :int
- Quand
attach_functionest appelé, FJIT génère du code machine qui déballe la chaîne Ruby, appellestrlenen C, puis renvoie la longueur de la chaîne sous forme d’objet Ruby
Résultats du benchmark FJIT
- Résultats avec Ruby 3.5.0dev,
+RJIT +PRISM, sur ARM64 :ruby-direct: 41,907M i/s, 23,86 ns/istrlen-fjit: 32,508M i/s, 30,76 ns/i, 1,29× plus lent que l’appel directstrlen-cext: 29,778M i/s, 33,58 ns/i, 1,41× plus lent que l’appel directstrlen-ruby: 28,851M i/s, 34,66 ns/i, 1,45× plus lent que l’appel directstrlen-ffi: 15,629M i/s, 63,98 ns/i, 2,68× plus lent que l’appel direct
- L’appel direct à
String#bytesizereste le plus rapide - Le code machine généré par FJIT est le deuxième plus rapide et produit un résultat légèrement meilleur que l’extension C
strlen - FJIT est plus de deux fois plus rapide qu’un appel FFI, et plus rapide également qu’un appel indirect Ruby
- Ce résultat suggère qu’il est possible d’obtenir une vitesse égale ou supérieure à celle d’une extension C tout en conservant l’approche qui consiste à « écrire autant de Ruby que possible »
Limites restantes avant une application réelle
- Le compilateur JIT de preuve de concept est actuellement limité à la plateforme ARM64
- Pour en faire une implémentation réelle, il faudra ajouter un backend x86_64
- Tous les types d’arguments et de retours ne sont pas encore pris en charge
- La prise en charge de tous les types d’arguments semble possible, et le volume de travail ne devrait pas être excessif
- Pour l’instant, seules les fonctions recevant un unique argument et renvoyant une unique valeur sont gérées
- Ruby doit actuellement être lancé avec les flags
--rjit --rjit-disable- Une fois la fonctionnalité de Kokubun intégrée, cette condition devrait disparaître
- La preuve de concept ne fonctionne qu’avec le Ruby head de l’époque
- À la date de la mise à jour, RJIT ayant été retiré de Ruby head, il faut checkout Ruby au commit
f32d5071b7b01f258eb45cf533496d82d5c0f6a1pour exécuter le script
1 commentaires
Avis de Hacker News
J’ai beaucoup travaillé avec la FFI pour permettre au solveur de contraintes Java Timefold d’appeler des fonctions définies dans CPython, et la plupart des problèmes de performance venaient des allers-retours via des proxys entre le langage hôte et le langage externe
Quand on fait des appels FFI directs via JNI ou une nouvelle interface externe, c’est presque aussi rapide qu’un appel direct de méthode Java, mais les garbage collectors de CPython et de Java ne s’accordent pas bien, et leur synchronisation demande de la magie noire
En revanche, les proxys comme JPype ou GraalPy doivent convertir les paramètres et les valeurs de retour, et peuvent même déclencher des appels FFI supplémentaires dans l’autre sens. Si l’on passe un objet CPython à Java, Java crée un proxy de cet objet ; si l’on repasse ce proxy à CPython, il ne le déballe pas et crée plutôt un proxy du proxy
Au final, le proxy JPype était 1402 % plus lent qu’un appel FFI direct vers CPython, et le proxy GraalPy 453 % plus lent
Nous avons fini par traduire le bytecode CPython en bytecode Java et par générer des structures de données Java correspondant aux classes CPython utilisées, ce qui a donné un gain de 100× par rapport aux proxys. Au passage, le bytecode CPython est très instable, mal documenté, et les particularités de sa VM le rendent difficile à mapper directement vers un autre bytecode ; mieux vaut donc éviter d’essayer de le traduire ou même de le lire
Les détails sont résumés dans l’article : https://timefold.ai/blog/java-vs-python-speed
Le code Go et le code C doivent s’accorder sur la manière de partager des ressources comme l’espace d’adressage, les gestionnaires de signaux ou les slots TLS des threads ; en pratique, cela revient plutôt à dire que Go doit contourner les hypothèses du code C. Le code C peut supposer qu’il ne tourne que sur un seul thread, ou ne pas être du tout préparé à un environnement multithread
Comme C ne connaît ni la convention d’appel de Go ni ses piles extensibles, appeler du code C impose d’enregistrer les détails de la pile de la goroutine, de basculer vers une pile C, puis d’exécuter du code C qui ignore comment il a été appelé et ne connaît pas l’ensemble du runtime Go
Que l’on fasse un binding ou que l’on enveloppe du code C avec Python, Java via JNI, un langage via libffi ou Go via cgo, on finit de toute façon par vivre dans le monde de C
https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
Grâce à Rails At Scale et aux articles de byroot, c’est vraiment une excellente période pour lire des textes qui creusent en profondeur les internes et les performances de Ruby. Avec les améliorations récentes de Ruby et Rails, c’est aussi un assez bon moment pour être développeur Ruby
Il reste populaire pour certains types d’applications, mais son âge d’or semble déjà loin ; les améliorations récentes sont appréciables, mais je ne sais pas si un JIT est encore techniquement si intéressant en 2025
L’approche consistant à se dire « au lieu d’appeler une bibliothèque tierce, ne pourrait-on pas simplement JITer le code nécessaire pour appeler une fonction externe ? » ressemble assez au fondement de LuaJIT FFI : https://luajit.org/ext_ffi.html
C’est sans doute pour cela que la FFI de LuaJIT est très rapide
« Écrivez autant que possible en Ruby. En particulier, YJIT peut optimiser le code Ruby, mais pas le code C » : je ne comprends pas bien cette affirmation.
Ruby n’est-il pas un langage plutôt lent ? Si l’on descend au niveau natif, on aurait plutôt envie de mettre autant de choses que possible en code natif.
Dans une version majeure, le code Java qui gérait le comportement d’un élément d’UI a été identifié comme un goulot d’étranglement, puis réécrit en C dans la version majeure suivante.
Ensuite, quand le JIT est devenu réellement utile, le surcoût de la FFI est devenu supérieur à l’écart entre le code C optimisé à la main et le code généré par le JIT, et, dans la version majeure suivante, on est revenu à une implémentation pure Java.
Pour un langage de cette génération, la FFI de Java était plutôt rapide, mais quelques versions plus tard elle a été remplacée par une meilleure approche ; à ce moment-là, je faisais moins de code d’UI Java et je n’ai plus suivi. Comme, à la même période, l’interface entre le code spécifique aux plateformes et le code d’UI Java générique était aussi en cours de nettoyage, je ne suis pas certain de la façon dont cela s’est terminé.
Dans ce type de travail, il faut constamment surveiller cet effet de balancier. Il faut se demander s’il vaut mieux attendre quelques jalons pour réduire le travail d’optimisation manuelle, ou si, pour des raisons politiques ou techniques, il faut agir tout de suite.
Cela peut être inefficace pour une tâche exécutée une seule fois, mais sur des charges de travail desktop ou serveur de longue durée, il y a un gain à l’échelle de l’application entière.
Par exemple, le JIT de Dalvik était assez faible, si bien qu’il était plus rapide d’appeler les fonctions mathématiques en C ; mais après ART, ce n’était plus nécessaire, et le JIT pouvait dépasser le coût d’un appel C.
https://developer.android.com/reference/android/util/FloatMa...
On appelle parfois cette tendance « self-hosting », et les navigateurs l’exploitent beaucoup, par exemple en déplaçant vers du JavaScript privilégié des parties qui auraient autrefois probablement été écrites en C/C++. Une part étonnamment importante des bibliothèques standard n’est pas du code natif.
Même dans des langages bien plus rapides, comme C#, où le coût d’interopérabilité est presque nul, il reste un coût d’appel, et il faut parfois aussi payer le coût de changement des indicateurs d’état de la VM ou de transition du garbage collector.
Si Ruby YJIT commence à devenir un facteur mesurable, cette règle deviendra elle aussi de plus en plus importante.
eachde Ruby a été réécrite en Ruby : https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182Il y a aussi un billet bonus de tender love : https://railsatscale.com/2023-08-29-ruby-outperforms-c/
En résumé : le JIT gagne.
FFI signifie Foreign Function Interface, c’est-à-dire la manière d’appeler du C depuis Ruby.
Ensuite, depuis Ruby, on exécute dans le terminal ce programme C avec des flags ou des données, ce qui permet à Ruby d’exécuter le code C.
Je ne vois pas pourquoi il faudrait compiler cela en JIT. Si l’on peut l’écrire en C, ne pourrait-on pas simplement le compiler au moment du chargement ?
Cela augmente fortement la productivité et permet de partager le même code entre CRuby, JRuby et TruffleRuby.
Si toutes les liaisons pouvaient être connues statiquement au démarrage, on pourrait écrire des stubs et les placer dans la table des méthodes, mais cela resterait quelque chose qui se produit à l’exécution, donc cela relèverait du JIT. Et comme on ne peut pas s’adapter aux types qui circulent dans le système, il faut rester conservateur dans les valeurs acceptées et dans les optimisations, ce qui est assez proche de ce que fait libffi aujourd’hui.
L’approche AOT, c’est d’écrire une extension native.
À côté de cela, en général, ce n’est pas tant la FFI elle-même que les gems qui l’utilisent que j’évite. La compilation est souvent tellement pénible qu’il était plus simple de retirer l’étape intermédiaire Rubygems/bundler et de compiler directement.
Dans un registre un peu lié, cette bibliothèque utilise JVMCI pour générer à la volée du code arm64/amd64 appelant des bibliothèques natives sans JNI : https://github.com/apangin/nalim
N’est-ce pas exactement ce que fait libffi ?
Dans libffi, on crée un objet descripteur pour une fonction, et cette structure de données à l’exécution représente les types des arguments et de la valeur de retour
Lors d’un appel FFI, il faut passer le descripteur ainsi qu’un tableau de pointeurs vers les valeurs à transmettre. En interne, elle parcourt sans doute à la fois le tableau de valeurs et le descripteur pour placer les valeurs sur la pile selon leur type, puis, une fois la fonction terminée, récupère la valeur selon le type de retour. Il y a de fortes chances que des branchements par type soient présents un peu partout dans ce processus
Même si le mécanisme d’appel de libffi était JITté, la préparation du tableau d’arguments resterait lente. C’est moins direct qu’un JIT FFI qui accède directement aux arguments sans passer par un tableau intermédiaire
Le code JIT FFI reçoit directement les valeurs des arguments, les convertit des types Ruby vers les types C, place chaque valeur au bon endroit dans la pile ou les registres via du code inline, appelle la fonction, puis convertit la valeur de retour en type Ruby. En pratique, cela ressemble à du code d’extension écrit à la main
Avec de l’inférence de types, on peut éviter les vérifications de type dans le code de conversion. Par exemple, si l’on a la garantie que
arg1est une chaîne Ruby, on peut utiliser une version unsafe plus rapide de la fonction de conversionDans le pire des cas, le code JIT n’a besoin de refléter que les types Ruby, et n’a pas besoin de tableaux ni de listes liés aux arguments. Comme le type C vers lequel convertir est codé en dur dans le code, il n’est pas nécessaire de parcourir à l’exécution une structure de données décrivant le côté C
L’intérêt de cet article est que, à partir des informations fournies par l’utilisateur lors de l’appel à
attach_function, le code de déballage des types est en quelque sorte mis en cache dans le code machine généréLe
tramp.clié dans le commentaire frère sert à de la « FFI inverse », c’est-à-dire à exposer une opération utilisateur dynamique sous forme de pointeur de fonction ; le JIT qui s’y trouve se limite au total à trois instructions destinées à appeler du code précompiléhttps://github.com/libffi/libffi/blob/master/src/tramp.c