EthicsComputerCourses
Join training +91 90532 10052

Performance & Profiling

C++ · Lesson 44 of 46 Advanced 7 min read Updated

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:

Terminal
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=native lets 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 .cpp files, 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.

C++
#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());
}
Output
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.

C++
#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:

Terminal
perf record -g ./app-prof        # run the program and record samples
perf report                      # interactive table: functions sorted by time

The 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.

C++
#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));
}
Output
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. -O0 can 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=native for released programs. Users with older CPUs get crashes.

Key points

  • Measure before and after every optimisation, always on an optimised build (-O2, or RelWithDebInfo for profiling).
  • Use std::chrono::steady_clock for simple timings and a library such as Google Benchmark for small functions.
  • A profiler such as perf or 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' to std::endl in loops, and const& 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
C++
#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"));
}
Output
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?

2. Why is std::chrono::steady_clock used for timing?

3. Why can a timed loop show 0 ms even though it should do a lot of work?

4. Why is std::vector usually faster to loop over than std::list?

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