CratonVM
Inlined scopes and the deopt frame chain
Companion to deopt-metadata.md (what the metadata contains) and
deopt-frame-state-interning.md (how it is stored). This one covers the two
gaps §5.1 and §5.2 of the interning doc listed as producer edits required:
- inlined caller scopes —
FrameState::callerwas hard-codedNonebecause nothing recorded which inlined scope a safepoint belonged to; - the reexecute flag —
ResumeSemanticsexisted but reached only the interned form, somaterializedropped it and every consumer re-derived re-execute-vs-resume fromDeoptReason.
Both are closed on the IR side here. Neither changes any compile today: the scope table is empty on every compile (the IR builder does not inline), and every producer stamps the semantics the convention already implied.
1. The scope representation (jit/src/ir.rs)
pub struct InlineScope {
pub method_key: String, // the CALLER's key
pub caller_bci: u32, // the invoke, already in progress
pub caller_snapshot: Option<u32>, // index into Graph::safepoints
pub parent: Option<InlineScopeId>, // the caller's caller
}
pub struct InlineScopeTable { /* scopes + snapshot→scope bindings */ }
Read a scope as "the frame parked mid-invoke while the scope below it
runs". A SafepointSnapshot bound to scope s is the innermost frame; s,
s.parent, … are the frames stacked above it.
Why a side table and not a field
deopt-frame-state-interning.md §5.1 proposed
SafepointSnapshot { …, inline_scope: Option<InlineScopeId> } plus a per-graph
table on Graph. Both break every struct literal of those types in the crate,
and there are a lot of them in files owned by other passes:
| type | literal sites that would need a new field |
|---|---|
ir::SafepointSnapshot | jit/src/lib.rs:14982, :15021, :15049; jit/src/ir_verify.rs:1682, :1701, :1743; jit/src/ir_optimize.rs:3332, :3371; jit/src/ir_lower.rs ×3; jit/src/ir.rs ×6 |
ir::Graph | ~20 sites across lib.rs, ir_verify.rs, ir_optimize.rs, ir_schedule.rs, ir_lower.rs, ir.rs |
A table threaded alongside the graph costs one parameter at the lowering entry
point and nothing at all to the hand-built graphs in the test suites. It is
also the shape the producer wants: an inliner appends a contiguous run of
snapshots per splice, which is one bind_snapshot_range call.
The index-keying invariant
by_snapshot is keyed by the snapshot's index in Graph::safepoints. That is
sound because nothing in the pipeline removes or reorders that vector —
IrBuilder pushes, ir_optimize (:2041) and lib.rs (:8936) rewrite slots
in place, and the lowerer only reads. An index past the end reads back None,
so a table built against a shorter graph degrades to "no inlining info" rather
than to a wrong scope. If a future pass ever compacts safepoints, it must
remap the table.
Bounds
MAX_INLINE_SCOPE_DEPTH = 64, enforced at push_scope (HotSpot's own limit is
9). push_scope also refuses a parent the table never issued — a parent id is
always strictly smaller than the child's, so chains are acyclic by construction
rather than by defence at every walk.
2. Consumption (jit/src/ir_lower.rs)
Lowerercarriesinline_scopes: &'a InlineScopeTable.resolve_frame_state(sp, index)takes the snapshot's index (the lookup inresolve_frame_state_for_bcibecame aposition, still first-match-wins).caller_chain_for(index)buildsFrameState::caller, outermost last.resolve_frame_values(sp)is the split-out half that resolves a snapshot's(locals, stack)without touching scopes — socaller_chain_forcan resolve a caller's snapshot without mutual recursion.lower_inner_with_scopes(…, &InlineScopeTable)is the new entry point;lower_innerdelegates to it with an empty table.
Fail-closed on an undescribed caller
A caller frame is not empty — its locals and operand stack are live. When a
scope names no caller_snapshot, the lowerer emits a frame holding one
FrameValue::Unsupported, not an empty one. An empty caller frame is silently
wrong: every resume sink maps a missing slot to Value::Int(0), so the
interpreter would resume the caller with all-zero locals and no error anywhere —
the exact class of failure FrameValue::MaterializationRequired exists to
prevent.
3. frame_state_is_resumable now walks the chain
deopt-frame-state-interning.md §6 flagged this as the hazard that arrives
with caller chains:
The owned
frame_state_is_resumableonly inspects the innermost scope, which is correct today only because no producer builds a chain. Once §5.1 lands, the resume sinks must switch to the chain-wide predicate, or a caller scope holding aMaterializationRequiredslot will resume as if it were clean.
The predicate itself now follows caller, and it is behaviour-preserving until
a chain exists (a one-scope chain is exactly the old predicate). The walk is
bounded by MAX_SCOPE_CHAIN; a deeper chain is refused rather than walked
further.
Is the hazard reachable? No — twice over, and it is now closed anyway.
- The scope table is empty on every compile (no IR-path inliner), so nothing builds a chain in the first place.
- Even given a chain, the VM's resume sinks refuse any
ReconstructedFramewith a non-emptycaller_framesoutright:vm/src/runtime/interpreter.rs:13393(build_deopt_frame),:13616(build_deopt_frame_inner),:13959(the OSR-exit sink). An inlined chain never resumes today; it always takes the safe whole-method re-run.
The real callers of frame_state_is_resumable are the two compile-time
admission gates, not resume sinks:
| caller | question it decides |
|---|---|
jit/src/x64.rs:22109 | unresumable-invokedynamic-trap: bail the whole compile |
jit/src/lib.rs:2916 (CompiledMethod::osr_exit_policy) | may this artifact be OSR-entered at all |
Making the predicate chain-aware is what stops an artifact whose only trap sits
under an undescribable caller scope from being admitted because the innermost
frame happens to be clean. (osr_exit_policy separately refuses any deopt point
with caller.is_some() — OSR_REFUSE_INLINED_SCOPE — so the OSR path is
already conservative about chains.)
The interned side used to keep both answers on purpose — a scope-local
is_resumable and a whole-chain chain_is_resumable. Both are gone, with the
rest of the interned representation, as of 2026-09-21
(deopt-frame-state-interning.md §7). frame_state_is_resumable is the only
resumability predicate now, and it is the whole-chain one.
4. DeoptimizationPoint::semantics
DeoptimizationPoint gained semantics: ResumeSemantics. Every producer
stamps ResumeSemantics::for_reason(reason), which is the convention the
consumers were each re-deriving, so this is behaviour-preserving by
construction. Two consequences:
The interned form was retired on 2026-09-21 (FrameStateInterner::intern_pointreads the field instead of re-deriving it, andmaterialize_pointrestores it instead of dropping it — the interned form is now lossless for a flat point.deopt-frame-state-interning.md§7), so the round trip this bullet described no longer exists. What remains true is that the field is onDeoptimizationPointand thatDeoptVerifierchecks it againstreason(DeoptMetadataError::ResumeSemanticsMismatch), so a producer cannot contradict itself.- A producer that knows better can say so: a caller scope
(
ResumeSemantics::for_caller_scope()=RESUME) or a genuine post-call resume point.
Still lost: the caller scopes' semantics, because the owned FrameState has
no field for them — only the innermost scope's survive on a point. The interned
form carried them per scope, and that is one of the two things its retirement did
not close (deopt-frame-state-interning.md §7.5). Closing it means giving
FrameState the field, since "retire the owned form" is no longer on the table.
5. The producer edit (jit/src/lib.rs)
Not made here — lib.rs is owned elsewhere. It is the only thing standing
between this representation and a real caller chain.
try_compile_inner builds inline_sites: HashMap<usize, InlineSite>
(lib.rs:12204), keyed by caller pc, where InlineSite (lib.rs:3226)
carries class_name / method_name / descriptor. Both halves of a scope are
therefore already at the splice point.
// once, next to the graph:
let mut inline_scopes = cratonvm_jit::ir::InlineScopeTable::new();
// at each splice of an inlined callee, with `caller_pc` the invoke's bci:
let caller_snapshot = graph
.safepoints
.iter()
.position(|s| s.bci == caller_pc)
.map(|i| i as u32);
let first = graph.safepoints.len(); // before the callee's body is built
// … build/splice the callee body, appending its snapshots …
let scope = inline_scopes.push_scope(
&format!("{caller_class}.{caller_name}:{caller_desc}"),
caller_pc as u32,
caller_snapshot,
enclosing_scope, // None at the outermost method
);
if let Some(scope) = scope {
inline_scopes.bind_snapshot_range(first..graph.safepoints.len(), scope);
}
// and at the lowering call (lib.rs's `ir_lower::lower_inner(...)`):
ir_lower::lower_inner_with_scopes(
graph, schedule, num_params, num_locals, helpers, branch_hints, sr_map,
direct_calls, ic_slots, compact_fields, &inline_scopes,
)
push_scope returning None (foreign parent, or past the depth cap) means the
snapshots stay unbound and the deopt points stay flat — the same conservative
outcome as not inlining.
Status, verified 2026-09-12. Both halves of the note below have moved.
IrBuildernow inlines by splicing callee bytecode (IrInlineSite; gated byCRATONVM_JIT_IR_INLINE, default on since 2026-09-06,=0is the kill switch). It still registers no scope, deliberately: a spliced region carries the caller'sinvokebci with re-execute semantics instead of a scope chain (the reasoning is atIrInlineSiteinjit/src/ir.rs). So theInlineScopeTablestays empty on every compile, andLowerer::caller_chain_foryieldsNone. The single-pass backend's scope stack has landed: its deopt-point publisher (jit/src/x64/deopt_stubs.rs) setsframe_state.caller = self.inline_caller_chain()inside a spliced body. The single-pass production entry isx64::compile_with_param_slots;x64::compileis the legacy test wrapper.
Note the ordering constraint the IR path does not have yet: the IR builder
(ir.rs, IrBuilder::build) does not inline at all — inline_sites is
consumed by the single-pass backend (x64::compile). So until an IR-path
inliner exists, the producer above has nowhere to run, and the table stays
empty. The single-pass backend's own scope stack is a separate edit in
x64.rs:2612 (see deopt-frame-state-interning.md §5.1).
6. To reconcile
- Cross-scope virtual objects.
caller_chain_forresolves each scope's values with its ownemittedset, so a scalar-replaced object live in BOTH a callee and its caller is defined twice instead of once plus aVirtualObjectRef, and the materializer would rebuild two objects where the program had one. Unreachable while the table is empty; a producer that starts building chains must thread oneemittedset through the whole chain first. resolve_frame_state_for_bciis still first-match-wins onbci. With inlining, a callee and its caller can carry the same bci, and the guard sites (Op::Guard,emit_deopt_unless) key on bci alone. They need to key on(bci, scope)before an IR-path inliner lands.deopt-frame-state-interning.mdwas rewritten on 2026-09-21: its subject was retired, §5.1's two stale premises about the single-pass backend are corrected, §4's numbers now carry the "measured by construction, not a prediction" caveat, and §7 is the retirement argument. Read the current text rather than anything this page said about it. §6'sis_resumablevschain_is_resumablebullet is resolved.deopt-metadata.md§1 "Reexecute flag" ("absent / absent. No field exists.") is now stale forDeoptimizationPoint.MAX_SCOPE_CHAINtruncation is no longer open. This bullet used to say a chain deeper than 256 was truncated byintern/materializeand that "the interner's silent truncation still wants aBailout". Both halves are obsolete: by the time the interner was retired it marked the cut explicitly rather than truncating silently, and it is gone either way. What remains is the owned side, which fails closed in both directions —frame_state_is_resumableanswersfalseat the cap, andDeoptVerifier::check_pointreportsDeoptMetadataError::ScopeChainTooDeep.- This page's own §1–§3 describe an unpopulated table.
InlineScopeTable's read side is on the production lowering path but nothing callspush_scopeorbind_snapshot*outside#[cfg(test)], so every claim here about what a caller chain does is a claim about a code path no compile takes.jit/src/ir.rsnow carries that status on theimpl InlineScopeTableblock, anddocs/feature-designs/jit-r10-interner-proposals.md§2 states the populate-or-retire decision. Before anything populates it, readdocs/internal/fixed-bugs/r10-deoptverify-splice-published-point-would-carry-the-callers-locals-FIXED-20260922.md: a point published from inside a splice is miscompiled on an axis unrelated to scope tables.