资讯动态

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

发布时间:2026/10/27 5:50:23 来源:尧图企业网站定制
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]张量在 B32、S2048、H4096 时是 512MB 量级来回几趟就是好几个 GB 的流量。第二类是布局转换。NCHW 和 NHWC 之间的转换、转置、padding 补齐这些操作本身 FLOPs 接近零但要把整个张量读一遍写一遍AI 低到可以忽略纯纯的带宽消耗。第三类是训练特有的状态量。Adam 优化器要为每个参数维护一阶矩和二阶矩加上 FP32 的主权重副本一个 FP16 参数实际对应的显存读写量是权重的 6 倍以上。很多人算训练带宽预算时只按参数量乘 2 字节估实际跑起来差好几倍就是这个原因。注意显存容量和显存带宽是两件事。容量不够会 OOM带宽不够只会变慢而变慢往往不被当成故障只是被当成这卡就这样白白浪费了优化空间。2. 手算 FLOPs 与访存量一套能复现的推法2.1 矩阵乘的算术强度怎么一步步推出来拿最基础的 GEMM 练手A 是 M×KB 是 K×NC 是 M×N数据类型 FP16。FLOPs 的计算是把乘加算作两次运算所以是 2×M×N×K。访存量按最朴素的口径读 A、读 B、写 C每个元素 2 字节就是 2×(M×K K×N M×N)。两者一除就得到算术强度。取 MNK4096 代进去FLOPs 是 2×4096³ ≈ 1.37×10¹¹访存量是 2×3×4096² ≈ 1.0×10⁸ 字节AI 大约是 1360 FLOP/Byte。这个数远高于 150 的拐点说明大矩阵乘是标准的计算受限算子理论上能跑到峰值算力的八九成。把 K 换成 1也就是矩阵乘退化成矩阵向量乘GEMV情况立刻反转。MN4096、K1 时FLOPs 只有 3.3×10⁷主要流量是那个 4096×4096 的权重矩阵约 33.6MBAI 掉到 1 FLOP/Byte 左右。对比拐点 150意味着算力利用率上限只有 1/150不到 1%。这就是为什么大模型 decode 阶段明明每秒只生成几十个 tokenGPU 却已经跑满了带宽——每一层的前馈网络在 batch 很小时都退化成了 GEMV。这个推导过程建议自己写一遍脚本把参数留成变量改一改就能算别的形状。下面这段可以直接抄def gemm_intensity(M, N, K, bytes_per_elem2): 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(fM,N,K{shape} - AI{ai:.1f} FLOP/Byte, FLOPs{f:.3e}, Bytes{t:.3e})跑出来会看到M1 或 K1 的形状 AI 都在 1 附近这就是 batch 小的时候性能塌方的数学原因跟实现好坏关系不大。2.2 roofline 拐点给硬件画一条线roofline 模型的做法是把横轴设为算术强度对数刻度纵轴设为可达性能画出两条线一条水平线是峰值算力一条斜线是带宽乘以算术强度。两条线的交点就是拐点左边斜线区域是带宽受限区右边水平区域是计算受限区。这个图的价值在于它告诉你某个算子在当前硬件上的性能天花板在哪。如果一个算子的 AI 算出来是 5拐点是 150那它的性能上限就是峰值算力的 3.3%你花再多力气优化指令调度也没用唯一有效的手段是把 AI 提上去——要么增加复用分块、融合要么减少字节量化、稀疏。实际画图的时候有个细节要注意横轴应该用实际产生的显存流量而不是理论最小流量。比如一个卷积如果实现里做了 im2col输入张量被复制了 9 倍那实际 AI 就要除以 9。很多人画出来的点和实测对不上问题都出在这里。硬件档位标称带宽量级FP16 张量算力量级roofline 拐点数据中心加速卡HBM2e/HBM31.5 ~ 3 TB/s300 ~ 1000 TFLOPS150 ~ 350 FLOP/Byte消费级加速卡GDDR6X0.8 ~ 1 TB/s80 ~ 170 TFLOPS100 ~ 200 FLOP/Byte移动端 SoCLPDDR5X50 ~ 70 GB/s20 ~ 40 TOPSINT8300 ~ 600 OPS/Byte2.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带宽GEMM4096³2K摊薄后极低1000计算GEMVK12约等于权重字节约 1带宽3×3 卷积无 im2col18摊薄后低数十视尺寸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 只有一个 tokenkey 和 value 是历史上所有 token 的缓存每一步都要把整个 KV cache 读一遍。按序列长度 4096、单头维度 128 估算每层每步的计算量约 4×4096×128 ≈ 2.1 MFLOPs而 KV cache 的读取量是 2×4096×128×2 字节 ≈ 2.1 MBAI 又回到 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、实测耗时、实测有效带宽占峰值比例。模型一改卡片重算一遍瓶颈在哪一目了然。这个习惯坚持下来调优就不再是靠猜而是每次都能对着数字做决策。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑