CratonVM

CratonVM JIT Compiler — architecture and optimization level

This is the source of truth for what the JIT does today. Verify claims by reading the current source, not by trusting comments or older documents.


Current JIT architecture

The JIT is a tiered, two-backend compiler with a background compilation pipeline, a real deoptimization framework, and a second (AArch64) target.

Status after JIT review round 9 (2026-09-19)

Round 9 ran eleven waves of parallel review-and-fix lanes on 2026-09-18..19 and was merged into dev. It fixed about 520 defects and moved 91 pages to docs/internal/fixed-bugs/*-FIXED-20260918.md. Each lane's report is in docs/internal/jit-review-r9/NOTES-w<N>-<lane>.md. What is still open, with evidence and next steps, is indexed in docs/known-issues/jit/jit-review-round9-remaining-work-20260918.md.

Wrong-code fixes that had reached users include:

  • floating-point spills across calls;
  • an OSR inner-loop bounds-check guard;
  • synchronized fast-door lost updates;
  • private invokeinterface bound to a receiver's namesake.

Defaults that changed in round 9, all measured interleaved and all with kill switches:

FlagDefaultWhat it doesMeasured
CRATONVM_JIT_CYCLE_EDGE_CELLONmutual-recursion cycle edges call through a cell instead of dispatchMutualRec 970 -> 163 ms
CRATONVM_JIT_RETIRE_CELLONan in-loop direct call goes through a cell that eviction clearsTryLoop throw 8 500 -> 2 400 ms
CRATONVM_JIT_OSR_GUARD_EXIT_CHARGEONa guard exit out of an OSR body is charged to the guard's real reason, so a failing guard is withdrawnTryLoop throw 2 400 -> 80 ms, TryLoopRefl 8 800 -> 89 ms
CRATONVM_JIT_OSR_REENTRY_MEMOONa validated per-thread memo for OSR re-entry after a caught exception-0.25-0.3 us per caught exception
CRATONVM_JIT_INSTANCE_SELF_CALLONa statically bound private instance self-call is a direct CALLMutPriv self 2 800 -> 190 ms
CRATONVM_JIT_DIV_GUARD_RETIERONa hot IR division guard that keeps deopting re-tiers the method to single-passExcLoop callee 4.4 -> 2.9 us
CRATONVM_NO_IR_BOX_UNBOX_FOLD (kill switch)fold ONInteger.valueOf read back only by intValue/longValue is deletedBoxProbe bu 660 -> 4 ms
CRATONVM_JIT_IR_CARRY_RCX_TWINONa loop-carried value computes straight into RCXneutral on ArithProbe
CRATONVM_JIT_IR_VOLATILE_FIELDSONthe IR tier admits volatile fields and fences a volatile store (MFENCE); since wave 10 also in spliced calleesVolSplice vol 295 -> 65 ms (the wave-10 spliced-callee half)
CRATONVM_ZGC_READABLE_GIVE_BACKONZGC returns pages readable, so the JIT's announce path stays onBinary Trees 5.2 -> 2.0 s
CRATONVM_ZGC_PRISTINE_CHUNKSONa never-written TLAB chunk skips its memsetstandalone bintrees -8 %; nothing after the first GC
CRATONVM_OMIT_STACK_TRACE_IN_FAST_THROWOFFHotSpot's OmitStackTraceInFastThrow2x on caught implicit exceptions; breaks the RExceptions vector (owner's decision)

Biggest remaining gaps vs HotSpot (details on the pages indexed above):

  • a caught implicit exception costs about 3 us (HotSpot under 1 us);
  • ConcurrentHashMap.get is about 180x slower (the generic native call per get);
  • an Integer box that escapes pays a helper allocation;
  • the optimizing OSR body of a long counted loop is slower than the single-pass body;
  • the AArch64 backend still refuses field and array access.

Module size

Current, 2026-09-19 (wc -l, after round 9 and the merge with dev): jit/src/**/*.rs is 354,496 lines (244,308 in the 52 top-level files). The largest files are ir_lower.rs 41,553, lib.rs 34,685, ir.rs 20,533, ir_optimize.rs 13,666, aarch64_backend.rs 11,588 and regalloc.rs 11,608. jit/tests/ is 29,999 lines in 50 files. vm/src/jit/ is 53,432, and vm/src/runtime/interpreter/jit_bridge.rs is 16,634.

The figures in this table and the paragraphs under it are historical. wc -l on 2026-09-12: jit/src/*.rs is 174,861 lines across 33 files, and about 268,000 with jit/src/x64/. jit/src/x64.rs is now a 3,640-line module root, since the single-pass emitter was split into jit/src/x64/* (x64/bytecode_walk.rs holds the per-opcode walk). The largest single file is jit/src/lib.rs at 42,693 lines. Other current sizes: ir.rs 14,537, ir_lower.rs 30,296, ir_optimize.rs 9,073, ir_schedule.rs 4,080, escape_analysis.rs 6,560, aarch64.rs 2,445, aarch64_backend.rs 9,772. For the current file layout see the JIT section of ARCHITECTURE.md.

LocationLinesRole
jit/src/ (crate cratonvm-jit)81,903 across 17 filescodegen backends, IR pipeline, tiering, deopt
jit/tests/8,307differential / IR-vs-singlepass / intrinsic test suites
vm/src/jit/ (part of cratonvm-vm)15,197 across 7 filesVM-side glue: JIT-called helpers, skip-list, GC root scanning
Total (jit crate + vm/src/jit)~105,400

jit/src/x64.rs alone is 37,327 lines (still the largest single file — the x86-64 single-pass emitter, including several hundred unit tests). A second, independent backend now exists: jit/src/aarch64.rs (2,293 lines) + jit/src/aarch64_backend.rs (5,734 lines) — CratonVM has a real ARM64 JIT backend. Excluding both AArch64 files, the x86-64-only portion of jit/src is ~73,900 lines — roughly 10x the old "~7,200 line" figure even before counting vm/src/jit/.

The IR pipeline itself is substantial: ir.rs (3,149, graph builder), ir_lower.rs (3,342, IR→x64 lowering), ir_optimize.rs (4,039, optimization passes), ir_schedule.rs (737, scheduling), escape_analysis.rs (2,280, escape analysis / scalar replacement), scev.rs (665, scalar evolution), null_check_elim.rs (658), loop_analysis.rs (555). Supporting infrastructure: tiered.rs (2,549), deopt.rs (2,407), regalloc.rs (1,830, graph-coloring register allocator), pgo.rs (1,981, profile-guided optimization data), profile.rs (1,150, interpreter-side profile collection), platform.rs (537, W^X executable-memory allocation).

Tiered compilation — background pipeline is DEFAULT-ON

jit/src/tiered.rs implements a HotSpot-style tiering scheme (Interpreter → C1 → C1WithProfiling → FullProfile → C2). Default policy (CompilationPolicy, tiered.rs): C1 threshold 200 invocations, C2 threshold 5,000, c2_min_invocations 1,000 — each overridable via CRATONVM_TIER_C1_THRESHOLD / _C2_THRESHOLD / _C2_MIN_INVOCATIONS.

As of the "wire-tiered-manager Step 7" change, the background compilation pipeline is default-on (bg_compile(), vm/src/runtime/env_cache.rs; opt-out CRATONVM_BG_COMPILE=0): a hot method's invocation-count trigger enqueues a CompilationTask for a worker thread instead of compiling inline on the mutator; the mutator keeps interpreting until the worker publishes into the shared JIT cache. C1→C2 supersede is also default-on (c2_supersede(), opt-out CRATONVM_C2_SUPERSEDE=0): once a method's C1 (single-pass) body is published, an eligible candidate is enqueued for a low-priority C2 (IR-optimized) recompile; on publish, cached invoke-site entries are invalidated so callers re-resolve to the new body. Net effect under default settings: a hot method is first eagerly compiled by the mutator via the single-pass backend on its first call (unchanged from before Step 7), then re-tiered off-thread to the optimizing backend once profiling confirms its shape.

Two backends: single-pass (x64.rs) vs IR pipeline

The older single-pass emitter (x64.rs) remains the universal fallback and handles every JIT-eligible method. A separate IR-based backend (ir.rs/ir_lower.rs/ir_optimize.rs) is used for a bounded but broad subset of methods by default. Admission gate (ir_compatible(), ir.rs) declines (falls back to single-pass) on: athrow, any invokedynamic, more than 5 simple invokes / field ops / static-field ops, more than 3 new / anewarray, any multianewarray, and any checkcast/instanceof (no IR lowering exists for these yet).

At the VM layer (vm/src/runtime/env_cache.rs, which is what actually governs the running VM — the per-flag doc comments inside the jit crate itself are stale and describe an earlier, narrower default), the IR path by default admits: pure-int/ref methods, plus methods using long, plus methods using float/double, plus invokestatic and non-<init> invokespecial calls to oop-free-int callees. invokevirtual/invokeinterface sites remain single-pass-only by default (CRATONVM_JIT_IR_CALL_VIRTUAL opts in). So: the IR backend is on by default for a fairly broad "simple, call-light, exception-free, non-virtual-dispatch" method shape, not an experimental opt-in feature.

On-Stack Replacement (OSR) — default-ON, live threshold 1000

Master gate osr_backedge_enabled() (CRATONVM_JIT_OSR) defaults to on. The threshold that actually gates an OSR attempt is 1,000 back-edges (OSR_THRESHOLD in interpreter.rs, override CRATONVM_TIER_OSR_BACKEDGE) — not the 10,000 figure a dead CompilationPolicy.osr_threshold field would suggest (that field's only consumer, TieredCompilationManager::on_backedge, has zero call sites in the running VM). Since the background pipeline is default-on, OSR compilation also happens off-thread by default: a hot back-edge enqueues an OSR request, and the mutator only enters a worker-published OSR artifact; only CRATONVM_BG_COMPILE=0 restores the older synchronous inline OSR compile.

A prior blanket rule had permanently denied OSR for any method containing a primitive newarray, added as a workaround for a suspected corruption (GOST3412_2015Engine.init_gf256_mul_table). That workaround has been default-lifted (osr_newarray_allowed(), opt-out CRATONVM_OSR_NEWARRAY=0) — the corruption did not reproduce after later GC-root-coverage fixes (ThreadLocal value rooting, precise JIT maps, moving-young), and the blanket deny had been silently costing sieve250k ~55x (3.2s → 177s) before it was lifted.

Guarded-inline getfield — default-ON, region-bounds-guarded

guarded_inline_getfield_enabled() (x64.rs) is on unless CRATONVM_JIT_GETFIELD_HELPER is set. History: an earlier hardening routed every JIT getfield through the checked jit_getfield helper to close a stale/garbage-receiver SIGSEGV — at a measured ~4.7x cost on field-heavy workloads like bintrees-16. The current default instead null/alignment-checks the receiver and validates it against the GC's published [base, end) heap region bounds (the same containment check is_object_address uses) before a raw inline load; anything that fails the check falls back to the checked helper, preserving its NPE / i64::MIN-sentinel semantics exactly. A separate, still-opt-in CRATONVM_JIT_INLINE_GETFIELD raw (unguarded) path exists only for A/B measurement, not as a production default.

Compact reference-field layout — default-ON

compact_ref_fields_enabled() (types/src/field_layout.rs) is on unless CRATONVM_COMPACT_REF_FIELDS=0. Reference instance fields are stored as bare 8-byte pointers instead of the legacy 16-byte tagged Value cell; the GC consults a per-class oop-map (byte offsets of reference fields) built at class-define time instead of detecting references by cell tag.

Two independent inline putfield (reference-field write) paths exist:

  • Legacy 16-byte inline putfield — opt-in (CRATONVM_JIT_INLINE_PUTFIELD, default off) and additionally gated on the compact layout being disabled — under an unmodified default config it is unreachable.
  • Compact inline putfield — default-on, riding entirely on compact_ref_fields_enabled() with no separate gate. Emits an 8-byte bare-pointer store on the barrier-free fast path (non-null, young-gen receiver, null old value, in-bounds index), falling back to the compact-aware jit_putfield_object helper (full SATB pre-barrier + card write-barrier) otherwise.

Deoptimization and scalar replacement

jit/src/deopt.rs implements a real deopt framework: DeoptReason (NullCheck, ClassCheck, BoundsCheck, DivByZero, ReceiverTypeChanged, UncommonTrap, OsrExit, ...), DeoptAction (Reinterpret/RecompileAndReinterpret/MakeNotEntrant/MakeNotCompilable), and DeoptimizationPoint (native offset, bci, reason, action, frame state). Op::Guard IR nodes tie a speculative optimization (a bounds-check elision, a null/type assumption, a loop-header bounds guard, ...) to a specific bytecode index and a FrameState describing how to reconstruct every live interpreter local/stack slot from register/spill locations. On a guard failure, control transfers to a deopt trampoline that materializes a precise interpreter frame at the trapping bci and resumes there — not a whole-method re-run.

The master gate deopt_real_enabled() (CRATONVM_DEOPT_REAL) is default-on. A further extension, guard-surviving scalar replacement (CRATONVM_SCALAR_DEOPT, default off, opt-in), lets an escape-analysis-eliminated object survive a guard failure by materializing it on demand from a FrameValue::VirtualObject descriptor instead of forcing a full method re-run whenever a scalar-replaced object is live at a deopt point. Scalar replacement itself (jit/src/escape_analysis.rs) runs as part of the IR optimizer for eligible allocations and includes monitor/lock elision over scalar-replaced receivers.

Self-recursive call inlining — default-ON

inline_self_guard_enabled() (x64.rs, opt-out CRATONVM_JIT_INLINE_SELF_GUARD=0). A method with direct self-recursive call sites reserves one frame slot, fills it once in the prologue from a leaf helper, and each self-call site emits a cheap inline stack-depth compare instead of calling the full self_call_stack_guard helper on every call — the helper remains as the fallback that actually raises StackOverflowError. OSR trampolines initialize the slot to a sentinel so OSR-entered frames always take the helper path (they bypass the prologue).

BouncyCastle JIT eligibility

vm/src/jit/skip_list.rs is the single source of truth for JIT eligibility. Under the default (Conservative) policy, org/bouncycastle/ is blanket-banned from JIT compilation except for an explicit carveout: org/bouncycastle/crypto/{BufferedBlockCipher,DefaultBufferedBlockCipher} and everything under crypto/{engines,io,modes,paddings}/, plus everything under org/bouncycastle/math/ (EC + field arithmetic), with narrow forced-interpreted exceptions (CAST5Engine/CAST6Engine key schedule, NISTCTSBlockCipher.processBytes). The blanket ban traces to a suspected cross-package JIT arg-marshalling miscompile first seen during BC provider registration; it was deliberately held in place across several JIT hardening rounds pending a clean org.bouncycastle.math.ec.test.AllTests run under the allow-override, which now passes (14/14 OK) after later root-coverage fixes (moving-young GC + precise JIT maps, RRWL/refproc roots, ThreadLocal value rooting, guarded-inline getfield). The rest of BC (asn1/, util/, ...) remains banned.

Precise JIT stack maps — default-ON

precise_jit_maps_enabled() (x64.rs, opt-out CRATONVM_NO_PRECISE_JIT_MAPS) is default-on. It had originally shipped default-off (a ~6x throughput tax on call-heavy code, "BUG-01") but re-measurement found the tax gone on current dev — more aggressive inlining leaves far fewer real call safepoints in hot reflection/framework methods. A dependent flag, precise_inline_frame_record_enabled() (also default-on), further optimizes this by storing the frame pointer inline instead of via a helper call.

Bytecode and intrinsic coverage

Core coverage is close to the old ~140-opcode set (loads/stores/arrays/ arithmetic/branches/fields/invokes/newarray/multianewarray) plus new/anewarray/checkcast/instanceof/tableswitch/lookupswitch/ athrow/monitorenter/monitorexit (with lock elision over scalar-replaced receivers). invokedynamic is no longer a permanent compile-time bail on the single-pass backend: the call site itself lowers to an uncommon-trap deopt stub, so the rest of the method still compiles (the IR backend still declines any method containing invokedynamic).

On top of the opcode set sits a call-site intrinsics layer (JitIntrinsic, jit/src/lib.rs) — roughly 40 intrinsics, each with a receiver/type guard that deopts to the normal call path on mismatch:

  • Math/StrictMath: sqrt, floor, ceil, rint, abs, fma, min/max, multiplyHigh, unsignedMultiplyHigh
  • Integer/Long bit ops: bitCount, numberOfLeadingZeros, numberOfTrailingZeros, reverseBytes, highestOneBit, lowestOneBit, reverse, compare, rotateLeft, rotateRight
  • System.arraycopy (inline memmove fast path)
  • String: length, isEmpty, charAt, hashCode, equals, compareTo, indexOf(int), indexOf(String) (coder-aware for LATIN1/UTF16)
  • Arrays.fill/Arrays.equals (4 element widths each), Arrays.sort for primitive arrays (insertion sort, inline)
  • CRC32/CRC32C.update

Register residency in the optimizing tier — BUILT AND DEFAULT-ON

Corrected 2026-09-16. The heading read "built, verified, still opt-in", and the section below still argues from that. It is wrong in the direction that matters: linear_scan_enabled (jit/src/ir_lower.rs) has been DEFAULT ON since 2026-09-02, with CRATONVM_JIT_IR_LINEAR_SCAN=0 as the kill switch, and its own comment gives the reason — the enumeration disagreement that declined the file on every array-touching method was reconciled the same day.

The closing paragraph of this section, "a loop counter and a loop accumulator are Op::Phi at the loop header, and phis are excluded", is wrong for the same reason. Phis are admitted, behind ir_phi_residency_enabled (CRATONVM_JIT_IR_PHI_RESIDENCY, default on within the outer flag so the two can be A/B'd apart); regalloc::ir_op_defines_value and ir_lower::op_defines_result_slot both list Op::Phi, LiveModel::release_phi_pins exists, and emit_phi_copies publishes into the phi's register at every incoming edge as well as writing the home word.

The measured table below therefore describes a configuration that is no longer the default, and re-taking it is the open work. Read it as the problem statement it was, not as the current state.

What IS still refused, and by type rather than by heuristic: a Ref-typed phi. A GC root walk reads frame slots only (OopMapEntry carries frame_slot_offsets and nothing else), so no reference may be register-resident at a safepoint, and a phi is published on the edge — outside the per-node audit that invalidate_ref_residency performs. The refusal is unconditional, including under CRATONVM_JIT_IR_REF_RESIDENCY=1.

The section as written when residency was opt-in

ir_lower keeps every value in a frame word. Its GP tier is fixed (RAX/RCX/RDX for values, R10/R11 for safepoint and shadow-stack work, R8/R9 for call arguments), frame_word_off returns Err for ValueLoc::Reg, and the linear-scan allocator that exists and self-verifies (regalloc::allocate_linear_scan plus verify_allocation) was wired only as a write-through read cache over XMM2–XMM7, behind CRATONVM_JIT_IR_LINEAR_SCAN, default off. Its own doc comment stated the consequence: "this wiring is still FP-only. An int loop counter gets nothing out of it."

The single-pass backend, meanwhile, colours Java locals into callee-saved GPRs (LOCAL_REGS = RBX/R12–R15, plus RSI/RDI on Windows) by default. So the baseline tier keeps loop counters and accumulators in registers and the optimizing tier that supersedes it does not.

That inversion is measured, in one binary. CRATONVM_JIT_IR_LONG=0 declines any method using long, which routes a long-accumulating kernel to the single-pass backend and changes nothing else. Five one-line kernels, 20.5M iterations each, identical checksums on every arm:

kernel (inner loop body)C2 / IRC1 / single-passC1 advantage
s += a[i] over int[]84–90 ms25–28 ms3.2x
N n = a[i]; if (n != null)91–103 ms31–34 ms3.0x
s += a[i].v93–102 ms34–37 ms2.7x
a[i].v = i165–178 ms119–129 ms1.4x
a[i].next = a[i]347–361 ms284–304 ms~1.2x

(Linux/EPYC, load 5–12, three rounds. An earlier Windows run at load ~0.6 put the first row at 1.6x; the direction is the same and the size is host-dependent.) The last two rows are the control that makes the rest readable: they are dominated by out-of-line barrier work, so register residency cannot move them, and it does not. jit/src/x64/single_pass_only.rs treats this inversion as a finite, enumerable list of single-pass specialisations to veto on. It is not finite.

What landed. regalloc::xmm_roles::IR_GP_LINEAR_SCAN = RBX, R12–R15 — a general-purpose file beside the XMM one, on the same write-through contract.

Every part of the register choice is forced. They must be callee-saved on the target ABI, which this wiring needs because it has no reload machinery: a value's register must survive a call by the calling convention rather than by analysis, and that rules out every caller-saved register. They are untouched by this emitter's own tiers. The prologue saves them and every exit restores them (IR_GP_PROLOGUE_SAVED), on the same footing as the XMM save area and just as dynamically — a method that promotes nothing emits no save.

Corrected 2026-09-10. This paragraph used to read "callee-saved on both ABIs […] and that rules out even the otherwise-obvious System V candidates RSI/RDI" — a System V fact stated as an ABI-independent one. Win64 makes RSI and RDI callee-saved, and the single-pass backend has been colouring locals into them all along (x64::LOCAL_REGS is [u8; 7] on Windows and [u8; 5] elsewhere). The IR file now widens to seven there behind CRATONVM_JIT_IR_GP_WIDE, which is default OFF because it was measured slower, not because it is unsoaked: docs/internal/performance/c2-the-gp-register-file-is-not-the-binding-constraint-20260910.md. The reason it is slower is the next paragraph but one — write-through means a wider file buys publishes, not fewer stores.

The safepoint obligation is discharged by type, not by structure. A GC root walk reads a frame it did not stop, through RBP, and OopMapEntry names frame slots only, so no reference may be register-resident at a safepoint. The XMM file discharged that by having no register a Ref could occupy; the GP file has to refuse the type, which plan_register_residency's bank match does. Everything else is unchanged by construction: the home word is written at every definition, so emit_safepoint_map, build_deopt_points and emit_phi_copies read exactly what they always did.

The default did not move, and the census is why. With CRATONVM_DBG_IR_LINEAR_SCAN=1:

  • on BinTrees.itemCheck the file works — resident=7 (fp=0 gp=7) demoted=0, phi=0, 13 candidates lost to splits and 2 to type;
  • on all five probe kernels above it never runs at all: "refused: liveness and colourer disagree about which values want a home", 5 of 5.

That refusal was a pre-existing gate, not something the GP file introduced. regalloc::ir_op_defines_value (through wants_loc) and ir_lower's op_defines_result_slot (through node_color) are two enumerations of one question — the second and third of the three the_three_ir_op_enumerations names — and any disagreement declines the whole method. It was declining the XMM cache the same way and nobody could see it, because the flag printed only on success. Both halves of that are fixed: every refusal now carries a reason, the enumeration disagreement names the offending node and op, and a successful plan reports its per-cause skip census (split_or_spilled, wrong_bank_or_type, no_home, phi).

The disagreement itself is fixed too, and it was exactly two ops. op_defines_result_slot lists Op::ArrayLength and Op::NewArray; ir_op_defines_value did not — while its own doc comment asserted lockstep and named those two as "absent there and absent here". The comment written to prevent the drift was the drift. Every counted loop spelled for (i = 0; i < a.length; i++) contains an arraylength, which is why the five array-touching kernels declined and BinTrees.itemCheck, which touches no array, was the one that promoted. Nothing was ever miscompiled — the agreement check did its job and declined — but the optimization was unavailable wherever arrays are, which is most places.

The lockstep claim is now enforced rather than asserted: the_two_value_defining_enumerations_agree parses both function bodies out of the two source files and compares the sets, so adding an arm to one list and forgetting the other fails a test instead of surfacing as an unexplained refusal months later.

So the capability is built, tested, safe, and no longer structurally blocked. The flip is a separate decision that still wants a measurement on a quiet host, which is what this work could not supply: the box ran at load 30–352 throughout, with the fat-LTO link of the verifying binary SIGKILLed by the OOM killer at load 352. Do not flip it on the strength of the table above; that table is the problem statement, not a result.

Off is exactly the pre-change emission: no register handed out, no save area reserved, every read from its home word.

Two couplings are worth knowing. Residency and the level-2 selector are mutually exclusive — isel's encoder is anchored byte-for-byte against the per-opcode arms under the assumption that the frame-homed allocation is this backend's allocation, which residency makes false — so a MIR mode turns residency off. And the assertion that would catch an OSR trampoline entering past a prologue that saves registers now covers the GP band too.

Corrected 2026-09-16. That last sentence used to open "And the IR tier still publishes no OSR entry table". It does publish one, and had for five days when the sentence was written: ir_lower::emit_osr_entry_stubs (CRATONVM_JIT_IR_OSR_ENTRY, default ON since 2026-09-05) fills CompiledMethod::ir_osr_entries, and ir_osr_enter is its ABI. The correction matters here specifically because the sentence is load-bearing for the register-residency argument: "no OSR entry exists" and "an OSR entry exists and must not enter past the prologue that saves RBX/R12–R15" are different obligations, and only the second is real. It is discharged by emit_osr_entry_stubs building the frame itself rather than being entered through the single-pass osr_trampoline — see ir_osr_entry_addr's doc for the two ABIs side by side.

The optimizing tier's OSR reach — wired, and narrower than the gate

Also corrected 2026-09-16: ir_lower's own ir_osr_entry_enabled doc said "nothing in the VM calls these yet". The consumer is vm::runtime::interpreter::jit_bridge::try_osr, gated by CRATONVM_JIT_OSR_OPTIMIZING and default ON since 2026-09-05. It asks compile_optimizing_artifact for a body, caches it per (class, method, entry_pc), and enters it with ir_osr_enter; compile_osr_artifact — the single-pass OSR compile — is the fallback beneath it, not the only route.

The real limit is the door's admission, which is ir_osr_entry_addr(pc).is_some() && ir_osr_sentinel_free — both. ir_osr_sentinel_free means the body emits no deopt stub and no call-exception stub, so any method containing a call fails it. That is most methods, which is why jit_bridge keeps a negative cache (OSR_OPTIMIZING_REFUSED) to stop the door paying a full optimizing compile per OSR attempt for a body it will always refuse.

Since 2026-09-16 lower_inner also skips emitting the entry stubs for a body that is not sentinel-free, since the only door will refuse it: that is bytes, a JMP patch, a published triple and a build_live_model pass saved on the common case. ir_osr_entry_skipped_census() counts the skips and CRATONVM_JIT_IR_OSR_ENTRY_ALWAYS=1 restores unconditional emission for an A/B.

So the honest statement of the gap is not "OSR never reaches the optimizing tier" but "OSR reaches the optimizing tier only for call-free loop bodies", and widening it means giving an entered optimizing body a way to survive its own deopt sentinel — the OsrEntryPlan::resume_after_exit proof the single-pass path has and this one does not.

Known limit, named rather than guessed at: a loop counter and a loop accumulator are Op::Phi at the loop header, and phis are excluded — the allocator refuses them, and their homes are written by emit_phi_copies at each incoming edge rather than by a definition arm, so there is no site that could publish one into a register. Reaching loop-carried values therefore needs the allocator to admit phis and emit_phi_copies to publish; the phi= field of the skip census is there to say how much that is worth on a given workload before anyone builds it.

Reference stores: barrier gates instead of a region table

Under the default collector, every reference putfield in compiled code was an out-of-line call, and the inline fast path guarding it was dead code that still cost about twenty-five instructions.

The fast path was gated on region_bounds_are_live(region_bounds_addr) — the contents of the process-global JIT_REGION_BOUNDS table. ZGC (the default since 2026-08-10) and G1 both deliberately never publish into it; zgc.rs says so outright: filling it "would re-enable an inline reference STORE fast path this collector must not have". Worse, the emitter's admission test was helpers.region_bounds_addr != 0 — the address of a static, hence a constant true — so the whole sequence was emitted (null test, alignment test, six containment compares that could never pass, a compactness test, an old-gen test) and then fell through to jit_putfield_object anyway.

The prediction that follows is falsifiable and was checked: CRATONVM_NO_JIT_INLINE_PUTFIELD=1 must measure exactly zero. On BinTrees d=16 it did — 3546/3541 ms on against 3525/3786 ms off, checksum 14985902 on all four runs. A kill switch that cannot change a number is not a lever.

What landed is not a re-run at the same question. The old guard asked "is the receiver in a published young region", which is a generational question G1 and ZGC do not answer. The collector now publishes three bytes (gc::gen_heap::JIT_REF_STORE_GATES, reached through JitRuntimeHelpers::ref_store_pre_gate / _post_gate / _post_young_floor), and each names a prefix of a barrier helper's own control flow:

inline testthe helper's own first act
pre_active == 0satb_pre_barrier loads mark_active and returns
flags_byte < young_floornote_ref_store_slow compares gc_age to the promotion age and returns
post_active == 0note_ref_store loads has_old_objects and returns

So a skipped call is one that would have returned having done nothing. On any other answer the sequence calls the collector's own write_barrier — no remembered-set contract moves into the emitter, which is the mistake the previous inline store path made and the reason inline_card_mark_available is hard-false.

Two properties make reading these inline safe. Each gate may be conservative but never permissive: publishers raise a mirror before the state it mirrors and lower it after, so it can only ever say "there may be work" when there is none. And the SATB flag is armed only inside the mark-start pause (start_concurrent_mark takes a StopTheWorldToken), so no mutator can sit between its inline test and its store while the flag flips. The young floor is pinned at gc_age == 0 — the bound that needs no ordering argument at all, since the promotion age is clamped to at least 1 — and that is the case that matters, because in allocation-heavy code the receiver of a reference store is overwhelmingly an object allocated moments earlier.

The gated path also drops the condition that the field's old value be null, which is what used to send every re-assignment of an already-set reference to the helper. With pre_active read directly, the old value stops mattering.

CRATONVM_JIT_GATED_REF_STORE=0 restores the helper path. A collector that publishes no plan (all three addresses zero) gets the previous emission byte for byte, which is what G1 and Generational get today. CRATONVM_DBG=jit-method-stats prints compiled reference stores: gated=N declined=M — both numbers always, because a zero on the left alone cannot distinguish "no plan published" from "this workload compiles no reference stores". On BinTrees d=16 under the default collector it reads gated=2 declined=0, which is the engagement evidence the switch it replaces could not produce.

The throughput result is NEUTRAL, and it is stated here rather than left to be inferred from the mechanism. Wall clock was unusable — the host ran at load 20–63 with four other sessions' VMs on it, and a BinTrees arm swung 1600–5000 ms — so the arms were priced in CPU time, which is what this host's own methodology calls for. Eight pairs, alternated with the order flipped on alternate pairs, -Xmx4g, BinTrees d=16:

armuser CPU (s), 8 runsmedian
gated ON2.16 2.17 2.16 2.20 2.16 2.20 2.13 2.172.165
gated OFF2.14 2.19 2.13 2.12 2.21 2.21 2.14 2.172.165

Identical. (System CPU ranged 0.83–2.11 on both arms — GC and page-fault noise, not attributable to either.)

Two things explain that without contradicting the change. The census reads gated=2 declined=0: only two compiled sites in this workload take the sequence at all, so the sample is small. And BinTrees builds a tree that survives, so has_old_objects arms early and the surviving path still calls write_barrier — the young-receiver floor is what would elide it, and it covers only gc_age == 0.

So what is established is engagement, correctness and the emitted sequence — a call plus six compares that could never pass, replaced by three byte tests — and what is not established is a throughput win on any workload measured so far. It is kept on because the removed compares are provably dead code and the off arm is a supported configuration, not because a number says so. A call-denser workload on a quiet host is the measurement that would settle it.

Call sites: argument staging moved to the cold path

Both emit_direct_cross_call and emit_inline_cache_call opened by copying every outgoing argument into a contiguous staging region, then loaded the same frame slots again to marshal them into ABI registers — 3N memory operations per call where N does. The only reader of that region is the callee-deopt service, reached when a callee returns the deopt sentinel and otherwise never.

The staging now happens on each reader's own cold side: inside emit_inline_callee_deopt_service past its JNE .done, and immediately before the shared hashed/vtable stub in the megamorphic region rather than ahead of the monomorphic guard. The resolving slow path already re-staged for itself, which is what made the copy at the top redundant even before this. The values are read out of the same frame slots in all three places, and nothing between the marshal and any of them writes those slots.

Known residual, not fixed here. Because needs_context is checked as a runtime property of the cached entry, the argument marshalling is emitted twice per cache entry — ten copies at a site with one MIC and a four-entry PIC. That is a code-size cost rather than a per-execution one (each execution runs exactly one copy), and the clean fix is a uniform entry ABI, which changes how every compiled method receives its arguments. needs_context is an output of optimization and it moves the ABI; that is not a change to stack on top of a register-file change in the same pass.

Displacement widths

Both backends hard-coded the disp32 ModRM form at sites where a disp8 is legal, each with a smallest-form encoder sitting next to the site that did not call it. ir_lower's frame accessors (load_reg_from_frame, lea_reg_from_frame, fp_load, fp_store, emit_xmm_frame_move) now share one emit_rbp_modrm_disp, which is where the RBP-has-no-mod=00 rule lives — three bytes on the instruction class that dominates every IR body. The guarded receiver check reads six table words at displacements 0..40 through RDX, every one of them a disp8, and paid disp32 on each: 18 bytes per unproven-receiver field access.

The operand-stack register cache, and why it is still pure-kernel-only

push_from_rax parks a pushed value in a scratch register instead of storing it to the frame. It is gated on pure_kernel — no invokes, no MIC/PIC or indy sites, no field or static-field ops, no allocation, no typechecks, no inline sites, no speculative BCE guards — so one getfield anywhere in a method turns it off for the whole method and it never engages on application code.

The comment at that gate blamed "the broad R8/R9 experiment regressed call-heavy methods because each call flushed live scratch values", which reads as a cost argument and is not one: a flush emits the store the frame push would have emitted anyway, only later.

The real blocker is a register collision. SCRATCH_REGS is [R8, R9] and ARG_REGS is [RCX, RDX, R8, R9] on Win64, [RDI, RSI, RDX, RCX, R8, R9] on System V — R8 and R9 are argument registers on both. Every helper call marshalling three arguments writes R8; four writes R9. Only sites that call flush_scratch_registers first are safe, and the emitter has far more emit_call_absolute sites than flush sites. Under pure_kernel none of them is reachable, which is why the collision has never mattered. Turning the cache on broadly without that audit produces wrong code, measurably: with it default-on, test_compile_fib returned 20 for fib(10) and test_getfield_putfield_roundtrip returned garbage.

Two things did change. CRATONVM_JIT_OPERAND_CACHE=1 exists so the two arms can be measured in one binary, which the previous shape could not be. And a real defect underneath was fixed unconditionally: StackSlot::Scratch now carries the home word its push reserved and flush_scratch_registers stores into that instead of reserving another, so a straight-line stretch with several calls no longer grows the spill region once per call until spill-range-exhausted fails the compile.

Allocation in the optimizing tier — the bump landed; the gate is now a policy question

This section used to say the optimizing tier lowered every new through emit_new_object_stub — three register loads and a CALL — while the single-pass backend had emit_inline_tlab_new, so an escaping allocation compiled worse after escape analysis had run on it. That is fixed: runtime_lowering::emit_inline_tlab_new_ir gives this tier the cursor load, the bump, the limit compare and the inline header writes, with the old stub as its slow path. It lives beside the stub because that module exists to be the one place both front ends share allocation, dispatch and monitor contracts.

Three things in it are load-bearing and none of them is a copy of the single-pass sequence:

  • The size comes from class_layout, not from arithmetic. jit_post_tlab_init derives the object's shape and total size from class_layout(class_id) itself, so a caller that sizes the allocation as HEADER_SIZE + num_fields * SLOT_SIZE while the class carries a registered compact layout hands the helper a size mismatch and corrupts the heap. The snapshot is taken from the same two functions the helper and the single-pass emitter read, so all three agree by construction rather than by inspection.
  • A layout can be replaced between compile and execution, which is what the guard emitted first is for: it compares the live field count against the one this compile baked and diverts to the helper on a mismatch, before any state exists to unwind.
  • Every header write lands before the cursor commits, and the mark word is written unconditionally. Publishing the cursor first exposes an object whose header is still whatever the TLAB slot held — class_id = 0 to the GC walker, which then mis-decodes it and steps into its neighbour. The mark word stopped being padding when the 24 → 16 byte shrink folded kind, element_type, gc_age and gc_flags into it; skipping it is the 2026-08-07 Spring Boot regression (read_slot: corrupt Value cell in 178 of 184 classes).

Measured: the site census reads inline-bump=2 stub-only=0 with the path on and 0 / 2 with CRATONVM_JIT_IR_INLINE_TLAB=0, the Binary Trees checksum is 674478 on every arm and matches HotSpot, and the regression suite is 85/85 with CRATONVM_JIT_C2_ALLOC_UPGRADE=1 forcing the new path.

CRATONVM_JIT_C2_ALLOC_UPGRADE is still opt-in, and IR_MAX_ALLOCATIONS is still 16. That is now a population question rather than a codegen one — flipping it moves every allocation-bearing method to a different tier, which is a change that wants its own measurement — but the reason the gate could not be opened at all is gone.

The receiver null check nobody could prove away

getfield on this re-tested this for null on every execution, including every iteration of a loop, for a value the JVM guarantees at the call site. Two independent gaps produced that, and both had to be closed:

The dataflow could not represent the fact. null_check_elim::analyze seeded entry IN to 0 — nothing proven on entry — so a local became known non-null only by being dereferenced. That is enough for straight-line code and useless for a loop: the IN mask at a loop header is the meet of the entry path and the backedge, the backedge carries "local 0 non-null" because the body's own getfield proved it, the entry path carries nothing, and the intersection is empty. The fact died at the header on every iteration.

The getfield emitter never asked. emit_trusted_oop_receiver_check emitted TEST/JZ unconditionally, while the array sites next door had consulted is_local_nonnull since round 11. The proof existed and had no consumer.

CRATONVM_JIT_THIS_NONNULL seeds bit 0 when local 0 holds a receiver; CRATONVM_JIT_RECEIVER_NULL_ELIM lets the two getfield arms consult the result. They are separate switches because the blast radii differ — the seed widens a fact three consumers already read (inline array null-check elision, ifnull/ifnonnull branch elision, and the new one), while the consumer flag only adds the third — and one switch for both would have made them indistinguishable in a bisect.

Instance-ness is derived, and the derivation refuses rather than guesses. compile_with_param_slots takes no is_static, but method_key already carries the descriptor and num_params is the argument count with this included, so the answer is which of declared / declared + 1 the count equals. "Neither" answers None. Note the width convention: count_param_slots counts J and D as one argument each, unlike compute_param_jvm_slots, which counts them as two slots — reading the wrong one misclassifies every method with a long or double parameter.

Scoped to getfield and deliberately to nothing else. putfield's preceding push is the stored value, not the receiver — the exact shape of the Tomcat MessageBytes.setString miscompile that opcode_dereferences_receiver already documents — and checkcast does not throw on a null receiver at all, so its JZ targets a legal null path; eliding it would let a null fall into the KIND_TAGS byte compare and fault.

Measured: it engages, and it does not show up in the clock

Probe: for (i = 0; i < 2000; i++) s += this.x;, called 40,000 times — 80M executions of the elided check. Debug binary, Azure vm1, a shared host that was also running two fat-LTO release builds and an H2 suite.

Armcensuschecksum
both onelided=2 emitted=0240000000
CRATONVM_JIT_RECEIVER_NULL_ELIM=0elided=0 emitted=2240000000
CRATONVM_JIT_THIS_NONNULL=0elided=0 emitted=2240000000

Both halves are load-bearing and independently switchable — turning off either one returns the count to zero — and the answer matches HotSpot on every arm.

Throughput, five interleaved reps per arm, CPU time (%U + %S; wall clock is meaningless on that host): medians 3.30 s on and 3.30 s off, ranges 3.01–3.33 and 3.13–3.39. No detectable difference. The loop does run compiled — the --nojit arm was still going after two minutes against 3.3 seconds — so this is a measurement of compiled code, not of the interpreter.

That is the expected result and it is worth stating rather than filing away: TEST r,r; JZ rel32 is nine bytes and two well-predicted µops, and an out-of-order core hides them behind the load they guard. What the elision buys is code size, paid entirely at compile time, plus the fact that a check that is not emitted cannot be got wrong.

Eliding by proof is strictly better than faulting, wherever the proof exists. Where it does not, there is the implicit null check.

The implicit null check — the receiver dereference is the check

CRATONVM_JIT_IMPLICIT_NULL_CHECK=1, default OFF.

Where the dataflow proves nothing, the compact getfield arm can drop TEST RAX, RAX; JZ slow anyway and let the receiver dereference that follows it fault. The signal handler translates that fault back into the arm's own slow path, which calls the helper that raises the NullPointerException. The load that would have been guarded is the guard.

This is the HotSpot mechanism, and it is the one item of the JIT audit that did not land with the rest of its round. The reason was never the signal handler — it was lifetime, and it is worth writing down what each of the three hazards actually needed.

Signal safety. The lookup runs inside the handler, so it cannot use the mutex lookup_jit_method_name uses. That function is only ever reached while the process is already dying, which is what makes a try_lock acceptable there; here the process is expected to survive, and a handler that blocks on a lock its own interrupted thread holds deadlocks. The table is a fixed array of atomics and the reader does nothing but loads — no allocation, no lock, and no call into anything that takes one.

Lifetime — the hazard that actually blocked it. A CompiledMethod's buffer is unmapped on drop and, in that function's own words, "the address is then reusable by the next alloc_executable". An entry that outlived its buffer would eventually match a PC belonging to different code, and the handler would resume execution at a stale address inside a live method. That is not a crash; it is silent, arbitrary control flow. Two things close it: Drop calls implicit_null::unregister_range beside the unregister_jit_method_name that exists for exactly the same reason, and slots are never reused — retiring stores 0 and leaks the slot, because reuse would let a reader that has already matched fault_pc read a recover_pc that a concurrent re-registration had since overwritten. Exhaustion declines: the site keeps its explicit check and declined counts it, so the feature turns itself off rather than turning unsound.

Mis-recovery. A genuine backend bug also faults inside compiled code, and silently resuming from one would convert a diagnosable crash into corrupted state. Recovery requires all of: a memory-access fault; an si_code saying si_addr is an address at all rather than a union member left over from a kill -SEGV; a faulting address inside the null page; and an exact registered PC, not merely one inside some compiled method's range. The last two are what separate "a null receiver reached a load we chose not to guard" from "compiled code dereferenced garbage" — a wild pointer does not land in the first page.

Fail-closed, twice, because the elision is far from the thing it depends on

The compiler does not trust its own source. bind_implicit_null_recovery decodes the bytes at the site it declined to guard and requires MOV r32, [RAX + disp32] with disp32 inside the same null-page constant the handler screens on — so the two agree by construction rather than by two people remembering the same number. A second backstop fails any compile that reaches the end with a site still unbound. Both discard the artifact and return the method to the interpreter.

That is more machinery than the elision itself, and deliberately so: the elision happens in one function and the property it depends on — that the next instruction dereferences the receiver, and that the slow path is reached — lives several hundred lines away in the arm that called it. An edit that broke the coupling would not produce a red test, it would produce a crash on a null receiver in production.

Both arms opt in now, and the second one has bought nothing yet

The legacy-cell getfield arm passed false at first, because it emits its GC_FLAGS read only under compact_ref_fields_enabled(). It can now make the guarantee exactly: raw_mode is already false in that branch, so the guard's !raw_mode && compact reduces to compact, and the opt-in is the same expression rather than a second one that has to be kept in step. The arm binds its recovery address where its guarded slow path begins, and that slow path reloads the receiver from its frame slot, so a recovered fault needs no register repair.

It changed no number. On the H2 workload the census reads implicit=285 (compact-arm=285 legacy-arm=0); on a purpose-built megamorphic probe reading a public field off a JDK class, compact-arm=8 legacy-arm=0. The legacy arm is 13 sites against the compact arm's 1,705 on H2, and all 13 had receivers the dataflow already proved.

So the widening rests on "it could carry sites no proof reaches", not on a measurement that it does. That is recorded rather than smoothed over, and the census is split by arm precisely so the claim is falsifiable: if legacy-arm stays 0 across real workloads, this widening is dead and should be withdrawn.

It is not the same as unreachable code — the arm does fire, 13 times on H2. What is unproven is that it ever fires with a receiver no proof covers, and that is a property of workloads rather than of the code, which is why it gets a counter instead of an argument.

Measured

An 800,000-call probe whose receiver is a parameter (so the dataflow proves nothing and the implicit path is the one taken), with five null calls in the middle:

Armcensusanswer
default (off)implicit=0 emitted=1, registered=0 recovered=0sum=5600000 caught=5
=1implicit=1 emitted=0, registered=1 retired=1 recovered=5sum=5600000 caught=5

recovered=5 is the whole feature in one number: five hardware faults, five exact-PC matches, five RIP redirects, five NullPointerExceptions. The 400,000 iterations after the faults still sum correctly, so recovery does not leave the frame damaged, and retired=1 shows the entry withdrawn when the artifact dropped. HotSpot returns the same two numbers.

Note the second row needs CRATONVM_C2_SUPERSEDE=0 to be reached at all: with the default policy the method tiers up before the null calls, the C1 artifact is dropped, and the optimizing tier's own explicit check handles them. That is worth knowing before reading a recovered=0 as a broken feature — it is more often a measurement of which tier owned the method.

Throughput is unchanged. A 120-million-call probe reading a field off a parameter, five interleaved reps of CPU time, CRATONVM_C2_SUPERSEDE=0 so the arm under test is the one that runs: medians 5.68 s on and 5.55 s off, ranges 5.22-5.78 and 5.12-5.87. Read that as no detectable difference rather than as a regression -- the fast path with the flag on is the fast path with it off minus two instructions, so it cannot actually be slower, and the overlap is the host.

Which is exactly what the elision A/B above predicted: an implicit check removes the same TEST/JZ pair the proof-based elision removes, and that pair did not move the clock either. The reason to have this is not speed. It is that the sites where no proof exists are precisely the ones the elision cannot reach, and this is the only thing that covers them -- and that having it built, measured and switchable is worth more than an argument about whether it would have helped.

The soak, 2026-09-02, and the default

It was off pending a soak. The soak ran, it is clean, and it also produced the number that argues against flipping the default. Both halves are recorded because the second one is the useful one.

Correctness. Roughly an hour of continuous execution plus two full regression-suite passes, all with CRATONVM_JIT_IMPLICIT_NULL_CHECK=1:

Armcensusanswer
5M iterations, default tiering, --Xmx 256mregistered=8 retired=8 recovered=0sum=187500000 npes=19532 ✓
3M iterations, CRATONVM_C2_SUPERSEDE=0registered=8 retired=8 recovered=11663sum=112500000 npes=11719 ✓
5M iterations, flag OFF (control)—sum=187500000 npes=19532 ✓
regression suite × 2—87 passed, 0 failed each

The middle row is the one that exercises the mechanism: 11,663 of 11,719 null dereferences were hardware faults translated into NullPointerExceptions, under GC pressure, with the checksum matching HotSpot exactly. The 56 that were not recovered are the ones taken before the method compiled. Every arm exited rc=0. CPU time was 962 s with the flag on against 1001 s off — read as identical on a shared host, not as a win.

Reach — and the correction that matters. This section first estimated the reach from short suite vectors and synthetic probes, and concluded it was "a real set, and a small one". That was wrong, and it was wrong for a reason worth keeping: the probes were too small to compile much, so they measured the JIT's warm-up threshold rather than the feature's reach.

  • RMapGcStress, RJitGc, RStringOps read elided=0 implicit=0 emitted=0 with CALL sites emitted by arm: empty. That empty field is the tell, and it was in the output all along: the arm was not declining, the vectors compile no getfield in this tier at all. A census of a workload that compiles nothing measures nothing.
  • The soak probe had to be built megamorphic to provoke 8 sites, which said more about the probe than about the feature.

Pointed at a real application — the H2 engine, 60,000 batched inserts and 20 sorted full scans over a 3-column table — the same census reads:

Armelidedimplicitemitted
default (all on)14252880
CRATONVM_JIT_IMPLICIT_NULL_CHECK=014250288
CRATONVM_JIT_THIS_NONNULL=010007134
all three off (the old behaviour)001715

Every arm returns the identical answer, matching HotSpot.

So the real numbers are: 1,715 receiver null checks on this workload before any of this work, and 0 after it. The this seed accounts for 425 of the elisions on its own (1425 → 1000 when it is switched off). The implicit check covers 288 sites that no proof reaches — and the two are complementary rather than redundant: with the seed off, 713 sites fall through to the implicit path instead, and only 4 end up with neither.

recovered=0 on this run, because correct code does not dereference null. That is the expected steady state: the implicit check costs nothing until a null arrives, and then it costs a fault instead of a branch.

The default is ON, since 2026-09-02. Opt out with CRATONVM_JIT_IMPLICIT_NULL_CHECK=0.

The engineering recommendation at the end of the soak was to leave it off, and it is worth recording that it was overruled deliberately rather than forgotten. The case for off was never correctness — the soak settles that — it was that none of the three things a default usually rests on were present: the throughput effect is unmeasurable, the reach looked like a handful of sites, and the failure mode is the only silent one in this backend. The middle term was wrong — measured on the H2 engine rather than on probes, this work removes 1,715 receiver null checks and leaves zero, 288 of them reachable only by the implicit path (see the reach table above). The recommendation to leave it off was made on synthetic evidence and does not survive the real measurement; the decision to default it on does. The case for on is that the mechanism is the one thing covering the sites the proof-based elision cannot reach, it has soaked clean across three full suite passes and ~12,000 translated faults, and a feature that is only ever exercised behind an opt-in flag is a feature that decays.

Both readings are defensible. What matters more than which one won is that the kill switch stays, and that anyone debugging an unexplained crash in compiled code knows to reach for it first: =0 restores emit_trusted_oop_receiver_check at both arms unconditionally, registers nothing, and returns a fault in compiled code to the crash reporter exactly as before this existed. Same binary, one run, no rebuild. That is the property that makes a silent failure mode survivable, and it is worth more here than it is anywhere else in this file.

The two things the soak left untested, and what closed them

The soak above ran single-threaded and saw 8 CompiledMethod drops per run. That left the lock-free table untested under concurrent faults, and the lifetime path barely exercised. Both were closed before the default moved, with CRATONVM_JIT_THRESHOLD=1 (so every method compiles immediately, which is what actually produces drop churn) and 128 reader classes behind an interface:

Armcensusanswer
8 threads, no supersede, ONregistered=23 retired=23 recovered=7433sum=61988608 npes=12504 ✓
8 threads, same, OFF (control)emitted=23 registered=0sum=61988608 npes=12504 ✓
8 threads, supersede ON, 200k itersregistered=23 retired=23 recovered=1sum=123985408 npes=25000 ✓

7,433 faults recovered across eight threads at once, with the checksum matching HotSpot exactly on every arm and rc=0 throughout. That is the lock-free table doing concurrent reads against concurrent registration and retirement, which is the shape nothing else had exercised.

registered equalled retired in every arm, in every run, at every scale — 8/8, 15/15, 23/23. The table does not leak entries, which is the accounting half of the lifetime argument.

The correctness half rests on an invariant worth naming, because it is easy to break and nothing else would notice. CompiledMethod::drop retires [entry, entry + buffer.pos()), and registration keys sites off cm.entry + fault_off. Those two agree only because the method entry is the buffer base — driver.rs says so in as many words (let entry_offset = 0; // prologue starts at offset 0), and the OSR-trampoline purge in that same Drop already depends on it. Give the prologue a non-zero offset and every site below the new entry silently stops being retired, which is precisely the stale-entry hazard the whole design exists to prevent.

What is still not stressed, for whoever revisits this: there is no production eviction path to drive drops harder than tier-up does. jit_code_cache_cap_reached refuses new compiles rather than evicting, and CachedMethods::evict_least_used is called from tests only. So drops come from supersede and deopt, and 23 per process is what a compile-everything workload produces. Hammering address reuse beyond that needs a deopt storm.

The 2026-09-02 eight-finding pass — what moved, and what did not

An audit of where the JIT loses to HotSpot produced eight findings; all eight are addressed above. What follows is the measurement, and the residuals that survive it, stated rather than left to be inferred.

Method. ONE binary, every new default flipped off against every one on, arms alternated with the order reversed on alternate reps, -Xmx4g, bench/CratonBench.java one phase per fresh process. Windows workstation, not the Azure bench host, so these are NOT comparable with BENCHMARK.md's HotSpot ratios and are not written there. The statistic is the MINIMUM of each arm, which is the least contaminated one.

The result is NEUTRAL on every CratonBench row, and that is the finding.

phaseall-offall-on
HashMap (10M put/get)9,115 ms8,749 msneutral
Binary Trees (d=18)13,346 ms13,168 msneutral
Matrix 1280²3,289 ms3,191 msneutral
Arithmetic (2B ops)5,768 ms5,804 msneutral
String/Regex (100K)239 ms251 msneutral
Fibonacci(44), Sieve——inside the noise band

Checksums identical on every row of every arm.

An earlier draft of this table claimed 1.62x on HashMap and 1.40x on Binary Trees. Those numbers were real and they were not this branch's. They came from a VM-thread TLAB on ZGC that feat/zgc-jit-tlab-20260902 landed in parallel — and that feature ships OPT-IN (CRATONVM_ZGC_JIT_TLAB=1), because its own author measured it slower in the general case. Enabling it in BOTH arms attributes this branch correctly: HashMap 4,828 ms with these switches off against 4,866 ms with them on. Neutral. The lesson is the one this document already states about control arms — an arm that differs in two features measures neither.

What IS established, and it is not a throughput number. The emitted sequences are shorter, verified by disassembly rather than by a clock: BinTreesClassic.itemCheck is 2,348 bytes against 2,437 with the switches off, fib's optimizing-tier body no longer materialises its constants into frame words or lowers n > 1 through a stored boolean, and a receiver is proved once per block instead of once per field read. Those removals are real and permanent; what this host cannot do is resolve them above a spread that reaches 45% inside a single arm. A quiet Linux bench host is where a 3-5% codegen change becomes measurable, and that measurement has not been taken.

Correctness, which was measured: regression suite 88/88 with every switch on, cargo test -p cratonvm-jit --lib 2,178 passed, jit-api 56, types and gc green.

One finding is only half closed, and this says which half. Finding 4 was "allocation and reference stores in the optimizing tier are helper calls". The reference stores are fixed here (emit_gated_compact_ref_store). Compiled allocation under the default collector was fixed in parallel by feat/zgc-jit-tlab-20260902, which reached dev first and is the implementation that ships: VmHeap::refill_tlab had answered None on the Zgc arm, so a thread TLAB was always empty and the inline bump both tiers emit was dead code. This branch had built the same thing and it was dropped in favour of theirs on the merge — including its answer to the registration problem below, which they express as cratonvm_types:: jit_tlab_registration_required() rather than as a helper-ABI slot. The optimizing tier's OWN bump (emit_inline_tlab_new_ir) stays opt-in: turning it on makes regression-suite vector RJitMapTierDiff SIGSEGV 4 runs in 10, inside VM code, on a reference read back as 0x2800. What was ruled out: the header writes (read out of a disassembly, they match the single-pass sequence field for field) and relocation (4/4 with CRATONVM_ZGC_RELOCATE=0). One real defect was found and fixed on the way — the reserved tail was freed twice, once by Tlab::retire's hook and once by tlab_retire_locked, because ZGC's own ZArenaTlab contains a Tlab — and it is not this one. CRATONVM_JIT_C2_ALLOC_UPGRADE stays opt-in with it, because with that bump off a promoted allocation lowers through the stub's CALL again, which is the downgrade that gate was shut for.

Residuals, in the order they are worth taking.

  1. The operand-stack register cache stays pure-kernel-only, and this pass recommends AGAINST widening it. Its blocker is unchanged — SCRATCH_REGS is [R8, R9] and both are argument registers on both ABIs — and the fix would be a dynamic pool of callee-saved registers the local allocator did not hand out. But two things now argue the payoff does not justify it: the RELOAD half of the round-trip is already elided by slot_mirror (see the array-load page's step 7), so what remains is one STORE per push; and the adjacent change — reserving the home word at push time — shipped a nondeterministic heap corruption on 2026-09-02 and was reverted the same day. A one-store win is not worth a third visit to that code.

  2. Every compiled call still republishes RBP and pushes/reloads the shadow stack. Those buy precise roots, not nothing, and removing them is a GC trade rather than a codegen one.

  3. Nothing compares a C2 body against the C1 body it replaces. (Partly closed 2026-09-10 — see the note at the end of this item.) The policy question is unchanged and deliberately still open — the obvious static metrics both misjudge the good cases, since a bigger body is usually inlining or unrolling and more call sites can be a callee's own calls after its frame was inlined away. What this pass adds is the DATA: with CRATONVM_DBG=jitc, a supersede prints c1=<bytes> c2=<bytes>. What it also does is remove the causes that made a C2 body worse — the tier now has an inline TLAB bump, gated inline reference stores, and a register file. What that instrument then said is below. 2026-09-10, what is now closed of item 3. The acceptance gate no longer judges only by what the tier did. ir_evidence::CompileRecord carries the per-execution cost a compile introduced beside its transform bitset, and is_worth_publishing refuses a body whose priced cost went UP however much it transformed. Evidence is now necessary, not sufficient.

    The prices are the two this crate already reasons in — a blind jit_invoke_dispatch resolves by name at ~175 ns against a direct CALL's ~4 — and the trade they settle is splicing: a spliced frame saves a call, a call the splice strands without a bindable target costs a resolution on every execution. What is NOT modelled is site execution frequency, so a resolution on a cold branch is charged like one in a loop; that errs toward refusing, which is the safe direction, and it is the first thing to fix if the gate is ever measured refusing better bodies.

    This is still not a general C1-vs-C2 comparison. It prices one specific trade because that trade has measured constants; the rest of item 3 stands. [c2-supersede] refused as a cost regression: bodies=N est_ns_per_execution_declined=M is the reading. It came out of a case where a transform's presence was the evidence that published a 3x regression — Objects.checkIndex spliced into ArrayList.get set Inlined, the stranded call was native-shadowed, and the published body ran its probe in 897 ms against the single-pass 338. See internal/performance/c2-splice-checkcast-and-instanceof-20260909.md.

  4. Node is still 48 bytes against HotSpot's 24. Unchanged, structural, and a GC item: see known-issues/perf/perf-bintrees-gap-characterised.md.

What the supersede diagnostic said, and the optimisation it refuted

The c1=/c2= line above was built to gather data, and the first reading off it looked like a finding. CratonBench, nine supersedes: seven republished a body of exactly the same size, an eighth (fib) had c1=?, and only itemCheck changed size (1335 → 2355). Since every publish calls bump_jit_supersede_epoch(), and that epoch is a process-wide counter every Jit invoke-cache entry in every thread is measured against (CachedInvokeTarget::is_stale), seven of nine looked like pure waste. (The epoch is per VM since interpreter round i1 wave 19: JitRealm::supersede_epoch, bump_supersede_epoch, read through SupersedeGate.)

Three things came out of chasing it, two of them negative.

The c1=? was not a broken lookup. fib is the narrow scalar self-recursion shape, and promote_scalar_selfrec_to_ir sends that straight to the optimizing tier on its first background compile — deliberately, because compiling it as C1 first strands recursive frames in the slower body. There was no C1 body to find. The defect was that the diagnostic printed ? for both "no predecessor" and "lookup failed". It now classifies (SupersedeOutcome) and prints outcome=first-publish|unchanged|changed with epoch_bumped=.

Byte equality never fires, and cannot. The seven same-size bodies differ in 0.116%–1.06% of their bytes (6 of 5193 for sieve, 379 of 35686 for Pattern.clazz). They are the same code: a C2 task whose IR pipeline bails — [ir] ir_lower::lower_inner returned None for Pattern.clazz(Z)… — falls back to the single-pass backend and recompiles the same bytecode. But each compile allocates fresh JitInvokeInfo boxes and embeds their addresses as absolute immediates (emit_mov_imm64(ARG_REGS[1], info as *const _ as i64)), so the bodies are equal modulo relocations and unequal as bytes. Detecting "unchanged" would mean building a relocation table for a case that should not be created in the first place.

And the cost it was going to save is not there — though not for the reason first written here. epoch_stale_evictions() counts the invoke-cache entries the epoch actually throws away. Over a whole CratonBench run: 9. Over the regex workload: 0.

That measurement does not generalize, and this document claimed it did. On an H2 test class (org.h2.test.db.TestAlter, 612 compilations) the same counter reads 2,640 — roughly 290x the CratonBench figure, because the cost scales with live call sites and CratonBench has almost none. The original wording, "measured worthless: 9 IC evictions/run", was a micro-benchmark number presented as a property of the mechanism.

The conclusion survives, on different evidence. On that same H2 run the supersede census reads first_publish=0 unchanged=0 changed=75: every publish replaced a genuinely different body, so every bump was owed, and CRATONVM_JIT_SUPERSEDE_EPOCH_SKIP_USELESS would have skipped none of them. The switch stays off because there is nothing for it to skip on a real workload, not because skipping would be cheap.

So the suppression is implemented, correct, and off by default (CRATONVM_JIT_SUPERSEDE_EPOCH_SKIP_USELESS=1). Two of the three outcomes provably cannot invalidate anything — first-publish has no predecessor, and the interpreter's negative "no compiled body" memo is driven by jit_cache_generation, which JitCache::put bumps on every publication — but safe and worthless is not a reason to move a default.

The residual worth having is the one this uncovered by accident: a C2 task that declines after entering the IR pipeline still recompiles via single-pass, republishes an equivalent body, and pays an invalidation. The compile is the expensive part, not the epoch — timed at 36 ms across 6 such tasks in one CratonBench run, against 2 ms for the 3 that produced an IR body. That residual is now fixed; see below.

The deferred-new retry was spent blind

Naming the decline routes ([ir] IrBuilder::build refused at ir.rs:N, [ir] ir_lower::lower_inner refused (<reason>)) moved the diagnosis twice.

The residual above was written as "bails in ir_lower". It mostly does not. Five of the six fall-throughs bail one stage earlier, in IrBuilder::build, at the 0xbb arm — a new whose class had no new_info row. That is the documented JitNewSite::Deferred path: the resolver never runs a user ClassLoader.loadClass from inside a compile, so a new of a not-yet-loaded class defers, and the builder refuses the whole method.

That refusal is transient by design, and there is a one-shot memo (note_deferred_new_bail / take_deferred_new_retry) to give such a method one more optimizing attempt once the class loads. The grant never checked whether it had. It flipped its 0 to 1 on the next supersede attempt regardless, so on CratonBench each of the five java/util/regex/Pattern methods bailed twice — once for real, once on a retry spent while the class was still unloaded — and then had no retry left for the moment it did load. The doc comment even described the failure ("a class that is still not loaded on the retry bails again") without treating it as one.

The memo now records the deferred sites as (holder_class_id, cp_idx) and the grant asks the resolver whether they resolve now; if not it holds the retry rather than burning it. Measured on CratonBench:

beforeafter
C2 tasks that fell through to single-pass6 (36 ms)1 (6 ms)
C2 tasks that produced an IR body3 (2 ms)3 (2 ms)
supersede publishes with a changed body83
deferred-new retries held / spent0 / 65 / 1

The gate discriminates rather than refusing everything — it held five and granted one — and lowered=3 is unchanged, so no method lost its optimized body. All seven CratonBench checksums are identical.

This is not a throughput claim. The saving is ~30 ms of background compile CPU per run; the benchmark wall times moved by more than that in both directions, which is run-to-run noise on this host, not a result. CRATONVM_JIT_DEFERRED_NEW_RETRY_BLIND=1 restores the blind grant.

Holding a retry nobody offers again is the same as spending it

Holding was only half of it. A method that bailed on an unloaded class already has a body, so nothing ever compiles it again — and the retry door is only ever walked by the method being compiled at that moment. The first sweep was placed on the compile door and re-offered nothing: re_offered=0 against held=21 on CratonBench, and on a fixture built to load the class after the bail it held four times and never came back.

The event that can change the answer is a class definition, and the place to observe it is ClassManagerWriteGuard::drop — after the write lock is released, beside drain_pending_class_hooks, which is there for the same reason. The sweep now runs from there across every live VM, and costs one relaxed load (held_deferred_new_count) when nothing is held.

bench/DeferredNewReoffer.java is the fixture that separates the two: the new sits on a branch warmup never takes, so the class is still unloaded when the hot method is compiled, and a later touch() loads it.

BLIND=1 (blind grant)held + re-offered
after the IR build bailsretry spent immediatelyretry held
second compilesingle-pass again, 2492 bytes—
when the class loadsnothing left to offerre-offered
final bodysingle-pass, 2492 bytesIR, 1495 bytes

Same checksum on both arms. This one is a capability change rather than avoided waste: make reaches the optimizing tier, which under the blind grant it could not. The size drop is the emitted body, not a timing.

Once per collector, because one run per arm is not a measurement

The collector is a variable this fixture has no business depending on, which is the reason to check rather than assume. Five runs per collector, and the same fixture under CRATONVM_JIT_DEFERRED_NEW_RETRY_BLIND=1 as the control:

collectorarmedre-offeredreached an IR bodycontrol: IR body
ZGC (default)5/55/55/50/3
G15/55/55/50/2
Generational4/54/54/40/1
Serial4/54/54/40/1
Parallel5/55/55/50/1

Every one of the 25 runs produced checksum -353614574. Every re-offer that happened produced an IR body (1495 or 1502 bytes against single-pass 2492); under the blind grant, no armed memo on any collector ever reached one. So the mechanism is collector-independent, and the control attributes the difference to the grant rather than to anything else that moved.

The fixture is timing-sensitive, and a 0 from it is not a regression. Arming requires make to be compiled by the C2 door before the C1 door reaches it, and which door gets there first varies run to run: a single G1 run during this sweep armed 0 times, and five consecutive runs immediately after armed 5/5. Read this fixture over at least five runs. make contains a new, so once the C1 door has it, c2_upgrade_would_engage refuses it without CRATONVM_JIT_C2_ALLOC_UPGRADE=1 — which is also how to force the arming path deterministically when bisecting.

One measurement trap worth recording, because it cost a wrong conclusion here first: probing for the IR body by grepping the exact literal len=1495 reported Generational at 2/5 when the true figure was 4/4. The re-offered body is 1495 or 1502 bytes depending on inlining, and an exact-size probe reads a body that got 7 bytes bigger as no body at all.

Why a method falls through: the refusals could not be counted

bailout.rs's own module doc names the gap: of the three ways the compiler says "I cannot compile this", Option::None from IrBuilder::build and ir_lower::lower_inner "carries no reason at all, so the per-method compiler report the review asks for (admitted/bailout counts by reason) cannot be produced." It could not, and nothing said so out loud — the per-compilation record has carried an empty bailouts array since it was added.

Measured before touching anything: over CratonBench, all 105 compilations reported bailouts:[], including the 17 that fell through to single-pass. The process-wide category counters were moving the whole time, which is what made the hole hard to see — the totals looked alive while every per-method row was blank.

Two halves were missing, and both are the same one-line split verify_or_bail already documents ("attribution, not duplication"): record_bailout owns the process-wide counters, metrics::note_current_bailout attaches the same bailout to this method. ir_lower::refuse did the first and not the second; ir::ir_build_bail did neither.

With both wired, on org.h2.test.db.TestAlter (612 compilations, 50 fall-throughs) 50 of 50 now name a reason, where 6 did before:

reasonphasecount
unsupported_shapebuild38
unsupported_opcodebuild6 — five 0x53 (aastore), one 0x5c (dup2)
unallocated_valuelower4
code_buffer_exhaustedlower2

Read with CRATONVM_JIT_METRICS=1 CRATONVM_JIT_METRICS_OUT=<path>, one JSON object per compilation.

And ranked by site: one refusal is three quarters of them

unsupported_shape was 38 of 50 with the site living only in a debug line nothing aggregates — 38 identical rows saying "the builder refused", naming nothing to fix. The site now rides in the bailout's context, which turns that into a work list. On org.h2.test.db.TestAlter, 44 build refusals:

refusalcountshare
ir.rs:7150 — an invoke pc with no invoke_info entry3375%
opcode 0x53 (aastore)511%
ir.rs:6888 — new with no new_info (the deferred-class path)36%
ir.rs:7094 — invokespecial, neither lowering applies24%
opcode 0x5c (dup2)12%

Three quarters of everything the IR builder turns away on a real workload is a single line: self.invoke_info.get(&pc) answering None. Its neighbour at 7094 documents why that concentrates so hard — "one non-emittable invoke elsewhere in the method discarded the whole map" — so a single unlowerable call site refuses every call site in the method, and with it the method. That is the next thing to fix in this tier, and it is one map rather than a list of opcodes.

By contrast the missing opcodes (aastore, dup2) are 6 of 44 together: real, but not where the methods are going.

What the census then said: a block-placement defect, not a sizing one

The investigation started from the hypothesis that large methods fail on code buffer capacity. That is real but rare — 2 of 50. The dominant lowering refusal is unallocated_value: a value emitted after its own use.

StringUTF16.compress   n21 (Call)    at position 15, used by n34 (Return) at 12
Pattern.range          n51 (Cmp(Ne)) at position 51, used by n57 (If)     at 21

verify_data_locations models emission as blocks in index order, so a use at 21 and a def at 51 means the definition's block is laid out after its user's. ir_schedule's own module doc states the opposite as invariant 1 — the layout "keeps every definition before every use, so no live range inverts" — so this is a violated invariant, not a missing feature. Fixed 2026-09-04; see below, where the per-method effect is also stated more carefully than it was here: it costs String.equals and StringLatin1.equals one of their two compilations each, not the optimizing tier outright.

Not caused by the 2026-09-02 switches: CRATONVM_JIT_IR_FUSED_BRANCH=0 and CRATONVM_JIT_IR_LINEAR_SCAN=0 each still produce exactly 3 on CratonBench. Left open — correcting global code motion is a change to every compiled method, and it wants its own branch and its own per-collector sweep.

The block order was creation order, and nothing made it a reverse postorder

ir_lower emits schedule.blocks front to back, so block index order is emission order. Block indices are assigned in creation order — whatever order Step 1 happened to walk control nodes in — and nothing turned that into a reverse postorder. A block could therefore be emitted before a block that dominates it, and a value read before the instruction that defines it.

verify_data_locations catches exactly that and refuses the compile, so it was never wrong code. It was lost compiles, silently, and until the bailout attribution above it could not even be counted.

The machinery to fix it was already present and switched off. layout_blocks produces a DFS layout whose stated property (invariant 2 of ir_schedule's module doc) is "for every edge u → v reachable from the entry, pos(u) < pos(v) unless the edge is retreating" — and a dominator is a DFS ancestor, so an RPO layout places it first and def-before-use follows. But it runs only under ScheduleOptions::layout_hot_paths, which is false in the production pipeline: schedule() is schedule_with_options(graph, &ScheduleOptions::default()).

So when hot-path layout is off, the blocks are now still laid out — just without the frequency priority. layout_blocks_rpo is dfs_layout(blocks, None) behind the same validate_order, and a validation failure keeps creation order and compiles anyway, exactly as the hot-path arm already did.

CRATONVM_JIT_IR_RPO_LAYOUT=0default
CratonBench: unallocated_value30
CratonBench: fell through1714
H2 TestAlter: unallocated_value40
H2 TestAlter: code_buffer_exhausted20
H2 TestAlter: fell through5044

Three H2 methods gain an optimizing-tier body they previously never got — FutureTask.awaitDone, MVMap.<init>, TransactionStore.getEntryId — and String.equals / StringLatin1.equals go from 1-of-2 compilations wasted to 2-of-2 lowered. The code_buffer_exhausted pair disappearing was not predicted: a better block order emits less code, and those two methods were the ones on the edge of their estimate.

No throughput claim. Six interleaved CratonBench rounds alternate in sign (on faster, off faster, on faster) while the absolute totals drift ~50% across rounds, which is this host under load, not a result. Checksums are identical in every run.

Validated per collector, because reordering blocks moves oop-map and safepoint positions in every compiled method: regression-suite 90/90 on ZGC, G1 and Generational; cratonvm-jit 2225 passed.

Summary table

FeatureDefaultOpt-out / opt-in var
Background compilation pipelineONCRATONVM_BG_COMPILE=0
C1→C2 supersedeONCRATONVM_C2_SUPERSEDE=0
IR backend (int/ref/long/FP, non-virtual calls)ON (bounded shape)see ir_compatible()
IR backend for virtual/interface callsONCRATONVM_JIT_IR_CALL_VIRTUAL=0
Back-edge OSRON, threshold 1000CRATONVM_JIT_OSR=0
OSR for newarray-containing methodsONCRATONVM_OSR_NEWARRAY=0
Guarded-inline getfield (region-bounds-checked)ONCRATONVM_JIT_GETFIELD_HELPER=1
Raw (unguarded) inline getfieldoffCRATONVM_JIT_INLINE_GETFIELD
Compact reference-field layoutONCRATONVM_COMPACT_REF_FIELDS=0
Compact inline putfieldON (rides on layout)—
Legacy 16-byte inline putfieldoff (and dead under default layout)CRATONVM_JIT_INLINE_PUTFIELD
Deopt framework (DEOPT_REAL)ONCRATONVM_DEOPT_REAL=0
Guard-surviving scalar replacementoffCRATONVM_SCALAR_DEOPT
Self-recursive inline stack guardONCRATONVM_JIT_INLINE_SELF_GUARD=0
BC crypto/{engines,io,modes,paddings} + math/ JITallowed—
BC blanket ban (asn1/, util/, ...)still bannedCRATONVM_JIT_ALLOW_PACKAGES
Precise JIT stack mapsONCRATONVM_NO_PRECISE_JIT_MAPS
IR-tier register residency (GP + FP files)ON since 2026-09-02, phis includedCRATONVM_JIT_IR_LINEAR_SCAN=0, CRATONVM_JIT_IR_PHI_RESIDENCY=0
IR-tier constants as immediatesONCRATONVM_JIT_IR_CONST_IMM=0
Skip the supersede-epoch bump when it cannot invalidate anythingoff (nothing to skip: H2 shows 75/75 publishes genuinely changed)CRATONVM_JIT_SUPERSEDE_EPOCH_SKIP_USELESS
Deferred-new retry held until the class resolvesONCRATONVM_JIT_DEFERRED_NEW_RETRY_BLIND=1
Reverse-postorder block layout (def before use)ONCRATONVM_JIT_IR_RPO_LAYOUT=0
IR-tier fused compare-and-branch, trampoline-free branchesONCRATONVM_JIT_IR_FUSED_BRANCH=0
IR-tier fused compare reads its operands in place (register, frame slot or folded immediate)ONCRATONVM_JIT_IR_CMP_IN_PLACE=0
IR-tier x + k / x - k as one LEAONCRATONVM_JIT_IR_ADD_LEA=0
IR-tier receiver-guard CSE (once per receiver per block)ONCRATONVM_JIT_IR_RECEIVER_GUARD_CSE=0
IR-tier redundant-read elimination (same cell, same heap, same block)on since JIT round 13 (2026-09-28): large on the probe written for it (0.627x on FieldLoop.sumWide with the memory-edge hoist off), neutral to slightly positive elsewhere, correctness-clean on the whole probe battery; c2-the-loop-body-is-mostly-code-it-never-runs-20260911.md §8, docs/internal/jit-proposals/jit-optin-switch-triage-r13-20260928.mdCRATONVM_JIT_IR_LOAD_CSE=0 (kill switch)
LICM may hoist a read of a maybe-null base out of a loop whose constant trip count proves the body runsON since round 11 wave 5 — probe battery and CratonBench outputs identical with it on and off, CratonBenchC2 1152 -> 951 ms (single run); the hoisted read deopts with the first-iteration frameCRATONVM_JIT_IR_LICM_HOIST_COUNTED=0
Redundant-read elimination steps over a store that provably cannot alias the cell readon since JIT round 13, with the load CSE it extends (measured neutral; §11.4 of the same page, and the round-13 opt-in triage)CRATONVM_JIT_IR_LOAD_CSE_ALIAS=0 (kill switch)
IR-tier gated inline reference storesON where a collector publishes a planCRATONVM_JIT_IR_GATED_REF_STORE=0
IR-tier inline TLAB bump for Op::Newoff — the sequence has a defect RJitMapTierDiff reproduces 4/10; see ir_inline_tlab_enabledCRATONVM_JIT_IR_INLINE_TLAB=1
Thread pointer fetched from a TLS mirror (both tiers)ON where the probe succeedsCRATONVM_JIT_TLS_THREAD_FETCH=0
One post-call sentinel compare (both tiers)ONCRATONVM_JIT_MERGED_CALL_SENTINEL=0
ZGC VM-thread TLAB (the chunk the inline bump bumps)on since 2026-09-18 (JIT review round 9 wave 5, measured: bintrees ~6x, ZgcTlabStress faster, PutScale level)CRATONVM_ZGC_JIT_TLAB=0 turns it off
Receiver-type + call-site profile recordingONCRATONVM_TIER_PGO_RECEIVERS=0 (branch/back-edge recording stays behind CRATONVM_TIER_PGO)
Guarded virtual inlining on receiver profilesONCRATONVM_JIT_GUARDED_VIRTUAL_INLINE=0
Gated inline reference storesON where a collector publishes a planCRATONVM_JIT_GATED_REF_STORE=0
Operand-stack register cache beyond pure kernelsoff (see the section above for the ARG_REGS collision)CRATONVM_JIT_OPERAND_CACHE=1
Optimizing tier for allocation-bearing methodsoff — the tier's own bump is off, so a promoted allocation would lower through the stub's CALL againCRATONVM_JIT_C2_ALLOC_UPGRADE=1
this seeded non-null at method entryONCRATONVM_JIT_THIS_NONNULL=0
getfield receiver null-check elisionONCRATONVM_JIT_RECEIVER_NULL_ELIM=0
Implicit null check (fault + signal translation)ON — 288 sites on H2 that no proof reaches; kill switch is the first move on any unexplained compiled-code crashCRATONVM_JIT_IMPLICIT_NULL_CHECK=0
IR-tier nested-lock elision (relock on deopt through the split monitor entry)ON since round 11 wave 17CRATONVM_JIT_IR_NESTED_LOCK_ELIM=0
Inline inflated-monitor enter/exit arm (both tiers, before the helper)ON since round 11 wave 17CRATONVM_JIT_INLINE_INFLATED_LOCK=0
IR-tier inline Integer.valueOf box outside the cache rangeON since round 11 wave 17CRATONVM_JIT_IR_VALUEOF_INLINE_BOX=0
Callee-compiled bodies promoted (back-edge word, outside publishes reported to the tiered manager)ON since round 11 wave 18; cost not measurable on binary-trees or SyncCostCRATONVM_JIT_CALLEE_PROMOTION=0
Deferred compiled new memoises its CP resolutionON since round 11 wave 18CRATONVM_JIT_NEW_CP_SITE_MEMO=0
Superseding a body retargets the machine-code inline caches that still publish itON since round 11 wave 19CRATONVM_JIT_IC_SUPERSEDE_RETARGET=0
An OSR exit the frame cannot resume raises InternalError instead of replayingON since round 11 wave 19CRATONVM_JIT_OSR_LOUD_UNRESUMABLE=0
Monitor lazy park (a contended enter parks up to N us as a running mutator before the GC-blocked park)off; not measurable on ThreadChurn in round 11CRATONVM_MONITOR_LAZY_PARK_US=<us>
Helper compiled-entry calls up to 16 ABI words (wide callees no longer fall back to the interpreter)ON since round 12 wave 2CRATONVM_JIT_HELPER_WIDE_ENTRY_CALLS=0
Compiled inline inflated-monitor spin (both tiers)ON since round 12 wave 2CRATONVM_JIT_INLINE_INFLATED_SPIN=0
Shared per-VM megamorphic table probed in machine code; per-class vtable-column cells; class cell first at megamorphic sitesON since round 12 waves 3 / 4 / 6 (R12Mega4OneSite mega24 138 -> 37 ms)CRATONVM_JIT_HASHED_STUB_MEGA_TABLE=0, CRATONVM_JIT_MEGA_CLASS_SLOTS=0, CRATONVM_JIT_MEGA_CELL_FIRST=0
One stack walk per compiled implicit exception; compact retained compiled framesON since round 12 waves 4 / 5 (R12ExcCost 33.4 -> 2.7 s, HotSpot 2.6 s)CRATONVM_JIT_TRAP_CAPTURE_ONE_WALK=0, CRATONVM_JIT_COMPACT_COMPILED_BACKTRACE=0
Replay rule (loop extent + first arrival) at compile time and at every call door; unsound re-runs refused or retiredON since round 12 waves 3-4CRATONVM_JIT_SINGLE_PASS_REPLAY_CHECK=0, CRATONVM_JIT_DOOR_UNSOUND_RERUN_RETIRE=0
Every body withdrawal demotes the method's tier (no method stranded at C2 over an empty cache)ON since round 12 wave 4CRATONVM_JIT_WITHDRAWAL_DEMOTES=0
A define withdraws inliners by class name only when it can replace a classON since round 12 wave 5 (400 same-named defines 2 739 -> 56 ms)CRATONVM_JIT_DEFINE_SCOPED_WITHDRAWAL=0
IR-tier exact-allocation receivers spliceable; splice replay fence relaxations; planner store fence and rebuildON since round 12 waves 4-6CRATONVM_JIT_IR_EXACT_RECEIVER_SPLICE=0, CRATONVM_JIT_IR_SPLICE_FENCE_*=0, CRATONVM_JIT_IR_SPLICE_REBUILD=0
IR-tier constant If lowered as one edge and pruned in the optimizerON since round 12 waves 4 / 6CRATONVM_JIT_IR_CONST_IF_FOLD=0, CRATONVM_JIT_IR_CONST_IF_PRUNE=0
Single-pass this.data field-array hoist; ref-hoist slots in the in-loop oop mapsON since round 12 wave 4CRATONVM_JIT_FIELD_ARRAY_HOIST=0, CRATONVM_JIT_REF_HOIST_OOP_MAPS=0
Trace-only safepoint id at the post-call stubs (callee re-runs keep the caller's inlined frames)ON since round 12 wave 6CRATONVM_JIT_TRACE_ONLY_SP_ID=0
FFM: close retires verdicts and frees confined arenas; scope / read-only / byte-order / alignment checks; downcall session acquireON since round 12 waves 3-6, both modes; shared-arena free stays offCRATONVM_FFM_* (see docs/internal/fixed-bugs/r12-ROUND-SUMMARY-CLOSED-20260928.md)
IR-tier guarded receiver splice (If(ExactClassIs) around a spliced virtual body); post-LICM read dedupON since round 12 wave 7CRATONVM_JIT_IR_GUARDED_RECEIVER_SPLICE=0, CRATONVM_JIT_IR_LICM_HOIST_DEDUP=0
Megamorphic: same-key IC refill, superseded-body rollback and reclaim in the shared table, gate fast entryON since round 12 wave 7 (no measurable gain on R12Mega4OneSite: its calls already stay in compiled stubs)CRATONVM_JIT_IC_SAME_KEY_REFILL=0, CRATONVM_JIT_MEGA_TABLE_RECLAIM=0, CRATONVM_JIT_MEGA_GATE_FAST_ENTRY=0
Call doors: an unsound re-run raises instead of re-running; synchronized additive resume; synchronized deopt with scalar-replaced objects resumesON since round 12 wave 7CRATONVM_JIT_DOOR_UNSOUND_RERUN_RAISES=0, CRATONVM_JIT_DOOR_SYNC_ADDITIVE_RESUME=0, CRATONVM_DEOPT_SYNC_VIRTUALS_RESUME=0
FFM: offset-form element fast path in compiled code; FFM upcall stubs (libffi closures); segment VarHandle and reinterpret checks; ResourceCleanup run on closeON since round 12 wave 7, both modesCRATONVM_JIT_FFM_OFFSET_ACCESS=0, CRATONVM_FFM_UPCALL_STUBS=0, CRATONVM_FFM_SEGMENT_VH_CHECKS=0, CRATONVM_FFM_REINTERPRET_CHECKS=0, CRATONVM_FFM_CLOSE_RUNS_CLEANUP=0
Math.fma / StrictMath.fma inline VFMADD213SD/SS (FMA3 hosts) and an exact software fma elsewhere (natives, helpers, Vector API lanes, Lucene kernels)ON since round 12 wave 8CRATONVM_JIT_FMA_INLINE=0, CRATONVM_MATH_FMA_EXACT=0
Monitors: quiet release, lost-CAS backoff (Rust spin and compiled inline spin), the entry count on its own cache lineON since round 12 wave 8 / close-out (the layout has no switch)CRATONVM_MONITOR_QUIET_RELEASE=0, CRATONVM_MONITOR_SPIN_BACKOFF=0, CRATONVM_JIT_INLINE_SPIN_BACKOFF=0
IR-tier entry fast return (a base case returns before the frame is built) and dominated self-call sample elisionON since round 12 wave 8CRATONVM_JIT_IR_ENTRY_FAST_RETURN=0, CRATONVM_JIT_IR_SELF_CALL_SAMPLE_DOMINATED=0
A compiled caller's synchronized INSTANCE callee enters its compiled wrapped body at the bytecode-callee door (was interpreted on every call)ON since the round 12 close-out (~30% on sync-method-1t; still ~10x HotSpot, see r12w8-orch-synchronized-instance-calls-and-bigdecimal-are-slow-20260927.md)CRATONVM_JIT_DOOR_SYNC_INSTANCE_BODY=0
JVMS 5.4.6 selection errors (AbstractMethodError, conflicting defaults) raised by compiled virtual/interface calls and Method.invoke, which ran the lenient bodyON since the round 12 close-out, both modesCRATONVM_NATIVE_INVOKE_STRICT_SELECTION=0 (--compatible only)

The tiering inversion — status, 2026-09-03

The audit that opened this work led with: the optimizing tier compiles slower code than the baseline tier, on every loop measured. Where that stands, with the measurement rather than an argument.

Method, because the first two attempts at this were wrong and the method is what fixed them: release binary; arms interleaved run-by-run rather than in blocks; and a second arm of each configuration, identical to the first, so the spread between a config and itself is the noise floor. Fifteen reps, CPU time, medians. CRATONVM_C2_SUPERSEDE=0 pins the baseline tier, CRATONVM_JIT_FORCE_C2=1 the optimizing one, and compiles: c1=N c2=M in the method-stats line witnesses that the arms really differ.

loop shapebaselineoptimizingverdict
int arithmetic2.422.60 / 2.25within noise (control spread 14%)
field read0.86 / 0.831.39 / 1.41optimizing ~1.65x SLOWER
long arithmetic1.771.65 / 1.81within noise (10%)
double arithmetic8.588.61 / 8.61identical (0.3%, ±1% ranges)
array sum0.650.67 / 0.64within noise (5%)

So "every loop" is no longer true — one shape of five is. The field-read loop reproduces cleanly: both control pairs agree (3.5% and 1.4%) while the groups differ by 65%, and the medians are separated by far more than either spread.

Re-measured 2026-09-10: still there, at 1.594x. The table above is a 2026-09-03 snapshot and seven register flags went default-ON after it, so the number was retaken rather than carried forward — probes/FieldLoop.java sum, tools/tier-ab/tier-ab.sh, 503 ms baseline against 812 ms optimizing over a 2.6% floor. The register work that landed in between did not close it, and widening the GP file does not either. See docs/internal/performance/c2-the-gp-register-file-is-not-the-binding-constraint-20260910.md.

Two things it is not. The optimizing tier emits less code for that method (1,030 bytes against 1,579), so it is not bloat; and getfield helper calls is 0 at runtime in both arms, so it is not an out-of-line call per iteration. What has not been examined is the instruction-level shape of the loop body at each tier. That is where the next person should start, with CRATONVM_DBG=jit-disasm CRATONVM_DBG_JIT_DISASM=TierProbe.fieldloop.

The disassembly, which settles it: neither candidate was the cause

Both tiers' fieldloop bodies, extracted between the backedge and its target (CRATONVM_DBG=jit-disasm CRATONVM_DBG_JIT_DISASM=TierProbe.fieldloop).

The baseline tier unrolls 4x and keeps every loop-carried value in a callee-saved register — i in r12d, n in r14d, sum in r13, this in r15:

219: cmp r12d,r14d          ; counter and bound, both registers
226: mov rax,r15            ; receiver, from a register
229: mov ecx,[rax+0Fh]      ; GC_FLAGS — and the implicit null check
239: movsxd rax,[rax+10h]   ; the field
284: add eax,ecx            ; sum, in a register
290: add r12d,1

Note what is absent: no TEST RAX, RAX; JZ. The receiver null check is already gone here, elided by the this seed, and mov ecx,[rax+0Fh] is the implicit check that replaced it.

The optimizing tier does not unroll, and round-trips every loop-carried value through the frame on every iteration:

1aa: mov rax,[rbp-58h]      ; receiver reloaded from the frame
1ae: test rax,rax           ; the null check
20d: mov [rbp-88h],rax      ; spill the loaded field
217: mov rcx,[rbp-88h]      ; reload it
220: mov [rbp-90h],rax      ; spill sum
227: mov rbx,[rbp-78h]      ; reload sum
235: mov [rbp-98h],rax      ; spill counter
23c: mov r12,[rbp-80h]      ; reload counter
243: mov rcx,[rbp-60h]      ; reload bound

That is roughly eight extra memory operations per iteration against a body whose real work is one load and one add. The null check is a rounding error beside it, which is why removing it moved nothing useful — and why the tiering inversion on this shape was never a null-check problem.

The allocator is not absent — it is losing. With CRATONVM_DBG_IR_LINEAR_SCAN=1 on this method:

[ir-ls] nodes=23 positions=17 peak_live=11 scan_promoted=9
        resident=3 (fp=0 gp=3) demoted=0 splits=4 scan_spills=1 scan_reloads=1

It runs, and it holds three of nine promoted candidates — against a peak of eleven live values and a file of five GP registers (IR_LOWER_LS_GPRS).

Where the other six go was, until 2026-09-03, misreported. The consumer's skip census read split_or_spilled=5 beside the allocator's splits=4, and the two together said "four candidates lost to live-range splits". They were not. Printing the segment shapes showed four of the five refusals had empty segment lists — nodes the scan produced no interval for, which is every control and memory node in the graph, Start and Proj included — and one genuinely spilled. There was no split value on this method to reclaim.

That miscount cost a day: split residency was designed, built, tested and measured against it, and its engagement counter read zero, which is how the mislabel was found. The census now separates no_alloc, spilled and split_or_spilled, so the next reader gets three numbers that mean three different things.

So the named causes of the residual inversion are, in order:

  1. The optimizing tier does not unroll. The baseline's 4x unroll amortises the counter compare, the backedge and the safepoint poll over four iterations; the optimizing tier pays all three every iteration.
  2. The loop's values are ENTRY PARAMETERS, and entry parameters cannot be promoted at all. Traced 2026-09-03, and it is the end of the chain.

single_use was the largest bucket (13) and looked like the answer: it refuses any value read fewer than twice, counting static graph edges. Printing the shape first — the lesson from the split miscount immediately above — gave:

single_use n3 op=Param(0) static_uses=1 loop_weight=10   <- the receiver
single_use n4 op=Param(1) static_uses=1 loop_weight=10   <- the loop bound

One static use, ten loop-weighted. The rule compares a static count while the definition and the uses sit at different loop depths: this and n are defined once at method entry and read every iteration. CRATONVM_JIT_IR_LS_LOOP_WEIGHT=1 generalises the test to uses_frequency >= 2 x definition_frequency, which reduces exactly to use_count >= 2 at depth 0.

It admits them past that gate and residency does not move — resident=3 either way; they land in no_alloc/spilled instead. The policy was never the binding constraint, because the allocator had already declined them.

MachineModel::pin_entry_params pins every Param to its incoming ABI register, and allocate_linear_scan skips a pinned value outright (regalloc.rs, live.pinned[id]). The ABI registers are caller-saved and are not in IR_LOWER_LS_GPRS (RBX, R12–R15), so an entry parameter can never be promoted into the callee-saved file the loop needs. It reaches the loop through its frame slot, every iteration, by construction.

The baseline tier does the one thing this tier does not — it copies parameters into callee-saved registers in the prologue:

39: mov r15,rsi        ; this -> r15
3c: mov r14,rdx        ; n    -> r14

The prologue copy was built (CRATONVM_JIT_IR_PARAM_COPY=1, default OFF). It works: resident=3 becomes resident=4, param_copies=1, the loop bound gets a callee-saved register. It measures zero — 2.07 s against 2.07/2.08 for the two control arms, which agree with each other to 0.5%, so that is a real zero and not one hidden by noise.

Four candidates, four zeros, and what that finally says

candidateengaged?effect
split residencyno (split_recovered=0)census mislabel; nothing to reclaim
implicit null check portyes (elided=2)~20% worse, then noise
loop-weighted use countyes (params left single_use)none — allocator had already declined them
parameter prologue copyyes (resident 3→4)none

Every one of them was a register-residency or null-check argument, and none of them moved a loop that is 1.6x slower at this tier. The cost is not where any of that reasoning says it is, and the disassembly said so from the start if the instruction counts are read per ITERATION rather than per body:

  • baseline: 178 instructions covering four iterations — about 44 per iteration, because the tier unrolls 4x;
  • optimizing: 95 instructions for one — about 95 per iteration.

A ratio of roughly 2.2x against a measured 1.6x, which is the only account so far that is the right size. The counter compare, the backedge and the safepoint poll are each paid once per iteration here and once per four iterations there, and no amount of register residency changes that.

Unrolling tested: worth a fifth of the gap, not the gap

Tested the cheap way — by removing the advantage from the FAST arm rather than building it into the slow one. CRATONVM_DISABLE_UNROLL=1 turns off the baseline's 4x unroll (both its call sites are in x64/), so if unrolling explains the inversion the baseline should collapse toward the optimizing tier.

armmedian
baseline, unrolled0.73
baseline, same config (control)0.66
baseline, CRATONVM_DISABLE_UNROLL=10.84
optimizing tier1.61

Unrolling is worth about 20% — real, above the ~10% control spread. And it is nowhere near the whole gap: the un-unrolled baseline is 0.84 against 1.61, still 1.9x apart. So the section above overreached in calling instruction count per iteration "the only account of the right size"; it is an account, of about a fifth of it.

What that leaves, and the reconciliation the four zeros needed

The remaining 1.9x is per-iteration work that has nothing to do with unrolling, and the disassembly names it: the optimizing tier round-trips every loop-carried value through the frame, roughly eight memory operations against a body whose real work is one load and one add.

That also explains why four successive fixes measured zero without any of them being wrong. Each addressed ONE value. Removing one of eight memory operations is ~12% of the loop's memory traffic and a few percent of its time — at or under the measurement floor on this host. The four zeros are not evidence that frame traffic is innocent; they are evidence that it cannot be fixed one value at a time.

So the target is the class, not a member of it: the optimizing tier needs loop-carried values to stay in registers as a group, which means the write-through publish (a store at every definition, a load at every publish) and the per-value residency policy both have to give way to something that treats a loop's live set as one decision. That is a larger change than any of the four, and it is the first one whose expected effect is above the noise floor rather than under it.

Confirmed: register residency is 1.57x of it

Tested the same way, by taking the advantage away from the fast arm. CRATONVM_JIT_LOCAL_REGS=0 truncates the baseline's local-colouring pool to empty, so every Java local lives in the frame — the optimizing tier's situation, imposed on the tier that normally wins.

armmedianvs baseline
baseline1.33—
baseline, same config (control)1.27—
baseline, CRATONVM_JIT_LOCAL_REGS=02.041.57x
baseline, no locals and no unroll2.281.75x
optimizing tier3.092.38x

(A busier host than the unrolling table above, so the absolute numbers are larger; only the within-run ratios are being read, and the control pair agrees to 5%.)

Spilling the loop-carried values alone costs the baseline 1.57x — the single largest factor found, and it moves the baseline most of the way to the optimizing tier without touching anything else. Adding the unroll loss brings it to 1.75x of a 2.38x gap: about two thirds of the inversion, with register residency the dominant share and unrolling roughly 1.12x on top.

The residual 1.36x: latency, not volume

Chased, and it is not what the rest of this section assumed. Both tiers were disassembled at MATCHED settings — baseline with CRATONVM_JIT_LOCAL_REGS=0 and CRATONVM_DISABLE_UNROLL=1, so both spill and neither unrolls — and counted:

baseline (matched)optimizing
loop-body instructions10895
distinct frame slots touched2415
memory movs in the body4132
time2.233.01

The optimizing tier does less of everything and takes 1.36x longer. So the residual is not instruction count, not memory-operation count, and not slot count — every volume measure points the wrong way. The "instructions per iteration" framing earlier in this section explains the unrolling fifth and nothing beyond it.

CRATONVM_JIT_KERNEL_REG_LOCALS=0, which makes the baseline's operand-stack scratch cache inert, moved the matched arm not at all (2.23 against 2.23), so that is not it either.

It is a dependency chain. A probe with four INDEPENDENT accumulators (a+=this.fx; b+=this.fx; c+=this.fx; d+=this.fx;) instead of one shrinks the gap from 1.36x to 1.18x, with the two control arms landing on 1.41 and 1.41. Independent work overlaps a stall; it cannot overlap extra instructions. That is the signature of a latency bottleneck, and the disassembly shows the mechanism: the optimizing tier's body is a chain of store-then-load pairs on the same slot two instructions apart —

1f5: mov [rbp-88h],rax      ; store the loaded field
1fc: mov rax,[rbp-78h]
200: mov rcx,[rbp-88h]      ; reload it, two instructions later
207: add eax,ecx
209: mov [rbp-90h],rax      ; and the accumulator goes back to memory

— with the accumulator itself crossing the back edge through the frame, so every iteration waits on the previous one's store.

That pair has since been removed for one shape, and the removal is where this tier's remaining frame traffic was finally counted. A second carry slot lets a consumer take BOTH of its single-use operands in registers instead of one, which deletes exactly the store-and-reload above; widening the rule that says which arm a carried value may cross — from an OP-level allowlist to the NODE-level question the arm actually asks — took the probe set from 1 such carry to 11 and is worth 1.009x against a 0.1% floor on a kernel that has the shape. The census that made possible is the part to read before reaching for this paragraph again: 82% of the candidate sites fail on operand POSITION, not on anything the emitter decides. Full write-up in c2-one-carry-slot-is-the-frame-traffic-ceiling-FIXED-20260910.md.

The exact stall could not be named: this host is a VM without PMU passthrough (perf stat reports <not supported> for cycles and instructions), so store-forwarding latency is the likely mechanism rather than the measured one.

What this changes. It strengthens the register-residency conclusion rather than competing with it: keeping a loop's live set in registers removes the memory round trip and the chain that round trip creates. And it explains the four zeros a second way — shortening a serial chain by one link out of several does not speed it up. Both readings say the same thing: the live set has to move as a group, or not at all.

Moving it as a group is an architecture change, not another heuristic

Attempted, and this is where the incremental route ends. A fifth change — a register-to-register publish, taking the register at a resident definition whenever last_home_store proves one already holds the value, position-checked so any intervening emission falls back to the load — produced byte-identical code on this method: same 1,030 bytes, same 95 loop instructions, same 22 frame loads. The precondition never held. It was withdrawn rather than landed.

That failure is the informative one, because of what the census says alongside it. Residency IS working: resident=3 (gp=3), and rbx, r12 and r13 appear eleven times in the loop body. Three values are genuinely being read out of registers — and the body still has 22 frame loads and is still a store-then-load chain.

The reason is structural. lower_data_node gives every node a frame slot (alloc_slot(id)) and writes it; the residency file is a read cache layered on top of that. So a value costs its store whether or not it is resident, the store-then-load pair survives residency, and no policy change on the cache can remove a store the model emits unconditionally.

So "move the live set as a group" means making the home slot OPTIONAL — a value that lives in a register for its whole range and is named by no deopt frame and no safepoint map should not have a home at all. That is a change to the lowering model, with the deopt and oop-map obligations to discharge for every value that loses its slot, and it is the first item on this list that cannot be tried behind a flag in an afternoon.

The five zeros are the case for doing it properly rather than continuing: split residency, the null-check port, the loop-weighted use count, the parameter prologue copy and the direct publish each addressed a symptom of the frame-slot-first model, and the model absorbed all five.

That change is designed, sized and not built: feature-designs/ir-optional-home-slot.md. It records the three obligations already verified (deopt and safepoints are covered by pinned; references are excluded for oop-map reasons; the read side is 84 cached against 13 direct), the coupling that sets its shape (dropping the store needs a register-to-register publish, which needs the arms to say where their result is, and 50 of them say it only by writing memory), and a fail-closed route for the one hazard — slot_of on a homeless value fails the compile rather than reading a stale word, so the first run names the sites to convert instead of a whitelist being guessed.

So the recommendation stands and now has a number behind it. Getting a loop's live set into registers as a group is worth about 1.57x on this shape. That is an order of magnitude above the measurement floor that swallowed all four single-value fixes, which is exactly why it is the one worth building.

The loop-weight rule is kept, default OFF, because it is a correct generalisation that will matter once the parameters can be promoted at all — and because its own measurement is on record as not moving this workload.

What it is not: splits, register pressure at the file's edge, code size (the optimizing tier emits less code here), or the null check.

Neither is the null check, and neither is code size — the optimizing tier emits less code for this method (1,030 bytes against 1,579).

The named candidate was tried, and it made things worse. Porting the receiver null-check elision to the optimizing tier is built and switchable (CRATONVM_JIT_IR_THIS_NONNULL=1, default OFF). It is correct and it engages — seeded=9 elided=2 emitted=0 against 0/0/2 with it off, same answer — and on the very loop above it is ~20% SLOWER with the check removed: medians 1.78/1.93 on against 1.49/1.61 off, two replicate pairs, within-config spread 8%, same direction both times.

At reps=1, where the loop barely runs, the arms are 0.19 against 0.18, so the per-block seed costs ~0.01s and the 0.3s is in the emitted code, not the compile.

Deleting two instructions cannot slow a loop by 20% on its own. What that result actually says is that this loop body is dominated by something layout- or branch-structure-sensitive, and removing a never-taken forward JZ moved it. That is now the most promising lead for the residual inversion, and it is why the switch is kept rather than the change reverted: it is the smallest known perturbation that moves this loop by 20%, which makes it the cheapest handle on whatever the real cause is.

2026-09-11 — RETIRED: the anomaly no longer reproduces. Re-measured on the current tree with tools/tier-ab/flag-ab.sh (7 rounds, interleaved, same-config control, checksum 1200150000 on every run), CRATONVM_JIT_IR_THIS_NONNULL on probes/FieldLoop.java sum is 0.981x — UNMEASURABLE inside a 4.6% floor. It is not 20% slower; it is not measurably anything. Whatever arrangement produced the 1.78/1.93-against-1.49/1.61 medians is gone, most likely with the phi-copy change in docs/internal/performance/c2-the-phi-copy-staging-register-20260911.md §5.

So this paragraph's standing recommendation — keep the switch because it is the cheapest handle on the residual inversion — no longer holds: there is no longer an effect for it to be a handle on. Keep the switch on its own merits (it is correct and it elides real checks), not as a lead.

The layout theory it invited was tested and did not survive either. docs/internal/performance/c2-the-loop-body-is-mostly-code-it-never-runs-20260911.md counts this loop at 412 bytes spanned, ~122 executed, the rest cold code emitted inline; CRATONVM_JIT_IR_POLL_OUTLINE removes the largest of those blocks (229 bytes) and one taken branch per iteration, and it measures 0.999x — UNMEASURABLE. A well-predicted branch over cold bytes costs approximately nothing, because fetch follows the predicted target rather than the linear address. What actually moved this loop was removing WORK: see the same document's §3.

One structural asymmetry is worth naming as a candidate: the receiver null-check elision described above — the this seed and CRATONVM_JIT_RECEIVER_NULL_ELIM — is single-pass only. Both arms it touches are in x64/bytecode_walk.rs; the optimizing tier still emits TEST RAX, RAX; JZ at every getfield. On a loop whose entire body is one field read, that is not obviously small.

A note on the apparatus, because it cost two wrong answers. The first run compared blocked arms with no same-config control and reported the optimizing tier slower on three shapes; the second showed the same configuration disagreeing with itself by 13–22%, which was larger than every effect claimed. A timing arm on this host without a same-config control is not a measurement. The C1/C1B and C2/CTRL pairs above exist for that reason and should be kept in any re-run.

The census refuted the design, and named the site that was worth fixing

Built the instrument before the feature, and it is the reason there is no sixth zero to report. plan_register_residency now counts, over the values it actually gave a register to, how many could lose their home word:

[ir-ls] resident=3 (fp=0 gp=3)
[ir-ls] home: droppable=0 blocked_deopt=1 blocked_phi=2 safepoints=16

The census in that code block no longer exists, 2026-09-10. It kept asking the question it was written for — "promoted, named by no safepoint at all, and not a phi" — while ir-reg-authoritative, ir-drop-home and ir-drop-phi-home widened the rule the emission uses to "named by no REACHABLE frame state". By 2026-09-10 it printed droppable=0 on a compile of FieldLoop.sum that dropped three homes and skipped five stores. It is replaced by [ir-ls] homes: dropped_values= (the outcome) and [ir-ls] homes kept: switch/deopt/type/op (the per-cause remainder), the second computed from home_dropped itself under an accounting identity so it cannot fall behind again. Read the block below as the 2026-09-04 record it is.

Zero of three, on the loop this whole section is about. The design's safety argument — that pinned covers every deopt-named value, so a promoted value is named by no frame state — is true in regalloc.rs and false where it is used: plan_register_residency calls release_deopt_pins deliberately and pays for it by keeping every home the colourer planned. A 50-arm refactor of the lowering arms would have had nothing to act on.

blocked_phi=2 is the useful half. The loop-carried values ARE the phis, and a phi's home is written by emit_copy_op, which was memory to memory: load rax, [src], store [dst], rax, and then emit_phi_copies reloaded the word it had just written to publish the phi's register. Then, because a phi appears in its header block's node list like any other value, the generic publish site reloaded it again, once per iteration — which is the other half of the loop-carried chain, mov [rbp-78h],rax on the back edge and mov rbx,[rbp-78h] at the top of the next iteration waiting on it.

Two changes at that one site (CRATONVM_JIT_IR_PHI_COPY_REGS=1, CRATONVM_JIT_IR_SKIP_REPUBLISH=1, both default OFF). The disassembly confirms both fire: FieldLoop.sum goes 1030 → 1026 → 1018 bytes as they are turned on, the preheader's two publishes become mov rbx,rax / mov r12,rax, and both loop-body reloads disappear — the phis are read straight out of rbx and r12.

And it measures zero. Interleaved arms, user CPU time, a second arm of the control configuration as the floor:

probefloor (ctl vs ctl2)on vs ctlP(on < control)
FieldLoop.sum (2 loop-carried)0.86%+0.86%0.529
FieldLoop.sumWide (5 loop-carried)0.00%+1.27%0.516

P(on < control) is over all 15x30 arm pairs; 0.50 is no effect. Two instructions out of a thirty-two instruction body, in a loop with enough independent work to overlap them, is below what this host can resolve.

Half of it is inert on this shape, and the counter says which half. [ir-ls] phi copies: reg_reads=0 reg_publishes=4 — four publishes (two phis times two edges) and not one register read, because the sources of those copies are the Add results, which are single-use and therefore never promoted. The read half waits on a shape where a phi's incoming value is itself resident.

Correctness is established rather than assumed: probes/PhiSwapLoop.java (two-cycle, three-cycle, mixed GP/FP) matches HotSpot with the flags on and off, the 2,489 cratonvm-jit tests pass with both flags on, and the regression suite is 90/90 in both arms.

The CratonBench arms were vacuous, and the check that caught it is worth copying. sieve, matrix and arithmetic were run the same way and came back at 0.407, 0.475 and 0.549 — until CRATONVM_DBG=ir-linear-scan was read on each of them:

fib:    ir-ls=3
sieve:  ir-ls=0

The IR tier plans no residency at all on those kernels, so both arms ran identical machine code and the three numbers describe nothing. osr_entered=504 with osr: admitted=2 says where the time actually goes. That reach question — how much of a real workload the optimizing tier's body reaches in the first place — is a prerequisite for any further measurement in this section, and it had not been asked.

What is sequenced next, and why it is now sequenced rather than assumed. FrameValue::Register, RegisterLong and RegisterRef already exist and are tested, so deopt metadata CAN name a register — but the IR tier's emit_deopt_stub passes only rbp to ir_deopt_entry and reserves no SavedRegisters region, so nothing would fill one. Dropping the home of a deopt-named value needs that region reserved and the callee-saved file spilled into it first. blocked_deopt is the counter that says what that would buy (retired 2026-09-10; [ir-ls] homes kept: deopt= is its successor).

The register image was built, and the home is gone for the values it covers

The sequenced step, taken. ir_deopt_entry used to say "the IR lowerer keeps every live value in a frame slot, so no register file is needed; a register-allocating backend would spill GPRs/XMMs in the trampoline and pass them here instead" — and that sentence was the whole reason a resident value could never lose its home. Four switches, all default OFF:

switchwhat it does
CRATONVM_JIT_IR_DEOPT_REGS=1reserve 256 bytes, spill 16 GPRs + 16 XMMs at the stub, pass a *const SavedRegisters
CRATONVM_JIT_IR_DROP_PHI_HOME=1stop writing the home word of a value a deopt frame can name in its register
CRATONVM_JIT_IR_PHI_COPY_REGS=1(already present) edge copies move register to register
CRATONVM_JIT_IR_SKIP_REPUBLISH=1(already present) an already-live register is not re-published

The naming rule is exclusive ownership, not residency, and the distinction is the whole safety argument. A value owns its register over its LIVE RANGE, and plan_register_residency releases the deopt pins — so a bytecode local can still be named by a frame state long after its last IR use, by which time the allocator may have handed the register to something else. A value is therefore nameable only when no other value anywhere in the method holds the same register: then it holds from the definition to the end of the frame, and no mapping between graph.safepoints and allocator positions is needed at all. slot_of on a dropped home fails the compile; home_read_refusals counts it.

It engages, and the loop loses its last frame traffic. On FieldLoop.sum:

[ir-ls] deopt regs: nameable=2 frame_slots_named_by_register=7 regs_base=464
[ir-ls] homes:      dropped_values=1 stores_skipped=2 read_refusals=0

Seven frame-state slots now describe a register, one loop-carried value has no frame word at all, and no reader refused. In the disassembly the counter is rbx throughout — no store on the back edge, no reload at the top, nothing.

A bug the executable test found, which is the reason to write that kind of test. deopt_regs_base was computed arithmetically whether or not the region had been reserved, so with the flag OFF it came out non-zero — which the stub reads as "there is a region here" — and 32 spill stores went over the argument staging area and past it. test_guard_deopt_reconstructs_live_frame SIGSEGV'd. This is the mirror image of the hazard deopt_spill_region_reserved already records for the single-pass backend, where an unreserved region left the base at 0 and the stores walked UP over the saved RBP and the return address.

And it measures nothing. Interleaved, user CPU time, 15 rounds, with the control repeated:

armmedianP(arm < control)
control0.79—
control again0.85—
phi copies only0.930.411
all four0.950.424

The two controls differ by 7.6%, so the floor swallows everything; P(all < phi) — the home-drop on its own, against the arm it depends on — is 0.516, which is chance. The host was at load 5-9 throughout. The honest reading is that this is not resolvable here, not that it is a regression.

Two limits worth stating rather than discovering later. With the flag on, every IR frame grows by 256 bytes and every deopt stub by 32 stores, which is why it is off. And no Java workload built for this reaches an IR-tier deopt at all — deopts=0 everywhere, and the probe written to force one (probes/DeoptRegLoop.java, a null receiver inside a loop) was never offered to the IR backend. The register-naming path is proven by a_deopt_frame_reads_a_register_the_stub_spilled, which emits a body that puts a sentinel in RBX and in no frame word, jumps to the stub as a guard does, runs it, and asserts the reconstructed local is that sentinel. That is a stronger proof than a workload would have been, and it is currently the only one.

How much of a real workload the optimizing tier reaches: the census

This is the prerequisite question, and it had never been asked. Every measurement in this section — the 2.38x inversion, the four zeros, the five zeros, the register image — concerns the body the optimizing tier emits. None of them asked how often it emits one.

CRATONVM_DBG_IR_COMPILES=1 already answers it: the pipeline has four stages that can decline a method (ir_compatible, the admission conjunction, IrBuilder::build, ir_lower), and each reports which one it was. Run over CratonBench, one phase per process, with CRATONVM_JIT=force-c2:

phaseoffered to the gateadmittedreached loweringwhy refused
arithmetic000never offered at all
hashmap000never offered at all
sieve200a bulk byte-array zero fill (REP STOSB)
matrix100one multianewarray in the method
fib111—
bintrees444—
stringregex91655222 invokedynamic, 12 String pin, 4 over the invoke cap, 1 precise frames

On four of the seven kernels the optimizing tier lowers nothing at all, and they are the loop-dominated four. That is the explanation for every zero this section records against a real workload, and it is a better explanation than any of the per-optimization ones: an A/B of an IR-tier switch on sieve, matrix, arithmetic or hashmap compares a binary against itself.

Three distinct causes, and they want different answers:

  • Never offered. arithmetic and hashmap produce no [ir] line whatever — the admission gate is not consulted once. Their hot code enters through OSR, and compile_osr_artifact "reaches x64::compile_with_param_slots directly": the OSR door has never gone through the optimizing tier. No flag changes that.
  • One instruction disqualifies the whole method. matmul allocates its result with a single multianewarray at the top and its hot triple loop is refused along with it; sieve zero-fills a byte array once and pays the same. Both refusals are of the form "the single-pass backend has an intrinsic here and the IR tier has none", which is a fair trade when the intrinsic is hot and the wrong one when it runs once per call against a loop that runs millions of times. CRATONVM_JIT_IR_OVER_INTRINSIC=1 already makes exactly this trade for CALL-SITE intrinsics; neither of these two is covered by it.
  • Attrition through the funnel. Where the tier does work, it still loses most of what it takes: stringregex goes 91 → 65 → 52 → 17 bodies, and the largest single loss at the builder is new-site DEFERRED — a class not yet loaded when the compile ran, which take_deferred_new_retry grants exactly one retry for.

What this changes about the work in this section. The tier-inversion programme has been optimising a body that, on the kernels used to motivate it, is never emitted. Before another switch is added to ir_lower, the reach number is the one to move — and of the three causes, the OSR door is the largest and the only one no flag can reach.

Method note. Read [ir] admission counts before believing a per-phase A/B, and do not read compiles: c2=N as "N optimizing-tier compiles": on sieve it says c2=3 while the IR backend lowered nothing, because that counter is fed by the tier manager's nomination and not by the backend that ran.

The OSR door: the go/no-go, and the blocker that is actually in the way

The reach census named the OSR door as the largest cause and the only one no flag can reach. The first question is whether there is anything at the far end of a route through it, and ir_compatible_sized is pure and scan is already in hand at that door, so the question costs nothing to ask. Per CratonBench phase, over the methods actually compiled there:

phaseOSR compilespass ir_compatible
arithmetic11
hashmap11
sieve22
matrix10 (multianewarray)
bintrees11
stringregex11

Six of seven, and they include arithmetic and hashmap — the two phases where the optimizing tier is offered nothing whatsoever today. Read it as an UPPER BOUND: ir_compatible is the first of four gates, and sieve in particular would still be refused further down for its bulk byte-array zero fill. But the route is not empty, which is what a go/no-go needed.

The design that fits this codebase. Do NOT teach osr_trampoline the IR frame. It takes some twenty layout parameters (osr_local_assignments, osr_callee_saved_base, osr_xmm_saved_base, …) because it builds the single-pass frame from OUTSIDE, and that arrangement depends on a property the optimizing tier does not have: a fixed per-method local→home map. IR locals are SSA values on colour-assigned slots that differ from bci to bci.

Instead, have ir_lower emit its own OSR entry stub per eligible bci. It already holds everything needed and the trampoline holds none of it:

  • the frame — it built it, bookkeeping slots and save bands included;
  • where local i lives at bci b — the safepoint snapshot at b names the node, and node_slot / gp_reg_of name its location. This is the same information build_deopt_points already reads, used in the opposite direction;
  • the native offset of b — bci_native.

The VM side then needs only "is there an entry for this bci" and a three-argument call, instead of twenty layout fields. Entry bcis must have an EMPTY operand stack (a javac loop header does), and a Ref local must be seeded exactly where that bci's oop map says it lives.

The blocker, named precisely, because it is not the frame. The optimizing tier's inputs — the invoke plans, checkcast_info, the new-site resolutions, the inline sites — are assembled inside try_compile_inner, and the OSR door does not go through it. compile_osr_artifact assembles a DIFFERENT set for the single-pass backend and calls x64::compile_with_param_slots directly. So the work is not "emit an entry stub"; it is "give this door the optimizing tier's input pipeline", and the honest way to do that is to route OSR through the common funnel rather than beside it.

That refactor pays for itself twice. The same divergence has already produced three bugs this file records — the permanent bail-list, the bisect levers and the code-cache cap were each hand-copied to this door after a bug, and the compile_gate::admit token exists to stop a fourth. A door that goes through the funnel cannot drift from it again.

Not started, deliberately. Entering a compiled body part-way with a hand-seeded frame is the failure mode that produces a plausible wrong number rather than a crash, and this section already records what happens when that class of work is begun without the instrument first. The census above is the instrument; the go is now on the record with a number behind it.

The OSR route, measured: it works, it is correct, and it is 1.56x SLOWER

The reach census said the optimizing tier never sees a loop entered by a back edge; the wiring made it possible; this is what it bought. probes/OsrTierBench.java is the shape the census named — a kernel entered ONCE, calling nothing, so a back edge is its only route to compiled code — and it is admissible under ir_osr_sentinel_free. Interleaved, seven rounds, the probe's own in-kernel ms so process startup is not in the number:

armmedian ms
control352
control again355
CRATONVM_JIT_FORCE_C2=1 alone359
door ON551

The two controls differ by 0.9%, and FORCE_C2 alone lands inside that — so the confound is excluded and the door owns the difference. 1.56x slower, slowest in all seven rounds. The answer is ck=25500075088100865, which is HotSpot's, so this is a speed result and not a correctness one.

This is the tier inversion again, and that is the point. It is the same ~1.5x this section has measured all along, now reproduced on a DIFFERENT probe through a DIFFERENT door — which is the independent confirmation the original number never had. The optimizing tier's loop body is worse than the single-pass tier's, and giving it more loops to compile makes things worse in proportion.

So CRATONVM_JIT_OSR_OPTIMIZING stays off, and the reach work's payoff is GATED on the body, not on the route: the 1.57x register-residency share and the 1.36x latency residual are what stand between this route and a win. The route is infrastructure that pays nothing until they are fixed — which is worth knowing now rather than after they are.

And the phi work finally has a positive number

The register-to-register phi copies, the suppressed re-publish, the register image and the dropped home all measured ZERO when they landed. They were measured on FieldLoop, where the optimizing tier was not running the hot loop — so the arms compared a body that barely mattered. This probe is the first workload where that tier owns a loop through OSR, and the same four switches on top of the door read:

armmedian ms
door ON (control)558
door ON again (control)575
door ON + the four switches526

all beat its paired on run in 11 of 12 rounds, P(all < control) = 0.743 over all pairs, against a 3.1% control-vs-control floor on a busier host. About 6%, and the paired count is the statistic to read — under no effect it is a fair coin, and 11 of 12 is not.

That does not rescue the 1.56x. It does say the work was sound and the earlier zeros were a measurement problem, not a design problem: a change to the optimizing tier's loop body cannot be measured on a workload where that body is not what runs. Every zero this section records against those switches was taken on one.

Taking on the 1.57x: the census says ONE value of four is in a register

With a workload where the optimizing tier finally owns a loop (probes/OsrTierBench.java through the OSR door), the residency census is readable for the first time on the population that matters. Its kernel has four live values — n, sum, acc, i — and five registers to put them in:

[ir-ls] peak_live=15 scan_promoted=19 resident=1 (gp=1) splits=12
[ir-ls] skipped: split_or_spilled=3 const=7 single_use=20 no_alloc=3

The allocator promotes nineteen values and the residency file accepts one. split_or_spilled=3 is the whole loop-carried set minus the one that survived.

Why the scan evicts exactly the wrong values. ls_pick_victim maximises next_use_distance × SCALE / frequency_weight, which is the classic rule and is right when a reload is paid ONCE. A loop-carried value's next use is across the back edge, so its distance is large; and it typically has FEWER uses than a temporary in the same body, so its weight is smaller. Both terms point the same way, and the value whose eviction costs a store and a reload on every iteration scores as the best victim available.

Loop-depth weighting cannot separate the two, and that is worth stating plainly because the weighting is already there: a loop-carried phi and a temporary in the same loop body sit at the SAME depth. What distinguishes them is not where they are but how long they live — across the back edge, or not.

The fix, and what it bought. LiveModel::carried marks a value whose range spans a back-edge position, and ls_carry_relief divides such a value's distance before scoring, which keeps the ordering among carried values while moving all of them behind the uncarried ones. It works, deterministically: resident 1 → 2 and split_or_spilled 3 → 2, saturating by a relief of 64.

It is default OFF, because it is not shown to pay. The timing arm was attempted and is not usable: the host was at load 22 on 8 cores, and this file has recorded twice already what a contended host does to an arm. A zero taken there is not a zero. That measurement is owed.

And the census names what actually stands in the way. Two of the four carried values are STILL split, and plan_register_residency refuses any split value outright — the file has no reload machinery, so "one segment, one register, whole range" is the admission. With four carried values and five registers there is no reason to split any of them; the scan splits them because it allocates them in competition with eleven transients under peak_live=15.

So the shape of the remaining work is not a better heuristic. It is reserving the carried set — assigning those values registers before the scan runs and letting everything else compete for what is left, which is what the single-pass tier does by colouring locals into callee-saved registers and is why it wins by 1.57x. The heuristic fix above moves one value; reserving moves the set, and this section's own conclusion has been "the live set has to move as a group, or not at all" since the four zeros.

Reserving the carried set: the whole live set is in registers now

The blocker the previous census named, taken on. plan_register_residency assigns a register to every LOOP-CARRIED value out of the ones the scan left free, before the parameter copy and after the scan — the same door the parameter copy already uses, and the same safety argument: a register in this file that the accepted set does not name is written by nothing, because emission is driven by gp_reg_of alone.

The point is not that the scan decided badly. It is that it was asked the wrong question: it allocated four values that are needed on every iteration in competition with eleven transients under peak_live=15, so it split them, and the file refuses a split value because it has no reload machinery. Reserving asks instead — these few are needed every iteration, give each one a register and let everything else have the rest.

On probes/OsrTierBench.java, with CRATONVM_JIT_IR_RESERVE_CARRIED=1:

residentcarried_reserved
off1 (gp=1)0
on5 (gp=5)4

All five registers in use, and each of the kernel's four loop-carried values has one. That is "the live set moves as a group" actually happening, after a section-length run of changes that each moved one value.

Correctness holds: ck=25500075088100865, HotSpot's answer, with the reservation on — and the same under --nojit.

The timing is owed, again, and for the same reason. The control-vs-control floor on the day was 6.3% at host load 12–40 on 8 cores; the arms (base 691, reserve 752, reserve+the phi stack 677) all sit inside it. This file has now recorded three separate days where a contended host denied an arm, and the rule it keeps proving is that a number taken there is not a number. Default OFF until it is measured on a quiet one.

What the measurement should expect to see, when it happens. Reserving removes the RELOADS of the carried set; the write-through home STORES remain unless CRATONVM_JIT_IR_DROP_PHI_HOME is also on, which is why the two want measuring together. The prediction the 1.57x implies is that the pair, not either alone, is what closes it.

The measurement a contended host cannot deny: the loop body itself

Three days of timing arms have been refused by host load. The emitted code is not: it is a deterministic function of the compile, so counting it settles what a stopwatch could not. Doing that first required fixing an instrument.

The OSR door's optimizing artifact was invisible to CRATONVM_DBG_JIT_DISASM. The only osr dump comes from inside compile_osr_artifact, which the door SKIPS when it takes an optimizing body — and the background tier worker calls that function anyway, so a dump appears, is labelled osr, and is the single-pass body. It is byte-identical with the door on and off, which reads as "the door changes nothing" while osr_entered_optimizing=1 and a 1.56x timing gap say it changes everything. The door now dumps what it actually enters, under osr-optimizing.

With that, OsrTierBench.kernel's loop body, counted:

armloop insnsframe opsloadsstores
door only138522219
+ reserve the carried set152602623
+ reserve + the phi/home-drop stack146502019

Reserving the carried set ALONE makes the loop worse, and the mechanism is the one this section named long ago: residency is a write-through read cache over a frame-slot-first model. Promoting a value adds a PUBLISH at its definition and does not remove its home STORE, so promoting four more values buys four more memory operations per iteration and removes reads only where a read already went through gp_load_value. resident=1 → 5 is a real census movement and a regression in emitted traffic.

That is a direct correction to the expectation set when the reservation landed, which predicted the pair would close the 1.57x. The pair is better than reserving alone — 50 against 60 — but it is only 52 → 50 against doing neither, on a kernel with four live values whose loop still performs fifty frame operations. The gap is not going to be closed by promoting more values into a cache that cannot remove the stores underneath them.

So both switches stay OFF, and the next move is not another promotion policy. It is the one the design page named and the census keeps re-deriving: a value that lives in a register for its whole range should have no home slot and no store. CRATONVM_JIT_IR_DROP_PHI_HOME does that for phis, at one site; the other forty-nine store sites are what the loop's remaining fifty frame operations are made of.

The forty-nine store sites, and what removing them did not buy

CRATONVM_JIT_IR_DROP_PHI_HOME dropped one frame store, at the one site (emit_copy_op) where this backend knew both that RAX held a value and which value it was. Every other definition writes its home through store_rax, which knew neither. Both facts were available and neither was being passed: lower_data_node_tracked now records the definition being lowered, and publish_def_at_store recognises the store whose offset is that definition's own home — at which point RAX provably holds it, because the only thing a home word is ever written with is its own value.

That makes two per-definition frame operations reachable without editing fifty arms:

  • CRATONVM_JIT_IR_PUBLISH_AT_DEF publishes the register from RAX at the store, instead of the generic publish site reloading the word the arm just wrote — the register-to-register publish that site's own comment has called cheaper since it landed;
  • CRATONVM_JIT_IR_DROP_HOME then drops the store itself for any value the deopt register image can name, extending the phi case to the arithmetic arms.

op_home_is_one_store_rax is an audit of the arms whose lowering writes its home exactly once through store_rax with RAX holding the value. Arms with a home write on one path and not another (Op::Load, Op::CheckCast, Op::Call), arms that reach the home another way (Op::Const's immediate store, Op::Param's gp_store_value, every FP fp_store_value), and the comparisons — whose home write is conditional on a fusion decision made in lower_terminator — are all absent. Getting that list wrong is not silent: lower_data_node_tracked refuses the compile when a dropped-home value reaches the end of its own lowering unpublished, and value_home_droppable additionally requires ir_phi_copy_regs_enabled, because gather_phi_copies is the one reader exempt from slot_of's fail-closed refusal.

First, an instrument correction, because it reverses a published verdict. The loop-body counts in the section above were taken over the range between a back edge's target and the jump that takes it — choosing the OUTERMOST such pair. On an artifact that carries OSR entry stubs that is the wrong range: a stub is emitted AFTER the body and ends by jumping to the loop header, which reads as a back edge spanning the loop, the epilogue and the stub. The numbers in that table therefore counted the stub's local-zeroing and the epilogue's callee-saved restores. Taking the INNERMOST back edge instead:

armloop insnsframe opsloadsstores
door only58311714
+ the phi/register stack57291514
+ DROP_PHI_HOME56281513
+ DROP_HOME as well56281513
+ reserve the carried set (no drops)60251114
reserve + publish-at-def + drop home5619910

Reserving the carried set alone is not a regression. Over the real loop it takes 31 frame operations to 25 — it removes six RELOADS, exactly what it was built to do — and the earlier "52 → 60" was the entry stub being counted, which grows with the number of reserved registers because there are more seeds to copy. That verdict is withdrawn.

The rest behaves as the design predicts and the census confirms it engaged: dropped_values=4 stores_skipped=6 read_refusals=0 def_publishes=2 def_stores_skipped=2 with resident=5 (gp=5). Reserving removes the reloads, write-through leaves all fourteen stores, and dropping the home removes four of them and two more loads. 31 → 19 frame operations, a 39% cut, with the instruction count also down (58 → 56), and ck=5100017428506113 — HotSpot's answer — identical across all six arms.

And it is worth about 6%. Four interleaved arms at n=200,000,000, nine rounds, host load 11–17 on 8 cores, with the single-pass arm run twice as its own control:

armmean ms
single-pass OSR398
single-pass OSR (control)408
optimizing door, switches off675
optimizing door, full stack630

The control-vs-control floor is 2.4%; the full stack beats the door arm by 6.7% and does so in 7 of 9 rounds. Real, and far outside the floor.

It does not close the gap, and that is the finding. The tier inversion goes from 1.68x to 1.56x. Removing 39% of the loop's frame traffic bought 6.7% — which refutes, with a number, the assumption this whole line of work has been built on: that the optimizing tier's loops are slow because they go through frame words.

What the control says instead. The single-pass body for the same kernel, counted the same way, is unrolled two ways and runs ~30 instructions per iteration with ZERO frame operations in the hot path — i, acc, sum and n live in r12, r13, r14, r15 from entry to exit, and the only [rbp-...] traffic in its loop is the safepoint poll's spill on the slow side of a je. Against that, the optimizing body is 56 instructions and 19 frame operations.

So the remaining distance is not one more promotion policy either. Reading what those nineteen operations are makes the next target concrete:

mov [rbp-0C0h],rax      ; store a value
mov rax,[rbp-0C0h]      ; ...and read the same word straight back

They are SINGLE-USE INTERMEDIATES — the result of an Op::Add or Op::And that feeds exactly one consumer — written to a frame word and reloaded on the next instruction. plan_register_residency skips every one of them by policy (single_use=20 in its census), and it is right to: they do not want a register. They want not to be spilled at all, which is a question about the shape of lower_data_node's value model rather than about who gets a register. That, and the 2:1 instruction count against a tier that unrolls, is what the 1.56x is made of.

Both switches stay OFF pending that.

The single-use intermediates, and the frame states that pin them

The previous section ended by naming what the optimizing tier's loop still spends its frame traffic on: pairs of the shape

mov [rbp-0C0h],rax      ; store the result of an Add
mov rax,[rbp-0C0h]      ; ...and read the same word straight back

plan_register_residency skips every one of these (single_use in its census) and is right to — their live range is one instruction, so they do not want a register, they want not to be written to memory. CRATONVM_JIT_IR_CARRY_SINGLE_USE is fused_cmp's move generalised to them: when a value has exactly one use and its consumer is the very next node in the same block, it stays in the register the arm computed it in. RAX when the consumer reads it first, RCX when it reads it second — one register move that still removes a memory access.

Neither half of the contract is trusted. The read refuses unless it is the planned consumer asking for the planned register, and — for an RAX carry — unless buf.pos() proves nothing was emitted in between, which is a proof rather than an audit of what the arms do. A carry that outlives its consumer, or reaches a block boundary or a terminator, refuses too. Both allowlists have source-scanning tests that check them against the arms they name.

The first cut planned ZERO carries, and why is the more useful half of this section. The screen was deopt_named, and the IR graph says every candidate fails it:

20: Mul : Int <- [13, 19]  bci=Some(16)
safepoint[12] bci=17 locals=[3, 12, -, 13, 14] stack=[20]
safepoint[15] bci=22 locals=[3, 12, -, 13, 14] stack=[20, 22]
safepoint[16] bci=23 locals=[3, 12, -, 13, 14] stack=[23]

graph.safepoints records the full operand stack at every bci, so an intermediate is named by a frame state from its definition until its consumer pops it. That is the same wall the residency file hit — its blocked_deopt census, today [ir-ls] homes kept: deopt= — reached from a different direction.

And it is worth reading beside what the door reports for this very method: sentinel_free=true, which is deopt_stub_patches.is_empty() && call_exc_patches.is_empty() — this body emits no deopt stub at all. Thirty-three frame states, not one of them reachable from inside the code, and they are what pins every intermediate to memory.

So the change splits in two, and the split is the point. Eliding the LOAD asks nothing of the frame: the home word is still written, every frame state still resolves through it, and a read of a word RAX already holds becomes no instruction. Dropping the STORE keeps the deopt_named screen. On this kernel that is planned=4 ... stores_dropped=0 still_deopt_named=4.

armloop insnsframe opsloadsstores
door only58311714
+ carry alone57271314
+ the residency stack5619910
+ both5515510

Four carries, four loads gone, refused=0, and ck=5100017428506113 — HotSpot's answer — in all four arms.

And this one is unambiguous on the clock. Five interleaved arms at n=200,000,000, nine rounds, host load ~10–12 on 8 cores, single-pass run twice as its own control:

armmean ms
single-pass OSR385.7
single-pass OSR (control)385.4
optimizing door, switches off657.4
+ carry alone592.4
+ carry + the residency stack557.9

The control-vs-control floor is 0.08% — the quietest measurement this file has recorded. The carry alone is 9.9% and beats the door arm in 9 of 9 rounds; with the residency stack it is 15.1%, also 9 of 9. The tier inversion goes 1.70x to 1.45x.

That also revises the previous section's conclusion, in the direction of the evidence rather than away from it. Frame stores alone did not explain the gap — 39% of them bought 6.7%. But frame round trips on the critical path do: these four loads are each one instruction's distance from the store that fed them, so every one is a store-forwarding stall in the middle of a serial recurrence, and removing four of them is worth more than removing twelve stores that nothing was waiting on.

What the remaining ten stores are, and the exact rule that would remove them. They are home writes for values named only by frame states no deopt can reach. The precise condition is not "no safepoint names it" but no safepoint that names it sits where a deopt can actually be taken — which is the union of

  • trap bcis: Op::Guard, the Op::Div/Op::Rem zero guard, calls, allocations, array and field access, checkcast, the monitor ops;
  • safepoint-poll bcis: the back-edge terminators, where the runtime can transfer a frame out from under the compiled body.

For OsrTierBench.kernel the first set is empty and the second is the single If at bci 10, whose stack is [14, 3] — a phi and a parameter, neither of them an intermediate. All four carried values would become droppable.

It is a separate change because it removes a backstop rather than adding one. build_deopt_points currently builds a point for every safepoint with a native anchor, so a dropped home is caught there today by frame_value_of refusing the compile. Making these values droppable means also not building the unreachable points — at which point the trap/poll classification above is load-bearing rather than backstopped, and it deserves its own pass.

The loop was computing its own return value, every iteration

Reading the whole emitted loop rather than only its frame operations found something bigger than either of the previous two sections was chasing:

27  mov rax,rbx          ; sum
28  mov ecx,0F4243h      ; 1000003
29  imul rax,rcx         ; sum * 1000003L
30  mov [rbp-0E8h],rax
31  mov rax,[rbp-80h]
32  movsxd rax,eax       ; (long) acc
33  mov [rbp-0F0h],rax
34  mov rcx,rax
35  mov rax,[rbp-0E8h]
36  add rax,rcx
37  mov [rbp-0F8h],rax

That is return sum * 1000003L + acc — the method's exit expression — computed and discarded on every one of two hundred million iterations. Eleven of the loop's fifty-five instructions and three of its fifteen frame operations.

ir_schedule's own header says a data node is "placed as late as possible (to minimize register pressure)". It is not. find_best_block picks the deepest block that all of a node's INPUTS dominate, which is schedule-EARLY. For a value whose inputs are a loop phi and a constant, that is inside the loop — whatever its uses do.

CRATONVM_JIT_IR_SINK_LATE moves a pure node to the shallowest loop nesting on the dominator path between where its inputs put it and where its uses need it, and only when the depth strictly decreases. The classic schedule-late also prefers the latest block at equal depth, to shorten live ranges; that is a different trade with a different risk, and leaving it out keeps this pass's effect attributable to the one thing it claims.

The obligation it discharges is the safepoint one. A frame state resolves a value it names from that value's HOME WORD, and the home is written wherever the node is emitted — so a moved node must still dominate every block that can anchor a safepoint naming it. That is checked against the final placement, and a violation reverts the whole method rather than the offending node: reverting one node can break another's dominance, so undoing the lot is the only revert that is obviously correct.

armloop insnsframe opsloadsstores
door only58311714
+ sink alone50251411
+ carry + the residency stack5515510
+ all three441037

moved=3 reverted_for_safepoints=0, and ck=5100017428506113 — HotSpot's answer — in all four arms.

Priced in CPU time, which is what a contended host leaves usable. Two wall-clock runs at load 23–36 produced control-vs-control floors of 3.7% and 10.0% — unusable for a magnitude, though the paired counts held (the sink arm beat its door arm in 17 of 18 rounds). User CPU over the same five interleaved arms, nine rounds:

armuser CPU (s)
single-pass OSR0.564
single-pass OSR (control)0.568
optimizing door, switches off0.812
+ sink alone0.743
+ sink + carry + the residency stack0.621

The floor is 0.60%, and every comparison separates in 9 of 9 rounds: the sink alone is 8.5%, all three together 23.5%. In CPU time the tier inversion is 1.435x → 1.097x — the optimizing tier is now within ten per cent of the single-pass body it was 1.7x behind when this arc started. (The wall-clock figures earlier in this file are wall clock; on this host CPU time is the instrument that survives the load, and it puts the door arm at 1.435x rather than 1.70x. Different instruments, not a correction.)

Engagement outside the probe is small, and that is the reason it stays OFF. Across forty regression-suite classes and 112 optimizing compiles, exactly one compile sank anything, with zero reverts. The pass is correct and free when it does not fire, but a default-on codegen change wants evidence broader than one constructed kernel, and this is a targeted fix for a specific shape: a loop whose method computes something after it.

The constant operands, and the end of the tier inversion

Every binary arithmetic arm in ir_lower reads its second operand through gp_load_value(RCX, ..) and then works register-to-register. When that operand is a constant, ir_const_imm turns the read into mov ecx, imm — so the emitted code carries a whole instruction per constant operand that x86 has an addressing form for:

mov ecx,1Fh  / imul eax,ecx        becomes   imul eax,eax,1Fh
mov ecx,1    / add eax,ecx         becomes   add eax,1
mov ecx,0FFh / and rax,rcx         becomes   and rax,0FFh

CRATONVM_JIT_IR_ALU_IMM folds it. Nine arms, three encoders: the accumulator short form for ADD/SUB/AND/OR/XOR (one column of the opcode map, so one helper), IMUL r, r/m, imm32, and C1 /digit ib for the shifts.

Unlike everything else in this arc it is not shaped like a loop. The residency work, the carry and the sink each need a particular structure to fire — a loop-carried value, an adjacent single-use consumer, work that outlives its loop. This is every x + 1, x & 0xFF and x * 31 in every compiled method.

Two details are load-bearing. The shift count is masked here, to 5 bits for ishl/ishr/iushr and 6 for the l forms: x86 masks a shift count the same way, which is exactly what makes the existing CL form correct without a mask, and an immediate form that inherited that assumption silently would be a coincidence rather than a reason. And a long constant outside i32 falls back to the register form, because every immediate form here SIGN-EXTENDS its imm32 — 0xFFFFFFFFL is the case that separates a correct fallback from a truncating one. probes/AluImmProbe.java checks both against HotSpot, along with negative shift counts and the two's-complement edges; it agrees in all five arms, --nojit included.

Only the SECOND operand folds, never the first, even for the commutative ops. gp_load_value(RAX, node.inputs[0]) being unconditional is what op_reads_rax_then_rcx and the carry's RAX contract rest on — and it is what keeps this change and the RCX carry disjoint by construction, since a carry to RCX is planned only when inputs[1] is the carried node, which is never a constant.

armloop insnsframe opsloadsstores
door only58311714
+ the fold alone53311714
+ everything else441037
+ everything401037

The loop control itself, 2026-09-11

The fold above reaches every x op k in a method, and the residue it left behind was the three instructions at the bottom of every counted loop. Two changes finish it. Both are default-ON with a kill switch, both strictly remove instructions and bytes wherever they fire, and neither resolves on this build host's clock — which is said here rather than dressed up.

A fused compare reads its operands where they already are. A compare whose only consumer is the If defines no value, writes no home and publishes no register; the only thing that outlives it is the flags, and those are the same whichever registers or addresses the comparison names. So it names them:

mov rax,rbx / mov rcx,r12        / cmp eax,ecx    becomes   cmp ebx,r12d
mov rax,rbx / mov rcx,[rbp-60h]  / cmp eax,ecx    becomes   cmp ebx,[rbp-60h]
mov rax,rbx / mov ecx,64h        / cmp eax,ecx    becomes   cmp ebx,64h
mov rax,[rbp-60h] / mov ecx,64h  / cmp eax,ecx    becomes   cmp [rbp-60h],64h

Four forms, pick_cmp_form in that order of preference. The two immediate forms are the common ones — i < 100 is the shape of most Java loops — and the two frame forms are not fallbacks: peak_live routinely exceeds the five-register GP file, and a loop bound is exactly the long-lived value that loses its register.

Three guards, each of which fails closed rather than wrong. carry_names declines either operand a carry is holding, because a carried value has to be read through gp_load_value or the carry strands. The frame forms go through slot_of_checked, so a dropped home declines the form rather than latching a bailout on a path with a perfectly good fallback. And the immediate forms gate on alu_imm32, which is the same gate the arithmetic folds use: it declines a constant too wide for i32 (every immediate form here sign-extends, so such a constant has no immediate encoding at all) and it is off under a MIR mode, where a tiled node is emitted by the selector and a fold here would leave the byte-equality lane comparing two different programs.

The 32-bit frame forms read four bytes where the MOV they replace read eight. That is the same comparison: the slot holds the int in its low word, and the CMP EAX, ECX being replaced only ever looked at those four bytes either.

x + k and x - k become one LEA. LEA is the only three-operand integer instruction on this machine, so it is the only way to read a source and write a different destination without routing through the accumulator:

mov rax,rbx / add eax,1 / mov r14,rax     becomes   lea r14d,[rbx+1]
mov rax,rbx / add eax,1                   becomes   lea eax,[rbx+1]

Two forms, and the weaker one is the common case, which is the thing to know about this change. Whether the first is reachable turns on whether the result got a register of its own, and a loop-carried increment does not: measured on CmpImm.wide, def_publishes=0 against phi copies: reg_publishes=10 — the loop-carried values are published by the phi copies on the back edge, so i + 1 writes a home word and is given no register. A first version that required one engaged nowhere on that probe. PollReach.hotLoop is where the direct form does fire (add_lea=1+0), and it is worth two instructions there rather than one.

Unlike the compare this DEFINES a value, so the direct form owes everything a definition owes, and every_droppable_op_writes_its_home_once_through_store_rax had to grow an exception for it — the one home write in a claimed arm that does not go through store_rax. The exception is named in that test and proved in the_lea_add_form_publishes_what_it_does_not_store, which reads the direct form's source and requires that it publish the register and say so BEFORE it decides whether to skip the home store. The accumulator form needs none of that: it leaves RAX holding exactly what mov rax,x; add eax,k would have, and store_rax finishes unchanged.

x - k is x + (-k) through the same encoder, except at Integer.MIN_VALUE, whose negation is not an int. One constant in the language, and it declines rather than wrapping into a silent + MIN.

Measured — instructions and bytes yes, time no. Release binary, the two flags as the A/B, first optimizing-tier compile of each method:

probeboth offcmp onlylea onlyboth onforms that fired
CmpImm.wide186 / 923184 / 919185 / 918183 / 914cmp_imm=1+0 add_lea=0+1
CmpImm.down186 / 919184 / 912185 / 914183 / 907cmp_imm=1+0 add_lea=0+1
LoopCtl.spin189 / 933187 / 927188 / 928186 / 922cmp_in_place=0+1 add_lea=0+1
PollReach.hotLoop200 / 1190198 / 1185198 / 1183196 / 1178cmp_in_place=1+0 add_lea=1+0
PollReach.wideLoop268 / 1637266 / 1631267 / 1632265 / 1626cmp_in_place=0+1 add_lea=0+1

instructions / bytes. The four arms are additive to the instruction, which is what says the two levers are disjoint. Bytes fall in every arm — the point worth contrasting with the operand-pairing pass, which bought its one instruction for three extra bytes and was rejected.

All four compare forms engage somewhere: the register-immediate form on CmpImm.wide, the frame-immediate form on CmpImmProbe (cmp_imm=0+2 on one compile) and on CmpImm.tight (1+1), the register-register form on PollReach.hotLoop, the register-frame form on LoopCtl.spin.

The timing is a null on this host and no speedup is claimed. Three arms interleaved ABCCBA, A and C the SAME build, so the A-C spread is the floor:

proberoundsfloor (A vs C)effect (B→A)
CmpImm.wide121.43% median / 1.18% min−2.62% median / +3.05% min
LoopCtl.spin125.69% median / 1.55% min+3.19% median / +3.49% min
CmpImm.wide2014.63% median / 0.07% min+9.35% median / −5.28% min
LoopCtl.spin203.11% median / 2.74% min+22.22% median / +0.40% min

Host load ran 40-111 on 8 cores across those runs, and it shows: two identical builds come out 14.63% apart, the measured "effect" ranges from −5.28% to +22.22%, and the median and the minimum disagree about its SIGN. That is a null, not a small win, and adding rounds made it worse rather than better because the load rose faster than the averaging helped. A quieter box is what this needs; the instruction and byte counts above need nothing, being exact.

One mechanism worth writing down, because it is the only argument AGAINST the LEA. mov rax,rbx is eliminated at rename on every current x86-64, so the instruction the accumulator form removes was very likely already free. On Intel LEA also issues on fewer ports than ADD (1 and 5, against 0/1/5/6), so in a port-1/5-bound loop it could in principle cost a cycle it does not spend. Not on this host — an AMD EPYC 9V45 (Zen 5), where the simple base-plus- displacement form runs on all four ALUs — but the flag is not host-specific and the next machine may be. So: the LEA removes an instruction and five bytes but probably not a uop. It ships ON for the decode and I-cache saving, which is not in doubt, and CRATONVM_JIT_IR_ADD_LEA=0 is the way back. The compare has no such counter-argument — mov ecx,imm is a real uop that no renamer removes.

Verified. 2366 cratonvm-jit unit tests and 2645 cratonvm-vm unit tests in debug, so debug_assert is live; 145 ir_vs_singlepass differential tests. The regression suite 92/92 with the new defaults and 92/92 with both kill switches — both directions, because a switch nobody exercises is not a switch. probes/CmpImmProbe.java agrees with HotSpot to the checksum under the defaults, under each kill switch, under both, under CRATONVM_JIT_IR_ALU_IMM=0, under CRATONVM_JIT_IR_LINEAR_SCAN=0 and under --nojit; it covers the imm8/imm32 boundary in both signs, negative bounds, a long constant outside i32 that no immediate can express, Integer.MIN_VALUE as a bound and as an addend, a first operand forced out of its register, and a reference against null.

alu immediates folded: 5, and ck=5100017428506113 in every arm.

And this is where the arc ends. User CPU, five interleaved arms, nine rounds, single-pass run twice as its own control:

armuser CPU (s)
single-pass OSR0.586
single-pass OSR (control)0.583
optimizing door, switches off0.916
+ the fold alone0.837
+ everything except the fold0.663
+ everything0.579

The floor is 0.39%. The fold alone is 8.6% (8 of 9 rounds) and 12.7% on top of the rest (9 of 9). Within this run the tier inversion goes 1.566x to 0.990x — the optimizing tier now matches the single-pass body on this kernel, where it started 1.7x behind.

Parity, not a win: 0.990 is inside the spread between the two single-pass arms, so the honest statement is that the gap this whole section set out to explain is gone rather than reversed. Compare within a run and not across runs — the door arm alone measured 0.812 s earlier the same day and 0.916 s here, which is more drift than several of the effects being measured.

What it took, in order of size: taking work out of the loop that was never loop work (the sink, 11 instructions), not spilling values whose live range is one instruction (the carry), folding constant operands (this, 5), and only then the register residency this section spent three days on. The frame traffic fell from 31 operations to 10, and the instruction count from 58 to 40 — and the first of those was worth less than the second, which is the opposite of the premise the work started from.

Everything above is still default OFF. Eleven switches, every one measured positive on this kernel, none of them defaulted on — because the engagement censuses say what a single kernel cannot: the sink fires on 1 optimizing compile in 112 across the regression suite. Defaulting these on wants a measurement on a real workload, and that is the next thing this file should record.

Turning them on, and the two bugs that only appeared when they met

Thirteen switches, every one of them measured on OsrTierBench.kernel and every one of them shipping OFF:

switchwhat it does
CRATONVM_JIT_OSR_OPTIMIZINGthe OSR door reaches the optimizing tier
CRATONVM_JIT_IR_OSR_ENTRYthe entry stubs that door needs
CRATONVM_JIT_IR_DEOPT_REGSthe register image a deopt frame reads
CRATONVM_JIT_IR_PHI_COPY_REGSedge copies through registers
CRATONVM_JIT_IR_SKIP_REPUBLISHno reload of a live register
CRATONVM_JIT_IR_PUBLISH_AT_DEFpublish from RAX at the store
CRATONVM_JIT_IR_DROP_PHI_HOMEa phi with no home word
CRATONVM_JIT_IR_DROP_HOMEthe same for ordinary values
CRATONVM_JIT_IR_RESERVE_CARRIEDa register each for the carried set
CRATONVM_JIT_LS_CARRY_RELIEFprice a carried value's eviction (0 → 64)
CRATONVM_JIT_IR_CARRY_SINGLE_USEone-instruction live ranges stay in a register
CRATONVM_JIT_IR_SINK_LATEpure work out of loops it is not used in
CRATONVM_JIT_IR_ALU_IMMconstant operands folded into the ALU op

All thirteen are now ON, each keeping =0 as its kill switch. The three that were tested with runtime_var_os(..).is_some() now read the VALUE, because presence alone cannot express an off word.

Flipping them found two bugs that no arrangement of them one at a time could. Both were caught by cargo test -p cratonvm-jit, and neither was a stale expectation:

  • A carried value and a dropped home refused each other. lower_data_node_tracked bailed on any value whose home was dropped and whose lowering published no register — and a carried value never publishes one, because being left in RAX or RCX for its single consumer is the contract. The probe this was all built on could not reach it: every intermediate there is named by a frame state, so no carried value's home was ever dropped and the two mechanisms never met. Two hand-built lowering tests with no safepoints at all did meet them, and failed closed — n5's home was dropped but its lowering published no register — rather than emitting anything wrong.
  • The level-2 machine list drifted from the arms it is measured against. mir_emitted_bytes already forced residency off for both arms of its comparison, with the reason written down: a comparison that left it on for one arm would measure residency rather than the selector. The carry and the folded immediates are two more of exactly that. They also now refuse under a MIR mode outright, and not merely for the lane's convenience — in MirMode::Emit a tiled node is emitted by the SELECTOR and its arm never runs, so a mix is genuinely broken rather than different: an arm could start a carry its tiled consumer never reads, or fold an immediate the tiler then re-materialises.

Two tests also had to stop asserting a default and start asserting a property. the_osr_entry_kill_switch_emits_no_stub and the deopt-region test now reach for a force-off, because a default-on codegen change is only as good as its way back, and the way back is the thing worth pinning.

Engagement, with nothing set at all — this is the census on a plain run, which is what "default on" has to mean:

osr optimizing OsrTierBench.kernel pc=7: stub=true entries=[7] sentinel_free=true
[ir-ls] resident=5 (fp=0 gp=5) scan_promoted=20 peak_live=13
[ir-ls] carries: planned=4 taken=4 read=4 refused=0 stores_dropped=0 still_deopt_named=4
[ir-ls] homes: dropped_values=4 stores_skipped=7 read_refusals=0 def_publishes=1
[ir-ls] alu immediates folded: 5
[ir-sink] moved=3 reverted_for_safepoints=0

Verified. cargo test -p cratonvm-jit and -p cratonvm-vm in debug, so debug_assert is live. The regression suite 90/90 with the new defaults and 90/90 with every kill switch set — both directions, because a switch nobody exercises is not a switch. AluImmProbe and OsrTierBench agree with HotSpot under the defaults, under every kill switch, and under --nojit.

What is still owed, and it has not changed. Every number in this section comes from one six-line kernel. The engagement censuses say what that kernel cannot: the sink fires on 1 optimizing compile in 112 across the regression suite. These are on now because they are correct, reversible and free where they do not fire — not because a real workload has been measured. That measurement is the next thing this file should record, and until it does, the right reading of the parity result is "on this kernel", not "in general".

An inline cache that installed, and then stopped being used

Defaulting the optimizing-tier switches on made test_inline_cache_takes_over_the_sam_call_site fail intermittently — 3 runs in 46, never in 60 with the switches off, always at ~398 000 of 800 000 dispatches "still going through the Rust arm". The number's tightness across occurrences (398568, 398618, 398496, 397002) said race, not latency drift.

Two theories died before the right one, and both are worth recording because each looked conclusive.

  • The optimizing OSR artifact has no inline-cache slots. compile_optimizing_artifact mentions ic_slots, mic_slots and pic_slots exactly zero times, which reads as a smoking gun. It is not: the function routes through try_compile_with_invokespecial_resolver into try_compile_inner, and that builds ir_ic_slots. A grep over one function is not a call graph.
  • The door rebuilds an optimizing artifact it will refuse, delaying OSR entry. True, and worth fixing on its own — ir_osr_sentinel_free requires no deopt stub and no call-exception stub, so any method containing a call is refused, and the door was paying a full optimizing compile per OSR attempt for most of them. But memoizing that refusal did not change the failure rate (3 in 40). A real inefficiency, not this bug.

What it actually was. Adding the counters as of the instant of install to the lambda-adapter installed line settled it in one run:

lambda-adapter installed ... at site_direct=0 fast_returns=2962
lambda-adapter installed ... at site_direct=1 fast_returns=3198
RESULT: 397002 of 800 000 dispatches still went through the Rust arm

Both thunks installed immediately — and the site still served 397 002 calls from Rust afterwards. The feature engaged and then stopped working, which is why site_adapters=2 looked healthy the whole time.

claim_adapter_install latched a bare bool for the life of the process. The MIC/PIC slots it fills, though, belong to the caller's compiled body — and callers get recompiled: C1 then C2, or an OSR body published beside the entry one. The new body's slots are fresh and empty, and a site already latched true can never fill them, so every dispatch after the recompile falls back to Rust permanently.

The latch is now the slot pair rather than a bool. A repeat of the same pair still refuses — that is the 202 000-re-install case the latch was added for, and it is unchanged — while a different pair claims once more. Re-installs are bounded by the number of distinct compiled bodies, which is small, instead of by the number of calls, which is not.

buildfailures
dev with the defaults on3 / 46
+ the refusal memo alone3 / 40
+ the slot-keyed latch0 / 108

The bug predates the defaults. Nothing in the optimizing tier caused it; turning the switches on merely made caller recompilation likely enough to expose it, and the test was the only thing in the tree sensitive enough to notice. Any workload whose lambda call site sits in a method that tiers up has been losing its inline cache at the tier-up boundary.

The frame states nothing can reach, and how to drop them without removing the net

The stores this section kept naming as the residual were home writes for values pinned by frame states. graph.safepoints records the full operand stack at every bci, so an intermediate is deopt-named from its definition until its consumer pops it — which is what the home census's deopt cause (then plan_register_residency's blocked_deopt) and the carry's still_deopt_named refuse on. Meanwhile the OSR door reports sentinel_free=true for the same method: it emits no deopt stub and no call-exception stub, so nothing inside it can transfer to the interpreter. Thirty-three frame states, not one reachable, all of them pinning intermediates to memory.

The obvious implementation is the dangerous one. Stop building the unreachable points and the trap classification becomes load-bearing: misclassify one op and a deopt reconstructs a confidently wrong value — the failure mode this area produces, and the reason this was split out of the perf arc rather than done inline.

So this does the opposite. It predicts trap-freedom from the graph to decide what to drop, and then verifies the prediction against the emission that actually happened. lower_inner computes osr_sentinel_free from deopt_stub_patches and call_exc_patches before build_deopt_points runs, and a point is skipped only when the graph prediction and the emission agree.

If they disagree, every point is built, frame_value_of meets a dropped home it cannot describe, and refuses the compile — the behaviour with this switch off, unchanged. Nothing is taken away; a case is added in which the net is provably not needed. op_cannot_deopt is an allowlist, so an op this file has never heard of counts as trapping.

That direction was not theoretical. The first cut omitted Op::Return, so OsrTierBench.kernel — pure arithmetic and a return — reported graph_trap_free=false and declined itself: homes_freed=0, loop unchanged. A missing entry costs an optimization, never a value.

With it fixed:

[ir-ls] unreachable frame states: graph_trap_free=true homes_freed=4 points_skipped=33
[ir-ls] carries: planned=4 taken=4 read=4 refused=0 stores_dropped=4 still_deopt_named=0
[ir-ls] homes:   dropped_values=8 stores_skipped=7 read_refusals=0
armloop insnsframe opsloadsstores
the arc as merged401037
+ unreachable homes dropped37734

still_deopt_named goes 4 → 0 and the seven stores this section has been pointing at become four.

Correctness is shown where the loop kernels cannot show it. They are trap-free, so no deopt is ever taken and a frame state that reconstructed garbage would never be consulted — a green run there is agreement about a path nobody took. probes/DeoptLiveProbe.java is the other half: three hot loops that really trap part-way through, with an intermediate live across the trap point (a zero divisor, a null receiver, an out-of-bounds index), each folding into a checksum that depends on the values live at the deopt. It agrees with HotSpot under the default, with the switch on, with the switch on plus the OSR door, and under --nojit.

On the clock it is a null result, and that is the right size. Paired user CPU, each arm run twice per round over fifteen rounds at host load 5-8: before 0.4840 s, after 0.4850 s — 0.2% apart, against a same-config control floor of 2.2-2.9%. Three instructions and three frame operations out of 40 and 10 is roughly 7% of the loop, and this host cannot resolve that. An earlier nine-round pass appeared to show a 16% regression; it was small-sample scatter in a bimodal distribution and did not survive pairing. The counted change is the result here — the stopwatch has nothing to add at this size.

(The first run of that comparison passed vacuously — the probe was not yet on the remote worktree, HotSpot printed nothing, and empty matched empty. The script now refuses to compare against an empty oracle. A differential harness that cannot fail is worth less than no harness, because it reports success.)

Performance — current status

Checksums stay exact (e.g. bintrees-18 = 68332206) across every change described above. The current CratonVM-vs-HotSpot ratios live in ../BENCHMARK.md; do not restate them here.

A cdb sampling profile of bintrees-20 finds the dominant cost is allocation plus young-GC throughput (sweep, free-list scan, old-gen spill for the live tree) rather than JIT codegen — i.e. further JIT optimization has limited headroom left for that workload, and GC throughput is the next lever.


Technical Implementation

JIT module (~105,400 lines — see "Current JIT architecture" above for the full breakdown)

FileLinesPurpose
jit/src/x64.rs37,327x86-64 single-pass emitter, Compiler, ~hundreds of unit tests
jit/src/lib.rs10,670try_compile/try_compile_inner, intrinsics registry, feature flags
jit/src/aarch64_backend.rs5,734AArch64 codegen backend
jit/src/ir_optimize.rs4,039IR optimization passes
jit/src/ir_lower.rs3,342IR → x64 lowering
jit/src/ir.rs3,149IR graph builder, ir_compatible admission gate
jit/src/tiered.rs2,549tiered compilation manager
jit/src/deopt.rs2,407deoptimization framework
jit/src/aarch64.rs2,293AArch64 instruction encoding
jit/src/escape_analysis.rs2,280escape analysis / scalar replacement
jit/src/pgo.rs1,981profile-guided optimization data
jit/src/regalloc.rs1,830graph-coloring register allocator
vm/src/jit/helpers.rs7,075JIT-called runtime helpers (getfield/putfield/invoke/newarray/...)
vm/src/jit/skip_list.rs3,898JIT eligibility policy (see "BouncyCastle JIT eligibility" above)
vm/src/jit/conservative_roots.rs2,411GC root scanning for JIT frames
(remaining files, jit/src + vm/src/jit)~24,900profile.rs, ir_schedule.rs, scev.rs, null_check_elim.rs, loop_analysis.rs, platform.rs, xt_root_scan.rs, alloc_class_cache.rs, disasm.rs, mod.rs

x86-64 Instructions Emitted

CategoryInstructions
Data movementMOV reg↔mem, MOV reg←imm32/64, PUSH/POP, LEA
ArithmeticADD, SUB, IMUL (reg and imm), NEG (32-bit and 64-bit)
Magic divisionIMUL+SAR+ADD (constant div/rem without IDIV)
BitwiseAND, OR, XOR, SHL, SHR, SAR, BTC
ComparisonCMP, CMP-imm, Jcc (6 conditions), CMOV, SETcc
Control flowCALL rel32, JMP rel32, RET
ExtensionREX.W prefixes, MOVSXD, MOVSX, MOVZX
Array accessSIB addressing (*1, *2, *4, *8), SHL for *16
SSE floatMOVD GPR↔XMM, ADDSS, SUBSS, MULSS, DIVSS, UCOMISS
SSE doubleMOVQ GPR↔XMM, ADDSD, SUBSD, MULSD, DIVSD, UCOMISD
SSE convertCVTSI2SS/SD, CVTTSS/SD2SI, CVTSS2SD, CVTSD2SS
Stack frameMOV save/restore callee-saved (R12-R15,RBX,RSI,RDI)

A parallel AArch64 encoder (jit/src/aarch64.rs) targets the equivalent instruction classes for the ARM64 backend.

JIT-Compiled JVM Bytecodes and Intrinsics

See "Bytecode and intrinsic coverage" under "Current JIT Architecture" above for the current, verified list — coverage has grown beyond the historical 130-opcode table below with new/anewarray/checkcast/instanceof/ tableswitch/lookupswitch/athrow/monitorenter/monitorexit/partial- invokedynamic, plus ~40 call-site intrinsics (Math, Integer/Long bit ops, String access/search, Arrays, CRC32).

  Constants:    iconst_m1..5, lconst_0/1, fconst_0/1/2, dconst_0/1,
                bipush, sipush
  Loads:        iload, lload, fload, dload, aload,
                iload_0..3, lload_0..3, fload_0..3, dload_0..3, aload_0..3
  Stores:       istore, lstore, fstore, dstore, astore,
                istore_0..3, lstore_0..3, fstore_0..3, dstore_0..3, astore_0..3
  Arrays:       iaload/iastore, laload/lastore, faload/fastore,
                daload/dastore, aaload/aastore, baload/bastore,
                caload/castore, saload/sastore, arraylength
  Int arith:    iadd, ladd, isub, lsub, imul, lmul, idiv, ldiv, irem, lrem
  Float arith:  fadd, dadd, fsub, dsub, fmul, dmul, fdiv, ddiv
  Negation:     ineg, lneg, fneg, dneg
  Shifts:       ishl, lshl, ishr, lshr, iushr, lushr
  Bitwise:      iand, land, ior, lor, ixor, lxor
  Increment:    iinc
  Conversion:   i2l, l2i, i2f, i2d, l2f, l2d, f2i, f2l, f2d, d2i, d2l, d2f,
                i2b, i2c, i2s
  Comparison:   lcmp, fcmpl, fcmpg, dcmpl, dcmpg
  Branches:     ifeq, ifne, iflt, ifge, ifgt, ifle
                if_icmpeq, if_icmpne, if_icmplt, if_icmpge, if_icmpgt, if_icmple
  Jump:         goto
  Return:       ireturn, lreturn, freturn, dreturn, areturn, return
  Fields:       getfield, putfield
  Invoke:       invokestatic, invokevirtual, invokespecial, invokeinterface
  Allocation:   newarray, multianewarray (2D)
  Stack:        dup, pop, swap, nop

Key Metrics Summary

MetricValue
Total Rust LoC~1,350,000 (22 workspace members; .rs files under the root Cargo.toml members, excluding target/, the non-member fuzz/ workspace, and any vendor/ directory — 1,349,978 lines across 702 files)
JIT LoC (jit crate + vm/src/jit)~131,600 (jit/src 100,391 + jit/tests 9,674 + vm/src/jit 21,549)
JIT backendsx86-64 (single-pass + IR-optimizing), AArch64
JIT bytecodes~130 core opcodes + ~40 call-site intrinsics
JIT unit/integration tests~hundreds in x64.rs + differential/IR-vs-singlepass/intrinsic suites in jit/tests/
Test corpusLarge Rust/Java unit, integration, regression, difftest, and fuzz layers
Lint statusclippy -D warnings is a release gate, not a baked-in metric
Native methods~3,100+
vs JDK C2See ../BENCHMARK.md for the current interleaved series

2026-09-06 — the seven-item pass: C2 was a SMALLER optimizer than C1

The audit that opened it

An audit asked why the optimizing tier delivers no measurable gain, and found a different answer from the one this file's tier-inversion arc had been chasing. That arc was about the BODY — frame traffic, register residency, the latency of a store-then-load chain — and it took OsrTierBench.kernel from 1.70x behind to parity. This one is about the OPTIMIZER, and the finding is structural:

C2 is not a superset of C1. Tracing which modules each analysis is reachable from, on dev@91d3077a9:

OptimizationC1 single-passC2 optimizing (before this pass)
Bounds-check elimination (range_analysis + x64/bce.rs)default onabsent
Null-check elimination (null_check_elim, the this seed)default onabsent
Runtime-trip loop unrolling (x64/loop_unroll_admission.rs)4xconst-trip only
Method inlining (x64/inlining.rs + guarded virtual)default onoff by default
Call-site intrinsics (~40)~400, and refuses the method
Vectorization (x64/simd_analysis.rs, x64/vec_emit.rs)presentabsent
Loop-carried values in registersdefault onwrite-through cache
Escape analysis / scalar replacementbytecode-walkSSA graph
SSA GVN, LICM, sink-late, ALU-imm foldingpartialfull
Exception-handler bodiescompiledskipped

range_analysis is referenced by x64/bce.rs and nothing else; null_check_elim by x64/* and nothing else. So ir_lower::emit_array_null_bounds_guards emitted a null test, a length load and a bounds compare at EVERY ArrayLoad and ArrayStore, every iteration, with no elision and no proof — in the tier that is supposed to be the optimizing one.

And the tier consumed no profile. CRATONVM_TIER_PGO has never shipped on, so MethodProfile::branches is empty and the ir_branch_hints map lower_inner receives is empty in every default run. Worse, the production pipeline called ir_schedule::schedule(&graph), which is schedule_with_options(graph, &ScheduleOptions::default()) — and Default is documented as "the historical scheduler exactly: no profile, no reordering, dependence-order-only intra-block scheduling". Two of the three stages that module implements were off in every compile the VM has ever run.

The measurement that made it a defect rather than an observation

H2 JDBC (probes/DodJdbcWorkload.java against the local h2corpus), three arms interleaved run-by-run with the order reversed on alternate reps, the same configuration run twice as its own noise floor, process CPU time. Windows workstation, 32 logical cores, host load ~76% from other sessions — so only within-run ratios are readable, which is why the control pair is there.

armrun A medianrun A minrun B medianrun B min
C2 on (default)3.9383.5944.7344.109
C2 on (control)3.9533.5164.5473.922
CRATONVM_C2_SUPERSEDE=03.7033.2974.2343.891

Control-vs-control floor 0.4% and 4.1%. c2-off is the fastest arm in both runs, on median AND minimum. About a quarter of the difference is background compile CPU (161 extra compiles, total_compile_time_ms 201 against 143); the rest is the published bodies and the supersede epoch bump.

The size census says the same thing from the other side. Over 161 supersedes: 81 C2 bodies larger than the C1 body they replaced, 66 smaller, 14 the same, 519,344 bytes against 488,834 — 6% larger in aggregate. A tier that neither inlines nor unrolls cannot explain a larger body by having done more work.

The refusal census: one site refused the whole method, five times over

Reach at the method-entry door was never the problem — 164 of 199 C1 compiles reach C2 on H2. What was lost, was lost to one design decision repeated at five sites: when the IR front end meets something it cannot lower, it discards the entire method rather than the site.

refusalcountwhat it was
ir.rs:7150 — invoke with no invoke_info64downstream of the discard below
invoke plan discarded whole68100% "one call-site intrinsic somewhere in the method"
invokedynamic anywhere53C1 lowers indy to an uncommon trap and compiles the rest
opcode 0x53 (aastore)35no IR lowering, though jit_aastore exists
unresolved checkcast / instanceof34a class that has never been loaded
new of a not-yet-loaded class13an exception construction on a path that never runs

The intrinsics were Math.max (7), System.arraycopy (7), Long.longValue (7), AtomicLong.get (7), Long.numberOfLeadingZeros (4), Math.min (2), Integer.compare (2), Long.compare (2) and a long tail.

And the deferred-new retry sweep, which runs on every class definition, re-resolved 8,131 sites over 101 distinct ones in a single run — the top one 3,905 times, a new java/nio/charset/MalformedInputException on a decoding error path inside java/lang/String, for a class the program never loads because that path never runs.

What landed

Seven changes, each with a kill switch and an engagement census.

#changedefaultkill switch
1IR-tier inliningONCRATONVM_JIT_IR_INLINE=0
2aIR null-check + bounds-check eliminationONCRATONVM_JIT_IR_CHECK_ELIM=0
2bunroll over unreachable frame statesONCRATONVM_JIT_IR_UNROLL_UNREACHABLE_FRAMES=0
3ascalar intrinsics lowered as arithmeticONCRATONVM_JIT_IR_SCALAR_INTRINSICS=0
3buncommon trap at an invokedynamic siteONCRATONVM_JIT_IR_SITE_TRAP=0
3b'...at an unresolved checkcast/instanceof/newOFF — the coldness argument was refuted; see belowCRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP=1
3b"...only where the trap could be RESUMEDON (added 2026-09-07)CRATONVM_JIT_IR_TRAP_REPLAY_GUARD=0
3caastore through jit_aastoreONCRATONVM_JIT_IR_AASTORE=0
4afrequency block layout + list schedulingONCRATONVM_JIT_IR_HOT_LAYOUT=0, CRATONVM_JIT_IR_LIST_SCHED=0
4bbranch profile window around a C2 nominationONCRATONVM_TIER_PGO_C2_WINDOW=0
5ScalarIntrinsic + ArrayLoad may drop their homeON(rides CRATONVM_JIT_IR_DROP_HOME)
6C1→C2 acceptance gateevidenceCRATONVM_C2_ACCEPT=always
6ba refused supersede abandons the publishONrides the gate; CRATONVM_C2_ACCEPT_MEMO=0 for the memo half
7deferred-new look budget16CRATONVM_JIT_DEFERRED_NEW_LOOKS=0

Four of them are worth reading for their reasoning rather than their effect.

The trap is not a new mechanism, it is Op::Guard with a constant-zero condition — the shape add_div_zero_guard already builds. Its lowering already emits DeoptReason::UncommonTrap with DeoptAction::Reinterpret, so the interpreter re-runs the bytecode at that bci, which is exactly what a site this tier declined to compile needs. Each caller owes a coldness argument and two of the three have a real one: an unresolved new/checkcast/instanceof names a class that has never been loaded, and a class that has never been loaded cannot have been touched by any path that has executed. That is a proof. invokedynamic has no such proof, and is on the list because the single-pass backend has made exactly this trade by default since it stopped bailing on indy.

A trap the interpreter cannot get back from is not a slow path (2026-09-07). That last sentence — matching the single-pass backend's indy trade — copied the trade without its precondition, and the precondition is the whole thing. The single-pass 0xba arm checks the snapshot it just built and bails the entire compile (mark_codegen_unencodable("unresumable-indy-trap")) when the trap could not be resumed. This tier needs that check MORE, not less: x64::driver sets can_deopt_resume = !deopt_points.is_empty() && !has_elided_monitor, while ir_lower sets it only on the scalar-replacement path, so on a production artifact an optimizing-tier deopt has exactly one fallback — the interpreter's whole-method replay from entry — and that replay is refused, fatally, once the bytecode before the trap has committed something a re-run would duplicate.

IrBuilder::trap_replay_is_safe now asks the CONSUMER's own predicate (replay_from_entry_is_observably_equivalent) before planting, and a refusal returns false, which the callers already turn into ir_build_bail — so the method falls back to the single-pass backend rather than going uncompiled. ir_trap_refusal_census() counts refusals by cause beside ir_trap_census()'s plants.

Note that invokedynamic is 0xba and opcode_commits_side_effect commits the whole 0xb6..=0xba invoke range, so a body containing an indy can never satisfy the whole-body clause: every indy trap is decided by the prefix before it.

It costs nothing measurable. An indy trap is UNCONDITIONAL, so an optimizing body whose live path reaches one pays a deopt on every call and is strictly worse than the single-pass body it superseded; declining it hands the method back to a tier that runs it. Measured ABBA-interleaved over 24 Spring classes (673 test methods) on one binary: guard ON 294.9 s / 286.6 s, guard OFF 316.4 s / 336.9 s — no overlap, and the fastest slot is the last one, so host drift cannot explain the ordering.

What it is worth, measured on the same day's dev tip and AFTER both deopt-sink resume fixes had landed: on the 56-class Spring Framework cluster, switching it off costs 20 classes and 296 test methods (53 OK / 1 FAIL / 2 TIMEOUT becomes 34 OK / 21 FAIL / 1 TIMEOUT). Confirmed ABBA-interleaved over six of them, byte-identical between repeats of each arm. The sink fixes make an unresumable trap recoverable; not planting it is what stops these classes failing.

Before any of the three fixes, every in-process javac compile under Spring's TestCompiler died with InternalError: precise deoptimization unavailable ... refusing side-effecting replay (javac catches it, prints its own banner to stderr and returns false with an empty DiagnosticListener, which reads as a compile that failed with no diagnostics), and every H2 CRASH class in the 2026-09-07 3-arm run died the same way. See the retired testcompiler-injit-mode-silent-compile-failure-19-class-aot-cluster and precise-deoptimization-unavailable-cross-suite-crash write-ups.

The intrinsics split by what the intrinsic replaces, not by convenience. Letting an unlowerable intrinsic site take an ordinary Op::Call is a DOWNGRADE — a dispatch is a ~300 ns floor where the single-pass backend emits two instructions — so the families whose emitted form is a handful of straight-line instructions (Math.min/max/abs, Integer.compare, Long.compare) are lowered as ARITHMETIC through one new op, Op::ScalarIntrinsic, and the families whose intrinsic replaces a LOOP (arraycopy, Arrays.fill/equals/sort, String.equals/indexOf, CRC32) keep the method-level refusal. scalar_intrinsic_census counts both halves, and the refused count is the work list for whoever extends the first.

One op with a sub-enum rather than eight ops, because every new Op has to be added to ir_verify's arity table, regalloc::ir_op_defines_value and ir_lower::op_defines_result_slot — and the last two are the pair the_two_value_defining_enumerations_agree exists to keep in lockstep after a drift that silently disabled register residency on every array-touching method.

The unroller's blocker was never sizing. It refuses any loop whose body is named by a safepoint snapshot, because a SafepointSnapshot is keyed by one bci and trip copies of a body bci cannot be represented by one snapshot. That reasoning is correct and it is unchanged. What it did not ask is whether any of those snapshots is REACHABLE — and on a trap-free graph none is, which is the same fact lower_inner's graph_trap_free already computes and acts on when it skips building unreachable deopt points. The relaxation is a PREDICTION, and it carries its own net: UNROLL_USED_UNREACHABLE_FRAMES is read in lower_inner, and if any deopt point was in fact built the compile is refused and the method takes the single-pass backend — which is what it did before the relaxation existed.

The acceptance gate is a policy and says so. ir_evidence records which transforms a compile applied, and CRATONVM_C2_ACCEPT=evidence (the default) publishes only a body that applied one the baseline tier has no equivalent for: scalar replacement, splicing, guard elision, late sinking, a scalar intrinsic. Unrolled and Licm are deliberately absent — the single-pass backend has both, so doing the same is not a reason to replace its body. The membership of that list is a judgment, stated as one so it can be argued with. A compile with no recorded evidence (a plumbing mistake) is ACCEPTED and counted as unjudged, because a mistake there must cost an unfiltered publish and never a silently disabled tier.

Verified

cargo test -p cratonvm-jit --lib: 2,267 passed, 0 failed. cargo test -p cratonvm-types: green, including the flag-surface guards and the regenerated docs/config/flag-inventory.md / docs/flag-tokens.md.

The new emitted sequences are EXECUTED against the answers the JLS specifies, edges included: the_scalar_intrinsic_sequences_execute_to_the_specified_answers runs each compiled body and checks Math.abs(Integer.MIN_VALUE) == Integer.MIN_VALUE (JLS 15.15.4 — the negation wraps, and the branchless (x ^ (x>>31)) - (x>>31) reproduces that exactly), signed min/max across zero, and Long.compare on operands whose low halves are equal — the case that catches sizing the CMP from the RESULT type, which is why ScalarOp::operands_are_long exists separately from ScalarOp::result_type.

Three source-scanning audits keep the couplings visible rather than remembered: every_eligible_op_is_claimed_or_explicitly_rejected (an op whose arm is shaped for a dropped home must be claimed or listed in DELIBERATELY_NOT_DROPPABLE with a reason), the_block_node_order_is_the_emission_order (the check-elision pass reads Block::nodes in order and treats that as program order, which is sound only while ir_lower emits a block the same way), and the existing every_droppable_op_writes_its_home_once_through_store_rax.

What is owed, stated rather than left to be inferred

No throughput claim is made for any of the seven. Every number above is a COUNT — a census, a refusal tally, a body size — or the C2-off A/B that motivated the work. The arms that would price these changes have not been run, and the host they would have to be run on was at ~76% load throughout.

Three measurements are owed, in this order:

  1. The H2 A/B again, on the new binary. The claim being tested is that the c2-off arm is no longer the fastest one. If it still is, the acceptance gate is doing its job and the tier is still not worth its epoch bumps.
  2. The IR-inlining flip on a second real workload. Its 8% netty / 15-26% hibernate came from the 2026-08-28 gauntlet soak, taken BEFORE the deopt-bci fix; those numbers should be re-taken rather than inherited.
  3. A per-collector regression sweep. Frequency-driven block layout moves every oop-map and safepoint position in every compiled method, which is exactly the change the RPO-layout work validated per collector for the same reason.

And one thing is NOT claimed: that C2's code generation was wrong. It was not. The existing arc took OsrTierBench.kernel from 1.70x behind to parity, and every checksum on every arm of this pass matched HotSpot. The problem was the size of the optimizer, not the quality of its emitter.

The measurement, and the three things it corrected

The section above listed what was owed. It was taken. Three of its claims did not survive, and the corrections are more useful than the original text.

1. The unresolved-class trap's coldness argument was wrong

The argument was: "the named class has never been loaded, and a class that has never been loaded cannot have been touched by any path that has executed." The first clause is true and the conclusion does not follow, because the observation is made at COMPILE time. A class not loaded when the method compiles can load a moment later, the path then runs, the trap fires on a LIVE path, and the body returns the deopt sentinel — on every call, for the life of the process.

ir_vs_singlepass_checkcast_not_yet_loaded_refuses_ir executes exactly that path and caught it on the first run. The fixture predates the trap by years.

Worse, trapping BYPASSES the machinery built for this transience: note_deferred_new_bail / take_deferred_new_retry refuse the method and re-offer it once the class loads. With the builder no longer bailing, nothing armed that memo and nothing re-offered the method — trading a delayed optimizing body for a permanently deopting one. The H2 census shows the repair: deferred-new retries: held=152 spent=2 retired=10 re_offered=1, where the trapping build read spent=0 re_offered=0.

So CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP is its own switch and ships OFF. invokedynamic keeps its trap and stays on: there is no "later" for a bootstrap this tier will never lower, and the single-pass backend has made that exact trade by default since it stopped bailing on indy. The reach claim drops from ~200 methods to ~150 accordingly.

What would make the other three safe is DeoptAction::RecompileAndReinterpret instead of Reinterpret, so the first trap triggers the recompile that resolves the class. Op::Guard's lowering hard-codes the action; parameterising it is the work that switch is waiting on.

2. The acceptance gate refused, and then published anyway

The gate discards the optimizing body, try_compile_inner falls through to the single-pass backend, and try_jit_compile_callee_slow puts THAT in the cache — replacing a C1 body with an equal one and bumping the process-wide supersede epoch, which stales every cached invoke target in every thread. The entire cost the gate exists to avoid, paid in full by a gate that refused.

A refused verdict on a method that ALREADY HAS a published body now abandons the supersede. Both halves of that condition are load-bearing: a refusal alone is not enough, because at the eager first-call door there is no predecessor and skipping the publish there would leave the method INTERPRETED.

H2 censusbeforeafter
supersede publishes (changed)17969
IC evictions from the epoch bump1,558696
fell through to single-pass128 (275 ms)19 (42 ms)
supersedes abandoned—113

3. "C2 supersede costs 6% of CPU" is true and was reported as more than it is

Process CPU counts background compile threads. The tier's cost is largely the compile itself — lowered=50 (33 ms) fell_through=19 (42 ms) against a ~1.9 s run — and that work overlaps with the mutator. Measuring only CPU attributes a parallel cost as though it were serial.

Three interleaved runs of probes/DodJdbcWorkload.java, order reversed on alternate rounds, the default configuration run TWICE as its own floor, paired counts (a fair coin under no effect):

runhost loadCPU: default beats c2-offWALL: default beats c2-offcontrol (CPU / wall)
A105 / 21not measured9/21 / —
Bmid7 / 2111 / 219/21 / 10/21
C3810 / 2511 / 2512/25 / 11/25

The control pair is a coin in every run, in both instruments, which is what makes the rest readable.

On wall clock there is no difference. 11/21 and 11/25 against controls of 10/21 and 11/25. On process CPU, CRATONVM_C2_SUPERSEDE=0 is a few per cent cheaper on a quiet host (5/21 and 7/21) and washes out on a busy one — the signature of background work, not of a slower body.

So the recommendation is NOT to flip the supersede default: the only instrument where turning the tier off wins is the one that charges parallel compile work to the serial total. And the honest verdict on the seven items is that they are correctness and reach work whose throughput effect on this workload is below the measurement floor in both directions — not a win, and not the regression the CPU-only reading suggested.

The pre-change finding that motivated the work stands unaltered: before these changes C2 bodies were 6% larger in aggregate than the C1 bodies they replaced (81 bigger, 66 smaller, 14 equal over 161 supersedes), and c2-off was the fastest arm. It is no longer the fastest arm on wall clock.

What is still owed

A second real application. Every number here is H2, and H2 is a workload whose own profile is dominated by allocation and young-GC throughput rather than JIT codegen — which is the least favourable place to look for a codegen win. netty and hibernate are where the IR-inlining soak measured 8% and 15–26%, and those are the arms that would price item 1 on its own terms.

The per-collector sweep is no longer owed -- it was taken, and the section below records both what it found and the G1 flake it turned out NOT to be.

The per-collector sweep, and the G1 failure that was not this branch's

Owed because frequency-driven block layout moves every oop-map and safepoint position in every compiled method. Three collectors exist (parse_gc_algorithm): ZGC (default), G1, Generational.

collectornew defaultsevery kill switch set
ZGC91/91, twice91/91
Generational91/91—
G191, 91, 90 (RMapGcStress)91, 91, 90 (RTreeRangeGc)

Both arms fail at 1 in 3 full-suite runs, with DIFFERENT vectors, both GC-stress. That is a collector-level intermittency on G1, not something these changes introduced — and RTreeRangeGc is the vector known-flaky.txt's own header records as quarantined at 3/12 (25%) on 2026-08-22, which is the same rate.

Two things follow, and the second matters more than the first.

RMapGcStress alone on G1 passes 4/4 with the new defaults; it fails only inside a full-suite run. So whatever perturbs it needs the suite's own memory and GC pressure, which is why running the vector alone — what known-flaky.txt asks for — cannot measure it.

And a single G1 suite run is not a gate. At ~1/3, one green run says little and one red run says less. Any claim of the form "91/91 on G1" from one run is over-read, this file's earlier ones included.

The hypothesis that was wrong is worth recording because it was well-motivated: RMapGcStress stresses HashMap and ConcurrentHashMap chains, which is exactly where JDK-internal aastore on an Object[] table lives, and this branch both added a new jit_aastore call site and moved every oop-map position. The all-off arm failing at the same rate refutes it.

The acceptance gate's evidence list was too narrow, and the measurement said so

The gate shipped refusing 580 bodies on H2. The list omitted constant folding, algebraic simplification, GVN and dead-node elimination — real optimizer work — on this argument, written when the diagnostic landed:

the single-pass backend folds constants too, so a graph getting smaller says the optimizer ran, not that its output beats C1's.

It is a reasonable argument and it is wrong. Transform::Simplified was added to split the refusals by whether ir_optimize's fixpoint loop actually removed nodes, and the split came back 296 simplified against 284 inert — half the refusals had the optimizer genuinely work on them.

So the gate was A/B'd against accepting everything. Twenty-one interleaved rounds, host load 7, order reversed on alternate rounds:

armwall meanCPU mean
the gate (evidence)2.3152.458
the gate again (control)2.3062.463
CRATONVM_C2_ACCEPT=always2.2372.378

always won 15 of 21 rounds on wall AND on CPU, ~3.4%, against a control pair agreeing to 0.4% on the means. The bodies the gate refused were better, and the gate was the thing costing throughput.

Simplified is evidence now. That keeps the half that is provably inert refused — 282 methods where the optimizer removed NOTHING — so the abandon path still saves their epoch bumps, and stops the gate discarding the half that did work:

censusnarrow listcorrected
accepted238568
refused (all inert now: simplified=0)580282
supersedes abandoned11342

And the regression that opened this whole section is gone. Paired counts, same harness, same workload:

comparisonnarrow listcorrected list
C2 supersede vs CRATONVM_C2_SUPERSEDE=0c2-off won 16 of 21 on CPUcoin — 9/21 CPU, 10/21 wall
the gate vs CRATONVM_C2_ACCEPT=alwaysthe gate lost, 6 of 21coin — 9/21 and 9/21

So the gate is now free: indistinguishable from accepting everything, while still refusing 282 methods whose optimizing body provably did nothing and saving their publishes and epoch bumps. Regression suite 91/91 on ZGC and 91/91 on G1 with the corrected list, which publishes 568 optimizing bodies instead of 238.

A harness defect worth copying, because it produced a fake control

The arm order is reversed on alternate rounds so no arm is systematically first. With THREE arms that balances arms 1 and 3 and leaves arm 2 in the middle every single round — so the CONTROL pair, which is arms 1 and 2, carries a position bias its paired count cannot shed. It read 7 of 21 while its own means agreed to 0.4%.

Read the paired count off the 1-vs-3 comparison, which is balanced, and read the control for its MEANS. A control whose paired count is skewed by position looks exactly like a control that has found an effect.

One run was discarded outright rather than reported: host load reached 76 and the control pair disagreed with ITSELF by 3.7%, larger than the effect being measured. This file has recorded that rule several times and it applied again.

The unresolved-class trap: the blocker was named wrong, and the reach is smaller than claimed

The previous section left this: "What would make the other three safe is DeoptAction::RecompileAndReinterpret instead of Reinterpret, so the first trap triggers the recompile that resolves the class. Op::Guard's lowering hard-codes the action; parameterising it is the work that switch is waiting on."

That names the wrong field. Op::Guard does bake an action into its DeoptimizationPoint, but nothing reads it. The runtime recomputes the action from the REASON on every deopt — record_deoptimization calls DeoptimizationLog::recommend_action_at_bci(method, reason, bci) and returns that. The baked action is dead metadata on this path.

And the reason it does bake, DeoptReason::UncommonTrap, already escalates. UncommonTrap takes the count-based policy: Reinterpret on the first deopt, then RecompileAndReinterpret. So the self-healing the switch was said to be waiting for is already there, one deopt later than ideal. Planting DeoptReason::ClassLoading instead would reach it on the FIRST deopt and would additionally clear the method's assumptions — but that is a one-deopt saving on a path measured below, not the unblocking the note described.

The measurement, which is what should have been taken first

H2, CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAPoffon
traps planted (typecheck / new)0 / 039 / 9
bodies accepted579586
bodies lowered118124
fell through to single-pass1915
deferred-new retries spent / re_offered2 / 10 / 0
eager re-queues (RecompileAndReinterpret)00
DOD RESULTOKOK

All 48 traps plant and not one fires. On this workload the coldness argument holds exactly as written: no path that executes reaches a site whose class was unloaded at compile time.

Which cuts both ways, and the second reading is the one worth keeping. Zero fires means there is no evidence the trap is harmful — and equally none that the escalation path works, because it never ran. "I read the policy function and it returns RecompileAndReinterpret at count 1" is a claim about source, not a measurement. The switch stays OFF until something fires it.

ir_vs_singlepass_checkcast_not_yet_loaded_refuses_ir cannot supply that. It was read earlier as proving the body "returns the deopt sentinel instead of the object — forever, on every call". It proves the first half only: call_with_dummy_context invokes the compiled body directly, with no VM, no deopt entry, no record_deoptimization and no recompiler. A unit test with no runtime cannot tell "traps once and heals" from "traps forever", and this one was cited for the second.

So the honest state of this item: reach is +7 accepted bodies and +6 lowered on H2, the danger is unobserved rather than absent, and what it actually needs is a VM-level test that FIRES a trap and shows the recompile come back without it. That test is the work; the action parameter never was.

Range-based bounds-check elimination

bounds_elided=8 against bounds_emitted=174 was the largest number left on the board, and it has a structural cause: the dominance pass removes the SECOND check on an SSA (base, index) pair and can never remove the FIRST. Almost all Java array traffic has no second access to remove.

The new pass proves the check dead outright. A check at (base, idx) goes when both halves hold:

  • idx < base.length — a dominating If on Cmp(Lt)[idx, ArrayLength(base)], taken on its true edge, matched on the array's SSA node so two arrays of equal length stay two arrays.
  • idx >= 0 — a non-negative constant, another array length, or a unit-stride induction variable.

Both, always. The emitted check is an UNSIGNED compare, which is exactly the conjunction 0 <= idx < len; the source-level test it is being proven from is SIGNED, and a negative index passes that. Eliding on the upper bound alone indexes behind the object.

Two restrictions are proofs rather than conservatism, and the tests say so in their names:

Unit stride only. For i = [region, init, i + s] guarded by i < len, the guard gives i <= len - 1, so after the increment i <= len - 1 + s. An array length reaches i32::MAX, so any s > 1 overflows at the top of the range; the wrapped i is negative, passes the signed test, and indexes out of bounds with the check gone. At s == 1 the bound is i <= len <= i32::MAX and the proof closes without knowing len. A "small constant" stride bound would not — it is still unsound against a near-maximal array.

The guard must dominate the BACK EDGE, not just the access. for (int i = 0; ; i++) if (c) if (i < a.length) a[i] = 1; has a bounds test dominating every access and still lets i reach i32::MAX and wrap, after which that same test passes on a negative index. Requiring every back edge into the loop header to pass through the guard is what rules it out.

The census splits the two mechanisms — ir bounds elisions by range proof — because a single total moves for either reason and they have opposite reach.

Two ways it nearly shipped inert, and how each was caught

Both were found by asking what shape the BUILDER actually emits, not by any test -- every unit test in the module passed through both.

Loop headers are Op::Merge, never Op::Region. IrBuilder::ensure_merge creates every merge point as an Op::Merge; activate_loop_header then fills in its inputs and phis and never rewrites the op. Op::Region appears only in hand-built graphs -- including this module's own tests. A first version required Op::Region and would have eliminated exactly zero bounds checks in a compiled method while showing twelve green tests.

javac puts the loop body on the FALSE edge. The builder preserves bytecode branch polarity: if_icmpge exit becomes Cmp(Ge) with Proj(0) going to the BRANCH TARGET, so for a top-tested loop the useful fact is not (i >= len) on Proj(1). Matching only Lt-on-true finds the bottom-tested shape and misses the other one silently. Both edges are read now, with the four (edge, comparison) pairs tabulated at upper_bounds.

Each has a test named for the failure rather than the feature -- the_header_op_the_builder_actually_emits_is_recognised, the_bound_is_read_off_the_false_edge_of_a_negated_test -- because the thing that needs catching is a silent zero, and a zero is what a workload with no such loops also produces.

Measured

probes/BceProbe.java is the behavioural half: the unit tests assert what the ANALYSIS decides, the probe asserts what the EMITTED CODE does. It sums, fills and walks a jagged array on the proven shape, and demands ArrayIndexOutOfBoundsException from four shapes the pass must refuse -- an inclusive bound, a negative start, a second shorter array, and an empty array -- all after 20,000 warm-up calls, so it is the compiled body under test and not the interpreter.

range pass onoff
BceProbe bounds elided / emitted6 / 40 / 10
BceProbe verdictOKOK
H2 bounds elided (of which by range)18 (10)8 (0)
H2 DOD RESULTOKOK

Six of the probe's ten checks go, and every refusal still traps. On H2 the range pass adds ten elisions on top of the eight redundancy already found -- purely additive, since the two prove disjoint things. Ten of ~166 is a modest share, and expected: H2's hot code is collections and MVStore rather than raw array loops, which is the same reason the seven-item pass measured flat there.

Regression suite 91/91 on ZGC.

The remaining 57 refusals are recoverable for free, and recovering them costs 3%

CRATONVM_JIT_IR_OVER_INTRINSIC=1 already exists and already works. With it on, the planner stops refusing a method for containing an intrinsic call site it has no node for, and falls back to an ordinary Op::Call there:

H2defaultOVER_INTRINSIC=1
refused_method570
bodies accepted569614
bodies lowered113123
fell through to single-pass1912
lowered_as_arithmetic2029
DOD RESULTOKOK
regression suite91/9191/91

So the whole remaining work list clears, with no correctness cost. That is the easy half, and it is the wrong half.

The refusal carries an argument rather than a measurement:

Every family that reaches here has an emitted intrinsic that replaces a LOOP … or a memory form this tier has no node for … For those the intrinsic really is worth more than the rest of the method's optimization, so the method-level refusal stays.

Earlier in this file an argument of exactly that shape — the acceptance gate's evidence list — was measured and turned out to be wrong by 3.4%. This one was measured too, and it is right.

Three interleaved runs, order reversed on alternate rounds, the default configuration run TWICE as its own noise floor, paired counts:

runhost loadcontrol (cpu / wall)over beats default (cpu / wall)
118%11/21, 12/21 — coin, means within 0.6%8/21, 8/21
2high17/21, 16/21 — means 6% apartdiscarded
331%9/21, 7/21 — means within 1.4%3/21, 2/21

Run 2 is discarded rather than reported: the control disagreed with ITSELF by 6% on the means, larger than the effect under test, and the absolute times jumped from ~2.1 s to ~2.8 s mid-run. Its treatment comparison happened to read as a coin, which is exactly why the rule is to discard on the control and not on whether the answer is convenient.

Over the two valid runs over wins 11 of 42 on CPU and 10 of 42 on wall — about 3% slower on the means, and agreeing in DIRECTION in both instruments and both runs.

(Re-scored 2026-09-07 against the stricter standard the hibernate reversal forced on this file. Per run: run 1 is 8 of 21 on both instruments, z = -1.09 — a coin on its own; run 3 is 3 of 21 and 2 of 21, z = -3.27 and -3.71. Pooled, z = -3.09 and -3.39. So "consistent in both runs" overstated run 1: what is consistent is the DIRECTION, four times out of four, and the pooled count is what carries the significance. That is still a much stronger position than the withdrawn hibernate result, whose two samples pointed OPPOSITE ways (+2.47 and -2.49) — direction agreement across independent runs is exactly the check that one failed and this one passes.

What remains untested is the same thing that broke hibernate: both runs used the SAME 21 classes, so this establishes REPEATABILITY, not that the effect generalises to other H2 classes. The claim is load-bearing — it is why OVER_INTRINSIC stays off and why the accessor work is scoped as "lower these families" rather than "stop refusing them" — so the disjoint-class check is worth running before anyone leans on it harder than that.)

And that check cannot currently be run, which is the more useful finding: the instrument that produced these numbers is not in the repo. tools/suite-pair-ab is fork-per-class JUnit only ("netty, hibernate-reactive" by its own header) and knows nothing about H2; the three-interleaved-run harness described above was ad-hoc and did not survive its session. So the measurement backing a default-OFF flag and a filed work item is, today, unreproducible by anyone including its author. Committing an H2 equivalent of pair-ab — same ABBA-per-unit shape, same same-config noise floor, same split-half check — is the prerequisite for re-testing any H2 throughput claim in this file, not just this one.

So the intrinsic at those sites really is worth more than optimizing the method around it. Trading an inline unboxing load or an Atomic* accessor for a generic jit_invoke_dispatch costs more than the surrounding body gains, and the ~57-method refusal is paying for itself.

The flag stays OFF, and its default is now measured instead of argued.

What this makes the work list mean

refused_method is a list of families to LOWER, not a list of refusals to lift. The bit-scan families above are the pattern that works: recognised in try_ir_scalar_intrinsic, lowered as real IR nodes, no call at all — both the intrinsic's speed and the method's optimization. Calling them instead gets the reach and loses the point.

Ranked by how much of the current H2 refusal each family holds:

familymethods heldshape
System.arraycopy10memory; a real loop-replacing intrinsic
Long.longValue8a field load behind two header layouts
AtomicLong.get7a volatile load (plain MOV on x86-64 TSO)
String.valueOf(Object)5allocation + dispatch
String.trim4string internals
String.isNotContinuation, String.<init>([BB)V4string internals
String.isLatin13string internals
Integer.intValue3as longValue
AtomicInteger inc/dec, AtomicLong.getAndAdd3LOCK XADD
Math.abs(F) / Math.abs(D)2ANDPS/ANDPD with a sign mask
Long.bitCount1needs POPCNT, deliberately excluded
7 more, one method each7String ctor/indexOf/join/valueOf(J)/startsWith, Arrays.equals, AtomicLong.<init>

The two Math.abs FP forms are the cheapest real entry (a sign-mask AND, no memory, no guard); the unboxing and Atomic* accessors are the largest single block but need a header-shape decision — box_unbox_intrinsic_shape resolves a value_compact_offset AND a value_legacy_offset, so an IR node for them has to pick between two layouts or guard on one.

netty and IR inlining: the pass rate is clean, and the throughput number could not be taken

CRATONVM_JIT_IR_INLINE ships default ON as of this branch, and every number justifying that came from H2. The debt this section pays is the one named above: "netty and hibernate are where the IR-inlining soak measured 8% and 15–26%, and those are the arms that would price item 1 on its own terms."

200 netty test classes on the Azure host, fork-per-class, 4 shards, 180 s per-class cap. ONE binary, one lever, three arms — on, off, and on AGAIN, because that box is shared and two arms cannot separate an inlining effect from the machine getting busier between them.

armIR_INLINEwallsum_class_msPASSFAILABORTEDHANG[ir] spliced
on (1st)1977 s2,279,174111571822802
off01061 s2,374,963110571830
on (2nd)11083 s2,693,458111571822836

The engagement census, first

spliced is 2802 and 2836 on the two on arms and 0 on the off arm. That is what makes the rest of the table readable at all: the lever demonstrably moves the thing it names, on this workload, in this binary. A soak without that row is not a soak, and netty is exactly the workload where IR inlining has something to chew on — 6600 inline-plan sites against H2's handful.

The pass rate: no regression

Across 200 classes the on and off arms differ in exactly one class:

only in the INLINE=1 arm:  io.netty.buffer.AdaptiveByteBufAllocatorGrowthTest ok=400 failed=0
only in the INLINE=0 arm:  (none)

Every other class reports identical ok= and failed=. FAIL=57, ABORTED=18 and NOTESTS=12 are byte-identical in all three arms, and the two on arms agree exactly (111/57/18/2/12). The one class that differs completed 400 tests with inlining and hit the flat 180 s cap without it — which is a HANG-vs-PASS at a timeout on a host under load 10–20, not a demonstration that inlining rescued it.

So the default-ON flip does not regress netty. That is the claim worth making and it is the one the run supports.

The throughput number: NOT measured, and not reported as if it were

The naive reading of the table is that on beats off by 7.9% on wall and 4.0% on sum_class_ms, comfortably in line with the 8% this section owed. That reading is wrong, and the third arm is what says so:

  • wall: the two IDENTICAL arms are 977 s and 1083 s — 10.8% apart, against a 7.9% on-vs-off difference.
  • sum_class_ms: 2,279,174 and 2,693,458 — 18% apart, against 4.0%.

The control disagrees with itself by more than the effect, in both instruments, so no throughput conclusion is available. Host load ran between 2 and 21 over the three arms (another session was building with -j 6 throughout), and the arms are necessarily sequential because the harness is fork-per-class rather than interleaved.

This is the third time in this document that rule has fired — the acceptance gate's discarded run, the over-intrinsic run 2, and now this one. It keeps firing because the tempting number and the invalid control arrive together: had the third arm been skipped, this section would have reported "8% confirmed on netty" and been believed.

What it would take to price it properly: a quiet host, or an interleaved harness that alternates the lever per CLASS rather than per run so drift cancels pairwise. The per-class @@RESULT ms= values make the second one cheap to build, and that is the right next step for anyone who wants the number rather than the pass rate.

hibernate-reactive was not run. The netty arm alone took ~50 minutes of host time in a three-arm shape, and a second suite would have added nothing the control did not already invalidate.

The per-class alternating harness, and what it says about the host

The section above ended with what it would take to price IR inlining properly: "a harness that alternates the lever per CLASS rather than per run so drift cancels pairwise". That is tools/suite-pair-ab/pair-ab.sh.

It is generic over the lever (--lever CRATONVM_X --on 1 --off 0) and over the suite, because nothing in it is netty-specific beyond the runner directory it is pointed at.

The design, and why each piece is load-bearing

ABBA, not AB. Each class is measured as four runs, A B B A (and B A A B on odd classes, so the block asymmetry cancels across the list). ABBA cancels LINEAR drift exactly: the mean timestamp of the two A runs equals the mean timestamp of the two B runs, so a host steadily getting busier contributes equally to both arms. Plain alternation does not have that property.

The within-arm noise floor. |A1-A2| and |B1-B2| are two runs of the SAME configuration, so they measure the host and not the lever. This is the piece the per-run shape could not have at any repetition count, and it is what lets the harness say UNMEASURABLE instead of reporting a number. A harness that cannot decline will eventually assert something false.

The two floors are deliberately asymmetric and the pessimistic one is used: in ABBA the B runs are adjacent (positions 2, 3) while the A runs are separated by them (1, 4), so |B1-B2| understates the noise and |A1-A2| overstates it. The reported floor is max(A, B), because the failure being defended against is claiming an effect that is really drift.

The same-work gate. A pair counts only when all four runs report identical found/ok/failed/skipped/aborted. Two runs that executed different numbers of tests have incomparable ms=, and without this gate a flaky class contributes a work difference disguised as a timing difference. Classes with ok=0, any failure, or any abort are dropped for the same reason — on the 24-class validation slice that dropped 13 of 24, which is the gate working, not a defect.

Strictly sequential. No shards. Sharded forks compete with each other, so the two members of a pair would see different contention — the exact thing the design exists to remove.

What it measured, which is the host

24 netty classes, CRATONVM_JIT_IR_INLINE on vs off, host load 11-13 with another session building throughout:

A faster than B in 7 of 11 classes  (fair coin under no effect)
median per-class delta : +1.0%
median within-arm noise: 11.6%   (SAME config, two runs)
  of the 2 classes whose own delta beats their own noise: A faster in 1
VERDICT: UNMEASURABLE.

Read naively that is "inlining wins 7 of 11 and is 1.0% faster". The harness refuses it, and it is right to: two runs of the identical configuration differ by 11.6%, eleven times the effect. Only two of eleven classes had a delta larger than their own noise, and those two split 1-1.

That number is the useful output. It quantifies, for the first time, why the three-arm per-run A/B could not work on this host — not "the arms were 17 minutes apart" as a hypothesis, but 11.6% same-config variance measured back-to-back on the same class. Any per-run design was doomed by a wide margin, and so is this one at this load.

Two individual classes are worth recording because the per-run shape could never surface them: AdaptiveLittleEndianHeapByteBufTest came in at -3.9% against a 1.6% floor (inlining SLOWER) while AdaptiveBigEndianHeapByteBufTest read +12.9% against 11.6%. Whatever the aggregate turns out to be, IR inlining is not uniformly good or bad across classes, and a single suite-wide number would hide that.

How to get the number

Run it on a quiet host. The floor is a property of the machine, not the harness: on the 12-class smoke run earlier the same day, individual classes reported 1% and 3% floors, so a quiet box should resolve effects in the low single digits. --min-ms drops classes too short for the JIT to matter, and --count / --start shard the list across sessions.

The verdict line is the contract: if it says UNMEASURABLE, the run has produced a noise measurement and no throughput claim, and the honest report is the floor.

The floor is the machine, and the 8% is not there

The claim above — that the 11.6% floor is a property of the host rather than of the harness — is testable, so it was tested: the SAME classes, the same lever, run again when the box had quietened from load 11-13 to load ~4.

classfloor at load 11-13floor at load ~4
BootstrapTest2.5%0.9%
ServerBootstrapTest26.9%1.9%
AbstractReferenceCountedByteBufTest93.0%2.7%
AdaptiveBigEndianDirectByteBufTest3.7%2.1%
AdaptiveBigEndianHeapByteBufTest11.6%0.9%

The floor is the machine. On the quiet run the harness resolves to about 2%, and it does that on the very classes that read 27% and 93% an hour earlier.

A faster than B in 2 of 6 classes
median per-class delta : -0.6%
median within-arm noise: 2.0%   (SAME config, two runs)
  no class had a delta larger than its own within-arm noise
VERDICT: UNMEASURABLE

And that is the substantive finding, not a shrug. At a 2.0% floor the effect of IR inlining on these six netty classes is smaller than 2%, and the median points very slightly the OTHER way (-0.6%, inlining marginally slower). The original 8% is not merely unconfirmed here — it is excluded at this resolution on this slice. Six classes is a small slice and the honest scope is "these six", but the instrument was good enough to have seen 8% and did not.

The one class that differed in the suite A/B was a timeout, confirmed

The per-run three-arm comparison found exactly one class differing between inline-on and inline-off: AdaptiveByteBufAllocatorGrowthTest passed with inlining and HUNG without it, at the flat 180 s cap. That was read cautiously at the time as "a slow class near the cap, not a rescue".

Run sequentially with no shard contention it takes 91.4 s with inlining and 90.7 s without — a 0.7% difference against a 3.0% floor. It is a ~91 s class that crosses a 180 s cap when four shards compete, and the lever had nothing to do with it. The caution was right, and this is what it looks like to close that kind of loose end instead of leaving it as a hedge.

The seven follow-ups, 2026-09-07

1. Pricing what shipped default-ON without a price

Range BCE and the scalar-intrinsic families both went in on correctness and engagement — 91/91 on three collectors, six of ten checks removed on a probe, twenty-odd sites lowered — and neither was ever measured for throughput. That is the same omission this file criticised the acceptance gate for, so it was closed. H2, one binary, one lever, three arms with the default run TWICE as its own floor, host at 4% CPU:

leverpaired count (cpu / wall)control (cpu / wall)verdict
CRATONVM_JIT_IR_BCE_RANGE=011/21, 11/2113/21, 11/21coin
CRATONVM_JIT_IR_SCALAR_INTRINSICS=09/21, 10/2112/21, 12/21coin

Both are throughput-neutral on H2. For the scalar-intrinsic arm the control's own means sat 3.6% apart, which is as large as the treatment gap, so only the paired counts are readable there — the means are not.

Neither result is a disappointment and neither is a reason to remove anything: they are reach and correctness work, which is the same verdict the original seven-item pass earned. What changed is that it is now measured rather than assumed in the favourable direction.

2. IR inlining on netty, with the per-class harness

Two runs of tools/suite-pair-ab/pair-ab.sh, 46 usable classes between them:

runclassesA fastermedian deltanoise floorverdict
quiet host62-0.6%2.0%UNMEASURABLE
busier host4017-0.7%8.8%UNMEASURABLE

19 of 46 overall — a coin, leaning very slightly against inlining, with both runs agreeing on the sign and the magnitude (-0.6% / -0.7%). The 8% that motivated this whole line of work is not there. The quiet run resolves to 2%, so an 8% effect would have been unmissable.

Five classes had a delta beating their own noise; A was faster in three of them. Even the individually-significant subset is a coin.

3. Why the other bounds checks are not provable — and why NOT to extend the pass

The range pass proves 9-11 checks of ~130 on H2. The obvious next move is to relax its two restrictions (unit stride, guard-dominates-back-edge). The refusal census says that would be wasted work:

[c2-supersede] ir bounds range refusals:
    no-length-test=110  other-array=16  index-not-non-negative=4

guard-not-dominating=0. iv-shape-rejected=0. Not one bounds check on H2 fails because of the stride rule or the back-edge rule. 110 of 130 fail because the index is never compared against ANY array length anywhere in the graph — they are isolated accesses, not loop-guarded ones, and no relaxation of a loop-shape rule reaches them.

Removing those needs a different technique altogether (whole-method length-fact propagation, or speculative predication with a deopt), not an extension of this pass. Sixteen more are indexed by one array and length-tested against another, which needs an equal-length or aliasing fact this tier does not have.

That is the entire value of the census: without it the next session extends the stride rule, measures no change, and has to work out why. pair-ab and this are the same lesson in two places — build the instrument that can say "no".

4. Math.abs(float) / Math.abs(double) as scalar intrinsics

The first FP members of ScalarOp, and the cheapest entry left on the work list. One AND against a sign mask — no branch, no memory, no CPU feature.

The sign-mask form is not merely faster than x < 0 ? -x : x, it is more correct: the comparison form returns -0.0 for abs(-0.0), because -0.0 < 0 is false. probes/ScalarFpAbsProbe.java pins that (via 1/x, since -0.0 == +0.0 compares true), plus NaN, both infinities, both MIN_VALUE subnormals and a full-mantissa value, and agrees with HotSpot on all of them.

These do NOT go through gp_load_value/store_rax like every other member, so the lowering arm gained an is_fp() guard that returns before the general-purpose load. Two things made that safe to bolt onto the existing op rather than needing a new one: fp_load_value/fp_store_value already exist, and value_home_droppable refuses any type that is not Int/Long, so the op_home_is_one_store_rax claim over Op::ScalarIntrinsic is filtered by type before it can be consulted for an FP node.

every_declared_family_is_recognised now drives off an EXHAUSTIVE match instead of a hand-kept tuple list. The old form could not catch the one failure it existed for — a family present in the enum and the lowering but missing from the recognizer, which reads from outside exactly like a workload with no such call site. Adding a variant is now a compile error until its signature is declared.

H2 refused_method 57 -> 45.

5. The unresolved-class trap does NOT self-heal — and the correction above was wrong

This file said, earlier today, that the original assessment of this trap had named the wrong blocker:

Op::Guard does bake an action into its DeoptimizationPoint, but nothing reads it. […] UncommonTrap takes the count-based policy: Reinterpret on the first deopt, then RecompileAndReinterpret. So the self-healing the switch was said to be waiting for is already there, one deopt later than ideal.

That correction was itself wrong, and the text it corrected was right. probes/UnresolvedTrapProbe.java fires the trap and the answer is not ambiguous.

The probe's shape is the awkward part and worth keeping: for a trap to be planted the class must be unloaded when the method compiles, but a method only gets hot by running, and running the cast would load the class. So the cast sits behind a parameter that is false during warm-up — 200,000 calls with doCast=false compile the method with Shape still unloaded, then 200,000 calls with doCast=true put the trap on a live path.

[c2-supersede] ir site traps planted: unresolved-typecheck=1
200000  reason=UnreachedCode bci=5 action=MakeNotCompilable
        eager re-queue (RecompileAndReinterpret): 0

Every single call deopts. 200,000 of 200,000, at the checkcast bci, with MakeNotCompilable and not one recompile. The answers stay correct — the interpreter finishes the bytecode — which is exactly why this could never have been settled by a correctness probe, and why the earlier reasoning went astray: the failure mode is throughput, permanently, and it is invisible unless you count deopts.

Two things the run also settles:

The reason is UnreachedCode, not the UncommonTrap that Op::Guard's lowering bakes into its DeoptimizationPoint. So the guard's reason field is as dead as its action field — the runtime sees a fixed code — and UnreachedCode maps to MakeNotCompilable on the FIRST occurrence, with no count-based escalation at all. That is the mechanism, and it is worse than either the original text or its correction supposed.

It is not the acceptance gate hiding the IR body either. With CRATONVM_C2_ACCEPT=always (accepted=4 refused_no_evidence=0) the result is byte-identical: 200,000 deopts, same reason, same action.

CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP stays OFF, now for a measured reason. And a flag worth raising rather than burying: the invokedynamic trap is DEFAULT ON and is planted by the same plant_uncommon_trap, so it has the same property. No harm is observed — H2 plants 21 of them and fires none, and an indy bootstrap this tier will never lower has no "later" to wait for — but "none of them has ever gone live on a workload we run" is the only thing standing between that default and this behaviour.

The lesson worth keeping is about the correction, not the trap: a unit test with no deopt runtime could not distinguish "traps once and heals" from "traps forever", and I used that inability as licence to prefer the reading I had derived from reading a policy function. Reading a policy function is not running one.

6. hibernate-reactive

60 classes, one binary, one lever.

armIR_INLINEPASSNOTESTS[ir] spliced
on159112,677
off05910

Pass rates identical, and the engagement census is emphatic: hibernate gives IR inlining more than four times the work netty does (12,677 splices against 2,802). If any workload here were going to show the effect, it is this one.

sum_class_ms reads 1,061,583 on and 1,179,251 off — a 10% win, the largest apparent number in this whole investigation. It is not reported as a result, because it is a per-run sequential comparison, which the section above proved cannot produce a timing number on this host. It is exactly the shape that read "8% on netty" and turned out to be drift.

So the harness was pointed at hibernate instead, and it found something.

The one real throughput result: inlining is a small, consistent win on hibernate

37 usable classes, ABBA per class:

A faster than B in 26 of 37 classes
median per-class delta : +0.3%
median within-arm noise: 6.9%   (SAME config, two runs)
  of the 3 classes whose own delta beats their own noise: A faster in 3
sign test on the paired count: z = +2.47  (consistent, p < 0.05)
VERDICT: SMALL BUT CONSISTENT.

26 of 37 is not something a fair coin does. Each individual class is noise-dominated — 0.3% against a 6.9% floor — but the DIRECTION survives averaging over 37 of them, and the three classes that individually clear their own noise all point the same way.

This is the first positive throughput result for IR inlining on real code in this document, and it is nothing like 8%: it is a fraction of a percent, detectable only because the sign test aggregates many classes. Set against netty's 19 of 46 (z = -1.18, a coin), the picture is that IR inlining is somewhere between neutral and slightly positive on real applications, and the original 8% does not reproduce anywhere.

The harness had to be corrected to see it

The first version compared the median effect against the median noise floor and called hibernate UNMEASURABLE. That rule is right for a single class and wrong for a suite: a small effect that is CONSISTENT across many classes is exactly what a per-class design can detect and a per-run design cannot, and folding the paired count out of the verdict threw away the only thing this harness was built to find. pair-ab.sh now reports the sign test as a z-score and has a third verdict, SMALL BUT CONSISTENT, for effect-below-floor with a paired count a coin does not produce. netty still reads UNMEASURABLE under the new rule (z = +0.90); hibernate reads z = +2.47.

7. The two flakes

G1's GC-stress family is a harness TIMEOUT, not a stochastic defect. Four clean full-suite G1 runs on an uncontended host: 92/92, four times. The earlier "91, 91, 90" that put this on the residual list was taken while other work shared the box.

A fifth run, taken deliberately while twelve cargo test invocations ran alongside, reproduced the failure and labelled it:

RExceptions   FAIL rc=124: HARNESS FAULT — TIMED OUT; the harness killed the
                   VM, it did not fail [try TIMEOUT=600]
RMapGcStress  FAIL rc=124: HARNESS FAULT — TIMED OUT ...
HARNESS ERROR [G4] RJdkFormatLocale: the HotSpot oracle run FAILED (rc=1)

The HotSpot ORACLE failed in that run too, which no CratonVM defect can cause. So the vector to record is not "RMapGcStress is flaky on G1" but "the suite's flat per-class timeout is too tight for a contended host", and the fix is TIMEOUT=600 or an uncontended run, not a known-flaky.txt row. That also explains why the vector passed 4/4 when run alone and failed only inside a full suite: the suite is what supplies the load.

test_jit_cache_clear_all_evicts_entries is NOT MEASURED, and the reason is worth writing down. The cratonvm-vm lib-test target does not build in this checkout: release ends in rustc exit 101, and debug fails with os error 112 — not enough disk space. The machine had 0 bytes free of 930 GB, and the debug tree from that one attempt was itself 9.3 GB. Nothing was measured because nothing could be run.

What the G1 result does supply is a much better prior. The original observation was "1 failure in 11 PARALLEL runs, 0 single-threaded" — the same contention signature that turned out to explain the G1 family entirely. That is a hypothesis with new support, not a result, and it is recorded as one.

The invokedynamic trap fires on real code, and firing cost the method every tier

The section above flagged this and did not measure it: the invokedynamic trap is DEFAULT ON, is planted by the same plant_uncommon_trap as the unresolved-class trap, and therefore has the same never-heals property — "no harm is observed" being the only thing standing between that default and the behaviour.

Harm is observed. H2, default configuration:

ir site traps planted: invokedynamic=21 ... | TAKEN at runtime: 1
org/h2/mvstore/MVStore.getMapId:(Ljava/lang/String;)I
    reason=UnreachedCode bci=5 action=MakeNotCompilable

One of the twenty-one went live, and MakeNotCompilable is consulted by compile_gate itself (is_jit_bail_listed), so getMapId lost its body on every tier — including the single-pass backend, which lowers invokedynamic perfectly well and had been compiling that method before site traps existed. Planting a trap to gain the rest of the method's optimization cost the method all of its compilation, permanently, the first time the trapped path ran.

Where it came from

try_resume_trapped_callee hard-codes the reason it reports, ignoring the DeoptimizationPoint the artifact carries — which is why Op::Guard's baked UncommonTrap never reaches the policy. Its comment says so, and anticipates this exact case:

Reason: UnreachedCode — the one-shot "give up immediately" policy […] so the trapping method is made not-compilable on the FIRST resolution […] (A guard-bail stash reaching this arm is over-blacklisted by this — acceptable: it reverts to the interpreter, which is always correct.)

Correct for the single-pass indy trap, where no tier can do better. Wrong for an IR site trap, where only the OPTIMIZING tier had the problem. "Reverts to the interpreter, which is always correct" is true about answers and silent about throughput, and this is the second time in this document that a correctness-only argument hid a permanent slowdown.

The fix

A site trap now says what it means: ban the optimizing tier for this method — the memo try_compile_inner already consults — and pick a reason that RECOMPILES rather than blacklists. The recompile then goes single-pass and does not trap. SpeculationFailed is that reason: RecompileAndReinterpret until the per-method deopt count crosses max_deopts_per_method, which keeps a backstop if the assumption is ever wrong.

Telling the two apart needs the runtime to know the artifact carries a site trap, so plant_uncommon_trap now records the method (build(mut self, ..) consumes the builder, so the count comes back through a per-build thread-local, the same idiom reset_string_access_sites uses).

H2, same workload, after:

ir site traps planted: invokedynamic=21 ... | TAKEN at runtime: 1
org/h2/mvstore/MVStore.getMapId  reason=SpeculationFailed
                                 action=RecompileAndReinterpret
eager re-queue (RecompileAndReinterpret) org/h2/mvstore/MVStore.getMapId

One deopt, one recompile, IR banned for that method, single-pass body restored. DOD RESULT OK, regression suite 92/92.

TAKEN, at last

plant_uncommon_trap's own doc has promised this since it was written — "[ir_trap_census] counts what was PLANTED by cause; the runtime side counts what is TAKEN. A cause whose taken count is not ~0 has had its coldness argument refuted" — and the runtime side did not exist. It does now, and it is the number that refuted the argument: TAKEN at runtime: 1 on H2 by default, and 200,000 on UnresolvedTrapProbe.

What is NOT fixed

The fix restores the method's compilation; it does not stop a HOT trapped path from deopting. UnresolvedTrapProbe, whose trapped path runs 200,000 times, still reads 200,000 deopts — 20 of them RecompileAndReinterpret and the rest MakeNotCompilable once the per-method count crosses its threshold. The IR ban takes effect (memo_skips=5) and the re-queues happen (20), but the trapping artifact is not displaced from under the running caller, so calls keep entering it.

So: a trap on a COLD-ish path (the real H2 case, one fire) is now cheap and self-correcting. A trap on a HOT path is still a permanent deopt loop, and the remaining blocker is artifact displacement, not the deopt policy. That is the next piece of work, and it is why CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP — whose sites are demonstrably hot — stays OFF.

Artifact displacement: what it is, and what could actually be done

The residual above was "the trapping artifact is not displaced from under the running caller". Tracing it named the mechanism exactly.

main bakes a raw direct CALL to the callee's entry (CRATONVM_DBG_JIT_FIELD_SITES prints one [jit-emit-direct] per baked call, here at pc=23 and pc=92). Eviction machinery all fires on the first trap: jit_cache.remove runs invalidate_matching, whose transitive reverse closure evicts the direct caller too, and the epoch is bumped. It demonstrably works — the tracer shows main recompiled and baking a SECOND callee entry.

And it changes nothing here, because the caller is a single in-flight invocation running a loop: 200,000 calls happen inside one main frame, whose already-executing code holds the old address. Cache eviction governs future ENTRIES to a method, not a frame midway through one. Displacing that needs the caller's frame deoptimized, or the callee's entry patched to a re-dispatch stub — and MakeNotEntrant is an enum variant in this tree with no entry-patching behind it, so there is no cheap displacement to reach for. Writing one is real runtime surgery (atomic patching of live code under W^X, against threads that may be at the entry) and is not something to bolt on beside a trap fix.

What IS in reach is refusing to compound the damage. The deopt bookkeeping exists to DECIDE a policy; once IR is banned for the method and the recompile has happened, every later trap re-runs a decision already taken — and that is not free, because SpeculationFailed escalates on the per-method deopt COUNT. A trapped site in a long-running caller therefore drove the method to MakeNotCompilable purely by being reached often: the exact outcome the fix exists to prevent, arrived at by a different road.

So the decision is taken once, and the rest resume in the interpreter.

UnresolvedTrapProbebefore the trap fixafter itafter this
deopt EVENTS200,000200,0001
MakeNotCompilable200,000199,9800
site traps TAKEN(uncounted)200,000200,000
re-fired after the decision——199,999

The trap still fires 199,999 times — that is the in-flight caller and nothing short of displacement removes it — but it now costs an interpreter resume apiece instead of deopt bookkeeping plus permanent blacklisting, and the method is fully compiled again the moment that frame returns. The residual is COUNTED rather than hidden: ir site traps re-fired after the decision is a direct measure of how much a real workload would gain from entry patching, and on H2 it is 0.

H2 unchanged: 21 planted, 1 taken, 0 re-fired, 0 blacklisted, suite 92/92.

A census number is only comparable to one taken BACK TO BACK

Several deltas in this file were quoted from runs taken minutes or hours apart — refused_method 57 -> 45 for the Math.abs work most recently. That is not a valid delta, and this section is the measurement that says so.

Twenty-four runs of the same workload on the same binary, no lever changed:

batchacceptedrefused_methodlowered_as_arithmeticbounds_emitted
A (7 runs)527-58543-5419-24129-164
B (5 runs)526-58244-5419-24130-166
C (12 runs)560-578———

Across all 24, accepted spans 526 to 585 — 11%. Within a contiguous batch it is roughly ±1%. The between-batch spread is an order of magnitude larger than the within-batch spread.

An intermediate reading of the first twelve runs looked cleanly BIMODAL — nine at ~529 and two at ~584, well separated and internally tight — and that reading was wrong. The third batch sat at 570-578, between the two supposed modes, which no two-mode model produces. Twelve samples were enough to fit a story and not enough to test it.

What survives is simpler and more useful: the census drifts with ambient machine state, and the drift dwarfs most of the effects being reported. The cause is not chased here — background compilation is a race between the compiler threads and the workload's own progress, and which methods cross their thresholds depends on how the machine feels — but the consequence is concrete:

A census delta is only meaningful between runs taken back to back, in one batch, on one binary, with one lever changed. Exactly the discipline the timing work already uses; it applies to deterministic-looking counters too, because they are not deterministic.

The claims this corrects

refused_method 57 -> 45 for Math.abs(F)/(D): the 57 and the 45 came from different sittings and the gap is inside the 43-54 range a single configuration produces. The DIRECTION is not in doubt — the two families are recognised and lowered, every_declared_family_is_recognised proves the recognizer sees them, and Math.abs disappears from the per-family refusal breakdown — but the MAGNITUDE was never measured. The honest statement is "two families moved from refused to lowered", with no number attached.

The same caution applies to every single-run census figure quoted above. The ones taken as an A/B pair in one sitting — the range-BCE arms, the over-intrinsic arms, the trap on/off arms — are unaffected, because both halves were taken back to back. That is the whole distinction.

test_jit_cache_clear_all_evicts_entries: 111 runs, 0 failures

Recorded on the residual list as "1 failure in 11 parallel runs, 0 single-threaded — measured, not proven pre-existing". It was unmeasurable for most of a day because the test target would not build: release ended in rustc exit 101 and debug in os error 112, not enough disk space, on a machine with 0 bytes free of 930 GB. Once space came back it built immediately, which says the exit-101 was the disk too.

Then a false start worth recording, because it produced a clean-looking zero: the first attempt ran the cratonvm-vm lib-test binary and got 0 failures in 30 — from a binary that does not contain the test. --list | grep -c said present: 0. The test lives in jit/src/lib.rs, not the vm crate. A pass count from a binary that never ran the test is the vacuous green this file keeps re-learning, and the only thing that caught it was asking the binary whether it had the test rather than assuming the filter matched something.

With the right binary, four shapes:

shaperunsfailures
the test alone, --test-threads=1300
the jit_cache module, 8 threads200
the FULL binary (2,283 tests), default parallelism250
six CONCURRENT full binaries, deliberate max contention360

111 runs, no failures. At 0/111 the 95% upper bound on the rate is about 2.7%, which excludes the 9% that "1 in 11" implies. The last shape matters most: the original sighting was inside a cargo test run, which starts many test binaries at once, so six concurrent copies of the heaviest one is a harder version of the same condition.

Two readings survive and the second is better supported. Either the single observed failure was far rarer than one in eleven, or — consistent with the G1 family in this same session, where failures under load turned out to be rc=124 HARNESS FAULT — TIMED OUT and the HotSpot ORACLE failed too — it was whole-machine contention during a cargo test that was also compiling. The recorded rate is not supported either way, and the residual should say so rather than carry a number nothing reproduces.

The hibernate inlining result REVERSES on a disjoint sample — there is no positive throughput result

This file said, earlier today:

This is the first positive throughput result for IR inlining on real code in this document […] 26 of 37 is not something a fair coin does.

It was tested on the rest of the suite and it does not hold. Same binary (one mtime, both runs on it), same harness, same lever, disjoint classes:

sampleclassesA (inline ON) fastermedian deltamedian noisez
hibernate, classes 0-393726 (70%)+0.3%6.9%+2.47
hibernate, classes 40-1197828 (36%)-0.4%2.7%-2.49
hibernate pooled11554 (47%)——-0.65
netty4619 (41%)——-1.18
every inline pair taken16173 (45%)——-1.18

Two disjoint halves of ONE suite, each "consistent, p < 0.05", pointing in OPPOSITE directions, with z-scores that are near mirror images. Pooled, the whole thing is a coin — and so is every inlining pair ever taken here, 73 of 161.

The second sample is the better one on every axis that matters: twice the classes, and a median within-arm noise of 2.7% against the first's 6.9%. If either were to be believed it would be the one saying inlining is SLOWER. The honest reading is that neither is: IR inlining has no measurable throughput effect on hibernate, and the earlier claim is withdrawn.

What went wrong, and what the harness now has to say

The design was right about the thing it was built for — a per-class ABBA pairing does remove the drift that made a per-run comparison useless, and the noise floor it reports is real. The error was in the inference laid on top: a sign test over classes assumes the per-class deltas differ only by the lever plus symmetric noise. They do not. Classes carry their own systematic differences — how much of the run is JIT-visible at all, how much is MySQL round-trips — and slicing a null effect into two class subsets can hand you a significant count in either direction. Which is exactly what it did.

So a paired count is evidence about THE CLASSES IT WAS TAKEN OVER, and a significant z is a reason to take a SECOND, disjoint sample — not a result. This is the same lesson as the census drift recorded above, one level up: there the trap was comparing runs across time, here it is generalising from a sample to the suite.

pair-ab.sh prints the count, the z and the noise floor and it printed them correctly both times. The verdict line is what over-reached, and it now says so: SMALL BUT CONSISTENT requires a confirming disjoint sample before it means anything.

A one-lever A/B found the TRIGGER and I called it the defect

The question was whether CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP could default ON now that firing no longer blacklists a method. H2 said yes emphatically — 48 traps planted, none taken, accepted 579 -> 592, lowered 116 -> 127, no blacklists. Thirty hibernate-reactive classes said the opposite, and the control arm is where the interesting number was:

arm (binary WITHOUT the deopt-sink fix)okfailedTAKENrefusing side-effecting replay
unresolved-class trap ON6433120102
the shipped default (indy trap ON)1827436
all site traps OFF241000

The third arm only got run because the second — the control — had 36 hard errors sitting in it. One lever, 241/0/0 against 182/7/36, and the conclusion looked inescapable: the default-ON invokedynamic trap was costing 59 passing tests and 36 InternalErrors in the stock configuration. The callees named in those errors were exactly the ones tabulated on the TransferToInterpreter known-issue page filed that morning. So site traps were switched to default OFF.

That was wrong, and the check that caught it was re-reading dev before pushing. Another session had spent the same afternoon on the same family from the other end and found the actual defect: one deopt SINK aborted on a trapped frame that its sibling sink resumed (CRATONVM_JIT_DEOPT_SINK_RESUME, default ON). Re-measured on a binary carrying their fix:

arm (binary WITH the deopt-sink fix)okfailedTAKENrefusing side-effecting replay
site traps ON239070
site traps OFF241000

Zero errors either way. The trap was never the defect — it was the thing that produced the deopts the broken sink then mishandled. Traps fire (TAKEN=7) and nothing breaks. The default-OFF flip is reverted.

And the arm that started all this reverses too. The unresolved-class trap, the one that read ok=64 failed=33 and looked destructive, on the fixed binary:

arm (binary WITH the deopt-sink fix)okfailedTAKENrefusing side-effecting replay
CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP=124101120
the shipped default241090

One hundred and twelve traps fired, no failures, no errors, the same pass count as the default. Every number this file has ever recorded against that switch — "200,000 deopts", "traps forever", "craters hibernate" — was measuring a broken deopt sink through it.

So the CORRECTNESS objection to CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP is gone. It stays OFF anyway, because the case for turning it ON was always throughput (+13 accepted bodies and +11 lowered on H2) and that has never been measured — and the inlining reversal recorded above is a fresh demonstration of how hard that measurement is to get right here. What has changed is the reason: it is no longer "this is harmful", it is "this is unmeasured".

The methodological point, which is the part worth keeping

A single-lever A/B is airtight about one thing and silent about another. Turning site traps off removed the errors, and that PROVED the trap is on the causal path to the failure. It said nothing about whether the trap or something downstream of it was the defect — and a kill switch answers identically in both cases. Every feature that produces deopts would have "fixed" this bug by being switched off.

The tell was available and I read past it: the failing arm's errors named can_deopt_resume=false, a property of the METHOD and the SINK, not of the trap. A lever that removes a symptom by removing its input is a bisection step, not a diagnosis.

Two further notes. TAKEN undercounts on the pre-fix binary by construction: the counter sits in the resume path past the point where the resume succeeded, so a trap whose resume was REFUSED never reached it — TAKEN=4 beside 36 errors was 4 traps that resumed and 36 that could not, and that discrepancy was itself a signal the trap was not the whole story. And H2 remains unable to see any of this: it plants traps and fires none, so its census reads as pure gain either way. A lever whose entire risk is what happens when a trap FIRES has to be measured where traps fire.

What this does retire

The case for artifact displacement — entry patching, a real MakeNotEntrant. Its premise was that a trap on a HOT path needs the trapping artifact displaced to be survivable. With the sink fixed, traps fire on hibernate and nothing breaks, and the residual re-fire counter reads 0 on both real workloads. Nothing here is asking for live-code patching, which in this tree means atomic surgery under W^X with no safepoint hook and no existing patch site to copy. It is not being built, and this is the measurement that says why.

The split-half check, so the next run catches this itself

The reversal above took a day and a second deliberate sample to find. It should not have: the evidence was inside the FIRST run, in the classes it had already measured. pair-ab.sh now scores its own two halves and prints them:

sign test on the paired count: z = +2.85  (consistent, p < 0.05)
split-half   : first 20 classes z = +4.02 | last 20 classes z = -0.45
  ** THE HALVES DISAGREE IN SIGN. ...
VERDICT: UNMEASURABLE (SPLIT-HALF DISAGREEMENT). A wins 29 of 40
         overall, but the two halves of this run point OPPOSITE ways.

The discriminating case is the pair the self-test is built on: two runs with the IDENTICAL pooled count — 29 of 40, z = +2.85 — where one has halves at +1.79/+1.79 and the other +4.02/-0.45. The first is reported as SMALL BUT CONSISTENT; the second is refused. A check that could not separate those two would be doing nothing, which is why the self-test asserts the pooled counts match before asserting the verdicts differ.

tools/suite-pair-ab/selftest.sh runs the real awk out of pair-ab.sh rather than a copy, so the two cannot drift, and it was verified to FAIL when the detector is disabled.

The first version of that check was confounded, and it was my own. Rows are written in RUN order, so first-half-vs-last-half is also EARLY-vs-LATE across a run that can span an hour: a disagreement could equally be the classes or the host drifting underneath. It now computes an ODD/EVEN split as well, which interleaves the same classes in time and is therefore blind to drift:

first/lastodd/evenreading
agreeagreestable direction
disagreeagreedrift during the run, not a class effect
—disagreegenuinely class-dependent — does not generalise

A near-zero z has no sign to disagree with, so a disagreement counts only when both sides clear |z| >= 1; without that gate the drift fixture reads +0.00 against -0.45 and gets reported as class-dependence, which is how the bug was found. And the "identical pooled count" pair now separates three ways rather than two: agree reports a direction, hidden (one half carries it, the other is flat) keeps the direction but is annotated as resting on half the sample, and only a real sign reversal is refused. Its own first draft had the bug this file keeps meeting: the "same pooled count" assertion compared two EMPTY strings and reported ok when the summary had not run at all.

And the trap does not buy anything measurable either

With the correctness objection retracted, the only thing keeping CRATONVM_JIT_IR_UNRESOLVED_CLASS_TRAP off was that its benefit had never been measured. Measured now — 57 hibernate-reactive classes, ABBA per class, on a binary with the deopt-sink fix:

A faster than B in 34 of 57 classes            z = +1.46  (a coin)
split-half   : first 28 z = +1.13 | last 29 z = +0.56
median per-class delta : +1.4%
median within-arm noise: 10.7%   (SAME config, two runs)
VERDICT: UNMEASURABLE

No effect. The two halves at least AGREE in direction this time — both weakly positive, so this is not the reversal pattern the split-half check exists to catch — but the pooled count is a coin and the effect is a seventh of the noise floor.

That floor is the caveat and it is a large one: 10.7%, against 2.7% on the quiet-host hibernate run earlier the same day. Load was 3.5-6.5 on 8 cores throughout. This is a weak measurement, and it is reported as one. The sub-result that "of the 10 classes whose own delta beats their own noise, A is faster in 9" is NOT quoted as evidence: it is a selection conditioned on the noise estimate, over ten classes, and this document has already been burned once today by a significant-looking count over a small sample.

So the switch stays OFF with both halves of its case now measured rather than assumed:

  • not harmful — 112 traps fired across 30 classes, ok=241 failed=0, zero refusing side-effecting replay (the old "it craters hibernate" was a broken deopt sink seen through this switch);
  • not beneficial — no measurable throughput effect, on a noisy run.

What would settle it: the same 57-class A/B on a host at load < 2.5, which is what produced the 2.7% floor. Anything less and the answer is the noise floor, not the lever.

tools/h2-ab — so an H2 claim can be re-checked at all

The audit above found that no H2 throughput number in this file is reproducible: the harness that produced them was never committed, and the corpus's test-classes/ directory is empty on this box, so even the 21-class shape cannot be rebuilt. tools/h2-ab/h2-ab.sh is the replacement, and its limits are stated in its own header rather than discovered later.

It carries over the two rules that made the original trustworthy — ABBA per round (BAAB on odd rounds, so order bias cancels across rounds too), and a CONTROL arm measured twice every round whose spread is the noise floor, with an effect inside the floor refused. It adds a third: with no control pair at all it reports NO CONTROL rather than comparing against nothing.

What it cannot do, and the header says so. One timed unit means no per-class sign test and no split-half check — the two things that caught a false result the same day. It answers only "is this bigger than the host's own same-config spread", the weakest of the three questions, and its positive verdict tells the reader to confirm on a second workload. suite-pair-ab remains the better instrument wherever the workload is fork-per-class.

--analyze <samples.tsv> runs the statistics on recorded samples with no VM, so selftest.sh can check the maths directly. It was mutation-tested, and the mutation testing paid immediately: an assertion that checked only the VERDICT passed when worst was changed to "whichever round awk visited last", because awk walks an associative array in unspecified order and both readings happened to give UNMEASURABLE. The fixture now puts the worst round FIRST and asserts the reported floor VALUE (30.0%), which fails at 0.3% under that mutation.

First real run, corroborating the Azure result on different hardware: the unresolved-class trap reads an effect of -0.2% against a 7.5% floor — UNMEASURABLE, agreeing with hibernate's z = +1.46.

The unboxing accessors, lowered — the first MEMORY family the IR tier has

Long.longValue()J and Integer.intValue()I were the two largest single entries on the call-site-intrinsic refusal list (8 and 2-3 sites on H2). They are now lowered by the optimizing tier instead of refusing the method, and they are the first family it lowers that touches the HEAP rather than registers.

[ir] unbox-intrinsics UnboxIntrinsicProbe.sumLong([Ljava/lang/Long;)J:
     1 site(s) lowered as a guarded field load

Both families are gone from the H2 refusal breakdown — java/lang/Long.longValue and java/lang/Integer.intValue no longer appear at all, against 8 and 2-3 before — and 11 sites lower per run. refused_method reads 43-45, which is inside the 43-54 band one configuration produces, so no delta is claimed there: the disappearance of the two families from the per-family list is the engagement evidence, not the total.

Why this one was left until last

The arithmetic families are pure register work. These read a field, and the byte offset of that field is not a compile-time constant: a compact instance keeps the payload at a registered body offset, a legacy one inside its 16-byte Value cell, and BOTH shapes exist in one heap because different allocators build different cells. So the lowering is not a load — it is a null check, an exact receiver class guard, a per-object test of the header's compact bit, and then one of two loads.

The node carries only the guard class id. Offsets are re-derived at lowering from ir::unbox_offsets, which is the same AtomicLongFieldLayout / AtomicIntFieldLayout the single-pass backend asks — because two backends disagreeing about where a field lives is not a wrong answer, it is a wild read. A test pins that agreement.

What the codebase made me declare

Adding one ir::Op variant failed to compile in FIVE places, every one of them a deliberate forcing function, and each wanted a different decision:

wherewhat it forced
ir_verify::expected_aritythe node's input shape ([ctrl, mem, obj])
declared_loweringthat it produces a value, not an effect
op_representativesa concrete instance for the coverage tests to drive
op_defines_result_slotthat it allocates a result slot
regalloc::ir_op_defines_valuethe same, for the LIVENESS model — without it every method containing the node silently loses register residency

And a sixth asked for a judgement rather than a fact: the arm ends in exactly one store_rax, so every_eligible_op_is_claimed_or_explicitly_rejected demanded it be claimed for the home-drop optimization or explicitly rejected with a reason. It is rejected: unlike every claimed op its arm is not straight-line — two deopts and a layout branch precede that store — and reasoning about what each deopt edge sees is exactly what that list exists to stop being done casually. Claimable later with a measurement; not worth a wrong answer to save one store.

Verification

probes/UnboxIntrinsicProbe.java mixes boxes from BOTH allocation paths on purpose — values inside the Integer/Long cache come from a preallocated table, values outside it are freshly allocated — so a compact/legacy branch that was wrong for either shape returns garbage for one group. Every answer is checked against a value computed without the accessor, so it cannot pass by agreeing with itself, and a null receiver must still raise NPE rather than read offset 0 of nothing.

It agrees with HotSpot exactly (SUMS -4 -3) under CRATONVM_COMPACT_REF_FIELDS=1 and =0, which exercises both offset derivations. The kill switch was checked in the direction that matters: with CRATONVM_JIT_IR_SCALAR_INTRINSICS=0 the two families reappear in the refusal log (86 lines); on, zero. Suite 92/92, 2,285 jit tests green.

Not claimed: any throughput number. h2-ab says an effect this size is inside this host's noise floor, and today's two withdrawn results are the reason that is left as a measurement someone takes on a quiet host rather than a figure asserted here.


2026-09-11 — a phi copy staged in RAX, and a census that redirected the work

Two things landed, and the second is the reason the first was findable.

The census: operand POSITION is 3.8%, not 82%

c2-one-carry-slot-is-the-frame-traffic-ceiling-FIXED-20260910.md closed by naming ir_schedule::pair_single_use_operands as the next lever, on the strength of the deferred carry declining 82% of its candidate windows for operand_position, and asking for a census of that pass because "which of those four dominates is not yet counted".

Built (ir_schedule::PairCensus — one counter per continue, under a debug_asserted accounting identity so a new reason cannot read low) and run over 188 probes, 34,289 windows:

causeshare
producer is multi-use78.3%
producer's arm not certified by op_home_is_one_store_rax16.2%
the four POSITION buckets, together3.8%
paired1.7%

The two figures have different denominators and both are right — the carry's 82% is over windows where a consumer already takes its first operand in RAX, the pass's is over every (consumer, operand) pair — but only the second says what the PASS could act on. 94.5% of the operands it sees were never eligible, and no scheduling change reaches them. The carry page now carries the correction beside its prediction.

Where it points instead: producer_arm, and inside it Op::Load. A getfield's three lowering paths are mutually exclusive and each ends in one store_rax with RAX holding the result, but the mechanical test counts self.store_rax(slot); textually and sees three, so the op is excluded from a certification it appears to satisfy. Unowned, and the per-op breakdown that would size it is one more counter.

The change: CRATONVM_JIT_IR_PHI_COPY_DIRECT, default ON

Every phi edge copy staged through RAX and then published into the phi's own register. On a loop back edge that is mov rax,r15 / mov r12,rax for a resident source and mov rax,[slot] / mov rbx,rax for one still in its word — one instruction per loop-carried value per iteration, to move a value that is already in a register or already in the word.

emit_copy_op now reads straight into the phi's register when it has one, so the publish disappears. It is the same program: the write to that register moves earlier inside ONE CopyOp, crossing only that copy's own store, so resolve_parallel_copy's cross-op invariant is untouched.

FieldLoop.sum's back edge goes from four instructions to two, its loop body from 26 to 24, and its body from 1071 to 1059 bytes. Measured (tools/tier-ab/cpu-ab.ps1, four invocations, all outside their own floors and agreeing on the sign, the last re-taken after merging dev): −4.8% / −7.0% / −7.3% / −10.5%, i.e. about 1.08x on that shape, and the tiering inversion there goes 1.208x → 1.11x on this host. FieldLoop.sumWide, which folds twice as many copies, is UNMEASURABLE — four times the arithmetic per iteration, so the same two instructions are a quarter of the share.

A restriction worth copying, not just recording. The first version also staged when the phi's home store survived, and wrote that home from the staged register. Replacing that store with panic!() left the entire cratonvm-jit suite green — 2356 unit tests and 145 differential tests — so the branch was shipping unexercised; and forcing it to run still could not catch storing the WRONG register, because nothing reads a resident phi's home word back. The change was narrowed to the home-dropped case (where the store does not exist at all) rather than the test weakened, and a_phi_copy_that_keeps_its_home_is_byte_identical pins the exclusion by demanding byte equality. One instruction given up on a path nothing reaches, in exchange for every remaining path being one the suite can fail.

And a third witness that the register file is not the constraint

c2-the-gp-register-file-is-not-the-binding-constraint-20260910.md argued from census counters that widening the GP file does not pay. The disassembly now shows what the two extra Win64 registers actually buy: with CRATONVM_JIT_IR_GP_WIDE=1 the induction variable's home store AND its reload on the back edge disappear entirely — lea r15d,[rbx+1], no frame traffic — which is exactly the store-to-load-forwarding pair this document traced the tier's residual 1.36x to. It still measures +0.4% against a 0.8% floor.

So that chain is not this loop's critical path, whatever its latency is in isolation, and the next person reaching for "the loop-carried value round-trips through the frame" has a measurement to answer first.

Full write-up, including the per-iteration instruction budget that says where the remaining gap is — 20 instructions against 26, with the two survivors being that this tier does not unroll (so it pays the safepoint poll and the back edge every iteration rather than every fourth) and that its receiver null check is explicit where the single-pass tier's is implicit — in internal/performance/c2-the-phi-copy-staging-register-20260911.md.

The same budget's next line: the loop branched the wrong way

FieldLoop.sum takes four branches per iteration at this tier against the single-pass tier's one, and one of the four was free:

25d: cmp ebx,r14d
260: jl  +5        ; to the loop body -- TAKEN every iteration
266: jmp exit      ;   ...skipping this
26b: <loop body>

The fused-branch arm picks its fall-through edge from branch_hints, which is empty without CRATONVM_TIER_PGO. The fallback that left behind — the true edge is the near one — is inverted for every javac counted loop, and for a reason this document already records in the range-BCE closeout: javac puts the loop body on the FALSE edge, because for (i = 0; i < n; i++) compiles to if_icmpge exit. So the near edge was the loop EXIT, the exit was not the next block, and ir_fallthrough_enabled's JMP rel32 elision — default-ON since 2026-09-09 and built for exactly this — could never reach it.

CRATONVM_JIT_IR_BRANCH_LAYOUT_POLARITY (default ON) takes the fall-through edge from the block LAYOUT when there is no hint: layout_hot_paths is default-ON, needs no profile, and block_idx + 1 is its decision. The sequence becomes one not-taken jge exit: −5 bytes, −1 instruction, −1 taken branch per iteration, and 41 branches take it on CratonBenchC2.

It measures nothing — UNMEASURABLE on FieldLoop (+0.6% against a 0.6% floor) and on CratonBenchC2 (−2.5% against a 4.8% floor), checksums identical throughout. It ships ON because it is weakly better in both instructions and taken branches and strictly better whenever the near edge would otherwise need a JMP, not because anything here shows it pays.

It does not override a profile hint, and a test caught it trying. step4_ir_lower_consumes_branch_bias_hint went red on the first version. The interaction it exposed is a real gap: ScheduleOptions::branch_counts is documented as taking the same per-bci bias the lowerer takes, and production_schedule_options() leaves it empty — so a profile informs the polarity of one Jcc and never informs which block is placed next. Populating it is small, and nobody has.

And a harness finding worth more than the number. The first run reported +1.3% against a 0.0% floor — the tightest this apparatus has printed, and meaningless: Windows accounts CPU in ~15.625 ms ticks, the samples were 0.586 s, so one tick was 2.7% of a sample and both medians had merely landed on the same one. cpu-ab.ps1 now prints the tick as a percentage of the median and refuses a verdict inside it. That is the second way a clean floor misleads — the first being drift between invocations (§5.2 of the GP-register page) — and both make a tight floor read as permission to stop.

CORRECTION: "the optimizing tier does not unroll" — true, and not for the reason implied

This document has said since 2026-09-03 that the optimizing tier does not unroll, priced it at about 1.12x on a counted loop, and listed it as the largest remaining item in that tier's per-iteration budget. All three stand. What does not stand is the conclusion anyone would draw from them — that an unroller needs writing.

ir_optimize::unroll exists, is default-ON, and recognises a javac counted loop exactly. Driven against a real bytecode-built for (i = 0; i < 5; i++) a += i; it reports trip=5 init=0 stride=1 and then declines, silently, on the body_named_by_safepoint refusal — whose escape hatch is gated on CRATONVM_JIT_IR_DROP_UNREACHABLE_HOMES, default OFF. With that flag set, the same loop unrolls. The flag's own sibling (CRATONVM_JIT_IR_REG_AUTHORITATIVE) rests on the identical prediction, was soaked and flipped ON on 2026-09-09, and says so in its doc comment; the flag it names was never revisited.

And that would not reach the loops that matter. ir_optimize::UnrollCensus — one counter per continue, under a closing identity — says every counted loop in both benchmark suites has a RUNTIME bound, which full unrolling can never serve:

CratonBenchC2CratonBench
loops found (merges − not_single_backedge)136
of which runtime-bounded64
safepoint_named00
unrolled00

So the thing to build is a PARTIAL unroller, in the single-pass tier's own shape (keep the test in every copy, amortise only the poll and the back edge — no trip-count arithmetic, so none of the overflow hazard the range-BCE closeout records). It was designed and deliberately not built, because both of the gates under which it could be written without touching deopt metadata measure zero:

gateasksCratonBenchC2CratonBenchFieldLoop
whole method trap-freegraph_cannot_deopt0 of 60 of 40 of 1
cloned nodes all purethe real obligation0 of 60 of 40 of 1

The second is zero for the same reason these loops are worth unrolling: FieldLoop.sum's body IS a field read, and Op::Load is not pure.

What unrolling actually needs is one thing, and it is the same for both unrollers: a deopt point addressable per COPY rather than per bci. DeoptimizationPoint already carries (native_offset, bci, frame_state) and two points may share a bci — the representation is fine. Two things collapse them: bci_native keeps the EARLIEST offset per bci, so only copy 0 is anchored, and find_deopt_point is an exact-offset binary search returning None for the rest; and graph.safepoints has one snapshot per bci naming the original nodes, so a later copy has no frame describing its own values. That is a bounded change to three named places, and it is the prerequisite for every version of this feature.

Full write-up, including the census, the refusal taxonomy and the partial-unroll design that was not built, in internal/performance/c2-unrolling-is-a-deopt-metadata-problem-20260911.md.

FOLLOW-UP: the per-copy deopt frame, built (CRATONVM_JIT_IR_PER_COPY_FRAMES, default OFF)

The "bounded change to three named places" above is done, and it is off by default because it is deopt metadata: the failure mode is a right-looking wrong answer, not a crash.

  • Node::frame_snapshot: Option<u32> — the per-copy identity, on the node. None on everything the builder makes, so the by-bci scan is unchanged for every compile that does not unroll. Graph::set_node_frame_snapshot refuses a snapshot whose bci is not the node's own.
  • ir_optimize::install_copy_frames — one substituted snapshot per iteration; iteration 0 rewrites its own in place so bci_native's anchor and the frame at it keep describing the same code.
  • Lowerer::snapshot_native / resolve_frame_state_for_site — the anchor and the frame taken from the copy rather than from the bci.

Two places the design note did not name turned out to matter. GVN's identity now includes frame_snapshot: two copies of a body compute the same value at different program points, and merging them hands one copy's code the other's frame. And ir_verify's duplicate-bci rule, which existed because of this exact collapse, is now "a duplicated bci is a violation unless every snapshot at it is claimed by a node" — an unclaimed duplicate is still the bug, and is what a half-finished copy looks like.

One thing the end-to-end run taught that is not about unrolling. The probe that exercises a deopt out of copy 3 still crashed on a default run, and the reason was ir_evidence::accept: it priced the unrolled C2 body as not worth publishing and handed the method back to the single-pass tier. The C2 body was never running. CRATONVM_C2_ACCEPT=always installs it, and then all three shapes match HotSpot exactly (20 000 NullPointerExceptions out of a cloned body, each resuming in the copy that trapped). Worth remembering generally: with an acceptance gate between a transform and its execution, "the checksum matched" can be a statement about code that never ran.

It wins nothing measurable yet, and that is expected. The census above says every counted loop in both suites has a runtime bound, so per_copy_frames (a new sub-count of unrolled) is zero there. What it buys is that the sentence the previous page ended on is no longer owed: the partial unroller can now be written against a frame mechanism instead of around one.

One cost is worth knowing before it is discovered: safepoint slots are DCE roots, so per-copy frames keep every iteration's intermediates alive to describe them. On the for (i = 0; i < 5; i++) a += i; fixture the loop folds to Const(10) and five Const nodes survive anyway, materialised purely for the frames. The narrower fix (root only snapshots that can be consulted) is a DCE change, not this one.

Full write-up in internal/performance/c2-per-copy-deopt-frames-20260911.md.

FOLLOW-UP: the partial unroller was written (CRATONVM_JIT_IR_PARTIAL_UNROLL, default OFF)

2026-09-11, same day. It keeps the loop test in every copy — so no trip-count arithmetic and no speculation — and sends each copy's failing test back to the header rather than to a new exit merge, which is what keeps the transform closed under the loop and leaves every post-loop use and safepoint slot untouched.

It is correct and it is not faster: 9 alternating pairs on bench/C2PartialUnrollProbe.java read 356 ms rolled against 362 ms unrolled at factor 4 — a ratio of 0.98 against a ±8% spread — with checksums matching Temurin 25 on trip counts both divisible and not divisible by the factor. The per-iteration instruction count does fall, 20 to 16.25. It buys nothing because the rolled loop keeps a and i in rbx/r15 with no memory operand in its loop at all and the unrolled one spills every carried value: sink_pure_nodes moves a node only when the loop depth strictly DECREASES, and every copy of an unrolled body sits at the header's own depth, so all four are computed above the first test and eight intermediates contend for a five-register file.

The sentence at 1437 and item 1 at 1484 both need a caveat now. The optimizing tier can unroll; unrolling is not by itself what the baseline's 4x buys. The baseline also colours its locals into callee-saved registers, and that is the half this tier is still missing.

Two wrong-code defects were found on the way, both in shared code, both live before this transform and reachable by anything that clones a control node: an If's successors were ordered by node id rather than by projection index, and an OSR entry resolved a bci two blocks claimed. Both are fixed and pinned by tests.

Full write-up, including why the obvious schedule-late fix is not landed, in internal/performance/c2-the-partial-unroller-20260911.md.


Built, tested, parked on a measurement — the list, so it stops being invisible

Two optimizations in this backend are complete, soaked at the unit level, and default OFF for want of a number rather than for want of code. They are collected here because a capability that is off by default and mentioned only in a doc comment is indistinguishable, from outside, from one that does not exist.

This table said "three" and listed CRATONVM_JIT_IR_LINEAR_SCAN until 2026-09-16. That entry was wrong — the flag has been default ON since 2026-09-02 — and it was wrong because the table was written from the register-residency section above, which was itself stale. The lesson is the one this document keeps re-learning: a flag's default is a fact about *_enabled(), and reading it out of prose reproduces the prose's age.

flagdefaultwhat it turns onwhat the flip needs
CRATONVM_JIT_REASSOCoffaffine strength reduction (ir_optimize::reassociate_affine) — collapses an unrolled affine recurrence x = x*c1 + c2 (×N) into one k*root + ca quiet-host A/B — correctness is no longer the open question. See below
CRATONVM_SCALAR_DEOPToffa scalar-replaced object survives a guard failure by materialising from a FrameValue::VirtualObject descriptor, instead of forcing a whole-method re-runnot a measurement — see below. Its soak RAN, and found the flag green and inert

The CRATONVM_JIT_REASSOC correctness soak ran on 2026-09-16

Added 2026-09-16. The row above used to say the flip needed "a soak"; that conflated two different soaks, and only one of them is still outstanding.

cratonvm-difftest fuzz-jit --seed 300 --count 150 --modes nojit,direct-emit,ir-jit with CRATONVM_JIT_REASSOC=1 ran 150 programs and reported 3 failures. All three (seeds 335, 345, 350) were the interpreter's dup2_x2 bug fixed the same day — each panicked at its own dup2_x2 with the oracle unusable, and none was a reassociation miscompile. See internal/fixed-bugs/interpreter-dup2_x2-missed-jvms-form-3-FIXED-20260916.md.

So: reassociation is differentially clean over 150 programs against the interpreter. What the flip still needs is the performance number — the quiet-host A/B on compute kernels — not a correctness argument. That is a materially smaller ask than the row implied, and worth writing down, because "needs a soak" is the kind of phrase that keeps a finished feature parked indefinitely once nobody remembers which soak was meant.

CRATONVM_SCALAR_DEOPT is a different case from the other two, and saying

"it needs a measurement" understates it

Corrected 2026-09-16, after reading the soak instead of the flag. The 2026-08-27 gauntlet soak (internal/performance/scalar-deopt-gauntlet-soak-20260827.md, summarised in feature-designs/activate-ir-optimizer.md) did run, and its finding was not "we could not tell". It was:

the flag green and inert: 30,392 allocation-bearing IR compiles across netty and hibernate produced ZERO scalar replacements, so it emitted no descriptor anywhere — including on the probe written to exercise it. Flipping it on that evidence would record a soak that never ran the feature.

So the blocker is one level down. The question is not "is guard-surviving materialisation worth it" but "why does escape analysis scalar-replace nothing on real workloads", and until that has an answer this flag cannot be soaked at all — there is nothing for it to do.

That question is answerable with the instrument already in the tree: ir_evidence's Transform::ScalarReplacement is noted whenever the optimizer replaces one, so a run over netty/hibernate with the evidence census on says directly whether the 2026-08-27 reading still holds. Note that 26c880f88 ("allocation elision fires by default") landed after that soak, so the reading may have moved; nobody has re-taken it.

The order of work this implies: re-take the census; if EA still fires zero times on those workloads, that is the defect and this flag is downstream of it. Do not flip this flag first.

The rule this table is written under. This project does not flip a default on the strength of a reading; every flip above the line got a soak (IR_CALL/IR_LONG/IR_FP/LICM/UNROLL all name theirs). Nothing here argues for changing that. What it argues for is that "parked" is a state somebody has to be able to see, with the specific missing evidence named, so that the next person with a quiet box knows exactly which three runs to do.

How to tell a parked flag from a dead one. A parked flag has a soak condition in this table. A flag with no line here and no consumer is dead code — jit/src/pgo.rs is the worked example, and docs/jit/pgo-inlining.md §1 is how that gets written down.


Escape analysis offers no scalar-replacement candidates — measured, 2026-09-16

This section replaces an inference this document and its neighbours had been repeating: that the 2026-08-27 gauntlet soak showed escape analysis "scalar-replaces nothing on real workloads", and that CRATONVM_SCALAR_DEOPT was therefore blocked on a workload property. The finding is sharper than that and sits one stage earlier in the pipeline.

First, two instrument defects had to be fixed before the question could be asked at all, and both are worth recording because both would have produced a confident wrong answer:

  1. ir_evidence::transform_census() had no caller. The counter was correct and pub and nothing read it, so on a release binary the counts accumulated and were dropped at exit. Fixed with exit_summary, on both VM exit arms, under CRATONVM_JIT_TRANSFORM_CENSUS.
  2. The Transform::ScalarReplacement note was attached to escape_analysis::apply_scalar_replacement, which has no production caller — every call site is a unit test in that module. The shipping path plans on the EA graph and applies on the IR graph (lib.rs, plan_scalar_replacement plus the victim splice). A counter on an unreachable applier reads zero whether or not the optimization is running, which is worse than no counter: it answers wrongly rather than not at all.

With both fixed, and a probe (ScalarReplProbe) written as the deliberate mirror of IrEscapeProbe — every allocation consumed entirely within the method that made it, no arrays, no statics, nothing that would refuse IR admission — forced to the optimizing tier with CRATONVM_JIT_FORCE_C2=1:

[jit-transform-census] scalar-replacement funnel: candidates=0 planned=0
[jit-transform-census]   scalar-replacement = 0

candidates=0 is the reading that matters. Escape analysis offers nothing to replace. The planner's refusal arms are not implicated, the applier is not implicated, and no workload property is implicated — on the friendliest input that can be constructed, the analysis produces an empty scalar_replaceable. That is where anyone trying to flip CRATONVM_SCALAR_DEOPT should start, and it is a different place from where the parked-flag table has been pointing.

The candidates / planned pair exists so this stays answerable: a zero in the first is a statement about the analysis, a non-zero first with a zero second is a statement about the planner.

Three successive refutations, and where the question now sits

Added 2026-09-16, after building two more instruments. Each stage refuted the explanation the previous stage suggested, which is the only reason this is not still confidently wrong.

Stage 1 — candidates=0. Established that escape analysis offers nothing. Suggested the planner was innocent, which was right.

Stage 2 — all thirteen refusal rows are ZERO. The refusal census (escape_analysis::scalar_refusal_census) counts every ScalarRefusal reason at the single exit of find_scalar_replacements. Every row reads zero. That refutes both explanations available from reading the module:

  • "Every new escapes to its own <init>" — Op::Call does arg-escape every reference operand (escape_analysis.rs, the Op::Call arm), and a Java allocation is always new / dup / invokespecial <init>, so the reasoning is sound. It is not what is happening: refused[escapes:arg-escape] = 0.
  • "The refusal a hot loop hits" — ScalarRefusal::LoadNotAnswerable's own doc comment says so. Also not happening: that row is zero too.

Escape analysis is not rejecting these allocations for any reason. It is not seeing an allocation to reject.

Stage 3 — analyses=2, allocation-nodes-seen=0. The scope census (scalar_scope_census) separates "never ran" from "ran on a graph with no allocation node". The analysis RUNS, on graphs containing zero Op::New/Op::NewArray. Two further candidates are eliminated by direct check:

  • the IR→EA bridge maps allocations correctly (ea_ir_bridge.rs, ir::Op::New => EaOp::New, ir::Op::NewArray => EaOp::NewArray);
  • the allocation sites are not taking the deferred/unresolved path — [c2-supersede] deferred-new retries: held=0 spent=0 and ir deferred carries: candidates=0, so new_info is populated and ir.rs's 0xbb arm emits a real Op::New rather than planting TrapCause::UnresolvedNew.

And the allocating method IS admitted: [ir] admission ScalarReplProbe.hot(II)I: admitted to the optimizing pipeline.

What is left, stated precisely. Five methods are admitted to the optimizing pipeline and escape analysis runs one or two times, on graphs with no allocation in them — i.e. not on hot. So the open question is no longer about escape analysis at all: it is why the EA block does not run for the admitted method that allocates. The block is guarded by cached.exception_table .is_empty() (lib.rs), and admission is documented elsewhere in this file as a PREDICTION — a method the chain admits can still fall back to the single-pass backend inside the pipeline, in which case no IR-tier pass runs for it at all. Either would produce exactly this reading, and distinguishing them needs one more counter (which method each analysis ran on), not more reasoning.

That is a deliberate stopping point. Every explanation offered so far has been refuted by the next measurement, and the honest move is to note that the next step is a counter rather than to offer a fourth story.

An open lead, stated as a lead and not as a finding

While measuring the above, ScalarReplProbe — whose hot method is called 100,000 times — did not promote past C1, and the optimizing tier engaged only under CRATONVM_JIT_FORCE_C2=1. CRATONVM_DBG=jit-method-stats reports:

3 distinct methods tracked, 2 ever invoked, 5032 total invocations
  still-interpreted=1 c1=2 full-profile=0 c2=0
  compiles: c1=2 c2=1 osr=1   c1_threshold=500   hot_but_stuck_in_interpreter=0

Two things in that line are unexplained. The counted invocations (≈5,000) are far below the real call count (100,000), and the methods sit at c1=2 / c2=0 even when CRATONVM_TIER_C2_THRESHOLD is lowered to 1,000 — below the count that was actually reached. tiered.rs does have a C1 -> C2 arm guarded on invocation_count, and it also has a "settling" mechanism that lets a call site stop asking once a method looks stable.

The obvious hypothesis is that invocations stop being counted once a method is C1-compiled, which would make the C1 -> C2 arm unreachable in practice and leave OSR as the only route into the optimizing tier. That is a hypothesis from reading the code plus two runs, not a measured conclusion, and it is deliberately not written up as one. If it holds it would suppress the whole optimizing tier rather than escape analysis alone, so it deserves its own investigation rather than a paragraph here.

Computed but never read — the other half of the same census

A parked flag is a capability with a switch nobody has flipped. These are capabilities with no switch at all: an analysis produces the answer and nothing consumes it. They cost compile time on every compile that reaches them and buy nothing, and — more to the point — they are invisible from outside, because there is no flag to look up and no counter to read.

produced bywhat it isconsumer
escape_analysis::EscapeResult::lock_coarseningplans to merge two adjacent monitor regions on the same object into onenone WIRED 2026-09-16 in apply_ea_to_ir_pinned, behind CRATONVM_JIT_LOCK_COARSEN (default off), with LOCK_COARSENING_PLANS_OFFERED / LOCK_COARSENINGS_APPLIED so the two numbers can be read apart
escape_analysis::EscapeResult::stack_allocatableevery ArgEscape allocation — the population a stack-allocating tier would takenone. The module header says so: "computed, never read", and calls a non-empty result "a measurement, not an optimisation"
ir_optimize::licm_scev_corroboratesthe bytecode-side counted-loop corroboration of an SSA hoistnone as a gate DELETED round 9 wave 3, with the legacy scev::InductionVar / loop_analysis::{detect_loops, find_invariant_loads} API it was the only caller of. A bytecode-threaded gate, if one is ever wanted, should be built on loop_analysis::analyze_counted_loop_at + body_is_heap_stable

Lock coarsening was the one of the three that was finished, and it is now wired (see the row). The remaining two are not finished and should not be described as if they were: stack_allocatable has no allocator to hand its population to, and the SCEV bridge needs bytecode threaded into a function that does not take it.

The same pattern is easy to re-create, and this document did it

Added 2026-09-16. The round-3 work added ir_evidence::TRANSFORM_CENSUS — the per-transform counter whose absence had forced the 2026-08-27 scalar-deopt soak to build a bespoke instrumented binary — and its doc comment ended with the words "this is a reader." It was not. transform_census() was pub, it was correct, and nothing in the tree called it, so on a release binary the counts accumulated and were then dropped on exit. That is the same failure as the three rows above, committed by the change that was documenting the failure.

The general lesson is worth more than the fix: pub fn is not a consumer. A counter is read when a caller exists, and on this codebase a caller that is only a debug! does not count either, because the workspace pins release_max_level_info and compiles those away. exit_summary is now that caller, on both VM exit arms under CRATONVM_JIT_TRANSFORM_CENSUS, and it prints the zeroes — a zero is what separates "the pass ran and was neutral" from "the pass never ran", which is the entire question the census exists to answer.

Of the three, lock coarsening is the one that is finished. Its applier is written, it re-verifies rather than trusting the plan (an elision that already removed the pair is a no-op; one that removed the outer enter or exit is a refusal, so coarsening can never compound an unbalanced sequence it did not create), and the only thing between it and production is a call in apply_ea_to_ir_pinned beside the lock-elision loop, plus a default-off flag and a soak. The other two are not finished and should not be described as if they were: stack_allocatable has no allocator to hand its population to, and the SCEV bridge needs bytecode threaded into a function that does not take it.