2、GPU架构与显存层次:NVIDIA GPU架构概览,显存层次结构(HBM、L2 Cache、L1 Cache),显存带宽与延迟

聊到吞吐压测,你绕不开GPU的显存。说白了,压测就是在跟显存打交道。我见过太多人,拿着A100跑模型,结果吞吐上不去,第一反应是“代码写得不好”。其实很多时候,是你不了解GPU的“脾气”。

今天我们就来扒一扒NVIDIA GPU的架构,尤其是显存这块。嗯,这部分内容有点硬核,但相信我,搞懂了它,你调优的时候会少走很多弯路。

NVIDIA GPU架构概览:从Fermi到Hopper

先看一张图,这是我个人习惯的总结方式——把GPU架构拆成几个核心模块:

NVIDIA GPU 核心架构模块 GPC (图形处理簇) 包含多个TPC/SM TPC (纹理处理簇) 包含2个SM SM (流式多处理器) CUDA核心 + 共享内存 HBM (高带宽显存) 容量大,带宽高 L2 Cache 所有SM共享 L1 Cache / 共享内存 每个SM独有 数据流方向:HBM → L2 Cache → L1 Cache/共享内存 → SM 带宽递增 ↑ | 延迟递减 ↓

这张图我画了好几次才满意。你注意看,数据是从HBM一层层往上“喂”到SM的。每一层都有它的脾气——容量、带宽、延迟都不一样。

从Fermi架构开始,NVIDIA就在不断优化这个层次结构。到了Hopper(H100),HBM3的带宽已经飙到3.35TB/s了。但你知道吗?我去年调一个推理服务,发现实际有效带宽连理论值的60%都没跑到。问题出在哪?后面会讲。

显存层次结构:HBM、L2 Cache、L1 Cache

咱们一层层拆开看。

1. HBM(高带宽显存)

HBM是GPU的“大仓库”。它容量大,但离计算单元最远。A100用的是HBM2e,40GB或80GB版本。H100升级到了HBM3。

这里有个关键点:HBM的带宽虽然高,但延迟也高。我记得有一次压测一个BERT模型,发现GPU利用率只有30%。一查,发现大部分时间都在等数据从HBM搬过来。这就是典型的“显存带宽瓶颈”。

核心指标:

  • A100 (HBM2e): 带宽 2.0 TB/s,容量 40/80 GB
  • H100 (HBM3): 带宽 3.35 TB/s,容量 80 GB
  • V100 (HBM2): 带宽 0.9 TB/s,容量 16/32 GB

2. L2 Cache

L2 Cache是所有SM共享的“中转站”。A100的L2 Cache有40MB,H100有50MB。你想想看,如果数据能命中L2,就不用去HBM拿了,延迟能降一个数量级。

我做过一个实验:把矩阵乘法中的分块大小从256调到512,L2命中率从70%升到了92%。吞吐直接涨了1.8倍。这就是L2的威力。

调优技巧: 尽量让数据“热”在L2里。比如做Attention时,把Q、K、V的tile大小控制在L2容量以内。我曾经用这个技巧,把GPT推理的延迟从12ms降到了7ms。

3. L1 Cache / 共享内存

L1 Cache是每个SM私有的,A100每个SM有192KB(可配置为L1或共享内存)。它离CUDA核心最近,延迟只有几十个时钟周期。

但容量太小了。你放不下大矩阵,只能放一些“热数据”。比如做卷积时,把输入feature map的一个小窗口放到共享内存里,反复用。

嗯,这里要注意:L1和共享内存是“共享”同一块物理空间的。你可以通过编译器选项调整它们的比例。我一般习惯把共享内存配大一点,因为很多kernel需要用它做数据交换。

显存带宽与延迟:为什么你的压测跑不满?

好,到了最核心的部分。显存带宽和延迟,是压测的“命门”。

先看一组数据:

层级 容量 带宽 延迟(近似)
HBM 40-80 GB ~2 TB/s ~400 ns
L2 Cache 40-50 MB ~4 TB/s ~200 ns
L1 Cache 128-192 KB ~12 TB/s ~30 ns
寄存器 256 KB/SM ~20 TB/s ~1 ns

你看,从HBM到寄存器,带宽差了10倍,延迟差了400倍。这就是为什么我们要做“数据局部性”优化——尽量让数据待在离计算单元近的地方。

我曾经踩过一个坑:跑一个大规模推荐模型,batch size设得太大,导致L2 Cache被频繁“踢出”。结果有效带宽从1.5TB/s掉到了0.6TB/s。后来我把batch size减半,并用了梯度累积,吞吐反而上去了。

避坑指南: 不要盲目追求大batch size。它会让你的数据“溢出”L2 Cache,导致频繁访问HBM。我曾经因为这个原因,一个模型训练时间从2天拖到了5天。后来用nsys profile一看,L2命中率只有40%。

那怎么测有效带宽呢?我一般用NVIDIA的bandwidthTest工具,或者自己写一个简单的kernel:

// 一个简单的带宽测试kernel
__global__ void bandwidth_test(float* in, float* out, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        out[idx] = in[idx] * 2.0f;  // 简单的读-写操作
    }
}

// 调用方式
int N = 1 << 28;  // 256M个元素
float *d_in, *d_out;
cudaMalloc(&d_in, N * sizeof(float));
cudaMalloc(&d_out, N * sizeof(float));

// 计时
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord(start);
bandwidth_test<<<N/256, 256>>>(d_in, d_out, N);
cudaEventRecord(stop);
cudaEventSynchronize(stop);

float ms;
cudaEventElapsedTime(&ms, start, stop);
float bandwidth = (2.0f * N * sizeof(float)) / (ms / 1000.0f) / 1e9;  // GB/s
printf("Effective bandwidth: %.2f GB/s\n", bandwidth);

这个代码很简单,但能反映出真实的有效带宽。我一般会跑三次取中位数,避免冷启动的影响。

最后说一句:显存层次不是死的。你可以通过调整数据布局、访问模式来“欺骗”硬件,让数据尽量留在L1或L2里。这就是压测调优的精髓。

想要更多资料,联系可可