Chapter 20: Performance Optimization

Here are three optimisations from later in this chapter. Each was measured twice on this machine, once the way the benchmark is usually written and once the way it has to be written. The two columns disagree about every one of them.

Change
Padding counters against false sharing
sync.Pool instead of allocating
Sharding one mutex into 64

Nothing in the left column is a typo. Each is a benchmark that compiles, passes go vet, runs clean under -race, and reports a number to four significant figures. The padding row was measured with one goroutine, so the contention the padding exists to remove was not in the program. The pool row compared pooling against an object that never left the stack, so the allocation it was replacing did not exist. The sharding row was measured at one core count, which is the single variable that decides the answer.

The last of those is the easiest to show, so here it is in full. This function is two multiplies, two shifts and three exclusive-ors — a dozen or so cycles of work, and no memory it does not already hold in registers:

mix_20.go
// Illustrative snippet — not a complete program
func mix(a, b uint64) uint64 {
    h := a ^ (b * 0x9E3779B97F4A7C15)
    h ^= h >> 29
    h *= 0xBF58476D1CE4E5B9
    return h ^ (h >> 32)
}
Measured five forms of the same benchmark, seven runs each, medians.
Loop body
for i := 0; i < b.N; i++ { mix(i, 3) }
for i := 0; i < b.N; i++ { } — the control
for i := 0; i < b.N; i++ { sink = mix(1, 3) }
for i := 0; i < b.N; i++ { sink = mix(i, 3) }
for b.Loop() { mix(i, 3); i++ }
for b.Loop() { } — the control

Read the first two rows together. Calling mix costs what calling nothing costs, to four significant figures. The compiler saw a result nobody used, deleted the call, and the benchmark reported the cost of an empty loop with a straight face. On a 3.8 GHz machine 0.2141 nanoseconds is less than a single cycle, which is the tell: the number was not implausible, it was impossible.

The third row is the quiet one. With constant arguments the call is hoisted out of the loop entirely, and 0.4282 ns looks like a number somebody would paste into a commit message.

Derived mix costs about 0.86 ns — the fourth row minus the b.N control — and not one of the five forms reports it.

That is the state of the art before you optimise anything.

This chapter is the sentence after Chapter 19's. That chapter taught you to take a profile and read it; this one is about the decision that follows, and the first thing that decision needs is a number you can defend. Three separate attempts at this chapter’s own material hit the trap above and reported a real speedup backwards. So §20.1 comes first, and everything after it depends on it.

There is a second claim, and the rest of the chapter carries it:

Parallelism is a property of your code, not of your machine.

The same sixteen-core machine makes one piece of code 4.2 times faster and another 5.6 times slower, and no profile distinguishes them. §20.4 measures both, and the command that tells them apart takes ten seconds.

What you’ll learn
  • Why a benchmark that compiles, vets and races clean can still be timing an empty loop, and the ladder of five fixes of which only the last one works
  • What b.Loop protects, which is narrower than its reputation — and the measured case where inlining takes the protection back
  • benchstat used properly, including the run where identical code appeared 13% faster, and why the ± column is the result
  • What ns/op means under b.RunParallel, which is not what it means anywhere else
  • What a hot frame licenses you to do, when 86% of a CPU profile is three runtime functions you cannot edit, and why the cumulative column names the one you can
  • Whether concurrency is the answer at all — measured against the sequential baseline nobody takes
  • Why granularity rather than core count decides a parallel speedup: the same job, the same sixteen cores, a 1,531× spread — and a crossover that moves a thousandfold when the work per item changes
  • Contention as a curve rather than a number, and as a tail phenomenon: a median that barely moves while p99 gets three orders of magnitude worse
  • The same false-sharing fix measured at 1.37× and 8.2× on one machine, and why neither figure is wrong
  • The optimisations that made it worse, measured — including a pool seven times slower than doing nothing, a change that was 2.4× faster and lost 92% of its data with -race reporting nothing, and an environment variable worth more than a week of allocation removal
What we’re not covering
  • Taking a profile. Chapter 19 owns the instruments — CPU, heap, block and mutex profiles, the tracer, and what each costs to collect. This chapter reads their output and decides
  • What GOMAXPROCS is, or the container and cgroup story — §1.5 covers it in full, including why the classic advice is retired. §20.5 takes only the decision
  • What sync.Pool is (§12.3), what a goroutine costs (§2.5), when to buffer a channel (§5.1–§5.4), lock granularity as a design rule (§9.4, §10.5.2). This chapter cites the mechanism and measures only the delta
  • Work stealing and the GMP model — Chapter 21
  • Benchmarks as correctness tests — §16.7.6 owns that half; this chapter takes speed
Building toward

Chapter 19 ended by saying that none of its instruments tells you what to change. Chapter 18 priced nothing and said so. Chapter 17 established that every bound has a cost and left the arithmetic. Chapter 1 said “profile first; the bottleneck picks the strategy” and never paid it with a measurement. This chapter is where those four debts come due, and the currency is numbers you can reproduce.

Prerequisites

§16.7.6 for why one run is not a number — this chapter starts there and keeps going. §8.4's happens-before, because §20.5 is about what the hardware does underneath it. §11.3.7's false sharing, which §20.5 measures three ways and gets three answers nine times apart. §17.3.6 for what sharding a limiter measured. And Chapter 19 throughout, since §20.2 begins with a profile in hand.

Which Go are we on?

Every figure was measured on go1.26.1, darwin/amd64, an Intel i7-10700K — eight physical cores, sixteen hardware threads — with GOMAXPROCS=16 and benchstat from golang.org/x/perf. Three cautions, and this chapter is largely about why they matter. Absolute nanoseconds will not reproduce on your hardware; the directions and the crossovers are what travel. Ratios are properties of the benchmark shape as much as of the machine, which §20.5 demonstrates by measuring one effect at 1.37× and 8.2×. And three figures are version-dependent and flagged where they appear: b.Loop arrived in Go 1.24; the compiler bug in §20.1.9 is present in go1.26.1 and fixed in go1.27 — which is the point: compiler bugs get fixed, and the reflex outlives any one of them; and go1.27's size-specialised allocator makes heap allocations under 80 bytes up to 30% cheaper — so the timing column of §20.6.4's table and the 64-byte row of §20.6.5 are the figures most likely to have moved, while their allocs/op columns cannot.

20.1 Before Any Number Means Anything

20.1.1 The Three Shapes of a Deleted Benchmark

The cold open showed all three. They differ in how obvious they are.

The result is discarded. mix(i, 3) with nothing on the left. The compiler proves the call has no effect and removes it. This is the loudest of the three, because the number that comes back is absurd — sub-nanosecond for real work — but only if you know what the real work should cost.

The result is stored somewhere the compiler can see is dead. A local variable that is never read afterwards is the same case with a longer proof, and the number is identical.

The input is constant. mix(1, 3) can be computed once and hoisted out of the loop, leaving a loop that assigns the same constant N times. Measured, that form reports 0.4282 ns against the honest 1.0710 — a plausible number, measuring the wrong thing, and no control catches it unless you vary the input.

The tell is a number you cannot account for.

Sub-nanosecond for anything with a memory access. A figure that does not change when you make the function do more work. A speedup much larger than the change could possibly explain. Each of those means the benchmark, not the code.

WHAT THE COMPILER LEFT BEHIND

A four-step chain. You wrote a loop calling mix and discarding the result; the compiler proved the result is never read; it emitted an empty loop; you measured 0.2141 nanoseconds, which is the loop and not the call. An empty control loop measures 0.2146, and the two agree to four figures.

This chapter hit that trap three separate times while being written, on three different techniques, and each time the first number was better than the honest one. That is not a coincidence. The deleted version is always faster, so dead-code elimination systematically produces results that look like successful optimisations. It is the one measurement error that never disappoints you, which is why it survives review.

20.1.2 The Elimination Ladder

The obvious fix is to use the result. It is worth watching how many times that fails before it works.

Five benchmarks, all allocating the same four kilobytes, differing only in what they do with it:

r1_discard_201.go
// Illustrative snippet — not a complete program
var byteSink byte
var sliceSink []byte

func BenchmarkR1Discard(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = make([]byte, 4096)
    }
}

func BenchmarkR2WriteRead(b *testing.B) {
    for i := 0; i < b.N; i++ {
        buf := make([]byte, 4096)
        buf[0] = 1
        byteSink = buf[0]
    }
}

func BenchmarkR3LoopVar(b *testing.B) {
    for i := 0; i < b.N; i++ {
        buf := make([]byte, 4096)
        buf[i%len(buf)] = byte(i)
        byteSink = buf[i%len(buf)]
    }
}

func BenchmarkR4KeepAlive(b *testing.B) {
    for i := 0; i < b.N; i++ {
        buf := make([]byte, 4096)
        runtime.KeepAlive(buf)
    }
}

func BenchmarkR5Escape(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sliceSink = make([]byte, 4096)
    }
}

Rung 2 is the fix most people write: touch the buffer, read the value back, store it somewhere package-level. Rung 3 is the fix people write when rung 2 does not work: make the index depend on the loop counter so nothing can be precomputed.

Measured -benchmem, seven runs, medians. The five differ by a factor of two thousand.
Benchmark
R1 result discarded
R2 written and read back
R3 index from the loop counter
R4 runtime.KeepAlive
R5 slice escapes to a package variable

Rungs 1 through 3 are all measuring an empty loop, and they agree with the control to three decimal places. Rung 2's write and read-back did not help: the compiler folds buf[0] = 1 followed by reading buf[0] into the constant 1, and a constant needs no buffer. Rung 3's dependence on i did not help either, because the buffer still dies inside the iteration that made it, and a buffer nobody outside the loop can observe does not need to exist.

Rung 4 is the reason this is a ladder rather than a pair. runtime.KeepAlive did what it promises — the allocation is no longer deleted, and 31.56 ns is real work. But B/op is still zero. The buffer exists, on the stack. It never escapes the function, so the compiler put it in the stack frame, where creating it costs a pointer bump and zeroing four kilobytes, and costs the collector nothing at all.

Only rung 5 measures a heap allocation, and it is fifteen times rung 4 and two thousand times rung 1.

Three of the five rungs report a number a reader would happily paste into a commit message. Only one of them measures what the code does in production, and nothing in the output distinguishes them.

20.1.3 The One Line the Benchmark Cannot Tell You

Nothing in the benchmark output distinguishes rung 1 from rung 3, and 0 allocs/op appears on four of the five. The compiler will tell you directly, though, if you ask:

Terminal
$ go test -run xxx -bench xxx -gcflags=-m 2>&1 \
    | grep 'make(\[\]byte, 4096)'
Terminal
hygiene_test.go:51:11: make([]byte, 4096) does not escape
hygiene_test.go:56:14: make([]byte, 4096) does not escape
hygiene_test.go:63:14: make([]byte, 4096) does not escape
hygiene_test.go:70:14: make([]byte, 4096) does not escape
hygiene_test.go:76:19: make([]byte, 4096) escapes to heap

Only the last says escapes to heap. That output answers “is this benchmark measuring what I think”, and it is available before the benchmark runs.

This is worth building a habit around, because it generalises past benchmarks. §20.6 returns to it as an optimisation technique in its own right: the allocation that does not escape needs no pool, no arena and no tuning, because it is not an allocation.

A three-second check.

Before trusting any allocation-related benchmark, run it once with -gcflags=-m and read the line for the allocation you care about. “escapes to heap” means the benchmark is measuring the heap. “does not escape” means it is measuring the stack, or nothing. The check costs one compile and settles the question completely.

20.1.4 What b.Loop Protects, and What Inlining Takes Back

Go 1.24 added b.Loop, and it is widely described as the fix for this whole problem. It is a real improvement and it is not that.

Read what it promises:

From go doc testing.B.Loop.

Within the body of a for b.Loop() { ... } loop, arguments to and results from function calls and assigned variables within the loop are kept alive, preventing the compiler from fully optimizing away the loop body. […] This applies only to statements syntactically between the curly braces of the loop, and the loop condition must be written exactly as b.Loop().

Every word is load-bearing, and most readers stop at the end of the first sentence. Function calls. Assigned variables. A discarded make is neither: make is a builtin rather than a call, and _ = is a blank assignment rather than an assigned variable. A discarded alloc4k() is one of them — it is a function call, and the promise covers the result of a function call whether or not anybody keeps it.

Measured the same discarded four-kilobyte allocation in four forms, seven runs each, medians.
Loop body
empty
_ = make([]byte, 4096)
_ = alloc4k(), a call, result discarded
_ = alloc4kNoInline(), the same call

The second row is the documented boundary. Under b.Loop, the discarded make costs 1.129 ns against an empty b.Loop's 1.134 — which is to say it is still gone. b.Loop did not save it, because there was no call and no assignment to keep alive.

The third row is the one the documentation is technically right about and nobody reads that way. Wrapping the allocation in a function should make b.Loop's promise apply, because the result of a function call is exactly what it covers. It reports 1.134 ns: the floor. The call was inlined — which b.Loop has allowed inside its body since Go 1.26; before that it suppressed inlining there, and the third row would have read like the fourth — and after inlining there is no function call, so there is no function-call result to keep alive. The promise held; the thing it was promised about stopped existing.

Terminal
$ go test -run xxx -bench xxx -gcflags=-m 2>&1 | grep alloc4k
Terminal
hygiene_test.go:81:6: can inline alloc4k
hygiene_test.go:90:14: inlining call to alloc4k
hygiene_test.go:90:14: make([]byte, 4096) does not escape

Line 81 is the function, where the make escapes to heap. Line 90 is the call site inside b.Loop, where after inlining the same make does not escape. The fourth row adds //go:noinline and the protection arrives: 510.7 ns and a real 4096 B/op.

Derived the rule is narrower than the folklore. b.Loop protects the results of calls that survive as calls, and the variables you assign. It does not protect discarded builtins — make, append, copy, new — and it does not protect a call the compiler inlines away. Notice also that once the call survives, b.N and b.Loop agree to within 1%: the difference was never the loop form, it was the inlining.

Two more clauses of that documentation are worth reading before you trust a b.Loop benchmark, and both are one line each. The protection applies only between the braces, so a helper called from the loop protects nothing inside itself. And the condition must be written exactly b.Loop()for b.Loop() && !done compiles, runs, reports a number, and silently has no protection at all, which is a quieter failure than the inlining one because there is no -gcflags=-m line to find.

One clause is useful rather than dangerous: after Loop returns false, b.N holds the total number of iterations that ran. That is the mechanism §20.1.8's custom metrics need, and it is the one legitimate reading of b.N in a b.Loop benchmark.

The two forms do differ in one measured way. An empty b.N loop costs 0.2146 ns per iteration; an empty b.Loop costs 1.134. b.Loop is a function call and a bounds check per turn rather than a compare-and-increment, so it carries a floor about 0.92 ns higher. For anything costing tens of nanoseconds that is irrelevant. For the operations this chapter spends most of its time on — measured, an uncontended atomic add at 5.50 ns, an uncontended mutex at 10.79 ns, a sync.Pool round trip at 11.32 ns — it is between eight and eighteen per cent of the measurement.

Which to use, and the one rule about mixing them.

Use b.Loop by default: it protects call results, and it resets the timer on its first call and stops it when it returns false, which removes the ResetTimer/StopTimer bookkeeping everybody forgets. Drop to b.N with a package-level sink when the operation is in the low single-digit nanoseconds and the floor would dominate. Never use both in one benchmark — the documentation is explicit, and the iteration count then agrees with neither mechanism. And keep both arms of a comparison in the same form, or you are comparing a 0.92 ns floor against nothing.

20.1.5 benchstat, and the Run That Looked Like a Win

Chapter 16 stopped at “one run is not a number” and handed the tool here. This is the tool doing the only job that matters.

Two implementations of the same sum, one with a four-way unrolled loop, ten runs each:

Terminal
$ benchstat old.txt new.txt
$ benchstat a1.txt a2.txt

The first of those compares the two implementations. The second compares two samples of identical code — nothing was changed between the runs at all.

BENCHSTAT ON A RESULT AND ON NOTHING

Two benchstat comparisons printed as the tool prints them. The first, old.txt against new.txt, reports 1.813 microseconds per operation against 1.000, with a one per cent spread on each side, minus 44.82 per cent at p equals zero over ten samples. The second compares two samples of the identical binary and reports 1.859 microseconds at a fourteen per cent spread against 1.828 at one per cent, minus 1.67 per cent at p equals 0.010. The second pair is the same binary twice.

The first is a result. A 45% improvement, a 1% spread on both sides, and a p-value that leaves no room to argue.

Read the second carefully, because there are two traps in four numbers.

The first is that p=0.010 looks significant. It is, statistically — and the code is byte-for-byte the same. With ten samples and a noisy first run, benchstat will report a significant difference between a thing and itself.

The second is the ± 14%, and it is the one that saves you. A sample with a 14% spread cannot support a claim about a 1.67% difference. The ± column is the result; the percentage is a story about it.

And here is what a single run of each would have shown. Measured: the first run in each file was 2112 ns and 1829 ns — a 13% improvement that does not exist. Taking the best of the new sample against the worst of the old, which is what happens when somebody runs a benchmark until they like it, reads as 22.3% faster.

The control run.

Once, on any machine you benchmark on, compare a binary against itself and look at the output. That number is your noise floor, and knowing it is the difference between “this change did something” and “this machine did something”. On this machine, quiet, the answer was ~ (p=0.644); with a browser open it was the ± 14% above.

Producing those two files correctly is most of the work, and it is where the errors that survive benchstat come from. The shape that holds up:

Terminal
$ git stash # the "before"
$ go test -run '^$' -bench Sum -count=10 \
    -benchmem > old.txt
$ git stash pop # the "after"
$ go test -run '^$' -bench Sum -count=10 \
    -benchmem > new.txt
$ benchstat old.txt new.txt

Four details in that, each of which has produced a wrong answer for somebody.

-run '^$' stops the tests running before the benchmarks. Tests warm caches, allocate, and start goroutines that may still be running; none of that belongs in the first benchmark’s numbers, and it explains a surprising number of first-run outliers.

-count=10 in one invocation, not ten invocations appended. A single process runs all ten samples against the same code, the same heap and the same CPU state.

-benchmem. B/op and allocs/op are deterministic where ns/op is not — the same code allocates the same amount every time — so they carry a claim that timing cannot. A change that removes allocations has proved something even on a laptop running a video call.

The two runs must be close together in time. Thermal state, other processes and the page cache all drift. Building both binaries first and running them alternately is the paranoid version, and it is what you do when the difference you are claiming is under 10%.

One thing that shape deliberately does not do is compare across machines. There is no correction factor for a different CPU, and benchstat will compute a percentage between two machines as happily as between two commits.

What a confidence interval licenses.

benchstat reporting -8% (p=0.001) says this change, on this machine, under this workload, produced a difference unlikely to be chance. It does not say the change is 8% faster in production, where the cache is cold, the cores are shared and the workload is not your benchmark. It is evidence that something real happened. It is not a promise about your service. And ~ is an answer, not a failed experiment: it means you have no result, however far the medians moved.

20.1.6 Variance Is a Result, and the Machine Is Part of the Measurement

An optimisation has two outputs and most write-ups report one.

A service at p50 20 ms and p99 400 ms has a spread problem, and a change that takes the mean from 20 ms to 18 ms while leaving p99 alone has improved nothing anyone notices. benchstat's ± column is the benchmark-scale version of the same fact.

The intuition is that contended code is noisier, and it is worth checking rather than assuming. Measured: §20.4.1's own two counters — one mutex against 64 padded shards, under b.RunParallel at -cpu 16 — twelve runs each on a quiet machine, and §20.5.2's narrow false-sharing shape, ten runs each. Two separate benchstat invocations, with their result rows collected here:

Terminal
$ benchstat one.txt sharded.txt
$ benchstat packed.txt padded.txt
Benchmark
Counter-16
FalseSharing-16

The intuition does not survive, and it fails the same way twice. In both comparisons the worse version is the tight one at ± 1%, and the improvement is four or five times noisier.

Both results are clean — p=0.000, and neither ± is anywhere near the gap; the counter improved 6.4× and the padding 1.47×. But in both, the improved arm is the wider one. On a benchmark that is a win; on a service it is a question, because a p99 is built out of the spread and both changes made the distribution wider while making the middle better. §20.4.5 measures how far that can go.

That 1.47× is the same physical fix the cold open reports at 8.2×, on the same machine, in the same week — and §20.5.2 reports the same benchmark at 1.37× in a different run, which is the ± column doing its job. Neither the 1.47 nor the 8.2 is an error. §20.5.2 is the section that explains the gap between them, and the explanation is the chapter’s thesis: the ratio belongs to the benchmark’s shape, not to the optimisation.

Three things move a benchmark result without any code changing, and all three have been mistaken for optimisations.

Thermal state. A machine that has been running benchmarks for ten minutes is slower than one that has not. pmset -g therm on macOS reports CPU_Speed_Limit; while this chapter was being drafted it read 56, which is to say the processor was running at a little over half its nominal speed, and every absolute number taken in that window was wrong by an unknown factor. Interleaved -count samples, which is what benchstat compares, survive this. Ten runs of the old version followed by ten of the new do not.

Other processes. The ± 14% above was a browser. A system indexing daemon at 127% CPU produced a four-fold spread on a contention benchmark in the same session.

The garbage collector. A benchmark that allocates runs collections at unpredictable points, so the allocation and the collection land in different samples. -benchmem is how you see past it.

The cheapest hygiene that works.

Close everything, check that the machine is not throttled, run -count=10, and compare with benchstat. If you cannot close everything — CI, a shared machine — say so alongside the number, and treat ratios measured in one run rather than absolutes as the result. Nearly every contention figure in this chapter is a ratio for exactly that reason.

20.1.7 ns/op Means Something Else Under RunParallel

Everything above applies to any Go benchmark. This one is specific to concurrent ones, it is not documented anywhere you will trip over it, and it silently invalidates the comparison people most want to make.

Here is a function containing a fixed microsecond and a half of pure CPU work — no locks, no sharing, no allocation — measured twice. The body is byte-for-byte identical.

Benchmark form
sequential loop
b.RunParallel

The operation did not become eleven times faster. Each call still burns its microsecond and a half; that is what the function does. What changed is the denominator.

b.RunParallel starts GOMAXPROCS goroutines and hands b.N iterations out among all of them, then divides the wall-clock time by the total. So ns/op from a parallel benchmark is inverse throughput — how often the system completes an operation — and ns/op from a sequential one is latency, how long one operation takes. Same units, same column heading, different questions.

THE SAME WORK, TWO DENOMINATORS

The same function measured two ways. Sequentially, one goroutine, wall time is the sum of the operations, and each costs 1,547 nanoseconds. Under RunParallel, sixteen goroutines, wall time is the maximum rather than the sum, and each operation is reported at 134 nanoseconds while its latency is still about 1,547. Multiplying 134 by sixteen gives 2,144, so the goroutine count is the ratio.

Multiplying back gives the check: 134 x 16 = 2,144 against a true latency of 1,547 — in the right place, and over rather than exact because sixteen GOMAXPROCS on eight physical cores does not scale linearly.

The division is worth doing, once you have said out loud what it is. Derived, 1,547 / 134 = 11.5× is a throughput ratio: the system completes 11.5 times as many operations per second. It is not a latency ratio, and no single call got faster. That is the number worth having and neither benchmark printed it.

Three practical consequences.

Never read a RunParallel figure as a latency, and never divide it into a sequential one without naming which quantity you have. The same two numbers support “11.5 times the throughput” (true) and “11.5 times faster per call” (false), and only the second one ever ends up in a commit message. The comparison people actually want is §20.3.1's crossover question, and answering that needs both benchmarks written the same way — either both sequential over the same total work, or both parallel.

A RunParallel regression can be a latency improvement, and the reverse. A change that adds queueing raises latency and can raise throughput. If the product cares about p99, §20.1.8's custom metric is the only way to see it.

b.SetParallelism(n) multiplies GOMAXPROCS, it does not set it. SetParallelism(2) on a sixteen-thread machine runs 32 goroutines, not 2. To vary the core count, use -cpu — which is §20.4's whole method.

20.1.8 Measuring Something Other Than Time

ns/op is the default and often the wrong metric. b.ReportMetric lets a benchmark report what it is actually about:

pipeline_201.go
// Illustrative snippet — not a complete program
func BenchmarkPipeline(b *testing.B) {
    var handled int64
    for b.Loop() {
        handled += processBatch()
    }
    b.ReportMetric(float64(handled)/b.Elapsed().Seconds(), "items/s")
}

Three cases where this matters more than the default, and the first is not hypothetical.

Throughput under a fixed cost. A batching change makes each call slower and the system faster, because each call now carries more items. Measured: the same journal commit under a mutex, four batch sizes, five runs, medians.

Items per commit
1
8
64
512

ns/op gets 10.5× worse and throughput gets 48.6× better, in the same runs, from the same change. A reviewer reading the default column rejects a change that made the system five times faster in the metric the product is measured on. §20.6.1 is where that change is made on purpose.

Tail latency. A change that lowers the mean and raises p99 is usually a regression, and ns/op cannot express it. Collecting per-iteration durations and reporting p99 as a custom metric turns §20.1.6's argument about variance into a number benchstat will compare for you.

Latency under RunParallel. §20.1.7's ambiguity has a one-line fix: multiply the elapsed nanoseconds per iteration by runtime.GOMAXPROCS(0) and report it as ns/op-latency. The throughput figure and the approximate per-operation latency then appear side by side, which is what a reviewer needs to tell an improvement from a redistribution. This is also the one place b.N is legitimate inside a b.Loop benchmark: after the loop it holds the iteration count, which is the denominator every custom metric needs.

The general rule: benchmark the thing you would put on a dashboard. If nobody would alert on ns/op for this code path, it is not the metric to optimise.

20.1.9 The Apparatus Is Also Code

One reminder that the tools are software, found while writing this chapter.

Measured on go1.26.1, this benchmark does not compile.
ice_201.go
// Illustrative snippet — not a complete program
var p = sync.Pool{New: func() any {
    b := make([]byte, 1024)
    return &b
}}

func BenchmarkICE(b *testing.B) {
    for b.Loop() {
        v := p.Get().(*[]byte)
        (*v)[0] = 1
        p.Put(v)
    }
}
Terminal
$ go test -bench ICE
Terminal
   # bench20/ice [bench20/ice.test]
   /usr/local/.../src/sync/atomic/type.go:47:6:
       internal compiler error: panic: interface conversion:
       ir.Node is *ir.IndexExpr, not *ir.StarExpr
   Please file a bug report including a short program that
   triggers the error.
   FAIL bench20/ice [build failed]
Measured all three ingredients are required — b.Loop, a sync.Pool whose New returns a pointer to a slice, and an indexed write through that pointer. Remove any one and it compiles. The identical body under for i := 0; i < b.N; i++ compiles and runs at 10.23 ns/op, and assigning buf := *v before indexing also compiles.

This one no longer does, as of Go 1.27 — the same benchmark compiles and runs there — and that does not much matter; compiler bugs get fixed. What matters is the reflex. When a benchmark will not build, or produces a number you cannot explain, the possibilities include a mistake in your code, a misunderstanding of the API, and a defect in the toolchain — and the third is not so rare that you should spend a day assuming it is impossible. A build failure containing the words “internal compiler error” is the toolchain telling you which one it is.

20.1.10 Common Mistakes

No empty-loop control
Problem

A sub-nanosecond figure believed

Fix

Measured, b.N's floor is 0.2146 ns and b.Loop's is 1.134

Discarding the result
Problem

The compiler deletes the call

Fix

A package-level sink, and confirm with -gcflags=-m

Constant arguments
Problem

A plausible number measuring the wrong thing

Fix

Measured, 0.4282 against an honest 1.0710; vary the input

Trusting b.Loop with a builtin
Problem

0 allocs/op on a function that allocates

Fix

It covers call results and assigned variables; make is neither

Trusting b.Loop with a small function
Problem

The floor, on a call you thought was protected

Fix

Measured, inlining removes the call; //go:noinline or check -gcflags=-m

Comparing a b.Loop number to a b.N one
Problem

A 0.92 ns discrepancy that is a large percentage on cheap operations

Fix

Same loop form in both arms, or subtract the floor

One run of each
Problem

A 13% improvement that is not there

Fix

-count=10 and benchstat

Reading the % before the ±
Problem

Significance claimed on a noisy sample

Fix

Measured, p=0.010 between identical code at ± 14%

Reporting the median and not the spread
Problem

The tail got worse and nobody saw

Fix

The ± column is half the result

Benchmarking on a busy or throttled machine
Problem

A four-fold spread mistaken for a result

Fix

pmset -g therm; close everything; report ratios if you cannot

Comparing a RunParallel number to a sequential one
Problem

An 11× “speedup” that is a changed denominator

Fix

Measured, 1,547 ns/op sequential is 134 under RunParallel — same work

Optimising ns/op for a throughput system
Problem

The metric improves, the product does not

Fix

b.ReportMetric — benchmark what you would alert on

Summary: Before Any Number Means Anything

A concurrent optimisation is a claim about a difference between two programs, and the difference is usually small enough that the measuring apparatus decides the answer. The compiler deletes work nothing observes, which inside a benchmark is the work you meant to time — and it does so silently, accurately, and always in the direction that flatters you. Four of the five obvious ways to stop it do not work, b.Loop protects a narrower set of things than its reputation and loses even that to inlining, and one line of -gcflags=-m settles the question before the benchmark runs. A single run is a sample rather than a number; benchstat is what says so out loud, and its ± column is the half of the result that tells the next person whether to trust the other half.

Key Takeaways

  • A benchmark reporting sub-nanosecond times, or 0 allocs/op for allocating work, is measuring an empty loop — accurately
  • Touching the result, reading it back, and making it depend on the loop counter all fail; only escaping the loop works, and runtime.KeepAlive keeps the allocation on the stack rather than the heap
  • -gcflags=-m answers “is this measuring the heap” before you run anything, and it also shows when inlining has removed the call b.Loop was protecting
  • b.Loop covers call results and assigned variables, carries a ~0.92 ns floor, and must not be mixed with b.N in one benchmark or compared across arms
  • -count=10 and benchstat; ~ is a result and it means you have none
  • The ± column bounds what you can claim: identical code measured p=0.010 and -1.67% at a 14% spread
  • ns/op under b.RunParallel is inverse throughput, not latency, and the two differ by roughly GOMAXPROCS
  • The machine is part of the measurement — thermal throttling, other processes and the collector all move a number with no code change
Section 20.1 — in one line

Before you can ask whether a change helped, you have to be sure the benchmark contains the change, the workload contains the condition it targets, and the machine was not the variable — and the compiler, the loop form, the sample size and the thermal state each get a vote.

Self-Check Questions: Before Any Number Means Anything

A benchmark of a function that allocates a 1 KB buffer reports 0.31 ns/op and 0 B/op. What has happened, and what is the fastest way to confirm it?

The compiler deleted the allocation because nothing observes the result, so the harness timed an empty loop, correctly and to four significant figures.

The fastest confirmation is not another benchmark. Run go test -gcflags=-m and read the line for your make: does not escape means the buffer never reached the heap. Measured on the ladder in §20.1.2, four of five rungs report that line and all four report the empty-loop cost.

The second confirmation is arithmetic, and it is free. Decide in advance what the number should be. A kilobyte of memory cannot be produced in a third of a nanosecond on any machine you own; that is less than two cycles. A figure you cannot account for is the signal, and it is available before any tooling.

Fix it by assigning the slice itself to a package-level variable, then re-check that the line now reads escapes to heap. Note what that fix does not include: writing to the buffer and reading the value back is rung 2, and it does not work.

You rewrite a b.N benchmark to use b.Loop and the reported cost of a sync.Mutex Lock/Unlock pair rises from 11.2 ns to 12.1 ns. Did b.Loop make the mutex slower?

No. Measured, an empty b.Loop costs 1.134 ns per iteration against an empty b.N loop’s 0.2146 — a floor about 0.92 ns higher, because b.Loop is a function call and a bounds check per turn rather than a compare-and-increment. 11.2 plus 0.92 is 12.1, and nothing about the mutex changed.

Two consequences follow. Both arms of a comparison must use the same loop form, or the floor appears in one side and not the other and compresses or inflates the ratio between them. And a number quoted from before Go 1.24 is not directly comparable to one measured with b.Loop — which matters for this book, since several earlier chapters' figures predate it.

The floor is only worth subtracting when it is a material share of the measurement. At 11 ns it is 8%. At 780 ns it is a tenth of one per cent and the correction is noise.

benchstat reports -3.2% (p=0.089 n=10) for your change. A colleague asks whether to ship it. What do you tell them?

That there is no result yet. At the conventional threshold of 0.05, p=0.089 does not distinguish the two distributions, so the 3.2% is not established — and §20.1.5 measured what that looks like from the other side, where two samples of identical code produced -1.67% at p=0.010.

The right next step is more samples: -count 20 or -count 30 will either push p down and confirm a small real effect, or leave it where it is and confirm there is nothing there. Before doing that, check the ± column on both sides. If either spread is wider than 3.2%, more samples will not rescue the claim and the machine is the thing to fix first.

There is also a question worth asking that is not statistical. On this machine a quiet benchmark’s spread is 2 to 9%. A change that moves the median by 3% is inside that band whatever p eventually says — so if a 3% improvement genuinely matters to your system, the benchmark is the wrong instrument, and you want a load test with a latency distribution rather than a longer -count.

Your change targets lock contention. You benchmark it with b.RunParallel at the default -cpu, see no difference, and conclude the change is not worth it. What did you skip?

Three things, and each of them is a section of this chapter.

The workload check. RunParallel at the default GOMAXPROCS does create contention, but one core count is one point on a curve. §20.4 measures the same comparison reversing between -cpu 1 and -cpu 16 — the sharded version is 25% worse at one core and the single mutex is 5.6× worse at sixteen. A single point cannot see that.

The denominator. §20.1.7: ns/op under RunParallel is inverse throughput. If you compared it against a sequential number from before the change, the two figures answer different questions and the comparison is meaningless regardless of which way it came out.

The variance. Contention often shows up in the spread before it shows up in the median, and it shows up in the tail before either — §20.4.5 measures a lock whose p50 barely moves while its p99 gets three orders of magnitude worse. A ~ on sec/op with the spread unchanged is a different finding from a ~ with the spread halved, and benchstat prints both.

20.2 A Profile Is Not a Decision

Chapter 19 hands you a profile. It is accurate, it was cheap to take, and reading it correctly is a skill that chapter spent seven sections on. This section is about the step nobody teaches: a profile tells you where the time went, and that is not the same as telling you what to change.

20.2.1 The Profile That Names Nothing You Can Edit

Here is a counter behind a mutex, hammered by sixteen goroutines. It is the simplest contended thing in the book.

one_mu_202.go
// Illustrative snippet — not a complete program
func (c *oneMu) inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }
Measured this is the top of its CPU profile, sorted by flat time, exactly as pprof prints it.
Terminal
$ go tool pprof -top -nodecount=8 cpu.out
THE PROFILE OF A CONTENDED COUNTER

The top eight frames of a CPU profile by flat time, as pprof prints them, with flat, flat percent, sum percent, cum and cum percent columns. pthread_cond_wait 34.32 per cent, usleep 31.56, pthread_cond_signal 19.85 — the sum percent column reaches 85.73 after those three. Below them Mutex.lockSlow at 3.03 per cent flat and 20.52 cumulative, procyieldAsm, Mutex.Lock at 1.21 flat and 21.74 cumulative, Mutex.Unlock at 0.94 flat and 27.93 cumulative, and pMask.read. The three biggest frames are runtime functions you cannot edit.

The sum% column does the arithmetic for you: 85.73% of the profile is three runtime functions you cannot edit. inc does not appear at all. Neither does the increment. The profile is entirely correct: that really is where the CPU time went, and the total exceeding the duration is sixteen threads' worth of samples over one wall-clock run.

It is also useless as a decision. Nothing in those eight lines tells you to shard the counter, shorten the critical section, or stop sharing — and those are the only moves available.

Read the listing once more before moving on. It is going to turn out that everything you needed was printed there.

WHAT YOU CAN ACTUALLY CHANGE

Three runtime frames account for 85.7 per cent of the profile: pthread_cond_wait at 34.3 per cent, usleep at 31.6 and pthread_cond_signal at 19.9, all of which mean the machine is waiting. Against them, four things you can change: the number of locks, the size of the critical section, whether the state is shared at all, and which primitive it uses. None of the four is named anywhere in the profile.

20.2.2 The Three Things a Hot Frame Can Mean

Every hot frame is one of three, and they take different actions.

It is the algorithm. The frame is doing work that is genuinely necessary given the approach, and the approach is wrong. No amount of making the frame faster helps, because the fix is to stop calling it that many times. This is the most common case and the one people miss most often, because “optimise the hot function” is such a natural reading. §20.2.3 is a worked example where the hot frame is runtime.memmove and the bug is a quadratic insert.

It is contention. The frame is fast in isolation and expensive here because goroutines are queued for it. The profile names the symptom; §19.3.3's block and mutex profiles name the waiter and the holder, which is more than the CPU profile gives you and still not a decision.

It is nothing you can move. Memory bandwidth, a syscall, the collector, a library you do not own, crypto/sha256.blockAVX2. It is real, it is necessary, and it is about as fast as it is going to get. The correct action is to do less of it (§20.6) rather than to make it faster.

Only the first two are actionable, and the action for the first is never “optimise this function”.

WHAT A HOT FRAME LICENSES

A hot frame branches three ways. If it is the algorithm, call it fewer times or differently. If it is contention, remove the serialisation, which is section 20.4. If it is irreducible, call it fewer times, which is section 20.6. All three branches rejoin at one conclusion: every reading is a hypothesis until section 20.1 has tested it.

20.2.3 Flat and Cumulative Are Different Questions

pprof sorts the same profile two ways and they disagree about what matters. Flat is time spent in a function; cumulative is time in it and everything it called.

Here is an index builder. It reads twenty thousand records, hashes each one, and keeps the hashes in sorted order so lookups are binary searches.

index_202.go
// Illustrative snippet — not a complete program
func (x *index) add(v uint64) {
    x.mu.Lock()
    i := sort.Search(len(x.keys), func(i int) bool {
        return x.keys[i] >= v
    })
    x.keys = append(x.keys, 0)
    copy(x.keys[i+1:], x.keys[i:])
    x.keys[i] = v
    x.mu.Unlock()
}

It takes 13.2 ms for twenty thousand records, which is slower than it looks like it should be. Chapter 19's instructions produce this:

Terminal
$ go tool pprof -top -nodecount=8 cpu.out
THE HOT FRAME IS IN THE RUNTIME

The top eight frames of an index builder’s CPU profile by flat time. runtime.memmove is 54.31 per cent, sha256.blockAVX2 15.52, pthread_cond_signal 7.76, kevent 4.02, sort.Search 3.16 flat and 4.89 cumulative, an add closure 1.72, the SHA-256 Write at 1.44 flat and 17.24 cumulative, and Mutex.Unlock 1.44. The top frame is in the runtime and there is no way to optimise memmove.

runtime.memmove at 54.31% flat. It is the top frame by a wide margin, it is in the runtime, and there is no version of “optimise runtime.memmove” available to you. By §20.2.2's taxonomy it looks like case three: irreducible.

It is case one, and the column that says so is cum.

Terminal
$ go tool pprof -top -cum -nodecount=6 cpu.out
THE SAME PROFILE, SORTED BY CUMULATIVE

The same profile sorted by cumulative time. RunSerial has zero flat and 84.20 per cent cumulative; the index add method has 1.15 per cent flat and 63.22 per cent cumulative and is marked as the frame that is yours; runtime.memmove has 54.31 per cent both ways; digest has zero flat and 20.98 cumulative; and sha256.Sum256 has 0.57 flat and 20.40 cumulative.

add is 63.22% cumulative and 1.15% flat. Nearly all of the memmove is add's copy, shifting the tail of the slice one element right on every insertion. Twenty thousand insertions into a slice that grows to twenty thousand elements is quadratic, and memmove is where the quadratic lives.

The same reading applies to §20.2.1's contended counter, where it is starker still. Sorted by cumulative time, inc — the function under test — is 50.07% of the profile with 0.2% flat time. It does no work at all; it waits, and everything it waits in is somebody else’s code.

The rule that survives: the target is the highest-cumulative frame that is your code. Everything above it is scaffolding — runtime.mcall, park_m, the benchmark closure, testing's wrapper — and everything below it is what it called. It is the top of the part you can edit, and its cumulative share is the number that goes into §20.2.4's arithmetic.

Now go back to §20.2.1's listing, which was the flat sort and which you have already read once. The cum column was in it the whole time. sync.(*Mutex).Unlock sits at 0.94% flat and 27.93% cumulative, and Lock at 1.21% flat and 21.74% cumulative. On a saturated mutex, releasing costs more than acquiring — the releasing goroutine is the one that has to wake a waiter, which is where the 19.85% of pthread_cond_signal comes from. Nothing was hidden. Both numbers were printed, in the cum column, on the sixth and seventh lines of a listing that had already been dismissed as unactionable.

That asymmetry is also why §20.4.6's “shorten the critical section” advice has a floor: a quarter of the cost is not inside the section at all.

Two commands, not one.

go tool pprof -top and go tool pprof -top -cum on the same file take about four seconds together and disagree usefully. Running only the first is how a program spends a week having runtime.memmove optimised.

20.2.4 Amdahl as Arithmetic, and Where It Has No Term

We now have a diagnosis: add is responsible for 63.22% of the time, and it holds a mutex while it does it. Two changes suggest themselves, and both are hypotheses.

The first is the reflex this whole book has been building toward and the one this chapter exists to question: the work is under a lock, records could be hashed concurrently, so parallelise it.

Before writing it, the arithmetic. If a fraction s of the runtime is serial and the rest parallelises perfectly across N workers, the best possible speedup is:

AMDAHL'S CEILING

Amdahl’s law written as a formula: speedup equals one divided by s plus one minus s over N, where s is the fraction of the runtime that cannot be done in parallel and N is the number of workers.

The serial fraction is not a guess here. add takes the mutex for its whole body, so its cumulative share is s: 0.6322.

Derived at sixteen goroutines the ceiling is 1 / (0.6322 + 0.3678/16) = 1.53×. At infinite goroutines it is 1 / 0.6322 = 1.58×. The entire theoretical value of parallelising this program, with unlimited hardware, is fifty-eight per cent.

Now the measurement. Measured: twenty thousand records, five runs, medians.

Version
serial, insert-sorted
4 goroutines, insert-sorted
16 goroutines, insert-sorted

Parallelising made it 1.8 times slower, and adding four times as many goroutines changed almost nothing — the two parallel rows agree to within half a per cent, which is what a fully serialised program looks like from the outside. The hypothesis was wrong, and Amdahl did not predict how wrong: it predicted only that the upside was capped at 58%, which was already enough to decide against it.

That gap is worth naming, because it is the formula’s most misunderstood property. Amdahl’s law gives a ceiling, not a floor. Its worst case is no improvement: the serial fraction caps you, and nothing in the formula lets a change make the program slower. Real concurrent systems do not obey that. Measured in §20.4.1, one mutex under a rising core count is 5.6× slower at sixteen cores than at one, and measured here, sixteen goroutines are 0.55× a single one. Amdahl has no term for either curve.

The missing term is coordination. Every additional worker adds not just its share of the serial section but a cost paid by every other worker — the cache line moved, the waiter woken, the queue joined. That cost grows with the number of participants rather than staying fixed, so past some point each new worker subtracts more than it adds.

WHAT AMDAHL DOES AND DOES NOT PREDICT

Three curves against worker count. The ideal curve rises linearly. Amdahl’s curve rises and then flattens at one over one minus s. The measured curve rises, flattens, and then falls. Only the third is what a contended mutex actually does, and Amdahl’s formula has no term for it.

The practical use of that is not a better formula. It is a caveat on the estimate: Amdahl gives you a ceiling, and the floor can be below where you started. So the arithmetic is worth doing to decide whether an optimisation is worth attempting, and never as a substitute for §20.4's sweep, which is the only one of the two that can report a negative number.

The number Amdahl needs is the one people assume.

Every use of this formula turns on s, and it is routinely guessed at “oh, maybe 10%”. Guessing 10% here would have predicted a 6.4× speedup and justified the change. The measured 63.22% predicted 1.53× and rejected it. The cumulative column of a profile you already have is where s comes from, and when there is no single serialising function to read it off, §20.3.6 solves for it from two -cpu runs instead.

20.2.5 The Change the Profile Was Actually Asking For

The second hypothesis follows from reading add as case one, the algorithm. It maintains sorted order on every insert, at a cost of one memmove per record. Nothing requires that: the same output — an ordered slice — comes from appending everything and sorting once at the end.

run_sorted_202.go
// Illustrative snippet — not a complete program
func RunSorted(in []string) int {
    keys := make([]uint64, 0, len(in))
    for _, s := range in {
        keys = append(keys, digest(s))
    }
    slices.Sort(keys)
    return len(keys)
}

No mutex, no goroutines, no concurrency of any kind. It replaces twenty thousand O(n) insertions with one O(n log n) sort.

slices.Sort rather than sort.Slice is not incidental. sort.Slice takes a func(i, j int) bool and reaches the elements through reflection; slices.Sort is generic and compiles to a direct comparison. Measured on twenty thousand uint64 in isolation: 1.840 ms against 0.957 ms, a 1.92× difference for a change of one identifier. In this workload, where SHA-256 is most of the remaining time, it is worth 1.17× — 5.03 ms down to 4.30 ms. A performance chapter has no business shipping the reflective one.

Measured the same workload, five runs, medians, with the earlier rows kept.
Version
serial, insert-sorted
16 goroutines, insert-sorted
serial, sort once
16 goroutines, sort once

Removing the serial work — with no concurrency at all — bought 3.06×, which is twice Amdahl’s entire ceiling for the parallel version. And it did something more useful than that: it changed the serial fraction, so parallelising the remaining work now buys another 2.76× on top, for 8.43× overall.

That is the shape of the whole chapter in one example. Concurrency did not fix this program; it made it 1.8 times worse. The algorithm fixed it, and then concurrency was worth adding, because the thing standing in its way was gone. Note the order, because it is the reusable part: the change that made the parallel version worth writing was made in the sequential one.

20.2.6 Attribution Is Not Causation

The profile says 40% of the time is in json.Marshal. Removing it saves 40%, so the service gets 1.67 times faster.

That inference is wrong twice over. It is wrong because Amdahl bounds it — 1.67× is the ceiling for making the frame infinitely fast, which nobody does, and a realistic halving of a 40% frame buys 1.25×. And it is wrong because the 40% may not be removable at all: §20.2.2's third case is a frame doing necessary work, and §20.2.1's is a frame that is hot because fifteen goroutines are queued behind it.

Fraction removed
10%
25%
40%
50%
90%

Two readings. Removing the top frame of a profile almost never doubles anything — you need to delete half the program for that. And a 1.25× improvement is frequently not worth a week, which is worth knowing before the week rather than after.

The deeper error is treating the profile’s attribution as a causal claim. A profile is a record of where samples landed. “Removing this makes the program faster by that much” is a hypothesis about a counterfactual program that does not exist yet, and §20.1 is how you test it. §20.2.5's parallel version is what happens when the hypothesis is not tested: every step of the reasoning was sound and the result was a 1.8× regression.

20.2.7 What the Other Profiles License

§19.3.3 drew the distinction this section depends on: the block profile blames the waiter and the mutex profile blames the holder. Here is what each authorises.

A block profile with one dominant stack tells you where goroutines are parked. It does not tell you whether that is a problem — a worker parked on an empty channel is healthy and looks identical to one starved by a slow producer. The decision it licenses is narrow: find out what the other side of that channel is doing. It is a pointer, not a verdict.

A mutex profile with one dominant stack is the strongest signal in Chapter 19's set, because it names the code that held the lock while others waited. That maps directly onto §20.4.6's ladder: stop sharing, change the primitive, move work out, shrink the section, shard. It is also the instrument that would have found §20.2.1's counter, where the CPU profile named nothing.

A heap profile has two modes and confusing them wastes days. -inuse_space answers “what is resident right now”, which is the memory-leak question. -alloc_space answers “what has been allocated since the process started”, which is the garbage question and the one that matters for throughput. A service with a flat inuse_space and an enormous alloc_space has no leak and a great deal of collector work, and §20.6 is the section for it.

Measured in §20.6.2, the relevance: a four-kilobyte buffer that escapes costs 469.7 ns and 4096 B/op; the same buffer on the stack costs 56.99 ns and 0 B/op, and appears in no heap profile at all. alloc_space is where that difference shows up, and inuse_space is where it does not.

A flat CPU profile is itself a result. A service with a p99 of 800 ms whose CPU profile has no peak and sums to a small fraction of wall-clock time has not told you “there is no bottleneck”. A CPU profile samples goroutines that are running; time spent blocked on a channel, a mutex or a network read produces no samples, because the goroutine is not on a processor to be sampled. Flat CPU plus high latency means the time is spent waiting, which narrows the problem to exactly the class §20.4 is about — and points at the block and mutex profiles, both off by default, which §19.3.1 measured as reporting an empty profile rather than an error.

20.2.8 The Decision Procedure

Putting §20.2 together, in the order that costs least:

FROM PROFILE TO DECISION

Six questions in order. What fraction is it, which Amdahl bounds and which the cumulative column supplies. Is it your code, and if not read the cumulative sort to find the frame that is. Which of the three readings is it: algorithm, contention or irreducible. Sweep minus cpu, because worse with more cores means section 20.4. Can you do less of it, which beats making it faster. Only then, make it faster, and prove it with section 20.1.

Questions 1 through 5 cost minutes and no code. Question 6 is where everyone starts.

The worked handover, using this chapter’s own numbers. Chapter 19 gives you a CPU profile whose top three frames are 85.73% runtime waiting functions, and a mutex profile whose top stack is inc. §20.2.4 bounds it: inc is 50.07% cumulative, so eliminating the waiting entirely caps at 2.00×. Worth a day, so continue. §20.4's sweep answers the shape: the number rises with core count, so contention is confirmed and a bigger machine will make it worse. §20.4.6 supplies the moves in order: does this need to be shared at all? Is an atomic correct here? Can the work move out? Can the section shrink? Only then, shard. §20.1 checks the result: -count=10 on both, benchstat, and the improvement has to clear the spread.

Six questions, one code change, and the code change is last.

20.2.9 Common Mistakes

Optimising the top flat frame
Problem

A week spent on runtime.memmove

Fix

Measured, it was 54% flat and the bug was a quadratic insert

Reading a runtime frame as a target
Problem

You cannot edit pthread_cond_wait

Fix

Measured, 86% of a contended profile is three unfixable frames

Reading only the flat list
Problem

The function causing half the profile has 0% flat

Fix

Measured, inc is 0.2% flat and 50.07% cumulative

Guessing the serial fraction
Problem

Amdahl predicting 6.4× on a change that regressed

Fix

Measured, s was 63.22% and the ceiling was 1.53×

Parallelising before removing serial work
Problem

A change slower than the version it replaced

Fix

Measured, 0.55×; fixing the algorithm first bought 3.06×

Treating Amdahl as a floor
Problem

A change that made it slower, unpredicted

Fix

The formula has no negative branch; only a -cpu sweep does

Treating attribution as causation
Problem

The projected speedup never appears

Fix

Removing it is a hypothesis; §20.1 tests it

Using sort.Slice where slices.Sort fits
Problem

A reflective comparison on every swap

Fix

Measured, 1.92× on 20,000 uint64 for one identifier

Reading inuse_space for a throughput problem
Problem

A flat graph and a busy collector

Fix

-alloc_space is the garbage question

Treating a block profile as a verdict
Problem

Healthy parked workers look like starved ones

Fix

It points at the other side of the channel

Reading a flat CPU profile as “no bottleneck”
Problem

A slow service whose profile says everything is fine

Fix

Flat CPU plus high latency means waiting; §19.3

Summary: A Profile Is Not a Decision

A profile is an accurate account of where time went and a poor account of what to do. Measured on a contended counter, 85.73% of the CPU profile is three runtime functions nobody can edit and the function under test has 0.2% flat time — while carrying 50.07% of the cumulative. Every hot frame is the algorithm, contention, or something unmovable, and the three take different actions that the CPU profile cannot distinguish. The cumulative column is what names the frame you can change, and its share is the s that turns Amdahl’s law from a slogan into a bound — 1.53×, computed before any code was written, on a change that then measured 0.55×. Amdahl gives a ceiling and has no term for the floor, which is why the sweep in §20.4 is not optional. And the change that worked on that program was algorithmic, used no concurrency at all, bought 3.06×, and then made concurrency worth another 2.76×.

Key Takeaways

  • 85.73% of a contended CPU profile was pthread_cond_wait, usleep and pthread_cond_signal; the code under test did not appear in the flat list at all
  • flat says where the CPU went; cum says which of your functions is responsible, and the target is the highest-cumulative frame that is yours
  • On a saturated mutex Unlock costs more than Lock — 27.93% against 21.74% cumulative — because the releaser wakes the waiter, and both were printed in the flat listing nobody read
  • The three readings of a hot frame are the algorithm, contention, and irreducible work; the first is the most common and the least suspected
  • The cumulative share of a function that holds a lock for its whole body is the serial fraction Amdahl needs
  • A 63.22% serial fraction caps parallelisation at 1.58× at any core count; the measured result was 0.55×, because Amdahl has no negative branch
  • Removing the serial work bought 3.06× with no concurrency, and then made concurrency worth 2.76× more
  • -inuse_space is the leak question and -alloc_space is the garbage question; a flat CPU profile with high latency means waiting, not health
Section 20.2 — in one line

A profile tells you where the time went, which is not what to change — and the arithmetic that turns one into the other is a cumulative column, a division, and a hypothesis you still have to test.

Self-Check Questions: A Profile Is Not a Decision

Your CPU profile shows 45% of samples in json.Unmarshal. Your service’s p99 is 400 ms and its CPU utilisation is 12%. What is the profile telling you?

That json.Unmarshal is where the CPU went, and that the CPU is not where the time went.

Twelve per cent utilisation with a 400 ms p99 means the service is spending most of its wall-clock time not running — blocked on a lock, a channel, or a network read. A CPU profile samples goroutines that are on a processor, so none of that waiting appears in it at all. The 45% is 45% of the 12%, which is about five per cent of the wall clock, and making json.Unmarshal infinitely fast would move p99 by roughly that.

The instrument for the other 88% is §19.3's block and mutex profiles, and §19.3.1 measured the trap: both are off by default and return an empty profile rather than an error, so “I checked and there was nothing there” is a claim worth verifying.

There is a second reading worth having. Unmarshalling is often case three from §20.2.2 — necessary work — and the productive question is not “can this be faster” but “does this have to happen at all, at this size, on this path”. A response that decodes fields nobody reads is doing removable work, and §20.6 is that section.

You compute your workload’s Amdahl ceiling at 12× on a sixteen-core machine, and measure 11.4×. Where should the next week go?

Not into more parallelism, and the arithmetic says so in one line: you are at 95% of the available ceiling, so the entire remaining upside from perfect parallel execution is 5%.

That is the useful thing about computing the ceiling first. A measured 11.4× looks like a disappointment against sixteen cores and looks like a success against twelve, and only one of those readings leads anywhere. The gap between 11.4 and 12 is not where a week belongs.

What is left is the serial fraction itself. With a 12× ceiling at N=16, s is about 0.022 — two per cent of the runtime is serial, and that is the term to attack, because shrinking it raises the ceiling for every future change. §20.2.5 is the shape: the win came from changing what the serial part did, not from parallelising harder around it.

And there is the option this chapter keeps returning to. If the serial part cannot shrink and you are at the ceiling, the honest report is that this workload is finished, and the next week belongs to a different part of the system — or to §20.6, where doing less work has no ceiling of this kind at all.

A CPU profile shows runtime.mapaccess2_faststr at 34% flat, the highest in the profile. What are the two most likely real explanations, and which column distinguishes them?

Either a hot loop is doing far more map lookups than it needs to, or one caller is doing a reasonable number of lookups on a map that has grown large enough that each is expensive. Both are “the algorithm” rather than “make map access faster”, which is not an option available to you.

The cum column and the callers view — pprof -peek, or the graph — name the function responsible. That is the frame to change, by caching the lookup, restructuring the key, or hoisting it out of the loop. §20.2.3's index builder is the same shape with a different runtime function on top: runtime.memmove at 54.31% flat, and the bug was a quadratic insert one frame up.

There is a third possibility worth eliminating cheaply, because it is specific to this function. mapaccess2_faststr hashes the string on every call, so a hot path building its key with fmt.Sprintf is paying for the construction as well as the lookup — and §20.6.2 measures that at three allocations per call. If the key is a concatenation of two strings, a struct key removes the hashing of the joined form and the allocation together.

Why can a CPU profile be flat while the p99 is 800 ms, and what does that narrow the problem to?

Because a CPU profile samples goroutines that are running. A goroutine blocked on a channel receive, a mutex, or a network read is not on a processor, so it contributes no samples — the time is real and invisible to that instrument.

Flat CPU plus high latency therefore means the time is spent waiting, which is a narrowing rather than a dead end. It points at exactly the class of problem §20.4 is about, and at the two instruments built for it: §19.3.3's block profile, which blames the waiter, and its mutex profile, which blames the holder.

The counter in §20.2.1 is the version of this that is not flat, and it is worth contrasting. There the CPU profile had a very sharp peak — 85.73% in three runtime functions — and it was equally useless, because those functions are the machinery of waiting rather than the reason for it. A flat profile and a profile that is entirely pthread_cond_wait are the same finding wearing different shapes: the program is waiting, and the CPU profile cannot tell you what for.

20.3 Is Concurrency the Answer?

Nineteen chapters have taught you to make concurrent code correct. This is the first one that asks whether the code should be concurrent at all.

One chapter has already asked it once. §2.5 measured a goroutine per item against a plain loop, on this machine and this toolchain, at a thousand items of trivial work: 269,521 ns against 1,868, which that section reports as 144× slower and correctly attributes to goroutine creation. It then stops, because Chapter 2 is about what a goroutine costs rather than about when to use one.

What no chapter has done is sweep the size until the answer changes sign. That is this section, and it turns §2.5's single point into a curve — one that crosses zero, keeps going, and then turns out to depend on something other than the number of items.

The omission produces a specific bug, and it is one of the most common shapes in Go code: a service where every operation is a goroutine, none of the goroutines has enough work to justify itself, and the profile shows time in the scheduler.

20.3.1 The Baseline Nobody Takes

Every optimisation claim in this chapter is a ratio, and a ratio needs a denominator. For concurrency the denominator is the sequential version — and almost nobody writes it, because the code is already concurrent by the time anyone benchmarks it. Almost every published concurrency benchmark compares one concurrent design against another concurrent design.

Writing the sequential version is usually twenty minutes and it settles three questions at once: whether concurrency helps at all, what the serial fraction actually is, and what the parallel overhead costs. §20.7's worst cases are all changes that were never compared against doing nothing.

The procedure:

  1. Write the sequential version, however ugly. It does not ship.
  2. Benchmark both at the sizes you actually see, not the size that makes the point.
  3. If the parallel version does not win by a clear margin under §20.1's rules, keep the sequential one — it is simpler, it has no §18.4 protocol or lifetime bugs, and it cannot deadlock.

That last point is the one that gets forgotten. The sequential version’s advantage is not only speed at small n; it is that Chapters 2 through 18 do not apply to it.

20.3.2 The Crossover, Measured

The same arithmetic over n items, done sequentially and split across sixteen goroutines with a WaitGroup and a per-goroutine accumulator. No shared state, no locks, no channels — the friendliest possible parallel decomposition.

Measured five runs at each size, medians, -benchtime 200x so that every row runs the same number of times regardless of how long it takes.
Items
100
10,000
1,000,000

The concurrency did not become correct at a million items. It was always correct. It became worth it.

Three readings, in increasing order of usefulness.

The 1,000,000 row is the headline because it is the reproducible one, and 4.2× on a machine with eight physical cores is a good result. §20.3.5 is about why it is not sixteen.

It also lines up with §2.5 from the other end. That section’s 144× at a thousand items and this one’s 134× at a hundred are the same phenomenon at two sizes, measured a year and eighteen chapters apart, and neither of them is a statement about goroutines being slow. Both are statements about a thousand goroutines each doing a nanosecond of work.

The 100 row should be read as “two orders of magnitude”, not as 134. It is the least stable figure in this chapter: across runs the sequential baseline ranged from 38.77 to 144.1 ns, because 39 ns is close enough to the timer’s floor that the ratio is mostly measuring goroutine creation. The instability is the point rather than a flaw — at this size the concurrency costs so much more than the work that the exact multiple is uninteresting.

The 10,000 row is where people actually live, and it is still a loss. Ten thousand items feels like a lot. It is not enough to pay for sixteen goroutines, a WaitGroup, and the cache traffic of writing into a shared output slice.

20.3.3 It Is Work Per Goroutine, Not Item Count

The crossover above is not at “a million items”. It is at a total amount of work, and the same experiment with heavier items moves it by three orders of magnitude.

The summation costs well under a nanosecond per item, which is why it takes hundreds of thousands of them to cover the coordination. Replace the addition with a SHA-256 and everything else stays the same — same decomposition, same WaitGroup, same sixteen goroutines, same machine.

Measured five runs at each size, medians. The only change from §20.3.2 is what happens to each item.
Items
100
400
1,600
10,000

The crossover moved from somewhere past ten thousand items to somewhere between one hundred and four hundred — a factor of a thousand or more, for a change that touched neither the concurrency nor the machine. Derived, each hash costs about 183 ns, so at four hundred items each goroutine carries twenty-five of them: about 4.6 µs of work, which is where §2.5's band says to expect concurrency to start paying and where it does.

So the question to ask before parallelising is not “how many items?” but “will each goroutine do at least a few microseconds of work before it needs to coordinate again?” If the answer is no, the answer to the whole question is no — and the two tables put the same workload on opposite sides of §2.5's band: 4.6 µs of hashing per goroutine at the SHA crossover, against a few hundred nanoseconds of addition at a size where the sum still loses.

There is a number available for that, and it is §2.5's rather than this chapter’s. That section measured the goroutine round trip — creation through to completion — at 469 ns, and derived a band from it: under about a microsecond of work per goroutine, batch; past about fifty microseconds, spawn freely; in between, measure. This chapter’s own data sit inside that band exactly where they should. §20.3.2's 100-item row spends 5,244 ns on sixteen goroutines, which is 328 ns each — inside §2.5's measured 150–470 ns creation cost, and doing a hundred sub-nanosecond additions to earn it.

The rule of thumb, and where the two chapters agree.

§2.5's bands are the ones to use, and this chapter has not found a reason to move them: under ~1 µs of work per goroutine, batch it; past ~50 µs, spawn freely; between the two, measure. What §20.3.4 adds is that the band is about work per goroutine and not per item, so the same workload can sit at either end of it depending only on how you chunk it — and that the “measure” instruction in the middle is not a formality. That band is wide, it is where most real workloads sit, and nothing about it is predictable from first principles.

The batching escape.

When the items are small and there are many of them, the answer is not “do not parallelise” — it is “give each goroutine more items”. That is §20.6's batching, and it is why the two sections are the same argument at different scales: a goroutine per item is the worst case of a batch size of one.

20.3.4 The Granularity Is the Knob

The previous three subsections ask whether to parallelise. This one is about the mistake people make once they have decided to, and it is the largest single factor in this chapter.

Here is one fixed job — apply a four-instruction hash to 1,048,576 values — split across the same sixteen threads six different ways. The only variable is how many items each goroutine handles.

Measured three runs at each granularity, medians; the totals are identical, so the rows differ only in how the work was divided.
Items per goroutine
1
16
256
4,096
65,536
1,048,576

The best and worst rows are both correct, both parallel, and both use all sixteen threads. They differ by 1,531×.

Divide the worst row out: 274 ms across 1,048,576 goroutines is roughly 261 nanoseconds of scheduling per item, against a hash that the bottom row prices at about 1.16 ns. The program spent two hundred and twenty-six times as long deciding who would do the work as doing it. That is not a scheduler defect — §2.5 measured a goroutine at 156 ns to issue and 469 ns to create and finish, and 261 ns is squarely inside that, multiplied by a million.

The curve is also the answer to “how many goroutines?”, which is the wrong question. The two parallel rows at 4,096 and 65,536 items per goroutine are within 20% of each other — 0.179 ms and 0.214 — so anywhere in that range is right, and the useful reading is that there is a floor, not an optimum. Give each goroutine enough work to clear the overhead — the ~1 µs of §20.3.3 — and the exact figure above that stops mattering. The last row is not part of that plateau: it is the one-goroutine control, and it is 6.8× the plateau because it uses one thread out of sixteen.

ITEMS PER GOROUTINE, ONE JOB, 16 THREADS

One job of 1,048,576 hashes split six ways across the same sixteen threads, drawn as bars. One item per goroutine takes 274.00 milliseconds; sixteen items each takes 17.30; two hundred and fifty-six takes 1.08; four thousand and ninety-six takes 0.18 and sixty-five thousand five hundred and thirty-six takes 0.21, and those two are within twenty per cent of each other; the last row, labelled one goroutine, does all of them on a single goroutine at 1.21 milliseconds. The knee is granularity, not core count.

A goroutine per item.

for i := range items { go work(i) } is the top row of the table and it is one of the most common shapes in Go code. It is not “using concurrency”; measured here it is 226× slower than a plain loop, and it gets worse in proportion to the number of items. The fix is never “fewer items” — it is a worker pool (§7.3) or a chunk loop, both of which are the same change: raise the work per goroutine.

20.3.5 Why Sixteen Threads Did Not Give Sixteen Times

The million-item row is 4.2× on sixteen hardware threads. The missing 11.8× has three causes and they are worth separating, because only one of them is yours.

The serial fraction. Setting up, splitting, and summing the sixteen partial results happens once, on one goroutine. §20.2.4's arithmetic bounds the rest.

Memory bandwidth. Sixteen threads reading one array saturate a shared resource that does not scale with core count. §20.5.4 measures it directly — the same loop scales 8.1× when each element is given arithmetic to do and 4.4× when it is not — and it is the ceiling most lock-free parallel code actually meets.

The machine. Sixteen GOMAXPROCS on an eight-core, sixteen-thread part is eight physical cores with hyper-threading — throughput scaling past eight is real but sublinear by construction.

None of the three is a bug, and all three are why the honest expectation for a parallel speedup is well under the thread count, and why claiming otherwise in a design document is a sign nobody measured.

20.3.6 Measuring Your Own Serial Fraction

§20.2.4's arithmetic needs s, and §20.2.3 read it off a profile because there was one function holding a lock for its whole body. Often there is not: the serial part is spread across a setup phase, a merge phase, and the coordination itself.

There is a direct way that needs no profile. Run the same workload at one thread and at N, and solve Amdahl backwards:

SOLVING FOR THE SERIAL FRACTION

Amdahl’s law rearranged to give the serial fraction: s equals T of N over T of one, minus one over N, all divided by one minus one over N. Two minus-cpu runs and one division, with no profile required.

Derived, from §20.3.2's numbers: sixteen goroutines produced 4.2× on the million-item case, so T(N)/T(1) is 0.238 and s = (0.238 - 0.0625) / 0.9375 = 0.19. Nineteen per cent of that workload is not parallelisable, which for a split-and-sum is the split, the sum, and the memory bandwidth together.

That number is worth having before the next optimisation, because it is the ceiling for every future parallel change to the same code. With s = 0.19, no amount of hardware takes it past 5.3×, and a proposal to move from sixteen threads to sixty-four is asking for a 1.2× improvement in exchange for four times the machine.

The estimate is rougher than the profile’s: it attributes all non-scaling to serial work, including contention and cache effects that are not strictly serial. That is arguably the more useful number, because those costs are real and they do not go away when you add cores either. And it converts an argument into a measurement — “this will scale if we give it more cores” is a claim about s, and s costs two benchmark runs.

20.3.7 Two Different Things Are Called Concurrency

§1.1 drew this distinction and the performance consequence is worth stating plainly, because the two have different crossovers — and one of them has none.

Parallelism for throughput. Split one job across cores to finish it sooner. This is §20.3.2's table. It needs the work to be divisible and large, the crossover sat near a million items there, and it is bounded by Amdahl, by memory bandwidth and by the split.

Concurrency for latency. Start a slow thing now so you can do something else while it runs. Two backend calls concurrently finish in max(a, b) instead of a + b, and this has no crossover at all — it wins whenever the operations are slow relative to a goroutine, which for anything involving a network is always.

THE TWO CASES HAVE DIFFERENT ARITHMETIC

Two things are called concurrency and they have different arithmetic. Parallelism for throughput splits a million items across sixteen threads, is bounded by Amdahl, memory bandwidth and the split, and paid 4.2 times after needing a million items. Concurrency for latency runs two forty-millisecond calls at once, is bounded by the slower one, and turns eighty milliseconds into forty at n equals two. The second needs no crossover analysis at all.

The confusion between them produces both of this section’s bugs. A goroutine per item over a slice is parallelism applied where the work is too small — §20.3.4's top row. And a sequential loop over three independent HTTP calls is latency left on the table where one errgroup would have taken it, which is §14.4's whole subject arriving here as a performance point.

The question that separates them: is the work waiting, or is it computing? Waiting parallelises for free. Computing has a crossover, and §20.3.2 measured where.

This is also the clarification that keeps the section from being over-applied. A server handling ten thousand connections is concurrent because the connections are independent and each spends most of its life blocked; §1.3's I/O-bound case is where a goroutine’s few kilobytes buy a capability rather than a speedup, and none of this section’s numbers apply to it. And a change that splits a 100 ms request four ways to return in 30 ms is a win even though it burns more total CPU, because the thing being optimised is the wait. Decide which objective you are optimising before reading any table here.

20.3.8 Common Mistakes

A goroutine per item
Problem

Time in the scheduler; slower than a loop

Fix

Measured, 226× slower at a million items

Assuming ten thousand items is enough
Problem

Still slower, and nobody checks

Fix

Measured, 2.2× slower at n=10,000

Expecting speedup near the thread count
Problem

Design documents promising 16×

Fix

Measured, 4.2× on sixteen threads; derived s = 0.19

No sequential baseline
Problem

Every ratio has no denominator

Fix

Twenty minutes, and it settles three questions

Tuning the goroutine count
Problem

The wrong knob; the curve is flat above the floor

Fix

Measured, 4,096 and 65,536 items per goroutine are within 20%

Parallelising because the data is a slice
Problem

Concurrency added, latency added

Fix

Work per goroutine, not item count

Sequential independent network calls
Problem

Latency left on the table at n=2

Fix

Waiting parallelises for free; §14.4

Guessing the serial fraction
Problem

Every scaling projection is wrong

Fix

Derived, solve it from a two-point sweep

Applying these numbers to I/O-bound work
Problem

Concluding a connection-per-goroutine server should be sequential

Fix

§1.3 — for blocked goroutines the cost model does not apply

Summary: Is Concurrency the Answer?

Concurrency has a fixed cost that is paid before any benefit arrives, and the benefit scales with the work while the cost does not. That makes it a crossover rather than a preference, and the crossover sits further out than almost anyone guesses: the same summation was 134 times slower across sixteen goroutines at a hundred items, still 2.2 times slower at ten thousand, and 4.2 times faster at a million. The unit that decides it is work per goroutine rather than item count: measured, swapping the addition for a SHA-256 moves the crossover from past ten thousand items to between one hundred and four hundred. And once you have decided to parallelise, granularity rather than core count is the knob — the same job split six ways across the same sixteen threads spans 1,531×, with a goroutine per item at the bottom of it, spending 261 nanoseconds of scheduling on 1.16 nanoseconds of work. None of which applies to I/O-bound concurrency, where a goroutine buys the ability to wait on many things at once and there is no crossover to find.

Key Takeaways

  • The same work over 100 items was two orders of magnitude slower concurrently; over a million it was 4.2× faster
  • The crossover is set by work per goroutine, not item count; measured, a SHA-256 instead of an addition moves it from past 10,000 items to between 100 and 400
  • §2.5's bands still hold: under about a microsecond of work per goroutine, batch; past about fifty, spawn freely; in between, measure
  • for i := range items { go work(i) } measured 226× slower than a plain loop, and gets worse with more items
  • Above the floor the granularity curve is flat, so there is a minimum work per goroutine rather than an optimum goroutine count
  • Sixteen threads gave 4.2× for three separable reasons: the serial fraction, memory bandwidth, and eight physical cores
  • s can be solved from two -cpu runs and a division when no single function serialises the work
  • Parallelism for throughput has a crossover; concurrency for latency has none, and the question that separates them is whether the work waits or computes
  • The sequential baseline is the control every other number is a ratio against, and it is the one nobody writes
Section 20.3 — in one line

Concurrency is a purchase rather than a property, and the first measurement worth taking is the one where you do not buy it.

Self-Check Questions: Is Concurrency the Answer?

Your image pipeline processes 500 images per request, each about 2 ms of CPU work, across a worker pool. A colleague proposes removing the pool. What do you tell them?

That this is the case where concurrency is clearly worth it, and the arithmetic is not close.

Each item carries 2 ms of work. §20.3.3's threshold is a few microseconds per goroutine, and this is three orders of magnitude above it — the coordination is invisible against the work. Five hundred images at 2 ms is a full second of CPU per request served sequentially, against roughly 125 ms across eight physical cores.

What is worth checking is the granularity rather than the decision. If the pool hands out one image per task, that is fine here because an image is already 2 ms. If some other stage in the same pipeline hands out one pixel per task, §20.3.4's top row is what you have, and the fix is chunking rather than removing the concurrency.

And the colleague may be right about something adjacent: a pool sized far above the core count buys nothing for CPU-bound work and costs memory and scheduling. Removing the concurrency is wrong; sizing the pool near GOMAXPROCS may not be.

You delete a fan-out, benchstat reports ~, and you ship the sequential loop. What did you gain?

Everything except speed, which is the point.

~ means the two versions could not be distinguished, so you gave up nothing measurable. What you removed is a WaitGroup, a set of goroutine lifetimes, a place for a panic to escape unrecovered, an ordering that was implicit, and a piece of code to which Chapters 2 through 18 apply. The sequential version cannot deadlock, cannot leak a goroutine, and cannot race.

It also gets easier to reason about in a way that compounds. The next person changing that code does not have to establish which invariants hold across the fan-out, and the next profile of it has one fewer source of scheduler time.

There is a second gain that is easy to miss: you now have a measured baseline. If the workload grows and someone proposes re-parallelising it, the question is already framed as a crossover with a known control, rather than as an argument about whether concurrency is good.

Why does the same code show 134× slower at 100 items and 4.2× faster at a million, when nothing about its correctness changed?

Because both figures are the same ratio of two costs whose sizes move in opposite directions with the item count.

The cost of the concurrency is roughly fixed: sixteen goroutines to create and schedule, a WaitGroup to coordinate, and cache lines to move between cores. Call it a few microseconds, and it barely changes between the two experiments. The cost of the work scales with the items — 39 ns at a hundred, 394 µs at a million.

At a hundred items you are dividing a few microseconds of overhead by 39 ns of work, and the ratio is enormous. At a million you are dividing the same few microseconds by nearly four hundred microseconds, and it disappears while the eight physical cores do their job.

That is why §20.3.3 insists the unit is work per goroutine rather than item count. It also explains why the small-n figure is unstable — the sequential baseline ranged from 38.77 to 144.1 ns across runs — while the large-n figure reproduces: the denominator at a hundred items is close to the timer’s floor, which is the noisiest thing in the measurement.

Your handler already runs one goroutine per request and the service is at 80% CPU across sixteen threads. A colleague proposes parallelising the work inside each handler. What is the argument against?

That the cores are already busy, so there is no idle capacity for the new goroutines to use, and the crossover arithmetic is being applied at the wrong level.

Parallelism inside a request pays when the machine has cores that would otherwise be idle during that request. At one concurrent request that is the whole machine. At the concurrency implied by 80% utilisation it is close to nothing: every goroutine the handler spawns queues behind work that was already going to run. Total throughput cannot improve, because the bottleneck is not the shape of any one request; it is the sum of them.

What does change is the cost. Each handler now pays goroutine creation, a WaitGroup, and the cache traffic of moving its working set between cores — measured in §20.3.2, sixteen goroutines and a WaitGroup cost about five microseconds before any work happens. At a few thousand requests per second that is a measurable fraction of a core spent on scheduling.

There is one case where it still wins, and it is why the proposal keeps coming back: it improves the latency of an individual request at the cost of throughput. If your p99 is the problem and your utilisation is not, trading capacity for a shorter critical path is legitimate — but it is a choice, and it must be measured as one, with the load test running at production concurrency rather than one request at a time.

20.4 Contention as a Curve

§20.2 said a profile is a single point. Contention is not a point; it is a curve, and the shape of the curve is the finding.

The reason is structural. A mutex under no contention costs a compare-and-swap and nothing else. A mutex with two goroutines occasionally colliding costs a little more. A mutex with sixteen goroutines all wanting it costs the compare-and-swap, plus the handoff, plus the cache line moving between cores, plus the parked goroutines waking in an order the runtime chose. Those costs do not scale linearly with the number of contenders, and they do not scale the same way for different designs.

Which means a benchmark run at one core count is a single sample from a function nobody has looked at. go test -bench defaults to your GOMAXPROCS, so most people’s single sample is taken at whatever their laptop has.

20.4.1 The Same Code, Two Directions

One counter behind one mutex, and the same counter split across 64 shards, each padded to its own cache line. Identical work, identical machine, identical minute. The only thing that changes is GOMAXPROCS.

one_mu_204.go
// Illustrative snippet — not a complete program
type oneMu struct {
    mu sync.Mutex
    n  uint64
}

func (c *oneMu) inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

type shardMu struct {
    s []struct {
        mu sync.Mutex
        n  uint64
        _  [48]byte // 8 + 8 + 48 = one cache line
    }
}

func (c *shardMu) inc(k uint64) {
    i := k % uint64(len(c.s))
    c.s[i].mu.Lock()
    c.s[i].n++
    c.s[i].mu.Unlock()
}

The padding is not decoration, and the size of it is not a guess: sync.Mutex is 8 bytes on this platform and the counter is 8, so 48 bytes of padding is what makes a shard exactly one 64-byte line. Get it wrong and the struct is 56 bytes, four shards straddle three lines, and the benchmark measures §20.5's problem instead of this one.

Measured, that is not a hypothetical. The same 64-shard counter, sixteen goroutines, seven runs, medians — padded 8.57 ns/op, unpadded 13.77 ns/op, a 1.61× penalty for eight bytes of arithmetic in a struct declaration. That figure is §20.5's subject leaking into a §20.4 benchmark, and it is an instance of §20.1's rule arriving from the other direction: the workload has to contain the thing you meant to measure, and only the thing you meant to measure.

Measured b.RunParallel, a contention-only critical section, median of three at each point.
-cpu
1
2
4
8
16

Sixteen times the processors made the left column 5.6× slower. The same code, the same machine, the same afternoon, and the only thing that changed was -cpu.

Two findings, and the second is the one nobody quotes.

The single mutex degrades monotonically with processor count, 5.6× from one to sixteen. That is the convoy: one goroutine runs the critical section while the others queue, so adding processors adds queuers rather than throughput. What they add is cache-line traffic on the lock word, futex wakeups, and scheduler work, all of which scale with the number of contenders. Buying a bigger instance for a workload shaped like the left column does not merely fail to help. It costs you throughput, and the bill arrives as a latency regression that no code change explains — because the deploy was to a larger machine type.

And at one and two processors, sharding is the worse design. 13.50 against 10.79 at -cpu 1, 25% worse. The sharded version does more work per operation: an index computation, a bounds check, and a cache line that is probably cold because the previous operation touched a different shard. Sharding is not free and it is not always right.

That second finding has a specific and common production shape. A map is sharded on a sixteen-thread laptop, where it is genuinely faster. It is deployed to a container with a one-CPU quota, where it is measurably slower than the plain mutex it replaced — and because the change was benchmarked and the benchmark was honest, nobody re-checks it.

THE SAME WORKLOAD, SWEPT

Two bar charts of nanoseconds per operation against processor count. One mutex climbs from 10.8 at one processor through 13.6, 31.0 and 51.4 to 60.4 at sixteen, which is worse. Sixty-four padded shards go 13.5, 15.8, 10.0, 10.2 and 7.6, which is better. More processors, opposite answers.

20.4.2 What Reproduces, and What Does Not

That table is one run, and this chapter’s own rule is that one run is not a number. Three independent sweeps of the sharded column, taken at different times on the same machine, disagree about a great deal:

-cpu
1
2
4
8
16

Run A improves monotonically after two. Run B has a deep trough at four processors. Run C is flat and unremarkable. All three agree on the thing that matters — sharding removes the degradation — and none of them agrees on where the sharded curve’s best point is. A chapter that printed run B alone would have told you, with a straight face, that four processors beat sixteen.

The single-mutex column is the robust half: across runs it degrades by 5.6×, 4.8× and 3.8× from one processor to sixteen. Call it roughly four to five. The direction never changes.

There is a further trap underneath. Swap the counter payload for a map write and the sharded column reverses completely — 24.91 ns at one processor to 42.30 ns at sixteen — because that benchmark is measuring memory traffic across sixty-four maps rather than lock contention. Same idea, same sweep, opposite conclusion, and the only difference is what the critical section does.

The most quotable cell is the one to check twice.

The robust output of a -cpu sweep is the direction: does this code get better or worse as processors are added? That reproduces. The position of an optimum does not, and neither does the magnitude — both depend on the payload, the cache, and the scheduler’s mood. Report the direction; treat everything else as local to the run. An earlier draft of this chapter reported a dramatic trough at four processors as a finding. It did not survive being run again, and it is recorded here because it is exactly the failure §20.1 warns about: a real measurement, correctly taken, whose most quotable feature was noise.

20.4.3 The Primitive Is a Bigger Lever Than the Count

Before sharding a lock, it is worth asking whether it needed to be a lock.

Measured sixteen goroutines under b.RunParallel, one shared word, the same operation through four primitives, seven runs, medians.
Primitive
sync.Mutex
sync.RWMutex
atomic.Load / atomic.Add

Two things in that block are worth stopping on, and the first is a number this chapter has trained you to distrust.

0.124 ns is below the empty-loop floor. §20.1.1 says sub-nanosecond for anything with a memory access means the benchmark; §20.1.4 measured b.N's own floor at 0.2146 ns. A reader who took §20.1 seriously should stop here — and the resolution is §20.1.7's, one section later than most people remember it. This is a RunParallel figure, so it is inverse throughput and not latency: sixteen goroutines each performed a load, and the wall-clock time was divided by all sixteen. The per-operation latency is about 0.124 x 16 = 2.0 ns, which is an L1 hit and exactly what an atomic load on this architecture should cost. The benchmark is honest, the units are the trap, and the chapter’s own rule catches it.

The mutex costs about the same to read through as to write through — 62.25 against 53.11 — while the atomic differs by a factor of 140 between the two. Under sixteen-way contention you are not paying for the operation inside the critical section; you are paying for the lock, and §20.4.6 measures how little shrinking the section can do about that.

With those established, the read row is the one to sit with. An atomic load is around five hundred times cheaper than taking a mutex for the same read — 503× at the medians, and taking the worst atomic sample against the best mutex sample it is still 426×. There is no lock, no waiting, and nothing for sixteen cores to queue on. RWMutex sits in between at 2.35× better than Mutex, which is a real improvement and a rounding error next to the atomic.

Chapter 11 taught when an atomic is correct — a single word, no invariant spanning two variables — and §11.3 spent a section on where that stops being true. This is the performance side of the same decision, and the gap is not incremental. For writes it is smaller, 3.0×, but still larger than most sharding buys and it comes without a shard count to tune. §20.7.7 is the price of getting the correctness half wrong, and it is high.

WHICH PRIMITIVE, BY WHAT YOU ARE DOING

Four primitives by what the code is doing. One word with no invariant across fields takes an atomic, measured at 0.124 nanoseconds per read. Reads dominating long sections take an RWMutex at 26.4 nanoseconds. Anything else takes a Mutex at 62.3 nanoseconds. Still contended after all three takes shards, and a parameter to tune. Going down the list: more general, more expensive, and more to maintain.

RWMutex deserves one caveat, because §9.3 measured where it stops paying and this is the same boundary from the performance side. An RWMutex does more bookkeeping than a Mutex — a reader count and a writer-pending flag — so it only wins when readers both dominate and hold the lock long enough to amortise it. The 2.35× above is an all-read workload doing nothing but a load; with a longer read the gap widens, and with a write-heavy mix RWMutex is slower than Mutex. §17.3.6 measured the case where it is decisive: swapping Mutex for RWMutex on a sharded limiter map took 128.5 ns to 49.6, which that section correctly called a bigger win than every sharding decision combined.

20.4.4 What Sharding Costs

Sharding is presented almost everywhere as free. It is not, and there are three bills. (Picking the shard needs a hash of the key; for any comparable key type maphash.Comparable supplies one, and Go 1.27's maphash.ComparableHasher packages hash and equality together as a maphash.Hasher.)

The first is diminishing returns, and §17.3.6 already measured it on a sharded limiter map:

Design
one shared rate.Limiter
keyed map, sync.Mutex, 8 shards
keyed map, sync.Mutex, 64 shards
keyed map, sync.Mutex, 512 shards
keyed map, sync.RWMutex, 64 shards
one limiter per goroutine

Sharing costs 52.7 ns in that table — 180.3 down to 127.6 — and the first eight shards recover 43.2 of it. Sixty-four times the shards for the last 18%. Everything useful happened before the eighth shard.

That table also names the reason the curve flattens, which is more useful than the flattening itself. Every request still took the enclosing map’s own lock regardless of which shard it wanted, and a floor that does not shard cannot be broken by sharding. Before adding shards, find what every caller still touches; the distance between your current number and that floor is all the sharding has left to buy.

The second is memory and cache footprint. Each shard is a cache line you did not have, a hash you compute on every operation, and a map you cannot iterate consistently. §20.5 has the arithmetic.

The third goes unmeasured, because the benchmark that justifies sharding almost always measures the write. Reading a sharded counter means touching every shard.

Measured one Sum() over a padded sharded counter — every shard locked, read and unlocked — against reading a single mutex-guarded counter, seven runs, medians.
Shards
one mutex, uncontended
1
8
64
512
4,096

The read cost is almost exactly linear — about 10 ns per shard, which is the uncontended lock from the first row, paid once per shard. That is the point: sharding does not remove the cost, it moves it from the write path to the read path, and the move is one-for-one.

Whether that matters is a question about your read rate, and it has two clean answers. If the counter is scraped every fifteen seconds, 5 µs some six thousand times a day is thirty milliseconds a day and the trade is free. If the counter is read on the request path — a quota check, a rate decision, an admission gate — you have made every request pay 5 µs to make every increment cheaper, and nobody benchmarked that direction.

So the shard count is a real parameter with a real cost on both sides, and the default reflex of “make it 256, why not” is measurably wrong.

20.4.5 Contention Is a Tail Phenomenon

Every number in this section so far has been a mean, and means are the reason contention ships to production unnoticed.

Here is the same mutex — a critical section of about thirty nanoseconds — with the distribution of the wait recorded rather than the average, at five worker counts.

Measured twenty thousand acquisitions per worker, the wait before the lock recorded on every single one, five worker counts.
Workers
1
2
4
8
16

Read the p50 column first. From one worker to sixteen it goes from 29 ns to 46 ns — it does not come close to doubling. Most acquisitions of a contended lock are uncontended: the goroutine arrives, the lock is free, it takes it, and nothing about that changed.

Now read the p99. It goes from 31 ns to 60.6 µs: 1,954 times worse, on the same lock, in the same run, for the same operation.

The mean is the number that would have been reported, and it describes neither end.

ONE MUTEX, 16 WORKERS, WHERE THE TIME WENT

Four bars for the same distribution of lock waits under sixteen workers. The median is 46 nanoseconds and barely registers; the mean is 2,363; the ninety-ninth percentile is 60,578 and the 99.9th is 135,623. The mean is fifty-one times the median and four per cent of the p99, and it is the only one of the four that a dashboard shows by default.

The consequence is the one this chapter keeps arriving at in new places. A dashboard of medians cannot see contention at all. A service whose lock wait has a 46 ns median and a 60.6 µs 99th percentile looks perfectly healthy on every graph anyone draws by default, right up to the point where a p99 objective fires and there is nothing in the median to explain it. This is why §20.1.8 asks you to benchmark percentiles, and why §19's mutex profile — which accumulates total wait — finds this when a latency graph does not.

The mechanism is queueing, and §17.1.3's Little’s Law already gave it to you: as utilisation approaches one, the mean queue depth rises smoothly and the tail rises much faster. A mutex is a single-server queue with no admission control, so its waiting-time distribution has the shape every such queue has. The 430 µs maximum is one goroutine that lost fifteen coin flips in a row — with 320,000 acquisitions, some goroutine was always going to.

The one-worker row is the control and it is worth a glance. Its maximum is 146 ns, five times its own p99. An uncontended lock has no queue, so its worst case is a cache miss rather than a wait, and the entire distribution fits inside two orders of magnitude. Every row below it is the queue arriving.

Go’s mutex has a starvation mode, and this is why.

internal/sync/mutex.go sets starvationThresholdNs = 1e6. A sync.Mutex a goroutine has waited more than one millisecond for switches from barging to strict hand-off: arriving goroutines stop spinning and queue at the tail, and ownership passes directly from unlocker to the waiter at the front. The comment in the source says why in one line — starvation mode exists to prevent pathological cases of tail latency. That bound is why the maxima above stay in the hundreds of microseconds rather than growing without limit. The runtime is already defending this tail for you; it is a floor under the damage rather than a fix, and it is one more reason a mutex judged by its mean looks better than it is.

20.4.6 Four Times the Answer Is Not “Shard”

Sharding is the most-reached-for fix and the fifth-best one. Here is the whole ladder first, so that the four rungs above it are visible before they are argued for:

THE ORDER TO TRY THINGS IN

Five moves before and including sharding. One, does this need to be shared at all: per-worker state merged at the end, measured at 16.5 times. Two, is the primitive right: an atomic where the invariant is one word, measured at 503 times. Three, can the work move out of the critical section: compute unlocked and publish locked, measured at 1.51 times. Four, can the critical section be shorter: measured at 2.70 times. Five, shard it, and stop near GOMAXPROCS, measured at 7.9 times. The order is by cost to you rather than by payoff, because the first four also help at minus cpu one and sharding does not.

The ordering is deliberate and it is not the ordering by payoff — step 2 is the largest number on the list and it is second, because it is only available when the state is one word. Read the ladder as cheapest question first: each rung is a question you can answer by reading your own code, and the first four all improve the single-processor case as well as the sixteen-processor one. Sharding is last because it is the only rung that makes something worse, and §20.4.1 measured what: 25% worse at -cpu 1.

1. Remove the sharing. A per-goroutine accumulator combined at the end has no lock at all, which is what §20.3's parallel version did and why it scaled. §17.3.6's bottom row is the same finding from the other side: one limiter per goroutine at 10.9 ns/op against 180.3 shared, a factor of 16.5, available to no sharded design.

2. Change the primitive. §20.4.3's table, and it is the biggest single number in this section: 503× on the read path when the state is one word and no invariant spans two fields. §11.3 is where you check whether that is true, and §20.7.7 is what it costs when you decide wrongly.

3. Move the work out of the critical section. Compute under no lock, take the lock only to publish. §9.4's copy-release-call is the pattern, and §18.6.3 measured what happens when it is not done — a 50 ms deadline waiting 480 ms behind a dependency call held under a mutex.

Measured sixteen goroutines, the total work per operation held constant, and only the fraction of it under the lock varying. Seven runs, medians.
Under the lock
100%
50%
25%
10%

Moving ninety per cent of the work out bought 1.51×. That is a real improvement and it is nothing like ten times, which is the number the intuition suggests.

4. Make the critical section smaller. The convoy’s severity scales with how long the lock is held, so shrinking the held time shortens the queue. §10.5.2 is the design rule. Here is what it buys when the lock is fully saturated — nothing at all outside the critical section — with the section itself shrinking rather than the work moving.

Measured sixteen goroutines, a saturated lock, seven runs, medians.
Critical section
200 units
100 units
50 units
20 units

Ten times smaller buys 2.70×, not 10×. The remainder is the handoff — waking the next waiter and transferring the lock costs something no shrinking removes, and §20.2.3 measured where it lives: Unlock at 27.93% cumulative against Lock's 21.74%. A quarter of the cost is outside the section, so a quarter of the cost cannot be shortened out of it.

The two tables together give the rule: shrinking the critical section helps in proportion to how saturated the lock is, and even at full saturation the return is sublinear. Check the saturation before doing the work — a -cpu sweep that shows a rising curve is the evidence that it is worth anything at all.

5. Shard it. Last, and §20.4.1 and §20.4.4 are the two halves of why: it is 25% worse at one processor, and it moves cost onto the read path at about 10 ns per shard. When the four questions above have all been answered “no”, it works — 7.9× at sixteen processors in §20.4.1 — and it is worth doing. It is simply never the place to start, and it is where almost everybody does.

20.4.7 Why -cpu Belongs in Your Benchmark Suite

The convoy is invisible at any single processor count, and so is its inverse. The practical recommendation is narrow and cheap: for any benchmark that touches shared state, run -cpu 1,2,4,8,16 and look at the direction. It costs one flag and five times the benchmark duration.

The flag has one precondition that is easy to miss, and missing it produces a sweep that looks reassuring and means nothing. -cpu sets GOMAXPROCS; it does not make your benchmark concurrent. A benchmark body that calls the locked code in a plain loop runs on one goroutine no matter what the flag says.

Measured the same mutex-guarded increment, called sequentially in b.Loop rather than under RunParallel, median of five.
Processors
1
2
4
8
16

The left column moves by 12% between its endpoints and never exceeds 17% of its own floor — drift, not a slope. The right column is the incident. Same lock, same machine, same afternoon — and the left column is what you get if you write the benchmark the way most benchmarks are written. A flat -cpu sweep is evidence of contention-free code only if the benchmark could have shown contention in the first place, which is §20.1's argument arriving in a new place: check the harness before you trust the number.

Three habits make the sweep useful rather than decorative.

Include -cpu 1. It is the cheapest detector of a change that only pays under contention, and it catches the design that is a pessimisation for every deployment smaller than your benchmark machine.

Sweep past your production core count. The question a sweep answers is not “how fast is this now” but “what happens when the machine gets bigger” — and that has to be asked before the machine gets bigger, because afterwards it arrives as an unexplained latency regression correlated with nothing in the deploy log.

Record the sweep, not the endpoint, so that a future change which bends the curve shows up as a change in shape rather than a 4% regression in one number nobody investigates. benchstat handles a sweep natively — each -cpu value is its own row — so §20.1's statistical discipline applies unchanged.

20.4.8 The Contention You Did Not Write

Every fix in §20.4.6 assumes you own the critical section. Often you do not, and the options change completely.

§17.3.6's table is the canonical example. A shared rate.Limiter costs 180.3 ns/op under sixteen-way parallel Allow(), against 10.9 ns/op for one limiter per goroutine — and every one of those nanoseconds is inside golang.org/x/time/rate, behind a mutex you cannot shrink, holding a critical section you cannot shorten. Three of §20.4.6's five rungs are unavailable.

Two of the five remain.

Shard the thing you do not own, by holding several of them. A keyed map of limiters is exactly this: 137.1 ns at eight keys against 180.3 shared. You have not changed the library; you have reduced how many goroutines meet at each instance of it.

Or stop sharing it, which is the same table’s bottom row at 10.9 ns and a 16.5× improvement — rung 1 of §20.4.6, and the better of the two that survive. Per-goroutine or per-worker instances, reconciled at the end if they need to be. This is available far more often than people check, because the instinct that a limiter must be shared to limit anything is correct for a rate and not for many other kinds of state.

The general shape: when the contention is inside a dependency, your only levers are how many instances exist and how many goroutines reach each one. Both are decisions you make outside the library, and both are visible in a -cpu sweep long before they are visible in production.

20.4.9 Common Mistakes

Benchmarking shared state at one core count
Problem

The convoy is invisible; it is a slope

Fix

-cpu 1,2,4,8,16 and read the direction

Assuming more cores means more throughput
Problem

Measured, 5.6× slower from 1 to 16 under one mutex

Fix

Contention scales with contenders, not work

Omitting -cpu 1
Problem

A design that is pure overhead in a one-CPU container ships

Fix

Measured, sharding is 25% worse at one processor

Quoting a sharded curve’s optimum
Problem

Measured, three runs put it in three places

Fix

Only the direction reproduces

Reading a flat -cpu sweep as “no contention”
Problem

The benchmark body never ran in parallel

Fix

RunParallel, or the flag changes nothing

Reaching for shards first
Problem

Complexity and a tuning parameter

Fix

Measured, an atomic read is 503× a mutex read

Benchmarking only the write side of a shard
Problem

Measured, a 512-way read costs 5,082 ns against 10.72 for one mutex

Fix

Sharding moves cost to the read path; measure both

Adding shards past the floor
Problem

Eight times the shards for the last 18%

Fix

§17.3.6 — find what every caller still touches

Judging a lock by its mean
Problem

Measured, p50 moved 1.6× while p99 moved 1,954×

Fix

Record the distribution; §19.4's metric accumulates it

Forgetting to pad shards
Problem

Measured, 13.77 ns unpadded against 8.57 padded

Fix

Pad to a full line, or the benchmark is about §20.5

Padding to the wrong size
Problem

[40]byte on a mutex plus a counter is 56 bytes

Fix

Check with unsafe.Sizeof; this platform needs [48]byte

Summary: Contention as a Curve

A benchmark of shared state at one core count is one sample from a function nobody has plotted, and the function is not monotonic in the direction people assume. Measured, one mutex under b.RunParallel degrades 5.6× from one processor to sixteen while the same workload sharded 64 ways improves — and at one and two processors the sharded version is the worse design, by 25%. Which means a change that is honestly benchmarked as a win on a laptop can be a measured regression in a one-CPU container, and nobody re-checks it because the benchmark was real.

What reproduces from a sweep is the direction. The position of the optimum does not: three independent sweeps of the sharded column put its best point in three different places, and an earlier draft of this chapter published one of them as a finding.

Before sharding there are four cheaper questions, and all four also help the single-processor case. Does this need to be shared at all — measured at 16.5× in §17.3.6's limiter table. Is the primitive right — measured at 503× for a contended read that could be an atomic load. Can the work move out of the critical section — measured at 1.51× for moving ninety per cent of it. Can the section be shorter — measured at 2.70× for making it ten times smaller, which is sublinear because a quarter of the cost is in the handoff and not in the section.

And every mean in this section understates the problem. Measured, the same lock’s p50 moves 1.6× from one worker to sixteen while its p99 moves 1,954×. A dashboard of medians cannot see contention at all.

Key Takeaways

  • Contention is a curve; a single core count is one sample, and go test -bench defaults to whatever your laptop has
  • Measured, one mutex is 5.6× slower at 16 processors than at 1 — more cores is a throughput loss for a workload of that shape
  • Measured, sharding is 25% worse at -cpu 1; it is the only fix here that makes something worse
  • Only the direction of a sweep reproduces — three runs put the sharded optimum at three different core counts
  • Measured, an atomic load is 503× a contended mutex read; the primitive is a bigger lever than the shard count
  • Sharding moves cost to the read path at about 10 ns per shard: 5,082 ns to read a 512-way counter
  • Measured, shrinking a saturated critical section 10× buys 2.70×, because a quarter of the cost is the handoff
  • Measured, p50 moves 1.6× while p99 moves 1,954×; record the distribution or you will not see contention at all
  • -cpu 1,2,4,8,16 costs one flag, and a flat sweep only means something if the benchmark used RunParallel
Section 20.4 — in one line

Contention has a shape rather than a value, the shape depends on a variable most benchmarks never vary, and four of the five things worth doing about it are cheaper than the one everybody does first.

Self-Check Questions: Contention as a Curve

Your sharded cache benchmarks 3× faster than the mutex version it replaces. It ships, and p99 latency in the one-CPU staging container gets worse. What happened, and what would have caught it?

The benchmark was taken at one core count, and it was the wrong one.

Measured in §20.4.1: 64 padded shards against a single mutex, identical work, only GOMAXPROCS varying. At -cpu 16 the shards win by 7.9×. At -cpu 1 they lose by 25%, and at -cpu 2 by 16%. The sharded version does strictly more work per operation — an index computation, a bounds check, and a cache line that is cold because the previous operation touched a different shard — and none of that is paid back until there are enough processors for the contention it removes to have existed.

So the staging container is not behaving oddly. It is the left-hand end of a curve the benchmark never plotted.

-cpu 1,2,4,8,16 would have caught it, in five times the benchmark duration and one flag. That is why §20.4.7 argues for including -cpu 1 specifically: it is the cheapest possible detector of a change that is pure overhead below your benchmark machine’s core count, and container quotas mean “below your benchmark machine” is where a great deal of production runs.

The deeper reading is that “3× faster” was never a property of the change. It was a property of the change at sixteen processors, and the benchmark did not say so because benchmarks do not print the conditions you did not vary.

A colleague proposes sharding a hot map[string]*Session behind a mutex. What do you ask before agreeing?

Four questions, in the order §20.4.6 puts them, and each one is cheaper than sharding.

Does it need to be shared? Sessions keyed by connection are often reachable from the goroutine that owns the connection. §17.3.6's bottom row measured what removing the sharing buys where it is possible: 10.9 ns/op against 180.3, a factor of 16.5 that no sharded design reaches.

Is the primitive right? If the map is read far more than written and the values are pointers, an atomic.Pointer to an immutable map — copy-on-write — turns every read into a load. Measured, that is 503× on the read path. It is only correct when no invariant spans two entries, which §11.3 is the place to check.

Can the work move out of the critical section? If the code holds the lock while building a *Session, building it outside and taking the lock only to insert is §9.4's copy-release-call and costs nothing to try. Measured at 1.51× when ninety per cent of the work moves.

Can the section be shorter? Measured at 2.70× for a tenfold shrink under saturation, and much less when the lock is not saturated — so ask for the -cpu sweep that shows it is.

Then, and only then, sharding. And two conditions on it: pad each shard to a full cache line or the benchmark is measuring §20.5, and measure the read path, because §20.4.4 shows the cost does not disappear — it moves.

Your lock’s mean wait time went from 40 ns to 45 ns after a change. The change is safe to ship. Do you have a result?

You have almost no information, and the mean is the reason.

Measured in §20.4.5, on one mutex with a thirty-nanosecond critical section: from one worker to sixteen, p50 goes from 29 ns to 46 ns and p99 goes from 31 ns to 60.6 µs. The median barely moves because most acquisitions of a contended lock genuinely are uncontended — the goroutine arrives, the lock is free, it takes it. The distribution’s whole story is in a tail the mean averages away.

So a mean that moved from 40 to 45 is consistent with nothing having changed, with the tail having improved substantially, and with the tail having got several orders of magnitude worse. All three produce means in that neighbourhood.

The instrument is a percentile. In a benchmark, b.ReportMetric with a collected p99 (§20.1.8). In production, §19.4's mutex wait metric, which accumulates total wait rather than sampling it, or a latency histogram. What you must not do is report the mean and let a reviewer assume it describes the distribution, because on a contended lock it describes neither end of it.

You add -cpu 1,2,4,8,16 to a benchmark of your locked cache and the numbers are flat across all five. Is the cache contention-free?

Only if the benchmark could have shown contention, and the most common way to write it cannot.

-cpu sets GOMAXPROCS; it does not make your benchmark concurrent. A body that calls the locked code in a plain for b.Loop() loop runs on one goroutine at every -cpu value, so the flag changes the runtime’s processor count and nothing about the workload. Measured in §20.4.7: the same mutex-guarded increment called sequentially reads 10.78, 10.97, 11.01, 12.66 and 12.08 ns across the sweep — a 12% drift between the endpoints — while the identical operation under b.RunParallel goes 10.79 to 60.38.

A flat sweep is therefore two different findings depending on the harness, and the harness is not visible in the output. Check that the benchmark uses b.RunParallel — or spawns its own goroutines — before reading a flat line as good news.

There is one more case worth eliminating. A RunParallel benchmark whose goroutines each touch their own key never contends either, however many processors it gets. If the production workload has a hot key and the benchmark spreads uniformly, the benchmark is honest, concurrent, and measuring a workload nobody has.

20.5 The Machine Underneath

Two of this chapter’s effects come from hardware Go hides almost completely — the cache line, and the memory bus. Neither appears in any profile Chapter 19 taught, both are measurable in ten minutes, and one of them is the best demonstration in the book of why §20.1 comes first.

20.5.1 What Chapter 11 Already Measured

§11.3.7 covers false sharing properly. It has the cache-line diagram, the padding fix, a Measured badge, and the sentence that matters most:

From §11.3.7, which this chapter measures against rather than repeats.

2.3× slower for a struct that is correct either way, and no profile will name the cause: both versions show time in Add, and the second one just does less of it.

That is the mechanism and the blind spot, and this chapter re-derives neither. What §11.3.7 does not have — because no single measurement can have it — is the thing that makes false sharing a §20.1 problem rather than a §11 problem.

20.5.2 The Same Effect, Three Shapes, Nine Times Apart

Measured the identical physical effect, three benchmark shapes, one machine, one afternoon, seven runs each, medians.
Benchmark shape
1 goroutine, 2 counters
16 goroutines, 2 counters
4 goroutines, 4 counters, 200k each

Same cores, same cache, same 56 bytes of padding, same hour. The reported benefit of the identical optimisation ranges from 8% slower to 8.18× faster depending only on how the benchmark was shaped — and §11.3.7 publishes 2.3× for a fourth shape, which sits inside that range.

All three are correct measurements. They measure different things.

The first row is the cold open’s left column and the shortest of the three stories: with one goroutine there is no second core to invalidate anything, so there is no coherence traffic to remove, and all the padding does is make the struct eight times larger and slightly worse to touch. A benchmark of a fix for contention, run without contention, does not report zero. It reports a regression — which is worse than zero, because it is a number, and a reviewer will argue about it.

The second row’s narrow shape has sixteen goroutines fighting over two counters, so each goroutine spends most of its time in ordinary contention on a line it would be contending for anyway. Padding separates the two counters, but the sixteen writers still collide — eight per counter, on a line that is now exclusively theirs and still shared eight ways. The wide shape gives each of four goroutines its own counter, so once padded there is no sharing at all: the cores stop talking to each other entirely, and the increment becomes a register operation with a store.

THREE SHAPES, ONE CACHE LINE

The same padding fix in three benchmark shapes. Alone, one goroutine and two counters: padding splits one cache line into two, leaving one writer per line before and one after, and the payoff is 0.93 times, a regression. Narrow, sixteen goroutines and two counters: padding splits one line into two, leaving eight writers per line before and eight after, and pays 1.37 times. Wide, four goroutines and four counters: padding splits one line into four, taking four writers per line down to one, and pays 8.18 times. The question is writers per line after the fix, not before it.

This is the chapter’s thesis with the hardware underneath it.

Three engineers can benchmark the same optimisation honestly on the same machine and publish a regression, 1.37× and 8.18×, and all three be right. Neither number transfers to your code, because the number is a property of the shape. What transfers is the method: build the shape your production code actually has, and measure that. If you cannot tell which shape your code is, that is the finding — and it is answerable by counting writers per cache line before and after.

20.5.3 Which Shape Is Yours

The question is not “am I falsely sharing” but “how many independent writers are there per cache line, and would separating them leave them independent”.

Three cases, in descending order of how much padding buys.

Independent per-writer state, packed together. A slice of per-worker counters, a struct of per-shard statistics, an array indexed by processor.

padded_205_x_1.go
// Illustrative snippet — not a complete program
// ✗ eight workers, four to a cache line
stats := make([]struct{ hits, misses uint64 }, workers)

// ✓ one worker per line
type padded struct {
    hits, misses uint64
    _            [48]byte
}
stats := make([]padded, workers)

Each writer touches only its own element, so padding makes them genuinely independent and the gain is the wide shape’s — large. This is the case worth looking for, and §20.4.1's shard counter is an instance of it: measured at 1.61× there, on a struct whose padding was eight bytes short of a line.

Shared state that happens to be adjacent. Two counters both written by everybody. Padding separates the lines but the contention is real rather than false, so the gain is the narrow shape’s — modest, and you are now paying memory for a small win.

Read-mostly data. Cache lines shared by readers cost nothing; the coherence traffic is caused by writes. Padding a read-mostly struct is pure waste, and §20.7.2 has the arithmetic.

The diagnostic is a question about your data, not a profiler run — which is fortunate, because §11.3.7 already established that no profile will answer it.

20.5.4 The Other Shared Resource

Cache lines are the coherence problem. There is a capacity problem underneath them, it has nothing to do with sharing, and it puts a hard ceiling on parallel code that has no locks in it at all.

Measured one 256 MB array, summed by 1 to 16 goroutines. Two versions of the same loop: one that reads each element and adds it, and one that reads each element and does forty multiply-adds on it before adding. Best of three at each point.
Threads
1
2
4
8
16
Threads
1
2
4
8
16

The second table is the shape everyone expects: near-linear to four threads, 7.4× at eight, 8.09× at sixteen, tailing off exactly where sixteen hardware threads stop being eight physical cores.

The first stops at 4.76× and then goes backwards. There is no lock in it. There is no shared writable state in it — every goroutine reads a disjoint slice and writes only its own accumulator. It stops scaling because the memory bus is a shared resource of fixed width, and past four threads the cores are queueing for it rather than for anything Go can see.

THE SAME LOOP, TWO ARITHMETIC INTENSITIES

Scaling of one loop over a 256-megabyte array, drawn as two sets of bars against thread count. With forty arithmetic operations per element it scales 1.00, 2.00, 3.99, 7.40 and 8.09 times. With a read-only sum it scales 1.00, 1.99, 3.80, 4.76 and then falls back to 4.35 at sixteen threads. There is no lock in either version; the memory bus is the lock.

This is the third of §20.3.5's three reasons sixteen threads did not give sixteen times, and it is the one people never look for, because the instinct that “no locks means it scales” is so strong. The diagnostic is arithmetic rather than instrumentation: count the bytes each goroutine touches and the operations it performs on them. A loop doing less than roughly one arithmetic operation per byte read is memory-bound, and adding cores to it buys progressively less and eventually nothing.

The fixes are not concurrency fixes at all. Touch less memory — a smaller element type, a filtered pass, a better layout. Do more per byte touched — fuse two passes into one so the data is read once. Or accept the ceiling, which is a real answer, and stop paying for cores that are queueing.

20.5.5 The Sharing You Did Not Write

§20.5.3 asks which shape your data has, which presumes you know where the adjacency came from. In practice false sharing arrives through three shapes nobody wrote on purpose.

A slice of small structs, indexed by worker. make([]stats, workers) puts every worker’s counters in one contiguous block, so eight workers with a 16-byte struct fit four to a cache line. This is the shape padding was invented for and the one where §20.5.2's wide measurement applies — the writers are genuinely independent, and separating them makes them independent in hardware too.

Adjacent fields in a long-lived struct. A server object with a requests counter next to an errors counter, incremented by different goroutines on different paths. They share a line because they were declared next to each other, which is a decision nobody made.

A slice that grew. append reallocates, and the new backing array’s alignment is not the old one’s. Code that was accidentally well-separated can become accidentally packed after a resize, which makes this the one shape that appears without any code change at all.

The first is worth padding. The second is worth reordering the struct — putting a large field between two hot counters costs nothing and is not padding. The third is worth knowing about mainly so that an unexplained regression after a capacity change has a candidate.

Reordering is the fix people forget.

Padding adds memory to create separation. Reordering achieves the same separation by putting fields you already have between the contended ones, and costs nothing at all. If a struct has a hot counter, a hot flag and a 48-byte buffer, the buffer between the two hot fields is free padding — and unlike [48]byte, nobody will delete it in a tidy-up because it looks like it belongs.

20.5.6 What Padding Costs

§11.3.7 gives the fix and this chapter prices it, because “add padding” is advice that gets applied by reflex.

Derived a [56]byte pad on a uint64 counter turns 8 bytes into 64. At 256 shards that is 2 KB against 16 KB, which is irrelevant. At a million instances it is 8 MB against 64 MB — and a last-level cache is measured in tens of megabytes, so the padded version no longer fits in it and the unpadded one did.

That is the trade stated exactly: you have exchanged a cache-coherence problem for a cache-capacity one. §20.7.2 measures what that costs when the exchange is a bad one.

The rule is narrow: pad things there are few of and that are written concurrently. Shard counters, per-processor state, the hot fields of a long-lived server struct. Not elements of a large collection, and not anything read-mostly.

And check the size rather than assuming it. Measured with unsafe.Sizeof on go1.26.1/amd64: sync.Mutex is 8 bytes and sync.RWMutex is 24, so a shard of {sync.Mutex; uint64} needs [48]byte to reach 64 and a shard of {sync.RWMutex; uint64} needs [32]byte. A pad copied from one struct to another is a pad that is wrong, and §20.4.1 measured the penalty for being eight bytes short — 56 where 64 was needed — at 1.61×.

20.5.7 GOMAXPROCS, and the One Decision Left

§1.5 owns this topic and ends at the right conclusion: “on a current toolchain, do nothing.” Since Go 1.25 the runtime reads the CPU affinity mask and, on Linux, the cgroup quota, and it re-reads them periodically; setting GOMAXPROCS yourself disables that updating, and runtime.SetDefaultGOMAXPROCS restores it. Both behaviours are gated on the go directive in your go.mod, which is the same mechanism §18.2 measured for loopclosure. Read §1.5; none of it is repeated here.

What belongs in a chapter about decisions is the narrow set of cases where changing it still helps.

Lowering it to reduce contention. §20.4.1's left column is the argument: if your workload’s curve slopes the wrong way, fewer processors is a real if unsatisfying mitigation while you fix the lock. Measure it with a -cpu sweep first — you already have the number.

Lowering it because you are not the only tenant. A batch job sharing a machine with a latency-sensitive service is a scheduling decision, not a Go one, and GOMAXPROCS is one of several levers.

Raising it above the core count. Almost never. It does not create parallelism and it does add scheduler overhead; the exception is workloads dominated by blocking syscalls the runtime cannot see around, and those are better fixed at the syscall.

Everything else — and in particular reaching for automaxprocs on a current toolchain — is covered by §1.5's conclusion and by the fact that setting the value at all turns off the runtime’s own tracking.

20.5.8 Common Mistakes

Quoting a false-sharing ratio
Problem

Measured, 0.93× to 8.18× for one effect on one machine

Fix

The ratio is a property of the shape; measure yours

Looking for false sharing in a profile
Problem

Both versions show time in Add

Fix

§11.3.7 — no profile names it; count writers per line

Padding by reflex
Problem

Memory up, cache capacity down, no gain

Fix

Pad few things that are written concurrently

Copying a pad size between structs
Problem

Measured, [40]byte on a mutex plus a counter is 56 bytes

Fix

unsafe.Sizeof, then pad to 64

Padding elements of a large collection
Problem

A coherence problem traded for a capacity one

Fix

8 MB becomes 64 MB and everything misses

Padding read-mostly data
Problem

Pure waste; reads cause no coherence traffic

Fix

Writes are what invalidate

Assuming lock-free code scales
Problem

Measured, a read-only sum peaks at 4.76× and falls

Fix

Count operations per byte; the bus is a shared resource

Setting GOMAXPROCS in a container
Problem

The runtime’s periodic quota tracking is now off

Fix

§1.5 — do nothing, or SetDefaultGOMAXPROCS

Raising GOMAXPROCS above the core count
Problem

Scheduler overhead, no new parallelism

Fix

It does not create cores

Summary: The Machine Underneath

§11.3.7 already measures false sharing at 2.3× and already says no profile will name it. What one measurement cannot show is that the same effect, on the same machine, in the same hour, measures 1.37× and 8.18× depending only on the benchmark’s shape — sixteen goroutines over two counters against four goroutines over four. Both are honest, they measure different things, and neither transfers. The question that decides which you have is writers per cache line after the fix, and it is answered by reading your struct rather than by running an instrument.

Underneath coherence there is capacity. Measured, one loop over 256 MB scales 8.09× to sixteen threads when each element gets forty operations and 4.35× — peaking at eight and then falling — when it gets one. There is no lock in either version. Memory bandwidth is the ceiling most lock-free parallel code actually meets, and the diagnostic is arithmetic per byte, not a profile.

Padding costs 8 bytes turning into 64, which is free on a few hot structures and converts a coherence problem into a capacity one on a large collection. And GOMAXPROCS is §1.5's, which ends at “do nothing” — the only decisions left here are lowering it to mitigate a curve you have already measured, or lowering it because you are sharing the machine.

Key Takeaways

  • Measured, one false-sharing effect reports 0.93×, 1.37× and 8.18× on one machine, purely by benchmark shape
  • The narrow shape keeps the writers contended after padding; the wide one makes them independent, which is where the large numbers are
  • Count writers per cache line before and after — §11.3.7 established no profile will tell you
  • Measured, sync.Mutex is 8 bytes here, so {Mutex; uint64} needs [48]byte and not [40]; being short cost 1.61×
  • Padding pays where there are few objects and concurrent writers; on a large collection it trades coherence for capacity
  • Measured, a lock-free read-only sum peaks at 4.76× on sixteen threads and then goes backwards — the memory bus is a shared resource
  • Below about one arithmetic operation per byte read, a loop is memory-bound and cores stop helping
  • GOMAXPROCS is §1.5's subject; setting it disables the runtime’s quota tracking, and the remaining decisions are both about lowering it
Section 20.5 — in one line

The cache line and the memory bus are the two pieces of hardware Go cannot hide, neither is visible in any profile, and the size of their effect on your code is not the size of their effect on anyone else’s.

Self-Check Questions: The Machine Underneath

You read a blog post measuring a 40× speedup from cache-line padding and apply it to your service. Nothing changes. What most likely happened?

The post measured a shape you do not have.

Measured in this chapter: the identical physical effect, on one machine, in one hour, reports 1.37× with sixteen goroutines sharing two counters and 8.18× with four goroutines each owning one. Neither is a mistake. The first still has eight writers per line after the padding; the second has one, because padding made the four counters fully independent. A post reporting 40× has found a shape further along that axis still — many independent writers, tiny critical work, and a particular cache hierarchy.

Your service almost certainly does not have that shape. If your concurrent writes are to genuinely shared state — one counter everybody increments — then the sharing is real rather than false, padding separates nothing, and zero improvement is the correct outcome.

The diagnostic is not a profile, because §11.3.7 established both versions show time in Add. It is counting: how many goroutines write to each cache line now, and how many would after the fix? If the second number is not smaller, padding was never going to help and you have added memory for nothing.

Why is padding a struct that appears a million times usually a mistake?

Because it converts a problem you may not have into one you certainly will.

A [56]byte pad turns an 8-byte counter into 64 bytes. At a million instances that is 8 MB becoming 64 MB. Cache-line padding buys coherence — cores stop invalidating each other’s lines — but it costs capacity, and an eightfold increase in a hot collection means eight times fewer elements fit in L2. Every access that used to hit now stands a good chance of missing, and a miss is far more expensive than the coherence traffic you removed.

There is a second reason specific to large collections: with a million instances, the odds that two concurrently written elements land on the same line at the same instant are low. False sharing is a problem of a few hot objects written constantly by different cores, which is the opposite profile.

So §20.5.6's rule is a shape rule rather than a size rule: pad the shard counters, the per-processor state, the handful of hot fields on a long-lived server struct. Do not pad the elements of anything you have a lot of.

You parallelise an image filter across sixteen goroutines. It is lock-free, each goroutine owns a disjoint band of pixels, and it runs 4.1× faster. Is something wrong?

Probably not — that may be the ceiling, and the way to find out is arithmetic rather than a profiler.

Measured in §20.5.4: the same 256 MB array, the same disjoint-slice decomposition, no locks anywhere. With forty arithmetic operations per element it scales 8.09× on sixteen threads. With one, it peaks at 4.76× at eight threads and then falls to 4.35× at sixteen. Nothing about the concurrency changed between those two columns; only the amount of work done per byte read.

An image filter reading three or four bytes per pixel and doing a handful of operations on them sits near the memory-bound end. 4.1× is what that looks like, and the missing scaling is not in your code — it is in a bus of fixed width that all sixteen threads share.

Two things follow. Adding goroutines will not help and may hurt, so a proposal to raise the worker count should be rejected with this measurement rather than tried. And the productive optimisations are the ones that touch less memory or do more per byte: fusing two filter passes into one so the image is read once, working in a smaller pixel format, or tiling so a band stays in L2 across passes. Those raise the ceiling. More cores do not.

Before concluding, check the shape is really disjoint. Bands that meet on a cache-line boundary have two goroutines writing one line at the seam, which is §20.5.2's effect, and it is worth ruling out because the fix is one line of alignment.

Padding a hot struct moved the benchmark’s median from 2.101 ns to 1.434 ns, and its ± from 1% to 5%. Is that a win?

On the benchmark, yes. On a service, it is a question the benchmark cannot answer, and the ± is the part that says so.

Those are §20.1.6's real numbers for this chapter’s own false-sharing fix: -31.75% (p=0.000 n=10), which is as clean a result as benchstat produces. The median improved by a third and the difference is not noise.

But the spread went the other way, and it did so in both of §20.1.6's comparisons — the improved arm was the wider one each time. A distribution whose middle moved down and whose width quintupled has a tail that may not have improved at all, and a p99 is built out of the tail. The benchmark reports a median because a median is what a benchmark can report; nobody experiences a median.

So the finding is real and its consequences are not yet known. Ship it, and then look at the tail with an instrument that has one — §19.4's runtime metrics for the distribution, or a load test that reports percentiles. That is §17.5.1's argument arriving from the measurement side: capacity is a latency budget, and a change that spends variance to buy median is spending exactly that budget.

There is a second reading worth having. A widening spread is sometimes a signal that the benchmark has become sensitive to something it was not sensitive to before — allocation alignment, which core the goroutine landed on, whether the padded structure still fits in a cache level. On this machine the quiet band is 1 to 9%, so 5% is inside normal rather than beyond it, and the honest conclusion is “watch it” rather than “revert it”.

What would be indefensible is reporting the -31.75% without the ±. That is the half of the result that tells the next person whether to trust the first half.

20.6 Doing Less Work

Every optimisation so far has made the same work faster or reduced the interference between the pieces of it. This section is about the class that beats all of them and has no Amdahl ceiling of its own: doing less work in the first place.

It comes last among the techniques because it is the one people reach for after concurrency, and it should be the one they reach for before. Removing an allocation helps at every core count. Batching helps at every core count. Neither has a left-hand end of the curve where it makes things worse — which, after §20.4, is worth something on its own.

20.6.1 Batching, Generalised

§20.4.6's ladder shrinks the critical section. Batching does something better: it reduces how often the critical section is entered at all. The lock stays exactly as it was, and n operations become one.

Measured sixteen goroutines incrementing a mutex-guarded total, accumulating locally and flushing every k operations. Seven runs, medians.
Batch size
1
8
64
512
4,096

That is the largest ratio in this chapter, and the reason is arithmetic rather than cleverness: at batch size k the lock is taken 1/k as often, so the per-operation cost of the lock falls as 1/k until something else becomes the floor. Nothing was made faster. The number of times the expensive thing happens was divided.

The same shape applies to anything with a fixed per-call cost: a syscall, a network round trip, a database statement, a log write, a sync.Pool round trip. §5.4's buffered channel is batching in disguise, and §17.2's token bucket is a batch of permits.

WHY BATCHING WINS SO LARGELY

Unbatched, the sequence lock, work, unlock repeats 4,096 times, for 4,096 lock acquisitions. Batched, the work happens 4,096 times and then one lock, merge and unlock, for a single lock acquisition. The work did not shrink; the number of lock acquisitions did.

20.6.2 What Batching Costs

There is a bill, it is always the same one, and it is not visible in the table above.

Latency. At batch size 4,096 the first item in a batch waits for 4,095 more to arrive before anything happens to it. If items arrive at 10,000 a second, that is 410 milliseconds of waiting for an operation that takes a fraction of a microsecond. Derived, and it is the only arithmetic that matters here: worst-case added latency is (batch size - 1) / arrival rate, and that number belongs in the design discussion beside the throughput one.

Loss on failure. A batch that is in flight when the process dies is n lost operations rather than one. For a metrics counter that is acceptable; for an audit log it is not, and the batch size is bounded by what you can afford to lose rather than by the throughput curve.

Memory. n items held is n items resident, and a batch that grows without a bound is an unbounded buffer, which §17.4 spends a section on.

The standard fix for the first is a timeout: flush when the batch is full or when it has been open for longer than a deadline, whichever comes first. That converts an unbounded latency risk into a bounded one and it is why almost every real batching layer has a FlushInterval next to its BatchSize.

Choose the batch size from a latency budget, not from a benchmark.

The throughput table above never stops improving, so it cannot tell you when to stop — it will happily recommend 4,096. Start from the deadline instead. If the budget allows 5 ms of batching delay and items arrive at 10,000 a second, the largest batch that fits is 50, which brackets between the table’s 8-row and 64-row — call it around 12×. Take that, and note that the 393× on the bottom row was never available to you.

20.6.3 The Allocation You Do Not Make

The cheapest allocation is the one the compiler turns into a stack slot. §20.1.3 introduced -gcflags=-m as a benchmark hygiene check; here it is the optimisation itself.

Measured the same four-kilobyte buffer, allocated in a loop, differing only in whether it escapes. Five runs, medians.
Buffer
assigned to a package-level variable
used locally, never escaping

8.2×, and the second row is not a pool, a cache or an arena. It is the same make([]byte, 4096) with nothing keeping it alive past the iteration — the compiler put it in the stack frame, where it costs a pointer bump and zeroing four kilobytes and costs the collector nothing. The allocation did not get faster; it stopped being an allocation.

Two details in that table are worth pausing on, because both are §20.1 arriving inside a §20.6 measurement. allocs/op is deterministic where ns/op is not, so the 1-against-0 in the last column is the durable half of this result and will reproduce on your hardware when the 8.2× does not. And the second row only reports 56.99 ns because it is a b.Loop benchmark: measured, the identical body under for i := 0; i < b.N; i++ reports 0.2186 ns and zero bytes, because with nothing keeping buf alive the stack allocation is deleted too. b.Loop's assigned-variable protection is what makes the stack version measurable at all.

Four things make a value escape, and all four are visible in the -gcflags=-m output:

The third is the one this book keeps producing. go func() { use(buf) }() moves buf to the heap even if the goroutine finishes immediately, because the compiler cannot prove it will.

20.6.4 The Allocations You Make Anyway

Some allocations are structural. Many are a formatting call nobody thought about.

Measured building the same short string five ways, seven runs, medians. Both string arguments are variables rather than constants — with constants the compiler boxes them statically, and the allocation count is then not the one your code will see.
Construction
fmt.Sprintf("%s/%s", a, b)
a + "/" + b
strings.Builder with Grow
fmt.Sprintf("%d", n)
strconv.Itoa(n)

Read the allocs/op column before the timing one, because §20.6.3 just argued it is the deterministic half — and on go1.27, which specialises the allocator for objects under 80 bytes, the timing column is also the one a newer toolchain will have moved: three allocations against one. Sprintf takes ...any, so each of the two string arguments is boxed into an interface — two allocations before the function is even entered — and the result is the third. On top of that the format string is parsed at run time and a reflective path decides what to do with each verb. 3.7× for replacing one call with an operator, and the resulting string is byte-identical. None of that work is visible at the call site, which is why it survives review.

The integer row is the same shape with one argument instead of two: two allocations against one, and 3.7× the time. strconv.Itoa boxes nothing, parses nothing, and writes its digits into a stack buffer before the single allocation that becomes the string.

strings.Builder is the interesting row, because it is slower than the operator. Grow plus three writes is the right tool for a loop that appends an unknown number of times; for a two-part join the compiler’s own concatenation already does one exactly-sized allocation, and the Builder adds bookkeeping to reach the same place.

Those three allocations are not a benchmark artefact. The exercise at the end of this chapter has a Record built exactly this way, and its gate reports the same number: “Record allocates 3 time(s) per call.”

This is not an argument against fmt. It is an argument about where: fmt is right in error paths, log messages that are usually disabled, and anything that runs once. It is wrong on a hot path that runs a million times a second, and the replacement is not clever — it is the operator the language already has.

A map key built with Sprintf pays twice.

m[fmt.Sprintf("%s/%s", svc, evt)] allocates the key and hashes the joined string on every lookup. A struct key — m[key{svc, evt}] — removes the allocation and hashes two fields the runtime already has. This exact shape is the exercise at the end of this chapter, and it is the single most common allocation in instrumented Go code.

20.6.5 sync.Pool as a Crossover, Not a Preference

§12.3 covers what sync.Pool is: a per-P private slot, a shared queue, and a victim cache that survives one collection and not two. This section is about when it is worth having, and the answer is a crossover rather than a rule.

Measured a Get/use/Put round trip against make plus a use, three sizes, five runs, medians.
Object
64-byte slice
4 KB slice
1 MB slice

The pool’s cost is flat — 11 to 14 ns whatever it holds, because a pooled Get is a bounds check and a pointer move regardless of what the pointer points at. Allocation’s cost is not: it rises with size, because the allocator has to find the span and the runtime has to zero the memory.

So the decision is a comparison between a constant and a line, and it has three regions.

Large objects, high churn: pool. Buffers of kilobytes and up, allocated and discarded per request. This is what sync.Pool was built for and what net/http and encoding/json use it for.

Small objects: usually not. 1.4× on a 64-byte slice is real on go1.26.1 — and narrower on go1.27, whose allocator specialises exactly this size class — and it is rarely worth the two ways a pool goes wrong — an object returned to the pool while still referenced, and an object taken from the pool with stale contents.

Objects that never escape: never. This is the trap, and §20.7.1 measures it: if the object was on the stack, pooling it adds the pool’s 12 ns to something that cost almost nothing.

Run -gcflags=-m before you pool anything.

The pool’s cost is real and constant; the allocation’s cost may be zero. §20.1.3's three-second check is the same check, and it answers the whole question before you write the pool.

20.6.6 The Collector Is a Knob Before It Is a Code Change

Every allocation you do not remove is work for the garbage collector, and the collector has a dial that costs nothing to turn.

GOGC sets how much the heap may grow between collections, as a percentage of the live set. The default of 100 means the heap doubles before the collector runs; 200 means it triples.

Measured an allocation-heavy benchmark with a steady 20,000-object live set and continuous garbage, three runs at each setting, medians.
GOGC
50
100 (default)
200
400
800
off

1.38× for an environment variable, on a service where a week of removing allocations might buy the same. That is worth knowing before the week starts, and it is worth knowing that it is a trade rather than a free lunch: at GOGC=800 this program uses roughly nine times its live set in heap, and the trade is only available if you have the memory.

The last row is the interesting one. GOGC=off is slower than the default — 57.21 ns/op against 37.76, half as much time again per operation, from switching the collector off entirely. Nothing is being collected, so nothing is being reused: every allocation gets fresh pages from the operating system, the working set grows past every cache level, and the program spends more on memory misses than the collector was costing. Turning the collector off is not the limit of the GOGC curve; it is off the end of it.

GOMEMLIMIT is the companion and the safer half. It sets a soft ceiling on total memory, and the collector runs harder as you approach it. The combination people actually want in a container is GOGC raised and GOMEMLIMIT set to something under the container limit: collect lazily while there is room, and collect aggressively rather than being killed when there is not.

Do this before the allocation work, not after.

A GOGC sweep is four benchmark runs and no code review. If it moves your number by 30%, you have learned that the collector is a material cost — which is the evidence that the allocation-removal work is worth doing. If it moves nothing, the collector was not your problem and a week spent on allocations would have been a week spent on the wrong thing. Either way you have spent twenty minutes to decide.

20.6.7 Common Mistakes

Choosing a batch size from the throughput curve
Problem

Throughput improves; p99 becomes the batch window

Fix

(size - 1) / arrival rate is the added latency

Batching without a flush timeout
Problem

A quiet period leaves items in a half-full batch

Fix

Flush on full or on a deadline

Pooling an object that never escaped
Problem

Measured, the pool’s 12 ns added to something free

Fix

-gcflags=-m first; §20.7.1

Pooling small objects
Problem

Measured, 1.4× at 64 bytes, and two new bug classes

Fix

Pool where the line is above the constant: kilobytes up

fmt.Sprintf on a hot path
Problem

Measured, 3.7× and three allocations against one

Fix

Concatenation or strconv; check allocs/op

Sprintf to build a map key
Problem

An allocation and a hash of the joined string

Fix

A struct key hashes fields you already have

Removing allocations before checking GOGC
Problem

A week of work for what a variable did

Fix

Measured, 1.38× from GOGC=800

Setting GOGC=off for speed
Problem

Measured, 57.21 against 37.76 ns/op — half as much again

Fix

Nothing is reused; the working set leaves the cache

Raising GOGC without GOMEMLIMIT
Problem

The container’s OOM killer arrives instead

Fix

Raise GOGC, set a limit under the container’s

Summary: Doing Less Work

The techniques in this section have no Amdahl ceiling and no left-hand end of the curve where they hurt, which makes them the ones to try first even though they are usually tried last.

Batching is the largest ratio in this chapter — measured at 393× for a batch of 4,096 — because it divides the number of times an expensive thing happens rather than making it cheaper. It costs latency, exactly (size - 1) / arrival rate, and the batch size should come from a latency budget rather than from the throughput curve, which never stops improving.

An allocation that does not escape is not an allocation: measured, 56.99 ns and zero bytes against 469.7 ns and 4,096, for the same make. -gcflags=-m tells you which one you have before you write anything. Of the allocations that remain, a surprising share are formatting calls — measured, fmt.Sprintf is 3.7× a concatenation and allocates three times against one, because ...any boxes every argument.

sync.Pool is a crossover, not a preference: its cost is a flat 12 ns and allocation’s is a rising line, so it wins hugely on kilobyte buffers, marginally on small ones, and never on objects that were on the stack. And before any of this, GOGC is four benchmark runs — measured at 1.38×, with GOGC=off at 0.66× the default’s throughput because nothing gets reused.

Key Takeaways

  • Measured, batching a mutex-guarded increment 4,096 ways is 393×; nothing got faster, the lock was taken 4,096 times less often
  • Batching’s bill is latency: (size - 1) / arrival rate, plus loss on failure and memory held
  • Choose the batch size from the latency budget; the throughput curve will always recommend more
  • Measured, an escaping 4 KB buffer is 469.7 ns and a stack one is 56.99 ns — 8.2×, and the second is not a pool
  • Interfaces, closures and returned pointers are what force escapes; -gcflags=-m names them
  • Measured, fmt.Sprintf is 3.7× a concatenation at three allocations against one; fmt.Sprintf("%d", n) is 3.7× strconv.Itoa at two against one
  • sync.Pool costs a flat ~12 ns; pool where allocation’s rising line is above that, which is kilobytes and up
  • Measured, GOGC=800 is 1.38× the default and GOGC=off is 0.66× — the curve has an end and it is before “off”
Section 20.6 — in one line

Making something faster is bounded by how fast it can be; doing it fewer times is not, and the four cheapest optimisations in this chapter are all the second kind.

Self-Check Questions: Doing Less Work

Your throughput benchmark says batch size 4,096 is 393× faster than unbatched. Your service has a 20 ms p99 budget and receives 5,000 events a second. What batch size do you choose?

Not 4,096, and the benchmark cannot tell you that — it does not know about the budget.

Derived from §20.6.2's arithmetic: worst-case added latency is (size - 1) / arrival rate. At 5,000 events a second, a batch of 4,096 takes 819 ms to fill, which is forty times the entire p99 budget spent waiting for a batch that has not filled yet.

Working backwards from the budget instead: to add no more than a few milliseconds, size - 1 <= 0.005 x 5,000, so a batch of about 25. §20.6.1's table prices that between 6.7× and 28× — call it around 12×. That is the number available to you, and 393× was never on offer.

Then add the flush timeout, because the arithmetic above assumes events arrive at the stated rate. At three in the morning they do not, and a batch of 25 waiting for its twenty-fifth event can wait indefinitely. Flush on full or after a deadline — say 2 ms — whichever comes first. That bounds the latency whatever the arrival rate does, and it costs one timer.

The general form: batching’s throughput curve has no maximum, so the maximum has to come from somewhere else. It comes from the latency budget, from what you can afford to lose if the process dies mid-batch, and from what you can afford to hold in memory.

A colleague’s PR pools a 48-byte request context object with sync.Pool and reports a 15% benchmark improvement. What do you check?

Whether the object escaped in the first place, and then whether the benchmark contained the thing the pool is for.

The escape check comes first, because it can invalidate the change entirely. go build -gcflags=-m on the original: if the context did not escape, it was on the stack, it cost approximately nothing, and pooling it adds the pool’s flat ~12 ns per operation. §20.7.1 measures that case at 7.0× slower. A 15% improvement is not consistent with that outcome, which suggests the object does escape — but the check is three seconds and it settles the question.

Then the size. Measured in §20.6.5: at 64 bytes a pool is 1.4× faster than allocating, at 4 KB it is 43×. Forty-eight bytes sits at the bottom of that range, where the win is real but small and the two failure modes of pooling are not — an object put back while something still holds a reference, and an object taken out with stale fields. A 15% throughput improvement is a thin return for that.

Then the benchmark itself, by §20.1's rules. -count=10 and benchstat, because 15% needs a spread narrower than 15% to mean anything. And -benchmem, because the allocation count is deterministic where the timing is not: if allocs/op did not fall, the pool is not doing what the PR says.

The alternative worth proposing is not a better pool. It is GOGC: measured at 1.38× for an environment variable, on the same class of problem, with no new object-lifetime bugs.

-gcflags=-m says your 8 KB buffer escapes to heap, and the line that causes it is go func() { process(buf) }(). What are your options?

Three, in increasing order of how much they change.

Make the goroutine not outlive the buffer, and prove it to the compiler. The compiler moves buf to the heap because it cannot prove the goroutine finishes before the frame returns — and it cannot, because nothing in the language says so. If the work does not actually need to be concurrent, removing the go keeps the buffer on the stack and removes the allocation entirely. §20.3 is the section for deciding that, and for a buffer this small the answer is often yes.

Pool it. 8 KB is comfortably in the range where §20.6.5's crossover favours a pool: allocation’s cost rises with size and the pool’s does not. The bill is lifetime management — the buffer cannot go back to the pool until the goroutine has finished with it, which usually means the goroutine does the Put, which usually means a defer inside it.

Hoist it to the caller. If the caller allocates the buffer once and passes it in for many calls, the allocation happens once per caller rather than once per operation. This is the append(dst[:0], ...) shape the standard library uses everywhere, and it removes the allocation without introducing a pool’s lifetime questions — at the cost of an API that asks the caller for scratch space.

What is not an option is making the escape go away by rearranging the code inside the goroutine. The escape is caused by the go statement’s inability to be bounded, not by anything the goroutine does.

Your service allocates heavily. GOGC=400 makes the benchmark 30% faster. Do you ship it?

Probably, with a GOMEMLIMIT beside it — and you have learned something more useful than the 30%.

The number itself is a genuine finding and it is cheap: measured in §20.6.6 at 1.31× for GOGC=400 on an allocation-heavy workload, with the heap growing to about five times the live set instead of two. If you have that memory, take it.

The GOMEMLIMIT is not optional. GOGC is a ratio, so a live set that grows for any reason — a cache warming, a traffic spike, a leak — scales the heap by the same factor, and at GOGC=400 a doubling of the live set is a doubling of an already-five-times heap. In a container that ends at the OOM killer. Setting GOMEMLIMIT under the container’s limit converts that into the collector working harder, which is a slowdown rather than a termination.

The more useful thing you learned is that the collector is a material cost in this service. A 30% response to a GOGC change is evidence that allocation-removal work has something to find, which is exactly the evidence §20.6.3 and §20.6.4 need before anyone spends a week on them. Had the sweep moved nothing, the correct conclusion would have been that the collector is not the problem and that week belongs elsewhere.

One thing not to do: GOGC=off. Measured, it costs 57.21 ns/op against the default’s 37.76 — half as much time again per operation — because nothing is reused, every allocation takes fresh pages, and the working set leaves the cache. The curve has an end before “off”.

20.7 The Changes That Made It Worse, and When to Stop

Every section so far has had a measured half nobody writes down: the version of the optimisation that was slower. This section is that half, collected, because it is the more useful one. Each of these was a reasonable idea, applied by an experienced person, benchmarked honestly, and wrong — and in every case the thing that would have caught it was cheaper than the change.

20.7.1 A Pool That Was Slower Than Doing Nothing

Measured an eight-field, 64-byte struct used inside a function and never allocated on the heap, against the same struct taken from and returned to a sync.Pool. Five runs, medians.
Loop body
var s small; use(&s)
s := pool.Get(); use(s); Put(s)

The pool is 7.0× slower than not pooling, and the allocs/op column is zero on both sides, which is the part that makes it survive review. A reviewer looking for the pool’s benefit finds a benchmark with no allocations, concludes the pool is working, and does not notice that the version without the pool also has no allocations — because the object was never on the heap to begin with.

The pool did exactly what it promises. It replaced an allocation with a Get and a Put. There was no allocation.

The check is §20.1.3's, it costs one compile, and it is the same check §20.6.5 asks for: -gcflags=-m on the original, before the pool exists. does not escape means there is nothing to pool.

20.7.2 Padding That Added Cache Lines

Derived, from §20.5.6: padding a uint64 counter to a cache line turns 8 bytes into 64. On sixteen shards that is 128 bytes becoming 1 KB, which nobody will ever notice. On a million-element collection it is 8 MB becoming 64 MB, and a last-level cache measured in tens of megabytes now holds an eighth as many elements.

The change removed cache-line coherence traffic between cores and added cache capacity misses on every access. Both are real; on a large collection the second is much larger, and it does not show up in the benchmark that motivated the padding because that benchmark had sixteen elements.

Two conditions have to hold together for padding to pay: few objects, and concurrent writers. §20.5.3's three cases are the taxonomy, and the reflex application skips the counting step entirely.

20.7.3 Pooling That Outlived the Object

sync.Pool retains what you put in it, and it retains the largest thing you put in it.

Measured a pool of byte buffers under a workload where 99% of requests need 1 KB and 1% need 4 MB, 200,000 requests, then a forced collection.
Pool and collection
naive pool, after one GC
naive pool, after two GCs
capped pool, after one GC

The naive pool holds a 4 MB buffer that one request in a hundred needed, and holds it across a collection because §12.3's victim cache is doing exactly what it was designed to do: keep pooled objects alive through one GC cycle so a burst does not have to re-allocate everything. The buffer is released on the second collection — but in a running service the pool is refilled between collections, so the second collection never comes for that slot and the retention is permanent.

The fix is one condition on the Put: return the buffer only if it is a size you want to keep.

put_207.go
// Illustrative snippet — not a complete program
func put(b *[]byte) {
    if cap(*b) > maxPooled {
        return // let it go; the next Get makes a new one
    }
    *b = (*b)[:0]
    pool.Put(b)
}

Measured, that takes the resident heap from 4.98 MB to 0.99 MB — the same 5× on a pool holding one oversized buffer, and the arithmetic scales with how many oversized buffers your traffic produces.

This is the failure mode that does not show up in a throughput benchmark at all. ns/op is unchanged. The cost is memory, it appears in inuse_space rather than alloc_space, and §20.2.7's distinction between the two heap profile modes is what finds it.

20.7.4 A Cache That Cost More Than the Computation

A cache replaces a computation with a lookup, which is a win exactly when the lookup is cheaper than the computation. That comparison is rarely made.

Measured the same value computed on every call, against the same value read from a warm cache, under sixteen goroutines. Two computations — one cheap, one four hundred multiply-adds — and two cache implementations. Five runs, medians.
Computation
cheap (one multiply-add)
expensive (400 ops)

The top row is the failure. Caching a computation that costs four nanoseconds makes it 3.1× slower through a sync.Map and 19.6× slower through a mutex-guarded map, on a 100% hit rate — the best case a cache can possibly have. The mutex version is worse than the sync.Map version for §20.4's reason: sixteen goroutines are now queueing on a lock that the uncached version did not have.

The bottom row is the success, and it is the same code: a computation costing 44.73 ns becomes a 12.67 ns lookup, 3.53×.

The crossover is not subtle and it is arithmetic. A cache is worth having when the computation costs more than the lookup, which on this machine means more than about 13 ns for a sync.Map and more than about 83 ns for a mutex-guarded map under this much concurrency. Below that the cache is a pessimisation with a hit rate of one hundred per cent, and it gets worse from there — every real cache has misses, an eviction policy, and a memory footprint, none of which appear in the table above.

Measure the cache against the computation, at your concurrency.

The mutex-and-map row is 6.4× the sync.Map row for the same cheap computation, and neither is the fastest option — the fastest option is not caching. A cache adds a shared data structure to a code path that did not have one, which means §20.4's entire section now applies to a function that used to be embarrassingly parallel.

20.7.5 The Cost of a Bound That Never Fires

Chapter 17 established that every bound has a cost and left the arithmetic. Here it is.

Measured the same small computation under sixteen goroutines, wrapped in four bounds that are all set wide enough that none of them ever blocks. Five runs, medians.
Bound
none
buffered channel, cap 64
rate.Limiter at rate.Inf
semaphore.Weighted(64)

Nothing was ever refused. Every Acquire succeeded immediately, every Allow returned true, every channel send found space. The cost is entirely the checking, and it is 21× to 45× the work being protected.

That ratio is the whole point, and it cuts both ways.

When the bounded work is small, the bound dominates. Three nanoseconds of computation behind a 140 ns semaphore is a semaphore benchmark. If the thing you are limiting is this cheap, you almost certainly do not need to limit it — and §17.4's argument for bounding is about resources, not about cheap CPU work.

When the bounded work is large, the bound is free. The same 140 ns in front of a 5 ms database call is 0.003% overhead, and §17.3's entire case for admission control applies unchanged. Bounds are for expensive, resource-holding operations, and they cost nothing there.

The mistake is not “using a bound”. It is using one on a hot path where the protected work costs less than the check — and then, because the bound is a shared object under contention, discovering that the limiter has become the bottleneck it was supposed to prevent. §17.3.6's shared rate.Limiter at 180.3 ns/op under sixteen-way Allow() is that outcome measured.

20.7.6 Sharding Past the Floor

§17.3.6's table again, because it is the cleanest measured example of an optimisation continuing after it stopped working:

Design
one shared rate.Limiter
keyed map, 8 shards
keyed map, 64 shards
keyed map, 512 shards

The first eight shards recover 43.2 ns of the 52.7 ns that sharing was costing — 82% of the available gain. Sixty-four shards reach 98%. Five hundred and twelve reach 100%, for sixty-four times the shards and the last 18% of a gain that was already almost complete.

And then the section stops improving entirely, because the floor is not the shard count. Every request still takes the enclosing map’s own lock, whichever shard it wants. A floor that does not shard cannot be broken by sharding, and the distance between your current number and that floor is all the sharding has left to buy.

The habit worth forming: before adding shards, find what every caller still touches. Then the arithmetic is available in advance — if the floor is 127 and you are at 137, the remaining prize is 7%, and no shard count reaches it.

20.7.7 The One Where the Benchmark Was Right

This one is different from the others, and it is the reason this section exists.

A counter is incremented by sixteen goroutines under a mutex. §20.4.3's table says an atomic is far cheaper, atomic.Uint64 has Load and Store, and the replacement is obvious:

mu_207.go
// Illustrative snippet — not a complete program
// before
mu.Lock(); n++; mu.Unlock()

// after
a.Store(a.Load() + 1)
Measured the change is real. b.RunParallel at sixteen goroutines, a local mutex and counter rather than §20.4.3's package-level ones, five runs, medians — 42.31 ns/op with the mutex, 17.75 ns/op with the atomic pair. 2.38× faster, and every rule in §20.1 was followed: -count=5, medians, the same loop form on both sides, the same machine in the same minute.

go vet is quiet. go test -race is quiet — and it is correct to be quiet, because every individual operation genuinely is atomic and there is no data race in the memory-model sense.

Measured sixteen goroutines, a hundred thousand increments each, expected total 1,600,000.
Terminal
   expected 1600000, got 126470, lost 92.10%
   expected 1600000, got 134841, lost 91.57%
   expected 1600000, got 118172, lost 92.61%

The change lost 92% of its increments, three runs in a row, and under -race it lost 93.61% while reporting no race at all.

Load and Store are each atomic. The sequence of them is not. Between one goroutine’s Load and its Store, fifteen others load the same value and store the same result, and fifteen increments become one. This is the lost-update problem §8.4 names and §11.1 warns about, arriving through a door marked “performance optimisation”.

The correct version is faster still:

Increment
mu.Lock(); n++; mu.Unlock()
a.Store(a.Load() + 1)
a.Add(1)

atomic.Add is a single read-modify-write instruction, it is 3.10× the mutex, and it does not lose anything. The performance instinct was right; the API choice was wrong, and no measurement in this chapter would have caught it.

The tool that finds this is not a benchmark.

-race finds unsynchronised access, and both versions above are synchronised — the atomic one is simply synchronised at the wrong granularity. What finds it is a test that asserts the total, which the exercise at the end of this chapter has as its first gate and which every concurrency change should have before any benchmark is written. §16.7.6's argument in one measured example: correctness gates come first, and they are what make a performance claim mean anything at all.

20.7.8 Two That Are Not Optimisations At All

Both of these appear in performance PRs regularly and neither belongs in one.

Removing a defer. Since Go 1.14 an open-coded defer costs about a nanosecond, which is inside the noise of anything it would be guarding. Removing defer mu.Unlock() in favour of a manual Unlock on every return path trades a nanosecond for a class of bug — the early return added later that does not unlock — and §9.2 is the section that measures what that bug costs. This is a negative-value change.

Replacing a channel with a mutex “because channels are slow”. They are slower per operation, and §16.3.5 has the figures: an uncontended Lock/Unlock pair at 12.0–12.4 ns/op against a channel ping-pong at 222–277. But the two do different things: a channel transfers ownership and carries a happens-before edge, a mutex protects a region. Replacing one with the other is a redesign of the concurrency structure with a performance justification attached, and the redesign is what should be reviewed. If the channel is genuinely a hot path, §20.6.1's batching applies to it exactly as it applies to a lock.

20.7.9 When to Stop

Optimisation has no natural end, so it needs an imposed one. Four conditions, any of which is sufficient.

You are at the ceiling. §20.3.6 gives s from two -cpu runs. If you are at 90% of 1/s, the remaining parallel upside is 10% and it is not there.

The next change is worth less than its risk. §20.2.6's table: removing a 25% frame entirely buys 1.33×. If the removal means a cache with an invalidation protocol, the arithmetic is 1.33× against a class of bug that is hard to test for, and the answer is usually no.

The profile has gone flat. When the top frame is 8% and there are forty frames below it, there is no single change left worth making. That is a finding, and it means the next improvement is architectural — a different algorithm, a different data layout, or not doing the work — rather than another pass over the same code.

The number is inside the noise. §20.1.5's control run is the reference. On this machine a quiet benchmark’s spread is 1 to 9%; a 3% improvement cannot be distinguished from nothing, and pursuing it produces commits that are indistinguishable from reverts.

There is a fifth condition that is not about numbers. The code got harder to read and the win was small. Every optimisation in this chapter has a maintenance cost — a shard count to tune, a pool with lifetime rules, a batch with a flush timeout, a padded struct somebody will tidy up. A 5% improvement that adds one of those has a negative total return, and the person who pays it is not you.

Write down the number before you start.

“This path is 40% of the profile, Amdahl caps the fix at 1.67×, and I will stop when I have 1.3× or two days, whichever comes first.” That sentence takes a minute, it is checkable afterwards, and it is the only reliable defence against the week that produced 4%. §20.2.4's arithmetic is what makes it writable.

20.7.10 Common Mistakes

Pooling something that never escaped
Problem

Measured, 7.0× slower, and 0 allocs/op on both sides

Fix

-gcflags=-m before the pool exists

Returning oversized objects to a pool
Problem

Measured, 4.98 MB resident against 0.99

Fix

Cap what goes back in; inuse_space finds it

Caching a computation cheaper than the lookup
Problem

Measured, 19.6× slower at a 100% hit rate

Fix

Compare the computation against the lookup first

Caching with a mutex map under concurrency
Problem

Measured, 6.4× the sync.Map version, both losing to no cache

Fix

The cache adds §20.4's whole section to the path

Bounding cheap work
Problem

Measured, +140 ns of semaphore on 3.2 ns of work

Fix

Bounds are for expensive, resource-holding operations

Sharding past the floor
Problem

Measured, 64× the shards for the last 18%

Fix

Find what every caller still touches

Replacing a lock with Load then Store
Problem

Measured, 2.38× faster, 92% of updates lost, -race clean

Fix

atomic.Add; and a test that asserts the total

Removing defer for speed
Problem

A nanosecond bought, an unlock path lost

Fix

Open-coded defer is ~1 ns since Go 1.14

Swapping a channel for a mutex on speed grounds
Problem

A concurrency redesign in a performance PR

Fix

They do different things; batch the channel instead

Continuing past a flat profile
Problem

Commits indistinguishable from reverts

Fix

Write the stopping number down before starting

Summary: The Changes That Made It Worse, and When to Stop

Every technique in this chapter has a measured failure, and in each case the check that would have caught it was cheaper than the change. Pooling an object that never escaped is 7.0× slower and reports 0 allocs/op on both sides. Padding a large collection trades coherence for capacity. A pool that keeps an oversized buffer holds five times the resident memory across a collection, and ns/op never notices. A cache in front of a four-nanosecond computation is 19.6× slower at a hundred per cent hit rate. A bound that never fires still costs +140 ns on three nanoseconds of work. And sharding past the floor buys the last 18% for sixty-four times the shards.

The one that matters most is the one where the benchmark was right. Replacing a mutex with Store(Load()+1) is 2.38× faster, passes go vet, passes -race, and loses 92% of its increments. No measurement discipline in §20.1 would have caught it; a test that asserts the total catches it immediately. Correctness gates come before performance claims, because a performance claim about incorrect code is not a claim about anything.

And stopping is a decision, not an absence of one. You are done when you are near Amdahl’s ceiling, when the next change is worth less than the bug class it introduces, when the profile has gone flat, when the improvement is inside the noise — or when the code got harder to read for a number nobody will notice.

Key Takeaways

  • Measured, a sync.Pool on a stack-allocated object is 7.0× slower, with 0 allocs/op on both sides hiding it
  • Measured, a pool retaining one oversized buffer holds 4.98 MB against 0.99; the victim cache keeps it across a GC
  • Measured, caching a 4 ns computation is 3.1× slower through sync.Map and 19.6× through a mutex map, at a 100% hit rate
  • Measured, a bound that never blocks costs +67 to +140 ns; that is free in front of a database call and 45× the work on a hot path
  • Measured, Store(Load()+1) is 2.38× faster than a mutex, -race clean, and loses 92% of updates; atomic.Add is 3.10× and correct
  • Removing defer and swapping channels for mutexes are not optimisations; they are a bug class and a redesign
  • Stop at the Amdahl ceiling, at a flat profile, inside the noise, or when the maintenance cost exceeds the win
  • Write the target number down before starting; it is the only defence against the week that produced 4%
Section 20.7 — in one line

Every optimisation in this chapter has a shape where it is a pessimisation, the check that distinguishes them is always cheaper than the change, and the only one that no benchmark can catch is the one that was faster and wrong.

Self-Check Questions: The Changes That Made It Worse, and When to Stop

A PR replaces mu.Lock(); total += n; mu.Unlock() with a.Store(a.Load() + n). The benchmark shows 2.4×, go vet is clean, -race is clean, and all existing tests pass. Do you approve it?

No, and the reason is not in any of the evidence offered.

Load and Store are each atomic; the sequence is not. Between one goroutine’s Load and its Store, others load the same value and store the same result, and their updates vanish. Measured in §20.7.7: sixteen goroutines, a hundred thousand increments each, expected 1,600,000, got 126,470 — 92% lost, three runs in a row.

-race is quiet because it is right to be. There is no data race: every access is a properly synchronised atomic operation. The race detector finds unsynchronised access, and this code is synchronised at the wrong granularity, which is a different defect and one no dynamic race detector is built to find.

“All existing tests pass” is the part to push on. The tests do not assert the total under concurrency, or they would have failed. That is the gap: a concurrency change needs a test that runs the operation from many goroutines and checks the invariant afterwards, and it should exist before the benchmark does.

The fix is not to revert to the mutex. a.Add(n) is a single read-modify-write instruction, measured at 3.10× the mutex — faster than the broken version — and it is correct. The performance instinct was right and the API choice was wrong.

Your service’s CPU profile is flat: the top frame is 6%, and there are fifty frames below it. Where does the next week go?

Not into any of those fifty frames, and the arithmetic says why. §20.2.6's table: removing a 6% frame entirely buys 1.06×. Doing that fifty times is not available, and doing it three times buys 1.2× for three weeks.

A flat profile is a finding rather than a dead end, and it has three readings worth separating.

The CPU is not where the time is. §20.2.7: a CPU profile samples running goroutines, so blocked time produces no samples at all. Check the utilisation against the latency. If p99 is 800 ms and CPU is 12%, the profile is flat because the program is waiting, and the instruments are §19.3's block and mutex profiles — both off by default, both returning an empty profile rather than an error.

The work is genuinely distributed and genuinely necessary. Then the improvement is architectural rather than local: a different algorithm, a different data layout, caching a whole result rather than a step, or not doing the work — §20.6 rather than §20.2. §20.2.5 is the shape, where the win came from replacing twenty thousand insertions with one sort rather than from speeding any frame up.

It is memory-bound. §20.5.4: a loop that reads more than it computes is limited by the bus, and the profile shows time spread across every frame that touches memory. The diagnostic is arithmetic per byte, and the fixes are to touch less memory or do more per byte.

In all three cases the honest answer to “which frame do I optimise” is none of them, and saying so is more valuable than a week of 2% commits.

You are asked to add a sync.Pool for 200-byte request objects, and told it is “standard practice”. What does the measurement say?

That it depends on one question nobody has asked yet, and the answer may be that there is nothing to pool.

First, does the object escape? go build -gcflags=-m. If it says does not escape, the object is on the stack, it costs approximately nothing, and adding a pool adds the pool’s flat ~12 ns to something free. Measured in §20.7.1: 1.769 ns without the pool, 12.440 ns with it — 7.0× slower, and 0 allocs/op on both sides, which is exactly why the change survives review.

If it does escape, the size decides. §20.6.5's crossover: the pool costs a flat ~12 ns whatever it holds, and allocation costs a rising line. At 64 bytes allocation is 19.28 ns and the pool wins by 1.4×. At 200 bytes the win is in the same modest range. At 4 KB it is 43×, and at 1 MB it is 6,050×. Two hundred bytes is at the bottom of the range where a pool is worth its bug classes — an object put back while something still holds it, and an object taken out with stale fields.

And check the retention. If request objects hold references to response bodies or buffers, §20.7.3's failure applies: the pool keeps the largest thing you put in it, across one collection, and in a busy service that means permanently. Measured there at 4.98 MB against 0.99 for one oversized buffer.

The alternative worth putting in the review: GOGC, measured at 1.38× on an allocation-heavy workload, for an environment variable and no new lifetime rules. If the pool’s case is that allocation is expensive here, the GOGC sweep tests that claim in twenty minutes and does not need to be maintained.

A change makes your benchmark 4% faster. benchstat reports -4.1% (p=0.03 n=10) with ± 6% on both sides. Ship it?

There is no result here, and the ± is the column that says so — not the p.

§20.1.5 measured what this looks like from the other side: two samples of identical code, nothing changed between them, reported -1.67% at p=0.010. A significant p on a sample whose spread is wider than the effect is a statement about ten numbers, not about the program.

With ± 6% on both sides, a 4.1% difference sits inside the noise band of either sample alone. More samples might resolve it — -count 30 will push p down or leave it — but the more useful question is what a resolved 4% would be worth. §20.7.9's fourth stopping condition: on this machine a quiet benchmark’s spread is 1 to 9%, so a 4% change is not observable in production and will not be visible in any dashboard anyone draws.

Then ask the maintenance question, which is the one this section ends on. If the change is a one-line simplification, ship it for being simpler and make no performance claim. If it adds a shard count, a pool, a batch timeout or a padded struct, the win is 4% and the cost is permanent, and the answer is no. A 4% improvement that adds something for a future maintainer to misunderstand has a negative total return.

What would change the answer is a deterministic metric. If -benchmem shows allocs/op falling from 3 to 1, that is a real, reproducible, machine-independent claim — and it will hold up on hardware where the 4% does not.

Chapter Summary

This chapter had one argument and made it seven times.

A number is not a measurement until you know what produced it. The compiler deletes work nothing observes, and inside a benchmark that is exactly the work you meant to time — silently, accurately, and always in the flattering direction. Five obvious fixes were measured and four failed. b.Loop protects call results and assigned variables, which is narrower than its reputation, and inlining takes even that back. -gcflags=-m settles the question in one compile. One run is a sample: measured, two samples of identical code reported p=0.010 and a 13% improvement that did not exist.

A profile says where time went, not what to change. On a contended counter, 85.73% of the CPU profile was three runtime frames nobody can edit, and the function under test had 0.2% flat time while carrying 50.07% cumulative — printed, in the same listing, in a column nobody reads. The cumulative share of a serialising function is the s Amdahl needs, and measured at 63.22% it capped an index builder’s parallel upside at 1.53×. The change that worked used no concurrency at all, bought 3.06×, and then made concurrency worth 2.76× more.

Concurrency is a crossover, not a default. Measured, the same summation was 134× slower across sixteen goroutines at a hundred items and 4.2× faster at a million — and §2.5 measured 144× at a thousand, eighteen chapters earlier. Swapping the addition for a SHA-256 moves that crossover to between one hundred and four hundred items, a factor of a thousand, with nothing about the concurrency changed. The unit that decides it is work per goroutine: the same job, the same sixteen threads, split six ways, spans 1,531×, with a goroutine per item at the bottom spending 261 ns of scheduling on 1.16 ns of work.

Contention is a curve. Measured, one mutex is 5.6× slower at sixteen processors than at one; 64 shards are 25% worse at one processor and 7.9× better at sixteen. Only the direction of a sweep reproduces. Four questions come before sharding and all four also help the single-processor case, the largest of them being the primitive: an atomic load is 503× a contended mutex read. And every mean understates it — measured, p50 moved 1.6× while p99 moved 1,954×.

The hardware has two effects Go cannot hide. Measured, one false-sharing fix reports 1.37× and 8.18× on the same machine in the same hour, depending only on writers per cache line after the fix. Underneath it, a lock-free read-only sum peaks at 4.76× on sixteen threads and then goes backwards, because the memory bus is a shared resource and the loop does less than one operation per byte.

Doing less work has no ceiling. Measured, batching a mutex-guarded increment 4,096 ways is 393× — nothing got faster, the lock was taken 4,096 times less often. An allocation that does not escape is 8.2× cheaper and is not an allocation. fmt.Sprintf is 3.7× a concatenation and allocates three times against one, because ...any boxes every argument. And GOGC=800 is 1.38× for an environment variable, while GOGC=off is 0.66×, because nothing gets reused.

Every one of these has a shape where it is a pessimisation, and the check is always cheaper than the change. A pool on a stack object is 7.0× slower with 0 allocs/op on both sides. A cache in front of a 4 ns computation is 19.6× slower at a 100% hit rate. A bound that never fires costs +140 ns. And the one no benchmark catches: Store(Load()+1) is 2.38× faster, -race clean, and loses 92% of its increments.

The thread through all of it is the same. Optimisation is a claim, and a claim needs evidence that survives someone checking it. Not a number — a number is easy, and this chapter produced several that were wrong. Evidence: a benchmark that contains the change, a workload that contains the condition, a sample large enough to support the claim, and a correctness test that runs first.

Chapter Connections

Chapter 19 hands over the instruments and stops. This chapter is the decision that follows a profile, and §20.2 is the bridge: flat against cumulative, the three readings of a hot frame, and the arithmetic that turns a percentage into a bound.

Chapter 16 established that one run is not a number and handed benchstat forward. §20.1 is that promise paid, and §20.7.7 is §16.7.6's argument arriving as a measurement: correctness gates come before performance claims.

Chapter 17 priced its bounds and left the arithmetic to a later chapter. §20.7.5 is that arithmetic — +67 to +140 ns for a bound that never fires — and §20.7.6 finishes §17.3.6's sharding table by naming the floor that shards cannot break.

Chapter 11 taught when an atomic is correct. §20.4.3 is what it is worth — 503× on a contended read — and §20.7.7 is what getting the correctness half wrong costs, with the race detector silent throughout.

Chapter 12's sync.Pool becomes a crossover here rather than a tool: measured at 43× on a 4 KB buffer, 1.4× at 64 bytes, and 7.0× slower on an object that never escaped.

Chapter 2 measured what a goroutine costs and drew a band from it. §20.3 sweeps that band until the answer changes sign, and every number in it lands where §2.5 said it would.

Chapter 1 said “profile first; the bottleneck picks the strategy” and could not yet pay for it. This chapter is the payment, and §1.5's GOMAXPROCS conclusion — do nothing — survives §20.5.7 unchanged.

Chapter 21 takes the scheduler itself, which is the one instrument this chapter kept pointing at and never opened.

The Measurements in One Place

Every figure in this chapter, on go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16. Directions travel; absolutes do not.

Measurement
b.N empty-loop floor / b.Loop empty-loop floor
A deleted call vs the honest one
b.Loop with an inlined call vs //go:noinline
Identical code, two benchstat samples
Sequential vs RunParallel, same function body
Contended counter profile: three runtime frames
The same profile: inc flat vs cumulative
Unlock vs Lock, cumulative, saturated mutex
Index builder: serial, 16 goroutines, sort-once, both
sort.Slice vs slices.Sort, 20,000 uint64
Crossover: 100 / 10,000 / 1,000,000 items
The same sweep with SHA-256: 100 / 400 / 10,000
Granularity, one job, 16 threads, best vs worst
One mutex, -cpu 1 to 16
64 padded shards, -cpu 1 to 16
64 shards padded vs unpadded, -cpu 16
Contended read: Mutex / RWMutex / atomic.Load
Contended write: Mutex / atomic.Add
Reading a sharded counter: 1 / 8 / 64 / 512 / 4,096
One mutex, p50 / p99 / max at 16 workers
Saturated critical section, 200 → 20 units
Work moved out of the lock, 100% → 10%
False sharing: 1 goroutine / narrow / wide shape
Read-only sum vs 40-ops-per-element, 16 threads
Batching a locked increment, 1 → 4,096
Journal commit: ns/op vs items/s, batch 1 → 512
4 KB buffer: escaping vs stack
fmt.Sprintf vs concatenation
sync.Pool vs allocation: 64 B / 4 KB / 1 MB
sync.Pool on a stack object
Pool retaining an oversized buffer, after one GC
GOGC 100 / 800 / off
Caching a 4 ns computation: sync.Map / mutex map
Caching a 45 ns computation: sync.Map
A bound that never fires: channel / limiter / semaphore
Store(Load()+1) vs mutex vs Add

Final Checklist

Before claiming an optimisation:

Exercise 20.1 — The Optimisation That Was Already Shipped

Your move

The Optimisation That Was Already Shipped

The code is in labs/go-concurrency/code/ch20. Package metrics counts events by service and name, and it has been optimised once already: the counter struct is padded to a full cache line, and the commit that added the padding quoted a benchmark showing 40%.

Your job is to find out whether that is true, and then to make the package actually faster without breaking what it is good at.

Here is the package. The padding is the change that was measured; Record is the line that was not.

ch20/metrics.go
// Package metrics counts events by service and name.
//
// The Recorder below has already been optimised once. The counter
// struct is padded to a full cache line "to avoid false sharing",
// and the commit message that added the padding quoted a benchmark
// showing a 40% improvement.
//
// TODO(reader): the padding is not the optimisation it claims to be,
// and the benchmark that proved it was measuring nothing. Find the
// real cost, prove the fix with numbers that survive §20.1, and do
// not regress the workload the current shape is good at.
package metrics

import (
	"fmt"
	"sort"
	"sync"
)

// counter holds one event count.
//
// The padding is here because a profile showed atomic contention in
// an unrelated package and the same fix was applied by analogy.
type counter struct {
	n uint64
	_ [56]byte // pad to 64 bytes: one counter per cache line
}

// Entry is one row of a Snapshot.
type Entry struct {
	Key string
	N   uint64
}

// Recorder counts events. It is safe for concurrent use.
type Recorder struct {
	mu     sync.Mutex
	counts map[string]*counter
}

// New returns an empty Recorder.
func New() *Recorder {
	return &Recorder{counts: make(map[string]*counter)}
}

// Record adds one to the count for service/event.
func (r *Recorder) Record(service, event string) {
	key := fmt.Sprintf("%s/%s", service, event)
	r.mu.Lock()
	c := r.counts[key]
	if c == nil {
		c = &counter{}
		r.counts[key] = c
	}
	c.n++
	r.mu.Unlock()
}

// Snapshot returns every count, ordered by key.
//
// The result is a copy: callers hold it while the Recorder keeps
// running, and a caller that sorts or edits it must not be able to
// disturb the Recorder's own state.
func (r *Recorder) Snapshot() []Entry {
	r.mu.Lock()
	out := make([]Entry, 0, len(r.counts))
	for k, c := range r.counts {
		out = append(out, Entry{Key: k, N: c.n})
	}
	r.mu.Unlock()
	sort.Slice(out, func(i, j int) bool {
		return out[i].Key < out[j].Key
	})
	return out
}

// Total returns the sum of every count.
func (r *Recorder) Total() uint64 {
	r.mu.Lock()
	defer r.mu.Unlock()
	var t uint64
	for _, c := range r.counts {
		t += c.n
	}
	return t
}

And the harness that “40%” was taken with:

ch20/workload.go
package metrics

// Workload describes what a measurement run exercises.
type Workload struct {
	Goroutines int // concurrent callers
	Keys       int // distinct service/event pairs
	Ops        int // Record calls per goroutine
}

// DefaultWorkload is the harness the "40% faster" commit used.
//
// TODO(reader): one goroutine and one key. Padding separates counters
// so that different cores stop invalidating each other's cache line.
// Neither condition exists here, so whatever this measured, it was
// not the change it was quoted to justify. Gate 2 fails until the
// workload exercises the thing being optimised.
var DefaultWorkload = Workload{Goroutines: 1, Keys: 1, Ops: 20000}
Where the files are:

The five gates:

  1. The toolchain is satisfied. go vet is quiet, -race is quiet, the counts are right. This was green when the padding was committed, and it is the gate that made a wrong change look safe.
  2. The benchmark measures something. DefaultWorkload is one goroutine and one key. Padding changes what happens when two cores write two counters that share a cache line; neither condition exists here. Fix the workload before you trust any number taken under it.
  3. The optimisation is outside the noise. Not a threshold in nanoseconds — a paired win count. Fifteen interleaved rounds, alternating which version runs first, and the new one must win at least thirteen. That is a result a coin produces less than 1% of the time, and unlike a fixed threshold it survives being run on your machine rather than this one.
  4. The hot path stopped allocating. Record allocates on every call today. Two shapes are checked — a caller repeating one key, and a caller cycling through 512 — so a fix that remembers only the last key does not count.
  5. Snapshot is an independent, sorted copy. This passes today and constrains every fix. Callers hold a snapshot while the recorder keeps running, and they sort and filter it in place.

Gate 3 is the one worth reading before you start, because it is this chapter’s argument compiled:

ch20/metrics_test.go
// Illustrative snippet — not a complete program
// TestTheOptimisationIsOutsideTheNoise is the gate this chapter is
// about. It does not compare two means. It runs the two versions
// alternately, counts how often the new one wins, and requires the
// count to be one a coin would produce less than 1% of the time.
//
// A fixed nanosecond threshold would not survive being run on another
// machine. A paired win count does.
func TestTheOptimisationIsOutsideTheNoise(t *testing.T) {
	if testing.Short() {
		t.Skip("timing gate: skipped under -short")
	}
	w := keysOf(DefaultWorkload)
	const rounds, needed = 15, 13

	wins := 0
	for i := 0; i < rounds; i++ {
		// Interleaved, and the order alternates, so that neither
		// version is always the one running on a cold heap.
		var dNew, dOld time.Duration
		if i%2 == 0 {
			dOld = run(w, NewBaseline().Record)
			dNew = run(w, New().Record)
		} else {
			dNew = run(w, New().Record)
			dOld = run(w, NewBaseline().Record)
		}
		if dNew < dOld {
			wins++
		}
	}
	if wins < needed {
		t.Fatalf("Recorder beat Baseline in %d of %d paired rounds, "+
			"need %d: an improvement this size is inside the noise, "+
			"which is what the committed 40%% was",
			wins, rounds, needed)
	}
}
Two traps:

The first is that gate 2 is about the harness and gate 3 is about the result, and they must be fixed in that order. A number taken under a workload that cannot exhibit the effect is not a small number — it is not a number at all, which is §20.1 in one gate.

The second is that the obvious speedup is not the padding. Read Record and count what it does before it takes the lock. Measured, it allocates three times per call — twice boxing the arguments into ...any, once for the result — and then hashes the joined string to reach the map. §20.6.4 measured that exact shape, and gate 4 is where it is failing today.

Done when: all five gates pass, go vet ./... is clean, go test -race is clean, and you can state in one sentence what the padding was actually worth — with the measurement that says so.

Further Reading

Go documentation and source

Measurement and statistics

Hardware

Amdahl and its limits

Next

You can now tell a benchmark that contains your change from one that contains an empty loop, read a profile as a decision rather than a picture, compute the ceiling before paying for the parallelism, and sweep a core count instead of quoting one. You know that the same optimisation measured two ways gave answers a factor of six apart, and which of the two your code looks like. Every curve in this chapter bent at a point the runtime chose. Chapter 21 is that runtime: the scheduler underneath all of it, and why the numbers moved where they did.