4、CUDA并行计算基础:向量加法实现、矩阵乘法实现、性能分析工具nvprof/Nsight Systems初探

好,咱们直接进入正题。这一章是CUDA并行计算的“手把手”环节。说白了,就是让你亲手写出第一个能跑在GPU上的程序,然后看看它到底有多快。

我个人习惯,学任何并行框架,先搞定两个例子:向量加法和矩阵乘法。前者让你理解“线程怎么干活”,后者让你理解“线程怎么协作干活”。搞懂了这两个,后面再学卷积、Transformer什么的,心里就有底了。

4.1 向量加法:Hello World的GPU版

先来个最简单的。两个数组对应元素相加。CPU上写个for循环就完事,GPU上怎么写?

嗯,这里要注意:GPU是“单指令多线程”的架构。你写一个函数(叫kernel),然后告诉GPU:“给我启动N个线程,每个线程执行这个函数”。每个线程通过内置变量 threadIdx.x 知道自己是第几个。

核心思想: 一个线程处理一个元素。N个元素,就启动N个线程。

// 向量加法的kernel函数
__global__ void vecAdd(float *A, float *B, float *C, int N) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

// 调用方式(假设N=1024,每个block 256个线程)
int blockSize = 256;
int gridSize = (N + blockSize - 1) / blockSize;
vecAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);

你看,代码就这么几行。但坑在哪?我刚开始学的时候,老忘记做边界检查。万一N不是blockSize的整数倍呢?最后几个线程就会访问越界。所以那个 if (i < N) 是保命符。

我的经验: 写kernel时,第一件事就是算好全局线程ID,第二件事就是做边界检查。养成肌肉记忆,能省掉你后面无数debug时间。

4.2 矩阵乘法:从单线程到共享内存

向量加法太简单了,体现不出GPU的威力。矩阵乘法才是重头戏。你想想看,一个1024x1024的矩阵相乘,CPU上要算10亿次乘加。GPU怎么加速?

最朴素的思路:每个线程算输出矩阵中的一个元素。也就是一个线程负责一行和一列的点积。

__global__ void matMulNaive(float *A, float *B, float *C, int M, int N, int K) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    
    if (row < M && col < N) {
        float sum = 0.0f;
        for (int k = 0; k < K; k++) {
            sum += A[row * K + k] * B[k * N + col];
        }
        C[row * N + col] = sum;
    }
}

这个版本能跑,但性能很差。为什么?因为每个线程都在反复从全局内存读数据。A矩阵的一行会被读N次,B矩阵的一列会被读M次。全局内存的带宽是有限的,这成了瓶颈。

我在项目中遇到过类似问题。当时算一个1280x1280的矩阵乘法,用这个朴素版本跑了快2秒。后来优化到用共享内存,直接降到80毫秒。差距就是这么大。

避坑指南: 我曾经天真地以为GPU算得快,随便写写就能起飞。结果发现,如果不考虑访存模式,性能可能还不如CPU。记住:GPU的算力远大于内存带宽,所以优化访存永远是第一位的。

优化的关键:用共享内存做数据复用。把A的一块子矩阵和B的一块子矩阵加载到共享内存里,然后让一个block内的线程共享这些数据。

// 使用共享内存的矩阵乘法(分块版本)
#define TILE_SIZE 16

__global__ void matMulShared(float *A, float *B, float *C, int M, int N, int K) {
    __shared__ float As[TILE_SIZE][TILE_SIZE];
    __shared__ float Bs[TILE_SIZE][TILE_SIZE];
    
    int bx = blockIdx.x, by = blockIdx.y;
    int tx = threadIdx.x, ty = threadIdx.y;
    
    int row = by * TILE_SIZE + ty;
    int col = bx * TILE_SIZE + tx;
    
    float sum = 0.0f;
    for (int tile = 0; tile < (K + TILE_SIZE - 1) / TILE_SIZE; tile++) {
        // 协作加载数据到共享内存
        if (row < M && tile * TILE_SIZE + tx < K)
            As[ty][tx] = A[row * K + tile * TILE_SIZE + tx];
        else
            As[ty][tx] = 0.0f;
            
        if (col < N && tile * TILE_SIZE + ty < K)
            Bs[ty][tx] = B[(tile * TILE_SIZE + ty) * N + col];
        else
            Bs[ty][tx] = 0.0f;
            
        __syncthreads();  // 确保所有线程都加载完毕
        
        for (int k = 0; k < TILE_SIZE; k++)
            sum += As[ty][k] * Bs[k][tx];
            
        __syncthreads();  // 确保所有线程都算完,再加载下一块
    }
    
    if (row < M && col < N)
        C[row * N + col] = sum;
}

这里有个关键点:__syncthreads()。它是个同步屏障。我刚开始写的时候,经常忘记加这个,结果数据还没加载完就开始算,算出来的全是错的。嗯,这个坑我踩过不止一次。

4.3 性能分析工具:nvprof与Nsight Systems

代码写完了,跑起来了,结果也对。然后呢?你得看看它到底跑得多快,瓶颈在哪。这就轮到性能分析工具上场了。

老牌工具是 nvprof,新工具是 Nsight Systems。我个人的建议:新项目直接用Nsight Systems,nvprof已经逐渐被弃用了。但有些老项目还在用,所以两个都得会。

4.3.1 nvprof快速上手

nvprof用起来很简单,直接在命令行里包一下你的程序就行:

nvprof ./my_cuda_program

它会输出每个kernel的耗时、内存传输时间、占用率等信息。我最常用的几个参数:

  • --print-gpu-trace:显示每个GPU操作的详细时间线
  • --metrics:查看具体指标,比如 --metrics gld_efficiency 看全局内存加载效率

关键指标: 全局内存加载效率(gld_efficiency)。如果低于80%,说明你的访存模式有问题,需要调整数据布局或使用共享内存。

4.3.2 Nsight Systems:可视化分析

Nsight Systems是个图形化工具,能看到整个程序的时间线。CPU在干什么,GPU在干什么,数据传输和kernel执行是不是重叠的,一目了然。

我一般这样用:

  1. 先跑一次 nsys profile -o output_file ./my_program 生成分析文件
  2. 然后用 nsys-ui 打开,看时间线
  3. 重点关注:GPU利用率、内存拷贝时间、kernel启动开销

记得有一次,我发现我的程序GPU利用率只有30%。一看时间线,原来是CPU在忙着准备数据,GPU一直在空等。后来改成异步传输和流水线,利用率直接拉到90%以上。

小技巧: 用Nsight Systems看时间线时,注意看CPU和GPU之间有没有“空隙”。如果有,说明你的程序没有做到CPU和GPU的并行工作。用CUDA Stream可以解决这个问题。

4.4 本章知识体系

下面这张图,是我自己梳理的本章核心逻辑。从最简单的向量加法,到需要协作的矩阵乘法,再到性能分析工具,是一条完整的链路。

CUDA并行计算基础:知识体系 向量加法 矩阵乘法 性能分析工具 线程ID计算 · 边界检查 grid/block 配置策略 朴素实现 · 访存瓶颈分析 共享内存 · 分块优化 __syncthreads() 同步 nvprof 命令行分析 Nsight Systems 时间线 关键指标解读 核心:理解线程模型 → 优化访存模式 → 工具验证瓶颈

说白了,这一章就是让你把CUDA的“三板斧”练熟:怎么写kernel、怎么优化访存、怎么用工具找瓶颈。这三样东西,后面每一章都会用到。

好,这一章就到这。代码示例我都跑过,你直接复制到项目里就能用。如果遇到问题,先检查边界条件,再检查同步,最后用工具看瓶颈。这三步走下来,90%的问题都能解决。

可可精品资料库