从FLOPs到访存量:用算术强度与Roofline破解GPU带宽瓶颈

发布时间:2026/10/27 5:50:19
从FLOPs到访存量:用算术强度与Roofline破解GPU带宽瓶颈

1. 先把账算清楚:延迟到底卡在算力还是带宽

1.1 一次"算力够用却跑不动"的复盘

去年帮一个团队看推理服务的性能问题,现象非常典型:单卡标称算力几百 TFLOPS 级别,模型参数量也不算大,但端到端吞吐死活上不去,单请求延迟抖得厉害。第一反应是算子实现有问题,翻了一遍 kernel 代码没找到明显毛病;第二反应是并行策略不对,调了 tensor parallel 的切分比例,收益也有限。真正把问题定位下来,是在 profiler 里看到一组很难看但很说明问题的数字:SM 的占用率长期在 20% 出头,而显存控制器的吞吐已经顶到标称值的 85% 以上。

这个组合基本就宣判了:瓶颈根本不在深度神经网络的计算环节,而在内存带宽。芯片的浮点单元在等数据,等的是从显存搬过来的权重和激活值。你把矩阵乘的 tiling 调得再漂亮,只要每个输出元素需要的数据没法在片上缓存里被反复复用,算力就只能闲着。

这件事之后我养成一个习惯:拿到任何一个网络结构,先不算它有多少 FLOPs,先算它要搬多少字节。这两个数一比,性能上限在哪个量级基本就定了,后面调优方向也就不容易跑偏。深度神经网络里的计算和内存带宽,说白了就是"每秒能算多少次"和"每秒能搬多少字节"这两个预算之间的博弈,谁先见底,谁决定延迟。

1.2 算术强度:把算子分成两拨人

衡量这个博弈的指标叫算术强度(Arithmetic Intensity),记作 AI,单位是 FLOP/Byte,含义是每从显存搬一个字节的数据,能顺带完成多少次浮点运算。算出来之后拿去和硬件的"拐点"比,就知道这个算子是计算受限还是带宽受限。

硬件拐点的算法很直白:峰值算力除以峰值带宽。举个量级上的例子,一块标称 FP16 张量算力约 300 TFLOPS、HBM 标称带宽约 2 TB/s 的数据中心卡,拐点大约在 150 FLOP/Byte 附近;一块带宽 1 TB/s、FP16 张量算力 160 TFLOPS 左右的消费级卡,拐点差不多也是 150 上下。这个数字很有意思——不管高端低端,现代加速卡的算力带宽比大致都落在同一个区间,因为工艺进步对两者的提升速度是同步的,业内常说的"内存墙"其实就是这个比值长期停滞的结果。

于是算子就被分成了两拨。AI 远高于拐点的,比如大尺寸矩阵乘、大卷积,属于计算受限,优化方向是喂饱计算单元、减少流水线气泡;AI 远低于拐点的,比如逐元素加、归一化、softmax、decode 阶段的自注意力,属于带宽受限,优化方向只有一个——少搬数据。麻烦在于,真实网络里带宽受限的算子数量往往比计算受限的算子还多,而且它们夹在中间,把计算密集算子好不容易攒起来的效率又拖回去。

1.3 三类最容易被漏算的访存

手算访存量的时候,新手最常犯的错是只算"输入张量 + 权重 + 输出张量",把下面三类开销全漏了,结果理论值和实测差出一大截。

第一类是中间张量。一个残差块里,conv 输出、归一化输出、激活输出如果不做融合,每个都要完整写回显存再读出来,一个 FP16 的[B, S, H]张量在 B=32、S=2048、H=4096 时是 512MB 量级,来回几趟就是好几个 GB 的流量。

第二类是布局转换。NCHW 和 NHWC 之间的转换、转置、padding 补齐,这些操作本身 FLOPs 接近零,但要把整个张量读一遍写一遍,AI 低到可以忽略,纯纯的带宽消耗。

第三类是训练特有的状态量。Adam 优化器要为每个参数维护一阶矩和二阶矩,加上 FP32 的主权重副本,一个 FP16 参数实际对应的显存读写量是权重的 6 倍以上。很多人算训练带宽预算时只按参数量乘 2 字节估,实际跑起来差好几倍,就是这个原因。

注意:显存容量和显存带宽是两件事。容量不够会 OOM,带宽不够只会变慢,而变慢往往不被当成故障,只是被当成"这卡就这样",白白浪费了优化空间。

2. 手算 FLOPs 与访存量:一套能复现的推法

2.1 矩阵乘的算术强度怎么一步步推出来

拿最基础的 GEMM 练手:A 是 M×K,B 是 K×N,C 是 M×N,数据类型 FP16。FLOPs 的计算是把乘加算作两次运算,所以是 2×M×N×K。访存量按最朴素的口径,读 A、读 B、写 C,每个元素 2 字节,就是 2×(M×K + K×N + M×N)。

两者一除就得到算术强度。取 M=N=K=4096 代进去:FLOPs 是 2×4096³ ≈ 1.37×10¹¹,访存量是 2×3×4096² ≈ 1.0×10⁸ 字节,AI 大约是 1360 FLOP/Byte。这个数远高于 150 的拐点,说明大矩阵乘是标准的计算受限算子,理论上能跑到峰值算力的八九成。

把 K 换成 1,也就是矩阵乘退化成矩阵向量乘(GEMV),情况立刻反转。M=N=4096、K=1 时,FLOPs 只有 3.3×10⁷,主要流量是那个 4096×4096 的权重矩阵,约 33.6MB,AI 掉到 1 FLOP/Byte 左右。对比拐点 150,意味着算力利用率上限只有 1/150,不到 1%。这就是为什么大模型 decode 阶段明明每秒只生成几十个 token,GPU 却已经跑满了带宽——每一层的前馈网络在 batch 很小时都退化成了 GEMV。

这个推导过程建议自己写一遍脚本,把参数留成变量,改一改就能算别的形状。下面这段可以直接抄:

def gemm_intensity(M, N, K, bytes_per_elem=2): flops = 2 * M * N * K traffic = 2 * (M * K + K * N + M * N) * bytes_per_elem return flops / traffic, flops, traffic for shape in [(4096, 4096, 4096), (4096, 4096, 1), (1, 4096, 4096), (128, 4096, 4096)]: ai, f, t = gemm_intensity(*shape) print(f"M,N,K={shape} -> AI={ai:.1f} FLOP/Byte, FLOPs={f:.3e}, Bytes={t:.3e}")

跑出来会看到,M=1 或 K=1 的形状 AI 都在 1 附近,这就是 batch 小的时候性能塌方的数学原因,跟实现好坏关系不大。

2.2 roofline 拐点:给硬件画一条线

roofline 模型的做法是把横轴设为算术强度(对数刻度),纵轴设为可达性能,画出两条线:一条水平线是峰值算力,一条斜线是"带宽乘以算术强度"。两条线的交点就是拐点,左边斜线区域是带宽受限区,右边水平区域是计算受限区。

这个图的价值在于,它告诉你某个算子在当前硬件上的性能天花板在哪。如果一个算子的 AI 算出来是 5,拐点是 150,那它的性能上限就是峰值算力的 3.3%,你花再多力气优化指令调度也没用,唯一有效的手段是把 AI 提上去——要么增加复用(分块、融合),要么减少字节(量化、稀疏)。

实际画图的时候有个细节要注意:横轴应该用"实际产生的显存流量"而不是"理论最小流量"。比如一个卷积,如果实现里做了 im2col,输入张量被复制了 9 倍,那实际 AI 就要除以 9。很多人画出来的点和实测对不上,问题都出在这里。

硬件档位标称带宽量级FP16 张量算力量级roofline 拐点
数据中心加速卡(HBM2e/HBM3)1.5 ~ 3 TB/s300 ~ 1000 TFLOPS150 ~ 350 FLOP/Byte
消费级加速卡(GDDR6X)0.8 ~ 1 TB/s80 ~ 170 TFLOPS100 ~ 200 FLOP/Byte
移动端 SoC(LPDDR5X)50 ~ 70 GB/s20 ~ 40 TOPS(INT8)300 ~ 600 OPS/Byte

2.3 常见算子算术强度速查

把常用算子按同样的口径算一遍,心里就有谱了。这里统一按 FP16、单元素 2 字节估算,逐元素类算子按"读几份写几份"计流量。

算子典型 FLOPs/元素典型字节/元素算术强度受限类型
逐元素加法(残差)16(两读一写)0.17带宽
GELU / SiLU约 8 ~ 154(一读一写)2 ~ 4带宽
LayerNorm约 5 ~ 841.3 ~ 2带宽
Softmax约 54 ~ 8(含中间遍历)0.6 ~ 1.2带宽
GEMM(4096³)2K摊薄后极低1000+计算
GEMV(K=1)2约等于权重字节约 1带宽
3×3 卷积(无 im2col)18摊薄后低数十视尺寸
3×3 卷积(im2col 展开)18输入被复制 9 倍降为 1/9视尺寸

这张表最该记住的是第一行和最后两行。逐元素算子的 AI 是 0.17,也就是说搬 6 个字节才做 1 次运算,在任何现代硬件上都是纯带宽活;而卷积一旦走了 im2col 且没有靠 L2 缓存把复制摊薄,理论上的计算优势会直接缩水一个数量级。

3. 逐算子拆解:到底是谁在偷带宽

3.1 逐元素与归一化层:看着不起眼,加起来吓人

单个逐元素算子的耗时确实可以忽略,问题是它们数量多。一个标准的 Transformer block 里,残差相加、LayerNorm、激活、dropout、scale 这些操作加起来有十来个,每一个都要把整个张量读一遍写一遍。以 hidden 维度 4096、序列长度 2048、batch 32 为例,单个张量约 512MB,一个算子一读一写就是 1GB 流量,十个算子就是 10GB。按 2 TB/s 带宽算,光这些"零碎活"就要花 5 毫秒,而同样数据量的大矩阵乘可能只要 1 毫秒出头。

LayerNorm 更值得单独说。它的计算需要先求均值和方差,意味着至少要遍历一遍数据,再遍历一遍做归一化,如果不做单遍实现(用 Welford 或者两遍合并),流量会翻倍。很多框架的 LayerNorm 实现里,中间还额外存了 mean 和 rstd 两个小张量,虽然字节数不多,但多一次 kernel launch 和一次全局同步,延迟上的损失比带宽上的更大。

提示:判断一个逐元素算子有没有被融合,最快的办法是看 profiler 里的 kernel 数量和 HBM 读写字节数。如果两个连续算子的读写字节数都接近张量大小,说明它们各自独立跑了一趟显存,肯定有融合空间。

3.2 Softmax 与注意力:decode 阶段的带宽黑洞

Softmax 的算术强度低到 1 左右,原因很直白:它需要对一行做最大值、指数、求和、再除,中间要多次访问同一批数据。做得好可以两遍遍历搞定,做得差要四遍。分子分母还都是 FP32 累加,流量按 4 字节算,比 FP16 更费。

真正的大头在注意力机制。prefill 阶段(输入提示词一次性处理)的注意力是有大矩阵乘的,计算受限,问题不大;decode 阶段(逐 token 生成)就完全变了性质。这时候 query 只有一个 token,key 和 value 是历史上所有 token 的缓存,每一步都要把整个 KV cache 读一遍。按序列长度 4096、单头维度 128 估算,每层每步的计算量约 4×4096×128 ≈ 2.1 MFLOPs,而 KV cache 的读取量是 2×4096×128×2 字节 ≈ 2.1 MB,AI 又回到 1 附近。

这就是 decode 阶段吞吐上不去的根本原因:它不是算得慢,是每次只算这么点东西却要搬 2MB 数据。把 batch 调大能摊薄权重的读取,但每个请求的 KV cache 是独立的,读取量随 batch 线性增长,AI 并不会因此改善。要真正缓解,只能从减少 KV cache 字节数(量化到 INT8/FP8)、减少重复读取(分页管理、前缀共享)、或者把 attention 和其他算子融合起来做这几条路走。

3.3 卷积、布局转换与那些隐形的流量

卷积的算术强度取决于尺寸。大 kernel、大通道数的卷积复用率高,是计算受限;但 depthwise 卷积、1×1 卷积在小通道数下就未必了。depthwise 卷积每个通道独立计算,通道间没有复用,AI 只有 kernel 面积那么多,3×3 的情况下也就 9 左右,远低于拐点,属于典型的内存受限算子,在移动端和轻量模型里非常常见。

布局转换是另一类隐形杀手。NCHW 转 NHWC 在 CPU 上还能靠编译器优化掉,在 GPU 上往往就是一个独立的 transpose kernel,读写各一遍。有些框架在每次卷积前都插一次转换,层层叠加下来,光布局转换就能吃掉 10% 到 20% 的时间。我见过一个项目,把模型里的所有 transpose 合并成一次,端到端快了 8%,代码改动不超过五十行。

还有一类是 padding 和 mask 带来的无效计算与无效访存。序列长度 2048 被补齐到 2048 还好,补到 4096 就是整整一倍的无用流量。变长序列场景里这个比例可能更高,值得在数据组织层面处理掉,而不是留给 kernel 去算。

4. 实测:把"计算耗时"和"带宽耗时"分开量

4.1 先测有效带宽,别信标称值

标称带宽是理论峰值,实际能跑到的有效带宽通常只有 75% 到 90%,取决于访问模式、TLB 命中、ECC 开销等因素。做性能分析之前,一定要先在你的机器上测一个基线,后面所有推算都用这个基线。

最省事的办法是跑一次大张量的拷贝:读一份、写一份,用总字节数除以耗时。张量大小要选得足够大,超过最后一级缓存,比如单块卡上跑 1GB 到 4GB 的规模。测的时候记得预热几次,把首次分配和页表建立的开销排除掉。

# 用 profiler 抓一段纯拷贝 kernel 的指标,重点看两项 ncu --metrics dram__bytes_read.sum,dram__bytes_write.sum,gpu__time_duration.sum \ --target-processes all python bench_copy.py

测出来的有效带宽如果是标称值的 85%,那后面所有"理论上限"的计算都要乘这个系数。很多性能模型对不上实测,就是因为拿标称值当分母。

4.2 profiler 指标怎么读:三个信号定方向

用 Nsight Compute 这类工具抓一个 kernel,指标几十上百个,新手容易看花眼。我的经验是只盯三个信号,基本就能定性。

第一个是DRAM 吞吐占峰值带宽的比例(dram__throughput.avg.pct_of_peak_sustained_elapsed)。这个值超过 80%,说明已经撞到带宽墙,再怎么调计算都没用,必须减少流量。低于 50% 而耗时又长,说明既没吃满带宽也没吃满算力,问题在延迟隐藏或者并行度不够。

第二个是SM 的忙碌比例和计算管线利用率。如果带宽不高、SM 也不忙,典型的可能是 occupancy 太低、block 数量不足、或者有严重的串行依赖。

第三个是warp 的 stall 原因分布。long_scoreboard占比高说明在等全局内存,mio_throttle高说明共享内存或特殊功能单元排队,barrier高说明同步开销大。这三者的处理方式完全不同,看错了方向就白忙。

注意:单看一个 kernel 的指标容易误判。连续的小 kernel 之间可能有依赖,单个看都不慢,串起来却拉长了整体时间。用时间线视图看完整的调用序列,比看单个 kernel 的火焰图更有价值。

4.3 计时纪律:预热、锁频、多次采样

这件事看着琐碎,但不做的话测出来的数全是噪声。第一,必须预热。首次运行包含显存分配、kernel 编译、缓存预热,耗时可能是稳态的好几倍,一般跑 10 到 20 次再开始计时。

第二,锁定频率。加速卡有动态调频,负载一上来频率先掉,测出来的数忽高忽低。有条件的话把核心频率和显存频率都锁死,样本之间的波动立刻小一个量级。如果没有权限锁频,就多采几次取中位数,别用平均值,异常值会把均值拉偏。

第三,控制输入分布。变长序列、稀疏输入、极端小的 batch,这些都会让单次测量失去代表性。测试集要覆盖真实业务的分布,最好能拿到线上流量的长度分位数,按 P50 和 P99 分别测一遍。

第四,区分冷缓存和热缓存。同一份权重反复调用,第二次开始可能命中 L2,测出来的带宽需求偏低。评估真实场景时要考虑权重规模——权重几百 MB 的时候 L2 根本装不下,每次都从 HBM 读,这个差别在模型小于缓存时特别容易误导人。

5. 优化路径:提高复用,压低流量

5.1 算子融合:把中间张量摁在片上

融合是提升算术强度最直接的手段,原理简单:两个相邻算子如果不融合,中间结果要写回显存再读出来,一写一读就是两趟流量;融合之后中间结果留在寄存器或共享内存里,流量直接归零。

典型可融合的组合有:卷积/矩阵乘 + 偏置 + 激活、残差相加 + LayerNorm、scale + mask + softmax、dropout + 残差。以 LayerNorm 为例,把残差加法、均值方差计算、归一化、缩放四个步骤融成一个 kernel,原来至少四趟读写变成一趟读一趟写,流量降到四分之一,实测在中等规模模型上能省 10% 到 15% 的端到端时间。

融合也有代价。寄存器压力会上升,occupancy 可能下降;如果融合后的 kernel 太大导致指令缓存命中率变差,反而会变慢。我的经验是融合链不要超过四到五个算子,超过就先测一下再决定。另外,融合会牺牲调试便利性,中间张量看不到了,出问题不好定位,建议在开发阶段保留一个可切换的未融合版本。

5.2 分块、布局与合并访问:把每个字节用满

分块(tiling)的作用是提高片上复用,让同一个数据块在被换出之前被尽可能多的计算用到。前面算过,一个 128×128 的块在 FP16 下的单块算术强度只有 32 左右,单看并不高;但实际大 GEMM 里同一份 A 会被 N/128 个块复用,同一份 B 会被 M/128 个块复用,靠 L2 缓存把这些复用接住,整体 AI 才能冲到上千。所以分块尺寸的选择不只是片上内存够不够的问题,还要考虑 L2 容量能不能装下相邻块的工作集。

布局方面,最核心的原则是合并访问(coalescing)。同一个 warp 里的线程如果访问连续地址,一次事务就能取回全部数据;如果地址跨越大,事务数翻几倍,有效带宽直接打对折。实践中要注意的坑包括:转置操作一定要用共享内存做中转,别让线程跨步读全局内存;结构体数组和数组结构体的选择要匹配访问模式;对齐到 128 字节边界能让向量化加载生效,这个细节能带来 10% 以上的差别。

还有一个常被忽略的点是 bank 冲突。共享内存分成多个 bank,如果同一 warp 内的线程访问到同一个 bank 的不同地址,就会串行化。在矩阵乘里给行步长加个 padding(比如 32 加 1),是很经典的规避手法,改动一行代码,性能提升可能就到两成。

5.3 量化:省的是带宽,也可能是新的瓶颈

把 FP16 换成 INT8,权重和激活的字节数直接减半,理论上带宽受限的算子能快接近一倍。这个收益在 decode 阶段尤其明显,因为那儿的瓶颈就是纯搬运。业界常见的做法是权重-only 量化到 4bit 或 8bit,激活保持 FP16,权重的读取量能降到四分之一到二分之一。

不过量化不是免费的午餐,有两个陷阱需要注意。第一,如果算子本身是计算受限的,量化带来的精度损失换不来速度提升,因为瓶颈不在带宽。第二,反量化操作本身要读要算,如果在 kernel 内部做逐元素反量化而没有向量化,反而可能增加指令开销。我的判断标准是:先算这个算子的 AI,低于拐点的一半才值得考虑量化,高于拐点的优先做别的优化。

混合精度训练还有个额外好处:FP16 的前向和反向计算本身就能把激活的带宽需求减半,同时张量核心的吞吐更高。代价是数值稳定性,需要梯度缩放,这部分如果没调好,训练会发散,排查起来很痛苦。经验做法是用动态缩放,初始缩放因子给大一点(比如 2¹⁶),让它自己往下掉。

5.4 重叠与预取:让搬运和计算同时发生

带宽优化的终极形态不是少搬,而是"搬的时候同时在算"。硬件层面有多条通路可以做到这一点:异步拷贝指令让数据在从全局内存到共享内存的过程中,计算单元处理上一块数据;多流并行让不同 kernel 的访存和计算互相填补空隙;计算与通信重叠让分布式训练中的梯度传输不占用纯等待时间。

实际写 kernel 的时候,双缓冲(double buffering)是最常用的手法:开两块共享内存,一块在算,另一块在加载,循环往复。要点是加载指令要尽早发出,让访存延迟被计算掩盖住。如果加载放在循环末尾,计算单元就要空转等数据,重叠等于没做。

在框架层面,能做的事包括把数据预处理放到独立的流里并行、把 embedding 查表提前、把通信算子插入到反向计算图中间而不是等所有梯度算完再通信。这些改动的共同点是提高资源重叠度,不减少总流量,但能把墙上的时间压下来。我见过的最夸张的一个案例,纯粹通过调整通信和计算的重叠顺序,端到端训练速度提升了 30%,一行业务逻辑都没改。

6. 排查速查与踩过的坑

6.1 现象与成因对照表

性能问题最怕的是方向判断错,花了三天优化计算,结果瓶颈在带宽。下面这张表是我自己总结的对照关系,遇到现象先查表,能省下不少试错时间。

现象最可能的原因处置方向
带宽吞吐超过峰值 80%,SM 占用率低带宽受限融合算子、量化、减少中间张量
带宽和算力都不高,耗时长延迟未隐藏、并行度不足增大 block 数、异步拷贝、双缓冲
计算单元利用率高但整体仍慢计算受限或指令开销大换更快指令、减少分支、用张量核心
小 batch 时延迟高,大 batch 相对正常GEMV 化,AI 掉到 1 附近KV cache 量化、增大 batch、算子融合
单 kernel 都快,端到端慢kernel launch 开销、串行依赖图融合、CUDA Graph、减少同步点
训练时显存吃紧且速度慢优化器状态、激活未重计算梯度检查点、分片优化器状态
理论带宽和实测差 30% 以上非合并访问、bank 冲突、对齐问题检查访存模式、加 padding、向量化

6.2 几条踩过的坑

第一条,别拿理论峰值当基准。前面说过有效带宽通常只有标称的八成多,我有一个项目在优化时一直对不上账,最后发现是拿标称值算的上限,把所有系数重新标定之后,模型预测和实测的误差从 40% 降到了 5% 以内。做性能建模,标定这一步不能省。

第二条,L2 命中率是被严重低估的变量。同一个小模型,权重能装进 L2 的时候跑得飞快,权重稍微大一点立刻断崖式下跌。评估的时候一定要用真实模型规模,别用小 demo 的数据下结论。判断方法很简单:把模型规模放大一倍再看延迟曲线,如果延迟跳变而不是线性增长,说明越过了缓存容量边界。

第三条,逐元素算子的融合收益经常超过预期。很多人觉得这些算子本身 FLOPs 少、不重要,实际上它们占用的带宽比例很高。我在一个视觉模型上做过统计,所有逐元素和归一化算子加起来占了 35% 的显存流量,融合之后端到端提速 22%,比优化主干的卷积收益还大。

第四条,量化之后一定要重新测,不要用 FP16 的结论外推。INT8 的算力通常是 FP16 的两到四倍,拐点位置跟着移动,原来计算受限的算子可能变成带宽受限,优化优先级要重排。而且低比特的反量化如果没做好向量化,实际性能可能不升反降,这种案例我见过不止一次。

第五条,序列长度和 batch 的组织方式对带宽的影响比想象中大。padding 到固定长度会带来成比例的无用流量,变长序列如果按最长样本补齐,浪费可能高达数倍。能按 bucket 分组、能动态打包,就别图省事用统一长度。这个改动通常在数据层,不改模型结构,性价比很高。

第六条,工具给出的"带宽利用率"要看清楚口径。有的指标是相对峰值,有的是相对可持续峰值,两者的分母能差出 15%。看指标的时候顺手确认一下定义,别在同一张图上混用不同口径的数字,不然得出的结论会自相矛盾。

最后分享一个我一直在用的小方法:给每个关键算子建一张卡片,记下它的形状、FLOPs、理论最小访存量、算出来的 AI、实测耗时、实测有效带宽占峰值比例。模型一改,卡片重算一遍,瓶颈在哪一目了然。这个习惯坚持下来,调优就不再是靠猜,而是每次都能对着数字做决策。