Spinel is an ahead-of-time (AOT) compiler for Ruby. You give it a Ruby program, it writes C code, and a C compiler turns that into a native program. The program runs without Ruby installed.
Spinel is Matz's project (github.com/matz/spinel). I made some contributions to it, and I hope this one-page intro helps you understand more of it too.
CRuby (also called MRI, Matz's Ruby Interpreter) is an interpreter: it compiles each file to bytecode when it loads it, and runs that bytecode in a virtual machine. So a program can load or create new code while it runs.
Spinel works differently. It reads the whole program first, figures out the type of every value, and writes C. So the program you run is native machine code, plus a small runtime library from Spinel for things like garbage collection.
Before Spinel writes any C, it checks that it can be sure about every value and every call in the program. Here's a small program that passes the check, and one that fails it:
def double(x) = x * 2
puts double(21)
Spinel reads the whole program and sees that double only gets 21. So it knows x is always an Integer, and it turns x * 2 into plain C math.
code = $stdin.gets
puts eval(code)
Here the code arrives while the program runs, so Spinel can't know what it will be. (Spinel refuses eval even when the String is written in the program.) Spinel stops and prints a message like this (trimmed) instead of making a program:
spinel: e3.rb:2: unsupported eval of a runtime string is not supported by AOT compilation (define the code statically) spinel: 1 refusal, nothing written
Spinel's docs call this a refusal. The message tells us the file and line.
| CRuby | Spinel | |
|---|---|---|
| Running the program | You need Ruby installed. | You only need the system C libraries (libc and libm). The program runs on the same OS and CPU type it was built for. |
| What you ship | You ship your .rb files. | You ship one program file. The 2-line double program above becomes a 153 KB program. |
| Code that's decided while the program runs | It works: eval, method_missing and define_method with any name. | It doesn't work, because Spinel needs to see all the code when it compiles. |
| Errors | Errors like NoMethodError show up when that line runs. | Spinel refuses some calls to a method that doesn't exist when it compiles, even if that line would never run. Other calls to a missing method, and errors like calling + on nil, show up when that line runs. |
| Gems | Bundler installs them, and any gem works. | Spinel has its own package tool, spin, and the community built a Bundler plugin. A gem only works if Spinel can compile the parts your program uses. |
Let's go back to double. We can compile it with spinel -S, which prints the C instead of making a program. We can compile it twice: first when it only gets Integers, and then when it gets an Integer and a String. The C below is trimmed to the parts that change, with comments added.
1. double only gets Integers
# one.rb def double(x) x * 2 end puts double(21) puts double(4)
/* one.c */ sp_int sp_double(sp_int lv_x) { return sp_int_mul(lv_x, 2LL); } /* puts double(21) */ sp_oint _t1 = sp_oint_of(sp_double(21LL)); printf("%lld\n", (long long)_t1.v);
sp_int is a plain C integer. So x can stay in a CPU register, and x * 2 is one multiply plus an overflow check. puts becomes a printf.
2. double gets an Integer and a String
# two.rb def double(x) x * 2 end puts double(21) puts double("ab") # "abab"
/* two.c */ sp_RbVal sp_double(sp_RbVal lv_x) { 1/* tell the garbage collector about x */ struct { sp_gc_frame_hdr h; void **p[1]; } _gcf = {{0, 1}}; SP_GC_ROOT_FRAME(_gcf); _gcf.p[0] = SP_GC_ENTRY_RBVAL(lv_x); 2return sp_poly_mul(lv_x, sp_box_int(2LL)); } /* puts double(21): box 21 into main's own GC frame first */ _gcf.v[0] = sp_box_int(21LL); 3sp_poly_puts(sp_double(_gcf.v[0])); /* puts double("ab") */ 3sp_poly_puts(sp_double(SP_STR_BOX(((char *)_fzl_4d2505ca.d))));
Now x can be two types, so Spinel can't use a plain C integer for it. It uses sp_RbVal instead, a boxed value: a tag that says what's inside (Integer, String, and so on) and the value itself. We can also see these changed:
double now tells the garbage collector about x, because x can hold a String that lives in memory.x * 2 becomes a call to sp_poly_mul. It checks the tags while the program runs and picks the right multiply: Integer times Integer, String repeat, Float, Array, and more.puts becomes sp_poly_puts, which also checks the tag first.Without the #line directives (spinel -S --no-line-map), the whole C file goes from 47 lines to 97 lines, mostly for the support code that Strings, Symbols and class names now need.
To see the cost, we can call double(i) 200 million times in a loop, and then add one double("ab") call after the loop:
def double(x) x * 2 end sum = 0 i = 0 while i < 200_000_000 sum += double(i) i += 1 end puts sum puts double("ab") # second version only
The String call runs only once, after the loop. But it still makes Spinel's loop about 15 times slower, because double now takes a boxed value on every call.
We can see why in the machine code, with objdump -d on the two programs. In the Integer-only program, the C compiler inlines double and turns x * 2 into adding 2 each time. The whole loop is 8 instructions and has no calls (the jump labels are renamed to make it easier to read):
# Integers only loop: xor %ecx,%ecx add %rax,%rdx # sum += 2 * i seto %cl # overflow check add $0x2,%rax # next 2 * i test %rcx,%rcx jne overflow cmp $0x17d78400,%rax # 2 * 200_000_000 jne loop
In the two-type program, sum also becomes a boxed value, because double now returns one. So every time through the loop, it registers values with the garbage collector and calls two runtime functions that check the tags:
# Integer and String call sp_poly_mul # x * 2 call sp_poly_add # sum += double(i)
So Spinel is fastest when each value keeps one type.
Here are two more Ruby features that Spinel refuses:
method_missingSpinel decides which method each call runs when it compiles, so it never sends a missing method to method_missing. Here it refuses the call. In other places the call compiles and raises NoMethodError when it runs.
class Ghost def method_missing(name, *args) "you called #{name}" end end puts Ghost.new.hello
spinel: mm.rb:2: warning: method_missing is defined but spinel does not dispatch undefined-method calls to it; such calls raise NoMethodError (method_missing can still be called explicitly) spinel: mm.rb:6: undefined method 'hello' for an instance of Ghost (NoMethodError) spinel: 1 refusal, nothing written
define_method with a name that isn't written in the codeHere the method names come from an environment variable. Spinel refuses this even when the list is built in the program, like "host,port".split(","). A literal list like %i[host port] works.
class Config ENV.fetch("FIELDS", "host,port").split(",").each do |name| define_method(name) { "value of #{name}" } end end puts Config.new.host
spinel: dm.rb:3: Module#define_method with a non-literal name is not supported; use a literal Symbol or String (see docs/limitations.md) spinel: 1 refusal, nothing written
Spinel also doesn't support ObjectSpace.each_object, TracePoint, binding as an object, refinements and callcc. The full list is in docs/limitations.md.
Spinel tries to refuse any code it can't be sure about. But some unsupported code still compiles and only fails when it runs: Ractor.new compiles and then raises NameError. And Spinel still has bugs where it compiles a program and prints a wrong answer.
Metaprogramming works when Spinel can see the names when it compiles: define_method and send with names written in the code, and Struct.new with a block. This program compiles and prints the same output as CRuby:
Point = Struct.new(:x, :y) do def +(other) = Point.new(x + other.x, y + other.y) end class Shape %i[area perimeter].each do |m| define_method(m) { raise NotImplementedError } end end class Square < Shape def initialize(side) = @side = side def area = @side * @side end p [Point.new(1, 2), Point.new(3, 4)].sum(Point.new(0, 0)) p [1, 2, 3].map { |n| Square.new(n).area } %w[upcase reverse].each { |m| puts "spinel".send(m) }✓ COMPILES, SAME OUTPUT AS CRUBY
#<struct Point x=4, y=6> [1, 4, 9] SPINEL lenips
ruby-bench is the benchmark suite in the ruby organization on GitHub. It has 88 benchmarks, and Spinel gets a speed number on only 29 of them.
Most of these are micro benchmarks, and real apps like Rails still can't run, so the averages don't say much about real apps yet.
Setup: Oct 31, 2026, Spinel 3e867e30, CRuby 4.0.7 and ruby-bench 73918b2 on a shared 4-core cloud VM, using ruby-bench's run_benchmarks.rb with its default settings. The chart shows the median time. YJIT's speedups on this VM are a little smaller than on rubybench.github.io.
Matz made the first commit on March 16, 2026 and worked alone for 5 weeks. Then other people started to send pull requests.