Skip to content

[off-CPU stack 1/3] Sync profiler prerequisites onto LuaJIT omnibus - #73

Closed
dalehamel wants to merge 59 commits into
dale/luajit-omnibusfrom
dale/offcpu-omnibus-upstream-sync
Closed

dalehamel wants to merge 59 commits into
dale/luajit-omnibusfrom
dale/offcpu-omnibus-upstream-sync

Conversation

@dalehamel

@dalehamel dalehamel commented Jul 24, 2026 •

Copy link
Copy Markdown
Member

Stack

  1. This PR — upstream prerequisites → #50
  2. #74 — minimal dynamic ProbeContext runtime
  3. #75 — duration-filtered off-CPU probe + task-state labels

What

Bring the LuaJIT omnibus branch forward through upstream c1e8d97e (tracer: give the probe unwinder its own per-CPU record map, open-telemetry#1605), including dynamic trace-origin registration (open-telemetry#1607). These are prerequisites for loading independent custom-probe collections without clobbering the perf-event unwinder state or relying on hardcoded origin IDs.

This is an upstream synchronization layer only. It contains none of the Hack Days I/O, async, process-observer, lock-wait, heap, or off-CPU custom probes.

Merge construction

Validation

  • clang-17 amd64 + arm64 BPF rebuild and format lint.
  • Builds: interpreter, support, tracer, processmanager, metrics.
  • Tests: Ruby, tracer, processmanager/ebpf/execinfomanager, reporter/pdata/samples.

Why separate

The existing Hack Days base (#59) contains the same upstream prerequisites but also ~all Hack Days probes. Splitting this layer lets the off-CPU stack target #50 without importing those unrelated features.

umanwizard and others added 30 commits June 15, 2026 15:22
This PR contains an interpreter for LuaJIT code, which is primarily
useful in practice due to OpenResty. It has been used in Parca/Polar
Signals since Nov. 2024.

A thorough explanation of the approach used as well as an introduction
to Lua internals can be found at the accompanying blog post:
https://www.polarsignals.com/blog/posts/2024/11/13/lua-unwinding .
…en-telemetry#234)

For example, the standalone "luajit" binary in the openresty docker
containers is a symlink to something called `luajit-2.1.ROLLING`
I can't figure out why at some point we thought that r7 holds the
dispatch table on ARM, but as far as I can tell, that's not the case.

What is, however, the case, is that r22 holds G; see here:

https://github.com/LuaJIT/LuaJIT/blob/659a61693aa3b87661864ad0f12eee14c865cd7f/src/lj_target_arm64.h#L41

And this is the register we load `g` from, e.g. when entering `lj_gc_step_jit`.
The main fixes are as follows:

* Document and make clear that hitting the bottom of the Lua stack is an unexpected condition (due to dummy values)
* Always return to the native unwinder from a cframe. Previously we didn't do so in the case that it was the root cframe (implicitly relying on the fact that we would then unwind into the dummy values described above on the next iteration, where we would then return to the native unwinder anyway).
* Fix the spadjust value for jit frames on aarch64
* Always unwind past the native frame when returning to the native unwinder.
Our code, which seems to have been copied from
[debug_framepc](https://github.com/luajit/luajit/blob/659a6169/src/lj_debug.c#L53-L53)
in the LuaJIT source, assumes that for Lua functions at the top of the
stack, we can read the PC from the physical stack.

However, this is only true if the interpreter has intentionally saved
it there, which it does when entering C utility functions. For
example, before the C helper `lj_meta_arith` is invoked, the PC is
saved
[here](https://github.com/luajit/luajit/blob/659a6169/src/vm_arm64.dasc#L891-L891).

The reason the interpreter's `debug_framepc` assumes it can always
find it there is because the only code paths that can lead to that
function being invoked indeed go through an instruction that saves the
PC. But for us that is not the case, we might be interrupted in the
interpreter while executing random Lua code, or in a JITted
function. Fortunately, in this case, the virtual PC will always be
stored in the "PC" register (x21 on aarch64, rbx on x86-64), which we
can just read directly. Since we're in a leaf frame, we just read it
from `pt_regs` directly.
… cleanup

- Extract JIT region detection into standalone findJITRegion() function
  that handles both prctl-labeled mappings (spanning full reserved area
  including holes) and heuristic fallback (first anonymous executable mapping)
- Add comprehensive unit tests for JIT region detection: no mappings,
  file-backed only, labeled single/multiple, heuristic fallback, precedence
- Fix Detach() to clean up PidInterpreterMapping prefixes (previously only
  deleted proc data, leaking eBPF map entries)
- Remove dead code: m.Vaddr < jitMapping.Vaddr branch that could never be
  true since /proc/pid/maps is sorted by address
- Fix JIT region check to use >= for lower bound (half-open interval [start, end))
  matching V8 and HotSpot conventions
- Reuse map lookup value to avoid double hashing of RawMapping struct key
- Wrap CalculatePrefixList error for better debugging
- Log stale prefix deletion errors instead of silently discarding
- Move prctl(PR_SET_VMA) named anonymous mapping support from process/process.go
  (was in PR #36 but is needed here for findJITRegion labeled detection)
- Remove stale 'assume all anonymous' comment
- Rebuild BPF blobs for >= fix
- Add 'Register LPM prefixes' comment to SynchronizeMappings loop
- Extract in_jit bool variable from inline if condition for clarity
- Improve JIT detection comments (read_ruby_frame and walk_ruby_stack)
- Fix whitespace: add blank line after FRAMES_PER_WALK_RUBY_STACK define,
  normalize MAX_EP_CHECKS spacing
- Rebuild BPF blobs
Ruby 3.4.7 with --yjit on arm64. Verifies that:
- JIT code region is detected from anonymous executable mappings
- A JIT frame is pushed for the YJIT-compiled code
- Ruby VM frames (is_prime, sum_of_primes, loop) are properly unwound
  after the JIT frame

On main, this coredump fails with native_no_pid_page_mapping because
the profiler doesn't know how to handle the anonymous JIT mapping.

Moduledata tar for CI upload: ~/coredump-uploads/ruby-yjit-moduledata.tar
Ruby 3.4.7 with --yjit on x86_64. Same verification as arm64 test:
- JIT code region detected from anonymous executable mappings
- JIT frame pushed for YJIT-compiled code
- Full Ruby VM stack unwound (is_prime, sum_of_primes, loop)

Moduledata tar: ~/coredump-uploads/ruby-yjit-amd64-moduledata.tar
On systems with CONFIG_ANON_VMA_NAME=y, Ruby labels its JIT memory
region via prctl(PR_SET_VMA), giving it a path like
'[anon:Ruby:rb_yjit_reserve_addr_space]'. Without this fix, IsFileBacked()
returns true for these mappings (non-empty Path), causing IsAnonymous()
to return false. This prevents the LPM prefix registration loop in
SynchronizeMappings from registering JIT executable pages for eBPF
dispatch.

Add IsPrctlNamed() helper and exclude prctl-named mappings from
IsFileBacked(), so they are correctly classified as anonymous.
Co-authored-by: Florian Lehner <florianl@users.noreply.github.com>
Adapt SynchronizeMappings to work with uint32 values instead of *uint32
pointers: since generations are no longer shared via pointer, both mappings
and their prefixes must be updated independently each cycle. Use an isNew
flag to only call UpdatePidInterpreterMapping for newly discovered mappings.
Address florianl's concern about unlimited growth: entries are pruned
each SynchronizeMappings call via generation tracking, and the map size
is bounded by the number of executable anonymous mappings per process.
Only push the JIT frame when trace->num_frames == 0 (leaf position),
restoring the original behavior. Without this guard, when native frames
were already unwound before the Ruby unwinder runs, the JIT frame was
pushed deep in the stack instead of at the leaf, causing it to appear
as the root frame in flamegraphs.
walk_ruby_stack is re-entered via tail calls to process more frames.
On re-entry, in_jit was recomputed from record->state.pc which hasn't
changed (non-FP path), causing the JIT frame to be pushed again on
every tail call. Guard with !jit_detected so the JIT frame is only
pushed once on the first entry.
Assisted-By: devx/77645bc7-9f68-451a-8bc1-2d199434227a
dalehamel and others added 21 commits June 30, 2026 13:41
… from non-GC64 lua_close)

extractor_x86.findOffsetsFromLuaClose derives glref/curL by matching the
register that holds global_State across the lua_close disassembly. sameReg only
mapped EAX/ECX/EDX/EBX/ESI to their 64-bit forms, omitting EBP/ESP/EDI.

Tarantool's x86 (non-GC64) LuaJIT loads G with a 32-bit `movl 0x8(%rdi),%ebp`
(greg=EBP) and then stores cur_L via `movl $0x0,0x188(%rbp)` (base RBP).
sameReg(RBP,EBP) returned false, so the cur_L store never matched and extraction
failed with 'find offsets from lua_close failed' — no Lua unwinding on x86
tarantool (arm64 worked). Complete the 32->64-bit GPR mapping via reg64().
…resent

extractInterpreterBounds scans stack deltas for the first large gap matching the
VM unwind pattern. On x86 tarantool that matched an unrelated function (0x164469)
instead of the real lj_vm_asm_begin (0x261ca0), so extractOffsets failed its
start-address sanity check ('unexpected start address') and LuaJIT unwinding was
skipped (only native lj_* frames + ERROR_4012 from unmapped JIT mcode).

When the binary is unstripped (tarantool ships lj_vm_asm_begin), look the symbol
up via ef.LookupSymbol and return the delta gap that starts exactly there;
fall back to the heuristic for stripped binaries (asmBegin==0). Tests pass 0.
…m_asm_begin

The VM asm FDE starts at lj_vm_asm_begin but the preceding function's unwind info
extends to a few bytes below it, so the delta interval starts just under the
symbol (exact-address match missed it). Match the large delta interval that
contains the symbol and start the range exactly at lj_vm_asm_begin.
….LookupSymbol

ef.LookupSymbol only resolves dynamic symbols; lj_vm_asm_begin is a hidden
.symtab symbol, so the lookup returned ErrSymbolNotFound and asmBegin stayed 0,
skipping the symbol-anchored interp-range logic entirely. Use scanSymbols (reads
.symtab via VisitSymbols), matching how extractOffsets resolves the symbol.
…egression

- Hermetic TestExtractInterpreterBoundsAnchor: proves the anchor picks the VM
  asm over a decoy SP/param gap (x86 bug) and is byte-identical to the heuristic
  on the arm64 frame-pointer layout; covers start-below-symbol + fallback.
- Strengthen the OpenResty integration test to run the anchor on real shared-lib
  builds (amd64+arm64) and assert it yields identical offsets to the heuristic.
…ARF)

Tarantool's LuaJIT inserts a mem_L field in global_State between cur_L and
jit_base, so the eBPF assumption that jit_base is adjacent to cur_L read mem_L
instead -> garbage frame base for JIT-trace samples -> unresolved <lua> frames.

Extract jit_base's offset (g2jitbase) from lj_vm_exit_handler, which clears
G->jit_base via the DISPATCH register on trace exit (g2jitbase = g2dispatch +
disp). Cheap targeted disassembly, no DWARF. Falls back to cur_L+8 on stripped
binaries, correct for OpenResty/luajit2 (no mem_L). arm64 extraction is a TODO
(falls back; no regression to the arm64 interpreter path).

- support/ebpf: LuaJITProcInfo.g2jitbase; read jit_base from G+g2jitbase.
- offsets.go: findG2JitBaseOffset (+ lj_vm_exit_handler symbol); extractor iface.
- extractor_x86.go: findG2JitBaseFromExitHandler (movq $0,disp(%r14) -> g2dispatch+disp).
- offsets_test.go: assert openresty g2jitbase == cur_L+8 (regression guard, both arches).
…t unsigned)

x86asm.Decode reports a negative disp32 as its unsigned 32-bit value in the
int64 Disp field (0xFFFFF1E0, not -3616), so the g2dispatch+disp candidate
overflowed the validation range and the extractor fell back to cur_L+8 (mem_L).
Sign-extend the low 32 bits. Verified against the tarantool binary: g2jitbase
now extracts 0x1f0 (jit_base) instead of 0x1e8 (mem_L).
arm64 tarantool also has the mem_L field, so jit_base is at cur_L+0x10 not +8.
On arm64 the VM holds G directly in a register (x22), so the trace-exit clear
'str xzr, [x22, #0x1f0]' encodes jit_base's offset from G directly (g2dispatch
unused, unlike x86). Mirrors the setgcrefnull(g->cur_L) matcher in lua_close.
Verified against the real arm64 binary: g2jitbase=0x1f0.
…R_4012/4005)

After Lua frames resolve, the unwinder hands back to native by stepping over the
VM gate's C frame using a hardcoded LUAJIT_CFRAME_SPACE (x86 80, arm64 208). Both
are wrong for tarantool: the interp region's own stack delta says x86 CFA=sp+96
and arm64 CFA=fp+16 (frame-pointer based). The mismatch lands the resume PC in an
unmapped region -> every tarantool stack carried ERROR_4012 (x86, no_pid_page_mapping)
/ ERROR_4005 (arm64, stack_delta_invalid), losing the native frames below the VM.

Extract the interp CFA offset + base register from the interpreter region's stack
delta (info.Deltas covering luaInterp.Start) and use them in unwind_native_frame:
SP-based (sp+=param) on x86, FP-based (sp=fp+param) on arm64. Falls back to
LUAJIT_CFRAME_SPACE when unavailable. New LuaJITProcInfo.cframe_size_interp/interp_fp.
… ERROR_4012)

At the bottom of the Lua stack (diff<=2) the code only stepped over the C frame
(unwind_native_frame) for is_jit; for the interpreter it set next_unwinder=NATIVE
with state->pc still in the luajit-mapped interp region, so the native unwinder
re-entered luajit/failed -> ERROR_4012 and the C stack below the VM was dropped.
arm64 happened to take the FRAME_CP path (which pokes); amd64 (tarantool, where
the bottom Lua frame meets C here) took this path. Now poke for both.
…r handback)

The JIT native handback resumed 16 bytes short -> into the fiber stack/heap
instead of lua_pcall's return -> ERROR_4012, no native C stack. Root cause:
cframeSizeJIT = cframeSize(80) + 16, but 80 is OpenResty's VM gate cframe; tarantool's
lj_vm_pcall gate cframe is 96 (4 pushes + sub 56 + ret), which we already extract as
cframeSizeInterp. Use that base on SP-based (x86) builds: cframeSizeJIT = interp + 16 = 112.
Verified against the live fiber stack: lua_pcall's return address sits at new_sp+8.
arm64 (FP-based) keeps the arch constant; its JIT path isn't the hot path there.
…d LuaJIT hosts)

Lets coredump test cases probe binaries that statically link LuaJIT (e.g.
tarantool) during extraction, and persists the config into the test case so
the harness probes the same hosts on replay.
Core's transitional 4.0.5-pshopify3 build reports RUBY_VERSION=4.0.5,
but its exported ruby_description identifies source revision 21a2595676.
That revision is 31 commits after master_box change 99aac00 and is itself
the rb_thread_sched change, so it unambiguously carries both 4.0.6
layout shifts.

Treat only Ruby 4.0.5 at that exact description revision as using the
new layout. Stock 4.0.0-4.0.5 retain their original offsets, while 4.0.6+
continues using the upstream open-telemetry#1634 version gate. If the description is
missing or a pshopify rebuild changes revision, detection fails safe.

Assisted-By: devx/38cc1a8a-dd3e-41a2-8b65-28604cba42e1
Match the production Core profiling policy in the existing Ruby 3.4.7
YJIT coredump. Native resume after a JIT-owned Ruby stack is intentionally
unsupported and now emits ruby_jit_native_resume_unsupported when enabled;
setting skip_native_resume removes that expected diagnostic and preserves
the fixture's existing stack.

Assisted-By: devx/38cc1a8a-dd3e-41a2-8b65-28604cba42e1
…phat

fix(ruby): restore Core unwinding with version-safe offsets only
Bring PR #50 forward through upstream open-telemetry#1605, including dynamic trace
origins (open-telemetry#1607) and an isolated kprobe unwind record (open-telemetry#1605), which
the ProbeContext API requires. Preserve the current PR #50 Ruby 4.0.6
layout detection and coredump fixtures; regenerate support types and
both BPF blobs.

Assisted-By: devx/2d48a84f-4aab-48eb-9009-5dd68fa593f3
@dalehamel
dalehamel force-pushed the dale/offcpu-omnibus-upstream-sync branch from 7419022 to 9bfba71 Compare July 24, 2026 20:32
@dalehamel
dalehamel force-pushed the dale/luajit-omnibus branch from 7c16777 to 19aafba Compare August 13, 2026 22:41
@github-actions

Copy link
Copy Markdown

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Sep 11, 2026
@github-actions

Copy link
Copy Markdown

Closed as inactive. Feel free to reopen if this PR is still being worked on.

@github-actions github-actions Bot closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants