算子在做什么,先看一个算例
GEMV 就是矩阵乘向量:y = A · x。矩阵 A 是 M×N,每一行和向量 x 做点积,得到输出 y 的一个分量。
为什么单独关心它:大模型推理的解码阶段每生成一个 token 都要做一次 GEMV——此时 batch 很小(常常是 1),矩阵乘退化成矩阵乘向量。这是推理延迟里最典型的形态,也是各家推理框架反复优化的一类 kernel。
它有一个很鲜明的性格:算术强度极低。看 M=N=4096 的情况:
先算一下它到底受什么限制
这就是为什么 GEMV 的优化手法和 reduce 是同一类:把访存做宽、做合并,而不是减少计算。理想情况下,这个问题的耗时下限就是“把 67.1 MB 从显存读进来”的时间:
一个可以手工核对的算例
取 M=3、N=4 的小例子,A 按行主序存放:
每个 y 分量都要把 A 的一整行走一遍。这里就出现了本文的核心问题:一行有 N 个数,一个线程读不完也等不起。三个版本的差别,全都发生在“这一行谁来读、怎么读”上。
为什么用 warp 而不是 block 来做一行
最自然的想法是“一个 block 负责一行”——但那样每行算完都要在 shared memory 里跨 warp 归约、加栅栏、再写回,开销不小,而 GEMV 的行之间又完全独立、没有复用(x 会被所有行共用,但行与行之间没有数据交换)。
这份实现选的是一个 warp 承包一行:32 个线程分头读这一行的 32 个元素、各自乘上对应的 x、然后靠寄存器内的 __shfl_down_sync 归约成一个值。全程不需要 shared memory、不需要栅栏。代价是每行只用上了 32 个线程,对长行来说并行度偏低——这正是 v1、v2 各自想解决的问题。
代码逐段解析
三个版本共用同一段归约代码,也共用同一套“一个 warp 一行”的骨架,差别只在每个 lane 手里的 res 是怎么算出来的。这一点很关键——看懂了就能明白三个版本的优化目标完全不同。
① 共用地基:warpReduceSum
template <unsigned int WarpSize>
__device__ __forceinline__ float warpReduceSum(float sum) {
if (WarpSize >= 32)sum += __shfl_down_sync(0xffffffff, sum, 16); // 0-16, 1-17, 2-18, etc.
if (WarpSize >= 16)sum += __shfl_down_sync(0xffffffff, sum, 8);// 0-8, 1-9, 2-10, etc.
if (WarpSize >= 8)sum += __shfl_down_sync(0xffffffff, sum, 4);// 0-4, 1-5, 2-6, etc.
if (WarpSize >= 4)sum += __shfl_down_sync(0xffffffff, sum, 2);// 0-2, 1-3, 4-6, 5-7, etc.
if (WarpSize >= 2)sum += __shfl_down_sync(0xffffffff, sum, 1);// 0-1, 2-3, 4-5, etc.
return sum;
}
五行取值、五步归约。模板参数 WarpSize 不是传给 shuffle 的 width(那份掩码一直是默认的 32),它只控制“从哪个 delta 起步”:
这个模板参数在 v0、v1 里都传 32,只有 v2 传 16——因为 v2 把 warp 劈成了两半。
当“源 lane”(自己下标 + delta)越界时,PTX 的语义是取自己的输入值,而不是保持不变。落到 sum += __shfl_down_sync(...) 上就等价于 sum += sum——值直接翻倍。这就是为什么陪跑的 lane 数值会越滚越大。lane 0 不受影响,因为它的依赖链 16→8→4→2→1 全程都不越界。所以这段代码的结果是对的,但除了 lane 0 之外,其他 lane 的值没有意义,不能拿去用。
② v0:一个 warp 一行,标量读
// if N == 32
__global__ void Sgemv_v0(
float * __restrict__ A,
float * __restrict__ x,
float * __restrict__ y,
const int M,
const int N) {
// Block index
int bx = blockIdx.x;
// Thread index
int tx = threadIdx.x;
int ty = threadIdx.y;
const int warp_size=32;
int laneId= tx % warp_size;
int current_row = blockDim.y * bx + ty;
if(current_row < M){
float res=0;
int kIteration = N/warp_size;
if(kIteration==0) kIteration=1;
#pragma unroll
for(int i=0; i< kIteration; i++){
int current_col = i*warp_size + laneId;
res += A[current_row*N + current_col] * x[current_col];
}
res = warpReduceSum<warp_size>(res);
if(laneId==0) y[current_row]=res;
}
}
访存是最理想的情形:同一轮里 32 个 lane 读的 A 地址是row*N + i*32 + laneId——lane 之间连续,合起来正好 32 × 4 = 128 字节,一个完整事务。x 的访问也同理。所以 v0 的合并度天然是满的,没有任何访存浪费。
它的限制写在文件顶部那行注释里:// if N == 32。但代码实际上写的是 kIteration = N/warp_size; if(kIteration==0) kIteration=1;——只要 N 是 32 的倍数就能跑,注释和代码口径并不一致。真正硬的要求是 N ≥ 32:N 小于 32 时 kIteration 会被兜底成 1,于是 lane 会去读第 0~31 列,而这一行根本没有那么多列——越界。
③ v1:把标量读换成 float4
// if N>= 128
__global__ void Sgemv_v1(
float * __restrict__ A,
float * __restrict__ x,
float * __restrict__ y,
const int M,
const int N) {
// Block index
int bx = blockIdx.x;
// Thread index
int tx = threadIdx.x;
int ty = threadIdx.y;
const int warp_size=32;
int laneId= tx % warp_size;
int current_row = blockDim.y * bx + ty;
if(current_row < M){
float res=0;
int kIteration = (N/warp_size)/4;
if(kIteration==0) kIteration=1;
A = &A[current_row*N];
#pragma unroll
for(int i=0; i< kIteration; i++){
int current_col_vec = (i*warp_size + laneId);
float4 current_val= reinterpret_cast<float4 *>(A)[current_col_vec];
float4 current_x = reinterpret_cast<float4 *>(x)[current_col_vec];
res += current_val.x*current_x.x;
res += current_val.y*current_x.y;
res += current_val.z*current_x.z;
res += current_val.w*current_x.w;
}
res = warpReduceSum<warp_size>(res);
if(laneId==0) y[current_row]=res;
}
}
v0 每轮每 lane 读 4 字节,一个 warp 一轮一共搬 128 字节。v1 把 float4(16 字节)搬进来,一个 lane 一轮读 4 个 float,于是循环轮数从 N/32 降到 (N/32)/4 = N/128——访存指令数直接除以 4,每线程在飞的字节数乘以 4。
最容易算错的地方是 reinterpret_cast<float4*>(A)[current_col_vec] 的下标语义:current_col_vec 是 float4 的下标,不是 float 的下标。A = &A[current_row*N] 先把指针挪到本行行首(这一步是 float 下标),之后再按 float4 索引。第 c 个 float4 覆盖的正是第 4c ~ 4c+3 个 float。
硬约束:N % 128 == 0 且 N ≥ 128(要保证 (N/32)/4 ≥ 1)。摊销下来每个 lane 的归约成本没变,但装载阶段的指令数少了四分之三。
④ v2:一个 warp 同时管两行
template <
const int ROW_PER_WARP
>
__global__ void Sgemv_v2(
float * __restrict__ A,
float * __restrict__ x,
float * __restrict__ y,
const int M,
const int N) {
// Block index
int bx = blockIdx.x;
// Thread index
int tx = threadIdx.x;
int ty = threadIdx.y;
const int warp_size=32;
int laneId= tx % warp_size;
int current_warp_row = (blockDim.y * bx + ty) * ROW_PER_WARP;
const int kWarp_size = warp_size / ROW_PER_WARP;
int kLaneId = laneId % kWarp_size;
int current_thread_row = current_warp_row + laneId / kWarp_size;
if(current_thread_row < M){
float res=0;
int current_col = kLaneId;
res += A[current_thread_row * N + current_col] * x[current_col];
res = warpReduceSum<kWarp_size>(res);
if(kLaneId==0) y[current_thread_row]=res;
}
}
这一版换了思路。前面两版都是“一行用满 32 个 lane”,但如果 N 很小——比如 N=16,一行只有 16 个数——32 个 lane 里就有 16 个是闲着的,并行度浪费一半。
v2 把 warp 从中间劈成两半:ROW_PER_WARP = 2,0~15 号 lane 管一行、16~31 号 lane 管另一行。
两个变化值得注意。归约宽度从 32 降到 16:warpReduceSum<16> 跳过最贵的 delta=16 那一步,5 步变 4 步。装载回到标量:一行只有 16 个数,再上 float4 是没有意义的(float4 一次就要 4 个,16 个元素只够 4 条指令),所以 v2 用回了最简单的逐元素读。
它的适用范围注释写的是 // if N <= 16,但更准确的说法是 N 必须恰好等于 16:N 更小时应上面还有空闲 lane,N 更大时一行覆盖不全。这也是 v2 没有成为通用版本的原因——它专门为“行特别短”的场景设计。
v0 解决“怎么让一个 warp 协作算出一行”(骨架正确、访存合并)——这是地基。
v1 解决“装载指令太多”(N 大、行长的场景)。
v2 解决“一行太短、lane 闲着”(N 小的场景)。
换行长度就要换版本,这也是 GEMV 这个算子有意思的地方:它的最优实现取决于形状,没有一个版本能通吃。
完整代码
下面贴出 v1/sgemv_v1.cu 全文(159 行)。选 v1 而不是目录里的最后一个 v2,是因为 v1 针对的是“N 较大”这个实际推理场景的常态,而 v2 只在 N 恰好等于 16 时才能用。
// Copy from https://github.com/Liu-xiandong/How_to_optimize_in_GPU/blob/master/sgemv/Sgemv_v1.cu
#include <stdio.h>
#include <stdlib.h>
// CUDA runtime
#include <cuda_runtime.h>
#include <cublas_v2.h>
#include <math.h>
// cal offset from row col and ld , in row-major matrix, ld is the width of the matrix
#define OFFSET(row, col, ld) ((row) * (ld) + (col))
// transfer float4
#define FETCH_FLOAT4(pointer) (reinterpret_cast<float4*>(&(pointer))[0])
#define checkCudaErrors(func) \
{ \
cudaError_t e = (func); \
if(e != cudaSuccess) \
printf ("%s %d CUDA: %s\n", __FILE__, __LINE__, cudaGetErrorString(e)); \
}
template <unsigned int WarpSize>
__device__ __forceinline__ float warpReduceSum(float sum) {
if (WarpSize >= 32)sum += __shfl_down_sync(0xffffffff, sum, 16); // 0-16, 1-17, 2-18, etc.
if (WarpSize >= 16)sum += __shfl_down_sync(0xffffffff, sum, 8);// 0-8, 1-9, 2-10, etc.
if (WarpSize >= 8)sum += __shfl_down_sync(0xffffffff, sum, 4);// 0-4, 1-5, 2-6, etc.
if (WarpSize >= 4)sum += __shfl_down_sync(0xffffffff, sum, 2);// 0-2, 1-3, 4-6, 5-7, etc.
if (WarpSize >= 2)sum += __shfl_down_sync(0xffffffff, sum, 1);// 0-1, 2-3, 4-5, etc.
return sum;
}
// if N>= 128
__global__ void Sgemv_v1(
float * __restrict__ A,
float * __restrict__ x,
float * __restrict__ y,
const int M,
const int N) {
// Block index
int bx = blockIdx.x;
// Thread index
int tx = threadIdx.x;
int ty = threadIdx.y;
const int warp_size=32;
int laneId= tx % warp_size;
int current_row = blockDim.y * bx + ty;
if(current_row < M){
float res=0;
int kIteration = (N/warp_size)/4;
if(kIteration==0) kIteration=1;
A = &A[current_row*N];
#pragma unroll
for(int i=0; i< kIteration; i++){
int current_col_vec = (i*warp_size + laneId);
float4 current_val= reinterpret_cast<float4 *>(A)[current_col_vec];
float4 current_x = reinterpret_cast<float4 *>(x)[current_col_vec];
res += current_val.x*current_x.x;
res += current_val.y*current_x.y;
res += current_val.z*current_x.z;
res += current_val.w*current_x.w;
}
res = warpReduceSum<warp_size>(res);
if(laneId==0) y[current_row]=res;
}
}
int main(int argc, char** argv) {
if (argc != 3) {
printf("usage: ./main [M] [N]\n");
exit(0);
}
size_t M = atoi(argv[1]);
size_t N = atoi(argv[2]);
size_t bytes_A = sizeof(float) * M * N;
size_t bytes_x = sizeof(float) * N;
size_t bytes_y = sizeof(float) * M;
float* h_A = (float*)malloc(bytes_A);
float* h_x = (float*)malloc(bytes_x);
float* h_y = (float*)malloc(bytes_y);
float* h_y1 = (float*)malloc(bytes_y);
float* d_A;
float* d_x;
float* d_y;
checkCudaErrors(cudaMalloc(&d_A, bytes_A));
checkCudaErrors(cudaMalloc(&d_x, bytes_x));
checkCudaErrors(cudaMalloc(&d_y, bytes_y));
// 生成A的数据
for( int i = 0; i < M * N; i++ ) {
h_A[i] = (float)i/N;
}
// 生成x的数据
for( int i = 0; i < N; i++ ) {
h_x[i] = 1;
}
memset(h_y,0,M*sizeof(float));
memset(h_y1,0,M*sizeof(float));
int nIter = 1000;
checkCudaErrors(cudaMemcpy( d_A, h_A, bytes_A, cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy( d_x, h_x, bytes_x, cudaMemcpyHostToDevice));
checkCudaErrors(cudaMemcpy( d_y, h_y, bytes_y, cudaMemcpyHostToDevice));
for (int run = 0 ; run < nIter; run ++ ) {
dim3 dimGrid(M/4);
dim3 dimBlock(32,4);
Sgemv_v1<<< dimGrid, dimBlock >>>(d_A, d_x, d_y, M, N);
}
checkCudaErrors(cudaMemcpy( h_y, d_y, bytes_y, cudaMemcpyDeviceToHost));
// cublas
cublasHandle_t blas_handle;
cublasCreate(&blas_handle);
float alpha = 1.0;
float beta = 0;
checkCudaErrors(cudaMemcpy( d_y, h_y1, bytes_y, cudaMemcpyHostToDevice));
for (int run = 0 ; run < nIter; run ++ ) {
cublasSgemv (blas_handle, CUBLAS_OP_T,
N, M, &alpha,
d_A, N, d_x, 1, &beta, d_y, 1
);
}
checkCudaErrors(cudaMemcpy( h_y1, d_y, bytes_y, cudaMemcpyDeviceToHost));
cublasDestroy(blas_handle);
double eps = 1.e-6; // machine zero
bool correct = true;
for (int i = 0; i < M; i++) {
double abs_err = fabs(h_y[i] - h_y1[i]);
double dot_length = M;
double abs_val = fabs(h_y[i]);
double rel_err = abs_err / abs_val / dot_length;
if (rel_err > eps) {
printf("Error! Matrix[%05d]=%.8f, ref=%.8f error term is > %E\n",
i, h_y[i], h_y1[i], eps);
correct = false;
break;
}
}
printf("%s\n", correct ? "Result= PASS" : "Result= FAIL");
// Free Memory
cudaFree(d_A);
cudaFree(d_x);
cudaFree(d_y);
free(h_A);
free(h_x);
free(h_y);
free(h_y1);
}
v0 与 v2 的 kernel 全文已经在上面的逐段解析里完整给出(第 ②、④ 段)。三个文件的 main 结构几乎相同:解析命令行参数 [M] [N]、生成数据(A[i] = i/N、x 全 1,于是 y[i] 约等于 i,便于肉眼核对)、连跑 nIter = 1000 次、再用 cublasSgemv 对拍。差别只有启动配置那一行:
| 版本 | grid | block | 模板实参 |
|---|---|---|---|
| v0 | M / 4 | (32, 4) | — |
| v1 | M / 4 | (32, 4) | — |
| v2 | M / 8 | (32, 4) | <2>(ROW_PER_WARP) |
那个 nIter = 1000 的循环不是在测时间,而是为了让 kernel 跑得够久、给 ncu 之类的 profiler 留出足够的采样窗口。文件里没有 cudaEvent,也没有任何耗时/带宽打印。本文表格里的数字是另加计时后测出来的。
另外,仓库的 main 用 CUBLAS_OP_T 做对拍是对的:行主序的 A 在 cuBLAS 眼里是列主序的 Aᵀ,所以要算 A·x 必须传 CUBLAS_OP_T 并把维度写成 (N, M)。我第一版基准把它写成了 CUBLAS_OP_N,结果因为测试矩阵恰好对称而“看起来通过”——这类错误很容易骗过自己,所以本文的对照同时用 CPU 参考实现交叉验证过。
实测性能对比分析
测两个形状。M=N=4096 是常规推理形状,A 占 64 MiB、总访存 67.1 MB;M=1024、N=16 是 v2 专门针对的短行场景,总访存只有 69.6 KB——这个规模下耗时基本由 kernel 启动开销决定,看的应该是绝对微秒数而不是带宽。对照实现是 cublasSgemv,并在 host 侧用 CPU 逐元素算了一遍真值交叉验证。
M = N = 4096(常规形状)
| 实现 | 耗时 (µs) | 等效带宽 (GB/s) | 占峰值 672 GB/s |
|---|---|---|---|
| v0(warp 一行 + 标量读) | 108.99 | 616.0 | 91.7% |
| v1(warp 一行 + float4) | 108.80 | 617.1 | 91.8% |
| cuBLAS | 112.93 | 594.6 | 88.5% |
三个实现全部正确(对 CPU 真值的最大误差是 0)。结论有两点:第一,都跑到了带宽上限附近——91.8% 的峰值占用,距离 100 µs 的理论下限只差 9%;第二,v0 和 v1 的差距小到看不出来(0.2 µs)。
为什么 float4 向量化在 4096 这个规模上没能拉开差距?因为 v0 的访存本来就是完美合并的:32 个 lane 读连续的 128 字节,一个事务搬完。v1 把指令条数减到四分之一,但瓶颈在 DRAM 带宽上,而 L1 早就把 v0 那些 4 字节请求合并成同样的事务了——指令发得少,DRAM 那边要搬的字节一个也不少。
这和最大化带宽利用率的思路是一致的:当合并度已经满分时,减少指令数只对“指令发射受限”的负载有用;纯带宽受限时它不改变 DRAM 的搬运量。顺带说,两个手写 kernel 都略微快过 cuBLAS(约 3%)——在这个形状下,cuBLAS 也没能做得更好。
M = 1024,N = 16(短行形状)
| 实现 | 耗时 (µs) | 等效带宽 (GB/s) | 说明 |
|---|---|---|---|
| v0 | 1.664 | 41.9 | 超出适用范围:N < 32 会越界读 |
| v2(warp 劈两半) | 1.504 | 46.3 | 正确 |
| cuBLAS | 4.512 | 15.5 | 对小形状没有专门优化 |
这个规模下的绝对耗时都在微秒级,绝大部分是 kernel 启动开销(本机实测一个空 kernel 大约 1.5~2 µs),所以带宽数字没有意义,要看的是相对关系。v2 比 cuBLAS 快了 3 倍——但更该注意的是 v2 比 v0 只快 0.16 µs,而这 0.16 µs 里还混着测量噪声。
换句话说,v2 那套“把 warp 劈成两半、归约宽度降到 16”的改动,在这个规模上带来的收益已经被启动开销淹没了。它的价值要在“大量短行、且 kernel 已经启动了”的场景里才体现得出来(比如一次 kernel 里处理几千行)。
v0 在 N=16 下确实打印了“结果正确”,但那是运气:它按 N=32 的循环去读第 16~31 列,读到了行外的内存;碰巧 x 数组后面的内存是 0,乘出来的贡献是 0,于是结果看起来对了。换个内存布局或换个分配器,它就会给出错误答案。越界读返回“正确答案”是这类 bug 最难查的形态。
关于 A100 上的数据
和前三篇不同,gemv 目录里没有任何实测性能数据——README 只有一句“用一个 warp 处理一行或者 2 行”,配套的 Note.md 里给的是理论推算而非测量:
这个 43 µs 是推算值,不是在 A100 上跑出来的,(而且那份文档借用的是 reduce 目录记录的硬件环境)。所以本文的性能一节只有本机 RTX 5070 的数据,没有跨卡对照。
小结
GEMV 这一篇的实测结果,比前几篇更“反直觉”,因为它没有出现预期的加速:v1 的 float4 向量化只快了 0.2%,v2 的半个 warp 方案只快了 0.16 µs。
v1 的情况是“已经到顶”。v0 的访存本来就完美合并——32 个 lane 读连续 128 字节,一个事务搬完,合并度已经是 100%。v1 减掉的是指令条数,但 DRAM 要搬的字节数一个没少。带宽受限的负载上,指令数不是瓶颈。
v2 的情况是“收益被启动开销吃掉”。N=16 时整个问题只有 69.6 KB 的数据,kernel 跑 1.5 µs,其中大部分是启动开销。任何算法层面的改进都会被这个固定成本淹没。
这两条合起来是一个很实用的判据:优化之前先确认瓶颈在哪一层。GEMV 这类算子的瓶颈通常在两个地方之一——要么是 DRAM 带宽(那就只能靠提高合并度、减少无效搬运,而 v0 已经把这件事做到了),要么是 kernel 启动/调度开销(那就该考虑把多个小 kernel 合并,或者换更大的 batch)。“把访存向量化”这个标准手法,在这两种瓶颈下都帮不上忙。
值得一提的是,三个手写版本里最“朴素”的 v0 在这两个形状下都没吃亏。它的骨架——一个 warp 一行、lane 之间连续读、寄存器内 shuffle 归约——已经把 GEMV 的主要矛盾解决掉了,后面的版本都只是在特定形状上抠细节。
下一篇:半精度原子加——换一个完全不同的问题:3350 万个线程往同一个地址做原子加,看怎么把每次操作的宽度从 2 字节凑到 4 字节。