CodeRigor

FREE LESSON

Profiling and benchmarking

Measure before optimising: honest timing loops, the right statistic, keeping the optimiser from deleting your benchmark, and reading perf counters.

Performance intuition is wrong surprisingly often. The function you're sure is slow turns out to take 2% of the time; the innocent-looking std::to_string in a loop turns out to dominate. This skill is about getting numbers you can trust: how to time code honestly, which statistic to report, how to stop the optimiser from deleting the thing you're measuring, and how to ask the CPU itself where the time goes.

1. Measure before optimising

The workflow is always the same:

  1. Reproduce the slow case with realistic input.
  2. Measure the whole thing (wall time) to get a baseline.
  3. Profile to find where the time goes — don't guess.
  4. Change one thing, measure again, keep it only if it helped.

Amdahl's law keeps you honest: if a function takes 10% of the run time, making it infinitely fast saves at most 10%. Profile first so you spend effort where the time actually is.

Amdahl's law: the part you don't speed up sets the ceiling
Total speedup 1 / ((1 − p) + p / s) when a fraction p of the run gets s× faster. Speeding up a 10% hot spot infinitely gains at most 1.11×; a 50% one, at most 2×.
Amdahl's law: the part you don't speed up sets the ceiling
Total speedup 1 / ((1 − p) + p / s) when a fraction p of the run gets s× faster. Speeding up a 10% hot spot infinitely gains at most 1.11×; a 50% one, at most 2×.

2. Which clock?

Clock Measures Use for
std::chrono::steady_clock wall time, monotonic — never jumps backwards timing code (the default choice)
std::chrono::system_clock calendar time; can jump when NTP adjusts it timestamps, not intervals
std::chrono::high_resolution_clock an alias of one of the two above (implementation-defined) nothing — say what you mean
std::clock() CPU time of the process (all threads) "was it computing or waiting?"

Wall time vs CPU time answer different questions. A program that sleeps, waits for disk or blocks on a lock has wall time ≫ CPU time; a program using 8 threads can have CPU time ≈ 8 × wall time.

C++
#include <chrono>
#include <cstdio>
#include <ctime>
#include <print>

int main() {
    auto w0 = std::chrono::steady_clock::now();
    std::clock_t c0 = std::clock();

    volatile long sink = 0;
    for (long i = 0; i < 2'000'000; ++i) sink = sink + i;  // busy: CPU ≈ wall

    auto wall = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - w0);
    double cpu = 1000.0 * static_cast<double>(std::clock() - c0) / CLOCKS_PER_SEC;
    std::println("wall {:.1f} ms, cpu {:.1f} ms", wall.count(), cpu);
}

Durations in <chrono> are typed: nanoseconds converts to milliseconds only with an explicit duration_cast (it would lose precision), while the other direction is implicit. duration<double, std::milli> is the easy way to print fractional milliseconds.

3. Repetitions, warm-up, and the right statistic

One measurement is a sample of code + noise. Noise comes from interrupts, other processes, CPU frequency changes and the cache state you happened to start in — and it only ever adds time. So:

  • Warm up first: run the code once untimed so caches, page tables, branch predictors and lazily-initialised state are in their steady state.
  • Repeat and summarise:
    • minimum — the best estimate of the code's intrinsic cost; ideal for comparing two versions of a small, deterministic function;
    • median — a robust "typical" run; better when the work genuinely varies (allocation, I/O, contention);
    • mean — sensitive to outliers; one 5 ms hiccup ruins a set of 100 µs samples. Report it only alongside a spread.
Noise only ever adds time
Ten runs of the same code (illustrative). Two hiccups drag the mean to 119.5 µs; the median (101.5) and minimum (100) stay put. Compare versions by their minimum.
Noise only ever adds time
Ten runs of the same code (illustrative). Two hiccups drag the mean to 119.5 µs; the median (101.5) and minimum (100) stay put. Compare versions by their minimum.

Worked example: best of N

C++
#include <algorithm>
#include <chrono>
#include <print>
#include <vector>

template <class T>
inline void do_not_optimize(const T& value) {
    asm volatile("" : : "r,m"(value) : "memory");  // see §4
}

template <class F>
std::chrono::nanoseconds best_of(F&& f, int reps) {
    f();  // warm-up, not timed
    auto best = std::chrono::nanoseconds::max();
    for (int i = 0; i < reps; ++i) {
        auto t0 = std::chrono::steady_clock::now();
        f();
        auto t1 = std::chrono::steady_clock::now();
        best = std::min(best, std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0));
    }
    return best;
}

int main() {
    std::vector<int> v(100'000, 1);
    auto t = best_of([&] {
        long s = 0;
        for (int x : v) s += x;
        do_not_optimize(s);
    }, 9);
    std::println("best of 9: {} us", t.count() / 1000);
}

Each sample brackets exactly one call with two now() reads. Taking now() once before the loop and once after would also time the loop overhead and smear the samples together, so you'd lose the minimum.

Faded example: the median

Collect every sample instead of only the best, then fill in the median — including the even-count case:

C++
std::ranges::sort(samples);
std::size_t mid = samples.size() / 2;
auto median = /* ??? */;
Answer

samples.size() % 2 == 1 ? samples[mid] : (samples[mid - 1] + samples[mid]) / 2. With an even count there are two middle samples; their mean is the median. std::chrono durations support + and / directly.

4. The optimiser will delete your benchmark

At -O2, code whose result is never used is dead, and the compiler removes it. Measured on this machine (1M-element sum, best of 7):

Text
                       -O1        -O2        -O3
sum, result unused     282 µs     0.02 µs    0.02 µs
sum, result kept       284 µs     284 µs     142 µs

The "0.02 µs" isn't a fast sum — there's no sum. Open the Playground's Compiler tab at -O2 and the loop has vanished from the assembly.

Benchmark libraries prevent this with two compiler barriers, which you can write yourself with GCC/Clang inline assembly:

C++
// "value is used by something you can't see": the computation must happen.
template <class T>
inline void do_not_optimize(const T& value) {
    asm volatile("" : : "r,m"(value) : "memory");
}

// "all memory may have been read or written": stores can't be dropped or
// moved across this point.
inline void clobber_memory() {
    asm volatile("" : : : "memory");
}

The harness this app uses has the same tool: crucible::do_not_optimize.

Two more optimiser tricks to watch for:

  • Hoisting. Calling a pure function with the same arguments in a timing loop lets the compiler compute it once and reuse the result, even when you pass the result to do_not_optimize. Vary an input every iteration.
  • Constant folding. If the input is a compile-time constant, the whole computation may happen at compile time. Generate inputs at run time (from a seed, or argv), never from literals.

5. Microbenchmark pitfalls

  • Cache warmth. Timing the same 4 KB array a thousand times measures L1 cache speed. If production data doesn't fit in cache, benchmark with production-sized data (or flush between runs).

  • Branch predictor training. Repeating identical input trains the predictor perfectly. Measured here, a loop that adds only the elements ≥ 128 of a million random bytes:

    GCC -O2 (branchless cmov)            unsorted 568 µs   sorted 564 µs
    GCC -O2 -fno-if-conversion (branch)  unsorted 3472 µs  sorted 434 µs
    

    With a real branch, unsorted data is 8× slower — every other branch is mispredicted. GCC normally avoids the branch altogether, which is why the classic "sorted is faster" demo often shows nothing. Always look at the asm before explaining a timing.

  • Frequency scaling and turbo. CPUs change clock speed with load and temperature. Warm up, keep runs short and repeated, and compare versions in the same session (interleave A, B, A, B).

  • Noisy neighbours. Other processes, other agents, a browser. Use the minimum, pin to a core (taskset -c 3 ./bench) on a quiet machine, and distrust differences under ~5%.

6. Benchmarks at scale: Google Benchmark, and this app's probe

Google Benchmark (#include <benchmark/benchmark.h>) packages all of the above. A benchmark is a function with a State loop; the library decides how many iterations to run, repeats, and reports statistics:

C++
static void BM_dedupe(benchmark::State& state) {
    auto v = make_input(state.range(0));
    for (auto _ : state) {
        benchmark::DoNotOptimize(dedupe_keep_first(v));
    }
    state.SetComplexityN(state.range(0));
}
BENCHMARK(BM_dedupe)->RangeMultiplier(2)->Range(1 << 10, 1 << 16)->Complexity();

->Complexity() fits the times against O(1), O(n), O(n log n), O(n²)… This app's complexity probe does something similar and simpler:

  1. Run your code at several sizes n (say 2048, 4096, 8192, 16384), keeping the minimum of a few repetitions at each size.
  2. Fit a straight line to log(time) vs log(n). Its slope is the exponent: ≈ 1 for O(n), ≈ 2 for O(n²), a little over 1 for O(n log n).
  3. Compare with the reference solution measured the same way, and flag yours only if the slope is clearly steeper and it's at least 3× slower at the largest size — so timing noise alone can't fail you.
On log–log axes, the slope is the exponent
Doubling n doubles an O(n) time (slope 1) and quadruples an O(n²) one (slope 2). The probe fits this slope for your code and the reference.
On log–log axes, the slope is the exponent
Doubling n doubles an O(n) time (slope 1) and quadruples an O(n²) one (slope 2). The probe fits this slope for your code and the reference.

7. Asking the CPU: perf

Timers tell you how long; hardware counters tell you why. On Linux, perf reads them. (It isn't available inside this app's sandbox; run it on your own machine. If it refuses, lower /proc/sys/kernel/perf_event_paranoid or run as root.)

perf stat — whole-program counters (illustrative output):

Text
$ perf stat -e cycles,instructions,cache-misses,branch-misses ./analyse big.log

 Performance counter stats for './analyse big.log':

     4,812,330,117      cycles
     1,402,118,905      instructions        #  0.29  insn per cycle
        61,903,224      cache-misses
         1,208,447      branch-misses

       1.271 seconds time elapsed

Read it top-down. IPC (instructions per cycle) of 0.29 on a core that can retire 4+ per cycle means it's mostly waiting — here on memory, given 62M cache misses. Pointer-chasing data structures look like this. A high branch-miss count with good cache behaviour points at unpredictable branches instead.

perf record / perf report — where the time goes (illustrative output):

Text
$ perf record -g ./analyse big.log
$ perf report --stdio
  Children  Self  Symbol
    92.1%   0.4%  main
    71.3%  45.2%  count_by_user
    25.8%  25.8%  __memcmp_avx2_movbe
    14.6%  14.6%  malloc

Self is time in the function itself; Children includes its callees. Here count_by_user and the memcmp/malloc it triggers are the whole story — that's where to look. Build with -g (and -fno-omit-frame-pointer for reliable call stacks) so the report has names and lines.

Flame graphs (perf script | stackcollapse-perf.pl | flamegraph.pl) draw the same call stacks as nested bars whose width is time — the fastest way to spot the one wide tower in a big program.

8. Compiler flags that matter

  • -O2 is the sane default for release builds; -O3 adds more aggressive inlining and vectorisation (it halved the sum above), sometimes at the cost of code size. Measure both.
  • -march=native lets the compiler use every instruction your CPU has (AVX2, AVX-512…). The binary may not run on older machines.
  • LTO (-flto) optimises across translation units — inlining a function from another .cpp file.
  • PGO (-fprofile-generate, run a representative workload, -fprofile-use) tells the compiler which branches are hot. Typical wins: 10–30% on branchy code.

The Playground's Compiler tab shows the assembly for each of these flags side by side — the quickest way to see what a flag actually changed.

Summary

  • Measure, profile, change one thing, measure again.
  • Time intervals with steady_clock; know whether you want wall or CPU time.
  • Warm up; report the minimum for deterministic code, the median for noisy work; distrust the mean.
  • Keep results alive (do_not_optimize), vary inputs, and check the asm.
  • Use perf stat to learn why (IPC, cache and branch misses) and perf record to learn where.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Live benchmarking with Google Benchmark and perf, DoNotOptimize/ClobberMemory, and reading the results honestly.

Profiling loops down to the instruction level with perf annotate — and the surprising reasons code gets slower.

Why layout effects make naive benchmarks lie (from ~7:16), statistically sound measurement with Stabilizer, and causal profiling with Coz.

Sources of measurement noise, quiescing a machine, and the statistics of comparing two programs.

Game-dev context: how to investigate a performance problem — find the real bottleneck first, then pick the fix — instead of applying rules of thumb.

Game-dev/GPU context: LOD and impostors make things faster, but not for the reason most people think — a good case for measuring and explaining from the hardware up.

What -O0/-O1/-O2/-O3/-Os/-Og/-Oz actually change, and which to benchmark with.