SIMD-Accelerated Vector Operations

Systems Programming advanced Building with AI ~35 min read

Overview

Single Instruction, Multiple Data (SIMD) is a class of CPU instructions that apply the same operation to multiple data elements simultaneously. Modern processors provide SIMD registers that are 128 bits (SSE), 256 bits (AVX2), or 512 bits (AVX-512) wide, allowing a single instruction to process 4, 8, or 16 floating-point values at once. For vector databases and search engines that compute millions of distance calculations per query, SIMD is not an optimization—it is a requirement for competitive performance.

Zig provides first-class SIMD support through its @Vector type, which maps directly to hardware SIMD registers. Unlike C intrinsics (_mm256_add_ps) that are architecture-specific and unreadable, Zig’s vector operations use natural operators (+, *, <) that the compiler lowers to the best available SIMD instructions for the target architecture. This makes SIMD code portable across x86 (SSE/AVX) and ARM (NEON/SVE) without architecture-specific branches.

In a typical vector search engine, SIMD-accelerated vector operations power the core distance computations: Euclidean distance, cosine similarity, dot product, and Hamming distance for binary vectors. These operations also underpin quantized vector support, where 32-bit floats are compressed to 8-bit integers and distance computations use integer SIMD lanes with popcount for binary quantization.

Core Concepts

SIMD Registers and Lane Width

A SIMD register holds multiple “lanes” of data. A 256-bit AVX2 register can hold 8 lanes of f32, 4 lanes of f64, 32 lanes of u8, or 16 lanes of u16. Operations execute on all lanes in parallel: adding two 8-lane f32 vectors performs 8 floating-point additions in a single instruction cycle. The throughput gain is proportional to the number of lanes, minus overhead for loading data into registers and extracting results.

Horizontal vs Vertical Operations

Vertical operations apply independently to each lane: a + b adds lane 0 of a to lane 0 of b, lane 1 to lane 1, etc. Horizontal operations combine lanes within a single register: summing all 8 lanes of an f32 vector to produce a scalar result. Vertical operations map perfectly to SIMD; horizontal operations (like computing the final sum in a dot product) require additional shuffle and add instructions, which are slower.

Quantization and Integer SIMD

Vector quantization compresses high-dimensional float vectors into lower-precision representations. Scalar quantization maps each f32 to a u8 (256 levels), allowing 4x more vectors per cache line. Binary quantization maps each dimension to a single bit. Distance between binary vectors is computed using popcount (population count—counting set bits in an XOR result), which modern CPUs execute in a single instruction.

Key Concept: @Vector in Zig

@Vector(N, T) creates a fixed-size SIMD vector of N elements of type T. Arithmetic operators (+, *, -) work element-wise. The compiler maps these to the best available hardware instructions (SSE, AVX2, NEON) based on the target architecture. No intrinsics or platform-specific code needed.

Key Concept: Data Alignment for SIMD

SIMD loads and stores are fastest when data is aligned to the register width (16-byte for SSE, 32-byte for AVX2). Misaligned access may work but incurs a performance penalty or fault on some architectures. In Zig, use @alignCast and aligned allocators to ensure vector data meets alignment requirements.

Key Concept: Popcount for Binary Vectors

The Hamming distance between two binary vectors is popcount(a XOR b)—the number of bit positions where they differ. Modern CPUs provide hardware POPCNT instructions. In Zig, @popCount on a @Vector of integers compiles to SIMD popcount operations, processing hundreds of dimensions per instruction cycle.

How It Works

SIMD Euclidean Distance in Zig

Euclidean distance between two vectors is sqrt(sum((a[i] - b[i])^2)). The SIMD version processes multiple dimensions per iteration, accumulating squared differences in a SIMD accumulator and reducing to a scalar at the end:

const VEC_SIZE = 8; // 8 x f32 = 256 bits (AVX2)

pub fn euclideanDistance(a: []const f32, b: []const f32) f32 {
    std.debug.assert(a.len == b.len);

    var sum_vec: @Vector(VEC_SIZE, f32) = @splat(0.0);
    const n = a.len;
    const aligned = n - (n % VEC_SIZE);

    // SIMD loop: process VEC_SIZE elements per iteration
    var i: usize = 0;
    while (i < aligned) : (i += VEC_SIZE) {
        const va: @Vector(VEC_SIZE, f32) = a[i..][0..VEC_SIZE].*;
        const vb: @Vector(VEC_SIZE, f32) = b[i..][0..VEC_SIZE].*;
        const diff = va - vb;
        sum_vec += diff * diff; // fused multiply-add on supported hardware
    }

    // Horizontal reduction: sum all lanes
    var sum: f32 = @reduce(.Add, sum_vec);

    // Scalar tail: handle remaining elements
    while (i < n) : (i += 1) {
        const diff = a[i] - b[i];
        sum += diff * diff;
    }

    return @sqrt(sum);
}

SIMD Dot Product for Cosine Similarity

Cosine similarity is dot(a, b) / (norm(a) * norm(b)). When vectors are pre-normalized (common in embedding spaces), cosine similarity reduces to just the dot product:

pub fn dotProduct(a: []const f32, b: []const f32) f32 {
    std.debug.assert(a.len == b.len);

    var dot_vec: @Vector(VEC_SIZE, f32) = @splat(0.0);
    const n = a.len;
    const aligned = n - (n % VEC_SIZE);

    var i: usize = 0;
    while (i < aligned) : (i += VEC_SIZE) {
        const va: @Vector(VEC_SIZE, f32) = a[i..][0..VEC_SIZE].*;
        const vb: @Vector(VEC_SIZE, f32) = b[i..][0..VEC_SIZE].*;
        dot_vec += va * vb;
    }

    var dot: f32 = @reduce(.Add, dot_vec);

    while (i < n) : (i += 1) {
        dot += a[i] * b[i];
    }

    return dot;
}

Binary Vector Hamming Distance

For binary-quantized vectors, each dimension is a single bit packed into u64 words. Hamming distance uses XOR and popcount:

pub fn hammingDistance(a: []const u64, b: []const u64) u32 {
    std.debug.assert(a.len == b.len);

    const VEC_LEN = 4; // 4 x u64 = 256 bits
    var count_vec: @Vector(VEC_LEN, u64) = @splat(0);
    const n = a.len;
    const aligned = n - (n % VEC_LEN);

    var i: usize = 0;
    while (i < aligned) : (i += VEC_LEN) {
        const va: @Vector(VEC_LEN, u64) = a[i..][0..VEC_LEN].*;
        const vb: @Vector(VEC_LEN, u64) = b[i..][0..VEC_LEN].*;
        const xor_result = va ^ vb;
        count_vec += @popCount(xor_result);
    }

    var count: u32 = @intCast(@reduce(.Add, count_vec));

    while (i < n) : (i += 1) {
        count += @popCount(a[i] ^ b[i]);
    }

    return count;
}
Key Concept: @reduce for Horizontal Operations

@reduce(.Add, vec) sums all lanes of a SIMD vector into a scalar. Other reduction operations include .Min, .Max, .And, .Or, and .Xor. Reductions are the bridge between the SIMD world (parallel lanes) and the scalar world (single values). Minimize reductions in inner loops— accumulate in SIMD registers and reduce once at the end.

Practical Applications

Vector Search with HNSW

The HNSW (Hierarchical Navigable Small World) graph index performs distance computations on every edge traversal during search. A single query may evaluate thousands of distance calculations. SIMD acceleration reduces the per-distance cost from microseconds to tens of nanoseconds, making real-time approximate nearest neighbor search feasible for million-scale vector collections.

Product Quantization Distance Tables

Product quantization divides vectors into subspaces and quantizes each subspace independently. At query time, a distance lookup table is precomputed for each subspace, and the total distance is the sum of table lookups. SIMD accelerates both the table precomputation (distance from query subvector to each centroid) and the table-lookup-and-sum step, using gather instructions to load non-contiguous table entries into SIMD registers.

Batch Distance Computation

When scoring a candidate list against a query vector, a typical search engine processes candidates in batches. Each batch loads multiple candidate vectors into SIMD registers, computes distances against the query, and writes results to a score buffer. This amortizes the cost of loading the query vector and maximizes instruction-level parallelism by interleaving loads and arithmetic.

Quantized Vector Compression

For example, scalar quantization can compress f32 vectors to u8 using min/max normalization per dimension. The quantization and dequantization loops use SIMD: a vector of f32 values is scaled, clamped to [0, 255], and truncated to u8 in a single SIMD pipeline. Distance computation on quantized vectors uses u8 SIMD lanes (32 per AVX2 register), achieving 4x throughput over f32 operations.

Implementation Patterns

The Strip-Mine Pattern

The fundamental SIMD coding pattern is “strip mining”: process data in chunks of the SIMD width, then handle the remaining elements with a scalar tail. This pattern appears in every SIMD distance function:

  1. Initialize a SIMD accumulator with @splat(0.0)
  2. Loop over aligned chunks, loading @Vector(N, T) from slices
  3. Perform SIMD arithmetic on each chunk, accumulating results
  4. After the loop, reduce the accumulator to a scalar with @reduce
  5. Process remaining elements (0 to N-1) with scalar code

Multi-Width Dispatch

Different CPUs support different SIMD widths. A robust implementation dispatches to the widest available instruction set at runtime. In Zig, this can be done with comptime feature detection or runtime CPU feature queries:

pub fn selectDistanceFn(comptime T: type) DistanceFn {
    const features = std.Target.x86.featureSet;
    if (comptime std.Target.x86.featureSetHas(features, .avx512f)) {
        return euclidean_avx512;
    } else if (comptime std.Target.x86.featureSetHas(features, .avx2)) {
        return euclidean_avx2;
    } else {
        return euclidean_sse2; // baseline x86-64 always has SSE2
    }
}

// Build with: zig build -Dcpu=native
// This enables all features supported by the build machine

Prefetching for Sequential Scans

When scanning through a large array of vectors, memory latency dominates over compute. Software prefetch instructions (@prefetch in Zig) hint the CPU to load the next vector into cache before it is needed:

pub fn batchDistance(query: []const f32, vectors: []const []const f32,
    scores: []f32) void {
    for (vectors, 0..) |vec, i| {
        // Prefetch next vector while computing current distance
        if (i + 1 < vectors.len) {
            @prefetch(vectors[i + 1].ptr, .{
                .rw = .read,
                .locality = 1, // L2 cache hint
            });
        }
        scores[i] = euclideanDistance(query, vec);
    }
}
Key Concept: Compile for Native CPU

Building with -Dcpu=native enables all SIMD instruction sets supported by the build machine. For deployment, either build with a known baseline (-Dcpu=x86_64_v3 for AVX2) or use runtime dispatch to select the optimal code path. Shipping AVX-512 code to a machine without AVX-512 causes an illegal instruction fault.

Performance Considerations

Common Pitfalls

Further Reading