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 EntityManager, one recording
to a single ECB. Measure the frame-time gap.
- System A (immediate): each frame, destroy 5,000 flagged entities
by calling
EntityManager.DestroyEntityper entity, inside the loop. Every call forces an immediate structural change and sync point. - System B (deferred): identical logic, but record
DestroyEntityinto oneEntityCommandBufferandPlaybackonce after the loop. - 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
RespawnSystemstays 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.