GPU计算基础:GPU架构、CUDA核心、显存层次与并行计算模型

核心原理:从“多核”到“众核”的架构分野

要理解GPU为何能加速AI与高性能计算,首先需要看清CPU与GPU在硬件设计哲学上的根本差异。CPU由少数几个(通常8~64个)功能强大的核心构成,每个核心拥有大容量缓存、复杂的分支预测与乱序执行单元,擅长处理延迟敏感、逻辑复杂的串行任务。而GPU则采用“众核”架构,以NVIDIA A100为例,它集成了6912个CUDA核心,每个核心都非常精简,没有复杂的控制单元,依赖大规模线程并行来隐藏访存延迟。

这种差异源于两种芯片的目标不同:CPU追求单线程性能最大化,GPU追求总吞吐量最大化。GPU将更多晶体管用于浮点运算单元(FP32/FP64/Tensor Core),而非控制逻辑和缓存。下图展示了GPU流式多处理器(SM)内部的典型结构,这是理解CUDA编程模型的关键硬件视图。

NVIDIA Ampere架构 GA100 SM(流式多处理器)内部结构 SM分区 0 FP32 FP32 FP32 INT32 INT32 INT32 SFU SFU SFU 寄存器文件 64K×32bit SM分区 1 FP32 FP32 FP32 INT32 INT32 INT32 SFU SFU SFU 寄存器文件 64K×32bit SM分区 2 FP32 FP32 FP32 INT32 INT32 INT32 SFU SFU SFU 寄存器文件 64K×32bit SM分区 3 FP32 FP32 FP32 INT32 INT32 INT32 SFU SFU SFU 寄存器文件 64K×32bit 统一共享内存 / L1缓存(SM内共 256 KB,可配置) 纹理单元 (4个) Tensor Core (4个) Warp调度器 (4个) LD/ST 单元 (32个) 关键参数(A100 SM): • 每个SM包含 64 个FP32 CUDA核心 + 64 个INT32单元 + 32 个SFU(特殊函数单元) • 4个Tensor Core(第三代),支持FP16/BF16/INT8/INT4混合精度矩阵运算 • 最大常驻线程数:2048;最大线程块数:32;寄存器总量:64K×32bit • 每个SM的共享内存+L1缓存总容量:256 KB,可配置为 164KB共享内存 + 92KB L1

CUDA核心与线程层次模型

CUDA(Compute Unified Device Architecture)是NVIDIA推出的并行计算平台和编程模型。在CUDA中,线程(Thread)是最小的执行单位,多个线程组成线程块(Block),多个线程块组成网格(Grid)。硬件执行时,GPU以Warp(32个线程)为基本调度单位,一个Warp中的线程在同一个SM上执行相同的指令(SIMT,单指令多线程)。

这种层次化设计对性能有决定性影响:

  • 线程块内的线程可以通过共享内存(Shared Memory)通信,并通过__syncthreads()同步。
  • 不同线程块之间无法直接通信,只能通过全局内存(Global Memory)间接交互。
  • Warp divergence(分支发散):若Warp内线程执行不同分支,则分支会被串行执行,导致性能下降。

在编写内核函数(Kernel)时,开发者需要显式指定网格和线程块的维度。例如,一个典型的向量加法内核启动配置如下:

// 内核定义
__global__ void vecAdd(float *A, float *B, float *C, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        C[i] = A[i] + B[i];
    }
}

// 主机端启动配置
int threadsPerBlock = 256;
int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, n);

上述代码中,blockIdx.x、blockDim.x、threadIdx.x是CUDA内置变量,分别表示当前线程块的索引、线程块维度大小和线程在线程块内的索引。这种“网格-线程块-线程”的三层结构,使得GPU能够灵活地将海量线程映射到有限的SM上执行。

显存层次结构与数据搬运策略

GPU的显存层次是性能优化的核心战场。从寄存器到全局内存,每一级的容量、延迟和带宽差异巨大。下表总结了A100各存储层次的典型参数:

存储层次 容量 延迟 带宽/特点 作用域
寄存器文件 64K×32bit / SM ~1个时钟周期 每个线程私有 线程
共享内存 最多164KB / SM ~20-30周期 可编程管理,低延迟 线程块
L1缓存 与共享内存共享256KB ~30周期 自动缓存全局内存访问 SM
L2缓存 40MB(A100) ~200周期 芯片级共享 GPU
全局内存(HBM2e) 40GB / 80GB ~400-600周期 带宽高达2TB/s以上 所有线程

在实际优化中,最核心的原则是减少对全局内存的访问。全局内存虽然带宽很高,但延迟是共享内存的20倍以上。常用的优化手段包括:

  1. 数据复用:将需要反复使用的数据块先加载到共享内存,例如矩阵分块乘法的tile策略。
  2. 合并访问(Coalesced Access):让同一Warp的32个线程访问连续的内存地址,使硬件将多次访问合并为一次或少数几次内存事务。
  3. 使用向量化类型:如float4,一次读取16字节,减少指令数。
实战提示:在NVIDIA的Nsight Compute性能分析器中,Memory Throughput和L1/TEX Cache Throughput指标能直观反映显存访问效率。若发现Global Load Efficiency低于80%,通常意味着存在非合并访问,需要调整数据布局或索引计算方式。

并行计算模型:从数据并行到任务并行

GPU最擅长的是数据并行(Data Parallelism)——同一操作应用于大量数据元素。典型的例子包括图像像素处理、矩阵元素运算、神经网络中的张量操作等。CUDA的编程模型天然适合数据并行:每个线程处理一个或多个数据元素。

除了数据并行,GPU也支持任务并行(Task Parallelism),即不同线程块执行不同的内核或不同的代码路径。但任务并行在GPU上通常效率较低,因为GPU的调度器倾向于将资源分配给同质的Warp。因此,实际工程中应优先考虑数据并行,将任务分解为可并行处理的数据块。

一个经典的并行计算模式是归约(Reduction),例如计算一个大型数组的总和。朴素实现会让每个线程读取一个元素并累加,但更好的做法是采用分块归约:

__global__ void reduce(float *input, float *output, int n) {
    __shared__ float sdata[256];
    int tid = threadIdx.x;
    int i = blockIdx.x * blockDim.x + tid;
    
    // 每个线程加载一个元素到共享内存
    sdata[tid] = (i < n) ? input[i] : 0.0f;
    __syncthreads();
    
    // 树形归约
    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) {
            sdata[tid] += sdata[tid + stride];
        }
        __syncthreads();
    }
    
    // 每个线程块输出一个部分和
    if (tid == 0) output[blockIdx.x] = sdata[0];
}

上述归约算法将时间复杂度从O(n)降低到O(log n)的并行步数,同时通过共享内存减少了全局内存访问次数。在实际应用中,还需要考虑Warp级归约(使用__shfl_down_sync指令)来进一步减少同步开销。

案例:矩阵乘法优化中的显存与并行策略

矩阵乘法(GEMM)是深度学习与科学计算中最核心的运算之一。以C = A × B为例,其中A为M×K矩阵,B为K×N矩阵。朴素实现中,每个线程计算C的一个元素,需要读取A的一行和B的一列,导致大量重复的全局内存访问。

优化的关键思路是分块(Tiling):将矩阵划分为大小为BLOCK_SIZE × BLOCK_SIZE的子块,每个线程块负责计算一个输出子块。计算过程中,将A和B对应的子块加载到共享内存,实现数据复用。以下是一个简化版本的核心逻辑:

#define BLOCK_SIZE 16

__global__ void matMulTiled(float *A, float *B, float *C, int M, int N, int K) {
    __shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
    __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
    
    int row = blockIdx.y * BLOCK_SIZE + threadIdx.y;
    int col = blockIdx.x * BLOCK_SIZE + threadIdx.x;
    float sum = 0.0f;
    
    for (int tile = 0; tile < (K + BLOCK_SIZE - 1) / BLOCK_SIZE; tile++) {
        // 协作加载A和B的子块到共享内存
        if (row < M && tile * BLOCK_SIZE + threadIdx.x < K)
            As[threadIdx.y][threadIdx.x] = A[row * K + tile * BLOCK_SIZE + threadIdx.x];
        else
            As[threadIdx.y][threadIdx.x] = 0.0f;
            
        if (col < N && tile * BLOCK_SIZE + threadIdx.y < K)
            Bs[threadIdx.y][threadIdx.x] = B[(tile * BLOCK_SIZE + threadIdx.y) * N + col];
        else
            Bs[threadIdx.y][threadIdx.x] = 0.0f;
            
        __syncthreads();
        
        // 计算当前子块的乘积
        for (int k = 0; k < BLOCK_SIZE; k++) {
            sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        }
        __syncthreads();
    }
    
    if (row < M && col < N) {
        C[row * N + col] = sum;
    }
}

这个分块矩阵乘法将全局内存访问次数从O(M×N×K)降低到O(M×N×K/BLOCK_SIZE),显著提升了计算访存比。在A100上,配合Tensor Core使用wmma或mma指令,可以达到接近理论峰值的性能。

常见误区与性能陷阱

误区1:线程数越多越好。实际上,当线程数超过SM可容纳的最大常驻线程数时,多余的线程会排队等待,并不会提升性能。合理的线程块大小通常是128~512的倍数,且需考虑寄存器占用率。
误区2:忽略Warp divergence。在条件分支中,如果同一Warp内的线程走不同路径,所有路径都会串行执行。应尽量让分支粒度对齐到Warp级别,或使用算术方式替代分支。
误区3:频繁使用malloc和free分配显存。显存分配是重量级操作,应在初始化阶段一次性分配好所需内存池,避免在内核执行期间动态分配。
误区4:忽视cudaMemcpy的同步开销。主机与设备之间的数据传输是PCIe总线上的串行操作,应尽量批量传输,并使用cudaMemcpyAsync配合流(Stream)实现数据传输与计算的重叠。