DOTS//CORE local · not synced

m02 · Systems · lab · 30 min

Feel the sync point

The brief

The command-buffer lesson claimed that a sync point is a hard barrier and that collapsing many structural changes into one playback pays that barrier once instead of many times. Put a number on it. Two systems that do the same work — spawn-and-destroy churn over 5,000 entities per frame — one doing it immediately through EntityManager, one recording to a single ECB. Measure the frame-time gap.

  1. System A (immediate): each frame, destroy 5,000 flagged entities by calling EntityManager.DestroyEntity per entity, inside the loop. Every call forces an immediate structural change and sync point.
  2. System B (deferred): identical logic, but record DestroyEntity into one EntityCommandBuffer and Playback once after the loop.
  3. Measure per-frame time for each. The ratio is the sync-point tax you just avoided.

Before you start

You need a Unity project with the Entities package. Unlike the density lab, this one runs continuously — the systems fire every frame while you’re in Play mode — so you’ll measure with a stopwatch that averages over many frames, then read the average from the Console.

Checklist:

  • Entities package present; Console window open.
  • An empty scene. These systems auto-register in the default world and run on Play; no scene objects needed.
  • You’ll enable exactly one churn system at a time (Part 1, then Part 2) while RespawnSystem stays on for both.

Where this runs

Create one script, SyncPointProbe.cs, and paste this in. It holds the shared types, the respawner, and a tiny timing helper each churn system will use. The two churn systems go in the marked slots:

using Unity.Entities;
using Unity.Collections;
using System.Diagnostics;

public struct Doomed : IComponentData { }   // tag marking entities to churn

// ── Timing helper: averages a system's own update cost 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 over {_frames} frames");
            _accMs = 0; _frames = 0;   // roll into the next 100-frame window
        }
    }
}

// ── Respawns the population each frame so both systems have equal work ──
public partial struct RespawnSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        var q = SystemAPI.QueryBuilder().WithAll<Doomed>().Build();
        int alive = q.CalculateEntityCount();
        for (int i = alive; i < 5000; i++)
        {
            var e = state.EntityManager.CreateEntity();
            state.EntityManager.AddComponent<Doomed>(e);
        }
    }
}

// >>> PART 1: paste ChurnImmediateSystem here <<<
// >>> PART 2: paste ChurnDeferredSystem here <<<

Press Play. With only RespawnSystem present it compiles and spins quietly — no churn yet, no timing line. Each Part adds one churn system, which prints an avg … ms/frame line to the Console every 100 frames once it’s the active system. Let it run a few seconds so the average settles before you trust it.

Part 1 — System A, immediate structural change

Step 1. Paste this at the Part 1 marker and press Play:

[UpdateAfter(typeof(RespawnSystem))]
public partial struct ChurnImmediateSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        FrameTimer.Begin();

        var em = state.EntityManager;
        // NOTE: must collect first — you cannot destroy while iterating.
        var doomed = SystemAPI.QueryBuilder().WithAll<Doomed>().Build()
                       .ToEntityArray(Allocator.Temp);
        foreach (var e in doomed)
            em.DestroyEntity(e);      // immediate structural change + sync point, each call
        doomed.Dispose();

        FrameTimer.EndAndReport("immediate");
    }
}

Step 2. Let it run ~2–3 seconds. Read the [immediate] line in the Console and record its number as immediate_frame_ms. Every DestroyEntity here is its own barrier mid-frame — that’s what you’re timing.

Part 2 — System B, one deferred playback

Step 1. Paste this at the Part 2 marker. Then comment out ChurnImmediateSystem (or gate it off) so only the deferred system runs, and press Play:

[UpdateAfter(typeof(RespawnSystem))]
public partial struct ChurnDeferredSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        FrameTimer.Begin();

        var ecb = new EntityCommandBuffer(Allocator.TempJob);
        foreach (var (_, e) in
                 SystemAPI.Query<RefRO<Doomed>>().WithEntityAccess())
            ecb.DestroyEntity(e);     // recorded, not executed

        ecb.Playback(state.EntityManager);   // ONE barrier, here
        ecb.Dispose();

        FrameTimer.EndAndReport("deferred");
    }
}

Step 2. Read the [deferred] line and note its ms/frame. Same 5,000 destroys, one sync point.

The number

Step 3. Compute the ratio from your two recorded averages:

ecb_vs_immediate_ratio = immediate_frame_ms / deferred_frame_ms

Expect the deferred system to be several times faster per frame — often 3–10× depending on your Entities version, entity count, and what else is in flight to be drained. The gap is the sync-point barrier cost, made visible: same work, same result, and the only difference is whether you hit the wall once or five thousand times.

Log ecb_vs_immediate_ratio and immediate_frame_ms. Together with the density numbers from Module 1, you’re now accumulating a picture of where your frames actually go — the dataset Module 13 charts back to you.

m02.l05