并行与分布式系统中的并行前缀和(扫描)算法:Sklansky并行前缀扫描算法
题目描述
给定一个包含 n 个元素的数组 A[0..n-1] 和一个满足结合律的二元运算符 ⊕(例如加法、乘法、最大值、最小值等)。并行前缀和(也称为扫描)的目标是计算一个新的数组 B[0..n-1],其中 B[i] = A[0] ⊕ A[1] ⊕ ... ⊕ A[i](即从第一个元素到第 i 个元素的累积结果)。我们要求设计一个并行算法,使其在并行计算模型(如PRAM模型)上的运行时间比串行算法的 O(n) 更低。Sklansky算法是众多并行前缀和算法中经典的一种,它通过一种树形结构在 O(log n) 时间内完成计算,但需要 O(n log n) 的总工作量(即操作的次数),因此它属于“工作非最优”但时间复杂度优秀的算法。请你详细解释Sklansky算法的设计思路、步骤、并行性分析以及在实际实现时的注意事项。
解题过程
前缀和(扫描)是并行计算中的基础操作,广泛应用于排序、图算法、多项式求值等领域。Sklansky算法(由J. Sklansky在1962年提出)是早期提出的并行前缀和算法之一,其核心思想是通过一个类似树形结构的通信模式,在多个步骤中逐步累积部分和,最终得到每个位置的前缀和。
1. 问题形式化与模型假设
假设我们有:
- 输入数组:
A[0], A[1], ..., A[n-1] - 结合运算符:
⊕(满足结合律,即(a ⊕ b) ⊕ c = a ⊕ (b ⊕ c)) - 输出数组:
B[i] = A[0] ⊕ A[1] ⊕ ... ⊕ A[i],对于i = 0, 1, ..., n-1
我们假设在并行计算模型(如PRAM,特别是CRCW或CREW模型)上有 p 个处理器可用,且 n 是2的幂(如果不是,可以填充虚拟元素,最后忽略)。Sklansky算法的目标是用 O(log n) 步(即时间)完成计算,即使每个步骤中某些处理器可能进行多次操作。
2. 算法核心思想:递归倍增与树形传播
Sklansky算法的结构可以看作两棵重叠的树:
- 第一棵树(向上累积树):自底向上计算每个节点所覆盖区间内的总和(即区间和)。
- 第二棵树(向下传播树):自顶向下将累积的部分和传播到每个叶子节点,从而形成前缀和。
但Sklansky的实现方式更为紧凑,它通过一系列“步”(step)来完成,每一步中,每个处理器根据其索引与一个偏移量进行通信,接收来自较远位置的值并累积。
3. 算法步骤详解
为简化,设 n = 8,元素为 A[0] ... A[7],我们逐步计算前缀和 B[0] ... B[7]。初始时,每个位置 i 保存一个值 S[i],初始 S[i] = A[i]。算法将逐步更新 S[i],最终 B[i] = S[i]。
步骤1:偏移量 offset = 1
- 对于每个
i ≥ 1,S[i] = S[i] ⊕ S[i-1] - 含义:每个位置加上其直接左边的邻居的值。
- 操作后:
S[1] = A[1] + A[0]S[2] = A[2] + A[1]- ...
S[7] = A[7] + A[6]
此时,S[i] 包含了从 A[i-1] 到 A[i] 的和(即连续两个元素的和),但还没有完整的前缀和。
步骤2:偏移量 offset = 2
- 对于每个
i ≥ 2,S[i] = S[i] ⊕ S[i-2] - 含义:每个位置加上左边距离为2的位置的值。
- 操作后:
S[2] = (A[2]+A[1]) + (A[0]+A[1])?实际上,因为上一步后S[0]=A[0]未变,S[1]=A[1]+A[0],S[2]=A[2]+A[1]。现在S[2] ⊕ S[0] = (A[2]+A[1]) + A[0] = A[0]+A[1]+A[2]- 类似地,
S[3] = (A[3]+A[2]) ⊕ (A[1]+A[0]) = A[0]+A[1]+A[2]+A[3] - 而
S[4] = (A[4]+A[3]) ⊕ (A[2]+A[1]),这还不是完整的前缀和(还缺A[0]和A[1])。
步骤3:偏移量 offset = 4
- 对于每个
i ≥ 4,S[i] = S[i] ⊕ S[i-4] - 含义:每个位置加上左边距离为4的位置的值。
- 操作后:
S[4]原来为(A[4]+A[3]) ⊕ (A[2]+A[1]),现在加上S[0] = A[0],得到A[0]+A[1]+A[2]+A[3]+A[4]- 类似地,
S[5]加上S[1](即A[0]+A[1]),得到前6个元素的和。 S[6]加上S[2](前3个元素和),得到前7个元素和。S[7]加上S[3](前4个元素和),得到全部8个元素和。
此时,S[i] 正好等于 B[i],即前缀和。
一般化步骤:
对于大小为 n = 2^k 的数组,算法有 k = log₂ n 步。
在第 t 步(t = 1, 2, ..., k):
- 偏移量
offset = 2^{t-1} - 对于所有满足
i ≥ offset的i,并行执行:S[i] = S[i] ⊕ S[i - offset]
注意:每个 S[i] 是累积更新的,即上一步的结果作为下一步的输入。
4. 并行性分析
- 时间复杂度:共有
log₂ n步,每一步中的所有更新操作可以并行执行(只要处理器足够多),因此并行时间为O(log n)。 - 工作量:每一步中,大约有
n/2^t个位置需要执行操作(因为i ≥ offset的数量约为n - offset)。总操作次数为:
因此,Sklansky算法是“工作非最优”的(因为最优工作量应为(n-1) + (n-2) + (n-4) + ... + (n-2^{k-1}) ≈ n log n - (n-1) = O(n log n)O(n),例如Blelloch扫描算法可以达到O(n)工作量和O(log n)时间,但需要更多步骤和更复杂的调度)。 - 通信模式:每一步中,每个需要更新的位置从左边固定距离的位置读取数据。在分布式内存系统中,这可能需要精心设计数据分布以避免热点。
5. 示例演算(n=8)
假设 A = [a0, a1, a2, a3, a4, a5, a6, a7],运算符为加法。
初始化:S = [a0, a1, a2, a3, a4, a5, a6, a7]
步骤1 (offset=1):
- i=1: S[1] = a1 + a0
- i=2: S[2] = a2 + a1
- i=3: S[3] = a3 + a2
- i=4: S[4] = a4 + a3
- i=5: S[5] = a5 + a4
- i=6: S[6] = a6 + a5
- i=7: S[7] = a7 + a6
S变为:[a0, a0+a1, a1+a2, a2+a3, a3+a4, a4+a5, a5+a6, a6+a7]
步骤2 (offset=2):
- i=2: S[2] = (a1+a2) + a0
- i=3: S[3] = (a2+a3) + (a0+a1) = a0+a1+a2+a3
- i=4: S[4] = (a3+a4) + (a1+a2)
- i=5: S[5] = (a4+a5) + (a2+a3)
- i=6: S[6] = (a5+a6) + (a3+a4)
- i=7: S[7] = (a6+a7) + (a4+a5)
S变为:[a0, a0+a1, a0+a1+a2, a0+...+a3, a1+a2+a3+a4, a2+a3+a4+a5, a3+a4+a5+a6, a4+a5+a6+a7]
步骤3 (offset=4):
- i=4: S[4] = (a1+a2+a3+a4) + a0 = a0+...+a4
- i=5: S[5] = (a2+a3+a4+a5) + (a0+a1) = a0+...+a5
- i=6: S[6] = (a3+a4+a5+a6) + (a0+a1+a2) = a0+...+a6
- i=7: S[7] = (a4+a5+a6+a7) + (a0+a1+a2+a3) = a0+...+a7
最终S即为前缀和数组B。
6. 算法的变体与优化
- 非2的幂的长度:可以通过填充虚拟元素(单位元,如加法的0)扩展到2的幂,计算后再截断。
- 多步合并:在某些硬件上,连续步骤可能合并以减少同步开销,但Sklansky的步骤间有严格的数据依赖,必须按顺序执行。
- 与Brent-Kung算法对比:Sklansky算法需要的步数少,但总操作数多;Brent-Kung算法是工作最优的(O(n)工作量),但需要2*log n - 2步。两者是并行前缀和算法中“时间最优”与“工作最优”的两个经典代表。
7. 实际实现考虑
- 数据竞争:在共享内存并行中,如果多个处理器写同一内存位置会导致竞争。但在Sklansky算法中,每一步中每个S[i]只被一个处理器更新(因为每个i只接收一个来源),所以可以使用CREW(并发读互斥写)或更弱的PRAM模型。
- SIMD友好性:由于每一步中所有操作是统一的(相同偏移量),适合SIMD(单指令多数据)架构,如GPU。
- 分布式内存版本:在分布式内存系统中,需要将数组分块到不同处理器,每个处理器先计算本地前缀和,然后通过类似Sklansky的树形通信收集前方块的总和,再传播回本地块进行修正。这通常称为“两阶段扫描”。
总结
Sklansky并行前缀和算法通过对数步的倍增偏移累加,在O(log n)时间内完成前缀和计算,虽然工作量非最优,但其简单的通信模式和对数步数使其在处理器充足且n不太大的情况下非常高效。理解此算法有助于掌握并行前缀和问题的本质,并为学习更复杂的扫描算法(如Blelloch、Kogge-Stone等)打下基础。