Every Rails developer eventually meets the same mystery: the first request after boot is slow, and every request after that is fine. Part of that warm-up is Ruby quietly filling method call caches. When foo.bar executes, the interpreter records which class it found the method on and which C handler will invoke it — and the next call skips the entire superclass walk. Delete that bookkeeping and Ruby would feel like a different language; it is the reason each, map, and your controller actions can afford to be plain method calls.

A second mystery is the gap between typing ruby app.rb and seeing your first line run. Your source is parsed by a real parser, compiled into bytecode for a register-based virtual machine, laid out into stack frames, and only then does execution begin. None of this is visible from the language surface, yet every performance question — why some allocations are cheap, why threads switch when they do — answers to it.

This post is a source tour of ruby/ruby at branch master (commit c0013b358f59, checked out 2026-11-10). Rather than listing features, we follow one execution path: startup, parsing, compilation, bytecode dispatch, method resolution, garbage collection, and concurrency. Every file, symbol, and line number below was grep-verified against that exact tree before publication; where the tree has drifted from folklore — and it has — we say so.

Ruby interpreter overview: startup, Prism and parse.y frontends, compile.c and prism_compile.c, the YARV VM loop, dispatch with call cache, GC and concurrency

The Ruby execution path from left to right: two parser frontends converge on one bytecode compiler, whose output runs inside the YARV interpreter loop, with method dispatch, garbage collection, and thread scheduling hanging off the same execution context.

Reading the overview from left to right:

Ruby internals detail: ruby_options, process_options, Prism and parse.y parsing, iseq compilation, vm_exec_core dispatch, call cache population, GC marking and sweeping, thread and fiber switching

The detail view adds the machinery behind each stage: option handling, both compile paths, cache population under the VM lock, incremental mark steps, lazy sweeping, heap page allocation, and the fiber switch path.

Two parsers, one bytecode compiler

The interesting design decision in current master is that Ruby carries two complete frontends and guarantees they emit the same downstream artifact. Prism parses your script in prism_script when the default holds; otherwise process_options (ruby.c, L2375) routes to the LALR machinery, where yycompile drives yyparse over tokens from parser_yylex. What both produce is not shared code but a shared contract: an iseq built by iseq.c, using the same prepare_iseq_build prologue and the same instruction set.

Compilation is where the AST dies and bytecode is born. The parse.y compiler is compile.c’s recursive walk: rb_iseq_compile_node at compile.c, L901 dispatches into iseq_compile_each0 per NODE type, appending instructions like putobject into a linked anchor, then the peephole pass repairs jump chains. The Prism compiler mirrors this shape in prism_compile.c, from pm_compile_node, L8803 downward — same target instruction sequence, different source tree. If you have read our CPython tour, the contrast is instructive: Python 3.13 keeps one parser with optional speed-ups, while Ruby runs two real frontends behind one bytecode format.

Inside vm_exec_core: frames, insns, interrupts

The interpreter loop is deliberately unremarkable C. vm_exec (vm.c, L2891) pushes a tag, tries the JIT entry first, and otherwise calls vm_exec_core; exceptions unwind through vm_exec_loop and vm_exec_handle_exception before re-entering the core. The core itself is one function that never returns between instructions: pc and the control frame pointer are register-pinned, and INSN_DISPATCH is a computed-goto over labels generated from insns.def. The fallback call-threaded variant, a plain switch loop, exists in the same file (vm_exec.c, L122).

Frames are just slices of one contiguous VM stack. The struct at rb_control_frame_t, L956 carries pc, sp, the iseq pointer, self, and the environment pointer ep — seven fields, no allocation per call. That is why Ruby frame pushes are cheap and why caller walks the stack instead of a heap list. The instruction definitions themselves show how far the economy goes: putobject has a body that is literally an empty comment block, because its operand — the immediate value to push — is stored inside the encoded instruction stream and the VM’s register conventions do the rest. Branch instructions double as safe points: branchif runs RUBY_VM_CHECK_INTS (insns.def, L1270) so the interpreter can honor a signal or a timer between jumps without a dedicated polling instruction. Anything that might allocate, raise, or block is marked non-leaf in the same definitions, which is how the loop stays interruptible without checking on every single opcode.

The call cache: method calls that remember

Every foo.bar compiles to a send instruction carrying a CALL_DATA — an immutable rb_callinfo (name, flags, arity) plus a mutable rb_callcache slot. The cache entry records the receiver class, the resolved method entry, and a C call handler. At runtime vm_sendish calls vm_search_method_fastpath: if vm_cc_hit_p sees the same class and the method entry was not invalidated, the cached handler is tail-called directly — no method table touch at all.

On a miss, vm_populate_cc takes the VM lock, resolves the method through rb_callable_method_entry, and stores a fresh cache entry with vm_cc_new (vm_insnhelper.c, L2151) into a per-class table; under OPT_INLINE_METHOD_CACHE the result is also written back into the call site itself (vm_insnhelper.c, L2318). The per-class layer has its own lock-free fast read (vm_method.c, L2151) and even caches failures — a method that does not exist becomes a negative_cme — which is why method_missing misses stay cheap. Redefining a method flips invalidation bits through vm_cme_invalidate, and vm_call_method (vm_insnhelper.c, L5128) then re-specializes the handler per method type in vm_call_method_each_type — ISEQ, CFUNC, IVAR, BMETHOD, ZSUPER, REFINED. This is the warm-up tax Rails pays at boot so steady-state requests do not; other dynamic frameworks re-resolve lookups differently, but Ruby’s cache is inline in the bytecode stream itself.

GC and concurrency: pages, steps, switches

GC.start is, remarkably, Ruby code: gc.rb, L65 forwards to Primitive.gc_start_internal (gc.c, L4387), which dispatches through a modular function table (rb_gc_function_map_t, L766) to the default engine in gc/default/default.c. The engine grows the heap in fixed pages (heap_page_allocate, L3003) carved into slots sized by RVALUE_SLOT_SIZE — the old one-union-fits-all RVALUE layout is gone; slot geometry is now explicit, and allocation walks a per-page free list rather than picking objects out of one giant union. Marking runs incrementally with a per-cycle budget (gc_marks_step, L8134), spreading gc_mark_children work across short pauses, and sweeping is lazy (gc_sweep_step, L5871), amortized over later allocations. The practical consequence is the one every Rails operator knows from profiling: stop-the-world pauses are not the norm, because a mark step can end mid-cycle and resume after the mutator runs more instructions.

Threads and fibers share the safe-point story. Thread.new reaches rb_thread_create and starts in thread_start_func_2; switching happens in rb_thread_schedule, where rb_thread_schedule_limits yields the current thread once running_time_us exceeds its slice (thread.c, L1682), saving machine context and handing off through the per-Ractor scheduler. Fibers are cheaper: fiber_switch swaps stack contexts, entered via rb_fiber_transfer, and a first resume starts the body in rb_fiber_start. If you run Ruby workloads on Redis or Postgres-backed services, this is the layer your async drivers ride on.

From Install to Your First Model

Everything above can be reproduced from a checkout. Per the repo’s own docs: clone with git clone https://github.com/ruby/ruby.git (README.md, L43), generate the build system with ./autogen.sh (building_ruby.md, L94), configure into a prefix such as ../configure --prefix="${HOME}/.rubies/ruby-master" (building_ruby.md, L115), then make (building_ruby.md, L124) and make install (building_ruby.md, L132). The tree’s own test suite runs with make test-all -j8 (building_ruby.md, L201). From there, ruby -e 'puts RubyVM::InstructionSequence.compile("1+1").disasm' shows you the same bytecode vm_exec_core executes, and --parser=parse.y lets you diff both frontends’ output. Building your first model on top — a Rack app, an ORM class — then exercises exactly the dispatch and GC paths diagrammed above, the same ones your Rails app hits in production.

Honest Limits

  • This tour describes the interpreter path only. YJIT and ZJIT live in the same tree (yjit.c, zjit.c) and hook the same call sites we visited, but we verified none of their codegen internals here.
  • Line numbers are pinned to master at commit c0013b358f59; Ruby moves fast, and symbols like vm_sendish have been reshaped repeatedly in recent years.
  • The GC engine is pluggable in-tree: besides gc/default/default.c, the repo carries an mmtk binding. We covered the default engine only, and no performance numbers in this post are benchmarks — they are structural facts from the source.
  • We did not verify Windows-specific scheduling, coroutine assembly per platform (coroutine/), or Ractor semantics beyond the scheduler hand-off visible in thread.c.
Watch PyShine on YouTube

Contents