Table of Contents

Entity Clusters (Batched SoA Storage)

GPU-inspired batched SoA storage that turns per-entity hashmap/page lookups into sequential array scans.

Status: โœ… Implemented ยท Visibility: Public ยท Level: ๐ŸŸฃ Advanced ยท Category: Ecs

Assumes: Storage Modes

๐ŸŽฏ What it solves

Per-entity storage pays a hash-map lookup plus a scattered page fetch for every component, on every entity, every tick. At 100K+ entities that indirection โ€” not the actual computation โ€” dominates iteration cost (profiling found 66% of per-entity time in lookup/fetch overhead) and blows the working set past L2/L3 cache. Entity Clusters eliminate that indirection for bulk iteration (movement systems, AI ticks, batch queries) while leaving random single-entity access, MVCC isolation, indexes, and all three storage modes working exactly as before.

โš™๏ธ How it works (in brief)

Eligible archetypes auto-compute a cluster size N (8-64, chosen to maximize entities-per-page) and pack N same-archetype entities into one contiguous chunk: an occupancy bitmask, an enabled-bitmask per component, an entity-key array, then each component's data as a separate contiguous array (SoA) โ€” Component0[N], Component1[N], .... ClusterEnumerator<TArch> walks an archetype's active clusters; each ClusterRef<TArch> exposes OccupancyBits plus GetSpan<T>/Get<T> for direct, branch-free access to a component's slot array โ€” no hash lookup, no per-component page fetch. Random access (Open/OpenMut) is unchanged at the API level: it transparently resolves through the EntityMap to the same cluster + slot. SingleVersion and Versioned components live in the cluster (Versioned stores its HEAD there, the revision chain stays separate); Transient components live in a parallel heap-backed segment with identical layout so the SoA pattern is uniform across all three modes. When an archetype also declares a [SpatialIndex] field, cluster membership gains a further constraint: Spatially-Coherent Entity Clustering additionally packs entities by grid cell, not just by archetype, so per-cell spatial operations (tier assignment, dormancy, broadphase) stay per-cluster instead of per-entity โ€” this is what a "cluster migration" (mentioned in the tick fence docs) actually is: an entity crossing its cluster's cell boundary and being moved to a cluster for its new cell.

๐Ÿ’ป Usage

[Archetype]
public class Ant : Archetype<Ant>
{
    public static readonly Comp<Position> Position = Register<Position>();
    public static readonly Comp<Movement> Movement = Register<Movement>();
}

// Bulk iteration โ€” the cluster-native path, ~50x faster than per-entity Open/OpenMut.
var ants = ctx.Accessor.For<Ant>();
using var clusters = ants.GetClusterEnumerator();
while (clusters.MoveNext())
{
    var cluster = clusters.Current;
    var positions = cluster.GetSpan(Ant.Position);
    var movements = cluster.GetReadOnlySpan(Ant.Movement);

    ulong bits = cluster.OccupancyBits;
    while (bits != 0)
    {
        int idx = BitOperations.TrailingZeroCount(bits);
        bits &= bits - 1;
        positions[idx].X += movements[idx].VX * ctx.DeltaTime;
        positions[idx].Y += movements[idx].VY * ctx.DeltaTime;
    }
    clusters.MarkCurrentDirty(); // required: flags the writes for WAL/checkpoint โ€” the cluster path skips dirty tracking otherwise
}

// Random single-entity access โ€” same archetype, same API, transparently cluster-backed.
var entity = ants.OpenMut(someAntId);
var pos = entity.Read(Ant.Position);
pos.X += 1;
entity.Set(Ant.Position, pos);

โš ๏ธ Guarantees & limits

  • Cluster size is auto-computed, not chosen by the caller: N โˆˆ [8, 64] is picked per archetype to maximize entities-per-page; iteration code is identical for every N.
  • Every archetype is cluster-backed, and there is no opt-out: eligibility is unconditional, the pure-Versioned archetype included โ€” a cluster stores the Versioned HEAD in the slot and keeps the revision chain separate, exactly as it does for a mixed archetype. Being unconditional is what makes exactly one spatial index possible; an opt-out would keep a second index home alive for every consumer to branch on.
  • Direct GetSpan/Get writes bypass dirty tracking: call MarkCurrentDirty() (whole cluster) or MarkSlotDirty(slot) (single entity) after writing, or the change never reaches the WAL/checkpoint.
  • A SingleVersion or Transient component with an [Index] field has no writable span: GetSpan/Get throw on it, in every build (a write through memory handed out records no old key, so the index could never follow it). Write it per entity with ClusterRef.Set(comp, slot, value), which records the old keys and marks the entity dirty in the same call, or a cluster's worth at once with ClusterRef.Set(comp, slotMask, values) (values indexed by slot; the bookkeeping is paid once per cluster); read it with GetReadOnlySpan. A write whose value keeps every indexed key records nothing for the index, so rewriting an indexed component's other fields costs little. A WriteSpatial that changes an indexed field of the spatial component does the same bookkeeping. Set refuses what an in-place store cannot do โ€” a Versioned component, one that defaults to the Commit discipline, a SingleVersion component in a transaction running under it (all three: OpenMut(...).Set(...)), the spatial component (WriteSpatial) and the realm key. Writing Versioned components through GetSpan is rejected under strict mode (CheckConfig.Enabled, an InvalidOperationException) โ€” use OpenMut(...).Set(...) for those, which still goes through the revision chain.
  • Measured impact (100K entities, 2-component archetype): per-entity cost 134 ns โ†’ ~2.7 ns (~50x), tick time ~10x, working set 19.2 MB โ†’ 2.5 MB (L3 โ†’ L2).
  • Random access stays correct, not just fast: MVCC visibility (BornTSN/DiedTSN), B+Tree/spatial indexes, and EnabledBits are all maintained per entity; Open/OpenMut need no code changes to benefit.
  • Trade-offs are real, not hidden: checkpoint I/O is ~1.6x larger (bigger stride per page); highly selective queries pay a zone-map scan floor (~19 ยตs); schema evolution rebuilds every cluster in the archetype (~Cร— more expensive than per-component migration, an offline operation).
  • EntityId format is unchanged โ€” clusters are a storage detail; entity identity, serialization, and WAL/network formats are unaffected.

๐Ÿงช Tests

  • ClusterStorageTests โ€” cluster sizing/cache-line-aligned stride, GetClusterEnumerator bulk iteration, random-access read/write/destroy through the cluster
  • ClusterDirtyTests โ€” MarkCurrentDirty/per-slot dirty-bitmap semantics, tick-fence snapshot clearing, writes lost if dirty isn't marked
  • ClusterVersionedTests โ€” Versioned HEAD stored in the cluster with the revision chain kept separate, bulk iteration seeing the latest HEAD after a write
  • Sibling: Storage Modes โ€” clustered component data still splits by Versioned/SingleVersion/Transient mode, just batched
  • Sibling: Spatially-Coherent Entity Clustering โ€” for archetypes with a [SpatialIndex] field, additionally constrains cluster membership to one grid cell, and defines cluster migration
  • Sibling: Cluster Spatial Queries โ€” indexes cluster bounding boxes instead of per-entity, exploiting this same batched layout
  • Sibling: Spatial Tiers & Adaptive Dispatch โ€” per-cluster simulation tiers dispatch over this storage layer