AlgoMaster Logo

Performance Optimization

Low Priority17 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Performance optimization in C++ is less about clever tricks and more about discipline. Most slowdowns come from a small handful of root causes: bad data layout, the wrong container, surprise allocations, and code paths nobody actually profiled. This chapter walks through how to find those root causes, what the compiler already does for you, and which optimizations are usually worth the time.

The Optimization Mindset

Donald Knuth's famous line is, "Premature optimization is the root of all evil." The full quote is more careful: he is warning against optimizing the 97% of code that does not matter, while still encouraging hard work on the critical 3%. The point is not to skip optimization. Find the 3% before touching it.

In practice this means three rules:

  1. Make it correct first. A fast wrong answer is still wrong.
  2. Make it clean second. Clean code is easier to profile and easier to change.
  3. Make it fast last, and only where measurement says it matters.

Most C++ code does not need to be fast in any micro sense. It needs to be fast in the macro sense, which usually means picking the right data structures, avoiding accidental copies, and not allocating in a hot loop. The compiler will handle most of the rest.

The big mistake is reasoning about performance without measuring. Modern CPUs, caches, branch predictors, and out-of-order execution make intuition unreliable. A loop that "looks slow" might be free because it fits in L1 cache. A loop that "looks fast" might stall on cache misses every iteration. The only reliable answer is measurement.

The diagram is opinionated on purpose. Without a "yes" to "profiled?", every change is a guess. Guesses sometimes help, but they also add complexity that no one can justify later.

Measure First: Profilers and Benchmarks

There are two kinds of measurement, and they answer different questions.

Profilers answer "where is time being spent across this whole program?" You attach a profiler to a running binary, do realistic work, and the profiler tells you which functions burned the most cycles. Common tools:

ToolPlatformNotes
perfLinuxBuilt into the kernel. Sampling-based, low overhead. Pair with perf record and perf report.
InstrumentsmacOSShips with Xcode. Good for time profiling and allocations.
VTuneIntel CPUs, cross-platformDeep CPU-level insight (cache misses, branch mispredicts).
TracyCross-platformFrame-by-frame profiler, popular in games and real-time systems.
Callgrind (Valgrind)Cross-platformSlower, deterministic, instruction-level. Useful when sampling misses short paths.

Benchmarks answer "how fast is this one piece of code in isolation?" You run a specific function many times under controlled conditions and measure how long each run takes. The standard tool in C++ is Google Benchmark, which handles warm-up, iteration count, and timer noise for you.

A small do-it-yourself benchmark using <chrono>, useful for a quick number without pulling in a library:

That number means nothing on its own. It only matters compared to another version of the same code or a known target ("must process a million prices in under 5 ms"). Always benchmark against a baseline.

std::chrono::high_resolution_clock::now() itself takes tens of nanoseconds. Timing something that runs in 5 nanoseconds means clock noise is bigger than the work. Wrap many iterations inside one timing block.

A few rules for trustworthy numbers:

  • Compile with optimizations on (-O2 minimum). Debug builds lie.
  • Run the code many times and take the median, not the average. Outliers from OS scheduling skew averages.
  • Make sure the compiler does not delete your code as dead. Either use the result (print it, accumulate it) or use Google Benchmark's benchmark::DoNotOptimize.
  • Disable CPU frequency scaling for the run, or accept noisy results.
  • Benchmark on hardware similar to production, not on a laptop with thermal throttling.

Reading a Flame Graph

Flame graphs visualize profiler output. The x-axis is total time, not chronological time. Each box is a function. The width of a box is how much time was spent in that function (and its callees). Boxes stacked on top mean "this function called that function".

When reading one, look for wide, flat plateaus. A wide box at the top of the stack is a hot leaf function, which means actual work is happening there. A wide box near the bottom is a hot call site, which means you might be calling something hot too often. Narrow tall stacks are usually fine; they say "we go deep, but we do not stay long".

The first time reading a flame graph, look for surprises: is std::string::operator== 30% of runtime? Is 15% spent in malloc? Those are real findings. Anything below 1% is usually noise.

Compiler Optimization Levels

The compiler does more than many developers realize. Picking the right optimization level often gives a 5x to 50x speedup on the same source code.

FlagWhat it doesWhen to use
-O0No optimization. Variables stay in memory, easy to debug.Debug builds only.
-O1Basic optimizations (dead code removal, simple inlining).Faster than -O0 with similar debug-friendliness.
-O2Most safe optimizations. The standard "release" level.Default for production builds.
-O3-O2 plus aggressive inlining, vectorization, loop unrolling.When measurement shows a real gain over -O2.
-OsOptimize for size. Drops some speed optimizations that bloat code.Embedded systems, binaries that need to fit.
-Ofast-O3 plus math optimizations that break IEEE 754 strictness.Numeric code where you accept slight FP differences.

-O3 is not automatically faster than -O2. Aggressive inlining can hurt instruction cache locality, and vectorization can pessimize code that does not vectorize cleanly. Measure both.

Two more flags worth knowing:

Link-Time Optimization (`-flto`) lets the compiler optimize across translation units. Without LTO, a function defined in cart.cpp cannot be inlined into order.cpp, even at -O3. With LTO, the linker re-runs optimization with the whole program visible. Builds get slower, binaries get faster.

Profile-Guided Optimization (PGO) is a two-step process: compile an instrumented binary, run it on realistic input, then recompile using the recorded profile. The compiler uses the profile to make smarter inlining and branch-prediction decisions. PGO can give 5-20% on top of -O2 for branchy code like compilers, browsers, and interpreters.

-O0 builds can run 10-100x slower than -O2. Never benchmark a debug build and conclude that C++ is slow.

Cache-Friendly Data

The biggest wins usually live here, and intuition is often most wrong. Modern CPUs are not bottlenecked on arithmetic. They are bottlenecked on memory.

The numbers above are approximate, but the ratio is what matters. Reading from main memory is roughly 100x slower than reading from L1. A loop that misses cache on every iteration can run 50x slower than the same loop with sequential access, even though the source code is identical.

Two patterns dominate:

  • Spatial locality: if you access address X, you will probably access addresses near X soon. CPUs exploit this by fetching whole cache lines (typically 64 bytes) at a time. Touch one double (8 bytes), and seven more nearby doubles come along for free.
  • Temporal locality: if you access address X, you will probably access X again soon. Caches keep recently-used data hot.

Most "this code is mysteriously slow" problems trace back to violating one of these.

Vector vs List

std::vector stores its elements in one contiguous block of memory. std::list stores each element as its own heap-allocated node with pointers between them. The asymptotic complexity tables make std::list look great (O(1) insert anywhere, O(1) splice), but on modern hardware those big-O guarantees are misleading.

The vector is roughly 20x faster, doing identical work. Every list node is a separate heap allocation, scattered across memory. Each step has to follow a pointer, which is a cache miss the prefetcher cannot predict. The vector iterates through one contiguous block and the hardware prefetcher handles it well.

std::list requires roughly 3 pointers of overhead per element (prev, next, allocation metadata). For small POD types like int, that is 3-6x more memory than std::vector, on top of being cache-hostile. Use std::vector by default and use std::list only when O(1) splice or stable iterators under arbitrary insertion are required.

Array of Structs vs Struct of Arrays

A collection of objects can be laid out two ways. The default in C++ is Array of Structs (AoS): each object has all its fields together, and you keep an array of objects. The alternative is Struct of Arrays (SoA): keep one array per field, and an object is the tuple of values at the same index across all arrays.

Why does this matter? Computing the average price across a million products with the AoS layout reads id and stock into cache on every iteration even though they're unused. The SoA layout reads only the prices array, packing 8 doubles into each 64-byte cache line.

The SoA version is roughly 4x faster because it does not waste cache reading fields it ignores. SoA also vectorizes more cleanly, so -O3 can use SIMD instructions on it.

SoA is not free. It makes "give me one product" awkward (you have to gather fields from multiple arrays), and it complicates code. Use it for bulk processing of one or two fields across many objects. Stick with AoS for everything else.

Hot and Cold Field Splitting

A related trick: if a struct has a few fields touched in every hot loop and many fields touched only on the slow path, split them. Put the hot fields in one struct, the cold ones in a separate struct (or behind a pointer), and each cache line carries more hot data.

The hot loop now touches 24 bytes per product instead of ~200. About 2.5 hot products fit in a cache line now, versus a third of a product before.

Container Choice for Performance

Algorithmic complexity is necessary but not sufficient. A good default rule for C++ today is "start with std::vector, switch only when measurement forces you to."

ContainerBest forCost watch-outs
std::vectorDefault sequence container. Bulk processing, random access.Insert/erase in the middle is O(n). Reallocation invalidates pointers and iterators.
std::dequePush/pop on both ends.Random access is slightly slower than vector. Memory is segmented, not contiguous.
std::listFrequent insert/erase in the middle, splicing without copying.Cache-hostile. 3+ pointers of overhead per node. Often slower than vector even with shuffles.
std::unordered_mapO(1) average lookup by key.Hashing cost. Memory overhead. Iteration order is unstable.
std::mapSorted iteration, range queries, lookup by key.O(log n), but each step is a pointer chase through a tree node.
std::flat_map (C++23)Key-value with vector-like layout.O(log n) lookup, O(n) insert. Faster than std::map on small/medium sizes.

A few specifics:

`reserve()` before bulk inserts. std::vector grows by doubling, which means inserting N elements without reserving costs roughly N + N/2 + N/4 + ... ≈ 2N total copies. Reserving first avoids all the intermediate reallocations.

vector::push_back is amortized O(1), but the reallocations along the way move every existing element. For small N this is invisible. For millions of elements, reserve() typically cuts time by 2-3x.

`unordered_map` vs `map`. For lookup only, unordered_map is usually 2-3x faster on average. For sorted iteration or range queries, map is required. For tiny sizes (under ~30 keys), a sorted std::vector<std::pair<K, V>> with std::lower_bound often beats both because of cache locality.

Avoid `std::list` for small POD. As shown above, the cache penalty almost always swamps the algorithmic benefit. Fast splicing is the one case where std::list is the better choice.

Move vs Copy

Move semantics lets you transfer ownership of resources without copying them. A std::vector<std::string> with a million entries can be moved in a few pointer swaps; copying it allocates a million strings on the heap.

Move is essentially free here because the new vector steals the old one's pointer, size, and capacity. A few things to know:

  • Move only helps when the type owns something (heap memory, file handles, sockets). Moving an int is the same as copying it.
  • The compiler often elides copies entirely under Named Return Value Optimization (NRVO). Returning a local std::vector by value does not usually copy in modern compilers.
  • std::move does not move anything by itself. It casts to an rvalue reference, which lets a move constructor or move assignment operator kick in.

Small Object Optimization

std::string and some other types use Small String Optimization (SSO) or Small Buffer Optimization (SBO) to avoid heap allocation for short strings. They keep a small fixed buffer inside the object itself (typically 15-23 characters, depending on the standard library), and only fall back to heap allocation when the string outgrows it.

The practical takeaway: storing short identifiers, customer names, and short labels in std::string is usually allocation-free. There's no need to roll your own short-string class. But concatenating strings in a loop can flip a short string into a long one and trigger a heap allocation; reserve capacity up front when the final size is known.

Heap allocations cost roughly 50-200 nanoseconds on a typical glibc allocator, depending on contention. A loop that does ten million allocations spends most of its time inside malloc, not in the user code.

Parameter Passing

The right convention depends on the size and cost of the type.

Parameter typeHow to pass
int, double, small enum, pointerBy value. Cheap to copy, no indirection.
Non-trivial type that you only readBy const reference (const std::vector<int>&). Avoids the copy.
Non-trivial type that you want to consume/storeBy value, then std::move inside. Lets the caller move or copy.
Non-trivial type you want to modify in placeBy non-const reference.

A common mistake: passing std::string by value to a getter or comparison function. Each call allocates and copies. Use const std::string& or std::string_view (C++17). std::string_view is a non-owning view, so it has none of the SSO/heap concerns of std::string and is the right type for "read these characters".

Inlining

The inline keyword started life as a performance hint and has since drifted toward an ODR (one-definition rule) marker. The compiler inlines based on its own heuristics: function size, call frequency, whether the body is visible at the call site, and so on.

What inline does today:

  • It tells the linker "this function can appear in multiple translation units; pick any definition."
  • It serves as a suggestion to the compiler, which the compiler will usually ignore in favor of its own analysis.

Inlining matters because a function call has overhead (push arguments, jump, return), and inlining lets the compiler optimize across the call boundary. For tiny accessor functions like getPrice(), inlining is almost always a win. For a 200-line function, it inflates code size and can hurt instruction cache.

LTO (link-time optimization) helps here. Without LTO, the compiler cannot inline a function defined in another .cpp file. With LTO, the linker sees all source and can inline freely. If the hot path crosses translation units, turning on -flto often gives a meaningful gain.

Branch Prediction

CPUs guess which way a branch (if, loop condition, switch) will go and start executing the predicted path. If they guess right, work continues. If they guess wrong, the pipeline flushes and restarts, costing 10-20 cycles per mispredict.

The classic illustration: summing a random array versus a sorted one.

Same data, same result, 3x faster after sorting. The sorted version has predictable branches (the if is false for the first half of the array, then true for the rest), so the CPU's branch predictor is right most of the time.

C++20 added [[likely]] and [[unlikely]] attributes to give the compiler hints about which branch to favor in code layout:

Use these sparingly. The compiler is usually right without hints. Use them only when profiling shows a branch mispredict problem and the hot side is known.

Allocation Cost

new, malloc, and any container that grows (vector reallocation, unordered_map rehash, string append) call into the allocator, which is not free.

Patterns that reduce allocation pressure:

  • Reserve up front for vectors, strings, and unordered containers when you know the size.
  • Reuse buffers across iterations instead of allocating fresh ones inside a loop.
  • Use stack storage when the size is small and known at compile time (std::array).
  • Use arena or pool allocators for many short-lived objects of the same size.

Arena allocators (also called bump allocators) hand out memory by advancing a pointer through a pre-allocated chunk. Freeing is either nothing (free the whole arena at once) or trivial (decrement the pointer). Pool allocators preallocate a fixed-size chunk and recycle slots. They can cut allocation cost from "hundreds of nanoseconds" to "a few cycles" in the right scenario.

A new for a 100-byte object typically costs 50-100 ns on glibc. An arena allocation for the same object can cost 1-2 ns. In a tight loop, that is a 50x difference in allocation overhead alone.

Premature Optimization Patterns

A few patterns show up often in code that has been "optimized" without measurement:

  • Over-templating. Turning everything into a template to avoid std::function indirection, ending up with code that takes 30 seconds to compile and is twice as long. Useful in hot generic code, useless elsewhere.
  • Aggressive `constexpr`. Putting constexpr on every function in case it might run at compile time. Most of them never do, and the contortions required to make them work hurt readability.
  • Hand-rolled containers. Writing a custom hash table because std::unordered_map "is slow". For 99% of code, the standard one is fine. Profile first.
  • `__attribute__((always_inline))` everywhere. Bloats the binary, hurts instruction cache, rarely helps.
  • Manual loop unrolling. The compiler does this with -O2 or -O3. Hand-unrolling makes code uglier without changing the generated assembly.
  • Bit-packing structs prematurely. Saves memory in theory, costs CPU cycles in practice when fields are read.
  • Replacing `std::vector` with raw arrays. Almost never faster. The standard vector compiles to the same loops at -O2.

The thread running through all of these is the same: optimization without measurement is superstition. The principle that holds up best is C++'s own design rule, often phrased as "don't pay for what you don't use, and what you do use should be as efficient as a hand-written version." Trust that principle, and let the profiler point at the actual cost.

Quiz

Performance Optimization Quiz

10 quizzes