DOTS//CORE local · not synced

m03 · Burst · lab · 35 min

Prove the vector

The brief

Every claim in this module is now yours to verify on your own CPU. You’ll measure three things and read one artifact: the Burst-vs-managed gap, the vectorized-vs-scalar gap, and the assembly that explains both. The whole point is to leave this lab having seen the packed instructions, not having taken vectorization on faith.

  1. Burst vs no-Burst: the same loop, [BurstCompile] on vs off.
  2. Vectorized vs scalar: two Burst loops, one clean and one with a deliberate vectorization-blocker, and the speed difference between them.
  3. Read the ASM: open the Inspector and confirm packed ops in the clean loop, scalar ops in the blocked one.

And throughout: defeat dead-code elimination, or you’ll measure nothing.

Before you start

This lab doesn’t use entities at all — just jobs and arrays. You’ll run the jobs from a single MonoBehaviour on Play, print timings to the Console, then read compiled assembly in the Burst Inspector.

Checklist:

  • Unity project with the Burst, Collections, Jobs, and Mathematics packages.
  • Console open; and you’ll need Jobs ▸ Burst ▸ Open Inspector for Part 3.
  • An empty scene with one empty GameObject to hold the driver script.

Where this runs

Create VectorProbe.cs, paste this in, and drop it on an empty GameObject in the scene. It allocates the arrays, warms up, and times a job by running it many times and taking the median — the honest way to benchmark. The job structs go in the marked slots:

using System.Diagnostics;
using System.Linq;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

public class VectorProbe : MonoBehaviour
{
    const int N = 10_000_000;   // large enough that the loop dominates
    NativeArray<float> _in, _out;

    void Start()
    {
        _in  = new NativeArray<float>(N, Allocator.Persistent);
        _out = new NativeArray<float>(1, Allocator.Persistent);
        for (int i = 0; i < N; i++) _in[i] = i * 0.001f;

        // >>> PART 1 & 2: call Measure(...) on each job here <<<
    }

    // Runs a job 5 untimed warmups, then 15 timed, and logs the median ms.
    double Measure(string label, System.Func<JobHandle> schedule)
    {
        for (int w = 0; w < 5; w++) schedule().Complete();      // warmup

        var samples = new double[15];
        var sw = new Stopwatch();
        for (int r = 0; r < samples.Length; r++)
        {
            sw.Restart();
            schedule().Complete();
            sw.Stop();
            samples[r] = sw.Elapsed.TotalMilliseconds;
        }
        double median = samples.OrderBy(x => x).ElementAt(samples.Length / 2);
        UnityEngine.Debug.Log($"[{label}] median {median:F3} ms  (result={_out[0]})");
        return median;
    }

    void OnDestroy()
    {
        if (_in.IsCreated)  _in.Dispose();
        if (_out.IsCreated) _out.Dispose();
    }
}

// >>> PART 1: paste CleanJob here (and toggle its [BurstCompile]) <<<
// >>> PART 2: paste ScalarForcedJob here <<<

Press Play. It compiles and allocates but prints nothing until you wire up the jobs below. Each Part adds a job struct and one Measure(...) call; the median ms appears in the Console. Logging _out[0] in the timer isn’t decoration — it’s what keeps the result observable (see the Trap).

Setup — a loop that CAN vectorize

Add this job struct at the Part 1 marker:

using Unity.Burst;

[BurstCompile]
struct CleanJob : IJob
{
    [ReadOnly] public NativeArray<float> In;
    public NativeArray<float> Out;   // DISTINCT from In — no aliasing

    public void Execute()
    {
        float acc = 0f;
        for (int i = 0; i < In.Length; i++)
            acc += In[i] * 2f + 1f;   // simple, independent, vectorizable
        Out[0] = acc;                 // observable → defeats DCE
    }
}

Part 1 — Burst vs no-Burst

Step 1. In Start(), at the marker, measure the Burst version:

double tBurst = Measure("clean+burst",
    () => new CleanJob { In = _in, Out = _out }.Schedule());

Press Play and read the [clean+burst] median.

Step 2. Now measure the same job with Burst off. Comment out [BurstCompile] above CleanJob, add a second measure call, and press Play again:

double tMono = Measure("clean+noBurst",
    () => new CleanJob { In = _in, Out = _out }.Schedule());

(Warmup and median are already handled inside Measure.)

Step 3. Record burst_vs_mono_ratio = tMono / tBurst. Expect a multiple — often several times faster. That gap is LLVM + vectorization over managed code, on the same source. Restore [BurstCompile] before moving on.

Part 2 — vectorized vs scalar (same compiler)

Now isolate vectorization itself by keeping Burst on for both and breaking it in one. The cleanest blocker is an inter-iteration dependency the compiler can’t vectorize.

Step 1. Paste this job at the Part 2 marker:

[BurstCompile]
struct ScalarForcedJob : IJob
{
    [ReadOnly] public NativeArray<float> In;
    public NativeArray<float> Out;

    public void Execute()
    {
        float acc = 0f;
        for (int i = 0; i < In.Length; i++)
        {
            // carry that forces serialization: each step needs the last result
            acc = math.sqrt(acc * acc + In[i]) + acc * 0.5f;
        }
        Out[0] = acc;   // still observable
    }
}

Step 2. Add its measure call in Start() and press Play:

double tScalar = Measure("scalar-forced",
    () => new ScalarForcedJob { In = _in, Out = _out }.Schedule());

Step 3. Both are Burst; both defeat DCE; the only difference is the clean loop has independent iterations and this one carries acc in a way that can’t go 8-at-a-time. Record vectorized_vs_scalar_ratio = tScalar / tBurst (the clean+burst median from Part 1). This is vectorization’s contribution alone, isolated from the Burst-vs-managed effect you measured in Part 1.

Part 3 — read the assembly

This part has no code to run — you’re reading what Burst already compiled.

Step 1. Open Jobs ▸ Burst ▸ Open Inspector, and in the list on the left select CleanJob.Execute.

Step 2. Look at the inner loop. You want to see packed ops — vmulps, vaddps, possibly vfmadd... — on ymm/xmm registers. Set saw_packed_ops to 1 if you find them.

Step 3. Now select ScalarForcedJob.Execute and confirm the inner loop is scalarvmulss, vaddss, sqrtss — one value per instruction.

Seeing the two side by side is the lab’s real deliverable: the suffix (ps vs ss) is the explanation for the ratio you measured in Part 2. You’re not inferring that vectorization happened; you’re reading it.

What you proved

Log burst_vs_mono_ratio, vectorized_vs_scalar_ratio, and saw_packed_ops. With the density numbers from M1 and the sync-point numbers from M2, you’re building a profile of where performance comes from on your machine — the dataset the capstone module revisits.

m03.l08