Parallel Entity Processing (QuerySystem.Parallel)
Multi-core entity chunking that skips per-chunk Transaction overhead for non-Versioned writes.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ฃ Advanced ยท Category: Runtime
๐ฏ What it solves
A game server with dozens of cores but only a handful of systems can't get throughput from DAG-level
parallelism alone โ most of the win has to come from splitting one system's entity set across workers.
Doing that naively (materialize the full entity list, give every chunk its own Transaction) burns
cycles on list-copying and per-chunk Transaction setup/commit before any game logic runs. Parallel
entity processing removes both costs for the common case (writing non-Versioned components), so
b.Parallel() scales a QuerySystem without changing its Execute loop.
โ๏ธ How it works (in brief)
The runtime picks one of four dispatch paths from two facts about the system: whether it has a
ChangeFilter and whether it declares WritesVersioned(). Non-Versioned systems get a reusable
PointInTimeAccessor (PTA) โ attached once per tick to a fresh MVCC snapshot โ and each worker gets its
own EntityAccessor from it, so entity access has no per-entity dictionary lookup and no Transaction
commit. Unfiltered non-Versioned systems go further: the entity set is never materialized into an array
โ each chunk iterates a contiguous slice of the View's underlying hash map directly. Filtered systems
(and any system writing Versioned components) materialize a list instead โ dirty-only when filtered,
full-set otherwise โ and Versioned writers get a real per-chunk Transaction, since the PTA cannot
perform copy-on-write.
| Path | Selected when | Prepare cost | Chunk entity access |
|---|---|---|---|
| 1 โ Full, non-Versioned | no ChangeFilter, no WritesVersioned() |
O(1) โ no list built | ctx.Accessor (PTA), zero-copy hash-map slice |
| 2 โ Filtered, non-Versioned | ChangeFilter set, no WritesVersioned() |
O(dirty count) | ctx.Accessor (PTA), pooled slice |
| 3 โ Full, Versioned | no ChangeFilter, WritesVersioned() |
O(entity count) | ctx.Transaction, pooled slice |
| 4 โ Filtered, Versioned | ChangeFilter set, WritesVersioned() |
O(dirty count) | ctx.Transaction, pooled slice |
Chunk count. A system's first dispatch is split by entity count: min(round(WorkerCount ร ChunksPerWorker), ceil(entities / ParallelQueryMinChunkSize)). From the second dispatch on (RuntimeOptions.CostBasedChunking, on by default), the split follows what the system measurably cost the time before. Its chunks spread over round(WorkerCount ร ChunksPerWorker) while each carries 25โ100 ยตs of work; below that it gets fewer chunks, above it up to twice as many. Workers claim the extra chunks as they come free, so the pool no longer waits on one slow last chunk.
๐ป Usage
public class MovementSystem : QuerySystem
{
protected override void Configure(SystemBuilder b) => b
.Name("Movement")
.Input(() => activeUnitsView)
.Parallel(); // Path 1: no ChangeFilter, no WritesVersioned
protected override void Execute(TickContext ctx)
{
foreach (var id in ctx.Entities)
{
var entity = ctx.Accessor.OpenMut(id); // PTA โ no per-entity dictionary lookup
var pos = entity.Read<EcsPosition>();
pos.X += entity.Read<EcsVelocity>().X * ctx.DeltaTime;
entity.Set<EcsPosition>(pos);
}
}
}
// Path 4 (filtered + Versioned write): same Configure, plus
// .ChangeFilter(typeof(EcsBuffs)).WritesVersioned()
// โ Execute then uses ctx.Transaction.OpenMut(id) instead of ctx.Accessor.
| Option | Default | Effect |
|---|---|---|
b.Parallel() |
off | Enables chunking; required for any of the four paths |
b.ChangeFilter(...) |
none | Narrows the dispatched set to dirty/Added entities (Paths 2/4) |
b.WritesVersioned() |
off | Switches chunk access from ctx.Accessor to a per-chunk ctx.Transaction (Paths 3/4) |
b.ChunksPerWorker(factor) |
1.0 |
Oversubscribes chunk count beyond worker count, range [1.0, 64.0] |
โ ๏ธ Guarantees & limits
ctx.Accessorreads all storage modes (Versioned via MVCC chain walk, SingleVersion/Transient direct) and writes SingleVersion/Transient, but throws on a Versioned write โ declareWritesVersioned()instead.ctx.Accessorcannot Spawn, Destroy, Commit, or Rollback. Usectx.Commandsto queue deferred spawns and destroys from inside a parallel chunk; the engine applies the buffer at the fence phase after all chunks complete. Commit and Rollback remain exclusive toctx.Transaction.- A parallel system's
ctx.ChunkCountcan change from one tick to the next and reach twiceround(WorkerCount ร ChunksPerWorker). Never size per-chunk state by it; index per worker (ctx.WorkerId). - A dispatch's chunks tile the cluster list as it stood when the dispatch began: clusters a spawn appends while they run are walked from the next tick (rule CD-02).
- Path 1 vs. a per-chunk
Transaction: ~2.2x lower per-chunk overhead (PTA ~380ยตs/chunk vs. Transaction ~850ยตs/chunk) โ only declareWritesVersioned()when actually needed. - All workers in a tick see the same frozen MVCC snapshot (one TSN per
Attach()); the PTA is reused across ticks and across all non-Versioned parallel systems with zero steady-state allocation. - Scaling is good up to one CCD's worth of cores; cross-CCD
EntityMapaccess flattens the curve on multi-CCD hardware (measured: ~89% efficiency at 8 workers, ~42% at 16 on a 2-CCD part). - The DAG, not the runtime, guarantees chunks across different parallel systems don't race.
- Out of scope: cross-system pipelining (next system's chunk K starting before this system fully completes).
๐งช Tests
- ParallelQueryTests โ all four dispatch paths (
ParallelQuery_NonVersioned_ChunkReceivesAccessor,ParallelQuery_WritesVersioned_ChunkReceivesTransaction), chunk partitioning, chunk-throw isolation - ChunksPerWorkerTests โ
ChunksPerWorkeroversubscription factor vs. worker-count cap and entity-count cap - CostChunkingTests โ
RuntimeOptions.CostBasedChunking: the cost rule's grain, an expensive system split past the worker count, the entity rule kept when it is off or when a system setsMinChunkSize - ChunkClusterRangeTests โ a dispatch's chunks tile the cluster list Prepare counted, even when a spawn grows it while they run (CD-02)
๐ Related
- Sibling feature: QuerySystem