CUDA算子优化(1):Reduce

Reduce(规约)是GPU编程中最基础,也是最能体现并行思维的算子之一。本文将一步一步带你优化Reduce sum算子,让你理解怎么写出更快的kernel。

1. Reduce算子基础

Reduce是一类"多输入->单输出"的操作。比如下列操作:

  • Sum Reduce:$\sum_{i=0}^{N-1} x_{i}$ (输入为长度N的数组,输出为所有元素的和)
  • Max/Min Reduce:$\max \left(x_{0}, x_{1}, \ldots, x_{N-1}\right)$ (输入是长度为N的数组,输出为数组的最大/最小值)
  • Dot Product:$\sum_{i=0}^{N-1} a_i \cdot b_i$ (输入是长度都为N的数组a和b,输出为它们的点积值)

1.1 性能瓶颈分析
GPU Kernel的性能上限由计算量访存量共同决定的。对于sum Reduce来说,每个元素读取一次(1次load)就做一次加法(1次FLOP),算术强度为$\frac{1 \text{FLOP}}{4 \text{Byte}} = 0.25 \text{FLOP/Byte}$,说明Reduce是典型的访存密集型操作,优化的核心在于提高内存带宽的利用率,而不是减少计算次数

2. 版本V0朴素并行规约

下图所示,以N=8个元素为例,我们仅需要N/2=4个线程即可;首先,第一轮,每个线程计算两个相邻元素的和,其中步长stride=1。

sum_reduce1

经过上述一轮后,就剩下data[0]、data[2]、data[4]和data[6],求这几个元素的和即可,第二轮和上一轮一样,线程0和线程2分别计算data[0]+data[2],data[4]+data[6],其中步长stride=2。

sum_reduce2

第三轮让线程0计算data[0]+data[4]即可,其中步长stride=4。这样代码就很好写了。

sum_reduce3

#include <stdio.h>
#include <stdlib.h>


__global__ void kernel(float *d_out, float *d_data, int N) {
int tid = threadIdx.x;
int i = 2 * tid;

for (int stride = 1; stride <= N / 2; stride *= 2) {
if (i % (2 * stride) == 0) {
d_data[i] += d_data[i + stride];
}
__syncthreads();
}

if (tid == 0) {
d_out[0] = d_data[0];
}
}

float sum_cpu(float *h_data, int N) {
float sum = 0.0f;
for (int i = 0; i < N; ++i) {
sum += h_data[i];
}
return sum;
}

int main() {
int N = 2048;
size_t bytes = N * sizeof(float);
float *h_data = (float *)malloc(bytes);
float *h_out = (float *)malloc(sizeof(float));
for (int i = 0; i < N; ++i) {
h_data[i] = (float)rand() / RAND_MAX;
}

float sum = sum_cpu(h_data, N);

float *d_data, *d_out;
cudaMalloc(&d_data, bytes);
cudaMalloc(&d_out, sizeof(float));
cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);

kernel<<<1, 1024>>>(d_out, d_data, N);
cudaMemcpy(h_out, d_out, sizeof(float), cudaMemcpyDeviceToHost);

printf("sum:%f, h_out:%f", sum, h_out[0]);

free(h_data);
free(h_out);
cudaFree(d_data);
cudaFree(d_out);

return 0;
}

这个方法有 1 个问题,Warp Divergence。
Warp Divergence:GPU 最小的调度单位是Warp(32 个线程)。这 32 个线程都执行同一条指令,如果部分线程满足if 条件,部分线程不满足,就会发生分支分歧,GPU 就会分 2 次执行,先执行满足if 条件的指令,再执行else 条件的指令,这样就会导致性能下降。

3. 版本V1消除Warp

改进思路:改变步长的方向,让步长从大到小,而不是从小到大。整体思路如下图,第一轮,以N=8个元素为例,让data[0]+data[4],data[1]+data[5],data[2]+data[6],data[3]+data[7],步长为stride = 4.
sum_reduce4

第二轮,让data[0]+data[2],data[1]+data[3],步长为stride=2。
sum_reduce5

第三轮,让data[0]+data[1],步长为stride=1
sum_reduce6

#include <stdio.h>
#include <stdlib.h>


#define BLOCK_SIZE 1024

__global__ void reduce_kernel(float *d_out, float *d_data, int N) {
int tid = threadIdx.x;

for (int stride = blockDim.x; stride >= 1; stride /= 2) {
if (tid < stride) {
d_data[tid] += d_data[tid + stride];
}
__syncthreads();
}

if (tid == 0) {
d_out[0] = d_data[0];
}
}

float sum_cpu(float *data, int N) {
float sum = 0.0;
for (int i = 0; i < N; ++i) {
sum += data[i];
}
return sum;
}

int main() {
int N = 2048;
size_t bytes = N * sizeof(float);
float *h_data = (float *)malloc(bytes);
float *h_out = (float *)malloc(sizeof(float));
for (int i = 0; i < N; ++i) {
h_data[i] = (float) rand() / RAND_MAX;
}
float sum = sum_cpu(h_data, N);

float *d_data, *d_out;
cudaMalloc(&d_data, bytes);
cudaMalloc(&d_out, sizeof(float));
cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);

reduce_kernel<<<1, BLOCK_SIZE>>>(d_out, d_data, N);
cudaMemcpy(h_out, d_out, sizeof(float), cudaMemcpyDeviceToHost);

printf("sum = %f, h_out = %f\n", sum, h_out[0]);

free(h_data);
free(h_out);
cudaFree(d_data);
cudaFree(d_out);

return 0;
}

这样,以N=256为例,我们对比下第一种方法,这种方法效率是如何提升的。
第一轮:

活跃线程 Warp Divergence
版本V0 每个Warp 只有偶数线程工作 50% 利用率
版本V1 tid 0-127 活跃,恰好 4 个完整的Warp 无分化

第二轮

活跃线程 Warp Divergence
版本V0 每 4 个线程只有一个工作 25% 利用率
版本V1 tid 0-63 活跃,恰好 2 个完整的Warp 无分化

第三轮

活跃线程 Warp Divergence
版本V0 每 8 个线程只有一个工作 12.5% 利用率
版本V1 tid 0-31 活跃,恰好 1 个完整的Warp 无分化

依次类推,版本V0越来越差,版本V1相比于V0有较大的提升

3. 版本V2共享内存

微信图片2026-06-27_124138_056
上述两种方法均是对全局内存的访问,共享内存的读写速度仅次于寄存器,对整个线程块可见。它的主要作用是减少对全局内存的访问,我们可以利用共享内存加快核函数的运行时间。

共享内存一般使用_shared_关键字,先将全局内存中的数据搬运到共享内存中,然后求和操作在共享内存中进行。

#include <stdio.h>
#include <stdlib.h>


#define BLOCK_SIZE 1024

__global__ void reduce_kernel(float *d_out, float *d_data, int N) {
int tid = threadIdx.x;

__shared__ float smem[BLOCK_SIZE];

smem[tid] = d_data[tid] + d_data[tid + BLOCK_SIZE];
__syncthreads();

for (int stride = BLOCK_SIZE / 2; stride >= 1; stride /= 2) {
if (tid < stride) {
smem[tid] += smem[tid + stride];
}
__syncthreads();
}

if (tid == 0) {
d_out[0] = smem[0];
}
}

float sum_cpu(float *data, int N) {
float sum = 0.0;
for (int i = 0; i < N; ++i) {
sum += data[i];
}
return sum;
}

int main() {
int N = 2048;
size_t bytes = N * sizeof(float);
float *h_data = (float *)malloc(bytes);
float *h_out = (float *)malloc(sizeof(float));
for (int i = 0; i < N; ++i) {
h_data[i] = (float) rand() / RAND_MAX;
}
float sum = sum_cpu(h_data, N);

float *d_data, *d_out;
cudaMalloc(&d_data, bytes);
cudaMalloc(&d_out, sizeof(float));
cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);

reduce_kernel<<<1, BLOCK_SIZE>>>(d_out, d_data, N);
cudaMemcpy(h_out, d_out, sizeof(float), cudaMemcpyDeviceToHost);

printf("sum = %f, h_out = %f\n", sum, h_out[0]);

free(h_data);
free(h_out);
cudaFree(d_data);
cudaFree(d_out);

return 0;
}

4. 版本V3多block情况

当数据量比较大的时候,一个block显然是不够用的,每种GPU的block都有最大的上限,这个时候就需要使用多block。这里很明显的思考是,为每个block创建一个共享内存,分别负责某一块的数据的求和,最后将得到的所有求和值,再求一次和。

#include <stdio.h>
#include <stdlib.h>


#define BLOCK_SIZE 1024

__global__ void reduce_kernel(float *d_out, float *d_data, int N) {
int tid = threadIdx.x + blockIdx.x * blockDim.x;
int i = threadIdx.x;

__shared__ float smem[BLOCK_SIZE];
if (tid < N) {
smem[i] = d_data[tid];
} else {
smem[i] = 0.0f;
}
__syncthreads();

for (int stride = BLOCK_SIZE / 2; stride >= 1; stride /= 2) {
if (i < stride && tid < N) {
smem[i] += smem[i + stride];
}
__syncthreads();
}

if (i == 0) {
atomicAdd(&d_out[0], smem[0]);
}
}

float sum_cpu(float *data, int N) {
float sum = 0.0;
for (int i = 0; i < N; ++i) {
sum += data[i];
}
return sum;
}

int main() {
int N = 18299;
size_t bytes = N * sizeof(float);
float *h_data = (float *)malloc(bytes);
float *h_out = (float *)malloc(sizeof(float));
for (int i = 0; i < N; ++i) {
h_data[i] = (float) rand() / RAND_MAX;
}
float sum = sum_cpu(h_data, N);

float *d_data, *d_out;
cudaMalloc(&d_data, bytes);
cudaMalloc(&d_out, sizeof(float));
cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);

dim3 grid_size = (N + BLOCK_SIZE - 1) / BLOCK_SIZE;
reduce_kernel<<<grid_size, BLOCK_SIZE>>>(d_out, d_data, N);

cudaMemcpy(h_out, d_out, sizeof(float), cudaMemcpyDeviceToHost);

printf("sum = %f, h_out = %f\n", sum, h_out[0]);

free(h_data);
free(h_out);
cudaFree(d_data);
cudaFree(d_out);

return 0;
}

5. 版本V4线程粗化

回顾之前的方法,每一个线程只负责处理一个元素,以规约的第一轮为例(blockDim=256),只有前128个线程在工作,后128个线程彻底闲置了(他们仅仅只是把数据从全局内存搬运到了共享内存)。
线程粗化的意思是让一个线程处理多个元素,如果每个线程在加载阶段就负责 2 个元素并预先求和,那么用同样 256 个线程可以处理 512 个数据,block的数量却可以减半,grid 更小,调度开销降低

#include <stdio.h>
#include <stdlib.h>


#define BLOCK_SIZE 512
#define COARSEN 4

__global__ void kernel(float *d_out, float *d_data, int N) {
int tid = threadIdx.x;
int block_idx = blockDim.x * blockIdx.x * COARSEN;

__shared__ float smem[BLOCK_SIZE];
float local_sum = 0.0f;
for (int i = 0; i < COARSEN; ++i) {
int idx = block_idx + tid * COARSEN + i;
if (idx < N) {
local_sum += d_data[idx];
}
}
smem[tid] = local_sum;
__syncthreads();

for (int stride = BLOCK_SIZE / 2; stride >= 1; stride /= 2) {
if (tid < stride) {
smem[tid] += smem[tid +stride];
}
__syncthreads();
}

if (tid == 0) {
atomicAdd(&d_out[0], smem[0]);
}
}

float sum_cpu(float *h_data, int N) {
float sum = 0.0f;
for (int i = 0; i < N; ++i) {
sum += h_data[i];
}
return sum;
}

void init_float_rand(float *arr, int n, float low, float high) {
for (int i = 0; i < n; i++) {
float r = (float)rand() / (float)RAND_MAX;
arr[i] = low + (high - low) * r;
}
}

int main() {
int N = 123341;
size_t bytes = N * sizeof(float);
float *h_data = (float *)malloc(bytes);
float *h_out = (float *)malloc(sizeof(float));
init_float_rand(h_data, N, -1.0, 1.0);

float sum = sum_cpu(h_data, N);
float *d_data, *d_out;
cudaMalloc(&d_data, bytes);
cudaMalloc(&d_out, sizeof(float));
cudaMemcpy(d_data, h_data, bytes, cudaMemcpyHostToDevice);

dim3 grid = (N + BLOCK_SIZE * COARSEN - 1) / (BLOCK_SIZE * COARSEN);
kernel<<<grid, BLOCK_SIZE>>>(d_out, d_data, N);
cudaMemcpy(h_out, d_out, sizeof(float), cudaMemcpyDeviceToHost);

printf("sum = %f, h_out = %f\n", sum, h_out[0]);

free(h_data);
free(h_out);
cudaFree(d_data);
cudaFree(d_out);
return 0;
}