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);
ref var pos = ref entity.Write(Ant.Position);
pos.X += 1;
โ ๏ธ 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.
- Eligibility is automatic, not opt-in: an archetype clusters if it has at least one
SingleVersionorTransientcomponent. Pure-Versionedarchetypes andTransientcomponents with indexed fields stay on the legacy per-entity path โ there is no manual opt-out. - Direct
GetSpan/Getwrites bypass dirty tracking: callMarkCurrentDirty()(whole cluster) orMarkSlotDirty(slot)(single entity) after writing, or the change never reaches the WAL/checkpoint. WritingVersionedcomponents throughGetSpanis rejected by design (Debug.Assert) โ useOpenMut/Writefor 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