High Performance
By the end of this lesson you'll be able to make C++ run several times faster the right way: measure first, lay your data out so the CPU cache loves it, stop copying things you don't need to, and turn on the compiler optimisations that do the heavy lifting for you.
Part of the free C++ course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 Real-World Analogy
Think of the CPU as a chef and memory as a giant warehouse. The chef keeps a tiny counter (the cache) right next to the stove. When ingredients sit together on one shelf (a contiguous std::vector ), an assistant grabs a whole tray at once and the chef never waits. When ingredients are scattered all over the warehouse (a node-based std::list ), the assistant runs back and forth for each one — that round trip to RAM is roughly 100× slower than reaching the counter. Most "slow" C++ isn't slow because of clever maths; it's slow because the chef is standing around waiting for memory. Performance work is mostly about keeping the right things on the counter, and about not copying trays you only need to read .
1. Measure First — Never Guess
The golden rule of optimisation is measure, don't guess . Programmers are notoriously bad at predicting where time goes — most of a program's runtime hides in a tiny slice of the code, and the rest is irrelevant. So before you change anything, put a clock around the work and get a real number. A std::chrono timer is all you need to compare two versions of the same task.
Once you can measure, you can optimise honestly: change one thing, re-run, and keep the change only if the number actually drops. Everything below follows the same pattern — a slow "before" and a fast "after", with a timer proving the difference.
2. Cache Locality — Keep Data Contiguous
The CPU never fetches one byte at a time. It pulls a cache line — usually 64 bytes — into a small, blazing-fast cache. A std::vector stores its elements in one contiguous block , so walking it reads sequential bytes the CPU can prefetch; this is called data-oriented design — laying memory out for how you'll read it. A node-based container like std::list scatters each element somewhere different, so every step risks a cache miss — a trip to RAM that's about 100× slower. The lesson: prefer contiguous containers unless you genuinely need constant-time insertion in the middle.
3. Avoid Unnecessary Copies
A huge amount of accidental slowness comes from copying data you only meant to read . Passing a big std::vector or std::string by value duplicates the whole thing on every call. The fix is to pass large, read-only arguments by const reference ( const T& ) — the function gets a read-only alias to the original and copies nothing. Pass small types ( int , double , a pointer) by value; pass big ones you won't modify by const& . This is the single highest-value habit in everyday C++ performance.
Your turn. The function below copies the whole vector — and every string inside it — on every call. Change the parameter to a const reference and the loop item to a const string& so nothing is copied. Fill in the two blanks marked ___ :
4. reserve() — Stop Reallocating
When you push_back into a growing std::vector , it occasionally runs out of room, allocates a bigger block, and copies every element it already holds into the new block. Do that as the vector grows from 1 to a million and you've copied a lot of data many times over. If you know roughly how many items you'll add, call vec.reserve(n) once up front: the vector grabs the whole block immediately and never re-copies. It's one line and it's basically free speed.
Now you try. The loop below knows it will add N items, so it should reserve that space first. Fill in the blank with the right call:
5. Inlining, Move & Compiler Flags
Some speed isn't yours to win by hand — it belongs to the compiler. Inlining pastes a small function's body straight into the caller, removing the call overhead; at -O2 the compiler does this for hot functions automatically, so you rarely write inline for speed. Move semantics ( std::move ) hand ownership of a big buffer across instead of copying it. And the biggest lever of all is the optimisation flag : compile a release build with -O2 and the same source can run several times faster than an unoptimised -O0 build.
6. Branch Prediction (Briefly)
Modern CPUs guess which way an if will go before they know the answer, so they can keep working. Guess right (predictable data) and the branch is nearly free; guess wrong (random data) and the CPU throws away ~15–20 cycles of work. You usually don't micro-manage this, but two practical takeaways hold: processing sorted data makes branches predictable, and a branchless expression like sum += (x >= 128) * x; avoids the penalty entirely in a hot loop. As always — measure before reaching for either.
🔎 Deep Dive: SoA vs AoS
Array of Structures (AoS) groups all the fields of one object together: vector<Particle> where each Particle holds x, y, z, mass, type . It reads naturally, but if a loop only needs x , every cache line also drags in y , z , mass and type — mostly wasted.
Structure of Arrays (SoA) flips it: one array per field — vector<float> x, y, z; . Now a loop over all x values reads perfectly sequential memory, so the whole cache line is useful. SoA shines for batch processing (update every particle's position), which is why game engines and simulations lean on it.
Rule of thumb: AoS when you use most fields of one object at a time; SoA when you sweep one field across everything.
Pro Tips
- 💡 Profile, don't guess: tools like perf or a chrono timer show the real hot spot; it's rarely where you expected.
- 💡 Default to std::vector : contiguous memory makes it the fastest container for most workloads — reach for list / map only with a measured reason.
- 💡 Pass big read-only args by const& : the cheapest performance habit there is — no copy, no surprises.
- 💡 Always build releases with -O2 : an unoptimised binary can be several times slower for free reasons.
Common Errors (and the fix)
- Premature optimisation: rewriting code to be "fast" before measuring usually just makes it harder to read with no real gain. Get it correct, profile it, then optimise the one hot spot the numbers point to.
- Accidental copies: void f(vector<int> v) copies the whole vector on every call. Take it by const reference — void f(const vector<int>& v) — when you only read it.
- Per-item copies in a loop: for (string s : words) copies every string. Use for (const string& s : words) to read them in place.
- Forgetting reserve() : building a big vector with push_back and no reserve reallocates and re-copies repeatedly. Call v.reserve(n); when you know the size.
- Benchmarking a debug build: timing an -O0 binary tells you almost nothing about release speed. Always measure with -O2 (or -O3 ) on.
📋 Quick Reference
Goal
Do this
Why
Find the slow part
chrono / perf
Measure, don't guess
Cache locality
std::vector
Contiguous = fast
Read big arg
const T& arg
No copy
Loop a container
for (const T& x : c)
No per-item copy
Build a vector
v.reserve(n);
One allocation
Transfer ownership
std::move(x)
Steal, don't copy
Release build
g++ -O2 file.cpp
Compiler optimises
Batch one field
SoA layout
Sequential reads
Frequently Asked Questions
Mini-Challenge: Prove Your Optimisation
No blanks this time — just a brief and an outline. Build a word counter that takes its data by const& , time it with std::chrono , and print the count and the microseconds. The point isn't only to make it fast — it's to prove it with a number, exactly like a real performance engineer.
🎉 Lesson Complete
- ✅ Measure first with a std::chrono timer — never guess where the time goes
- ✅ Prefer contiguous std::vector over node-based containers for cache locality
- ✅ Pass big read-only arguments by const& ; loop with const T& to avoid copies
- ✅ Call reserve(n) to remove repeated reallocations; use std::move to transfer instead of copy
- ✅ Let -O2 handle inlining and the rest; only chase branch prediction once you've measured
- ✅ Lay data out as SoA for batch field sweeps, AoS for whole-object access
- ✅ Next lesson: Data Structures — choose the right container for each job
Practice quiz
What is the golden rule of optimisation?
- Always use -O3
- Inline every function
- Measure, don't guess
- Rewrite hot loops in assembly first
Answer: Measure, don't guess. Programmers are bad at predicting where time goes. Put a clock around the work and get a real number before changing anything.
Why is std::vector usually faster to traverse than std::list?
- Its elements are contiguous, so the CPU can prefetch and cache them
- It uses less memory per element only
- It has a smaller interface
- It never reallocates
Answer: Its elements are contiguous, so the CPU can prefetch and cache them. A vector stores elements in one contiguous block (cache-friendly). A list scatters nodes in memory, so each step risks a cache miss ~100x slower.
How should you pass a large, read-only std::vector into a function?
- By value
- By raw pointer copy
- By std::move
- By const reference (const T&)
Answer: By const reference (const T&). Passing by const reference hands the function a read-only alias to the original, copying nothing. Passing by value duplicates the whole vector.
What does vec.reserve(n) do?
- Inserts n default elements
- Allocates room for n elements up front so the vector won't repeatedly reallocate
- Shrinks the vector to n
- Sorts the first n elements
Answer: Allocates room for n elements up front so the vector won't repeatedly reallocate. reserve(n) grabs the whole block once. Without it a growing vector reallocates and copies every element several times as it expands.
In modern C++, do you usually need to write 'inline' to make a hot function fast?
- No — at -O2 the compiler inlines small hot functions for you
- Yes, always
- Only for member functions
- Only for templates
Answer: No — at -O2 the compiler inlines small hot functions for you. Modern compilers decide what to inline. Today 'inline' is mainly about allowing a definition in a header without breaking the one-definition rule.
What is the difference between -O2 and -O3?
- -O3 is always faster
- -O2 disables optimisation
- -O2 is the safe default; -O3 adds more aggressive optimisations that sometimes help and sometimes hurt
- They are identical
Answer: -O2 is the safe default; -O3 adds more aggressive optimisations that sometimes help and sometimes hurt. -O2 turns on standard, well-tested optimisations. -O3 adds aggressive ones (extra vectorisation, unrolling) — build with -O2, then measure -O3.
What does std::move(big) do to a std::string big?
- Copies big's buffer
- Transfers ownership of big's buffer to the target, leaving big empty
- Deletes big
- Sorts big's characters
Answer: Transfers ownership of big's buffer to the target, leaving big empty. Move hands ownership across instead of copying. After string moved = std::move(big), moved holds the buffer and big.size() is 0.
In SoA (Structure of Arrays) layout, when does it shine compared to AoS?
- When you touch all fields of one object at a time
- When objects are never accessed
- When using std::map
- When a loop sweeps one field across all objects (batch processing)
Answer: When a loop sweeps one field across all objects (batch processing). SoA keeps each field in its own array, so a loop over one field reads sequential memory. AoS is better when you use most fields of one object.
What happens on a branch misprediction in a hot loop?
- The program crashes
- The CPU throws away ~15-20 cycles of speculative work
- Nothing measurable
- The branch is removed
Answer: The CPU throws away ~15-20 cycles of speculative work. Modern CPUs guess which way an if goes. A wrong guess wastes cycles. Sorted data makes branches predictable; a branchless expression avoids the penalty.
Why should you avoid benchmarking an -O0 (debug) build?
- It uses too much memory
- It cannot run loops
- An unoptimised binary can be several times slower, telling you nothing about release speed
- It always crashes
Answer: An unoptimised binary can be several times slower, telling you nothing about release speed. Timing an -O0 binary is meaningless for release performance. Always measure with -O2 (or -O3) on.
Continue this course
- Previous: C++ Networking
- Next: Data Structures