Skip to content

runtime: support native CPU profiling - #2339

Open
zhouguangyuan0718 wants to merge 3 commits into
xgo-dev:mainfrom
zhouguangyuan0718:codex/cpu-profile-20260816
Open

runtime: support native CPU profiling#2339
zhouguangyuan0718 wants to merge 3 commits into
xgo-dev:mainfrom
zhouguangyuan0718:codex/cpu-profile-20260816

Conversation

@zhouguangyuan0718

@zhouguangyuan0718 zhouguangyuan0718 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • implement native CPU sampling with ITIMER_PROF/SIGPROF on Darwin and Linux amd64/arm64
  • capture interrupted PC/frame-pointer stacks into a fixed async-signal-safe ring and emit the raw record stream expected by runtime/pprof
  • preserve and restore the process SIGPROF disposition, coordinating profiler ownership with the libuv-backed os/signal watcher across Notify, Stop, Ignore, and Reset
  • keep Go-compatible semantics while profiling: SIGPROF is reserved for the profiler, while an existing signal.Notify(SIGPROF) registration is restored after profiling stops
  • support clean stop, buffer draining, lost-sample records, repeated profiles, and goroutine/pthread workloads
  • replace the empty-profile smoke test with validation that a real hot function was sampled

Validation

  • go test -vet=off ./test/std/runtime/pprof ./test/std/os/signal
  • Darwin arm64: all new SIGPROF lifecycle and lost-sample tests; full LLGo runtime/pprof and os/signal compatibility packages; lifecycle tests repeated five times
  • Linux arm64 with LLVM 19: the same lifecycle/lost-sample tests and both full LLGo compatibility packages
  • Darwin amd64/arm64 and Linux amd64/arm64 C compilation checks with warnings as errors
  • go tool pprof -top parsed LLGo profiles on Darwin and Linux; the synthetic hot loop accounted for 93.75% and 95% flat CPU respectively

Scope

This PR covers native executables on Darwin/Linux amd64/arm64. Profile labels, inline-frame expansion, richer C-stack unwinding, and c-archive/c-shared integration remain follow-up work.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: CPU profiling for native LLGo executables

This is a well-engineered change. The separation of the async-signal-unsafe sampling path (C, profile.c) from the Go drain path is sound, the ring buffer uses correct atomic/lock discipline (handler uses non-blocking try_lock only, so no self-deadlock; double-checks active after acquiring the lock to race correctly against stop()), errno is saved/restored on every entry point per the existing fault.c convention, and the pprof record stream format matches runtime/pprof.readProfile (header {3,0,hz}, sample {3+n,0,1,pcs...}, lost {4,0,0,lost} — all verified against upstream addCPUData). The frame-pointer walk is defensively bounded (alignment, llgo_mem_readable probe, monotonic FP stride, ret < 4096 guard).

Findings are inline. Highest priority: the msync-per-frame probe inside the SIGPROF handler, and confirming the case 0 retry contract.

Other notes (no reliable inline location)

  • Test platform gating (test/std/runtime/pprof/pprof_test.go): the new tests assert the profile contains cpuProfileHotLoop. On platforms where SetCPUProfileRate is the no-op stub (wasm/baremetal/unsupported arch), readProfile returns eof=true with only the period record, so requireCPUProfileContains will fail. If CI only runs these on darwin/linux amd64/arm64 this is fine; otherwise consider a matching //go:build constraint or t.Skip. The tests are also timing-dependent (500ms/300ms hot loops) — a documented flakiness expectation or testing.Short() guard may help on loaded CI.
  • Magic numbers (cpuprof_sigprof_llgo.go): the drain cap 256 (appears twice) and preallocation sizes 1024/16 are unexplained. Consider named constants tied to LLGO_PROF_SAMPLES with a one-line rationale.

while (fp != 0 && sample->n < LLGO_PROF_STACK) {
uintptr_t prev, ret;
if ((fp & (word - 1)) != 0 ||
!llgo_mem_readable((void *)fp) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] msync() per frame in SIGPROF handler: signal-unsafe + costly

The SIGPROF handler's frame walk calls llgo_mem_readable() up to twice per frame, and each call issues an msync(page, 1, MS_ASYNC) syscall (see fault.c:112).

Signal-safety: msync is not on the POSIX signal-safety(7) async-signal-safe list. SIGPROF interrupts arbitrary code, so this is technically undefined behavior. In practice msync is a thin syscall wrapper and the kernel VMA lock is not held by the interrupted user thread, so real-world risk is low — but the file header comment (lines 3-5) claims the handler does not call into Go while being silent on this syscall dependency. Worth documenting as a deliberate trade-off.

Performance: with LLGO_PROF_STACK == 64, a single sample can execute up to ~128 syscalls, all inside the signal handler. At the default 100 Hz that is up to ~12,800 syscalls/sec purely for readability probes, scaling with stack depth and with hz (the API allows up to 1,000,000). The profiler thus perturbs exactly the deep-stack CPU-bound workloads it is meant to measure. Unlike fault.c, which probes once on a dying path, this runs on every steady-state tick. Consider caching the last known-good readable page range across frames within a walk, or a lower sampling frame cap.

Also: there is a TOCTOU gap between the llgo_mem_readable check and the *(uintptr_t*)fp dereference — a concurrent munmap/mremap could still fault. The window is tiny and inherent to unsynchronized frame walking, but a fault here (delivered while SIGPROF processing is active) risks recursing rather than recovering.

cpuProfilePeriodRecord[2] = uint64(hz)
latomic.StoreUint32(&cpuProfilePeriodPending, 1)
latomic.StoreInt32(&cpuProfileRate, int32(hz))
case 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] case 0 clears cpuProfileOpen while old samples may remain in ring

The comment says "Samples from a stopped profile have not been drained yet," yet this path stores cpuProfileOpen = 0, re-permitting a fresh StartCPUProfile immediately. On the C side llgo_cpu_profile_start returns 0 precisely when read_index != write_index (ring not drained) and does not reset the ring indices. If a caller retries StartCPUProfile, the next successful start would begin with leftover samples from the previous profile still in the ring, and those stale PCs would be attributed to the new profile.

Please confirm the intended contract: either keep cpuProfileOpen held until the drain completes, or have the retry path discard the residual ring. At minimum, expand the comment to explain why clearing cpuProfileOpen here is safe despite undrained samples.

@@ -0,0 +1,15 @@
//go:build (darwin || linux) && (baremetal || (!amd64 && !arm64))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] readProfile linkname not defined for wasm builds

The //go:linkname runtime_pprof_readProfile runtime/pprof.readProfile symbol is provided in exactly two files:

  • impl (cpuprof_sigprof_llgo.go): !baremetal && !wasm && (darwin || linux) && (amd64 || arm64)
  • this stub: (darwin || linux) && (baremetal || (!amd64 && !arm64))

The union is (darwin || linux) && ...wasm satisfies neither. Meanwhile the sibling stubs SetCPUProfileRate (cpuprof_stub_llgo.go, tag includes wasm) and pprof_runtime_stub_llgo.go (darwin || linux || wasm) do cover wasm. So a wasm build importing runtime/pprof would resolve every other pprof linkname but leave runtime/pprof.readProfile undefined. This looks like an unintended exclusion. Consider widening this stub to also cover wasm, e.g. wasm || ((darwin || linux) && (baremetal || (!amd64 && !arm64))).

data = append(data, uint64(pcs[i]))
}
tags = append(tags, nil)
if records == 255 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] if records == 255 break is load-bearing but non-obvious

The loop header for records := 0; n != 0 && records < 256; records++ already bounds the loop, so this inner break looks redundant. It is not: without it, the 256th iteration (records==255) would still run the trailing c_cpuProfileRead below, consuming a sample the loop then discards — silently dropping one sample per drain chunk. A future reader may "simplify" it away. Add a short comment (e.g. "don't consume a sample we won't emit this chunk"), or restructure so the read happens at the top of the loop guarded by capacity, removing the special case.

// Linux's profile writer expects readProfile to block. Keep the wait
// out of the signal path and poll at the 100 Hz profiler's period;
// Darwin already sleeps in pprof.
c.Usleep(10000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Poll comment implies interval tracks configured hz

10000 usec = 10 ms = 100 Hz period, so the arithmetic is internally consistent. But the profiler rate is user-configurable (1..1,000,000 Hz); 100 Hz is only the default. The 10 ms poll is a fixed constant regardless of the actual hz, so "poll at the 100 Hz profiler's period" reads as if the interval adapts to the configured rate. Suggest wording like "poll at 10 ms (the default 100 Hz period)".

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

064901299bcb | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 19248 B +0.0% 343.741 ms -6.9% (better) 1.393 ms -1.5% (better)
Linux fmtprintf 1880704 B +0.0% (worse) 2.692 s +1.1% (worse) 3.625 ms -0.3% (better)
Linux println 68728 B +0.0% 333.404 ms -3.0% (better) 1.768 ms +0.8% (worse)
macOS cprintf 84624 B +0.0% 437.253 ms -26.0% (better) 3.126 ms -38.6% (better)
macOS fmtprintf 1892176 B +0.0% 3.419 s +9.2% (worse) 15.794 ms -36.4% (better)
macOS println 121312 B +0.0% 516.435 ms -6.1% (better) 5.075 ms -15.5% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 12.270 ns/op -0.5% (better)
Linux BenchmarkMergeCompilerFlags 144.500 ns/op +0.2% (worse)
Linux BenchmarkMergeLinkerFlags 94.340 ns/op +0.4% (worse)
Linux BenchmarkChannelBuffered 36.350 ns/op -0.5% (better)
Linux BenchmarkChannelHandoff 24414 ns/op -0.1% (better)
Linux BenchmarkDefer 44.210 ns/op -1.4% (better)
Linux BenchmarkDirectCall 1.758 ns/op +0.1% (worse)
Linux BenchmarkGlobalRead 1.759 ns/op +0.1% (worse)
Linux BenchmarkGlobalWrite 2.805 ns/op +0.0%
Linux BenchmarkGoroutine 30532 ns/op -0.3% (better)
Linux BenchmarkInterfaceCall 8.789 ns/op -0.0% (better)
Linux BenchmarkRuntimeGetG 2.113 ns/op -14.3% (better)
macOS BenchmarkLookupPCRandom 14.520 ns/op -8.2% (better)
macOS BenchmarkMergeCompilerFlags 147.100 ns/op +7.1% (worse)
macOS BenchmarkMergeLinkerFlags 100.600 ns/op +20.5% (worse)
macOS BenchmarkChannelBuffered 28.710 ns/op +14.9% (worse)
macOS BenchmarkChannelHandoff 8015 ns/op +1.6% (worse)
macOS BenchmarkDefer 42.560 ns/op +10.6% (worse)
macOS BenchmarkDirectCall 1.336 ns/op +17.2% (worse)
macOS BenchmarkGlobalRead 1.129 ns/op -1.9% (better)
macOS BenchmarkGlobalWrite 1.517 ns/op +30.2% (worse)
macOS BenchmarkGoroutine 68306 ns/op +46.1% (worse)
macOS BenchmarkInterfaceCall 7.470 ns/op +17.8% (worse)
macOS BenchmarkRuntimeGetG 3.014 ns/op +7.5% (worse)

Compared with fe934c4f5a46 measured in the same runner job.

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@zhouguangyuan0718
zhouguangyuan0718 force-pushed the codex/cpu-profile-20260816 branch from 6e4d796 to 086d454 Compare August 16, 2026 14:15
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the codex/cpu-profile-20260816 branch from a48cd70 to 0649012 Compare August 17, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant