SIMDvectorizationaliasingdependenciesrestrictloop transformation

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.

Follow the mechanism

Software view, hardware view

The gap between what you wrote and what the machine does is where this whole domain lives.

The question
What exactly has to be true about my loop before it can legally be turned into vector operations?
What you wrote
A loop over an array. It looks obviously parallel — each iteration touches a different index — so it should vectorise.
What the hardware does
The transformation is only legal if every iteration is provably independent. "Provably" is the operative word: the compiler must rule out any possibility that two pointers refer to overlapping memory, and if it cannot, it must generate the scalar version.
The gap between "obviously parallel to a human" and "provable by a compiler" is where most lost vectorisation lives. Knowing exactly which proof is missing usually turns a failed vectorisation into a one-line fix.
SourceCompilerInstructionsFront EndExecutionRegistersCachesMemoryI/OBehavior

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.

The compiler cannot prove a, b and c are disjoint
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 read
6# a value iteration i just wrote.
7# Compiler must assume the worst -> scalar code.
Non-overlap promised, so the transformation is legal
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 these
6# ranges do not overlap. The compiler can now
7# 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.

Four conditions, what breaks each, and the usual fix
ConditionWhat breaks itUsual fixCost of the fix
Provable non-aliasingPointer parameters that might overlaprestrict-style annotation, or runtime versioningA promise you must keep, or a branch
No loop-carried dependencyRunning sums, previous-element referencesPer-lane partials, combine at the endChanges floating-point grouping
Uniform control flowData-dependent branches in the bodyMasking, or split into filtered passesAll paths cost, or an extra pass
Contiguous accessStrided or scattered indicesChange layout to unit strideLayout may hurt other access patterns

Reductions, and why they need permission

PLATFORM-SPECIFICWhether reassociation is permitted is controlled by compiler flags and pragmas that differ between toolchains, and the granularity varies — some allow it per loop, others only per translation unit. Global fast-math options typically relax several unrelated assumptions at once, including behaviour around infinities and not-a-number values.

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.

A reduction, rewritten with per-lane partials
1// blocked: one accumulator, loop-carried
2sum = 0
3for i in 0..n:
4 sum = sum + a[i]
5
6// vectorisable: one partial per lane
7vsum = vector_zero()
8i = 0
9while i + LANES <= n:
10 vsum = vector_add(vsum, vector_load(a, i))
11 i = i + LANES
12
13sum = horizontal_add(vsum) // one cross-lane combine
14while i < n: // scalar remainder
15 sum = sum + a[i]
16 i = i + 1
17
18// legal for integers; for floats this regroups the
19// additions and changes the result, so it needs permission

Key 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.

  1. 1
    Compiler → dependence analysis: the loop is checked for cross-iteration dependencies through both variables and memory.
  2. 2
    Dependence analysis → aliasing question: pointer parameters that cannot be proved disjoint are treated as potentially overlapping.
  3. 3
    Legality established → transformation: the loop body is widened to operate on several elements per iteration.
  4. 4
    Transformation → remainder: a scalar epilogue handles elements that do not fill a final vector register.
  5. 5
    Runtime versioning → dispatch: where overlap cannot be proved statically, both paths may be emitted and chosen at run time.
What people conclude from this — wrongly
  • "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

What it causes
  • • 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.
What you can do
  • • 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.
How to see it
  • • 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.
What it costs
  • • 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.

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

Claim
“The compiler vectorises whenever it would be faster.”
Reality
It vectorises when it can *prove* the transformation preserves semantics. Speed is a separate consideration it applies afterwards, and legality is the binding constraint far more often.
Claim
“Adding restrict is a free optimisation.”
Reality
It is a promise about memory the compiler will not verify. If the ranges do overlap at run time, the program has undefined behaviour and may produce silently wrong results.
Claim
“If a loop did not vectorise, the loop must be unsuitable.”
Reality
Most commonly one specific fact was unavailable — usually non-aliasing, sometimes permission to reassociate. The vectorisation report will name it, and the fix is often one line.

Apply it

Where the rest of this lives

Programming Languages & Runtime Internals
Alias analysis and the as-if rule

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.