Your micro-benchmark reports an absurdly fast time. What did the optimiser probably do, and how do you write a benchmark that cannot be fooled?

21.8.1 · L3 · Interview

Predict first

What happens to a loop whose result is never used, in a -O build?

import Foundation

@inline(never) func sink(_ x: UInt64) { if x == 42 { print("never") } }
func ms(_ f: () -> Void) -> Double {
    let t = DispatchTime.now(); f()
    return Double(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1e6
}
func lcg(_ n: Int) -> UInt64 { var x: UInt64 = 1; for _ in 0..<n { x = x &* 6364136223846793005 &+ 1442695040888963407 }; return x }
let n = 50_000_000
let unused = (0..<3).map { _ in ms { _ = lcg(n) } }.min()!
let used = (0..<3).map { _ in ms { sink(lcg(n)) } }.min()!
print("loop whose result is discarded is more than 50x faster:", unused * 50 < used)
  • loop whose result is discarded is more than 50x faster: true
  • The two loops take about the same time
  • The discarded loop is slightly slower

Mental model. Timing a runner who was teleported to the finish line. The stopwatch is accurate; the race never happened.

In an optimised build the compiler deletes work whose result is never used. A loop that computes a value and throws it away has no side effects, so it can be removed entirely. In the check, a 50-million-step loop took 0 ms when its result was discarded and tens of milliseconds when the result was passed to a function the optimiser cannot see through (@inline(never)). The same program built with -Onone takes about three seconds either way, which is the other half of the trap: Debug builds measure something your users never run. A test that is too fast to be physical, or that does not change when you make the code slower, is usually measuring nothing.

A trustworthy harness does four things. It builds in Release. It consumes every result through a sink the optimiser must respect (an @inline(never) function, a global accumulator, or withExtendedLifetime), and checks the answer is correct. It feeds inputs the compiler cannot constant-fold (read from the process, a file or a seeded generator, not literals). And it repeats: take the minimum or median of several runs, discard the first (caches, lazy initialisation), and report spread (Q428). Put hot-path work in a measure block with a baseline (Q430), and confirm any win in Instruments on a device, because a micro-benchmark that is fast for one input shape may not matter in the app.

The optimiser removes side-effect-free work whose result nobody reads.

Common traps

  • Benchmarking in Debug. The numbers can be tens or hundreds of times off and the ranking of two options can flip.
  • Ignoring the result of the code under test. If nothing reads it, the optimiser may delete it.

They might follow up with

  • Can the optimiser also make a benchmark look too slow? Yes: constant inputs can be folded away or, on the other side, an unrealistic cache-hot loop hides real memory costs.
  • How do I check what ran? Look at the profile or the disassembly, or change the input size and see whether the time moves.

Related questions

  • 21.8.2 How much slower is a Debug (-Onone) build than Release (-O), why, and what does that mean for profiling and…
  • 21.8.3 How do you find out how an operation really scales, and which Swift operations that look cheap are quadratic?
  • 21.1.2 How do you measure performance reliably: what makes numbers lie, and why do percentiles beat averages?
  • 21.2.2 How do signposts and Points of Interest work, and how do you turn a measurement into an XCTest performance…

How much slower is a Debug (-Onone) build than Release (-O), why, and… →