Reduce 归约求和

算子在做什么,先看一个算例

Reduce(归约)就是把一个数组“收”成一个数的运算:求和、求最大值、求平均值都属于它。叫“归约”是因为每算一步,元素的个数就少一些,最后收敛到一个标量。这篇文章只看最简单也最常用的那个——求和。

它在深度学习里出现的频率比想象中高:LayerNorm 要算均值与方差、Softmax 要算指数和、损失函数要把整个 batch 的误差加起来。这些地方的共同点是——数据在显存里躺着,而结果只有一个数。

输入:a[0], a[1], ..., a[n-1] 输出:s = a[0] + a[1] + ... + a[n-1] (一个标量)

任务本身没有难度,难的是怎么让几千个线程一起算一个和,还不互相踩。这才是 Reduce 成为 CUDA 优化经典入门题的原因。

一个可以手工核对的算例

先看 8 个数的求和。串行做法是一条 7 步的加法链:

串行(一条链,7 步): (((((((a0+a1)+a2)+a3)+a4)+a5)+a6)+a7) 树形(三轮,每轮并行): 第 1 轮: a0+a4 a1+a5 a2+a6 a3+a7 ← 4 个加法同时做 第 2 轮: (a0+a4)+(a2+a6) (a1+a5)+(a3+a7) 第 3 轮: 全部相加 ← 得到总和

以 a = [1, 2, 3, 4, 5, 6, 7, 8] 为例,总和应为 36。树形做法在 GPU 上的对应物,就是先让线程两两相加,再让结果两两相加——每一轮参与的线程数减半,只要 log₂(n) 轮。这正是后文 __shfl_down_sync 那段代码在做的事。

用寄存器模拟一遍 32 个 lane 的 warp 归约,每一步的 delta 就是“和隔多远的那一份相加”:

val 初始:lane i 手里是它自己那部分的和 delta = 16 : lane0 += lane16 lane1 += lane17 ... lane15 += lane31 delta = 8 : lane0 += lane8 lane1 += lane9 ... lane7 += lane15 delta = 4 : lane0 += lane4 ... delta = 2 : lane0 += lane2 ... delta = 1 : lane0 += lane1 → lane0 拿到全部 32 份的和 5 步之后只有 lane0 的值是完整结果,其余 lane 手里的都是“半成品”。 每一轮都是一条 __shfl_down_sync 指令,32 个 lane 同时完成一次配对加法。

为什么不是一个 kernel 干完

GPU 上没有跨 block 的同步机制:一个 kernel 里的不同 block 谁先谁后跑完、能不能同时驻留,都不保证。所以一个 block 没法安全地去读另一个 block 的结果。既然不能在一次启动里收成 1 个数,那就分两步走:

阶段 1:开很多 block,每个 block 负责一段数据,各出一个“部分和” (长度 n 的数组 → 长度 block_num 的部分和数组) 阶段 2:把 block_num 个部分和再归约成 1 个数 (这一步很短,可以只开 1 个 block 做完)

这个两阶段结构在 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。

reduce_v8_shfl_down_sync_pack.cpp : 50-78CUDA C++
//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。

reduce_v8_shfl_down_sync_pack.cpp : 23-47CUDA C++
// 【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;
}
SM 个数 × 每个 SM 最大线程数 ÷ 每 block 线程数 × kNumWaves = 驻留线程总量换算成 block 数(再乘“发几波”) RTX 5070:SM 48 个 × 每 SM 1536 线程 ÷ 256 × 1 = 288 个 block (v0~v6 用的是 131072 个,相差 455 倍)

代码里 std::min 取“够用就好”的那个,std::max(1, ...) 兜底防止数据量太小时算出 0 个 block。注意本机打印的是 288,而注释里写的“864 个 block”是 A100(108 个 SM)的数字——这段注释是从 A100 上抄过来的,没跟着机器更新。

③ kernel 主体:网格步长循环 + 打包累加

reduce_v8_shfl_down_sync_pack.cpp : 106-128CUDA C++
__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 个

reduce_v8_shfl_down_sync_pack.cpp : 80-91CUDA C++
// 【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 步归约

reduce_v8_shfl_down_sync_pack.cpp : 93-104CUDA C++
// 【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 + 一个栅栏

reduce_v8_shfl_down_sync_pack.cpp : 130-147CUDA C++
    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 中转。

laneId = threadIdx.x % 32 本线程在 warp 内的编号 warpId = threadIdx.x / 32 本线程属于第几个 warp ① warpReduceSum → 每个 warp 内部收成 1 个值 ② laneId==0 的线程把值写进 warpLevelSums[warpId] (8 个值) ③ 栅栏 ④ 前 8 个线程各读走 1 个值(其余线程置 0),再由 0 号 warp 做一次 warp 归约 ⑤ threadIdx.x==0 写回 g_odata[blockIdx.x]

第 ④ 步那句三元表达式 (threadIdx.x < blockDim.x / kWarpSize) ? warpLevelSums[laneId] : 0 是关键:只有前 8 个线程取到真值,第 8~31 号线程取 0。这样 0 号 warp 拼出来的是一个 32 长度的向量,前 8 位有效、后面全是 0,正好能喂给第 ⑤ 步那个定长 5 步的 warpReduceSum。

这里为什么要两次 __syncthreads

第一次栅栏(第 137 行)在 warpLevelSums[warpId] = sum 之前:它保证所有 warp 都已经算完自己的 warpReduceSum,避免某个慢 warp 的值还没算好就被别人读走。

第二次栅栏(第 141 行)在写入之后:保证 8 个值都写进 shared memory 了,0 号 warp 才能安全地读。这两道栅栏缺一个都会读到脏数据,而且这种错是间歇性的,很难查。

⑦ main:两阶段启动

reduce_v8_shfl_down_sync_pack.cpp : 164-217CUDA C++
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> 在本文件里其实都没用到,是随模板一起带过来的。

reduce_v8_shfl_down_sync_pack.cpp · 全文 217 行CUDA C++
#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.1288.642.9%1.00×
v1跨步寻址 Interleaved501.9267.439.8%0.93×
v2顺序寻址 Bank-free457.7293.343.6%1.02×
v3空闲线程多算一次240.8557.482.9%1.93×
v4最后 6 轮交 0 号 warp213.3629.393.6%2.18×
v5blockSize 变模板参数213.2629.793.7%2.18×
v6每线程多算 128 个211.8633.794.3%2.20×
v7改用 __shfl_down_sync212.0633.194.2%2.19×
v8 最终版16 字节 pack + 自动算 block 数211.7634.094.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% 的峰值占用率,再优化计算侧也不会更快了。

为什么 v8 只比 v6 快 0.05%

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.6639.57%1.00×
v1479.5881.74%2.06×
v2462.0284.81%2.14×
v3244.1683.16%4.06×
v4167.1054.10%5.93×
v5158.7856.94%6.24×
v6105.4785.75%9.39×
v7101.7087.42%9.74×
v8 最终版99.7189.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 的模板怎么用编译期计算把访存指令再压一轮。