← Articoli

Fluxpinel: plugin ed eval per Ruby compilato con Spinel

Nel mio articolo su Ruby in WebAssembly ho provato Spinel, il nuovo compilatore di Matz che trasforma un programma Ruby in C e poi in un binario nativo veloce. Il prezzo da pagare è che l’intero programma deve essere noto al momento della compilazione: niente eval, niente method_missing e nessun caricamento di nuovo codice mentre il programma gira.

Fluxpinel di Hitoshi Hasumi, l’autore di PicoRuby, affronta proprio questo limite. Il suo motto è “Make Spinel Dynamic”. È un interprete Ruby che vive nello stesso binario del programma compilato e usa lo stesso runtime. La parte compilata resta veloce, e le parti che devono poter cambiare, come plugin, configurazione, query o una console, sono semplici file Ruby che il binario legge in fase di esecuzione.

Il progetto è nuovissimo: il primo commit è del 19 ottobre 2026 e ne conta già più di 200. L’ho compilato e provato.

Come funziona

Spinel compila il vostro programma, che Fluxpinel chiama kernel, in C. La build mette quel codice C e Fluxpinel in un unico binario. Entrambe le parti condividono un solo heap, un solo garbage collector e i metodi builtin del runtime di Spinel. Così un oggetto passa tra codice compilato e script così com’è, senza copie né conversioni, e un rescue da una parte cattura un raise dall’altra.

Il kernel comunica con l’interprete attraverso un piccolo modulo:

Nell’altra direzione, uno script può chiamare i metodi di modulo del kernel che elencate al momento della build.

Fluxpinel funziona anche da solo, come il comando ruby, con -e, -I, -r e una console. C’è anche un’opzione --irb per IRB, ma sulla mia macchina si è fermata con un errore di caricamento nelle gem irb e prism installate, quindi non ho potuto provarla. Lì RUBY_ENGINE vale "spinel".

La build

Servono git, un compilatore C, libffi e Ruby. make deps scarica e compila Spinel al commit fissato da Fluxpinel, e make compila l’interprete:

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

Sul mio portatile con Ubuntu, GCC 15 e Ruby 4.0.9 ha funzionato senza problemi. Spinel ha richiesto circa dieci minuti, Fluxpinel circa 40 secondi. Poi:

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

La build predefinita solleva un RangeError quando un Integer va in overflow, come fa Spinel per impostazione predefinita. Per avere i Bignum come in CRuby c’è una seconda build: make BUILD=build/promote INT_OVERFLOW=PROMOTE build/promote/fluxpinel, e a quel punto 2**100 funziona.

Il mio test: regole di fatturazione come plugin

Come nei miei articoli precedenti, ho usato una piccola fattura svizzera. La parte compilata legge gli articoli, calcola il subtotale, l’IVA dell’8,1% e arrotonda il totale ai 5 centesimi. Le regole di sconto non sono compilate. Ogni regola è un file Ruby in una cartella 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

I commenti #: sono tipi RBS inline. Spinel ne ha bisogno per i metodi che solo lo script chiama, perché non può ricavarne i tipi dal solo codice compilato.

Due regole:

# 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 regola fedeltà chiama Invoice.round5, che è codice compilato. Una sola chiamata a make compila il binario e indica i metodi che gli script possono chiamare:

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

Ha funzionato al primo colpo. Poi ho aggiunto una terza regola, una settimana del caffè in cui ogni terzo sacchetto è gratis, come nuovo file in rules/, e ho rieseguito lo stesso binario. Stesso checksum, nuova riga in fattura, totale 250,60. Nessun compilatore coinvolto. È questo il senso di Fluxpinel, e sembra proprio di usare Ruby normale.

Gli stessi file girano anche con CRuby: lì kernel/fluxpinel_cruby.rb fornisce il modulo Fluxpinel. Entrambi hanno stampato esattamente la stessa fattura. Il binario compilato si è avviato e ha terminato in meno di 10 millisecondi, CRuby ha impiegato circa 0,25 secondi, in gran parte per l’avvio.

Gli esempi

Il repository contiene tre esempi, e li ho compilati tutti:

logq è quello che da me non ha funzionato: si è bloccato con un segmentation fault, anche su un log di 1’000 righe. Ho indagato, e la causa è circoscritta. Una String creata nello script da Symbol#to_s, qui by.to_s per top(5, by: :duration), diventa spazzatura quando viene passata a un metodo compilato. Un letterale String, oppure :duration.to_s.dup, funziona senza problemi. La CI del progetto è verde, ma non esegue gli esempi. Per un progetto di tre settimane, trovo un bug del genere del tutto normale. L’ho segnalato con una piccola riproduzione e la causa: issue #1.

Quanto è veloce l’interprete?

L’interprete è pensato per la colla di un programma, non per i suoi cicli critici, e il README lo dice chiaramente. Ho misurato comunque:

  Fluxpinel CRuby 4.0.9 CRuby + YJIT
fib(32), molte chiamate di metodo 1,78 s 0,37 s 0,18 s
Lavoro su Hash e String, 300’000 iterazioni 0,22 s 0,27 s 0,25 s

Le chiamate di metodo nell’interprete sono circa cinque volte più lente che in CRuby. Ma quando uno script chiama soprattutto metodi builtin, che nel runtime di Spinel sono C compilato, è veloce quanto CRuby o persino un po’ di più. Il README riporta lo stesso schema su scala più ampia: optcarrot, il benchmark dell’emulatore NES, gira a circa 9 fotogrammi al secondo in Fluxpinel, contro 55 in CRuby. Per confronto, fib(32) compilato con Spinel ha terminato in meno di 10 millisecondi.

La divisione che mostra logq è quindi il modo giusto di usarlo: lo script decide cosa fare, e il codice compilato esegue i cicli.

Più che plugin

Altre due cose che trovo interessanti:

Ci sono ancora dei limiti. Uno script può chiamare solo i metodi di modulo che esportate, non metodi su istanze di classi compilate. E il codice compilato non può ripiegare sull’interprete per una chiamata che non riesce a risolvere. Entrambe le cose richiedono modifiche a Spinel. Matz tiene volutamente eval e una VM fuori dal design di Spinel, per cui Fluxpinel è un progetto separato e fissa un preciso commit di Spinel.

Conclusione

Spinel rende Ruby veloce chiudendo il programma. Fluxpinel lo riapre, esattamente dove lo volete: plugin, regole, query, una console. La mia fattura con regole come plugin ha funzionato al primo colpo, e aggiungere una regola senza ricompilare sembrava proprio Ruby normale. L’interprete è lento per il codice ricco di chiamate di metodo, quindi il lavoro pesante va nella parte compilata.

L’idea mi piace molto. Dà a un programma Spinel la parte di Ruby che mi mancherebbe di più. È molto giovane, quindi aspettatevi qualche spigolo, come il crash di logq. Sono curioso di vedere dove andrà, e come Spinel e Fluxpinel cresceranno insieme.