《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代码时,应尽量让线程数远大于核心数,以隐藏内存延迟并填满计算流水线。
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:忽略内存合并访问。当线程访问连续内存地址时,硬件可合并为一次内存事务。若线程间地址跳跃(如按列访问矩阵),带宽利用率会骤降。应尽量让相邻线程访问相邻地址。
调优复盘清单:
- 使用
cudaMemcpy时,检查是否可改用cudaMemcpyAsync实现异步传输 - 核函数内避免
if-else分支发散(Warp Divergence),可重写为算术表达式 - 用
__syncthreads()同步线程块内线程,但注意不要过度同步 - 使用
cudaOccupancyMaxPotentialBlockSize辅助计算最优块大小 - 用
nvprof或Nsight Compute分析内存吞吐量和SM占用率