Parallel Reduction 优化
并行归约:把一条加法链折成归约树
归约,SIMD,OpenMP,CUDA,浮点误差。
把 \(n\) 个数加成一个数,最直接的写法是一条首尾相接的链:第 \(i\) 次加法要等第 \(i - 1\) 次的结果。链长是 \(n - 1\),累加器的读写延迟全串在中间,乱序窗口再宽也藏不住。并行归约要动的就是这条链——把数据切开交给多个执行单元各自累加,再用一棵树把部分结果折起来。
折树改变不了算术强度
先算这个算子本身能吃多少算力。以 float 为例,每个元素读 \(4\) byte、贡献 \(1\) 次加法,也就是每字节 \(\frac{1}{4}\) 次浮点运算。这个比值放在现在的处理器上属于典型的 memory bound,想算得快只能让数据流得快,多出来的执行单元帮不上忙。
这件事对后面的取舍影响很大。折树降低的是依赖深度,换来的是并行度,但每个元素还是要被读一遍,带宽是硬上限。GPU 上卡在显存带宽、CPU 上卡在内存带宽,都是这一类问题。
每个线程一份私有累加器
数据切开之后,每个线程都需要一块自己的累加变量,最后再合并。OpenMP 的 reduction 子句把这件事写成了语法:它为每个线程生成一份私有副本,初值取算子的单位元(加法是 \(0\),乘法是 \(1\)),线程内先各自归约,出并行区之后再用同一个算子合并回共享变量。要注意私有副本的初值不是循环前面写的那个用户初值,用户初值是在最后合并阶段才参与的[1] 。
手写的时候容易踩到伪共享。多个线程的私有槽如果落在同一条 cacheline 上,写的是不同变量,cacheline 却要在核之间来回搬:
struct alignas(64) PartialSum {
double value;
char padding[64 - sizeof(double)]; // 填满一条 cacheline,让相邻线程的私有槽互不干扰
};
\(64\) 是一般 x86 的 cacheline 大小。padding 不是浪费,是让每个槽独占一行。
内层循环还要再拆一次
线程私有解决了线程之间的竞争,单个线程内部却还是一条长度约为 \(\frac{n}{p}\) 的加法链。线程数不够大的时候这条链就是瓶颈,而且它是串行的:一次加法没回来,下一次加法发不出去。
办法是在线程内部再摆几个互不相关的累加器,把一条链拆成几条。拆到 \(4\) 条之后深度降到 \(\frac{1}{4}\),编译器也才有机会把它们装进向量寄存器:
__m256d acc0 = _mm256_setzero_pd();
__m256d acc1 = _mm256_setzero_pd();
__m256d acc2 = _mm256_setzero_pd();
__m256d acc3 = _mm256_setzero_pd();
std::size_t i = begin;
const std::size_t step = 16; // 每轮 16 个 double,四条链各吃 4 个
for (; i + step <= end; i += step) {
acc0 = _mm256_add_pd(acc0, _mm256_loadu_pd(data + i));
acc1 = _mm256_add_pd(acc1, _mm256_loadu_pd(data + i + 4));
acc2 = _mm256_add_pd(acc2, _mm256_loadu_pd(data + i + 8));
acc3 = _mm256_add_pd(acc3, _mm256_loadu_pd(data + i + 12));
}
这四条加法之间没有依赖,流水线可以一直填满。约束出在累加器条数和硬件参数的匹配上:条数太少,前一条加法没回来,下一条就发不出去;条数太多,向量寄存器又不够分。\(4\) 是个常见起点,具体几条合适还是得看一眼编译出来的汇编再定。
收尾再把四条链折到一起。水平归约(_mm256_hadd_pd 这一类)在不少微架构上要拆成若干条 shuffle 加跨 \(128\) 位通道的搬运,比一次纵向加法贵得多,所以只能出现一次,不能放进循环里,编译器那边也一直在想办法少生成几条[2] :
// 四条链先两两合并,再落到标量,水平归约只做一次
acc0 = _mm256_add_pd(acc0, acc1);
acc2 = _mm256_add_pd(acc2, acc3);
acc0 = _mm256_add_pd(acc0, acc2);
double lanes[4];
_mm256_storeu_pd(lanes, acc0);
double sum = (lanes[0] + lanes[1]) + (lanes[2] + lanes[3]);
for (; i < end; ++i) { // 剩下不到 16 个的尾巴
sum += data[i];
}
块内树形归约
线程数上去之后,部分结果本身也有 \(p\) 个,合并阶段又变回一条链。GPU 上的做法把这个阶段也折成树:一个 block 的 \(b\) 个部分和放在 shared memory 里,步长从 \(\frac{b}{2}\) 开始逐次折半。
for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) {
sdata[threadIdx.x] += sdata[threadIdx.x + s];
}
__syncthreads();
}
每一轮活跃的线程减半,\(b - 1\) 次加法摊在 \(\lceil \log_2 b \rceil\) 轮里,深度从 \(b - 1\) 降到 \(\lceil \log_2 b \rceil\)。__syncthreads() 不能省,它保证这一轮读到的 sdata[threadIdx.x + s] 已经是上一轮的最终值,否则会读到别人还没写完的中间结果。
共享内存是分 bank 的,同一个 warp 内的线程按 \(\frac{b}{2}, \frac{b}{4}, \ldots\) 这样的步长访问,前几轮会大量撞在同一个 bank 上,串行化之后等待时间就白花了。缓解方式有两种,一是往上加一个 offset 把访问错开,二是换成顺序寻址——先在全局内存里按连续的 threadIdx.x 把数据读进来(顺便拿到合并访问),再在 shared memory 里按 \(1, 2, 4\) 的步长向上归约。后一种更干净,因为它顺带解决了全局内存访问的合并问题。
warp 内不用共享内存
warp 里的 \(32\) 个线程本来就是同一批指令一起走,交换数据不必绕共享内存。CUDA 的 shuffle 让线程直接读同一个 warp 里另一个线程的寄存器:
__inline__ __device__ float warpReduceSum(float value) {
for (int offset = 16; offset > 0; offset >>= 1) {
value += __shfl_down_sync(0xffffffff, value, offset);
}
return value;
}
\(5\) 轮之后 lane \(0\) 拿到整个 warp 的和,路径上没有共享内存访问,也没有 __syncthreads()。一个 block 里还剩若干 warp 的结果,把它们写进 shared memory,再让第一个 warp 重复一遍上面的操作,一个 block 就收敛到一个值。到这里为止的分层顺序(全局内存合并读、warp 内 shuffle、warp 间共享内存、块间第二轮 kernel)在 CUDA 的 reduction 讲义里是一步步压下来的[3] ,每一步都对应一个具体的开销;同一份内容的课程笔记版见[4] 。
每个线程多领几个元素
再往前是让一个线程处理多个元素,也就是 thread coarsening 或者 grid-stride:
std::size_t idx = blockIdx.x * blockDim.x * 2 + threadIdx.x;
float sum = 0.0f;
if (idx < n) {
sum += input[idx];
}
if (idx + blockDim.x < n) {
sum += input[idx + blockDim.x]; // 同一个线程再吃一个,两个元素先在寄存器里加
}
每线程多读一个元素,block 数就少一半,第二轮要归约的部分和也跟着少一半;元素在寄存器里先加一次,连 shared memory 都不用进。代价是并行度下降,块数少到填不满 SM 的时候反而变慢,尾块不均衡也会更明显。这个平衡点跟 block 大小、每线程元素数、数据规模都有关系,调参的意义大于理论。
多轮 kernel 启动是这套结构里另一块固定开销。第一轮之后剩下的是部分和,还要再来一轮甚至几轮才能收敛到一个标量,中间要在两个 buffer 之间来回切,免得在同一块内存上边读边写。
余数、重结合与不确定性
按块长切分的数据量很少正好是整数倍,余数分支得写清楚。常见做法是靠 idx < n 这类卫语句在边界外直接跳过,而不是单独开一个只处理尾巴的循环,那样等于多一遍循环入口和边界判断。
顺序问题更要紧。浮点加法不满足结合律,\((a + b) + c\) 和 \(a + (b + c)\) 的结果可能差一个舍入。编译器默认不允许重排浮点表达式,只有 -ffast-math 或 -fassociative-math 打开之后才允许向量化这类重排;前面手写的多累加器,等于在源码层面主动放弃了顺序。OpenMP 规范把合并发生的位置和顺序都留成 unspecified,同样的线程数、同样的调度,两次运行也不保证逐位一致[5] 。要可复现性得依赖具体运行时的开关。
整数归约没有这个问题,模 \(2^{k}\) 的加法满足结合律,怎么折树结果都一样。
总操作量、深度和误差
折树不减少加法的总数。一段 \(m\) 个元素的成对归约仍然是 \(m - 1\) 次加法,省下来的是深度:
两级结构多出来的是部分和的写出和读回,规模是 \(O(p)\),相对 \(O(n)\) 的数据量可以忽略;但如果每个线程只领 \(1 \sim 2\) 个元素,\(p\) 就接近 \(n\),这部分开销会重新变成主要成本,coarsening 正是冲着它去的。
粗化之后深度不再是纯净的对数。每个线程先串行加 \(c\) 个元素得到深度 \(c - 1\),剩下 \(\frac{n}{c}\) 个部分和再上树:
这个式子也说明收益是有边界的:\(c\) 从 \(1\) 涨到 \(4\),深度掉一大截;继续往上加,串行那一段就追回来了。
误差用常规模型,\(\text{fl}(a + b) = (a + b)(1 + \delta)\),\(|\delta| \le \varepsilon\),并记 \(\gamma_{k} = \frac{k\varepsilon}{1 - k\varepsilon}\)。以同样的 \(\sum_{i} |x_{i}|\) 作基准,串行累加的误差界正比于链长:
成对归约只跟树高有关:
从 \(O(\varepsilon n)\) 退到 \(O(\varepsilon \log n)\),这是归约树顺带拿到的好处。两个都是上界,实际误差跟数据分布关系很大,正负相消的序列比这个界乐观得多。另外 \(\gamma_{k}\) 在 \(k\varepsilon\) 接近 \(1\) 的时候就失效了,数据量大到那个程度时这个模型本身不再成立——不过实际算例离这条线很远,暂时不用担心。
误差界这一项在真实归约里通常不是主要矛盾。每个元素只读一遍,算术强度低,时间花在等内存上,所以值得做的是把同一个数组上的多个归约量合并到一次遍历里,比如同时求总和、计数、最小值、最大值,省下来的是一整趟内存访问,而不是几个 \(\log\) 层。
适用场景
这套结构的前提是数据量足够大,而且归约本身确实是瓶颈。算术强度摆在那里,每个元素只参与一次运算,时间大多花在等内存上,所以更值钱的是减少遍历次数——把同一个数组上的和、计数、最值合并到一趟里算完,比继续把树折得更深划算得多。折树解决的是延迟和并行度,只有在数据小到能放进 cache、或者部分和本身变成瓶颈的时候,深度才会重新成为主要矛盾。
输入规模小的时候这套分层是负收益。一个 block 都填不满、或者元素数还不到一条向量的宽度时,部分和的读写、padding 留下的空洞、块间那一轮额外的 kernel 启动,这些固定开销加起来会超过折树省下来的时间,这时候串行累加或者每个线程各算各的更直接。GPU 上尤其明显,两级结构意味着至少两次 kernel 往返,成本要摊到足够多的数据上才划得来。
算子本身也有要求。折树默认加法满足结合律和交换律,浮点加法在数学上并不满足,只能容忍那点舍入误差,前面才会反复强调分块方式会改变结果。如果归约算子真的依赖顺序,比如带顺序语义的合并,那它更接近前缀和,得换 scan 那一套。要求逐位可复现的场合也一样,要么把分块和合并顺序固定下来,要么用补偿求和把舍入压到不影响结论的量级。
线程数这边 CPU 和 GPU 的取舍不一样。CPU 核数有限,线程内累加加线程间合并这两级就够用,再往下细分只会多出同步开销;GPU 上 SM 多、单核弱,才需要 warp 内 shuffle、warp 间共享内存、块间多轮 kernel 这一串分层。coarsening 该切几个元素同样没有通用答案,它跟 block 大小、数据规模、尾块的不均衡程度都相关,只能拿自己的数据实测。
完整实现
完整的 CPU 版并行归约实现:
点击查看代码
// 线程内四条独立累加链 + 线程间 cacheline 隔离 + 成对合并部分和
#include <algorithm>
#include <cstddef>
#include <vector>
#include <immintrin.h>
namespace {
constexpr std::size_t kCacheLineBytes = 64; // 一般 x86 的 cacheline 大小
constexpr std::size_t kScalarAccumulators = 4; // 线程内互相独立的累加链条数
/**
* @brief 线程私有的部分和槽,独占一条 cacheline 以避免伪共享
*/
struct alignas(kCacheLineBytes) PartialSum {
double value = 0.0;
char padding[kCacheLineBytes - sizeof(double)] = {};
};
/**
* @brief 对一段数据做归约,线程内保留四条独立累加链,收尾只做一次水平归约
* @param data 数据首地址
* @param begin 区段起点
* @param end 区段终点(不含)
* @return 该区段的和
* @note 结果依赖分块方式,浮点加法不满足结合律
*/
double reduceChunk(const double* data, std::size_t begin, std::size_t end) {
#if defined(__AVX2__)
__m256d acc0 = _mm256_setzero_pd();
__m256d acc1 = _mm256_setzero_pd();
__m256d acc2 = _mm256_setzero_pd();
__m256d acc3 = _mm256_setzero_pd();
std::size_t i = begin;
const std::size_t step = 4 * kScalarAccumulators; // 每轮 16 个 double,四条链各吃 4 个
for (; i + step <= end; i += step) {
acc0 = _mm256_add_pd(acc0, _mm256_loadu_pd(data + i));
acc1 = _mm256_add_pd(acc1, _mm256_loadu_pd(data + i + 4));
acc2 = _mm256_add_pd(acc2, _mm256_loadu_pd(data + i + 8));
acc3 = _mm256_add_pd(acc3, _mm256_loadu_pd(data + i + 12));
}
// 四条链先两两合并,再落到标量,水平归约只在这里出现一次
acc0 = _mm256_add_pd(acc0, acc1);
acc2 = _mm256_add_pd(acc2, acc3);
acc0 = _mm256_add_pd(acc0, acc2);
double lanes[4];
_mm256_storeu_pd(lanes, acc0);
double sum = (lanes[0] + lanes[1]) + (lanes[2] + lanes[3]);
for (; i < end; ++i) {
sum += data[i];
}
return sum;
#else
// 没有 AVX2 时保留同样条数的标量累加链,指令级并行的结构不变
double acc[kScalarAccumulators] = {0.0, 0.0, 0.0, 0.0};
std::size_t i = begin;
for (; i + kScalarAccumulators <= end; i += kScalarAccumulators) {
acc[0] += data[i];
acc[1] += data[i + 1];
acc[2] += data[i + 2];
acc[3] += data[i + 3];
}
double sum = (acc[0] + acc[1]) + (acc[2] + acc[3]);
for (; i < end; ++i) {
sum += data[i];
}
return sum;
#endif
}
/**
* @brief 把部分和按成对方式合并,合并链深度是 log2(部分和个数)
* @param partials 部分和数组,返回时只有首元素有意义
* @return 合并后的结果
* @note 原地覆盖;个数为奇数时把落单的那个并入前半段的最后一个
*/
double mergePartialsTree(std::vector<PartialSum>& partials) {
std::size_t count = partials.size();
while (count > 1) {
const std::size_t half = (count + 1) / 2;
for (std::size_t i = 0; i < count - half; ++i) {
partials[i].value += partials[i + half].value;
}
count = half;
}
return partials.front().value;
}
} // namespace
/**
* @brief 串行参照实现,只用来对照误差
* @param data 数据首地址
* @param n 元素个数
* @return 串行累加的结果
*/
double reduceReference(const double* data, std::size_t n) {
double sum = 0.0;
for (std::size_t i = 0; i < n; ++i) {
sum += data[i];
}
return sum;
}
/**
* @brief 多线程并行归约
* @param data 数据首地址
* @param n 元素个数
* @param threadCount 线程数,小于等于 1 时退化为单线程
* @return 归约结果
* @note 每个线程按连续区段取数,尾部区段靠 begin/end 截断处理余数
* @warning 浮点加法不满足结合律,结果依赖 threadCount 和分块方式
*/
double reduceParallel(const double* data, std::size_t n, int threadCount) {
if (data == nullptr || n == 0) {
return 0.0;
}
if (threadCount <= 1) {
return reduceChunk(data, 0, n);
}
const std::size_t workers = static_cast<std::size_t>(threadCount);
std::vector<PartialSum> partials(workers);
#pragma omp parallel for num_threads(threadCount) schedule(static)
for (int t = 0; t < threadCount; ++t) {
const std::size_t chunk = (n + workers - 1) / workers;
const std::size_t begin = std::min(n, chunk * static_cast<std::size_t>(t));
const std::size_t end = std::min(n, begin + chunk);
// 相邻线程写的是不同 cacheline,合并阶段之前不会互相干扰
partials[static_cast<std::size_t>(t)].value = reduceChunk(data, begin, end);
}
return mergePartialsTree(partials);
}

浙公网安备 33010602011771号