CUDA 编程基础回顾

好,咱们正式开始。这个系列课程讲的是多GPU负载均衡,但上来我得先带你把单卡上的CUDA基础再过一遍。别嫌啰嗦——我在实际调优时发现,大部分多卡性能瓶颈其实都根植于单卡基础没打牢。

1. GPU架构概览

先说说GPU长什么样。你想想看,CPU是“精壮小子”:核少但单个核又大又强,擅长复杂控制流。GPU呢?它是个“人海战术”的典范:成百上千个轻量级核心堆在一起,专攻数据并行。

核心事实:NVIDIA GPU内部由多个流多处理器(SM)构成,每个SM包含一组CUDA核心、共享内存、调度器等。SM才是真正的执行单元,不是你写的线程直接跑的。

我记得早期用Fermi架构时,一个SM只有32个CUDA核心,到了现在的H100,一个SM可以塞进128个。但不管怎么变,Warp始终是调度的基本单位——32个线程一捆,一起执行同一指令。这是理解性能的关键。

架构代号SM特性典型算力我的踩坑点
Fermi16个SM,每个32 CUDA核1.3 TFLOPS共享内存没bank冲突时很开心,有冲突就血崩
KeplerSMX,动态并行4 TFLOPS动态并行听着牛,实际调试起来想哭
Volta独立线程调度,Tensor Core7.5 TFLOPS线程束发散第一次把我整懵了
Ampere/HopperMIG,DPX指令9~60 TFLOPS多实例GPU切分时资源隔离要小心

2. CUDA核心概念

说白了,CUDA编程就是告诉GPU:你的数据在哪儿、怎么切、每个小片用哪个函数处理。这里有两个主角:Host(CPU端)和Device(GPU端)。

  • Kernel:你写的一个 __global__ void 函数,在设备上并行执行。
  • 流(Stream):一系列按顺序执行的命令队列。多流可以并发。
  • 事件(Event):用于计时或同步,比 cudaDeviceSynchronize 更精细。

我习惯在写Kernel前先画一个数据流水线图——从Host搬运到Device,算完再搬回来。这一步看似简单,但曾经有个项目我把两批独立数据放进了同一个流,结果吞吐量直接腰斩。

技巧:用 cudaMemcpyAsync 配合流,可以实现计算和传输重叠。这在多卡场景格外重要。

3. 线程层次结构

CUDA的线程组织是三层嵌套:Grid → Block → Thread。每个Block内的线程可以协作(通过共享内存和同步原语),不同Block之间则是独立的。

// 典型的Kernel启动配置
dim3 gridDim(256, 1, 1);   // 网格中有256个block
dim3 blockDim(128, 1, 1);  // 每个block中有128个thread

myKernel<<<gridDim, blockDim>>>(d_data, N);

Block内的线程数量最好是Warp大小(32)的整数倍,不然会有空着的线程束浪费资源。嗯,这里要注意:一个Block最多1024个线程(计算能力3.0+),但别真塞满——要留给寄存器、共享内存总量。

警告:我曾经为了省事把blockDim设成256,结果某个Kernel的寄存器压力爆了,编译器自动把寄存器溢出到Local Memory,速度降了10倍。所以Block大小不是越大越好,得拿Occupancy Calculator算一算。

4. 内存层次

CUDA的内存模型,我把它总结成一张“速度‐容量”图。说白了:离计算单元越近越快,但越小越贵。

内存类型作用域典型容量延迟(时钟周期)缓存?
寄存器(Register)单个线程每个线程最多255个~1无
局部内存(Local Memory)单个线程依赖寄存器溢出~400有L1/L2
共享内存(Shared Memory)整个Block每个SM通常48KB~164KB~5(无bank冲突)无,手动管理
全局内存(Global Memory)所有线程 + Host卡上显存(GB级)~400有L2
常量内存(Constant Memory)所有线程,只读64KB~5(缓存命中时)专用常量缓存
纹理内存(Texture Memory)所有线程,只读同显存~400(无缓存)专用纹理缓存

我刚开始做GPU加速时,一股脑把数据全扔进全局内存,然后被慢得怀疑人生。后来才发现,很多场景下把频繁访问的数据搬进共享内存,性能能翻几番。

优化口诀:全局内存要合并访问(同一个Warp的线程访问连续地址);共享内存要避免bank冲突(避免多个线程访问同一bank的不同地址)。

5. Kernel编写实战

光说不练假把式。咱们写一个最经典的向量加法Kernel,顺便把上面讲的概念串起来。

// vector_add.cu
#include <cuda_runtime.h>

__global__ void vecAddKernel(const float* A, const float* B, float* C, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx];
    }
}

int main() {
    int N = 1 << 20;  // 约100万
    size_t bytes = N * sizeof(float);

    float *h_A, *h_B, *h_C;
    float *d_A, *d_B, *d_C;

    // 1. 分配Host内存
    h_A = (float*)malloc(bytes);
    h_B = (float*)malloc(bytes);
    h_C = (float*)malloc(bytes);

    // 初始化数据
    for (int i = 0; i < N; i++) { h_A[i] = 1.0f; h_B[i] = 2.0f; }

    // 2. 分配Device内存
    cudaMalloc(&d_A, bytes);
    cudaMalloc(&d_B, bytes);
    cudaMalloc(&d_C, bytes);

    // 3. 数据从Host搬到Device
    cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice);

    // 4. 配置Kernel启动参数
    int blockSize = 256;
    int gridSize = (N + blockSize - 1) / blockSize;
    vecAddKernel<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);

    // 5. 结果从Device搬回Host
    cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost);

    // 6. 验证
    for (int i = 0; i < 10; i++) {
        printf("h_C[%d] = %f\n", i, h_C[i]);
    }

    // 7. 清理
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C);
    return 0;
}

你看,这个流程其实就六步:分配Host内存 → 分配Device内存 → 数据传输 → 启动Kernel → 传回结果 → 清理。简单吧?但我在实际项目中踩过坑:忘了检查cudaMalloc的返回值,结果在大卡上分配了过大的共享内存直接挂掉。所以——

最佳实践:每个CUDA API调用后面加个 cudaError_t err = cudaGetLastError(); 检查,至少上线前要跑一遍 cuda-memcheck。

好了,基础回顾到这儿。下面这张SVG图把这一章的核心脉络画出来了,你留个印象。

CUDA编程基础·知识框架 GPU架构 • 流多处理器(SM) • Warp (32线程/束) • CUDA Core • 寄存器/共享内存 线程层次 • Grid (多个Block) • Block (协作) • Thread (执行单元) • 内置变量: blockIdx, threadIdx 内存层次 • 全局内存 (显存) • 共享内存 (Block内) • 寄存器 (Thread内) • 常量/纹理内存 Kernel编写 & H2D/D2H数据传输 💡 性能关键在于:全局内存合并访问 + 共享内存用足 + 避免Warp发散 + Occupancy合理 多GPU负载均衡会在此基础上增加跨设备通信和任务切分