Skip to content

Commit 36fff44

Browse files
committed
ZJIT: Create delta debugging script to narrow JIT failures
Add support for `--zjit-allowed-iseqs=SomeFile` and `--zjit-save-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% ./jit_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: bar@test.rb:8 plum% We start with 4 compiled functions and shrink to just one.
1 parent b22eb0e commit 36fff44

4 files changed

Lines changed: 173 additions & 2 deletions

File tree

zjit/jit_bisect.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
temp_file = Tempfile.new("jit_list")
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+
71+
# Try running with no JIT list to get a stable baseline
72+
_, stderr, status = run_with_jit_list(RUBY, OPTIONS, [])
73+
if !status.success?
74+
raise "Command failed with empty JIT list: #{stderr}"
75+
end
76+
# Collect the JIT list from the failing Ruby process
77+
jit_list = nil
78+
temp_file = Tempfile.new "jit_list"
79+
Open3.capture3("#{RUBY} --zjit-save-compiled-iseqs=#{temp_file.path} #{OPTIONS}")
80+
jit_list = File.readlines(temp_file.path).map(&:strip).reject(&:empty?)
81+
LOGGER.info("Starting with JIT list of #{jit_list.length} items.")
82+
# Now narrow it down
83+
command = lambda do |items|
84+
status = Timeout.timeout(5) do
85+
_, _, status = run_with_jit_list(RUBY, OPTIONS, items)
86+
status
87+
end
88+
status.success?
89+
end
90+
result = run_bisect(command, jit_list)
91+
File.open("jitlist.txt", "w") do |file|
92+
file.puts(result)
93+
end
94+
puts "Reduced JIT list (available in jitlist.txt):"
95+
puts result

zjit/src/codegen.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ pub extern "C" fn rb_zjit_iseq_gen_entry_point(iseq: IseqPtr, _ec: EcPtr) -> *co
101101
/// See [gen_iseq_entry_point_body]. This wrapper is to make sure cb.mark_all_executable()
102102
/// is called even if gen_iseq_entry_point_body() partially fails and returns a null pointer.
103103
fn gen_iseq_entry_point(iseq: IseqPtr) -> *const u8 {
104+
if !ZJITState::can_compile_iseq(iseq_get_location(iseq, 0)) {
105+
return std::ptr::null();
106+
}
104107
let cb = ZJITState::get_code_block();
105108
let code_ptr = gen_iseq_entry_point_body(cb, iseq);
106109

@@ -284,6 +287,10 @@ fn gen_function(cb: &mut CodeBlock, iseq: IseqPtr, function: &Function) -> Optio
284287
let iseq_name = iseq_get_location(iseq, 0);
285288
register_with_perf(iseq_name, start_usize, code_size);
286289
}
290+
if ZJITState::should_save_compiled_iseqs() {
291+
let iseq_name = iseq_get_location(iseq, 0);
292+
ZJITState::log_compile(iseq_name);
293+
}
287294
}
288295
result
289296
}

zjit/src/options.rs

Lines changed: 43 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 save_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+
save_compiled_iseqs: None,
6069
}
6170
}
6271

@@ -108,6 +117,26 @@ pub extern "C" fn rb_zjit_parse_option(options: *const u8, str_ptr: *const c_cha
108117
parse_option(options, str_ptr).is_some()
109118
}
110119

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

166195
("perf", "") => options.perf = true,
167196

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

zjit/src/state.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,34 @@ impl ZJITState {
136136
pub fn get_counters() -> &'static mut Counters {
137137
&mut ZJITState::get_instance().counters
138138
}
139+
140+
pub fn should_save_compiled_iseqs() -> bool {
141+
ZJITState::get_instance().options.save_compiled_iseqs.is_some()
142+
}
143+
144+
pub fn log_compile(iseq_name: String) {
145+
assert!(ZJITState::should_save_compiled_iseqs());
146+
let filename = ZJITState::get_instance().options.save_compiled_iseqs.as_ref().unwrap();
147+
use std::io::Write;
148+
let mut file = match std::fs::OpenOptions::new().create(true).append(true).open(filename) {
149+
Ok(f) => f,
150+
Err(e) => {
151+
eprintln!("ZJIT: Failed to create file '{}': {}", filename, e);
152+
return;
153+
}
154+
};
155+
if let Err(e) = writeln!(file, "{}", iseq_name) {
156+
eprintln!("ZJIT: Failed to write to file '{}': {}", filename, e);
157+
}
158+
}
159+
160+
pub fn can_compile_iseq(iseq_name: String) -> bool {
161+
if let Some(ref allowed_iseqs) = ZJITState::get_instance().options.allowed_iseqs {
162+
allowed_iseqs.contains(&iseq_name)
163+
} else {
164+
true // If no restrictions, allow all ISEQs
165+
}
166+
}
139167
}
140168

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

149177
let options = unsafe { Box::from_raw(options as *mut Options) };
150178
ZJITState::init(*options);
151-
std::mem::drop(options);
152179

153180
rb_bug_panic_hook();
154181

0 commit comments

Comments
 (0)