Go 1.26: Memory Allocation Optimization Analysis
Boosting Small Object Allocation Speed by 30%
Go 1.26 RC1 has been released, which means the official release is just around the corner. In the Go 1.26 release notes, there is one small line that particularly stands out:
reducing the cost of some small (<512 byte) memory allocations by up to 30%.
This means that, without changing a single line of code, we can get a noticeable performance boost in specific scenarios.
This article takes a deep dive into how Go 1.26 achieves this memory allocation optimization — from benchmark results and assembly analysis to runtime and compiler implementation details — so you can fully understand this compiler–runtime co-design optimization.
Test platform
goos: darwin
goarch: arm64
pkg: blog-example/go1.26/malloc
cpu: Apple M4
✗ go version
go version go1.24.9 darwin/arm64
✗ gotip version
go version go1.26-devel_f8ee0f84 Fri Jan 2 19:26:36 2026 -0800 darwin/arm64Small Object Allocation Benchmarks
To validate this improvement, I wrote a simple benchmark:
// https://gist.github.com/hxzhouh/f662b3b149e10106f6a30a5355883919
package main
import (
"testing"
)
// Structs of different sizes
type Small16 struct {
A int64
B int64
}
type Small32 struct {
A, B, C, D int64
}
type Small64 struct {
Data [8]int64
}
// Global sinks to prevent full optimization
var (
sink16 *Small16
sink32 *Small32
sink64 *Small64
)
// --- 16-byte allocation ---
func BenchmarkAlloc16(b *testing.B) {
for i := 0; i < b.N; i++ {
// In Go 1.26, this call is replaced with a specialized mallocgcSmallNoscan16
sink16 = &Small16{A: int64(i), B: int64(i)}
}
}
// --- 32-byte allocation ---
func BenchmarkAlloc32(b *testing.B) {
for i := 0; i < b.N; i++ {
sink32 = &Small32{A: 1, B: 2, C: 3, D: 4}
}
}
// --- 64-byte allocation ---
func BenchmarkAlloc64(b *testing.B) {
for i := 0; i < b.N; i++ {
sink64 = &Small64{}
}
}
// --- Allocation with pointers (Scan path) ---
type WithPtr struct {
A *int
B int
}
var sinkPtr *WithPtr
func BenchmarkAllocWithPtr(b *testing.B) {
val := 42
for i := 0; i < b.N; i++ {
sinkPtr = &WithPtr{A: &val, B: i}
}
}Run the benchmarks:
gotip test -bench=. -count=10 > new_results.txt
go test -bench=. -count=10 > old_results.txtBenchmark comparison results:

- 16-byte objects: 7.24ns → 5.08ns (~30% faster)
- 32-byte objects: 9.17ns → 6.03ns (~34% faster)
- 64-byte objects: 10.08ns → 7.66ns (~24% faster)
- Objects with pointers: 9.77ns → 5.77ns (~41% faster)
These numbers clearly demonstrate the significant improvement in small-object allocation in Go 1.26.
Let’s analyze the assembly differences using a simpler example:
type Data32 struct {
a, b, c, d int64
}
//go:noinline
func createData() *Data32 {
// In Go 1.26, this is optimized into a specialized allocator call
return &Data32{a: 1, b: 2, c: 3, d: 4}
}Inspect the assembly:
go build -gcflags="-S" malloc.go 2>&1 |grep CALL
gotip build -gcflags="-S" malloc.go 2>&1 |grep CALL
In Go 1.24, the code calls the generic runtime.newobject(SB).
In Go 1.26, it directly calls the specialized runtime.mallocgcSmallNoScanSC4(SB).
This is the root cause of the performance difference.
The “All-Purpose” Burden of the Generic Allocator
In Go 1.25 and earlier, almost all heap allocations (such as new(T)) eventually funnel through a single generic entry point: runtime.mallocgc.
This function is potent — but that power comes with overhead. It must handle:
- All sizes: from 8 bytes to multiple megabytes
- All types: scan vs. noscan
- All states: GC marking, GC assist, zeroing, etc.
Because it must be “universal,” mallocgc contains complex logic and multiple runtime checks. Even allocating a 16-byte integer requires recomputing the size class, loading type metadata, and executing various branches. This runtime polymorphism adds measurable overhead.
In Go 1.26, the compiler no longer blindly calls the generic allocator. Instead, it directly invokes size- and type-specialized allocation functions whenever possible.
Go 1.26’s Secret Weapon: Specialized Malloc
Go 1.26 introduces Size-Specialized Malloc, which fundamentally resolves this structural inefficiency.
Given that:
- Size class boundaries are static
- Small object sizes are known at compile time
- Pointer information is part of the type system
There is no need to recompute these decisions at runtime.
So Go 1.26 makes a critical change:
It generates a dedicated allocation template for each size class and lets the compiler select it directly during the SSA phase.
From this point on:
- Size class selection is no longer a runtime responsibility
mallocgcis no longer on the hot path for small objects- The runtime only executes an already-optimal plan
This is what it means to:
Shift allocation decisions left — from runtime to compile time.
At its core, this is a textbook example of compiler–runtime co-design optimization.
High-level flow:
![graph TD A[“Go source: &Data32{…}”] → B[“Compiler SSA phase”] B → C{“Size known at compile time?”} C — “YES” → D{“< 512B?”} C — “NO” → E[“runtime.newobject”] D — “YES” → F[“Specialized call: mallocgcSmallNoScanSC4”] D — “NO” → E F → G[“Direct mcache.alloc[4] access”] G → H[“Fast allocation + inline zeroing”]](https://cdn-images-1.medium.com/max/800/1*77k9glp6W_7jJOBritnliQ.png)
Template System: AST-Level “Surgery”
Rather than using string-based templates like text/template, Go 1.26 adopts a much more hardcore approach: AST (Abstract Syntax Tree) manipulation.
The core templates live in src/runtime/malloc_stubs.go. Functions like mallocStub look like ordinary Go code, but they contain special placeholder variables such as elemsize_, sizeclass_, and noscanint_.
// src/runtime/malloc_stubs.go (simplified)
func mallocStub() unsafe.Pointer {
// sizeclass_ will be replaced with a constant (e.g. 4)
sizeclass := sizeclass_
// ...
}This approach has two major advantages:
- Type safety: The template is valid Go code and fully type-checked.
- Precision: The generator operates on syntax nodes, allowing exact replacement of identifiers without the risk of string-based errors.
Code Generation: The Unsung Hero mkmalloc
Located in src/runtime/_mkmalloc/, mkmalloc.go and mksizeclasses.go form the “factory” behind this optimization. They run during toolchain build time and generate the final specialized allocator code.
1. Precision Size Table Computation (mksizeclasses.go)
This step computes the optimal size classes:
- Fragmentation control: It simulates layouts to keep tail waste under 12.5%.
- Division elimination: It precomputes magic numbers so runtime divisions like
offset / sizebecome(offset * magic) >> shift.
2. Static Specialization and Forced Inlining (mkmalloc.go)
This is where most of the performance comes from.
- Constant folding
Template variables likeelemsize_are replaced with literals (e.g.32), allowing dead code elimination to remove unnecessary branches. - Manual AST inlining
To bypass compiler inlining limits,mkmallocdirectly injects the AST of helper functions likenextFreeFastandwriteHeapBitsSmallinto the generated allocator.
The result: zero function calls and a straight-line instruction sequence. - Fast dispatch tables
malloc_tables_generated.gobuilds arrays likemallocNoScanTable, mapping size classes directly to specialized functions.
var mallocNoScanTable = [513]func(...) unsafe.Pointer{
// ...
mallocgcSmallNoScanSC4, // index 4
// ...
}Compiler SSA Optimization
During compilation, SSA performs logic similar to:
func (s *state) expr(n *ir.Node) *ssa.Value {
switch n.Op() {
case ir.ONEW:
typ := n.Type().Elem()
if typ.Size() <= 512 && typ.Size() > 0 {
sizeClass := sizeToClass(typ.Size())
if !typ.HasPointers() {
return s.newValue1(ssa.OpMallocSmallNoScanSC, sizeClass, ...)
}
// Scan variants handled similarly
}
return s.newValue0(ssa.OpNewObject, ...)
}
}Size Class System Explained
Go’s allocator follows the TCMalloc design and uses size classes to fight fragmentation.
67 Size Classes
Go 1.26 defines 67 size classes (0–66):
const _NumSizeClasses = 67
var class_to_size = [_NumSizeClasses]uint16{
0,
8,
16,
24,
32,
48,
// ...
}Allocation Strategy

Why Go 1.26 Is Faster
Fewer Indirections
- Old:
newobject→mallocgc→ checks → size class → span → alloc - New:
mallocgcSmallNoScanSC4→mcache.alloc[4]→ alloc
Eliminated Type Checks
Specialized allocators hardcode size and pointer properties.
Inline Zeroing
With compile-time constants, memclrNoHeapPointers(x, 32) becomes a few MOV instructions—no function call.
Better CPU Cache Behavior
Direct mcache.alloc[N] access improves predictability and cache locality.
Applicability and Limitations
Requirements:
- Fixed size at compile time
- Object size < 512 bytes
GOEXPERIMENT=sizespecializedmallocenabled (default in 1.26)- No sanitizers (race/asan/msan/valgrind), non-Plan9
Conclusion
Go 1.26’s memory allocation optimization is a textbook example of compiler–runtime co-design.
Key Takeaways
- AST-based template generation
- SSA-based allocation dispatch
- Manual AST-level inlining
- Constant folding enabling intrinsic zeroing
Advice for Developers
This is a free performance:
- Upgrade to Go 1.26
- No change code
- Prefer fixed-size small objects in hot paths
While keeping Go simple, the language continues to push performance boundaries through increasingly sophisticated compiler techniques.