Skip to content

Commit 1842c44

Browse files
committed
Refactor JIT FP walk to per-iteration unwind and fix JIT region detection
Restructure the eBPF JIT frame pointer walk: instead of a separate loop before the main CFP walk, advance the native FP chain by one frame per main loop iteration. This keeps the native unwind state in lockstep with the Ruby VM stack, supporting both YJIT (1 JIT frame, exits after first iteration) and ZJIT (1 JIT frame per iseq, 1:1 with CFPs). Fix JIT region detection in SynchronizeMappings to scan all mappings (including non-executable ---p reservations) for the prctl-labeled JIT region, then only register LPM prefixes for executable pages. This ensures jit_start/jit_end cover the full reserved address range even when the r-xp committed pages don't carry the label. Also fix IsAnonymous() to recognize [anon:...] labeled mappings, and remove debug log spam from parseMappings.
1 parent 5af1c6c commit 1842c44

File tree

3 files changed

+67
-52
lines changed

3 files changed

+67
-52
lines changed

interpreter/ruby/ruby.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,33 @@ func (r *rubyInstance) SynchronizeMappings(ebpf interpreter.EbpfHandler,
13101310

13111311
log.Debugf("Synchronizing ruby mappings")
13121312

1313+
// First pass: detect JIT bounds from ALL mappings (including non-executable).
1314+
// Ruby reserves a large address range for JIT code via mmap and labels it with
1315+
// prctl(PR_SET_VMA), giving it a path like "[anon:Ruby:rb_jit_reserve_addr_space]".
1316+
// The reserved region is typically ---p (non-executable). Ruby then mprotects individual
1317+
// pages to r-xp as JIT code is compiled. We need the full reserved region bounds for
1318+
// jit_start/jit_end so the eBPF program can recognize any PC within the JIT range,
1319+
// even as new pages are made executable.
1320+
var jitStart, jitEnd uint64
1321+
jitFound := false
1322+
for idx := range mappings {
1323+
m := &mappings[idx]
1324+
if strings.Contains(m.Path, "jit_reserve_addr_space") {
1325+
if !jitFound || m.Vaddr < jitStart {
1326+
jitStart = m.Vaddr
1327+
}
1328+
if !jitFound || m.Vaddr+m.Length > jitEnd {
1329+
jitEnd = m.Vaddr + m.Length
1330+
}
1331+
jitFound = true
1332+
}
1333+
}
1334+
1335+
// Second pass: register LPM prefixes for executable anonymous/JIT mappings.
1336+
// This only covers r-xp pages so the eBPF unwinder is invoked for JIT code that
1337+
// has actually been committed. If no labeled JIT region was found above, fall back
1338+
// to heuristic detection from executable anonymous mappings.
1339+
var heuristicJitMapping *process.RawMapping
13131340
for idx := range mappings {
13141341
m := &mappings[idx]
13151342
if !m.IsExecutable() || !m.IsAnonymous() {

support/ebpf/ruby_tracer.ebpf.c

Lines changed: 39 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ struct ruby_procs_t {
2020
// NOTE: the maximum size stack is FRAMES_PER_WALK_RUBY_STACK * calls to tail_call().
2121
#define FRAMES_PER_WALK_RUBY_STACK 32
2222

23-
// The maximum number of JIT frames to unwind via frame pointers.
24-
// YJIT creates one native frame per JIT entry (not per Ruby method),
25-
// so in practice there is typically only 1 (occasionally 2 for nested entries).
26-
#define MAX_JIT_FP_FRAMES 4
2723
// When resolving a CME, we need to traverse environment pointers until we
2824
// find IMEMO_MENT. Since we can't do a while loop, we have to bound this
2925
// the max encountered in experimentation on a production rails app is 6.
@@ -34,7 +30,7 @@ struct ruby_procs_t {
3430
// This increases insn for the kernel verifier: all code in the ep check "loop"
3531
// is M*N for instruction checks, so be extra sensitive about additions there.
3632
// If we get ERR_RUBY_READ_CME_MAX_EP regularly, we may need to raise it.
37-
#define MAX_EP_CHECKS 10
33+
#define MAX_EP_CHECKS 10
3834

3935
// Constants related to reading a method entry
4036
// https://github.com/ruby/ruby/blob/523857bfcb0f0cdfd1ed7faa09b9c59a0266e7e2/method.h#L118
@@ -456,55 +452,27 @@ static EBPF_INLINE ErrorCode walk_ruby_stack(
456452
record->rubyUnwindState.cfunc_saved_frame = 0;
457453
}
458454

459-
// If the CPU PC is in the JIT region, walk the native frame pointer chain through JIT frames.
460-
// This follows the same pattern as the V8 unwinder (v8_tracer.ebpf.c): push each JIT frame,
461-
// then use unwinder_unwind_frame_pointer() to advance PC/SP/FP to the caller.
462-
// YJIT creates one native FP frame per JIT entry, not per Ruby method, so there are
463-
// typically only 1-2 frames to walk.
455+
// Detect if the CPU PC is in the JIT region.
456+
// When frame pointers are available, we keep the native unwind state in sync with
457+
// the Ruby VM stack by advancing the FP chain by one frame per loop iteration.
458+
// This handles both YJIT (1 JIT frame, exits after first iteration) and ZJIT
459+
// (1 JIT frame per iseq, 1:1 with CFPs, stays in sync throughout the walk).
464460
//
465-
// If frame_pointers_enabled is false (e.g. x86_64 without --yjit-perf), we push a single
466-
// dummy JIT frame and skip FP walking -- the stack will be truncated at the Ruby VM frames
467-
// but won't produce garbage from following an invalid FP chain.
468-
if (
469-
rubyinfo->jit_start > 0 && record->state.pc >= rubyinfo->jit_start &&
470-
record->state.pc < rubyinfo->jit_end) {
471-
if (rubyinfo->frame_pointers_enabled) {
472-
// Walk the native FP chain through JIT frames, pushing each as a JIT frame
473-
// so it can potentially be symbolized via perf maps later.
474-
UNROLL for (int j = 0; j < MAX_JIT_FP_FRAMES; j++)
475-
{
476-
ErrorCode jit_error =
477-
push_ruby(&record->state, trace, RUBY_FRAME_TYPE_JIT, (u64)record->state.pc, 0, 0);
478-
if (jit_error) {
479-
return jit_error;
480-
}
461+
// When frame pointers are not available, we push a single dummy JIT frame and
462+
// set jit_detected to suppress native unwinding.
463+
bool in_jit = rubyinfo->jit_start > 0 && record->state.pc >= rubyinfo->jit_start &&
464+
record->state.pc < rubyinfo->jit_end;
481465

482-
if (!unwinder_unwind_frame_pointer(&record->state)) {
483-
// FP chain broken, cannot continue
484-
*next_unwinder = PROG_UNWIND_STOP;
485-
return ERR_OK;
486-
}
487-
488-
// Check if we've left the JIT region
489-
if (record->state.pc < rubyinfo->jit_start || record->state.pc >= rubyinfo->jit_end) {
490-
break;
491-
}
492-
}
493-
// After walking JIT frames, PC should be in rb_vm_exec or other native code.
494-
// We must resolve the mapping for the new PC so that text_section_id/offset/bias
495-
// are up to date. Without this, the native unwinder would try to use stale mapping
496-
// info from the JIT region and fail with ERR_NATIVE_NO_PID_PAGE_MAPPING.
497-
ErrorCode map_err = get_next_unwinder_after_native_frame(record, next_unwinder);
498-
if (map_err) {
499-
return map_err;
466+
if (in_jit) {
467+
if (rubyinfo->frame_pointers_enabled) {
468+
// Push a leaf JIT frame with the raw machine PC for perf-map symbolization.
469+
ErrorCode jit_error =
470+
push_ruby(&record->state, trace, RUBY_FRAME_TYPE_JIT, (u64)record->state.pc, 0, 0);
471+
if (jit_error) {
472+
return jit_error;
500473
}
501-
// The resolved unwinder should be PROG_UNWIND_RUBY (since PC is in rb_vm_exec
502-
// which is in interpreter_offsets) or PROG_UNWIND_NATIVE. Either way, we continue
503-
// with the Ruby VM stack walk below and the mapping state is now correct for when
504-
// we eventually hand off to the native unwinder.
505474
} else {
506475
// No frame pointers available: push a single dummy JIT frame.
507-
// We cannot walk the FP chain so we will not be able to resume native unwinding.
508476
// Mark jit_detected so that cfuncs are pushed inline and end-of-stack uses
509477
// PROG_UNWIND_STOP instead of PROG_UNWIND_NATIVE.
510478
record->rubyUnwindState.jit_detected = true;
@@ -513,18 +481,38 @@ static EBPF_INLINE ErrorCode walk_ruby_stack(
513481
if (jit_error) {
514482
return jit_error;
515483
}
484+
in_jit = false;
516485
}
517486
}
518487

519488
for (u32 i = 0; i < FRAMES_PER_WALK_RUBY_STACK; ++i) {
489+
// Keep the native unwind state in sync: if the native PC is still in the JIT
490+
// region, advance it by one frame pointer to match the Ruby VM stack pop.
491+
// For YJIT this exits JIT on the first iteration. For ZJIT this pops one JIT
492+
// native frame per CFP, keeping the two stacks in lockstep.
493+
if (in_jit) {
494+
if (!unwinder_unwind_frame_pointer(&record->state)) {
495+
*next_unwinder = PROG_UNWIND_STOP;
496+
return ERR_OK;
497+
}
498+
if (record->state.pc < rubyinfo->jit_start || record->state.pc >= rubyinfo->jit_end) {
499+
// Exited the JIT region. Resolve the mapping for the post-JIT PC so that
500+
// text_section_id/offset/bias are correct for native unwinding later.
501+
in_jit = false;
502+
ErrorCode map_err = get_next_unwinder_after_native_frame(record, next_unwinder);
503+
if (map_err) {
504+
return map_err;
505+
}
506+
}
507+
}
508+
520509
error = read_ruby_frame(record, rubyinfo, stack_ptr, next_unwinder);
521510
if (error != ERR_OK)
522511
return error;
523512

524513
if (last_stack_frame <= stack_ptr) {
525514
// We have processed all frames in the Ruby VM and can stop here.
526-
// If we walked through JIT frames via FP, the state is clean and native unwinding
527-
// can continue. If JIT was detected without FP, the PC is still in the JIT region
515+
// If JIT was detected without FP, the PC is still in the JIT region
528516
// and native unwinding would fail, so we stop.
529517
*next_unwinder = record->rubyUnwindState.jit_detected ? PROG_UNWIND_STOP : PROG_UNWIND_NATIVE;
530518
goto save_state;

support/types.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)