跳到主要内容

[LLM 10/10] 部署:你等的不是计算,而是权重在路上

· 阅读需 8 分钟
Kobkrit Viriyayudhakorn
CEO, iApp Technology

前面九章里,我们灌进了知识、教会了格式、对齐了 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 缓存—— 都是从不同角度攻击同一个瓶颈:减少要搬运的字节数,或者让搬运这一趟更划算

这一章的计划非常直白,而我认为它是整个系列里"自我证明"力度最强的一次实验:

  1. 算出上限:从 T4 的 datasheet 推出 decode 的速度上限——此时还一行代码都不用跑
  2. 实测真实值:先用一个故意写得很糟的服务器来测,看看离上限差了几倍
  3. 逐级填平差距:static KV 缓存、torch.compile、自己手写约 60 行的 continuous batching——每一步都重新测一遍,好知道每一项各自贡献了多少
  4. 代价是什么:量化成 int8 和 nf4,然后速度质量必须成对测量,质量用 KobEval-TH

3. 公式(Equation)

3.1 服务时的显存预算

M  =  Pbwweights  +  2LnkvdhsBbkvKV cache  +  MactM \;=\; \underbrace{P\,b_w}_{\text{weights}} \;+\; \underbrace{2\,L\,n_{kv}\,d_h\,s\,B\,b_{kv}}_{\text{KV cache}} \;+\; M_{\text{act}}
  • PP = 参数量,bwb_w = 每个权重占的字节数(fp16 = 2)
  • LL = 层数,nkvn_{kv} = key-value heads 的数量,dhd_h = 每个 head 的维度,bkvb_{kv} = cache 中每个值占的字节数
  • ss = context 长度,BB = 同时持有的 sequence 数
  • 最前面那个 2 是因为 K 和 V 各要存一份;而 MactM_{\text{act}} 在 inference 阶段小到几乎可以直接扔掉

代入 Qwen3-0.6B 的 config.json 里的真实数值(L=28L=28nkv=8n_{kv}=8dh=128d_h=128、fp16):

KV/token  =  2×28×8×128×2  =  114,688 字节  =  112 KiB 整\text{KV/token} \;=\; 2 \times 28 \times 8 \times 128 \times 2 \;=\; 114{,}688 \text{ 字节} \;=\; 112\ \text{KiB 整}

这里要当心大家最常踩的那个坑:Qwen3 用的是 grouped-query attention,必须用 nkv=8n_{kv}=8, 而不是 attention heads 的数量(16)——这一个数字取错,答案立刻翻倍。 而且这个 112 KiB 不是文章里随手写的数,它被 assert 在本站那个 widget 的 test suite 里 (memoryMath.test.ts)——系列的代码和文章被强制保持一致。

现在把它乘上模型的满额 context(s=40,960s = 40{,}960,来自真实的 max_position_embeddings):

114,688×40,960  =  4,697,620,480 字节    4.7 GB  (4.4 GiB)114{,}688 \times 40{,}960 \;=\; 4{,}697{,}620{,}480 \text{ 字节} \;\approx\; 4.7\ \text{GB} \;(\approx 4.4\ \text{GiB})

而这只是一条 sequence——大约是整个模型权重的 3.9 倍(596M 参数 × 2 字节 ≈ 1.19 GB)。 这就是长 context 之所以昂贵的算术原因:吃掉预算的不是模型,而是这段对话的记忆

3.2 本章最重要的公式——decode 的上限

生成一个 token 需要把所有权重读一遍,再加上已经累积起来的 KV 缓存,因此

ttoken    Mweights+MKVBWtok/s    320 GB/s1.2 GB    266t_{\text{token}} \;\gtrsim\; \frac{M_{\text{weights}} + M_{\text{KV}}}{\text{BW}} \qquad\Longrightarrow\qquad \text{tok/s} \;\lesssim\; \frac{320\ \text{GB/s}}{1.2\ \text{GB}} \;\approx\; 266

320 GB/s 就是 T4 datasheet 上写的 GDDR6 带宽,原封不动拿来用——~266 tok/s 就是 batch = 1 时的理论上限。 这世界上没有任何代码能让 T4 以单流方式把这个模型 decode 得更快,因为这是线路的极限,不是软件的极限。

再验一下瓶颈是不是真的在带宽:在 266 tok/s 下,计算量是 2P1.192P \approx 1.19 GFLOP/token, 合计约 0.32 TFLOPS,也就是 T4 那 65 TFLOPS(fp16)的 大约 0.5%——芯片有 99.5% 的时间在闲着。 notebook 会测出真实数字(会比上限低很多),然后第 8 节会逐层解释并填平这个差距。

3.3 Little's Law——系统要撑住多大

L  =  λWL \;=\; \lambda\,W

系统中滞留的任务数(LL)等于任务到达速率(λ\lambda)乘以每个任务的平均耗时(WW)——永远成立,不需要任何关于分布的假设。 它可以直接拿来估算系统规模:如果用户以 λ=5\lambda = 5 requests/秒 打进来,每个回答耗时 W=2W = 2 秒, 那么系统必须同时持有 L=10L = 10 个 request——在平均 context 1,024 token 的情况下,这意味着 KV 缓存 10×1,024×112 KiB1.210 \times 1{,}024 \times 112\ \text{KiB} \approx 1.2 GB 必须一直被占着。公式 3.1 和 3.3 其实是同一个公式的两个视角。

3.4 INT8 对称量化

s  =  maxx127,xq  =  round ⁣(xs),x^  =  sxqs \;=\; \frac{\max|x|}{127},\qquad x_q \;=\; \mathrm{round}\!\left(\frac{x}{s}\right),\qquad \hat{x} \;=\; s\,x_q

把权重存成 8 位整数(xq[127,127]x_q \in [-127, 127]),每一组配一个缩放系数 ss,用的时候再乘回去。 每个值的误差不超过 s/2s/2。得到的收益是每个权重的字节数减半——而公式 3.2 说了每个 token 的耗时 和要读取的字节数成正比,所以理论上 decode 会快 2 倍。至于实践中,负责 dequantise 的 kernel 可能把这份收益吃光甚至吃到亏本(尤其是 bitsandbytes 的 LLM.int8() 跑在 T4 上)——必须测,不许猜。 另外别忘了:bitsandbytes 只作用于权重,KV 缓存仍然是 fp16,仍然是 112 KiB/token。

3.5 Prefill 与 Decode——两个绝对不能混为一谈的阶段

定义 arithmetic intensity II = 每读一个字节能做多少 FLOPs,然后和 GPU 的"脊点"作比较:

Iridge  =  65 TFLOPS320 GB/s    203 FLOP/byteI_{\text{ridge}} \;=\; \frac{65\ \text{TFLOPS}}{320\ \text{GB/s}} \;\approx\; 203\ \text{FLOP/byte}
  • Decode(batch 1):读 2 个字节的权重,做 2 FLOP → I1I \approx 1——比脊点低了大约 200 倍 → 受带宽限制
  • Prefill:长度为 ss 个 token 的 prompt 被一次性处理,一个权重读进来之后会被复用 ss 次 → IsI \approx s——prompt 一旦超过 ~200 token,就已经受算力限制

这两个阶段完全是两个世界:prefill 每秒能吞掉上千个 token,decode 只有几十到几百。 谁要是把这两者平均成一个"tok/s",那个数字基本什么都说明不了—— 所以我们的 notebook 永远把 TTFT(time to first token,衡量 prefill)和 ITL(inter-token latency,衡量 decode)分开报告。

完整内容在课程中

这篇文章大约是本章的前 30%。其余部分——环境准备、数据准备、核心代码、实测结果与总结——都在免费的 LLM Finetuning 课程中,使用 Google 登录即可阅读。

在课程中阅读完整章节 →