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 |
๐ป 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
ref var pos = ref entity.Write<EcsPosition>();
pos.X += entity.Read<EcsVelocity>().X * ctx.DeltaTime;
}
}
}
// 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 โ structural changes need an upstream non-parallel system.- 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: Spawn/Destroy inside a parallel chunk; 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
๐ Related
- Sibling feature: QuerySystem