High-Performance Code
Reviewed & published by Brayan K
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 worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Measure before optimising with a simple chrono timer
- Choose contiguous std::vector over node-based containers for cache locality
- Avoid needless copies with const&, move, and pass-by-value rules
- Use reserve() to remove repeated vector reallocations
- Understand inlining, branch prediction, and -O2 / -O3
- Lay data out as SoA vs AoS for batch processing
💡 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.
#include <iostream>
#include <vector>
#include <chrono>
using namespace std;
int main() {
// RULE #1 of performance: MEASURE, do not guess.
// A tiny timer lets you compare two versions of the SAME work.
const int N = 1000000;
vector<int> data(N, 1); // a million 1s
// Record the clock, do the work, record it again.
auto start = chrono::high_resolution_clock::now();
long long sum = 0;
for (int i = 0; i < N; i++) sum += data[i]; // the work we time
auto end = chrono::high_resolution_clock::now();
auto us = chrono::duration_cast<chrono::microseconds>(end - start).count();
cout << "Sum: " << sum << endl; // Sum: 1000000
cout << "Took: " << us << " microseconds" << endl; // e.g. Took: 900 microseconds
// Only AFTER you have a number is it worth changing anything.
return 0;
}
// ⚠️ No expected-output panel for this one, on purpose.
// These numbers are stopwatch readings. They depend on the machine, the
// compiler and what else it is doing, so only the direction of the
// comparison is the lesson — never the figures.
//
// One real run on the machine that builds this site printed:
// Sum: 1000000
// Took: 2690 microsecondsOnce 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.
#include <iostream>
#include <vector>
#include <list>
#include <chrono>
using namespace std;
// Time a block of work and print how long it took, in microseconds.
template <typename Func>
long long timeIt(Func work) {
auto start = chrono::high_resolution_clock::now();
work();
auto end = chrono::high_resolution_clock::now();
return chrono::duration_cast<chrono::microseconds>(end - start).count();
}
int main() {
const int N = 1000000;
// vector = ONE contiguous block -> the CPU reads sequential bytes
// and prefetches the next ones. Cache-friendly.
vector<int> vec(N, 1);
// list = each node lives ANYWHERE in memory -> every step may be a
// cache MISS (RAM is ~100x slower than the L1 cache).
list<int> lst(N, 1);
long long vsum = 0, lsum = 0;
auto vecTime = timeIt([&]() { for (int x : vec) vsum += x; });
auto lstTime = timeIt([&]() { for (int x : lst) lsum += x; });
cout << "vector sum: " << vsum << " in " << vecTime << " us" << endl;
cout << "list sum: " << lsum << " in " << lstTime << " us" << endl;
cout << "vector is usually several times faster -> contiguous wins" << endl;
return 0;
}
// ⚠️ No expected-output panel for this one, on purpose.
// These numbers are stopwatch readings. They depend on the machine, the
// compiler and what else it is doing, so only the direction of the
// comparison is the lesson — never the figures.
//
// One real run on the machine that builds this site printed:
// vector sum: 1000000 in 10833 us
// list sum: 1000000 in 14557 us
// vector is usually several times faster -> contiguous wins3. 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.
#include <iostream>
#include <vector>
#include <string>
#include <chrono>
using namespace std;
template <typename Func>
long long timeIt(Func work) {
auto s = chrono::high_resolution_clock::now();
work();
auto e = chrono::high_resolution_clock::now();
return chrono::duration_cast<chrono::microseconds>(e - s).count();
}
// ❌ BEFORE: takes the vector BY VALUE -> copies all N strings on EVERY call.
long long countLongBefore(vector<string> words) { // <-- copy!
long long n = 0;
for (size_t i = 0; i < words.size(); i++)
if (words[i].size() > 3) n++;
return n;
}
// ✅ AFTER: takes it BY CONST REFERENCE -> reads the original, copies nothing.
long long countLongAfter(const vector<string>& words) { // <-- no copy
long long n = 0;
for (const string& w : words) // const& again: no per-item copy
if (w.size() > 3) n++;
return n;
}
int main() {
vector<string> words(200000, "performance");
auto before = timeIt([&]() { countLongBefore(words); });
auto after = timeIt([&]() { countLongAfter(words); });
cout << "BEFORE (by value): " << before << " us" << endl;
cout << "AFTER (by const ref): " << after << " us" << endl;
cout << "Same result, far less work -> the copy was pure waste." << endl;
return 0;
}
// ⚠️ No expected-output panel for this one, on purpose.
// These numbers are stopwatch readings. They depend on the machine, the
// compiler and what else it is doing, so only the direction of the
// comparison is the lesson — never the figures.
//
// One real run on the machine that builds this site printed:
// BEFORE (by value): 31879 us
// AFTER (by const ref): 2593 us
// Same result, far less work -> the copy was pure waste.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 ___:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// 🎯 YOUR TURN — this function copies the whole vector on every call.
// Make it take the data BY CONST REFERENCE so nothing is copied.
// 1) Change the parameter type from a copy to a const reference
long long totalLength(___ names) { // 👉 const vector<string>& names
long long total = 0;
// 2) Loop WITHOUT copying each string: use a const reference item
for (___ name : names) // 👉 const string&
total += name.size();
return total;
}
int main() {
vector<string> names = {"Ada", "Grace", "Alan", "Linus"};
cout << "Total characters: " << totalLength(names) << endl;
// ✅ Expected output: Total characters: 16
return 0;
}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.
#include <iostream>
#include <vector>
#include <chrono>
using namespace std;
template <typename Func>
long long timeIt(Func work) {
auto s = chrono::high_resolution_clock::now();
work();
auto e = chrono::high_resolution_clock::now();
return chrono::duration_cast<chrono::microseconds>(e - s).count();
}
int main() {
const int N = 2000000;
// ❌ No reserve: the vector grows, reallocates, and COPIES every
// element it already holds, again and again as it expands.
auto noReserve = timeIt([&]() {
vector<int> v;
for (int i = 0; i < N; i++) v.push_back(i);
});
// ✅ reserve(N): allocate the whole block ONCE, then just fill it.
auto withReserve = timeIt([&]() {
vector<int> v;
v.reserve(N); // one allocation, zero re-copies
for (int i = 0; i < N; i++) v.push_back(i);
});
cout << "Without reserve: " << noReserve << " us" << endl;
cout << "With reserve: " << withReserve << " us" << endl;
cout << "reserve() pays off whenever you know the size ahead of time." << endl;
return 0;
}
// ⚠️ No expected-output panel for this one, on purpose.
// These numbers are stopwatch readings. They depend on the machine, the
// compiler and what else it is doing, so only the direction of the
// comparison is the lesson — never the figures.
//
// One real run on the machine that builds this site printed:
// Without reserve: 34716 us
// With reserve: 29322 us
// reserve() pays off whenever you know the size ahead of time.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:
#include <iostream>
#include <vector>
using namespace std;
int main() {
const int N = 1000;
vector<int> squares;
// 🎯 YOUR TURN — you already KNOW you will add N items.
// 1) Reserve room for all of them up front (one allocation, no re-copies)
squares.___; // 👉 reserve(N)
// 2) Fill the vector — this part already works
for (int i = 1; i <= N; i++)
squares.push_back(i * i);
cout << "Count: " << squares.size() << endl; // Count: 1000
cout << "Last square: " << squares.back() << endl; // Last square: 1000000
// ✅ Expected output:
// Count: 1000
// Last square: 1000000
return 0;
}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.
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// A tiny "hot" function. At -O2 the compiler INLINES it for you:
// it pastes the body into the caller, removing the call overhead.
// (You almost never need to write 'inline' yourself for speed.)
inline int square(int x) { return x * x; }
vector<int> makeData(int n) {
vector<int> v;
v.reserve(n);
for (int i = 0; i < n; i++) v.push_back(square(i));
return v; // returned by MOVE (no copy) thanks to the compiler
}
int main() {
// std::move hands ownership across instead of copying.
string big(1000, 'x');
string moved = std::move(big); // 'moved' steals big's buffer, no copy
cout << "moved size: " << moved.size() << endl; // moved size: 1000
cout << "big size: " << big.size() << endl; // big size: 0 (moved-from)
vector<int> data = makeData(5); // built once, moved out, never copied
cout << "data: ";
for (int x : data) cout << x << " "; // data: 0 1 4 9 16
cout << endl;
// Build it ALL with: g++ -O2 program.cpp -o program
// -O2 = the safe, standard optimisation level for release builds.
return 0;
}
// ✅ Expected output:
// moved size: 1000
// big size: 0
// data: 0 1 4 9 166. 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.
// AoS — good when you touch ALL fields of one object
struct Particle { float x, y, z, mass; };
vector<Particle> aos(N);
// SoA — good when you touch ONE field across all objects
struct Particles { vector<float> x, y, z, mass; };
Particles soa; // looping over soa.x is pure sequential accessRule 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 |
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.
#include <iostream>
#include <vector>
#include <string>
#include <chrono>
using namespace std;
int main() {
// 🎯 MINI-CHALLENGE: optimise a word counter and prove it is faster
//
// 1. Make a vector<string> with ~100000 copies of some word.
// 2. Write a function that counts words longer than 4 letters,
// taking the vector BY CONST REFERENCE (const vector<string>&)
// and looping with a const string& item (no per-item copies).
// 3. Time it with chrono::high_resolution_clock (see earlier examples).
// 4. Print the count and the microseconds it took.
// 5. BONUS: build with -O2 and again with -O0 and compare the numbers.
//
// ✅ Example output (numbers vary by machine):
// Long words: 100000
// Took: 350 microseconds
// your code here
return 0;
}🎉 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
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 (Sockets, Protocol Handling, Async I/O)
- Next: Data Structures in C++: Trees, Graphs, Heaps with STL & Custom Code — Implement and use trees, heaps, graphs, and tries in C++
- Quick reference: C++ cheat sheet
Frequently asked questions
Should I optimise my code from the start?
No. Write clear, correct code first, then measure where it is actually slow with a profiler or a timer, and optimise only that part. Most of a program's time is spent in a tiny fraction of the code, so guessing wastes effort and makes the rest harder to read. This is the rule 'measure, do not guess'.
Why is std::vector usually faster than std::list?
A std::vector stores its elements in one contiguous block of memory, so walking through it reads sequential bytes that the CPU can prefetch and cache. A std::list scatters each node anywhere in memory, so every step is a cache miss that can cost 100x more than a cache hit. Unless you do a lot of inserting and removing in the middle, prefer vector.
What is the difference between passing by value and by const reference?
Passing by value copies the whole argument into the function — cheap for an int, expensive for a big std::vector or std::string. Passing by const reference (const T&) hands the function a read-only alias to the original, so nothing is copied. Pass small types by value and large read-only types by const reference.
What does reserve() actually do?
vec.reserve(n) tells the vector to allocate room for n elements up front. Without it, a growing vector reallocates and copies every element several times as it expands. If you know roughly how many items you will add, reserving once removes all of those repeated copies.
Do I need to write 'inline' to make functions fast?
Almost never. Modern compilers decide what to inline on their own, and at -O2 they inline small hot functions for you. The inline keyword today is mainly about allowing a definition in a header without breaking the one-definition rule. Focus on -O2, good data layout, and fewer copies instead.
What is the difference between -O2 and -O3?
-O2 turns on the standard, well-tested optimisations and is the safe default for release builds. -O3 adds more aggressive ones such as extra vectorisation and loop unrolling, which sometimes help and sometimes make code bigger and slower. Build with -O2, then measure -O3 to see if it actually wins for your program.