| name | go-perf-allocator-internals |
| description | What a Go heap allocation costs — the ~70 size classes and rounding waste (unsafe.Sizeof vs real slot), the tiny allocator packing sub-16B pointer-free objects, the lock-free per-P mcache → mcentral → mheap hierarchy, and scannable vs noscan spans (pointer-free objects cut GC cost; "1 alloc/op" hides different costs). Fires on "still slow after removing allocs", "reduce GC pressure", "size class", "how does Go allocate". Routes to go-perf-escape-analysis, go-perf-sync-pool, go-perf-gc-tuning, go-perf-struct-layout. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Allocator Internals
What escapes to the heap is go-perf-escape-analysis; the measure-first loop is
go-performance. This skill owns the layer beneath both: once a value is heap-bound, what does
that allocation actually cost? The answer is rarely "one alloc." It is a size class (with rounding
waste), a span (scannable or not), and — the part that dominates — a recurring tax the garbage
collector pays on every live pointer the object holds. An LLM that reasons only in allocs/op misses
all three.
All Go below builds clean under gofmt, go vet, and go test on Go 1.26.
1. The Allocation Hierarchy — mcache → mcentral → mheap
Go's allocator "was originally based on tcmalloc, but has diverged quite a bit"
(src/runtime/malloc.go:6-7).
Small objects climb a four-level hierarchy
(malloc.go:17-47):
mcache — "a per-P cache of mspans with free space." Step 1 is: round the size to a size
class, look in this P's mcache, scan the span's free bitmap for a slot. Critically: "This can
all be done without acquiring a lock"
(malloc.go:24,27-33). The
mcache is "Per-thread (in Go, per-P) … No locking needed because it is per-thread (per-P)"
(src/runtime/mcache.go:14-16).
mcentral — when the P's span is full, it gets a fresh span from the central list for that
size class. "Obtaining a whole span amortizes the cost of locking the mcentral"
(malloc.go:35-39) — so the lock
is paid once per span, not once per object.
mheap — when the central list is empty, it pulls a run of pages (page size 1<<13 = 8192
bytes; PageShift = 13 in
sizeclasses.go:91).
- OS — when the heap is exhausted, it maps "at least 1MB" from the OS, amortizing the syscall
(
malloc.go:44-47).
The mental model: the common-case allocation is a lock-free bump/bitmap scan in a per-P cache —
genuinely cheap. The hierarchy exists to make that fast path common, so the allocation itself is
rarely the expensive part. The expense is what the object then costs the GC (§4). Large objects
(> 32 KB) skip the cache entirely: "Allocating and freeing a large object uses the mheap directly,
bypassing the mcache and mcentral"
(malloc.go:62-63).
2. Size Classes and Rounding Waste
"Small allocation sizes (up to and including 32 kB) are rounded to one of about 70 size classes, each
of which has its own free set of objects of exactly that size"
(malloc.go:11-13). The generated
table has NumSizeClasses = 68 and MaxSmallSize = 32768
(sizeclasses.go:86,90).
A request is rounded up to the next class, and the slack is wasted. From the table header
(sizeclasses.go:6-24):
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 18 256 8192 32 0 5.86% 256
Two kinds of waste live here. Rounding waste: a 33-byte struct rounds up to class 5 (48 bytes) —
15 bytes (31%) gone. A 17-byte object jumps from class 2 (16) to class 3 (24). Tail waste: 8192
isn't a multiple of every class size, so class 3 (24 B) leaves 8 bytes unused at the span tail
(sizeclasses.go:9).
The max waste column is the worst-case fraction lost (87.50% for class 1, where a 1-byte object
occupies an 8-byte slot).
The consequence for an LLM: unsafe.Sizeof(x) is the struct size, not the allocated size. A
struct that unsafe.Sizeof reports as 40 bytes is allocated from class 5 (48 bytes). Shrinking a
field from 40→33 bytes changes nothing — both round to 48. Shrinking 40→32 drops it to class 4 and
saves a real 16 bytes per object. Size reductions only matter when they cross a class boundary.
Verify the real footprint by mapping unsafe.Sizeof onto the table above, not by reading the struct.
3. The Tiny Allocator
Objects smaller than 16 bytes that contain no pointers take a separate path. "Tiny allocator
combines several tiny allocation requests into a single memory block … The subobjects must be noscan
(don't have pointers), this ensures that the amount of potentially wasted memory is bounded"
(malloc.go:1226-1230). The block
size is 16 bytes (TinySize = 16,
sizeclasses.go:94).
"The main targets of tiny allocator are small strings and standalone escaping variables. On a json
benchmark the allocator reduces number of allocations by ~12% and reduces heap size by ~20%"
(malloc.go:1249-1252). Several
escaping *int-sized scalars, small []byte, single runes, etc. are bump-packed into one 16-byte
slot.
The gotcha: the packing only happens for pointer-free values. The moment a small struct gains
a pointer field (a *T, string, slice, map, channel, or interface), it leaves the tiny path and
takes a full size-class slot — and becomes scannable (§4). A tiny boxed int escaping into an any
is two costs in one: the box, and the loss of tiny-packing if it carried a pointer.
4. Scannable vs Noscan Spans — the Real Cost
This is the section that changes how you write structs. Every size class exists twice — a scan
variant and a noscan variant: "Each size class has a noscan spanClass and a scan spanClass. The
noscan spanClass contains only noscan objects, which do not contain pointers and thus do not need
to be scanned by the garbage collector"
(src/runtime/mheap.go, spanClass doc;
numSpanClasses = NumSizeClasses << 1). The compiler computes pointer-freeness from the type and
routes the allocation: pointer-free → mallocgcSmallNoscan; has-pointers → a scan path
(malloc.go:1125-1158,1364).
Why this dominates: the GC's CPU cost is "a fixed cost per cycle, and a marginal cost that scales
proportionally with the size of the live heap … GC CPU time for cycle N = Fixed cost + average
cost per byte × live heap" (GC Guide). And "the GC must interact with
nearly every pointer it sees, so using indices into a slice, for example, instead of pointers, can
aid in reducing GC costs" (GC Guide). A noscan object contributes
its bytes to the live heap but the GC never walks it — zero marking work, every cycle, for its
whole lifetime.
"Pointer-free values are segregated from other values. As a result, it may be advantageous to
eliminate pointers from data structures that do not strictly need them, as this reduces the cache
pressure the GC exerts" (GC Guide). Concretely:
- A
[1000]Node where Node has a *Node field is scanned in full every GC cycle. Replace the
pointer with an int32 index into a backing slice and the array becomes noscan — the GC stops
walking it. (Field-ordering and pointer-elimination mechanics → go-perf-struct-layout.)
[]byte, []int, string bodies, and structs of only fixed-width scalars are noscan. []*T,
[]string, maps, and structs holding any pointer are scannable.
- The
mcache even tracks this separately: scanAlloc is "bytes of scannable heap allocated"
(mcache.go:27).
This is the highest-leverage allocator fact: two structs with identical allocs/op and identical
size class can differ by unbounded GC cost depending on whether they carry pointers.
5. The Cost Model to Reason With
When asked "why is this still slow after I removed allocations," or "how do I cut GC pressure," reason
in this order — not in allocs/op:
- The allocation is usually cheap. Fast path = lock-free per-P bump/bitmap (§1). If a benchmark
shows
1 allocs/op and is still slow, the alloc count is not the story.
- The size class sets the byte cost. Round
unsafe.Sizeof up to the next class (§2). "Fewer
allocs" of a class-67 (32 KB) object can cost more live-heap bytes than "more allocs" of class-1
objects.
- Scannability sets the recurring GC cost. A pointer-bearing object taxes every GC cycle it
survives; a noscan object of the same size taxes none (§4). This is where "1 alloc" hides wildly
different real costs.
- Allocation rate drives GC frequency. "GC CPU costs are largely determined by allocation
rate and the cost per byte to scan memory" (GC Guide). Halving the
allocation rate halves how often the per-byte scan tax is paid. Tuning
GOGC/GOMEMLIMIT is
go-perf-gc-tuning; reuse via pooling is go-perf-sync-pool; both attack rate.
The synthesis: the cheapest object is a pointer-free one that fits a small size class and is
allocated rarely. "Fewer allocations" is a proxy; "fewer scannable bytes allocated per second" is
the quantity the GC actually charges for.
6. Don't
- Don't equate
allocs/op with cost. One class-67 (32 KB) alloc ≫ one class-1 (8 B) alloc in
bytes, and a pointer-bearing object ≫ a noscan one in recurring GC work, at the same alloc count
(§4).
- Don't trust
unsafe.Sizeof as the allocated size. The object occupies the next size class up;
shrinking within a class saves nothing (§2,
sizeclasses.go).
- Don't add a pointer field to a hot, long-lived struct without counting the GC scan cost. It
flips the object from a noscan span to a scan span and taxes every GC cycle (§4) — prefer an index
into a slice (GC Guide).
- Don't assume small means tiny-packed. The tiny allocator packs only sub-16-byte pointer-free
objects; one pointer field opts out
(
malloc.go:1226-1230).
- Don't assume "many small allocs" ≈ "one big alloc." Each small object pays its own size-class
rounding and, if scannable, its own per-object scan; one big noscan buffer may be far cheaper than
many small scannable nodes.
- Don't reach for the GC knobs first. The usual lever is fewer/smaller/pointer-free allocations
(rate × scannable bytes), not
GOGC — that routes to go-perf-gc-tuning only after the rate
is addressed.
7. Routing to Related Skills
This plugin (go-perf-* depth):
go-perf-escape-analysis — whether a value heap-allocates at all (reading -gcflags=-m); this
skill takes over once it does.
go-perf-struct-layout — field ordering, padding, fieldalignment, and pointer-elimination
mechanics that make a struct noscan and shrink its size class.
go-perf-sync-pool — reusing allocations to cut allocation rate (the §5 lever) via per-P pools +
victim cache.
go-perf-gc-tuning — GOGC/GOMEMLIMIT, the pacer, gctrace; act here only after allocation
rate and scannability are addressed.
go-perf-slices / go-perf-maps — growth internals and prealloc, the most common
allocation-rate sources.
go-perf-methodology — whether this is worth doing and how to prove the win (heap profile
inuse vs alloc, benchstat).
Parent / sibling marketplace (go-* correctness):
go-performance — the measure-first overview and reducing-allocs headline this skill deepens.
8. Reference Files
Allocator anti-patterns in LLM-generated Go, each with a wrong/right contrast and source citation:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance (runtime file paths + line ranges) for every claim:
${CLAUDE_SKILL_DIR}/references/sources.yaml