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 is monoruby’s documentation: how to build and run it, how it performs, and how it works inside.
- The Getting Started section covers installing and building monoruby, and the build options and test workflow used when developing it.
- The Performance and Compatibility section is where the live dashboards are explained: Benchmark for speed against CRuby+YJIT, Compatibility for ruby/spec conformance. The Changelog sits alongside them.
- 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).
Quick start
git clone https://github.com/sisshiki1969/monoruby.git
cd monoruby
cargo install --path monoruby # nightly Rust is installed automatically by rust-toolchain.toml
monoruby -e 'puts "hello"'
See Installation and Build for prebuilt binaries, the GitHub Action, platform notes and the full command-line reference.
Related resources
- README — features and the monthly changelog
- Benchmark dashboard — continuously updated yjit-bench comparison, re-run on every push to
masterthat touches the interpreter - ruby/spec dashboard — spec compliance for this repository
- rubyspec-stats — daily ruby/spec pass rates across Ruby implementations
Installation and Build
monoruby is a standalone Ruby implementation: it does not need CRuby or any other Ruby runtime to build or to run. There are two ways to get it — a prebuilt binary, or a build from source.
Supported platforms
| Platform | Status |
|---|---|
| x86-64 Linux | Fully supported (VM + JIT). Primary CI target. |
| aarch64 macOS (Apple Silicon) | Fully supported (VM + JIT). CI runs natively on macos-latest. |
| aarch64 Linux | Supported (VM + JIT). Exercised by the manual gc-stress workflow on ubuntu-24.04-arm. |
Both architectures lower the complete instruction set; the JIT never declines a compile on either. See x86-64 / aarch64 JIT backend differences for what actually differs between the two backends.
Prebuilt binaries
Releases carry prebuilt tarballs, and a rolling nightly prerelease is
rebuilt on every push to master. The easiest way to consume them is the
setup-monoruby GitHub Action that this repository doubles as, modeled after
ruby/setup-ruby. The ref after @
selects the version:
steps:
- uses: sisshiki1969/monoruby@master # or a release tag
- run: monoruby my_script.rb
The action downloads a release asset when one exists for the ref and platform
(seconds), and otherwise builds from source once per monoruby revision ×
runner OS/arch and caches the result with actions/cache. Automatic builds
cover x86-64 Linux; Linux arm64 and macOS arm64 assets are published on demand
by dispatching the release binaries workflow. See the README’s action
section
for the full input/output table.
Each asset is a tarball containing bin/monoruby, bin/irm, and the
monoruby-home/v<version>/ runtime tree described under What the build
installs below. Because the binary bakes in the
build machine’s install-root path, point MONORUBY_INSTALL_ROOT at wherever
you extracted that tree.
Building from source
1. Install Rust
Only nightly Rust works. monoruby uses several nightly-only language
features (box_patterns, iter_next_chunk, step_trait,
coverage_attribute), so a stable toolchain fails to build.
You do not have to select the channel by hand: rust-toolchain.toml pins the
exact nightly the project is developed and tested against, and rustup
installs and uses it automatically the first time you run cargo in the
checkout.
# rust-toolchain.toml
[toolchain]
channel = "nightly-2026-08-18"
If you have no Rust at all yet, install rustup first.
2. Clone the repository
git clone https://github.com/sisshiki1969/monoruby.git
cd monoruby
3. Platform-specific dependencies
On aarch64 macOS only, monoruby links the system libffi instead of the
bundled one (the bundled libffi-sys fails to link _ffi_prep_cif_machdep on
arm64):
brew install libffi pkg-config
export PKG_CONFIG_PATH="$(brew --prefix libffi)/lib/pkgconfig"
Linux and x86-64 macOS need nothing extra.
4. Build and run
cargo build --release
cargo run --release -- test.rb
The debug profile is built at opt-level = 1 (set in the workspace
Cargo.toml), so a debug build is usable for day-to-day work — but use
--release for anything you intend to measure.
A one-liner:
cargo run --release -- -e "puts 100"
5. Install
cargo install --path monoruby
This puts two binaries on your PATH:
monoruby test.rb # the interpreter
irm # the REPL
From a checkout you can launch the REPL without installing:
cargo run --bin irm
# or
bin/irm
What the build installs
monoruby/build.rs runs on every cargo build and is host-Ruby
independent — it does not need a ruby on PATH to produce a correct,
reproducible build. It does two things:
-
Bakes the reported Ruby version.
MONORUBY_RUBY_VERSIONis read frommonoruby/vendor/ruby-stdlib/.ruby-version(currently 4.0.2) and reported at run time asRUBY_VERSION. Taking it from the vendored snapshot rather than a hostrubykeeps the version monoruby reports in step with the stdlib it actually ships. -
Installs the runtime tree into a per-version root,
~/.monoruby/v<version>/(e.g.~/.monoruby/v0.3.0/), whose absolute path is baked into the binary asMONORUBY_INSTALL_ROOT:Source Installed as Contents monoruby/vendor/ruby-stdlib/<root>/lib/Checked-in CRuby stdlib + default-gem snapshot monoruby/builtins/<root>/builtins/Ruby files loaded at interpreter start ( startup.rb,enumerable.rb, …)monoruby/stdlib/,monoruby/gem/<root>/lib/and<root>/stub/monoruby’s own host-independent replacements for C-extension-backed libraries The install is staged in a private directory and swapped in with an atomic rename, so a running monoruby never sees a half-populated tree, and the per-version namespacing keeps concurrent builds and multiple checkouts from clobbering each other.
build.rs declares cargo:rerun-if-changed for each of those trees, so
editing builtins/, stdlib/ or gem/ reinstalls on the next build, and
deleting ~/.monoruby re-triggers the whole install.
Setting MONORUBY_INSTALL_ROOT in the runtime environment overrides the
baked path, which is what makes a distributed binary relocatable.
Do I need a host Ruby?
To build and run monoruby: no. The stdlib is vendored and the version is read from the snapshot.
A host ruby is used for exactly two optional things:
-
Host-installed (non-default) gems. At startup
src/ruby_probe.rsinvokes a hostrubyonce — if one is present and is 4.0 or later — to discover$LOAD_PATHandGem.paths.path, and caches the answer in~/.monoruby/{library_path,gem_path}so the ~50 ms spawn is paid once per machine. Precedence isMONORUBY_GEM_PATH/MONORUBY_LOAD_PATH, thenGEM_PATH, then the cache files, then the probe.MONORUBY_REPROBE=1forces a fresh probe. With no host Ruby, those caches stay empty and the vendored stdlib still loads normally — you will just see this on startup:Warning: failed to read library path file: "~/.monoruby/library_path". Ruby may not be installed.It is a warning, not an error: only host-installed gems are unavailable.
-
Running the test suite, which compares monoruby’s output against CRuby. See Development and Build Options.
Command-line options
monoruby accepts CRuby’s command-line switches:
Usage: monoruby [switches] [--] [programfile] [arguments]
-0[octal] specify record separator (\0, if no argument)
-a autosplit mode with -n or -p (splits $_ into $F)
-i[extension] edit ARGV files in place (make backup if extension supplied)
-c check syntax only
-Cdirectory cd to directory before executing your script
-d, --debug set debugging flags (set $DEBUG to true)
-e 'command' one line of script. Several -e's allowed. Omit [programfile]
-Eex[:in], --encoding=ex[:in]
specify the default external and internal character encodings
-Fpattern split() pattern for autosplit (-a)
-Idirectory specify $LOAD_PATH directory (may be used more than once)
-l enable line ending processing
-n assume 'while gets(); ... end' loop around your script
-p assume loop like -n but print line also like sed
-rlibrary require the library before executing your script
-s enable some switch parsing for switches after script name
-S look for the script using PATH environment variable
-U set the internal encoding to UTF-8
-v print the version number, then turn on verbose mode
-w turn warnings on for your script
-W[level=2|:category]
set warning level; 0=silence, 1=medium, 2=verbose
-x[directory] strip off text before #!ruby line and perhaps cd to directory
-h show this message, --help for more options
--ast dump the parsed ruby-prism AST and exit
--no-jit disable just-in-time compilation
--no-gc disable garbage collection
--enable=feature[,...], --disable=feature[,...]
enable or disable features (gems, did_you_mean, rubyopt,
frozen-string-literal, all)
RUBYOPT is honoured for the subset of switches CRuby permits there.
The three monoruby-specific switches are worth knowing:
--no-jit— run everything in the bytecode VM. The JIT is always compiled into the binary; this disables it at run time. Useful for isolating a JIT bug from a VM bug, and as the baseline half of a JIT A/B.--no-gc— disable garbage collection entirely.--ast— dump the parsed prism AST and exit.
Development and Build Options
This page collects the build features, environment variables and helper scripts used when working on monoruby, rather than with it. For getting a working binary in the first place see Installation and Build.
Cargo features
Every feature is off by default. They are diagnostic or stress switches: none of them changes what a correct program computes, and none is needed for a normal build.
The JIT is always compiled in regardless of features — it is disabled at
run time with --no-jit. (The old jit / jit_x86 build cfgs and the
no-jit feature are gone; backend selection is purely by target_arch.)
Dumping the compilation pipeline
| Feature | Effect |
|---|---|
emit-bc | Dump bytecode to stderr (implies dump-bc, dump-traceir) |
emit-asm | Dump generated machine code to stderr (implies dump-bc, dump-traceir, jit-log) |
emit-cfg | Write each JIT-compiled function’s control-flow graph to .cfg/fid-<id>.dot (implies dump-bc, dump-traceir) |
dump-bc | Enable the bytecode dumper |
dump-traceir | Enable the TraceIR dumper |
dump-require | Log require / load file resolution |
JIT diagnostics
| Feature | Effect |
|---|---|
jit-log | Log JIT compilation events |
jit-debug | Detailed JIT debug output (implies dump-traceir) |
deopt | Log deoptimizations (implies jit-log, dump-bc, dump-traceir) |
chain-deopt-log | Trace every chain-deopt escalation to stderr. Deliberately not implied by deopt / profile: it fires hundreds of thousands of times per activerecord iteration and once wrote 160 MB of stderr in five iterations |
profile | Collect deopt / recompile statistics (implies dump-traceir for the deopt-site table, but not the bytecode dumps) |
perf | Emit perf-compatible symbol maps so JIT frames get names |
GC
| Feature | Effect |
|---|---|
gc-log | Log GC statistics at exit |
gc-debug | GC debug assertions |
gc-stress | Start in GC.stress and collect at every safepoint |
gc-verify | After every minor GC, independently re-mark the whole live graph from the roots, so a missed write barrier trips an assertion. Debug-only and very slow |
Register allocation and allocator experiments
| Feature | Effect |
|---|---|
stress-spill-pool | Shrink PHYS_FPR_POOL to 2 so almost every float-resident slot becomes a spilled virtual FP register, stressing the spill paths |
shadow-placement | Record every physical FP placement in emission order, producing a per-compile fingerprint of the lowering. The gate for the abstract-interpreter / register-allocation separation |
phys-table | Move the physical FP placement policy out of the resolver into an explicit table-backed function. Byte-identical to the formula it replaces |
mimalloc | Route the global allocator’s delegation to mimalloc instead of glibc, changing exactly one variable for an A/B |
The last three are gated on measurement and are off until their A/B clears; see Separating the abstract interpreter from register allocation.
Worked example: reading the pipeline
Everyone loves Fibonacci, so all three dumps below are of the same
benchmark/app_fib.rb:
def fib n
if n < 3
1
else
fib(n-1) + fib(n-2)
end
end
puts fib(34)
Bytecode (--features emit-bc)
cargo build --release --features emit-bc
target/release/monoruby benchmark/app_fib.rb 2> fib.bytecode > /dev/null
Each method and block is dumped as a register-based instruction listing,
grouped into basic blocks (BBx). %n is a bytecode register (SlotId);
_%n marks a result the next instruction consumes directly without the value
ever being stored. The bracketed columns on the right are inline-cache slots,
shown as <INVALID> here because the dump happens at compile time, before any
cache has been filled.
<fib> benchmark/app_fib.rb:1
FuncId(3835) SIMPLE stack reg_num:5 owner:[] local_vars:1 temp:3
ParamsInfo { required_num: 1, optional_num: 0, rest: None, rest_is_implicit: false, post_num: 0, args_names: [Some(n)], kw_names: [], kw_required: [], kw_rest: None, block_param: None, forwarding: false, it_param: false, forbid_keyword: false }
[]
BB0
:00000 [02] init_method reg:4 arg:1 stack_offset:8
:00001 [03] %2 = 3
:00002 [03] _%2 = %1 < %2 [<INVALID>][<INVALID>]
:00003 [02] condnotbr _%2 => BB2
BB1
:00004 [03] %2 = 1
:00005 [02] ret %2
BB2
:00006 [03] %2 = 1
:00007 [03] %2 = %1 - %2 [<INVALID>][<INVALID>]
:00008 [03] %2 = %0.fib(%2) [<INVALID>] -
:00010 [04] %3 = 2
:00011 [04] %3 = %1 - %3 [<INVALID>][<INVALID>]
:00012 [04] %3 = %0.fib(%3) [<INVALID>] -
:00014 [03] %2 = %2 + %3 [<INVALID>][<INVALID>]
:00015 [02] ret %2
%0 is self, %1 is the parameter n, and [nn] is the source line.
See Method argument processing for what the
ParamsInfo counters mean.
JIT-compiled machine code (--features emit-asm)
cargo build --release --features emit-asm
target/release/monoruby benchmark/app_fib.rb 2> fib.disas > /dev/null
Each bytecode instruction is printed with the machine code it lowered to, so
the dump reads as an annotated disassembly. The bracketed columns are now the
resolved inline caches — [Integer][Integer] for the arithmetic, and
[#<Class:main>] FuncId(3835) for the recursive call — because by the time
the JIT runs, the VM has executed the method often enough to fill them. That
is exactly the type information the compiler speculates on; see Invariants
compiled code speculates on.
==> start whole compile: FuncId(3835) <Object#fib> self_class: #<Class:main> benchmark/app_fib.rb:1
>>> [0] ISeqId(2164) <Object#fib> self_class:#<Class:main>
offset:Pos(251180) code: 517 bytes data: 0 bytes
BB0
:00000 init_method reg:4 arg:1 stack_offset:8
000000: push rbp
000001: mov rbp,rsp
000004: sub rsp,0x80
00000b: movabs rax,0x4
000015: mov QWORD PTR [rbp-0x48],rax
000019: mov QWORD PTR [rbp-0x50],rax
00001d: mov QWORD PTR [rbp-0x58],rax
000021: cmp DWORD PTR [rip+0x7ffc2ad4],0x0 # 0x7ffc2afc
000028: jne 0x3ffc60be
:00001 %2 = 3
:00002 _%2 = %1 < %2 [Integer][Integer]
00002e: mov r8,QWORD PTR [rbp-0x40]
000032: test r8,0x1
000039: je 0x3ffc60e9
00003f: cmp r8,0x7
000043: jge 0x55
:00003 condnotbr _%2 => BB2
BB1
:00004 %2 = 1
:00005 ret %2
000049: movabs rax,0x3
000053: leave
000054: ret
BB2
:00006 %2 = 1
:00007 %2 = %1 - %2 [Integer][Integer]
000055: mov r8,QWORD PTR [rbp-0x40]
000059: sub r8,0x2
00005d: jo 0x3ffc61ce
:00008 %2 = %0.fib(%2) [#<Class:main>] FuncId(3835)
000063: mov eax,DWORD PTR [rip+0x7ffc2a6b] # 0x7ffc2ad4
000069: cmp eax,DWORD PTR [rip+0xffffffffffffff8d] # 0xfffffffc
00006f: jne 0x3ffc61dd
000075: cmp rsp,QWORD PTR [rbx+0x18]
000079: jle 0x3ffc6344
… call sequence and the second recursive call elided …
:00014 %2 = %2 + %3 [Integer][Integer]
0001d5: mov r9,QWORD PTR [rbp-0x48]
0001d9: test r9,0x1
0001e0: je 0x3ffc6540
0001e6: test r8,0x1
0001ed: je 0x3ffc6548
0001f3: sub r9,0x1
0001f7: add r9,r8
0001fa: jo 0x3ffc6550
:00015 ret %2
000200: mov rax,r9
000203: leave
000204: ret
<<<
Object#fib #<Class:main> None (522 bytes, 1223 bytes) [wm0:CodePtr(139715286390055)-CodePtr(139715286390577) wm1:CodePtr(139716359894468)-CodePtr(139716359895691)] 5.462877ms
- [ISeqId(2164)] <Object#fib> self_class:#<Class:main>
Things worth reading out of that listing:
- The guards are the speculation.
test r8,0x1/jeis the Fixnum tag check onn; thejoafter eachsub/addcatches overflow out of the 63-bit Fixnum range; andmovl rax,[rip+global_version]/cmpl rax,[rip+cached_version]/jnebefore each call is the class-version guard. Every one of those branch targets is a side exit. - Fixnums are tagged, and the constants are pre-encoded.
n < 3compiles tocmp r8,0x7because3as a Fixnum is3 << 1 | 1;n - 1issub r8,0x2; and the base case returnsmovabs rax,0x3, which is1. The finalsub r9,0x1; add r9,r8strips one tag bit before adding. See Value Representation. - No accumulator register. Values live in whatever GP register the
per-basic-block allocator picked (
r8andr9here), with the frame at[rbp-…]. The fixedr15accumulator was retired in June 2026. cmp rsp,[rbx+0x18]before each call is the stack-limit check;rbxholds&mut Executor.
The summary line reports the code size and the two code regions the compiler
emitted into: wm0 is the fast path, wm1 the out-of-line page holding the
recompile and deopt stubs each failing guard jumps to.
CFG (--features emit-cfg)
emit-cfg writes one DOT file per JIT-compiled function into a .cfg/
directory under the current working directory, named by FuncId:
cargo build --release --features emit-cfg
target/release/monoruby benchmark/app_fib.rb > /dev/null
dot -Tsvg .cfg/fid-3835.dot -o fib-cfg.svg
Only functions that actually reach the JIT are dumped, so the set of files also tells you what got compiled.
Testing
cargo test # unit + integration tests
bin/test # the full CI scope: tests + coverage + benchmarks + optcarrot + spec
bin/test is what CI runs. In order it:
- Runs
cargo llvm-cov nextestwithstress-spill-pool(plusgc-stressonly whenGC_STRESS=1is exported). - Builds a debug benchmark binary with the same feature list, so a
GC_STRESS=1run stresses the benchmark / optcarrot / spec phases too. - Runs the benchmark scripts and
diffs their output against CRuby (app_fib,tarai,so_nbody, plb2nqueen/sudoku, andso_mandelbrotboth with and without the JIT). - Runs optcarrot plain and with
--opt, comparing output against CRuby. - Runs a ruby/spec subset if
../specand../mspecexist. - Writes an
lcov.infocoverage report.
SKIP_COV=1 runs the whole script without llvm-cov instrumentation.
The snapshot oracle
Tests compare monoruby’s output against CRuby, but they do not spawn CRuby
per test. The single-code helpers — run_test, run_test_once, run_test2,
run_test_with_prelude — memoize expected output in the checked-in file
monoruby/tests/ruby_oracle.tsv (code-hash → output, key-sorted and
flock-serialized so concurrent nextest processes cooperate). A live ruby is
invoked only on a cache miss, and the fresh entry is written back — commit
it. The batched helpers (run_tests, run_tests2, and the binop/unop
generators built on them) always spawn a live CRuby, because their generated
code strings churn too much to be worth snapshotting.
MONORUBY_TEST_ORACLE selects the mode:
| Value | Behavior |
|---|---|
unset / snapshot | Replay stored entries; spawn and record on a miss (the default) |
ruby | Always spawn CRuby and refresh stale entries in place — use this after bumping the reference CRuby version |
rm monoruby/tests/ruby_oracle.tsv && cargo test # regenerate from scratch
MONORUBY_TEST_ORACLE=ruby cargo test # re-verify against a new CRuby
Tests whose expected value varies per host or OS must use run_test_live /
run_test_once_live, which always compare against a live CRuby: anything
touching Dir.home / Dir.pwd, absolute checkout paths, ~user expansion
(/root vs macOS /var/root), or realpath under /tmp (a symlink to
/private/tmp on macOS). As a safety net a cached entry that disagrees with
monoruby is re-verified against a live CRuby before failing — grep test output
for re-verifying against a live ruby to find tests that should move to the
_live helpers.
Running the suite needs a host ruby matching the vendored pin (4.0.2,
from monoruby/vendor/ruby-stdlib/.ruby-version). Since Ruby 3.4 bigdecimal
is no longer a default gem, so install it explicitly or every
tests/bigdecimal.rs test fails with LoadError:
gem install bigdecimal
GC stress
gc-stress collects at every safepoint. It is what finds unrooted-Value
bugs — a builtin that creates a Value and then re-enters Ruby — and it is
opt-in precisely because it is expensive: stacked on llvm-cov it took the
x86-64 nextest phase from ~10 minutes to ~100, and a full bin/test scope
under stress is an hours-scale job.
GC_STRESS=1 bin/test # applies to every phase, not just nextest
Nothing enables it implicitly, so the automatic CI never pays for it. Instead
dispatch the manual gc-stress workflow from the Actions tab when touching
the GC, frame layout, argument binding, or any builtin that creates a Value
and re-enters Ruby. Its inputs are arch (both / x86_64 / aarch64),
scope (nextest = the unit + integration suite, minutes; full = the whole
bin/test scope, hours), test_filter (a nextest -E expression), and
no_fail_fast. It runs on ubuntu-latest and native arm64
(ubuntu-24.04-arm), not qemu, and collects no coverage.
Tests whose loop counts exist only to reach the JIT thresholds should shrink
them under cfg!(feature = "gc-stress") (see tests/method_call.rs), or they
blow past nextest’s per-test cap.
ruby/spec
ruby/spec is cloned outside the monoruby repository, alongside it:
parent/
├── monoruby/ # this repository
├── spec/ # ruby/spec
└── mspec/ # the mspec runner
cd /path/to/parent-of-monoruby
git clone --depth 1 https://github.com/ruby/spec.git spec
git clone --depth 1 https://github.com/ruby/mspec.git mspec
cd monoruby && cargo install --path monoruby
cd ../spec
../mspec/bin/mspec run core/array -t monoruby # one category
../mspec/bin/mspec run core/array/flatten_spec.rb -t monoruby # one file
../mspec/bin/mspec run core/array -t monoruby --format dotted
bin/spec from the monoruby checkout runs a standard set of categories in one
go. Pass rates are published continuously on the spec
dashboard; see ruby/spec hang
countermeasures for how the suite avoids
hangs.
aarch64
On an x86-64 host the aarch64 backend is cross-compiled and run under qemu-user:
bin/setup-aarch64-cross # qemu-aarch64, aarch64-linux-gnu-gcc, rust std
bin/test-aarch64 # same scope as bin/test, for aarch64
bin/test-aarch64 takes SKIP_HEAVY=1 (skip the benchmarks and specs that
take over ten minutes under emulation), SKIP_COV=1, and STRESS=1. The
.cargo/config.toml wiring points aarch64-unknown-linux-gnu at that cross
toolchain and runner, which is why the CI job on a native arm64 runner
overrides CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_{LINKER,RUNNER} — otherwise
it would emulate everything.
Measurement and profiling
--features profile collects deopt and recompile statistics: which sites
deoptimized, how often, and why. The reasons are the RecompileReason
variants — NotCached, MethodNotFound, IvarIdNotFound,
ClassVersionGuardFailed, BecamePolymorphic, ConstVersionGuardFailed.
--features deopt logs individual deoptimizations;
Reading the deopt log explains the format, including
how the log names the guard that actually branched when exits have been
deduplicated.
For time rather than counts, see Benchmarks.
JIT thresholds
A function is compiled after 20 calls (COUNT_START_COMPILE) and a loop
after 100 iterations (COUNT_LOOP_START_COMPILE). Both drop to 5 and 15
in test builds, so tests reach compiled code without long warm-up loops.
Vendored and pinned dependencies
hashbrown/ is a workspace member (a local fork). smallvec is also a fork
but is consumed as a git dependency rather than in-tree.
The ruby-prism wrapper is pinned to the monoruby-vendored branch of
sisshiki1969/prism. That fork has
two branches:
monoruby— the minimal upstream-bound diff (the Rustparse_with_optionsAPI plusruby-prism-sysbindgen allowlist additions). The base for any upstream PR toruby/prism.monoruby-vendored—monorubyplus one commit checking in the C sources the upstreamvendored.rsbuild script needs, so consumers need neither bundler norrake cargo:build.
To bump the prism revision: push to the fork’s monoruby branch, run
bin/refresh-prism-vendored (which rebuilds and force-pushes
monoruby-vendored), then cargo update -p ruby-prism here.
bin/vendor-ruby-stdlib re-snapshots CRuby’s pure-Ruby stdlib and default
gems into monoruby/vendor/ruby-stdlib/. It is a maintenance step, never run
by cargo build.
Continuous integration
| Workflow | Trigger | What it does |
|---|---|---|
rust.yml | push / PR to master | bin/test on x86-64 Linux (with coverage to Codecov) and on native Apple Silicon |
bench.yml | push to master touching the interpreter | yjit-bench vs YJIT on x86-64 and aarch64, published to the portal |
spec-core.yml, spec-library.yml | push to master touching the interpreter, plus a weekly cron | ruby/spec pass rates, published to the portal |
docs.yml | push to master touching doc/** or docs/** | Builds this book with mdBook and publishes it to gh-pages under /docs/ |
release-binaries.yml | push to master, release published, dispatch | Prebuilt binaries for the setup-monoruby action |
gc-stress.yml | dispatch only | The stress run described above |
Neither rust.yml job sets GC_STRESS, so neither pays the per-safepoint
cost.
Documentation
This book lives in docs/. docs/build.sh copies doc/*.md and the diagrams
into docs/src/design/ (gitignored) and runs mdbook build; docs.yml
publishes the result. If you add a design document to doc/, give it a
chapter in docs/src/SUMMARY.md — mdBook renders only what SUMMARY.md
lists, so an unlisted document is copied and then silently dropped.
bash docs/build.sh # output in docs/book/
mdbook serve docs # live preview
The changelog and the README
docs/src/changelog.md is the single source of truth for the
monthly changelog. The README shows only the two newest month sections,
copied verbatim between its <!-- BEGIN LATEST-MONTHS --> /
<!-- END LATEST-MONTHS --> markers — that block is generated, so edit the
changelog and regenerate rather than editing the README:
bin/sync-changelog-readme # rewrite the README block
bin/sync-changelog-readme --check # exit 1 if the two have drifted
A scheduled Routine runs on the 1st of each month: it summarizes the previous
month’s merged PRs into a new ### <Month> <Year> section at the top of the
changelog’s current ## <year> group, runs the sync script, and pushes both
files. Pushing docs/src/changelog.md also re-triggers docs.yml, so the
published book picks the new month up in the same run.
Benchmark
monoruby is performance-focused, and performance is measured continuously rather than quoted from a README. This page covers the live dashboards, how to reproduce a measurement locally, and the historical one-off comparisons that used to live in the project wiki.
Live dashboards
Every push to master that touches the interpreter re-runs the benchmark
suite and republishes the project
portal:
| Dashboard | What it shows |
|---|---|
| Performance vs YJIT (x86-64) | Per-benchmark speed relative to CRuby + YJIT, plus a history chart per benchmark |
| Performance vs YJIT (aarch64) | The same suite on an Apple Silicon runner |
The portal also hosts the two ruby/spec dashboards; those are covered in Compatibility.
Methodology, from .github/workflows/bench.yml:
- The suite is yjit-bench, run with
--rss --harness=harness-warmup, monoruby againstruby --yjiton the same runner in the same job. - The reference CRuby is 4.0.2 (
ruby/setup-ruby), and monoruby is installed withcargo install --path monoruby --locked. - Each benchmark gets a 400-second timeout per interpreter; a benchmark that times out or exits non-zero is recorded as a failure rather than silently dropped, and shows on the dashboard as a gap.
- The published
ratiois × YJIT, higher = monoruby faster, 1× being parity.latest.jsonanddata/history.csvnext to each dashboard hold the raw numbers if you want to plot them yourself.
As a snapshot: on the x86-64 run of commit a13bfe0 (2026-08-31), 59 of the
76 benchmarks produced a ratio on both interpreters; over those the geometric
mean was 1.28× YJIT, with monoruby ahead on 30 of them. The spread is
wide in both directions — from ~16× on string_malloc_pressure down to ~0.3×
on send_bmethod — which is the point of reading the dashboard rather than a
single headline number.
Reproducing a measurement locally
The helper scripts in bin/ wrap the two harnesses the project uses. Most of
them assume benchmark-driver
and rbenv-managed reference Rubies; adjust the version strings inside to
match what you have installed.
| Script | What it runs |
|---|---|
bin/bench | The standard set (app_fib, so_nbody, so_mandelbrot, app_aobench, plb2) via benchmark-driver, against 4.0.5 --yjit and 4.0.5 --zjit |
bin/compare | Comparing two git refs of monoruby against each other — bin/compare HEAD~1 HEAD by default, over app_fib, so_nbody, so_mandelbrot, quick_sort, integer, vm_send, vm_block, vm_yield |
bin/ruby-bench | The full yjit-bench suite (expects a ../ruby-bench checkout), the same harness CI uses |
bin/optcarrot | optcarrot on ruby, ruby --yjit and monoruby in turn (expects ../optcarrot) |
bin/opt.rb | optcarrot fps history over 3000 frames, the data behind the fps-history charts |
bin/index | Array / Hash element access (benchmark/index.yaml) |
bin/inline | Integer and Math methods, i.e. the inline-asm builtins, also run with --no-jit for contrast |
bin/ivar | Instance-variable get/set, generic and attr_-generated |
bin/send | Method dispatch, Class.new, Array literals and constant lookup |
bin/times | Integer#times, JIT’ed Array / Hash work, block_given? |
bin/compare is the one to reach for when you want to know whether a change
you just made helped:
bin/compare # HEAD~1 vs HEAD, standard benchmarks
bin/compare abc1234 def5678 # two specific commits
bin/compare HEAD~5 HEAD app_fib.yml # one benchmark
For a single script, a release build is enough:
cargo build --release
target/release/monoruby benchmark/app_fib.rb
Benchmark scripts and their benchmark-driver YAML configs live in
benchmark/. Passing --no-jit gives you the VM-only baseline for the same
script, which is often more informative than an absolute number.
Profiling
# Flame graph via Linux perf (needs ../FlameGraph)
bin/perf benchmark/app_fib.rb
# or by hand
cargo build --release --features perf
perf record target/release/monoruby benchmark/app_fib.rb
perf report
The perf feature makes monoruby emit perf-compatible symbol maps so
JIT-compiled frames get names instead of raw addresses.
.cargo/config.toml sets -Cforce-frame-pointers=yes globally, which is what
makes the stacks walkable.
--features profile collects deopt and recompile statistics instead of a time
profile — see Development and Build Options.
Where optcarrot –opt spends its time is a
worked example of both.
Historical measurements
The two comparisons below are one-off measurements previously published in the project wiki. They are kept for the record and are not re-measured; for current numbers use the live dashboards above.
optcarrot (April 2024)
Measured with optcarrot.
Rubies:
- ruby 3.4.0dev (2024-04-27T08:56:20Z master 9ea77cb351) [x86_64-linux]
- truffleruby 24.0.1, like ruby 3.2.2, Oracle GraalVM JVM [x86_64-linux]
- truffleruby 24.0.1, like ruby 3.2.2, Oracle GraalVM Native [x86_64-linux]
- monoruby 3e348afd4141c40978342e67ad26d42dc0b8d2a7
fps history, 0–3000 frames:
With --opt (optcarrot’s self-rewriting optimization mode):
yjit-bench (December 2024)
Speed ratio against truffleruby; higher is better. Measured with
yjit-bench using
--rss --harness=harness-warmup. Benchmark sources are from
ruby/ruby’s benchmark/
and plb2.
Rubies:
- monoruby 0.3.0
- ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [x86_64-linux]
- truffleruby 24.1.1, like ruby 3.2.4, Oracle GraalVM Native [x86_64-linux]
Raw data — execution time in milliseconds, resident set size (RSS) in MiB.
monoruby/yjit and monoruby/truffle are time ratios; above 1 means monoruby
is faster.
| bench | monoruby (ms) | RSS (MiB) | yjit (ms) | RSS (MiB) | truffle (ms) | RSS (MiB) | monoruby/yjit | monoruby/truffle |
|---|---|---|---|---|---|---|---|---|
| bedcov | 4412.0 | 234.3 | 4803.9 | 413.8 | 1881.8 | 1909.5 | 0.918 | 2.345 |
| binarytrees | 175.0 | 28.7 | 137.0 | 22.0 | 31.7 | 1126.4 | 1.278 | 5.525 |
| matmul | 39.7 | 35.0 | 121.8 | 22.8 | 1.4 | 803.2 | 0.326 | 29.059 |
| nbody | 8.5 | 27.7 | 21.9 | 14.0 | 1.1 | 690.2 | 0.389 | 7.473 |
| nqueens | 14.7 | 24.7 | 31.0 | 14.2 | 7.2 | 637.9 | 0.475 | 2.044 |
| optcarrot | 520.4 | 79.0 | 720.9 | 54.7 | 432.2 | 1506.3 | 0.722 | 1.204 |
| rubykon | 214.9 | 34.9 | 348.2 | 18.6 | 65.2 | 2279.9 | 0.617 | 3.298 |
| so_mandelbrot | 39.9 | 22.9 | 509.5 | 14.5 | 26.3 | 548.8 | 0.078 | 1.517 |
| sudoku | 41.6 | 23.9 | 88.8 | 14.9 | 17.5 | 1165.2 | 0.469 | 2.381 |
| fib | 16.7 | 23.5 | 17.4 | 15.0 | 8.8 | 483.4 | 0.960 | 1.895 |
Machine
Both historical runs used the same machine:
- Architecture: x86_64
- CPU(s): 32 — 13th Gen Intel(R) Core(TM) i9-13900HX, 16 cores / 2 threads per core
- Caches (sum of all): L1d 768 KiB (16), L1i 512 KiB (16), L2 32 MiB (16), L3 36 MiB (1)
Compatibility
monoruby aims at CRuby 4.0 compatibility, and measures it against
ruby/spec — the executable specification
suite CRuby itself is tested with. The suite is re-run on every push to
master that touches the interpreter, and the results are published as live
dashboards.
Dashboards
| Dashboard | Scope |
|---|---|
| ruby/spec core | The core/ group — the built-in classes and modules |
| ruby/spec library | The library/ group — the standard library |
| rubyspec-stats | Daily pass rates for monoruby, CRuby, TruffleRuby and JRuby side by side |
Each dashboard carries a per-category table, a history chart, and the raw
latest.json / data/history.csv behind it. The core dashboard also
publishes per-branch pages for branches under active spec work.
Where things stand
As of commit a13bfe0 (2026-08-31):
| Group | Examples | Passing | Pass rate | Categories at 100% |
|---|---|---|---|---|
core | 23029 | 22468 | 97.6% | 39 / 59 |
library | 5330 | 3148 | 59.1% | 23 / 59 |
Additionally, as of July 2026 monoruby passed 100% of the command-line specs and 99.6% of the language specs.
The core figure is the result of a sustained compliance push through 2026 — the first published measurement, on 2026-04-27, was 59.5%:
| Date | Commit | Examples | Pass rate |
|---|---|---|---|
| 2026-04-27 | 7d2dd48 | 22520 | 59.5% |
| 2026-08-31 | a13bfe0 | 23029 | 97.6% |
The largest core categories are in good shape — array 100% over 2898
examples, file 99.6%, kernel 99.5%, module 98.9%, io 96.2%, and
string 95.4% over 3905. What remains is concentrated rather than spread out:
tracepoint (TracePoint is deliberately not implemented — the JIT’s
speculation assumes its absence, see Invariants compiled code speculates
on), objectspace at 39.3%, then time,
marshal and encoding in the 89–95% band.
The library group is the frontier. matrix and net-http are essentially
complete (100% and 99.5%), while the biggest gaps are socket (40.2% over
1129 examples), stringio (67.5%), net-ftp (54.9%) and bigdecimal
(68.6%). Libraries needing facilities monoruby does not have — openssl,
coverage, mkmf, irb — sit at 0%.
How the numbers are produced
Both dashboards come from the same workflow shape
(.github/workflows/spec-core.yml and spec-library.yml):
- Every
*_spec.rbunder the group is run throughmspec, category by category, in batches of 10 files. - Each batch runs under a hard 60-second deadline with
timeout -k 5. The-kmatters: monoruby installs its ownSIGTERMhandler which is deferred to a VM poll point, so a process stuck in a blocking read never dies to a plainTERM. - A killed batch prints no summary line, which would lose the tally for all
ten files, so the batch is re-run one file at a time — completing files are
counted after all, and the hanging file is pinned down and recorded in
data/timeouts.csv. Both groups currently record zero timeouts. --excl-tag failsexcludes examples tagged in this repository’sspec/tags/. That list is deliberately tiny — 6 examples in 4 files at present, coveringRefinement#import_methodsfrom a C extension,$LOAD_PATH.resolve_feature_pathfor a.so, the/oRegexp modifier, and three refinement-driven pattern-matching cases. The published pass rate is therefore very close to an unfiltered one.
The audit that cut the skip list down from coarse file-level exclusions to
that handful is written up in ruby/spec hang
countermeasures. Green threads removed the
last structural hangs: blocking IO now parks the calling thread on the
scheduler’s fd poller instead of blocking the process, so specs like
core/io/copy_stream_spec.rb and core/io/select_spec.rb run to completion.
Running the specs yourself
ruby/spec and mspec are cloned alongside the monoruby checkout, not inside it. See Development and Build Options → ruby/spec for the layout and the commands.
Deliberate differences from CRuby
A few behaviours differ by design rather than by omission:
- TracePoint is not implemented. Compiled code speculates on its absence.
- Backtraces are formatted lazily. Raise, unwind and catch record what is needed; the backtrace string is not built until something asks for it. See Exception handling.
- The garbage collector is non-moving, single-threaded and stop-the-world (generational since June 2026). Compiled code relies on objects not moving.
- Threads are M:1 green threads with time-slice preemption, not OS threads. See Thread / Fiber / non-blocking IO / preemption.
- C extensions cannot be loaded. Libraries that CRuby backs with a C
extension —
json,date,digest,stringio,zlib,openssland others — are either reimplemented in Rust/Ruby and shipped in the vendored tree, or unavailable. C extension support is a design study, not a shipped feature.
Changelog
Monthly highlights of monoruby’s development, with representative PRs. The README carries the most recent two months; this page is the full record.
For a prose account of the same period rather than a list — what changed and why, over roughly 500 commits — see Progress summary (April 2025 – April 2026).
Maintenance. This page is the single source of truth. A scheduled Routine appends the previous month on the 1st of each month, then runs
bin/sync-changelog-readme, which copies the two newest month sections verbatim into the README between itsLATEST-MONTHSmarkers. The README’s month text is generated — edit this page instead, and re-run the script (bin/sync-changelog-readme --checkverifies the two are in sync).
2026
August 2026
- Broad early-month ruby/spec compliance drive across Kernel, Enumerator, IO and process handling:
Enumerator::Lazy/Product/Chain, fiber-free#each/#with_indexinternals, ARGF reimplemented in Rust, Refinements, and a full spawn-exec engine with realDatasubclasses (#1039–#1065, notably #1046, #1054, #1064). - Signal delivery moved onto gated safepoint polls, and the safepoint word was reworked into one process-global, four-byte-lane poll;
gc-stressthen went opt-in and ran at every safepoint on both arches, catching a string of unrooted-Valueand FP-pool bugs (#1070–#1075, #1123–#1125). - Hash subsystem overhaul: an AR-mode small-hash table embedded directly in the
RValuecell, JIT-inlined accessors ([]=,size,default, …), and correctness fixes for tombstone deletes and iteration order (#1060, #1086–#1108). - JIT dispatch modernization: polymorphic call and comparison sites now dispatch through the PIC instead of guarding and evicting, with BinOp/BinCmp lowering unified into one skeleton (#1110, #1127–#1133).
- Chain deopt became the sole recovery path off a stale JIT frame, followed by a multi-week abstract-state and frame-chain rework (unified joins, outer-frame float promotion across block calls) that got the ActiveRecord benchmark running both correctly and fast (#1134, #1136, #1149–#1168, #1171–#1198).
- aarch64 kept pace with the chain-deopt work: outlined side-exit islands, branch-range relaxation past the
Imm19reach, and ports of the class-version recovery jump-back (#1153, #1175, #1176, #1182, #1183, #1189). - Late-month perf sprint: cached
Symbol#to_procprocs, dispatch-free Array/Range scans and equality-by-identity fast paths, plus a JIT correctness fix for inlinedsendandmethod_missingdispatch (#1200–#1214). - Consolidated the README and wiki into the mdBook documentation site and added this generated Changelog chapter (#1215, #1216).
July 2026
- Big language-semantics compliance drive on the ruby/spec “language” group: destructuring, block-argument semantics,
defined?, flip-flops,BEGIN/END, and predefined globals (#804–#874, notably #861). - Implemented pattern matching (
case/in,=>) (#883) and MRI’s full eigenclass tower (#877). - Green threads (M:1): scheduler core, real Thread / Mutex / Queue, scheduler-integrated blocking IO, and preemptive timeslice multithreading (#941–#944, #962).
- Real TCP / UDP / UNIX-domain sockets (#964, #981);
IO::Bufferwith mmap file mapping (#927, #931);IO.copy_stream(#924). - Added the
setup-monorubyGitHub Action with prebuilt binaries (#884, #886) and CRuby-compatible command-line option processing (#891). - Completed the Exception API: raise-time backtraces,
full_message, NameError / NoMethodError metadata (#893–#896). - Brought the aarch64 JIT to optimization parity with x86-64 (#993–#1004) and shrank call frames for ~7% faster fib (#850).
- Completed
Fiber— transfer / raise / kill / storage and a real root fiber, making core/fiber fully green (#1036) — and refined Thread semantics: the asyncThread#raiseprotocol and structuredThread::Backtrace::Location(#1026, #1027). - JIT: object allocation emitted as inline machine code (free-list pop + bump) (#1011), guard-free slot dispatch for method calls on aarch64 (#1010), and trivial-method folding through
...forwards (#1012). - Encoding: streaming
Encoding::Converterstate andString#encodefallback / newline decorators (#1018, #1019); Regexp gained native-encoding byte matching for non-UTF-8 subjects plus upstream Onigmo engine fixes (#1037, #1038). - CRuby-compatible
require/load/autoloadresolution rules (#1033), a Rust fnmatch engine backingDir.glob(#1017), and an mdBook documentation site published to the project portal (#1008).
June 2026
- Completed the aarch64 JIT backend: full AsmInst coverage, inline methods, loop JIT, and recompilation on Apple Silicon (#645–#704).
- Introduced a generational GC (RGenGC-style) (#705); GC now also triggers on malloc growth (#732).
- Completed the Prism migration and removed the old hand-written parser (ruruby-parse) (#657).
- New JIT register allocation: LIR-based lowering with a per-basic-block GP register allocator; retired the R15 accumulator (#741, #756, #763).
- Cut allocation/dispatch overhead by 12–26% on addressable benchmarks (#708); zero-copy String operations (gsub, slice, lines, scan) (#722–#724).
- Made the build host-Ruby independent and reproducible (#769).
May 2026
- Switched the parser to Prism, the official Ruby parser (#412).
- Encoding subsystem overhaul: real
String#encode(#443),Encoding::Converter(#447, #451), ISO-2022-JP (#449), and EUC-JP / Shift_JIS-aware string operations (#536–#545). - Native String fast paths (
reverse,rindex, case mapping, …) that beat YJIT (#493–#497); dropped per-builtincatch_unwindfor +8–10% on optcarrot (#501). - JIT: virtual FP registers with spill-to-stack (#387), heap-constant folding and
Object#is_a?inlining (#504, #505), non-deopting polymorphic comparisons (#519). - Decoupled monoruby from any host Ruby installation (#579, #595); completed the Marshal format tags (#588–#603); real
File::Stat(#607); frame-local$~/$_(#608). - Started the aarch64 port: VM-tier backend and macOS (Apple Silicon) support (#640, #641, #644).
- Continuous yjit-bench benchmarking against YJIT, charted on GitHub Pages (#411, #416).
April 2026
- Reimplemented
Rationalas a first-class Rust type with literal support (#266). - Broad ruby/spec compliance work on Integer / Float / Array / Symbol / Class / Module (e.g. #281, #284, #309), with a live spec dashboard published on GitHub Pages (#364, #365).
- Moved shift / bitwise /
**/%into the inline-function JIT pipeline with constant folding (#307, #308), and added frame-free trivial-method optimization viaISeqHint(#290). - Struct: per-instance slot storage with JIT-inlined member accessors (#367–#369).
- String: encoding-aware data model — code ranges,
Encoding::CompatibilityError, encoding-aware char iteration (#382–#384). - Reworked
autoloadas a proper state machine (#376) and added lazy heap promotion for Proc / Lambda / Binding captures (#332).
March 2026
- Implemented
Marshal.dump/Marshal.load(#121, #125) and theSetclass, a CRuby 4.0 built-in (#130). - Implemented the
retrystatement (#109), module/class lifecycle hooks (#127), anonymous block forwarding (#128), and regex backreferences / special variables (#129). - Added
Kernel.#load(#105), correctDir.glob(#106),Kernel.#format/sprintf(#162), and a SQLite3 FFI bridge for the sqlite3 gem (#160). - Started a large ruby/spec compatibility push: implicit type conversions (#225), frozen-object support (#240), Errno exceptions (#250), and many crash fixes on invalid UTF-8 input (#209–#212).
- Fixed many issues to run optcarrot (#115) and the rubyboy / lee benchmarks (#111, #120).
February 2026
- Optimized rest / keyword-rest parameters in the JIT (#96).
- Corrected
/and%semantics for Integer and Float (#97) and fixedInteger#digits(#99). - Optimized the Range class (#98) and fixed
Range#include?for string / beginless / endless ranges (#104). - Introduced continuation frames (#101) and reworked JIT slot write-back logic (#102).
January 2026
- Hardened JIT frame handling so local frames are never captured by JIT-compiled code (#93).
- Added
SAFETYcomments tounsafeblocks across the codebase (#94). - Introduced immediate operands (
BytecodeInst::Immediate) into the bytecode (#95).
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
matchloop. - 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
| Subsystem | Chapter | Design documents |
|---|---|---|
| Value representation (64-bit tagged union) | Value Representation | — |
| JIT compiler (TraceIR / AsmIR / register allocation) | JIT Compiler | jit.md, lir.md, regalloc_separation.md |
| Garbage collection (generational mark-and-sweep) | Garbage Collection | gc.md |
| Green threads and fibers | Threads and Fibers | threads.md |
| Stack frames and method calls | Stack Frames and Method Calls | stack_frame.md, method_args.md |
| Exception handling | Exception Handling | exception_handling.md |
| aarch64 (Apple Silicon) backend | aarch64 Backend | arch_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):
| Register | Holds |
|---|---|
rbx | &mut Executor |
r12 | &mut Globals |
r13 | program counter |
r14 | local frame pointer (LFP) |
Further reading
- Build and Install — how to build and run monoruby
- Build options for performance tuning — bytecode / TraceIR / assembly dumps
doc/directory — the full set of design documents, including C-extension design notes, encoding design, and progress notes
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 immediate — nil / 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
| Constant | Hex | Binary | Meaning |
|---|---|---|---|
NIL_VALUE | 0x04 | 0000_0100 | nil |
FALSE_VALUE | 0x14 | 0001_0100 | false |
TRUE_VALUE | 0x1c | 0001_1100 | true |
TAG_SYMBOL | 0x0c | 0000_1100 | Symbol (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 classFloat. nil/falseare 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
monoruby/src/value.rs— theValuetype and tag schememonoruby/src/value/numeric.rs— Fixnum / Flonum / BigInt helpersmonoruby/src/value/rvalue/— heap object representation
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 (unboxedf64), 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 oneencode_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 feature | Output |
|---|---|
dump-bc / emit-bc | bytecode |
dump-traceir | TraceIR |
emit-asm | generated assembly |
jit-log / jit-debug | compilation events / detailed debug |
deopt | deoptimization log |
profile | deopt & recompile statistics |
perf | perf-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 RValuepointers 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.startalways 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. - Explicit —
GC.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
| Control | Effect |
|---|---|
GC.start | Force a full (major) collection |
GC.enable / GC.disable | Toggle collection at runtime |
--no-gc CLI flag | Disable GC for the process |
GC.count / GC.stat | Collection counters / CRuby-compatible stats |
Debugging Cargo features: gc-log (stats at exit), gc-debug (assertions), gc-stress (force a collection at every safepoint, unconditionally — used by bin/test’s nextest phase on x86-64 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.passat its next safepoint.MONORUBY_NO_PREEMPT=1disables it;MONORUBY_PREEMPT_STRESS=1switches 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
doc/threads.md— full design document (scheduler internals, preemption, IO parking, sync primitives, kill/raise delivery)doc/scheduler_state_diagram.md— the state diagrams with commentarydoc/safepoint.md— the unified GC / preemption / signal poll mechanismdoc/signal.md— async-signal-safe handlers and deferred delivery
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 saved
rbp, the return address, the caller’s suspendedpc, and a pad word. The saved call-site pc is what powers lazy backtraces,Kernel#caller, andsuperresolution; the pad is unread on the normal return path and is reused by chain deopt as the converted call’s continuation word. - Control frame (CFP) —
prev cfpandlfp; the executor’scfpchain links all active frames. Every frame establishesbp == cfp + 8in its prologue, so the machine frame pointer is recoverable from the CFP alone. - Local frame (LFP) — the Ruby-visible part, addressed at negative offsets from
lfp:outer(for blocks, the enclosing frame),meta(a packed word ofFuncId,reg_num, arg mode and flags),svar(frame-local$~/$_, lazily allocated),block,self, then the argument/local slotsarg0, arg1, ….selfis register slot%0, so a method’s first parameter is%1.
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, r15 = accumulator. The accumulator is a VM-tier register — JIT’ed code keeps no fixed accumulator and allocates general-purpose registers per basic block instead.
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
doc/stack_frame.md— exact slot offsets, pre-call vs post-prologue layoutsdoc/method_args.md— caller/callee argument-processing responsibilitiesdoc/native_func.md— builtin registration and argument slots
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:
- Dispatches control-flow kinds first —
return/break/throw/retry/redoare resolved before any backtrace capture, so non-local control flow never pays for a backtrace or allocates an exception object. - Records the frame’s source location into the incremental trace (frames are being destroyed, so this is the only chance).
- 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 insideensuresupersedes the deferred one). - 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
| Role | x86-64 | aarch64 |
|---|---|---|
&mut Executor | rbx | x19 |
&mut Globals | r12 | x20 |
| Program counter | r13 | x21 |
| Local frame pointer (LFP) | r14 | x22 |
| (former accumulator slot) | r15 | x23 |
| Scratch for lowering temps | — | x9–x15 |
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 buildproduces a native binary (Homebrewlibffi+pkg-configare required — see the target-specific dependency block inmonoruby/Cargo.toml). - From an x86-64 Linux host,
bin/setup-aarch64-crosssets up a cross toolchain andbin/test-aarch64runs 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
The exit / vm_entry arrow leaving each class_guard below is the
megamorphic gate: a receiver class that misses the whole chain runs the
call in the interpreter, and is compiled for only once the stub’s warm-up
sampler has seen it twice. That arrow must therefore actually go somewhere
— when its label was left unbound the branch became a no-op and every
missing class got its own compiled body. Both that gate and what happens
when a version guard (rather than a class guard) fails are described in
jit_invalidation.md.
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 (x86arch/x86_64/compile/*.rs, aarch64arch/aarch64/compile.rs). LIR makesencode_linstthe 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
| Type | Role |
|---|---|
GP | General-purpose register. Reused from codegen.rs; already arch-neutral. |
LReg | Gp(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>. |
FPReg | Virtual 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. |
LOperand | Reg(GP) or Imm(i64) — an ALU/compare source the encoder folds or materializes. |
LMem | A 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. |
LCond | Signed integer branch condition (Eq/Ne/Lt/Le/Gt/Ge), with from_int_cmp / invert. |
LAluOp | Add/Sub/Mul/And/Or/Xor/Shl/Sar, with from_binop. |
Instructions (LInst)
| Group | Variants |
|---|---|
| Move / immediate | Mov, LoadImm |
| Memory | Load, Store, StoreImm (over LMem::Slot / Field / RspRel) |
| ALU / compare | Alu, Cmp |
| Branch | Label, Br, CondBr { cond: LCond, … }, BranchTruthy { negate }, BranchIfNil, BranchIfNonzero |
| GC / nil | WriteBarrier { parent, value }, NilIfZero { reg } |
Guards (carry a side-exit deopt) | GuardClass, GuardArrayTy, GuardFrozen, GuardConstBaseClass, GuardConstVersion, GuardCapture, CheckBOP |
| Integer arithmetic | IntegerBinOp { …, deopt }, IntegerCmp, FixnumNeg { …, deopt }, FixnumBitNot |
| Floating-point | FprMove, 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, ChainExit, 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]/imm32cover 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, scaledldr/str,sub sp) and otherwise materializes the offset into reserved scratchx9/x10(thea64_frame_*/a64_field_*/a64_rsp_slot_addrhelpers).
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:
-
Scratch register operand (
LReg::Scratch). Lets the LIR express intermediate pointers that map to a different physical register per arch (x86rdx, aarch64x9). Without it, an abstractGPwould 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). -
Deopt side-exits. Guard ops carry the resolved side-exit
DestLabelthey fall through to, making deopt a first-class LIR concept. The arch-neutral dispatcher resolveslabels[deopt]and builds the guard; the encoder branches to it. This pattern carries the overflow exit ofIntegerBinOpand 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 fromemit_*into the encoder arms, deleting thoseemit_*). - Macro-op delegation — a single
LInstwhose 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-archencode_linstmatches the variant directly and calls the substantive helper (guard_class/a64_guard_class,integer_binop/a64_integer_binop, …); the thinemit_*wrapper is deleted. encode_linst_macro-style delegation (the large runtime-call families): the variant falls through each backend’sother =>arm into the arch-neutralencode_linst_macro, which calls the per-archemit_*helper. Theemit_*helper is retained verbatim, so the migration is a pure routing change — the dispatcher arm now builds anLInstand hands it toencode_linstinstead of callingemit_*directly. This is how the construction / variable /defined?/ definition / control-flow families were migrated (batches A and B).
- decomposed-style delegation (guards,
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).
| Stage | Family | Notes |
|---|---|---|
| 0 / 1 | LIR data model + this doc | scaffolding |
| 2-A | Mov (emit_reg_move) | first encode_linst user |
| 2-B | slot memory (Load/Store/LoadImm/StoreImm over Slot) | frame legalization |
| 2-C | reg-imm ALU (emit_reg_add/sub → Alu) | LAluOp / LOperand |
| 2-D | integer compare-branch (Cmp + CondBr) | LCond; built in shared dispatcher |
| 2-E | conditional branches (BranchTruthy / BranchIfNil / BranchIfNonzero) | |
| 2-F | inline struct-slot load (Load over LMem::Field) | first field-offset legalization |
| 2-G | inline ivar/struct stores (Store{Field} + WriteBarrier) | introduces WriteBarrier |
| 2-H | inline ivar load (Load{Field} + NilIfZero) | introduces NilIfZero |
| 2-I | heap struct-slot load/store | composed from existing ops — no new op |
| 2-J | rsp-relative arg stores (LMem::RspRel) | completes the addressing modes |
| 3-A | scratch operand + self heap-ivar store | model extension ① |
| 3-B | deopt model + class / array-ty / frozen guards | model extension ② |
| 3-C | const-base-class / const-version / capture / BOP guards | |
| 3-D | fixnum IntegerBinOp (overflow deopt) | hottest arithmetic path |
| 3-E | FP transfer/convert (FprMove / F64ToFpr / FixnumToFpr / FprToStack) | first FP family; real decomposition |
| 3-F | FP swap / FloatToFpr (deopt) / I64ToBoth | |
| 3-G | FP arithmetic & compare (FloatBinOp / FloatUnOp / FloatCmp / FloatCmpBr) | NaN-correct conditions |
| 3-H | FP C-calls (CFunc_F_F / CFunc_FF_F) + FprSave / FprRestore | completes the FP family |
| A | bounds-checked heap-ivar load/store (LoadIVarHeap / StoreIVarHeap) | first macro-op via encode_linst_macro |
| B1 | variable + construction macro-ops (g/c/dyn-var, array/hash/range/str, to_a, defined?, generic binop, alias/undef, …) | bulk macro-op routing |
| B2 | remaining construction / defined? / dispatch-helper macro-ops | |
| B3 | control-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 |
| B4 | class-def + method-prologue guards (ClassDef, SingletonClassDef, GuardClassVersion, RecompileDeopt) | last non-store/non-frame arms |
| B5 | elementary moves (RegMove/RegToAcc/AccToStack/RegToStack/StackToReg/LitToReg/LitToStack/RegAdd/RegSub) | dispatcher lowers straight to LInst; the thin emit_* move wrappers deleted from both backends |
| B6 | method-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 |
| B7 | the 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 |
| B8 | cold 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--libtests fail on format differences (3.4Hash#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 fromcodeload.github.com, installgperf(needed to generatelex.c), emptygems/bundled_gemsto skip the bundled-gem download (or setSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtso the bundled-gem fetch trusts the egress proxy CA), thenautogen → 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, andSpecializedYieldlower 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::Inlineis no longer in this group — it lowers toLInst::Inline; see below.) -
The
AsmInst::Inlineescape hatch. Most builtin inline generators (e.g.emit_math_sqrt) still run a closure that emits arch asm directly viagen. As of the AsmIR→LIR consolidation,AsmInst::Inlinedoes lower to a carrier LIR op —LInst::Inline(InlineProcedure)— so everyAsmInstreaching the dispatcher now lowers toLInst.LInst::Inlineis the one LIR op whose emit is not store-free: its wrapped closure needs the compile context (&Store,&SideExitLabels, framebase), so it is dispatched at the lowering boundary viaencode_linst_inlinerather than through the store-freeencode_linst. (If it ever reachedencode_linst/_macroit hits theunreachable!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 readers →
AsmIr::load_field_to_reg→AsmInst::LoadFieldToReg→ existingLInst::Load { Field }(no new op, byte-identical on both arches). Done:Range#begin/end(hand-writtenemit_range_begin/enddeleted) andArithmeticSequence#begin/#end/#step(via the sharedinline_field_loadhelper;emit_load_value_fielddeleted from both backends). - Bool field readers →
AsmIr::bool_field_to_reg→AsmInst::BoolFieldToReg→LInst::BoolFieldToReg(a small macro-op: 32-bit load +shl 3+or FALSE_VALUE, deduping the two byte-identicalemit_*_exclude_endemitters into one encode arm per arch). Done:Range#exclude_end?,ArithmeticSequence#exclude_end?. - Container length →
AsmIr::array_len_fixnum/string_len_fixnum→AsmInst::ArrayLenFixnum/StringLenFixnum→ the matchingLInst(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, x86cmov/ aarch64csel). Done:Array#size,String#bytesize— one op each (differing only in the inline-cap constant,ARRAY_INLINE_CAPAvsSTRING_INLINE_CAP), replacing the fouremit_array_size/emit_string_bytesizeemitters. - Fixnum → float (
Integer#to_f) → the existingAsmIr::fixnum2fpr→LInst::FixnumToFprop (untag +cvtsi2sd/scvtfstraight into the result fpr). No new primitive; deletesemit_int_to_floatfrom both backends (and drops a redundantxmm0round-trip the old emitter always did). - Bool predicates (
Object#nil?,BasicObject#!) →AsmIr::is_nil_to_bool/not_to_bool→LInst::IsNilToBool/NotToBool(a macro-op: compare + conditional-select TRUE/FALSE; the select is the per-arch part,cmov/csel). Replaces theemit_kernel_nil/emit_object_notemitters. - C-function wrappers (e.g.
Math.sin/cos/atan2,Float#**) are already arch-neutral: they route through the typedAsmInst::CFunc_F_F/CFunc_FF_F(→ existingLInst::CFunc_*), not the closure escape hatch. - FP guard + op (
Math.sqrt) →AsmIr::math_sqrt→LInst::MathSqrt, a macro-op carrying the deopt label (resolved by the dispatcher likeGuardClass). It encapsulates the per-arch domain guard (x86ucomisd+jp/jb, aarch64fcmp+b.vs/b.mi) and thesqrtsd/fsqrtin one encode arm per arch — so even a deopt-branching FP builtin migrates without a closure (it does not declarefpr_operands, matching the prior opaqueInline, so the spill-area sizing is unchanged; the fprs are accounted via the surroundingload_fpr/def_F). - Integer guard + op (
Integer#succ) →AsmIr::integer_succ→LInst::IntegerSucc, a macro-op carrying the deopt label (add+jo/adds+b.vs, deopt → Bignum promotion). The integer analog ofMathSqrt. - Control-flow predicate (
Kernel#block_given?) →AsmIr::block_given→LInst::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. - 64-bit field readers →
-
Pure registration / 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:ChainExitrecords a call site’s replay data inchain_deopt_table(zero bytes), and the actual frame conversion happens at BOP-redefinition / deopt time, not at compile time. The clean invariant is therefore:
encode_linstis 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
AsmIrlowers toLIR(AsmInstis frame-independent;LInstalready carriesbase). WhatLIRdoes not yet bake is the physical register assignment. - Registers are virtual in LIR. FP registers already are (
FPReg= phys-or-spill, resolved byPhysMapat encode time — §25/§27). GP registers are not: todayLInstnames concreteGPs (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 — seeCLAUDE.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:
self.jit.label()/bind_label(..)— label creation and binding.self.jit.select_page(1)— cold/hot page selection (x86 lays side-exit handlers on the cold page).frame.sourcemap.push((i, pos))— source-position records keyed on the current code position.- 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 singleVec<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 withVReg, 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 asFPRegdoes for FP), then drain → encode. This is the first point the output may legitimately differ from today’s bytes; like the (since removed)phys-loop-awareexperiment (regalloc doc §42, §49), 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::a64map (x9..x15minus 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 r8–r11 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:
-
Write-back + flush pool-support (done). Two seams, both inert until a slot is placed:
- Side-exit / deopt / GC write-back.
WriteBackcarries agp: Vec<(GP, SlotId)>list — the pool-resident slots and their physical registers — alongside the singler15accumulator. 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 forr15. The producer (wb_gp) scans for slots in modeG(_, VReg::Alloc(_)). - In-function flush-at-boundary.
writeback_acc— already called before every call / store / definition, and already asserting noG(_, _)slot survives it — now also flushes the pool residents (writeback_pool_state): eachG(_, Alloc)slot is stored to its home and dropped toS. 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, theirHash/Debug, and the write-back/flush loops are inert and the emitted bytes are unchanged. - Side-exit / deopt / GC write-back.
-
Reserve the pool registers (x86-64) (done). The pool design above assumed
r8–r11were “otherwise-unused caller-saved scratch”, but an audit of the JIT-body lowerings found that is not true:r8–r11are 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 toxmm0/xmm1.The audit (in-scope: every
compile.rs/compile/*/guard.rslowering 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: onlyclass_def, a post-flush call-staging use (safe).r8: every use is post-flush call-staging (a method-send/define/store sequence runswriteback_accfirst, emptying the pool — sor8is free there, the GP analogue of using the pool afterfpr_save) except one:emit_string_setbyte, a pure inline op that can execute with a pool value live. That one was migrated offr8(tag scratch →rcx; negative-index adjust → a sign branch instead of a cmov-through-scratch).
The reservation invariant: a JIT-body lowering may use
r8–r11as scratch only in a post-flush call-staging window (afterwriteback_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::R8–R11map tox5–x8; that backend needs the same audit before placement targets it. Thegp-allocfeature is x86-first and off by default, so nothing places into the aarch64 pool yet.) -
Placement policy + multi-residency (done).
def_reg2acc_guardedis 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_poolmoves 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).SlotStatestoresplace+tyand reconstructs everymode()viafrom_parts— so the placement must carry theVReg, elseset_mode(G(Alloc))loses the pool-register identity on the very next state read (it round-trips toStack).- Register-aware reads everywhere a
Gslot is consumed:on_reg/on_reg_or(binop operands),load_state(GpLoad::Reg(vreg.phys())),fetch_for_callee(call-argument materialization), theG→Sfbridge — all resolvevreg.phys()rather than assuming R15. - Alloc-aware destructive sites + flush (§1, the
gpfield /writeback_ pool_state/ Alloc-awareclear/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 inr8–r11across 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 includinggc-stress(GC on every allocation).
-
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-callget_using_fprsnapshot (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’sbytesizeinr8, clobbered by the""literal’svalue_deep_copy). The asmir builders that take&AbstractFrame(immutable, so they cannot flush) get an explicitflush_gpin their handler instead. - Branch-merge reconciliation needs no special pool handling: pool residents are
flushed (→
S) before any branch (the compile-loopflush_gpand the back-edge analysis G→S demotion), so a merge never sees aG(_, 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 r8–r11)
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:
copy_slot’sGarm spilledsrcthendef_G(dst)— which claims R15 without moving the value there. Sound only forPinned(R15); for a pool resident it leftdstreading R15 while the value sat in the pool register. Fixed by transferring the pool register’s ownership todst(no data move).- 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
2nchoice:2a + 2b = 2(a+b)overflows i64 iffa+boverflows i63 (the Fixnum range), so the existingjoworks unchanged. (Rawnwould 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 xmm0..PHYS_FPR_POOL; else demote anSfcache; else spill toFPReg(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
LInstdirectly; theAsmInstlayer (already ~isomorphic toLInstafter 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
jointable 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:
- Representation: keep a
Float/Fixnumvalue unboxed where it is consumed by FP arithmetic, else boxed. (Today: theFvsSvsSfchoice.) - Placement: assign each live unboxed value an
FPReg(pool or spill) and each boxed value its stack home / ther15accumulator. 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.
| Step | Change | Risk |
|---|---|---|
| 0a. Decomposition + test ✅ | Add 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 meet ✅ | Extract 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 seam ✅ | Extract 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 analysis | Run 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
joinmay 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 (insertFprMoves 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:Sfis 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 (theFprStackplacement) using the heuristic in §3. The demote-on-pressure logic (try_alloc_fprphase 1) is a further allocation policy that moves into theAllocator. (In the current fused state theSfGuardedrefinement still round-trips losslessly through the pairedGuarded, sinceSfGuarded → Guardedis injective.)r15accumulator. The single GP “accumulator” slot is its own tiny allocation problem fused intoSlotState.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 primitives — writeback_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:
- Split each transfer/eviction primitive (a bounded set —
writeback_acc, the xmm spill/swap/float_to_fpremitters,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. - 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_blockreusescompile_instructionbut discards itsAsmIr— today that discard is wasteful (it buildsAsmInstonly to drop them); after the split it would call the state halves and skip emission. - The lowering pass replays the records, emitting
LInstvia 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
Spillrecord (None/Fpr/Lit/Acc):writeback_acc,write_back_slot,to_S_unguarded. - FP-register transfers → the
FpXferrecord (Move/Swap):to_sf(gen_fpr_swapwas 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-pass —
JitContext::loop_analysissetscodegen_mode: false(context.rs:687).analyse_backedge_fixpoint→analyse_basic_blockruns the handlers to compute the loop’s back-edge / liveness fix-point.pushis suppressed, so noAsmInstis built — it produces abstract state, not an instruction stream. - Codegen pass —
traceir_to_asmir→compile_basic_blockruns withcodegen_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:
- 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 onlyGuarded+ 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. - 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) emittingLInstdirectly, andAsmInstretires. 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 frozenAsmDeoptthe 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 enumTransferIR(state/read_slot.rs) with a singleemitdispatch. The two deopt-carrying variants still freeze anAsmDeopt(lifting it to a program point is §9’s open item, the next wall). - One funnel.
AsmIr::transfer(t)is the sole sink: it pushestonto the newtransfers: Vec<TransferIR>(codegen mode only, so it stays in lock-step with thecodegen_mode-gatedinst) and then emits viat.emit(self). Every transfer/eviction wrapper (load,load_fpr,load_fpr_fixnum,write_back_slot,to_S_unguarded,to_sf) now callsir.transfer(...)instead ofrecord.emit(ir, …). - Faithful by construction. The collected
tis 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/restoretruncatestransfersalongsideinst, 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
TransferIR → LInst) 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; andapply_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.bridgepattern-matches(pred.mode(slot), target.mode(slot))and has both placements in hand. - The merge is commutative and predecessor-blind.
decide_joinsees only the twoLinkModes; it does not know which predecessor carriedF(xmm2)vsF(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, andbridgeturns 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 (noapply_join, no xmm allocation). The codegen pass owns all placement/allocation, and the existingbridgealready 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) callsAbstractFrame::join, where the stage-2 assertion (self.guarded(i) == join_ty(pre, other)[i]) lives.join_entriesis reached from bothincoming_contextandanalyse_backedge_fixpoint(merge.rs:77, 79, 107), and the analysis pre-passloop_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 oncodegen_mode()only to choose emission, e.g.binop_uncachedwidens toSin analysis — theGuardedit 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.
- Merge half — already discharged by stage 2.
-
3b — benchmark-gated: actually strip allocation from the analysis pass. Make
loop_analysis(and any othercodegen_mode:falsewalk) 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 thecodegen_mode:falsepath. 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
BranchEntrystates, jitgen.rs:114) instead of the commutative merge. Linear scan over the type/liveness result; spill = today’stry_alloc_fprphase-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):
cargo build --releaseat the pre-3b commit; recordbin/benchnumbers andoptcarrotfps for the standard set (benchmark/*.rb:app_fib, the binary- trees / so_* set, optcarrot). M1bin/testalready passing is the correctness baseline; the gate adds the speed baseline.- 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. - 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:falsepath 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
- 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
AsmDeoptthe 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. - 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.
- 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_analysis → use_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 loop —
emit-asmfor thex += i*0.5while-loop is identical off vs on: the loop-carried accumulator was alreadySf(boxed per iteration) in the baseline, anduse_floatre-promotes it to the sameSf, 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:
| benchmark | base | 3b | verdict |
|---|---|---|---|
| mandelbrot (M1, iter/s) | 24.903 | 9.716 | 2.56× slower |
| nbody (M1, iter/s) | 11.353 | 10.184 | ~10% slower |
| mandelbrot (x86-64, wall-clock) | 0.792 s | 1.479 s | 1.87× slower |
| fib / aobench / bf / nqueen / sudoku / matmul / bedcov | — | — | flat |
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
Guardedtype at each program point; - the representation decision kept separate from placement: “this slot is used
as f64” (today’s
use_floatliveness) decides unboxed-float-ness; the allocator then decides which xmm (or spill) — splittingSf’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):
- Free spill of read-only caches.
try_alloc_fprphase-1 demotes an all-Sfregister toSwith no asm (stack is canonical). A linear-scan spill of anSfinterval must keep this — spilling a clean float cache costs nothing. - 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
AsmDeoptthe 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/Sbundle “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 directmovq 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/movqflonum-decode) before themulsd: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:
- The regression is specific to
for…inloops. Awhile-loop float kernel (zr/zi loop-carried) produces identical asm base-vs-3b — no regression. mandelbrot/nbody usefor…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 thewhileloop; thefor…incase is where 3b loses. - 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_floatpass 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’sTryFresh*(already isolated, §5 stage 1).
Every one of these funnels through two primitives — SlotState::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):
- Extract the register-selection policy into a named
alloc_policyunit:try_alloc_fpr/alloc_fprmove out of theSlotStateimpl into a child module taking&mut SlotState; the methods delegate. No field, no dispatch yet — the seam is the module boundary. This increment. - 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. - 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-
Sfregister; 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_floatspill fallback (§14.7): promote a pool-full float to an unboxed spill (set_new_Sf) instead of leaving it boxedS. No-op.liveness-aware-spill: intry_alloc_fprphase 1, prefer demoting a cleanSfregister whose slots are dead over one still live (using theIsUsedlivenessSlotStatealready 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 merge — decide_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 asSf— 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_fprphase-2 (push_spill) hands back aVirtFPRegthat lives on the stack as a rawf64(movsd), never a boxedValue. 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 _ => SetS → S. 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_valuecount 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, notS(Float)(verified injit-debug: the pre-header forward entry holds%3: S(Value)even though it iszr = 0.0). The fixpoint correctly has it asF(Float); the codegen mergeSetS(join(Value, Float)) = Valuedegrades it. ForcingFon aValue-typed slot then panics at theC(non-float) -> Fbridge (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 anFslot carries no runtime guard. - The sound guard (
guarded == Float) makes it a no-op, because the loop-carried floats areValue-typed: suite 1704/0, but mandelbrot is byte- identical to base (340/16). The placement fix has nothing to act on until the type isFloat.
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-
Sfregister. It scans for an xmm whose linked slots are allSf(Integer-def’d / Float-use’d, kept coerced — the stack already holds the canonical boxed value), demotes them toS, 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 anyFslot 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 aVirtFPRegthat lives on the stack as a rawf64(movsd), still unboxed (§15.3). So no allocation decision ever boxes anF.
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
Guardedtypes (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 adoption —
keep_backedge_floatsreadsbackedge.mode(i) == F(§15.7);- (b) placement reconciliation —
target.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.
| Increment | Change | Risk / gate |
|---|---|---|
| L2-0 ✅ | Split 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-1 | Swap 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-2 | Decouple 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-3 | With (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-4 | Standalone 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-op —
emit-asmon 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_valuecount 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 superset “Float-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:
- Consumer (a) already reads the analysis output, not the live codegen
placement.
incoming_contextderives the adoption set fromloop_info(bbid)’s stored back-edge frame (be.mode(i) == F) — the analysis pre-pass’s result, cloned intobackedge_for_floats. So the dependency we are trying to remove is specifically on the back-edge frame’sF-placement. - 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 whichFloat-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 ∧ livenesssuperset (and lettry_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 theFchoice 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):
| benchmark | default | L2-1 | verdict |
|---|---|---|---|
| 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.150 | flat (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/Sfwhere profitable,Sotherwise). 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 “alwaysS” 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:
| Site | JitGreedy (today) | VmResidual |
|---|---|---|
try_set_new_F / try_set_new_Sf | allocate xmm if free | return 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_Sf | no-op (skip promotion) |
merge apply_join TryFresh* / keep_backedge_floats | allocate / adopt F | skip (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 onAllocCtx(defaultJitGreedy). Route the easy sites (try_set_new_*,use_float, the mergeTryFresh*/keep_backedge_floats) through it: underVmResidualthey skip xmm creation.JitGreedybyte-identical.VmResidualnot yet constructed (so float-op handlers stilldef_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_Fconsumers) aVmResidualboxed lowering, selected by the strategy. This is the bulk;JitGreedypath unchanged at each. - L2-2.3 — goal-3 spike: drive a
VmResidualcodegen 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 decidesFold(f64)(both operands const floats, result a flonum immediate) vsFprOp, without allocating an xmm or emitting.binop_float— executes the plan (Layer-②):Fold→def_C_float(a pure constant, no xmm);FprOp→load_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_fixnumare 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 aDeoptPointprogram point) is what emits.transfer()collects the record intoself.transfersand (today) emits it inline, with a debug shadow check proving the record replays to the identicalAsmInst/SideExit— i.e. the record is self-contained.def_F(the dst) is pure allocation (no emission);fpr_binopis 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
- Migrate the data-only operations to records — float/integer arithmetic and
comparison (
FloatCmp,IntegerCmp,IntegerBinOp, …). Each: add a record variant, route the handler throughtransfer(), verify via the shadow check. (This step: float binop.) - Generalize the type — once it carries both transfers and ops, rename
TransferIRto a unifiedLowerRecord/CodegenIR;Transferno longer fits. - Handle the closure-carrying ops (inlined calls, C-func trampolines): these
variants of
AsmInstare notClone(they ownFnOnceclosures), 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. - Build the replay (lowering) pass — collect the full ordered stream during
the codegen walk, then emit
instby 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
xmmpool ∪ 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-Sfcaches; anFis never boxed; pool overflow spillsFas 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:
| ② action | cost now / on next use |
|---|---|
box an F | forbidden (∞) |
drop an Sf coercion cache (→S) | 0 now / one flonum decode on next float use |
raw-f64 spill of an F | one movsd now / one movsd on next use |
keep loop-carried F/Sf resident | preferred 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
| Step | Change | Risk |
|---|---|---|
| P0 ✅ | Introduce PhysMap (codegen.rs): the single resolve(FPReg) -> FPRegLoc chokepoint, today the pool-vs-spill formula. | none |
| P1 ✅ | Route every emission-site FPReg::loc(base) (22 sites, both arches) through PhysMap::resolve; delete FPReg::loc. | none |
| P2 | Make ① 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 |
| P4 | Swap ②’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 |
| P5 | Deopt-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:
| Phase | Action | Touches representation? |
|---|---|---|
| 0 vacant | return lowest-index free fpr | no |
| 1 demote | victim 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 spill | push_spill appends a new FPReg(N≥POOL) for the value being allocated | no — 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 F — but only via
try_set_new_F → try_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:
- ① 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 thePHYS_FPR_POOL-wide pool rather than spill it, at this program point?” - The low-risk route to byte-identity: share one allocator oracle. Keep
keep_backedge_floatscallingtry_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 greedytry_alloc_fpr, ①’s gate and ②’s placement read identical occupancy ⇒ byte-identical output by construction. ThePhysMapseam (§22.5) is what lets ②’s final physical slot later diverge from the gate’s tentative one without touching ① or ③. - 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 insideresolveunder#[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 toiseq=… 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 aspolicy(i) -> PhysSlot, wherePhysSlotis a frame-independent placement (Xmm(p)orSpill(n), then-th f64 slot). With featurephys-table,policyis memoised into an explicit per-compilation table (phys_alloc::slot, grown lazily); without it,policyis called directly. Both yield identicalPhysSlots. - ③ (
PhysMap::resolve) is now policy-free: ask ② for the virtual fpr’sPhysSlot, thenapply_baseturns aSpill(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=0x01531d81c82b05c4identical with and withoutphys-table. so_nbody: all 79 per-compilation fingerprints byte-identical across the flag (diffempty).- 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 shadowdiff.
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/FprSwapreconciliations 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? | risk | gate | |
|---|---|---|---|
| Step 1 (③ policy-free, ② owns the placement table) — done | yes | low | shadow diff empty ✓ |
| Step 2 (① virtual ids, ② global allocation) | no — different placement + fewer swaps | high (§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
- 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.
- The lever is Phase 0/2, not victim_rank. §15.9 (and the
AllocCtxheader) prove the Phase-1 victim choice is performance-neutral: it only drops anSfread-only cache, never boxes anF. 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):
| Stage | Work | Needs |
|---|---|---|
| 2a | Collect 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 |
| 2b | A 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 |
| 2c | Measure: 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_floats →
try_set_new_F → try_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_vacantfor 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_Fpromotion 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; sinceLis monotone over the back-edge fixpoint (§14.1) the reserve count stabilises with it. Cap reservation atPHYS_FPR_POOL - 1so 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/FprSwapremoved) + 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_fpr →
AllocCtx::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_F → push_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 usetry_set_new_F(no Phase-2 spill): set the target toFonly when a physical fpr is free, otherwise leave itS/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_floatsdeliberately uses the no-spill variant because spill-promotion was “exercised wrongly under register pressure (thestress-spill-poolpath)” (slot.rs:758-763). Diagnose that failure mode before re-enabling — start by switching only thelayer2-float-by-typeconfirmed-loop-float arm and running the suite understress-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 — expectS→Fedges 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-typeunder register pressure (stress-spill-pool). The two flags are never combined in CI (stress-spill-poolruns withoutlayer2-float-by-type), so it went unnoticed.layer2-float-by-type’s broader type+liveness adoption promotes more slots to loop-carriedF, 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-typethebe.mode == Fadopt 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_floatsoverrides the loop entry afteranalyse_backedge_fixpointhas 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_Fat the back-edge) code are reverted; the tree is restored to §27 Stage 1 (③ policy-free, ② owns the placement table, Phase-0pick_vacantseam — byte-identical, shipping-safe). Thephys-loop-awarefeature is removed. - The pre-existing
layer2-float-by-type×stress-spill-poolfailure 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.
endvis float-typed and used-as-float in the loop, so layer2’s type+livenessadoptfires — **even though the back-edge fixpoint (analyse_backedge_fixpoint, merge.rs:79, run beforekeep_backedge_floatsat-
- placed
endvasSf, notF.** The entry state is thus overridden to a representation (F) the already-frozen loop body was never analysed for. Understress-spill-pool(pool 2) the freshly allocatedFPReg1is 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.
- placed
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 theFvsSfrepresentation 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 treatsendvasSf.
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 anF(physical) → Sf(spill)value into the spill slot. The normal(F, Sf)arm (slot.rs:1916:fpr2stack+to_sf→FprMove) does emit the spill store (FprMove(Xmm→Spill)lowers tomovsd [spill],xmm), so the defect is in how the side-branch stub is generated/reconciled, not inFprMoveitself. - Trigger:
layer2-float-by-type. Its type-based promotion ofendvto a physicalFis what forcesa’s merge onto a spill and thus exercises this side-branchF→Sf(spill)path; the default adopt never creates it, which is whystress-spill-poolalone (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): theBB2→BB4side bridge isFprMove(FPReg0 → FPReg2)—a(FPReg0, theaddsdresult) moved to its spillFPReg2. Exactly the materialisation the targetSf(FPReg2)needs. - Survives optimisation: not a self-move (
0 != 2), sooptimize_peepholekeeps it; non-empty, sothread_empty_outline_bridgesdoes not drop it. - Reaches emission: the outline-bridge loop emits it with
base=192, soFPReg2resolves toSpill(192-24)=Spill(0xa8)— the same[rbp-0xa8]theBB3path writes andBB4reads. - Lowering is correct:
FprMove(Xmm(s), Spill(d))→movq [rbp-d], xmm(s)(x86_64/compile/mod.rs:485). So the bridge does storeato[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:
a’s spill slot[rbp-0xa8](FPReg2) is aliased with the scratch staging used to load the0.5and-1.0constants in the body (movq [rbp-0xa8],xmm0appears for both the constant staging and asa’s home). Underpool=2the spill region is tiny and heavily reused.testis JIT-compiled twice — a loop-JIT (partial) and a method-JIT (whole) — and the two placeain different fprs (FprMove(FPReg0,…)vsFprMove(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: FprToStack → fpr_to_stack
→ load_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:
-
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.
-
§37’s “uninitialised spill read” hypothesis is falsified. If
res[0]were a pre-store read of a spilleda, the poison would surface asNaN. It does not.6.92321020550915e-310has bits0x0000_7f71_f000_1e02— a0x7f…userspace pointer (low nibble0x2, not 16-byte-aligned, so not a clean RValue ptr; its low two bits0b10are the flonum tag). Sores[0]is a heapFloatwhose stored f64 is a boxedValue’s pointer bits read as a raw IEEE-754 double, then re-boxed byf64_to_val/float_heap. The corruption is a boxed↔unboxedLinkModedisagreement, not uninitialised memory and not a missing spill store. -
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-headerS → Fbridge fails to unbox a slot the body then consumes asF, 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 a → res[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):
| benchmark | OFF (base) | ON (old adopt) | ratio |
|---|---|---|---|
| so_mandelbrot (2000²) | 0.94 s | 1.03 s | 1.086 ❌ |
| so_nbody (200k) | 0.249 s | 0.247 s | 0.991 |
| app_aobench (256²) | 6.23 s | 6.12 s | 0.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 stayF, 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):
| bench | ratio | bench | ratio | |
|---|---|---|---|---|
| mandelbrot | 1.002 | nqueen | 1.018 | |
| aobench | 1.021 | bedcov | 1.019 | |
| nbody | 1.001 | fib | 0.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:
| config | kernel loop digest | n (placements) |
|---|---|---|
shadow-placement (off) | 0x48168ad7c99d76d5 | 28 |
+ phys-loop-aware | 0x6191a84d9623dbad | 26 |
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):
| benchmark | back-edge bridges w/ moves | total FprMove+FprSwap |
|---|---|---|
| kernel (complex iter) | 1 | 4 |
| so_nbody | 1 | 7 |
| so_mandelbrot (150) | 6 | 29 |
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 := trproduces the newzrintr’s reg; landing it in the header’s expected reg costs one move somewhere. Pinningzrto 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.vfprisVec<Vec<SlotId>>— one fpr already can hold several slots. The twozr-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.rsis unconditional (the union-adopt of §40 — the mandelbrot-safe signalbe.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-typeis dropped from[features]and thedefaultset inmonoruby/Cargo.toml, and thebin/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.
45. Slot copies share a GP register (many slots per register, as the fpr file)
%dst = %src used to be a load/store pair through the stack home — and, since
Mov flushed the GP file first, it also ended every run of register-resident
integer work. On a copy-heavy body (yjit-bench 30k_variables: 100 locals per
method, mostly vN = vM) that was ~300 memory ops per call, each copy’s load
waiting on the previous copy’s store (a store-forwarding chain), and 12× the
cost of ZJIT’s SSA form, where a copy is a renaming.
The fpr file has always let one xmm back several slots (vfpr: Vec<Vec<SlotId>>,
the F/Sf sharing copy_slot relies on). GpRegFile now has the same shape:
holders: Vec<Vec<Holder>>, one holder set per allocatable register, each
holder carrying its own dirty bit. copy_slot’s S arm binds dst dirty
to src’s register (loading src clean into a fresh register first if it was
not resident) and emits nothing; Mov no longer flushes for an S/C
source. The store to dst’s home is owed at the register’s eviction or the
next flush, and is dropped if dst dies first (a ret, a popped temporary —
free_above_sp retains per holder). Eviction spills every dirty holder of the
victim (alloc_reg returns a Vec of spills; evict_reg is the shared
primitive, also used where Mul/Div are about to destroy rhs’s register).
Two rules follow from sharing:
- An op may compute in place only in a register that will hold nothing
but its
lhs(will_hold_at_most); a register shared with copies carries their only value, so the result takes a distinct register. - The result register must be reserved before the deopt snapshot
(
plan_dst_reg→GpRegFile::reserve, thennew_deopt, thentake_dst_reg). The snapshot is taken before the popped operands are cleared so the interpreter can re-read them from their homes; a victim evicted after it stayed listed in it, and an overflow side exit then stored the register — by now holding the result — into the victim’s home. Copies made this latent ordering bug reachable (plb2bedcov’ssplitmix32:z = xkeptxresident for the whole body, the second multiply overflowed to a Bignum, andxcame back a Float); it is fixed for the copy-free case too.
Result (x86-64, release): 30k_variables 306 ms → 25 ms per iteration; the
other benchmarks are flat. Tests: tests/copy_propagation.rs (in-place ops on
a shared register, Mul/Div clobbering a shared operand, overflow and type
deopts with copies outstanding, calls/allocations, loops, non-fixnum values,
the splitmix32 shape) and the gp_alloc unit tests.
46. place/ty folded back into one Vec<Slot> (the scaffolding is retired)
Steps 0b–0c split SlotState.slots: Vec<LinkMode> into place: Vec<Placement>
ty: Vec<Guarded>so a standalone analysis pass could consume the type vector alone. That pass never materialised in the shape the split assumed: the attempts to run allocation apart from the fixpoint (§13.8, §16.6, §26.3, §31.2) all lost to the greedy backedge placement, and the durable results are the seams that were kept (decide_join/apply_join,alloc_policy, the transfer records, thekeep_backedge_floatsmechanism/policy split) — none of which readsplaceortyseparately. After §44 the only consumer of the split wasjoin_ty, called from the debug-onlyverify_join_replay; thePlacementenum had no consumer at all. Meanwhile everymode()recomposed the pair throughfrom_partsand everyset_mode()decomposed it, with the sentinels (None/MaybeNone/V) needing a “no type” special case on both sides.
So the split is undone: LinkMode is stored directly again, Placement /
placement() / from_parts() and the round-trip test are gone, and join_ty
is computed from the stored modes (sentinels read as ⊤, as the stored ty
did) for the assertion that still uses it. The type-meet separability
invariant (§12 stage 2) is unchanged — it is a property of LinkMode::guarded
and Guarded::join, not of how the pair is stored.
At the same time the per-slot vectors that had accumulated beside place/ty
— liveness, dynvar_src, subtree_float_read, dynvar_alias — became one
Vec<Slot> record, so a per-slot fact is added, cleared (clear / discard)
and merged (join_subtree_read_meta) in one place; and deferred_forward,
which is fixed for the compile unit, moved from the per-path SlotState to
AbstractFrame next to invariants. Verified byte-identical: emit-asm dumps
of app_fib, so_mandelbrot, so_nbody, binarytrees, quick_sort, bf,
tarai, loop_whileloop are identical to master modulo the compile-time
lines; app_aobench differs at one site that master itself emits differently
from run to run (a pre-existing nondeterminism, not this change).
What was not folded, and why:
pending_outer_float_reads(the stage-A report queue). The natural replacement — marking the owner frame directly at the raw-f64 consumption — needs the chain, and the consumption sites (load_fpr_stateand the float binop helpers) areAbstractFramemethods that only see their own frame. A per-slot “pending” bit instead of the queue loses the report when the slot is redefined in the same instruction (a = a * 2.0consumesaand then discards it before the next boundary). The queue is the honest representation of “events for a frame this frame cannot see”.FprAllocator’s reverse map (vfpr). It is derivable from the slot modes, and deriving it would remove thefpr_add/fpr_remove/swapbookkeeping and the desync class thealloc_fpraliasing regression belonged to. But the map’s per-register slot order is the binding order, and the deopt write-back (wb_fpr) emits stores in that order — a derived map would emit them in slot order, so the change is semantics-preserving but not byte-identical. Left for its own change with its own gate.
47. The fpr file is derived from the slot modes (and the stale entry it was hiding)
FprAllocator kept a reverse map vfpr: Vec<Vec<SlotId>> — for each fpr, the
slots bound to it — maintained by fpr_add / fpr_remove / clear / swap
alongside every F / Sf mode transition. The map is a function of the modes,
so it is now computed from them: SlotState::fpr_slots(fpr) and a one-pass
pool_occupancy() (per pool register: occupied?, all-Sf?) serve the
allocator’s two phases, is_fpr_vacant, the call-site save set and the deopt
write-back. FprAllocator keeps only what is not derivable: the number of ids
issued (spill ids are never re-issued once vacant — §46’s pick_vacant note)
and the pin set. set_F / set_Sf / clear no longer touch a file, and the
bookkeeping that the alloc_fpr aliasing regression lived in is gone.
What the reverse map was hiding. The suite caught the derivation:
outer_float_write_through’s type-flip tests returned wrong values. The
emitted code differed from master at exactly one place — the each call site
in the owner loop saved xmm2 on master and not on the branch — and the block
compiled inside that call then read the owner’s a where it meant to read i
(the _%2 = %2 == %3 [Float][Integer] deopt storm: the block’s static
frame-chain offsets were off by the missing 16-byte save).
The mechanism: specialized_compile freezes the caller’s FP save set before
the nested compile (stack_offset = using_fpr_offset().offset() lays out the
callee’s extra chain offsets over it), while the call emission after the
compile took get_using_fpr again. In between, the callee’s compile can
widen a caller slot (StoreDynVar through the chain → widen_outer_slot →
invalidate_slot, which set S without fpr_remove). With the map, the
stale entry kept xmm2 “occupied”, so both sets agreed by accident (and the
register was leaked for the rest of the compile). Derived from the modes, the
second set was smaller than the first, and the callee’s offsets no longer
matched the frame the call actually built.
Fixed at the root, not by re-adding the map: the frozen set rides back on the
compiled frame (JitStackFrame::call_site_using_fpr, surfaced as
SpecializedCompileResult::using_fpr), and both specialized call sites emit
that set — get_using_fpr still runs for its GP flush and alias kill, and a
debug assertion checks the live set is a subset of the frozen one (it can only
shrink: a suspended frame never gains a pool register). Saving a register the
caller no longer needs is harmless; saving fewer than the callee was laid out
over was the bug.
Verified: emit-asm dumps of the nine benchmarks are identical to master
(app_aobench included this time), the JIT lib tests and the float / block /
loop integration tests pass, and the full cargo test suite passes.
48. One write_back(slot, Keep) for the four write-back policies
SlotState had four ways to put a slot’s value into its frame slot, each a
function with its own prose: write_back_slot (keep everything), unbox_to_S
with keep_claims == false (drop views and claims, keep the type) and == true
(the specialized-call demotion: keep claims, move a pool F to a spill home),
to_S_unguarded (forget everything), plus give_up_const as the C arm of the
second. Their differences — what each mode becomes, what is written, what is
forgotten — were spread over four bodies and their comments, and the
GP-resident re-homing preamble was copied into three of them.
They are now one function, write_back(ir, slot, Keep), whose match is the
mode × policy table (reproduced in its doc comment), and one enum:
Keep | was | meaning |
|---|---|---|
All | write_back_slot | the slot gets the value; every view and claim stays |
Type | unbox_to_S(_, false), give_up_const | views and claims go, the type stays (a block leaves the unit) |
Nothing | to_S_unguarded | everything goes: S(Value) |
Claims | unbox_to_S(_, true) | claims and views stay; a pool F moves to a spill home |
Arm for arm the transitions and emissions are the ones the four functions
performed (the GP-resident flush under every policy, the resident drop only
under All, clear where the old bodies cleared), so the change is
byte-identical: emit-asm dumps of the nine benchmarks match master, the JIT
lib tests, the float / block / loop integration tests and the full cargo test
suite pass. The *_state analysis-half split of the two old functions
(write_back_slot_state, to_S_unguarded_state) is folded in as well: the
Spill record is still computed by the state transition and emitted through
ir.spill, so analysis mode still emits nothing.
49. phys-loop-aware removed
The §42 loop-aware spill-victim policy (loop_carried on SlotState, filled
at the loop-entry merge from the back-edge fixpoint; the phase-1 filter that
kept a loop-carried Sf cache resident) is deleted along with its Cargo
feature. It was default-off, not built by CI, and — as §42 itself records —
inert under the shipping POOL=14: phase 1 runs only when no pool register is
vacant, so the lever bit only under stress-spill-pool or a ≈14-live-float
loop, and its M1 A/B (§27.3-2c) was never run. The L-collection timing
finding (§42: the multi-iteration fixpoint makes the back-edge available at
merge time) stays in the record for whoever revisits loop-aware allocation;
the code it justified no longer earns its field in the per-path state.
50. pending_outer_float_reads removed: the mark lands at the consumption
§46 kept the stage-A report queue on the grounds that the raw-f64
consumption sites (load_fpr_state and the float binop helpers) were
AbstractFrame methods that only see their own frame, so the owner frame of a
dynvar-loaded value could not be marked there and the pair had to wait for the
next compile_instruction boundary, where the JitContext holds the chain.
The cheaper move is to put those consumption sites on the chain. binop.rs’s
single impl AbstractFrame block is now impl AbstractState, and load_fpr /
load_fpr_state moved with it; every field and frame-level method they use
still resolves through AbstractState’s Deref/DerefMut to the innermost
frame, so the bodies are unchanged. AbstractState::use_as_float then does
what the drain did — look up the slot’s dynvar_src, resolve outer against
the chain, mark_outer_float_read — right at the consumption, and the frame
keeps only the liveness half (use_as_float_liveness). The queue, its drain,
its join concatenation and take_pending_outer_float_reads are gone, and
SlotState is down to slots, fpr_alloc, gp_regfile, local_num.
Why the timing change is safe: outer is resolved against the chain as it
stands at the consumption, which is the chain the LoadDynVar recorded the
provenance under (a nested compile pushes and pops its frames inside the same
instruction, and a LoadDynVar’s consumer is a later instruction of the same
frame). The mark is a monotone hint whose readers run at merges and
boundaries, after the instruction that would have drained it, so seeing it one
instruction earlier changes nothing they compute; and a read consumed by an
instruction that ends a block is no longer parked on a queue that a merge
concatenates — the owner frame simply carries the bit into the join, which
ORs it exactly as the concatenated queue’s drain would have.
Verified byte-identical: emit-asm dumps of eight benchmarks match master;
app_aobench differs only at the site master itself emits differently from run
to run (§46). The JIT lib tests, the float / block / loop integration tests and
the full cargo test suite pass.
Trace-chain joins — one AbstractState for the whole specialization stack
Kind: design record. Why the JIT’s abstract state spans every frame of the trace being compiled — suspended method callers included — and how the merge machinery (join / equiv / bridge), specialized returns, and the outer-frame float claims all ride that one chain. This records the design as landed and the alternatives that were tried and rejected on the way.
The observation driving it: joining the frames suspended mid-trace and joining the lexical outer scopes are the same operation. Every mechanism below is an instance of letting the ordinary state machinery — clone at a branch, join at a merge, bridge the difference — do a job that previously had a bespoke side channel.
1. The chain
AbstractState holds a Vec<AbstractFrame>, 1:1 with JitContext’s
specialization stack (stack_frame): the unit root at position 0, the
innermost (currently compiling) frame last. This is the trace chain,
not the lexical chain — a specialized method caller like Integer#times
sits in it even though it is nobody’s lexical outer. Each frame carries
lexical_outer: Option<usize> (the state-side twin of
JitStackFrame::outer); dynvar addressing walks those links
(outer_level), while joins, equiv, and bridges walk every frame of
the chain regardless of lexical reachability.
A nested specialized compile enters with the caller’s live chain
(specialized_compile passes state.frames_cloned() to
AbstractState::with_chain), so what the callee’s compile believes about
the outer frames is exactly what this call path believed at the call —
per branch, not a checkpoint from frame entry.
2. Returns are merge edges
A plain Ret in a specialized callee is not a terminator; it is an entry
edge of the caller’s continuation. Each one branches to an outline return
segment recording the full chain at that return
(record_return_edge); after the callee’s body is compiled,
build_return_segments joins the edges’ chains (join_no_alloc) and
emits, per edge, the bridge from that edge’s state to the join, the
return-value load, and the actual Ret.
The bridge is where the kept-constant discipline becomes per-path. A
caller may keep a C(v) claim on one of its slots across the call (the
slot unwritten, the constant folded into the callee); a return path on
which the callee’s subtree gave the claim up meets C ⊔ S → S at the
join, and that path’s segment emits the literal write
(chain-addressed StoreDynVarSpecialized) — the surrender write — so
every resuming path arrives with the slot current. A path that kept the
claim writes nothing. Before this, keeping a constant required the claim
to survive every path or be given up everywhere.
3. The resume asymmetry
When specialized_compile returns, the caller resumes from the join of
the return-path chains — with one deliberate asymmetry, learned by
bisection (see §7):
- Outer levels (everything below the caller) take the joined chains whole. Their own-timeline bookkeeping is not consumed here; each becomes “the resuming level” only at its own caller’s resume, where this same rule applies.
- The caller level keeps the frame it parked at the call (its pc,
liveness, hints, and invariant flags belong to its own compile
timeline) and overlays only the slot claims the join validated
(
overlay_kept_constants): keptCs (sound because every return path either held the claim or emitted its surrender write), spill-homedSf(Float)promotions the subtree made, and the monotonesubtree_float_readmark bits.
If the callee compiled no plain Ret (every path raises, breaks, or
returns non-locally), the caller’s continuation is unreachable on
compiled paths. The resume state is then rebuilt from a copy of the
call’s own entry chain, re-widened by the callee-era delta of the
widened_outer_log. Any claim that placeholder still carries is
vacuous — no execution arrives through it — and a later merge’s claims
are established by its reachable entries’ bridges.
4. Invariants travel lexically on the live chain
The frame-chain no-capture invariant (no_capture_guard, licensing
static chain addressing) is maintained on the live chain by a lexical
walk: a call that may capture unsets it up the lexical links
(unset_lexical_no_capture_guard), and the capture guard emitted after
the call re-proves it up the same links
(set_lexical_no_capture_guard) — sound because
branch_if_captured‘s meta check also catches an ancestor
move_frame_to_heap (a captured ancestor tombstones the frame). The
first cut unset the invariant on every chain frame but re-proved only
the innermost, which one-way-ratcheted the outer frames’ invariants
false and silently degraded every chain-addressed load behind them back
to the generic walk — found by instruction-count probes, +22% on the
block-read benchmarks.
5. Outer-float claims as ordinary state
The outer-frame float roadmap (write-through keeps, store-driven S→Sf
promotion with spill homes, home-directed reads, home-aliased bare-F
reads, and loop-entry adoption) originally kept several side channels on
the parked frame copies. Each has since become ordinary state:
- Read decisions consult the live chain (
outer_sf_float,outer_no_capture_guardonAbstractState). - Float-read marks (stage A: an inlined callee consumed an outer
slot’s value as a raw f64) land on the owner’s frame in the state
(
mark_outer_float_read) and travel like any monotone hint — ORed through joins, carried by return chains, merged by the resume overlay. - Loop-entry adoption (stage C) reads the marks off the loop’s
back-edge state at the merge, minus what the incoming state already
had; the loop analysis exports only its vetoes (the claim barrier /
generic-yield poison and the outer widens its walk performed). The
adopted
Sf(Float)binds on the live loop-entry state; its scope is handled by the joins themselves — a path bypassing the loop head meets the claim back toS— so no revert machinery exists.
6. What the chain-wide join subsumed
converge_block_entry — a probe fixpoint that compiled a yielded block
into a throwaway context to discover which outer constants it gives up,
treating “this block may be entered again” as a back edge — was deleted
outright. Every re-entry path is a join the chain already covers:
- a yield inside the compiling unit’s own loop demotes outer
Cs at that loop’s back-edge join (analyse_backedge_fixpointjoins every frame of the chain); - straight-line repeated yields thread the widen through the live chain from one yield to the next;
- a re-entry through a loop outside the chain can only be a generic
yield, where the caller’s
forget_constantsbet-confirmation drops every claim.
With it went the last cross-frame readers of the parked copies’
constant claims (outer_const_count, adopt_outer_widenings,
lost_constants_of) — and one probe compile per specialized yield.
7. What the parked copy still is
JitStackFrame.abstract_state — the frame a compile parks when it
suspends for a nested compile — is no longer a truth channel for
cross-frame claims. Its one remaining job is the suspended frame’s own
resume record: the caller level resumes from it (§3), and subtree
events that must reach that record do so under strict monotone rules —
widens (widen_outer_at_pos, dual-written with the live chain and
logged) and capture unsets.
The two roles that outlived the join unification were retired by the
frame-global home ledger (JitStackFrame::spill_home_watermark). A
persistent raw-f64 home id is a physical resource — a stack slot in the
owner’s frame, baked into callee code as an address — not a per-path
belief, so its id space cannot live in the per-path-cloned state; the
parked copy had provided it by accident of being one-per-frame. The
ledger is context-side (one per frame, never cloned per branch; analysis
clones inherit the mark and their bumps die with them), promotions and
adoptions issue max(ledger, the allocating path's file length) and
bind on the live chain only, the stage-2 gate reads the live chain, and
on resume every frame’s file is grown to its ledger mark. That growth —
together with capping the allocator’s vacant-pick and demote-victim
phases at the physical pool — closes a reuse hazard the parked scheme
left open: spill-resident values are not saved across calls, and callee
code that established a home keeps writing it at runtime, so a home id
that went vacant must never be re-issued to a transient.
Never publish a nested compile’s view of a frame into its parked slot: that replaces the state the suspended frame will resume from with a view taken at a different program point, in a different frame’s terms — three levels of nested blocks segfaulted in generated code when this was tried.
8. Rejected alternatives
- Full-chain replacement at resume. Replacing the caller level with
the callee’s joined view of it broke real code (
Integer#downtoresumed aC(0)where the parked frame held a runtime value;Array#permutationreadnil). Bisection modes that kept the parked caller level and overlaid only validated claims passed — hence §3’s asymmetry. The underlying reason:AbstractFrame/SlotStateinterleave slot claims with own-timeline metadata (pc, liveness, guard hints, the FPR allocator), and only the claims are validated by the return join. - Marks inside the
IsUsedlattice. Folding the stage-A float-read marks into the existing use/type lattice perturbed tuned owner policies (+7% on mandelbrot); the marks are a separate bitvec with a single consumer instead. - Invariants restored from the parked copy at nested entry. Worked, but treated the symptom of the one-way ratchet (§4); replaced by the lexical unset/re-prove pair, after which the restoration was removed.
- Capture events as an adoption barrier. Not needed: the runtime capture guard (with its tombstone check, §4) already covers ancestor promotions, so stage-C adoption gates on the invariant rather than poisoning the whole loop.
- Dead outer-home store elimination. A whole-tree use scan at
resolve time (collect every home the finished compile reads — chain
reads, the owner’s own references, exit write-backs — and elide
StoreOuterFprHomeFs to homes read nowhere) was built and probed, and found no targets: a store to a constant never exists (the kept-Cmachinery folds the claim and surrenders per return path, §2), and every surviving non-constant home had migrated to a pool register by codegen time — the owner, once resumed as the innermost frame, re-placesSf(spill)claims into the pool at its own merges (allow_fpr), and pool-home refreshes already ride the save-set channel, whose shrunk-set case elides them. This held even understress-spill-pool(67 spill promotions, zero spill stores emitted, across the outer-float suite). Pool-home dead-store analysis would need binding-sensitive (per-store, flow-sensitive) liveness — pool ids are reused constantly, so id-level liveness says nothing — and was not pursued. The machinery was reverted; this note is what it bought.
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
trueif inlining succeeded, otherwisefalse. - 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 (inrax) 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
falseto indicate inlining failed. - We then check the abstract state of the receiver. If we can determine it’s definitely
nilor definitely notnil, 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
raxto the accumulator and returntrueto indicate successful inlining.
Argument Forwarding (def f(a, ...) g(...) end) の最適化
本書は argument forwarding(...)の最適化について、設計方針と実装済みの
機構を現行コードに紐づけて記述する設計記録である。原則・段階分け・deopt
安全性を中心にまとめる。
行番号は変動しやすいため、参照は原則としてファイル名+関数名で示す。
1. 素朴に実装した場合のコスト構造
... は prism から ParamKind::Forwarding(ast/node.rs)として取り込まれ、
globals/store.rs の ParamKind::Forwarding アームで
- 合成 rest スロット
- 合成 kw_rest スロット(
SlotId(1 + args_names.len())) - 匿名 block パラメータ
へ脱糖される(ParamsInfo::forwarding = true、globals/store/iseq.rs)。
g(...) 呼び出しは bytecodegen/method_call/arguments.rs の handle_forward
が、
splat_posに mother の rest を指す位置(純転送は(pos_start, 1, vec![0])、 先頭引数つきはsplat_pos.push(len)で末尾)、hash_splat_pos = [kw_rest]、BlockArgProxy(bytecodegen/inst.rs、エンコードはbytecodegen/encode.rs)
を持つ CallSite { forwarding: true } を生成する。
素朴な実装の実行時コストは 2 箇所:
- caller →
f:set_callee_frame_arguments(codegen/runtime/args.rs)。aを超える位置引数を rest Array に確保し、余剰 keyword を kw_rest Hash に確保する(fill_positional_args)。 f→g(...):is_simple_call(globals/store/function.rs)がhas_splat()により偽 → JIT は specialize 不可でAsmInst::SetArguments→jit_generic_set_argumentsの汎用パス(CallSiteInfoの実行時再解釈) へ落ちる。
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、eval、外側 proc、zsuper 等)
これは JIT が float を XMM に保持し deopt 時のみ stack へ書き戻す
WriteBack(codegen/jitgen.rs::gen_write_back_for_deopt、doc/jit.md)と
同型の「遅延実体化(lazy materialization)」問題である。実装はこの観察を
両ティアで別々に具体化している:
- JIT ティア — D1/K1 転送遅延(§3.4)。specialize されたトランポリンの rest/kwrest 確保を省き、呼び出し元フレームのスロット窓から直接読む。
- VM/汎用ティア — lazy
(...)呼出規約(§3.6)。rest スロットにFixnum(callid)マーカーを置き、転送時に元 caller のスロットを直読みする。
3. 実装状況
| # | 内容 | 状態 |
|---|---|---|
| 1 | f→g の specialize(eager: 実 Array に対する長さガード + インライン充填) | 実装済み |
| 1.5 | opt/post/rest 持ち callee 向け専用 runtime ヘルパ | 実装済み |
| 2 | mixed 経路の Vec 排除(SmallVec 化) | 実装済み |
| 3 | D1/K1: f 側 rest Array / kw Hash の確保省略(deopt 実体化つき) | 実装済み |
| 4 | super 暗黙転送(単一 splat 任意位置) | 実装済み |
| 5 | VM/汎用ティアの lazy (...) 呼出規約 | 実装済み |
| 6 | 転送を跨いだ trivial fold / frameless 展開 | 実装済み |
f → g の引数設定は codegen/jitgen/compile/method_call.rs::set_arguments
の 4 段階層に集約されている:
| 段 | 条件 | 生成 AsmInst | コスト |
|---|---|---|---|
| A | D1 source-routed(§3.4 が発火) | SetArgumentsForwarded { deferred_src: Some(..) } | ガード・フォールバック無し、確保ゼロ |
| B | eager(g は required(+opt) のみ、末尾単一 splat) | SetArgumentsForwarded { deferred_src: None } | 長さガード + miss 時フォールバック |
| C | g が opt/post/rest 持ち、単一 splat が任意位置(super 含む) | SetArgumentsForwardedHelper | 専用ヘルパ(汎用再解釈をスキップ) |
| D | 上記外(named kw を持つ callee など) | SetArguments | 従来の汎用パス |
3.0 前提: forwarding callee は常に specialize
compile_method_call(compile/method_call.rs)は params().forwarding() な
callee を is_C_immediate ヒューリスティックから除外し、無条件に
specialize する。トランポリン本体を同一コンパイル単位に取り込まない限り
§3.4 以降の跨ぎ最適化が成立しないため。
3.1 Increment 1 — f→g 呼び出しの specialize(Array は温存)
対象: forwarding g(x.., ...)(callsite.forwarding かつ末尾単一 splat
splat_pos == [pos_num-1]、先頭 lead_num = pos_num-1 個の通常引数 +
... rest)で、g の positional が required(+optional) のみの場合。純転送
g(...) は lead_num == 0 の特殊形として同経路に内包。
AsmInst::SetArgumentsForwarded の lowering は
arch/x86_64/compile/method_call.rs::jit_set_arguments_forwarded /
arch/aarch64/compile/…。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 両対応)
→ 長さガード(expected_len は即値)→ 転送 kw_rest が非 nil なら脱出 →
callee slot lead_num.. へ src 昇順 / dst 降順の 2 ポインタコピー → 成功
sentinel rax = NIL_VALUE。ガードミスは page1 の fallback: で既存
jit_set_arguments(jit_generic_set_arguments)へバイト一致委譲。
Array を温存するため deopt は自明に安全(インタプリタは実 Array を普通に 使うだけ)。
なお非 forwarding の素の末尾 splat(g(x.., *ary))にも同じ lowering を
適用するアームがある(item_check(*node) 形の再帰が該当)。
3.2 Increment 1.5 — opt/post/rest を持つ g(runtime ヘルパ方式)
rest 付き g(def g(a,*r) 等)は *rest 配列の新規確保が CRuby セマン
ティクス上不可避で、手書きアロケーション asm は GC / ライトバリア絡みで
破壊リスクが高い。よって専用 runtime ヘルパ方式:
runtime::jit_forwarded_set_arguments(jit_generic_set_argumentsと同 シグネチャ)。forwarding 形状(単一 splat、lead = sp)が静的に既知なの で、転送 kw が空の常套ケースは汎用set_callee_frame_argumentsのsplat_pos走査・余剰 kw 機構をスキップして positional buffer を直接構築 しfill_positional_args1(req/opt/rest/post を正しく処理)へ渡す。kw が 実際に転送される稀ケースは実証済み汎用関数へ委譲し、微妙な kw→rest セマン ティクスをバイト一致で保つ。AsmInst::SetArgumentsForwardedHelperの lowering はjit_set_argumentsと同一の asm 形状(レジスタ設定・rsp 調整・エラー処理)でcall 先のみ 差し替え。手書き asm ループ・アロケーションは追加しない。
3.3 Increment 2 — mixed 経路の Vec 排除
set_callee_frame_arguments の汎用 splat 分岐は g(x, ...) / super(x, ...)
等で呼び出し毎に Vec<Value> をヒープ確保していた。これを
smallvec::SmallVec<[Value; 8]> に置換し、引数列が短い通常ケースでヒープ
確保を消去(巨大引数列のみ heap へスピル)。分配ロジック
(fill_positional_args1)は不変で共有。
3.4 Increment 3 — D1/K1: f 側 rest Array / kw Hash の確保省略
当初計画で「最大の利得かつ最大のリスク」としていた遅延実体化。現行実装は
新しい LinkMode を導入せず、抽象状態の注釈(DeferredForward)+ 生成側
の拒否権という形に落ちている。
ゲート
構造ゲート — Store::forwarding_trampoline_rest(store/function.rs):
f が純転送トランポリン(def f(...) = g(...))であること。すなわち
req/opt/post が 0、rest あり、params().forwarding()、かつ本体が単一基本
ブロック(join が遅延スロットを観測し得ない)。転送呼び出しの個数は無制限
— 各 consume が source-route するか拒否権を行使するかのどちらかなので、
任意個数・混在でも安全。
呼び出し側ゲート — JitContext::forward_rest_deferral(jitgen/context.rs):
自フレームが specialize 済みであること、mother の callsite が
is_simple_call、hash_splat_pos と block_arg が無いこと。リテラル
keyword があれば K1 として rest と一括で遅延する。結果は
#![allow(unused)]
fn main() {
DeferredForward { rest_local, src, len, kw }
}
で、src/len は呼び出し元フレームの引数スロット窓(caller の
レジスタ番号)。f は自前の rbp を確立している(init_func の
pushq rbp; movq rbp, rsp)ので、直接の物理呼び出し元の rbp は f が
[rbp] に退避した値であり、source は [caller_rbp - rbp_local(src + i)]
に居る。トランポリンの入れ子で「遅延済み source をさらに遅延する」ことは
起きない — 親自身の転送 callsite は splat を持つため is_simple_call に
落ちるからである。
注釈 — LinkMode は変えない
AbstractState(jitgen/state/slot.rs)はフレーム入口で deferred_forward
を置くだけで、rest スロットの LinkMode は baseline の S のまま残す。
遅延が発火すれば caller 側の set_arguments が実 nil を物理的に書き
(GC 安全)、発火しなければ caller が普通に Array を作ってそのスロットに
入る。C(nil) にしないので、後者で書き戻しが実 Array を壊すことがない。
consumer(f 内の各転送)
set_arguments の A 段が AbstractState::deferred_rest_src() を引き、
req <= lead+len、post 無し、余剰は明示 *rest が吸収、転送 **kwrest は
nil、という条件で充填レイアウトがコンパイル時定数と示せたときだけ
ir.set_deferred_rest() を立てて caller 窓から直接読む。示せない場合は
ir.set_needs_rest_array() で拒否権を行使する(配列パス/ヘルパ/
汎用/native callee のすべてが拒否権を行使する)。
callee フレームの静的レイアウトは
FuncInfo::forwarded_deferred_layout → ForwardedLayout { from_src, none_fill, rest, kw_rest }。先頭引数は必ず req/opt に消費される
(reqopt >= lead_num)ので、*rest に入る値は caller の source 窓の
連続した末尾になり、create_array 一発で呼び出し元フレームから直接
構築できる(中間バッファ無し)。埋まらない optional スロットには None
(0)が入り、callee プロローグの CheckLocal が既定値式を走らせる。
producer(caller 側)
send_specialized が defer_rest = deferred_rest && !needs_rest_array を
計算する。すなわち1 つ以上の consume が source-route され、かつどの
consume も実 Array を要求しないときだけ set_arguments の rest 充填が
create_array を省略し、代わりに
- rest スロットへ実
nilを格納(GC 安全)、 write_back_rangeで source 窓をスピルしてメモリ常駐化 (routed read と deopt 実体化の両方がメモリを読むため)
を行う。**kwrest 側は TraceIr::CheckKwRest の空 Hash 生成
(jitgen/compile.rs)も省略され nil のまま。nil の hash-splat は全ての
consume 経路で「keyword なし」として扱われるので普遍的に安全。
K1 — リテラル keyword の同時遅延
X.new(a, k: 1) のように mother がリテラル keyword を渡す場合、
DeferredForward::kw = (kwrest_local, kw_pos, names) として kw も遅延する。
kw_forward_route(compile/method_call.rs)が callee の宣言 keyword と
静的に突き合わせ、route[i] = callee kw パラメータ i を満たす caller
スロット(省略可能 keyword が無ければ None → 0 埋めで既定値が走る)を返す。
必須 keyword が埋まらない、**kwrest を持つ callee、名前が合わない、と
いった場合は route 不成立で汎用へ。rest と kw は一括で遅延するか一括で
諦めるかのどちらかである(caller 側のスキップは 1 つのフラグが両方を覆う
ため)。
deopt 安全性
- (D1)
f内 deopt:WriteBackにforward_rest/forward_kwrestエントリが載り、gen_write_back_for_deoptがリテラル書き戻しの後にgen_forward_rest_materialize(create_array)とgen_forward_kwrest_materialize(runtime::correct_rest_kw)を走らせて 実体をスロットへ書く。呼び出し元 rbp は[rbp]から復元。順序を後ろに 置くことで、確保を伴う呼び出しの最中もフレームが GC 整合を保つ (未書き込みの遅延スロットは caller が入れたnilを保持している)。 aarch64 版はarch/aarch64/compile/mod.rs::a64_gen_forward_rest_materialize。 - (D2) 呼出ガード失敗: A 段は「レイアウトが定数」というゲートを通って いるので長さガードもフォールバックも持たない(失敗し得ない)。B 段の ガードはすべて callee フレーム書込み前にあり、ミスは無ロールバックで 汎用へ。
- (D3) 多重転送: 注釈は最初の転送で消さない。すべての consume と side exit が同じ注釈を参照する(拒否権も注釈が生きている間だけ意味を 持つ)。
*rest の意味論(毎回新しい Array)とも整合する — 実体化は常に新規確保。
3.5 Increment 4 — super 暗黙転送(単一 splat 任意位置)
jit_check_super が super 先 FuncId をコンパイル時解決し、
handle_super_forward(bytecodegen/method_call/arguments.rs)は
forwarding=true の CallSite を生成するため、super も同じ set_arguments
経路に乗る。
def m(a,b); super; end(splat なし)→ 既にis_simple特化済み。def m(a,*r); super; end(rest 末尾、sp == pn-1)→ Increment 1 系。def m(a,*r,z); super; end(rest の後ろに post、sp != pn-1)→ ヘルパゲートをsplat_pos.len() == 1(任意位置の単一 splat)へ一般化し、jit_forwarded_set_argumentsの fast path が lead[0..sp] ++ splat配列 ++ post[sp+1..](汎用 splat 分岐とバイト一致の 順序)を直接構築する。
zero-alloc の inline 路は trailing + required-only のまま据え置き(post を 跨ぐ asm は複雑化=リスクのため安全なヘルパへ誘導)。
3.6 VM/汎用ティア — lazy (...) 呼出規約
JIT が specialize しない経路(インタプリタ実行、汎用 set_arguments)でも
rest Array を作らずに済ませる規約。codegen/runtime/args.rs。
ゲート — Store::lazy_forwarding_rest: 構造ゲート
(forwarding_trampoline_rest)に加え、ISeqInfo::forwarding_no_escape が
真であること。後者は bytecodegen/encode.rs::forwarding_no_escape が
バイトコード列から前計算する述語で、
super(zsuper はメソッドフレームの全パラメータスロットを読む)yield- ブロックリテラル(
callsite.block_fid。ブロック本体に zsuper があると 外側チェーン経由で親のスロットを読む) - 保守的に
defined?(super)/defined?(yield)
のいずれかを含む本体を失格にする。結果は ISeqInfo::lazy_forwarding_rest
にキャッシュされ、実行時は単なるフィールドロードになる。
エントリ — set_frame_arguments: 呼び出し側が平坦(splat / kw /
hash_splat なし)なら、rest Array を作らず callee の rest スロットへ
Fixnum(callid) マーカーを、**kwrest へ nil を書いて終わり。
解決 — resolve_lazy_forwarding: 転送 callsite の splat スロットが
マーカーなら、lazy_marker_source が cfp チェーンを辿って元 caller の
フレームと callsite を特定し、その引数スロットを直読みして
lazy_forward_fill で callee フレームを直接埋める(lead ++ 元の引数列 ++ post の順は実体化パスとバイト一致)。fast gate を外れる形
(keyword を取る callee、block 形 callee、g(*a, ...) のような追加 splat)は
materialize_lazy_at_callsite がその場で実 Array を作り、トランポリンの
rest スロットと splat 引数スロットの両方に書いてから実証済みの汎用機構へ
渡す。
マーカーの誤認防止: splat スロットの Fixnum がマーカーであるのは
「呼び出しフレーム自身が lazy 資格を持つ」場合に限る。def m(*r); r = 7; super; end のような再代入済み名前付き rest も splat スロットに Fixnum を
置くが、そのフレームは lazy 資格を持たないので通常のスカラ包み込みに落ちる。
加えてマーカーは末尾 splat にしか居ない(... は末尾必須)ので
trailing 判定も課している。
エスケープ: 文字列 eval(globals.rs)と Kernel#binding
(builtins/kernel.rs)は、コンパイル対象フレームから cfp チェーンを辿って
materialize_lazy_forwarding で全マーカーを実体化してから進む。lazy な
フレームはブロックリテラルを含み得ない(=Proc として生き延びない)ので、
cfp チェーンの走査で必要な範囲を尽くせる。
3.7 転送を跨いだ上位最適化
D1 注釈があると転送後の位置引数の個数が静的に確定する
(forwarded_trivial_pos_num)。これにより is_simple_call が門前払いする
forwarding callsite に対しても、
- trivial method fold(
ISeqHint::ConstReturn/SelfReturn)— 呼び出し 自体が消える。call を消すこと自体が転送の consume なので、ir.set_deferred_rest()を立てて caller 側スキップを維持する(さもないと誰も見ない Array を作る)。 - frameless な ivar ストア展開(
compile/frameless.rs::ivar_store_body)—ArgSlot::Caller/AsmInst::LoadCallerSlotで呼び出し元スロットを直読み して@a = a相当のストアを caller の命令として展開する。
が効く。Ruby レベルの Class#new は o.__builtin_initialize__(...)
(bypass_visibility 付きの forwarding call)というまさにこの形なので、
X.new(a, b) が allocate + ivar ストア 2 本にまで落ちる。これが実利上
最大の効果である。
ただしこれはトランポリンがそのコンパイル単位にインラインされたとき
だけ成立する(D1 の注釈を付けるのが specialize されたフレームなので、
specialization 深度上限を使い切った深い呼び出し位置では成立しない)。
そこで JitContext::inline_class_new は、呼び出しを一切出さずに済む形
(allocate に続けて fold、または ivar ストア展開の 2 択)に限って X.new を
呼び出しサイトで直接展開する。それ以外(native な initialize、展開できない
本体、ブロック付き、キーワード付き、splat、define_method の initialize
など)は Ruby の Class#new を通り、そこでの (...) 転送は D1 が畳む。
深い位置でも D1 が効くのは、forwarding hop が specialization 深度上限を
消費しないため(forward_exempt)。
4. フォールバック条件(汎用パス据置)
- named keyword パラメータを持つ callee への転送 /
super(K1 の静的ルーティングが成立する場合を除く) fがbinding/eval/ フレーム capture を含む(lazy 規約はforwarding_no_escapeで、JIT は capture ガードで排除)- 複数 splat(
g(*a, ...)) - ruby2_keywords: 呼び出し側が keyword 構文を持たない転送
(
ruby2_keywords def t(*args); super; endや、委譲ブロックのtarget(*args, **kwargs))。フラグ付き末尾 Hash の keyword 昇格 (r2k_promote)を fast path が実装していないため、汎用パス必須。 single_arg_expand(block 形 callee)対象の転送gが単相に未解決(megamorphic / 未キャッシュ)
5. 主な実装箇所
| 箇所 | 内容 |
|---|---|
globals/store.rs(ParamKind::Forwarding) | ... の脱糖 |
bytecodegen/method_call/arguments.rs | handle_forward / handle_super_forward |
bytecodegen/encode.rs::forwarding_no_escape | lazy 規約の本体側ゲート(前計算) |
globals/store/function.rs | is_simple_call / forwarding_trampoline_rest / lazy_forwarding_rest / forwarded_deferred_layout |
codegen/jitgen/context.rs::forward_rest_deferral | D1/K1 の呼び出し側ゲート、DeferredForward |
codegen/jitgen/state/slot.rs | 遅延注釈と deferred_rest_src |
codegen/jitgen/compile/method_call.rs::set_arguments | 4 段の分岐、kw_forward_route、拒否権 |
codegen/jitgen/compile/method_call.rs::send_specialized | defer_rest の確定(producer) |
codegen/jitgen/compile.rs(CheckKwRest) | 空 Hash 生成の省略 |
codegen/jitgen/asmir.rs | SetArgumentsForwarded / …Helper / LoadCallerSlot |
arch/{x86_64,aarch64}/compile/… | 上記の lowering、deopt 実体化 |
codegen/jitgen.rs::gen_write_back_for_deopt | forward_rest / forward_kwrest の実体化 |
codegen/runtime/args.rs | 専用ヘルパ、lazy 規約(マーカー / 解決 / 実体化)、SmallVec 化 |
globals.rs(eval)・builtins/kernel.rs(binding) | マーカーの強制実体化 |
6. 検証
monoruby/tests/method_call.rs—forwarding1..3/forwarding_super/anonymous_block_forwarding1..4/anonymous_rest_forwarding/forwarding_specialized_*(inline / heap / zero-arity / arity 不一致 フォールバック / kwargs フォールバック / block 透過)/forwarding_leading_*。monoruby/tests/kwargs_forward.rs— K1(X.new(...)形のリテラル keyword)。monoruby/tests/forwarded_block_yield.rs— block 透過・break・非局所return・二段ホップ。monoruby/tests/ruby2_keywords.rs— 汎用パス据置の担保。codegen/jitgen/compile/method_call.rsのインラインテスト —forwarded_opt_callee/forwarded_rest_callee/forwarded_struct_rest_native/trivial_forwarded_fold_and_redefine/deferred_construction_deopt(遅延中の deopt で実体化を踏む)。codegen/runtime/args.rsのインラインテスト —lazy_forwarding/lazy_forwarding_escape(binding/evalによるマーカー実体化)/lazy_forwarding_class_new。- 経路の発火確認は
--features jit-log,emit-asm、確保ゼロの確認は--features gc-log、遅延実体化の踏み込みは--features deoptで行う。 - GC 絡みの回帰は
GC_STRESS=1(手動gc-stressワークフロー)で確認する。 遅延スロットは常に物理的なnilを保持し、source 窓は cfp チェーン上の caller フレームにあるので、両者ともスキャン対象である。
参照 Ruby について
単一コードのテストヘルパは monoruby/tests/ruby_oracle.tsv のスナップショット
オラクルを再生し、ミス時のみ実 ruby を起動する(CLAUDE.md 参照)。keyword を
印字する比較は vendored pin(現在 4.0.6)に一致する CRuby が必要で、
MONORUBY_TEST_ORACLE=ruby で全件を実 Ruby に対して取り直せる。positional
転送のみのケースは古い Ruby でも検証できる。
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. Current as of the loop-JIT entry
pin (#1176).
The two trees are mirrors of each other — same file names, 20 files and ~12.1 k lines each:
monoruby/src/codegen/arch/{x86_64,aarch64}/
codegen.rs compile/ guard.rs invoker.rs jit_module.rs
vmgen.rs vmgen/ wrapper.rs
- Shared front-end + dispatcher:
monoruby/src/codegen/jitgen/(TraceIR → AsmIR) andjitgen/asmir/compile_shared.rs(the arch-neutral AsmInst lowering dispatcher).
Line-number links below are omitted on purpose: both backends have been re-split since this document was first written (
#994broke the aarch64compile.rs/vmgen.rsmonoliths into thecompile/andvmgen/directories above), and the previous revision’s line anchors had all gone stale. Grep for the function names instead.
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#704aarch64 lowers everyAsmInstand every side exit, so it never bails out of JIT compilation. The sections below describe the current, bail-free state; §4 covers the asymmetry that does remain (recompilation strategy — 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
(jitgen/asmir/compile_shared.rs),
which lowers each AsmInst by one of two routes:
- Shared arm — the
matchincompile_asmirhandles 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. - Per-arch arm — the
other =>fallthrough callscompile_asmir_arch, the backend-private match (compile_asmir_archin each backend’scompile/mod.rs). On both arches this handles only the same five specialized inlined-frame variants (LoadCallerSlot,GuardClassVersionSpecialized,GuardConstVersionSpecialized,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
...-forwarding deferral, once disabled upstream for aarch64, is lowered there as well (a64_set_arguments_forwarded_deferred).
There is no return false anywhere in the aarch64 lowering (compile/,
guard.rs), and compile_asmir_arch’s wildcard arm is unreachable!(). 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 thea64_frame_*/a64_sp_*/a64_addr_*helpers incompile/mod.rs. - RValue heap-field offsets (inline/heap ivar & struct-slot access) — same scratch-materialization treatment.
- Float
FloatBinOp/FloatUnOp— the fullBinOpK/UnOpKset is lowered (the old port handled onlyAdd|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 byforward_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 strategy (not coverage)
One mechanism still differs — recompiling already-emitted code on a class-version miss, and only for non-specialized frames. It is not a coverage gap: where x86 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) differs. §4.2 records an eviction asymmetry that no longer exists.
4.1 Class-version-miss recompilation
The guard itself is symmetric now. Both guard_class_version and
a64_guard_class_version compare the global version word against the unit’s
patchable snapshot word (the class_version_label jit_compile creates), so
a successful salvage re-validates the unit’s code in place on either arch by
storing the current version into that word (Codegen::set_class_version). An
earlier revision of this document quoted an aarch64 comment saying “we do not
recompile on miss yet — just deopt”; that text is gone.
The recovery jump-back is ported too: both arches now have a
jit_recompile_method_with_recovery (the aarch64 one returns a tri-state —
salvaged / recompiled / recompile-panicked — since aarch64 surfaces a
recompile panic as a Ruby FatalError, which x86 does not). On either arch a
non-specialized class-version miss whose salvage succeeds jumps straight back
into the compiled body; only a genuine change pays the deopt. The aarch64
call helper saves the full x86 save_registers equivalent (x1-x8 + d2-d7;
d8-d15/x19-x28 are callee-saved under AAPCS64, and x0 is the return register
and dead at a guard, like rax on x86).
Specialized frames are symmetric: the specialized class-version guard
recompiles on both arches. x86 uses guard_class_version_specialized /
gen_recompile_specialized; aarch64 uses GuardClassVersionSpecialized /
RecompileDeoptSpecialized → a64_call_recompile_specialized, which rewrites
the specialized body’s SpecializedCall bl.
4.2 On-stack eviction (BOP redefinition) — no longer asymmetric
This section used to record an asymmetry: x86 recorded a return-address patch
point at every call site and, on BOP (basic-op) redefinition, wrote a jmp
into the live return path so the suspended frame deopted; aarch64 did that for
specialized calls only, and relied on the inline class-version deopt for the
rest.
Both halves are gone. Every call/yield site on both arches now records its
return address (set_deopt_with_return_addr) purely as a key, and BOP
redefinition runs the arch-neutral chain-deopt walk (Codegen::chain_deopt_into),
which converts each suspended JIT frame into an interpreter frame from the
stack alone — no code is patched, on either arch. See doc/chain_deopt.md
§10.
5. Guard logic comparison
| Guard | x86-64 | aarch64 |
|---|---|---|
guard_class immediates | Fixnum/nil/true/false/bool/symbol/float via testq/cmpq | same set via tbz/tbnz/cmp |
guard_class heap | guard_rvalue (low-3-bits + class compare) | a64_guard_rvalue (same logic, and/cbnz/ldr w) |
guard_class2 (BigNum→VM) | yes, from the monomorphic method-entry patch path (codegen/patch.rs) | yes — a64_guard_class2, from wrapper.rs; only INTEGER_CLASS differs |
guard_array_ty | yes (ObjTy::ARRAY at RVALUE_OFFSET_TY) | yes |
guard_capture | yes (branch_if_captured) | yes |
float_to_f64 unbox | yes (flonum / heap-Float, 0.0 sign-bit trick) | yes (mirrored) |
| class-version guard | unit snapshot word + recovery jump-back (§4.1) | same — recovery jump-back ported (§4.1) |
| eviction on BOP redefinition | arch-neutral chain-deopt walk, no code patching (§4.2) | identical (§4.2) |
deopt recording (deopt / profile) | log_deoptimize from every deopt handler, per-guard trampolines, class-guard miss recorder | log_deoptimize from every deopt handler; no trampolines (guard: unknown, see doc/deopt_log.md) and no class-guard miss recorder |
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-64 | aarch64 | |
|---|---|---|
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, which GP_ALLOC_POOL = &[] now makes unconditional on both
arches (LinkMode::G was abolished; jitgen/gp_alloc.rs drives GP reuse
locally instead, identically on both).
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.
5c. Other current asymmetries
Neither a coverage gap nor a guard difference, but worth knowing:
| x86-64 | aarch64 | |
|---|---|---|
| Installing / re-pointing JIT code | patches branches in place (apply_jmp_patch_address, the patch_point call) | indirect heap slots — ISeqInfo::jit_slot and jit_guard_free_slot are #[cfg(target_arch = "aarch64")] fields — plus Codegen::patch_call_to_entry, a single bl rewrite under the MAP_JIT writable/executable flip |
| Cold-code placement | cold handlers go on page 1 (select_page, ~90 sites) | page 1 is past B/BL range from page 0, so cold blocks are laid inline (~7 sites). In exchange aarch64 has an optimization x86 has no use for: AsmIr::as_pure_deopt / pure_deopt_target (#[cfg(target_arch = "aarch64")]) emit a deopt-only block’s handler at the block label, so predecessors branch straight onto the deopt code |
| Branch range | never a constraint | a large loop body can put a TBZ/TBNZ further from its deopt than imm14 (+/-32 KiB) reaches, which panics the emit. jit_compile_loop catches it and leaves the codeptr unpublished; a64_op_loop_start’s tri-state slot (0 / 1 sentinel / codeptr) stops the retry loop |
RecompileDeopt error exit | error: None | needs Some(ir.new_error(state)) — a recompile-time panic surfaces as a Ruby FatalError to branch to (jitgen/compile.rs) |
| GC frame tracing | alloc.rs’s record_frames walks rbp via inline asm | not implemented (x86-only debug aid) |
Loop-JIT entry sp | lea rsp, [rbp - depth] | sub+mov sp to the same depth |
The last row is symmetric by design as of #1176: both pin the entry to
total - PROLOGUE_OVERHEAD rather than subtracting from the sp they inherit,
because the frame may have been built by either the VM’s init_method or a JIT
prologue and only the latter has already reserved the unit’s spill region.
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.
- Recompile behavior is symmetric (§4.1): a salvaged class-version miss resumes compiled code in place on both arches; a genuine change recompiles and pays one deopt.
- A very large loop body may stay interpreted on aarch64 (§5c, branch range). x86 has no such limit.
One-line summary
x86-64 and aarch64 share the entire AsmIR front-end and full AsmInst coverage — aarch64 lowers everything (large immediates via scratch registers), so the
boolbail return is vestigial. What remains is non-coverage: different code-installation mechanisms (branch patching vs indirect slots) and cold-code placement (page 1 vs inline, which buys aarch64 theas_pure_deoptcollapse), and aarch64’s branch-range ceiling on very large loop bodies (§5c). Guards, the recovery jump-back, BOP eviction, block/method inlining and the GP pool are all symmetric.
JIT の不変条件 — 何を前提に投機し、破れたらどう戻るか
monoruby の JIT は、コンパイル時点でまだ真である事実をコードに焼き込む。
「この呼び出しは FuncId(42) へ行く」「この定数は 3 である」「この足し算は
組み込みの Integer#+ である」「このスロットは常に Float である」——
どれも Ruby が実行中に覆せる。覆されたまま走り続ければ JIT コードは
インタプリタとの等価性を失う。
したがって投機ごとに次の 3 点が必要になる:
- 前提(不変条件)を明示する — 何を真だと仮定したか。
- 破れを検知する — ガード、あるいは外部イベントからの通知。
- 整合性を保って戻る — 生成コードが持っていた状態(レジスタ内の値、 アンボックスされた float、遅延したオブジェクト確保)をすべてフレームへ 書き戻し、正しい pc からインタプリタを再開する。実行中の呼び出し元 フレームも同時に救出する必要がある。
本書は monoruby がいま持っている不変条件を一覧し、それぞれの検知と復帰を 実装に即して記述する。個別の機構には専用ドキュメントがあるので、詳細は そちらへ譲る:
jit_invalidation.md— バージョンカウンタと salvagebop_redefinition.md— 基本演算の再定義chain_deopt.md— 呼び出し元チェーンの一括変換deopt_log.md—--features deoptの読み方refinements.md— refinements とメソッド解決safepoint.md— GC/preempt ポーリング(同じ write-back を共有)jit.md— スタブとクラスガードチェーンの形
1. 三つの道具
不変条件を守る手段は 3 つあり、どれを使うかは「破れがどこから来るか」で 決まる。
| 道具 | いつ使うか | 例 |
|---|---|---|
| ガード(guard) | 生成コード自身が実行時に確かめられる | クラスガード、型ガード、バージョンワード比較、キャプチャガード |
| 無効化(invalidation) | 外部イベントが起きた時に Rust 側からコードを差し替える/捨てる | BOP 再定義によるエビクション |
| 投機の放棄(bail / 前提の成立) | そもそも守れない、または安く成立させられる | eval を呼ぶ callee は specialize しない/プロローグで ivar テーブルを拡張する |
3 つ目には二種類ある。放棄は「投機しない」であり、 前提の成立は「ガードする代わりに、入口でその条件を真にしてしまう」 である(§3.7 の ivar テーブル)。後者はガードより安いことが多い。
2. 不変条件の一覧
| # | 不変条件 | 破るイベント | 検知 | 復帰 |
|---|---|---|---|---|
| I1 | 呼び出し先の解決((recv_class, name, refinements) → FuncId) | def / define_method / attr_* / alias / undef / 可視性変更 / include / prepend / refine / using | クラスバージョンワードの比較 | salvage → 成功なら継続、失敗なら再コンパイル |
| I2 | 畳み込んだ定数の値 | 定数代入・remove_const・include/prepend | 定数バージョンワードの比較 | 2 段 salvage(名前エポック → 値比較) |
| I3 | 基本演算が組み込みのまま | Integer#+ 等の再定義 | CheckBOP(バージョン即値比較)+ 依存 iseq のエビクション | コード破棄 + 実行中フレームの chain 変換 |
| I4 | レシーバ/スロットの型・クラス | 別クラスの値が流れてくる、Fixnum のオーバーフロー、freeze | クラスガード/型ガード/jo | deopt |
| I5 | フレームが未キャプチャ | ブロック・Proc.new・binding・eval によるヒープ昇格 | GuardCapture(meta のビット検査) | deopt |
| I6 | eval 族がフレームを覗かない | eval / instance_eval / binding の呼び出し | コンパイル時に拒否(Effect::EVAL/BINDING) | そもそも投機しない |
| I7 | TracePoint が存在しない | (未サポート) | 検知機構なし | — |
| I8 | 呼び出しが単相 | 2 つ目のレシーバクラスの到来 | クラスガードのミス | 一度だけ再コンパイル(多相形へ)/以後は素の deopt |
| I9 | 遅延した実体(float・転送 rest/kwrest・定数)はフレームに無い | あらゆる側方退出・セーフポイント | — (破れではなく義務) | write-back で実体化してから戻る |
| I10 | GC がオブジェクトを動かさない | (現状の GC は非移動) | 検知機構なし | — |
I1〜I3 が「グローバルな再定義」、I4〜I5 が「そのフレーム固有の投機」、 I6〜I7・I10 が「そもそも投機しない/前提を置いている」もの、I9 は復帰の 手続きそのものである。
3. 個別の不変条件
3.1 I1: メソッド解決(クラスバージョン)
コンパイル時に (recv_class, name, refinements) → FuncId を解決してコードへ
焼き込む。この答えを変えうる全イベントが Globals::class_version_inc() で
グローバルなクラスバージョンを進める(def、define_method、attr_*、
alias_method、remove_method、undef_method、可視性変更、include /
prepend、refine / using)。
検知: コンパイル単位(ルート本体 + そこへインライン展開された全 specialize 済み callee)につきひとつのバージョンワードを持ち、単位内の 全ガードがグローバルカウンタとそのワードを比較する。ワードは即値ではなく メモリでなければならない(即値にすると salvage が無言で効かなくなる。 aarch64 で実際に起きた)。
復帰: カウンタはグローバルなので、無関係な def ひとつでも動く。そこで
ミスは即再コンパイルではなく salvage(修復)を試みる。単位は自分が
解決したメソッドを inline_cache_map に記録しており、全件を現在の
バージョンで再解決して答えが変わっていなければワードに現在値を書き込んで
コードを生かす。ruby/spec core で ~107,000 回の再コンパイルが ~320 回に
なった。詳細は jit_invalidation.md §1〜§3。
復帰の粒度: x86-64 のメソッド単位クラスガードだけは salvage 成功時に
その場で実行を再開する(jit_recompile_method_with_recovery が 1 を返し、
スタブの jnz recover が戻る)。それ以外は salvage が成功しても当該呼び出しは
一度 deopt し、次回から修復済みコードが走る。
3.2 I2: 定数の畳み込み(定数バージョン)
定数を畳み込んだ単位は、クラスバージョンとは別に定数バージョンワードを持つ。 ミス時の salvage は 2 段:
- 名前エポック — 各イベントは触った名前のエポックを進める。畳み込んだ 名前のエポックが一つも動いていなければ(ワイルドカードも動いていなければ) ルックアップ 0 回で確定。
- 値の再確認 — 触られた名前は VM 側インラインキャッシュと突き合わせる。
同じ値ならパッチして継続、違えば再コンパイル、キャッシュが古ければ
Defer(今回は deopt させて VM にキャッシュを温めさせ、次回再試行。stale_defers= 3 回で打ち切り)。
畳み込んだ Value は ConstSalvageMap::mark から GC ルートとして生かされる。
詳細は jit_invalidation.md §4。
3.3 I3: 基本演算(BOP)の再定義
1 + 2 を機械語の加算にインライン化してよいのは Integer#+ が組み込みの
ままだからである。この前提だけはバージョンガードでは修復されない。
検知: 二段構え。
- コンパイル時に
bop_deps: Vec<(ClassId, IdentId)>を記録する (「この本体はInteger#+をガード無しでインライン化した」)。 - 生成コードには
AsmInst::CheckBOPを置く。比較対象は コンパイル時に観測した BOP バージョンであって 0 ではない。ゼロ比較に すると、無関係な再定義以降すべてのdefで deopt し続けてしまう。
復帰: 再定義が起きると Store::evict_jit_code_for_bop(class, name) が
depends_on_bop(class, name) な iseq だけのコードを捨てる
(evict_jit_code)。Integer#+ をインライン化した本体は Integer#~ の
再定義では無傷であり、実メソッド呼び出しとして出た演算子は I1 の
クラスバージョンガードが既にカバーしている。
実行中フレームの救出: 捨てるだけでは、いまスタックで動いている JIT
フレームが古いコードを実行し続ける。Codegen::check_bop_redefine が
メソッド定義のたびに「今の定義が実際に BOP を潰したか」を確認し、潰していれば
chain_deopt_into(cfp) でスタック上の全 JIT フレームをインタプリタ
フレームへ変換する(§4.4)。BOP エビクションと側方退出のチェーン変換は
同一の walk を通る。詳細は bop_redefinition.md。
3.4 I4: 型・クラスの投機
インラインキャッシュから作った TraceIR は「このスロットは Integer」
「このレシーバは Foo」という型注釈を持つ。JIT はそれを信じてアンボックス
表現(LinkMode::F の生 float、タグ付き Fixnum の直接演算)や
クラス特化コードを出し、代わりにガードを置く。
| ガード | 破れ方 | 退出 |
|---|---|---|
GuardClass | 別クラスのレシーバ | deopt(呼び出しサイトでは §3.8 の再コンパイル付き) |
float アンボックス(GuardFloat 系) | Float でない値 | deopt |
Fixnum 演算のオーバーフロー(jo) | i63 を超える | deopt(インタプリタが Bignum へ昇格) |
GuardArrayTy | Array でない | deopt |
guard_frozen | 凍結オブジェクトへの ivar 代入 | deopt(インタプリタが FrozenError を上げる) |
| 引数形状ガード(転送の長さガード等) | 個数不一致 | deopt ではなく汎用ランタイムへフォールバック |
最後の行は重要な設計上の区別である。ガードがフレームを書き始める前に
すべて置かれていれば、ミスは deopt ではなく「同じことを汎用パスでやり直す」
で済み、ロールバックが要らない。引数設定の高速路(SetArgumentsForwarded
など)はこの形をとっている。
unfrozen_slots のような「直線コード内でだけ有効な証明」は基本ブロックの
合流で捨てられる(合流点では両側の事実が保証されないため)。投機的事実の
生存範囲を状態機械に持たせる、という一般則の一例。
3.5 I5: フレームのキャプチャ(クロージャ)
JIT はローカル変数をスタックフレーム上のスロットとして rbp 相対で読み書き
する。ところが Ruby はフレームをヒープへ昇格させられる:
ブロックや Proc が外側フレームを掴む、binding を取る、eval がフレームに
対してコンパイルする、など。
機構: Lfp::move_frame_to_heap がフレームの内容をヒープへコピーし、
cfp.set_lfp(heap_lfp) で正本を差し替え、元のスタックスロットに
tombstone(invalidated ビット)を立てる。以後スタック側を読む者は
cfp.lfp() 経由でヒープコピーへ転送される。外側フレームも再帰的に昇格する。
検知: AsmInst::GuardCapture が meta のビットを見て、昇格済み
(on_heap / invalidated)なら deopt する。tombstone ビットのおかげで
この 1 つの検査がレキシカルな祖先の昇格までカバーするので、ガードを
1 回通せば自フレームとその祖先すべてについて「未キャプチャ」を再証明できる
(set_lexical_no_capture_guard)。
抽象状態側: no_capture_guard は各フレームの invariant として持たれ、
ブロックリテラルを渡す呼び出しのたびに保守的に落とされ、ガード通過で
再び立つ。落ちている間、呼び出し結果の格納は rbp 相対ではなく
LFP(r14、呼び出し後に cfp.lfp から再ロード)経由になる
(def_return_store_guarded / def_rax2acc_capturing)。昇格が起きても
生きているフレームへ書き込むためである。
specialize の拒否: &block を転送する(BlockArg を持つ)callee は
インライン展開しない。specialize すると pop_frame が無いため、昇格後に
r14 がヒープコピーへ更新されず、以後のローカル読みが tombstone と
ヒープコピーに分裂する。
3.6 I6: eval 族
eval / instance_eval / class_eval / module_eval / Binding#eval は
Effect::EVAL、Kernel#binding は Effect::CAPTURE | Effect::BINDING を
持つ。possibly_capture_without_block() が真の callee を呼ぶサイトは
specialize を拒否する(CompileError → その呼び出しは通常ディスパッチ)。
これらはブロックを介さずに任意のフレームを覗ける=ローカル変数の配置に
関するあらゆる投機を無効化しうるので、ガードではなく投機の放棄で対処する。
加えて eval/binding は遅延実体化の強制解決も引き起こす:
文字列 eval(globals.rs)と Kernel#binding(builtins/kernel.rs)は
materialize_lazy_forwarding を呼び、cfp チェーン上の
lazy (...)-転送マーカーをすべて実 Array にしてから進む
(arg_forwarding_jit.md §3.6)。eval されたコードは
生のパラメータスロットに到達できるため、マーカーを残せない。
3.7 前提を成立させる例: ivar テーブルと ivar id
self のインスタンス変数は「クラスごとの IvarId スロット」で表される。
JIT が LoadIVarInline / StoreIVarHeap をガード無しで出せるのは、
その id までテーブルが確保済みだからである。
これはガードで守られていない。メソッドのプロローグ(AsmInst::Preparation)
が、self のクラスが持つ ivar 数からヒープテーブルの必要長を計算し、
足りなければ extend_ivar でその場で拡張する。以後の本体は境界検査
なしで書ける。frozen なクラスや inline 領域だけで済む場合は no-op。
コンパイル時に ivar id が引けなかった場合(まだ一度も代入されていない)は
RecompileReason::IvarIdNotFound で、VM のキャッシュが温まってからの
再コンパイルに委ねる。
3.8 I8: 単相性
単相前提でコンパイルしたサイトに 2 つ目のクラスが来ると、クラスガードが
外れる。ここは素の deopt ではなく SideExit::RecompileDeoptimize で、
ミスカウンタが尽きた時に多相形(クラス集合ガードまたは PIC)へ
一度だけ作り直す(RecompileReason::BecamePolymorphic)。作り直した
サイトにはレシーバクラスガードが無いので再発しない(単調・一回限り)。
対になるのがメガモルフィックゲート: クラスガードチェーンを全部外した
レシーバはインタプリタで実行され、スタブのウォームアップサンプラが 2 回
観測して初めてそのクラス用のコードを作る。この脱出先ラベルが未バインドで
分岐が no-op になっていた時、ミスしたクラス全部にコードを作ってしまい
ruby/spec が ~15,000 本 / ~300MB を吐いてタイムアウトした
(jit_invalidation.md §7)。
3.9 I7: TracePoint
monoruby には行・呼び出しイベントのフックが無い。Kernel#set_trace_func
は CRuby と同じ形(private instance method かつ module function)で存在
するが、ハンドラを受け取って返すだけの no-op スタブ
(monoruby/builtins/kernel.rb)であり、TracePoint クラスも無い。
これは単なる未実装ではなく、現在の最適化が寄りかかっている前提である。 JIT は次のように「呼び出しが起きた事実」そのものを消している:
- trivial method fold —
ISeqHint::ConstReturn/SelfReturnな callee の 呼び出しを丸ごと消す。 - frameless 展開 —
def initialize(a,b) = (@a=a; @b=b)をフレームを作らずに 呼び出し元の命令列へ展開する。 - specialize — callee をコンパイル単位へ取り込む(フレーム自体は作るので バックトレースは保たれるが、呼び出し規約は消える)。
イベントフックが入れば、これらは :call / :return / :line イベントを
落とすことになり等価性を失う。将来 TracePoint を実装する場合は、
「フックが有効か」を新しいグローバル不変条件として立て、有効化時に
全 JIT コードをエビクトする(I3 と同じ形)のが素直である。フックが無効な
限りコストがゼロという点でも BOP と同型になる。
3.10 I10: GC がオブジェクトを動かさないこと
JIT コードは畳み込んだ Value(定数、シンボル、ISeqHint::ConstReturn の
戻り値)を即値としてコードに埋め込む。現在の GC は非移動
(gc.md:非移動・単一スレッド・stop-the-world の世代別
mark & sweep)なので、これは安全である。埋め込んだ Value が回収されない
ことは、salvage レコードを GC ルートとして走査することで保証している。
移動 GC(コンパクション)を導入する場合、この暗黙の前提が破れる。焼き込んだ ポインタの一覧を持って更新するか、間接参照に変えるか、いずれにせよ本書の 表に新しい行が要る。
4. 復帰の機構
破れの検知はここまでで、以降はどう戻るかである。復帰は 4 つの部品から なる。
4.1 側方退出(side exit)の 4 形態
SideExit(jitgen/asmir.rs):
| 形態 | 使われ方 |
|---|---|
Deoptimize(pc, wb, chain) | 素の deopt。インタプリタで pc から再開 |
RecompileDeoptimize(pc, wb, reason, target, chain) | deopt に加え、カウンタが尽きたら target を再コンパイル(§3.8) |
Error(pc, wb, chain) | 例外送出。write-back 後に entry_raise へ |
Evict(..) | 歴史的な即時エビクション用。現在どこからも入らないが、AsmEvict が chain 変換のための返りアドレス登録キーなのでスロットは残る |
退出ハンドラはホットパスの外に置かれる(x86-64 はコールドページ page 1、 aarch64 は本体の後ろへ島として outline する)ので、ホットパスが払うのは 分岐命令 1 つだけである。
4.2 write-back — 何を書き戻すか
WriteBack(jitgen.rs)がその地点で「フレームに無い状態」を列挙する:
| フィールド | 内容 |
|---|---|
fpr | XMM/D レジスタにアンボックスで載っている float(f64_to_val でボックス化) |
literal | コンパイル時定数として持っていたスロット |
void | 未定義スロット(nil で埋める) |
gp | GP プール(x86-64 の r8–r11)に載っているスロット |
forward_rest | D1: 未実体化の ... rest Array(呼び出し元のスロット窓から create_array) |
forward_kwrest | K1: 未実体化の **kwrest Hash(correct_rest_kw) |
順序に意味がある。literal を先に書いてから forward_rest /
forward_kwrest を実体化する — 後者は確保を伴う呼び出しであり、その時点で
フレームが GC 整合でなければならないからである(未書き込みの遅延スロットは
呼び出し元が入れた物理的な nil を保持している)。
Loop JIT の rsp: write-back は Loop JIT のスピル領域を rsp が跨いだ状態で
先に行う。先に rsp を戻すと、ボックス化のための call が積む戻りアドレスが
スピルスロットを踏み、生きた float をコードポインタで上書きする。
4.3 pc の復元とインタプリタ再開
write-back 後、r13(プログラムカウンタ)に退出地点の pc を書き、
VM のフェッチループ(vm_fetch)へ jmp する。フレームのレイアウトは
VM と JIT で共通なので、フレームの作り直しは不要である。Loop JIT の場合は
ここでスピル領域分だけ rsp を戻し、VM の init_method が想定する深さに
合わせる。
4.4 chain deopt — 呼び出し元フレームの救出
自フレームだけ戻しても足りない。呼び出し元にも JIT フレームが積まれており、
それらもアンボックス float を抱えている。インタプリタになった内側フレームは
Load/StoreDynVar・binding・キャプチャ経由で外側のスロットに触れるので、
古いままでは壊れた値を読む。
runtime::chain_deopt は cfp チェーンを歩いて、各 JIT フレームについて
- スピルされたレジスタをスロットへ書き戻し(Rust 側で replay)、
- スタック上の返りアドレススロットを VM の継続スタブへ書き換える。
これで制御は二度と JIT コードへ戻らず、各 ret がインタプリタに着地する。
コードには一切触らないので、他のフレームや他スレッドに影響しない。
エスケープは個々の emitter が選ぶのではない: escalate_side_exits が
AsmIr に一度スタンプされ、すべての側方退出コンストラクタがそれを読む。
現在は無条件に真(実測で 1 回 ~160ns、最悪ケースでも 1.6% 以内)。
Error 退出も対象である — 例外はフレーム内で rescue されるかもしれず、
それは deopt と同じ「インタプリタでの再開」だからである。
一度変換済みのフレームは(返りアドレスが既に VM のものなので)スキップされる。
二重 replay は、その後インタプリタが更新した値を古い float で上書きしてしまう。
BOP エビクション(§3.3)もこの同じ walk を通る。変換機構は 1 つだけである。
4.5 コンパイルそのものの放棄
フロントエンドがコンパイルを諦めた場合(CompileError):
- メソッド単位の bail は iseq に
jit_invalidatedを立て、以後二度と コンパイルしない。 - ループ単位の bail はその地点をインタプリタのままにするだけ。
これは不変条件の破れではなく「投機を始めない」判断だが、同じ表に載せておく
価値がある。eval を呼ぶ callee(§3.6)や &block 転送メソッド(§3.5)が
ここに来る。
5. 実行中フレームがある場合の一般則
コードを捨てる・差し替える操作は、そのコードを今実行しているフレームが あるかどうかで難度が変わる。monoruby の答えは一貫している:
| 操作 | 実行中フレームへの対処 |
|---|---|
| salvage(I1/I2) | 不要。コードは生き続け、ワードを書き換えるだけ |
| 再コンパイル | 走行中のフレームは、再コンパイルを起動したその退出でインタプリタへ戻る(write-back 済み)。エントリのパッチポイントを新本体へ向け直すので、次回の呼び出し/次回のループ入口から新本体が使われる |
| BOP エビクション | 必要。走行中の本体は誤った演算をインライン化しているので chain_deopt_into で全 JIT フレームを変換する |
| 側方退出 | 必要。chain_deopt で呼び出し元チェーンを変換する |
MAP_JIT ページ(Apple Silicon)へ書き込む salvage は、パッチの直前に ページを書き込み可へ倒す必要がある。
6. 新しい投機を足すときのチェックリスト
- 前提を一文で書けるか。 書けないなら、それはまだ設計されていない。
- 破れうるイベントを列挙したか。 メソッド解決を変えうるなら クラスバージョンを、定数解決を変えうるなら定数バージョンと適切な エポック(名前、または名前が特定できないならワイルドカード)を進める。
- 検知はガードか無効化か。 ガードなら「フレームを書き始める前」に 置けるか(置けるならミスはフォールバックで済み、deopt が要らない)。
- salvage レコードを登録したか。 登録しないとガードミスが永久に 再コンパイルを呼ぶ。既存のマップに形の違う不変条件を相乗りさせない。
- 遅延した実体があるなら
WriteBackに載せたか。 側方退出だけでなく GC セーフポイントとフレームキャプチャも、その実体を要求する。 - バージョン比較の相手はワードか。 即値にすると salvage が無言で死ぬ。
- ガードのミス先ラベルを
cfgブロックの中でバインドしていないか。 未バインドの分岐は no-op になり、投機が全件通ってしまう。 - 両アーキで lowering したか。 x86-64 と aarch64 は全
AsmInstを ロワリングする(arch_difference.md)。
7. 観測のしかた
| 目的 | 方法 |
|---|---|
| どのガードが外れたか | --features deopt → deopt_log.md。guard: 欄が実際に分岐した箇所、cause: がその時の被検査値 |
| salvage と再コンパイルの収支 | --features jit-log → 終了時に jit_stats::dump() |
| deopt / 再コンパイルの統計 | --features profile |
| 生成コードそのもの | --features emit-asm |
fail: value changed が spec 一周で 0 のまま、というのが salvage 機構の
経験的な正当化になっている(定数イベントは実在したが、畳み込んだ値を
変えたものは一つも無かった)。
8. まとめ
- 不変条件は 3 つの道具で守る: ガード(コードが自分で確かめる)、 無効化(外部イベントがコードを捨てる)、放棄/前提の成立 (投機しない、または入口で条件を真にする)。
- グローバルなバージョンカウンタの破れは、まず修復を試みる。 カウンタが粗い(グローバル)以上、ミス=変更ではないからである。
- 復帰は「write-back → pc 復元 → チェーン変換 → VM フェッチ」の 4 段で、 どの退出形態も同じ道を通る。
- 実行中フレームを持つ無効化(BOP、側方退出)は、コードではなくフレームを 書き換えることで解決する。返りアドレスの差し替えが chain deopt の核心。
- TracePoint の不在は暗黙の不変条件であり、実装するなら BOP と同型の グローバル無効化として設計するのが自然。
- 非移動 GC も暗黙の不変条件である。
Invalidating compiled code — and repairing it instead
Compiled code bakes in answers that Ruby is allowed to change later: which
FuncId a call site dispatches to, what value a folded constant has, which
operator is still the builtin. monoruby guards those answers with two
global version counters, and a guard failure used to mean one thing —
throw the body away and compile it again.
That was far too blunt. The counters are global: any def anywhere moves
the class version, any X = 1 anywhere moves the constant version. A
program that keeps defining things (a spec suite, a Rails boot) moved them
constantly, so hot bodies were recompiled over and over even though
nothing they had cached had actually changed.
Salvage is the answer: on a guard failure, re-validate what the body
assumed. If every assumption still holds, stamp the current version into
the body and keep the code. Only a genuine change recompiles. On a
ruby/spec core run this turns ~107,000 recompiles into ~320.
This document covers what moves the counters, how a unit records what it
assumed, and how each guard failure is repaired. For the stub and
class-guard-chain shapes the guards live in, see jit.md; for
basic-operator redefinition — the one invalidation that is not repaired
this way — see bop_redefinition.md.
1. The two counters, and what moves them
Both live in Codegen (codegen.rs) and are read by compiled code as a
plain memory word.
Class version — Globals::class_version_inc():
| Site | Trigger |
|---|---|
Store::insert_method (globals/store/class.rs) | def, define_method, attr_*, alias_method |
Store::remove_method | remove_method, undef_method |
| visibility update (same file) | public / private / protected on an existing entry |
include_or_prepend_module (value/rvalue/module.rs) | include, prepend |
refinement import (builtins/module.rs), using (executor.rs) | refine, using — see refinements.md §6.1 |
Constant version — Globals::const_version_inc(), and the per-name
epochs described in §4:
| Site | Trigger | Epoch bumped |
|---|---|---|
Globals::set_constant | any constant assignment, including the first | that name |
Globals::remove_constant | remove_const | that name |
bare const_version_inc() (value/rvalue/module.rs) | include / prepend — resolution may change without any assignment | wildcard |
Note the third row: an event with no single name to attribute the change to bumps a wildcard epoch, which makes every unit’s fast path (§4) conservative until it re-validates.
2. One patchable snapshot word per compilation unit
A compilation unit is a root body plus every specialized callee inlined
into it. jit_compile (codegen/jitgen.rs) creates one class-version
word and — when the body folded at least one constant — one
const-version word for the whole unit:
#![allow(unused)]
fn main() {
let class_version_label = self.jit.const_i32(class_version as _);
let const_version_label = (!const_folds.is_empty())
.then(|| self.jit.const_i64(const_version as _));
}
Every guard in the unit, children included, compares the global counter
against that one word. Compilation is atomic at one version, so one word
is enough — and one store re-validates the entire unit. set_class_version
/ set_const_version (codegen.rs) are that store.
The snapshot’s representation differs per arch. aarch64 reads the word (
a64_guard_class_versiontakes the unit’s label). x86-64 bakes the snapshot into each guard as a patchable imm32 — a fixed 5-bytemovl rax, imm32whose last 4 bytes salvage re-stamps — removing the per-unit data load (one scattered cache line per compilation unit, ~1 D1 miss per call in call-dense code) from the hot path. This is safe only because every imm site is registered:check_version(arch/x86_64/guard.rs) is the single emitter of the compare and pushes its patch label intounit_version_patch_sites;jit_compilefiles the unit’s list under its snapshot-word address (version_imm_sites), andset_class_versionpatches the word and every registered site. The word itself is retained as the salvage records’ key (and as what aarch64 reads), so the record plumbing is unchanged.The history that makes the registration invariant load-bearing: aarch64 once baked the snapshot as an unpatched immediate, which silently made salvage a no-op there — the word was patched and the guard kept comparing against the old immediate (fixed in #1157). An immediate is acceptable exactly when its patch site cannot be forgotten; a guard shape that emits its own compare instead of going through
check_versionwould reintroduce the bug.
The word is reachable afterwards through the unit’s salvage record:
| Unit kind | Record | Keyed by |
|---|---|---|
| whole method | ISeqInfo::jit_entry → JitInfo | self_class |
| loop (OSR) | ISeqInfo::loop_jit_info → LoopJitInfo | (self_class, LoopStart index) |
| specialized child | its owner’s record, via SpecializedPatchEntry::owner | — |
3. Class-version salvage
JitInfo / LoopJitInfo carry an inline_cache_map: Vec<InlineCacheEntry>
— every method call the compiler resolved at compile time, as
(recv_class, name, refinements, func_id). Store::salvage_method_unit /
salvage_loop_unit (globals/store/class.rs) re-ask each question with
check_method_for_name and compare the answer:
- all unchanged → return the unit’s version label; the caller patches it and the code stands.
- any changed →
None; recompile.
Two details that are easy to get wrong:
- The refinement set is part of the question. Re-asking the unrefined
question after a
usingmoved the version would confirm an answer the code is no longer allowed to give (refinements.md§3.4). supersites need the executing frame.check_method_for_nametakesOption<Lfp>; asuperentry (name: None) resolves relative to the owning frame. A salvage triggered from an inlined child has no such frame, passesNone, and conservatively fails — so a unit containing a cachedsupersite recompiles rather than guessing.
Specialized children salvage their owner. A child’s guard reads the
owner’s word, and the owner’s cache map covers the child’s call sites, so
salvage_specialized (codegen/compiler.rs) validates the owner unit.
SpecializedPatchEntry::owner is cleared to None when a child is
recompiled individually: the fresh body reads its own fresh words, which
the owner’s record no longer names, and patching the owner’s word would
“heal” words that body never reads — deopting it on every call, forever.
4. Const-version salvage — two tiers
A const-version guard failure says only “some constant event happened
somewhere”. ConstSalvageMap (globals/store/iseq.rs) records what the
unit actually folded — for each site, the ConstCache tuple the emitted
code relies on and the names whose redefinition could change its
resolution (the final name plus every path qualifier) — plus a snapshot of
those names’ epochs at compile time.
Store::salvage_const_unit then escalates:
- Per-name epochs. Every event in §1 bumps the epoch of the name it touched. If no folded name’s epoch moved (and the wildcard didn’t), no fold can have changed — patch the word, done, without a single lookup. This is the common case: a spec file defining unrelated constants.
- Value re-check. For a site whose name was touched, compare the
fold against that site’s VM inline cache. A cache already refreshed at
the current version whose
(value, base_class, self_class)equals the fold proves the assignment wrote the same value — patch and keep. A differing value recompiles. A stale cache (the VM has not re-run the site at this version yet) returnsConstSalvage::Defer: skip the recompile, let this invocation deopt so the interpreter refreshes the cache, and retry on the next miss. Bounded bystale_defers(3) so a fold whose site the deopted execution never reaches cannot defer forever.
The recorded fold values are GC roots: ConstSalvageMap::mark is reached
from ISeqInfo::mark, so a folded Value stays alive as long as the code
that baked it in.
5. Where a guard miss goes
All misses call into Rust before deciding. The entry points live in
codegen/compiler.rs.
| Unit | x86-64 | aarch64 |
|---|---|---|
| whole method, class ver | jit_recompile_method_with_recovery — on a successful salvage returns 1 and the stub’s jnz recover resumes in place, no deopt | jit_recompile_method — salvages, then deopts once |
| whole method, const ver | jit_recompile_method → salvage_method_const | same |
| loop (OSR) | jit_recompile_loop → salvage_loop (class or const) | same |
| specialized | jit_recompile_specialized → salvage_specialized | same |
Except for x86’s resume-in-place path, a successful salvage still lets the current invocation deopt to the VM once; the healed code passes its guards from the next call or iteration.
Salvage must run outside the CODEGEN borrow. Validation reads
Globals::class_version() / const_version(), which borrow the same
thread-local RefCell — so the salvage attempt sits in the extern "C"
entry point, before CODEGEN.with(...), and the helpers in Store return
the DestLabel for the caller to patch in its own borrow context rather
than patching it themselves.
Const guards are not counter-gated. The generic recompile side exit
(SideExit::RecompileDeoptimize → AsmInst::RecompileDeopt, gated by
COUNT_DEOPT_RECOMPILE) is one-shot: once drained
it never re-arms. That is right for an expensive recompile but wrong for a
cheap salvage — the second version move after a successful salvage would
strand the body in the interpreter for the rest of the run. So a
const-version miss calls the salvaging entry on every failure, the same
shape the class-version guard uses. A block-style root is the exception
and keeps a plain deopt: the whole-method recompile entry rebuilds
whatever lfp.func_id() names as a method, which is the wrong frame shape
for a block body.
6. What is not salvaged
| Reason | Behaviour |
|---|---|
NotCached, MethodNotFound, IvarIdNotFound | the compiler lacked information; recompiling with a warmed VM cache is the point |
BecamePolymorphic | a monomorphic-compiled send saw a second class. Ratcheted: recompile while the PMC holds fewer than two classes, plain deopt afterwards (RecvMissMode::Learn) |
| basic-operator redefinition | not a version guard. Bodies that inlined the operator are evicted (ISeqInfo::evict_jit_code, bop_deps) — see bop_redefinition.md |
| front-end bail | a whole-method bail marks the iseq jit_invalidated, and it is never compiled again; a loop bail just leaves the site interpreted |
7. The megamorphic gate
Salvage repairs code that is still correct. The complementary question — should this receiver class be compiled at all? — is answered by the class-guard chain’s miss exit, and it is worth stating because getting it wrong is invisible.
A chain miss must leave the chain and run the call in the interpreter
(jit_class_guard_fail: jmp vm_entry). The stub samples the receiver
class only when its warm-up counter expires, so a class used for a single
call is sampled at most once, evicted from the small profile
(profile_self_class: cap 8, threshold 2), and never compiled for.
When that exit label was left unbound (bound only under
#[cfg(feature = "profile")], so ordinary builds emitted a branch with a
zeroed rel32 — a no-op), every miss fell through into the profiler
instead, sampled the class twice back-to-back, crossed the threshold and
compiled a specialization for every class that ever missed. ruby/spec’s
concurrent-subclasses example compiled ~15,000 Object#should bodies
(~300 MB) and timed the suite out. Fixed in #1158.
8. Measuring
Build with --features jit-log; jit_stats::dump() prints at exit:
version / salvage stats:
class_version incs: 25022
recovery attempts (class guard): ...
salvaged (re-resolution ok): ...
recovery attempts (loop guard): ...
recovery attempts (spec guard): ...
recovery attempts (const guard): 15740
salvaged: 10485
value-compared sites: 13309
fail: cache stale: ...
fail: value changed: 0
whole recompiles class-ver: ...
...
Read it as: attempts are guard failures that reached a salvage entry,
salvaged are the ones that kept their code, and the recompiles block
is what was left over. fail: value changed staying at 0 over a whole
spec run is the empirical case for the whole mechanism — the constant
events were real, but never changed a folded value.
ruby/spec core (2,144 files / 23,034 examples), x86-64. Measured in two
series, because the base moved in between — do not read across them:
Salvage, measured against the pre-#1156 base:
| before salvage | class-version salvage (#1151, #1155) | + const-version salvage (#1157) | |
|---|---|---|---|
| specialized recompiles (class ver) | 90,593 | 68 | 149 |
| loop recompiles (class ver) | 12,395 | 0 | 0 |
| whole recompiles (const ver) | 4,174 | 4,148 | 155 |
| JIT compile time | 22.4 s | 15.6 s | 12.8 s |
| peak code emission | 350.7 MB | 327.7 MB | 297.9 MB |
Version-guard-driven recompiles across the whole run: 107,220 → 323.
The megamorphic gate (§7), measured against current master:
| before #1158 | after #1158 | |
|---|---|---|
Object#should whole compiles | 14,945 | 30 |
| JIT compile time | 23.1 s | 0.44 s |
| peak code emission | 318.4 MB | 10.6 MB |
The gate dominates everything else: it was compiling one body per receiver class, which is why its emission dwarfs the recompile traffic salvage removes.
9. If you change this code
- A new event that can change method resolution must bump the class version; one that can change constant resolution must bump the constant version and the right epoch (a name, or the wildcard when there is no single name).
- A new compile path must register a salvage record, or its guard
failures will recompile forever. The whole-method and loop paths do this
in
compile_patch/compile_partial_by_id. - A new question the compiler bakes in needs a new record and a new tier — do not extend an existing map with a differently-shaped invariant.
- Never bind a guard’s miss-exit label inside a
cfgblock (§7). The snapshot side of a version compare is either the unit’s word or a registered patchable immediate (§2) — never an unregistered immediate, and never a compare emitted outsidecheck_versionon x86-64.
基本演算の再定義(BOP redefinition)
1 + 2 のような演算子式を、メソッド探索を挟まずインライン展開してよいのは
「Integer#+ が再定義されていない」という仮定が成り立つ間だけである。Ruby
はその仮定をいつでも壊せる(class Integer; def +(o); …; end; end)ので、
処理系は「仮定を置く」「壊れたことを検知する」「壊れたあと辻褄を合わせる」の
3 点を用意しなければならない。
本書は monoruby の現行実装をコードに即して記述し、CRuby の戦略と実測で比較し、 なぜ今の設計を変えるべきかと、どの順で変えるべきかを記録する。
本書は design record である(
doc/README.mdの分類)。測定値は 2026-08-10 時点、076cd951、Linux/x86-64、release ビルドのもの。
関連: doc/refinements.md §6.7(refinements と基本演算)、doc/jit.md、
doc/inline.md
1. 出発点の実装(§5 Step 1 で置き換え済み)
注: 本節は Step 1 以前の実装を記述する。検知まわり(§1.1・§1.2・§1.4)は
globals/store/basic_op.rsの静的表に置き換わった(§5 Step 1)。反応 (§1.3)とコスト(§1.5)は現在も同じで、Step 2 の対象である。問題の形を 残すために原文のまま置く。
1.1 「basic op」として登録されていたもの
登録の入口は 1 つだけで、Globals::define_basic_op
(globals/method.rs)→ new_basic_op → add_basic_op_method →
add_method_inner(.., is_basic_op = true, ..) と流れ、
MethodTableEntry::is_basic_op を立てる。
呼び出し箇所は全部で 10 個:
| クラス | 演算子 |
|---|---|
Integer | + - * / != |
Float | + - * != |
String | != |
これが「再定義を検知できる集合」の全てである。VM / JIT がインライン展開して いる演算子はこれよりはるかに多い(§1.4)。
1.2 検知
ClassInfoTable::insert_method(globals/store/class.rs)は、メソッド表を
上書きしたとき 元のエントリが is_basic_op だったかだけを見る:
#![allow(unused)]
fn main() {
fn insert_method(&mut self, class_id: ClassId, name: IdentId, entry: MethodTableEntry) {
Globals::class_version_inc();
if let Some(old) = self.classes[class_id].methods.insert(name, entry)
&& old.is_basic_op
{
self.set_bop_redefine();
}
}
}
どの演算子が・どのクラスで再定義されたかは記録されない。 呼ばれた事実だけが 伝わる。
1.3 反応 — グローバルかつ恒久
Store::set_bop_redefine(同ファイル)が一度に次を行う:
Codegen::set_bop_redefine—bop_redefined_flags: u32に!0を書く。 以後クリアされない。Store::invalidate_jit_code()— 全 iseq のjit_invalidated = true。 これも一方向ラッチで、以後その iseq は method-JIT の対象から外れる (compiler.rs/patch.rsがjit_invalidated()で早期 return)。- x86-64: 全コンパイル済みメソッドの entry を
apply_jmp_patch_addressでvm_entryに書き戻す。aarch64:jit_slot/jit_guard_free_slotの dispatch word をゼロ化(invalidate_jit_code内)。 remove_vm_bop_optimization()— VM のディスパッチ表を_no_opt版に 恒久的に差し替える。算術・比較・単項の fixnum fast path が全て フルディスパッチになる。dispatch[14](loop_start)も no-opt になるので、 OSR ループ JIT が二度と起動しない。
以上でオフスタックのコードは片付くが、今スタックに乗っている JIT フレームは
まだ古い本体を実行し続ける。その始末は set_bop_redefine の中ではなく、
呼び出し元の Executor::{add_method, add_method_with_original, alias_method_for_class} が担う。メソッド表を書き換えた後・method_added
フックを呼ぶ前に Codegen::check_bop_redefine(cfp) を通し、フラグが非 0 なら
Codegen::chain_deopt_into が CFP 鎖を遡って各サスペンド中フレームを
インタプリタフレームへ変換する(write-back の replay + return address を
共有 VM continuation stub に書き換え。doc/chain_deopt.md §10 を参照)。
戻ってきた時点で VM に落ちる。かつてはコードそのものに jmp deopt を
書き込む immediate_eviction がこれを担っていたが、chain deopt が完全に
置き換えたので自己書き換えコードは無くなった。
method_added は任意の Ruby を走らせるので、その前に片付けておく必要がある。
この配置には 2 つ性質がある。
- レベルトリガであってエッジトリガではない。
check_bop_redefineは 「今回の定義が basic op を潰したか」ではなく「フラグが立っているか」を見る。 フラグは一度立つとクリアされないので、以後プロセス内のあらゆるメソッド定義が 毎回 CFP 鎖の全走査とフレーム変換を行う。実測では他の要因 (JIT が止まることで再コンパイル churn も消える)に埋もれて有意差は出なかったが、 構造としては無駄が残り続ける。 - 経路が 3 つの funnel に限られる。
remove_methodはここを通らない (§1.4 の但し書き)。
JIT コード内でのランタイム検査は AsmInst::CheckBOP(フラグのロード + 分岐)
だが、発行箇所は MethodDef / SingletonMethodDef の直後のみ
(jitgen/compile.rs)。演算そのものにはガードが無い — これは意図的で、
定数畳み込み(100 * 100 → 10000)とレジスタ常駐を成立させるための選択
(jitgen/compile/binary_op.rs のコメント参照)。
1.4 網羅の穴
インライン展開しているのに basic op として登録されていない演算子が多数ある。 48 ケースを CRuby 4.0.2 と差分テストした結果、22 件が食い違った。
VM ティアで再定義が完全に無視されるもの:
Integer % ** << >> & | ^ == < <= > >= -@
Float / == < >
String ==
Array [] Hash [] Symbol ==
JIT ティアは挙動が分かれる:
- 正しい —
Integer#%、Array#[]、Hash#[]、Symbol#==。BinOpK::{Shl, Shr, Exp, Rem}は「常にメソッド呼び出しにコンパイル」する 分岐に入り、インラインキャッシュ + class-version ガードを経由するため。 - 誤り —
& | ^、比較全般、-@。binop_integer/ 比較の無条件インライン 展開で、ガードが無い。
send(:+) 経由は常に正しい(メソッド表を引くため)。構文としての演算子だけが
壊れる。
上記 22 件のうち 1 件(
Array#size)は種類が違う。monoruby はArray#sizeを Ruby で実装しているため、再定義がKernel#pの内部を壊してArgumentErrorになる。BOP フックの欠落ではなく「ビルトインが Ruby 実装で あることの露出」で、CRuby(C 実装)には無い問題。切り分けて扱うべき。
remove_method は検知経路そのものが無い。 検知は
ClassInfoTable::insert_method(「上書きされた古いエントリが basic op か」)に
だけ置かれているが、ClassInfoTable::remove_method は methods.remove() を
直接呼び、is_basic_op を一切見ない。結果:
| 操作 | monoruby | CRuby |
|---|---|---|
Integer.remove_method(:+) → 1 + 2 | 3 | NoMethodError |
Integer.undef_method(:+) → 1 + 2 | NoMethodError | NoMethodError |
Integer.alias_method(:+, :-) → 1 + 2 | -1 | -1 |
undef_method が通るのは add_empty_method 経由で insert_method を踏むため。
CRuby は追加・削除・prepend のいずれでも
rb_vm_check_redefinition_opt_method を引くので取りこぼさない。検知点は
「メソッド表を変更する全経路」に置く必要がある。
1.5 コスト(実測)
fib(30)、Float#+ を再定義。ワークロードは Float を一切使わない。
| 条件 | 時間 |
|---|---|
| JIT・再定義なし | 0.022 s |
--no-jit(純 VM)・再定義なし | 0.069 s |
JIT・Float#+ 再定義後 | 0.51 s |
| JIT・再定義後に定義したメソッド | 0.52 s |
無関係なクラスの再定義 1 回で JIT ありの 24 倍、純 VM の 7.5 倍遅くなる。
_no_opt ハンドラは fixnum インラインパスを捨てて毎回フルディスパッチするため、
「JIT を止める」より悪い。再定義後に定義したコードも救われない
(remove_vm_bop_optimization の効果はプロセス全体・恒久のため)。
2. CRuby の戦略
ruby_vm_redefined_flag[BOP_xxx]— BOP ごとに 1 ワード、その中のビットが クラス(INTEGER_REDEFINED_OP_FLAG、FLOAT_…、STRING_…、ARRAY_…、HASH_…、SYMBOL_……)。粒度は (演算子, クラス) の組。vm_opt_method_defs—(クラス, メソッド名) → BOPの対応表。rb_vm_check_redefinition_opt_methodがメソッドの追加・削除のたびに引き、 一致したときだけ該当ビットを立てる。- インタプリタ —
opt_plus等の中で、オペランドの型チェックの後にBASIC_OP_UNREDEFINED_P(BOP_PLUS, INTEGER_REDEFINED_OP_FLAG)(ロード + AND + 分岐)を実行する。実行のたびに検査するが、対象は 1 ワード なので分岐予測がほぼ完全に効く。 - YJIT / ZJIT —
assume_bop_not_redefined()で「その (class, bop) は未再定義」 という invariant にコードブロックを登録する。再定義時はその invariant に 依存するブロックだけを無効化する。 - 通知 — Ruby 3.4+ は
-W:performanceでRedefining 'Integer#+' disables interpreter and JIT optimizationsを出す。
つまり CRuby はインタプリタでは実行時チェック、JIT では invariant + 局所無効化 という二本立てで、どちらも粒度は (演算子, クラス) である。
3. 比較(fib(29) × 3 回、実測)
| baseline | Float#+ 再定義後 | String#+ 再定義後 | |
|---|---|---|---|
| CRuby | 0.062 0.061 0.059 | 0.059 0.059 0.061 | 0.058 0.059 0.059 |
| monoruby | 0.014 0.013 0.013 | 0.310 0.321 0.328 | 0.012 0.012 0.012 |
CRuby には測定できるほどの影響が無い。monoruby の String#+ が無影響なのは、
そもそもフックが無い(未登録)からであって、良い意味ではない。
| 軸 | CRuby | monoruby |
|---|---|---|
| 粒度 | (演算子, クラス) | グローバル 1 ビット |
| 検査方式 | fast path 内の実行時テスト(VM)/ invariant(JIT) | 事前の一括無効化 |
| 影響範囲 | その組を使うコードのみ | プロセス全体 |
| 可逆性 | 不要(他が影響を受けない) | 不可逆 |
| 網羅 | 約 30 op × 十数クラス | 10 エントリ |
| 未登録 op | そもそも fast path を持たない | fast path はあるがフックが無い → 誤答 |
| 通知 | -W:performance | 無言 |
4. 評価 — 何が正しく、何が間違っているか
戦略は正しい。 monoruby の JIT が採る「ガード無しでインライン展開し、 再定義時に無効化する」は YJIT と同じ方向であり、CRuby インタプリタ式の 「毎回フラグを読む」を JIT に持ち込むと、いま効いている定数畳み込みと レジスタ常駐が成立しなくなる。ここは維持すべきである。
間違っているのは粒度と網羅である。
- 粒度 — 「どれか 1 つでも再定義されたか」しか持たないので、反応は
プロセス全体を落とすしかない。
bop_redefined_flagsが既にu32である にもかかわらず、0 か全ビットかの真偽値としてしか使われていない。 - 網羅 — インライン展開している (クラス, 演算子) の大半が未登録で、 再定義が無言で無視される。速い誤答は遅い正答より悪い。
- 恒久性 —
jit_invalidatedも VM ディスパッチ表の差し替えも一方向で、 回復手段が無い。粒度 1 の設計から必然的にこうなっている。
5. 方針
Step 1 — 網羅を閉じる(正しさ、最優先)— 実装済み
globals/store/basic_op.rs に 62 組の (クラス, メソッド) の静的表
(BASIC_OP_DEFS)を置き、検知をそこに移した。CRuby の vm_opt_method_defs
と同型である。
設計上の要点は 判定キーを「上書きされたエントリのフラグ」から「(クラス,
メソッド) の組」に変えたこと。§1.4 の内訳を見ると、Integer#! / #+@ /
#~、NilClass#== などはそのクラスにエントリを持たない(Object /
BasicObject からの継承)。class Integer; def !; … は上書きではなく
挿入なので、いくらエントリにフラグを立てても旧方式では原理的に検知できない。
CRuby が静的表を引くのも同じ理由による。
あわせて:
remove_methodにも検知を入れた(§1.4 の但し書き。undef_methodはadd_empty_method経由でinsert_methodを踏むので既に通っていた)。- ブートストラップ用ラッチ
armedを追加。ビルトイン自身の定義がこの表そのもの なので、startup.rbとgem のロードが終わるまで報告しない (Store::arm_basic_ops、Executorのstartup_flagと同じ地点)。 - ディスパッチ表を持たない Rust 側の fast path(
runtime::{get_index, set_index}のArray#[]/Hash#[]/Array#[]=)は_no_opt版に 差し替えられないので、BasicOpTable::redefinedを直接読ませた。
結果(実測):
| 変更前 | 変更後 | |
|---|---|---|
| 演算子再定義スイープ(257 ケース) | 51 件が CRuby と食い違い | 0 件 |
remove_method(:+) → 1 + 2 | 3 | NoMethodError |
| core 全体スペック(単一プロセス) | 347 F / 219 E | 345 F / 218 E |
cargo test | 59/59 | 59/59 |
--features emit-asm(BOP 再定義なし) | — | バイト単位で同一 |
Step 1 が露出させたもの — Ruby 実装ビルトインの脆さ — 解決済み(#1135)
注: 本節は問題の記述である。最後に解決の記録を足してある。
再定義が実際に効くようになった結果、monoruby が Ruby で書いている
ビルトイン(builtins/*.rb、約 9,000 行)がその再定義を踏むようになった。
class Integer; def <(o); :OV; end; end
[1, 2, 3].map { |x| x * 2 }
# CRuby => [2, 4, 6] (Array#map は C)
# monoruby => NoMethodError (array.rb の `while i < size` が壊れる)
Integer.remove_method(:<) はさらに露骨で、Comparable#<(comparable.rb)が
res < 0 で自分自身に再帰し StackOverflow になる。CRuby の Comparable#< は
C なのでディスパッチしない。
これは Step 1 が作った欠陥ではなく、Step 1 が可視化した既存の構造的弱点で ある。変更前は再定義そのものが無視されていたので、ユーザのコードでも ビルトインでも一様に「元の演算子」が使われ、辻褄だけは合っていた。
トレードオフを正直に言えば、Step 1 は「1 < 2 が誤答」を「1 < 2 は正答だが
Array#map が壊れる」に置き換えた。前者はサイレント、後者はラウド。
cargo test と ruby/spec には影響が無い(どちらも組み込みクラスの演算子を
再定義しない)ため実害は測定されていないが、基本演算を monkey patch する
実プログラムは動かない。
塞ぐには、builtins/*.rb の演算子をビルトインに束縛する(= CRuby が C で
書くことで無料で得ている性質を明示的に作る)必要がある。粒度とは独立の課題
なので Step 2 の前提ではないが、Step 1 の帰結として記録しておく。
解決(#1135)
その束縛を入れた。builtins/*.rb 由来のフレームにはもともと 1 ビットが
立っている(Meta::set_internal_builtin。$~/$_ を持たせないためと、
refinements を届かせないため —— 後者は #1066 で、まったく同じ理由で
同じ判断をしている: 「Array#map 自身の i += 1 はライブラリのコードで、
CRuby では C だから refinement には見えない」)。再定義もそこに届かせない。
読む場所は 3 つ:
| 場所 | 役割 |
|---|---|
Executor::dispatch_redefined_op | VM ヘルパ(*_values 系)のガード。内部ビルトインのフレームからなら _raw へ落とす |
JitContext::resolve_basic_op | JIT の fast path 解決。生のメソッド表ではなく arm 時のスナップショット(Store::basic_op_armed_func)を引く |
JitContext::basic_op_assumable | 上で解決したものをガード無しで inline してよい、という licence |
VM 側だけでは直らない。JIT は jit_check_method で Integer#+ を生の
メソッド表から引き、再定義後はユーザのメソッドに解決して inline generator を
失う —— つまり basic_op_assumable に到達すらしない。スナップショット解決が
その半分である。
差分スイープ(8 種の再定義 × 28 プローブ = 224 セル、CRuby 4.0.2 と比較): CRuby と異なるセルが 30 → 1。
残る 1 セルは Integer#< を再定義したときの 5.times: CRuby は [] を返す。
CRuby の Integer#times も Ruby で書かれているためである。推定ではなく
CRuby 自身に吐かせた:
$ ruby -e 'p Integer.instance_method(:times).source_location'
["<internal:numeric>", 255] # upto / Array#each は nil(= C)
その iseq の逆アセンブル(ループ本体):
0020 getlocal_WC_0 i
0022 invokeblock <argc:1> # yield i
0025 getlocal_WC_0 i
0027 opt_succ <calldata!mid:succ, argc:0> # i = i.succ
0029 setlocal_WC_0 i
0031 getlocal_WC_0 i
0034 opt_lt <calldata!mid:<, argc:1> # while i < self
0036 branchif 20
+ を呼ぶ箇所が無いので Integer#+ の再定義は届かない。succ と < は実際に
呼んでいるので届く —— opt_succ / opt_lt は BOP 高速命令だが、再定義されれば
BASIC_OP_UNREDEFINED_P が落ちて本物のディスパッチに戻る。monoruby が Step 1 以降
踏んでいたのとまったく同じ構造である。裏取りとして succ を self + 2 に再定義
すると CRuby の times は [0, 2, 4] を返す。
monoruby は + / < / succ のいずれに対しても免疫になった。CRuby の同種の
瑕疵をわざわざ再現しない、という判断である。
将来の選択肢 — intrinsic による粒度の精密化(見送り)
フレーム単位の束縛は「このファイルの演算子は全部ビルトイン束縛」としか言えず、
ライブラリ自身の帳簿(i += 1)と呼び出し側のための演算(sort の
<=>)を区別できない。区別は今のところ手作業で保っている
(EqSearch::new と op/sort.rs は意図的に束縛の外)。
原理的に正しいのは issue #1135 が第一候補として挙げた方向 ——
__add / __lt のような再定義されない内部呼び出しを導入し、
1 箇所ずつ明示する。実装するなら「本物のメソッド」ではなく
bytecodegen が名前を認識して通常の BinOp 命令に落とす形にすること。
本物のメソッドにすると VM 階層の演算子高速路を失う(i += 1 → i.succ で
増分が実測 1.8 倍)。名前を「再定義チェックを外す印」として使い命令自体は
同じにすれば、性能は不変になる。
規模(実測):
| CRuby のコアメソッドのうち Ruby 実装 | 74 / 1061(7%) |
| — うちループを回すもの | Integer#times のみ |
builtins/*.rb の演算子使用 | 約 1,058 箇所 / 11,217 行 |
— うちループ帳簿(+= 1 / -= 1 / while … <) | 約 150(機械的) |
— 残り(== 199, < 168, <=> 43 …) | 約 900(1 件ずつ判断が要る) |
CRuby 側が C なのは 93% なので「CRuby が C のものだけ intrinsic 化」は事実上 ほぼ全部が対象になる。そして残り約 900 箇所の多くは呼び出し側の値を比較して おり、intrinsic 化してはいけない —— 判断を誤ると今回のバグと逆向きに、 同じくサイレントに壊れる。
したがって一度に移行はしない。フレーム単位の束縛を既定の安全側とし、粒度が
粗すぎて実害が出た箇所から漸進的に intrinsic 化する方針とする(2026-09、
#1362 のレビューで決定)。なお CRuby との完全一致だけが目的なら、intrinsic
より安い道がある —— Ruby 実装側の 74 件を束縛から除外する方向で、Integer#times
なら「印から除外」+「増分を i.succ に変更」の 2 箇所で + / < / succ の
3 セルとも一致する。こちらも採らない(CRuby の瑕疵の再現になるため)。
Step 2a — VM を (クラス) 粒度にする — 実装済み
鍵になった観測: VM の asm fast path はすべて fixnum 限定である。
vm_binops_opt / vm_cmp_opt! / vm_neg … はどれも
guard_rdi_rsi_fixnum / guard_rdi_fixnum で始まり、fixnum でないオペランドは
必ず Rust ヘルパ(add_values、cmp_lt_values、eq_values_vis、not_value、
get_index …)に落ちる。
したがって:
- Integer の再定義 → asm が無効になる → ディスパッチ表の差し替えが要る。
- それ以外のクラス(Float / String / Symbol / nil / true / false / Complex / Array / Hash)→ asm は書かれたとおり正しいまま。無効になるのは Rust ヘルパのそのクラス用アームだけなので、ディスパッチ表に触る必要がない。
実装は 2 点だけ。
BasicOpTableが「何が再定義されたか」を持つ(redefined_setとinteger_redefined)。Store::set_bop_redefineは Integer が初めて 再定義されたときだけremove_vm_bop_optimization()を呼ぶ。- ネイティブに答える Rust ヘルパが、そのアームに入る前に
basic_op_redefined_for(受け手のクラス, 演算子名)を確認する。 グローバル bool で門番しているので、再定義しないプログラムの追加コストは bool 1 個(しかも呼び出し自体が既に C-ABI)。
対象ヘルパ: binop_values!(add/sub/mul)、手書きの div_values /
rem_values / pow_values / shl_values / shr_values、cmp_values!
(lt/le/gt/ge)、eq_values_vis、cmp_teq_values_impl、not_value /
neg_value / pos_value / bitnot_value、get_index / set_index。
(!= は custom_neq が既にメソッド表を引くので変更不要。)
結果(fib(29) × 3、実測):
| 条件 | Step 1 まで | Step 2a |
|---|---|---|
| baseline(JIT) | 0.009 | 0.009 |
Float#+ 再定義後 | 0.31 | 0.030 |
String#== 再定義後 | 0.31 | 0.031 |
Array#[] 再定義後 | 0.30 | 0.030 |
Symbol#== 再定義後 | 0.30 | 0.030 |
Complex#* 再定義後 | 0.31 | 0.030 |
(参考)--no-jit | 0.030 | 0.030 |
| (参考)CRuby | 0.051 | 0.051 |
再定義後の 0.030 は --no-jit の 0.030 と完全に一致する。 つまり VM 側の
劣化は完全に消え、残る 3.3 倍(0.009 → 0.030)はまるごと JIT の
グローバル無効化である。これが Step 2b の対象。
正しさは 257 ケースのスイープが 0 差分を維持、cargo test 59/59、core 全体
スペックは 345F/218E → 344F/218E、--features emit-asm は Step 1 前の
master とバイト単位で同一のまま。
Step 2c — VM を (演算子) 粒度にする — 実装済み
Step 2a の後も、Integer の再定義だけは依然としてディスパッチ表の一括差し替えを
起こしていた。Integer#~(fib が一切使わない)を再定義するだけで
0.013 → 0.33 s(25 倍、--no-jit の 7.6 倍悪い)。粒度がクラス単位までしか
無かったためである。
解法はフラグをアセンブリから読ませること — CRuby インタプリタの
BASIC_OP_UNREDEFINED_P と同じ配置。VmBop ごとに 1 ワードを JIT データ領域に
置き(Codegen::bop_flags)、各 VM ハンドラが自分の fixnum ガードの直後で
自分のワードを読む。非 0 なら inline 計算を諦めて generic パス(Rust ヘルパ)へ
抜ける。
ガードを持つのは asm の inline 計算を持つ 11 個だけ:
add sub eq ne lt le gt ge === +@ -@。~ と ! は元々 inline 計算が無く、
残りの binop(* / % ** << >> & | ^)は asm 高速路自体を持たず最初から Rust
ヘルパ直行なので、Step 2a のペアチェックで足りる。
副産物として _no_opt 系が丸ごと不要になった。 差し替え先が要らなくなった
ので、両アーキの VM _no_opt ハンドラ群と、*_values_no_opt /
*_value_no_opt の Rust ヘルパ群(マクロごと)を削除した。
remove_vm_bop_optimization は dispatch[14](loop_start)だけを扱う
disable_vm_loop_jit に縮小した — これは basic op ではなく JIT 無効化の話で、
Step 2b の対象。
結果(fib(29) × 3、実測):
| 条件 | Step 1 | Step 2a | Step 2c |
|---|---|---|---|
| baseline(JIT) | 0.009 | 0.009 | 0.009 |
Float#+ 再定義後 | 0.31 | 0.030 | 0.030 |
Integer#~ 再定義後 | 0.33 | 0.33 | 0.032 |
| `Integer# | ` 再定義後 | 0.33 | 0.33 |
Integer#* 再定義後 | 0.33 | 0.33 | 0.031 |
(参考)--no-jit | 0.030 | 0.030 | 0.030 |
すべての再定義が --no-jit 相当に収まった。 残る 3.3 倍(0.009 → 0.030)は
まるごと JIT のグローバル無効化= Step 2b。
ガードの実費(正直な数字):
| モード | ガード無し | ガード有り |
|---|---|---|
| 通常(JIT) | fib 0.0020 / loop 0.0131 | fib 0.0021 / loop 0.0137 |
--no-jit、算術飽和ループ(15 回交互、中央値) | 0.230 | 0.246(+7%) |
通常モードでは min / median が一致し測定できない。VM 専用の合成ベンチでのみ
約 7% で、その大半は asm ガードではなく Rust ヘルパ側の
basic_op_redefined() 参照(& ^ | を多用するため)と見られる。VM は通常
ウォームアップ経路なので、10 倍の崖を消す対価として受け入れた。
フラグは 1 ワード・1 演算子 1 ビット。 当初は VmBop ごとに data_i32 を
1 語ずつ(11 語 44 バイト)確保していたが、これは CRuby の
ruby_vm_redefined_flag がビットマップである理由をなぞり損ねていた。1 語
(data_i64)にまとめ、VmBop は語の添字ではなくビット番号を意味するようにした。
x86 は cmpl [rip+f], 0 → testq [rip+f], (1<<bit)、aarch64 は
ldr w9/cbnz → ldr x9/tbnz #bit で、どちらも命令数は不変。ガードが全部
同じキャッシュラインに乗り、bop_flags: Vec<DestLabel> と VmBop::COUNT が
消え、残る 53 ビットが Step 2b の (op, class) 用に空く。
Step 2a の取りこぼし — ガードを「実装」に置いてしまっていた。
add_values などの共有ヘルパは VM/JIT の generic パス(メソッド探索を
飛ばした呼び出し元)だけでなく、ビルトイン Integer#+ の本体そのものでも
ある。ガードをその共有ヘルパの先頭に置いたため、
class Integer; alias __orig :+; def +(o); __orig(o); end; end
1 + 2 # => StackOverflow
__orig(= 元のビルトイン)に入った時点でガードが再び発火し、名前 + で
再ディスパッチして利用者の + に戻る、という無限再帰になっていた。CRuby が
BASIC_OP_UNREDEFINED_P を命令側(opt_plus)に置き rb_int_plus には
置かないのは、まさにこの理由である。
修正は同じ形に揃えること — 実装を *_values_raw として無防備なまま残し、
ガードは「探索を飛ばした呼び出し元のための入口」*_values に分離した。
ビルトインの本体(numeric.rs の binop!/unop!、integer.rs の
cmpop! と % ** << >>、float.rs の比較と ==)は _raw を呼ぶ。
Array#sum や Numeric#angle のように探索せずに演算子を使う内部呼び出しは
ガード付きのままでよい(CRuby も同じ位置で BOP を見る)。
検出には alias スイープ(253 ケース)を使い、39 → 10 = master と同数・同一集合まで
戻した。回帰テストは aliasing_an_operator_before_redefining_it_does_not_recurse。
この手法の再利用先 — TracePoint。 「JIT データ領域に 1 ワード置き、生成
コードの決まった地点でそれを読んで分岐する」という形は basic op 固有ではなく、
実行中に切り替わりうるグローバルなスイッチ全般に効く。次にこれを必要とする
のは TracePoint(および set_trace_func)で、line / call / return /
b_call などのイベントが有効かどうかを VM ハンドラが自前で判定できるように
なる。
CRuby はここでは別の道を採っていて、TracePoint 有効化時に iseq の命令列を
trace_* 変種へ書き換える(vm_trace_setup)。無効時のコストが厳密に 0 に
なる代わりに、命令ごとに並行するハンドラ集合を維持する必要がある — つまり
Step 2c でちょうど削除した _no_opt 系と同じ構造の重複を、命令表全体に対して
背負うことになる。フラグ読みは load + 分岐 1 個を払うが、上表の通り通常
モードでは測定にかからず、ハンドラは 1 系統のままで済む。monoruby には
こちらが合う。
検証: 257 ケースのスイープ 0 差分(途中 Integer#& | ^ が 3 件後退した —
これらは int_binop_values! という別マクロ由来でガードから漏れており、
スイープが検出した)、cargo test 59/59、JIT の出力コードは
バイト単位で同一(ページ内オフセットのみ 160 バイト移動)、core 全体スペックは
344–345F / 218–219E で同一バイナリでも実行ごとにこの幅で振れるため回帰なし。
Step 2b — JIT を invariant 単位にする — 実装済み
Step 2c まで終えても、baseline 0.009 に対してあらゆる再定義が 0.030 に
落ちていた。原因は 1 か所 — set_bop_redefine がプロセス内の 全 iseq の
JIT コードを一方向ラッチ (jit_invalidated) で恒久的に捨てていたこと。
Array#[] を再定義しただけで fib の JIT が消える。
依存を記録して、該当分だけ捨てる。 JIT がガード無しに basic op を仮定して
いるのは、整数・浮動小数の算術/比較/定数畳み込みと単項演算だけである
(state/binop.rs の binop_integer / binop_float / gen_cmp_* /
unop_integer_* / unop_float — いずれも per-method の inline 生成器から
呼ばれ、fire_binary_inline / fire_unary_inline が license を確認・記録
する)。それ以外
— メソッド呼び出しに落ちるものすべて — は既に class-version ガードで
守られている(再定義は必ず class version を進める)。そこで:
JitContext::assume_basic_op(class, op)を inline 経路の入口に置き、- 再定義済みなら
falseを返してその演算だけ通常のメソッド呼び出しへ 降格する。だから再定義後に再コンパイルしても 健全で、しかも他の演算子の inline は保たれる。 - まだビルトインなら
trueを返しつつ(class, op)を記録する。 - (現在は binop/cmp のディスパッチが inline 生成器経由
(
fire_binary_inline)になり、純検査basic_op_assumable+ 生成器成功時のみのrecord_bop_depに分離されている。融合 compare-and-branch もこの経路で license を確認・記録する。)
- 再定義済みなら
- 記録は
jit_compile→ISeqInfo::bop_deps(self class をまたいで union)。 set_bop_redefineはevict_jit_code_for_bop(class, name)で 依存している iseq だけを捨てる。x86 の entry 巻き戻しも同じ集合に限定。jit_invalidatedの意味を分離した。従来はフロントエンドの bail(恒久的に コンパイル不能)と「コードを捨てた」を同じフラグで表していた。前者はinvalidate_jit_code、後者は回復可能なevict_jit_codeとし、捨てた 本体は次のウォームアップで再コンパイルされる。check_bop_redefineのレベルトリガを解消(§1.3)。bop_eviction_pendingをset_bop_redefineが立て、直後のcheck_bop_redefineが消費する。 従来は sticky フラグを読んでいたため、一度どれかを再定義したプログラムでは 以後すべてのdefが制御フレーム鎖を全走査して、既にパッチ済みの return address を patch し直していた。AsmInst::CheckBOPを世代比較にした。JIT データ語を「何か再定義された」 フラグからカウンタに変え、コンパイル時の値を焼き込んで比較する (class-version ガードと同じ形)。従来は 0 比較の sticky だったので、 一度でも再定義が起きるとコンパイル済みコード中のdef地点が永久に deopt し続けた。
結果(fib(29) × 3、実測):
| 条件 | Step 2c | Step 2b |
|---|---|---|
| baseline(JIT) | 0.009 | 0.009 |
Float#+ 再定義後 | 0.030 | 0.009 |
Integer#~ 再定義後 | 0.032 | 0.009 |
Integer#| 再定義後 | 0.031 | 0.009 |
Integer#* 再定義後 | 0.030 | 0.009 |
Array#[] 再定義後 | 0.031 | 0.009 |
(参考)--no-jit | 0.030 | 0.031 |
崖が消えた。 fib が使わない演算子の再定義は完全に無料になった。
検証: 再定義スイープ 257 件 0 差分、alias スイープ 253 件 10(master と同数・
同一集合)、cargo test 全 59 スイート 0 失敗、aarch64 クロスチェック通過。
回帰テストは redefining_an_operator_a_compiled_method_inlined(ウォーム済み
JIT コードが使っている演算子を再定義しても正しい)と
redefining_an_unrelated_operator_leaves_compiled_code_alone。
Step 2b-2 — OSR ループ本体も iseq 単位にする — 実装済み
Step 2b の時点では、stale な本体が 1 つでも出ると dispatch[14]
(loop_start)を no-opt に落としてプロセス全体のループ JIT を止めていた。
コンパイル済みループ本体の codeptr はバイトコード内 LoopStart の
[pc+8] に埋まっていて、他から辿る口が無かったためである。
そのオペランドを直接ゼロにすればよい。 evict_jit_code が、捨てる iseq の
バイトコードを走査して LoopStart(opcode 14)の [pc+8] をクリアする
(clear_loop_jit_entries)。サイトは「まだコンパイルしていない」状態に戻り、
次の周回で再コンパイルされる — 今度は再定義された演算子を inline せずに。
オペランドは三値である点に注意: 0 = 未コンパイル、1 = コンパイルが bail
した恒久センチネル、それ以外が実エントリ。クリアするのは 3 番目だけで、
センチネルを戻すと bail するループが閾値を跨ぐたびに(失敗する高価な)
コンパイルを再試行してしまう。[pc+0] のヒットカウンタは閾値のまま残すので、
再ウォームアップを待たず次の周回で再コンパイルされる。
これで disable_vm_loop_jit / vm_loop_start_no_opt は不要になり削除した。
結果(無関係なループ、3M 周 × 3):
loop_mul と loop_add を両方温めてから、loop_mul だけが使う
Integer#* を(意味を変えずに)再定義し、loop_add を測る:
loop_add(無関係) | |
|---|---|
| 変更前 | 0.027 |
| 変更後 | 0.006 |
4.5 倍。 無関係なループが巻き添えで OSR JIT を失うことは無くなった。
Step 2b(当初の計画・記録として保存)
jit_invalidated のグローバル一方向ラッチをやめ、「この iseq がどの
(op, class) invariant に依存したか」を記録して該当分だけ無効化する。
InlineCacheEntry と class-version ラベルという前例がある。あわせて
dispatch[14](loop_start)の no-opt 化とオンスタック始末
(現 chain_deopt)のレベルトリガ(§1.3)もここで直す。
Step 2 — 粒度を (演算子, クラス) へ(性能)
bop_redefined_flags: u32 を BOP ごとのワード × クラスビットに変える
(CRuby と同型)。
- VM:
_no_optへの全面差し替えをやめ、各 fast path で該当ビットだけを テストする。fixnum パスに分岐 1 個を足すコストは、現状の 7.5 倍劣化に比べれば 無視できる。 - JIT:
jit_invalidatedのグローバル一方向ラッチをやめ、「この iseq が どの (op, class) invariant に依存したか」を記録して該当分だけ無効化する。InlineCacheEntryと class-version ラベルという前例がある。 - オンスタック始末(現
chain_deopt)は残す — オンスタックのフレームを 片付ける手段は粒度に関係なく必要である。ただし §1.3 の 2 性質を直す: 「フラグが非 0 か」の レベルトリガをやめてマスクのビットが 0→1 に遷移したときだけ走らせ (エッジトリガ)、走査対象もその (op, class) に依存したフレームに絞る。 - 効果: 「無関係な
Float#+の再定義で fib が 24 倍遅くなる」が消える。
Step 3 — refinements の基本演算(#1066)— 実装済み
Step 1・2 を終えた時点で、残作業はペアを記録することだけだった。実験で
確認できる: 意味を変えない再定義(alias __p +; def +(o) = __p(o))で
フラグだけを立てると、refine Integer { def +(o) = 42 } が VM・JIT とも
CRuby と同じ 42 を返した。下流はすべて既に動いていた —
Executor::find_method は refinement 対応、JIT は assume_basic_op が
false を返して通常呼び出しへ降格し jit_check_method が
JitContext::refinements の下で解決する。
検知点は refine 側ではなく insert_method / remove_method に置いた。
refinement のメソッドは refinement モジュール側の ClassId に入るので、
refined_class() を引いて (refined_class, name) で照会する。この位置なら
def / define_method / alias / import_methods を 1 箇所でカバーできる
(Step 1 の「メソッド表を変更する全経路に検知点を置く」と同じ理由)。
必要だった前提修正 — ライブラリ境界。
Executor::frame_refinements は monoruby が Ruby で書いたコアライブラリの
フレームを透過して呼び出し側スコープまで歩く。これは &obj の
to_proc や補間の to_s のためで、CRuby がそれらを呼び出し側の iseq で
行うのに対し monoruby は callee 側で行うため、透過しないと一致しない。
ところが演算子はその種の変換ではない。Array#map 自身の i += 1 は
ライブラリのコードで、CRuby では C — どの refinement からも見えない。
呼び出し側スコープで解決した結果、refine された Integer#+ が 42 を返して
map が 1 周で終了していた([1,2,3].map { |x| x*2 } → [2, nil, nil]、
(1..3).sum → 42)。
そこで find_method が解決対象の名前で分岐するようにした:
BASIC_OP_DEFS の演算子名ならライブラリ境界で歩みを止め
(Executor::basic_op_refinements)、それ以外は従来どおり透過する。
Step 3b — JIT をスコープ単位にする — 実装済み
Step 3 直後はペアの記録がプロセス全体だったので、refine しただけで
(using していないスコープでも)その演算子の inline を失っていた
(fib(29) 0.009 → 0.034)。
「何が無効化したか」を分けて記録する。 BasicOpTable の
redefined_set(= fast path はもう無条件には健全でない、という和集合)を
残したまま、内訳として globally_redefined_set と refined_set を持つ。
assume_basic_op は 2 段で問う:
- グローバル再定義なら、どのスコープも逃れられないので inline しない。
- refinement 由来なら、コンパイル中のスコープの
RefinementSetIdが 実際にそのペアを解決し直すかだけを問う(basic_op_refined_in_scope)。 refine していないスコープ — refine しないプログラムの全スコープを含む — は inline を維持する。
判定は「set のエントリのうち当該クラスを refine するものを辿り、その
refinement モジュール(と ancestors)が当該名を持つか」で行う。当初は
「set 有り/無しで解決して比較する」正確版を書いたが、無し側が
check_method_for_class → メソッドキャッシュ → Globals::class_version() と
辿って JIT コンパイル中に CODEGEN を borrow し panic した(コンパイル時は
既に borrow_mut 中)。エントリ走査ならクラス表しか触らない。判定は
意図的に広めに倒してある — 迷ったら実呼び出しにするのは常に健全で、逆は
refinement が置き換えたはずの算術をそのまま出してしまう。
| 条件 | Step 3 | Step 3b |
|---|---|---|
| baseline | 0.009 | 0.009 |
refine(Integer) { def + } 後(using 外) | 0.034 | 0.009 |
refine(Integer) { def ~ } 後 | 0.034 | 0.009 |
| (参考)グローバル再定義後 | 0.032 | 0.032 |
refine しただけのコストが消えた。 VM 側の asm ガードは呼び出し地点の 文脈を持たないグローバル語なので粗いまま(正しさは dispatch が担保する)。
Step 4 — mixin 経由の再定義(#1214, #1219)— 実装済み
Step 1 で「メソッド表を変更する全経路に検知点を置く」と決めたが、経路が 1 本抜けていた。モジュール経由である。
module M
def +(o) = super(o) * 2
end
class Integer
prepend M
end
insert_method が受け取る class_id は M であって Integer ではない。
表が記録しているペアは (Integer, "+") なので contains(M, "+") は false、
何も立たず両ティアがビルトインを撃ち続けた。順序を入れ替えて
prepend を先にしても同じ(定義そのものが走らない)。
検知点は 2 つ:
insert_method→check_mixed_in_basic_op(module_id, name)— モジュールに後からメソッドが入った場合append_features/prepend_features→check_mixin_basic_ops(module)— すでにメソッドを持つモジュールが後から差し込まれた場合
罠 — 「モジュールがその名前を定義しているか」は問いとして間違っている。
最初の実装はこう書いた:「name が BOP 名で、かつ module_id が基本演算
クラス C の祖先にあるなら (C, name) を再定義済みにする」。これは
ユーザコードの include Comparable ひとつでプロセス中の整数 fast path が
全滅する。Comparable は < <= > >= == を定義しており、
Integer はブートストラップ以来それを include している。つまり
「祖先のモジュールがその名前を定義している」はどんなプログラムでも常に真
で、一方 Integer 自身の < はルックアップで勝ち続けるので実際には何も
変わっていない。
正しい問いは「そのクラスは今もビルトインに解決するか」である。
arm_basic_ops が起動時に全ペアの解決先 FuncId をスナップショットし
(BasicOpTable::armed_funcs)、mixin 検知はそれと現在の解決先を比較する。
Integer.prepend M(M が + を持つ)なら解決先が動くので立ち、
class Foo; include Comparable; end なら動かないので立たない。
実測(yjit-bench lee, x86-64)。 この誤検知は 1 回の include で
Integer#< <= > >= == != === <=> !、Float・String・Symbol の同種、
計 27 ペアを恒久的に落とした。lee は require "json" の過程で
Comparable を include するため直撃した:
| median | |
|---|---|
誤検知の前(037a720) | 512 ms |
誤検知の入った master(21ed6aa) | 1728 ms |
| 解決先比較を入れた後 | 520 ms |
CI の履歴では amd64 が 478 ms → 1550 ms(YJIT 比 1.48x → 0.44x)、 arm64 は 400 秒のタイムアウトに達した。
回帰テストは basic_op.rs の
a_mixin_that_displaces_nothing_keeps_the_basic_ops /
a_prepend_that_displaces_the_builtin_retires_its_pair。前者は「何も
落ちない」ことを、後者は「落ちるべきものは落ちる」ことを表の状態に対して
直接主張する — 値の比較では、fast path が消えても答えは正しいままなので
検出できない。
Step 3 の当初計画(記録として保存)
Step 2 の (op, class) ビットマスクができれば、#1066 が要求する 「refinement セットごとの BOP ビットマスク」はその自然な拡張になる。
逆に、今の 1 ビット設計のままでは #1066 は実装できない。 refinement は 「このレキシカルスコープでだけ再定義」であって、グローバルビットとは意味が 異なるためである。したがって #1066 の前提として Step 1・2 を先に行うべきで、 順序を逆にすると設計をやり直すことになる。
検討した候補 — hash / eql? を BOP にする(見送り)
Hash / Set のルックアップは hash → eql? と連鎖するので、これらを BOP に
すれば速くなるのではないか、という案。測定した結果、利得はゼロだった。
理由は BOP 表の性質そのものにある。表は「この仮定を置いてよい」という許可と
検知であって、速度は「仮定を使う fast path」を書いて初めて出る。そして
hash / eql? については その fast path が既にあり、無条件に効いている —
Value::ruby_hash / Value::eql(value.rs)が ObjTy で分岐して Rust 側で
直接計算する:
| ネイティブ計算(ディスパッチ無し) | |
|---|---|
ruby_hash | Fixnum, Flonum, nil/true/false/Symbol, BigInt, Float, String, Array, Hash, Range |
eql | 同一 id, 両方 immediate, BigInt, Float, Complex, String, Array, Range, Hash |
基本クラスのキーには消せるディスパッチが最初から無い。登録しても得るものは無く、
グローバル崖のトリップワイヤが増えるだけである。正しさの穴も無い(String#hash
を再定義しても Hash のバケッティング・Array#hash とも CRuby と一致する。CRuby
も基本クラスのキーは rb_str_hash 等で直接計算する)。
実測(各 300k 回ルックアップ):
| monoruby | CRuby | |
|---|---|---|
Hash[String] | 0.014 s | 0.028 s |
Hash[Symbol] | 0.011 s | 0.016 s |
Hash[Integer] | 0.016 s | 0.014 s |
Hash[ユーザ定義 hash/eql?] | 0.030 s | 0.100 s |
Set#include? | 0.016 s | 0.017 s |
ただし派生案は有効。 hash / eql? を定義していない素のオブジェクト
(identity ハッシュ)は ruby_hash の _ アームに落ち、ルックアップのたびに
Object#hash へディスパッチしている。5 項目中ここだけ monoruby が負ける:
| monoruby | CRuby | |
|---|---|---|
Hash[素のオブジェクト] | 0.031 s | 0.023 s |
Set#include?[素のオブジェクト] | 0.031 s | 0.024 s |
obj.hash 単体 | 0.008 s | 0.016 s |
obj.hash 単体では monoruby が 2 倍速いので、差はハッシュ計算ではなく
ルックアップ 1 回ごとのディスパッチ往復にある。(Object, hash) を表に入れ、
_ アームで「このクラスの hash はまだ Object#hash か」を確かめてから
identity ハッシュを直接計算すれば省ける(eql 側は既に id() 比較で短絡済み)。
派生案 — 実装済み。 Value::ruby_hash の _ アーム(ディスパッチ経路)の
手前に「このレシーバの hash はまだビルトインの Kernel#hash か」を問う
アームを足し、そうなら Kernel#hash と同じ digest を直接計算する。
判定は BOP 表ではなく FuncId の比較にした。表は静的な (クラス, 名前)
なので (Object, hash) を入れても Object#hash 自体の再定義しか捕まえられず、
ユーザ定義クラスが自前の hash を持つ場合を取りこぼす。実際に必要なのは
「このレシーバのクラスにおける hash の解決結果がビルトインか」で、これは
Store::check_method(class-version キーのキャッシュ付き)1 回で答えられる。
表に入れると set_bop_redefine が発火して JIT まで巻き添えにするので、
むしろ有害だった。CRuby も rb_any_hash で同じ特別扱いをしている。
実測(同一ベンチの A/B、300k 回ルックアップ):
| 変更前 | 変更後 | CRuby | |
|---|---|---|---|
Hash[素のオブジェクト] | 0.050 | 0.039 | 0.024 |
Set#include?[素のオブジェクト] | 0.049 | 0.038 | 0.026 |
22% 改善したが CRuby にはまだ届かない。 消えたのはディスパッチ往復だけで、
残る差はハッシュ表の実装そのものにあると見られる(obj.hash 単体では
monoruby が 2 倍速いままなので、digest の計算コストではない)。ここから先は
BOP とは別の課題。
正しさは CRuby と一致を確認 — 同一性による引き当て、hash/eql? を持つ
クラスの値による引き当て、あとから hash を定義した場合の追随(fast path
が外れる)、Set と Array#hash の digest 一貫性。
併せて検討するもの
-W:performance相当の警告 — 実装は小さく、デバッグ価値は高い。現状の monoruby は完全に無言で 24 倍遅くなる。- Ruby 実装ビルトインの露出(§1.4 但し書き)— BOP とは別問題だが、Step 1 で 誤答が減ると顕在化しやすくなるので、同時に把握しておく。
検討して採らなかった案
- JIT にも実行時チェックを入れる(CRuby インタプリタ方式) — 定数畳み込みが できなくなる。invariant + 無効化を維持する。
jit_invalidatedを解除可能にする(再コンパイル許可) — 複雑な割に、 Step 2 を行えば「そもそも無効化されない」ので不要になる。- basic op 登録をやめて全演算子をメソッド呼び出しにする — 正しさは得られるが、 monoruby の性能特性そのものを捨てることになる。
6. 受け入れ条件(Step 1・2 共通)
doc/refinements.md で用いた手法をそのまま使う。
- コード生成の同一性 — BOP 再定義を含まないワークロードで
--features emit-asmの出力が変更前とバイト単位で一致すること。 - 差分テスト — §1.4 の 48 ケースが CRuby と一致すること(
Array#sizeを除く)。 - 性能回帰なし —
fib/ optcarrot が baseline を維持すること。 - Step 2 の効果 —
Float#+再定義後のfibが baseline に戻ること (現状 0.51 s → 目標 0.022 s 近傍)。
Chain deopt — handoff note
Kind: plan. Status: the mechanism (§5 steps 1–3) is implemented and
exercised end-to-end in its eager form — the walk replays every suspended
frame’s write-back at deopt time and points its return-address slot at one
shared VM continuation stub (§9.3’s conversion has landed; §8 describes the
implementation). Registration is unconditional in every build, and chain
conversion is now the only way an on-stack JIT frame is dropped to the
interpreter: immediate eviction — the code-patching mechanism this document
was written against — is gone (§10). The escalation half of step 4 — the
switch that makes every interpreter-resuming side exit run the
chain-deopt walk, plus the runtime entry it calls — is in place and on by
default in every build (§8.5; the former chain-deopt validation feature
is gone). The speculation itself (§5 steps 4–5) is implemented — see
§11 for what shipped: qualifying block-passing specialized call sites keep
pure-F (and Float-literal) locals unboxed across the call, specialized
blocks in the subtree read/write them in the speculating frame’s FP
save/spill area (Load/StoreDynVarSpeculatedF), a non-Float store
compiles to load_fpr’s Float guard whose escalated deopt converts the
chain before the offending store, and a compile-time-known non-Float
store (or any capture-capable construct in the subtree) poisons the
attempt, which recompiles unspeculated. The return-state recovery (§6) is
not implemented. §9 stays as the record
of why the original lazy build was wrong.
1. What this is for
The optimization we want is speculative unboxed Float locals across a
block call.
Today a method call that passes a block demotes every local to
LinkMode::S before the call:
#![allow(unused)]
fn main() {
// compile/method_call.rs
// We must write back all local vars to the stack and set the state to
// LinkMode::S when they are possibly accessed or captured from inner blocks.
if callsite.block_fid.is_some() {
state.locals_to_S(ir);
}
}
so a Float local is boxed on every block call, because the inner block
might read or write it. The speculation is to stop demoting: keep the
local unboxed in the frame’s FP spill area and let the (specialized,
non-capturable) block read and write it there. If the block ever stores a
non-Float into such a local the speculation is dead — the outer frame’s
compiled code assumes an f64 at a spill offset that no longer holds one —
and the whole suspended chain has to fall back to the interpreter at once.
The motivating shape is real and hot. benchmark/app_aobench.rb:
occlusion = 0.0
nphi.times do |j|
ntheta.times do |i|
...
occlusion += 1.0 # outer Float local, mutated from two blocks deep
end
end
Chain deopt is the enabling mechanism, not the optimization. It also pays for itself a second time — see §6.
2. The mechanism
On a deep deopt, leave every frame where it is and convert the whole suspended chain from JIT frames into interpreter frames:
- for every JIT frame on the stack, write its spilled xmm/d registers back into its local slots (boxing the floats);
- rewrite each frame’s return-address slot on the stack to the VM’s post-call continuation;
- the continuation reads the suspended pc off the stack, so that slot has to be right (it is — see §3.2).
Control then never re-enters JIT code: each ret lands in the interpreter.
This is not what Codegen::immediate_eviction did. That one wrote a
jmp deopt into the code at the call’s return continuation, so control
returned into JIT code and deopted one instruction later. Chain deopt does not
touch code and does not return into JIT code at all — which is why it could
replace immediate eviction outright rather than sit beside it (§10).
3. What was verified in the source
3.1 The VM entry already exists and is shared
arch/x86_64/vmgen/method_call.rs:81, the send opcode’s post-call sequence:
done:
pop_cont_frame ; popq r13 (suspended pc); addq rsp, 8
; movzxw rdi, [r13 + RET_REG_FROM_CALLSITE] <- dst slot, from the bytecode
; addq r13, 32 <- past the 2-unit send
vm_handle_error
vm_store_rdi(rax) ; store the return value into dst
fetch_and_dispatch
It is entirely stack-driven and carries no per-site state: it recovers
the pc from the stack, re-derives the destination slot from the bytecode at
that pc, stores rax, and resumes. So a single address serves every call
site. Bind a label there and expose its address.
3.2 The suspended pc is already correct — no prerequisite work
AsmInst::ContFramePc { call_site_pc } (asmir.rs:1678) stores the
call-site pc into the outgoing cont-frame slot — documented as “the slot
Kernel#caller reads”. Its producers cover every JIT call/yield emitter:
compile/method_call.rs | emitter | path |
|---|---|---|
| 822 | compile_yield_specialized | specialized yield |
| 1419 | send | ordinary call |
| 1484 | send_specialized | specialized call |
| 1520 | compile_yield | generic yield |
Cfp::caller_pc_slot’s “not every dispatch path writes it” caveat is about
non-JIT paths (the invoker’s zero sentinel). Filling the pc lazily during
the walk remains available as belt-and-braces, but is not needed to start.
3.3 The write-back is heavier than a memcpy
WriteBack (jitgen.rs:175) has six kinds of entry:
#![allow(unused)]
fn main() {
fpr: Vec<(FPReg, Vec<SlotId>)>, // float -> one or more slots
literal: Vec<(Value, SlotId)>,
void: Vec<SlotId>,
gp: Vec<(GP, SlotId)>, // empty in shipping builds
forward_rest: Vec<(SlotId, SlotId, u16)>, // D1: build the rest Array
forward_kwrest: Vec<(SlotId, Box<[(IdentId, SlotId)]>)>, // K1: build the kwrest Hash
}
To replay one from Rust the walker needs the WriteBack and the frame’s
spill base (base, today only a compile-time argument to
gen_write_back_for_deopt). forward_rest / forward_kwrest additionally
need create_array / correct_rest_kw calls, so this is not a pure memory
shuffle.
Why replaying from Rust is possible at all: xmm is caller-saved, so
FprSave has already spilled every live float of a suspended frame into
that frame’s own spill area. Only the innermost frame still has live values
in registers, and that one deopts through its compiled handler as it does
today.
3.4 The registration key already exists
return_addr_table (codegen.rs) maps a suspended call’s return address to
its side exit, filled by set_deopt_with_return_addr from all four emitters
above. The runtime table wants the same key.
4. A rejected attempt — do not repeat it
Rewriting the stack’s return address to point at that frame’s Evict
handler looks like it should work. It does not, and it fails loudly
(caller_lines_survive_specialized_frame_eviction, SIGABRT inside
pmc_record_binary).
The instruction order at a specialized call is:
call callee <- the return address points here
<result-store code emitted by def_rax2acc_return>
patch_point <- where immediate_eviction wrote its jmp
and compile/method_call.rs:835 fixed the contract:
#![allow(unused)]
fn main() {
let res = state.def_rax2acc_return(ir, dst, return_state); // emits the result store
state.immediate_evict(ir, evict); // records patch_point + write_back
}
The Evict write-back is captured after the result store is modelled,
so the handler assumes that store has already run. Entering it directly by
ret skips the store, the callee’s return value never reaches its slot, and
garbage propagates.
immediate_eviction patched at patch_point — after the result store —
precisely for this reason. Chain deopt sidesteps the whole issue by
targeting the VM entry (§3.1), where vm_store_rdi does the store. (The
argument is preserved because it is the reason the stub, not the frame’s
own Evict handler, is the conversion target; the patching mechanism it
describes no longer exists — §10.)
5. Order of work
Steps 1–3 are done, essentially as written (after a detour through a lazy per-site-handler form — §9) — see §8.
- Runtime table
return_addr -> (WriteBack, spill base), plus the Rust routine that replays one against a frame’srbp/lfp. - Expose the VM entry: bind a label at
done:and publish its address. - The walk:
Cfp::set_return_addr(a writer next to the existingreturn_addr()atframe.rs:76), then walk the CFP chain applying 1 and 2. Model the loop onimmediate_eviction, which already skips VM frames viacheck_vm_addressand handles recursion by visiting every frame. (The chain walk has since become that loop — §10.) - Firing point: the
Floatguard on a block’sStoreDynVarinto an outer unboxed local. Write back before performing the offending store, or the write-back overwrites it. - Relax
locals_to_Sfor qualifying block calls, and point the block’sLoad/StoreDynVarat the outer frame’s spill area. - Recover the return-state narrowing — see §6.
6. The second payoff: frame_had_deopt
compile/method_call.rs:1243 currently gives up all return-type inference
for any callee that could deopt:
#![allow(unused)]
fn main() {
if self.store[iseq_id].has_exception_handler() || frame_had_deopt {
s.taint_for_unmodeled_rescue(); // ret -> ReturnValue::Value
}
}
and propagates the fact one level out (current_frame_mut().had_deopt = true),
so one deopt-able site anywhere in an inlined subtree flattens every
enclosing return state to Value. This is PR #505’s fix for a real
unsoundness: a deopted callee resumes in the interpreter and can return a
value outside the class the abstract interpreter predicted.
Chain deopt licenses removing the frame_had_deopt half:
- callee completes in JIT — the inference was derived from exactly those compiled paths, so it holds;
- callee deopts — the caller is converted too, its compiled code never
resumes, and the
Guarded::Class(..)tag is never acted on.
Two conditions on that argument:
- Every deopt below a site that consumed a narrowed return state must escalate to chain deopt. Blanket escalation would make today’s cheap per-frame deopts pay a chain walk and throw away the caller’s compiled execution, so gate it per site. Specialization compiles the callee body per call site, so the flag can be baked in at compile time. This is the quietest thing in the design to get wrong — a missed escalation shows up only as a wrong class tag — so it wants a mechanical guarantee (a frame flag every side-exit emitter in that frame consults), not review discipline.
has_exception_handlerstays. An exception raised and rescued inside the callee returns normally with no deopt at all, so chain deopt never fires and the happy-path-only inference is still wrong (issue #405).
Note also that taint_for_unmodeled_rescue (state.rs:598) bundles the
ret downgrade with clearing invariants.side_effect_guard; splitting the
deopt reason from the rescue reason means deciding both, and the
side_effect_guard half needs its own written argument.
7. Gating for the speculation itself (§5.4–5.5)
Agreed scope: specialized iseq_block only, and only where the frame
cannot be captured (possibly_capture_without_block / has_block_arg
false). Generic block invocation is out of scope for now.
The constraints that force this:
- GC.
Lfp::mark(frame.rs:284) walksmeta.regs()and marks every slot as aValue. A rawf64in a local slot would be scanned as a heap pointer, so unboxed values must stay in the FP spill area and the block must be pointed there, not at[outer_lfp - slot]. - Escape paths. Anything that reads
[outer_lfp - slot]expecting a boxedValuebreaks the speculation: a capturedProccalled later,binding/Binding#local_variable_get, generic (non-specialized) block invocation,move_frame_to_heap, backtraces — and the interpreter itself once the block deopts and the VM runs the block’s body.
8. What is implemented (§5 steps 1–3, eager form)
8.1 The write-back is replayed by a compiled per-site stub, at deopt time
Step 1’s runtime table exists, one step further along than designed. At
compile time each site’s ChainReplay (jitgen.rs — the site’s WriteBack,
the frame’s spill base, the site’s UsingFpr, the call’s dst slot, and
the call-site pc) is lowered by Codegen::gen_chain_replay_stub into a
compiled conversion stub, and Codegen::chain_deopt_table maps the
call’s return address straight to that stub’s entry (CodePtr -> CodePtr).
The walk therefore carries no per-site data and clones nothing: it calls the
stub with three frame pointers (callee bp, caller bp, caller Lfp) and the
stub writes the suspended caller frame back during the walk.
Layout facts the replay depends on (each re-verified against the emission code; they are load-bearing now):
- Every frame — VM, JIT, native wrapper — establishes
bp == cfp + BP_CFPin its prologue (Cfp::frame_bp), socallee_rbp = callee_cfp + 8and the caller’s saved bp is[callee_rbp]. - §3.3’s premise was wrong in one detail:
FprSavedoes not spill the pool-resident floats into the frame’sbase-relative spill area. The cont-mode save (fpr_save_with_cont/emit_fpr_save) puts them in anrsp-relative area allocated at the call — one 8-byte slot per set bit of the site’sUsingFpr, in bit order, at[rsp + 16 + 8i]— which after the call/prologue sits atcallee_rbp + 32 + 8i(sincecallee_rbp == rsp_after_FprSave - 16). The formula is byte-identical on aarch64 (emit_fpr_savemirrors the x86 shape;stp x29, x30is the same 16-byte adjustment ascall+pushq rbp), so one arch-neutral replay serves both. - Spilled
FPRegs (ids>= PHYS_FPR_POOL) livebase-relative in the caller frame:[caller_rbp - (base - 24 + 8n)](PhysMap::resolve, shared by both arches). - The caller’s LFP is read through
caller_cfp.lfp()— the cfp slot is redirected when a frame is promoted to the heap, exactly like ther14the emitted deopt write-back uses.
forward_rest / forward_kwrest emit calls to runtime::create_array /
runtime::correct_rest_kw against the dynamic caller’s frame reached through
the saved bp — the same helpers and the same addressing the deopt path’s
gen_forward_rest_materialize / gen_forward_kwrest_materialize use, which
is why the stub form needs no Rust-side equivalents of its own. They run
after the fpr/literal/void stores, so every deferred dst slot already holds
its nil and the frame stays GC-consistent across the allocating helper
calls. GC safety overall: slots always hold whole, valid (possibly stale)
Values, and the GC scans them as such, so the replay may allocate at any
point.
The stub reaches nothing on Codegen — it reads its three frame pointers and
writes frame slots — so the walk applies each conversion in place, under the
CODEGEN borrow it already holds; there is no plan to collect and hand back
to the callers (runtime::chain_deopt, Codegen::check_bop_redefine).
Write-back order within one frame is fixed (deferred materialization last);
frames are converted in walk order, which is sound because the replays touch
disjoint frames (rest/kwrest sources are raw slots, valid regardless of
conversion order).
8.2 One shared continuation stub — §3.1’s raw VM entry is still wrong, the pad slot fixes it
§3.1 proposes pointing every rewritten return-address slot at the VM’s
shared post-call continuation, on the grounds that the sequence is entirely
stack-driven and carries no per-site state. That is wrong for a reason §3.1
missed: the shared continuation re-derives the destination slot and the
resume pc from the bytecode assuming a 2-unit send instruction
(movzxw rdi, [r13 + 4]; addq r13, 32; on error entry_raise’s
r13 - 16 lands on the send’s second unit, which the exception table still
covers for sends). But operator call sites — BinOp / Index / …,
1-unit bytecodes — dispatch through the same send emitter and register
for chain deopt too. At such a site the raw VM continuation reads a garbage
destination slot, resumes one whole instruction too far, and on a
propagating exception looks up the exception table at the following
instruction — which silently skips an ensure whose protected range ends at
the operator (found by Enumerator.new { |y| begin; y << 1; ensure; … }
with a raising consumer block, once every error exit escalated).
The fix keeps §3.1’s “one address serves every site” without decoding
bytecode: one stub per arch (gen_chain_cont_stub, emitted with the VM
handlers), plus a per-site continuation word the site’s conversion stub
stores into the callee’s cont-frame pad slot (CFP+32 — the second half of
the 16-byte cont frame, reserved by every caller and read by nothing on the
normal return path). The word packs conv(dst) (0 = none)
in the high 32 bits and the byte advance to the next instruction
(pc.next() - pc, per-opcode-size correct: 16 or 32) in the low 32 bits.
Entered by ret — the frame’s write-back already replayed at deopt time —
the stub only has to:
- run the
pop_framethe hijackedretskipped (rbp/x29-derived, so correct whatever the callee left in the global registers): restoreExecutor::cfpand the LFP register; - read the call-site pc from the cont frame’s pc slot (
ContFramePcwrote it at every JIT site;Kernel#callerreads the same slot) and the continuation word from the pad, then drop the cont frame; - on the error signal (result 0), hand the call-site pc to
entry_raiseunder each arch’s convention (x86pc + 1before the-16, aarch64pcunchanged); - otherwise store the result into
dst(if any) and resume the fetch loop atpc + advance.
The stub never allocates, so no GC concern; the dead FprSave area above
the cont frame is simply left below the frame, as every side exit leaves
it. There are no per-site handlers, no SideExit::ChainExit, and no
fpr_reload_cont — the pool registers are dead at the stub (the replay
consumed the save area).
8.3 How it is exercised without a firing point
Steps 4–5’s speculation does not exist yet, so the Float guard that will
fire the walk does not either. Two things exercise it meanwhile:
- BOP eviction, in every build: it is now the walk’s only production
caller, so
tests/redefine.rscovers the mechanism unconditionally.redefine_bop_onstack_callerfails with the stale inlined+(8 instead of 999) if the suspended caller resumes its compiled body, so the test passing is a positive signal that the conversion happened, not just that nothing crashed. - Every side exit escalates (§8.6), unconditionally in every build
(formerly the default-off
chain-deoptcargo feature; promoted to the default after measurement — see §8.5): each deopt / recompile-deopt / error exit taken anywhere in the suite fires the walk from the deopting frame, so the deopt → replay → stub-return path — the one the speculation will actually take — is exercised at every deopt site the suite reaches, not only at BOP redefinitions.
What this validates: the walk, the eager replay, the return-address rewrite,
the stub’s frame/LFP restore and result/raise hand-off, across normal calls,
generic yields, and specialized calls/yields. What it does not validate
is the case chain deopt exists for — a frame whose local is unboxed at the
moment of conversion — because without the locals_to_S relaxation no
suspended frame ever holds one.
The producers (AbstractState::chain_exit) are not gated: every call and
yield site registers in every build, because with immediate eviction gone a
site the table does not know is a site BOP eviction cannot convert (§10).
The metadata cost is accepted. When step 5 lands, the per-site decision §6
argues for rides on top of unconditional registration, not instead of it.
8.4 A rewritten return-address slot is also on the unwind path
Worth knowing before building step 4, because it is not obvious: an
exception does not bypass the rewritten slot. entry_raise with no
in-frame handler unwinds by running the frame’s ordinary epilogue and
reting with the error signal in rax/x0 — so a propagating exception
lands in the continuation stub too, and the stub’s error branch (§8.2)
hands the call-site pc to entry_raise, which re-raises from the
now-converted frame at the call site — running its rescue/ensure there
or unwinding further. Non-local exits (MethodReturn, Break) take the
same route, including method_return_specialized, whose ret lands in the
stub via the slot belonging to the outermost inlined call — the one whose
dst the value is destined for.
This is what we want (the frame is converted before the VM inspects it for
a rescue), and it means the stub must be correct for rax == 0, not only
for a normal return. It is: the error branch touches neither dst nor the
advance, and the frame’s write-back already ran at deopt time.
8.5 Escalating side exits (§5 step 4’s mechanism, §6’s mechanical guarantee)
A frame compiled under the speculation must convert its suspended callers on
every path that resumes the interpreter in-frame, not just the Float
guard on the offending store: once the interpreter runs any of the frame’s
bytecode it can reach an outer local through Load/StoreDynVar, a binding,
or a capture, and would read the stale slot. §6 asks for a mechanical
guarantee rather than review discipline, and this is it:
JitContext::escalate_side_exitsis the single decision point. It is stamped onto eachAsmIrat construction, and every side-exit constructor (new_deopt/new_deopt_with_pc/deopt_from_point/new_recompile_deopt/new_error) reads it — an emitter cannot forget to escalate because emitters do not choose. It returnstrueunconditionally: A/B measurement of blanket escalation (full suite, benchmark set, optcarrot) put the walk at ~160ns per escalation and ≤1.6% wall-clock even on bedcov’s 1.8M-escalation worst case, every other benchmark inside noise — cheap enough that §6’s per-site gating is unnecessary for the escalation itself, and blanket escalation is exactly the invariant the speculation needs (a speculated frame’s callers convert on every interpreter resume below it, not only on the Float guard).- An escalated
Deoptimize/RecompileDeoptimize/Errorhandler callsruntime::chain_deopt(vm)after its write-back (the frame is fully homed) and before resuming the fetch loop / enteringentry_raise. The runtime entry collects the plan under theCODEGENborrow and applies it (replay + slot rewrite) after releasing it — the replay allocates (§8.1). The walk starts at the deopting frame’s own cfp, so its own return-address slot is rewritten too — that is what converts its caller when the now-interpreted frame eventually returns. Errorexits escalate because a raise can be rescued in-frame (an interpreter resume like any deopt) or unwind through the suspended callers — §8.4’s path, which requires the slots to have been rewritten.Evicthandlers do not escalate — and are in fact no longer entered at all: immediate eviction was their only entry (§10). The side-exit slot survives becauseAsmEvictis the id under which a call site’s return address is recorded forchain_exit.
Every JIT call/yield site registers, so the walk converts every suspended
JIT frame it reaches — strictly more conversion than the speculation will
need, which is sound for the same reason BOP eviction is. A frame converted
by an earlier walk is skipped the same way a VM frame is — its rewritten
return address is a VM address (the stub) — which is load-bearing:
interpreted inner frames may have updated its slots through outer_lfp
since, and a second replay would clobber them with stale floats.
BOP eviction goes through the same walk (Codegen::chain_deopt_into) as an
escalated side exit; there is one CFP walk and one conversion mechanism.
8.6 Where the code is
| Piece | Location |
|---|---|
ChainExitSpec (compile-time), ChainReplay, gen_chain_replay_stub (the compiled per-site conversion, x86-64) | jitgen.rs (aarch64 form in arch/aarch64/compile/mod.rs) |
AsmInst::ChainExit (registration, no code emitted) | jitgen/asmir.rs; lowered in jitgen/asmir/compile_shared.rs |
LInst::ChainExit | jitgen/lir.rs |
| Producers (call / specialized call / yield / specialized yield) | AbstractState::chain_exit, jitgen/compile/method_call.rs |
| Shared continuation stub | gen_chain_cont_stub (arch/x86_64/vmgen.rs, arch/aarch64/vmgen.rs), address in Codegen::chain_cont_stub |
| Deferred rest / kwrest materialization the replay stub calls | runtime::create_array, runtime::correct_rest_kw (codegen/runtime.rs) |
| Runtime table, the walk | chain_deopt_table (return address -> stub entry), register_chain_exit, chain_deopt_into (codegen.rs) |
| Escalation switch + runtime entry | JitContext::escalate_side_exits (jitgen/context.rs), AsmIr::escalate_exits (jitgen/asmir.rs), runtime::chain_deopt (codegen/runtime.rs) |
| Frame readers the walk uses (the return-address and cont-frame-pad writes are emitted into the replay stub, so there are no Rust-side writers) | Cfp::return_addr, Cfp::frame_bp, Cfp::lfp (executor/frame.rs) |
9. The lazy write-back was a deviation — record of the eager conversion
Resolved. The mechanism was first built in a lazy, per-site-handler form; this section records what that form did, why it was unsound to build the speculation on, and the conversion plan — which has since landed (§8 describes the eager implementation). §9.1–9.2 describe the former build.
9.1 What the built mechanism actually did
Codegen::chain_deopt (codegen.rs) rewrote each suspended frame’s
return-address slot to that site’s chain-exit handler — and nothing
else. The frame’s write-back runs only when control returns to it: the
innermost frame rets, lands in the handler, the handler writes that one
frame back and tails into the VM continuation; then the next frame out, and
so on. Only the frame that raised the deopt is written back eagerly (by
its own escalated side exit, before it calls runtime::chain_deopt).
So immediately after a deopt in A → B → C at C:
| frame | local slots |
|---|---|
| C | correct (its own side exit wrote them) |
| B, A | stale — write-back deferred to their rets |
9.2 Why that is wrong, and why nothing catches it today
The intended design (§2) is that the walk converts the whole chain at deopt time: all frames written back, return addresses pointed at the VM’s shared post-call continuation — control never re-enters JIT code, and no per-site handler exists at all. (Note the tension in the built form: a per-site handler is only reachable because the return address points at it rather than at the VM — the lazy write-back and the per-site handlers are two faces of the same deviation.)
The soundness gap: once C is running in the interpreter, anything that
reads an outer frame’s locals through outer_lfp — a block body touching
A’s variables, binding, a backtrace with argument values — reads A’s
slots, which still hold whatever was there before A’s floats were
promoted to LinkMode::F. Today this cannot be observed solely because
locals_to_S boxes every local into its slot before every block-passing
call, so every frame on an outer chain happens to have correct slots.
§5 step 5 removes exactly that guarantee; the lazy form breaks the moment
it lands.
The chain-deopt feature suite (3203/3203 green) was therefore a regression
base for the conversion machinery — stack rewriting, ret-entry handler
state, unwind interaction — not evidence that lazy write-back was sound.
9.3 The conversion — landed
Extend the walk to replay each suspended frame’s write-back duringDone — with §8.2’s correction: the raw VM send continuation is wrong for 1-unit operator sites, so the rewritten slots point at one shared stub (chain_deopt, then point its return address at the shared VM entry.gen_chain_cont_stub) that reads the per-sitedst/advance word the walk stored in the cont-frame pad slot.The replay needs, per return address: theDone —WriteBack, the frame’s spillbase, and the site’sUsingFprsave-area layout.ChainReplaycarries exactly that (plusdstand the site pc for the stub’s continuation word); the layout facts are restated, verified, in §8.1.forward_rest/forward_kwrestcallruntime::create_array/runtime::correct_rest_kw— GC-safe because every source value is in a scanned frame slot.Remove the per-site chain-exit handlers and theDone —DestLabeltable —chain_deopt_tablemapsreturn_addrto the replay data instead.SideExit::ChainExit/LSideExitKind::ChainExit,gen_chain_exit_with_label/a64_gen_chain_exit, andfpr_reload_cont/a64_fpr_reload_contare deleted; §8.4’s unwind path now terminates in the stub’s error branch.Re-run theDone — 3203/3203 green in the eager form (and the default suite unchanged).chain-deoptfeature suite; it must stay green through the conversion.
Ordering: this preceded §5 steps 4–5, as required. The locals_to_S
relaxation is now unblocked.
10. Immediate eviction is gone
Landed. Chain conversion is the only mechanism that drops an on-stack JIT frame to the interpreter.
Immediate eviction existed because a basic-op redefinition inside a callee
makes the caller’s already-compiled continuation stale — its inlined integer
arithmetic and constant folds assume the builtin op — and the callee’s entry
guards cannot protect a frame that is already suspended. With no way to
convert a suspended frame, the only lever was the code itself: record each
call’s return continuation as a patch point and, on redefinition, overwrite it
with a jmp/B to that site’s Evict handler, so the frame deopted one
instruction after it resumed.
Chain conversion subsumes that completely — it drops the same frames to the interpreter, from the stack rather than from the code — with three things the patching form could not offer:
- a compiled body still valid for future invocations survives, because no machine code is rewritten;
- on aarch64 it removes a self-modifying-code path, with its
writable/I-cache-invalidate dance around every patched word (the remaining
SMC users are
patch_call_to_entryand the recompilation patch points, which are unrelated); - it is the mechanism the unboxed-locals speculation needs anyway, so there is one walk to reason about instead of two that must agree.
The single precondition was that every call and yield site register a
chain_deopt_table entry, since an unregistered site is one the walk leaves
running its stale body. §8.3’s feature gate on AbstractState::chain_exit was
therefore removed; every build pays the metadata.
Removed with it: Codegen::patch_return_to_deopt (both arches),
get_deopt_with_return_addr and the return_addr_table it read,
emit_immediate_evict (both arches), and AsmInst::ImmediateEvict /
LInst::ImmediateEvict with their dispatch arms. asm_return_addr_table
stays — it is how AsmInst::ChainExit, pushed after the call when the return
address is no longer at hand, names the site it belongs to. AsmEvict and
SideExit::Evict stay for the same reason, though nothing branches to an
Evict handler any more; retiring that emission is a separate cleanup.
Codegen::evict_suspended_frames and Codegen::chain_deopt were the same CFP
walk once the patch fallback was gone, and are now one function
(chain_deopt), used by both check_bop_redefine and runtime::chain_deopt.
11. The speculation as shipped (§5 steps 4–5)
Scope (§7, enforced by float_speculation_qualifies +
poison hooks): a block-passing call site whose callee is a plain iseq and
whose literal block is an iseq with no capture surface
(possibly_capture_without_block / has_block_arg false), compiled
outside a dispatch arm. Everything subtler is caught during the subtree
compile by the poison hooks — a generic block-passing call, a generic
yield, a block-handler materialization (BlockArgProxy / BlockArg), or
a no-capture invalidation reaching the speculating frame — and flips the
site to an unspeculated recompile.
Step 5 — the relaxation. At a qualifying site,
locals_to_S_keep_F demotes every local except pure-F ones and
Float literals (occlusion = 0.0 is materialized to F); the kept set
is armed on the frame (begin_float_speculation) for the duration of
the specialized-subtree compile. The unboxed local’s canonical home
during the call is memory the machinery already maintains:
- pool-resident
F→ the call’s cont-mode save slot ([callee_rbp + 32 + 8·rank], rank = the register’s ascending-bit rank in the site’sUsingFpr— the exact layoutemit_fpr_savewrites andChainReplay§8.1 reads); - spilled
F→ its own[rbp - (base - 24 + 8n)]spill slot.
A specialized block resolving Load/StoreDynVar against an armed outer
frame (speculated_dynvar) compiles a single f64 move at
[rbp + Σ frame-sizes + disp] — the same late-resolved
DynVarOffset::Hint chain the boxed specialized access uses. After the
call nothing needs fixing: fpr_restore_cont reloads pool registers
from the (possibly block-updated) save slots, a spilled local reads its
own slot, and the site’s ChainReplay boxes from exactly the locations
the block writes.
Step 4 — the Float guard. A store’s source must be a Float, not
coerce to one: a proven-Float source stores its f64 directly; a
Float-guarded stack source goes through load_fpr’s guard + unbox,
whose (escalated, §8.5) deopt converts the whole chain before the
offending store runs — the interpreter then re-executes the store
against the boxed frame. A statically non-Float source poisons the
attempt instead (speculated_store_src returns None): the store
would fail every execution, so the site recompiles unspeculated.
Nesting. A qualified site whose subtree compiles without a single
capture-relevant event (JitContext::capture_events snapshot) keeps
the caller’s no-capture invariant instead of running the blanket
post-call unset — which is what lets an enclosing frame’s speculation
survive an inner qualified block call (nphi.times { ntheta.times { occlusion += … } }, the motivating aobench shape, speculates through
both levels).
Pinning. The kept pool floats are pin_fpr-ed from the demote
point to the end of the site emission so an allocation in between
cannot spill one out of the save slot the compiled block addresses.
Poisoned retry. The first (speculated) subtree compile is discarded
wholesale on poison — its specialized bodies are emitted but never
referenced — and the site recompiles the subtree against the
post-locals_to_S state under a fresh patch point.
Reading the deopt log (--features deopt)
What a record looks like
<-- deopt occurs in <Array#each> FuncId(2251).
[:00019] %1 = %1 + %2 [Integer][Integer] exit: deopt (chained)
guard: monoruby/src/codegen/jitgen/asmir/compile_shared.rs:541:34
exit emitted by: monoruby/src/codegen/jitgen/compile/method_call.rs:973:24
cause: class version
| field | means |
|---|---|
| first line | the frame that deoptimized |
[:NNNNN] | its bytecode index, and the TraceIR at that index |
exit: | what the handler is — deopt, evict, or recompile[reason], plus (chained) |
guard: | the lowering site of the guard that actually branched |
exit emitted by: | the front-end site that decided a deopt was needed here |
cause: | the operand the guard was looking at, or a name when there is none |
guard: is the field to read first. It identifies the branch, not the
handler — see below for why those differ.
Why it is built the way it is
The cause column used to be “whatever sat in rdi when the side exit ran”. That was wrong in two independent ways, and the two together produced four consecutive misdiagnoses during the activerecord deopt investigation:
The value was stale. The handler read rdi after the deopt write-back,
which calls into C (f64_to_val, create_array, …) and clobbers the
register file. Most records printed UNDEFINED — how a zero word renders —
so the column was noise dressed as data.
The guard was unidentifiable. Deopt exits are deduplicated by
(pc, write_back, chain), and a single AsmDeopt is handed to several
guards even before that. Neither the handler nor the AsmDeopt index names
the branch that was taken. Two guards at one pc are one handler, and the
log could not tell them apart.
So identity is recorded where the branch is. Every lowering site that asks for a deopt label gets a trampoline of its own:
site_NNN:
movq [rbx + EXECUTOR_DEOPT_CAUSE], <cause register>
movl [rbx + EXECUTOR_DEOPT_SITE], <site id>
jmp <deduplicated handler>
rbx is &mut Executor for the whole body, so this costs no scratch
register, no stack traffic, and — at a point where a guard has just done its
compare — no flag-clobbering instruction. Recording the operand before the
write-back is what makes the value trustworthy, which in turn lets the log
call stay where it always was: after the write-back, with nothing live in
registers.
Handlers stay deduplicated. Only the trampolines multiply, at ~20 cold-page bytes each.
The invariant
The only way to reach a deopt handler from a JIT body is
Codegen::deopt_label, and it demands a DeoptCause.
SideExitLabels has no Index<AsmDeopt> impl, and deopt_label has no
default for cause. A new guard that skips the question does not compile.
Choosing a cause: for the label this call returns, is a meaningful operand
present on every path that jumps to it? If some edge leaves the
register undefined, the honest answer is DeoptCause::Static("…").
| variant | when |
|---|---|
Value(r) | a Ruby Value is in r on every edge |
ValueVsBaked(r, v) | …and the guard compared it against v, baked at compile time |
Raw(r) | non-Value bits (pointers, byte counts); printed as hex, never decoded |
Static(s) | no operand: global state (version word, BOP flag, counter), an unconditional deopt, or an unboxed float |
Floats are deliberately Static. An FPReg is virtual — resolving one
needs the frame’s base_stack_offset, since a spilled float lives on the
stack rather than in an xmm — and threading a frame through every guard’s
lowering to recover an operand for two call sites is not worth it.
What the log will not do
It will not decode a word it cannot vouch for. A Value cause is
checked (Value::debug_check) before it is decoded, and the raw bits are
always printed alongside. The write-back between capture and log can run a
GC, so an object reachable only from the guard’s register may be gone by
then; such a word reads as <not a Value> rather than as a plausible lie.
It will not attribute a branch it did not see. A handler entered without
a trampoline — an evict resuming through a patched return address, say —
reports guard: unknown (handler entered without a trampoline). The site id
is taken and cleared on every read, so a later entry cannot inherit an
earlier one’s identity.
It will flag its own contradictions. A ValueVsBaked guard that reports
a miss on bits equal to what it compares against prints
!!! guard reported a miss on equal bits. That exact contradiction — a
guard appearing to fail against the value it was testing for — is what cost
four hypotheses before the log could state it.
Scope
Everything here is cfg(feature = "deopt"). Normal builds emit byte-identical
code: deopt_label compiles to the bare handler label and the cause
argument is discarded. profile-only builds keep their original call site.
aarch64 records every deopt but not the branch that took it. a64_gen_deopt
calls log_deoptimize at the x86 position (after the write-back, before the
chain walk), so deopt builds print each record with its bytecode index and
exit kind, and profile builds fill the deoptimization table. There are no
trampolines, so every aarch64 record reads
guard: unknown (handler entered without a trampoline).
Until the call existed, an aarch64 guard that failed on every execution was
invisible to both builds.
The trampoline cannot be copied from x86 as is. x86 parks it on the cold
page; aarch64 guards reach their handlers with tbz/tbnz (±32 KiB), which
is why the handlers are emitted as islands within reach
(a64_thunk_side_exits), and a cold-page trampoline would be out of range.
A port has to place the trampolines in the same islands as the handlers.
Polymorphic call サイトの最適化 — 設計メモ
Kind: plan(第一歩の nil? 耐性ガードは実装済み・このブランチに同梱。
一般化はここでの設計判断待ち)
このメモは、ruby-bench の binarytrees / protoboeuf-encode 調査
(String#<< インライン化・trailing-splat fast path は #1109 で master 入り)
の過程で見つかった レシーバ多相なコールサイトの deopt 問題 について、
実測・実装済みの第一歩・残された設計空間をまとめたものである。
1. 現状のコールサイト設計と、その盲点
JIT のメソッド呼び出しは monomorphic 前提 で組み立てられている:
- VM がインラインキャッシュを温める(receiver class → FuncId)。
- JIT はキャッシュされた 1 クラスで レシーバクラスガード を発行し、
ガードの内側で FuncId を確定させ、インライン生成器
(
inline_info.get_inline)か直接呼び出しへ落とす。 - ガード失敗は deopt。
BinCmpサイトのみ、ミス回数が温まるとRecompileReason::BecamePolymorphicで「多相対応版」へ再コンパイル する経路(Part B)がある。ふつうのメソッド呼び出しにはこれが無く、 ガード失敗は永久に毎回 deopt する。
盲点は「nil との二相」である。Ruby の慣用句はレシーバが nil かもしれない 場所でこそメソッドを呼ぶ:
return 1 if left.nil? # left は Array か nil(binarytrees の item_check)
limit = to unless to.nil? # to は Numeric か nil(numeric.rb の step)
step ||= 1
if by == nil ... # 同型の == バリアント
このため nil? / == nil サイトは 設計上 50/50 で二相 になり、
毎回 deopt が観測された。
2. 実測(2026-08、x86-64 Linux、release)
-
binarytrees(#1109 適用後):
item_checkのleft.nil?サイトで 1 実行あたり 458 万回の deopt(profileフィーチャで観測。 deopt ログはPOLYMORPHIC [NilClass])。 -
マイクロベンチ(2^16 ノードの木 ×40 周、YJIT 比):
変種 monoruby(修正前) 修正後(§3) CRuby+YJIT left.nil?353ms 64ms 87ms left(truthiness)317ms 62ms 90ms left == nil885ms 489ms(未対策) 193ms -
ベンチ全体: binarytrees 118ms → 100ms(−15%)。
-
== nilが truthiness より 2.8 倍遅いのは、==サイトも同じ nil/非 nil 二相 deopt を踏んでいるからで、未回収の最大候補。
3. 第一歩として試作した nil? 専用の nil 耐性ガード(撤去済み)
注: この節の
GuardClassOrNil実装は一度ブランチに入れたが、 §7 の一般機構(観測クラス集合が単一 native FuncId に収束するサイトのGuardClassIn)がnil?サイトも吸収するため、PR からは撤去した。 nil? は現在、集合ガード + 組み込み呼び出しとして扱われる。ガード後に 値比較 1 命令へ畳む特化(このメモの元の §3 形)は、生成器の nil 安全 flag(§5.1)とセットで将来の最適化として残る。§3.3 の page 耐性修正だけは、集合ガードも同じ「state 未確定 → cold ブロックに Float unbox」経路を踏むため残している。以下は設計記録。
このブランチに含まれる(master 未マージ)。構成は 3 点:
3.1 GuardClassOrNil(AsmIR / LIR / 両アーキ lowering)
「レシーバが nil なら通過、そうでなければ従来のクラスガード」。 nil ケースは比較 1 回+分岐 1 回で、deopt と違い定常コストがほぼ無い。
cmp recv, NIL_VALUE
jeq nil_ok
<従来の guard_class(recv_class), 失敗は deopt>
nil_ok:
3.2 フロントエンドのゲート(compile_method_call)
適用条件(健全性の根拠ごと):
- サイト名が
nil?、simple、引数 0。 recv_class != NIL_CLASS(nil 単相なら従来ガードで十分)。jit_check_call(NIL_CLASS, name)の解決 FuncId が、キャッシュ クラスでの解決 FuncId と一致すること。これが健全性の核で、 「nil が来ても非 nil が来ても呼ぶべきメソッドは同一」をコンパイル時に 証明する。直前に発行済みの class version guard が再定義を無効化する。NilClass#nil?だけが再定義されていれば一致せず、自動的に従来経路へ 戻る。第三のクラス(独自nil?を持つ等)は従来どおり deopt して 正しくディスパッチされる。- ガード通過後、abstract state は refine しない(レシーバは
「nil か recv_class」なので、クラスを確定させたら嘘になる)。
未確定状態の
kernel_nil生成器はrecv == nilの値比較 (is_nil_to_bool)を出すので、これがそのまま両ケースの正解になる。
3.3 副作用: page 規律との衝突(修正済み、ただし全面解消ではない)
state を refine しないことの下流コスト: to.nil? の後で to を Float
として使うコード(numeric.rb step)では、Float unbox(ガード付き
Value→f64)が cold ブロック(page 1 に out-of-line 発行される基本
ブロック)内 に現れるようになった。x86-64 バックエンドの多くの
lowering は「hot(page 0)から呼ばれ、自分の cold スニペットを page 1 に
置く」前提で assert_eq!(0, get_page()) を持っており、ここで abort した
(numeric_step テストで決定的に再現)。
クラッシュ経路の float_to_f64 / float_val_to_f64 は、guard_class の
fail ラッパが既にやっている二形態(page 0 なら cold スニペットを
page 1 へ、page 1 で発行中ならその場に inline 化して飛び越す)に直して
解消した。ただし同種の assert は binary_op.rs 等にも十数箇所残って
おり、「refine しない state を広げる」設計はこの規律と面で衝突する
(§5.4)。
4. なぜ nil? に限定したか
仕組み(FuncId 一致検証 + GuardClassOrNil)自体は名前非依存だが、
ガード通過後に走る インライン生成器の前提 が問題になる。生成器の
多くは「ガード済み recv_class のレシーバ」を前提に書かれている
(例: Hash#[] は as_hash へ直行)。nil が素通りすれば即クラッシュ
なので、無条件の一般化は生成器全数の nil 安全性監査を要求する。
kernel_nil は未確定状態で値比較を出すだけなので唯一安全と言い切れた。
5. 設計空間(これから決めること)
5.1 nil 耐性ガードの一般化 — 最小コストで == nil を回収する
適用条件を「名前 = nil?」から次へ広げる:
jit_check_call(NIL_CLASS, name) == func_idかつ (a)func_idにインライン生成器が無い、または (b) 生成器が nil 安全(InlineFuncInfoに flag を追加)
(a) が成り立つ場合、通常呼び出しは FuncId 直接ディスパッチなので
レシーバが nil でも健全(フレーム構築・visibility はクラス非依存)。
候補: ==(NilClass は独自 == を持つため一致しないケースが多い点に
注意 — 要確認)、is_a? / kind_of?、respond_to?、frozen? など
Object/Kernel から継承される述語群。left == nil の 885→489ms(§2)が
最初の回収目標。
コスト: 小。リスク: 生成器 flag の付け間違い。§3 の機構をそのまま使う。
5.2 Polymorphic Inline Cache(N-way ディスパッチ)→ 採用方針。§7 の調査記録を参照
一般解。サイトごとに観測クラス列 (class, FuncId) を 2〜4 way まで持ち、
線形比較チェーンで分岐、全ミスで deopt/再コンパイル。BinCmp の
Part B(BecamePolymorphic 再コンパイル)を method call 全般へ拡張する
形が自然で、「初回は monomorphic でコンパイル → ミスが温まったら
polymorphic 版へ再コンパイル」という既存のポリシーに乗る。
方針決定(2026-08): VM が実行時に polymorphic を検出して
バイトコードへ書き込む既存機構(opcode_sub)を土台に、
CallSiteInfo に 4-way 程度の PMC を蓄積し JIT コンパイル時に利用する。
nil? はこの一般機構で自然に扱える(§7.2)。== など二項演算子命令へ
の適用可否は §7.3 の調査結果のとおり「検出機構は既にあり有効、
ただしキャッシュがペア形で置き場所の追加が要る」。
論点(§7 の調査で一部解決):
- インライン生成器との併用: way ガード通過後はレシーバクラスが
確定するので、way ごとに生成器を適用しても ③ の unrefined-state
問題は起きない。ただし最小実装は「全 way が同一 FuncId のときだけ
生成器適用(
nil?がこれ)、それ以外は way → 直接 FuncId 呼び出し」。 - abstract state: way ごとに事後状態が異なる。合流で全 way の join を取る(≒ Value に落ちる)なら §5.3 の問題に帰着する。 最小実装はチェーン全体を 1 命令として扱い、結果は Value で合流。
5.3 abstract state に union を持たせるか
現在の Guarded ラティスは単一クラス(+ Fixnum/Float 系の特例)。
nil? 対応で「refine しない」を選んだが、recv_class ∪ NilClass の
ような 2 要素 union を表現できれば、非 nil 側の下流(unless to.nil?
の then 側など)は分岐条件から recv_class 単相へ 絞り直せる。
truthiness 分岐(if x / unless x.nil?)で nil を落とす flow-sensitive
な絞り込みは、§5.1/5.2 のどちらを選んでも効く直交した改善。ただし
ラティス拡張は merge/bridge 全体に波及するので、最も工数が大きい。
5.4 page 規律の全面解消(前提整備)
「cold ブロック内で page-1 スニペットを使う lowering が走り得る」は、 refine しない state を広げるほど踏みやすくなる。選択肢:
- §3.3 の二形態化を、assert を持つ全 lowering(
binary_op.rsほか 十数箇所)へ横展開する。機械的だが確実。 - LIR 化(
doc/lir.md)の進行に合わせ、cold スニペット配置を encoder の責務にして assert 自体を消す。方向としては正しいが待ちが長い。
§5.1 だけなら影響面は小さい(== 述語群の下流は Float unbox を含み
にくい)が、§5.2/5.3 をやるなら 1. を先に済ませるべき。
5.5 やらないと決めたこと
- ガード全廃(値比較のみで nil? を実装): 「どのクラスも
nil?を 再定義していない」というグローバル性質が必要で、Kernel を include しない BasicObject 系レシーバ(nil?呼び出しは NoMethodError に なるべき)で誤答する。クラスガードは第三クラスの安全網として残す。
6. 提案する順序(§7 の方針決定で改訂)
- VM 駆動 4-way PMC(メソッド呼び出し) — §7.2 の実装点に沿って
CallSiteInfoに PMC を蓄積し、JIT が_polymorphicサイトで ガードチェーンを発行する。nil?はこの一般機構に吸収され、§3 のGuardClassOrNil特例は「全 way 同一 FuncId の縮退形」として整理 し直す(または撤去する)。 - §5.4-1 の page 二形態化の横展開 — way 分岐後のコード配置が cold 側へ広がるため、PMC より先に済ませるのが安全。
- 二項演算子命令へのペア PMC 拡張 — §7.3。
== nil(現況 489ms vs YJIT 193ms)を計測基準にする。
7. 調査記録: VM 駆動 4-way PMC の実装点(2026-08)
7.1 既存の検出機構(前提の確認)
VM は メソッド呼び出し・二項演算の両方で polymorphic を実行時検出
し、バイトコードの opcode_sub バイトに書き込む機構を既に持つ:
- メソッド呼び出し(
vmgen/method_call.rsslow_path): インラインキャッシュ(バイトコード内の CACHED_CLASS / VERSION / FUNCID、1 エントリ)が populated かつ receiver class 不一致のときopcode_sub = 1。直後に必ずruntime::find_method(vm, globals, callid, recv)が呼ばれ、(class_for_ic, FuncId) を解決してキャッシュを 再タグ付けする。 - 二項演算(
vmgen.rsvm_save_binary_class): バイトコード内 IC に (lhs_class, rhs_class) の 1 ペアを保存し、 どちらかの変化を検出したらopcode_sub = 1。
TraceIR はどちらも読み取り済みで、BinCmp/BinCmpBr/BinOp は
polymorphic を 消費している(単相 → ペアガード +
recompile-on-miss(Part B)、多相 → ガードなし generic C-call +
Eq/Ne は即値 fast path(Part C))。一方 TraceIr::MethodCall の
_polymorphic は 未使用(compile.rs で _polymorphic: _)。
つまりメソッド呼び出し側は「検出はあるが JIT が利用していない」状態で、
PMC 案はまさにこの穴を埋める。
7.2 メソッド呼び出し側の実装点
- 記録:
runtime::find_methodは&mut GlobalsとCallSiteIdを 受けて解決結果 (cache_class, fid) を計算済みなので、ここでCallSiteInfoに固定長 4-way の(ClassId, FuncId)列を追記するのが 最小変更。既存のclass_for_ic(Bool 統合)とcacheable = false(frame-dependent super — class タグ 0 で毎回再解決)の扱いを そのまま流用でき、cacheable でないサイトは PMC 対象外にマークする。 5 クラス目が観測されたら megamorphic フラグ(PMC 打ち切り、JIT は ガードなし generic dispatch を選ぶ)。 - 消費:
compile_method_callで_polymorphicが真なら、単一 キャッシュではなく PMC を読み、way ごとの class 比較チェーンを発行:- way ガード通過後はレシーバクラスが確定するので、既存の monomorphic 経路(インライン生成器を含む)を way 内で再利用できる 余地がある。ただし最小実装は「way → FuncId 直接呼び出し、結果は Value で合流」。
- 同一 FuncId の way は 1 経路に畳む。
nil?は全 way が Kernel#nil? に解決されるため、畳んだ結果が §3 のガードレス値比較と 一致する — nil? 特例が一般機構の縮退形になる、というのがこの案の 利点。 - 全 way ミス: deopt(+ 観測が増えたら再コンパイル)か、その場で generic dispatch へ落とすかは選択。Part B の単調再コンパイル 不変量(多相化は一方向)に合わせるなら後者。
- 無効化: PMC エントリは class version guard の傘の下にある (再定義で全サイト再コンパイル)ので、エントリ個別の無効化は不要。
7.3 二項演算子命令(== ほか)への適用可否
有効。ただしキャッシュの形と置き場所が method call と異なる。
- 検出(
opcode_sub)は §7.1 のとおり既にあり、多相サイトは現在 「ガードなし generic C-call」へ落ちている。left == nil(left: Array/nil 二相)の実測 489ms(YJIT 193ms)の残りコストは この generic C-call: Part C の即値 fast path は 両オペランドが 非 heap のときだけ効くので、Array 側の呼び出しが毎回cmp_eq_values(メソッド解決+実行)を払う。 - binop の IC は (lhs_class, rhs_class) ペアで、バイトコード内の
8 バイト(2×u32)に 1 ペアしか置けない。4-way 化はバイトコード内では
無理なので、method call と同じくサイド構造に置く。置き場所の候補:
- binop サイトの一部は
IseqInfo::callsite_map(bc_pos → CallSiteId) でCallSiteInfoに到達できる(polymorphic 分岐が is_func_call 判定に既に使っている)。ペア PMC をCallSiteInfoに置くなら これに乗るが、全 binop 命令に callsite があるかは未確認 (get_callsite_idは Option)。無いサイトにはIseqInfo側に bc_pos キーのテーブルを足す。 - 記録タイミング:
vm_save_binary_classはアセンブラ内でクラス保存 まで。ペア追記は set_poly 分岐(既に slow path)から Rust ヘルパを 呼ぶ形になる。generic C ヘルパ(cmp_eq_values等)は pc を 受けない規約なので、記録はヘルパ側でなく VM 命令側で行う。
- binop サイトの一部は
- way 特化の価値: 第一段階は「ペアガード → FuncId 直接呼び出し」で
解決コストを消すだけでよい。第二段階として、組み込みが確定する
ペアには特化コードの余地がある(例: (Array, NilClass) の
==は Array#== が組み込みなら定数 false)。どちらが効くかはcmp_eq_valuesの内部コスト内訳(解決 vs 本体実行)を測ってから 決める。
7.4 検証結果: callsite 記録と poly 検出の全数確認(2026-08)
callsite 記録(bytecodegen/encode.rs): UnOp(121-124)/
BinOp(160+)/ Cmp 両形(140-146, 150-156)/ Index(132)/
StoreIndex(133)は全て new_callsite + new_callsite_map_entry で
CallSiteId に到達できる。例外は RescueTEq(157)のみ(rescue 節
マッチ専用、常にランタイムヘルパ経由で IC なし — PMC 対象外で問題ない)。
get_callsite_id が Option なのはこの 1 命令のためで、ペア PMC を
CallSiteInfo に置く設計は追加テーブルなしで成立する。
VM の poly 検出(x86_64 / aarch64 でミラーを確認):
| 経路 | 保存 | opcode_sub 検出 |
|---|---|---|
binop/cmp generic(vm_generic_binop → vm_save_binary_class) | ✓ | ✓ |
binop/cmp fixnum fast path(vm_save_binary_integer) | 上書きのみ | ✗(無害 — 下記) |
unop generic(vm_generic_unop → vm_save_lhs_class) | ✓ | ✗ 欠落 |
unop fixnum fast path(vm_lhs_integer) | 上書きのみ | ✗ |
Index / StoreIndex(runtime::get_index/set_index が ClassIdSlot へ無条件代入) | ✓ | ✗ 欠落 |
| メソッド呼び出し slow path | ✓ | ✓ |
- binop/cmp fast path の欠落は実質無害: fast path しか通らないサイトは 単相で、多相サイトは必ず generic を通ってそこで検出される(最大 1 実行遅れるだけ)。binop/cmp は現状のまま PMC の前提を満たす。
- unop の検出欠落 → 実装済み(このブランチ):
vm_save_lhs_class/a64_save_lhs_classにvm_save_binary_classと同じ 「キャッシュ populated かつクラス変化 →opcode_sub = 1」を追加した (generic 側のみ、両アーキ)。TraceIr::UnOpにも_polymorphicを 配線済み(JIT 消費は PMC 本実装で)。 - Index/StoreIndex の検出欠落 → 実装済み(このブランチ): クラス
記録をランタイムヘルパから VM 命令内の機械語へ移した。binop と
同じ
[pc+8]/[pc+12]レイアウトなのでvm_save_binary_class/a64_save_binary_classをそのまま流用でき、検出も同時に付く。get_index/set_indexはClassIdSlotポインタ引数(bit 0 に is_func_call を折り込むハック)を廃止して素のis_func_callを 受ける形に単純化した。TraceIr::Index/IndexAssignにも_polymorphicを配線済み。Index は binop と違い fixnum fast path を 持たないため、検出は全実行に効く(遅延なし)。
7.5 実装済み: PMC の記録と profile ダンプ(2026-08)
記録側を実装した(JIT 消費は未着手)。設計:
- 格納:
CallSiteInfo::pmc: PolyCache(store.rs)。最大PMC_WAYS = 4エントリ(recv, Option<arg>, Option<fid>, count)+ megamorphic overflow カウンタ。キー形状は MethodCall / UnOp = レシーバのみ、BinOp / Cmp / Index / StoreIndex = レシーバ + 第一引数。 - 記録点(すべて slow path のみ、定常状態のコストゼロ):
- MethodCall:
runtime::find_method(単一エントリキャッシュのミス時に 呼ばれる)でclass_for_icと解決済み FuncId を記録。cacheable = false(frame-dependent super)は記録しない。 - BinOp / Cmp / Index / StoreIndex:
vm_save_binary_class/a64_save_binary_classの「初回 population」と「poly 遷移」の 2 分岐 からruntime::pmc_record_binary(vm, globals, pc, old_lhs, old_rhs)を呼ぶ。新クラスは直前にバイトコード IC へ書いた値を pc から読み 戻し、置き換えられた旧ペアも引数で渡して一緒に記録する。 fixnum fast path(vm_save_binary_integer)は IC に無記録で スタンプするため、displacement の瞬間がそのペアを PMC に残す唯一の 機会 — IC は実行とともに変化するので、これを取り逃すと IC を 通過したクラスが失われる。callsite はcfp → iseq → get_pc_index → callsite_mapで解決 (record 分岐のみのコスト)。 なお「fast path のスタンプが一度も displace されずに終わった」 サイトはその 1 クラスが PMC に載らないが、IC 自体が生きている — 消費側は IC の現内容と PMC の和集合を観測集合として扱うこと。 - UnOp: 同様に
vm_save_lhs_class/a64_save_lhs_classからpmc_record_unary(レシーバのみ)。
- MethodCall:
- ダンプ:
--features profileの終了時統計(Store::show_stats)に 「polymorphic method cache」節を追加。サイト数サマリ (recorded / polymorphic / megamorphic)と、slow-path 観測数順の 多相サイト top 40 をclass(/arg)(=resolved-func) xN形式で表示。 count は slow-path 観測数(fast path は記録しないので呼び出し 総数ではない)。 - 実測例(gem 起動込みの小スクリプト): 3,984 サイト記録・35 多相・
13 megamorphic。
initialize/__builtin_allocate__(overflow 196)や Errno 網羅のis_a?(overflow 130)が megamorphic として正しく 弁別され、nil?はArray=Kernel#nil? | NilClass=Kernel#nil? | …と 全 way 同一 FuncId が観測できる — §7.2 の「同一 FuncId way の畳み込み」 の実データがそのまま得られる。
8. このブランチに含まれる実装
x86_64: make the Float-unbox guards page-tolerant— §3.3。 集合ガード(下記)が受け手 state を未確定のまま残すために必要。- VM 検出の全命令完備(unop / Index / StoreIndex)— §7.4。
- PMC の記録と profile ダンプ — §7.5。
- PMC 消費 v1: 観測クラス集合が単一 native FuncId に収束する
method call サイトへの
GuardClassIn(集合メンバーシップガード)+ 通常呼び出し。FuncId はjit_check_callで再計算し PMC の保存値は 使わない。iseq ターゲットはレシーバクラス特殊化があるため対象外 (polymorphic_callテストが検出した制約)。nil?/is_a?/frozen?型の 4-way 多相サイトが deopt ゼロ・ほぼ単相速度になる。
検証済み: フルスイート green、Weird#is_a? 等の第三クラス・
オーバーライドは CRuby と一致。
9. 実装済み: クラス非依存 inline 生成器(PMC 消費 v2、2026-08)
§8 の v1 は集合ガード通過後を builtin 呼び出しに固定していた
(inline 生成器は単一 recv_class 前提のため全面スキップ)。v2 は
「生成コードがレシーバクラスに一切依存しない」生成器だけを型で選別し、
集合ガードの背後でもインライン発火させる。
- 契約の型強制:
InlineGenClassIndependent(globals.rs)はInlineGenからClassId/Option<ClassId>引数を持たない 別型。クラスを参照したくても引数に無いので、契約違反はコンパイル エラーになる。InlineFuncInfo::InlineGenClassIndependentvariant +define_builtin_inline_func_class_independent登録経路を追加。 - dispatch(
compile_method_call): クラス非依存 variant はsame_target_set_guardedでも発火。他の variant(InlineGen、 Float 前提のCFunc_*)は従来どおり集合ガード時スキップ。 - 対象(v2 時点):
Kernel#nil?(値比較のみ)、object_id/__id__(Value::id()のみ)、frozen?(新規 インライン、両アーキemit_frozen_pred)。frozen? の述語はValue::is_frozenの鏡写し: 即値→true、ヒープ Numeric (Bignum/Float/Complex/Rational)→true、他は header FROZEN bit (chilled 文字列は bit が落ちているので false)。 - 対象外のまま:
is_a?/instance_of?/respond_to?系は 静的 recv_class で畳み込む設計なのでInlineGenのまま(多相 サイトでは builtin 呼び出し継続)。動的 ancestor チェック版を書けば 対象化できるが別段階。
テスト: polymorphic_class_independent_inline(4-way サイトでの
nil?/frozen?/object_id、frozen? 述語の全表現アーム、warmup 後の
frozen? オーバーライドの deopt)。
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_gc | monoruby/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_SIZE、Allocator::new で
assert_eq!(64, GCBOX_SIZE))。
主なフィールド(alloc.rs:299 付近):
| フィールド | 意味 |
|---|---|
current_page / head_page / pages | 現ページ / 最上位ページ / 割り当て済みページ一覧 |
used_in_current | 現ページのバンプ位置 |
free / free_list_count | フリーリスト先頭と要素数 |
free_pages | 空きになって再利用待ちのページ(常駐したまま) |
released_pages / total_released_pages | 空きページのうち OS に返したもの(madvise)と、その累計 |
total_gc_counter / minor_gc_count / major_gc_count | GC 回数の各カウンタ |
minors_since_major | 直近メジャー以降のマイナー回数(kind 判定に使用) |
old_count | old 世代オブジェクト数(昇格で +1、メジャーで 0 リセット) |
old_major_threshold | 適応的メジャー閾値(old_count がこれに達したら次はメジャー) |
promoting | マーク中に昇格候補を収集するか(実マーク中のみ true) |
promoted | 今サイクルで昇格したオブジェクト(マーク後に remembered/armed へ分類) |
mark_queue | マーク済み・未走査オブジェクトのキュー(幅優先走査 + 先読み) |
remembered | remembered set(old→young 参照を持つ old オブジェクト) |
pages_since_gc | 前回の収集以降に THRESHOLD まで充填したページ数(gc_trigger_pages() で GC レーンを立てる。§4.1) |
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(ページ圧力を数える位置)
const ALLOC_SIZE: usize = PAGE_LEN * GCBOX_SIZE; // 262144 = 256KB
const MAX_PAGES: usize = 8192;
- アリーナは起動時に
ALLOC_SIZE * MAX_PAGES(= 2GB)を 1 回だけ予約する (Allocator::new、System.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_SIZE が Allocator::new で保証される。
data の後ろにビットマップ 2 枚が同居する(セル本体の外にマークを置く
mark-external 方式なので、生存中のオブジェクト内容を汚さない)。
3. 割り当て(Allocator::alloc)
alloc.rs:779。順序は以下:
- フリーリストが空でなければそこから 1 セル pop(
self.free)。直前の GC で スイープされたセルの再利用。 - 空でなければ現ページのバンプ割り当て。
used_in_current == THRESHOLD(3968)に達したらon_page_pressure()でpages_since_gc += 1、gc_trigger_pages()に達したら poll ワードの GC レーンを 立てる(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:658free_list_count_addr()/total_allocated_addr()— 統計の同期用
JIT コードはセーフポイント外でのみこれらを触る(Rust 側が ALLOC を借用中や
gc() 実行中は触らない)ため、単一スレッド前提でエイリアスは生じない。
4. GC のトリガとセーフポイント
GC は「アロケーションの延長で即実行」はしない。JIT の生きたレジスタが未退避の まま GC ルート走査に入るのは危険なため、フラグを立てて次のセーフポイントで 実行する。
4.1 poll ワードの GC レーン(poll_flag.rs)
VM/JIT が参照する poll ワード(u32、8bit×4 レーン。全体像は doc/safepoint.md §3)の
byte 0 が GC レーン。これを立てる経路:
| 経路 | 実装 | 操作 |
|---|---|---|
| ページ圧力 | on_page_pressure(alloc.rs) | pages_since_gc が gc_trigger_pages()(下記)に達したら set_gc() |
| malloc 圧(§8) | request_gc_if_malloc_over | set_gc() |
GC.start | request_gc(true) | set_gc() + メジャー強制 |
GC.stress | set_stress(true) / 各収集の末尾 | set_gc()(再武装) |
すべて冪等な fetch_or で、シグナル(SIGNAL レーン)・プリエンプト(PREEMPT レーン)とは
byte が分かれているため互いを踏み潰す競合は原理的にない。GC 判定は GC レーン単体で行うので、
純粋なプリエンプト tick やシグナル到着が偽の full GC を起こすことはない(§4.3 手順 2)。
収集の完了時は ack_gc_request(alloc.rs)が GC レーンの byte だけを落とし、
pages_since_gc を 0 に戻す(並行して立った他レーンは保存される)。--no-gc 時の
空収集も同じ経路で要求を無効化するため、レーンが立ちっぱなしで poll が空回りすることはない。
収集間隔はヒープに比例する(gc_trigger_pages)
ページ圧力の閾値は固定値ではなく、稼働中ページ数の一定割合:
gc_trigger_pages() = max(PAGES_PER_GC_TRIGGER, 稼働ページ数 / GC_HEAP_FRACTION)
= max(8, pages / 16)
収集 1 回のコスト(ルート走査・remembered set 走査・ビットマップ走査)は生存量で 決まるのに対し、固定予算はそれを一定量の確保にしか償却しない。生存量が増え続ける プログラムでは総 GC コストが O(生存量 × 総確保量) になってしまうため、予算をヒープに 比例させてコレクタ/ミュータタ比を有界に保つ。
- 128 ページ(32MB)未満のヒープでは
maxの下限が効き、従来と完全に同一挙動 (optcarrot・aobench・sudoku 等はページ数・収集回数・RSS すべて不変)。 - 代償は浮遊ゴミで、ヒープの最大 1/16 が回収を先送りされる。plb2 bedcov(生存 270 万 オブジェクト)では マイナー 202 → 83 回、GC 時間 −35%、実行時間 −12%、RSS +23% (それでも同プログラムの CRuby の RSS より小さい)。
- 収集で全滅したページは
pagesを離れてfree_pagesに移るため、生存量が落ちた ヒープは自動的に予算も縮む。
4.2 poll のコード生成
execute_gc_inner(codegen/arch/x86_64/jit_module.rs:255)が poll を出力:
cmpl [rip + poll_flag], 0
jne gc ; いずれかのレーンが立っていれば slow path へ
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 も
同等のゼロ判定(ldr; cbz)を出力する。
4.3 execute_gc(executor.rs:3743)
セーフポイントから呼ばれる extern "C" 関数。順に:
watchdog::poll()— ハングウォッチドッグのカウントダウンをリセット。poll_flag::consume_preempt()で PREEMPT レーンを消費する。GC 判定は GC レーンで 独立に行う(§4.1)。- 保留シグナルの処理 — SIGNAL レーンをクリアしてから
PENDING_SIGNALSビットマップを drain し、最小番号のシグナルをSignal.trapハンドラ呼び出し / 既定例外(SIGINT ⇒Interrupt等)に変換(doc/signal.md)。 - GC レーンが立っているときだけ GC 本体を実行:
parent_fiberを辿って **ルート Executor(最上位ファイバ)**へ行き、ALLOC.with(|a| a.borrow_mut().gc(&Root { globals, executor }))。 drain できなかった保留シグナルがある poll では収集を次の poll へ延期する (doc/signal.md§4.1)。 - プリエンプトビットが立っていて
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-64cmpb/ aarch64ldrb)なので、隣接バイトが任意の値でも 問題ない。HASH オブジェクトはここにインライン表現のビット(hash.rs のHashFlags)を置く。dup/リテラルコピー(Header::newborn/CellHeader::NewbornOf)はこのバイトを保存する。世代別 GC の age は従来どおりflagの上位バイトに置く。
flag: u16 のビット割り当て(rvalue.rs:2447 以降)
| ビット | マスク | 意味 |
|---|---|---|
| 0 | 0b0000_0001 | LIVE(生存;確保時 flag = 1) |
| 1 | 0b0000_0010 | FROZEN |
| 2 | 0b0000_0100 | CHILLED(Symbol#to_s 由来の準 frozen 文字列) |
| 3 | 0b0000_1000 | OLD(old 世代へ昇格済み) |
| 4 | 0b0001_0000 | WB_UNPROTECTED(shady 用に予約。現状未使用 — §7.3) |
| 5 | 0b0010_0000 | 空き(旧 REMEMBERED。「remembered set 登録済み」は専用ビットではなく OLD ∧ ¬WB_ARMED で導出する) |
| 6 | 0b0100_0000 | WB_ARMED(old かつ未 remembered = 書き込みバリアの slow path 対象) |
| 7 | 0b1000_0000 | CHILLED_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_ARMED、remembered = 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.startはGC_FORCE_MAJORを立てるので、次の収集は無条件にメジャー。
6.2 マークビットマップの準備
| kind | 操作 |
|---|---|
| Major | clear_mark()(mark_bits=0)のみ。全オブジェクトが収集候補に戻り、ルートから再マーク・全スイープ。old_bits と remembered set は保持する(下記)。 |
| Minor | seed_marks()。各ページで mark_bits ← old_bits をコピー(seed_mark_from_old)。old オブジェクトは最初からマーク済みとみなされ、再走査もスイープもされない。 |
メジャーは old 世代を維持する
メジャー GC は old を降格しない。old だったオブジェクトがメジャーを生き延びたら
old のままで、remembered / armed の区別(ヘッダの OLD + WB_ARMED、および
remembered)もそのまま引き継ぐ。
降格していた頃は、生きている old 世代全件が毎メジャーで
「aging への push → age 加算 → promote_to_old → young_child_exists の全子走査」
をやり直していた(age は飽和済みなので昇格自体は同じサイクル内で完了するが、
old 世代の参照辺をもう一度全走査するコストがそのまま乗る)。さらにメジャー直後の
マイナーは old 世代が空の状態から始まるため、シードマークの利得も失われていた。
維持に伴い、死んだ old セルのビットを畳む場所が変わる:
sweep:各ビットマップ語でold_bits &= mark_bitsとし、落ちたビット数をold_countから引く(retire_dead_old)。解放セルは free list 経由で 若いオブジェクトとして再利用されるので、古いビットが残っていると次のマイナーで seed-mark され、新しいオブジェクトの子が一度も走査されない。salvage_empty_pages:全セルが死んだページはsweepの前にpagesから外れる ため、ここでold_countを減らしclear_old_bits()する。- 適応的メジャー閾値
old_major_thresholdの再計算はスイープ後に行う (生きている old 世代を基準にするため)。
remembered set は、マイナーの mark_remembered に加えて、メジャーでは
reclassify_remembered(§6.4)が young の子を失った entry を落として arm_barrier
する。コストは remembered set のサイズ(生きた old→young 辺)に比例し、
old 世代全体には比例しない。
6.3 マークフェーズ
self.promoting = trueにしてからroot.mark(self)(ルートは §8)。RValue::mark→Allocator::mark(alloc.rs)は、ページのビットマップだけを 見てマークビットを立て、未マークだったセルをmark_queue(VecDeque)に積む。 オブジェクト本体(ヘッダ)はここでは読まない。- Minor のみ
mark_remembered():remembered set の各 old オブジェクトの 子だけをmark_childrenで辿る(親 old は既にシードマーク済み)。これにより 「old からしか参照されていない young オブジェクト」に到達する。走査後、若い子が いなくなった entry は set から外してarm_barrier(自己クリーニング)。 - 上記 1・3 の直後に
drain_mark_queue()。キューが空になるまで先頭から取り出し、 各オブジェクトについて (a)check_live(死んだセルなら forensics 付きで abort)、 (b) 加齢と昇格(§6.4)、(c)mark_childrenの順に処理する。ここで到達した子も またmarkでキューに積まれるので、1 回の drain でグラフの残り全体に届く。 マークビットを読む処理(remember_promoted・filter_remembered・スイープ)より 前に必ず drain されている必要がある。 self.promoting = false。
マークキュー(mark_queue)と先読み(MARK_PREFETCH_DISTANCE)
以前のマークは純粋な再帰で、スタック消費がオブジェクトグラフの深さに比例して
いた。a = [a] を 7.5 万回、連結リスト、ivar チェーン、入れ子 Hash — いずれも
8MB のメインスタックを溢れさせ、GC の最中にプロセスが abort していた
(thread 'main' has overflowed its stack)。その後しばらくは「32 段までは再帰、
それ以降はキュー」という折衷だった(全部キューに積むと bedcov で GC 時間 +9% と
測れたため)。
現在は全オブジェクトをキュー経由で幅優先に辿る。決め手はスタックではなく
キャッシュミスで、マークの費用はほぼ「オブジェクトのヘッダを 1 行読む DRAM
アクセス」そのものだった(splay: 1 マークあたり 60–90 ns)。再帰では子のヘッダを
読むまで次のアドレスが分からずミスが直列化するが、キューなら数個先のエントリの
アドレスが既に手元にあるので、drain_mark_queue は MARK_PREFETCH_DISTANCE
(= 8)個先のヘッダを prefetch してからいまのオブジェクトを走査する。これで
ミスが重なり、splay のマーク走査は 1 オブジェクト 28–35 ns(2 倍強の高速化)、
GC 時間全体で −50% になった(§6.4 の変更込み。12 反復の GC 合計 1415 → 714 ms)。
mark 側でヘッダを読まない(ビットマップのみ)ことが前提で、is_live の検査と
昇格判定はすべて drain 側に移してある。
キューの実体はヒープなので、500 万段の連結リストでも通り、ネイティブスタックの 使用量はグラフの深さに依らず 1 段で済む。
6.4 加齢と昇格(drain_mark_queue / remember_promoted)
加齢と昇格は drain_mark_queue がオブジェクトを取り出したその場で行う
(§6.3 の 4-(b))。取り出した生ポインタからヘッダを書き換えてから、mark_children
用の &T を作る — この時点でそのセルへの共有参照は存在しない(キューに積んだ
&T は mark から戻った時点で消えている)ので、ヘッダ書き込みはエイリアスしない。
以前は「マーク走査が握る &self と衝突しないよう、マーク後に aging 配列を
なめ直す」2 パス構成だったが、それは生存者全員をもう一度ランダムアクセスする
パスで、splay ではマーク時間の 20–30% を占めていた。いまは走査が読むヘッダ行の
上でそのまま加齢する。
- 加齢:
promotingかつis_promotable()の生存者の age を +1 (age_and_check_promote)。age >= RGENGC_OLD_AGE(= 3)に達したものを昇格:old_bitsをセット + ヘッダ OLD をセット +old_count += 1+promotedに記録。 → 即時昇格ではなく「3 回生存したら昇格」。1 回の収集でたまたま生きていた 短命オブジェクトを old に上げてしまい浮遊ゴミ化するのを避ける。 メジャーは old も普通にマークするため、既に old のセル(old_bits)は加齢しない (major_markフラグで、この余分なビットマップ読みをマイナー側に持ち込まない)。 remember_promoted(マーク完了後): remember-on-promote。昇格したオブジェクトが まだ young を参照している(young_child_exists)なら remembered set に追加 (バリア導入前から存在した old→young 辺をカバー)。young 参照が無ければarm_barrierして以後の young ストアに備える。今サイクルの昇格が全部見えてから 走るので、子より先に昇格した親が無駄に remembered されることはない。
メジャーではこの後に reclassify_remembered(filter_remembered で死んだ entry を
落とした後)が走る。生き残った entry のうち young の子を失ったもの
(この収集で子が昇格した/死んだ)を set から外し arm_barrier する — マイナーの
mark_remembered に内蔵された自己クリーニングと同じ役割で、メジャーが set を
作り直さなくなった分をここで担保する。
注: 以前はメジャーが remembered set を毎回ゼロから作り直していたため、 書き込みバリアの漏れがあってもメジャーごとに自己修復されていた。維持方式では それが無いので、バリア契約(§7.3 の
is_promotable)の破れはマスクされずに 顕在化する。gc-stress+gc-verifyで検証すること。
6.5 マイナー後の検証(gc-verify フィーチャ)
マイナー GC の後、シード無し・昇格無しでルートから全ライブグラフを独立に再マーク
する(alloc.rs:964)。もしマイナーが到達可能なオブジェクトを解放していれば
(バリア漏れ/remembered set 漏れ)、この走査が解放済みセルに到達し
RValue::mark の is_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::mark は threads / 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)。
8.1 ビルトインの一時値 — 素の Vec<Value> はルートではない
ルート走査は上記の列挙がすべてなので、Rust 側のローカル(Vec<Value>、
HashMap<_, Value>、単なる Value 束など)は GC からまったく見えない。
vm.invoke_block / vm.invoke_method_inner / vm.invoke_proc はいずれも
任意の Ruby を走らせる = セーフポイントを跨ぐので、
原則: Ruby 呼び出しを跨いで生かしたい
Valueは、必ずtemp_stack(temp_push/temp_array_new+temp_array_push/temp_array_extend_from_slice/with_temp_scope)に載せる。
引数ベクタのように「組み立てた直後に 1 回だけ invoke へ渡す」用途は、
その間にセーフポイントが無いので素の Vec で構わない。危険なのは
invoke をループで回しながら結果を貯めるアキュムレータである。
Executor にはこの型の定型処理を安全側に閉じ込めたヘルパがある:
| ヘルパ | 用途 |
|---|---|
invoke_block_iter1 | each 系(結果を捨てる) |
invoke_block_iter1_rooted | 同上。イテレート元を先に materialise してルート付けする |
invoke_block_map1 | map 系(結果を 1 個ずつ push) |
invoke_block_flat_map1 | flat_map 系(Array なら展開して push) |
実例(gc-stress CI の optcarrot abort): Array#flat_map は #to_ary 対応を足した際に
invoke_block_flat_map1 から素の let mut res: Vec<Value> に書き換えられ、
ブロック呼び出しを跨いで貯めた要素がすべて未ルートになっていた。通常ビルドでは
GC の閾値に届かず表面化しなかったが、gc-stress(毎セーフポイント収集)では
optcarrot の Palette.defacto_palette(512 要素の flat_map)が返す Array が
解放済み RValue で埋まり、次のマークで DEAD RVALUE reached in mark で abort した。
再現は 9 行で足りる:
src = [[1.0, 1.0, 1.0]] * 8
res = src.flat_map { |rf, gf, bf| (0...64).map { |i| [i * rf, i * gf, i * bf] } }
診断のコツ: RValue::mark の DEAD 検出点で、(a) Lfp::mark 側に
「いまマーク中のフレームの func_id とスロット番号」、(b) RValue::mark 側に
「いま children を辿っている親 RValue」を thread-local で持たせて出力すると、
どのメソッドのどの一時値が壊れているかが一発で分かる。今回は
func=Video#initialize(driver.rb:67) slot=2 / parent=Array(len=512) まで出て、
そこから flat_map に到達した。
(b) の「直接の親」は常にバックトレースに出る — 失敗した mark を呼んだのは
親の mark_children フレームだからである。ただし §6.3 のマークキューに積まれた
地点より上の祖先はバックトレースから切れるので、DEAD 検出点は
scanning from: <ptr> ty=...(Allocator::mark_referrer)としてその
キュー entry を併せて出力する。scanning from: root set なら、走査はまだルート集合の
中にいた — つまり壊れた辺はオブジェクトの中ではなくルート側(ビルトインの
未ルート一時値、古いフレームスロット)にある。
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 へ戻す。以後の割り当てで再利用される。
free_pages のうち予備(稼働中ページ数の 1/8、最低 2 枚:
FREE_PAGE_RESERVE_FRACTION / FREE_PAGE_RESERVE_MIN)を超えた分は
release_excess_free_pages が OS に返す(released_pages へ移し、Linux は
madvise(MADV_DONTNEED)、macOS は MADV_FREE_REUSABLE)。アドレスはアリーナ予約内に
留まり、free_pages が尽きたときに take_free_page が(macOS では MADV_FREE_REUSE
を打って)再び稼働に戻す。返したページの中身は不定だが、稼働に戻るページは
clear_old_bits とバンプ割り当てが読む前にすべて書き直すので問題ない。
以前は空きページを常駐させたままだったため、ワークロードのピーク時のヒープが そのまま RSS に残っていた(lee: 生存 22 ページに対して常駐 ~140 ページ、30 MB)。 予備は 2 回の収集ぶんの成長(トリガー予算は稼働ページの 1/16)を賄うので、 定常状態のヒープは常駐ページを回し、縮んだヒープだけが再タッチのページフォルトを払う。
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/deallocでMALLOC_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)がMALLOC_AMOUNT >= MALLOC_GC_THRESHOLDで poll ワードの GC レーンを立てる(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.start | builtins/gc.rb + __request_gc | request_gc(full_mark) で収集を要求したあと、ループ後方辺(セーフポイント)を跨いで GC.count が進むまで回るので、CRuby 同様に回収を終えてから返る。builtin の中で直接 gc() を呼べないのは、JIT 呼び出し元の生きたレジスタがセーフポイント以外では退避されておらずルート走査から見えないため。full_mark: false はマイナーを許す(強制しない)。 |
GC.disable / GC.enable | Globals::gc_enable(false/true) | GC の有効/無効を切り替え、直前の disable 状態を bool で返す。GC_ENABLED(§4 の malloc 経路が参照)も同期。 |
GC.count | total_gc_counter | 総 GC 回数。 |
GC.stat | stat(CRuby 4.0 のキー順) | ページ数・スロット数・累計確保/解放オブジェクト数・old 世代・malloc 量・フェーズ別時間まで実カウンタ。圧縮とファイナライザ、CRuby 固有の old malloc 会計だけが 0(概念が無いため)。 |
GC.total_time / GC.measure_total_time | gc_time_ns | gc() の実測ナノ秒。measure_total_time = false の間は計測自体を行わない(GC::Profiler が有効なら計測は続く)。 |
GC.stress | Allocator::stress | 収集の最後に poll フラグをトリガ帯へ戻すので、以降すべてのセーフポイントで収集する。CRuby の「確保ごと」は JIT が確保の高速路をインライン化する都合で再現できないが、ルート漏れの炙り出しという用途は同じ。 |
GC.config | builtins/gc.rb + __allow_full_mark | :rgengc_allow_full_mark は実ノブで、false の間 decide_gc_kind はメジャーを選ばない(明示的な GC.start は依然メジャーを強制する)。:implementation は読み取り専用。 |
GC.auto_compact / GC.compact | — | NotImplementedError。monoruby の収集器はオブジェクトを移動しないので、CRuby が圧縮非対応環境で返すのと同じ答えを返す。 |
GC::Profiler | Allocator::profile | 有効な間、収集ごとに GcProfileRecord(invoke time / 所要時間 / live バイト / ヒープ総バイト / 総スロット / メジャーか)を積む。result は CRuby と同じ表形式、raw_data は同じキー、total_time は秒の Float。 |
コマンドラインでは --no-gc で GC を無効化できる。GC 無効時は gc() が即 return
するため、request_gc_if_malloc_over は GC_ENABLED を見て要求自体をスキップする
(さもないとフラグがトリガ帯に張り付いて poll が空回りする)。
13. デバッグ・検証用フィーチャ
| フィーチャ | 効果 |
|---|---|
gc-log | 終了時に GC 統計を出力(old 数の実 popcount 等)。 |
gc-debug | GC 中の各種アサート・ダンプ。old_count と実 popcount の一致検証など。 |
gc-stress | 毎セーフポイントで無条件に強制 GC(実行時 GC.stress フラグとは独立。execute_gc が常に収集し、GC レーンを常時再アーム)。bin/test の nextest フェーズが使用(CI では x86-64 のみ)。世代別のバリア/remembered set 漏れや、Rust ローカルに保持したままの未ルート Value を最も強く炙り出す。 |
gc-verify | マイナー GC 後に独立フル再マークで健全性検証(§6.5)。 |
環境変数 MONORUBY_MALLOC_HARD_LIMIT(例 3G。K/M/G サフィックス可)を設定すると、
malloc 総量がこれを超える確保が要求された瞬間に、要求サイズとバックトレースを
stderr へ出力して abort する(alloc.rs の malloc_hard_limit)。OOM でマシン/
ランナーごと死んでログが失われる環境(darwin CI)で、暴走アロケーションを
「名前付きで診断可能なクラッシュ」に変換するための装置。ポーリング型の監視では
捕捉できない単発の巨大確保も、アロケータ内の同期チェックなので確実に捕まる。
未設定なら無効(コストは relaxed load 1 回)。
14. まとめ
- monoruby の GC は 非移動・単一スレッド・stop-the-world の世代別 mark & sweep。
- 256KB ページ + マーク/old の 2 枚のビットマップ(mark-external)で、非移動と 世代別を両立。ページはアドレスマスクで O(1) 逆引き。
- 割り当てはフリーリスト → バンプ。ページ圧力の閾値到達で poll ワードの GC レーンを立て、
次のセーフポイントで
execute_gcが同期収集する(JIT レジスタ退避のため即実行はしない)。 - 世代別の心臓部は、3 回生存で昇格(aging)・適応的メジャー閾値・ 1 ビット高速パスの書き込みバリア + remembered set(自己クリーニング付き)。 マイナーは old をシードマークして young + old→young 辺だけを辿る。
- メジャーも old 世代を維持する(降格して作り直さない)。old のビットと remembered/armed の区別はオブジェクトと寿命を共にし、死んだ old セルの後始末は スイープとページ回収が担う。old 世代が大きいほどメジャーが安くなる (old 61 万で 1 回あたり 74ms → 27ms)。
- マークの走査は幅優先のマークキュー(
mark_queue)一本で、数個先の エントリのヘッダを先読みしてキャッシュミスを重ねる。加齢・昇格も取り出した その場で行い、生存者を二度なめない。ネイティブスタックの使用量はグラフの 深さから切り離されている。 - 外部 malloc 圧・シグナル・
GC.startも同じ poll ワード経由で同一のセーフ ポイント収集に集約される(レーン分割はpoll_flag.rs/doc/safepoint.md§3)。
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.rs | ThreadInner(スレッド制御ブロック)と状態機械 |
monoruby/src/value/rvalue/fiber.rs | FiberInner(Fiber 制御ブロック) |
monoruby/src/builtins/thread.rs | Thread クラスのネイティブ・ビルトイン |
monoruby/src/builtins/fiber.rs | Fiber クラスのビルトイン + JIT インライン Fiber.yield |
monoruby/src/codegen/arch/{x86_64,aarch64}/invoker.rs | コンテキストスイッチのスタブ(両アーキ) |
monoruby/builtins/startup.rb | Mutex / Queue / SizedQueue / ConditionVariable(純 Ruby)、Thread の簿記系メソッド |
monoruby/src/builtins/io.rs | blocking_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 系統:
- ブロッキング地点。
sleep/Thread.stop/#join/Thread.pass/ ブロックする IO / 同期プリミティブの待機で、実行中のスレッドが自発的に スケジューラへ制御を返す(協調型)。 - タイマ駆動プリエンプション(§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.rs、ObjTy::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_deadlock は
ThreadInner のフィールドではなく、すべて 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_RSP は OS スレッドごと(thread_local の Cell<u64>)。各 OS スレッドの
Codegen が自分のスロットのアドレスをスタブに焼き込む(poll ワードと同じ構図)。
テストハーネスのように複数のインタプリタが別 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) は全て同型:
- 短い
RefCell借用で自分を適切な待機構造(sleepers / joiners / io_waiters / ready)に 登録し、state を更新する(借用をスイッチをまたいで保持しない・借用中に Ruby アロケーションをしない、が規律)。 - green thread なら
park_switch(resume_execを記録してswitch_to_scheduler)。 main ならscheduler_runを呼び、戻ったらtake_main_pending()で 割り込みの配送を受ける。 - 起床後、呼び出し元のループで条件(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.rs、init):
Thread.new/start/fork(本体はキューされ、どこかのスレッドが最初にブロックした時点で
初実行される)、Thread.current/main/list/pass/stop、
Thread.kill(th) / Thread.exit、Thread.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/kill と pending/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用。nativeThread.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 のコメントブロック参照):
- test-and-set はセーフポイントのない一直線コードにする。判定の前に
Thread.current等の呼び出しを巻き上げておき、判定〜フラグ設定の間にセーフポイント (=切替点)を挟まない(Mutex#try_lock)。ただし callee-entry poll がある以上、 メソッド呼び出しを巻き上げても複数呼び出しの列をアトミックにはできない (どの呼び出しもセーフポイント)。複合的な状態遷移にはロックが唯一の正しい道具。 - 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#pushon 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の両側)を包む。- 他に生存スレッドがいなければ従来どおり(本当にブロックする)。
- read 系は先にバッファ(ungetc pushback / BufReader 内残データ)を確認し、 あれば fd を見ずに実行(バッファがあるのに fd 未 ready で park すると自己デッドロック)。
- ゼロタイムアウト
pollで readiness を確認し、未 ready ならscheduler::wait_fd(fd, events)で park(§3.3)。起床後に再検査。 - 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)→POLLOUTをwait_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 ワードの PREEMPT レーンをfetch_or。Codegen::drop(codegen_dropped)が同じ mutex 下でflag_addrを 0 に落とすので、 タイマが解放済み JIT メモリを触ることはない(マルチインタプリタのテストハーネス対策)。
8.2 レーンプロトコル
poll ワードは GC・シグナルと同じ 1 つの u32 で、8bit×4 のレーンに分割される
(全体像は poll_flag.rs / doc/safepoint.md §3):
| byte | レーン | 書き手 |
|---|---|---|
| 0 | GC | アリーナのページ圧力 / malloc トリガ / GC.start |
| 1 | PREEMPT | プリエンプトタイマ |
| 2 | SIGNAL | シグナルハンドラ |
- 全書き込みは冪等なアトミック
fetch_or/ 自レーン byte のみのfetch_andclear。 タイマ(別 OS スレッド)とシグナルハンドラ(async-signal 文脈)が同じ語に書いても、 レーンが byte で分かれているため互いの更新を失わない。 - poll はワード全体のゼロ判定(
cmpl …, 0; jne/ldr; cbz)なので符号の罠もない (旧設計は符号付きjgeの都合で PREEMPT がビット 30 に制限されていた)。
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)の順序:
watchdog::poll()。let preempt = poll_flag::consume_preempt() || preempt::stress();— PREEMPT レーンを 消費する。- 保留シグナルを drain(エラーを立てて
Noneを返しうる)。 - GC レーンが立っているときだけ実際に GC(純プリエンプト tick は GC レーンに触れないので スキップ = 偽の full GC を起こさない)。
stress_renudge()— stress モードでは切替の前にレーンを再武装し、切替先スレッドも poll するようにする。if preempt && scheduler::preempt_ok() { scheduler::pass(vm, globals)? }。passのErr(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 スレッドへ逃がす。
- オフロード対象(
NativeOp):flock(2)のブロッキング取得(File#flock)。LOCK_NB/LOCK_UNは カーネルでブロックしないのでインライン実行。- FIFO に対するブロッキング
open(2)(相手が開くまでブロックする)。事前にstatして FIFO のときだけオフロードし、それ以外の open はインライン。 fcntl(2)の待つコマンド(NativeOp::Fcntl):F_SETLKW(および Linux のF_OFD_SETLKW)は record lock が空くまでカーネルで待つ。IO#fcntlに String (packedstruct flock)を渡す形だけが対象で、待たないコマンドは インライン実行のまま(F_GETLKは文字列バッファに書き戻すため、なおさら インラインでなければならない)。待つコマンドは構造体を読むだけなので、 ジョブは自分のコピーを持ち、結果を書き戻す必要がない。- blocking 指定された FFI 呼び出し(
NativeOp::Ffi)。上 2 つと違い走るのは Ruby プログラムが選んだ任意の C なので、「生データのみ」の規律は fiddle 側で 担保する:FfiWorkerCallは不死の descriptor のアドレスとマーシャル済み C 引数 だけを持ち、runは Ruby ヒープに触れない。ワーカーは生の 64bit 結果を返し、 インタプリタ側スレッドがbits_to_valueで box する。- opt-in:
Fiddle.___prepareの flags(2 = BLOCKING)で宣言する。 FFI ではattach_function ..., blocking: true、sqlite3 ブリッジではattach_function ..., blocking: true。 - 本当にブロックする関数だけに付ける。オフロード往復はワーカーが温まっていて
40µs 前後(condvar 受け渡し + park/wake)。CPU 密な green thread がいると再開は
タイムスライス 1 回分待たされる。行単位で呼ばれる関数に付けてはならない
(
sqlite3_stepを blocking にすると 2000 行の SELECT が ~2ms → ~80ms になる)。
- opt-in:
- ワーカープール:
submitはジョブをキューに積み、park 中のワーカーがいれば それに渡す(往復は thread spawn ではなく condvar の受け渡しになる)。 ワーカーが全員塞がっているときだけ新しく生やす —— カーネルでブロック中の ワーカーは何分も戻らないことがあり、その後ろにキューイングしたら §9 が避けたい ストールそのものになるため。ワーカーは 10 秒 idle で終了する。 - fork(2) の子ではワーカーは生き残らない。子は
idleカウントと親のキュー、 親宛てのresults/orphansを受け継ぐので、Process._fork/Process.daemonの 子側でnative_pool::ForkLocks::reset_childがそれを捨てる(捨てないと、子の最初のsubmitが「idle ワーカーが拾う」と信じて永久に待つ)。 そのロック自体も fork をまたいでフォークするスレッドが先に取る (native_pool::prepare_fork、pthread_atforkの prepare/parent/child を手で書いた もの)。ワーカーがロックを握った瞬間に fork すると、そのワーカーのいない子は 誰も解放しないロックを受け継ぎ、最初のlock()で永久に止まる。do_spawnも同じ扱いにしてある(その子は exec するだけでこれらのロックを取らないが、 「すべての fork(2) は pool を静止させてから」という不変条件を揃えておく)。 - 同じ危険はプロセスグローバルなロック全部にある —— 識別子テーブル
(
id_table::ID)、正規表現のコンパイルキャッシュ(REGEX_CACHE/NATIVE_CACHE)、 標準ストリームのバッファ(STDIN_BUF/STDOUT_BUF/STDERR_BUF)。monoruby 自身の スレッドは green なのでインタプリタスレッドが自分と競合することはないが、複数の インタプリタを別 OS スレッドで走らせる embedder(テストハーネスはテストスレッドごとに 1 つ走らせる)では、fork の瞬間に別スレッドがどれかを握っていることがある。実際にforking_while_a_thread_keeps_offloadingの子がFile.open→IdentId::get_idで 止まった(識別子テーブルのRwLockを別スレッドが握っていた)。crate::fork::prepareがこれらすべてを pool のロックと一緒に、固定順で、フォークするスレッド上で取る (どのパスもこれらを入れ子では取らないので、この順で他スレッドとデッドロックしない)。 親は guard を drop、子はForkGuards::reset_childで pool とタイマの状態を捨てつつ 解放する。Process._fork/Process.daemon/do_spawnの 3 箇所のfork(2)は すべてここを通る。fork::testsに、別 OS スレッドが intern し続ける中で 200 回 fork する再現テストがある(修正前は最初の 10 秒で子が止まる)。 ただし子が受け継いだRwLockを unlock してはいけない: Linux の stdRwLockは futex 語で、子での unlock は store と誰も起こさない wake だが、Apple では queue lock (std::sys::sync::rwlock::queue)で、unlock は親の待機スレッドがスタックに残した ノードを辿って各スレッドをdispatch_semaphoreで起こしにいく —— libdispatch は マルチスレッドプロセスの fork 子で trap する(darwin ランナーで、fork 時に待機者が いた最初の回にSIGTRAP)。そこで識別子テーブルと正規表現キャッシュはfork::ForkableRwLock(AtomicPtr<RwLock<T>>の間接参照)にし、子は自分が持つ guard 越しにデータをmem::takeで取り出し、guard をforgetして、新しい lock を 差し込む。受け継いだ lock はロックされたまま leak し、二度と触らない (preempt::ForkStateがタイマの mutex にしているのと同じ)。Mutex(ストリーム、 pool)は Apple では pthread、Linux では futex で、所有者による子での unlock は 誰も起こさない普通の unlock なので、そのまま drop する。 - 完了パイプは子で作り直す(
replace_pipe)。fork(2)が複製するのは fd テーブルで あってパイプではないので、親子が同じバッファを読み書きすることになる。親の waiter が 取るはずだった完了バイトを子のdrainが飲んでしまうと、その waiter は結果がresultsに載ったまま永久に park する —— 親側には異常が何もないのに復帰できない。 子で継承端を close しても影響するのは子の fd テーブルだけなので、親のパイプは無傷。 - ワーカーは Ruby ヒープにも VM のスレッドローカルにも触れない。
NativeOpは生 fd / フラグ /CStringパスだけを運ぶ(ヒープ参照を持たない)。共有状態はプロセスグローバル:results(Mutex<HashMap<ticket, Completion{ret, errno}>>)、orphans、NEXT_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_fdがErrを返したら、チケットをdiscard(結果が既に届いていれば除去、まだならorphansに入れてワーカーの結果を 到着時に捨てる)しエラーを伝播する。ワーカー本体はそのまま無害に完走させる (可搬なキャンセル syscall がないため)。
10. 既知の制限と今後
- シグナルは「ポーリングしたスレッド」で変換される(CRuby は main に配送)。
- (解消済み)
Threadのサブクラスは Ruby のinitializeオーバーライドを実行する ——class MyThread < Thread; def initialize(*a); ...; super; end; endは CRuby と 一致する。 Thread#priorityは保存のみ(スケジューリングに影響しない)。native_thread_idは 実 tid ではなくオブジェクト単位トークン。ThreadGroup/forkとの相互作用、Thread.ignore_deadlockの実効(検出器の停止)は未実装。- ネイティブオフロード(§9)の往復は、ワーカープール化後も 40µs 前後ある
(park/wake の往復そのもの)。
sqlite3_stepのように行単位で呼ばれる関数は 依然オフロードできず、busy_timeout 待ちは他の green thread を止める。これを 外すには往復を無くす方向(呼び出しをインラインで試し、ブロックしそうなときだけ 逃がす等)が要る。対象操作は flock / FIFO open / blocking 指定 FFI /fcntlの待つコマンドで、まだ増やす余地はある。 - 真の並列化は別の話(Ractor 型の分離が現アーキテクチャ — OS スレッドごとの ALLOC / CODEGEN / SCHEDULER — と整合的)。
(解決済み: Thread.handle_interrupt マスキング、mid-operation の IO ブロック(§7 の
would-block エミュレーション)、タイムスライス・プリエンプション(§8)、
カーネルブロッキング syscall のオフロード(§9)、オフロードのワーカープール化と
fcntl(F_SETLKW) 対応(§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.rbとcore/io/select_spec.rbは完走・全パスする。
Green thread スケジューラ: Thread / Fiber の状態遷移図
doc/threads.md の補足。スケジューラ(src/scheduler.rs)が管理する
ThreadState(src/value/rvalue/thread.rs)と、Fiber の
FiberState(src/executor.rs の fiber_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) |
|---|---|---|
[*] → Created | Thread.new が ThreadInner::new を生成、spawn() が threads + ready に登録 | spawn |
Created → Runnable | ready から取り出され dispatch() が初回起動(スタック確保 → thread_invoker) | dispatch の Entry::Invoke |
Created → Dead | 起動前に #kill / #raise が pending に積まれていた場合、本体を実行せず死亡(CRuby 意味論)。raise は終了例外として記録 | dispatch の Entry::Skip → finalize_unstarted |
Runnable → Sleeping | Kernel#sleep / Thread.stop。deadline None = #wakeup されるまで | sleep |
Runnable → Joining | Thread#join / #value。対象の joiners に登録。timeout 付きなら sleepers にも登録 | join |
Runnable → IoWaiting | ブロックする IO / IO.select。fd ごとに io_waiters へ登録(1 スレッドが複数 fd を待てる) | wait_fd / wait_fds |
Runnable → Runnable | Thread.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 → Runnable | join 対象の死亡(finalize_common が joiners を一括起床)/ timeout / 割り込み | finalize_common / wake_due_sleepers / interrupt |
IoWaiting → Runnable | poll(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/#raiseはpendingキューに積み、対象が park 中(Sleeping | Joining | IoWaiting)かつ マスクが全て:neverでなければRunnableに戻すだけ。実際の配送はdispatchの再開時(Entry::ResumeInterrupt: エラーをセットして 0 で resume → park していたpark_switchがErrを返す)か、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_waitersとsleepersの両方)。どちらか一方の経路で起きたら他方のエントリは stale になり、wake_due_sleepers/prune_io_waitersが状態を再検査して破棄する。 スプリアス起床は常に許容され、呼び出し元のループが条件を再検査する。 Thread.allocateの shell(Thread::Waiter等)は最初からDeadで 生成され、スケジュールされない。
Thread#status との対応
ThreadState | #status | #alive? | #stop? |
|---|---|---|---|
Created / Runnable | "run" | true | false |
Sleeping / Joining / IoWaiting | "sleep" | true | true |
Dead(正常終了 / kill) | false | false | true |
Dead(例外終了) | nil | false | true |
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
遷移の詳細とコード対応
| 遷移 | トリガ | 実装箇所 |
|---|---|---|
[*] → Created | Fiber.new(FiberInner::new、スタック未確保) | value/rvalue/fiber.rs |
Created → Suspended(実行開始) | 初回 #resume / Enumerator#next / Generator 起動。initialize() が 256 KiB スタックを確保し rsp_save にスタックトップを書く → fiber_invoker | Fiber::invoke_fiber / invoke_fiber_with_self |
Suspended ⇄ Suspended | Fiber.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 → (エラー) | #resume は FiberError(“attempt to resume a terminated fiber”)、Enumerator / Generator 経路は StopIteration | Fiber::resume / enum_yield_values / generator_yield_values |
注意点
- エラー遷移(状態は変わらない):
- 実行中の Fiber 自身、または
parent_fiberチェーン上の祖先への#resume→FiberError(double resume。ライブなスタックへの switch は SIGSEGV になるため事前検査)。 parent_fiber == Noneのコンテキスト(main、およびスレッド root)でのFiber.yield→FiberError(“can’t yield from main fiber”)。
- 実行中の 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 ワード | poll_flag.rs(レーン定義・set/clear/consume) / alloc.rs(ページ圧力・ack_gc_request) / preempt.rs(タイマ) |
| JIT tier の poll IR | codegen/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 ワード(poll_flag.rs、u32)
VM/JIT が参照する単一の u32。3 イベントすべてがこの 1 語を共有し、8bit×4 のレーンに
分割される。poll はワード全体のゼロ判定 1 個(非ゼロ = いずれかのレーンが立っている)。
| byte | レーン | 立てる側 | 落とす側 |
|---|---|---|---|
| 0 | GC | ページ圧力(8 ページで set_gc)/ malloc 圧 / GC.start / GC.stress 再武装 | 収集完了(ack_gc_request。--no-gc の空収集も同様) |
| 1 | PREEMPT | プリエンプトタイマ(別 OS スレッド)/ stress 再武装 | poll 入口の consume_preempt |
| 2 | SIGNAL | シグナルハンドラ(async-signal 文脈) | ビットマップを drain した poll(main 配送時) |
| 3 | 予備 | — | — |
- 全書き込みは冪等なアトミック演算(
fetch_orで set、fetch_andで自レーンの byte のみ clear)。別スレッド・async-signal 文脈からの書き込みが互いのレーンを失わせることはない。 - ページ圧力の「8 ページ」カウンタはアロケータ内部(
pages_since_gc)にあり、poll ワード自体は 算術を持たない(旧設計はページ充填+=1・シグナル+=10・>= 8トリガ帯を 1 語に重畳し、 符号付き比較の都合で PREEMPT がビット 30 に制限されていた)。 - 詳細な相互作用は
poll_flag.rsのモジュールドキュメント、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.rs の vm_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.rs の vm_loop_start は先頭で vm_execute_gc() を出力する。JIT tier でも
compile.rs の TraceIr::LoopStart が state.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 + poll_flag], 0
jne gc ; いずれかのレーンが立っていれば slow path へ
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_gcはexecute_gcを呼ぶスタブ。戻り値Someをrax != 0、Noneをrax == 0として 分岐する。
5.2 VM tier(aarch64)
a64_vm_execute_gc(codegen.rs)が対称に出力する:
mov x10, poll_flag_addr
ldr w11, [x10]
cbz x11, 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 に落とす。InitMethod と LoopStart の両方で発行される(compile.rs)。
VM tier との違いは、退避すべきライブレジスタ集合がその地点の抽象状態から算出した
WriteBack として渡ること(§6)。
6. write-back(ライブレジスタの退避)
poll でフラグが立っていたら、収集本体に入る前に、レジスタにキャッシュされた
生存 Value をスタックスロットへ書き戻し、フレームを GC-complete にする:
- VM tier: entry poll は直前の
fill_nilで残スロットを nil 詰め済み。JIT のように レジスタキャッシュを持たないので、追加退避は基本的に不要(execute_gc_innerのwrite_backクロージャは VM 経路では空)。 - JIT tier:
gen_write_back(jitgen.rs)が、その poll 地点の抽象状態が示す 「レジスタに載っている生きたスロット」をスタックへ書き出す。これにより GC ルート走査 (Executor::markがlfp()のスロットを辿る、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 か所で捌く。順序:
watchdog::poll()— poll 到達はインタプリタの進捗なので、ハングウォッチドッグの カウントダウンをリセット(doc/signal.md)。poll_flag::consume_preempt()— PREEMPT レーンを消費する。GC 判定は GC レーンで 独立に行う(純プリエンプト tick で偽の full GC を起こさない)。ワード未登録なら 防御的に「GC 要求あり」とみなす。- 保留シグナルの drain — SIGNAL レーンをクリアしてから
PENDING_SIGNALSビットマップを 取り、最小番号のシグナルをSignal.trapハンドラ呼び出し / 既定例外(SIGINT ⇒Interrupt等)に変換。エラーを立ててNoneを返しうる。配送が main にゲートされ drain できない poll では SIGNAL レーンを残し(main の wakeup 維持)、GC も次の poll へ 延期する(doc/signal.md§4.1)。 - GC(GC レーンが立っているときだけ) —
parent_fiberを辿ってルート Executor へ行き、ALLOC.borrow_mut().gc(&Root { globals, executor })。 preempt::stress_renudge()— stress モードでは切替の前にレーンを再武装し、 切替先スレッドも poll するようにする。- プリエンプション —
preempt && scheduler::preempt_ok()のときscheduler::pass。Err(このスレッドへ配送された 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↔例外・disposition | codegen/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 段階にする:
- 記録(async-signal 文脈): Rust ハンドラ(
signal_table::signal_handler)が、 プロセスグローバルなビットマップに自分のビットをfetch_orし、poll ワード (poll_flag.rs)の SIGNAL レーンをfetch_orして即 return。lock-free な アトミック演算 2 つだけ(ロックなし・確保なし・TLS なし)。 - 配送(セーフポイント): 次に VM/JIT がセーフポイント(callee-entry / ループ
バックエッジ、
doc/safepoint.md§4)へ到達するとexecute_gc(executor.rs)が ビットマップを drain し、最小番号のシグナルを Ruby 例外 /Signal.trapハンドラ呼び出しに 変換する。
この構造により、シグナルは GC・プリエンプションと同一の poll ワード・同一の poll・
同一の execute_gc を共有する。ワードは 8bit×4 のレーン(GC / PREEMPT / SIGNAL / 予備)に
分割されており、poll はワード全体のゼロ判定 1 個(poll_flag.rs 参照)。
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がインストールしたかによらず一致する。 take_pending_signals()—swap(0, Relaxed)でアトミックに drain。lowest_pending_signo(bitmap)—bitmap.trailing_zeros() + 1。最小番号の signo が優先 (§6)。1 回の drain で配送するのは 1 シグナルだけ。
Rust ハンドラ(A2;signal_table::signal_handler)
sa_handler 形式で sigaction(2) される単一の extern "C" fn(signo: i32):
#![allow(unused)]
fn main() {
pub(crate) extern "C" fn signal_handler(signo: i32) {
if (1..=32).contains(&signo) {
PENDING_SIGNALS.fetch_or(1 << (signo - 1) as u32, Ordering::Relaxed);
}
crate::poll_flag::set_signal_from_handler(); // SIGNAL レーンを fetch_or
}
}
async-signal-safe な理由: 本体は lock-free な relaxed アトミック演算 2 つだけ。
ロックなし・確保なし・TLS なし・libc 呼び出しなし。poll ワードのアドレスは
poll_flag.rs のプロセスグローバルなレジストリ(AtomicUsize、最後に登録した
Codegen が勝つ — sigaction のプロセスワイド性と対応)から取得する。
かつては signo ごとに JIT asm スタブ(addl [alloc_flag], 10; orl [pending], bit)を
事前生成していたが、(1) lock なし RMW がプリエンプトタイマの fetch_or と競合して
どちらかの更新を失いうる、(2) インストール元 Codegen の解放後にシグナルが来ると
解放済み JIT メモリを実行する、という 2 つの欠陥があり、Rust ハンドラ+アトミック演算に
置き換えた。
3. sigaction のインストール(A3)
codegen.rs の sigaction_to / install_signal_handler がプロセスワイドに libc::sigaction する。
SA_RESTARTを付けない(flags = 0)。これは意図的(§8)。シグナルがブロッキング syscall を EINTR で中断させ、インタプリタを poll 地点へ到達させるため。- ハンドラは全 signo 共通の Rust 関数(§2)なので事前生成物はなく、実行時の trap は
sigaction(2)だけで済む(稼働中のバッファに JIT コード生成を行わない、という 旧スタブ方式の利点はそのまま)。install_signal_handlerはis_trappableで守られる。 - デフォルトインストール: 起動時に
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_SIGNALS | Signal.trap でハンドラを張ってよい集合(Linux/非 Linux で cfg 分岐)。KILL/STOP(捕捉不能)、SEGV/BUS/FPE/ILL/TRAP/ABRT(フォールト)を除外。SIGPWR は Linux のみ。 |
4. セーフポイントでの配送(A6;execute_gc)
execute_gc(executor.rs)がセーフポイントで実行する処理のうち、シグナル部分:
watchdog::poll()(§9)。poll_flag::consume_preempt()で PREEMPT レーンを消費する。- シグナル drain: SIGNAL レーンをクリアしてから
take_pending_signals()→lowest_pending_signo()(クリア→drain の順序: 間に届いたシグナルはレーンとビットを 立て直すので余分な poll 1 回で済む。逆順はビットの arming を失いうる)。signo があればglobals.signal_disposition(signo)で分岐:Handler(handler)→arg = Value::integer(signo)、#callをinvoke_method_innerで 呼ぶ。Errならset_error+return None。Ignore{..}→ no-op(防御的。SIG_IGN のシグナルは通常ビットを立てない)。Default | SystemDefault→signo_to_error(signo)。Some(err)ならset_error(err); return None。マップ外は何もしない。- 同じ drain 窓に複数立っていても最小 signo だけ配送し、残りは捨てる (CRuby も coalesce したシグナルの全配送を保証しない)。
- GC 本体(GC レーンが立っているとき)。
- プリエンプション(
scheduler::pass)。
シグナル配送は GC・プリエンプションより前に同じ poll 内で行われる。
execute_gc はハンドラ呼び出し中に CODEGEN 借用を保持しないので、trap ハンドラが
その内部で JIT コンパイルや GC を起こしても自由に再入できる。
4.1 配送ゲート(scheduler::signal_delivery_ok)
すべての poll 地点で配送してよいわけではない。3 条件を満たす poll だけが drain する:
- メインのグリーンスレッド上であること(
current == main)。CRuby と同じく、 非メインスレッド実行中に届いたシグナルはメインの次の poll まで保留される。 - スケジューラ自身の機構(
machinery)が走っていないこと。 - スケジューラの入口(
pass/sleep/join/terminate_all)の中でないこと。 これらの内側ではコンテキストが既にスイッチ用に保存されている(あるいは直後に 保存される)場合があり、その上に積んだ Ruby フレームは保存コンテキストの復帰時に もう一度実行されてしまう。
3 番目は SCHED_CALL_DEPTH(RAII ガード SchedCall)で数える。この深度は
コンテキストスイッチと一緒に持ち回らなければならない — カウンタは OS スレッドの
thread-local だが、ガードは各グリーンスレッドのスタック上にあり、park した
スレッドは Rust フレームごと凍結されるのでガードが解放されないまま残る。
dispatch は machinery と同じ区間で深度を退避・復元し、park 中の深度は
ThreadInner::sched_call_depth に置く。
これを怠ると、
sleepで park したスレッドが 1 本あるだけでカウンタが恒久的に 1 以上に張り付き、以後Signal.trapハンドラが二度と走らなくなる。nil until flagは無限に回り、次のブロッキング書き込みが内部マーカー__monoruby_signal_interrupt__(§ マーカー)を RuntimeError として表に出す。 回帰テスト:process.rsのsignal_delivered_while_another_thread_is_parked。
5. signo → 既定例外(A4;signo_to_error)
Signal.trap ハンドラが無い場合、Default/SystemDefault は既定例外に落ちる:
| signo | 例外 |
|---|---|
| SIGINT | Interrupt(専用クラス。Interrupt < SignalException) |
| SIGTERM / SIGHUP / SIGQUIT / SIGALRM / SIGPIPE / SIGUSR1 / SIGUSR2 | SignalException("SIG…") |
| その他 | None(防御的フォールスルー) |
Interrupt < SignalException(A4)なので、rescue SignalException は SIGINT も捕まえる。
これらはいずれも rescuable な例外として VM の unwind 経路に乗る。
6. Signal.trap / Kernel#trap(A7)
登録
- モジュール
Signalにlist/signame/trap(builtins/process.rs)。 Kernel#trap(builtins/kernel.rs)は同じprocess::signal_trapに委譲。
Signal.trap / Kernel#trap の挙動
trap_signoで signo を解決(Integer は妥当な signo、Symbol/String/#to_strは名前で。"SIG"有無どちらも可。#to_intは呼ばない。それ以外はArgumentError: bad signal type)。- KILL/STOP(捕捉不能)→
ArgumentError: "Signal already used by VM or OS"。 予約シグナル(SEGV/BUS/ILL/FPE/VTALRM, EXIT)→ArgumentError: "can't trap reserved signal"。 - disposition はコマンド引数(
command_disposition)かブロック (Handler(generate_proc(...)))から決まる(コマンド優先)。 - 先に OS レベルでインストールし、その後 trap テーブルへ記録する。
CODEGEN借用下で disposition ごとにinstall_signal_stub/install_signal_ignore/install_signal_default/install_signal_system_defaultを呼ぶ。失敗時はErrno。set_signal_dispositionが前の disposition を返し、disposition_to_valueで Ruby 値に 変換して返す。
コマンド文字列と disposition
| コマンド引数 | disposition | Ruby へ返る表現 |
|---|---|---|
nil | Ignore{from_nil:true} | nil |
"" / "SIG_IGN" / "IGNORE" | Ignore{from_nil:false} | "IGNORE" |
"SIG_DFL" / "DEFAULT" | Default | "DEFAULT" |
"SYSTEM_DEFAULT" | SystemDefault | "SYSTEM_DEFAULT" |
| その他の String/Symbol | ArgumentError: 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 は未使用)。初期値は全SystemDefault、POSIX_SIGNALSのみDefault。signal_disposition(signo)/set_signal_disposition(signo, disp)(後者は前値を返す)。- GC ルート:
Globals::markが各Handler(v)をマークする(trap ハンドラの Proc は この表からしか到達できないが、将来任意の poll 地点で呼ばれうるため)。
名前 ↔ 番号(SIGNAL_TABLE)
process.rs の SIGNAL_TABLE が唯一の真実:Signal.list(名前→番号 Hash)、
Signal.signame(番号→名前)、trap、Process.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 を包む単一のチョークポイント:
- 入口で保留シグナルを先に drain(
PENDING_SIGNALS != 0ならexecute_gc)。 f()を実行。is_signal_interrupt()なら poll 地点(execute_gc)を通す。既定 disposition は変換した SignalException を read の外へ raise、trap ハンドラなら実行して正常復帰時に操作を再開 (消費済みバイトは pushback 済みなので失われない)。is_would_block_interrupt()ならスケジューラの fd ポーラで park(doc/threads.md§7)。
その他の EINTR→poll 経路(いずれも SA_RESTART なし)
Process.waitpid、Kernel#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から呼ばれ、COUNTDOWNをBUDGETに戻す(=「前進した」)。 無効時は relaxed ロード 1 回だけ。handler(signo)は async-signal-safe(atomics +write(2)+_exit(2)のみ)。毎秒COUNTDOWNを 1 減らし、0 で fd 2 に中断メッセージを書いて_exit(134)。 中断判断は poll 地点ではなくハンドラに置く — 本当にハングしていれば poll 地点に そもそも到達しないため。
9. スレッド / スケジューラとの関係
- どのスレッドが変換するか: main グリーンスレッド(§4.1 の配送ゲート、CRuby と同じ)。 非 main スレッドの poll ではビットマップと SIGNAL レーンが残り、main の次の poll が配送する。
- poll ワードは GC・プリエンプションと共有の
u32で、8bit×4 のレーンに分割 (poll_flag.rs)。書き手はアリーナのページ圧力 / malloc /GC.start(GC レーン)、 シグナルハンドラ(SIGNAL レーン)、プリエンプトタイマ(PREEMPT レーン)。 全書き込みは冪等なアトミックfetch_or/fetch_and(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 文脈でビットマップと SIGNAL レーンを立てるだけ、実配送は
次のセーフポイント(
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)、共通 Rust ハンドラ(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.rs—MonorubyErr,MonorubyErrKind, backtrace formatting.../monoruby/src/executor.rs—set_error/take_error,$!handling,take_ex_obj(materialization),complete_backtrace_for_rescue, ensure deferral.../monoruby/src/codegen/jit_module.rs—handle_error(the unwinder).../monoruby/src/globals/store/iseq.rs— the per-method exception table (get_exception_dest,covering_ensure,errinfo_restore_slots,nonlocal_exit_needs_vm_unwind).../monoruby/src/builtins/exception.rs,../monoruby/builtins/startup.rb— the Ruby-visibleExceptionAPI (#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_exception → rb_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:
| field | purpose |
|---|---|
kind | MonorubyErrKind — the error class / control-flow tag (see §3) |
message | the message string |
trace | Vec<(Option<(Loc, SourceInfoRef)>, Option<FuncId>)> — the backtrace, built incrementally as cheap tuples (no strings) |
original | when re-raising an existing exception object (raise exc), that Value, so identity + ivars survive |
explicit_cause | an explicit cause: keyword (Some(nil) for cause: nil) |
payload | kind-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:
| kind | Ruby construct | how it stops unwinding |
|---|---|---|
MethodReturn(val, lfp) | return from a block/proc/lambda | stops at the target frame lfp |
BlockBreak(val, fid, lfp) | break out of a block | resumes the block’s defining call, or degrades to LocalJumpError |
Throw(tag, val) | Kernel#throw / Kernel#catch | intercepted only by a matching catch, never by rescue |
Retry | retry in a rescue clause | redirected to the begin-region start |
Redo | redo in a loop | redirected to the loop body start |
Fatal | a Rust panic! caught at an extern "C" boundary | uncatchable — 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:118–238, 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:
-
Retry/Redo (
jit_module.rs:106) — take the error andgotothe begin-region / loop start encoded in the instruction. No trace, no object. -
MethodReturn (
jit_module.rs:118) — if this frame is the targetlfp, return the value here; if anensuresits 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). -
Throw (
jit_module.rs:157) — run any interveningensure, else keep propagating (a matchingKernel#catchframe consumes it).These three non-local-exit arms ask
covering_ensure(pc), notget_exception_dest(pc): theensureto run is the innermost covering region that has one, which is not always the innermost covering region. Nest a rescue-onlybegininside anensureregion and the tightest entry carries noensure, so askingget_exception_destconcluded there was none and skipped the body outright (#1185). Running the innermost ensure-bearing region chains the rest by itself — itsEnsureEndre-delivers the exit from a pc outside that region, where the next one out is now innermost. -
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’sBREAKcatch table); otherwise degrade toLocalJumpError(“break from proc-closure”). -
Incremental trace capture (
jit_module.rs:242) —push_error_location(loc, sourceinfo, fid)appends this frame’s cheap tuple. Only real exceptions reach here. -
Fatal (
jit_module.rs:247) — never caught; skiprescue/ensure, propagate to the top. -
Exception table lookup (
jit_module.rs:251) —get_exception_dest(pc)returns(rescue_pc, ensure_pc, err_slot)for the innermost region coveringpc:- rescue → call
complete_backtrace_for_rescue(§7), materialize the object withtake_ex_obj, store it into$!and the handler’s error slot, andgotothe rescue clause. - ensure →
defer_unwind(§6) andgotothe ensure body. - neither →
return ErrorReturn::return_err(), unwinding one frame; the caller re-entershandle_error.
- rescue → call
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 — right for a raise, which the tightest rescue
catches, but not for a non-local exit, which wants the tightest ensure (see
arm 2 above).
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.
Only the generic unwind replays them, so a frame owing one cannot take the
JIT’s specialized teardown — ISeqInfo::nonlocal_exit_needs_vm_unwind pairs
that condition with covering_ensure as the two reasons a non-local exit has
to go through handle_error at all. A protected region that is neither (a
plain begin..rescue the exit merely passes through) contributes nothing: a
rescue does not intercept a non-local exit.
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:1102–1153):
defer_unwind(lfp)moves the in-flight error out ofexceptionand stashes it keyed by frame, thengotoes the ensure body.finish_ensure(lfp)(theEnsureEndhook) re-raises the deferred error — unless the ensure body left a new error pending, in which case the new one wins (CRuby: araise/return/throwinsideensuresupersedes).discard_deferred_unwind(lfp)drops a deferral when the frame leaves by some other path so itsEnsureEndwill not consume it.
This mirrors CRuby’s CATCH_TYPE_ENSURE continuation plus the “ensure result
overrides pending throw” rule.
Note that bytecodegen compiles the body once per edge, which is what makes
the compiled EnsureEnd cheap (below). A begin..ensure without rescue
still gets a rescue_pc: it names a second copy of the body that ends in
raise rather than EnsureEnd.
def m(a); begin; a * 2; ensure; $n = 1; end; end
[(:00002..:00004, rescue=:00005, ensure=:00008, err_slot=%4)]
BB1 :00005 %5 = 1 / :00006 $n = %5 / :00007 raise %4 <- the exception edge
BB2 :00008 %4 = 1 / :00009 $n = %4 / :00010 ensure_end <- the normal edge
So an exception takes BB1 and never reaches BB2’s EnsureEnd; a non-local exit
takes handle_error’s goto(ensure) into BB2 in the VM; and compiled code
falls into BB2 only on normal completion.
6.1 The compiled EnsureEnd’s gate
EnsureEnd asks one question — “is a deferred unwind parked for this frame?” —
and for JIT-compiled code the answer is always no. defer_unwind has five call
sites: three in handle_error, each immediately followed by
ErrorReturn::goto(ensure), which resumes the VM, and two in the splice helpers
(§6.3), which pair with ensure_end_spliced rather than with this call. The
exception edge cannot arrive either, per the two-copy layout above.
It was nevertheless a runtime call on the normal path of every compiled
ensure region — measured at about 40 cycles per execution: a trivial
begin..ensure made a hot method 63% slower than the same method without
one. It now sits behind the same one-word mirror emit_ret tests
(Executor::deferred_top_lfp, #1186): a compare and a not-taken branch.
The gate is conservative rather than an elision — when the mirror does name
this frame it runs exactly the old sequence — which matters because compiled
code demonstrably can run with a deferral parked: a loop inside an ensure
body entered by an unwind can be re-entered by OSR, which is why emit_ret
carries the same gate. Worth about 10% on a hot method with a trivial
ensure, and 14% on §6.3’s find-first shape.
6.2 The region-entry $! save
A protected region saves $! at its entry so a non-local exit leaving a
rescue clause can put it back (§5). That save read $! through the generic
hooked-global runtime call, once per invocation of every method carrying a
begin..ensure or begin..rescue:
:00001 %2 = $(errinfo)
mov rdi,rbx / mov rsi,r12 / mov edx,0x6a before
movabs rax,<runtime::get_global_var> / call rax
mov rax,QWORD PTR [rbx+0x198] after
$! is a plain Value field of the Executor and $(errinfo)’s hook is
Some(vm.errinfo()), so AsmInst::LoadErrinfo is the same read — with the
call’s FP save set and GP flush gone with it. The load is equivalent only
because rbx is the current Executor, which is what $! is per (CRuby
keeps errinfo per execution context, and so does monoruby), so a Fiber or
Thread reads its own.
Only the internal name is specialized. $(errinfo) is what bytecodegen emits
and is not a name Ruby’s parser can produce, so no program can alias it,
trace_var it or otherwise put a hook in the way; user-written $! reads keep
the generic path. The name is pre-interned as
IdentId::GVAR_ERRINFO_INTERNAL so the recognition is an integer compare
rather than a lock and a string hash.
Worth about 28% on a hot method with a trivial ensure (0.663-0.675 s ->
0.475-0.486 s over three runs). Together with the gate above, the surcharge for
putting a begin..ensure around a hot method’s body falls from +63% to +23%
over the same method without one.
6.3 Spliced non-local exits (issue #1185)
A break / non-local return whose whole chain is specialized-inlined into
one JIT unit lowers to the specialized teardown (lea rbp += Σ; leave; ret) — three instructions, no handle_error. An ensure on the way out
used to disqualify that outright: nonlocal_exit_needs_vm_unwind (§5) sent
the exit down the generic unwind, which interprets the ensure bodies,
converts the suspended frames by the chain-deopt walk, and — for break —
leaves the defining frame in the VM until the next loop_start re-enters by
OSR.
Splicing keeps the teardown and reaches the ensure body as ordinary
compiled code: the exit defers its unwind exactly as handle_error
would (defer_block_break_at / defer_method_return_at), and the region’s
EnsureEnd delivers it — ensure_end_spliced classifies the parked
deferral and the compiled arm runs the teardown for that kind. The gain is
that the unwind edge now exists in the CFG, so the ensure’s writes are
visible to the abstract interpreter instead of happening behind its back.
This was built in two stages, by which frame owns the region. Stage 1
(SplicePlan::SameFrame, #1187) handled the exit’s own frame, where the
body is a block of the iseq being compiled and the exit is an ordinary
forward branch to it. §6.5 removed the need for it: the exit now replays
its own frame’s bodies inline and crosses no region of its own, so
covering_ensure never names the current frame at an exit’s pc.
Measured over the whole test suite, stage 1 went from 304 splices to 0, and
it was deleted; try_splice_exit still refuses a same-frame host rather
than assuming, in case bytecodegen ever stops replaying. What remains is:
An intermediate frame (stage 2). The owner is a suspended frame: its
compile is parked at the call that leads to the exit, and its ensure body
is several machine frames away, so there is no branch to emit. The splice
travels by the machine’s own return path instead:
AsmInst::SplicedExitToOuter, at the exit, builds the error wherevm.cfp()is still the exiting frame (that is what resolves abreak’s target) but keys the deferral on the host frame’s LFP, read from the frame chain —defer_block_break_at/defer_method_return_at.- The same instruction then sets rbp to the frame the host called and
leave; rets. That lands at the host’s call site withSplicedExitKind::outer_tag()in the return register — a value no normal return can produce (low three bits000, and noRValuelives at address 8 or 16). AsmInst::SplicedExitLanding, emitted after that call whenever a nested compile asked for one, recognizes the marker and branches into the host’sensurebody. It is an ordinary side branch of the host’s own CFG, so the body’s entry merge sees this path exactly as it sees the normal fall-through.
The landing edge’s state is the host’s state right after the call, which is
what the ret really arrives at, with two corrections: every widen the call
reached is re-applied (the resume adopts the return join’s kept claims,
and a spliced exit is by definition not a returning path), and every temp
still void at the call is claimed as a boxed Value in its slot (a temp the
ensure body keeps live may only be written later in the begin body; the
prologue nil-fills the frame, so the claim is true, and nothing on this path
reads it).
Two things are re-proved at the exit rather than inherited, both by
defer_*_at and both before anything is torn down, so a refusal is the
generic raise from the exit’s own pc with every frame still in place:
- the call site’s own capture guard (
immediate_evict) is emitted after the landing, sodefer_*_atchecks the host’sMetafor the two bitsbranch_if_capturedtests and degenerates when the callee promoted the host frame to the heap; - the exit’s target. The JIT laid the chain out statically — a
breakreturns into the defining frame one below the popped iter frame, areturnreturns from the popped home method — and handsdefer_*_atthat frame’s LFP (SplicedExitToOuter::expect, read off the chain at a static rbp offset exactly as the host’s is). The runtime resolved the same target from the frame’s current style (err_block_break/err_method_return: a block promoted to a lambda breaks locally, adefine_methodbody catches areturnthe static walk passed through), and the splice proceeds only when the two agree. A refusal here matters more than it looks: the runtime target may be one of the very frames the hop would pop, and a mismatch found only at the host’sEnsureEndwould have nowhere left to deliver.
A degenerate error (LocalJumpError out of a proc-escaped block) takes the
same exit. With the target settled at the exit, finish_ensure_spliced
classifies a parked deferral by kind alone.
That is not where the check started. It used to sit at the EnsureEnd, as a
comparison against the host’s own outer() / outermost() — the relation the
exiting block’s frame satisfies (stage 1) and one an intermediate method
host never does, having no outer. So every stage-2 delivery took the
re-raise instead of the arm: handle_error, the chain-deopt walk, the VM and
an OSR re-entry, on top of the deferral. Instrumented over the whole test
suite, codes 2 / 3 had never once been returned. Measured on the 1-host shape
with a trivial body so the machinery is what is timed, the hop went from
~270 ns to ~105 ns per exit once deliveries took the arm, and the
surcharge over the same loop with no ensure from 1.62× to 1.20×.
The spliced EnsureEnd also sits behind the same one-word deferral gate as
the plain form (§6.1): a host’s normal completion reaches it far more often
than a spliced exit does, and with nothing parked for the frame the dispatch
could only have answered “continue”.
With the hop at ~100 ns, chaining pays: a break crossing two
intermediate ensures went from 1.28 s (the generic unwind) to 0.83 s on the
same 1.28M-exit benchmark whose no-ensure baseline is 0.63 s and whose
one-host time is 0.75 s — the second hop costs about what the first does,
where the generic unwind’s second region had cost ~0.08 s on top of its
~0.35 s entry. That arithmetic is why the hop had to be made cheap first: at
the ~270 ns it cost before deliveries took the arm, a second hop would have
lost to the generic path.
More than one ensure on the way out chains hop by hop. try_splice_exit
collects every host the unwind crosses (innermost first) and, once each has
passed the checks below, records on each host’s EnsureEnd what to do with
the exit once its body has run (SpliceHop): every host but the last hands
it on — SplicedArm::Hop re-keys the deferral on the next host (the mirror
follows, so that host’s EnsureEnd gate sees it), tears down to the frame the
next host called and rets the marker into its landing, which is the tail of
the exit’s own hop run from the EnsureEnd instead — and the last host
delivers through the teardown arm as above (SplicedArm::Final). Each
landing is requested on its host at the exit, all at once; hosts emit them as
their own compiles resume, innermost host first, which is the order the
recursion unwinds in anyway. The exit registers one return context, at the
target, from the last host; a hand-on registers none, as the exit’s own hop
does not — the next host’s landing is a branch edge of that host’s CFG. A
next host that can no longer be entered by compiled code (captured to the
heap since) puts the error back in flight at that EnsureEnd, and the generic
unwind resumes from there with every frame below it still intact.
A host’s arm is static — one destination per kind — so an exit that would
route a kind through a host somewhere other than an exit already recorded is
refused (spliced_ensure_conflicts; the second route used to be silently
overwritten, a latent stage-2 bug with one host too).
The route also carries what the exit claimed about its value. The value
the delivering EnsureEnd hands over is the very one the exit left with (it
rides the deferral unchanged), so the ReturnState the exit’s own state made
(as_return: the constant it is, or its class) holds at delivery. Each exit
routed through a host joins its claim into the route, and the delivering host
registers the joined claim — under its own invariants, since the ensure
bodies ran in between — as the exit’s return context at the target
(as_return_like). The target’s continuation therefore learns the class or
the constant exactly as it would from a plain specialized break / return,
and a spliced exit whose value agrees with the normal return path no longer
collapses that join to Value. (It used to register as_return_any.)
try_splice_exit refuses everything it cannot prove: a dispatch arm, a
loop-rooted frame (whose compile may not cover the body), a $! restore
owed anywhere on the way out, a conflicting route through a host, a body that
is not a basic-block head, a body containing an exit of its own (next /
break / return / retry / redo, which would leave the deferral parked
past the frame) or a nested handler, and a host whose in-progress call site is
not one of the two shapes that emit a landing. Every refusal falls back to the
generic unwind, which handles every case.
Measured on the shape the issue names — a break-with-ensure that is the
normal exit of an inner iteration inside a hot loop in the block’s defining
frame — this is worth roughly 10% (0.79–0.83 s → 0.69–0.77 s over
repeated runs). The same exit without the ensure runs in 0.35 s, so most
of what is left is the deferral machinery itself — two runtime calls and a
MonorubyErr per exit — rather than the unwind the splice removed. On a
chain that merely tears down (no hot continuation to return to) the
difference is within noise.
6.4 Replayed ensure bodies and the exception table
The two copies above are not the only ones. A non-local exit written inside
a region — a local return, a loop break / next / redo, retry —
does not go through handle_error at all: bytecodegen replays the bodies of
every region the exit leaves inline, innermost first, immediately ahead of
the exit instruction (gen_all_pending_ensures, gen_loop_pending_ensures).
Those inline copies sit lexically inside the very regions they replay, so the exception table covered them like any other code in the region. A copy that raised was therefore handed straight back to the region whose body was running, and the body ran a second time:
begin
begin
return :never
ensure
$log << :inner # ran twice; CRuby runs it once
raise "E"
end
ensure
$log << :outer
end
The generator already states the rule on its own side: while it emits the body
of the region at stack index idx, it truncates its ensure stack to
ensures[..idx], so a return written in an ensure body does not
re-generate that body (and begin return 1 ensure return 2 end returns 2, as
in CRuby). The table now says the same thing at run time.
Each region gets an id (BytecodeGen::new_region_id), carried by every
exception-table entry it emits and by its entry on the ensure stack. Each
replayed copy is recorded as a replay span — a BcIndex range plus the
ids of the regions it runs outside of: the one whose body it is, and the inner
ones the exit has already replayed. ISeqInfo::active_entries drops those
entries, and every “which regions are in force at this pc” lookup goes through
it (get_exception_dest, covering_ensure, single_covering_ensure), so the
raise path and the non-local-exit path agree.
Ids rather than nesting depths, because a begin written inside an ensure
body is a region of its own and must keep catching:
begin
return :done
ensure
begin
raise "E"
rescue => e # still catches
end
end
Bodies are generated once per copy, so that nested region gets a fresh id in each copy and never collides with the region being replayed — where a depth count would, since the generator’s truncated stack gives it the same depth as the region whose body it is in.
Spans nest, too (an ensure body may hold an exit that replays further bodies),
so a pc is checked against every span covering it, not just the innermost.
errinfo_restore_slots takes the same cut for the same reason — a replayed
body is preceded by its region’s $! restore, so restoring again on the way
out would undo whatever the body did to $! — but it cannot go through
active_entries, because it is keyed on the rescue clause spans rather than
the region spans. It applies the replay-span filter directly
(ISeqInfo::is_replayed_at).
6.5 Non-local exits replay their own frame inline (issue #1185)
A break out of a block and a non-local return were the last exits that left
the job to handle_error: the unwinder found the covering region, deferred the
exit, ran the body interpreted, and re-delivered it from EnsureEnd. That is
what §6.3’s splice machinery was built to compile around.
They now replay their own frame’s open regions inline, exactly as emit_ret
does for a local return — same set, same order, same $! protocol
(gen_method_return / gen_block_break → replay_ensures_for_nonlocal_exit).
With the bodies emitted ahead of the exit there is nothing left to compile
around: the exit crosses no region, the JIT lowers it to the plain specialized
teardown, and no deferral is created.
The exit value is generated before the replay and popped after it, so the
bodies take their temps above it and the exit instruction’s recorded sp is
unchanged — raising it by the value’s own slot changed JIT liveness even for
exits that replay nothing, and cost about 4%.
What stops the bodies running a second time is the §6.4 machinery, reused
rather than duplicated: emit_nonlocal_exit extends the spans that replay just
recorded over the exit instruction itself. handle_error is handed exactly
that pc, and asks the same “which regions are in force here?” question the
raise path asks — so covering_ensure finds nothing to run,
errinfo_restore_slots nothing to restore, nonlocal_exit_needs_vm_unwind
answers false, and try_splice_exit (which starts from
single_covering_ensure) declines on its own. No separate table, and no third
place to keep in sync.
A span is therefore recorded even when the body generated no code: an
ensure nil end still has a region the unwinder would otherwise run through
the VM. The empty span is inert until the exit extends it.
Measured on the break-inside-its-own-begin..ensure shape, with a trivial
body so the machinery is what is being timed: 0.389 s → 0.207 s against
0.185 s for the same loop with no ensure at all — a 2.1× surcharge down to
1.12×. With a body that does real work ($n += 1) the remaining gap is the
body: 0.690 s → 0.605 s. Standard benchmarks are unchanged.
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
| scenario | backtrace cost in monoruby |
|---|---|
return from block, break, throw | none — dispatched before trace capture (§3b) |
StopIteration caught by loop | a 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 rescue | raise→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. #backtracestrings: both format lazily and memoize; monoruby in the/backtraceivar.- Control-flow tags: neither builds a Ruby backtrace for them.
- Frame labels: monoruby renders owners with their fully-qualified name
(
Ns::Cx.foo, special-casingObject#foo) infunc_description(../monoruby/src/globals/store.rs), matching CRuby’sNs::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.originalset): return the same object, filling itstraceonly 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 explicitcause:keyword wins; otherwise CRuby’sexc_setup_cause— if a different exception is currently being handled ($!), record it as/cause.cause: nilsuppresses 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
| concern | location |
|---|---|
| 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 |
| JIT-spliced non-local exits (§6.3) | ../monoruby/src/codegen/jitgen/context.rs (try_splice_exit), jitgen/compile.rs (emit_spliced_exit), jitgen/compile/method_call.rs (emit_spliced_landing) |
| 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 |
| spliced-exit regression tests | ../monoruby/tests/nonlocal_exit_ensure.rs, tests/nonlocal_exit_intermediate_ensure.rs, tests/nonlocal_exit_rescue.rs |
EnsureEnd gate tests (§6.1) | ../monoruby/tests/ensure_end_deferral_gate.rs |
region-entry $! tests (§6.2) | ../monoruby/tests/errinfo_inline_load.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
MonorubyErrwith 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(vialoop) is caught at the Rust level below the bytecode-rescuepath.
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
The interpreter and JIT’ed code share one frame layout: both go through the
same set_lfp / set_method_outer / set_block_outer primitives in
codegen/arch/<arch>/jit_module.rs, so a single picture covers a VM frame, a
JIT frame and a native wrapper alike. The offsets below are the constants in
executor.rs; when this document and that file disagree, the file is right.
Slot offsets
Each Ruby-level call occupies three contiguous regions, stack growing down.
cfp and lfp are two pointers into it: the control frame is addressed at
positive offsets from cfp, the local frame at negative offsets from
lfp.
| Slot | Address | Constant |
|---|---|---|
| pad / chain-deopt continuation word | cfp + 0x20 | — |
| caller pc | cfp + 0x18 | — |
| return address | cfp + 0x10 | — |
saved rbp | cfp + 0x08 | BP_CFP (bp == cfp + BP_CFP) |
| prev cfp | cfp + 0x00 | — |
| lfp | cfp - 0x08 | CFP_LFP |
| outer | lfp - 0x00 | LFP_OUTER = 0 |
| meta | lfp - 0x08 | LFP_META = 8 |
| svar | lfp - 0x10 | LFP_SVAR = 16 |
| block | lfp - 0x18 | LFP_BLOCK = 24 |
| self | lfp - 0x20 | LFP_SELF = 32 |
| arg0 | lfp - 0x28 | LFP_ARG0 = 40 |
The local frame starts at cfp - 0x10, one word below the lfp slot itself.
From a caller’s rsp at the call, RSP_CFP (24) and RSP_LOCAL_FRAME (40)
name the same two points.
Just after the prologue
+-------------+----------------------
cfp+0x20 | pad |
+-------------+
cfp+0x18 | caller pc |
+-------------+ continuation frame
cfp+0x10 | return addr |
+-------------+
cfp+0x08 | saved rbp | <- rbp
+-------------+----------------------
cfp+0x00 | prev cfp | <- cfp
+-------------+ control frame
cfp-0x08 | lfp |
+-------------+----------------------
-0x00 | outer | <- r14 (lfp)
+-------------+
-0x08 | meta |
+-------------+
-0x10 | svar |
+-------------+ local frame
-0x18 | block |
+-------------+
-0x20 | self |
+-------------+
-0x28 | arg0 |
+-------------+
| : |
+-------------+
| arg(n-1) |
+-------------+----------------------
| | <- rsp
+-------------+
| : |
Just before the call
The caller builds the callee’s frame below its own rsp and then calls;
the two words above rsp are the cont-frame extension it reserved with
sub rsp, 0x10, and call / the callee prologue push the return address and
the saved rbp into the two words below it.
+-------------+----------------------
+0x08 | pad |
+-------------+ reserved by the caller
+0x00 | caller pc | <- rsp
+-------------+----------------------
-0x08 | return addr | pushed by `call`
+-------------+
-0x10 | saved rbp | pushed by the callee prologue
+-------------+----------------------
-0x18 | prev cfp | <- cfp (RSP_CFP)
+-------------+ control frame
-0x20 | lfp |
+-------------+----------------------
-0x28 | outer | <- r14 (lfp) (RSP_LOCAL_FRAME)
+-------------+
-0x30 | meta |
+-------------+
-0x38 | svar |
+-------------+ local frame
-0x40 | block |
+-------------+
-0x48 | self |
+-------------+
-0x50 | arg0 |
+-------------+
| : |
+-------------+
| arg(n-1) |
+-------------+----------------------
| : |
What the header slots hold
- outer — the lexically enclosing frame’s lfp for a block,
0for a method-introducing frame.$~resolution and outer-local access walk this chain. - meta — one packed 8-byte word:
FuncId(4 bytes),reg_num(2), the argumentmodebyte, and akindbyte carrying on-stack/on-heap, simple-arity, invalidated, native, block-style and related flags.LFP_REGNUMandLFP_FUNCIDaddress fields inside this word; they are not separate slots. - svar — frame-local special variables, the counterpart of CRuby’s
vm_svar.0is the lazy-allocation sentinel (“nothing set in this scope yet”); otherwise a 2-elementArraycontainer[$~, $_]. Only a method-introducing frame owns one — blocks walk the outer chain to the LEP. - block — the block passed to this call, if any.
- self — the receiver, and register slot
%0. Locals follow it contiguously, soLfp::register_ptraddresses slotiaslfp - (LFP_SELF + 8 * i):%0isself,%1isarg0, and so on — which is why the bytecode dumps show a method’s first parameter as%1.
Continuation frame
The four words above cfp are written by different parties:
- saved rbp and return address by
calland the callee prologue. Every frame — VM, JIT or native wrapper — establishesbp == cfp + BP_CFPin its prologue, soCfp::frame_bpcan recover the register’s value from the CFP alone. - caller pc by the caller just before dispatching (the VM’s
pushq r13, the JIT’s equivalent store, or a zero sentinel from an invoker). Not every dispatch path writes it, so consumers must range-validate it against the caller frame’s bytecode span before trusting it. This is what powers lazy backtraces,Kernel#caller, andsuperresolution (seesuper_resolution.md). - pad is reserved by every caller and read by nothing on the normal
return path. Chain deopt reuses it as the converted call’s per-site
continuation word (see
chain_deopt.md§9.3).
ABI of the interpreter and JIT’ed code
Global registers, callee-saved on both architectures:
| Role | x86-64 | aarch64 |
|---|---|---|
&mut Executor ([rbx] points to cfp) | rbx | x19 |
&mut Globals | r12 | x20 |
| program counter | r13 | x21 |
| local frame pointer (lfp) | r14 | x22 |
| accumulator | r15 | x23 |
The accumulator is a VM-tier register. JIT’ed code does not keep a fixed
accumulator: GP_ALLOC_POOL is empty, and the local allocator in
jitgen/gp_alloc.rs assigns general-purpose registers per basic block
instead, so a compiled body’s values live in whatever caller-saved register
it picked.
Negative result: moving META / SVAR to the callee
The caller writes the whole header, including the two words that depend only
on the callee — meta (store[fid].meta()) and the svar zero sentinel.
They are the same at every call site of a given method, so they look like an
obvious thing to write once in the callee instead: 24 bytes off each of a
program’s call sites, in exchange for 17 bytes added once per method.
This was implemented and measured, and it is slower. The x86-64 form
was: Codegen::init_func writes both words at rbp - RBP_LOCAL_FRAME (the
same addresses the caller reached at rsp - RSP_LOCAL_FRAME), and a call
site drops them exactly when it dispatches straight to the callee’s compiled
entry — the same get_jit_entry lookup AsmInst::Call performs, resolved in
the same lowering so the two cannot disagree. Block-style callees were
excluded: define_method re-tags a block’s LFP_META with
Meta::PROC_METHOD_MASK at call time, and a body rewriting the word from its
own compile-time copy would drop that bit.
Measured against the same tree, interleaved runs (x86-64, release):
| benchmark | delta |
|---|---|
| fib | +4% |
| 30k_methods | −5 to −10% |
| tarai | −5.8% |
| bedcov | −5.5% |
| sudoku | −3.6% |
| qsort | −3.3% |
| 30k_ifelse | −3% |
| aobench | −2.5% |
| nbody, mandelbrot, bf, 30k_variables | flat |
Two reasons, and both generalise:
- The size win is much smaller than the call-site arithmetic suggests.
A
movabsfor the 64-bitMetais 10 of the 17 bytes added per method, and these programs have nearly as many methods as call sites (30k_methods: ~31k sites over 30k methods). The net was ~6% of the JIT code, not the ~19% the caller-side saving alone implies. The trade only pays where call sites outnumber methods by a wide margin — which is whatfibis, and whyfibis the one benchmark that improved. - Placement matters more than count. The stores moved from the caller,
where they issue well ahead of the
calland drain from the store buffer while the caller finishes its own work, to the head of the callee, where they are serialized behindpush rbp/mov rbp, rsp/sub rspand compete with the entry poll’s load — on a taken branch, at the exact point the front end is refilling.
So the header stays where it is. A future attempt at trimming the call sequence should target words that can be dropped rather than moved, or move work off the callee’s entry rather than onto it.
メソッド引数の処理
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:
| Operation | Reads from CREF |
|---|---|
module Foo; end / class Foo; end | parent for the new module/class |
def foo; end | default 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_function | flags toggled on the innermost CREF |
Module.nesting | walk of the lexical chain |
using Foo | refinement 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):
| Flag | Set by | Means |
|---|---|---|
CREF_FL_PUSHED_BY_EVAL | class_eval { … }, class_exec, instance_eval { … }, instance_exec, Kernel#eval (no binding) | “this CREF is a runtime override, not a real lexical scope” |
CREF_FL_SINGLETON | instance_eval family | “klass_or_self is the receiver; promote to its singleton when needed for def” |
CREF_FL_OMOD_SHARED | refinement set sharing | refinements 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
-
Toplevel:
vm_cref_new_toplevelbuilds the initial CREF(rb_cObject, METHOD_VISI_PRIVATE, FALSE, NULL, FALSE, FALSE). IfKernel#load(path, true)was used, an additional CREF for the wrapper module is pushed on top. -
class Foo … end/module Foo … end/class << obj … end: thedefineclassinstruction (insns.def:802) pushes a new cref withpushed_by_eval = FALSE,klass_or_self = Foo, and chains it to the previous CREF. Singleton class form setssingleton = TRUE. -
def foo; end: reads the current CREF (vm_get_cbase(ep) → CREF_CLASS_FOR_DEFINITION). The new method lands oncref->klass(or its singleton ifCREF_FL_SINGLETON). The method’s iseq captures the same CREF chain in its environment so unqualified constants andsuperresolve relative to where the method was defined, not where it was called. -
module_eval { … }/class_eval { … }/module_exec/class_exec:yield_under(self, FALSE, …)pushes a new CREF withpushed_by_eval = TRUE,klass_or_self = self,singleton = FALSE. The flag means:definside the block lands onself, but constant lookup /module Foo; endskips this CREF (it’s not a lexical scope). -
instance_eval { … }/instance_exec:yield_under(self, TRUE, …)— same as above but withsingleton = TRUE, sodeflands on the singleton class of the receiver. -
module_eval(string)/class_eval(string):eval_under—vm_cref_push(self, NULL, FALSE /* pushed_by_eval */, singleton). Note that the string form setspushed_by_eval = FALSEbecause 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). -
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. Settingmodule_function/private/…inside the eval mutates the duplicate’sscope_visi; the outer CREF is untouched. -
Kernel#eval(string, binding):binding.crefis 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 t2 — public 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
}
}
| Field | CRuby 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_function | scope_visi.module_func |
visibility | scope_visi.method_visi |
is_lexical | inverse 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/loadboundary (enter_class_contextpushes;exit_class_contextpops). 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
| Event | Helper | Resulting Cref |
|---|---|---|
| Toplevel script start | Default for Executor initializes lexical_class = vec![vec![]] | empty inner vec — context_class_id() falls back to OBJECT_CLASS |
require / load | enter_class_context | new 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_exec | push_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_exec | push_instance_eval_context(self_val) | Cref::new_instance_eval(self_val, Public) — Receiver mode, is_lexical=false |
Kernel#eval(string) no-binding | push_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:
-
context_class_id(): the innermost Cref’s class. Used fordef’s default definee. Walks neitheris_lexicalnor require-frames. Direct equivalent of CRuby’svm_get_cbase(ep) → CREF_CLASS_FOR_DEFINITION. -
lexical_context_class_id(globals): the innermost lexical class. Walks only the current require-frame top-down, returning the firstis_lexical=trueentry. If none, falls back to the iseq’s capturedlexical_context.last()(orOBJECT_CLASS). Used formodule Foo; end/class Foo; endparent and forModule.nesting. Equivalent to CRuby’s “while CREF_PUSHED_BY_EVAL: next” walk invm_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
| Concept | CRuby | monoruby |
|---|---|---|
| Type | rb_cref_t heap imemo | Cref plain Copy struct (16 bytes) |
| Linkage | linked list via next | Vec inside Vec; index = depth |
| Per-frame storage | yes — ep[CREF_SLOT] | no — shared Executor::lexical_class |
| Real-vs-runtime split | CREF_FL_PUSHED_BY_EVAL flag | Cref::is_lexical (inverse polarity) |
Receiver-mode (instance_eval) | CREF_FL_SINGLETON + klass_or_self = receiver | DefinitionContext::Receiver(value) |
def definee | CREF_CLASS_FOR_DEFINITION (auto-promotes to singleton when flag set) | context_class_id() (handles both variants) |
| Constant chain | walks cref.next, skipping PUSHED_BY_EVAL | lexical_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 dup | vm_cref_dup (deep, dup refinements) | push_eval_cref (shallow Copy of innermost Cref) |
| Refinements | first-class field on rb_cref_t | not implemented |
Kernel#load(path, true) wrap | extra cref above toplevel | extra push_class_context(wrap) after enter_class_context |
| Require boundary | implicit (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 Foois a no-op stub. ~83core/moduleruby/spec failures are attributable to this. -
Per-PC cref propagation for
Module.nestinginside methods.current_class_nestingwalks 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 storelexical_context: Vec<ClassId>, butModule.nestingdoesn’t consult it yet. -
module_function/privatetoggles insideKernel#evalwith aBinding. PR #444’spush_eval_crefonly 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_evalpropagation throughdefine_methodproc body. When aprocis wrapped into a method viadefine_method, the proc’s outer cref isn’t faithfully reproduced on the wrapper’s iseq. Currently observable as ~3 failures indefine_method’s “nested method in default definee” / “lambda for break” specs.
Refinements — the design, and why it is shaped this way
monoruby implements refinements. Module#refine, Module#using and
main.using are real, Refinement is a class with #target and
#import_methods, and Module#refinements / Module.used_refinements /
Module.used_modules report the truth. The interpreter and the JIT both
resolve through an activated refinement.
This document is the design record: §§1–5 are the problem — what
refinements do to method resolution and where each of monoruby’s
resolution paths would break under the naive approach — and §§6–7 are the
implementation that avoids it. Read it together with doc/cref.md (the
CREF refinements hang off) and doc/jit.md.
The short version of the problem: refinements make the resolved method a function of the caller’s lexical scope, and every method-resolution path in monoruby — the global method cache, the VM inline cache, JIT compile-time resolution, and the class-version repair path — was keyed without it. The repair path was the dangerous one: left alone it would silently re-validate machine code that resolved the wrong method.
The short version of the answer (§6): represent an activated set as an
interned u32, treat it as a compile-time constant of each body with
class_version as the invalidation channel, and gate everything on “does
this process contain a refinement at all”. A program that never calls
refine emits byte-identical machine code to the one that predates all of
this — checked against an emit-asm baseline, not assumed.
Known gaps. One refinement cell per iseq means a block that runs
using and executes more than once bases on the previous execution’s set
(ISeqInfo::refinements, §7.2 option C). Refinements of basic operations
are honoured in both tiers (§6.7); the JIT keeps its inline path in scopes
that do not activate the refinement, while the VM’s assembly guard has no
call site to ask about and gives it up process-wide.
1. What refinements change about lookup
Without refinements, monoruby’s method resolution is a function of
(receiver class, method name, class version) -> FuncId
and every cache in the system is keyed on exactly that. With refinements it becomes
(receiver class, method name, class version, caller's cref) -> FuncId
Four properties of the cref dependency matter for the implementation. All four were checked against CRuby 4.0.2.
(a) Activation is a runtime event at a lexical position. using is a
statement, not a declaration. The same source text resolves differently
before and after it runs:
module R
refine Integer do
def +(o) = "refined+"
end
end
p (1 + 1) # => 2
using R
p (1 + 1) # => "refined+"
(b) The activated set is captured per method body, at definition
time. A method defined before the using is not refined even when it is
called afterwards:
def unrefined = 1 + 1
using R
p unrefined # => 2 (not "refined+")
So this is not a global mode that can be flipped, and it is not a property of the receiver either.
(c) Basic operations are refinable. Integer#+ above is a monoruby
basic op and a JIT inline-generator target — the two paths that never
dispatch at all.
(d) The reflective entry points see refinements too. In Ruby 4.0
send, public_send, respond_to?, Object#method and blocks / eval
written in the activated scope all resolve through the refinement:
class C
using R
def direct = "x".foo
def via_send = "x".send(:foo)
def via_respond = "x".respond_to?(:foo)
def via_method = "x".method(:foo).call
def in_block = [1].map { "x".foo }.first
def in_eval = eval('"x".foo')
end
# => ["refined", "refined", true, "refined", "refined", "refined"]
There is no “reflection is exempt” shortcut to lean on.
2. The prerequisite: per-scope cref state
doc/cref.md covers this in full; the part that blocked refinements:
#![allow(unused)]
fn main() {
// Executor
lexical_class: Vec<Vec<Cref>>,
}
This is one VM-wide stack, whose outer level is a require / load
boundary, not a call frame. Method calls push nothing. In CRuby the CREF
chain is per-frame, hung off the environment pointer, and using
populates the current frame’s chain.
The consequence has already been observed. Module.nesting used to read
this stack and therefore reported whatever class body happened to be on
it — a method called from inside an unrelated class body saw that body’s
scope. That was fixed (current_class_nesting) by reading the static
ISeqInfo::lexical_context instead, because the lexical nesting of an
iseq never changes after compilation.
A refinement set cannot be recovered the same way. Property (a) above says it is mutated at runtime; property (b) says the mutation is visible only to bodies compiled under that cref. A static per-iseq field cannot express “the set as it stood when this body was defined” unless the field is snapshotted per definition — which is exactly a per-frame cref by another name.
This was the first step. It is a change to where scope state is stored,
not to how lookup works — and, as §7 works out, it did not have to be a
change to the frame layout: the cell lives on ISeqInfo, resolved through
the lexical-parent chain.
3. Where each resolution layer would have broken
3.1 Global method cache
Store::check_method_for_class_with_version (globals/store.rs)
#![allow(unused)]
fn main() {
let mut cache = self.method_cache.borrow_mut();
if let Some(entry) = cache.get(class_id, name, class_version) { … }
}
Key: (name, class_id) + class_version. Every consumer in the tree goes
through here or through search_method_by_class_id beneath it. Under
refinements this key no longer identifies an answer.
The per-class memoized predicates built on the same key (no_to_str,
neq_basic_at, match_method_at, default_copy_at — all Cell<Option<u32>>
stamps on ClassInfo) inherit the problem: a refinement of #to_str on a
class whose no_to_str memo says “no such method” would be invisible to
every coercion site.
3.2 VM inline cache
vm_send (codegen/arch/{x86_64,aarch64}/vmgen/method_call.rs) caches
into the bytecode operand words:
CACHED_FUNCID (u32) CACHED_CLASS (ClassId) CACHED_VERSION (u32)
and guards with two compares:
cmpl r15, [r13 + CACHED_CLASS] ; receiver class
jne slow_path1
movl rdi, [r13 + CACHED_VERSION]
cmpl rdi, [rip + class_version] ; global class version
jne slow_path2
This cache is per call site, so in principle it can hold a cref-dependent
answer — a given call site has exactly one cref. The problem is
invalidation: nothing in these two guards notices that using ran. Making
using bump the global class_version works but is blunt — it invalidates
every inline cache in the program. §6.1 argues that is the right trade
anyway, because using is a load-time event, not a hot-path one.
3.3 JIT compile-time resolution
JitContext::jit_check_method (codegen/jitgen/compile.rs)
#![allow(unused)]
fn main() {
fn jit_check_method(&self, class_id: ClassId, name: IdentId) -> Option<(FuncId, Visibility)> {
let class_version = self.class_version();
let entry = self.store
.check_method_for_class_with_version(class_id, name, class_version)?;
Some((entry.func_id()?, entry.visibility()))
}
}
The JIT resolves to a concrete FuncId at compile time and bakes it into
machine code (and, on the specialized path, inlines the callee body). The
inputs are receiver class, name and version — the compiling iseq’s cref is
not among them and is not even reachable from JitContext.
3.4 Class-version repair — the silent-wrong-answer path
This is the one that makes a half-measure unsafe.
Every JIT compilation records what it assumed:
#![allow(unused)]
fn main() {
// JitContext, per compiled method call
self.inline_method_cache.push((recv_class, callsite.name, func_id));
// stored on the iseq as `inline_cache_map`
}
When the global class version moves, compiled code deopts — and
Store::update_inline_cache (globals/store/class.rs) tries to repair
rather than recompile:
#![allow(unused)]
fn main() {
for (recv_class, name, comptime_fid) in cache_map {
let func_id = self.check_method_for_name(lfp, *recv_class, *name);
if func_id != Some(*comptime_fid) {
return false; // resolution moved -> recompile
}
}
// nothing moved: stamp the new version into the compiled code and keep running
codegen.set_class_version(class_version, &version_label);
}
check_method_for_name is a cref-free lookup. If using only bumped the
class version (§3.2’s blunt option), this loop would re-ask the unrefined
question, get the same unrefined answer it got at compile time, conclude
nothing moved, and re-validate machine code that must now dispatch into the
refinement. No error, no deopt — just the wrong method, indefinitely.
Any refinement implementation has to record the cref alongside each
inline_cache_map entry so the repair re-asks the same question the
compiler asked.
3.5 Specialized inlining
JitType::Specialized inlines a callee’s body into the caller’s frame, and
gen_machine_code recurses through SpecializeInfo to do it for a whole
tree of callees. A refined method body carries its own cref — calls
inside it resolve under the refinement module’s scope, not the caller’s.
Inlining across that boundary needs a per-inlined-frame cref threaded
through JitContext, in the same place the specialized frame sizes and
argument info are threaded today.
3.6 Inlined builtins and basic ops
Two mechanisms bypass dispatch entirely:
InlineTable: HashMap<FuncId, InlineFuncInfo>— an inline generator emits machine code forInteger#+,Array#[],Math.sqrt, … in place of a call.- Basic ops additionally consult a single global word,
bop_redefined_flags;Codegen::set_bop_redefinesets it to!0and callsremove_vm_bop_optimization()— a process-wide, one-way de-optimisation.
Property (c) says Integer#+ is refinable, so a refinement must reach both.
Framed as an either/or — thread the cref into every InlineGen, or take
the process-wide set_bop_redefine cliff — both options are bad. §6.7
splits it instead: the JIT’s inline generators gate per call site on a
refined_names set, and only the VM’s dispatch-table basic ops take the
global hit.
3.7 super
find_super / jit_check_super resolve super by finding the position
of the running body in the receiver’s ancestor chain
(body_dispatched_by, occurrence counting) and continuing from the next
one. The chain is a property of the receiver class.
A refinement is, semantically, an entry that exists in the chain only
relative to a cref, and super inside a refined method means “the method
this refinement shadows”. Neither is expressible in a position-in-the-real-
chain model; super from a refinement needs its own resolution rule.
4. Why the cheap version would have been worse than nothing
A stub refine that creates an anonymous module, evaluates the block in
it and returns it — no activation — was prototyped and measured. It
unblocks 55 of the 59 core/binding examples (see §5) because the blocker
there is a fixture that merely calls refine at load time.
It was not kept, and the real thing was implemented instead. Without
activation, refine turns every refinement-using
program from a clear NoMethodError at the refine call into a silently
wrong result at every refined call site. The same trade-off applies to
using, which is why the existing Module#using is documented as
“activates the (necessarily empty) set of refinements” — it is only honest
because refine cannot produce a non-empty one.
5. What it is worth, in ruby/spec
Measured against CRuby 4.0.2, current tree:
| category | monoruby | note |
|---|---|---|
core/refinement | 25 examples, 25 errors | nothing implemented |
core/module | 11 F / 66 E, of which 62 mention refine | refine_spec, using_spec, module_eval refinement scope, … |
core/main | 3 F / 6 E, of which 5 | main.using |
core/kernel | 4 F / 11 E, of which 2 | Kernel#eval refinement scope |
language/pattern_matching | 2 E | #deconstruct via refinement |
core/binding | 0 F / 7 E | see below |
core/binding is a special case worth separating. Its 7 errors are not 7
failing examples — they are 7 spec files that fail to load, because
core/binding/fixtures/classes.rb:54 calls refine. That takes 59
examples out of the run (monoruby executes 39 of CRuby’s 98). Exactly one
of those 59 needs refinements to work (Binding#eval reflects refinements activated in the binding scope); the other 58 only need refine to
exist.
So the ledger is roughly:
- ~96 examples need real refinements,
- ~58 examples are collateral damage from a fixture that only needs the method to be defined.
That asymmetry is the argument for eventually implementing refinements properly rather than stubbing them: the stub buys the 58 at the cost of making the 96 fail silently instead of loudly.
6. An implementation strategy that keeps the performance
The naive framing of §3 — “every cache key has to grow a cref” — is what
makes refinements look prohibitive. It is avoidable. The design below rests
on one representation choice and two observations, and its acceptance
criterion is that a program which never calls refine executes the same
machine code it does today.
6.0 The representation: an interned set id
Represent an activated refinement set as an interned RefinementSetId(u32):
#![allow(unused)]
fn main() {
RefinementSetId::EMPTY == 0 // no refinements activated
using M on a scope holding S => intern(S ∪ refinements_of(M))
}
Interning is hash-consing over a table of small sets; equal sets get equal ids. Real programs have a handful of distinct activations, so the table stays tiny.
This is the move that makes everything else cheap. The cref’s refinement
state stops being a Hash[refined_class => module] that has to be walked
and starts being a u32 that can be compared, stored in a cache entry,
and baked into compiled code as a constant.
6.1 Observation 1 — a call site’s set is a compile-time constant
using is illegal in a method body (§7.1), so:
- A method body snapshots its scope’s set at
def. Every invocation of that method resolves under the same id. - A block reads its home scope’s live cell — but that cell changes only
when
usingruns in that scope, andusingis a load-time event.
So for any iseq, the set is constant except across a using in its home
scope. That is precisely the shape a JIT speculates on: treat the set id
as a compile-time constant, and let using bump class_version.
using bumping the global class version invalidates every inline cache and
every JIT entry in the program. That sounds violent until you notice it is
exactly what a def at load time already does, and using happens once
per scope during startup, never in a loop.
6.2 Observation 2 — the cost should scale with refined names, not with using refinements at all
Maintain
#![allow(unused)]
fn main() {
refined_names: HashSet<IdentId> // union of names any refinement defines
}
This is typically a handful of symbols. Then:
- the global method cache and the per-class memoized predicates
(
no_to_str,neq_basic_at, …) keep their existing key, and simply refuse to serve a name inrefined_names; - those names — and only those — take
search_method_with_refinements(recv_class, name, set_id).
A program that refines String#blank? pays nothing on Array#each,
Integer#+, or any of the other tens of thousands of call sites. The tax
is proportional to how much is refined, which is the right shape and is
what keeps a refinement-using program fast, not just a refinement-free one.
6.3 Where the mutable cell lives
Only toplevel / class-module bodies / eval-at-toplevel own one (§7.1), and
now it holds a u32. §7.2 option (B) — a side table on the Executor
keyed by LEP, alongside the existing deferred_unwind — stays the
recommendation: no frame-layout change, correct per execution, and
allocated only once a refinement exists.
A block finds its own set by the same outer-chain walk to the LEP that
$~ already does. A method finds its own on its FuncInfo, written by
def. (def re-executing under a different set writes a different id;
since using already bumped the version, that is self-correcting rather
than stale — unlike lexical_context, which has no such guard.)
6.4 The zero-cost gate
A process-wide flag, false until the first refine call:
| path | flag false | flag true |
|---|---|---|
search_method | today’s code | + refined_names check |
| global method cache | today’s key | today’s key; skipped for refined names |
| VM inline cache | today’s two guards | unchanged (see 6.5) |
jit_check_method | today’s lookup | takes set_id |
update_inline_cache | today’s loop | + id comparison (see 6.6) |
| inline generators | fire as today | gated per call site (see 6.7) |
The acceptance criterion is stronger than “fast”: with the flag false the
emitted machine code must be identical, which --features emit-asm
makes directly checkable against a baseline. Benchmarks (optcarrot and
benchmark/) then cannot regress, by construction rather than by
measurement.
6.5 VM inline cache — no format change
The cached triple lives in the bytecode operand words
(CACHED_FUNCID / CACHED_CLASS / CACHED_VERSION) and has no room for a
fourth. It does not need one: a call site has exactly one set id, so the
cached FuncId is already the right answer for that site. The warm path
(runtime::find_method) has vm, hence the current frame, hence the set —
it resolves with it and caches the result. using’s version bump forces a
re-warm. Nothing in the guard sequence changes.
6.6 JIT — closing the repair hole for 4 bytes
inline_cache_map entries grow from
#![allow(unused)]
fn main() {
(ClassId, Option<IdentId>, FuncId)
}
to
#![allow(unused)]
fn main() {
(ClassId, Option<IdentId>, RefinementSetId, FuncId)
}
and update_inline_cache’s re-check calls the set-aware lookup. That is
the whole fix for §3.4’s silent-wrong-answer path: the repair now re-asks
the question the compiler asked. Four bytes per recorded call site, no
runtime cost, and with the gate off every recorded id is EMPTY and the
comparison is a constant-folded no-op.
JitContext gains the set id of the iseq it is compiling — read from the
FuncInfo snapshot for a method, or from the live frame for a block/loop,
both at compile time in Rust. No emitted prologue changes and no machine
code ever loads a cref.
6.7 Inline generators and basic ops — split the two
§3.6 framed this as a choice between threading the cref into InlineGen
and taking the global basic-op cliff. With refined_names it is neither:
- Inline generators (
Array#[],String#size, …) are consulted at JIT compile time, where the set id is known. Gate them onset_id == EMPTY || !refined_names.contains(name). A refinement ofArray#[]costs the fast path only in scopes that activated it; every other scope keeps it. - Basic ops in the VM (
vm_binops, the comparison dispatch entries) are selected by a dispatch table with no call-site context, andremove_vm_bop_optimizationis a one-way process-wide switch. Refining one of those does take the global hit. That is acceptable because it is the same hit a global monkey patch ofInteger#+takes today — the honest comparison — and because the JIT, which is where the time actually goes, keeps per-scope precision via the gate above.
Update. The order this called for was followed —
doc/bop_redefinition.mdgave basic ops(op, class)granularity, full coverage and per-iseq JIT invalidation first — and once that was in place, honouring a refinement of a basic operation turned out to need almost nothing beyond marking the pair.insert_method/remove_methodaskrefined_class()for the class the refinement refines and mark(that class, name); both tiers then stop answering it without a lookup, and the dispatch they fall back to was already refinement-aware.refine Integer { def +(o) = 42 }now yields 42 in the VM and in JIT-compiled code.Two things that fix did not buy:
- Per-scope precision in the JIT — since added.
BasicOpTablekeeps the union it always did (“this pair is no longer unconditionally sound”, which is what the runtime guards want) but records the reason alongside it, soassume_basic_opcan ask two questions: a global redefinition binds everywhere and is never inlined, while a refinement is checked against the compiling scope’s set (Store::basic_op_refined_in_scope).refine-ing an operator no longer costs anything outside the scopes thatusingit —fib(29)0.034 → 0.009, back to baseline. The VM’s asm guard is a global word with no call-site context and stays coarse; correctness there rests on the dispatch it falls through to, which is refinement-aware.- The inline-generator half, which is still ungated.
One boundary had to be drawn to make any of this correct — see
Executor::basic_op_refinements. monoruby writes part of its core library in Ruby where CRuby uses C, and those frames are transparent to refinements so that&obj/ interpolation, which monoruby converts in the callee and CRuby in the caller, reach the user’s scope. An operator is never such a conversion:Array#map’s owni += 1is library code, C in CRuby and invisible to any refinement. Resolving it against the caller ended the loop after one iteration. So operator names stop the walk at the library boundary and everything else still walks out.
6.8 The remaining pieces
superinside a refinement means “the method this refinement shadows”. Recordrefined_class: Option<ClassId>on the refinement module and special-casefind_superwhen the running body belongs to one, instead of trying to express it as a position in the real ancestor chain (§3.7).- Reflection (
send,respond_to?,Object#method) resolves with the caller’s set: the samenearest_ruby_framewalk the eval builtins now use, then that frame’s set id. Only reached with the gate on.
6.9 Order of work — as landed
Each step shipped on its own, with the spec ledger and (from step 3) the
emit-asm baseline checked at every one.
refine/usingbuild and intern sets;Module#refinementsandused_refinementsstop being mocks. Lookup still ignores them, so nothing can regress yet.search_method_with_refinements+refined_names+ the gate.usingbumpsclass_version. Correct end-to-end through the VM; the JIT still refuses to compile any iseq with a non-empty set (deopt to VM), which is safe because the gate keeps that path cold.RefinementSetIdintoJitContextandinline_cache_map; lift the step-2 refusal. This is the step that must not move the emit-asm baseline for the gate-off case.- Inline-generator gate,
super, reflection.
Steps 1–2 were shippable on their own: refinements worked, refinement-using
code was slower than it needed to be, and nothing else in the system
changed speed. Step 3 is where the §3.4 hazard is actually closed, which
is why the JIT kept refusing until it landed — a refusal is a performance
choice, a wrong FuncId is not.
Over core/{refinement,module,main,binding,kernel,proc,class,basicobject}
and language, the four steps took 137 failing examples to 30. What is left
is §6.7’s basic ops, the per-iseq cell’s re-execution case, and one
import_methods example that is not a defect (monoruby implements Zlib in
Ruby, so importing from it legitimately succeeds).
7. Does this change the method frame layout?
Not for method frames. It does need a mutable cell somewhere for the
scopes that can run using — but those are a small, bounded set, and the
JIT never has to read it from machine code.
7.1 What each kind of body actually needs
The requirement is bounded by what a scope’s own refinement state does
when it changes mid-execution. Two using calls in one scope, with a
proc and a def interleaved between them (CRuby 4.0.2):
module A; refine(Integer) { def tag; "A" } end
module B; refine(Integer) { def tag2; "B" } end
p0 = proc { … } ; def m0 = … # before both
using A
p1 = proc { … } ; def m1 = … # between
using B
p2 = proc { … } ; def m2 = … # after both
# procs: p0 -> [A, B] p1 -> [A, B] p2 -> [A, B]
# methods: m0 -> [-, -] m1 -> [A, -] m2 -> [A, B]
Every proc sees the final state, including the one created before any
using ran. Every method sees the state as of its own def, and the
three methods in one scope carry three different sets.
So the scope’s refinement set is genuinely modified at runtime; a block
reads the scope’s live state at call time rather than snapshotting it,
while def snapshots. The cell must therefore be mutable, owned by the
scope’s environment, and read through — not a value copied into each
closure. A static per-iseq field cannot express both halves.
Runtime mutation of a CREF is not itself new to monoruby: bare private /
public / protected already write Cref::visibility in place
(set_context_visibility), module_function writes Cref::module_function
(set_module_function / clear_module_function), and class bodies and
evals push and pop entries (push_class_context, push_eval_cref). What
refinements add is that method resolution starts depending on that
mutable state — and that the mutation must be visible to blocks already
created and invisible to methods already defined.
But the mutable half is needed only where using is legal, and that is
narrow (verified):
| position | using |
|---|---|
| toplevel | main.using — ok |
class / module body, Class.new { } | Module#using — ok |
Kernel#eval at toplevel | ok |
| method body | RuntimeError: Module#using is not permitted in methods |
| outside toplevel via main | RuntimeError: main.using is permitted only at toplevel |
A method body therefore only ever needs a read-only snapshot taken at
def — which is precisely what ISeqInfo::lexical_context and
ISeqInfo::nested_definee already are. Method frames need no new slot.
The same applies to the JIT: it compiles method / block / loop bodies and resolves methods at compile time, in Rust, with a live frame in hand. It never needs to load a cref from machine code, so none of the emitted prologues change.
7.2 Three places the mutable cell could live
(A) A new LFP_CREF word in the local frame. The faithful option, and
there is a template: LFP_SVAR was added for the structurally identical
problem — $~ owned by the LEP, shared with blocks through the outer
chain, lazily allocated with 0 as the “unset” sentinel, marked in
Lfp::mark. A cref slot would copy it line for line.
The cost is the layout shift. Today:
LFP_OUTER 0 LFP_META 8 LFP_SVAR 16 LFP_BLOCK 24 LFP_SELF 32 LFP_ARG0 40
RSP_LOCAL_FRAME = 40
Inserting a word moves LFP_ARG0 and RSP_LOCAL_FRAME to 48, which
reaches 41 LFP_ARG0 and 149 RSP_LOCAL_FRAME references across 26 files
— both architectures’ vmgen/{init_method,method_call,definition}, both
JIT compile/ trees, the invokers and native wrappers, Lfp::heap_frame /
move_frame_to_heap / frame_bytes, and Lfp::mark. It also spends 8
bytes on every frame for something only non-method frames use.
(B) A side table on the Executor, keyed by LEP. The codebase already
keys per-frame state this way: deferred_unwind: Vec<(Lfp, MonorubyErr)>
and adapter_blocks: Vec<(Value, ProcData)>. Resolving “my scope’s cref”
is the same outer-chain walk to the LEP that $~ does, followed by a
lookup. No layout change, per-execution correct, and behind the Stage-2
global gate a non-refining program never touches it.
What it has to handle: move_frame_to_heap changes an Lfp’s identity
(deferred_unwind carries the same exposure), entries must be dropped when
the frame dies so a reused stack address cannot inherit a stale cref, and
the stored crefs must be reachable from the GC.
(C) Per-iseq storage, no frame involvement at all. Put the cref next to
lexical_context on ISeqInfo, last-execution-wins — which is already how
enter_classdef stamps lexical_context today. using writes the running
scope’s cell, blocks read their mother’s, def snapshots.
This inherits the staleness class ISeqInfo already documents: one cell
per iseq, so re-entrant execution of a scope that runs using (a recursive
or concurrently-loaded Class.new { using … }, the same file required on
two threads) shares it. Rare, given §7.1’s table — but silently wrong when
it happens, which is the failure mode §4 argues against.
7.3 Recommendation
(B). It leaves the frame layout alone, is correct per execution rather than
per iseq, and costs nothing while no refinement has been activated. (A) is
the more faithful model and is known-feasible, but it taxes every call in
every program for a feature most never use; it is the right answer only if
per-frame cref turns out to be wanted for other reasons too — doc/cref.md
lists several places where monoruby’s single VM-wide lexical_class stack
already diverges from CRuby, so that is not far-fetched.
TOPLEVEL_BINDING and the main script’s frame
Why the main script no longer runs inside TOPLEVEL_BINDING, what is
built instead and when, and which reads still force the old shape.
1. The problem: a binding’s frame is a captured frame
TOPLEVEL_BINDING has to expose exactly the main script’s locals —
live, and shared with anything the binding sets dynamically:
# main.rb
p TOPLEVEL_BINDING.local_variables # => [:a] (parse-time locals, before the assignment)
a = 1
p TOPLEVEL_BINDING.local_variable_get(:a) # => 1
The direct way to get that is to be the binding’s frame: compile the
main script as a body of the binding and run it there
(Globals::compile_main_script_binding). That is what monoruby did.
The cost was invisible and large. A Binding’s frame is heap-allocated,
so the main script’s frame has the on_heap bit set in its Meta, and
vm_loop_start — the VM’s loop-JIT trigger — begins with
branch_if_captured:
testb [r14 - (LFP_META - META_KIND)], 0b1000_1000 ; on_heap | invalidated
jnz cont ; …never count, never compile
A captured frame’s locals may be aliased through the heap, which the
register-caching JIT cannot honour, so the trigger skips such frames
entirely. For the main script that meant top-level code was never
JIT-compiled at all — not “compiled and then deoptimized”, never
compiled: with --features jit-log a top-level-loop program reports
elapsed JIT compile time: 0ns, and --no-jit runs it at the same
speed.
Measured on x86-64 (release, best of 3), the same loop as a main script
versus inside a required file (a plain stack frame):
| main script | via require | CRuby 4.0.2 | |
|---|---|---|---|
benchmark/so_mandelbrot.rb | 3.41 s | 0.21 s | 1.98 s |
minimal while loop over floats | 1.35 s | 0.13 s | 0.77 s |
Method-shaped code was never affected — only code running directly at
the top level. (The benchmark harness measures the method-wrapped form:
benchmark/so_mandelbrot.yml wraps the body in def do_it, which is why
the suite never showed this.)
2. What happens now
TOPLEVEL_BINDING is registered at startup as a lazy constant —
ConstStateKind::LazyToplevelBinding. The name is defined (it lists in
Object.constants, answers const_defined? and defined?, and reports
no autoload) but holds no Binding object yet.
- The main script runs as a plain toplevel body on a stack frame
(
Executor::try_exec_main_script_plain), so its loops reach the JIT like any method’s. - The first read of the constant builds the binding
(
Executor::materialize_toplevel_binding), over the main script’s own frame — promoted to the heap exactly asKernel#bindingpromotes a method’s frame. Reader and script then share one set of locals, which is the semantics above. Promotion mid-run is the same event abindingcall inside a hot method loop causes, and is handled by the existinginvalidatedtombstone machinery. - Reading it where no main-script frame is running — during a
-rrequire, before the script starts — builds an empty frame on the main object, which is what CRuby exposes at that point too. - If a read happened before the main script starts (a
-rlibrary that touched it), the script runs inside that already-built binding: the old path, unchanged, because the binding has already handed out the frame its locals must live in.
3. Scripts that name the constant themselves
Two readers cannot be served by “build it over the frame that is running right now”:
z = 7
at_exit { p TOPLEVEL_BINDING.local_variable_get(:z) } # toplevel frame is gone by then
Thread.new { p TOPLEVEL_BINDING.local_variables }.join # another call chain
So a main script that names TOPLEVEL_BINDING anywhere in its own
source — a mention inside a block counts — runs inside the binding, the
pre-existing path, and keeps the pre-existing semantics.
The test is the constant sites the script’s compilation recorded
(Store::names_constant_since), so it sees exactly the literal
references the script compiled: a mention in a comment does not count, a
reference nested in a block or a def does. A script that trips this
loses top-level JIT, as before; a script that does not mention the
constant pays nothing.
Dynamic reads from other files (Object.const_get(:TOPLEVEL_BINDING)
in a library, an eval) are not statically visible, and do not need to
be: they run while the main script’s frame is live, so §2’s
materialization serves them. The residual gap — a required library
reading it from a thread or an at_exit handler, in a program whose own
source never mentions the constant — yields the empty binding.
4. Where the pieces live
| Piece | Location |
|---|---|
| Lazy constant slot | ConstStateKind::LazyToplevelBinding, globals/store/class/constants.rs |
| Registration at startup | Executor::init, executor.rs |
| First-read hook | Executor::get_constant, executor/constants.rs |
| Building the binding | Executor::materialize_toplevel_binding, executor.rs |
| Fast path + static scan | Executor::try_exec_main_script_plain, executor.rs |
| Binding path (unchanged) | Globals::compile_main_script_binding, globals.rs |
<main> backtrace label | Store::func_description_for, globals/store.rs (keyed by the recorded main-script FuncId, since a plain main body is otherwise indistinguishable from a required file’s toplevel) |
| Tests | monoruby/tests/main_script.rs |
super のメソッド名解決と呼び出し元 PC を用いた実装
super は「いま実行中のメソッドと同じ名前のメソッドを、祖先チェーンの
現在位置より先から探して呼ぶ」命令である。単純に見えるが、「同じ名前とは
どの名前か」「現在位置とはどこか」の 2 点が自明でなく、CRuby は両方をメソッド
エントリ(callable method entry)に持たせて解決している。monoruby はフレームに
メソッドエントリを持たない(FuncId しか持たない)ため、同じ情報を
呼び出し元 PC(cont-frame スロット)から復元する。本書はその機構を説明する。
対象ソース:
monoruby/src/codegen/runtime.rs—entered_by/super_run/super_resolution/find_super/defined_super/find_methodmonoruby/src/globals/store/class.rs—body_dispatched_by/super_occurrences/check_super/check_super_at/change_method_visibility_for_classmonoruby/src/globals/store.rs—MethodTableEntrymonoruby/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 つ:
- VM tier —
push_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 を 保存する。 - JIT tier(#889) — cont-frame 16 バイト領域は cont モードの
FprSaveが予約済み(xmm 退避はその上に置かれる)なので、AsmInst::ContFramePcがmovq [rsp], pc(a64:str x10, [sp])を send / specialized send / yield / specialized yield の 4 箇所すべてで発行する。 - 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 / 33 | super |
| 34 / 35 | yield |
30〜33 の第 1 ワード下位 32 ビットが CallSiteId であり、
CallSiteInfo::name から「呼び出しに使われた名前」が得られる。
検証に失敗した場合(invoker 境界など)は None を返し、後述の
フォールバックに落ちる。
4. 解決アルゴリズム(find_super)
super 実行時、ランタイムは次の 2 つを復元する
(runtime.rs::super_resolution)。
4.1 呼び出し名の復元 → original_name への写像
- メソッドフレームを特定する。ブロック内の super は外側メソッドに属するため、
lfp.outermost()(proc-method 境界で停止する外側連鎖)でメソッド LFP を 求め、その LFP を実行している CFP まで下る。 - そのフレームの cont-frame スロットを
entered_byで復号し、通常 send (opcode 30/31)なら CallSiteId →CallSiteInfo::name= 呼ばれた名前 を得る。 - 呼ばれた名前をレシーバクラスのメソッドテーブルで引き、
そのエントリが本当にこのフレームの本体へディスパッチするか検証する:
entry.func_id == 実行中 FuncId、または- エントリが proc-method ラッパー(
FuncKind::Proc)でproc.func_id() == 実行中 FuncId(define_method はラッパー FuncId を登録するが、実行フレームには ブロック本体の FuncId が乗るため)。
- 検証に通れば
entry.original_nameを検索名とする。これで define_method 複数名(1.1: original_name = 各インストール名)と alias(1.2: original_name = 元定義名)の両方が CRuby と一致する。 - コールサイト PC が無い場合(invoker 境界)は、
Executor::invoked_as——send/Method#call/UnboundMethod#bind_call/ Rust 側のinvoke_method_*が「いま何という名前でディスパッチしているか」を 退避・復元付きで記録するフィールド —— を代わりに読む。読んだ名前は 3 と同じ検証(そのエントリが本当にこの本体へディスパッチするか)を 通すので、古い名前が残っていても誤用されない。 - それでも復元できない場合のみ
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 && !is_proc_method && super_occurrences(...) <= 1の ときだけキャッシュ可。is_proc_methodを見るのが必須である:define_methodは本体を(厳密 arity のために)method-style にするのでis_block_styleでは捕まらず、これを落とすと最初の呼び名の解決結果が サイトに焼き付き、以降どの名前で呼んでも同じ親が呼ばれる(#1346)。 JIT 側の ambiguous-super ガードは元から同じ条件を見ており、VM が キャッシュしないことを前提にしている。 不可ならfind_methodはキャッシュタグに ClassId 0 を返す (ClassIdはNonZeroU32なので実クラスと一致せず、そのサイトは 毎回スローパスで再解決される)。 - 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 スロットが無効である。呼び出し名はinvoked_as(§4.1-5)で復元できるのでsend/Method#call/UnboundMethod#bind_callは CRuby と一致するが、出現カウントは 依然フォールバック(exact=false→ 旧ヒューリスティック)になる。 名前を持たない経路(Fiber の再開など)は焼き付け名のままである。 - 可視性シャドウの 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 解決機構
ランタイム最適化 — 目次と共通の前提
runtime_optimization/ は、Ruby のコアクラスが monoruby の中でどう表現され、
VM と JIT がその上のホットな操作をどう安くしているかを、クラスごとに 1 文書
ずつまとめたものである。各文書は末尾に CRuby との実装差異の表を持つ。
すべて日本語。
| 文書 | 内容 |
|---|---|
| array.md | SmallVec<[Value; 5]> の inline / ヒープ、[] / []= / << / リテラル / 多重代入の VM・JIT 経路、Ruby で書かれた each / map / initialize、sum / sort / hash の注記。共有配列を持たない理由 |
| hash.md | inline(≤ 3 ペア)/ boxed 線形 / boxed 索引の 3 表現、vm 不要の prehashed probe、機械語の probe(Symbol / String キー)、2 つのハッシュ関数、Ruby で書かれた走査とそのプリミティブ、計測と残課題。ar_table / st_table との対比 |
| string.md | SmallVec<[u8; 32]> と共有部分文字列(隠れ frozen root、copy-on-write)、エンコーディングと code range の O(1) 畳み込み、frozen リテラルと "lit".freeze、補間、<< / getbyte / setbyte / == の JIT、Hash の String キー |
| regexp.md | Onigmo とプロセス全体のパターンキャッシュ、FrozenLiteral としてのリテラル、LFP_SVAR の $~ と 40 バイトの MatchData、MatchData を作らない経路、正規表現を使う String メソッドの高速経路、StringScanner |
共通の前提
以下は 4 文書が暗黙に使う事実で、それぞれの出典を示す。
RValue は 64 バイト
ヒープオブジェクトはすべて RValue(value/rvalue.rs)で、8 バイトの
ヘッダ(live / frozen / chilled / GC の OLD・WB_UNPROTECTED・WB_ARMED /
年齢、型タグ ObjTy、型別メタデータバイト ty_flags、クラス)、8 バイトの
var_table(あふれた ivar)、48 バイトのペイロード共用体 ObjKind
からなる。Array / String / Hash はこの 48 バイトに「小さいものは直置き、
大きければヒープ」の 2 段を入れていて、閾値はそれぞれ 5 要素 / 32 バイト /
3 ペアである。値の表現(Fixnum / Flonum / 即値 / ヒープポインタ)は
CLAUDE.md の「Value Representation」を参照。
fork した smallvec
Array と String のバッファは sisshiki1969/rust-smallvec(const_generics)
で、fork している理由は OFFSET_CAPA / OFFSET_INLINE / OFFSET_HEAP_PTR /
OFFSET_HEAP_LEN を公開して JIT / VM がペイロードを直接アドレッシング
できるようにするためである。規約は「inline のあいだは capacity が長さを
兼ね、capacity > inline 容量 ならヒープ変種」で、機械語の高速経路はすべて
cmp capa, N の 1 命令でこれを分岐する。
世代別 GC と write barrier
GC は非移動・stop-the-world・世代別(../gc.md)。子への参照を
持つオブジェクトが OLD へ昇格できるのは、その参照の格納がすべて
write barrier を通る場合だけである。Array / Hash は mutator を barrier 付きの
ラッパで隠し、String は隠れ root への 1 本の参照だけを barrier し、Regexp は
Value を持たないので無条件に昇格できる。JIT がインラインで格納するときは
emit_write_barrier_rdi / _bulk を自分で出す。
インライン生成器と BOP
JIT は組み込みメソッドの呼び出しを、InlineTable::add_inline に
inline_gen2! で登録された生成器(抽象状態と AsmIR を受け取り、機械語を出す
か false で辞退する)で置き換える。生成器は class-version ガードと
レシーバクラスガードの後で呼ばれ、辞退すれば状態と IR が巻き戻される
(trial inlining、../inline.md)。
Array#[] / Array#[]= / Hash#[] / String#freeze / String#!= など
**基本演算(BOP)**として表(globals/store/basic_op.rs)に載っている組は、
class-version ガード無しの直接発火経路を取れ、再定義は記録した依存
(record_bop_dep)で該当本体だけを evict する
(../bop_redefinition.md)。表に無いメソッドは
通常の class-version ガード付き経路に残る。
deopt と「call で逃がす」
型ガードの失敗はインタプリタへの side exit(deopt)になるが、deopt が
再コンパイルを起こさない場面で毎回 deopt するサイトは慢性的に遅くなる
(../chain_deopt.md、../deopt_log.md)。
そのため各文書に出てくる高速経路は、形が合わないケースを deopt でなく
builtin への call で逃がす設計を繰り返し選んでいる: Hash の probe が
inline / identity / 索引の表現を hashindex に渡す、Array#[] の多相
サイトが 2 腕の dispatch を出す、String#<< が fallback を末尾呼び出しする、
String#setbyte が共有レシーバを detach して再試行する、など。
Ruby で書かれた組み込み
builtins/*.rb の Array#each / Hash#each / Array#initialize などが
Ruby なのは、JIT がインライン展開するのが FuncKind::ISeq の呼び出し先だけ
だからで、ホットなサイトではメソッド本体とブロックが yield の位置に展開
される。代償として、CRuby の C 実装なら無傷な Integer#< や Array#size の
再定義が組み込みの内部に及ぶ(意図したトレードオフ、
../bop_redefinition.md)。
計測の規律
各文書の数値は、同じ計測機で交互ラウンドの中央値の最小値を取り、 マイクロベンチは「同じループから対象操作だけ抜いたもの」を baseline として 差し引いている。monoruby と CRuby ではループ自体のコストが 10 倍近く違う ので、生の時間を並べても比較にならない。手順は hash.md §8。
Array の実装と最適化
Array は Ruby プログラムの「ただの並び」であると同時に、each / map /
[] / << が最内ループに現れる型でもある。optcarrot では Array#<< と
Array#[]= のスライス形が最上位のホットスポットだった
(../optcarrot_opt_profile.md §3)。
この文書は、Array がメモリ上でどう置かれ、VM と JIT が [] / []= / << /
リテラル生成 / 多重代入をどう処理し、どの組み込みメソッドが Ruby で書かれて
いてそれがなぜかをまとめ、最後に CRuby との実装差異を並べる。共通の前提
(RValue の 64 バイト、write barrier、インライン生成器の仕組み)は
README.md にある。
1. 表現
1.1 セルの中身
RValue は 64 バイトの #[repr(C)] で、8 バイトのヘッダ、8 バイトの
var_table(あふれた ivar)、48 バイトのペイロード共用体 ObjKind から
なる(rvalue.rs)。Array のペイロードは
#![allow(unused)]
fn main() {
pub const ARRAY_INLINE_CAPA: usize = 5;
#[repr(transparent)]
pub struct ArrayInner(SmallVec<[Value; ARRAY_INLINE_CAPA]>);
}
で、5 要素までは 48 バイトの中に直接置かれ、それを超えるとヒープに
あふれる(value/rvalue/array.rs)。smallvec は fork
(sisshiki1969/rust-smallvec、const_generics 有効)で、fork している理由は
JIT / VM のアセンブリがペイロードを直接アドレッシングできるように
OFFSET_CAPA / OFFSET_INLINE / OFFSET_HEAP_PTR / OFFSET_HEAP_LEN を
公開するためである。rvalue.rs はこれを RVALUE_OFFSET_ARY_CAPA などとして
セル先頭からのオフセットに焼き直す。
すべての高速経路の前提になる規約: inline のあいだは capacity が
長さを兼ねる。capacity > 5 ならヒープ変種が生きていて、そのときは
capacity が確保サイズ、実際の長さはポインタの隣にある。したがって
cmpq capa, 5 の 1 命令で「inline かヒープか」と「inline なら長さは capa」
の両方が決まる。Struct(StructInner)は同じレイアウトを共有していて、
スロットアクセスに同じ定数を使う。
1.2 フラグ
frozen はヘッダ flag のビット 1(0b10)。ビット 0 が live、ビット 2 が
chilled(String 専用)、ビット 3 / 4 / 6 が世代別 GC の OLD / WB_UNPROTECTED /
WB_ARMED、ビット 8..15 が GC の年齢。Array は Hash と違って型別メタデータ
バイト ty_flags を使わない。
NEWBORN_FLAG_MASK は JIT と共有していて、リテラル複製の機械語がテンプレート
のヘッダ語をこのマスクで落とすので、dup / clone / リテラル評価はすべて
若く・バリア未装填の状態で始まる。
1.3 GC との関係
- mark は要素を順に mark するだけ(
rvalue.rsのObjTy::ARRAYアーム)。 - Array は OLD 世代へ昇格できる(
is_promotable)。条件は「要素の 格納が全部バリアされていること」で、インタプリタ側はArrayラッパ、 JIT 側はarray_index_assignと emit されたバリアがそれを保証する。 - ラッパ
Array(#[monoruby_object(write_barrier)])の固有メソッドはDeref先のArrayInnerの同名メソッドを隠すので、ary.push(v)/set_index/insert/fill/resize/extend/replace/set_index2は必ずwrite_barrier/write_barrier_bulkを通る。削除系 (pop/remove/truncate/clear/drain)にバリアは要らない。 as_array_mut(&Store)は frozen 検査を取得時に 1 回だけ行い、返った ラッパの mutator は再検査しない。
詳細は ../gc.md。
1.4 共有ビュー(長いスライス)
a[10..] / a[i, n] / slice が返す 16 要素以上(ARRAY_SHARE_MIN)の
スライスは要素をコピーせず、String の SharedContent と同じ仕組みで
共有ルートのバッファへのビューになる(ArrayContent 共用体、
SharedArray { tag, ptr, len, root })。CRuby の rb_ary_subseq /
ary_make_shared に相当する。
- 親がヒープ変種なら、最初のスライス時にバッファを隠れた frozen なルート
Array に移し、親自身をそのルート全体のビューにする(
ensure_shared_root)。 親が frozen ならルートは親そのもの。inline 変種(5 要素以下)はバッファが セルの中で動くので共有せずコピーする。短いスライスもコピー。 tagはcapacityスロットに置くisize::MAX(ARRAY_SHARED_TAG)で、ptr/lenはあふれたSmallVecのOFFSET_HEAP_PTR/OFFSET_HEAP_LENに重なる。したがって JIT の読み出し経路([]、length、ブロック引数展開)は signed のcapa > 5でそのままヒープ経路に入り、ビューを変更なしに読める。- 書き込みは copy-on-write: Rust 側は
owned_mut()がビューを自前の バッファにコピーしてから返す(DerefMutも同じ)。JIT の書き込み高速経路 (array_index_assign、<<、同長スライス代入)はヒープ経路の先頭でtagを 検査し、ビューなら汎用経路に落とす。ルートのバッファは決して書かれない。 - GC: ビューの mark は要素ではなくルートを mark し、
young_child_existsも ルートだけを見る(String の STRING アームと同じ理由)。親をビューに変えるとき 古い親から若いルートへの辺が生えるのでwrite_barrier(root)を打つ。 - 代償は CRuby と同じで、共有された親への次の破壊的操作が 1 回だけ全コピーに
なること。hexapdf の行送り(
@items[@beginning_of_line_index..-1]を 行ごとに返す)はこれでmemmove11% が消える。
2. VM の経路
2.1 バイトコード
| 命令 | 用途 |
|---|---|
Array(dst, callsite) | リテラル生成。splat 位置は call site が持つ |
ArrayConcat { dst, src } | 長いリテラルの分割結合 |
ExpandArray(src, dst, len, rest_pos) | 多重代入・ブロック引数の分解 |
ArrayTEq / ArrayAny | when *ary(=== あり / 真偽のみ) |
Index / IndexAssign | 単一・非 splat の添字 |
Literal / FrozenLiteral | 定数リテラル(Array は常に Literal、§3.6) |
リテラルは LITERAL_CHUNK_LEN まで 1 命令、それより長ければ有界の
チャンクに分けて ArrayConcat で繋ぐ(bytecodegen/expression.rs::gen_array)。
一時レジスタの数がリテラル長に比例せず、ret が作りかけの配列を持たない。
定数リテラルは Value::from_const_ast でテンプレートを 1 個作り
emit_literal に渡すが、FrozenLiteral になるのは is_always_frozen な
クラスだけなので Array は Literal、つまり評価ごとに deep copy(Ruby の
意味論どおり新しい可変オブジェクト)になる。
実行時の Array は runtime::gen_array: splat が無ければスタックスロットの
範囲を Value::array_from_iter で一気に取り込む。
2.2 [] / []=
VM スタブ vm_index / vm_index_assign は二項演算と同じ簿記をしてから
runtime::get_index / set_index を呼ぶ:
(base_class, idx_class)をサイトのインラインキャッシュに記録し、多相 ビットを立てる(vm_save_binary_class)。is_func_call = (base slot == 0)、すなわちレシーバ無しのself[i]を 区別する。CRuby と同じく private な#[]に届くようにするため。
runtime::get_index はまず basic_op_redefined_for(base_class, :[]) を見る。
ディスパッチテーブル型の演算子と違ってこのヘルパ自体が実装なので、
_no_opt 版と差し替えるのではなくフラグを直接読む。再定義されていれば
invoke_method。ARRAY_CLASS アームは、Fixnum でも Range でもない添字が
Enumerator::ArithmeticSequence なら aseq.[](self) に委ね(スライスの
規則は AS 側にある)、それ以外は to_int して ArrayInner::get_elem1。
続いて HASH_CLASS / METHOD_CLASS のアームがあり、他は dispatch。
このヘルパの is_func_call が VM のフラグ語ではなく bool なのは、型を
BinaryOpFn に揃えて JIT が多相サイトの残余アームとして再利用できるように
するためである(§3.2)。
runtime::set_index は Array + Fixnum 添字 + []= 未再定義なら明示的な
frozen 検査(FrozenError)の後 Array::set_index を呼び、メソッド探索を
しない。Hash#[]= の並行アームは #1245 で追加された
(hash.md §2)。
2.3 添字計算
value/rvalue/array.rs:
get_array_index(i64) -> Option<usize>: 負なら折り返し、それでも負ならNone。get_elem1: Range 添字(beginless / endless / exclusive、負の折り返し、len == start → []とlen < start → nilの区別)とスカラ。Range 形は 常に新しい配列を確保する(共有しない、§6)。get_elem2:ary[start, len]形。set_index: 末尾より先の正の添字は nil で埋めて伸ばす、範囲外の負はIndexError。set_index2: スライス代入の splice。自己代入は先にスナップショットを取り (is_self)、伸縮方向に応じてcopy_within。
2.4 ExpandArray と case/when
runtime::expand_array はユーザーが上書きした respond_to? を尊重してから
#to_ary を探し(CRuby 準拠)、素の BasicObject はスカラのままにし、
エラーは None で返す。array_teq は splat 配列を === で回し、rescue 版は
「class or module required for rescue clause」を出す。array_any は CRuby の
checkmatch(VM_CHECKMATCH_TYPE_WHEN | ARRAY) と同じく真偽だけで、=== も
ユーザーから見える呼び出しも無い。
3. JIT の経路
3.1 登録されているインライン生成器
builtins/array.rs::init で inline_gen2!(中身は Box::new)により登録:
| メソッド | 生成器 | 出るもの |
|---|---|---|
size / length | array_size | 型付き LIR ArrayLenFixnum(アーキ固有クロージャ無し) |
clone / dup | array_clone / array_dup_inline | array_clone_extern / array_dup_extern への直接 call |
<< | array_shl | guard_frozen + emit_array_shl |
[] / slice | array_index | AsmInst::ArrayIndex |
[]= | array_index_assign / array_slice_assign | AsmInst::ArrayIndexAssign / emit_array_slice_assign |
rotate! | array_rotate_ | guard_frozen + ary_rotate_ への call |
生成器は compile_method_call から、class-version ガードとレシーバクラス
ガードを出した後に呼ばれる。proven: Option<ClassId>(クラス集合ガードや
dispatch アームの陰では None)と arg_class を受け取り、false を返せば
状態と IR が巻き戻される(trial inlining、../inline.md)。
3.2 [] / []= のコンパイル経路
codegen/jitgen/compile/index.rs。index() は次の順で決める:
- 多相サイトでレシーバクラスが抽象状態に無い →
index_dispatch。 - レシーバクラスが証明できない →
Recompile(NotCached)。 fire_index_inline— 前置きを省いたインライン。- それ以外は通常の
call_binary_method。
fire_index_inline は数値演算の直接発火と違ってレシーバクラスガードを
残す。理由は添字の生成器が自分でレシーバを守るとは限らないからで、
array_index の load_array_ty はオブジェクト型(ObjTy::ARRAY)を
証明するだけなので、独自の #[] を持つ Array サブクラスがすり抜ける。
省くのは class-version ガードと compile_method_call の残りの前置きで、
再定義は記録した BOP 依存で捕まえる(§3.7)。メソッドが無い・生成器が
無い・basic_op_assumable でない・可視性で弾かれる・ブロック無しで
キャプチャしうる・ブロックが渡される、のどれかなら断る。ガードと生成器は
巻き戻し可能な 1 単位として出る。
index_dispatch は多相サイトへの 2 腕の答え:
br_class_ne rdi, C -> slow
<C#[] をインライン> ; BOP 依存を記録、class-version ガード無し
br merge
slow: <runtime::get_index> ; どんなレシーバでも正しい。BOP の許可も自分で再確認
merge:
要点は deopt という第 3 の選択肢が無いこと。optcarrot の
@fetch[addr][addr] は Array と Method を交互に取り、ホットループ最大の
deopt 源だった。index_inline_class はインラインキャッシュが持つクラス
ではなく「観測されたうち生成器を持つ最頻クラス」を選ぶ — キャッシュには
たまたま Method が入っていて、それをインライン化すると RAM 読みの全部が
C 呼び出しに残ってしまうからである。両腕とも dst に素の Value を残す
ので join 機構は要らない。index_assign に dispatch 腕は無い。
3.3 [] / []= の機械語(x86-64)
codegen/arch/x86_64/compile/index.rs。
array_index(非負添字の読み):movq rax,[rdi+ARY_CAPA]; cmpq rax,5; jgt heap。inline 腕はcapa(= 長さ)と添字を比べ、jle out_rangeの後movq rax,[rdi + rsi*8 + INLINE]。heap 腕はHEAP_LENで境界検査してからHEAP_PTR経由。out_rangeはNIL_VALUEを返す(範囲外の[]は Ruby でも nil なので deopt しない)。cold ブロックはページ 1 に置く。array_index_assign: 同じ常駐分岐だが、write barrier はrdiを ヒープバッファに付け替える前に出す。範囲外の添字はインラインで raise せずgeneric→set_array_integer_index(正なら伸長、負すぎればIndexError)へ落ち、エラーはhandle_errorに戻る。gen_array_index/gen_array_index_assign(ArrayIndexKindの駆動):U16なら添字はmovlの即値。Fixnumならsarq rsi,1で untag しjs negative。negative 腕(ページ 1)はget_array_lengthを呼んでaddq rsi,rax; jns checked— 一度折り返して検査済み経路に再入する。 まだ負なら generic / out_range。get_array_lengthは分岐無し:cmpq rax,5; cmovgtq rax,[rdi+HEAP_LEN]。
抽象状態側(jitgen/state/index.rs): 添字がコンパイル時の u16 リテラル
なら ArrayIndexKind::U16、そうでなければ load_fixnum(Fixnum ガードを
出す)して Fixnum。load_array_ty は抽象状態がまだ配列型を証明していない
ときだけ GuardArrayTy を出す(ClassInfo::is_array_ty_instance なので
サブクラスも通る — これが §3.2 でクラスガードを残す理由)。
aarch64 は cmov を csel で置き換えた同型で、cold ブロックは同じページに
インライン配置する(b / b.cond が monoasm の第 2 ページに届かないため)。
a[-100000] / a[100000] / 添字 100000 への []= 伸長 / a[-3]= のエラーは
同ファイルのテストが固定している。
3.4 <<
array_shl は guard_frozen を明示的に出す。emit_array_shl のインライン
格納は Array::push を迂回し、push 自体は frozen を見ないので、frozen な
レシーバは JIT を抜けてインタプリタに FrozenError を出させる必要がある —
これはインライン化と同時に直した実バグである
(../optcarrot_opt_profile.md §3.2)。
emit_array_shl(x86_64/compile/builtin.rs): capa <= 5 ⇔ 未あふれ
なので、どちらの常駐でも「2 ロード + 1 ストア」の高速経路になる。
cmpq rax, 5 がinline バッファ満杯の判定のフラグも同時に立てているので
jeq grow はただで付く。満杯のとき — capacity 回に 1 回、償却 — だけ
ary_shl を呼んで再確保させる(そこでバリアも走る)。最後に
emit_write_barrier_rdi(Rsi) と rax = rdi(<< は self を返す)。
3.5 rotate! / スライス代入 / size / clone / dup
rotate!:pos_num <= 1、引数があるときはarg_class == INTEGER_CLASSのときだけ。guard_frozen→ 回転数を untag →ary_rotate_。ary[start, len] = other(array_slice_assign): 動機は optcarrot の@bg_pixels[@scroll_xfine, 8] = ...で、支配的な形は「配列の内側の run を同じ長さの run で置き換える」、つまりサイズが変わらない素のコピー。 ゲートはmay_be_fixnum(start)(意図的に弱い —ary[ivar, 8]の start は 抽象状態が絞っていない ivar から来るので、load_fixnumのガードに任せる。 Float / Range と証明されたスロットだけ断る)と、lenが0..=MAX_INLINE_SLICE(8)のコンパイル時 Fixnum リテラルであること (コピーの展開回数になる)。emit_array_slice_assignは start の untag →js slow、自己代入(cmpq rdi, rdx)→ slow、rdxがちょうどlen要素の Array で、レシーバの run がその内側に収まるときだけlen個のmov対を展開し、emit_write_barrier_bulk_rdi、rax = rdx。それ以外はset_array_sliceが builtin と同じ規則(添字正規化、#to_ary)で処理。size/length:ir.array_len_fixnum(Rax, Rdi)の純 LIR。x86 はcmov+salq/orq、aarch64 はcsel(../lir.mdの 「container length」族)。clone/dup: 証明済みレシーバのときだけ、fpr_save+ 直接 call。cloneは frozen を伝播し、dupは本来のクラスに付け替える意味論を保つ。
3.6 リテラルと生成
- リテラル確保のインライン化(
TraceIr::Array): splat 無しで長さがARRAY_INLINE_CAPA以下([]を含む)なら GC free list からセルを直接 取る(new_array_inline→emit_alloc_cell)。ヘッダはclass<<32 | ObjTy::ARRAY<<16 | 1の即値、var_table = 0、ARY_CAPA = len、 続けてlen個のスロット→フィールド移送。free list が空/ページ境界ならruntime::gen_arrayに落ちる。 - リテラル複製のインライン化(
DeepCopyLit): テンプレートが ivar 無し・len <= 5・全要素が immediate ならRValue::inline_copyable_array。immediate の deep copy は恒等なので語 コピーで済み、要素は GC ポインタでなくテンプレートも不変なので機械語に 焼き込める。emit_deep_copy_litはCellHeader::NewbornOf(template)で ヘッダをNEWBORN_FLAG_MASKで落として写す(Header::newbornと同じ マスクなので乖離しない)。それ以外はvalue_deep_copy呼び出し。 [a, b].min/.maxの融合(CRuby のopt_newarray_send):try_fuse_array_minmax。消費する呼び出しが同一基本ブロックの直後の 命令で、レシーバがそのリテラルで、リテラルが一時スロットに落ち (ローカルは再読される)、splat / 引数 / ブロック無し、Array#min/#maxが builtin に解決する(FuncKind::Builtin { abs_address }をbuiltins::array::min/maxと比較)ときだけ。class-version ガードの後ir.array_min_maxを出し、call 命令をfused_skipで飛ばす。実行時のopt_array_minmaxは要素をスタックスロット上で比較し、Array を 作らない。同点は先の要素、比較不能は raise、と builtin と同じ。ExpandArrayの高速経路:srcが既にlen要素以上の Array ならlen回の移送で済ませる(respond_to?/#to_aryの dispatch 無し、 nil 埋め無し、raise 無し)。rest_posあり・len == 0・len > MAX_INLINE_EXPAND(8)は断る。成功時rax = 1(expand_arrayは null 返しでエラーを伝えるため)。呼び出し元が続けて 出す実行時呼び出しがそのまま遅い経路になる。
3.7 BOP(基本演算)の再定義
globals/store/basic_op.rs の Array エントリは 3 つだけ:
(ARRAY_CLASS, "[]"), (HASH_CLASS, "[]"), (ARRAY_CLASS, "[]=")。
runtime::get_index / set_index は素の Rust ヘルパなので、差し替えられる
のではなく BasicOpTable::redefined を直接見る。
Array#size / #length は BOP ではない(CRuby には BOP_SIZE /
BOP_LENGTH がある)。そのインライン生成器は compile_method_call から
発火し、そこで既に class-version ガードが出ているので再定義はそちらで
捕まる。JIT 側の許可は basic_op_assumable(追跡対象の組・グローバルに
未再定義・コンパイル中スコープで refine されていない)で、record_bop_dep
が (class, op) を記録し、set_bop_redefine がそれを読んで依存本体だけを
evict する。直接発火経路には class-version ガードが無く、健全性は記録した
依存だけに拠る。Hash#[]= が意図的に表に無いのはこのためで、通常の
class-version ガード付き経路に残す(index_hash_assign_redefinition テスト)。
効果は ../bop_redefinition.md(Array#[] 再定義
時 0.30 → 0.030 → 0.009 s)。
3.8 Array.new と Class#new
Array.new は native では定義されていない。Ruby の Class#new トランポリン
(o = __builtin_allocate__; o.__builtin_initialize__(...))を継いで、Ruby
で書かれた Array#initialize(builtins/array.rb、位置引数 0〜2、rest /
キーワード無し)にインライン経路で届く。以前の __send__ による override は
rest + kwrest 登録だったため JIT の転送高速経路を全部外し、汎用の引数再解析
と rest Array の即時実体化を毎回払っていた(約 8 倍遅い)。さらに public な
allocate を dispatch していたのでユーザーの上書きを尊重してしまい、CRuby の
Array.new とは違っていた。
Array#initialize の重い脚は native の __init_fill / __init_from /
__size_to_int。#to_ary の探索は引数が Integer でないときに限る(CRuby の
!FIXNUM_P)。ブロック形は要素を逐次 push して break 時の部分内容を
CRuby と一致させ、yield はユーザーの Array.new { } サイトのリテラル
ブロックに対して特殊化される(resolve_given_block が Class#new の転送
連鎖を辿る)。
アロケータは array_alloc_func で default_alloc_func ではないため、
emit_class_allocate の InlineAlloc::Object 列にはならず、array_alloc_func
への直接 call になる。クラス記録により後続の __builtin_initialize__ は
単相になる。
rest 引数 Array の遅延実体化(D1)は
../arg_forwarding_jit.md §3.4 にある。
WriteBack::forward_rest が (dst, src, len) を持ち、deopt 時に
runtime::create_array で作る。リテラルの write back が forward_rest より
先なのは、実体化が確保を伴い、その時点でフレームが GC 整合でなければならない
からである(../jit_invariants.md)。
4. Ruby で書かれているメソッド
builtins/array.rb に initialize / each / reverse_each /
each_with_index / each_index / map / map! / bsearch /
bsearch_index / dig / tally / filter_map / cycle / combination /
permutation / repeated_* / at / to_ary / deconstruct / drop_while /
fetch_values / rindex / assoc / rassoc / values_at / __zip_pull が
ある。
理由はファイル自身が書いている: ホットな Array.new サイトが JIT の
特殊化された引数束縛を得られ、ブロック形では要素ごとの yield がインライン
展開される(native 版は要素ごとに native→Ruby のブロック呼び出しを払って
いた)。each / map の Rust 版は builtins/array.rs に残っているが登録が
コメントアウトされていて、while i < self.size を Array#size / Array#[]
のインラインで回す Ruby 版が勝つ。
逆に Array#sum は意図的に Ruby で開き直さない(Rust の Fixnum 高速
経路を迂回してしまうため)。sample / shuffle / shuffle! は random:
の解決と RAND_UPTO を共有するため Rust のまま。
このトレードオフは ../bop_redefinition.md に記録
されている: Integer#< を再定義すると monoruby では Array#map(の
while i < size)が壊れるが、C 実装の CRuby は影響を受けない。
Array#size を再定義すると Kernel#p の内部が ArgumentError になるのも
同種で、BOP フックの欠落ではなく「組み込みが Ruby で書かれていることの露出」
である。
5. 個別メソッドの注記
Array#sum: ブロック無し・整数の初期値・全要素 Fixnum ならchecked_addの高速経路(約 30 倍)、外れたら元のsumで汎用ループへ。 Float が続くあいだは Kahan–Neumaier 補償加算(CRuby と同じ精度)。 補償項は全 Float 経路を抜けるときと最後に畳み、非有限になったら更新 しない(Inf - Infの偽 NaN を避ける)。配列はイテレータではなく毎回 添字で読み直すので、ブロックがレシーバを伸ばしても CRuby と同じく耐える。sort/sort!/min/max/minmax(executor/op/sort.rs): 20 要素以下は挿入ソート(マージバッファ無し)。マージ中のバッファは 一方の run の唯一の参照を持ち、比較子は任意の Ruby を走らせるので、 Rust のVec<Value>では GC に見えない — 実際にあったバグで、呼び出し側 は nil 埋めのArrayを temp スタックに置いてバッファにする。全 Fixnum / 全 String / 全(非 NaN)Float のスライスはhomogeneous_ordで Rust の中だけでソートし、クラスごとに別のsort_unstable_by*を呼ぶ (キー抽出が単相化して inline される)。Ruby の sort は安定でないので unstable で良い。許可はBASIC_OP_DEFS+cmp_redefined。cmpintはrb_cmpintに従い、Bignum は符号を直接読み、非 Integer は<=> 0でなく> 0と< 0を尋ねる。min(n)/max(n)は部分選択ではなく rooted な コピーの 1 回ソートを使う。hash/eql?: 外側再帰はexec_recursive_outer_unregisteredで 畳み、循環構造はすべて同じ番兵にハッシュする(rec.hash == [{x: rec}].hashがeql?と整合する)。Store::has_builtin_container_hashが「この Array / Hash はまだ builtin の:hashに解決するか」を class version で キャッシュしていて、真ならValue::ruby_hashが構造ダイジェストを native で計算する。singleton / mock / monkey-patch があれば dispatch。include?/index/count/delete(EqSearch、executor/op.rs): needle を 1 回準備してから走査する。Integer / Symbol / nil / true / false の needle なら同クラスでビットが違う要素は==無しで不一致、String は 内容で決まる。basic_op_redefined_for(class, :==)でゲート。dispatch に 達する要素には(ClassId, class_version)キーの単相キャッシュcached_eqがあり、独自==を持たないクラスはIdentityアームで 呼び出し自体を省く。以前は引数に==を尋ねて hoist していて、 レシーバが違うバグだった。-/&/intersection/uniq:RubySetを作る。insertが Ruby を走らせうるので、格納ポインタを temp スタックで root する。flatten: object id のseenで循環検出。要素の変換探索try_convert_to_arrayはrb_check_funcallの 4 段(ユーザーのrespond_to?→to_ary→respond_to_missing?→method_missing)を ruby/spec どおりに踏む。flat_map: 蓄積先は Rust のVecではなく rooted な Ruby Array (vm.temp_array_*)。invoke_blockはすべて safepoint だからで、 gc-stress の optcarrot が落ちた事例が../gc.mdにある。pack:string/pack.rsの CRubypack.c忠実移植で、実行時の 高速経路は特に無い。shuffle/sample: CRuby のRAND_UPTO(rb_random_ulong_limited) をそのまま使うので、srand(n)後の列が1 回の draw 単位で CRuby と 一致する。
6. CRuby との実装差異
| 項目 | CRuby | monoruby |
|---|---|---|
| 埋め込み/共有 | RARRAY_EMBED(埋め込み)と ary_make_shared による共有配列 + copy-on-write の 2 段 | 共有配列は無い。SmallVec<[Value; 5]> の 1 機構: 5 要素まで 48 バイトのペイロードに直置き、超えたらヒープ。get_elem1 / get_elem2 の Range 形は毎回新しい配列を確保する。String には隠れ frozen root による共有部分文字列があるので(string.md)、Array に無いのは意図的な選択 |
| 基本演算の追跡 | BOP_SIZE / BOP_LENGTH / BOP_AREF / BOP_ASET … | Array#[] / Array#[]= / Hash#[] のみ。size は class-version ガードで守る。Hash#[]= は意図的に外す |
| 組み込みの実装言語 | C(Integer#< を再定義しても Array#map は無傷) | each / map / initialize などが Ruby。Integer#< や Array#size の再定義が組み込みの内部に及ぶ(意図したトレードオフ、§4) |
Array.new と allocate | ユーザー定義の self.allocate を迂回 | 同じ。private な __builtin_allocate__ 経由で実現 |
範囲外の [] | nil | JIT コード内で nil(deopt 無し)。負添字も生成コードで折り返す。範囲外の書き込みは必ず高速経路を抜ける(伸長・IndexError のため) |
ArithmeticSequence 添字 | rb_ary_aref1 内で処理 | aseq.[](self) に委譲(3 箇所とも)。特異クラス付きの AS(Range#% 由来)も ObjTy で判定するので届く |
[]= の Range 開始 | 折り返しても負なら RangeError | 同じ。素の負 Integer 添字は IndexError |
Array#<=> | 最初に 0 でなかった要素比較の結果をそのまま返す | 同じ(nil / ±1 / String もそのまま) |
| リテラル | duparray / newarray、opt_newarray_send | Literal(テンプレート deep copy)。要素が全部 immediate で 5 以下なら複製を機械語に焼く。[a,b].min/max 融合あり |
Array.new の #to_ary 探索 | !FIXNUM_P で判定。Bignum は探索する | Integer クラスが 1 つなので Bignum も探索を飛ばし、__init_fill のサイズ検査が CRuby の NUM2LONG + 「array size too big」の順で拒む。MAX_ARRAY_SIZE = 1 << 30 |
zip の引き出し | take_items(each + break) | 同じ。Enumerator#next の fiber は使わないので、引数の each からの StopIteration は呼び出し元に届く |
| 要素格納のバリア | RARRAY_ASET が RB_OBJ_WRITE | Array ラッパが ArrayInner の mutator を隠して全経路をバリア。JIT は emit_write_barrier_rdi / _bulk |
7. テストと関連文書
| 場所 | 内容 |
|---|---|
codegen/arch/x86_64/compile/index.rs のテスト | 添字境界(±100000、伸長、a[-3]= のエラー) |
codegen/jitgen/compile/index.rs のテスト | index_redefinition_evicts / index_polymorphic_array_and_method / index_array_subclass_override / index_private_receiver / index_hash_assign_redefinition |
builtins/array.rs の array_literal_minmax_fusion | [a, b].min 融合 |
../optcarrot_opt_profile.md | << / rotate! / expand_array / スライス代入の計測と経緯 |
../bop_redefinition.md | BOP の差分掃討(48 ケース)、Ruby 実装組み込みの露出 |
../lir.md | GuardArrayTy / GuardFrozen / ArrayLenFixnum の LIR |
../arg_forwarding_jit.md | rest Array の遅延実体化(D1) |
../gc.md | バリア、temp スタックの root、flat_map の事例 |
../progress_2025-2026.md | Array#[] / #[]= のインライン化(#269)などの変更履歴 |
Hash の実装と最適化
Ruby プログラムは Hash を「オブジェクトのフィールド袋」として使うので、
[] / []= / key? / fetch はアプリケーションのもっとも内側のループに
現れる。yjit-bench の erubi では実行時間の 29 %、rack で 18 %、
activerecord で 13 % が Hash 参照だった
(yjit_bench_slow_investigation_2026-09.md §5.2)。
この文書は、現在の Hash がどういう表現とどういう探索経路を持っているかを
まとめ、そこに入れた最適化を計測とともに記録し、残っているコストと次の
候補を優先順位付きで並べる。個々の API の意味論ではなく「1 回の参照に何が
起きるか」に焦点を当てる。CRuby の ar_table / st_table と実装が
どう違うかは §6 にまとめた。
runtime_optimization/ の他の章(Array / String /
Regexp)と共通の前提は README.md にある。
1. 3 つの表現
Hash の表現は RValue ヘッダの型別メタデータバイト(Metadata::ty_flags)に
入っている。48 バイトのペイロードを表現の判別に使わないので、小さい Hash は
ペイロード全部をデータに使える。
| ビット | 意味 |
|---|---|
| 0-2 | 表現: 0..=3 = その数のペアを inline 保持、7 = boxed |
| 3 | ruby2_keywords フラグ |
| 4-5 | inline hash の反復深度(飽和カウンタ。boxed は BoxedHash::iter_lev) |
| 6 | inline hash が compare_by_identity |
ゼロバイト(Header::new の既定値)がそのまま空の inline hash として妥当で、
dup / clone(Header::newborn)はこのバイトを保存するので、表現はヘッダと
一緒に移動する(sanitize_dup_flags が表現と identity ビットだけを残し、
ruby2_keywords と反復深度は落とす)。
表現は「3 つ」と言っているが、identity 比較の有無を掛けると形は 4 つある:
inline(eql? 比較)、inline + IDENT_BIT、boxed の HashContent::Map、
boxed の HashContent::IdentMap。boxed 側は Box<RubyMap<Option<Value>, Value>>
と Box<RubyMap<Option<IdentKey>, Value>> という別の型のマップで、
#[repr(C, usize)] の判別子がオフセット 0 にあるので機械語からも見分けられる。
1.1 inline 表現(≤ 3 ペア)
HashBody::inline に (key, value) を 3 組、セルの中に直接持つ。ヒープ確保
ゼロ、探索は 3 要素の線形走査。is_inline_key(rvalue/hash.rs)が許すキーは:
- packed immediate(Fixnum / Symbol / nil / true / false / flonum)— 同一性がそのまま内容なので、比較はビット比較で足り、プローブ時に再計算した ダイジェストが挿入時のものと食い違うことがない。
- frozen String — String の
eql?はエンコーディング規則込みのバイト比較で (再定義されたString#eql?/#hashはどちらの表現でも参照しない)、frozen なら探索中に内容が変わらない。
リテラルのキーも h["k"] = v の格納キー(Value::frozen_hash_key が
コピーして freeze する)も frozen なので、String キーの小さな Hash リテラルは
boxed map を作らない。
compare_by_identity な inline hash は id 走査だけなので、任意のキー(可変な
ヒープオブジェクトを含む)を保持できる(IDENT_BIT)。
4 ペア目、inline に置けないヒープキー、デフォルト値/デフォルト proc の設定、
反復中の delete / shift(tombstone を置く場所が inline には無い)のいずれかが
来ると promote が boxed へ移す。移送は native の再挿入で Ruby コードは
走らず、inline の反復深度ビットは iter_lev に引き継がれる。逆方向は
clear だけで、デフォルトが無ければ boxed の格納を手放して inline に戻る
(identity ビットは保つ)。
1.2 boxed 表現
BoxedHash は Box<RubyMap<Option<Value>, Value>>(compare_by_identity なら
Option<IdentKey> キーの別インスタンス)とデフォルト値/デフォルト proc、
反復深度、tombstone 数を持つ。
rubymap は順序保持マップ(hashbrown のインデックステーブル + エントリ Vec)
で、Ruby の挿入順序保持セマンティクスをそのまま表現する。反復中の delete は
エントリを tombstone(キーを None)にして位置を保ち、Option<Value> の
niche によりキーワードがゼロなら死んだエントリ、という判定が機械語からも
できる。反復が終わった後の最初の変更操作が compact_if_dirty で詰める。
1.3 探索経路
JIT: Hash#[] ──inline gen (hash_index)──► hashindex(vm, globals, recv, key)
│
VM: Hash#[] ──builtin index───────────────────► Hashmap::index
│ (ミス時のみ default / default_proc)
▼
HashRef::get
┌─────────┴─────────┐
inline 走査 boxed プローブ
┌────────┴────────┐
packed_digest string_digest
(vm 不要) + string_key_eq(vm 不要)
└────────┬────────┘
IndexMapCore::get_index_of_prehashed*
要点は prehashed probe: packed キーと String キーは Ruby コードを一切
起動せずにダイジェストと eql? を決められるので、vm / globals を触らない
専用経路を通る。汎用経路(RubyMap::hash)とバケットが一致することは
packed_digest / string_digest のドキュメントコメントが不変条件として
書いている。
1.4 機械語が直接歩く部分
Hash#size / #__key_at / #__value_at / #__entry_count / #__live_at /
#__get_or_key / #default / #default= / #compare_by_identity? は JIT が
表現を直接歩く機械語として出る。焼き込むオフセットは手計算せず
HASH_INLINE_PAIRS_OFFSET などのレイアウト定数(offset_of! 由来)と
rubymap::EntriesLayout のプローブから取る。Bucket<K, V> は repr(Rust) で
キー型の niche によってフィールドが並び替わる(Option<Value> では
value+0 / hash+8 / key+16)ため、entries_layout() は本物の Vec を作って
実測し、rubymap 側の raw_probe / layout_matches_the_safe_api テストが
その値で歩いた結果が安全な API と一致することを固定している。
3 ペア以下の Hash リテラルは、キーが全部 packed immediate で互いに異なる
場合に限り JIT がセルを bump 確保してヘッダとペアを直接書き込む
(new_hash_inline → emit_alloc_cell)。frozen String キーは inline 表現には
置けるが、この bump 確保の対象ではなく、gen_hash の実行時呼び出しを経て
inline 表現に落ち着く。
Hash#each / each_key / each_value はこれらのプリミティブの上に Ruby で
書かれている(builtins/hash.rb)ので、ホットな呼び出しサイトではメソッドと
ブロックの両方がインライン展開される(§1.6)。
1.5 2 つのハッシュ関数
Hash には用途の違う 2 つのハッシュ関数が共存していて、これが §4.1 の前提に なる。
| 用途 | 実装 | 場所 |
|---|---|---|
| バケッティング(マップ内部のダイジェスト) | seeded な wyhash 系 multiply-fold(RubyHasher、施策 5) | rubymap/src/hasher.rs |
Ruby から見える Object#hash の値 | std の RandomState = SipHash-1-3(HASH_STATE、seeded_hasher()) | value.rs |
RubyHasher は長さを最初に混ぜ、16 バイト単位のループの後に重なりを許した
4 / 8 バイトの末尾読みをして最後にもう 1 回 fold する。シードはプロセスで 1 回
RandomState から引く(OnceLock<u64>)ので、全マップと全 JIT 呼び出しサイトが
同じシードを共有する。ハッシュ品質は hasher.rs のテストが固定している:
長さごとの分離、1 ビットの変化が上位 7 ビット(hashbrown の control byte)
まで拡散すること、等間隔の整数キーが散ること。
Ruby 側の #hash は Value::ruby_hash / ruby_hash_packed が作る。nil /
true / false / Symbol はビットそのもの、String は RStringInner::hash(生
バイト列、エンコーディング非依存、再定義された String#hash は見ない)、
Array / Hash は組み込みの hash が再定義されていない限り native の構造
ハッシュ、それ以外は #hash を dispatch して #to_int で整数化する。
Fixnum / Float だけは内側で SipHash を丸ごと 1 回回して Integer#hash /
Float#hash の値を作り、その結果をマップ側のハッシャに流す。これは
Array#hash が要素を混ぜるときに Ruby レベルの #hash と一致させるためで、
バケッティングには要らない二重ハッシュである(§4.1)。
異なるプロセスで #hash が変わること(CVE-2011-4815 対策)は
tests/hash_seed.rs が本物のバイナリを 2 回起動して確認している。
1.6 Ruby で書かれた走査と、そのためのプリミティブ
builtins/hash.rb に each 系が Ruby で書かれている理由は、JIT が
インライン展開するのが FuncKind::ISeq の呼び出し先だけだからである。
Rust の builtin が要素ごとにブロックを起動すると 1 要素ごとにフルの
ブロック呼び出しを払うが、Ruby の each なら h.each { .. } のホットな
サイトでメソッド本体とブロックの両方が yield の位置に展開される。
ブロック引数を &block で受けると BlockArg バイトコードになって
メソッド全体が特殊化不能になり、フレームもヒープに移る
(Iseq::has_block_arg)ので、必ず yield で書く。
そのために Rust 側が出しているプリミティブ(大半に機械語インライナがある):
| プリミティブ | 役割 |
|---|---|
__entry_count | エントリ配列の生の長さ(tombstone 込み) |
__live_at(i) | 位置 i が生きているか |
__key_at(i) / __value_at(i) | O(1) の位置参照。範囲外・tombstone は nil(エラー経路が無いので while ループに境界機構が要らない) |
__set_value_at(i, v) | 位置指定の値上書き。ダイジェストもプローブも走らない |
__iter_begin / __iter_end(g) | 反復参照の取得・返却(inline の深度は飽和するので戻り値を必ず返す) |
__dup_table | エントリと identity モードだけを複製。デフォルトは持たず、常に素の Hash(CRuby の hash_dup_with_compare_by_id) |
__new_hash_with_capacity(n) | 容量指定の空 Hash |
__get_or_key(k) | key?(k) ? self[k] : k を 1 プローブで(CRuby の rb_hash_lookup2(map, k, k))。デフォルトは見ない |
__pairs | [[k, v], ...] のスナップショット |
__block_splits_pair? | 呼び出し元ブロックの形(` |
この上に乗っている走査の要点:
each/each_pair/each_key/each_valueはguard = __iter_begin; ... while i < __entry_count ... if __live_at(i) ... ensure __iter_end(guard)の形。transform_valuesは__dup_table+__set_value_atで、キーは変わらない ので一度も再ハッシュもプローブもしない。切り離したコピーを歩くので ガードも不要で、全位置が生きている。transform_values!は同じ位置上書きをselfに対してガード付きで行う。transform_keysはブロック無しtransform_keys(map)にペアごとのブロック 判定が乗らないよう 3 本のループに分け、__get_or_keyを使う。to_hにブロックが付いたときはeachを経由せず位置を直接歩き、[k, v]配列を作らない。結果は__new_hash_with_capacityで事前確保。map/collectはmethod(:each).owner == ::Hashを確認したうえで__block_splits_pair?をループの外で 1 回だけ判定する(|*vs|の rest 引数は specialized-yield の対象外になるため)。digを Rust に移した実験は計測で退行したので Ruby に戻してある (hash.rbのコメントに記録)。
2. 入れた最適化
効果は同じ計測機で交互ラウンドの中央値の最小値で比較している(単発は ±10 % 揺れる)。「純ルックアップ」は同じループからルックアップだけ抜いた時間を 差し引いた値。
| # | 施策 | 変更箇所 | 効果 |
|---|---|---|---|
| 1 | ≤3 ペアの packed キー Hash を inline 表現に(ヒープ確保ゼロ、JIT がセルを直接書く) | rvalue/hash.rs、JIT の literal 経路 | Symbol キーリテラル生成が boxed の 1/2 以下 |
| 2 | 定数 Hash リテラルをテンプレート化し、評価ごとに複製(#1232) | bytecodegen/expression.rs::from_literal_pairs | 評価ごとの挿入ループが消える |
| 3 | 汎用 []=(VM と JIT の多相残余)に Hash の直接経路(#1245) | codegen/runtime.rs::set_index | rack −8 %、graphql −13 %、activerecord −8 %(他の 3 施策込み) |
| 4 | frozen String キーを inline 表現に許可(#1246) | rvalue/hash.rs::is_inline_key | {"content-type" => "text/plain"} 生成 134 → 57 ns(YJIT 60)、h["k"] 50 → 23 ns、h["k"] = v 40 → 22 ns。rack −7 %、activerecord −13.5 % |
| 5 | バケット用ハッシュを SipHash-1-3 から seeded ミキサーへ(本ブランチ) | rubymap/src/hasher.rs | 下表。erubi −12.4 %、graphql −4.1 % |
施策 5 の背景: RubyMap は std の RandomState(SipHash-1-3)を既定の
ハッシャに継いでいて、perf で見るとルックアップ 1 回の 1/3 がそこだった
(String キーで string_digest 18.7 % + SipHasher13::write 13.9 %、Symbol キーで
packed_digest 24.6 % + 11.7 %)。CRuby の st_hash は seeded な非暗号ミキサー
なので、同じトレードを取った。プロセス単位の乱数シード(RandomState から
1 度引くので新規依存なし)+ wyhash 系の multiply-fold。シードを秘匿することが
hash-flooding を抑える根拠で、混ぜ方自体は乗算 2 回。Ruby から見える
Object#hash は従来どおり HASH_STATE 側なので変わらない。
純ルックアップのコスト(1 回、ns。18 エントリの Hash)
| キー | 施策 4 まで | 施策 5 後 | CRuby 4.0.6 + YJIT |
|---|---|---|---|
| String 4 バイト | 42.8 | 33.7 | 17.5 |
| String 19 バイト | 49.1 | 37.7 | 18.5 |
| String ミス | 42.0 | 30.7 | 16.7 |
| Symbol | 39.0 | 25.7 | 16.1 |
| Integer | 52.1 | 39.5 | 16.4 |
ハッシュ品質のサニティチェック: 20 万エントリで 4096 刻みの整数キー(弱い ミキサーが破綻する典型ケース)は 94.2 → 88.8 ns と悪化しない。
3. 残っているコスト
施策 5 後の h["name"] / h[:name] ループの perf セルフ時間:
| String キー | Symbol キー | |
|---|---|---|
| IndexMap / hashbrown の probe | 31.6 % | 32.6 % |
HashRef::get(2 インスタンス化の合計) | 25.0 % | 19.7 % |
Hashmap::index | 10.4 % | 13.6 % |
ダイジェスト(RubyHasher) | 7.8 % | 8.9 % |
hashindex builtin | 7.2 % | 6.3 % |
memcmp(キー比較) | 7.7 % | — |
ハッシュは 33 % → 8 % まで落ち、いまの支配項は probe と 4 段のディスパッチ
(hashindex → Hashmap::index → HashRef::get → IndexMapCore)である。
4. 今後のアイデア(効果の見込み順)
4.1 Integer / Float キーの二重ハッシュを外す(小・確度高)
Value::ruby_hash_packed の Fixnum / Float アームは、Integer#hash /
Float#hash が返す値を作るために内側で seeded_hasher()(std の
DefaultHasher = SipHash)を丸ごと 1 回回し、その結果を外側のマップハッシャに
流している。これは Array#hash / Hash#hash が要素を混ぜるときに Ruby レベルの
#hash 結果と一致させるための要請で、バケッティングには要らない。上表で
Integer キーだけ Symbol キーより 14 ns 遅い(39.5 vs 25.7)のがそのコスト。
構造ハッシュ用(Ruby から見える #hash 値)とバケッティング用を分ければ、
Integer キーの参照が Symbol キーと同じところまで来るはず。
4.2 probe を JIT でインライン展開する(大・最大の残り。段階 1〜3 実装済み、2026-09-04/05)
いまは Hash#[] のインライン生成が hashindex への直接呼び出しまでしか
やらない(メソッドフレームは省くが、そこから先は Rust)。受信側が
Hash ちょうどで boxed 表現だと分かっている呼び出しサイトなら、
ダイジェスト → インデックステーブル引き → エントリ比較を機械語で出せる。
上の内訳の probe 32 % + ディスパッチ 42 % のかなりの部分が対象で、YJIT との
残差(33.7 vs 17.5 ns)を埋める本命。gen_hash_entry_at が既に
rubymap::EntriesLayout からエントリ配列を歩いているので、必要なレイアウト
知識は揃っている。
段階分け
IndexMapCore には 2 つの動作領域がある。≤ 8 エントリ(AR_MAX、CRuby の
RHASH_AR_TABLE_MAX_SIZE と同じ)は linear、indices を作らず entries を
線形走査して entry.hash == hash の一致だけキー比較する。それ以上は hashbrown
の probe(上位 7 ビットで control byte を 16 個ずつ SIMD 比較 → 添字 →
エントリ比較)。Bucket { hash, key, value } はハッシュ値を格納しているので、
線形走査は「64 ビット比較 × N、当たったときだけキー比較」で済む。
| 段階 | キー | 領域 | 状態 |
|---|---|---|---|
| 1 | packed のうちビットがそのままミキサーに入るもの(Symbol / nil / true / false) | 線形(≤ 8) | 実装済み AsmInst::HashProbe(旧 HashProbePacked) |
| 2 | 同上 | 索引(> 8)— hashbrown の group probe | 実装済み(段階 3 と同時) |
| 3 | String — バイト列ダイジェスト + memcmp + plain_string 判定 | 両方 | 実装済み(erubi の 18 エントリ・String キーはここ) |
Integer キーは 4.1 の二重ハッシュ(SipHash を内側で 1 回回す)が解けるまで 対象外。葉ヘルパで正しく計算はできるが、ヘルパ自体が現状のルックアップの 大半のコストになる。
段階 1 の設計判断(2026-09-04)
- ダイジェストは葉ヘルパ
packed_digest_c、probe は機械語。 fold は 64×64→128 の乗算だが、この JIT が使う x86-64 アセンブラ(monoasm)に 1 オペランドmulが無い(div/idivはあり、imulは 2 オペランドの 下位 64 ビットのみ)。aarch64 にはumulhがある。monoasm に足したら ヘルパをその命令列に置き換えるだけで、他は変わらない。 - 形の不一致は deopt ではなく builtin 呼び出し。 inline 表現・identity
マップ・索引領域はすべて
hashindexへの合流で、exit しない。最初は deopt に していたが、この deopt は再コンパイルを起こさないので、大きな Symbol キー Hash のサイトが毎回 deoptすることになる(4.5 の ic ゲートで踏んだのと 同型)。builtin 呼び出しなら現状と同コストで退行しない。 - 線形領域の miss は in-line で
nil。 走査を尽くしたらOption<Box<HashDefault>>(null = default 値も proc も無し)を 1 ロード見て、 無ければ nil。builtin に落とすとダイジェストと走査をもう一度やり直すので、 最初の実装では miss が 16 → 30 ns に退行していた。 EntriesLayoutにhash_offset/linear_offsetを追加。Bucketは niche を持つキー型で並び替わる(Option<Value>では value+0 / hash+8 / key+16)ので、既存どおり実測で取る。
段階 1 の効果(6 エントリ・Symbol キー、純ルックアップ、交互 4 ラウンド)
| 従来 | 段階 1 | |
|---|---|---|
| hit(先頭) | 16.3–18.1 | 6.3–9.7 |
| hit(末尾) | 15.3–19.3 | 8.2–11.0 |
| miss | 16.5–20.4 | 7.4–10.6 |
| 18 エントリ Symbol(索引領域、fallback) | 18.7–26.0 | 15.5–21.2(退行なし) |
hit 経路の call は packed_digest_c の 1 つだけになり、hashindex の call は
miss 側にしか残らない(emit-asm で確認)。tests/hash_probe_jit.rs が
hit / miss / default 値 / default proc / nil・true・false キー / tombstone /
索引領域・inline 表現・identity マップへの委譲 / クラスガード / 線形↔索引の
境界を跨ぐ成長 / 生きた状態の読み取りを CRuby と突き合わせる。
段階 2・3 の設計判断(2026-09-05)
- 索引領域は hashbrown の
find_innerを SIMD 無しで写す。 monoasm には SIMD が無いので、16 バイトの control group を 64 ビット語 2 つとして読み、 hashbrown 自身の generic backend と同じ SWAR ゼロバイト判定 ((x - 0x01..) & !x & 0x80..、x = word ^ tag×0x01..)で候補ビットを出す。 真の一致より後ろのバイトに偽陽性が出うるが、候補は必ずエントリの格納 ダイジェスト(64 ビット全部)で検証するので無害。候補の添字は control バイト列の下にあるバケット配列(bucket i はctrl - 8(i+1))から 読む。2 語とも見終わってから EMPTY(0xFF— bit 7 と 6 が立つのでw & (w << 1) & 0x80..)の有無で打ち切り、無ければ三角数列で次の group へ。EMPTY を見た瞬間に打ち切ってはいけない:削除で EMPTY に戻った スロットより後ろに、それ以前に挿入された要素が同じ group 内に居られる (hashbrown は group 内の一致を全部見てから EMPTY を判定する)。rubymapのraw_probeテストがこのアルゴリズムを生の offset だけで なぞり、find_innerと同じ答えになることを固定している。 - レイアウトは
offset_of!で取る。EntriesLayoutにindices_ctrl_offset/indices_mask_offset/group_widthを追加。 hashbrown(vendored)のRawTable/HashTableにctrl_offset()/bucket_mask_offset()/GROUP_WIDTHを生やした。group 幅が 16 で ない構成では索引領域を builtin に渡す(静的分岐)。 - String キーは葉ヘルパ 2 つ + 機械語 probe。
string_digest_c(バイト 列の wyhash、挿入時と同じ)とstring_key_eq_c(string_key_eqそのもの :identity → STRING×STRING バイト比較)。同一オブジェクト(frozen リテラル)なら eq 呼び出しを省く。格納ダイジェストが一致したのに eq が 偽なら(64 ビット衝突か tombstone か異種キー)miss→ builtin。こうする と eq 呼び出しの前後で probe のループ状態を退避する必要が無く、rdx/rcx と entry ポインタだけ push すれば済む。 - サブクラスは class guard が弾く。
guard_class(STRING_CLASS)はクラス 一致なので、eql?を dispatch すべき String サブクラス(#1258)は probe に入らず deopt する。 - identity マップは probe しない。
HashContentの判別子(0 = Map, 1 = IdentMap)を見て IdentMap なら builtin へ。段階 1 は判別子を見て いなかったが、Symbol はIdentKeyのダイジェスト(id())と packed ダイジェストが一致するので偶然正しかった。String は内容ダイジェスト ≠ identity ダイジェストで、自分自身すら見つからず nil を返していた (compare_by_identity+ String キーで検出、テスト追加)。 - キークラスが混在するサイトは段階 1 と同じ性質。
h[k]の k の クラスは抽象状態から取り、guard の失敗は plain deopt(再コンパイル無し)。 同じサイトに String と Symbol が交互に来ると、後から来たクラスは毎回 deopt して interpreter で実行される(計測では 12 → 24 ns)。段階 1 の Symbol guard も同じ。receiver 側のBecamePolymorphic再コンパイルは VM が立てる POLY ビットで「次は generic に」と判断できるが、引数クラスには その仕組みが無く、再コンパイルしても同じ guard が出るだけなので保留。
補足: どこから発火し、いつ機械語を諦めるか
Hash#[]のインライン生成hash_indexはfire_index_inlineから呼ばれ、 受信側クラスがちょうどHashのときだけ probe を出す。理由は builtin のHash#[]がミス時にdefaultメソッドを dispatch する(サブクラスや 特異メソッドで上書きできる)のに対し、hashindexは格納されたデフォルトを 直接読むからで、サブクラスは通常の class-version ガード付き呼び出しに残す。Hash#[]はBASIC_OP_DEFSに入っているので class-version ガードは省き、record_bop_depで再定義を捕まえる。Hash#[]=は意図的に BOP に入って いないので、hash_index_assignは通常経路のまま(index_hash_assign_redefinitionテストが固定)。- 受信側クラスを抽象状態が確定できない多相サイトでは
index_dispatchが 2 腕のディスパッチを出す:BrClassNeで Hash なら probe、そうでなければruntime::get_index。外れたクラスは deopt ではなく C 呼び出しになる。 - キーのクラスはサイトのインラインキャッシュ/抽象状態から取り、
Symbol / nil / true / false は
packed_digest_c、String はstring_digest_cstring_key_eq_c、それ以外(Integer を含む)は probe を出さずhashindexの直接呼び出しに落ちる。
hash_entries_layout()がNone(2 つのマップ型でレイアウトが一致しない 構成)か、hashbrown の group 幅が 16 でない構成では、索引領域は静的にmissへ飛ばす。いずれも性能上の後退で、正しさには関わらない。
段階 2・3 の効果(純ルックアップ、交互 2 ラウンド、ns)
| 段階 1 | 段階 3 | CRuby+YJIT | |
|---|---|---|---|
| String 6 エントリ hit(先頭 / 末尾) | 12.4–13.7 / 14.6–16.2 | 10.7–11.1 / 14.4–14.6 | 33.1 / 34.6 |
| String 6 エントリ miss | 13.5–15.6 | 10.9 | 27.9 |
| String 18 エントリ(erubi 形)hit | 14.4–17.1 | 11.7–12.3 | 26.4–40.5 |
| String 18 エントリ miss | 12.6–17.1 | 8.1–8.3 | 18.8 |
| String 100 エントリ hit / miss | 14.7–17.1 / 12.7–14.6 | 11.6–12.1 / 8.0–10.7 | 25.3 / 19.0 |
| Symbol 18 / 100 / 1000 エントリ hit | 10.9–13.3 | 7.5–8.6 | 10.6 |
| Symbol 18 / 100 / 1000 エントリ miss | 11.1–15.0 | 8.8–11.2 | 11.2 |
String の hit は digest 呼び出し(wyhash)と eq 呼び出し(memcmp)が残るので
Symbol ほどは縮まないが、miss は in-line で nil を返せるので 8 ns 台。
yjit-bench の erubi(benchmark_mono.rb、warmup 10 + 30 反復、交互 2 ラウンド)
は 215–221 ms → 192–206 ms(−7〜−10 %)。
tests/hash_probe_jit.rs に String キー(線形 / 索引 / frozen 同一 /
default 値・proc)、索引領域の Symbol(100・3000 エントリ、削除後)、
索引領域の tombstone、サブクラスキー、identity マップ、線形↔索引の成長、
String → Symbol のクラス変化を追加。
4.3 呼び出しサイトのキーセット・インラインキャッシュ(中・要検証)
erubi の 322 個の spec Hash は すべて同じ 18 個のキーを同じ順序で持つ
(同じ JSON 形状から作られる)。spec["name"] のような「リテラルキー ×
同形状の Hash」という組み合わせは、テンプレートエンジンや JSON 処理では
支配的なパターンである。呼び出しサイトに「このキーセットならインデックスは
これ」を憶えさせられれば、probe そのものを飛ばせる。CRuby のオブジェクト
shape に相当する仕組みを Hash に持ち込むことになるので、キーセットの同一性を
安く判定する仕掛け(挿入順の版番号など)の設計が要る。
4.4 ディスパッチ段数を減らす(小〜中)
hashindex → Hashmap::index → HashRef::get → IndexMapCore の 4 段で、
それぞれが vm / globals を引き回している。Hashmap::index はミス時の
default 処理のためだけに 1 段あり、HashRef::get は表現の分岐をしている。
表現の分岐を呼び出し側に持ち上げて boxed 専用の入口を用意すれば数 ns 縮む
見込み。4.2 を先にやると自然に消える部分もある。
4.5 frozen String にダイジェストをキャッシュ(小)
施策 5 の前は有力だったが、ハッシュが 8 % まで落ちた今は上積みが小さい。
長いキー(パスや URL)を多用するコードでは効くので、RStringInner に
4 バイトの空きができたときの候補として残す。frozen なら無効化が不要という
性質は変わらない。
4.6 inline 表現の拡張(要検討)
48 バイトのペイロードにペア 3 組でちょうど埋まっている。4 組以上にするには
RValue を大きくするかキー・値を別配列にするかで、どちらも Hash 以外の
すべてのオブジェクトに影響する。erubi の Hash は 18 ペアなので、この方向で
救えるワークロードは限られる。
4.7 RValue の中身のアロケータ(別件だが Hash に効く)
boxed Hash の実体(インデックステーブルとエントリ Vec)は glibc malloc
経由で、activerecord では malloc/free 系だけで 15 % を占める。mimalloc
フィーチャの既定化 A/B は Hash に限らない話だが、Hash の生成・成長が多い
ワークロードにはここが効く。
5. == / eql? / hash の意味論
Hash#==/#eql?はまず同一性で短絡する(h = {x: Float::NAN}; h == hが true になる)。空でない Hash 同士では identity モードが違えば偽。再帰構造はexec_recursive_pairedで扱う。Hash#hashはペアごとにkpart ^ vpartを作って順序非依存に足し合わせ、 サイズを混ぜてからfrom_hash_digestで Fixnum 範囲に畳む。キーと値の#hashは本物の dispatch で、rb_exec_recursive_outer相当の再帰保護が付く。- 内部の
HashRef::eql(Hash 自身がキーになったときのキー比較)は別物で、 モードが違えば偽を返す。 Hash#rehashは Ruby でto_a; clear; 再挿入と書かれていて、C レベルの テーブル再構築ではない。
6. CRuby との実装差異
| 項目 | CRuby | monoruby |
|---|---|---|
| 表現の段階 | ar_table(≤ 8、線形、ハッシュ値格納)→ st_table | ≤ 3 ペアは RValue のペイロードに直置き(ヒープ確保ゼロ) → boxed の線形領域(≤ 8、AR_MAX、CRuby と同じ閾値)→ hashbrown の索引領域。線形→索引はエントリ Vec を共有したまま index table を作るだけで、ar→st のような作り直しは無い |
| 挿入順序 | st_table のエントリ配列 | rubymap(IndexMap 系): エントリ Vec + 位置の index table。観測される順序は同じ |
| 反復中の変更 | 追加は例外、delete は許可(RHASH_ITER_LEV) | 同じ規則(既存キーの更新は許可)。機構は tombstone + dead カウンタ + compact_if_dirty。inline hash は tombstone を置く前に boxed へ昇格する。深度は inline が 2 ビット飽和、boxed が Cell<u32> |
| 走査の実装 | rb_hash_foreach(C のコールバックループ) | each は Ruby の位置ループ(§1.6)。JIT がブロックごと展開するため |
| String キーの複製 | rb_hash_key_str が fstring テーブルにインターン(Hash 間で共有) | Value::frozen_hash_key がバイト列から新しい frozen コピーを毎回作る。重複排除テーブルは無い。特異メソッドは引き継がない |
| String キーの比較・ハッシュ | 内容のみ、String#hash は dispatch しない。クラスが String ちょうどのときだけバイト比較で短絡(rb_any_cmp / any_hash) | 同じ規則(Value::eql、string_key_eq)。ただしバイト比較は RStringInner::eq でエンコーディングを見ない。CRuby の rb_str_hash は非 ASCII 文字列でエンコーディング index を混ぜる |
compare_by_identity | st_table の型を identhash に切り替えて再ハッシュ | inline は IDENT_BIT を立てるだけ(packed キーは eql? と同一性が一致するので inline のまま)。boxed は別型の RubyMap<Option<IdentKey>, Value> に作り直す。空マップ限定で双方向に切り替える set_compare_by_identity_empty があり、Hash#replace / Set が使う |
| デフォルト値/proc | ifnone スロット | Option<Box<HashDefault>>。null なら「どちらも無し」を 1 ロードで判定でき、JIT のミス経路が in-line で nil を返せる。デフォルトを持つと boxed 強制(Hash.new(7) は確保する)。clear はデフォルトを保つ(CRuby と同じ) |
ミス時の default | rb_funcall(id_default) | builtin の Hash#[] は同じく dispatch。hashindex / runtime::get_index は格納値を直接読むので、対象を Hash ちょうどに限定 |
Ruby から見える #hash | rb_hash_start 系(SipHash-1-3、seeded) | HASH_STATE(std RandomState = SipHash-1-3、seeded)。バケッティングは別の seeded ミキサー(§1.5) |
| String ハッシュのキャッシュ | 無し | 無し(§4.5 に候補として残す) |
| GC | rb_gc_mark | HashRef::mark がデフォルトと生きたエントリを mark。young_child_exists が世代別の remember 判定、mutator と JIT の emit_hash_default_assign が write barrier を出す(../gc.md) |
7. テストと計測の所在
| 場所 | 何を固定しているか |
|---|---|
monoruby/tests/hash_probe_jit.rs | 機械語 probe: hit / miss / default 値 / default proc / nil・true・false キー / tombstone / 索引・inline・identity への委譲(call であって exit ではない)/ クラスガード / 線形↔索引の成長 / String キー(線形・索引・frozen 同一)/ サブクラスキー / String → Symbol のクラス変化 |
monoruby/tests/hash_string_keys.rs | boxed と inline の String キー経路: 往復、混在、サブクラス、エンコーディング(バイト比較)、tombstone、compare_by_identity、string_subclass_key_dispatches_eql |
monoruby/tests/hash_compare_by_identity.rs | identity hash は String キーを複製しない、通常 hash は複製して freeze する |
monoruby/tests/hash_seed.rs | #hash がプロセス間で変わり、プロセス内で安定 |
rubymap/src/lib.rs(raw_probe, layout_matches_the_safe_api, niche_carrying_keys_reorder_the_bucket) | JIT が使う生オフセットでの探索が find_inner と同じ答えになること |
rubymap/src/hasher.rs のテスト | ミキサーの品質(長さ分離、上位 7 ビットへの拡散、等間隔キー) |
builtins/hash.rs の jit_layout_matches_entry_at* | monoruby 側のレイアウト定数が entry_at と一致 |
benchmark/jit_hash.yaml | 1000 エントリ Hash の反復・変換ベンチ |
8. 計測手順(再現用)
# ビルド
cargo build --release
cargo build --release --features perf --target-dir target-perf # perf 用シンボル
# 純ルックアップのマイクロ(baseline を引く)
target/release/monoruby bench_hash.rb
# 内訳
perf record -F 999 -e cpu-clock -g --call-graph=fp -o h.data -- \
target-perf/release/monoruby h_str.rb
perf report -i h.data --stdio --no-children --sort symbol -g none
# ベンチ(交互ラウンド。単発は信用しない)
cd ../yjit-bench && export LANG=C.UTF-8
MAX_TIME=25 RESULT_JSON_PATH=out.json \
/path/to/monoruby -Iharness-warmup benchmarks/erubi/benchmark.rb
マイクロは必ず「同じループからルックアップを抜いたもの」を baseline として 測り、その差を見る。monoruby と CRuby ではループ自体のコストが 10 倍違う (2.5 ns 対 23 ns)ので、生の時間を並べると比較にならない。
String の実装と最適化
String はテンプレートエンジンとパーサの最内ループに出てくる型で、erubi の
バッファ <<、Hash の String キー、"lit".freeze がそれぞれ計測で
ボトルネックになった
(../yjit_bench_slow_investigation_2026-09.md
§5.2、§5.6)。
この文書は、String のセル内レイアウト、部分文字列の共有と copy-on-write、 エンコーディングと code range のキャッシュ、リテラルの扱い、VM と JIT の 高速経路をまとめ、最後に CRuby との実装差異を並べる。共通の前提は README.md、Hash の String キーについては hash.md を参照。
1. 表現
1.1 セルと 32 バイトの inline 閾値
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct RStringInner {
content: StringContent, // 先頭固定: JIT が直接アドレッシングする
ty: Encoding, // repr(u8)
cr: Cell<CodeRange>, // repr(u8)、遅延で埋まる
}
}
バッファは fork した SmallVec<[u8; 32]>(STRING_INLINE_CAP = 32、
lib.rs)で、Array と同じく OFFSET_CAPA / OFFSET_INLINE /
OFFSET_HEAP_PTR / OFFSET_HEAP_LEN を JIT に公開するために fork している。
32 バイトまでは 64 バイトの RValue セルの中(malloc 無し)、超えると
ヒープにあふれる。inline のあいだは capa スロットが長さを兼ね、あふれると
capa は容量で長さはポインタの隣に置かれる。JIT の全 emitter は
cmp capa, STRING_INLINE_CAP でこの二重の意味を分岐する。Encoding が
u8 ペイロードに収まるサイズに抑えられているのは RValue を 64 バイトに
保つためである。
1.2 共有文字列と copy-on-write
CRuby の STR_SHARED に相当する仕組みがあり、あふれた SmallVec の
フィールドに重ねる形で置かれている:
| offset | SmallVec(あふれ時) | SharedContent |
|---|---|---|
| 0 | capacity | tag(= STRING_SHARED_TAG = isize::MAX) |
| 8 | heap ptr | ptr(root のバッファ内を指す) |
| 16 | heap len | len |
| 24 | (inline の残り) | root: Value |
タグが usize::MAX でなく isize::MAX なのは、JIT のインライン bytesize /
getbyte が capa > INLINE_CAP を符号付きで比較するため(cmovgt /
csel gt)で、正の値にしておけば共有文字列が自動的にヒープ経路に乗る。
その帰結として 読み取り専用のインライン命令は共有検査を一切必要としない。
検査が要るのは書き込み側だけである。
union StringContent { owned, shared }の判別は先頭のusizeを読むだけ。Clone: sharer の複製は O(1)(4 語のコピーで同じ root のもう 1 つの sharer)。所有バッファの複製はバイトコピー。owned_mut()が唯一の変更の入口で、書き込みはまずuniquify()(見ているバイト列を新しい所有バッファに写す)を通る。これが CoW の 「write」側。JIT 向けには同じものがdetach()/runtime::str_detachと して出ている。string_substring(parent, start, end)が共有するのはlen > 32かつ 親が既に共有かヒープあふれのときだけ。inline に収まる複製は最大 32 バイトで、root の確保 + GC エッジより安い。ensure_shared_root(parent)の 3 ケース: (1) 親が既に sharer → その root を返す(root の連鎖は決して作らない)、(2) 親が frozen → 親自身が root(chilled は該当しない)、(3) 可変でヒープあふれ → バッファをmem::takeして隠れた frozen root String を新設し、親を最初の sharer に する。確保をまたぐ瞬間だけ親は空文字列として妥当な状態にあり、parent.write_barrier(root)が String が持ちうる唯一の外向き参照を記録する。string_snapshot(receiver): 正規表現系メソッドが使う frozen で安定な スナップショット。ブロックがレシーバを書き換えても match のビューは 有効なまま。frozen ならそれ自身、共有/あふれなら frozen root(レシーバが 窓なら frozen なサブビュー)、短い inline なら frozen な複製。check_string_not_modified(CRuby のstr_mod_check)はバイト長だけ で判定する。monoruby は同じ長さの in-place 編集でも CoW detach で再確保 するので、ポインタ比較だと過剰に発火する。
共有ビューを作る側: String#[] / slice / chomp / strip 系 / scan /
byteslice / lines / each_line、MatchData#[] / pre_match /
post_match / $~ の haystack、Regexp 側の各種(regexp.md)。
GC: sharer は root を mark し(rvalue.rs の STRING アーム)、
young_child_exists は「若い root を持つ sharer」を報告する — 報告しないと
minor GC が root を足元から解放する。String は OLD へ昇格可能。
shared_string_tests(string.rs)が overlay のオフセットを smallvec::OFFSET_*
と突き合わせ、短いスライスが所有のままであること、sharer のサブ文字列が
同じ root を使うこと、frozen 親が自分自身を root にすること、sharer の
clone が安価で独立していることを固定する。
1.3 順序・等価・ハッシュ・C 連携
PartialEqはas_bytes()の比較(memcmp)。Hashはバイト内容のハッシュで、 String にダイジェストはキャッシュされない(§7)。Ordは手書きの prefix 比較で、ループ内の長さ差分岐を避ける。nul_terminated_buf_ptr: CoW を先に detach し(C 側が書くかもしれない)、 必要ならreserve(1)して予備容量に NUL を書く。as_bytes/len/hash/ 等価は変わらない。Fiddle / FFI ブリッジが使う。- 容量成長は SmallVec の倍々で、CRuby の
rb_str_capaのような調整は無い。 事前サイズ指定はconcatenate_string_inner(§4.2)とwith_encoding_capacityの 2 箇所。
2. エンコーディングと code range
CodeRange { Unknown=0, SevenBit=1, Valid=2, Broken=3 }はrepr(u8)で 判別値を固定している(JIT が直接書く)。Encodingはrepr(u8):Ascii8(0), Utf8(1), UsAscii(2), Utf16Le(3), Utf16Be(4), Utf32Le(5), Utf32Be(6), Iso8859(u8), EucJp, Sjis(u8), Iso2022Jp, Other(u8), NamedByte(u8)。先頭 7 つはペイロード無しで、 JIT の<<高速経路の<= 6判定はこれに対応する。Otherは UTF-7 / CP50220/1 / BOM 付き UTF-16/32(ASCII 非互換、コーデック無し、名前だけ 保持)、NamedByteは Big5 / GBK / GB18030 / EUC-KR / Windows-125x / IBM* / KOI8 / TIS-620 / Emacs-Mule など約 38 の ASCII 互換コードページで、 格納と反復は ASCII-8BIT として振る舞う。crは遅延:code_range()が最初の問い合わせで分類してCellに 入れ、set_encodingが無効化する。Encoding::classifyは空なら SevenBit、 ASCII 互換で全バイト < 0x80 なら SevenBit(短絡)、あとはエンコーディング ごと(UTF-8 はstr::from_utf8、UTF-16/32 はバイト数の偶奇、EUC-JP / SJIS は*_char_width、ISO-2022-JP はencoding_rs)。- コンストラクタは「走査する/しない」の対で用意されている:
from_str/from_str_scanned、from_string/_scanned、from_encoding/_scanned、from_vec_scanned(UTF-8 か binary かを同じ 一巡で自動判定)、from_ascii_bytes(呼び出し側が SevenBit を保証)、from_vec_cr/from_buf_cr(StringBufに直接組んだバッファをそのまま 採用)、with_encoding_capacity(SevenBit で開始)。方針は「長生きする テンプレートは先に走査(複製がcrをただで継ぐ)、使い捨ては後回し」。 - 追記時の O(1) 畳み込み(
extend)が最大の性能修正である。素朴にcrを Unknown に戻すと、10 万回のs << "abcde"ループが CRuby 5.6 ms に 対して約 5.7 s(O(N²))になった。(SevenBit, SevenBit) → SevenBit、({SevenBit, Valid}, {SevenBit, Valid}) → Valid、それ以外 Unknown、の 畳み込みで O(N) に戻る。extend_from_slice_checked/extend_from_slice_merge_cr(packのbuffer:)も同じで、extend_from_slice_no_validate(append_as_bytes)は意図的に畳まない。 - 部分文字列への伝播
propagated_cr: SevenBit は無条件、Valid は 単バイト系なら無条件、UTF-8 は両端が文字境界のとき、UTF-16/32 は 2/4 バイト整合のとき。UsAscii / EucJp / Sjis / Iso2022Jp と Broken / Unknown の 親は Unknown に落とす。 set_byteは ASCII バイトを置くときだけ SevenBit キャッシュを保つ (JIT のsetbyteが同じ規則を再現する)。char_lengthはバイト指向のエンコーディングと、SevenBit がキャッシュ された UTF-8 / EUC-JP / SJIS で O(1)。UTF-8 の Valid は非継続バイトを数え、 Broken はiter_char_bytesを歩く。regex_view/from_mapped_utf8/needs_byte_mapping: UTF-8 専用のregexクレートをバイト指向エンコーディングにかけるための バイト ↔U+00XX代理写像(regexp.md)。compatible_encoding/Encoding::compatibleはrb_enc_compatibleの 移植で、キャッシュ済みのcrを消費して再走査しない。
../encoding_char_iteration_design.md
はエンコーディングごとの文字境界層の提案(proposed)で、§2 に現状の
RStringInner の穴(EUC-JP / SJIS のバイト単位反復、非 UTF-8 の to_str
退避)の監査がある。ただしその文書は content: Vec<u8> 時代の記述で、
SmallVec / 共有 union への改修より前のものであり、§2 が挙げる反復の穴も
すでに埋まっている(EUC-JP / Shift_JIS / Emacs-Mule は precise_mbclen を
持ち、CharByteIter はそれを通る)。
3. リテラル
3.1 fstring プール
Store::frozen_str_pool: HashMap<(Vec<u8>, Encoding), Value> は (bytes, encoding) をキーにしたプログラム全体のプールで(require をまたいで
生き、Store::mark で GC root)、Store::intern_frozen_str が
Value::string_from_source_bytes(cr 走査済み)+ set_frozen() で作って
記憶する。目的は CRuby と同じく pragma 下で "abc".equal?("abc") を true に
すること。
3.2 # frozen_string_literal: true
emit_string / emit_bytes は pragma 下で emit_frozen_interned →
BytecodeInst::FrozenLiteral(そのままロード、複製しない)。pragma が
無ければテンプレート値 + BytecodeInst::Literal で、評価ごとに deep_copy。
emit_literal は is_always_frozen なクラスのリテラルも FrozenLiteral に
回す。source_encoding() は # encoding: マジックコメントを解決し既定は
UTF-8。
3.3 chilled リテラル(CRuby 3.4 の移行経路)
pragma の無いファイルのテンプレートは chilled と印を付ける
(chill_literal_template)。ただし monoruby 自身の install_root() 配下の
ランタイム Ruby は除く(Warning[:deprecated] がインタプリタ内部を指さない
ように)。フラグは CHILLED_LITERAL_BIT = 0b1000_0000(意図的に下位バイトの
中 — 以前は GC 年齢フィールドと衝突していた、issue #975)。Symbol#to_s も
chilled な String を返す。--debug-frozen-string-literal はリテラルの出自を
record_string_origin / string_origin で記録し、エラー表示が参照する。
3.4 非 frozen リテラルの評価: テンプレート + O(1) CoW 複製
Value::value_deep_copy は最初に share_string_buffer(&mut val) を呼ぶ。
テンプレート自身が(1 回だけ)隠れ frozen root の sharer に変わり、以後
の評価ごとの deep_copy は O(len) のバイトコピーではなく O(1) のビュー複製
になり、キャッシュ済みの cr も継ぐ。ERB / heredoc サイズのテンプレートが
動機。chilled(デバッグ時は出自も)は各複製に伝播する。
VM の vm_literal は Value::value_deep_copy への call。JIT の
TraceIr::Literal → DeepCopyLit → emit_deep_copy_lit で完全にインライン
確保されるのは Array リテラルだけで、String は deepcopy_literal の
call になる。FrozenLiteral は純粋なレジスタロード。
3.5 "lit".freeze の畳み込み(CRuby の opt_str_freeze)
bytecodegen/method_call.rs が、safe-nav でなく・pragma 下でなく
(pragma 下ではリテラルが既にインターン済みオブジェクトで Object#freeze は
安い no-op)・メソッドが freeze・引数無し・レシーバが NodeKind::String
のときだけ BytecodeInst::StringFreeze(reg, interned) を出す。実行時の
string_freeze_literal は String#freeze が未再定義ならインターン済み
リテラルをそのまま返し(複製も呼び出しも無し)、再定義されていれば
dup + set_chilled_literal + 再定義された freeze を dispatch する。
BOP 表に (STRING_CLASS, "freeze") があり、JIT は basic_op_assumable の
もとで BOP 依存を記録して定数ロードにし、再定義は本体を evict、コンパイル時
に再定義済みなら無条件 deopt。
計測: 'abc'.freeze 67.6 → 9.8 ns(YJIT 26)、buf << 'lit'.freeze
73 → 15.9 ns(YJIT 21)。
-"lit"(opt_str_uminus)の畳み込みは無い。 String#-@ は Ruby で
frozen? ? self : dup.freeze と定義されていて(builtins/string.rb)、
CRuby の rb_fstring のような重複排除テーブルは無い。String#+@ は
chilled フラグに Ruby レベルの述語が無いため Rust のまま。
4. VM の経路
4.1 + / <<
+(add)はサブクラスのレシーバでも素の String を返す(dupせず 新規に組む)。#to_strが Ruby に再入するので引数を先に変換する。<<(shl/shl_inner)はs << sのエイリアスを inner のスナップ ショットで処理し(sharer なら O(1))、Integer をコードポイントとして 受ける CRuby のrb_str_concatの ASCII 特例(US-ASCII は高位バイトで ASCII-8BIT に自動拡張)、codepoint_bytesによるエンコーディングごとの 符号化(rb_enc_codelen/rb_enc_mbcput相当、InvalidとOutOfRangeで onigmo の 2 つのメッセージを再現)、#to_str変換中のレシーバのvm.temp_pushによる root を行う。extern "C" fn string_shlが JIT の 呼ぶフレームレスの入口。
4.2 補間 — ConcatStr
バイトコード側(bytecodegen/expression.rs): 補間がリテラル断片で
始まらないときは空の seed リテラルを押し、実行時の結合が常にソース
エンコーディングから交渉するようにする(pragma 下でも結果が新規の可変
String になる)。リテラル断片はインターン済み frozen テンプレートとして
出す(評価ごとの deep copy ではない)。CRuby も dstr の断片を frozen で
埋め込んでおり、テンプレートのキャッシュ済み cr が結合の逐次交渉に
流れ込むので、大きな断片の分類は一生に 1 回で済む。
実行時(runtime::concatenate_string_inner)の設計:
- 一貫して生バイト(Rust の
Stringを経由すると不正列が U+FFFD に書き 換わる)。 - エンコーディングは最初の String オペランドから seed し、以降は
Encoding::compatibleで交渉。 - エンコーディングと
crを逐次で追跡する。 以前は交渉のたびに 成長中のバッファを一時 String で包んでオペランドごとにコピーしていて O(N·total) だったのが O(total) になった。 - 結果は String オペランドから事前サイズを決めた
StringBufに直接 組む。短い結果はヒープに触れず、長い結果は確保 1 回で、RStringInner::from_buf_crがそのまま採用する。 - Fixnum オペランドはバッファに直接整形する(
format_i64+append_piece)。to_sでヒープ String を作らない。vm.to_s_is_refinedで refinement が勝つようゲート。 - ユーザーの
to_sが非 String を返せば#<Class:0xADDR>に落とす。
JIT の TraceIr::ConcatStr → AsmInst::ConcatStr → LInst::ConcatStr →
emit_concat_str は同じヘルパへの call。
4.3 == / eql? / != / <=> / hash
==(===/eql?は同じ builtin と同じインライン生成器):string_eq_boolはバイト比較に加えてエンコーディング互換検査を行う (CRuby 準拠: 交渉できないエンコーディングの同一バイト列は不一致)。右辺 が String でなくto_strに応答するなら(store.no_to_strで判定、to_str自体は呼ばない)rhs == selfを dispatch する。!=は基本演算(define_basic_op)で、CRuby にString#!=は無いので 上記の逆 dispatch を含む厳密な否定になっている(右辺がto_strと独自==の両方を持つ場合に素のバイト比較だと乖離する)。<=>はバイト比較にエンコーディング序数のタイブレーク(CRuby のrb_enc_index順)。非 ASCII 内容でだけ効く。hashはValue::calculate_hash→ プロセスごとのRandomStateで seed した SipHash(HASH_STATE、CVE-2011-4815 対策)をfrom_hash_digestで Fixnum 範囲に畳む。String ごとのハッシュキャッシュは無い。
4.4 []、to_sym、%、その他
String#[]の Fixnum / Range 形はコピー無しの共有部分文字列。Range はrb_range_beg_lenの順(負の正規化 →exclude_endの畳み込み)で、 以前"abc"[0...0]が折り返していたバグの記録がある。ASCII のみの レシーバは SevenBit キャッシュでchar_lengthが O(1)。to_sym/internは CRuby のシンボルエンコーディング正規化を実装する: ASCII 互換エンコーディングの ASCII のみ内容は US-ASCII に畳む ("a".force_encoding("KOI8-R").to_sym == :a)、US-ASCII / UTF-8 名は 文字列で、他は(bytes, encoding)でインターン。IdentifierTable(id_table.rs)は UTF-8 名のrev_table、バイナリ名のrev_table_bytes、前方のtable: Vec<IdentName>、バイトインターンした シンボルの出自エンコーディングを持つenc_mapからなり、LazyLock<RwLock<_>>のグローバル。IdentId::compareは id の一致で read lock を取る前に短絡する。%(rem)は Hash 1 個なら名前参照の源としてformat_by_argsへ (%{name}と%<name>specの両方)、Array は splat、それ以外はto_aryを試してから包む。Kernel#formatと共有。JIT 特殊化は無い。*は overflow ガード後RStringInner::repeatで、crを再走査せず 解析的に保つ。splitは$~の haystack を設定してコピー無しのスナップショットを使い、 非 UTF-8 で空セパレータならiter_char_bytesの経路。lines/each_lineはブロックが走る前に全行を実体化する (build_lines)ので、ブロックがレシーバを変えても全ビューが同じ frozen root を指す。scan/gsub/subは先にstring_snapshotを取り、各 match 結果は スナップショットの共有ビュー、$~も同じバッファを共有する。Array#packのbuffer:はextend_from_slice_merge_cr、Array#joinはfrom_vec_crを使う。string_alloc_funcは空の ASCII-8BIT 文字列を作り、String.newは 汎用のClass#newを通るのでサブクラスのinitialize上書きが効く。
5. JIT の経路
5.1 登録されているインライン生成器
| メソッド | 生成器 | 出るもの |
|---|---|---|
== / === / eql? | string_eq_gen | 無し — 定数畳み込み |
!=(基本演算) | string_ne_gen | 無し — 定数畳み込み |
<< | string_shl_gen | emit_string_shl |
bytesize | string_bytesize | 純 LIR StringLenFixnum |
getbyte | string_getbyte | emit_string_getbyte |
setbyte | string_setbyte | emit_string_setbyte |
これ以外の String メソッドはインライン化されていない。特に length /
empty? / == のバイト比較 emitter は無く、String#length と
String#empty? は普通の builtin である。
5.2 == / !=: コンパイル時の定数畳み込み
右辺のクラスがコンパイル時に分かっていて、それが String でも to_str を
持つクラスでもなければ(store.no_to_str(rhs_class, class_version))、比較
全体をコンパイル時定数の false / true に畳む — 至る所にある str == nil
がこれ。クラスは二項演算のインラインキャッシュ由来の推測なので
guard_class を出し、後からの(再)定義はインライン dispatch が既に出して
いる class-version ガードが捕まえる。結果は state.def_C で登録するので、
値としての利用は抽象状態から読み、分岐での利用は静的に解決される(素の
CondBr は真偽で、融合した BinCmpBr は binary_cmp_br で)。no_to_str
はクラス × class_version でメモ化され、JIT コンパイル中に global method
cache の RefCell が借用中でも呼べるよう、キャッシュしない祖先走査で
解決する。
5.3 bytesize: 型付き LIR マクロ命令
movq d, [b + RVALUE_OFFSET_ARY_CAPA]
cmpq d, STRING_INLINE_CAP
cmovgtq d, [b + RVALUE_OFFSET_HEAP_LEN]
salq d,1 ; orq d,1
共有文字列の capa は isize::MAX と読めるのでヒープ長のロードに回り、
それは SharedContent::len である — 「共有タグは正でなければならない」
要件の出所。aarch64 は csel gt。
5.4 getbyte / setbyte
emit_string_getbyte: 添字の untag、cmp capa, 32 + cmovgt の対で
(len, ptr) の inline / heap 選択、cmovs で負添字の補正、符号無し境界
検査(まだ負の添字も同時に捕まえる)、movzxb + Fixnum タグ。範囲外は
インラインで nil を返すので while b = s.getbyte(i) の終端で deopt しない。
共有検査は overlay により不要。
emit_string_setbyte: frozen(0b010)/chilled(0b100)のレシーバと
範囲外添字で deopt。共有レシーバは deopt せず detach して再試行する。
s = lit.dup; s.setbyte(..) では共有ミスが慢性化し(dup した文字列ごとに
1 回)、side-exit のエスカレーションが無条件になった今、deopt のたびに
呼び出し元の連鎖全体を歩いて変換してしまう(ruby-xor の退行)。out-of-line
経路は runtime::str_detach(素の malloc + memcpy、Value 確保無し、GC 無し)
を呼んで reload に戻る。cr キャッシュはインラインで整合を保つ:
SevenBit + バイト < 0x80 は SevenBit のまま、それ以外は STRING_CR_OFFSET に
Unknown を書く(RStringInner::set_byte と同じ規則)。
5.5 << — 最大のもの
string_shl_gen は証明済みのレシーバクラスを要求し、引数クラスから
StringShlHint(Integer → Fixnum、String → Str、それ以外 Both)を
導いて使わない経路を出さない。レシーバクラスの制限は要らない(String
サブクラスも同じ builtin に解決する)。emit_string_shl の doc コメントが
正式な説明で、要点は:
Fixnum バイト経路: 引数が Fixnum、レシーバが frozen / chilled でない、
バイトが 0..=255、エンコーディングタグが STRING_TY_MAX_INLINE_SHL
(= UsAscii = 2)以下 — Ascii8 / UTF-8 / US-ASCII では 7 ビットの
コードポイントがそのままバイトで、UTF-16/32 は 2/4 バイト、US-ASCII より
先は高速経路で組めない多バイト列がある — 高位バイトはさらに Ascii8
(タグ 0)に絞る。共有レシーバは fallback(この経路は detach しない)。
inline / heap の格納と容量検査(満杯は fallback — 成長で deopt しては
いけない)。cr の畳み込み: ASCII バイトはキャッシュを保ち、高位バイトは
Unknown を書く。
String 引数経路: 引数が ObjTy::STRING のヒープオブジェクト、レシーバが
frozen / chilled でない。タグが等しいときは両方ペイロード無し
(<= 6)、不一致のときは両方 ASCII 互換(<= 2)で断片が SevenBit
キャッシュ済み、レシーバが SevenBit か Valid のキャッシュ済み — これは
Encoding.compatible? がレシーバのエンコーディングに解決するケース
(コメントは「erubi のバッファの形」と呼ぶ)。断片の cr が単に未キャッシュ
なら fallback するが、ヘルパが分類してキャッシュするので、同じ断片は
2 回目の追記からインラインになる。共有レシーバは out-of-line の
str_detach + 再試行で fallback ではない(「共有は dup ごとの出来事で、
高速経路を永久に離れる理由ではない」)。引数は共有でも overlay 経由で
正しく読める。両側の inline / heap 選択、容量検査、バイトコピーループ
(s << s は重ならない: 元 [0,len)、先 [len,2len))、生きているスロット
(inline なら capa、あふれなら heap_len)への長さ書き戻し。
cr の畳み込みはレジスタ内で RStringInner::extend と同じ: sub 1 で
SevenBit→0、Valid→1、Unknown はラップ、Broken→2 とし、ja 1 つで「整形
でない」を捕まえ、2 つの or で SevenBit か Valid を決める。
fallback は生成コードの中で string_shl(vm, globals, recv, arg) を
deopt 無しで末尾呼び出しし、builtin の完全な意味論を運ぶ。
レイアウト定数 STRING_CR_OFFSET / STRING_TY_OFFSET /
STRING_TY_MAX_INLINE_SHL / Encoding::tag() は
inline_shl_encoding_tags_are_pinned テストが固定する。振る舞いは
tests/string_bytes.rs が全ケースでバイト列・エンコーディング・キャッシュ
された code range(valid_encoding? / ascii_only? 越しに観測)を CRuby
オラクルと突き合わせる。トップレベルの main スクリプトは JIT されないので、
各ホットな追記ループは 20 回の呼び出し閾値を越える def の中に置く必要が
ある、とファイル先頭が注意している。
5.6 Hash の String キー
Hash#[] のインライン probe は、キークラスが String ちょうどなら
string_digest_c(バイト列の wyhash、挿入時と同じ)と string_key_eq_c
(同一性 → STRING×STRING バイト比較)を葉ヘルパにして機械語で探索する。
再定義された String#hash はバケッティングに一切使われない。frozen String は
inline(≤ 3 ペア)Hash のキーとして許され、リテラルキーも h["k"] = v の
格納キー(Value::frozen_hash_key が複製して freeze)も frozen なので
{"content-type" => "text/plain"} は boxed map を作らない(134 → 57 ns)。
詳細は hash.md §1.1、§1.3、§4.2。
6. Ruby で書かれているメソッド
builtins/string.rb:
include Comparableはbetween?/clampを得るためだけ。順序演算子 は native のままで、クラス自身のメソッドがモジュールより勝つので</<=/>/>=は遅い<=>経由に落ちない。to_s/to_str、insert、prepend、chop/chop!、delete_suffix/!、partition/rpartition、codepoints/each_codepoint、clear(bytesplice経由)、upto。concat/appendはrb_str_concat_multiの実装で、引数が 2 つ以上なら 先にバッファに集めてからつなぐので、レシーバをエイリアスする引数は 入口の値で寄与する(str.concat str, strは 4 倍でなく 3 倍)。each_byteは明示的に性能上の選択で、bytes.each(&block)でなく素のwhileループ。前者は bytesize 個の Array と Proc を呼び出しごとに作って いた。ここでは JIT がbytesize/getbyte/yieldをインライン化し、 「バイトあたり約 3 倍速い」。uptoは CRuby の 2 つの特例(全桁数字の端点は整数として、1 文字 ASCII の 端点はバイトで反復)を再現する。-@/dedupはfrozen? ? self : dup.freeze。重複排除テーブル無し。+@は chilled に Ruby レベルの述語が無いため Rust に残っている。
7. CRuby との実装差異
| 項目 | CRuby | monoruby |
|---|---|---|
| 埋め込み文字列 | RSTRING_EMBED、64 ビットで約 24 バイト | 32 バイト。fork した SmallVec<[u8; 32]> が 64 バイトの RValue セル内 |
| 共有文字列 | STR_SHARED + RString の shared フィールド | あふれた SmallVec のフィールドに重ねた SharedContent、capacity == isize::MAX でタグ。読み取り専用の JIT 命令が共有検査を要らないように設計 |
| 共有の方針 | 積極的(rb_str_new_shared、RSTRING_EMBED_LEN_MAX 超の rb_str_substr) | len > 32 かつ親が既に共有かヒープあふれのときだけ |
| root | 共有の親自身が共有でありうる(連鎖、rb_str_shared_root で解決) | 隠れた frozen root。sharer は連鎖しない。frozen な親は自分自身が root |
| CoW | rb_str_modify → str_make_independent | owned_mut() / uniquify() の単一入口 |
rb_fstring の重複排除 | pragma のリテラルと実行時の -"str" / String#-@ / Hash キーに適用 | コンパイル時のリテラルプール Store::frozen_str_pool((bytes, encoding) キー)のみ。String#-@ は素の dup.freeze(実行時の重複排除無し)。Hash の String キーは格納ごとに dup + freeze でインターンしない |
| chilled リテラル | CRuby 3.4 の STR_CHILLED | ヘッダフラグのビット 7 CHILLED_LITERAL_BIT、Symbol#to_s 用の別の is_chilled ビット、--debug-frozen-string-literal の出自テーブル |
| coderange | ENC_CODERANGE_* を RBasic のフラグにキャッシュ | JIT 既知オフセットの Cell<CodeRange>(repr(u8))。遅延計算で、追記・部分文字列・repeat では再計算でなく O(1) で畳む |
String#hash | seeded SipHash。fstring 側でキャッシュ | seeded SipHash(DefaultHasher + プロセスごとの RandomState)。String ごとのキャッシュ無し(../yjit_bench_slow_investigation_2026-09.md §5.2 が未解決コストとして挙げる) |
| Hash キーのバケッティング | any_hash はサブクラスでも内容をハッシュ、rb_any_cmp は String ちょうどのときだけバイト比較 | 同じ分割: string_digest は無条件に内容、string_key_eq / is_plain_rstring_inner がバイト比較をクラス一致でゲート |
opt_str_freeze | あり | あり — StringFreeze 命令(§3.5) |
opt_str_uminus | あり | 無し |
| エンコーディング | 約 100 種の本物のコーデック | native コーデックは UTF-8, US-ASCII, ASCII-8BIT, UTF-16LE/BE, UTF-32LE/BE, ISO-8859-1..16, EUC-JP, Shift_JIS / CP932 / Windows-31J, ISO-2022-JP(encoding_rs)。他は名前だけ保持(Other = ASCII 非互換のダミー、NamedByte = 約 38 の ASCII 互換コードページで、格納と文字反復は ASCII-8BIT として振る舞い #name / #inspect / ASCII 互換性だけが違う) |
| EUC-JP / Shift_JIS の文字反復 | 完全な mbclen | classify / char_length / CharByteIter / #scrub が同じ三値 precise_mbclen(eucjp_precise_len / sjis_precise_len、Emacs-Mule も同形)を通る。継続バイトまで見るので、先頭バイトだけで幅を決めて次のバイトを飲み込むことはない。#scrub の不正部分は CRuby の enc_str_scrub と同じ連単位(8F A1 は 1 個の置換) |
| 非 UTF-8 上の正規表現 | onigmo の多エンコーディング | バイト → U+00XX 代理写像で UTF-8 専用の regex クレートに掛ける(regex_view / from_mapped_utf8)。EUC-JP / SJIS はバイト単位の近似 |
| 宣言エンコーディング下の不正バイト | 許容、coderange BROKEN | 同じ(content は不透明なバイトバッファ、ty は情報のみ) |
str_mod_check | ポインタ + 長さ | 長さのみ(同じ長さの in-place 編集でも再確保するため) |
#tr の否定集合 + 多バイト置換 | 受信側の coderange が 7BIT とキャッシュ済みなら置換文字の下位 1 バイトだけを書く("abc".tr("^x", "う") → "FFF"。#length を先に呼んだかどうかで答えが変わる) | 常に文字全体を書く("ううう")。CRuby の coderange キャッシュを観測可能な状態として模倣しないという判断(#1492、tests/tr_wide_replacement.rs) |
8. テストと関連文書
| 場所 | 内容 |
|---|---|
tests/string_bytes.rs | << / setbyte のバイト・エンコーディング・code range を CRuby と突き合わせ |
tests/hash_string_keys.rs | String キーの Hash 経路 |
tests/literal.rs | リテラルの評価 |
string.rs の shared_string_tests / inline_shl_encoding_tags_are_pinned | overlay のレイアウト、CoW の分離、タグの固定 |
| hash.md | String キーの probe、frozen_hash_key |
../inline.md | インライン生成器の契約(trial inlining、is_simple()) |
../lir.md | StringLenFixnum の LIR |
../gc.md | 共有 root の write barrier、young_child_exists |
../bop_redefinition.md | (STRING_CLASS, "freeze") / "!=" の BOP |
../chain_deopt.md / ../deopt_log.md | setbyte / << が deopt でなく detach + 再試行を選ぶ背景 |
../c_extention.md | nul_terminated_buf_ptr と FFI |
Regexp の実装と最適化
Regexp は graphql の実行時間の 3 分の 1 を占め(perf で 33.9 %)、
StringScanner を介して activerecord / rack のパーサ群にも効いてくる
(../yjit_bench_slow_investigation_2026-09.md
§5.6、§8.1)。
この文書は、正規表現エンジンとコンパイル済みパターンのキャッシュ、$~ と
MatchData の置き方、正規表現を使う String メソッドがどこで高速経路を
取り、どこで取らないかをまとめ、最後に CRuby との実装差異を並べる。
String 側の表現(共有部分文字列、code range、regex_view)は
string.md が前提になる。共通の前提は README.md。
1. エンジンとコンパイル済みパターン
1.1 onigmo-regex クレート
エンジンは CRuby と同じ Onigmo で、onigmo-regex クレート(git 依存)が
束ねている。Regex は生の re_pattern_buffer ポインタ、コンパイル時の
パターンバイト列、オプション語、OnigmoEncoding、コンパイル時の診断
Vec<String> を持ち、unsafe impl Send + Sync なので、プロセス全体で 1 つの
Arc<Regex> をグリーンスレッド間で共有できる。
構築は Regex::new_bytes_with_encoding の 1 本に集約され、onig_new を
OnigSyntaxRuby で呼び、Onigmo の onig_syntax_warn 出力をフックで拾って
warnings に溜める。
照合の入口は 3 つ:
| 入口 | Onigmo | Region |
|---|---|---|
captures_from_pos | onig_search | 呼び出しごとに新規確保(onig_region_new / free が 1 回ずつ) |
match_at_with_region | onig_match(アンカー、前方探索無し) | 呼び出し側が持つ再利用 Region |
search_with_region | onig_search | 同上 |
後ろ 2 つは StringScanner のために追加された(§3.6)。Captures は
Region と haystack への &str を持ち、グループ参照はレジスタ配列を直接
読むのでグループごとの確保は無い。
1.2 RegexpInner
regex: Arc<Regex> // 共有されるコンパイル済みパターン
source: Arc<[u8]> // CRuby から見える #source の生バイト列(\u{} 展開前)
encoding: OnigmoEncoding // エンジンが実際に使うエンコーディング(UTF8 / ASCII のみ)
declared_encoding: Encoding // Regexp#encoding
fixed_encoding: bool // Regexp#fixed_encoding?
initialized: bool // Regexp.allocate の placeholder だけ false
重いフィールドは両方 Arc なので clone は参照カウント 2 回。PartialEq は
Arc::ptr_eq で短絡する。Value を 1 つも持たないことが ObjTy::REGEXP
を GC で昇格可能にしている(§2.6)。
1.3 プロセス全体の正規表現キャッシュ REGEX_CACHE
static REGEX_CACHE: LazyLock<RwLock<HashMap<(String, u32, OnigmoEncoding), Arc<Regex>>>>。キーは \u{} 展開後のエンジン向けパターン文字列、Onigmo
だけのオプションビット、エンジンエンコーディング。Ruby だけのビット
(NOENCODING / FIXEDENCODING / KCODE_*)はキーを作る前に落とすので
/x/ と /x/n はコンパイル済み Regex を共有する
(ruby_only_option_bits_dont_split_cache テスト)。
探索は RegexpInner::with_option_kcode_source の 1 本に集まっていて、
インタプリタのあらゆる構築経路(with_option / with_option_and_encoding /
with_option_kcode / from_escaped)がここを通る。ヒットなら Arc を
複製してコンパイル警告を再キューする(CRuby は同じパターンを
コンパイルするたびに警告し直す)。ミスならコンパイルして挿入し、Onigmo の
エラーを CRuby の "<msg>: /<source>/" に整形する。
コストの注記: 探索は常に書き込みロックを取り、ヒットでもキーの
String を確保する(reg_str.clone())。マップは決して evict されない
ので、Regexp.new で無限に異なるソースを作るプログラムは際限なく育つ。
1.4 第 2 のキャッシュ(native エンコーディング)
RegexpInner::native_regex は NATIVE_CACHE(生ソースバイト列 + オプション
- コーデックがキー)を持ち、被照合文字列が EUC-JP / Windows-31J / ISO-8859-* /
KOI8 / Windows-125x のとき、そのコーデックでパターンを再コンパイルする。
こちらは
read()の高速経路が先にある。コーデック対応表onigmo_encoding_forは多バイトのNamedByteコーデック(Big5 / GB18030 / EUC-KR)を意図的に 外している — monoruby の文字イテレータが Onigmo の境界と食い違うため。 これが CRuby のrb_reg_prepare_reのエンコーディング別再コンパイル キャッシュに相当する(§5)。
1.5 Onigmo に渡す前のソース前処理
check_regexp_source_valid(builtins/regexp.rs、with_option_kcode_sourceより手前): ソースのバイト列がそれ自身の encoding で文字を成さないとき、 Onigmo に渡す前にRegexpError: invalid multibyte character: /…/を出す。 CRuby のrb_reg_preprocessと同じ位置で、フラグ文字(/\x81/mix、rb_reg_descの m-i-x 順)まで含めて描画する。1 バイト 1 文字の BINARY と、 バイト列として読む/…/n(NOENCODING)は対象外。Marshal.loadの/ペイロードとRegexp.unionの結合後ソースもcheck_regexp_source_bytes_validで同じ検査を通る(union が描画するのは メンバ単体ではなく結合後 —Regexp.union("x", eucbad)は/x|a\xA4/)。 なお生バイトではなく\xHHエスケープで書かれた切り詰め (Regexp.new("\\xa4".force_encoding("EUC-JP")))は前処理を通り抜けて Onigmo が見つけるので、そのtoo short multibyte code stringは CRuby に ならってtoo short escaped multibyte characterに言い換える (normalize_onigmo_message)。pre_validate_regex: Onigmo が黙って受けてしまう入力に CRuby の文言でRegexpErrorを出す(末尾の\、桁ゼロの\x、閉じていない\p{)。expand_unicode_braces: Onigmo の\uは 4 桁ちょうどしか受けないので\u{XXXX}/\u{XX YY}を\uHHHH(BMP)か生 UTF-8(補助面)に書き換え、\uXYZ形のエラーは CRuby の文言で出す。\uを含まなければ即座に抜ける。resolve_declared_encoding系: CRuby のエンコーディング解決の梯子 (n → u/e/s → FIXEDENCODING → 非 ASCII 内容 → US-ASCII 非固定)。
1.6 Executor 無しでのコンパイル時警告
bytecodegen はリテラルを Executor の届かないところでコンパイルするので、
thread_local! PENDING_REGEXP_WARNINGS に溜めて queue_regexp_warnings /
drain_pending_warnings で受け渡す。Executor::flush_compile_warnings が
Regexp.new / Regexp#initialize / eval / Binding / スクリプトコンパイル
後に流す。
1.7 Regexp.new / .escape / .union / その他
regexp_new: レシーバがREGEXP_CLASSちょうどなら直接構築(allocate + initialize の往復無し)。サブクラスは allocate してから#initializeを dispatch し、インスタンスをvm.temp_pushで root する。Regexp.allocateは空パターンをコンパイルし(必ずキャッシュヒット)、initialized = falseを立てる。#match/#=~/#optionsはTypeError、#==/#hashは動く。Regexp.escape/.quoteはバイト単位の一巡escape_bytes(rb_reg_quote相当、Vec::with_capacity(len))。「壊れた」文字列も raise せずバイト単位でエスケープする。Regexp.union: 空 →/(?!)/、Regexp1 個 → 再コンパイル無しで そのまま返す、to_regexpに応える 1 個 → その regexp。それ以外はparts.join("|")して 1 回コンパイル(キャッシュが効きうる)。 エンコーディング合成の状態機械UnionEnc/ArgEncがある。Regexp.linear_time?はエンジンに訊く(onig_check_linear_time)。 コンパイル済みプログラムを走査して、match cache がメモ化を跨げない構成 —— 後方参照、部分式呼び出し(\g<…>)、absent operator、push に落ちる look-around 内のキャプチャ、入れ子の repeat —— を探す。ソースの構文走査 ではないので、/.(?=(a))/は false で/.(?<=(a))/は true になる (ソースだけ読んでもこの差は出ない)。Regexp.timeout/timeout=は強制される。regexp 自身の timeout → グローバル → 無し の順(CRuby と同じ順)で deadline を引き、越えたらRegexp::TimeoutError。deadline はonig_set_interrupt_funcフックが 読むスレッドローカルで、ガードがスコープを抜けると元に戻る。
1.8 bytecodegen でのリテラル
- パーサ(
prism_backend.rs)はNodeKind::RegExp(parts, flags, is_const)を 作る。フラグ抽出regex_flags_from_closingはi m x n u e sだけを残し、oを落とす(§5)。 - 非補間リテラル(
is_const):const_regexpが bytecode 生成時に 1 回だけコンパイルし、結果のValueを frozen にしてemit_literalに 渡す。REGEXP_CLASS.is_always_frozen()が真なので命令はFrozenLiteral(Literalではない): VM は共有オブジェクトを 複製無しでロードし、JIT のTraceIr::FrozenLiteralはdef_lit2gpで GP レジスタへの即値ロードになる。/re/を回すループは 1 反復あたり 確保ゼロ・エンジン仕事ゼロで、同じサイトの/re/.equal?(/re/)が成り 立つ。リテラルのコンパイルエラーはロード時の構文エラーになる。 - 補間リテラル:
gen_regexpがConcatRegexp命令を出し、オプション語 は(?imx)のソース接頭辞ではなく先頭の Fixnum オペランドで渡す ((?n)は Onigmo のグループオプションとして無効、かつRegexp#sourceに フラグが見えてはいけない)。実行時のruntime::concatenate_regexpは まず通常の"#{}"結合(concatenate_string_inner、string.md §4.2)で補間 String を組み、そのバイト列と lossy な UTF-8 ビューをwith_option_kcode_sourceに渡す。つまり補間 regexp は評価のたびに 組み直されるが、補間結果が同じなら Onigmo のコンパイルはREGEX_CACHEが 省く。VM / JIT の lowering は素の実行時呼び出し。 - 条件式の裸のリテラル(
if /pat/)はパーサがregex =~ $_に脱糖する。/(?<name>…)/ =~ strの名前付きキャプチャのローカル束縛は、パーサがRegexp.last_match(:name)の代入列に書き換える。
2. $~ と MatchData
2.1 LFP_SVAR コンテナ
フレームスロット LFP_SVAR(= 16)の 0 は「未確保」の番兵で、非ゼロなら
2 要素の Array [$~, $_](SVAR_BACKREF = 0 / SVAR_LASTLINE = 1)。
所有するのはメソッドを導入するフレームだけで、ブロックと lambda は
outer を辿って LEP に至り、def / クラス本体 / トップレベル /
define_method 本体で止まる(Lfp::mfp、
../stack_frame.md)。
native な builtin フレームは svar スコープを作らない。Executor::current_mfp
は動的な CFP 連鎖を is_native() と is_svar_transparent() のフレーム
越しに歩く。is_svar_transparent は monoruby 自身の builtins/*.rb から
コンパイルされた全 iseq に立つので、"wawa".to_enum(:scan, /./).map { $& }
は monoruby の Enumerable#map が Ruby フレームでも scan の match を見る。
遅延確保: svar_container_of はスロットがゼロなら None を返し、
svar_container_of_create だけが Value::array2(nil, nil) を確保する。
clear_capture_special_variables は nil を格納するためだけにコンテナを
作ったりしない。すべてのフレーム設定コード(VM / JIT / invoker、両アーキ)
がゼロの番兵を書く。
GC: Lfp::mark_contents は非ゼロのときだけ mark。c.as_array()[SVAR_BACKREF] = val の格納は Array の DerefMut が write_barrier_bulk() を走らせる
のでバリア済み。
実行コンテキストごとの上書き root_lep / root_svar: スレッド/ファイバー
本体は spawner の LEP を持つブロックなので、そこを通して解決すると $~ が
双方向に漏れる。CRuby の ec->root_lep を鏡写しにしていて、
FiberInner::svar_isolated で opt-out できる(Enumerator の内部は意図的に
共有のまま)。
2.2 フックされたグローバル
$~(get / set、nil か MatchData のみ)、$&、$'、$`、$+、
$1..$N(IdentId から桁を読む)、$_ は全部、格納された 1 つの
MatchData から導出する読み手で、別の格納は無い。$~ が設定される
までは None(defined? が nil)を返し、$~ 自身は常に Some。
stdlib/stringio.rb が rb_lastline_set を真似るための
__set_lastline_in_caller もある。
2.3 MatchDataInner — RValue のセルに収める
regex: Option<Regexp> // 8
heystack: Value // 8 (String のスナップショット)
matches: SmallVec<[Span; 2]> // 24
= 40 ≤ RValue の 48 バイト
Span = (u32, u32) で NO_MATCH = (u32::MAX, u32::MAX) が不参加グループ。
usize 対の半分の幅で、inline 容量 2 は「全体一致のみ」と「キャプチャ 1 つ」
を位置の確保無しで賄う。被照合文字列は 4 GiB 未満に制限(構築時に assert)。
コピー無しの haystack スナップショット: MatchDataInner::snapshot は
照合側が被照合 Value を知っていれば、それの共有 copy-on-write 部分文字列
(string_substring)として保持し、知らなければ所有コピーにする。
from_captures_snap が $~ のホットな構築子で、Captures::iter から
中間の位置 Vec 無しで span を組む。アクセサ at_value / pre_match_value /
post_match_value / captures_values も CoW 部分文字列を返し、
string_value は毎回同じ frozen スナップショットを返す
(md.string.equal?(md.string))。at は &[u8] を返す — /n のバイト
クラス一致で文字境界のスライスをすると extern "C" 境界で abort するため。
2.4 一時の stash と保存経路
sp_match_regex: 進行中のRegexp。次のsave_capture_special_variablesが名前付きキャプチャ用に付ける。消費されたら消える。sp_match_haystack: 被照合 String のValue。resolve_haystackは照合側 の&strが本当にそのバッファを借りているかをポインタの包含で 検証し、(subject, byte offset)を返す。古い stash は単にNoneに 解決してコピーに落ちるだけで、誤帰属はしない。- 両方とも
Executor::markの GC root(MatchData確保が GC を起こす)。 save_capture_special_variables/_bytesがMatchDataを組み、regexp を 付け、SVAR_BACKREFに格納する。呼び出し側はString#match/=~/[]系 /split/sub/gsub/scan、Regexp#=~/===/match。
2.5 MatchData を確保しない場合
RegexpInner::match_pred: 真偽だけの照合で、$~に触れずMatchDataも作らない。Regexp#match?、String#match?、Array#grep/Enumerable#grep(grep は$~を乱してはいけない、CRuby 準拠)。captures_from_pos_no_save: 特殊変数の副作用無しの生の照合。gsub+ ブロックの先行探索とreplace_all_block_innerの走査。strscan_match:onig_match/onig_searchを再利用 region に、$~無し(§3.6)。String#match/Regexp#matchは保存したばかりの$~を返り値として 再利用し、別の haystack ビュー上に 2 個目のMatchDataを作らない (match_one/rmatch)。str.match(re).equal?($~)が CRuby と同じく 成り立つ。
避けられていないもの: str =~ /x/(どちらのレシーバ順でも)は必ず
確保する。Regexp#=~ → find_one → captures → captures_from_pos →
無条件の save_capture_special_variables。bytecodegen にも JIT にも
$~ の死活解析は無いので、JIT されたループ内の if str =~ /x/ は
1 反復あたり MatchData 1 個(+ onig_region_new / free 1 回)を確保する。
Regexp#===(teq)も find_one を通るので case s when /x/ はテストごと
に MatchData を確保する — CRuby も同じだが、CRuby は busy でない
MatchData オブジェクトを再利用する(§5)。
2.6 GC での昇格
RValue::is_promotable で ObjTy::REGEXP は TIME / UMETHOD とともに
昇格可能(「この 3 つは Value を一切持たない」)。remember-on-promote
(young_child_exists)からも参照無しの腕で除外される。効果は
../yjit_bench_slow_investigation_2026-09.md
§8.6(minor GC ごとの REGEXP の mark が erubi で 291 → 5)。
ObjTy::MATCHDATA は昇格しない(regexp と haystack の Value を持ち、
_ => false の腕に落ちる)。
3. VM / JIT の経路と正規表現を使う String メソッド
3.0 無いもの
Regexpにも正規表現駆動のStringメソッドにも、インライン生成器・JIT intrinsic・LIR ノードは 1 つも無い。builtins/regexp.rsにadd_inline/inline_gen2!は無く、codegen/にある regexp 形の機械語はConcatRegexpの実行時呼び出しだけである。Regexp#===は基本演算表に無い(===は Integer / Float / Symbol / nil / true / false だけ)。case/whenに regexp が来るとCmp(TEq)→cmp_teq_case_values→cmp_teq_values_implに Regexp の腕が無く、汎用のinvoke_method(_TEQ)でフルの dispatch になる。OptCase(ジャンプ表)は 小さな Integer のwhenが 8 個以上のときだけで、regexp には効かない。
3.1 regex_view の継ぎ目
RStringInner::regex_view はエンジン向けに UTF-8 として妥当な Cow<str> を
返す: UTF-8 / US-ASCII / ASCII のみの内容なら check_utf8 経由の借用
ビュー、8 ビット内容を持つバイト指向エンコーディングなら各バイト b を
U+00bb にした所有の代理像(元の 1 バイト ↔ ビューの 1 文字)。述語
needs_byte_mapping は宣言されたエンコーディングで決め、バイト列が
たまたま UTF-8 として妥当かどうかは見ない。逆写像は from_mapped_utf8。
全 String 正規表現メソッドが mapped フラグを持ち回って結果を戻す。
check_utf8 はキャッシュ済み CodeRange が SevenBit か Utf8 + Valid
なら O(n) の再検証を飛ばす。
3.2 CodeRange — O(1) の分類キャッシュ
string.md §2 の cr が regexp 経路を安くしている当のもの:
is_ascii_only() が「文字添字 == バイト添字」の全ショートカットをゲートし、
char_length() は SevenBit で O(1)、Regexp#match は
code_range() != SevenBit で native コーデック経路が要るかを決め、
String#match / #match? は ASCII のみのレシーバで chars().count() を
飛ばす。
3.3 バイト ↔ 文字オフセット変換
文字添字 → バイト添字のキャッシュは無い。 変換は毎回線形走査
(byte_to_char_index は char_indices().enumerate()、char_to_byte_pos は
char_indices().nth(cp)、Regexp#match の native 経路は
iter_char_bytes().take(cp).map(len).sum())。ASCII のみのショートカットが
緩和のすべてで、背景と計画は
../encoding_char_iteration_design.md
(CharByteIter、「Onigmo coupling」の制限、多エンコーディングの Onigmo
走査は別件という注記)。
3.4 部分文字列の共有と一巡の splice
string_substring:len > 32で親がヒープ/共有ならコピー無しの CoW 共有ビュー。全キャプチャ / pre / post 文字列とscanの結果に使う。string_snapshot: ユーザーコードが生きたレシーバを書き換えてもinvoke_blockをまたいで&strを借りられる frozen スナップショット。scan、gsub+ ブロック、gsub+ Hash。check_string_not_modified: CRuby のstr_mod_check(長さ基準、RuntimeError: string modified)。EncBuf(rvalue/string.rs): CRuby のrb_enc_cr_str_buf_catそのもの。gsubの結果(str_gsub)と置換テンプレートの展開(rb_reg_regsub)は この追記バッファに前方一巡で片(マッチまでの区間、置換)を追記して 組み立て、結果のエンコーディングは片が来るたびに確定する — 7 bit の片は 何も変えず、バッファが 7 bit しか持たないうちは非 ASCII の片がその エンコーディングを与え、非 ASCII 同士の別エンコーディングはEncoding::CompatibilityError(バッファ側を先に名指し)。UTF-16 / UTF-32 の片は空のバッファだけが受け入れる("-".gsub(/-/, "a".encode("UTF-16LE"))は UTF-16LE、"-b".gsub(...)はbの追記で拒否、#1634)。ブロック / Hash 形式はループ内で追記するので、受け入れられない片はそれを生んだ yield の直後に拒否される。N 回の末尾シフトbytesplice_withのO(haystack · matches)はO(haystack + Σ replacements)(回帰テストgsub_many_matches_linear)。splice_allは集めた置換をこの上に流す ラッパ(Hash の plain 経路)。RStringInner::sub_splice: 単一一致のsub用(rb_str_sub_bang)。 受信者と置換をrb_enc_compatibleで一度に判定し、判定できなければ マッチの前後が 7 bit のときだけ置換のエンコーディングで生バイトを splice する("x-".sub(/-/, "a".encode("UTF-16LE"))は UTF-16LE の 3 バイト)。bytesplice_with:[]=などの単一 splice 用。両側が SevenBit / Valid で splice 端点が UTF-8 境界なら変更後の code range 分類を O(1) に短縮。
3.5 メソッドごとの注記
| メソッド | 最適化の要点 |
|---|---|
Regexp#=~ | nil 引数は $~ を消して nil(エンジン呼び出し無し)。1 回の from_utf8 検証後 from_utf8_unchecked でバイト列を借り、CoW スナップショットのために被照合を stash。バイトオフセットを返す |
Regexp#=== | nil / Regexp / 非文字列様の引数は照合せず false。Symbol → String、他は to_str |
Regexp#match? | match_pred。MatchData 無し、$~ 無し、sp_match_regex 無し |
Regexp#match | まずエンコーディング検証。非 SevenBit でコーデックのある被照合は native コーデックのバイト経路(. が宣言エンコーディングに従う)、それ以外は借用 UTF-8 経路で、保存したばかりの $~ を返す |
String#=~ | Regexp 右辺が高速経路で文字添字を返す。String 右辺は TypeError、他は to_str の前に rhs =~ self を逆 dispatch |
String#match | 両引数を借用の前に変換(ユーザーの to_str がレシーバを変えうる)。文字 → バイト位置は ASCII のみで O(1)、末尾超えは CRuby どおり clamp |
String#match? | match_pred。範囲外の位置は clamp せず拒否 |
String#[] / slice(Regexp) | 名前付きは get_group_members でグループ番号を解決し、最右の参加グループ。$~ を設定 |
String#index | String の needle はエンジンに入らない — バイト/文字境界の部分文字列探索で $~ を触らず(issue #721)、壊れた UTF-8 も許容。Regexp 経路はミス時に $~ を消し、末尾のゼロ幅一致を専用に扱う |
String#rindex | 前方走査ループ。最終的な $~ は返す位置での追加 1 プローブで再確立 |
String#sub / sub! | Hash / 置換 String / ブロックの 3 腕が with_coerced_regexp(Regexp・String(エスケープ)・to_str)に集まる。結果は apply_template_encoding で再タグ |
String#gsub / gsub! | ブロック形は replace_all_block が frozen スナップショットを取る前に生きたレシーバで最初の一致を先行探索する: 「一致無し」の一般的なケースがスナップショット + splice でなくコピー 1 回で済み、本走査は 0 でなく最初の一致位置から始まる(gsub_block_without_a_match_probes_first テスト)。ブロック結果のエンコーディング互換は文字列化前に検査 |
String#scan | 先に frozen スナップショット。yield する各断片は CoW string_substring。CRuby のゼロ幅一致を落とさないよう captures_iter でなく手動走査。ループ後 $~ を最後の一致に戻す(終端の不一致で消えるため) |
String#split | 区切り SepKind::{Awk, Chars, Str, Re}: 空ソースの Regexp は文字分割、単一スペースのソースはリテラル文字列分割に降格し、どちらもエンジンに入らない。非 UTF-8 + 空区切りの専用文字経路、limit == 1 の短絡、CRuby の split_string を写した空フィールドの遅延カウンタ |
String#start_with? | Regexp はエンジン(m.start() == 0 でアンカー)。String は生の starts_with バイト検査 + enc_char_boundary で、エンジンも確保も無し |
String#end_with? | Regexp は TypeError(CRuby どおり)。String は ends_with + 境界検査 |
String#include? | 2 つの regex_view に str::contains |
String#tr / tr_s / delete / count / squeeze | 3 段: (1) ASCII のみのレシーバ かつ ASCII のみの指定なら [u64; 2] の 128 ビットマップ(削除/否定)か平坦な [u8; 128] 変換表で全部バイト単位、(2) 非 ASCII レシーバに ASCII 指定は tr_translate_bytes、(3) 汎用の chars() 走査。Charset は ascii_bitmap: [u64; 2] + non_ascii: BTreeSet<char> + negated で、contains_ascii_byte はシフト / AND / XOR 各 1 回 |
3.6 StringScanner — 唯一の手調整されたエンジン経路
Ruby 側は stdlib/strscan.rb。_anchored が \A(?:…) で包んだ regexp を
作るのはfallback 経路だけで、サイズ上限 512 の 3 つの Hash
(ANCHORED_RE(identity キー)/ ANCHORED_STR / PLAIN_STR)にキャッシュ
する。
Rust 側の String#__strscan_match:
thread_local! STRSCAN_REGION: RefCell<onigmo_regex::Region>— スレッド 全体で 1 つの再利用 region なので、グループ無しのヒットは確保ゼロ。- String パターンはリテラルバイト列: アンカー時
starts_with、それ以外はwindows().position()— Onigmo に入らない。 - Regexp パターンはエンジンビューがバッファそのもの(
is_ascii_only() || (Utf8 && valid))のときだけバイトバッファ上でその場照合し、それ以外はfalseを返して Ruby 側がコピーに対するString#matchに落ちる。 - アンカーは suffix に対する
onig_match(strscan_match)で、高速経路では\A(?:…)の包み regexp を決して作らない。 - 返り値は確保を意識している: 走査位置から始まるグループ無しの全体一致は
素の Fixnum、それ以外は平坦な
[b0, e0, b1, e1, …]Array。 $~は触らない(CRuby の C 実装 strscan もレジスタを自分で持つ)。
計測と経緯は
../yjit_bench_slow_investigation_2026-09.md
§5.6、§8.1。
4. エンジンとエンコーディングの相互作用
- 主経路でエンジンに渡るのは UTF-8 か ASCII だけ。より豊かな宣言
エンコーディングは
declared_encodingに残り、#encoding/#fixed_encoding?/Regexp.unionの互換判定に使われ、走査自体は UTF-8 / 代理ビュー上で走る。 - 例外は非 SevenBit でコーデックのある被照合に対する
Regexp#match:onigmo_encoding_for→native_regex(NATIVE_CACHE)→captures_bytes_from_pos→MatchDataInner::from_captures_bytes。Onigmo が 多エンコーディング照合をするのはここだけ。 - 互換ゲートは CRuby の
rb_reg_prepare_encを写す:check_match_encodingの 3 規則、regexp_encoding_mismatch、check_subject_match_encoding、warn_binary_regexp_match(「historical binary regexp match /…/n against X string」)、Regexp#matchのArgumentError: invalid byte sequence、String 側のcheck_pattern_encoding_compat等。 - 代理写像は多バイトのバイト指向エンコーディングに対する CRuby のエンコー ディング別文字走査のバイト単位の近似である。
- 結果のエンコーディング:
apply_template_encodingがsub/gsubの出力を レシーバのエンコーディングに再タグし(置換機構は既定で UTF-8 を組む)、Regexp#sourceはfixed_encoding?に従い、補間 regexp は修飾子で固定 されない限り補間内容から取る。
5. CRuby との実装差異
| 項目 | CRuby | monoruby |
|---|---|---|
/o(once) | 補間リテラルを 1 回だけコンパイルして再利用 | o をパーサが落とすので /#{x}/o は評価ごとに組み直す。REGEX_CACHE のヒットで再構築はハッシュ探索で済むが、Regexp オブジェクトの同一性は違う。ruby/spec の regexp 関連で唯一の失敗タグ(spec/tags/language/regexp/modifiers_tags.txt の “supports /o (once)”) |
| 非補間リテラル | 1 回コンパイル、frozen、共有 | 同じだがさらに早く、bytecodegen 時にコンパイルして FrozenLiteral で出す。コンパイルエラーは実行時 RegexpError でなくロード時の構文エラー |
| エンコーディング別の再コンパイル | rb_reg_prepare_re が regexp オブジェクト上にキャッシュ | プロセス全体の 2 つのキャッシュ(REGEX_CACHE / NATIVE_CACHE)、どちらも RwLock<HashMap> で上限無し。Arc<Regex> でスレッド間共有 |
| エンジンが見るエンコーディング | 被照合の実エンコーディングで Onigmo が動く | 既定で UTF-8 / ASCII。Regexp#match + 非 SevenBit + 対応コーデックだけ native 経路。多バイト NamedByte(Big5 / GB18030 / EUC-KR/TW)は意図的に除外。他はバイト ↔ U+00XX の代理空間 |
$~ の格納 | MFP の vm_svar | 同じ設計を意図的に写す: LEP の LFP_SVAR、遅延確保の [$~, $_]、ブロック / lambda が outer を辿る。追加で is_svar_transparent(monoruby の Ruby 実装コアメソッド用。CRuby はそれらを C で書くので相当物が無い) |
Regexp#match? の MatchData 回避 | あり(rb_reg_match_p) | あり(match_pred)。CRuby より広く Array#grep / Enumerable#grep も使う |
MatchData オブジェクトの再利用 | rb_reg_search0 が backref スロットの busy でない MatchData を再利用 | 常に新規確保。40 バイトのペイロード、SmallVec<[Span; 2]> の inline 容量、コピー無しの CoW haystack スナップショットで緩和 |
onig_region の再利用 | 照合ごとに再利用 | captures_from_pos は照合ごとに OnigRegion を確保・解放。再利用は StringScanner のプリミティブ(スレッドローカル Region)のみ(onig_region_clear + memset で約 3.3 %) |
リテラル接頭辞の最適探索 / ONIG_OPTION_FIND_NOT_EMPTY | 使う | 使わない。代わりに scan / replace_repeat / replace_all_block_inner / scan_block_loop で手動走査(クレートの captures_iter は CRuby が yield するはずのゼロ幅一致を落とすため) |
Regexp.timeout | 強制(ReDoS 中断) | 同じ。onig_set_interrupt_func のフックが読むスレッドローカルの deadline を match ループ内で観測し、Regexp::TimeoutError を上げる。CRuby は rb_reg_timeout_p を同じ位置で呼ぶ |
| 照合の線形時間保証(match cache) | あり(3.2 以降)。onig_check_linear_time が分類 | 同じ。vendored Onigmo に移植済みで、num_fails が (end - str) * num_cache_opcodes を越えてから遅延で有効化するので、線形しか戻らない照合は走査もバッファ確保もしない |
Regexp#== / #hash | hash はエンコーディングフラグを無視、== は無視しない | 同じ非対称を写す: == は REGEXP_EQ_OPTION_MASK(m / i / x)+ declared_encoding、hash はソース + マスクのみ |
| エラーメッセージ | Onigmo の文言 + : /<source>/ | pre_validate_regex / expand_unicode_braces / キャッシュミスのエラー腕が CRuby の文言を手で再現 |
String#index(String needle) | $~ を設定しない | 同じ(issue #721)。さらにエンジンにも入らない |
\k<name> の重複名 | 最後に参加したグループ | 同じ(get_group_members 上で実装) |
Regexp#initialize | private、再初期化は raise | private で常に raise: frozen リテラルは FrozenError、それ以外は TypeError: already initialized regexp。埋めるのは allocate の placeholder 経路だけ |
6. 現在の限界
REGEX_CACHEとNATIVE_CACHEは上限が無く evict されない。REGEX_CACHEは構築のたびに書き込みロックを取りキーStringを確保する。captures_from_posは照合ごとにOnigRegionを確保・解放する。再利用は StringScanner だけ。$~の死活解析が無いのでstr =~ /x/とcase … when /x/は結果を 使わなくても評価ごとにMatchDataを確保する。- match cache は入れ子の
OP_REPEATを扱えない。小さい有界反復は Onigmo が展開するので該当しないが(/(?:a{1,2}){1,3}/は true)、 展開の閾値を越える反復の入れ子 ——/(?:a{10,20})+/、/(a{100,200})*/など —— は false になる。CRuby も同じ (count_num_cache_opcodes_innerの “A nested OP_REPEAT is not yet supported”)。Regexp.linear_time?が false を返すものを縛るにはRegexp.timeoutを使う。 Regexp#multiline?は未実装(../plan-activerecord.md)。- 非 UTF-8 レシーバに対する Regexp 付き
String#slice!、同一エンコーディング の多バイトtr/count集合は追記予定としてstring.rsに記録がある。
7. テストと関連文書
| 場所 | 内容 |
|---|---|
value/rvalue/regexp.rs の regex_cache_tests | キャッシュの契約(Ruby だけのビットがキャッシュを分割しない、等) |
builtins/regexp.rs のテスト | regexp_match_pred_does_not_set_special_vars、gsub_block_without_a_match_probes_first、regexp_hash_ignores_encoding、initialize の raise |
builtins/string.rs の gsub_many_matches_linear | splice_all の線形性 |
builtins/fiber.rs / builtins/thread.rs の svar テスト | root_svar の分離 |
../stack_frame.md | LFP_SVAR スロットと LEP の所有規則 |
../gc.md | sp_match_regex / sp_match_haystack の root |
../encoding_char_iteration_design.md | バイト ↔ 文字オフセット、CharByteIter |
../bop_redefinition.md | Regexp#=== が表に無い理由の背景 |
../lir.md / ../arch_difference.md | ConcatRegexp マクロ命令 |
../threads.md / ../refinements.md | 実行コンテキストごとの $~ / $_、LEP 走査の再利用 |
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 throughencoding_rsonly for the count.iter_char_bytes() -> CharByteIter— encoding-aware byte-slice iterator, but itsnext()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 callto_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:
| Encoding | rule |
|---|---|
UsAscii | b < 0x80 → Char(1); else Invalid(1) |
Ascii8 | always Char(1) (binary: every byte is a “character”) |
Iso8859(_) | always Char(1) |
Utf8 | existing UTF-8 lead-byte logic (reuse current code) |
Utf16Le/Be | surrogate-pair aware: Char(2) or Char(4); trailing odd byte Invalid(1) |
Utf32Le/Be | Char(4); trailing <4 bytes Invalid(rem) |
EucJp | 0x00–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) |
Iso2022Jp | ESC-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
CharByteIter::nextbecomes the single consumer ofchar_len(carryingDecodeState). Every other character operation already funnels throughiter_char_bytes()or should be migrated to.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 forSevenBitand fixed-width.conv_char_index/byte_to_char_index/from_substring/reverse/scrub: reimplement on top ofiter_char_bytes()(offsets are the iterator’s runningpos), so they are correct for every encoding by construction.to_str()policy: this is the riskiest seam (§5). Introducechars_lossy()/ explicit byte APIs and migrate builtins that do character work offto_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)
SevenBitpath (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, rewriteCharByteIterto use them. No public behavior change for UTF-8/ASCII/fixed-width (proven bycargo test+core/stringdiff). Adds correctness only for EUC-JP/SJIS/ISO-2022-JP iteration. - P1 — length/index. Route
char_length,conv_char_index,byte_to_char_indexthrough the iterator; delete the byte-count fallbacks. Target specs:String#length,#[],#slicefor non-UTF-8. - P2 — chars/each_char/reverse/scrub. Migrate these builtins off
to_str()+chars()ontoiter_char_bytes(). - P3 —
to_str()callers. Audit the ~40coerce_to_str/to_str()call sites inbuiltins/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 changeto_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
SevenBitO(1) short-circuits inchar_length;char_len’s first match arm is the UTF-8b < 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.
crmust be invalidated/recomputed consistently with the new width logic. Mitigation:classify()andchar_lenshare the same per-encoding validity definition; add a debug-assert (cfg(debug_assertions)) thatiter_char_bytes()concatenation ==content. - Spec oracle. All tables are validated by
run_testsagainst CRuby 4.0.2 (s.force_encoding(enc).each_char.map(&:bytes)golden vectors) and acore/string+core/encodingregression diff per PR, consistent with the established zero-regression workflow.
6. Validation strategy
- Unit: golden
char_lenvectors per encoding vs CRuby (force_encoding+each_char/length/reverse). - Property: for random byte buffers, assert
concat(iter_char_bytes()) == contentandchar_length() == iter_char_bytes().count()for everyEncoding. - Spec:
core/string,core/encoding,core/symbol,core/regexp(MatchData captures) regression diff vsorigin/master— zero regressions gate per PR; both Prism and ruruby parsers.
7. Decisions (confirmed)
- 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
DecodeStatemachine for ISO-2022-JP is out of P0; ISO-2022-JP continues to route throughencoding_rsfor length/iteration (it is rare in specs) and lands in a later step.char_len’s signature still carries&mut DecodeStateso the later step is additive. to_str()long-term —display_lossy()split + convention. The\xHHfallback rendering moves to a clearly-nameddisplay_lossy()accessor;to_str()is reserved for “real characters / bytes” and character-work-on-non-UTF-8 viato_str()is forbidden by review convention. This split happens in P3 (not P0–P2, which keepto_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 ルートとして登録する仕組みも未整備。GCRootはGlobals/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のタグレイアウトが CRubyVALUEと異なる(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)
require.rs—search_load_pathで.soを見つけたら、現行の.rbスタブ置換ではなく新関数load_native_extension(path)へ分岐。- dlopen —
kernel.rsの既存パターンを再利用して.soをロード。 Init_<basename>を dlsym してextern "C" fn(*mut MrContext)として呼ぶ。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
- GC ルート登録 —
alloc.rsのGCRootツリーに「外部 C 側から保持中の Value」コンテナを追加(GlobalsにVec<Value>の固定根として)。 - 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で抽象化するのが安全。- mkmf —
extconf.rbが CRuby のruby.hを見つけにいく前提なので、monoruby 専用のmkmf置換 orruby.h互換ヘッダ生成が必要。 - スレッド/Fiber —
rb_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 block に WeakReference で格納し、
RubyFiberがHandleBlockHolderで管理。
つまり 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::CExtmodule.”
C 側には薄いシムだけ置き、本体は Ruby 側 Truffle::CExt モジュールにある。互換 API の実装コストを劇的に下げる戦略。
3.4 GC 統合
TruffleRuby の GC は VALUE を理解しないため、3 層のレシピで生存性を担保:
ExtensionCallStack— C 呼び出しごとにpreservedObjectsリストを積み、その呼び出し中に作られた ValueWrapper を強参照で pin。- Handle blocks は弱参照だが、ValueWrapper 側がブロックを強参照 することで、wrap が生きている限りブロックも生きる。
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 への示唆
- Bit 互換は捨てる — TruffleRuby ほど高度な仕組みを使っても VALUE は wrap/unwrap が必須だった。
- C API の大半を Ruby/Rust 側で実装する戦略が有効 — TruffleRuby 式に「C シムは数十行、本体は
Monoruby::CExtモジュール」にすれば互換シム数百個の重みが下がる。 - GC は per-call pin stack 方式 — C 呼び出しスコープごとに
Vec<Value>を一つ push し、その間に作られた外部参照可能な Value を pin、呼び出し終了で pop。 - 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
structfield 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 フィールドへの代入をインスタンス変数への書き込みに転送。 |
RTYPEDDATA | TypedData とデータ構造を常に同一アロケーションに置く(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] = vの lvalue 書き込み — TruffleRuby はプロキシで吸収したがネイティブ実行では不可能。これに依存する gem は未対応とし、porting 時にrb_ary_storeへの置換を要求。RHASHの内部構造直接アクセス — CRuby 自身が 3.0 頃から非推奨化済み。RSTRUCT_PTR等の lvalue 系も同様。
4.3 まとめ
「マクロをどう吸収するか」の答えは 3 層:
- ABI 互換は諦める(TruffleRuby も諦めた)。
- API 互換ヘッダ + マクロを関数呼び出しに再定義(最も効くてこ)。
- 書き換え不能なポインタ寿命保証は lazy native materialize + pin、書き込み系は co-allocation で物理レイアウトを合わせる。
5. 再コンパイルの必要性
5.1 結論:再コンパイルは必須
CRuby でビルド済みの .so をそのまま monoruby にロードすることは原理的に不可能:
| 不一致点 | 影響 |
|---|---|
Value のビットレイアウト | Qnil = 0x04 vs 0x08、Fixnum タグ位置等が違う。.so 内に埋め込まれた即値定数がそのまま意味を持たない。 |
| エクスポートシンボル名 | .so は rb_define_method、rb_str_new 等を dlopen 時にリンク解決する。monoruby はこれらを自前のシムで実装するので、シンボルテーブル自体は同名で提供できるが、ABI が合わなければ即クラッシュ。 |
| マクロ展開結果 | RSTRING_PTR(s) を CRuby ヘッダで展開するか monoruby ヘッダで展開するかで生成コードが全く違う。これはヘッダ依存=ビルド時依存。 |
| 構造体オフセット | ((RBasic*)obj)->klass を生コード上のオフセットとしてアクセスされた場合、monoruby の RValue 配置と合わない限り破綻。 |
5.2 ただし「再コンパイル」は実用上ほぼ自動化される
これは TruffleRuby・JRuby・Artichoke でも同じ前提で、ユーザ体験としては gem install nokogiri するだけ で済むよう作る:
- monoruby 用
mkmfを提供 —extconf.rbがrequire "mkmf"したときに、monoruby の include パス(~/.monoruby/include/ruby.h)と link フラグを返す。 gem installのフロー:$ monoruby -S gem install nokogiri → extconf.rb 実行(monoruby の mkmf) → Makefile 生成(-I ~/.monoruby/include, -lmonoruby_ext 等) → gcc/clang でビルド → nokogiri.so が monoruby ヘッダで再生成される → ~/.monoruby/gems/... に配置- ユーザは何も意識しない — 「初回 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 は他に RakeBuilder(Rakefile)/ConfigureBuilder(configure)/CmakeBuilder(CMakeLists.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 移植の核:
| キー | 用途 | 例 |
|---|---|---|
rubyhdrdir | ruby.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 の配置先 | (配布パッケージ用) |
CC | C コンパイラ | gcc |
CXX | C++ コンパイラ | g++ |
CFLAGS/CPPFLAGS | コンパイル時フラグ | -O3 -fPIC ... |
LDSHARED | 共有ライブラリのリンクコマンド | gcc -shared |
DLDFLAGS | リンク時フラグ | -Wl,--no-undefined |
LIBRUBYARG | Ruby ランタイムのリンク引数 | -lruby |
DLEXT | 共有ライブラリ拡張子 | so/bundle/dll |
arch | プラットフォーム識別子 | x86_64-linux |
ruby_version | ABI バージョン | 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 は .so を 2 か所に配置する:
~/.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 できるようになる仕組み
Gem::Specificationが gem のlib/を$LOAD_PATHに追加。require "nokogiri"でnokogiri.rbをロード。nokogiri.rb内部でrequire "nokogiri/nokogiri"→.soをロード。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.rb | Gem::Installer — gem 展開、ext 呼び出し |
lib/rubygems/ext/builder.rb | Gem::Ext::Builder — 種別判定とディスパッチ |
lib/rubygems/ext/ext_conf_builder.rb | extconf.rb 方式の実行 |
lib/rubygems/ext/rake_builder.rb 等 | 別方式(Rake/configure/cmake) |
lib/mkmf.rb | Ruby 本体に同梱、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 でも動く」を最初のマイルストーンにするなら:
- RubyGems を monoruby 上で起動できるところまで(
gemコマンドが立ち上がる)。 RbConfig::CONFIGを monoruby 値で完備。- 互換
ruby.hを配布。 - ダミーシム
libmonoruby_ext.so(rb_define_methodを 1 個だけ実装)。 - 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.txt → tags/ 移行
更新(2026-09-21):
language/pattern_matching_tags.txtを削除、タグは 5 ファイル 9 件に。 全タグを棚卸しした。現 master(0faa867)の release ビルド + ruby/spec (af6351a)で、タグを無効にして 6 ファイルをtimeout付きで実行:
pattern_matching_tags.txtの 3 例(refinements are used for #deconstruct/#deconstruct_keys/#=== in constant pattern)は refinements の実装(doc/refinements.md)後、deconstruct系が通常の メソッド呼び出しとして lower されるため pass する。fails:は pass する example を集計から落とすので、剪定した(ファイルごと削除)。fork_tags.txt(5 タグ、実例 8)/wait_tags.txt(1): ローカルでは pass し、両ファイルを 10 回連続で回してもハングしない。しかし #1386 以降の master に fork / wait / signal を触るコミットは無く、これらは GitHub runner 上でだけ起きるハング(下記)なので、ローカルで通ることは 外す根拠にならない。残す。import_methods_tags.txt(1)/predefined_tags.txt(1)/regexp/modifiers_tags.txt(1): いずれも今も fail。前者は下記 「実装前提の非互換」、resolve_feature_pathの.so例は monoruby のetcがstdlib/etc.rbなので[:rb, …]を返し:soと恒久に一致しない (RbConfig::CONFIG["EXTSTATIC"]を"static"にすれば spec 側が skip するが、実態と違う値を報告することになる)、/oはregex_flags_from_closingがoを落とす本物の未実装 (doc/runtime_optimization/regexp.md§5)。残す。副産物:
pattern_matching_specのHash pattern raises NoMatchingPatternKeyError if the key does not matchがタグ無しで error している(NoMatchingPatternKeyErrorが上がらない)。タグとは無関係の 実装差なので統計に出たままで正しい。
更新(2026-09-17):
core/process/wait_tags.txtを追加(#1386)。 rubyspec-stats の 2026-09-17 の run(nightly = 900252fd)で core が空になった。spec/default.mspecの example トレースが指す最後の example はProcess.wait doesn't block if no child is available when WNOHANG is used(wait_spec.rb):Process.forkした子がSignal.trap("TERM") { exit! }してsleep、親が pipe で同期してからProcess.kill("TERM")→Process.waitするもので、07:06:02 に入ったまま 07:25:42 のtimeout -k 5 1200の TERM で 抜けた(その TERM で次の example のトレースが 1 行出てから KILL、exit 137)。 前日の run(a1aa2ec1e6)では同じ example が 20 ms で通っている。ローカルでは再現しない: release ビルドの master(bf089972)で core 全体を 15 回(各 25 秒、23163 examples)、
taskset -c 0の 1 CPU で core/process を 4 回、さらに CI と同じ 900252fd をビルドして core 全体と wait_spec を走らせたが いずれも完走した。stdin を閉じる //dev/nullにする、mspec の--timeout監視スレッドや fd 待ちスレッド・busy スレッドを立てた状態で fork する、といった条件でも通る。900252fd と a1aa2ec1e6 の間の 7 コミット (#1370-#1376)に fork / wait / signal を触るものは無い。つまり GitHub runner 上でだけ起きる
forkした子 +Process.waitのハング で、fork_tags.txt(fork_spec の 5 例、同じ「fork した子を wait する」型)と 同じ族。原因が掴めるまで同じ扱いにし、この 1 例をcritical(hangs)で外す。 監視スレッド・ready キュー・fd 待ちを fork 直後の子で dispatch する経路 (scheduler::fork_child_reset_threads)は Dead マークで弾かれていることを ローカルで確認済み。タグが効いているかをローカルで確かめるときの注意: mspec は spec ファイルの 実パスに
[%r(spec/), 'spec/tags/']を当ててタグファイルを探す (action.yml のコメント参照)。spec/rubyを ruby/spec の checkout への symlink にした workspace では<checkout>/tags/core/process/wait_tags.txtに置かないと “0 tagged” のまま黙って全例が走る。CI の action はspec/tags/ruby/とspec/ruby/tags/の両方にコピーしているので、 手元でも同じ 2 か所に置けばmspec ciが1 taggedと報告する。
更新(2026-08-09): 最後のタグを削除、タグはゼロに。 #1065 で
Process.killがプロセスグループ宛て負シグナル(-TERM/ 負の番号)を 実装し、core/process/kill_tags.txtの3件は pass するようになった。 あわせて #1065 が持ち込んだ「trap ハンドラのメインスレッド配送」の 副作用 — 非メインスレッドがProcess.killで自プロセスへシグナルを 送った後、プロセスが 終了時に スピンしてハングする(kill_spec 実行が サマリ出力後に固まる)— も修正済み(非メインの kill 内自己ドレインの スキップ + 遅延シグナルのポールフラグ再アーム)。タグ無しで kill_spec を 反復実行(4回+)し、19 examples / 0F / 0E かつ終了ハング無しを確認して タグファイルを削除した。spec/tags/は現在 language/ 配下のみ。
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.txt(IO#expect4件 → 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 ciはfails/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.txt(ruby/セグメント入り)になる。- ところが 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 ciがspec/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.mspec の tags_patterns により、spec リポジトリのルート基準で
core/thread/backtrace_spec.rb → tags/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
なお、monoruby リポジトリ側の spec/default.mspec は別物で、setup action
(rubyspec: "true")が rubyspec-stats のワークスペース直下 spec/ に
コピーする設定ファイル。tags_patterns は設定しない(mspec 組み込みの
spec/tags/ruby/... 解決のまま)。中身は example トレースのみ: 各 example の
description を実行直前に stderr へ出す。mspec 自身の --timeout は対象
プロセス内の監視スレッドなので、メインスレッドがカーネル内(waitpid 等)で
ブロックすると monoruby では発火せず、外側の timeout(1) に kill されて
結果ファイルが空になる。トレースがあれば CI ログの最後の description が
ハングした example であり、そのままタグに書ける。
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) | 6 | 5件を fails: タグへ移行、1件は skip 維持 |
| COMPLETED(もうハングしない) | 30 | skip から除去、タグ不要 |
| CRASH — SIGABRT | 1 | skip 維持(monoruby バグ) |
| CRASH — SIGTERM | 3 | skip 維持(シグナル配送) |
大きな COMPLETED バケットが最大の発見: skip.txt は陳腐化していた —
#899 以降、リストの4分の3はもうハングしない(代わりに即エラー/失敗で完走)
ため、盲目的に skip 維持することが合格率を過小評価していた。
タグへ移行(6ファイル)
| spec ファイル | タグ付けした example |
|---|---|
core/argf/read_spec.rb | ARGF.read reads the contents of a special device file(/dev/zero を read(100) — 長さ制限を無視して無限読み込み) |
core/argf/readlines_spec.rb | ARGF.readlines returns an empty Array when end of stream reached |
core/io/copy_stream_spec.rb | IO.copy_stream with a destination that does partial reads calls #write repeatedly on the destination Object |
core/io/select_spec.rb | IO.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.rb | Socket.tcp_server_loop when a connection is available yields a Socket and an Addrinfo |
library/socket/socket/udp_server_loop_spec.rb | Socket.udp_server_loop when a connection is available yields the message and a Socket::UDPSource |
各ファイルは mspec ci(fails: タグを除外)で完走し、残りのハングが無いことを
検証済み。
skip.txt に維持(5ファイル)
タグでは救えないもの — プロセスごと死ぬか、全 example がハングする:
| spec ファイル | 理由 |
|---|---|
core/enumerator/new_spec.rb | builtins::array::eq(Array#==)での非巻き戻し 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.rb | 6 example 全てが IO#expect でブロック。全部タグ付けはファイルごと skip と等価 |
解除した30件の検証
mspec ci はカテゴリ内のファイルを1プロセスで順次実行するため、以前
スキップされていたファイルがグローバル状態(fd, at_exit, シグナル trap,
Mutex/Queue/Thread の状態)を残し、後続ファイルをハングさせる恐れがある。
これが起きないことを2段階で確認した。
- 解除30ファイルを1つの
mspec ciプロセスで実行 — 約35秒で完走、 815 examples、ハング0。 - 状態依存が強いカテゴリの全体実行(未スキップの隣接 spec も込み):
core/thread(全53ファイル)・core/mutex・core/queue・core/sizedqueue— 全て完走、ハングなし。
単一スレッド化(#899)により残留バックグラウンドスレッド起因のハングは
起こりにくく、この結果と整合する。I/O 依存の非常に大きいカテゴリ
(core/io・core/kernel・core/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_stream、core/io/select(2件)、library/socket/socket/{tcp,udp}_server_loop、library/expect/expect(IO#expectがブロックする4件のみ。閉じた IO / EOF の 2件は即返るので除外しない)。 - (
core/enumerator/newは issue #905 のas_arraypanic を根治したため 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秒)ので対象外。
- I/O 系ブロッキング(tag 適用で fast-path 完走):
フォローアップ
— 修正済み。 真因は2点: (1) generator の駆動(core/enumerator/newのas_arraypanic(issue #905)generator_yield_values)が resume 時にもyielderを渡していたためYielder#yieldが nil ではなく Yielder 自身を返し、 ユーザ側(r << y.yield(1))へ Yielder が漏れていた。(2)YielderがArrayの サブクラスで、継承したArray#inspect等が非配列の Yielder にValue::as_arrayを実行して非巻き戻し panic → abort。resume 値をnilにし、Yielderの親クラスをObjectに変更(CRuby 準拠)。tag は撤去し#yield returns nilは pass する。SIGTERM →— 実装済み。CRuby と同一の デフォルト変換セット(HUP/INT/QUIT/ALRM/TERM/USR1/USR2)を async ハンドラで 受けて poll 点でSignalException変換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 二重クローズ crashIO.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 = false、into_raw_fdで閉じずに 解放)とすることで、閉じるのは元の所有者だけになるようにした。fileno の同一性は 保たれる。これによりcore/io(103ファイル / 1483 example)・core/file・core/kernelがカテゴリ一括実行で crash せず完走するようになった。
実装前提の非互換による tags(恒久・非ハング)
原則(tags はハング専用、セマンティクス差は統計に出す)の唯一の例外として、 spec の前提がこの処理系上で成立し得ない example をタグ化している。 「いずれ直すべき差」ではなく、直すことが誤りになる類のもの。
core/refinement/import_methods_tags.txt—raises ArgumentError when importing methods from C extension: この spec は「C 拡張のメソッドは import できない」ことを検証するために、CRuby では確実に C 実装であるZlibを素材に選んでいる。monoruby のZlibは Ruby 実装 (stdlib/zlib.rb)なので import は正しく成功し、raise しないのが 正直な挙動。ガード自体(native builtin・attr アクセサ・define_method+block・aliasエントリ・可視性 shadow の拒否)は 実装済みで、module.rsのユニットテストが CRuby と突き合わせている。 Zlib が C 実装でなくなる限りこの example は恒久に成立しないため、 ハングではないが tag で除外する。
グリーンスレッド導入後のタイムアウト 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.txt—Thread.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) の実装で解除できる。— 解除済み。真因は 2 つ: (1)core/thread/report_on_exception_tags.txtThreadInner::pendingが 1 スロットで#killが直前の#raiseを 上書きしていた → CRuby のpending_interrupt_queueと同じ FIFOVecDequeに変更(raise が先に配達され、スレッドは例外で死ぬ)。 (2) 「ハング」の正体はクラスメソッドThread.report_on_exception=の欠落 — upstreamspec_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.txt—Mutex#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.txt—File.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.txt—File#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):
- モニタ自身が tags を適用: リポジトリの
spec/tags/を ruby/spec チェックアウトへコピーし、全ラン--excl-tag failsで実行する。 タグ済みのハング example が毎回 60 秒予算を食い潰すのを防ぐ (統計上は tagged として除外カウントされる)。 - 検知: 従来どおり、ファイル単位
timeout -k 5 60で完走しなかった ファイルがtimeouts.csvに記録される。 - 同定:
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 秒の上限。
- exit 124/137 →
- タグ更新と 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})に出力される。
optcarrot --opt プロファイル調査と高速化ロードマップ
bin/optcarrot --opt(--opt-ppu=all --opt-cpu=all)を対象に、どこで時間を
使っているかを perf と --features profile で測り、追加すべき最適化を洗い
出した記録。調査日 2026-07-31 / 2026-08-01。調査の出発点は ba8e5599、
最終的な A/B は 111d1895(PR のマージベース)に対して取り直したもの。
計測機は x86-64 / Linux。数値は --frames 3000 を 9 回走らせた中央値
(--frames 180 の既定計測は分散が大きいので、定常状態の比較にはフレーム数を
増やしたものを使う)。
1. 出発点
| 実装 | fps (--opt, 180 frames) |
|---|---|
| CRuby 4.0.1 | 約 114 |
| CRuby 4.0.1 + YJIT | 約 153 |
monoruby (ba8e5599) | 約 460〜494 (3000 frames 中央値) |
monoruby (111d1895) | 486.5 (3000 frames 中央値) |
2. 時間の内訳
perf record -F 4999 -g --call-graph=fp の結果を大づかみに分類すると:
| 区分 | 割合 |
|---|---|
| JIT が吐いたコード本体 | 約 40 % |
| JIT コードから呼び出す Rust 側 builtin / runtime | 約 30 % |
JIT コンパイル自身(AbstractState::join ほか) | 約 8 % |
| GC / malloc / memmove | 約 2 % |
つまり「JIT の出すコードが遅い」のではなく、ホットループが 1 命令ごとに Rust の関数呼び出しへ抜けているのが最大の損失だった。individual な内訳 (改善前):
| シンボル | 自己時間 | 呼び出し元 |
|---|---|---|
Array::push (← ary_shl) | 10.0 % | @output_pixels << … ×8 連鎖 |
builtins::numeric::integer::index | 3.9 % | data[8], @_a[6](CPU のビット取り出し) |
builtins::array::rotate_ | 3.6 % | @bg_pixels.rotate!(8) |
runtime::expand_array | 3.0 % | 多重代入 a, b = … |
Array::set_index2 + index_assign | 4.3 % | @bg_pixels[@scroll_xfine, 8] = … |
Value::unpack | 2.2 % | 上記 builtin の中 |
Encoding::classify (← $1) | 1.3 % | --opt のソース書き換え(起動時) |
--opt の起動コスト
--frames を振って外挿すると、定常状態は約 2.2 ms/frame、固定コストが約
1.3 s。optcarrot の --opt は起動時に Ruby ソースを生成・正規表現で書き換え
るため、そのぶんが丸ごと乗る。fps の数値自体には影響しないが、
Encoding::classify / match_at はここに属する。
3. 実施した最適化
3.1 Integer#[nth] の JIT インライン化
data[8] のようなビット取り出しが builtin 呼び出しになっていた。レシーバは
INTEGER_CLASS ガード(= Guarded::Fixnum)で Fixnum が確定しているので、
nth が定数なら 3 命令で済む。
Fixnum の表現は 2n+1 なので、n のビット nth はタグ付き値のビット
nth+1 にある。算術右シフト nth でちょうどビット 1 に落ち、これは Fixnum
タグが求める位置そのもの:
sarq rdi, (nth.min(63)) ; nth>=63 は符号ビット複製で正しい答えになる
andq rdi, 2
orq rdi, 1
負の nth は常に 0、両辺リテラルなら定数畳み込み。[nth, len] / Range 形式は
従来どおり generic path。x86-64 / aarch64 双方に実装
(gen_bit_index_imm)。
効果: 中央値 492 → 510 fps(+4 %)、integer::index は 3.9 % → 0.7 %、
Value::unpack は 2.2 % → 0.7 % に低下。
3.2 Array#<< の真のインライン化
Array#<< は define_builtin_inline_func に登録されてはいたが、
emit_array_shl の中身は movq rax, (f); call rax; ——つまり
「generic dispatch を飛ばした直接呼び出し」でしかなく、Array::push 本体
(プロローグ/エピローグ + SmallVec の inline/heap 判定 + 書き込みバリア)は
毎回実行されていた。optcarrot の生成コードは
@output_pixels << @output_color[pixel0] << … << @output_color[pixel7]
を 1 フレームあたり 61440 回実行するため、これが単独で最大のホットスポット。
ArrayInner は SmallVec<[Value; 5]> で、レイアウトは
capacity > 5⇔ spill 済み- inline のときは
capacityフィールドがそのまま長さ、容量は 5 固定 - spill 済みなら
heap_ptr/heap_lenが別フィールド
なので、どちらの residency でも「2 ロード + 1 ストア + 長さ加算」で追記できる。
容量いっぱいのとき(capacity 回に 1 回、償却的にごく稀)だけ ary_shl に
落として再確保させる。x86-64 / aarch64 双方に実装。
副産物として凍結チェックのバグを修正: ary_shl は Array::push を直接
呼ぶだけで frozen を見ていなかったため、JIT 化された a << v は凍結配列に
書き込めてしまっていた。
def go(a, v) = a << v
300.times { |i| go([], i) } # JIT を温める
go([1].freeze, 2) # CRuby: FrozenError / 修正前 monoruby: 素通り
インライン化されたストアは Array::push を経由しないので、インライン生成側で
ir.guard_frozen(deopt) を張り、凍結時はインタプリタへ deopt して正しく
FrozenError を上げるようにした。
効果: 中央値 510 → 590 fps。Array::push / ary_shl はプロファイルから
完全に消滅。
3.3 Array#rotate!
rotate_ の自己時間の 約 48 % が i % ary_len の 32bit div だった
(@bg_pixels.rotate!(8) は 16 要素配列に対する回転で、剰余は常に恒等)。
除算は数十サイクルかかる一方、回転量が配列長未満という圧倒的多数のケースでは
剰余は |cnt| そのものなので、範囲内なら除算を飛ばす wrap_rotate_count を
入れた。Array#rotate も同じ。
ついでに (-i) % ary_len が i == i64::MIN でオーバーフローする既存の穴も
塞いだ(先に剰余を取ってから符号を反転する)。
さらに JIT インライン生成を追加し、レシーバのクラスガード + guard_frozen の
あと ary_rotate_(ary, i64) を直接呼ぶようにした(generic dispatch と
coerce_to_int_i64 が消える)。
3.4 expand_array(多重代入)
AsmInst::ExpandArray は常に runtime::expand_array を呼んでいた。rest なし
で src がすでに Array、かつ要素数が足りているケース——多重代入の圧倒的多数
——は #to_ary も nil 埋めも起きない単なる代入列なので、len <= 8 のときは
is_array_ty チェック + 長さ確認 + 定数回のロード/ストアに展開し、外れた場合
だけ従来の runtime 呼び出しへ落ちるようにした。
3.5 Array#[]= の slice 形式
array_index_assign のインライン生成は pos_num != 2 を弾いており、3 引数形式
(@bg_pixels[@scroll_xfine, 8] = @bg_pattern_lut[@bg_pattern])が丸ごと
generic dispatch に落ちていた。set_index2 は try_array_ty → copy_within
→ resize → copy_from_slice と一般形を通るが、エミュレータの内側ループが
書くのは「同じ長さの並びを配列の内側で置き換える」形だけ——要素の増減も
移動もない、ただのコピー。
そこで len をコンパイル時リテラルから取り、残り(other が Array であること、
other.len() == len、0 <= start かつ start + len <= self.len())を実行時に
チェックして len 個コピーする。外れたもの(伸縮する splice、負のインデックス、
Array でない右辺、自己代入)はすべて set_array_slice に落ちて builtin と同じ
意味論を再現する。
落とし穴: 最初
state.is_fixnum(start)でゲートしたら optcarrot では 一度も発火しなかった。@scroll_xfineは ivar から読んだ値で、抽象状態は Fixnum と証明できていない(Guarded::Value)ためである。load_fixnumは どのみちガードを張るので、「Integer 以外だと証明されていない限り許可」と いう弱い述語may_be_fixnumを足してゲートし直した。これで発火するようになり、 単独で 645 → 799 fps を稼いだ。
3.6 合計
段階ごとの寄与(ba8e5599 を基準に測ったもの):
| fps (中央値, 3000 frames) | |
|---|---|
ba8e5599 | 460〜494(run 間で振れる) |
+ Integer#[] + Array#<< | 590.4 |
+ rotate! + expand_array + []= slice | 779.0 |
最終的な A/B は PR のマージベース 111d1895 に対して取り直した:
| fps (中央値, 3000 frames) | |
|---|---|
111d1895 | 486.5 |
| + 本変更 | 775.6 |
+59.4 %。180 frames の既定計測では CRuby ≈ 135〜141 / YJIT ≈ 157〜158
に対し 111d1895 が 498〜528、本変更で 854〜900 fps。
checksum は全 run で一致(60838)。
改善後プロファイルでは JIT が吐いたコードが全体の約 70 % を占め、
Array::push / ary_shl / integer::index / set_index2 / index_assign /
expand_array / coerce_to_int_i64 / SmallVec::resize はいずれも上位から
消えた。
検証: cargo test --release 3073 件 pass / 0 fail、--features gc-stress の
lib テスト 2253 件 pass / 0 fail。加えて各最適化について、境界・エラー・凍結・
GC 書き込みバリアを突く Ruby スクリプトを release と gc-stress の両ビルドで
CRuby と差分比較(Array#<< の inline/spill/成長境界/自己追記、slice 代入の
伸縮・負インデックス・非 Array 右辺・自己代入・to_ary 強制、rotate! の
巨大/負/to_int 引数、expand_array の 1〜9 要素・*rest・to_ary)。
注意: aarch64 側の実装はクロスコンパイラ (
gcc-aarch64-linux-gnu) が 手元になく 型チェックできていない。monoasm の arm64 命令定義と既存の aarch64 コードに照らした目視レビューのみ。CI の macOS arm64 ジョブが初回の ビルド検証になる。
4. 残っている改善余地
改善後プロファイル(--frames 4000)の、JIT コード外の上位:
| 対象 | 割合 | 内容 |
|---|---|---|
Array#rotate! の実作業 | 3.3 % | ary_rotate_ 1.7 + ptr_rotate 1.6 |
Encoding::classify + match_at | 1.8 % | --opt の起動時ソース書き換え |
| JIT コンパイル自身 | 約 2 % | AbstractState::join 1.0 ほか |
4.1 Array#rotate! の回転そのもの
除算は消えたが、core::slice::rotate::ptr_rotate(三段リバース/ジャグリング
の汎用実装)は残っている。@bg_pixels のような 16 要素固定の小配列なら、
スタック上のテンポラリにコピーして戻すだけの特殊化のほうが速い。
4.2 $1 取得時のコードレンジ再走査
get_match_nth → RStringInner::propagated_cr → Encoding::classify が
1.7 %。propagated_cr は親の code_range() が Unknown だと O(N) の再分類に
落ちる。親文字列を生成した時点(リテラル、String#+、gsub の結果など)で
コードレンジを確定させておけば、$1 ごとの再走査がなくなる。fps には効かな
いが --opt の 1.3 s の起動時間には効く。
4.3 JIT コンパイル時間 / 再コンパイル
--features profile の jit recompile stats で、巨大な生成メソッド
run が RecompileReason::NotCached で CPU 側 16 回・PPU 側 6 回
再コンパイルされている。数千の呼び出しサイトを持つメソッドでは、
「まだ暖まっていない 1 サイト」のたびに全体を捨てて再コンパイルすることになる。
- 冷たいサイトだけを遅延コンパイルする(サイド出口スタブ方式)
- あるいは一定数のサイトが暖まるまでコンパイルを遅らせる
のどちらかで、起動 1.3 s と定常 2.5 % の双方が縮む見込み。
4.4 多相インラインキャッシュ (PIC)
jit class guard failed stats を見ると APU::Oscillator#poke_3 などの
スーパークラス共有メソッドが Pulse / Noise / Triangle の 3 クラスで
呼ばれ、単相ガードのミス→ deopt になっている。JIT コード内に 2〜4 way の
PIC を持たせれば deopt を避けられる。
4.5 Ruby メソッドのインライン展開条件の緩和
現在 specialized_iseq(callee の iseq を呼び出し側に展開)が起動する条件は
is_simple_call かつ レシーバか引数のどれかがコンパイル時即値、または
... フォワーディングのとき。APU のように「レシーバは ivar から読んだ
オブジェクトだが、インラインキャッシュ上はクラスが単相」という典型的な
ケースは対象外になっている。
「単相 かつ callee の iseq が小さい(バイトコード N 命令未満)」を追加条件に
すれば、Oscillator#active? のような薄いメソッドを取り込める。機構
(JitType::Specialized、specialize_level による深さ制限)はすでにあるので、
ゲートの緩和とサイズヒューリスティクスの追加が主な作業になる。
5. 調べ方の再現手順
# プロファイル
cargo build --release --features perf
perf record -F 4999 -g --call-graph=fp -o oc.data \
target/release/monoruby ../optcarrot/bin/optcarrot -b --opt --frames 3000 \
../optcarrot/examples/Lan_Master.nes
perf report -i oc.data --no-children -g none --stdio # 自己時間の一覧
perf report -i oc.data --no-children --stdio -S <symbol> # 呼び出し元
# deopt / 再コンパイル / メソッドキャッシュ統計
cargo build --release --features profile
target/release/monoruby ../optcarrot/bin/optcarrot -b --opt --frames 500 \
../optcarrot/examples/Lan_Master.nes 2> prof.txt
# A/B は必ずインターリーブして中央値を取る(単発は ±20 % 振れる)
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
| Period | Key themes |
|---|---|
| Apr–Jun 2025 | Standard library expansion, Onigmo regex engine integration |
| Jul–Sep 2025 | Full keyword-argument support, Hash/Enumerable improvements, JIT refactoring |
| Oct–Dec 2025 | JIT abstract-interpretation improvements, optimizations, ruby-bench support |
| Jan–Feb 2026 | FFI/Fiddle support, continuation frames, further optimizations |
| Mar–Apr 2026 | Large-scale ruby/spec compatibility push, crash/panic elimination |
Language Features
Parser
- Splat in multiple-assignment LHS:
*a = 100 when *arysyntax%Wword-array literals=begin/=endblock 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...endblock incorrectly attaching toFoo::BARinstead of the outer call (#144)
Runtime / Closures
defined? super- SIGINT handled as a Ruby-level exception
retrystatement (#109)returninsideevalnow propagates correctly (#232)yieldcorrectly traverses Fiber boundaries (#187, #188)method_missingaccepts 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
Regexpclass implementationMatchDataclass ([], 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.newsucc!/next!,insert,byteindex,to_c,to_rencode/encode!(stubs),codepoints(#260)concat,prepend,reverse,chop,squeeze,partition,uptoString#bytesizeJIT-inlined (#139)
Hash
replace,clone,filter!assoc,rassoc,shift,key,keep_ifdelete_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_whileeach_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.readlinesFile.stat/File::Stat,File.umask,File.fnmatch,File.absolute_path,File.splitFile.zero?(#260)Dir.glob/Dir[](#106),Dir.mkdir/Dir.rmdir/Dir.entriesDir.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
Mathmodule 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.findexpanded to ~70 encoding names and aliases (#246)- ~60
Encodingconstants added (US_ASCII, ISO-8859-, Shift_JIS, EUC_JP, IBM, …) (#242) Encoding.default_external/internalstubs
Module / Class / Object
Module#ancestors,Module#private_instance_methodsModule#undef_method(#73, Jun 2025)Module#method_added,Kernel#__method__Module#define_method(fully correct)Object#singleton_methods,Object#methodsModule.newwith block (#174)Class.newwith block (#163)- Per-class
Class#allocate(#164) Object#clone,initialize_copy,initialize_clone,initialize_dup(#153, #154)define_singleton_method(#155)BasicObjectspec compliance improvements (#166, #168)singleton_classfixes (#167)
Other Classes
- Marshal support (#125, Mar 2026)
- Thread class — minimal single-threaded implementation (#222)
- SizedQueue, ConditionVariable (#260)
Method#to_proc,Method#source_locationMethod/UnboundMethodbuiltin methods (#216)Exception#set_backtrace(#156)Range#bsearch,Range#min/max/count/minmax(endless-range support)Structsubclass supportKernel#autoload,Kernel#load(#105)Kernel#exec(#145),Kernel#format/Kernel#sprintf(#162)$0,Dir.pwd,Dir.chdirProcess::Status,Signal.list- Frozen-object support (#240) — correct
freeze/frozen?behavior throughout - Stack-overflow detection
JIT Compiler Improvements
Architecture
CacheMapintroduction (#75, Sep 2025) — better inline-cache managementStackFrameintroduction — abstraction for JIT stack frames- Constant functions (#77, Sep 2025) — pure constant computations optimized away
Effecttracking (#89, Dec 2025) — side-effect information per functionImmediatebytecode 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)Codegendetached fromGlobalsRecompileReasonintroduced
Optimizations
- Binary-op JIT optimization (#87, Dec 2025) — faster Float/Integer arithmetic
f ** 2special-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#bytesizeinlined (#139, Mar 2026)- Constant folding across method/block boundaries (#90, Dec 2025)
Array#[]and#[]=inlined (#269, Apr 2026)inlinegencan 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
ffigem - SQLite3 FFI bridge (#160, Mar 2026) — support for the
sqlite3gem - one shared native-call primitive set (Aug 2026) —
Fiddle.___dlopen/___call/___read/___write/ … (src/builtins/fiddle.rs) are now the single place C is reached from.gem/ffi_c.rbcalls them instead of a duplicate registration underFFI(which also gains the JIT inliners for___read/___write), and thesqlite3bridge calls them directly rather than throughFFI::Library, so it no longer needs the hostffigem.
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-parsevendored into the workspace (Apr 2025)CLAUDE.mdAI-assistant guide added (#107)bin/spec— script for batch-running all 58 ruby/spec core categoriesbin/comparecommand added (Mar 2026)--disable-gemsCLI option (Mar 2026)
ruby/spec Compatibility
A large-scale compatibility drive (mainly Feb–Apr 2026) pushed ruby/spec core pass rates substantially:
| Metric | Before (≈ Feb 2026) | After (Apr 2026) |
|---|---|---|
| F+E (Fail + Error) | ~14,000 | ~10,300 |
| Pass rate | ~60 % | ~73.6 % |
| Crashes | many | 0 |
cargo test | 568 pass / 10 fail | 756 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 exceptions —
RuntimeError→ properErrno::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
SAFETYcomments on allunsafeblocks (#94, Jan 2026)RubyHashtrait for Hash abstraction (#76)RubyEql,RubyDiv,RubyMod,RubyDivModtraits 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:
- Onigmo regex engine integration — full Ruby-compatible regular expressions
- FFI / Fiddle / SQLite3 support — interoperability with native C libraries and gems
- Hundreds of builtin methods added or corrected
- ruby/spec pass rate raised from ~60 % to 73.6 % (F+E reduced 14,000 → 10,300)
- Continuous JIT improvements — fixpoint type inference, constant functions, continuation frames, constant folding
- Zero crashes in
cargo test— from 10 failures to a clean 756/0