perf(stacktrace): defer frame symbolization to first read 🔥 😍😍😍#137
Conversation
Program counters are still captured eagerly at error-creation time via runtime.Callers (the stack is gone afterwards, so this cannot move), but resolving PCs into file/function/line strings through runtime.CallersFrames — the dominant cost of error creation per CPU profile (~30% of BenchmarkNew) — now happens behind a sync.Once on first access to Stacktrace()/Sources()/StackFrames(). Most errors in production are created, checked against nil, and discarded without ever being formatted (BenchmarkBuilderCreatedNotRead models this), so they no longer pay for symbolization at all. Errors that are formatted pay the same total cost as before, on first read. Filtering rules, their inputs (StackTraceMaxDepth is snapshotted at capture time) and output are unchanged. GOROOT and the examples-package prefix are now computed once at init instead of per error — runtime.GOROOT re-reads an env var on every call. This implements the existing "@todo: filtering should be done lazily, not at creation time". benchstat (linux/amd64, Intel Xeon @ 2.80GHz, -count=6 -cpu=1): │ before │ after │ New 4.335µ ± 3% 1.779µ ± 1% -58.96% (p=0.002 n=6) Wrap 4.618µ ± 3% 1.517µ ± 1% -67.15% (p=0.002 n=6) NewStacktrace 4.506µ ± 5% 1.773µ ± 4% -60.65% (p=0.002 n=6) CreatedNotRead/simple/Wrap 5.689µ ± 4% 1.837µ ± 3% -67.72% (p=0.002 n=6) CreatedNotRead/rich/chain=7 5.333µ ± 3% 2.042µ ± 1% -61.71% (p=0.002 n=6) CreateThenStacktrace 6.405µ ± 7% 6.302µ ± 4% ~ (p=0.589 n=6) ToMap 8.644µ ± 5% 8.316µ ± 3% -3.80% (p=0.026 n=6) │ B/op │ B/op │ New 2.336Ki ± 0% 1.273Ki ± 0% -45.48% (p=0.002 n=6) Wrap 2.320Ki ± 0% 1.258Ki ± 0% -45.79% (p=0.002 n=6) CreateThenStacktrace 3.477Ki ± 0% 3.523Ki ± 0% +1.35% (p=0.002 n=6) BenchmarkCreateThenStacktrace is added in this commit to keep the deferred cost measurable: the existing read benchmarks reuse a single error, which would otherwise amortize resolution away.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #137 +/- ##
==========================================
+ Coverage 90.17% 90.50% +0.33%
==========================================
Files 15 15
Lines 1231 1243 +12
==========================================
+ Hits 1110 1125 +15
+ Misses 96 93 -3
Partials 25 25
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR optimizes stacktrace handling by capturing program counters eagerly but deferring expensive symbolization (via runtime.CallersFrames) until the stacktrace is actually read, reducing the cost of creating errors that are never formatted.
Changes:
- Added lazy stack frame resolution behind a
sync.OnceinoopsStacktrace. - Moved invariant filtering inputs (
GOROOT, examples-package prefix) to package init-time. - Added a benchmark to measure create+resolve+format cost now that resolution is deferred.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| stacktrace.go | Defers symbolization to first read, adds sync.Once, and hoists invariant filtering inputs. |
| stacktrace_test.go | Updates tests to force resolution via resolvedFrames() before asserting on frames. |
| error.go | Ensures formatting paths use resolvedFrames() so lazy resolution happens when needed. |
| benchmarks/benchmark_test.go | Adds BenchmarkCreateThenStacktrace to measure deferred resolution cost. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Summary
runtime.Callers, but resolving them into file/function/line strings viaruntime.CallersFrames(the dominant cost of error creation, ~30% ofBenchmarkNew) is now deferred behind async.Once, triggered on first access toStacktrace()/Source()/StackFrames().StackTraceMaxDepthsnapshotted at capture time) are unchanged.GOROOTand the examples-package prefix are now computed once at init instead of per error.BenchmarkCreateThenStacktraceto keep the deferred cost measurable, since the existing read benchmarks reuse a single error and would otherwise amortize resolution away.Benchmarks
benchstat (arm64, Apple M3,
-count=6):