紅葉 — momiji · Ruby in Rust

Ruby, at Rust speed.

A tiny Ruby-subset interpreter written in Rust, built on Prism. Small, memory-safe, embeddable — and it compiles to WebAssembly. The mruby niche, in Rust.

42×

faster end-to-end than CRuby 3.4 on DSL hosting — the actual product-niche benchmark.

1.5 ms cold start 2.1 MB peak memory 8.8× lighter than CRuby
Benchmarks

The number that matters is end-to-end.

Parse, load, run, exit. That's the real product-niche workload — and where the gap is widest.

End-to-end DSL hosting — Brewfile, ~50 lines (lower is better)
momiji
1.8 ms
CRuby 3.4
74.7 ms
CRuby + YJIT
75.5 ms
Cold start — puts 1+2
ShapeTime
momiji (native)1.5 ms
momiji · cwasm (AOT + wizer)~7 ms
momiji · wasm (raw JIT)12.7 ms
CRuby 3.478 ms
1M fizzbuzz — time & memory
momijiCRuby
Time0.33 s0.19 s
Peak memory2.1 MB18.4 MB
Sandbox

Run untrusted Ruby. On purpose.

Per-run resource caps; trip one and you get a clean ResourceExhausted trap — never a host panic.

The shape is Lua-in-Rust + Ruby grammar + sandbox — not CRuby with fewer features. Embedders building sandboxed DSL hosts get deterministic-by-default behaviour.

  • RUBYRS_FUELinstruction budget
  • RUBYRS_MAX_OBJECTSheap object cap
  • RUBYRS_MAX_FRAMEScall-stack depth
let mut rt = Runtime::with_config(Config {
    fuel: Some(1_000_000),
    max_heap_objects: Some(10_000),
    max_frames: Some(128),
    ..Default::default()
});

rt.register_fn("host_pid", |_| {
    Ok(Value::Int(std::process::id() as i64))
});

rt.eval(r#"puts "pid #{host_pid}""#, "inline")?;
WebAssembly

Cold-start in single-digit milliseconds.

Ship the raw .wasm, or precompile to .cwasm with wizer pre-initialization.

1.5 ms

Native binary — the baseline.

~7 ms

cwasm — AOT + wizer snapshot. The shipping shape.

12.7 ms

Raw .wasm, JIT on load — still 6× faster than CRuby.

HTTP server battery · preview

A Rack-shape server, in the runtime.

Rust front (hyper + tokio), Ruby handler. Add _fiber for true SSE streaming.

class SSEStream
  def each
    10.times { |i| yield "data: tick #{i}\n\n" }
  end
end

app = ->(env) {
  [200,
   {"Content-Type" => "text/event-stream"},
   SSEStream.new]
}
__rubyrs_http_serve_with_app("127.0.0.1:9292", 60, app)

Each yield becomes one HTTP/1.1 chunked frame, flushed before the next is produced — SSE, long-poll, large files.

  • Linux 3.9+pre-fork · SO_REUSEPORT
  • FreeBSDpre-fork · REUSEPORT_LB
  • macOS / Windowssingle-process
The subset

Honest about what it isn't.

Not a Ruby gem host. The omissions are deliberate. Need Rails, Sinatra, Bundler, or gems? Use CRuby.

Works today
  • Classes, methods, blocks, *splat, **kwargs
  • require for user .rb files & $LOAD_PATH
  • Vendored pathname · set · stringio · strscan
  • Real Gemfile / Brewfile DSLs, end-to-end
  • Incremental runtime — defs persist across eval
Deliberately not
  • Gem hosting — no Rails, Sinatra, Bundler
  • Kernel#load · real autoload
  • Auto-populated $LOAD_PATH
  • JSON.parse, YAML.load, URI.parse — stubbed
  • Anything non-deterministic by default
What's next

rubund — Bundler, in Rust.

A sibling crate and the first real driver of the embedding API. Gemfiles are Ruby DSLs, so it doubles as in-tree dogfooding.

# Gemfile is just a Ruby DSL
source "https://rubygems.org"

gem "rails", "~> 7.1"
group :test, :development do
  gem "rspec"
  gem "rubocop", require: false
end
# → parsed end-to-end in ~0.4 ms
The momiji family

More of the Ruby world, in Rust.

Momiji's siblings in the momiji-rs org — each one matched byte-for-byte against the tool it replaces.