Skip to content

Commit b9d93a7

Browse files
committed
ZJIT: Per-ISEQ feedback for singleton class speculation
Instead of globally disabling NoSingletonClass optimization for a class once any singleton class has been seen, track invalidations per-ISEQ. When an ISEQ's NoSingletonClass patchpoint is invalidated, set a flag on its payload so only that ISEQ avoids re-speculating on recompilation. ISEQs that were never invalidated continue to optimize with NoSingletonClass even if other ISEQs were invalidated for the same class. This reduces singleton_class_seen fallbacks from 9.7M (26.9% of all fallbacks) to 1.3M (4.8%) on the lobsters benchmark.
1 parent f0f1f02 commit b9d93a7

4 files changed

Lines changed: 88 additions & 32 deletions

File tree

zjit/src/hir.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#![allow(clippy::match_like_matches_macro)]
88
use crate::{
99
backend::lir::C_ARG_OPNDS,
10-
cast::IntoUsize, codegen::local_idx_to_ep_offset, cruby::*, invariants::{self, has_singleton_class_of}, payload::{get_or_create_iseq_payload, IseqPayload}, options::{debug, get_option, DumpHIR}, state::ZJITState, json::Json,
10+
cast::IntoUsize, codegen::local_idx_to_ep_offset, cruby::*, invariants::{self}, payload::{get_or_create_iseq_payload, IseqPayload}, options::{debug, get_option, DumpHIR}, state::ZJITState, json::Json,
1111
state,
1212
};
1313
use std::{
@@ -2302,6 +2302,9 @@ fn can_direct_send(function: &mut Function, block: BlockId, iseq: *const rb_iseq
23022302
pub struct Function {
23032303
// ISEQ this function refers to
23042304
iseq: *const rb_iseq_t,
2305+
/// Whether previously, a function for this ISEQ was invalidated due to
2306+
/// singleton class creation (violation of NoSingletonClass invariant).
2307+
was_invalidated_for_singleton_class_creation: bool,
23052308
/// The types for the parameters of this function. They are copied to the type
23062309
/// of entry block params after infer_types() fills Empty to all insn_types.
23072310
param_types: Vec<Type>,
@@ -2408,6 +2411,7 @@ impl Function {
24082411
fn new(iseq: *const rb_iseq_t) -> Function {
24092412
Function {
24102413
iseq,
2414+
was_invalidated_for_singleton_class_creation: false,
24112415
insns: vec![],
24122416
insn_types: vec![],
24132417
union_find: UnionFind::new().into(),
@@ -2533,9 +2537,9 @@ impl Function {
25332537
// No patchpoint needed.
25342538
return true;
25352539
}
2536-
if has_singleton_class_of(klass) {
2537-
// We've seen a singleton class for this klass. Disable the optimization
2538-
// to avoid an invalidation loop.
2540+
if self.was_invalidated_for_singleton_class_creation {
2541+
// A previous compilation of this ISEQ was invalidated for singleton class
2542+
// creation. Avoid a repeating the invalidation.
25392543
return false;
25402544
}
25412545
self.push_insn(block, Insn::PatchPoint { invariant: Invariant::NoSingletonClass { klass }, state });
@@ -6700,6 +6704,7 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
67006704
let payload = get_or_create_iseq_payload(iseq);
67016705
let mut profiles = ProfileOracle::new(payload);
67026706
let mut fun = Function::new(iseq);
6707+
fun.was_invalidated_for_singleton_class_creation = payload.was_invalidated_for_singleton_class_creation;
67036708

67046709
// Compute a map of PC->Block by finding jump targets
67056710
let jit_entry_insns = jit_entry_insns(iseq);

zjit/src/hir/opt_tests.rs

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13227,44 +13227,82 @@ mod hir_opt_tests {
1322713227
// NoSingletonClass optimization to avoid an invalidation loop.
1322813228
#[test]
1322913229
fn test_skip_optimization_after_singleton_class_seen() {
13230-
// First, trigger the singleton class callback for String by creating a singleton class.
13231-
// This should mark String as having had a singleton class seen.
13230+
// First, compile a function that uses the NoSingletonClass assumption
1323213231
eval(r#"
13233-
"hello".singleton_class
13232+
def test(s, proc)
13233+
s.length
13234+
proc.call
13235+
s.length
13236+
end
13237+
test("hi", -> {})
13238+
test("hi", -> {})
1323413239
"#);
13240+
assert!(hir_string("test").contains("NoSingletonClass"));
1323513241

13236-
// Now define and compile a method that would normally be optimized with NoSingletonClass.
13237-
// Since String has had a singleton class, the optimization should be skipped and we
13238-
// should fall back to SendWithoutBlock.
13242+
// Now we break the assumption by defining a singleton method on a string.
1323913243
eval(r#"
13240-
def test(s)
13241-
s.length
13242-
end
13243-
test("asdf")
13244+
special_string = +""
13245+
test(special_string, -> { def special_string.length = -1 })
1324413246
"#);
1324513247

1324613248
// The output should NOT have NoSingletonClass patchpoint for String, and should
1324713249
// fall back to SendWithoutBlock instead of the optimized CCall path.
13248-
assert_snapshot!(hir_string("test"), @"
13250+
let hir = hir_string("test");
13251+
assert!(! hir.contains("NoSingletonClass"));
13252+
assert_snapshot!(hir, @"
1324913253
fn test@<compiled>:3:
1325013254
bb1():
1325113255
EntryPoint interpreter
1325213256
v1:BasicObject = LoadSelf
1325313257
v2:CPtr = LoadSP
1325413258
v3:BasicObject = LoadField v2, :s@0x1000
13255-
Jump bb3(v1, v3)
13259+
v4:BasicObject = LoadField v2, :proc@0x1001
13260+
Jump bb3(v1, v3, v4)
1325613261
bb2():
1325713262
EntryPoint JIT(0)
13258-
v6:BasicObject = LoadArg :self@0
13259-
v7:BasicObject = LoadArg :s@1
13260-
Jump bb3(v6, v7)
13261-
bb3(v9:BasicObject, v10:BasicObject):
13262-
v16:BasicObject = Send v10, :length # SendFallbackReason: Singleton class previously created for receiver class
13263+
v7:BasicObject = LoadArg :self@0
13264+
v8:BasicObject = LoadArg :s@1
13265+
v9:BasicObject = LoadArg :proc@2
13266+
Jump bb3(v7, v8, v9)
13267+
bb3(v11:BasicObject, v12:BasicObject, v13:BasicObject):
13268+
v19:BasicObject = Send v12, :length # SendFallbackReason: Singleton class previously created for receiver class
13269+
v24:BasicObject = Send v13, :call # SendFallbackReason: Singleton class previously created for receiver class
13270+
PatchPoint NoEPEscape(test)
13271+
v32:BasicObject = Send v12, :length # SendFallbackReason: Singleton class previously created for receiver class
1326313272
CheckInterrupts
13264-
Return v16
13273+
Return v32
1326513274
");
1326613275
}
1326713276

13277+
#[test]
13278+
fn test_no_singleton_class_busts_isolated_per_iseq() {
13279+
// First, compile a function that uses the NoSingletonClass assumption
13280+
eval(r#"
13281+
def will_bust(s, proc)
13282+
s.length
13283+
proc.call
13284+
s.length
13285+
end
13286+
13287+
def call_length(s) = s.length
13288+
13289+
will_bust("hi", -> {})
13290+
will_bust("hi", -> {})
13291+
"#);
13292+
assert!(hir_string("will_bust").contains("NoSingletonClass"));
13293+
13294+
// Now we break the assumption by defining a singleton method on a string.
13295+
eval(r#"
13296+
special_string = +""
13297+
will_bust(special_string, -> { def special_string.length = -1 })
13298+
"#);
13299+
assert!(! hir_string("will_bust").contains("NoSingletonClass"));
13300+
13301+
// But, the unrelated call_length() should still use NoSingletonClass
13302+
eval("call_length('profile')");
13303+
assert!(hir_string("call_length").contains("NoSingletonClass"));
13304+
}
13305+
1326813306
#[test]
1326913307
fn test_invokesuper_to_iseq_optimizes_to_direct() {
1327013308
eval("

zjit/src/invariants.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::stats::Counter::invalidation_time_ns;
1111
use crate::gc::remove_gc_offsets;
1212

1313
macro_rules! compile_patch_points {
14-
($cb:expr, $patch_points:expr, $($comment_args:tt)*) => {
14+
($cb:expr, $patch_points:expr, $cause:ident, $($comment_args:tt)*) => {
1515
with_time_stat(invalidation_time_ns, || {
1616
for patch_point in $patch_points {
1717
let written_range = $cb.with_write_ptr(patch_point.patch_point_ptr, |cb| {
@@ -24,11 +24,11 @@ macro_rules! compile_patch_points {
2424
// Stop marking GC offsets corrupted by the jump instruction
2525
remove_gc_offsets(patch_point.version, &written_range);
2626

27-
// If the ISEQ doesn't have max versions, invalidate this version.
2827
let mut version = patch_point.version;
2928
let iseq = unsafe { version.as_ref() }.iseq;
3029
if !iseq.is_null() {
3130
let payload = get_or_create_iseq_payload(iseq);
31+
// If the ISEQ doesn't have max versions, invalidate this version.
3232
if unsafe { version.as_ref() }.status != IseqStatus::Invalidated && payload.versions.len() < MAX_ISEQ_VERSIONS {
3333
unsafe { version.as_mut() }.status = IseqStatus::Invalidated;
3434
unsafe { rb_iseq_reset_jit_func(version.as_ref().iseq) };
@@ -40,12 +40,21 @@ macro_rules! compile_patch_points {
4040
}
4141
}
4242
}
43+
// Remember NoSingletonClass busts on the payload
44+
if is_no_singleton_class!($cause) {
45+
payload.was_invalidated_for_singleton_class_creation = true;
46+
}
4347
}
4448
}
4549
});
4650
};
4751
}
4852

53+
macro_rules! is_no_singleton_class {
54+
(NoSingletonClass) => { true };
55+
($_:ident) => { false };
56+
}
57+
4958
/// When a PatchPoint is invalidated, it generates a jump instruction from `from` to `to`.
5059
#[derive(Debug, Eq, Hash, PartialEq)]
5160
struct PatchPoint {
@@ -196,7 +205,7 @@ pub extern "C" fn rb_zjit_bop_redefined(klass: RedefinitionFlag, bop: ruby_basic
196205
debug!("BOP is redefined: {}", bop);
197206

198207
// Invalidate all patch points for this BOP
199-
compile_patch_points!(cb, patch_points, "BOP is redefined: {}", bop);
208+
compile_patch_points!(cb, patch_points, BOP, "BOP is redefined: {}", bop);
200209

201210
cb.mark_all_executable();
202211
}
@@ -223,7 +232,7 @@ pub extern "C" fn rb_zjit_invalidate_no_ep_escape(iseq: IseqPtr) {
223232

224233
// Invalidate the patch points for this ISEQ
225234
let cb = ZJITState::get_code_block();
226-
compile_patch_points!(cb, patch_points, "EP is escaped: {}", iseq_name(iseq));
235+
compile_patch_points!(cb, patch_points, EP, "EP is escaped: {}", iseq_name(iseq));
227236

228237
cb.mark_all_executable();
229238
}
@@ -338,7 +347,7 @@ pub extern "C" fn rb_zjit_cme_invalidate(cme: *const rb_callable_method_entry_t)
338347
debug!("CME is invalidated: {:?}", cme);
339348

340349
// Invalidate all patch points for this CME
341-
compile_patch_points!(cb, patch_points, "CME is invalidated: {:?}", cme);
350+
compile_patch_points!(cb, patch_points, CME, "CME is invalidated: {:?}", cme);
342351

343352
cb.mark_all_executable();
344353
}
@@ -360,7 +369,7 @@ pub extern "C" fn rb_zjit_constant_state_changed(id: ID) {
360369
debug!("Constant state changed: {:?}", id);
361370

362371
// Invalidate all patch points for this constant ID
363-
compile_patch_points!(cb, patch_points, "Constant state changed: {:?}", id);
372+
compile_patch_points!(cb, patch_points, Const, "Constant state changed: {:?}", id);
364373

365374
cb.mark_all_executable();
366375
}
@@ -395,7 +404,7 @@ pub extern "C" fn rb_zjit_before_ractor_spawn() {
395404
let patch_points = mem::take(&mut ZJITState::get_invariants().single_ractor_patch_points);
396405

397406
// Invalidate all patch points for single ractor mode
398-
compile_patch_points!(cb, patch_points, "Another ractor spawned, invalidating single ractor mode assumption");
407+
compile_patch_points!(cb, patch_points, Ractor, "Another ractor spawned, invalidating single ractor mode assumption");
399408

400409
cb.mark_all_executable();
401410
});
@@ -439,7 +448,7 @@ pub extern "C" fn rb_zjit_tracing_invalidate_all() {
439448
let cb = ZJITState::get_code_block();
440449
let patch_points = mem::take(&mut ZJITState::get_invariants().no_trace_point_patch_points);
441450

442-
compile_patch_points!(cb, patch_points, "TracePoint is enabled, invalidating no TracePoint assumption");
451+
compile_patch_points!(cb, patch_points, TracePoint, "TracePoint is enabled, invalidating no TracePoint assumption");
443452

444453
cb.mark_all_executable();
445454
});
@@ -481,7 +490,7 @@ pub extern "C" fn rb_zjit_invalidate_root_box() {
481490
let patch_points = mem::take(&mut invariants.root_box_patch_points);
482491

483492
// Invalidate all patch points for root box mode
484-
compile_patch_points!(cb, patch_points, "Non-root box created, invalidating root box assumption");
493+
compile_patch_points!(cb, patch_points, Box, "Non-root box created, invalidating root box assumption");
485494

486495
cb.mark_all_executable();
487496
});
@@ -513,7 +522,7 @@ pub extern "C" fn rb_zjit_invalidate_no_singleton_class(klass: VALUE) {
513522
if !patch_points.is_empty() {
514523
let cb = ZJITState::get_code_block();
515524
debug!("Singleton class created for {:?}", klass);
516-
compile_patch_points!(cb, patch_points, "Singleton class created for {:?}", klass);
525+
compile_patch_points!(cb, patch_points, NoSingletonClass, "Singleton class created for {:?}", klass);
517526
cb.mark_all_executable();
518527
}
519528
}

zjit/src/payload.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ pub struct IseqPayload {
1111
pub profile: IseqProfile,
1212
/// JIT code versions. Different versions should have different assumptions.
1313
pub versions: Vec<IseqVersionRef>,
14+
/// Whether a previous compilation of this ISEQ was invalidated due to
15+
/// singleton class creation (violation of [`crate::hir::Invariant::NoSingletonClass`]).
16+
pub was_invalidated_for_singleton_class_creation: bool,
1417
}
1518

1619
impl IseqPayload {
1720
fn new(iseq_size: u32) -> Self {
1821
Self {
1922
profile: IseqProfile::new(iseq_size),
2023
versions: vec![],
24+
was_invalidated_for_singleton_class_creation: false,
2125
}
2226
}
2327
}

0 commit comments

Comments
 (0)