· 3 min read ·

When Range Adaptors Break the Optimizer's Mental Model

Source: isocpp

C++ has marketed zero-cost abstractions as a core principle for decades. The idea is that higher-level constructs compile away, leaving code no worse than what you’d write by hand. std::ranges is supposed to be a poster child for this: expressive pipelines with no overhead. Daniel Lemire tested that claim in November 2025, and the results are worth looking at before you migrate anything throughput-sensitive.

The Vectorization Problem

The specific failure mode Lemire identifies is auto-vectorization. Modern compilers generate SIMD instructions for tight loops over contiguous data. A raw loop over a std::vector<int> is something compilers have been successfully vectorizing for fifteen-plus years. The optimizer sees a pointer, a stride of one, and a trip count it can often determine at compile time.

Range adaptors complicate that picture. Lazy evaluation means each call to operator++ on a range iterator may go through several adaptor layers before touching the underlying data. The compiler has to see through all of them. For simple cases it often can. But as soon as the adaptor chain introduces type complexity the optimizer doesn’t recognize, the SIMD path disappears and you’re back to scalar code.

// Compiler has no trouble vectorizing this
long long sum = 0;
for (int x : vec) sum += x;

// Whether this produces equivalent machine code depends heavily
// on your compiler, version, and the exact adaptor chain
auto result = std::ranges::fold_left(vec, 0LL, std::plus{});

In theory, a sufficiently smart compiler with enough inlining budget gets to the same place. In practice, the inlining budgets are finite and the type-erased adaptor layers can opaque the data access pattern just enough to break the fast path.

Why Zero-Cost Is a Claim Worth Verifying

The zero-overhead principle in C++ has always had an implicit caveat: it applies when the abstraction is transparent to the optimizer. High-level constructs that compile down to the same machine code as hand-written code are zero-cost. Constructs that require the optimizer to perform additional analysis to recover equivalent code are zero-cost only if the optimizer succeeds, and that success is not guaranteed.

This matters more for some workloads than others. If you’re writing Discord bot logic or API handlers, a 20% slowdown in a range pipeline that runs a hundred times per request is invisible noise. If you’re doing text processing, encoding, numerical work, or any operation over large arrays at high throughput, you want to know what the compiler actually generated.

Lemire’s point is not that std::ranges is bad. It’s that the assumption of equivalence is an assumption, and assumptions in hot paths deserve measurement.

Practical Guidance

The habits that follow from this are straightforward:

  • Use std::ranges freely for non-critical code. The readability improvement is real.
  • For any loop that runs over large datasets or is on a measured critical path, check the disassembly. Look for ymm or zmm registers in the output — their absence tells you vectorization didn’t happen.
  • Benchmark ranges versus raw loops with your actual compiler at your actual optimization level. GCC, Clang, and MSVC have meaningfully different optimization behaviors here, and behavior varies across major versions.
  • When in doubt, keep the inner loop simple. A raw range-for over a span is usually fine. A multi-adaptor pipeline with lambdas is worth verifying.

Ranges are a genuine improvement to how C++ reads. Lemire’s retrospective is a useful reminder that readability improvements don’t automatically come with performance equivalence, and that zero-cost abstractions require the compiler to cooperate. It usually does. Usually isn’t always.

Was this interesting?