Chapter 34Gpu Fundamentals

GPU 基础

概览

上一章的 C 互操作桥梁使 Zig 得以与数十年的原生代码对话(见33);下一步是利用高度并行的设备,同时保留 Zig 的易用性。我们将把 GPU 执行模型映射到 Zig 的语言原语,审视地址空间与调用约定如何约束内核代码,并学习驯服仍在演进的 SPIR-V 工具链所需的构建标志(见v0.15.2)。

同时,我们将对比以计算为先的设计与图形流水线,指出 Zig 标准库已理解 GPU 目标的部分,并概述在仍需纯 CPU 运行的项目中可行的宿主/设备协作模式(见Target.zig)。

学习目标

  • 理解 Zig 的编译模型与 GPU 执行层级及内存类别之间的对应关系。
  • 使用显式的调用约定与地址空间声明并编译 GPU 内核。
  • 规划启动参数,使在无加速器时能优雅地回退到 CPU 实现。

相关定义参见builtin.zigmath.zig

GPU 架构基础

GPU 暴露成千上万的轻量线程,按照 work item、work group 与 grid 的层级组织;Zig 通过@workGroupId@workGroupSize@workItemId等内建提供这些索引,并保持模型显式,使内核行为可预测。由于 GPU 编译器会惩罚隐式全局状态,Zig 倾向显式参数与结果位置的设计,天然契合 SIMT 硬件所要求的确定性流程。

执行模型:SIMT 与线程组

单指令多线程(SIMT)将多个 lane 捆绑为 warp 或 wavefront,在分歧发生前运行同一指令流。为.spirv32.spirv64.nvptx.amdgcn等目标编译时,Zig 会将默认调用约定替换为专用的 GPU 变体,因此callconv(.kernel)会生成满足各平台调度器期望的代码。分歧以显式方式处理:基于每个 lane 的值进行分支会产生谓词掩码以暂停非活动线程,因此以粗颗粒分支来组织内核可保持吞吐量的可预测性。

内存层级与地址空间

Zig 通过一等地址空间来建模 GPU 内存——包括.global.shared.local.constant.storage_buffer等——它们各自拥有一致性与生命周期规则。编译器会拒绝跨越禁止空间的指针算术,迫使内核作者明确数据究竟位于共享内存还是设备全局缓冲。仅在能够证明访问规则仍然有效时才使用@addrSpaceCast等显式转换;对与宿主 API 共享的数据,优先使用extern struct有效载荷以保证布局稳定。

计算与图形流水线

计算内核是从宿主代码入队的 SPIR-V 或 PTX 入口;图形着色器则经过固定流水线,Zig 目前将其视为你用着色语言编写或由 SPIR-V 翻译得到的外部二进制。Zig 的@import系统尚不能生成渲染流水线,但你可以嵌入预编译 SPIR-V,并通过用 Zig 编写的 Vulkan 或 WebGPU 宿主进行调度,从而与标准库其他位置所依赖的分配器与错误处理纪律实现统一。

用 Zig 面向 GPU

编译器对构建的视图由builtin.target体现,记录架构、OS 标签、ABI 与允许的地址空间;在 CLI 层面切换-target即可将代码重新定位到宿主 CPU、SPIR-V 或 CUDA 后端。Zig 0.15.2 同时提供自托管 SPIR-V 后端与可通过-fllvm选择的 LLVM 回退,使你能试验更契合下游驱动的管线。

理解 Target 结构

在进行 GPU 特定编译之前,了解 Zig 在内部如何表示编译目标很有价值。下图展示完整的std.Target结构:

graph TB subgraph "std.Target Structure" TARGET["std.Target"] CPU["cpu: Cpu"] OS["os: Os"] ABI["abi: Abi"] OFMT["ofmt: ObjectFormat"] DYNLINKER["dynamic_linker: DynamicLinker"] TARGET --> CPU TARGET --> OS TARGET --> ABI TARGET --> OFMT TARGET --> DYNLINKER end subgraph "Cpu Components" CPU --> ARCH["arch: Cpu.Arch"] CPU --> MODEL["model: *const Cpu.Model"] CPU --> FEATURES["features: Feature.Set"] ARCH --> ARCHEX["x86_64, aarch64, wasm32, etc"] MODEL --> MODELEX["generic, native, specific variants"] FEATURES --> FEATEX["CPU feature flags"] end subgraph "Os Components" OS --> OSTAG["tag: Os.Tag"] OS --> VERSION["version_range: VersionRange"] OSTAG --> OSEX["linux, windows, macos, wasi, etc"] VERSION --> VERUNION["linux: LinuxVersionRange<br/>windows: WindowsVersion.Range<br/>semver: SemanticVersion.Range<br/>none: void"] end subgraph "Abi and Format" ABI --> ABIEX["gnu, musl, msvc, none, etc"] OFMT --> OFMTEX["elf, macho, coff, wasm, c, spirv"] end

该目标结构揭示了 GPU 编译如何与 Zig 的类型系统整合。当你指定-target spirv32-vulkan-none时,设置为:CPU 架构spirv32(32 位 SPIR-V)、OS 标签vulkan(Vulkan 环境)、ABInone(独立环境,无 C 运行时),并隐式选择对象格式spirv。目标完全决定代码生成行为:builtin.target.cpu.arch.isSpirV()为真,地址空间支持启用,编译器选择 SPIR-V 后端而非 x86_64 或 ARM。该结构以统一语义处理所有目标——CPU、GPU、WebAssembly、裸机。对象格式字段(ofmt)告诉链接器生成哪种二进制:Linux 用elf、Darwin 用macho、Windows 用coff、WebAssembly 用wasm、GPU 着色器用spirv。理解此架构有助于解析三元组、预测可用内建(如 GPU 目标上的@workGroupId),并排查跨编译问题。

检查目标与地址空间

第一个示例内省本机构建目标,报告编译器允许的 GPU 地址空间,并合成用于 SPIR-V 的跨编译三元组。即使在非 GPU 宿主运行,也能了解 Zig 用于描述加速器的术语(参见Query.zig)。

Zig
const std = @import("std");
const builtin = @import("builtin");

pub fn main() !void {
    // Query the compile-time target information to inspect the environment
    // 查询编译时目标信息以检查环境
    // this binary is being compiled for (host or cross-compilation target)
    // 此二进制文件正在为其编译(主机或交叉编译目标)
    const target = builtin.target;

    // Display basic target information: CPU architecture, OS, and object format
    // 显示基本目标信息:CPU架构、操作系统和对象格式
    std.debug.print("host architecture: {s}\n", .{@tagName(target.cpu.arch)});
    std.debug.print("host operating system: {s}\n", .{@tagName(target.os.tag)});
    std.debug.print("default object format: {s}\n", .{@tagName(target.ofmt)});

    // Check if we're compiling for a GPU backend by examining the target CPU architecture.
    // 通过检查目标 CPU 架构,检查我们是否正在为 GPU 后端编译。
    // GPU architectures include AMD GCN, NVIDIA PTX variants, and SPIR-V targets.
    // GPU 架构包括 AMD GCN、NVIDIA PTX 变体和 SPIR-V 目标。
    const is_gpu_backend = switch (target.cpu.arch) {
        .amdgcn, .nvptx, .nvptx64, .spirv32, .spirv64 => true,
        else => false,
    };
    std.debug.print("compiling as GPU backend: {}\n", .{is_gpu_backend});

    // Import address space types for querying GPU-specific memory capabilities
    // 导入地址空间类型以查询 GPU 特定的内存功能
    const AddressSpace = std.builtin.AddressSpace;
    const Context = AddressSpace.Context;

    // Query whether the target supports GPU-specific address spaces:
    // 查询目标是否支持 GPU 特定的地址空间:
    // - shared: memory shared within a workgroup/threadblock
    // - shared: 工作组/线程块内共享的内存
    // - constant: read-only memory optimized for uniform access across threads
    // - constant: 为跨线程统一访问优化的只读内存
    const shared_ok = target.cpu.supportsAddressSpace(AddressSpace.shared, null);
    const constant_ok = target.cpu.supportsAddressSpace(AddressSpace.constant, Context.constant);

    std.debug.print("supports shared address space: {}\n", .{shared_ok});
    std.debug.print("supports constant address space: {}\n", .{constant_ok});

    // Construct a custom target query for SPIR-V 64-bit targeting Vulkan
    // 构造一个针对 Vulkan 的 SPIR-V 64 位自定义目标查询
    const gpa = std.heap.page_allocator;
    const query = std.Target.Query{
        .cpu_arch = .spirv64,
        .os_tag = .vulkan,
        .abi = .none,
    };

    // Convert the target query to a triple string (e.g., "spirv64-vulkan")
    // 将目标查询转换为三元组字符串(例如,“spirv64-vulkan”)
    const triple = try query.zigTriple(gpa);
    defer gpa.free(triple);
    std.debug.print("example SPIR-V triple: {s}\n", .{triple});
}
运行
Shell
$ zig run 01_target_introspection.zig
输出
Shell
host architecture: x86_64
host operating system: linux
default object format: elf
compiling as GPU backend: false
supports shared address space: false
supports constant address space: false
example SPIR-V triple: spirv64-vulkan-none

即便本机架构是 CPU,合成 SPIR-V 三元组也能帮助你接线生成 GPU 二进制的构建步骤,而无需更换机器。

声明内核与调度元数据

下述内核将其调度坐标存入一个 storage-buffer 结构,展示 GPU 特定的调用约定、地址空间与内建如何组合。编译需使用 SPIR-V 目标与自托管后端(-fno-llvm),使 Zig 发射可供 Vulkan 或 WebGPU 队列提交的二进制模块。

Zig
// ! GPU内核:坐标捕获
//!
//! 此模块演示一个最小的SPIR-V计算内核,它将GPU调度坐标捕获到存储缓冲区中。
//! 它展示了如何使用Zig的GPU特定内置函数和地址空间注释来编写编译为SPIR-V的内核。

const builtin = @import("builtin");

/// 表示单个调用的GPU调度坐标
///
/// 使用`extern`布局来保证内存布局与主机端期望匹配,
/// 确保内核的输出可以被读取缓冲区的CPU代码安全解释。
const Coordinates = extern struct {
    // 工作组ID(此调用所属的组)
    group: u32,
    // 工作组大小(此维度中每组的调用数)
    group_size: u32,
    // 工作组内的本地调用ID(0到group_size-1)
    local: u32,
    // 所有调用的全局线性ID(group * group_size + local)
    linear: u32,
};

/// 捕获调度坐标的GPU内核入口点
///
/// 此函数必须被导出,以便SPIR-V编译器生成入口点。
/// `callconv(.kernel)`调用约定告诉Zig发出GPU特定函数属性,
/// 并根据计算着色器ABI处理参数传递。
///
/// 参数:
///   - out:指向将写入坐标的存储缓冲区的指针。
///          `.storage_buffer`地址空间注释确保适当的
///          设备可见GPU内存的内存访问模式。
pub export fn captureCoordinates(out: *addrspace(.storage_buffer) Coordinates) callconv(.kernel) void {
    // 查询X维度中的工作组ID(第一维度)
    // @workGroupId是GPU特定的内置函数,返回当前工作组的坐标
    const group = @workGroupId(0);

    // 查询工作组大小(此维度中每组的调用数)
    // 这在调度时由主机设置并在此处查询以保持完整性
    const group_size = @workGroupSize(0);

    // 查询此工作组内的本地调用ID(0到group_size-1)
    // @workItemId是每个工作组的线程索引
    const local = @workItemId(0);

    // 计算所有调用的全局线性索引
    // 此公式将2D坐标(group, local)转换为扁平1D索引
    const linear = group * group_size + local;

    // 将所有捕获的坐标写入输出缓冲区
    // GPU将确保此写入在同步后对主机可见
    out.* = .{
        .group = group,
        .group_size = group_size,
        .local = local,
        .linear = linear,
    };
}

// 编译时验证,确保此模块仅针对SPIR-V目标编译
// 这可以防止意外编译到GPU内置函数不可用的CPU架构
comptime {
    switch (builtin.target.cpu.arch) {
        // 接受32位和64位SPIR-V架构
        .spirv32, .spirv64 => {},
        // 使用有用的错误消息拒绝所有其他架构
        else => @compileError("captureCoordinates 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=chapters-data/code/34__gpu-fundamentals/capture_coordinates.spv \
    chapters-data/code/34__gpu-fundamentals/02_spirv_fill_kernel.zig
输出
Shell
no output (binary module generated)

发射的.spv二进制可直接用于 Vulkan 的vkCreateShaderModule或 WebGPU 的wgpuDeviceCreateShaderModuleextern struct确保宿主描述符与内核预期布局一致。

工具链选择与二进制格式

Zig 的构建系统可通过addObjectaddLibrary注册 GPU 制品,使你能在同一工作区中将 SPIR-V 模块与 CPU 可执行并置。当 SPIR-V 验证需要特定环境(Vulkan vs OpenCL)时,请在-target三元组中设置相应 OS 标签,并固定优化模式(着色器使用-O ReleaseSmall)以控制指令数量与寄存器压力(参见build.zig)。当自托管后端落后于最新 SPIR-V 扩展时,可使用-fllvm作为回退以解锁厂商特性。

GPU 目标的对象格式与 ABI

SPIR-V 是 Zig 中的一等对象格式,与传统可执行格式并列。下图展示对象格式与 ABI 的组织方式:

graph TB subgraph "Common ABIs" ABI["Abi enum"] ABI --> GNU["gnu<br/>GNU toolchain"] ABI --> MUSL["musl<br/>musl libc"] ABI --> MSVC["msvc<br/>Microsoft Visual C++"] ABI --> NONE["none<br/>freestanding"] ABI --> ANDROID["android, gnueabi, etc<br/>platform variants"] end subgraph "Object Formats" OFMT["ObjectFormat enum"] OFMT --> ELF["elf<br/>Linux, BSD"] OFMT --> MACHO["macho<br/>Darwin systems"] OFMT --> COFF["coff<br/>Windows PE"] OFMT --> WASM["wasm<br/>WebAssembly"] OFMT --> C["c<br/>C source output"] OFMT --> SPIRV["spirv<br/>Shaders"] end

GPU 内核通常采用abi = none,因为它们运行在没有 C 运行时的独立环境中——无 libc、无标准库初始化,仅进行计算。SPIR-V 对象格式生成的.spv二进制绕过传统链接:不同于 ELF 或 Mach-O 链接器的重定位与节合并,SPIR-V 模块是完整且自包含的着色程序,可直接被 Vulkan 的vkCreateShaderModule或 WebGPU 的着色器创建 API 使用。这也是 GPU 代码无需独立链接步骤的原因——编译器直接发射最终二进制。指定-target spirv32-vulkan-none时,none ABI 表示 Zig 跳过一切 C 运行时设置,而spirv对象格式确保输出为合法的 SPIR-V 字节码,而非带入口与程序头的可执行。

代码生成后端架构

Zig 支持多种代码生成后端,提供生成 SPIR-V 的灵活性:

graph TB subgraph "Code Generation" CG["Code Generation"] CG --> LLVM["LLVM Backend<br/>use_llvm flag"] CG --> NATIVE["Native Backends<br/>x86_64, aarch64, wasm, riscv64"] CG --> CBACK["C Backend<br/>ofmt == .c"] end

LLVM 后端-fllvm)通过 LLVM 的 SPIR-V 目标生成,支持厂商扩展与更新的 SPIR-V 版本;当自托管后端尚未实现某些特性或需调试编译器问题时可选用它——LLVM 成熟的 SPIR-V 支持可作为可靠参照。原生后端-fno-llvm,默认)使用 Zig 的自托管代码生成,编译更快、二进制更小,但在扩展支持上可能落后于 LLVM。对 SPIR-V,自托管后端直接发射字节码,无中间表示。C 后端不适用于 GPU 目标,但展示了 Zig 的多后端灵活性。尝试 GPU 代码时,先用-fno-llvm以便快速迭代;若遇到缺失的 SPIR-V 特性或需与参考实现对比,再切换到-fllvm。该选择影响编译速度与特性可用性,但不改变你编写的 API——内核代码保持一致。

启动规划与数据并行模式

选择启动规模需要在 GPU 占用与共享内存预算间权衡;CPU 回退应复用相同的算术,使不同设备间的正确性保持一致。Zig 的强类型使这些计算显式化,鼓励为宿主规划器与内核编写可复用助手。

选择工作组大小

该助手根据问题规模计算所需工作组数量、末组引入的填充量,并以同样的计算模型应用于 CPU 端分块。采用统一例程可消除宿主与设备调度之间的“差一”失同步。

Zig
// ! GPU 调度规划工具
//!
// ! 该模块演示了如何计算 GPU 计算着色器的工作组调度参数。
// ! 它展示了总工作项、工作组大小和由此产生的调度
// ! 配置之间的关系,包括处理未填满完整工作组的“尾部”元素。

const std = @import("std");

// / 表示用于并行执行的完整调度配置
// / 包含启动计算内核或并行任务所需的所有参数
const DispatchPlan = struct {
    // / 每个工作组的大小(每组的线程/调用数)
    workgroup_size: u32,
    // / 覆盖所有项目所需的工作组数量
    group_count: u32,
    // / 包括填充在内的总调用次数(始终是 workgroup_size 的倍数)
    padded_invocations: u32,
    // / 最后一个工作组中填充/未使用的调用次数
    tail: u32,
};

// / 为给定的问题大小和工作组配置计算最佳调度参数
///
// / 计算处理所有项目所需的工作组数量,考虑到
// / 最后一个工作组可能部分填充的事实。这对于 GPU 计算着色器至关重要,
// / 其中工作必须以工作组大小的倍数进行调度。
fn computeDispatch(total_items: u32, workgroup_size: u32) DispatchPlan {
    // 确保工作组大小有效(GPU 工作组不能为空)
    std.debug.assert(workgroup_size > 0);

    // 计算所需工作组的数量,向上取整以确保所有项目都被覆盖
    const groups = std.math.divCeil(u32, total_items, workgroup_size) catch unreachable;

    // 计算包括填充在内的总调用次数(GPU 总是启动完整的工作组)
    const padded = groups * workgroup_size;

    return .{
        .workgroup_size = workgroup_size,
        .group_count = groups,
        .padded_invocations = padded,
        // 尾部表示必须通过边界检查处理的浪费的调用
        .tail = padded - total_items,
    };
}

// / 使用相同的调度逻辑模拟 CPU 侧并行执行规划
///
// / 演示了工作组调度公式同样适用于 CPU 线程批处理,
// / 确保 GPU 和 CPU 回退实现之间的一致行为。
fn simulateCpuFallback(total_items: u32, lanes: u32) DispatchPlan {
    // 重用 GPU 公式,使主机侧分块与设备调度匹配。
    return computeDispatch(total_items, lanes);
}

pub fn main() !void {
    // 定义一个示例问题:处理 1000 个项目
    const problem_size: u32 = 1000;

    // 典型的 GPU 工作组大小(通常为 32、64 或 256,取决于硬件)
    const workgroup_size: u32 = 64;

    // 计算 GPU 调度配置
    const plan = computeDispatch(problem_size, workgroup_size);
    std.debug.print(
        "gpu dispatch: {d} groups × {d} lanes => {d} invocations (tail {d})\n",
        .{ plan.group_count, plan.workgroup_size, plan.padded_invocations, plan.tail },
    );

    // 模拟 CPU 回退,并行通道更少
    const fallback_threads: u32 = 16;
    const cpu = simulateCpuFallback(problem_size, fallback_threads);
    std.debug.print(
        "cpu chunks: {d} batches × {d} lanes => {d} logical tasks (tail {d})\n",
        .{ cpu.group_count, cpu.workgroup_size, cpu.padded_invocations, cpu.tail },
    );
}
运行
Shell
$ zig run 03_dispatch_planner.zig
输出
Shell
gpu dispatch: 16 groups × 64 lanes => 1024 invocations (tail 24)
cpu chunks: 63 batches × 16 lanes => 1008 logical tasks (tail 8)

将规划器输出同时反馈至内核启动描述符与 CPU 任务调度器,使各平台的插桩保持一致。

CPU 回退与统一代码路径

现代应用常为能力受限的设备提供 CPU 实现;通过共享调度规划器与extern有效载荷,可复用验证代码,在投入生产前用 CPU 复算核对 GPU 输出。结合 Zig 的构建选项(如-Dgpu=false),可在无加速器的环境打包时有条件地排除内核模块。

注意与警示

  • 始终在特性检查后启用 GPU 特定代码,以确保仅 CPU 的构建仍能通过 CI。
  • Vulkan 验证层可早期捕获众多错误;在用 Zig 编译 SPIR-V 的阶段应始终启用,直至你的内核集合稳定。
  • 内核优先使用 release-small 优化:它可最小化指令数量,缓解指令缓存与寄存器文件的压力。

练习

  • 扩展内核,将多维度(XYZ)写入坐标结构,并使用spirv-dis验证发射的 SPIR-V。
  • 添加 CPU 端验证器,将 SPIR-V 输出缓冲映射回 Zig,并与simulateCpuFallback的运行结果进行交叉校验。
  • 修改构建脚本,通过切换-target三元组并相应替换地址空间注解,同时发射 SPIR-V 与 PTX 变体。

注意事项、替代方案与边界情况

  • 部分 GPU 驱动要求专用调用约定(如 AMD 的.amdgcn.kernel),因此参数顺序与类型必须与厂商文档完全匹配。
  • 仅当你将函数标记为inline且提供大小字面量时,@workGroupSize才返回编译期常量;否则应视为运行期值,并为动态路径加护栏。
  • OpenCL 目标偏好.param地址空间;在跨编译时,应审核每个指针参数并调整addrspace注解以维持正确性。

Help make this chapter better.

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