Vectorization: Turning a Loop Into Vector Work
The transformation from one-element-per-iteration to many, and the four conditions that have to hold for it to be legal. Most loops that fail to vectorise fail on a single unprovable assumption rather than on anything fundamental.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
Aliasing: the condition that fails most often
Given two pointers into arrays, a compiler generally cannot assume they point to different memory. If a and c overlap, then writing c[i] may change a value that a later iteration reads through a, creating a genuine loop-carried dependency. Vectorising would compute several iterations at once and produce a different, wrong answer.
So the compiler faces a choice: prove non-overlap, or emit scalar code. Sometimes it can prove it — arrays declared locally, or types that cannot alias under the language rules. Often it cannot, particularly across function boundaries where the pointers arrive as parameters. The result is a loop that looks perfectly parallel and compiles to scalar code.
The fixes are all about supplying the missing proof: a restrict-style annotation promising non-overlap, passing by value or by a type the language guarantees cannot alias, or a runtime overlap check that dispatches to a vector path when the ranges are disjoint. Many compilers generate that last one automatically — versioning the loop and choosing at run time — which costs a branch and some code size in exchange for keeping the vector path available.
1function add(a, b, c, n):2 for i in 0..n:3 c[i] = a[i] + b[i]4 5# If c overlaps a, iteration i+1 might read6# a value iteration i just wrote.7# Compiler must assume the worst -> scalar code.1function add(restrict a, restrict b, restrict c, n):2 for i in 0..n:3 c[i] = a[i] + b[i]4 5# "restrict" is a promise from you that these6# ranges do not overlap. The compiler can now7# vectorise. If you lie, the behaviour is undefined.Nothing about the arithmetic changed. The only difference is that the second version supplies a fact the compiler could not derive, making the transformation provably legal. Note that the annotation is a promise you are responsible for keeping — the compiler will not check it, and violating it produces incorrect results rather than a diagnostic.
The other three conditions
Loop-carried dependencies are the fundamental blocker. If iteration i reads a value that iteration i-1 wrote, the iterations are genuinely ordered and no legal transformation can run them together. A running sum is the common case — and the standard fix is per-lane partial sums combined at the end, which is legal only if reassociating the operation is acceptable. For integers it always is; for floating point it changes the result, so the compiler needs explicit permission (Why 0.1 + 0.2 Is Not 0.2 + 0.1's Problem).
Control flow inside the loop body breaks uniformity. Where the instruction set supports masking, the usual implementation computes all paths and blends the results, so a loop with a rarely-taken expensive branch pays for it on every element. Where masking is unavailable, the loop simply does not vectorise. Restructuring into separate uniform passes over filtered data is often faster than either.
Memory access shape decides whether the loads are cheap. Contiguous unit-stride access maps onto a single wide load. Strided access wastes most of each cache line. Truly scattered access requires gather instructions where they exist, and those cost substantially more than a contiguous load — often enough to erase the benefit entirely. This is the deepest reason layout choices like Array of Structs, or Struct of Arrays? have such a large effect on numeric code.
| Condition | What breaks it | Usual fix | Cost of the fix |
|---|---|---|---|
| Provable non-aliasing | Pointer parameters that might overlap | restrict-style annotation, or runtime versioning | A promise you must keep, or a branch |
| No loop-carried dependency | Running sums, previous-element references | Per-lane partials, combine at the end | Changes floating-point grouping |
| Uniform control flow | Data-dependent branches in the body | Masking, or split into filtered passes | All paths cost, or an extra pass |
| Contiguous access | Strided or scattered indices | Change layout to unit stride | Layout may hurt other access patterns |
Reductions, and why they need permission
A reduction is the most common loop shape that appears blocked but is not. sum = sum + a[i] has a loop-carried dependency on sum by construction, so a literal transformation is illegal. The standard rewrite keeps one partial sum per lane, accumulates independently, and performs a single horizontal combine after the loop.
That rewrite is legal for integer addition, which is associative. It is *not* legal for floating-point addition, which is not: grouping the additions differently changes rounding and therefore the result. The difference is usually tiny and occasionally is not, and it is not the compiler's decision to make — so by default it will refuse, and vectorising the reduction requires an explicit flag or pragma granting permission to reassociate.
This is the single most common surprise when a numeric loop refuses to vectorise. The loop looks trivially parallel, the arrays do not alias, and it still emits scalar code — because the answer would differ in the last bits and the compiler is not allowed to decide that is acceptable on your behalf. The correct response is to decide deliberately, not to reach for a global fast-math flag that also relaxes assumptions you may be relying on elsewhere.
1// blocked: one accumulator, loop-carried2sum = 03for i in 0..n:4 sum = sum + a[i]5 6// vectorisable: one partial per lane7vsum = vector_zero()8i = 09while i + LANES <= n:10 vsum = vector_add(vsum, vector_load(a, i))11 i = i + LANES12 13sum = horizontal_add(vsum) // one cross-lane combine14while i < n: // scalar remainder15 sum = sum + a[i]16 i = i + 117 18// legal for integers; for floats this regroups the19// additions and changes the result, so it needs permissionKey points
- The transformation must be provably legal, not merely obviously parallel to a reader.
- Aliasing is the condition that fails most often, and it fails silently.
- Loop-carried dependencies are fundamental, except for reductions, which can be rewritten as per-lane partials.
- Reassociating floating-point addition changes the result, so vectorising a float reduction requires explicit permission.
- Access shape determines whether the loads are cheap; scattered access can erase the benefit entirely.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Compiler → dependence analysis: the loop is checked for cross-iteration dependencies through both variables and memory.
- 2Dependence analysis → aliasing question: pointer parameters that cannot be proved disjoint are treated as potentially overlapping.
- 3Legality established → transformation: the loop body is widened to operate on several elements per iteration.
- 4Transformation → remainder: a scalar epilogue handles elements that do not fill a final vector register.
- 5Runtime versioning → dispatch: where overlap cannot be proved statically, both paths may be emitted and chosen at run time.
- • "It is obviously parallel, so it will vectorise." Obvious to you is not provable to the compiler.
- • "Fast-math just makes floating point quicker." It relaxes several correctness assumptions at once, some unrelated to reassociation.
- • "restrict is a hint." It is a promise; violating it is undefined behaviour, not a missed optimisation.
- • "My loop vectorised, so it is fast." It may now be bandwidth-bound and no faster at all.
- • "Gather instructions make scattered access as good as contiguous." They make it possible, at substantially higher cost.
Consequences, controls and cost
- • A loop that looks trivially parallel can compile to scalar code because of one unprovable pointer relationship.
- • Float reductions do not vectorise by default, and enabling them changes numeric results.
- • Data-dependent branches in the body cause all paths to be evaluated under masks.
- • Non-unit-stride access can make the vector version slower than the scalar one.
- • Runtime-versioned loops carry both paths, increasing code size and adding a dispatch branch.
- • Supply the aliasing proof the compiler is missing — restrict-style annotations, local buffers, or types that cannot alias.
- • Rewrite reductions as per-lane partials and decide explicitly whether reassociation is acceptable.
- • Hoist data-dependent branches out of the loop, splitting into uniform passes where possible.
- • Change layout to unit-stride access for the field being processed ([[aos-vs-soa]]).
- • Prefer targeted per-loop pragmas over global fast-math flags, which relax more than intended.
- • Enable the compiler's vectorisation report; most toolchains will state which loops vectorised and why the others did not.
- • Inspect the disassembly of the hot loop for vector instructions rather than trusting the report alone.
- • Compare elapsed time before and after on realistic data, including realistic trip counts.
- • Verify numeric results after enabling reassociation, especially for sums over large or badly-conditioned data.
- • Check whether the compiler emitted a runtime overlap check, which indicates it could not prove disjointness statically.
- • Aliasing annotations move a correctness obligation from the compiler to you, with undefined behaviour as the penalty for error.
- • Permitting reassociation changes numeric results, which may be unacceptable in some domains.
- • Layout changes for unit stride can degrade other access patterns over the same data.
- • Runtime versioning increases code size and adds a branch on every entry to the loop.
Scope
§224 — what these claims are specific to.
- PLATFORM-SPECIFICVectorisation reports, pragmas, aliasing annotations and reassociation flags are toolchain features with different names and granularity across compilers. The same source can vectorise under one compiler and not another.
- ISA-SPECIFICWhether masking and gather are available determines whether branchy or scattered loops can vectorise at all. Instruction sets differ substantially here, and so does the cost of the resulting code.
Misconceptions
Apply it
Where the rest of this lives
Whether two references can point at the same object is a language-semantics question the compiler answers before any hardware is involved. Languages with stronger aliasing guarantees give their compilers more freedom to vectorise without annotations.