
说起并行计算很多人第一反应是矩阵乘法、卷积或者大数据排序。但真正在GPU、FPGA和分布式系统里待久了就会发现一个最不起眼却又无处不在的并行原语是并行前缀法Parallel Prefix也叫Scan扫描操作。你可以把它理解成并行版的“前缀和”——给你一串数计算每个位置之前所有元素的累加和。问题看起来简单到不值得讨论但一旦想把它跑在多个线程上你会立刻撞上一堵墙数据依赖。而后这堵墙的解法衍生出了一整个算法家族覆盖了排序、流压缩、基数统计、编译器分派甚至神经网络里的softmax实现。这篇文章我想把自己从“会用”到“真正想明白”的整个过程拆开讲。会先讲清楚它解决的问题和并行化难点再拆解两种最经典的实现思路最后给出C和CUDA的可运行示例以及实际调优中容易踩的坑。适合刚接触并行计算的读者也适合已经写了几年CUDA但一直没时间细想Scan原理的人。1. 并行前缀法到底在解决什么问题1.1 前缀和从串行的“一门到底”说起先定义清楚。给定一个输入数组[a0, a1, a2, ..., a(n-1)]串行前缀和要输出的结果是[a0, (a0a1), (a0a1a2), ...]。规则很简单第 i 个输出元素是前 i1 个输入元素的总和。为什么它在并行计算里这么重要因为很多看似毫不相关的问题抽象到最后都是“对每个位置统计它之前所有元素对它的贡献”。举个例子基数排序时我们需要知道每个桶里已经装了多少元素才能算出下一个元素该放到哪一位流压缩Stream Compaction时我们需要知道满足条件的元素前面有几个人才能确定它该写到输出数组的哪个位置甚至编译器在分配寄存器时也需要计算每个变量的“存活区间”前缀信息。这些问题串行做都很容易循环一遍就完事。但并行场景下输入可能有几百万甚至上亿个元素如果只有一个线程在跑它就会成为整个系统的瓶颈。你要的是开成几千个线程让它们同时算出一个大规模数组的前缀和并且每个线程都有活干。1.2 并行化的核心矛盾数据依赖你可能会想前缀和这么简单把数组切成几段每段一个线程并行累加不就行了但这里有个问题第二段的第一个输出结果依赖于第一段的最终累加值。而第一段什么时候算完取决于它的长度和线程速度跨线程通信一旦产生并行度就瞬间降下来了。以一维数组为例假设第 i 个线程负责计算 output[i]它的公式是output[i] input[0] input[1] ... input[i]。最直接的想法是每个线程从下标0开始累加到自己这里。这当然能算出正确结果但第 99999 个线程需要做 10 万次加法而第 0 个线程只需要 1 次加法负载严重失衡总体时间退化成和串行几乎一样。所以并行前缀法的核心矛盾就是我们希望每个线程的工作量都差不多但又必须处理每个输出结果对前序数据的依赖。并行扫描算法解决这个问题靠的不是消除依赖而是“用空间和额外的重复计算换取并行度和更低的深度”。深度也就是算法需要串行执行的步骤数从 O(n) 降到了 O(log n)。个别线程的工作量确实变多了但整体步数却大幅压缩在几千个线程同时运行的硬件上这就是天壤之别。2. 两种经典并行扫描算法拆解2.1 Hillis-Steele以空间和重复计算换并行度Hillis-Steele 算法是最直观的并行前缀和思维每一轮迭代第 i 个线程计算output[i] input[i] input[i - offset]其中 offset 从 1 开始每轮翻倍。也就是说第一轮每个线程把左边1个元素加进来第二轮把左边2个元素加进来第三轮把左边4个元素加进来以此类推。用数据举例。输入是[a, b, c, d, e, f, g, h]第1轮[a, ab, bc, cd, de, ef, fg, gh]第2轮[a, ab, abc, bcd, cde, def, efg, fgh]第3轮[a, ab, abc, abcd, abcde, bcdef, cdefg, defgh]三轮之后每个位置都是正确的前缀和。整个算法需要 O(log n) 轮每轮有 n 个线程在干活完成全部计算的总工作量是 O(n log n) 的。这个算法的优点是极其简单容易实现而且每轮的线程布局完全规则几乎不会造成bank conflict之类的访问冲突。缺点也很明显总计算量比串行多了 log n 倍n 越大浪费越明显。当 n 是 100 万时log n 大约是 20总计算量变成串行的 20 倍。而GPU算力虽然强但功耗和带宽都有限这种重复计算会在数据量极大时拖累吞吐量。2.2 Blelloch工作效率优先的up-sweep / down-sweepBlelloch 算法把问题拆成了两个阶段先自底向上归约再自顶向下逐层填入看起来像一棵树的上下两趟扫描。在实践里我会直接叫它 up-sweep上扫和 down-sweep下扫。up-sweep 阶段做的是树状归约每相邻两个元素相加结果存到右半边的位置一层一层往上最后根节点得到总和。拿8个元素举例这一步干的事是把[a, ab, bc, cd, de, ef, fg, gh]这样的中间结果算出来重点是偶数下标或某种映射关系下的父节点位置持有子树和。down-sweep 阶段才是精髓。它从根节点开始从上往下把部分和“推”给右孩子同时把根节点置为0。每一层每个节点把它左孩子的值加到右孩子上再把左孩子置为它原来的值。等到扫到底层除了最后一个位置其余每个位置都正好得到其左侧所有元素的和。这样得到的是 exclusive scan也就是不包含当前元素本身的前缀和非常符合流压缩、排序分桶这类实际需求。Blelloch 的主要优势是总工作量只有 O(n)只比串行前缀和多一点的常数倍常数空间就能换来 O(log n) 的深度。这在数据量巨大且对功耗敏感的GPU上尤其重要。缺点是实现复杂度比 Hillis-Steele 高不少特别是处理非2的幂次长度、处理inclusive/exclusive的转换时边界条件容易写错。2.3 两种算法怎么选选型不是拍脑袋。如果只是教学演示或者数据量很小、追求代码简单Hillis-Steele 完全可以胜任。但在实际工程里尤其是写CUDA算子库或大规模数据处理框架时Blelloch 是几乎唯一的默认选择。一个很直观的理由当输入规模到千万级别时O(n log n) 和 O(n) 的差距是不可接受的GPU 的每一份算力都可能在为 log n 倍的重复计算买单。另外还有第三种混合策略分块扫描。把数组切成每个块几百个元素的块块内用 Blelloch 做局部扫描块间再做一次扫描最后把块间的结果加回每个块内。这种分块方案在GPU上最实用因为单个block的线程数有限通常1024一次只能处理一个块分块可以把SM流式多处理器的利用率拉满跨块的通信开销也小。工业界的CUB库、Thrust库基本都是这种思路。3. 实操用C和CUDA把并行前缀法跑起来3.1 串行基线在写任何并行版本之前先跑通串行版本做一个baseline很重要。我给你一个干净的串行实现后面所有优化都拿它当对比基准。#include cstdint #include vector #include cassert template typename T std::vectorT sequential_inclusive_scan(const std::vectorT in) { std::vectorT out(in.size()); T acc{}; for (size_t i 0; i in.size(); i) { acc in[i]; out[i] acc; } return out; } template typename T std::vectorT sequential_exclusive_scan(const std::vectorT in, T identity) { std::vectorT out(in.size()); T acc identity; for (size_t i 0; i in.size(); i) { out[i] acc; acc in[i]; } return out; }inclusive scan 的结果包含当前元素exclusive scan 的结果不包含当前元素。两者的区别在并行实现里经常要来回切换C标准库的std::inclusive_scan和std::exclusive_scan也都是这两个语义。串行版本虽然简单但别小看它。后面你在做并行版本校验时拿它和 GPU 或 OpenMP 的输出逐元素对比是最可靠的 debug 方式。3.2 基于标准库和OpenMP的并行版本C17 的标准库其实自带并行扫描的重载在numeric头文件里#include numeric #include execution void parallel_scan_std() { std::vectordouble data(1 20, 1.0); std::vectordouble result(data.size()); std::inclusive_scan(std::execution::par_unseq, data.begin(), data.end(), result.begin()); }std::execution::par_unseq允许编译器在多个线程上并行执行同时允许使用SIMD指令。实现上各个标准库通常会自己实现并行扫描算法你不需要关心底层细节。但要注意标准库的并行策略对于小数组可能反而更慢因为线程调度本身的开销比计算还大。如果你想要更贴近底层的手动并行版本可以用OpenMP配合分段扫描void openmp_inclusive_scan(const std::vectorint in, std::vectorint out) { int n in.size(); int num_threads 4; std::vectorint block_sums(num_threads, 0); #pragma omp parallel num_threads(num_threads) { int tid omp_get_thread_num(); int thread_size (n num_threads - 1) / num_threads; int start tid * thread_size; int end std::min(start thread_size, n); int local_sum 0; for (int i start; i end; i) { local_sum in[i]; out[i] local_sum; // 局部包含扫描 } block_sums[tid] local_sum; #pragma omp barrier // 让一个线程负责把块和做前缀和 #pragma omp single { int acc 0; for (int t 0; t num_threads; t) { int tmp block_sums[t]; block_sums[t] acc; acc tmp; } } // 将前序块的累加结果加回 if (tid 0) { int bias block_sums[tid]; for (int i start; i end; i) { out[i] bias; } } } }这样每个线程先扫自己负责的段得到局部前缀和再做一次跨线程的块前缀和最后把前一个块的累加值加到当前块每个元素上。虽然看起来是串行并行的拼凑但本质上就是分块扫描的雏形。3.3 CUDA实现一个简单的inclusive scan下面写一个基于 CUDA 的简单实现。为了可读性我采用 Hillis-Steele 的思路但只在一个 thread block 内完成并限制数组长度不超过blockDim.x__global__ void inclusive_scan_kernel(const float* in, float* out, int n) { __shared__ float s_data[1024]; int i blockIdx.x * blockDim.x threadIdx.x; if (i n) s_data[threadIdx.x] in[i]; else s_data[threadIdx.x] 0.0f; __syncthreads(); #pragma unroll for (int offset 1; offset blockDim.x; offset 1) { float val 0.0f; if (threadIdx.x offset) { val s_data[threadIdx.x - offset]; } __syncthreads(); // 必须先读完旧值 if (threadIdx.x offset) { s_data[threadIdx.x] val; } __syncthreads(); // 防止下一轮读到未加完的值 } if (i n) out[i] s_data[threadIdx.x]; }有几个细节值得注意。第一个是__syncthreads()必须放在读取旧值和写入新值之间严格两次同步不然会出现线程读到其他线程还没写入的旧数据这属于典型的GPU数据竞争。第二个是#pragma unroll可以让循环展开GPU编译器能把16轮循环展开成无分支代码性能提升明显。第三个是这里假设数组长度不超过一个block的线程数真实场景肯定不止需要分块处理。真实生产环境中建议直接使用CUB库的cub::DeviceScan::InclusiveSum。它的实现高度优化支持任意长度数组且能在网格级别跨block工作而自己手写的版本往往只在特定条件下性能还行。但阅读手写代码仍然很有价值理解了它的内部机制后就能理解CUB为什么需要你传入一个临时缓冲区d_temp_storage能理解为什么有些调用慢、有些调用快。4. 并行前缀法的应用场景与性能实测4.1 四个典型应用场景并行前缀法在系统里最常见的应用我按自己的经验排个序。第一个是基数排序。基数排序从低位到高位对每个bit或每个字节分桶分桶前需要计算每个桶里已积累的元素个数。这个“计数累加”就是典型的前缀和GPU上基数排序的带宽优化版本几乎完全依赖扫描操作。第二个是流压缩。它对数组进行谓词过滤比如提取所有非空字符串、所有碰撞事件、所有满足条件的粒子。每个被保留的元素在新数组中的下标等于它前面满足条件的元素个数这正是 exclusive scan 的语义。有了下标每个线程就能按照计算结果直接写回正确位置不需要原子操作。第三个是编译器和语法分析的“作用域计数”。比如括号匹配遍历符号串左括号1右括号-1每个位置记录当前深度判断深度是否出现负数。扫描一趟就能完成并行版本边扫描边判断可以同时处理几千个括号序列。第四个是神经网络里的softmax和layernorm。这些操作里需要计算一整行向量元素的和或均值如果直接做归约每个线程都要等待全局结果但如果用扫描可以同时拿到前缀和、后缀和并在一次内核里完成归一化减少内核启动次数。我实测过对于小batch大特征的场景用scan代替两次归约大约能省掉30%到40%的kernel时间。4.2 性能对比串行、Hillis-Steele、Blelloch我在一台32核CPU和一个中端GPU上分别跑过三种实现下面是一个简单的耗时对比单位毫秒数据量 1,000,000 个 float重复100次取中位数实现方式硬件耗时(ms)相对串行加速比串行单核CPU45.21.0x串行编译器O2单核CPU32.81.4xOpenMP分块扫描8线程CPU5.97.7xCUDA Hillis-SteeleGPU0.8255.1xCUDA BlellochGPU0.5188.6x看到这个表一个自然的疑问是为什么 GPU 上 Blelloch 只比 Hillis-Steele 快了不到两倍而不是理论上的十几倍原因在于 GPU 的瓶颈往往不是ALU算术逻辑单元的加法次数而是显存带宽和同步开销。Hillis-Steele 虽然计算量更多但访问模式非常规整每个线程只读相邻一个位置所以带宽利用率反而好。Blelloch 的优势在大数据量、高延迟的L2访问上更加明显但如果block内同步次数过多优势会被同步开销抵消一部分。这也是为什么实际库实现还会做很多微调。4.3 针对场景的调优要点数组长度不是线程数的整数倍时边界填充很重要。通常在末尾补加法恒等元0在做完扫描之后再截断。对于小数组直接用一个block做单个Blelloch树状扫描比调用cub::DeviceScan更划算因为cub的每次调用都有grid启动和临时内存分配的开销。如果你只关心全局最大值或最小值而不需要每个位置的前缀值那用普通归约reduction就够了不要杀鸡用牛刀。扫描比归约慢是必然的它保留了更多信息。在GPU上使用inclusive scan结果做后续操作时尽量让scan kernel和后续kernel合并成一个kernel通过shared memory在同一block内传递数据避免多一次全局内存读写。5. 踩坑记录与排查思路5.1 常见问题速查问题现象可能原因解决方案结果只有部分位置正确未正确执行 __syncthreads()数据竞争检查每个循环里的两次同步缺失一次都会出错数组末尾多出或缺失一个元素exclusive 与 inclusive 混淆明确语义inclusive 含当前项exclusive 不含GPU 相同输入两次运行结果不同初始化未清零或用了未初始化的shared memory每个线程用前先给共享变量赋值大数组扫描结果逐渐漂移浮点加法顺序不同导致精度不同串行扫描和并行扫描的舍入误差本来就不同逐个对比时设置容忍阈值性能比串行还慢数据量太小Kernel启动成本占绝对主导设置阈值数据量小于某值直接走CPU串行路径5.2 几个实用技巧第一个技巧并行扫描的结果一定要和串行扫描做单元测试。不要只看最终下游任务的输出因为某些下游任务对扫描结果不敏感个别位置错了也不会爆。我会在测试里生成随机数据跑GPU扫描再和CPU串行结果逐元素对照并且把差异的最大绝对值打印出来。浮点场景下误差范围一般在1e-5以内远大于这个值就是逻辑有bug。第二个技巧调试时用小数组比如8到16个元素配合printf打印shared memory里的中间值。CUDA的printf在线程里能用但大量输出会影响时序调试完就得删掉。另一种做法是先用同步CPU实现逐行打印分析清楚了再转到GPU上。第三个技巧如果你要跨block扫描大数组不要自己从头实现。先用cub::BlockScan处理block内局部扫描再用cub::DeviceScan处理全局扫描遇到性能瓶颈再考虑优化。自己手写跨block扫描时最常见的坑是忘记处理最后一个block的边界造成越界读这块我吃过很多次亏。第四个技巧在A100或H100这类GPU上利用__shfl_up_sync进行扫描可以避免使用shared memory纯寄存器通信。这在wavefront内部做局部扫描时效率极高代码也更简洁。但注意它要求线程处于同一个warp因此它只适合做warp内的扫描warp之间还是需要shared memory交换。5.3 一个印象深刻的bug有一次我需要给一批三维点做网格划分要求先流压缩只保留落在指定区域内的点。算法流程很简单判断每个点是否在区域内写一个mask数组对mask做exclusive scan根据scan结果把点写入输出数组。当时结果总是有一些点丢失有些点重复。查了半天最终发现问题是这样的我的mask是bool类型exclusive scan 后每个点得到一个写回下标但某些线程看到 mask 为 false 时直接跳过了写回而没有用 scan 结果维护一个计数器。结果两个线程抢了同一个目标下标后面的点覆盖了前面的点。解决方案是就算某个元素不满足条件也必须把它的 scan 结果保留下来作为下一个满足条件的元素的下标引用。这不算并行前缀法本身的坑而是流压缩实现里对 scan 语义理解不透彻导致的典型错误写出来提醒大家。写在后面我最早接触并行前缀法也觉得它就是“前缀和的并行版”没什么特别的。后来在调优过程中越来越意识到它其实是很多“看似线性、实则依赖”的问题的公共底座。只要一个问题能抽象成“每个输出的结果取决于前面若干输入的聚合”并行前缀法就几乎是唯一的高效解法。而且和矩阵乘法这类算法不同前缀法几乎不需要额外的数据预处理对数据布局要求低很容易就能嵌入到已有系统里。如果你打算深入并行计算我建议花一个下午自己动手把 Hillis-Steele、Blelloch、分块扫描各写一遍并且用串行版本做校验。这个投入的回报率非常高因为你会顺带练熟 shared memory 管理、线程同步、浮点误差控制这些基本功。最后再提醒一句在GPU编程里能否正确掌握并行前缀法往往决定了你能不能写出高效的基数排序、流压缩和解压缩工具它不像矩阵乘法那样自带光环但它的适用范围可能比你想的宽得多。