# Go Perf Allocator Internals

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

- Skill: `ctoth/go-perf-allocator-internals` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-allocator-internals`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-allocator-internals/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-allocator-internals

---


# 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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)).
Small objects climb a four-level hierarchy
([`malloc.go:17-47`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)):

- **`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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)). 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`](https://github.com/golang/go/blob/master/src/runtime/mcache.go)).
- **`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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)) — 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`](https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go)).
- **OS** — when the heap is exhausted, it maps "at least 1MB" from the OS, amortizing the syscall
  ([`malloc.go:44-47`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)).

**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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)).

---

## 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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)). The generated
table has `NumSizeClasses = 68` and `MaxSmallSize = 32768`
([`sizeclasses.go:86,90`](https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go)).
A request is rounded **up** to the next class, and the slack is wasted. From the table header
([`sizeclasses.go:6-24`](https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go)):

```
// 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`](https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go)).
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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)). The block
size is 16 bytes (`TinySize = 16`,
[`sizeclasses.go:94`](https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go)).

"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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)). 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](https://github.com/golang/go/blob/master/src/runtime/mheap.go);
`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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)).

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](https://go.dev/doc/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](https://go.dev/doc/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](https://go.dev/doc/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`](https://github.com/golang/go/blob/master/src/runtime/mcache.go)).

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

1. **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.
2. **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.
3. **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.
4. **Allocation *rate* drives GC *frequency*.** "GC CPU costs are largely determined by allocation
   rate and the cost per byte to scan memory" ([GC Guide](https://go.dev/doc/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`](https://github.com/golang/go/blob/master/src/internal/runtime/gc/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](https://go.dev/doc/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`](https://github.com/golang/go/blob/master/src/runtime/malloc.go)).
- **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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance (runtime file paths + line ranges) for every claim:

[references/sources.yaml](references/sources.yaml)

