DOTS//CORE local · not synced

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 chunks, each 16 KB (16,384 bytes). Every chunk belongs to exactly one archetype and holds some number of that archetype’s entities, laid out SoA — each component type in its own contiguous span inside the chunk, plus a small header the runtime uses for bookkeeping.

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.

cache line · 64 B fetched 48 B useful · 75% of the bandwidth you paid for a 48-byte entity spans most of a line; the runtime caps the chunk at 128 of them — its bytes could hold ~290, and that headroom is why small components feel free right up until the archetype crosses the ~120-byte cap line

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 memory wall alone; Unity picked the specific value in that band.

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 cache line’s worth of the components it does read.

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.

m01.l02