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.
Why the chunk is what makes it legal
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
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.
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 = curcarry), 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
ifwhose 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.