ZJIT: Drop has_blockiseq guard on local variable access (#16113)

So `has_blockiseq` was masking two other bugs.

First, that we had only a static EP escape check for an iseq. We need to check
both that an iseq is not known to statically escape them *and that it has not
escaped them before*. Only then can we use SSA locals. This popped up in a
weird PP unit test that was reducible to the test cases added in the PR. So I
fixed that.

Second, that we didn't VM lock when code patching for EP escape. That showed up
in the Ractor btests in my previous PRs #14600 and #15254 #15316, and I had no
idea what was going on at the time, so I dropped it. But if we add this lock,
like all the other invariant patching cases have, the problem goes away. Seems
reasonable.

Finally, we can use normal full SSA locals even in the presence of a blockiseq.
We still reload all of them after each send-with-block, but we can in a later
PR reduce that to just the locals that are (recursively) visibly written to.
This commit is contained in:
Max Bernstein authored and GitHub committed 2026-02-09 21:40:25 +00:00
1 parent 51d4c2c465
commit 9efe557da5
3 files changed
+128 -40
Notes: git 2026-08-22 21:06:29 +00:00
Merged-By: tekknolagi <donotemailthisaddress@bernsteinbear.com>

No files matched your search

+10 -14
View File
@@ -6087,14 +6087,12 @@ pub fn jit_entry_insns(iseq: IseqPtr) -> Vec<u32> {
struct BytecodeInfo {
jump_targets: Vec<u32>,
has_blockiseq: bool,
}
fn compute_bytecode_info(iseq: *const rb_iseq_t, opt_table: &[u32]) -> BytecodeInfo {
let iseq_size = unsafe { get_iseq_encoded_size(iseq) };
let mut insn_idx = 0;
let mut jump_targets: HashSet<u32> = opt_table.iter().copied().collect();
let mut has_blockiseq = false;
while insn_idx < iseq_size {
// Get the current pc and opcode
let pc = unsafe { rb_iseq_pc_at_idx(iseq, insn_idx) };
@@ -6118,18 +6116,12 @@ fn compute_bytecode_info(iseq: *const rb_iseq_t, opt_table: &[u32]) -> BytecodeI
jump_targets.insert(insn_idx);
}
}
YARVINSN_send | YARVINSN_invokesuper => {
let blockiseq: IseqPtr = get_arg(pc, 1).as_iseq();
if !blockiseq.is_null() {
has_blockiseq = true;
}
}
_ => {}
}
}
let mut result = jump_targets.into_iter().collect::<Vec<_>>();
result.sort();
BytecodeInfo { jump_targets: result, has_blockiseq }
BytecodeInfo { jump_targets: result }
}
#[derive(Debug, PartialEq, Clone, Copy)]
@@ -6244,7 +6236,7 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
// Compute a map of PC->Block by finding jump targets
let jit_entry_insns = jit_entry_insns(iseq);
let BytecodeInfo { jump_targets, has_blockiseq } = compute_bytecode_info(iseq, &jit_entry_insns);
let BytecodeInfo { jump_targets } = compute_bytecode_info(iseq, &jit_entry_insns);
// Make all empty basic blocks. The ordering of the BBs matters for getting fallthrough jumps
// in good places, but it's not necessary for correctness. TODO: Higher quality scheduling during lowering.
@@ -6276,7 +6268,11 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
// Check if the EP is escaped for the ISEQ from the beginning. We give up
// optimizing locals in that case because they're shared with other frames.
let ep_escaped = iseq_escapes_ep(iseq);
let ep_starts_escaped = iseq_escapes_ep(iseq);
// Check if the EP has been escaped at some point in the ISEQ. If it has, then we assume that
// its EP is shared with other frames.
let ep_has_been_escaped = crate::invariants::iseq_escapes_ep(iseq);
let ep_escaped = ep_starts_escaped || ep_has_been_escaped;
// Iteratively fill out basic blocks using a queue.
// TODO(max): Basic block arguments at edges
@@ -6620,7 +6616,7 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
// Use FrameState to get kw_bits when possible, just like getlocal_WC_0.
let val = if !local_inval {
state.getlocal(ep_offset)
} else if ep_escaped || has_blockiseq {
} else if ep_escaped {
fun.push_insn(block, Insn::GetLocal { ep_offset, level: 0, use_sp: false, rest_param: false })
} else {
let exit_id = fun.push_insn(block, Insn::Snapshot { state: exit_state.without_locals() });
@@ -6743,7 +6739,7 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
// In case of JIT-to-JIT send locals might never end up in EP memory.
let val = state.getlocal(ep_offset);
state.stack_push(val);
} else if ep_escaped || has_blockiseq { // TODO: figure out how to drop has_blockiseq here
} else if ep_escaped {
// Read the local using EP
let val = fun.push_insn(block, Insn::GetLocal { ep_offset, level: 0, use_sp: false, rest_param: false });
state.setlocal(ep_offset, val); // remember the result to spill on side-exits
@@ -6764,7 +6760,7 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
YARVINSN_setlocal_WC_0 => {
let ep_offset = get_arg(pc, 0).as_u32();
let val = state.stack_pop()?;
if ep_escaped || has_blockiseq { // TODO: figure out how to drop has_blockiseq here
if ep_escaped {
// Write the local using EP
fun.push_insn(block, Insn::SetLocal { val, ep_offset, level: 0 });
} else if local_inval {
+105 -15
View File
@@ -2926,15 +2926,14 @@ mod hir_opt_tests {
Jump bb2(v5, v6)
bb2(v8:BasicObject, v9:NilClass):
v13:Fixnum[1] = Const Value(1)
SetLocal :a, l0, EP@3, v13
PatchPoint NoSingletonClass(Object@0x1000)
PatchPoint MethodRedefined(Object@0x1000, foo@0x1008, cme:0x1010)
v31:HeapObject[class_exact*:Object@VALUE(0x1000)] = GuardType v8, HeapObject[class_exact*:Object@VALUE(0x1000)]
IncrCounter inline_iseq_optimized_send_count
v20:BasicObject = GetLocal :a, l0, EP@3
v24:BasicObject = GetLocal :a, l0, EP@3
v19:BasicObject = GetLocal :a, l0, EP@3
PatchPoint NoEPEscape(test)
CheckInterrupts
Return v24
Return v19
");
}
@@ -3415,15 +3414,14 @@ mod hir_opt_tests {
Jump bb2(v6, v7, v8)
bb2(v10:BasicObject, v11:BasicObject, v12:NilClass):
v16:ArrayExact = NewArray
SetLocal :a, l0, EP@3, v16
v22:TrueClass = Const Value(true)
v21:TrueClass = Const Value(true)
IncrCounter complex_arg_pass_caller_kwarg
v24:BasicObject = Send v11, 0x1000, :each_line, v22 # SendFallbackReason: Complex argument passing
v25:BasicObject = GetLocal :s, l0, EP@4
v26:BasicObject = GetLocal :a, l0, EP@3
v30:BasicObject = GetLocal :a, l0, EP@3
v23:BasicObject = Send v11, 0x1000, :each_line, v21 # SendFallbackReason: Complex argument passing
v24:BasicObject = GetLocal :s, l0, EP@4
v25:BasicObject = GetLocal :a, l0, EP@3
PatchPoint NoEPEscape(test)
CheckInterrupts
Return v30
Return v25
");
}
@@ -6523,7 +6521,6 @@ mod hir_opt_tests {
Jump bb2(v5, v6)
bb2(v8:BasicObject, v9:NilClass):
v13:ArrayExact = NewArray
SetLocal :result, l0, EP@3, v13
PatchPoint SingleRactorMode
PatchPoint StableConstantNames(0x1000, A)
v36:ArrayExact[VALUE(0x1008)] = Const Value(VALUE(0x1008))
@@ -6533,10 +6530,10 @@ mod hir_opt_tests {
PatchPoint NoSingletonClass(Array@0x1020)
PatchPoint MethodRedefined(Array@0x1020, zip@0x1028, cme:0x1030)
v43:BasicObject = CCallVariadic v36, :zip@0x1058, v39
v25:BasicObject = GetLocal :result, l0, EP@3
v29:BasicObject = GetLocal :result, l0, EP@3
v24:BasicObject = GetLocal :result, l0, EP@3
PatchPoint NoEPEscape(test)
CheckInterrupts
Return v29
Return v24
");
}
@@ -11887,4 +11884,97 @@ mod hir_opt_tests {
Return v31
");
}
#[test]
fn recompile_after_ep_escape_uses_ep_locals() {
// When a method creates a lambda, EP escapes to the heap. After
// invalidation and recompilation, the compiler must use EP-based
// locals (SetLocal/GetLocal) instead of SSA locals, because the
// spill target (stack) and the read target (heap EP) diverge.
eval("
CONST = {}.freeze
def test_ep_escape(list, sep=nil, iter_method=:each)
sep ||= lambda { }
kwsplat = CONST
list.__send__(iter_method) {|*v| yield(*v) }
end
test_ep_escape({a: 1}, nil, :each_pair) { |k, v|
test_ep_escape([1], lambda { }) { |x| }
}
test_ep_escape({a: 1}, nil, :each_pair) { |k, v|
test_ep_escape([1], lambda { }) { |x| }
}
");
assert_snapshot!(hir_string("test_ep_escape"), @r"
fn test_ep_escape@<compiled>:3:
bb0():
EntryPoint interpreter
v1:BasicObject = LoadSelf
v2:BasicObject = GetLocal :list, l0, SP@7
v3:BasicObject = GetLocal :sep, l0, SP@6
v4:BasicObject = GetLocal :iter_method, l0, SP@5
v5:NilClass = Const Value(nil)
v6:CPtr = LoadPC
v7:CPtr[CPtr(0x1000)] = Const CPtr(0x1008)
v8:CBool = IsBitEqual v6, v7
IfTrue v8, bb2(v1, v2, v3, v4, v5)
v10:CPtr[CPtr(0x1000)] = Const CPtr(0x1008)
v11:CBool = IsBitEqual v6, v10
IfTrue v11, bb4(v1, v2, v3, v4, v5)
Jump bb6(v1, v2, v3, v4, v5)
bb1(v15:BasicObject, v16:BasicObject):
EntryPoint JIT(0)
v17:NilClass = Const Value(nil)
v18:NilClass = Const Value(nil)
v19:NilClass = Const Value(nil)
Jump bb2(v15, v16, v17, v18, v19)
bb2(v35:BasicObject, v36:BasicObject, v37:BasicObject, v38:BasicObject, v39:NilClass):
v42:NilClass = Const Value(nil)
SetLocal :sep, l0, EP@5, v42
Jump bb4(v35, v36, v42, v38, v39)
bb3(v22:BasicObject, v23:BasicObject, v24:BasicObject):
EntryPoint JIT(1)
v25:NilClass = Const Value(nil)
v26:NilClass = Const Value(nil)
Jump bb4(v22, v23, v24, v25, v26)
bb4(v46:BasicObject, v47:BasicObject, v48:BasicObject, v49:BasicObject, v50:NilClass):
v53:StaticSymbol[:each] = Const Value(VALUE(0x1010))
SetLocal :iter_method, l0, EP@4, v53
Jump bb6(v46, v47, v48, v53, v50)
bb5(v29:BasicObject, v30:BasicObject, v31:BasicObject, v32:BasicObject):
EntryPoint JIT(2)
v33:NilClass = Const Value(nil)
Jump bb6(v29, v30, v31, v32, v33)
bb6(v57:BasicObject, v58:BasicObject, v59:BasicObject, v60:BasicObject, v61:NilClass):
CheckInterrupts
v67:CBool = Test v59
v68:Truthy = RefineType v59, Truthy
IfTrue v67, bb7(v57, v58, v68, v60, v61)
v70:Falsy = RefineType v59, Falsy
PatchPoint NoSingletonClass(Object@0x1018)
PatchPoint MethodRedefined(Object@0x1018, lambda@0x1020, cme:0x1028)
v114:HeapObject[class_exact*:Object@VALUE(0x1018)] = GuardType v57, HeapObject[class_exact*:Object@VALUE(0x1018)]
v115:BasicObject = CCallWithFrame v114, :Kernel#lambda@0x1050, block=0x1058
v74:BasicObject = GetLocal :list, l0, EP@6
v76:BasicObject = GetLocal :iter_method, l0, EP@4
v77:BasicObject = GetLocal :kwsplat, l0, EP@3
SetLocal :sep, l0, EP@5, v115
Jump bb7(v57, v74, v115, v76, v77)
bb7(v81:BasicObject, v82:BasicObject, v83:BasicObject, v84:BasicObject, v85:BasicObject):
PatchPoint SingleRactorMode
PatchPoint StableConstantNames(0x1060, CONST)
v110:HashExact[VALUE(0x1068)] = Const Value(VALUE(0x1068))
SetLocal :kwsplat, l0, EP@3, v110
v95:BasicObject = GetLocal :list, l0, EP@6
v97:BasicObject = GetLocal :iter_method, l0, EP@4
v99:BasicObject = Send v95, 0x1070, :__send__, v97 # SendFallbackReason: Send: unsupported method type Optimized
v100:BasicObject = GetLocal :list, l0, EP@6
v101:BasicObject = GetLocal :sep, l0, EP@5
v102:BasicObject = GetLocal :iter_method, l0, EP@4
v103:BasicObject = GetLocal :kwsplat, l0, EP@3
CheckInterrupts
Return v99
");
}
}
+13 -11
View File
@@ -206,20 +206,22 @@ pub extern "C" fn rb_zjit_invalidate_no_ep_escape(iseq: IseqPtr) {
return;
}
// Remember that this ISEQ may escape EP
let invariants = ZJITState::get_invariants();
invariants.ep_escape_iseqs.insert(iseq);
with_vm_lock(src_loc!(), || {
// Remember that this ISEQ may escape EP
let invariants = ZJITState::get_invariants();
invariants.ep_escape_iseqs.insert(iseq);
// If the ISEQ has been compiled assuming it doesn't escape EP, invalidate the JIT code.
if let Some(patch_points) = invariants.no_ep_escape_iseq_patch_points.get(&iseq) {
debug!("EP is escaped: {}", iseq_name(iseq));
// If the ISEQ has been compiled assuming it doesn't escape EP, invalidate the JIT code.
if let Some(patch_points) = invariants.no_ep_escape_iseq_patch_points.get(&iseq) {
debug!("EP is escaped: {}", iseq_name(iseq));
// Invalidate the patch points for this ISEQ
let cb = ZJITState::get_code_block();
compile_patch_points!(cb, patch_points, "EP is escaped: {}", iseq_name(iseq));
// Invalidate the patch points for this ISEQ
let cb = ZJITState::get_code_block();
compile_patch_points!(cb, patch_points, "EP is escaped: {}", iseq_name(iseq));
cb.mark_all_executable();
}
cb.mark_all_executable();
}
});
}
/// Track that JIT code for a ISEQ will assume that base pointer is equal to environment pointer.