tracer: load unwinder eBPF programs concurrently - #1840
Conversation
816a148 to
6dc0e5c
Compare
…oad) Point the go.opentelemetry.io/ebpf-profiler replace directive at alban_ig9 (38a8228), which is alban_ig8 plus a cherry-pick of the upstream proposal open-telemetry/opentelemetry-ebpf-profiler#1840: the unwinder eBPF programs (perf unwinders, probe unwinders and the off-CPU probe) are loaded concurrently through a bounded errgroup instead of one at a time, with rlimit.MaximizeMemlock() hoisted to wrap the whole load phase and all tail-call map updates kept sequential afterwards. Measured on this branch: - otel BenchmarkNewTracer (-benchtime=5x -count=10, benchstat): NewTracer-8 2037.8m -> 810.6m -60.22% (p=0.000 n=10) - ig run --timeout=1 trace_capabilities --collect-ustack --symbolizers=otel-ebpf-profiler, 10 runs: 4.326s -> 3.060s total (-29.3%); startup only -38.1% Correctness was checked with the same gadget invocation: 10561 events captured, all 10561 with a non-empty, fully unwound ustack, so the kprobe -> native_tracer_entry tail-call path is unaffected. gadgets/ci/stacks/test-integration fails identically before and after the bump (the Python interpreter frame is missing on this host's Python 3.14), i.e. a pre-existing failure and not a regression. examples/go.mod is updated at the same time; it previously pointed at af89924d87af, a dangling earlier force-push of the same alban_ig8 commit, despite the "keep in sync" comment. This commit is separable and can be dropped if the parallel loading change is not wanted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Alban Crequy <albancrequy@microsoft.com>
|
I added a second commit, Why there was anything left to gainLoading the programs concurrently makes this phase bounded by the single slowest program rather than by the total amount of work. The order the jobs are currently submitted in is just the order they happen to be built in, which is unrelated to cost. Every unwinder is loaded twice, once for perf and once for kprobe, and the two sets are concatenated by
The changeSort the jobs longest-first before submitting them, using the instruction count as the cost proxy. This is longest-processing-time-first scheduling. ResultsProgram load makespan, comparing the existing build order against the sorted order on the same binary (the ordering is switched at runtime so both arms are byte-identical), interleaved A/B runs,
Relative to the pre-PR sequential baseline of ~1.70s, the two commits together give:
The lower bound for this phase is On the cost proxyThe instruction count is deliberately coarse. It ranks If a more exact proxy is ever wanted, Measurement setupReproducing this on other hardware would be welcome, in particular on x86-64.
Per-program load times (mean of 3 runs at 8 CPUs)These are measured under 8-way contention, so they are upper bounds.
Total ≈ 1823ms across 27 programs. Unrelated observationWhile measuring this I noticed |
Pull request dashboard statusWaiting on the author · refreshed 2026-09-10 20:29 UTC Respond to 3 review items (e.g. link a commit, explain why not, ask a follow-up): Status above doesn't look right?
|
| @@ -718,10 +729,18 @@ func loadPerfUnwinders(coll *cebpf.CollectionSpec, ebpfProgs map[string]*cebpf.P | |||
There was a problem hiding this comment.
Since this seems to be dead code, can we safely remove it?
| // Hand over every program that did load, so that a partially failed load is | ||
| // still cleaned up by the caller. |
There was a problem hiding this comment.
Currently, no callers in the profiler deal with these failed loads, so the comment might be misleading here (this was also the case before, if I'm not mistaken).
If this is behavior we want to support for users, it should be explicit in LoadProbeUnwinders rather than here. Even then, I'm not sure we should make the user handle it instead of handling it directly in the code.
| // Hand over every program that did load, so that a partially failed load is | |
| // still cleaned up by the caller. | |
| // Register every program that did load, also on a partial failure, so the | |
| // loaded programs stay reachable for the caller. No current caller closes | |
| // them on error. |
There was a problem hiding this comment.
Good catch. main does the same: the loaded programs stay in ebpfProgs, which the caller discards, so they're only closed once the GC runs cilium/ebpf's fd cleanup. Rather than document that, I think it is better if loadPrograms closes them on failure and hands over nothing; safe there because the tail call maps haven't been updated yet. I updated the PR.
Signed-off-by: Alban Crequy <albancrequy@microsoft.com> Assisted-by: Claude Opus 5
Signed-off-by: Alban Crequy <albancrequy@microsoft.com> Assisted-by: Claude Opus 5
f08421c to
7660950
Compare
florianl
left a comment
There was a problem hiding this comment.
I think, this change would cause issues atm originating from (*ProbeContext) CollectionSpecWith(...) and the use of *cebpf.ProgramSpec.Copy() and *cebpf.MapSpec.Copy(), as these XYZ.Copy() functions only create shallow copies. Modifying/rewriting one might causes issues in another. Currently, this is not an issue, as elements are loaded sequentually.
| continue | ||
| } | ||
| if firstErr == nil { | ||
| logLoadError(job.err) |
There was a problem hiding this comment.
Maybe that is a personal preference, but I tend to look for getting all errors instead of just the first one. If subsequent errors are hidden, one might miss something in the whole picture/situation.
|
|
||
| if err = loadPrograms(append(perfJobs, probeJobs...), cfg.BPFVerifierLogLevel, | ||
| ebpfProgs); err != nil { | ||
| return nil, nil, nil, fmt.Errorf("failed to load unwinder eBPF programs: %v", err) |
There was a problem hiding this comment.
Here we cause a resource leakage if some programs load but others fail. In this case, some error is returned, OTel collector might keep running, depending on ErrorMode, and partial programs and maps are being kept loaded.
Problem
Starting the tracer is dominated by loading the unwinder eBPF programs into the
kernel. Measured on an 8 vCPU arm64 VM, of the ~2.02s spent in
NewTracer(),~1.84s (91%) is spent loading eBPF programs:
kallsyms.NewSymbolizerLoadCollectionSpecloadRodataVarsloadAllMapsrewriteMapsloadPerfUnwindersloadProbeUnwinderspmebpf.LoadMaps+pm.NewNewTracertotalThe programs are loaded one at a time, and each load blocks in the kernel
verifier. Verification of one program does not depend on any other program, so
the loads can run concurrently.
Change
Split the collection of the programs to load from the loading itself:
perfUnwinderJobs()andprobeUnwinderJobs()only prepare the (alreadyexisting) per-program work, including the probe specific instruction rewrites.
They do not touch the kernel.
loadPrograms()then verifies the collected jobs with a boundederrgroup, limited toGOMAXPROCS. Everything that mutates shared state,registering the programs in
ebpfProgsand updating the tail call maps, stayssequential and happens once all programs are loaded.
The perf and the probe unwinders refer to disjoint sets of program
specifications (
"perf_"and"kprobe_"prefixed), and the probe unwinders onlydepend on maps that are already loaded at that point, not on the loaded perf
programs. Their jobs are therefore collected first and loaded together, so the
two groups are verified concurrently as well.
rlimit.MaximizeMemlock()was called per program. It changes a process wideresource limit and restores it afterwards, so it must not run while other
programs are being loaded. It is now called once for the whole loading phase.
loadPerfUnwinders()andloadProbeUnwinders()keep their signatures; thelatter is still used to load probes at runtime from
probes.go.Program load errors are no longer logged from the loading goroutines. A verifier
error is hundreds of lines long and is logged line by line, so logging it
concurrently would interleave several of them into an unreadable mess. Each job
records its error instead, and once all loads have finished the failures are
reported in job order, so the error that is returned does not depend on the order
in which the loads happened to complete. A single unsupported instruction usually
makes every program fail, so only the first failure is logged in full and the
others are summarized; the sequential loader used to stop at the first failure
and so only ever printed one log.
Results
Adds
BenchmarkNewTracer(tracer/ebpf_integration_test.go), the first benchmarkcovering tracer startup. Run with:
benchstatover 10 runs each, 8 vCPU arm64 VM:The program loading phase itself goes from ~1.84s to ~0.60s (~3.0x). The
end-to-end number is lower than that because
NewTraceralso does work thischange does not touch (
NewSymbolizer, map creation).A note on profiling this code path
The Go CPU profiler cannot be used to measure eBPF program loading: cilium/ebpf
masks
SIGPROFaround the program load syscall (maskProfilerSignal()ininternal/sys), so the verifier time is invisible to the profiler. A CPU profileof this benchmark only samples ~23% of the wall time and attributes a large
share to
unmaskProfilerSignal, which is an artifact. The numbers above arewall-clock measurements.
Testing
go vet -tags integration ./tracer/andgofmtare clean.sudo go test -tags integration -count=1 ./tracer/...passes, including theeBPF integration tests
TestTracerErrorPropagation,TestTracerMapMonitorsError,TestTraceTransmissionAndParsingandTestAllTracers.-race, exercising the concurrentloading path.
AI disclosure
The commit carries an
Assisted-by:trailer as required by the OpenTelemetryGenAI policy (https://github.com/open-telemetry/community/blob/main/policies/genai.md).
AIL:3
("AI Created, Human Full Structure" on the AI Influence Level scale,
https://danielmiessler.com/blog/ai-influence-level-ail: the problem statement,
the constraints, the measurement methodology and the review of each hypothesis
were mine; the implementation and the benchmark were written by the model within
that structure. I have read, understood and tested the result, and I take
responsibility for it.)