一、显存访问的瓶颈:为什么显存访问是算子的主要瓶颈
做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%。
后来一分析,问题出在:
- 非合并访问:每个线程读的数据在显存里不连续,导致带宽利用率极低
- 重复读取:同一个数据被多个线程反复读,浪费带宽
- 延迟没隐藏:线程数太少,无法掩盖访存延迟
优化后,我把数据先读到共享内存里做复用,再调整线程布局实现合并访问。最终带宽利用率提到了75%以上,速度翻了5倍。
避坑指南:我曾经犯过一个低级错误——在kernel里频繁用if分支导致warp divergence,结果部分线程在等,部分线程在算,访存模式全乱了。记住:warp内的线程要尽量走同一条路,否则带宽利用率会断崖式下跌。
1.5 知识体系总览
下面这张图是我自己总结的显存访问优化框架,帮你理清思路:
1.6 小结
记住三句话:
- 显存带宽是算子的第一瓶颈——大部分算子都是带宽受限的
- 带宽和延迟是两回事——带宽看数据量,延迟看等待时间
- 优化要有的放矢——带宽问题靠复用和合并,延迟问题靠隐藏
嗯,这一章就讲这些。下一章咱们聊聊合并访问的具体实现——怎么让显存带宽利用率从20%提到90%。