一、显存访问的瓶颈:为什么显存访问是算子的主要瓶颈

做GPU算子优化这些年,我最大的体会就是——算力不是问题,显存带宽才是。

你想想看,现在的GPU算力有多猛?A100的FP16算力接近312 TFLOPS,H100更是奔着2000 TFLOPS去了。但显存带宽呢?A100 HBM2e也就2 TB/s出头。这中间差了两个数量级。说白了,你的计算单元大部分时间都在等数据。

1.1 为什么显存访问这么慢?

先看一组数字,我习惯用这个对比来说明问题:

存储层级 典型延迟 带宽 相对速度
寄存器 ~1 cycle 几十TB/s ⚡⚡⚡⚡⚡
共享内存 ~20 cycles ~10 TB/s ⚡⚡⚡⚡
L1缓存 ~30 cycles ~5 TB/s ⚡⚡⚡
L2缓存 ~200 cycles ~1 TB/s ⚡⚡
HBM显存 ~400-800 cycles ~2 TB/s ⚡

看到没?从显存读一个数,GPU核心能等上几百个周期。这期间本来可以执行几百条指令,结果全在空转。

核心观点:现代GPU算力增长远快于显存带宽增长。算力每年翻倍,带宽只涨30-40%。所以大部分算子都是带宽受限的,而不是计算受限。

1.2 带宽 vs 延迟:两个不同的敌人

这两个概念经常被混为一谈,但其实是两码事。我举个例子你就明白了:

  • 带宽:好比高速公路的车道数。车道越多,单位时间能过的车越多。
  • 延迟:好比从入口到出口的通行时间。哪怕100条车道,该等红灯还是得等。

在GPU里:

  • 带宽瓶颈:数据量太大,显存接口忙不过来。比如做一次大矩阵乘法,要读几GB的数据。
  • 延迟瓶颈:每次访存都要等几百个周期。比如随机访问显存中的小数据块。

我的经验:优化带宽问题,靠的是数据复用和合并访问。优化延迟问题,靠的是隐藏延迟——用更多的线程来填满等待时间。

1.3 一个简单的计算示例

咱们算一笔账。假设你要做向量加法:C = A + B,每个向量有1亿个float元素。

// 数据量
每个向量:100M × 4 bytes = 400 MB
总共读取:A(400MB) + B(400MB) = 800MB
写入:C(400MB) = 400MB
总访存量:1.2 GB

// 计算量
1亿次加法 = 100M FLOPs

// 假设A100 HBM带宽:2 TB/s
理论最小访存时间:1.2 GB / 2 GB/s = 0.6 ms

// 假设A100 FP32算力:19.5 TFLOPS
理论最小计算时间:100M / 19.5T = 0.005 ms

// 结论
访存时间 ≈ 计算时间 × 120倍!

说白了,这个算子的性能天花板就是显存带宽。你把计算优化得再快也没用,瓶颈在数据搬运上。

1.4 我在项目中遇到的典型问题

我曾经优化过一个LayerNorm算子。一开始直接按naive方式实现,每个线程读一次输入,写一次输出。结果性能惨不忍睹,只有理论带宽的15%。

后来一分析,问题出在:

  1. 非合并访问:每个线程读的数据在显存里不连续,导致带宽利用率极低
  2. 重复读取:同一个数据被多个线程反复读,浪费带宽
  3. 延迟没隐藏:线程数太少,无法掩盖访存延迟

优化后,我把数据先读到共享内存里做复用,再调整线程布局实现合并访问。最终带宽利用率提到了75%以上,速度翻了5倍。

避坑指南:我曾经犯过一个低级错误——在kernel里频繁用if分支导致warp divergence,结果部分线程在等,部分线程在算,访存模式全乱了。记住:warp内的线程要尽量走同一条路,否则带宽利用率会断崖式下跌。

1.5 知识体系总览

下面这张图是我自己总结的显存访问优化框架,帮你理清思路:

显存访问瓶颈分析框架 显存访问瓶颈 带宽瓶颈 延迟瓶颈 数据量过大 非合并访问 数据复用不足 线程数不足 随机访问模式 warp divergence 优化方向:数据复用 + 合并访问 + 隐藏延迟

1.6 小结

记住三句话:

  • 显存带宽是算子的第一瓶颈——大部分算子都是带宽受限的
  • 带宽和延迟是两回事——带宽看数据量,延迟看等待时间
  • 优化要有的放矢——带宽问题靠复用和合并,延迟问题靠隐藏

嗯,这一章就讲这些。下一章咱们聊聊合并访问的具体实现——怎么让显存带宽利用率从20%提到90%。

可可精品资料库