算力还是带宽?用算术强度定位模型性能瓶颈
做模型性能调优这些年,被问得最多的一类问题是:"这块卡标称算力这么高,为什么我的模型跑上去只有个位数利用率?"问的人往往已经试过换框架、调线程数、加批大小,效果都不明显。问题多半不在代码写得对不对,而在于根本没搞清楚自己的模型到底在等计算还是在等数据搬运。深度神经网络里的计算和内存带宽,是两条完全不同的天花板,撞哪一条,优化手段天差地别。撞计算墙,你要做的是减少冗余运算、换更低精度的乘加;撞带宽墙,你要做的是提高数据复用率、把中间结果留在片上、把权重压得更小。方向搞反,越努力越离谱。
这篇内容想做的事很具体:把"算力"和"带宽"这两个经常被混为一谈的概念拆开,用可复算的数字告诉你一个算子、一层网络、一次解码到底受限于谁;再给出几条能直接上手跑的估算和实测路径。适合正在做推理服务、训练加速、端侧部署的工程师,也适合刚接触性能分析、想建立一套判断框架的同学。不需要你懂芯片微架构,但需要你愿意动笔算几个乘法。
1. 为什么标称算力和实际速度总是对不上
先把一个常见的误解摆正:峰值算力指的是在理想条件下,计算单元满负荷运转时每秒能完成的浮点运算次数。这个数字成立的前提是数据源源不断地送到计算单元嘴边,中间不发生任何停顿。现实里,数据从显存搬到片上要走很长一段路,这段路的通行能力就是内存带宽。计算单元再快,喂不饱就只能干等。所以任何一段程序的真实耗时,取的是"计算时间"和"搬运时间"里更长的那个,而不是简单相加,更不是只看计算那一项。
1.1 一次矩阵乘的耗时到底花在哪
拿最基础的矩阵乘举例。两个矩阵 A(M×K)和 B(K×N)相乘得到 C(M×N),浮点运算量是 2×M×N×K,因为每个输出元素要做 K 次乘法和 K 次加法。访存量则取决于数据精度和分块策略,最朴素的情况下要把 A、B 各读一遍、C 写一遍。用半精度存储,字节数大约是 2×(M×K + K×N + M×N)。
把这两个数一除,得到的就是算术强度,单位是 FLOPs/Byte,意思是"每搬运一个字节的数据,能顺带完成多少次浮点运算"。这个比值是整个性能分析的支点。M=N=K=4096 时,分子是 2×4096³ ≈ 1.37×10¹¹,分母约 1.0×10⁸ 字节,算术强度约 1365 FLOPs/Byte。而一次矩阵向量乘(M=1,N=K=4096),运算量只有 3.3×10⁷,访存量却有 3.3×10⁷ 字节,算术强度掉到 1 FLOPs/Byte 左右。两者差了三个数量级,硬件对它们的态度自然完全不同。
提示:算算术强度时一定要把读写都算进去,很多人只算读取、漏掉输出写回,估出来的数值会偏高,误判成计算密集。
1.2 屋顶线模型:一条折线分出两类算子
把横轴设为算术强度、纵轴设为可达性能,硬件的性能上界就是一条折线:左侧斜线受带宽限制,性能等于带宽乘以算术强度;右侧水平线受算力限制,性能封顶在峰值算力。两条线的交点对应的算术强度,叫平衡点,等于峰值算力除以峰值带宽。
假设某代数据中心加速卡的半精度峰值算力是 300 TFLOPS,显存带宽 2 TB/s,平衡点就是 150 FLOPs/Byte。前面那个 4096 的三维矩阵乘,算术强度 1365,远在平衡点右侧,属于计算密集,优化方向是提升计算单元利用率、减少流水线气泡;而矩阵向量乘只有 1,深深落在左侧,属于访存密集,再怎么优化计算指令也没用,唯一有效的动作是减少搬运量或者提高复用。
这个模型的价值在于它给了你一个查表式的判断方法:拿到一个算子,先估算术强度,再和硬件平衡点比一比,立刻知道该往哪个方向使劲。我见过太多团队在访存密集的算子上死磕指令调度,折腾两周收益不到 3%,就是这个道理。
1.3 常见算子的算术强度速查
下面这张表是我平时随手记的估算值,按半精度、无特殊分块优化的朴素实现计算,实际工程里因为分块和缓存命中会高一些,但量级关系不变。
| 算子类型 | 典型形状假设 | 算术强度(FLOPs/Byte) | 瓶颈归属 |
|---|---|---|---|
| 大矩阵乘 | 4096×4096×4096 | 约 680 | 计算密集 |
| 中等矩阵乘 | 512×512×512 | 约 85 | 临界,视硬件而定 |
| 小矩阵乘 | 64×64×64 | 约 10 | 访存密集 |
| 矩阵向量乘 | 1×4096×4096 | 约 1 | 严重访存密集 |
| 逐元素加法 | 任意 | 约 0.17 | 纯带宽受限 |
| 归一化层 | 任意 | 约 0.2 到 0.5 | 纯带宽受限 |
| 卷积(大通道) | 256 通道 3×3 | 数十到上百 | 视实现而定 |
这张表能解释一个很反直觉的现象:网络里 FLOPs 占比最高的往往是卷积和矩阵乘,但实际耗时占比最高的经常是那些 FLOPs 少得可怜的归一化、激活、残差相加。因为它们的算术强度接近零,每处理一个元素都要完整读一遍写一遍,纯粹在消耗带宽。一层 LayerNorm 的运算量可能只有相邻矩阵乘的千分之一,耗时却能占到百分之十以上。
2. 内存金字塔:带宽从来不是一个孤立的数字
多数人看带宽只看显存那一栏,这远远不够。真实的数据通路是一条从大容量外存到极小容量寄存器的阶梯,每一层的容量、带宽、延迟都差着数量级。理解这条链路,才能明白为什么"显存带宽还有富余"和"计算单元在挨饿"可以同时成立。
2.1 从显存到寄存器的搬运成本
典型加速卡的内存层级大致是这样:最外层是几十 GB 的显存,带宽在 TB/s 量级,延迟几百个时钟周期;往内是容量几 MB 到几十 MB 的片上缓存,带宽高一个数量级,延迟低一个数量级;再往内是每个计算单元私有的共享内存或暂存区,容量几十到几百 KB,带宽再高一个台阶;最内层是寄存器堆,容量最小,但访问几乎无延迟。
关键在于,数据每上升一层,都要显式或隐式地搬运一次。一次矩阵乘如果完全不做分块,每个输出元素都要从显存重新读一遍输入,搬运量会被放大几十倍。做了分块之后,一块数据被搬进共享内存,在片上被多个输出元素反复使用,显存访问次数才降下来。所以同一个算法,写得好和写得差,显存带宽消耗能差一个数量级,这也是各种高性能算子库存在的根本理由。
2.2 数据复用的三个维度
提高复用率有三个可以下手的方向,我习惯按这个顺序排查。空间复用指同一份数据被多个并行计算单元同时使用,比如卷积里一个输入像素参与多个输出通道的计算,把它放在共享内存里让整个线程块共享,就是空间复用。时间复用指同一份数据在相邻的计算步骤里被反复使用,典型的是矩阵乘的分块循环,外层循环把一块 A 固定在片上,内层遍历所有 B 的分块。跨算子复用则更宏观,指上一个算子的输出直接留在片上给下一个算子用,不写回显存,这就是算子融合的理论基础。
三个维度的收益递减但难度递增。空间和时间复用主要靠算子内部的循环重排,属于算子库该做的事;跨算子复用需要编译器或手写融合,属于框架层的工作。实际调优时,先看算子内部有没有做好分块,再看图层面有没有可以合并的连续小算子,顺序别搞反。
2.3 延迟隐藏靠的是并行度,不是带宽
还有一个经常被忽略的点:带宽是吞吐指标,延迟是响应指标,两者不是一回事。显存延迟高达几百周期,单个计算流串行执行时,每次访存都要干等。硬件靠切换大量并发线程来掩盖这个等待,这就是常说的延迟隐藏。并发度不够,即使带宽有富余,实际有效带宽也上不去。
这解释了一个实操中的常见困惑:把小模型的批大小设为 1,测出来的带宽利用率往往只有峰值的两三成。不是带宽不够,是并发访存的请求太少,延迟没被掩盖住。把批大小提上去、或者把单个算子的并行粒度做细,利用率立刻就上来了。当然,批大小提上去之后算术强度也会变化,这两件事要一起看。
注意:带宽利用率低不等于带宽不足。先确认并发度是否足够,再判断是不是真的撞了带宽墙,否则容易误判方向。
3. 训练侧的账本:计算密集背后藏着访存暗流
训练常被当成典型的计算密集场景,这个判断在大矩阵乘为主的整体视角下成立,但拆到每个环节就不一定了。一个完整的训练步包含前向、反向、参数更新三段,三段的访存特征差别很大,优化手段也不能一概而论。
3.1 前向、反向与参数更新的访存差异
前向计算以大矩阵乘和卷积为主,算术强度高,通常能吃满计算单元,属于相对健康的部分。反向传播要同时算三样东西:对输入的梯度、对权重的梯度、以及链式法则所需的中间量。对输入梯度的计算形状和前向的矩阵乘类似,仍然是计算密集;但对权重梯度的计算,形状往往是"批维度 × 序列维度"作为收缩维,收缩维很长时还好,收缩维短的时候算术强度会明显下滑。小批量训练时这个问题尤其突出,梯度计算会从计算密集滑向访存密集。
参数更新环节是最容易被低估的。以常见的混合精度加自适应优化器为例,每个参数在一步更新里要涉及半精度权重、半精度梯度、单精度主权重、一阶动量、二阶动量,粗略估算每参数每步的访存需求在 16 字节上下。一个十亿参数量的模型,光参数更新就要搬运十几 GB,如果带宽是 2 TB/s,理论上限也就几毫秒,而实际的矩阵乘部分可能只需要一两毫秒。这就是为什么小模型、小批量的训练常常是优化器在拖后腿。
3.2 混合精度与激活重计算的取舍
混合精度训练省的不只是计算量,更重要的是访存量。权重和激活从 4 字节压到 2 字节,所有涉及这些张量的读写带宽需求直接减半。对于那些本来就在带宽墙上挣扎的环节,这个收益是立竿见影的,通常比计算单元的提速更明显。
激活重计算是另一个经典取舍。前向时只保留部分中间激活,反向时重新算一遍。代价是多花一次前向计算,收益是省下大量激活存储和相应的读写带宽。判断要不要用,可以算一笔账:如果被省下的激活读写时间大于重算的计算时间,就划算。在计算密集的大模型训练里,这笔账通常是划算的,因为重算的部分算术强度高,而激活的读写是纯带宽消耗。反过来,在小模型或者计算资源本来就紧张的场景里,重算可能得不偿失。
3.3 并行切分对带宽的二次影响
数据并行、张量并行、流水并行这些策略,很多人只关注它们对显存占用的影响,忽略了它们会改变通信量和访存模式。数据并行需要在每步结束时同步梯度,通信量和参数量成正比;张量并行把单层切开,层内的矩阵乘形状变小,算术强度随之下降,原本计算密集的算子可能变成访存密集,同时还多出层内的通信开销。
我在实际项目里遇到过一个典型情况:单卡跑得好好的模型,切到八卡张量并行之后单卡吞吐不升反降。排查下来是两个原因叠加,一是切开后单个矩阵乘的 K 维度变短,算术强度掉到平衡点以下;二是切分引入了额外的中间结果写回和跨卡同步。后来改成更粗粒度的切分、把通信和计算重叠起来,才把效率拉回来。
4. 推理侧的现实:解码阶段就是一场带宽搬运
如果说训练还有计算密集的体面,那自回归推理的解码阶段就是把带宽瓶颈写在脸上。这一段值得单独展开,因为它是目前绝大多数推理服务成本的主要来源,也是优化收益最集中的地方。
4.1 逐 token 解码的带宽下界推导
自回归生成每一步只产出一个 token,但为了产出这一个 token,必须把模型全部权重读一遍,做一次完整的前向。注意这里的关键:每一步的计算量是固定的,和序列长度基本无关(注意力部分除外),但权重读取量也是固定的。于是算术强度约等于每参数 2 次浮点运算除以每参数字节数。
用半精度算,每参数 2 字节,算术强度就是 1 FLOPs/Byte。对比前面说的 150 FLOPs/Byte 平衡点,差了整整两个数量级。这意味着解码阶段的理论耗时几乎完全由带宽决定:模型权重总字节数除以有效带宽。
拿一个 70B 参数量的模型举例,半精度权重约 140 GB,显存带宽按 2 TB/s 算,单步理论下界是 70 毫秒,对应每秒 14 个 token 左右。实际因为带宽利用率通常在六到八成,能跑到每秒 9 到 11 个 token 就算不错。这个数字和模型结构、序列长度关系不大,只和参数量、精度、带宽三者相关,非常硬。想提速,只有三条路:减小权重字节数、提高有效带宽、或者让一次搬运服务更多请求。
4.2 KV 缓存与批大小的平衡点
一次搬运服务更多请求,就是批处理。批大小提上去之后,权重读取量不变但被多个请求摊薄,单请求的平均权重带宽成本下降,吞吐明显提升。但天下没有免费的午餐,每个请求还要维护自己的键值缓存,这部分读写量随批大小线性增长。
估算一下键值缓存的大小:假设模型 32 层、32 个注意力头、每头维度 128、半精度存储,每个 token 每层的键值缓存是 2×32×128×2 字节,约 16 KB;32 层合计约 512 KB 每 token。序列长度 2048 时,单个请求约 1 GB;批大小 8 就是 8 GB。
解码单步的访存总量约等于权重字节数加上批大小乘以键值缓存字节数。批大小从 1 涨到 8,总量从约 27 GB 涨到约 34 GB,吞吐却能提升近 8 倍,边际收益非常高。继续涨到 32,键值缓存涨到 32 GB,总量约 58 GB,吞吐相比批大小 8 只提升约 2.4 倍,收益开始衰减。找到这个拐点,就是推理服务调优的核心工作之一。
| 批大小 | 权重读取 | 键值缓存读取 | 单步总访存 | 相对吞吐 |
|---|---|---|---|---|
| 1 | 26 GB | 1 GB | 27 GB | 1.0 |
| 8 | 26 GB | 8 GB | 34 GB | 7.5 |
| 16 | 26 GB | 16 GB | 42 GB | 10.7 |
| 32 | 26 GB | 32 GB | 58 GB | 14.3 |
| 64 | 26 GB | 64 GB | 90 GB | 18.5 |
(以 13B 参数量、半精度、序列 2048 为例的粗略估算,仅用于说明趋势)
4.3 量化、融合与推测解码的收益边界
沿着"减小权重字节数"这条线,量化是最直接的手段。权重从半精度压到 8 位整数,字节数减半,理论上解码速度翻倍,实测通常能拿到 1.6 到 1.9 倍,损耗来自反量化开销和精度补偿。压到 4 位,理论上再翻一倍,但实测收益会明显打折,因为反量化的计算开始变得不可忽略,而且精度损失需要更精细的补偿策略。我的经验是,8 位量化在绝大多数场景下是稳妥的选择,4 位要看具体的模型和任务容差,别指望它无脑翻四倍。
算子融合在解码阶段的价值也很高。前面提到归一化、残差、激活这些算子算术强度接近零,解码阶段本来就在带宽上吃紧,这些算子的开销占比会被放大。把连续的小算子合并成一个内核,中间结果留在片上不落显存,能省下可观的带宽。这条路的收益通常在百分之十到二十的量级,稳定但不算惊人。
还有一种思路是让一次前向产出多个 token,比如用小模型先草拟几个候选、再用大模型批量校验。校验阶段一次前向能处理多个位置,权重的读取被摊薄,算术强度上升。这条路在候选接受率高的场景下收益可观,接受率低的时候反而亏。实际部署前一定要在自己的业务数据上量一下接受率,别直接拿论文数字套。
5. 动手实测:三步定位你的模型到底卡在哪
纸上算完还得落地。下面这套流程是我自己常用的排查顺序,从粗到细,每一步都能独立得出结论,不需要一上来就上重型工具。
5.1 第一步:算理论下界,判断有没有优化空间
先做纯纸面计算,不碰代码。把模型参数量、精度、目标硬件的带宽和算力三个数字拿到手,算出两类理论下界:带宽受限耗时等于访存字节数除以带宽,计算受限耗时等于运算量除以算力。取两者较大值作为理论最优,再拿实测耗时去除,得到的就是效率。效率低于五成,说明优化空间大;效率到八成以上,基本就是工程极限,别硬磕了。
下面这段伪代码可以直接改成脚本用,输入参数量和序列长度,输出解码阶段理论下界。
# 伪代码:估算解码阶段的理论带宽下界 def decode_bound(params_billion, bytes_per_param, layers, heads, head_dim, seq_len, batch, bandwidth_gbps): weight_bytes = params_billion * 1e9 * bytes_per_param kv_per_token = 2 * layers * heads * head_dim * bytes_per_param kv_bytes = kv_per_token * seq_len * batch total_bytes = weight_bytes + kv_bytes seconds = total_bytes / (bandwidth_gbps * 1e9) return seconds, 1.0 / seconds # 单步耗时、每秒 token 数 # 示例:13B 模型,半精度,32 层 32 头 头维 128,序列 2048 t, tps = decode_bound(13, 2, 32, 32, 128, 2048, 1, 2000) print(f"单步下界 {t*1000:.1f} ms, 吞吐上限 {tps:.1f} token/s")跑出来的结果会告诉你一个残酷的事实:很多模型在单请求下的吞吐上限本来就不高,你以为是实现问题,其实是物理问题。先接受这个下界,再谈优化。
5.2 第二步:用剖析工具看真实吞吐与利用率
有了下界,接下来看实测。性能剖析工具里我最关注三个指标:实际达成的显存读写吞吐、计算单元的活跃周期占比、以及每个算子的耗时排名。
判断方法很直接。如果某个算子耗时靠前,同时它的显存吞吐已经接近硬件峰值,那它就是带宽受限,优化方向是减少字节数或者提高复用;如果显存吞吐只有峰值的三四成,计算单元也不忙,那多半是延迟没掩盖住,去看并发度和访存模式;如果计算单元接近满载而显存吞吐不高,那才是真正的计算受限,可以考虑降精度或者换算法。
这里有个容易踩的坑:工具报的"显存吞吐"是硬件计数器统计的,包含了缓存命中的部分,不等于真实打到显存上的流量。判断带宽压力要看真实的外存读写量,别只盯着一个百分比数字。
5.3 第三步:逐项改造与前后对照
改造要一次只动一个变量,否则出了效果也不知道是谁的功劳。我通常按这个优先级排:先做算子融合,把能合并的小算子合掉,这一步风险低收益稳定;再做量化或者降精度,收益大但要做精度回归;最后调批大小和并发策略,这一步最依赖具体业务场景。
每次改完记录三个数字:吞吐、单步耗时、显存峰值。做个简单的对照表,两周下来你对自己的模型会有非常具体的认识,比看任何文档都管用。
| 改造项 | 吞吐(token/s) | 单步耗时(ms) | 显存峰值(GB) | 备注 |
|---|---|---|---|---|
| 基线 | 9.2 | 108 | 41 | 单请求,半精度 |
| 融合归一化与残差 | 10.4 | 96 | 40 | 收益约 13% |
| 权重 8 位量化 | 17.1 | 58 | 24 | 需做精度回归 |
| 批大小调到 8 | 62.5 | 128 | 39 | 单步变长但总吞吐涨 |
| 批大小调到 16 | 88.3 | 181 | 47 | 接近键值缓存拐点 |
这张表是我在一个中等规模模型上记录的真实趋势,具体数字因硬件和实现而异,但拐点的位置和收益的量级有参考价值。可以看到量化带来的提升最直接,批大小带来的提升最可观但受显存容量约束。
6. 排查速查表与几个容易误判的场景
最后把常见的症状和原因整理成一张表,遇到问题时先对号入座,能省下不少瞎试的时间。
6.1 典型症状对照表
| 症状 | 高概率原因 | 优先动作 |
|---|---|---|
| 计算单元利用率低,显存吞吐也低 | 并发度不足,延迟未掩盖 | 提高批大小或并行粒度 |
| 显存吞吐接近峰值,计算单元空闲 | 算子访存密集 | 算子融合、降精度、提高复用 |
| 计算单元接近满载,显存吞吐不高 | 计算受限 | 换更快算法、降精度、减少冗余计算 |
| 单步耗时随序列长度线性上涨 | 键值缓存读写主导 | 分页管理缓存、压缩缓存精度 |
| 批大小翻倍吞吐不涨 | 已过键值缓存拐点或显存带宽打满 | 停在这里,改做量化 |
| 小批量训练梯度计算慢 | 收缩维过短,算术强度低 | 增大批大小或做梯度累积 |
| 参数更新占总耗时比例异常高 | 优化器状态访存量大 | 检查是否用了省内存的优化器实现 |
6.2 几个容易误判的场景
第一个误判是把"显存不够"当成"带宽不够"。这两件事完全不同,显存不够是容量问题,加卡、切分、卸载都能解;带宽不够是速度问题,只能靠减少搬运。我见过有人显存报警之后拼命加卡,结果单卡负载下来了但总吞吐没变,因为瓶颈根本不在容量。
第二个误判是拿 FLOPs 占比当耗时占比。前面反复说过,算术强度低的算子 FLOPs 少但耗时不低。做优化排期时如果按 FLOPs 排序,会漏掉真正的大头。正确的做法是按剖析工具里的实际耗时排序,再看每个算子的算术强度决定手段。
第三个误判是认为提高批大小永远有利。在计算密集的训练场景里这个判断大致成立,但在解码推理里,键值缓存会随批大小线性增长,过了拐点之后总吞吐增速明显放缓,而单请求延迟却在变差。线上服务要同时看吞吐和延迟两个指标,不能只盯一个。
提示:任何优化动作之前,先把理论下界算出来贴在墙上。所有实测数字都对着这个下界看,能避免绝大多数方向性错误。
我个人在实际项目里最深的一点体会是,性能优化这件事百分之八十的价值在"判断"而不是"动手"。判断对了,改动往往只有几行;判断错了,重构一周也白搭。而判断的依据,就是那几个简单到可以手算的数字:运算量、访存量、带宽、算力,以及它们相除得到的算术强度。这套东西不需要任何工具,一张纸一支笔就能推。真正难的不是算,是养成"先算再改"的习惯。
另外一个私房技巧:遇到拿不准的算子,直接把它单独拎出来做微基准测试,构造几组不同形状的输入,测出耗时随形状变化的曲线。如果耗时基本不随计算量变化、却随数据量线性变化,那它一定是带宽受限,不用再猜。这个方法笨,但极准,我在好几个项目里靠它一锤定音。