memory_regression_hunting.md
August 4, 2026 · View on GitHub
A checklist, not a tutorial. memory.md covers the per-script gotchas and sampling math; this page is the workflow on top.
Setup
- Run in a benchmark-only worktree (see basics.md) so you don't fight the user's daemon and you don't pay remote-cache splitting from isolation dirs.
- Switching binaries kills the daemon (version skew). That's the natural way to get fresh DICE between iterations; lean into it rather than fighting it.
- For statistical confidence (which you usually need — see
benchmarking.md), size the regression with an
abtestrun:--daemon-lifecycle=freshrecords the daemon's peak RSS and post-purge jemalloc stats for every sample. The local sequence below is for attribution, not detection.
Single-iteration sequence per binary
scripts/measure.sh wraps this. Internally:
buck2 kill- Warmup build with
MALLOC_CONF=prof:true,prof_active:true,prof_final:false buck2 kill(DICE was warm; restart for a clean retained-memory baseline)- Measurement build (same MALLOC_CONF)
- Capture
VmHWM/VmRSSfrom/proc/<daemon-pid>/status buck2 debug allocator-stats > B_stats.jsonbuck2 debug heap-dump --path B.heap
For peak attribution use scripts/peak_watch.sh
instead — the heap dump from step 7 is post-build retained, not peak.
Diff
python3 scripts/heap_diff.py /path/to/buck2_a A.heap /path/to/buck2_b B.heap a b
Symbol names are stable across unrelated binary changes, so leaves align even after refactors. To pull the full call chain for a specific leaf:
python3 scripts/heap_stacks.py /path/to/buck2_b B.heap "<leaf-substring>"
Substring matches against both mangled and demangled forms. Demangled
substrings need to include the angle bracket: Foo>::bar, not
Foo::bar (the demangled form is <…::Foo>::bar).
Sanity check
Sum of scaled bytes in the heap profile should be within ~5–10% of
allocator-stats.allocated. If it's wildly off, sampling under-counted
small allocations — try MALLOC_CONF=…,lg_prof_sample:17 for 128 KB
sampling.
Reading the diff
A leaf with scale ≈ 1× is one or a few large allocations; the raw byte
count is the real size and the regression is exactly what it looks like.
A leaf with scale ≫ 1× is many small allocations; the regression is
"more of these are alive", and the answer for why often lives one or
two frames up the stack rather than at the leaf itself.
The heap-profile leaf labels where memory was allocated, not what's
keeping it alive. If the diff points at a generic constructor or
make_* function with no obvious lifecycle change, the actual regression
is probably one of its callers retaining results longer or more often.
For "what is reachable from X" use the allocative path described in
memory.md.