Category: Performance and measurementSeries · part 1 of 1

The 30% is 46%, and it stops dead at 80 bytes

Go 1.27's size-specialised allocation is worth up to 46% on the pointer-containing path, not the 30% announced — and from 96 bytes up it is worth nothing.

An allocation box leads to a decision diamond branching into a fast-path moving cube and a standard cube, both connecting to memory.

TL;DR

  1. Go 1.27 picks size-specialised allocation routines for small objects. Measured at 24 bytes: 9.34 ns to 6.21 ns on the pointer-free path, and 12.03 ns to 6.53 ns on the pointer-containing one — 33.5% and 45.7%.
  2. The release notes promise "up to 30%". The pointer-containing path beat 30% at every size from 8 to 80 bytes.
  3. It is a cliff rather than a taper. Every size up to 80 bytes improves; 96 bytes and above is unchanged, on both paths, in both experiments.
  4. Two independent experiments agree to 0.04% — the same-toolchain flag A/B gives −13.74% geomean and the genuine 1.26.8→1.27.0 upgrade gives −13.70%. At these sizes the whole upgrade is this one change.
  5. Allocation counts do not move. Every row is 1 alloc/op before and after; what changed is what one allocation costs.

A 24-byte allocation that holds a pointer went from 12.03 ns to 6.53 ns between Go 1.26.8 and Go 1.27.0. That is 45.7%, and the release notes promise up to 30%.

One size class higher, at 96 bytes, the same measurement comes back unchanged. Not smaller — absent. This post is the shape of that cliff, and it opens the-allocator, a series about what an allocation costs once escape analysis has decided you are having one.

What is being measured

All numbers: go1.27.0 darwin/arm64 and go1.26.8 darwin/arm64, Apple M4 Pro, 12 cores, macOS 26.6.2. The harness is allocsize/.

Go 1.27’s change is a compiler decision:

The compiler now generates calls to size-specialized memory allocation routines, reducing the cost of some small (<80 byte) memory allocations by up to 30%.

The routine is chosen from a compile-time constant size, so every benchmark allocates a literal. A size read out of a slice would defeat the thing being measured and the table would come back flat for the wrong reason. Both allocation paths are swept: make([]byte, N) for the pointer-free one, and a struct carrying one pointer plus padding for the one the collector has to scan.

Two experiments, because either alone can be fooled:

# Inside one toolchain, the feature is a build-time GOEXPERIMENT.
go test -run=^$ -bench='Warmup|Alloc' -benchmem -count=6 ./allocsize
GOEXPERIMENT=nosizespecializedmalloc \
  go test -run=^$ -bench='Warmup|Alloc' -benchmem -count=6 ./allocsize

# Across toolchains. GOTOOLCHAIN=local is not optional — see below.
GOTOOLCHAIN=local go1.26.8 test -run=^$ -bench='Warmup|Alloc' -benchmem -count=6 ./allocsize

Each configuration was run in interleaved rounds rather than back to back, and the samples pooled: five rounds of -count=6 for the flag comparison (n=30), three for the cross-toolchain one (n=18). A third configuration — go1.27.0 default, run again in the same rotation — is the control, and its true delta is zero.

control: go1.27.0 default against itself, n=30
0008  4.949n ± 3%   5.007n ± 1%   ~ (p=0.652)
0024  6.200n ± 1%   6.219n ± 1%   ~ (p=0.473)
0080  9.766n ± 2%   9.743n ± 3%   ~ (p=0.900)
1024  110.6n ± 1%   110.6n ± 4%   ~ (p=0.530)
geomean                          +0.30%

That +0.30% is the bar. It is also the second thing this harness taught me, and the first is worth a paragraph of its own further down.

The cliff

The flag comparison, pointer-free path:

size      off           on            vs off
0008      6.059n ± 0%   4.949n ± 3%   -18.33% (p=0.000 n=30)
0016      7.831n ± 2%   5.643n ± 3%   -27.95% (p=0.000 n=30)
0024      9.432n ± 1%   6.200n ± 1%   -34.27% (p=0.000 n=30)
0032      9.505n ± 1%   6.628n ± 1%   -30.27% (p=0.000 n=30)
0048     10.255n ± 1%   7.628n ± 2%   -25.62% (p=0.000 n=30)
0064     10.820n ± 1%   8.660n ± 1%   -19.96% (p=0.000 n=30)
0080     12.425n ± 1%   9.766n ± 2%   -21.40% (p=0.000 n=30)
0096      13.31n ± 1%   13.38n ± 2%         ~ (p=0.610 n=30)
0112      14.28n ± 3%   14.22n ± 1%         ~ (p=0.403 n=30)
0128      52.05n ± 6%   51.69n ± 4%         ~ (p=0.501 n=30)
0160      24.21n ± 3%   24.09n ± 16%        ~ (p=0.923 n=30)
0256      60.39n ± 1%   60.93n ± 6%         ~ (p=0.223 n=30)
0512      79.01n ± 1%   78.77n ± 3%         ~ (p=0.730 n=30)
1024      110.0n ± 1%   110.6n ± 1%         ~ (p=0.108 n=30)
geomean   18.89n        16.30n        -13.74%

Seven sizes improve and seven do not, and the line between them is between 80 and 96 bytes — which is the next size class up from the documented threshold. There is no taper. allocs/op is 1.000 in every row of every configuration: nothing changed about how many allocations happen, only what one costs.

The path the notes do not separate

The same comparison on the pointer-containing path:

size      off            on             vs off
0008       7.814n ± 1%    4.750n ± 0%   -39.21% (p=0.000 n=30)
0016      10.500n ± 1%    5.838n ± 3%   -44.40% (p=0.000 n=30)
0024      12.045n ± 2%    6.484n ± 2%   -46.17% (p=0.000 n=30)
0032      12.070n ± 1%    6.783n ± 2%   -43.80% (p=0.000 n=30)
0048      13.315n ± 1%    8.165n ± 2%   -38.67% (p=0.000 n=30)
0064      13.565n ± 1%    8.922n ± 6%   -34.23% (p=0.000 n=30)
0080       15.69n ± 1%   10.48n ± 11%   -33.15% (p=0.000 n=30)
0096       16.61n ± 1%    16.54n ± 3%          ~ (p=0.923 n=30)
geomean    22.52n         17.65n        -21.65%

Every size from 8 to 80 bytes beats the announced 30%, and the peak is 46.17%. The notes give one figure for both paths; the paths do not agree, and the one that does better is the one most structs are on — anything containing a pointer, a string, a slice, a map or an interface.

The cliff is in the same place. At 96 bytes the improvement is gone on this path too.

Does upgrading get you this?

The flag isolates the change but nobody ships a flag. The question a reader has is what happens when they move from 1.26 to 1.27, and that is a different measurement — one that includes every other change in the release.

size      go1.26.8      go1.27.0      vs 1.26      (pointer-free, n=18)
0016       7.768n ± 1%   5.573n ± 1%  -28.26% (p=0.000)
0024       9.337n ± 2%   6.205n ± 1%  -33.54% (p=0.000)
0080      12.300n ± 1%   9.700n ± 1%  -21.14% (p=0.000)
0096       13.30n ± 2%   13.28n ± 2%        ~ (p=0.821)
1024       110.0n ± 1%   110.0n ± 1%        ~ (p=0.748)
geomean    18.73n        16.16n       -13.70%

−13.70% against the flag comparison’s −13.74%. Two experiments that could have disagreed about anything agree to four hundredths of a percent, which says something neither could say alone: at these sizes, the entire 1.26→1.27 allocation improvement is this one change. Nothing else in the release moved these numbers.

The 1.26 benchmark that was not a 1.26 benchmark

The cross-toolchain leg took three attempts. The third failed in a way worth publishing, because the failure is silent and the reader will meet it.

GOTOOLCHAIN defaults to auto. The harness’s go.mod says go 1.27.0. Inside that module, running the older toolchain does this:

$ ~/go/bin/go1.26.8 version
go version go1.27.0 darwin/arm64

Go downloaded 1.27.0 and re-executed under it, because the module asked for it. Both legs of my comparison were the same compiler, which is why the first cross-toolchain result showed the upgrade gaining exactly nothing — ~ on all fourteen rows. The fix is GOTOOLCHAIN=local plus a go directive the older toolchain will accept, so that one module state is built by two compilers.

The symbol count is the check that settles it, and it needs GOTOOLCHAIN=local too:

go1.27.0  default                          13 size-specialised symbols emitted
go1.27.0  GOEXPERIMENT=nosizespecializedmalloc    0
go1.26.8  default (GOTOOLCHAIN=local)              0

The other two discarded attempts were more ordinary and are in the harness repository. One ran while this machine was generating an image. The other compared configurations in separate back-to-back processes, and the control pair reported a 44.82% difference between two runs of identical code — which is what sent me to interleaved rounds in the first place.

What it costs

The notes are explicit that this is a trade:

This causes the binary size to increase by about 60 KB (independent of the workload).

Measured on one of this site’s harness programs, built both ways:

3064034  size-specialised on
3012642  off
  51392  difference

51 kB rather than 60, on this binary. Close enough to the stated figure to believe the mechanism, and worth knowing exactly if you ship to somewhere that counts bytes.

One thing I cannot explain

At 128 bytes both paths cost about 51 ns, against 14 ns at 112 and 24 ns at 160. It is not the feature — it is identical with the flag on and off, and identical on 1.26.8 — and it is not noise, reproducing across every configuration at ±1–6% over 18 to 30 samples. Three and a half times the 112-byte class below it and twice the 160-byte class above it, at exactly one size, and I have not found what causes it. It is in the tables above rather than trimmed out of them.

What this does not measure

The notes’ other number. “~1% in real allocation-heavy programs” is a claim about programs, and this is a microbenchmark of one allocation with nothing around it; a 34% saving on a thing that is 3% of your runtime is the 1%. Nothing here tells you which fraction of your time is mallocgc, and a CPU profile does.

It also measures one architecture. The routines are chosen by the compiler and the win is real work removed rather than a vector instruction, so it should travel — but I have not run it on amd64 and will not claim it.

And the opt-out is temporary: the notes say nosizespecializedmalloc is expected to be removed in Go 1.28. The flag half of this experiment has a shelf life, which is the argument for having done the cross-toolchain half at all.

Frequently asked

Which allocation sizes does Go 1.27's improvement actually cover?

Up to 80 bytes, and then it stops. In the sweep below every size class from 8 to 80 bytes improved — between 15% and 34% on the pointer-free path, between 33% and 46% on the pointer-containing one — and 96, 112, 128, 160, 256, 512 and 1024 bytes were unchanged, with both experiments agreeing. The release notes say "<80 byte" and the boundary is exactly where they put it.

Is the improvement really only 30%?

Not on the path that scans. The notes give a single "up to 30%" figure, but the two allocation paths do not benefit equally: the pointer-free path peaked at 33.5% and the pointer-containing path at 45.7%, both at 24 bytes. If your small objects contain pointers — which most structs do — the announced figure understates what you get.

Do I get this by upgrading, or do I need to change something?

By upgrading, and there is nothing to change. It is a compiler decision made from a compile-time constant size, so it applies to existing code recompiled. Measuring the genuine 1.26.8 to 1.27.0 upgrade gave −13.70% geomean against the flag A/B's −13.74%, which is the same result reached two ways.

Why did my go1.26 benchmark produce go1.27 numbers?

Because GOTOOLCHAIN defaults to auto and go.mod asked for a newer toolchain than the one invoked. Inside a module whose go directive says 1.27.0, running go1.26.8 makes Go download go1.27.0 and re-execute under it — go1.26.8 version prints "go version go1.27.0". Any cross-version benchmark needs GOTOOLCHAIN=local and a go directive the older toolchain accepts, or both legs measure the same compiler.

Nolan Keir

Systems-minded Go engineer

Nolan Keir writes about Go, backend engineering, and the systems behind production software. His work focuses on concurrency, runtime behavior, performance, tooling, and the trade-offs hidden behind clean abstractions. He prefers reproducible experiments and measurable behavior over rules of thumb. He writes at Gopheria.

More about the author

Arrow keys to move, Enter to open.