Issue #21539 has been updated by dalehamel (Dale Hamel). File protected.patch added File 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-bench `13e09f746431f2159db478f15b80a2c7cd95aadb`. - 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 `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. ## 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_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 tested change (abbreviated): ```diff --- 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 `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 ---------------------------------------- Feature #21539: Export symbols for external profiling of stripped shared Ruby builds https://bugs.ruby-lang.org/issues/21539#change-119528 * Author: dalehamel (Dale Hamel) * Status: Open ---------------------------------------- ## Motivation External profilers such as [OpenTelemetry's eBPF profiler](https://github.com/open-telemetry/opentelemetry-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-------------------------------- protected.patch (1.55 KB) exported.patch (1.21 KB) -- https://bugs.ruby-lang.org/