Go's Memory Allocator: 68 Size Classes, Tiny Allocators and the mcache Fast Path
Go rounds every small allocation up to one of 68 fixed size classes, serves them from a per-P mcache with a bit-scanning free list, and packs sub-16-byte pointer-free objects into shared 16-byte blocks.
TL;DR: Go never gives you the size you asked for. Every allocation up to 32 KiB is rounded up to one of 68 fixed size classes, served out of a per-P
mcachethat keeps one span per class, and that span is refilled from a per-classmcentralwhen it runs dry. Objects smaller than 16 bytes with no pointers are packed into shared 16-byte blocks by a dedicated “tiny” allocator.
Every Go program that allocates has to answer the same question ten million times a second: where does the next 24 bytes come from?
The answer is a stack of layers, and it is worth understanding because almost
every Go memory question — “why is my heap twice as big as my data”, “why is
append slow”, “why did the GC just run” — bottoms out in one of them.
From fastest to slowest:
- Tiny allocator — packs sub-16-byte pointer-free objects into a 16-byte block.
mcache— per-P, lock-free, one cached span per size class.mcentral— one per size class, shared between all Ps, holding partial and full spans.mheap— the global heap, handing out runs of pages.- Page allocator — tracks which pages of the address space are in use.
This article covers layers 1 through 3, plus what happens when you ask for more than 32 KiB.
The size class table
Everything starts with a table. The Go runtime has a fixed set of size
classes — NumSizeClasses is 68, counting a dummy entry at index 0 that
exists so that a class number doubles as an array index — and the constants
that describe them live in a small internal package:
// src/internal/runtime/gc/sizeclasses.go:84
const (
MinHeapAlign = 8
MaxSmallSize = 32768
SmallSizeDiv = 8
SmallSizeMax = 1024
LargeSizeDiv = 128
NumSizeClasses = 68
PageShift = 13
MaxObjsPerSpan = 1024
MaxSizeClassNPages = 10
TinySize = 16
TinySizeClass = 2
)The table itself is a flat array, index 0 unused so that the class number matches the array index directly:
// src/internal/runtime/gc/sizeclasses.go:98
var SizeClassToSize = [NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}Rendered as a table, the interesting part is the jump from 32 KiB, which is the last small size class. Class 67 is 32768 bytes and is the largest allocation the size-class machinery handles:
| Class | Bytes | Class | Bytes | Class | Bytes |
|---|---|---|---|---|---|
| 1 | 8 | 24 | 448 | 46 | 5376 |
| 2 | 16 | 25 | 480 | 47 | 6144 |
| 3 | 24 | 26 | 512 | 48 | 6528 |
| 4 | 32 | 27 | 576 | 49 | 6784 |
| 5 | 48 | 28 | 640 | 50 | 6912 |
| 6 | 64 | 29 | 704 | 51 | 8192 |
| 7 | 80 | 30 | 768 | 52 | 9472 |
| 8 | 96 | 31 | 896 | 53 | 9728 |
| 9 | 112 | 32 | 1024 | 54 | 10240 |
| 10 | 128 | 33 | 1152 | 55 | 10880 |
| 11 | 144 | 34 | 1280 | 56 | 12288 |
| 12 | 160 | 35 | 1408 | 57 | 13568 |
| 13 | 176 | 36 | 1536 | 58 | 14336 |
| 14 | 192 | 37 | 1792 | 59 | 16384 |
| 15 | 208 | 38 | 2048 | 60 | 18432 |
| 16 | 224 | 39 | 2304 | 61 | 19072 |
| 17 | 240 | 40 | 2688 | 62 | 20480 |
| 18 | 256 | 41 | 3072 | 63 | 21760 |
| 19 | 288 | 42 | 3200 | 64 | 24576 |
| 20 | 320 | 43 | 3456 | 65 | 27264 |
| 21 | 352 | 44 | 4096 | 66 | 28672 |
| 22 | 384 | 45 | 4864 | 67 | 32768 |
| 23 | 416 |
The first classes step by 8 bytes, then by 16 up to 256, then by 32 up to 512, then by 64 up to 768. Past that the steps grow, and they stop being uniform entirely: 128 bytes up to 6912, 256 up to 9728, then a scatter of 384, 512, 640, 768, 1280, 1408, 2048 and larger. Those irregular sizes are the second pass of the generator at work — a class is inflated so that a whole number of objects fits into a whole number of pages.
Note that this is not the “round up to a power of two” scheme that PostgreSQL uses. Go’s classes are much finer, and the reason is the rule that generated them.
How the table is generated
The table is not hand-written. It comes out of a generator whose header comment states the design rule:
The size classes are chosen so that rounding an allocation request up to the next size class wastes at most 12.5% (1.125x).
and then:
Each size class has its own page count that gets allocated and chopped up when new objects of the size class are needed. That page count is chosen so that chopping up the run of pages into objects of the given size wastes at most 12.5% (1.125x) of the memory.
Here is the generator:
// src/runtime/_mkmalloc/mksizeclasses.go:66
func makeClasses() []class {
var classes []class
classes = append(classes, class{}) // class #0 is a dummy entry
align := minHeapAlign
for size := align; size <= maxSmallSize; size += align {
if powerOfTwo(size) { // bump alignment once in a while
if size >= 2048 {
align = 256
} else if size >= 128 {
align = size / 8
} else if size >= 32 {
align = 16 // heap bitmaps assume 16 byte alignment for allocations >= 32 bytes.
}
}
if !powerOfTwo(align) {
panic("incorrect alignment")
}
// Make the allocnpages big enough that
// the leftover is less than 1/8 of the total,
// so wasted space is at most 12.5%.
allocsize := pageSize
for allocsize%size > allocsize/8 {
allocsize += pageSize
}
npages := allocsize / pageSizeTwo sources of waste multiply here: rounding the request up to the class size, and the leftover bytes at the end of the span. The generator keeps each of them under 12.5%, so the worst case is 26.6% overhead — but in practice only one of them bites for any given size.
The interesting part is the align variable. The heap bitmap machinery
assumes certain alignments, so the generator forces every class at or above 32
bytes to be 16-byte aligned, and every class at or above 2048 bytes to be
256-byte aligned. That is why class 4 is exactly 32 bytes and class 5 jumps
straight to 48: there is no 40-byte class, because a 40-byte object would
break the “16-byte aligned” rule for anything at or above 32 bytes.
So the class-to-size relationship is not a formula you can write down and evaluate. It is the output of a search over candidate sizes, constrained by alignment, page count, and a 12.5% waste budget. What the runtime does at allocation time is a lookup, not a computation.
The lookup is a pair of tables:
// src/internal/runtime/gc/sizeclasses.go:101
var SizeToSizeClass8 = [SmallSizeMax/SmallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}That is SizeToSizeClass8: for requests up to 1024 bytes, index by
ceil(size / 8). Above 1024 bytes, SizeToSizeClass128 does the same with
128-byte granularity. Two array lookups, no division. Then the size is
rounded up:
// src/runtime/malloc.go:1384
checkGCTrigger := false
c := getMCache(mp)
var sizeclass uint8
if size <= gc.SmallSizeMax-8 {
sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
} else {
sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
}
size = uintptr(gc.SizeClassToSize[sizeclass])
spc := makeSpanClass(sizeclass, true)
span := c.alloc[spc]That is the entire cost of “round up to a size class”: two table lookups and one multiply-free increment.
The per-P mcache
The mcache is the reason Go allocation is fast. There is one per P, and it
is never locked, because a P only ever touches its own.
// src/runtime/mcache.go:20
type mcache struct {
_ sys.NotInHeap
// The following members are accessed on every malloc,
// so they are grouped here for better caching.
nextSample int64 // trigger heap sample after allocating this many bytes
memProfRate int // cached mem profile rate, used to detect changes
scanAlloc uintptr // bytes of scannable heap allocated
// Allocator cache for tiny objects w/o pointers.
// See "Tiny allocator" comment in malloc.go.
// tiny points to the beginning of the current tiny block, or
// nil if there is no current tiny block.
//
// tiny is a heap pointer. Since mcache is in non-GC'd memory,
// we handle it by clearing it in releaseAll during mark
// termination.
//
// tinyAllocs is the number of tiny allocations performed
// by the P that owns this mcache.
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
// The rest is not accessed on every malloc.
// alloc contains spans to allocate from, indexed by spanClass.
alloc [numSpanClasses]*mspanThe interesting bit is alloc: an array of numSpanClasses pointers, one
cached span per size class per scan type. The runtime keeps two copies of the
array — one for pointer-containing objects and one for pointer-free ones —
and packs them into a single byte-sized spanClass:
// src/runtime/mheap.go:573
type spanClass uint8
const (
numSpanClasses = gc.NumSizeClasses << 1
tinySpanClass = spanClass(tinySizeClass<<1 | 1)
)So c.alloc[spc] is a single array lookup that gets you a span containing
objects of exactly the size class and scan type you want, with free slots
already marked in a bitmask.
The tiny allocator
Before the size classes, there is a special case: allocations smaller than 16 bytes that contain no pointers.
The idea is that a 3-byte string header and a 2-byte field can share a single 16-byte block, because the block is noscan and the runtime only needs to reclaim all 16 bytes at once.
The comment in malloc.go explains the trade-off:
// src/runtime/malloc.go:1226
mp.mallocing = 1
// Tiny allocator.
//
// Tiny allocator combines several tiny allocation requests
// into a single memory block. The resulting memory block
// is freed when all subobjects are unreachable. The subobjects
// must be noscan (don't have pointers), this ensures that
// the amount of potentially wasted memory is bounded.
//
// Size of the memory block used for combining (maxTinySize) is tunable.
// Current setting is 16 bytes, which relates to 2x worst case memory
// wastage (when all but one subobjects are unreachable).
// 8 bytes would result in no wastage at all, but provides less
// opportunities for combining.
// 32 bytes provides more opportunities for combining,
// but can lead to 4x worst case wastage.
// The best case winning is 8x regardless of block size.And here is the whole fast path, once you have the mcache:
// src/runtime/malloc.go:1257
c := getMCache(mp)
off := c.tinyoffset
// Align tiny pointer for required (conservative) alignment.
if size&7 == 0 {
off = alignUp(off, 8)
} else if goarch.PtrSize == 4 && size == 12 {
// Conservatively align 12-byte objects to 8 bytes on 32-bit
// systems so that objects whose first field is a 64-bit
// value is aligned to 8 bytes and does not cause a fault on
// atomic access. See issue 37262.
// TODO(mknyszek): Remove this workaround if/when issue 36606
// is resolved.
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
if off+size <= maxTinySize && c.tiny != 0 {
// The object fits into existing tiny block.
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x, 0
}That is the whole thing: bump a pointer, return it. No size class, no span, no bit scanning.
When it does need a new block, it takes one from the tiny size class (class 2, the 16-byte class) and always zeroes it:
// src/runtime/malloc.go:1284
// Allocate a new maxTinySize block.
checkGCTrigger := false
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(tinySpanClass)
}
x := unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0 // Always zero
(*[2]uint64)(x)[1] = 0The runtime claims “on a json benchmark the allocator reduces number of allocations by ~12% and reduces heap size by ~20%” from this one mechanism.
The small object path
For everything else under 32 KiB, the flow is: find the size class, look up the cached span, pop a free slot, zero it if needed.
// src/runtime/malloc.go:1405
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(spc)
}
x := unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(x, size)
}Three things to notice.
needzero is a per-span flag, not a per-object one. If a span was freshly
mapped from the OS, its memory is already zero and there is nothing to do. If
the span was reused after the GC freed objects in it, the memory holds garbage
and must be cleared. The runtime tracks this at span granularity because that
is the coarsest granularity at which the answer is known.
The zeroing is conditional on needzero and the request asking for zeroed
memory. mallocgc is called with needzero == true for anything that has
pointers, because the GC needs a valid heap bitmap, and for any allocation
whose type requires zero values.
And the allocation is “black” during a GC cycle: the new object is marked immediately so the collector does not have to find it.
Finding the next free slot
nextFreeFast is the hot path. The span keeps a 64-bit cache of the
allocation bitmap, so finding the next free object is a count-trailing-zeros
instruction:
// src/runtime/malloc.go:975
// nextFreeFast returns the next free object if one is quickly available.
// Otherwise it returns 0.
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
if theBit < 64 {
result := s.freeindex + uint16(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(uintptr(result)*s.elemsize + s.base())
}
}
return 0
}allocCache holds the complement of the allocation bits, so a set bit
means “free”. TrailingZeros64 finds the lowest free slot. When the cache
runs out — which happens every 64 allocations — the function returns 0 and
the caller falls back to the slower path.
That slower path is nextFree, which scans the bitmap a word at a time and
refills the cache:
// src/runtime/malloc.go:995
// nextFree returns the next free object from the cached span if one is available.
// Otherwise it refills the cache with a span with an available object and
// returns that object along with a flag indicating that this was a heavy
// weight allocation. If it is a heavy weight allocation the caller must
// determine whether a new GC cycle needs to be started or if the GC is active
// whether this goroutine needs to assist the GC.
//
// Must run in a non-preemptible context since otherwise the owner of
// c could change.
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, checkGCTrigger bool) {
s = c.alloc[spc]
checkGCTrigger = false
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
// The span is full.
if s.allocCount != s.nelems {
println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount != s.nelems && freeIndex == s.nelems")
}
c.refill(spc)
checkGCTrigger = true
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
if freeIndex >= s.nelems {
throw("freeIndex is not valid")
}
v = gclinkptr(uintptr(freeIndex)*s.elemsize + s.base())
s.allocCount++
if s.allocCount > s.nelems {
println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount > s.nelems")
}
return
}The checkGCTrigger return value is the interesting bit: refilling a span is
a “heavy weight” operation, so the caller checks whether it just pushed the
heap over the GC trigger and starts a collection if so.
Refilling: from mcache to mcentral
When the cached span for a class is exhausted, refill returns it to the
central lists and grabs a fresh one:
// src/runtime/mcache.go:160
func (c *mcache) refill(spc spanClass) {
// Return the current cached span to the central lists.
s := c.alloc[spc]
if s.allocCount != s.nelems {
throw("refill of span with free space remaining")
}
// TODO(thepudds): we might be able to allow mallocgcTiny to reuse 16 byte objects from spc==5,
// but for now, just clear our reusable objects for tinySpanClass.
if spc == tinySpanClass {
c.reusableNoscan[spc] = 0
}
if c.reusableNoscan[spc] != 0 {
throw("refill of span with reusable pointers remaining on pointer free list")
}
if s != &emptymspan {
// Mark this span as no longer cached.
if s.sweepgen != mheap_.sweepgen+3 {
throw("bad sweepgen in refill")
}
mheap_.central[spc].mcentral.uncacheSpan(s)
// Count up how many slots were used and record it.
stats := memstats.heapStats.acquire()
slotsUsed := int64(s.allocCount) - int64(s.allocCountBeforeCache)
atomic.Xadd64(&stats.smallAllocCount[spc.sizeclass()], slotsUsed)
// Flush tinyAllocs.
if spc == tinySpanClass {
atomic.Xadd64(&stats.tinyAllocCount, int64(c.tinyAllocs))
c.tinyAllocs = 0
}
memstats.heapStats.release()
// Count the allocs in inconsistent, internal stats.
bytesAllocated := slotsUsed * int64(s.elemsize)
gcController.totalAlloc.Add(bytesAllocated)
// Clear the second allocCount just to be safe.
s.allocCountBeforeCache = 0
}
// Get a new cached span from the central lists.
s = mheap_.central[spc].mcentral.cacheSpan()
if s == nil {
throw("out of memory")
}The sweepgen field is a version stamp that lets the runtime tell a span
that was cached before the sweep phase — and which therefore still needs
sweeping — from one that is safe to hand out again.
mcentral: the shared per-class lists
Behind every mcache is an mcentral, one per size class. The struct is
tiny, because the actual free lists live in the spans:
// src/runtime/mcentral.go:22
type mcentral struct {
_ sys.NotInHeap
spanclass spanClass
// partial and full contain two mspan sets: one of swept in-use
// spans, and one of unswept in-use spans. These two trade
// roles on each GC cycle. The unswept set is drained either by
// allocation or by the background sweeper in every GC cycle,
// so only two roles are necessary.
//
// sweepgen is increased by 2 on each GC cycle, so the swept
// spans are in partial[sweepgen/2%2] and the unswept spans are in
// partial[1-sweepgen/2%2]. Sweeping pops spans from the
// unswept set and pushes spans that are still in-use on the
// swept set. Likewise, allocating an in-use span pushes it
// on the swept set.
//
// Some parts of the sweeper can sweep arbitrary spans, and hence
// can't remove them from the unswept set, but will add the span
// to the appropriate swept list. As a result, the parts of the
// sweeper and mcentral that do consume from the unswept list may
// encounter swept spans, and these should be ignored.
partial [2]spanSet // list of spans with a free object
full [2]spanSet // list of spans with no free objects
}Four span sets, not two: partial and full, each doubled for “swept” and
“unswept”. The doubling is a neat trick — instead of maintaining separate
lists and moving spans between them at the right moment, the runtime keeps
two lists per category and swaps their roles every GC cycle.
cacheSpan is where a refill actually gets its span:
// src/runtime/mcentral.go:82
func (c *mcentral) cacheSpan() *mspan {
// Deduct credit for this span allocation and sweep if necessary.
spanBytes := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()]) * pageSize
deductSweepCredit(spanBytes, 0)
traceDone := false
trace := traceAcquire()
if trace.ok() {
trace.GCSweepStart()
traceRelease(trace)
}
// If we sweep spanBudget spans without finding any free
// space, just allocate a fresh span. This limits the amount
// of time we can spend trying to find free space and
// amortizes the cost of small object sweeping over the
// benefit of having a full free span to allocate from. By
// setting this to 100, we limit the space overhead to 1%.
//
// TODO(austin,mknyszek): This still has bad worst-case
// throughput. For example, this could find just one free slot
// on the 100th swept span. That limits allocation latency, but
// still has very poor throughput. We could instead keep a
// running free-to-used budget and switch to fresh span
// allocation if the budget runs low.
spanBudget := 100
var s *mspan
var sl sweepLocker
// Try partial swept spans first.
sg := mheap_.sweepgen
if s = c.partialSwept(sg).pop(); s != nil {
goto havespan
}The order is: swept partial spans first (free and ready), then unswept partial spans (sweep one and use it), then unswept full spans (sweep one and see if it has any free slots), and finally give up and ask the heap for a brand new span.
If all of that fails, grow asks the mheap for fresh pages:
// src/runtime/mcentral.go:251
func (c *mcentral) grow() *mspan {
npages := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()])
s := mheap_.alloc(npages, c.spanclass)
if s == nil {
return nil
}
s.initHeapBits()
return s
}Large objects
Anything above 32 KiB skips the size class machinery entirely and gets its own span straight from the heap:
// src/runtime/malloc.go:1697
func mallocgcLarge(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
// Set mp.mallocing to keep from being preempted by GC.
mp := acquirem()
if doubleCheckMalloc {
if mp.mallocing != 0 {
throw("malloc deadlock")
}
if mp.gsignal == getg() {
throw("malloc during signal")
}
}
mp.mallocing = 1
c := getMCache(mp)
// For large allocations, keep track of zeroed state so that
// bulk zeroing can be happen later in a preemptible context.
span := c.allocLarge(size, typ == nil || !typ.Pointers())
span.freeindex = 1
span.allocCount = 1
span.largeType = nil // Tell the GC not to look at this yet.
size = span.elemsize
x := unsafe.Pointer(span.base())A large object gets a span to itself, so freeindex is 1 (the object) and
allocCount is 1. Its type information is stored in a “malloc header” at the
end of the span rather than in the per-object heap bitmap. The zeroing of
large objects is deferred to a preemptible context, which matters for a
100 MB allocation.
Profiling the allocations
Every so often — governed by MemProfileRate, default 512 KiB — the
allocator records the allocation in the heap profile:
// src/runtime/malloc.go:2226
func profilealloc(mp *m, x unsafe.Pointer, size uintptr) {
c := getMCache(mp)
if c == nil {
throw("profilealloc called without a P or outside bootstrapping")
}
c.memProfRate = MemProfileRate
c.nextSample = nextSample()
mProf_Malloc(mp, x, size)
}c.nextSample is a countdown in bytes decremented by every allocation.
When it goes negative the allocation is sampled and the counter is reset to
a random value drawn from an exponential distribution, so samples are spread
evenly over time rather than clustered.
The whole picture
mallocgc(size, typ, needzero)
|
+-- size == 0 --------------------> &zerobase
|
+-- size < 16, noscan -----------> mallocgcTiny
| |
| +-- fits in c.tiny block? -> bump c.tinyoffset
| +-- else: take 16-byte slot from tiny size class
|
+-- size <= 32 KiB ---------------> mallocgcSmallNoscan / mallocgcSmallScan*
| |
| +-- sizeclass = SizeToSizeClass8[ceil(size/8)]
| +-- span = c.alloc[spanClass]
| +-- v = nextFreeFast(span)
| | +-- 0? -> c.nextFree(spanClass)
| | +-- span full? -> c.refill()
| | +-- mcentral.cacheSpan()
| | +-- partial swept
| | +-- partial unswept (sweep)
| | +-- full unswept (sweep)
| | +-- mcentral.grow() -> mheap.alloc()
| +-- if needzero && span.needzero: memclr
|
+-- size > 32 KiB ----------------> mallocgcLarge -> mcache.allocLarge -> mheap.allocTrade-offs
What size classes buy you. Predictable memory use and predictable performance. A 100-byte allocation always occupies 112 bytes, and always comes out of the same span. There is no per-object bookkeeping and no allocator fragmentation from odd-sized chunks. The cost is bounded at 12.5% per class from rounding plus 12.5% from span tails, so at worst about a quarter of your heap is overhead.
What the tiny allocator buys you. Fewer objects for the GC to track and less memory overall, at the cost of a 2x worst-case waste when all but one subobject in a block dies. It is also only enabled for pointer-free objects, which is what bounds the waste: the runtime can never have a pointer into the middle of a tiny block.
What the mcache buys you. Lock-free allocation on the hot path. No
atomics, no locks, no contention between Ps. The price is memory: every P
holds up to 68 (well, 136 with the scan/noscan split) cached spans, and those
spans are stranded if the P goes idle. That is why the runtime flushes
mcaches during GC (releaseAll) and on P shutdown.
What you give up. The size of an allocation is not knowable from the pointer. You cannot recover “this was a 100-byte request” from a pointer into a 112-byte slot. The size class is recoverable, but the requested size is not tracked per object.
Caveats
The size class table is not stable across Go releases. It has changed several times, and the generator’s rules — 16-byte alignment above 32 bytes, 256-byte alignment above 2048 bytes — have shifted. If you are measuring exact heap sizes, pin your Go version.
The mcache is per-P, not per-thread. Goroutines move between Ps, so “the cache for my goroutine” is not a thing; a migrating goroutine pays for a cold cache on its new P.
needzero is conservative. A span that has never been used is not
zeroed again. But a span that has been swept is zeroed on every reuse, even
for parts that were never written. This is why GOGC=off and then a large
GOGC transition can produce an unexpectedly slow allocation burst.
The tiny allocator is disabled in some builds. It is skipped when the
race detector is on, for “secret” allocations, and when
sizeSpecializedMallocEnabled is false.
Allocation is not free even at the top of the stack. mallocgc does GC
assist accounting, publication barriers, and write-barrier bookkeeping on top
of the size class lookup.
Wrapping up
The one thing to remember: every small allocation is rounded up to one of
68 size classes and served from a per-P mcache that holds one cached span
per class. The rounding is not arbitrary — it is generated to keep both the
request-rounding waste and the span-tail waste under 12.5%.
The part people get wrong: the tiny allocator is not a general-purpose optimisation. It only handles pointer-free objects under 16 bytes, it packs them into 16-byte blocks, and those blocks are only reclaimed when every subobject in them is dead. A program whose tiny allocations contain pointers gets none of the benefit.
What to go do about it: if your heap is larger than you expect, look at
the size classes your objects land in. A pprof heap profile with
-sample_index=inuse_space shows you exactly which classes dominate. If a
large fraction of your allocation is in one class with a high waste
percentage, you are probably better off with a size that fits the class
exactly.
Further reading
src/internal/runtime/gc/sizeclasses.go— the size class tablessrc/runtime/_mkmalloc/mksizeclasses.go— the size class generatorsrc/runtime/malloc.go—mallocgc,mallocgcTiny,mallocgcSmallNoscan,mallocgcLargesrc/runtime/mcache.go— the per-P cache,refill,nextFreesrc/runtime/mcentral.go— the per-class central listssrc/runtime/mheap.go—mspan,heapArena,mheap.allocsrc/runtime/mbitmap.go— heap bitmaps andheapSetTypesrc/runtime/mgcmark.go— how spans are markedsrc/runtime/mgcsweep.go— how spans are freed