Heaptrack — Heap Memory Profiler
Heaptrack for C++ — tracking heap allocations, finding memory leaks, peak consumption, allocation hotspots, and GUI visualization.
TL;DR
Heaptrack records every heap allocation with a full stack trace, then lets you analyze peak memory usage, total allocations, and memory leaks — with much lower overhead than Valgrind massif.
Installation
# Ubuntu/Debian
sudo apt install heaptrack heaptrack-gui
# From source (KDE repository)
git clone https://github.com/KDE/heaptrack.git
cd heaptrack && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build buildBasic Usage
# Record
heaptrack ./myapp args
# Creates: heaptrack.myapp.PID.zst
# Analyze in terminal
heaptrack_print heaptrack.myapp.*.zst
# GUI analysis (heaptrack-gui)
heaptrack_gui heaptrack.myapp.*.zstheaptrack_print Output
heaptrack_print heaptrack.myapp.1234.zst
# Output sections:
# TOTAL: X allocations, Y bytes allocated, Z bytes leaked
# PEAK: highest simultaneous heap usage
# MOST BYTES: functions responsible for most total allocation bytes
# MOST CALLS: functions with the highest allocation call count
# LEAKED: allocations never freed (with stack traces)Finding Memory Leaks
heaptrack ./leaky_program
heaptrack_print --print-leaks heaptrack.leaky_program.*.zst
# Example output:
# LEAKED 1 allocations of 1024 bytes each
# main (main.cpp:15)
# allocate_buffer (buffer.cpp:8)
# ::operator new(unsigned long)Finding Allocation Hotspots
heaptrack_print --print-allocators heaptrack.*.zst
# Shows which functions allocate most — useful for:
# - Eliminating unnecessary std::string copies
# - Finding where to introduce object pools
# - Reducing vector reallocationsGUI Walkthrough
heaptrack_gui heaptrack.myapp.1234.zst
Tabs:
Summary → peak RSS, total alloc count, leaked bytes
Allocations → flame graph by allocation count
Temporary → short-lived allocations (candidates for optimization)
Consumed → flame graph by bytes consumed at peak
Leaks → flame graph of leaked allocations
Top-Down/Bottom-Up → call tree with annotationAttach to Running Process
# Inject into running process (Linux, requires ptrace permission)
heaptrack --pid 12345
# Detach with Ctrl+C — saves the .zst fileCompare Baseline vs After Optimization
heaptrack ./myapp_before
heaptrack ./myapp_after
heaptrack_print --diff-relative \
heaptrack.myapp_before.*.zst \
heaptrack.myapp_after.*.zstHeaptrack vs Other Tools
| Heaptrack | Valgrind massif | ASan LeakSanitizer | perf | |
|---|---|---|---|---|
| Overhead | ~3–5× | ~20–30× | ~2× | < 5% |
| Leak detection | ✅ | ✅ | ✅ | ❌ |
| Peak usage | ✅ | ✅ | ❌ | ❌ |
| Allocation traces | ✅ | ✅ | On leak only | ❌ |
| GUI | ✅ (Qt) | ✅ (Massif Viz) | ❌ | ✅ (Hotspot) |
| Platform | Linux | Linux/macOS | All | Linux |
CMake: Build with Symbols for Better Traces
# Symbols needed for meaningful stack traces
target_compile_options(myapp PRIVATE -g -fno-omit-frame-pointer)
# For production profiling: O2 with symbols
target_compile_options(myapp PRIVATE -O2 -g -fno-omit-frame-pointer)Reading heaptrack_print Output in Detail
heaptrack_print --print-allocators gives you ranked allocation hotspots with annotated stack traces. Each entry shows how many bytes and how many calls originate from a particular call chain, resolved to source lines via DWARF debug info. Understanding the structure of the output is the first step to acting on it.
heaptrack_print --print-allocators heaptrack.myapp.1234.zstMOST CALLS (top 10): # ranked by raw call count
1,482,304 calls consuming 142.3 MB # (A) total bytes from this chain
1,482,304 allocations # (B) allocation count
in operator new(unsigned long) # (C) function name (allocator)
std::__cxx11::basic_string::_M_create # immediate caller
parseToken (lexer.cpp:87) # (D) file:line from DWARF
lex (lexer.cpp:201)
parse (parser.cpp:44)
main (main.cpp:12)
TEMPORARY ALLOCATIONS (top 10):
891,200 temporary calls consuming 0 bytes at peak
in operator new(unsigned long)
std::__cxx11::basic_string::_M_create
buildKey (cache.cpp:33)
lookup (cache.cpp:71)The four annotated fields:
- (A) total bytes — cumulative bytes allocated by this chain over the entire run, regardless of whether they were freed. A high number here does not mean they contribute to peak memory.
- (B) allocation count — number of times
new/mallocwas called through this chain. High counts with low byte totals suggest many small, short-lived objects. - (C) function name — the allocating function, almost always
operator newormalloc. The interesting frame is the first application frame above it. - (D) file:line from DWARF — resolved only when you compile with
-g. Without debug symbols you see raw offsets like(+0x4a31)instead.
Temporary allocations are objects that were allocated and freed before peak heap was reached. They do not appear in the "Consumed" flame graph because they contribute zero bytes at peak. However, they inflate the allocation count and can dominate CPU time through repeated malloc/free round-trips. The Temporary Allocations flame graph in the GUI is the fastest way to find candidates for stack allocation, thread_local buffers, or object pools.
Tracking std::string and STL Allocations
STL containers leave distinctive fingerprints in heaptrack stack traces. The mangled names resolve to readable forms with -g and a recent libstdc++, so you can tell at a glance whether a hotspot is a string construction, a vector reallocation, or a map node insertion.
Common STL frames you will encounter:
std::__cxx11::basic_string::_M_create # new string heap buffer
std::__cxx11::basic_string::_M_replace_aux # operator+= or assign growing buffer
std::vector<T>::_M_realloc_insert # push_back past capacity
std::vector<T>::_M_default_append # resize growing past capacity
std::_Rb_tree_insert_and_rebalance # std::map/set node insert
std::_Hashtable::_M_insert_unique_node # std::unordered_map bucket insertIf you see mangled names like _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE9_M_createERmm in the output, compile with -g and ensure binutils' c++filt is on the path — heaptrack invokes it automatically when available. You can also pipe any heaptrack output through c++filt manually:
heaptrack_print --print-allocators heaptrack.*.zst | c++filtUse --filter-symbol to cut through libstdc++ internals and focus on your own call sites:
# Show only chains that pass through your namespace
heaptrack_print --print-allocators \
--filter-symbol "myapp::" \
heaptrack.myapp.*.zstThe following example shows a string-heavy loop before and after optimization. The before version allocates a new heap buffer on every iteration; the after version reuses a pre-sized buffer and avoids all heap traffic inside the loop.
// BEFORE — every iteration allocates
std::vector<std::string> tokens;
for (int i = 0; i < 100'000; ++i) {
tokens.push_back("prefix_" + std::to_string(i)); // two allocations per iteration
}// AFTER — reserve + emplace_back eliminates reallocations;
// pre-built string avoids temporary
std::vector<std::string> tokens;
tokens.reserve(100'000);
for (int i = 0; i < 100'000; ++i) {
tokens.emplace_back("prefix_" + std::to_string(i)); // one allocation, no realloc
}In heaptrack, the "before" profile shows _M_realloc_insert repeatedly in the Allocations flame graph and a spike in Temporary Allocations for the std::to_string temporaries. After the fix, _M_realloc_insert disappears entirely and the Temporary frame shrinks to a single batch.
Heap Profiling in CI
Catching regressions before they ship is cheaper than triaging them in production. Heaptrack integrates naturally into CI pipelines — run it as a wrapper around your test binary, then inspect the compressed .zst output with heaptrack_print and a threshold check.
# Run tests under heaptrack, save output to a known path
heaptrack --output /ci/artifacts/heaptrack.zst ./tests --gtest_filter='*'Check peak heap against a budget with a self-contained shell script:
#!/usr/bin/env bash
set -euo pipefail
PROFILE="/ci/artifacts/heaptrack.zst"
LIMIT_BYTES=$((500 * 1024 * 1024)) # 500 MB
# Extract the peak heap line and convert the human-readable size to bytes
PEAK=$(heaptrack_print "$PROFILE" \
| grep "peak heap memory consumption" \
| awk '{print $NF}' \
| numfmt --from=iec)
echo "Peak heap: $(numfmt --to=iec "$PEAK") (limit: $(numfmt --to=iec "$LIMIT_BYTES"))"
if (( PEAK > LIMIT_BYTES )); then
echo "FAIL: peak heap exceeded limit"
exit 1
fi
echo "PASS"numfmt --from=iec converts strings like 312.4M to raw bytes, making the arithmetic comparison exact. Store the .zst file as a CI artifact so you can open it in heaptrack_gui locally when the threshold is breached. The heaptrack process itself exits with the same code as the profiled binary, so test failures propagate normally — you only need the extra threshold check for the memory budget.
Heaptrack respects a handful of environment variables that are useful in CI:
# Disable heaptrack in child processes (avoids double-profiling fork/exec'd helpers)
export HEAPTRACK_DISABLE=1
# Suppress heaptrack's own stderr chatter in CI logs
heaptrack --output /ci/artifacts/heaptrack.zst ./tests 2>/dev/null
# Annotate the profile with a build label for diff comparisons
heaptrack --output /ci/artifacts/heaptrack-${GIT_SHA}.zst ./testsFor multi-binary test suites, profile the full test runner rather than individual binaries — heaptrack follows fork/exec and records each subprocess into a separate .zst file named with the child's PID. You can then compare peak values across files with a loop:
for f in /ci/artifacts/heaptrack.*.zst; do
peak=$(heaptrack_print "$f" | grep "peak heap" | awk '{print $NF}')
echo "$f: $peak"
doneCustom Allocators and heaptrack
Heaptrack intercepts heap traffic at the C runtime boundary via LD_PRELOAD. It wraps malloc, calloc, realloc, free, operator new, and operator delete before your binary starts. Any allocator that ultimately calls one of those system entry points is tracked transparently — including Boost.Pool, tcmalloc (when used as a drop-in), and most custom arena allocators that delegate to ::operator new for their backing storage.
// Custom allocator backed by system malloc — fully tracked
template <typename T>
struct TrackingAllocator {
using value_type = T;
T* allocate(std::size_t n) {
return static_cast<T*>(::operator new(n * sizeof(T))); // intercepted
}
void deallocate(T* p, std::size_t) noexcept {
::operator delete(p); // intercepted
}
};PMR (std::pmr::polymorphic_allocator) behaves differently. A monotonic_buffer_resource makes a single large upstream allocation for its backing buffer and then hands out slices internally without calling malloc again. Heaptrack sees only that one large allocation:
1 allocations consuming 8.0 MB
in operator new(unsigned long)
std::pmr::monotonic_buffer_resource::_M_allocate_from_upstream
processRequests (server.cpp:114)This means the per-object allocation detail you would normally see in a std::vector<Foo> is invisible when those objects live inside a monotonic buffer. What you gain from PMR — reduced allocation count and better cache locality — is precisely why heaptrack shows a single large block. If you need to profile the internal layout of a PMR arena, instrument it manually with counters or use a custom memory_resource that records each bump-pointer advance.
When using jemalloc or tcmalloc as a drop-in malloc replacement, heaptrack's LD_PRELOAD shim intercepts calls before they reach the alternative allocator, so profiles remain accurate. However, tcmalloc's own gperftools profiler and jemalloc's jeprof offer allocator-internal statistics (thread cache efficiency, size-class fragmentation) that heaptrack cannot see. Use heaptrack for application-level hotspots and the allocator's own profiler when you suspect the allocator itself is the bottleneck.
Interpreting the GUI — heaptrack_gui Walkthrough
Each tab in heaptrack_gui answers a different question about your program's memory behavior. Open a .zst file and you will see six views; knowing what each one measures prevents misreading the data.
heaptrack_gui heaptrack.myapp.1234.zstSummary tab shows five numbers at a glance:
Total runtime: 4.2 s
Calls to allocation functions: 1,482,304
Temporary allocations: 891,200 # freed before peak
Peak heap memory consumption: 312.4 MB # (heap only)
Peak RSS (total process memory): 498.1 MB # heap + code + stack + mmap
Leaked memory: 0 bytesPeak RSS is always larger than peak heap because RSS includes the mapped text segment (your compiled code), the stack, memory-mapped files, and shared libraries. If RSS far exceeds heap peak, investigate anonymous mmap calls or shared-memory usage — heaptrack does not track those.
Allocations flame graph — width proportional to allocation count. Read bottom-up: the bottom frame is your entry point (main), frames above it are callees, and the widest top-level slices are the hottest allocators by call frequency. A wide slice for basic_string::_M_create above a narrow application frame means that one call site produces a disproportionate number of small string allocations.
Consumed flame graph — width proportional to bytes alive at peak. This is the most important view for understanding why your process reaches a given RSS. Functions that allocate and then free everything before peak will not appear wide here even if they appear wide in the Allocations graph.
Temporary Allocations flame graph — width proportional to allocations that were freed before peak. A wide slice here flags code that is generating short-lived heap objects — prime candidates for stack allocation (alloca or a local array), a thread_local buffer, or a pooled allocator.
Top-Down tree — shows callers at the root with callees expanding below. Useful for walking down from main to find which subsystem owns the most memory.
Bottom-Up tree — groups by the leaf allocating function first, then shows which callers reach it. Useful for finding all call sites that feed into a single expensive allocator like _M_realloc_insert.
heaptrack vs massif vs ASan LSan — Choosing the Right Tool
No single tool dominates; the right choice depends on what question you are asking. The table above summarizes capabilities; the notes below explain when each tool earns its overhead.
Heaptrack is the best default for interactive memory optimization work on Linux. Its 3–5× overhead is low enough to run on realistic workloads, the .zst file is compact enough to store as a CI artifact, and heaptrack_gui's flame graphs make allocation hotspots immediately visible without reading walls of text. Use it when you want to reduce peak memory, eliminate allocation churn, or confirm that an optimization actually moved the numbers.
Valgrind massif answers the same questions but at 20–30× overhead, which makes it impractical for long-running programs. Its advantage is that it can profile heap and stack memory separately and can track memory at the page level with --pages-as-heap. The ms_print workflow — valgrind --tool=massif ./myapp, then ms_print massif.out.* — produces a time-series ASCII graph of heap growth over program lifetime, which is more informative than heaptrack's single-peak snapshot when you need to understand when memory grows, not just where.
# massif workflow
valgrind --tool=massif --time-unit=ms ./myapp
ms_print massif.out.* | head -60 # ASCII heap timeline
massif-visualizer massif.out.* # GUI alternativeAddressSanitizer LeakSanitizer (LSan) is the right tool when you want leak detection baked into the compiler toolchain with minimal friction. It runs at roughly 2× overhead, requires no separate tool invocation, and integrates with CI sanitizer builds you likely already have. LSan finds leaks at program exit; it does not give you peak memory or allocation hotspot data. Use it as your baseline leak gate in every sanitizer build, and reach for heaptrack when you need to understand why memory is high, not just whether something leaked.
# LSan standalone (without full ASan overhead)
clang++ -fsanitize=leak -fno-omit-frame-pointer -g -o myapp myapp.cpp
./myapp
# Or combined with ASan (default on Linux)
clang++ -fsanitize=address -fno-omit-frame-pointer -g -o myapp myapp.cpp
ASAN_OPTIONS=detect_leaks=1 ./myappperf does not track allocations at all — it samples CPU time. The only memory-related signal you get from perf is cache miss rates and LLC (last-level cache) miss counts, which tell you about access patterns rather than allocation volume. Use perf when you suspect cache pressure or TLB thrashing is the bottleneck, not allocation overhead.
| Scenario | Recommended tool |
|---|---|
| Find where peak heap comes from | heaptrack |
| Detect leaks in CI sanitizer build | ASan + LSan |
| Understand heap growth over time | Valgrind massif |
| Profile a long-running server | heaptrack (lower overhead) |
| Diagnose cache miss patterns | perf |
| Confirm a PMR optimization reduced allocation count | heaptrack |
See Also
A complete memory optimization workflow typically combines at least two tools: heaptrack for profiling allocation patterns and ASan/LSan for catching correctness errors. The pages below cover each tool in depth.
- Valgrind — memcheck for use-after-free and uninitialized reads; massif for heap timeline profiling
- Address Sanitizer — compiler-integrated sanitizer for memory errors and leak detection
- perf — Linux hardware counter profiler for CPU hotspots and cache analysis
- profiling — overview of the C++ profiling toolchain and when to use each tool