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.
- Burst vs no-Burst: the same loop,
[BurstCompile]on vs off. - Vectorized vs scalar: two Burst loops, one clean and one with a deliberate vectorization-blocker, and the speed difference between them.
- 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 scalar — vmulss, 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.