Performance & Profiling
People choose C++ because it can be very fast, but C++ code is not fast automatically. A badly chosen algorithm or a hidden copy in a loop can make a C++ program slower than a careful Python script. The professional rule is simple: measure, then optimise the part that is actually slow. The C++ Core Guidelines put it as "Don't optimize without reason" (Per.1) and "Don't make claims about performance without measurements" (Per.6).
In this lesson you will build programs for speed, time code with <chrono>, write a micro-benchmark, find hot spots with a profiler, and learn the fixes that matter most in everyday C++.
Step 1: build the right way
Measuring a debug build tells you nothing about the real program. Always measure an optimised build:
g++ -std=c++23 -O2 -DNDEBUG app.cpp -o app # release build
g++ -std=c++23 -O2 -g -DNDEBUG app.cpp -o app-prof # release + debug info, for profilers-O2 is the normal release level. The second command adds -g so that a profiler can name your functions and lines; this does not slow the program down. In CMake this is the RelWithDebInfo build type. Two more options you will see:
-march=nativelets GCC use every instruction your CPU supports. The program may then crash with "illegal instruction" on an older CPU, so do not use it for programs you distribute.-flto(link-time optimisation) lets the compiler optimise across.cppfiles, for example inlining a function from another file.
Step 2: time the code
The <chrono> library gives you a steady clock, a clock that never jumps backwards when the system time changes, which makes it right for measuring durations.
#include <chrono>
#include <numeric>
#include <print>
#include <vector>
int main() {
using clock = std::chrono::steady_clock;
std::vector<long long> fares(10'000'000);
std::iota(fares.begin(), fares.end(), 1); // 1, 2, 3, ... 10,000,000
auto start = clock::now();
long long total = std::accumulate(fares.begin(), fares.end(), 0LL);
auto stop = clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(stop - start);
std::println("Total: {}", total);
std::println("Took {} ms", ms.count());
}Total: 50000005000000 Took 6 ms
The total is always the same; the time depends on your computer and will differ on every run. Always print or use the result of the work you time. If the result is never used, the optimiser may skip the work entirely and you will measure nothing. Run several times and look at the typical value, not the fastest or slowest single run.
Step 3: micro-benchmarks
Timing by hand is fine for whole programs. For comparing two versions of a small function, use a benchmark library such as Google Benchmark, which repeats the code until the timing is stable and reports the time per run.
#include <benchmark/benchmark.h>
#include <string>
#include <vector>
static void BM_BuildNames(benchmark::State& state) {
for (auto _ : state) {
std::vector<std::string> names;
names.reserve(1000);
for (int i = 0; i < 1000; ++i) {
names.push_back("Passenger " + std::to_string(i));
}
benchmark::DoNotOptimize(names.data());
}
}
BENCHMARK(BM_BuildNames);
BENCHMARK_MAIN();The code inside for (auto _ : state) is what gets timed. benchmark::DoNotOptimize tells the compiler that the value is used, so the work is not removed. Build benchmarks in release mode and link them with benchmark::benchmark in CMake.
Step 4: profile to find the hot spot
A profiler watches a running program and reports where the time goes. You rarely guess this correctly: the slow part is often a small helper called millions of times. On Linux, perf samples the program many times per second:
perf record -g ./app-prof # run the program and record samples
perf report # interactive table: functions sorted by timeThe report lists functions with the percentage of samples spent in each. Start with the top entry; improving a function that takes 2% of the time can never save more than 2%. Other good profilers are the Performance Profiler in Visual Studio (CPU Usage tool) on Windows, Instruments on macOS, and Valgrind's callgrind tool, which is slower but very detailed.
The fixes that matter most
1. A better algorithm beats any micro-tuning. Checking a list of 100,000 ticket IDs for duplicates by comparing every pair needs about 4,999,950,000 comparisons. A hash set needs about 100,000 insertions.
#include <cstddef>
#include <print>
#include <unordered_set>
#include <vector>
bool has_duplicate_slow(const std::vector<int>& ids) { // O(n^2)
for (std::size_t i = 0; i < ids.size(); ++i) {
for (std::size_t j = i + 1; j < ids.size(); ++j) {
if (ids[i] == ids[j]) return true;
}
}
return false;
}
bool has_duplicate_fast(const std::vector<int>& ids) { // O(n) on average
std::unordered_set<int> seen;
seen.reserve(ids.size());
for (int id : ids) {
if (!seen.insert(id).second) return true; // insert failed: already seen
}
return false;
}
int main() {
std::vector<int> ids = {1042, 2210, 3307, 2210, 5150};
std::println("{} {}", has_duplicate_slow(ids), has_duplicate_fast(ids));
}true true
Both give the same answer; only the growth differs. See Container Complexity & Iterator Invalidation for the cost of each container operation.
2. Avoid hidden copies. Passing a std::vector or std::string by value copies it on every call. Pass read-only arguments as const& (or std::string_view, std::span), and move objects you no longer need with std::move. The common culprit in loops is for (auto x : big_objects), which copies each element; write for (const auto& x : big_objects).
3. Allocate less. Every new, and every growing vector or string, asks the memory allocator for space, which is slow compared with arithmetic. Call reserve when you know the final size, reuse buffers inside loops instead of creating new ones, and build strings with += on one string rather than a + b + c in a loop.
4. Keep data together. Modern CPUs read memory in blocks called cache lines, and reading data that is next to the last item is very fast. A std::vector stores items side by side; a std::list scatters them across memory and follows pointers. For most work, a vector beats a list even when the textbook complexity says otherwise. The Core Guidelines call this "Access memory predictably" (Per.19).
5. Do not flush output in loops. std::endl writes a newline and flushes the output, which is slow when repeated many times. Use '\n' or std::println, and let the stream flush when it needs to.
Common mistakes
- Measuring a debug build.
-O0can be many times slower than-O2, and the slow parts are different. - Timing work whose result is unused. The optimiser deletes it, and you measure an empty loop.
- Trusting a single run. Other programs, CPU frequency changes and caches cause noise. Repeat and compare typical values.
- Guessing instead of profiling. Developers are famously bad at guessing hot spots.
- Using
-march=nativefor released programs. Users with older CPUs get crashes.
Key points
- Measure before and after every optimisation, always on an optimised build (
-O2, orRelWithDebInfofor profiling). - Use
std::chrono::steady_clockfor simple timings and a library such as Google Benchmark for small functions. - A profiler such as
perfor the Visual Studio profiler shows where time really goes. - The biggest wins usually come from better algorithms, fewer copies, fewer allocations and contiguous data.
- Prefer
'\n'tostd::endlin loops, andconst&or views for read-only parameters.
Exercise
This function is called once per passenger in a booking system and is slow. List every performance problem you can find, then rewrite it.
std::string label(std::vector<std::string> names, std::string city) { std::string out; for (auto n : names) { out = out + n + " (" + city + ")\n"; } return out; }
Show solution
#include <print>
#include <string>
#include <string_view>
#include <vector>
std::string label(const std::vector<std::string>& names, std::string_view city) {
std::string out;
out.reserve(names.size() * (city.size() + 20)); // a rough guess is enough
for (const auto& n : names) {
out += n;
out += " (";
out += city;
out += ")\n";
}
return out;
}
int main() {
std::print("{}", label({"Isha", "Farhan"}, "Kochi"));
}Isha (Kochi) Farhan (Kochi)
The original copied the whole vector and the city on every call, copied each name in the loop, and built a brand new string with out + n + ... on every iteration. The new version takes read-only parameters by reference or view, reserves space once, and appends to the same string.
Quick Quiz
1. What should you do before optimising code?
Measuring shows which part is slow; optimising anything else is wasted effort.
2. Why is std::chrono::steady_clock used for timing?
A steady clock is monotonic, so differences between two readings are reliable.
3. Why can a timed loop show 0 ms even though it should do a lot of work?
Use the result, or benchmark::DoNotOptimize, so the work is kept.
4. Why is std::vector usually faster to loop over than std::list?
Contiguous data is read in cache-line blocks; a list follows pointers to scattered nodes.
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
