memory.md
July 14, 2026 · View on GitHub
basics.md covers the headline metrics, the daemon model, and the
debug allocator-stats / debug heap-dump / kill commands. This page is
the additional, mostly buck2-specific things that the heap-profiling tool
chain will trip you up on.
This page is about where allocations happen (the heap profile). For the
active - allocated slab-fragmentation gap — partially-empty slabs pinned
by survivors, which a heap profile cannot see — see
memory_fragmentation.md.
Enable profiling
The daemon must be started with profiling on:
MALLOC_CONF=prof:true,prof_active:true,prof_final:false buck2 <cmd>
prof_final:false is the important non-default — it suppresses the auto-dump
on exit, which we don't want. Without MALLOC_CONF=prof:true set in the env
when the daemon was started, debug heap-dump returns an error and you must
restart the daemon.
--no-buckd mode runs everything in one process that exits before you can
ask for a heap dump. If you must heap-profile a --no-buckd run, set
MALLOC_CONF=…,prof_final:true to dump at exit — but that captures
post-cleanup state, not peak.
Allocator stats vs RSS
allocator-stats.allocated is what to track for retained memory: it counts
bytes the program has malloc'd and not freed. The relationship to VmRSS:
allocated≤active≤resident≈VmRSS. The slack betweenallocatedandresidentis jemalloc page slack — pages backing freed allocations that haven't been returned to the OS yet.- After a build, jemalloc's bg threads spend ~10–30 s decommitting unused
pages. RSS visibly drops in that window.
allocateddoes not change during decommit, because no user code freed anything. Always checkallocated, not RSS, to tell whether something actually freed. retainedandmetadatafromallocator-statsare jemalloc-internal bookkeeping and almost never the answer to a question.
Peak vs retained
A debug heap-dump is a snapshot at the moment you call it — typically
after the build, when transient allocations (in-flight tokio task records,
starlark eval bumpalo heaps, scratch hashmaps) have been freed. The gap
between VmHWM and post-build allocated is dominated by these
transients, not by retained data.
To attribute peak, dump during the build when RSS is at a new
high-water mark: scripts/peak_watch.sh polls
/proc/<daemon-pid>/status and triggers debug heap-dump on each new HWM,
keeping only the latest. Don't reach for MALLOC_CONF=…,prof_gdump:true
to do this — at the default sample rate it fires constantly and produces
tens of thousands of files, exhausting FDs and crashing the daemon. (If
you really want gdump, raise lg_prof_sample to ≥24.)
Sampling
jemalloc samples each allocation with probability 1 - exp(-size/period),
period = 2^lg_prof_sample (default 19 = 512 KB). The non-obvious
consequence:
- One huge allocation (multi-MB hashtable, GB-scale Vec) is sampled at ≈100% — the raw byte count in the profile equals its true size.
- Many small allocations are heavily under-counted in raw bytes, with the
underestimate proportional to
period / avg_sizefor that site.
The correct adjustment is per-stack, not uniform. For each stack,
multiply by 1 / (1 - exp(-avg/period)) where avg = bytes/count. A
plausible-looking "scale every site by allocated / raw_total" is wrong:
it badly inflates large-allocation sites and is the most common
misinterpretation of a raw heap profile.
scripts/heap_aggregate.py applies the
per-stack scaling.
Don't use jeprof
jeprof shells out to addr2line. On a full-debug buck2 binary that's
many minutes per profile because the DWARF section is huge and the binary
contains references to missing .dwo files. eu-addr2line is no faster.
scripts/heap_aggregate.py symbolicates with
nm + binary search and runs in seconds. For diffing two profiles or
extracting a specific stack see the other scripts in that directory.
Demangling Rust v0 names
c++filt does not handle Rust v0 (_R…) names, even with --format=rust.
Use rustfilt. Inside fbsource it lives at
fbsource/third-party/rust/tools/rustfilt. The included scripts auto-detect
it.
.llvm.NNN suffixes (LLVM-added for inlined functions) sometimes cause
rustfilt to leave a frame undemangled even though it can demangle the
same name in isolation. When this happens it's a frame-by-frame artifact;
re-pipe the offending symbol on its own to demangle it.
Reading a heap profile
- The deepest frame in every stack is jemalloc's own backtrace function
(
prof_backtrace_impl/jemalloc_je_*). The actual user allocation site is the first non-jemalloc frame walking up.heap_aggregate.pyskips jemalloc frames automatically. - The leaf is the allocation site, not the retainer. A
Box::newin functionmake_foomay move into a struct that's stored on a DICE node somewhere else; the heap profile points atmake_fooeither way. If the question is "what's keeping this alive", the heap profile is at best a starting point.
When the answer is in DICE
For "what is reachable from object X" rather than "where was X allocated",
use allocative (already
integrated into buck2). The DICE state implements Allocative;
buck2 debug allocative serializes a graph for offline analysis. This is
the right tool when a heap profile shows lots of attribution to upstream
constructors but you actually want to know which DICE keys are retaining
the data.