《CUDA与TensorRT初探必修课》

GPU架构与CUDA编程模型

要真正理解CUDA,首先需要回答一个根本问题:GPU为什么能比CPU快那么多?答案并不在于“频率更高”或“核心更多”这么简单,而在于两者设计哲学的根本差异。CPU是为低延迟优化的——它拥有庞大的缓存、复杂的乱序执行逻辑和分支预测器,目的是让单条指令尽快完成;而GPU是为高吞吐量优化的——它把大量晶体管用于计算单元(ALU),用极简单的控制逻辑和较小的缓存,同时运行成千上万个线程来掩盖访存延迟。这种“以数量换延迟”的策略,使得GPU在数据并行任务上拥有数量级的优势。

2.1 GPU硬件架构:从SM到CUDA Core

NVIDIA GPU的基本计算单元是流多处理器(Streaming Multiprocessor, SM)。一个GPU由多个SM组成,每个SM内部又包含若干CUDA Core(即浮点/整数运算单元)、共享内存、寄存器文件、调度器和缓存。以Ampere架构的A100为例,它拥有108个SM,每个SM包含64个FP32 CUDA Core,总计6912个核心。但请注意:“核心数”并不等于“线程数”,CUDA Core是物理执行单元,而线程是软件抽象,一个CUDA Core可以按时间片执行多个线程的指令。

GPU执行指令的最小调度单位是Warp——通常由32个线程组成。每个Warp内的线程在同一时刻执行同一条指令(SIMT,单指令多线程),如果出现分支分歧(即线程走不同路径),则Warp会串行执行各分支,导致性能损失。一个SM中的Warp调度器负责在每个时钟周期选择一个可执行的Warp发射指令。下图展示了GPU硬件与CUDA线程层次的对应关系:

GPU 硬件层次 SM 0(流多处理器) Warp 调度器 ×4 共享内存 (L1) CUDA Core 阵列(64个FP32核心) …(共64个) SM 1(流多处理器) Warp 调度器 ×4 共享内存 (L1) CUDA Core 阵列(64个FP32核心) …(共64个) … 全局内存(Global Memory / HBM / GDDR) 容量大(数GB~数十GB),延迟高(数百周期),所有SM共享 CUDA 线程层次(软件视图) Grid(网格) 包含多个Block Block(线程块) 映射到SM Thread(线程) Warp = 32线程 Block→SM映射

上图展示了GPU硬件与CUDA软件线程层次的对应关系。每个Block(线程块)会被调度到某个SM上执行,一个SM可以同时驻留多个Block(取决于资源限制)。Block内的线程通过共享内存和同步屏障(__syncthreads())协作;而不同Block之间无法直接通信,只能通过全局内存间接交互。这种设计使得GPU可以透明地扩展——无论硬件上有多少个SM,同一个CUDA程序都能运行,只是并行度不同。

2.2 CUDA线程层次:Grid、Block与Thread

CUDA的线程组织是三层结构:Grid(网格)→ Block(线程块)→ Thread(线程)。启动内核时,程序员需要指定Grid和Block的维度,每个维度最多3层(x, y, z)。例如:

// 定义网格和块的大小
dim3 grid(2, 2);      // 2×2的网格,共4个Block
dim3 block(4, 4);     // 每个Block包含4×4=16个线程
kernel<<<grid, block>>>(args);  // 启动内核

上述代码共启动4个Block × 16个线程 = 64个线程。每个线程在核函数内部通过内置变量threadIdx、blockIdx、blockDim、gridDim来定位自己的全局唯一ID。对于一维情况,全局线程ID的计算公式为:

int tid = blockIdx.x * blockDim.x + threadIdx.x;

这个公式是CUDA编程中最基础、最常用的索引方式。对于二维或三维情况,需要分别计算各维度的索引,再组合成线性偏移。理解这个映射关系是编写正确内核的第一步。

2.3 内存层次:从寄存器到全局内存

CUDA的内存层次与硬件架构紧密对应,不同内存的速度、容量、作用域差异巨大。下表总结了各类内存的关键特性:

内存类型 位置 作用域 速度 生命周期
寄存器(Register) SM内部 单个线程 最快(1周期) 线程执行期间
共享内存(Shared Memory) SM内部 Block内所有线程 快(~20周期) Block执行期间
全局内存(Global Memory) 显存(HBM/GDDR) 所有线程+主机 慢(400~800周期) 程序运行期间
常量内存(Constant) 显存(带缓存) 所有线程(只读) 快(缓存命中时) 程序运行期间
纹理内存(Texture) 显存(带缓存) 所有线程(只读) 快(空间局部性好) 程序运行期间

在编写内核时,应优先使用寄存器存储频繁访问的局部变量;当Block内需要数据共享时,使用共享内存可以避免重复访问全局内存;而全局内存的访问应尽量合并(coalesced)——即让Warp内32个线程访问连续的内存地址,这样硬件可以将多次访问合并为一次事务,大幅提升带宽利用率。

2.4 核函数编写与执行:一个完整的向量加法案例

下面通过一个经典的向量加法案例,完整展示核函数的编写、编译和执行流程。假设我们要计算 C = A + B,其中A、B、C均为长度为N的浮点数组。

// 核函数定义:每个线程处理一个元素
__global__ void vectorAdd(const float* A, const float* B, float* C, int N) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    if (tid < N) {  // 边界检查,防止越界
        C[tid] = A[tid] + B[tid];
    }
}

// 主机端调用
int main() {
    int N = 1 << 20;  // 1,048,576 个元素
    size_t bytes = N * sizeof(float);

    // 1. 分配主机内存并初始化
    float *h_A = (float*)malloc(bytes);
    float *h_B = (float*)malloc(bytes);
    float *h_C = (float*)malloc(bytes);
    // ... 初始化 h_A, h_B ...

    // 2. 分配设备内存
    float *d_A, *d_B, *d_C;
    cudaMalloc(&d_A, bytes);
    cudaMalloc(&d_B, bytes);
    cudaMalloc(&d_C, bytes);

    // 3. 将数据从主机拷贝到设备
    cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);

    // 4. 配置内核启动参数并启动
    int blockSize = 256;
    int gridSize = (N + blockSize - 1) / blockSize;  // 向上取整
    vectorAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);

    // 5. 将结果拷贝回主机
    cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);

    // 6. 释放设备内存
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C);
    return 0;
}

执行流程可以归纳为六个步骤:分配主机内存 → 分配设备内存 → 主机到设备拷贝 → 启动内核 → 设备到主机拷贝 → 释放内存。其中第4步的<<<gridSize, blockSize>>>语法是CUDA特有的内核启动配置,它告诉运行时需要创建多少个Block、每个Block有多少线程。

性能要点:blockSize通常选择128~512之间的值(且为32的倍数),因为Warp是32线程,这样能保证Block大小是Warp的整数倍,避免资源浪费。gridSize则根据数据总量和blockSize计算得出,确保覆盖所有数据元素。

2.5 常见误区与调试建议

初学者在编写CUDA程序时,最容易犯以下几类错误:

  • 忘记边界检查:当N不能被blockSize整除时,最后一个Block的部分线程会越界访问。必须像示例中那样加上 if (tid < N) 判断。
  • 忽略错误检查:CUDA API调用(如cudaMalloc、cudaMemcpy)和内核启动都可能失败,但默认不会抛出异常。建议使用 cudaGetLastError() 或定义宏来检查每个API调用的返回值。
  • 主机与设备内存混淆:在核函数中访问主机指针(如h_A)会导致程序崩溃。必须通过cudaMemcpy在主机和设备之间显式传输数据。
  • 过度使用同步:在Block内使用__syncthreads()是必要的,但如果在条件分支中使用不当(如只有部分线程执行到同步点),会导致死锁。
  • 忽视内存合并访问:如果线程访问的地址是跳跃的(如 A[tid * 2]),会导致内存带宽利用率下降数倍。应尽量让相邻线程访问相邻地址。

调试CUDA程序时,推荐使用 cuda-memcheck 工具检测越界和内存泄漏,使用 Nsight Systems 和 Nsight Compute 进行性能分析。在开发阶段,可以在核函数中临时加入 printf() 输出(CUDA支持设备端printf),但要注意大量输出会严重影响性能。

重要提醒:核函数中不能调用主机端函数(如malloc、printf的某些重载),也不能使用C++标准库的容器(如std::vector)。设备端代码必须使用CUDA提供的API(如动态内存分配使用 cudaMalloc 而非 malloc)。

掌握GPU架构与CUDA编程模型是后续学习TensorRT的基础。TensorRT的层融合、张量并行等优化策略,本质上都是在利用GPU的SM架构和内存层次特性。理解Warp的执行方式、共享内存的容量限制、全局内存的带宽瓶颈,将帮助你更好地理解TensorRT为何能实现数倍甚至数十倍的推理加速。