GPU Gems 2 · Chapter 34. GPU Flow-Control Idioms
把 GPU 分支、循环和提前终止放回 SIMD 执行模型:比较 predication、预计算、z-cull、原生 branch 与 occlusion query。
学习目标
- 能解释 fragment SIMD 中分支分歧为什么会让两条路径都付出代价,并判断条件的空间一致性
- 能修改 Flow-Control Lab 中的分支形状、分支体量和 active ratio,并选择 predication、预计算或 z-cull
- 能回答:一个 GPU 迭代何时应使用 occlusion query 把“是否继续”交给 CPU
先看一排工位,而不是一条 CPU 路口
想象一排同时工作的工位:每个工位都拿到自己的订单,但这一排必须在同一时刻听同一个口令。若相邻订单都需要同一种动作,整排很顺;若一半订单要拐左、一半要拐右,工位只能轮流完成两种动作。
本章解决的问题是:GPU 里 if、循环和提前终止怎样写,才能让并行工作少走弯路?如果把 CPU 的“每个订单独立决定路线”原样搬过来,程序可能仍然正确,却会让一组 lane 等另一组 lane,或在真正无效的工作上启动 shader。
1. SIMD 分支:正确不等于同样高效
↡一组处理器用同一条指令同时处理多个数据元素;组内元素若走不同路径,就会发生分支分歧。在 CPU 上,if (a) f() else g() 通常意味着只执行被选中的一边;在 GPU fragment processor 上,一组相邻 fragment 共享执行节奏。若条件在空间上相近,整组可以一起走同一条路径;若条件随机交错,硬件必须让不同 lane 依次完成两条路径,未走当前路径的 lane 只能等待。
这就是“空间一致性”的工程含义:不是统计全局有多少元素为真,而是观察同一执行组里的邻居是否大致有相同结果。原章也区分了 vertex processor 的 MIMD 分支和 fragment processor 的 SIMD 分支;同一个算法放在不同阶段,分支代价不能直接类比。
2. predication:短分支可以两边都算
↡同时计算条件分支的两侧,再根据条件只让选中的结果写回;它避免真正跳转,但会支付两侧计算成本。旧式 GPU 可能用 condition code 模拟分支:先把 f() 和 g() 都算出来,再根据条件屏蔽其中一边的写回。这个方案对两到四个操作的短分支很实用,因为建立新 pass 或跳转的成本可能反而更高;如果两边都是大函数,计算两次就会浪费大量周期。
// 短分支:两边很小,predication 可能更简单
vec4 left = a * gain;
vec4 right = a + offset;
float takeLeft = step(threshold, signal);
vec4 result = mix(right, left, takeLeft);这里 mix 只是表达“按 mask 选择结果”的 WebGL2 镜像,不应被误解成一定比原生 if 快。真正的选择取决于目标设备、分支两侧的指令量以及条件是否一致;应以 profile 验证,而不是靠语法猜性能。
3. 把决定上移:静态分支与预计算
↡把稳定不变或空间上成块的分支条件提前计算,改成多个无分支 pass,或存成后续 kernel 反复读取的 mask。如果网格的边界位置在整个循环中不变,与其让每个 cell 每次都判断“我是内部还是边界”,不如把 interior 和 boundary 拆成不同的绘制区域。主循环只执行对应的专用程序,分支决定被移到了 rasterization 或 CPU 的调度阶段。
另一种情况是条件只在用户重新绘制障碍物时变化。此时可以在变化发生时预计算邻居方向,把结果存进 offset texture;之后的流体迭代只读取 offset,不在每个 cell 内重复判断。预计算不是免费,它把成本从每轮循环移到状态变化时刻;只有条件确实稳定,才能获得回报。
第 1 / 5 步 · 同一组 fragment 走同一条路径
分支不是禁用项;真正要问的是一组并行元素是否保持空间一致,以及能否把决定提前。
4. z-cull:让 shader 根本不启动
↡利用深度测试在 fragment processor 运行前拒绝不需要的 fragment;它跳过的是整个 shader 执行,而不是 shader 内的一条路径。z-cull 需要两个阶段。预处理 pass 先把满足“无需继续”的 cell 写成深度 0,把其他 cell 写成深度 1;计算 pass 以 z = 0.5 绘制同一片区域,深度测试会在 fragment shader 启动前挡住深度 0 的 cell。这样被拒绝的 cell 不需要执行完整计算。
它的限制也很明确:硬件可能以一小块连续区域为单位做早期剔除。若待跳过的 cell 随机散落,整块区域未必全部失败,实际节省会接近没有。因此 z-cull 适合有空间局部性的 mask;它不是一个能把随机条件自动变快的魔法开关。
5. 原生 branch:硬件支持也仍怕分歧
现代 GPU 可以把 if、for 和 while 编译成原生指令,但“有原生指令”不等于“每个 lane 自由分叉”。一组 fragment 通常仍只能同时推进一个路径;条件不一致时,硬件可能退回接近 predication 的执行方式。
经验上,短分支优先考虑 predication;分支嵌在大程序中且局部性尚可时,可以使用原生 branch;能把分支隔离成独立 pass、同时又有成块条件时,z-cull 可能更划算。z-cull 需要额外保存/恢复状态和多一个 pass,所以不能只拿“有没有 shader branch”做二元选择。
先猜一猜:把 Flow-Control Lab 的模式从 random 切到 coherent,再把 branch work 调大,哪一个派生指标会先放大?每次只改一个控件,观察 SIMD groups、branch instruction units 和 units skipped 的变化。
Flow-Control Lab · compare the execution shape
What this layout exposes
条件随机交错:即使只有一半元素 active,两条路径也可能都被执行。
Derived metrics
recommended move
先整理条件:随机分支会让两条路径都付费
这些值是由 lane、分支体量和 active ratio 推导出的执行形状,不是合成性能分数;最终仍需在目标 GPU 上 profile。
6. data-dependent looping:用 occlusion query 做全局停止
↡查询一次绘制实际更新了多少 fragment;在 GPGPU 循环中可用它统计尚未满足终止条件的元素数量。GPU 适合处理每个元素的迭代,却不适合让一个 fragment 直接决定整个 stream 何时结束。可以把终止判断单独做成 termination pass:已经满足条件的元素 discard,未满足的元素留下;occlusion query 返回仍可见的 fragment 数。CPU 只读回一个整数:数量大于零就继续下一轮,等于零就停止。
这种方法把“每个元素是否完成”留在 GPU,把“整个任务是否结束”交给 CPU,避免每轮读回整张纹理。不过 query 仍是一个同步边界,循环次数太多或活跃元素太少时,查询与 pass 的固定成本会成为新的瓶颈。应记录每轮 active count、query 延迟和总 pass 数。
remaining = streamSize
while (remaining > 0) {
run(computeProgram)
beginQuery()
run(terminationProgram) // discard elements that already converged
endQuery()
remaining = getQueryResult()
}第一段代码表达的是控制结构,第二段 termination pass 表达的是数据筛选;二者之间的边界很重要:CPU 不应逐元素参与循环,GPU 也不应被迫猜全局停止条件。
三步验收:从分歧定位到停止条件
第一步:画出 SIMD 组的路径一致性
把同一执行组里的条件标出来,区分“成块一致”和“随机交错”;先判断分支是否会让两条路径都执行,再决定是否需要改布局。
第 1 / 5 步 · 同一组 fragment 走同一条路径
分支不是禁用项;真正要问的是一组并行元素是否保持空间一致,以及能否把决定提前。
本章小结
- SIMD 分支的关键成本来自同组元素路径不一致。
- predication 适合短分支,但大分支会支付两侧计算。
- 预计算、static branch resolution 和 z-cull 都是在 shader 前移动决定。
- 原生 branch 仍受空间局部性约束,不能照搬 CPU 直觉。
- occlusion query 用一个小计数把 GPU 迭代与 CPU 全局停止连接起来。
练习
问题 1|识别分歧。 一个 8×8 fragment block 中,左半块都满足条件,右半块都不满足;另一个 block 的条件随机交错。哪一个更适合 branch 或 z-cull?为什么不能只看两个 block 的 true 数量相同?
问题 2|改 Demo 代码。 在 Flow-Control Lab 中加入一个 query 模式:当 active ratio 低于 20% 时显示“termination pass + occlusion query”,否则保留当前 branch 建议。请再添加一个派生指标,说明 query 模式的固定边界成本。
问题 3|选择机制。 一个大程序中的分支只有 3 个操作,但条件随机;另一个分支很大、条件每 100 个迭代才变化一次且区域成块。分别优先尝试什么?写出各自需要验证的证据。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- SIMD branching
- predication
- static branch resolution
- z-cull
- occlusion query