@@ -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+
47864832impl 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
0 commit comments