CratonVM
Real-frame deoptimization + precise OSR (entry and exit)
Real-frame deoptimization + precise OSR (entry and exit)
Status: Shipped (default on; CRATONVM_DEOPT_REAL=0 opts out).
A guard or loop bail resumes at the trapping bci instead of re-running the whole method. The shared per-pc state map, the virtual-object re-materialization consumer, de-speculation with epoch invalidation, and the category-2 / floating-point resume are all live.
Two companions stay default-off, so an OSR exit takes the safe reject path:
CRATONVM_OSR_EXIT_TRANSFER (in-place OSR-exit transfer) and
CRATONVM_SHADOW_OSR_TRACK (moving-GC OSR-frame tracking).
Footprint cost: every JIT frame reserves an extra 256 B for the
SavedRegisters deopt region while the feature is on.
This document is the combined design. The per-feature current state lives in
real-frame-deopt.md,
jit-osr-entry-metadata.md and
jit-osr-exit-and-recompile.md.
Problem & motivation
Today a JIT method that hits a failed speculation returns the i64::MIN sentinel and the interpreter re-runs the whole method from bci 0 (vm/src/runtime/interpreter.rs -> CacheMiss / slow sink). Re-running a method with side effects double-executes them. That is the root constraint:
- Speculative optimization is throttled. Null-check elision, monomorphic inline caches, CHA-based devirtualization, and scalar replacement all need a cheap, correct fallback when the speculation fails. Without precise frame reconstruction the JIT must keep slow paths inline or decline the optimization, because re-running is unsound past any side-effecting bytecode.
- OSR can enter but can never cleanly leave. A long-running loop is OSR'd into JIT code, but if that compiled frame must bail mid-loop (a rare branch, a guard, a class load that invalidates an inlined callee), there is no way to hand control back to the interpreter at the current loop bci with the loop's live state. Re-running from entry is semantically incorrect because the pre-loop prologue may have side effects, and the loop counter state is gone.
- Scalar replacement is unsafe across a guard. Escape analysis (
jit/src/escape_analysis.rs) can prove an object non-escaping along the fast path and elide its allocation — but if a guard failure needs that object, deopt must re-materialize it on the heap.
The payoff is the standard tiered-JIT contract: speculate aggressively on the fast path, and on the rare guard miss, rebuild the exact interpreter frame and continue — no double execution, no whole-method re-run.
Current state & codebase citation
1. Deopt data model (jit/src/deopt.rs)
FrameValue—Int/Float/Object/Register(u8)/StackSlot(i32)/StackSlotRef(i32)/VirtualObject(VirtualObjectState)/VirtualObjectRef(usize)/Undefined/Unsupported/Double(u64)/RegisterLong(u8)/XmmFloat(u8)/XmmDouble(u8)/StackSlotFloat(i32)/StackSlotDouble(i32).VirtualObjectState—id,class_id,num_fields,field_values: Vec<FrameValue>. Recursive descriptor for a scalar-replaced object (handles cycles/references viaid/VirtualObjectRef).FrameState—method_key,bci,locals,stack,monitors,caller: Option<Box<FrameState>>(inlined caller chain).DeoptimizationPoint—native_offset,bci,reason,action,speculation_id,frame_state. Per-safepoint record.DeoptimizationLog— records events, drivesshould_give_up/most_common_reason/recommend_action.InvalidationManager— assumption tracking (LeafClass/UniqueConcreteMethod/…) with reverse indices for invalidation.
2. CompiledMethod deopt plumbing (jit/src/lib.rs)
CompiledMethodfields:deopt_points: Vec<DeoptimizationPoint>,can_deopt_resume: bool,can_osr_exit: bool,compilation_epoch: u64,osr_exit_points: Vec<usize>.deopt_real_enabled()(CRATONVM_DEOPT_REAL) anddeopt_verify_enabled()(CRATONVM_DEOPT_VERIFY) read-once env gates.
3. OSR-entry substrate (jit/src/x64.rs & vm/src/runtime/)
- Canonical loop-boundary PC set:
osr_entry_native: Vec<i32>is positive at boundary PCs outside all LICM-hoisted loop bodies. OSR-exit reuses this vetted PC set. - Entry trampoline:
CompiledMethod.osr_entertakesvm_ptr,jit_locals,entry_pc,thread_ptr. - Driver:
try_osr_with_backofforchestrated from the dispatch loop.
Detailed Design & Implementation
A. Shared per-PC state map
These are inverses of the same regalloc state at a PC:
- Exit map (machine -> interpreter):
DeoptimizationPoint.frame_stateemitted at each eligible PC. Used by deopt-exit and OSR-exit. - Entry map (interpreter -> machine):
osr_pc_to_native+ OSR trampoline local/XMM assignment copy.
To keep both consistent, the exit map emission reuses the same canonical-boundary PCs that OSR-entry already trusts (loop headers / block boundaries outside hoisted loops).
B. Virtual-object (scalar-replaced) re-materialization
Implemented in deopt_materialize.rs via materialize_virtual_objects. It mutates ReconstructedFrame in-place, rewriting every VirtualObject/VirtualObjectRef slot to a real FrameValue::Object(addr).
- Two-phase, cycle-safe materialization:
- Phase 1 — shell allocation: Collects all distinct
VirtualObjectState::ids reachable from the frame. Allocates a pinned shell per id viaTempRootScope::alloc_shell(TLAB allocation + class/mark header initialization). - Phase 2 — field stores: Fills shell fields via
field_value_to_value+store_field_barriered(realputfieldSATB/card barrier). Handles cyclic/shared references by retrieving shell addresses from Phase 1.
- Phase 1 — shell allocation: Collects all distinct
- GC-rooting handoff:
- Shell pins must be rooted continuously from allocation until the resumed frame is on the stack.
materialize_virtual_objectstakes akeep_pinsparameter. On the live path (keep_pins = true), itmem::forgets the scope so shell pins persist innative_pin_roots. The resume sink owns releasing these pins via its existingpin_base..truncatewindow after the frame is pushed ontothread.frames.
C. OSR-exit (mid-loop deopt-out) & True OSR-exit transfer
OSR-exit is a real-frame deopt specialized for loop BCIs:
- Emit exit maps: Done on the x64
Compilerviaemit_osr_exit_map_atat loop boundaries, tagging the points withDeoptReason::OsrExit. - True OSR-exit transfer:
transfer_osr_exit_into_live_frame(vm/src/runtime/interpreter.rs) performs an in-place mutation of the active interpreter frame (overwriting locals + operand stack) rather than a full frame rebuild-and-swap. This preserves the frame's identity and metadata. It maps inputs before writing to allow a safe rollback if an unsupported type is hit. - OSR Driver Safety: If OSR-entered JIT code bails, the driver intercepts the
i64::MINsentinel, checkstake_last_deopt(), and rejects the OSR (falls back to interpreter execution) to avoid corrupting values or looping indefinitely. Oncecan_osr_exitholds, whole-method re-runs viai64::MINare disabled for OSR-compiled methods.
D. De-speculation & Epoch Invalidation
- De-speculation: Every deopt/OSR-exit routes through
real_frame_deopt_resume_and_despeculate. It logs the event, evicts the artifact fromjit_cache(making it not entrant so subsequent calls trigger recompilation), and escalates toMakeNotCompilableif the deopt rate exceeds a threshold. - Epoch Invalidation: To prevent stale boxed
DeoptimizationPointpointers from being followed after recompilation,SharedVm.method_epochstracks live epochs. Fresher artifacts are stamped with the live epoch at install. Before resuming, the sink assertscompiled.compilation_epoch >= live_epoch(M).
Step-9 follow-ups (done, gated CRATONVM_DEOPT_REAL, gate-off byte-identical; bt18 = 68332206 gate-off and gate-on)
- In-entry before-deref epoch check (
CRATONVM_JIT_FREE_CODE=1): the epoch comparisonmethod_epochsenables is now also done inside the deopt trampoline, before the box is dereferenced. A process-lifetime-retainedDeoptEpochGuard { creation_epoch, live_epoch_cell }(jit/src/deopt.rs) is baked as a 4th arg into every frame-deopt stub and stamped by the VM at install (CompiledMethod::stamp_deopt_epoch_guard);method_epochsis nowFxHashMap<String, Box<AtomicU64>>so the live cell has a stable address (SharedVm::live_epoch_cell_ptr).x64_deopt_entryconsults the guard FIRST and, on a superseded artifact, stashes abci = u32::MAXre-run sentinel without touching the (possibly-freed) box. Deopt-point boxes are now retained even underCRATONVM_JIT_FREE_CODE=1, so the baked box pointer can never dangle. - Eager recompile re-queue: on a
RecompileAndReinterpretaction,DeoptimizationController::deoptimize(vm/src/jit/helpers.rs) eagerly enqueues a high-priorityCompilationTaskwhen a background compiler is active, instead of waiting for the method to re-cross the interpreter hotness threshold. No-op (no queue leak) when no worker drains it — the hotness-retry path still recompiles, so behaviour never regresses. - Per-bci de-spec: a process-global
(method_key, bci)registry (deopt::despec_insert/despec_contains, allocation-free when empty) plusDeoptimizationLog::deopt_count_at_bci. Once a single speculation site has deopted ≥PER_BCI_DESPEC_LIMIT(4, HotSpotPerBytecodeTrapLimit-like), the sink records it and the optimizing backend drops that speculative-BCE loop-header guard on recompile (method_keyplumbed throughcompile_with_param_slots) — so one pathological site is de-spec'd and the method stays compilable instead of a whole-method blacklist (diffuse deopts still hit the per-method backstop). Skipped for the superseded-sentinel bci so a stale artifact never de-specs a non-current speculation.
E. Cat-2 & FP Real-frame Resume
Allows methods with long, double, and float variables to resume:
- SavedRegisters expansion: Expanded to 256 bytes to store
xmm: [u64; 16]in addition to GPRs. - Width classification:
classify_local_kindsscans method opcodes to buildCompiler.local_kinds, mapping locals to specific widths/types (Int,Long,Float,Double,Ref,HighHalf,Ambiguous,Unknown). - Sound fallback: Provenance-kind contradictions or
Ambiguousslots resolve toUnsupportedto trigger a safe whole-method re-run instead of silent corruption.
Incremental delivery plan & Progress status
All steps are merged and build-green on dev:
- Step 1: x64 BCE Pilot Guard Snapshot: Emissions of exit maps at BCE pilot guard, in-stub deopt entry, and frame reconstruction.
- Step 2: GPR Spill & Stash: Frame-deopt stub spills RAX..R15 into
SavedRegistersand stashesLAST_DEOPT. - Step 3: Reconstruct & Root: Interpreter sink builds
Frameat trapping bci, rooting oops innative_pin_rootsbefore refill. - Step 4: Flip the Resume: Sink resumes Object-bearing deopts at trapping BCI; implements the GC-rooting handoff.
- Step 5: Virtual Shell Allocation: Two-phase shell allocation & GC rooting in
materialize_virtual_objects. - Step 6: Virtual Field Stores: Field fills, cyclic patching, Card/SATB barriers, and resume enablement.
- Step 7: OSR-exit Map Emission: Exit maps recorded at OSR-vetted loop boundaries.
- Step 8: Flip OSR-exit & Transfer: Loop-bci resume flip, OSR-driver safety integration, and true OSR-exit state transfer.
- Step 9: De-speculation & Epochs:
DeoptimizationLogintegration, compilation epoch bumping and invalidation. - Workstream P2: Cat-2 & FP Resume: Typed local classification, XMM register spilling, and resolver support.
Risks & open questions
- Elided monitors on scalar-replaced objects:
can_deopt_resumeis keptfalse(forces re-run) when the compilation'sscalar_replacedset is non-empty or the method isACC_SYNCHRONIZED. Monitor re-entry at resume remains a future refinement. - Every-boundary vs loop-headers-only exit maps: Emitting exit maps at every canonical boundary maximizes coverage but inflates metadata. We default to loop-headers-first.
- OSR-track vs moving GC: Relocating live oops in an OSR frame requires
CRATONVM_SHADOW_OSR_TRACK, which currently regressesbt18. Non-moving sweep configurations sidestep this, but resolving the precise-move × conservative-pin interaction is a prerequisite for making OSR-exit default-on under a moving young generation.
Validation / verification plan
- Automated Tests:
- VM deopt tests (
vm/src/runtime/deopt_materialize.rsandinterpreter.rsstep3 tests): covers virtual materialization, cyclic object graphs, GCs during build, and epoch bumps. - JIT tests (
jit/src/deopt.rs&x64.rsclassifier/resolver tests).
- VM deopt tests (
- Differential Verifier (
CRATONVM_DEOPT_VERIFY):verify_reconstructed_framechecks structural invariants (slot counts, malformed virtual descriptors, dangling references).verify_reconstructed_oopsasserts that object reference slots are either null or contain a valid heap address, acting as a runtime fail-safe.
- Live Through-JIT Eager Deopt Validation:
- Under
CRATONVM_DEOPT_EAGER = 1, the JIT records snapshots at the first loop header and unconditionally branches to the deopt stub. Verified with separate-process differential checks onNoArrayDeoptandbt18workloads.
- Under
- Remaining verification follow-up:
- Eager-deopt value differential: Blocked by read-once cached env gates. Requires adding a forced-BCE-deopt gate in
emit_deopt_stubsand executing a separate-process runner comparing gate-on vs gate-off stdout/checksums.
- Eager-deopt value differential: Blocked by read-once cached env gates. Requires adding a forced-BCE-deopt gate in