Chapter 35Project Gpu Compute In Zig

项目

概览

第 34 章概述了 GPU 执行模型、地址空间与调度规划;现在我们构建一个端到端工作负载:从 Zig 源开始,到验证后的二进制转储结束,准备提交到 Vulkan 或 WebGPU 的队列族。Target.zig 本项目由三部分拼接而成:用纯 Zig 编写的 SPIR-V 内核、具备 CPU 回退的宿主编排 CLI、以及用于比较捕获的 GPU 缓冲与期望结果的 diff 工具。build.zig

学习目标

  • 使用自托管后端将 Zig 计算内核翻译为 SPIR-V,并理解其期望的资源布局。
  • 在可有/无 GPU 访问的宿主应用中协调缓冲、调度几何与验证路径。
  • 构建轻量诊断,将 GPU 输出与确定性的 CPU 参考进行比对。

Refs: 34__gpu-fundamentals.xml, Random.zig

构建计算流水线

本工作负载对向量元素进行平方。宿主创建提交元数据与数据缓冲,内核对每个 lane 求平方,diff 工具校验设备捕获的结果。静态的 lane 容量映射 GPU 的 storage-buffer 布局,而宿主强制逻辑边界,以便在调度了额外线程时内核可以安全退出。builtin.zig

拓扑与数据流

调度规模刻意保持适中(1000 个元素,按 64 线程分块),从而让你把精力放在正确性而非占用率调优上。宿主注入随机浮点值、记录校验和以便观测,并发射下游工具或真实 GPU 驱动可复用的二进制数据。由于 storage buffer 面向原始字节,我们为每个指针参数配以extern struct封装,以保证与描述符表的布局一致。

编写 SPIR-V 内核

内核接收三个 storage buffer:描述逻辑长度的提交头、输入向量与输出向量。每次调用读取一个 lane、进行平方,并在边界内时将结果写回。防御性检查可阻止游离工作组越过逻辑长度访问内存——这在宿主为匹配 wavefront 粒度而填充调度时尤为常见。

Zig
//! SPIR-V 内核:逐元素向量平方
//!
// ! 该内核需要三个存储缓冲区:一个输入向量 (`in_values`),一个
// ! 输出向量 (`out_values`),以及一个描述符结构体 `Submission`,它
// ! 传递逻辑元素计数。每次调用都会将一个元素平方
// ! 并将结果写入 `out_values`。

const builtin = @import("builtin");

// / 内核将触及的最大元素数量。
pub const lane_capacity: u32 = 1024;

// / 主机和内核之间共享的提交头。
///
// / `extern` 布局确保结构与 Vulkan 或
/// WebGPU 描述符表创建的绑定匹配。
const Submission = extern struct {
    // / 主机请求的逻辑元素计数。
    len: u32,
    _padding: u32 = 0,
};

// / 内核预期的存储缓冲区布局。
const VectorPayload = extern struct {
    values: [lane_capacity]f32,
};

// / 将 `in_values` 的每个元素平方并将结果写入 `out_values`。
///
// / 内核是防御性编写的:它检查主机传递的逻辑长度
// / 和静态 `lane_capacity`,以避免在调度时出现越界写入,
// / 当调度线程数量超过所需时。
pub export fn squareVector(
    submission: *addrspace(.storage_buffer) const Submission,
    in_values: *addrspace(.storage_buffer) const VectorPayload,
    out_values: *addrspace(.storage_buffer) VectorPayload,
) callconv(.kernel) void {
    const group_index = @workGroupId(0);
    const group_width = @workGroupSize(0);
    const local_index = @workItemId(0);
    const linear = group_index * group_width + local_index;

    const logical_len = submission.len;
    if (linear >= logical_len or linear >= lane_capacity) return;

    const value = in_values.*.values[linear];
    out_values.*.values[linear] = value * value;
}

// 保护编译,使此文件仅在目标为 SPIR-V 时编译。
comptime {
    switch (builtin.target.cpu.arch) {
        .spirv32, .spirv64 => {},
        else => @compileError("squareVector must be compiled with a SPIR-V target, e.g. -target spirv32-vulkan-none"),
    }
}
运行
Shell
$ zig build-obj -fno-llvm -O ReleaseSmall -target spirv32-vulkan-none \
    -femit-bin=kernels/vector_square.spv \
    01_vector_square_kernel.zig
输出
Shell
no output (binary module generated)

实验结束后删除kernels/vector_square.spv,以便重复运行时始终从源代码重建着色程序。fs.zig

宿主编排与 CPU 回退

宿主 CLI 规划调度、播种确定性输入、运行 CPU 回退,并在需要时将参考转储写入out/reference.bin。它还校验 SPIR-V 头(0x07230203),使构建问题能立即暴露,而非在图形 API 深处才失败。可选钩子允许你放入捕获的 GPU 缓冲(out/gpu_result.bin)以供事后比较。

Zig
// 向量平方内核的项目主机管线。
//
// 此程序演示了与 `squareVector` SPIR-V 内核配对的 CPU 编排。
// 它准备输入数据,规划调度,
// 验证编译后的着色器模块,并运行镜像 GPU 算法的 CPU 回退。
// 当通过 `--emit-binary` 请求时,它还将 CPU
// 输出写入 `out/reference.bin`,以便外部 GPU 运行可以进行逐位比较。

const std = @import("std");

//  必须与 01_vector_square_kernel.zig 中的 `lane_capacity` 匹配。
const lane_capacity: u32 = 1024;
const default_problem_len: u32 = 1000;
const workgroup_size: u32 = 64;
const spirv_path = "kernels/vector_square.spv";
const gpu_dump_path = "out/gpu_result.bin";
const cpu_dump_path = "out/reference.bin";

//  封装了 GPU 工作组调度几何体,考虑了填充
//  当总工作负载不能均匀地划分为工作组边界时。
const DispatchPlan = struct {
    workgroup_size: u32,
    group_count: u32,
    //  包括填充在内的总调用次数,以填充完整的工作组
    padded_invocations: u32,
    //  最后一个工作组中未使用的通道数量
    tail: u32,
};

//  跟踪一个经过验证的 SPIR-V 模块及其文件系统路径,用于诊断。
const ModuleInfo = struct {
    path: []const u8,
    bytes: []u8,
};

pub fn main() !void {
    // 为开发构建初始化带有泄漏检测的分配器
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer switch (gpa.deinit()) {
        .ok => {},
        .leak => std.log.err("general-purpose allocator detected a leak", .{}),
    };
    const allocator = gpa.allocator();

    // 解析命令行参数以获取可选标志
    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();
    _ = args.next(); // 跳过程序名

    var emit_binary = false;
    var logical_len: u32 = default_problem_len;

    while (args.next()) |arg| {
        if (std.mem.eql(u8, arg, "--emit-binary")) {
            emit_binary = true;
        } else if (std.mem.eql(u8, arg, "--length")) {
            const value = args.next() orelse return error.MissingLengthValue;
            logical_len = try std.fmt.parseInt(u32, value, 10);
        } else {
            return error.UnknownFlag;
        }
    }

    // 钳制用户提供的长度,以防止内核中的缓冲区溢出
    if (logical_len == 0 or logical_len > lane_capacity) {
        std.log.warn("clamping problem length to lane capacity ({d})", .{lane_capacity});
        logical_len = @min(lane_capacity, logical_len);
        if (logical_len == 0) logical_len = @min(lane_capacity, default_problem_len);
    }

    // 计算处理这么多元素需要多少个工作组
    const plan = computeDispatch(logical_len, workgroup_size);
    std.debug.print(
        "launch plan: {d} groups × {d} lanes => {d} invocations (tail {d})\n",
        .{ plan.group_count, plan.workgroup_size, plan.padded_invocations, plan.tail },
    );

    // 使用确定性 PRNG,以便在不同环境中进行可重现的测试运行
    var prng = std.Random.DefaultPrng.init(0xBEEFFACE);
    const random = prng.random();

    // 生成具有可预测模式和随机噪声的输入数据
    var input = try allocator.alloc(f32, logical_len);
    defer allocator.free(input);
    for (input, 0..input.len) |*slot, idx| {
        const base: f32 = @floatFromInt(idx);
        slot.* = base * 0.5 + random.float(f32);
    }

    // 执行 CPU 参考实现以生成预期结果
    var cpu_output = try allocator.alloc(f32, logical_len);
    defer allocator.free(cpu_output);
    runCpuFallback(input, cpu_output);

    // 计算简单的校验和以进行快速健全性验证
    const checksum = checksumSlice(cpu_output);
    std.debug.print("cpu fallback checksum: {d:.6}\n", .{checksum});

    // 尝试加载和验证已编译的 SPIR-V 着色器模块
    const module = try loadSpirvIfPresent(allocator, spirv_path);
    defer if (module) |info| allocator.free(info.bytes);

    if (module) |info| {
        std.debug.print(
            "gpu module: {s} ({d} bytes, header ok)\n",
            .{ info.path, info.bytes.len },
        );
    } else {
        std.debug.print(
            "gpu module: missing ({s}); run kernel build command to generate it\n",
            .{spirv_path},
        );
    }

    // 检查 GPU 执行是否捕获了用于比较的输出
    const maybe_gpu_dump = try loadBinaryIfPresent(allocator, gpu_dump_path);
    defer if (maybe_gpu_dump) |blob| allocator.free(blob);

    if (maybe_gpu_dump) |blob| {
        // 将 GPU 结果与 CPU 参考逐通道比较
        const mismatches = compareF32Slices(cpu_output, blob);
        std.debug.print(
            "gpu capture diff: {d} mismatched lanes\n",
            .{mismatches},
        );
    } else {
        std.debug.print(
            "gpu capture diff: skipped (no {s} file found)\n",
            .{gpu_dump_path},
        );
    }

    // 显示前几个通道以供手动检查
    const sample_count = @min(input.len, 6);
    for (input[0..sample_count], cpu_output[0..sample_count], 0..) |original, squared, idx| {
        std.debug.print(
            "lane {d:>3}: in={d:.5} out={d:.5}\n",
            .{ idx, original, squared },
        );
    }

    // 如果请求,写入参考转储以供外部 GPU 验证工具使用
    if (emit_binary) {
        try emitCpuDump(cpu_output);
        std.debug.print("cpu reference written to {s}\n", .{cpu_dump_path});
    }
}

// / 通过向上取整到完整工作组来计算调度几何体。
// / 返回组数、总填充调用次数和未使用的尾部通道数。
fn computeDispatch(total_items: u32, group_size: u32) DispatchPlan {
    std.debug.assert(group_size > 0);
    // 向上取整以确保所有项目都被覆盖
    const groups = std.math.divCeil(u32, total_items, group_size) catch unreachable;
    const padded = groups * group_size;
    return .{
        .workgroup_size = group_size,
        .group_count = groups,
        .padded_invocations = padded,
        .tail = padded - total_items,
    };
}

// / 在 CPU 上执行平方操作,镜像 GPU 内核逻辑。
// / 每个输出元素都是其相应输入的平方。
fn runCpuFallback(input: []const f32, output: []f32) void {
    std.debug.assert(input.len == output.len);
    for (input, output) |value, *slot| {
        slot.* = value * value;
    }
}

// / 以双精度计算所有 f32 值的简单总和,以供观察。
fn checksumSlice(values: []const f32) f64 {
    var total: f64 = 0.0;
    for (values) |value| {
        total += @as(f64, @floatCast(value));
    }
    return total;
}

//  尝试从磁盘读取和验证 SPIR-V 二进制模块。
//  如果文件不存在则返回 null;验证魔术数字 (0x07230203)。
fn loadSpirvIfPresent(allocator: std.mem.Allocator, path: []const u8) !?ModuleInfo {
    var file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
        error.FileNotFound => return null,
        else => return err,
    };
    defer file.close();

    const bytes = try file.readToEndAlloc(allocator, 1 << 20);
    errdefer allocator.free(bytes);

    // 验证 SPIR-V 头的最小大小
    if (bytes.len < 4) return error.SpirvTooSmall;
    // 检查小端序魔术数字
    const magic = std.mem.readInt(u32, bytes[0..4], .little);
    if (magic != 0x0723_0203) return error.InvalidSpirvMagic;

    return ModuleInfo{ .path = path, .bytes = bytes };
}

//  如果文件存在,则加载原始二进制数据;如果文件丢失,则返回 null。
fn loadBinaryIfPresent(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
    var file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
        error.FileNotFound => return null,
        else => return err,
    };
    defer file.close();
    const bytes = try file.readToEndAlloc(allocator, 1 << 24);
    return bytes;
}

//  在 1e-6 容差范围内比较两个 f32 切片的近似相等性。
//  返回不匹配通道的数量;如果大小不同,则返回 expected.len。
fn compareF32Slices(expected: []const f32, blob_bytes: []const u8) usize {
    // 确保 blob 大小与 f32 边界对齐
    if (blob_bytes.len % @sizeOf(f32) != 0) return expected.len;
    const actual = std.mem.bytesAsSlice(f32, blob_bytes);
    if (actual.len != expected.len) return expected.len;

    var mismatches: usize = 0;
    for (expected, actual) |lhs, rhs| {
        // 使用浮点容差以考虑微小的 GPU 精度差异
        if (!std.math.approxEqAbs(f32, lhs, rhs, 1e-6)) {
            mismatches += 1;
        }
    }
    return mismatches;
}

// / 将 CPU 计算的 f32 数组作为原始字节写入磁盘,以供外部比较工具使用。
fn emitCpuDump(values: []const f32) !void {
    // 确保输出目录存在后再写入
    try std.fs.cwd().makePath("out");
    var file = try std.fs.cwd().createFile(cpu_dump_path, .{ .truncate = true });
    defer file.close();
    // 将 f32 切片转换为原始字节以进行二进制序列化
    const bytes = std.mem.sliceAsBytes(values);
    try file.writeAll(bytes);
}

math.zig

运行
Shell
$ zig build-obj -fno-llvm -O ReleaseSmall -target spirv32-vulkan-none \
    -femit-bin=kernels/vector_square.spv \
    01_vector_square_kernel.zig
$ zig run 02_host_pipeline.zig -- --emit-binary
输出
Shell
launch plan: 16 groups × 64 lanes => 1024 invocations (tail 24)
cpu fallback checksum: 83467485.758038
gpu module: kernels/vector_square.spv (5368 bytes, header ok)
gpu capture diff: skipped (no out/gpu_result.bin file found)
lane   0: in=0.10821 out=0.01171
lane   1: in=1.07972 out=1.16579
lane   2: in=1.03577 out=1.07281
lane   3: in=2.33225 out=5.43938
lane   4: in=2.92146 out=8.53491
lane   5: in=2.89332 out=8.37133
cpu reference written to out/reference.bin

若计划捕获 GPU 缓冲,请保留生成的out/reference.bin;否则删除以保持工作区整洁。

验证设备转储

diff 工具读取两份二进制转储(期望与捕获),并报告不匹配的 lane,同时预览前几处差异,以帮助你快速发现与数据相关的缺陷。它假定小端f32值,这与多数宿主 API 暴露原始映射缓冲的方式一致。mem.zig

Zig
// 比较两个 float32 二进制转储的工具。
//
// 这些文件预计是原始的小端序 32 位浮点数组。该
// 程序打印不匹配通道的数量(基于绝对容差),
// 并高亮显示前几个差异以进行快速诊断。

const std = @import("std");

// / 在诊断输出中显示的最大不匹配差异数量
const max_preview = 5;

pub fn main() !void {
    // 为开发构建初始化带有泄漏检测的分配器
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer switch (gpa.deinit()) {
        .ok => {},
        .leak => std.log.warn("compare_dump leaked memory", .{}),
    };
    const allocator = gpa.allocator();

    // 解析命令行参数,期望精确地有两个文件路径
    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();
    _ = args.next(); // 跳过程序名

    const expected_path = args.next() orelse return usageError();
    const actual_path = args.next() orelse return usageError();
    if (args.next()) |_| return usageError(); // 拒绝额外参数

    // 将两个二进制转储加载到内存中进行比较
    const expected_bytes = try readAll(allocator, expected_path);
    defer allocator.free(expected_bytes);

    const actual_bytes = try readAll(allocator, actual_path);
    defer allocator.free(actual_bytes);

    // 将原始字节重新解释为 f32 切片以进行逐元素比较
    const expected = std.mem.bytesAsSlice(f32, expected_bytes);
    const actual = std.mem.bytesAsSlice(f32, actual_bytes);

    // 如果数组长度不同,则提前退出
    if (expected.len != actual.len) {
        std.debug.print(
            "length mismatch: expected {d} elements, actual {d} elements\n",
            .{ expected.len, actual.len },
        );
        return;
    }

    // 跟踪总不匹配项并收集前几个以进行详细报告
    var mismatches: usize = 0;
    var first_few: [max_preview]?Diff = .{null} ** max_preview;

    // 使用浮点容差比较每个通道,以考虑微小的精度差异
    for (expected, actual, 0..) |lhs, rhs, idx| {
        if (!std.math.approxEqAbs(f32, lhs, rhs, 1e-6)) {
            // 存储前 N 个差异以进行诊断显示
            if (mismatches < max_preview) {
                first_few[mismatches] = Diff{ .index = idx, .expected = lhs, .actual = rhs };
            }
            mismatches += 1;
        }
    }

    // 打印比较结果摘要
    std.debug.print("mismatched lanes: {d}\n", .{mismatches});

    // 显示前几个不匹配项的详细信息以帮助调试
    for (first_few) |maybe_diff| {
        if (maybe_diff) |diff| {
            std.debug.print(
                "  lane {d}: expected={d:.6} actual={d:.6}\n",
                .{ diff.index, diff.expected, diff.actual },
            );
        }
    }
}

// / 打印用法信息并在调用无效时返回错误
fn usageError() !void {
    std.debug.print("usage: compare_dump <expected.bin> <actual.bin>\n", .{});
    return error.InvalidInvocation;
}

// / 将整个文件内容读取到分配的内存中,大小限制为 64 MiB
fn readAll(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    var file = try std.fs.cwd().openFile(path, .{});
    defer file.close();
    return try file.readToEndAlloc(allocator, 1 << 26);
}

// / 捕获单个浮点不匹配及其位置和值
const Diff = struct {
    index: usize,
    expected: f32,
    actual: f32,
};
运行
Shell
$ zig run 03_compare_dump.zig -- out/reference.bin out/reference.bin
输出
Shell
mismatched lanes: 0

要验证一次真实 GPU 运行,请将设备缓冲保存为out/gpu_result.bin,并用该文件重新运行03_compare_dump.zig以暴露任何分歧。Io.zig

注意与警示

  • storage buffer 需要显式对齐;请让你的extern struct定义与宿主描述符绑定保持同步,以避免隐性填充错误。
  • 自托管 SPIR-V 后端会在 CPU 目标上拒绝不受支持的地址空间,因此需将内核源文件与宿主构建隔离(CPU 可执行不应@import内核)。
  • 确定性 PRNG 播种能使 CPU 与 GPU 执行在多次运行与 CI 环境中保持可比较性。

练习

  • 通过绑定第二个输入缓冲扩展内核以融合乘加(a * a + b);相应更新宿主与 diff 工具。
  • 让宿主 CLI 发射描述调度计划的 JSON 元数据,以便外部分析器读取运行配置。json.zig
  • 捕获真实 GPU 输出(通过 Vulkan、WebGPU 或 wgpu-native),并将二进制输入03_compare_dump.zig,注意你的硬件可能需要调整容差。

替代方案与边界情况

  • 不同厂商对 storage buffer 的映射不同;在假设f32数组是密集排布前,请检查所需的最小对齐(例如部分驱动要求 16 字节)。
  • 对非常大的缓冲,采用流式比较而非将整个转储载入内存,以避免在低端机器上对分配器施压。
  • 面向 CUDA(nvptx64)目标时,将调用约定切换为.kernel并调整地址空间(.global/.shared),以满足 PTX 预期。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.