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 只能等待。

branch cost 取决于邻居是否同路high spatial locality · 可成块跳过random condition · lane 互相等待同一组 lane 大多同路branch instruction 或 z-cull 都有机会跳过整块工作条件稳定且有空间局部性同一组 lane 交替分裂两条路径都可能执行,成本接近两边相加先考虑整理数据或预计算 mask不是“有 if 就慢”,而是“相邻元素走不同路时难以保持并行”

这就是“空间一致性”的工程含义:不是统计全局有多少元素为真,而是观察同一执行组里的邻居是否大致有相同结果。原章也区分了 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 验证,而不是靠语法猜性能。

先看分支体量,再看条件能否提前选择机制不是 API 偏好,而是“要算多少”和“能跳过多少”的取舍2–4 个操作的短分支predication:两边计算的额外成本较小分支嵌在较大的 program 中有原生 branch 且局部性尚可否则考虑先整理 stream,再进入 branch分支可独立成 pass,且条件有块状局部性z-cull:让 fragment program根本不启动随机分支 + 大分支体 + 低局部性:任何“跳过”机制都可能退化

3. 把决定上移:静态分支与预计算

如果网格的边界位置在整个循环中不变,与其让每个 cell 每次都判断“我是内部还是边界”,不如把 interior 和 boundary 拆成不同的绘制区域。主循环只执行对应的专用程序,分支决定被移到了 rasterization 或 CPU 的调度阶段。

另一种情况是条件只在用户重新绘制障碍物时变化。此时可以在变化发生时预计算邻居方向,把结果存进 offset texture;之后的流体迭代只读取 offset,不在每个 cell 内重复判断。预计算不是免费,它把成本从每轮循环移到状态变化时刻;只有条件确实稳定,才能获得回报。

一条分支的五种命运:先看 lane 是否同路动画按原章顺序把“执行”“跳过”和“继续”拆成可观察的状态① 同一路径coherent② 路径分裂两条 branch 不能并发③ predication两边都算,只有一边写④ 预计算 mask先算稳定条件,再复用⑤ query 计数还有未终止元素?继续

第 1 / 5 步 · 同一组 fragment 走同一条路径

分支不是禁用项;真正要问的是一组并行元素是否保持空间一致,以及能否把决定提前。

4. z-cull:让 shader 根本不启动

z-cull 需要两个阶段。预处理 pass 先把满足“无需继续”的 cell 写成深度 0,把其他 cell 写成深度 1;计算 pass 以 z = 0.5 绘制同一片区域,深度测试会在 fragment shader 启动前挡住深度 0 的 cell。这样被拒绝的 cell 不需要执行完整计算。

z-cull:在 shader 启动前就把无效工作挡掉pass 1 · preprocess检查邻居,生成 z masklandlocked = z 0.0z-testpass 2 · computequad at z = 0.5灰色格子根本不进 fragment shader注意 locality随机散点可能只按粗粒度 block 判断块不全被拒绝,节省就会变少z-cull 是“提前不执行”,不同于 shader 内部 branch 后再 discard

它的限制也很明确:硬件可能以一小块连续区域为单位做早期剔除。若待跳过的 cell 随机散落,整块区域未必全部失败,实际节省会接近没有。因此 z-cull 适合有空间局部性的 mask;它不是一个能把随机条件自动变快的魔法开关。

5. 原生 branch:硬件支持也仍怕分歧

现代 GPU 可以把 ifforwhile 编译成原生指令,但“有原生指令”不等于“每个 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

active lanes512 / 1024
SIMD groups touched32
branch instruction units192
units skipped before shader0

recommended move

先整理条件:随机分支会让两条路径都付费

这些值是由 lane、分支体量和 active ratio 推导出的执行形状,不是合成性能分数;最终仍需在目标 GPU 上 profile。

6. data-dependent looping:用 occlusion query 做全局停止

GPU 适合处理每个元素的迭代,却不适合让一个 fragment 直接决定整个 stream 何时结束。可以把终止判断单独做成 termination pass:已经满足条件的元素 discard,未满足的元素留下;occlusion query 返回仍可见的 fragment 数。CPU 只读回一个整数:数量大于零就继续下一轮,等于零就停止。

data-dependent loop:GPU 算,CPU 只问“还有多少没结束”compute pass更新所有尚未完成元素仍保持 stream 形式termination pass已满足条件的元素 discard未满足者留下可见 fragmentquerycount = 37只读回整数count > 0:继续下一轮;count = 0:CPU 结束循环避免每轮读回整张纹理,但 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 也不应被迫猜全局停止条件。

三步验收:从分歧定位到停止条件

分步1 / 3

第一步:画出 SIMD 组的路径一致性

把同一执行组里的条件标出来,区分“成块一致”和“随机交错”;先判断分支是否会让两条路径都执行,再决定是否需要改布局。

branch cost 取决于邻居是否同路high spatial locality · 可成块跳过random condition · lane 互相等待同一组 lane 大多同路branch instruction 或 z-cull 都有机会跳过整块工作条件稳定且有空间局部性同一组 lane 交替分裂两条路径都可能执行,成本接近两边相加先考虑整理数据或预计算 mask不是“有 if 就慢”,而是“相邻元素走不同路时难以保持并行”
一条分支的五种命运:先看 lane 是否同路动画按原章顺序把“执行”“跳过”和“继续”拆成可观察的状态① 同一路径coherent② 路径分裂两条 branch 不能并发③ predication两边都算,只有一边写④ 预计算 mask先算稳定条件,再复用⑤ query 计数还有未终止元素?继续

第 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

资料与写作方式声明

本章以GPU Gems 2 · Chapter 34. GPU Flow-Control Idioms权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…