Go 1.27 Size-Specialized Allocation: Why Small Allocations Got Faster
The Go runtime now contains a function called mallocgcSmallNoScanSC3. It allocates pointer-free objects between 17 and 24 bytes. That’s it. There’s a sibling for every other small size class, and together they make up size-specialized allocation, shipping in Go 1.27. The official Go blog post puts the win at 20-30% on small allocations, which comes out to about 1% on allocation-heavy programs.
You get all of it by rebuilding with 1.27. No flags, no code changes. The mechanism is still worth understanding, though, because it reveals how the allocator rounds objects up to fixed sizes and why the optimization stops at 80 bytes.
How the Go allocator picks a size class
The generic heap-allocation path runs through the runtime’s mallocgc. Traditionally, when the compiler decided an object escaped, it emitted a call to newobject, which extracted the size and whether the object contained pointers before calling mallocgc. Go 1.27 can replace that route with a direct call to a specialized allocator when it knows enough at compile time.
Those two facts drive nearly everything that follows. The allocator never hands out arbitrary byte counts. It rounds up to a size class:
| Size class | Range of sizes |
|---|---|
| 1 | 1-8 bytes |
| 2 | 9-16 bytes |
| 3 | 17-24 bytes |
| 4 | 25-32 bytes |
| 5 | 33-48 bytes |
| 6 | 49-64 bytes |
| 7 | 65-80 bytes |
Ask for 17 bytes, get 24. Ask for 24, get 24. Free lists (spans) are kept separately for objects with and without pointers, since the garbage collector has to track them differently. The runtime folds both facts into a span class: sizeClass<<1 | noPointers.
Specialization happens per span class, which is the obvious place to draw the line, because the span class already decides most of what the allocator does next.
Where the speedup comes from
Memory clearing, mostly. mallocgc usually has to zero what it returns, and on a 24-byte object that zeroing is a large share of the total work. memclrNoHeapPointers is hand-tuned assembly, which sounds fast until you notice you’re paying for a function call and a ladder of size branches to clear three words.
In a specialized function, the clear size is a compile-time constant. The compiler emits the stores inline. No call, no branches.
The rest of the gains are smaller. A function that serves exactly one span class doesn’t have to compute the span class before it grabs a span. A constant size lets the compiler flatten the GC bookkeeping, including recording where pointers sit inside the object. Rare conditions, debug flags, an in-progress GC cycle, get shoved into slow-path fallbacks so the hot path stays short.
That last bit is where the design gets interesting, because specialization has a hard ceiling. Every specialized function costs binary size and competes for instruction cache. The generic mallocgc is often resident in the instruction cache because allocation is so frequent. Split that work across too many functions and cache misses can eat the gains while also crowding out user code. The Go team benchmarked several cutoffs and landed on 80 bytes. The feature was aimed at 1.26 and held back a full release to shrink the generated code and tune exactly this tradeoff.
Constant sizes let the compiler skip the dispatch
Here’s where it touches code you write. If the compiler knows the span class at compile time, it emits a direct call to the specialized function and skips newobject entirely. If it doesn’t, the call goes to the generic mallocgc, which works out at runtime whether a specialized function exists for this span class and calls it indirectly.
// Size known at compile time. The compiler can emit a direct call
// to the specialized allocation function for this span class.
func fixedBuf() *[24]byte {
return new([24]byte)
}
// Size unknown at compile time. Stays on the generic mallocgc path,
// which dispatches to a specialized function dynamically.
func dynamicBuf(n int) []byte {
return make([]byte, n)
}
When n is within the specialized range, both paths can benefit. The dynamic path pays for the indirect call first, which is precisely why the specialized functions had to be fast enough to cover that dispatch overhead.
Struct layout can move you across a size class boundary
The most common small allocations are 16 and 24 bytes, because those are two and three 64-bit words. Interface values and string headers are two words; slice headers are three. Your own structs land wherever field ordering and padding drop them.
package main
import (
"fmt"
"unsafe"
)
type eventGood struct {
ID int64
Timestamp int64
Flags uint8
Kind uint8
}
type eventBad struct {
Flags uint8
ID int64
Kind uint8
Timestamp int64
}
func main() {
fmt.Println(unsafe.Sizeof(eventGood{})) // 24 -> size class 3
fmt.Println(unsafe.Sizeof(eventBad{})) // 32 -> size class 4
}
Identical fields, identical data. The interleaved version needs 7 bytes of padding after each uint8 to align the following int64, which pushes it from 24 bytes to 32: different size class, different span, 8 bytes thrown away per object.
None of that is new in 1.27. Size specialization changes no layout rules, and both classes in this example have their own specialized allocation path. Compact layouts still reduce memory use and garbage-collector work; that is the reason to care about the boundary, not a new 1.27 layout rule. As before, grouping same-width fields and putting smaller fields together can avoid padding.
To check whether an allocation is on the heap at all, escape analysis will tell you:
go build -gcflags='-m' ./...
And for counts and bytes per operation:
package bench
import "testing"
type event struct {
ID int64
Timestamp int64
Flags uint8
Kind uint8
}
// Package-level sink so the allocation escapes and isn't
// optimized away by the compiler.
var sink *event
func BenchmarkAllocEvent(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
sink = &event{ID: 1, Timestamp: 2}
}
}
b.ReportAllocs reports B/op, which is the size-class-rounded number the allocator handed out, not unsafe.Sizeof. If you’re trying to shrink allocations, that’s the number that matters. unsafe.Sizeof will happily tell you 25 bytes while the runtime charges you 32.
The code generation trick behind the specialized functions
Fourteen near-identical hot-path allocation functions cover the tiny allocator plus the scanned and pointer-free span classes through 80 bytes. Hand-copying those variants would be a maintenance trap: they could drift apart as the allocator changes.
So the runtime team wrote an inliner. It parses Go source with go/parser, represents it with go/ast, and uses golang.org/x/tools/go/ast/astutil to rewrite the trees before formatting the output with go/format. The shared parts of the allocation path live in the runtime as ordinary Go, compiled and type-checked with everything else. The generator then stamps out one copy per span class, substituting constants and manually inlining helpers the compiler would otherwise refuse to inline for being too big.
The core pattern is smaller than you’d expect:
package main
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"log"
"golang.org/x/tools/go/ast/astutil"
)
const src = `package p
func clearObj(p *byte) {
memclr(p, objSize)
}
`
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "p.go", src, parser.ParseComments)
if err != nil {
log.Fatal(err)
}
// Specialize: replace the objSize identifier with a literal constant.
astutil.Apply(f, nil, func(c *astutil.Cursor) bool {
if id, ok := c.Node().(*ast.Ident); ok && id.Name == "objSize" {
c.Replace(&ast.BasicLit{Kind: token.INT, Value: "24"})
}
return true
})
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, f); err != nil {
log.Fatal(err)
}
fmt.Println(buf.String())
}
Output: memclr(p, 24). Same idea, scaled up. The source templates are ordinary Go that the toolchain can compile and type-check, while the generator performs structural rewrites instead of textual substitution. An AST rewrite can still produce invalid or incorrect code, so the generated result must still be formatted, compiled, and tested. Reading the runtime source turns up more of these techniques than you might expect.
If something regresses
The Go team expects no regressions, and Go 1.27 includes a temporary opt-out:
GOEXPERIMENT=nosizespecializedmalloc go build ./...
The Go 1.27 release notes say this setting is expected to be removed in Go 1.28. If flipping it helps your workload, file an issue. Instruction-cache behavior depends on what else is running in your hot loops, so real workloads can expose effects that a benchmark suite misses.
For allocation work that needs real attention, the Go Garbage Collector Optimization Guide is still the reference. Removing an unnecessary allocation can save both allocation time and later garbage-collector work. Size specialization reduces the cost of allocations that remain, in the same vein as the Go 1.24 performance improvements: incremental runtime improvements that require no source changes.