Skip to content

Commit 1ef9464

Browse files
committed
ZJIT: ::RubyVM::ZJIT.induce_side_exit! and induce_compile_failure!
Tells ZJIT to do a side exit or to fail to compile, useful testing and for bug reports. We are picky about the syntactic form so we can tell where the call lands early in the compiler pipeline. The `::` prefix allows us to interpret it without needing to know under what lexical scope the iseq will run. Special semantics with ZJIT doesn't interfere with ruby level semantics; running these methods do nothing in all modes. So it's no problem to call these without ZJIT.
1 parent 0837263 commit 1ef9464

9 files changed

Lines changed: 263 additions & 5 deletions

File tree

zjit.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "vm_insnhelper.h"
2020
#include "probes.h"
2121
#include "probes_helper.h"
22+
#include "constant.h"
2223
#include "iseq.h"
2324
#include "ruby/debug.h"
2425
#include "internal/cont.h"

zjit.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ def trace_exit_locations_enabled?
4444
Primitive.rb_zjit_trace_exit_locations_enabled_p
4545
end
4646

47+
# A directive for the compiler to fail to compile the call to this method.
48+
# To show this to ZJIT, say `::RubyVM::ZJIT.induce_compile_failure!` verbatim.
49+
# Other forms are too dynamic to detect during compilation.
50+
#
51+
# Actually running this method does nothing, whether ZJIT sees the call or not.
52+
def induce_compile_failure! = nil
53+
54+
# A directive for the compiler to exit out of compiled code at the call site of this method.
55+
# To show this to ZJIT, say `::RubyVM::ZJIT.induce_side_exit!` verbatim.
56+
# Other forms are too dynamic to detect during compilation.
57+
#
58+
# Actually running this method does nothing, whether ZJIT sees the call or not.
59+
def induce_side_exit! = nil
60+
4761
# If --zjit-trace-exits is enabled parse the hashes from
4862
# Primitive.rb_zjit_get_exit_locations into a format readable
4963
# by Stackprof. This will allow us to find the exact location of a

zjit/bindgen/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ fn main() {
145145
.allowlist_function("rb_gc_location")
146146
.allowlist_function("rb_gc_writebarrier")
147147
.allowlist_function("rb_gc_writebarrier_remember")
148+
.allowlist_function("rb_gc_register_mark_object")
148149
.allowlist_function("rb_zjit_writebarrier_check_immediate")
149150

150151
// VALUE variables for Ruby class objects
@@ -432,6 +433,7 @@ fn main() {
432433
.allowlist_function("rb_vm_base_ptr")
433434
.allowlist_function("rb_ec_stack_check")
434435
.allowlist_function("rb_vm_top_self")
436+
.allowlist_function("rb_const_lookup")
435437

436438
// We define these manually, don't import them
437439
.blocklist_type("VALUE")

zjit/src/cruby.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1544,14 +1544,18 @@ pub(crate) mod ids {
15441544
name: RUBY_FL_FREEZE
15451545
name: RUBY_ELTS_SHARED
15461546
name: VM_FRAME_FLAG_MODIFIED_BLOCK_PARAM
1547+
name: RubyVM
1548+
name: ZJIT
1549+
name: induce_side_exit_bang content: b"induce_side_exit!"
1550+
name: induce_compile_failure_bang content: b"induce_compile_failure!"
15471551
}
15481552

15491553
/// Get an CRuby `ID` to an interned string, e.g. a particular method name.
15501554
macro_rules! ID {
15511555
($id_name:ident) => {{
15521556
let id = $crate::cruby::ids::$id_name.load(std::sync::atomic::Ordering::Relaxed);
15531557
debug_assert_ne!(0, id, "ids module should be initialized");
1554-
ID(id)
1558+
$crate::cruby::ID(id)
15551559
}}
15561560
}
15571561
pub(crate) use ID;

zjit/src/cruby_bindings.inc.rs

Lines changed: 16 additions & 0 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: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
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, has_singleton_class_of}, payload::{get_or_create_iseq_payload, IseqPayload}, options::{debug, get_option, DumpHIR}, state::ZJITState, json::Json,
11+
state,
1112
};
1213
use std::{
13-
cell::RefCell, collections::{BTreeSet, HashMap, HashSet, VecDeque}, ffi::{c_void, c_uint, c_int, CStr}, fmt::Display, mem::{align_of, size_of}, ptr, slice::Iter
14+
cell::RefCell, collections::{BTreeSet, HashMap, HashSet, VecDeque}, ffi::{c_void, c_uint, c_int, CStr}, fmt::Display, mem::{align_of, size_of}, ptr, slice::Iter,
15+
sync::atomic::Ordering,
1416
};
1517
use crate::hir_type::{Type, types};
1618
use crate::hir_effect::{Effect, abstract_heaps, effects};
@@ -529,6 +531,7 @@ pub enum SideExitReason {
529531
SplatKwNotNilOrHash,
530532
SplatKwPolymorphic,
531533
SplatKwNotProfiled,
534+
DirectiveInduced,
532535
}
533536

534537
#[derive(Debug, Clone, Copy)]
@@ -6547,6 +6550,7 @@ pub enum ParseError {
65476550
MalformedIseq(u32), // insn_idx into iseq_encoded
65486551
Validation(ValidationError),
65496552
NotAllowed,
6553+
DirectiveInduced,
65506554
}
65516555

65526556
/// Return the number of locals in the current ISEQ (includes parameters)
@@ -7077,7 +7081,32 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
70777081
}
70787082
YARVINSN_opt_getconstant_path => {
70797083
let ic = get_arg(pc, 0).as_ptr();
7080-
state.stack_push(fun.push_insn(block, Insn::GetConstantPath { ic, state: exit_id }));
7084+
let get_const_path = fun.push_insn(block, Insn::GetConstantPath { ic, state: exit_id });
7085+
state.stack_push(get_const_path);
7086+
7087+
// Check for `::RubyVM::ZJIT` for directives
7088+
unsafe {
7089+
let mut current_segment = (*ic).segments;
7090+
let mut segments = [ID(0); 4];
7091+
for segment in segments.iter_mut() {
7092+
*segment = current_segment.read();
7093+
if *segment == ID(0) {
7094+
break;
7095+
}
7096+
current_segment = current_segment.add(1);
7097+
}
7098+
if [ID!(NULL), ID!(RubyVM), ID!(ZJIT), ID(0)] == segments {
7099+
debug_assert_ne!(ID!(NULL), ID(0));
7100+
let ruby_vm_mod = rb_const_lookup(rb_cObject, ID!(RubyVM));
7101+
if !ruby_vm_mod.is_null() && (*ruby_vm_mod).value == rb_cRubyVM {
7102+
let zjit_module = VALUE(state::ZJIT_MODULE.load(Ordering::Relaxed));
7103+
let lookedup_module = rb_const_lookup(rb_cRubyVM, ID!(ZJIT));
7104+
if !lookedup_module.is_null() && (*lookedup_module).value == zjit_module {
7105+
fun.insn_types[get_const_path.0] = Type::from_value(zjit_module);
7106+
}
7107+
}
7108+
}
7109+
}
70817110
}
70827111
YARVINSN_branchunless | YARVINSN_branchunless_without_ints => {
70837112
if opcode == YARVINSN_branchunless {
@@ -7538,6 +7567,28 @@ pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
75387567
break; // End the block
75397568
}
75407569
let argc = unsafe { vm_ci_argc((*cd).ci) };
7570+
let mid = unsafe { rb_vm_ci_mid(call_info) };
7571+
7572+
// Check for calls to directives
7573+
if argc == 0
7574+
&& (mid == ID!(induce_side_exit_bang) || mid == ID!(induce_compile_failure_bang))
7575+
&& fun.type_of(state.stack_top()?)
7576+
.ruby_object()
7577+
.is_some_and(|obj| obj == VALUE(state::ZJIT_MODULE.load(Ordering::Relaxed)))
7578+
{
7579+
7580+
if mid == ID!(induce_side_exit_bang)
7581+
&& state::zjit_module_method_match_serial(ID!(induce_side_exit_bang), &state::INDUCE_SIDE_EXIT_SERIAL)
7582+
{
7583+
fun.push_insn(block, Insn::SideExit { state: exit_id, reason: SideExitReason::DirectiveInduced });
7584+
break; // End the block
7585+
}
7586+
if mid == ID!(induce_compile_failure_bang)
7587+
&& state::zjit_module_method_match_serial(ID!(induce_compile_failure_bang), &state::INDUCE_COMPILE_FAILURE_SERIAL)
7588+
{
7589+
return Err(ParseError::DirectiveInduced);
7590+
}
7591+
}
75417592

75427593
{
75437594
fn new_branch_block(

zjit/src/hir/tests.rs

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4793,7 +4793,132 @@ pub mod hir_build_tests {
47934793
Jump bb8(v67, v78)
47944794
");
47954795
}
4796-
}
4796+
4797+
#[test]
4798+
fn test_induce_side_exit() {
4799+
eval("
4800+
class NonTopLexicalScope
4801+
RubyVM = 0
4802+
def test
4803+
RubyVM::ZJIT.induce_side_exit! # lexical scope dependant -- should not recognize
4804+
::RubyVM::ZJIT.induce_side_exit!
4805+
end
4806+
end
4807+
");
4808+
assert_snapshot!(hir_string_proc("NonTopLexicalScope.instance_method(:test)"), @"
4809+
fn test@<compiled>:5:
4810+
bb1():
4811+
EntryPoint interpreter
4812+
v1:BasicObject = LoadSelf
4813+
Jump bb3(v1)
4814+
bb2():
4815+
EntryPoint JIT(0)
4816+
v4:BasicObject = LoadArg :self@0
4817+
Jump bb3(v4)
4818+
bb3(v6:BasicObject):
4819+
v10:BasicObject = GetConstantPath 0x1000
4820+
v12:BasicObject = Send v10, :induce_side_exit! # SendFallbackReason: Uncategorized(opt_send_without_block)
4821+
v16:BasicObject = GetConstantPath 0x1000
4822+
SideExit DirectiveInduced
4823+
");
4824+
}
4825+
4826+
#[test]
4827+
fn test_induce_side_exit_sensitive_to_constant_state() {
4828+
eval("
4829+
def test = ::RubyVM::ZJIT.induce_side_exit!
4830+
");
4831+
assert!(hir_string("test").contains("SideExit DirectiveInduced"));
4832+
eval("
4833+
class RubyVM
4834+
remove_const(:ZJIT)
4835+
end
4836+
");
4837+
let hir_after_removal = hir_string("test");
4838+
assert_eq!(false, hir_string("test").contains("SideExit DirectiveInduced"), "should not work when the constant lookup would fail");
4839+
assert_snapshot!(hir_after_removal, @"
4840+
fn test@<compiled>:2:
4841+
bb1():
4842+
EntryPoint interpreter
4843+
v1:BasicObject = LoadSelf
4844+
Jump bb3(v1)
4845+
bb2():
4846+
EntryPoint JIT(0)
4847+
v4:BasicObject = LoadArg :self@0
4848+
Jump bb3(v4)
4849+
bb3(v6:BasicObject):
4850+
v10:BasicObject = GetConstantPath 0x1000
4851+
v12:BasicObject = Send v10, :induce_side_exit! # SendFallbackReason: Uncategorized(opt_send_without_block)
4852+
CheckInterrupts
4853+
Return v12
4854+
");
4855+
}
4856+
4857+
#[test]
4858+
fn test_induce_side_exit_doesnt_work_when_method_after_undef() {
4859+
eval("
4860+
class << RubyVM::ZJIT
4861+
undef :induce_side_exit!
4862+
end
4863+
def test = ::RubyVM::ZJIT.induce_side_exit!
4864+
");
4865+
assert_eq!(false, hir_string("test").contains("SideExit DirectiveInduced"), "should not work after undef");
4866+
}
4867+
4868+
#[test]
4869+
fn test_induce_compile_failure_does_not_trigger_autoload() {
4870+
eval("
4871+
class RubyVM
4872+
remove_const(:ZJIT)
4873+
autoload :ZJIT, 'a-file-that-does-not-exist-as-a-trap'
4874+
end
4875+
def test = ::RubyVM::ZJIT.induce_compile_failure!
4876+
");
4877+
assert_snapshot!(hir_string("test"), @"
4878+
fn test@<compiled>:6:
4879+
bb1():
4880+
EntryPoint interpreter
4881+
v1:BasicObject = LoadSelf
4882+
Jump bb3(v1)
4883+
bb2():
4884+
EntryPoint JIT(0)
4885+
v4:BasicObject = LoadArg :self@0
4886+
Jump bb3(v4)
4887+
bb3(v6:BasicObject):
4888+
v10:BasicObject = GetConstantPath 0x1000
4889+
v12:BasicObject = Send v10, :induce_compile_failure! # SendFallbackReason: Uncategorized(opt_send_without_block)
4890+
CheckInterrupts
4891+
Return v12
4892+
");
4893+
}
4894+
4895+
#[test]
4896+
fn test_induce_compile_failure_checks_full_const_path() {
4897+
eval("def test = ::RubyVM::ZJIT::TooDeep.induce_compile_failure!");
4898+
assert_snapshot!(hir_string("test"), @"
4899+
fn test@<compiled>:1:
4900+
bb1():
4901+
EntryPoint interpreter
4902+
v1:BasicObject = LoadSelf
4903+
Jump bb3(v1)
4904+
bb2():
4905+
EntryPoint JIT(0)
4906+
v4:BasicObject = LoadArg :self@0
4907+
Jump bb3(v4)
4908+
bb3(v6:BasicObject):
4909+
v10:BasicObject = GetConstantPath 0x1000
4910+
v12:BasicObject = Send v10, :induce_compile_failure! # SendFallbackReason: Uncategorized(opt_send_without_block)
4911+
CheckInterrupts
4912+
Return v12
4913+
");
4914+
}
4915+
4916+
#[test]
4917+
fn test_induce_compile_failure() {
4918+
eval("def test = ::RubyVM::ZJIT.induce_compile_failure!");
4919+
assert_compile_fails("test", ParseError::DirectiveInduced);
4920+
}
4921+
}
47974922

47984923
/// Test successor and predecessor set computations.
47994924
#[cfg(test)]

zjit/src/state.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
use crate::codegen::{gen_entry_trampoline, gen_exit_trampoline, gen_exit_trampoline_with_counter, gen_function_stub_hit_trampoline};
44
use crate::cruby::{self, rb_bug_panic_hook, rb_vm_insn_count, src_loc, EcPtr, Qnil, Qtrue, rb_vm_insn_addr2opcode, rb_profile_frames, VALUE, VM_INSTRUCTION_SIZE, size_t, rb_gc_mark, with_vm_lock, rust_str_to_id, rb_funcallv, rb_const_get, rb_cRubyVM};
55
use crate::cruby_methods;
6+
use cruby::{ID, rb_callable_method_entry, get_def_method_serial, rb_gc_register_mark_object};
7+
use std::sync::atomic::Ordering;
68
use crate::invariants::Invariants;
79
use crate::asm::CodeBlock;
810
use crate::options::{get_option, rb_zjit_prepare_options};
911
use crate::stats::{Counters, InsnCounters, SideExitLocations};
1012
use crate::virtualmem::CodePtr;
13+
use std::sync::atomic::AtomicUsize;
1114
use std::collections::HashMap;
1215
use std::ptr::null;
1316

@@ -301,6 +304,25 @@ impl ZJITState {
301304
}
302305
}
303306

307+
/// The `::RubyVM::ZJIT` module.
308+
pub static ZJIT_MODULE: AtomicUsize = AtomicUsize::new(!0);
309+
/// Serial of the canonical version of `` right after VM boot.
310+
pub static INDUCE_SIDE_EXIT_SERIAL: AtomicUsize = AtomicUsize::new(!0);
311+
/// Serial of the canonical version of `` right after VM boot.
312+
pub static INDUCE_COMPILE_FAILURE_SERIAL: AtomicUsize = AtomicUsize::new(!0);
313+
314+
/// Check if a method, `method_id`, curerntly exists on `ZJIT.singleton_class` and has the `expected_serial`.
315+
pub fn zjit_module_method_match_serial(method_id: ID, expected_serial: &AtomicUsize) -> bool {
316+
let zjit_module_singleton = VALUE(ZJIT_MODULE.load(Ordering::Relaxed)).class_of();
317+
let cme = unsafe { rb_callable_method_entry(zjit_module_singleton, method_id) };
318+
if cme.is_null() {
319+
false
320+
} else {
321+
let serial = unsafe { get_def_method_serial((*cme).def) };
322+
serial == expected_serial.load(std::sync::atomic::Ordering::Relaxed)
323+
}
324+
}
325+
304326
/// Initialize IDs and annotate builtin C method entries.
305327
/// Must be called at boot before ruby_init_prelude() since the prelude
306328
/// could redefine core methods (e.g. Kernel.prepend via bundler).
@@ -318,6 +340,25 @@ pub extern "C" fn rb_zjit_init_builtin_cmes() {
318340
let method_annotations = cruby_methods::init();
319341

320342
unsafe { ZJIT_STATE = Initialized(method_annotations); }
343+
344+
// Boot time setup for compiler directives
345+
unsafe {
346+
let zjit_module = rb_const_get(rb_cRubyVM, rust_str_to_id("ZJIT"));
347+
348+
let cme = rb_callable_method_entry(zjit_module.class_of(), ID!(induce_side_exit_bang));
349+
assert!(! cme.is_null(), "RubyVM::ZJIT.induce_side_exit! should exist on boot");
350+
let serial = get_def_method_serial((*cme).def) ;
351+
INDUCE_SIDE_EXIT_SERIAL.store(serial, Ordering::Relaxed);
352+
353+
let cme = rb_callable_method_entry(zjit_module.class_of(), ID!(induce_compile_failure_bang));
354+
assert!(! cme.is_null(), "RubyVM::ZJIT.induce_compile_failure! should exist on boot");
355+
let serial = get_def_method_serial((*cme).def) ;
356+
INDUCE_COMPILE_FAILURE_SERIAL.store(serial, Ordering::Relaxed);
357+
358+
// Root and pin the module since we'll be doing object identity comparisons.
359+
ZJIT_MODULE.store(zjit_module.0, Ordering::Relaxed);
360+
rb_gc_register_mark_object(zjit_module);
361+
}
321362
}
322363

323364
/// Initialize ZJIT at boot. This is called even if ZJIT is disabled.

0 commit comments

Comments
 (0)