DOTS//CORE local · not synced

m03 · Burst · reading · 9 min

Reading the ASM — proving it, not hoping it

The only source of truth

Every claim in this module so far — it vectorized, the branch broke it, the math type became one instruction — is checkable, and there is exactly one place to check it: the assembly Burst generated. Unity ships the Burst Inspector (Jobs ▸ Burst ▸ Open Inspector), which shows you, for any Burst-compiled method, the actual machine instructions produced for your target CPU. Everything else is inference; this is the artifact.

Learning to read it doesn’t require deep assembly fluency — it requires recognizing a few patterns. Vectorized floating-point math shows up as instructions on the wide registers: vmulps, vaddps, vfmadd... on ymm/xmm registers (the p is “packed” — multiple values per instruction). Scalar fallback shows the same operations without the pack: vmulss, vaddss (the s is “scalar” — one value). If you expected a vectorized loop and the inner block is full of ...ss scalar ops, the compiler didn’t vectorize it, and now you know to hunt for the branch, dependency, or opaque call that stopped it. The Inspector turns “I think it’s fast” into “I can see the packed instructions or I can’t.”

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for vaddps/vmulps on ymm registers = vectorized (packed, many values per op); vaddss/vmulss = scalar fallback. The suffix is the whole diagnosis.

The benchmark that lies to you

Now the failure that has fooled nearly everyone who benchmarks Burst once. You write a loop that does a million multiplies, time it, and it reports a number so fast it implies the CPU did billions of operations in nanoseconds. You did not discover a miracle. You measured nothing, because the optimizer deleted your loop.

Here’s the mechanism, and it follows directly from what an optimizer is allowed to do. If a loop computes a result that is never used — never read, never stored anywhere observable, never returned — then the loop has no effect on the program’s output, and a correct optimizer is free to delete it entirely. This is dead code elimination (DCE), and it’s not a bug; it’s the compiler correctly noticing that unobserved work is work it doesn’t have to do. Burst, being aggressive, does this reliably. Your “benchmark” of unused arithmetic compiles to an empty loop, and you time the empty loop.

The defeat for DCE is to make the result observable, so the compiler can’t prove the work is dead:

// WRONG: sum is never used → the whole loop is deleted → you time nothing.
float sum = 0f;
for (int i = 0; i < n; i++) sum += data[i] * 2f;
// (nothing reads sum)

// RIGHT: write the result somewhere the compiler must preserve.
float sum = 0f;
for (int i = 0; i < n; i++) sum += data[i] * 2f;
output[0] = sum;   // now the loop has an observable effect and survives

Storing to a NativeArray the compiler can’t see through, returning the value, or accumulating into an output the caller reads — any of these anchors the work as observable. The rule generalizes: a benchmark only measures work whose result escapes the optimizer’s view.

Benchmarking honestly, beyond DCE

Even with DCE defeated, several things move the number, and an honest benchmark controls them:

  • Warmup. The first execution pays one-time costs — JIT/Burst compilation of the method, cold caches, first-touch page faults. Timing it measures startup, not steady state. Run the workload several times untimed, then measure, and report a stable central value (median over many runs), not a single sample.
  • Compilation mode. Burst in the Editor with safety checks and debugging on is not Burst in a release player. Leak detection, bounds checks, and synchronous-compilation stalls all inflate Editor numbers. A number that decides an architecture choice should come from a release build, or at least Burst with safety checks off — and you should know which you measured.
  • Measure the work, not the scaffolding. Timing a loop that includes allocation, container setup, or a Debug.Log measures those too. Isolate the arithmetic you’re actually asking about, and make sure the entity count is large enough that the per-run fixed costs are noise against the work.

None of this is Burst-specific ceremony; it’s what separates a measurement from a vibe. The discipline is the same one the labs have enforced since the memory wall: change one variable, observe long enough to be steady, and make sure the number reflects the thing you think you’re measuring.

What this buys you

You can now prove what the last four lessons claimed: open the Inspector and see whether your loop vectorized, and write a benchmark that measures real, observed work in steady state rather than an empty loop in a cold Editor. That closes the Burst module — you understand the compiler, what it does to your loops, the aliasing contract that unlocks it, the math types that make SIMD explicit, and how to verify all of it. The lab makes you do exactly that: measure a vectorized loop against a de-vectorized one, defeat DCE, and read the assembly to confirm the vmulps is really there. After Burst comes the other half of DOTS performance — running these compiled loops across many cores at once — the Jobs system.

m03.l06