m01 · Entities Core · reading · 9 min
Chunks — why 16 KB, and what it costs you
The archetype is made of blocks
An archetype’s storage isn’t one big growable array. It’s a linked set
of fixed-size blocks called
Fixed size is a deliberate choice with consequences in both directions. Because every chunk is the same size, allocating storage for a new entity is cheap and non-fragmenting: grab a slot in a chunk that has room, or allocate one more 16 KB block. Because chunks are the unit of iteration and of parallel work distribution, a fixed size means the runtime can hand “one chunk” to a worker thread as a clean, bounded unit without knowing anything about the archetype. The number 16,384 is the same for a 2-entity archetype and a 2,000-entity one; only how many entities fit changes.
Capacity is arithmetic, and the arithmetic is yours
How many entities fit in a chunk? Subtract the header, divide by the per-entity byte cost — then clamp:
capacity = min( (16384 − header) / (component sum + 8) , 128 )
Two terms in there earn their place. The +8 is the Entity handle:
every chunk keeps an 8-byte entity ID per slot alongside your component
arrays, so the chunk can map slots back to entities. It rides in every
archetype whether you ask for it or not.
The 128 is a hard cap: kMaximumEntitiesPerChunk. Derive why it
exists from something you’ll meet later — enableable components. A
chunk stores each enableable type’s on/off state as a fixed 128-bit
mask in the chunk header, one bit per slot. A mask can’t cover slot
129, so no chunk holds more than 128 entities, even when the bytes
would fit thousands. Lean archetypes hit the cap long before they hit
the bytes: a 48-byte archetype computes (16384 − 64) / 56 ≈ 291 and
then clamps to 128. A 16-byte one computes ~680 — and clamps to
128. The clamp stops binding only once component sum + 8 exceeds
16320 / 128 ≈ 127 bytes — roughly a 120-byte component sum. Below
that line, capacity is a constant and shaving bytes buys you nothing;
above it, the division is live and every byte counts. A 184-byte
per-entity archetype really does pack only ~88.
The header is on the order of tens of bytes (it varies by Entities version and by how many component types the archetype has — it stores per-type offsets, entity count, change-version numbers, and those enableable bitmasks). Round it to ~64 for a back-of-envelope figure.
This is the single most useful calculation in the whole system, because it means density is something you decide — and you now know which regime you’re deciding in. Every byte you add to a component on a hot archetype divides into that 16 KB, but the division only shows up in capacity once you’re past the cap line. Below it the damage is deferred, not absent: the bytes still cost memory bandwidth, and the moment a teammate adds one more component the archetype can cross the line and every byte you “got for free” starts billing.
Why 16 KB, specifically
The number isn’t magic but it isn’t arbitrary either — it’s a balance between two failure modes.
Too small, and per-chunk fixed costs dominate. Every chunk carries a header, and every query pays a small cost to move from one chunk to the next (a bounds check, pointer setup, a change-version comparison). If a chunk held only a handful of entities, you’d spend a large fraction of iteration time on that per-chunk overhead instead of on entities. You’d also multiply the header’s memory cost across far more chunks.
Too large, and a chunk stops fitting the caches it’s meant to stream through. 16 KB sits comfortably relative to a typical 32–48 KB L1 data cache and well within L2, so a system sweeping one component across a chunk keeps its working set hot. Push chunks to hundreds of KB and a single chunk no longer resides in fast cache; the linear walk that was supposed to be prefetcher-friendly starts evicting its own earlier lines. Larger chunks also make the parallel-work unit coarser — fewer, bigger jobs — which hurts load balancing when entity counts are uneven.
16 KB is the size that keeps per-chunk overhead amortized while keeping
the streaming working set inside fast cache. You could have reasoned
your way to “a few tens of KB” from the
The tax of the component you don’t read
Here’s the consequence people miss. The chunk holds all of the
archetype’s components, contiguously but interleaved by type. A wide
component — say a 128-byte struct you only touch occasionally — sits in
its own span inside every chunk of that archetype, whether or not a
given loop reads it. It doesn’t slow the loop that ignores it directly
(SoA means you don’t stride over it). But it does something quieter and
worse: it lowers the entity capacity of the chunk, so every query
over that archetype — including the hot ones that never read the wide
component — is spread across more chunks and packs fewer entities per
What this buys you
You can now look at any archetype, sum its component sizes, and predict its chunk capacity to within a rounding error on the header — and you understand why shaving bytes off a hot component is a real optimization rather than a micro-optimization. The next lesson turns to the two ways a component can carry no per-entity bytes at all — tags and shared components — and why each exists to keep exactly this density math on your side.