GPU 计算基础
GPU(图形处理器)是现代 AI 计算的核心硬件。理解 GPU 的工作原理和编程模型,对于优化 AI 应用的性能至关重要。
GPU 与 CPU 的区别
GPU 和 CPU 在设计理念上有根本性的不同:
| 特性 | CPU | GPU |
|---|---|---|
| 核心数量 | 少(几十个) | 多(数千个) |
| 单核性能 | 强 | 弱 |
| 内存带宽 | 较低 | 极高 |
| 适用场景 | 串行任务、复杂逻辑 | 并行任务、简单计算 |
| 设计目标 | 低延迟 | 高吞吐量 |
CPU 适合处理复杂的逻辑控制和串行任务,而 GPU 则擅长处理大规模并行计算。这种差异源于它们的设计目标:CPU 追求单线程的低延迟,GPU 追求整体的高吞吐量。
一个形象的比喻
如果把计算任务比作运送货物:
- CPU 就像一辆法拉利,速度快但载货量小,适合运送少量急需的货物
- GPU 就像一支由小型卡车组成的车队,每辆车速度不快但数量庞大,适合运送大量货物
GPU 架构详解
流式多处理器(SM)
GPU 的核心计算单元是流式多处理器(Streaming Multiprocessor,SM)。每个 SM 包含:
- CUDA 核心:执行浮点和整数运算
- 张量核心:专门用于矩阵运算,对深度学习至关重要
- 共享内存:SM 内部的高速缓存
- 寄存器文件:存储线程的局部变量
- 调度单元:管理线程的执行
以 NVIDIA H100 为例,单个 GPU 包含 132 个 SM,每个 SM 有 128 个 CUDA 核心,总计 16896 个核心。
内存层次结构
GPU 的内存层次结构对性能有重大影响:
┌─────────────────────────────────────────┐
│ HBM(高带宽内存) │
│ 容量:80GB,带宽:3.35TB/s │
├─────────────────────────────────────────┤
│ L2 缓存 │
│ 容量:50MB,延迟:低 │
├─────────────────────────────────────────┤
│ 共享内存 / L1 缓存(每个 SM) │
│ 容量:228KB,延迟:极低 │
├─────────────────────────────────────────┤
│ 寄存器文件 │
│ 容量:256KB,延迟:最低 │
└─────────────────────────────────────────┘
关键原则:数据越靠近计算单元,访问速度越快。优化 GPU 程序的核心就是最大化数据在高速存储中的复用。
张量核心
张量核心(Tensor Core)是 NVIDIA GPU 中专门用于矩阵乘法加速的硬件单元。它可以在一个时钟周期内完成一个 4×4 矩阵乘法运算。
对于深度学习中最常见的矩阵乘法 C = A × B:
- 传统 CUDA 核心:需要 64 次乘法和 48 次加法
- 张量核心:单次操作完成
H100 的张量核心在 FP16 精度下可提供近 2000 TFLOPS 的算力。
CUDA 编程模型
CUDA(Compute Unified Device Architecture)是 NVIDIA 推出的并行计算平台和编程模型。
线程层次结构
CUDA 的线程组织分为三个层次:
Grid(网格)
└── Block(线程块)
└── Thread(线程)
- Thread(线程):最小的执行单元,执行 kernel 中的代码
- Block(线程块):一组线程,可以协作执行,共享内存
- Grid(网格):一组线程块,组成完整的 kernel 执行
线程索引
每个线程都有唯一的索引,用于确定它应该处理的数据:
// 一维索引
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// 二维索引
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
Kernel 函数
Kernel 是在 GPU 上执行的函数,使用 __global__ 关键字声明:
// 向量加法 kernel
__global__ void vectorAdd(float *a, float *b, float *c, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
c[idx] = a[idx] + b[idx];
}
}
// 主机端调用
int blockSize = 256;
int numBlocks = (n + blockSize - 1) / blockSize;
vectorAdd<<<numBlocks, blockSize>>>(d_a, d_b, d_c, n);
内存管理
CUDA 程序需要显式管理主机(CPU)和设备(GPU)之间的数据传输:
// 分配设备内存
float *d_a;
cudaMalloc(&d_a, size);
// 数据传输:主机到设备
cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
// 数据传输:设备到主机
cudaMemcpy(h_c, d_c, size, cudaMemcpyDeviceToHost);
// 释放内存
cudaFree(d_a);
性能优化原则
最大化并行度
GPU 的性能来自于大规模并行。要充分利用 GPU,需要:
- 足够的线程数量:通常需要数千到数万个线程来隐藏内存延迟
- 合理的线程块大小:通常是 128、256 或 512,需要是 32 的倍数
优化内存访问
内存带宽通常是 GPU 程序的瓶颈:
合并访问:相邻线程访问相邻的内存地址,可以合并为一次内存事务
// 好的访问模式(合并访问)
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float val = data[idx];
// 差的访问模式(跨步访问)
float val = data[threadIdx.x * stride];
使用共享内存:共享内存比全局内存快很多,适合存储需要重复访问的数据
__shared__ float sharedData[BLOCK_SIZE];
sharedData[threadIdx.x] = globalData[idx];
__syncthreads(); // 同步线程块内所有线程
减少数据传输
主机和设备之间的数据传输是性能瓶颈:
- 尽量减少传输次数
- 使用 pinned memory 加速传输
- 考虑使用统一内存(Unified Memory)
利用张量核心
对于矩阵乘法等操作,使用张量核心可以大幅提升性能:
// 使用 cublas 库调用张量核心
cublasGemmEx(handle,
CUBLAS_OP_N, CUBLAS_OP_N,
m, n, k,
&alpha, A, CUDA_R_16F, lda,
B, CUDA_R_16F, ldb,
&beta, C, CUDA_R_16F, ldc,
CUDA_R_32F, CUBLAS_GEMM_DEFAULT_TENSOR_OP);
常用工具
NVIDIA Nsight
Nsight 是 NVIDIA 提供的性能分析工具套件:
- Nsight Systems:系统级性能分析,查看整体执行时间线
- Nsight Compute:kernel 级性能分析,分析内存访问效率等
CUDA-MEMCHECK
检测内存错误:
cuda-memcheck ./my_program
nvprof
命令行性能分析工具:
nvprof ./my_program
多 GPU 编程
数据并行
每个 GPU 持有完整模型,处理不同数据:
int deviceCount;
cudaGetDeviceCount(&deviceCount);
for (int i = 0; i < deviceCount; i++) {
cudaSetDevice(i);
// 在设备 i 上执行 kernel
myKernel<<<blocks, threads>>>(...);
}
点对点通信
GPU 之间直接通信:
// 启用 P2P 访问
cudaDeviceEnablePeerAccess(peerDevice, 0);
// 直接从另一个 GPU 复制数据
cudaMemcpyPeer(dst, dstDevice, src, srcDevice, size);
实践建议
- 先保证正确性,再优化性能:使用 CUDA-MEMCHECK 检查内存错误
- 使用性能分析工具:找到真正的瓶颈再优化
- 参考最佳实践:NVIDIA 提供了许多优化示例
- 利用现有库:cuBLAS、cuDNN 等库已经高度优化
小结
GPU 是 AI 计算的核心硬件,理解其架构和编程模型对于构建高效的 AI 系统至关重要。CUDA 提供了灵活的编程接口,但也需要开发者深入理解硬件特性才能充分发挥性能。在实际开发中,优先使用高度优化的库,如 cuBLAS 和 cuDNN,可以避免重复造轮子。