GPU Gems 2 · Chapter 36. Stream Reduction Operations for GPGPU Applications

把 GPU 无法直接表达的变长输出改写成 filtering、scan、search 和 gather,并将它应用到碰撞检测与自适应细分。

学习目标

  • 能解释 GPU 固定输出位置为什么不能直接表达按内容变长的 stream,并用 null record 表示待过滤元素
  • 能修改 Reduction Lab 中的输入规模、过滤比例和应用模式,预测 scan passes、search steps 与输出长度如何变化
  • 能回答:什么时候用 scan + search/gather 进行 compaction,什么时候应避免为了删除少量元素而增加多 pass

先解决“货物变少了,传送带怎么办”

想象传送带上每个工位都必须在自己的格子里放一件货物,但检查后有些货物要被丢弃。工位不能把剩下的货物直接推到左边空出来的格子;它只能在原位留下一个可识别的空标记,再由后续工序重新安排。

本章解决的问题是:当每个输入元素可能产生零个、一个或多个后续元素时,怎样仍然使用固定输出位置的 GPU stream?如果硬把 CPU 的“删掉并向前移动”照搬过来,就会需要 fragment processor 不支持的任意 scatter,或者把整条最大长度的空传送带一直带到后面。

1. data filtering:先保留原长度,再制造可识别的空洞

fragment processor 可以为每个固定输出位置生成一个结果,却不能根据当前输入决定“这个位置不写,后面的元素往前挪”。过滤的第一步因此不是缩短 stream,而是为每个输入位置写出两种结果:要保留的值,或一个正常算法不会产生的特殊值。

固定输出位置,也能生成变长结果input stream+4−2+7+1+3predicate:保留正值,其余写成 null record;scan 计算每个元素应移动几格search / gather 替代 scattercompacted output+4+7+1+3length = 4

原章使用浮点 infinity 作为 null 的例子:predicate 通过的元素输出原值,否则输出 infinity。这个哨兵必须和业务数据的合法范围分离,并且在所有后续 pass 中使用同一判定规则;否则 scan 可能把真实数据误当成空洞。

// 固定输出位置的 filtering pass
float value = inputValue;
bool keep = value > threshold;
float nullRecord = 1.0 / 0.0;
outputValue = keep ? value : nullRecord;

这段代码只完成“标记”,还没有产生紧凑输出。把空洞移动到末尾需要知道每个保留元素左侧有多少个 null,这正是下一节 scan 的任务。

2. scan:用多 pass 计算每个元素的移动距离

要把一个保留元素移动到正确位置,只需要知道它左边有多少个 null。串行循环可以从左到右累计,但 GPU 更适合用多轮固定偏移:第一轮看左边 1 个位置,第二轮看左边 2 个位置,第三轮看左边 4 个位置,直到偏移覆盖整个输入。

scan:每一轮把“知道的范围”扩大一倍同一轮的每个 record 都能并行读取上一轮的固定偏移offset 110110101offset 211221212offset 411232324n 个元素需要约 log₂(n) 个依赖 pass;最右值给出 null 总数

第 i 轮的输入来自第 i−1 轮的独立 stream,因此同一轮的元素仍可并行;代价是大约 log₂(n) 个 pass 和每轮一次中间 stream。最右边的累计值还告诉我们总共有多少个 null,从而得到紧凑输出的长度。

3. compaction:把累计结果变成没有空洞的 stream

scan 给了“每个位置左边有多少空洞”,但 fragment processor 仍不能让输入记录自己 scatter 到新地址。直接用稳定排序把 null 推到尾部当然可行,却会引入 bitonic sort 的额外比较与多轮;原章的替代方案是让每个目标位置搜索对应来源,再 gather。

用四个可观察阶段实现变长输出每个阶段仍是固定输出位置的 stream pass① markpredicate 输出 value 或 null record② scan每轮把相邻范围扩大一倍,得到累计 null 数③ search利用单调偏移做二分搜索,解决原本的 scatter 地址④ gather每个输出位置读取对应来源,空洞被移到 stream 尾部① mark② scan③ search④ gather

第 1 / 4 步 · 先用 predicate 标出保留与 null

核心转换:把不可表达的变长 scatter,改写成多 pass 的 scan + search + gather。

在实践中要分开看两种成本:scan 有跨 pass 的数据依赖,每一轮必须结束才能进入下一轮;search 的不同试探可以写成依赖 texture read,某些硬件支持 data-dependent looping 时可在一个 pass 内完成。若硬件不支持动态循环边界,也可以针对应用规模编译固定次数的搜索 kernel。

4. search/gather:反过来询问“谁应该填这里”

scan 结果是单调递增的,因此每个输出位置都可以独立做并行搜索:猜一个输入位置,比较累计 null 数与目标距离,再把猜测范围缩小一半。找到来源后,输出 kernel 在自己固定的位置读取 value[source]。从数据流角度看,记录没有被推送,输出位置主动把想要的记录取过来。

找位置,不把记录“推”过去scan 产生单调递增的偏移;search 找到来源,gather 在固定输出位置读取它scan stream013467单调递增,适合 binary search目标:输出位置 2searchfixed output locationsout[2] = gather(value[4])读取代替任意地址写入禁止的 scatter:让 value[4] 自己写到 out[2]fragment processor 只能在预定位置输出,因此把地址关系反过来表达

这种 compaction 的总工作量是 O(n log n):n 个输出位置各做约 log n 步搜索。它的优势是适配 fragment processor 的 gather 限制;代价是依赖读取、scan 中间 pass、边界处理和浮点地址精度。删除比例很低时,应先比较额外 pass 是否值得。

5. 碰撞检测:过滤候选,再展开非叶节点

GPU filtering 的价值不只在删除数组元素。碰撞检测可以把两个包围盒树的候选节点对放入 stream:明显不相交的 pair 被过滤掉,相交且仍是非叶节点的 pair 被展开为子节点 pair,下一轮继续处理;叶节点才进入实际三角形检测。

filtering 让树遍历的候选列表随状态变化level 0A × B一个根节点 pairoverlap?filter + splitrejectA₀ × B₀A₁ × B₀A₀ × B₁next levellevel nnonleaf 继续展开leaf 进入 triangle test列表每轮先过滤再展开,直到 active list 变成空

这是一种 breadth-first 的 GPU 友好表达:每一轮都面对一个当前长度已知的 candidate stream,先做固定位置的 overlap test,再用 filtering 产生更短的 active list 或更长的 child list。与“从最大可能的三角形对开始、把绝大多数空位置带到底”相比,它把树的 pruning 价值保留下来。

6. 自适应细分:过滤完成项,展开 active 项

自适应 subdivision 展示了过滤的另一面:已达到尺寸条件的三角形应停止并进入渲染 stream,仍需细分的三角形则生成 children,并携带新的邻居信息进入下一轮。

变长输出不只是删除,也可以过滤后再展开自适应细分把“已足够小”和“还要继续”的三角形分开处理input triangles邻居坐标 + edge flagsfilterdecisioncompleteactive → splitnextrender4 children+ neighbors每轮 active stream 可能变长或变短,但输出位置仍由当前 pass 预先决定邻居坐标随 triangle 携带,可少一次 pointer indirection

这里不能只保存一个“指向邻居的指针”然后期待 GPU 随时维护动态邻接表。原章选择让三角形携带邻居坐标,牺牲一部分存储换取较少的 dependent texture lookup;每轮生成新的 Triangle 和 Neighbor stream,再根据 edge flags 进行过滤。为了防止相邻三角形对同一顶点做出不同决定,浮点加法的顺序也必须保持一致。

先猜一猜:在 Reduction Lab 中把模式从 collision 切到 subdivide,再把 filtered ratio 调低,哪一个指标会变大?打开实验后一次只改一个控件,观察 active records、scan passes、output records 和 expansion factor。

Reduction Lab · inspect variable-length streams

What this mode exposes

候选 pair 的列表每轮先缩短,再把相交的 nonleaf pair 展开到下一层。

Derived metrics

input records1024
active records512
scan passes10
search steps9
output records174
expansion factor

recommended representation

每层先 filter 候选 pair,再展开相交的非叶节点

这些是由输入规模、过滤比例和算法模式推导出的 stream 形状,不是合成性能分数。

三步验收:从空洞到变长 stream

分步1 / 3

第一步:标记并累计空洞

为 predicate 选择不会和业务数据冲突的 null record;第一 pass 只负责标记,随后用偏移翻倍的 scan 计算每个位置左侧的 null 数,并从最右值推导输出长度。

固定输出位置,也能生成变长结果input stream+4−2+7+1+3predicate:保留正值,其余写成 null record;scan 计算每个元素应移动几格search / gather 替代 scattercompacted output+4+7+1+3length = 4
scan:每一轮把“知道的范围”扩大一倍同一轮的每个 record 都能并行读取上一轮的固定偏移offset 110110101offset 211221212offset 411232324n 个元素需要约 log₂(n) 个依赖 pass;最右值给出 null 总数

本章小结

  • null record 让固定输出 pass 能表达“这个元素不要”。
  • scan 用约 log₂(n) 轮累计每个元素的移动距离。
  • search/gather 在固定输出位置重建紧凑 stream,绕开 scatter。
  • 碰撞检测把过滤与树节点展开组合成 breadth-first stream。
  • 自适应细分同时需要过滤完成项和展开 active 项。

练习

问题 1|设计 compaction。 输入 [4, -2, 7, -1] 只保留正数。请写出 null 标记后的 stream,并说明 scan 结果如何帮助输出位置找到 47

问题 2|修改 Demo 代码。 在 Reduction Lab 中增加一个 sort 模式,用 sort passes = ceil(log2(n))² 展示 bitonic sort 的额外阶段,并与当前的 scan passes + search steps 并列。这个对比应该避免表达什么错误结论?

问题 3|应用选型。 碰撞检测每轮保留 5% 的 candidate pair,并把非叶 pair 平均展开成 2 个 child;另一个过滤任务只丢掉 2% 元素且后续只消费一次。哪个更值得 compaction?需要记录哪些证据?

名词解释

本章出现的专业名词,用大白话再讲一遍。

data filtering
null record
scan
compaction
search/gather

讨论

评论区加载中…