← Articles

Fluxpinel : plugins et eval pour du Ruby compilé avec Spinel

Dans mon article sur Ruby en WebAssembly, j’ai essayé Spinel, le nouveau compilateur de Matz qui transforme un programme Ruby en C, puis en un binaire natif rapide. Le prix à payer : le programme entier doit être connu à la compilation. Pas de eval, pas de method_missing, et pas de chargement de nouveau code pendant que le programme tourne.

Fluxpinel, de Hitoshi Hasumi, l’auteur de PicoRuby, s’attaque précisément à cette limite. Son slogan est « Make Spinel Dynamic ». C’est un interpréteur Ruby qui vit dans le même binaire que le programme compilé et utilise le même runtime. La partie compilée reste rapide, et les parties qui doivent pouvoir changer, comme les plugins, la configuration, les requêtes ou une console, sont de simples fichiers Ruby que le binaire lit à l’exécution.

Le projet est tout neuf : le premier commit date du 19 octobre 2026, et il compte déjà plus de 200 commits. Je l’ai compilé et essayé.

Comment ça fonctionne

Spinel compile votre programme, que Fluxpinel appelle le noyau (kernel), en C. Le build réunit ce code C et Fluxpinel dans un seul binaire. Les deux côtés partagent un seul tas, un seul ramasse-miettes et les méthodes intégrées du runtime Spinel. Un objet passe donc tel quel entre le code compilé et le script, sans copie ni conversion, et un rescue d’un côté attrape un raise de l’autre.

Le noyau communique avec l’interpréteur via un petit module :

Dans l’autre sens, un script peut appeler les méthodes de module du noyau que vous listez au moment du build.

Fluxpinel fonctionne aussi de façon autonome, comme la commande ruby, avec -e, -I, -r et une console. Il y a aussi une option --irb pour IRB, mais sur ma machine elle s’est arrêtée sur une erreur de chargement dans les gems irb et prism installés, je n’ai donc pas pu l’essayer. RUBY_ENGINE y vaut "spinel".

La compilation

Il faut git, un compilateur C, libffi et Ruby. make deps récupère et compile Spinel au commit que Fluxpinel fixe, et make compile l’interpréteur :

git clone https://github.com/hasumikin/fluxpinel
cd fluxpinel
make deps
make
build/fluxpinel -v

Sur mon portable sous Ubuntu, avec GCC 15 et Ruby 4.0.9, cela a fonctionné sans problème. Spinel a pris une dizaine de minutes, Fluxpinel environ 40 secondes. Ensuite :

$ build/fluxpinel -v
fluxpinel 1 (spinel 5cce6eb03, ruby 4.0.9) [x86_64-linux]
$ build/fluxpinel -e 'p RUBY_ENGINE'
"spinel"

Le build par défaut lève une RangeError quand un Integer déborde, comme Spinel par défaut. Pour avoir des Bignums comme dans CRuby, il existe un second build : make BUILD=build/promote INT_OVERFLOW=PROMOTE build/promote/fluxpinel, et ensuite 2**100 fonctionne.

Mon test : des règles de facture en plugins

Comme dans mes articles précédents, j’ai utilisé une petite facture suisse. La partie compilée lit les articles, calcule le sous-total, la TVA de 8,1 % et arrondit le total à 5 centimes. Les règles de rabais ne sont pas compilées. Chaque règle est un fichier Ruby dans un dossier rules/ :

# app.rb: Spinel compiles this file
require_relative "fluxpinel/kernel/fluxpinel"

module Invoice
  VAT = 0.081

  # round to 5 Rappen
  #: (Float) -> Float
  def self.round5(amount) = (amount * 20).round / 20.0

  #: (Array[String]) -> nil
  def self.main(args)
    lines = File.readlines(args[0], chomp: true).map do |l|
      name, qty, price = l.split(",")
      { "name" => name, "qty" => qty.to_i, "price" => price.to_f }
    end
    subtotal = lines.sum { |l| l["qty"] * l["price"] }
    puts format("%-24s %10.2f", "Subtotal", subtotal)

    Dir.glob(File.join(args[1], "*.rb")).sort.each do |path|
      rule = Fluxpinel.load(path)
      off = Fluxpinel.call(rule, "discount", [lines, subtotal])
      next if off == 0
      puts format("%-24s %10.2f", Fluxpinel.call(rule, "name", []), -off)
      subtotal -= off
    end

    vat = subtotal * VAT
    puts format("%-24s %10.2f", "VAT 8.1%", vat)
    puts format("%-24s %10.2f", "Total", round5(subtotal + vat))
    nil
  end
end

Les commentaires #: sont des types RBS en ligne. Spinel en a besoin pour les méthodes que seul le script appelle, car il ne peut pas déduire leurs types à partir du seul code compilé.

Deux règles :

# rules/10_bulk.rb: 10% off every line with 10 or more pieces
class Bulk
  def name = "Bulk discount"
  def discount(lines, _subtotal)
    lines.select { _1["qty"] >= 10 }.sum { _1["qty"] * _1["price"] * 0.1 }
  end
end
Bulk.new
# rules/20_loyalty.rb: 5% off orders over 200 francs
class Loyalty
  def name = "Loyalty 5%"
  def discount(_lines, subtotal) = subtotal > 200 ? Invoice.round5(subtotal * 0.05) : 0
end
Loyalty.new

La règle de fidélité appelle Invoice.round5, qui est du code compilé. Un seul appel à make compile le binaire et nomme les méthodes que les scripts ont le droit d’appeler :

make host HOST=build/invoice HOST_NAME=invoice KERNEL=app.rb \
  KERNEL_MAIN=Invoice.main KERNEL_ENTRIES=Invoice.main,Invoice.round5
$ build/invoice/invoice items.csv rules
Subtotal                     275.50
Bulk discount                -12.00
Loyalty 5%                   -13.20
VAT 8.1%                      20.27
Total                        270.55

Cela a fonctionné du premier coup. J’ai ensuite ajouté une troisième règle, une semaine du café où chaque troisième paquet est offert, sous forme de nouveau fichier dans rules/, et relancé le même binaire. Même somme de contrôle, nouvelle ligne sur la facture, total de 250.60. Sans passer par le compilateur. C’est tout l’intérêt de Fluxpinel, et on a vraiment l’impression d’écrire du Ruby ordinaire.

Les mêmes fichiers tournent aussi sous CRuby : kernel/fluxpinel_cruby.rb y fournit le module Fluxpinel. Les deux ont affiché exactement la même facture. Le binaire compilé a démarré et terminé en moins de 10 millisecondes, CRuby a eu besoin d’environ 0,25 seconde, surtout pour démarrer.

Les exemples

Le dépôt contient trois exemples, et je les ai tous compilés :

logq est le seul qui n’a pas fonctionné chez moi : il a planté avec une erreur de segmentation, même sur un log de 1000 lignes. J’ai creusé, et la cause est bien circonscrite. Une String créée par Symbol#to_s dans le script, ici by.to_s pour top(5, by: :duration), devient un déchet lorsqu’elle est passée à une méthode compilée. Un littéral String, ou :duration.to_s.dup, fonctionne très bien. La CI du projet est au vert, mais elle n’exécute pas les exemples. Pour un projet vieux de trois semaines, je trouve un bug de ce genre tout à fait normal. Je l’ai signalé avec une petite reproduction et la cause : issue n° 1.

Quelle est la vitesse de l’interpréteur ?

L’interpréteur est conçu pour la colle d’un programme, pas pour ses boucles critiques, et le README le dit. J’ai tout de même mesuré :

  Fluxpinel CRuby 4.0.9 CRuby + YJIT
fib(32), beaucoup d’appels de méthodes 1,78 s 0,37 s 0,18 s
Travail sur Hash et String, 300 000 itérations 0,22 s 0,27 s 0,25 s

Les appels de méthodes dans l’interpréteur sont environ cinq fois plus lents que dans CRuby. Mais quand un script appelle surtout des méthodes intégrées, qui sont du C compilé dans le runtime Spinel, il est aussi rapide que CRuby, voire un peu plus rapide. Le README rapporte le même schéma à plus grande échelle : optcarrot, le benchmark d’émulateur NES, tourne à environ 9 images par seconde dans Fluxpinel, contre 55 dans CRuby. À titre de comparaison, le fib(32) compilé par Spinel s’est terminé en moins de 10 millisecondes.

La répartition que montre logq est donc la bonne façon de l’utiliser : le script décide quoi faire, et le code compilé fait les boucles.

Plus que des plugins

Deux autres choses que je trouve intéressantes :

Il reste des limites. Un script ne peut appeler que les méthodes de module que vous exportez, pas les méthodes d’instances de classes compilées. Et le code compilé ne peut pas se rabattre sur l’interpréteur pour un appel qu’il ne sait pas résoudre. Les deux nécessitent des modifications dans Spinel. Matz tient volontairement eval et une VM à l’écart de la conception de Spinel ; Fluxpinel est donc un projet séparé et fixe un commit précis de Spinel.

Conclusion

Spinel rend Ruby rapide en fermant le programme. Fluxpinel le rouvre, exactement là où vous le voulez : plugins, règles, requêtes, console. Ma facture avec des règles en plugins a fonctionné du premier coup, et ajouter une règle sans recompiler donnait vraiment l’impression d’écrire du Ruby ordinaire. L’interpréteur est lent pour le code riche en appels de méthodes ; le gros du travail a donc sa place dans la partie compilée.

L’idée me plaît beaucoup. Elle donne à un programme Spinel la part de Ruby qui me manquerait le plus. Le projet est très jeune, attendez-vous donc à quelques aspérités, comme le plantage de logq. Je suis curieux de voir où il va, et comment Spinel et Fluxpinel évolueront ensemble.

Liens