《CUDA与TensorRT动态批处理深度调优》

2. CUDA编程基础:GPU架构、线程层次、内存层次、CUDA核函数编写

2.1 GPU架构核心认知:从“多核”到“众核”

CPU与GPU的设计哲学截然不同。CPU拥有少量但强大的核心(如8核16线程),擅长处理复杂分支和串行任务;GPU则集成了数千个轻量级计算核心,专为大规模并行、高吞吐量的数据密集型计算设计。理解这一差异是编写高效CUDA代码的前提。

现代NVIDIA GPU(如Ampere、Hopper架构)的硬件层次可概括为:

  • 流式多处理器(SM):GPU的基本计算单元,每个SM包含若干CUDA核心、共享内存、寄存器文件和调度器。
  • CUDA核心:执行浮点/整数运算的最小单元,一个SM通常包含64~128个CUDA核心。
  • 线程束(Warp):SM以32个线程为一组进行调度和执行,这是硬件执行的基本粒度。
  • 全局内存(Global Memory):所有线程共享的大容量显存(如HBM),延迟高(数百周期),需通过合并访问提升带宽利用率。
关键启示:GPU的并行能力来自“大量线程 + 硬件自动调度”。编写CUDA代码时,应尽量让线程数远大于核心数,以隐藏内存延迟并填满计算流水线。
GPU (设备) SM 0 CUDA核 CUDA核 CUDA核 CUDA核 共享内存 (48KB) SM 1 CUDA核 CUDA核 CUDA核 CUDA核 共享内存 (48KB) SM 2 CUDA核 CUDA核 CUDA核 CUDA核 共享内存 (48KB) ...... 全局内存 (Global Memory) — 显存,如 HBM2e 40GB 延迟 ~400-800 周期 | 带宽 ~2TB/s | 所有线程共享 需合并访问 (Coalesced Access) 以充分利用带宽 线程层次 Grid (网格) dim3 gridDim(2, 2, 1) Block (0,0) blockDim(4,4,1) Block (1,0) blockDim(4,4,1) Thread (线程) threadIdx.x, threadIdx.y 全局ID = blockIdx.x * blockDim.x + threadIdx.x Warp (线程束) = 32个线程 硬件调度和执行的基本单位

2.2 线程层次与索引计算

CUDA将线程组织为三层结构:网格(Grid)→ 线程块(Block)→ 线程(Thread)。核函数启动时,需要指定网格和线程块的维度。每个线程通过内置变量确定自己的全局唯一ID,这是访问数据的基础。

// 核函数启动配置
dim3 gridDim(4, 2);      // 网格:4×2 = 8个线程块
dim3 blockDim(16, 16);   // 线程块:16×16 = 256个线程
kernel<<<gridDim, blockDim>>>(data, N);

// 线程全局ID计算(一维示例)
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < N) {  // 边界检查,防止越界
    data[tid] = data[tid] * 2.0f;
}
索引计算要点:
  • blockIdx.x:当前线程块在网格中的x索引(0~gridDim.x-1)
  • threadIdx.x:当前线程在线程块内的x索引(0~blockDim.x-1)
  • blockDim.x:线程块在x维度的线程数
  • 多维数据(矩阵)需组合计算:row = blockIdx.y * blockDim.y + threadIdx.y

2.3 内存层次与数据访问策略

CUDA内存模型是分层的,不同内存的速度、容量、作用域差异巨大。合理利用内存层次是性能优化的核心。

内存类型 作用域 速度/延迟 典型用途
寄存器 (Register) 单个线程 最快 (~1周期) 局部变量、中间计算
共享内存 (Shared Memory) 线程块内 快 (~20周期) 块内数据复用、协作计算
全局内存 (Global Memory) 所有线程 慢 (~400-800周期) 主数据存储、CPU-GPU通信
常量内存 (Constant) 所有线程 快(有缓存) 只读参数、查找表
纹理内存 (Texture) 所有线程 中(有缓存) 图像处理、空间局部性访问
常见误区:初学者往往将所有数据放在全局内存中直接访问,导致大量内存事务和延迟。正确做法是:先加载到共享内存 → 同步 → 计算 → 写回全局内存。例如矩阵乘法中,利用共享内存分块(Tiling)可将性能提升数倍。

2.4 CUDA核函数编写规范与案例

核函数(Kernel)是运行在GPU上的函数,需遵循以下规则:

  • 使用 __global__ 修饰符声明,返回类型必须为 void
  • 不能使用可变参数(如 printf 受限)
  • 不能调用主机端函数(如 malloc)
  • 通过 <<<grid, block>>> 语法启动

下面是一个完整的向量加法案例,展示核函数编写、内存分配和数据传输流程:

// 核函数定义
__global__ void vectorAdd(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() {
    int n = 1 << 20;  // 1M 元素
    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 threads = 256;
    int blocks = (n + threads - 1) / threads;
    vectorAdd<<<blocks, threads>>>(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;
}

2.5 性能误区与调优复盘

误区1:线程数越多越好。实际上,过多的线程会导致线程块调度开销和寄存器溢出。经验法则:每个SM活跃线程数控制在2048以内,每个线程块256~512线程较优。
误区2:忽略内存合并访问。当线程访问连续内存地址时,硬件可合并为一次内存事务。若线程间地址跳跃(如按列访问矩阵),带宽利用率会骤降。应尽量让相邻线程访问相邻地址。
调优复盘清单:
  1. 使用 cudaMemcpy 时,检查是否可改用 cudaMemcpyAsync 实现异步传输
  2. 核函数内避免 if-else 分支发散(Warp Divergence),可重写为算术表达式
  3. 用 __syncthreads() 同步线程块内线程,但注意不要过度同步
  4. 使用 cudaOccupancyMaxPotentialBlockSize 辅助计算最优块大小
  5. 用 nvprof 或 Nsight Compute 分析内存吞吐量和SM占用率