2. CUDA基础回顾:GPU架构、CUDA编程模型、内存层次结构、线程组织与调度
在深入CUDA图优化与TensorRT动态形状之前,必须夯实CUDA编程的核心基础。GPU并非“更快的CPU”,而是一种大规模并行吞吐量处理器。理解其硬件架构、编程模型、内存层次与线程调度机制,是后续分析图捕获、内核融合与动态形状优化的前提。
2.1 GPU硬件架构:从SM到CUDA Core
现代NVIDIA GPU(如Ampere、Hopper、Ada Lovelace架构)的核心计算单元是流式多处理器(Streaming Multiprocessor, SM)。每个SM包含若干CUDA Core(FP32/INT32单元)、Tensor Core(用于矩阵乘加)、特殊函数单元(SFU)、共享内存(Shared Memory)、寄存器文件(Register File)以及调度器(Warp Scheduler)。
以Ampere架构的GA102为例,一个SM包含128个CUDA Core、4个Tensor Core、4个Warp Scheduler,以及最大228KB可配置的L1/共享内存。GPU由多个GPC(图形处理簇)和TPC(纹理处理簇)组成,每个TPC包含两个SM。硬件层级关系如下:
GPU (例如 A100)
└── GPC (图形处理簇) ×8
└── TPC (纹理处理簇) ×2
└── SM (流式多处理器) ×2
├── CUDA Core ×128 (FP32)
├── Tensor Core ×4
├── Warp Scheduler ×4
├── Register File (64K × 32-bit)
└── Shared Memory / L1 Cache (最大228KB)
关键认知:GPU的并行度来自SM的数量和每个SM的吞吐能力。一个A100 GPU拥有108个SM,每个SM同时可驻留2048个线程,因此硬件理论上可同时承载约22万个线程。但实际并行执行的线程数受限于寄存器、共享内存等资源。
2.2 CUDA编程模型:网格、线程块与线程
CUDA采用层次化编程模型,将并行计算抽象为三层:网格(Grid)→ 线程块(Block)→ 线程(Thread)。内核函数(Kernel)以网格形式启动,每个网格包含若干线程块,每个线程块包含若干线程。这种层次结构直接映射到硬件:线程块被调度到SM上执行,线程块内的线程通过共享内存协作。
线程和线程块可以使用dim3类型定义多维索引(最多三维),便于处理图像、体素等数据。典型的内核启动语法如下:
// 定义线程块和网格维度
dim3 blockDim(16, 16); // 线程块:16×16 = 256线程
dim3 gridDim((width + 15) / 16, (height + 15) / 16);
// 启动内核
myKernel<<<gridDim, blockDim>>>(d_input, d_output, width, height);
// 内核内部获取索引
__global__ void myKernel(const float* in, float* out, int w, int h) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < w && y < h) {
int idx = y * w + x;
out[idx] = in[idx] * 2.0f;
}
}
编程模型与硬件的映射关系:
- 线程(Thread) → 运行在CUDA Core上,每个线程有独立的程序计数器和寄存器。
- 线程块(Block) → 被调度到单个SM上,块内线程可通过共享内存通信,并支持同步(
__syncthreads())。 - 网格(Grid) → 由多个线程块组成,块之间无通信,可独立调度到不同SM。
2.3 内存层次结构:从全局内存到寄存器
CUDA内存模型是性能优化的核心。不同内存的容量、延迟、带宽差异巨大,合理使用内存层次是CUDA优化的关键。下表总结了主要内存类型及其特征:
| 内存类型 | 作用域 | 生命周期 | 延迟 | 缓存 | 典型用途 |
|---|---|---|---|---|---|
| 寄存器 | 单个线程 | 内核执行期间 | ~1周期 | 无 | 局部变量、中间计算 |
| 共享内存 | 线程块内 | 内核执行期间 | ~20-30周期 | 无(手动管理) | 块内数据共享、数据复用 |
| L1缓存 | SM内 | 自动 | ~30周期 | 硬件管理 | 全局内存访问加速 |
| L2缓存 | GPU全局 | 自动 | ~200周期 | 硬件管理 | 跨SM数据共享 |
| 全局内存 | 所有线程 | 主机分配至释放 | ~400-800周期 | L1/L2 | 主数据存储、主机-设备通信 |
| 常量内存 | 所有线程 | 主机分配至释放 | ~400周期(缓存后快) | 常量缓存 | 只读参数、系数 |
| 纹理内存 | 所有线程 | 主机分配至释放 | ~400周期(缓存后快) | 纹理缓存 | 图像处理、空间局部性访问 |
动态形状对内存的影响:TensorRT动态形状意味着输入张量的尺寸在运行时才确定。这会导致:
- 全局内存分配需要动态调整,可能引入额外拷贝开销。
- 共享内存大小需要根据形状动态配置,影响SM驻留线程数。
- 访问模式随形状变化,可能导致缓存命中率下降。
2.4 线程组织与调度机制
GPU的线程调度遵循SIMT(单指令多线程)模型。硬件以Warp(32线程)为单位进行调度。当一个Warp中的线程执行条件分支时,会产生分支发散(Branch Divergence),导致串行执行不同路径,降低效率。
调度流程如下:
- 网格启动:主机端通过
<<<gridDim, blockDim>>>启动内核,GPU工作调度器将线程块分配到空闲SM。 - Warp划分:每个线程块被划分为多个Warp(线程ID连续32个线程为一组)。例如,256线程的块被分为8个Warp。
- Warp调度:SM内的Warp Scheduler每个周期选择一个就绪的Warp发射指令。当Warp等待内存访问时,调度器切换到其他Warp,隐藏延迟。
- 执行:Warp中的32个线程在同一周期执行相同指令,但操作不同的数据。
// 线程索引计算示例
__global__ void exampleKernel(float* data, int pitch) {
// 线性索引
int tid = blockIdx.x * blockDim.x + threadIdx.x;
// 2D索引
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int idx_2d = y * pitch + x;
// Warp内线程ID
int lane = threadIdx.x % 32;
// Warp ID(块内)
int warp_id = threadIdx.x / 32;
}
Warp调度与延迟隐藏:GPU通过大量线程 + 快速Warp切换来隐藏内存延迟。当某个Warp因访问全局内存而等待时(约400-800周期),调度器立即切换到其他就绪Warp执行计算。因此,足够的并行度(Occupancy)是性能的关键。Occupancy = 活跃Warp数 / 最大Warp数。动态形状可能导致线程块大小变化,影响Occupancy。
2.5 案例:矩阵乘法的CUDA实现与优化思路
矩阵乘法(GEMM)是深度学习中最核心的运算。以C = A × B为例,朴素实现和优化实现差异巨大。
朴素实现(直接映射):
__global__ void matmul_naive(const float* A, const float* B, float* C, int N) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0.0f;
for (int k = 0; k < N; ++k) {
sum += A[row * N + k] * B[k * N + col];
}
C[row * N + col] = sum;
}
该实现每个线程计算一个输出元素,但每次循环都访问全局内存,内存带宽成为瓶颈。优化思路包括:
- 共享内存分块(Tiling):将A和B的块加载到共享内存,减少全局内存访问次数。
- 寄存器缓存:每个线程计算多个输出元素,复用寄存器中的数据。
- 避免Bank Conflict:调整共享内存访问模式,使同一Warp的线程访问不同Bank。
- 向量化加载:使用
float4一次加载4个浮点数,提高内存吞吐。
TensorRT的启示:TensorRT的自动调优(如tactic选择)本质上是在搜索最优的GEMM实现策略(包括tile大小、unroll因子、向量化宽度等)。动态形状使得每次推理时形状可能不同,TensorRT需要为每个形状选择或调整最优tactic,这正是动态形状优化的核心挑战之一。
2.6 常见误区与复盘
误区1:认为线程越多性能越好。
线程过多会导致寄存器溢出(Spill)到局部内存,反而降低性能。每个SM的寄存器文件是有限的(64K×32bit),如果每个线程使用过多寄存器,SM能驻留的线程数就会减少,Occupancy下降。
误区2:忽略Warp发散的影响。
在条件分支中,如果Warp内线程走向不同路径,所有路径都会串行执行。例如:
if (threadIdx.x % 2 == 0) {
// 偶数线程执行
} else {
// 奇数线程执行
}
这个Warp需要执行两次,效率减半。应尽量让分支以Warp为单位(即32的倍数)对齐。
误区3:共享内存使用不当。
共享内存容量有限(最大228KB/SM),且需要手动管理Bank Conflict。动态形状下,共享内存大小需要动态调整,若分配过大,会降低Occupancy;若过小,则无法有效复用数据。
复盘总结:CUDA性能优化的本质是最大化有效计算/内存访问比。核心手段包括:
- 提高数据复用(共享内存、寄存器缓存)。
- 保证内存访问合并(Coalesced Access),即Warp内线程访问连续内存地址。
- 维持足够高的Occupancy以隐藏延迟。
- 避免Warp发散和Bank Conflict。
这些原则在TensorRT的图优化中同样适用——图优化通过融合内核、减少内存往返、调整数据布局来达到类似目的。