DOTS//CORE local · not synced

m01 · Entities Core · lab · 30 min

Watch density move

The brief

You proved the memory wall on your machine last module. Now prove the chunk-density arithmetic — the claim that component representation, not just component data, decides how many entities ride in each 16 KB block. Three measurements, one project, your own Entities version and header size.

  1. Baseline capacity. Build an archetype that sums to ~48 bytes per entity. Read its Archetype.ChunkCapacity and record it.
  2. The cold-component tax. Add a 128-byte component the query never reads. Measure how far capacity falls.
  3. The shared-component cliff. Put a high-cardinality value into a shared component across 5,000 entities and count the resulting chunks.

You are checking three predictions from the readings against numbers your build actually produces. Where they disagree, the disagreement is the lesson — recount your bytes.

Before you start

You need a Unity project with the Entities package installed (any recent version — the whole point is to read your numbers, not match mine). You’ll run everything from a single system that fires once on play and prints to the Console. No scene setup, no GameObjects, no Inspector wiring — three log lines are the entire deliverable.

Checklist:

  • A Unity project open, Entities package present.
  • The Console window visible (Window → General → Console).
  • An empty scene. Pressing Play is all it takes to trigger the probe.

Where this runs

Every snippet below lives inside one file. Create a C# script called DensityProbe.cs anywhere in Assets/ and paste this scaffold in. It’s a system that runs a single time, does its measurements, then disables itself so it never spams the Console:

using Unity.Entities;
using Unity.Mathematics;

// ── Component definitions ──────────────────────────────────────────────
// ~48 bytes: float3 (12) + float3 (12) + float3 (12) + int (4) + int (4) + int (4) = 48
public struct Pos      : IComponentData { public float3 Value; }
public struct Vel      : IComponentData { public float3 Value; }
public struct Accel    : IComponentData { public float3 Value; }
public struct Health   : IComponentData { public int Value; }
public struct Armor    : IComponentData { public int Value; }
public struct Faction  : IComponentData { public int Value; }

// The cold component: 128 bytes the hot query will never request.
public struct ColdBlob : IComponentData
{
    public float4x4 A;   // 64 bytes
    public float4x4 B;   // 64 bytes
}

// The shared component you'll abuse with high cardinality.
public struct GridCell : ISharedComponentData { public int Value; }

// ── The probe ──────────────────────────────────────────────────────────
public partial class DensityProbe : SystemBase
{
    protected override void OnCreate()
    {
        var em = EntityManager;

        // >>> PART 1 code goes here <<<
        // >>> PART 2 code goes here <<<
        // >>> PART 3 code goes here <<<

        Enabled = false;   // run once, then stop
    }

    protected override void OnUpdate() { }
}

Press Play. Right now it does nothing but compile — that’s expected. You’ll drop each Part’s code into the marked spot, press Play, read the Console, then move on. Use Debug.Log (from UnityEngine) for the output; it’s already available inside a system.

Part 1 — baseline capacity

Step 1. Paste this where Part 1 is marked, then press Play:

var lean = em.CreateArchetype(
    typeof(Pos), typeof(Vel), typeof(Accel),
    typeof(Health), typeof(Armor), typeof(Faction));

UnityEngine.Debug.Log($"[P1] lean bytes/entity ≈ 48, ChunkCapacity = {lean.ChunkCapacity}");

Step 2. Read the [P1] line in the Console. Record that ChunkCapacity as chunk_capacity_48b.

Step 3. Check it against the prediction min( (16384 − header) / (48 + 8) , 128 ). The +8 is the Entity handle every chunk stores per slot; the 128 is kMaximumEntitiesPerChunk, a hard cap that exists because enableable components keep their on/off state in fixed 128-bit masks in the chunk header — one bit per slot, so no chunk may hold a 129th entity. The byte math says ~291 fit. The cap says 128, and 128 is what you’ll read. You just measured a constraint that isn’t about bytes at all.

Part 2 — the cold-component tax

Step 1. Add this at the Part 2 marker (keep Part 1 above it) and press Play:

var fat = em.CreateArchetype(
    typeof(Pos), typeof(Vel), typeof(Accel),
    typeof(Health), typeof(Armor), typeof(Faction),
    typeof(ColdBlob));                        // +128 bytes, never queried

UnityEngine.Debug.Log($"[P2] fat ChunkCapacity = {fat.ChunkCapacity}");

Step 2. Read the [P2] line. Compute capacity_ratio_fat = lean.ChunkCapacity / fat.ChunkCapacity.

Step 3. Sanity-check both numbers. The fat archetype’s per-slot cost is 48 + 128 + 8 = 184 bytes — past the ~120-byte cap line — so here the division is live: ⌊(16384 − header) / 184⌋ ≈ 88, and you’ll read 87 or 88 (the gap is per-array alignment padding, since each SoA array is aligned separately). This is the archetype you back the real overhead out of: 16384 − capacity × 184 — the lean one can’t tell you, because its capacity is a clamp, not a division.

So capacity_ratio_fat lands near 128 / 87 ≈ 1.5 — not the 184 / 56 ≈ 3.3 the raw bytes predict. Sit with that gap: the cap was already discarding most of the lean archetype’s theoretical density, so it absorbs most of the cold tax. The tax didn’t shrink — 184 bytes per entity is 3.3× the memory traffic of 56, every frame, forever. The cap just hides it from the capacity number until the day the lean side crosses the line too. A query over the fat archetype still spreads the same entities across ~1.5× as many chunks and pays 3.3× the bandwidth.

Part 3 — the shared-component cliff

Step 1. Add this at the Part 3 marker and press Play:

var shared = em.CreateArchetype(typeof(Pos), typeof(GridCell));

// HIGH cardinality: a near-unique value per entity.
for (int i = 0; i < 5000; i++)
{
    var e = em.CreateEntity(shared);
    em.SetSharedComponentManaged(e, new GridCell { Value = i }); // 5000 distinct values
}

var q = em.CreateEntityQuery(typeof(Pos), typeof(GridCell));
UnityEngine.Debug.Log($"[P3] high-cardinality chunk count = {q.CalculateChunkCount()}");

Step 2. Read the [P3] line and record it as shared_chunk_count. With 5,000 distinct shared values you should see a chunk count in the thousands — approaching one chunk per value, each holding roughly one entity, each burning 16 KB. That’s the fragmentation cliff from the reading, made concrete.

Step 3. Now change one thing — make the value low cardinality — and watch it collapse. Edit the line inside the loop to:

em.SetSharedComponentManaged(e, new GridCell { Value = i % 16 }); // only 16 values

Step 4. Press Play again and derive the [P3] number before you read it. Shared values can’t mix in a chunk, so each of the 16 values gets its own chunks: 5000 / 16 ≈ 313 entities per value. Pos + GridCell is only 12 + 8 = 20 bytes per slot — the bytes would allow ~800 per chunk, but the cap says 128 — so each value needs ⌈313 / 128⌉ = 3 chunks. 16 × 3 = 48. If you read 48, you’ve just measured kMaximumEntitiesPerChunk a second way, from a direction that has nothing to do with component sizes. (Without the cap you’d have seen 16.) Same feature, same code, cardinality is the only variable — and it’s the difference between 48 chunks and 5,000.

What you just proved

Log your three numbers — chunk_capacity_48b, capacity_ratio_fat, and shared_chunk_count. Module 13 charts chunk_capacity_48b against your future archetypes, so this baseline is the first point in a dataset about your own hardware and Entities version — not anyone else’s.

m01.l07