CUDA算子手撕与面试

引言

最近秋招落幕,期间一直在找高性能计算(HPC)相关岗位,最终上岸某大厂推理引擎开发。面试期间比较难受的一点是HPC相关的面试资料太少,故自己在面试过程中陆续整理了一些CUDA算子手撕的代码和知识点,最后打包成一个开源项目分享给大家。

项目地址https://github.com/Tongkaio/CUDA_Kernel_Samples

如果觉得本项目对你有帮助,欢迎给项目点个 ⭐ 哦 ~

项目介绍

本项目是 CUDA 算子手撕与面试指南

  1. 汇总了面试高频的 CUDA 算子题目和优化策略,包含面试高频算子的编写示例
  2. 项目从算子 naive 实现到优化版本均包含完整代码,便于调试与性能分析
  3. 每个算子附有相关的 GPU 知识点,帮助求职者高效备战 CUDA 编程面试

目前覆盖以下 CUDA 常见算子及其优化版本:

文件夹 描述 内容
example 一些简单的例子 /
elementwise 数组对应元素计算 add
gemv 矩阵乘向量 sgemv
reduce 归约计算优化 sum, max, softmax, softmax_matrix
sgemm 矩阵乘优化 naive, blocktile, threadtile, ...
transpose 矩阵转置优化 naive, 优化访存并解决bank conflict

算子手撕说明

面试时不会提供 CUDA 运行环境,也不会要求完整写出可以运行的代码,通常只需要写出 CUDA 算子函数(大部分情况只需要写这个),block_size,grid_size 和函数调用。

在此列出一些宏,后面会用到:

// 1. 向上取整
#define CEIL(a, b) ((a + b - 1) / (b))

// 2. FLOAT4,用于向量化访存,以下两种都可以
// c写法
#define FLOAT4(value) *(float4*)(&(value))

// c++写法
#define FLOAT4(value) (reinterpret_cast<float4*>(&(value))[0])

本文剩余篇幅从这一角度出发,展示必要的代码,以供参考和练习。

elementwise

考察频率

算子描述:elementwise 是最简单的一类算子,其指的是对数据进行逐元素操作,例如将两个等长的数组对应元素相加(add)。另外在深度学习中,激活函数会对输入数据的每个元素求对应激活值,故激活函数也算在 elementwise 范围内。

算子主要分两种写法:

  1. naive:每个线程负责一个元素的运算
  2. 使用float4等向量化访存方式:只对大规模数据有加速效果,需要注意,要在 grid 上除以 4,而不是在 block 上除以 4,否则会降低SM的占用率(Occupancy,实际运行的线程数 / SM 理论最大线程数),可以参考👉grid_size 和 block_size 选择(block_size 不小于SM上最大支持的线程数/最大同时执行的block数量,且得是SM上最大支持的线程数的约数,才有可能达到100% Occupancy)。向量化存取的好处在于可以减少访存指令的数量,单位时间内读取的数据量变多,增大访存带宽。

源码文件夹elementwise

add

源码:add.cu

naive版

// block_size,grid_size 和函数调用
int block_size = 1024;
int grid_size  = CEIL(N, block_size);
elementwise_add<<<grid_size, block_size>>>(a, b, c, N);

// 函数定义
__global__ void elementwise_add(float* a, float* b, float *c, int N) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < N) {
        c[idx] = a[idx] + b[idx];
    }
}

使用向量化访存

使用向量化访存进行优化,需要注意,要在 grid 上除以 4

int block_size = 1024;
int grid_size  = CEIL(CEIL(N,4), block_size);  // 注:在grid维度除以4
elementwise_add<<<grid_size, block_size>>>(a, b, c, N);

__global__ void elementwise_add_float4(float* a, float* b, float *c, int N) {
    int idx = (blockDim.x * blockIdx.x + threadIdx.x) * 4;

    if (idx < N) {
        float4 tmp_a = FLOAT4(a[idx]);
        float4 tmp_b = FLOAT4(b[idx]);
        float4 tmp_c;
        tmp_c.x = tmp_a.x + tmp_b.x;
        tmp_c.y = tmp_a.y + tmp_b.y;
        tmp_c.z = tmp_a.z + tmp_b.z;
        tmp_c.w = tmp_a.w + tmp_b.w;
        FLOAT4(c[idx]) = tmp_c;
    }
}

以下算子的 block_size, grid_size, 函数调用与 add 的写法相同, 不再重复写出。

sigmoid

__global__ void sigmoid(float* x, float* y, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) y[idx] = 1.0f / (1.0f + expf(-x[idx]));
}

// float4
__global__ void sigmoid_float4(float* x, float* y, int N) {
    int idx = (blockDim.x * blockIdx.x + threadIdx.x) * 4;
    if (idx < N) {
        float4 tmp_x = FLOAT4(x[idx]);
        float4 tmp_y;
        tmp_y.x = 1.0f / (1.0f + expf(-tmp_x.x));
        tmp_y.y = 1.0f / (1.0f + expf(-tmp_x.y));
        tmp_y.z = 1.0f / (1.0f + expf(-tmp_x.z));
        tmp_y.w = 1.0f / (1.0f + expf(-tmp_x.w));
        FLOAT4(y[idx]) = tmp_y;
    }
}

relu

__global__ void relu(float* x, float* y, int N) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < N) y[idx] = fmaxf(0.0f, x[idx]);
    }

// float4
__global__ void relu_float4(float* x, float* y, int N) {
    int idx = (blockDim.x * blockIdx.x + threadIdx.x) * 4;
    if (idx < N) {
        float4 tmp_x = FLOAT4(x[idx]);
        float4 tmp_y;
        tmp_y.x = fmaxf(0.0f, tmp_x.x);
        tmp_y.y = fmaxf(0.0f, tmp_x.y);
        tmp_y.z = fmaxf(0.0f, tmp_x.z);
        tmp_y.w = fmaxf(0.0f, tmp_x.w);
        FLOAT4(y[idx]) = tmp_y;
    }
}

reduce

考察频率

算子描述:reduce 是一种聚合操作,通常用于将一个多元素的数据结构(如数组或张量)通过某种规则归约为一个更小的数据结构(通常是单个值或更小的数组)。它广泛应用于数据处理、并行计算以及深度学习中。例如对数组进行求和 (sum),求均值 (mean),求最大值 (max),还有求 softmax。其中,sum 和 softmax 的考察频率最高

源码文件夹reduce

sum

源码:sum.cu

naive版

每个线程通过原子函数 atomicAdd,往同一个全局内存里面写数据,原子函数会导致线程变成序列化,丧失并行性,算子性能大大降低,不能滥用:

dim3 block_size(BLOCK_SIZE);  // BLOCK_SIZE 是通过宏定义的某个数字
dim3 grid_size(CIEL(N, BLOCK_SIZE));
reduce_v1<<<grid_size, block_size>>>(d_x, d_y, N);

__global__ void reduce_v1(const float* input, float* output, int N) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < N) atomicAdd(output, input[idx]);
}

折半归约

在block内进行折半归约,一个block归约一部分,先搬到自己 block 内的 shared_memory 下,然后归约到首元素。

这种方法的缺点是 BLOCK_SIZE 必须是 2 的幂次,否则折半操作时会计算出错,导致误差很大。而且每次迭代折半时必须使用 __syncthreads() 进行同步,会强制所有线程在同步点等待,直到线程块中的其他线程也到达。会导致性能下降。

dim3 block_size(BLOCK_SIZE);  // BLOCK_SIZE 是通过宏定义的某个数字
dim3 grid_size(CIEL(N, BLOCK_SIZE));
reduce_v2<<<grid_size, block_size>>>(d_x, d_y, N);

__global__ void reduce_v2(const float* input, float* output, int N) {
    int tid = threadIdx.x;
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    __shared__ float input_s[BLOCK_SIZE];

    // 1. 搬运和线程数量(blockDim.x)相等的数据,到当前block的共享内存中
    input_s[tid] = (idx < N) ? input[idx] : 0.0f;
    __syncthreads();

    // 2. 用1/2, 1/4, 1/8...的线程进行折半归约
    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1) {
        if (tid < offset) {  // 2.折半归约
            input_s[tid] += input_s[tid + offset];
        }
        __syncthreads();
    }

    // 3. 每个block的第一个线程将计算结果累加到输出中
    if (tid == 0) atomicAdd(output, input_s[0]);
}

warp shuffle(推荐写法)

在 warp 内进行折半归约,其优势在于,一个 warp 内的线程是同步的,相比于以 block 为单位进行折半,以 warp 为单位进行每次折半时不需要 __syncthreads(),并行性更高。

BLOCK_SIZE需要是32的整数倍,否则产生线程数不足32的warp,可能会导致访问到无效数据。

使用 CUDA 提供的 warp shuffle 操作,有以下函数可以用:

  1. __shfl_sync():拷贝来自任意laneId(0~31)线程里的值
  2. __shf_xor_sync():拷贝来自一个计算出来的laneId(0~31)线程里的值
  3. __shfl_up_sync():拷贝来自有一定偏移量laneId更小的线程里的值
  4. __sync_down_sync():拷贝来自有一定偏移量laneId更大的线程里的值

其中 __shf_xor_sync()__sync_down_sync() 使用频率较高。

dim3 block_size(BLOCK_SIZE);
dim3 grid_size(CIEL(N, BLOCK_SIZE));
reduce_v3<<<grid_size, block_size>>>(d_x, d_y, N)

__global__ void reduce_v3(float* d_x, float* d_y, const int N) {
    __shared__ float s_y[32];  // 仅需要32个,因为一个block最多1024个线程,最多1024/32=32个warp

    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    int warpId = threadIdx.x / warpSize;  // 当前线程属于哪个warp
    int laneId = threadIdx.x % warpSize;  // 当前线程是warp中的第几个线程

    float val = (idx < N) ? d_x[idx] : 0.0f;  // 搬运d_x[idx]到当前线程的寄存器中
    #pragma unroll
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        val += __shfl_down_sync(0xFFFFFFFF, val, offset);   // 在一个warp里折半归约
    }

    if (laneId == 0) s_y[warpId] = val;  // 每个warp里的第一个线程,负责将数据存储到shared mem中
    __syncthreads();

    if (warpId == 0) {  // 使用每个block中的第一个warp对s_y进行最后的归约
        int warpNum = blockDim.x / warpSize;  // 每个block中的warp数量
        val = (laneId < warpNum) ? s_y[laneId] : 0.0f;
        for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
            val += __shfl_down_sync(0xFFFFFFFF, val, offset);
        }
        if (laneId == 0) atomicAdd(d_y, val);  // 使用此warp中的第一个线程,将结果累加到输出
    }
}

warp shuffle + float4

在 warp shuffle 上进一步优化,搬运数据时使用 float4:

#define FLOAT4(value) (float4*)(&(value))[0]
dim3 block_size(BLOCK_SIZE);
dim3 grid_size(CEIL(CIEL(N, BLOCK_SIZE),4));  // 这里要除以4
reduce_v3<<<grid_size, block_size>>>(d_x, d_y, N)

__global__ void reduce_v4(float* d_x, float* d_y, const int N) {
    __shared__ float s_y[32];
    int idx = (blockDim.x * blockIdx.x + threadIdx.x) * 4;  // 这里要乘以4
    int warpId = threadIdx.x / warpSize;   // 当前线程位于第几个warp
    int laneId = threadIdx.x % warpSize;   // 当前线程是warp中的第几个线程
    float val = 0.0f;
    if (idx < N) {
        float4 tmp_x = FLOAT4(d_x[idx]);
        val += tmp_x.x;
        val += tmp_x.y;
        val += tmp_x.z;
        val += tmp_x.w;
    }
    #pragma unroll
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        val += __shfl_down_sync(0xFFFFFFFF, val, offset);
    }

    if (laneId == 0) s_y[warpId] = val;
    __syncthreads();

    if (warpId == 0) {
        int warpNum = blockDim.x / warpSize;
        val = (laneId < warpNum) ? s_y[laneId] : 0.0f;
        for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
            val += __shfl_down_sync(0xFFFFFFFF, val, offset);
        }
        if (landId == 0) atomicAdd(d_y, val);
    }
}

SoftMax

Softmax 的 CPU 和 CUDA 写法均是高频考察。面试时有可能会让任选一种写法进行书写,此时自己可以量力而行。

源码:softmax.cu

Softmax公式如下:

一般为了避免溢出,需要减去最大值,所以通常采用下面这个公式:

其中 是输入向量的最大值。

CPU 写法

void softmax(float* input, float* output, int N) {
    int M = *(std::max_element(input, input + N));
    float div = 0;
    for (int i = 0; i < N; i++) {
        output[i] = std::exp(input[i] - M);
        div += output[i];
    }
    for (int i = 0; i < N; i++) {
        output[i] /= div;
    }
}

CUDA写法

最直接的思路是将 Softmax 计算过程拆分为多个归约算子,只要会写归约,那么 Softmax 就能写。

这种写法的优点是比较简单,虽然代码比较多,但基本都是采用归约的写法,几个算子的逻辑上差异不大。缺点是算子效率比较低。这里建议学习 softmax_matrix 的写法!

思路:

  • 核函数1:归约求最值 max_val
  • 核函数2:归约求和 sum
  • 核函数3:计算每个元素减去 max_val 除以 sum。
__device__ static float atomicMax(float* address, float val) {
    int* address_as_i = (int*)address;
    int old = *address_as_i;
    int assumed;
    do {
        assumed = old;
        old = atomicCAS(address_as_i, assumed, __float_as_int(fmaxf(val, __int_as_float(assumed))));
    } while (assumed != old);
    return __int_as_float(old);
}

__global__ void max_kernel(float* input, float* max_val, int N) {
    __shared__ float s_mem[32];
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    int warpId = threadIdx.x / warpSize;
    int laneId = threadIdx.x % warpSize;

    float val = (idx < N) ? input[idx] : (-FLT_MAX);
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        val = fmaxf(val, __shfl_down_sync(0xFFFFFFFF, val, offset));
    }
    if (laneId == 0) s_mem[warpId] = val;
    __syncthreads();

    if (warpId == 0) {
        int warpNum = blockDim.x / warpSize;
        val = (laneId < warpNum) ? s_mem[laneId] : (-FLT_MAX);
        for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
            val = fmaxf(val, __shfl_down_sync(0xFFFFFFFF, val, offset));
        }
        if (laneId == 0) atomicMax(max_val, val);
    }
}

__global__ void sum_kernel(float* input, float* sum, float* max_val, int N) {
    __shared__ float s_mem[32];
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    int warpId = threadIdx.x / warpSize;
    int laneId = threadIdx.x % warpSize;

    float val = (idx < N) ? expf(input[idx] - *max_val) : 0.0f;
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        val += __shfl_down_sync(0xFFFFFFFF, val, offset);
    }
    if (laneId == 0) s_mem[warpId] = val;
    __syncthreads();

    if (warpId == 0) {
        int warpNum = blockDim.x / warpSize;
        val = (laneId < warpNum) ? s_mem[laneId] : 0.0f;
        for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
            val += __shfl_down_sync(0xFFFFFFFF, val, offset);
        }
        if (laneId == 0) atomicAdd(sum, val);
    }
}

__global__ void softmax_kernel(float* input, float* output, float* sum, float* max_val, int N) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < N) output[idx] = expf(input[idx] - *max_val) / (*sum);
}

// 初始化相关变量
// ...
// 调用
int block_size = 256;
int grid_size  = CEIL(N, block_size);
max_kernel<<<gird_size, block_size>>>(input, max_val, N);
sum_kernel<<<gird_size, block_size>>>(input, sum, max_val, N);
softmax_kernel<<<gird_size, block_size>>>(input, output, sum, max_val, N);

transpose

考察频率

算子描述:指的是矩阵转置,其中会涉及到 GPU 全局内存的高效访问、bank conflict 知识点。

如何优化全局内存的访问:

  1. 尽量合并访问,即连续的线程读取连续的内存,且尽量让访问的全局内存的首地址是32字节(一次数据传输处理的数据量)的倍数(cudaMalloc分配的至少是256字节整数倍);
  2. 如果不能同时合并读取和写入,则应该尽量做到合并写入,因为编译器如果能判断一个全局内存变量在核函数内是只可读的,会自动调用 __ldg() 读取全局内存,从而对数据进行缓存,缓解非合并访问带来的影响,但这只对读取有效,写入则没有类似的函数。另外,对于开普勒架构和麦克斯韦架构,需要显式的使用 __ldg() 函数,例如 B[ny * N + nx] = __ldg(&A[nx * N + ny])

源码文件夹transpose

naive:

__global__ void transpose(float* input, float* output, int M, int N) {
    // input的row和col
    int row = blockDim.y * blockIdx.y + threadIdx.y;
    int col = blockDim.x * blockIdx.x + threadIdx.x;

    if (row < M && col < N) {
        output[col * M + row] = input[row * N + col];
    }
}

仅合并写入:

__global__ void transpose(float* input, float* output, int M, int N) {
    // output的row和col
    int row = blockDim.y * blockIdx.y + threadIdx.y;
    int col = blockDim.x * blockIdx.x + threadIdx.x;

    if (row < N && col < M) {
        output[row * M + col] = __ldg(&input[col * N + row]);  // 合并写入,读取使用__ldg进行缓存
    }
}

使用共享内存中转,同时合并读取和写入(推荐): 图片上传失败,请重新上传

// 输入矩阵是M行N列,输出矩阵是N行M列
dim3 block(32, 32);
dim3 grid(CEIL(M,32), CEIL(N,32));

template <const int BLOCK_SIZE>
__global__ void transpose(float* input, float* output, int M, int N) {
    __shared__ float s_mem[BLOCK_SIZE][BLOCK_SIZE + 1];  // 避免bank conflict
    int bx = blockIdx.x * BLOCK_SIZE;
    int by = blockIdx.y * BLOCK_SIZE;

    int x1 = bx + threadIdx.x;
    int y1 = by + threadIdx.y;
    if (x1 < N && y1 < M) {
        s_mem[threadIdx.y][threadIdx.x] = input[y1 * N + x1];
    }
    __syncthreads();

    int x2 = by + threadIdx.x;
    int y2 = bx + threadIdx.y;
    if (x2 < M && y2 < N) {
        output[y2 * M + x2] = s_mem[threadIdx.x][threadIdx.y];  // padding后,不存在bank conflict
    }
}

sgemm

考察频率

算子描述:指的是矩阵乘。矩阵乘是 CUDA 学习时的经典案例,涉及多种 CUDA 编程中的常用优化技巧。建议阅读 CUDA SGEMM 优化。但手撕时难度往往较大,建议优先掌握最简单的 naive 版本以及 block_tile 版本。掌握 block_tile 版本后,只需要加一些代码就可以优化为 thread_tile 版本,故也可以考虑掌握。其余的更高效的优化版本,个人认为了解其原理即可,不必强求面试时手写。

源码文件夹:[sgemm](CUDA_Kernel_Samples/sgemm at master · Tongkaio/CUDA_Kernel_Samples)

naive 版

// C(MxN) = A(MxK) * B(KxN) 行优先
// 每个线程处理一个输出矩阵中的元素

// 假设 M N K 已经赋值
const int BLOCK_SIZE = 32;
dim3 block(BLOCK_SIZE, BLOCK_SIZE);
dim3 grid(CEIL(N,BLOCK_SIZE), CEIL(M,BLOCK_SIZE));
sgemm<<<grid, block>>>(d_A, d_B, d_C, M, N, K);

__global__ void sgemm(float* A, float* B, float* C, int M, int N, int K) {
    int col = blockDim.x * blockIdx.x + threadIdx.x;
    int row = blockDim.y * blockIdx.y + threadIdx.y;
    if (row >= M || col >= N) return;

    float accum = 0.0f;
    for (int i = 0; i < K; i++) {
        accum += A[row * K + i] * B[i * N + col];
    }

    C[row * N + col] = accum;
}

block_tile 版本

还是一个线程计算一个输出矩阵中的元素,但是用 shared mem 做缓存,重复从 shared mem 中读取,而不是从 global mem,虽然读取次数没变少,但是 shared mem 比 global mem 读取速度快:

#define BLOCK_SIZE 32

dim3 block(BLOCK_SIZE, BLOCK_SIZE);
dim3 grid(CEIL(N,BLOCK_SIZE), CEIL(M,BLOCK_SIZE));
sgemm<<<grid, block>>>(d_A, d_B, d_C, M, N, K);

__global__ void sgemm(float* A, float* B, float* C, int M, int N, int K) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    int idy = blockDim.y * blockIdx.y + threadIdx.y;
    if (idx >= M || idy >= N) return;

    int bx = blockIdx.x;
    int by = blockIdx.y;
    int tx = threadIdx.x;
    int ty = threadIdx.y;

    const int BM = BLOCK_SIZE;
    const int BN = BLOCK_SIZE;
    const int BK = BLOCK_SIZE;
    __shared__ float As[BM * BK];
    __shared__ float Bs[BK * BN];

    // 初始化block tile起始位置
    A = &A[(by * BM) * K];
    B = &B[bx * BN];
    C = &C[(by * BM) * N + bx * BN];

    float accum = 0.0f;
    for (int k = 0; k < K; k += BK) {
        // 搬运 global ==> shared
        As[ty * BK + tx] = A[ty * K + tx];
        Bs[ty * BN + tx] = B[ty * N + tx];
        __syncthreads();
        A = A + BK;
        B = B + BK * N;
        for (int i = 0; i < BK; i++) {
            accum += As[ty * BK + i] * Bs[i * BN + tx];
        }
        __syncthreads();
    }

    C[ty * N + tx] = accum;
}

thread_tile

一个线程承担更多的计算,更加高效:

dim3 block(256);
dim3 grid(CEIL(N,128), CEIL(M,128));
sgemm<128, 128, 8, 8, 8><<<grid, block>>>(A,B,C,M,N,K);

template<const int BM,
         const int BN,
         const int BK,
         const int TM,
         const int TN>
__global__ void sgemm(float* A, float* B, float* C, int M, int N, int K) {
    int bx = blockIdx.x;
    int by = blockIdy.y;

    int block_row_thread = BN / TN;  // block中一行的thread数量
    int block_col_thread = BM / TM;  // block中一列的thread数量
    int thread_num = block_row_thread * block_col_thread;  // block中thread总量

    int tx = (threadIdx.x % block_row_thread) * TN;  // threadtile左上角x坐标
    int ty = (threadIdx.x / block_row_thread) * TM;  // threadtile左上角y坐标

    __shared__ float As[BM * BK];
    __shared__ float Bs[BK * BN];

    A = &A[by * BM * K];
    B = &B[bx * BN];
    C = &C[by * BM * N + bx * BN];

    int a_tile_row = threadIdx.x / BK;
    int a_tile_col = threadIdx.x % BK;
    int a_tile_stride = thread_num / BK;  // BM/(BM/(thread_num/BK)) = thread_num/BK = stride

    int b_tile_row = threadIdx.x / BN;
    int b_tile_col = threadIdx.x % BN;
    int b_tile_stride = thread_num / BN;

    float accum[TM][TN] = {0.0f};
    for (int k = 0; k < K; k += BK) {
        for (int i = 0; i < BM; i += a_tile_stride) {
            As[(a_tile_row + i) * BK + a_tile_col] = A[(a_tile_row + i) * K + a_tile_col];
        }
        for (int i = 0; i < BK; i += b_tile_stride) {
            Bs[(b_tile_row + i) * BN + b_tile_col] = B[(b_tile_row + i) * N + b_tile_col];
        }
        __syncthreads();

        A += BK;
        B += BK * N;

        for (int row = 0; row < TM; row++) {
            for (int col = 0; col < TN; col++) {
                for (int i = 0; i < BK; i++) {
                    accum[row][col] += As[(ty + row) * BK + i] * Bs[i * BN + (tx + col)];
                }
            }
        }
        __syncthreads();
    }
    for (int row = 0; row < TM; row++) {
        for (int col = 0; col < TN; col++) {
            C[(ty + row) * N + (tx + col)] = accum[row][col];
        }
    }
}

gemv

考察频率

算子描述:求一个矩阵乘以一个向量,方法是每个block中有一个warp,每个warp负责一行的计算。虽然面试考察频率不大但,推荐学习并了解。因为 gemv 中使用一个 warp 负责一行的计算方式,可以拓展到对一个矩阵按行求归约(面试时有概率会考察二维矩阵的按行求归约,而不只是一维数组

源码文件夹gemv

gemv

// 行数: M = 1024
// 列数: K = 32
// block数量和行数相同: grid_size = M
// 每个block里一个warp: block_size = 32
sgemv<<<grid_size, block_size>>>(A, x, y, M, K);
__global__ void sgemv(float* A, float* x, float* y, int M, int K) {
    int laneId = threadIdx.x % warpSize;
    int row = blockIdx.x;  // 0~M-1
    if (row >= M) return;

    float res = 0.0f;
    int kIteration = CEIL(K, warpSize);  // 每个线程需要负责计算的数据个数

    for (int i = 0; i < kIteration; i++){
        int col = i * warpSize + laneId;
        res += (col < K) ? A[row * K + col] * x[col] : 0.0f;
    }

    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        res += __shfl_down_sync(0xFFFFFFFF, res, offset);
    }

    if(laneId == 0) y[row] = res;
}

拓展应用

了解了 gemv 后,按照同样的思路,我们可以写出对 MxN 的矩阵,每一行求 softmax。M = 1 时,问题变为对一个长度为 N 的数组求 softmax。

softmax_matrix

源码:softmax_matrix.cu

对一个 MxN 的矩阵,每一行求 softmax,思路同样是每个 warp 处理一行,用这个 warp 对一行进行求和、求最值,计算结果存入共享内存,然后每个元素求 softmax:

__global__ void softmax_kernel(float* input, float* output, int M, int N) {
    __shared__ float s_max_val;
    __shared__ float s_sum;
    int laneId = threadIdx.x % warpSize;
    // 当前行
    int row = blockIdx.x;
    if (row >= M) return;

    int iteration = CEIL(N, warpSize);  // 每个线程负责计算的数据个数

    // 求每一行最大值
    float max_val = -FLT_MAX;
    for (int i = 0; i < iteration; i++) {
        int col = i * warpSize + laneId;
        max_val = (col < N) ? fmaxf(max_val, input[row * N + col]) : max_val;
    }
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, offset));
    }
    if (laneId == 0) s_max_val = max_val;  // 最大值汇总到第一个线程,第一个线程将它搬运到s_mem

    // 求每一行的和,且要减去最大值
    float sum = 0.0f;
    for (int i = 0; i < iteration; i++) {
        int col = i * warpSize + laneId;
        sum += (col < N) ? expf(input[row * N + col] - s_max_val) : 0.0f;
    }
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        sum += __shfl_down_sync(0xFFFFFFFF, sum, offset);
    }
    if (laneId == 0) s_sum = sum;  // sum值汇总到第一个线程,第一个线程将它搬运到s_mem

    // 计算每一行的softmax
    for (int i = 0; i < iteration; i++) {
        int col = i * warpSize + laneId;
        if (col < N) output[row * N + col] = expf(input[row * N + col] - s_max_val) / s_sum;
    }
}

改用 __shfl_xor_sync 后,每个线程的寄存器的 max_valsum 都是最终的结果,就不用写到共享内存再读取了:

dim3 block(32);
dim3 grid(M);

__global__ void softmax_kernel(float* input, float* output, int M, int N) {
    int laneId = threadIdx.x % warpSize;
    // 当前行
    int row = blockIdx.x;
    if (row >= M) return;

    int iteration = CEIL(N, warpSize);  // 每个线程负责计算的数据个数

    // 求每一行最大值
    float max_val = -FLT_MAX;
    for (int i = 0; i < iteration; i++) {
        int col = i * warpSize + laneId;
        max_val = (col < N) ? fmaxf(max_val, input[row * N + col]) : max_val;
    }
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        max_val = fmaxf(max_val, __shfl_xor_sync(0xFFFFFFFF, max_val, offset));
    }

    // 求每一行的和,且要减去最大值
    float sum = 0.0f;
    for (int i = 0; i < iteration; i++) {
        int col = i * warpSize + laneId;
        sum += (col < N) ? expf(input[row * N + col] - max_val) : 0.0f;
    }
    for (int offset = warpSize >> 1; offset > 0; offset >>= 1) {
        sum += __shfl_xor_sync(0xFFFFFFFF, sum, offset);
    }

    // 计算每一行的softmax
    for (int i = 0; i < iteration; i++) {
        int col = i * warpSize + laneId;
        if (col < N) output[row * N + col] = expf(input[row * N + col] - max_val) / sum;
    }
}

进一步地,当行数 M = 1,问题退化为对一个长度为 N 的数组进行归约求和。可以自行编写。

#高性能计算面经##HPC高性能计算工程师##推理引擎##推理优化##大模型#
全部评论
请问lz这个方向当前有实习岗位吗?
点赞 回复 分享
发布于 2024-12-15 22:40 湖北
大佬是985硕吗?
点赞 回复 分享
发布于 2024-12-22 18:13 河南
想问下佬有对口实习吗?秋招投这个方向需要对口实习吗?
点赞 回复 分享
发布于 01-04 19:43 湖南
非常感谢分享,请教下楼主在入门后,在提升阶段有哪些书籍可以推荐下嘛
点赞 回复 分享
发布于 01-10 14:41 江苏
请问会问到nvsight compute分析算子性能相关知识吗?
点赞 回复 分享
发布于 02-26 14:29 湖北
大佬的那篇MoE Inference有没有讲解的博客呀实在有些地方没看懂
点赞 回复 分享
发布于 今天 20:25 湖北

相关推荐

C++的上限非常高,但是分阶段性逐步学习是没有问题的,一步步的学,慢慢领悟,总有一天会熟练掌握的。C++&nbsp;语言的学习其实就三个阶段就好了:(1)&nbsp;入门阶段这个阶段的学习主要是熟悉&nbsp;C++&nbsp;语言的语法知识。在这个阶段要做到理解对象的思想方法,培养自己的编程思维能力。目标是可以开发一些像贪吃蛇这种简单的控制台小程序。(2)&nbsp;进阶阶段进阶阶段的学习主要是要掌握&nbsp;C++&nbsp;标准模板库(STL)、设计模式、数据结构基础以及&nbsp;UI&nbsp;界面开发、数据库开发等高级技能。在这个阶段是要达到可以开发复杂的程序,达到工作中&nbsp;C++&nbsp;开发程序员的能力。(3)&nbsp;应用阶段这个是实战阶段,要具备一定的综合性应用软件开发能力。这个阶段就是多观摩别人的项目,看人家的写法,模仿项目,学习其中的思想,一点点的积累,一步步形成自己的东西,厚积而薄发,慢慢你就会发现你也可以了。注意!下面都是超极干的干货一、入门阶段入门阶段的学习主要是熟悉&nbsp;C++&nbsp;语言的语法知识。除了基础的变量、常量、关键字、数据类型、运算符、数组、函数、指针、结构体外,还要学习&nbsp;C++&nbsp;的面向对象编程思想、命名空间&nbsp;namespace、引用、函数扩展、类的封装、构造和析构、继承、多态、异常处理等内容。语言部分的学习建议不要拖太久,一定要规划好时间,一鼓作气,不然自己容易泄气!1.视频推荐此时同学们应该是毫无基础或者稍微有点&nbsp;C&nbsp;语言基础的小白。对于小白来说,不建议上来就看书,因为干看看不懂,容易劝退。可以先从视频教程开始,教材为辅。我当初&nbsp;C++&nbsp;视频是在&nbsp;b&nbsp;站看的黑马程序员的&nbsp;C++&nbsp;课程(我不是他们的托儿从&nbsp;0&nbsp;到&nbsp;1&nbsp;教&nbsp;C++,三百多个小节,每个小节时间都不是很长,除了个别几个在二十多分钟,其余的基本上都在几分钟到十几分钟之间。每一个阶段都会有相应的小项目教学,对初学者来说是很友好的。看视频的时候不是看看就过去了,编程毕竟是门一门手艺活,孰能生巧。建议一边看,一边将视频中的示例或者小项目教学自己也实现一下,刚开始不会可以照着敲,比只看不动手强一百倍。此外,我最近发现深蓝学院出品的「C++&nbsp;基础与深度解析」课程也很不错,深入基础,讲解语法细节。从基础语法讲到&nbsp;Modern&nbsp;C++,从面向过程开发到新编程范式,对大家学习&nbsp;C++&nbsp;很有帮助。2.书籍推荐入门阶段的书籍为辅,怎么为辅呢?就是视频看完一个阶段,然后就可以去看书上对应阶段的内容,这样看书,一方面看书的时候会很快,容易理解,另一方面可以印证自己在看视频的时候一些不太理解的地方。入门阶段推荐两本书,一本薄的,一本厚的,都是超级经典的书籍。《Essential&nbsp;C++》《Essential&nbsp;C++》是一本内容不多但很实用的&nbsp;C++&nbsp;入门书籍,这本书强调的是快速上手与理解&nbsp;C++&nbsp;编程。主要围绕一系列逐渐复杂的程序问题,以及用以解决这些问题的语言特性展开讲解。你不只学到&nbsp;C++&nbsp;的函数和结构,也会学习到它们的设计目的和基本原理。《C++&nbsp;Primer&nbsp;Plus》&amp;《C++&nbsp;Primer》很多人&nbsp;C++&nbsp;入门的时候会推荐《C++&nbsp;Primer&nbsp;Plus》,很多人&nbsp;C++&nbsp;入门的时候会推荐《C++&nbsp;Primer&nbsp;Plus》,我当年先看的也是这本书,当年&nbsp;C&nbsp;语言除了学校的教材,我看的就是《C&nbsp;Primer&nbsp;Plus》。这本书怎么说的,讲的超级全面,甚至有点过于全面了,书中的例子和课后习题循序渐进,不夸张的讲所有的知识点可能都囊括进去了,作者可能为了怕大家学不明白,讲的巨细,甚至我感觉都有点啰嗦,造成这本书巨厚,字又巨小,看完感觉近视又加了几度。当时我学习的时候《C++&nbsp;Primer》还是第&nbsp;4&nbsp;版,现在都到第&nbsp;5&nbsp;版了!《C++&nbsp;Primer》堪称&nbsp;C++&nbsp;语法学习的最权威书籍,非常全面地讲解了C++的语法以及C++11的各种新特性,看完之后真的帮助特别大!如果有时间建议至少看两遍以上!时面向&nbsp;C++&nbsp;语言的初学者,是一本很友好的自学教材!而且例程和习题丰富,相信认真读过之后,可以完成&nbsp;C++&nbsp;语言入门这个目标!!如果你在这个阶段觉得差不多了,可以尝试找一些在线的练习题做下,如果你不知道去哪找,那可以去下面这个初学者练习编程巩固语法的绝佳去处。它有专门的&nbsp;C++&nbsp;入门编程练习题,专门练习语法和大家的编程逻辑,从变量、数据类型这些基础语法,到数组、字符串这种复合类型,再到函数、面向对象,以及在&nbsp;C++&nbsp;中很重要的&nbsp;STL,最后再来点综合练习,差不多&nbsp;70&nbsp;多道题,够你练的。除了编程练习以外,如果你想知道你自己的知识点掌握的如何,也可以做一下专项练习。以类似试卷的形式,可以很好的检验自己的学习成果,不管是对之后应对考试,或者应付笔试面试都很有帮助。二、进阶阶段在进阶阶段,你已经对&nbsp;C++&nbsp;有一定的认知了。这个时候我们可以深入学习&nbsp;C++&nbsp;标准模板库(STL)、设计模式、数据结构基础以及&nbsp;UI&nbsp;界面开发、数据库开发等高级技能。1.书籍推荐《C++标准程序库》关于&nbsp;STL,可以先读这本侯捷老师翻译的《C++&nbsp;标准程序库》。通过这本书对STL有个基本认识,学会使用&nbsp;STL。《STL源码剖析》读完&nbsp;《C++&nbsp;标准程序库》,就可以来读这本侯捷老师编写的《STL源码剖析》了。这本书建议必读!这本书讲解了&nbsp;C++&nbsp;底层实现,主要包括&nbsp;C++&nbsp;底层内存管理、各种容器的数据结构实现、常见算法的实现等。可以帮助深入理解C++底层,同时也是对数据结构的复习和巩固。《Effective&nbsp;C++》《Effective&nbsp;C++》讲了&nbsp;C++&nbsp;编程的&nbsp;55&nbsp;条准则,提高你的&nbsp;C++&nbsp;编程质量,也是侯捷老师翻译的!这本书有助于梳理在编写&nbsp;C++&nbsp;程序时的一些常见错误和注意事项,也是面试常考的。《深度探索C++对象模型》《深度探索C++对象模型》这本书讲解了C++面向对象特性的底层实现机制。侯捷老师翻译的,看完这本书,对C++面向对象的理解帮助极大,建议必读!2.视频推荐不知道大家注意了没,上面我推荐了四本书,都和一个人有关:侯捷老师。书要么是他翻译的,要么是他写的,C++&nbsp;领域&nbsp;YYDS!同意吧?侯捷老师当然也有讲课,针对书都有对应内容的视频课程!三、应用阶段其实编程语言就是要多练,怎么多练,就是代码量。自己多写,然后多观摩别人的项目,看人家的写法,模仿项目,学习其中的思想,一点点的积累,一步步形成自己的东西,厚积而薄发,慢慢你就会发现你也可以了。面经可以参考c++面经&nbsp;总结的很详细https://www.nowcoder.com/creation/manager/columnDetail/MJNwoM
点赞 评论 收藏
分享
头像
02-21 16:31
长沙理工大学
大家好,今天分享一个很贴合目前校招时间段的提问:Up你好,本人双非本科大四,软件工程专业。大学前两年因为感觉前端好学,岗位也多选择学习前端。但那时比较懒散,课也多,所以前端也没有学多好。后来互联网寒冬,觉得出去不好找工作。就在大三下开始准备考研,但在去年10月份放弃考研(因为家里的一些事故,一个半月没有复习考研),处理好后,剩70多天感觉考不上值得上的学校。所以干脆准备就业,但感觉前端这个方向特别凉,于是换成了Linux&nbsp;c++方向(为此拒绝了一个前端实习)10月底到现在复习了c语言,学习了C++语法,特性,包括STL这些。学习了Linux系统编程进程线程,网络编程tcp/udp,多路转接,l...
牛客230000345号:毕业入坑两年,提点参考的东西吧,建议边找边备研,学历才是第一生产力,后期如果你要职业发展,这是最基本的几个了,工作和晋升除了项目经验,不就是比的派个人学历、吹牛能力和一堆头衔了(晋升的话,派系很重要)。 工作方面,不了解服务端,但是你可以看招聘,其实相比来说qt在客户端和服务端都可以用到,而且跨平台兼容性好,而且qt不就是c+++吗(学好c++,用哪个框架都不头痛),qt不只是给你个UI界面,封装的很多东西都是可以借鉴的。看你想去哪个城市,现在长沙软件行情不好,真心建议没上岸可以去深圳看看,长沙这边工资对标深圳砍半(眼泪流下来),长沙不少大一点私企面试的也开始卷学历卷项目(双非泪奔),如果想去国企你要能吹当然也可以(其实国企也就那12%的公积金了,并不稳定,但是稳定穷是肯定的)。 想去好一点的,建议把基础打牢,学历一定要提高(长期发展一定要,国内还是不少地方学历论的),如果有实习期建议能参与公司项目就参与,不然只会被拷打,最好从项目或者demo里把设计模式、指针、特性、模板、多线程实现并发并行、通讯协议、数据库这些基本的学会一部分,建议再学学qml和Linux,最好学一点嵌入式(Linux用在嵌入式板挺多的),掌握一门脚本语言(Python,Python,Python)和git或者svn代码管理,没签合同(不是三方),你还是校招生,校招只有一次(当然也可以说是本科一次,硕士一次,博士一次),用了错过就没有了,好多公司最喜欢招应届生了,一张白纸(又便宜又容易被PUA)。 最后,其实纠结这么多,不如第一份工作就选你最喜欢的编程语言、框架和操作系统,反正都是牛马,也不一定只吃一家喂的草
点赞 评论 收藏
分享
评论
7
69
分享

创作者周榜

更多
牛客网
牛客企业服务