m04 · Jobs · lab · 35 min
Spread it across cores
The brief
The whole module argued that jobs turn independent per-chunk work into per-core throughput, and that the way you lose that throughput is over-completing. Measure both on your own machine. One heavy per-entity job, scheduled three ways, and the numbers that separate real parallelism from parallelism you accidentally serialized.
- Single vs parallel: the same job via
Schedule(one worker) vsScheduleParallel(all cores). The ratio is your real speedup. - The over-completion penalty: a chain of jobs completed once at the
end vs the same chain with a
Complete()after each. The ratio is the cost of the most common jobs mistake.
Make the per-entity work heavy enough to dominate scheduling overhead, or you’ll measure noise instead of parallelism.
Before you start
You need the Entities, Burst, Jobs, and Mathematics packages. Like the sync-point lab, the probe runs every frame in Play mode and reports a rolling average to the Console. You’ll switch which scheduling path is active by editing one system and re-pressing Play.
Checklist:
- Packages above installed; Console open.
- An empty scene — the systems auto-register and run on Play.
- You’ll flip a
Modevalue (or comment/uncomment blocks) to select which of the three schedulings you’re timing, one at a time.
Where this runs
Create ParallelProbe.cs and paste this in. It defines the components,
the heavy job, a system that populates ~200,000 entities once, and the
probe system with a Mode switch and the same FrameTimer helper you
used for sync points. Your only edits are choosing a Mode and reading
the Console:
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Jobs;
using Unity.Transforms; // LocalTransform
using System.Diagnostics;
public struct Velocity : IComponentData { public float3 Value; }
// ── Heavy but pure per-entity math so cost dominates scheduling ──────────
[BurstCompile]
partial struct HeavyJob : IJobEntity
{
void Execute(ref LocalTransform t, in Velocity v)
{
float3 p = t.Position;
for (int k = 0; k < 64; k++)
p = math.normalize(p + v.Value) * math.length(p) + math.sin(p);
t.Position = p;
}
}
// ── Timing helper: rolling average over ~100 frames ─────────────────────
public static class FrameTimer
{
static readonly Stopwatch _sw = new Stopwatch();
static double _accMs; static int _frames;
public static void Begin() => _sw.Restart();
public static void EndAndReport(string label)
{
_sw.Stop(); _accMs += _sw.Elapsed.TotalMilliseconds;
if (++_frames >= 100)
{
UnityEngine.Debug.Log($"[{label}] avg {_accMs / _frames:F3} ms/frame");
_accMs = 0; _frames = 0;
}
}
}
// ── Create 200k entities once ───────────────────────────────────────────
public partial struct PopulateSystem : ISystem
{
bool _done;
public void OnUpdate(ref SystemState state)
{
if (_done) return;
var arch = state.EntityManager.CreateArchetype(
typeof(LocalTransform), typeof(Velocity));
var ents = state.EntityManager.CreateEntity(arch, 200_000, Allocator.Temp);
foreach (var e in ents)
{
state.EntityManager.SetComponentData(e, LocalTransform.Identity);
state.EntityManager.SetComponentData(e, new Velocity { Value = new float3(1, 0.5f, 0.25f) });
}
ents.Dispose();
_done = true;
}
}
Then add the probe system. It reads a single Mode constant so you pick
one scheduling path per Play session:
[UpdateAfter(typeof(PopulateSystem))]
public partial struct ParallelProbe : ISystem
{
// Change this, press Play, read the Console. One mode at a time.
const int Mode = 1; // 1=single, 2=parallel, 3=completeOnce, 4=completeEach
public void OnUpdate(ref SystemState state)
{
FrameTimer.Begin();
if (Mode == 1) // >>> PART 1 single <<<
{
}
else if (Mode == 2) // >>> PART 1 parallel <<<
{
}
else if (Mode == 3) // >>> PART 2 completed once <<<
{
}
else if (Mode == 4) // >>> PART 2 completed each <<<
{
}
FrameTimer.EndAndReport($"mode{Mode}");
}
}
Press Play. PopulateSystem fills the world once; ParallelProbe runs
the selected mode each frame and logs [mode N] avg … ms/frame every 100
frames. Let it settle a few seconds before recording. You’ll fill the four
if bodies from the Parts below.
Setup — why the work is heavy
Parallel speedup only shows when there’s enough work per entity to outrun
the fixed cost of distributing HeavyJob runs 64 iterations of real vector math per entity, over
200,000 entities across many chunks. Keep that count fixed across every
measurement; changing it mid-lab invalidates the comparison.
Part 1 — single-threaded vs parallel
Step 1. Fill the Mode == 1 body (single worker), set Mode = 1, and
press Play:
var h = new HeavyJob().Schedule(state.Dependency);
h.Complete();
Record the [mode1] average.
Step 2. Fill the Mode == 2 body (all workers, per-chunk
distribution), set Mode = 2, press Play:
var h = new HeavyJob().ScheduleParallel(state.Dependency);
h.Complete();
Record the [mode2] average.
Step 3. Record parallel_vs_single_ratio = mode1 / mode2, and
effective_cores as that ratio rounded. On an 8-core machine expect
somewhere in the 4–7× range — never a perfect 8× (scheduling overhead,
memory bandwidth limits, and uneven chunk counts all take a cut). Seeing
“effective cores” land below your physical core count is normal and worth
internalizing: parallel speedup is real but never free or perfect.
Part 2 — the over-completion penalty
Now reproduce the classic mistake and price it. You’ll schedule a three-job chain two ways.
Step 1. Fill the Mode == 3 body — correct: chain via handles,
complete once. Set Mode = 3, press Play:
var a = new HeavyJob().ScheduleParallel(state.Dependency);
var b = new HeavyJob().ScheduleParallel(a);
var c = new HeavyJob().ScheduleParallel(b);
c.Complete(); // ONE main-thread wait, at the end
Record the [mode3] average.
Step 2. Fill the Mode == 4 body — the anti-pattern: complete after
every job. Set Mode = 4, press Play:
var a = new HeavyJob().ScheduleParallel(state.Dependency); a.Complete();
var b = new HeavyJob().ScheduleParallel(state.Dependency); b.Complete();
var c = new HeavyJob().ScheduleParallel(state.Dependency); c.Complete();
Record the [mode4] average.
Step 3. Record overcomplete_penalty = mode4 / mode3. Each
mid-chain Complete is a main-thread stall that drains the workers before
the next job can be scheduled, so the system can never overlap scheduling
with execution. The penalty is the barrier cost from Module 2, now
self-inflicted and measured.
What you proved
Log parallel_vs_single_ratio, overcomplete_penalty, and effective_cores. Together with density (M1), sync points (M2), and vectorization (M3), you now have a four-part profile of where DOTS performance comes from on your specific machine — layout, deferral, compilation, and parallelism, each measured rather than assumed.