# Go benchmark 0 allocs/op: dead code elimination, constant folding, b.Loop > [!summary] TL;DR > A benchmark of a function that calls `make([]byte, 64)` and reports `0.34 ns/op, 0 B/op, 0 allocs/op` is measuring an empty loop: once the call is inlined, the compiler proves the body has no observable effect and deletes it. Keep the result alive with the sink pattern, always run with `-benchmem`, and prefer `for b.Loop()` (Go 1.24+) so the compiler keeps loop-body values alive. With the sink, the honest cost is about `15 ns/op` and `1 allocs/op`. ## Key points - The suspicious result came from a real run: `0.34 ns/op`, `0 B/op`, `0 allocs/op` for a function that calls `make([]byte, 64)` every time. That would be almost three billion heap allocations per second on a laptop, and the allocator counted none. - Dead-code elimination (DCE) removes a computation whose value nothing reads. Inlining is the accomplice: once a small function body sits inside the loop, the compiler sees the unused result and deletes the whole body, allocation included. - A call the compiler cannot inline is kept. `//go:noinline` on `makeBuffer` makes the same benchmark report `64 B/op, 1 allocs/op`. - The DCE case reproduces on Go 1.25 and later (tested 1.25.0, 1.26.0, 1.27.1). On Go 1.24 the inlined `make` still escapes to the heap, so it allocates even with the result discarded. - Fix: the sink pattern. Assign to a local inside the loop, then write that local to a package-level variable after the loop. One write after the loop costs almost nothing and keeps the whole chain alive. - `runtime.KeepAlive` is callable too, but forces no escape. - Constant folding: if every input is a compile-time constant, the compiler evaluates the expression at compile time. `bits.OnesCount(0b10110)` becomes the literal `MOVD $3` (arm64) or `MOVL $3` (amd64) instead of a real popcount. - `ns/op` sits near the measurement floor (about 0.25 ns on Apple Silicon, one loop iteration per cycle), so an empty loop and a fast function look identical. `allocs/op` is a discrete count from `runtime.ReadMemStats` deltas and has no floor. Run with `-benchmem` in every invocation. - Caveats: `allocs/op` is an integer average, so a heap hit every fourth iteration rounds down to `0` (check `B/op`). A zero can also mean the buffer stayed on the stack; verify with `-gcflags=-m`. - Timer traps: call `b.ResetTimer()` after one-time setup; bracket per-iteration setup with `b.StopTimer()`/`b.StartTimer()` in that order. A `StopTimer` with no `StartTimer` grows `b.N` to its 1e9 cap in about 20 seconds and prints something like `0.0000007 ns/op`. - `for b.Loop()` (Go 1.24, proposed in issue #61515) resets the timer on first call, stops it when it returns false, runs the benchmark function once per `-count`, and keeps loop-body values alive so inlining cannot leave a dead body. - `b.Loop` DCE protection had a bug in Go 1.26.0 to 1.26.2 (issue #77654, fixed in Go 1.26.3 and 1.27) and only applies when the loop condition is written exactly as `b.Loop()`. - This is part 1 of 5 in the Why Your Go Benchmarks Are Lying series. The series asks three questions of every benchmark number: did the compiler let it measure real work, is the sample stable, is the difference large relative to the noise. This part answers the first. ## How the compiler empties a benchmark loop: DCE and the sink pattern DCE follows a simple rule: if a computation produces a value that nothing ever reads, the compiler can remove it. A benchmark typically calls a function and throws away the return value. Inlining replaces a call to a small function with a copy of its body, nearly always a win in production. In a benchmark, once the body sits inside the loop, the compiler sees the result is unused and deletes the lot. The DCE pair, excerpted from `dce_bench_test.go` (long comments left out in the source): ```go func makeBuffer(n int) []byte { return make([]byte, n) // heap-escaping allocation } func BenchmarkMakeBuffer_DCE(b *testing.B) { for range b.N { makeBuffer(64) // result discarded → compiler removes the call } } ``` `makeBuffer` really does put its buffer on the heap when called on its own. The benchmark discards the result, and because `makeBuffer` is small enough to inline, the compiler sees a `make` nobody reads and deletes it, allocation included. This reproduces on Go 1.25 and later (tested 1.25.0, 1.26.0 and 1.27.1). On Go 1.24 the inlined `make` still escapes to the heap, so it allocates even with the result discarded. The trick needs inlining: put `//go:noinline` on `makeBuffer` and the same benchmark reports `64 B/op, 1 allocs/op`. The fix is the sink pattern: assign the result to a local variable inside the loop, then write that local to a package-level variable after the loop. The compiler treats a store to a package-level variable as observable, so it must keep the computation behind it. Same file: ```go // sink is the package-level variable that defeats DCE. var sink []byte func BenchmarkMakeBuffer_Correct(b *testing.B) { var s []byte for range b.N { s = makeBuffer(64) } sink = s } ``` The two-variable idiom matters. Writing to `sink` inside the loop would add a global write per iteration, which is measurable. One write after the loop costs almost nothing and still keeps the whole chain alive. Every iteration still runs: with the last buffer stored in `sink`, the `make` escapes, so each one allocates. `runtime.KeepAlive` is callable too, but forces no escape. ## Constant folding: when the compiler already knows the answer If every input to an expression is a compile-time constant, the compiler evaluates the expression at compile time and replaces it with a literal. The benchmark then loads a constant, and the compiler already did the work. The `bits.OnesCount` pair in the same file shows it: the first function feeds in a constant, the second a package-level variable, which the compiler cannot constant-propagate because its value can change at run time: ```go var sinkInt int func BenchmarkOnesCount_ConstantFolded(b *testing.B) { var s int for range b.N { s = bits.OnesCount(0b10110) // constant → evaluated at compile time } sinkInt = s } // onesInput breaks the constant chain — compiler cannot prove this is 0b10110. var onesInput uint = 0b10110 func BenchmarkOnesCount_Correct(b *testing.B) { var s int for range b.N { s = bits.OnesCount(onesInput) } sinkInt = s } ``` On Apple Silicon both versions report similar timings, because `bits.OnesCount` compiles to a four-instruction NEON sequence that runs near the timer floor regardless. Timing cannot tell them apart, so ask the assembly instead (`make asm-dce` runs this). `XXX` matches nothing, so no benchmark runs, and plain `go build` would print nothing because it never compiles `_test.go` files: ```bash go test -gcflags='-S' -run XXX -bench XXX . 2>&1 | grep -A14 'OnesCount_ConstantFolded(SB)' ``` The constant-folded version shows `MOVD $3, Rxx`: the compiler substituted the literal 3 for the entire `bits.OnesCount` call. The correct version shows `VCNT` and `VUADDLV`, an actual popcount sequence (arm64 names; on amd64, widen the grep to `-A30` and look for `MOVL $3` against `POPCNTQ`). Similar timing, entirely different code. ## The honest signal: -benchmem and allocs/op The `0.34 ns/op` result sits right next to the measurement floor on Apple Silicon, around 0.25 ns, about one loop iteration per cycle. An empty loop and a blazing-fast function look identical down there. `allocs/op` has no such floor. An allocation is a discrete event: the testing framework reads `runtime.ReadMemStats` at the start and end of the run, takes the delta, and divides by `b.N`. Either a heap allocation happened or it did not. Run the DCE pair side by side (`make bench-dce` does it, after a one-time `make tools` to install `benchstat`): ```bash go test -run XXX -bench=BenchmarkMakeBuffer -benchmem -count=10 . ``` `benchstat` summarizes the ten runs as medians. This is one run on one machine: an Apple M4 Max (darwin/arm64) under background load (load average 8 to 11), Go 1.27.1. The `ns/op` column is noisy and `allocs/op` is exact, which is the point. | Benchmark | ns/op | B/op | allocs/op | |---|---|---|---| | `BenchmarkMakeBuffer_DCE` | 0.3444 ± 96% | 0 | **0** | | `BenchmarkMakeBuffer_Correct` | 15.43 ± 29% | 64 | **1** | `BenchmarkMakeBuffer_DCE` reports zero bytes and zero allocations. Not "almost none": zero. The `make([]byte, 64)` in the function body never executed, and no timer resolution or clock speed can change that column. `BenchmarkMakeBuffer_Correct` reports `64 B/op, 1 allocs/op`: the allocation happened, so that measurement is trustworthy. The `0.34 ns/op` was an empty loop; the honest cost is about 15 ns and one allocation. These numbers are being recaptured on a quiet machine with a reproduction kit, and the table will be updated. This is why `-benchmem` belongs in every Go benchmark invocation (`go test -bench=. -benchmem ./...`). `0 allocs/op` for a function you know calls `make` is a red flag, and a result under 1 ns/op for anything non-trivial is the strongest hint that DCE or constant folding has struck. Two caveats apply. `allocs/op` is an integer average, so a loop that hits the heap on every fourth iteration rounds down to `0`, and `B/op` gives it away. And a zero can also mean the buffer stayed on the stack, so check `-gcflags=-m`: `inlining call to X` means it was inlined; `can inline X` only means it could be. And for a function that never allocates, `allocs/op` stays silent; check the assembly instead. ## Timer traps: ResetTimer, StopTimer, StartTimer How `testing.B` keeps time: it runs the benchmark with a growing iteration count until one run fills `-benchtime`; `ns/op` is that run's duration divided by the count. Go compiles ahead of time, so there is no JIT warmup, and each `-count` line is one such mean, not a distribution. The `testing.B` timer starts when the benchmark function is called, so everything that runs before the loop is measured unless we reset it. `b.ResetTimer()` zeros the elapsed time and the allocation counters, so call it after one-time setup, just before the loop. It does not stop the timer: a running timer keeps running, from zero. When each iteration needs its own setup, bracket the setup with `b.StopTimer()` and `b.StartTimer()`, and the order matters. This one is from `timer_bench_test.go`, where `buildFixture`, `fixtureSize`, `processString` and `sinkStr` are helpers: ```go func BenchmarkProcess_PerIterSetup_Correct(b *testing.B) { var s string for range b.N { b.StopTimer() input := buildFixture(fixtureSize) b.StartTimer() // ← timer restarts; only processString is measured s = processString(input) } sinkStr = s } ``` The timer stops before the fixture is built and restarts before the work, so only `processString` is timed. Call `StartTimer` after the work instead, as `BenchmarkProcess_TimerOrder_BUG` in the same file does, and the timer measures fixture construction while `processString` runs with the timer off. The reported ns/op is then the cost of the wrong thing. Worse: `StopTimer` with no `StartTimer` at all. The testing framework decides how long to run a benchmark by accumulating timed duration until it reaches the target time (default 1 second). If the timer is stopped and never restarted, the accumulated duration holds only the time up to the first `StopTimer`. The framework keeps growing `b.N` until it hits its cap of 1e9. For a trivial body with a single `StopTimer`, it reaches that cap in about 20 seconds and prints something like `0.0000007 ns/op`. The demo repository describes this case rather than demonstrating it; the comment in `timer_bench_test.go` reads "Don't run that live." If one of your own benchmarks seems to hang, look for a missing `b.StartTimer()`, or a stop/start pair left at the default `-benchtime`. Ctrl-C, fix it, run again. Each `StopTimer`/`StartTimer` call runs `runtime.ReadMemStats`, which stops the world, which is why the demo pins these benchmarks to `-benchtime=50000x`, a fixed count with no ramp. ## testing.B.Loop (Go 1.24+) and running the demo Austin Clements proposed `testing.B.Loop` in [Go issue #61515](https://github.com/golang/go/issues/61515) because the `b.N` pattern has failure modes that are easy to hit and hard to detect statically. It shipped in [Go 1.24](https://go.dev/doc/go1.24). Here is the demo's hashing benchmark in the new form, from `bloop_bench_test.go` (`payload` is a package-level `[]byte` fixture): ```go func BenchmarkHash_BLoop(b *testing.B) { // Setup: excluded from timing automatically. data := make([]byte, 1024) copy(data, payload) var s [32]byte for b.Loop() { // ← each call to Loop() is one measured iteration s = sha256.Sum256(data) } _ = s } ``` Notice what is missing: no `b.ResetTimer()`. [The first call to `Loop` resets the timer and the call that returns false stops it](https://pkg.go.dev/testing#B.Loop), so setup and cleanup are not measured. The benchmark function is called exactly once per `-count` value, so expensive setup does not re-execute while the framework ramps `b.N` up. And the compiler recognizes loops whose condition is syntactically `b.Loop()` and keeps the arguments, results and assigned variables of calls in the loop body alive (it wraps them in a `runtime.KeepAlive`), so inlining can no longer leave a dead body behind. Go 1.24 and 1.25 did this by [refusing to inline into the body](https://go.dev/blog/testing-b-loop), which could add heap allocations that production code would not have. [Go 1.26 keeps inlining and keeps the values alive instead](https://go.dev/doc/go1.26). The DCE protection has limits. It had a bug in Go 1.26.0 to 1.26.2, where assigning a call's result to `_` inside the loop still let the compiler drop the body ([#77654](https://github.com/golang/go/issues/77654), fixed in Go 1.26.3 and 1.27). It applies only when the loop condition is written exactly as `b.Loop()`: assigning the method to a variable first (`loop := b.Loop; for loop()`) does not trigger the compiler transformation. `b.Loop` protects the call, not its inputs: `bits.OnesCount(0b10110)` in a `for b.Loop()` body still folds to `MOVL $3` on amd64 (Go 1.27.1), so keep a non-constant input. `_ = s` after the loop only silences "declared and not used"; `-benchmem` remains the check. A `StartTimer` that never comes is a fatal error with `b.Loop` ("B.Loop called with timer stopped"), not a hang. For new benchmarks, prefer `b.Loop`. Migrating an old one is mechanical: swap `for range b.N` for `for b.Loop()` and drop any `b.ResetTimer()` that only excluded setup. Code that reads `b.N` inside the loop needs a second look: `b.N` is 0 until the loop ends. Back to the number one last time. `0.34 ns/op` started as an empty loop, and the sink turned it into 15 ns and one allocation. On Go 1.26 and later, `for b.Loop() { makeBuffer(64) }` reports `0 B/op, 0 allocs/op` for the same buffer, and this time the zero is honest: the body runs, but the inlined buffer no longer escapes and lives on the stack, as it would in production code. Same digit, opposite meaning. Report the `b.Loop` number. The sink version measures a heap escape that production code may not have, and `b.Loop` on Go 1.26 or later measures the function as the compiler would really treat it. If an allocation count surprises you, `-gcflags=-m` tells you which case you are in. Everything above runs from the [demo repository](https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo). The module declares `go 1.26.5`, so Go fetches that toolchain if yours is older. The assembly step shows arm64 instructions: ```console git clone https://github.com/kakkoyun/gopherconuk-26 cd gopherconuk-26 git checkout fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb cd talks/go-benchmarks-lying/demo # DCE and the sink pattern: compare allocs/op go test -run XXX -bench='BenchmarkMakeBuffer' -benchmem -count=10 . # Constant folding: MOVD $3 against VCNT and VUADDLV go test -gcflags='-S' -run XXX -bench XXX . 2>&1 | grep -A14 'OnesCount_ConstantFolded(SB)' # Timer ordering (fixed iterations, as make bench-timer does) go test -run XXX -bench='BenchmarkProcess' -benchmem -count=3 -benchtime=50000x . ``` `BenchmarkMakeBuffer_DCE` should report `0 allocs/op`, and `BenchmarkMakeBuffer_Correct` should report `1`. Expect the ns/op column to wobble with whatever else your machine is doing. That wobble is the next part of the story, covered in [[resources/documentation/Go/Benchmarks/Go Benchmarks - Statistics and benchstat|part 2, A Single Benchmark Number Is a Lie]]. Versions, links and commands were checked on 2 October 2026. New to `testing.B`? The primer [[resources/documentation/Go/Benchmarks/Go Benchmarks - Quickstart|Benchmarking Go, quickly]] covers writing and reading one first. The prerequisite is [Measuring Software Performance: Why Your Benchmarks Are Probably Lying](https://kakkoyun.me/posts/fosdem-2026-measuring-software-performance/), which covers hardware noise and statistical method in any language. A benchmark bot once flagged a pull request as 6 to 9% slower; that is handled in part 5. ## FAQ ### Why does my Go benchmark report 0 allocs/op when the function calls make? The compiler inlined the call, saw the result was unused, and removed the body, allocation included. The loop still ran `b.N` times, but it ran empty. Use the sink pattern or `for b.Loop()` to keep the computation alive. ### How do I stop the Go compiler from optimizing away my benchmark? Use the sink pattern: assign the result to a local inside the loop and store that local in a package-level variable after the loop. Alternatively, write the loop as `for b.Loop()` (Go 1.24+), where the compiler keeps the calls' arguments, results and assigned variables alive. ### What is the sink pattern in Go benchmarks? A package-level variable that defeats dead-code elimination. The compiler treats a store to a package-level variable as observable, so it must keep the computation behind it. Write to it once after the loop, not inside it: a global write per iteration is measurable, one write after the loop costs almost nothing. ### How do I detect constant folding in a Go benchmark? Feed the function a non-constant input, such as a package-level variable. Then compare the assembly: `go test -gcflags='-S' -run XXX -bench XXX . 2>&1 | grep -A14 'OnesCount_ConstantFolded(SB)'`. The folded version shows `MOVD $3`; a real popcount shows `VCNT` and `VUADDLV` on arm64. ### Why is allocs/op more trustworthy than ns/op? `ns/op` sits near a measurement floor of about 0.25 ns on Apple Silicon, so empty loops look fast. `allocs/op` comes from `runtime.ReadMemStats` deltas divided by `b.N`: either a heap allocation happened or it did not. Always run with `-benchmem`. ### What happens if I call b.StopTimer() without b.StartTimer()? The accumulated duration stops growing at the first `StopTimer`, and the framework grows `b.N` to its 1e9 cap, about 20 seconds for a trivial body, then prints something like `0.0000007 ns/op`. With `b.Loop` this is a fatal error ("B.Loop called with timer stopped") instead of a hang. ### Should I migrate b.N benchmarks to testing.B.Loop? Yes, for new code prefer `b.Loop`. The migration is mechanical: swap `for range b.N` for `for b.Loop()` and drop any `b.ResetTimer()` that only excluded setup. Code that reads `b.N` inside the loop needs a second look, because `b.N` is 0 until the loop ends. ### Does b.Loop protect against constant folding? No. `b.Loop` protects the call, not its inputs. `bits.OnesCount(0b10110)` in a `for b.Loop()` body still folds to `MOVL $3` on amd64 (Go 1.27.1), so keep a non-constant input. ## Related - [[resources/documentation/Go/Benchmarks/Go Benchmarks|Go Benchmarks]] (series hub) - [[resources/documentation/Go/Benchmarks/Go Benchmarks - Quickstart|Benchmarking Go, quickly]] (writing and reading your first `testing.B` benchmark) - [[resources/documentation/Go/Benchmarks/Go Benchmarks - Statistics and benchstat|Statistics and benchstat]] (part 2: reading the distribution behind one `ns/op` number) ## Sources - [Why Your Go Benchmarks Are Lying (talk, GopherCon UK 2026, GoLab 2026)](https://kakkoyun.me/talks/why-your-go-benchmarks-are-lying/) - [Why Your Go Benchmarks Are Lying (series)](https://kakkoyun.me/series/why-your-go-benchmarks-are-lying/) - [Measuring Software Performance: Why Your Benchmarks Are Probably Lying](https://kakkoyun.me/posts/fosdem-2026-measuring-software-performance/) - [Demo repository at commit fdce88cc](https://github.com/kakkoyun/gopherconuk-26/tree/fdce88cc0ce129b7d2edfb1a20d02fe647c17eeb/talks/go-benchmarks-lying/demo) - [Go issue #61515: testing.B.Loop proposal](https://github.com/golang/go/issues/61515) - [Go 1.24 release notes](https://go.dev/doc/go1.24) - [testing.B.Loop package documentation](https://pkg.go.dev/testing#B.Loop) - [Go blog: testing.B.Loop](https://go.dev/blog/testing-b-loop) - [Go 1.26 release notes](https://go.dev/doc/go1.26) - [Go issue #77654: b.Loop DCE bug](https://github.com/golang/go/issues/77654)