GEMV 矩阵向量乘

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

GEMV 就是矩阵乘向量:y = A · x。矩阵 A 是 M×N,每一行和向量 x 做点积,得到输出 y 的一个分量。

y = A · x A: M×N(行主序) x: 长度 N y: 长度 M 逐元素展开: y[i] = Σ(j = 0 .. N-1) A[i][j] * x[j] i ∈ [0, M) 也就是 M 次独立的点积,每次长度为 N。

为什么单独关心它:大模型推理的解码阶段每生成一个 token 都要做一次 GEMV——此时 batch 很小(常常是 1),矩阵乘退化成矩阵乘向量。这是推理延迟里最典型的形态,也是各家推理框架反复优化的一类 kernel。

它有一个很鲜明的性格:算术强度极低。看 M=N=4096 的情况:

先算一下它到底受什么限制

要读的数据:A 占 4096 × 4096 × 4 B = 64 MiB x 占 4096 × 4 B = 16 KiB y 占 4096 × 4 B = 16 KiB ──────────────────────────────────── 合计约 67.1 MB 要算的运算:4096 × 4096 = 16.7 M 次乘加 = 33.5 MFLOP 算术强度 = 33.5e6 FLOP ÷ 67.1e6 字节 ≈ 0.5 FLOP/字节 RTX 5070 的平衡点 ≈ 算力 ÷ 带宽(远大于 0.5) 结论:纯 memory-bound —— 每搬 4 个字节只换来 1 次乘加,计算完全藏不住访存。

这就是为什么 GEMV 的优化手法和 reduce 是同一类:把访存做宽、做合并,而不是减少计算。理想情况下,这个问题的耗时下限就是“把 67.1 MB 从显存读进来”的时间:

理想耗时 = 67.1e6 字节 ÷ 672 GB/s ≈ 100 µs (RTX 5070 理论峰值) 本轮最好的实测是 108.8 µs,也就是峰值的 91.8%。

一个可以手工核对的算例

取 M=3、N=4 的小例子,A 按行主序存放:

A = [ 1 2 3 4 ] x = [ 2 ] [ 5 6 7 8 ] [ 1 ] [ 9 10 11 12 ] [ 0 ] [ 3 ] y[0] = 1*2 + 2*1 + 3*0 + 4*3 = 2 + 2 + 0 + 12 = 16 y[1] = 5*2 + 6*1 + 7*0 + 8*3 = 10 + 6 + 0 + 24 = 40 y[2] = 9*2 + 10*1 + 11*0 + 12*3 = 18 + 10 + 0 + 36 = 64 y = [16, 40, 64]

每个 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

sgemv_v1.cu : 23-31 · 三个版本一字未改CUDA C++
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 起步”:

warpReduceSum<32> → 跑 delta = 16, 8, 4, 2, 1 共 5 步,收 32 个 lane warpReduceSum<16> → 跑 delta = 8, 4, 2, 1 共 4 步,收 16 个 lane (最贵的 delta=16 那一步在编译期被剪掉了)

这个模板参数在 v0、v1 里都传 32,只有 v2 传 16——因为 v2 把 warp 劈成了两半。

__shfl_down_sync 的一个坑

当“源 lane”(自己下标 + delta)越界时,PTX 的语义是取自己的输入值,而不是保持不变。落到 sum += __shfl_down_sync(...) 上就等价于 sum += sum——值直接翻倍。这就是为什么陪跑的 lane 数值会越滚越大。lane 0 不受影响,因为它的依赖链 16→8→4→2→1 全程都不越界。所以这段代码的结果是对的,但除了 lane 0 之外,其他 lane 的值没有意义,不能拿去用。

② v0:一个 warp 一行,标量读

sgemv_v0.cu : 35-65 · kernel 全文CUDA C++
// 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;
    }
}
线程组织:dimBlock(32, 4) → 一个 block 里有 4 个 warp = 4 行 dimGrid(M/4) → 共 M/4 个 block laneId = tx % 32 本 lane 在 warp 内的编号 current_row = blockDim.y * bx + ty 本 warp 负责的行号 循环 N/32 轮,第 i 轮: current_col = i*32 + laneId res += A[current_row*N + current_col] * x[current_col]

访存是最理想的情形:同一轮里 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

sgemv_v1.cu : 33-69 · kernel 全文CUDA C++
// 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。

v0:循环 N/32 轮,每轮每 lane 1 条 4 字节读 v1:循环 N/128 轮,每轮每 lane 1 条 16 字节读(float4) 合并度不变(lane 之间仍然连续),变的只是指令条数。 一行 4 个分量的乘加在寄存器里展开: res += current_val.x * current_x.x; res += current_val.y * current_x.y; ← 这 4 条会进流水线重叠, res += current_val.z * current_x.z; 不会变成一条 4 步的依赖链 res += current_val.w * current_x.w;

最容易算错的地方是 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 同时管两行

sgemv_v2.cu : 34-64 · kernel 全文CUDA C++
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 管另一行。

kWarp_size = 32 / ROW_PER_WARP = 16 每个半区 16 个 lane kLaneId = laneId % 16 本 lane 在半个 warp 内的编号 current_thread_row = current_warp_row + laneId / 16 属于哪一行 res = A[current_thread_row * N + kLaneId] * x[kLaneId] ← 回归标量读 res = warpReduceSum<kWarp_size>(res) ← 传 16,剪掉 delta=16 那步 if (kLaneId == 0) y[current_thread_row] = res ← 每个半区的 0 号 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 时才能用。

sgemv_v1.cu · 全文 159 行CUDA C++
// 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 对拍。差别只有启动配置那一行:

版本gridblock模板实参
v0M / 4(32, 4)—
v1M / 4(32, 4)—
v2M / 8(32, 4)<2>(ROW_PER_WARP)
三份 main 里都没有计时代码

那个 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.99616.091.7%
v1(warp 一行 + float4)108.80617.191.8%
cuBLAS112.93594.688.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)说明
v01.66441.9超出适用范围:N < 32 会越界读
v2(warp 劈两半)1.50446.3正确
cuBLAS4.51215.5对小形状没有专门优化

这个规模下的绝对耗时都在微秒级,绝大部分是 kernel 启动开销(本机实测一个空 kernel 大约 1.5~2 µs),所以带宽数字没有意义,要看的是相对关系。v2 比 cuBLAS 快了 3 倍——但更该注意的是 v2 比 v0 只快 0.16 µs,而这 0.16 µs 里还混着测量噪声。

换句话说,v2 那套“把 warp 劈成两半、归约宽度降到 16”的改动,在这个规模上带来的收益已经被启动开销淹没了。它的价值要在“大量短行、且 kernel 已经启动了”的场景里才体现得出来(比如一次 kernel 里处理几千行)。

v0 那一行不能当真

v0 在 N=16 下确实打印了“结果正确”,但那是运气:它按 N=32 的循环去读第 16~31 列,读到了行外的内存;碰巧 x 数组后面的内存是 0,乘出来的贡献是 0,于是结果看起来对了。换个内存布局或换个分配器,它就会给出错误答案。越界读返回“正确答案”是这类 bug 最难查的形态。

关于 A100 上的数据

和前三篇不同,gemv 目录里没有任何实测性能数据——README 只有一句“用一个 warp 处理一行或者 2 行”,配套的 Note.md 里给的是理论推算而非测量:

Note.md 里的理论推演(M = N = 4096): 算术强度 ≈ 0.5 FLOP/字节 A100 峰值带宽 1555 GB/s、FP32 峰值算力约 19.5 TFLOPS 平衡点 = 19.5e12 ÷ 1555e9 ≈ 12.5 FLOP/字节 0.5 与 12.5 相差 25 倍 → 纯 memory-bound 访存下限 = 67.1e6 字节 ÷ 1555 GB/s ≈ 43 µs

这个 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 字节。