Skip to content

Commit 3b8317e

Browse files
authored
ZJIT: Dump side-exit locations in Fuchsia trace format (ruby#16470)
This gives us instant access to all nice Fuchsia and Perfetto tooling, including zoomable, SQL queryable browsing for traces: <img width="1912" height="1185" alt="Screenshot 2026-03-20 at 10 50 57 AM" src="https://github.com/user-attachments/assets/6475bbec-eb55-4886-8e94-13450def2de5" /> Hottest side-exits grouped by exit location using SQL: ```sql SELECT reason, backtrace, count(*) AS exits FROM ( SELECT s.id, s.name AS reason, group_concat(a.display_value, ' <- ') AS backtrace FROM slice s JOIN args a USING(arg_set_id) WHERE s.category = 'side_exit' GROUP BY s.id ) GROUP BY reason, backtrace ORDER BY exits DESC LIMIT 30 ``` <img width="1912" height="1186" alt="Screenshot 2026-03-24 at 3 58 28 PM" src="https://github.com/user-attachments/assets/8195ccd8-aeb6-4396-8c07-e85bbb280a4a" />
1 parent e74823a commit 3b8317e

12 files changed

Lines changed: 269 additions & 458 deletions

File tree

test/ruby/test_zjit.rb

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -383,25 +383,20 @@ def test
383383
end
384384

385385
def test_exit_tracing
386-
# This is a very basic smoke test. The StackProf format
387-
# this option generates is external to us.
388-
Dir.mktmpdir("zjit_test_exit_tracing") do |tmp_dir|
389-
assert_compiles('true', <<~RUBY, extra_args: ['-C', tmp_dir, '--zjit-trace-exits'])
390-
def test(object) = object.itself
391-
392-
# induce an exit just for good measure
393-
array = []
394-
test(array)
395-
test(array)
396-
def array.itself = :not_itself
397-
test(array)
398-
399-
RubyVM::ZJIT.exit_locations.is_a?(Hash)
400-
RUBY
401-
dump_files = Dir.glob('zjit_exits_*.dump', base: tmp_dir)
402-
assert_equal(1, dump_files.length)
403-
refute(File.empty?(File.join(tmp_dir, dump_files.first)))
404-
end
386+
# Smoke test: --zjit-trace-exits writes a Fuchsia trace (.fxt) file to /tmp
387+
assert_compiles('true', <<~RUBY, extra_args: ['--zjit-trace-exits'])
388+
def test(object) = object.itself
389+
390+
# induce an exit just for good measure
391+
array = []
392+
test(array)
393+
test(array)
394+
def array.itself = :not_itself
395+
test(array)
396+
397+
fxt_files = Dir.glob("/tmp/perfetto-\#{Process.pid}.fxt")
398+
fxt_files.length == 1 && !File.empty?(fxt_files.first)
399+
RUBY
405400
end
406401

407402
private

zjit.c

Lines changed: 0 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -32,98 +32,6 @@ enum zjit_struct_offsets {
3232
ISEQ_BODY_OFFSET_PARAM = offsetof(struct rb_iseq_constant_body, param)
3333
};
3434

35-
// For a given raw_sample (frame), set the hash with the caller's
36-
// name, file, and line number. Return the hash with collected frame_info.
37-
static void
38-
rb_zjit_add_frame(VALUE hash, VALUE frame)
39-
{
40-
VALUE frame_id = PTR2NUM(frame);
41-
42-
if (RTEST(rb_hash_aref(hash, frame_id))) {
43-
return;
44-
}
45-
else {
46-
VALUE frame_info = rb_hash_new();
47-
// Full label for the frame
48-
VALUE name = rb_profile_frame_full_label(frame);
49-
// Absolute path of the frame from rb_iseq_realpath
50-
VALUE file = rb_profile_frame_absolute_path(frame);
51-
// Line number of the frame
52-
VALUE line = rb_profile_frame_first_lineno(frame);
53-
54-
// If absolute path isn't available use the rb_iseq_path
55-
if (NIL_P(file)) {
56-
file = rb_profile_frame_path(frame);
57-
}
58-
59-
rb_hash_aset(frame_info, ID2SYM(rb_intern("name")), name);
60-
rb_hash_aset(frame_info, ID2SYM(rb_intern("file")), file);
61-
rb_hash_aset(frame_info, ID2SYM(rb_intern("samples")), INT2NUM(0));
62-
rb_hash_aset(frame_info, ID2SYM(rb_intern("total_samples")), INT2NUM(0));
63-
rb_hash_aset(frame_info, ID2SYM(rb_intern("edges")), rb_hash_new());
64-
rb_hash_aset(frame_info, ID2SYM(rb_intern("lines")), rb_hash_new());
65-
66-
if (line != INT2FIX(0)) {
67-
rb_hash_aset(frame_info, ID2SYM(rb_intern("line")), line);
68-
}
69-
70-
rb_hash_aset(hash, frame_id, frame_info);
71-
}
72-
}
73-
74-
// Parses the ZjitExitLocations raw_samples and line_samples collected by
75-
// rb_zjit_record_exit_stack and turns them into 3 hashes (raw, lines, and frames) to
76-
// be used by RubyVM::ZJIT.exit_locations. zjit_raw_samples represents the raw frames information
77-
// (without name, file, and line), and zjit_line_samples represents the line information
78-
// of the iseq caller.
79-
VALUE
80-
rb_zjit_exit_locations_dict(VALUE *zjit_raw_samples, int *zjit_line_samples, int samples_len)
81-
{
82-
VALUE result = rb_hash_new();
83-
VALUE raw_samples = rb_ary_new_capa(samples_len);
84-
VALUE line_samples = rb_ary_new_capa(samples_len);
85-
VALUE frames = rb_hash_new();
86-
int idx = 0;
87-
88-
// While the index is less than samples_len, parse zjit_raw_samples and
89-
// zjit_line_samples, then add casted values to raw_samples and line_samples array.
90-
while (idx < samples_len) {
91-
int num = (int)zjit_raw_samples[idx];
92-
int line_num = (int)zjit_line_samples[idx];
93-
idx++;
94-
95-
// + 1 as we append an additional sample for the insn
96-
rb_ary_push(raw_samples, SIZET2NUM(num + 1));
97-
rb_ary_push(line_samples, INT2NUM(line_num + 1));
98-
99-
// Loop through the length of samples_len and add data to the
100-
// frames hash. Also push the current value onto the raw_samples
101-
// and line_samples array respectively.
102-
for (int o = 0; o < num; o++) {
103-
rb_zjit_add_frame(frames, zjit_raw_samples[idx]);
104-
rb_ary_push(raw_samples, SIZET2NUM(zjit_raw_samples[idx]));
105-
rb_ary_push(line_samples, INT2NUM(zjit_line_samples[idx]));
106-
idx++;
107-
}
108-
109-
rb_ary_push(raw_samples, SIZET2NUM(zjit_raw_samples[idx]));
110-
rb_ary_push(line_samples, INT2NUM(zjit_line_samples[idx]));
111-
idx++;
112-
113-
rb_ary_push(raw_samples, SIZET2NUM(zjit_raw_samples[idx]));
114-
rb_ary_push(line_samples, INT2NUM(zjit_line_samples[idx]));
115-
idx++;
116-
}
117-
118-
// Set add the raw_samples, line_samples, and frames to the results
119-
// hash.
120-
rb_hash_aset(result, ID2SYM(rb_intern("raw")), raw_samples);
121-
rb_hash_aset(result, ID2SYM(rb_intern("lines")), line_samples);
122-
rb_hash_aset(result, ID2SYM(rb_intern("frames")), frames);
123-
124-
return result;
125-
}
126-
12735
void rb_zjit_profile_disable(const rb_iseq_t *iseq);
12836

12937
void

zjit.rb

Lines changed: 0 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ module RubyVM::ZJIT
1616
if Primitive.rb_zjit_print_stats_p
1717
at_exit { print_stats }
1818
end
19-
if Primitive.rb_zjit_trace_exit_locations_enabled_p
20-
at_exit { dump_locations }
21-
end
2219
end
2320

2421
class << RubyVM::ZJIT
@@ -65,110 +62,6 @@ def induce_side_exit! = nil
6562
# Actually running this method does nothing, whether ZJIT sees the call or not.
6663
def induce_breakpoint! = nil
6764

68-
# If --zjit-trace-exits is enabled parse the hashes from
69-
# Primitive.rb_zjit_get_exit_locations into a format readable
70-
# by Stackprof. This will allow us to find the exact location of a
71-
# side exit in ZJIT based on the instruction that is exiting.
72-
def exit_locations
73-
return unless trace_exit_locations_enabled?
74-
75-
results = Primitive.rb_zjit_get_exit_locations
76-
raw_samples = results[:raw]
77-
line_samples = results[:lines]
78-
frames = results[:frames]
79-
samples_count = 0
80-
81-
# Use nonexistent.def as a dummy file name.
82-
frame_template = { samples: 0, total_samples: 0, edges: {}, name: name, file: "nonexistent.def", line: nil, lines: {} }
83-
84-
# Loop through all possible instructions and setup the frame hash.
85-
RubyVM::INSTRUCTION_NAMES.each_with_index do |name, frame_id|
86-
frames[frame_id] = frame_template.dup.tap { |h| h[:name] = name }
87-
end
88-
89-
# Loop through the raw_samples and build the hashes for StackProf.
90-
# The loop is based off an example in the StackProf documentation and therefore
91-
# this functionality can only work with that library.
92-
#
93-
# Raw Samples:
94-
# [ length, frame1, frame2, frameN, ..., instruction, count
95-
#
96-
# Line Samples
97-
# [ length, line_1, line_2, line_n, ..., dummy value, count
98-
i = 0
99-
while i < raw_samples.length
100-
stack_length = raw_samples[i]
101-
i += 1 # consume the stack length
102-
103-
sample_count = raw_samples[i + stack_length]
104-
105-
prev_frame_id = nil
106-
stack_length.times do |idx|
107-
idx += i
108-
frame_id = raw_samples[idx]
109-
110-
if prev_frame_id
111-
prev_frame = frames[prev_frame_id]
112-
prev_frame[:edges][frame_id] ||= 0
113-
prev_frame[:edges][frame_id] += sample_count
114-
end
115-
116-
frame_info = frames[frame_id]
117-
frame_info[:total_samples] += sample_count
118-
119-
frame_info[:lines][line_samples[idx]] ||= [0, 0]
120-
frame_info[:lines][line_samples[idx]][0] += sample_count
121-
122-
prev_frame_id = frame_id
123-
end
124-
125-
i += stack_length # consume the stack
126-
127-
top_frame_id = prev_frame_id
128-
top_frame_line = 1
129-
130-
frames[top_frame_id][:samples] += sample_count
131-
frames[top_frame_id][:lines] ||= {}
132-
frames[top_frame_id][:lines][top_frame_line] ||= [0, 0]
133-
frames[top_frame_id][:lines][top_frame_line][1] += sample_count
134-
135-
samples_count += sample_count
136-
i += 1
137-
end
138-
139-
results[:samples] = samples_count
140-
141-
# These values are mandatory to include for stackprof, but we don't use them.
142-
results[:missed_samples] = 0
143-
results[:gc_samples] = 0
144-
results
145-
end
146-
147-
# Marshal dumps exit locations to the given filename.
148-
#
149-
# Usage:
150-
#
151-
# In a script call:
152-
#
153-
# RubyVM::ZJIT.dump_exit_locations("my_file.dump")
154-
#
155-
# Then run the file with the following options:
156-
#
157-
# ruby --zjit --zjit-stats --zjit-trace-exits test.rb
158-
#
159-
# Once the code is done running, use Stackprof to read the dump file.
160-
# See Stackprof documentation for options.
161-
def dump_exit_locations(filename)
162-
unless trace_exit_locations_enabled?
163-
raise ArgumentError, "--zjit-trace-exits must be enabled to use dump_exit_locations."
164-
end
165-
166-
File.open(filename, "wb") do |file|
167-
Marshal.dump(RubyVM::ZJIT.exit_locations, file)
168-
file.size
169-
end
170-
end
171-
17265
# Check if `--zjit-stats` is used
17366
def stats_enabled?
17467
Primitive.rb_zjit_stats_enabled_p
@@ -387,13 +280,4 @@ def print_stats_file
387280
end
388281
end
389282

390-
def dump_locations # :nodoc:
391-
return unless trace_exit_locations_enabled?
392-
393-
filename = "zjit_exits_#{Process.pid}.dump"
394-
n_bytes = dump_exit_locations(filename)
395-
396-
absolute_filename = File.expand_path(filename)
397-
$stderr.puts("#{n_bytes} bytes written to #{absolute_filename}")
398-
end
399283
end

zjit/bindgen/src/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,9 @@ fn main() {
299299
.allowlist_function("rb_RSTRING_PTR")
300300
.allowlist_function("rb_RSTRING_LEN")
301301
.allowlist_function("rb_ENCODING_GET")
302-
.allowlist_function("rb_zjit_exit_locations_dict")
302+
.allowlist_function("rb_profile_frame_full_label")
303+
.allowlist_function("rb_profile_frame_absolute_path")
304+
.allowlist_function("rb_profile_frame_path")
303305
.allowlist_function("rb_optimized_call")
304306
.allowlist_function("rb_jit_icache_invalidate")
305307
.allowlist_function("rb_zjit_print_exception")

zjit/src/backend/lir.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2727,7 +2727,11 @@ impl Assembler
27272727
// ccall doesn't clobber caller-saved registers
27282728
// holding stack/local operands.
27292729
compile_exit_save_state(self, &exit);
2730-
asm_ccall!(self, rb_zjit_record_exit_stack, pc);
2730+
// Leak a CString with the reason so it's available at runtime
2731+
let reason_cstr = std::ffi::CString::new(reason.to_string())
2732+
.unwrap_or_else(|_| std::ffi::CString::new("unknown").unwrap());
2733+
let reason_ptr = reason_cstr.into_raw() as *const u8;
2734+
asm_ccall!(self, rb_zjit_record_exit_stack, Opnd::const_ptr(reason_ptr));
27312735
compile_exit_return(self);
27322736
} else {
27332737
// If the side exit has already been compiled, jump to it.

zjit/src/codegen.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,14 @@ fn gen_function(cb: &mut CodeBlock, iseq: IseqPtr, version: IseqVersionRef, func
467467
if let Err(last_snapshot) = result {
468468
debug!("ZJIT: gen_function: Failed to compile insn: {insn_id} {insn}. Generating side-exit.");
469469
gen_incr_counter(&mut asm, exit_counter_for_unhandled_hir_insn(&insn));
470-
gen_side_exit(&mut jit, &mut asm, &SideExitReason::UnhandledHIRInsn(insn_id), &function.frame_state(last_snapshot));
470+
let reason = match insn {
471+
Insn::ArrayMax { .. } => SideExitReason::UnhandledHIRArrayMax,
472+
Insn::FixnumDiv { .. } => SideExitReason::UnhandledHIRFixnumDiv,
473+
Insn::Throw { .. } => SideExitReason::UnhandledHIRThrow,
474+
Insn::InvokeBuiltin { .. } => SideExitReason::UnhandledHIRInvokeBuiltin,
475+
_ => SideExitReason::UnhandledHIRUnknown(insn_id),
476+
};
477+
gen_side_exit(&mut jit, &mut asm, &reason, &function.frame_state(last_snapshot));
471478
// Don't bother generating code after a side-exit. We won't run it.
472479
// TODO(max): Generate ud2 or equivalent.
473480
break;

zjit/src/cruby.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -902,15 +902,18 @@ pub fn iseq_get_location(iseq: IseqPtr, pos: u32) -> String {
902902
s
903903
}
904904

905+
pub fn ruby_str_to_rust_string_result(v: VALUE) -> Result<String, std::string::FromUtf8Error> {
906+
let str_ptr = unsafe { rb_RSTRING_PTR(v) } as *mut u8;
907+
let str_len: usize = unsafe { rb_RSTRING_LEN(v) }.try_into().unwrap();
908+
let str_slice: &[u8] = unsafe { std::slice::from_raw_parts(str_ptr, str_len) };
909+
String::from_utf8(str_slice.to_vec())
910+
}
905911

906912
// Convert a CRuby UTF-8-encoded RSTRING into a Rust string.
907913
// This should work fine on ASCII strings and anything else
908914
// that is considered legal UTF-8, including embedded nulls.
909-
fn ruby_str_to_rust_string(v: VALUE) -> String {
910-
let str_ptr = unsafe { rb_RSTRING_PTR(v) } as *mut u8;
911-
let str_len: usize = unsafe { rb_RSTRING_LEN(v) }.try_into().unwrap();
912-
let str_slice: &[u8] = unsafe { std::slice::from_raw_parts(str_ptr, str_len) };
913-
String::from_utf8(str_slice.to_vec()).unwrap_or_default()
915+
pub fn ruby_str_to_rust_string(v: VALUE) -> String {
916+
ruby_str_to_rust_string_result(v).unwrap_or_default()
914917
}
915918

916919
pub fn ruby_sym_to_rust_string(v: VALUE) -> String {

zjit/src/cruby_bindings.inc.rs

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

zjit/src/gc.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use std::{ffi::c_void, ops::Range};
55
use crate::{cruby::*, state::ZJITState, stats::with_time_stat, virtualmem::CodePtr};
66
use crate::payload::{IseqPayload, IseqVersionRef, get_or_create_iseq_payload};
77
use crate::stats::Counter::gc_time_ns;
8-
use crate::state::gc_mark_raw_samples;
98

109
/// GC callback for marking GC objects in the per-ISEQ payload.
1110
#[unsafe(no_mangle)]
@@ -207,5 +206,5 @@ fn ranges_overlap<T>(left: &Range<T>, right: &Range<T>) -> bool where T: Partial
207206
/// Callback for marking GC objects inside [crate::invariants::Invariants].
208207
#[unsafe(no_mangle)]
209208
pub extern "C" fn rb_zjit_root_mark() {
210-
gc_mark_raw_samples();
209+
// TODO(max): Either add roots to mark or consider removing this callback
211210
}

zjit/src/hir.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,11 @@ pub enum SideExitReason {
498498
UnhandledNewarraySend(vm_opt_newarray_send_type),
499499
UnhandledDuparraySend(u64),
500500
UnknownSpecialVariable(u64),
501-
UnhandledHIRInsn(InsnId),
501+
UnhandledHIRArrayMax,
502+
UnhandledHIRFixnumDiv,
503+
UnhandledHIRThrow,
504+
UnhandledHIRInvokeBuiltin,
505+
UnhandledHIRUnknown(InsnId),
502506
UnhandledYARVInsn(u32),
503507
UnhandledCallType(CallType),
504508
UnhandledBlockArg,

0 commit comments

Comments
 (0)