DOTS//CORE local · not synced

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.

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for disjoint source and destination: the compiler can load eight, transform, store eight, because no store can disturb a not-yet-read load — the promise no-alias encodes

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.

m03.l03