DOTS//CORE local · not synced

m07 · Advanced Patterns · lab · 35 min

Break determinism, then fix it

The brief

The last two lessons made claims you have no reason to believe yet: that parallel accumulation silently destroys determinism, that the safety system won’t warn you, and that a one-bit difference compounds into a visible one.

Those are testable. You will run the same simulation from the same seed repeatedly and checksum the result:

  1. An order-independent version — per-entity work only. Prove it produces identical checksums every run.
  2. A parallel-accumulation version — mathematically identical, structurally different. Count how many distinct results five runs produce.
  3. Find the first tick where two runs diverge.
  4. Inject a deliberate one-ULP difference and measure how many ticks it takes to become visible.

Part 4 is the one that changes how you think. Everyone accepts “floating point is imprecise.” Watching a last-bit difference become a gameplay-visible divergence in a countable number of ticks is different.

Before you start

  • Unity project with the Entities package. Console window open.
  • An empty scene. Everything is created in code.
  • Burst enabled (default). Part 2’s divergence depends on real parallel scheduling, so leave it on.
  • Ideally a machine with ≥4 cores. On 1–2 cores the scheduler may serialize the parallel job and Part 2 will look deterministic — see the trap in Part 2.

Where this runs

One file, DeterminismProbe.cs. It runs a fixed number of simulation ticks, checksums the world state, and logs it.

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;

public struct Body : IComponentData
{
    public float3 Pos;
    public float3 Vel;
}

// Checksum: reinterpret float bits as int so ANY difference shows, even one ULP.
public partial struct DeterminismProbeSystem : ISystem
{
    // >>> PARTS 1-4: change MODE, exit Play, recompile, re-enter Play <<<
    //   1 = order-independent   2 = parallel accumulation
    //   3 = per-tick checksums  4 = one-ULP injection
    const int MODE = 1;

    const int Count = 4096;
    const int Ticks = 600;

    bool _built;
    int _tick;
    NativeArray<float3> _shared;   // used by MODE 2

    public void OnCreate(ref SystemState state)
    {
        _shared = new NativeArray<float3>(1, Allocator.Persistent);
    }

    public void OnDestroy(ref SystemState state)
    {
        if (_shared.IsCreated) _shared.Dispose();
    }

    public void OnUpdate(ref SystemState state)
    {
        if (!_built) { Build(ref state); _built = true; return; }
        if (_tick >= Ticks) return;

        var em = state.EntityManager;
        var q  = em.CreateEntityQuery(ComponentType.ReadWrite<Body>());

        if (MODE == 1 || MODE == 3 || MODE == 4)
        {
            // Order-independent: each entity's result depends only on itself.
            new StepJob { Dt = 1f / 60f }.ScheduleParallel();
        }
        else // MODE == 2
        {
            // Parallel accumulation into shared state. Thread-safe. Not deterministic.
            _shared[0] = float3.zero;
            state.Dependency = new AccumulateJob
            {
                Sum = _shared
            }.ScheduleParallel(state.Dependency);
            state.Dependency.Complete();

            float3 centre = _shared[0] / Count;
            new PullJob { Centre = centre, Dt = 1f / 60f }.ScheduleParallel();
        }

        state.Dependency.Complete();
        _tick++;

        bool report = (MODE == 3 || MODE == 4)
            ? true                      // every tick
            : (_tick == Ticks);         // final only

        if (report)
        {
            var bodies = q.ToComponentDataArray<Body>(Allocator.Temp);
            ulong sum = 0;
            for (int i = 0; i < bodies.Length; i++)
            {
                var p = bodies[i].Pos;
                sum = sum * 31 + (ulong)math.asuint(p.x);
                sum = sum * 31 + (ulong)math.asuint(p.y);
                sum = sum * 31 + (ulong)math.asuint(p.z);
            }
            bodies.Dispose();

            if (MODE == 3 || MODE == 4)
            {
                if (_tick % 20 == 0 || _tick < 5)
                    UnityEngine.Debug.Log($"[MODE{MODE}] tick {_tick} checksum = {sum:X16}");
            }
            else
            {
                UnityEngine.Debug.Log($"[MODE{MODE}] FINAL after {Ticks} ticks: checksum = {sum:X16}");
            }
        }
    }

    void Build(ref SystemState state)
    {
        var em   = state.EntityManager;
        var arch = em.CreateArchetype(typeof(Body));
        using var e = em.CreateEntity(arch, Count, Allocator.Temp);

        var rng = new Unity.Mathematics.Random(0xC0FFEE);   // FIXED seed
        for (int i = 0; i < Count; i++)
        {
            var pos = rng.NextFloat3(-10f, 10f);
            var vel = rng.NextFloat3(-1f, 1f);

            // >>> PART 4: inject one ULP into a single entity <<<
            if (MODE == 4 && i == 0)
                pos.x = math.asfloat(math.asuint(pos.x) + 1u);

            em.SetComponentData(e[i], new Body { Pos = pos, Vel = vel });
        }
        UnityEngine.Debug.Log($"[MODE{MODE}] built {Count} bodies, seed fixed, running {Ticks} ticks");
    }
}

[BurstCompile]
public partial struct StepJob : IJobEntity
{
    public float Dt;
    void Execute(ref Body b)
    {
        // Pure per-entity: depends on nothing but itself.
        b.Vel += new float3(0f, -9.81f * Dt, 0f);
        b.Pos += b.Vel * Dt;
        if (b.Pos.y < -10f) { b.Pos.y = -10f; b.Vel.y = -b.Vel.y * 0.8f; }
    }
}

[BurstCompile]
public partial struct AccumulateJob : IJobEntity
{
    [NativeDisableParallelForRestriction] public NativeArray<float3> Sum;
    void Execute(in Body b)
    {
        // Order of combination across threads is NOT fixed. This is the bug.
        Sum[0] += b.Pos;
    }
}

[BurstCompile]
public partial struct PullJob : IJobEntity
{
    public float3 Centre;
    public float  Dt;
    void Execute(ref Body b)
    {
        b.Vel += math.normalizesafe(Centre - b.Pos) * Dt;
        b.Pos += b.Vel * Dt;
    }
}

The system auto-registers; nothing goes in the scene. Press Play, wait for the FINAL line, then stop. The build line appears immediately.

Part 1 — prove order-independence is deterministic

Step 1. With MODE = 1, press Play. Wait for the FINAL line. Note the checksum.

Step 2. Stop. Press Play again. Note the checksum. Do this a third time.

Step 3. All three identical? Record checksum_stable_runs as 1. If any differ, record 0 and stop — something in your setup is nondeterministic before you’ve even introduced the bug, and Parts 2–4 won’t mean anything.

Step 4. Note what makes this work. StepJob runs ScheduleParallel across every core, and the scheduler distributes chunks differently each run. It doesn’t matter, because each entity’s result depends only on its own data. Order-independence is why parallel and deterministic coexist here.

Part 2 — break it

Step 1. Exit Play. Edit MODE to 2. Recompile. Press Play. Note the FINAL checksum.

Step 2. Repeat five times total. Write down each checksum.

Step 3. Count the distinct values. Record as divergent_run_count.

Step 4. Sit with what just happened. The math is the same every run. The seed is fixed. The safety system reported nothing — AccumulateJob is thread-safe. And you got multiple different simulations, because the order in which threads combined their contributions into Sum[0] varied, and floating-point addition isn’t associative.

Part 3 — find the divergence point

Step 1. Exit Play. Edit MODE to 3. Recompile.

Step 2. Press Play, let it finish, and copy the whole Console output somewhere. Repeat for a second run.

Step 3. Compare the two logs tick by tick. Find the earliest tick where the checksums differ. Record as first_divergent_tick.

Step 4. Note that MODE 3 uses the order-independent job — so this should show no divergence at all, matching Part 1. That’s the control. If you want to find the divergence tick for the broken version, change StepJob to the MODE-2 path in the branch and re-run; the point of the control is to prove the per-tick checksum machinery itself isn’t the source of variation.

Part 4 — watch one bit become visible

Step 1. Exit Play. Edit MODE to 4. Recompile. This injects a single ULP into entity 0’s starting x-position — the smallest representable difference in a float.

Step 2. Run it and save the per-tick checksums.

Step 3. Now set the injection line to not fire (change i == 0 to i == -1), recompile, run again, and save those checksums.

Step 4. The checksums differ from tick 1, by construction. What you want is when the position difference becomes visible, not just present. Add this line inside the checksum block to print entity 0’s x directly:

if (_tick % 20 == 0)
    UnityEngine.Debug.Log($"[MODE4] tick {_tick} entity0.x = {bodies[0].Pos.x:F9}");

Step 5. Compare the two runs’ entity0.x values and find the first reported tick where they differ by more than 0.01. Record as ulp_growth_ticks.

Step 6. That number is the lesson. A difference of one bit — the smallest difference two floats can have — became a difference you could see in gameplay within that many ticks. At 60 Hz, divide by 60 for seconds. This is why “close enough” is not a determinism standard, and why lockstep clients drift into different games rather than slightly different ones.

What you proved

Four metrics into the dataset: checksum_stable_runs, divergent_run_count, first_divergent_tick and ulp_growth_ticks. These are the prerequisites for Module 15’s rollback material — prediction re-simulates past ticks and requires the re-run to match, so checksum_stable_runs = 1 is the precondition for the entire Netcode module being meaningful. Module 13 charts them alongside your frame-time history.

m07.l06