skip to content

In a Go benchmark, why store the computed result in a package-level variable rather than a local one?

level: middleimportance: should knowfreq 34%

answer

  1. the compiler sees every read of a local
  2. who else could read this value?
  3. package scope means unknown readers
  4. one anchored store keeps the chain live
  5. still no defence against constant folding

basics

~20 s

A store to a package-level variable is observable outside the function, so the compiler must perform it, which keeps the computation alive. A local nothing reads is a dead store; the blank identifier observes nothing.

solid answer

~50 s

The compiler removes computations whose results are never observed. A local variable that the function never reads again is exactly that: the store is dead, and everything feeding it is dead, so the benchmarked call disappears. A package-level variable is different — its value can be read by any other function in the package, and by other packages if it is exported, so the compiler cannot prove the store is unobservable and must emit it. That single store anchors the whole dependency chain. The usual shape is to accumulate into a local inside the loop with something cheap like `h ^= f(x)` and assign that local to the sink once after the loop, which costs one ALU operation per iteration instead of a memory store. Note the sink defeats dead-code elimination, not constant folding: if the input is a compile-time constant the compiler may still fold the answer.

code

go · 11 lines
go
var Sink uint64

var buf = make([]byte, 1<<20)

func BenchmarkHashBlock(b *testing.B) {
	var h uint64
	for i := 0; i < b.N; i++ {
		h ^= hashBlock(buf)
	}
	Sink = h
}

go deeper

for a junior

Remember the shape: declare a variable at package level, accumulate the result into a local inside the loop, and assign that local to the package-level variable once the loop finishes.

for a middle

Explain why scope decides the outcome — the compiler sees every read of a local and none of the possible readers of a package-level variable — and say what the store costs per iteration.

for a senior

Show where the sink stops helping: constant-folded inputs, and inlining that changes what is being measured. Be able to say which of those you would rule out first on a number you do not believe.

for a principal

Decide the convention the codebase writes benchmarks in and why, so that any engineer reading a benchmark can tell at a glance whether its number is anchored, rather than each author inventing a defence.

## The rule the compiler is applying An optimising compiler may delete any computation whose result is never observed, as long as the computation has no side effects. "Observed" has a precise meaning: the value must be read by code that itself is reachable and observable, or stored somewhere that outlives the function in a way the compiler cannot see through. Everything in a benchmark loop is a candidate for this rule, because a benchmark exists to compute something and throw it away — which is exactly the shape the optimiser is built to remove. ## Why a local does not stop it ```go func BenchmarkHashBlock(b *testing.B) { var h uint64 for i := 0; i < b.N; i++ { h = hashBlock(buf) } // h never read } ``` The compiler has the entire function in front of it. It can see every read of `h`, and there are none. So the final store is a dead store; deleting it makes the previous store dead, and so on, and once no store to `h` survives, the calls producing those values are dead too. A local variable gives the optimiser complete information, which is precisely why it is useless as a sink. Assigning to the blank identifier is weaker still. `_ = hashBlock(buf)` is defined by the language as evaluating the operand and discarding it; with an inlined, effect-free callee there is nothing left to evaluate. It is not a fence of any kind. ## Why a package-level variable does stop it ```go var Sink uint64 func BenchmarkHashBlock(b *testing.B) { var h uint64 for i := 0; i < b.N; i++ { h ^= hashBlock(buf) } Sink = h } ``` The Go compiler works one package at a time and does not perform whole-program dead-store elimination on package-level variables. Any other function in the package may read `Sink`, and if it is exported any other package may too — including code compiled later, in another build. So the store must happen. Once the store to `Sink` must happen, `h` must hold the right value, so every `h ^= hashBlock(buf)` must run, so every call must run. One anchored store keeps the whole chain live. Exporting the sink (a capital `S`) is a common belt-and-braces choice; unexported works in practice, and exported removes any doubt about a future compiler noticing that nothing in the package reads it. ## Where to put the store Storing inside the loop — `Sink = hashBlock(buf)` — works, but adds a memory write to every iteration. When you are measuring something that costs a megabyte of hashing that write is invisible; when you are measuring a five-nanosecond function it is not. Accumulating into a local with a cheap, dependency-preserving operator (`^=`, `+=`) and storing once after the loop costs a single register operation per iteration. Use `^=` rather than `+=` when overflow behaviour might otherwise let a compiler reason about the accumulator; both are fine in practice. ## What the sink does not protect against The sink defeats dead-code elimination. It does not defeat **constant folding**. If the benchmarked expression has compile-time-constant inputs, the compiler can evaluate it at build time and store a constant into the sink; the loop then measures nothing again, and the symptom looks identical. Keep the input in a package-level variable, derive it from `b.N`, or build it at run time so the compiler cannot fold it. It also does not control **inlining**. If the function under test is inlined into the benchmark, you are measuring the inlined form, not a real call. That may be exactly what you want — real callers inline it too — but if you want the call overhead included, mark the function `//go:noinline` and be explicit in the benchmark's comment that you have changed what is being measured. ## The modern alternative Since Go 1.24, `for b.Loop() { hashBlock(buf) }` keeps the arguments and results of calls in the loop alive, so a simple call-and-discard benchmark no longer needs a hand-written sink. The sink idiom still matters: it is what you reach for when the thing you are measuring is not a call — a piece of inline arithmetic or a type conversion — and it is what you will see in every benchmark written before Go 1.24.

  • Does the sink variable have to be exported?
    No — an unexported package-level variable is enough in practice, because the compiler does not eliminate stores to package-level variables. Exporting it is a belt-and-braces habit: an exported variable can be read by any package in any later build, so no analysis could ever conclude the store is unobservable. Either way, use a package-level variable, never a local and never the blank identifier.
  • Why accumulate with ^= inside the loop instead of assigning to the sink on every iteration?
    Assigning to the sink on every iteration adds a memory store to each measurement. That is noise for a megabyte hash but can dominate a five-nanosecond function. Accumulating into a local with `^=` keeps the value in a register, costs about one ALU operation, and still creates a data dependency from every call to the single store after the loop.
  • You added the sink and the benchmark is still suspiciously fast. What else could be happening?
    Most likely constant folding: if the input is a compile-time constant, the compiler evaluates the expression at build time and stores a constant, so the loop again does nothing. Build the input at run time or hold it in a package-level variable. The other possibility is that the work really was cheap — check by scaling the input and confirming the number scales with it.

saying these in an interview costs you the question

  • Says a local variable is enough to keep the value alive
  • Believes assigning to the blank identifier prevents elimination
  • Thinks the sink also prevents constant folding
  • Stores to the sink every iteration while measuring a nanosecond-scale function
  • Claims the compiler cannot remove stores at all