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

- Published: 2026-10-25
- Tags: allocation, memory, benchmarking, compiler
- Source: https://gopheria.com/blog/the-thirty-percent-smaller-allocation/
- Language: en-US
- Author: Nolan Keir

---
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](/blog/escape-analysis-is-not-a-rule-of-thumb/) 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/`](https://github.com/CognatePress/experiments.gopheria.com/tree/main/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:

```bash
# 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](/blog/benchstat-or-it-didnt-happen/), and its true delta is zero.

```text
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:

```text
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:

```text
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.

```text
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:

```text
$ ~/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:

```text
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](/blog/benchstat-or-it-didnt-happen/) reported a 44.82% difference
between two runs of identical code — which is what sent me to interleaved rounds
in the first place.

:::warning
An apparent 2–6% regression above 256 bytes in the flag comparison did not
reproduce in the cross-toolchain run, where the same rows came back `~` with
tighter variance. It was the harness. I am reporting it because a reader running
the flag A/B alone may see it too.
:::

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

```text
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.
