Spinel -- Ruby AOT Compiler Spinel compiles Ruby source code into standalone native executables. It performs whole-program type inference a…
Spinel -- Ruby AOT Compiler Spinel compiles Ruby source code into standalone native executables. It performs whole-program type inference a…
Spinel compiles Ruby source code into standalone native executables. It performs whole-program type inference and generates optimized C code, achieving significant speedups over CRuby.
The compiler is a single self-contained C binary: it parses Ruby (via
libprism), infers types across the whole program, emits C, and invokes the
system cc to produce a native executable -- no Ruby runtime and no chained
helper binaries at compile time. (Spinel was previously written in a
self-hosting Ruby subset; that backend is preserved on the self-host
branch -- see History.)
…Analyze and codegen run in one process and share the in-memory model: codegen reads the types analyze just inferred directly, with no serialization step in between. See docs/internals/AST.md for the text AST format the parser emits and the analyzer consumes.
Spinel is built from source: clone this repository and build with make.
It is not distributed as a RubyGem, and there is no gem install spinel.
(Spinel compiles certain gems into your program, but the compiler itself
ships as source.)
# Fetch libprism sources (from the prism gem on rubygems.org):
make deps
# Build everything (the compiler `spinel` and the project tool `spin`):
make
sudo make install # optional: puts spinel and spin on PATH
# Start a project:
spin new myapp && cd myapp
spin run # compile bin/myapp.rb and run itReleases are dated tags, YYYY.MM.DD (spinel --version names the one a
build belongs to, +N when the build is N commits past it, then the git
revision in parentheses); the name says when a release was cut,
and what it promises is in docs/limitations.md and the
tests. The first one is 2026.09.12. Each release carries a source archive
(spinel-<release>.tar.xz, what make dist produces) with the vendored
parsers included, so it builds with make alone and no network.
spin is the day-to-day interface — cargo/mix style. It scaffolds
projects, resolves dependencies, drives the compiler, and runs tests; no
Makefile, no hand-written -I flags:
spin add ansi --version "~> 1.0" # from the spin package index
spin add mylib --path ../mylib # or a local checkout / --git URL
spin test # snapshot tests (CRuby is the oracle)
spin build && ./build/bin/myappDependencies (packages) are source trees compiled into your binary — no
runtime loading, no extension builds; a package can even carry .c files.
See docs/spin.md for the full guide, including how to
write and publish a library.
spinel is the underlying compiler — gcc-like, one job — and stays the
right tool for single-file scripts and experiments:
cat > hello.rb <<'RUBY'
def fib(n)
if n < 2
n
else
fib(n - 1) + fib(n - 2)
end
end
puts fib(34)
RUBY
./spinel hello.rb
./hello # prints 5702887 (instantly)./spinel app.rb # compiles to ./app
./spinel app.rb -o myapp # compiles to ./myapp
./spinel app.rb -c # generates app.c only
./spinel app.rb -S # prints C to stdout
./spinel -E app.rb a b c # compile, run with ARGV=[a, b, c], discard binary
./spinel -e 'puts 42' # compile inline source
./spinel app.rb --int-overflow=wrap # +/-/* wrap silently instead of raising./spinel is a single native binary (build/spinel; the repo-root
spinel is a convenience symlink make creates) that parses, infers
types, emits C, invokes cc to link it, and can run the result — no shell
wrapper or chained helper binaries, no network, no manifest knowledge
(that separation is what keeps builds hermetic; spin owns the stateful
side). It supports the full option set, including --rbs DIR (RBS-seeded
inference) and the --emit-rbs / --emit-types / --emit-symbol-map
analysis modes.
Integers are native fixed-width words. --int-overflow=MODE selects how
+/-/* behave when a result exceeds that width:
raise (default) -- raise on overflow. Safe (never silently wrong or
undefined), but 9223372036854775807 + 1 raises instead of returning a
Bignum.wrap -- silent two's-complement wrap. Fastest, no check.promote -- escalate to arbitrary-precision Integer (Bigint), matching
CRuby. 9223372036854775807 + 1 returns 9223372036854775808.In the default mode, integer locals that an obvious growth pattern would
overflow (e.g. a q = q * k accumulator) are still auto-promoted to Bigint;
promote extends that to every integer operation.
Spinel can read RBS files to seed the analyzer. When invoked with
--rbs DIR, spinel runs spinel_rbs_extract over a directory of
*.rbs files (the same layout rbs and Steep use) and feeds the
resulting seed into the analyzer. Seeds are advisory — inference still runs on top and
widens on observed contradiction, so a wrong or unrepresentable seed
is at worst a no-op. See docs/rbs-extract.md
for the supported subset.
3,651 tests pass. 62 benchmarks pass.
Geometric mean: ~8.5x faster than Ruby 4.0.4 with --yjit across
the 28 benchmarks below (~15.2x against the interpreter). Measured
2026-09-17 on spinel 74c8e0a4 (32-core Linux box, gcc 13.3). Baseline is CRuby 4.0.4 (stable,
revision b89eb1bc), run with --disable-gems and with --yjit for
the JIT column. Each timing is the mean of five wall-clock runs under
perf stat -r 5, which held every cell inside a few percent. A compiled binary
starts in about 0.8 ms, so a sub-10 ms Spinel cell is mostly process
startup and its ratio should be read as a lower bound on the machine,
not as a measurement of the loop.
| Benchmark | Spinel | Ruby 4.0.4 | + YJIT | Speedup vs YJIT |
|---|---|---|---|---|
| mandelbrot | 18 ms | 939 ms | 938 ms | 53.6x |
| fib (recursive) | 1.1 ms | 387 ms | 49 ms | 44.6x |
| matmul | 6.1 ms | 200 ms | 201 ms | 32.9x |
| nqueens | 6.0 ms | 147 ms | 141 ms | 23.6x |
| sieve | 14 ms | 274 ms | 274 ms | 19.2x |
| partial_sums | 41 ms | 786 ms | 777 ms | 19.0x |
| life (Conway's GoL) | 19 ms | 499 ms | 229 ms | 12.1x |
| sudoku | 3.3 ms | 63 ms | 32 ms | 9.6x |
| fannkuch | 1.3 ms | 9.2 ms | 8.9 ms | 6.8x |
| fasta (DNA seq gen) | 1.5 ms | 8.4 ms | 8.8 ms | 5.9x |
| tak | 7.8 ms | 320 ms | 46 ms | 5.9x |
| ackermann | 6.7 ms | 290 ms | 39 ms | 5.8x |
| tarai | 6.9 ms | 255 ms | 38 ms | 5.5x |
| Benchmark | Spinel | Ruby 4.0.4 | + YJIT | Speedup vs YJIT |
|---|---|---|---|---|
| so_lists | 12 ms | 259 ms | 159 ms | 13.1x |
| huffman (encoding) | 5.4 ms | 38 ms | 39 ms | 7.2x |
| linked_list | 23 ms | 189 ms | 153 ms | 6.7x |
| gcbench | 196 ms | 2_114 ms | 952 ms | 4.9x |
| binary_trees | 2.7 ms | 23 ms | 13 ms | 4.7x |
| rbtree (red-black tree) | 15 ms | 355 ms | 67 ms | 4.5x |
| splay tree | 8.7 ms | 116 ms | 39 ms | 4.5x |
| Benchmark | Spinel | Ruby 4.0.4 | + YJIT | Speedup vs YJIT |
|---|---|---|---|---|
| ao_render (ray tracer) | 70 ms | 1_646 ms | 611 ms | 8.7x |
| bigint_fib (1000 digits) | 0.9 ms | 5.7 ms | 6.1 ms | 6.8x |
| pidigits (bigint) | 1.0 ms | 5.8 ms | 6.2 ms | 6.2x |
| template engine | 56 ms | 460 ms | 328 ms | 5.9x |
| json_parse | 28 ms | 212 ms | 133 ms | 4.7x |
| str_concat | 1.3 ms | 6.4 ms | 6.0 ms | 4.6x |
| csv_process | 95 ms | 491 ms | 396 ms | 4.2x |
| io_wordcount | 14 ms | 49 ms | 44 ms | 3.1x |
A few notes on what YJIT does and doesn't change. On some integer-loop workloads (mandelbrot, nqueens, matmul, partial_sums, sieve) YJIT's numbers are essentially identical to interpreted Ruby; the benchmark is bound by integer / float operations that the interpreter already runs at native speed. On call-heavy code (ackermann, tarai, tak, rbtree) YJIT gives a real 5-8x lift over the interpreter, which is why those rows carry Spinel's smaller multiples.
One row deserves a caveat rather than a boast. fib is not 45x faster
per call: its whole call tree is a pure function of a literal, so the C
compiler collapses most of it at build time -- fib(42) retires 231M
instructions where the recursion itself would make 866M calls. Read it
as "the C compiler got to see the whole program," which is the point of
compiling ahead of time, but not as a per-call number.
The genuinely narrow cells are io_wordcount, bound by line reading and
hash updates that neither compiler can specialize, and csv_process and
gcbench, bound by allocation.
Core: Classes, inheritance, super, include (mixin), attr_accessor,
Struct.new, alias, module constants, open classes for built-in types.
Control Flow: if/elsif/else, unless, case/when,
case/in (pattern matching), while, until, loop, for..in
(range and array), break, next, return, catch/throw,
&. (safe navigation).
Blocks: yield, block_given?, &block, proc {}, Proc.new,
lambda -> x { }, method(:name). Block methods: each,
each_with_index, map, select, reject, reduce, sort_by,
any?, all?, none?, times, upto, downto.
Exceptions: begin/rescue/ensure/retry, raise,
custom exception classes.
Types: Integer, Float, String (immutable + mutable), Symbol, Array,
Hash, Range, Time, StringIO, File, Regexp, MatchData, Complex, Rational,
Bigint (auto-promoted), Enumerator, Set, Fiber, Thread, Mutex, Queue,
SizedQueue, ConditionVariable, Marshal (dump/load). Polymorphic values
via tagged unions. Nullable object types (T?) for self-referential
data structures (linked lists, trees).
Inspect / p: Object#inspect produces CRuby-byte-identical
output across the whole type surface -- primitives, typed and
heterogeneous arrays, every Hash variant, Range, Struct, and
user-class instances (#<Name:0x... @ivar=...>, or a user-defined
inspect if present), including values reached through a polymorphic
(tagged-union) binding. p obj, obj.inspect, obj.to_s, and
"#{obj}" interpolation all agree.
Global Variables: $name compiled to static C variables with
type-mismatch detection at compile time.
Strings: << automatically promotes to mutable strings (sp_String)
for O(n) in-place append. +, interpolation, tr, ljust/rjust/center,
and all standard methods work on both. Character comparisons like
s[i] == "c" are optimized to direct char array access (zero allocation).
Chained concatenation (a + b + c + d) collapses to a single malloc
via sp_str_concat4 / sp_str_concat_arr -- N-1 fewer allocations.
Loop-local str.split(sep) reuses the same sp_StrArray across
iterations (csv_process: 4 M allocations eliminated).
Regexp: Built-in NFA regexp engine (no external dependency).
=~, $1-$9, match?, gsub(/re/, str), sub(/re/, str),
scan(/re/), split(/re/).
Bigint: Arbitrary precision integers via mruby-bigint. Auto-promoted
from loop multiplication patterns (e.g. q = q * k), or from every integer
operation under --int-overflow=promote (see Integer overflow).
Linked as static library -- only included when used.
Fiber: Cooperative concurrency via a small portable assembly
context switch (x86-64 / arm64; no ucontext dependency). Fiber.new,
Fiber#resume, Fiber.yield with value passing, Fiber#raise/#kill,
external Enumerators and Enumerator::Lazy ride the same machinery.
Captures free variables via heap-promoted cells. Per-fiber storage via
Fiber[:k] / Fiber[:k] = v (and the Fiber.current[:k] aliases) —
symbol-keyed poly-valued, lazily allocated, shallow-snapshot inherited
from the parent at Fiber.new time.
Threads: Thread runs with true parallelism and no GVL — an
M:N scheduler multiplexes green threads onto OS workers (one per c
No open issues yet, or sync has not completed.