算子在做什么,先看一个算例
Reduce(归约)就是把一个数组“收”成一个数的运算:求和、求最大值、求平均值都属于它。叫“归约”是因为每算一步,元素的个数就少一些,最后收敛到一个标量。这篇文章只看最简单也最常用的那个——求和。
它在深度学习里出现的频率比想象中高:LayerNorm 要算均值与方差、Softmax 要算指数和、损失函数要把整个 batch 的误差加起来。这些地方的共同点是——数据在显存里躺着,而结果只有一个数。
任务本身没有难度,难的是怎么让几千个线程一起算一个和,还不互相踩。这才是 Reduce 成为 CUDA 优化经典入门题的原因。
一个可以手工核对的算例
先看 8 个数的求和。串行做法是一条 7 步的加法链:
以 a = [1, 2, 3, 4, 5, 6, 7, 8] 为例,总和应为 36。树形做法在 GPU 上的对应物,就是先让线程两两相加,再让结果两两相加——每一轮参与的线程数减半,只要 log₂(n) 轮。这正是后文 __shfl_down_sync 那段代码在做的事。
用寄存器模拟一遍 32 个 lane 的 warp 归约,每一步的 delta 就是“和隔多远的那一份相加”:
为什么不是一个 kernel 干完
GPU 上没有跨 block 的同步机制:一个 kernel 里的不同 block 谁先谁后跑完、能不能同时驻留,都不保证。所以一个 block 没法安全地去读另一个 block 的结果。既然不能在一次启动里收成 1 个数,那就分两步走:
这个两阶段结构在 v0 里就定下了,v8 只是在每个阶段内部把效率压到极限。
RTX 5070(sm_120,理论峰值带宽 672 GB/s)+ CUDA 13.4 + nvcc 13.4。所有耗时都是本机实测:每个 kernel 连跑 13 次,丢掉前 3 次热身,取后 10 次的最小值,只计 kernel、不含 cudaMemcpy。
代码逐段解析(v8)
下面按“数据怎么进来 → 怎么累加 → 怎么收成一个数 → 怎么启动”的顺序拆 v8。引用行号对应本节末尾“完整代码”里的那份 reduce_v8_shfl_down_sync_pack.cpp。
① Packed:把 4 个 float 当成一个单位
性能问题的根源在这里就解决了大半。如果一次只搬 1 个 float(4 字节),访存通路就用不满;把相邻的 4 个 float 打包成 16 字节,一次搬完,指令条数直接除以 4。
//alignas(sizeof(T) * pack_size) —— 整段的开关
//含义:这个类型的每一个对象的起始地址,都必须是 N 的倍数。这里 N = sizeof(float) × 4 = 16。它把结构体的对齐要求抬到 16 字节,没有这句 alignas,结构体可能只有 4 字节对齐,"一次读 16 字节"就没法保证了。
template<typename T, int pack_size>
struct alignas(sizeof(T) * pack_size) Packed {
__device__ Packed(T val){
#pragma unroll
for(int i = 0; i < pack_size; i++){
elem[i] = val;
}
}
__device__ Packed() {
// do nothing
}
// 【匿名 union】union 的成员共用同一块内存,每个成员的偏移都是 0。
// 这里只有 elem 一个成员,所以等价于直接写 `T elem[pack_size];`。
// 匿名 → 没有名字,elem 直接就是 Packed 的成员,写 p.elem 即可。
union {
T elem[pack_size];
};
__device__ void operator+=(Packed<T, pack_size> packA){
#pragma unroll
for(int i = 0; i < pack_size; i++){
elem[i] += packA.elem[i];
}
}
};
alignas(sizeof(T) * pack_size) 是这一整段的开关。它把结构体的对齐要求抬到 16 字节——没有它,Packed<float, 4> 只按成员最大对齐(4 字节),编译器不敢假设这个对象落在 16 字节边界上,于是发不出 128 位的访存指令,打包就白做了。
匿名 union 里只有一个成员 elem,写法上等价于直接写 T elem[pack_size];,作用是让 p.elem[i] 能直接用。文件里第 9-16 行那两组重复的 #define 是抄写时留下的重复,值完全相同,重复定义同一常量在 C++ 里合法,编译不出错,但确实该删。
② GetNumBlocks:block 数不写死,按硬件规格算
v0~v6 都是“数据量 ÷ 每 block 元素数”直接算出 131072 个 block。block 开太多并不好:每个 block 都有启动开销,而显存带宽是固定的,塞进去的 block 超出 SM 的承载能力只会排队。OneFlow 的做法是反过来——先问硬件能同时装下多少线程,再决定开几个 block。
// 【GetNumBlocks:按硬件规格算 block 数(OneFlow 的做法)】
int64_t GetNumBlocks(int64_t n) {
// 【第 1 步:拿到当前设备号】cudaGetDevice 写进 dev。
int dev;
{
cudaError_t err = cudaGetDevice(&dev);
if (err != cudaSuccess) { return err; }
}
// 【第 2 步:SM 个数】cudaDevAttrMultiProcessorCount。A100 是 108。
int sm_count;
{
cudaError_t err = cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, dev);
if (err != cudaSuccess) { return err; }
}
// 【第 3 步:每个 SM 最多常驻多少线程】cudaDevAttrMaxThreadsPerMultiProcessor。
int tpm;
{
cudaError_t err = cudaDeviceGetAttribute(&tpm, cudaDevAttrMaxThreadsPerMultiProcessor, dev);
if (err != cudaSuccess) { return err; }
}
int64_t num_blocks = std::max<int64_t>(1, std::min<int64_t>((n + kBlockSize - 1) / kBlockSize,
sm_count * tpm / kBlockSize * kNumWaves));
return num_blocks;
}
代码里 std::min 取“够用就好”的那个,std::max(1, ...) 兜底防止数据量太小时算出 0 个 block。注意本机打印的是 288,而注释里写的“864 个 block”是 A100(108 个 SM)的数字——这段注释是从 A100 上抄过来的,没跟着机器更新。
③ kernel 主体:网格步长循环 + 打包累加
__global__ void reduce_v8(float *g_idata,float *g_odata, unsigned int n){
// 【本线程在网格里的全局编号
unsigned int i = blockDim.x * blockIdx.x + threadIdx.x;
Packed<float, PackSize> sum_pack(0.0);
const auto* pack_ptr = reinterpret_cast<const Packed<float, PackSize>*>(g_idata);
// 【网格步长循环】linear_index 是"pack 的下标",上界是 n/PackSize(pack 的个数)。
// 每次跨 blockDim.x*gridDim.x 个 pack,也就是"整个网格的线程数"。
// 于是:每个线程读一个 pack,这和"相邻线程读相邻 pack"是同一件事 ——
// 一个 warp 一次读 32 个 pack = 512 字节连续内存 = 4 条 128 字节的事务,完全合并。
for(int32_t linear_index = i; linear_index < n / PackSize; linear_index+=blockDim.x * gridDim.x){
Packed<float, PackSize> g_idata_load = pack_ptr[linear_index];
sum_pack += g_idata_load;
}
float PackReduceVal = PackReduce<float, PackSize>(sum_pack);
pack_ptr 把传进来的 float* 换个视图当 Packed<float,4>* 用,于是循环上界从 n 变成 n / PackSize,每次下标前进 1 个 pack 就是 4 个 float。
循环写成 linear_index += blockDim.x * gridDim.x 这种“网格步长”形式,好处是 kernel 不挑 grid 大小:block 少了每个线程多绕几圈,block 多了每线程少绕几圈,结果都一样。这也正是 GetNumBlocks 敢把 block 数压到 288 的前提。
访存模式和 v0 的树形归约完全不同:相邻线程读相邻的 pack。一个 warp 的 32 个 lane 一次读 32 个 pack = 512 字节连续内存,正好是 4 条完整的 128 字节事务,合并度 100%。这一句是整个优化里最值钱的地方。
注意累加发生在 sum_pack 这个寄存器里的 pack 上——4 个 float 各自独立累加,4 路并行,不会像单个 float 累加那样被一条依赖链卡住。
④ PackReduce:把 4 个分量收成 1 个
// 【PackReduce:把一个 pack 压成 1 个 T】就是把这 4 个 lane 加起来。
// 注意这一步是**串行的 3 次加法**(一条链),但那只是收尾一次,
// 前面几百次累加都是 4 路并行的。
template<typename T, int pack_size>
__device__ T PackReduce(Packed<T, pack_size> pack){
T res = 0.0;
#pragma unroll
for(int i = 0; i < pack_size; i++){
res += pack.elem[i];
}
return res;
}
三步串行加法。看起来是加了 3 步延迟,但前面几百次累加都是 4 路并行的,这里只是收尾一次,摊销下来可以忽略。
⑤ warpReduceSum:warp 内的 5 步归约
// 【warp 内归约:这一版换成了循环写法】与 v7 的 5 个 if 相比:
// - 没有模板参数、没有编译期门限,delta 从 16 一路除到 1,5 步恒定;
// - 每次都用 0xffffffff 全掩码(32 个 lane 全参与)。
// 代价是"第二级只归约 8 个值"时也要走满 5 步 —— 好在 lane 8~31 手里是 0,
// 多走的那两步只是加了 0,不影响结果(见调用点那句三元表达式的注释)。
template<typename T>
__device__ T warpReduceSum(T val){
for(int lane_mask = 16; lane_mask > 0; lane_mask /=2){
val += __shfl_down_sync(0xffffffff, val, lane_mask);
}
return val;
}
__shfl_down_sync(mask, val, delta) 的语义是:每个 lane 从“自己下标 + delta”那个 lane 取一个值回来。配合 val += ,delta 从 16 减到 1 走完 5 步,lane0 手里就是 32 份的和。
这里和 v7 有个区别值得点出:v7 用 5 个 if (WarpSize >= N) 配模板参数,把用不到的步数在编译期剪掉;v8 改成了定长 5 步的循环,每次都用 0xffffffff 全掩码。为什么敢这么写——因为第二级归约时只有 8 个值有效,lane 8~31 手里是 0(见下面第 ⑥ 段的三元表达式),多走的几步只是在加 0,不影响结果。代价是固定 5 步、换来实现简单。
⑥ 跨 warp 收尾:shared memory + 一个栅栏
static __shared__ float warpLevelSums[kWarpSize];
const int laneId = threadIdx.x % kWarpSize;
const int warpId = threadIdx.x / kWarpSize;
float sum = warpReduceSum<float>(PackReduceVal);
__syncthreads();
if(laneId == 0 )warpLevelSums[warpId] = sum;
__syncthreads();
sum = (threadIdx.x < blockDim.x / kWarpSize) ? warpLevelSums[laneId] : 0;
if (warpId == 0) sum = warpReduceSum<float>(sum);
if (threadIdx.x == 0) g_odata[blockIdx.x] = sum;
一个 block 有 256 个线程 = 8 个 warp。第 ⑤ 步做完,每个 warp 的 lane0 手里有一个 warp 内的和,接下来要把 8 个这样的值收成 1 个。线程之间不能直接读对方的寄存器,所以走 shared memory 中转。
第 ④ 步那句三元表达式 (threadIdx.x < blockDim.x / kWarpSize) ? warpLevelSums[laneId] : 0 是关键:只有前 8 个线程取到真值,第 8~31 号线程取 0。这样 0 号 warp 拼出来的是一个 32 长度的向量,前 8 位有效、后面全是 0,正好能喂给第 ⑤ 步那个定长 5 步的 warpReduceSum。
第一次栅栏(第 137 行)在 warpLevelSums[warpId] = sum 之前:它保证所有 warp 都已经算完自己的 warpReduceSum,避免某个慢 warp 的值还没算好就被别人读走。
第二次栅栏(第 141 行)在写入之后:保证 8 个值都写进 shared memory 了,0 号 warp 才能安全地读。这两道栅栏缺一个都会读到脏数据,而且这种错是间歇性的,很难查。
⑦ main:两阶段启动
int main(){
float *a=(float *)malloc(N*sizeof(float));
float *d_a;
cudaMalloc((void **)&d_a,N*sizeof(float));
const int64_t block_num = GetNumBlocks(N);
printf("Block num is: %ld \n", block_num);
// 【out 只有 1 个元素】因为最后要的是总和
float *out=(float *)malloc(sizeof(float));
float *g_odata;
cudaMalloc((void **)&g_odata,block_num*sizeof(float));
// 【给阶段2 用的单元素输出缓冲】
float *g_final_data;
cudaMalloc((void **)&g_final_data,1*sizeof(float));
// 【输入 a[i] = i】
for(int i=0;i<N;i++){
a[i]=i;
}
// 【标准答案:等差数列求和】N*(首项+末项)/2。这里的算式假设 a[i] 就是 i,
float *res=(float *)malloc(sizeof(float));
res[0] = N * (a[0] + a[N - 1]) / 2;
cudaMemcpy(d_a,a,N*sizeof(float),cudaMemcpyHostToDevice);;
// 【阶段1:864 个 block,每个 block 出一个部分和,共 864 个】
reduce_v8<<<block_num, BLOCK_SIZE>>>(d_a, g_odata, N);
// 【阶段2:只起 1 个 block,把 864 个部分和收成 1 个】
// 同一个 kernel 复用:此时"数据"是 g_odata,n = block_num。
// 1 个 block 的 8 个 warp 先各自归约、再在 0 号 warp 里收成 1 个 ——
// 和阶段1 内部的机制完全一样,所以不用另写一个 kernel。
reduce_v8<<<1, BLOCK_SIZE>>>(g_odata, g_final_data, block_num);
cudaMemcpy(out,g_final_data,1*sizeof(float),cudaMemcpyDeviceToHost);
// 【对拍】只有一个数,直接比。
if(check(out,res,1))printf("the answer is right\n");
else{
printf("the answer is wrong\n");
printf("out[0]: %lf\n",out[0]);
printf("res[0]: %lf\n", res[0]);
}
cudaFree(d_a);
cudaFree(g_odata);
free(a);
free(out);
return 0;
}
阶段 1 用 GetNumBlocks(N) 算出的 block 数跑,产出 block_num 个部分和;阶段 2 复用同一个 kernel,只开 1 个 block,把 block_num 个部分和收成 1 个数。
复用能成立,是因为这个 kernel 本身就是通用的——它不关心“数据”是原始数组还是上一阶段的部分和,只要给出首地址和长度就能归约。阶段 2 时 n 传的是 block_num(本机 288),小于一个 block 的 256 线程 × 4 pack = 1024,所以网格步长循环每线程最多走一圈就结束了。
对拍用的标准答案是等差数列求和公式 N*(a[0]+a[N-1])/2,这里输入是 a[i] = i。注意 check() 用的是精确相等比较(out[i] != res[i]),浮点求和本来就有舍入误差,这个对拍能过是因为两边恰好都算出了同一个值——它很脆,换个数据分布或换个 block 数就可能失败。这属于“验证代码”而不是“产品代码”。
完整代码
下面是 v8 的完整实现,即仓库里的 reduce/v8/reduce_v8_shfl_down_sync_pack.cpp,一字未改(保留了原有的中文注释和那两组重复的 #define)。
这段代码前面出现的 #include <cuda.h>、<time.h>、<iostream>、<cuda_fp16.h> 在本文件里其实都没用到,是随模板一起带过来的。
#include <cuda.h>
#include <cuda_runtime.h>
#include <time.h>
#include <algorithm>
#include <iostream>
#include <cuda_fp16.h>
using namespace std;
#define PackSize 4
#define kWarpSize 32
#define N 32 * 1024 * 1024
// 【PackSize:一个 pack 装几个 float】4 个 float = 16 字节,是一次访存能拿的最大宽度。
#define PackSize 4
#define kWarpSize 32
#define N 32 * 1024 * 1024
constexpr int BLOCK_SIZE = 256;
constexpr int kBlockSize = 256;
// 【kNumWaves:要发几波】1 表示"只要发一波就够"(一波刚好把 SM 填满)。
constexpr int kNumWaves = 1;
// 【GetNumBlocks:按硬件规格算 block 数(OneFlow 的做法)】
int64_t GetNumBlocks(int64_t n) {
// 【第 1 步:拿到当前设备号】cudaGetDevice 写进 dev。
int dev;
{
cudaError_t err = cudaGetDevice(&dev);
if (err != cudaSuccess) { return err; }
}
// 【第 2 步:SM 个数】cudaDevAttrMultiProcessorCount。A100 是 108。
int sm_count;
{
cudaError_t err = cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, dev);
if (err != cudaSuccess) { return err; }
}
// 【第 3 步:每个 SM 最多常驻多少线程】cudaDevAttrMaxThreadsPerMultiProcessor。
int tpm;
{
cudaError_t err = cudaDeviceGetAttribute(&tpm, cudaDevAttrMaxThreadsPerMultiProcessor, dev);
if (err != cudaSuccess) { return err; }
}
int64_t num_blocks = std::max<int64_t>(1, std::min<int64_t>((n + kBlockSize - 1) / kBlockSize,
sm_count * tpm / kBlockSize * kNumWaves));
return num_blocks;
}
//alignas(sizeof(T) * pack_size) —— 整段的开关
//含义:这个类型的每一个对象的起始地址,都必须是 N 的倍数。这里 N = sizeof(float) × 4 = 16。它把结构体的对齐要求抬到 16 字节,没有这句 alignas,结构体可能只有 4 字节对齐,"一次读 16 字节"就没法保证了。
template<typename T, int pack_size>
struct alignas(sizeof(T) * pack_size) Packed {
__device__ Packed(T val){
#pragma unroll
for(int i = 0; i < pack_size; i++){
elem[i] = val;
}
}
__device__ Packed() {
// do nothing
}
// 【匿名 union】union 的成员共用同一块内存,每个成员的偏移都是 0。
// 这里只有 elem 一个成员,所以等价于直接写 `T elem[pack_size];`。
// 匿名 → 没有名字,elem 直接就是 Packed 的成员,写 p.elem 即可。
union {
T elem[pack_size];
};
__device__ void operator+=(Packed<T, pack_size> packA){
#pragma unroll
for(int i = 0; i < pack_size; i++){
elem[i] += packA.elem[i];
}
}
};
// 【PackReduce:把一个 pack 压成 1 个 T】就是把这 4 个 lane 加起来。
// 注意这一步是**串行的 3 次加法**(一条链),但那只是收尾一次,
// 前面几百次累加都是 4 路并行的。
template<typename T, int pack_size>
__device__ T PackReduce(Packed<T, pack_size> pack){
T res = 0.0;
#pragma unroll
for(int i = 0; i < pack_size; i++){
res += pack.elem[i];
}
return res;
}
// 【warp 内归约:这一版换成了循环写法】与 v7 的 5 个 if 相比:
// - 没有模板参数、没有编译期门限,delta 从 16 一路除到 1,5 步恒定;
// - 每次都用 0xffffffff 全掩码(32 个 lane 全参与)。
// 代价是"第二级只归约 8 个值"时也要走满 5 步 —— 好在 lane 8~31 手里是 0,
// 多走的那两步只是加了 0,不影响结果(见调用点那句三元表达式的注释)。
template<typename T>
__device__ T warpReduceSum(T val){
for(int lane_mask = 16; lane_mask > 0; lane_mask /=2){
val += __shfl_down_sync(0xffffffff, val, lane_mask);
}
return val;
}
__global__ void reduce_v8(float *g_idata,float *g_odata, unsigned int n){
// 【本线程在网格里的全局编号
unsigned int i = blockDim.x * blockIdx.x + threadIdx.x;
Packed<float, PackSize> sum_pack(0.0);
const auto* pack_ptr = reinterpret_cast<const Packed<float, PackSize>*>(g_idata);
// 【网格步长循环】linear_index 是"pack 的下标",上界是 n/PackSize(pack 的个数)。
// 每次跨 blockDim.x*gridDim.x 个 pack,也就是"整个网格的线程数"。
// 于是:每个线程读一个 pack,这和"相邻线程读相邻 pack"是同一件事 ——
// 一个 warp 一次读 32 个 pack = 512 字节连续内存 = 4 条 128 字节的事务,完全合并。
for(int32_t linear_index = i; linear_index < n / PackSize; linear_index+=blockDim.x * gridDim.x){
Packed<float, PackSize> g_idata_load = pack_ptr[linear_index];
sum_pack += g_idata_load;
}
float PackReduceVal = PackReduce<float, PackSize>(sum_pack);
static __shared__ float warpLevelSums[kWarpSize];
const int laneId = threadIdx.x % kWarpSize;
const int warpId = threadIdx.x / kWarpSize;
float sum = warpReduceSum<float>(PackReduceVal);
__syncthreads();
if(laneId == 0 )warpLevelSums[warpId] = sum;
__syncthreads();
sum = (threadIdx.x < blockDim.x / kWarpSize) ? warpLevelSums[laneId] : 0;
if (warpId == 0) sum = warpReduceSum<float>(sum);
if (threadIdx.x == 0) g_odata[blockIdx.x] = sum;
}
// ============================================================================
// check —— 精确相等比较(关于它为什么脆,见文件头那段)
// ============================================================================
bool check(float *out,float *res,int n){
for(int i=0;i<n;i++){
if(out[i]!=res[i])
return false;
}
return true;
}
// ============================================================================
// main —— 两次启动:阶段1 出 block_num 个部分和,阶段2 收成 1 个
// ============================================================================
int main(){
float *a=(float *)malloc(N*sizeof(float));
float *d_a;
cudaMalloc((void **)&d_a,N*sizeof(float));
const int64_t block_num = GetNumBlocks(N);
printf("Block num is: %ld \n", block_num);
// 【out 只有 1 个元素】因为最后要的是总和
float *out=(float *)malloc(sizeof(float));
float *g_odata;
cudaMalloc((void **)&g_odata,block_num*sizeof(float));
// 【给阶段2 用的单元素输出缓冲】
float *g_final_data;
cudaMalloc((void **)&g_final_data,1*sizeof(float));
// 【输入 a[i] = i】
for(int i=0;i<N;i++){
a[i]=i;
}
// 【标准答案:等差数列求和】N*(首项+末项)/2。这里的算式假设 a[i] 就是 i,
float *res=(float *)malloc(sizeof(float));
res[0] = N * (a[0] + a[N - 1]) / 2;
cudaMemcpy(d_a,a,N*sizeof(float),cudaMemcpyHostToDevice);;
// 【阶段1:864 个 block,每个 block 出一个部分和,共 864 个】
reduce_v8<<<block_num, BLOCK_SIZE>>>(d_a, g_odata, N);
// 【阶段2:只起 1 个 block,把 864 个部分和收成 1 个】
// 同一个 kernel 复用:此时"数据"是 g_odata,n = block_num。
// 1 个 block 的 8 个 warp 先各自归约、再在 0 号 warp 里收成 1 个 ——
// 和阶段1 内部的机制完全一样,所以不用另写一个 kernel。
reduce_v8<<<1, BLOCK_SIZE>>>(g_odata, g_final_data, block_num);
cudaMemcpy(out,g_final_data,1*sizeof(float),cudaMemcpyDeviceToHost);
// 【对拍】只有一个数,直接比。
if(check(out,res,1))printf("the answer is right\n");
else{
printf("the answer is wrong\n");
printf("out[0]: %lf\n",out[0]);
printf("res[0]: %lf\n", res[0]);
}
cudaFree(d_a);
cudaFree(g_odata);
free(a);
free(out);
return 0;
}
文件扩展名是 .cpp,nvcc 会按纯 C++ 处理,认不出 __global__ 和 <<<>>>,直接编会报一堆 “threadIdx was not declared” —— 必须加 -x cu 强制走 CUDA 前端:
nvcc -x cu -arch=sm_120 -O3 -o reduce_v8 reduce_v8_shfl_down_sync_pack.cpp && ./reduce_v8
另外这份文件里没有计时代码(它只打印 block 数和对拍结果),本文表格里的 v8 耗时是我另加了一段与 v0~v7 完全相同的计时后才测出来的。
实测性能对比分析
数据规模统一为 N = 32×1024×1024 = 33,554,432 个 float(输入 128 MiB),每个 kernel 跑 13 次取后 10 次最小值。带宽按“读取的 134.2 MB ÷ 耗时”算,理论峰值取 RTX 5070 的 672 GB/s。
本机实测(RTX 5070)
| 版本 | 这一版做了什么 | 耗时 (µs) | 等效带宽 (GB/s) | 占峰值 | 相对 v0 |
|---|---|---|---|---|---|
| v0 基线 | 共享内存树形归约 | 465.1 | 288.6 | 42.9% | 1.00× |
| v1 | 跨步寻址 Interleaved | 501.9 | 267.4 | 39.8% | 0.93× |
| v2 | 顺序寻址 Bank-free | 457.7 | 293.3 | 43.6% | 1.02× |
| v3 | 空闲线程多算一次 | 240.8 | 557.4 | 82.9% | 1.93× |
| v4 | 最后 6 轮交 0 号 warp | 213.3 | 629.3 | 93.6% | 2.18× |
| v5 | blockSize 变模板参数 | 213.2 | 629.7 | 93.7% | 2.18× |
| v6 | 每线程多算 128 个 | 211.8 | 633.7 | 94.3% | 2.20× |
| v7 | 改用 __shfl_down_sync | 212.0 | 633.1 | 94.2% | 2.19× |
| v8 最终版 | 16 字节 pack + 自动算 block 数 | 211.7 | 634.0 | 94.3% | 2.20× |
先给一个参照物:同一台机器上纯 float4 设备间拷贝(等量读+写)能跑出 575 GB/s。reduce 是几乎纯读的负载(读 128 MiB、写几百字节),所以能跑得更高——v8 的 634 GB/s 已经是这台卡的读带宽上限附近了。
四个台阶,和它们各自的理由
第一台阶:v0 → v2 几乎没动(465 → 458 µs),v1 反而更慢了。这一段最容易被误解成“优化无效”。其实 v1/v2 修的是分支发散——v0 用 tid % (2*s) == 0 做判据,同一个 warp 里一半线程干活一半闲置;v1 换成 index = 2*s*tid 消除了取模,却让访存地址变成跨步的,撞上 shared memory 的 bank conflict,快了发散、慢了访存,净效果是慢了 8%。v2 把判据改成 if (tid < s) 且 s 递减,地址重新连续、bank conflict 归零,才勉强追平 v0。
根因是这三个版本的瓶颈根本不在同一处:它们都还在用“一个 block 认领 256 个元素、开 131072 个 block”这种碎粒度划分,访存还没喂饱,归约本身的开销就显不出来。换句话说,前面几版优化的是一个还没成为瓶颈的东西。
第二台阶:v3 一次性砍掉一半(458 → 241 µs)。v3 让装载阶段空闲的线程多算一次(sdata[tid] = g_idata[i] + g_idata[i+blockDim.x]),一个 block 认领的元素从 256 变成 512,block 数直接减半。这一版的收益比 v1+v2 加起来大得多,因为它改的是划分粒度,而不是某条指令。
第三台阶:v4/v5 继续小步快跑(241 → 213 µs)。v4 把最后 6 轮交给 0 号 warp 用 volatile 手写展开,栅栏从 9 道减到 3 道;v5 把 blockSize 变成模板参数,让 if 分支在编译期就被剪掉。两道栅栏和几个分支省下的时间已经进入几十微秒量级。
第四台阶:v6/v7/v8 基本重合(211.7~212.0 µs)。这三版各有明确改进——v6 把每线程处理量提到 128 个元素、block 数从 65536 降到 1024;v7 把 warp 内归约换成 __shfl_down_sync、shared memory 写入量从 256 个降到 8 个;v8 加上 16 字节打包和自动 block 数。但耗时几乎没变,因为已经撞到显存带宽的上限:94.3% 的峰值占用率,再优化计算侧也不会更快了。
v6 的装载循环写成 128 次寄存器累加,ptxas 已经把它合并成“寄存器里加 128 次、最后写一次 shared memory”,所以 v8 用 16 字节 pack 省下的访存指令,在 v6 里其实也已经被合并掉了大半。带宽到顶之后,这些差异都落在测量噪声里。
v8 的两阶段合计耗时是 212.8 µs,只比阶段 1 单独跑(211.7 µs)多 1.1 µs——阶段 2 只处理 288 个数,相对 128 MiB 的主循环可以忽略。这也说明为了“收成一个数”付出的额外代价很小。
和 A100 上的数据对照
仓库 README 里有一份原作者在 A100-PCIE-40GB(峰值带宽 1555 GB/s,CUDA 11.8)上用 Nsight Compute 测的数据。同代码、同数据规模,两张卡上的曲线形状很不一样:
| 版本 | A100 耗时 (µs) | A100 占峰值 | A100 相对 v0 |
|---|---|---|---|
| v0 基线 | 990.66 | 39.57% | 1.00× |
| v1 | 479.58 | 81.74% | 2.06× |
| v2 | 462.02 | 84.81% | 2.14× |
| v3 | 244.16 | 83.16% | 4.06× |
| v4 | 167.10 | 54.10% | 5.93× |
| v5 | 158.78 | 56.94% | 6.24× |
| v6 | 105.47 | 85.75% | 9.39× |
| v7 | 101.70 | 87.42% | 9.74× |
| v8 最终版 | 99.71 | 89.76% | 9.94× |
第一个差别:A100 上 v1 比 v0 快一倍,本机反而慢 8%。A100 的 shared memory 有 32 个 bank、全套 8 路 bank conflict 惩罚很重,但它对分支发散的容忍度也低;而 RTX 5070 这一代的两者代价比例不同,导致 v1 的“修发散、引入 bank conflict”这笔交易在两张卡上盈亏相反。
第二个差别:A100 的加速比做到 9.94×,本机只有 2.20×。不是因为本机优化得差,而是因为基线本身就不一样:A100 的 v0 只跑到峰值的 39.6%,留出了巨大空间;本机的 v0 一起步就有 42.9%,而 RTX 5070 的绝对带宽只有 A100 的四成多(672 vs 1555 GB/s),基线离带宽墙更近,能爬的坡自然更短。
绝对耗时和加速比不能跨卡直接比——它们同时受显存带宽、SM 数量、bank 结构、编译器版本影响。有意义的是看同一张卡上每一版相对前一版的方向和幅度,以及背后的原因是否成立。
小结
Reduce 的九个版本,本质上是三次不同层面的改动:
改访存模式(v1、v2):修分支发散、消 bank conflict。在本机这张卡上收益接近零,因为瓶颈还不在这里。
改划分粒度(v3、v6):让每个 block 认领更多数据、让每个线程处理更多元素。v3 一步就砍掉一半时间,是全部九版里收益最大的一次改动。
改归约方式(v4、v5、v7):减少栅栏、把 shared memory 中转换成 warp shuffle、用编译期常量剪掉分支。收益稳定但越来越小。
v8 站在所有这些之上,又补了两件事:用 16 字节的 pack 把访存宽度拉满,用 GetNumBlocks 把 block 数从硬编码的 131072 降到按硬件规格算出来的 288。
最有价值的一条经验或许是:要先把瓶颈定位对,再动手。v1 和 v2 的优化本身没有错——消发散、消 bank conflict 都是标准手法——但它们优化的是一个还没成为瓶颈的地方,所以一个变慢、一个白干。如果只按“标准优化清单”一条条往下做,很容易停在 43% 的带宽占用率上,还以为已经优化完了。
下一篇:逐点运算——同样是带宽受限,但这次要处理的是 half 类型的 2 字节访存,看 OneFlow 的模板怎么用编译期计算把访存指令再压一轮。