Skip to content

Commit 6dc0e5c

Browse files
committed
tracer: load unwinder eBPF programs concurrently
Signed-off-by: Alban Crequy <albancrequy@microsoft.com> Assisted-by: Claude Opus 5
1 parent e0d247f commit 6dc0e5c

2 files changed

Lines changed: 218 additions & 56 deletions

File tree

tracer/ebpf_integration_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package tracer_test
77

88
import (
99
"context"
10+
"log/slog"
1011
"os"
1112
"runtime"
1213
"slices"
@@ -22,6 +23,7 @@ import (
2223

2324
"go.opentelemetry.io/otel/metric/noop"
2425

26+
"go.opentelemetry.io/ebpf-profiler/internal/log"
2527
"go.opentelemetry.io/ebpf-profiler/interpreter/interpreterconfig"
2628
"go.opentelemetry.io/ebpf-profiler/libpf"
2729
"go.opentelemetry.io/ebpf-profiler/metrics"
@@ -288,3 +290,36 @@ func TestAllTracers(t *testing.T) {
288290
})
289291
}
290292
}
293+
294+
// BenchmarkNewTracer measures the cost of the whole tracer startup path,
295+
// which is dominated by loading eBPF maps and programs into the kernel.
296+
//
297+
// Run with:
298+
//
299+
// sudo go test -tags integration -run='^$' -bench=BenchmarkNewTracer \
300+
// -benchtime=5x -count=10 ./tracer/
301+
func BenchmarkNewTracer(b *testing.B) {
302+
// Keep the benchmark output parseable by benchstat.
303+
log.SetLevel(slog.LevelError)
304+
305+
ctx := b.Context()
306+
307+
for b.Loop() {
308+
tr, err := tracer.NewTracer(ctx, &tracer.Config{
309+
Intervals: &mockIntervals{},
310+
InterpretersConfig: interpreterconfig.AllInterpreters(),
311+
FilterErrorFrames: false,
312+
SamplesPerSecond: 20,
313+
MapScaleFactor: 0,
314+
KernelVersionCheck: true,
315+
BPFVerifierLogLevel: 0,
316+
ProbabilisticInterval: 100,
317+
ProbabilisticThreshold: 100,
318+
})
319+
require.NoError(b, err)
320+
321+
b.StopTimer()
322+
tr.Close()
323+
b.StartTimer()
324+
}
325+
}

tracer/tracer.go

Lines changed: 183 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/cilium/ebpf/features"
2727
"github.com/cilium/ebpf/link"
2828
"github.com/elastic/go-perf"
29+
"golang.org/x/sync/errgroup"
2930

3031
"go.opentelemetry.io/ebpf-profiler/internal/linux"
3132
"go.opentelemetry.io/ebpf-profiler/internal/log"
@@ -453,19 +454,29 @@ func initializeMapsAndPrograms(kmod *kallsyms.Module, cfg *Config, origins *orig
453454
}
454455
}
455456

456-
if err = loadPerfUnwinders(coll, ebpfProgs, ebpfMaps["perf_progs"], tailCallProgs,
457-
cfg.BPFVerifierLogLevel); err != nil {
458-
return nil, nil, nil, fmt.Errorf("failed to load perf eBPF programs: %v", err)
457+
// The perf and the probe unwinders refer to disjoint sets of program
458+
// specifications ("perf_" and "kprobe_" prefixed), and the probe unwinders only
459+
// depend on already loaded maps, not on the loaded perf programs. Collect the load
460+
// jobs of both so that all of them are verified by the kernel concurrently.
461+
perfJobs, err := perfUnwinderJobs(coll, ebpfMaps["perf_progs"], tailCallProgs)
462+
if err != nil {
463+
return nil, nil, nil, fmt.Errorf("failed to prepare perf eBPF programs: %v", err)
459464
}
460465

461466
// Load the tail call destinations so custom probes can use it.
462-
// loadProbeUnwinders repoints the probe unwinder's per_cpu_records references
467+
// probeUnwinderJobs repoints the probe unwinder's per_cpu_records references
463468
// to per_cpu_records_kp so a perf sampler can't clobber an in-flight uprobe unwind;
464469
// the perf unwinder keeps per_cpu_records.
465-
if err = loadProbeUnwinders(coll, ebpfProgs, ebpfMaps["kprobe_progs"], tailCallProgs,
466-
cfg.BPFVerifierLogLevel, ebpfMaps["perf_progs"].FD(),
467-
ebpfMaps["per_cpu_records"].FD(), ebpfMaps["per_cpu_records_kp"]); err != nil {
468-
return nil, nil, nil, fmt.Errorf("failed to load kprobe eBPF programs: %v", err)
470+
probeJobs, err := probeUnwinderJobs(coll, ebpfMaps["kprobe_progs"], tailCallProgs,
471+
ebpfMaps["perf_progs"].FD(), ebpfMaps["per_cpu_records"].FD(),
472+
ebpfMaps["per_cpu_records_kp"])
473+
if err != nil {
474+
return nil, nil, nil, fmt.Errorf("failed to prepare kprobe eBPF programs: %v", err)
475+
}
476+
477+
if err = loadPrograms(append(perfJobs, probeJobs...), cfg.BPFVerifierLogLevel,
478+
ebpfProgs); err != nil {
479+
return nil, nil, nil, fmt.Errorf("failed to load unwinder eBPF programs: %v", err)
469480
}
470481

471482
if err = removeTemporaryMaps(ebpfMaps); err != nil {
@@ -718,10 +729,18 @@ func loadPerfUnwinders(coll *cebpf.CollectionSpec, ebpfProgs map[string]*cebpf.P
718729
tailcallMap *cebpf.Map, tailCallProgs []ProgLoaderHelper,
719730
bpfVerifierLogLevel uint32,
720731
) error {
721-
programOptions := cebpf.ProgramOptions{
722-
LogLevel: cebpf.LogLevel(bpfVerifierLogLevel),
732+
jobs, err := perfUnwinderJobs(coll, tailcallMap, tailCallProgs)
733+
if err != nil {
734+
return err
723735
}
736+
return loadPrograms(jobs, bpfVerifierLogLevel, ebpfProgs)
737+
}
724738

739+
// perfUnwinderJobs collects the load jobs for all perf eBPF programs and their tail
740+
// call targets. It only prepares the jobs, it does not load anything into the kernel.
741+
func perfUnwinderJobs(coll *cebpf.CollectionSpec, tailcallMap *cebpf.Map,
742+
tailCallProgs []ProgLoaderHelper,
743+
) ([]loadJob, error) {
725744
progs := make([]ProgLoaderHelper, len(tailCallProgs)+3)
726745
copy(progs, tailCallProgs)
727746

@@ -743,6 +762,7 @@ func loadPerfUnwinders(coll *cebpf.CollectionSpec, ebpfProgs map[string]*cebpf.P
743762
Enable: true,
744763
})
745764

765+
jobs := make([]loadJob, 0, len(progs))
746766
for _, unwindProg := range progs {
747767
if !unwindProg.Enable {
748768
continue
@@ -755,16 +775,18 @@ func loadPerfUnwinders(coll *cebpf.CollectionSpec, ebpfProgs map[string]*cebpf.P
755775

756776
progSpec, ok := coll.Programs[unwindProgName]
757777
if !ok {
758-
return fmt.Errorf("program %s does not exist", unwindProgName)
778+
return nil, fmt.Errorf("program %s does not exist", unwindProgName)
759779
}
760780

761-
if err := loadProgram(ebpfProgs, tailcallMap, unwindProg.ProgID, progSpec,
762-
programOptions, unwindProg.NoTailCallTarget); err != nil {
763-
return err
764-
}
781+
jobs = append(jobs, loadJob{
782+
progID: unwindProg.ProgID,
783+
progSpec: progSpec,
784+
tailcallMap: tailcallMap,
785+
noTailCallTarget: unwindProg.NoTailCallTarget,
786+
})
765787
}
766788

767-
return nil
789+
return jobs, nil
768790
}
769791

770792
// progArrayReferences returns a list of instructions which load a specified tail
@@ -795,10 +817,21 @@ func loadProbeUnwinders(coll *cebpf.CollectionSpec, ebpfProgs map[string]*cebpf.
795817
bpfVerifierLogLevel uint32, perfTailCallMapFD int,
796818
perCPURecordsFD int, perCPURecordsKprobeMap *cebpf.Map,
797819
) error {
798-
programOptions := cebpf.ProgramOptions{
799-
LogLevel: cebpf.LogLevel(bpfVerifierLogLevel),
820+
jobs, err := probeUnwinderJobs(coll, tailcallMap, progs, perfTailCallMapFD,
821+
perCPURecordsFD, perCPURecordsKprobeMap)
822+
if err != nil {
823+
return err
800824
}
825+
return loadPrograms(jobs, bpfVerifierLogLevel, ebpfProgs)
826+
}
801827

828+
// probeUnwinderJobs rewrites the probe program specifications and collects their load
829+
// jobs. It only prepares the jobs, it does not load anything into the kernel.
830+
func probeUnwinderJobs(coll *cebpf.CollectionSpec, tailcallMap *cebpf.Map,
831+
progs []ProgLoaderHelper, perfTailCallMapFD int,
832+
perCPURecordsFD int, perCPURecordsKprobeMap *cebpf.Map,
833+
) ([]loadJob, error) {
834+
jobs := make([]loadJob, 0, len(progs))
802835
for _, unwindProg := range progs {
803836
if !unwindProg.Enable {
804837
continue
@@ -811,79 +844,173 @@ func loadProbeUnwinders(coll *cebpf.CollectionSpec, ebpfProgs map[string]*cebpf.
811844

812845
progSpec, ok := coll.Programs[unwindProgName]
813846
if !ok {
814-
return fmt.Errorf("program %s does not exist", unwindProgName)
847+
return nil, fmt.Errorf("program %s does not exist", unwindProgName)
815848
}
816849

817850
// Replace the prog array for the tail calls.
818851
insns := progArrayReferences(perfTailCallMapFD, progSpec.Instructions)
819852
for _, ins := range insns {
820853
if err := progSpec.Instructions[ins].AssociateMap(tailcallMap); err != nil {
821-
return fmt.Errorf("failed to rewrite map ptr: %v", err)
854+
return nil, fmt.Errorf("failed to rewrite map ptr: %v", err)
822855
}
823856
}
824857

825858
// Repoint per_cpu_records to the probe unwinder's own record map.
826859
recInsns := progArrayReferences(perCPURecordsFD, progSpec.Instructions)
827860
for _, ins := range recInsns {
828861
if err := progSpec.Instructions[ins].AssociateMap(perCPURecordsKprobeMap); err != nil {
829-
return fmt.Errorf("failed to rewrite per_cpu_records ptr: %v", err)
862+
return nil, fmt.Errorf("failed to rewrite per_cpu_records ptr: %v", err)
830863
}
831864
}
832865

833-
if err := loadProgram(ebpfProgs, tailcallMap, unwindProg.ProgID, progSpec,
834-
programOptions, unwindProg.NoTailCallTarget); err != nil {
835-
return err
836-
}
866+
jobs = append(jobs, loadJob{
867+
progID: unwindProg.ProgID,
868+
progSpec: progSpec,
869+
tailcallMap: tailcallMap,
870+
noTailCallTarget: unwindProg.NoTailCallTarget,
871+
})
837872
}
838873

839-
return nil
874+
return jobs, nil
840875
}
841876

842-
// loadProgram loads an eBPF program from progSpec and populates the related maps.
843-
func loadProgram(ebpfProgs map[string]*cebpf.Program, tailcallMap *cebpf.Map,
844-
progID uint32, progSpec *cebpf.ProgramSpec, programOptions cebpf.ProgramOptions,
845-
noTailCallTarget bool,
877+
// loadJob describes a single eBPF program that needs to be loaded into the kernel.
878+
type loadJob struct {
879+
// progID is the tail call map index of the program, unused if noTailCallTarget.
880+
progID uint32
881+
// progSpec is the specification of the program to load. Each job refers to a
882+
// distinct spec, so jobs can be verified concurrently.
883+
progSpec *cebpf.ProgramSpec
884+
// tailcallMap is the prog array the loaded program is registered in.
885+
tailcallMap *cebpf.Map
886+
// noTailCallTarget indicates the program is not the destination of a tail call.
887+
noTailCallTarget bool
888+
// prog is the loaded program, populated by loadPrograms.
889+
prog *cebpf.Program
890+
// err is the error returned by the kernel, populated by loadPrograms.
891+
err error
892+
}
893+
894+
// loadPrograms loads the given eBPF programs into the kernel and populates the related
895+
// maps.
896+
//
897+
// Loading a program is dominated by the time the kernel spends in the verifier, and each
898+
// program is verified independently of the others, so the programs are loaded
899+
// concurrently. Everything that mutates shared state (registering the programs and
900+
// updating the tail call maps) is done sequentially once all programs are loaded.
901+
func loadPrograms(jobs []loadJob, bpfVerifierLogLevel uint32,
902+
ebpfProgs map[string]*cebpf.Program,
846903
) error {
904+
if len(jobs) == 0 {
905+
return nil
906+
}
907+
908+
programOptions := cebpf.ProgramOptions{
909+
LogLevel: cebpf.LogLevel(bpfVerifierLogLevel),
910+
}
911+
912+
// Raise the memlock rlimit once for all programs. It is a process wide resource
913+
// limit, so it must not be modified while other programs are being loaded.
847914
restoreRlimit, err := rlimit.MaximizeMemlock()
848915
if err != nil {
849916
return fmt.Errorf("failed to adjust rlimit: %v", err)
850917
}
851918
defer restoreRlimit()
852919

853-
// Load the eBPF program into the kernel. If no error is returned,
854-
// the eBPF program can be used/called/triggered from now on.
855-
unwinder, err := cebpf.NewProgramWithOptions(progSpec, programOptions)
856-
if err != nil {
857-
// These errors tend to have hundreds of lines (or more),
858-
// so we print each line individually.
859-
if ve, ok := err.(*cebpf.VerifierError); ok {
860-
for _, line := range ve.Log {
861-
log.Errorf("%s", line)
862-
}
863-
} else {
864-
scanner := bufio.NewScanner(strings.NewReader(err.Error()))
865-
for scanner.Scan() {
866-
log.Errorf("%s", scanner.Text())
920+
eg := &errgroup.Group{}
921+
eg.SetLimit(min(runtime.GOMAXPROCS(0), len(jobs)))
922+
for i := range jobs {
923+
job := &jobs[i]
924+
eg.Go(func() error {
925+
// Load the eBPF program into the kernel. If no error is returned,
926+
// the eBPF program can be used/called/triggered from now on.
927+
prog, err := cebpf.NewProgramWithOptions(job.progSpec, programOptions)
928+
if err != nil {
929+
// The error is only recorded here and reported once all loads
930+
// have finished: verifier errors are hundreds of lines long and
931+
// are logged line by line, so logging them from several
932+
// goroutines would interleave them into an unreadable mess.
933+
job.err = err
934+
return err
867935
}
868-
}
869-
return fmt.Errorf("failed to load %s", progSpec.Name)
936+
job.prog = prog
937+
return nil
938+
})
870939
}
871-
ebpfProgs[progSpec.Name] = unwinder
940+
loadErr := eg.Wait()
872941

873-
if noTailCallTarget {
874-
return nil
942+
// Hand over every program that did load, so that a partially failed load is
943+
// still cleaned up by the caller.
944+
for i := range jobs {
945+
if jobs[i].prog != nil {
946+
ebpfProgs[jobs[i].progSpec.Name] = jobs[i].prog
947+
}
875948
}
876-
fd := uint32(unwinder.FD())
877-
if err := tailcallMap.Update(unsafe.Pointer(&progID), unsafe.Pointer(&fd),
878-
cebpf.UpdateAny); err != nil {
879-
// Every eBPF program that is loaded within loadUnwinders can be the
880-
// destination of a tail call of another eBPF program. If we can not update
881-
// the eBPF map that manages these destinations our unwinding will fail.
882-
return fmt.Errorf("failed to update tailcall map: %v", err)
949+
if loadErr != nil {
950+
return reportLoadErrors(jobs)
951+
}
952+
953+
for i := range jobs {
954+
job := &jobs[i]
955+
if job.noTailCallTarget {
956+
continue
957+
}
958+
fd := uint32(job.prog.FD())
959+
if err := job.tailcallMap.Update(unsafe.Pointer(&job.progID), unsafe.Pointer(&fd),
960+
cebpf.UpdateAny); err != nil {
961+
// Every eBPF program that is loaded within loadUnwinders can be the
962+
// destination of a tail call of another eBPF program. If we can not update
963+
// the eBPF map that manages these destinations our unwinding will fail.
964+
return fmt.Errorf("failed to update tailcall map: %v", err)
965+
}
883966
}
884967
return nil
885968
}
886969

970+
// reportLoadErrors logs the failures recorded by loadPrograms and returns the error
971+
// of the first job that failed, in job order, so that the reported error does not
972+
// depend on the order in which the loads happened to complete.
973+
//
974+
// A single unsupported instruction usually makes every program fail, and the kernel
975+
// log of one failure is already hundreds of lines long, so only the first one is
976+
// logged in full and the others are summarized.
977+
func reportLoadErrors(jobs []loadJob) error {
978+
var firstErr error
979+
var alsoFailed []string
980+
for i := range jobs {
981+
job := &jobs[i]
982+
if job.err == nil {
983+
continue
984+
}
985+
if firstErr == nil {
986+
logLoadError(job.err)
987+
firstErr = fmt.Errorf("failed to load %s", job.progSpec.Name)
988+
continue
989+
}
990+
alsoFailed = append(alsoFailed, job.progSpec.Name)
991+
}
992+
if len(alsoFailed) > 0 {
993+
log.Errorf("%d other eBPF programs failed to load: %s",
994+
len(alsoFailed), strings.Join(alsoFailed, ", "))
995+
}
996+
return firstErr
997+
}
998+
999+
// logLoadError logs a program load error. These errors tend to have hundreds of
1000+
// lines (or more), so we print each line individually.
1001+
func logLoadError(err error) {
1002+
if ve, ok := errors.AsType[*cebpf.VerifierError](err); ok {
1003+
for _, line := range ve.Log {
1004+
log.Errorf("%s", line)
1005+
}
1006+
return
1007+
}
1008+
scanner := bufio.NewScanner(strings.NewReader(err.Error()))
1009+
for scanner.Scan() {
1010+
log.Errorf("%s", scanner.Text())
1011+
}
1012+
}
1013+
8871014
// enableEvent removes the entry of given eventType from the inhibitEvents map
8881015
// so that the eBPF code will send the event again.
8891016
func (t *Tracer) enableEvent(eventType int) {

0 commit comments

Comments
 (0)