Skip to content

Commit c4df737

Browse files
olwangclaude
andcommitted
feat(reg-vm,vm-jit): J0.1 heap-aware deopt reg reconstruction (fixes B2)
The deopt state map now distinguishes reconstructible scalars from heap refs, so precise deopt restores reassigned SCALAR params without corrupting heap/flat-pointer params. - vm-jit `decode_deopt_live`: reconstruct only `Int`/`Float` regs; skip `Handle`/`FlatInt`/`FlatFloat` (heap table index / borrow-pinned buffer pointer). The interpreter frame already holds those heap VmValues -- precise resume implies no heap writes -- so they must not be decoded as raw scalars. Exhaustive match (a new JitValueType must choose a side). - reg-vm `restore_native_deopt_live_regs`: drop the `< n_params` guard (and the now-unused `n_params` arg, updating 3 call sites). Every reg in `live` is now a true scalar, so a reassigned scalar param is safe to restore. - Un-#[ignore] `precise_deopt_restores_reassigned_scalar_param` (B2 repro); it now passes. Update the vm-jit `force_bail_at_every_safepoint_captures_correct_state` test to the scalar-only-capture contract (heap/flat regs intentionally absent). Root cause of the earlier failed attempt: it skipped only `Handle`, leaving `FlatInt`/`FlatFloat` to decode as garbage scalars and corrupt flat-buffer params. `JitValueType` already carries the flat distinction, so the fix is to skip all three non-scalar kinds. Verification (dev container): deopt-stress 9/0, differential 33/0 (incl. native_heap_reads / native_float_heap_reads / tv2_direct_flat_reads), jit_acceptance 83/0, rsscript lib 276/0, vm-jit 81/0, default workspace build OK. Remaining for full J0.1: inlined logical-frame-chain state-map format, heap-payload-variant / live-out value reconstruction, precise-resume-default-on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 900a7e7 commit c4df737

4 files changed

Lines changed: 75 additions & 65 deletions

File tree

crates/rsscript/src/reg_vm/tests.rs

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5637,21 +5637,18 @@ fn main() -> Unit {
56375637
);
56385638
}
56395639

5640-
/// B2 (KNOWN GAP — blocked on J0.1 heap-aware deopt state maps; precise resume is
5641-
/// default-OFF so this is latent, not a production bug): the reg VM binds params as
5640+
/// B2 (FIXED by J0.1 heap-aware deopt state maps): the reg VM binds params as
56425641
/// locals, so `n = n + 1` rewrites the param register. A native scalar function
5643-
/// that REASSIGNS a param still live at a safepoint should, on precise deopt,
5644-
/// restore the param to its native-computed value — but the restore skips every
5645-
/// `reg < n_params`, resuming with the stale call-time value. The skip is required
5646-
/// because a heap/flat param (`Handle`/`FlatInt`/…) is marshalled as a raw `i64`
5647-
/// and is INDISTINGUISHABLE from a scalar `Int` in the deopt payload
5648-
/// (`JitValueType` loses the `NativeTy` flat/handle distinction); dropping the skip
5649-
/// corrupts flat-buffer params (proven: it broke `native_heap_reads` /
5650-
/// `tv2_direct_flat_reads` differential parity). A sound fix must thread the
5651-
/// scalar-vs-heap param kind into the deopt state map (J0.1). Ignored until then.
5652-
#[cfg(feature = "native-jit")]
5653-
#[test]
5654-
#[ignore = "B2: needs J0.1 NativeTy-aware deopt state maps; precise resume is default-off (latent)"]
5642+
/// that REASSIGNS a param still live at a safepoint must, on precise deopt, restore
5643+
/// the param to its native-computed value. The deopt state map distinguishes
5644+
/// reconstructible scalars (`Int`/`Float`) from heap refs (`Handle`/`FlatInt`/
5645+
/// `FlatFloat`): `decode_deopt_live` drops the latter (the frame already holds
5646+
/// their `VmValue`), so `restore_native_deopt_live_regs` can restore ALL scalar
5647+
/// regs — params included — without the old `< n_params` skip that lost reassigned
5648+
/// scalar params. (Skipping only `Handle` and not `FlatInt`/`FlatFloat` corrupts
5649+
/// flat-buffer params — see `native_heap_reads`/`tv2_direct_flat_reads`.)
5650+
#[cfg(feature = "native-jit")]
5651+
#[test]
56555652
fn precise_deopt_restores_reassigned_scalar_param() {
56565653
let big: i64 = 4_000_000_000;
56575654
// reg 0 = param x (runtime `big`); reg 0 = x + 1 (REASSIGN, live past the

crates/rsscript/src/reg_vm/tier.rs

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -736,23 +736,14 @@ impl RegVm {
736736
}
737737

738738
#[cfg(feature = "native-jit")]
739-
fn restore_native_deopt_live_regs(
740-
&mut self,
741-
base: usize,
742-
n_params: usize,
743-
n_regs: usize,
744-
live: &[vm_jit::DeoptReg],
745-
) {
739+
fn restore_native_deopt_live_regs(&mut self, base: usize, n_regs: usize, live: &[vm_jit::DeoptReg]) {
746740
for vm_jit::DeoptReg { reg, value } in live {
747-
// Params are skipped: a heap/flat param (`Handle`/`FlatInt`/…) is
748-
// marshalled as a raw `i64` handle/pointer and is INDISTINGUISHABLE from a
749-
// scalar `Int` at this (`JitValueType`) boundary, so writing it back would
750-
// corrupt the interpreter frame's heap value. The cost is that a
751-
// reassigned *scalar* param resumes with its stale call-time value (see
752-
// `precise_deopt_restores_reassigned_scalar_param`, #[ignore]); fixing that
753-
// soundly needs `NativeTy`-aware deopt state maps (J0.1), which carry the
754-
// scalar-vs-heap param distinction the payload currently loses.
755-
if (*reg as usize) < n_params || (*reg as usize) >= n_regs {
741+
// Heap-aware deopt (J0.1): every entry in `live` is a TRUE scalar —
742+
// `decode_deopt_live` drops `Handle`/`FlatInt`/`FlatFloat` regs — so a
743+
// reassigned SCALAR param is safe to restore (no `< n_params` guard).
744+
// Heap / flat-pointer params never reach here, so the interpreter frame
745+
// keeps its own heap value.
746+
if (*reg as usize) >= n_regs {
756747
continue;
757748
}
758749
let vm_value = match value {
@@ -802,7 +793,7 @@ impl RegVm {
802793
let value = self.reg(caller_base + *reg).clone();
803794
self.set_reg(child_base + index, value);
804795
}
805-
self.restore_native_deopt_live_regs(child_base, callee.params, callee.regs, &child.live);
796+
self.restore_native_deopt_live_regs(child_base, callee.regs, &child.live);
806797

807798
let Some(caller_frame) = self.frames.last_mut() else {
808799
return false;
@@ -839,7 +830,7 @@ impl RegVm {
839830
) -> bool {
840831
let original_len = self.frames.len();
841832
let original_ip = self.frames.last().map(|frame| frame.ip).unwrap_or_default();
842-
self.restore_native_deopt_live_regs(base, func.params, func.regs, live);
833+
self.restore_native_deopt_live_regs(base, func.regs, live);
843834
let resumed = self.push_native_child_deopt_frame(unit, func, base, resume_ip, child);
844835
if !resumed {
845836
self.frames.truncate(original_len);
@@ -1412,7 +1403,7 @@ impl RegVm {
14121403
// SKIPPING parameter registers: their window slots
14131404
// `base..base+n_params` are already valid and may hold heap
14141405
// `VmValue`s the scalar deopt payload cannot represent.
1415-
self.restore_native_deopt_live_regs(base, func.params, func.regs, &live);
1406+
self.restore_native_deopt_live_regs(base, func.regs, &live);
14161407
// Resume interpretation AT the bailing instruction.
14171408
self.frames.last_mut().expect("active frame").ip = resume_ip as usize;
14181409
scratch.restore(self.native.as_mut());

crates/vm-jit/src/lib.rs

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,13 +2013,22 @@ impl NativeModule {
20132013
site.live
20142014
.iter()
20152015
.filter_map(|&(reg, ty)| {
2016-
payload.get(payload_base + reg as usize).map(|&bits| {
2017-
let value = match ty {
2018-
JitValueType::Float => DeoptValue::Float(f64::from_bits(bits as u64)),
2019-
_ => DeoptValue::Int(bits),
2020-
};
2021-
DeoptReg { reg, value }
2022-
})
2016+
let &bits = payload.get(payload_base + reg as usize)?;
2017+
// Heap-aware deopt (J0.1): only a TRUE scalar (`Int`/`Float`) reg is
2018+
// reconstructible as a deopt value. A non-scalar reg — a `Handle` (heap
2019+
// table index) or a `FlatInt`/`FlatFloat` (raw borrow-pinned buffer
2020+
// pointer) — carries no scalar payload; the interpreter frame already
2021+
// holds its heap `VmValue` (precise resume implies no heap writes), so
2022+
// it MUST NOT be decoded as a raw `Int`/`Float` and written back.
2023+
// Exhaustive on purpose: a new `JitValueType` must choose a side here.
2024+
let value = match ty {
2025+
JitValueType::Int => DeoptValue::Int(bits),
2026+
JitValueType::Float => DeoptValue::Float(f64::from_bits(bits as u64)),
2027+
JitValueType::Handle | JitValueType::FlatInt | JitValueType::FlatFloat => {
2028+
return None;
2029+
}
2030+
};
2031+
Some(DeoptReg { reg, value })
20232032
})
20242033
.collect()
20252034
}
@@ -7649,39 +7658,51 @@ mod tests {
76497658
}
76507659
};
76517660

7652-
// The captured live registers must be exactly the map's live set...
7661+
// Heap-aware deopt (J0.1): the captured live set is exactly the SCALAR
7662+
// (`Int`/`Float`) subset of the map's live set — `Handle`/`FlatInt`/
7663+
// `FlatFloat` regs are reconstructed from the interpreter frame, not the
7664+
// payload, so they are intentionally absent from the capture.
76537665
let mut captured: Vec<u32> = live.iter().map(|r| r.reg).collect();
76547666
captured.sort_unstable();
7655-
let mut expected_regs: Vec<u32> = site.live.iter().map(|(r, _)| *r).collect();
7667+
let mut expected_regs: Vec<u32> = site
7668+
.live
7669+
.iter()
7670+
.filter(|(_, ty)| matches!(ty, JitValueType::Int | JitValueType::Float))
7671+
.map(|(r, _)| *r)
7672+
.collect();
76567673
expected_regs.sort_unstable();
76577674
assert_eq!(
76587675
captured, expected_regs,
7659-
"{}: site {} live-reg set mismatch (map vs capture)",
7676+
"{}: site {} scalar live-reg set mismatch (map vs capture)",
76607677
case.name, k
76617678
);
76627679

7663-
// ...and each captured value must match what the function computes,
7664-
// including FlatInt pointer registers (checked as the raw arg word).
7665-
for &(reg, _ty) in &site.live {
7666-
let got = live_value(&out, reg).expect("captured reg present");
7667-
if case.func.reg_types[reg as usize] == FlatInt {
7668-
assert_eq!(
7669-
got,
7670-
DeoptValue::Int(case.args[reg as usize]),
7671-
"{}: site {} reg {} (flat ptr) value mismatch",
7672-
case.name,
7673-
k,
7674-
reg
7675-
);
7676-
} else {
7677-
assert_eq!(
7678-
got,
7679-
(case.expect)(reg),
7680-
"{}: site {} reg {} value mismatch",
7681-
case.name,
7682-
k,
7683-
reg
7684-
);
7680+
// ...each captured SCALAR value must match what the function computes;
7681+
// a non-scalar (Handle/FlatInt/FlatFloat) reg is NOT reconstructed and
7682+
// must be absent from the capture.
7683+
for &(reg, ty) in &site.live {
7684+
match ty {
7685+
JitValueType::Int | JitValueType::Float => {
7686+
let got =
7687+
live_value(&out, reg).expect("captured scalar reg present");
7688+
assert_eq!(
7689+
got,
7690+
(case.expect)(reg),
7691+
"{}: site {} reg {} value mismatch",
7692+
case.name,
7693+
k,
7694+
reg
7695+
);
7696+
}
7697+
JitValueType::Handle | JitValueType::FlatInt | JitValueType::FlatFloat => {
7698+
assert!(
7699+
live_value(&out, reg).is_none(),
7700+
"{}: site {} reg {} (non-scalar) must not be reconstructed",
7701+
case.name,
7702+
k,
7703+
reg
7704+
);
7705+
}
76857706
}
76867707
}
76877708

docs/planning/vm-optimizing-jit-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ numeric-mode language decision). Owner: TBD. Created 2026-06-20; status updated
3737
| Item | State | Notes |
3838
|---|---|---|
3939
| **J0.0–J0.3** precise-deopt spine | **shipped (leaf/scalar subset)** | `NativeOutcome::Deopt` + per-site safepoint ids + live-reg capture + state-map + precise resume + every-safepoint stress test. **Precise resume is default-OFF** (gated by `RSS_JIT_PRECISE_DEOPT` / a test entrypoint); production still re-runs from the function top (byte-identical for the side-effect-free subset). The state-map is `resume_ip + live registers`**not** yet the full inlined logical-frame-chain format J0.1 describes. So: done for the current leaf/scalar subset; full inlined-frame / side-effecting deopt is still **future**. |
40+
| **J0.1 (heap-aware reg reconstruction)** | **shipped (slice)** | The deopt state map now distinguishes reconstructible scalars (`Int`/`Float`) from heap refs (`Handle`/`FlatInt`/`FlatFloat`): `decode_deopt_live` reconstructs only scalar regs (the interpreter frame already holds heap values — precise resume implies no heap writes), and `restore_native_deopt_live_regs` restores ALL scalar regs incl. **reassigned scalar params** (the `< n_params` skip is gone). Fixes the reassigned-scalar-param resume bug (`precise_deopt_restores_reassigned_scalar_param`); flat-buffer params stay uncorrupted. **Remaining for full J0.1:** inlined logical-frame-chain state-map format, heap-payload-variant / live-out *value* reconstruction (native-built heap values across a bail), and enabling precise resume by default. |
4041
| **J0.4** heap writes + deopt-after-write | **future** | blocked on native heap-write codegen; the native subset is read-only today. |
4142
| **J0.5** in-generated-code `VmLimits` accounting | **future** | currently the Model-A fallback (see J6); the `VmLimitsSnapshot` tick/poll machinery is not built. |
4243
| **J1** profiling | **shipped** | per-call-site type feedback on dynamic sites, warm-gated, no interpreter regression. (Branch profiling **not** shipped — it regressed `bool_logic_loop` ~7%; needs a hot/cold dispatch split.) |

0 commit comments

Comments
 (0)