Zig – SIMD: an explanation

Single Instruction Multiple Data: do the same work on several values at once

SIMD stands for “single instruction, multiple data”. A CPU with SIMD support can perform the same operation on several numbers in parallel.

Suppose we want to add these four pairs:

a = [1, 2, 3, 4]
b = [5, 6, 7, 8]

Scalar code conceptually performs four additions:

1 + 5
2 + 6
3 + 7
4 + 8

With SIMD, those values are grouped into vectors, and the program expresses the operation as one vector addition:

[1, 2, 3, 4] + [5, 6, 7, 8]

The result is:

[6, 8, 10, 12]

Zig represents such values with @Vector:

const Vec4i = @Vector(4, i32);

const a: Vec4i = .{ 1, 2, 3, 4 };
const b: Vec4i = .{ 5, 6, 7, 8 };

const result = a + b;
// result is .{ 6, 8, 10, 12 }

The type:

@Vector(4, i32)

means “a vector containing four i32 lanes”. A lane is simply one element of the vector.

Zig permits vectors of booleans, integers, floats, and pointers. Their lengths must be known at compile time. Vector operations use SIMD instructions when the target supports them; otherwise, Zig can lower them to multiple instructions or process the elements individually. In other words, @Vector expresses parallel data operations without tying the source code directly to x86 SSE/AVX, ARM NEON, or another particular instruction set.

Operations are element-wise

Most ordinary operators work lane by lane:

const Vec4f = @Vector(4, f32);

const a: Vec4f = .{ 10.0, 20.0, 30.0, 40.0 };
const b: Vec4f = .{ 2.0, 4.0, 5.0, 8.0 };

const added = a + b; // { 12, 24, 35, 48 }
const multiplied = a * b; // { 20, 80, 150, 320 }
const divided = a / b; // { 5, 5, 6, 5 }
const greater = a > b; // @Vector(4, bool)

This applies to arithmetic, comparisons, bitwise operators, and many mathematical builtins.

One important rule is that Zig does not implicitly mix a vector with a scalar:

const values: @Vector(4, f32) = .{ 1, 2, 3, 4 };
const scale: f32 = 2;

// Invalid:
// const result = values * scale;

The scalar must first be copied into every lane using @splat:

const scale_vector: @Vector(4, f32) = @splat(scale);
const result = values * scale_vector;

// result is .{ 2, 4, 6, 8 }

@splat is the SIMD equivalent of turning 2 into [2, 2, 2, 2].

Turning a vector back into one value

Sometimes a vector calculation ends with a single result. For that, Zig provides @reduce:

const values: @Vector(4, i32) = .{ 1, 2, 3, 4 };

const sum = @reduce(.Add, values); // 10
const minimum = @reduce(.Min, values); // 1
const maximum = @reduce(.Max, values); // 4

A dot product combines element-wise multiplication with a reduction:

fn dot(a: @Vector(4, f32), b: @Vector(4, f32)) f32 {
    return @reduce(.Add, a * b);
}

test "vector dot product" {
    const std = @import("std");

    const a: @Vector(4, f32) = .{ 1, 2, 3, 4 };
    const b: @Vector(4, f32) = .{ 5, 6, 7, 8 };

    try std.testing.expectEqual(@as(f32, 70), dot(a, b));
}

The calculation is:

(1 × 5) + (2 × 6) + (3 × 7) + (4 × 8) = 70

Loading vectors from arrays and slices

Fixed-length arrays and vectors can be converted directly when their element types and lengths match:

const array: [4]f32 = .{ 1, 2, 3, 4 };
const vector: @Vector(4, f32) = array;
const another_array: [4]f32 = vector;

A fixed-length portion of a slice can also be loaded through a pointer-to-array:

fn loadFour(values: []const f32) @Vector(4, f32) {
    return values[0..4].*;
}

This requires the slice to contain at least four elements.

A realistic function usually processes full vector-sized chunks, followed by a scalar tail:

fn scale(output: []f32, input: []const f32, factor: f32) void {
    const lanes = 4;
    const Vec = @Vector(lanes, f32);
    const factor_vector: Vec = @splat(factor);

    var i: usize = 0;

    while (i + lanes <= input.len) : (i += lanes) {
        const values: Vec = input[i..][0..lanes].*;
        const scaled: Vec = values * factor_vector;
        output[i..][0..lanes].* = scaled;
    }

    // Elements that do not fill an entire vector.
    while (i < input.len) : (i += 1) {
        output[i] = input[i] * factor;
    }
}

For an input length of 11, the vector loop handles elements 0..3 and 4..7; the scalar loop handles elements 8..10.

Masks and selection

A vector comparison produces a boolean vector—a mask:

const values: @Vector(4, i32) = .{ -3, 7, -1, 9 };
const zero: @Vector(4, i32) = @splat(0);

const negative = values < zero;
// { true, false, true, false }

@select chooses each result lane according to such a mask:

const clamped = @select(i32, negative, zero, values);
// { 0, 7, 0, 9 }

This implements max(value, 0) for every lane without writing a branch for each element.

Zig also provides @shuffle for reordering, duplicating, or combining lanes. It is useful for operations such as rearranging color channels or preparing values for horizontal calculations.

Vectors are not merely arrays

Arrays and vectors look similar, but communicate different intent:

– [4]f32 means four values stored as an ordinary fixed-length collection.
– @Vector(4, f32) means four values intended for element-wise parallel operations.

They can be converted because both have a defined bit layout. However, vectors do not have a defined byte layout, so pointer-casting between array and vector pointers is illegal behavior. Use value conversion or coercion instead. Zig 0.16.0: Relationship with Arrays

Does @Vector guarantee one SIMD instruction?

No. It guarantees vector semantics, not a particular machine instruction. The compiler may:

– use one native SIMD instruction;
– split a large vector into several native instructions;
– lower an unsupported operation to scalar instructions;
– evaluate the expression at compile time when all inputs are known.

A four-lane f32 vector fits naturally into a 128-bit SIMD register, but the final code still depends on the target architecture, selected CPU features, operation, and optimization mode.

For performance work, compile an optimized build and inspect or benchmark the generated code. Explicit vectors make the intended parallelism clear, but they do not remove the need to
measure.