Parallelism Guide
The KeenEyes.Parallelism plugin enables parallel execution of systems and provides APIs for manual parallel work distribution. This guide covers parallel system scheduling, the job system, and performance profiling.
Overview
The parallelism plugin provides:
- Parallel System Execution - Automatically batch systems that don't conflict
- Component Dependency Tracking - Declare read/write dependencies per system
- Job System API - Manual parallel work distribution for advanced use cases
- Profiler - Measure execution times and identify bottlenecks
Installation
NuGet Package
dotnet add package KeenEyes.Parallelism
Plugin Installation
using KeenEyes;
using KeenEyes.Parallelism;
using var world = new World();
world.InstallPlugin(new ParallelSystemPlugin());
// Or with options
world.InstallPlugin(new ParallelSystemPlugin(new ParallelSystemOptions
{
MaxDegreeOfParallelism = 4,
MinBatchSizeForParallel = 2
}));
Parallel System Scheduling
How It Works
The scheduler analyzes system component dependencies to determine which systems can run concurrently:
- Systems declare dependencies - What components they read/write
- Conflict detection - Systems that write to the same component conflict
- Batch creation - Non-conflicting systems are grouped into parallel batches
- Sequential batch execution - Batches run one after another; systems within a batch run in parallel
Declaring Dependencies
Systems implement ISystemDependencyProvider to declare their component access patterns:
public class MovementSystem : SystemBase, ISystemDependencyProvider
{
public void GetDependencies(ISystemDependencyBuilder builder)
{
builder
.Reads<Velocity>() // Read-only access
.Writes<Position>(); // Read-write access
}
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Position, Velocity>())
{
ref var pos = ref World.Get<Position>(entity);
ref readonly var vel = ref World.Get<Velocity>(entity);
pos.X += vel.X * deltaTime;
pos.Y += vel.Y * deltaTime;
}
}
}
public class HealthRegenSystem : SystemBase, ISystemDependencyProvider
{
public void GetDependencies(ISystemDependencyBuilder builder)
{
builder.Writes<Health>();
}
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Health>())
{
ref var health = ref World.Get<Health>(entity);
health.Current = Math.Min(health.Current + 1, health.Max);
}
}
}
In this example, MovementSystem and HealthRegenSystem can run in parallel because they access different components.
Conflict Rules
Systems conflict when:
- Write-Write - Both systems write to the same component type
- Read-Write - One system reads a component another writes
Systems do NOT conflict when:
- Read-Read - Both systems only read the same component (safe for parallel access)
Registering Systems
var scheduler = world.GetExtension<ParallelSystemScheduler>();
// Systems that implement ISystemDependencyProvider
var movement = new MovementSystem();
var healthRegen = new HealthRegenSystem();
movement.Initialize(world);
healthRegen.Initialize(world);
scheduler.RegisterSystem(movement);
scheduler.RegisterSystem(healthRegen);
// Or with explicit dependencies
scheduler.RegisterSystem(customSystem, new ComponentDependencies(
reads: [typeof(Position)],
writes: [typeof(Velocity)]));
Running Parallel Updates
// In your game loop
while (running)
{
var deltaTime = CalculateDeltaTime();
// Execute all systems in parallel batches
scheduler.UpdateParallel(deltaTime);
}
Analyzing Batches
var scheduler = world.GetExtension<ParallelSystemScheduler>();
var analysis = scheduler.GetAnalysis();
Console.WriteLine($"Batches: {analysis.BatchCount}");
Console.WriteLine($"Max Parallelism: {analysis.MaxParallelism}");
Console.WriteLine($"Conflicts: {analysis.ConflictCount}");
// View conflict details
foreach (var conflict in analysis.Conflicts)
{
Console.WriteLine($" {conflict.SystemA.Name} <-> {conflict.SystemB.Name}");
Console.WriteLine($" Components: {string.Join(", ", conflict.ConflictingComponents.Select(c => c.Name))}");
}
Parallel Query Iteration
For systems processing large numbers of entities, use parallel query extensions:
using KeenEyes.Parallelism;
public class ParallelMovementSystem : SystemBase
{
public override void Update(float deltaTime)
{
// Process entities in parallel (chunk-level parallelism)
World.Query<Position, Velocity>()
.ForEachParallel((Entity entity, ref Position pos, ref Velocity vel) =>
{
pos.X += vel.X * deltaTime;
pos.Y += vel.Y * deltaTime;
});
}
}
Configuration
// Only parallelize if enough entities (default: 1000)
query.ForEachParallel(action, minEntityCount: 500);
// Read-only variant for better performance when not modifying
query.ForEachParallelReadOnly((Entity e, in Position pos, in Velocity vel) =>
{
// Read-only access
});
When to Use
- Large entity counts - Parallelism overhead is only worth it with many entities
- Independent processing - Each entity can be processed without affecting others
- CPU-bound work - Heavy calculations benefit most from parallelism
Job System API
The job system provides fine-grained control over parallel work distribution for advanced scenarios.
Basic Jobs
using KeenEyes.Parallelism;
public struct ProcessDataJob : IJob
{
public float[] Data { get; init; }
public float Multiplier { get; init; }
public void Execute()
{
for (int i = 0; i < Data.Length; i++)
{
Data[i] *= Multiplier;
}
}
}
// Usage
using var scheduler = new JobScheduler();
var job = new ProcessDataJob { Data = myData, Multiplier = 2.0f };
var handle = scheduler.Schedule(job);
// Do other work...
handle.Complete(); // Wait for job to finish
Parallel Jobs
Execute across multiple indices in parallel:
public struct UpdatePositionsJob : IParallelJob
{
public Position[] Positions { get; init; }
public Velocity[] Velocities { get; init; }
public float DeltaTime { get; init; }
public void Execute(int index)
{
Positions[index].X += Velocities[index].X * DeltaTime;
Positions[index].Y += Velocities[index].Y * DeltaTime;
}
}
// Usage
var job = new UpdatePositionsJob
{
Positions = positions,
Velocities = velocities,
DeltaTime = 0.016f
};
var handle = scheduler.ScheduleParallel(job, positions.Length);
handle.Complete();
Batch Jobs
Process ranges of items for better cache locality:
public struct SumBatchJob : IBatchJob
{
public int[] Values { get; init; }
public int[] PartialSums { get; init; }
public void Execute(int startIndex, int count)
{
var sum = 0;
for (int i = startIndex; i < startIndex + count; i++)
{
sum += Values[i];
}
PartialSums[startIndex / count] = sum;
}
}
// Usage
var handle = scheduler.ScheduleBatch(job, values.Length, batchSize: 64);
Job Dependencies
Chain jobs together:
// First job
var prepareHandle = scheduler.Schedule(new PrepareDataJob { Data = data });
// Second job depends on first
var processHandle = scheduler.Schedule(new ProcessDataJob { Data = data }, prepareHandle);
// Third job depends on second
var finalizeHandle = scheduler.Schedule(new FinalizeJob { Data = data }, processHandle);
// Wait for entire chain
finalizeHandle.Complete();
Combining Dependencies
var handle1 = scheduler.Schedule(job1);
var handle2 = scheduler.Schedule(job2);
var handle3 = scheduler.Schedule(job3);
// Wait for all three
var combined = JobHandle.CombineDependencies(handle1, handle2, handle3);
combined.Complete();
Error Handling
var handle = scheduler.Schedule(new RiskyJob());
handle.Complete();
if (handle.IsFaulted)
{
Console.WriteLine($"Job failed: {handle.Exception?.Message}");
}
Scheduler Options
var scheduler = new JobScheduler(new JobSchedulerOptions
{
MaxDegreeOfParallelism = 4 // Limit concurrent threads (-1 for unlimited)
});
Profiler
The profiler helps identify performance bottlenecks in parallel execution.
Basic Usage
var scheduler = world.GetExtension<ParallelSystemScheduler>();
var profiler = new ParallelProfiler(scheduler);
// Start profiling
profiler.Start();
// Run your game loop
for (int i = 0; i < 1000; i++)
{
profiler.BeginFrame();
scheduler.UpdateParallel(0.016f);
profiler.EndFrame();
}
// Stop and analyze
profiler.Stop();
var metrics = profiler.GetMetrics();
Metrics
var metrics = profiler.GetMetrics();
Console.WriteLine($"Frames: {metrics.TotalFrames}");
Console.WriteLine($"Avg Frame Time: {metrics.AverageFrameTimeMs:F2}ms");
Console.WriteLine($"Parallel Efficiency: {metrics.ParallelEfficiency:P1}");
Console.WriteLine($"Thread Utilization: {metrics.ThreadUtilization:P1}");
Console.WriteLine($"Unique Threads Used: {metrics.UniqueThreadsUsed}");
Console.WriteLine($"Batches: {metrics.BatchCount}");
Console.WriteLine($"Conflicts: {metrics.ConflictCount}");
Console.WriteLine($"Max Parallelism: {metrics.MaxParallelism}");
// Per-system stats
foreach (var (systemType, stats) in metrics.SystemStats)
{
Console.WriteLine($" {systemType.Name}:");
Console.WriteLine($" Executions: {stats.ExecutionCount}");
Console.WriteLine($" Total: {stats.TotalTimeMs:F2}ms");
Console.WriteLine($" Average: {stats.AverageTimeMs:F3}ms");
Console.WriteLine($" Min/Max: {stats.MinTimeMs:F3}/{stats.MaxTimeMs:F3}ms");
}
// Bottlenecks
foreach (var bottleneck in metrics.Bottlenecks)
{
Console.WriteLine($"BOTTLENECK: {bottleneck.SystemType.Name}");
Console.WriteLine($" Reason: {bottleneck.Reason}");
}
Timing Report
// Get formatted text report
var report = profiler.ExportTimingReport();
Console.WriteLine(report);
File.WriteAllText("profile_report.txt", report);
Dependency Graph Visualization
Export to DOT format for visualization with Graphviz:
var dot = profiler.ExportDependencyGraph();
File.WriteAllText("dependencies.dot", dot);
// Then generate image:
// dot -Tpng dependencies.dot -o dependencies.png
The graph shows:
- Systems grouped by execution batch (clusters)
- Conflicts as red dashed edges
- Conflicting component names on edges
Best Practices
Do
- Declare accurate dependencies - Only list components your system actually accesses
- Prefer reads over writes - Read-only access allows more parallelism
- Keep systems focused - Smaller systems with fewer dependencies parallelize better
- Profile before optimizing - Use the profiler to identify actual bottlenecks
- Use parallel queries for large entity counts - Set appropriate
minEntityCountthreshold
Don't
- Don't share mutable state - Systems running in parallel must not share data unsafely
- Don't forget thread safety - Use
Interlockedoperations or locks when needed - Don't over-parallelize - Parallelism has overhead; use it for substantial work
- Don't ignore conflicts - High conflict counts indicate poor parallelism potential
Thread Safety Considerations
When systems run in parallel:
// SAFE: Each entity processed independently
public void Execute(int index)
{
positions[index].X += velocities[index].X;
}
// SAFE: Atomic operations
public void Execute(int index)
{
Interlocked.Add(ref totalCount, 1);
}
// UNSAFE: Shared mutable state without synchronization
private int counter;
public void Execute(int index)
{
counter++; // Race condition!
}
CommandBuffer Integration
Systems in parallel batches receive isolated CommandBuffers:
public class SpawnerSystem : SystemBase, ISystemDependencyProvider, ICommandBufferConsumer
{
private ICommandBuffer? buffer;
public void GetDependencies(ISystemDependencyBuilder builder)
{
builder.Reads<Spawner>();
}
public void SetCommandBuffer(ICommandBuffer commandBuffer)
{
buffer = commandBuffer;
}
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Spawner>())
{
// Safe to use during parallel execution
buffer?.Spawn()
.With(new Position { X = 0, Y = 0 })
.With(new Velocity { X = 1, Y = 0 });
}
}
}
CommandBuffers are flushed after each batch completes, ensuring deterministic ordering.
Next Steps
- Systems Guide - System design patterns
- Plugins Guide - Plugin architecture
- Command Buffer Guide - Deferred entity operations