Table of Contents

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.Accessor reads all storage modes (Versioned via MVCC chain walk, SingleVersion/Transient direct) and writes SingleVersion/Transient, but throws on a Versioned write โ€” declare WritesVersioned() instead.
  • ctx.Accessor cannot 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 declare WritesVersioned() 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 EntityMap access 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 โ€” ChunksPerWorker oversubscription factor vs. worker-count cap and entity-count cap