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.

TL;DR
- 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%.
- The release notes promise "up to 30%". The pointer-containing path beat 30% at every size from 8 to 80 bytes.
- 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.
- 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.
- 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.

