DOTS//CORE local · not synced

m03 · Burst · reading · 9 min

Vectorization — one instruction, many lanes

The width you’re leaving on the table

A scalar add instruction adds two numbers. A SIMD instruction — Single Instruction, Multiple Data — adds two vectors of numbers in the same time: four 32-bit floats at once on 128-bit SSE, eight on 256-bit AVX2. The arithmetic hardware to do this has been in every mainstream CPU for over a decade. The question is never whether the machine can process eight floats per instruction; it’s whether your code is shaped so the compiler is allowed to emit that instruction.

Do the arithmetic on what that width is worth. A loop that adds a velocity to a position, one float at a time, issues one add per component per entity. The same loop vectorized issues one add for every eight entities’ worth of that component. That’s the difference between finishing a 10,000-entity movement pass in 10,000 arithmetic steps and finishing it in ~1,250. Vectorization is not a small constant-factor tweak; it’s dividing your arithmetic instruction count by the vector width, and it’s the single biggest thing Burst does to a numeric loop.

Here is the payoff for everything in the last two modules. A vector add loads eight consecutive floats from one array, eight from another, adds them lane-for-lane, and stores eight results. That only works if those floats are consecutive in memory — if position.x for eight entities sits in a contiguous run the CPU can load in one aligned vector load. Which is exactly, precisely what the SoA chunk layout produces: each component type in its own dense span, one entity’s value right after the last.

Auto-vectorization — the compiler doing this for you without intrinsics — is only legal when the compiler can prove the memory is laid out this way and the loop iterations are independent. Array-of-structs layout breaks it immediately: if each entity is a fat struct and you want its x, the eight x values are 64+ bytes apart, not contiguous, and there is no single vector load that gathers them cheaply. The dense component array isn’t just cache-friendly (the memory-wall argument); it’s the precondition for the compiler to vectorize at all. SoA buys you two things at once — locality and vectorizability — and they’re the same layout.

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for eight contiguous floats — one aligned vector load feeds one SIMD add; the SoA span is exactly this shape, an AoS struct scatters these eight across eight lines

What quietly kills vectorization

A loop being shaped right (contiguous data, independent iterations) doesn’t guarantee the compiler vectorizes it. Several ordinary-looking things inside the loop body block it, and knowing them is most of the craft:

  • A data dependency between iterations. If iteration n reads a value iteration n−1 wrote (a running sum into the same variable, a prev = cur carry), the iterations aren’t independent and can’t be done eight-at-a-time. Some reductions the compiler can still handle; arbitrary carries it cannot.
  • A branch that differs per element. An if whose result varies per entity can force the compiler to fall back to scalar, because the lanes would need to do different things. Sometimes it can vectorize with a select (compute both sides, blend), but a complex or side-effecting branch defeats it.
  • A call to a function it can’t see into. A call into managed code, or any function the optimizer can’t inline and analyze, is an opaque wall — it might have side effects, so the compiler can’t prove the iterations independent. This is another reason the managed-reference restriction matters: a managed call in your loop doesn’t just cost the call, it costs the vectorization of the whole loop.

The vector types make it explicit

Unity.Mathematics gives you float4, float3, and friends — types that are SIMD vectors. A float4 add compiles to a single vector instruction directly; you’re not hoping the compiler auto-vectorizes a loop, you’re handing it data already in vector shape. Operating on a float4 is operating on four lanes by construction. (The next lesson is about these types and their idioms specifically — for now, know that they exist because expressing math in vector-width units is the most reliable way to get vector instructions out the other side.)

What this buys you

You can now explain why the dense chunk layout does double duty — cache locality and the legal precondition for SIMD — and you know the three loop-body hazards that silently defeat vectorization. The next lesson goes deeper on the one piece of information the optimizer needs and most often lacks: whether two pointers can overlap. That’s aliasing, and it’s the difference between a loop the compiler vectorizes and an identical-looking one it refuses to.

m03.l02