Size Classes and Span Management

Go’s allocator never hands the operating system’s pages directly to a make([]byte, 24) call. Instead it rounds every small request up to one of a fixed set of size classes — exact byte sizes such as 8, 16, 24, 32, 48, 64, … up to 32 KiB — and serves the request from a span (runtime.mspan), a run of contiguous 8 KiB pages carved entirely into objects of one size class. Rounding wastes a little memory per object (internal fragmentation) but buys an enormous simplification: every object in a span is interchangeable, so the free list collapses into a per-span bitmap, allocation is a bit scan, and freeing is a bit clear. As of Go 1.26 (Go 1.26.0 released 2026-02-10; current stable Go 1.26.3, 2026-05-07) there are 68 size-class entries (NumSizeClasses = 68, verified against the go1.26.0 tag), of which class 0 is a sentinel, leaving 67 real small-object classes; the table is machine-generated and lives in internal/runtime/gc/sizeclasses.go (sizeclasses.go).

This note covers what the size classes are, why they were chosen the way they were, and how a span is structured so an object can be allocated and freed without a per-object linked list. The three-tier cache that holds spans — mcache, mcentral, mheap — is the subject of mcache mcentral and mheap; the radix-tree allocator that hands raw pages to spans is Page Allocator; the sub-16-byte combining path is Tiny Allocator. Read this note first: size classes and spans are the vocabulary the rest of the allocator speaks.

Mental Model

Think of the heap as a warehouse of fixed-shelf racks. Each rack (a span) is dedicated to boxes of exactly one size. When you ask for a box that holds 20 bytes, the warehouse does not build a custom 20-byte box; it sends you to the 24-byte rack (the smallest class that fits) and gives you one slot. You waste 4 bytes, but the clerk never has to measure anything — every slot on that rack is identical, so “find a free slot” is just “scan this rack’s occupancy bitmap for a zero bit.”

The allocator splits all requests into three regimes by size:

  • Tiny (size < 16 bytes, no pointers): handled by the Tiny Allocator, which sub-divides a single 16-byte slot among several even-smaller objects.
  • Small (16 ≤ size ≤ 32768 bytes): rounded up to a size class and served from a span via the mcache/mcentral hierarchy. This is the common case and the focus of this note.
  • Large (size > 32768 bytes): served by a dedicated span sized to the request, allocated straight from the mheap, bypassing the per-P cache entirely (mcache.allocLarge, mcache.go).
flowchart TD
    R["allocation request<br/>size bytes"] --> Q{size?}
    Q -- "&lt; 16, noscan" --> T["Tiny allocator<br/>(combine into 16B slot)"]
    Q -- "16 .. 32768" --> S["round up to a<br/>size class (1..67)"]
    Q -- "&gt; 32768" --> L["large span<br/>sized to request"]
    S --> SP["span for that size class:<br/>N pages carved into<br/>fixed-size slots + alloc bitmap"]
    SP --> SLOT["scan bitmap → free slot"]
    L --> LSPAN["dedicated span,<br/>one object"]

Diagram: the three size regimes. The insight is that “size class” is purely a small-object concept — tiny objects are packed below the smallest class, and large objects get a bespoke span and skip the class table entirely. Only the middle band pays the size-class rounding tax in exchange for bitmap-based allocation.

The Size-Class Table

The canonical table is the comment block at the top of internal/runtime/gc/sizeclasses.go, reproduced and verified against the Go master tree. Each row is class, bytes/obj, bytes/span, objects, tail waste, max waste, min align:

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
   ...
   31        896        8192        9         128     15.52%        128
   32       1024        8192        8           0     12.40%       1024
   ...
   44       4096        8192        2           0     15.60%       4096
   ...
   51       8192        8192        1           0     15.61%       8192
   ...
   67      32768       32768        1           0     12.50%       8192

Reading the columns precisely:

  • bytes/obj — the exact size every object in this class occupies. SizeClassToSize in the generated file is the array form: {0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, ...} (sizeclasses.go). A request for 20 bytes rounds up to class 3 (24 bytes); a request for 1000 bytes rounds up to class 32 (1024 bytes).
  • bytes/span — the total size of a span dedicated to this class. For small classes it is one page (8192 bytes); for larger classes it is several pages so that some whole number of objects fits with acceptable waste. SizeClassToNPages records the page count per class.
  • objectsbytes/span ÷ bytes/obj, rounded down: how many slots a span of this class holds.
  • tail wastebytes/span − objects × bytes/obj: the unused bytes at the end of the span because the object size does not evenly divide the span size. Class 3 (24-byte objects, 8192-byte span) fits 341 objects = 8184 bytes, leaving 8 bytes of tail waste.
  • max waste — the worst-case fraction of a span that can be wasted, combining tail waste with the rounding gap for the smallest request that maps to this class. Class 1 (8-byte objects) has 87.50% max waste because a 1-byte allocation rounds up to 8 bytes — 7 of 8 bytes wasted.
  • min align — the alignment guarantee. Larger classes are naturally more aligned because their sizes are larger powers-of-two-ish numbers.

Why these particular numbers — the generator

The table is not hand-written. It is produced by mksizeclasses.go (in src/runtime/_mkmalloc/), and the file carries a // Code generated ... DO NOT EDIT. banner (sizeclasses.go, line 1). The generator searches for a set of classes that satisfies several competing constraints simultaneously:

  1. Bounded waste. No class may waste more than ~12.5% on average for requests that land on it (the spacing between adjacent classes widens geometrically so the relative gap stays roughly constant — 8→16 is +100%, but 16384→18432 is only +12.5%).
  2. Alignment. Object sizes are chosen so objects within a span are properly aligned for the largest primitive type they could hold.
  3. Small page counts. A span should use the fewest pages that still yields low tail waste. The generator picks the page count per class to keep the wasted tail small — hence class 35 uses 2 pages (16384 bytes) for 1408-byte objects (11 objects, 896 tail waste) where 1 page would fit only 5 objects with worse proportional waste.

The fast-path lookups are also pre-computed arrays so that rounding a request to a class is a couple of table indexes, not a loop. SizeToSizeClass8 maps size/8 to a class for requests up to 1024 bytes (SmallSizeMax), and SizeToSizeClass128 maps (size−1024)/128 to a class for the 1024–32768 range. The relevant constants — SmallSizeDiv = 8, LargeSizeDiv = 128, SmallSizeMax = 1024, MaxSmallSize = 32768 — are defined in the generated sizeclasses.go (sizeclasses.go). The malloc-header constants used by roundupsize (MallocHeaderSize = 8, MinSizeForMallocHeader = goarch.PtrSize * goarch.PtrBits) live in a sibling file, internal/runtime/gc/malloc.go — both files are part of the same internal/runtime/gc package, which is where the size-class tables and heap-geometry constants were consolidated as of Go 1.26.

The exact figure is verified against the go1.26.0 tag: NumSizeClasses = 68, of which class 0 is the conventional sentinel, leaving 67 real small-object classes. The top-of-file comment in runtime/malloc.go still says “about 70 size classes” — that is an intentional informal approximation in prose, not a contradiction of the precise generated constant.

The roundupsize Path

roundupsize in runtime/msize.go is the function that converts a requested byte count into the size the allocator will actually reserve. As of Go 1.26 the size-class tables moved into the internal/runtime/gc package, and msize.go is now a thin 36-line file (msize.go):

func roundupsize(size uintptr, noscan bool) (reqSize uintptr) {
	reqSize = size
	if reqSize <= maxSmallSize-gc.MallocHeaderSize {
		// Small object.
		if !noscan && reqSize > gc.MinSizeForMallocHeader {
			reqSize += gc.MallocHeaderSize
		}
		if reqSize <= gc.SmallSizeMax-8 {
			return uintptr(gc.SizeClassToSize[gc.SizeToSizeClass8[divRoundUp(reqSize, gc.SmallSizeDiv)]]) - (reqSize - size)
		}
		return uintptr(gc.SizeClassToSize[gc.SizeToSizeClass128[divRoundUp(reqSize-gc.SmallSizeMax, gc.LargeSizeDiv)]]) - (reqSize - size)
	}
	// Large object. Align reqSize up to the next page.
	reqSize += pageSize - 1
	if reqSize < size {
		return size
	}
	return reqSize &^ (pageSize - 1)
}

Line by line:

  • reqSize <= maxSmallSize - gc.MallocHeaderSizemaxSmallSize is 32768; MallocHeaderSize is 8. The malloc header is an 8-byte word prepended to pointer-bearing objects above a threshold so the GC can find the type’s pointer bitmap (see mcache mcentral and mheap for how headers vs. in-span heap bits are decided). Subtracting it from the cap means a scannable object near the size limit still has room for its header.
  • if !noscan && reqSize > gc.MinSizeForMallocHeader { reqSize += gc.MallocHeaderSize } — if the object contains pointers (!noscan) and is large enough to need an out-of-line header, the function pads the request by 8 bytes so the class chosen leaves room for the header.
  • gc.SizeToSizeClass8[divRoundUp(reqSize, gc.SmallSizeDiv)] — for requests up to ~1016 bytes, divide by 8 (rounding up) and index the small lookup array to get the class number; SizeClassToSize[...] turns the class number back into the rounded byte size.
  • - (reqSize - size) — subtracts the header padding back out, because mallocgc adds the header itself; roundupsize reports the object size visible to the caller.
  • The else branch handles the 1024–32768 band with the /128 divisor and the second lookup array.
  • Large path: reqSize += pageSize - 1; reqSize &^ (pageSize - 1) rounds the size up to a multiple of 8192 bytes. There is no size class — the span is exactly as many pages as the object needs. The if reqSize < size check catches integer overflow on a near-uintptr-max request.

Span Anatomy — runtime.mspan

A span is the unit of bookkeeping. The struct (runtime/mheap.go, lines 422–516) carries everything needed to allocate, free, sweep, and GC-scan its objects (mheap.go):

type mspan struct {
	_    sys.NotInHeap
	next *mspan      // intrusive list links
	prev *mspan
 
	startAddr uintptr // address of first byte of span (s.base())
	npages    uintptr // number of 8 KiB pages in the span
 
	freeindex uint16  // scan allocBits from here for the next free slot
	nelems    uint16  // number of object slots in the span
	freeIndexForScan uint16 // freeindex as the GC scanner sees it
 
	allocCache uint64 // cached, inverted slice of allocBits at freeindex
	allocBits  *gcBits // 1 bit per slot: allocated?
	gcmarkBits *gcBits // 1 bit per slot: marked live by the GC?
 
	sweepgen   uint32     // sweep-generation state machine
	allocCount uint16     // number of allocated objects
	spanclass  spanClass  // size class + noscan flag (one uint8)
	state      mSpanStateBox // mSpanInUse / mSpanFree / mSpanManual / mSpanDead
	needzero   uint8      // free slots need zeroing before reuse
	elemsize   uintptr    // bytes per object (from sizeclass, or npages*pageSize)
	limit      uintptr    // end of usable data in span
	largeType  *_type     // type pointer for a large (single-object) span
}

The key fields for span management:

  • spanclass packs two things into one byte: the size class (bits 1–7) and a noscan flag (bit 0). numSpanClasses = NumSizeClasses << 1 = 136every size class exists twice, once for objects that contain pointers (scan) and once for pointer-free objects (noscan) (mheap.go, makeSpanClass). Segregating scan from noscan lets the GC skip noscan spans entirely during marking.
  • allocBits is a bitmap with one bit per slot: 1 = allocated, 0 = free. Freeing an object is not immediate — the sweeper clears the bit later (see mcache mcentral and mheap).
  • allocCache is a 64-bit cache of allocBits near freeindex, inverted so a free slot is a 1 bit. Allocation is then ctz (count-trailing-zeros) on allocCache — a single instruction finds the next free slot. When the cache is exhausted it is refilled from allocBits (mcentral.cacheSpan calls refillAllocCache, mcentral.go).
  • freeindex is the high-water mark: the allocator never scans below it, because everything below is known allocated. When freeindex == nelems the span is full.
  • gcmarkBits is the GC’s mark bitmap; at the end of a cycle the sweeper swaps gcmarkBits into allocBits, atomically turning “marked live” into “still allocated.”

The _ sys.NotInHeap marker is important: mspan structs are not themselves on the GC heap. They are allocated from a fixalloc free-list allocator (mheap.spanalloc), which is the same off-heap mechanism that backs mcache structs — see Memory Mapping and OS Interaction for fixalloc and the off-heap metadata story.

Allocating a Slot From a Span

Given a span with free slots, allocating is purely arithmetic and bit manipulation — no locks, no OS calls:

  1. Look at allocCache. If non-zero, ctz gives the bit offset of a free slot relative to freeindex.
  2. The slot’s address is startAddr + slotIndex * elemsize.
  3. Set the bit in allocBits, advance freeindex past the slot, increment allocCount.
  4. If allocCache is zero, refill it from the next 64-bit window of allocBits; if freeindex has reached nelems, the span is full and the caller (mcache) must refill from the mcentral.

This is nextFreeFast / nextFreeIndex in the runtime. Because the span and its bitmaps are owned exclusively by one P’s mcache, the entire sequence runs without a single atomic instruction on the common path — the central performance property the size-class design exists to enable.

Failure Modes and Common Misunderstandings

“Size classes waste memory.” They do — internal fragmentation is real. A make([]byte, 33) consumes a 48-byte slot (class 5), wasting 15 bytes. But the max waste column shows the worst case per class is bounded (~12.5% average for mid-size classes). The alternative — exact-fit allocation — needs a per-object header and a general free list, which costs more in metadata and lock contention than the rounding costs in fragmentation. The trade was made deliberately; it descends from TCMalloc (TCMalloc design).

“32 KiB is the small/large boundary, so allocate just under it.” MaxSmallSize = 32768. An object of 32769 bytes becomes a large allocation with its own span and skips the mcache fast path — every such allocation locks the mheap. But the largest small classes (class 67 = 32768, with its own 32 KiB single-object span) are not especially efficient either: a 32 KiB span holds one object. The boundary is not a cliff to micro-optimize around; it is where bitmap management stops paying off.

“A span always holds many objects.” Not for large classes. Classes 51 (8192), 59 (16384), 64 (24576), 67 (32768) each hold exactly one object per span (the objects column is 1). For these the span is the object. Large allocations (>32 KiB) are the same idea taken further.

“Adjacent size classes differ by a constant.” They differ geometrically, not arithmetically — 8, 16, 24, 32 (still arithmetic at the bottom for alignment), then 48, 64, 80, …, then 288, 320, 352, …, then 9472, 9728, 10240. The spacing widens so the relative rounding error stays roughly constant across the whole range.

Span-class confusion in profiling. runtime.MemStats and pprof heap profiles report by size class. Two objects of 17 bytes and 24 bytes both appear under the 24-byte class — they are physically indistinguishable once allocated. When chasing fragmentation, group by size class, not by requested size.

Alternatives and Comparison

  • General-purpose malloc (dlmalloc, ptmalloc/glibc). Uses bins of free chunks with boundary tags and coalescing. Handles arbitrary sizes with less internal fragmentation but needs per-chunk headers and is harder to make lock-free per-thread. Go chose the size-class model precisely because uniform spans make the per-P lock-free fast path simple.
  • TCMalloc. The direct ancestor. Go’s allocator “was originally based on tcmalloc, but has diverged quite a bit” (malloc.go, line 7). TCMalloc also uses size classes and per-thread caches; Go added GC integration (scan/noscan span split, mark bitmaps in the span) that a non-GC allocator does not need.
  • Slab allocators (Linux kernel kmem_cache). Same core idea — caches dedicated to one object size — but slabs are typically per-type and constructor-aware. Go’s spans are per-size, type-agnostic, which is why two unrelated 24-byte types share class 3.
  • jemalloc. Also size-class-segregated with per-thread arenas; uses runs (analogous to spans) and extents. The designs converged because size-class segregation is the dominant strategy for high-throughput allocators.

Production Notes

Rick Hudson’s ISMM 2018 keynote frames why this matters for a garbage-collected language: the allocator and collector are co-designed (ismmkeynote). Size-segregated spans give the GC a cheap way to find every object of a class (walk the span’s bitmap) and to mark without moving — Go’s collector is non-moving partly because spans make non-moving collection efficient.

Practical consequences for Go programmers:

  • Slice growth follows size classes. append’s growth heuristic interacts with roundupsize: when a slice’s backing array is regrown, the runtime rounds the new capacity up to a size class, so a slice’s cap after growth is often a size-class boundary, not a clean power of two. This is why cap can be, say, 24 or 48.
  • Struct size tuning. Shrinking a struct from 33 to 32 bytes drops it from class 5 (48 B) to class 4 (32 B) — a 33% memory reduction for that allocation. Field reordering to cut padding (see Struct Memory Layout and Alignment) pays off precisely at these class boundaries.
  • pprof size-class buckets. Heap profiles bucket by size class; a sudden jump between two classes in a profile usually means an object grew across a class boundary.

See Also