1. CUDA并行计算工程导论

在传统CPU程序里,我们习惯把任务写成一条顺序执行的指令流:取数、计算、写回,循环往复。CPU为这种模式做了极致优化——大容量缓存、分支预测、乱序执行,让单条指令流的延迟尽可能低。但当问题规模膨胀到数亿个像素、数百万个粒子或上千亿次矩阵乘加时,CPU的几十个核心就显得捉襟见肘。GPU换了一条路:它不追求单个线程跑得多快,而是用成千上万个轻量线程同时开工,用吞吐量换延迟。这就是异构计算范式的核心——CPU负责逻辑控制与串行部分,GPU负责数据密集的并行部分,两者通过PCIe或NVLink协同。

理解CUDA,先要理解它的执行模型。CUDA程序由主机端(Host,即CPU)和设备端(Device,即GPU)代码组成。主机端负责分配显存、拷贝数据、启动核函数;设备端执行核函数(kernel)。核函数被成千上万个线程并行执行,这些线程按线程—线程块—网格三级组织:32个线程组成一个warp,是硬件调度的基本单位;若干warp组成一个线程块(block),块内线程可通过共享内存通信;若干块组成一个网格(grid),对应一次核函数启动。线程通过blockIdx、threadIdx等内置变量确定自己的全局编号,从而决定处理哪一部分数据。

CUDA生态远不止一个编译器。从底层的驱动API、运行时API,到cuBLAS、cuFFT、cuDNN、TensorRT等加速库,再到Nsight系列性能分析工具、NCCL多卡通信库、CUDA Graph、统一内存等运行时特性,构成了完整的工程栈。实际项目中,我们很少从零手写所有算子——矩阵乘用cuBLAS,卷积用cuDNN,通信靠NCCL,自己专注写那些库覆盖不到的自定义核函数,这才是工程化的正确姿势。

学习路线建议分四步走:第一步,搭好环境,跑通第一个向量加法核函数,理解Host/Device内存模型;第二步,掌握线程索引、内存层次(全局内存、共享内存、寄存器)、同步机制;第三步,学会用Nsight Compute和Nsight Systems定位瓶颈,理解占用率、内存带宽、指令吞吐等指标;第四步,进入工程实践,处理多流并发、动态并行、多GPU通信等真实场景。下面这张图展示了从主机端到设备端的完整数据流与执行链,是理解CUDA程序结构的骨架。

主机端 Host (CPU) 分配主机内存 初始化数据 cudaMalloc 显存 cudaMemcpy H→D 启动核函数 <<<grid, block>>> 启动 设备端 Device (GPU) Grid 网格 Block 线程块 Warp (32线程) Thread 线程 并行执行核函数 · 共享内存/寄存器 回传 CUDA 内存层次与数据流 全局内存 Global Memory 共享内存 Shared Memory 寄存器 Registers 常量/纹理内存 Constant/Texture 访问速度:寄存器 > 共享内存 > 全局内存 | 容量:全局内存 > 共享内存 > 寄存器 核函数执行时数据从全局内存加载到共享内存/寄存器,计算完成后写回全局内存

环境搭建是第一步,也是最容易卡住新手的地方。你需要三样东西:一块支持CUDA的NVIDIA GPU(计算能力≥5.0即可,现代工程建议≥7.0)、对应版本的显卡驱动、以及CUDA Toolkit。在Ubuntu 22.04上,推荐用官方runfile安装,避免apt源版本混乱:

# 查看GPU型号与驱动版本
nvidia-smi

# 下载并安装CUDA Toolkit 12.4(示例)
wget https://developer.download.nvidia.com/compute/cuda/12.4.0/local_installers/cuda_12.4.0_550.54.14_linux.run
sudo sh cuda_12.4.0_550.54.14_linux.run

# 配置环境变量
echo 'export PATH=/usr/local/cuda-12.4/bin:$PATH' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH=/usr/local/cuda-12.4/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc
source ~/.bashrc

# 验证
nvcc --version

安装完成后,写一个最小可运行的向量加法程序来验证环境。这个例子虽然简单,却包含了CUDA编程的全部关键要素:显存分配、数据拷贝、核函数定义与启动、结果回传与校验。

#include <stdio.h>
#include <cuda_runtime.h>

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

int main() {
    const int N = 1 << 20;          // 约100万个元素
    size_t bytes = N * sizeof(float);
    float *h_A = (float*)malloc(bytes);
    float *h_B = (float*)malloc(bytes);
    float *h_C = (float*)malloc(bytes);
    for (int i = 0; i < N; i++) { h_A[i] = 1.0f; h_B[i] = 2.0f; }

    float *d_A, *d_B, *d_C;
    cudaMalloc(&d_A, bytes);
    cudaMalloc(&d_B, bytes);
    cudaMalloc(&d_C, bytes);
    cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);

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

    cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);
    printf("C[0]=%f, C[N-1]=%f\n", h_C[0], h_C[N-1]);

    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C);
    return 0;
}

编译命令为 nvcc -o vecadd vecadd.cu。运行后若输出 C[0]=3.000000, C[N-1]=3.000000,说明环境正常。这里有几个值得注意的工程细节:blocksPerGrid 用向上取整公式计算,确保覆盖所有元素;核函数内用 if (i < N) 做边界保护,防止越界访问;cudaDeviceSynchronize() 确保核函数执行完毕后再回传数据。这些看似琐碎的检查,在实际工程中是避免段错误和结果错误的底线。

工程建议:初学阶段不要急着优化性能,先把“正确性”跑通。用 cudaGetErrorString(cudaGetLastError()) 检查每次CUDA调用的返回值,能帮你快速定位是内存分配失败、拷贝越界还是核函数启动参数错误。等程序稳定运行后,再引入Nsight工具做性能剖析。

最后要建立的一个认知是:CUDA不是“把CPU代码翻译成GPU代码”的魔法。它要求你重新思考数据的划分方式、线程的映射关系、内存的访问模式。一个在CPU上跑得很快的算法,直接搬到GPU上可能因为线程发散、内存不连续访问而变得很慢。工程化的学习路径,就是不断在“写得出”和“跑得快”之间迭代,用工具量化瓶颈,用架构知识指导优化。本章后续内容将围绕这条主线逐步展开。