Repository navigation
pprof source and disasm views overcount recursive functions #707
Description
What version of pprof are you using?
go tool pprof with go1.18.3.
Full go env output
GO111MODULE="" GOARCH="amd64" GOBIN="" GOCACHE="/home/keyhan/.cache/go-build" GOENV="/home/keyhan/.config/go/env" GOEXE="" GOEXPERIMENT="" GOFLAGS="" GOHOSTARCH="amd64" GOHOSTOS="linux" GOINSECURE="" GOMODCACHE="/home/keyhan/go/pkg/mod" GONOPROXY="" GONOSUMDB="" GOOS="linux" GOPATH="/home/keyhan/go" GOPRIVATE="" GOPROXY="https://proxy.golang.org,direct" GOROOT="/snap/go/9848" GOSUMDB="sum.golang.org" GOTMPDIR="" GOTOOLDIR="/snap/go/9848/pkg/tool/linux_amd64" GOVCS="" GOVERSION="go1.18.3" GCCGO="gccgo" GOAMD64="v1" AR="ar" CC="gcc" CXX="g++" CGO_ENABLED="1" GOMOD="/dev/null" GOWORK="" CGO_CFLAGS="-g -O2" CGO_CPPFLAGS="" CGO_CXXFLAGS="-g -O2" CGO_FFLAGS="-g -O2" CGO_LDFLAGS="-g -O2" PKG_CONFIG="pkg-config" GOGCCFLAGS="-fPIC -m64 -pthread -fmessage-length=0 -fdebug-prefix-map=/tmp/go-build2938140119=/tmp/go-build -gno-record-gcc-switches"
What operating system and processor architecture are you using?
Linux, x86-64 Intel.
What did you do?
I opened the attached profile with go tool pprof -http=: fac.pb.gz,
and navigated to the "Sources" page. Here is the output:
As you can see, it is claimed that fac took 6193% of the total time.
It is also said that it took 150 seconds, which is wrong since the
program took only a couple of seconds.
The issue also occurs with the command line version, although strangely
the output is better.
(pprof) list fac
Total: 2.43s
ROUTINE ======================== main.fac in /home/keyhan/exgo/e.go
2.43s 3.34s (flat, cum) 137.45% of Total
. . 2:
. . 3:import "os"
. . 4:import "log"
. . 5:import "runtime/pprof"
. . 6:
850ms 850ms 7:func fac(n uint64) uint64 {
60ms 60ms 8: if n == 0 {
. . 9: return 1
. . 10: } else {
1.52s 2.43s 11: return n * fac(n - 1)
. . 12: }
. . 13:}
. . 14:
. . 15:func main() {
. . 16: f, err := os.Create("example.pb.gz")
What did you expect to see?
cum% should not be more than 100%.
What did you see instead?
cum% is claimed to be 6193%.
Root cause(?)
I believe the root cause is this line of code:
pprof/internal/report/source.go
Line 104 in c488b8f
| flatSum, cumSum := fns.Sum() |
This is wrong, since the cumulative time spent in the current function
is not fns.Sum() if the function calls itself recursively. In those
cases some nodes will be counted multiple times.
Source Code
package main
import "os"
import "log"
import "runtime/pprof"
func fac(n uint64) uint64 {
if n == 0 {
return 1
} else {
return n * fac(n - 1)
}
}
func main() {
f, err := os.Create("example.pb.gz")
if err != nil {
log.Fatal("could not create CPU profile: ", err)
}
defer f.Close() // error handling omitted for example
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal("could not start CPU profile: ", err)
}
defer pprof.StopCPUProfile()
for i := 1; i <= 1e6; i++ {
fac(1000)
}
}
Activity
kvakil commentedon Jun 26, 2022
Forgot to mention that the graph view looks correct.
The disasm view is similarly broken:
[-]pprof source view overcounts recursive functions[/-][+]pprof source and disasm views overcount recursive functions[/+]Hee-San commentedon Sep 21, 2026
Hi, I'd like to pick this up if nobody else has. I have two PRs for it, but the analysis first, because there turn out to be two independent bugs behind the numbers in this issue.
One of them you already diagnosed:
That's right, and it accounts for the 137.45% you got from
list. It can't account for 6193% though. The Sources page has a second bug stacked on top of it. I'll call the weblist-only one A and the sum B.Reproduction
A recursive
fibinstead offac, so that there are two distinct recursive call sites (darwin/arm64, go1.25.0, pprof at 6331bc6):-topis correct:The source and assembly views are not, and they don't agree with each other either. Total is 310ms:
Cause A: weblist adds a sample once per stack frame
One sample from
-traces -addresses(paths trimmed):Shortened, the shape is
main.main -> fib(4) at R1 -> fib(3) at R1 -> fib(2) at R2 -> fib(0) at X. Two addresses carry the whole recursion: R1 shows up once for every frame that is inside itsfib(n-1)call, R2 once for every frame insidefib(n-2).This sample has 29
main.fibframes: R1 nineteen times, R2 nine times, the leaf once. It is worth 10ms, and cum for an address should mean "this sample reached the address", so R1, R2 and the leaf should each get 10ms from it.newSourcePrinterinstead doesfor every frame, so this one sample hands R1 190ms and R2 90ms.
Over the whole profile
main.fiboccupies 855 frames in 30 samples, 520 of them R1 and 305 R2. At 10ms per sample that is 5.20s and 3.05s, which is exactly what the weblist page prints next to the two call instructions, and 855 × 10ms = 8.55s is its header.It only misfires on recursion. A function that isn't recursive has at most one frame in any sample, so each of its addresses is visited at most once and adding per frame happens to equal counting per sample. That is why none of the existing tests notice.
It also explains your numbers. The runtime caps CPU profile stacks at 64 frames (
maxCPUProfStack), so afac(1000)sample carries roughly 62facframes however deep the recursion really is. 2.43s × 62 ≈ 150s, and 150s / 2.43s ≈ 6200%, which matches the 150 seconds and 6193% in your screenshot.This looks like a regression rather than a design choice. weblist went through
rpt.newGraphuntil #599 (cbba55b), andnewGraphkeeps aseenNodeset that does exactly this deduplication. The README still documents the rule:With each address counted once per sample, weblist lines up with
-disasm: header 900ms / 290.32%, and 300ms for each call instruction.Cause B: the totals are sums of overlapping parts
Fixing A doesn't get to 100%, because the header is built by adding up its parts.
-liston the same profile:Every per-line number here is already right. Line 13 says 300ms, which is exactly the cum
-topreports formain.fib. Only the header is wrong, and it is wrong by construction: 130 + 20 + 20 + 300 = 470.cum is the weight of a set of samples, and set weights don't add:
so a sum overshoots by exactly the samples that reach both parts. A non-recursive function contributes one frame per sample, hence one line and one instruction, so the intersections are empty and the sum is accidentally right. Recursion is precisely the case where it isn't.
Smallest example: one 10ms sample with three
fibframes at three different addresses. Each address is correctly 10ms, but-disasmand weblist add per instruction and report 30ms, while-listadds per source line, so R1 and R2 collapse into one entry and it reports 20ms. Three granularities, three different wrong answers. The sums are at source.go#L105 (the line you linked), report.go#L454 for-disasm, and source.go#L690 and #L740 for weblist lines and functions.testdata/source.rpt has this checked in as an expectation: the recursive
teeheader is recorded at189.90% of Total.What is and isn't affected
Only the annotated source and assembly views:
-list,-disasm,-weblistand the web UI Source page.-top, the graph (-dot) and the flame graph take their numbers straight fromnewGraphwithout summing anything, and their output is byte-for-byte the same before and after both fixes.The PRs
Two, the second stacked on the first.
The first fixes A: a few lines in
newSourcePrinterplus a test. I don't think there is anything to decide there. It's a regression, and the rule it restores is already implemented innewGraphand written down in the README. Output for profiles without recursion is byte-for-byte unchanged.The second fixes B. It drops the sums and counts each sample once per function, and likewise per line in weblist, which is the rule
newGraphalready applies per node. It stays single-pass, so weblist doesn't get slower (0.33s before, 0.35s after, for-weblist=.on a 76,000-sample profile). With both applied, all four views report 300ms / 96.77% formain.fib. On a profile without recursion,-list,-disasmand-weblistoutput is byte-for-byte unchanged, and the one expectation that moves isteein source.rpt, from 189.90% to 99.90%.They are separate because neither is enough alone. With only B, the weblist header says 300ms while the two call instructions under it still say 5.20s and 3.05s.
B is more of a judgement call than A: it changes
-listand-disasmoutput that has looked this way for years, and it rewrites a checked-in expectation. I'm sending it as a PR rather than asking first because it's easier to judge as code than as a description, but I'm not attached to it. If a maintainer would rather it worked differently, or not at all, say so and I'll close it. The one thing I'd like a view on: is "the header should match the cum that-topreports" the right goal? That's what I aimed at, and it's the part I can't settle from the code alone.internal/report: count each address once per sample in weblist (#1030)