Post

Understanding SIMD - From Vector Registers to Unity Burst Intrinsics

Understanding SIMD - From Vector Registers to Unity Burst Intrinsics
Prerequisites — Read these first
TL;DR — Key Takeaways
  • SIMD is a CPU feature that packs several values into one vector register and computes on all of them with a single instruction. ARM NEON is 128-bit and handles 4 floats; x86 AVX2 is 256-bit and handles 8 at a time
  • SIMD is not a separate device like a GPU but an execution port inside the CPU core. Its offload cost is zero, which is the decisive difference from GPU compute — it pays off even on microsecond-scale work
  • Intel consumer CPUs, the i9-14900K included, have no AVX-512 (AVX2 256-bit is the ceiling). Zen 5 has a native 512-bit datapath, and Radeon is a SIMD machine by construction with two SIMD32 units per CU
  • Every 64-bit CPU has SIMD — SSE2 is mandatory on x86-64 and NEON is mandatory on AArch64. The technique is industry-proven too, from Unreal's VectorRegister and ISPC to Jolt Physics (Horizon Forbidden West) and shipped Unity DOTS titles
  • An explicit SIMD loop is always the same five stages — broadcast the constant, traverse in vector-width steps, operate across lanes in parallel, reduce to a scalar, handle the remaining tail
  • Measured on .NET 10 with an Apple M4 Pro, summing 1 million floats ran 3.6x faster than scalar and match counting 2.7x faster. Both allocate zero heap memory
  • Burst is the only trustworthy SIMD path in Unity. Auto-vectorization plus Unity.Mathematics is the default, and v128 from Unity.Burst.Intrinsics goes only into bottlenecks verified with Burst Inspector
Visitors

Hits

Introduction: Is SIMD Really an Expert-Only Technique?

Mitchell Hashimoto, the creator of the Ghostty terminal, recently published a piece arguing that “SIMD is an everyday optimization tool every programmer should know.” Rewriting Ghostty’s codepoint search loop with AVX2 made it roughly 5x faster, and his point was that the structure of that code was not assembly wizardry but a formulaic five-stage pattern anyone can follow.

Mitchell Hashimoto, “SIMD Basics”https://mitchellh.com/writing/simd-basics

SIMD has already come up several times in this series. The Burst Compiler deep dive covered how LLVM’s Loop Vectorizer vectorizes loops on its own, and the SoA vs AoS post covered the memory layouts that vectorize well. Both, however, skipped one question: what exactly do SIMD instructions do in hardware that makes them fast, and how do you write them by hand when auto-vectorization fails?

This post has three goals.

  1. Understand SIMD at the level of vector registers and lanes, and see what form SIMD actually takes on real hardware (Intel i9, Ryzen, Apple M, Radeon)
  2. Write the five-stage structure of an explicit SIMD loop directly in C# Vector<T> and measure it
  3. Bring that knowledge into Unity and lay out the three-tier selection criteria running from auto-vectorization to Unity.Mathematics to Burst Intrinsics

All measurements were taken by me with BenchmarkDotNet on .NET 10 with an Apple M4 Pro.


Part 1: What SIMD Does in Hardware

Scalar Instructions and Vector Instructions

SIMD stands for Single Instruction, Multiple Data. Exactly as the name says, there is one instruction, and that instruction processes multiple pieces of data.

Beyond the general-purpose registers used for ordinary arithmetic (x0x30, 64-bit on Arm64), a CPU has separate vector registers (v0v31, 128-bit). A float is 32 bits, so four of them fit in one 128-bit vector register, and each of those slots is called a lane. A single vector addition instruction performs four additions at once, pairing lanes at matching positions in the two registers.

Scalar — 4 instructions
a[i]
+
b[i]
=
c[i]
↻ i = 0, 1, 2, 3 — the same instruction repeated 4 times
fadd s0, s1, s2
SIMD — 1 instruction
a[0]a[1]a[2]a[3]
+
b[0]b[1]b[2]b[3]
=
c[0]c[1]c[2]c[3]
4 lanes of a 128-bit register computed simultaneously
fadd v0.4s, v1.4s, v2.4s

Two ways to perform the same four additions (Arm64 NEON)

Vector Width — A Theoretical Ceiling That Differs by Platform

How many values one instruction processes is fixed by the width of the vector registers the instruction set provides.

Instruction SetRegister Widthfloat LanesMain Platforms
ARM NEON128-bit4All mobile devices, Apple Silicon, Nintendo Switch
x86 SSE4128-bit4Common x86-64 baseline
x86 AVX2256-bit8Desktops since 2013, PS5, Xbox Series
x86 AVX-512512-bit16Some servers and recent desktop CPUs

For a game programmer this table has a single conclusion. The conservative cross-platform baseline is 128-bit, that is, “4 floats = 4x in theory.” For a mobile target it is NEON 128-bit, full stop. Desktop is somewhat better off — Burst’s default 64-bit desktop setting compiles two variants, SSE2 and AVX2, and picks between them at runtime by inspecting the CPU (runtime dispatch), so 256-bit is used automatically on CPUs that support AVX2. AVX-512 can be dropped from the game shipping matrix entirely.

Why You Don’t Always Get 4x

Four lanes does not turn arbitrary code into 4x. Three preconditions have to hold for SIMD to pay off.

  • Contiguous memory: a vector load instruction fetches a contiguous 128 bits from memory in one go. If the data is scattered, the cost of filling the lanes eats the arithmetic gain
  • Identical operation: the same instruction is applied to all four lanes. If each element needs different handling, the SIMD model does not apply at all
  • Minimal branching: there is no per-lane if. Conditionals have to be rewritten as comparison masks and arithmetic (we do exactly this in Part 3)

The thing that forces these three preconditions into your code structure is the SoA layout. The previous post’s conclusion that “SoA suits SIMD” was, in the end, the software-side expression of a hardware constraint: vector loads demand a contiguous 128 bits.


Part 2: SIMD on Real Hardware — i9, Ryzen, Apple M, and Radeon

Is There a Dedicated Pipeline? No, It’s an Execution Port Inside the Core

To answer “does SIMD have a dedicated pipeline the way the CPU has a path to the GPU” up front: SIMD has no separate device and no separate transfer path. The vector unit is a subset of the execution ports inside the CPU core. It shares the front end — instruction fetch, decode, scheduler — with scalar instructions as-is, and the scheduler merely looks at the instruction type and decides whether to send it to an integer ALU port or a vector FMA port. The data comes straight out of the same L1 cache.

Inside a CPU core — SIMD is an execution port
Front end (fetch · decode)
Scheduler — assigns ports by instruction type
Scalar
ALU port
Vector FMA
ports ×2
L1 cache (shared)
Zero offload cost — applies even to µs-scale work
GPU — a separate device
CPU — record command buffer
↓ PCIe transfer + dispatch latency
GPU
dozens of CUs, two SIMD32 units per CU
↓ result readback (sync wait)
CPU — receive results
Round trip of tens of µs to ms — bulk work only

The same "parallel computation", but in different places — CPU SIMD sits inside the core, the GPU across the bus

Being located at an “execution port” determines SIMD’s character. Since it is not a separate device, the startup cost is zero. GPU compute has a round trip — record the command buffer, dispatch, read the results back — that runs from tens of microseconds to milliseconds, so small jobs cost more than they return. SIMD has no setup cost whatsoever for vectorizing a loop, so the gain shows up even on microsecond-scale work. In exchange it consumes the core’s own resources, so the scale of parallelism is capped at core count × lane count.

That said, even the “free execution port” had one historical exception. Early AVX-512 implementations (Skylake-X) dropped the core clock substantially when running the 512-bit units because of power limits, and vector code ended up slowing down the scalar code on the same core. Recent implementations have largely fixed this: Zen 5 (Ryzen 9950X) drops only from 5.7 to 5.3 GHz, roughly 10%, even under an AVX-512 power-virus load.

Where the Gaming PC Stands — the i9 Has No AVX-512

The answer to “can I use SIMD on an Intel i9” is “definitely up to AVX2, and no AVX-512.” Here is the SIMD support picture for the major gaming CPUs as of 2026.

CPUMax SIMDVector WidthVector Register CapacityNotes
Intel i9-14900K (Raptor Lake)AVX2256-bit16 YMM = 512 BAVX-512 hardware exists but is fused off
Intel Core Ultra 9 285K (Arrow Lake)AVX2256-bit16 YMM = 512 BConsumer line still lacks AVX-512
Intel Nova Lake (planned for 2026)AVX10.2512-bit32 ZMM = 2 KB512-bit announced for both P- and E-cores
AMD Ryzen 9 9950X (Zen 5)AVX-512512-bit32 ZMM = 2 KBNative 512-bit datapath (Zen 4 double-pumped 256-bit)
Apple M4NEON128-bit32 V = 512 BSecures throughput with pipe count (4 per P-core) instead of width

What a game developer should take from this table is not the ranking but the history. Intel supported consumer AVX-512 in the 11th generation (Rocket Lake), disabled it in the 12th (Alder Lake) because of the instruction set mismatch with the E-cores, and never brought it back to the consumer line in later generations. AMD went the other way, adding AVX-512 from Zen 4 and expanding it to full 512-bit in Zen 5. So the assumption “any modern desktop has AVX-512” is wrong on half the market — the safe line that covers the entire shipping target is AVX2 (256-bit). This support fragmentation is exactly why Burst’s desktop default compiles two variants, SSE2 and AVX2, and chooses at runtime.

Viewed as “capacity,” the vector register file is tiny — kilobytes. All 16 AVX2 YMM registers together come to 512 bytes, and even AVX-512’s 32 ZMM registers total 2 KB. A vector register is not storage for holding data but a window through which data streaming from L1 cache passes, which is why Part 1’s “contiguous memory” precondition matters again. No matter how wide the window, it is useless if the supply dries up.

The table also shows that width is not everything. Effective throughput is width × vector ports per core. The i9’s P-core has two 256-bit FMA ports, so per cycle in FP32 terms that is 8 lanes × 2 ports × 2 operations (multiply + add) = 32 FLOP; the Apple M4’s P-core has four 128-bit pipes, so 4 lanes × 4 pipes × 2 operations = 32 FLOP as well. The intuition that NEON “is slow because it’s half as wide” is arithmetic that forgot to count ports.

CPUs With No SIMD at All — If It’s 64-bit, It Has SIMD

Talk of support fragmentation invites the worry that “maybe some CPUs have no SIMD at all,” but as long as 64-bit is your shipping target, no such CPU exists. The presence of SIMD is guaranteed by the architecture standard.

  • x86-64: SSE2 (128-bit) is a mandatory architectural feature. Every x86-64 CPU since the first one in 2003 has it without exception, and compilers even compile ordinary float arithmetic into SSE registers rather than x87
  • AArch64 (64-bit ARM): NEON (AdvSIMD) is mandatory. That covers every 64-bit smartphone, tablet, and Apple Silicon chip
  • The exceptions live in the past and in embedded: back in the 32-bit ARMv7 era NEON was optional, and chips shipped without it (the NVIDIA Tegra 2 used in early Android tablets is the famous case), and Cortex-M microcontrollers still have no vector unit today

In fact, this guarantee was already hidden in the Part 1 diagram. The s0 in the scalar instruction fadd s0, s1, s2 is not a separate scalar register but the lower 32 bits of vector register v0. On a 64-bit CPU even scalar floating-point code runs on top of the vector register file, and using SIMD is closer to finishing off the remaining lanes of hardware that is already there. So the thing to worry about is not “is it there” but the single question the previous table answered: “how wide is it.”

What the Minimum Spec Sheet Really Means — Guess the Width Wrong and You Get Illegal Instruction

You should also know what happens when that “width” assumption is violated. The result is not a slowdown but instant death. When a CPU meets an instruction it cannot decode, an Illegal Instruction exception fires and the process terminates on the spot. Running a game built for a higher instruction set on an older CPU produces exactly this crash, and there are several real-world incidents.

  • Cyberpunk 2077 (2020) — the executable contained AVX instructions and crashed on CPUs without AVX (the AMD Phenom family and others). Hotfix 1.05 removed AVX usage and resolved it
  • Helldivers 2 (2024) — an update effectively made AVX2 mandatory, and overnight the game became unlaunchable for users with pre-2013 CPUs
  • Nixxes, Sony’s PC porting studio, even maintains an official error page titled “This game requires a CPU that supports the AVX2 instruction set”

The CPU model names on a game’s minimum spec sheet often mean precisely this. “Core i3-8100 or better” is not saying the clock is too low; it is closer to specifying an instruction set generation.

The standard fix on the developer side is the runtime dispatch mentioned earlier. You put both SSE2 and AVX2 code in the executable and pick between them at startup by checking CPU support via CPUID — and that is exactly what Burst’s desktop default (compiling SSE2 + AVX2 variants) does. Cyberpunk and Helldivers 2 are cases where higher instructions were baked in without dispatch. Consoles, on the other hand, have fixed hardware (the Zen 2 in PS5 and Xbox Series guarantees AVX2), so hardcoding AVX2 is safe there — which is why this problem blows up specifically in PC ports of console games.

Is Radeon SIMD Too? A GPU Is a Machine Built Out of SIMD

The question “do GPUs like Radeon have SIMD” gets accurate only when you flip the direction. It is not that GPUs “have” SIMD — a GPU is a machine built from the ground up by stacking SIMD units in bulk.

One Compute Unit (CU) in AMD’s RDNA architecture consists of two SIMD32 units. A SIMD32 unit is a scaled-up version of a CPU vector unit in which 32 lanes execute the same instruction every cycle. The marketing phrase “64 stream processors” is just this 32 lanes × 2 units spelled out, and converting the Radeon RX 7900 XTX’s 96 CUs gives 6,144 FP32 lanes. That is three orders of magnitude away from the 8–16 lanes of a single CPU core.

Yet nobody writes intrinsics on a GPU. The programming model is different.

  • CPU SIMD: the programmer is directly aware of lanes. Broadcast, mask, and reduction are written out in code (the five stages of Part 3)
  • GPU (SIMT): the programmer writes scalar code for “one thread” (HLSL and friends), and the hardware bundles 32 threads into a wavefront and assigns them automatically to the lanes of a SIMD32 unit
  • Branch handling: when threads take different sides of an if (divergence), the hardware executes both paths and picks results with a mask — the hardware doing for you the mask arithmetic we will write by hand in Part 3

In other words, SIMT is a convenience layer laid over SIMD hardware, and the cost model “a branch becomes a mask” is identical on CPU and GPU. The common wisdom that branches are expensive in GPU shaders is rooted in the same place as the fact that CPU SIMD has no per-lane if.

In the Unity context, this choice is Compute Shader vs Burst Job. The criterion was already shown by the diagram in the previous section — if the data is already on the GPU (wired into the rendering pipeline) or the workload is large enough to amortize the round-trip latency, use a Compute Shader; if CPU logic has to consume the results every frame and the work is on the scale of microseconds to hundreds of microseconds, CPU SIMD (Burst) is the right call. 6,144 lanes do not always beat 8 lanes. To beat them, the data first has to cross the bus.


Part 3: The Five-Stage Structure of an Explicit SIMD Loop

Always the Same Five Stages

What makes Mitchell Hashimoto’s article good is that it pins down how SIMD code, regardless of language and instruction set, is always composed of the same five stages. Whether you write it in Zig, in C intrinsics, or in the C# Vector<T> we are about to see, the structure is identical.

1
Broadcast
Copy the constant into every lane
2
Vector traversal
Advance the array in vector-width steps
3
Lane-parallel operation
Apply one instruction across all lanes
4
Reduction
Collapse the lane results into one scalar
5
Tail handling
Handle the remainder with a scalar loop

The five stages of an explicit SIMD loop — success or failure is decided at ③ the lane-parallel operation

Writing It Yourself With C# Vector<T>

.NET’s System.Numerics.Vector<T> is a type that abstracts “the vector width of the current hardware.” Vector<int>.Count is 4 on NEON and 8 on AVX2, and the JIT translates each operation into the platform’s vector instructions. Porting “count occurrences of a value in an array” — the same structure as Ghostty’s codepoint search — straight into the five stages gives this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System.Numerics;

int CountTarget(int[] data, int target)
{
    // ① Broadcast — copy target into every lane
    var targetVec = new Vector<int>(target);
    var acc = Vector<int>.Zero;

    int width = Vector<int>.Count;   // 4 on NEON
    int i = 0;

    // ② Vector traversal — advance 4 at a time
    for (; i <= data.Length - width; i += width)
    {
        var chunk = new Vector<int>(data, i);

        // ③ Lane-parallel operation — matching lanes become -1 (all bits set), others 0
        acc += Vector.Equals(chunk, targetVec);
    }

    // ④ Reduction — collapse the 4 lane accumulators into one scalar
    int count = -Vector.Sum(acc);

    // ⑤ Tail handling — the remainder that isn't divisible by 4
    for (; i < data.Length; i++)
        if (data[i] == target) count++;

    return count;
}

Stage ③ is the heart of this code. In a scalar version this is where you would write if (data[i] == target) count++, but SIMD has no per-lane branch, so we take the comparison result as a mask and handle it with arithmetic. Vector.Equals returns a vector with matching lanes filled with -1 (all bits set) and non-matching lanes with 0, and accumulating that directly builds up “match count × (-1)” in each lane. Flipping the sign in ④ gives the total. It is barely an exaggeration to say that this conversion of a branch into mask arithmetic is the whole of the SIMD mindset.

The remaining stages are formulaic boilerplate. ①, ②, and ⑤ look nearly the same in any SIMD code, and the reduction in ④ is a single Vector.Sum line. So when you meet a new problem, the thing to think about narrows to one question: “can ③ be built without a branch?”


Part 4: Measurements — Scalar vs Vector<T>

Environment and Targets

I measured on my own development machine how much of the theoretical 4x actually materializes.

  • Environment: .NET 10.0.0, Apple M4 Pro, Arm64 RyuJIT (AdvSIMD), BenchmarkDotNet v0.14.0, [MemoryDiagnoser]
  • Data: arrays of 1 million elements (float sum / int match count, fixed-seed random values)
  • Pairs compared: a plain scalar loop vs the five-stage Vector<T> loop above (Vector<float>.Count = 4)

The summation code is even simpler than the count.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Benchmark(Baseline = true)]
public float SumScalar()
{
    float sum = 0f;
    for (int i = 0; i < _floats.Length; i++)
        sum += _floats[i];
    return sum;
}

[Benchmark]
public float SumVector()
{
    var acc = Vector<float>.Zero;
    int width = Vector<float>.Count;
    int i = 0;
    for (; i <= _floats.Length - width; i += width)
        acc += new Vector<float>(_floats, i);   // 4 lanes accumulated at once

    float sum = Vector.Sum(acc);                // Reduction
    for (; i < _floats.Length; i++)             // Tail
        sum += _floats[i];
    return sum;
}

Results

BenchmarkMeanvs ScalarAllocated
SumScalar (1M floats)585.5 µs1.00x0 B
SumVector163.0 µs3.59x faster0 B
CountScalar (1M ints)331.1 µs1.00x0 B
CountVector124.3 µs2.66x faster0 B
Scalar vs Vector<T> — processing time for 1M elements (Apple M4 Pro, .NET 10)

Reading the Numbers — Why 3.6x and 2.7x

Why the two speedups differ is where this benchmark has the most to teach.

Summation reaches 3.59x, close to the theoretical ceiling of 4x, because scalar summation is the worst-case setup. sum += x is a serial dependency chain in which the next addition cannot start until the previous one finishes, so the scalar loop’s speed is pinned directly to floating-point addition latency. The vector version splits that chain across 4 lanes, so almost the full width-proportional gain comes through.

Counting stops at 2.66x for the opposite reason: the scalar side is already quite fast. In the test data the match probability is 1/256, so the if (data[i] == target) branch is almost always predicted “no match,” and a loop whose branch predictions keep hitting never stalls the pipeline. The faster your opponent, the smaller the speedup — the size of SIMD’s gain is set not by the vector code but by what the scalar code you’re comparing against is bound on.

Two caveats also surface in the measurements.

  • The floating-point summation order changes. Scalar adds one at a time from the left, while the vector version splits values across 4 lanes and combines them at the end, so the association order differs — and since floating-point addition is not associative, bit-identical results are not guaranteed. This is exactly why Burst needs FloatMode.Fast for reduction vectorization (deep dive, Part 2)
  • These numbers belong to RyuJIT. Take the same Vector<T> code into Unity and you will not see these ratios. Unity’s Mono runtime handles Vector<T> in software with no hardware acceleration, and IL2CPP does not guarantee vector instruction generation either

That second caveat is the subject of the next part. There is a separate road to SIMD in Unity.


Part 5: Unity’s SIMD Paths — a Three-Tier Ladder

Why System.Numerics.Vector Is Not the Answer in Unity

In practice there is exactly one path in the Unity runtime along which SIMD instructions actually get generated: Burst.

  • Mono: the Vector<T> API works but has no hardware acceleration. It emulates per-lane operations with a software loop, so it can end up slower than scalar
  • IL2CPP: it only converts IL to C++ and gives no special treatment to System.Numerics types as vector instructions. What remains is the C++ compiler’s auto-vectorization, and as the deep dive showed, auto-vectorization is a fragile optimization that one condition can break
  • Burst: it compiles Job code directly through LLVM and emits NEON/SSE/AVX instructions. You can verify vectorization with Burst Inspector, and even force it at compile time with Loop.ExpectVectorized()

How Burst Turns C# Into Vector Instructions

Burst’s process for producing SIMD boils down to four stages.

  1. Discovery: it gathers the IL (intermediate language) of Jobs marked with [BurstCompile]
  2. Conversion: its own front end converts IL directly into LLVM IR. This is where it diverges from IL2CPP, which goes through C++ — with no C++ source as an intermediate stage, type and aliasing information reaches LLVM without loss
  3. Optimization: the LLVM passes run. SROA hoists structs like float4 wholesale into vector registers, and the Loop Vectorizer rewrites loops in vector-width units
  4. Code generation: per-target back ends emit NEON/SSE2/AVX2 machine code. On desktop that means two variants, SSE2 and AVX2, plus runtime dispatch, as we saw in Part 2

Burst’s decisive weapon in this pipeline is not compiler technology but the aliasing guarantee the Job structure hands over for free. A Job’s NativeArray fields are guaranteed non-overlapping by the Safety System, so Burst can treat every input and output as alias-free and vectorize. The point where a C++ compiler gives up on vectorization, worrying “what if these two pointers refer to the same memory,” Burst simply walks past — the core of “why Burst can be faster than C++,” covered in the deep dive.

For example, when a Job that multiplies a float4 array by a scalar (the ScaleJob that appears shortly as the tier 2 example) goes through this pipeline, the loop body conceptually compresses to these three lines.

1
2
3
ldr  q0, [x0, x2]          ; load 128 bits (4 floats) from input
fmul v0.4s, v0.4s, v1.4s   ; multiply 4 lanes at once — scale is broadcast into v1
str  q0, [x1, x2]          ; store 128 bits into output

One line of C# (output[i] = input[i] * scale) came out as one instruction each for load, operate, and store. Burst Inspector is where you confirm this actually happened, and the per-pass LLVM detail plus how to read the assembly are in deep dive, Parts 1 and 3.

So doing SIMD optimization in Unity comes down to choosing how far down to go on top of Burst. There are three tiers to choose from.

Tier 1 — Default Auto-vectorization
Leave it to the LLVM Loop Vectorizer with nothing more than [BurstCompile] + Job + NativeArray. The code stays an ordinary C# loop, and in most cases this is where it should end.
Tier 2 — Recommended Unity.Mathematics
Use float4 and int4 and Burst maps the type straight onto a vector register. It depends less on whether auto-vectorization succeeds, while the code stays portable.
Tier 3 — Last resort Unity.Burst.Intrinsics
Specify instructions yourself with the v128 type and NEON/SSE intrinsics. Platform-specific branching becomes necessary, so use it only on bottlenecks where Burst Inspector confirmed tiers 1 and 2 failed.
▲ Portability · maintainability Control · certainty ▼

Unity's three SIMD tiers — going lower buys certainty but binds the code to a platform

Tiers 1 and 2 — Auto-Vectorization and Unity.Mathematics

Tiers 1 and 2 were already covered in detail in this series, so here are just the key points again. The success and failure conditions of auto-vectorization and the workflow for checking assembly in Burst Inspector are in Burst Compiler deep dive, Parts 3 and 4. The core of tier 2 is that float4 operations map onto vector instructions by themselves, with no need to wait on auto-vectorization.

1
2
3
4
5
6
7
8
9
10
[BurstCompile]
struct ScaleJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float4> input;
    public NativeArray<float4> output;
    public float scale;

    // One float4 multiply compiles into one NEON fmul v0.4s instruction
    public void Execute(int i) => output[i] = input[i] * scale;
}

The thing to watch out for is float3. Only three values go into a four-lane register, so one lane is wasted, and laying them out as an array also breaks 16-byte alignment. For data you intend to feed to SIMD, it is better to model it as float4 from the start, or split x, y, and z into separate arrays with the SoA layout.

Tier 3 — Writing It Directly With Unity.Burst.Intrinsics

Patterns where auto-vectorization fails and float4 cannot express the operation either — reductions like Part 3’s “mask accumulation” being the classic case — get written directly with intrinsics. Porting Part 3’s count loop to the NEON version in Unity.Burst.Intrinsics looks like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using Unity.Burst;
using Unity.Burst.Intrinsics;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using static Unity.Burst.Intrinsics.Arm.Neon;

[BurstCompile]
unsafe struct CountTargetJob : IJob
{
    [ReadOnly] public NativeArray<int> data;
    public int target;
    public NativeReference<int> result;

    public void Execute()
    {
        int* p = (int*)data.GetUnsafeReadOnlyPtr();
        int count = 0;
        int i = 0;

        if (IsNeonSupported)   // compile-time constant — zero runtime branch cost
        {
            v128 targetVec = new v128(target);        // ① Broadcast
            v128 acc = new v128(0);

            for (; i <= data.Length - 4; i += 4)      // ② Vector traversal
            {
                v128 chunk = vld1q_s32(p + i);        //    128-bit load
                v128 mask  = vceqq_s32(chunk, targetVec); // ③ matching lane = -1
                acc = vsubq_s32(acc, mask);           //    -(-1) = +1 accumulated
            }
            count = vaddvq_s32(acc);                  // ④ Reduction via horizontal sum
        }

        for (; i < data.Length; i++)                  // ⑤ Tail + fallback for no NEON
            if (p[i] == target) count++;

        result.Value = count;
    }
}

I hope you notice that the structure is exactly the same as the Vector<T> version in Part 3. Vector.Equals became vceqq_s32 and Vector.Sum became vaddvq_s32, and the five stages are otherwise untouched. The learning cost of explicit SIMD lies not in memorizing instruction names but in internalizing this structure once, and once you have, you slot the intrinsics of any platform into the same frame.

The IsNeonSupported branch is free thanks to the compile-time evaluation mechanism covered in the deep dive. Burst compiles code separately per target platform, so the ARM build keeps only the NEON path and the x86 build only the scalar fallback. Put the other way around, if you want SIMD on x86 too, you have to write a separate X86.Sse2 path — and this per-platform duplication is the real maintenance cost of tier 3.


Part 6: SIMD in Shipped Game Software

The best way to show that Unity’s Burst is not some special path is to look at other engines and shipped games. To put the conclusion first: SIMD is a settled, standard technique in the game industry, and what differs between engines is only the answer to “how do we let people use it.”

Unreal Engine ① — VectorRegister, the Floor of Engine Math

Unreal’s math library has stood on SIMD from the beginning. The key piece is the VectorRegister4Float type. As the name says, it is an abstraction of a four-float vector register, and the platform-specific implementations split at the header level — on x86 UnrealMathSSE.h implements the function set (VectorAdd, VectorMultiplyAdd, VectorCompareGT…) with SSE intrinsics, and on ARM UnrealMathNeon.h implements the same set with NEON intrinsics.

The structure should look familiar. It is exactly the same “thin abstraction” pattern as Burst’s v128. The difference is placement — in Unity, v128 is a tier 3 that people optionally descend to when they need optimization, whereas in Unreal VectorRegister4Float is the floor that all of engine math stands on at all times: FMatrix multiplication, FTransform composition, quaternion interpolation. An Unreal game already benefits from SIMD in its per-frame transform math even if the developer never writes a line of SIMD.

Unreal Engine ② — ISPC, “a Shader for the CPU”

Where explicit SIMD is needed, Unreal’s answer was not intrinsics but a dedicated compiler. Intel ISPC (Implicit SPMD Program Compiler), integrated since UE 4.23, is a tool where you write the code for “one element” in a C-like language and the compiler generates vector code for SSE4, AVX2, and NEON separately. It is used in Chaos physics, cloth simulation, and the animation system.

1
2
3
4
5
6
7
/* ISPC — write it like a single thread and it runs in parallel across the lane width */
export void Scale(uniform float input[], uniform float scale,
                  uniform float output[], uniform int count)
{
    foreach (i = 0 ... count)
        output[i] = input[i] * scale;   /* this one line is 8 lanes on AVX2 */
}

The code inside foreach is written “from one element’s point of view” like a GPU shader, but it actually executes in units of the vector lane width — the GPU’s SIMT model from Part 2, reproduced in software on top of a CPU vector unit. Comparing the approach with Unity’s lays out like this.

 Unity BurstUnreal ISPC
LanguageC# as-isDedicated language (C-like)
Who vectorizesLLVM auto-vectorization + optional intrinsicsThe compiler always vectorizes via the SPMD model
Multi-targetPer-platform compilation (SSE2/AVX2 dispatch)Code generated per ISA, chosen at runtime
ScopeAll Job codeDesignated modules such as physics and cloth

The approaches differ, but they arrive at the same place. Both engines converged on the same conclusion: leave gameplay code alone and vectorize only the system layer that churns through bulk data.

Physics Middleware — Jolt Physics and Horizon Forbidden West

The best example outside of engines is Jolt Physics. It is the open-source physics engine used by Horizon Forbidden West and Death Stranding 2, and it supports compile targets from SSE4.1 through AVX-512 on x86 and NEON on ARM64 — the hot loops of collision detection and the rigid body solver all sit on SIMD.

The numbers Guerrilla Games published at GDC 2022 sum up the effect of data-oriented design with SIMD. Replacing a commercial physics engine with Jolt doubled the simulation frequency while reducing memory and executable size, and used less CPU time on top of that. Physics is a textbook specimen of the “identical operation × bulk contiguous data” pattern, so it is a domain where every condition discussed in this post is satisfied.

Unity — From First-Party Packages to Shipped Games

The first thing to look at on the Unity side is that Unity itself practices the three tiers of Part 5 exactly as written.

  • Unity Physics: a stateless DOTS-based physics engine whose collision detection and solver are entirely Burst Jobs on top of Unity.Mathematics — a large-scale, in-production instance of tier 2 (SIMD-friendly types + auto-vectorization). What Jolt did with C++ intrinsics, Unity did with C# + Burst
  • xxHash3 in Unity.Collections: one hash function ships with two implementations — a general-purpose one built on Unity.Mathematics, and a Burst intrinsics implementation used on AVX2-capable platforms. Per the official docs, the intrinsics implementation yields an additional 30–50% on large data — a specimen that shows both the principle “descend to tier 3 only for verified bottlenecks” and the cost “once you descend, you maintain two platform-specific copies”

On the shipped-game side, V Rising (2022, released on ECS) and Cities: Skylines II serve as validation. CS2 in particular runs city-wide citizen and traffic simulation on ECS + Burst, and remains the largest commercial DOTS game to date.

CS2 also carries a cautionary lesson. Analyses of the performance controversy right after release found the bottleneck was not the Burst-compiled simulation but the GPU rendering side (excessive vertex counts, missing LODs, and so on). No matter how fast you make the simulation layer with SIMD, frame time is decided by the slowest bottleneck — a demonstration at commercial-game scale of the question “is compute the bottleneck?” that the next part will formalize.

The common thread running through these cases is that SIMD concentrates in the system layer. Physics (Jolt, Chaos, Unity Physics), animation (ISPC), transform math (VectorRegister), utilities like hashing (xxHash3), bulk simulation (DOTS) — all of it is engine and middleware level, and in none of these cases is the gameplay code above it a vectorization target. Part 7’s criteria, in other words, line up with what the whole industry actually does.


Part 7: Deciding Where to Apply It and Where Not To

Layout Comes First

SIMD sits near the last rung of the optimization ladder. By the time you consider applying it, two things should already be settled.

  1. Is the data laid out contiguously in SoA form? Code that iterates over scattered GameObject fields is not a vectorization candidate at all. The layout change alone yields the first gain from cache efficiency, and SIMD is a factor multiplied on top of that
  2. Is compute the bottleneck? Once the data grows past the cache, the bottleneck moves to memory bandwidth, and quadrupling the ALUs does nothing when data supply cannot keep up. The 1 million elements (4 MB) in my benchmark fit inside the M4 Pro’s L2 cache, which is why the compute bottleneck held

The workloads that pass both conditions are fairly well defined in games. Particle simulation, procedural mesh generation, movement and distance calculation for large unit counts, audio DSP — the “identical operation × bulk contiguous data” pattern. Conversely, general gameplay logic — code full of state branches where each object is handled differently — fails the very first precondition of “identical operation,” so it is not up for consideration. A rebuttal comment on the original article argued that “every programmer should know this” is an overstatement, and restricted to game programmers I think the rebuttal is right. The people who need to know are the ones building the systems in that list; for everyone else Burst’s auto-vectorization is enough.

The Question Before SIMD — Does Your Data Structure Chase Pointers?

Condition 1 above (“is the data laid out in SoA form?”) actually hides a harder question: is this a data structure that can be expressed as an array at all? Trees and graphs, where nodes reference one another, are not fixed by “changing the layout” to SoA.

The reason pointer chasing is the natural enemy of vectorization is specific. The address you read next lives inside the value you are reading right now. A load has to complete before the address of the next load is known, so memory accesses become serialized, the hardware prefetcher cannot predict where to go next, and the contiguous 128 bits a vector load demands never exist in the first place. In Part 4 the scalar sum was slow because it was bound to the latency of floating-point addition; pointer chasing is that same serial dependency, except bound to memory latency — hundreds of cycles on a cache miss. Layering SIMD on top accomplishes nothing, because compute was never the bottleneck.

The same trap shows up outside data structures in exactly the same shape. Running a regex engine’s DFA is the classic case: the core of it is the single line state = table[state][byte], which means the table address you read next lives inside the value you just read. Same structure as pointer chasing, same outcome — so search tools like grep and ripgrep don’t vectorize the automaton itself. They split the work into a SIMD prefilter plus scalar verification. SIMD algorithms such as memchr (SSE2/AVX2/NEON) or Teddy handle the scan that picks out candidate positions, and only the small set of survivors is handed to the slow state machine. The principle that vectorizability is decided by whether the access is serial rather than by what kind of code it is holds just as firmly outside of games.

The fix is not SIMD but linearization: replace pointers with array indices and put the nodes in one contiguous arena. This is exactly what Rendello described on HN — taking a pointer-based tree scattered across the heap and turning it into a linearized array structure to raise cache efficiency.

1
2
3
4
5
/* Before — pointer chasing: a cache miss per node, no vectorization possible */
class Node { Node left, right; float bound; }

/* After — index references: nodes sit contiguously, traversal becomes a linear scan */
struct Node { int left, right; float bound; }   /* indices into a NativeArray<Node> */

The transformation pays off even setting SIMD aside. An index is 32 bits, so nodes shrink relative to 64-bit pointers; the whole array can be serialized, copied, and relocated as a block; and there are no references left for the GC to trace. In Unity, if you want a tree inside a NativeArray, this form is the only option anyway.

Push one step further and you reach the most counterintuitive point in this article. The vector width changes the branching factor of your data structure. Instead of stopping at linearizing a binary tree, you redesign it to hold four children — so that testing one node compares four children’s bounds across four lanes at once.

Unity Physics’ BVH is built exactly that way. The fields of BoundingVolumeHierarchy.Node are FourTransposedAabbs Bounds and int4 Data, with up to four children. The “Transposed” in the name is the essential part: instead of storing four AABBs side by side, it transposes them per axis — four minimum-x values, then four minimum-y values, and so on. It is SoA applied inside a single node, and it turns intersection testing against four children into a handful of four-lane comparisons. The reason this is a 4-way BVH rather than a binary one is register width, not algorithms.

Rendello’s principle — that a data representation should be tied to access patterns rather than dogma — gets pushed this far in game engines. When the access pattern is bound to the hardware, the representation ends up bound to the hardware too.

Which is why the “premature optimization” wariness is only half right. Bolting SIMD code on later is indeed premature optimization, but which data structure to use is not a decision you can defer. Converting a pointer tree into a 4-way arena is a structural change that touches every call site, so starting it after the profiler has already named your bottleneck is starting too late. That is what makes the HN comment about “putting high-performance racing tires on a lemon with a broken engine” so accurate — the question is not when you fit the tires, but whether the chassis was ever built to take them.

There Is No SIMD Without Verification

Finally, the most practical single line of advice in the original article, translated into the Unity context: don’t believe it vectorized — check the assembly. If your code leans on auto-vectorization (tier 1), confirm in Burst Inspector that vector instructions like fadd v0.4s were actually emitted, and plant a Loop.ExpectVectorized() to catch regressions at compile time. Even code written explicitly (tier 3) cannot claim a gain without before-and-after profiling — the 3.6x in this post is a number I can state because I measured it.


Summary

QuestionAnswer
Why SIMD is fastIt computes 4 lanes of a 128-bit vector register with one instruction — the theoretical ceiling is a multiple of the vector width
A dedicated pipeline?No. Not a separate device like a GPU but an execution port inside the core — zero offload cost, scale capped at core count × lane count
Hardware landscapeIntel consumer CPUs (i9 included) top out at AVX2 256-bit, Zen 5 is native 512-bit, and Radeon is a SIMD machine built from two SIMD32 units per CU (SIMT model)
CPUs without SIMD?If it’s 64-bit it has SIMD — SSE2 is mandatory on x86-64, NEON on AArch64. The question is width, not presence
Industry casesUnreal uses VectorRegister (the floor of its math) + ISPC (Chaos, cloth), Jolt Physics (Horizon Forbidden West) covers SSE4.1–AVX-512 and NEON, and Unity has Unity Physics and xxHash3 (first-party packages) plus V Rising and Cities: Skylines II (shipped games)
Old-CPU riskAn unsupported instruction means instant death by Illegal Instruction (the Cyberpunk 2077 AVX and Helldivers 2 AVX2 cases). The fix is runtime dispatch — Burst’s desktop default already works this way
How to write explicit SIMDThe fixed five stages of broadcast → vector traversal → lane-parallel operation → reduction → tail, with branches turned into mask arithmetic
Measured gain (M4 Pro, .NET 10)3.6x on summing 1M floats, 2.7x on match counting — the ratio is set by what the scalar side is bound on
The path in UnityBurst is the only trustworthy path. Descend in order from auto-vectorization to Unity.Mathematics float4 to Burst Intrinsics v128, and check with Burst Inspector before each step down
Trees and graphsPointer chasing is serialized on memory latency and cannot be vectorized → linearizing into index arrays comes first. Beyond that, the vector width sets the branching factor (Unity Physics’ BVH is 4-way with FourTransposedAabbs)
State machines (outside games)A DFA’s state = table[state][byte] carries the same serial dependency → grep and ripgrep vectorize only the scan, not the automaton (SIMD prefilter + scalar verification)
  • Unity Job System and Burst — Job, NativeContainer, and Burst basics plus memory alignment
  • SoA vs AoS — the data layout that SIMD presupposes
  • Burst Compiler Deep Dive — success and failure conditions of auto-vectorization and a Burst Inspector walkthrough
  • This post — SIMD hardware principles and writing explicit SIMD

References

Primary Sources · Official Documentation

Hardware Analysis

Game Industry Cases

Data-Oriented Design

Community · Discussion

Measurement Tools

This post is licensed under CC BY 4.0 by the author.