Skip to content

Commit f0ccfee

Browse files
olwangclaude
andcommitted
feat(reg-vm): OSR hot-backedge auto-trigger, default-on (Pending #2)
OSR now fires automatically when a loop is hot, not just under RSS_JIT_OSR. Per-function OSR-candidate state is lazily computed + cached (NotCandidate pays one hoisted check per call; only candidate loops count backedges), and a backedge counter triggers try_osr at a threshold — after which the loop runs native, so the counter cost is bounded to the warm-up iterations. RSS_JIT_OSR stays as the eager/force path. Default differential (now auto-OSR) byte-identical; non-OSR interpreter dispatch (pure_loop_sum/bool_logic_loop) non-regression measured. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dfb11a8 commit f0ccfee

2 files changed

Lines changed: 265 additions & 21 deletions

File tree

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 171 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4781,8 +4781,54 @@ struct RegFunction {
47814781
/// crosses [`PROFILE_WARMUP`]. `None` for cold functions (zero allocation).
47824782
/// Feeds J2 compile decisions only — never a value (determinism).
47834783
profile: RefCell<Option<Box<FunctionProfile>>>,
4784+
/// OSR hot-backedge auto-trigger state (Pending #2). Lazily resolved ONCE on
4785+
/// first `drive` entry into this function: a cheap single-natural-loop
4786+
/// detection decides `NotCandidate` (no loop / unanalyzable — the common case,
4787+
/// which then pays nothing per-instruction) vs `Counting` (has a candidate
4788+
/// header). For a candidate, the interpreter counts backedges to the header and
4789+
/// at [`OSR_BACKEDGE_THRESHOLD`] calls `try_osr` (the real detect+compile); on
4790+
/// success the loop runs native and the counter cost is bounded to the warm-up
4791+
/// iterations, on failure the state goes `GaveUp` (never retried). A `Cell`
4792+
/// (interior-mut, no allocation), so a non-candidate function pays one `Cell`
4793+
/// read per call and zero per-instruction cost.
4794+
#[cfg_attr(not(feature = "native-jit"), allow(dead_code))]
4795+
osr_state: std::cell::Cell<OsrTrigger>,
47844796
}
47854797

4798+
/// Per-function OSR auto-trigger state machine (Pending #2). Lives in a `Cell` on
4799+
/// [`RegFunction`]; see `osr_state`.
4800+
#[cfg(feature = "native-jit")]
4801+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4802+
enum OsrTrigger {
4803+
/// Not yet inspected — resolved on first `drive` entry.
4804+
Unknown,
4805+
/// No qualifying single natural loop (or step/cancel budget armed): pays only a
4806+
/// single hoisted `Cell` read per call, NO per-instruction cost.
4807+
NotCandidate,
4808+
/// Has a candidate loop header; the interpreter counts backedges to `header_ip`.
4809+
/// At [`OSR_BACKEDGE_THRESHOLD`] it fires `try_osr`.
4810+
Counting { header_ip: usize, count: u32 },
4811+
/// OSR fired (or `try_osr` declined at threshold): stop counting. `GaveUp` and
4812+
/// `Fired` collapse to the same terminal "do nothing" behavior, but are kept
4813+
/// distinct for telemetry/clarity.
4814+
GaveUp,
4815+
}
4816+
4817+
/// Mirror of `OsrTrigger` that is always present (so the non-`native-jit`
4818+
/// `RegFunction` constructor compiles). Only the `native-jit` build reads it.
4819+
#[cfg(not(feature = "native-jit"))]
4820+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4821+
enum OsrTrigger {
4822+
Unknown,
4823+
}
4824+
4825+
/// Backedge count at which a counting OSR candidate fires `try_osr`. Chosen so
4826+
/// tiny loops (which run < this many iterations) never pay OSR compile/setup, but
4827+
/// genuinely hot loops cross it quickly and then run native for the rest of their
4828+
/// (much longer) life. The eager path (`RSS_JIT_OSR`) uses threshold 0.
4829+
#[cfg(feature = "native-jit")]
4830+
const OSR_BACKEDGE_THRESHOLD: u32 = 1000;
4831+
47864832
impl RegFunction {
47874833
fn placeholder(name: String) -> Self {
47884834
Self {
@@ -4796,6 +4842,7 @@ impl RegFunction {
47964842
native_status: std::cell::Cell::new(0),
47974843
call_count: std::cell::Cell::new(0),
47984844
profile: RefCell::new(None),
4845+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
47994846
}
48004847
}
48014848
}
@@ -6077,6 +6124,7 @@ impl RegUnit {
60776124
native_status: std::cell::Cell::new(0),
60786125
call_count: std::cell::Cell::new(0),
60796126
profile: RefCell::new(None),
6127+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
60806128
},
60816129
loop_stack: Vec::new(),
60826130
cleanup_stack: Vec::new(),
@@ -6116,6 +6164,7 @@ impl RegUnit {
61166164
native_status: std::cell::Cell::new(0),
61176165
call_count: std::cell::Cell::new(0),
61186166
profile: RefCell::new(None),
6167+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
61196168
},
61206169
loop_stack: Vec::new(),
61216170
cleanup_stack: Vec::new(),
@@ -7153,6 +7202,7 @@ impl RegLowerer<'_> {
71537202
native_status: std::cell::Cell::new(0),
71547203
call_count: std::cell::Cell::new(0),
71557204
profile: RefCell::new(None),
7205+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
71567206
},
71577207
loop_stack: Vec::new(),
71587208
cleanup_stack: Vec::new(),
@@ -11031,6 +11081,54 @@ impl RegVm {
1103111081
/// and the only native exit is the OSR-exit, whose `resume_ip` is the
1103211082
/// interpreter's own post-loop instruction index — so resuming there with the
1103311083
/// restored window is byte-identical to having interpreted the loop.
11084+
/// Resolve a function's OSR auto-trigger state ONCE, on first `drive` entry
11085+
/// (Pending #2). Returns the candidate loop header (if any) so the caller can
11086+
/// hoist it into a single per-frame local. Runs a cheap single-natural-loop
11087+
/// detection on `func.code` (no compile, no region passes — that is deferred to
11088+
/// the threshold `try_osr` call); a function with no analyzable loop becomes
11089+
/// `NotCandidate` and thereafter pays only one `Cell` read per call with NO
11090+
/// per-instruction cost. The eager `RSS_JIT_OSR` path resolves the same way but
11091+
/// fires at threshold 0 (handled by the caller), so its very first header hit
11092+
/// triggers — preserving the forced-OSR behavior and the differential backend.
11093+
///
11094+
/// Determinism: this only decides *whether/when* to attempt OSR; `try_osr` is
11095+
/// byte-identical to interpretation, so triggering never changes a value.
11096+
#[cfg(feature = "native-jit")]
11097+
fn resolve_osr_candidate(&self, func: &RegFunction) -> Option<usize> {
11098+
match func.osr_state.get() {
11099+
OsrTrigger::Unknown => {
11100+
// Preemption parity with `try_osr`: native loops poll neither the
11101+
// step budget nor the cancel flag, so a function can never OSR while
11102+
// either is armed. Resolve to `NotCandidate` WITHOUT caching it
11103+
// permanently — a later run without the budget must re-resolve, so
11104+
// leave the state `Unknown` (return `None` for this frame only).
11105+
if self.limits.step_budget.is_some() || self.limits.cancel.is_some() {
11106+
return None;
11107+
}
11108+
// Cheap candidacy pre-check: does the ORIGINAL code have a single
11109+
// analyzable natural loop? (No inline/region passes, no compile.)
11110+
// The real native-subset/dissolvable verdict is only known after
11111+
// `try_osr` at threshold; a detected-but-uncompilable loop simply
11112+
// counts to threshold once, fails `try_osr`, and goes `GaveUp` —
11113+
// bounded cost, only for functions that have a natural loop at all.
11114+
let state = match detect_single_natural_loop(&func.code) {
11115+
Some(lp) => OsrTrigger::Counting {
11116+
header_ip: lp.header,
11117+
count: 0,
11118+
},
11119+
None => OsrTrigger::NotCandidate,
11120+
};
11121+
func.osr_state.set(state);
11122+
match state {
11123+
OsrTrigger::Counting { header_ip, .. } => Some(header_ip),
11124+
_ => None,
11125+
}
11126+
}
11127+
OsrTrigger::Counting { header_ip, .. } => Some(header_ip),
11128+
OsrTrigger::NotCandidate | OsrTrigger::GaveUp => None,
11129+
}
11130+
}
11131+
1103411132
#[cfg(feature = "native-jit")]
1103511133
fn try_osr(&mut self, func: &RegFunction, base: usize, header_ip: usize) -> bool {
1103611134
// Preemption parity (as in `try_native`): native loops poll neither the
@@ -12549,32 +12647,81 @@ impl RegVm {
1254912647
continue 'frames;
1255012648
}
1255112649

12552-
// J5.2 OSR (forced trigger): is on-stack replacement armed for this run?
12553-
// A single per-frame `Cell` read; when OSR is off (the default and every
12554-
// production path) `osr_active` is `false`, so the per-instruction check
12555-
// below is a single never-taken branch and the interpreter hot path is
12556-
// unchanged. When on, the first time the interpreter reaches a qualifying
12557-
// loop header we hand the loop to the native OSR body (see `try_osr`).
12558-
// NOTE: this is deliberately NOT gated on `native_status`: OSR targets
12559-
// exactly the functions that are native-INELIGIBLE as a whole (the loop
12560-
// is wrapped by non-native I/O), which `try_native` marks NOT_ELIGIBLE.
12561-
// Gating on it would exclude every OSR candidate. The per-function OSR
12562-
// verdict is cached separately in `native.osr_cache`.
12650+
// OSR auto-trigger (Pending #2): resolve this function's OSR-candidate
12651+
// state ONCE (lazy, cached in `func.osr_state`) and hoist the candidate
12652+
// loop header into a single per-frame local. For the overwhelming common
12653+
// case — a function with no analyzable natural loop — this is a single
12654+
// `Cell` read that yields `None`, and the per-instruction guard below is
12655+
// then EXACTLY today's hoisted never-taken branch (`if let Some(..)`),
12656+
// so the non-candidate interpreter hot path is byte-for-byte unchanged.
12657+
// Only a candidate function pays the per-`ip` header compare, and only
12658+
// until OSR fires (after which the loop runs native).
12659+
//
12660+
// `osr_eager` (set by `RSS_JIT_OSR` / a test override) keeps the forced
12661+
// path: threshold 0, so the FIRST header hit triggers `try_osr` — this
12662+
// preserves the differential OSR backend and the deterministic
12663+
// forced-OSR tests. NOTE: candidacy is NOT gated on `native_status`: OSR
12664+
// targets functions that are native-INELIGIBLE as a whole (the loop is
12665+
// wrapped by non-native I/O); the verdict is cached in `native.osr_cache`.
1256312666
#[cfg(feature = "native-jit")]
12564-
let osr_active = self.native.as_ref().is_some_and(|n| n.osr_enabled);
12667+
let (osr_candidate, osr_eager) = if self.native.is_some() {
12668+
(
12669+
self.resolve_osr_candidate(&func),
12670+
self.native.as_ref().is_some_and(|n| n.osr_enabled),
12671+
)
12672+
} else {
12673+
(None, false)
12674+
};
1256512675
#[cfg(not(feature = "native-jit"))]
12566-
let osr_active = false;
12676+
let _osr_candidate: Option<usize> = None;
1256712677

1256812678
while let Some(instr) = func.code.get(ip) {
12569-
// OSR trigger: when armed and the interpreter is *at* a qualifying
12570-
// loop header, run the loop natively and resume at the post-loop ip.
12571-
// `try_osr` is total — on any non-applicability it leaves the frame
12572-
// untouched and returns `false`, so the loop just runs normally.
12679+
// OSR trigger: only candidate functions enter this arm (`None` for
12680+
// every non-loop / unanalyzable function ⇒ a hoisted never-taken
12681+
// branch, no per-instruction work). When the interpreter reaches the
12682+
// candidate header, count the backedge; at the threshold (or
12683+
// immediately when eager) fire `try_osr`. `try_osr` is total — on any
12684+
// non-applicability it leaves the frame untouched and returns `false`,
12685+
// and we mark `GaveUp` so we never recompile-probe in a tight loop.
1257312686
#[cfg(feature = "native-jit")]
12574-
if osr_active {
12575-
self.frames.last_mut().expect("active frame").ip = ip;
12576-
if self.try_osr(&func, base, ip) {
12577-
continue 'frames;
12687+
if let Some(header) = osr_candidate {
12688+
if ip == header {
12689+
let fire = if osr_eager {
12690+
true
12691+
} else {
12692+
// Count this backedge/header hit; fire at threshold.
12693+
match func.osr_state.get() {
12694+
OsrTrigger::Counting { header_ip, count } => {
12695+
let next = count.saturating_add(1);
12696+
if next >= OSR_BACKEDGE_THRESHOLD {
12697+
true
12698+
} else {
12699+
func.osr_state.set(OsrTrigger::Counting {
12700+
header_ip,
12701+
count: next,
12702+
});
12703+
false
12704+
}
12705+
}
12706+
// Already fired/gave up: stop probing.
12707+
_ => false,
12708+
}
12709+
};
12710+
if fire {
12711+
self.frames.last_mut().expect("active frame").ip = ip;
12712+
if self.try_osr(&func, base, ip) {
12713+
continue 'frames;
12714+
}
12715+
// Declined: in COUNTING (auto) mode, don't retry forever —
12716+
// mark `GaveUp` so a non-compilable detected loop stops
12717+
// re-probing `try_osr` on every header hit. In EAGER mode
12718+
// keep today's behavior (fire every header hit; the
12719+
// `osr_cache` `None` verdict already makes the retry cheap,
12720+
// and a still-pending closure profile must be re-probed).
12721+
if !osr_eager {
12722+
func.osr_state.set(OsrTrigger::GaveUp);
12723+
}
12724+
}
1257812725
}
1257912726
}
1258012727
self.tick()?;
@@ -20880,6 +21027,7 @@ mod register_window_tests {
2088021027
native_status: std::cell::Cell::new(0),
2088121028
call_count: std::cell::Cell::new(0),
2088221029
profile: RefCell::new(None),
21030+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
2088321031
}
2088421032
}
2088521033

@@ -21044,6 +21192,7 @@ mod register_window_tests {
2104421192
native_status: std::cell::Cell::new(0),
2104521193
call_count: std::cell::Cell::new(0),
2104621194
profile: RefCell::new(None),
21195+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
2104721196
}
2104821197
}
2104921198

@@ -21146,6 +21295,7 @@ mod register_window_tests {
2114621295
native_status: std::cell::Cell::new(0),
2114721296
call_count: std::cell::Cell::new(0),
2114821297
profile: RefCell::new(None),
21298+
osr_state: std::cell::Cell::new(OsrTrigger::Unknown),
2114921299
}
2115021300
}
2115121301

crates/rsscript/tests/jit_acceptance.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,3 +1549,97 @@ fn main() -> Unit {
15491549
);
15501550
}
15511551
}
1552+
1553+
/// Pending #2 OSR hot-backedge AUTO-trigger: a hot native-subset scalar loop
1554+
/// wrapped by non-native I/O *in the same (once-called) function* — so the
1555+
/// function is native-INELIGIBLE as a whole and only OSR can run the loop
1556+
/// natively — must auto-fire with NO `RSS_JIT_OSR` env flag and NO test override
1557+
/// (the plain `eval_main_with_args_native_with_stats` path). The loop runs far
1558+
/// more than the OSR backedge threshold iterations, so the backedge counter
1559+
/// crosses the threshold and `try_osr` fires on its own. We assert (a) the output
1560+
/// is byte-identical to the pure interpreter and (b) `osr_entries > 0` — i.e. the
1561+
/// auto-trigger genuinely handed the loop to native code without any flag.
1562+
#[cfg(feature = "native-jit")]
1563+
#[test]
1564+
fn native_osr_auto_triggers_on_hot_loop_without_flag() {
1565+
let source = "\
1566+
fn compute(limit: Int) -> Int {
1567+
Log.write(message: read \"begin\")
1568+
let mut i = 0
1569+
let mut total = 0
1570+
while i < limit {
1571+
total = total + i * i
1572+
i = i + 1
1573+
}
1574+
Log.write(message: read String.from_int(value: total))
1575+
return total
1576+
}
1577+
1578+
fn main() -> Unit {
1579+
Log.write(message: read String.from_int(value: compute(limit: read 5000)))
1580+
return Unit
1581+
}
1582+
";
1583+
let file = "jit-osr-auto.rss";
1584+
let interp = common::run_vm_source(file, source, &[]).expect("interp run");
1585+
let executable = rsscript::reg_vm_compile_source(file, source).expect("source compiles");
1586+
// NO env flag, NO override: the default native path. Auto-trigger must fire on
1587+
// the hot loop entirely on its own.
1588+
let (auto, stats) = executable
1589+
.eval_main_with_args_native_with_stats(std::iter::empty::<String>())
1590+
.expect("native auto-OSR run should succeed");
1591+
assert_eq!(
1592+
auto.stdout, interp.stdout,
1593+
"auto-OSR loop must be byte-identical to the interpreter (stdout)",
1594+
);
1595+
assert!(
1596+
stats.osr_entries > 0,
1597+
"a hot (> threshold) native-subset loop must AUTO-OSR with no env flag and \
1598+
no override: {stats:?}",
1599+
);
1600+
}
1601+
1602+
/// Pending #2 OSR auto-trigger gating: a loop that runs FEWER than the OSR backedge
1603+
/// threshold iterations must NOT auto-fire (the backedge counter never crosses the
1604+
/// threshold) — so the OSR compile/setup cost is never paid for a short loop — and
1605+
/// the result must still be correct. Run with NO env flag and NO override; assert
1606+
/// `osr_entries == 0` and output == interpreter.
1607+
#[cfg(feature = "native-jit")]
1608+
#[test]
1609+
fn native_osr_short_loop_does_not_auto_trigger() {
1610+
let source = "\
1611+
fn compute(limit: Int) -> Int {
1612+
Log.write(message: read \"begin\")
1613+
let mut i = 0
1614+
let mut total = 0
1615+
while i < limit {
1616+
total = total + i * i
1617+
i = i + 1
1618+
}
1619+
Log.write(message: read String.from_int(value: total))
1620+
return total
1621+
}
1622+
1623+
fn main() -> Unit {
1624+
Log.write(message: read String.from_int(value: compute(limit: read 50)))
1625+
return Unit
1626+
}
1627+
";
1628+
let file = "jit-osr-short.rss";
1629+
let interp = common::run_vm_source(file, source, &[]).expect("interp run");
1630+
let executable = rsscript::reg_vm_compile_source(file, source).expect("source compiles");
1631+
let (auto, stats) = executable
1632+
.eval_main_with_args_native_with_stats(std::iter::empty::<String>())
1633+
.expect("native run should succeed");
1634+
assert_eq!(
1635+
auto.stdout, interp.stdout,
1636+
"short-loop result must still be byte-identical to the interpreter",
1637+
);
1638+
assert_eq!(
1639+
stats.osr_entries, 0,
1640+
"a loop shorter than the OSR backedge threshold must NOT auto-fire (no OSR \
1641+
setup paid for tiny loops): {stats:?}",
1642+
);
1643+
// sum_{i=0}^{49} i*i = 40425, matching native_osr_scalar_loop_matches_interpreter.
1644+
assert_eq!(auto.stdout.trim_end(), "begin\n40425\n40425");
1645+
}

0 commit comments

Comments
 (0)