Spinel

Spinel compiles Ruby into native programs

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.

Snapshot: Oct 31, 2026 · Spinel master 3e867e30
How Spinel works

Spinel is a compiler and it works differently than CRuby (MRI)

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.

What Spinel checks before compiling a Ruby script

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:

✓ COMPILES
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.

✕ REFUSED
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 and Spinel side by side
CRubySpinel
Running the programYou 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 shipYou 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 runsIt 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.
ErrorsErrors 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.
GemsBundler 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.

What does the C program that Spinel writes look like?

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:

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.

In Spinel, value types can affect performance A LOT

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
Time to run the loop, in seconds
Shorter is faster. Each bar is the median of 5 runs, with Spinel 3e867e30 and CRuby 4.0.7.

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.

Ruby features that Spinel doesn't support

Here are two more Ruby features that Spinel refuses:

✕ REFUSED

method_missing

Spinel 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
✕ REFUSED

define_method with a name that isn't written in the code

Here 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.

Spinel still supports some metaprogramming

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
Speed

Speed on ruby-bench

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.

Which benchmarks Spinel can run

The 88 ruby-bench benchmarks with Spinel
One square is one benchmark.
Why 37 benchmarks can't run
This shows the first problem that stops each benchmark. There can be more after it.

The 29 benchmarks that have a speed number

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.

13.9×
faster than CRuby (geometric mean)
4.5×
faster than CRuby with YJIT (geometric mean)
Spinel speed compared to CRuby and YJIT
Right of the line means Spinel is faster. Bold names are where Spinel is slower than YJIT. The scale is logarithmic.

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.

Development

Spinel's development activity

Matz made the first commit on March 16, 2026 and worked alone for 5 weeks. Then other people started to send pull requests.

5,616
PRs merged
100+
people with a commit
10,934
test programs
22,052
commits since Mar 16
PRs merged each week
The count jumped at the end of September, when the number of people with a commit also jumped. The last bar is the current week, which isn't over yet.
Test programs in Spinel's test suite
Each test is a Ruby program with a saved output, usually what CRuby prints, and Spinel must print the same thing.
People with a commit in master, each week