Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

monoruby

monoruby is a Ruby implementation written from scratch in Rust, featuring a register-based bytecode VM and a just-in-time (JIT) compiler for x86-64 and aarch64 (Apple Silicon). It is fast — comparable to CRuby with YJIT/ZJIT on many benchmarks — and has no dependency on any other Ruby runtime.

This site documents monoruby’s internals.

  • The Architecture section contains overview pages: start with the Architecture Overview.
  • The Design Documents section renders the full design documents from the repository’s doc/ directory (some are written in Japanese, as marked).

Architecture Overview

monoruby is a Ruby implementation written from scratch in Rust, featuring a register-based bytecode VM and a just-in-time (JIT) compiler. It has no dependency on CRuby or any other Ruby runtime. This page gives a bird’s-eye view of the system; each section links to a dedicated chapter and to the detailed design documents (rendered in the “Design Documents” section of this book, sourced from the repository’s doc/ directory).

Compilation pipeline

Ruby source
    │
    ▼
prism (ruby-prism)      prism syntax tree — the official Ruby parser
    │
    ▼
parser/ + ast/          monoruby AST
    │
    ▼
bytecodegen/            register-based bytecode
    │
    ▼
Executor (VM)           interpreted execution, machine-code VM tier
    │  when hot (≥20 calls / ≥100 loop iterations)
    ▼
JIT: TraceIR            type-annotated IR built from inline-cache feedback
    │
    ▼
JIT: AsmIR              register-allocated, arch-neutral assembly IR
    │
    ▼
codegen/arch/<arch>     AsmIR → machine code (x86-64 / aarch64 backends)
    │
    ▼
monoasm                 self-made dynamic assembler
    │
    ▼
Native machine code

Ruby source is parsed by prism (consumed as the ruby-prism crate) and converted into monoruby’s own AST. The AST is compiled into register-based bytecode, which the VM executes. Hot methods (≥ 20 calls) and hot loops (≥ 100 iterations) are handed to the JIT, which uses runtime type feedback to produce specialized machine code, falling back to the VM through deoptimization when its assumptions are invalidated. See JIT Compiler for details.

Execution tiers

  • VM tier — the bytecode executor. Its dispatch loop and operation handlers are themselves emitted as machine code through monoasm (per target architecture), rather than being a Rust match loop.
  • JIT tier — specialized machine code per method / loop, guarded by type and class-version checks. Both x86-64 and aarch64 lower the full instruction set; see aarch64 Backend.

Major subsystems

SubsystemChapterDesign documents
Value representation (64-bit tagged union)Value Representation
JIT compiler (TraceIR / AsmIR / register allocation)JIT Compilerjit.md, lir.md, regalloc_separation.md
Garbage collection (generational mark-and-sweep)Garbage Collectiongc.md
Green threads and fibersThreads and Fibersthreads.md
Stack frames and method callsStack Frames and Method Callsstack_frame.md, method_args.md
Exception handlingException Handlingexception_handling.md
aarch64 (Apple Silicon) backendaarch64 Backendarch_difference.md

Source layout

monoruby/                   workspace root
├── monoruby/src/
│   ├── parser/, ast/       prism → monoruby-AST bridge, AST definitions
│   ├── bytecodegen/        AST → register-based bytecode
│   ├── executor/           bytecode interpreter (VM), frames, operator dispatch
│   ├── codegen/            JIT compiler
│   │   ├── jitgen/         bytecode → TraceIR → AsmIR (arch-neutral front-end)
│   │   └── arch/           per-arch backends: x86_64/ and aarch64/
│   ├── value.rs, value/    Value type and heap objects (RValue)
│   ├── alloc.rs            garbage collector
│   ├── globals/            global interpreter state, function/class tables
│   └── builtins/           built-in Ruby classes implemented in Rust
├── monoruby/builtins/      built-in library code written in Ruby
├── monoruby_attr/          proc macros (#[monoruby_builtin], …)
├── rubymap/, hashbrown/    order-preserving hash map for Ruby Hash
└── doc/                    detailed design documents

Key global registers (JIT / VM tier)

On x86-64, JIT-compiled code keeps interpreter state in fixed registers (the aarch64 backend uses an equivalent fixed assignment):

RegisterHolds
rbx&mut Executor
r12&mut Globals
r13program counter
r14local frame pointer (LFP)

Further reading

Value Representation

A monoruby Value is a 64-bit non-zero integer (NonZeroU64) using a tagged-union scheme: the lower 3 bits encode the kind of value. It is not NaN-boxing. Because Value is always a single machine word, values can live directly in VM registers, JIT machine registers, and GC-scanned stack slots.

Dispatch on the lower 3 bits

Lower bits (& 0b111)Kind
???????1 (bit 0 = 1)Fixnum — integer stored in bits 63:1 as i63 (value >> 1)
??????10 (bits 1:0 = 10)Flonum — double-precision float encoded inline (bit-rotated)
?????000 (bits 2:0 = 000)Heap pointer — raw pointer to a GC-managed RValue
other (bit 2 = 1, bits 1:0 ≠ 10)Other immediatenil / true / false / Symbol

is_packed_value() tests bits & 0b0111 != 0; if true, the value is an immediate and try_rvalue() returns None. If false, the bits are a valid *const RValue pointer (RValues are 8-byte aligned, so their low 3 bits are always zero).

Immediate tag constants

ConstantHexBinaryMeaning
NIL_VALUE0x040000_0100nil
FALSE_VALUE0x140001_0100false
TRUE_VALUE0x1c0001_1100true
TAG_SYMBOL0x0c0000_1100Symbol (IdentId packed in the upper 32 bits)

FLOAT_ZERO ((0b1000 << 60) | 0b10) is the flonum encoding of 0.0.

Consequences

  • Fixnum covers 63-bit signed integers. Integer results that overflow i63 are promoted to heap-allocated Bignum objects (backed by BigInt).
  • Flonum covers most doubles; floats whose exponent falls outside the encodable range are heap-allocated as RValues of class Float.
  • nil / false are the only falsy values, and both have bit patterns distinguishable with a single mask — which the JIT exploits for cheap truthiness tests.
  • Equality on immediates (Fixnum, Symbol, nil, true, false) is plain 64-bit comparison.

Heap values: RValue

Everything that is not an immediate lives on the GC heap as an RValue (defined under monoruby/src/value/rvalue/): Strings, Arrays, Hashes, objects with instance variables, Bignums, non-flonum Floats, Ranges, Procs, Fibers, and so on. RValues are allocated from the GC’s page-based arena and carry the object’s class, flags (including the generational-GC age bits), and kind-specific payload. See Garbage Collection.

Relevant source

JIT Compiler

monoruby executes bytecode in the VM until code gets hot, then compiles it to specialized machine code. This page is an overview; the detailed documents are doc/jit.md (stub/bridge code), doc/lir.md (the low-level IR), doc/regalloc_separation.md (register allocation), and doc/inline.md (inline builtins).

When compilation triggers

  • Method JIT — after ≥ 20 calls (COUNT_START_COMPILE; 5 in test mode)
  • Loop JIT — after ≥ 100 iterations of a loop (COUNT_LOOP_START_COMPILE; 15 in test mode), compiling the enclosing method from the loop entry

Each function starts with a small wrapper that decrements a counter and falls through to the VM until the counter expires, then triggers compilation and patches itself.

IR pipeline

bytecode ──abstract interpretation──▶ TraceIR ──▶ AsmIR ──▶ LIR ──▶ machine code (monoasm)
                (type feedback from inline caches)        (per-arch encoder)
  • TraceIR — bytecode annotated with type information gathered from the VM tier’s inline caches.
  • AsmIR (AsmInst) — arch-neutral, register-allocated assembly IR produced by an abstract interpreter that tracks, per slot, whether a value lives on the stack, in a floating-point register (unboxed f64), both, or is a compile-time constant (LinkMode).
  • LIR (LInst) — arch-neutral machine-level ops with offsets and labels resolved; the single seam where bytes are emitted. Each architecture implements one encode_linst (see aarch64 Backend).

Specialization and inline caches

Compiled code is specialized per receiver class. The method entry is a chain of self-class guard stubs: each guard tests the receiver’s class and jumps to the machine code compiled for that class; a miss falls through to the next guard or to the VM. Method calls inside JIT code are resolved through inline caches and guarded by a class-version check, so redefining a method invalidates dependent code. Small hot builtins (Array#[], Integer arithmetic, Math.sqrt, Object#is_a?, Struct accessors, Class#new, Fiber.yield, …) are inlined directly by generator functions that can both consult the abstract state (folding results at compile time when types are proven) and emit code; a failed inline attempt rolls back cleanly and falls back to a normal call. Monomorphic methods can additionally be specialized inline — the callee’s frame is inlined into the caller.

Deoptimization and recompilation

JIT code is speculative. Guards — receiver class, class version, array type, frozen state, fixnum overflow, basic-operator (BOP) redefinition, frame capture — branch to side exits that write register-resident values back to the frame and resume in the VM at the equivalent program point. Repeated deopts trigger recompilation with the newly observed classes (e.g. polymorphic call sites, method_missing dispatch, newly resolved constants/ivars). Deopt logging is available with the deopt Cargo feature, recompile/deopt statistics with profile.

Register allocation

  • Floating point — virtual FP registers (VirtFPReg) allocated greedily over the physical pool (14 xmm registers on x86-64), with automatic spill-to-stack when the pool is exhausted; loop entries specialize float-typed slots so hot numeric loops keep values unboxed in registers.
  • General purpose — a per-basic-block local GP register allocator keeps boxed values (notably Fixnums) in a small pool of scratch registers within a block, eliding redundant fixnum guards, and flushes the pool at calls and GC safepoints (pool registers are not GC roots).

The long-form design discussion — separating type inference from placement, the retirement of the dedicated accumulator register, and measured results — is in doc/regalloc_separation.md and doc/lir.md.

Argument forwarding (D1)

def f(...) forwarding is compiled as an opaque pipe: for simple callees the rest-Array / keyword-Hash allocation is elided entirely and arguments are copied (or lazily deferred) straight from the caller’s frame, with deopt-safe lazy materialization if a side exit or frame capture ever needs the real objects. See doc/arg_forwarding_jit.md.

Observing the JIT

Cargo featureOutput
dump-bc / emit-bcbytecode
dump-traceirTraceIR
emit-asmgenerated assembly
jit-log / jit-debugcompilation events / detailed debug
deoptdeoptimization log
profiledeopt & recompile statistics
perfperf-compatible symbol maps

The JIT is always built in; disable it at runtime with --no-jit. See Build options for performance tuning for example output.

Garbage Collection

monoruby has its own garbage collector: a non-moving, single-threaded, stop-the-world, generational mark-and-sweep collector, modeled on CRuby’s RGenGC. This page is an overview; the full design document is doc/gc.md, and the implementation lives in monoruby/src/alloc.rs.

Heap layout

  • All heap objects (RValue) are exactly 64 bytes. Memory comes from a single 2 GB virtual arena reserved up front, carved into 256 KB pages of 4032 cells each.
  • Mark bits and old bits are stored outside the object cells, as per-page bitmaps. A pointer’s page is found with a single address mask, so bitmap lookup is O(1).
  • Objects never move, so raw *const RValue pointers stay valid across collections.
  • Allocation pops from a free list when possible, otherwise bump-allocates in the current page. The JIT inlines this free-list fast path directly into compiled code.

Generational collection

Minor collections trace only young objects; old objects are assumed live and their mark bits are seeded from the old bitmap.

  • Objects of promotable types (Object, String, Array, Hash, Bignum, Float, Struct) age by one on each minor GC they survive; at age 3 they are promoted to the old generation.
  • Write barrier: when a reference is stored into an old object, a single header-bit test decides whether the object must enter the remembered set, whose old→young edges are traced during minor GCs. The JIT emits the barrier inline; bulk operations (Array#concat, …) use a bulk variant.
  • A major (full) collection runs when the old-object count crosses an adaptive threshold or after 64 consecutive minors; it clears all generation state and retraces everything. GC.start always forces a major.

The write-barrier / remembered-set interaction (including why a minor GC without the barrier would sweep live objects) is illustrated in doc/gc_write_barrier.svg.

GC triggers and safepoints

Collections are requested by setting a single per-thread alloc_flag, and performed only at safepoints:

  • Allocation pressure — every ~8 filled pages trips the flag.
  • malloc pressure — a custom #[global_allocator] tracks off-heap allocation (String/Array backing stores etc.) and requests a GC when it outgrows an adaptive threshold, so heavy malloc traffic can’t outrun the heap-cell trigger.
  • ExplicitGC.start.

Safepoint polls (compare alloc_flag; conditionally call gc) are emitted at callee entry and loop back-edges in both the VM tier and JIT code. The same poll also drives green-thread preemption and pending-signal delivery (see Threads and Fibers). Rooting is precise: roots are explicitly enumerated — the executor’s frame chain, temporary-value stack, the green-thread scheduler’s thread registry, pending exceptions, and global state — never conservatively scanned off the machine stack; JIT code spills live registers before a safepoint call.

Controlling the GC

ControlEffect
GC.startForce a full (major) collection
GC.enable / GC.disableToggle collection at runtime
--no-gc CLI flagDisable GC for the process
GC.count / GC.statCollection counters / CRuby-compatible stats

Debugging Cargo features: gc-log (stats at exit), gc-debug (assertions), gc-stress (collect on every allocation — used by bin/test in CI), gc-verify (independent re-mark verification after each minor GC).

Further reading

  • doc/gc.md — full design document (heap layout, bitmaps, aging/promotion, remembered-set self-cleaning, heap-escaped frame reclamation)
  • doc/safepoint.md — the safepoint / poll-flag mechanism shared by GC, preemption, and signals

Threads and Fibers

monoruby implements M:1 green threads: all Ruby Threads are multiplexed onto a single OS thread that runs the VM. There is no parallel Ruby execution (and no GVL — there is simply one VM-running OS thread); short-lived helper OS threads exist only for blocking-syscall offload and the preemption timer, and they never touch the Ruby heap. This page is an overview; the full design document is doc/threads.md.

Scheduler

The scheduler (monoruby/src/scheduler.rs) is a per-OS-thread singleton. Its event loop runs on the main thread’s stack: main enters it as an ordinary function call when it parks, and returns from it when main becomes runnable again; green threads never call the loop themselves — they switch into its saved context. The scheduler tracks live threads (a GC root), a FIFO run queue, sleepers with deadlines, and fd waiters. When idle it poll(2)s the waited fds or sleeps to the nearest deadline; if no thread can ever run again it raises a fatal deadlock error.

Context switching reuses the Fiber stack-switching machinery (rsp exchange): each thread gets its own 256 KiB stack with a guard page and its own Executor.

Cooperative and preemptive switching

Switching is hybrid:

  • Cooperative — at blocking points: sleep, Thread.stop, #join, Thread.pass, blocking IO, and synchronization-primitive waits.
  • Preemptive — a dedicated timer OS thread ticks every 10 ms (spawned only while ≥ 2 threads are live) and arms the shared poll flag, which acts as if the running thread called Thread.pass at its next safepoint. MONORUBY_NO_PREEMPT=1 disables it; MONORUBY_PREEMPT_STRESS=1 switches at every poll site.

Both kinds of switch happen only at VM safepoints — the same callee-entry / loop-back-edge polls used by the GC (see Garbage Collection and doc/safepoint.md) — so a suspended thread’s frames are always in a GC-complete state. A consequence: Rust builtins are atomic with respect to other threads (like C functions under CRuby’s GVL), but sequences of pure-Ruby statements can interleave, so Ruby code must use locks for compound state transitions.

Blocking IO

Blocking-IO builtins go through a common wrapper that checks buffered data, probes readiness with a zero-timeout poll, and otherwise parks the thread on the scheduler’s fd poller instead of blocking the process. While other threads are live, fds are temporarily set to non-blocking so that a mid-operation would-block parks and resumes without data loss. IO.select, non-blocking TCP connect, and accept retry-loops are integrated with the same poller. The only truly blocking syscalls (flock, FIFO open) are offloaded to short-lived native helper threads that signal completion through a self-pipe registered with the poller.

Synchronization primitives and interrupts

Mutex, Queue, SizedQueue, and ConditionVariable are implemented in Ruby (in builtins/startup.rb), relying on safepoint-free straight-line test-and-set plus a park permit mechanism that closes the classic lost-wakeup race. Locks abandoned by a dead thread are reclaimed by the next acquirer, and Mutex#owned? is per-Fiber.

Thread#kill / #raise are queued and delivered by the scheduler: a parked target is woken and unwinds from its exact blocking point (running ensure blocks); a running target is caught by preemption at its next safepoint, so even busy loops are killable. Thread.handle_interrupt masking is honored at mask boundaries.

Fibers

Threads are built on Fiber’s stack switching, but the two remain distinct: Fibers form an asymmetric resume/yield chain within a thread, while the scheduler schedules threads only. A green thread may park while deep inside a nested Fiber and be resumed exactly there. Signal handling uses the same deferred safepoint model — see doc/signal.md.

State diagrams

Further reading

Stack Frames and Method Calls

This page describes how monoruby lays out call frames and processes method arguments. Details: doc/stack_frame.md, doc/method_args.md, doc/cref.md, doc/super_resolution.md.

Frame layout

Each Ruby-level call pushes three contiguous regions on the native stack (growing downward):

  • Continuation frame — the caller’s saved lfp, pc, return address, and rbp. The saved call-site pc is also what powers lazy backtraces, Kernel#caller, and super resolution.
  • Control frame (CFP)prev cfp and lfp; the executor’s cfp chain links all active frames.
  • Local frame (LFP) — the Ruby-visible part: outer (for blocks: the enclosing frame), meta, block, self, then the argument/local slots arg0, arg1, ….

The bytecode interpreter and JIT code share a fixed register ABI on x86-64 (the aarch64 backend uses an equivalent assignment): rbx = &mut Executor, r12 = &mut Globals, r13 = pc, r14 = lfp.

Frames captured by blocks, Procs, or Bindings are promoted to the heap lazily — only when the capture actually escapes — and heap frames are reclaimed by the GC once unreachable.

Argument processing

Formal parameters occupy frame slots in a fixed order: required | optional | rest | keyword | block | destructured-children. At call time the caller copies positional arguments into the callee frame, expanding splats, gathering overflow into rest, filling missing slots with nil/none-markers, and checking arity. Keyword arguments are then assigned by name, with surplus keywords gathered into the keyword-rest slot; if the callee accepts no keywords at all, trailing keywords are packed into a Hash and passed as one extra positional argument. Blocks additionally auto-splat a single Array argument when they take multiple parameters. The callee prologue (InitMethod) then links the frame, homes arguments, nil-fills the remaining slots — and runs the safepoint poll. Destructuring and optional-parameter default initializers are compiled as ordinary bytecode at the top of the method body.

Built-in (native) methods declare their arity and keyword names at registration time (e.g. define_builtin_func_with_kw(..., min, max, rest, kw)), and receive their arguments in the same fixed slot order via Lfp. The #[monoruby_builtin] proc macro wraps a Rust fn(vm, globals, lfp) -> Result<Value> into the VM’s calling convention, converting errors into the VM’s error protocol (vm.set_error).

Lexical scope (CREF)

Where def, constant lookup, and visibility land is decided by monoruby’s CREF model — a compact 16-byte Cref struct kept on a VM-wide stack, distinguishing the definition context (used by def) from the lexical context (used by module/class nesting and unqualified constants). This is monoruby’s counterpart to CRuby’s per-frame rb_cref_t chain; doc/cref.md contrasts the two models in depth, including how class_eval / instance_eval / Kernel#eval push their scopes.

super resolution

CRuby frames carry a callable-method-entry that tells super both the method name to search and where in the ancestor chain to continue. monoruby frames carry only a FuncId, so super reconstructs that information from the caller pc saved in the continuation frame: it recovers the call site’s opcode and call-site info, derives the originally-called name (correct across alias and multi-name define_method), and counts how many times the running body occurs in the ancestor chain so a method aliased into several places supers past the right occurrence. See doc/super_resolution.md.

Further reading

Exception Handling

monoruby’s exception machinery is built around laziness: at raise time it stores the minimum needed to unwind, and defers every expensive step — building the Ruby exception object, walking callers, formatting strings — until (and unless) something actually asks for it. This page is an overview; the full design document, including a detailed contrast with CRuby, is doc/exception_handling.md.

In-flight errors

While an exception is propagating it is not a Ruby object but a Rust struct, MonorubyErr, stored in the executor. Its kind covers both real exceptions (TypeError, NameError, ArgumentError, …, plus Other(ClassId) for user classes) and control-flow pseudo-exceptions: MethodReturn (non-local return), BlockBreak, Throw (Kernel#throw), Retry, and Redo. Both families share one unwinder, mirroring how CRuby routes break/return through its THROW_DATA tags.

Unwinding

The unwinder runs once per frame. For each interpreted frame it:

  1. Dispatches control-flow kinds first — return/break/throw/retry/redo are resolved before any backtrace capture, so non-local control flow never pays for a backtrace or allocates an exception object.
  2. Records the frame’s source location into the incremental trace (frames are being destroyed, so this is the only chance).
  3. Consults the function’s exception table for the innermost region covering the current pc, yielding a rescue target, an ensure target, and the slot for the error value. Rescue jumps materialize the exception object and set $!; ensure jumps defer the in-flight unwind, resuming it when the ensure body finishes (a new exception raised inside ensure supersedes the deferred one).
  4. Otherwise returns the error to the caller frame and repeats.

$! is scoped per rescue region: its previous value is saved on region entry and restored on exit, so nested and non-local exits observe CRuby’s semantics.

Lazy backtraces

Backtrace cost is split into three deferred stages: frames between raise and rescue are captured incrementally during unwinding; frames above the rescuer are filled in only at the catch point (the last moment the stack is coherent), by walking each caller’s saved call-site pc — the same mechanism behind Kernel#caller; and formatting into strings happens only when Exception#backtrace is first called, then is memoized. CRuby, by contrast, captures the full backtrace eagerly at raise time. The practical result: rescue-based control flow in hot code is far cheaper than in CRuby, and StopIteration under Kernel#loop is caught at the Rust level with near-zero cost.

Exception objects

The Ruby exception object is created only at a catch point or at top level. Re-raising an exception object preserves its identity; implicit cause chaining from $! and the explicit raise ..., cause: keyword follow CRuby’s rules. Class-specific payloads (LoadError#path, SystemExit#status, NoMethodError#name/#receiver, …) ride along as hidden instance variables.

Native builtins participate through a simple protocol: a builtin returns a Result<Value>, and on error the #[monoruby_builtin] wrapper stores the MonorubyErr in the executor and returns a sentinel that routes into the same unwinder.

Further reading

  • doc/exception_handling.md — full mechanism, exception-table format, $! restoration, ensure deferral, and the CRuby (catch_table / THROW_DATA) comparison

aarch64 Backend

monoruby runs natively on aarch64 — macOS on Apple Silicon is fully supported (VM tier + JIT), and CI runs on GitHub’s Apple Silicon runners. The aarch64 backend was ported in mid-2026 and lowers the complete instruction set: it never declines a compilation. This page is an overview; the detailed comparison is doc/arch_difference.md.

How the backends are organized

codegen/jitgen/                 arch-neutral front-end: bytecode → TraceIR → AsmIR
codegen/jitgen/asmir/           arch-neutral lowering dispatcher (compile_asmir → LIR)
codegen/arch/x86_64/            x86-64 backend: VM tier, invokers, wrappers, encode_linst
codegen/arch/aarch64/           aarch64 backend: same structure, mirrored file layout

Everything above machine-code emission is shared. The arch-neutral dispatcher lowers each AsmInst either through common code paths built on small per-arch emission primitives (emit_reg_move, emit_guard_class, emit_integer_binop, …) or into arch-neutral LIR, which each architecture encodes with its own encode_linst (selected by cfg(target_arch), no dynamic dispatch). Machine code is emitted with the monoasm dynamic assembler, which provides both monoasm! (x86-64) and monoasm_arm64! DSLs.

Full coverage — no bail

Historically the aarch64 port could “bail” (fall back to the VM) on instructions it didn’t yet support — almost always because an offset didn’t fit AArch64’s 12-bit immediate encodings. Today every AsmInst and every side exit is lowered: displacements that fit are folded into ldur/stur/scaled ldr/str, and larger frame/field/sp offsets are materialized through reserved scratch registers (x9/x10). The bool “decline” return still present in some lowering signatures is vestigial.

Register mapping

Rolex86-64aarch64
&mut Executorrbxx19
&mut Globalsr12x20
Program counterr13x21
Local frame pointer (LFP)r14x22
(former accumulator slot)r15x23
Scratch for lowering tempsx9x15

The C-call ABIs differ (arguments in rdi/rsi/rdx/… vs x0..x7), so call-argument lowering shuffles into the ABI registers explicitly rather than using a 1:1 map.

Remaining differences

Correctness and instruction coverage are identical across the two backends; the few remaining asymmetries only affect transition costs around recompilation (e.g. how a class-version guard miss recovers: x86-64 patches and recompiles in place in some paths where aarch64 deopts and re-JITs via warm-up counters). These are catalogued, with rationale, in doc/arch_difference.md.

Building and testing on aarch64

  • On Apple Silicon macOS, a normal cargo build produces a native binary (Homebrew libffi + pkg-config are required — see the target-specific dependency block in monoruby/Cargo.toml).
  • From an x86-64 Linux host, bin/setup-aarch64-cross sets up a cross toolchain and bin/test-aarch64 runs the test suite under emulation.
  • CI runs the full test scope natively on macos-latest (Apple Silicon) for every push and pull request.

Stub code for JIT’ed code generated by compiler

before compilation


      wrapper
   +---------------------------------+
   |                                 |
---+-> entry:                        |
   |       jmp [next];               |
   |   next:                         |
   |       subl [rip + counter], 1;  |
   |       jne vm_entry;             |
   |       <exec_compile_and_patch>  |
   |       jmp entry;                |
   |                                 |
   +---------------------------------+

after compilation for self_class1


      wrapper                                class guard stub (1)
   +---------------------------------+     +--------------------------------------------+
   |                                 |     |                                            |
---+-> entry:                        |  +--+-> guard1:                                  |
   |       jmp [guard1]; ------------+-/   |       movq rdi, [r14 - (LFP_SELF)];        |
   |   next:                         |     |       class_guard(self_class1)  -----------+-----> exit
   |       subl [rip + counter], 1;  |     |   patch_point:                             |
   |       jne vm_entry;             |     |       jmp [jit_entry1];                    |
   |       <exec_compile_and_patch>  |     |         |                                  |
   |       jmp entry;                |     +---------+----------------------------------+
   |                                 |               |
   +---------------------------------+               |
                                                     |      JIT code for self_class
                                                     |   +---------------------------+
                                                     |   |                           |
                                                     +---+-> jit_entry1              |
                                                         |                           |
                                                         |     <jit_code>            |
                                                         |                           |
                                                         |                           |
                                                         +---------------------------+

after re-compilation for self_class1


      wrapper                                class guard stub (1)
   +---------------------------------+     +--------------------------------------------+
   |                                 |     |                                            |
---+-> entry:                        |  +--+-> guard1:                                  |
   |       jmp [guard1]; ------------+-/   |       movq rdi, [r14 - (LFP_SELF)];        |
   |   next:                         |     |       class_guard(self_class1)  -----------+-----> exit
   |       subl [rip + counter], 1;  |     |   patch_point:                             |
   |       jne vm_entry;             |     |       jmp [jit_entry2];                    |
   |       <exec_compile_and_patch>  |     |         |                                  |
   |       jmp entry;                |     +---------+----------------------------------+
   |                                 |               |
   +---------------------------------+               |
                                                     |      JIT code for self_class1
                                                     |   +---------------------------+
                                                     |   | +---------------------------+
                                                     |   | |                           |
                                                     +---+-+-> jit_entry2              |
                                                         | |                           |
                                                         | |     <jit_code>            |
                                                         | |                           |
                                                         +-|                           |
                                                           +---------------------------+

after compilation for self_class2


      wrapper                                class guard stub (1)                               class guard stub (2)
   +---------------------------------+     +--------------------------------------------+     +--------------------------------------------+
   |                                 |     |                                            |     |                                            |
---+-> entry:                        |  +--+-> guard1:                                  |   +-+-> guard2:                                  |
   |       jmp [guard1]; ------------+-/   |       movq rdi, [r14 - (LFP_SELF)];        |  /  |       movq rdi, [r14 - (LFP_SELF)];        |
   |   next:                         |     |       class_guard(self_class1)  -----------+-+   |       class_guard(self_class2)  -----------+-----> vm_entry
   |       subl [rip + counter], 1;  |     |   patch_point:                             |     |   patch_point:                             |
   |       jne vm_entry;             |     |       jmp [jit_entry1];                    |     |       jmp [jit_entry2];                    |
   |       <exec_compile_and_patch>  |     |         |                                  |     |         |                                  |
   |       jmp entry;                |     +---------+----------------------------------+     +---------+----------------------------------+
   |                                 |               |                                                  |
   +---------------------------------+               |                                                  |
                                                     |      JIT code for self_class1                    |      JIT code for self_class2
                                                     |   +---------------------------+                  |   +---------------------------+
                                                     |   |                           |                  |   |                           |
                                                     +---+-> jit_entry1:             |                  +---+-> jit_entry2:             |
                                                         |                           |                      |                           |
                                                         |     <jit_code>            |                      |     <jit_code>            |
                                                         |                           |                      |                           |
                                                         |                           |                      |                           |
                                                         +---------------------------+                      +---------------------------+

Unified low-level IR (LIR)

Design notes and migration log for the arch-neutral, machine-level IR that sits between AsmIR and the per-arch monoasm! / monoasm_arm64! byte emission. This is Phase-1 item ① (“a low-level IR that describes amd64 and aarch64 uniformly”).

Status: the great majority of AsmInst families now lower through encode_linst. The model lives in monoruby/src/codegen/jitgen/lir.rs; the per-arch encoder is Codegen::encode_linst, defined once in each arch/<arch>/compile/… file. As of this writing the integer + control-flow core, all addressing modes, the GC write barrier, the common guard/check family, fixnum arithmetic (with overflow deopt), the whole floating-point family, the bounds-checked heap-ivar load/store, and the large runtime-call macro-op families (array/hash/string/range construction, defined?, generic binops, class/method definition, exceptions, yield, the method-prologue guards, …) are all lowered through encode_linst, as is the entire method-call family — argument setup and the call itself (the dispatcher pre-resolves the store/frame-dependent values and the encoder stays store-free) — and the cold deopt side-exit handler blocks. Even the AsmInst::Inline builtin escape hatch now lowers to a carrier LInst::Inline (dispatched via the context-carrying encode_linst_inline), so every AsmInst reaching the dispatcher lowers to LInst. What remains outside encode_linst is the specialized inlined-frame family and the zero-byte patch/recompile bookkeeping that is not byte emission — see §8.


1. Motivation

The original justification (“close the aarch64 bail gap”) is gone: the full aarch64 port (#704) made aarch64 lower every AsmInst (see doc/arch_difference.md). The remaining — and real — motivation is description unification:

  • The AsmIR → machine-code step used to be two parallel sets of emit_* primitives (x86 arch/x86_64/compile/*.rs, aarch64 arch/aarch64/compile.rs). LIR makes encode_linst the single seam through which all migrated families emit code.
  • LIR is also the concrete code-generation target the future interpreter/JIT DSL (Phase-1 item ③, “derive interpreter + JIT from one description”) lowers to. A hand-written, test-validated LIR gives ③ a well-defined target and a correctness oracle.

2. Where LIR sits

TraceIR
  → AsmIR (AsmInst, arch-neutral, register-allocated)
  → [compile_asmir dispatcher]                         jitgen/asmir/compile_shared.rs
  → LIR (LInst, arch-neutral machine ops)              jitgen/lir.rs            ← this layer
  → [per-arch Codegen::encode_linst]                   arch/<arch>/compile/…
  → monoasm! / monoasm_arm64!
  → bytes

AsmIR stays the register-allocated, arch-neutral front-end output (one AsmInst per semantic operation). The arch-neutral dispatcher compile_asmir (compile_shared.rs) lowers each migrated AsmInst into one or more LInsts and feeds them to encode_linst; not-yet-migrated families still call their per-arch emit_* directly from the dispatcher.

encode_linst is an inherent Codegen method, defined once per arch in the file that the compile module includes for the active target_arch. Because only one compiles per target, no trait/dynamic dispatch is needed — cfg selects the right encoder.


3. Data model

Defined in lir.rs. Branch and side-exit targets carry a resolved monoasm DestLabel (the encoder runs after label resolution), so LInst is #[derive(Debug, Clone)]DestLabel is Clone but not Copy. PartialEq is intentionally not derived: several payload types reached by the macro-op variants (WriteBack, AsmEvict, FnInitInfo, …) are not PartialEq, and deriving it would force a PartialEq cascade across unrelated types for no benefit. The lir.rs unit test uses matches! instead of ==.

Operands

TypeRole
GPGeneral-purpose register. Reused from codegen.rs; already arch-neutral.
LRegGp(GP) or Scratch — a register that may be the per-arch reserved scratch pointer (rdx on x86, x9 on aarch64). For intermediate pointers (heap var-table derefs) that must not clobber an allocated value; aarch64’s x9 is outside GP’s allocatable map, so it needs its own kind. From<GP>.
FPRegVirtual FP register (physical xmm/d-reg or a stack spill); the encoder resolves the spill via FPReg::loc(base), so FP LInsts carry the frame’s spill base.
LOperandReg(GP) or Imm(i64) — an ALU/compare source the encoder folds or materializes.
LMemA logical memory location with unbounded displacement: Slot(SlotId) (LFP-relative, negative), Field { base: LReg, disp } (object field / scratch-relative, positive), RspRel { disp } (callee-frame arg slot). The encoder legalizes the displacement per arch.
LCondSigned integer branch condition (Eq/Ne/Lt/Le/Gt/Ge), with from_int_cmp / invert.
LAluOpAdd/Sub/Mul/And/Or/Xor/Shl/Sar, with from_binop.

Instructions (LInst)

GroupVariants
Move / immediateMov, LoadImm
MemoryLoad, Store, StoreImm (over LMem::Slot / Field / RspRel)
ALU / compareAlu, Cmp
BranchLabel, Br, CondBr { cond: LCond, … }, BranchTruthy { negate }, BranchIfNil, BranchIfNonzero
GC / nilWriteBarrier { parent, value }, NilIfZero { reg }
Guards (carry a side-exit deopt)GuardClass, GuardArrayTy, GuardFrozen, GuardConstBaseClass, GuardConstVersion, GuardCapture, CheckBOP
Integer arithmeticIntegerBinOp { …, deopt }, IntegerCmp, FixnumNeg { …, deopt }, FixnumBitNot
Floating-pointFprMove, F64ToFpr, FixnumToFpr, FprToStack, FprSwap, FloatToFpr (deopt), I64ToBoth, FloatBinOp, FloatUnOp, FloatCmp, FloatCmpBr, FprSave, FprRestore, CFunc_F_F, CFunc_FF_F (FP ops carry the spill base)
Heap ivar (macro-op)LoadIVarHeap, StoreIVarHeap
Construction (macro-op)CreateArray, NewArray, NewHash, HashInsert, ArrayConcat, NewRange, ConcatStr, ConcatRegexp, ToA, DeepCopyLit, ExpandArray
Variables (macro-op)StoreConstant, LoadGVar, StoreGVar, LoadCVar, CheckCVar, StoreCVar, LoadDynVar, StoreDynVar, AliasGvar
defined? (macro-op)DefinedYield, DefinedSuper, DefinedGvar, DefinedCvar, DefinedConst, DefinedMethod, DefinedIvar
Dispatch helpers (macro-op)GenericBinOp, OptEqCmp, ArrayTEq, CheckKwRest, RestKw
Definition (macro-op)MethodDef, SingletonMethodDef, ClassDef, SingletonClassDef, AliasMethod, UndefMethod
Method-prologue guards (macro-op)GuardClassVersion, RecompileDeopt, CheckStack, ExecGc, Init, LoopJitRspBump
Control flow / exceptions (macro-op)Ret, MethodRet, BlockBreak, Raise, Retry, Redo, EnsureEnd, Yield, BlockArgProxy, BlockArg, ImmediateEvict, Deopt, HandleError, Unreachable

Lir is a thin Vec<LInst> builder used where a multi-instruction sequence is convenient.

The macro-op variants do not lower to primitives inside encode_linst; instead each backend’s encode_linst ends with other => self.encode_linst_macro(other), and the arch-neutral encode_linst_macro (compile_shared.rs) delegates each macro-op to the existing per-arch emit_* helper. This keeps the delegation in one place (rather than duplicating it in both backends) while still funnelling all emission through encode_linst. See §5.

The legalization contract (the core idea)

LMem displacements and LOperand::Imm values are unbounded; the per-arch encoder legalizes them:

  • x86-64: [reg + disp32] / imm32 cover essentially everything — mostly a no-op.
  • aarch64: fixed-width encodings allow only small immediates, so the encoder folds a fitting displacement into the instruction (ldur/stur ±256, scaled ldr/str, sub sp) and otherwise materializes the offset into reserved scratch x9/x10 (the a64_frame_* / a64_field_* / a64_rsp_slot_addr helpers).

4. The two model extensions (Stage 3)

Stages 2-A…2-J were byte-for-byte family ports that fit the data model as-is. Stage 3 extended the model so the harder families could be expressed:

  1. Scratch register operand (LReg::Scratch). Lets the LIR express intermediate pointers that map to a different physical register per arch (x86 rdx, aarch64 x9). Without it, an abstract GP would either pick a register that aarch64 keeps allocatable (clobber hazard) or have no name for aarch64’s reserved scratch. First user: the self heap-ivar store (Load{Scratch ← [rdi+VAR]} ; Load{Scratch ← [Scratch+MONOVEC_PTR]} ; Store{[Scratch+idx*8] ← src} ; WriteBarrier).

  2. Deopt side-exits. Guard ops carry the resolved side-exit DestLabel they fall through to, making deopt a first-class LIR concept. The arch-neutral dispatcher resolves labels[deopt] and builds the guard; the encoder branches to it. This pattern carries the overflow exit of IntegerBinOp and every guard/check op.


5. Encoding styles: decomposition vs. macro-op

Migrated families use one of two encoder styles, both byte-identical to the prior code:

  • Decomposition — the LIR sequence is built from reusable primitives and the per-arch lowering lives in encode_linst. Used by the move/memory/ALU/branch core, the field load/store + write-barrier, and the FP transfer/convert ops (whose spill-aware bodies were moved from emit_* into the encoder arms, deleting those emit_*).
  • Macro-op delegation — a single LInst whose encoder calls an existing per-arch helper that stays (the tag-test / page-split / tagged-arith / C-call sequence is irreducibly per-arch). Two sub-cases:
    • decomposed-style delegation (guards, IntegerBinOp): the per-arch encode_linst matches the variant directly and calls the substantive helper (guard_class / a64_guard_class, integer_binop / a64_integer_binop, …); the thin emit_* wrapper is deleted.
    • encode_linst_macro-style delegation (the large runtime-call families): the variant falls through each backend’s other => arm into the arch-neutral encode_linst_macro, which calls the per-arch emit_* helper. The emit_* helper is retained verbatim, so the migration is a pure routing change — the dispatcher arm now builds an LInst and hands it to encode_linst instead of calling emit_* directly. This is how the construction / variable / defined? / definition / control-flow families were migrated (batches A and B).

6. Migration log

Each stage routes one family through encode_linst, commits, and is verified by the full cargo test --lib suite (see §7). All stages are byte-identical (modulo eliding a redundant self-mov when a value is already in the accumulator).

StageFamilyNotes
0 / 1LIR data model + this docscaffolding
2-AMov (emit_reg_move)first encode_linst user
2-Bslot memory (Load/Store/LoadImm/StoreImm over Slot)frame legalization
2-Creg-imm ALU (emit_reg_add/subAlu)LAluOp / LOperand
2-Dinteger compare-branch (Cmp + CondBr)LCond; built in shared dispatcher
2-Econditional branches (BranchTruthy / BranchIfNil / BranchIfNonzero)
2-Finline struct-slot load (Load over LMem::Field)first field-offset legalization
2-Ginline ivar/struct stores (Store{Field} + WriteBarrier)introduces WriteBarrier
2-Hinline ivar load (Load{Field} + NilIfZero)introduces NilIfZero
2-Iheap struct-slot load/storecomposed from existing ops — no new op
2-Jrsp-relative arg stores (LMem::RspRel)completes the addressing modes
3-Ascratch operand + self heap-ivar storemodel extension ①
3-Bdeopt model + class / array-ty / frozen guardsmodel extension ②
3-Cconst-base-class / const-version / capture / BOP guards
3-Dfixnum IntegerBinOp (overflow deopt)hottest arithmetic path
3-EFP transfer/convert (FprMove / F64ToFpr / FixnumToFpr / FprToStack)first FP family; real decomposition
3-FFP swap / FloatToFpr (deopt) / I64ToBoth
3-GFP arithmetic & compare (FloatBinOp / FloatUnOp / FloatCmp / FloatCmpBr)NaN-correct conditions
3-HFP C-calls (CFunc_F_F / CFunc_FF_F) + FprSave / FprRestorecompletes the FP family
Abounds-checked heap-ivar load/store (LoadIVarHeap / StoreIVarHeap)first macro-op via encode_linst_macro
B1variable + construction macro-ops (g/c/dyn-var, array/hash/range/str, to_a, defined?, generic binop, alias/undef, …)bulk macro-op routing
B2remaining construction / defined? / dispatch-helper macro-ops
B3control-flow / exception / definition macro-ops (Ret, MethodRet, Raise, Retry, Redo, EnsureEnd, Yield, MethodDef, Init, CheckStack, ExecGc, IntegerCmp, BlockArg, …)Raise/Retry/Redo/EnsureEnd carry loop_jit_spill_bytes for the aarch64 loop-JIT sp unwind
B4class-def + method-prologue guards (ClassDef, SingletonClassDef, GuardClassVersion, RecompileDeopt)last non-store/non-frame arms
B5elementary moves (RegMove/RegToAcc/AccToStack/RegToStack/StackToReg/LitToReg/LitToStack/RegAdd/RegSub)dispatcher lowers straight to LInst; the thin emit_* move wrappers deleted from both backends
B6method-call / argument-setup (SetupMethodFrame, SetArguments, SetArgumentsForwardedHelper, Preparation, OptCase)dispatcher pre-resolves the store/frame-dependent values (offset, block info, heap-ivar length, jump-table labels) into the LInst; per-arch helpers made store-free
B7the call itself (Call)dispatcher pre-resolves codeptr / is_iseq / callee+call-site pcs / x86 JIT entry (get_jit_entry); do_call/a64_do_call made store-free. x86 still records the return-address patch point inside the call’s encoder (it is captured at the emission point); aarch64 ignores the x86-only fields
B8cold side-exit / deopt handlers (LInst::SideExit { kind })both gen_asm side-exit loops build an LInst::SideExit and route through encode_linst; the per-arch encoder dispatches on LSideExitKind (Deopt / Evict / RecompileDeopt / Error) to the existing handler emitters (x86 gen_*_with_label / gen_handle_error; aarch64 a64_gen_deopt / a64_gen_handle_error, which collapse Evict/RecompileDeopt to a plain deopt)

7. Verification

Because the migrations are pure refactors, the safety net is behaviour preservation under a correct reference Ruby:

  • The harness compares JIT output against the system ruby, and monoruby targets CRuby 4.0+. With an older Ruby (e.g. 3.3) ~33 --lib tests fail on format differences (3.4 Hash#inspect, etc.) — unrelated to LIR. Every stage here is verified under CRuby 4.0.5, where the baseline is 1702 passed / 0 failed; each migration keeps it at 1702 / 0 with no new warnings.
  • Each family migration is local to its emit_* / dispatcher arm and is independently revertible.

Building CRuby 4.x from source in a restricted-network sandbox: the official tarball host (cache.ruby-lang.org) may be blocked while GitHub is reachable. Fetch the git tag source archive from codeload.github.com, install gperf (needed to generate lex.c), empty gems/bundled_gems to skip the bundled-gem download (or set SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt so the bundled-gem fetch trusts the egress proxy CA), then autogen → configure → make → make install.


8. Remaining work

First, what is no longer a blocker. The whole method-call family is now migrated — argument setup (SetupMethodFrame, SetArguments, SetArgumentsForwardedHelper, Preparation, OptCase; B6) and the call itself (Call; B7) lower through encode_linst. The dispatcher — which holds &Store / &mut AsmInfo — resolves every store/frame-dependent value (callee scratch offset, block (fid, arg), heap-ivar table length, jump-table DestLabels, and for Call the codeptr / is_iseq / callee & call-site pcs / x86 JIT entry) and carries them in the LInst, so the per-arch helpers (do_call/a64_do_call, …) are store-free. Call’s encoder also records the return-address patch point, which is captured at the emission point and so belongs with the call’s emission. (The elementary register/stack moves were likewise inlined into the dispatcher in B5, deleting their forwarding wrappers.)

The cold side-exit / deopt handler blocks that guards branch to — laid out by gen_asm before the main instruction loop, not by an AsmInst — are also byte emission, and as of B8 they route through encode_linst via LInst::SideExit (write-back + sp-unwind + VM-resume / unwind). Both arches’ gen_asm side-exit loops now build an LInst::SideExit and the per-arch encoder dispatches on LSideExitKind.

The remaining things handled directly (not through encode_linst):

  • Specialized inlined-frame family. MethodRetSpecialized, BlockBreakSpecialized, SpecializedCall, SetupYieldFrame, and SpecializedYield lower an inlined callee/block frame; they resolve frame-local labels and patch points and are dispatched to a per-arch method of the same name (arch/<arch>/compile/…). (AsmInst::Inline is no longer in this group — it lowers to LInst::Inline; see below.)

  • The AsmInst::Inline escape hatch. Most builtin inline generators (e.g. emit_math_sqrt) still run a closure that emits arch asm directly via gen. As of the AsmIR→LIR consolidation, AsmInst::Inline does lower to a carrier LIR op — LInst::Inline(InlineProcedure) — so every AsmInst reaching the dispatcher now lowers to LInst. LInst::Inline is the one LIR op whose emit is not store-free: its wrapped closure needs the compile context (&Store, &SideExitLabels, frame base), so it is dispatched at the lowering boundary via encode_linst_inline rather than through the store-free encode_linst. (If it ever reached encode_linst/_macro it hits the unreachable! fallthrough.) This is a transitional carrier: migrating the closures onto typed, arch-neutral LIR ops — so the variant can eventually go away — is goal 2 of the long-term plan (“express the inline-builtin codegen once, arch-neutrally”). The migratable category is the pure generators — property/field readers and trivial C-function wrappers, whose codegen is one existing LIR op with no arch-specific control flow:

    • 64-bit field readersAsmIr::load_field_to_regAsmInst::LoadFieldToReg → existing LInst::Load { Field } (no new op, byte-identical on both arches). Done: Range#begin/end (hand-written emit_range_begin/end deleted) and ArithmeticSequence#begin/#end/#step (via the shared inline_field_load helper; emit_load_value_field deleted from both backends).
    • Bool field readersAsmIr::bool_field_to_regAsmInst::BoolFieldToRegLInst::BoolFieldToReg (a small macro-op: 32-bit load + shl 3 + or FALSE_VALUE, deduping the two byte-identical emit_*_exclude_end emitters into one encode arm per arch). Done: Range#exclude_end?, ArithmeticSequence#exclude_end?.
    • Container lengthAsmIr::array_len_fixnum / string_len_fixnumAsmInst::ArrayLenFixnum / StringLenFixnum → the matching LInst (a macro-op: load inline capa + conditional-select the heap length when capa exceeds the inline cap + fixnum-tag; the conditional select is the only per-arch part, x86 cmov / aarch64 csel). Done: Array#size, String#bytesize — one op each (differing only in the inline-cap constant, ARRAY_INLINE_CAPA vs STRING_INLINE_CAP), replacing the four emit_array_size/emit_string_bytesize emitters.
    • Fixnum → float (Integer#to_f) → the existing AsmIr::fixnum2fprLInst::FixnumToFpr op (untag + cvtsi2sd/scvtf straight into the result fpr). No new primitive; deletes emit_int_to_float from both backends (and drops a redundant xmm0 round-trip the old emitter always did).
    • Bool predicates (Object#nil?, BasicObject#!) → AsmIr::is_nil_to_bool / not_to_boolLInst::IsNilToBool / NotToBool (a macro-op: compare + conditional-select TRUE/FALSE; the select is the per-arch part, cmov/csel). Replaces the emit_kernel_nil / emit_object_not emitters.
    • C-function wrappers (e.g. Math.sin/cos/atan2, Float#**) are already arch-neutral: they route through the typed AsmInst::CFunc_F_F / CFunc_FF_F (→ existing LInst::CFunc_*), not the closure escape hatch.
    • FP guard + op (Math.sqrt) → AsmIr::math_sqrtLInst::MathSqrt, a macro-op carrying the deopt label (resolved by the dispatcher like GuardClass). It encapsulates the per-arch domain guard (x86 ucomisd + jp/jb, aarch64 fcmp + b.vs/b.mi) and the sqrtsd/fsqrt in one encode arm per arch — so even a deopt-branching FP builtin migrates without a closure (it does not declare fpr_operands, matching the prior opaque Inline, so the spill-area sizing is unchanged; the fprs are accounted via the surrounding load_fpr/def_F).
    • Integer guard + op (Integer#succ) → AsmIr::integer_succLInst::IntegerSucc, a macro-op carrying the deopt label (add+jo / adds+b.vs, deopt → Bignum promotion). The integer analog of MathSqrt.
    • Control-flow predicate (Kernel#block_given?) → AsmIr::block_givenLInst::BlockGiven, a macro-op with a self-contained local exit label (reads [LFP - LFP_BLOCK]). No external context needed.

    What stays a closure is the genuinely complex shapes — object allocation, send, fiddle, the remaining integer shift/division guards, object_id (a runtime call). Several would still benefit from a couple of generic FP/branch/call LIR primitives.

  • Pure patch / recompile bookkeeping — the x86/aarch64 non-coverage asymmetry of doc/arch_difference.md §4. The deopt handler emission now goes through LIR (above); what stays out is the part that emits no bytes: ImmediateEvict records a return_addr_table patch point (zero bytes), and the actual return-address overwrite happens at BOP-redefinition time, not at compile time. The clean invariant is therefore:

encode_linst is the single seam for byte emission; zero-byte code-position metadata / runtime patch bookkeeping is not emission and stays in the dispatcher / Codegen.

None of this invalidates the model: encode_linst (with encode_linst_macro, plus the context-carrying encode_linst_inline for the one LInst::Inline op) is the single seam for byte emission. The only arms still handled directly in the dispatcher are the specialized inlined-frame family (which resolve frame-local labels / patch points) and the patch/recompile bookkeeping, which is not emission by the invariant above.


§9 The two-phase pipeline: whole-function buffering + virtual GP + physical allocation

Everything above is the single-pass seam: compile_asmir lowers each AsmInst to one or more LInsts and emits them immediately through encode_linst. That gave us one byte-emission description per op, but it cannot host a register allocator: allocation needs the whole function’s instruction stream in hand to compute liveness, so an op cannot be encoded the instant it is produced.

The next architectural step (the AsmIr → LIR pipeline) splits emission into two phases while keeping AsmIr and LIR as separate layers (collapsing them would just reconstruct AsmIr):

AsmIr (AsmInst, frame-INDEPENDENT, FP already virtual, GP physical)
  │  lower (resolve frame size / labels / patch points)
  ▼
LIR  = Vec<LInst>   ← whole compiled unit, offsets baked in, GP *virtual*
  │  arch-dependent physical-register allocation pass (GP virtual → physical)
  ▼
encode (drain → encode_linst → bytes)
  • LIR is barely-arch-independent and offset-baked. Frame base, slot displacements, field offsets, and labels are all resolved when AsmIr lowers to LIR (AsmInst is frame-independent; LInst already carries base). What LIR does not yet bake is the physical register assignment.
  • Registers are virtual in LIR. FP registers already are (FPReg = phys-or-spill, resolved by PhysMap at encode time — §25/§27). GP registers are not: today LInst names concrete GPs (R15 = accumulator, R12 = globals, R14 = LFP, plus scratch temporaries). The pipeline introduces a virtual GP (VReg) for the allocatable GP uses; the fixed globals (acc / pc / lfp / globals / executor — see CLAUDE.md) stay pinned and are not virtualized. Only the temporary/scratch GP traffic becomes virtual and is assigned in the post-LIR phase.

Blockers (from the current driver, asmir.rs::compile ~2293–2400)

The single-pass driver interleaves byte emission with ordering operations that are not themselves LInsts emitted through encode_linst:

  1. self.jit.label() / bind_label(..) — label creation and binding.
  2. self.jit.select_page(1) — cold/hot page selection (x86 lays side-exit handlers on the cold page).
  3. frame.sourcemap.push((i, pos)) — source-position records keyed on the current code position.
  4. Patch-point / return-address-table bookkeeping (doc/arch_difference.md §4), which emits zero bytes but is position-sensitive.

If LIR buffered only the encode_linst calls and left (1)–(4) inline, replaying the buffer later would desync every label, page boundary, and source mapping. Therefore the buffer must model these as ordering pseudo-ops in the same Vec<LInst> (e.g. LInst::BindLabel, LInst::SelectPage, LInst::SourcePos), so the entire emission stream — bytes and position metadata — is one ordered sequence the allocator can walk and the drainer can replay faithfully.

Staged increments

  • 9a — Make the emission stream fully reified. Add the ordering pseudo-ops so compile_asmir + the side-exit loop produce a single Vec<LInst> covering all of byte emission, label binding, page selection, and sourcemap. Verify byte-identical output by draining the buffer immediately (no allocation pass yet) — a pure refactor, gated/shadowed like the §24 placement harness.
  • 9b — Introduce VReg (virtual GP) with an identity map. Replace the allocatable GP operands with VReg, lowered through a trivial virtual→physical map that reproduces today’s assignment. Still byte-identical; this only changes the type carried, establishing the seam.
  • 9c — Carry liveness / loop metadata into LIR. Per §27.1, re-deriving liveness after LIR-flatten cost 2.5×, so the allocator must consume the metadata the ① fixpoint already computed (loop-carried sets, etc.) rather than recompute it. Thread it onto the buffer.
  • 9d — The arch-dependent physical-allocation pass. Walk the buffered LIR, assign physical GPs to VRegs (spilling to frame slots under pressure, exactly as FPReg does for FP), then drain → encode. This is the first point the output may legitimately differ from today’s bytes; like phys-loop-aware (§42), it is a perf experiment gated behind a flag + the M1 A/B bench gate, and the shadow digest becomes a delta meter, not an equality check.

9d allocatable GP pool (design decision)

The VM’s fixed registers stay pinned (acc=R15, lfp=R14, pc=R13, globals=R12, executor=rbx) and the C-ABI / inline-builtin convention regs (rdi=recv, rax=result, rsi/rdx/rcx=call args) are pre-colored by the operations that use them — the allocator cannot move those. The allocatable pool is the otherwise-unused caller-saved scratch set:

  • x86-64: r8, r9, r10, r11 (4 registers). These are caller-saved, so any of them that is live across a C-ABI call (e.g. a runtime helper / CFunc_*) must be spilled to the frame and reloaded — the allocator inserts the save/restore exactly as the FP side already does around calls (fpr_save / fpr_restore).
  • aarch64: the analogous caller-saved temps left free in the GP::a64 map (x9..x15 minus the encode scratch x9/x10), with the same spill-across-call rule.

So a VReg is either pinned (an ABI/convention register the front-end chose) or allocatable (assigned by 9d to a pool register or a spill slot). The identity map of 9b is the degenerate all-pinned case; 9d makes the front-end emit allocatable VRegs for cross-operation temporaries and colours them into the pool. The payoff is keeping hot bytecode-slot values in r8r11 instead of re-loading them from the LFP each use.

9a/9b are byte-identical refactors (safe to land once verified); 9c/9d are the substantive allocator work and stay behind a flag until the bench gate clears.

9d placement build order

The placement phase (filling gp_alloc, colouring VReg::Alloc into pool registers, and keeping the frame consistent) is built as a sequence of feature-gated (gp-alloc) increments, each byte-identical while no slot is yet placed in the pool:

  1. Write-back + flush pool-support (done). Two seams, both inert until a slot is placed:

    • Side-exit / deopt / GC write-back. WriteBack carries a gp: Vec<(GP, SlotId)> list — the pool-resident slots and their physical registers — alongside the single r15 accumulator. Every such write-back (gen_write_back, gen_write_back_for_deopt, a64_gen_write_back_for_deopt) stores each pool register to its slot’s frame home, exactly as for r15. The producer (wb_gp) scans for slots in mode G(_, VReg::Alloc(_)).
    • In-function flush-at-boundary. writeback_acc — already called before every call / store / definition, and already asserting no G(_, _) slot survives it — now also flushes the pool residents (writeback_pool_state): each G(_, Alloc) slot is stored to its home and dropped to S. Because this runs before every C-ABI call, a pool value never stays resident across a call, so the GP allocator needs no caller-saved spill/reload threading (using_gp) the way the FP side does — the flush already did it.

    Until the placement policy creates a G(_, Alloc(_)) slot both lists are empty, so the fields, their Hash/Debug, and the write-back/flush loops are inert and the emitted bytes are unchanged.

  2. Reserve the pool registers (x86-64) (done). The pool design above assumed r8r11 were “otherwise-unused caller-saved scratch”, but an audit of the JIT-body lowerings found that is not true: r8r11 are the JIT’s secondary scratch pool, used freely once the primary scratch (rax/rcx/rdx/rsi/rdi) runs out. To hold a value in a pool register across instructions, no intervening lowering may clobber it — so the pool registers must be reserved, exactly as the FP side reserves its xmm pool and confines scratch to xmm0/xmm1.

    The audit (in-scope: every compile.rs / compile/* / guard.rs lowering that can run while a pool value is live; out of scope: vmgen = the VM interpreter, invoker/wrapper = reached via a call boundary) found:

    • r10, r11: unused in JIT-body code.
    • r9: only class_def, a post-flush call-staging use (safe).
    • r8: every use is post-flush call-staging (a method-send/define/store sequence runs writeback_acc first, emptying the pool — so r8 is free there, the GP analogue of using the pool after fpr_save) except one: emit_string_setbyte, a pure inline op that can execute with a pool value live. That one was migrated off r8 (tag scratch → rcx; negative-index adjust → a sign branch instead of a cmov-through-scratch).

    The reservation invariant: a JIT-body lowering may use r8r11 as scratch only in a post-flush call-staging window (after writeback_acc, where the pool is provably empty). Everywhere a pool value can be live, the pool registers are off-limits. After the migration this holds for the whole x86-64 backend. (aarch64 pool reservation is pending: GP::R8R11 map to x5x8; that backend needs the same audit before placement targets it. The gp-alloc feature is x86-first and off by default, so nothing places into the aarch64 pool yet.)

  3. Placement policy + multi-residency (done). def_reg2acc_guarded is the trigger: when a new accumulator value arrives from a register other than R15 (so R15 still holds the previous accumulator), try_relocate_acc_to_pool moves that previous value into a free pool register (G(_, Alloc(id))) instead of spilling it. Later reads then come from the register (no LFP reload) until the next flush. Making the state machine track pool residents (not just the single R15 accumulator) required:

    • Placement::Gp(VReg) (load-bearing). SlotState stores place + ty and reconstructs every mode() via from_parts — so the placement must carry the VReg, else set_mode(G(Alloc)) loses the pool-register identity on the very next state read (it round-trips to Stack).
    • Register-aware reads everywhere a G slot is consumed: on_reg / on_reg_or (binop operands), load_state (GpLoad::Reg(vreg.phys())), fetch_for_callee (call-argument materialization), the G→Sf bridge — all resolve vreg.phys() rather than assuming R15.
    • Alloc-aware destructive sites + flush (§1, the gp field / writeback_ pool_state / Alloc-aware clear/write_back_slot).
    • Fixnum-only restriction (GC safety): a pool register is not a GC root, so only values statically guarded Fixnum (immediates — no heap pointer) are relocated. A heap pointer kept solely in r8r11 across a collection would be freed; heap-value pooling is deferred until pool registers are made GC-rootable. Hot integer loops — the primary target — are covered. Verified byte-identical on default; gp-alloc passes the full suite including gc-stress (GC on every allocation).
  4. M1 A/B bench gate before the feature becomes default.

Notes:

  • C-ABI call-save is not a separate step: the flush-at-boundary (§1) makes it unnecessary — pool slots are always written back before a call. This holds for every call, not just Ruby method calls: the method-call path flushes via writeback_acc, and every runtime helper (deep_copy_lit, new_array, new_hash, to_a, concat_str, generic_binop, class/method def, defined?, …) flushes the pool at its pre-call get_using_fpr snapshot (which now flushes the GP pool as a side effect — the universal pre-C-call chokepoint). The pool registers are caller-saved, so a Fixnum left resident there would otherwise be clobbered by the helper’s C call (e.g. String#clear’s bytesize in r8, clobbered by the "" literal’s value_deep_copy). The asmir builders that take &AbstractFrame (immutable, so they cannot flush) get an explicit flush_gp in their handler instead.
  • Branch-merge reconciliation needs no special pool handling: pool residents are flushed (→ S) before any branch (the compile-loop flush_gp and the back-edge analysis G→S demotion), so a merge never sees a G(_, Alloc) slot.

9d outcome: GP register residency does not pay; the untagged-Fixnum direction does

The temp-relocate placement (§9-3) is correct (full suite incl. gc-stress passes) but the M1 A/B bench gate fails: no benchmark improves beyond noise and binarytrees regresses ~14% (the relocate mov + flush is pure overhead when the pooled value is not reused enough). So gp-alloc stays off.

9d-B: accumulator register file (results born directly in r8r11)

Instead of the relocate model (new value → R15, then relocate the old R15 resident into the pool), the accumulator register file extends the accumulator’s register set from {R15} to {R15, r8–r11}: a Fixnum result is written directly into a free pool register (try_def_G_pool in def_reg2acc_guarded), with no R15 round-trip and no relocate mov. R15 remains the fallback when the pool is full or the value is not a Fixnum immediate. This removes the relocate overhead that made the temp-relocate version regress binarytrees.

Making it correct required eliminating two latent “the current value is in R15” assumptions that the single-accumulator design relied on:

  1. copy_slot’s G arm spilled src then def_G(dst) — which claims R15 without moving the value there. Sound only for Pinned(R15); for a pool resident it left dst reading R15 while the value sat in the pool register. Fixed by transferring the pool register’s ownership to dst (no data move).
  2. Caller-saved clobbering across runtime helpers (see the §1 note above): the pool registers are caller-saved, and the flush-at-boundary invariant was only honoured by Ruby method calls, not the runtime helpers. Fixed by folding the GP-pool flush into get_using_fpr, the universal pre-C-call snapshot.

Result: full lib suite green under --features gp-alloc (1678/1678, reduced parallelism — the harness flakes on ruby-subprocess spawn under high parallelism, unrelated to the JIT). A/B (release, best-of-5): binarytrees 0.997× (the −14% regression is gone), app_fib 0.959×, tarai 1.007×, so_nbody 0.999× — i.e. overhead-free but not a speedup. So the register-file fixes the cost of the relocate model without changing the fundamental conclusion below: residency alone is marginal for Fixnums. gp-alloc stays off pending the untagged-Fixnum representation, which supplies the missing per-op cost. The work lives on claude/gp-acc-regfile.

Why register residency alone is marginal for integers. Unlike floats — where the win is avoiding boxing (a heap allocation per spilled float) — a Fixnum is an immediate, so keeping it in a GP register only saves an LFP load/store, and that store is already cheap (L1 + store-to-load forwarding). Locals must also be materialized in the frame at every deopt / call / GC safepoint anyway. Microbench (hand-asm, faithful to the JIT loop body): keeping the loop locals of while i<n; s=s+i; i=i+1; end in registers instead of memory is only ~1.26× on the tightest possible integer loop, diluted to ~noise on real benchmarks.

Loop-carried integer residency (the float-F-mode analogue: promote loop-carried Fixnum locals to pool registers at the loop entry, keep them across the back-edge) was prototyped on branch claude/gp-loop-carried-wip. It promotes, but the JIT’d loop deopts and recompiles ~every iteration (the VM→JIT loop entry / OSR does not set up the pool registers the JIT’d loop head expects). Given the marginal ceiling above, it was not pursued to completion.

The promising direction — untagged Fixnum (“integer F-mode”). A Fixnum is tagged 2n+1, so every integer op pays a tag adjustment (sub 1 / or 1) and a type guard (test $1; je). Give integers a second representation — untagged 2n (LSB cleared), held in a GP register — exactly as a float has boxed Value vs unboxed F (xmm). Within a run of integer ops the value stays untagged; it is tagged (or 1) only when it escapes to a Value context (stored to a boxed slot, passed to a call, …). This removes both per-op costs, because an untagged-integer slot is statically known to be an integer (no guard, like F needs no float guard).

Key properties:

  • Overflow detection is preserved by the 2n choice: 2a + 2b = 2(a+b) overflows i64 iff a+b overflows i63 (the Fixnum range), so the existing jo works unchanged. (Raw n would not preserve this.)
  • Per-op, not per-loop: the tag/guard elimination helps every integer op, so it benefits straight-line integer code too, not just loops.
  • Microbench (hand-asm): untagged + guard-free is ~1.50× over the tagged+guarded in-register loop; compounded with residency, ~1.9× ceiling on the tightest integer loop.

Implementation shape (a substantial change, comparable to introducing F/Sf): a new LinkMode for an untagged integer in a GP register (I, the F analogue) plus an untagged-with-boxed-cache variant (Si, the Sf analogue); integer-op lowerings that consume/produce the untagged form; tag on escape to Value. This is the recorded future direction for integer JIT performance — it supplies the “boxing-like” per-op cost that finally makes GP register residency worthwhile.

Separating the abstract interpreter from register allocation

Design study for Phase-1 item ② (“separate the abstract interpreter / fixpoint search from register allocation”). This is the structural prerequisite for the longer-term goals: collapsing AsmIR into LIR (goal 1) and deriving the VM and JIT from one description via partial evaluation (goal 3 / item ③).

Status: design proposal only — no code has moved yet.


1. Where the two concerns are fused today

The JIT’s middle end runs a single abstract-interpretation pass over TraceIR that simultaneously infers types and assigns physical storage. The fusion lives in three places:

LinkMode — one enum, three concerns

monoruby/src/codegen/jitgen/state/slot.rs:1229

#![allow(unused)]
fn main() {
enum LinkMode {
    V,                  // no value
    None / MaybeNone,   // optional-arg sentinels
    S(Guarded),         // boxed on the stack      + type guard
    G(Guarded),         // boxed in GP r15 (acc)   + type guard
    F(FPReg),           // unboxed f64 in an xmm    (type = Float)
    Sf(FPReg, SfGuarded // unboxed in xmm + boxed cache on stack + type
    C(Value),           // compile-time constant   (type = guarded(v))
}
}

Each variant encodes all three of: the abstract type (Guarded class / float-ness / concrete value), the representation (boxed Value vs unboxed f64), and the location (stack home / GP r15 / xmm pool).

SlotState — type lattice and allocation map in one struct

monoruby/src/codegen/jitgen/state/slot.rs:4

#![allow(unused)]
fn main() {
struct SlotState {
    slots: Vec<LinkMode>,    // per-slot fused type+repr+location
    liveness: Vec<IsUsed>,   // analysis
    vfpr: Vec<Vec<SlotId>>,  // allocation: reverse map xmm/spill -> slots
    r15: Option<SlotId>,     // allocation: who owns the accumulator
    pinned_vfpr: Vec<FPReg>, // allocation directive (anti-aliasing)
    …
}
}

Allocation decisions are taken inside the dataflow

  • alloc_fpr (slot.rs:370) does greedy linear-scan allocation (find a vacant physical xmm 0..PHYS_FPR_POOL; else demote an Sf cache; else spill to FPReg(N≥PHYS_FPR_POOL)).
  • def_F / def_Sf_* (slot.rs:554) allocate a register and set the slot’s type in one call.
  • AbstractFrame::join (state/join.rs:46) merges type guards and reconciles registers together — it can even allocate a fresh xmm mid-merge (try_set_new_F) when two predecessors hold a value in different xmms.

What is already factored out

FPReg (codegen.rs:181) is already a virtual register: FPReg(0..13)xmm2..xmm15, FPReg(14+) → an 8-byte stack spill, resolved late by FPReg::loc(base) (codegen.rs:188). AsmInst operands carry FPReg, so the operand layer is virtual. What is not factored out is when/where the FPReg assignment is decided — it happens inline with type inference.

Net: type analysis, representation (box/unbox), and register allocation are one pass over one fused LinkMode/SlotState. ② is about teasing these apart.


2. Why separate (the payoff)

  • Goal 1 (collapse AsmIR into LIR). Once allocation is a distinct step that emits, it can emit LInst directly; the AsmInst layer (already ~isomorphic to LInst after the B-migrations) stops carrying its own existence.
  • Goal 3 (one description → VM + JIT). Partial evaluation needs the analysis to be reusable under two different allocation policies:
    • JIT residual = analysis with inline-cache types + the greedy xmm allocator (today’s behaviour).
    • VM residual = analysis with ⊤ (no specialization) + a fixed-convention allocator (everything boxed in its stack home, no pool). You cannot instantiate two allocators while allocation is welded to inference.
  • Maintainability. The join table conflates a type lattice with a register reconciler; splitting them makes each independently testable.

3. Target architecture

Three layers, with a typed IR in the middle:

TraceIR
  │  ① analysis pass  (fixpoint; types + liveness only — NO locations)
  ▼
Typed IR            per-slot `Guarded` type lattice + liveness; operands are
  │                 (slot, representation) — still virtual, no phys regs
  │  ② allocation + lowering pass  (pluggable Allocator)
  ▼
LIR (LInst)         concrete regs/spills; emitted straight to encode_linst
  ▼  encode_linst → bytes

Layer ① — the type lattice (analysis)

A pure lattice element per slot, no location. This is the existing Guarded enum — no new type is needed:

#![allow(unused)]
fn main() {
enum Guarded { Value /*⊤*/, Fixnum, Float, Class(ClassId) }
}

join over Guarded is a pure lattice meet (Guarded::join already exists) — no register churn. Liveness stays here. This is what goal 3’s partial evaluator parameterizes (feed ⊤ for the VM residual, IC-narrowed types for the JIT residual).

Sf is not a type. Per review, the Sf linkage (“Integer def’d, Float use’d, kept coerced to f64”) is not a lattice element but a representation decision taken by a separate analysis. In the typed IR an Sf slot lowers to its plain boxed type (Fixnum); a dedicated def-use + loop pass then marks it for the xmm-coerced representation when:

the slot is def’d as Integer and use’d as Float, and it is a constant/literal or def’d outside a loop and use’d inside it (i.e. the coercion is loop-invariant and worth hoisting into an xmm).

That mark is the FprStack placement. Keeping it out of the type lattice is what lets the VM residual (no marks, everything boxed) and the JIT residual (marks applied) share one analysis.

Layer ② — representation + allocation

Given the typed IR + liveness, a separate step decides:

  1. Representation: keep a Float/Fixnum value unboxed where it is consumed by FP arithmetic, else boxed. (Today: the F vs S vs Sf choice.)
  2. Placement: assign each live unboxed value an FPReg (pool or spill) and each boxed value its stack home / the r15 accumulator. Insert transfer / spill / φ-move code at edges.

This is the swappable Allocator. The default is the current greedy policy; the VM policy is “no pool, everything in its stack home.”


4. Incremental migration path

Each step is independently shippable and verified at 1702/0 (behaviour preservation under CRuby 4.0+). Order chosen so the risky structural change comes last, after the data is already decoupled.

StepChangeRisk
0a. Decomposition + testAdd the location-only Placement enum, plus LinkMode::{placement, from_parts} projections, with a round-trip test proving LinkMode ≅ (Placement, Guarded). The type lattice is the existing Guarded (no new type — per review, Sf is a representation mark, not a type; its SfGuarded refinement is recovered from the paired Guarded). Additive scaffolding — no live state touched. Done; suite 1703/0.none
0b. Storage split(0b-i) Encapsulate every self.slots access behind mode()/set_mode()/all_regs()/slots_len() (the two in-place mutations become local-copy RMW). (0b-ii) Replace SlotState.slots: Vec<LinkMode> with place: Vec<Placement> + ty: Vec<Guarded>; mode() composes via from_parts, set_mode() decomposes. Behaviour-identical. Done; suite 1703/0.done
0c. Factor the type meetExtract the analysis-layer join as a reusable primitive: relocate Guarded::join next to Guarded and add SlotState::join_ty (element-wise Guarded::join over the ty vec). Verified arm-by-arm that the fused AbstractFrame::join’s resulting type equals this meet for every non-sentinel slot, so the fused join’s remaining work is purely placement reconciliation (which carries the allocation side-effects and moves to the Allocator in steps 1–2). Done; suite 1703/0.done
1. Allocator seamExtract vfpr + pinned_vfpr and the pure pool primitives into an FprAllocator struct owned by SlotState; the xmm_* methods delegate to it. The policy (try_alloc_fpr/alloc_fpr) stays on SlotState (it also mutates slot placements). Done; suite 1703/0.done
2. Standalone analysisRun the Guarded/liveness fixpoint as its own pass producing a typed IR, before the allocation+lowering pass consumes it. The lowering pass becomes fn(typed_ir, &mut Allocator) -> Vec<LInst>. This is the real separation. Spike done — see §9.high
3. AsmIR → LIR (goal 1)The lowering pass emits LInst directly; retire AsmInst as a distinct stream (its AsmIr bookkeeping — side_exit, flags — moves to the lowering driver).med
4. Two allocators (goal 3 enabler)Add the fixed-convention VM Allocator; spike VM-residual generation for one bytecode.research

Step 0a is shipped (Placement + projections + round-trip test). Steps 0b–1 are mechanical decoupling that pay off immediately (clearer code, testable lattice) and de-risk step 2. Steps 3–4 are where goals 1 and 3 land.


5. Hard parts / open questions

  • Join-time reallocation. Today join may allocate a fresh xmm when two predecessors hold a value in different registers. Separated, the allocator must resolve this as an SSA-φ with edge moves (insert FprMoves on the CFG edges) rather than reallocation during the meet. This is the crux: step 2 effectively turns the fused greedy pass into a proper linear-scan / SSA allocator with edge fixups. Codegen quality must not regress (the current greedy is decent on the FP-heavy benchmarks).
  • Sf (xmm + stack cache). Resolved in review: Sf is a representation decision, not a type. The typed IR carries the plain boxed type (Fixnum); a separate def-use + loop analysis marks the slot for the xmm-coerced representation (the FprStack placement) using the heuristic in §3. The demote-on-pressure logic (try_alloc_fpr phase 1) is a further allocation policy that moves into the Allocator. (In the current fused state the SfGuarded refinement still round-trips losslessly through the paired Guarded, since SfGuarded → Guarded is injective.)
  • r15 accumulator. The single GP “accumulator” slot is its own tiny allocation problem fused into SlotState.r15; it follows the same split (type vs placement) but is simpler than the xmm pool.
  • Spill-region sizing across joins (grow_fpr_to, gen_bridge) becomes the allocator’s responsibility once placement is its own layer.

6. Progress

Step 0a is done (revised per review). LinkMode now has the placement() / from_parts() projections, and a unit test (linkmode_placement_roundtrip) proves it is isomorphic to (Placement, Guarded). The type lattice is the existing Guarded; Sf is treated as a representation mark (the FprStack placement), not a type, with its refinement recovered from the paired Guarded. No live state changed; suite at 1703/0.

Steps 0a–0c are done. SlotState is backed by place: Vec<Placement> + ty: Vec<Guarded>; the type lattice is a standalone per-slot vector with a reusable meet (join_ty). Crucially, the fused AbstractFrame::join’s type result is exactly that meet for every non-sentinel slot — so the join’s residual work is purely placement reconciliation (the register/φ reconciliation that carries allocation side-effects). That confirms the clean split point: the type analysis is already separable; what remains entangled is allocation, which is precisely what steps 1–2 pull out.

Step 1 is done. The xmm allocation state (vfpr + pinned_vfpr) and its pure pool primitives now live in an FprAllocator struct owned by SlotState, physically separated from the slot type/placement state. The allocation policy still sits on SlotState.

Next: step 2 (standalone analysis pass) — run the Guarded/liveness fixpoint as its own pass (consuming join_ty) producing a typed IR, before the allocation+lowering pass. This is the high-risk structural change; goals 1/3 fall out of steps 3–4 afterward.


9. Step-2 spike: where the analysis/emission fusion actually lives

Before committing to step 2 (the big rewrite), a spike traced exactly how analysis and emission are entangled in compile_instruction. The finding reshapes the plan.

What the spike found

The bytecode handlers (compile_instruction, ~100 TraceIr arms) are not where analysis and emission are knotted together. A handler like LoadGvar is just discard(dst); push(LoadGVar); def_rax2acc(dst). Following that down:

def_rax2acc → def_reg2acc_guarded → def_G → writeback_acc

the only emission on the whole chain is at the very bottom, in a handful of transfer / eviction primitiveswriteback_acc (evict the r15 accumulator owner to its stack home), the xmm spill/swap emitters, etc. Almost everything else (the Guarded lattice, liveness, placement bookkeeping in place/ty/FprAllocator) is already pure state.

And those transfer primitives split cleanly. writeback_acc was:

#![allow(unused)]
fn main() {
fn writeback_acc(&mut self, ir) {
    if let Some(slot) = self.r15 {
        self.set_mode(slot, S(self.guarded(slot)));  // state
        self.r15 = None;                              // state
        ir.acc2stack(slot);                           // emission
    }
}
}

The spike split it into writeback_acc_state() -> Option<SlotId> (the pure state transition, returns which slot was evicted) and the residual writeback_acc = if let Some(slot) = self.writeback_acc_state() { ir.acc2stack(slot) }. The emission is fully determined by the slot the state half returns — i.e. the transfer primitive is (state-mutation that yields a transfer record) + (emit from that record). Behaviour-identical; suite 1703/0.

How this reshapes step 2

Step 2 is therefore not “split ~100 op handlers”. It is:

  1. Split each transfer/eviction primitive (a bounded set — writeback_acc, the xmm spill/swap/float_to_fpr emitters, def_*’s eviction step) into a state half that returns a transfer record and an emit half that consumes it. The records are exactly the typed IR the analysis pass produces.
  2. The standalone analysis pass runs the handlers with the state halves and collects the transfer records (no AsmIr). It already exists in skeleton form: analyse_basic_block reuses compile_instruction but discards its AsmIr — today that discard is wasteful (it builds AsmInst only to drop them); after the split it would call the state halves and skip emission.
  3. The lowering pass replays the records, emitting LInst via the emit halves + encode_linst.

This is a far more bounded and mechanical change than a per-handler rewrite, and each primitive split is independently shippable and behaviour-verifiable at 1703/0 (the writeback_acc split is the first). It also subsumes goal 1: once lowering is its own pass, it emits LInst directly and AsmInst retires.

Progress on the transfer-primitive split

Split so far, each behaviour-identical at 1703/0:

  • Stack writebacks → the Spill record (None / Fpr / Lit / Acc): writeback_acc, write_back_slot, to_S_unguarded.
  • FP-register transfers → the FpXfer record (Move / Swap): to_sf (gen_fpr_swap was already a clean state-line + emit-line).

Each primitive now has a *_state half that performs the abstract-state transition and returns the record, plus a thin codegen wrapper record.emit(ir). The records (Spill, FpXfer) are the growing typed IR vocabulary.

The hard tail: deopt-carrying transfers

The unbox loads (load_fpr and friends) are not a clean (state) + (record → emit) split, because they create a deopt side-exit mid-flight:

#![allow(unused)]
fn main() {
let deopt = ir.new_deopt(self);     // captures state.get_write_back() — a
self.use_as_float(slot);            // SNAPSHOT of the live placement state
match self.mode(slot) { S(_) => { let x = self.set_new_Sf(..); 
    ir.stack2reg(slot, Rdi); ir.float_to_fpr(Rdi, x, deopt); x } … }
}

new_deopt snapshots get_write_back()which values are unboxed/in-acc and must be restored to the stack if the float guard fails. That snapshot is the placement state at this program point. So in the separated design the deopt is created by the codegen pass, reconstructing the write-back from the analysis-precomputed placement at that point; the typed IR records the deopt program point (pc), not a frozen AsmDeopt. This is the main wrinkle that distinguishes the FP-load transfers from the simple evictions, and it is where the typed IR must carry per-point placement (which the analysis already tracks).

Resolved (load_fpr split). load_fpr / load_fpr_fixnum now split into a load_fpr_state half (allocate the xmm, bind the slot) returning an FprLoad record (None / FromStack / FromAcc / FromF64 / FromFixnum), plus a wrapper that creates the deopt first (so its write-back snapshot is the pre-load placement) and passes it as Option<AsmDeopt> to FprLoad::emit. The deopt is therefore supplied by the codegen side, not frozen into the record; the guard-free numeric variants pass None. This confirms the resolution above concretely — behaviour-identical at 1703/0.

The guard primitive

guard_class (the guard primitive behind guard_fixnum / load_fixnum / load_array_ty) splits into guard_class_state(slot, class) -> bool (refine the slot’s type; return whether a runtime guard must be emitted) plus the emit if guard_class_state { ir.push(GuardClass(r, class, deopt)) }. load_fixnum and load_array_ty then compose split primitives (load + the guard).

load_fpr_fixnum: the interleaving dissolves (single record after all)

load_fpr_fixnum’s S/G arms looked like the case that could not reduce to a single (state) + (record → emit) pair, because they interleave a load, a new_deopt, a guard and the conversion:

stack2reg(Rdi)            // emit  — load the boxed value
new_deopt                 // deopt
guard_class_state         // state (type)
push GuardClass(deopt)    // emit  — Integer guard
set_new_Sf                // state (placement — allocates the xmm)
fixnum2fpr(Rdi, x)        // emit  — int → f64

The deopt snapshot (get_write_back) must precede the placement change (set_new_Sf). The apparent obstacle was that the load (stack2reg) “must” precede the deopt. But stack2reg/reg2stack are pure emits on ir — they push an AsmInst and never touch the frame’s placement state — so new_deopt commutes with them. Reordered, the dependency chain is just new_deopt → {guard_class_state, set_new_Sf}, the same shape load_fpr already solved: create the deopt up front, run the (now reorderable) state half, defer all emission into the record. The guard folds into the record as a bool (guard_class_state’s verdict). So load_fpr_fixnum splits into load_fpr_fixnum_state -> (FPReg, FprFixnumLoad) plus a wrapper that creates the deopt only for the guarded S/G arms (peeking the mode — use_as_value only marks liveness, so the peek is stable) and supplies it to FprFixnumLoad::emit. Behaviour-identical at 1703/0.

The lesson: a “pure emit” instruction between a state mutation and a new_deopt is not a true interleaving — it commutes out to the emit half. The single-record model is therefore more general than first thought, and with this split every transfer/eviction primitive in the table is now decomposed into a *_state analysis half returning a typed-IR record (Spill / FpXfer / FprLoad / GpLoad / FprFixnumLoad, plus the guard_class_state verdict) and a record-replaying emit half. That completes the prerequisite for the two-pass wiring: the analysis pass calls the *_state halves and collects the records; codegen replays record.emit(...). The remaining step is plumbing those two passes through compile_instruction / analyse_basic_block.

10. The analysis/codegen seam already exists: codegen_mode

Before building a two-pass from scratch, a closer read of the driver shows the seam is already present, which reframes step 2.

What is actually there

AsmIr carries codegen_mode: bool (asmir.rs:76), seeded from JitContext::codegen_mode(). Crucially AsmIr::push is already gated on it:

#![allow(unused)]
fn main() {
fn push(&mut self, inst: AsmInst) {
    if self.codegen_mode { self.inst.push(inst); }   // no-op in analysis mode
}
}

Two passes already run the same compile_instruction under the two modes:

  • Loop-analysis pre-passJitContext::loop_analysis sets codegen_mode: false (context.rs:687). analyse_backedge_fixpointanalyse_basic_block runs the handlers to compute the loop’s back-edge / liveness fix-point. push is suppressed, so no AsmInst is built — it produces abstract state, not an instruction stream.
  • Codegen passtraceir_to_asmircompile_basic_block runs with codegen_mode: true, doing analysis and emission in one fused walk.

Handlers that must diverge between the two modes already branch on self.codegen_mode() (e.g. binop_uncached in binary_op.rs:25 widens to S during analysis but emits a per-instruction deopt+recompile during codegen).

Correction to §9: the claim that the analysis pass “builds AsmInst only to drop them” is wrong — push is gated, so analysis never accumulates the stream. The only residual analysis-mode waste is computing a transfer record and then calling its no-op emit, plus ungated side_exit growth (new_deopt / new_label are not gated, but their results are unused in analysis).

What this means for step 2

The separation is not “introduce an analysis pass” — that exists. It is two remaining, independent pieces:

  1. De-fuse allocation from the dataflow (the §5 crux). Today both passes allocate: alloc_fpr / join’s register reconciliation mutate placement inside the abstract-interpretation walk. So the loop pre-pass is “analysis + allocation with emission suppressed,” not pure type/liveness analysis. The real work is pulling placement out of the join — turning join-time reallocation into SSA-φ edge moves — so the analysis pass computes only Guarded + liveness and the allocator runs as the second pass over that result. This is the high-risk core; codegen quality (the greedy xmm policy) must not regress.
  2. Record-driven lowering (goal 1). Once allocation is its own pass, the codegen walk replays the typed-IR records (Spill / FpXfer / FprLoad / GpLoad / FprFixnumLoad / guard) emitting LInst directly, and AsmInst retires. The transfer-primitive split (now complete) is exactly what makes the replay possible; the open wrinkle is the deopt, which must become a program point reconstructed from the analysis-precomputed placement at that pc rather than the frozen AsmDeopt the records carry today (§9 deopt note).

So the two-mode compile_instruction is the chassis; the remaining engineering is (1) then (2). (1) is the architectural fork — re-execution-based (generalize the codegen_mode pre-pass to all code, feeding precomputed states forward) vs. record-stream-based (collect records, lower from them) — and is the decision to take deliberately, since it sets how allocation is staged.

11. Record collection: the TransferIR stream

The first concrete step of record-driven lowering (chosen over the §5 allocation de-fusing as the lower-risk groundwork): collect the transfer records into a stream so a later pass can replay them.

  • Unified element. The five per-primitive records (Spill, FpXfer, GpLoad, FprLoad, FprFixnumLoad) now share one enum TransferIR (state/read_slot.rs) with a single emit dispatch. The two deopt-carrying variants still freeze an AsmDeopt (lifting it to a program point is §9’s open item, the next wall).
  • One funnel. AsmIr::transfer(t) is the sole sink: it pushes t onto the new transfers: Vec<TransferIR> (codegen mode only, so it stays in lock-step with the codegen_mode-gated inst) and then emits via t.emit(self). Every transfer/eviction wrapper (load, load_fpr, load_fpr_fixnum, write_back_slot, to_S_unguarded, to_sf) now calls ir.transfer(...) instead of record.emit(ir, …).
  • Faithful by construction. The collected t is the record that gets emitted (same value, same call), so the stream is exactly the emitted transfer sequence — no shadow comparison needed; the suite (1703/0) confirms emission is byte-identical. save/restore truncates transfers alongside inst, so the stream survives speculative-emit rollback intact.

The transfers stream is collected but not yet consumed — that is the groundwork. The next steps are (1) lift the deopt to a program point so the stream is codegen-independent, then (2) drive lowering from the stream (replay TransferIRLInst) and retire the corresponding direct AsmInst emission.

Shadow harness: the records are self-contained

A debug-only shadow check in AsmIr::transfer replays each record alone into a fresh scratch AsmIr and asserts it reproduces exactly the AsmInsts the real emit just appended (compared via Debug, since AsmInst is not PartialEq). This proves TransferIR::emit is a pure function of the record — it reads nothing from self/the frame beyond the record’s own payload. That self- containment is precisely what record-driven lowering needs (it will replay the stream with no analysis frame in hand), and the assert is a standing guard against a future transfer whose emit sneaks in a state dependence. All transfer emit helpers are single deterministic pushes, so the check holds for the whole suite (1703/0, debug build, every codegen-mode transfer exercised).

Deopt program-point-ification: the stream is now codegen-independent

The one remaining codegen dependence in the TransferIR stream was the frozen AsmDeopt (an index into the codegen pass’s side_exit table) carried by the guarded FprLoad / FprFixnumLoad records. That index is meaningless without the exact side_exit table it points into — so a standalone lowering pass could not replay the stream.

Resolved as doc §9 foretold: the records now carry a DeoptPoint — the program point (pc, write_back), both pure analysis values the frame already tracks (get_write_back() is the placement snapshot restored on guard failure). The analysis half (load_fpr / load_fpr_fixnum wrappers) records the point via deopt_point() (no side_exit push); the emit half materializes the actual side-exit via AsmIr::deopt_from_point, so side_exit construction lives entirely on the codegen side. new_deopt is no longer called from the transfer wrappers.

Ordering is preserved exactly: within load_fpr{,_fixnum} the only side_exit push was this one deopt, created “first” — deopt_from_point runs at the top of the emit arm, at the same relative position, so the side_exit table is byte-identical. The guarded-but-guard == false S/G arms still materialize the (dead) deopt, matching the pre-split wrapper.

TransferIR is consequently Clone (not Copy — a DeoptPoint owns a WriteBack). The shadow harness was strengthened accordingly: emit is now a pure function of the record and the side_exit cursor (a guarded record materializes AsmDeopt(side_exit.len())), so the replay pre-pads the scratch’s side_exit to the same length and asserts both the produced AsmInsts and the produced SideExits match (Debug-compared; neither is PartialEq). Suite 1703/0, no replay mismatches — the stream is now fully codegen-independent, the last prerequisite for record-driven lowering (step 3).

Analysis pass skips emission (doc §9 step 2, realized)

With the deopt program-point-ified, AsmIr::transfer now returns immediately in analysis mode (codegen_mode == false). The abstract-state mutation already happened in the wrapper’s *_state half; emit only writes to ir, and the loop pre-pass discards its local AsmIr (analyse_basic_block drops it — the loop_analysis context “emits AsmIr only for analysis, it is never codegen’d”, context.rs:692). So emission in analysis mode was pure dead work — and, since new_deopt/deopt_from_point are not push-gated, it also grew a side_exit table nobody reads (the waste §10 flagged).

This is provably safe: emit’s signature, fn emit(self, ir: &mut AsmIr), cannot touch frame/abstract state — the same property the shadow check independently verifies. So the analysis pass now literally “calls the state halves and skips emission” for every transfer primitive, exactly the §9-step-2 shape. The §10 deopt wrinkle (“the open wrinkle is the deopt … frozen AsmDeopt”) is closed: the records carry a DeoptPoint, and the only thing left fusing analysis and codegen for the transfer primitives is gone. Suite 1703/0.

What remains for full record-driven lowering is the §10-item-1 core (de-fuse allocation from the join — the SSA-φ edge-move rewrite) and lowering the op handlers’ direct emissions through records too; the transfer primitives are done.

12. §5 crux, first cut: de-fusing allocation from the join

The §5 crux is that AbstractFrame::join allocates (try_set_new_F / try_set_new_Sf) during the meet — fusing register allocation into the dataflow. First de-fusion step, mirroring the transfer state/emit split:

The per-slot meet table is now split into

  • decide_join(other, i) -> JoinAction — a pure, read-only function of the two predecessors’ LinkModes: the merge decision; and
  • apply_join(i, action) — which performs the placement mutation and is the only place the meet allocates an xmm.

JoinAction reifies the nine meet outcomes (Nop / SetMaybeNone / Discard / TryFreshFKeep / TryFreshFElseS / SetSf / TryFreshSfElseKeep / TryFreshSfElseS / SetS). The fresh-xmm rebinds (TryFresh*) are the join-time reallocation §5 targets; they are now isolated in apply_join, behind one seam. Behaviour is identical (the same operations, reorganized) — suite 1703/0.

This is the structural prerequisite for the real change: an allocator pass that consumes the JoinAction stream and assigns registers + inserts edge moves (bridge already emits the FprMove/swap edge fixups), instead of apply_join allocating inline during the meet. The decide/apply boundary is exactly where that pass plugs in. Not yet done: turning the TryFresh* inline allocations into allocator-assigned φ registers (the SSA-φ edge-move rewrite) — but the meet is now cleanly type-decision (decide_join) vs placement-allocation (apply_join).

§5 stage 1: the merge as a replayable record stream

With the meet split into decide_join / apply_join, stage 1 of the safe allocator de-fusion records the per-slot JoinAction stream as the merge runs, then (debug) replays it from a clone of the pre-merge frame and asserts it reproduces the identical placement (every slot’s resulting LinkMode). This is the allocation analog of the transfer shadow harness: it locks the property the separated allocator pass relies on — the decision stream plus apply_join is a complete, replayable record of the meet — and becomes the regression harness future allocator changes shadow against. Suite 1703/0, no replay mismatches.

Key finding for the allocator design — the meet has cross-slot coupling. A TryFresh* action’s allocation (try_alloc_fpr phase 1) can demote other slots’ Sf bindings to S to free a physical xmm. So a later slot’s decide_join may read a LinkMode that an earlier slot’s apply_join mutated: decide and apply are not separable into two clean passes over the slots — they must interleave. The replay shadow confirms this reproduces faithfully (it replays apply_join in order, demotions included). This rules out the naive “decide-all then allocate-all” staging and tells us the allocator pass must model the pool as evolving across the merge’s slots (a linear-scan-style sweep), not a batch assignment — the constraint that shapes stage 2.

§5 stage 2: type-meet separability is now a standing invariant

Stage 2 promotes doc §6’s once-checked claim — “the fused join’s type result is exactly join_ty for every non-sentinel slot” — to a standing debug assertion in verify_join_replay: after each merge, self.guarded(i) equals the standalone join_ty(pre, other)[i] (the allocation-free Guarded meet) for every non-sentinel slot. Verified arm-by-arm and then across the whole suite (1703/0): every meet arm’s result type is join_ty, because the SfGuarded → Guarded projection is a join homomorphism (FixnumOrFloat ↦ Value, matching join_ty(Fixnum, Float) = Value), so even the Sf arms that look like they refine the type actually agree with the plain Guarded meet.

This nails down the type/placement split at the merge: a standalone type+liveness analysis pass — running join_ty with no xmm allocation — computes types identical to the fused meet, and all allocation is isolated in apply_join. Combined with §5 stage 1 (the merge is a replayable record stream) the merge is now cleanly factored into (a) a separable, allocation-free type meet and (b) a recorded placement-allocation stream. What remains for stage 3 is the behaviour-changing switch — an allocator that assigns φ registers (reusing a predecessor’s where it lowers edge-move cost) instead of apply_join’s inline TryFresh* grab — which is benchmark-gated (codegen quality must not regress) and will diverge from the stage-1 placement shadow by construction.

13. §5 stage 3 design: lifting allocation out of the dataflow (the high-risk core)

Stages 1–2 finished the de-fusion inside the merge: the meet is now decide_join (pure, allocation-free, type result proven == join_ty) + a recorded apply_join placement-allocation stream. Stage 3 is the architectural switch — the §10-item-1 / §4-step-2 core — and it is behaviour-changing and benchmark-gated: the stage-1/2 shadows are necessary scaffolding for it but stop applying the moment placements are allowed to diverge.

13.1 What the bridge investigation changed about the plan

A read of the edge-move machinery (AbstractFrame::bridge, slot.rs:1707; driver gen_bridge, state.rs:89; merge in merge.rs:60–116) settled the key question:

  • Edge moves already exist. The actual φ-reconciliation MOVs/swaps/spills are emitted by bridge, not by the merge. bridge pattern-matches (pred.mode(slot), target.mode(slot)) and has both placements in hand.
  • The merge is commutative and predecessor-blind. decide_join sees only the two LinkModes; it does not know which predecessor carried F(xmm2) vs F(xmm3), nor how many predecessors there are. So a “reuse predecessor p’s register” policy is not expressible at merge time — only the bridge, or a later pass with per-predecessor placement, can express it.

Consequence: the quick “prefer-keep-l instead of grab-fresh” heuristic in the TryFresh* arms is a weak, commutative lever (it biases toward whichever frame happens to be self), not the principled fix. We do not pursue it as stage 3. The principled fix is to move allocation to a pass that runs after the type/liveness fixpoint and can see global/per-predecessor placement — exactly the §3 Layer ② Allocator.

13.2 What stage 3 actually is

Today both compile passes call AbstractFrame::join = decide_join (types) + apply_join (allocation):

  • the analysis pre-pass (loop_analysis, codegen_mode:false, context.rs:687) allocates with emission suppressed — so it is “analysis + allocation,” not pure type/liveness;
  • the codegen pass (codegen_mode:true) allocates and emits in one walk, and bridge turns the per-edge placement deltas into MOVs.

Stage 2 proved the analysis pass does not need apply_join: its type result is exactly join_ty. Stage 3 acts on that:

The analysis pre-pass computes only join_ty + liveness (no apply_join, no xmm allocation). The codegen pass owns all placement/allocation, and the existing bridge already turns the resulting per-edge placement deltas into edge moves.

This is the de-fusion §10 item 1 calls “the high-risk core; codegen quality (the greedy xmm policy) must not regress.”

13.3 Decomposition (re-narrowing the safe regime)

The naive view is “stage 3 is all benchmark-gated.” It is not — one more slice stays shadow-able:

  • 3a — safe: the analysis fixpoint is allocation-independent. The goal was to prove stripping allocation from the analysis pass does not perturb the type + liveness it exists to compute. This splits into two halves:

    • Merge half — already discharged by stage 2. AbstractState::join_entries (state.rs:81) calls AbstractFrame::join, where the stage-2 assertion (self.guarded(i) == join_ty(pre, other)[i]) lives. join_entries is reached from both incoming_context and analyse_backedge_fixpoint (merge.rs:77, 79, 107), and the analysis pre-pass loop_analysis (context.rs:682, codegen_mode:false) drives them. So stage 2 already runs on every analysis-pass merge and every back-edge fixpoint merge; the suite exercises loop JIT and passes 1704/0 with it active. Merge-level type meet is therefore allocation-independent by an assertion that is already live — no duplicate fixpoint harness needed (building one would be disproportionate).
    • Transfer half — by construction, confirmed by the gate. Between merges the fixpoint runs the per-instruction transfer handlers. Their type result is computed from operand Guardeds + IC classes, never from placement (handlers branch on codegen_mode() only to choose emission, e.g. binop_uncached widens to S in analysis — the Guarded it records is the same). A full static shadow of every handler is disproportionate; this half is covered empirically by 3b’s benchmark gate plus the exact CRuby-diff correctness suite (any type-fixpoint perturbation would change output, which the suite catches exactly).

    Net: the safe regime of stage 3 is essentially complete — the merge half is formally asserted (live), the transfer half rests on the handlers’ type/emission split and is confirmed by the gate. The next implementable step is 3b.

  • 3b — benchmark-gated: actually strip allocation from the analysis pass. Make loop_analysis (and any other codegen_mode:false walk) call the type-only meet; let the codegen pass allocate from a type-only loop-entry frame. The final asm will differ (the codegen pass no longer inherits the pre-pass’s placements), so the stage-1 placement shadow is expected to diverge and must be scoped off for the codegen_mode:false path. Gate: §13.4.

  • 3c — benchmark-gated: improve the allocator with its new global view. Only now is “reuse a predecessor’s register / minimise edge moves” expressible, because the allocator pass can see per-predecessor placements (the BranchEntry states, jitgen.rs:114) instead of the commutative merge. Linear scan over the type/liveness result; spill = today’s try_alloc_fpr phase-1 demotion generalised. Each policy change is an independent benchmark-gated diff.

13.4 The benchmark gate

Baseline must be captured before any 3b change, from a --release build (the debug shadows compile out, so they do not affect it):

  1. cargo build --release at the pre-3b commit; record bin/bench numbers and optcarrot fps for the standard set (benchmark/*.rb: app_fib, the binary- trees / so_* set, optcarrot). M1 bin/test already passing is the correctness baseline; the gate adds the speed baseline.
  2. Acceptance for promoting 3b/3c to default: no benchmark regresses beyond noise (≈2 %) vs baseline, and the headline JIT benchmarks (optcarrot, app_fib) are within noise or better. A regression that is real and not quickly recoverable parks the change behind a runtime flag (mirroring --no-jit) rather than flipping the default.
  3. Run on both backends (x86-64 CI + M1 aarch64) before default-flip, since the bridge emits per-arch and the two backends differ in deopt/recompile handling (x86 recompiles-in-place for non-specialized misses; aarch64 deopts + re-JITs).

13.5 Where the existing harness applies / stops

  • Stage-2 type-meet assertion: still valid through 3a/3b — types never depend on allocation, so it keeps guarding the analysis pass after allocation is stripped. Keep it.
  • Stage-1 placement replay shadow: valid until 3b — it asserts the recorded stream reproduces the current placements; once the analysis pass stops allocating, the codegen_mode:false path has no placement stream to replay, so the shadow is scoped to the codegen pass only (or retired). It is not a correctness oracle for 3b’s intended divergence.
  • Net: 3a is covered by shadows; 3b/3c are covered by the benchmark gate plus the full CRuby-diff suite (output correctness is still an exact oracle — only speed/codegen is what the gate watches).

13.6 Open questions / risks

  1. Deopt as a program point. §10 item 2 / §9’s deopt note: once placement is decided in a later pass, a deopt must be reconstructed from the analysis- precomputed placement at that pc, not the frozen AsmDeopt the records carry. 3b can sidestep this only if the codegen pass still decides placement in its own forward walk (it does today) — i.e. 3b strips allocation from analysis but keeps codegen single-walk. Full Layer-② extraction (allocation as a distinct pass feeding codegen) is a later step and is where the deopt-program- point work lands.
  2. Loop-entry placement quality. The pre-pass’s allocation currently seeds the loop body with sensible xmm bindings; a type-only fixpoint hands codegen a placement-free entry, so the codegen pass must pick loop-carried xmm bindings itself. This is the most likely source of a 3b regression (loop bodies are the hot JIT path) and is what the optcarrot/app_fib gate specifically watches.
  3. Phase-1 demotion as spill. The cross-slot demotion (stage-1 finding) is the allocator’s only spill mechanism today; a linear-scan allocator (3c) subsumes it but must preserve the “stack is canonical, dropping the xmm cache needs no asm” property that makes demotion free.

13.7 §5 stage 3b: landed behind a default-off feature

3b is implemented as the loop-type-only-entry cargo feature (default off, so the shipping build is bit-identical). When on, incoming_context strips the analysis pass’s loop-carried backedge frame to a type-only projection (AbstractState::strip_fpr_to_stack: every F/Sf slot → S(guarded)) before target.join(&backedge), so the codegen pass re-derives the loop-entry xmm bindings itself via the liveness pass (liveness_analysisuse_float’s try_set_new_Sf) instead of inheriting them. This is the minimal, reversible lever for “the codegen pass owns allocation; the analysis pass contributes only types + liveness.”

Verified:

  • Default (off): unchanged — the strip is #[cfg]-compiled out; the join takes the placed backedge verbatim.
  • Feature on: correct — suite 1704/0 (stage-1/2 shadows active) and the sample programs match CRuby (249750.0, 1249925000.0).
  • Codegen neutral on the canonical float loopemit-asm for the x += i*0.5 while-loop is identical off vs on: the loop-carried accumulator was already Sf (boxed per iteration) in the baseline, and use_float re-promotes it to the same Sf, so the analysis-pass backedge placement was redundant with liveness-driven promotion here. This is the intended behaviour-preserving result — 3b removes the analysis-pass allocation without regressing this hot path.

What 3b does not do: it does not improve the per-iteration box (keeping the float in an xmm across the back-edge is the 3c allocator-policy change). 3b only establishes that de-fusing analysis-pass allocation is codegen-neutral on the canonical case. Cases where liveness-promotion and the analysis backedge diverge (the regression risk surface) are what the broad benchmark A/B on M1 must probe; build --release twice (with/without the feature) and compare bin/bench + optcarrot before considering a default flip / removing the analysis-pass allocation outright.

13.8 §5 stage 3b benchmark verdict: REGRESSION — the analysis-pass backedge is load-bearing

Corrects §13.7. The canonical x += i*0.5 loop produced identical asm off/on, which I wrongly generalised to “codegen neutral.” The real float-heavy benchmarks say otherwise. M1 bin/bench (iter/sec, higher = faster) and an independent x86-64 wall-clock A/B (seconds, lower = faster) both show 3b regressing:

benchmarkbase3bverdict
mandelbrot (M1, iter/s)24.9039.7162.56× slower
nbody (M1, iter/s)11.35310.184~10% slower
mandelbrot (x86-64, wall-clock)0.792 s1.479 s1.87× slower
fib / aobench / bf / nqueen / sudoku / matmul / bedcovflat

So 3b fails the §13.4 gate (>2% regression on the hot float path). It stays default-off — nothing shipped, and the gate did its job.

The finding (the experiment’s real value). The analysis pre-pass’s backedge placement is load-bearing for float-heavy loops: it captures good loop-carried xmm bindings (floats kept in registers across the back-edge) that liveness-driven re-derivation (use_float) does not recover. The canonical loop was too trivial to show this (its accumulator was already Sf/boxed-per-iteration, so both paths agreed); mandelbrot/nbody carry several live floats across the loop where the fixpoint’s placement genuinely beats a from-scratch use_float pass. This refutes the “backedge placement is redundant with liveness” hypothesis from §13.7.

Consequence for the architecture. De-fusing allocation from the analysis pass cannot simply discard the loop-carried placement and re-derive it from liveness — that information is real and the greedy fixpoint computes it well. The allocator pass (stage 3c / §3 Layer ②) must reconstruct or carry forward at least the quality of the current analysis-pass backedge bindings, i.e. a proper loop-aware allocation (linear-scan with loop-carried liveness), not the liveness-hint promotion use_float does today. The keep-Sf-cache, drop-xmm-free demotion property (stage-1 finding) and the backedge fixpoint together are the bar 3c must clear. Net: 3b is retained as a negative result / regression probe behind its feature flag; the next real step is designing 3c to match-or-beat the backedge, not to replace it with liveness promotion.

14. §5 stage 3c design: a separable allocator that matches the backedge

Stage 3b established the hard constraint: the analysis pre-pass’s loop backedge fixpoint computes loop-carried xmm placements that are load-bearing for tight float loops (mandelbrot 1.9–2.6× slower without them), and a naive liveness-only re-derivation does not recover them. optcarrot was flat base-vs-3b, so the placement quality that matters is localized to tight loops carrying several live floats across the back-edge — that is the regression surface 3c must not touch.

14.1 The realization: loop allocation is already a fixpoint

analyse_backedge_fixpoint (compile/loop_analysis.rs) iterates analyse_loop (≤10 times) until the back-edge AbstractState stops changing (be.equiv). Each iteration runs the per-BB walk with apply_join allocation, so the loop’s placement is the fixed point of the greedy per-merge allocator. The fusion is that this single fixpoint co-evolves types and placements. Stages 2/3a proved the type half is allocation-independent. So the separation is not “remove allocation from the loop” (3b’s mistake) — it is sequence two fixpoints instead of fusing one:

fused today:     one fixpoint over (types + placements)        [analyse_loop ×N]
3c target:       fixpoint-1 over (types + liveness)   — NO placement
                 fixpoint-2 over (placements)         — greedy alloc on fixed types

Because fixpoint-2 runs the same greedy apply_join allocation, just sequenced after type analysis rather than interleaved with it, it converges to the same loop-carried placements — that is what makes the separation behaviour-preserving and shadow-verifiable. 3b failed precisely because it replaced fixpoint-2 with a single use_float liveness hint, not a placement fixpoint.

14.2 Decomposition

  • 3c-i — safe / shadow-able: extract the allocation fixpoint as its own pass. Run a type+liveness-only fixpoint first (the §3 Layer-① analysis; the type meet is already join_ty, the liveness is already separate), then run the greedy allocation fixpoint over the frozen types to produce placements. The allocator is now a distinct, pluggable component running today’s greedy policy. Verify with the stage-1 placement replay shadow that the placements equal the fused result, slot-for-slot, including the backedge. No behaviour change — this is the real Layer-② extraction, and the thing 3b should have been. Next implementable step (behind a feature until the shadow is green across the suite + benches).

  • 3c-ii — benchmark-gated: swap the greedy fixpoint for linear scan. With the allocator extracted, replace the iterate-to-fixpoint greedy policy with a loop-aware linear-scan over live intervals (below). Gate against the mandelbrot/nbody bar (must match-or-beat the backedge) and optcarrot (must stay flat). Each policy change is an independent gated diff.

14.3 3c-ii allocator shape (the linear-scan)

Inputs (all allocation-independent, already computed by Layer ①):

  • per-slot Guarded type at each program point;
  • the representation decision kept separate from placement: “this slot is used as f64” (today’s use_float liveness) decides unboxed-float-ness; the allocator then decides which xmm (or spill) — splitting Sf’s two jobs (mark vs register) per §3 Layer ②;
  • live intervals per slot, with loop-carried intervals (live across the back-edge) flagged so the scan keeps them resident across the whole loop body — this is what reproduces the backedge’s “float stays in xmm across iterations.”

Output: a placement per (slot, point) + edge moves. The edge moves already exist — AbstractFrame::bridge emits the φ-reconciliation MOV/swap/spill from (pred.mode, target.mode); the allocator only chooses the target registers and the bridge lowers them (so the recently-fixed fpr_swap and the F/Sf/S bridge arms are reused unchanged).

Two properties the scan must preserve (both already in the codebase):

  1. Free spill of read-only caches. try_alloc_fpr phase-1 demotes an all-Sf register to S with no asm (stack is canonical). A linear-scan spill of an Sf interval must keep this — spilling a clean float cache costs nothing.
  2. Loop-carried priority. The fixpoint today keeps loop-carried floats in xmm by construction; the scan must give intervals that span the back-edge higher priority than intra-loop temporaries when registers are scarce, or it will reintroduce the 3b regression.

14.4 Hard parts carried over

  • Deopt as a program point (§10 item 2). Once placement is decided in fixpoint-2, a deopt’s register/stack map must be reconstructed from the allocator’s result at that pc, not the frozen AsmDeopt the transfer records carry today. 3c-i sidesteps this only if fixpoint-2 still emits in a forward walk that knows placements at each pc (it does, today). Full record-driven lowering (goal 1) is where the deopt-program-point work lands.
  • Representation vs placement split. Today Sf/F/S bundle “unboxed?” with “which register?”. 3c separates them: liveness marks unboxed-float slots; the allocator assigns registers. The typed IR carries the mark, not the register.

14.5 Why this is the right order

3c-i is the behaviour-preserving separation the whole §5 effort has been building toward (decide/apply split, record stream, type-meet invariant all feed it), and it is shadow-verifiable against the fused result. 3c-ii is the only step that may regress, and it is gated on the exact benchmarks 3b flagged. 3b is retained as the negative-result probe that calibrated the bar: any allocator that cannot match the backedge on mandelbrot/nbody is not ready to land.

14.6 The 3b regression, diagnosed at the asm level (what 3c-ii must reproduce)

Diffing the JIT asm of the mandelbrot kernel (for dummy in 0..ITER with several live floats) base-vs-3b pins the mechanism exactly. do_it grows from 340 → 412 instructions (+21%) under 3b. The delta is not the back-edge box (both box the same loop-carried results); it is the operand loads inside the loop body:

  • base keeps the loop-invariant / loop-carried floats (cr, ci, …) resident in xmm across the loop, so each use is a direct movq xmmA,xmmCr; mulsd …:
    movq xmm9,xmm2 ; mulsd xmm9,xmm2      # cr already in xmm2
    
  • 3b demotes them to their boxed stack home, so every use re-loads and re-decodes the flonum (~10 insts: tag tests + the sar/add/and/or/rol/movq flonum-decode) before the mulsd:
    mov rdi,[rbp-N]; test rdi,1; jne…; test rdi,2; je…; …; rol rdi,0x3d; movq xmm2,rdi
    movq xmm3,xmm2 ; mulsd xmm3,xmm2
    

Why use_float does not recover it: the inner loop has more simultaneously-live floats than the xmm pool, so the per-entry best-effort try_set_new_Sf promotion loses the race for some of them and they stay boxed — re-decoded every use. The backedge fixpoint instead converges on a stable assignment that keeps the hot floats resident. So the missing quality is not the Sf mark (liveness has it) but the spill choice under pressure.

Spec for 3c-ii, made concrete. The loop-aware linear scan must keep floats whose live interval spans the loop body (loop-invariant operands and loop-carried accumulators) resident in xmm with priority over intra-iteration temporaries, i.e. choose spill victims by furthest next-use across the whole loop — exactly what the backedge fixpoint approximates and what greedy per-entry use_float does not. This is the property §14.3 named, now backed by the asm: optcarrot stayed flat because its hot loops do not exceed the float pool, so the spill choice never bites; mandelbrot/nbody do, so it dominates.

14.7 Negative result: the cheap spill-policy lever does not fix 3b

Tested the simplest 3c-ii lever directly: make use_float’s promotion spill an unboxed float (set_new_Sf → VirtFPReg) instead of leaving it boxed in S when the physical pool is full. No-op — the mandelbrot kernel’s JIT asm was byte-identical to plain 3b (412 insts, same box count). So try_alloc_fpr was already succeeding for the slots use_float touches; the boxed-operand decodes that cause the regression do not originate from use_float’s best-effort fallback. Reverted.

Two refinements this pins down:

  1. The regression is specific to for…in loops. A while-loop float kernel (zr/zi loop-carried) produces identical asm base-vs-3b — no regression. mandelbrot/nbody use for…in (inlined, multiple loop_starts within one iseq); that is where the placement divergence lives. The earlier “simple float loop is codegen-neutral” (§13.7) was right and misleading: the neutral case is the while loop; the for…in case is where 3b loses.
  2. It is not a local heuristic miss. A point fix to the promotion policy cannot recover it, because the boxed operands are not the ones the promotion pass decides. The analysis-pass backedge fixpoint reaches a globally-consistent loop placement that a single forward use_float pass over a stripped (all-S) entry simply does not reconstruct.

Conclusion — abandon “strip + re-derive” (3b) as the separation mechanism. 3b’s value was diagnostic (it calibrated the bar and proved the backedge is load-bearing). The behaviour-preserving separation is §14.2’s 3c-i: extract the existing allocation fixpoint as its own pass, unchanged, so the placements are identical to today (shadow-verified, zero regression) — not stripped and re-derived. A better policy (3c-ii linear scan) comes only after that seam exists and is benchmark-gated. The loop-type-only-entry feature stays as the negative-result probe.

15. §5 stage 3c-i implementation plan: extract the allocator, unchanged

The behaviour-preserving separation (per §14.7’s conclusion). Surface map of where the JIT allocates an xmm today (outside the merge, which is already decide/apply split):

  • operand loads: load_fpr / load_fpr_fixnum / load_binary_fpr / fetch_float_assume (state/read_slot.rs, compile/binary_op.rs) — allocate an xmm for an operand and emit the load (the per-use flonum decode seen in §14.6);
  • destination defs: def_F / def_Sf_float (compile/binary_op.rs, method_call.rs, variables.rs, compile.rs);
  • the merge: apply_join’s TryFresh* (already isolated, §5 stage 1).

Every one of these funnels through two primitivesSlotState::try_alloc_fpr (phase-0 vacant / phase-1 Sf-demote) and alloc_fpr (+ phase-2 spill). So those two are the universal allocation seam, and the loop-aware spill-victim choice that 3c-ii needs (demote/spill by furthest next-use across the loop, §14.3) lives exactly in phase-1 of try_alloc_fpr.

Increment sequence (each behaviour-identical, suite + stage-1 shadow verified):

  1. Extract the register-selection policy into a named alloc_policy unit: try_alloc_fpr / alloc_fpr move out of the SlotState impl into a child module taking &mut SlotState; the methods delegate. No field, no dispatch yet — the seam is the module boundary. This increment.
  2. Thread an AllocCtx (the live-interval / loop-membership info 3c-ii’s victim choice needs) into the policy, computed by the existing liveness pass. Default greedy ignores it → identical placements.
  3. Add the loop-aware policy (3c-ii) behind the seam: phase-1 picks the victim with the furthest next-use instead of the first all-Sf register; gated on mandelbrot/nbody (match-or-beat backedge) + optcarrot (flat).

The full Layer-② “type-only fixpoint then allocation pass” (un-welding the type computation from the operand-load/def handlers above) is the larger, later arc; 3c-i increments 1–3 deliver the swappable allocator and the measured win first, since that is where the §14.6 regression and the latent base-case back-edge boxing both live.

15.1 Two no-op experiments locate the lever: it is the merge, not the allocator

Increment 1 gave a clean alloc_policy seam, but two targeted experiments behind it both came back byte-identical on the mandelbrot kernel:

  • use_float spill fallback (§14.7): promote a pool-full float to an unboxed spill (set_new_Sf) instead of leaving it boxed S. No-op.
  • liveness-aware-spill: in try_alloc_fpr phase 1, prefer demoting a clean Sf register whose slots are dead over one still live (using the IsUsed liveness SlotState already carries). No-op (340→340 insts, same decode count).

Two independent per-call allocation levers changing nothing means the per-iteration boxing of loop-carried floats is not decided in alloc_policy or use_float. It is decided at the loop-header mergedecide_join / apply_join choose the loop-carried float’s mode (F pure-xmm vs Sf/S boxed-cache), and that is what the body inherits and re-decodes each iteration (§14.6). The allocator only places what the merge already decided to keep unboxed; it never gets the chance to keep a value the merge boxed.

Redirect for 3c-ii. The lever is the loop-header join’s float placement: why the meet demotes a loop-carried F to Sf/S instead of keeping it F. That is in the already-split decide_join table (the F/F, F/Sf, F/S arms) — and it governs both the 3b regression and the latent base-case back-edge box. Increment 1’s alloc_policy seam stays as valid structural cleanup, but 3c-ii’s measured win must come from the join arms, not the spill policy. The next concrete step is to read the loop-header join decision for a loop-carried float and determine whether keeping it F across the back-edge (no boxed cache) is sound and cheaper.

15.2 jit-debug confirms: loop-carried floats can be F; pressure forces S

jit-debug on the float kernel shows the same loop compiled two ways:

  • one specialization keeps the loop-carried floats F(FPReg0) / F(FPReg1) — pure xmm, no per-iteration box, back-edge is an xmm move;
  • another puts them in S(Value) (boxed) while the loop-invariant operands take the physical pool as Sf — so the loop-carried values lose the pool and box every iteration.

So F for a loop-carried float is achievable and is the good outcome. The S fallback comes from the loop-header join’s C/F arm TryFreshFElseS (join.rs:228) and the F/F arm TryFreshFKeep: both try a fresh/kept xmm and fall back to S only when no physical xmm is free. The per-iteration box is exactly that fallback firing under register pressure — the loop-carried value losing the pool to other live values.

This closes the diagnosis loop: every lever (use_float promotion, phase-1 spill victim, join arm) ultimately bottoms out at the same thing — which live values hold the physical pool across the loop. Today that is decided greedily in allocation order; the loop-carried/invariant floats must instead win the pool over short-lived temporaries. There is no cheaper intermediate fix (three no-op probes confirm it). 3c-ii is therefore necessarily the loop-aware linear scan over live intervals of §14.3: rank pool occupancy by interval length / loop membership, not allocation order. The seam (incr. 1) and the calibrated bar (mandelbrot/nbody regress, optcarrot flat) are in place; what remains is the interval analysis + the priority allocator, a substantial standalone implementation.

15.3 CORRECTION: it is not register pressure — the loop-entry merge discards the fixpoint’s F

§15.2’s “register pressure forces the S box” is wrong. Re-verified facts:

  • No pressure. The float kernel uses 2 of 14 physical xmm; base mandelbrot uses 10 of 14. The pool is never exhausted.
  • Spilling is unboxed by design. alloc_fpr phase-2 (push_spill) hands back a VirtFPReg that lives on the stack as a raw f64 (movsd), never a boxed Value. A spilled float is not re-decoded. So boxing ≠ spilling.

The actual mechanism, from jit-debug on the kernel loop:

fixed: 1 { … [%3(zr): F(FPReg4)] [%4(zi): F(FPReg6)] … }   ← backedge fixpoint: F (good)
target:  { … [%3(zr): S(Value)]  [%4(zi): S(Value)]  … }   ← codegen loop-entry: S (boxed!)

The back-edge fixpoint already computes the loop-carried floats as F (pure xmm) — the right answer. But codegen’s loop-entry target is incoming.join(backedge) (merge.rs), and the forward entry (incoming, the first loop entry from outside) holds the loop-carried float as S — the boxed initial value (zr = 0.0 materialised boxed). decide_join has no S/F arm, so (S, F) falls to the default _ => SetSS. The merge therefore discards the fixpoint’s F and collapses the whole loop body to boxed, decoding+re-boxing the loop-carried float every iteration — with 12 xmm sitting free.

So the lever is neither the allocator, the spill policy, nor register pressure: it is the loop-entry merge letting the forward entry’s boxed initial value win over the back-edge’s unboxed steady-state placement. The fix is to make the loop header adopt the back-edge’s F/Sf placement for loop-carried floats (a one-time unbox of the forward entry at the pre-header bridge, which the bridge’s S -> F/Sf arms already emit) instead of SetS. This is a loop-header-local change to how incoming_context builds the target, not a new allocator — and it fixes the latent base-case box, not just the 3b regression. (The earlier no-op probes were no-ops precisely because they targeted the allocator/promotion, while the value was being boxed by the merge upstream of them.)

15.4 Prototype result: the box CAN be eliminated, but it is blocked by a TYPE loss, not placement

Prototyped the §15.3 fix (loop-keep-float): a new S -> F (and Sf -> F) bridge arm + keep_backedge_floats, which re-adopts the back-edge fixpoint’s F for loop-carried floats the loop-entry merge collapsed.

  • Concept proven (x86-64). With an unguarded promotion, the mandelbrot do_it loses all boxing: call float_to_value count 16 → 0, the inner-loop body becomes pure xmm (movq xmm,xmm; mulsd), and the per-iteration flonum-decode moves to a single pre-header unbox. The optimisation is real.
  • But it is unsound as written, and that exposed the actual blocker. The loop-carried float is typed S(Value) at the loop-entry merge, not S(Float) (verified in jit-debug: the pre-header forward entry holds %3: S(Value) even though it is zr = 0.0). The fixpoint correctly has it as F (Float); the codegen merge SetS(join(Value, Float)) = Value degrades it. Forcing F on a Value-typed slot then panics at the C(non-float) -> F bridge (a different slot that genuinely is a non-float const in some path) — and would be silently wrong for any slot that is actually sometimes non-float, since an F slot carries no runtime guard.
  • The sound guard (guarded == Float) makes it a no-op, because the loop-carried floats are Value-typed: suite 1704/0, but mandelbrot is byte- identical to base (340/16). The placement fix has nothing to act on until the type is Float.

So the lever is the type analysis, not placement or allocation. A loop-carried pure float (zr = 0.0 then float arithmetic) is typed Value at the loop-entry merge instead of Float; fix that precision loss and the (already-prototyped, sound) guarded == Float promotion fires and removes the box — on the shipping build, not just under 3b. This also finally explains the whole §15 thread: every allocator/placement lever was downstream of a value the type meet had already widened to Value. The loop-keep-float feature (default off, 1704/0) and the S -> F / Sf -> F bridge arms are kept as the staging ground; the next step is the loop-carried-float type precision fix. (aarch64 unverified here — no cross toolchain in this container; the new bridge arms reuse float_to_fpr / fpr_move, which both backends already lower, so they are arch-neutral by construction, but this needs an M1 bin/test to confirm.)

15.5 Root cause + sound fix: loop-JIT conservative entry typing

The “type loss” is not a literal-handling bug. jit-debug on a loop-JIT (start:[:loop_start]) shows the loop-entry forward state has every local as S(Value) — because a loop JIT does not see the values produced before the loop (x = 0.0 ran in the VM). So a loop-carried float necessarily enters from the VM as a conservative boxed S(Value), even though the back-edge fixpoint proves it is a Float (F). The merge join(S(Value), F)S(Value) then forces the body to decode+rebox it every iteration (§15.3/§15.4).

Sound fix (loop-keep-float, suite 1704/0, default-off). At the loop header, adopt the back-edge fixpoint’s F for such a slot. The forward entry is unboxed once at the pre-header by the new S -> F bridge arm, whose float_to_fpr carries the runtime float guard (deopt if the VM value is not a float) — so the specialization is sound for a runtime value. Soundness across all predecessors is enforced by keep_backedge_floats’s promotable(i) gate: promote only when every predecessor entry has a valid _ -> F bridge (F/S/Sf/float-C); a non-float-C path is genuinely not a float, so it is left boxed (this is the gate the earlier guarded == Float over-approximated, which made it a no-op — §15.4).

Result on the mandelbrot kernel: call float_to_value 16 → 0, the hot inner loop becomes pure xmm, and the per-iteration flonum decode collapses to a single guarded pre-header unbox. Correct on the whole suite (1704/0), including the fpr_swap/bridge regression cases. Static do_it grows 340 → 450 (the decodes move to the per-loop-entry pre-headers), so the win is dynamic (hot loop) — to be confirmed by an M1 --release bench A/B (and aarch64, which reuses the same float_to_fpr/fpr_move AsmIR ops). This is the first measured improvement over base in the §5 line, and it lands as a guarded loop-entry type specialization — the same shape YJIT uses — rather than a new allocator.

15.6 Confirmed on both arches

M1 (bin/bench, i/s) base vs loop-keep-float: mandelbrot 24.326 → 27.372 (+12.5 %), everything else flat (fib/nbody/aobench/bf/nqueen/sudoku within noise). Matches the x86-64 local --release result (mandelbrot ~0.80 s → ~0.70 s, ~12 %). So the guarded loop-entry float specialization is a real, arch-neutral win (the S -> F / Sf -> F bridge arms reuse float_to_fpr / fpr_move, which both backends already lower — confirmed on aarch64). fib -1.6 % is noise: fib has no float loop, so keep_backedge_floats never fires and its codegen is byte-identical.

Remaining before default-on: a full bin/bench incl. optcarrot (headline) and the other float loops (matmul/bedcov, which may also improve), confirming no regression; then flip the cargo default to include the feature (and fold the S -> F / Sf -> F bridge arms in unconditionally, as they are general-purpose).

15.7 Landed: default-on

Bench gate cleared on both arches, so the loop-entry float specialization is now default (no feature flag): mandelbrot +12.5 %, matmul +2.4 %, optcarrot 184.8 → 186.2 fps (checksum unchanged, 59662), everything else flat, no regressions; suite 1705/0, mandelbrot do_it call float_to_value 0 in the default build. keep_backedge_floats + the predecessor-gated promotion in incoming_context, and the S -> F / Sf -> F bridge arms, are now unconditional. The two experimental features (loop-keep-float, loop-type-only-entry) and the dead strip_fpr_to_stack probe are removed; loop-type-only-entry’s lesson (the analysis-pass backedge is load-bearing — a naive type-only strip regresses 2.5×) is retained in §13–14 as the calibration that led here. Added test_loop_carried_float_kept_unboxed.

15.8 §5 stage 3c-i increment 2: the allocator consults an explicit AllocCtx

Increment 1 (§15, commit 37ac994) extracted the two universal xmm-allocation primitives into the alloc_policy module. Increment 2 takes the next planned step: thread an explicit AllocCtx into the policy so the spill-victim decision consults a named analysis-facts input instead of reaching into the fused SlotState ad hoc — the structural shape the Layer-② allocator needs.

Concretely, try_alloc_fpr phase 1 (“demote the first xmm whose linked slots are all Sf”) is refactored from a for 0..len { … return first } scan into

#![allow(unused)]
fn main() {
candidates.min_by_key(|&xmm| ctx.victim_rank(xmm))
}

The default AllocCtx::victim_rank is the physical-pool index, so min_by_key selects the same lowest-index register the prior scan returned — placements are byte-identical. Phase 0 (vacant) and phase 2 (spill) are policy-invariant and unchanged. This is exactly the seam 3c-ii’s loop-aware policy plugs in: victim_rank becomes a furthest-next-use / non-loop-carried key fed by the live-interval + loop-membership fields AllocCtx will carry, and no allocation call site changes (operand loads, defs, and the merge’s apply_join all funnel through set_new_*/try_set_new_* → these two primitives).

Verified behaviour-identical. Built under stress-spill-pool (forces PHYS_FPR_POOL to 2, so almost every Float-resident slot is driven through the phase-1 demote path this refactor touches) and ran the lib suite with and without the change: the pass/fail set is identical — 1671 passed; the 34 failures are the pre-existing environment mismatches (CRuby version / timezone / missing bigdecimal gem), present in both runs, the only diff being the wall-clock line. So the refactor exercises the touched path under maximal pressure and does not perturb a single placement.

Honest scope note. §15.1’s two no-op probes already established that a per-call spill-victim change is neutral on mandelbrot — the per-iteration box was the merge discarding the fixpoint’s F (fixed in §15.5–15.7), not the allocator’s victim order. So increment 3’s measured win is likely small or subsumed by the shipped merge fix; the value of increments 2–3 is the structural separation (a swappable allocator fed by an explicit analysis-facts input, decoupled from the placement state), not a fresh benchmark delta. The remaining headline §5 arc stays the larger Layer-② extraction (§4 step 2): a type-only + liveness fixpoint feeding a distinct allocation/lowering pass, which is where “abstract interpretation + fixpoint search” is finally, fully separated from physical register allocation.

15.9 Closing the allocator-policy axis: phase 1 already protects every F

A precise reading of try_alloc_fpr (the universal allocation seam, §15) settles why every spill-victim probe in this thread (§14.7, §15.1’s two no-ops) came back neutral — and retires the 3c-ii “loop-aware victim” line as a performance lever:

  • Phase 1 demotes only an all-Sf register. It scans for an xmm whose linked slots are all Sf (Integer-def’d / Float-use’d, kept coerced — the stack already holds the canonical boxed value), demotes them to S, and reuses the freed xmm. The demote emits no asm (the stack is canonical) and the value reloads lazily on its next float use. An xmm holding any F slot is skipped.
  • An F (pure unboxed float) therefore never loses its xmm to phase 1, and when the pool is genuinely full, phase 2 (push_spill) hands back a VirtFPReg that lives on the stack as a raw f64 (movsd), still unboxed (§15.3). So no allocation decision ever boxes an F.

Consequently the only freedom the victim policy has is which already-Sf cache to drop — a free, reversible, lazily-reloaded choice among loop-invariant coerced operands. That cannot change the count of per-iteration boxes, which is why §15.1’s dead-vs-live probe and §14.7’s spill-fallback were both byte-for-byte no-ops. The per-iteration box was always upstream — the merge deciding a loop-carried value’s representation (F vs Sf vs S), fixed in §15.5–15.7 by adopting the back-edge’s F at the loop header.

Verdict. The allocator-policy axis (3c-i increment 3 / 3c-ii furthest-next-use) is closed as a performance lever: it is provably neutral by the phase-1 all-Sf restriction. The AllocCtx seam (increment 2) is retained, but its justification is corrected: it exists for goal 3 (§3 Layer ②) — plugging in a different allocation strategy, namely the VM-residual allocator’s fixed “no pool, every value in its stack home” convention — not a better JIT victim rank.

Where the §5 work goes from here. With the merge-representation lever shipped (§15.7) and the allocator-policy lever shown neutral (this section), the remaining separation is purely structural, not perf-seeking: the Layer-② extraction (§4 step 2) — run the Guarded + liveness fixpoint as a standalone, allocation-free pass producing a typed IR, then a distinct allocation/lowering pass consumes it. That is the last place “abstract interpretation + fixpoint search” and “physical register allocation” remain interleaved (in the single forward codegen walk). It is behaviour-preserving by intent (same final placements) but a large structural change, and the one remaining benchmark-gated risk is the loop-entry placement quality the analysis pre-pass currently seeds (§13.8) — now partly de-risked because §15.7’s keep_backedge_floats already reconstructs the load-bearing loop-carried-F placement at the loop header from the back-edge frame.

16. Layer-② extraction: the concrete increment plan

§15 closed the performance line: the merge-representation lever shipped (§15.7) and the allocator-policy lever is provably neutral (§15.9). What remains is the structural separation goal — §4 step 2 / §3 Layer ② — and it is now the only place “abstract interpretation + fixpoint search” and “physical register allocation” are still interleaved: the single forward codegen walk, and the loop analysis pre-pass that allocates while it computes types + liveness.

16.1 The target and the blocker

Target. The analysis pass computes types + liveness only (no xmm allocation), producing a typed IR; a distinct allocation/lowering pass consumes it. The fixpoint searches over Guarded types (stage-2-proven allocation-independent); placement becomes a separate layer.

Blocker (why 3b regressed 2.5×, §13.8). The analysis pre-pass’s allocation is not dead — it computes the loop-carried placement (the back-edge frame), which the codegen pass consumes in two places:

  • (a) float adoptionkeep_backedge_floats reads backedge.mode(i) == F (§15.7);
  • (b) placement reconciliationtarget.join(backedge) folds the back-edge placement into the loop-entry target (the φ/edge-move seed).

Naively stripping the allocation (3b) forced codegen to re-derive (a)+(b) from liveness alone, which is worse — hence the regression.

16.2 Strategy: decouple the consumers, then strip

Reroute each consumer of the analysis-pass placement to read the analysis-pass types + liveness instead (both allocation-free, stage-2-proven). When every consumer reads only types+liveness, the analysis-pass allocation has no consumer and can be removed — at which point the analysis pass is the pure Layer-① pass.

IncrementChangeRisk / gate
L2-0Split keep_backedge_floats into mechanism + a caller-supplied adoption policy (adopt(i)). Default policy = placement-based (mode == F), byte-identical.none (behaviour-preserving; suite)
L2-1Swap the adoption policy (a) to type + liveness: adopt F when the back-edge type is Float and the slot is used-as-float in the loop (Liveness::loop_used_as_float), instead of reading mode == F. Decouples consumer (a) from the analysis-pass placement.benchmark-gated (default-off flag → M1 bench → flip); §13.4
L2-2Decouple consumer (b): reconstruct the loop-carried xmm bindings in the codegen pass from types + liveness (a loop-aware allocation that matches the fixpoint’s quality — the 3b regression surface). This is where the real linear-scan / loop-aware allocation lives; §15.9 (phase-1 protects every F) + L2-1’s typed float adoption are the tools that make it tractable now.benchmark-gated; high
L2-3With (a)+(b) reading only types+liveness, make analyse_loop type + liveness only (drop apply_join allocation and the handlers’ def_F/load_fpr placement). It returns (Liveness, backedge_types) — a pure typed IR. Deopt-as-program-point (§13.6) lands here.benchmark-gated; high
L2-4Standalone allocation/lowering pass emitting LInst (goal 1); add the VM-residual fixed-convention allocator (goal 3, the reason the AllocCtx seam exists — §15.9).research

Each increment is correctness-verified by the exact CRuby-diff suite here; the behaviour-changing ones (L2-1/2/3) are benchmark-gated on M1 (mandelbrot / nbody / optcarrot, no >2 % regression, §13.4) before any default flip. The order keeps the high-risk placement reconstruction (L2-2) behind the cheap, well-understood float decoupling (L2-1), and the irreversible analysis-pass strip (L2-3) last.

16.3 L2-0 landed (this step)

keep_backedge_floats no longer hard-codes the adoption condition: it takes an adopt: impl Fn(SlotId) -> bool policy from the caller and applies the mechanism (“adopt F for a loop-carried slot the boxed loop-entry left S/Sf, when a physical xmm is free”). incoming_context supplies the current placement-based policy (be.mode(i) == F), so the result is byte-identical — but the representation decision is now a named, swappable policy at the call site, exactly the Layer-② seam L2-1 plugs the type+liveness policy into. Suite green.

16.4 L2-1 landed behind layer2-float-by-type (benchmark probe + a design finding)

L2-1 swaps consumer (a)’s adoption policy from placement (be.mode(i) == F) to the allocation-free type + liveness signal (be.is_float_typed(i) ∧ the slot is in Liveness::loop_used_as_float), behind a default-off feature so the shipping build stays byte-identical.

Verified:

  • Default (off): byte-identical — the type policy is #[cfg]-compiled out.
  • Feature on: correct — full lib suite identical to baseline (1671 passed; the 34 failures are the pre-existing env mismatches), so the CRuby-diff oracle holds: the type policy never produces a wrong result.
  • It is not a no-opemit-asm on the canonical loop-carried-float kernel (x = x*1.5 + i*0.5; y = y - x*0.25) differs (code 347 → 379 bytes, ~176 normalised asm lines). Both keep the hot loop body at zero boxing (float_to_value count 0 either way — §15.7 already won that); the diff is entirely in the pre-header.

Design finding (why L2-1 is a probe, not an obvious win). The type+liveness policy promotes the supersetFloat-typed ∧ used-as-float,” whereas the placement policy promotes exactly the floats the back-edge fixpoint chose to keep in F. On the no-pressure kernel the superset is strictly larger, so L2-1 adds pre-header unboxes that buy nothing in the body — a mild regression risk. In other words, consumer (a) has the same load-bearing-placement property §13.8 found for consumer (b): the fixpoint’s F-selection is real information, and a naive type+liveness re-derivation over-approximates it. try_set_new_F’s self-limiting (promote only when a physical xmm is free) bounds the damage but does not restore the selectivity.

Consequence for the plan. A quality-preserving consumer-(a) decoupling must carry the fixpoint’s per-slot F-preference forward as an explicit allocation-free annotation on the typed IR (a derived bit computed during the fixpoint), not re-derive it from type ∧ liveness. That annotation is the same object L2-2 needs for consumer (b) — so L2-1 and L2-2 share one missing piece: a loop-carried-F preference set produced by the analysis pass as typed-IR metadata (distinct from the live xmm placement). L2-1 stays a default-off benchmark probe (mirroring loop-type-only-entry in §13.8) until the M1 mandelbrot / nbody / optcarrot A/B says whether the superset is neutral in practice; the likely outcome, per this finding, is that the next real increment is the F-preference annotation, after which both consumers decouple cleanly.

16.5 Sharpening §16.4: the F-selection is allocation — the real fork

§16.4 floated carrying the fixpoint’s F-preference forward as an “allocation-free annotation (a bit computed during the fixpoint).” That phrasing is imprecise and worth correcting, because it changes what L2-1’s bench actually decides.

Two facts settle it:

  1. Consumer (a) already reads the analysis output, not the live codegen placement. incoming_context derives the adoption set from loop_info(bbid)’s stored back-edge frame (be.mode(i) == F) — the analysis pre-pass’s result, cloned into backedge_for_floats. So the dependency we are trying to remove is specifically on the back-edge frame’s F-placement.
  2. That F-placement is produced by allocation, and §13.8 proved the selection is load-bearing (liveness re-derivation regresses 2.5×). The fixpoint chooses which Float-typed, used-as-float slots win the limited pool — and that choice is a register-allocation decision, not a type/liveness fact.

So a “bit computed during the fixpoint” is just be.mode(i) == F renamed: it still requires the analysis pass to allocate. There is no allocation-free annotation that reproduces the selection byte-for-byte — the selection is allocation.

The real fork L2-1’s M1 bench decides:

  • (i) Approximate, allocation-free — accept L2-1’s type ∧ liveness superset (and let try_set_new_F’s self-limit bound the over-promotion). If the mandelbrot / nbody / optcarrot A/B is within noise, this is the consumer-(a) decoupling: the analysis pass no longer needs to allocate for consumer (a).
  • (ii) Exact, allocation-bearing — if L2-1 regresses (the §16.4 over-promotion finding predicts a mild one), the F-selection genuinely needs allocation quality, so consumer (a) cannot be decoupled in isolation. It folds into L2-2: the codegen-side loop-aware allocator reproduces the fixpoint’s selection (a real linear scan over the loop’s live intervals), and that pass owns the F choice for both consumers (a) and (b) at once.

Either way the next concrete action is the L2-1 A/B on M1; its result picks (i) vs (ii) and is the first hard data on whether the loop-carried F-selection can be made allocation-free at all. (This supersedes §16.4’s “shared F-preference annotation” as the immediate next step — there is no such free annotation; there is a bench that tells us whether we need the L2-2 allocator.)

16.6 L2-1 bench verdict (x86-64): REGRESSION — path (ii) confirmed

x86-64 --release wall-clock A/B (default vs layer2-float-by-type, steady-state median of 6/5 runs, seconds, lower = faster):

benchmarkdefaultL2-1verdict
so_mandelbrot~0.181~0.189~4 % slower
so_nbody~0.254~0.260~2.4 % slower
app_fib (no float loop)~0.154~0.150flat (noise; keep_backedge_floats never fires)

L2-1 fails the §13.4 gate (>2 %) on both float loops — exactly the §16.4 over-promotion prediction, now measured. So §16.5’s fork resolves to (ii): the loop-carried F-selection genuinely needs allocation quality; the allocation-free type ∧ liveness superset cannot reproduce it (it promotes loop-invariant / fixpoint-rejected floats, adding pre-header unboxes that cost without body benefit). L2-1 stays default-off as a negative-result probe (mirroring loop-type-only-entry, §13.8), and keep_backedge_floats’s L2-0 mechanism/policy split is retained as the clean seam.

What this proves. This is the second empirical confirmation — after §13.8 for consumer (b) — that the greedy fixpoint’s loop-carried-F selection is load-bearing and not reproducible allocation-free. Both consumers (a) and (b) need it. So decoupling them is not “read types instead of placement”; it requires a codegen-side loop-aware allocator that reproduces the fixpoint’s selection (L2-2). And critically, since the fixpoint’s greedy selection is already good (§15.9: it never boxes an F; §15.7 shipped the merge win), L2-2 is parity at best on perf — its sole payoff is the goal-3 enabler (a swappable allocator for VM-residual codegen / the unified DSL), at real reimplementation risk (it must match the greedy fixpoint byte-for-byte on the hot float loops or regress).

Strategic state of the §5 line. The perf wins are shipped (§15.7) and the two remaining fusion points (consumer (a) here, consumer (b) §13.8) are both proven to need allocation quality. The separation is therefore complete as far as it pays for itself: what remains (L2-2/L2-3 — reimplement the greedy loop-carried selection as a standalone allocation pass, then strip the analysis-pass allocation) is a large, perf-neutral, regression-risky refactor whose only return is the research-grade goal-3. That is a deliberate investment decision, not an incremental win — recorded here so the call is explicit rather than drifted into.

17. L2-2 design: the swappable allocator for goal 3 (the right scoping)

The user chose to invest in L2-2 for goal 3 (VM-residual codegen / the unified interpreter-JIT DSL). The first design task is scoping it correctly, because the naïve scope hits the §16.6 wall and goal 3 does not require crossing it.

17.1 Key reframing: goal 3 does not need to reproduce the fixpoint’s selection

§16.6 proved that reproducing the JIT fixpoint’s loop-carried-F selection allocation-free is hard (it is greedy/emergent) and perf-neutral. But that is the requirement of §4-step-2 for the JIT (eliminate the JIT’s own fixpoint), which is not goal 3. Goal 3 needs a swappable allocation strategy so a different policy plugs in; the JIT keeps its fixpoint as the default strategy. Two strategies:

  • JitGreedy (default) — today’s behaviour: the xmm pool + the greedy fixpoint selection (F/Sf where profitable, S otherwise). Must stay byte-identical.
  • VmResidual — the VM’s fixed convention: no pool, no unboxed representation; every value lives boxed in its stack home (S). There is no selection problem here — it is the trivial “always S” policy. This is the residual the partial-evaluator emits for the VM (⊤ types, no IC narrowing).

So L2-2 is not “reimplement the fixpoint’s selection.” It is “thread an allocation strategy through the representation/placement decisions; default JitGreedy (byte-identical); add VmResidual.” The §16.6 reproduction problem is sidestepped — the JIT keeps its fixpoint.

17.2 Where the strategy must be consulted

VmResidual is a representation-level decision, not just a pool-size knob: it must prevent any F/Sf from being created, so every value stays S. The sites that create an unboxed float representation, and what each does under VmResidual:

SiteJitGreedy (today)VmResidual
try_set_new_F / try_set_new_Sfallocate xmm if freereturn None → caller keeps S
def_F / def_Sf_float (mandatory)alloc_fpr (pool or spill)must not exist — the float-op handler emits the boxed op (VM-style) instead
use_float (liveness promotion)try_set_new_Sfno-op (skip promotion)
merge apply_join TryFresh* / keep_backedge_floatsallocate / adopt Fskip (stay S)

The try_* and merge sites are easy (they already have a “stay S” fallback). The hard one is def_F: a float binary op currently commits to F and emits xmm arithmetic. Under VmResidual the same handler must emit the boxed path (the VM’s float + float → boxed Float). That is exactly generating VM-equivalent code — the goal-3 payoff — and it touches every float-op handler. So the bulk of L2-2 is giving the float-op handlers a VmResidual lowering, gated so JitGreedy is untouched.

17.3 Increment plan (each JitGreedy-byte-identical; M1-gated)

  • L2-2.1 — define AllocStrategy { JitGreedy, VmResidual } and thread it on AllocCtx (default JitGreedy). Route the easy sites (try_set_new_*, use_float, the merge TryFresh*/keep_backedge_floats) through it: under VmResidual they skip xmm creation. JitGreedy byte-identical. VmResidual not yet constructed (so float-op handlers still def_F — incomplete, but the representation seam exists and is exercised by a unit smoke test).
  • L2-2.2 — give the float-op handlers (binop_float, gen_cmp_float, the unary/def_F consumers) a VmResidual boxed lowering, selected by the strategy. This is the bulk; JitGreedy path unchanged at each.
  • L2-2.3 — goal-3 spike: drive a VmResidual codegen for one float bytecode and validate its output equals the VM’s, end to end.
  • L2-3 (separate, optional, not goal 3) — only if we later want the JIT fixpoint gone: the §16.6 loop-aware reproduction. Parked behind goal 3.

This order delivers goal 3’s swappable allocator without paying the §16.6 cost, and keeps every step a JitGreedy-byte-identical, M1-benchable diff. L2-2.1 is the next code increment.

17.4 Deferred: goal-3 / VmResidual not pursued now (per user)

Per the user, VM support is not needed at this point, so the goal-3 / VmResidual direction designed in §17.1–17.3 is deferred. The L2-2.1 code (the AllocStrategy { JitGreedy, VmResidual } enum, the SlotState.alloc_strategy field, the try_alloc_fpr VM gate, and the force-vm-residual validation feature) has been reverted to keep the tree focused on the JIT. §17.1–17.3 remain as the record of the goal-3 plan for whenever VM-residual codegen is revisited.

Refocus. The active goal returns to the JIT-internal, behaviour-preserving separation of “abstract interpretation + fixpoint” from “physical register allocation” — §4 step 2 done as a structural refactor that preserves the current greedy placement, not the VM application. Consequence of deferring goal 3: the deepest remaining separation (un-welding the per-instruction handlers’ type/representation decision from their allocation+emission — e.g. binop_float = load_binary_ret_fpr (alloc) + fpr_binop (emit)) loses its near-term functional payoff (it was the enabler for the swappable VM allocator). What is already done de-fuses the merge and the allocation seam (data-model split place/ty, alloc_policy/AllocCtx, decide_join/apply_join, the keep_backedge_floats mechanism/policy split); the remaining handler-level un-welding is a large, IR-introducing refactor whose value, with goal 3 deferred, is architectural cleanliness rather than a feature. That trade-off is the open decision.

18. Handler-level separation (JIT-internal, behaviour-preserving)

With goal-3 deferred (§17.4), the remaining fusion is inside the per-instruction handlers: each float-op handler interleaves the type/representation decision (what the result is, what representation) with allocation (which xmm) and emission (the AsmInst). Un-welding these — behaviour-preserving, the current greedy placement unchanged — is the last structural step of §4-step-2 done as a refactor.

18.1 The template: decision (Layer-①) vs execution (Layer-②), on binop_float

binop_float is split into a pure decision and an execution:

  • plan_binop_float(&self, …) -> FloatBinOpPlan — a pure, &self, allocation-free function (Layer-①): it decides Fold(f64) (both operands const floats, result a flonum immediate) vs FprOp, without allocating an xmm or emitting.
  • binop_floatexecutes the plan (Layer-②): Folddef_C_float (a pure constant, no xmm); FprOpload_binary_ret_fpr (alloc) + fpr_binop (emit).

What this concretely fixes: the original folded the decision and a side effect together — … && self.def_C_float(dst, result) put the constant definition inside the if condition (the fold “succeeded” only if def_C_float mutated the slot). The split lifts the flonum-representability check into the pure plan_* (Immediate::flonum(result).is_some()) so the decision is a value with no side effect, and the definition happens only in the execute half. Behaviour-identical (suite 1671 passed, baseline-identical failure set; the 34 are the env mismatches).

18.2 Scope: the fold decision separates cleanly; the xmm path needs a virtual-operand IR

This lifts out the fold decision (a pure Layer-① constant). The FprOp execution still fuses allocation and emission internally: load_binary_ret_fpr both allocates an xmm per operand/dest and emits the load, with the xmm identities threading through (operand pins, dst == lhs aliasing). Separating alloc from emit there requires a virtual-operand IR — the float op recorded with slot operands, lowered to physical xmm by a distinct allocation pass — because the allocation produces the operands the emission consumes. That is the substantial next step; this increment establishes the decision/execution seam and the FloatBinOpPlan value that a virtual-operand lowering would carry. The same plan_*/execute shape applies to the other handlers that fold-or-emit (gen_cmp_*, binop_integer) as they are migrated.

18.3 Correction: the FprOp alloc/emit is already split (§9/§11); (b) is record-driven lowering

§18.2 claimed the FprOp path “still fuses allocation and emission internally.” Tracing it precisely, that is wrong — the primitive-level split is already done by the §9/§11 transfer work:

  • load_fpr / load_fpr_fixnum are each (*_state) + transfer(TransferIR::…): the state half (load_fpr_state) allocates the xmm and binds the slot (pure abstract-state mutation, no emission); the record (TransferIR::FprLoad, carrying its deopt as a DeoptPoint program point) is what emits. transfer() collects the record into self.transfers and (today) emits it inline, with a debug shadow check proving the record replays to the identical AsmInst/SideExit — i.e. the record is self-contained.
  • def_F (the dst) is pure allocation (no emission); fpr_binop is pure emission (no allocation — its operands are already placed).

So in binop_float’s FprOp arm, every call is either allocation (state) or emission (a record / a pure AsmInst); they are not fused, only sequenced. And the handler already consumes virtual operands (FBinOpInfo = slots) and produces physical xmm — it is a virtual→physical lowering. §18.2’s “needs a virtual-operand IR” mis-stated the situation.

What (b) actually is: record-driven lowering (the deferred two-pass). The transfer records are collected but not yet consumed — emission still happens inline during the analysis/allocation walk. The remaining separation is to make emission a distinct pass that replays the record stream, instead of emitting inline. The records already carry everything needed (deopt program points; physical regs assigned during the walk), so replay is behaviour-preserving — the §11 shadow proves it per-call.

The one concrete blocker. The stream is not yet complete: transfers (value-movements) are recorded, but operations (fpr_binop, FloatCmp, the integer ops) and dst-defs are emitted directly to inst, outside transfers. A deferred replay of transfers alone would drop the ops and lose their ordering relative to the loads. So the prerequisite for record-driven lowering is a single ordered record stream that includes the operations, after which a pass-2 replay can emit the whole method from records.

Scope honesty. This is a large, global restructure (route all emission through one ordered record stream; then split the codegen walk into collect-then-replay), not an op-by-op behaviour-preserving edit — the value-movement primitives are already split, so the increments left are (i) inert scaffolding (widen the record stream to cover ops, which does nothing until a replay pass exists) or (ii) the replay pass itself (the deferred two-pass). With goal 3 deferred (§17.4), this restructure’s payoff is purely architectural. The boundary of the primitive-level separation has been reached; crossing into the deferred two-pass is the open, large-investment decision.

19. (B) Record-driven lowering: toward a single ordered codegen record

Per the user the goal is clean architectural layering so optimization logic is easy to add. The record stream (TransferIR, built by §9/§11) is the emerging IR layer between the handlers and the AsmIR/machine code. Today it covers value-movement transfers only; operations emit directly to inst, outside the stream (the §18.3 blocker). (B) unifies them into one ordered stream, then makes lowering replay it.

19.1 Step 1: operations join the record stream (float binop)

The first operation routed in: the float binary op. binop_float’s FprOp arm now emits TransferIR::FloatBinOp { kind, lhs, rhs, dst } through transfer() instead of a direct fpr_binop. The record is pure data (Clone, no closure, no abstract-state read), so transfer() collects it into the transfers stream and the debug shadow check replays it to the identical AsmInst — exactly like the transfer records. Behaviour-preserving: lib suite 1671 passed (baseline-identical failure set), zero replay mismatches.

This proves the pattern: data-only operations can join the record stream and be shadow-verified. In analysis mode transfer() skips emission (the op has no state half, and the pass discards its AsmIr), matching the prior behaviour.

19.2 The plan to a replayable stream

  1. Migrate the data-only operations to records — float/integer arithmetic and comparison (FloatCmp, IntegerCmp, IntegerBinOp, …). Each: add a record variant, route the handler through transfer(), verify via the shadow check. (This step: float binop.)
  2. Generalize the type — once it carries both transfers and ops, rename TransferIR to a unified LowerRecord / CodegenIR; Transfer no longer fits.
  3. Handle the closure-carrying ops (inlined calls, C-func trampolines): these variants of AsmInst are not Clone (they own FnOnce closures), so they cannot be recorded the same way. Either represent the inlined body as a record sub-stream (data), or keep a move-only escape-hatch variant the replay emits in place. This is the hard part of the unification.
  4. Build the replay (lowering) pass — collect the full ordered stream during the codegen walk, then emit inst by replaying the records, removing the inline emit. The stream becomes the clean IR layer that optimization passes operate on (the goal). Behaviour-preserving by the per-record shadow property.

Each step (1) is behaviour-preserving and shadow/suite-verified; the risk concentrates in (3) and the (4) switch. Starting from the data-only ops keeps the early increments safe while the stream grows toward completeness.

20. (B) De-closuring the array-index codegen

The array integer-index read/assign sites were the densest remaining ir.inline(|gen| …) closures on the hot path. The 8 closures (x86 + aarch64, read + assign, with/without bounds info) became typed data records AsmInst::ArrayIndex / ArrayIndexAssign (carrying an ArrayIndexKind), with the moved closure bodies living in per-arch gen_array_index / gen_array_index_assign. The two array_integer_index{,_assign} builders in jitgen/compile/index.rs are now cfg-gated twins that just ir.push the same arch-neutral variant.

This is independently valuable (it removes opaque closures from inst, making those ops inspectable by any pass over the stream) and is the concrete precedent for §19’s step (3): a closure-carrying op can be turned into inspectable data when its body is arch-uniform enough. Behaviour-preserving (lib suite identical, zero replay mismatches). Commit 818c15c.

21. The realization: inst already is the replayable stream — add the seam

§19.2’s step (4) sketched a move-based rewrite — records own the AsmInsts, a replay pass rebuilds inst — as the way to reach “a clean IR layer optimization passes operate on”. That rewrite is unnecessary: inst: Vec<AsmInst> is already the ordered, replayable stream. Handlers build it during the analysis/codegen walk (traceir_to_asmir); Codegen::gen_machine_code replays it afterwards to emit machine code (compile_asmir, one match arm per AsmInst). The hot ops are already typed variants in it (FloatBinOp, IntegerBinOp, IntegerCmp, ArrayIndex, … — §19/§20), directly inspectable.

So Path 2’s actual goal — a place to add optimization logic over a typed, arch-neutral instruction stream — is reached simply by adding an optimization hook over inst between AsmIR construction and emission, not by the §19.2(4) rewrite, and not by making AsmInst: Clone (blocked anyway: ~52 inline-builtin closures across the builtins/*.rs use ir.inline, not the 8 array-index sites).

21.1 The seam

AsmIr::optimize_peephole(&mut self) -> usize (in asmir.rs, where inst is private) runs peephole passes over a block’s stream and returns the count removed. gen_machine_code calls it just after frame.detach_ir() — over every main block and every inline/outline bridge AsmIr — before the emission loop. Optimizing the bridges before thread_empty_outline_bridges lets one that collapses to nothing be jump-threaded away as usual.

Soundness of dropping instructions here: branch targets are labels (JitLabel); deopt targets index the side_exit vec (AsmEvict / AsmDeopt), never inst positions. So removing an instruction cannot perturb control-flow or deopt resolution. (BcIndex source-map markers are likewise emission-time, not position-indexed.) No block can be emptied by the pass — each carries a leading Label — so live_bb / is_empty accounting is unaffected.

21.2 Pass 1: self-move elimination

AsmInst::is_self_move flags RegMove(r, r) / FprMove(r, r) (dst == src); the pass retains the rest. A self-move is inert (mov r, r does not even set flags; movapd x, x is a no-op). Under the jit-log feature the call site prints the removed count.

Observed: across the lib suite and the benchmarks (app_fib, so_nbody, app_aobench, …) the count is 0 — the allocator does not currently emit self-moves (its move-insertion guards against src == dst). That is the expected “safe first pass that rarely fires”; the seam is the deliverable — the typed stream now has a layer boundary where real passes (peephole arithmetic identities, dead FprSave-pair removal, redundant load/store elision) attach with no further plumbing. Behaviour-preserving: lib suite 1705 passed, 0 failed (this environment’s CRuby-4.0.2 baseline), zero transfer() replay mismatches.

22. The ①/②/③ reframing and the PhysMap seam (phase-0/1)

A fresh framing of the whole §5 effort, stated as three phases:

abstract interpretation + fixpoint subtyping and assignment to virtual registers (types + liveness + representation + a virtual FP register per value); physical register allocation (virtual → the 14-wide xmm pool ∪ spill slots); machine-code generation.

This is a better cut than §3’s “analysis with NO locations vs allocation+lowering” because it puts the representation decision in ①, which dissolves the wall §13.8 and §16.6 hit twice.

22.1 Why this cut dissolves the §13.8/§16.6 wall

Those sections proved the fixpoint’s loop-carried-F selection is load-bearing and not reproducible allocation-free (a liveness re-derivation regresses 2.5×). But that “selection” fuses two decisions:

  • (A) representation — is a value unboxed (F/Sf) or boxed (S)? This is the load-bearing part (§14.6: boxing → a ~10-inst flonum decode every use).
  • (B) physical placement — which xmm, and who spills under pressure? §15.9 proved this is perf-neutral (phase-1 only demotes all-Sf caches; an F is never boxed; pool overflow spills F as raw f64, never re-decoded).

The ①/②/③ cut keeps (A) in ① (the fixpoint, unchanged) and moves only the perf-neutral (B) into ②. We never try to reproduce the selection allocation-free — that was the 3b/L2-1 mistake. So the regression driver (boxing) is structurally absent from ②.

22.2 The f64-spill axiom (②’s cost model)

The defining Float constraint — boxing/unboxing is expensive, so an overflowing unboxed float spills as f64 rather than boxing — fixes ②’s spill cost model and its vocabulary. ② may place a value in {physical xmm, raw-f64 spill slot, or (for an Sf cache) drop-the-cache}; boxing an F is not in ②’s vocabulary:

② actioncost now / on next use
box an Fforbidden (∞)
drop an Sf coercion cache (→S)0 now / one flonum decode on next float use
raw-f64 spill of an Fone movsd now / one movsd on next use
keep loop-carried F/Sf residentpreferred under pressure (§14.6 bar)

The worst case ② can produce is therefore a movsd (or one decode for an Sf cache), never the per-use decode storm that sank 3b — the downside is capped by construction. Re-cast in canonical/cache terms, the three LinkModes split cleanly across the layers: F = unboxed-canonical (① picks it; ② places it in xmm or f64-spill); S/Sf = boxed-canonical-with-optional-cache (① marks “float-used / cache-eligible”; ② decides whether the coercion cache is materialised in an xmm (Sf) or dropped (S)).

Key soundness fact. Because pressure never changes representation today (§15.9: spill ≠ box), F/Sf/S counts are already pressure-independent — which is exactly the precondition that lets “representation in ①, placement in ②” be behaviour-preserving. (Open check before P2: confirm the “Sf canonical = boxed” premise holds across all arms, incl. keep_backedge_floats’s Sf→F promotion, §15.7.)

22.3 The linchpin: decouple FPReg from its physical slot

The one structural blocker is that FPReg(usize) conflates the virtual id with the physical slot: loc() was id < PHYS_FPR_POOL ? xmm(id+2) : spill, so the pool-vs-spill decision is the FPReg number, chosen greedily inside the ① fixpoint by try_alloc_fpr. (This corrects §1’s “FPReg is already a virtual register” — it is pool-number-encoded, not a true virtual register.) The fix is a PhysMap: FPReg → FPRegLoc produced by ②; ① assigns unbounded virtual FPRegs, ② maps them to physical, ③ resolves through the map.

22.4 Increment plan

StepChangeRisk
P0Introduce PhysMap (codegen.rs): the single resolve(FPReg) -> FPRegLoc chokepoint, today the pool-vs-spill formula.none
P1Route every emission-site FPReg::loc(base) (22 sites, both arches) through PhysMap::resolve; delete FPReg::loc.none
P2Make ① assign unbounded virtual FPRegs (drop try_alloc_fpr’s pool/spill phases from the fixpoint); representation stays in ①. Paired with P3 behind a feature.high · bench
P3② = a distinct pass that reproduces today’s greedy FPReg→FPRegLoc exactly; verify byte-identical via the stage-1 placement shadow. The real, zero-regression separation.high · shadow + M1 bench
P4Swap ②’s greedy for a loop-aware linear scan over live intervals (the f64-spill cost model of §22.2; loop-carried priority §14.3). Parity-at-best on perf (§16.6).bench-gated
P5Deopt-as-program-point (§13.6) now that placement is a ② product; extend ③’s optimize_peephole (§21) with post-allocation passes.med

22.5 P0/P1 landed

PhysMap is the lone FPReg → FPRegLoc resolver; all 22 arch emission sites call PhysMap::new(base).resolve(reg) instead of the deleted FPReg::loc. Pure seam-creation, behaviour-identical: both x86-64 and aarch64 build clean; lib suite 1706 passed, 0 failed (1705 baseline + physmap_resolve_formula). ② will later swap the formula for an explicit per-FPReg table behind this same resolve, with no emission-site change.

23. Pre-P2 verification: the premise holds, with one coupling to preserve

Before touching the fixpoint (P2), §22.2’s open check — “is Sf always boxed-canonical, and does placement ever change representation?” — was resolved against the code. Result: the correctness premise holds unconditionally, and there is exactly one representation↔placement coupling, which is perf-load- bearing (not correctness-bearing).

23.1 Correctness premise — holds at the type level

LinkMode (slot.rs) encodes the split directly:

  • F(fpr) — “mutation of the fpr lazily affects the stack slot”: unboxed- canonical, stack is stale. Canonical value lives in the fpr.
  • Sf(fpr, _) — “on the stack slot and the fpr which is read-only”: boxed-canonical on the stack, fpr is a droppable read-only cache.
  • S(_) — boxed on the stack only.

The placement phases (alloc_policy::try_alloc_fpr, slot.rs:73–127) respect this:

PhaseActionTouches representation?
0 vacantreturn lowest-index free fprno
1 demotevictim filter is slots.all(Sf) (line 93–99); demote Sf→S losslessly, no asm (stack is canonical)only drops a cache (Sf→S), never boxes/unboxes
2 spillpush_spill appends a new FPReg(N≥POOL) for the value being allocatedno — never evicts an existing F

So no placement action ever boxes an F, unboxes anything, or evicts an F. An F (stale-stack) is structurally excluded from Phase-1 victims, and Phase-2 only ever hands a fresh spill slot to a new allocation. The _ => unreachable!() at slot.rs:107 is the live guard for “Phase-1 demotion only touches Sf”; the full x86-64 (1706) and aarch64 (22 float/numeric) suites exercise it without firing — the empirical confirmation. ② can therefore be split out with zero correctness risk.

23.2 The one coupling: the promotion gate (perf-load-bearing)

keep_backedge_floats (merge.rs:123/130 → slot.rs:648) is the loop-back-edge representation decision: for each loop-carried slot that is S|Sf and float-typed (adopt && promotable), promote it to unboxed Fbut only via try_set_new_Ftry_alloc_fpr()? (slot.rs:703), i.e. Phase 0/1 only, no spill. If only a Phase-2 spill could free an fpr, it returns None and the slot stays boxed (S/Sf).

That is the crux: ① decides representation (box vs unbox) by querying ②’s physical-pool occupancy (“can the greedy allocator seat this in xmm2..15 without spilling?”). This is precisely the §13.8/§16.6 load-bearing selection — benign for correctness (the fallback “stay boxed” is always sound) but it determines which loop floats are unboxed, which is the entire perf delta.

23.3 Consequence for P2 (refines §22.4)

P2 is therefore not merely “drop the pool/spill encoding from try_alloc_fpr”. The promotion gate must keep producing the same decisions, so:

  1. ① cannot ask a raw physical question (“is FPReg(i) vacant?”) once VRegs are unbounded. The gate must be re-expressed as a virtual-pressure predicate that P3’s greedy ② reproduces exactly: “would ② seat this VReg in the PHYS_FPR_POOL-wide pool rather than spill it, at this program point?”
  2. The low-risk route to byte-identity: share one allocator oracle. Keep keep_backedge_floats calling try_alloc_fpr (now ②’s policy object), let ① record the tentative virtual assignment it returns, and have ② reuse that same assignment. Since P3’s ② is today’s greedy try_alloc_fpr, ①’s gate and ②’s placement read identical occupancy ⇒ byte-identical output by construction. The PhysMap seam (§22.5) is what lets ②’s final physical slot later diverge from the gate’s tentative one without touching ① or ③.
  3. Validation: P2/P3 land behind a feature flag; perf-neutrality of the promotion gate must be confirmed on real Apple-silicon / x86 hardware (qemu gives no perf signal), A/B against the stage-1 placement shadow, before default- on. Correctness is already covered by §23.1 + the shadow’s byte-compare.

Net: the separation is more tractable than §16.6 feared — ① and ② already meet at a single oracle (try_alloc_fpr), so the job is to make that oracle a shared ② object rather than to re-derive a second allocator. The risk is confined to the promotion gate’s perf, which the feature flag + M1 A/B gate-keeps.

24. Stage-1 placement shadow (the P3 byte-identity oracle)

Per §23.3, P2/P3 stay perf-neutral only if the separated ② emits the same physical placements as today’s greedy allocator. §24 lands the verification harness for that, ahead of P2 (so the oracle exists before the risky change).

24.1 What it captures

The shadow records, in emission order, every FPRegLoc that PhysMap::resolve returns during a compilation — one entry per resolve at the §22.5 chokepoint. Because every arch emission site (all 22) funnels through resolve, this Vec is a faithful, order-preserving fingerprint of ③’s entire FP-placement output, on both x86-64 and aarch64, with zero per-site plumbing. API (codegen.rs, placement_shadow, feature shadow-placement, default-off):

  • begin() — start recording.
  • record(loc) — append (called inside resolve under #[cfg]; no-op when idle).
  • take() -> Option<Vec<FPRegLoc>> — stop and return the fingerprint.

FPRegLoc now derives PartialEq, Eq, Hash, so two fingerprints compare with plain Vec equality.

24.2 How P3 uses it

baseline = { begin(); jit_compile(f, greedy ②);      take() }   // shipping
candidate = { begin(); jit_compile(f, separated ②);   take() }   // P2/P3 build
assert_eq!(baseline, candidate);   // byte-identical placement ⇒ perf-neutral

Today there is only one allocator, so the harness is validated on the identity / determinism case: placement_shadow_fingerprint (codegen.rs tests) asserts the fingerprint matches the resolve order exactly and is byte-identical across two passes, and that recording is correctly scoped (off after take). The full lib suite under --features shadow-placement builds and passes (1706 + the gated test); the default build is byte-for-byte untouched (the record call is #[cfg]-gated, resolve is otherwise unchanged).

24.3 Why this shape

The fingerprint is the emission-order sequence of physical locations, not a per-FPReg table, because that is precisely what must be invariant for ③ to produce identical machine code: P2/P3 may renumber virtual FPRegs freely (① assigns unbounded VRegs; ② maps them to physical), and renumbering is invisible to ③ iff the resolved FPRegLoc stream is unchanged. Comparing the resolved stream — rather than the VReg ids — is therefore the right and minimal oracle: it permits VReg renumbering while pinning the observable placement.

24.4 Wired into jit_compile; validated on real emission

The shadow is now bracketed around the whole ③ emission in jit_compile (jitgen.rs): begin() before gen_machine_code, take() after — and since the driver recurses into inlined callees, one bracket captures the entire compilation unit. Under --features shadow-placement each compile logs [shadow] iseq=<id> type=entry|loop n=<len> digest=<fnv64> (a compact FNV-1a-64 of the fingerprint; order-sensitive, so any placement or ordering change shows up). M1 bin/test already runs the P0/P1 + shadow code green on real Apple silicon.

Validated end-to-end on x86-64:

  • A hot float loop (while i<N: s += i.to_f*1.5 + 0.25) JITs to iseq=… type=loop n=12 digest=0x01531d81c82b05c4, byte-identical across repeated runs — the deterministic baseline a P3 candidate must reproduce.
  • Most non-float compilations log n=0 (no FP placement emitted) — expected; the fingerprint only grows where ③ actually lowers FP operands.
  • Default build (feature off) is byte-for-byte unchanged; the lib suite under the feature passes (1706 + the gated placement_shadow_fingerprint, now also asserting digest stability + order-sensitivity).

Baseline-capture recipe (run on M1 before P2 flips ② on):

cargo run --features shadow-placement -- benchmark/so_nbody.rb 2>&1 \
  | grep '\[shadow\]' | sort > baseline.txt
# … after P2/P3 land behind their flag, same command → candidate.txt
diff baseline.txt candidate.txt   # must be empty ⇒ ② is byte-identical ⇒ perf-neutral

25. P2 step 1: extract ②’s placement policy out of ③ (byte-identical)

FPReg(usize) is deeply physical in ①: FprAllocator.vfpr is indexed by fpr.0, and add/remove/clear/swap/pin plus every LinkMode::F(fpr) / Sf(fpr) store the physical id. A full virtual-id renumbering of ① therefore touches a large, swap/pin-coupled surface and is deferred. P2 starts at the other end — the cheap, byte-identical half — by moving the placement policy to where ② will own it:

25.1 What moved

Before, PhysMap::resolve (③) hardcoded the rule id < PHYS_FPR_POOL ? xmm(id+2) : spill(base-24+8·(id-POOL)). Now:

  • (codegen::phys_alloc) owns the rule as policy(i) -> PhysSlot, where PhysSlot is a frame-independent placement (Xmm(p) or Spill(n), the n-th f64 slot). With feature phys-table, policy is memoised into an explicit per-compilation table (phys_alloc::slot, grown lazily); without it, policy is called directly. Both yield identical PhysSlots.
  • (PhysMap::resolve) is now policy-free: ask ② for the virtual fpr’s PhysSlot, then apply_base turns a Spill(n) into the frame’s concrete [rbp/x29 - off]. The frame base is the only thing ③ still contributes.

This is the clean split the §22 framing wants: which physical resource is a ② decision (frame-independent), where on this frame’s stack is a ③ mechanic (base-relative). P4 swaps phys_alloc’s table for a loop-aware allocation with zero change to ③ or the 22 emission sites.

25.2 Why byte-identical, and the evidence

policy(i) reproduces the old formula termwise (Xmm(i+2) for the pool; Spill(i-POOL) + apply_base’s base-24+8n for the rest), so the resolved FPRegLoc stream is unchanged. Verified with the §24 shadow:

  • hot float loop: digest=0x01531d81c82b05c4 identical with and without phys-table.
  • so_nbody: all 79 per-compilation fingerprints byte-identical across the flag (diff empty).
  • x86-64 lib suite 1706 passed / 0 failed with --features phys-table; default build and aarch64 build both clean.

25.3 What is not yet done (P2 step 2)

① still hands out physical ids (fpr.0 = pool/spill index); the phys-table is therefore still the identity policy. The remaining, riskier half — making ① assign unbounded virtual ids and having ② pack them into PhysSlots (the genuine decoupling, §22.3) — requires threading a virt→phys indirection through FprAllocator’s swap/pin/vfpr surface and every LinkMode::F/Sf store. With ③ already policy-free and the table seam in place, that change is now confined to ①

  • phys_alloc, and its output stays checkable by the same shadow diff.

26. P2 step 2 finding: true virtual-id decoupling is not byte-identical

Designing step 2 (① assigns unbounded virtual ids; ② packs them into PhysSlots) surfaced a hard fact that reshapes the roadmap.

26.1 Greedy placement is time-varying

FPReg.0 is not just “physical” — a live value’s physical register changes over its lifetime. At every control-flow merge / loop back-edge, the bridge reconciles the current state against the target block’s expected assignment (slot.rs ~1806):

#![allow(unused)]
fn main() {
(LinkMode::F(l), LinkMode::F(r)) => if l != r {
    if self.is_fpr_vacant(r) { self.set_F(slot, r); ir.fpr_move(l, r); }
    else { self.gen_fpr_swap(ir, l, r); }   // move a LIVE value l -> r
}
}

So a loop-carried float can occupy xmm5 in the body and xmm7 at the header, reconciled by an FprMove/FprSwap mid-life. The physical placement is a function of program point, not a single value per virtual register.

26.2 Why that blocks a byte-identical step 2

A genuine ② (the point of decoupling) assigns each virtual register one physical location for its whole live range — SSA/linear-scan style. That:

  • eliminates the back-edge FprMove/FprSwap reconciliations greedy emits (a global assignment needs no per-edge shuffle for a value it pins), and
  • changes which physical register each value sits in.

Both change ③’s emitted FPRegLoc stream. Therefore the §24 shadow diff cannot be empty for a real step 2 — byte-identity and decoupling are mutually exclusive here. (Reuse of a physical slot across non-overlapping lifetimes is fine for stable mapping; only the mid-life moves of §26.1 are the obstacle, and they are intrinsic to greedy’s per-edge reconciliation.)

26.3 Consequence: step 1 is the byte-identical terminus

The separation splits cleanly into two regimes:

byte-identical?riskgate
Step 1 (③ policy-free, ② owns the placement table) — doneyeslowshadow diff empty ✓
Step 2 (① virtual ids, ② global allocation)no — different placement + fewer swapshigh (§16.6 regressed before)M1 perf A/B; shadow = delta measurement, not equality

Step 2 is thus a perf experiment, not a safe refactor: it must beat greedy on real hardware to be worth the §16.6-class risk, and the shadow’s role flips from “prove zero change” to “quantify the placement delta”. The earlier “feature flag + shadow diff = byte-identical step 2” framing (§22.4/§23.3) was wrong on this point: it assumed a stable mapping could reproduce greedy, but greedy has no stable mapping to reproduce.

27. The real ② global allocator: constraints and staging

Per the §26 decision to build a genuine global ② (not the byte-identical relabel), investigation fixed two hard constraints that shape how:

27.1 Two constraints

  1. Not as an AsmIR post-pass. Re-deriving liveness/CFG from the flattened AsmIR and allocating there is exactly the allocation-free re-derivation §13.8 / §16.6 measured at a 2.5× regression. The allocator must live inside the ① fixpoint, where types, liveness and loop structure are already computed.
  2. The lever is Phase 0/2, not victim_rank. §15.9 (and the AllocCtx header) prove the Phase-1 victim choice is performance-neutral: it only drops an Sf read-only cache, never boxes an F. The decisions that move the needle are Phase 0 (which physical register a fresh value gets, hence pool-vs-spill at the boundary) and Phase 2 (spill vs. keep). A real ② must steer those.

27.2 Stage 1 (landed): widen the seam to Phase 0

AllocCtx gains pick_vacant(state) -> Option<FPReg> — the Phase-0 placement hook — alongside the existing victim_rank. The default reproduces the historical lowest-physical-index first-fit, so it is byte-identical (hot-float-loop digest 0x01531d81c82b05c4 unchanged; x86 lib suite 1706/0; aarch64 builds). The seam now covers the real lever: a global policy overrides pick_vacant to seat loop-carried values where they will not be evicted, with zero default-path cost (the default is the original Phase-0 scan).

27.3 Stage 2+ (next): the loop-aware policy

The perf-bearing work, all inside the fixpoint and all non-byte-identical (so shadow diff becomes a delta measurement, M1 A/B is the gate):

StageWorkNeeds
2aCollect per-value live intervals + loop-carried set from the fixpoint’s existing liveness / keep_backedge_floats predicates (the info §16.6’s post-pass lacked).fixpoint hook
2bA loop-aware AllocCtx (pick_vacant/victim_rank driven by 2a) that pins loop-carried F/Sf in the pool and pushes short-lived temporaries to spill first — fewer in-loop reloads.2a
2cMeasure: shadow delta (placements changed, back-edge FprMove/FprSwap removed) + M1 A/B on mandelbrot/nbody/optcarrot. Default-on only if it wins.M1

Stage 1 keeps the shipping build byte-identical while making Stage 2 a contained, fixpoint-local change behind AllocCtx, gated by the §24 shadow (now a delta meter) and real-hardware benchmarks.

28. Stage 2b design: capacity-reservation, the real loop-aware lever

Scoping the loop-aware policy pinned down what actually moves the needle, which is subtler than “pick a better vacant register”.

28.1 Why pick_vacant’s choice is (mostly) neutral, and what isn’t

Choosing which vacant pool register a value lands in is a renaming — perf- neutral (§15.9). And an already-resident F is never evicted (Phase 2 spills the new value; Phase 1 only drops Sf caches). So the only way a loop-carried float ends up non-resident is at promotion time: keep_backedge_floatstry_set_new_Ftry_alloc_fpr returns None because the pool is full (no vacant, no all-Sf victim), so the slot stays boxed S/Sf and reloads every loop iteration. The lever is therefore pool capacity at promotion, not victim choice.

28.2 The mechanism: reserve pool capacity for loop-carried floats

The loop-carried-float set is already available at the back-edge merge (liveness.loop_used_as_float()be.is_float_typed, merge.rs ~118-130). A loop-aware AllocCtx uses the previous fixpoint iteration’s set L (the loop re-runs to convergence, so the prior pass’s L is known when allocating the current one) to:

  • Reserve the top min(|L|, PHYS_FPR_POOL) pool slots: pick_vacant for a non-loop-carried allocation skips reserved slots (falls through to Phase 1/2, i.e. spills a short-lived temporary instead of consuming a slot a loop-carried value will need).
  • Loop-carried allocations may use any slot, so their try_set_new_F promotion succeeds where it previously overflowed → resident across the loop → no per- iteration reload.

This needs the allocation entry points (set_new_F/try_set_new_F/… → alloc_fpr) to pass which slot / is-loop-carried into the policy — a signature widening of alloc_fpr/try_alloc_fpr and their ~10 callers (FPReg stays an opaque handle; only the policy reads the context).

28.3 Correctness, convergence, verification

  • Correctness: reservation only changes placement (resident vs spilled- unboxed / boxed), never representation soundness — a spilled or boxed float is always a valid materialisation. The full suite must pass with the flag on (different digests, still correct).
  • Convergence: the reserve count comes from the prior iteration’s L; since L is monotone over the back-edge fixpoint (§14.1) the reserve count stabilises with it. Cap reservation at PHYS_FPR_POOL - 1 so a degenerate |L| can never starve the allocator into livelock.
  • Risk: over-reserving forces more temporary spills; net effect is empirical. Gate: §24 shadow as a delta meter (placements changed, back-edge FprMove/FprSwap removed) + M1 A/B on mandelbrot/nbody/optcarrot. Default- on only on a win; this is the §16.6-class risk the experiment exists to test.

Stage 1 (§27) already exposes the pick_vacant hook this rides on; Stage 2b is the contained AllocCtx body + the allocation-entry signature widening, behind a new default-off feature.

29. Stage 2b result: the reservation policy is inert (forward-fixpoint limit)

Stage 2b (§28) was implemented behind phys-loop-aware: target: SlotId threaded through the six FP allocation entry points → alloc_fpr/try_alloc_fprAllocCtx::should_reserve, a loop_float set captured in liveness_analysis, and the capacity-reservation gate at the top of try_alloc_fpr_ctx. It is correct (full lib suite 1706/0 under stress-spill-pool,phys-loop-aware; default build byte-identical, hot-float digest unchanged) — but empirically inert.

29.1 The measurement

should_reserve never fires. Instrumentation showed state.loop_float is always empty at every allocation, on every probe — so_nbody (pool 14), a 16-accumulator loop, and a single-accumulator loop under stress-spill-pool (pool 2). Placement digests are identical with and without the feature in all cases (diff empty).

29.2 Why — the same forward-fixpoint wall

The loop-carried set L is produced by liveness.loop_used_as_float() and consumed at the back-edge merge (liveness_analysis, merge.rs:89). But the body’s float registers are allocated once, during the forward body walk, which runs before the back-edge is reached. So at every allocation decision L is not yet known (loop_float empty); by the time L exists, the placements are committed. This is exactly the §13.8/§16.6 limitation — the information a better allocation needs is downstream of the point that needs it — now confirmed at the allocation seam itself. (And loop_used_as_float at that merge was empty for the probes anyway, a second symptom of the same ordering.)

29.3 Consequence

A binding loop-aware allocator cannot be a tweak inside the forward fixpoint; it needs L (per-loop float liveness) surfaced to the body walk — i.e. a preliminary liveness pass feeding the allocation pass. That is a two-pass re-architecture, and §16.6 measured a 2.5× regression for the nearest prior attempt at allocating with re-derived (rather than fixpoint-native) liveness. So Stage 2b’s lever, as a forward-fixpoint reservation, does not bind, and the two-pass alternative is high-risk.

Standing decision: Stage 1 (§27 — ③ policy-free, ② owns the placement table, Phase-0 seam) remains the byte-identical, shipping terminus. The Stage 2b seam (should_reserve + target threading + loop_float) is kept, feature-gated and inert, as the documented attachment point: a future two-pass L-surfacing pass plugs a binding policy in here, gated by the §24 shadow (delta meter) + M1 A/B.

30. Correction: the bridge can spill — the bottleneck is the join’s target policy

§29 (and the prior explanation) leaned on “the merge has no AsmIr, so a pool-overflow loop float must stay boxed”. That framing is wrong, and the correction reshapes the real lever.

30.1 The bridge materialises S→F, spill included

AbstractFrame::bridge(ir: &mut AsmIr, target, slot, pc) — the per-incoming-edge reconciliation stub — has AsmIr and already handles S → F (slot.rs:1957):

#![allow(unused)]
fn main() {
(LinkMode::S(_), LinkMode::F(x)) => {            // boxed home -> unboxed fpr
    ir.stack2reg(slot, GP::Rax);
    let deopt = ir.new_deopt_with_pc(&self, pc + 1);
    if self.is_fpr_vacant(x) {
        ir.float_to_fpr(GP::Rax, x, deopt);      // box→f64 decode, on the edge
        self.set_F(slot, x);
    } else {
        let tmp = self.set_new_F(slot);          // ← spill-capable, WITH AsmIr
        ir.float_to_fpr(GP::Rax, tmp, deopt);
        self.gen_fpr_swap(ir, x, tmp);
    }
}
}

So the edge stub can decode a boxed float into an fpr — and into a spilled F (set_new_Fpush_spill, line 1968) when no physical reg is free. Every incoming edge to a merge/back-edge gets such a stub. Materialisation of an f64-spill loop float is therefore fully supported infrastructure.

30.2 The real division of labour

  • join / apply_join / keep_backedge_floats (no AsmIr) decide the target representation. They use try_set_new_F (no Phase-2 spill): set the target to F only when a physical fpr is free, otherwise leave it S/Sf.
  • bridge (AsmIr) materialises each edge into that target, spilling if needed.

So a pool-overflow loop float stays boxed because the join declined to make the target F, not because materialisation is impossible. §28’s “reserve a physical slot” was the wrong lever; the actual knob is the target-representation policy at the join.

30.3 The correct Stage 2b retry (replaces §28)

For a known loop float — be.is_float_typed(i) ∧ loop_used_as_float(i) (the non-speculative signal already wired in merge.rs:128, feature layer2-float-by-type) — commit the target to F via the spill-capable path rather than try_set_new_F:

#![allow(unused)]
fn main() {
// keep_backedge_floats, for confirmed loop floats only:
self.set_new_F(i);            // instead of try_set_new_F(i)
}

Then the bridge’s S → F arm materialises an f64-spill resident binding: one box→f64 decode per edge (loop pre-header + back-edge), and raw movsd f64 in the body — eliminating the §29 per-iteration decode at the boxed home. No physical reservation, no L-at-allocation-time problem (§29): the decision is at the back-edge, exactly where L is known.

30.4 Risks to clear first

  • The noted past bug. keep_backedge_floats deliberately uses the no-spill variant because spill-promotion was “exercised wrongly under register pressure (the stress-spill-pool path)” (slot.rs:758-763). Diagnose that failure mode before re-enabling — start by switching only the layer2-float-by-type confirmed-loop-float arm and running the suite under stress-spill-pool.
  • Cost trade. Gains one f64-spill home (per-iteration movsd) but pays a decode on each incoming edge and grows the spill region by |overflow loop floats|. Net is empirical: gate on the §24 shadow (now a delta meter — expect S→F edges to appear) + M1 A/B on float-heavy loops that overflow the 14-wide pool. Only such loops benefit; with ≤14 live floats nothing changes.

This is the architecturally-supported lever the §28 reservation should have been: edge-materialised f64-spill residency for confirmed loop floats, driven by the back-edge L and the existing S→F bridge arm.

31. Stage 2b/§30 result: inconclusive — confounded by a pre-existing layer2 bug

§30 (edge f64-spill residency: keep_backedge_floats using the spill-capable set_new_F for confirmed loop floats) was implemented under phys-loop-aware. The meaningful test needs the confirmed loop-float signal, i.e. running together with layer2-float-by-type. Under layer2-float-by-type,phys-loop-aware,stress-spill-pool, test_join_float_register_disagreement failed with a garbage f64 (-1.0 != 6.9…e-310, an uninitialised spill slot). I first attributed this to §30.

That attribution was wrong. The same test fails with layer2-float-by-type,stress-spill-pool without phys-loop-aware, and — the decisive check — it fails identically at commit bfc4ef1 (P0/P1, before any of this Stage-2 work existed). So:

  • There is a pre-existing latent bug in layer2-float-by-type under register pressure (stress-spill-pool). The two flags are never combined in CI (stress-spill-pool runs without layer2-float-by-type), so it went unnoticed. layer2-float-by-type’s broader type+liveness adoption promotes more slots to loop-carried F, and something in that path reads an uninitialised spill under pool=2. This is independent of §28/§30 and is the real bug to file.
  • §30’s own soundness is therefore inconclusive. Its only meaningful exercise (the confirmed signal) is confounded by the layer2 bug, and without layer2-float-by-type the be.mode == F adopt signal barely fires under pressure, so §30 could not be cleanly evaluated either way. The §31-draft claim that §30 was “architecturally unsound” was not actually demonstrated; the ordering concern it raised (keep_backedge_floats overrides the loop entry after analyse_backedge_fixpoint has frozen the body placements, merge.rs:79 vs 123) remains a real risk hypothesis, not a proven cause.

31.1 Actions

  • §28 (reservation) and §30 (set_new_F at the back-edge) code are reverted; the tree is restored to §27 Stage 1 (③ policy-free, ② owns the placement table, Phase-0 pick_vacant seam — byte-identical, shipping-safe). The phys-loop-aware feature is removed.
  • The pre-existing layer2-float-by-type × stress-spill-pool failure is recorded here as a separate bug to fix before that feature’s M1 bench gate.

31.2 Standing conclusion (§27–§31)

§27 Stage 1 is the durable result of the whole “real ② allocator” effort. Three distinct attempts to make ② beat greedy failed, and a fourth was confounded: §28 (reservation) inert (L unknown at allocation, §29); §30 (entry spill-promote) inconclusive + risky (post-fixpoint override); §16.6 (post-pass) regressed. The robust invariant stands: a better FP placement must be decided inside the back-edge fixpoint with its native type/liveness/CFG state. Before any such work, the layer2-float-by-type pressure bug (§31) must be fixed, since that feature is the intended carrier of the confirmed loop-float signal.

32. Diagnosis of the pre-existing layer2-float-by-type × stress-spill-pool bug

§31 flagged that test_join_float_register_disagreement fails under layer2-float-by-type,stress-spill-pool independently of §28/§30. This section roots it out.

32.1 Reproduction and symptom

Reproduced standalone by replicating run_test’s wrapper (__res = (CODE); for __i in 0..24 { (CODE) }; (CODE) — the outer for loop-JITs and the snippet redefines+calls test each iteration) with the test-mode thresholds. Only the first loop iteration’s float is wrong: res[0] is a denormalised garbage f64 (6.9…e-310) instead of -1.0; iterations 1–4 are correct. Default (pool 14) and default-adopt (no layer2-float-by-type) are both correct.

32.2 Root cause: entry promotion inconsistent with the body fixpoint

Instrumenting keep_backedge_floats shows the only difference: under layer2-float-by-type it promotes slot %3 = endv (the loop-carried 1.0) from Sf(FPReg0) to F(FPReg1); the default adopt promotes nothing.

endv is float-typed and used-as-float in the loop, so layer2’s type+liveness adopt fires — **even though the back-edge fixpoint (analyse_backedge_fixpoint, merge.rs:79, run before keep_backedge_floats at
  1. placed endv as Sf, not F.** The entry state is thus overridden to a representation (F) the already-frozen loop body was never analysed for. Under stress-spill-pool (pool 2) the freshly allocated FPReg1 is exactly a register the body reuses for a temporary, so the loop-entry binding and the body disagree and the value is read before it is materialised → uninitialised garbage.

This is the §31.3 invariant, now confirmed from the opposite direction: a loop-entry placement/representation must be a subset of what the body fixpoint produced. The default adopt (be.mode == F) is sound precisely because it only re-adopts placements the body already made F. layer2-float-by-type’s whole premise — decouple adoption from the analysis-pass placement, drive it from type+liveness — violates that invariant whenever the body kept the value boxed.

32.3 Why the obvious fixes do not work

  • Reuse the incumbent fpr (Sf(x) → F(x) instead of allocating a fresh one): tried, still corrupts. The fault is the F vs Sf representation mismatch between entry and body, not which register.
  • Restrict to free fprs (try_set_new_F, already the case): does not help — the body still treats endv as Sf.

A correct fix must keep layer2’s adoption consistent with the body fixpoint’s representation — i.e. either re-run the body fixpoint after adoption, or only adopt slots the body’s back-edge state already carries as F. The latter is essentially the default adopt, so layer2-float-by-type as specified cannot be made sound without folding the adoption decision into analyse_backedge_fixpoint (the §31.4 conclusion: placement lives inside the fixpoint).

32.4 Status

layer2-float-by-type is default-off and explicitly “flip after the M1 bench gate clears”, so this latent bug ships to nobody. It is left as-is with this diagnosis; fixing it is the same fixpoint-internal-placement work the §27–§31 arc converged on, and is the prerequisite for that feature (the intended carrier of the confirmed loop-float signal) ever being enabled.

33. IR/asm-level root cause of the §32 bug: side-branch F→Sf(spill) not materialised

Visualised the failing compile with jit-debug (per-instruction TraceIR + abstract states; note: dump-traceir alone is silent — the trace is gated on jit-debug, compile.rs:158) and emit-asm (machine code). The bug is now pinned to the exact instruction.

33.1 The abstract-state divergence

The body snippet is the diamond a = -1.0 + i*0.5; a = endv if a > endv; res << a. Under layer2-float-by-type, keep_backedge_floats promotes endv (%3) to F(FPReg1) at the compilation loop header (it fires in the compilation’s incoming_context but not in the analysis/frame-sizing pass, whose loop_info isn’t ready). With endv pinned to a physical reg under stress-spill-pool (pool 2), a (%4) at the BB4 merge resolves to Sf(FPReg2) — a spill (its two predecessors are BB2: F(FPReg0) via the condnotbr side exit, and BB3: Sf).

33.2 The faulting machine code

0000d5: ucomisd xmm3,xmm2        ; a(xmm3) > endv(xmm2)
0000d9: jbe     0xffdf4fd        ; a<=endv: side-branch BB2->BB4  (a stays in xmm3)
        ; BB3 (a>endv): a = endv
0000df: movq    [rbp-0xa8],xmm2  ; endv -> a's FPReg2 spill slot   ✓ written here
        ; BB4:
0000fd: movq    xmm0,[rbp-0xa8]  ; read a for `res << a`           ← reads the spill

a’s spill slot [rbp-0xa8] is written only on the BB3 path. On the BB2→BB4 side exit (a <= endv, taken for i=0: -1.0 <= 1.0), a is live in xmm3 (F(FPReg0)) and the side-branch bridge must store it to [rbp-0xa8] (F → Sf(spill)), but it does not — so BB4 reads the uninitialised slot (6.9e-310). Only the first iteration is wrong because later iterations leave stale-but-plausible data there.

33.3 Root cause and trigger

  • Proximate bug: the side-exit (condnotbr) bridge does not materialise an F(physical) → Sf(spill) value into the spill slot. The normal (F, Sf) arm (slot.rs:1916: fpr2stack + to_sfFprMove) does emit the spill store (FprMove(Xmm→Spill) lowers to movsd [spill],xmm), so the defect is in how the side-branch stub is generated/reconciled, not in FprMove itself.
  • Trigger: layer2-float-by-type. Its type-based promotion of endv to a physical F is what forces a’s merge onto a spill and thus exercises this side-branch F→Sf(spill) path; the default adopt never creates it, which is why stress-spill-pool alone (CI) is green. So this is a latent side-branch/spill bridge bug, surfaced by layer2 under pressure — not unique to layer2.

33.4 Status

Precise, actionable diagnosis reached; no code change (thresholds restored, tree clean). The fix lives in the side-branch bridge generation (ensure a condbr/side-exit edge runs the full F→Sf(spill) materialisation, i.e. emits the FprMove to the spill slot). That is the concrete next task; it would fix the §32 layer2-float-by-type corruption and harden the side-branch spill path generally.

34. Correction to §33: the bridge IS correct — it is an uninitialised-spill Heisenbug

Deeper IR/asm tracing (instrumenting gen_bridge, the outline-bridge emission loop, and the FprMove lowering) corrects §33. The side-branch F→Sf(spill) bridge is not missing — every link in the chain is individually correct:

  • Bridge IR (gen_bridges_for_branches, Side mode): the BB2→BB4 side bridge is FprMove(FPReg0 → FPReg2)a (FPReg0, the addsd result) moved to its spill FPReg2. Exactly the materialisation the target Sf(FPReg2) needs.
  • Survives optimisation: not a self-move (0 != 2), so optimize_peephole keeps it; non-empty, so thread_empty_outline_bridges does not drop it.
  • Reaches emission: the outline-bridge loop emits it with base=192, so FPReg2 resolves to Spill(192-24)=Spill(0xa8) — the same [rbp-0xa8] the BB3 path writes and BB4 reads.
  • Lowering is correct: FprMove(Xmm(s), Spill(d))movq [rbp-d], xmm(s) (x86_64/compile/mod.rs:485). So the bridge does store a to [rbp-0xa8].

So §33’s “the side bridge does not materialise the value” was wrong.

34.1 What it actually is

The defect is an uninitialised-spill Heisenbug: adding any eprintln instrumentation (in gen_bridge or the emit loop) makes the corruption vanish (run_test’s mismatch stops firing). That is the signature of a read of uninitialised/aliased stack, perturbed by the extra code. Two corroborating facts:

  1. a’s spill slot [rbp-0xa8] (FPReg2) is aliased with the scratch staging used to load the 0.5 and -1.0 constants in the body (movq [rbp-0xa8],xmm0 appears for both the constant staging and as a’s home). Under pool=2 the spill region is tiny and heavily reused.
  2. test is JIT-compiled twice — a loop-JIT (partial) and a method-JIT (whole) — and the two place a in different fprs (FprMove(FPReg0,…) vs FprMove(FPReg1,…)). A deopt/transition between the two, or the first-iteration pre-header path, reads the slot before the responsible store on that exact path.

34.2 Status and honest limit

This is a genuine uninitialised-memory/spill-aliasing bug, exposed only by layer2-float-by-type shifting a onto a pool=2 spill, and it is a Heisenbug — instrumentation masks it, so the standard dump/trace tools cannot pin the faulting store/read ordering. Pinning it further needs a non-perturbing probe (e.g. poisoning spill slots with a sentinel and watching which read survives, or a single-stepped memory watch), which is beyond what jit-debug/emit-asm provide.

The whole §27–§34 arc is recorded; the shipping result remains §27 Stage 1 (byte-identical, default build clean, CI green). The layer2-float-by-type spill-aliasing Heisenbug (default-off, unflipped) is the precisely-scoped open item: it lives in the pool=2 spill-slot lifetime/aliasing under that feature’s loop-entry float promotion, not in the bridge generation (§33 corrected).

35. Ruled out: it is not a stack-reservation / spill-clobber bug

Tested the hypothesis that the compiler (or a callee) clobbers the spill region because the frame’s sub rsp does not reserve enough — i.e. the spill slots sit at/below rsp and something writing below the frame corrupts them.

Test: the method prologue is sub rsp, 0xb0 (init_func, prologue_bytes), and the two spills land at [rbp-0xa8] / [rbp-0xb0] — the latter exactly at rsp. Added a 256-byte safety buffer below the frame (sub rsp, prologue_bytes + 256). Against the reliable oracle (the cargo test … test_join_float_register_disagreement run, which fails deterministically — unlike the standalone repro, which is an intermittent Heisenbug), the bug still reproduces (-1.0 != 6.9…e-310).

So extending the frame below the spill region does not help: the spill slot is not being clobbered from below by an under-sized frame or by the compiler’s stack (the compile trampolines already sub rsp, 4088 before calling in, far below the 0xb0 frame). The hypothesis is ruled out.

This confirms §34’s framing: it is a genuine read of an un-written spill slot on a specific path, not a clobber. The side-branch FprMove(FPReg0→FPReg2) that should write [rbp-0xa8] is present and reaches emission (§34), yet the slot reads uninitialised — so the live suspects narrow to (a) the outline-bridge entry label not actually being where the jbe side exit lands (the store is emitted but jumped over), or (b) an allocation disagreement between the loop-JIT (partial) and method-JIT (whole) compiles that place a in different fprs, so one compile’s read path expects the value where the other’s store put it. Both are control-flow/label or cross-compile issues, not stack sizing. Code unchanged; tree clean.

36. Definitive: the machine code is correct — the store IS present

The remaining question — does the emitted machine code load a from an uninitialised spill slot, or is the slot always written first? — is now answered from the actual bytes of the outline (cold-page) bridge, read via get_label_address(&entry).as_ptr() (emit-asm/dump_disas only lists the hot page, which is why the side bridge was never visible before):

66 0f d6 95 58 ff ff ff    movq [rbp-0xa8], xmm2     ; a -> FPReg2 spill slot
e9 c2 0c 02 f0             jmp  BB4

66 0F D6 /r is MOVQ m64, xmm; ModRM 95 = [rbp+disp32], xmm2; disp32 = 0xffffff58 = -0xa8. So the BB2→BB4 side-exit bridge does store a to [rbp-0xa8] and then jumps to BB4 — exactly the materialisation §33 wrongly suspected was missing. (The second fpr bridge is the symmetric movq [rbp-0xa8], xmm3.) And on the path into the bridge, xmm2 holds a (addsd xmm2,xmm1; ucomisd xmm2,xmm3; jbe <bridge> — nothing clobbers xmm2 between).

So the machine code is correct: every path to the BB4 read writes the spill slot first. It is not an uninitialised-load bug at the machine-code level.

36.1 What this leaves

Every artefact that can be observed — bridge IR, emission, FprMove lowering, and now the emitted bytes — is correct, yet the un-instrumented build still corrupts (-1.0 != 6.9…e-310) and any probe (emit-asm, jit-debug, an eprintln, even a stray finalize) makes it vanish. That is a textbook layout-sensitive Heisenbug: the observed build and the failing build differ in register/spill layout, and only the unobserved layout hits the fault. The correct machine code above is the observed layout; the failing layout is, by construction, the one we cannot print.

Pinning it now requires a non-perturbing technique — e.g. poisoning every spill slot with a NaN sentinel in the prologue (changes data, not layout) and seeing which load returns the sentinel, or a hardware watchpoint on the slot — rather than any dump/trace, all of which move the layout. That is the precise, and only, remaining way forward. Code unchanged; tree clean; shipping result stays §27 Stage 1.

37. Captured under emit-asm via recompile; every observable path is correct

A breakthrough on observability: the bug is captured under emit-asm when the failure happens on a partial-recompile (ClassVersionGuardFailed) path — the run_test wrapper drives Array-class version bumps that trigger loop recompiles, and one of those recompiled-loop runs prints the garbage (res[0] = 6.9…e-310) with full asm. So that path is not Heisenbug-masked, and the cold-page side bridge could finally be read.

Using §36’s get_label_address(&entry).as_ptr() byte read in the outline-bridge emit loop, every side-exit fpr bridge in the failing run is:

66 0f d6 95 58 ff ff ff    movq [rbp-0xa8], xmm2    ; a -> FPReg2 spill
e9 .. .. .. ..             jmp  BB4

i.e. correct — the store is present even when the run corrupts. And the two compiles of test (first + recompile) have identical, correct main-page code.

Then the last runtime suspect — the deopt writeback of a spilled live float (a at [rbp-0xa8], only spilled because layer2 pins endv to a physical reg under pool=2) — was checked and is also correct: FprToStackfpr_to_stackload_fpr_into_xmm0(fpr, base), which for fpr.0 >= PHYS_FPR_POOL loads movq xmm0, [rbp-(base-24+8·n)] — the right spill slot.

37.1 Every observable artefact is correct; the bug remains layout-Heisenbug

Verified correct, in the failing configuration: the main-block code, the side- exit outline bridge bytes, the FprMove and FprToStack lowerings, the deopt writeback’s spill read, and the frame’s sub rsp reservation (§35). Yet the un-instrumented build still corrupts res[0] and every probe heals it. The conclusion of §36 stands and is now airtight: this is a layout-sensitive uninitialised/aliased-memory Heisenbug whose failing register/spill layout is, by construction, the one no dump can print — not a defect in any single emitted instruction. The res[0]-only / first-iteration-after-recompile signature most likely implicates the transition (old-compile deopt → VM → recompiled-loop re-entry) leaving a‘s spill slot in a state the fresh layout reads before its own store on exactly that entry — but that crosses two compiles’ layouts, so it too resists a single-build dump.

Only a non-perturbing probe can close it: poison every spill slot with a NaN sentinel in the prologue (data, not layout) and see whether res[0] returns the sentinel (⇒ a genuine pre-store read on the re-entry path) or unrelated garbage (⇒ cross-compile transition). Shipping result unchanged: §27 Stage 1; the bug is default-off (layer2-float-by-type), x86-only, never in CI.

38. The NaN-poison probe fires — §37 is falsified; it is a boxed→F unbox gap

§37’s non-perturbing probe was run: init_func’s prologue now fills the JIT-grown spill region (every 8-byte slot below the last temp, before the nil-clear loop re-clears the temps) with the quiet-NaN sentinel 0x7ff8_0000_dead_beef. This is data, not layout-only in spirit, but note it does add instructions — and the bug still reproduced under cargo test --features layer2-float-by-type,stress-spill-pool test_join_float_register_disagreement:

expected:[-1.0, -0.5, 0.0, 0.5, 1.0]
actual  :[6.92321020550915e-310, -0.5, 0.0, 0.5, 1.0]

Two findings overturn §34–§37:

  1. It is not a pure Heisenbug. Adding the whole poison-fill prologue (a real layout change) did not heal it. The “every probe heals it” claim was an artefact of where the earlier probes sat (in the emit/print path), not a law.

  2. §37’s “uninitialised spill read” hypothesis is falsified. If res[0] were a pre-store read of a spilled a, the poison would surface as NaN. It does not. 6.92321020550915e-310 has bits 0x0000_7f71_f000_1e02 — a 0x7f… userspace pointer (low nibble 0x2, not 16-byte-aligned, so not a clean RValue ptr; its low two bits 0b10 are the flonum tag). So res[0] is a heap Float whose stored f64 is a boxed Value’s pointer bits read as a raw IEEE-754 double, then re-boxed by f64_to_val/float_heap. The corruption is a boxed↔unboxed LinkMode disagreement, not uninitialised memory and not a missing spill store.

  3. Only res[0] (the forward-entry / first iteration) is wrong; res[1..4] (back-edge-resident iterations) are correct. So the defect is on the forward entry into the loop — the pre-header S → F bridge fails to unbox a slot the body then consumes as F, leaving the F home holding the raw boxed pointer. This vindicates §32 (entry-vs-body adopt-set mismatch) and retracts §34’s walk-back.

38.1 Where the seam is

merge.rs:incoming_context builds the loop-entry target and, under layer2-float-by-type, adopts slot i as F iff be.is_float_typed(i) && loop_float.contains(&i) (type+liveness) — a different set from the default mode==F (placement). gen_bridges_for_branches then reconciles each forward entry to target via state.gen_bridge. The hypothesis: layer2 adopts a loop-carried float (endv, or whichever slot aliases a’s physical home) whose forward-entry gen_bridge does not emit the guarded float_to_fpr unbox into the adopted F home (or emits it into the wrong home), so the i=0 body reads the still-boxed pointer. The poison rules the home out of the spill file (not NaN), so the mis-loaded F home is a pool xmm, narrowing the search to the S/Sf/C(float) → F(pool) forward-bridge path under the layer2 adopt set.

38.2 The F home is actively mis-loaded, not read uninitialised

Extending the probe to also poison the pool xmms (movq xmm2..xmm15, NaN at prologue) did not change the outcome: res[0] stays a fresh 0x7f… pointer (6.936…e-310) every run, never the NaN. So the consumed F home is not read before a store — a raw boxed pointer is actively moved into it (a movq xmm, [r14-conv(slot)] from a boxed stack slot, with no float_to_fpr unbox). The pointer shifts run-to-run (ASLR), confirming it is a live heap address, not a constant — and since a’s own values (±1.0/±0.5/0.0) are flonum immediates (never pointers), the mis-read slot is a different slot that holds a heap object. The only loop-live heap object is res (the Array). So on the i=0 forward entry, res’s boxed Array pointer is unboxed-as-float into the home the body reads for a, then re-boxed and pushed — an entry-bridge that unboxes the wrong slot into a’s F home (a register-aliasing / adopt-set mismatch, §32), not a missing store. Next: read gen_bridge’s S/C → F emission for the layer2 adopt set to find the slot whose forward bridge writes a’s home. Shipping result unchanged: §27 Stage 1; bug still default-off, x86-only, never in CI.

38.3 Bridge dump: layer2 forces a to spill; corruption is runtime, not IR

Instrumenting gen_bridges_for_branches to print every loop-merge bridge (entry → target, with the emitted inst stream) for the failing recompile of test pins the placement exactly. Slot map: %1=res, %2=i, %3=endv, %4=a. The forward (method/loop) entry bridge is correct:

BRIDGE BB1 (loop head) entry=all-S
  target: [%3: F(FPReg(1))] [%4: S(Value)] …
  inst  : [StackToReg(%3,Rax), FloatToFpr(Rax,FPReg(1),deopt)]   ; endv unboxed, guarded

Under layer2-float-by-type, endv (%3) is adopted F for the whole loop and permanently pins the physical reg FPReg(1). With stress-spill-pool (POOL=2, only FPReg(0..1) physical), the body then computes a (%4) into the one remaining physical FPReg(0), and at the diamond join BB4 must move it to FPReg(2) — a spill (id 2 ≥ POOL):

BRIDGE BB4 Side(BB2)  entry [%4:F(FPReg(0))] → target [%4:F(FPReg(2))]  inst [FprMove(FPReg(0),FPReg(2))]

Every iteration takes this same Side path (a = -1+i·0.5 ≤ 1.0 = endv always, so a > endv is always false), so the IR is identical for i=0..4 — there is no IR-level distinction for the corrupted first iteration. The spill offsets agree: the store FprMove(FPReg(0)→FPReg(2)) and the res << a read (FprToStack → fpr_to_stack → load_fpr_into_xmm0) both resolve FPReg(2) to [rbp-168] (base_stack_offset=192, 168 = 192-24+8·0), well clear of the live slots (conv(%1)=72, conv(%4)=96) — so no slot/spill aliasing, and 168 is inside the poisoned region yet res[0] is still the pointer, not NaN.

Conclusion. The store writes xmm2 (FPReg(0)) to [rbp-168]; the read loads it back; both offsets are correct and poisoned. So on the i=0 path xmm2 itself transiently holds a boxed pointer at the moment a’s value is spilled — the a = -1.0 + i*0.5 computation deposits a non-float into its result reg on exactly the first iteration. This is a runtime register-state corruption, not a missing store, not an aliased slot, not an uninitialised read (all falsified). It is caused by the layer2 × spill interaction (pinning endv forces a to live in FPReg(0)/spill under POOL=2), but the defect is upstream of the bridge: the first-iteration float computation feeding FPReg(0). Pinning the exact instruction needs a runtime watch on xmm2 across i=0’s a-computation (gdb/hardware watchpoint) — the IR and emitted bytes are now exhausted as evidence. Shipping result unchanged: §27 Stage 1; bug default-off (layer2-float-by-type), x86-only, never in CI.

39. SOLVED — the deopt write-back undoes the loop sp-bump too early

The gdb watch closed it, and it is not a regalloc bug at all — §32/§38’s “upstream of the bridge” instinct was right but the culprit is the deopt side-exit handler, and the trigger is mundane stack discipline.

39.1 The decisive observation

Break on Value::float_heap (called only for the bug, since ±1/±0.5/0 are all flonum immediates → no heap Float on the happy path). It fires with num = 0x00007fff_40001e02. That is not a float and not res — it is a code address on the cold page (0x7fff40000000…). Disassembling there:

0x7fff40001df5: add  $0x10,%rsp           ; ← undo the loop-JIT rsp bump
0x7fff40001df9: movq %xmm3,%xmm0          ; box endv
0x7fff40001dfd: call f64_to_val           ; pushes ret-addr 0x7fff40001e02 at [rsp-8]
0x7fff40001e02: mov  %rax,-0x40(%r14)
0x7fff40001e06: movq -0xa8(%rbp),%xmm0    ; read a's spill — now 0x7fff40001e02 !
0x7fff40001e0e: call f64_to_val           ; box the garbage → float_heap(num)

num equals the return address of the immediately-preceding call in the same bridge. Proof, not inference: that call pushed its return address at [rsp-8], and [rsp-8] == [rbp-0xa8] because the bridge had just done add $0x10,%rsp. The constant pool was verified correct in the same session (0.5 = 0x3fe0…, -1.0 = 0xbff0…), ruling out the const-corruption hypothesis.

39.2 Root cause

side_exit_with_label (the deopt / evict / recompile handler) did:

if loop_jit_spill_bytes > 0 { addq rsp, bytes }   // undo bump
gen_write_back_for_deopt(wb, base)                 // box spilled floats via calls

The loop-JIT entry’s subq rsp, bytes (emit_loop_jit_rsp_bump) is exactly what keeps rsp below the spill region ([rbp-(base-24+8n)]). Undoing it before the write-back exposes the spill slots: the write-back boxes each spilled float with a call, and the call’s pushed return address lands on the very slot it is about to read. The first boxed value (endv) corrupts the second (a), so the deopt writes a code pointer reinterpreted as f64 back to the VM frame, and the VM resumes the loop body with that garbage as ares[0].

This is why it is first-iteration-only (the partial recompile’s class-version guard deopts once, on entry, while the version is still stale), spill-only (no spill ⇒ no slot below the restored rsp), and layer2-dependent (pinning endv to a physical reg under POOL=2 is what forces a to spill in the first place). It also explains why every static artefact looked correct: the corruption happens at runtime, inside the cold deopt bridge, between the boxing call and the spill read.

39.3 Fix

Reorder: run gen_write_back_for_deopt first (while the bump still protects the spill slots — the boxing calls then push below the region), then undo the bump. Applied to x86 side_exit_with_label; the aarch64 twins (a64_gen_deopt, a64_gen_handle_error) had the identical undo-before-write-back order and were reordered to match (the other aarch64 unwinders — emit_raise/retry/redo/ensure_end — already undo after their call, so they were correct). The aarch64 emit_loop_jit_rsp_bump likewise lowers sp below the fp-relative spills, so the same hazard and the same fix apply.

Verified on x86: test_join_float_register_disagreement passes under layer2-float-by-type,stress-spill-pool; the full default lib suite is 1706/0; stress-spill-pool alone and layer2+stress codegen suites are green. The aarch64 reorder is by symmetry (untested on the x86 host; M1 CI will confirm).

39.4 Bearing on §5

This was a latent deopt-bridge bug, not a flaw in the §27 Stage-1 ②/③ split — the regalloc separation work is vindicated. The bug was merely exposed by layer2-float-by-type because that policy is the first thing that reliably drives a loop-carried float into a spill slot whose deopt write-back then trips the stack-discipline error. With §39 fixed, layer2-float-by-type no longer has a known correctness blocker under stress-spill-pool.

40. Bench gate for layer2-float-by-type: a −9% mandelbrot regression, root-caused and fixed

With §39 unblocking correctness, the bench gate (§13.4) was run on x86-64, layer2-float-by-type ON vs OFF (release, best-of-N min):

benchmarkOFF (base)ON (old adopt)ratio
so_mandelbrot (2000²)0.94 s1.03 s1.086 ❌
so_nbody (200k)0.249 s0.247 s0.991
app_aobench (256²)6.23 s6.12 s0.983

mandelbrot regressed ~9 %, and the regression scaled with the float loop (600² 2.2 % → 2000² 9 %), so it was steady-state, not noise — and on the default POOL=14 build, so not spill pressure.

40.1 Root cause (emit-asm of the kernel loop)

emit-asm of the isolated complex-iteration kernel (tr=zr²−zi²+cr; ti=2zr·zi+ci; zr,zi=tr,ti) showed the back-edge bridge:

  • OFF: 4 pure movq xmm,xmm (carried floats stay F, unboxed).
  • ON: 4× movq xmm0,xmmN; call f64_to_val; mov [rbp-off],rax — i.e. it boxes the carried floats every iteration, then reloads them at loop entry.

So layer2’s loop-entry/back-edge target held the carried floats as S (boxed), not F. The cause: the L2-1 adopt set is_float_typed(i) ∧ loop_used_as_float(i) is narrower than the fixpoint’s mode==F — it misses copy-aliased carried floats (the zr,zi = tr,ti duplicates, written by copy and only read as float next iteration, so their UseTy isn’t Float). OFF unboxes 6, ON only 4; the missing 2 get boxed/reboxed per iteration.

40.2 Fix — adopt the union (layer2 ⊇ greedy)

#![allow(unused)]
fn main() {
let adopt = |i| (be.is_float_typed(i) && loop_float.contains(&i))
             || matches!(be.mode(i), LinkMode::F(_));
}

Keep the type+liveness signal (the L2-1 decoupling intent — it can still adopt more than placement), but never adopt a narrower set than the fixpoint, so a carried float the fixpoint kept F can never be boxed at the back-edge. This re-introduces a floor dependence on placement (mode==F), which is acceptable: §16.6 already established that fully placement-free adoption is perf-neutral at best, and the gate is what matters for default-promotion.

40.3 Result — gate passes

Release best-of-13, layer2-fixed vs base: mandelbrot 0.995 (regression gone, 0.5 % faster), aobench 0.987 (1.3 % faster), nbody ≈1.0. All within noise or better → the bench gate passes. Correctness preserved: the full stress suite (stress-spill-pool,gc-stress,layer2-float-by-type) is 2090/2090. The default build is unchanged (the adopt block is layer2-float-by-type-gated). layer2-float-by-type is now a candidate for default-on.

41. layer2-float-by-type promoted to default-on (bench gate cleared, both arches)

The §40 union-adopt fix cleared the bench gate on both architectures, so layer2-float-by-type is added to default in monoruby/Cargo.toml.

x86-64 (release, best-of-13, layer2/base): mandelbrot 0.995, aobench 0.987, nbody ≈1.0 — no regression, mandelbrot/aobench faster.

M1 / arm64-apple-darwin (benchmark-driver, i/s, layer2/base):

benchratiobenchratio
mandelbrot1.002nqueen1.018
aobench1.021bedcov1.019
nbody1.001fib0.997
bf / sudoku / matmul≈1.00

No benchmark regresses beyond noise; aobench/nqueen/bedcov gain ~2 %. The mandelbrot −9 % seen with the old (pre-§40) adopt is gone on both arches.

The feature is kept (not yet folded) so --no-default-features rolls back to the greedy placement adopt and the A/B stays available; it will be folded once it has soaked. Correctness already covered: the full stress suite (stress-spill-pool,gc-stress,layer2-float-by-type) is 2090/2090 on x86-64, and bin/test passes on M1 + x86-64.

Note (separate, pre-existing): a standalone (single cold run) so_nbody / size-2000 so_mandelbrot raises a spurious ZeroDivisionError on arm64-apple-darwin plain release builds — impossible under correct Ruby float semantics, so a darwin-aarch64 codegen miscompile in the POOL=14 float path. It is sidestepped by stress-spill-pool (so bin/test and the benchmark-driver warmup path are unaffected) and is unrelated to layer2 (reproduces on base). Tracked separately; not reproducible under linux-aarch64 QEMU.

42. Stage 2a+2b landed behind phys-loop-aware: §29 inertness cracked (under pressure)

Implements the §27.3 loop-aware allocator and — unlike the reverted §28/§29 attempt — verifies it is non-inert.

Stage 2a (collect L). A loop_carried: HashSet<SlotId> on SlotState records the loop-carried float set (slots F/Sf at the back-edge), populated at the loop-entry merge from backedge_for_floats. The timing §29 lacked is solved by the multi-iteration fixpoint: the back-edge is already computed before real codegen, so L is known at merge time. It propagates into the body for free via Clone and the &mut self joins (a correctness-neutral hint). Confirmed populated: the complex-iteration kernel gives L=6 (F=4, Sf=2); so_nbody L=8 (F=7, Sf=1).

Stage 2b (use L). The phase-1 spill-victim filter (gated on phys-loop-aware) excludes an all-Sf fpr that holds a loop-carried slot, so a fresh value takes a phase-2 spill instead of evicting a loop-carried Sf cache — fewer in-loop reloads (§27.3-2b).

Non-inertness (the verification the user asked for). Via the §24 shadow digest, on the kernel:

configkernel loop digestn (placements)
shadow-placement (off)0x48168ad7c99d76d528
+ phys-loop-aware0x6191a84d9623dbad26

The digest differs and the placement count drops 28 → 26 — keeping the loop-carried Sf resident removed two in-loop reload placements, exactly §26’s predicted “fewer back-edge moves / different placement”. So the §29 wall (“L always empty at allocation”) is cracked: L is available and changes placement.

Scope / honest caveat. The lever only engages under register pressure: phase 1 (the Sf-demote it gates) runs only when phase 0 finds no vacant fpr. Under the shipping POOL=14, float loops with < 14 simultaneously-live floats never hit phase 1, so phys-loop-aware is inert there and the shipping build stays byte-identical (the so_nbody POOL=14 digest diff is empty). It bites under stress-spill-pool (POOL=2) and on genuinely high-pressure loops (the doom-renderer ≈14-float class). So its real-hardware value is narrow, and 2c (the M1 perf A/B) measures exactly that.

Correctness. stress-spill-pool,gc-stress,phys-loop-aware (the lever firing under GC) is 2090/2090. Default build unaffected (the field is an inert, #[allow(dead_code)] hint; all policy code is phys-loop-aware-gated).

Status. Stage 2a+2b are implemented and proven non-inert; 2c (M1 perf A/B on high-pressure float code) is the gate, and given the POOL=14 inertness it should be evaluated on whether any shipping benchmark has enough FP pressure to benefit.

43. (2) the global-pin lever: headroom measured, win is copy coalescing (partial)

Per the user’s choice to pursue the §26 “eliminate back-edge moves” lever (which, unlike §42’s pressure-gated policy, can bite under shipping POOL=14), the back-edge FP-reconciliation move count was measured (default build, POOL=14, per loop iteration):

benchmarkback-edge bridges w/ movestotal FprMove+FprSwap
kernel (complex iter)14
so_nbody17
so_mandelbrot (150)629

So there is per-iteration headroom under POOL=14. But the emit-asm decomposition of the kernel’s 4 fixes what kind of move, and it reshapes (2):

000265: movq xmm4,xmm9     ; zr (new, in xmm9) -> xmm4
00026a: movq xmm5,xmm8     ; zi (new, in xmm8) -> xmm5
000273: movq xmm6,xmm9     ; zr -> xmm6   ← duplicate copy of zr
000278: movq xmm7,xmm8     ; zi -> xmm7   ← duplicate copy of zi

The 4 moves are 2 reconciliations (zr,zi land in the header’s regs) + 2 duplications (zr is kept in two regs xmm4/xmm6, zi in xmm5/xmm7, because two copy-aliased slots each got their own fpr).

43.1 Only the duplications are eliminable

  • The reconciliations are intrinsic. zr := tr produces the new zr in tr’s reg; landing it in the header’s expected reg costs one move somewhere. Pinning zr to a fixed reg merely relocates that move from the back-edge bridge into the body assignment — same per-iteration cost (confirmed by the SSA model: a relabel reconciliation cannot be moved off the critical path, only shifted).
  • The duplications are coalescable. FprAllocator.vfpr is Vec<Vec<SlotId>> — one fpr already can hold several slots. The two zr-aliases sit in separate fprs only because the parallel-assignment codegen split them. Coalescing copy-aliased loop-carried floats into one fpr removes the duplicate back-edge move (kernel 4 → 2).

43.2 Consequence

(2) is a copy-coalescing optimization, not a generic “global pin”: it recovers ~half the back-edge moves (the duplications), and the rest are intrinsic. That is a real but partial win, a substantial fixpoint-allocator change (coalesce decision threaded through copy_slot + the merge target), squarely in §16.6’s regressed- before risk class, and still M1-gated. The cheaper §42 phys-loop-aware lever is orthogonal (pressure-gated); coalescing is the POOL=14-relevant one but pays only on copy-heavy loop-carried floats (the a,b = c,d swap/rotate shape).

44. layer2-float-by-type folded into the default and removed

The type+liveness loop-entry float adoption (§16 L2-1, promoted to default-on in §41) has soaked as the default with no regression, so the Cargo feature is now removed rather than merely default-on:

  • the adopt block in merge.rs is unconditional (the union-adopt of §40 — the mandelbrot-safe signal be.is_float_typed(i) && loop_used_as_float(i)be.mode(i) == F);
  • the A/B-only #[cfg(not(feature = "layer2-float-by-type"))] placement-based adopt path is deleted;
  • layer2-float-by-type is dropped from [features] and the default set in monoruby/Cargo.toml, and the bin/ harness (test, bench, doom) no longer passes it.

Only the build knob is gone; runtime behaviour is unchanged from the default-on state. The §31/§32 layer2-float-by-type × stress-spill-pool history is retained above as the record of the pressure bug that §39/§40 fixed.

Inline asm function

  • To avoid the overhead of method calls in performance-critical code paths, we can inline certain method calls directly into the generated machine code using inline assembly functions.
  • This is particularly useful for small, frequently called methods where the overhead of a function call would be significant compared to the method’s execution time.
  • In inline asm functions, we have direct access to the JIT context, allowing us to manipulate the abstract state and generate machine code as needed.
  • We can do ‘trial inlining’ by attempting to inline a method call and reverting to the original state if inlining is not possible.

output

  • accumulator(r15): result: Value
#![allow(unused)]
fn main() {
impl<'a> JitContext<'a> {
    fn inline_asm(
        &mut self,
        state: &mut AbstractState,
        ir: &mut AsmIr,
        f: impl Fn(
            &mut AbstractState,
            &mut AsmIr,
            &JitContext,
            &Store,
            CallSiteId,
            ClassId,
            BytecodePtr,
        ) -> bool,
        callid: CallSiteId,
        recv_class: ClassId,
        pc: BytecodePtr,
    ) -> bool {
        let state_save = state.clone();
        let ir_save = ir.save();
        if f(state, ir, self, &self.store, callid, recv_class, pc) {
            true
        } else {
            *state = state_save;
            ir.restore(ir_save);
            false
        }
    }
}
}

the signature of inline asm function is as follows:

#![allow(unused)]
fn main() {
fn(
    &mut AbstractState,
    &mut AsmIr,
    &JitContext,
    &Store,
    &CallSiteInfo,
    ClassId,
    BytecodePtr,
) -> bool
}

inline asm function example

  • We must return true if inlining succeeded, otherwise false.
  • We must take arguments directly from the caller’s stack using callsite information (CallSiteId).
  • An ‘inlinable’ method call should have ‘simple’ call site, which means no keyword arguments, no splat arguments, and no block argument.
  • use AsmIr::inline() to embed a machine code directly.
  • use AbstractState::def_rax2acc() for moving the result (in rax) to the accumulator.
#![allow(unused)]
fn main() {
fn kernel_nil(
    state: &mut AbstractState,
    ir: &mut AsmIr,
    _: &JitContext,
    store: &Store,
    callid: CallSiteId,
    _: ClassId,
    _: BytecodePtr,
) -> bool {
    let callsite = &store[callid];
    if !callsite.is_simple() {
        return false;
    }
    let CallSiteInfo { recv, dst, .. } = *callsite;
    if state.is_nil(recv) {
        if let Some(dst) = dst {
            state.def_C(dst, Value::bool(true));
        }
    } else if state.is_not_nil(recv) {
        if let Some(dst) = dst {
            state.def_C(dst, Value::bool(false));
        }
    } else {
        state.load(ir, recv, GP::Rdi);
        ir.inline(|r#gen, _, _| {
            monoasm! { &mut r#gen.jit,
                movq rax, (FALSE_VALUE);
                movq rsi, (TRUE_VALUE);
                cmpq rdi, (NIL_VALUE);
                cmoveqq rax, rsi;
            }
        });
        state.def_rax2acc(ir, dst);
    }
    true
}
}
  • Here, we check if the call site is simple. If not, we return false to indicate inlining failed.
  • We then check the abstract state of the receiver. If we can determine it’s definitely nil or definitely not nil, we set the destination accordingly.
  • If we cannot determine the state of the receiver, we generate machine code to perform the check at runtime.
  • Finally, we move the result from rax to the accumulator and return true to indicate successful inlining.

JIT 最適化方針: Argument Forwarding (def f(a, ...) g(...) end)

本書は argument forwarding(...)を JIT で最適化するための実装方針を、 現行コードの該当箇所に紐づけて記述する。原則・段階分け・deopt 安全性 (呼び出し元が特に注意を要求した点)を中心にまとめる。

1. 現状のコスト構造

...ParamKind::Forwardingruruby-parse/src/node.rs:263)として パースされ、monoruby/src/globals/store.rs:943-952

  • 合成 rest スロット
  • 合成 kw_rest スロット(SlotId(1 + args_names.len())
  • 匿名 block パラメータ

へ脱糖される(ParamsInfo::forwarding = trueglobals/store/iseq.rs:608)。

g(...) 呼び出しは bytecodegen/method_call/arguments.rs:70-140handle_forward が、

  • splat_pos に mother の rest を指す位置(純転送は (pos_start, 1, vec![0])、 先頭引数つきは splat_pos.push(len) で末尾)、
  • hash_splat_pos = [kw_rest]
  • BlockArgProxyinst.rs:120、エンコード encode.rs:374-378

を持つ CallSite { forwarding: true } を生成する。

実行時コストは 2 箇所:

  1. caller → f: set_callee_frame_arguments (codegen/runtime/args.rs:134-216)。a を超える位置引数を rest Array に確保し、余剰 keyword を kw_rest Hash に確保する (fill_positional_args args.rs:354-372)。
  2. fg(...): is_simple_callglobals/store/function.rs:1241)が has_splat() により偽 → JIT は specialize 不可で AsmInst::SetArgumentscodegen/jitgen/compile/method_call.rs:992、 lowering asmir/compile.rs:332)→ jit_generic_set_arguments の 汎用パスへ落ちる。

g(...)args.rs:183-188pos_args==1 && splat_pos==[0])は rest Array をそのまま fill_positional_args1 に渡すため中間 Vec は 出ない。先頭引数つき g(x, ...)args.rs:189-207 の汎用 splat 分岐で 呼び出し毎に Vec<Value> を確保する。

2. 核心的観察 — forwarding は不透明パイプ

Ruby では ... は名前を持てず、f のコードから rest/kw_rest/block を 観測する手段が一切ない。唯一の読み手は handle_forward 生成の転送先 callsite と BlockArgProxy だけ。したがって f が確保する rest Array / kw Hash は次を除き Ruby から決して観測されない:

  • f がインタプリタへ deopt(呼出規約上 rest/kw_rest スロットに実体を期待)
  • フレームが capture される(binding、外側 proc 等。 possibly_capture_without_block / branch_if_captured が既存ガード)

これは JIT が float を XMM に保持し deopt 時のみ stack へ書き戻す WriteBackdoc/jit_architecture.md:188-197)と同型の 「遅延実体化(lazy materialization)」問題である。

3. 段階的実装計画

Increment 1 — f→g 呼び出しの specialize(Array は温存)【実装済み — required-only g、先頭引数対応】

実装済みスコープ: forwarding g(x.., ...)callsite.forwarding かつ 末尾単一 splat splat_pos==[pos_num-1]、先頭 lead_num = pos_num-1 個の通常引数 + ... rest)で、g が iseq かつ required 引数のみno_keyword && !is_rest && opt_num==0 && post_num==0)かつ req_num()+1 >= pos_num(= req_num >= lead_num) の場合。純転送 g(...)lead_num==0 の特殊形として同経路に内包。 AsmInst::SetArgumentsForwardedasmir.rs、lowering は asmir/compile/method_call.rs::jit_set_arguments_forwardedobject_send_splat_arg0 / object_send_handle_arguments の実証済み パターンを範とする)を compile/method_call.rs::set_arguments の 非 simple 分岐に追加。

asm(書込み前ガード → ミスは無ロールバックでフォールバック): self を LFP_SELF へ → lead_num 個の先頭引数を frame slot args+i から callee slot i へ unroll コピー → args+lead_num... Array を読み tag/RVALUE_OFFSET_TY==ARRAY 検査 → RVALUE_OFFSET_ARY_CAPA/HEAP_LEN/INLINE/HEAP_PTR で len と 要素基底取得(inline/heap 両対応)→ 長さガード cmpq len,(expected_len)expected_len = req_num - lead_num、 即値)→ forwarded kw_rest が非 nil なら脱出 → callee slot lead_num.. へ src 昇順 / dst 降順の 2 ポインタコピー → 成功 sentinel rax=NIL_VALUEhandle_errortestq rax,rax; jeq)。 ガードミスは page1 fallback: で既存 jit_set_argumentsjit_generic_set_arguments)にバイト一致委譲。Array 温存ゆえ deopt は自明に安全。

検証: Ruby 4.0.4 比較ハーネスで forwarding 20/20 グリーン。 新規ケース: pure(inline≤5 / heap>5(ARRAY_INLINE_CAPA=5)/ 0-arity)、arity 不一致→ArgumentError 等価、kw 転送→フォールバック、 block 透過、3 段連鎖+値コピー不変、先頭引数(lead=1 / multi+heap / 空 rest / lead 過多→ゲート却下 ArgumentError 等価 / block+ミス)。 ゲート発火(純: lead=0、先頭: lead=1)を JIT 実コンパイル下で計装 確認。gc-stress 緑。フルスイープで環境性失敗集合に対し新規退行ゼロ。

opt/post/rest を持つ g(runtime ヘルパ方式)【実装済み】

rest 付き gdef g(a,*r) 等)は *rest 配列の新規確保が CRuby セマンティクス上不可避で、Increment 2(SmallVec 化)後の汎用パス比 の利得は限界的、かつ手書きアロケーション asm は GC/ライトバリア絡み で破壊リスクが高い。よって 専用 runtime ヘルパ方式を採用:

  • runtime::jit_forwarded_set_argumentsjit_generic_set_arguments と同シグネチャ)。forwarding 形状(末尾単一 splat、lead = pos_num-1)が静的に既知なので、転送 kw が空の常套ケースは 汎用 set_callee_frame_argumentssplat_pos 走査・余剰 kw ex 機構をスキップして positional buffer を直接構築し fill_positional_args1(req/opt/rest/post を正しく処理)へ。 kw が実際に転送される稀ケースは実証済み汎用関数へ委譲し、 微妙な kw→rest セマンティクスをバイト一致で保つ。
  • AsmInst::SetArgumentsForwardedHelper の lowering は jit_set_arguments と同一の実証済み asm 形状(レジスタ設定・ rsp 調整・エラー処理)で call 先のみ差し替え。手書き asm ループ・アロケーションは一切追加せず asm リスクは増えない。
  • ゲート: required-only 分岐の後段に forwarding && splat_pos==[pos_num-1] && is_iseq && no_keyword。 required-only(確保ゼロ inline)が先取り、rest/opt/post が本経路、 kw パラメータ持ち callee は汎用据置。

検証: forwarding 26/26(新規 6: pure rest / rest-only+先頭 / opt+post+rest / kw 転送→委譲 / block 透過 / 連鎖+rest 変異不変)。 ヘルパ発火を実 JIT 下で pure(pos_num=1)・先頭(pos_num=2) ともに 計装確認・結果厳密一致。gc-stress 緑。フルスイープ退行ゼロ。

Increment 4 — super 暗黙転送(単一 splat 任意位置)【実装済み】

jit_check_supercompile.rs:894)が super 先 FuncId をコンパイル 時解決し、handle_super_forwardarguments.rs:142-213)は forwarding=true の CallSite を生成するため、super も同じ set_arguments 経路に乗る。計装調査の結果:

  • def m(a,b); super; end(splat なし)→ 既に is_simple 特化済み。
  • def m(a,*r); super; end(rest 末尾、sp=[1]=[pn-1])→ Increment 1 系で既に specialize 済み。
  • def m(a,*r,z); super; endrest の後ろに postsp=[1]≠[pn-1]) → 従来は汎用パス。未特化はこの形だった。

ヘルパゲートを splat_pos==[pos_num-1] から splat_pos.len()==1(任意位置の単一 splat)へ一般化し、 jit_forwarded_set_arguments の fast path を sp=splat_pos[0] として lead[0..sp] ++ splat配列 ++ post[sp+1..] (汎用 splat 分岐とバイト一致の順序)を直接構築するよう一般化。 zero-alloc inline 路は trailing+required-only のまま据置(post を 跨ぐ asm は複雑化=リスクのため安全なヘルパへ誘導)。asm/AsmInst 変更なし(ヘルパは callsite から splat_pos を読むのみ)。

検証: forwarding 31/31(新規 5: super rest+post / rest 末尾 / opt+rest+post / splat 無し透過 / block 透過)。rest+post super が 従来 GENERIC → 本変更で HELPER 経路へ移行を実 JIT 計装確認、 結果 CRuby 一致。gc-stress 緑。フルスイープ退行ゼロ。

未対応(フォールバック据置): kw パラメータを持つ callee への転送/super(汎用据置)。


(以下は当初計画の原文)

最も低リスク。f の caller が確保した rest Array を温存したまま、 g(...) 呼び出しのみを最適化する。Array が実在するため deopt は インタプリタが実 Array を普通に使うだけで安全(新規の heap 実体化不要)。

  • 述語追加(globals/store/function.rs 付近): callsite.forwarding かつ callee が iseq、転送束ねが「末尾単一 splat(= rest Array)+ forwarded kw_rest + proxy block」の形であることを判定。
  • compile/method_call.rs::set_arguments(871-995)の非 simple 分岐 (989 の else)に、上記形のときだけ通る専用 lowering を追加。 既存 simple/汎用パスはバイト一致で不変に保つ(blast radius 限定)。
  • 専用 lowering: rest Array 長を実行時に読み、観測値 N に対する 長さガードGuardArrayTyasmir.rs:932,367 を範とする新ガード) を張り、一致時は simple 充填路(fetch_for_callee / fetch_rest_for_calleestate/read_slot.rs:195-244)で Array 要素を g フレームへ直接 mov。不一致は deopt (実 Array が在るのでインタプリタ復帰は自明に正しい)。
  • これにより g の specialize / inline(specialized_iseqmethod_call.rs:254-272)が forwarding 越しに可能になる。
  • 単相 forwarding(def log(...); real(...); end 等、常に同 arity)で 長さガードはほぼ当たり、deopt スラッシュは起きない。

検証: 純/先頭引数つきの positional forwarding(本環境で検証可能)、 長さ不一致を強制する deopt テスト、--features deopt

Increment 2 — mixed 経路の Vec 排除【実装済み】

set_callee_frame_arguments の汎用 splat 分岐(args.rs:189- 付近)は forwarding(g(x, ...) / super(x, ...))等で呼び出し毎に Vec<Value> をヒープ確保していた。これを smallvec::SmallVec<[Value; 8]> に置換し、引数列が短い通常ケースでヒープ確保を消去(巨大引数列のみ heap へスピル)。分配ロジック(fill_positional_args1)は不変で共有、 x86 非依存・低 blast radius。Ruby 4.0.4 比較ハーネスで forwarding スイート全 8 件+method_call 全 64 件グリーンを確認。

Increment 3 — f 側 rest Array / kw Hash の確保省略(要 deopt 実体化)

最大の利得かつ最大のリスク。f が forwarding-transparent (forwarding() && 非capture)な JIT パスで rest Array / kw Hash の 確保を発行せず、抽象状態に新 LinkModeForwardRest{src_base,src_len} / ForwardKw{..}jitgen/state.rscontext.rs)として記録。転送元 (caller 引数領域)を pin し、f 内の全転送 callsite を跨いで維持する。

Deopt 安全性(呼出元が注意を要求した点):

  • (D1) f 内 deopt: 各側方退出で pin 済み転送元から rest Array / kw Hash を新規確保して rest/kw_rest スロットへ書き戻し、block を 復元する遅延実体化を WriteBackdoc/jit_architecture.md:191)へ 追加。float spill-on-deopt と同枠組み、生成物が heap obj になる差のみ。 *rest 意味通り「毎回新 Array」で意味論も整合。
  • (D2) g(...) 直前/呼出ガード失敗: set_arguments は既に reg_sub Rsp → 充填 → reg_add Rsp 順。pin 転送元を呼出確定まで 上書きしない順序を守り、ガード失敗時も束ね再構築可能に保つ。
  • (D3) 多重転送 def f(...); g(...); h(...); end: pin を最初の転送で 解放せず最終転送 or deopt まで維持。

Increment 4 — super 暗黙転送・block 透過

handle_super_forwardarguments.rs:142-213)。block 転送が move_frame_to_heap を誘発する specialize 拒否(method_call.rs:72-86has_block_arg())と最も込み入って干渉するため最後に。block は LFP_BLOCK に既存、透過専用パスを用意して解決する。

4. フォールバック条件(現行 eager パス据置)

  • fbinding / フレーム capture / possibly_capture_without_block
  • g が単相に未解決(megamorphic / 未キャッシュ)
  • single_arg_expand(block-style callee)対象の転送
  • 本書が扱わない束ね形(複数 splat、ex あり 等)

5. 変更ファイル一覧

箇所内容Increment
globals/store/function.rsforwarding-shape 述語1
codegen/jitgen/compile/method_call.rs::set_argumentsforwarding 専用 lowering1
codegen/jitgen/asmir.rs / asmir/compile.rs長さガード asm 命令1
codegen/runtime/args.rsmixed Vec 排除2
codegen/jitgen/state.rs / context.rsLinkMode::ForwardRest/ForwardKw、pin 管理3
codegen/jitgen/asmir/compile/init_method.rs + prologue確保抑止・束ね記録3
codegen/jitgen/deoptimize.rsWriteBack遅延実体化3
bytecodegen/method_call/arguments.rssuper 透過調整4

6. 検証戦略

  • run_test で forwarding 形状マトリクス + deopt 強制版(型変化ガード / BOP 再定義 / block 経由)を回し遅延実体化を踏ませる。
  • --features deopt*rest 同一性(呼出毎 fresh)テスト。
  • --features gc-log でホットパスの Array/Hash 確保ゼロをベンチ確認。
  • 注: Ruby 3.4↔3.3 の Hash inspect 差(build.rs MIN_RUBY_VERSION=(4,0)) のため keyword を印字する比較は Ruby 4.0 環境で行うこと。positional 転送は Ruby 3.3 環境でも検証可能。

7. 検証環境の構築(ネットワーク制限下)

CRuby 比較ハーネスは Ruby ≥4.0 を要求するが、apt/cache.ruby-lang.org は 遮断される一方 github.com は到達可能。再現手順:

  1. git clone --depth 1 --branch ruby_4_0 https://github.com/ruby/ruby.git
  2. ./autogen.sh && ./configure --prefix=/usr/local --disable-install-doc
  3. make -j$(nproc)make install-localmake install は bundled gems 取得で失敗するため install-local
  4. bundled/default gems 未取得のため export RUBYOPT=--disable-gems
  5. ruby -e 'puts RUBY_VERSION' > ~/.monoruby/ruby_versionruby -e 'puts($:)' > ~/.monoruby/library_pathtouch monoruby/build.rs

これで cargo test の CRuby 比較が Ruby 4.0.4 で機能する。

x86-64 / aarch64 JIT backend differences

A survey of how the two JIT machine-code backends differ today, focused on AsmInst coverage and lowering logic. It is current as of the full aarch64 port (#704) and the chunked-literal frame-size fix (#709).

  • x86-64 backend: monoruby/src/codegen/arch/x86_64/compile/ (split into mod.rs, binary_op.rs, method_call.rs, variables.rs, index.rs, builtin.rs, defined.rs, definition.rs, constants.rs, init_method.rs) + guard.rs.
  • aarch64 backend: monoruby/src/codegen/arch/aarch64/compile.rs (one ~4.8 k-line file) + guard.rs.
  • Shared front-end + dispatcher: monoruby/src/codegen/jitgen/ (TraceIR → AsmIR) and jitgen/asmir/compile_shared.rs (the arch-neutral AsmInst lowering dispatcher).

History. An earlier revision of this document (pre-#704) described aarch64 as a streaming port that bails to the VM on any instruction shape it could not lower, and catalogued ~two dozen bail sites. That is no longer true. As of #704 aarch64 lowers every AsmInst and every side exit, so it never bails out of JIT compilation. The sections below describe the current, bail-free state; §4 covers the asymmetries that do remain (recompilation strategy and eviction patching — not coverage).


1. The big picture: one front-end, two backends, coverage-symmetric

Both backends consume the same arch-neutral AsmIR produced by jitgen (TraceIR → register-allocated AsmIR). They diverge only at the final AsmIR → machine-code step, driven by a single shared dispatcher, Codegen::compile_asmir (compile_shared.rs:25), which lowers each AsmInst by one of two routes:

  1. Shared arm — the match in compile_asmir handles the instruction structurally and calls a tiny per-arch emission primitive (emit_reg_move, emit_reg_to_stack, emit_guard_class, emit_integer_binop, …). Only the emitted bytes differ per arch.
  2. Per-arch arm — the other => fallthrough calls compile_asmir_arch, the backend-private match (x86_64/compile/mod.rs:23, aarch64/compile.rs:4730). On both arches this handles only the same three specialized inlined-frame variants (GuardClassVersionSpecialized, RecompileDeoptSpecialized, SetArgumentsForwarded); everything else is handled by the shared arm.

The bool return is now vestigial

Every emission primitive (and compile_asmir itself) returns a bool. In the pre-#704 port this was the aarch64 “not-yet-ported / out-of-range → bail to the VM” signal. Today both backends always return true:

  • x86-64 is the original fully-featured reference backend; it never declines.
  • aarch64 now lowers everything too — large frame/field/sp offsets are materialized through scratch registers rather than bailing, and the one shape that needed unported caller-relative codegen (the ...-forwarding deferral) is disabled upstream in forward_rest_deferral instead of bailing in the backend. See the header comment at aarch64/compile.rs:1: “Every AsmInst and side exit is lowered … aarch64 never bails out of JIT compilation; compile_asmir’s bool is vestigial.”

There is no return false anywhere in the aarch64 lowering (compile.rs, guard.rs). The driver chain (gen_asm / gen_machine_code / jit_compile) no longer acts on the result either; the bool is kept only because flipping ~150 signatures to () is pure churn.


2. AsmInst coverage — full on both arches

The large majority of AsmInst variants are dispatched through the shared compile_asmir match and lowered by per-arch emit primitives. Structurally identical families covered on both arches include:

  • Register / stack moves: RegMove, RegToAcc, AccToStack, RegToStack, StackToReg, LitToReg, LitToStack.
  • Control flow: CondBr, NilBr, CheckLocal, OptCase, Deopt, HandleError, Ret, MethodRet, BlockBreak.
  • Guards: GuardClass, GuardClassVersion, GuardConstBaseClass, GuardConstVersion, GuardArrayTy, GuardFrozen, GuardCapture, CheckBOP, CheckStack, ExecGc.
  • Arithmetic: IntegerBinOp, IntegerCmp, IntegerCmpBr, FloatBinOp, FloatUnOp, FloatCmp, FloatCmpBr, FixnumNeg, FixnumBitNot, RegAdd, RegSub.
  • FP transfer: FprMove, FprSwap, F64ToFpr, FixnumToFpr, FloatToFpr, FprToStack, I64ToBoth, FprSave, FprRestore, CFunc_F_F, CFunc_FF_F.
  • Allocation / C-call: CreateArray, NewArray, NewHash, NewRange, ConcatStr, ToA, DeepCopyLit, ConcatRegexp, ExpandArray, GenericBinOp, OptEqCmp, ArrayTEq.
  • Variables: LoadGVar, StoreGVar, LoadCVar, StoreCVar, CheckCVar, LoadDynVar, StoreDynVar, ivar/struct-slot inline & heap loads/stores, constants (StoreConstant, GuardConst*).
  • defined? family, method/class definition (MethodDef, ClassDef, SingletonClassDef, …), method-call prologue (GuardClassVersion, SetupMethodFrame, SetArguments, Call, Init, Preparation), exceptions (Raise, Retry, Redo, EnsureEnd), Yield, Inline, and the specialized inlined-frame family.

Both backends emit all of these unconditionally. The aarch64 wildcard in compile_asmir_arch is unreachable!("handled by the shared compile_asmir dispatcher") — it can no longer be a bail.

How aarch64 lowers what used to bail

The pre-#704 bail sites were overwhelmingly 12-bit immediate-range limits (aarch64 fixed-width instructions encode only small immediates). They are now handled, not declined:

  • LFP-relative frame offsets, callee-frame / prologue / loop-JIT sub sp, RSP-relative argument stores, block-arg offsets, class-def field offsets — offsets that overflow the field are materialized into a scratch register (mov xN, #imm + register-offset addressing) instead of bailing. See the a64_frame_* / a64_sp_* / a64_rsp_* helpers in compile.rs.
  • RValue heap-field offsets (inline/heap ivar & struct-slot access) — same scratch-materialization treatment.
  • Float FloatBinOp / FloatUnOp — the full BinOpK / UnOpK set is lowered (the old port handled only Add|Sub|Mul|Div / Neg|Pos).
  • Live FP-pool register across a runtime call — the runtime-call primitives save/restore the live xmm pool (emit_fpr_save / emit_fpr_restore) around the call, so they no longer bail on a live pool register.
  • Deopt write-back & forwarded arguments — the side-exit generator reconstructs live frame state for all shapes; the single unported shape (the deferred-source ...-forwarding deferral, g(*rest, **kw, &blk)) is prevented upstream by forward_rest_deferral, so it never reaches the backend.

3. (former §3 “aarch64 bail conditions” — removed)

This section catalogued the aarch64 bail sites. With the full port (#704) there are no bail sites left; the content has been folded into §2’s “How aarch64 lowers what used to bail”. The remaining non-bail asymmetries are in §4.


4. Remaining asymmetry: recompilation & eviction patching (not coverage)

Two mechanisms still differ, both centered on patching / recompiling already-emitted code, and both scoped to non-specialized frames. Neither is a coverage gap: where x86 patches or recompiles in place, aarch64 deopts to the VM, which then re-JITs through the normal warm-up counters. Correctness is identical; only the recompile strategy (and thus steady-state performance after a class-version change or BOP redefinition) differs.

4.1 Class-version-miss recompilation

  • x86-64: guard_class_version (x86_64/guard.rs:28) emits a fast inline version check (page 0) plus an outlined recompile-and-recover slow path (page 1) via gen_recompile, distinguishing loop vs. method recompiles via position and offering a with_recovery jump-back. On a version miss it recompiles the whole method/loop in place and resumes.
  • aarch64: a64_guard_class_version (aarch64/guard.rs:89) emits the inline check and just deopts on miss“Unlike x86 we do not recompile on miss yet — just deopt.” It ignores the x86 recompile params (position, with_recovery) it has no recompiler for.
  • Specialized frames are symmetric: the specialized class-version guard does recompile on both arches. x86 uses guard_class_version_specialized / gen_recompile_specialized (x86_64/guard.rs:57); aarch64 uses GuardClassVersionSpecialized / RecompileDeoptSpecializeda64_call_recompile_specialized (aarch64/compile.rs:4755), which rewrites the specialized body’s SpecializedCall bl.

So the gap is specifically the non-specialized method/loop class-version guard: x86 recompiles, aarch64 deopts.

4.2 Eviction via return-address patching (BOP redefinition)

  • x86-64: the regular Call / Yield records, per call site, the return address plus a patch point (emit_callset_deopt_with_return_addr, x86_64/compile/mod.rs:1285). On BOP (basic-op) redefinition it rewrites the live return path to redirect into a deopt handler — without recompiling.
  • aarch64: a64_do_call (aarch64/compile.rs:825) skips the return-address patching for the regular Call/Yield“The eviction-on-return patching (set_deopt_with_return_addr) is x86-only (runtime branch patching), so it is skipped — class-version changes are caught by GuardClassVersion deopts instead.”
  • Specialized calls/yields are symmetric: aarch64 does implement return-address patching for the specialized inlined-frame path — do_specialized_call (aarch64/compile.rs:1068) records the return address via set_deopt_with_return_addr (aarch64/compile.rs:2433), and emit_immediate_evict (aarch64/compile.rs:2416) overwrites the recorded instruction on eviction.

So the gap is specifically the non-specialized Call/Yield: x86 patches the return path for BOP eviction, aarch64 relies on the inline class-version deopt.


5. Guard logic comparison

Guardx86-64aarch64
guard_class immediatesFixnum/nil/true/false/symbol/float via testq/cmpqFixnum/nil/true/false/symbol/float via tbz/tbnz/cmp (guard.rs:14)
guard_class heapguard_rvalue (low-3-bits + class compare)a64_guard_rvalue (same logic, and/cbnz/ldr w, guard.rs:68)
guard_class2 (BigNum→VM)yes — x86-only helper (guard.rs:177), called from the monomorphic method-entry patch path (codegen/patch.rs:158)not present
guard_array_tyyes (ObjTy::ARRAY at RVALUE_OFFSET_TY)yes
guard_captureyes (branch_if_captured)yes
float_to_f64 unboxyes (flonum / heap-Float, 0.0 sign-bit trick)yes (mirrored)
class-version guardinline check + recompile + recovery (page split, §4.1)inline check, deopt only for non-specialized; recompile for specialized frames (§4.1)
eviction on BOP redefinitionreturn-address patching for regular & specialized calls (§4.2)return-address patching for specialized calls only; regular calls rely on class-version deopt (§4.2)

Both a64_guard_class and a64_guard_rvalue always emit (they return a bool for symmetry with x86, but never return false — every ClassId is handled, immediates inline and everything else via the heap fallback).


5b. Local-slot addressing: rbp (x86) vs LFP (aarch64)

The two backends address a frame’s own local/temporary slots through different base registers, and this leaks into one correctness-relevant corner:

x86-64aarch64
LMem::Slot lowering[rbp - rbp_local(slot)] (native frame pointer)[x22 - (slot*8 + LFP_SELF)] (LFP)
deopt write-back (wb.gp)[r14 - conv(slot)] (LFP)[x22 - …] (LFP)

Normally rbp and the LFP point at the same stack frame, so the choice is invisible. They diverge after move_frame_to_heap: when a callee captures the caller’s frame (e.g. turns a block into a Proc — to_enum(:m) { size }, lazy, …), the live frame becomes a heap copy that the LFP (reloaded from cfp.lfp after the call) points at, while rbp still names the abandoned stack frame. The JIT handles this by emitting a guard_capture after such a call that deopts to the VM when capture happened; the deopt’s write-back re-homes register-resident (wb.gp / wb.fpr) slots via the LFP, so they reach the heap copy.

A slot in LinkMode::S (value already at its stack home) is not in the write-back — it is assumed materialized. On x86 that materialization is rbp-relative, so a call result written to an S slot after a capturing call lands on the dead stack frame and is lost (the VM then reads the stale heap copy). With a non-empty GP pool this was masked because results stayed pool- resident (G) and the deopt re-homed them via the LFP; it surfaces once the pool is empty (the aarch64 default, and the x86 GP_ALLOC_POOL = &[] config). aarch64 never had the bug because all its slot stores are already LFP-relative.

The fix is AsmInst::RegToLfpStack / LMem::LfpSlot (this commit): the result of a possibly-capturing call (the send / compile_yield paths, gated on !no_capture_guard()) is stored via the LFP (def_rax2acc_capturing) so it follows the frame onto the heap — matching what aarch64 does for every slot, and what the deopt write-back does for G/F slots. On aarch64 LfpSlot lowers identically to Slot.


6. Practical consequences

  • Correctness is equal. Both backends produce correct results.
  • Coverage is equal. Both backends JIT every method/loop the front-end produces; aarch64 no longer falls back to the VM for any instruction shape.
  • Steady-state recompile behavior differs in two narrow, non-specialized cases (§4): after a class-version change, x86 recompiles the method/loop in place while aarch64 deopts and re-JITs via warm-up counters; and on BOP redefinition, x86 patches the live return path of regular calls while aarch64 relies on the inline class-version deopt. Both eventually reach an equivalent JIT-compiled steady state; the difference is the transition cost.
  • guard_class2 / BigNum routing is an x86-only method-entry guard helper (patch.rs); aarch64 has no equivalent on that path.

One-line summary

x86-64 and aarch64 now share the entire AsmIR front-end and full AsmInst coverage — aarch64 lowers everything (large immediates via scratch registers), so the bool bail return is vestigial. The only remaining asymmetries are non-coverage: x86 recompiles-in-place / patches live return addresses for non-specialized class-version misses and BOP eviction, where aarch64 deopts to the VM and re-JITs (specialized frames are symmetric); plus the x86-only guard_class2 BigNum-routing helper.

monoruby の GC — 機構と実装

monoruby のガベージコレクタの現行実装を、コードに即して解説するドキュメント。 本書は「いま実際に動いているもの」を対象とする。

補足: CLAUDE.md は GC を「mark-and-sweep」と一言で書いているが、現行実装は より正確には 非移動(non-moving)・単一スレッド・stop-the-world の 世代別 mark & sweep(CRuby の RGenGC に相当)である。世代別化は既に有効で、 オブジェクトは実際に old 世代へ昇格し、マイナー/メジャー GC が使い分けられる。 alloc.rs に残る一部コメント(「old_bits is always empty」「not enabled yet」等)は 実装より古い名残りである。実挙動は本書と該当コードを正とする。

主な実装ファイル:

対象ファイル
アロケータ・ページ・GC 本体monoruby/src/alloc.rs
RValue のヘッダ / マーク / 書き込みバリアmonoruby/src/value/rvalue.rs
セーフポイント・ルート走査・execute_gcmonoruby/src/executor.rs
GC poll のコード生成monoruby/src/codegen/arch/{x86_64,aarch64}/…
GC モジュールのビルトインmonoruby/src/builtins/gc.rs

1. 全体像

  • 非移動 (non-moving): オブジェクトは一度確保したセルから動かない。コピーや コンパクションを行わないので、生ポインタ(*const RValue)を保持したまま GC を 跨いでも安全。ページ・フリーリスト・スイープ機構をそのまま世代別化に流用できる。
  • 単一スレッド・stop-the-world: monoruby の VM は 1 本の OS スレッドで走る。 GC は VM セーフポイントで同期的に実行され、並行 GC やインクリメンタル GC は 持たない。
  • 世代別 (generational): 弱い世代別仮説(多くのオブジェクトは若くして死ぬ)に 基づき、マイナー GC ではマーク対象を「若い世代 + old→young 参照」に限定する。 長命オブジェクトを多数抱えるワークロード(Rails 系・optcarrot 等)でのマーク コストを削減する。
  • 保守的ではない (precise): ルートは明示的に列挙してマークする(スタックの 値スキャンではない)。JIT コンパイル済みコードのセーフポイントでは、生きた レジスタをスタックに退避してからマークする。

コンパイルパイプライン全体における GC の位置づけは CLAUDE.md の “Custom GC (alloc.rs)” と本書を対応させて読むとよい。

オブジェクトの状態遷移(世代間の移動と OLD / WB_ARMED / age 各フラグの変化)を 1 枚にまとめた図が gc_state_transitions.svg にある(§6・§7 の図解版)。


2. ヒープのレイアウト

2.1 アロケータ

thread_local! { pub static ALLOC: RefCell<Allocator<RValue>> }   // alloc.rs

Allocator<RValue> はスレッドローカルなシングルトン(alloc.rs:155)。 RValue は 64 バイト固定(GCBOX_SIZEAllocator::newassert_eq!(64, GCBOX_SIZE))。

主なフィールド(alloc.rs:299 付近):

フィールド意味
current_page / head_page / pages現ページ / 最上位ページ / 割り当て済みページ一覧
used_in_current現ページのバンプ位置
free / free_list_countフリーリスト先頭と要素数
free_pages空きになって再利用待ちのページ
total_gc_counter / minor_gc_count / major_gc_countGC 回数の各カウンタ
minors_since_major直近メジャー以降のマイナー回数(kind 判定に使用)
old_countold 世代オブジェクト数(昇格で +1、メジャーで 0 リセット)
old_major_threshold適応的メジャー閾値(old_count がこれに達したら次はメジャー)
promotingマーク中に昇格候補を収集するか(実マーク中のみ true)
aging今サイクルで生存した昇格候補(マーク後に加齢)
rememberedremembered set(old→young 参照を持つ old オブジェクト)
alloc_flagGC 起動フラグ(u32)のアドレス
heap_framesヒープに退避したフレームバッファの登録表(§9)

2.2 アリーナとページ

const SIZE: usize        = 64;
const GCBOX_SIZE: usize  = size_of::<RValue>();          // 64
const PAGE_LEN: usize    = 64 * SIZE;                     // 4096 セル/ページ
const DATA_LEN: usize    = 64 * (SIZE - 1);               // 4032 データセル
const THRESHOLD: usize   = 64 * (SIZE - 2);               // 3968(alloc_flag を立てる位置)
const ALLOC_SIZE: usize  = PAGE_LEN * GCBOX_SIZE;         // 262144 = 256KB
const MAX_PAGES: usize   = 8192;
  • アリーナは起動時に ALLOC_SIZE * MAX_PAGES(= 2GB)を 1 回だけ予約する (Allocator::newSystem.alloc)。実 RSS はページを使うぶんだけ増える (予約は仮想アドレス空間)。ページは 256KB 境界に整列。
  • ページからポインタへの逆引きはアドレスマスクで O(1): get_page(ptr) = ptr & !(ALLOC_SIZE - 1)(alloc.rs:1375)。これにより任意の *const RValue から所属ページ(とマークビット)を即座に求められる。

Page<T>(alloc.rs:1449)の構造:

struct Page<T> {
    data:      [T; DATA_LEN],       // 4032 セル
    mark_bits: [u64; SIZE - 1],     // 63 ワード = セル1つにつき1ビットのマークビットマップ
    old_bits:  [u64; SIZE - 1],     // 63 ワード = old 世代ビットマップ(mark_bits と並行)
}

size_of::<Page<T>>() <= ALLOC_SIZEAllocator::new で保証される。 data の後ろにビットマップ 2 枚が同居する(セル本体の外にマークを置く mark-external 方式なので、生存中のオブジェクト内容を汚さない)。


3. 割り当て(Allocator::alloc)

alloc.rs:779。順序は以下:

  1. フリーリストが空でなければそこから 1 セル pop(self.free)。直前の GC で スイープされたセルの再利用。
  2. 空でなければ現ページのバンプ割り当て
    • used_in_current == THRESHOLD(3968)に達したら set_alloc_flag()alloc_flag += 1(GC を要求;§4)。
    • used_in_current == DATA_LEN(4032)でページ満杯 → free_pages から再利用、 なければ new_page() で新規ページ。新ページは clear_old_bits() で old ビットマップを 0 初期化(マイナー GC のシード整合性のため)。

JIT インライン高速パス

フリーリストからの pop は JIT がインライン展開できるよう、アロケータが 生アドレスを公開している:

  • free_list_head_addr()(self.free) — alloc.rs:658
  • free_list_count_addr() / total_allocated_addr() — 統計の同期用

JIT コードはセーフポイント外でのみこれらを触る(Rust 側が ALLOC を借用中や gc() 実行中は触らない)ため、単一スレッド前提でエイリアスは生じない。


4. GC のトリガとセーフポイント

GC は「アロケーションの延長で即実行」はしない。JIT の生きたレジスタが未退避の まま GC ルート走査に入るのは危険なため、フラグを立てて次のセーフポイントで 実行する。

4.1 起動フラグ alloc_flag(u32)

VM/JIT が参照する単一の u328 以上(ベース値)でトリガ帯。これを立てる経路:

経路実装フラグ操作
ページ充填set_alloc_flag(alloc.rs:681)ほぼ満杯ページごとに += 1(約 8 ページで 8 に到達)
malloc 圧(§8)request_gc_if_malloc_over(alloc.rs:123)8 未満なら 8 を書く
GC.startrequest_gc(true)(alloc.rs:84)8 未満なら 8 を書く + メジャー強制
シグナル配送シグナルハンドラ(jit_module.rs)+= 10(doc/signal.md)
プリエンプト tickタイマ OS スレッド(preempt.rs)|= 1 << 30(PREEMPT_BIT;doc/threads.md §8)

「8 未満のときだけ 8 を書く」ことで、ページ充填の累積値やシグナルの +10 を 踏み潰さず、ちょうど 1 回の収集を要求する。

この u32 はプリエンプションの poll フラグと同一の語である(doc/threads.md §8)。 タイマ OS スレッドが上位ビット PREEMPT_BIT(= 1 << 30。ビット 31 でないのは x86-64 poll cmpl …; jge符号付き比較で、ビット 31 だと負値に読めて発火しないため)を 立てる。別スレッドから書くのでフラグアクセスはすべてアトミックになった。GC 判定は ベース値(プリエンプトビットを剥がした値)が >= 8 かどうかで行うので、純粋な プリエンプト tick が偽の full GC を起こすことはない(§4.3 手順 2)。GC 完了後の unset_alloc_flag(alloc.rs:694)は fetch_and(PREEMPT_BIT) でベース帯だけ落とし、 並行して立ったプリエンプトビットは保存する。

4.2 poll のコード生成

execute_gc_inner(codegen/arch/x86_64/jit_module.rs:255)が poll を出力:

cmpl [rip + alloc_flag], 8
jge  gc          ; フラグ >= 8 なら収集パスへ
exit:
; gc: (別ページ)
;   write_back(生きたレジスタを退避)
;   call exec_gc      ; = execute_gc()
;   testq rax, rax
;   jne  exit         ; nil 以外(=正常)なら復帰
;   jmp  error        ; None(=例外/シグナル)なら伝播

この poll は呼び出し先エントリ(callee entry)とループのバックエッジという セーフポイントで実行される(vm_execute_gc;vmgen/init_method.rs / vm_loop_start ほか。 call-site には poll を置かない — doc/threads.md §8.3)。aarch64 backend も 同等のフラグ比較を出力する。

4.3 execute_gc(executor.rs:3743)

セーフポイントから呼ばれる extern "C" 関数。順に:

  1. watchdog::poll() — ハングウォッチドッグのカウントダウンをリセット。
  2. preempt::consume_poll_flag()プリエンプトビットを剥がし(ベース値, プリエンプトか) を得る(§4.1 の注)。以降の GC 判定はベース値で行う。
  3. 保留シグナルの処理pending_signals ビットマップを drain し、最小番号の シグナルを Signal.trap ハンドラ呼び出し / 既定例外(SIGINT ⇒ Interrupt 等)に 変換(doc/signal.md)。
  4. ベース値が >= 8 のときだけ GC 本体を実行:parent_fiber を辿って **ルート Executor(最上位ファイバ)**へ行き、 ALLOC.with(|a| a.borrow_mut().gc(&Root { globals, executor }))
  5. プリエンプトビットが立っていて scheduler::preempt_ok() なら scheduler::pass (タイムスライス切替。doc/threads.md §8.4)。

5. オブジェクトヘッダとフラグ

RValue 先頭の Header は union(rvalue.rs):

union Header { next: Option<NonNull<RValue>>, meta: Metadata }

struct Metadata {          // rvalue.rs:2373
    flag:  u16,
    ty:    Option<ObjTy>,  // 1 バイト
    ty_flags: u8,          // ObjTy 固有のメタデータ(HASH: 小ハッシュ表現ビット)
    class: Option<ClassId>,
}
  • フリーリスト上のセルは next(次の空きセル)として解釈され、生存セルは meta
  • ty_flags は ObjTy 固有のメタデータバイト。JIT の型判定は両アーキテクチャとも 1 バイト読み(x86-64 cmpb / aarch64 ldrb)なので、隣接バイトが任意の値でも 問題ない。HASH オブジェクトはここにインライン表現のビット(hash.rs の HashFlags)を置く。dup/リテラルコピー(Header::newborn / CellHeader::NewbornOf)はこのバイトを保存する。世代別 GC の age は従来どおり flag の上位バイトに置く。

flag: u16 のビット割り当て(rvalue.rs:2447 以降)

ビットマスク意味
00b0000_0001LIVE(生存;確保時 flag = 1)
10b0000_0010FROZEN
20b0000_0100CHILLED(Symbol#to_s 由来の準 frozen 文字列)
30b0000_1000OLD(old 世代へ昇格済み)
40b0001_0000WB_UNPROTECTED(shady 用に予約。現状未使用 — §7.3)
50b0010_0000空き(旧 REMEMBERED。「remembered set 登録済み」は専用ビットではなく OLD ∧ ¬WB_ARMED で導出する)
60b0100_0000WB_ARMED(old かつ未 remembered = 書き込みバリアの slow path 対象)
70b1000_0000CHILLED_LITERAL(リテラル由来の chilled 文字列;警告文言の出し分け用)
8..15上位バイトage(生存回数;RGENGC_OLD_AGE で昇格。上位バイトは age 専用 — 下位バイトのフラグはここに置かないこと)

新規オブジェクトは flag == 1 なので、OLD / WB_ARMED はともに 0 (= young・バリア対象外)、age は 0 から始まる。

old オブジェクトの 2 状態は WB_ARMED 1 ビットで表す: armed = OLD ∧ WB_ARMEDremembered = OLD ∧ ¬WB_ARMED。 remembered set の実体(列挙)は Allocator::remembered(Vec)であり、ヘッダ側は バリアの高速パスが見る WB_ARMED だけを持つ。arm_barrier(WB_ARMED を立てる)と enter_remembered(WB_ARMED を落とす)は単一ビットの反転で、 書き込みバリアの高速パスはこの 1 ビット(WB_ARMED)テストだけで済む。 「OLD=0 なのに WB_ARMED=1」は発生しない不正状態である。


6. 世代別 GC 本体(Allocator::gc, alloc.rs:864)

6.1 マイナー / メジャーの選択(decide_gc_kind, alloc.rs:854)

old_count >= old_major_threshold  ||  minors_since_major >= MAX_MINORS_PER_MAJOR
    → Major     それ以外 → Minor
  • 適応的メジャー閾値 old_major_threshold: メジャー直後に max(old_count * OLD_GROWTH_FACTOR, OLD_OBJECT_FLOOR) へ再設定 (OLD_GROWTH_FACTOR = 2, OLD_OBJECT_FLOOR = 16384)。old 世代が安定していれば メジャーは稀(世代別の利得を保つ)、浮遊ゴミを昇格し続けるワークロードでは 頻繁にメジャーして RSS を抑える。CRuby の RGENGC_OLD_OBJECT_LIMIT_FACTOR に相当。
  • MAX_MINORS_PER_MAJOR = 64: 安全上限。適応閾値が発火しなくても、64 回に 1 度は 必ずメジャーして remembered set を作り直し、浮遊 old ゴミを回収する。
  • GC.startGC_FORCE_MAJOR を立てるので、次の収集は無条件にメジャー。

6.2 マークビットマップの準備

kind操作
Majorclear_mark()(mark_bits=0)+ clear_old()(old_bits=0, old_count=0)+ remembered.clear()。全オブジェクトが収集候補に戻り、ルートから再マーク・全スイープ。
Minorseed_marks()。各ページで mark_bits ← old_bits をコピー(seed_mark_from_old)。old オブジェクトは最初からマーク済みとみなされ、再走査もスイープもされない。

6.3 マークフェーズ

  1. self.promoting = true にしてから root.mark(self)(ルートは §8)。
  2. RValue::mark(rvalue.rs:757)は gc_check_and_mark でビットを立て、未マーク だった場合のみ mark_children で子を辿る(深さ優先)。
  3. gc_check_and_mark(alloc.rs:1007)は、初めてマークしたセルが promoting かつ is_promotable() なら aging に積む(昇格候補の収集)。ヘッダ書き換えは マーク走査が握る &self とエイリアスしないようマーク後に遅延する。
  4. Minor のみ mark_remembered():remembered set の各 old オブジェクトの 子だけmark_children で辿る(親 old は既にシードマーク済み)。これにより 「old からしか参照されていない young オブジェクト」に到達する。走査後、若い子が いなくなった entry は set から外して arm_barrier(自己クリーニング; alloc.rs:1213)。
  5. self.promoting = false

6.4 加齢と昇格(apply_aging, alloc.rs:1048)

マーク完了後(生きた &self が無い状態)に:

  • Pass 1: aging の各生存者の age を +1(age_and_check_promote)。 age >= RGENGC_OLD_AGE(= 3)に達したものを昇格:old_bits をセット + ヘッダ OLD をセット + old_count += 1。 → 即時昇格ではなく「3 回生存したら昇格」。1 回の収集でたまたま生きていた 短命オブジェクトを old に上げてしまい浮遊ゴミ化するのを避ける。
  • Pass 2: remember-on-promote。昇格したオブジェクトがまだ young を参照して いる(young_child_exists)なら remembered set に追加(バリア導入前から存在した old→young 辺をカバー)。young 参照が無ければ arm_barrier して以後の young ストアに 備える。

6.5 マイナー後の検証(gc-verify フィーチャ)

マイナー GC の後、シード無し・昇格無しでルートから全ライブグラフを独立に再マーク する(alloc.rs:964)。もしマイナーが到達可能なオブジェクトを解放していれば (バリア漏れ/remembered set 漏れ)、この走査が解放済みセルに到達し RValue::markis_live アサートが発火する。世代別 GC の健全性テスト。


7. 書き込みバリア

world 停止型・非移動なので、必要なのは old→young 辺を remembered set に記録する だけの単純なバリア。

バリアと remembered set が「なぜ必要か」(世代別 GC なし / remembered set なしの minor GC / 完全な minor GC の 3 通りでのマーク走査の比較と、バリアが必要な辺の 分類)を図解したものが gc_write_barrier.svg にある。

7.1 実体(RValue::write_barrier, rvalue.rs:1115)

#![allow(unused)]
fn main() {
pub(crate) fn write_barrier(&mut self, child: Value) {
    if self.is_wb_armed() && !child.is_packed_value() {
        self.enter_remembered_set();
    }
}
}
  • 高速パスはヘッダ 1 ビットのテスト(is_wb_armed = WB_ARMED ビット)。 young オブジェクトも、既に remembered な old オブジェクトも、このビットが 0 なので 即 return(アロケータに触れない)。
  • 子の世代は見ない(old→old を覚える過剰近似は無害)。即値(is_packed_value)は除外。
  • write_barrier_bulk(rvalue.rs:1128)は Array#concat / Hash#[]= などの 複数要素ストア用。個々の子を見ず、armed なら無条件に記録する過剰近似。

呼び出しは「参照型フィールド(ivar / 配列・ハッシュ要素 / struct スロット)へ child を格納した」。インタプリタ経路(set_ivar、Array/Hash ラッパ、 Value::set_struct_slot 等)と、JIT が出力するインラインバリア (emit_write_barrier_rdi)の両方でカバーされる。

7.2 状態遷移

young(flag=1) ──[age>=3 で昇格]──▶ old
   昇格時に young 子あり ─▶ enter_remembered (WB_ARMED=0) ── remembered set 登録
   昇格時に young 子なし ─▶ arm_barrier      (WB_ARMED=1) ── 以後の young ストアを待つ
   armed な old に young ストア ─▶ write_barrier ─▶ enter_remembered_set ── set 登録 + WB_ARMED=0
   minor 走査で young 子が消えた remembered ─▶ arm_barrier に戻す(自己クリーニング)

生きている old については「WB_ARMED=0 ⇔ Allocator::remembered に登録済み」が 不変条件(専用の REMEMBERED ビットは持たない — §5)。remembered set の大きさは 「生きた old→young 辺の数」に比例し続ける(かつて young 子を持っていた全昇格 オブジェクトには比例しない)。

7.3 昇格可能性(is_promotable, rvalue.rs:918)

昇格してよいのは「そのオブジェクトへの Value 格納経路がすべてバリア保護されている」 型のみ。現状 ty() で判定し、以下が true:

OBJECT | STRING | BIGNUM | FLOAT | ARRAY | STRUCT | HASH
  • OBJECT と各リーフ(String バイト列 / Bignum / ヒープ Float)は ivar 経由でしか Value を持たず、ivar ストアは全経路バリア済み。
  • Array/Struct の要素ストア、Hash ストアもインタプリタ・JIT 双方でバリア済み。
  • それ以外の型は昇格しない(マイナーで毎回走査される young のまま)。

WB_UNPROTECTED(bit4) は「shady(バリアで追えない)オブジェクトは昇格しない」 ための予約フラグだが、現状 is_promotable は型のみで判定し、このフラグは 参照されていない(set_wb_unprotected の呼び出し箇所は無い)。将来のための予約。


8. ルート(マーク開始点)

Root(executor.rs:3713)の mark(executor.rs:3719)が起点:

Root::mark → YIELDER.mark      (ブロック/ファイバの yielder)
           → Globals::mark     (globals.rs)
           → Executor::mark    (executor.rs:248)
           → scheduler::mark    (executor.rs:3729 — グリーンスレッドの root)

Executor::mark(executor.rs:248)が辿るもの:

  • temp_stack の全 Value(ビルトインが GC を跨いで生かしたい一時値の退避先)。
  • cfp 連鎖の各 lfp()(= すべての生きたスタックフレームのローカル変数・レシーバ等)。
  • lexical_class 上の DefinitionContext::Receiver(Value) (instance_eval/instance_exec 中のレシーバ)。
  • 保留例外 exception(MonorubyErr は packed Value を持つ;MonorubyErr::mark)。
  • マッチ処理の一時退避 sp_match_regex / sp_match_haystack
  • deferred_unwind(ensure で中断した MethodReturn/Throw が握る Value と Lfp)。

Globals::mark はクラステーブル・定数・グローバル変数・呼び出しサイト等の 恒久ルートをマークする。

グリーンスレッド(scheduler::mark)

green thread 導入後、GC ルートにはスケジューラの生存スレッド registryが加わった (scheduler::mark, scheduler.rs)。Scheduler::markthreads / current / main / ready / sleepers / io_waiters の全 Thread オブジェクトをマークし、in_scheduler 中は main の Executor(main_exec)も deref してマークする。各 Thread は impl GC for ThreadInner を通じて自分の handle Executor(→ その CFP チェーン)と proc/args/result/exception/joiners/pending/masks/last_status をマークする。

したがって GC は事実上複数の Executorをマークする:各 green thread の handle と、 main_exec 経由で辿る埋め込み側所有の main Executor。切替はセーフポイントでしか 起きないので、サスペンド中のどのスレッドのフレームも GC-complete (詳細は doc/threads.md §2・§3.4・§8)。


9. スイープと空きページの回収

スイープ(sweep, alloc.rs:1269)

ページごとに mark_bits を 64 ビット単位で走査(sweep_bits)。未マークセルを free()(型に応じて ManuallyDrop::drop;rvalue.rs:785)してフリーリストに連結。 trailing_ones でマーク済みの連続領域を一気に飛ばす最適化がある。最後に self.free がフリーリスト先頭に、free_list_count が回収数になる。

free() は多重呼び出しに耐える(is_live() を先頭で確認)。フリーリスト上のセルは 次のスイープでもう一度 free されうるため。

空きページの回収(salvage_empty_pages, alloc.rs:1250)

スイープ前に、全セルが未マーク(all_dead)のページを pages から外して 中身をドロップし free_pages へ戻す。以後の割り当てで再利用される(OS へは返さず、 アリーナ予約内で回す)。


10. ヒープに退避したフレーム(heap_frames)

クロージャ等でスタックフレームがその生成メソッドより長生きする場合、フレームは move_frame_to_heap / heap_frame により Box<[u64]> としてヒープへ退避され、 Box::into_raw でリークされる。この生バッファを GC が回収できるよう、LFP アドレスを キーに heap_frames へ登録する(register_heap_frame, alloc.rs:563)。

  • マーク時、生きた LFP から到達したフレームに marked を立てる。
  • sweep_heap_frames(alloc.rs:603)が、2 サイクル連続で未マークだった フレームの Box<[u64]> を解放する(1 サイクルの猶予は昇格→ルート格納の窓を カバーするため)。
  • キーは 8 バイト整列の LFP アドレスなので、既定の SipHash ではなく Fibonacci ハッシュ 1 回(AddrHasher)で引く(gc-stress 下では毎確保ごとに引かれるため速度が効く)。

heap_frames が空のときは関連処理を丸ごとスキップし、コスト 0(optcarrot 等は フレーム退避が稀)。


11. malloc 連動トリガ(外部バッファ圧)

RValue アリーナの圧力だけでは、String#<< ループのように RValue をほとんど 作らずに malloc メモリだけ膨らむケースを検知できない。そこでグローバル アロケータ自身が外部バッファ量を追跡する:

  • RurubyAlloc(#[global_allocator], alloc.rs:7)が alloc/deallocMALLOC_AMOUNT を増減。
  • MALLOC_TRACK_LIMIT = 64MB 以上の確保は無視。これは JIT メモリ予約 (monoasm が起動時に 3 × 256MB を確保)のような一過性インフラ確保を除外するため。 無視すると閾値が GB 級に張り付き、通常の String/Array/Hash 成長で永遠に GC が 発火しなくなる。同じ判定で dealloc も gate するので MALLOC_AMOUNT は アンダーフローしない。
  • request_gc_if_malloc_over(alloc.rs:123)が MALLOC_AMOUNT >= MALLOC_GC_THRESHOLDalloc_flag を 8 に持ち上げる(GC 要求;割り当てフリーで安全)。
  • 閾値 MALLOC_GC_THRESHOLD は各 GC 後に malloced + max(malloced/2, MALLOC_THRESHOLD) へ再設定(alloc.rs:986)。 加算のみだと巨大ヒープでも 256KB ごとに GC してしまうので、乗算項で比例させる。
  • この経路の収集はメジャー強制しない(一過性バッファは若くして死ぬのでマイナーで 回収でき、old のバッファゴミは §6.1 のメジャートリガが拾う)。

12. GC の制御(GC モジュール, builtins/gc.rs)

メソッド実装挙動
GC.startbuiltins/gc.rb + __request_gcrequest_gc(full_mark) で収集を要求したあと、ループ後方辺(セーフポイント)を跨いで GC.count が進むまで回るので、CRuby 同様に回収を終えてから返る。builtin の中で直接 gc() を呼べないのは、JIT 呼び出し元の生きたレジスタがセーフポイント以外では退避されておらずルート走査から見えないため。full_mark: false はマイナーを許す(強制しない)。
GC.disable / GC.enableGlobals::gc_enable(false/true)GC の有効/無効を切り替え、直前の disable 状態を bool で返す。GC_ENABLED(§4 の malloc 経路が参照)も同期。
GC.counttotal_gc_counter総 GC 回数。
GC.statstat(CRuby 4.0 のキー順)ページ数・スロット数・累計確保/解放オブジェクト数・old 世代・malloc 量・フェーズ別時間まで実カウンタ。圧縮とファイナライザ、CRuby 固有の old malloc 会計だけが 0(概念が無いため)。
GC.total_time / GC.measure_total_timegc_time_nsgc() の実測ナノ秒。measure_total_time = false の間は計測自体を行わない(GC::Profiler が有効なら計測は続く)。
GC.stressAllocator::stress収集の最後に poll フラグをトリガ帯へ戻すので、以降すべてのセーフポイントで収集する。CRuby の「確保ごと」は JIT が確保の高速路をインライン化する都合で再現できないが、ルート漏れの炙り出しという用途は同じ。
GC.configbuiltins/gc.rb + __allow_full_mark:rgengc_allow_full_mark は実ノブで、false の間 decide_gc_kind はメジャーを選ばない(明示的な GC.start は依然メジャーを強制する)。:implementation は読み取り専用。
GC.auto_compact / GC.compactNotImplementedError。monoruby の収集器はオブジェクトを移動しないので、CRuby が圧縮非対応環境で返すのと同じ答えを返す。
GC::ProfilerAllocator::profile有効な間、収集ごとに GcProfileRecord(invoke time / 所要時間 / live バイト / ヒープ総バイト / 総スロット / メジャーか)を積む。result は CRuby と同じ表形式、raw_data は同じキー、total_time は秒の Float。

コマンドラインでは --no-gc で GC を無効化できる。GC 無効時は gc() が即 return するため、request_gc_if_malloc_overGC_ENABLED を見て要求自体をスキップする (さもないとフラグがトリガ帯に張り付いて poll が空回りする)。


13. デバッグ・検証用フィーチャ

フィーチャ効果
gc-log終了時に GC 統計を出力(old 数の実 popcount 等)。
gc-debugGC 中の各種アサート・ダンプ。old_count と実 popcount の一致検証など。
gc-stress毎回のアロケーションで GC を走らせる(bin/test が使用)。世代別のバリア/remembered set 漏れを最も強く炙り出す。
gc-verifyマイナー GC 後に独立フル再マークで健全性検証(§6.5)。

環境変数 MONORUBY_MALLOC_HARD_LIMIT(例 3G。K/M/G サフィックス可)を設定すると、 malloc 総量がこれを超える確保が要求された瞬間に、要求サイズとバックトレースを stderr へ出力して abort する(alloc.rsmalloc_hard_limit)。OOM でマシン/ ランナーごと死んでログが失われる環境(darwin CI)で、暴走アロケーションを 「名前付きで診断可能なクラッシュ」に変換するための装置。ポーリング型の監視では 捕捉できない単発の巨大確保も、アロケータ内の同期チェックなので確実に捕まる。 未設定なら無効(コストは relaxed load 1 回)。


14. まとめ

  • monoruby の GC は 非移動・単一スレッド・stop-the-world の世代別 mark & sweep
  • 256KB ページ + マーク/old の 2 枚のビットマップ(mark-external)で、非移動と 世代別を両立。ページはアドレスマスクで O(1) 逆引き。
  • 割り当てはフリーリスト → バンプ。閾値到達で alloc_flag を立て、次のセーフ ポイントexecute_gc が同期収集する(JIT レジスタ退避のため即実行はしない)。
  • 世代別の心臓部は、3 回生存で昇格(aging)適応的メジャー閾値1 ビット高速パスの書き込みバリア + remembered set(自己クリーニング付き)。 マイナーは old をシードマークして young + old→young 辺だけを辿る。
  • 外部 malloc 圧・シグナル・GC.start も同じ alloc_flag 経由で同一のセーフ ポイント収集に集約される。

Thread / Fiber / non-blocking IO / プリエンプション の実装

monoruby のスレッド機構(thread ブランチ系列 #941–#962)の現状をまとめる。 対象読者はランタイムの実装に手を入れる人。関連ソース:

ファイル内容
monoruby/src/scheduler.rsグリーンスレッド・スケジューラ本体
monoruby/src/preempt.rsタイムスライス・プリエンプションのタイマ(§8)
monoruby/src/native_pool.rsカーネルブロッキング syscall のネイティブ・オフロード(§9)
monoruby/src/value/rvalue/thread.rsThreadInner(スレッド制御ブロック)と状態機械
monoruby/src/value/rvalue/fiber.rsFiberInner(Fiber 制御ブロック)
monoruby/src/builtins/thread.rsThread クラスのネイティブ・ビルトイン
monoruby/src/builtins/fiber.rsFiber クラスのビルトイン + JIT インライン Fiber.yield
monoruby/src/codegen/arch/{x86_64,aarch64}/invoker.rsコンテキストスイッチのスタブ(両アーキ)
monoruby/builtins/startup.rbMutex / Queue / SizedQueue / ConditionVariable(純 Ruby)、Thread の簿記系メソッド
monoruby/src/builtins/io.rsblocking_io_region / IO.select のグリーンパス

関連ドキュメント: scheduler_state_diagram.md — Thread / Fiber の状態遷移図(mermaid + SVG)と遷移⇔実装対応表。

0. 全体像

  • M:1 グリーンスレッド。Ruby の Thread は 1 本の OS スレッド上で多重化される。 真の並列性はない(GVL 型でもない — そもそも VM を回す OS スレッドが 1 本)。 カーネルブロッキング syscall のオフロード(§9)には別の短命 OS スレッドを使うが、 それらは Ruby ヒープにも VM にも触れない。
  • 切替の 2 系統:
    1. ブロッキング地点sleep / Thread.stop / #join / Thread.pass / ブロックする IO / 同期プリミティブの待機で、実行中のスレッドが自発的に スケジューラへ制御を返す(協調型)。
    2. タイマ駆動プリエンプション(§8、#962)。生存スレッドが 2 本以上あるとき、 10 ms ごとに専用タイマ OS スレッドが GC poll フラグにプリエンプトビットを立て、 走行中のスレッドが次のセーフポイント(callee-entry / ループバックエッジ)で 強制的に Thread.pass 相当を行う。busy-loop するスレッドも CPU を譲る。
  • どちらの切替も VM のセーフポイントでしか起きない。この不変条件が設計全体を貫く:
    • 切替は必ずビルトイン内のブロッキング地点か、GC が起きうるのと同じセーフポイントで 起きる。よってサスペンド中のスレッドのフレームは常に GC-complete(§6・§3.4)。 プリエンプションが安全なのはこのため — 「GC が起きうる場所でしか切り替わらない」 ので register write-back を含めて GC と全く同じ扱いになる(§8)。
    • ビルトインは他スレッドに対してアトミック(ビルトイン内では自分のブロッキング/ poll 地点以外で切り替わらない — GVL 下で CRuby が C 関数に与える保証と同じ)。
    • ただし純 Ruby コードのアトミック性はプリエンプションで失われた。2 つの 非ブロッキング文の間に他スレッドが割り込みうる。かつて純 Ruby の Mutex / Queue / ConditionVariable が check-then-act 競合なしに書けていた根拠はこれだったので、 プリエンプション導入に合わせて park permit とロックで作り直してある(§5)。

1. Fiber(前提となる既存機構)

Thread は Fiber の機構(スタック切替)を土台にしている。まず Fiber 側の構造:

  • FiberInner { handle: Box<Executor>, proc: Proc, stack: Option<NonNull<u8>> }
  • 各 Fiber は 256 KiB の専用マシンスタック(最下位ページは mprotect(PROT_NONE) の ガードページ)と、自分専用の Executor(VM コンテキスト: cfp、エラー情報、$~/$_ など)を持つ。
  • コンテキストスイッチは Executor::rsp_save フィールドの rsp 交換で実現する。 スタブ(fiber_invoker / resume_fiber / yield_fiber)は callee-saved レジスタを スタックに退避してから rsp を差し替える。
  • Fiber の状態は rsp_save から導出される: None = Created、-1 = Terminated、それ以外 = Suspended。 状態遷移図は scheduler_state_diagram.md §2 (SVG)。
  • parent_fiber チェーン: resume した側が子の parent_fiber に記録され、 Fiber.yield はそこへ戻る(非対称コルーチン)。
  • GC は保守的スタックスキャンを行わない。サスペンド中の Fiber のフレームは FiberInner::mark → 子 Executor の CFP チェーン歩行で精密にマークされる。 そのために JIT インライン Fiber.yield は切替前に write-back(exec_gc)を発行し、 フレームを GC-complete にしてから切り替える。

2. Thread の構造

ThreadInner(value/rvalue/thread.rsObjTy::THREAD / THREAD_CLASS = 59)は FiberInner の拡張形で、RValue セルに収まらないため union 内では Box 化されている:

handle:        Option<Box<Executor>>   // main スレッドのみ None(Executor は埋め込み側所有)
proc / args:   本体ブロックと Thread.new の引数
stack:         専用 256 KiB スタック(初回起動時に遅延確保)
state:         Created | Runnable | Sleeping | Joining | IoWaiting | Dead
resume_exec:   park した実行コンテキスト(スレッド root、またはスレッド内で park した nested Fiber)
result / exception:  終了結果(#join / #value が参照)
joiners:       このスレッドを #join で待っているスレッド
pending:       未配送の非同期割り込み(Kill | Raise(err))
killed:        kill 配送済みフラグ(終了時の unwind をクリーンな死として扱う)
masks:         Thread.handle_interrupt のマスクスタック(§6)
park_blocking: 直近の park がブロッキング操作だったか(#status のポーリング用)
park_permit:   park permit。running な対象への #wakeup/#run が立て、次の park が即戻る(§5・§8)
last_status:   $? / Process.last_status をスレッドごとに保持(#972)

state の状態遷移図(各遷移とスケジューラ実装の対応表つき)は scheduler_state_diagram.md §1 (SVG)。

重要な設計判断: スレッド root の parent_fiber は常に None。 このため Fiber.yield をスレッド本体で呼ぶと、main fiber と同じ既存のエラー経路 (Rust 側 / JIT インライン側とも)が無変更で正しく FiberError を出す。 スレッドの切替は parent_fiber を使わず、専用スタブ(§3)で行う。

注: priority / native_thread_id / fiber-local / thread-variable / ignore_deadlockThreadInner のフィールドではなく、すべて startup.rb の純 Ruby 側で インスタンス変数(@priority / @fiber_locals / @thread_variables 等)として実装される(§4)。

3. スケジューラ

src/scheduler.rs。OS スレッドごとの thread_local! シングルトン(SCHEDULER: RefCell<Scheduler>)。

threads:          生存スレッドの registry(main 含む)— GC ルート
ready:            実行可能キュー(FIFO)
sleepers:         (Option<Instant>, Thread)  — sleep / タイムアウト付き join・IO 待ち
io_waiters:       (fd, poll events, Thread) — fd 待ち(§7)
current / main
main_exec:        scheduler_run 実行中のみ有効な main の Executor ポインタ(GC 用)
in_scheduler:     scheduler_run のイベントループ実行中か(main_exec の有効期間)
machinery:        スケジューラ自身の機構(dispatch ループ / fd ポーリング)が
                  main コンテキストで走行中か — プリエンプション抑止マーカー(§8)
pending_reports:  遅延した report_on_exception のテキスト(machinery 中に生成し後で flush)
flushing_reports: flush_pending_reports の再入ラッチ

3.1 トポロジ: main のスタック上で回るイベントループ

スケジューラループ(scheduler_run)は main スレッドのコンテキストでしか実行されない:

  • main が park するとき: 自分のスタック上で scheduler_run を普通の関数として呼ぶ。 ループは main が Runnable に戻るまで green thread を dispatch し、戻ったら return する。
  • green thread が park するとき: 起床条件を登録してから、プロセス(正確には OS スレッド)グローバルなスロット SCHED_RSP に保存されたスケジューラ・コンテキストへ switch する。つまり制御はループ中の pending な dispatch 呼び出しから「返って」くる。
  • 本体が終了したとき: thread_invoker のエピローグが rsp_save = -1(Terminated)を マークして同じくスケジューラへ switch し、ループが finalize する。

ループが制御を手放す直前に必ず自分のコンテキストを SCHED_RSP へ保存するので、 スロットは 1 個で足りる(green thread は scheduler_run を呼ばないため、 ループのインスタンスは常に高々 1 つ)。

SCHED_RSPOS スレッドごと(thread_localCell<u64>)。各 OS スレッドの Codegen が自分のスロットのアドレスをスタブに焼き込む(alloc_flag と同じ構図)。 テストハーネスのように複数のインタプリタが別 OS スレッドで並走しても衝突しない。

3.2 コンテキストスイッチのスタブ(×2 アーキ)

codegen/arch/{x86_64,aarch64}/invoker.rs に 3 種。いずれも parent_fiber に触らない:

スタブ役割
thread_invoker初回起動。スケジューラ・コンテキストを SCHED_RSP に保存 → 新スタックへ切替 → フレーム構築 → 本体実行。終了時は rsp_save=-1 をマークして SCHED_RSP へ復帰
switch_to_scheduler(cur, val)park。現コンテキストを cur.rsp_save へ保存し SCHED_RSP へ switch。val はスケジューラ側の resume 呼び出しの戻り値になる
scheduler_resume(exec, val)再開。ループのコンテキストを SCHED_RSP へ保存し exec.rsp_save へ switch。val(u64)は park 側の戻り値。0 を渡すとエラー再開(§6)

parent_fiber を使わないため、スレッド内にネストした Fiber の resume チェーンは スケジューラ切替をまたいでも壊れない(resume_exec は「park した実行コンテキスト」 そのものを指すので、nested Fiber の中で park してもそこへ直接戻る)。

3.3 ブロッキング API の流れ

sleep(dur) / join(target, timeout) / pass() / wait_fd(s) は全て同型:

  1. 短い RefCell 借用で自分を適切な待機構造(sleepers / joiners / io_waiters / ready)に 登録し、state を更新する(借用をスイッチをまたいで保持しない・借用中に Ruby アロケーションをしない、が規律)。
  2. green thread なら park_switch(resume_exec を記録して switch_to_scheduler)。 main なら scheduler_run を呼び、戻ったら take_main_pending() で 割り込みの配送を受ける。
  3. 起床後、呼び出し元のループで条件(join 対象死亡 / タイムアウト / fd ready)を再検査する。 スプリアス起床は常に許容される設計。

park の直前には park_permit を確認し、立っていればクリアして即戻る(§5)。 アイドル時(ready が空)のループは:

  • io_waiters があれば全 fd を poll(2)(タイムアウトは直近の deadline、なければ無限)
  • fd 待ちがなく deadline だけなら nanosleep
  • どちらもなければ デッドロック: CRuby と同じく fatal No live threads left. Deadlock? を main に投げる (fd 待ちは外部入力で解決しうるのでデッドロック扱いしない)。
  • どちらの待機も EINTR で VM ポーリング地点(execute_gc)を経由するので、 シグナル(SIGTERM 等)への応答性は保たれる。

3.4 GC との統合

  • registry が GC ルート: Root::mark(executor.rs)から scheduler::mark が呼ばれ、 全 Thread オブジェクト(→ ThreadInner::mark → 各スレッドの Executor の CFP チェーン) をマークする。詳細は doc/gc.md §8。
  • main のフレーム: GC のトリガが green thread 側だと、main のフレームは current の チェーンから辿れない。scheduler_run 実行中は main_exec ポインタを公開し、 in_scheduler フラグが立っている間だけそれを deref してマークする。
  • 切替はセーフポイントのみなので、サスペンド中のどのスレッドのフレームも GC-complete。

4. Thread API の実装状況

ネイティブ(builtins/thread.rsinit): Thread.new/start/fork(本体はキューされ、どこかのスレッドが最初にブロックした時点で 初実行される)、Thread.current/main/list/pass/stopThread.kill(th) / Thread.exitThread.handle_interrupt / Thread.pending_interrupt?#join(timeout) / #value(終了例外を再 raise)、#status(“run” / “sleep” / false / nil)、 #alive? / #stop?#wakeup / #run / #__wakeup_permit(§5)、 #raise / #kill / #exit / #terminate#pending_interrupt?

Thread.handle_interruptネイティブで機能する:マスク((例外クラス, タイミング) の列)を ThreadInner::masks へ push/pop し、マスク境界で保留割り込みを配送する(§6)。 ※ startup.rb 冒頭に残る「#raise / #kill はまだ未実装」旨のコメントは古い名残り (現在はネイティブ raise/killpending/masks の割り込み機構が動いている)。

Ruby 側(startup.rb、class Thread):

  • #name
  • fiber-local [] / []= / key? / keys / fetch(@fiber_locals に格納)
  • #thread_variable_get / _set / #thread_variable? / #thread_variables(@thread_variables に格納)
  • #priority / #priority=(-3..3 にクランプして @priority に保存するのみ。実際の スケジューリングには影響しない)
  • #native_thread_id(生存中は object_id、死後は nil を返す。実カーネル tid ではなく オブジェクトごとに一意なトークン)
  • #report_on_exception(インスタンスのみ)
  • Thread.ignore_deadlock / =(クラス変数に丸めるだけ。デッドロック検出器自体は止めない)
  • Thread::Waiter(Process.detach 用。native Thread.new はブロック必須なので allocate ベースで生成)

Kernel#sleep は他に生存スレッドがいるときだけスケジューラ経由になる (無引数 sleep は #wakeup まで park)。単独スレッド時は従来の nanosleep ループ。

5. 同期プリミティブ(純 Ruby)+ プリエンプション下での正しさ

Mutex / Queue / SizedQueue / ConditionVariable は startup.rb の純 Ruby 実装で ネイティブコードはない。かつて(協調型のみだった頃)は §0 のアトミック性 「2 つの非ブロッキング文の間に他スレッドが割り込まない」を根拠に、 「条件検査 → waiter 配列へ self を追加 → park」の列を素朴に書けた。

プリエンプション(§8)はこの前提を壊すので、次の 2 機構で正しさを保っている (startup.rb のコメントブロック参照):

  1. test-and-set はセーフポイントのない一直線コードにする。判定の前に Thread.current 等の呼び出しを巻き上げておき、判定〜フラグ設定の間にセーフポイント (=切替点)を挟まない(Mutex#try_lock)。ただし callee-entry poll がある以上、 メソッド呼び出しを巻き上げても複数呼び出しの列をアトミックにはできない (どの呼び出しもセーフポイント)。複合的な状態遷移にはロックが唯一の正しい道具。
  2. park permit で lost-wakeup を塞ぐrunning(park 中でない)スレッドに対する Thread#wakeup/#run は対象の park_permit(ThreadInner)を立て、対象の次の park は 即座に戻る。これで「waiter として登録 →(プリエンプトされ、起こす側が走って まだ running な対象を wake)→ 永遠に park」という古典的な lost-wakeup 窓が閉じる。 全 park 地点はリトライループの中にあるので、早期復帰しても条件は再検査される。 純 Ruby からは Thread#__wakeup_permit を使う(公開 #wakeup は permit なし版で、 running を起こしても no-op という CRuby 意味論に一致)。

その他:

  • park は Thread.stop(または timeout 付きは Kernel.sleep)、 unpark は上記 #wakeup / #__wakeup_permit を使う。
  • Mutex#sleep / ConditionVariable#wait は unlock → park → ensure で re-lock#kill / #raise が park 中に配送されても(§6 の unwind は ensure を実行するので) mutex は正しく再取得・解放される。終了スレッドが握ったまま放置したロックは 次の取得者側で回収される(#966)。Mutex#owned?Fiber 単位の所有で判定する(#967)。
  • Queue / SizedQueue は Mutex + ConditionVariable の上に載る(CRuby thread_sync.c と同じ構造)。 すべての check-and-take をキューの mutex 下で行うことで、かつての @items.empty? / @items.shift のセーフポイント窓(2 つの pop が競合し片方が幻の nil を 得る)を相互排他で閉じる。
  • 待機ループは loop do ではなく while true を使う: Kernel#loop は StopIteration を 握り潰し、ClosedQueueError < StopIteration なので loop ブロック内の raise ClosedQueueError が黙って飲まれてしまう(実際に SizedQueue#push on closed が nil を返すバグだった)。
  • Queue#close は全 waiter を起こす(consumer は残要素を排出後 nil、 producer は ClosedQueueError)。ClosedQueueError < StopIteration は startup.rb で定義。
  • デッドロックは §3.3 のスケジューラ検出に自然に乗る。

6. 非同期割り込み(Thread#raise / #kill)

割り込みは対象の ThreadInner.pending にキューされ、スケジューラが配送する:

  • park 中の対象: 起こして(Runnable 化)、dispatch 時に 「park していた Executor に set_error してから scheduler_resume(exec, 0)」。 park 側の park_switch は戻り値 None(=0)を見て Err(vm.take_error()) を返すので、 例外はブロックしていたまさにその地点から unwind する(ensure 実行)。 この「0-resume = エラー再開」が予約済みの配送経路。
  • 走行中の対象: プリエンプション(§8)が対象を次のセーフポイントで pass に落とし、 そこで pending が配送される。busy-loop 中のスレッドにも #kill / #raise が届く。
  • kill の unwind: Throw(タグは新規生成した Object なのでどの catch にも 一致しない)として配送する。monoruby の unwinder は Throw を rescue 節 (rescue Exception 含む)を素通しにしつつ ensure 節を実行する — これは CRuby の kill の意味論と一致する。スレッド root まで到達したら killed フラグにより「クリーンな死」(status false、join は正常返り、 report_on_exception 出力なし)として finalize される。 ※ FatalError は ensure をスキップするため使えない。ユーザーの 「uncaught throw」は throw サイトで UncaughtThrowError に変換されるので、 スレッド root に Throw が素で届くのは kill 配送だけ。
  • 自分自身が対象: その場で raise(kill なら kill-unwind、main の kill は SystemExit = プロセス終了)。
  • 未起動(Created)の対象: 本体を実行せずに死ぬ。
  • park 中の main: scheduler_run から戻った直後に take_main_pending() が配送する。
  • Thread.handle_interrupt: マスク(ThreadInner::masks)にマッチする割り込みは 即配送せず保留し、マスク境界(handle_interrupt ブロックの出入り)で配送する。

7. non-blocking IO(fd ポーラ統合)

前提として、シグナル対応の際に全ブロッキング IO は blocking_io_region (EINTR → VM ポーリング → 再開)という単一のチョークポイントに集約されている (SA_RESTART なしのシグナルハンドラ、EINTR を握り潰さない独自 read/write プリミティブ。 fd を持たない旧 blocking_region は全サイトが blocking_io_region に統合され削除)。 グリーンスレッド統合はこの上に載る:

  • blocking_io_region(vm, globals, io, events, f)(builtins/io.rs): fd を扱う 13 のビルトイン(read 系 / write 系 / IO.copy_stream の両側)を包む。
    1. 他に生存スレッドがいなければ従来どおり(本当にブロックする)。
    2. read 系は先にバッファ(ungetc pushback / BufReader 内残データ)を確認し、 あれば fd を見ずに実行(バッファがあるのに fd 未 ready で park すると自己デッドロック)。
    3. ゼロタイムアウト poll で readiness を確認し、未 ready なら scheduler::wait_fd(fd, events) で park(§3.3)。起床後に再検査。
    4. ready なら操作本体 f() を実行(直前に他スレッドは走れないので、 readiness が横取りされる競合はない)。
  • スケジューラ側: io_waiters に登録された fd 群をアイドル時に一括 poll(2) し、 revents が立ったスレッドを起こす(POLLERR/HUP/NVAL も起床 → 本体が実 errno を出す)。 #kill / #raise は fd 待ちのスレッドも起こす。
  • IO.select: グリーンパスでは与えられた全 fd を wait_fds で待つ (timeout は deadline として sleepers に併載)。非 IO オブジェクトは #to_io で変換。 全セット空 + timeout なしは CRuby 同様「status “sleep” で永眠(kill/wakeup 可能)」。 単独スレッド時は従来の select(2)(EINTR → ポーリング → 再試行)。
  • ソケット: TCP の connect は非ブロッキング発行(EINPROGRESS)→ POLLOUTwait_fd で待って SO_ERROR 確認、accept はリスナ fd を恒久 non-blocking にして POLLIN の park-retry ループで受ける(いずれも native worker ではなくスケジューラの fd ポーラで処理)。DNS(getaddrinfo)は CRuby 同様インラインでブロックする。
  • poll できないカーネルブロッキング(flock、FIFO の open)は native worker に オフロードする(§9)。

mid-operation の would-block エミュレーション

入口の readiness チェックだけでは、「利用可能なデータを消費し、さらに要求する」操作 (例: パイプ上の read(n) で n が到着済みバイト数を超える、行が複数チャンクに分かれて 届く gets、パイプ容量を超える write)の 2 チャンク目以降がプロセス全体をブロックする。 これを防ぐため、他に生存スレッドがいる間は f() の実行中だけ fd を一時的に O_NONBLOCK にする(NonblockGuard、drop で元のフラグへ復元):

  • ブロックするはずだったカーネル突入は EAGAIN を返し、read/write プリミティブは 消費済みバイトを pushback に戻して内部マーカー MonorubyErr::would_block_interrupt を浮上させる(シグナルの signal_interrupt マーカーと同型の配管)。
  • blocking_io_region がマーカーを捕捉し、fd のモードを復元してから scheduler::wait_fd で park → ready 後に操作を再開(pushback から再読するので データは失われない。write は *progress が書けた分を記録しているので重複しない)。
  • 生存スレッドがいないのにマーカーが浮上した場合(read_nonblock / write_nonblock が恒久的に nonblocking 化した fd)は、シグナル割り込み可能な 素の poll(2) で待つ(CRuby も nonblocking fd 上のバッファド IO はブロックする)。
  • 標準ストリーム(fd 0–2)はガード対象外: open file description を親シェルと 共有しており、異常終了でフラグが漏れると外側の IO を壊すため (古典的な「nonblocking stdout」問題)。入口 park のみでカバーする。

8. タイムスライス・プリエンプション(preempt.rs, #962)

協調型だけでは、busy-loop するスレッドが Thread.pass を呼ばない限り CPU を独占し、 他スレッドの飢餓・Thread#kill 未配送・mspec の --timeout watchdog スレッドが 永久に走らない、といった公平性の問題が残る。プリエンプションはこれらを、GC・JIT バックエンド・スレッドローカルシングルトンに一切触れずに解消する。

プリエンプションは正確に 「全スレッドが次のセーフポイントで Thread.pass を 呼んだかのように」 振る舞う — 既存の協調切替機構をそのまま再利用する。

8.1 タイマ

  • 10 ms tick(TICK)の専用タイマ OS スレッド。生存スレッドが 2 本以上ある間だけ 走る(on_thread_count(live)live >= 2 で起動、live < 2 で停止)。単独スレッドの プログラムはタイマを 1 本も生やさずコストゼロ。
  • MONORUBY_NO_PREEMPT / MONORUBY_PREEMPT_STRESS が設定されているとタイマは起動しない。
  • 毎 tick、flag_addr の mutex を取ってから poll フラグに fetch_or(PREEMPT_BIT)Codegen::drop(codegen_dropped)が同じ mutex 下で flag_addr を 0 に落とすので、 タイマが解放済み JIT メモリを触ることはない(マルチインタプリタのテストハーネス対策)。

8.2 フラグプロトコル

poll フラグは GC の alloc_flag と同じ 1 つの u32。複数の書き手がいる:

書き手操作
RValue アリーナ(ページ充填)+= 1
シグナルスタブ+= 10
malloc トリガ / GC.start>= 8 帯へ持ち上げ
プリエンプトタイマ|= 1 << 30(PREEMPT_BIT)
  • ビット 30であってビット 31 ではない: x86-64 の poll は cmpl [rip+alloc_flag], 8; jge という符号付き比較なので、ビット 31 だと負値に読めて発火しない。
  • タイマは別 OS スレッドから書くので、フラグアクセスはすべてアトミック (タイマ fetch_or、GC 後の unset_alloc_flagfetch_and(PREEMPT_BIT) で ベース帯だけ落として並行設定されたプリエンプトビットを保存する)。

8.3 poll 配置

callee-entry + ループバックエッジのみ。call-site poll は無い(JVM/CRuby 方式)。

  • callee entry(vmgen/init_method.rs = VM の vm_init / JIT の InitMethod): プロローグ直後、フレームがリンクされ rsp がその下、引数が rooted スロットに収まり、 残レジスタが nil 詰めされた「最も安全な」位置で poll する。GC ルート走査が 完全に整合したフレームを見る。全 dispatch 経路で一様に発火する。
  • call site: スタックオーバーフローチェック(CheckStack)のみ残す。Rust invoker (invoke_method / invoke_block)は caller 側にルート化されていないヒープ Value を ローカルに握るので、caller 側で poll してはならない(汎用 invoke_block の caller-side poll が File.open {} を壊した実績あり)。この callee-entry poll のおかげで、 Rust 側のイテレーションビルトイン(Kernel#loop / Array#each 等)もブロック本体が poll-free でも 1 反復ごとにプリエンプト・シグナル応答可能になる(ビルトインごとの 監査は不要)。
  • ループバックエッジ(vm_loop_start):同じく poll。
  • aarch64 も対称に実装(a64_op_init_method / a64_op_loop_start が poll、call-site なし)。

8.4 execute_gc での消費

セーフポイントから呼ばれる execute_gc(executor.rs)の順序:

  1. watchdog::poll()
  2. let (flag_base, preempt) = preempt::consume_poll_flag(); — プリエンプトビットを剥がし、 (ベース値, プリエンプトか) を返す(フラグ未登録なら防御的に (8, false))。
  3. 保留シグナルを drain(エラーを立てて None を返しうる)。
  4. flag_base >= 8 のときだけ実際に GC(純プリエンプト tick はベースが 8 未満なので スキップ = 偽の full GC を起こさない)。
  5. stress_renudge() — stress モードでは切替の前にフラグを再武装し、切替先スレッドも poll するようにする。
  6. if preempt && scheduler::preempt_ok() { scheduler::pass(vm, globals)? }passErr(kill/raise がこのスレッドに配送された)は set_error + None で浮上。

preempt_ok() = !machinery && main.is_some() && has_other_live_threads()machinery マーカー(§3 の Scheduler フィールド)は、スケジューラ自身の機構 (dispatch ループ / fd ポーリング)が main コンテキストで走る間 true・dispatch された スレッドの Ruby コードが走る間だけ false。よってプリエンプションは resume されたスレッドの コード中でだけ発火する(in_scheduler はこの役に立たない — dispatch 中のスレッド走行中も true のままだから)。

8.5 安全性(CRuby 互換)

  • 切替は GC が起きうる地点でしか起きず、register write-back も GC と同一。 よってサスペンド中のフレームは常に GC-complete。
  • ビルトインは他スレッドに対してアトミック(自分の blocking/poll 地点以外で切り替わらない)— GVL 下で CRuby が C 関数に与える保証と同じ。
  • JIT がローカルをレジスタにキャッシュしても他スレッドへ古値が漏れない: 他スレッドが フレームのローカルに触れるのは capture(ヒープ退避)経由のみで、JIT は capture された ローカルを必ずスタックスロットに書き戻す(block 渡しの call site は locals_to_S、 ループ tier コンパイルは uncaptured 前提でガード、外側変数特殊化は no_capture_guard)。 uncaptured フレームは他スレッドから到達不能なのでレジスタキャッシュは観測不能。

8.6 スイッチ

  • MONORUBY_NO_PREEMPT=1 — タイマを起動しない(協調型のみ)。
  • MONORUBY_PREEMPT_STRESS=1 — 全 poll 地点で切替を試みる(gc-stress のスケジューリング版。 「ここで切り替わるはずがない」系の潜在状態バグを炙り出す拷問モード)。

9. ネイティブ syscall オフロード(native_pool.rs, #962)

poll(2) で readiness を待てるもの(ソケット等)はスケジューラの fd ポーラで扱えるが、 待つべき fd を持たないカーネルブロッキング syscall はグリーンスレッドの単一 OS スレッドをそのままブロックしてしまう。これらだけを別の短命 OS スレッドへ逃がす。

  • オフロード対象は 2 つだけ(NativeOp):
    • flock(2) のブロッキング取得(File#flock)。LOCK_NB / LOCK_UN は カーネルでブロックしないのでインライン実行。
    • FIFO に対するブロッキング open(2)(相手が開くまでブロックする)。事前に stat して FIFO のときだけオフロードし、それ以外の open はインライン。
  • プールではない: submit は操作ごとに std::thread::spawn専用の短命 OS スレッドを 1 本生やし、syscall が返ったら終了する(ワーカー数・キュー・再利用なし)。
  • ワーカーは Ruby ヒープにも VM のスレッドローカルにも触れないNativeOp は生 fd / フラグ / CString パスだけを運ぶ(ヒープ参照を持たない)。共有状態はプロセスグローバル: results(Mutex<HashMap<ticket, Completion{ret, errno}>>)、orphansNEXT_ID
  • 完了通知は eventfd ではなく pipe(2)。各インタプリタ OS スレッドが thread_local に 自己パイプ(read/write)を遅延生成し、ワーカーは結果を results に置いてから提出元の write 端へ 1 バイト書く。read 端はスケジューラの通常の fd ポーラに登録される (スケジューラは native_pool を一切知らない)。
  • flow(run_blocking): submit(op) → チケット取得 → try_take で完了を回収、 未完なら scheduler::wait_fd(read端, POLLIN) で park(他 green thread に譲り、main が park しているなら poll(2) で寝る)→ 起床で pipe を drain → 再試行。パイプは複数 waiter で共有なので、未充足の waiter は再 park する。
  • キャンセル: park 中に kill/raise が来て wait_fdErr を返したら、チケットを discard(結果が既に届いていれば除去、まだなら orphans に入れてワーカーの結果を 到着時に捨てる)しエラーを伝播する。ワーカー本体はそのまま無害に完走させる (可搬なキャンセル syscall がないため)。

10. 既知の制限と今後

  1. シグナルは「ポーリングしたスレッド」で変換される(CRuby は main に配送)。
  2. Thread.new のサブクラスは Ruby の initialize オーバーライドを実行しない。
  3. Thread#priority は保存のみ(スケジューリングに影響しない)。native_thread_id は 実 tid ではなくオブジェクト単位トークン。ThreadGroup / fork との相互作用、 Thread.ignore_deadlock の実効(検出器の停止)は未実装。
  4. ネイティブオフロード(§9)は flock / FIFO open のみ。fcntl(F_SETLKW) 等、他の カーネルブロッキング操作は未対応(将来 NativeOp を増やす余地)。
  5. 真の並列化は別の話(Ractor 型の分離が現アーキテクチャ — OS スレッドごとの ALLOC / CODEGEN / SCHEDULER — と整合的)。

(解決済み: Thread.handle_interrupt マスキング、mid-operation の IO ブロック(§7 の would-block エミュレーション)、タイムスライス・プリエンプション(§8)、 カーネルブロッキング syscall のオフロード(§9)。)

11. テスト

  • builtins/thread.rs#[cfg(test)]: CRuby 4.0.2 との差分テスト (インターリーブ順序、status ポーリング、kill/raise 意味論、同期プリミティブ、 thread+IO の各パターン、IO.select エッジ、fiber-local / thread-variable、 native_offload_flock_and_fifo(flock / FIFO open がグリーンスレッドだけをブロックし プロセス全体を止めないことの検証)ほか)。
  • ruby/spec: bin/spec またはリポジトリ外の spec/mspec で core/thread / core/mutex / core/queue / core/sizedqueue / core/conditionvariable / core/io を実行。 かつてスペックランナーをハングさせた core/io/copy_stream_spec.rbcore/io/select_spec.rb は完走・全パスする。

Green thread スケジューラ: Thread / Fiber の状態遷移図

doc/threads.md の補足。スケジューラ(src/scheduler.rs)が管理する ThreadState(src/value/rvalue/thread.rs)と、Fiber の FiberState(src/executor.rsfiber_state())の状態遷移をまとめる。

1. Thread の状態遷移

ThreadState は 6 状態:

Created | Runnable | Sleeping | Joining | IoWaiting | Dead

stateDiagram-v2
    [*] --> Created : Thread.new / start / fork<br>spawn() が registry と ready キューに登録

    Created --> Runnable : dispatch()<br>スタック確保 (initialize_stack) →<br>thread_invoker で本体起動
    Created --> Dead : 起動前に #kill / #raise が queue 済み<br>本体を実行せず finalize_unstarted()

    Runnable --> Sleeping : Kernel#sleep / Thread.stop<br>(sleepers へ登録、deadline は Option)
    Runnable --> Joining : Thread#join / #value<br>(対象の joiners へ登録、timeout は sleepers 併用)
    Runnable --> IoWaiting : fd 待ち wait_fd / wait_fds<br>(io_waiters へ登録、deadline は sleepers 併用)
    Runnable --> Runnable : Thread.pass / タイマ・プリエンプション<br>(ready 末尾へ回る)<br>park_permit 消費時は park が即時復帰
    Runnable --> Dead : 本体 return / 未捕捉例外 /<br>kill unwind 到達 → finalize()

    Sleeping --> Runnable : ① #wakeup / #run<br>② deadline 経過 (wake_due_sleepers)<br>③ #kill / #raise (wake_worthy な割り込み)
    Joining --> Runnable : ① join 対象の死亡 (finalize_common が joiners を起床)<br>② timeout 経過<br>③ #kill / #raise
    IoWaiting --> Runnable : ① fd ready / HUP / error (poll_io_waiters)<br>② deadline 経過<br>③ #kill / #raise

    Dead --> [*] : registry から prune<br>(ユーザ参照が残る限りオブジェクトは生存)

遷移の詳細とコード対応

遷移トリガ実装箇所 (scheduler.rs)
[*] → CreatedThread.newThreadInner::new を生成、spawn()threads + ready に登録spawn
Created → Runnableready から取り出され dispatch() が初回起動(スタック確保 → thread_invoker)dispatchEntry::Invoke
Created → Dead起動前に #kill / #raise が pending に積まれていた場合、本体を実行せず死亡(CRuby 意味論)。raise は終了例外として記録dispatchEntry::Skipfinalize_unstarted
Runnable → SleepingKernel#sleep / Thread.stop。deadline None = #wakeup されるまでsleep
Runnable → JoiningThread#join / #value。対象の joiners に登録。timeout 付きなら sleepers にも登録join
Runnable → IoWaitingブロックする IO / IO.select。fd ごとに io_waiters へ登録(1 スレッドが複数 fd を待てる)wait_fd / wait_fds
Runnable → RunnableThread.pass(自発)、またはプリエンプション(preempt.rs のタイマが 10 ms ごとに poll フラグを立て、次のセーフポイントで scheduler::pass 相当)。ready 末尾へpass
Runnable → Dead本体の正常終了(result 記録)、未捕捉例外(exception 記録)、または kill unwind のスレッド root 到達(クリーンな死)dispatch 復帰後の finalize
Sleeping → Runnable#wakeup / #run(park 中でなければ park_permit を立てるだけで状態遷移なし)/ deadline 経過 / wake に値する割り込みwakeup_inner / wake_due_sleepers / interrupt
Joining → Runnablejoin 対象の死亡(finalize_commonjoiners を一括起床)/ timeout / 割り込みfinalize_common / wake_due_sleepers / interrupt
IoWaiting → Runnablepoll(2) で fd が ready(HUP / error 含む — 起床側が再試行して実 errno を得る)/ deadline / 割り込みpoll_io_waiters / wake_due_sleepers / interrupt
Dead → (prune)finalize_common が state を Dead にし、joiners を起こし、threads registry から除去finalize_common

注意点

  • 「Running」という状態はないRunnable は「ready キューにいる」と 「現在実行中(Scheduler::current)」の両方を含む。
  • main スレッドは ready キューに入らない。main が Runnable に戻ることが scheduler_loop の終了条件で、ループが return して main が再開する。 各起床パス(wakeup_inner / wake_due_sleepers / poll_io_waiters / interrupt / finalize_common)はすべて Some(t) != main を確認してから ready に push する。
  • 割り込みによる起床は「配送」ではない#kill / #raisepending キューに積み、対象が park 中(Sleeping | Joining | IoWaiting)かつ マスクが全て :never でなければ Runnable に戻すだけ。実際の配送は dispatch の再開時(Entry::ResumeInterrupt: エラーをセットして 0 で resume → park していた park_switchErr を返す)か、main なら take_main_pending で行われ、Thread.handle_interrupt のマスクに従う。
  • park_permit(図中の self-loop): running な対象への Thread#__wakeup_permit(Mutex / Queue / ConditionVariable が使用)は park_permit を立て、対象の次の park は状態遷移せず即時復帰する。 プリエンプション下の lost-wakeup 窓を塞ぐ(doc/threads.md §5)。
  • タイムアウト付き join / IO 待ちは二重登録される(joiners/io_waiterssleepers の両方)。どちらか一方の経路で起きたら他方のエントリは stale になり、 wake_due_sleepers / prune_io_waiters が状態を再検査して破棄する。 スプリアス起床は常に許容され、呼び出し元のループが条件を再検査する。
  • Thread.allocate の shell(Thread::Waiter 等)は最初から Dead で 生成され、スケジュールされない。

Thread#status との対応

ThreadState#status#alive?#stop?
Created / Runnable"run"truefalse
Sleeping / Joining / IoWaiting"sleep"truetrue
Dead(正常終了 / kill)falsefalsetrue
Dead(例外終了)nilfalsetrue

2. Fiber の状態遷移

Fiber の状態は専用フィールドではなく Executor::rsp_save から導出される (fiber_state()):

rsp_save == None  → Created
rsp_save == -1    → Terminated
それ以外          → Suspended

stateDiagram-v2
    [*] --> Created : Fiber.new (rsp_save = None)

    Created --> Suspended : #resume / Enumerator#next 等<br>invoke_fiber: スタック確保 → fiber_invoker で本体起動
    Suspended --> Suspended : Fiber.yield で親へ復帰 ⇄<br>#resume (resume_fiber) で再開
    Suspended --> Terminated : 本体 return / 例外<br>invoker エピローグが rsp_save = -1 を書き<br>parent の rsp_save へ復帰

    Terminated --> [*]

    note right of Created
        Terminated への #resume は
        FiberError
        (Enumerator 経路では StopIteration)
    end note

    note right of Suspended
        「Running」は rsp_save では表現されない —
        実行中の Fiber も Suspended と読める。
        current / 祖先 (parent_fiber チェーン) への
        #resume は double resume として FiberError
    end note

遷移の詳細とコード対応

遷移トリガ実装箇所
[*] → CreatedFiber.new(FiberInner::new、スタック未確保)value/rvalue/fiber.rs
Created → Suspended(実行開始)初回 #resume / Enumerator#next / Generator 起動。initialize() が 256 KiB スタックを確保し rsp_save にスタックトップを書く → fiber_invokerFiber::invoke_fiber / invoke_fiber_with_self
Suspended ⇄ SuspendedFiber.yield(yield_fiber: 自分の rsp_save に現コンテキストを保存し parent_fiber へ switch)と #resume(resume_fiber: 逆方向)executor.rs / codegen/arch/*/invoker.rs
Suspended → Terminated本体の return または例外。fiber_invoker のエピローグが rsp_save = -1 を書いて parent へ復帰codegen/arch/*/invoker.rs
Terminated → (エラー)#resumeFiberError(“attempt to resume a terminated fiber”)、Enumerator / Generator 経路は StopIterationFiber::resume / enum_yield_values / generator_yield_values

注意点

  • エラー遷移(状態は変わらない):
    • 実行中の Fiber 自身、または parent_fiber チェーン上の祖先への #resumeFiberError(double resume。ライブなスタックへの switch は SIGSEGV になるため事前検査)。
    • parent_fiber == None のコンテキスト(main、およびスレッド root)での Fiber.yieldFiberError(“can’t yield from main fiber”)。
  • Thread との関係: Thread は Fiber のスタック切替機構(rsp_save 交換)を 土台にするが、スレッド root の parent_fiber は常に None で、切替は 専用スタブ(thread_invoker / switch_to_scheduler / scheduler_resume)が SCHED_RSP 経由で行う。ThreadInner::body_terminated() は root Executor の FiberState::Terminated(= rsp_save == -1)で本体終了を検知し、これが Thread 側の Runnable → Dead 遷移(finalize)のトリガになる。
  • スレッド内にネストした Fiber: green thread が nested Fiber の中で park した場合、ThreadInner::resume_exec は root ではなく park した Fiber の Executor を指し、スケジューラはそこへ直接 resume する(parent_fiber チェーンは切替をまたいで保存される)。

3. 2 つの状態機械の関係(全体図)

flowchart TB
    subgraph sched["スケジューラ (main コンテキストのイベントループ)"]
        ready["ready キュー (FIFO)"]
        sleepers["sleepers (deadline)"]
        io["io_waiters (fd, events)"]
    end

    subgraph thread["green thread"]
        direction TB
        root["thread root Executor<br>(parent_fiber = None)"]
        fib["nested Fiber<br>(parent_fiber → resumer)"]
        root -- "#resume" --> fib
        fib -- "Fiber.yield" --> root
    end

    ready -- "dispatch()<br>thread_invoker / scheduler_resume" --> thread
    thread -- "park (switch_to_scheduler)<br>resume_exec を記録" --> sleepers
    thread -- "park" --> io
    thread -- "本体終了 (rsp_save = -1)" --> sched
  • スケジューラは Thread 単位でスケジュールし、Fiber の resume/yield は スレッド内で完結する(スケジューラは関与しない)。
  • park はどの Fiber の中からでもよく、resume_exec が park した Executor を 指すので、再開は park 地点へ直接戻る。

セーフポイント(safepoint)

monoruby の VM / JIT が「実行を割り込んでよい」と保証する地点をセーフポイントと呼ぶ。 GC(doc/gc.md)・タイムスライスプリエンプション(doc/threads.md §8)・シグナル配送 (doc/signal.md)という 3 つの非同期イベントは、すべてこの同一のセーフポイント 機構に集約されている。本書はその共通機構を横断的にまとめる。

対象読者はランタイム実装者。関連ソース:

対象ファイル
poll のコード生成(x86-64)codegen/arch/x86_64/jit_module.rs(execute_gc_inner) / vmgen/init_method.rs(vm_init) / vmgen.rs(vm_loop_start)
poll のコード生成(aarch64)codegen/arch/aarch64/codegen.rs(a64_vm_execute_gc) / vmgen.rs
セーフポイント本体executor.rs(execute_gc)
poll フラグalloc.rs(alloc_flag / set_alloc_flag / unset_alloc_flag) / preempt.rs(PREEMPT_BIT / consume_poll_flag)
JIT tier の poll IRcodegen/jitgen/asmir.rs(AsmInst::ExecGc) / codegen/jitgen/compile.rs

1. セーフポイントとは何か

セーフポイントとは、実行中のインタプリタがフレームを完全に整合した(GC-complete な)状態 にしたうえで poll フラグを検査する地点である。フラグがトリガ帯に立っていれば、その場で execute_gc(executor.rs)を呼び、以下のいずれか(または複数)を行う:

  • GC: マーク&スイープ(doc/gc.md)。
  • シグナル配送: 保留シグナルを Ruby 例外 / Signal.trap ハンドラ呼び出しに変換 (doc/signal.md)。
  • タイムスライスプリエンプション: Thread.pass 相当のスケジューラ切替 (doc/threads.md §8)。

3 つとも「フラグを立てて、次のセーフポイントで実行する」という遅延実行モデルを共有する。 イベント発生源(ページ充填 / シグナルハンドラ / 別 OS スレッドのプリエンプトタイマ)は フラグを立てるだけで、実処理はセーフポイントに到達した VM スレッド自身が行う。


2. なぜ即時実行してはいけないか

イベント発生の瞬間に処理を走らせると危険なため、セーフポイントまで遅延する:

  • 未退避のライブレジスタ: JIT コンパイル済みコードは Ruby のローカル変数やレシーバを マシンレジスタにキャッシュしている。任意地点で GC ルート走査に入ると、レジスタ上の Value を取りこぼす(=生存オブジェクトの誤回収)。
  • 半端なフレーム: フレーム構築の途中(引数のホーミング前、rsp 調整前など)では、 ルート走査が読むスロットが不定値を含みうる。
  • シグナルの非同期性: シグナルハンドラは async-signal 文脈で走るので、そこで Rust のアロケータや RefCell に触れられない。ハンドラはビットを立てるだけにする。

セーフポイントは「そこでなら GC ルート走査が完全に整合したフレームを見られる」と 設計上保証された地点であり、そこへ到達したときにライブレジスタを退避(write-back、§6) してから処理に入る。プリエンプションが安全なのも「GC が起きうるのと同じ地点でしか 切り替わらない」ため — register write-back を含め GC と全く同じ扱いになる。


3. poll フラグ alloc_flag(u32)

VM/JIT が参照する単一の u32。3 イベントすべてがこの 1 語を共有する。ベース値(下位)が 8 以上でトリガ帯、上位ビット PREEMPT_BIT がプリエンプト要求。

書き手操作意味
ページ充填set_alloc_flag:+= 1ほぼ満杯ページごと(約 8 ページで 8 に到達)→ GC
malloc 圧 / GC.start>= 8 帯へ持ち上げ外部バッファ圧・明示 GC → GC
シグナルハンドラ+= 10保留シグナル配送
プリエンプトタイマ|= 1 << 30(PREEMPT_BIT)タイムスライス切替
  • ビット 30 であってビット 31 ではない: x86-64 poll は cmpl …; jge(符号付き比較)なので、 ビット 31 だと負値に読めて発火しない。
  • タイマは別 OS スレッドから書くので、フラグアクセスはすべてアトミック。GC 後の unset_alloc_flagfetch_and(PREEMPT_BIT) でベース帯だけ落とし、並行して立った プリエンプトビットは保存する。
  • 詳細な相互作用は doc/gc.md §4.1、doc/threads.md §8.2 を参照。

4. poll の配置

callee-entry(呼び出し先エントリ)+ ループバックエッジのみに poll を置く。 call-site(呼び出し側)には置かない — これは古典的な JVM / CRuby 方式である。

4.1 callee entry(vm_init / JIT InitMethod)

vmgen/init_method.rsvm_init:プロローグ直後、fill_nil の後に vm_execute_gc() を出力。 この位置が「最も安全な poll 地点」である理由(同ファイルのコメント):

  • フレームが完全にリンクされ、rsp はその下(ステージング用のレッドゾーンなし)、
  • 引数はスロットに収まり、残りのレジスタは直前に nil 詰めされている。

よって GC ルート走査は完全に整合したフレームを見る。しかも callee entry は 全呼び出し経路が必ず 1 度通る唯一の合流点なので、Rust の invoker (invoke_method / invoke_block)から呼ばれた場合も含め、あらゆる dispatch 経路で 一様に発火する(§8)。

4.2 ループバックエッジ(vm_loop_start / JIT LoopStart)

vmgen.rsvm_loop_start は先頭で vm_execute_gc() を出力する。JIT tier でも compile.rsTraceIr::LoopStartstate.exec_gc(ir, false) を出す。これにより メソッド呼び出しを一切含まない tight loop でも、反復ごとに poll を通る。

4.3 call site には置かない

vmgen/method_call.rs のコメントどおり、呼び出し側にはスタックオーバーフロー チェック(CheckStack / vm_check_stack)だけを残し、GC/preempt poll は置かない。 callee entry が全呼び出しで poll するので二重にならず、かつ Rust invoker の caller 側で poll してはならない制約(§8)とも整合する。

4.4 poll 間距離の有界性

native(非 Ruby)の callee には entry poll がないが、任意の非有界実行は必ずループ バックエッジか Ruby フレームの entry を通過するので、poll から poll までの距離は有界に保たれる。


5. poll のコード生成

5.1 VM tier(x86-64)

execute_gc_inner(jit_module.rs)が出力する。ホットパスは 1 比較 + fall-through:

    cmpl [rip + alloc_flag], 8
    jge  gc          ; ベース値 >= 8(またはプリエンプトビット)なら収集パスへ
exit:
    ; --- 別ページ ---
gc:
    write_back        ; 生きたレジスタを退避(§6)
    call exec_gc      ; = executor::execute_gc()
    testq rax, rax
    jne  exit         ; Some(nil)(=正常)なら復帰
    jmp  error        ; None(=例外/シグナル/割り込み)なら伝播
  • fall-through が最頻ケース(フラグ未武装)で、収集本体は select_page(1) の別ページに 置いてホットパスの I-cache を汚さない。
  • exec_gcexecute_gc を呼ぶスタブ。戻り値 Somerax != 0Nonerax == 0 として 分岐する。

5.2 VM tier(aarch64)

a64_vm_execute_gc(codegen.rs)が対称に出力する:

    mov x10, alloc_flag_addr
    ldr w11, [x10]
    cmp x11, #8
    b.lt skip
    bl  gc
skip:

5.3 JIT tier

JIT コンパイル済みコードでは、state.exec_gc(...)AsmInst::ExecGc { write_back, error } (asmir.rs)を積み、アーキ別バックエンド(arch/*/compile)が execute_gc_inner / jit_execute_gc に落とす。InitMethodLoopStart の両方で発行される(compile.rs)。 VM tier との違いは、退避すべきライブレジスタ集合がその地点の抽象状態から算出した WriteBack として渡ること(§6)。


6. write-back(ライブレジスタの退避)

poll でフラグが立っていたら、収集本体に入るに、レジスタにキャッシュされた 生存 Value をスタックスロットへ書き戻し、フレームを GC-complete にする:

  • VM tier: entry poll は直前の fill_nil で残スロットを nil 詰め済み。JIT のように レジスタキャッシュを持たないので、追加退避は基本的に不要(execute_gc_innerwrite_back クロージャは VM 経路では空)。
  • JIT tier: gen_write_back(jitgen.rs)が、その poll 地点の抽象状態が示す 「レジスタに載っている生きたスロット」をスタックへ書き出す。これにより GC ルート走査 (Executor::marklfp() のスロットを辿る、doc/gc.md §8)がレジスタ値を取りこぼさない。

同じ write-back 規律はコンテキストスイッチにも適用される。JIT インライン Fiber.yield は切替前に write-back(exec_gc)を発行し、サスペンドされる側のフレームを GC-complete に してから rsp を差し替える(doc/threads.md §1)。スレッドのプリエンプション切替も、 セーフポイントで write-back 済みだからこそ安全に行える。


7. セーフポイント本体(execute_gc, executor.rs)

セーフポイントから呼ばれる extern "C" 関数。3 イベントを 1 か所で捌く。順序:

  1. watchdog::poll() — poll 到達はインタプリタの進捗なので、ハングウォッチドッグの カウントダウンをリセット(doc/signal.md)。
  2. preempt::consume_poll_flag() — プリエンプトビットを剥がし (ベース値, プリエンプトか) を得る。以降の GC 判定はベース値で行う(純プリエンプト tick で偽の full GC を起こさない)。 フラグ未登録なら防御的に (8, false)
  3. 保留シグナルの drainPENDING_SIGNALS ビットマップを取り、最小番号のシグナルを Signal.trap ハンドラ呼び出し / 既定例外(SIGINT ⇒ Interrupt 等)に変換。エラーを 立てて None を返しうる。
  4. GC(ベース値 >= 8 のときだけ)parent_fiber を辿ってルート Executor へ行き、 ALLOC.borrow_mut().gc(&Root { globals, executor })
  5. preempt::stress_renudge() — stress モードでは切替の前にフラグを再武装し、 切替先スレッドも poll するようにする。
  6. プリエンプションpreempt && scheduler::preempt_ok() のとき scheduler::passErr(このスレッドへ配送された kill/raise)は set_error + None で浮上させる。

戻り値の意味は poll コード(§5.1)と対になる: Some(nil) = 正常復帰、None = 例外 / シグナル / 割り込みを伝播せよ。


8. Rust invoker と「caller 側で poll しない」原則

セーフポイントを callee entry に置く設計上の要石は、Rust 側の invoker (invoke_method / invoke_block)を呼び出し側で poll してはならないという制約である。

  • Rust の caller はルート化されていないヒープ Value をローカル変数に握ったまま、これらの invoker を呼ぶ。caller 側に poll を置くと、その Value が GC ルートから外れて誤回収される (汎用 invoke_block の caller-side poll が File.open {} を壊した実績がある — レシーバが ブロック復帰後に Rust ローカルにしか無かった)。
  • callee entry poll は、これら Rust invoker から呼ばれた Ruby フレームでも一様に発火する。 結果として Kernel#loop / Array#each などの Rust 側イテレーションビルトインも、ブロック 本体が poll-free でも1 反復ごとにプリエンプト・シグナル応答可能になる (ビルトインごとの監査は不要)。

この不変条件のおかげで、ビルトインは他スレッドに対してアトミックになる(自分の blocking/poll 地点以外では切り替わらない)—— GVL 下で CRuby が C 関数に与える保証と同じ。 一方、純 Ruby コードのアトミック性はプリエンプションで失われているので、純 Ruby の 同期プリミティブは別途 park permit とロックで守られる(doc/threads.md §5)。


9. まとめ

  • セーフポイントは monoruby の GC・プリエンプション・シグナルを束ねる単一の割り込み点。 3 イベントとも「フラグを立て、次のセーフポイントで実行」する遅延モデルを共有する。
  • 配置は callee entry(vm_init / InitMethod)+ ループバックエッジ(vm_loop_start / LoopStart) のみ。call-site には置かず、スタックチェックだけ残す。
  • 各 poll はホットパス 1 比較で、フラグが立ったときだけライブレジスタを write-back してから execute_gc に入る。ゆえにサスペンド/収集時のフレームは常に GC-complete。
  • callee-entry 配置は「Rust invoker を caller 側で poll しない」原則と表裏一体で、これが ビルトインのアトミック性と Rust 側イテレーションの応答性を同時に成立させる。
  • 詳細は doc/gc.md(収集本体)・doc/threads.md §8(プリエンプション)・ doc/signal.md(シグナル)を参照。

シグナル処理

monoruby の POSIX シグナル処理の現行実装を、コードに即して解説する。 シグナルは GC・プリエンプションと同じセーフポイント機構(doc/safepoint.md)の上に 載っており、非同期に到着したシグナルを「フラグを立てて次のセーフポイントで Ruby 例外 / Signal.trap ハンドラに変換する」遅延配送モデルで扱う。

注: コードコメントが参照する節ラベル(A2 / A3 / A4 / A6 / A7 / B+)は本書の見出しに対応する。

主な実装ファイル:

対象ファイル
ペンディングビットマップ・signo↔例外・dispositioncodegen/signal_table.rs
ハンドラスタブ(async-signal-safe)codegen/arch/{x86_64,aarch64}/jit_module.rs(signal_handler_for)
sigaction インストール・スタブ事前生成codegen/codegen.rs
セーフポイント配送executor.rs(execute_gc)
Signal / Kernel#trap ビルトイン・名前表builtins/process.rs / builtins/kernel.rs
trap テーブル(GC ルート)globals/globals.rs
signal_interrupt マーカーglobals/error.rs
ブロッキング IO 統合builtins/io.rs(blocking_io_region)/ value/rvalue/io.rs
ハングウォッチドッグwatchdog.rs
終了時のシグナル死main.rs / executor.rs(terminate_with_signal)

1. 全体設計 — 遅延配送

シグナルハンドラは async-signal 文脈で走るため、そこでは Rust のアロケータ・RefCell・ libc 呼び出しに触れられない。そこで 2 段階にする:

  1. 記録(async-signal 文脈): ハンドラスタブは、プロセスグローバルなビットマップに 自分のビットを OR し、poll フラグ(alloc_flag)を += 10 して即 ret。メモリの ADD と OR だけで、Rust には一切入らない。
  2. 配送(セーフポイント): 次に VM/JIT がセーフポイント(callee-entry / ループ バックエッジ、doc/safepoint.md §4)へ到達すると execute_gc(executor.rs)が ビットマップを drain し、最小番号のシグナルを Ruby 例外 / Signal.trap ハンドラ呼び出しに 変換する。

この構造により、シグナルは GC・プリエンプションと同一の alloc_flag・同一の poll・ 同一の execute_gc を共有する。+= 10 はトリガ帯(>= 8)を確実に踏むためのナッジ。


2. ペンディングビットマップ(signal_table.rs)

#![allow(unused)]
fn main() {
pub(crate) static PENDING_SIGNALS: AtomicU32 = AtomicU32::new(0);
}
  • プロセスグローバルAtomicU32。ビット n = シグナル n+1(SIGINT=2 ⇒ bit1)。
  • プロセスグローバルにする理由: sigaction はプロセス全体に効くので、Codegen ごとに ビットマップを分けると、2 個目の Codegen がハンドラを自分のビットマップへ向け直した際に シグナルを取りこぼす。1 枚のグローバルにすれば、記録側(スタブ)と drain 側が どの Codegen がインストールしたかによらず一致する。
  • pending_signals_addr() — スタブに焼き込む絶対アドレス。
  • take_pending_signals()swap(0, Relaxed) でアトミックに drain。
  • lowest_pending_signo(bitmap)bitmap.trailing_zeros() + 1最小番号の signo が優先 (§6)。1 回の drain で配送するのは 1 シグナルだけ。

ハンドラスタブ(A2 の一部;signal_handler_for)

x86-64(jit_module.rs)が出力する内容そのもの:

addl [rip + alloc_flag], 10   ; 次の poll を必ず発火させる
movq rax, (ps_addr)
orl  [rax], (bit)             ; PENDING_SIGNALS に自分のビットを OR
ret
  • Rust を呼ばない。メモリの ADD と OR、そして ret のみ。rax はシグナルハンドラの C ABI で caller-saved。
  • RMW は LOCK 前置しない(async-signal 文脈では ldxr/stxr 相当が使えない)。ネストした シグナルで増分/ビットを稀に取りこぼしうるが無害(次の poll が拾う)。
  • aarch64(jit_module.rs)も対称。ただし alloc_flag のアドレス取得法が異なる (x86 は rip 相対、aarch64 はラベルアドレス)。

async-signal-safe な理由: 静的に既知の絶対アドレスへのロード/ストアと ret だけ。 ロックなし・確保なし・libc 呼び出しなし・再入 Rust なし。


3. sigaction のインストール(A3)

codegen.rssigaction_to / install_signal_stub がプロセスワイドに libc::sigaction する。

  • SA_RESTART を付けない(flags = 0)。これは意図的(§8)。シグナルがブロッキング syscall を EINTR で中断させ、インタプリタを poll 地点へ到達させるため。
  • スタブ事前生成(A2): Codegen::new 時に TRAPPABLE_SIGNALS 全てのスタブを signal_stubs: HashMap<i32, CodePtr> に用意する。ゆえに実行時の trap は sigaction(2) だけで済み、稼働中のバッファに JIT コード生成を行わない。
  • デフォルトインストール: 起動時に POSIX_SIGNALS(HUP, INT, QUIT, ALRM, TERM, USR1, USR2)へ自動で sigaction。CHLD/CONT/WINCH/TSTP や PIPE は既定では張らない。 ウォッチドッグが armed のとき(§9)は SIGALRM をスキップしてハンドラを奪わない。

シグナル集合

集合内容
POSIX_SIGNALSデフォルトで sigaction する集合。既定で rescuable な SignalException(INT のみ Interrupt)へ変換される。
TRAPPABLE_SIGNALSSignal.trap でハンドラを張ってよい集合(Linux/非 Linux で cfg 分岐)。KILL/STOP(捕捉不能)、SEGV/BUS/FPE/ILL/TRAP/ABRT(フォールト)を除外。SIGPWR は Linux のみ。

4. セーフポイントでの配送(A6;execute_gc)

execute_gc(executor.rs)がセーフポイントで実行する処理のうち、シグナル部分:

  1. watchdog::poll()(§9)。
  2. preempt::consume_poll_flag() でプリエンプトビットを剥がす。
  3. シグナル drain: take_pending_signals()lowest_pending_signo()。signo があれば globals.signal_disposition(signo) で分岐:
    • Handler(handler)arg = Value::integer(signo)#callinvoke_method_inner で 呼ぶ。Err なら set_error + return None
    • Ignore{..} → no-op(防御的。SIG_IGN のシグナルは通常ビットを立てない)。
    • Default | SystemDefaultsigno_to_error(signo)Some(err) なら set_error(err); return None。マップ外は何もしない。
    • 同じ drain 窓に複数立っていても最小 signo だけ配送し、残りは捨てる (CRuby も coalesce したシグナルの全配送を保証しない)。
  4. GC 本体(ベース値 >= 8 のとき)。
  5. プリエンプション(scheduler::pass)。

シグナル配送は GC・プリエンプションより前に同じ poll 内で行われる。 execute_gcハンドラ呼び出し中に CODEGEN 借用を保持しないので、trap ハンドラが その内部で JIT コンパイルや GC を起こしても自由に再入できる。


5. signo → 既定例外(A4;signo_to_error)

Signal.trap ハンドラが無い場合、Default/SystemDefault は既定例外に落ちる:

signo例外
SIGINTInterrupt(専用クラス。Interrupt < SignalException)
SIGTERM / SIGHUP / SIGQUIT / SIGALRM / SIGPIPE / SIGUSR1 / SIGUSR2SignalException("SIG…")
その他None(防御的フォールスルー)

Interrupt < SignalException(A4)なので、rescue SignalException は SIGINT も捕まえる。 これらはいずれも rescuable な例外として VM の unwind 経路に乗る。


6. Signal.trap / Kernel#trap(A7)

登録

  • モジュール Signallist / signame / trap(builtins/process.rs)。
  • Kernel#trap(builtins/kernel.rs)は同じ process::signal_trap に委譲。

Signal.trap / Kernel#trap の挙動

  1. trap_signo で signo を解決(Integer は妥当な signo、Symbol/String/#to_str は名前で。 "SIG" 有無どちらも可。#to_int は呼ばない。それ以外は ArgumentError: bad signal type)。
  2. KILL/STOP(捕捉不能)→ ArgumentError: "Signal already used by VM or OS"。 予約シグナル(SEGV/BUS/ILL/FPE/VTALRM, EXIT)→ ArgumentError: "can't trap reserved signal"
  3. disposition はコマンド引数(command_disposition)かブロック (Handler(generate_proc(...)))から決まる(コマンド優先)。
  4. 先に OS レベルでインストールし、その後 trap テーブルへ記録する。CODEGEN 借用下で disposition ごとに install_signal_stub / install_signal_ignore / install_signal_default / install_signal_system_default を呼ぶ。失敗時は Errnoset_signal_disposition前の disposition を返し、disposition_to_value で Ruby 値に 変換して返す。

コマンド文字列と disposition

コマンド引数dispositionRuby へ返る表現
nilIgnore{from_nil:true}nil
"" / "SIG_IGN" / "IGNORE"Ignore{from_nil:false}"IGNORE"
"SIG_DFL" / "DEFAULT"Default"DEFAULT"
"SYSTEM_DEFAULT"SystemDefault"SYSTEM_DEFAULT"
その他の String/SymbolArgumentError: unsupported command
String/Symbol 以外のオブジェクトHandler(cmd)そのオブジェクト

SignalDisposition(signal_table.rs)は Default / SystemDefault(OS の SIG_DFL)/ Ignore{from_nil} / Handler(Value) の 4 種。

trap テーブル(globals.rs)

  • signal_handlers: Vec<SignalDisposition>(signo で添字、0 は未使用)。初期値は全 SystemDefaultPOSIX_SIGNALS のみ Default
  • signal_disposition(signo) / set_signal_disposition(signo, disp)(後者は前値を返す)。
  • GC ルート: Globals::mark が各 Handler(v) をマークする(trap ハンドラの Proc は この表からしか到達できないが、将来任意の poll 地点で呼ばれうるため)。

名前 ↔ 番号(SIGNAL_TABLE)

process.rsSIGNAL_TABLE が唯一の真実:Signal.list(名前→番号 Hash)、 Signal.signame(番号→名前)、trapProcess.kill が共有する。正準名がエイリアスに先行 (IOT=ABRT, CLD=CHLD, POLL=IO)。cfg 分岐(POLL/PWR は Linux、EMT/INFO はそれ以外)。


7. EINTR / ブロッキング IO 統合

SA_RESTART を付けない理由(§3 再掲)

シグナルはブロッキング syscall を EINTR で中断させ、インタプリタを poll 地点へ運ぶ必要がある。 SA_RESTART を付けると、0 バイト転送で中断した read(2) が透過的に再開され、アイドルな パイプでブロックしたプロセスを SIGTERM で終了させられなくなる。ブロッキングプリミティブは シグナルの伴わない素の EINTR は自前で再試行する。 (ウォッチドッグ自身の SIGALRM は変換経路ではないので SA_RESTART を使う。)

signal_interrupt マーカー(globals/error.rs)

  • MonorubyErr::signal_interrupt() — 「ブロッキング IO プリミティブが、シグナル保留中に EINTR を見た」ことを表す内部マーカー例外(メッセージ __monoruby_signal_interrupt__)。
  • is_signal_interrupt() で呼び出し側が判定。非ブロッキングの would-block 用に is_would_block_interrupt という同型マーカーもある(doc/threads.md §7)。
  • プリミティブ(value/rvalue/io.rs の割り込み可能 read/write)は、EINTR がシグナル保留と 重なったとき素の再試行をやめて signal_interrupt() を返す。シグナルの無い素の EINTR は 再試行。入口で既にシグナル保留(EINTR なしでビットが立っている)なら、それも浮上させる。

blocking_io_region(builtins/io.rs)

ブロッキング IO を包む単一のチョークポイント:

  1. 入口で保留シグナルを先に drain(PENDING_SIGNALS != 0 なら execute_gc)。
  2. f() を実行。
  3. is_signal_interrupt() なら poll 地点(execute_gc)を通す。既定 disposition は変換した SignalException を read の外へ raise、trap ハンドラなら実行して正常復帰時に操作を再開 (消費済みバイトは pushback 済みなので失われない)。
  4. is_would_block_interrupt() ならスケジューラの fd ポーラで park(doc/threads.md §7)。

その他の EINTR→poll 経路(いずれも SA_RESTART なし)

Process.waitpidKernel#sleep(nanosleep 前に保留シグナルを drain)、IO#write/flush、 スケジューラの待機(park_until_deadline / fd 待ち)、native_pool(素の EINTR は再試行)。 自分宛の Process.kill は、単一スレッドではシグナルが kill(2) 復帰前に配送されビットが 既に立っているので、kill 呼び出し内で execute_gcインラインで回して配送する(CRuby 同様)。


8. ハングウォッチドッグ(B+;watchdog.rs)

単一スレッド・非プリエンプティブなプログラムは、前進のないままスピン/ブロックしうる。 MONORUBY_HANG_WATCHDOG_SEC=N(N>0)で N 秒間 poll 地点に到達しなければ強制終了する ウォッチドッグを arm する(既定では無効)。

  • 状態は BUDGET / COUNTDOWN(AtomicI32)。arm_from_env()SIGALRM(こちらは SA_RESTART 付き)ハンドラと 1 Hz の setitimer(ITIMER_REAL) を仕込む。
  • armed 中はウォッチドッグが SIGALRM を所有する(Codegen::new のデフォルトインストールは SIGALRM をスキップ)。
  • poll()execute_gc から呼ばれ、COUNTDOWNBUDGET に戻す(=「前進した」)。 無効時は relaxed ロード 1 回だけ。
  • handler(signo) は async-signal-safe(atomics + write(2) + _exit(2) のみ)。毎秒 COUNTDOWN を 1 減らし、0 で fd 2 に中断メッセージを書いて _exit(134)中断判断は poll 地点ではなくハンドラに置く — 本当にハングしていれば poll 地点に そもそも到達しないため。

9. スレッド / スケジューラとの関係

  • どのスレッドが変換するか: poll 地点(execute_gc)に到達したスレッド。シグナルは main ではなくポーリングしたスレッドで変換される(doc/threads.md §10 の既知の制限。 CRuby は main へ配送)。
  • poll フラグは GC の alloc_flag と同一の u32。書き手はページ充填 +=1シグナルスタブ +=10、malloc/GC.start>=8 持ち上げ、プリエンプトタイマ |= 1<<30(doc/threads.md §8.2 / doc/gc.md §4.1)。
  • グリーンスレッドをまたぐブロッキング IO はスケジューラの fd ポーラで park する。 シグナルによる EINTR が待機を起こし poll 地点を通すので、他スレッドが park していても シグナル応答性が保たれる。

10. 終了時の SignalException(シグナル死)

捕捉されなかった SignalException / Interrupt は、プロセスを exit(1) ではなく 同じシグナルで自死させる(main.rs::handle_error):

  • Interrupt はまずエラーレポートを出力、素の SignalException は静かに死ぬ。
  • terminate_with_signal(signo)(executor.rs)が SIG_DFL に戻し、sigprocmask で ブロック解除して kill(getpid(), signo)。これで親プロセスからはシグナル死に見え ($?.signaled? / termsig)、Process.kill("TERM", child); $?.signaled? が CRuby 同様に動く。

11. まとめ

  • シグナルは async-signal 文脈でビットを立てて alloc_flag をナッジするだけ、実配送は 次のセーフポイント(doc/safepoint.md)で execute_gc が行う遅延モデル。GC・ プリエンプションと poll・フラグ・入口関数を完全に共有する。
  • ビットマップ・trap テーブルはプロセス/インタプリタ単位で、trap ハンドラは GC ルート。
  • SA_RESTART を意図的に外し、ブロッキング IO は signal_interrupt マーカー経由で EINTR を poll 地点へ運ぶ(SIGTERM 応答性の担保)。
  • 最小 signo 優先(A6)、SIGINT→Interrupt(A4)、Signal.trap(A7)、スタブ事前生成(A2)、 デフォルトインストール(A3)、ハングウォッチドッグ(B+)。
  • 捕捉されない SignalException は同じシグナルで自死し、親に正しいシグナル死を見せる。

Exception handling in monoruby — mechanism and CRuby contrast

How monoruby raises, unwinds, catches, and reports exceptions, and how the design differs from CRuby. The through-line is laziness: monoruby stores the minimum at raise time and defers the expensive work (exception-object materialization, backtrace string formatting) until something actually asks for it. This keeps the raise path — including the control-flow “pseudo exceptions” (return from a block, break, throw, retry, and internal StopIteration) that reuse the same machinery — cheap.

Primary sources:

  • ../monoruby/src/globals/error.rsMonorubyErr, MonorubyErrKind, backtrace formatting.
  • ../monoruby/src/executor.rsset_error/take_error, $! handling, take_ex_obj (materialization), complete_backtrace_for_rescue, ensure deferral.
  • ../monoruby/src/codegen/jit_module.rshandle_error (the unwinder).
  • ../monoruby/src/globals/store/iseq.rs — the per-method exception table (get_exception_dest, errinfo_restore_slots).
  • ../monoruby/src/builtins/exception.rs, ../monoruby/builtins/startup.rb — the Ruby-visible Exception API (#backtrace, #backtrace_locations, #set_backtrace, #cause, …).

1. The big picture

   raise / error in a builtin or VM op
        │  vm.set_error(MonorubyErr)                (executor.rs:1074)
        ▼
   error sentinel returned  ──►  entry_raise  ──►  handle_error(vm, globals, meta, pc)
                                                        │   (jit_module.rs:85)
        ┌───────────────────────────────────────────────┤
        │  For the *current* frame:                      │
        │   1. dispatch control-flow kinds early         │
        │      (MethodReturn / Throw / BlockBreak /      │
        │       Retry / Redo) — may resume or redirect   │
        │   2. push this frame's (loc, sourceinfo, fid)  │  ← incremental
        │      onto err.trace                            │    trace capture
        │   3. consult the frame's exception table:      │
        │      • rescue dest?  → complete backtrace,     │
        │        materialize object, goto rescue         │
        │      • ensure dest?  → defer unwind, goto      │
        │        ensure                                  │
        │      • neither?      → return error to caller  │
        └───────────────────────────────────────────────┘
                                   │ unwind one frame, re-enter handle_error
                                   ▼
                        … up to the top level (main.rs) if never caught

handle_error runs once per frame as the exception unwinds. There is no separate “raise” bytecode that snapshots the whole stack; the stack is recorded incrementally, one frame at a time, as control leaves each frame.

CRuby contrast

CRuby captures the backtrace eagerly at raise time (rb_ec_setup_exceptionrb_vm_get_backtrace walks the whole control-frame stack and stores it on the exception object) before unwinding starts. That is simple and makes #backtrace a stored-field read, but it pays the full stack-walk cost on every raise — including the many raises that are caught immediately and whose backtrace is never inspected. monoruby instead pays only for the frames it actually unwinds through, defers the caller frames to the catch point, and defers string formatting to #backtrace.


2. MonorubyErr — the in-flight error

MonorubyErr (error.rs:9) is the value held in Executor.exception while an error is propagating. It is a Rust struct, not a Ruby object:

fieldpurpose
kindMonorubyErrKind — the error class / control-flow tag (see §3)
messagethe message string
traceVec<(Option<(Loc, SourceInfoRef)>, Option<FuncId>)> — the backtrace, built incrementally as cheap tuples (no strings)
originalwhen re-raising an existing exception object (raise exc), that Value, so identity + ivars survive
explicit_causean explicit cause: keyword (Some(nil) for cause: nil)
payloadkind-specific extra data surfaced as hidden ivars on materialization (e.g. LocalJumpError#exit_value, StopIteration#result)

The exception object (RVALUE of class RuntimeError, etc.) is not created here. It is materialized lazily by take_ex_obj (§8) only when a rescue actually binds it or the top level needs to print it. Deferring Value allocation is the first half of the laziness story.

MonorubyErr::mark (error.rs:91) participates in GC: while an error is in flight it is not a Ruby object, so the GC cannot reach the Values it smuggles (original, explicit_cause, payload, and the receiver/tag/value payloads of a few kinds) through the normal object graph — mark roots them explicitly.


3. Two families of MonorubyErrKind

MonorubyErrKind (error.rs:1111 and above) mixes two conceptually different things into one enum, because monoruby routes both through the same unwinder:

3a. Real exceptions (catchable by rescue)

Runtime, NotMethod, Name, Type, Index, Key, Frozen, Load, Range, DivideByZero, StopIteration, SystemExit, IO, Arguments, Syntax, Other(ClassId) (any user-defined subclass), … Each maps to a Ruby exception class via from_class_id (error.rs:1143) / a class id, and each may carry structured data (e.g. NotMethod { name, receiver }) that becomes hidden ivars on the materialized object.

3b. Control-flow pseudo-exceptions (NOT ordinary rescue targets)

These reuse the unwinding machinery to implement non-local control flow, the same way CRuby uses its throw/catch-table TAG_* mechanism:

kindRuby constructhow it stops unwinding
MethodReturn(val, lfp)return from a block/proc/lambdastops at the target frame lfp
BlockBreak(val, fid, lfp)break out of a blockresumes the block’s defining call, or degrades to LocalJumpError
Throw(tag, val)Kernel#throw / Kernel#catchintercepted only by a matching catch, never by rescue
Retryretry in a rescue clauseredirected to the begin-region start
Redoredo in a loopredirected to the loop body start
Fatala Rust panic! caught at an extern "C" boundaryuncatchable — propagates straight to the top level

The crucial property, exploited for performance, is that handle_error dispatches every control-flow kind before it touches err.trace (jit_module.rs:118238, all ahead of the push_error_location at jit_module.rs:242). A MethodReturn / Throw / BlockBreak therefore never accumulates a backtrace tuple and never materializes an exception object. return from a block and break are as cheap as they can be while still threading through ensure bodies correctly.

CRuby contrast

CRuby likewise implements return/break/next/redo/retry/throw with its internal tag mechanism rather than real exceptions, and likewise does not build a Ruby backtrace for them. Fatal corresponds to CRuby’s rb_fatal / uncatchable fatal class. The taxonomy is deliberately parallel; monoruby just folds it into one Rust enum.


4. The unwinder: handle_error

handle_error (jit_module.rs:85) is the heart of the mechanism. For the current frame’s FuncKind:

ISeq (Ruby) frames:

  1. Retry/Redo (jit_module.rs:106) — take the error and goto the begin-region / loop start encoded in the instruction. No trace, no object.
  2. MethodReturn (jit_module.rs:118) — if this frame is the target lfp, return the value here; if an ensure sits in the way, defer the unwind across it; otherwise keep propagating. $! is restored from the region-entry save on the way out (restore_errinfo_on_exit).
  3. Throw (jit_module.rs:157) — run any intervening ensure, else keep propagating (a matching Kernel#catch frame consumes it).
  4. BlockBreak (jit_module.rs:173) — at the block’s defining frame, if the in-progress call site is the one that received this block, resume it with the break value (CRuby’s BREAK catch table); otherwise degrade to LocalJumpError (“break from proc-closure”).
  5. Incremental trace capture (jit_module.rs:242) — push_error_location(loc, sourceinfo, fid) appends this frame’s cheap tuple. Only real exceptions reach here.
  6. Fatal (jit_module.rs:247) — never caught; skip rescue/ensure, propagate to the top.
  7. Exception table lookup (jit_module.rs:251) — get_exception_dest(pc) returns (rescue_pc, ensure_pc, err_slot) for the innermost region covering pc:
    • rescue → call complete_backtrace_for_rescue (§7), materialize the object with take_ex_obj, store it into $! and the handler’s error slot, and goto the rescue clause.
    • ensuredefer_unwind (§6) and goto the ensure body.
    • neitherreturn ErrorReturn::return_err(), unwinding one frame; the caller re-enters handle_error.

Builtin (native) frames (jit_module.rs:265): only the control-flow kinds that can pass through a builtin are handled (MethodReturn, Throw, BlockBreak); a real exception records an internal trace frame (push_internal_error_location, no source location — printed as <internal>) and unwinds. Builtins have no Ruby-level rescue.

The exception table itself is built by bytecodegen and stored per method (iseq.rs:504). Entries nest innermost-first, so get_exception_dest returns the tightest enclosing region.

CRuby contrast

CRuby’s unwinder (vm_exec_handle_exception / the catch_table on each ISEQ) is structurally the same idea: a per-ISEQ table of (type, start, end, cont, sp) entries scanned as the stack unwinds, with CATCH_TYPE_RESCUE, ENSURE, RETRY, BREAK, REDO, NEXT. monoruby’s ExceptionMapEntry plays the role of a catch_table entry; ErrorReturn::{goto, return_err, return_normal} plays the role of CRuby’s THROW_DATA / continuation.


5. $! (errinfo) and the deferred-unwind stack

Executor.errinfo holds Ruby’s $! — the exception currently being handled — and is set when a rescue catches (set_errinfo, executor.rs:1092). Because control can leave a frame while it is suspended inside a rescue clause (a return/break jumping out mid-handler), the region-entry value of $! is saved into a bytecode slot, and restore_errinfo_on_exit (jit_module.rs:72) replays those saves (outermost wins) when such a frame is exited. errinfo_restore_slots (iseq.rs:543) enumerates the relevant slots.


6. ensure and deferred unwind

ensure complicates unwinding because the ensure body must run with an empty error slot (so it can itself raise/return), yet the original in-flight error must be re-raised afterwards unless the body overrides it. monoruby models this with a deferred-unwind stack (executor.rs:11021153):

  • defer_unwind(lfp) moves the in-flight error out of exception and stashes it keyed by frame, then gotoes the ensure body.
  • finish_ensure(lfp) (the EnsureEnd hook) re-raises the deferred error — unless the ensure body left a new error pending, in which case the new one wins (CRuby: a raise/return/throw inside ensure supersedes).
  • discard_deferred_unwind(lfp) drops a deferral when the frame leaves by some other path so its EnsureEnd will not consume it.

This mirrors CRuby’s CATCH_TYPE_ENSURE continuation plus the “ensure result overrides pending throw” rule.


7. Backtrace construction — the key contrast

This is where the laziness pays off and where the recent work (PR #896) focused. A backtrace has three cost components, and monoruby defers each:

(a) The raise→rescue frames. These are captured incrementally by push_error_location as handle_error unwinds each frame (§4 step 5). They must be captured during unwinding because those frames are destroyed as the stack pops — they cannot be walked later. Cost: one 3-word tuple push per frame, no string formatting.

(b) The frames above the rescuing frame (the rest of the live stack at raise time). The incremental capture never sees these, because unwinding stops at the rescuing frame. CRuby includes them (its eager snapshot walked the whole stack). monoruby fills them in at the catch point with Executor::complete_backtrace_for_rescue (executor.rs:complete_backtrace_for_rescue, called from jit_module.rs:256 just before take_ex_obj):

#![allow(unused)]
fn main() {
// Walk the rescuing frame's callers via each inner frame's saved
// call-site pc — the same mechanism as Kernel#caller — appending the
// cheap (loc, sourceinfo, fid) tuples. No strings; formatting stays lazy.
}

Why the catch point, and not lazily at #backtrace time? Because it is the last moment the full stack is coherent: the raise→rescue tuples are already collected in (a), and the caller frames are still live (we are about to run a rescue clause nested inside them). If we deferred this walk to #backtrace, an exception object that escaped its rescue clause and was inspected later would find those caller frames gone — yielding a truncated, wrong backtrace. CRuby avoids the problem by snapshotting everything eagerly at raise; monoruby snapshots the caller half at catch, which is strictly cheaper (only exceptions that reach a real rescue pay for it) while remaining correct.

(c) String formatting. Fully deferred to Exception#backtrace (exception.rs:backtrace), which turns the tuples into "file:line:in 'method'" strings and memoizes the resulting Array in the /backtrace hidden ivar, so repeated calls return the same mutable object (matching CRuby’s e.backtrace.equal?(e.backtrace) and e.backtrace.unshift(x) visibility). #set_backtrace writes the same /backtrace ivar, unifying the explicit store with the memo.

#backtrace_locations is intentionally decoupled from the string backtrace via the __raise_backtrace intrinsic (raise-time capture only), so set_backtrace(strings) on a never-raised exception keeps #backtrace_locations nil, while an Array of Thread::Backtrace::Location sets both — matching CRuby 3.4+.

Cost summary for the hot paths

scenariobacktrace cost in monoruby
return from block, break, thrownone — dispatched before trace capture (§3b)
StopIteration caught by loopa few tuple pushes only — loop catches at the Rust level (err.is_stop_iteration(), kernel.rs:908), so it never hits a bytecode rescue, so complete_backtrace_for_rescue and take_ex_obj are never called
exception caught by a Ruby rescueraise→rescue tuples + one caller-stack walk (tuples only); strings only if #backtrace is called
uncaught exception (top level)full tuple trace; formatted once by the reporter

CRuby contrast (backtrace)

  • When captured: CRuby eagerly at raise; monoruby incrementally on unwind + once at catch.
  • What is stored: CRuby a rb_backtrace_t (frame snapshots); monoruby cheap (loc, sourceinfo, fid) tuples.
  • #backtrace strings: both format lazily and memoize; monoruby in the /backtrace ivar.
  • Control-flow tags: neither builds a Ruby backtrace for them.
  • Frame labels: monoruby renders owners with their fully-qualified name (Ns::Cx.foo, special-casing Object#foo) in func_description (../monoruby/src/globals/store.rs), matching CRuby’s Ns::Cx.foo.

8. Materializing the exception object — take_ex_obj

take_ex_obj (executor.rs:1174) converts the in-flight MonorubyErr into a Ruby Value, called only at a catch point or the top level:

  • Re-raise (err.original set): return the same object, filling its trace only if still empty (CRuby assigns a backtrace only when the exception lacks one).
  • Fresh object: allocate Value::new_exception(err) and attach kind-specific hidden ivars — LoadError#path, SystemExit#status, NoMethodError#{name,receiver}, NameError#{name,receiver}, KeyError#{receiver,key}, FrozenError#receiver, LocalJumpError#exit_value
    • #reason, StopIteration#result, SyntaxError#path, … Hidden ivars use /-prefixed names so they are excluded from #instance_variables.
  • Cause chaining (chain_cause, executor.rs:1335): an explicit cause: keyword wins; otherwise CRuby’s exc_setup_cause — if a different exception is currently being handled ($!), record it as /cause. cause: nil suppresses the implicit chain.

CRuby contrast

CRuby builds the exception object at raise (it is the raise). monoruby’s split — Rust MonorubyErr while in flight, Ruby object only at catch — is what lets it skip object allocation entirely for the immediately-caught and control-flow cases. The materialized object’s ivar layout and cause semantics are kept CRuby-compatible.


9. Fatal errors

A Rust panic! caught at an extern "C" trampoline becomes MonorubyErrKind::Fatal. is_fatal() (error.rs:1137) makes handle_error skip both rescue and ensure and propagate straight to the top (jit_module.rs:247), because VM/interpreter state may be inconsistent after a panic. This matches CRuby’s uncatchable fatal — not interceptable even by rescue Exception.


10. Top-level reporting

An exception that reaches the top uncaught is printed by the reporter in error.rs (show_error_message_and_all_loc, error.rs:167): the message line plus each caller frame as \tfrom <file>:<line>:in '<method>', honouring --backtrace-limit=N (extra frames collapse into \t ... K levels...). The compact single-location form (show_error_message_and_loc) is used where CRuby prints only the origin (e.g. SyntaxError, which also gets a source excerpt).


11. File map

concernlocation
in-flight error type + kinds../monoruby/src/globals/error.rs
unwinder../monoruby/src/codegen/jit_module.rs (handle_error)
set/take error, $!, ensure defer../monoruby/src/executor.rs
catch-time caller walk../monoruby/src/executor.rs (complete_backtrace_for_rescue)
object materialization + cause../monoruby/src/executor.rs (take_ex_obj, chain_cause)
per-method exception table../monoruby/src/globals/store/iseq.rs
frame-label rendering../monoruby/src/globals/store.rs (func_description)
Ruby Exception API (Rust side)../monoruby/src/builtins/exception.rs
Ruby Exception API (Ruby side)../monoruby/builtins/startup.rb
Kernel#raise / #loop / #caller../monoruby/src/builtins/kernel.rs
differential tests../monoruby/tests/backtrace.rs, tests/exception_api.rs

12. Design summary

monoruby’s exception mechanism is CRuby-compatible at the Ruby surface (rescue/ensure/retry/redo, Exception API, cause chaining, backtrace format, uncatchable fatals) while diverging in when work happens:

  • Raise stores the minimum — a Rust MonorubyErr with cheap trace tuples; no Ruby object, no formatted strings.
  • The stack is recorded incrementally on unwind, not snapshotted eagerly.
  • Caller frames are completed once, at the catch point — the last coherent moment — not eagerly at raise and not unsafely late at #backtrace.
  • Control-flow constructs pay nothing for backtraces because they are dispatched before trace capture, and internal StopIteration (via loop) is caught at the Rust level below the bytecode-rescue path.

The net effect is CRuby-equivalent observable behavior with the backtrace cost concentrated on exactly the exceptions that are genuinely caught and inspected.

Stack layout for the bytecode interpreter/ JIT-ed code

stack frame structure (just after prologue)

             +-------------+----------------------
             |  prev lfp   |
             +-------------+
             |   prev pc   |
             +-------------+  continuation frame
             | return addr |
             +-------------+
 BP->        |  prev rbp   | <- rbp
             +-------------+----------------------
CFP->        |  prev cfp   |
             +-------------+  control frame
             |     lfp     |
             +-------------+----------------------
       -0x00 |    outer    | <- r14
             +-------------+
       -0x08 |    meta     |
             +-------------+
       -0x10 |    block    |
             +-------------+
       -0x18 |    self     |  local frame
             +-------------+
       -0x20 |    arg0     |
             +-------------+
             |      :      |
             +-------------+
             |   arg(n-1)  |
             +-------------+----------------------
       -0xy0 |             | <- rsp
             +-------------+
             |      :      |

stack frame structure (just before call)

             +-------------+
       -0x00 |             | <- rsp
             +-------------+
       -0x08 |             |
             +-------------+
       -0x10 |             |
             +-------------+-----------------
       -0x18 |  prev cfp   |
             +-------------+  control frame
       -0x20 |     lfp     |
             +-------------+-----------------
       -0x28 |    outer    | <- r14
             +-------------+
       -0x30 |    meta     |
             +-------------+
       -0x38 |    block    |
             +-------------+
       -0x40 |    self     |  local frame
             +-------------+
       -0x48 |    arg0     |
             +-------------+
             |      :      |
             +-------------+
             |   arg(n-1)  |
             +-------------+------------------
             |      :      |

ABI of interpreter and JIT-ed code

global registers (callee save)

  • rbx: &mut Executer ([rbx] points to cfp)
  • r12: &mut Globals
  • r13: pc (current bytecode address, dummy for JIT-ed code)
  • r14: lfp (local frame pointer)

メソッド引数の処理

pos_num

位置仮引数の数。req の他に optional, rest 引数を含み、子引数(分割代入で用いられる仮引数)は含まない。

ex. def f(a,(b,c),d,e=42,f:100) => pos_num = 4

Caller

  • 位置引数を callee のフレームにコピー(あふれた引数はメソッド呼び出しの場合は rest に集める)
    • splat 引数を展開
    • callerにkeyword 引数・hash splat引数があり、かつcalleeにkeyword 仮引数・keyword rest仮引数がない場合、渡されるkeyword 引数をHashオブジェクトとし、1個の位置引数として引き渡す。
    • callee がブロックかつ必須仮引数+rest仮引数が複数の場合、もし引数が1個の Array なら展開する。
    • 余ったreqは nil 、余ったoptは None で埋める
    • 位置引数の個数をチェックして不正ならエラーを返す
  • keyword・hash splat 引数の割り当て
  • 余った keyword 引数を keyword rest 仮引数に集める

Callee側の処理

prologue での処理 (InitMethod)

  • スタックの調整
  • 一時変数スロットを nil で初期化。

bytecode での処理 (bytecode.rs/compile_func())

  • 分割代入がある場合は分割されるスロットを再帰的に展開(余った子引数は nil で埋める)
        +--------------+
        |   +----------+----+
  a , ( b , c ) , d    |    |
  |     |   |     |    |    |
  v     +-+-+     v    v    v
  0       1       2    3    4

  • opt引数がある場合は実引数が引き渡されていなければ初期化
               <------pos_num------>
               <---reqopt_num---->
               <-req_num->
               +---------+-------+-+----+-+-----+-
               |   req   |  opt  |r| kw |b|decon|
               +---------+-------+-+----+-+-----+-
               +---------+-------+---+-----
ARG >= pos_num |         ARG         |
               +---------+-------+---+-----
               |         |       |  /
               +---------+-------+-+--------
               |   req   |  opt  |r|  
               +---------+-------+-+--------

               +---------+--+----+-+--------
req_num <= ARG |     ARG    | 0  | |  
               +---------+--+----+-+--------
               +---------+--+----+=+--------
ARG < req_num  | ARG |nil|   0   | |  
               +---------+--+----+-+--------

Native (builtin) function registration

native method definition with optional / rest / keyword parameters

#![allow(unused)]
fn main() {
globals.define_builtin_class_func_with_kw(klass, "xxx", xxx, min: 1, max: 2, rest: true, kw: &["base", "sort"]);
}
  • min: required arguments
  • max: required + optional arguments
  • rest: rest argument
  • kw: keyword arguments

in this examples, the method xxx has 1 required argument(=arg0), 1 optional argument(=arg1), rest argument(=arg2), and 2 keyword arguments (base(=arg3) and sort(=arg4)).

arg0: rewuired ----+
arg1: optional ----+-- positional
arg2: rest --------+
arg3: keyword("base")
arg4: keyword("sort")

CREF — Class Reference / Constant Reference

CREF is the runtime data structure that records the lexical environment needed by Ruby semantics that aren’t local-variable lookup:

OperationReads from CREF
module Foo; end / class Foo; endparent for the new module/class
def foo; enddefault definee (cref->klass)
Foo (unqualified constant)lexical scopes to walk in order
Foo = 1 (unqualified assignment)enclosing class for the new constant
public / private / protected / module_functionflags toggled on the innermost CREF
Module.nestingwalk of the lexical chain
using Foorefinement set (CRuby only)

This document describes how CRuby implements CREF and how monoruby’s implementation differs. Read it together with monoruby/src/executor.rs (Cref, lexical_class, push_class_context, …) and CRuby’s vm.c / vm_insnhelper.c / eval_intern.h (rb_cref_t, vm_cref_push, …).


CRuby

Layout

rb_cref_t is a heap-allocated imemo object (method.h):

typedef struct rb_cref_struct {
    VALUE flags;                 // imemo header + CREF flags
    VALUE refinements;           // Hash[refined_class] => refinement_module
    VALUE klass_or_self;         // T_CLASS / T_MODULE / T_OBJECT (singleton)
    struct rb_cref_struct *next; // outer CREF
    const rb_scope_visibility_t scope_visi;  // method_visi (3 bits) + module_func (1 bit)
} rb_cref_t;

Three single-bit flags live in flags (eval_intern.h):

FlagSet byMeans
CREF_FL_PUSHED_BY_EVALclass_eval { … }, class_exec, instance_eval { … }, instance_exec, Kernel#eval (no binding)“this CREF is a runtime override, not a real lexical scope”
CREF_FL_SINGLETONinstance_eval family“klass_or_self is the receiver; promote to its singleton when needed for def
CREF_FL_OMOD_SHAREDrefinement set sharingrefinements hash is borrowed from outer cref

Storage

A CREF is linked-list-shaped (next) and owned by the frame’s local environment. The environment pointer (ep) holds the CREF as one of its slots; vm_get_cref(ep) returns it. Each call frame can have its own CREF chain — there is no VM-wide stack.

Lifecycle

  1. Toplevel: vm_cref_new_toplevel builds the initial CREF (rb_cObject, METHOD_VISI_PRIVATE, FALSE, NULL, FALSE, FALSE). If Kernel#load(path, true) was used, an additional CREF for the wrapper module is pushed on top.

  2. class Foo … end / module Foo … end / class << obj … end: the defineclass instruction (insns.def:802) pushes a new cref with pushed_by_eval = FALSE, klass_or_self = Foo, and chains it to the previous CREF. Singleton class form sets singleton = TRUE.

  3. def foo; end: reads the current CREF (vm_get_cbase(ep) → CREF_CLASS_FOR_DEFINITION). The new method lands on cref->klass (or its singleton if CREF_FL_SINGLETON). The method’s iseq captures the same CREF chain in its environment so unqualified constants and super resolve relative to where the method was defined, not where it was called.

  4. module_eval { … } / class_eval { … } / module_exec / class_exec: yield_under(self, FALSE, …) pushes a new CREF with pushed_by_eval = TRUE, klass_or_self = self, singleton = FALSE. The flag means: def inside the block lands on self, but constant lookup / module Foo; end skips this CREF (it’s not a lexical scope).

  5. instance_eval { … } / instance_exec: yield_under(self, TRUE, …) — same as above but with singleton = TRUE, so def lands on the singleton class of the receiver.

  6. module_eval(string) / class_eval(string): eval_undervm_cref_push(self, NULL, FALSE /* pushed_by_eval */, singleton). Note that the string form sets pushed_by_eval = FALSE because the parsed string-eval iseq runs in this CREF’s lexical scope (whereas the block form has its own captured CREF and only needs the runtime override).

  7. Kernel#eval(string) (no binding):

    /* vm_eval.c:2009 */
    if (!cref && block.as.captured.code.val) {
        rb_cref_t *orig_cref = vm_get_cref(vm_block_ep(&block));
        cref = vm_cref_dup(orig_cref);
    }
    

    The caller’s CREF is duplicated (vm_cref_dup) before the eval body runs. Setting module_function / private / inside the eval mutates the duplicate’s scope_visi; the outer CREF is untouched.

  8. Kernel#eval(string, binding): binding.cref is used directly.

Visibility / module_function toggle

vm_cref_set_visibility (vm_method.c:2244) writes to the innermost non-eval CREF’s scope_visi:

static void
vm_cref_set_visibility(rb_method_visibility_t method_visi, int module_func)
{
    rb_scope_visibility_t *scope_visi = (rb_scope_visibility_t *)&rb_vm_cref()->scope_visi;
    scope_visi->method_visi = method_visi;
    scope_visi->module_func = module_func;
}

module_function (no args) sets (METHOD_VISI_PRIVATE, TRUE); public/private/protected set their visibility and clear module_func to FALSE. This is why module_function; def t1; end; public; def t2; end produces a regular t2public is a state machine reset, not just a visibility change.

Constant lookup

vm_get_ev_const (vm_insnhelper.c:1100) walks the CREF chain, but skips eval-pushed entries when computing the lexical chain:

while (root_cref && CREF_PUSHED_BY_EVAL(root_cref)) {
    root_cref = CREF_NEXT(root_cref);
}

This is what makes module Foo; end / X = 1 inside class_exec fall back to the block’s captured CREF, not the runtime receiver.

Refinements

Each CREF carries its own refinements hash (lazily shared with parent CREFs via CREF_FL_OMOD_SHARED). using Foo populates the current CREF’s hash; the lookup walks the chain.

monoruby does not implement refinements; the Cref struct has no refinements field. See doc/refinements.md for what the missing field is the smallest part of — the per-frame CREF this document describes is a prerequisite, and every method-resolution cache in the tree is keyed without a cref.


monoruby

Layout

Cref lives in monoruby/src/executor.rs:2222:

#![allow(unused)]
fn main() {
struct Cref {
    pub(crate) context: DefinitionContext,
    pub(crate) module_function: bool,
    pub(crate) visibility: Visibility,
    pub(crate) is_lexical: bool,
}

enum DefinitionContext {
    Class(ClassId),     // normal class/module body, class_eval, class_exec
    Receiver(Value),    // instance_eval / instance_exec — singleton lazily on def
}
}
FieldCRuby analog
context: Class(id)klass_or_self with CREF_FL_SINGLETON = 0
context: Receiver(v)klass_or_self = v with CREF_FL_SINGLETON = 1
module_functionscope_visi.module_func
visibilityscope_visi.method_visi
is_lexicalinverse of CREF_FL_PUSHED_BY_EVAL (true when the entry IS a lexical scope)
(no field)refinements — refinements aren’t implemented
(no field)flags / OMOD_SHARED

Storage

#![allow(unused)]
fn main() {
// Executor::lexical_class
lexical_class: Vec<Vec<Cref>>,
}

Two-level structure:

  • Outer Vec: one entry per require / load boundary (enter_class_context pushes; exit_class_context pops). This isolates the requirer’s lexical scope from the loaded file.
  • Inner Vec: the CREF chain for the current require-frame, oldest at the bottom, innermost at the top.

In CRuby the chain is per-iseq-frame and stored on the env; in monoruby it’s a single VM-wide stack maintained by the executor.

Lifecycle

EventHelperResulting Cref
Toplevel script startDefault for Executor initializes lexical_class = vec![vec![]]empty inner vec — context_class_id() falls back to OBJECT_CLASS
require / loadenter_class_contextnew empty inner Vec
Kernel#load(path, true)enter_class_context then push_class_context(wrap)inner Vec is [wrap-cref(lexical=true)]
class Foo … end / module Foo … end keyword (define_class success)push_class_context(class_id)Cref::new(Foo, false, Public) with is_lexical=true
class << obj … end (singleton class def, codegen/runtime.rs:850)push_class_context(singleton_class_id)same — is_lexical=true
Module#class_eval { … }, module_eval { … }, class_exec, module_execpush_runtime_class_context(module.id())Cref::new_runtime(module, Public) with is_lexical=false, module_function=false
Module#class_eval(string), module_eval(string)push_runtime_class_context (note: differs from CRuby — see below)same as block form
BasicObject#instance_eval { … }, instance_execpush_instance_eval_context(self_val)Cref::new_instance_eval(self_val, Public)Receiver mode, is_lexical=false
Kernel#eval(string) no-bindingpush_eval_cref (duplicates current top)a copy of the current innermost Cref; toggles inside the eval don’t leak

Visibility / module_function

set_module_function, clear_module_function, set_context_visibility all mutate the innermost entry of the current require-frame:

#![allow(unused)]
fn main() {
self.lexical_class.last_mut().unwrap().last_mut().unwrap().module_function = …
}

Module#public / private / protected (no args) call both set_context_visibility(visi) and clear_module_function() — matching CRuby’s vm_cref_set_visibility(visi, FALSE).

“Lexical” vs “definee” — the is_lexical distinction

Two queries take the cref:

  1. context_class_id(): the innermost Cref’s class. Used for def’s default definee. Walks neither is_lexical nor require-frames. Direct equivalent of CRuby’s vm_get_cbase(ep) → CREF_CLASS_FOR_DEFINITION.

  2. lexical_context_class_id(globals): the innermost lexical class. Walks only the current require-frame top-down, returning the first is_lexical=true entry. If none, falls back to the iseq’s captured lexical_context.last() (or OBJECT_CLASS). Used for module Foo; end / class Foo; end parent and for Module.nesting. Equivalent to CRuby’s “while CREF_PUSHED_BY_EVAL: next” walk in vm_get_ev_const.

The split exists so class_exec / module_eval / instance_exec push only a method-definee override:

class A; end
A.class_exec do
  def foo; end       # → A (definee = receiver)
  module Inner; end  # → toplevel (lexical scope unchanged)
  C = 100            # → toplevel
end

iseq-captured lexical context

Each ISeqInfo also stores lexical_context: Vec<ClassId> — populated by enter_classdef (codegen/runtime.rs:43) when entering a class/module keyword body. This is the static / parse-time analog of CRuby attaching the cref to the iseq’s environment.

Executor::definition_func_id chooses between the current frame’s iseq lexical_context and the enclosing method’s, used by:

  • unqualified X = 1 (set_constant): the lexical scope’s class is the assignment target.
  • lexical_context_class_id’s fallback when no lexical Cref is on the runtime stack (e.g. inside a block whose surrounding eval push was non-lexical).

Kernel#eval cref isolation (PR #444)

Kernel#eval(string) without a binding calls Executor::push_eval_cref() which duplicates the current innermost Cref onto the same require-frame. Mutations from the eval’d source (module_function, private, …) hit the duplicate, and pop_eval_cref() discards it on return. Mirrors CRuby’s vm_cref_dup(orig_cref) in eval_string_with_cref.

GC

Executor’s mark impl (executor.rs:155) walks all frames and marks DefinitionContext::Receiver(v) entries — instance_eval’s receiver only lives on the cref stack while the eval body runs, and receiver.class() may be a singleton class held alive only through this entry. Class IDs (Class variant) are interned in the ClassInfoTable and reachable via Globals’s root, so they don’t need explicit marking here.


Side-by-side summary

ConceptCRubymonoruby
Typerb_cref_t heap imemoCref plain Copy struct (16 bytes)
Linkagelinked list via nextVec inside Vec; index = depth
Per-frame storageyes — ep[CREF_SLOT]no — shared Executor::lexical_class
Real-vs-runtime splitCREF_FL_PUSHED_BY_EVAL flagCref::is_lexical (inverse polarity)
Receiver-mode (instance_eval)CREF_FL_SINGLETON + klass_or_self = receiverDefinitionContext::Receiver(value)
def defineeCREF_CLASS_FOR_DEFINITION (auto-promotes to singleton when flag set)context_class_id() (handles both variants)
Constant chainwalks cref.next, skipping PUSHED_BY_EVALlexical_context_class_id walks current frame skipping !is_lexical, falls back to iseq’s static lexical_context
Toggle reset (public clears module_function)vm_cref_set_visibility(visi, FALSE)set_context_visibility(visi) + clear_module_function()
Eval cref dupvm_cref_dup (deep, dup refinements)push_eval_cref (shallow Copy of innermost Cref)
Refinementsfirst-class field on rb_cref_tnot implemented
Kernel#load(path, true) wrapextra cref above toplevelextra push_class_context(wrap) after enter_class_context
Require boundaryimplicit (each script gets its own toplevel CREF chain)explicit outer Vec (enter_class_context/exit_class_context)

Known gaps in monoruby’s CREF

  • Refinements. No tracking; using Foo is a no-op stub. ~83 core/module ruby/spec failures are attributable to this.

  • Per-PC cref propagation for Module.nesting inside methods. current_class_nesting walks the runtime stack, which is accurate inside class/module bodies but not inside method bodies — CRuby uses the method’s iseq-captured cref chain. monoruby’s iseq does store lexical_context: Vec<ClassId>, but Module.nesting doesn’t consult it yet.

  • module_function / private toggles inside Kernel#eval with a Binding. PR #444’s push_eval_cref only fires for the no-binding form; the binding form uses the binding’s own cref. CRuby has the same shape but its binding-cref is never reused across calls — monoruby may need a sweep here too.

  • pushed_by_eval propagation through define_method proc body. When a proc is wrapped into a method via define_method, the proc’s outer cref isn’t faithfully reproduced on the wrapper’s iseq. Currently observable as ~3 failures in define_method’s “nested method in default definee” / “lambda for break” specs.

super のメソッド名解決と呼び出し元 PC を用いた実装

super は「いま実行中のメソッドと同じ名前のメソッドを、祖先チェーンの 現在位置より先から探して呼ぶ」命令である。単純に見えるが、「同じ名前とは どの名前か」「現在位置とはどこか」の 2 点が自明でなく、CRuby は両方をメソッド エントリ(callable method entry)に持たせて解決している。monoruby はフレームに メソッドエントリを持たない(FuncId しか持たない)ため、同じ情報を 呼び出し元 PC(cont-frame スロット)から復元する。本書はその機構を説明する。

対象ソース:

  • monoruby/src/codegen/runtime.rsentered_by / super_run / super_resolution / find_super / defined_super / find_method
  • monoruby/src/globals/store/class.rsbody_dispatched_by / super_occurrences / check_super / check_super_at / change_method_visibility_for_class
  • monoruby/src/globals/store.rsMethodTableEntry
  • monoruby/src/codegen/jitgen/compile/method_call.rs — JIT 側の ambiguous-super ガード

1. CRuby の意味論

CRuby では、メソッド呼び出しのたびに callable method entry (cme) が フレームに紐付く。cme は以下を保持する:

フィールド意味super での役割
called_id呼び出しに使われた名前(直接は使わない)
def->original_id定義時の名前super はこの名前で検索する
defined_classメソッドが見つかった ICLASS(チェーン上の位置)この位置の直後から検索を始める

この 2 つの情報がどう効くかを、問題になるケースごとに見る。

1.1 一つの本体が複数の名前を持つ場合(define_method)

sub = Class.new(sup) do
  [:a, :b].each do |name|
    define_method(name) { super() }
  end
end

define_method は呼び出しごとに独立したメソッドエントリを作る (original_id はそれぞれ :a / :b)。ブロック本体は共有されていても、 sub.new.a の super は :a を、sub.new.b の super は :b を検索する。 つまり super の検索名は「本体に焼き付いた名前」ではなく 「その呼び出しでディスパッチされたエントリの original_id」である。

1.2 alias の場合

class Alias3 < Alias2
  alias_method :name3, :name   # Alias2#name を :name3 として登録
end
Alias3.new.name3   # 中の super は :name で検索される

alias が作るエントリは original_id = :name を保持する。name3 で呼ばれても super は 元の定義名 :name で検索する。さらに defined_class元の定義位置(Alias2) を指すため、super は Alias2 の直後 (= Alias1)から探し始める。alias を登録した Alias3 は位置として数えない。

1.3 同じ本体がチェーンに複数回現れる場合

class Base
  def self.whatever
    mod = Module.new do
      def a(ary); ary << "anon"; super; end
    end
    include mod
  end
  def a(ary); ary << "non-anon"; end
end
class Twice < Base
  whatever   # 匿名モジュール 1 個目
  whatever   # 2 個目(同じ `def a` バイトコードの再実行)
end
Twice.new.a([])  #=> ["anon", "anon", "non-anon"]

チェーンは Twice → mod2 → mod1 → Base。mod2#a の super は mod1#a (同じ本体!)を呼び、mod1#a の super が Base#a を呼ぶ。CRuby では各 フレームの cme が異なる defined_class(mod2 の ICLASS / mod1 の ICLASS)を 持つため、同じ本体でも「チェーン上のどの出現か」を区別できる。

1.4 可視性の再宣言は「位置」ではない

class C
  include A   # private def derp(msg)
  include B   # private def derp; super('...'); end
  public :derp
end

public :derp は C に ZSUPER メソッドエントリ(可視性だけを上書きする 委譲エントリ)を作る。これは定義ではないので、B#derp の super の起点は あくまで B の位置であり、C を位置として数えて B 自身へ再入してはならない。


2. monoruby の表現とギャップ

monoruby のフレーム(LFP/CFP)が持つメソッド識別情報は FuncId のみである。 FuncInfo には名前が 1 つ焼き付く(FuncInfo::name())が、これは 「最初に登録されたときの名前」であり、上記 1.1〜1.3 の情報をすべて失う:

  • define_method で複数名に登録された本体 → 名前は最初の 1 つだけ
  • 同じ def バイトコードの再実行 → 同一 FuncId がチェーンの複数位置に登録 され、フレームからはどの出現か分からない
  • alias → FuncId は共有(エントリ側に original_name は残る)

一方、メソッドテーブル側(MethodTableEntry)には必要な情報が揃っている:

#![allow(unused)]
fn main() {
pub(crate) struct MethodTableEntry {
    owner: ClassId,
    func_id: Option<FuncId>,
    visibility: Visibility,
    is_basic_op: bool,
    original_name: IdentId,     // alias / define_method(Method) 経由の元定義名
    visibility_shadow: bool,    // `public :inherited` 型の可視性シャドウ
}
}

欠けているのは「このフレームはどのエントリでディスパッチされたか」という 動的情報だけである。これを呼び出し元 PC から復元する。


3. 呼び出し元 PC(cont-frame スロット)

3.1 スロットの位置と書き込み

すべての呼び出しで、callee フレームの CFP+24(Cfp::caller_pc_slot, executor/frame.rs)に「呼び出し元のコールサイトのバイトコード PC」が 入る。書き込み経路は 3 つ:

  1. VM tierpush_cont_frame(arch/x86_64/vmgen/method_call.rs): subq rsp, 8; pushq r13; subq [rsp], 16。ディスパッチ時の r13 は コールサイト + 16(send は 2 バイトコード単位 = 32 バイト)なので 16 を引いてコールサイト先頭を保存する。aarch64 は最初からコールサイト PC を 保存する。
  2. JIT tier(#889) — cont-frame 16 バイト領域は cont モードの FprSave が予約済み(xmm 退避はその上に置かれる)なので、 AsmInst::ContFramePcmovq [rsp], pc(a64: str x10, [sp])を send / specialized send / yield / specialized yield の 4 箇所すべてで発行する。
  3. invoker / native 経路 — 書かれない(ゴミが残る)。読む側が必ず検証する。

3.2 読み出しと検証

読み手(Kernel#caller と本機構)は共通のパターンで検証する (runtime.rs::entered_by):

slot != 0 かつ slot % 8 == 0
→ BytecodePtr として解釈
→ 呼び出し元フレーム(cfp.prev())の iseq の範囲内か(contains_pc)
→ その位置のオペコードが send 系か

send 系オペコード(bytecodegen/encode.rs):

opcode命令
30 / 31メソッド呼び出し(simple / generic)
32 / 33super
34 / 35yield

30〜33 の第 1 ワード下位 32 ビットが CallSiteId であり、 CallSiteInfo::name から「呼び出しに使われた名前」が得られる。 検証に失敗した場合(invoker 境界など)は None を返し、後述の フォールバックに落ちる。


4. 解決アルゴリズム(find_super)

super 実行時、ランタイムは次の 2 つを復元する (runtime.rs::super_resolution)。

4.1 呼び出し名の復元 → original_name への写像

  1. メソッドフレームを特定する。ブロック内の super は外側メソッドに属するため、 lfp.outermost()(proc-method 境界で停止する外側連鎖)でメソッド LFP を 求め、その LFP を実行している CFP まで下る。
  2. そのフレームの cont-frame スロットを entered_by で復号し、通常 send (opcode 30/31)なら CallSiteId → CallSiteInfo::name = 呼ばれた名前 を得る。
  3. 呼ばれた名前をレシーバクラスのメソッドテーブルで引き、 そのエントリが本当にこのフレームの本体へディスパッチするか検証する:
    • entry.func_id == 実行中 FuncId、または
    • エントリが proc-method ラッパー(FuncKind::Proc)で proc.func_id() == 実行中 FuncId (define_method はラッパー FuncId を登録するが、実行フレームには ブロック本体の FuncId が乗るため)。
  4. 検証に通れば entry.original_name を検索名とする。これで define_method 複数名(1.1: original_name = 各インストール名)と alias(1.2: original_name = 元定義名)の両方が CRuby と一致する。
  5. 復元できない場合は従来どおり FuncInfo::name()(焼き付け名)に フォールバックする。

4.2 出現インデックス(occurrence)の復元

同じ本体がチェーンに複数回現れるケース(1.3)のために、 「このフレームはその本体の何番目の出現か」を数える (runtime.rs::super_run):

k = 1, cfp = メソッドフレーム
loop:
  entered_by(cfp) が super オペコード(32/33)で、かつ
  呼び出し元フレームが 同じ本体(method_func_id 一致)を
  同じレシーバ(self 一致)で実行している
    → k += 1 して呼び出し元へ(super 連鎖の 1 ホップ)
  通常 send → 連鎖の底。 (k, そのコールサイト, exact=true)
  別本体からの super → (k, なし, exact=true)
  復号失敗 → (k, なし, exact=false)

super 連鎖であることをオペコードで確認するのが重要で、単なる再帰呼び出し (obj.a を a の中から呼ぶ)は連鎖を切る。再帰はディスパッチをチェーン先頭から やり直すので、出現カウントもリセットされるのが正しい。

4.3 チェーン検索(check_super_at)

#![allow(unused)]
fn main() {
check_super_at(self_class, current_fid, name, occurrence: Option<usize>)
}

チェーンを歩き、定義位置body_dispatched_by で判定する:

クラス/モジュール m の自身のメソッドテーブルが name を実行中本体へ ディスパッチする(直接、または proc-method ラッパー経由)。ただし visibility_shadow エントリは除外。

  • 名前で引く(FuncId の登録位置全部ではなく)ことで alias 登録先(1.2)を 位置から除外する。
  • visibility_shadow の除外が 1.4 に対応する。owner 登録の有無では判定 できない(同じクラスへの alias_method が owner を汚染するため)。

occurrence = Some(k) なら k 番目の定義位置から先を検索し、見つかった ものを(同一 FuncId でも)返す — これが 1.3 の「同じ本体へ super する」 挙動である。None(復号失敗時)なら従来のヒューリスティック 「同一 FuncId が見つかったら次の出現まで歩き続ける」で前進を保証する。

名前ベースの位置が 1 つも見つからない場合(stale な可視性シャドウが 差し替え済みの旧本体をディスパッチした場合など)は、#890 以前の owner 登録ベースの走査にフォールバックし、それも失敗したら レシーバクラスからの直接検索(UnboundMethod#bind 対応)を試す。


5. キャッシュとの整合

super の解決結果はコールサイトのインラインキャッシュに (レシーバクラス, FuncId) で刻まれるが、上記のとおり super の正解は フレーム依存になり得る(同一コールサイト・同一レシーバクラスでも、 呼び名や出現位置で行き先が変わる)。そのため:

  • VM: find_super が cacheable フラグを返す。 !is_block_style && super_occurrences(...) <= 1 のときだけキャッシュ可。 不可なら find_method はキャッシュタグに ClassId 0 を返す (ClassIdNonZeroU32 なので実クラスと一致せず、そのサイトは 毎回スローパスで再解決される)。
  • JIT(jitgen/compile/method_call.rs): コンパイル時に同じ条件 (mother FuncId が block-style、または出現数 > 1)を検出したら、その super サイトは plain-deopt(VM 実行)にする。Recompile にすると VM がキャッシュを温めない(タグ 0)ため再コンパイルが収束しない。
  • コンパイル時/キャッシュ更新用の check_super(3 引数版)は、出現数 > 1 なら None を返して辞退する。

defined?(super) (defined_super) も同じ super_resolution + check_super_at を使い、実行時セマンティクスと一致させている。


6. 既知の限界

  • invoker 境界: Method#call / send / Fiber などで入ったフレームは cont-frame スロットが無効なので、呼び出し名の復元も出現カウントも フォールバックに落ちる(焼き付け名 + 旧ヒューリスティック)。 define_method 複数名のメソッドを send で呼ぶと、super の検索名は 焼き付け名になる。
  • 可視性シャドウの staleness: public :derp はシャドウエントリに 継承先の FuncId をコピーするため、その後に継承元が再定義されると シャドウが旧本体をディスパッチする(CRuby の ZSUPER エントリは委譲なので この問題がない)。可視性を再宣言すればスーパークラス解決で再同期される。 super 解決側は owner-walk フォールバックで旧本体からでも前進できる。
  • 出現カウントは「連続する super 連鎖」を前提にしており、途中に invoker 境界が挟まると exact=false となり旧ヒューリスティックに 切り替わる(過小カウントによる無限 super ループを防ぐため)。

7. 関連 PR

  • #887 — VM tier がコールサイト PC を cont-frame スロットに保存、 Kernel#caller の行番号解決
  • #888 — specialized JIT 呼び出しの lazy 解決(#889 で置換)
  • #889 — JIT/specialized 呼び出しも eager にコールサイト PC を保存 (AsmInst::ContFramePc)
  • #890 — 本書の super 解決機構

Design: Per-Encoding Character Iteration Layer

Status: proposed (foundation design; precedes implementation)

1. Goal

Remove the implicit “every String is UTF-8” assumption from monoruby’s String operations by introducing a per-encoding character-boundary layer, so that character-indexed operations (length, [], chars, each_char, reverse, slice, scrub, =~, …) are correct for non-UTF-8 encodings (EUC-JP, Shift_JIS, ISO-2022-JP, ISO-8859-*, UTF-16/32) the way CRuby’s rb_enc_* / mbclen machinery is.

Out of scope (separate efforts, tracked elsewhere): rb_enc_compatible unification (analysis item ③), Encoding::Converter (⑤), Onigmo multi-encoding regex scanning (⑦), source-encoding propagation (⑥). This document is strictly the character-boundary foundation (①).

2. Current state (code-grounded)

RStringInner (monoruby/src/value/rvalue/string.rs) already stores content: Vec<u8> + ty: Encoding + cr: Cell<CodeRange>, and a partial foundation exists:

  • char_length() — per-encoding length, but EUC-JP/Shift_JIS fall back to byte count (Encoding::EucJp | Encoding::Sjis(_) => self.content.len()), and ISO-2022-JP round-trips through encoding_rs only for the count.
  • iter_char_bytes() -> CharByteIter — encoding-aware byte-slice iterator, but its next() width table treats EUC-JP, Shift_JIS, ISO-2022-JP as 1 byte/char (incorrect: those are multibyte).
  • conv_char_index / byte_to_char_index / from_substring — exist but assume the same (incomplete) width logic.
  • to_str() -> Cow<str> — for non-UTF-8-compatible encodings returns a \xHH-escaped rendering, not the real characters. Many builtins call to_str() and therefore misbehave on non-UTF-8 input.

So the structural seam (CharByteIter) is in place; the missing piece is correct per-encoding character-width decoding plus a disciplined migration of UTF-8-assuming call sites onto that seam.

3. Design

3.1 EncodingCodec — the character-boundary trait

Introduce a single decision function (not a trait object; a match on Encoding keeps it allocation-free and inlinable, matching the existing CharByteIter style):

/// Length in bytes of the character starting at `bytes[0]` under
/// `enc`, given the *preceding* decoder state (for stateful
/// encodings). Returns `CharLen`:
///   - `Char(n)`   : a well-formed character of `n` bytes
///   - `Invalid(n)`: `n` bytes that do not form a valid character
///                   (CRuby counts these as 1 "character" each for
///                    `length`, and `scrub` replaces them)
enum CharLen { Char(usize), Invalid(usize) }

fn char_len(enc: Encoding, st: &mut DecodeState, bytes: &[u8]) -> CharLen

DecodeState is () for all stateless encodings and a small enum only for Iso2022Jp (Ascii | Jisx0208 | Jisx0201), updated when an ESC sequence is consumed. Stateless encodings ignore it; this keeps the hot path (UTF-8/ASCII) branch-predictable.

Per-encoding rules:

Encodingrule
UsAsciib < 0x80 → Char(1); else Invalid(1)
Ascii8always Char(1) (binary: every byte is a “character”)
Iso8859(_)always Char(1)
Utf8existing UTF-8 lead-byte logic (reuse current code)
Utf16Le/Besurrogate-pair aware: Char(2) or Char(4); trailing odd byte Invalid(1)
Utf32Le/BeChar(4); trailing <4 bytes Invalid(rem)
EucJp0x00–0x8D,0x90–0x9F → Char(1); 0x8E → Char(2) (JIS X 0201 kana); 0x8F → Char(3) (JIS X 0212); 0xA1–0xFE lead → Char(2); malformed → Invalid(1)
Sjis(_)0x00–0x80,0xA0,0xFD–0xFF → Char(1); 0xA1–0xDF → Char(1) (half-width kana); 0x81–0x9F,0xE0–0xFC lead + valid trail 0x40–0x7E,0x80–0xFC → Char(2); else Invalid(1)
Iso2022JpESC-sequence → consume the 3-byte escape as zero characters (state change); in ASCII/JISX0201 state Char(1); in JISX0208 state Char(2); malformed → Invalid(1)

These tables are the canonical Ruby onigenc_mbc_enc_len equivalents; they are pure functions over bytes (+ ISO-2022-JP state) and fully unit -testable against CRuby ("...".force_encoding(e).each_char.to_a).

3.2 Wiring it in

  1. CharByteIter::next becomes the single consumer of char_len (carrying DecodeState). Every other character operation already funnels through iter_char_bytes() or should be migrated to.
  2. char_length(): iter_char_bytes().count() for the non-fixed-width encodings (drop the EUC-JP/SJIS byte-count fallback). Keep O(1) fast paths for SevenBit and fixed-width.
  3. conv_char_index / byte_to_char_index / from_substring / reverse / scrub: reimplement on top of iter_char_bytes() (offsets are the iterator’s running pos), so they are correct for every encoding by construction.
  4. to_str() policy: this is the riskiest seam (§5). Introduce chars_lossy() / explicit byte APIs and migrate builtins that do character work off to_str() for non-UTF-8 strings, rather than silently \xHH-escaping.

3.3 Invariants

  • iter_char_bytes() yields slices whose concatenation == content (no byte is dropped or duplicated) for every encoding/byte input, including broken input. This is the property regression tests assert.
  • char_length() == iter_char_bytes().count() for all inputs.
  • ASCII-only content under any ASCII-compatible encoding stays the existing O(1) SevenBit path (no perf regression for the common case — the optcarrot/benchmark strings are ASCII UTF-8).

4. Migration plan (incremental, zero-regression per step)

Each step is an independently shippable, spec-diffed PR (same methodology used for the #525–#535 series):

  • P0 — codec tables + tests. Add char_len/DecodeState, rewrite CharByteIter to use them. No public behavior change for UTF-8/ASCII/fixed-width (proven by cargo test + core/string diff). Adds correctness only for EUC-JP/SJIS/ISO-2022-JP iteration.
  • P1 — length/index. Route char_length, conv_char_index, byte_to_char_index through the iterator; delete the byte-count fallbacks. Target specs: String#length, #[], #slice for non-UTF-8.
  • P2 — chars/each_char/reverse/scrub. Migrate these builtins off to_str()+chars() onto iter_char_bytes().
  • P3 — to_str() callers. Audit the ~40 coerce_to_str/to_str() call sites in builtins/string.rs; split into “needs bytes” (unchanged) vs “needs characters” (move to the iterator). This is the largest step and is itself sub-divided per method family.

Ordering rationale: P0 is pure addition (lowest risk, unlocks everything); P1/P2 are mechanical given P0; P3 is the long tail and can proceed method-family by method-family without blocking.

5. Risks & mitigations

  • to_str() blast radius (highest). It is the de-facto “give me the string” accessor across builtins; changing its non-UTF-8 semantics wholesale would regress widely. Mitigation: do not change to_str() semantics in P0–P2; only add new explicit character/byte APIs and migrate callers individually in P3 with a per-family spec diff.
  • Performance. The hot path is ASCII UTF-8. Mitigation: keep the SevenBit O(1) short-circuits in char_length; char_len’s first match arm is the UTF-8 b < 0x80 → Char(1) case (same as today).
  • Onigmo coupling. Regex scanning still only knows ASCII/UTF-8 (analysis item ⑦). This layer makes String correct but does not make =~ correct for EUC-JP patterns; that is explicitly out of scope and must be documented in each P-step PR to avoid scope creep.
  • CodeRange cache coherence. cr must be invalidated/recomputed consistently with the new width logic. Mitigation: classify() and char_len share the same per-encoding validity definition; add a debug-assert (cfg(debug_assertions)) that iter_char_bytes() concatenation == content.
  • Spec oracle. All tables are validated by run_tests against CRuby 4.0.2 (s.force_encoding(enc).each_char.map(&:bytes) golden vectors) and a core/string + core/encoding regression diff per PR, consistent with the established zero-regression workflow.

6. Validation strategy

  • Unit: golden char_len vectors per encoding vs CRuby (force_encoding + each_char/length/reverse).
  • Property: for random byte buffers, assert concat(iter_char_bytes()) == content and char_length() == iter_char_bytes().count() for every Encoding.
  • Spec: core/string, core/encoding, core/symbol, core/regexp (MatchData captures) regression diff vs origin/masterzero regressions gate per PR; both Prism and ruruby parsers.

7. Decisions (confirmed)

  1. ISO-2022-JP statefulness — deferred. P0 implements the stateless encodings natively (EUC-JP, Shift_JIS; ISO-8859-*, ASCII-8BIT, US-ASCII, UTF-16/32 are already fixed-width). The DecodeState machine for ISO-2022-JP is out of P0; ISO-2022-JP continues to route through encoding_rs for length/iteration (it is rare in specs) and lands in a later step. char_len’s signature still carries &mut DecodeState so the later step is additive.
  2. to_str() long-term — display_lossy() split + convention. The \xHH fallback rendering moves to a clearly-named display_lossy() accessor; to_str() is reserved for “real characters / bytes” and character-work-on-non-UTF-8 via to_str() is forbidden by review convention. This split happens in P3 (not P0–P2, which keep to_str() semantics unchanged).

monoruby における Ruby C 拡張サポートの設計検討

本ドキュメントは、monoruby で CRuby の C 拡張 (.so) をロード・実行する仕組みを導入する際の検討内容をまとめたものである。


1. monoruby 側の現状調査

1.1 既存インフラ(活用できるもの)

  • Value の C ABI 互換性: Value#[repr(transparent)] struct Value(NonZeroU64) で定義されており (monoruby/src/value.rs:148)、C ABI 上は単なる u64 として渡せる。
  • ビルトイン関数の安定 ABI: extern "C" fn(&mut Executor, &mut Globals, Lfp, BytecodePtr) -> Option<Value> という固定シグネチャ (monoruby/src/executor.rs:16)。
  • libc::dlopen / dlsym の利用例: Fiddle/FFI 用に既に実装済み (monoruby/src/builtins/kernel.rs:1628-1692)。libloading クレートは使わず、libc の生 API を直接呼ぶパターン。
  • require.rs.so を認識: ただし現状は ~/.monoruby/lib/ 内の .rb スタブにリダイレクトするのみで、ダイナミックロードは行わない (monoruby/src/globals/require.rs:157-171)。

1.2 不足しているもの

  • CRuby C API 互換シム(rb_define_method, rb_funcall, VALUE, ID 等)は 一切存在しない
  • 外部 C コードが保持する Value を GC ルートとして登録する仕組みも未整備。GCRootGlobals/Executor ツリーのみを辿る (monoruby/src/alloc.rs:39-47)。

2. 設計方針:3 つの選択肢

Path A — CRuby C API 完全互換層

ruby.h 互換ヘッダと libruby.so 相当のシムを書き、nokogiri 等の既存 gem を無改造で動かす。TruffleRuby/Artichoke 系のアプローチ。

  • 長所: gem エコシステムが手に入る。
  • 短所: API 面積が膨大(数百シンボル)。Value のタグレイアウトが CRuby VALUE と異なる(Qnil=8 vs 0x04、Fixnum タグ位置等)ため二値互換は不可能で、全境界に変換が要る。rb_protect/setjmp モデルや GVL も必要。

Path B — monoruby ネイティブ拡張 API

mr_* プレフィックスの新 API を定義し、新規に C 拡張を書く人向けにする。

  • 長所: 小さく完結する。Value をそのまま MrValue = uint64_t として晒せる。
  • 短所: 既存 gem は移植が必要。

Path C — B を土台に A を段階的に積む(推奨)

まず B の機構(.so ロード / Init_xxx 呼び出し / GC ピン留め / 登録 API)を作り、その上に CRuby 互換シム関数を「よく使われるものから順に」薄く積み上げる。

Path B の具体ステップ(最小ゴール:hello-world .so

  1. require.rssearch_load_path.so を見つけたら、現行の .rb スタブ置換ではなく新関数 load_native_extension(path) へ分岐。
  2. dlopenkernel.rs の既存パターンを再利用して .so をロード。
  3. Init_<basename> を dlsym して extern "C" fn(*mut MrContext) として呼ぶ。
  4. monoruby_ext.h を新設し、以下を C 側に export:
    • typedef uint64_t MrValue;
    • MrClassId mr_define_class(MrContext*, const char* name, MrClassId super);
    • void mr_define_method(MrContext*, MrClassId, const char* name, MrBuiltinFn fn, int arity);
    • MrValue mr_str_new(MrContext*, const char*, size_t); / mr_int_value / mr_funcall / mr_gc_register
  5. GC ルート登録alloc.rsGCRoot ツリーに「外部 C 側から保持中の Value」コンテナを追加(GlobalsVec<Value> の固定根として)。
  6. ABI バージョンInit_xxx には MrContext の先頭に abi_version: u32 を入れて将来拡張可能に。

主要な落とし穴

  • Value の bit layout は CRuby と非互換 — Path A を将来やる場合、境界での変換テーブルが必須。
  • 例外伝播rb_raise 互換は setjmp/longjmp か Rust の panic=unwind を経由する必要があり、JIT のフレームを巻き戻せるか要検証。
  • Lfp / BytecodePtr — C 側へ晒すと ABI が固定化されて VM リファクタを縛るので、MrContext で抽象化するのが安全。
  • mkmfextconf.rb が CRuby の ruby.h を見つけにいく前提なので、monoruby 専用の mkmf 置換 or ruby.h 互換ヘッダ生成が必要。
  • スレッド/Fiberrb_thread_create 等は monoruby が単一ネイティブスレッド前提なので、Path A でも対応外にする方が現実的。

3. TruffleRuby のアプローチ(参考事例)

3.1 Sulong 時代(過去の主流)

C 拡張を LLVM ビットコード にコンパイルし、GraalVM 内蔵の LLVM インタプリタ(Sulong)が同一 JVM プロセス内で実行。本来不透明な「ネイティブコード内での VALUE 操作」を Truffle/Graal 側からインターセプトできた。

3.2 VALUE の表現

CRuby の VALUE (unsigned long) を直接受け取らず、ValueWrapper という Java/Truffle 側のラッパーオブジェクトと、ネイティブ整数ハンドルの二形態を行き来させる:

  • C 境界に渡す前に wrap、Ruby に戻すときに unwrap
  • nil/true/false/小整数/即値はインライン化(特別ケース)。
  • それ以外は 4096 エントリ単位の handle blockWeakReference で格納し、RubyFiberHandleBlockHolder で管理。

つまり CRuby の VALUE と二値互換にはせず、境界で必ず変換する 設計を選んでいる。

3.3 C API の実装場所

rb_define_method 等の数百個ある関数は C ではなく Ruby (および Java/Truffle ノード) で実装 されている:

“Most API functions are defined in the C header file, the C implementation file, and then either implemented as a call to a method… using polyglot_invoke to do a foreign call from C into Ruby, or we implement the function in Ruby in the Truffle::CExt module.”

C 側には薄いシムだけ置き、本体は Ruby 側 Truffle::CExt モジュールにある。互換 API の実装コストを劇的に下げる戦略。

3.4 GC 統合

TruffleRuby の GC は VALUE を理解しないため、3 層のレシピで生存性を担保:

  1. ExtensionCallStack — C 呼び出しごとに preservedObjects リストを積み、その呼び出し中に作られた ValueWrapper を強参照で pin。
  2. Handle blocks は弱参照だが、ValueWrapper 側がブロックを強参照 することで、wrap が生きている限りブロックも生きる。
  3. DATA_PTR 経由の mark 関数 を記録しておき、C 呼び出し終了時に実行して可達オブジェクトを集める。

3.5 最近の方針転換:Sulong からネイティブ実行へ

近年、Sulong 経由をやめてシステムツールチェイン(gcc/clang)でビルド・ネイティブ実行する方式に移行している:

“C/C++ extensions are now compiled using the system toolchain and executed natively instead of using GraalVM LLVM (Sulong), which leads to faster startup, no warmup, better compatibility, smaller distribution and faster installation.”

LLVM 経由の透過性メリットより、起動時間・warmup・互換性・配布サイズの実利が勝った。

3.6 monoruby への示唆

  1. Bit 互換は捨てる — TruffleRuby ほど高度な仕組みを使っても VALUE は wrap/unwrap が必須だった。
  2. C API の大半を Ruby/Rust 側で実装する戦略が有効 — TruffleRuby 式に「C シムは数十行、本体は Monoruby::CExt モジュール」にすれば互換シム数百個の重みが下がる。
  3. GC は per-call pin stack 方式 — C 呼び出しスコープごとに Vec<Value> を一つ push し、その間に作られた外部参照可能な Value を pin、呼び出し終了で pop。
  4. LLVM ビットコード解釈は不要 — TruffleRuby ですら捨てた手段なので、monoruby は最初から dlopen/dlsym のネイティブ実行で良い。

4. オブジェクトレイアウト不一致の吸収方法

CRuby C 拡張は RSTRING_PTR/RARRAY_PTR/RBASIC/RDATA/RTYPEDDATA 等のマクロを多用し、その多くは CRuby のメモリレイアウトを前提としている。これをどう吸収するか。

4.1 TruffleRuby の 3 層対策

① Sulong 時代:LLVM load 命令そのものをインターセプト

“in our C interpreter rather than the struct field reads just being a load from an address in memory, we can instead insert any logic we want.”

C ソースを gcc -emit-llvm でビットコードにし、Sulong が解釈実行。obj->len のフィールドアクセスは LLVM ビットコード上では getelementptr + load 命令だが、その load 命令自体を Ruby メソッド呼び出しに差し替える。インラインキャッシュも入る。

② C ソースのプリプロセッサ・パイプライン

gcc/clang に渡す前に C ソースを書き換えるruby.h 互換ヘッダの再定義と組み合わせて、問題のあるマクロを関数呼び出しに変換する。ネイティブ実行モードに移行した今、主たる手段になっている。

③ 個別マクロごとの吸収戦略

マクロTruffleRuby での扱い
RSTRING_PTR(s)初回呼び出しで「rope」表現に永久変換してネイティブメモリに固定。以降そのポインタを返す。
RARRAY_PTR(a)VALUE* に見えるプロキシオブジェクトを返す。読み書き・ポインタ算術は配列+オフセットを内部的に追跡。
RDATA(obj)->data = ptr;プロキシポインタ。data フィールドへの代入をインスタンス変数への書き込みに転送
RTYPEDDATATypedData とデータ構造を常に同一アロケーションに置く(embedded TypedData 相当)。
RBASIC->flags/->klassフィールドアクセスを intercept → Ruby 側の状態/クラスにマップ。
RB_TYPE_P 等の判定マクロヘッダ側で関数呼び出しに再定義。

4.2 monoruby への適用案

monoruby はネイティブ実行しか取らない以上、Sulong 式の load 命令インターセプトは使えない。TruffleRuby が現在採っている②③の組み合わせが現実的:

A. ruby.h 互換ヘッダで全マクロを関数化

// monoruby が提供する ruby.h
#define RSTRING_PTR(s)   mr_rstring_ptr((MrValue)(s))
#define RSTRING_LEN(s)   mr_rstring_len((MrValue)(s))
#define RARRAY_LEN(a)    mr_rarray_len((MrValue)(a))
#define RARRAY_AREF(a,i) mr_rarray_aref((MrValue)(a),(i))
#define RB_TYPE_P(o,t)   mr_type_p((MrValue)(o),(t))
#define NIL_P(o)         ((o) == Qnil)

CRuby 自身も近年マクロ群の多くを static inline 関数化しており、ABI ではなく API 互換で十分動く拡張は多い。

B. lazy materialization(必要時にネイティブ化)

  • RSTRING_PTR(s): 初回呼び出しで RValue::String のバイト列をネイティブ固定アロケータにコピー&pin。Globals の pin テーブルに登録。以降は同じポインタを返す。書き込み後は rb_str_modify 呼出を要求する CRuby 慣習に乗る。
  • RARRAY_PTR(a): 同様だが、書き込みのある場所では使わない方針を貫く(CRuby でも rb_ary_store(a, i, v) 推奨)。読み専用なら lazy ネイティブコピー。

C. RTYPEDDATA は co-allocation 設計

TypedData_Make_Struct を呼ぶと、monoruby の RValue ヘッダ + ユーザデータ構造を同一ネイティブアロケーションとして確保。((struct RTypedData*)obj)->data への直接アクセスが成立するよう、レイアウトを CRuby 互換にする(GC も普通に mark 関数を呼ぶだけ)。

D. 諦めるべきマクロ

  • RARRAY_PTR(a)[i] = vlvalue 書き込み — TruffleRuby はプロキシで吸収したがネイティブ実行では不可能。これに依存する gem は未対応とし、porting 時に rb_ary_store への置換を要求。
  • RHASH の内部構造直接アクセス — CRuby 自身が 3.0 頃から非推奨化済み。
  • RSTRUCT_PTR 等の lvalue 系も同様。

4.3 まとめ

「マクロをどう吸収するか」の答えは 3 層

  1. ABI 互換は諦める(TruffleRuby も諦めた)。
  2. API 互換ヘッダ + マクロを関数呼び出しに再定義(最も効くてこ)。
  3. 書き換え不能なポインタ寿命保証は lazy native materialize + pin、書き込み系は co-allocation で物理レイアウトを合わせる

5. 再コンパイルの必要性

5.1 結論:再コンパイルは必須

CRuby でビルド済みの .so をそのまま monoruby にロードすることは原理的に不可能:

不一致点影響
Value のビットレイアウトQnil = 0x04 vs 0x08、Fixnum タグ位置等が違う。.so 内に埋め込まれた即値定数がそのまま意味を持たない。
エクスポートシンボル名.sorb_define_methodrb_str_new 等を dlopen 時にリンク解決する。monoruby はこれらを自前のシムで実装するので、シンボルテーブル自体は同名で提供できるが、ABI が合わなければ即クラッシュ。
マクロ展開結果RSTRING_PTR(s) を CRuby ヘッダで展開するか monoruby ヘッダで展開するかで生成コードが全く違う。これはヘッダ依存ビルド時依存
構造体オフセット((RBasic*)obj)->klass を生コード上のオフセットとしてアクセスされた場合、monoruby の RValue 配置と合わない限り破綻。

5.2 ただし「再コンパイル」は実用上ほぼ自動化される

これは TruffleRuby・JRuby・Artichoke でも同じ前提で、ユーザ体験としては gem install nokogiri するだけ で済むよう作る:

  1. monoruby 用 mkmf を提供extconf.rbrequire "mkmf" したときに、monoruby の include パス(~/.monoruby/include/ruby.h)と link フラグを返す。
  2. gem install のフロー:
    $ monoruby -S gem install nokogiri
    → extconf.rb 実行(monoruby の mkmf)
    → Makefile 生成(-I ~/.monoruby/include, -lmonoruby_ext 等)
    → gcc/clang でビルド → nokogiri.so が monoruby ヘッダで再生成される
    → ~/.monoruby/gems/... に配置
    
  3. ユーザは何も意識しない — 「初回 gem install でビルドが走る」のは CRuby でも同じ体験。

つまり「再コンパイル必須」は ABI 互換を諦める代わりに API 互換を取る という現実解で、ユーザの心理的負担はほぼゼロ。


6. gem install での C 拡張コンパイル詳細

6.1 全体フロー

gem install nokogiri
  │
  ▼
① Gem::Installer            # gemspec読込、依存解決、ファイル展開
  │
  ▼
② Gem::Ext::Builder.build_extensions   # gemspecの extensions 配列を順次処理
  │
  ▼ extconf.rb がある場合
③ Gem::Ext::ExtConfBuilder.build
  │  ├─ tmpdir 作成
  │  ├─ ruby extconf.rb [build_args]    # ← ここで Makefile 生成
  │  ├─ make DESTDIR=...
  │  ├─ make install DESTDIR=...
  │  └─ make clean & tmpdir削除
  │
  ▼
④ .so を gemの lib/ と extensions ディレクトリへ配置
  │
  ▼
⑤ Gem::Specification 登録 → require できる状態に

Gem::Ext::Builder は他に RakeBuilderRakefile)/ConfigureBuilderconfigure)/CmakeBuilderCMakeLists.txt)も持つ。

6.2 extconf.rb の中身

典型例:

require "mkmf"

# システム探査
have_header("zlib.h")               or abort "zlib.h not found"
have_library("z", "deflate")        or abort "libz not found"
have_func("strlcpy", "string.h")
find_executable("xml2-config")

# プリプロセッサ定数生成
$CFLAGS  << " -Wall -O2"
$LDFLAGS << " -lpthread"

create_header                       # extconf.h を生成
create_makefile("nokogiri/nokogiri") # ← Makefile を生成

have_header/have_library/have_func実際にテスト用の小さな C ソースをコンパイル&リンクして判定 している(try_compile/try_link)。

6.3 mkmf が参照する RbConfig キー

これが monoruby 移植の核

キー用途
rubyhdrdirruby.h の場所/usr/include/ruby-3.4.0
rubyarchhdrdirアーキ別ヘッダ/usr/include/ruby-3.4.0/x86_64-linux
archdir標準ライブラリ .so の場所/usr/lib/ruby/3.4.0/x86_64-linux
sitearchdirサイト拡張 .so の配置先/usr/local/lib/ruby/site_ruby/3.4.0/x86_64-linux
vendorarchdirベンダ拡張 .so の配置先(配布パッケージ用)
CCC コンパイラgcc
CXXC++ コンパイラg++
CFLAGS/CPPFLAGSコンパイル時フラグ-O3 -fPIC ...
LDSHARED共有ライブラリのリンクコマンドgcc -shared
DLDFLAGSリンク時フラグ-Wl,--no-undefined
LIBRUBYARGRuby ランタイムのリンク引数-lruby
DLEXT共有ライブラリ拡張子so/bundle/dll
archプラットフォーム識別子x86_64-linux
ruby_versionABI バージョン3.4.0
target_os/target_cpuクロスコンパイル制御

6.4 生成される Makefile の典型形

SHELL = /bin/sh
RUBYARCHDIR = $(sitearchdir)$(target_prefix)/nokogiri
RUBYHDRDIR  = /usr/include/ruby-3.4.0
arch_hdrdir = /usr/include/ruby-3.4.0/x86_64-linux

CC      = gcc
LDSHARED = gcc -shared
CFLAGS  = -fPIC -O3 -Wall ...
INCFLAGS = -I. -I$(arch_hdrdir) -I$(RUBYHDRDIR) -I$(srcdir)
DLDFLAGS = -Wl,--no-undefined
DLEXT    = so
TARGET   = nokogiri
DLLIB    = $(TARGET).$(DLEXT)

OBJS = nokogiri.o xml_node.o ...

$(DLLIB): $(OBJS)
	$(LDSHARED) -o $@ $(OBJS) $(LIBPATH) $(DLDFLAGS) $(LIBS)

install: $(DLLIB)
	$(INSTALL_PROG) $(DLLIB) $(RUBYARCHDIR)

ポイント:

  • -I$(RUBYHDRDIR)ruby.h を取りに行く(=monoruby ではここが我々のヘッダ群を指す必要あり)。
  • LDSHARED.so を作る。多くの環境では libruby への動的リンクはしない。Ruby シンボルは実行時にメインプロセスが提供する前提。
  • インストール先$(RUBYARCHDIR) = $(sitearchdir)/<gem-name> 配下。

6.5 ビルド成果物の配置

RubyGems は .so2 か所に配置する:

~/.gem/ruby/3.4.0/
  ├── gems/nokogiri-1.16.0/lib/nokogiri/nokogiri.so   # require先
  └── extensions/x86_64-linux/3.4.0/nokogiri-1.16.0/  # ビルド成果保管
       ├── nokogiri.so
       ├── gem_make.out                                # ビルドログ
       └── mkmf.log                                    # mkmfの探査ログ

extensions/<arch>/<abi>/<gem> の階層でプラットフォーム&Ruby ABI ごとに別管理される。

6.6 require できるようになる仕組み

  1. Gem::Specification が gem の lib/$LOAD_PATH に追加。
  2. require "nokogiri"nokogiri.rb をロード。
  3. nokogiri.rb 内部で require "nokogiri/nokogiri".so をロード。
  4. dlopen 後、Init_nokogiri() が呼ばれて rb_define_class 等で世界に登録。

6.7 失敗時の挙動とログ

  • mkmf.log試行コンパイルの全コマンドと出力 が残る。
  • gem_make.out に make の標準出力/エラー全文。
  • Gem::Ext::ExtConfBuilder は Cause exception を投げ、エラーメッセージにこれらのパスを含める。

6.8 主要ファイル(RubyGems 側)

パス役割
lib/rubygems/installer.rbGem::Installer — gem 展開、ext 呼び出し
lib/rubygems/ext/builder.rbGem::Ext::Builder — 種別判定とディスパッチ
lib/rubygems/ext/ext_conf_builder.rbextconf.rb 方式の実行
lib/rubygems/ext/rake_builder.rb別方式(Rake/configure/cmake)
lib/mkmf.rbRuby 本体に同梱、MakeMakefile モジュール

7. monoruby に持ち込むときの押さえどころ

「再コンパイル必須」を前提にすると、monoruby 側でやることは以下 5 点に絞れる:

7.1 RbConfig::CONFIG を提供

monoruby では RbConfig::CONFIG ハッシュを上書きまたは新規定義し、上記キーを monoruby 用の値 に設定:

RbConfig::CONFIG["rubyhdrdir"]    = "#{ENV['HOME']}/.monoruby/include"
RbConfig::CONFIG["sitearchdir"]   = "#{ENV['HOME']}/.monoruby/site/#{arch}"
RbConfig::CONFIG["LDSHARED"]      = "gcc -shared"
RbConfig::CONFIG["DLEXT"]         = "so"
RbConfig::CONFIG["arch"]          = "x86_64-linux-monoruby"   # ← 重要
RbConfig::CONFIG["ruby_version"]  = "monoruby-0.x"
RbConfig::CONFIG["LIBRUBYARG"]    = "-lmonoruby_ext"
RbConfig::CONFIG["CFLAGS"]        << " -DMONORUBY=1"

arch を独自値にする ことで extensions/<arch>/... が CRuby とぶつからない。

7.2 ~/.monoruby/include/ に互換 ruby.h を配置

マクロを関数化したヘッダ群を build.rs で配布。

7.3 mkmf.rb はそのまま使える可能性が高い

mkmf は Ruby で書かれており RbConfig::CONFIG を読むだけなので、 CRuby 由来の mkmf.rb を ~/.monoruby/lib/ にそのまま置く ことで動く可能性が高い。細部の try_link がリンカ呼び出しをするので、LDSHARED/LIBRUBYARG が正しく定義されていれば良い。

7.4 RubyGems も大部分流用可能

RubyGems も Ruby スクリプト群なので、そのまま monoruby で実行できれば良いGem.dir/Gem.path を monoruby 向けに上書きする小さなパッチで済む見込み。

7.5 シムライブラリ libmonoruby_ext.so を配布

rb_define_method 等のシンボルを export。-lmonoruby_ext.so がリンクし、dlopen 時に monoruby プロセス側でこれらの実体を提供する(あるいは --export-dynamic で monoruby 本体が提供)。


8. 実装最小ライン

gem install が monoruby でも動く」を最初のマイルストーンにするなら:

  1. RubyGems を monoruby 上で起動できるところまで(gem コマンドが立ち上がる)。
  2. RbConfig::CONFIG を monoruby 値で完備。
  3. 互換 ruby.h を配布。
  4. ダミーシム libmonoruby_ext.sorb_define_method を 1 個だけ実装)。
  5. hello-world の C 拡張 gem を作って gem install ./hello.gem が通ることを確認。

ここから順に実装する rb_* シンボル数を増やしていけば、徐々に実 gem が通るようになる。TruffleRuby も事実上この道を辿った。


9. 参考リンク

  • TruffleRuby cexts.md (contributor docs): https://github.com/oracle/truffleruby/blob/master/doc/contributor/cexts.md
  • TruffleRuby cext-values.md (handle/GC management): https://github.com/oracle/truffleruby/blob/master/doc/contributor/cext-values.md
  • Better support for C extensions in TruffleRuby (aardvark179): https://aardvark179.github.io/blog/capi.html/
  • Very High Performance C Extensions For JRuby+Truffle (Chris Seaton): https://chrisseaton.com/truffleruby/cext/
  • Ruby Objects as C Structs and Vice Versa (Chris Seaton): https://chrisseaton.com/truffleruby/structs/
  • Issue #1772: Cannot load more than one byte from RSTRING_PTR: https://github.com/oracle/truffleruby/issues/1772
  • Feature #21853: Make Embedded TypedData a public API: https://bugs.ruby-lang.org/issues/21853
  • A Rubyist’s Walk Along the C-side (Part 7): TypedData Objects: https://blog.peterzhu.ca/ruby-c-ext-part-7/
  • Gems with Extensions - RubyGems Guides: https://guides.rubygems.org/gems-with-extensions/
  • RubyGems Ext::ExtConfBuilder source: https://github.com/rubygems/rubygems/blob/master/lib/rubygems/ext/ext_conf_builder.rb
  • ruby/lib/mkmf.rb (Ruby 本体): https://github.com/ruby/ruby/blob/master/lib/mkmf.rb
  • MakeMakefile module documentation: https://www.rubydoc.info/stdlib/mkmf/MakeMakefile
  • Hacking extconf.rb (Yorick Peterse): https://yorickpeterse.com/articles/hacking-extconf-rb/
  • Don’t be terrified of building native extensions (Pat Shaughnessy): https://patshaughnessy.net/2011/10/31/dont-be-terrified-of-building-native-extensions
  • RbConfig module reference: https://docs.ruby-lang.org/en/3.0/RbConfig.html

ruby/spec ハング対策: skip.txttags/ 移行

monoruby が ruby/spec スイートのハングをどう回避しているか、そして粗い ファイル単位スキップリストを「本当に救えない5ファイル」まで絞り込んだ監査の 記録。#899 以降の単一スレッド化ランタイムを前提とする(最新反映時点)。

更新(2026-07-31): 陳腐化タグの剪定。 本ドキュメント以降、グリーン スレッド/プリエンプション(#962)と library ハング修正(#988)により、 かつてハングしていた example の多くが完走・pass するようになった。再検証の 結果、fails: タグはpass する example を集計から除外し pass 率を過小評価 するため、以下の陳腐化タグを削除した:

  • core/kernel/require_tags.txt(concurrently) 3件 → pass)
  • library/expect/expect_tags.txtIO#expect 4件 → pass、6例全完走)
  • library/socket/socket/{tcp,udp}_server_loop_tags.txt(各1件 → pass)

各ファイルを「タグ無し・mspec run --excl-tag fails」で4回反復+バッチ/ カテゴリ実行し、ハング/フレークが無いことを確認済み。残すのは core/process/kill_tags.txt のみ(プロセスグループ宛て負シグナルの3件は 現在もハングし、除外するとファイルが完走することを確認)。空で未参照だった spec/skip.txt も削除。なお自動タガー(下記モニタ)は追加専用なので、 万一 Linux CI で再ハングすれば次回モニタ実行が自動で再タグ付けする。

更新(重要・経緯): tags が fast-path で効かなかった原因はタグの 置き場所 のずれで、CI 側の1行修正で解消。tags を復活し skip.txt は5ファイルへ戻した。

一度は「rubyspec-stats の fast-path が tags を適用しない」と判断し、ハングする 6ファイルを skip.txt へ戻した(#903)。しかし真因は別だった:

  • fast-path は既に mspec ci を使っており、mspec cifails/critical/ unstable/incomplete/unsupported タグを既定で除外する(=タグ適用モード 自体は有効)。
  • mspec は各 spec のタグファイルパスを spec パスから tags_patterns で導出する。 config 無し(-B 無し)だと 組み込み既定 [[%r(spec/), 'spec/tags/'], [/_spec.rb$/, '_tags.txt']] が使われる。rubyspec-stats は ruby/spec を spec/ruby/ に clone するので、spec/ruby/core/io/select_spec.rb の導出先は spec/tags/ruby/core/io/select_tags.txtruby/ セグメント入り)になる。
  • ところが CI は monoruby の tags を spec/tags/(=spec/tags/core/...)へ コピーしていた。ruby/ 1セグメント分ずれてタグが見つからず、除外されず、 ハングしていた。

修正(rubyspec-stats/.github/workflows/ci.yml、コピー先を spec/tags/ruby/ に):

mkdir -p spec/tags/ruby
[ -d monoruby-repo/spec/tags ] && cp -R monoruby-repo/spec/tags/. spec/tags/ruby/ || true

これで mspec cispec/tags/ruby/<cat>/<name>_tags.txt を読み、ハングする example だけが除外され、fast-path が 90 秒予算内で完走する(fallback 不要)。 したがって tags を復活し(argf/read, argf/readlines, io/copy_stream, io/select, socket tcp_/udp_server_loop)、skip.txt からは対応6行を外して 5ファイル(救えない CRASH/プロセス死のみ)へ戻した。ファイル単位 skip では 落ちていた ハングしない example(io/copy_stream の63件など)が統計へ復帰する。 この CI 側修正と本コミットは同時にデプロイすること(CI 未修正のまま skip を 外すと fast-path が再びハングする)。

背景

外部の rubyspec-stats CI が ruby/spec を monoruby に対して定期実行し、 passing/total の推移を追跡している。ハングする(返ってこない)spec は カテゴリ実行全体を止めてしまうため除外が必要。除外手段は2つある。

  • spec/skip.txtファイル単位の粗い除外リスト (spec/ruby/core/thread/backtrace_spec.rb のようなパス)。ファイルごと 除外すると全 example が分子・分母の両方から落ち、真の合格率を過小評価する。
  • spec/tags/ — mspec ネイティブの example 単位タグ機構。spec ファイル <cat>/<name>_spec.rb に対し、mspec は tags/<cat>/<name>_tags.txt を 自動読み込みする。fails:<full description> の1行で、その example 1件だけを mspec ci が除外し、ファイル内の残りは実行・カウントされる。

本移行の目的: 少数の特定 example だけでハングするファイルについて、 ファイル単位スキップを example 単位の fails: タグへ置き換え、生き残る example を統計へ復帰させること。

タグファイル形式

1行1タグ: <class>:<full description>

  • <class>fails: を使う(mspec ci が自動除外)。

  • <full description> — ネストした describe/context 文字列と it 文字列を 半角スペースで連結したもの。例:

    # spec/tags/core/io/select_tags.txt
    fails:IO.select returns supplied objects when they are ready for I/O
    fails:IO.select returns the pipe read end in read set if the pipe write end is closed concurrently
    

mspec のタグパス解決

spec/default.mspectags_patterns により、spec リポジトリのルート基準で core/thread/backtrace_spec.rbtags/core/thread/backtrace_tags.txt と 変換される。rubyspec-stats CI は monoruby の spec/tags/ を spec チェックアウト 側へ配置して mspec に読ませる。ローカル検証では symlink で同じ配線を再現する。

ln -sfn /path/to/monoruby/spec/tags /path/to/spec/tags

bisect 手法(ローカル)

CI には “Bisect monoruby core hang” ワークフローがあるが、監査全体は 現 master バイナリ・sibling の spec/mspec/ チェックアウト・timeout が あればローカルで再現できる。

重要: bisect は必ず現 master バイナリで行うこと。インストール済みの リリースは関連修正より古い場合がある。例えば #899(「ruby/spec のハングを 止めるため単一スレッドの最小面へ削減」)は多くのハングを解消したため、古い バイナリはハングを見落とすと同時に、無いはずのハングを作り出す。

cargo install --path monoruby --force        # 現 master バイナリ

ファイルごとに specdoc フォーマッタ + timeout で実行する。specdoc は各 example の説明を実行前に出力するので、timeout 直前の最後の - … 行がハングしている example になる。

timeout 45 mspec run -t monoruby -fs <cat>/<name>_spec.rb

exit code による分類:

exit意味
0 / 1完走(1 = failures/errors あり。それでも実行は完了)
124ハングtimeout 発火。最後の - … 行が犯人
134 (SIGABRT)monoruby が abort(extern "C" 境界での Rust panic)
143 (SIGTERM)プロセスがシグナルで死亡(シグナル配送系 spec)

複数の example がハングするファイルは反復する: 見つけた example の fails: タグを追加し、タグを有効にして再実行 (mspec run -fs --excl-tag fails … または mspec ci …)して次を炙り出し、 ファイルが完走するまで繰り返す。

バッファリングの罠: mspec を timeout 下で grep/tail にパイプすると、 SIGTERM でバッファ済み stdout が失われることがある。ファイルへリダイレクト してから読み直すこと。

監査結果(元の skip.txt 40ファイル)

分類件数処置
HANG(少数 example)65件を fails: タグへ移行、1件は skip 維持
COMPLETED(もうハングしない)30skip から除去、タグ不要
CRASH — SIGABRT1skip 維持(monoruby バグ)
CRASH — SIGTERM3skip 維持(シグナル配送)

大きな COMPLETED バケットが最大の発見: skip.txt は陳腐化していた#899 以降、リストの4分の3はもうハングしない(代わりに即エラー/失敗で完走) ため、盲目的に skip 維持することが合格率を過小評価していた。

タグへ移行(6ファイル)

spec ファイルタグ付けした example
core/argf/read_spec.rbARGF.read reads the contents of a special device file/dev/zeroread(100) — 長さ制限を無視して無限読み込み)
core/argf/readlines_spec.rbARGF.readlines returns an empty Array when end of stream reached
core/io/copy_stream_spec.rbIO.copy_stream with a destination that does partial reads calls #write repeatedly on the destination Object
core/io/select_spec.rbIO.select returns supplied objects when they are ready for I/O IO.select returns the pipe read end in read set if the pipe write end is closed concurrently
library/socket/socket/tcp_server_loop_spec.rbSocket.tcp_server_loop when a connection is available yields a Socket and an Addrinfo
library/socket/socket/udp_server_loop_spec.rbSocket.udp_server_loop when a connection is available yields the message and a Socket::UDPSource

各ファイルは mspec cifails: タグを除外)で完走し、残りのハングが無いことを 検証済み。

skip.txt に維持(5ファイル)

タグでは救えないもの — プロセスごと死ぬか、全 example がハングする:

spec ファイル理由
core/enumerator/new_spec.rbbuiltins::array::eqArray#==)での非巻き戻し Rust panic → SIGABRT。隠すのではなく修正すべき monoruby バグ。
core/exception/signal_exception_spec.rb実際にシグナルを配送 → SIGTERM でプロセス死
core/exception/signm_spec.rb先頭 example で死亡(SIGTERM)
core/exception/signo_spec.rb先頭 example で死亡(SIGTERM)
library/expect/expect_spec.rb6 example 全てが IO#expect でブロック。全部タグ付けはファイルごと skip と等価

解除した30件の検証

mspec ci はカテゴリ内のファイルを1プロセスで順次実行するため、以前 スキップされていたファイルがグローバル状態(fd, at_exit, シグナル trap, Mutex/Queue/Thread の状態)を残し、後続ファイルをハングさせる恐れがある。 これが起きないことを2段階で確認した。

  1. 解除30ファイルを1つの mspec ci プロセスで実行 — 約35秒で完走、 815 examples、ハング0。
  2. 状態依存が強いカテゴリの全体実行(未スキップの隣接 spec も込み): core/thread(全53ファイル)・core/mutexcore/queuecore/sizedqueue — 全て完走、ハングなし。

単一スレッド化(#899)により残留バックグラウンドスレッド起因のハングは 起こりにくく、この結果と整合する。I/O 依存の非常に大きいカテゴリ (core/iocore/kernelcore/file)は全体実行していない — 無関係な ブロッキング spec が誤検知ハングを生むため。該当ファイル群は検証(1)で カバー済み。

最終状態

  • spec/skip.txt: (0 ファイル)。かつて「救えない」としていた5ファイルも、 crash/hang の原因は 特定の1〜数 example に限られることを突き止め、その example だけを fails: タグで除外すれば残りは完走することを確認したため、すべて tags へ 移行した。
  • spec/tags/: 5 ファイル。各ファイルで crash/hang する example のみ除外:
    • I/O 系ブロッキング(tag 適用で fast-path 完走): core/io/copy_streamcore/io/select(2件)、library/socket/socket/{tcp,udp}_server_looplibrary/expect/expectIO#expect がブロックする4件のみ。閉じた IO / EOF の 2件は即返るので除外しない)。
    • core/enumerator/new は issue #905 の as_array panic を根治したため tag を 撤去。#yield returns nil は現在 pass する。)
    • core/exception/{signal_exception,signm,signo} は signal handling 実装 (SIGTERM 等 → SignalException 変換、未捕捉時は SIG_DFL 再送でシグナル死)に より tag を撤去。signal_exception は16 example 全 pass、signm/signo も pass する。)
    • core/argf/{read,readlines} は ARGF の修正により tag を撤去。真因は2点: (1) ARGF#read が長さ引数を無視して EOF まで読むため /dev/zero で無限読み (メモリ無制限増加 → 環境により HANG または OOM/abort)。read(length, outbuf) を CRuby 準拠に実装(ファイル境界をまたぎ length で停止、EOF で nil、length 0 で “”)。(2) ARGV のファイルを消費し尽くした後に advance$stdin へフォールバックし、ARGF.read; ARGF.readlines が stdin 読みで 永久ブロック。ストリーム管理を __stream/__finish_stream に統一し、消費 後は exhausted(stdin フォールバック無し)とした。read_spec は 16 example 中 failure 1(エンコーディング別件)、readlines_spec は全 pass。)
    • CI 側で tags のコピー先を spec/tags/ruby/ に修正済み(上部「更新」参照)なので mspec ci がタグを読み、crash/hang する example だけ除外して fast-path が完走する。
    • library/cgi/unescapeURIComponent は報告にあったが実際はハングしない (約0.35秒)ので対象外。

フォローアップ

  • core/enumerator/newas_array panic(issue #905)修正済み。 真因は2点: (1) generator の駆動(generator_yield_values)が resume 時にも yielder を渡していたため Yielder#yield が nil ではなく Yielder 自身を返し、 ユーザ側(r << y.yield(1))へ Yielder が漏れていた。(2) YielderArray の サブクラスで、継承した Array#inspect 等が非配列の Yielder に Value::as_array を実行して非巻き戻し panic → abort。resume 値を nil にし、Yielder の親クラスを Object に変更(CRuby 準拠)。tag は撤去し #yield returns nil は pass する。
  • SIGTERM → SignalException 変換実装済み。CRuby と同一の デフォルト変換セット(HUP/INT/QUIT/ALRM/TERM/USR1/USR2)を async ハンドラで 受けて poll 点で SignalException(INT は Interrupt)として raise。未捕捉時は SIG_DFL を復元して自己再送し、プロセスはそのシグナルで死ぬ($?.signaled? / termsig が CRuby 準拠。Interrupt のみレポートを出力、素の SignalException は 無音 — CRuby と同じ)。自分宛て Process.kill は kill 内で同期的に raise。 Kernel#sleep は nanosleep + EINTR ポーリングで割り込み可能化。 pending_signals ビットマップはプロセスグローバルへ移動(シグナルはプロセス 資源のため。複数 Codegen 環境での取りこぼしを根治)。付随して IO.popen(文字列) のシェル経由をメタ文字必要時のみに限定(シグナル死の signaled?/termsig を保持。POSIX シェル組み込みは従来どおり sh 経由)、 バッククォートが $? を設定するように修正。3件の tag を撤去。 既知の限界: Rust が EINTR を内部再試行するブロッキング read/write 中は 1発の SIGTERM では死なない(pending bit は立つが poll に到達しない)。健全な 実行は即変換されるが、真にハングしたプロセスの kill には timeout -k (KILL フォールバック)を推奨。
  • core/io の fd 二重クローズ crash修正済み。原因は IO.new(other_io.fileno) / IO.open(fd) / File.open(fd) が、既に別の monoruby IO が所有する fd を 2つ目の 閉じる OwnedFd として包み、 Drop 時に二重 close(2) して Rust std の IO-safety abort を踏むこと。 スレッドローカルの所有 fd 集合(OWNED_FDS)を導入し、既に所有済みの fd を IO.new/open した場合は 借用autoclose = falseinto_raw_fd で閉じずに 解放)とすることで、閉じるのは元の所有者だけになるようにした。fileno の同一性は 保たれる。これにより core/io(103ファイル / 1483 example)・core/filecore/kernel がカテゴリ一括実行で crash せず完走するようになった。

グリーンスレッド導入後のタイムアウト tags(2026-07 追加 → 撤去済み)

2026-07 更新: 以下の 5 タグ(+ kernel/exit)は、タイムスライス・ プリエンプション(doc/threads.md §8)とカーネルブロッキング syscall のネイティブワーカーオフロード(同 §9)の実装により ハングしなくなったため撤去した。一部の example は依然セマンティクス 差で fail/error するが、それは統計に出すのが rubyspec-stats の方針 (tags はハング専用)。以下は当時の記録として残す。

限定的マルチスレッド(協調グリーンスレッド、M:1)の導入で spec-core モニタの 5ファイルが 60 秒のファイル予算を超えるようになり、rubyspec-stats CI が失敗した。 example 単位で切り分け、各ファイルとも犯人1例fails tag で除外 (除外後は全ファイルが数秒で完走)。いずれも既知の制限に帰着する:

  • core/thread/list_tags.txtThread.list returns instances of Thread and not null or nil values: begin … end while spawner.alive? という ブロッキング呼び出しゼロのループで main が回り続け、協調スケジューリング では spawner スレッドに永遠に CPU が渡らない。タイムスライス・ プリエンプション(SIGALRM watchdog → poll 点で強制 yield、doc/threads.md §8-1) の実装で解除できる。
  • core/thread/report_on_exception_tags.txt解除済み。真因は 2 つ: (1) ThreadInner::pending が 1 スロットで #kill が直前の #raise を 上書きしていた → CRuby の pending_interrupt_queue と同じ FIFO VecDeque に変更(raise が先に配達され、スレッドは例外で死ぬ)。 (2) 「ハング」の正体はクラスメソッド Thread.report_on_exception= の欠落 — upstream spec_helper.rb が「true で raise する shim」を注入しスレッド本体が 即死、Thread.pass until ready … が無限ループしていた → クラスレベル アクセサを追加。あわせてレポート出力を Ruby レベル $stderr 経由の CRuby 形式(#<Thread:0xADDR run> terminated with exception …)に変更 (mspec の output matcher は $stderr 差し替えで捕捉するため必須)。
  • core/mutex/lock_tags.txtMutex#lock does not raise deadlock if a fiber's attempt to lock was interrupted: fiber 内の Mutex#lock 待ちへの割り込み (Thread#raise)で待機が解けず永久ブロック。fiber が thread の代理で park する経路の interrupt 配送が未対応。
  • core/file/open_tags.txtFile.open on a FIFO opens it as a normal file: FIFO の open(2) は相手側が開くまでカーネル内でブロックする。M:1 では writer スレッドの open がプロセス全体を止め、reader スレッドが走れず デッドロック。O_NONBLOCK オープン + fd ポーラ待ちのエミュレーションが 必要(blocking_io_region は開いた後の read/write のみカバー)。
  • core/file/flock_tags.txtFile#flock blocks if trying to lock an exclusively locked file: サブプロセスが保持する排他ロックへの flock 待ちが 約 55 秒(内部期待のタイムアウトまで)ブロックし、単独でファイル予算を ほぼ使い切る。flock(2) の LOCK_NB + リトライによるスケジューラ統合で 解除できる。

タイムアウトの自動検知・自動タグ付け(spec-core モニタ)

上記のような「グリーンスレッド化で新たにハングする example」を手作業で 切り分ける運用を自動化した。仕組み(.github/workflows/spec-core.yml + .github/scripts/bisect-spec-timeouts.sh):

  1. モニタ自身が tags を適用: リポジトリの spec/tags/ を ruby/spec チェックアウトへコピーし、全ラン --excl-tag fails で実行する。 タグ済みのハング example が毎回 60 秒予算を食い潰すのを防ぐ (統計上は tagged として除外カウントされる)。
  2. 検知: 従来どおり、ファイル単位 timeout -k 5 60 で完走しなかった ファイルが timeouts.csv に記録される。
  3. 同定: bisect-spec-timeouts.sh が各タイムアウトファイルの example 一覧を mspec --dry-run -f s で取得し(describe の連結 = tag 名)、 1 example ずつ timeout -k 5 60 付きで個別実行。
    • exit 124/137 → hang(真のハング)
    • 単独で 30 秒以上 → slow(単独で予算を圧迫する近予算バーナー)
    • どちらも該当なし → cumulative-only(合算超過。タグ付けせず 予算見直し対象として報告のみ)
    • 病的ケース対策: dry-run にも 60 秒、1 ファイルの bisect に 900 秒の上限。
  4. タグ更新と PR: 見つかった culprit を spec/tags/<cat>/<file>_tags.txt へ重複排除で追記し、差分があれば固定ブランチ auto/spec-timeout-tags に commit して PR を自動作成する(既存 PR があれば force-push + body 更新で同じ PR が更新される=冪等)。マージは人間が判断する: tag が妥当(既知の制限)か、退行として修正すべきかのレビューを挟む。 結果テーブルはラン summary・PR body・artifact (timeout_culprits.{csv,md})に出力される。

monoruby Progress Summary (April 2025 – April 2026)

Over the past year monoruby advanced through roughly 500 commits, expanding language coverage, hardening the JIT, and dramatically improving CRuby compatibility.


Overview

PeriodKey themes
Apr–Jun 2025Standard library expansion, Onigmo regex engine integration
Jul–Sep 2025Full keyword-argument support, Hash/Enumerable improvements, JIT refactoring
Oct–Dec 2025JIT abstract-interpretation improvements, optimizations, ruby-bench support
Jan–Feb 2026FFI/Fiddle support, continuation frames, further optimizations
Mar–Apr 2026Large-scale ruby/spec compatibility push, crash/panic elimination

Language Features

Parser

  • Splat in multiple-assignment LHS: *a = 100
  • when *ary syntax
  • %W word-array literals
  • =begin/=end block comments
  • Brace-less hash literals (foo: 1, bar: 2)
  • Squiggly heredoc <<~
  • Reserved words (self/true/false) as keyword-argument labels
  • Anonymous block forwarding: def foo(&); bar(&) end (#128)
  • def ** pattern (used by BigDecimal)
  • Fixed rescue-modifier scoping (#181)
  • Fixed do...end block incorrectly attaching to Foo::BAR instead of the outer call (#144)

Runtime / Closures

  • defined? super
  • SIGINT handled as a Ruby-level exception
  • retry statement (#109)
  • return inside eval now propagates correctly (#232)
  • yield correctly traverses Fiber boundaries (#187, #188)
  • method_missing accepts splat / hash-splat / block arguments (#143)
  • Improved backtraces

Standard Library Expansion

Regexp / MatchData

  • Onigmo regex engine (#71, May 2025) — Rust bindings for Ruby’s standard regex engine
  • Regexp class implementation
  • MatchData class ([], back-references, special variables $1$9, …)
  • Interpolated symbols and back-references in alias (#147)

Rational / Complex

  • Rational reimplemented as a native Rust type with literal support (#266, Apr 2026)
  • Full arithmetic / comparison operators and to_f/to_i/to_r (#224)
  • Complex literals are now always frozen

Array

  • flatten!, reverse_each, shuffle!, to_h, union, intersect?, product
  • | (union), & (intersection)
  • eql?, slice, bsearch, replace, fill (including crash fixes)
  • values_at (#260)
  • Array#[] and #[]= defined as inlinable methods (#269)

String

  • rindex, delete, delete_prefix, upcase!, downcase!
  • String.try_convert, String.new
  • succ!/next!, insert, byteindex, to_c, to_r
  • encode/encode! (stubs), codepoints (#260)
  • concat, prepend, reverse, chop, squeeze, partition, upto
  • String#bytesize JIT-inlined (#139)

Hash

  • replace, clone, filter!
  • assoc, rassoc, shift, key, keep_if
  • delete_if, reject!, default_proc=
  • Comparison operators <, <=, >, >= (#196)
  • Hash.[] class method (#191)
  • Customizable hash / eql? methods (#76, Sep 2025)

Enumerable / Enumerator

  • find, filter, filter_map, one?, min_by, take_while
  • each_with_object, to_a, none?
  • step, lazy, permutation, curry (#227)

IO / File / Dir

  • IO#flush, IO#closed?, IO.popen (r+/w modes)
  • IO.select, IO#fileno, IO#write (variadic), IO#syswrite, IO.sysopen (#230)
  • IO.for_fd (#260)
  • File.size, File.size? (#247)
  • File.delete, File.chmod, File.symlink, File.readlines
  • File.stat / File::Stat, File.umask, File.fnmatch, File.absolute_path, File.split
  • File.zero? (#260)
  • Dir.glob / Dir[] (#106), Dir.mkdir / Dir.rmdir / Dir.entries
  • Dir.exist? (#208), Dir.open / Dir.new, Dir instance methods (#256)
  • Kernel#open (#256)

Numeric / Math

  • Dozens of missing methods added (#189, #193, #223)
  • All standard Math module functions implemented (#192)
  • Correct / and % semantics (sign, truncation direction) (#97)
  • Integer#div, Integer#ceildiv (#267)
  • Full overhaul of the coerce protocol (#225, #226, #249, #250)

Encoding

  • Encoding.find expanded to ~70 encoding names and aliases (#246)
  • ~60 Encoding constants added (US_ASCII, ISO-8859-, Shift_JIS, EUC_JP, IBM, …) (#242)
  • Encoding.default_external / internal stubs

Module / Class / Object

  • Module#ancestors, Module#private_instance_methods
  • Module#undef_method (#73, Jun 2025)
  • Module#method_added, Kernel#__method__
  • Module#define_method (fully correct)
  • Object#singleton_methods, Object#methods
  • Module.new with block (#174)
  • Class.new with block (#163)
  • Per-class Class#allocate (#164)
  • Object#clone, initialize_copy, initialize_clone, initialize_dup (#153, #154)
  • define_singleton_method (#155)
  • BasicObject spec compliance improvements (#166, #168)
  • singleton_class fixes (#167)

Other Classes

  • Marshal support (#125, Mar 2026)
  • Thread class — minimal single-threaded implementation (#222)
  • SizedQueue, ConditionVariable (#260)
  • Method#to_proc, Method#source_location
  • Method / UnboundMethod builtin methods (#216)
  • Exception#set_backtrace (#156)
  • Range#bsearch, Range#min/max/count/minmax (endless-range support)
  • Struct subclass support
  • Kernel#autoload, Kernel#load (#105)
  • Kernel#exec (#145), Kernel#format / Kernel#sprintf (#162)
  • $0, Dir.pwd, Dir.chdir
  • Process::Status, Signal.list
  • Frozen-object support (#240) — correct freeze / frozen? behavior throughout
  • Stack-overflow detection

JIT Compiler Improvements

Architecture

  • CacheMap introduction (#75, Sep 2025) — better inline-cache management
  • StackFrame introduction — abstraction for JIT stack frames
  • Constant functions (#77, Sep 2025) — pure constant computations optimized away
  • Effect tracking (#89, Dec 2025) — side-effect information per function
  • Immediate bytecode instruction (#95, Jan 2026)
  • Continuation frames (#101, Feb 2026)
  • Fixpoint iteration for abstract interpretation (#81, Nov 2025) — more precise type inference
  • LinkMode::None / LinkMode::MaybeNone (#79, Nov 2025)
  • InlineFuncInfo::CFunc_F_F (#80, Nov 2025)
  • Codegen detached from Globals
  • RecompileReason introduced

Optimizations

  • Binary-op JIT optimization (#87, Dec 2025) — faster Float/Integer arithmetic
  • f ** 2 special-cased (Apr 2025)
  • Rest-param and keyword-rest-param JIT optimization (#96, Feb 2026)
  • Range class optimization (#98, Feb 2026)
  • Eliminated unnecessary locals write-back
  • Frozen-literal allocation avoidance (Feb 2026)
  • String#bytesize inlined (#139, Mar 2026)
  • Constant folding across method/block boundaries (#90, Dec 2025)
  • Array#[] and #[]= inlined (#269, Apr 2026)
  • inlinegen can now use class information of the first argument (Apr 2026)

Bug Fixes

  • Wrong x86-64 condition codes for NaN float comparisons (#186)
  • GC crash on JIT constant slots (#185)
  • Panic after BOP (basic-op) redefinition (#220)
  • Stale type information in JIT locals_to_S (#112)
  • Temporary-receiver-slot bug in safe navigation operator (&.)
  • Bignum JIT dispatch falling through to wrong path (#271)

External Library / Ecosystem Support

FFI / Fiddle

  • fiddle support (Feb 2026) — Ruby bindings to C libraries
  • ffi support (Feb 2026) — compatibility with the ffi gem
  • SQLite3 FFI bridge (#160, Mar 2026) — support for the sqlite3 gem

Benchmarks

  • ruby-bench support (#88, Dec 2025)
  • optcarrot kept running continuously (#115, Mar 2026)
  • rubyboy benchmark support (#111)
  • lee benchmark support (#117, #120)

Infrastructure

  • ruruby-parse vendored into the workspace (Apr 2025)
  • CLAUDE.md AI-assistant guide added (#107)
  • bin/spec — script for batch-running all 58 ruby/spec core categories
  • bin/compare command added (Mar 2026)
  • --disable-gems CLI option (Mar 2026)

ruby/spec Compatibility

A large-scale compatibility drive (mainly Feb–Apr 2026) pushed ruby/spec core pass rates substantially:

MetricBefore (≈ Feb 2026)After (Apr 2026)
F+E (Fail + Error)~14,000~10,300
Pass rate~60 %~73.6 %
Crashesmany0
cargo test568 pass / 10 fail756 pass / 0 fail

Areas fixed

  • Implicit type conversions (to_int/to_str/to_f/to_ary/to_hash) across all builtins
  • Coerce protocol for numeric binary operators
  • TypeError / ArgumentError messages matching CRuby format
  • Errno exceptionsRuntimeError → proper Errno::ENOENT / EACCES / … everywhere
  • IOError for closed-stream operations
  • Float formatting (inf, -0.0, scientific-notation thresholds)
  • Frozen-object enforcement — freeze check added in JIT fast paths
  • Method arities — 30+ builtin methods corrected (#243, #245)
  • Integer spec — 10+ sub-categories fixed (#263, #265, #268, #270, #272)
  • Rational fully reimplemented (#266)

Code Quality / Infrastructure

  • SAFETY comments on all unsafe blocks (#94, Jan 2026)
  • RubyHash trait for Hash abstraction (#76)
  • RubyEql, RubyDiv, RubyMod, RubyDivMod traits introduced
  • Local-frame capture prevented during JIT execution (#93, Jan 2026)
  • Library files copied to ~/.monoruby/lib/ at build time (#119)
  • Codecov coverage tracked continuously

Summary

Over this one-year period monoruby achieved:

  1. Onigmo regex engine integration — full Ruby-compatible regular expressions
  2. FFI / Fiddle / SQLite3 support — interoperability with native C libraries and gems
  3. Hundreds of builtin methods added or corrected
  4. ruby/spec pass rate raised from ~60 % to 73.6 % (F+E reduced 14,000 → 10,300)
  5. Continuous JIT improvements — fixpoint type inference, constant functions, continuation frames, constant folding
  6. Zero crashes in cargo test — from 10 failures to a clean 756/0