第17章 向量与 SIMD

第17章 向量与 SIMD

警告:不要将本章的“向量”与 C++ 的 std::vector 或 Rust 的 Vec 混淆。

  • C++/Rust 的 Vector = 动态数组 (在 Zig 中是 ArrayList)。
  • 本章的 Vector = 数学/硬件向量 (SIMD, Single Instruction Multiple Data)。

在现代高性能计算中,SIMD (单指令多数据) 是一项关键技术。它允许 CPU 用一条指令同时处理多个数据(例如,一次性将 4 个整数相加)。这在图像处理、音频合成、物理模拟和加密算法中能带来巨大的性能提升。

Zig 是极少数将 SIMD 作为一等公民直接内置于语言语法的系统编程语言。你不需要写汇编,也不需要调用复杂的 intrinsic 函数,就可以享受 SIMD 带来的红利。

1. 什么是 @Vector

在 Zig 中,向量是一种原始数据类型,通过 @Vector(len, ChildType) 定义。

  • len:向量的长度(元素个数),必须在编译时已知。
  • ChildType:元素的类型(通常是整数、浮点数或布尔值)。
const std = @import("std");

pub fn main() !void {
    // 定义一个包含 4 个 u32 的向量
    const a = @Vector(4, u32){ 1, 2, 3, 4 };
    const b = @Vector(4, u32){ 5, 6, 7, 8 };

    // 向量加法:并行计算 1+5, 2+6, 3+7, 4+8
    const c = a + b;

    std.debug.print("Result: {any}\n", .{c}); 
    // 输出: { 6, 8, 10, 12 }
}

这段代码会被 Zig 编译器自动优化为对应 CPU 架构的 SIMD 指令(如 x86 的 SSE/AVX 或 ARM 的 NEON)。如果 CPU 不支持,编译器会自动生成回退的标量代码,保证程序依然能正确运行。

2. 向量运算

Zig 的大多数算术和位运算符都直接支持向量。

算术与位运算

const v = @Vector(4, f32){ 1.5, 2.5, 3.5, 4.5 };

// 标量广播:所有元素乘以 2.0
// 编译器会自动将标量 2.0 广播 (Splat) 为向量 {2.0, 2.0, 2.0, 2.0}
const doubled = v * @as(@Vector(4, f32), @splat(2.0)); 

// 或者更简单的写法(Zig 会尝试自动广播标量,但这取决于上下文)
// const doubled = v * 2.0; // 有时需要显式 @splat

std.debug.print("{any}\n", .{doubled}); // { 3.0, 5.0, 7.0, 9.0 }

比较运算

向量比较会返回一个布尔向量

const v1 = @Vector(4, i32){ 10, 20, 30, 40 };
const v2 = @Vector(4, i32){ 40, 30, 20, 10 };

// 结果是 @Vector(4, bool)
const mask = v1 > v2; 

std.debug.print("{any}\n", .{mask}); 
// 输出: { false, false, true, true }

select:基于掩码的选择

你可以使用布尔向量作为掩码,从两个向量中选择元素。这是 SIMD 编程中替代 if-else 分支的关键技术(因为 SIMD 指令通常不支持分支跳转)。

const pred = @Vector(4, bool){ true, false, true, false };
const a = @Vector(4, u8){ 1, 1, 1, 1 };
const b = @Vector(4, u8){ 2, 2, 2, 2 };

// 如果 pred 为 true,选 a 的元素,否则选 b 的元素
const result = @select(u8, pred, a, b);

std.debug.print("{any}\n", .{result}); // { 1, 2, 1, 2 }

3. 数组与向量的转换

数组转向量

只有编译时已知长度的数组或切片才能转换为向量。

const arr = [_]f32{ 1.1, 2.2, 3.3, 4.4 };
// 显式转换
const vec: @Vector(4, f32) = arr;

如果数组在运行时动态生成,你需要先将其复制到向量中。

// 假设 slice 是运行时切片
fn process(slice: []f32) !void {
    if (slice.len < 4) return;
    
    // 取前 4 个元素转换为向量
    // slice[0..4] 产生一个指针 *[4]f32
    // .* 解引用得到数组 [4]f32,然后自动转换为向量
    const vec: @Vector(4, f32) = slice[0..4].*;
    
    // ... SIMD 计算 ...
}

向量转数组

向量可以直接像数组一样通过索引访问,或者转换回数组。

const vec = @Vector(4, u8){ 'z', 'i', 'g', '!' };
const arr: [4]u8 = vec;

4. 实战:手动 SIMD 循环

在处理大数据时,我们通常会手动将数据分块,用 SIMD 处理大块数据,最后用标量代码处理剩余的尾部数据。

fn addArrays(a: []const f32, b: []const f32, result: []f32) void {
    const len = a.len;
    // 每次处理 8 个浮点数 (256-bit AVX) 
    const vec_len = 8; 
    
    var i: usize = 0;
    
    // 1. SIMD 循环
    while (i + vec_len <= len) : (i += vec_len) {
        // 加载数据
        const va: @Vector(vec_len, f32) = a[i..][0..vec_len].*;
        const vb: @Vector(vec_len, f32) = b[i..][0..vec_len].*;
        
        // 并行计算
        const vc = va + vb;
        
        // 存回结果
        result[i..][0..vec_len].* = vc;
    }
    
    // 2. 标量循环 (处理剩余部分)
    while (i < len) : (i += 1) {
        result[i] = a[i] + b[i];
    }
}

5. 性能陷阱

  1. 不要过度使用:对于极其简单的操作,现代编译器的自动向量化(Auto-vectorization)可能比你手写的代码更好。只有在性能分析(Profiler)显示瓶颈时再手动使用 @Vector
  2. 向量大小:不要创建过大的向量(如 @Vector(10000, u8))。向量应适配 CPU 寄存器大小(通常是 128-bit, 256-bit, 或 512-bit)。过大的向量会导致寄存器溢出(Spill),反而降低性能。
  3. 对齐 (Alignment):虽然 Zig 会自动处理,但 SIMD 加载/存储通常要求内存对齐。使用 align 关键字可以优化内存访问。

总结

Zig 的 @Vector 是揭开 SIMD 神秘面纱的钥匙。它将极其底层的硬件能力包装成了安全、易用的语言特性。

  • 语法@Vector(len, type)
  • 操作:算术、位运算自动并行化。
  • 逻辑:使用 @select 代替分支。
  • 互操作:与数组/切片可以方便地相互转换。

至此,我们的 Zig 基础教程就告一段落了。从基本的语法,到内存管理,再到构建系统和底层优化,你已经掌握了 Zig 语言的核心精髓。

现在的你,已经准备好去构建真正的高性能软件了。Happy Zigging! ⚡


原文出处: https://pedropark99.github.io/zig-book/