Skip to content

Commit 039f413

Browse files
authored
ZJIT: Create delta debugging script to narrow JIT failures (ruby#14041)
Add support for `--zjit-allowed-iseqs=SomeFile` and `--zjit-log-compiled-iseqs=SomeFile` so we can restrict and inspect which ISEQs get compiled. Then add `jit_bisect.rb` which we can run to try and narrow a failing script. For example: plum% ../tool/zjit_bisect.rb ../build-dev/miniruby "test.rb" I, [2025-07-29T12:41:18.657177 #96899] INFO -- : Starting with JIT list of 4 items. I, [2025-07-29T12:41:18.657229 #96899] INFO -- : Verifying items I, [2025-07-29T12:41:18.726213 #96899] INFO -- : step fixed[0] and items[4] I, [2025-07-29T12:41:18.726246 #96899] INFO -- : 4 candidates I, [2025-07-29T12:41:18.797212 #96899] INFO -- : 2 candidates Reduced JIT list: [email protected]:8 plum% We start with 4 compiled functions and shrink to just one.
1 parent b07e214 commit 039f413

File tree

5 files changed

+182
-2
lines changed

5 files changed

+182
-2
lines changed

tool/zjit_bisect.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env ruby
2+
require 'logger'
3+
require 'open3'
4+
require 'tempfile'
5+
require 'timeout'
6+
7+
RUBY = ARGV[0] || raise("Usage: ruby jit_bisect.rb <path_to_ruby> <options>")
8+
OPTIONS = ARGV[1] || raise("Usage: ruby jit_bisect.rb <path_to_ruby> <options>")
9+
TIMEOUT_SEC = 5
10+
LOGGER = Logger.new($stdout)
11+
12+
# From https://github.com/tekknolagi/omegastar
13+
# MIT License
14+
# Copyright (c) 2024 Maxwell Bernstein and Meta Platforms
15+
# Attempt to reduce the `items` argument as much as possible, returning the
16+
# shorter version. `fixed` will always be used as part of the items when
17+
# running `command`.
18+
# `command` should return True if the command succeeded (the failure did not
19+
# reproduce) and False if the command failed (the failure reproduced).
20+
def bisect_impl(command, fixed, items, indent="")
21+
LOGGER.info("#{indent}step fixed[#{fixed.length}] and items[#{items.length}]")
22+
while items.length > 1
23+
LOGGER.info("#{indent}#{fixed.length + items.length} candidates")
24+
# Return two halves of the given list. For odd-length lists, the second
25+
# half will be larger.
26+
half = items.length / 2
27+
left = items[0...half]
28+
right = items[half..]
29+
if !command.call(fixed + left)
30+
items = left
31+
next
32+
end
33+
if !command.call(fixed + right)
34+
items = right
35+
next
36+
end
37+
# We need something from both halves to trigger the failure. Try
38+
# holding each half fixed and bisecting the other half to reduce the
39+
# candidates.
40+
new_right = bisect_impl(command, fixed + left, right, indent + "< ")
41+
new_left = bisect_impl(command, fixed + new_right, left, indent + "> ")
42+
return new_left + new_right
43+
end
44+
items
45+
end
46+
47+
# From https://github.com/tekknolagi/omegastar
48+
# MIT License
49+
# Copyright (c) 2024 Maxwell Bernstein and Meta Platforms
50+
def run_bisect(command, items)
51+
LOGGER.info("Verifying items")
52+
if command.call(items)
53+
raise StandardError.new("Command succeeded with full items")
54+
end
55+
if !command.call([])
56+
raise StandardError.new("Command failed with empty items")
57+
end
58+
bisect_impl(command, [], items)
59+
end
60+
61+
def run_with_jit_list(ruby, options, jit_list)
62+
# Make a new temporary file containing the JIT list
63+
Tempfile.create("jit_list") do |temp_file|
64+
temp_file.write(jit_list.join("\n"))
65+
temp_file.flush
66+
temp_file.close
67+
# Run the JIT with the temporary file
68+
Open3.capture3("#{ruby} --zjit-allowed-iseqs=#{temp_file.path} #{options}")
69+
end
70+
end
71+
72+
# Try running with no JIT list to get a stable baseline
73+
_, stderr, status = run_with_jit_list(RUBY, OPTIONS, [])
74+
if !status.success?
75+
raise "Command failed with empty JIT list: #{stderr}"
76+
end
77+
# Collect the JIT list from the failing Ruby process
78+
jit_list = nil
79+
Tempfile.create "jit_list" do |temp_file|
80+
Open3.capture3("#{RUBY} --zjit-log-compiled-iseqs=#{temp_file.path} #{OPTIONS}")
81+
jit_list = File.readlines(temp_file.path).map(&:strip).reject(&:empty?)
82+
end
83+
LOGGER.info("Starting with JIT list of #{jit_list.length} items.")
84+
# Now narrow it down
85+
command = lambda do |items|
86+
status = Timeout.timeout(TIMEOUT_SEC) do
87+
_, _, status = run_with_jit_list(RUBY, OPTIONS, items)
88+
status
89+
end
90+
status.success?
91+
end
92+
result = run_bisect(command, jit_list)
93+
File.open("jitlist.txt", "w") do |file|
94+
file.puts(result)
95+
end
96+
puts "Reduced JIT list (available in jitlist.txt):"
97+
puts result

zjit/src/codegen.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ fn gen_function(cb: &mut CodeBlock, iseq: IseqPtr, function: &Function) -> Optio
284284
let iseq_name = iseq_get_location(iseq, 0);
285285
register_with_perf(iseq_name, start_usize, code_size);
286286
}
287+
if ZJITState::should_log_compiled_iseqs() {
288+
let iseq_name = iseq_get_location(iseq, 0);
289+
ZJITState::log_compile(iseq_name);
290+
}
287291
}
288292
result
289293
}

zjit/src/hir.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2480,6 +2480,7 @@ pub enum ParseError {
24802480
UnknownParameterType(ParameterType),
24812481
MalformedIseq(u32), // insn_idx into iseq_encoded
24822482
Validation(ValidationError),
2483+
NotAllowed,
24832484
}
24842485

24852486
/// Return the number of locals in the current ISEQ (includes parameters)
@@ -2545,6 +2546,9 @@ fn filter_unknown_parameter_type(iseq: *const rb_iseq_t) -> Result<(), ParseErro
25452546

25462547
/// Compile ISEQ into High-level IR
25472548
pub fn iseq_to_hir(iseq: *const rb_iseq_t) -> Result<Function, ParseError> {
2549+
if !ZJITState::can_compile_iseq(iseq) {
2550+
return Err(ParseError::NotAllowed);
2551+
}
25482552
filter_unknown_parameter_type(iseq)?;
25492553
let payload = get_or_create_iseq_payload(iseq);
25502554
let mut profiles = ProfileOracle::new(payload);

zjit/src/options.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::{ffi::{CStr, CString}, ptr::null};
22
use std::os::raw::{c_char, c_int, c_uint};
33
use crate::cruby::*;
4+
use std::collections::HashSet;
45

56
/// Number of calls to start profiling YARV instructions.
67
/// They are profiled `rb_zjit_call_threshold - rb_zjit_profile_threshold` times,
@@ -19,7 +20,7 @@ pub static mut rb_zjit_call_threshold: u64 = 2;
1920
#[allow(non_upper_case_globals)]
2021
static mut zjit_stats_enabled_p: bool = false;
2122

22-
#[derive(Clone, Copy, Debug)]
23+
#[derive(Clone, Debug)]
2324
pub struct Options {
2425
/// Number of times YARV instructions should be profiled.
2526
pub num_profiles: u8,
@@ -44,6 +45,12 @@ pub struct Options {
4445

4546
/// Dump code map to /tmp for performance profilers.
4647
pub perf: bool,
48+
49+
/// List of ISEQs that can be compiled, identified by their iseq_get_location()
50+
pub allowed_iseqs: Option<HashSet<String>>,
51+
52+
/// Path to a file where compiled ISEQs will be saved.
53+
pub log_compiled_iseqs: Option<String>,
4754
}
4855

4956
/// Return an Options with default values
@@ -57,6 +64,8 @@ pub fn init_options() -> Options {
5764
dump_lir: false,
5865
dump_disasm: false,
5966
perf: false,
67+
allowed_iseqs: None,
68+
log_compiled_iseqs: None,
6069
}
6170
}
6271

@@ -67,6 +76,8 @@ pub const ZJIT_OPTIONS: &'static [(&str, &str)] = &[
6776
("--zjit-num-profiles=num", "Number of profiled calls before JIT (default: 1, max: 255)."),
6877
("--zjit-stats", "Enable collecting ZJIT statistics."),
6978
("--zjit-perf", "Dump ISEQ symbols into /tmp/perf-{}.map for Linux perf."),
79+
("--zjit-log-compiled-iseqs=path",
80+
"Log compiled ISEQs to the file. The file will be truncated."),
7081
];
7182

7283
#[derive(Clone, Copy, Debug)]
@@ -108,6 +119,26 @@ pub extern "C" fn rb_zjit_parse_option(options: *const u8, str_ptr: *const c_cha
108119
parse_option(options, str_ptr).is_some()
109120
}
110121

122+
fn parse_jit_list(path_like: &str) -> HashSet<String> {
123+
// Read lines from the file
124+
let mut result = HashSet::new();
125+
if let Ok(lines) = std::fs::read_to_string(path_like) {
126+
for line in lines.lines() {
127+
let trimmed = line.trim();
128+
if !trimmed.is_empty() {
129+
result.insert(trimmed.to_string());
130+
}
131+
}
132+
} else {
133+
eprintln!("Failed to read JIT list from '{}'", path_like);
134+
}
135+
eprintln!("JIT list:");
136+
for item in &result {
137+
eprintln!(" {}", item);
138+
}
139+
result
140+
}
141+
111142
/// Expected to receive what comes after the third dash in "--zjit-*".
112143
/// Empty string means user passed only "--zjit". C code rejects when
113144
/// they pass exact "--zjit-".
@@ -165,6 +196,19 @@ fn parse_option(options: &mut Options, str_ptr: *const std::os::raw::c_char) ->
165196

166197
("perf", "") => options.perf = true,
167198

199+
("allowed-iseqs", _) if opt_val != "" => options.allowed_iseqs = Some(parse_jit_list(opt_val)),
200+
("log-compiled-iseqs", _) if opt_val != "" => {
201+
// Truncate the file if it exists
202+
std::fs::OpenOptions::new()
203+
.create(true)
204+
.write(true)
205+
.truncate(true)
206+
.open(opt_val)
207+
.map_err(|e| eprintln!("Failed to open file '{}': {}", opt_val, e))
208+
.ok();
209+
options.log_compiled_iseqs = Some(opt_val.into());
210+
}
211+
168212
_ => return None, // Option name not recognized
169213
}
170214

zjit/src/state.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,38 @@ impl ZJITState {
136136
pub fn get_counters() -> &'static mut Counters {
137137
&mut ZJITState::get_instance().counters
138138
}
139+
140+
/// Was --zjit-save-compiled-iseqs specified?
141+
pub fn should_log_compiled_iseqs() -> bool {
142+
ZJITState::get_instance().options.log_compiled_iseqs.is_some()
143+
}
144+
145+
/// Log the name of a compiled ISEQ to the file specified in options.log_compiled_iseqs
146+
pub fn log_compile(iseq_name: String) {
147+
assert!(ZJITState::should_log_compiled_iseqs());
148+
let filename = ZJITState::get_instance().options.log_compiled_iseqs.as_ref().unwrap();
149+
use std::io::Write;
150+
let mut file = match std::fs::OpenOptions::new().create(true).append(true).open(filename) {
151+
Ok(f) => f,
152+
Err(e) => {
153+
eprintln!("ZJIT: Failed to create file '{}': {}", filename, e);
154+
return;
155+
}
156+
};
157+
if let Err(e) = writeln!(file, "{}", iseq_name) {
158+
eprintln!("ZJIT: Failed to write to file '{}': {}", filename, e);
159+
}
160+
}
161+
162+
/// Check if we are allowed to compile a given ISEQ based on --zjit-allowed-iseqs
163+
pub fn can_compile_iseq(iseq: cruby::IseqPtr) -> bool {
164+
if let Some(ref allowed_iseqs) = ZJITState::get_instance().options.allowed_iseqs {
165+
let name = cruby::iseq_get_location(iseq, 0);
166+
allowed_iseqs.contains(&name)
167+
} else {
168+
true // If no restrictions, allow all ISEQs
169+
}
170+
}
139171
}
140172

141173
/// Initialize ZJIT, given options allocated by rb_zjit_init_options()
@@ -148,7 +180,6 @@ pub extern "C" fn rb_zjit_init(options: *const u8) {
148180

149181
let options = unsafe { Box::from_raw(options as *mut Options) };
150182
ZJITState::init(*options);
151-
std::mem::drop(options);
152183

153184
rb_bug_panic_hook();
154185

0 commit comments

Comments
 (0)