DOTS//CORE local · not synced

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.

  1. Single vs parallel: the same job via Schedule (one worker) vs ScheduleParallel (all cores). The ratio is your real speedup.
  2. 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 Mode value (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 chunks. A trivial add won’t do it — that’s why 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.

m04.l07