nano-plaid · SIMD school
an interactive field guide · no prior knowledge assumed

Your CPU can do sixteen multiplications in one instruction.

That trick is called SIMDSingle Instruction, Multiple Data — and it's how search engines, video codecs, and neural networks squeeze 10–40× more work out of the same silicon. This page teaches it from zero, with the machinery running live in front of you, and ends at real kernels measured on an Apple M4 and an x86 Linux box.

01 · the whole idea in one picture

Scalar vs SIMD: a race

A normal ("scalar") instruction touches one number at a time. A SIMD instruction touches a whole row of them. Same task below — double 32 numbers — two ways. Press run.

task: multiply every number by 2
SCALAR — 1 number / instruction instructions: 0
SIMD — 8 numbers / instruction instructions: 0

The SIMD side isn't running faster clock cycles — it's issuing fewer instructions, each carrying 8 numbers. That's the entire premise. Everything else on this page is about what "a row of numbers" means and what you're allowed to do to it.

02 · where the row of numbers lives

Registers & lanes

A SIMD register is just a fixed-width box of bits — 128, 256, or 512 of them, depending on the CPU. You choose how to slice it: into 8-bit, 16-bit, or 32-bit pieces. Each piece is a lane, and every lane gets the same operation.

lane explorer — slice a register yourself
register width
element type

Try it: 128-bit register + int8 → 16 lanes. Switch to 512-bit + int8 → 64 lanes. Same instruction cost, 4× the data. This is why quantizing neural-net values from float32 down to int8 isn't just a memory trick — it literally multiplies how many numbers fit per instruction.

03 · what one instruction does

Lane-wise operations

The bread and butter: take two registers, apply one operation independently in every lane, get a register of results. No lane ever talks to its neighbor. Pick an operation and execute it.

8 × int16 lanes
A
A + B
result

Lane-wise add: each result lane is A[i] + B[i]. One instruction, eight independent adds.

The sleeper hit is compare. It doesn't produce true/false — it produces a mask: all-ones (FFFF) where the test passed, all-zeros where it failed. AND-ing data with a mask keeps some lanes and zeroes others — which is how SIMD code does "if" without branching. Hold that thought; the case study in chapter 09 is built on exactly this.

04 · the catch

The horizontal tax

SIMD is blazing across lanes ("vertical") and awkward within a register ("horizontal"). Summing all lanes into one number takes log₂(lanes) rounds of shuffling — watch:

horizontal sum: 8 lanes → 1 value

Each round adds neighbors: 8 → 4 → 2 → 1. Three instructions just to fold one register — while a single lane-wise add would have processed 8 fresh numbers in the same time.

Consequence: good SIMD code keeps results spread across lanes for as long as possible — accumulate vertically a thousand times, fold horizontally once at the very end. When you see a weird data layout in a fast kernel, this is almost always why.

05 · the two dialects

Two worlds: x86 and ARM

Every mainstream CPU has SIMD, but the two big instruction-set families evolved it differently. x86 (Intel/AMD) kept widening the register. ARM (Apple, AWS, phones) kept the register at 128 bits and instead added smarter instructions and more parallel execution pipes.

familyextensionyearwidththe headline
x86SSE / SSE21999128-bitSIMD goes mainstream; xmm registers
x86AVX / AVX22011 / 2013256-bitdouble width; AVX2 adds full integer ops (ymm)
x86AVX-512 (+VNNI)2017 / 2019512-bithuge width + built-in masking + int8 dot products — but only on some chips
ARMNEON~2009128-bitARM's standard SIMD; mandatory on every 64-bit ARM core
ARM+dotprod2017128-bitsdot/udot: int8 dot products in one instruction
ARM+i8mm2020128-bitsmmla: tiny int8 matrix multiplies
ARMSVE / SVE22016+128–2048width-agnostic vectors (servers/HPC; your laptop doesn't have it)

So is a 512-bit x86 chip 4× faster than a 128-bit Apple chip? No — and this is the most common beginner miscalibration. An Apple M4 has four 128-bit SIMD pipes running every cycle, so its throughput per cycle rivals one 512-bit unit, with none of the downclocking older AVX-512 chips suffered. Width is one axis; pipes × width × what each instruction does is the real budget.

Reading the mnemonics

Instruction names look like cat-on-keyboard but are strict little sentences. The same operation, both dialects:

vpvector · packed ints dpdot product bof bytes (8-bit) u·sunsigned × signed daccumulate to 32-bit

= vpdpbusd (x86 AVX-512 VNNI)

ssigned dotdot product .4sinto 4 × 32-bit lanes .16bfrom 16 × 8-bit lanes

= sdot v0.4s, v1.16b, v2.16b (ARM NEON)

06 · the modern superstars

Dot-product instructions

Multiply pairs of numbers and add them up — the dot product — is the inner loop of neural networks, embeddings, and similarity search. It matters so much that both families added single instructions that do multiply + add + accumulate across many int8 lanes at once. Here is ARM's sdot, live:

sdot — 16 int8 pairs → 4 int32 sums, one instruction
A (16 × int8)
B (16 × int8)

Each output lane is the dot product of one 4-element group: acc[g] += A[4g]·B[4g] + … + A[4g+3]·B[4g+3]. That's 16 multiplies and 16 adds — 32 arithmetic ops — in one instruction, and it accumulates, so chaining them sums a whole vector.

ARM · dotprod

sdot / udot

16 int8 products → 4 int32 accumulators. The workhorse on Apple Silicon and AWS Graviton. 32 ops/instruction.

x86 · AVX-512 VNNI

vpdpbusd

The same idea at 512-bit width: 64 int8 products → 16 int32 accumulators. 128 ops/instruction — when the CPU has it, which many don't.

x86 · plain AVX2

psadbw (the improvised one)

No dot product on plain AVX2 — but "sum of absolute differences vs zero" is a sum of bytes. With a clever encoding (chapter 09) it impersonates a dot product on any x86 made since ~2013.

ARM · i8mm

smmla

A 2×8 × 8×2 int8 matrix multiply: 32 products/instruction — double sdot on paper. Whether that wins in practice is a chapter-09 plot twist.

07 · how code becomes SIMD

Three ways in

You almost never write raw machine code. There are three doors, in escalating order of effort and control — real code from a real kernel behind each one:

door 1 — the autovectorizer (free)
// Write branch-free code and the compiler
// emits SIMD for you. The trick: turn
// `if (bit) sum += q[i]` into a mask:
let mask = -((byte >> j) & 1);  // 0 or -1
p += q[i] & mask;               // no branch!
// Same math, but now it's straight-line
// arithmetic LLVM can vectorize.
door 2 — intrinsics (the usual)
// C/Rust functions that map 1:1 to
// instructions. You pick the ops; the
// compiler still allocates registers.
let s = _mm256_sad_epu8(
    _mm256_and_si256(mask, qbytes),
    zero);
// ^ one AVX2 instruction each
door 3 — inline asm (last resort)
// When the intrinsic doesn't exist yet
// (Rust's sdot was nightly-only):
asm!("sdot {out:v}.4s,
       {a:v}.16b, {b:v}.16b", …);
// Total control, zero safety net —
// YOU must check the CPU supports it.

Honest expectations, measured on the kernel in chapter 09: the autovectorizer bought ~2× over naive scalar. Hand-written intrinsics bought ~40×. The compiler is good at easy patterns and hopeless at exotic ones — it will never invent a data layout or discover that psadbw can fake a dot product. Layout is the human's job.

08 · shipping it without crashing

Dispatch & the traps

Here's the problem: you compile one binary, but your users' CPUs have different instruction sets. Run an AVX-512 instruction on a chip without it and the program doesn't get slower or wronger — it dies instantly (SIGILL: illegal instruction). So real libraries check the CPU at runtime and pick a kernel. Try it:

runtime dispatch simulator

Field notes: three ways this bites in real life

The benchmark that lied (dead-code elimination)

A kernel was benchmarked, its result thrown away — so the compiler deleted the entire loop and reported a fake 43× speedup. The fix is black_box() (Rust) or equivalent, forcing the result to be "used". Rule: a number that looks too good is a number to disassemble.

The wrong-architecture binary (Rosetta)

An Apple-Silicon Mac with an x86 toolchain installed will happily build x86 binaries and emulate them — ~7× slower, and every ARM kernel silently skipped. The tell: the disassembly contained zero sdot instructions. Always verify what you're actually running, not what you think you built.

Compile-time flags ≠ runtime reality

Building with -C target-cpu=native makes a binary that's fast on your machine and a SIGILL grenade on anyone else's. Shipped code detects features at runtime — that's exactly what the simulator above models.

09 · everything at once, for real

Case study: one kernel, five rungs

From nano-plaid, a search engine that stores documents as 1-bit vectors (just the signs — 25× smaller). Scoring needs a dot product between an int8 query and those bits. The identity that makes it possible, then the ladder of implementations — every chapter of this page appears in it:

the algebraic key

q · signs = 2P − T   where P = Σ q over 1-bits, T = Σ q (precomputed)

Signs are ±1, so split the sum by bit value and the whole dot product collapses to "sum the query values where the document has a 1-bit" — a masked sum. Chapter 03's masks + chapter 06's dot instructions can do exactly that, without ever decompressing the document.

measured: µs per document (lower = better)

Read the ladder bottom-up and every lesson clicks into place:

Rung 2 is the shocker — the clever algebra made it slower than the float loop (a branch per bit is poison). Rung 3, the branchless mask rewrite, let the autovectorizer help a little. Rung 4 is the real jump — sdot on ARM, the psadbw impersonation on x86, plus the deciding human insight: expand each document's bits once in registers and reuse them across all 32 query rows (the horizontal-tax lesson wearing work clothes). Rung 5 is the humility check: smmla does 2× the math per instruction and ties anyway, because the M4 issues it at half the rate. Paper MACs lose to measured microarchitecture.

End to end: that kernel took real search over 5,183 scientific documents from 18 ms to 5.7 ms per query — 3.3× faster than exhaustive float search, using 22× less memory.

10 · check yourself

Six questions

Instant feedback, explanations included. Miss one? The chapter number is next to it.

next → class 02 MaxSim via GEMM: why one matrix multiply is the bar to beat