Skip to content

Commit 8466e93

Browse files
XrXrk0kubun
authored andcommitted
YJIT: Fix not reading locals from cfp->ep after YJIT.enable and exceptional entry
[Backport #21941] In case of `--yjit-disable`, YJIT only starts to record environment escapes after `RubyVM::YJIT.enable`. Previously we falsely assumed that we always have a full history all the way back to VM boot. This had YJIT install and run code that assume EP=BP when EP≠BP for some exceptional entry into the middle of a running frame, if the environment escaped before `YJIT.enable`. The fix is to reject exceptional entry with an escaped environment. Rename things and explain in more detail how the predicate for deciding to assume EP=BP works. It's quite subtle since it reasons about all parties in the system that push a control frame and then run JIT code. Note that while can_assume_on_stack_env() checks the currently running environment if it so happens to be the one YJIT is compiling against, it can return true for any ISEQ. The check isn't necessary for fixing the bug, and the load bearing part of this patch is the change to exceptional entries. This fix is flat on speed and space on ruby-bench headline benchmarks. Many thanks for the community effort to create a small test case for this bug.
1 parent ad231cd commit 8466e93

7 files changed

Lines changed: 73 additions & 15 deletions

File tree

test/ruby/test_yjit.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,27 @@ def bar(w:, x:, y:, z:, **kwrest) = yield kwrest
17841784
RUBY
17851785
end
17861786

1787+
def test_exceptional_entry_into_env_escaped_before_yjit_enablement
1788+
threshold = 2
1789+
assert_separately(["--disable-all", "--yjit-disable", "--yjit-call-threshold=#{threshold}"], <<~RUBY)
1790+
def run
1791+
@captured_env = ->{}
1792+
RubyVM::YJIT.enable
1793+
1794+
i = 0
1795+
while i < #{threshold}
1796+
next_i = i + 1
1797+
from_break = tap { break i + 1 } # break from the block generates an exceptional entry
1798+
assert_equal(from_break, next_i, '[Bug #21941]')
1799+
i = next_i
1800+
end
1801+
end
1802+
1803+
run
1804+
assert_equal(#{threshold}, @captured_env.binding.local_variable_get(:i))
1805+
RUBY
1806+
end
1807+
17871808
private
17881809

17891810
def code_gc_helpers

yjit.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ def _print_stats(out: $stderr) # :nodoc:
353353
# Number of failed compiler invocations
354354
compilation_failure = stats[:compilation_failure]
355355

356+
# Number of refused exceptional entries with an escaped environment
357+
exceptional_entry_escaped_env = stats[:exceptional_entry_escaped_env]
358+
356359
code_region_overhead = stats[:code_region_size] - (stats[:inline_code_size] + stats[:outlined_code_size])
357360

358361
out.puts "num_send: " + format_number(13, stats[:num_send])
@@ -389,6 +392,7 @@ def _print_stats(out: $stderr) # :nodoc:
389392
out.puts "bindings_allocations: " + format_number(13, stats[:binding_allocations])
390393
out.puts "bindings_set: " + format_number(13, stats[:binding_set])
391394
out.puts "compilation_failure: " + format_number(13, compilation_failure) if compilation_failure != 0
395+
out.puts "exceptional_entry_escaped_env:" + format_number(6, exceptional_entry_escaped_env) if exceptional_entry_escaped_env != 0
392396
out.puts "live_iseq_count: " + format_number(13, stats[:live_iseq_count])
393397
out.puts "iseq_alloc_count: " + format_number(13, stats[:iseq_alloc_count])
394398
out.puts "compiled_iseq_entry: " + format_number(13, stats[:compiled_iseq_entry])

yjit/src/codegen.rs

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -234,20 +234,36 @@ impl<'a> JITState<'a> {
234234
result
235235
}
236236

237-
/// Return true if the current ISEQ could escape an environment.
237+
/// Return true if the JIT code can use [`Self::assume_no_ep_escape`]
238+
/// and will run with an on-stack (`!VM_ENV_ESCAPED_P`) environment.
238239
///
239-
/// As of vm_push_frame(), EP is always equal to BP. However, after pushing
240-
/// a frame, some ISEQ setups call vm_bind_update_env(), which redirects EP.
241-
/// Also, some method calls escape the environment to the heap.
242-
fn escapes_ep(&self) -> bool {
240+
/// ## Reasoning about ISEQs that are not currently running
241+
///
242+
/// As of vm_push_frame() and its JIT code equivalent, EP is always equal to BP (the
243+
/// environment is on-stack and has not escaped). We can usually assume this is the starting
244+
/// condition upon entry into JIT code. However, after pushing a frame and before entry into
245+
/// JIT code, some ISEQ setups call vm_bind_update_env(), which redirects EP.
246+
///
247+
/// ## After making the assumption
248+
///
249+
/// After JIT code entry, many ruby operations can have the environment escape to heap. These
250+
/// are handled by [`crate::invariants`].
251+
///
252+
/// Exceptional entry through jit_exec_exception() is an extreme case of the environment state
253+
/// changing between vm_push_frame() and entry into JIT code. The frame could have been pushed
254+
/// before YJIT is enabled. The exception entry point refuses entry with an escaped environment.
255+
fn can_assume_on_stack_env(&self) -> bool {
243256
match unsafe { get_iseq_body_type(self.iseq) } {
244257
// <main> frame is always associated to TOPLEVEL_BINDING.
245258
ISEQ_TYPE_MAIN |
246259
// Kernel#eval uses a heap EP when a Binding argument is not nil.
247-
ISEQ_TYPE_EVAL => true,
248-
// If this ISEQ has previously escaped EP, give up the optimization.
249-
_ if iseq_escapes_ep(self.iseq) => true,
250-
_ => false,
260+
ISEQ_TYPE_EVAL => false,
261+
// Check the running environment if compiling for it
262+
_ if unsafe { self.iseq == get_cfp_iseq(self.get_cfp()) && cfp_env_has_escaped(self.get_cfp()) } => false,
263+
// If we've seen this ISEQ run with an escaped environment, give up the optimization
264+
// to avoid excessive invalidations (even though it may be fine for soundness).
265+
_ if seen_escaped_env(self.iseq) => false,
266+
_ => true,
251267
}
252268
}
253269

@@ -376,8 +392,8 @@ impl<'a> JITState<'a> {
376392
if jit_ensure_block_entry_exit(self, asm).is_none() {
377393
return false; // out of space, give up
378394
}
379-
if self.escapes_ep() {
380-
return false; // EP has been escaped in this ISEQ. disable the optimization to avoid an invalidation loop.
395+
if !self.can_assume_on_stack_env() {
396+
return false; // Unsound or unprofitable to make the assumption
381397
}
382398
self.no_ep_escape = true;
383399
true
@@ -2448,7 +2464,7 @@ fn gen_getlocal_generic(
24482464
level: u32,
24492465
) -> Option<CodegenStatus> {
24502466
// Split the block if we need to invalidate this instruction when EP escapes
2451-
if level == 0 && !jit.escapes_ep() && !jit.at_compile_target() {
2467+
if level == 0 && jit.can_assume_on_stack_env() && !jit.at_compile_target() {
24522468
return jit.defer_compilation(asm);
24532469
}
24542470

@@ -2549,7 +2565,7 @@ fn gen_setlocal_generic(
25492565
}
25502566

25512567
// Split the block if we need to invalidate this instruction when EP escapes
2552-
if level == 0 && !jit.escapes_ep() && !jit.at_compile_target() {
2568+
if level == 0 && jit.can_assume_on_stack_env() && !jit.at_compile_target() {
25532569
return jit.defer_compilation(asm);
25542570
}
25552571

yjit/src/cruby.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,13 @@ impl From<VALUE> for u16 {
598598
}
599599
}
600600

601+
/// Check whether a control frame has an escaped environment
602+
pub unsafe fn cfp_env_has_escaped(cfp: *mut rb_control_frame_struct) -> bool {
603+
use crate::utils::IntoUsize;
604+
let ep = get_cfp_ep(cfp);
605+
0 != ep.offset(VM_ENV_DATA_INDEX_FLAGS as isize).read().0 & VM_ENV_FLAG_ESCAPED.as_usize()
606+
}
607+
601608
/// Produce a Ruby string from a Rust string slice
602609
pub fn rust_str_to_ruby(str: &str) -> VALUE {
603610
unsafe { rb_utf8_str_new(str.as_ptr() as *const _, str.len() as i64) }

yjit/src/invariants.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ pub fn track_no_ep_escape_assumption(uninit_block: BlockRef, iseq: IseqPtr) {
168168
.insert(uninit_block);
169169
}
170170

171-
/// Returns true if a given ISEQ has previously escaped an environment.
172-
pub fn iseq_escapes_ep(iseq: IseqPtr) -> bool {
171+
/// Returns true if a given ISEQ has escaped an environment since YJIT boot.
172+
pub fn seen_escaped_env(iseq: IseqPtr) -> bool {
173173
Invariants::get_instance()
174174
.no_ep_escape_iseqs
175175
.get(&iseq)

yjit/src/stats.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ pub const DEFAULT_COUNTERS: &'static [Counter] = &[
248248
Counter::compiled_blockid_count,
249249
Counter::compiled_block_count,
250250
Counter::deleted_defer_block_count,
251+
Counter::exceptional_entry_escaped_env,
251252
Counter::compiled_branch_count,
252253
Counter::compile_time_ns,
253254
Counter::compilation_failure,
@@ -599,6 +600,8 @@ make_counters! {
599600
iseq_stack_too_large,
600601
iseq_too_long,
601602

603+
exceptional_entry_escaped_env,
604+
602605
temp_reg_opnd,
603606
temp_mem_opnd,
604607
temp_spill,

yjit/src/yjit.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ pub extern "C" fn rb_yjit_iseq_gen_entry_point(iseq: IseqPtr, ec: EcPtr, jit_exc
157157
return std::ptr::null();
158158
}
159159

160+
// In case of exceptional entry, reject escaped environment.
161+
// This allows us to use the fact that new frames generally start with an on-stack environment.
162+
if jit_exception && unsafe { cfp_env_has_escaped(get_ec_cfp(ec)) } {
163+
incr_counter!(exceptional_entry_escaped_env);
164+
return std::ptr::null();
165+
}
166+
160167
// If a custom call threshold was not specified on the command-line and
161168
// this is a large application (has very many ISEQs), switch to
162169
// using the call threshold for large applications after this entry point

0 commit comments

Comments
 (0)