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.
Declaring and Using Vectors
A vector type is created with @Vector(len, T), where len must be a comptime-known power of two, and T must be a numeric or boolean type. 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. On a CPU with 128-bit SIMD, this executes in two instructions instead of eight.
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.
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.
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.
Try It Yourself
Implement 3D vector math using @Vector(3, f32). Calculate the magnitude of a vector and normalize it — both operations appear constantly in graphics, physics, and geometry code.
const std = @import("std");
fn magnitude(v: @Vector(3, f32)) f32 {
// @sqrt is a Zig builtin that works on both scalars and vectors
return @sqrt(@reduce(.Add, v * v));
}
fn normalize(v: @Vector(3, f32)) @Vector(3, f32) {
const mag: @Vector(3, f32) = @splat(magnitude(v));
return v / mag;
}
fn cross(a: @Vector(3, f32), b: @Vector(3, f32)) @Vector(3, f32) {
// Cross product: (a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x)
const a_yzx: @Vector(3, f32) = .{ a[1], a[2], a[0] };
const b_yzx: @Vector(3, f32) = .{ b[1], b[2], b[0] };
const a_zxy: @Vector(3, f32) = .{ a[2], a[0], a[1] };
const b_zxy: @Vector(3, f32) = .{ b[2], b[0], b[1] };
return a_yzx * b_zxy - a_zxy * b_yzx;
}
pub fn main() void {
const v: @Vector(3, f32) = .{ 3.0, 4.0, 0.0 };
const up: @Vector(3, f32) = .{ 0.0, 1.0, 0.0 };
const right: @Vector(3, f32) = .{ 1.0, 0.0, 0.0 };
std.debug.print("v: {d}\n", .{v});
std.debug.print("magnitude: {d:.4}\n", .{magnitude(v)});
std.debug.print("normalized: {d:.4}\n", .{normalize(v)});
std.debug.print("up x right: {d}\n", .{cross(up, right)});
// Try: compute the angle between two vectors using dot product
// angle = acos(dot(normalize(a), normalize(b)))
const a: @Vector(3, f32) = .{ 1.0, 0.0, 0.0 };
const b: @Vector(3, f32) = .{ 0.0, 1.0, 0.0 };
const dot = @reduce(.Add, normalize(a) * normalize(b));
const angle_rad = std.math.acos(dot);
std.debug.print("angle(x, y): {d:.4} rad\n", .{angle_rad});
}
Try extending this with a reflect function: given an incident vector and a surface normal, compute the reflection direction using v - 2 * dot(v, n) * n.
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.
Next Steps
High-performance code is only useful if it handles failures gracefully. Next, you'll take a deeper look at error handling patterns -- advanced techniques for composing, propagating, and recovering from errors in real-world Zig applications.
Next lesson
Error Handling Patterns
Go beyond basic Zig error handling — define custom error sets, use errdefer for cleanup, merge error sets, and switch on specific errors for recovery.
25 min