CUDA 06 · 并行算法模式
从算法骨架开始,而不是从线程开始
大多数 GPU 算法可由少数模式组合:
| 模式 | 输入到输出 | 代表用例 |
|---|---|---|
| Map | 每个元素独立变换 | 激活函数、颜色变换、缩放 |
| Reduce | 多个元素合成一个/少量结果 | sum/max/norm/dot |
| Scan | 输出每个前缀的归约结果 | prefix sum、compact、radix sort |
| Histogram / Group | 按 key 聚合 | 计数、分桶、embedding gradient |
| Stencil | 每点读取固定邻域 | 卷积、PDE、图像滤波 |
| Gather / Scatter | 间接索引读写 | 稀疏算法、重排、图算法 |
| Sort / Select | 建立次序或选出元素 | radix sort、top-k |
| GEMM | 规则乘加与多级复用 | 线性代数、神经网络 |
先识别骨架,通常能直接复用 CUB/Thrust/cuBLAS 等成熟实现。自己写 kernel 的主要理由应是融合、特殊布局、特殊数值语义或经过测量的性能缺口。
Map:带宽基线
__global__ void relu(const float* x, float* y, int n) {
for (int i = blockIdx.x * blockDim.x + threadIdx.x;
i < n; i += blockDim.x * gridDim.x) {
y[i] = fmaxf(x[i], 0.0f);
}
}Map 通常算术强度低,适合作为内存带宽基线。优化方向:连续访问、减少中间数组、把多次 map 融成一次。但融合也会增加寄存器和编译复杂度,不能无限进行。
Reduce:从串行到树形
求和满足结合律的数学形式:
可改成深度 的树:
x0 x1 x2 x3 x4 x5 x6 x7
\ / \ / \ / \ /
s0 s1 s2 s3
\ / \ /
t0 t1
\ /
sum浮点加法并不严格结合,所以不同树形会得到细微不同结果。这是数值语义变化,不一定是 bug。
Block reduce:shuffle + shared
以下版本要求 blockDim.x 是 32 的整数倍且不超过 1024:
__device__ float warp_sum(float v) {
constexpr unsigned full = 0xffffffffu;
for (int offset = warpSize / 2; offset > 0; offset >>= 1)
v += __shfl_down_sync(full, v, offset);
return v;
}
__global__ void reduce_sum(const float* x, float* partial, int n) {
__shared__ float warp_sums[32];
int tid = threadIdx.x;
int lane = tid & (warpSize - 1);
int warp = tid / warpSize;
float sum = 0.0f;
for (int i = blockIdx.x * blockDim.x * 2 + tid;
i < n; i += gridDim.x * blockDim.x * 2) {
sum += x[i];
if (i + blockDim.x < n) sum += x[i + blockDim.x];
}
sum = warp_sum(sum);
if (lane == 0) warp_sums[warp] = sum;
__syncthreads();
int warp_count = (blockDim.x + warpSize - 1) / warpSize;
float block_sum = (tid < warp_count) ? warp_sums[lane] : 0.0f;
if (warp == 0) {
block_sum = warp_sum(block_sum);
if (lane == 0) partial[blockIdx.x] = block_sum;
}
}设计层次:
每线程:grid-stride 累加多个元素到 register
每 warp:shuffle 在 register 间归约
每 block:每 warp 写一个 shared partial,首 warp 再归约
全 grid:第二次 kernel 归约 partial,或调用 CUB这比“每个输入直接 atomicAdd(global_sum)”减少几个数量级的全局原子争用。
生产代码优先评估 CUB:cub::DeviceReduce、cub::BlockReduce、cub::WarpReduce。CUB 的层次恰好对应 device/block/warp,且能随架构选择策略。
Scan:并行算法的瑞士军刀
Exclusive scan:
input: [3, 1, 7, 0, 4, 1, 6, 3]
exclusive sum: [0, 3, 4,11,11,15,16,22]
inclusive sum: [3, 4,11,11,15,16,22,25]Hillis–Steele 每轮向前读距离 的元素,深度,但总工作。Blelloch scan 使用 upsweep 构建归约树、把根置单位元,再 downsweep,工作量:
upsweep: 叶子 → 分段和 → 总和
downsweep: 总和位置置 0,父前缀分发到左右子树完整 device scan 通常分三层:
- 每 block 扫描自己的 tile,并输出 block total;
- 扫描所有 block totals;
- 把对应 block prefix 加回每个 tile。
典型派生:
- Stream compaction:predicate → 0/1 flags → exclusive scan → 有效元素 scatter;
- Radix sort:按某一位分桶,scan 得到每桶写入位置;
- CSR 构造:每行非零数 scan 得到 row offsets;
- 队列分配:每线程请求量 scan 得到无冲突区间。
除非是学习或要与其他操作融合,直接使用 cub::DeviceScan 或 thrust::inclusive_scan/exclusive_scan。
Histogram:解决热点,而不只是使用 atomic
最朴素:
atomicAdd(&bins[input[i]], 1);当 key 分布偏斜,许多线程争用同一地址。优化层次:
global histogram
▲ 每 block 合并一次
block-private histogram(shared)
▲ 每 warp 合并一次
warp-private / lane-private partials
▲
input items选择取决于 bin 数、数据分布和 shared memory 容量:
- bin 少:每 block/warp 私有副本可行;
- bin 多:完整副本太贵,可按 tile 处理 key range;
- key 高度偏斜:warp 内先用 ballot/match 聚合同 key,再由 leader atomic;
- 稀疏 key:排序 + reduce-by-key 可能更适合。
优化 histogram 必须用多种分布测试:均匀、单热点、Zipf、全相同。只测均匀随机会掩盖争用。
Stream compaction
目标:从输入中保留满足 predicate 的元素,且保持顺序。
input: [8, 1, 6, 2, 9]
keep: [1, 0, 1, 0, 1]
scan: [0, 1, 1, 2, 2]
scatter: out[0]=8, out[1]=6, out[2]=9代码骨架:
flag[i] = pred(input[i]);
exclusive_scan(flag, position);
if (flag[i]) output[position[i]] = input[i];若输出顺序无要求,可用 block-local queues + global atomic reservation,减少全局 scan;但这改变了语义。先写清“稳定/不稳定”的合同。
Transpose:重排访问,而不是增加计算
朴素转置无法同时让 global load 与 store 都合并:
out[x * height + y] = in[y * width + x];Shared tile 将问题拆成两次合并访问,中间在片上转置:
Global input Shared tile Global output
连续读一行 ───► 写 [row][col]
读 [col][row] ───► 连续写一行
第二维 +1 padding 避免 bank conflict它展示了 shared memory 的本质:不是“缓存一切”,而是在片上重排,让片外两端都高效。
Stencil:Halo 与时间分块
一维 3-point stencil:
每 block 把中心区域加左右 halo 放入 shared:
global: ... [halo L | block interior ........ | halo R] ...
shared: [ L | reusable interior | R ]注意:
- 边界条件(zero/clamp/wrap)必须明确;
- halo 加载要保持尽量合并;
- 邻域小且缓存命中好时,shared 版本未必更快;
- 多时间步 temporal blocking 增加复用,但扩大 halo、shared/register 压力和边界复杂度。
Gather 与 Scatter
out[i] = values[index[i]]; // gather:读地址不规则,写连续
out[index[i]] = values[i]; // scatter:读连续,写地址不规则Gather 的主要问题是 cache locality 和内存事务;scatter 还可能有写冲突。策略:
- 对索引排序/分桶,改善局部性;
- 压缩索引宽度;
- 将重复 index 先局部归约;
- scatter 冲突时明确使用 atomic 或唯一所有权;
- 若重排成本超过收益,不要重排。
Sort、Top-k 与图算法
| 场景 | 常见选择 |
|---|---|
| 固定宽度整数/浮点 key 大批排序 | radix sort,优先 CUB DeviceRadixSort |
| 通用比较器 | merge sort 等,优先成熟库 |
| 小 k 的 top-k | block/warp selection、heap/network,或库实现 |
| 大 k / 全排序 | 排序后截取或分层选择 |
| BFS frontier | compact + work queue;处理度数不均 |
| PageRank/SpMV | 按边或行分工,关注稀疏格式与负载均衡 |
图和稀疏问题常受不规则访存与负载不均限制,峰值 FLOP/s 不是有用目标。应观察 edge throughput、memory transactions、L2 hit、atomic contention 和尾部 block。
算法设计的五个量
- Work:总操作数是否接近最优复杂度?
- Depth:最长依赖链多长?能并行到什么程度?
- Traffic:各层级搬运多少字节?
- Contention:多少线程竞争同一资源/地址?
- Balance:warp/block 间工作是否均匀?
例如 scan 的工作高效版本可能指令更多、同步结构更复杂,但对大规模输入更可扩展;一个原子版本在小输入上可能更快。正确结论总是带输入规模和分布。
练习顺序
- Vector map:建立带宽与计时基线。
- Reduce:naive atomic → shared tree → shuffle → CUB。
- Scan:先实现单 block,再实现分层 device scan,对照 CUB。
- Histogram:测试四种分布,做 block-private 与 warp aggregation。
- Transpose:naive → coalesced shared → padded shared。
- Compact:predicate + scan + scatter,并验证稳定性。
- Stencil:global-only 与 shared halo 两版对照缓存指标。