m03 · Burst · reading · 10 min
Aliasing — the question the optimizer keeps asking
One question stands between you and vector code
Consider the most ordinary loop imaginable: read from array a, write to
array b.
for (int i = 0; i < n; i++)
b[i] = a[i] * 2f;
To vectorize this — process eight elements per instruction — the compiler
wants to load eight a values, multiply, store eight b values, and
move on. But it can only do that if it knows something you take for
granted: that writing b[i] doesn’t change a[i+1]. If a and b
might be the same array, or overlapping regions of one, then storing to
b[0] could alter a[1] before the vector load reads it, and doing
eight-at-a-time would compute different results than one-at-a-time. Faced
with that possibility, the compiler must assume the worst and emit slow,
scalar, one-element-at-a-time code that re-reads memory each step.
This is aliasing: the question of whether two pointers can refer to overlapping memory. It is the single most important piece of information an optimizer has about a loop, because the answer decides whether a whole category of optimizations — vectorization, hoisting reads out of the loop, reordering loads and stores — is correct. Not faster: correct. The compiler will not do them if it can’t rule out aliasing, no matter how much time it costs, because doing them under aliasing would produce wrong answers.
Why C# normally can’t help the compiler here
In ordinary managed C#, two array references genuinely might alias, and
the language gives the compiler no way to know they don’t. a and b
are both references; nothing in the type system says “these point at
disjoint memory.” So a managed JIT conservatively assumes they could
overlap and leaves the loop scalar. The information the optimizer needs —
these regions are disjoint — simply isn’t expressible in the code, so
the optimization can’t be justified.
This is a large part of why the same arithmetic is faster under Burst
than under Mono, beyond just “Burst optimizes more.” Burst changes the
default assumption. Native containers and pointers in Burst are treated
as [NoAlias] by default where the model allows it: Burst assumes
two distinct NativeArrays do not overlap, which lets it vectorize the
read-from-one-write-to-another loop that a managed JIT would refuse. The
speed isn’t only better instruction selection; it’s that Burst is
permitted to reason about memory in a way managed C# never let it.
The contract you’re signing
That default is a bargain, and you are one party to it. When Burst
assumes no-alias, it is trusting you that the arrays really are
disjoint. If they aren’t — if you pass the same NativeArray as both the
source and destination of a loop Burst vectorized on the assumption they
don’t overlap — the compiler has done a transformation that is only valid
under a premise you violated. The result is not a crash and not an error.
It’s silently wrong output: some elements computed against
already-overwritten neighbors, others not, in a pattern that depends on
the vector width and the exact overlap. This is the worst failure mode in
the module — no exception, no warning, just numbers that are quietly
incorrect and a bug that moves when you change hardware.
[NoAlias] and [ReadOnly] as information, not decoration
You can also give the compiler aliasing information explicitly, and the attributes that do so are best understood as facts you’re promising, not annotations you sprinkle for luck:
[ReadOnly]on a job’s input container says “this job only reads this; nothing writes it during the job.” That’s an anti-aliasing promise — if nothing writes it, no write can alias a read of it, so the compiler can hoist and cache reads freely. It also lets the job system schedule multiple readers in parallel (the Jobs module builds on this).[NoAlias]says “this pointer/field does not overlap the others.” You use it when you know a disjointness the compiler can’t infer, to unlock the same vectorization the default gives native arrays.
Both are load-bearing promises. [ReadOnly] on something you actually
write, or [NoAlias] on something that does overlap, is the same class
of bug as the default-aliasing violation: you told the optimizer a false
fact, and it optimized on it.
Reasoning about it going forward
The practical discipline is small and follows from the above. Keep source
and destination distinct in transforms Burst will vectorize. Mark job
inputs [ReadOnly] when they truly are — it’s free speed and free
parallelism, and honest. Reach for [NoAlias] only when you can state,
concretely, why the regions don’t overlap. And when a Burst routine
produces numbers that are wrong rather than slow, check aliasing before
anything else, because it’s the failure that hides.
What this buys you
You now understand the question that gates vectorization — can these
pointers overlap? — why managed C# leaves the compiler unable to answer
it, why Burst’s default answer is what makes it fast, and why signing
that contract falsely is the quiet, hardware-dependent bug to fear. Next
you’ll meet the Unity.Mathematics types that let you write this vector
math directly and idiomatically, so you’re expressing width-8 arithmetic
on purpose instead of hoping a loop gets vectorized.