High Performance, Rules that Survive Measurement

por Frank de Alcantara em 10/07/2026

Este artigo também está disponível em português.

High Performance, Rules that Survive Measurement

In March 2013, Andrei Alexandrescu published on Facebook’s engineering blog the transcript of a talk with three optimization tips for C++. Three. Not thirty, not “ten tricks compilers hate”. Three: measure before believing, reduce the strength of operations, and minimize writes to memory. The article aged better than most frameworks released that year, which says something about frameworks and something about fundamentals.

This article was translated into English using the Claude AI assistant, running the Fable 5 model, on July 10, 2026.

The most quoted part is not even one of the tips. It is the observation that modern CPUs abandoned the deterministic model, one cycle per operation, memory with no waiting, in exchange for statistical performance: deep caches, long pipelines, speculative execution, register renaming. The processor became faster on average and less predictable in any particular case. The practical consequence is uncomfortable: your intuition about what is fast is, with high probability, wrong. His was too. That is why he measured.

Alexandrescu sums it up with a phrase he borrows from Walter Bright: measuring gives you a leg up on experts who are too good to measure. Keep that phrase. It is the implicit footnote of everything that follows.

I took Alexandrescu’s 2013 tips, added a bit of experience, many hours of research, and a bit of the book on competitive programming and high performance I am writing, and put them into an article. This article. But this work does not replace the book. Nor complement it. At most, it draws attention; all the tips are useful, some are indispensable. The full treatment, though, only in the book to come, in English.

This article is a catalog of rules for writing fast C++23 without sacrificing correctness, something that thirteen years of language evolution since 2013 have made both easier and fuller of brand-new traps. The rules are grouped by theme: types, compile time, abstractions, namespaces, memory, loops, and algorithms. None of them replaces measurement. All of them shrink the search space, which is exactly what Alexandrescu promised: not guarantees, good heuristics for exploring an optimization space too large to search exhaustively.

Types Are Part of the Algorithm

The first family of rules deals with something usually decided on autopilot: numeric types. Deciding types on autopilot works until the day it does not, and on that day the program will not warn you. It just returns a wrong number with all the confidence in the world. Silently, with a straight face.

Use using, not #define, for type aliases

The preprocessor does not know what a type is. Think of it as a dumb beast. It does, however, know how to substitute text, and it substitutes text with the enthusiasm of someone who will not stick around to see the result. using, on the other hand, creates an alias the compiler understands, respects scope, shows up in error messages, and works with templates:

#define Int long long         // text substitution, no scope
using i64 = long long;        // an actual type

template <typename T>
using Vec = std::vector<T>;   // alias template: impossible with #define

#define survives in modern C++ for exactly two things: include guards, when #pragma once will not do, and conditional compilation.

Keep aliases few and clear

Aliases like i64, Vi for std::vector<int> or Vec<T> pay their own rent: they shorten code that appears all the time without hiding what it is. The trouble starts when the file opens with forty aliases and the reader needs a modern Rosetta Stone to discover that umap_pii_vll means something. Something only the author knows.

Use wide types wherever the accumulated value can exceed int

A 32-bit int holds up to a little over $2{.}1 \times 10^9$. That sounds like a lot until you add $10^6$ values of $10^4$ each, which gives $10^{10}$, and discover the sum did not fit. Signed integer overflow is Undefined Behavior: the program has no obligation to warn you, to wrap around, or even to do the same thing twice in a row.

The rule is napkin arithmetic: estimate the largest value the variable can take, add, multiply, accumulate, and pick the type for the worst case, not for the case in the example you happened to test.

Avoid auto sum = 0 when the numeric type is part of the algorithm

auto deduces int from a literal 0, and deduces it without remorse:

std::vector<int> prices(2'000'000, 2'000);

auto sum = 0;             // sum is int. The algorithm needed i64
for (const auto& p : prices) sum += p;   // silent overflow

auto correct = 0LL;       // now the type is in the literal

When the accumulator’s type is a decision of the algorithm, and in sums, products, and costs it almost always is, write the decision down. auto is for when the type is obvious or irrelevant, not for when it is the difference between the right answer and a random number that looks like an answer.

Use the correct suffixes and the digit separator

C++ has literal suffixes for exactly this problem: LL for long long, and C++23 added uz for std::size_t and z for its signed counterpart. Together with the ' separator, long numbers become readable and correctly typed:

constexpr auto MOD  = 1'000'000'007LL;   // readable and long long
for (auto i = 0uz; i < v.size(); ++i)    // i is std::size_t, no warning
    process(v[i]);

The ' separator changes nothing for the compiler and everything for the reviewer: 1000000000000 and 100000000000 are identical twins until the day of the production incident. 1'000'000'000'000 and 100'000'000'000 are not.

Promote operands before the operation, not after

This is the classic mistake of someone who knows the wide-types rule but applies it too late:

int a = 2'000'000'000, b = 3;

long long wrong = a * b;    // multiplies in int, overflow, UB,
                            // and only then converts the corpse

long long right = static_cast<long long>(a) * b;  // promote, then multiply
Assigning to long long protects nothing: the right-hand-side expression is evaluated in the types of its operands. The same reasoning applies to subtractions between unsigned values, which wrap around zero with enthusiasm, and to std::abs(INT_MIN), which is UB because $ {-2^{31}} $ does not fit in 32 signed bits. Promote the operand before the dangerous operation.

What the Compiler Solves Before the Program Exists

Alexandrescu recommended reducing the strength of runtime operations. The modern, more radical version of that tip is to reduce the strength to zero: do the math at compile time.

Use constexpr for constants and fixed tables

A table that does not depend on the input has no reason to be computed every time the program runs:

constexpr auto POW10 = [] {
    std::array<long long, 19> p{};
    p[0] = 1;
    for (auto i = 1uz; i < p.size(); ++i) p[i] = p[i - 1] * 10;
    return p;
}();

An immediately invoked lambda inside a constexpr builds the table at compile time. At runtime it is just initialized memory, zero cost, and const on top of that, which gives the optimizer the freedom to do what optimizers do when nobody can change the data.

Use static_assert for compilable contracts

If a property can be checked at compile time, check it at compile time. Alexandrescu’s example, digits10, that cascade of comparisons against powers of 10 that replaces divisions with comparisons, accepts the modern version of the contract:

constexpr std::uint32_t digits10(std::uint64_t v) {
    std::uint32_t r = 1;
    for (;;) {
        if (v < 10)     return r;
        if (v < 100)    return r + 1;
        if (v < 1'000)  return r + 2;
        if (v < 10'000) return r + 3;
        v /= 10'000;
        r += 4;
    }
}

static_assert(digits10(9) == 1);
static_assert(digits10(1'000'000) == 7);
static_assert(digits10(std::numeric_limits<std::uint64_t>::max()) == 20);

These static_asserts are tests that run on every compilation, cost nothing at runtime, and turn a regression into a compile error. A test that prevents the binary from existing is the only test nobody forgets to run.

Do not overdo constexpr

Like every good tool, constexpr has an abuse zone. Three signs you have crossed the line: the table is large enough to bloat the binary and evict useful data from the cache, the value depends on the input, in which case constexpr is simply a lie, or the project’s compile time doubled because someone decided to precompute half a million values on every make. A table of $10^7$ entries computed in 20 ms at the start of main is, frequently, the most sensible engineering decision in the file. Compile time is not a moral goal. It is a tool with an invoice.

Prefer <bit> for bit operations

Before C++20, bit operations were a festival of non-portable intrinsics and expressions with >> and masks that everyone wrote their own way. The <bit> header standardized what the hardware already knew how to do: std::popcount, std::countl_zero, std::countr_zero, std::bit_ceil, std::has_single_bit. All of them operate on unsigned types, and the conversion should be deliberate, not accidental:

#include <bit>

int msb(long long x) {                              // position of the most significant bit
    auto u = static_cast<unsigned long long>(x);   // visible conversion
    return 63 - std::countl_zero(u);
}

The explicit static_cast documents that you know you are changing domains, with the wrap-around rules of the new domain. Bits are unsigned by nature; signed integers are an interpretation. Do not mix the two without signing the liability waiver.

Abstractions that Charge No Toll

C++ has been selling zero-cost abstraction since the nineties. The advertisement is true, with one contractual condition in fine print: the cost is zero when the abstraction is resolved at compile time. When it survives into runtime, the toll exists, someone pays it, and production gets slower.

Templates only when they remove real duplication

A template earns its keep in four situations: it removes real code duplication, it preserves inlining where a function-pointer alternative would kill it, it expresses a fact known at compile time, like a size or a policy, or it rejects wrong types before the program exists. Outside those four, a template is speculative generality: you pay in compile time, in error messages, and in readability for a flexibility no caller asked for. Generalizing for two real use cases is engineering. Generalizing for imaginary use cases is science fiction with instantiation.

Constrain templates with concepts

When the template is justified, C++20 gave it a readable contract:

template <std::integral T>
constexpr T ceil_div(T a, T b) {
    return (a + b - 1) / b;
}

std::integral documents the precondition in the only place where documentation never goes stale: the signature. And it converts that four-hundred-line template error, the one that starts at ceil_div and ends in the bowels of a header you never opened, into a sentence: the type does not satisfy the concept. Concepts do not make code faster. They make failure faster, which in practice saves more time.

Local lambdas belong to the algorithm

When an operation only makes sense inside an algorithm, a local lambda keeps the operation where it lives, with access to the context and visible to the inliner. The two cautions are about lifetime: capturing by reference something that dies before the lambda is classic UB, and capturing this in a lambda that escapes the object is the same mistake in a suit and tie.

Avoid std::function in local recursion

The old idiom for a recursive lambda was std::function:

std::function<void(int, int)> dfs = [&](int u, int parent) {
    for (int v : adj[u])
        if (v != parent) dfs(v, u);
};

It works, and it charges: std::function is type erasure, which means possible heap allocation, indirect dispatch on every call, and a brick wall in front of the inliner, all of it inside the hottest part of a recursive algorithm. C++23 retired the idiom with deducing this:

auto dfs = [&](this auto&& self, int u, int parent) -> void {
    for (int v : adj[u])
        if (v != parent) self(v, u);
};
dfs(0, -1);

A direct call, eligible for inlining, no allocation. If your compiler has not reached C++23 yet, a local struct with operator() solves the same problem with 1998 syntax, which is less elegant and equally fast.

Namespaces: Hygiene Is Not Pedantry

Do not use using namespace std;

That line dumps a few hundred names into your scope, including std::size, std::count, std::begin, and other nouns you have certainly already used as variable names. The result ranges from a confusing compile error to, worse, silent overload resolution to a function you did not know you were calling. In a header, using namespace std; is not style, it is vandalism: it contaminates every file that includes yours.

Prefer namespace aliases and targeted using

When std::ranges:: on every line becomes too verbose, the surgical alternatives exist:

namespace rng = std::ranges;        // short and explicit alias

void sort_all(std::vector<int>& v) {
    using std::swap;                // targeted, at function scope
    rng::sort(v);
}

The alias reduces noise without hiding where names come from. The local using brings one name, into one scope, for one reason, which is exactly the granularity at which imports should happen.

Reusable components live in their own namespace

If a function is going to be used in more than one file, it deserves a surname. A dedicated namespace prevents collisions, groups what belongs together, and makes the point of use self-documenting: geo::cross(a, b) says more than cross(a, b), especially on the day the project gains a second cross.

Memory: Where the Nanoseconds Live

Alexandrescu’s second tip was to minimize writes to memory, because all traffic with memory happens at cache-line granularity, and writing one word costs reading the whole line and writing it back. The rules in this section are the structural version of the same idea: organize the data so that memory works in your favor, because against it you do not win.

Reserve vector when the final size is predictable

A std::vector that grows push_back by push_back reallocates a logarithmic number of times, and each reallocation copies or moves everything that already existed, the worst kind of memory write: the repeated one. If you know the final size, or a reasonable upper bound, say so:

std::vector<Result> output;
output.reserve(inputs.size());        // one allocation, zero reallocations

Reallocation invalidates everything; recreate whatever pointed inside

The grim corollary of vector growth: any reallocation invalidates all iterators, references, pointers, and spans to the elements. The code below compiles, runs, and lies:

std::vector<int> v = {1, 2, 3};
int& first = v.front();
v.push_back(4);                        // may reallocate
// first may now point to the old memory. UB.

After any operation that may reallocate, recreate whatever pointed inside. The bug that results from not recreating is among the worst in its category: intermittent, allocator-dependent, and absent on every machine except the customer’s.

std::array only when the size is known at compile time

std::array<T, N> lives on the stack, no allocation, with the size in the type. It is the right choice exactly when N is a genuine constant, a fixed dimension, a small table. Using it with an oversized N “to fit the worst case” trades one allocation for a stack overflow and for a type that lies about how many elements are valid. A size decided at runtime is a vector problem.

std::span is a parameter, not an owner

std::span is the modern answer to “I want to receive a contiguous sequence without caring where it came from”. A std::span<const T> accepts vector, array, C arrays, and slices of any of them, with no copy and no template:

long long sum(std::span<const int> data) {
    return std::ranges::fold_left(data, 0LL, std::plus{});
}

The word that governs span is view: it looks at someone else’s memory. Hence the two absolute prohibitions: never return a span to local storage, which dies at the end of the function and leaves the span pointing at a vacant lot, and never keep a span over a vector across an operation that may reallocate. A span does not know the owner moved out. It keeps visiting the old address.

Matrices: vector<vector<T>>, flat vector<T>, or mdspan

For two-dimensional data there are three honest forms. vector<vector<T>> is the clearest and the least contiguous: each row is a separate allocation, scattered across the heap, with the cache footing the bill on every row change. A flat vector<T> with i * cols + j indexing puts everything in one contiguous block, and C++23 brought std::mdspan to give that flat memory a multidimensional interface at no cost:

std::vector<double> data(rows * cols);
auto m = std::mdspan(data.data(), rows, cols);
m[i, j] = 1.0;                        // multidimensional operator[], C++23

The selection criterion: rows of different sizes call for vector<vector<T>>; a rectangular matrix traversed frequently calls for flat storage; and mdspan delivers the readability of the first with the locality of the second. When performance matters, the answer is almost never the first.

Traverse the matrix in memory order

In row-major layout, C++’s default, elements of the same row are neighbors in memory. Traversing with the column in the inner loop visits memory sequentially and the prefetcher is grateful; swapping the loops turns each access into a jump of cols * sizeof(T) bytes and each loaded cache line into near-total waste. The measured difference usually lands between 2x and 10x, depending on size. Same loops, same arithmetic, same big-O. Asymptotic notation does not see the cache, but the cache sees you. That one you will have to research, or wait for the book.

AoS or SoA: decide by the dominant access pattern

Array of Structs keeps each object whole and contiguous; Struct of Arrays keeps each field whole and contiguous. If the hot code touches all fields of one object at a time, AoS wins. If it sweeps one field across all objects, a sum of prices, a filter by coordinate, SoA loads into cache only the relevant field and opens the door to vectorization. The wrong choice breaks nothing. It just pays cache rent for data nobody read.

Loops the Hardware Understands

const auto& to read, auto& to mutate, copies only when deliberate

In a range-based for over non-trivial objects, the shape of the loop variable is a cost decision:

for (const auto& order : orders)  total += order.value;      // reads, no copy
for (auto& order : orders)        order.processed = true;    // mutates
for (auto order : copies_by_design) modify(order);           // deliberate copy

for (auto x : v) copies every element, and for a 200-byte std::string that means one allocation per iteration inside the hot loop. When the copy is intentional, you want to mutate a local draft, write it on purpose and, ideally, comment it. The accidental copy is the profiler’s favorite invisible tax.

Indexed loops when the index matters, without mixing signed and unsigned

When the algorithm needs the index, to access two parallel vectors, to look at v[i-1], use an indexed loop, but pick one side of the sign war. v.size() returns unsigned; an int i compared against it generates silent conversions, and the expression i < v.size() - 1 with an empty vector compares against $2^{64}-1$, which is a longer loop than planned. The clean options:

for (auto i = 0uz; i < v.size(); ++i)          // all unsigned, C++23
for (int i = 0; i < std::ssize(v); ++i)        // all signed, C++20

Either of the two. The mixture, never.

And when the index exists only to accompany the element, C++23 dissolved the dilemma with std::views::enumerate, which delivers index and element without you managing either:

for (auto [i, x] : std::views::enumerate(v))   // i is the index, x the element
    std::println("{}: {}", i, x);

The index from enumerate is born with a type defined by the library, and the loop has no stopping condition to get wrong. Fewer decisions, fewer places for the sign war to restart.

Prefer standard algorithms and ranges for the basic vocabulary

Sort, binary search, counting, filling, copying, generating, minimum and maximum have names in the standard library, and the names carry intent, tested correctness, and optimizations your handwritten loop would reimplement with bugs:

std::ranges::sort(v);
auto pos      = std::ranges::lower_bound(v, x);
auto how_many = std::ranges::count(v, target);
auto [mn, mx] = std::ranges::minmax(v);

std::ranges::sort(v) says what happens. The equivalent loop says how, and forces the reader to verify whether the how really implements the what.

Use a manual loop when it says something the algorithm does not

The previous rule has its honest counterpart: when the body maintains a non-trivial invariant, updates multiple pieces of state per iteration, has a complex early exit, or needs explicit widening in the accumulator, the manual loop is clearer than a composition of views nobody can debug. Standard algorithms for vocabulary, manual loops for logic. Forcing one into the other’s job produces code that impresses in code review and sabotages on call.

Erase-unique, the right way

Deduplicating a vector is a two-step idiom many people execute halfway:

std::ranges::sort(v);
auto [first, last] = std::ranges::unique(v);   // compacts, does not shrink
v.erase(first, last);                          // now it shrinks

unique only removes adjacent duplicates, hence the sort beforehand, and does not change the vector’s size: it moves the survivors to the front and returns the boundary of the garbage. Forgetting the erase leaves a suffix of values in a valid but unspecified state at the end of the vector, waiting patiently for the first loop that traverses it.

Projections eliminate parallel key arrays

The old idiom for sorting by a field was extracting keys into a parallel array or writing a three-line comparator. Ranges accept projections:

std::ranges::sort(people, {}, &Person::age);
auto it = std::ranges::max_element(orders, {}, &Order::value);

Less code, no auxiliary structure, and the sort key declared in the only place anyone will look for it.

In folds and reductions, the initializer decides the type

C++23 finally gave reduction a ranges interface: std::ranges::fold_left. But the rule that applied to the old std::accumulate survived modernization intact: the accumulator’s type is deduced from the initial value, not from the container. It is the auto sum = 0 rule wearing a standard-library badge:

std::vector<int> v(2'000'000, 2'000);

auto wrong = std::ranges::fold_left(v, 0,   std::plus{});  // accumulates in int
auto right = std::ranges::fold_left(v, 0LL, std::plus{});  // accumulates in long long

The same caution applies to the std::accumulate you will keep finding in existing code: accumulate(v.begin(), v.end(), 0) accumulates in int, with 0LL it accumulates in long long. Two characters separate the correct sum from overflow. The standard library is powerful, not parental.

Algorithms: Count the Whole Workflow

unordered_map::reserve when cardinality justifies it

An unordered_map that grows element by element rehashes periodically, and each rehash reallocates buckets and redistributes everything already inserted. If the frequency count is going to receive $10^6$ distinct keys, freq.reserve(1'000'000) pays the cost once instead of amortizing it in installments with interest. For half a dozen keys, the reserve is ceremonial noise. Expected cardinality is information; use it when you have it.

nth_element when sorting everything is waste

For the median, the 99th percentile, or the $k$ largest, sorting the whole vector is buying an ox to drink a glass of milk:

std::ranges::nth_element(v, v.begin() + k);
// v[k] is in its final position; before it, only smaller or equal values

nth_element is average $O(n)$ against the sort’s $O(n \log n)$, and the difference shows up exactly in the large vectors where order statistics are usually computed. If you later need the $k$ smallest in order, sort only the prefix: it is still cheaper.

Sort vs hash: the math closes at the end of the workflow, not in the middle

“Hashing is $O(1)$, therefore unordered_map beats sort” is the kind of reasoning that compares the first kilometer of two routes. And one I see repeated, every single day, by students from other courses.

The right question covers the whole workflow: does the output need to be sorted? Then the sort you avoided in the middle reappears at the end.

Is the data small? The hash’s constant factor, bucket allocation, pointers, cache misses, beats the $\log n$ of a sort over contiguous memory with room to spare. A sorted vector with binary search defeats the hash map in an embarrassing number of real benchmarks.

Remember: the big-O of one step is not the running time of the program.

Execution policies: only after measurement, only on independent iterations

C++17 made it possible to write std::sort(std::execution::par, ...) and C++23 did not revoke the temptation to sprinkle par around.

One compatibility detail before the conditions: execution policies live in the classic iterator-based algorithms; the std::ranges versions do not accept them in C++23.

If measurement justifies parallelism, it is the iterator-based std::sort that takes the policy, and this is one of the rare situations where going back to the old interface is the modern decision. The real conditions: the iterations must be independent, no I/O, no push_back into a shared container, no order that matters, no data race, and the work must be large enough to amortize the cost of coordinating threads, which is not small.

Parallelizing a loop of a thousand elements delivers all the synchronization and none of the gain; parallelizing a loop with a data race delivers UB on multiple cores, which is like regular UB, only harder to reproduce. The order of the steps is fixed: measure, confirm the region dominates the time, verify independence, apply the policy, and measure again. Parallelism without measurement is optimism, not engineering.

The Footnote of Everything

Reread the list and notice the pattern: almost no rule is about tricks. They are types that hold the values the algorithm produces, math done at compile time when the information already exists, abstractions the compiler dissolves, data laid out in the order it will be read, and algorithms chosen by the whole workflow’s bill. It is the same thesis as the 2013 article, with thirteen more years of standard library: the hardware is statistical, intuition is an outdated map, and rules of thumb exist to prune the search space, not to replace the search.

The final question in front of any optimization remains the one Alexandrescu asked in 2012 to an audience that thought it already knew the answer: did you measure? If you measured, the numbers decide. If you did not measure, you are not optimizing. You are cheering.

(Updated: )