Journal Koffi, un paquet simple, complet et rapide de FFI C pour Node.js

Posté par  . Licence CC By‑SA.
Étiquettes :
30
22
juin
2022

Hello :)

Aperçu du projet

Koffi, c'est un petit projet que j'ai démarré un petit peu par hasard il y a 4 mois, après avoir eu besoin d'appeler du C depuis un projet Node.js. J'ai commencé par utiliser node-ffi et node-ffi-napi, mais les performances étaient désastreuses. J'ai essayé d'autres paquets (comme fastcall, basé sur dyncall), mais il leur manque trop de choses : notamment, le passage de structures par valeur (en paramètre ou en valeur de retour) n'est pas implémenté.

J'ai commencé Koffi pour m'amuser, en C++ et en assembleur, sans réutiliser de code existant comme libffi. Tout est from scratch, en suivant la documentation des différentes ABI.

Fonctionnalités

Je sors aujourd'hui la version 1.3.0, qui commence à être assez complète :

  • Haute performance : l'overhead est 10 à 100x inférieur à celui de node-ffi et node-ffi-napi, chiffres à l'appui : https://koffi.dev/benchmarks#rand-results
  • Support multi-architecture : Windows (x86, x64, ARM64), Linux (x86, x64, ARM32, ARM64, RISC-V 64), macOS (x64, ARM64), et les autres systèmes POSIX comme FreeBSD et OpenBSD.
  • Types de valeurs : presque tout y est, sauf les unions pour le moment. Les structures c'est tout bon, qu'elles soient passées par valeur ou par référence (pointeur).
  • Support des callbacks : il est possible de passer une fonction Javascript à une fonction C, qui peut l'appeler en callback. Tout ceci fonctionne avec des sauts (trampolines) statiques, donc pas de problème de protection mémoire à prévoir, par exemple sur un noyau PaX.
  • Suite de tests assez complète, basée sur QEMU pour tester (ou presque… merci Apple M1) et packager facilement toutes les archi supportées. Ces tests reposent sur tout un tas de fonctions C écrites pour l'occasion (et tester des cas limites), ainsi que des appels à Raylib et à SQLite.
  • Support des appels asynchrones.

Et surtout, il y a désormais une documentation assez complète avec pas mal d'exemples en ligne (en anglais) : https://koffi.dev/

Exemple

Le petit exemple ci-dessous, fonctionnel sous Linux, vous illustre comment afficher l'heure actuelle en appelant directement les fonctions de la libc.

const koffi = require('koffi');

// Load the shared library
const lib = koffi.load('libc.so.6');

// Declare struct types
const timeval = koffi.struct('timeval', {
    tv_sec: 'unsigned int',
    tv_usec: 'unsigned int'
});
const timezone = koffi.struct('timezone', {
    tz_minuteswest: 'int',
    tz_dsttime: 'int'
});
const time_t = koffi.struct('time_t', { value: 'int64_t' });
const tm = koffi.struct('tm', {
    tm_sec: 'int',
    tm_min: 'int',
    tm_hour: 'int',
    tm_mday: 'int',
    tm_mon: 'int',
    tm_year: 'int',
    tm_wday: 'int',
    tm_yday: 'int',
    tm_isdst: 'int'
});

// Find functions
const gettimeofday = lib.func('int gettimeofday(_Out_ timeval *tv, _Out_ timezone *tz)');
const localtime_r = lib.func('tm *localtime_r(const time_t *timeval, _Out_ tm *result)');
const printf = lib.func('int printf(const char *format, ...)');

// Get local time
let tv = {};
let now = {};
gettimeofday(tv, null);
localtime_r({ value: tv.tv_sec }, now);

// And format it with printf (variadic function)
printf('Hello World!\n');
printf('Local time: %02d:%02d:%02d\n', 'int', now.tm_hour, 'int', now.tm_min, 'int', now.tm_sec);

Pour tester cet exemple, créer un nouveau projet avec npm init, installez le paquet koffi puis copiez-coller le code ci-dessus. Ensuite, exécutez-le avec node node time.js et voilà !

mkdir time
cd time
npm init
npm install koffi
vim time.js # Copier-coller le code JS au-dessus
node time.js

Support

La table ci-dessous résume les combinaisons SE/architecture supportées. Le paquet NPM contient des binaires précompilées pour toutes les combinaisons en vert, donc c'est (en théorie) installable sans avoir besoin de CMake et de compilateur.

ISA / SE Windows Linux macOS FreeBSD OpenBSD
x86 (IA32) ✅ Oui ✅ Oui ⬜️ N/A ✅ Oui ✅ Oui
x86_64 (AMD64) ✅ Oui ✅ Oui ✅ Oui ✅ Oui ✅ Oui
ARM32 LE ⬜️ N/A ✅ Oui ⬜️ N/A 🟨 Probable 🟨 Probable
ARM64 (AArch64) LE ✅ Oui ✅ Oui ✅ Oui ✅ Oui 🟨 Probable
RISC-V 64 ⬜️ N/A ✅ Oui ⬜️ N/A 🟨 Probable 🟨 Probable

Toutes les architectures supportées le sont complètement, il n'y a pas de demi-mesure. C'est complet ou il n'y a rien. Globalement le projet couvre les plateformes qui sont elles-mêmes supportées par Node.js.

Conclusion

Voilà, si vous avez besoin de faire de la FFI, et/ou que vous avez rencontré des problèmes avec node-ffi/node-ffi-napi, Koffi est là !

Page officielle / documentation : https://koffi.dev/
Paquet NPM : https://www.npmjs.com/package/koffi
Performances : https://koffi.dev/benchmarks#rand-results
Adresse du dépôt (monodépôt) : https://github.com/Koromix/luigi/tree/master/koffi

Si ça vous est utile, ou que ça peut l'être, n'hésitez pas à m'adresser vos retours, bugs et demandes de fonctionnalités :)

  • # Mises à jour 1.3.1 et 1.3.2

    Posté par  . Évalué à 4.

    J'ai publié deux petites mises à jour (1.3.1 puis 1.3.2), qui sont destinées à permettre (je l'espère) l'installation et la compilation de Koffi sur d'anciens systèmes comme Debian 9.

    Ces mises à jour ne changent rien pour les systèmes plus récents ou les autres plateformes.

    Les détails sont ici : https://github.com/Koromix/luigi/issues/10

  • # Performance

    Posté par  (site web personnel) . Évalué à 2.

    Quel est le niveau de performance par rapport à développer un bindings maison avec node-gyp ?

    Au niveau du blocage du thread principal: utilise tu la libuv ou autre pour éviter les appels long de fonction cpp ?

    • [^] # Re: Performance

      Posté par  . Évalué à 3.

      Il y a une page dédiée aux benchmarks sur le site, avec 3 tests implémentés en plusieurs versions :

      1. rand(): ce benchmark est basé sur des millions d'appels à rand(), compare un binding maison (qui utilise N-API) et Koffi (et node-ffi-napi, tellement catastrophique que je ne le mets que dans tes tableaux et pas les graphiques sinon on ne voit plus rien…). On est sur du +70% à +80% de temps d'exécution, donc même pas un doublement, et il s'agit d'une fonction C très simple donc l'overhead est majoré.
      2. atoi(): ce benchmark est similaire au précédent, mais basé sur atoi() pour mesurer les performances de passage de chaine.
      3. Raylib: ce benchmark est assez différent, il est basé sur Raylib (librairie C pour faire de la programmation de jeux vidéos). Ici 3 implémentations (sans compter node-ffi-napi) sont comparées : une version entièrement en C++, une version basée sur le paquet node-raylib (qui est un binding maison) et une version basée sur Koffi. Les fonctions Raylib font plus de boulot que les tests précédents, et là on est sur +20% de temps d'exécution par rapport au binding N-API.

      Performance Linux

      Les deux premiers permettent de révèler l'overhead lié à Koffi puisqu'il s'agit de fonctions très simples, notamment rand(), mais par contre ce n'est pas un cas très réaliste d'utilisation. Le troisième est plus réaliste par rapport à l'utilisation réelle d'un module FFI, avec un overhead qui est relativement tolérable.

      Pour résumer, l'overhead sur les deux premiers benchmarks (appels d'une fonction C très rapide) est de +70 à +80% par rapport au binding N-API, sur le troisième c'est +20% par rapport au binding N-API node-raylib. Pour comparaison, node-ffi-napi c'est +7000% sur atoi(), sans parler d'une explosion de la consommation mémoire (plusieurs Go) liée au GC qui ne réagit pas.

      Les appels synchrones ont lieu sur le thread principal, les appels asynchrones sont exécutés par un pool de threads (worker threads).

      • [^] # Re: Performance

        Posté par  (site web personnel) . Évalué à 5. Dernière modification le 23 juin 2022 à 09:29.

        tellement catastrophique que je ne le mets que dans tes tableaux et pas les graphiques sinon on ne voit plus rien

        C'est pour cette raison que généralement on met en graphique l'inverse, dans ton cas par exemple :
        - itérations sur temps total (en plus ça évite d'avoir des valeurs dépendantes du nombre d'itérations qui est un choix arbitraire)
        - le coefficient multiplicateur par rapport au base line mis à 1 (cas quand on a une base, cas ici, même si la base change suivant les tests, par principe ce serait quand même mieux de mettre une version "cc" aussi pour les tests simples pour avoir une bonne vue d'ensemble)

        De plus ça a l'avantage supplémentaire que le plus grand soit le mieux, réaction que les gens ont par défaut quand quand aucun avertissement sur un graphique disant que c'est l'inverse de ce que à quoi on peut s'attendre.

        Perso dans ton cas je mettrai le coefficient multiplicateur en graphique, car la durée ou débit ne sont pas très intéressants à connaître dans ton cas et les 3 graphiques peuvent alors aussi se comparer entre eux (la, tes 3 graphiques en un graphique donne une première impression bizarre et on ne voit pas trop ce que ça apporte d'avoir la même échelle).

        • [^] # Re: Performance

          Posté par  . Évalué à 2.

          C'est une bonne remarque, merci, j'ai mis à jour les graphiques disponibles sur le site (il y a un cache d'1h ça peut mettre un peu de temps à changer). Et j'ai switché vers une échelle logarithmique pour que ça reste lisible avec les résultats de node-ffi-napi.

          Perfs Linux x86_64

          En ce qui concerne la version C/C++ de chaque test, elle a existé par le passé mais je reconnais l'avoir retirée car elle montrait avant tout l'overhead Javascript par rapport au C/C++. Hors je souhaite plutôt parler de l'overhead lié à Koffi, qui se pose en alternative au développement d'un binding manuel (généralement basé sur N-API, voire directement sur les API V8. Et non à un redéveloppement intégral en C ou C++ (ou Rust, pour éviter qu'on me tombe dessus 🥷).

          Malgré tout, pour le troisième benchmark basé sur Raylib, les tableaux affichent les résultats de la version C++ : https://koffi.dev/benchmarks#raylib-results

          • [^] # Re: Performance

            Posté par  (site web personnel) . Évalué à 3.

            Je crois que la suggestion de Zenitram c’était plutôt quelque chose comme cet exemple1 où le plus haut est le meilleur et la référence est normée à 1. Ça t’évite aussi l’échelle logarithmique qui n’a pas de sens pour ta référence et qui ne rend pas visuelle la comparaison.


            1. Je ne sais pas pourquoi LinuxFr refuse d’inclure cette image dans mon commentaire, tant pis je ne bénéficierai pas du cache. 

            • [^] # Re: Performance

              Posté par  . Évalué à 2.

              Effectivement, merci pour l'exemple… :)
              J'espère que la troisième fois est la bonne !

              Voici les graphiques mis à jour pour Linux et Windows :

              Linux x86_64 perf
              x86_64 perf

              Quelqu'un me croit si je dis qu'à une époque je faisais de l'aide méthodologique dans un service de stats, sur R ? 😁
              J'ai bien fait d'arrêter !

              • [^] # Re: Performance

                Posté par  (site web personnel) . Évalué à 1.

                J'espère que la troisième fois est la bonne !

                Du moins je n'ai rien à redire :-p.
                On voit bien l'impact par rapport à NAPI (ça fait quand même mal… Si c'est utilisé souvent pour des petits trucs) mais bien moins méchant que FFI, et ce dans tous les cas (on peut bien comparer tous les éléments du graphique entre eux).

                • [^] # Re: Performance

                  Posté par  . Évalué à 5.

                  Merci !

                  Globalement utiliser un module de Foreign Function Interface avec C c'est un compromis par rapport à un binding manuel, je pense qu'on peut dire que ça a de l'intérêt dans 3 cas :

                  • La facilité de réutiliser du code existant en C, sans avoir à tout wrapper (avec N-API, les API V8, ou autre)
                  • L'appel direct à des fonctions du système d'exploitation, par exemple Win32
                  • Le côté dynamique, un peu comme faire la réflexion (en Java, en C#), au prix d'une perte de performance

                  De mon point de vue, le benchmark le plus réaliste est celui basé sur Raylib, avec une perte de 20%. Il est rare d'avoir besoin d'une librarie de FFI pour appeler des fonctions très légères comme atoi() ou rand(), car ce sont des fonctionnalités généralement disponibles dans le langage, et/ou faciles à wrapper.

                  Par contre, pour quelque chose comme Raylib, ImGui, une bibliothèque de sons, Win32, GTK+, etc. Et bien ça peut avoir un certain intérêt, et l'overhead a relativement moins d'impact dans ce cas. De l'ordre de celui observé avec le benchmark Raylib.

                  Et puis j'espère bien gagner en performance, même si je suis déjà relativement content des résultats obtenus :)

                  • [^] # Re: Performance

                    Posté par  (site web personnel) . Évalué à 0. Dernière modification le 23 juin 2022 à 15:24.

                    L'appel direct à des fonctions du système d'exploitation, par exemple Win32

                    De ce que je comprend est que même dans ce cas il vaut mieux la glue NAPI.

                    Mais je vois bien l’intérêt (complexité d'écrire le code vs rapidité, j'ai de vague souvenir de la blague de passer du temps donc de l'argent à coder pour réduire l'empreinte mémoire plutôt que d'acheter une barrette de RAM moins chère que l'humain, bref ça dépend de ce qu'on veut en faire; tout comme les possibilité de faire des choses en dynamique), sinon j'aurai balancé un "mais ça sert à rien ton truc à part te faire plaisir" ;-).

                    • [^] # Re: Performance

                      Posté par  . Évalué à 3.

                      Bah, si je suis honnête, me faire plaisir c'est 80% de la motivation de ce projet ;) J'ai pu (et je peux) jouer avec du code bas niveau, de l'assembleur x86, x64, RISC-V, ARM64 et ARM32, les différentes ABI C. Et bientôt PowerPC :)

                      Cela dit, ma motivation initiale c'était d'utiliser Raylib sans tout wrapper. Certes, node-raylib existe, et c'est ce que j'ai utilisé au début… Mais un certain nombre de bugs existent, et j'ai eu pas mal de crashs, ainsi que pas mal de fonctions inutilisables ou toujours pas wrappées à ce jour. A ce moment, je me suis dit "tiens, pourquoi pas utiliser node-ffi". J'ai converti le code que j'avais, j'ai lancé, les perfs étaient bien plus catastrophiques qu'attendu… Et Koffi est né :)

                      Et puis, node-ffi, libffi, dyncall , toutes ces choses existent déjà et ont pas mal d'utilisateurs, a priori il y a un public et un besoin. On verra bien !

                      Bien évidemment, une autre possibilité existe, c'est de générer un binding de manière automatisée. Émettre du C, qui utilise N-API et le compiler en live, c'est simple dans l'idée. Si l'écosystème C était moins arachaïque, on compilerait du code de binding dynamiquement et facilement (sans avoir de souci avec certaines plateformes, de problèmes avec les linkers, etc), et ça serait plus performant. Mais l'écosystème étant ce qu'il est, ce n'est pas si facile et ça pose tout un tas de problèmes, largement liés aux nombreux arachaïsmes du C, de POSIX, de Win32, de macOS, etc.

            • [^] # Re: Performance

              Posté par  (site web personnel, Mastodon) . Évalué à 4.

              Je ne sais pas pourquoi LinuxFr refuse d’inclure cette image dans mon commentaire, tant pis je ne bénéficierai pas du cache.

              à cause du tilde dans l'adresse : /~jyes/, ça, ça ne passe pas.

              « Tak ne veut pas quʼon pense à lui, il veut quʼon pense », Terry Pratchett, Déraillé.

  • # Koffi ?

    Posté par  (site web personnel, Mastodon) . Évalué à 2.

    Simple curiosité : pourquoi lui avoir donné un prénom baoulé ?

    • [^] # Re: Koffi ?

      Posté par  . Évalué à 2.

      Honnêtement, ce n'est pas voulu, le nom est composé de mon pseudo Koromix et de FFI.

    • [^] # Re: Koffi ?

      Posté par  (site web personnel, Mastodon) . Évalué à 2.

      Plus largement répandu dans les aires Akan et Gbé

      “It is seldom that liberty of any kind is lost all at once.” ― David Hume

  • # Et WASM ?

    Posté par  . Évalué à 0.

    Je ne suis pas un expert en techno web, mais le WASM, qui est du langage natif compilé en bytecode pour navigateur (?) a aussi de bonne performances.

    Pourquoi privilégier Koffi au WASM ? On-t-il la même utilité ?

    • [^] # Re: Et WASM ?

      Posté par  . Évalué à 1.

      Pour faire tourner du code non-JS dans un navigateur, le WebAssembly semble être la (bonne ?) solution.

      Koffi ne fonctionne pas dans un navigateur mais avec Node.js, qui est un runtime Javascript (basé sur V8) comme l'est la JVM pour Java ou le CLR .Net pour C#.

      L'intérêt du FFI, que ce soit avec Koffi ou autre chose, c'est :

      • L'appel direct à des fonctions du système d'exploitation, par exemple Win32 (ce que WASM ne fournit pas, et c'est d'ailleurs voulu, les navigateurs se devant de fournir un environnement d'exécution limité et contrôlé)
      • Réutiliser des librairies C déjà compilées, sans avoir à tout wrapper (avec N-API, les API V8, ou autre)

      Je prends un exemple complètement au hasard : lister les fenêtres ouvertes sous Windows en utilisant l'API Win32, depuis une application Node.js. Pour ce faire, il faut appeler EnumWindows(), c'est une fonction fournie par user32.dll, et qui utilise l'ABI C.

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.