Skip to content

Commit 84726e9

Browse files
committed
fix(jit): back off exit-heavy callable frames
1 parent 2e5399c commit 84726e9

5 files changed

Lines changed: 276 additions & 10 deletions

File tree

plans/pd_vm_call_overhead_reduction_plan.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,64 @@ Interpretation:
208208
- `7a151fc` added typed builtin fast-path checks and counters to the interpreter `Call` path after the
209209
plan baseline; it is a profiling candidate, not an established causal commit
210210

211+
### `script-bench-rs` callable-frame regression and mitigation (2026-07-18)
212+
213+
A separate same-machine rerun used `/mnt/TEMP/workspace/script-bench-rs` at
214+
`6fd2a61f0a2164c40f841d585ac7dbae55362b15` and its `Sort Rust objects` workload over 10,000
215+
Rust-backed objects. The baseline used crates.io `pd-vm 0.23.1`; the candidate used the RustScript
216+
working tree based on `2e5399c` with the benchmark dependency redirected by path in an isolated
217+
export. The benchmark repository itself was not modified or pushed.
218+
219+
The initial CPU 11 result, four alternating Criterion runs with `sample_size=10`, was:
220+
221+
| Mode | `0.23.1` median | `master` median before fix | Change |
222+
|---|---:|---:|---:|
223+
| interpreter | `330.6 ms` | `536.6 ms` | `+62.3%` |
224+
| JIT | `81.1 ms` | `1875.6 ms` | `+2212.4%` / `23.1x` slower |
225+
226+
CPU 3 reproduced the JIT delta at `23.3x`, so the result was not a scheduler-frequency artifact.
227+
Native-trace assertions passed on both endpoints.
228+
229+
Bisection and trace diagnostics found:
230+
231+
- first large JIT regression: `df6a0ff feat(jit): trace inside script callable frames`
232+
- all hot sort traces ran in prototype frame `0`
233+
- one reused pre-fix run produced about `352k` native executions, `352k` exits, `0` native
234+
loopbacks, and `332k` linked handoffs
235+
- trace shape and execution counts were close to `0.23.1`; the regression came from repeatedly
236+
entering and leaving frame-relative native traces that performed no sustained native loopback
237+
- after a trace was blocked, the interpreter still eagerly built `active_local_types()` before the
238+
engine could reject the frame, keeping the JIT-enabled fallback path materially slower
239+
240+
The mitigation is callable-frame adaptive backoff:
241+
242+
- count consecutive non-loopback native side exits for callable-frame traces
243+
- reset the streak after a successful native loopback
244+
- after 64 consecutive side exits, block the complete callable prototype frame and remove its
245+
compiled entry mappings
246+
- check the blocked-frame bitmap before constructing frame-local type vectors
247+
- root traces remain eligible, and scalar callable loops with native loopbacks remain native
248+
249+
Post-fix CPU 11 A/B, four alternating runs:
250+
251+
| Mode | Median | Range |
252+
|---|---:|---:|
253+
| current interpreter | `510.6 ms` | `490.6-537.0 ms` |
254+
| fixed JIT | `530.3 ms` | `485.3-551.0 ms` |
255+
256+
The fixed JIT is `71.7%` faster than the pre-fix master JIT and is within `+3.9%` of the same-run
257+
interpreter median. It remains `6.54x` slower than the old `0.23.1` JIT because `0.23.1` inlined
258+
direct named function calls, while real script call frames intentionally route them through
259+
`CallValue`. Restoring that older speed requires a separate compiler direct-call inlining design;
260+
the VM mitigation prevents pathological slowdown without changing callable semantics.
261+
262+
Verification:
263+
264+
- focused trace-engine and callable-frame regression tests passed
265+
- complete `jit_tests`: `101 passed`, `0 failed`, `11 ignored`
266+
- `cargo test --workspace --lib --tests` passed
267+
- `cargo clippy --workspace --lib --tests -- -D warnings` passed
268+
211269
## Non-Goals
212270

213271
- redesigning RustScript source-level function semantics

src/vm/jit/runtime.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ impl Vm {
237237
let mut next_trace_id = self
238238
.jit
239239
.compiled_trace_for_entry(frame_key, ip, stack_depth);
240-
if next_trace_id.is_none() {
240+
if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) {
241241
let entry_local_types =
242242
(frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types());
243243
let program = &self.program;
@@ -315,18 +315,24 @@ impl Vm {
315315
&& terminal == JitTraceTerminal::LoopBack
316316
&& self.ip == root_ip
317317
{
318+
self.jit.record_native_loop_back(current_trace_id);
318319
self.jit_native_loop_back_count =
319320
self.jit_native_loop_back_count.saturating_add(1);
320321
continue;
321322
}
323+
if self.jit.record_native_side_exit(current_trace_id) {
324+
self.jit.block_callable_frame(current_trace_id);
325+
return Ok(native::STATUS_LINKED_CONTINUE);
326+
}
322327
if !has_yielding_call {
323328
let ip = self.ip;
324329
let frame_key = self.active_frame_key();
325330
let stack_depth = self.active_operand_stack_len();
326331
let mut next_trace_id =
327332
self.jit
328333
.compiled_trace_for_entry(frame_key, ip, stack_depth);
329-
if next_trace_id.is_none() {
334+
if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key)
335+
{
330336
let entry_local_types =
331337
(frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types());
332338
let program = &self.program;
@@ -590,18 +596,24 @@ impl Vm {
590596
&& terminal == JitTraceTerminal::LoopBack
591597
&& self.ip == root_ip
592598
{
599+
self.jit.record_native_loop_back(current_trace_id);
593600
self.jit_native_loop_back_count =
594601
self.jit_native_loop_back_count.saturating_add(1);
595602
continue;
596603
}
604+
if self.jit.record_native_side_exit(current_trace_id) {
605+
self.jit.block_callable_frame(current_trace_id);
606+
return Ok(ExecOutcome::Continue);
607+
}
597608
if !has_yielding_call {
598609
let ip = self.ip;
599610
let frame_key = self.active_frame_key();
600611
let stack_depth = self.active_operand_stack_len();
601612
let mut next_trace_id =
602613
self.jit
603614
.compiled_trace_for_entry(frame_key, ip, stack_depth);
604-
if next_trace_id.is_none() {
615+
if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key)
616+
{
605617
next_trace_id = {
606618
let entry_local_types = (frame_key != ROOT_FRAME_KEY)
607619
.then(|| self.active_local_types());
@@ -653,7 +665,7 @@ impl Vm {
653665
let mut next_trace_id =
654666
self.jit
655667
.compiled_trace_for_entry(frame_key, ip, stack_depth);
656-
if next_trace_id.is_none() {
668+
if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) {
657669
next_trace_id = {
658670
let entry_local_types =
659671
(frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types());

src/vm/jit/trace.rs

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ pub struct JitNyiDoc {
181181
pub reason: &'static str,
182182
}
183183

184+
const CALLABLE_SIDE_EXIT_BACKOFF_THRESHOLD: u32 = 64;
185+
184186
pub struct TraceJitEngine {
185187
config: JitConfig,
186188
hot_counts: HashMap<TraceEntryKey, u32>,
@@ -189,6 +191,8 @@ pub struct TraceJitEngine {
189191
loop_headers: Option<Vec<bool>>,
190192
non_yielding_host_imports: Vec<bool>,
191193
traces: Vec<JitTrace>,
194+
callable_side_exit_streaks: Vec<u32>,
195+
blocked_callable_frames: Vec<bool>,
192196
attempts: Vec<JitAttempt>,
193197
}
194198

@@ -208,6 +212,8 @@ impl TraceJitEngine {
208212
loop_headers: None,
209213
non_yielding_host_imports: Vec::new(),
210214
traces: Vec::new(),
215+
callable_side_exit_streaks: Vec::new(),
216+
blocked_callable_frames: Vec::new(),
211217
attempts: Vec::new(),
212218
}
213219
}
@@ -223,6 +229,8 @@ impl TraceJitEngine {
223229
self.blocked_entries.clear();
224230
self.loop_headers = None;
225231
self.traces.clear();
232+
self.callable_side_exit_streaks.clear();
233+
self.blocked_callable_frames.clear();
226234
self.attempts.clear();
227235
}
228236

@@ -236,6 +244,8 @@ impl TraceJitEngine {
236244
self.blocked_entries.clear();
237245
self.loop_headers = None;
238246
self.traces.clear();
247+
self.callable_side_exit_streaks.clear();
248+
self.blocked_callable_frames.clear();
239249
self.attempts.clear();
240250
true
241251
}
@@ -262,7 +272,10 @@ impl TraceJitEngine {
262272
entry_local_types: Option<&[crate::ValueType]>,
263273
program: &Program,
264274
) -> Option<usize> {
265-
if !self.config.enabled || !native_jit_supported() {
275+
if !self.config.enabled
276+
|| !native_jit_supported()
277+
|| self.callable_frame_is_blocked(frame_key)
278+
{
266279
return None;
267280
}
268281
let key = TraceEntryKey {
@@ -323,7 +336,10 @@ impl TraceJitEngine {
323336
entry_local_types: Option<&[crate::ValueType]>,
324337
program: &Program,
325338
) -> Option<usize> {
326-
if !self.config.enabled || !native_jit_supported() {
339+
if !self.config.enabled
340+
|| !native_jit_supported()
341+
|| self.callable_frame_is_blocked(frame_key)
342+
{
327343
return None;
328344
}
329345
let key = TraceEntryKey {
@@ -385,6 +401,60 @@ impl TraceJitEngine {
385401
}
386402
}
387403

404+
pub(crate) fn record_native_side_exit(&mut self, trace_id: usize) -> bool {
405+
if self
406+
.traces
407+
.get(trace_id)
408+
.is_none_or(|trace| trace.frame_key == ROOT_FRAME_KEY)
409+
{
410+
return false;
411+
}
412+
let Some(streak) = self.callable_side_exit_streaks.get_mut(trace_id) else {
413+
return false;
414+
};
415+
*streak = streak.saturating_add(1);
416+
*streak >= CALLABLE_SIDE_EXIT_BACKOFF_THRESHOLD
417+
}
418+
419+
pub(crate) fn record_native_loop_back(&mut self, trace_id: usize) {
420+
if let Some(streak) = self.callable_side_exit_streaks.get_mut(trace_id) {
421+
*streak = 0;
422+
}
423+
}
424+
425+
pub(crate) fn callable_frame_is_blocked(&self, frame_key: u64) -> bool {
426+
if frame_key == ROOT_FRAME_KEY {
427+
return false;
428+
}
429+
usize::try_from(frame_key)
430+
.ok()
431+
.and_then(|index| self.blocked_callable_frames.get(index))
432+
.copied()
433+
.unwrap_or(false)
434+
}
435+
436+
pub(crate) fn block_callable_frame(&mut self, trace_id: usize) {
437+
let Some(frame_key) = self.traces.get(trace_id).map(|trace| trace.frame_key) else {
438+
return;
439+
};
440+
if frame_key == ROOT_FRAME_KEY {
441+
self.block_trace(trace_id);
442+
return;
443+
}
444+
let Ok(frame_index) = usize::try_from(frame_key) else {
445+
self.block_trace(trace_id);
446+
return;
447+
};
448+
if self.blocked_callable_frames.len() <= frame_index {
449+
self.blocked_callable_frames.resize(frame_index + 1, false);
450+
}
451+
self.blocked_callable_frames[frame_index] = true;
452+
for entries in &mut self.compiled_by_ip {
453+
entries.retain(|(entry_frame_key, _, _)| *entry_frame_key != frame_key);
454+
}
455+
self.hot_counts.retain(|key, _| key.frame_key != frame_key);
456+
}
457+
388458
pub(crate) fn block_trace(&mut self, trace_id: usize) {
389459
if let Some(trace) = self.traces.get(trace_id) {
390460
let key = TraceEntryKey {
@@ -570,6 +640,7 @@ impl TraceJitEngine {
570640
.and_then(|debug| debug.line_for_offset(key.root_ip));
571641
let trace = build_jit_trace(id, key, start_line, recorded);
572642
self.traces.push(trace);
643+
self.callable_side_exit_streaks.push(0);
573644
Ok(id)
574645
}
575646

@@ -779,6 +850,67 @@ mod tests {
779850
assert!(!headers[branch_ip as usize]);
780851
}
781852

853+
#[test]
854+
fn callable_side_exit_backoff_resets_on_native_loopback() {
855+
if !native_jit_supported() {
856+
return;
857+
}
858+
let mut bc = BytecodeBuilder::new();
859+
let root_ip = bc.position();
860+
bc.ldloc(0);
861+
bc.ldc(0);
862+
bc.add();
863+
bc.stloc(0);
864+
bc.br(root_ip);
865+
let mut program = Program::new(vec![Value::Int(1)], bc.finish()).with_local_count(1);
866+
program.script_functions.push(ScriptFunction {
867+
entry_ip: root_ip,
868+
end_ip: program.code.len() as u32,
869+
});
870+
program.callable_prototypes.push(CallablePrototype {
871+
kind: CallableKind::FunctionItem,
872+
target: CallableTarget::ScriptFunction(0),
873+
arity: 0,
874+
frame_local_count: 1,
875+
parameter_slots: Vec::new(),
876+
capture_source_slots: Vec::new(),
877+
capture_slots: Vec::new(),
878+
capture_modes: Vec::new(),
879+
self_slot: None,
880+
schema: None,
881+
});
882+
let mut engine = TraceJitEngine::new(JitConfig {
883+
enabled: true,
884+
hot_loop_threshold: 1,
885+
max_trace_len: 64,
886+
});
887+
let trace_id = engine
888+
.observe_hot_entry(0, root_ip as usize, 0, &program)
889+
.expect("script-frame trace should compile");
890+
891+
for _ in 1..CALLABLE_SIDE_EXIT_BACKOFF_THRESHOLD {
892+
assert!(!engine.record_native_side_exit(trace_id));
893+
}
894+
engine.record_native_loop_back(trace_id);
895+
for _ in 1..CALLABLE_SIDE_EXIT_BACKOFF_THRESHOLD {
896+
assert!(!engine.record_native_side_exit(trace_id));
897+
}
898+
assert!(engine.record_native_side_exit(trace_id));
899+
engine.block_callable_frame(trace_id);
900+
assert!(engine.callable_frame_is_blocked(0));
901+
assert_eq!(
902+
engine.compiled_trace_for_entry(0, root_ip as usize, 0),
903+
None
904+
);
905+
906+
let root_trace_id = engine
907+
.observe_hot_entry(ROOT_FRAME_KEY, root_ip as usize, 0, &program)
908+
.expect("root trace should compile");
909+
for _ in 0..CALLABLE_SIDE_EXIT_BACKOFF_THRESHOLD * 2 {
910+
assert!(!engine.record_native_side_exit(root_trace_id));
911+
}
912+
}
913+
782914
#[test]
783915
fn trace_entry_cache_separates_root_and_script_frames() {
784916
if !native_jit_supported() {

src/vm/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,10 +2148,12 @@ impl Vm {
21482148
&& !self.drop_contract_events_enabled()
21492149
{
21502150
let frame_key = self.active_frame_key();
2151-
let stack_depth = self.active_operand_stack_len();
2152-
let entry_local_types = (frame_key != crate::vm::native::ROOT_FRAME_KEY)
2153-
.then(|| self.active_local_types());
2154-
let trace_id = {
2151+
let trace_id = if self.jit.callable_frame_is_blocked(frame_key) {
2152+
None
2153+
} else {
2154+
let stack_depth = self.active_operand_stack_len();
2155+
let entry_local_types = (frame_key != crate::vm::native::ROOT_FRAME_KEY)
2156+
.then(|| self.active_local_types());
21552157
let program = &self.program;
21562158
self.jit.observe_hot_entry_with_local_types(
21572159
frame_key,

0 commit comments

Comments
 (0)