算力引擎:GPU架构与通用计算(CUDA核心与Tensor Core详解)

大家好,我是老张。今天咱们聊聊GPU。

很多人觉得GPU就是显卡,打游戏用的。其实不然。在数据中心里,GPU早就是算力引擎的核心了。我2015年刚接触GPU通用计算时,还觉得这东西就是个“大号向量机”。后来做项目多了,才真正理解它的设计哲学。

说白了,GPU和CPU最大的区别是什么?CPU是“全能选手”,什么活都能干,但一次只能干几件。GPU是“流水线工人”,成千上万个线程一起上,专治各种并行计算。

1. CUDA核心:GPU的“小工”

CUDA核心是什么?你可以把它想象成一个非常简单的计算单元。它只能做加减乘除,没有复杂的分支预测,没有乱序执行。但好处是——它小啊!

一个GPU里有多少CUDA核心?拿NVIDIA的A100来说,有6912个。H100更是达到了18432个。你想想看,CPU才几个核?8个、16个顶天了。

但这里有个坑:CUDA核心不是独立工作的。它们被组织成SM(Streaming Multiprocessor,流式多处理器)。每个SM里有几十到上百个CUDA核心,共享指令缓存、共享内存和寄存器文件。

关键点:CUDA核心执行的是“单指令多线程”(SIMT)模型。也就是说,同一时刻,一个SM里的所有CUDA核心执行同一条指令,但处理不同的数据。

我在项目中遇到过一个问题:某个算法里有很多if-else分支。结果发现,GPU利用率只有30%。为什么?因为分支导致线程束(warp)里的线程走了不同路径,SM只能串行执行。这就是所谓的“线程束发散”。

避坑指南:我曾经因为没注意分支发散,导致一个图像处理项目性能惨不忍睹。后来把分支条件改成数据驱动的查找表,性能直接翻了3倍。记住:GPU讨厌分支,喜欢无脑计算。

2. Tensor Core:AI计算的“特种兵”

CUDA核心虽然多,但做矩阵乘法还是不够快。为什么?因为矩阵乘法需要大量乘加操作(FMA)。CUDA核心一次只能做一个FMA,效率太低。

于是NVIDIA在Volta架构里引入了Tensor Core。这东西是专门为深度学习设计的。它一次能做一个4x4矩阵乘法,也就是16个FMA操作。你想想看,效率提升了多少?

到了H100,Tensor Core已经进化到第四代了。支持FP8、FP16、BF16、TF32等多种精度。我最常用的是TF32——它用19位精度做计算,但输出结果可以累加到32位。说白了,就是“用更少的位宽,做更快的计算,精度损失还看不出来”。

架构 Tensor Core代数 支持精度 峰值TFLOPS(FP16)
Volta (V100) 第一代 FP16 125
Turing (T4) 第二代 FP16, INT8, INT4 65
Ampere (A100) 第三代 TF32, FP16, BF16, INT8, INT4 312
Hopper (H100) 第四代 FP8, FP16, BF16, TF32, INT8 1979

嗯,这里要注意:Tensor Core不是万能的。它只适合矩阵乘法、卷积这类规则计算。如果你的算法不规则,比如稀疏矩阵、图计算,Tensor Core就帮不上忙了。这时候还得靠CUDA核心。

3. 通用计算:CUDA编程模型

说了这么多硬件,咱们聊聊怎么用。CUDA编程模型的核心是三个词:线程、块、网格。

  • 线程(Thread):最小的执行单元,每个线程执行一个kernel函数实例。
  • 线程块(Block):一组线程,可以共享共享内存,可以同步。
  • 网格(Grid):一组线程块,构成整个计算任务。

我刚开始学CUDA时,总觉得这玩意儿抽象。后来想明白了:你把数据切成小块,每个块交给一个线程块处理。线程块里的线程协作处理这块数据。就这么简单。

来看个例子。假设我们要做向量加法:

__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 main() {
    // ... 分配内存、拷贝数据 ...
    vecAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);
    // ... 拷贝结果回CPU ...
}

这段代码里,每个线程处理一个元素。blockIdx.x是块索引,threadIdx.x是线程在块内的索引。两者组合起来,就是全局线程ID。

个人经验:blockSize一般选256或512。太小了SM利用率低,太大了寄存器不够用。我习惯用256,因为它是32的倍数(一个warp是32线程),而且能整除大多数数据量。

4. 内存层次:别让数据成为瓶颈

GPU的性能瓶颈往往不在计算,而在内存。你想想看,CUDA核心算得再快,数据喂不进去也是白搭。

GPU的内存层次是这样的:

  • 全局内存(Global Memory):最大,但最慢。延迟400-800个周期。
  • 共享内存(Shared Memory):每个SM独有,很小但很快。延迟20-30个周期。
  • 寄存器(Register):每个线程独有,最快。延迟1个周期。
  • 常量内存(Constant Memory):只读,有缓存,适合存放常量。
  • 纹理内存(Texture Memory):有特殊缓存,适合空间局部性强的访问。

我记得有个项目是做矩阵转置。直接写全局内存,带宽利用率只有20%。后来用了共享内存做分块转置,利用率直接飙到80%。

为什么会这样?因为全局内存访问有“合并”要求。如果同一个warp里的线程访问连续地址,硬件会把多次访问合并成一次。否则,就是多次独立访问,带宽浪费严重。

核心原则:尽量让同一个warp里的线程访问连续的内存地址。这就是所谓的“内存合并访问”。

5. 实战:矩阵乘法优化

矩阵乘法是GPU的看家本领。咱们来看看怎么优化。

最简单的实现:每个线程算一个元素。但这样全局内存访问次数太多,性能很差。

优化思路:用共享内存做分块。每个线程块负责计算一个子矩阵。先把子矩阵的数据从全局内存加载到共享内存,然后从共享内存读取计算。

__global__ void matMulShared(float* A, float* B, float* C, int N) {
    __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 k = 0; k < N / BLOCK_SIZE; k++) {
        As[threadIdx.y][threadIdx.x] = A[row * N + k * BLOCK_SIZE + threadIdx.x];
        Bs[threadIdx.y][threadIdx.x] = B[(k * BLOCK_SIZE + threadIdx.y) * N + col];
        __syncthreads();
        
        for (int i = 0; i < BLOCK_SIZE; i++) {
            sum += As[threadIdx.y][i] * Bs[i][threadIdx.x];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}

这段代码里,__syncthreads()是同步点。确保所有线程都加载完数据后,再开始计算。否则,有的线程还在读,有的已经开始算了,数据就乱了。

注意:__syncthreads()只能在同一个线程块内同步。不同线程块之间无法同步。如果你需要全局同步,只能结束当前kernel,再启动下一个。

我做过测试:用共享内存优化后,矩阵乘法的性能提升了5-10倍。具体取决于矩阵大小和硬件配置。

6. Tensor Core编程:cuBLAS与cuDNN

直接编程Tensor Core很麻烦。NVIDIA提供了cuBLAS和cuDNN库,封装了底层细节。

比如用cuBLAS做矩阵乘法:

cublasHandle_t handle;
cublasCreate(&handle);

float alpha = 1.0f, beta = 0.0f;
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,
             CUBLAS_COMPUTE_32F,
             CUBLAS_GEMM_DEFAULT_TENSOR_OP);

cublasDestroy(handle);

这段代码里,cublasGemmEx会自动选择Tensor Core还是CUDA核心。你只需要指定精度和计算类型。CUBLAS_COMPUTE_32F表示用32位浮点累加,即使输入是FP16。

我个人习惯:能用库就用库。除非你有非常特殊的计算需求,否则不要自己写Tensor Core代码。库的优化比你手写的好得多。

7. 性能调优:从理论到实践

最后,分享几个性能调优的经验:

  1. 计算密度:确保每个线程有足够的计算量。如果计算太少,内存访问开销占比太大。
  2. 占用率(Occupancy):每个SM上活跃的warp数量。占用率越高,越能隐藏内存延迟。
  3. 寄存器压力:每个线程用的寄存器太多,会降低占用率。我一般控制在32个以内。
  4. 内存带宽:用nvidia-smi或Nsight Systems监控带宽利用率。如果低于50%,说明内存访问模式有问题。

避坑指南:我曾经为了追求高占用率,把每个线程的寄存器压到16个。结果因为寄存器溢出(spill),性能反而下降了。后来发现,适当增加寄存器,减少全局内存访问,效果更好。记住:占用率不是越高越好,要综合考虑。

好了,关于GPU架构和通用计算,今天就聊到这儿。CUDA核心和Tensor Core各有千秋,关键是要根据你的应用场景选择合适的工具。做AI训练,Tensor Core是首选;做科学计算,CUDA核心更灵活。两者配合,才能发挥GPU的最大潜力。

GPU架构与通用计算核心逻辑 GPU(图形处理器) SM(流式多处理器) CUDA核心(数百个) Tensor Core(数十个) 共享内存 / 寄存器 CUDA编程模型:线程 → 线程块 → 网格 科学计算(CUDA核心) AI训练/推理(Tensor Core) 图形渲染(CUDA核心) 核心思想:CUDA核心处理通用并行计算,Tensor Core专攻矩阵运算,两者协同工作

公众号:蓝海资料掘金营,微信deep3321