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.

Memory ownership and lifetime

Always ask yourself: who owns the memory behind input and how long it lives?
When you receive a []const u8 parameter, you’re getting a slice, which is just a pointer + length. You have no idea where that pointer points to.
Example:

// a stack buffer
var buf: [64]u8 = undefined;
const s = buf[0..5];
store_input(allocator, s); // buf dies when caller's scope ends

// a temporary allocation they're about to free
const s = try allocator.alloc(u8, 10);
defer allocator.free(s);
store_input(allocator, s); // freed right after this line

// a string literal — this one is actually fine
store_input(allocator, "hello"); // static memory, lives forever
store_input

So if store_input just returns input directly:

fn store_input(input: []const u8) []const u8 {
return input; // you're just returning the same pointer
}
store_input direct

The caller gets back a slice pointing to memory they already own and may free or invalidate at any time. If you store that returned slice somewhere and access it later, it’s a dangling pointer. Classic use-after-free.

By doing allocator.dupe(u8, input) instead:

fn store_input(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
return allocator.dupe(u8, input);
}
store_input on the heap

… you’re allocating new memory on the heap, copying the bytes from input into it, and returning a pointer to that. Now the returned slice is completely independent of whatever the caller passed in. Its lifetime is controlled by whoever holds the allocator and decides to free it, typically the caller, after they’re done with it:

const stored = try store_input(allocator, some_input);
defer allocator.free(stored); // caller owns it, caller frees it
controlled lifetime

The key mental model is: a []const u8 parameter is a borrowed view, not owned data. If you need to outlive the borrow, you must make your own copy.

AdP

Archived: Zig

Zig 0.16.0: Init and arena

Given the new juicy main in zig v0.16.0, the runtime owns resources, not you, so you must not deinit(). Here’s what the arena property in std.process.Init struct looks like:

pub const Init = struct {
/// Permanent storage for the entire process,
/// cleaned automatically on exit.
arena: *std.heap.ArenaAllocator,
// ...
};
Init

As you see, arena is a pointer (*std.heap.ArenaAllocator), not a value. So you don’t own it, the runtime allocated it and passes you a pointer to it. It’s described as “permanent storage for the entire process, cleaned automatically on exit.” If you call arena.deinit(), you’d deinit() memory you don’t own, getting segfault.

const std = @import("std");
const Io = std.Io;

pub fn main(init: std.process.Init) !void {
var arena = init.arena;
const allocator = arena.allocator();
// arena.deinit() <-- don't!

var phone_book = std.AutoHashMap(i64, []const u8).init(allocator);
defer phone_book.deinit();

const one = "Foo";
const two = "Bar";
try phone_book.put(1, one);
try phone_book.put(2, two);

var iterator = phone_book.iterator();

while (iterator.next()) |obj| {
std.debug.print("{d}->{s}\n", .{ obj.key_ptr.*, obj.value_ptr.* });
}
}

// OUT:
// 1->Foo
// 2->Bar
Allocator

 

AdP

Archived: Zig

Zig 0.16.0: env vars and process args become non-global

Zig standard library historically had a major footgun: std.os.environ was meant to be equivalent to environ in C, which forced it to be populated it in a library linked to libc. But C is designed to avoid calling environment-modifying functions like setenv in a threaded context, because environ often is directly accessed without lock-ing. That’s why now environment variables are available only in the application’s main function, so functions that need access env vars can accept parameters for the needed values, or accept a *const process.Environ.Map parameter. This is where the “Juicy main” comes into play: an instance of this environment variable map can be obtained via the init parameter.

Inspecting how the new Juicy main works, it’s worth noting that you can access Minimal, with Init as its superset, and used as the first parameter of main as well.

 

pub fn main(init: std.process.Init) !void {
// Obtain the process-lifetime arena allocator provided by Init.
// This allocator is intended for allocations that live for the
// entire duration of the program.
const arena: std.mem.Allocator = init.arena.allocator();

// Access command-line arguments through the environment map.
// `keys()` returns an iterator over the argument keys
// (arguments are stored as entries in the environment map).
const args_em = init.environ_map.keys();

// Iterate through arguments obtained from the environment map
// and log each one.
for (args_em) |arg| {
std.log.info("arg_em: {s}", .{arg});
}

// Access command-line arguments via the Minimal interface.
// `toSlice` materializes the arguments into a slice using
// the provided allocator.
const args_min = try init.minimal.args.toSlice(arena);

// Iterate through the slice of arguments obtained from Minimal
// and log each one.
for (args_min) |arg| {
std.log.info("arg_min: {s}", .{arg});
}
}
*Environ.Map

Output:

$ zig build run -- param_1
info: arg_em: SHELL
info: arg_em: SESSION_MANAGER
...
info: arg_em: COLORTERM
info: arg_em: MATRIX_ROOM_ID
info: arg_em: XDG_CONFIG_DIRS
info: arg_em: NVM_INC
info: arg_em: XDG_MENU_PREFIX
info: arg_em: GTK_IM_MODULE
...
info: arg_em: XMODIFIERS
info: arg_em: DESKTOP_SESSION
info: arg_em: MATRIX_HOMESERVER
info: arg_em: EDITOR
info: arg_em: GOBIN
info: arg_em: GTK_MODULES
info: arg_em: XDG_SEAT
...
info: arg_em: VTE_VERSION
info: arg_em: DENO_INSTALL
info: arg_em: QTWEBENGINE_DICTIONARIES_PATH
info: arg_em: CLUTTER_IM_MODULE
info: arg_em: NVM_DIR
info: arg_em: LESSCLOSE
info: arg_em: XDG_SESSION_CLASS
info: arg_em: TERM
info: arg_em: GTK_OVERLAY_SCROLLING
info: arg_em: LESSOPEN
info: arg_em: USER
info: arg_em: DISPLAY
info: arg_em: SHLVL
...
info: arg_em: OLDPWD
info: arg_em: _
info: arg_em: CLICOLOR_FORCE
info: arg_min: /home/foo/bar/baz/zig-out/bin/recap_args
info: arg_min: param_1
Output

 

AdP

Archived: Zig

Zig 0.16.0 released

What’s new:

  • switch improvements: packed struct and packed union can now be used as switch prong items, compared solely via their backing integer. Decl literals and @enumFromInt may now also appear as switch prong items, and union tag captures are now allowed for all prongs, not just inline ones.
  • @cImport deprecated: C translation is moving to the build system. The @cImport builtin is now deprecated; the recommended path is to use b.addTranslateC(...) in build.zig with a header file, or use the official translate-c package for more customization.
  • @Type replaced with individual builtins: The @Type builtin has been removed and replaced with 8 dedicated type-creating builtins: @EnumLiteral, @Int, @Tuple, @Pointer, @Fn, @Struct, @Union, and @Enum. For example, @Type(.{ .int = .{ .signedness = .unsigned, .bits = 10 } }) is now simply @Int(.unsigned, 10).
  • I/O as an Interface: The headline stdlib change. This debut makes I/O abstracted through an interface, enabling Future, Group, Batch, and Cancelation primitives, along with reworked File System, Networking, Process, Time, Entropy, and Sync Primitives. GenericReader, AnyReader, and FixedBufferStream are removed in favor of this model.
  • heap.ArenaAllocator is now thread-safe and lock-free. heap.ThreadSafeAllocator is removed since it’s no longer necessary.
  • Deflate compression added: alongside simplified decompression APIs, with a zlib performance comparison provided in the notes.
  • “Juicy Main”: a new pattern for the main function that takes an io argument, enabling more flexible I/O abstraction at the program entry point.
  • Containers migrated to “Unmanaged” style. Thread.Pool removed. PriorityDequeue added.
  • mem changes: new cut functions added, and indexOf-style functions renamed to find.
  • Windows improvements: networking without ws2_32.dll, completed migration to NtDll, and inter-process progress reporting.
  • Crypto additions: AES-SIV, AES-GCM-SIV, Ascon-AEAD, Ascon-Hash, Ascon-CHash.
  • Environment variables and process args are no longer global state.
  • Local package overrides: you can now override dependencies with local paths, useful during development.
  • Project-local package fetching: packages can be fetched into the project directory rather than a global cache.
  • Unit test timeouts, new --error-style and --multiline-errors flags, and a Temporary Files API.
  • Incremental compilation continues to advance. x86, aarch64, and WebAssembly backends all received improvements. Import libraries can now be generated from .def files without LLVM. C translation was also improved.
  • New ELF linker: a rewritten ELF linker replaces the old one.
  • Smith: an AST-based structure-aware fuzzer. It helped find and fix numerous compiler bugs. Multiprocess fuzzing, infinite fuzzing mode, and crash dumps were also added.
  • LLVM 21 (with loop vectorization temporarily disabled due to a regression). Updated bundled libraries: musl 1.2.5, glibc 2.43, Linux 6.19 headers, macOS 26.4 headers, FreeBSD 15.0 libc.
  • Solaris, AIX, and z/OS support dropped: the Zig project cannot support proprietary OSes that make it unreasonably difficult to obtain system headers. illumos remains supported as an open-source OpenSolaris fork.
  • Other language changes include small integer > float coercion, forbidding runtime vector indexes, forbidding trivial local addresses being returned from functions, pointers to comptime-only types no longer being comptime-only, and tighter rules on packed unions (no unused bits, no pointers inside).

 

AdP

Archived: Zig