Runtimec++allocationraiicache localitymove semantics

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.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
My C++ service has no GC pauses but latency is still spiky — where is the memory cost hiding?
Symptom
Tail latency spikes with no collector to blame, resident memory that grows and never returns under stable load, and a profile where `malloc`, `free` and copy constructors appear above business logic.
Signal
Allocation counts per operation and allocator statistics (arena counts, fragmentation) confirm it; cache-miss counters confirm the locality half. Instruction counts mislead — a loop can execute fewer instructions and run slower because of where its data lives.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

Deterministic destruction, non-deterministic allocation

Runtime-specific · C++ with a general-purpose allocator (glibc malloc, jemalloc, tcmalloc). Behaviour differs substantially between allocators.

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).

Memory costs without a collector
CostShows up asMeasure withUsual fix
Allocation frequencymalloc/free self time in the profileCPU profile; allocation counters per operationReserve capacity up front; reuse buffers; arena/pool allocation
Allocator contentionLatency spikes under thread count, not under data sizeLock profiling; scaling test across thread countsThread-caching allocator (tcmalloc/jemalloc); per-thread pools
FragmentationRSS grows and never returns; no leak foundAllocator statistics (arenas, bins), not the RSS graphSize-class-friendly allocation; arenas per lifetime; occasional recycle
Silent copiesCopy constructor time; allocation you did not writeProfile; -Wall plus explicit review of pass-by-valuePass by reference; std::move; avoid unnecessary temporaries
Cache missesSlow loops with low instruction countsHardware counters (perf stat), not instruction countsContiguous layout; struct-of-arrays; smaller working set

The copies nobody asked for

Runtime-specific · C++

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.

Four allocations and two full copies per call
1std::vector<Row> process(std::vector<Row> rows) { // by value: copies the caller's vector
2 std::vector<Row> out; // no reserve: repeated reallocation
3 for (const auto& r : rows) {
4 std::string key = r.name + ":" + r.region; // temporary per row
5 if (matches(key)) out.push_back(r); // copies each Row
6 }
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".
Borrow, reserve, move
1std::vector<Row> process(const std::vector<Row>& rows) { // borrow, no copy
2 std::vector<Row> out;
3 out.reserve(rows.size()); // one allocation, not log n
4 for (const auto& r : rows) {
5 if (matches(r.name, r.region)) { // no temporary built
6 out.push_back(r); // or std::move(r) if rows is consumable
7 }
8 }
9 return out; // elided, not copied
10}
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

Runtime-specific · C++ on modern cache-hierarchy hardware; effect sizes vary widely with CPU, working-set size and access pattern.

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.

Two implementations of the same traversal, same element countILLUSTRATIVE
SignalValueWhat it tells youVerdict
instructions retired (list)1.00x baselineRoughly the same amount of work by the traditional measure.normal
instructions retired (vector)1.05xSlightly more instructions — the version that is about to be much faster.normal
L3 cache misses (list)highEvery node is a dependent load into unrelated memory; prefetch cannot help.smoking gun
L3 cache misses (vector)lowSequential access; the prefetcher stays ahead of the loop.normal
wall timelist slower by a large factorThe gap comes from stalls waiting on memory, not from executing more instructions.smoking gun
IPC (instructions per cycle)list much lowerThe 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.

  1. 1
    Hot path → allocator: a filter function takes its argument by value and builds a temporary string per row, producing several allocations per element.
  2. 2
    Allocator → threads: under 16 worker threads, allocator lock contention adds latency that grows with thread count while data volume is constant.
  3. 3
    Profile → attribution: malloc, free and copy constructors appear above business logic in self time, which is the signature of allocation-bound rather than compute-bound code.
  4. 4
    Free 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.
  5. 5
    Memory graph → misdiagnosis: RSS growth is reported as a leak, and hours are spent looking for a missing delete that does not exist.
What this evidence makes people conclude — wrongly
  • "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::move made 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.

How to measure it
  • • 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.
What actually fixes it
  • • 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.
How you know it worked
  • • 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.
What it costs
  • • 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.
Stop it coming back
  • 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.

What these numbers depend on
  • 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

Claim
“C++ has no garbage collector, so memory performance is not a concern.”
Reality
The cost moves rather than disappearing: allocator contention under threads, fragmentation that grows RSS without any leak, and silent copies that allocate. These are diagnosed with different tools than GC pauses but occupy the same place in a profile.
Claim
“Growing RSS under stable load means a leak.”
Reality
Fragmentation produces exactly that shape with every allocation correctly freed. Allocator statistics distinguish them; the process memory graph cannot.
Claim
“The lower-complexity algorithm is faster.”
Reality
Big-O counts operations, and on modern hardware the unit of cost is closer to the cache miss. A contiguous O(n) scan routinely beats a pointer-chasing O(log n) structure at small n, and the crossover point is a measurement rather than a rule.

Apply it

Where the depth lives

Computer architecture
Cache hierarchy and prefetching

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.