# uprobe vs USDT cost in Go: what one probe hit costs > [!summary] TL;DR > The kernel's uprobe benchmark (x86_64) prices a uprobe on a NOP at 313 ns per hit and a USDT site at 309 ns; a probe on a bare RET costs 913 ns. OBI skips uretprobes because Go stacks move, so it decodes each function and attaches a uprobe at every RET. USDT moves the work from the tool to the runtime; the trap costs the same per hit. ## Key points - OBI (OpenTelemetry eBPF Instrumentation) attaches one entry uprobe plus one uprobe per RET instruction, found by decoding the machine code with x86asm. - OBI skips uretprobes: "since go linkage is non-standard we can't use uretprobe" (v0.10.0, instructions.go). - Go stacks "grow, and therefore move" (Ian Lance Taylor, golang/go#22008). A return address the kernel rewrote into a stack that then moves points nowhere good. - Austin Clements on uretprobes (golang/go#27077): "It's not clear to me how the runtime (or anything that unwinds the stack) could account for this." - Kernel uprobe benchmark, x86_64, one producer thread, trivial BPF counter program, CPU not stated: no probe 152.507 M/s (6.6 ns), uprobe-nop 3.190 M/s (313 ns), usdt-nop 3.235 M/s (309 ns), uprobe-ret 1.095 M/s (913 ns). - Disabled probes are not free. PostgreSQL with --enable-dtrace evaluates probe arguments "whenever control passes through the macro, even if no tracing is being done". libbpf: a USDT "normally has zero overhead, unless it is being traced by some external entity". - OBI keeps a table of struct offsets: 59 types and 96 fields at v0.10.0 (1,510 lines), 91 types and 183 fields at v0.14.0 (2,746 lines), refreshed by a daily workflow. - One field, net/http.http2ClientConn.fr, sits at offset 208 in Go 1.17.0 and 368 from Go 1.26.0. - The entry probe can fire twice: when Go grows the stack, the function restarts, and OBI's eBPF code has to allow for it. - In OBI v0.14.0 the RET probes are batched into one attachment. - A USDT site puts a NOP in the function body and a note in .note.stapsdt carrying a provider, a name, the site address, and per argument a size and a location such as -4@%edi. - A USDT site is a plain uprobe in the body, so a moving stack cannot break it, and there are no struct offsets to track if the site's author computes the arguments. That last part is the author's opinion, based on what OBI carries today. - A probe name can outlive a refactor. The caveat: CPython's docs say "No guarantees are made about probe compatibility between versions of CPython". - Go has no native USDT today. The poc_usdt fork is an experiment; the larger idea is generic tracepoints the standard library exposes on purpose. - Neither way carries trace context from an incoming request to an outgoing call. That stays unsolved. ## The probe target and its disassembly The function under test uses //go:noinline so the compiler cannot fold it into main, leaving something left to probe: ```go package main import "fmt" //go:noinline func handle(n int) int { if n < 0 { return -1 } if n == 0 { return 0 } return len(fmt.Sprint(n)) } func main() { fmt.Println(handle(42)) } ``` Built for linux/amd64 with Go 1.27.1, go tool objdump shows the entry check and every RET, one per return in the source. The listing is trimmed (… marks what was cut); the rest is byte for byte what the tool printed: ```text TEXT main.handle(SB) ./main.go main.go:6 0x49a080 493b6610 CMPQ SP, 0x10(R14) main.go:6 0x49a084 765b JBE 0x49a0e1 … main.go:11 0x49a09c c3 RET … main.go:13 0x49a0d2 c3 RET … main.go:8 0x49a0e0 c3 RET … main.go:6 0x49a0e6 e8b52afeff CALL runtime.morestack_noctxt.abi0(SB) main.go:6 0x49a0f0 eb8e JMP main.handle(SB) ``` The entry is `CMPQ SP, 0x10(R14)`. R14 holds the current goroutine, so this compares the stack pointer with the goroutine's stack guard. If the stack is too small, JBE jumps to runtime.morestack, which grows the stack (a copy, so it moves) and restarts the function with JMP main.handle. Three RET instructions leave the body. ## How OBI probes Go functions today - The textbook return trap is a uretprobe: the kernel rewrites the return address on the stack so the function returns into a trampoline. That is what moving Go stacks break. - OBI decodes the function's machine code with x86asm, records every RET, and attaches a plain uprobe at each one (v0.10.0). For handle that means one entry probe and three return probes. - In v0.14.0 the same idea is still there, now batched into one attachment. - Reading a struct field from a probe needs its offset, and some offsets move between Go releases, so OBI keeps offsets.json current with a daily workflow. - The entry probe fires twice when the stack grows and the function restarts (v0.14.0 eBPF code; the talk pins v0.10.0). Count the exits yourself: ```bash GOOS=linux GOARCH=amd64 go build -trimpath -o handle main.go go tool objdump -s '^main.handle handle | grep -c RET ``` With Go 1.27.1 the count is 3. Addresses may differ on another toolchain. ## What one hit costs The kernel's BPF selftests carry a uprobe benchmark. Jiri Olsa's commit that added the USDT rows recorded one run on an x86_64 kernel: a probe hit from a single producer thread running a trivial BPF counter program. The CPU is not stated. In millions of hits per second: | Probe target | M/s | Per hit | | --- | --- | --- | | No probe, just the loop (`usermode-count`) | 152.507 | 6.6 ns | | `uprobe-nop` | 3.190 | 313 ns | | `usdt-nop` | 3.235 | 309 ns | | `uprobe-ret`, a probe on a bare `ret` | 1.095 | 913 ns | A USDT site is a NOP with the same trap put on it, so it costs what a uprobe on a NOP costs. The table has no row for a Go entry, which starts with CMPQ, not a NOP. The ret row is the slowest one; whether a Go RET lands in that regime was not measured. ## What a USDT site buys - A tool attaches with less work: it reads the address and argument locations from the note and does not disassemble anything. For handle that is one site instead of an entry probe plus three RET probes. - Go's moving stacks stop mattering, because a site in the body is a plain uprobe with no return hijack. - A name is a stable contract: a tool asking for net_http:server_request_start does not care where the compiler put it. The project has to keep its probes, and CPython's docs warn that no guarantees are made about compatibility between versions. - The price is the same trap per hit, so the case for USDT rests on who does the work: a tool's maintainers, again for every Go release, or a runtime, once, if it accepts the idea. - Go has none of this natively. The poc_usdt fork is an experiment; the idea argued for is generic tracepoints that the standard library exposes on purpose. USDT is one way to build them, and it needs acceptance from the Go runtime team. Versions, links and commands checked on 2 October 2026. ## FAQ ### How much does one uprobe hit cost? On the kernel's uprobe benchmark (x86_64, single producer thread), uprobe-nop ran at 3.190 M hits/s, 313 ns per hit. A probe on a bare ret ran at 1.095 M/s, 913 ns per hit. ### How much does a USDT probe cost per hit? 309 ns (3.235 M hits/s) for usdt-nop, the same trap as a uprobe on a NOP. The benchmark runs a trivial BPF counter program; the CPU is not stated. ### Why doesn't OBI use uretprobes on Go? A uretprobe rewrites the return address on the stack, but Go stacks "grow, and therefore move" (Ian Lance Taylor, golang/go#22008). OBI's code comment: "since go linkage is non-standard we can't use uretprobe". It attaches a plain uprobe at every RET instead. ### How many probes does OBI attach to one Go function? One entry uprobe plus one uprobe per RET instruction. handle gets four. In v0.14.0 the RET attachments are batched into one. ### Can the entry uprobe fire twice? Yes. When Go grows the stack, the function restarts, and OBI's eBPF code has to allow for the entry probe firing twice. ### Is a disabled USDT probe free? No. PostgreSQL's docs say probe arguments are evaluated "even if no tracing is being done". libbpf says a USDT "normally has zero overhead, unless it is being traced by some external entity". ### Does Go support USDT probes natively? No. The poc_usdt fork is an experiment, and the idea behind it is generic tracepoints the standard library would expose on purpose. ### How do I count the RET instructions in a Go function? go tool objdump -s '^main.handle handle | grep -c RET returned 3 with Go 1.27.1 on linux/amd64. Addresses may differ on another toolchain. ## Related - [[resources/documentation/Go/Instrumentation/Go Instrumentation|Go Instrumentation]] (series hub) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - OBI eBPF Auto-Instrumentation|OBI eBPF Auto-Instrumentation]] - [[resources/documentation/Go/Instrumentation/Go Instrumentation - otelc Compile-Time Instrumentation|otelc Compile-Time Instrumentation]] - [[resources/documentation/Go/Instrumentation/Go Instrumentation - Flight Recording and USDT|Flight Recording and USDT]] - [[resources/documentation/Go/Go - uretprobes|uretprobes]] - [[resources/documentation/Go/Go - Internals|Go Internals]] ## Sources - [How to Instrument Go Without Changing a Single Line of Code (GopherCon UK 2026, GoLab 2026)](https://kakkoyun.me/talks/instrument-go-without-changing-a-single-line/) - [R14 holds the current goroutine (cmd/compile abi-internal.md, go1.27.1)](https://github.com/golang/go/blob/go1.27.1/src/cmd/compile/abi-internal.md) - [Austin Clements on uretprobes (golang/go#27077)](https://github.com/golang/go/issues/27077#issuecomment-414460569) - [Ian Lance Taylor on moving Go stacks (golang/go#22008)](https://github.com/golang/go/issues/22008#issuecomment-331773872) - [OBI: "we can't use uretprobe" (instructions.go, v0.10.0)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/internal/goexec/instructions.go#L97) - [OBI decodes machine code with x86asm and records every RET (instructions_amd64.go, v0.10.0)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/internal/goexec/instructions_amd64.go#L25-L48) - [OBI attaches a plain uprobe at each RET (instrumenter.go, v0.10.0)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/pkg/ebpf/instrumenter.go#L644) - [OBI v0.14.0 batches the RET attachment (instrumenter.go)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/pkg/ebpf/instrumenter.go#L995) - [Entry probe fires twice on stack growth (go_obi_ctx.h, v0.14.0)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/bpf/gotracer/go_obi_ctx.h#L74-L76) - [OBI struct offsets table (offsets.json, v0.14.0)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/pkg/internal/goexec/offsets.json) - [OBI daily offsets workflow (update-offsets.yml, v0.14.0)](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.14.0/.github/workflows/update-offsets.yml) - [Jiri Olsa's commit adding USDT rows to the uprobe benchmark](https://github.com/torvalds/linux/commit/0c4fc6bd61054a9378bce149b3758f9b6e8fb5ab) - [PostgreSQL dynamic tracing docs (argument evaluation)](https://www.postgresql.org/docs/18/dynamic-trace.html) - [libbpf USDT overhead (usdt.c, v7.2)](https://github.com/torvalds/linux/blob/v7.2/tools/lib/bpf/usdt.c#L52-L54) - [libbpf USDT note format (usdt.c, v7.2)](https://github.com/torvalds/linux/blob/v7.2/tools/lib/bpf/usdt.c#L66-L105) - [CPython instrumentation docs (probe compatibility)](https://github.com/python/cpython/blob/v3.14.0/Doc/howto/instrumentation.rst#L27-L30)