[LLM 10/10] 部署:你等的不是计算,而是权重在路上
前面九章里,我们灌进了知识、教会了格式、对齐了 preference、把模型蒸馏变小、给它加上护栏,还诚实地做了测量。 但我们手上最好的那个模型,到现在为止仍然只是一个没有人能调用的 checkpoint 文件。 最后这一章把它真正送上线,并且证明一句话——这句话支配着 LLM 服务里的每一个决策: 逐 token 的 decode 不是被算力限制住的,而是被内存带宽限制住的—— 我们会在写下第一行代码之前,先从 GPU 的 datasheet 算出速度上限,然后再拿实测数字来对照。
Open in Colab10_deployment.ipynb
1. 问题(Problem statement)
你手上已经有一个训练完、并且从第 9 章的 sweep 里挑选出来的模型。接下来的问题,没有一个还是 machine learning 的问题:
| 出钱的人会问什么 | 回答它的数字 |
|---|---|
| 一个用户要等多久 | p50 / p99 延迟 |
| 能同时接住多少用户 | 并发(Little's law) |
| 需要几张 GPU | 吞吐量(tok/s) |
| context 能给到多长 | KV 缓存预算 |
大多数人回答这些问题的方式是"跑一下 generate 看看,感觉挺快的嘛",这不叫工程。
而大多数 benchmark 文章给出的答案,往往同样没有意义,原因非常具体:
- 报告 tok/s 却不说 batch size——batch 1 下的 27 tok/s 和 batch 32 下的 400 tok/s,完全可能是同一台机器
- 把 prefill(读 prompt)阶段的速度和 decode(生成回答)阶段平均到一起,可这两个阶段撞的根本不是同一个瓶颈
- 报告量化之后的速度却不报告质量——这是这类文章的原罪,本章还会反复提到它
这一章会用自己实测的数字回答上面每一个问题,而且仍然是在整个系列一路用下来的那台免费机器上。
2. 我们要做什么(Solution)
我们从一条物理事实出发,然后让其余的一切都从它流出来。
在 batch = 1 的逐 token decode 中,生成一个 token 需要把模型的每一个权重从 HBM 完整读一遍, 但每个权重上只做大约 2 FLOP 的计算——于是 GPU 只能干坐着,等数据走完这段路。 你等的不是计算,你等的是权重从内存赶到芯片上。
所有真正有意义的服务端优化——批处理、量化、paged KV 缓存—— 都是从不同角度攻击同一个瓶颈:减少要搬运的字节数,或者让搬运这一趟更划算。
这一章的计划非常直白,而我认为它是整个系列里"自我证明"力度最强的一次实验:
- 算出上限:从 T4 的 datasheet 推出 decode 的速度上限——此时还一行代码都不用跑
- 实测真实值:先用一个故意写得很糟的服务器来测,看看离上限差了几倍
- 逐级填平差距:static KV 缓存、
torch.compile、自己手写约 60 行的 continuous batching——每一步都重新测一遍,好知道每一项各自贡献了多少 - 代价是什么:量化成 int8 和 nf4,然后速度和质量必须成对测量,质量用 KobEval-TH
3. 公式(Equation)
3.1 服务时的显存预算
- = 参数量, = 每个权重占的字节数(fp16 = 2)
- = 层数, = key-value heads 的数量, = 每个 head 的维度, = cache 中每个值占的字节数
- = context 长度, = 同时持有的 sequence 数
- 最前面那个 2 是因为 K 和 V 各要存一份;而 在 inference 阶段小到几乎可以直接扔掉
代入 Qwen3-0.6B 的 config.json 里的真实数值(、、、fp16):
这里要当心大家最常踩的那个坑:Qwen3 用的是 grouped-query attention,必须用 ,
而不是 attention heads 的数量(16)——这一个数字取错,答案立刻翻倍。
而且这个 112 KiB 不是文章里随手写的数,它被 assert 在本站那个 widget 的 test suite 里
(memoryMath.test.ts)——系列的代码和文章被强制保持一致。
现在把它乘上模型的满额 context(,来自真实的 max_position_embeddings):
而这只是一条 sequence——大约是整个模型权重的 3.9 倍(596M 参数 × 2 字节 ≈ 1.19 GB)。 这就是长 context 之所以昂贵的算术原因:吃掉预算的不是模型,而是这段对话的记忆。
3.2 本章最重要的公式——decode 的上限
生成一个 token 需要把所有权重读一遍,再加上已经累积起来的 KV 缓存,因此
320 GB/s 就是 T4 datasheet 上写的 GDDR6 带宽,原封不动拿来用——~266 tok/s 就是 batch = 1 时的理论上限。 这世界上没有任何代码能让 T4 以单流方式把这个模型 decode 得更快,因为这是线路的极限,不是软件的极限。
再验一下瓶颈是不是真的在带宽:在 266 tok/s 下,计算量是 GFLOP/token, 合计约 0.32 TFLOPS,也就是 T4 那 65 TFLOPS(fp16)的 大约 0.5%——芯片有 99.5% 的时间在闲着。 notebook 会测出真实数字(会比上限低很多),然后第 8 节会逐层解释并填平这个差距。
3.3 Little's Law——系统要撑住多大
系统中滞留的任务数()等于任务到达速率()乘以每个任务的平均耗时()——永远成立,不需要任何关于分布的假设。 它可以直接拿来估算系统规模:如果用户以 requests/秒 打进来,每个回答耗时 秒, 那么系统必须同时持有 个 request——在平均 context 1,024 token 的情况下,这意味着 KV 缓存 GB 必须一直被占着。公式 3.1 和 3.3 其实是同一个公式的两个视角。
3.4 INT8 对称量化
把权重存成 8 位整数(),每一组配一个缩放系数 ,用的时候再乘回去。 每个值的误差不超过 。得到的收益是每个权重的字节数减半——而公式 3.2 说了每个 token 的耗时 和要读取的字节数成正比,所以理论上 decode 会快 2 倍。至于实践中,负责 dequantise 的 kernel 可能把这份收益吃光甚至吃到亏本(尤其是 bitsandbytes 的 LLM.int8() 跑在 T4 上)——必须测,不许猜。 另外别忘了:bitsandbytes 只作用于权重,KV 缓存仍然是 fp16,仍然是 112 KiB/token。
3.5 Prefill 与 Decode——两个绝对不能混为一谈的阶段
定义 arithmetic intensity = 每读一个字节能做多少 FLOPs,然后和 GPU 的"脊点"作比较:
- Decode(batch 1):读 2 个字节的权重,做 2 FLOP → ——比脊点低了大约 200 倍 → 受带宽限制
- Prefill:长度为 个 token 的 prompt 被一次性处理,一个权重读进来之后会被复用 次 → ——prompt 一旦超过 ~200 token,就已经受算力限制了
这两个阶段完全是两个世界:prefill 每秒能吞掉上千个 token,decode 只有几十到几百。 谁要是把这两者平均成一个"tok/s",那个数字基本什么都说明不了—— 所以我们的 notebook 永远把 TTFT(time to first token,衡量 prefill)和 ITL(inter-token latency,衡量 decode)分开报告。
这篇文章大约是本章的前 30%。其余部分——环境准备、数据准备、核心代码、实测结果与总结——都在免费的 LLM Finetuning 课程中,使用 Google 登录即可阅读。
在课程中阅读完整章节 →