并行计算模式:从向量加法到矩阵乘法

各位同学,今天我们来聊聊并行计算模式。说实话,这是CUDA编程里最基础也最核心的内容。我刚开始接触GPU编程时,总觉得并行计算很玄乎,后来发现,说白了就是把一个大任务拆成很多小任务,让GPU里成千上万个线程同时干活。

向量加法:Hello World级别的并行

向量加法是并行计算的入门案例。两个数组对应元素相加,这个操作天然就是并行的——每个元素的计算互不依赖。

核心思路:每个线程处理一个元素,线程ID与数组索引一一对应。

__global__ void vecAdd(float *A, float *B, float *C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

这段代码看起来简单,但有个细节我吃过亏。你看那个if (i < N)的判断,为什么要加?因为数据总量不一定能被线程块大小整除。我曾经在项目里忘了加这个边界检查,结果数组末尾多写了一些垃圾数据,排查了半天。

调用方式是这样的:

int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

这里有个小技巧:用(N + threadsPerBlock - 1) / threadsPerBlock来计算线程块数量,可以保证线程总数不少于数据量,配合边界检查就万无一失了。

矩阵乘法:并行计算的经典案例

矩阵乘法比向量加法复杂得多。每个输出元素是A的一行和B的一列的点积。我当年面试时,面试官让我手写CUDA矩阵乘法,写出来不难,但要写出高性能版本,那才是真功夫。

朴素实现:每个线程计算一个输出元素。

__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;
    }
}

性能陷阱:朴素实现中,每个线程反复从全局内存读取数据,访存效率极低。矩阵越大,性能越差。

为什么会这样?因为全局内存的延迟很高,大约几百个时钟周期。而计算一个点积需要K次乘加操作,每次都要等数据从全局内存过来,GPU的计算单元大部分时间都在空转。

优化思路:使用共享内存,把数据分块加载到片上缓存。

__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 row = blockIdx.y * TILE_SIZE + threadIdx.y;
    int col = blockIdx.x * TILE_SIZE + threadIdx.x;
    
    float sum = 0.0f;
    for (int tile = 0; tile < (K + TILE_SIZE - 1) / TILE_SIZE; tile++) {
        // 协作加载数据到共享内存
        if (row < M && tile * TILE_SIZE + threadIdx.x < K)
            As[threadIdx.y][threadIdx.x] = A[row * K + tile * TILE_SIZE + threadIdx.x];
        else
            As[threadIdx.y][threadIdx.x] = 0.0f;
            
        if (col < N && tile * TILE_SIZE + threadIdx.y < K)
            Bs[threadIdx.y][threadIdx.x] = B[(tile * TILE_SIZE + threadIdx.y) * N + col];
        else
            Bs[threadIdx.y][threadIdx.x] = 0.0f;
        
        __syncthreads();
        
        for (int k = 0; k < TILE_SIZE; k++) {
            sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        }
        
        __syncthreads();
    }
    
    if (row < M && col < N) {
        C[row * N + col] = sum;
    }
}

个人经验:TILE_SIZE一般取16或32。取16时每个线程块256个线程,共享内存占用2×16×16×4=2KB,非常宽裕。取32时占用8KB,也还行。我建议从16开始调,逐步增大看性能变化。

性能分析工具:nvprof与Nsight Systems

写完了代码,怎么知道它跑得快不快?靠猜可不行。我见过太多人凭感觉优化,结果越优化越慢。这时候就需要性能分析工具了。

nvprof:命令行利器

nvprof是NVIDIA提供的命令行性能分析工具。用法很简单:

nvprof ./my_cuda_program

它会输出每个kernel的执行时间、内存传输时间、占用率等信息。我个人习惯先跑一遍nvprof,看看整体情况。

更详细的用法:

nvprof --metrics gld_efficiency,gst_efficiency,occupancy ./my_cuda_program

这个命令会告诉你全局内存加载/存储效率,以及SM的占用率。我曾经优化一个矩阵乘法,发现gld_efficiency只有30%多,说明大部分访存带宽被浪费了。后来改用共享内存分块,效率提升到了80%以上。

指标 含义 理想值
gld_efficiency 全局内存加载效率 接近100%
gst_efficiency 全局内存存储效率 接近100%
occupancy SM占用率 50%以上
achieved_occupancy 实际达到的占用率 越高越好

Nsight Systems:可视化分析

Nsight Systems是图形化的性能分析工具,比nvprof更直观。它能展示时间轴上的kernel执行、内存拷贝、CPU活动等。

启动方式:

nsys profile -o output_file ./my_cuda_program

然后打开生成的.nsys-rep文件,就能看到漂亮的时序图了。

避坑指南:我曾经用Nsight Systems发现一个奇怪现象——两个kernel之间有个很长的空隙。仔细一看,原来是CPU在等GPU完成,但CPU端没有做异步操作。加了个cudaStream后,CPU和GPU就能并行工作了,整体时间缩短了30%。

知识体系结构图

下面这张图展示了本章的核心知识脉络:

并行计算模式与性能分析 向量加法 线程ID与索引映射 边界检查:if(i < N) 线程块数量计算 矩阵乘法 朴素实现:逐元素计算 性能瓶颈:全局内存延迟 优化:共享内存分块 __syncthreads()同步 性能分析工具 nvprof:命令行分析 关键指标:效率/占用率 Nsight Systems:可视化 时间轴分析/异步优化 核心思想:数据并行 + 分块策略 + 性能分析驱动优化 从简单到复杂,从实现到优化,每一步都要用数据说话

总结

向量加法是入门,矩阵乘法是进阶,性能分析工具是武器。这三者构成了CUDA并行编程的基础框架。我个人建议的学习路径是:先跑通向量加法,理解线程模型;再挑战矩阵乘法,体会共享内存的威力;最后用nvprof和Nsight Systems来验证你的优化是否有效。

记住一句话:没有性能分析就没有优化。不要凭感觉调优,让数据告诉你瓶颈在哪里。

课后练习:实现一个256×256的矩阵乘法,分别用朴素版本和共享内存版本,用nvprof对比两者的gld_efficiency和运行时间。你会发现,数据不会骗人。

想要更多资料,联系可可