Feature #21539
openExport symbols for external profiling of stripped shared Ruby builds
Description
Motivation¶
External profilers such as OpenTelemetry's eBPF profiler collect Ruby stacks from outside the Ruby process.
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.
Proposed exports¶
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.
Scope¶
This is a symbol-visibility request, not a request for a general out-of-process profiling API:
- No requirement to stabilize internal struct layouts or make these internals a supported C-extension API.
- No additional profiling stack, per-frame bookkeeping, or in-process profiling machinery.
- The profiler remains responsible for supporting version-specific layouts and interpreter/JIT behavior.
This would address the symbol-discovery obstacle for stripped distribution builds without taking on the broader API design discussed in #19119.
Related profiler discussion: https://github.com/open-telemetry/opentelemetry-ebpf-profiler/issues/202
Files
Updated by mame (Yusuke Endoh) about 1 year ago
- Related to Feature #19119: Add an interface for out-of-process profiling tools to access Ruby information added
Updated by ivoanjo (Ivo Anjo) about 1 year ago
+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".
Updated by dalehamel (Dale Hamel) 4 days ago
- 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
- Description updated (diff)
Updated by dalehamel (Dale Hamel) 4 days ago
Updated the description and subject to reduce the scope of this substantially to just exporting a few symbols
Updated by Eregon (Benoit Daloze) 4 days ago
+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).
Updated by Eregon (Benoit Daloze) 4 days ago
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).
Updated by dalehamel (Dale Hamel) 4 days ago
- File protected.patch protected.patch added
- File exported.patch exported.patch added
Summary¶
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.
Performance evaluation¶
Setup
- Ruby master
b709bdb3e37451bb1103b50fb280968dbf038103, ruby-bench13e09f746431f2159db478f15b80a2c7cd95aadb. - Ubuntu 24.04, GCC 13.3, default flags (
-O3). Configured with--enable-shared --enable-yjit --enable-zjit. - 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
protectedvisibility. - Each block ran every build once, each in a fresh process, with build order balanced across blocks.
lobstersused 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_coregrows 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.
Suggested implementation¶
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_execstay direct. vm_exec_corecompiles 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 toruby_global_symbolsgains one instruction: a GOT load on arm64, aleaafter linker relaxation on x86-64. - On x86-64, the
ruby_current_ecaccesses invm.cswitch from local-dynamic to general-dynamic TLS, with the same instruction count and the same__tls_get_addrcall.
- In
- The linker still emits a TLS relocation that names
ruby_current_ec, which is how profilers locate it in stripped builds.
The tested change (abbreviated):
--- a/vm.c
+++ b/vm.c
-VALUE vm_exec(rb_execution_context_t *);
+__attribute__((visibility("protected"))) VALUE vm_exec(rb_execution_context_t *);
@@
#ifdef RB_THREAD_LOCAL_SPECIFIER
-RB_THREAD_LOCAL_SPECIFIER rb_execution_context_t *ruby_current_ec;
+RUBY_SYMBOL_EXPORT_BEGIN
+__attribute__((visibility("protected"))) RB_THREAD_LOCAL_SPECIFIER rb_execution_context_t *ruby_current_ec;
+RUBY_SYMBOL_EXPORT_END
--- a/thread_pthread.h
+++ b/thread_pthread.h
- RUBY_EXTERN RB_THREAD_LOCAL_SPECIFIER struct rb_execution_context_struct *ruby_current_ec;
+ RUBY_SYMBOL_EXPORT_BEGIN
+ RUBY_EXTERN __attribute__((visibility("protected"))) RB_THREAD_LOCAL_SPECIFIER struct rb_execution_context_struct *ruby_current_ec;
+ RUBY_SYMBOL_EXPORT_END
--- a/symbol.c
+++ b/symbol.c
-rb_symbols_t ruby_global_symbols = {
+RUBY_SYMBOL_EXPORT_BEGIN
+__attribute__((visibility("protected"))) rb_symbols_t ruby_global_symbols = {
.next_id = tNEXT_ID,
};
+RUBY_SYMBOL_EXPORT_END
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
protectedisn't supported. Mach-O compilers warn about it and fall back to default visibility. - The
vm.cdefinition must be annotated as well as the header. On arm64 and ppc64,RB_THREAD_CURRENT_EC_NOINLINEskips thethread_pthread.hdeclaration. - Protected data is only a problem if an executable links directly against
ruby_global_symbolsand gets a copy relocation. Nothing outsidesymbol.creferences 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
Updated by alanwu (Alan Wu) about 13 hours ago
- Related to Bug #22396: Using rb_postponed_job_trigger causes YJIT/ZJIT compiled methods side exits added