# 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)