2 points par GN⁺ 2025-02-14 | 1 commentaires | Partager sur WhatsApp
  • 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#bytesize atteint 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-disable et à 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 à strlen en C via FFI
    • Appel indirect à x.bytesize dans la méthode Ruby B.strlen
    • Appel à une extension C créée avec le Ruby Gem strlen
    • Appel direct à str.bytesize
  • Résultats avec Ruby 3.5.0dev sur ARM64 :
    • ruby-direct : 39,879M i/s, 25,08 ns/i
    • strlen-cext : 30,661M i/s, 32,61 ns/i, 1,30× plus lent que l’appel direct
    • strlen-ruby : 28,697M i/s, 34,85 ns/i, 1,39× plus lent que l’appel direct
    • strlen-ffi : 15,682M i/s, 63,77 ns/i, 2,54× plus lent que l’appel direct
  • L’appel direct à String#bytesize est le plus rapide, et tout appel indirect supplémentaire ajoute du surcoût
  • L’écart entre ruby-direct et strlen-ruby montre 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-cext et strlen-ffi montre 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
  • 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
  • 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 strlen avec une interface similaire à FFI
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • Quand attach_function est appelé, FJIT génère du code machine qui déballe la chaîne Ruby, appelle strlen en 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/i
    • strlen-fjit : 32,508M i/s, 30,76 ns/i, 1,29× plus lent que l’appel direct
    • strlen-cext : 29,778M i/s, 33,58 ns/i, 1,41× plus lent que l’appel direct
    • strlen-ruby : 28,851M i/s, 34,66 ns/i, 1,45× plus lent que l’appel direct
    • strlen-ffi : 15,629M i/s, 63,98 ns/i, 2,68× plus lent que l’appel direct
  • L’appel direct à String#bytesize reste 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 f32d5071b7b01f258eb45cf533496d82d5c0f6a1 pour exécuter le script

1 commentaires

 
GN⁺ 2025-02-14
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

    • Sans expérience directe, j’aurais tendance à dire que la FFI de Python et de Java vers C semble bien meilleure, et qu’un petit pont en C entre les deux pourrait devenir la solution générale
    • cgo is not Go mérite aussi d’être lu
      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
    • Dans ce genre de cas, je me demande où des formes de communication interprocessus comme des files, des fichiers ou HTTP trouveraient leur juste place
  • 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

    • Vraiment ? Ruby me semble sur le déclin : https://www.tiobe.com/tiobe-index/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.

    • Il y a eu ce genre de petit épisode à l’époque où Java se dotait d’un vrai JIT.
      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.
    • C’est là que le JIT entre en jeu. Idéalement, le JIT peut réoptimiser le code dans un meilleur état.
      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...
    • Dans les langages managés dotés d’un JIT ou d’un compilateur AOT, il est souvent idéal d’écrire beaucoup de code dans ce langage. Cela permet l’inlining et d’autres optimisations impossibles lorsqu’on franchit la frontière d’un appel C.
      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.
    • La FFI crée entre les morceaux de code une frontière opaque et impossible à optimiser. Le code qui fait souvent des allers-retours de ce type coûte cher.
      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.
    • JPCamara a très bien expliqué pourquoi la méthode each de Ruby a été réécrite en Ruby : https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      Il 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.

    • L’approche totalement sûre et raisonnable consiste à écrire du code C qui reçoit des données en ligne de commande à l’exécution et recrache le résultat sur la ligne de commande ou dans une page mémoire.
      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 ?

    • Je ne sais pas ce que vous voulez dire par « pouvoir l’écrire en C ». Une bibliothèque FFI permet de lier dynamiquement et d’exécuter des méthodes de bibliothèque depuis Ruby sans écrire d’extension native.
      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 ?

    • libffi est lente et, à ma connaissance, ne fait pas de JIT
      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 arg1 est une chaîne Ruby, on peut utiliser une version unsafe plus rapide de la fonction de conversion
      Dans 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
    • libffi ne sait pas ce qu’est Ruby, donc elle ne peut pas savoir comment déballer les types Ruby
      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é
    • libffi ne JITte pas les appels FFI et il faut toujours placer directement les valeurs des arguments. Par exemple, pour un argument chaîne, il faut écrire séparément le code qui convertit l’objet chaîne Ruby en pointeur vers une chaîne C. Et libffi est assez lente
      Le tramp.c lié 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é
    • Je pensais savoir ce que faisait libffi, et je croyais qu’elle bricolait avec des trucs comme la GOT. Mais il semble que tu aies raison
      https://github.com/libffi/libffi/blob/master/src/tramp.c