Skip to content

Commit d828390

Browse files
committed
ZJIT: Limit local reloads after send to ones syntactically written to
1 parent 218cec6 commit d828390

8 files changed

Lines changed: 436 additions & 141 deletions

File tree

zjit.c

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
STATIC_ASSERT(pointer_tagging_scheme, USE_FLONUM);
3030

3131
enum zjit_struct_offsets {
32-
ISEQ_BODY_OFFSET_PARAM = offsetof(struct rb_iseq_constant_body, param)
32+
ISEQ_BODY_OFFSET_PARAM = offsetof(struct rb_iseq_constant_body, param),
33+
ISEQ_BODY_OFFSET_OUTER_VARIABLES = offsetof(struct rb_iseq_constant_body, outer_variables)
3334
};
3435

3536
// Special JITFrame used by all C method calls. We don't control the native

zjit/bindgen/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ fn main() {
297297
.allowlist_function("rb_zjit_iseq_inspect")
298298
.allowlist_function("rb_zjit_iseq_insn_set")
299299
.allowlist_function("rb_zjit_local_id")
300+
.allowlist_function("rb_id_table_lookup")
300301
.allowlist_function("rb_set_cfp_(pc|sp)")
301302
.allowlist_function("rb_c_method_tracing_currently_enabled")
302303
.allowlist_function("rb_zjit_method_tracing_currently_enabled")
@@ -450,6 +451,9 @@ fn main() {
450451
.blocklist_type("ID")
451452
.blocklist_type("rb_iseq_constant_body")
452453

454+
// We only need id_table as an opaque pointer to pass to its APIs
455+
.opaque_type("rb_id_table")
456+
453457
// Avoid binding to stuff we don't use
454458
.blocklist_item("rb_thread_struct.*")
455459
.opaque_type("rb_thread_struct.*")

zjit/src/codegen_tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,26 @@ fn test_send_with_local_written_by_blockiseq() {
735735
"), @"[1, 2]");
736736
}
737737

738+
#[test]
739+
fn test_send_does_not_reload_local_untouched_by_blockiseq() {
740+
// https://github.com/Shopify/ruby/issues/976: a call with a block must not
741+
// reload locals the block never assigns, otherwise it reads a stale stack
742+
// slot and clobbers the correct SSA value (here, `a`).
743+
eval("
744+
def foo(&block) = 1
745+
746+
def test
747+
a = 1
748+
foo {}
749+
a
750+
end
751+
752+
test
753+
");
754+
assert_contains_opcode("test", YARVINSN_send);
755+
assert_snapshot!(assert_compiles("test"), @"1");
756+
}
757+
738758
#[test]
739759
fn test_no_ep_escape_patch_point_after_send_does_not_repeat_send() {
740760
eval(r#"

zjit/src/cruby.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ use std::ffi::{c_void, CString, CStr};
9393
use std::fmt::{Debug, Display, Formatter};
9494
use std::os::raw::{c_char, c_int, c_long, c_uint};
9595
use std::panic::{catch_unwind, UnwindSafe};
96+
use std::ptr::NonNull;
9697

9798
use crate::cast::IntoUsize as _;
9899

@@ -749,9 +750,42 @@ impl VALUE {
749750

750751
pub type IseqParameters = rb_iseq_constant_body_rb_iseq_parameters;
751752

753+
/// How a block iseq refers to a variable in an enclosing scope, as recorded in
754+
/// `ISEQ_BODY(blockiseq)->outer_variables`. `compile.c` aggregates accesses from
755+
/// nested blocks up the chain, and the same table backs `Ractor.shareable_proc`'s
756+
/// isolation checks.
757+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758+
pub enum OuterLocalAccess {
759+
/// The variable is read but never assigned to.
760+
ReadOnly,
761+
/// The variable is assigned to and maybe also read.
762+
ReadWrite,
763+
}
764+
765+
/// Wrapper over an iseq's `outer_variables` table, which describes
766+
/// how a block iseq refers to a variable in an enclosing scope.
767+
#[derive(Clone, Copy)]
768+
pub struct OuterVariables(Option<NonNull<rb_id_table>>);
769+
770+
impl OuterVariables {
771+
/// Look up how the enclosing-scope local `id` is accessed by the iseq (or any
772+
/// iseq nested within it). Returns `None` when the variable isn't referenced.
773+
pub fn local_access(self, id: ID) -> Option<OuterLocalAccess> {
774+
let table = self.0?;
775+
let mut write = Qfalse;
776+
// Non-zero return means there's a table entry, i.e. the variable is referenced.
777+
if unsafe { rb_id_table_lookup(table.as_ptr(), id, &mut write) } == 0 {
778+
return None;
779+
}
780+
// Truthy means write
781+
Some(if write.test() { OuterLocalAccess::ReadWrite } else { OuterLocalAccess::ReadOnly })
782+
}
783+
}
784+
752785
/// Extension trait to enable method calls on [`IseqPtr`]
753786
pub trait IseqAccess {
754787
unsafe fn params<'a>(self) -> &'a IseqParameters;
788+
unsafe fn outer_variables(self) -> OuterVariables;
755789
}
756790

757791
impl IseqAccess for IseqPtr {
@@ -760,6 +794,13 @@ impl IseqAccess for IseqPtr {
760794
use crate::cast::IntoUsize;
761795
unsafe { &*((*self).body.byte_add(ISEQ_BODY_OFFSET_PARAM.to_usize()) as *const IseqParameters) }
762796
}
797+
798+
/// The iseq's `outer_variables` table. See [`OuterVariables`].
799+
unsafe fn outer_variables(self) -> OuterVariables {
800+
use crate::cast::IntoUsize;
801+
let field = unsafe { (*self).body.byte_add(ISEQ_BODY_OFFSET_OUTER_VARIABLES.to_usize()) } as *const *mut rb_id_table;
802+
OuterVariables(NonNull::new(unsafe { *field }))
803+
}
763804
}
764805

765806
impl IseqParameters {

zjit/src/cruby_bindings.inc.rs

Lines changed: 8 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zjit/src/hir.rs

Lines changed: 61 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4962,6 +4962,62 @@ impl Function {
49624962
self.push_insn(block, Insn::PatchPoint { invariant: Invariant::NoEPEscape(iseq), state: reload_exit_id });
49634963
}
49644964

4965+
/// After a call that takes a block iseq, reload the locals that the block (or any iseq nested
4966+
/// within it) may have written. This covers syntactically visible local writes where the
4967+
/// environment does not escape. Exordinary modifications through `Binding` and debug.h APIs are
4968+
/// handled via patchpoints.
4969+
fn reload_locals_modified_by_block(
4970+
&mut self,
4971+
block: BlockId,
4972+
iseq: IseqPtr,
4973+
blockiseq: IseqPtr,
4974+
state: &mut FrameState,
4975+
ep_escaped: bool,
4976+
) {
4977+
let to_reload: &mut dyn Iterator<Item = usize> = if ep_escaped {
4978+
// Reload everything when working with an escaped environment
4979+
&mut (0..state.locals.len())
4980+
} else {
4981+
// When not escaped, only reload syntactically visible local modifications
4982+
let params = unsafe { iseq.params() };
4983+
let block_param_local_idx: Option<usize> = if params.flags.has_block() != 0 {
4984+
params.block_start.try_into().ok()
4985+
} else {
4986+
None
4987+
};
4988+
let outer_variables = unsafe { blockiseq.outer_variables() };
4989+
&mut (0..state.locals.len()).filter(move |&local_idx| {
4990+
let id = unsafe { rb_zjit_local_id(iseq, local_idx.try_into().unwrap()) };
4991+
let access = outer_variables.local_access(id);
4992+
if block_param_local_idx == Some(local_idx) {
4993+
// The block param slot is special: `getblockparam` come from a syntactic read,
4994+
// but operationally can write to the local slot. So, reload it whenever the
4995+
// block references it at all (read or write), not just on a setlocal.
4996+
access.is_some()
4997+
} else {
4998+
access == Some(OuterLocalAccess::ReadWrite)
4999+
}
5000+
})
5001+
};
5002+
5003+
let mut base: Option<InsnId> = None;
5004+
for local_idx in to_reload {
5005+
let ep_offset = local_idx_to_ep_offset(iseq, local_idx);
5006+
let ep_offset_u32 = u32::try_from(ep_offset)
5007+
.unwrap_or_else(|_| panic!("Could not convert ep_offset {ep_offset} to u32"));
5008+
let recv = *base.get_or_insert_with(|| {
5009+
let base_insn = if !ep_escaped { Insn::LoadSP } else { Insn::GetEP { level: 0 } };
5010+
self.push_insn(block, base_insn)
5011+
});
5012+
let val = if !ep_escaped {
5013+
self.get_local_from_sp(block, iseq, recv, ep_offset_u32, types::BasicObject)
5014+
} else {
5015+
self.get_local_from_ep(block, iseq, recv, ep_offset_u32, 0, types::BasicObject)
5016+
};
5017+
state.setlocal(ep_offset_u32, val);
5018+
}
5019+
}
5020+
49655021
fn count_not_inlined_cfunc(&mut self, block: BlockId, cme: *const rb_callable_method_entry_t) {
49665022
let owner = unsafe { (*cme).owner };
49675023
let called_id = unsafe { (*cme).called_id };
@@ -8653,29 +8709,12 @@ fn add_iseq_to_hir(
86538709
let send = fun.push_insn(block, Insn::Send { recv, cd, block: block_handler, args, state: exit_id, reason: Uncategorized(opcode) });
86548710
state.stack_push(send);
86558711

8656-
if let Some(BlockHandler::BlockIseq(_)) = block_handler {
8712+
if let Some(BlockHandler::BlockIseq(blockiseq)) = block_handler {
86578713
// Reload locals that may have been modified by the blockiseq.
8658-
// TODO: Avoid reloading locals that are not referenced by the blockiseq
8659-
// or not used after this. Max thinks we could eventually DCE them.
86608714
if !ep_escaped && !state.locals.is_empty() {
86618715
fun.gen_post_send_no_ep_escape_patch_point(block, &state, insn_idx);
86628716
}
8663-
let mut base: Option<InsnId> = None;
8664-
for local_idx in 0..state.locals.len() {
8665-
let ep_offset = local_idx_to_ep_offset(iseq, local_idx);
8666-
let ep_offset_u32 = u32::try_from(ep_offset)
8667-
.unwrap_or_else(|_| panic!("Could not convert ep_offset {ep_offset} to u32"));
8668-
let recv = *base.get_or_insert_with(|| {
8669-
let base_insn = if !ep_escaped { Insn::LoadSP } else { Insn::GetEP { level: 0 } };
8670-
fun.push_insn(block, base_insn)
8671-
});
8672-
let val = if !ep_escaped {
8673-
fun.get_local_from_sp(block, iseq, recv, ep_offset_u32, types::BasicObject)
8674-
} else {
8675-
fun.get_local_from_ep(block, iseq, recv, ep_offset_u32, 0, types::BasicObject)
8676-
};
8677-
state.setlocal(ep_offset_u32, val);
8678-
}
8717+
fun.reload_locals_modified_by_block(block, iseq, blockiseq, &mut state, ep_escaped);
86798718
}
86808719
}
86818720
YARVINSN_sendforward => {
@@ -8706,22 +8745,7 @@ fn add_iseq_to_hir(
87068745
if !ep_escaped && !state.locals.is_empty() {
87078746
fun.gen_post_send_no_ep_escape_patch_point(block, &state, insn_idx);
87088747
}
8709-
let mut base: Option<InsnId> = None;
8710-
for local_idx in 0..state.locals.len() {
8711-
let ep_offset = local_idx_to_ep_offset(iseq, local_idx);
8712-
let ep_offset_u32 = u32::try_from(ep_offset)
8713-
.unwrap_or_else(|_| panic!("Could not convert ep_offset {ep_offset} to u32"));
8714-
let recv = *base.get_or_insert_with(|| {
8715-
let base_insn = if !ep_escaped { Insn::LoadSP } else { Insn::GetEP { level: 0 } };
8716-
fun.push_insn(block, base_insn)
8717-
});
8718-
let val = if !ep_escaped {
8719-
fun.get_local_from_sp(block, iseq, recv, ep_offset_u32, types::BasicObject)
8720-
} else {
8721-
fun.get_local_from_ep(block, iseq, recv, ep_offset_u32, 0, types::BasicObject)
8722-
};
8723-
state.setlocal(ep_offset_u32, val);
8724-
}
8748+
fun.reload_locals_modified_by_block(block, iseq, blockiseq, &mut state, ep_escaped);
87258749
}
87268750
}
87278751
YARVINSN_invokesuper => {
@@ -8746,27 +8770,10 @@ fn add_iseq_to_hir(
87468770

87478771
if !blockiseq.is_null() {
87488772
// Reload locals that may have been modified by the blockiseq.
8749-
// TODO: Avoid reloading locals that are not referenced by the blockiseq
8750-
// or not used after this. Max thinks we could eventually DCE them.
87518773
if !ep_escaped && !state.locals.is_empty() {
87528774
fun.gen_post_send_no_ep_escape_patch_point(block, &state, insn_idx);
87538775
}
8754-
let mut base: Option<InsnId> = None;
8755-
for local_idx in 0..state.locals.len() {
8756-
let ep_offset = local_idx_to_ep_offset(iseq, local_idx);
8757-
let ep_offset_u32 = u32::try_from(ep_offset)
8758-
.unwrap_or_else(|_| panic!("Could not convert ep_offset {ep_offset} to u32"));
8759-
let recv = *base.get_or_insert_with(|| {
8760-
let base_insn = if !ep_escaped { Insn::LoadSP } else { Insn::GetEP { level: 0 } };
8761-
fun.push_insn(block, base_insn)
8762-
});
8763-
let val = if !ep_escaped {
8764-
fun.get_local_from_sp(block, iseq, recv, ep_offset_u32, types::BasicObject)
8765-
} else {
8766-
fun.get_local_from_ep(block, iseq, recv, ep_offset_u32, 0, types::BasicObject)
8767-
};
8768-
state.setlocal(ep_offset_u32, val);
8769-
}
8776+
fun.reload_locals_modified_by_block(block, iseq, blockiseq, &mut state, ep_escaped);
87708777
}
87718778
}
87728779
YARVINSN_invokesuperforward => {
@@ -8793,27 +8800,10 @@ fn add_iseq_to_hir(
87938800

87948801
if !blockiseq.is_null() {
87958802
// Reload locals that may have been modified by the blockiseq.
8796-
// TODO: Avoid reloading locals that are not referenced by the blockiseq
8797-
// or not used after this. Max thinks we could eventually DCE them.
87988803
if !ep_escaped && !state.locals.is_empty() {
87998804
fun.gen_post_send_no_ep_escape_patch_point(block, &state, insn_idx);
88008805
}
8801-
let mut base: Option<InsnId> = None;
8802-
for local_idx in 0..state.locals.len() {
8803-
let ep_offset = local_idx_to_ep_offset(iseq, local_idx);
8804-
let ep_offset_u32 = u32::try_from(ep_offset)
8805-
.unwrap_or_else(|_| panic!("Could not convert ep_offset {ep_offset} to u32"));
8806-
let recv = *base.get_or_insert_with(|| {
8807-
let base_insn = if !ep_escaped { Insn::LoadSP } else { Insn::GetEP { level: 0 } };
8808-
fun.push_insn(block, base_insn)
8809-
});
8810-
let val = if !ep_escaped {
8811-
fun.get_local_from_sp(block, iseq, recv, ep_offset_u32, types::BasicObject)
8812-
} else {
8813-
fun.get_local_from_ep(block, iseq, recv, ep_offset_u32, 0, types::BasicObject)
8814-
};
8815-
state.setlocal(ep_offset_u32, val);
8816-
}
8806+
fun.reload_locals_modified_by_block(block, iseq, blockiseq, &mut state, ep_escaped);
88178807
}
88188808
}
88198809
YARVINSN_invokeblock => {

0 commit comments

Comments
 (0)