Skip to main content

Chapter 2: Parallel Architectures SIMD and SIMT

上一章把 GPU 放在吞吐型处理器的位置上:它用大量执行单元、寄存器和内存系统同时推进许多相似任务。本章把观察点缩小到一次 shader 执行。读完本章后,读者应能区分 SIMD 与 SIMT 的表达层级,追踪一个 warp 或 wavefront 在等待内存、切换执行、分支发散和写回结果之间的路径,并能判断某段 shader 分支正在影响 ALU 吞吐、内存带宽,还是两者共同构成瓶颈。

贯穿本章的材料是一段全屏后处理 shader。它接收一张场景颜色纹理、一张遮罩纹理和一组调色参数,对每个屏幕像素执行亮度、饱和度和局部高光处理。源代码看起来像“每个像素各算各的”标量程序,GPU 的真实执行方式会把相邻像素对应的许多 shader invocation 分组,放到多个 lane 上批量运行。这个分组在 NVIDIA 语境中通常叫 warp,在 AMD 语境中通常叫 wavefront,在 Metal、Vulkan、Direct3D 的跨平台写法中更常用 subgroup、SIMD group 或 wave 这类抽象名词。

本章的核心结论是:SIMD 描述硬件一次指令驱动多个 lane 的执行形态,SIMT 描述程序员写多个标量线程、硬件把这些线程映射到 SIMD-like lane 的编程模型。分支发散的性能代价来自同一个执行组内部 lane 的路径分裂;优化 shader 分支时,先判断分支条件在执行组内是否一致,再判断被浪费的是 ALU lane、纹理采样带宽、寄存器占用,还是额外同步与写回。

2.1 SIMD 与 SIMT 执行模型原理

SIMD(Single Instruction, Multiple Data)描述一条指令同时作用于多个数据槽。这里的数据槽就是 lane。一个 8-lane SIMD 加法可以在同一条加法指令下处理 8 个浮点值;一个更宽的 GPU 执行单元可以把相似的标量操作摊到更多 lane 上执行。SIMD 的关键观察对象是“指令”和“lane”,也就是同一时刻发射了哪条操作、哪些 lane 正在参与这条操作、哪些 lane 被 mask 关闭。

SIMT(Single Instruction, Multiple Threads)描述程序员面对的抽象。shader 作者写的是标量代码:读取当前像素坐标、采样纹理、计算颜色、写出结果。每个 shader invocation 看起来都有独立的控制流和局部变量。硬件执行时会把多个 invocation 收成一组,在同一条指令流下批量推进。NVIDIA 的 CUDA Programming Guide v13.3 将这种模型称为 SIMT,并把 warp 作为重要执行单位;Apple Metal 的 threadExecutionWidth 文档把 compute pipeline 的推荐线程执行宽度暴露给应用,用于帮助选择线程组尺寸。正文后面使用“执行组”统一指代 warp、wavefront、SIMD group、subgroup 这一层对象。

把这个模型放回全屏后处理 shader,一帧 1920×1080 图像会产生约 207 万个像素任务。CPU 视角下,应用提交一次 draw 或 dispatch;API 视角下,pipeline state、texture、sampler、uniform buffer 被绑定;shader 视角下,每个像素 invocation 执行同一段程序;GPU 硬件视角下,调度器把这些 invocation 分成许多执行组,再把执行组发到可用 SIMD lane 上。读者调试性能时要在这些视角之间切换:源代码中的一行 color.rgb *= exposure; 是标量表达,硬件执行时是一组 lane 同时做乘法。

下面的简化 WGSL 片段只展示关键路径。它属于观察用局部代码,用来展示“同一段标量 shader 如何变成一组 lane 上的批量执行”。

@group(0) @binding(0) var scene_color: texture_2d<f32>;
@group(0) @binding(1) var scene_mask: texture_2d<f32>;
@group(0) @binding(2) var scene_sampler: sampler;

struct Params {
exposure: f32,
saturation: f32,
highlight_gain: f32,
};

@group(0) @binding(3) var<uniform> params: Params;

@fragment
fn post_process(@builtin(position) pixel_pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pixel_pos.xy / vec2<f32>(1920.0, 1080.0);
var color = textureSample(scene_color, scene_sampler, uv);
let mask_value = textureSample(scene_mask, scene_sampler, uv).r;

color.rgb = color.rgb * params.exposure;

if (mask_value > 0.5) {
color.rgb = color.rgb * params.highlight_gain;
}

return color;
}

这段代码中的 if 是后续所有讨论的观察点。假设一个执行组覆盖屏幕上的一小片连续像素,一部分像素的 mask_value 大于 0.5,另一部分像素的 mask_value 小于或等于 0.5。源代码层面,每个 invocation 都只走自己的路径;执行组层面,调度器要用 mask 记录哪些 lane 进入高光路径,哪些 lane 跳过高光路径。当所有 lane 的判断结果一致时,这个分支接近 uniform branch;当同一组内判断结果混杂时,它进入 divergent branch。

SIMD 与 SIMT 的边界可以用四个对象固定下来。第一,lane 是执行槽,决定同一条指令最多同时处理多少份数据。第二,invocation 或 thread 是程序员看到的单个工作项,决定局部变量、内建坐标和输出语义。第三,执行组是调度单位,决定一批 invocation 如何共享取指、发射、mask 和等待状态。第四,pipeline 或 dispatch 决定这些工作项来自哪个 draw、compute pass、resource binding 和 shader variant。

这四个对象在性能分析中承担不同责任。lane 利用率回答“有多少执行槽在做有效工作”;invocation 数量回答“这一帧产生了多少像素或线程任务”;执行组状态回答“同组任务是否因内存、分支或同步停住”;pipeline 状态回答“当前 shader、纹理、采样器、blend、depth/stencil 是否把工作量引到了正确资源路径”。把它们混成一个“GPU 并行很多线程”的口号,读者就会在真正的 frame capture 中失去定位依据。

2.2 线程束(warp / wavefront)调度行为解析

warp 或 wavefront 是 GPU 调度器直接推进的一批 shader invocation。它通常共享同一条程序计数位置,使用同一条指令流,通过 active mask 表示当前哪些 lane 参与执行。不同厂商的宽度和命名有差异:NVIDIA 传统 warp 宽度常见为 32;AMD GCN 常见 wavefront 宽度为 64,RDNA 之后的图形与 compute 路径可以涉及 wave32 与 wave64;Apple Metal 会通过 pipeline state 暴露 threadExecutionWidth 这类执行宽度信息。跨 API 编写 shader 时,应把具体宽度视为平台属性,用 feature、limit、pipeline reflection 或工具观察结果确认。

调度器推进一个执行组时,核心动作可以按“取指 → 读操作数 → 发射 → 等待 → 切换 → 写回”理解。取指阶段确定下一条 shader 指令;读操作数阶段从寄存器、常量缓冲、纹理坐标或内存路径准备输入;发射阶段把指令送到对应执行单元;等待阶段处理纹理采样、buffer load、特殊函数或同步依赖;切换阶段把执行资源让给另一个 ready 的执行组;写回阶段把计算结果写回寄存器、render target 或 storage buffer。GPU 隐藏延迟的方式,是在一个执行组等待时让另一个已经就绪的执行组使用执行单元。

下图只描述本章关注的执行组生命周期,不覆盖完整图形 API 命令队列,也不描述后续章节会展开的缓存与 command buffer 细节。

这张图里的关键点是 WaitMemory 与 ExecuteMasked。WaitMemory 说明吞吐来自多执行组交错运行:某个执行组等待纹理数据时,同一个 compute unit、streaming multiprocessor 或 GPU core 可以推进另一个 ready 执行组。ExecuteMasked 说明分支发散不会把同组 lane 变成真正独立的小处理器;硬件会按照 active mask 分阶段执行路径,关闭当前路径外的 lane,再在可重新汇合的位置恢复统一推进。

仍然回到全屏后处理 shader。执行组 A 覆盖一片天空像素,遮罩纹理在这片区域几乎全为 0;执行组 B 覆盖高光边缘,一半像素落在高光区域,一半像素落在普通区域;执行组 C 覆盖纯高光区域,遮罩几乎全为 1。A 和 C 的 if 条件在组内一致,调度器只需要执行一条有效路径。B 的条件在组内混杂,调度器需要先带着一组 mask 执行高光乘法,再带着另一组 mask 推进跳过路径或汇合点。B 的总时间可能接近两条路径的组合,而有效 lane 占比下降。

调度行为还受寄存器占用影响。每个活跃执行组需要保存自己的程序计数、active mask 和寄存器状态。shader 使用的临时变量越多,单个执行组占用的寄存器资源越多,同一硬件单元上可驻留的执行组数量越少。可驻留执行组数量下降后,GPU 用另一个执行组覆盖内存等待的空间变小,纹理采样延迟更容易暴露到帧时间。这里的性能链条是:shader 临时值增加 → 每个执行组寄存器需求增加 → 可驻留执行组减少 → 等待纹理或 buffer 时可切换对象减少 → GPU 执行单元空转比例上升。

工具观察时,可以把这条链拆成三个证据层。RenderDoc 能帮助确认 draw call、shader、纹理绑定和分支相关资源是否符合预期;Nsight Graphics / Nsight Compute 更适合在 NVIDIA 环境中观察 warp、occupancy、stall reason、branch efficiency 等指标;Xcode GPU tools 更适合 Metal 工程中观察 threadgroup、shader 时间、memory bandwidth 和相关 GPU counter。工具名称本身只提供观察入口,结论要落在“当前执行组因什么等待、哪些 lane 有效、哪条资源路径消耗时间”上。

调度器的行为也解释了为什么“多线程”这个词在 GPU 上容易误导。GPU invocation 的局部变量通常已经分配在寄存器文件中;执行组切换依赖硬件保存的状态,成本远低于操作系统线程切换。读者在 shader 优化中关注的对象转为执行组宽度、寄存器占用、内存等待、分支 mask、同步点和资源访问模式。

2.3 分支发散对性能的影响与处理策略

分支发散指同一个执行组内,不同 lane 对同一个控制流条件得到不同结果。硬件用 active mask 记录当前路径上哪些 lane 参与执行。路径 A 执行时,路径 B 的 lane 暂停;路径 B 执行时,路径 A 的 lane 暂停;到达可汇合位置后,执行组恢复统一推进。性能下降的直接原因是有效 lane 比例下降和路径执行次数增加,间接原因可能包括寄存器生命周期延长、纹理采样次数增加、缓存局部性变差和同步点等待增多。

在全屏后处理 shader 中,mask_value > 0.5 的分支会随遮罩纹理的空间分布变化。若遮罩边界是一条平滑大区域,许多执行组会完全落在边界一侧,分支接近 uniform。若遮罩是噪声、棋盘格、头发丝边缘或粒子碎片,同一个执行组内会出现密集混杂,分支发散升高。这个判断只依赖三个对象:执行组覆盖的像素集合、遮罩值在这个集合中的分布、分支两侧的工作量。

可以把分支发散的处理链条写成一个可复用检查顺序。先看分支条件来源:它来自 uniform 常量、draw call 级开关、material flag、像素纹理采样、顶点属性,还是计算结果。uniform 常量在同一 draw 内通常让所有 lane 得到一致结果;纹理采样和像素坐标常常让相邻 lane 出现不同结果。再看空间相关性:相邻像素的条件结果是否成片一致。成片一致时,许多执行组仍能保持高 lane 利用率;高频变化时,执行组内混杂概率上升。接着看分支体成本:分支体只有一两条 ALU 指令,branchless 写法可能更稳定;分支体包含多次纹理采样、循环或复杂函数时,保留分支可能节省带宽和采样时间。最后看写回与混合:如果分支改变 render target 写入、discard、alpha test 或 storage buffer 写入,优化目标还要覆盖 early/late depth、blend、atomic 和内存一致性。

以下伪代码展示同一个效果的两种写法。第一种显式分支只在遮罩命中时做高光乘法;第二种用 mix 把条件转换成系数选择。这里的目标是展示判断维度:分支体很短、条件高度发散时,branchless 版本可能提高 lane 利用率;分支体包含昂贵采样时,显式分支可能减少冗余采样。

fn highlight_branch(color: vec3<f32>, mask_value: f32, gain: f32) -> vec3<f32> {
var result = color;
if (mask_value > 0.5) {
result = color * gain;
}
return result;
}

fn highlight_branchless(color: vec3<f32>, mask_value: f32, gain: f32) -> vec3<f32> {
let weight = select(0.0, 1.0, mask_value > 0.5);
let scale = mix(1.0, gain, weight);
return color * scale;
}

这个例子中的 branchless 写法把控制流转成数据流。所有 lane 都执行同一组乘法和选择,active mask 更稳定。代价是每个 lane 都会执行这些 ALU 操作。若高光路径只是一次乘法,这个代价很小。若高光路径包含 6 次额外纹理采样,强行把控制流转成数据流会让所有 lane 都进行采样,带宽与纹理单元压力可能超过分支发散的损失。

分支发散还要和 early-out 区分。early-out 的目标是让一批工作尽早结束,常见于 compute shader 中的越界检查、tile culling、light list 过滤和透明像素处理。它的收益取决于退出条件是否在执行组内成片一致,以及退出后是否真的减少后续昂贵工作。若每个执行组里只有少数 lane 退出,剩余 lane 仍要继续跑完整路径,退出 lane 只是关闭 mask。若整个执行组都退出,后续指令可以整体跳过,收益会更稳定。

处理分支发散时,不应先把所有 if 当成问题。更可靠的做法是把分支分为四类。第一类是 uniform 分支,例如同一个 material 开关控制是否启用调色;它通常影响 shader variant 或 pipeline 分支,lane 发散低。第二类是空间连续分支,例如屏幕一大片区域进入同一路径;它在边界附近有成本,在区域内部成本低。第三类是高频像素分支,例如噪声遮罩、棋盘 alpha、随机粒子属性;它容易造成执行组内混杂。第四类是同步相关分支,例如 compute shader 中某些 lane 进入 barrier 而另一些 lane 退出;它会影响正确性和死锁风险,需要按照 API 与着色语言规范处理。

分支发散的视觉症状通常不明显。画面可能完全正确,帧时间却在某个 pass 上升。它与内存问题、overdraw、render target 带宽经常叠加。比如高光遮罩在边缘发散,同时高光路径又采样一张高分辨率 bloom mask,工具里可能同时看到 branch efficiency 下降和 texture bandwidth 上升。此时优化顺序应先固定可观察对象:同一帧、同一 pass、同一 shader variant、同一输入纹理;再分别降低分支混杂、采样次数、临时变量和写回成本。没有固定输入边界时,帧时间变化很难归因。

2.4 在 Shader 中减少分支开销

减少分支开销的目标是让执行组中的 lane 尽量走一致路径,或者让路径分裂时浪费的资源可控。这个目标要放在 shader 数据路径中执行:输入属性如何排列,纹理与 buffer 如何存储,分支条件在空间上是否连续,分支两侧分别消耗 ALU、纹理采样、寄存器还是写回带宽。只改写 if 语法,通常得不到可靠结论。

第一种稳定做法是把统一条件上移到 draw、dispatch、material 或 shader variant 层。若某个材质整批启用高光,就把它作为 uniform、specialization constant、pipeline variant 或 material pass 决策,让同一 draw 内的执行组得到一致条件。这样做的代价是 shader variant、pipeline state、材质排序和资源绑定会增加管理复杂度。它适合开关数量少、材质批次清晰、分支体成本较高的情况。

第二种做法是重排数据,让相似条件的 invocation 聚在一起。粒子系统、tile-based lighting、clustered shading 和实例化渲染都可以从数据重排中获益。比如粒子透明度分支高度发散时,可以按材质、生命阶段或遮罩类型分桶,再分批渲染。这样做会增加 CPU 或 compute preprocessing 成本,也可能改变排序、blend 或缓存局部性。判断时要比较“数据重排成本”和“shader 执行组混杂成本”。

第三种做法是使用 branchless math 处理短小分支。mixselectstepsaturateminmax 适合把小型条件计算转成数据选择。它适合 ALU 成本小、分支条件高频变化、两侧路径没有昂贵纹理采样和副作用的情况。对于高光乘法、阈值映射、颜色通道选择、简单权重混合,这类写法常常让执行路径更平滑。对于循环、采样、atomic、discard、复杂 BRDF 分支,它可能把本来可以跳过的工作扩散到所有 lane。

第四种做法是用空间连续性设计条件。图形渲染中的许多分支来自屏幕空间遮罩、深度、法线、材质 id、tile light count 和 SDF 距离。条件越接近大块连续区域,执行组内一致概率越高。高频噪声、蓝噪声抖动、alpha test 细碎边缘会提高混杂概率。某些视觉技术会主动引入高频随机性,此时需要把成本计入预算:它可能降低 banding 或 temporal artifact,同时增加执行组分支和纹理访问的不稳定性。

第五种做法是审慎使用 early-out。compute shader 中常见的越界 early-out 很有意义,因为线程组尺寸通常向上取整,边界外 invocation 继续执行会产生无效访问。屏幕空间 pass 中的 early-out 则要看退出是否成片。大面积天空、背景、无遮挡区域能稳定跳过复杂路径;像素级随机条件只能关闭少量 lane。early-out 后若仍然需要 barrier、group memory 写入或统一汇合,收益还要重新评估。

下面给出一个判断表,用于把 shader 分支决策落到具体证据。它覆盖本章的后处理 shader,也能迁移到后续的 fragment shader、compute shader、tile culling 和 material variant 设计。

观察点证据来源倾向做法主要风险
条件来自 uniform 或 material flagdraw 参数、constant buffer、pipeline key上移到 variant 或批次级决策variant 数量膨胀,pipeline cache 压力增加
条件来自连续遮罩区域mask texture、debug view、frame capture保留分支,关注边界区域成本遮罩变成高频后成本上升
条件来自高频纹理或随机属性mask debug view、粒子属性分布尝试 branchless 或数据分桶所有 lane 执行额外 ALU 或重排成本增加
分支体包含多次纹理采样shader source、texture unit counter保留可跳过路径,减少采样次数lane 发散与带宽瓶颈叠加
分支体只包含少量 ALUshader source、ALU counter使用 mix / select / step可读性下降,编译器生成码需复查
分支影响 discard 或写回pipeline state、depth/blend 状态单独评估 early/late depth 与 blend 成本画面正确但帧时间归因困难

这个表的使用顺序比表中单个建议更重要。先确认分支条件,接着确认执行组内一致性,再确认分支体资源成本,最后确认 API 状态和视觉输出。若工具显示 shader 时间升高,但遮罩 debug view 显示条件成片一致,问题可能在纹理带宽或 render target 写回。若工具显示 branch efficiency 下降,并且遮罩是高频噪声,问题更可能来自执行组内 mask 切换。若重写成 branchless 后 shader 时间下降但纹理带宽上升,说明原分支浪费 lane,新写法转移了一部分成本到 ALU 和采样路径。

本章最后给出一个收束判断:shader 分支优化应定位为执行组资源分配题,语法替换只作为局部手段。读者应把一段标量 shader 放到执行组、lane mask、寄存器、纹理采样、写回和工具 counter 之间观察。能说明“这个分支在同组 lane 中是否一致、分支体消耗哪类资源、重写后成本转移到哪里”,才算真正理解 SIMD / SIMT 对图形性能的影响。

最小自检任务

给定一个 1280×720 的全屏后处理 pass。shader 对每个像素采样 scene_colormask_texture。当 mask_texture.r > 0.5 时,shader 额外采样 4 次 blur texture 并混合高光;否则直接输出曝光后的颜色。现在有两种遮罩输入:A 是一整块矩形高光区域,边界清晰;B 是屏幕上均匀分布的随机噪声点。要求判断 A 和 B 哪一种更容易产生执行组内分支发散,并说明是否应把分支改成 branchless 写法。

答案要点

A 的遮罩空间连续,大量执行组会完全落在矩形内部或外部,只有边界附近容易出现 lane 条件混杂。B 的遮罩高频随机,同一个执行组内更容易同时包含命中和未命中的 lane,因此更容易产生分支发散。

是否改成 branchless,要看分支体成本。题目中的命中路径包含 4 次额外 blur texture 采样,这属于纹理采样和带宽成本较高的路径。把它改成 branchless 后,所有 lane 都可能执行额外采样,纹理带宽压力会显著增加。对 A,保留分支通常更合理,因为大区域内部可以整体跳过或整体执行高光路径。对 B,需要先用 debug view 和工具 counter 确认瓶颈:若主要问题是 lane 利用率下降,可考虑数据重排、降低遮罩高频度、分桶或简化高光路径;若主要问题是 texture bandwidth,强行 branchless 会把问题放大。

完整排查顺序是:先固定同一帧和同一 pass;再查看遮罩 debug view 的空间连续性;接着查看 shader 分支体是否包含昂贵采样;然后观察工具中的 shader 时间、纹理带宽、branch 或 wave 指标;最后选择保留分支、数据重排、简化采样或短小 branchless 计算。这个顺序把视觉输入、执行组行为、资源成本和工具证据连接起来。

本章知识点总结

  • SIMD:SIMD 描述一条指令同时驱动多个 lane 处理多份数据。
  • SIMT:SIMT 描述程序员编写多个标量线程,硬件把它们映射到批量 lane 上推进。
  • 执行组:warp、wavefront、SIMD group 和 subgroup 都位于 shader invocation 与硬件 lane 之间。
  • 标量代码:shader 源码通常按单个 invocation 表达,硬件执行时会把多个 invocation 成组运行。
  • lane mask:active mask 决定当前指令下哪些 lane 参与执行,分支路径会改变这个 mask。
  • 调度路径:执行组在取指、发射、等待内存、切换执行和写回之间循环推进。
  • 延迟隐藏:GPU 通过多个执行组交错运行覆盖纹理采样和 buffer 访问等待。
  • 寄存器占用:shader 临时值越多,每个执行组占用的寄存器越多,可驻留执行组数量可能下降。
  • 分支发散:同一执行组内 lane 对同一条件得到不同结果时,硬件需要按 mask 分路径执行。
  • 空间连续性:遮罩或条件成片连续时,执行组内一致概率更高;高频随机条件更容易混杂。
  • branchlessmixselectstep 适合短小 ALU 分支,包含昂贵采样的路径需要先评估带宽成本。
  • uniform 分支:来自 material、draw 或 pipeline key 的统一条件适合上移到批次或 shader variant 层处理。
  • 数据重排:把相似条件的 invocation 聚在一起,可以提高执行组内路径一致性。
  • early-out:early-out 的收益取决于退出条件是否在执行组内成片一致,以及后续昂贵工作是否真正减少。
  • 工具证据:RenderDoc、Nsight 和 Xcode GPU tools 的价值在于把分支、带宽、stall 和 pipeline 状态落到同一帧同一 pass 上观察。
  • 优化判断:shader 分支优化应按条件来源、执行组一致性、分支体成本、API 状态和视觉输出的顺序判断。