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-
Versionedarchetype included โ a cluster stores theVersionedHEAD 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/Getwrites bypass dirty tracking: callMarkCurrentDirty()(whole cluster) orMarkSlotDirty(slot)(single entity) after writing, or the change never reaches the WAL/checkpoint. - A
SingleVersionorTransientcomponent with an[Index]field has no writable span:GetSpan/Getthrow 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 withClusterRef.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 withClusterRef.Set(comp, slotMask, values)(values indexed by slot; the bookkeeping is paid once per cluster); read it withGetReadOnlySpan. A write whose value keeps every indexed key records nothing for the index, so rewriting an indexed component's other fields costs little. AWriteSpatialthat changes an indexed field of the spatial component does the same bookkeeping.Setrefuses what an in-place store cannot do โ aVersionedcomponent, one that defaults to the Commit discipline, aSingleVersioncomponent in a transaction running under it (all three:OpenMut(...).Set(...)), the spatial component (WriteSpatial) and the realm key. WritingVersionedcomponents throughGetSpanis rejected under strict mode (CheckConfig.Enabled, anInvalidOperationException) โ useOpenMut(...).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, andEnabledBitsare all maintained per entity;Open/OpenMutneed 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).
EntityIdformat is unchanged โ clusters are a storage detail; entity identity, serialization, and WAL/network formats are unaffected.
๐งช Tests
- ClusterStorageTests โ cluster sizing/cache-line-aligned stride,
GetClusterEnumeratorbulk 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 โ
VersionedHEAD stored in the cluster with the revision chain kept separate, bulk iteration seeing the latest HEAD after a write
๐ Related
- Sibling: Storage Modes โ clustered component data still splits by
Versioned/SingleVersion/Transientmode, 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