Skip to content

Commit e03bec1

Browse files
committed
ZJIT: Side-exit-driven recompilation
When a compiled method's type guards fail repeatedly (100 side exits by default), invalidate the V1 code and recompile. The recompilation resets interpreter profiles and re-enables profiling so the method gets fresh type data before V2 compilation. The mechanism is bounded by a global cap of 50 ISEQs to prevent code size inflation. The highest-traffic ISEQs reach the threshold first and get recompiled; the rest stay at V1. New runtime options: --zjit-recompile-threshold=num Side exits to trigger recompile (default: 100, 0 to disable) Key additions: - rb_zjit_count_side_exit: called from exit stubs to count side exits - trigger_recompilation: shared logic to invalidate V1 and re-profile - compile_exit in LIR emits the exit counter when recompilation is enabled - IseqPayload.side_exit_count tracks per-ISEQ exit count - DeferredForReprofiling compile error variant (used by later commits)
1 parent a2531ba commit e03bec1

7 files changed

Lines changed: 124 additions & 14 deletions

File tree

zjit.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ def stats_string
249249
print_counters([
250250
:compiled_iseq_count,
251251
:failed_iseq_count,
252+
:recompile_count,
252253

253254
:compile_time_ns,
254255
:compile_side_exit_time_ns,

zjit/src/backend/lir.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,11 @@ pub struct Assembler {
16401640

16411641
/// Current instruction index, incremented for each instruction pushed
16421642
idx: usize,
1643+
1644+
/// Payload pointer for the ISEQ being compiled (stored as usize for Send/Sync).
1645+
/// Points to the IseqPayload on the Rust heap (not moved by Ruby GC compaction).
1646+
/// Used by compile_exits to call rb_zjit_count_side_exit from generated exit code.
1647+
pub payload_ptr: Option<usize>,
16431648
}
16441649

16451650
impl Assembler
@@ -1655,6 +1660,7 @@ impl Assembler
16551660
current_block_id: BlockId(0),
16561661
num_vregs: 0,
16571662
idx: 0,
1663+
payload_ptr: None,
16581664
}
16591665
}
16601666

@@ -1688,6 +1694,7 @@ impl Assembler
16881694
// Initialize num_vregs to match the old assembler's size
16891695
// This allows reusing VRegs from the old assembler
16901696
asm.num_vregs = old_asm.num_vregs;
1697+
asm.payload_ptr = old_asm.payload_ptr;
16911698

16921699
asm
16931700
}
@@ -2632,8 +2639,18 @@ impl Assembler
26322639

26332640
/// Compile the main side-exit code. This function takes only SideExit so
26342641
/// that it can be safely deduplicated by using SideExit as a dedup key.
2635-
fn compile_exit(asm: &mut Assembler, exit: &SideExit) {
2642+
/// When recompilation is enabled, calls rb_zjit_count_side_exit(payload_ptr)
2643+
/// after saving VM state (ccall is safe here since state is already saved).
2644+
fn compile_exit(asm: &mut Assembler, exit: &SideExit, payload_ptr: Option<usize>) {
26362645
compile_exit_save_state(asm, exit);
2646+
// Recompilation: count exits in the shared (deduplicated) exit stub.
2647+
if get_option!(recompile_threshold) > 0 {
2648+
if let Some(payload_ptr) = payload_ptr {
2649+
use crate::codegen::rb_zjit_count_side_exit;
2650+
asm_comment!(asm, "count side exit for recompilation");
2651+
asm_ccall!(asm, rb_zjit_count_side_exit, Opnd::UImm(payload_ptr as u64));
2652+
}
2653+
}
26372654
compile_exit_return(asm);
26382655
}
26392656

@@ -2729,7 +2746,7 @@ impl Assembler
27292746
let new_exit = self.new_label("side_exit");
27302747
self.write_label(new_exit.clone());
27312748
asm_comment!(self, "Exit: {pc}");
2732-
compile_exit(self, &exit);
2749+
compile_exit(self, &exit, self.payload_ptr);
27332750
compiled_exits.insert(exit, new_exit.unwrap_label());
27342751
new_exit
27352752
};

zjit/src/codegen.rs

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::cell::{Cell, RefCell};
66
use std::rc::Rc;
77
use std::ffi::{c_int, c_long, c_void};
88
use std::slice;
9+
use std::sync::atomic::{AtomicU64, Ordering};
910

1011
use crate::backend::current::ALLOC_REGS;
1112
use crate::invariants::{
@@ -14,7 +15,7 @@ use crate::invariants::{
1415
track_root_box_assumption
1516
};
1617
use crate::gc::append_gc_offsets;
17-
use crate::payload::{get_or_create_iseq_payload, IseqCodePtrs, IseqVersion, IseqVersionRef, IseqStatus};
18+
use crate::payload::{get_or_create_iseq_payload, IseqCodePtrs, IseqPayload, IseqVersion, IseqVersionRef, IseqStatus};
1819
use crate::state::ZJITState;
1920
use crate::stats::{CompileError, exit_counter_for_compile_error, exit_counter_for_unhandled_hir_insn, incr_counter, incr_counter_by, send_fallback_counter, send_fallback_counter_for_method_type, send_fallback_counter_for_super_method_type, send_fallback_counter_ptr_for_opcode, send_without_block_fallback_counter_for_method_type, send_without_block_fallback_counter_for_optimized_method_type};
2021
use crate::stats::{counter_ptr, with_time_stat, Counter, Counter::{compile_time_ns, exit_compile_error}};
@@ -29,6 +30,52 @@ use crate::cast::IntoUsize;
2930
/// At the moment, we support recompiling each ISEQ only once.
3031
pub const MAX_ISEQ_VERSIONS: usize = 2;
3132

33+
/// Called from side-exit stubs to count exits for recompilation.
34+
#[unsafe(no_mangle)]
35+
pub extern "C" fn rb_zjit_count_side_exit(payload_raw: *mut std::ffi::c_void) {
36+
if payload_raw.is_null() { return; }
37+
let payload = unsafe { &mut *(payload_raw as *mut IseqPayload) };
38+
let threshold = get_option!(recompile_threshold) as u64;
39+
if threshold == 0 || payload.side_exit_count >= threshold { return; }
40+
payload.side_exit_count += 1;
41+
if payload.side_exit_count == threshold && payload.versions.len() < MAX_ISEQ_VERSIONS {
42+
let iseq = match payload.versions.last() {
43+
Some(version_ref) => unsafe { version_ref.as_ref() }.iseq,
44+
None => return,
45+
};
46+
with_vm_lock(src_loc!(), || {
47+
trigger_recompilation(payload_raw, iseq);
48+
});
49+
}
50+
}
51+
52+
static GLOBAL_RECOMPILE_COUNT: AtomicU64 = AtomicU64::new(0);
53+
const MAX_GLOBAL_RECOMPILATIONS: u64 = 50;
54+
55+
fn trigger_recompilation(payload_raw: *mut std::ffi::c_void, iseq: IseqPtr) {
56+
if MAX_GLOBAL_RECOMPILATIONS > 0 {
57+
let prev = GLOBAL_RECOMPILE_COUNT.fetch_add(1, Ordering::Relaxed);
58+
if prev >= MAX_GLOBAL_RECOMPILATIONS {
59+
GLOBAL_RECOMPILE_COUNT.fetch_sub(1, Ordering::Relaxed);
60+
return;
61+
}
62+
}
63+
let payload = unsafe { &mut *(payload_raw as *mut IseqPayload) };
64+
debug!("trigger_recompilation: recompiling {}", iseq_get_location(iseq, 0));
65+
incr_counter!(recompile_count);
66+
payload.profile.reset_for_recompile();
67+
if let Some(version) = payload.versions.last_mut() {
68+
let version = unsafe { version.as_mut() };
69+
version.status = IseqStatus::Invalidated;
70+
}
71+
unsafe { rb_iseq_reset_jit_func(iseq) };
72+
unsafe { rb_zjit_profile_enable(iseq) };
73+
}
74+
75+
unsafe extern "C" {
76+
fn rb_zjit_profile_enable(iseq: IseqPtr);
77+
}
78+
3279
/// Sentinel program counter stored in C frames when runtime checks are enabled.
3380
const PC_POISON: Option<*const VALUE> = if cfg!(feature = "runtime_checks") {
3481
Some(usize::MAX as *const VALUE)
@@ -55,18 +102,24 @@ struct JITState {
55102

56103
/// ISEQ calls that need to be compiled later
57104
iseq_calls: Vec<IseqCallRef>,
105+
payload_ptr: usize,
106+
has_version_budget: bool,
58107
}
59108

60109
impl JITState {
61-
/// Create a new JITState instance
62110
fn new(iseq: IseqPtr, version: IseqVersionRef, num_insns: usize, num_blocks: usize) -> Self {
111+
let payload_ptr = get_or_create_iseq_payload(iseq) as *const _ as usize;
112+
let payload = unsafe { &*(payload_ptr as *const IseqPayload) };
113+
let has_version_budget = payload.versions.len() < MAX_ISEQ_VERSIONS;
63114
JITState {
64115
iseq,
65116
version,
66117
opnds: vec![None; num_insns],
67118
labels: vec![None; num_blocks],
68119
jit_entries: Vec::default(),
69120
iseq_calls: Vec::default(),
121+
payload_ptr,
122+
has_version_budget,
70123
}
71124
}
72125

@@ -152,16 +205,15 @@ pub extern "C" fn rb_zjit_iseq_gen_entry_point(iseq: IseqPtr, jit_exception: boo
152205
let mut code_ptr = with_time_stat(compile_time_ns, || gen_iseq_entry_point(cb, iseq, jit_exception));
153206

154207
if let Err(err) = &code_ptr {
155-
// Assert that the ISEQ compiles if RubyVM::ZJIT.assert_compiles is enabled.
156-
// We assert only `jit_exception: false` cases until we support exception handlers.
157-
if ZJITState::assert_compiles_enabled() && !jit_exception {
158-
let iseq_location = iseq_get_location(iseq, 0);
159-
panic!("Failed to compile: {iseq_location}");
160-
}
161-
162-
// For --zjit-stats, generate an entry that just increments exit_compilation_failure and exits
163-
if get_option!(stats) {
164-
code_ptr = gen_compile_error_counter(cb, err);
208+
// DeferredForReprofiling is not a real failure
209+
if *err != CompileError::DeferredForReprofiling {
210+
if ZJITState::assert_compiles_enabled() && !jit_exception {
211+
let iseq_location = iseq_get_location(iseq, 0);
212+
panic!("Failed to compile: {iseq_location}");
213+
}
214+
if get_option!(stats) {
215+
code_ptr = gen_compile_error_counter(cb, err);
216+
}
165217
}
166218
}
167219

@@ -319,6 +371,10 @@ fn gen_function(cb: &mut CodeBlock, iseq: IseqPtr, version: IseqVersionRef, func
319371
let mut jit = JITState::new(iseq, version, function.num_insns(), function.num_blocks());
320372
let mut asm = Assembler::new_with_stack_slots(num_spilled_params);
321373

374+
if get_option!(recompile_threshold) > 0 && jit.payload_ptr != 0 {
375+
asm.payload_ptr = Some(jit.payload_ptr);
376+
}
377+
322378
// Mapping from HIR block IDs to LIR block IDs.
323379
// This is is a one-to-one mapping from HIR to LIR blocks used for finding
324380
// jump targets in LIR (LIR should always jump to the head of an HIR block)

zjit/src/options.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ pub type CallThreshold = u64;
2222
#[allow(non_upper_case_globals)]
2323
pub static mut rb_zjit_profile_threshold: CallThreshold = DEFAULT_CALL_THRESHOLD - DEFAULT_NUM_PROFILES as CallThreshold;
2424

25+
/// Default --zjit-recompile-threshold. Number of side exits before triggering recompilation.
26+
pub const DEFAULT_RECOMPILE_THRESHOLD: u64 = 100;
27+
2528
/// Number of calls to compile ISEQ with ZJIT at jit_compile() in vm.c.
2629
/// --zjit-call-threshold=1 compiles on first execution without profiling information.
2730
#[unsafe(no_mangle)]
@@ -96,6 +99,10 @@ pub struct Options {
9699

97100
/// Path to a file where compiled ISEQs will be saved.
98101
pub log_compiled_iseqs: Option<std::path::PathBuf>,
102+
103+
/// Number of side exits before triggering recompilation of an ISEQ.
104+
/// Set to 0 to disable recompilation.
105+
pub recompile_threshold: u64,
99106
}
100107

101108
impl Default for Options {
@@ -121,6 +128,7 @@ impl Default for Options {
121128
perf: false,
122129
allowed_iseqs: None,
123130
log_compiled_iseqs: None,
131+
recompile_threshold: DEFAULT_RECOMPILE_THRESHOLD,
124132
}
125133
}
126134
}
@@ -144,6 +152,8 @@ pub const ZJIT_OPTIONS: &[(&str, &str)] = &[
144152
("--zjit-perf", "Dump ISEQ symbols into /tmp/perf-{}.map for Linux perf."),
145153
("--zjit-log-compiled-iseqs=path",
146154
"Log compiled ISEQs to the file. The file will be truncated."),
155+
("--zjit-recompile-threshold=num",
156+
"Side exits to trigger recompile (default: 100, 0=off)."),
147157
("--zjit-trace-exits[=counter]",
148158
"Record source on side-exit. `Counter` picks specific counter."),
149159
("--zjit-trace-exits-sample-rate=num",
@@ -331,6 +341,13 @@ fn parse_option(str_ptr: *const std::os::raw::c_char) -> Option<()> {
331341
},
332342

333343

344+
("recompile-threshold", _) => match opt_val.parse() {
345+
Ok(n) => {
346+
options.recompile_threshold = n;
347+
}
348+
Err(_) => return None,
349+
},
350+
334351
("stats-quiet", _) => {
335352
options.stats = true;
336353
options.print_stats = false;

zjit/src/payload.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ pub struct IseqPayload {
1111
pub profile: IseqProfile,
1212
/// JIT code versions. Different versions should have different assumptions.
1313
pub versions: Vec<IseqVersionRef>,
14+
/// Number of side exits observed for this ISEQ
15+
pub side_exit_count: u64,
1416
}
1517

1618
impl IseqPayload {
1719
fn new(iseq_size: u32) -> Self {
1820
Self {
1921
profile: IseqProfile::new(iseq_size),
2022
versions: vec![],
23+
side_exit_count: 0,
2124
}
2225
}
2326
}

zjit/src/profile.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,17 @@ impl IseqProfile {
447447
}
448448
}
449449
}
450+
/// Reset profiling state so all instructions get re-profiled from scratch.
451+
/// Clears both the profiling counters AND the type distribution data.
452+
pub fn reset_for_recompile(&mut self) {
453+
for count in self.num_profiles.iter_mut() {
454+
*count = 0;
455+
}
456+
for operands in self.opnd_types.iter_mut() {
457+
operands.clear();
458+
}
459+
self.super_cme.clear();
460+
}
450461
}
451462

452463
#[cfg(test)]

zjit/src/stats.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ make_counters! {
155155
default {
156156
compiled_iseq_count,
157157
failed_iseq_count,
158+
recompile_count,
158159

159160
compile_time_ns,
160161
profile_time_ns,
@@ -330,6 +331,7 @@ make_counters! {
330331
compile_error_exception_handler,
331332
compile_error_out_of_memory,
332333
compile_error_label_linking_failure,
334+
compile_error_deferred_for_reprofiling,
333335
compile_error_jit_to_jit_optional,
334336
compile_error_register_spill_on_ccall,
335337
compile_error_register_spill_on_alloc,
@@ -505,6 +507,8 @@ pub enum CompileError {
505507
/// offsets that don't fit in one instruction. We error in
506508
/// error that case.
507509
LabelLinkingFailure,
510+
/// Compilation deferred for re-profiling of cold branches.
511+
DeferredForReprofiling,
508512
}
509513

510514
/// Return a raw pointer to the exit counter for a given CompileError
@@ -519,6 +523,7 @@ pub fn exit_counter_for_compile_error(compile_error: &CompileError) -> Counter {
519523
ExceptionHandler => compile_error_exception_handler,
520524
OutOfMemory => compile_error_out_of_memory,
521525
LabelLinkingFailure => compile_error_label_linking_failure,
526+
DeferredForReprofiling => compile_error_deferred_for_reprofiling,
522527
ParseError(parse_error) => match parse_error {
523528
StackUnderflow(_) => compile_error_parse_stack_underflow,
524529
MalformedIseq(_) => compile_error_parse_malformed_iseq,

0 commit comments

Comments
 (0)