Go 1.26: Memory Allocation Optimization Analysis

Boosting Small Object Allocation Speed by 30%

分享
Go 1.26: Memory Allocation Optimization Analysis
Generate By ChatGPT

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/arm64

Small 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.txt

Benchmark comparison results:

Golang 1.26 vs 1.24 Memory Allocation Benchmark
benchmark result
  • 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
  • mallocgc is 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”]

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:

  1. Type safety: The template is valid Go code and fully type-checked.
  2. 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 / size become (offset * magic) >> shift.

2. Static Specialization and Forced Inlining (mkmalloc.go)

This is where most of the performance comes from.

  • Constant folding
     Template variables like elemsize_ are replaced with literals (e.g. 32), allowing dead code elimination to remove unnecessary branches.
  • Manual AST inlining
     To bypass compiler inlining limits, mkmalloc directly injects the AST of helper functions like nextFreeFast and writeHeapBitsSmall into the generated allocator.
     The result: zero function calls and a straight-line instruction sequence.
  • Fast dispatch tables
     malloc_tables_generated.go builds arrays like mallocNoScanTable, 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: newobjectmallocgc → checks → size class → span → alloc
  • New: mallocgcSmallNoScanSC4mcache.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:

  1. Fixed size at compile time
  2. Object size < 512 bytes
  3. GOEXPERIMENT=sizespecializedmalloc enabled (default in 1.26)
  4. 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

  1. AST-based template generation
  2. SSA-based allocation dispatch
  3. Manual AST-level inlining
  4. Constant folding enabling intrinsic zeroing

Advice for Developers

This is a free performance:

  1. Upgrade to Go 1.26
  2. No change code
  3. Prefer fixed-size small objects in hot paths

While keeping Go simple, the language continues to push performance boundaries through increasingly sophisticated compiler techniques.

References