Skip to content

Commit aee7494

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 completely removes the singleton_class_seen fallbacks (26.9% of all fallbacks) on the lobsters benchmark.
1 parent e74823a commit aee7494

4 files changed

Lines changed: 95 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::{
@@ -2328,6 +2328,9 @@ fn can_direct_send(function: &mut Function, block: BlockId, iseq: *const rb_iseq
23282328
pub struct Function {
23292329
// ISEQ this function refers to
23302330
iseq: *const rb_iseq_t,
2331+
/// Whether previously, a function for this ISEQ was invalidated due to
2332+
/// singleton class creation (violation of NoSingletonClass invariant).
2333+
was_invalidated_for_singleton_class_creation: bool,
23312334
/// The types for the parameters of this function. They are copied to the type
23322335
/// of entry block params after infer_types() fills Empty to all insn_types.
23332336
param_types: Vec<Type>,
@@ -2434,6 +2437,7 @@ impl Function {
24342437
fn new(iseq: *const rb_iseq_t) -> Function {
24352438
Function {
24362439
iseq,
2440+
was_invalidated_for_singleton_class_creation: false,
24372441
insns: vec![],
24382442
insn_types: vec![],
24392443
union_find: UnionFind::new().into(),
@@ -2559,9 +2563,9 @@ impl Function {
25592563
// No patchpoint needed.
25602564
return true;
25612565
}
2562-
if has_singleton_class_of(klass) {
2563-
// We've seen a singleton class for this klass. Disable the optimization
2564-
// to avoid an invalidation loop.
2566+
if self.was_invalidated_for_singleton_class_creation && invariants::has_singleton_class_of(klass) {
2567+
// A previous compilation of this ISEQ was invalidated for singleton class
2568+
// creation. Avoid repeating the invalidation.
25652569
return false;
25662570
}
25672571
self.push_insn(block, Insn::PatchPoint { invariant: Invariant::NoSingletonClass { klass }, state });
@@ -6708,6 +6712,7 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
67086712
let payload = get_or_create_iseq_payload(iseq);
67096713
let mut profiles = ProfileOracle::new(payload);
67106714
let mut fun = Function::new(iseq);
6715+
fun.was_invalidated_for_singleton_class_creation = payload.was_invalidated_for_singleton_class_creation;
67116716

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

zjit/src/hir/opt_tests.rs

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13492,44 +13492,89 @@ mod hir_opt_tests {
1349213492
// NoSingletonClass optimization to avoid an invalidation loop.
1349313493
#[test]
1349413494
fn test_skip_optimization_after_singleton_class_seen() {
13495-
// First, trigger the singleton class callback for String by creating a singleton class.
13496-
// This should mark String as having had a singleton class seen.
13495+
// First, compile a function that uses the NoSingletonClass assumption
1349713496
eval(r#"
13498-
"hello".singleton_class
13497+
def test(s, proc)
13498+
s.length
13499+
proc.call
13500+
s.length
13501+
end
13502+
test("hi", -> {})
13503+
test("hi", -> {})
1349913504
"#);
13505+
let hir = hir_string("test");
13506+
assert!(hir.contains("NoSingletonClass(String"), "{hir}");
1350013507

13501-
// Now define and compile a method that would normally be optimized with NoSingletonClass.
13502-
// Since String has had a singleton class, the optimization should be skipped and we
13503-
// should fall back to SendWithoutBlock.
13508+
// Now we break the assumption by defining a singleton method on a string.
1350413509
eval(r#"
13505-
def test(s)
13506-
s.length
13507-
end
13508-
test("asdf")
13510+
special_string = +""
13511+
test(special_string, -> { def special_string.length = -1 })
1350913512
"#);
1351013513

1351113514
// The output should NOT have NoSingletonClass patchpoint for String, and should
1351213515
// fall back to SendWithoutBlock instead of the optimized CCall path.
13513-
assert_snapshot!(hir_string("test"), @"
13516+
let hir = hir_string("test");
13517+
assert!(! hir.contains("NoSingletonClass(String"), "{hir}");
13518+
assert_snapshot!(hir, @"
1351413519
fn test@<compiled>:3:
1351513520
bb1():
1351613521
EntryPoint interpreter
1351713522
v1:BasicObject = LoadSelf
1351813523
v2:CPtr = LoadSP
1351913524
v3:BasicObject = LoadField v2, :s@0x1000
13520-
Jump bb3(v1, v3)
13525+
v4:BasicObject = LoadField v2, :proc@0x1001
13526+
Jump bb3(v1, v3, v4)
1352113527
bb2():
1352213528
EntryPoint JIT(0)
13523-
v6:BasicObject = LoadArg :self@0
13524-
v7:BasicObject = LoadArg :s@1
13525-
Jump bb3(v6, v7)
13526-
bb3(v9:BasicObject, v10:BasicObject):
13527-
v16:BasicObject = Send v10, :length # SendFallbackReason: Singleton class previously created for receiver class
13529+
v7:BasicObject = LoadArg :self@0
13530+
v8:BasicObject = LoadArg :s@1
13531+
v9:BasicObject = LoadArg :proc@2
13532+
Jump bb3(v7, v8, v9)
13533+
bb3(v11:BasicObject, v12:BasicObject, v13:BasicObject):
13534+
v19:BasicObject = Send v12, :length # SendFallbackReason: Singleton class previously created for receiver class
13535+
PatchPoint NoSingletonClass(Proc@0x1008)
13536+
PatchPoint MethodRedefined(Proc@0x1008, call@0x1010, cme:0x1018)
13537+
v40:ObjectSubclass[class_exact:Proc] = GuardType v13, ObjectSubclass[class_exact:Proc]
13538+
v41:BasicObject = InvokeProc v40
13539+
PatchPoint NoEPEscape(test)
13540+
v32:BasicObject = Send v12, :length # SendFallbackReason: Singleton class previously created for receiver class
1352813541
CheckInterrupts
13529-
Return v16
13542+
Return v32
1353013543
");
1353113544
}
1353213545

13546+
#[test]
13547+
fn test_no_singleton_class_busts_isolated_per_iseq() {
13548+
// First, compile a function that uses the NoSingletonClass assumption
13549+
eval(r#"
13550+
def will_bust(s, proc)
13551+
s.length
13552+
proc.call
13553+
s.length
13554+
end
13555+
13556+
def call_length(s) = s.length
13557+
13558+
will_bust("hi", -> {})
13559+
will_bust("hi", -> {})
13560+
"#);
13561+
let hir = hir_string("will_bust");
13562+
assert!(hir.contains("NoSingletonClass(String"), "{hir}");
13563+
13564+
// Now we break the assumption by defining a singleton method on a string.
13565+
eval(r#"
13566+
special_string = +""
13567+
will_bust(special_string, -> { def special_string.length = -1 })
13568+
"#);
13569+
let hir = hir_string("will_bust");
13570+
assert!(! hir.contains("NoSingletonClas(String"), "{hir}");
13571+
13572+
// But, the unrelated call_length() should still use NoSingletonClass
13573+
eval("call_length('profile')");
13574+
let hir = hir_string("call_length");
13575+
assert!(hir.contains("NoSingletonClass"), "{hir}");
13576+
}
13577+
1353313578
#[test]
1353413579
fn test_invokesuper_to_iseq_optimizes_to_direct() {
1353513580
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() -> Self {
1821
Self {
1922
profile: IseqProfile::new(),
2023
versions: vec![],
24+
was_invalidated_for_singleton_class_creation: false,
2125
}
2226
}
2327
}

0 commit comments

Comments
 (0)