Distribution packages commonly ship shared libruby but strip the full symbol table (.symtab) and debug information. Some of the symbols needed to discover Ruby's execution state are currently local symbols, so they
disappear when stripped. Public dynamic symbols in .dynsym survive normal stripping.
I'd like to narrow this proposal to making the necessary discovery symbols available in these stripped shared builds.
Please export these three existing symbols from shared libruby:
Symbol
Purpose
ruby_current_ec
Locate the sampled native thread's execution context through TLS, providing access to its Ruby stack.
rb_vm_exec
Identify the function's address range, where native stack unwinding should hand off to Ruby stack unwinding. The profiler does not call this function.
ruby_global_symbols
Resolve Ruby method IDs to names for methods implemented in C. Stack walking can work without this, but those method names become <unknown cfunc>.
The profiler also needs ruby_version, which is already exported and requires no change.
The requested outcome is that these symbols are available through .dynsym in installed shared builds, without requiring distributions to retain the full symbol table or users to install debug information just to locate them.
+1 this would be really cool as well for the Datadog profiler as we currently rely on access to internal headers and thus are together with rbspy/rbperf on the group of "needs to be updated whenever Ruby internals change".
Subject changed from Facilitate walking native and interpreter (and jit?) stacks from outside of the ruby process to Export symbols for external profiling of stripped shared Ruby builds
+1, this would make external profilers more reliable (there are several important advantages of external profilers, e.g. reduce a lot the observer effect, no need to change anything in the already-running program).
Datadog is shipping the OpenTelemetry's eBPF profiler and it needs this change to work for the common case of Ruby installations with symbols stripped (e.g. by the Linux distribution).
For clarity, it's also fine if these symbols get renamed/changed/etc, exporting them doesn't imply any future compatibility, it's just best-effort.
Without the export it's very difficult and hacky to get to them, which translates to cases that simply don't work based on how Ruby was installed (e.g. stripped or not).
Exporting the three symbols had no measurable cost on ruby-bench's lobsters, with or without ZJIT, on x86-64 or arm64. With default visibility, though, GCC compiles vm.c differently, which can move interpreter microbenchmarks on x86-64 by roughly ±5–9%. ELF protected visibility keeps the symbols in .dynsym while leaving the interpreter's generated code unchanged, so that's what I'd suggest.
Two dedicated cloud VMs: x86-64 (Intel Xeon Platinum 8581C, SMT disabled) and arm64 (Google Axion, Neoverse-V2). Benchmark processes were pinned to an isolated core.
Three builds: unmodified, exported with default visibility, and exported with ELF protected visibility.
Each block ran every build once, each in a fresh process, with build order balanced across blocks. lobsters used 18 blocks (15 warmup + 20 measured iterations); the microbenchmarks used 12.
Results are paired ratios of mean iteration time with 95% Student-t intervals. They are not adjusted for multiple comparisons.
lobsters (negative = faster than unmodified)
CPU
Mode
default visibility
protected
x86-64
interpreter
-0.58% [-1.04, -0.11]
-0.77% [-1.25, -0.28]
x86-64
ZJIT
-0.37% [-0.62, -0.12]
-0.11% [-0.28, +0.06]
arm64
interpreter
-1.06% [-2.19, +0.07]
-0.90% [-1.70, -0.09]
arm64
ZJIT
+0.40% [-0.86, +1.67]
+0.35% [-1.12, +1.84]
I wouldn't count the small interpreter speedups as a real benefit. They also appear with protected, whose vm_exec_core compiles identically to the unmodified build (only addresses differ), so they are most likely code layout effects.
With the patch, --disable-shared builds export these symbols too, because ruby is linked with -rdynamic. Their compiler output is essentially unchanged, and lobsters showed no difference (all point estimates within ±0.2%).
Microbenchmarks (interpreter)
With default visibility, GCC has to assume rb_vm_exec may be interposed, so it optimizes vm.c differently. The 14 internal calls and tail calls to rb_vm_exec also go through the PLT. On x86-64, vm_exec_core grows from 27,387 to 27,456 bytes and its stack frame grows by 32 bytes. On arm64 it grows from 26,184 to 26,512 bytes.
This affects some interpreter microbenchmarks on x86-64:
CPU
Benchmark
default visibility
protected
x86-64
fib
+5.29% [+4.47, +6.12]
+0.87% [-0.47, +2.23]
x86-64
cfunc_itself
-8.51% [-12.81, -4.00]
-2.75% [-7.55, +2.30]
The x86-64 fib regression executes only 0.49% more instructions. Hardware counters show it as front-end stalls: the front-end-bound share of pipeline slots rises from 3.2% to 12.5%, versus 4.4% with protected (one profiling run per build). That points to a layout change in the dispatch loop, not PLT or TLS overhead; fib essentially never reaches the PLT calls. On arm64, both microbenchmarks stay within about 1% for both variants, apart from one slow outlier process.
An independent x86-64 rebuild with GCC 13.4 and GCC 14.3 (nixpkgs, with different hardening flags) confirmed the same code generation pattern; timing wasn't repeated there:
The default-visibility vm_exec_core grows by 23 instructions with GCC 13.4 and 69 with GCC 14.3. The size of the change depends on the compiler.
Both compilers show the same 32-byte frame growth and the same 14 PLT sites.
protected visibility avoids the code generation change. On ELF the symbols still appear in .dynsym after strip --strip-all, listed as GLOBAL PROTECTED, but references inside libruby stay bound locally:
Calls to rb_vm_exec stay direct.
vm_exec_core compiles identically to the unmodified build, instruction for instruction. This was checked with GCC 13.3 on both architectures, and with GCC 13.4 and 14.3 on x86-64.
The remaining code differences are small:
In symbol.c, each access to ruby_global_symbols gains one instruction: a GOT load on arm64, a lea after linker relaxation on x86-64.
On x86-64, the ruby_current_ec accesses in vm.c switch from local-dynamic to general-dynamic TLS, with the same instruction count and the same __tls_get_addr call.
The linker still emits a TLS relocation that names ruby_current_ec, which is how profilers locate it in stripped builds.
The default-visibility variant I compared uses RUBY_FUNC_EXPORTED and RUBY_SYMBOL_EXPORT_BEGIN/END at the same four places.
Notes:
An upstream version would need a macro that falls back to the existing default-visibility export where protected isn't supported. Mach-O compilers warn about it and fall back to default visibility.
The vm.c definition must be annotated as well as the header. On arm64 and ppc64, RB_THREAD_CURRENT_EC_NOINLINE skips the thread_pthread.h declaration.
Protected data is only a problem if an executable links directly against ruby_global_symbols and gets a copy relocation. Nothing outside symbol.c references it in C; Ruby's own lldb helpers already find it by name. Profilers that look symbols up through ELF aren't affected, and TLS variables aren't copy-relocated.
All of this was measured with GCC; Clang wasn't tested. Full patches attached