This episode covers C++ performance optimization: profiling with perf, Valgrind, and compiler tools, memory layout optimization and cache friendliness, compiler optimization flags, inline and loop unrolling, as well as the tradeoff between readability and performance.

C++ is chosen for its performance — but performance doesn't come by itself. The same code can run hundreds of times slower just because of poor memory layout or wrong compiler flags. The good news: C++ gives you the tools to measure and optimize.
Episode 15 teaches you how to think about performance: measure first with profiling before changing anything, understand compiler optimization flags, realize how big an impact the cache has, and maintain a balance between fast code and code humans can read.
Optimizing without measurement is guessing. perf is a lightweight profiler built into Linux that measures CPU, cache misses, and branch mispredictions. Build with -pg or debug symbols, then profile:
g++ -g -O2 -std=c++20 app.cpp -o app
perf stat ./app
perf record -g ./app
perf reportperf stat ./app shows a summary — how long the execution took, how many cache misses. perf record -g ./app records call stacks, and perf report shows which functions are the hottest. Pay attention to the cache misses column: high numbers signal a memory locality problem.
Valgrind checks for leaks and memory errors, and callgrind provides a more detailed profile without hardware support. Run:
valgrind --leak-check=full ./appvalgrind --leak-check=full ./app reports every leaked block along with its stack trace. Programs run much slower under Valgrind, so use it on debug builds. You can also use -fsanitize=address, already covered in episode 11, as a faster alternative.
C++ compilers have optimization levels that trade compilation time and binary size for speed: -O0 (no optimization, for debugging), -O1, -O2 (production default), -O3 (aggressive optimization), and -Os (size optimization). -march=native enables instructions available on your CPU:
g++ -O3 -DNDEBUG -march=native -std=c++20 app.cpp -o app-O3 enables aggressive optimizations like inlining and vectorization. -DNDEBUG removes all asserts — important because assert is only active in debug builds. -march=native targets the local CPU, but don't use it for distributed binaries because the code won't run on older CPUs.
-O3 and fast-math can change program behavior. -ffast-math assumes no NaN and ignores the sign of zero, which is valid for numerical simulation but can break code that depends on IEEE 754. Measure the impact; if -O2 is fast enough, you don't need -O3.
Reading from RAM is much slower than from the L1 cache. Programs that jump around in memory spend their time waiting for data. The key to cache friendliness is locality: sequential memory access, and data used together placed close together.
Contiguous containers like std::vector store elements adjacently, so sequential iteration uses the cache well. std::list, whose elements are scattered randomly, is actually slow to iterate even though insertion is O(1):
cat > cache.cpp <<'EOF'
#include <iostream>
#include <vector>
int main() {
std::vector<std::vector<int>> grid(1000, std::vector<int>(1000, 1));
long long total = 0;
for (int i = 0; i < 1000; ++i) {
for (int j = 0; j < 1000; ++j) {
total += grid[i][j];
}
}
std::cout << total << "\n";
}
EOF
g++ -O2 -std=c++20 cache.cpp -o cache
./cacheThe loop grid[i][j] with row-first order reads memory contiguously — this is the cache-friendly row-major pattern. Reversing the order to grid[j][i] would make the program much slower because it jumps between distant rows.
The order of fields in a struct affects its size and cache behavior. Arrange fields from largest to smallest to reduce padding. This rule matters for large struct arrays iterated constantly. sizeof(struct) reveals the real result — a small optimization with a big effect on hot data.
Inlining copies a function body to the call site, eliminating call overhead. With -O2 and above, the compiler decides on its own which functions to inline. The inline keyword is only a suggestion; __attribute__((always_inline)) forces it. Rule of thumb: let the compiler choose — too much inlining bloats the instruction cache.
Loop unrolling duplicates the loop body so each iteration processes several elements, reducing condition-checking overhead. The compiler does this automatically at -O3; manual unrolling with a pragma is available if needed:
#include <iostream>
#include <vector>
#pragma GCC unroll 4
void jumlah(const std::vector<int>& v, long long& total) {
for (int x : v) {
total += x;
}
}#pragma GCC unroll 4 asks the compiler to open up the loop 4 times per iteration. Before using a pragma or other micro-optimizations, measure first — the compiler often already produces good code without intervention.
Lots of code is "optimized" prematurely — writing micro-optimizations before there's evidence of need. The result is complicated, hard-to-maintain code that often isn't even faster because the compiler already handled it. The principle the industry holds:
There are two architectural decisions with more impact than micro-optimizations: choosing the right container (episode 8) and avoiding data copies with references and move semantics (episode 16). Make it a habit to start there.
Tip
Amdahl's law: the overall speedup is bounded by the slowest, most-called part. Profiling tells you which part truly needs optimization.
Here's what to take away:
-O2 is the production default; -O3 and -march=native as needed.std::vector is more cache-friendly than std::list.In the next episode, episode 16, we'll discuss modern C++ features — auto, range-based loops, and structured bindings, move semantics with rvalue references, std::optional, std::variant, std::any, and coroutines, as well as modules, concepts, and constexpr improvements.