Open-Source Wikis

/

Ruby

/

Systems

/

Bytecode compiler

ruby/ruby

Bytecode compiler

compile.c walks an AST and emits a packed iseq (instruction sequence). It's the bridge between either parser and the VM. At ~14,800 lines, it's the largest hand-edited C file in the tree and the place where most new language features land.

Purpose

Translate an in-memory AST into:

  1. A flat array of bytecode opcodes and operands (the actual bytecode).
  2. A constant pool referenced by index.
  3. A line-number table for backtraces.
  4. A catch table for rescue/ensure/retry/break/redo/next targets.
  5. Metadata: argument descriptor, local variable table, parent iseq, optimization level.

Files

File Purpose
compile.c The compiler proper. Walks NODE trees from parse.y.
prism_compile.c The Prism compiler. Walks pm_node_t trees.
prism_compile.h Shared declarations between both compilers.
iseq.c iseq allocation, marshaling, disassembly, GC integration.
iseq.h iseq layout. The rb_iseq_t struct.
insns.def Bytecode instruction definitions.
vm_core.h The runtime types (rb_iseq_t, rb_vm_t, callinfo, callcache).
vm_callinfo.h Inline cache, callinfo, and callcache structs.
tool/ruby_vm/ Codegen for vm_exec.c, dispatch table, JIT helpers.
tool/instruction.rb Loader for insns.def.

Compilation pipeline

graph LR
    ast[AST] -->|enter scope| optimize[NODE optimizations\ncompile_optimized_call]
    optimize -->|tree walk| emit[Emit ADD_INSN]
    emit -->|build linked list| linked[ANCHOR list]
    linked -->|peephole| peep[iseq_peephole_optimize]
    peep -->|fix labels| labels[Resolve jump targets]
    labels -->|finalize| iseq[rb_iseq_t]

Compilation happens in three phases:

1. Tree walk (iseq_compile_each)

iseq_compile_each is a giant switch on nd_type(node) that recursively emits bytecode for each AST node into a doubly-linked list of LINK_ANCHOR entries. Each entry is either an instruction (INSN) or a label (LABEL).

Many node types have their own helpers — compile_call, compile_iter, compile_match, etc. The compiler tracks:

  • The current scope (ISEQ_BODY(iseq)->local_table, ISEQ_BODY(iseq)->local_table_size).
  • Whether the current node's value is "popped" or "used".
  • The current rescue/ensure stack for catch-table generation.

2. Peephole optimization (iseq_optimize)

The linked list goes through several optimization passes:

  • Constant folding: putobject 1; putobject 2; opt_plusputobject 3.
  • Tail-call optimization: under --enable-tailcall.
  • Specialized instructions: opt_*_special variants for common patterns (+, -, <, ==, [], etc.) that avoid full method dispatch when the receiver is Integer, Float, String, or Array.
  • Dead-code elimination: branches that can be statically resolved.
  • Stack caching: the --enable-stack-caching build option enables an alternate dispatch where the top of the stack is always in registers.

The flags controlling each optimization are in rb_compile_option_t (iseq.h).

3. Finalization

After optimizations, the compiler:

  • Assigns numeric offsets to labels.
  • Builds the catch table from collected rescue/ensure entries.
  • Builds the line-number table from collected source locations.
  • Allocates the final iseq->body->iseq_encoded array and copies the linked list into it.
  • Computes operand types per instruction (for the GC scanner).

The result is an rb_iseq_t whose body field has fields like:

struct rb_iseq_constant_body {
    enum rb_iseq_type type;          /* :method, :block, :class, :top, :rescue, :ensure, ... */
    unsigned int iseq_size;
    VALUE *iseq_encoded;             /* the bytecode */
    struct rb_iseq_param_keyword *param_keyword;
    struct rb_iseq_constant_body::iseq_insn_info insn_info;
    rb_id_table_t *local_table;
    /* + many more fields */
};

insns.def — the instruction set

insns.def is a custom DSL describing every VM instruction. A typical entry:

DEFINE_INSN
opt_plus
(CALL_DATA cd)
(VALUE recv, VALUE obj)
(VALUE val)
{
    val = vm_opt_plus(recv, obj);
    if (UNDEF_P(val)) {
        CALL_SIMPLE_METHOD();
    }
}

The four parts are:

  • Operands: pulled from the bytecode stream (e.g., a CALL_DATA* for an inline cache).
  • Stack inputs: popped before the instruction body runs.
  • Stack outputs: pushed after.
  • Body: C code that runs the instruction.

tool/ruby_vm/ parses insns.def and emits:

  • vm_exec.c — the dispatch loop. Two flavours: switch-based and threaded.
  • vmtc.inc — the threaded-code label table.
  • optunifs.inc, opt_sc.inc — optimizer support files.
  • JIT entry points and decompilers.

A few example instructions:

Opcode Purpose
putobject Push a literal value
putstring Push a frozen String
getlocal/setlocal Local variable read/write
getinstancevariable/setinstancevariable IV read/write (shape-aware)
opt_send_without_block Method call with an inline cache
opt_plus/opt_minus/opt_lt/opt_eq Specialized arithmetic and comparison
branchif/branchunless Conditional jump
jump Unconditional jump
leave Return from current frame
throw Raise an exception (or break/return)
defineclass Open or define a class

The full list and semantics live in insns.def.

Inline caches

Method dispatch (opt_send_without_block and friends) consults a per-call-site rb_callcache (vm_callinfo.h). The cache stores:

  • The class of the last-seen receiver.
  • The resolved rb_method_entry_t.
  • A hit counter used by JITs.

If the cache hits, dispatch is roughly a class compare + indirect call. On miss, the VM walks the method table (vm_method.c) and updates the cache.

Constant lookups (getconstant) and instance variable accesses (getinstancevariable) have similar caches, keyed on the constant table version and the object shape respectively.

Shapes

shape.c / shape.h implement Object Shapes — small immutable transition records that describe an object's instance variables. The compiler emits IV access using shape-aware instructions; the VM looks up an IV by (shape_id, ivar_index) instead of by name. This makes IV access a couple of indirected loads in the common case.

The basic flow:

  1. New object starts with shape 0 (root).
  2. First obj.@x = ... transitions to a new shape "root → x".
  3. Future obj.@x accesses on objects with the same shape skip the lookup.

Selected optimizations

compile.c implements several non-obvious optimizations:

  • Inline definitions: lambda { |x| x + 1 } body is compiled into the parent iseq's pool and linked, not relegated to a separate dynamic alloc.
  • Specialized literals: [1, 2, 3] bypasses general Array.new and uses duparray.
  • Frozen string literals: with # frozen_string_literal: true, all "..." in the file lower to putobject of a frozen String.
  • Speculative inlining: methods named in tool/instruction.rb's "always inlined" list (Integer#+, Array#[], etc.) get specialized opcodes when the receiver type matches.

Disassembling

puts RubyVM::InstructionSequence.compile("a = 1; a + 2").disasm

Produces:

== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,12)> (catch: false)
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0
0000 putobject_INT2FIX_1_                                              (   1)[Li]
0001 setlocal_WC_0                          a@0
0003 getlocal_WC_0                          a@0
0005 putobject                              2
0007 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0009 leave

./ruby --dump=insns_without_opt -e 'expr' produces unoptimized output, useful when debugging the optimizer.

Entry points for modification

  • Add a new instruction: edit insns.def, run make srcs, update compile.c to emit it, update vm_exec.c (regenerated). Both YJIT (yjit/src/codegen.rs) and ZJIT (zjit/src/) need handling.
  • Fix a miscompile: the offending node type's compile function in compile.c is the place to look. --dump=parsetree and --dump=insns_without_opt are your friends.
  • Add an optimization: extend iseq_peephole_optimize or add a new pass invoked from iseq_setup.
  • Prism parity: every change to compile.c must be mirrored in prism_compile.c (or a justification given).

See vm.md for what runs the iseqs and jits/index.md for how YJIT/ZJIT consume them.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Bytecode compiler – Ruby wiki | Factory