Skip to content

runtime,cl: add Go-compatible sampled memory profiling - #2027

Open
cpunion wants to merge 26 commits into
xgo-dev:mainfrom
cpunion:codex/stage5-memprofile
Open

runtime,cl: add Go-compatible sampled memory profiling#2027
cpunion wants to merge 26 commits into
xgo-dev:mainfrom
cpunion:codex/stage5-memprofile

Conversation

@cpunion

@cpunion cpunion commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #2351.

Problem

LLGo's runtime.MemProfile only kept size-class counters, so native heap profiles lacked allocation stacks and source-line attribution. The initial stack-sampling implementation also put avoidable TLS, locking, buffer-allocation, and frame-retention costs on profiled programs.

Design

  • Native targets sample with randomized exponential per-thread thresholds and key raw samples by (physical stack, allocation size), matching Go's Poisson-correction model.
  • Existing buckets and snapshots use atomically published immutable chains; only first insertion takes the table lock. Profile materialization suppresses recursive sampling on its current physical thread.
  • runtime.MemProfile reuses the caller's compatible record buffer, and runtime/pprof keeps sampling paused through its buffer allocation and snapshot materialization.
  • After whole-program Go SSA construction, an executable that does not consume runtime.MemProfile, runtime.MemProfileRate, or runtime/pprof omits both allocator recording calls and native profile setup at code generation. Frame-table initialization, hook installation, capture, and recorder bodies are then unreachable and dead-stripped; allocator names, signatures, and ABI do not change.
  • Profile-enabled builds retain physical frames only along compiler-classified allocation paths across the static call graph, including cross-package wrappers. Unrelated helpers remain inlineable.
  • Native TLS addresses are dominance-cached, so the enabled allocator path resolves TLS once. Random generation and stack walking remain in a noinline sampled slow path.
  • Wasm and bare-metal retain the existing size-class implementation until equivalent stack capture exists. c-archive and c-shared conservatively retain profiling because future external calls are not visible at executable analysis time.
  • The whole-program choice is stored only in the internal runtime cache fingerprint and is shared by normal and full-LTO builds.
Output Recording path
Native executable, no consumer allocator recording and profile setup omitted; profiler dead-stripped
Native executable with consumer stack-and-size sampled profile
Wasm/bare-metal executable with consumer existing size-class fallback
c-archive / c-shared profiling retained; target-appropriate recorder

Benefits

  • Ordinary executables have no profiling calls or eager profile setup; profiler bodies are dead-stripped.
  • The enabled common path performs one rate load, one TLS resolution, and one countdown update; it has no RNG, stack walk, global lock, or target-mode dispatch.
  • Mixed-size allocations at one stack are corrected independently, and concurrent snapshots no longer serialize allocation samples.

On an Apple M4 Max (Darwin/arm64, Go 1.26.5), a GC-disabled allocation microbenchmark used 21 rotated rounds; each process ran 5 warm-up and 61 measured batches of 250,000 escaping 16-byte allocations. The table reports medians of process medians; signed differences subtract those displayed medians, while percentages are paired within each round.

Toolchain and path ns/op Signed difference / paired change
LLGo PR base (0f480253): unconditional size-class recorder 19.11 baseline
This PR: no profile consumer 18.04 -1.07 ns/op / -6.5% vs PR base
This PR: consumer, rate 0 18.99 +0.95 ns/op / +6.1% vs omitted; -0.12 ns/op / -2.4% vs PR base
This PR: consumer, default rate 20.03 +1.99 ns/op / +11.1% vs omitted; +0.92 ns/op / +5.8% vs PR base
Go 1.26.5: rate 0 12.69 Go baseline
Go 1.26.5: default rate 12.84 +0.15 ns/op / +0.3% vs Go rate 0

The standard Go toolchain has no no-consumer specialization, so rate 0 is its closest disabled path. Against Go's corresponding rate-0/rate-0/default paths, LLGo's omitted/rate-0/default medians differ by +5.35/+6.30/+7.19 ns/op and the paired changes are +39.9%/+48.7%/+53.0%. This gap includes the allocator and runtime, not just profiling; the profiling-specific comparisons are the within-toolchain deltas above. LLGo used GC_disable; Go used debug.SetGCPercent(-1).

A final same-runner CI comparison (run, full report) built both revisions first, then alternated base/head order for every workload and sample. Allocation rows below are differences between 18-process medians; InterfaceCall uses the median of seven pairwise signed differences and percentage changes.

Comparison Linux/amd64 Darwin/arm64
No-consumer allocation run vs PR base -53.16 ms / -7.5% -54.22 ms / -8.4%
Rate 0 run vs no consumer +14.37 ms / +2.2% +5.745 ms / +1.0%
Default-rate run vs no consumer +29.26 ms / +4.5% +60.92 ms / +10.4%
No-consumer build vs PR base +90.2 ms / +6.1% +119.4 ms / +6.0%
No-consumer file size vs PR base -4928 B / -0.8% -320 B / -0.1%
InterfaceCall vs PR base -0.028 ns/op / -0.7% -1.321 ns/op / -23.0%

The large Darwin InterfaceCall improvement was consistent in all seven pairs and comes from shrinking the cold IfacePtrData body after the old inlined recorder path was removed. Other unrelated microbenchmark pair ranges crossed zero; scheduler-heavy goroutine measurements remained noisy.

Final no-consumer binaries remove the profiler code. Minimal Linux cprintf is nevertheless +256 B on disk because 16 additional pre-DCE funcinfo symbol-index records (16 B each) remain in .rodata; .relro_padding shrinks by the same 256 B, so .text, loaded-section total, and page count are unchanged. Filtering dead funcinfo records is follow-up work.

Validation

  • LLGo ./test/go/memprofile: concurrent snapshots, reflect closures, tiny allocations, same-stack mixed sizes, and pprof output
  • Go GOROOT test/heapsampling.go
  • targeted cl, ssa, and internal/build tests, including cache separation, library selection, cross-package attribution, and one native TLS resolution
  • Darwin/arm64 and Linux/amd64 final-symbol, size, and statement-line checks for the omitted path, plus enabled-path disassembly
  • wasip1/wasm build with a direct runtime.MemProfile consumer
  • normal and full-LTO selection; c-archive and c-shared retention

Bare-metal target coverage is left to CI. Allocator-specific tuning, sampled-object free accounting, native-equivalent stack capture for wasm/bare-metal, and dead funcinfo filtering remain follow-up work.

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.70732% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cl/instr.go 92.76% 6 Missing and 5 partials ⚠️
cl/locality_lower.go 90.00% 1 Missing and 1 partial ⚠️
internal/build/build.go 84.61% 1 Missing and 1 partial ⚠️
ssa/memory.go 66.66% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@cpunion
cpunion force-pushed the codex/stage5-memprofile branch 4 times, most recently from c647b14 to 2b22d07 Compare July 4, 2026 12:52
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch 2 times, most recently from 4695ad3 to 9d9f6b0 Compare July 8, 2026 07:02
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch 3 times, most recently from e6bbe61 to 2e36dbf Compare July 9, 2026 05:52
@cpunion cpunion added go-test-compat Go standard-library and GOROOT test compatibility go1.26 Go 1.26 compatibility and support feature labels Jul 16, 2026
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch from 2e36dbf to ef62b2d Compare July 18, 2026 23:37
@xushiwei xushiwei removed the feature label Jul 19, 2026
@cpunion cpunion changed the title runtime,cl: stack-keyed sampled memory profiling with gc semantics runtime,cl: add Go-compatible sampled memory profiling Jul 20, 2026
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch from 3eff4be to a00bc0c Compare July 29, 2026 01:50
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch from 7b22159 to 0604edf Compare August 1, 2026 15:06
@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased the independent memprofile root onto current main (551626a1d) and force-pushed the branch.

Conflict handling kept current-main panic/recover snapshot hooks in unwind_llgo.go, then layered the memprofile initialization/capture hooks beside them. The final xfail change removes only native heapsampling.go entries; there are no CI timing/config changes and no wasm behavior work.

Fresh validation:

  • compiler detection/instrumentation tests pass; packageReadsMemProfile coverage 95.7%
  • runtime modules build
  • LLGo memprofile and caller/panic/introspection acceptance tests pass
  • upstream heapsampling.go passes without xfail with Go 1.24.11, 1.25.0, and 1.26.5 on darwin/arm64

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

a7bafd4e07b0 | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 19640 B +256 B / +1.3% (worse) 339.822 ms +10.91 ms / +3.3% (worse) 1.632 ms +59.54 us / +3.8% (worse)
Linux fmtprintf 1875576 B -5328 B / -0.3% (better) 2.846 s +224.5 ms / +8.6% (worse) 3.385 ms -72.5 us / -2.1% (better)
Linux memprofile-default 1294064 B +267008 B / +26.0% (worse) 2.309 s +104.6 ms / +4.7% (worse) 906.982 ms -63.46 ms / -6.5% (better)
Linux memprofile-no-consumer 585336 B -4928 B / -0.8% (better) 1.992 s +57.28 ms / +3.0% (worse) 867.319 ms -60.45 ms / -6.5% (better)
Linux memprofile-rate0 1294064 B +267008 B / +26.0% (worse) 2.278 s +50.96 ms / +2.3% (worse) 894.324 ms -74.96 ms / -7.7% (better)
Linux println 63120 B -5760 B / -8.4% (better) 338.482 ms +2.182 ms / +0.6% (worse) 1.646 ms -19.62 us / -1.2% (better)
macOS cprintf 84672 B 0 B / +0.0% 479.824 ms +20.01 ms / +4.4% (worse) 6.235 ms +86.19 us / +1.4% (worse)
macOS fmtprintf 1891888 B -320 B / -0.01691% (better) 3.275 s +12.11 ms / +0.4% (worse) 18.047 ms +536 us / +3.1% (worse)
macOS memprofile-default 1321696 B +150912 B / +12.9% (worse) 2.636 s +49.95 ms / +1.9% (worse) 838.361 ms -26 ms / -3.0% (better)
macOS memprofile-no-consumer 626640 B -320 B / -0.1% (better) 2.416 s +44.98 ms / +1.9% (worse) 828.467 ms -55.25 ms / -6.3% (better)
macOS memprofile-rate0 1321696 B +150912 B / +12.9% (worse) 2.550 s -49.1 ms / -1.9% (better) 828.360 ms -22.21 ms / -2.6% (better)
macOS println 121024 B -352 B / -0.3% (better) 491.012 ms +76.94 ms / +18.6% (worse) 6.512 ms +394.1 us / +6.4% (worse)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 13.210 ns/op -0.01 ns/op / -0.1% (better)
Linux BenchmarkMergeCompilerFlags 152.500 ns/op +0.8 ns/op / +0.5% (worse)
Linux BenchmarkMergeLinkerFlags 94.760 ns/op +0.01 ns/op / +0.01052% (worse)
Linux BenchmarkChannelBuffered 34.910 ns/op +1.24 ns/op / +3.7% (worse)
Linux BenchmarkChannelHandoff 27840 ns/op +7 ns/op / +0.02502% (worse)
Linux BenchmarkDefer 42.730 ns/op +1.72 ns/op / +4.2% (worse)
Linux BenchmarkDirectCall 1.556 ns/op 0 ns/op / +0.0%
Linux BenchmarkGlobalRead 1.556 ns/op 0 ns/op / +0.0%
Linux BenchmarkGlobalWrite 2.487 ns/op +0.008 ns/op / +0.3% (worse)
Linux BenchmarkGoroutine 30116 ns/op +120 ns/op / +0.4% (worse)
Linux BenchmarkInterfaceCall 8.093 ns/op +0.001 ns/op / +0.01236% (worse)
Linux BenchmarkRuntimeGetG 2.179 ns/op +0.31 ns/op / +16.6% (worse)
macOS BenchmarkLookupPCRandom 11.010 ns/op +0.04 ns/op / +0.4% (worse)
macOS BenchmarkMergeCompilerFlags 110.300 ns/op -5.1 ns/op / -4.3% (better)
macOS BenchmarkMergeLinkerFlags 72.650 ns/op +2.77 ns/op / +3.9% (worse)
macOS BenchmarkChannelBuffered 22.410 ns/op -0.32 ns/op / -1.5% (better)
macOS BenchmarkChannelHandoff 6232 ns/op -216 ns/op / -3.3% (better)
macOS BenchmarkDefer 24.930 ns/op +0.5 ns/op / +2.0% (worse)
macOS BenchmarkDirectCall 0.976 ns/op 0 ns/op / +0.0%
macOS BenchmarkGlobalRead 0.967 ns/op +0.004 ns/op / +0.4% (worse)
macOS BenchmarkGlobalWrite 0.942 ns/op -0.0037 ns/op / -0.4% (better)
macOS BenchmarkGoroutine 25544 ns/op -1924 ns/op / -7.4% (better)
macOS BenchmarkInterfaceCall 4.109 ns/op -1.322 ns/op / -24.3% (better)
macOS BenchmarkRuntimeGetG 1.891 ns/op +0.002 ns/op / +0.1% (worse)

Compared with 0f480253029a measured in the same runner job; index-paired signed differences and percentage changes are pairwise medians.

@cpunion
cpunion force-pushed the codex/stage5-memprofile branch 3 times, most recently from 857d2a8 to 1b77778 Compare August 3, 2026 22:54
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch from fefa1e0 to 2983928 Compare August 13, 2026 22:10
cpunion and others added 11 commits August 17, 2026 21:51
Replaces the size-class counters with gc-shaped heap profiling: sampled
allocations are attributed to physical call stacks at exact statement
lines, and records hold RAW sampled counts — consumers (pprof, goroot
heapsampling.go) apply the Poisson correction themselves, exactly as
with gc.

- Sampling mirrors gc's mcache.nextSample: bytes count down to an
  exponentially distributed threshold (mean MemProfileRate), sample once
  on crossing, redraw. The memoryless distribution is load-bearing: with
  any bounded-support threshold a near-periodic allocation pattern
  phase-locks the sample points onto the large sites (observed 1.6x
  per-site skew on heapsampling's interleaved sizes). ln() is a small
  local approximation — the runtime core cannot import math.
- Stacks come from the FP walk at sample time (fpCallers via a hook the
  public runtime registers), bucketed by stack hash; allocator plumbing
  (including __llgo_stub. wrapper frames of the hook) is trimmed at read
  time. A reentrancy flag spans the whole decision path: threshold
  drawing and bucket allocation themselves allocate, and a recursive
  sample overflows the stack.
- Heap allocations get statement anchors in tracked functions, and a
  package that reads the memory profile (runtime.MemProfile /
  MemProfileRate under either the "runtime" or the patched
  lib-runtime spelling) pins all its trackable functions: per-site
  attribution loses sites to inlining otherwise. Profiling packages are
  rare and accuracy beats inlining there; gc gets both via its inline
  tree (P4).

goroot heapsampling.go passes on darwin/arm64 and linux/arm64 (the
latter was a pre-existing platform gap). Depends on --icf=none from the
line-directive PR: heapsampling's three identical wrapper functions must
keep distinct pcs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An acceptance regression asserts exact per-line attribution at rate=1
(raw counts are exact there), and a cl unit test covers the
memprofile-package pinning criterion under both runtime spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… capture path

The frame-table init allocates; when one of those allocations crossed the
sampling threshold, captureMemProfileStack -> fpCallers re-entered
initRuntimeFuncPCFramesSlow on the thread that already held the Busy
latch and usleep-spun forever. First testing.callerName call of a test
binary triggers the init, so whole test binaries hung at startup —
which packages hit it depends on the deterministic threshold sequence
meeting the binary's pre-init allocation volume: net/rpc and
net/rpc/jsonrpc under go1.24 stdlib, net/http/expvar/cookiejar under
go1.26 (CI shard timeouts on ubuntu, both attempts).

Entering an Uninit latch from the capture path is safe (the whole sample
runs under memProfileInSample, so init's own allocations cannot
re-sample); only Busy must not be waited on. Drop that one sample.
@cpunion
cpunion force-pushed the codex/stage5-memprofile branch from 2983928 to 26ddb05 Compare August 17, 2026 16:32
@cpunion

cpunion commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark publishing is now pinned to setup-benchmark-go-action@v1.0.5 in both places: the PR-side recorder and the default-branch workflow_run publisher.

Validation used real paired LLGo artifacts. The v1.0.5 renderer reports the measured base SHA and signed unit/percentage deltas (for example, +256 B / +1.3% and 0 ns/op / +0.0%); index-paired samples are summarized with pairwise medians.

The live bot comment on this PR is still produced by the publisher currently on main (v1.0.4), so its presentation remains percentage-only until this workflow change is merged. After merge, the default-branch publisher is pinned to v1.0.5 as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go1.26 Go 1.26 compatibility and support go-test-compat Go standard-library and GOROOT test compatibility

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: low-overhead stack-keyed sampled heap profiling

4 participants