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 处理短小分支。mix、select、step、saturate、min、max 适合把小型条件计算转成数据选择。它适合 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 flag | draw 参数、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 发散与带宽瓶颈叠加 |
| 分支体只包含少量 ALU | shader 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_color 和 mask_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 分路径执行。
- 空间连续性:遮罩或条件成片连续时,执行组内一致概率更高;高频随机条件更容易混杂。
- branchless:
mix、select、step适合短小 ALU 分支,包含昂贵采样的路径需要先评估带宽成本。 - uniform 分支:来自 material、draw 或 pipeline key 的统一条件适合上移到批次或 shader variant 层处理。
- 数据重排:把相似条件的 invocation 聚在一起,可以提高执行组内路径一致性。
- early-out:early-out 的收益取决于退出条件是否在执行组内成片一致,以及后续昂贵工作是否真正减少。
- 工具证据:RenderDoc、Nsight 和 Xcode GPU tools 的价值在于把分支、带宽、stall 和 pipeline 状态落到同一帧同一 pass 上观察。
- 优化判断:shader 分支优化应按条件来源、执行组一致性、分支体成本、API 状态和视觉输出的顺序判断。