Fluxpinel: Plugins und eval für Ruby, kompiliert mit Spinel
In meinem Beitrag über Ruby in WebAssembly habe ich Spinel ausprobiert, den neuen Compiler von Matz, der ein Ruby-Programm in C-Code und daraus in eine schnelle native Datei verwandelt. Der Preis dafür: Das ganze Programm muss beim Kompilieren bekannt sein. Es gibt kein eval, kein method_missing und kein Nachladen von neuem Code, während das Programm läuft.
Fluxpinel von Hitoshi Hasumi, dem Autor von PicoRuby, nimmt sich genau diese Grenze vor. Sein Motto lautet «Make Spinel Dynamic». Es ist ein Ruby-Interpreter, der in derselben Binärdatei wie das kompilierte Programm lebt und dieselbe Runtime verwendet. Der kompilierte Teil bleibt schnell, und die Teile, die sich ändern sollen, etwa Plugins, Konfiguration, Abfragen oder eine Konsole, sind gewöhnliche Ruby-Dateien, welche die Binärdatei zur Laufzeit einliest.
Das Projekt ist brandneu: Der erste Commit stammt vom 19. Oktober 2026, und es hat bereits mehr als 200 Commits. Ich habe es gebaut und ausprobiert.
So funktioniert es
Spinel kompiliert Ihr Programm, das Fluxpinel den Kernel nennt, zu C. Der Build packt diesen C-Code und Fluxpinel in eine einzige Binärdatei. Beide Seiten teilen sich einen Heap, einen Garbage Collector und die eingebauten Methoden der Spinel-Runtime. Ein Objekt wechselt deshalb unverändert zwischen kompiliertem Code und Script hin und her, ohne Kopie oder Umwandlung, und ein rescue auf der einen Seite fängt ein raise auf der anderen.
Der Kernel spricht über ein kleines Modul mit dem Interpreter:
Fluxpinel.load(path)führt eine Ruby-Datei aus und gibt den Wert ihrer letzten Zeile zurück.Fluxpinel.eval(src)macht dasselbe für einen String.Fluxpinel.call(obj, name, args)ruft eine Methode auf einem Objekt auf, das das Script erzeugt hat.
In die andere Richtung kann ein Script die Modulmethoden des Kernels aufrufen, die Sie beim Build angeben.
Fluxpinel funktioniert auch eigenständig, wie der Befehl ruby, mit -e, -I, -r und einer Konsole. Es gibt auch eine Option --irb für IRB, aber auf meinem Rechner brach sie mit einem Ladefehler in den installierten Gems irb und prism ab, deshalb konnte ich sie nicht ausprobieren. RUBY_ENGINE ist dort "spinel".
Der Build
Sie brauchen git, einen C-Compiler, libffi und Ruby. make deps holt und baut Spinel auf dem Commit, den Fluxpinel festlegt, und make baut den Interpreter:
git clone https://github.com/hasumikin/fluxpinel
cd fluxpinel
make deps
make
build/fluxpinel -v
Auf meinem Laptop mit Ubuntu, GCC 15 und Ruby 4.0.9 klappte das ohne Probleme. Spinel dauerte etwa zehn Minuten, Fluxpinel etwa 40 Sekunden. Danach:
$ build/fluxpinel -v
fluxpinel 1 (spinel 5cce6eb03, ruby 4.0.9) [x86_64-linux]
$ build/fluxpinel -e 'p RUBY_ENGINE'
"spinel"
Der Standard-Build wirft einen RangeError, wenn ein Integer überläuft, so wie Spinel standardmässig. Für Bignums wie in CRuby gibt es einen zweiten Build: make BUILD=build/promote INT_OVERFLOW=PROMOTE build/promote/fluxpinel, und dann funktioniert 2**100.
Mein Test: Rechnungsregeln als Plugins
Wie in meinen früheren Beiträgen habe ich eine kleine Schweizer Rechnung verwendet. Der kompilierte Teil liest die Positionen ein, berechnet die Zwischensumme und 8,1% Mehrwertsteuer und rundet den Gesamtbetrag auf 5 Rappen. Die Rabattregeln sind nicht kompiliert. Jede Regel ist eine Ruby-Datei in einem Ordner 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
Die Kommentare mit #: sind Inline-RBS-Typen. Spinel braucht sie für Methoden, die nur das Script aufruft, weil es deren Typen nicht allein aus dem kompilierten Code herleiten kann.
Zwei Regeln:
# 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
Die Treueregel ruft Invoice.round5 auf, also kompilierten Code. Ein einziger make-Aufruf baut die Binärdatei und legt fest, welche Methoden Scripts aufrufen dürfen:
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
Es funktionierte auf Anhieb. Dann habe ich eine dritte Regel hinzugefügt, eine Kaffeewoche, in der jeder dritte Beutel gratis ist, als neue Datei in rules/, und dieselbe Binärdatei nochmals ausgeführt. Gleiche Prüfsumme, neue Zeile auf der Rechnung, Total 250,60. Kein Compiler im Spiel. Genau darum geht es bei Fluxpinel, und es fühlt sich an wie gewöhnliches Ruby.
Dieselben Dateien laufen auch unter CRuby: Dort stellt kernel/fluxpinel_cruby.rb das Modul Fluxpinel bereit. Beide gaben genau dieselbe Rechnung aus. Die kompilierte Binärdatei startete und war nach weniger als 10 Millisekunden fertig, CRuby brauchte etwa 0,25 Sekunden, vor allem für den Start.
Die Beispiele
Das Repository enthält drei Beispiele, und ich habe alle gebaut:
- calc wertet jedes Kommandozeilenargument mit
Fluxpinel.evalaus. Eine Methode, die ein Argument definiert, steht beim nächsten zur Verfügung. Spinel allein verweigertevalfür einen String, den es erst zur Laufzeit bekommt. - textkit ist ein Textwerkzeug mit Plugins, ganz ähnlich wie meine Rechnung. Es funktionierte wie beschrieben.
- logq wertet Access-Logs aus. Ein Benutzer schreibt Abfragen wie
where_status(500..599).slower_than(1.0), und die DSL erzeugt diese Methoden beim ersten Gebrauch mitmethod_missingunddefine_method, also mit Dingen, die Spinel allein nicht erlaubt. Die Schleifen über die Zeilen laufen in kompiliertem Code.
logq war das Beispiel, das bei mir nicht funktionierte: Es stürzte mit einem Segmentation Fault ab, sogar bei einem Log mit 1’000 Zeilen. Ich bin der Sache nachgegangen, und die Ursache ist eng begrenzt. Ein String, den Symbol#to_s im Script erzeugt, hier by.to_s für top(5, by: :duration), wird zu Datenmüll, wenn er an eine kompilierte Methode übergeben wird. Ein String-Literal oder :duration.to_s.dup funktioniert einwandfrei. Die CI des Projekts ist grün, führt die Beispiele aber nicht aus. Für ein Projekt, das drei Wochen alt ist, finde ich einen solchen Fehler ganz normal. Ich habe ihn mit einem kleinen Beispiel und der Ursache gemeldet: Issue #1.
Wie schnell ist der Interpreter?
Der Interpreter ist für den Leim eines Programms gedacht, nicht für seine heissen Schleifen, und das README sagt das auch. Ich habe trotzdem gemessen:
| Fluxpinel | CRuby 4.0.9 | CRuby + YJIT | |
|---|---|---|---|
fib(32), viele Methodenaufrufe |
1,78 s | 0,37 s | 0,18 s |
| Arbeit mit Hash und String, 300’000 Durchläufe | 0,22 s | 0,27 s | 0,25 s |
Methodenaufrufe sind im Interpreter etwa fünfmal langsamer als in CRuby. Wenn ein Script aber vor allem eingebaute Methoden aufruft, die in der Spinel-Runtime als kompiliertes C vorliegen, ist es so schnell wie CRuby oder sogar etwas schneller. Das README berichtet dasselbe Muster in grösserem Massstab: optcarrot, der Benchmark mit dem NES-Emulator, läuft in Fluxpinel mit etwa 9 Bildern pro Sekunde, gegenüber 55 in CRuby. Zum Vergleich: Das von Spinel kompilierte fib(32) war nach weniger als 10 Millisekunden fertig.
Die Aufteilung, die logq zeigt, ist also der richtige Weg: Das Script entscheidet, was getan wird, und der kompilierte Code erledigt die Schleifen.
Mehr als Plugins
Zwei weitere Dinge finde ich interessant:
- Eine Konsole in einem laufenden Programm. Mit
--irb-portkönnen Sie sich per telnet mit einer laufenden Binärdatei verbinden und erhalten IRB mit ihren lebenden Objekten. Das Repository zeigt das mit einem Rails-Blog, den Roundhouse von Sam Ruby in eine Spinel-Binärdatei verwandelt hat: Sie können Datensätze zählen und anlegen, während der Server weiter Seiten ausliefert. - Ein Testorakel für Spinel. Fluxpinel führt ein Programm ohne Typherleitung und ohne Optimierungen aus, auf derselben Runtime wie der kompilierte Code. Lässt man die Tests von Spinel in CRuby, in Fluxpinel und als kompilierten Code laufen und vergleicht die Ausgaben, zeigt sich, ob ein Fehler in der Analyse von Spinel steckt, in seiner Runtime oder in Fluxpinel.
Es gibt noch Grenzen. Ein Script kann nur die Modulmethoden aufrufen, die Sie exportieren, nicht aber Methoden auf Instanzen kompilierter Klassen. Und kompilierter Code kann für einen Aufruf, den er nicht auflösen kann, nicht auf den Interpreter ausweichen. Beides braucht Änderungen in Spinel. Matz hält eval und eine VM bewusst aus dem Design von Spinel heraus, deshalb ist Fluxpinel ein eigenes Projekt und legt einen bestimmten Spinel-Commit fest.
Fazit
Spinel macht Ruby schnell, indem es das Programm abschliesst. Fluxpinel öffnet es wieder, genau dort, wo Sie es wollen: Plugins, Regeln, Abfragen, eine Konsole. Meine Rechnung mit Regeln als Plugins funktionierte auf Anhieb, und eine Regel hinzuzufügen, ohne neu zu kompilieren, fühlte sich an wie gewöhnliches Ruby. Bei Code mit vielen Methodenaufrufen ist der Interpreter langsam, die schwere Arbeit gehört also in den kompilierten Teil.
Mir gefällt die Idee sehr. Sie gibt einem Spinel-Programm den Teil von Ruby zurück, den ich am meisten vermissen würde. Das Projekt ist sehr jung, rechnen Sie also mit Ecken und Kanten wie dem Absturz von logq. Ich bin gespannt, wohin es sich entwickelt und wie Spinel und Fluxpinel zusammenwachsen.