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:
Fluxpinel.load(path)esegue un file Ruby e restituisce il valore della sua ultima riga.Fluxpinel.eval(src)fa lo stesso per una String.Fluxpinel.call(obj, name, args)chiama un metodo su un oggetto creato dallo script.
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:
- calc valuta ogni argomento della riga di comando con
Fluxpinel.eval. Un metodo definito da un argomento è disponibile per quello successivo. Spinel da solo rifiuta l’evaldi una String che riceve solo in fase di esecuzione. - textkit è uno strumento di testo con plugin, molto simile alla mia fattura. Ha funzionato come descritto.
- logq analizza i log di accesso. Un utente scrive query come
where_status(500..599).slower_than(1.0), e il DSL crea questi metodi al primo utilizzo conmethod_missingedefine_method, cose che Spinel da solo non consente. I cicli sulle righe girano nel codice compilato.
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:
- Una console in un programma in esecuzione. Con
--irb-portpotete collegarvi via telnet a un binario in esecuzione e ottenere IRB con i suoi oggetti vivi. Il repository lo mostra con un blog Rails che Roundhouse di Sam Ruby ha trasformato in un binario Spinel: potete contare e creare record mentre il server continua a servire pagine. - Un oracolo di test per Spinel. Fluxpinel esegue un programma senza inferenza dei tipi né ottimizzazioni, sullo stesso runtime del codice compilato. Eseguire i test di Spinel in CRuby, in Fluxpinel e come codice compilato, e confrontarne l’output, mostra se un bug sta nell’analisi di Spinel, nel suo runtime o in Fluxpinel.
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.