C++ Memory Performance: Allocation, Copies and Locality
No collector means no pauses and no free lunch: cost moves to allocator behaviour, fragmentation, and copies the language will make for you silently. And on modern hardware, where your data sits usually matters more than how many instructions you execute.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
Deterministic destruction, non-deterministic allocation
RAII gives C++ something managed runtimes do not have: destruction happens at a known point, so there is no collector, no pause and no heap headroom to size. That is a genuine advantage for tail latency, and it is why the failure modes here look nothing like Garbage Collection: Pause, Throughput, Footprint — Pick Two.
What replaces it is allocator behaviour. new and delete are not free: a general-purpose allocator maintains size classes and free lists, may take a lock under contention, and occasionally asks the kernel for more memory. Under multi-threaded load, allocator contention becomes a real and easily-missed bottleneck — a profile showing significant self time inside malloc is usually a signal to allocate less rather than to change allocators, though switching to a thread-caching allocator is often a cheap first win.
Fragmentation is the other half, and it is why "resident memory grows and never returns" happens without any leak. Freed blocks that are too small or badly placed to satisfy later requests leave the heap holding memory it cannot reuse. The process is not leaking — every allocation has a matching free — and RSS still climbs. Distinguishing this from a true leak requires allocator statistics, not just the process memory graph (Memory Leaks: Growth That Does Not Come Back, Leak or Unbounded Cache? The Question That Picks the Fix).
| Cost | Shows up as | Measure with | Usual fix |
|---|---|---|---|
| Allocation frequency | malloc/free self time in the profile | CPU profile; allocation counters per operation | Reserve capacity up front; reuse buffers; arena/pool allocation |
| Allocator contention | Latency spikes under thread count, not under data size | Lock profiling; scaling test across thread counts | Thread-caching allocator (tcmalloc/jemalloc); per-thread pools |
| Fragmentation | RSS grows and never returns; no leak found | Allocator statistics (arenas, bins), not the RSS graph | Size-class-friendly allocation; arenas per lifetime; occasional recycle |
| Silent copies | Copy constructor time; allocation you did not write | Profile; -Wall plus explicit review of pass-by-value | Pass by reference; std::move; avoid unnecessary temporaries |
| Cache misses | Slow loops with low instruction counts | Hardware counters (perf stat), not instruction counts | Contiguous layout; struct-of-arrays; smaller working set |
The copies nobody asked for
C++ will copy for you silently, and each copy of a container is an allocation plus a memcpy. Passing a std::vector by value to a function that only reads it, returning by value in a way that defeats elision, storing into a container without reserve, or capturing by value in a lambda — each is a line that looks free and is not.
Move semantics exist to make transfer of ownership cheap, but only when the code lets them apply. std::move on a const reference silently does nothing. A copy into a container that is about to grow will still reallocate and copy everything. And a function taking const std::string& called with a string literal constructs a temporary anyway. None of these are exotic bugs; they are ordinary lines that a profile flags and a reading of the source does not.
The reason this matters for a *performance* domain rather than a C++ style guide: allocation and copy cost is the C++ equivalent of allocation pressure in a managed runtime. It is diagnosed the same way — profile, find the hot allocation site, remove the allocation rather than optimizing around it — even though the mechanism underneath is completely different.
1std::vector<Row> process(std::vector<Row> rows) { // by value: copies the caller's vector2 std::vector<Row> out; // no reserve: repeated reallocation3 for (const auto& r : rows) {4 std::string key = r.name + ":" + r.region; // temporary per row5 if (matches(key)) out.push_back(r); // copies each Row6 }7 return out;8}9 10// Called in a hot loop, this is where malloc self time comes from.11// The source reads as "a filter". The profile reads as "an allocator benchmark".1std::vector<Row> process(const std::vector<Row>& rows) { // borrow, no copy2 std::vector<Row> out;3 out.reserve(rows.size()); // one allocation, not log n4 for (const auto& r : rows) {5 if (matches(r.name, r.region)) { // no temporary built6 out.push_back(r); // or std::move(r) if rows is consumable7 }8 }9 return out; // elided, not copied10}11 12// Same algorithm, same complexity. Different allocation count by a large factor.Neither version changes the algorithm or its big-O. The difference is entirely in how many times memory is allocated and bytes are copied — which is exactly the class of cost that Algorithmic Cost in a Request Handler warns big-O does not describe.
Locality beats instruction count
Modern processors are far faster than main memory, so the practical cost of an operation depends heavily on whether its data is already in cache. A loop over a contiguous array and a loop over a linked list with the same number of elements and the same asymptotic complexity can differ by a large factor, entirely because one access pattern is prefetchable and the other is a chain of dependent loads.
This is the concrete version of the caveat attached to Algorithmic Cost in a Request Handler: big-O counts operations, and operations are not the unit of cost. For small n, an O(n) scan over a contiguous vector routinely beats an O(log n) lookup in a pointer-chasing tree, because the scan touches a handful of cache lines and the tree touches a handful of cache *misses*. Where the crossover sits depends on the hardware, the element size and the access pattern, which is why this is a measurement rather than a rule.
The design consequences are structural: prefer contiguous containers by default, consider struct-of-arrays when a hot loop touches one field of many, and treat working-set size as a first-class quantity. Verify with hardware counters (perf stat cache-miss rates) rather than reasoning, because intuition about locality is unreliable and the counters are cheap to read.
| Signal | Value | What it tells you | Verdict |
|---|---|---|---|
| instructions retired (list) | 1.00x baseline | Roughly the same amount of work by the traditional measure. | normal |
| instructions retired (vector) | 1.05x | Slightly more instructions — the version that is about to be much faster. | normal |
| L3 cache misses (list) | high | Every node is a dependent load into unrelated memory; prefetch cannot help. | smoking gun |
| L3 cache misses (vector) | low | Sequential access; the prefetcher stays ahead of the loop. | normal |
| wall time | list slower by a large factor | The gap comes from stalls waiting on memory, not from executing more instructions. | smoking gun |
| IPC (instructions per cycle) | list much lower | The core is idle waiting for data — the direct signature of a memory-bound loop. | smoking gun |
Key points
- No collector means no pauses; the cost moves to allocator behaviour, fragmentation and copies rather than disappearing.
- Allocator contention under many threads produces latency spikes that scale with thread count rather than with data size.
- RSS that grows and never returns can be fragmentation rather than a leak — allocator statistics distinguish them, the memory graph does not.
- C++ copies silently: pass-by-value, unreserved containers and temporaries are allocations that the source does not visibly contain.
- Cache locality frequently dominates instruction count; measure with hardware counters rather than reasoning about big-O.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1Hot path → allocator: a filter function takes its argument by value and builds a temporary string per row, producing several allocations per element.
- 2Allocator → threads: under 16 worker threads, allocator lock contention adds latency that grows with thread count while data volume is constant.
- 3Profile → attribution:
malloc,freeand copy constructors appear above business logic in self time, which is the signature of allocation-bound rather than compute-bound code. - 4Free pattern → fragmentation: many short-lived allocations of varying sizes leave the heap unable to reuse freed blocks, so RSS climbs under stable load with no leak present.
- 5Memory graph → misdiagnosis: RSS growth is reported as a leak, and hours are spent looking for a missing
deletethat does not exist.
- • "No GC, so memory is not a performance concern" — allocation, contention and fragmentation replace collection cost rather than eliminating it.
- • "RSS keeps growing, we have a leak" — check allocator statistics; fragmentation produces identical growth with every allocation correctly freed.
- • "Fewer instructions means faster" — a memory-bound loop stalls on cache misses; IPC exposes this and instruction counts do not.
- • "The algorithm is O(log n), it must beat the O(n) scan" — for small n on contiguous memory, the scan frequently wins. Measure the crossover.
- • "
std::movemade it cheap" — moving from a const reference silently copies, and a container still reallocates when it grows.
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Allocation count and bytes allocated per operation, which is the C++ analogue of allocation rate in a managed runtime.
- • `malloc`/`free` and copy-constructor self time in a CPU profile, which points at the hot allocation site directly.
- • Allocator statistics (arena count, bin utilization, fragmentation ratio) to distinguish fragmentation from a genuine leak.
- • Hardware counters via `perf stat`: cache-miss rate and instructions-per-cycle, where low IPC indicates a memory-bound loop.
- • A thread-count scaling test, since allocator contention shows up as sublinear scaling with flat data volume.
- • Remove allocations from hot paths: `reserve` up front, pass by reference, avoid temporaries, reuse buffers across iterations.
- • Use a thread-caching allocator (jemalloc, tcmalloc) when profiles show allocator contention — cheap to try, sometimes a large win.
- • Pool or arena-allocate objects with a common lifetime, which reduces both allocation count and fragmentation.
- • Restructure hot data for locality: contiguous containers, struct-of-arrays where a loop touches one field, smaller working sets.
- • Verify each change with hardware counters, since locality improvements are the ones most likely to be imagined rather than real.
- • Allocation count per operation drops measurably, and `malloc` self time falls out of the top of the profile.
- • Latency scaling across thread counts becomes closer to linear, confirming allocator contention was the constraint.
- • RSS stabilizes under sustained stable load, distinguishing a fragmentation fix from a leak fix that was never needed.
- • Cache-miss rate falls and IPC rises for the restructured loop — without these, a "locality improvement" is unproven.
- • Buffer reuse and pooling reduce allocation and introduce lifetime bugs — use-after-free and cross-request data exposure are the failure modes, and they are worse than the cost they avoid.
- • Arena allocation is fast and coarse: memory is reclaimed all at once, which is excellent for request-scoped work and wrong for long-lived objects.
- • Struct-of-arrays layouts improve loop locality and make the code harder to read and to keep consistent.
- • Alternative allocators improve contention and change memory footprint and fragmentation behaviour, so the trade needs measuring under production-like load.
- • Allocation counts per operation asserted in a benchmark test, so a reintroduced pass-by-value fails CI rather than a dashboard.
- • A microbenchmark on the hot path retained across releases, read with the caveats in Microbenchmark or End-to-End: Why p99 Did Not Move firmly in mind.
- • Thread-scaling tests in CI to catch newly-introduced allocator or lock contention.
- • RSS-under-soak-test tracking, which separates fragmentation growth from leak growth before either reaches production.
Accuracy
Performance numbers are conditional. These are the conditions.
- RUNTIME-SPECIFICAllocator behaviour differs substantially between glibc malloc, jemalloc and tcmalloc, and between platforms. Fragmentation and contention characteristics are properties of the allocator, not of C++.
- ENVIRONMENT-SPECIFICCache-locality effect sizes depend on CPU cache hierarchy, working-set size, element size and access pattern. The direction of the effect is general; the magnitude must be measured on your hardware.
Misconceptions
Apply it
Where the depth lives
Why contiguous access is fast and pointer chasing is slow is a hardware fact; the counters expose it, but the explanation lives below the OS.