perf(ebpf): cache uprobe symbol lookups by binary identity - #330
Merged
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a symbol cache to optimize Node.js uprobe and uretprobe attachments by caching resolved symbol addresses and return offsets based on file identity rather than path, preventing expensive, repeated ELF parsing. The review feedback correctly points out that the global mutex symbolCacheMu is redundant because the underlying lru.Cache is already thread-safe, and suggests removing it to simplify the code and avoid unnecessary lock contention.
RamanKharchee
approved these changes
Sep 10, 2026
Attaching the Node.js probes read the full ELF symbol table on every pid.
node is a large statically linked binary with V8 embedded, and readSymbols
loads both .symtab and .dynsym — allocating a Go string per symbol name —
to locate six libuv functions. Each found symbol is then disassembled to
find its RET instructions for uretprobes.
None of that is per-process. The address and return offsets are pure
functions of (binary, symbol name), but nothing cached them:
instrumentNodejs is gated on a per-Process field, so every new pid repeated
the whole thing. Node cluster mode runs many workers off one binary, and
pod churn repeats it again.
A customer CPU profile showed the cost:
Process.instrumentNodejs -> AttachNodejsProbes
-> ELFFile.GetSymbol -> readSymbols 17.5% of a core
debug/elf.getSymbols64 11.2%
The negative case is worse than the positive one. Any executable reaching
this path that does not export uv__io_poll was re-parsed for every pid,
forever, to reach the same conclusion.
LookupSymbols caches ProbeTarget{address, returnOffsets} keyed by file
identity — dev+inode+size+mtime — not by path. Paths here are per-process
(/proc/<pid>/root/...), so a path-keyed cache would miss every time, which
is precisely the case being fixed; pods from one image share the read-only
overlay layer, so dev+inode collapses them onto one entry. Absent symbols
are cached too, so a non-Node binary is parsed once and never again.
Caching is per (binary, symbol) rather than one map per binary. Keying only
by binary and storing whichever names the first caller asked for would hand
a later caller that map and read its own symbols as absent — reachable,
since the Node.js and Go-TLS probes can target the same executable. A
partial hit resolves the remaining names in a single parse.
Attach behaviour is unchanged, including stopping at the first missing
libuv callback. ./ebpftracer is not excluded from CI tests, so the new
cases gate this.
mayankpande88
force-pushed
the
perf/cache-uprobe-symbol-lookups
branch
from
September 10, 2026 13:12
7e3ed37 to
8ae6afb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Attaching the Node.js probes reads the full ELF symbol table on every pid.
nodeis a large statically linked binary with V8 embedded.readSymbolsloads both.symtaband.dynsym— allocating a Go string per symbol name — to locate six libuv functions. Each found symbol is then disassembled to find its RET instructions for uretprobes.None of that work is per-process.
Address()andReturnOffsets()are pure functions of (binary, symbol name). But nothing cached them:instrumentNodejsis gated onp.nodejsChecked, a per-Processfield, so every new pid repeats the whole thing. Node cluster mode runs many workers off one binary; pod churn repeats it again.A customer CPU profile showed the cost:
The heap profile showed the matching allocation churn (
saferio.ReadData18 MB,debug/elf.getString).The negative case is worse than the positive one. Any executable reaching this path that doesn't export
uv__io_pollwas re-parsed for every pid, forever, to reach the same conclusion.Why this instrumentation exists at all
libuv's
uv__io_polland theuv__*_iocallbacks are internal C functions with no stable ABI, so their addresses can't be hardcoded — the symbol table is the only way to find them. They producecontainer_nodejs_event_loop_blocked_time_seconds_total, which is the primary Node.js health signal and isn't derivable from CPU/memory/latency. The metric is worth having; paying for it once per pid is not.Fix
LookupSymbolscachesProbeTarget{address, returnOffsets}keyed by file identity —dev + inode + size + mtime— not by path.Path-keying would miss every time: probe paths are per-process (
/proc/<pid>/root/usr/bin/node), so every pid produces a different string for the same file. Pods from one image share the read-only overlay layer, so dev+inode collapses them onto a single entry.Absent symbols are cached as
Found=false, so a non-Node binary is parsed once and never again — this is what removes the dominant negative case.Caching is per
(binary, symbol), not one map per binary. Keying only by binary and storing whichever names the first caller asked for would hand a later caller that map and cause it to read its own symbols as absent. That's reachable, not theoretical — the Node.js and Go-TLS probes can target the same executable. My first draft had exactly this bug and a test caught it. A partial hit resolves the remaining names in a single parse.Bounded at 4096 entries (LRU) so a node cycling image versions can't grow it without limit.
Behaviour
Unchanged, deliberately — including stopping at the first missing libuv callback rather than continuing. This is a pure performance change.
(Separately: that
breaklooks wrong — different libuv builds export different subsets, so one absent callback silently skips the rest. Left alone here so this PR stays measurable; worth its own look.)Testing
./ebpftraceris not in CI's test exclusion (only./containersis), so these run in CI.Not yet measured
The 17.5% figure is the before number from the customer profile. I have not measured the after — that needs a Node-heavy node, and our own clusters barely run Node. Worth capturing a
/debug/pprof/profileon such a node post-deploy to confirm the win rather than assuming it.tls.goandpython.gotake the same per-pid full-table path and would benefit from the same treatment; not touched here to keep this reviewable.