skip to content

In a Go benchmark, what does a for b.Loop() loop do that a for i := 0; i < b.N; i++ loop does not?

level: middleimportance: nice to knowfreq 26%

answer

  1. two footguns removed at once
  2. the call's inputs and outputs stay live
  3. no ResetTimer needed for setup
  4. the function body runs once, not repeatedly
  5. added in Go 1.24

basics

~10 s

It keeps the arguments and results of calls in the loop alive, so the compiler cannot delete the body, and it bounds the timed region itself, so setup above the loop is not measured.

solid answer

~40 s

`b.Loop` was added in Go 1.24 to remove the two classic footguns of the `b.N` loop. First, elision: parameters and results of function calls inside a `for b.Loop()` loop are kept alive, so a call whose result you discard is not deleted, which is what a hand-written package-level sink used to be for. Second, timing: the loop bounds the measured region, so expensive setup written before it and cleanup written after are excluded without any `b.ResetTimer` or `b.StopTimer` call. It also changes the execution model — the function body runs once and the loop decides internally how long to keep going, instead of the whole function being re-invoked with a growing `b.N`, so setup is paid once. It is not a blanket guarantee: it protects calls, so inline arithmetic may still need a sink.

code

go · 6 lines
go
func BenchmarkHashBlock(b *testing.B) {
	buf := makeBuffer(1 << 20) // setup: outside the measured region
	for b.Loop() {
		hashBlock(buf)
	}
}

go deeper

for a junior

Recognise for b.Loop() when you read it and know it is the modern way to write a benchmark loop: setup goes above it, and the call inside it will not be optimised away.

for a middle

Explain both guarantees — call arguments and results kept alive, and timing bounded by the loop — plus the change in execution model that makes setup run once instead of once per attempt.

for a senior

Know the edges: it protects calls, not inline arithmetic, and it is Go 1.24 and later, so a library supporting older toolchains still writes the b.N loop with an explicit sink.

for a principal

Own the migration call — whether the codebase standardises on b.Loop and therefore on a minimum toolchain, and what that costs consumers who build with older Go.

## Two problems with the classic loop The traditional benchmark shape is ```go func BenchmarkHashBlock(b *testing.B) { buf := makeBuffer(1 << 20) // expensive setup b.ResetTimer() for i := 0; i < b.N; i++ { hashBlock(buf) } } ``` and it carries two well-known hazards. The body's result is discarded, so the compiler may delete it. And the timing is only correct if the author remembered `b.ResetTimer` after the setup — and, more subtly, the whole function is re-invoked several times with an increasing `b.N` while the runner searches for a duration, so that expensive setup is paid on every one of those attempts. ## What b.Loop changes Go 1.24 added `testing.B.Loop`, used as ```go func BenchmarkHashBlock(b *testing.B) { buf := makeBuffer(1 << 20) // not timed for b.Loop() { hashBlock(buf) } // cleanup here is not timed either } ``` `Loop` returns true while the benchmark should keep iterating. Three things follow. **Elision resistance.** Arguments and results of function calls made inside the loop are kept alive, so the compiler cannot conclude that `hashBlock(buf)` is unobserved and delete it. This is the whole reason the method exists in a discussion of compiler elision: for the common case of "call this function repeatedly", it replaces the package-level sink. **Honest timing without ceremony.** The loop takes care of starting timing when iteration begins and stopping it when it ends, so setup written above the loop and cleanup written below it fall outside the measured region. You do not need `b.ResetTimer` for the ordinary case, which removes the most common way a benchmark silently charges setup to the result. **One execution of the function.** With `b.Loop`, the benchmark function body is executed once for each `-count`, and the loop internally decides how many iterations to run. With the `b.N` form the runner calls the function again and again with larger `b.N`, so any setup outside the loop happens repeatedly. For a benchmark that builds a large fixture, that alone can dominate wall-clock time of the test run. ## What it does not promise `b.Loop` keeps call parameters and results alive. It is not a general optimisation barrier for everything you might write inside the loop. If the body is inline arithmetic with no call — a bit-twiddling expression, a conversion, an index computation — its result is still unobserved and can still be removed, and you still want a package-level sink. Likewise, it does not stop constant folding: if the input is a compile-time constant, a constant is what gets kept alive. It also does not remove the need to think about inlining. A small function called inside the loop can still be inlined; you are then measuring the inlined form. That is often the honest thing to measure, but it is a decision, not an accident, and `//go:noinline` is how you make the opposite choice explicitly. ## When you still write the b.N form The method is Go 1.24 and later. A library that supports older toolchains — or a benchmark that must run under whatever version a downstream consumer builds with — still needs the `b.N` loop plus an explicit sink and `b.ResetTimer`. You will also keep the classic form when you genuinely need per-iteration control of the timer with `b.StopTimer` and `b.StartTimer` around work that must not be counted. ## The practical rule For new benchmarks on a current toolchain, reach for `for b.Loop()` first: it is shorter, it defends the common elision case for you, and it makes the untimed regions obvious by position rather than by a call you might forget. Add a package-level sink on top when what you are measuring is not a function call.

  • Does using b.Loop mean you never need a package-level sink again?
    No. `b.Loop` keeps the arguments and results of calls inside the loop alive, so it covers the common "call this function and discard the result" benchmark. If the thing you are measuring is not a call — inline arithmetic, a conversion, a shift-and-mask expression — its result is still unobserved and can still be deleted, so anchor it in a package-level variable.
  • Why does the b.N form re-run expensive setup, and how does b.Loop avoid it?
    With `for i := 0; i < b.N; i++`, the runner calls the benchmark function repeatedly with larger `b.N` until the run lasts long enough, so anything above the loop executes once per attempt. With `for b.Loop()`, the function body is executed once per `-count` and the loop itself decides how long to keep iterating, so setup written above it is paid a single time.
  • When would you still write the b.N form on a current toolchain?
    When the module must build with a toolchain older than Go 1.24, or when you need per-iteration timer control — wrapping work you do not want counted in `b.StopTimer` and `b.StartTimer` inside the loop. In both cases you take on the sink and the `b.ResetTimer` call yourself.

saying these in an interview costs you the question

  • Claims b.Loop makes any benchmark body immune to optimisation
  • Thinks b.Loop is just shorthand for the b.N loop
  • Believes b.ResetTimer is still required before a b.Loop loop
  • Dates b.Loop to the original testing package
  • Assumes the b.N loop runs the function body exactly once