SIMD and Vectors
Modern CPUs are capable of performing the same operation on multiple pieces of data simultaneously — this is called SIMD: Single Instruction, Multiple Data. A single AVX2 instruction can add eight f32 values at once instead of one. Zig gives you direct access to this capability through the @Vector type, with no intrinsic headers, no platform-specific APIs, and no runtime dispatch required. You write arithmetic on vectors; the compiler emits the best instructions your target supports.
This lesson covers how to declare vector types, apply operations element-wise, reduce vectors to scalars, and use these tools to write measurably faster data-parallel code.
What You'll Learn
By the end of this lesson you will write 3D vector math that runs on real SIMD registers: a magnitude, a normalize, and a reflection off a surface, each built from lane-wise arithmetic and a reduction rather than from a loop over three floats. Along the way you will meet the trap that makes SIMD bugs hard — a wrong reduction operator compiles, runs, exits zero, and quietly reports a number that is not the answer.
This is an optional extension, not a capstone milestone: the record analyzer you built never needs a vector. What this lesson is for is the shape of data-parallel thinking — deciding what work is element-wise (cheap, one instruction) and what work is horizontal (expensive, a shuffle ladder), which is the judgment that makes numeric code fast in any language. You arrive here able to convert a slice into a fixed-size array (Slices and Iteration) and to have the compiler compute a type before the program runs (Comptime), and both are load-bearing: a vector length must be comptime-known, and the only way to feed a runtime slice into one is the fixed-size conversion.
This is also the final lesson of the course.
Declaring and Using Vectors
A vector type is created with @Vector(len, T), where len must be comptime-known and T must be a boolean, integer, float, or pointer type. Any length is accepted — small powers of two are the typical choice because they map cleanly onto hardware registers, but @Vector(3, f32) for 3D geometry is perfectly legal and appears later in this lesson. You initialize a vector using array literal syntax.
const std = @import("std");
pub fn main() void {
// A vector of four f32 values
const Vec4 = @Vector(4, f32);
const a: Vec4 = .{ 1.0, 2.0, 3.0, 4.0 };
const b: Vec4 = .{ 5.0, 6.0, 7.0, 8.0 };
// Arithmetic operators work element-wise
const added = a + b;
const multiplied = a * b;
const subtracted = b - a;
std.debug.print("a + b = {d}\n", .{added});
std.debug.print("a * b = {d}\n", .{multiplied});
std.debug.print("b - a = {d}\n", .{subtracted});
// Index into a vector like an array
std.debug.print("added[2] = {d}\n", .{added[2]});
}
All the standard arithmetic operators — +, -, *, /, % — work on vectors and map directly to SIMD instructions. The compiler lowers them to real vector ops when the target supports them, and emits scalar fallbacks otherwise. Your code is portable without any conditionals.
Splat: Broadcasting a Scalar
A common pattern is to apply a scalar to every lane of a vector — multiplying all elements by a constant, for example. @splat creates a vector with every element set to the same scalar value. The vector length is inferred from the type context.
const std = @import("std");
pub fn main() void {
const Vec8 = @Vector(8, i32);
const data: Vec8 = .{ 1, 2, 3, 4, 5, 6, 7, 8 };
// Scale every element by 3
const scale: Vec8 = @splat(3);
const scaled = data * scale;
// Clamp: subtract 10 then pick max(result, 0)
const ten: Vec8 = @splat(10);
const shifted = data - ten;
const zero: Vec8 = @splat(0);
const clamped = @max(shifted, zero);
std.debug.print("original: {d}\n", .{data});
std.debug.print("scaled: {d}\n", .{scaled});
std.debug.print("clamped: {d}\n", .{clamped});
}
@splat is the idiomatic way to mix scalars and vectors. Zig does not implicitly broadcast scalars — you must be explicit, which keeps vector arithmetic readable.
Reductions with @reduce
@reduce collapses a vector to a single scalar by applying a binary operation across all lanes. This is how you compute dot products, find a minimum, or sum an entire vector in one logical step.
The available reduction operations are: .Add, .Mul, .And, .Or, .Xor, .Min, and .Max.
const std = @import("std");
fn dotProduct(a: @Vector(4, f32), b: @Vector(4, f32)) f32 {
// Multiply element-wise, then sum all four results
return @reduce(.Add, a * b);
}
fn horizontalMax(v: @Vector(8, i32)) i32 {
return @reduce(.Max, v);
}
pub fn main() void {
const a: @Vector(4, f32) = .{ 1.0, 2.0, 3.0, 4.0 };
const b: @Vector(4, f32) = .{ 4.0, 3.0, 2.0, 1.0 };
std.debug.print("dot(a, b) = {d}\n", .{dotProduct(a, b)});
const scores: @Vector(8, i32) = .{ 42, 7, 99, 15, 63, 81, 4, 55 };
std.debug.print("max score = {d}\n", .{horizontalMax(scores)});
// Boolean reduction: is any element negative?
const data: @Vector(4, i32) = .{ 3, -1, 5, 2 };
const zero: @Vector(4, i32) = @splat(0);
const any_negative = @reduce(.Or, data < zero);
std.debug.print("any negative: {}\n", .{any_negative});
}
The dot product example is a canonical SIMD pattern: multiply two vectors element-wise, then reduce with .Add. The two halves cost very differently. The multiply collapses to a single instruction — on this x86-64 target with 128-bit registers, one vmulps replaces all four scalar multiplies. The reduction does not: x86 has no instruction that sums the lanes of a register, so the compiler emits a shuffle-and-add ladder, and an optimized build of this function comes out at seven instructions, only one of them the multiply; the reduction ladder is the rest. Written out as scalar arithmetic the same work is four multiplies and three adds, and a compiled scalar loop is longer still. Element-wise work is where vectors pay; horizontal reductions are the part you want to hoist out of a loop and do once, which is exactly what the chunked-array pattern below does.
Comparisons and @select
Vector comparisons return a @Vector(len, bool). You can use @select to pick elements from two vectors lane-by-lane based on a boolean mask — this is a branchless conditional.
@select(T, mask, a, b) walks the lanes in parallel: where the mask lane is true it takes the corresponding lane of a, and where it is false it takes the lane of b. Nothing branches and nothing is skipped — every lane of both inputs is evaluated, and the mask only decides which result survives. Work out one case before you run the fence below.
Predict
The program below builds a mask from data > zero, then uses it twice. The second use is an absolute value written without a branch: it selects the original lane where the mask is true and the negated lane where it is false. Predict the absolute line for the input { 3, -1, 5, -2, -4, 7, 0, -6 }. Pay attention to the lane holding 0.
const Vec8i = @Vector(8, i32);
const data: Vec8i = .{ 3, -1, 5, -2, -4, 7, 0, -6 };
const zero: Vec8i = @splat(0);
const is_positive = data > zero;
const negated = zero - data;
const absolute = @select(i32, is_positive, data, negated);
std.debug.print("absolute: {d}\n", .{absolute});const std = @import("std");
pub fn main() void {
const Vec8i = @Vector(8, i32);
const data: Vec8i = .{ 3, -1, 5, -2, -4, 7, 0, -6 };
const zero: Vec8i = @splat(0);
// Element-wise comparison produces a boolean vector
const is_positive = data > zero;
// @select: pick from `data` where true, `zero` where false
const rectified = @select(i32, is_positive, data, zero);
// Absolute value without branching
const negated = zero - data;
const absolute = @select(i32, is_positive, data, negated);
std.debug.print("original: {d}\n", .{data});
std.debug.print("rectified: {d}\n", .{rectified});
std.debug.print("absolute: {d}\n", .{absolute});
}
@select is the vector equivalent of a ternary expression. Because it uses no branches, it avoids branch misprediction penalties that would otherwise appear when processing arrays element-by-element inside a loop.
Combining a mask with a reduction is where the two features meet, and it is easy to reach for the wrong reduction operator — the code compiles either way and the answer is silently wrong. Diagnose this one before changing anything.
Debug
This computes the average of the vector lanes above a threshold. Four lanes qualify (10, 30, 50, 70) so the answer should be 40, and it prints something else. Diagnose which reduction is wrong, then fix it so the program prints 'average above 9 = 40'.
const std = @import("std");
fn averageAbove(v: @Vector(8, i32), threshold: i32) i32 {
const t: @Vector(8, i32) = @splat(threshold);
const mask = v > t;
const zeros: @Vector(8, i32) = @splat(0);
// Sum of the lanes that passed the mask.
const kept = @select(i32, mask, v, zeros);
// How many lanes passed: 1 where the mask is true, 0 elsewhere.
const count = @reduce(.Or, @select(i32, mask, @as(@Vector(8, i32), @splat(1)), zeros));
if (count == 0) return 0;
return @divTrunc(@reduce(.Add, kept), count);
}
pub fn main() !void {
const v: @Vector(8, i32) = .{ 10, 2, 30, 4, 50, 6, 70, 8 };
const avg = averageAbove(v, 9);
if (avg != 40) {
std.debug.print("CHECK FAILED: average of the four values above 9 is 40, got {d}\n", .{avg});
return error.CheckFailed;
}
std.debug.print("average above 9 = {d}\n", .{avg});
}Expected output:
average above 9 = 40
The bug is .Or. Over the lanes { 1, 0, 1, 0, 1, 0, 1, 0 } a bitwise OR folds to 1, not 4, so the sum of 160 was divided by 1 and reported as 160. Nothing crashed and nothing warned — the reduction was valid, just the wrong one. @reduce(.Add, ...) totals the lanes to 4 and the average comes out 160 / 4 = 40. The distinction is worth keeping: .Or and .And answer yes/no questions about a boolean mask, while .Add over a 1/0 selection is how you count how many lanes passed.
Processing Arrays in SIMD Chunks
The most practical use of vectors is accelerating loops over large arrays. The pattern is to process chunk_size elements at a time using vector types, then handle the remaining tail with a scalar loop.
Converting a slice to a vector uses the [0..N].* fixed-size slice-to-array conversion — vectors require their size to be known at compile time.
const std = @import("std");
fn simdSum(values: []const f32) f32 {
const chunk_size = 4;
var acc: @Vector(chunk_size, f32) = @splat(0.0);
var i: usize = 0;
// Process four elements at a time
while (i + chunk_size <= values.len) : (i += chunk_size) {
const chunk: @Vector(chunk_size, f32) = values[i..][0..chunk_size].*;
acc += chunk;
}
// Reduce the accumulator to a single value
var total = @reduce(.Add, acc);
// Handle the remaining elements (tail)
while (i < values.len) : (i += 1) {
total += values[i];
}
return total;
}
pub fn main() void {
const data = [_]f32{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
const result = simdSum(&data);
std.debug.print("sum of 1..11 = {d}\n", .{result});
}
The slice-to-array conversion values[i..][0..chunk_size].* is necessary because @Vector initialization requires a comptime-known length. The [0..chunk_size] part makes the length comptime-known, and the .* dereferences the resulting pointer to produce the array value.
Now assemble that pattern yourself, for a maximum instead of a sum. The lines below are the body of a chunked scan, and only one arrangement both compiles and reports the right answer.
Arrange the code
These lines are the body of a function that finds the largest value in a slice by comparing four lanes at a time. It seeds the accumulator with the first chunk rather than a sentinel, then folds in every later chunk. Reorder them so it returns 40 for the data { 5, 12, 3, 9, 40, 2, 7, 1 } — then answer what the order is really testing: which line must run once outside the loop rather than once per chunk, and what does the accumulator hold at the moment the reduction reads it?
const chunk: @Vector(4, i32) = tail[c * 4 ..][0..4].*;}for (0..tail.len / 4) |c| {const tail = values[@typeInfo(@TypeOf(acc)).vector.len..];acc = @max(acc, chunk);var acc: @Vector(4, i32) = values[0..4].*;return @reduce(.Max, acc);
Try It Yourself
Reading about lanes is not the same as computing with them. This is a build task: a small program that reports its own pass/fail. Three functions over @Vector(3, f32) are stubbed out — a magnitude, a normalize, and a reflection off a surface — and each is one line of lane-wise arithmetic plus a reduction. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that prints the failure and returns an error, the same shape as the CHECK FAILED pattern in the debug block above. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything.
Floating-point results are compared with a tolerance rather than with ==, because @sqrt and division do not produce exact decimal values — that is why the checks call closeEnough instead of testing equality.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, work out which lanes each answer needs and where the scalar has to become a vector, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
const Vec3 = @Vector(3, f32);
// TODO 1: return the length of v. Square each lane, sum the three lanes with
// a reduction, then take the square root of that scalar.
// magnitude(.{ 3, 4, 0 }) -> 5
fn magnitude(v: Vec3) f32 {
_ = v;
return 0.0;
}
// TODO 2: return v scaled to length 1. Divide every lane by the magnitude —
// remember Zig does not broadcast a scalar into a vector for you.
// normalize(.{ 0, 5, 0 }) -> { 0, 1, 0 }
fn normalize(v: Vec3) Vec3 {
return v;
}
// TODO 3: return the reflection of incident vector v off a surface whose unit
// normal is n, using v - 2 * dot(v, n) * n. dot is an .Add reduction of v * n,
// and the two scalar factors have to reach the vector through @splat.
// reflect(.{ 1, -1, 0 }, .{ 0, 1, 0 }) -> { 1, 1, 0 }
fn reflect(v: Vec3, n: Vec3) Vec3 {
_ = n;
return v;
}
// --- Build checks: Do not edit below this line. ---
fn closeEnough(a: f32, b: f32) bool {
return @abs(a - b) < 0.0001;
}
fn vecCloseEnough(a: Vec3, b: Vec3) bool {
return closeEnough(a[0], b[0]) and closeEnough(a[1], b[1]) and closeEnough(a[2], b[2]);
}
pub fn main() !void {
const v: Vec3 = .{ 3.0, 4.0, 0.0 };
try check(closeEnough(magnitude(v), 5.0), "magnitude of { 3, 4, 0 } should be 5");
try check(closeEnough(magnitude(.{ 0.0, 0.0, 2.0 }), 2.0), "magnitude must use all three lanes, not just the first two");
try check(closeEnough(magnitude(.{ -3.0, -4.0, 0.0 }), 5.0), "magnitude of a negated vector is the same 5, because squaring drops the sign");
try check(closeEnough(magnitude(normalize(v)), 1.0), "a normalized vector has length 1");
try check(vecCloseEnough(normalize(.{ 0.0, 5.0, 0.0 }), .{ 0.0, 1.0, 0.0 }), "normalize should scale every lane by the same divisor");
try check(closeEnough(v[0], 3.0), "normalize must not modify its argument");
try check(vecCloseEnough(reflect(.{ 1.0, -1.0, 0.0 }, .{ 0.0, 1.0, 0.0 }), .{ 1.0, 1.0, 0.0 }), "a ray going down-right off a flat floor comes back up-right");
try check(vecCloseEnough(reflect(.{ 0.0, -1.0, 0.0 }, .{ 0.0, 1.0, 0.0 }), .{ 0.0, 1.0, 0.0 }), "a head-on ray reflects straight back");
try check(vecCloseEnough(reflect(.{ 1.0, 0.0, 0.0 }, .{ 0.0, 1.0, 0.0 }), .{ 1.0, 0.0, 0.0 }), "a ray parallel to the surface is unchanged, because its dot with the normal is 0");
std.debug.print("All checks passed.\n", .{});
std.debug.print("magnitude: {d}\n", .{magnitude(v)});
std.debug.print("normalized: {d:.4}\n", .{normalize(v)});
std.debug.print("reflected: {d}\n", .{reflect(.{ 1.0, -1.0, 0.0 }, .{ 0.0, 1.0, 0.0 })});
}
Expected output:
All checks passed.
magnitude: 5
normalized: { 0.6000, 0.8000, 0.0000 }
reflected: { 1, 1, 0 }Once it passes, try two variations and predict each before running:
- Drop the
@splatinnormalize. Change the body toreturn v / magnitude(v);— dividing a vector directly by the scalar. Predict what happens before running. It does not produce a wrong number: it does not compile at all. The compiler reports a type mismatch, becausef32and@Vector(3, f32)are different types and Zig performs no implicit broadcast. This is the same explicitness rule as@splatearlier in the lesson, met from the failing side. - Seed the reflection with the wrong sign. In
reflect, changev - twice_dot * ntov + twice_dot * n. Predict which check fails before running. It compiles and runs, and fails on the firstreflectcheck — the one whose message readsa ray going down-right off a flat floor comes back up-right. With the sign flipped the incident vector is pushed further through the surface instead of bouncing off it: the down-right ray comes out as{ 1, -3, 0 }rather than{ 1, 1, 0 }. The third check, the ray parallel to the surface, still passes: its dot product is 0, so the sign of a zero term makes no difference. A test that only exercised the parallel case would have missed the bug entirely.
Before the summary, two questions that reach back rather than forward — one to a lesson sixteen orders behind this one, and one that carries the chunking idea into a language that spells it differently.
Recall
No notes. Every chunked loop in this lesson cut a fixed-size window out of a slice with values[i..][0..4], and the whole pattern depends on a rule about ranges you learned back in Slices and Iteration. In that lesson, numbers[1..4] produced a slice of length 3 ending at the value 40. Which statement about Zig slice ranges explains both that result and why [0..4] here yields exactly four lanes?
Transfer
Zig makes you write @splat to turn a scalar into lanes: with data a @Vector(8, i32), the expression data * scale compiles when scale is a vector, while data * 3 is rejected with 'mixed scalar and vector operands'. Rust behaves the same way one level down, on a plain array: with data a [i32; 8], the expression data * 3 is rejected with 'cannot multiply [i32; 8] by {integer}'. Which statement names the mechanism the two rejections share, rather than a surface resemblance?
Key Takeaways
@Vector(len, T)creates a fixed-width SIMD vector type; all arithmetic operators work element-wise@splat(scalar)broadcasts a scalar to every lane — the vector length is inferred from context@reduce(.Op, vec)collapses a vector to a scalar using operations like.Add,.Max,.Min, or.Or- Vector comparisons return
@Vector(len, bool); use@selectfor branchless lane-wise conditionals - Process arrays in chunks by converting fixed-size slices to vectors with
slice[0..N].* - Zig automatically lowers vector operations to the best available hardware instructions for your target
- Tail handling (the elements that don't fill a full chunk) must be done explicitly with a scalar loop
- Vector length must be comptime-known — use generic functions or comptime parameters to vary it
Pro Tip: Choose your chunk size based on the target architecture and element type. A
@Vector(8, f32)maps perfectly to 256-bit AVX registers. For maximum portability without manual tuning, checkstd.simd.suggestVectorLength(f32)— it returns the preferred vector width for the current target at comptime, letting you write one implementation that auto-tunes across x86, ARM NEON, and WebAssembly SIMD.
Course Complete!
Congratulations — you've completed the Learning Zig curriculum! You started with zig run on a single file and finished here: explicit failure with error unions, explicit allocation with allocators you pass by hand, data modeled with structs and tagged unions, comptime as a zero-cost abstraction tool, a real build.zig, a packaged module tree, a tested command-line capstone — and, in this final stretch of extensions, advanced error composition, vtable polymorphism, C interop, async I/O, and SIMD.
That is the whole of Zig's promise in one arc: no hidden control flow, no hidden allocation, nothing you cannot see and reason about.
What to do next:
- Build a project that combines what you've learned — a CLI tool or a small system utility is a great start
- Explore the Zig Playground to experiment further
- Check out the Zig Language Reference for deeper coverage
Everything in this track is pinned to Zig 0.16.0. When 0.17 lands, the language core you learned here holds; expect the build.zig and I/O surfaces to move.