The C++ Textbook

Part 7 · Performance

Measuring, not guessing

Benchmarks that tell the truth.

By the end of this chapter you can

  • Write a microbenchmark that is not optimized away
  • Explain why timing one run tells you nothing
  • Profile a program and find where the time goes

Almost every performance intuition you have is wrong, including the ones that turn out to be right — because you did not know which was which until you measured. The previous chapter showed the compiler deleting work you thought you had written. This one is about not being fooled by your own stopwatch.

Start with a warning about this page. The Run button compiles at -O0 with sanitizers on, because the priority everywhere else in this book is catching your mistakes, not timing your code. Sanitized -O0 code runs roughly five times slower than -O2 and, crucially, does not perform the optimisations this chapter is about. Every timing sample below will print honest numbers for what it actually ran — they just will not be the numbers your release build produces. The -O2 figures quoted in the prose come from running the same programs outside the browser. That gap is the first lesson, not a caveat about it.

One number is not a measurement

The same work, fifteen times
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <numeric>
#include <vector>

template <class T>
inline void keep(const T& value) {          // explained in the next section
    asm volatile("" : : "r,m"(value) : "memory");
}

int main() {
    std::vector<int> data(50'000);
    std::iota(data.begin(), data.end(), 0);

    std::vector<double> samples;
    for (int run = 0; run < 15; ++run) {
        auto start = std::chrono::steady_clock::now();
        long long total = 0;
        for (int x : data) total += x;
        auto end = std::chrono::steady_clock::now();
        keep(total);
        samples.push_back(std::chrono::duration<double, std::micro>(end - start).count());
    }

    std::vector<double> sorted = samples;
    std::sort(sorted.begin(), sorted.end());
    double mean = std::accumulate(samples.begin(), samples.end(), 0.0) / samples.size();

    std::printf("first run: %8.2f us\n", samples.front());
    std::printf("min:       %8.2f us\n", sorted.front());
    std::printf("median:    %8.2f us\n", sorted[sorted.size() / 2]);
    std::printf("mean:      %8.2f us\n", mean);
    std::printf("max:       %8.2f us\n", sorted.back());
    std::printf("max/min:   %8.2fx\n", sorted.back() / sorted.front());
}

Run it several times. Identical work, identical input, one process — and the slowest sample is routinely 25% above the fastest, sometimes two or three times it. Nothing about the program changed between samples. What changed was the machine: another process got scheduled, the CPU changed frequency, a page was faulted in, the cache was evicted.

Two consequences follow, and they are the whole of benchmarking hygiene.

Repeat, and report a distribution. A single timing is a sample from a noisy process. Reporting it as “this takes 21 µs” is a claim you did not measure.

Report the minimum, not the mean, for a microbenchmark. This is counterintuitive and it is correct. All the noise here is additive — the machine can only make your code slower, never faster than it actually is. The minimum is therefore the best estimate of the true cost, and the mean is the true cost plus however much interference you happened to collect. The median is a reasonable middle ground and is more robust when the workload itself varies.

Which clock

<chrono> offers three, and only one of them is right for this.

Clock Use it for
std::chrono::steady_clock measuring durations — guaranteed never to go backwards
std::chrono::system_clock wall-clock time, dates, timestamps in logs
std::chrono::high_resolution_clock nothing; it is an alias for one of the others

system_clock tracks civil time, so NTP can adjust it, and it can jump backwards while your benchmark is running — producing a negative duration or a suspiciously fast result. high_resolution_clock is permitted to be an alias for system_clock, and on libstdc++ it is exactly that, so it inherits the problem while sounding like the obvious choice. Use steady_clock.

Your benchmark was deleted

Chapter 7.1 showed that a computation nothing reads does not survive. A benchmark loop is precisely such a computation.

Timing nothing at all
#include <chrono>
#include <cstdio>

template <class T>
inline void keep(const T& value) {
    asm volatile("" : : "r,m"(value) : "memory");
}

long long sum_to(int n) {
    long long total = 0;
    for (int i = 0; i < n; ++i) total += i;
    return total;
}

int main() {
    volatile int opaque = 10000;      // hide the value from constant folding
    const int n = opaque;

    auto t0 = std::chrono::steady_clock::now();
    for (int r = 0; r < 2000; ++r) { long long s = sum_to(n); (void)s; }
    auto t1 = std::chrono::steady_clock::now();

    auto t2 = std::chrono::steady_clock::now();
    for (int r = 0; r < 2000; ++r) { long long s = sum_to(n); keep(s); }
    auto t3 = std::chrono::steady_clock::now();

    std::printf("result unused: %.3f ms\n",
                std::chrono::duration<double, std::milli>(t1 - t0).count());
    std::printf("result kept:   %.3f ms\n",
                std::chrono::duration<double, std::milli>(t3 - t2).count());
}

Press Run and the two numbers come out roughly equal — around 50 ms each, because -O0 does not delete anything. Compile the same file at -O2 and it prints:

result unused: 0.000 ms
result kept:   9.3 ms

The first loop is gone. s is never read, so sum_to has no observable effect, so twenty million additions were deleted and the timer measured the distance between two adjacent instructions.

keep is the standard defence, and it is worth understanding rather than copying. asm volatile("" : : "r,m"(value) : "memory") is an empty piece of inline assembly that claims to use value — in a register or in memory — and to clobber memory. The compiler cannot see inside it, so it must materialise the value and must not reorder memory operations across it, and it costs no instructions because there are none. Google Benchmark spells the same trick benchmark::DoNotOptimize.

The other half of the defence is the volatile int opaque. Without it, n is the literal 10000, so sum_to(n) folds to a constant at compile time and even the kept version measures nothing. Both ends need blocking: the input, so the work cannot be precomputed, and the output, so it cannot be discarded.

Find the hotspot before you optimise anything

Knowing how to time a function is not the same as knowing which function to time. The cheapest useful profiler is a scoped timer that adds up where the program actually spent its wall clock.

A profiler in twenty lines
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <map>
#include <string>
#include <vector>

class Region {
public:
    explicit Region(const char* name) : name_(name), start_(clock_type::now()) {}
    ~Region() {
        auto elapsed = std::chrono::duration<double, std::milli>(clock_type::now() - start_);
        totals()[name_] += elapsed.count();
    }
    static std::map<std::string, double>& totals() {
        static std::map<std::string, double> t;
        return t;
    }

private:
    using clock_type = std::chrono::steady_clock;
    std::string name_;
    clock_type::time_point start_;
};

int main() {
    std::vector<int> scores;
    {
        Region r{"generate"};
        scores.reserve(8000);
        for (int i = 0; i < 8000; ++i) scores.push_back((i * 7919) % 100000);
    }
    {
        Region r{"sort"};
        std::sort(scores.begin(), scores.end());
    }
    std::string report;
    {
        Region r{"format"};
        for (int s : scores) report = report + std::to_string(s) + "\n";
    }
    long long checksum = 0;
    {
        Region r{"checksum"};
        for (char c : report) checksum += c;
    }

    std::printf("report: %zu bytes, checksum %lld\n", report.size(), checksum);
    double total = 0;
    for (const auto& [name, ms] : Region::totals()) total += ms;
    for (const auto& [name, ms] : Region::totals())
        std::printf("  %-10s %8.2f ms  %5.1f%%\n", name.c_str(), ms, 100.0 * ms / total);
}

Ask someone which line is slow and they will say the std::sort. It is the only part with a name that sounds expensive, and it is O(n log n) on eight thousand elements.

The measurement says format takes 97% of the time and sort takes one or two. The reason is report = report + std::to_string(s) + "\n", which builds a brand-new string containing everything so far, on every one of eight thousand iterations — quadratic work, and 15,992 heap allocations. Changing it to report += std::to_string(s); report += '\n'; makes it linear, brings the allocation count down to 12, and drops the region below sort.

The same shape holds at both optimisation levels, which is why this sample is worth running here: format is 97–98% whether you press Run or compile at -O2. Profiles are much more portable than timings.

Real profilers

Instrumenting by hand tells you about the regions you thought to instrument. A sampling profiler tells you about the ones you did not.

perf stat ./program            # cycles, instructions, cache misses, branch misses
perf record -g ./program       # sample the call stack ~1000×/second
perf report                    # browse the result, hottest first

valgrind --tool=callgrind ./program   # exact instruction counts, ~50× slower

A sampling profiler like perf interrupts the process periodically and records where it was. It costs a few percent, needs no changes to your code, and finds the function you never suspected — but it attributes time to whatever was on the stack, so inlined functions can be reported under their caller. Callgrind simulates the machine, so it counts everything exactly and is unaffected by noise, at the price of running dozens of times slower and modelling a cache that is not quite yours.

Build with -O2 -g for either. -g adds symbols without changing code generation, so you get function names and line numbers from the binary you actually ship.

Check yourself

Practice