前阵子把 DeepMind 那份大模型推理指南翻出来重读,里面有句话让我印象特别深:大模型推理的瓶颈从来不在算力,而在显存带宽。第一次看到这个结论时我其实是有点抗拒的,毕竟大家买卡看的第一指标永远是算力,谁会去关心 HBM 的带宽数字?但后来自己动手压测了几轮,从数据中心卡一路试到 Jetson AGX Orin 这类边缘设备,才彻底认同了 DeepMind 的判断。这篇文章我就想把自己对这个结论的理解、验证过程,以及围绕带宽瓶颈做推理优化时踩过的坑和总结出来的方法,完整分享出来。
这篇内容适合正在做大模型推理部署、做性能调优的工程师,也适合那些刚接触 llama.cpp 或 vLLM、想搞清楚“为什么 token/s 上不去”的开发者。我会先讲清楚“为什么是带宽”这个核心问题,再拆解 KV Cache、量化、批处理、投机采样这些工程手段背后的带宽逻辑,最后用一套 Jetson AGX Orin 上的 llama.cpp 实战记录,把理论和落地串起来。
1. 一个反直觉的判断:算力很富余,带宽才是卡点
1.1 DeepMind 推理指南的核心结论到底说了什么
DeepMind 那份指南的核心观点,简单说就是:在自回归解码阶段,模型的每一步生成都受限于“从显存里读取参数”的速度,而不是“计算这些参数”的速度。这里有个非常反直觉的点,大多数人会默认 GPU 是算得慢才导致推理慢,但实际上在大模型 decode 这个场景里,GPU 的计算单元大部分时间都在空转,真正拖后腿的是 HBM 显存把权重数据搬运到计算核心的那条路。
我用一个 7B 模型来举例,FP16 精度下权重文件大概是 14GB。每生成一个 token,理论上都需要把这 14GB 权重全部从 HBM 读一遍。在 H100 SXM 上,显存带宽是 3.35TB/s,纯访存时间算下来大概是 4.2 毫秒。但一个 token 的前向计算量才多少?大概 14 GFLOPs,H100 的 FP16 稠密算力是 989 TFLOPs,算完它只需要 0.014 毫秒。两下一比,算力利用率连 0.5% 都不到,这还不是瓶颈是什么?
我一开始也怀疑这个算法是不是太理想化了,毕竟真实推理还有算子开销和 kernel launch 延迟。但后来用 ncu 抓过一轮 profiling,结果显示 decode 阶段的 SM 利用率确实低得可怜,而 HBM 读带宽几乎被吃满。那一刻我才真正接受了 DeepMind 的判断:模型越大,参数越多,权重搬家花的带宽成本就越离谱,算力反而成了最不缺的资源。
1.2 为什么多数人在算力误区里打转
这个问题其实不难解释,大家选卡时习惯看规格表上的 TFLOPs,这数字又大又直观,谁都会觉得算力高就代表推理快。但 TFLOPs 衡量的是“计算核心的理论极限”,而实际推理性能往往由“数据能不能及时送到计算核心”决定。放到生活里类比一下,算力就像是餐厅里刀工极好的厨师,带宽则是后厨到前厅的传菜员。如果传菜员一次只能端一盘菜,那厨师再快也没用,顾客感受到的上菜速度完全由传菜速度决定。
还有个原因是大模型的“计算量幻觉”。模型参数量动辄几十亿上百亿,大家自然觉得每次前向都要做海量计算。但 decode 阶段 batch size 通常很小,实际计算量远没有想象中那么大。反而是权重的体积摆在那里,7B 模型的 FP16 权重就有 14GB,70B 模型直接 140GB,光把这些数据搬一遍就是一笔巨大的访存开销。所以只要模型权重没有被完整缓存在片上 SRAM 里,带宽瓶颈就一定会出现。
1.3 一次压测给我的直观冲击
理论讲多了容易飘,我还是习惯用数据说话。之前我在同一台机器上对比过两张卡跑同一个 7B Q4 模型,一张是 RTX 4090,一张是 A100 80GB。4090 的算力大约是 82.6 TFLOPs,A100 是 312 TFLOPs,如果按算力论,A100 应该快接近四倍。但实际跑下来,4090 大概 90 token/s,A100 反而只有 60 token/s 上下。原因就在于 4090 虽然算力低,但 GDDR6X 带宽 1.01TB/s;A100 虽然算力高,但 HBM2e 带宽只有 2TB/s,同时它要加载的 KV Cache 和上下文更长,实际访存开销更大。
这张表我后面还会反复引用,先把几个典型平台的带宽和对应 7B Q4 模型的理论上限列出来:
| 平台 | 显存/内存带宽 | 7B Q4 权重体积 | 单 token 理论生成上限 |
|---|---|---|---|
| H100 SXM | 3.35 TB/s | 约 4.2 GB | 约 790 token/s |
| A100 80GB | 2.00 TB/s | 约 4.2 GB | 约 470 token/s |
| RTX 4090 | 1.01 TB/s | 约 4.2 GB | 约 240 token/s |
| Jetson AGX Orin 64GB | 204.8 GB/s | 约 4.2 GB | 约 48 token/s |
注意这只是权重搬运的理论上限,真实场景还要算上 KV Cache 读取、算子开销和访存效率损耗,实测通常只能达到理论值的 50% 到 70%。但这个数量级已经足够说明问题:带宽低一个数量级,token/s 就低一个数量级,跟算力关系不大。
2. 显存带宽为何成了大模型推理的命门
2.1 自回归解码的每一步都要搬一遍全部权重
要理解带宽瓶颈,先得搞清楚自回归解码的机制。大模型生成文本是一个 token 一个 token 来的,每生成一个新 token,都要把完整的权重矩阵从头到尾做一次前向计算。这意味着无论 batch size 是多少,只要模型权重没有变,每一轮 decode 就必须把全部权重从显存搬到计算单元。这不是优化能绕开的问题,除非把权重完全放进片上缓存,但以现在的 SRAM 容量,哪怕 7B 模型也是天方夜谭。
我做视觉分析时喜欢用一个词叫“全局数据重用率低”。大模型推理恰恰就是这种模式:权重整体只被“读一遍”,没有任何重复利用的机会。对比一下传统的卷积神经网络,同一个卷积核可以在空间维度上反复滑动复用,权重在片上缓存的命中率很高。但 Transformer 的矩阵乘法是逐层串行,每层的结果都要写回 HBM 再被下一层读取,中间几乎没有数据重用的空间。
还有一个加重问题的因素:现在的模型动不动就是长上下文。上下文一旦拉长,KV Cache 的体积就会线性膨胀。比如 7B 模型跑 32K 上下文,KV Cache 可能超过 2GB,这一部分也要参与每轮访存。于是每生成一个 token,不仅要搬权重,还要搬 KV Cache。权重是固定的,但 KV Cache 越大,带宽压力越大,这也是长上下文推理性能暴跌的核心原因之一。
2.2 算术强度与 Roofline 模型:一眼看穿 Memory Bound
工程上判断一个算子到底是计算密集还是访存密集,有个非常成熟的分析框架叫 Roofline 模型。核心就一个指标:算术强度,单位是 FLOPs/byte,也就是“每读入一个字节的数据,能支撑多少次浮点运算”。CPU 和 GPU 上都有一个“转折点”,低于这个点的负载就是访存受限,高于这个点才是算力受限。
算一下 decode 阶段的算术强度就明白了。7B 模型一次前向计算量约 14 GFLOPs,访存量约 14GB,算术强度大概只有 1 FLOPs/byte。而 H100 的转折点大概在 300 FLOPs/byte 左右,差了整整两个数量级。这意味着我们处在一个极端访存受限的区间,算力再高也没用,因为数据送不过来。
这个模型也帮我想通了很多现象。比如为什么小 batch 的 decode 那么慢,因为 batch=1 时算术强度本来就低得可怜;为什么加 batch 后吞吐能成倍上涨,因为权重只读一次,被多个请求分摊后有效算术强度会成倍提升。Roofline 模型相当于给调优工作画了张地图,先判断自己在哪个瓶颈区间,再决定用哪套优化策略,不然很容易白忙一场。
2.3 Prefill 和 Decode:一个吃算力,一个吃带宽
大模型推理可以拆成两个阶段:Prefill 和 Decode。Prefill 阶段处理用户输入的整段 prompt,一次性并行计算所有输入 token,这时的计算量非常密集,batch size 就是 prompt 长度,GPU 的算力能被充分利用,属于典型的计算受限场景。Decode 阶段则完全不同,它逐 token 自回归生成,batch 通常不会太大,权重访存占据了绝对主导,属于带宽受限场景。
这两个阶段的属性差异直接决定了推理系统的架构优化方向。比如很多生产系统会用分离式部署,让 Prefill 和 Decode 跑在不同的实例上,因为两者对硬件资源的需求完全不同。我把两个阶段的特征整理成一个对照表,方便直观感受:
| 阶段 | 单次处理的 token 数 | 主要瓶颈 | 典型优化手段 |
|---|---|---|---|
| Prefill | prompt 长度,通常数百到数千 | 算力(FLOPs) | 并行计算、Tensor Parallel、算子融合 |
| Decode | batch size,通常个位数到几十 | 显存带宽 | KV Cache、量化、批处理、投机采样 |
理解了这个区别,很多东西就自动串起来了。为什么量化对 decode 的提升那么明显?因为它直接压缩了权重访存体积,正好打在带宽瓶颈上。为什么投机采样在长上下文场景特别有效?因为它用一个小模型做草稿生成,大模型只做验证,大幅减少了大模型 decode 的次数,也就减少了大模型“搬权重”的次数。
3. 推理工程的优化方向:所有招数都在绕着带宽转
3.1 KV Cache:用显存容量换时间,但长上下文会反噬
KV Cache 算是 Transformer 推理最基础的优化。它的思路非常直白:既然每一轮自回归生成都要用之前所有 token 的 Key 和 Value 做注意力计算,那就把历史 token 的 K、V 矩阵缓存下来,避免重复算一遍历史部分的注意力。这个优化效果立竿见影,没有 KV Cache 的话,7B 模型的 decode 速度会慢到没法用。
但 KV Cache 不是免费的午餐。它是典型的“用容量换时间”,需要额外占显存。更关键的是,在每次 decode 时,不仅权重要被读一遍,KV Cache 也要被读一遍。上下文越长,KV Cache 体积越大,带宽占用就越高,直接吃掉原本可以用来搬运权重的带宽。这也是为什么长上下文场景下,即使显存装得下 KV Cache,token/s 还是肉眼可见地往下掉。
工程上有两个解决方向。一个是优化 KV Cache 的布局,比如 vLLM 里的 PagedAttention,把 KV Cache 切成固定大小的块,避免显存碎片浪费。另一个是 KV Cache 量化,比如把 K、V 从 FP16 压到 INT8,访存体积直接减半。但这两招只能缓解,不能根治,因为长上下文本身的 KV 数据量摆在那里,只要注意力计算还需要读它们,带宽压力就会持续存在。
3.2 量化:降精度就是在降带宽
在大模型推理优化里,量化可以说是收益最高、落地最广的手段。很多人把量化理解成“牺牲精度换速度”,这个说法不够准确。量化的核心效果其实是把权重体积压缩了,FP16 的 7B 权重是 14GB,INT8 降到 7GB,INT4 再降到 3.5GB。权重体积变小,每轮 decode 需要从 HBM 搬的数据量也跟着成比例下降,预算出来的延迟自然就下来了。
在 llama.cpp 生态里,GGUF 格式已经成了事实标准,量化等级从 Q2 到 Q8 有好几档。我自己的实操经验是,Q4_K_M 是边缘设备上性价比最高的档位,速度比 FP16 快 3 到 4 倍,而质量损失几乎感知不到。Q8_0 质量更好,但体积只比 FP16 小一半,带宽优势就没那么大了。对于 7B 这种规模的模型,Q4 是甜点位,再低到 Q3 或 Q2,虽然速度更快,但语言能力和逻辑推理能力会肉眼可见地变差。
量化还有一个重要价值容易被忽略:它能让模型塞进更小显存的设备。举个例子,70B 模型 FP16 要 140GB 显存,INT4 只要 35GB,一台 48GB 的消费级工作站就能跑。这个能力直接决定了模型能在多少种硬件上落地,是推理工程里“性价比”的代名词。
3.3 动态批处理:让一次权重读取服务更多请求
批处理是服务端推理里提升吞吐的最强手段,原理也是从带宽入手的。关键洞察是:同一模型权重只需要在显存里存一份,多个请求同时 decode 时,权重只需要被读一次,而这一次读取的计算结果可以被所有请求共享。也就是说,batch size 从 1 涨到 4,权重访存成本不变,但一次前向算出了 4 个 token,有效吞吐直接翻了 4 倍。
这个逻辑放到算术强度上就是:batch=1 时,14 GFLOPs 要搬 14GB 数据,算术强度 1;batch=16 时,计算量变成 224 GFLOPs,访存量还是 14GB 加 KV Cache,算术强度翻了 16 倍。虽然还是达不到算力受限的区间,但已经明显向算力侧移动,吞吐自然会提升。
批处理在实际工程里不是简单地把请求凑一起就完事。vLLM 的 continuous batching 做得更精细:不是等一个 batch 全部生成完再接收新请求,而是每步 decode 后,已经完成的请求立刻退出,空出的位置马上被新请求填上。这样能最大化权重的复用效率,而不是傻等最慢的请求拖垮整个 batch 的延迟。这也是 vLLM 比传统简单批处理吞吐高出一截的根本原因。
3.4 投机采样与算子融合:减少“搬数据”的次数
投机采样是一个特别有意思的优化,思路完全绕开了“搬权重”这个动作。它先让一个小模型快速生成一串候选 token,生成结果通常又快又稳,然后再让大模型一次性检查这串 token 的合理性,只接受验证通过的“连续片段”。这样大模型每做一次完整前向,就能“白捡”好几个 token,等于把大模型的高成本前向次数压到了最低,省下的就是大模型的访存开销。
这个方案在延迟要求不高的场景很好用,但也不是万能药。小模型生成草稿本身也有延迟,如果草稿质量太差,验证经常被拒绝,收益就被抵消了。实际工程里通常会让草稿模型跟目标模型共享部分词表,同时控制草稿长度在 4 到 8 个 token 左右,兼顾触发概率和验证成本。
算子融合则是另一个维度。FlashAttention 的核心思想是分块计算注意力,中间结果始终留在片上 SRAM 里,不需要反复写回 HBM。这种“少搬几趟数据”的思路,把注意力算子的访存开销从 O(N^2) 级别降到了 O(N) 级别,对长序列提升尤为显著。所有绕开带宽的优化,本质上都在做同一件事:让每次“搬数据”都能干更多的活。
4. 边缘设备实战:Jetson AGX Orin 部署 llama.cpp
4.1 为什么选 AGX Orin 和 llama.cpp
讲完理论,来一个完整的边缘部署实战。我选择 Jetson AGX Orin 64GB 作为实验平台,原因主要有三个:第一,它的功耗只有 60W,却能提供 204.8GB/s 的内存带宽,是边缘设备里“带宽天花板”的代表,正好用来验证带宽瓶颈的极限;第二,它的架构是 CPU 和 GPU 共享同一片内存,天然适合跑大模型推理;第三,它跑 llama.cpp 非常方便,生态成熟度高,踩坑率相对低。
llama.cpp 选择它的理由也很充分:纯 C/C++ 实现,代码轻量、易交叉编译,对显存占用控制精细,而且原生支持 GGUF 量化和 CPU/GPU 混合推理。虽然 vLLM 和 TensorRT-LLM 性能更强,但它们在 Jetson 上的部署复杂度高得多,对轻量边缘推理场景来说,llama.cpp 是性价比最高的选择。
4.2 部署步骤与关键参数
环境准备阶段,我建议直接用 NVIDIA 官方提供的 JetPack 6.0 镜像,里面已经预装了 CUDA、cuDNN 和 TensorRT,省去手动搭环境的痛苦。llama.cpp 官方仓库里提供了针对 Jetson 的构建说明,核心步骤是打开 CUDA 支持后编译:
git clone https://github.com/ggerganov/llama.cpp cd llama.cpp mkdir build && cd build cmake .. -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=87 make -j$(nproc)这里有个非常容易踩的坑:CMAKE_CUDA_ARCHITECTURES 必须显式设成 87,对应 AGX Orin 的 Ampere 架构。如果不写,cmake 会默认按宿主机显卡架构编译,最后在 Orin 上跑起来会直接报 “no kernel image available” 的错误。
模型方面,我用的是 Qwen2.5-7B-Instruct 的 GGUF 量化版本。下载 Q4_K_M 档位的文件后,启动命令的关键参数如下:
./llama-cli -m /path/to/qwen2.5-7b-instruct-q4_k_m.gguf \ --n-gpu-layers 999 \ --threads 8 \ -c 4096 \ -b 512 \ --temp 0.7参数含义:--n-gpu-layers 控制模型层放入 GPU 的数量,999 表示全部层都放;--threads 设置 CPU 线程数;-c 是上下文长度;-b 是 batch size。在 AGX Orin 上,实测全部层放 GPU 比纯 CPU 推理快 5 倍以上,因为 GPU 侧能利用更高的内存带宽。
4.3 实测数据分析:带宽如何决定性能上限
跑一轮 benchmark 看数据。模型是 Qwen2.5-7B-Instruct Q4_K_M,权重体积约 4.4GB,AGX Orin 的内存带宽 204.8GB/s。理论上限算一下:4.4GB / 204.8GB/s,约 21.5 毫秒出一个 token,也就是 46 token/s。
实测的 llama.cpp 输出分为两段:prefill 阶段和 generation 阶段。prefill 阶段速度取决于 prompt 长度,通常能跑到 300 token/s 以上,因为它是计算密集;generation 阶段才是我们要关注的 decode 吞吐,实测大概在 28 到 32 token/s 之间,达到了理论值的 60% 到 70%。
我把不同量化档位的实测数据整理成表:
| 量化档位 | 权重体积 | 实测 token/s(decode) | 带宽利用率估算 |
|---|---|---|---|
| Q8_0 | 约 7.5 GB | 约 18 token/s | 66% |
| Q4_K_M | 约 4.4 GB | 约 30 token/s | 65% |
| Q3_K_M | 约 3.3 GB | 约 38 token/s | 62% |
12 token/s 的差距,对交互体验的影响是非常直观的。Q4_K_M 大概两三秒出一个短句,Q8_0 用户会明显感到卡顿。这个数据也再次印证了核心结论:在带宽受限的场景里,权重体积几乎决定了速度上限,精度反而成了次要矛盾。
5. 常见问题排查与调优实录
5.1 为什么 token/s 低得离谱?先查内存频率和 GPU 利用率
如果实测的 token/s 远低于理论估算值的 50%,我一般会按下面的顺序排查。先看设备的带宽是否真正跑满,在 AGX Orin 上可以用 tegrastats 查看内存控制器频率,如果它没有跑到最高频率,token/s 会一直上不去。有一次我发现 Orin 的内存频率被锁在低频档,调整电源模式到最高性能档后,token/s 直接翻倍。
再查 GPU 利用率,decode 阶段 GPU 利用率不应该很高,一般 50% 到 70% 都属于正常。如果 GPU 利用率接近 100%,说明可能在偷懒用算力硬扛,需要检查是不是 weight 没有全部放 GPU,或者 batch size 设置得太大反而触碰了算力上限。反过来,如果 GPU 利用率低到 10% 以下,且 token/s 依然很低,那多半是单线程瓶颈,需要调大 --threads 或者确认 CPU 侧有没有其他进程抢占带宽。
5.2 量化后输出变差,怎么在速度和质量之间找平衡
量化最大的副作用是精度损失,尤其在数学推理和代码生成任务上表现明显。我的经验是,如果业务对输出质量要求高,优先尝试 Q8_0;如果显存或带宽实在紧张,再降到 Q5_K_M 或 Q4_K_M。Q3 以下的档位只适合闲聊类场景,不建议用在严肃用途上。
还有个技巧是量化感知训练,但这对普通工程团队来说成本太高。更实用的方案是做“混合精度”:权重主体保持 Q8 或 FP16,只对注意力层的 Q、K、V 部分做 INT8 量化,因为这部分对精度的影响相对可控。在 llama.cpp 里,这是通过 mmq 参数和不同的 GGUF 结构实现的,效果通常比全局 Q4 更好。
5.3 批处理上去了,单请求延迟却变高了
这是服务端推理里最常见的矛盾:加 batch 能提升吞吐,但单个请求的延迟也会上升。原因是 batch 变大后,每个请求分到的算力和带宽都被摊薄了,decode 的每一步都需要等整个 batch 算完。如果业务对延迟敏感,比如在线聊天,需要限制 batch 上限,或者在延迟和吞吐之间做加权权衡。
vLLM 的 continuous batching 对这个问题缓解得比较好,但也不是没有代价。它的调度开销比静态批处理更高,如果单个请求特别长,还是会拖慢整个 batch 的尾部延迟。我在生产环境里的经验是:用 P99 延迟作为主要监控指标,而不是追求最大化平均吞吐,这样能保证大多数用户的实际体验。
5.4 判断当前系统是计算瓶颈还是带宽瓶颈的实用方法
最后分享一个判断瓶颈类型的野路子方法,不需要 profiling 工具。先跑一遍推理,记录当前带宽利用率和 GPU SM 利用率。然后只做一件事:把权重量化到更低档位,比如从 Q8 降到 Q4,其他参数不变。
如果 token/s 几乎没变,说明系统是算力受限;如果 token/s 明显提升,说明是带宽受限。这个方法的逻辑很简单:量化只压缩了权重体积,不会提升算力,如果量化后速度明显变快,说明之前就是被权重搬运卡住了。
另一个更简单的判断是看 GPU 的显存控制器利用率和 SM 占用率曲线。如果显存控制器长期处于 90% 以上,而 SM 利用率只有 50% 以下,那基本可以断定瓶颈在带宽。用 ncu 抓 kernel 级别的数据会更准确,但对于大多数场景,上面这个野路子方法已经足够指导优化方向了。
我在 AGX Orin 上跑 Qwen2.5-7B 那一周,最大的感受是:大模型推理性能调优这件事,本质上就是在跟显存带宽做博弈。所有主流的优化手段,量化也好、KV Cache 也好、连续批处理也好,最终都落在同一个逻辑上——要么减少数据传输量,要么提高每次数据搬运的利用率。理解了这条主线,再去看各种推理框架的技术选型和文档,很多设计决策就都能看懂了。如果你的推理性能离预期差得很远,别急着换卡,先用带宽估算一下理论上限,再检查瓶颈到底出在哪个环节,往往能省下一大笔算力预算。