Cuda Rudece算子实现(附4090/h100测试)

1.Ruduce/规约 定义

对整个张量进行一个操作,得到一个标量结果。这个操作可以是 m a x , m i n , s u m max,min,sum max,min,sum等。

2.原理

我们用单线程的思路来实现的话,就是遍历整个张量,然后来做 r e d u c e reduce reduce的操作即可。

c 复制代码
for(int i = 0; i < n; i ++){
	ans = reduce_op(ans , a[i]);
}

但我们这里用 c u d a cuda cuda了,想要加速,就要挖掘可并行的地方。注意到 r e d u c e reduce reduce操作是有结合律的,可以分块,计算每一块的结果,然后再合并。

那么每一块的计算,彼此之间是没有数据依赖的,是可并行的。需要同步的地方只有需要等到每一块都算完,才能开始合并。

我们这里为了简单起见,实际上实现的是,每个 b l o c k block block计算出规约结果即可,接下来把每个 b l o c k block block的结果再规约到一个标量的过程,是类似的。

3.实现

以下实现主要借鉴视频
【CUDA】Reduce规约求和(已完结~)
【CUDA】硬件与工具探索合集(更新ing)

以下的性能分析均在 R T X 4090 RTX4090 RTX4090上实现

3.0 暴力/朴素规约算法

说是暴力,但其实理解起来也没那么简单。需要理解基础的规约算法,还有线程模型

基础的规约算法思路是,每一个分块内,我们每一次,都让问题规模折半,让当前的结果数组中,元素两两进行合并。

假设初始长度是 8 8 8,那么第一次合并后结果位于 [ 0 , 2 , 4 , 6 ] [0,2,4,6] [0,2,4,6],再合并一次,结果位于 [ 0 , 4 ] [0,4] [0,4],以此类推,如下图

这其实类似一个位运算 t r i c k trick trick:给你一个 i n t 64 int64 int64型变量,如何在 6 6 6次位运算操作内,计算出二进制位上 1 1 1的个数的奇偶。答案是每次都 s h i f t shift shift然后异或,这和规约算法是等价的

这个过程我们的并行度在于,每一次问题规模的缩小,进行的操作都是可并行的,读,写的都是不同位置。需要同步的地方只有每一层需要全算完了,才能开始下一层。

这个算法的实现上,可以每一轮都遍历当前有效位置,让相邻的有效位置进行规约操作,想要遍历有效位置,每轮的步长需要倍增。每个分块,我们安排等同于块长的线程数,第 i i i个线程就处理第 i i i个位置的操作,所以第一轮需要操作的线程就是 0 , 2 , 4 , 6 0,2,4,6 0,2,4,6,第二轮就是 0 , 4 0,4 0,4,以此类推

当然这是第一份代码,所以我们需要搭建整体框架,包括 d e v i c e device device侧的暴力计算,资源申请,释放,调用核函数,检查结果正确性。

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256

bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}

__global__ void reduce(int *d_input, int *d_output)
{
    //计算当前块的起始地址
    int *start = d_input + blockIdx.x * blockDim.x;

    for (int i = 1; i < blockDim.x; i *= 2)
    {
        if (threadIdx.x % (i * 2) == 0)
        {
            start[threadIdx.x] += start[threadIdx.x + i];
        }
        //每层需要同步 全算完了才能进入下一层
        __syncthreads();
    }
    //全部计算完了,0号线程保存的就是这个块的结果,拷贝到结果数组
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = start[0];
    }
}

void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / BLOCK_SIZE;

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE; j++)
        {
            sum += input[i * BLOCK_SIZE + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}

int main()
{
    solve();
    return 0;
}

n c u ncu ncu分析性能,看 D u r a t i o n Duration Duration字段, 377 u s 377 us 377us,以此作为基准,衡量后面的优化效果

3.1 使用共享内存/shared_memory

一个比较显然的优化是,上一份代码中,我们操作的都是全局内存,但每个块都只用自己块内的数据,那么可以把每个块的数据拷贝到自己的共享内存中,访问速度更快。

这个简单的优化其实包含一个深刻的思想:局部性和缓存。在 G P U GPU GPU编程诞生前,人们很早就意识到程序的空间局部性,因此发明了层次存储结构和多级缓存,把频繁访问的数据放在访问速度更快的缓存中,只不过操作缓存的接口,并没有留给编程开发者,而是留给系统自行调度,开发者想要利用这个局部性,只能通过调整数组存储顺序,或者在遍历时进行分块来实现

c 复制代码
//调整存储顺序
for(int i = 0; i < n; i ++){
	for(int j = 0; j < m; j ++){
		//ans += a[j][i];
		ans += a[i][j];
	}
}

//分块
//for(int i = 0; i < n; i ++){
//
//}
for(int i = 0; i < n/B; i += B){
	for(int j = 0; j < B; j ++){
	
	}
}

c u d a cuda cuda的共享内存其实就是一个高速缓存,并且把操作缓存的权限给了开发者,于是我们如果知道每个线程块内,频繁使用的数据是哪些,就可以直接拷贝进去,而不需要等着编译器或者操作系统来给我们优化

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256
// 使用共享内存
__global__ void reduce1(int *d_input, int *d_output)
{
    // 计算全局内存下标,拷贝到共享内存
    int *start = d_input + blockIdx.x * blockDim.x;

    __shared__ int sdata[BLOCK_SIZE];

    sdata[threadIdx.x] = start[threadIdx.x];
    //拷贝这里也需要同步
    __syncthreads();

    for (int i = 1; i < blockDim.x; i *= 2)
    {
        if (threadIdx.x % (i * 2) == 0)
        {
            sdata[threadIdx.x] += sdata[threadIdx.x + i];
        }
        __syncthreads();
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}

bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / BLOCK_SIZE;

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE; j++)
        {
            sum += input[i * BLOCK_SIZE + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce1<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

338 u s 338 us 338us,有一定效果,不是很明显主要是因为我么访存次数不算太多,共享内存的读取加速,没有掩盖掉从全局内存拷贝到贡献内存的开销。如果是 g e m m gemm gemm之类访存很多的算子,加速效果才会比较明显

3.2 消除 线程束分化/warp_diverse

进一步的优化需要我们理解 c u d a cuda cuda的执行过程。

c u d a cuda cuda的多线程执行过程,实际上是每次取出一个 b l o c k block block内的 32 32 32个线程,组成一个线程束 w a r p warp warp,然后统一执行,这里一个问题:一个 w a r p warp warp内的线程,如果执行的操作不一样怎么办?比如有个分支指令,一部分线程进入了 A A A分支,一部分进入了 B B B分支?

答案是这两部分之间会退化成串行的,如下图

来看一下我们目前的实现没有没线程束分化,我们前面说了,我们是让第 i i i个线程,负责第 i i i个位置,那么在第一轮中 0 , 2 , . . 30 0,2,..30 0,2,..30这些线程是有运算的,其余是没有运算的,走的是两个不同分支,第二轮中 0 , 4 , . . . , 28 0,4,...,28 0,4,...,28这些线程是有运算的,其余是不进行运算的,也是走了两个不同分支。可以看到是有线程束分化的。

c 复制代码
for (int i = 1; i < blockDim.x; i *= 2)
  {
      if (threadIdx.x % (i * 2) == 0)
      {
          sdata[threadIdx.x] += sdata[threadIdx.x + i];
      }
      __syncthreads();
  }

我们想避免这一点,去掉分支指令是不太可能的,但是可以让一个 w a r p warp warp内的分支结果全都相同。仔细来看产生分化的地方,如果一共有 64 64 64个元素,第一轮执行运算的线程是 0 , 2 , 4.. , 62 0,2,4..,62 0,2,4..,62,这里只用了 32 32 32个线程,我们可以让前 32 32 32个线程 0 , 1 , . . , 31 0,1,..,31 0,1,..,31来执行这些运算操作,然后后 32 32 32个线程不执行运算操作,这样,两个 w a r p warp warp内的线程就都无分化了

这里在实现上,线程 i i i负责的位置就不再是 i i i了,而是需要计算的,我们计算出这个下标 i d x idx idx,看他是否超出当前线程块的大小,如果不超,说明这个线程需要进行运算操作,超出则说明不需要,这样我们每轮执行运算操作线程,都是从 0 0 0号线程开始的一段,就能消除线程束分化了。

如下图,红色和橙色分别表示有无计算操作的线程

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256
// 使用共享内存
// 消除wrap分化
__global__ void reduce2(int *d_input, int *d_output)
{
    // 计算全局内存下标,拷贝到共享内存
    int *start = d_input + blockIdx.x * blockDim.x;

    __shared__ int sdata[BLOCK_SIZE];

    sdata[threadIdx.x] = start[threadIdx.x];
    __syncthreads();

    for (int i = 1; i < blockDim.x; i *= 2)
    {
        int idx = threadIdx.x * i * 2;
        if (idx < BLOCK_SIZE)
        {
            sdata[idx] += sdata[idx + i];
        }
        __syncthreads();
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}

bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / BLOCK_SIZE;

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE; j++)
        {
            sum += input[i * BLOCK_SIZE + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce2<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

235 u s 235us 235us,这个效果还是比较明显的

3.3 消除bank conflict

更进一步的优化需要我们了解 c u d a cuda cuda的内存架构,为了加速内存的读取,底层不是只有一个内存可读,而是把内存拆成 32 32 32份,每一份的读写时独立的,可并行,这里设计成 32 32 32也是为了配合前面提到的 w a r p warp warp中有 32 32 32个线程,理想情况下, w a r p warp warp中的每个线程,正好都去读不同的 b a n k bank bank,内存带宽提升 32 32 32倍。

比如下图, b a n k 0 bank0 bank0存的就是内存上 0 , 32 , 64 0,32,64 0,32,64这些位置的值

这里可能出现的问题是,可能多个线程会去同时读相同的 b a n k bank bank,如果有 n n n个线程去读同一 b a n k bank bank里的不同数据,称为 n n n路 b a n k bank bank冲突,那么在这个 b a n k bank bank的读就又变成串行的了。注意,如果多个线程读 b a n k bank bank里的同一数据,不会触发冲突,而是会触发广播, b a n k bank bank会把这个值广播出去,给所有需要的线程。

如下图,第二种情况在 b a n k 0 bank0 bank0,两个线程来读不同的数据,就是一个 2 2 2路 b a n k bank bank冲突,而最后两种情况,多个线程读同一个 b a n k bank bank,但是读的都是同一数据,不会冲突,而是广播

回到我们的程序,有 b a n k bank bank冲突吗?显然是有的,首先我们只用关注一个 w a r p warp warp内的线程,因为他们只有是同时执行,可能触发冲突,然后如下图, 0 , 8 , 16 , 24 0,8,16,24 0,8,16,24线程,一块取加法第一个操作数的值时,会同时访问 0 , 32 , 64 , 96 0,32,64,96 0,32,64,96的位置,触发一个至少四路的冲突

如何解决呢,可以调整每个线程负责的的元素位置,我们让每个线程负责的元素不再是相邻的两个,而是左右半区各一个,如下图,这样线程 0 , 8 , 16 , 24 0,8,16,24 0,8,16,24访问的内存就是 0 , 8 , 16 , 24 0,8,16,24 0,8,16,24了,他们之间显然是不存在冲突的。

并且之前线程编号,和负责的内存位置不是直接对应的,有点别扭,现在就简单了,线程 i i i负责的就是 i , i + s t r i d e i,i+stride i,i+stride这两个位置。 s t r i d e stride stride是当前有效元素个数的一半,每轮折半。这样实现起来也更清晰。

cpp 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256
// 使用共享内存
// 消除wrap分化
// 消除bank conflict
__global__ void reduce3(int *d_input, int *d_output)
{
    int *start = d_input + blockIdx.x * blockDim.x;

    __shared__ int sdata[BLOCK_SIZE];

    sdata[threadIdx.x] = start[threadIdx.x];
    __syncthreads();

    // if (blockIdx.x == 1 && threadIdx.x == 0)
    // {
    //     for (int i = 0; i < BLOCK_SIZE; i++)
    //     {
    //         printf("(%d)%d ", i, sdata[i]);
    //     }
    //     printf("\n");
    // }

    // 消除wrap分化
    for (int i = BLOCK_SIZE / 2; i >= 1; i /= 2)
    {
        if (threadIdx.x < i)
        {
            sdata[threadIdx.x] += sdata[threadIdx.x + i];
        }
        __syncthreads();
        // if (blockIdx.x == 1 && threadIdx.x == 0)
        // {
        //     for (int i = 0; i < BLOCK_SIZE; i++)
        //     {
        //         printf("(%d)%d ", i, sdata[i]);
        //     }
        //     printf("\n");
        // }
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}

bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / BLOCK_SIZE;

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE; j++)
        {
            sum += input[i * BLOCK_SIZE + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce3<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

221 u s 221us 221us,略有提高,内存吞吐率和 S M SM SM吞吐率都提高了 5 5 5个点,还是有点效果的。

3.4 提升线程工作量(线程块个数减半)

到这一步,小优化已经提不了太多了,需要从每个线程的操作着手。

注意到按我们目前的思路,第一轮只有一半的线程有计算任务,第二轮只有四分之一的线程有计算任务,以此类推,有计算任务的线程越来越少,剩下的线程都闲着,那么一个优化思路就是来一波降本增效,把不干活的人开了。

我们可以让线程数减半,每个线程的工作量翻倍,这样虽然还是有线程闲着,但是闲着的线程数减少了。

这里有两个思路,一个是线程块个数减半,每个线程块大小不变,另一个是线程块个数不变,每个线程块大小减半。我们先实现第一个

实现起来其实也比较简单,从全局内存搬运到共享内存时,我们就让每个线程搬两个,假设有个位于线程块 i i i,块内编号 j j j的线程,就让他搬运块 i i i的 j j j位置,和块 i + 1 i+1 i+1的 j j j位置。

然后在调用核函数时把线程块个数减半,其余都不用改动。

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256

// 增加每个线程的工作量,减少block数
__global__ void reduce4(int *d_input, int *d_output)
{
    int *start = d_input + blockIdx.x * blockDim.x * 2;

    __shared__ int sdata[BLOCK_SIZE];

    sdata[threadIdx.x] = start[threadIdx.x] + start[threadIdx.x + blockDim.x];
    __syncthreads();

    // 消除wrap分化
    for (int i = BLOCK_SIZE / 2; i >= 1; i >>= 1)
    {
        if (threadIdx.x < i)
        {
            sdata[threadIdx.x] += sdata[threadIdx.x + i];
        }
        __syncthreads();
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}
bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / (BLOCK_SIZE * 2);

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE * 2; j++)
        {
            sum += input[i * BLOCK_SIZE * 2 + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce4<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

146 u s 146us 146us,几乎减半,这还是很有效的

3.5 增加线程工作量(线程块大小减半)

类似地,我们也可以让线程块大小减半

注意,计算起始地址偏移量时,用的是线程块大小乘二,是因为这里的线程块大小,实际上是线程块所对应的数据块大小的一半。后面同理,搬运两个数据,另一个数据的下标加上线程块大小。

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256
// block数不变 减少block里的线程数
__global__ void reduce5(int *d_input, int *d_output)
{
    int *start = d_input + blockIdx.x * blockDim.x * 2;

    __shared__ int sdata[BLOCK_SIZE / 2];

    sdata[threadIdx.x] = start[threadIdx.x] + start[threadIdx.x + blockDim.x];
    __syncthreads();

    // 消除wrap分化
    for (int i = BLOCK_SIZE / 4; i >= 1; i /= 2)
    {
        if (threadIdx.x < i)
        {
            sdata[threadIdx.x] += sdata[threadIdx.x + i];
        }
        __syncthreads();
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}
bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / BLOCK_SIZE;

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE; j++)
        {
            sum += input[i * BLOCK_SIZE + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE / 2);

    reduce5<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i < 32 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }

    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

146 u s 146us 146us,表现差不多

3.6 不足一个warp时循环展开

到这里其实已经没什么优化的了,基本到极限了,注意上一节的性能指标,内存吞吐量已经到了极限了。剩下的优化,如果不能改进内存读取的效率,都没什么加速。

当然,理论上这个优化还是有意义的,我们不妨实现一下。

线程同步也是一个耗时的开销,我们来考虑优化这里。注意到我们计算每一层,由于计算任务可能被分成了多个 w a r p warp warp,都要等到所有 w a r p warp warp都计算完,才能计算下一层,也就是需要一个同步。但最后有效位置不足一个 w a r p warp warp,也就是不到 32 32 32个时,只有第一个 w a r p warp warp在计算,其它 w a r p warp warp根本不干活,同步等待他们是无意义的。甚至去创建这些 w a r p warp warp都是无意义的。

所以我们可以在不足 32 32 32个元素时,不再进行循环,而是把循环展开,手动累加,这样省去同步和循环的开销。

也就是前 32 32 32个线程,执行 w a r p R e d u c e warpReduce warpReduce函数。

这里初看可能有点迷惑,因为实际上还是有数据依赖的,每一层规约要全部执行玩才能进行下一层,为什么我们这里没有同步,也不会出错呢?因为这些线程始终在一个 w a r p warp warp里,每次都只会同步的执行一个指令,也就是会先同步的执行a[tid] += a[tid + 32],再同步执行a[tid] += a[tid + 16],以此类推。 w a r p warp warp的 s i m t simt simt机制自动帮我们实现了同步。

另一个可能迷惑的点是,根据之前我们的思路,第一轮有 32 32 32个线程有计算任务,下一轮就只有 16 16 16个了,以此类推,每轮有计算任务的线程数减半,但这里我们让前 32 32 32个线程都执行了完整的 6 6 6轮规约计算,这对吗?实际上对结果无影响,我们只是让一些原本无计算任务的线程也一起执行计算了,比如第二轮, 16 − 31 16-31 16−31这些线程原本不用求和了,但现在我们也让他们进行计算了。这其实没有影响,因为从结果的角度讲 [ 16 , 31 ] [16,31] [16,31]这些位置,只有第一轮规约才会读取,后面都不用读取了,那它们的值变化了也无所谓了;从性能的角度讲,就算不给他们安排计算任务,也要跟着 w a r p warp warp里的 [ 0 , 15 ] [0,15] [0,15]线程一起计算,等待他们计算完成,那就算让 [ 15 , 31 ] [15,31] [15,31]参与计算也不会导致额外的时间开销。

这里还有一个陷阱: w a r p R e d u c e warpReduce warpReduce函数内,我们给传入的数组指针这个参数设置成了 v o l a t i l e volatile volatile,也就是不可变,这是告诉编译器,不要对这个内存的操作进行优化。这是因为这个函数内,对 a [ t i d ] a[tid] a[tid]多次读取,如果不加关键字,编译器会优化成,把 a [ t i d ] a[tid] a[tid]这个位置的值取到寄存器里,然后在寄存器内进行累加,再写回内存,这看似是对的,但我们这里是多线程,且有数据依赖的,每一轮规约后 a [ t i d ] a[tid] a[tid]的值都是会变的,我们累计是要用的是最新的值,而寄存器里的值相当于是一个缓存,没有同步到最新的值,结果就错了。加上不可变关键字,强制每次都去共享内存重新读取,才是对的。

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256

// 有任务的线程不足一个warp时,省去同步
__device__ void warpReduce(volatile int *a, int tid)
{
    a[tid] += a[tid + 32];
    a[tid] += a[tid + 16];
    a[tid] += a[tid + 8];
    a[tid] += a[tid + 4];
    a[tid] += a[tid + 2];
    a[tid] += a[tid + 1];
}

__global__ void reduce6(int *d_input, int *d_output)
{
    int *start = d_input + blockIdx.x * blockDim.x * 2;

    volatile __shared__ int sdata[BLOCK_SIZE];

    sdata[threadIdx.x] = start[threadIdx.x] + start[threadIdx.x + blockDim.x];
    __syncthreads();

    // 消除wrap分化
    for (int i = BLOCK_SIZE / 2; i > 32; i /= 2)
    {
        if (threadIdx.x < i)
        {
            sdata[threadIdx.x] += sdata[threadIdx.x + i];
        }
        __syncthreads();
    }

    if (threadIdx.x < 32)
    {
        warpReduce(sdata, threadIdx.x);
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}
bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / (BLOCK_SIZE * 2);

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE * 2; j++)
        {
            sum += input[i * BLOCK_SIZE * 2 + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce6<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

145 u s 145us 145us,几乎无优化,因为内存带宽已经快用满了,瓶颈在内存。不过这其实减小了计算量,因此注意到计算吞吐率降低了。

3.7 全部循环展开

根据 3.6 3.6 3.6的思路,我们其实可以把全部循环都展开,这样所有的同步都可以省去了,循环的开销也可以省去。

c 复制代码
#include <stdio.h>

#define BLOCK_SIZE 256

__device__ void warpReduce(volatile int *a, int tid)
{
    a[tid] += a[tid + 32];
    a[tid] += a[tid + 16];
    a[tid] += a[tid + 8];
    a[tid] += a[tid + 4];
    a[tid] += a[tid + 2];
    a[tid] += a[tid + 1];
}

__global__ void reduce7(int *d_input, int *d_output)
{
    int *start = d_input + blockIdx.x * blockDim.x * 2;

    volatile __shared__ int sdata[BLOCK_SIZE];

    sdata[threadIdx.x] = start[threadIdx.x] + start[threadIdx.x + blockDim.x];
    __syncthreads();

    // 消除wrap分化

    if (threadIdx.x < 128)
    {
        sdata[threadIdx.x] += sdata[threadIdx.x + 128];
    }
    __syncthreads();
    if (threadIdx.x < 64)
    {
        sdata[threadIdx.x] += sdata[threadIdx.x + 64];
    }
    __syncthreads();

    if (threadIdx.x < 32)
    {
        warpReduce(sdata, threadIdx.x);
    }
    if (threadIdx.x == 0)
    {
        d_output[blockIdx.x] = sdata[0];
    }
}
bool check(int *a, int *b, int N)
{
    for (int i = 0; i < N; i++)
    {
        if (a[i] != b[i])
        {
            return false;
        }
    }
    return true;
}
void solve()
{
    // 申请host device侧空间
    int N = 32 * 1024 * 1024;
    int block_num = N / (BLOCK_SIZE * 2);

    int *input = (int *)malloc(N * sizeof(int));
    int *d_input;
    cudaMalloc((int **)&d_input, N * sizeof(int));

    int *output = (int *)malloc(block_num * sizeof(int));
    int *d_output;
    cudaMalloc((int **)&d_output, block_num * sizeof(int));

    int *result = (int *)malloc(block_num * sizeof(int));

    // 初始化值
    srand(666);
    for (int i = 0; i < N; i++)
    {
        input[i] = rand() % 1000;
    }

    // cpu暴力计算

    for (int i = 0; i < block_num; i++)
    {
        int sum = 0;
        for (int j = 0; j < BLOCK_SIZE * 2; j++)
        {
            sum += input[i * BLOCK_SIZE * 2 + j];
        }
        result[i] = sum;
    }

    cudaMemcpy(d_input, input, N * sizeof(int), cudaMemcpyHostToDevice);

    dim3 Grid(block_num);
    dim3 Block(BLOCK_SIZE);

    reduce7<<<Grid, Block>>>(d_input, d_output);

    cudaMemcpy(output, d_output, block_num * sizeof(int), cudaMemcpyDeviceToHost);

    if (!check(output, result, block_num))
    {
        for (int i = 0; i < block_num; i++)
        {
            if (i == 0 && output[i] != result[i])
            {
                printf("blockidx:%d,output:%d,ans:%d\n", i, output[i], result[i]);
            }
        }
    }
    else
    {
        printf("test passed!\n");
    }
    cudaFree(d_output);
    cudaFree(d_input);
    free(input);
    free(output);
    free(result);
}
int main()
{
    solve();
    return 0;
}

145 u s 145us 145us,没什么优化,但是计算吞吐量有一定的降低,计算任务确实变小了。这里应该是内存 b o u n d bound bound了,最后这几个优化,确实是优化了计算量和同步时间的,看起来没有效果主要是时间都在访存上了。

4.番外 h100上测试

4.0 朴素版本

比 4090 4090 4090还略慢,神秘

h 100 h100 h100上 n c u ncu ncu要 r o o t root root,用不了,只能 n s y s nsys nsys凑合着用,也有个核函数计时,只是单位变成 n s = 1 e − 9 s ns=1e-9s ns=1e−9s,而 n c u ncu ncu是 u s = 1 e − 6 s us=1e-6s us=1e−6s,换算一下即可,相当于 479 u s 479us 479us,比 4090 4090 4090慢百分之三十左右。

4.1 共享内存

还是比4090慢, 429 u s 429us 429us,但是优化后加速比比 4090 4090 4090要大,怀疑是 h 100 h100 h100的 H B M HBM HBM高速内存带来的加速,因为我们换到共享内存上相当于是在优化访存, H B M HBM HBM内存带宽大,优化一点访存就能提速很多

4.2 消除线程束分化

260 u s 260us 260us已经快追上4090了

4.3 消除bank冲突

199 u s 199us 199us,终于反超4090

4.4 增加线程工作量(减少线程块个数)

109 u s 109us 109us,快了一倍,比4090的 140 u s 140us 140us快很多了

4.5 增加线程工作量(减小线程块大小)

104 u s 104us 104us还要更快一点,后续优化以这个版本为基础

4.6 循环展开最后一个warp

87.4 u s 87.4us 87.4us,证明这个优化是有用的,我们前面的4090没效果完全是因为内存 b o u n d bound bound了

4.7 全部循环展开

87.1 u s 87.1us 87.1us还是有提升,可能是因为我们的线程块大小是 256 256 256,线程数超过 32 32 32的规约轮数只有两轮, 128 , 64 128,64 128,64,而且这两轮的计算,是在不同 w a r p warp warp的, w a r p warp warp间需要同步,同步的时间不能省,只剩去了 f o r for for循环的开销

相关推荐
嗑瓜子儿溜茶水儿5 小时前
docker 部署 kkfileview ; arm64; ky10;
java·docker
Thomas_Cai5 小时前
YOLOv10剪枝|稀疏训练、基于torch-pruning剪枝以及微调实践
算法·yolo·剪枝·稀疏训练·结构化剪枝
CoderYanger5 小时前
贪心算法:1.柠檬水找零
java·算法·leetcode·贪心算法·1024程序员节
JIngJaneIL5 小时前
基于Java饮食营养管理信息平台系统(源码+数据库+文档)
java·开发语言·数据库·vue.js·spring boot
猫天意5 小时前
【即插即用模块】AAAI2026 | MHCB+DPA:特征提取+双池化注意力,涨点必备,SCI保二争一!彻底疯狂!!!
网络·人工智能·深度学习·算法·yolo
JavaEdge.5 小时前
永别了,控制台!
java
爱笑的眼睛115 小时前
端到端语音识别系统的前沿实践与深度剖析:从RNN-T到Conformer
java·人工智能·python·ai
zl_vslam5 小时前
SLAM中的非线性优-3D图优化之相对位姿g2o::EdgeSE3Expmap(十)
人工智能·算法·计算机视觉·3d
deardao5 小时前
【智能制造】智能制造系统中的时间序列分类:最先进的机器学习算法的实验评估
算法·机器学习·制造