跳到主要内容

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

· 阅读需 27 分钟
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)分开报告。

4. 把公式画出来(Visualize)

吃掉预算的是 context,不是模型

服务时总显存随 context 长度变化的曲线图,分别对应 batch 1、4、16,并标出 T4 的 16 GB 上限线和 1.19 GB 的模型权重线服务时总显存随 context 长度变化的曲线图,分别对应 batch 1、4、16,并标出 T4 的 16 GB 上限线和 1.19 GB 的模型权重线

Figure 10.1公式 3.1 给出的服务时显存预算——整条曲线都由 Qwen3-0.6B config 中的真实数值算出:KV 每个 token 每条 sequence 涨 112 KiB,而在 batch 16 时,context 到 ~8,000 token 显卡就满了

注意右下角那个方点:一条 sequence 在 40,960 token 的满 context 下要吃 4.7 GB 的 KV——差不多是模型权重本身的四倍。 而 batch 16 那条线在 ~8,070 token 就撞上了 16 GB 的天花板。这就是 LLM 服务商按 context 长度收钱的原因。

批处理:读一趟权重,换回多个 token

总吞吐量随 batch size 上升并逼近 266 tok/s 带宽上限的曲线图,同时每个 request 的延迟在变差总吞吐量随 batch size 上升并逼近 266 tok/s 带宽上限的曲线图,同时每个 request 的延迟在变差

Figure 10.2公式 3.2 给出的 266 tok/s 上限(红色虚线——由真实 datasheet 算出),以及批处理摊薄固定成本的模型(实线——示意机制的插图,真实数字来自 notebook)

这张图两根轴都要读完:蓝线在涨(好事),可橙线也在涨(坏事)。 批处理不是免费的——它是在卖掉每个用户的延迟,买进整个系统的吞吐量。 你该站在这条曲线的哪个位置,是一个商业决策,不是技术决策。

队列在服务器满之前就先炸了

第 50 和第 99 百分位延迟随 request 到达率变化的曲线图,显示 p99 在接近饱和点时爆炸式上升第 50 和第 99 百分位延迟随 request 到达率变化的曲线图,显示 p99 在接近饱和点时爆炸式上升

Figure 10.3M/M/1 队列的 p50/p99,由公式 -ln(1-q)/(μ-λ) 算出——这是数学模型,不是实测结果,但这个'膝盖'形状会在第 8 节的真实测量里出现

必须记住的一点:在 utilisation 80% 的时候,p99 已经冲破 11 秒了,而服务器名义上还"空着"20%。 所以真实系统必须永远留 headroom——谁把系统容量刚好卡在实测吞吐量的 100%,就是在设计一个 p99 为无穷大的系统。

自己动手玩一下服务时的预算——切到 Serving 模式,调调 context 和并发 request 数,看看什么时候撞上 16 GB:

596.0M parameters, derived from config.json
Weight dtype
Run mode
Trades about 30% more compute for a large drop in activation memory.
weights: 1.11 GiBactivations: 68.00 KiBkvCache: 112.00 MiB16 GB — Colab T40481216GiB
  • Weights1.11 GiB
  • Gradients
  • Optimizer state
  • Activations68.00 KiB
  • KV cache112.00 MiB
Total VRAM1.22 GiB14.78 GiB to spare
Trainable params00.00%
KV cache per token112 KiB2 x 28 x 8 x 128
Full context KV4.38 GiB41.0K tok

It fits.This run needs 1.22 GiB and leaves 14.78 GiB of headroom on a free Colab T4.

5. 准备环境(Environment)

打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度就够用——这也是本系列最后一次)。

本系列的例行警告——最后一次,而且你自己应该已经能背出来了

如果你一路跟完了九章,这段你大概已经烂熟于心:T4 是 Turing 架构(SM 7.5),没有 bfloat16,没有 FlashAttention-2。 这个贯穿全系列的梗从来都不只是梗——这一章它还会再咬你两口:

torch_dtype=torch.float16      # 不是 bfloat16 —— 这是你最后一次从我这里读到这行
attn_implementation="sdpa" # 不是 flash_attention_2
# 还有先剧透一下:vLLM 必须写 dtype="half" —— 放任 auto 的话,它会在 config 里读到 bf16 然后当场拒绝
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你

较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。 请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。

Stage 1 —— 合并 adapter 并导出(约 2 分钟)

整个系列我们都在用 LoRA 训练,而它到了服务阶段就变成负担:每一次 forward 都要多算一遍 BAxBAx。 好消息是 LoRA 可以闭式地合并回底座权重:W=W+αrBAW' = W + \tfrac{\alpha}{r}BA——合完之后,服务成本和基座模型分毫不差。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()

policy = PeftModel.from_pretrained(base, "qwen3-th-lora-best") # 第 9 章 sweep 里胜出的那个
merged = policy.merge_and_unload() # W' = W + (α/r)·B·A
merged.save_pretrained("qwen3-th-serve")
tok.save_pretrained("qwen3-th-serve")

不要凭信心相信合并之后还是原来那个模型——用 logits 对比来证明它:

x = tok("泰国地方电力局的职责是什么?", return_tensors="pt").to("cuda")
with torch.no_grad():
d = (policy(**x).logits - merged(**x).logits).abs().max().item()
print(f"max |Δlogit| = {d:.4f}") # 大约在 ~1e-3 —— 接近零,但不是精确的零

值不会精确为零,是因为把 αrBA\tfrac{\alpha}{r}BA 加进 WW 时用的是 fp16,存在舍入误差——出现 1e-3 这个量级属于正常。 如果你看到的是 1.0 这个量级,说明 adapter 加载错了,或者 dtype 对不上。

要付的代价:约 20 MB 的 adapter 文件(r = 16 时是 10.1M 参数)变成了约 1.2 GB 的完整权重, 膨胀了约 60 倍,换来的是所有服务工具(包括 vLLM)都把它看成一个普普通通的单体模型。

6. 准备数据(Data)

这一章的"数据"不是训练集,而是 workload——而 workload 一旦测错方法,给出的 p99 永远漂亮得不真实。

  • 60 条泰语 prompt,取自与 KobEval-TH 相同的池子——长中短混在一起,好让 prefill 具备真实工况那样的多样性
  • 来自第 9 章的 TH-KNOW 质量评测集——对每一个测过速度的 configuration 都复用同一套
  • open-loop 压测器:request 的到达时间按 Poisson 随机生成,然后按时刻表发射,不管服务器准不准备好
import numpy as np

rng = np.random.default_rng(42)
gaps = rng.exponential(1.0 / LAM, size=N_REQUESTS) # Poisson 过程:间隔 ~ Exp(λ)
arrivals = np.cumsum(gaps) # 发射时刻表 —— 严格照此执行
为什么必须 open-loop——一个叫 coordinated omission 的陷阱

如果压测器每发一个请求就等到拿回回答才发下一个(closed-loop),那么服务器一慢,你的发射速度也会自动跟着变慢。 队列于是永远堆不起来,测出来的 p99 就漂亮得虚假,因为测量工具在"体谅"它正在测量的那个系统。 按照预先随机好的时刻表发射——哪怕上一个 request 还没做完——是让图 10.3 里那个膝盖真正显形的唯一办法。

每个 request 都记录三个值:TTFT平均 ITL,以及 token 数——然后按 configuration 汇总成 p50/p99。

7. 核心代码(Main code)

7.1 Stage 2 —— 故意写得很糟的 baseline(约 4 分钟)

from fastapi import FastAPI
import threading, uvicorn

app = FastAPI()

@app.post("/generate")
def generate(body: dict):
ids = tok(body["prompt"], return_tensors="pt").to("cuda")
out = merged.generate(**ids, max_new_tokens=128, do_sample=False)
return {"text": tok.decode(out[0, ids.input_ids.shape[1]:],
skip_special_tokens=True)}

threading.Thread(
target=uvicorn.run, args=(app,),
kwargs=dict(host="127.0.0.1", port=8000, log_level="warning"),
daemon=True,
).start()

这个服务器违反了本章教的每一条:一次只收一个 request、没有批处理、KV 缓存每次都重新分配、 Python 逐 token 打转——而这正是它的职责所在。它是 baseline,之后每一项改进都要拿回来跟它比。 如果不测起点,"快了 5 倍"就只是一句广告词。

Colab 会在断线时杀掉后台服务器

跑 uvicorn 的那个 thread 只能活到 session 还在为止——关掉标签页、屏幕挂太久,或者 runtime 被回收,服务器就悄无声息地没了。 所以 notebook 把测量拆成一小段一小段跑完,每一段结束立刻把结果写进 results.json。不要设计需要跨小时运行的测量方案,尤其在免费 Colab 上。

7.2 Stage 3a —— Static KV 缓存 + torch.compile

普通的 generate 会逐 token 扩展 KV 缓存,导致 shape 一直在变,compile 不了。 预先把整块 cache 一次性分配好(static),shape 就稳定到足以让 torch.compile 把整张图抓进 CUDA graph—— 这就干掉了 baseline 里最大的那块 overhead:从 Python 一个一个地发射 kernel。

merged.generation_config.cache_implementation = "static"
fast = torch.compile(merged, mode="reduce-overhead")

warm = tok("预热", return_tensors="pt").to("cuda")
for _ in range(3): # 计时之前一定要先预热
fast.generate(**warm, max_new_tokens=8)
不预热就开始测 = 整套数字都是垃圾

第一次调用 torch.compile 要花几十秒到一分钟量级的时间去 trace 和编译。 如果这段时间混进了计时,你会得出 compile "让它变慢了"的结论,而事实恰好相反。 notebook 的规矩是:正式开表之前至少空跑 3 轮,每一个 configuration 都一样,没有例外。

7.3 Stage 3b —— 手写约 60 行的 continuous batching

公式 3.2 告诉我们,读一趟权重才是那笔大成本——批处理就是让多个 token 去分摊同一笔成本。 但 static batching(凑够 BB 个才开始,然后要等最长的那个跑完)会浪费掉巨量的空位, 所以 continuous batching 一有空位就立刻把新的 request 收进 batch——精髓就在这个循环里:

from collections import deque
queue, running, MAX_BATCH = deque(), [], 16

while queue or running:
while queue and len(running) < MAX_BATCH:
running.append(Sequence(queue.popleft())) # 中途就收进来,不等原来那批跑完

step(running) # 给所有 sequence 走一步 forward —— 读 1 趟权重,拿到 B 个 token

running = [s for s in running if not s.done] # 跑完的立刻退出,把位置还给队列

完整版本(约 60 行,含每条 sequence 的 position id 和 mask 处理)在 notebook 里。 它不是 vLLM——没有 paged memory,没有 prefix cache——但它用一屏就能读完的代码证明了这套机制, 而且它测出来的每一个百分点的提升都能解释来源,这在这堂课里比华丽更重要。

7.4 量化 —— int8 与 nf4,以及真实的代价

from transformers import BitsAndBytesConfig

int8 = AutoModelForCausalLM.from_pretrained(
"qwen3-th-serve",
quantization_config=BitsAndBytesConfig(load_in_8bit=True),
device_map={"": 0},
)

nf4 = AutoModelForCausalLM.from_pretrained(
"qwen3-th-serve",
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
),
device_map={"": 0},
)
对比 fp16、int8、nf4 的 VRAM 与 decode 速度的柱状图,并为 TH-KNOW 准确率差值留出空位对比 fp16、int8、nf4 的 VRAM 与 decode 速度的柱状图,并为 TH-KNOW 准确率差值留出空位

Figure 10.4左:权重占用的 VRAM,按 config 真实计算得出(bitsandbytes 始终把 embedding 保留为 fp16)——右:T4 上的大致速度比例。ΔTH-KNOW 那一格是故意留成 ? 的,要等 notebook 来填

这类文章的原罪:报告速度却不报告质量

一句"nf4 省了 2.2 倍 VRAM!"如果没有附上质量分数,那就不是实验结果,那是广告。 因为把权重压到 4 位一定要拿某样东西去换,唯一有意义的问题是"换掉了多少"。 所以我们的 notebook 会对第 9 节表格中有行的每一个 configuration 都重跑一遍 KobEval-TH 上的 TH-KNOW—— fp16、int8、nf4 用的是同一套题目,并按本系列的惯例附上 Wilson CI。

再给一个坦率的事前预测:在 T4 上,bitsandbytes 的 int8 往往会 fp16 更慢—— LLM.int8() 会把 outlier 拆出去用 fp16 算,于是两头都付 overhead。它是一个省 VRAM 的工具,不是一个提速的工具。 如果你的实测结果就是这样,那不是你的 bug,那是评测文章不太爱印出来的真相。

7.5 Stage 4 —— vLLM(可选,也是全系列最脆的一段)

这个 cell 是全部 10 个 notebook 里最有可能跑挂的一个——而且原因都解释得清

vLLM 确实支持 SM 7.5,但在 Colab 的免费 T4 上有三层叠加的条件: (1)必须指定 dtype="half"——它在 config 里读到 bf16 就会当场拒绝(读到这里,你应该在看之前就猜到了) (2)不少版本还需要 enforce_eager=True,因为 CUDA graph 那条路径在老卡上有问题 (3)某些最新版本干脆砍掉了、或者根本编不出 sm_75 的 build——所以 notebook 把版本 pin 死,不要升到 latest

try:
from vllm import LLM, SamplingParams # 版本已在 notebook 的安装 cell 里 pin 死
llm = LLM(
model="qwen3-th-serve",
dtype="half", # T4 没有 bf16 —— 必须显式指定
enforce_eager=True, # 绕开在 sm_75 上闹脾气的 CUDA graph
gpu_memory_utilization=0.85,
max_model_len=4096,
)
VLLM_OK = True
except Exception as e:
VLLM_OK = False
print("vLLM 在这个运行时上用不了 —— 直接跳过即可:", e)

这个结构是刻意的:即便 vLLM 装不上、或者在 init 时崩掉,stage 1–3 的一切依然完整。 本章的主要结论完全不依赖 vLLM——它只是一份补充证据,说明 paged KV 加上 fuse 得当的 kernel, 相比我们那个 60 行的 scheduler 还能再多做到什么。

8. 结果(Results)

notebook 会把每一个数字写进 results.json,骨架就是从 datasheet 得到的理论值 vs 实测值

理论(第 3 节)实测(notebook)
单流 decode,naive generate上限 ≤ 266 tok/s?
单流 decode,static cache + compile上限 ≤ 266 tok/s?
batch 16 的 aggregate(continuous batching)数倍于 batch 1?
TTFT(prompt ~512 token)几十 ms(受算力限制)?

该期待什么,以及怎么读它: naive 的数字会比上限低大约 10 倍——别慌,也别怪 T4。 这个差距的来源是可以一层一层追出来的:Python 逐 token 打转、每一步要发射几十次 kernel、 KV 的动态分配、sampling 时同步回 CPU——stage 3 会逐层拆掉它们,并且每拆一层就重测一次。 结果一步步逼近上限(但永远碰不到,因为上限没有算进 KV、activation 和剩下的 overhead), 这就是公式 3.2 能解释真实机器的实证依据——这也正是我把它称为本系列最扎实那次实验的原因。

在负载之下,第二张表抓的是图 10.3 的形状:

λ(req/s)p50p99
低(约 capacity 的 30%)??
中(约 60%)??
接近饱和(约 90%)?? —— 应当出现图 10.3 里那种膝盖式爆炸

还有一项数字替代不了的、要用眼睛做的质量检查——同一个 prompt,fp16 与 nf4 的回答对照:

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. 对比(Comparison)

全章的汇总表——任何一份换来的速度,都必须在同一行里把付出的代价亮出来:

配置tok/s @B=1tok/s @B=16p99峰值 VRAM最大并发*TH-KNOW
fp16 + naive generate??~1.5 GB1baseline
+ static cache + torch.compile??~1.7 GB1= baseline(权重分毫未动)
+ continuous batching????16(取决于 MAX_BATCH)= baseline
int8(bitsandbytes)???~0.9 GB??
nf4(bitsandbytes)???~0.7 GB??
vLLM dtype="half"(如果跑得起来)???取决于 gpu_memory_utilization?= fp16

* 在 context 1,024 token 时,公式 3.1 给出的 KV 预算能撑住上百条 sequence(~14.8 GB ÷ 112 MiB ≈ 125) ——真正的限制来自 scheduler 和 prefill 的算力,而不是 VRAM,这本身就是一课。

应该看到的模式是:

  • compile 对 batch 1 的帮助最大(它干掉的是每一步的 overhead,而那正是单流场景的瓶颈)
  • 批处理几乎不改善每个 request 的 tok/s,但会把 aggregate 成倍放大——并且在高负载下让 p99 变差
  • int8 降低了 VRAM 但在 T4 上更慢,nf4 降得更多而且比 int8 更快——质量必须自己去看最右边那一列,不许从速度列下结论
  • vLLM 如果活下来了,在高并发下应该明显赢过我们手写的 scheduler——如果没赢,说明 enforce_eager 正在吃掉它的收益

需要提防的坑

1. 服务器因为 Colab 断线而悄悄消失 —— 测量分成短段,结果立刻写盘(第 7.1 节)。不要制定比 session 寿命更长的测量计划。

2. T4 上的 bf16 —— 都第十章了,你应该在 error 冒出来之前就猜到:transformers 里写 torch_dtype=torch.float16, vLLM 里写 dtype="half"。这个贯穿全系列的梗到这一章为止,但世界上的 Turing 卡还会继续活着。

3. 不预热就开始测 —— 第一次 compile 要吃掉分钟级的时间,一旦混进计时,结论会当场反过来(第 7.2 节)。

4. 报告 tok/s 却不说 batch size —— 同一个数字,可能代表用户每个 token 等 40 ms 的系统,也可能是等 500 ms 的系统。 所以本章的每一个数字都随身带着 @B。

5. 把 prefill 和 decode 混在一起 —— 长 prompt 会让"平均 tok/s"虚高,因为 prefill 是受算力限制的、 每秒能吞掉上千 token(第 3.5 节)——只能把 TTFT 和 ITL 分开报告。

10. 小结(Summary)

  • batch 1 的 decode 是在等权重赶路,不是在等计算 —— 芯片在"全速工作"的时候有 ~99.5% 是闲着的
  • 速度上限可以直接从 datasheet 算出来:320 GB/s ÷ 1.2 GB ≈ 266 tok/s,一行代码都还没跑
  • KV 缓存 112 KiB/token(和本站 test suite 里 assert 的是同一个数)——在 40,960 token 的满 context 下, 单条 sequence 就是 ~4.7 GB,接近模型权重的四倍——吃掉预算的是 context
  • 批处理 = 卖延迟买吞吐量,而 continuous batching 是这笔买卖里亏得最少的卖法
  • 量化减少的是要搬运的字节数 —— 理论上快一倍,实践中必须测,而且必须把质量并排一起测
  • Little's law 把这一切串起来L=λWL = \lambda W 给出必须同时持有的 sequence 数,而它又反过来变成 KV 预算
  • p99 在服务器满之前就先炸 —— 没有 headroom 的系统,就是被设计成在用的人最多时垮掉的系统
这个实验的局限

T4 是 2018 年的卡,320 GB/s 的带宽对上 H100 的 ~3.35 TB/s——差了整整一个数量级。 所以本章的每一个绝对数字都不可迁移到别的机器上。可以迁移的是比例关系和思考方式: 公式 3.1–3.5 对任何一张卡都成立,只要把常数换成新 datasheet 上的值。

而真正的 production 级服务还需要好几层本章完全没有碰的东西: autoscaling、health check 与 readiness probe、observability(metrics/logging/tracing)、 multi-tenancy 与用户隔离、rate limiting、身份认证、每个 request 的成本核算、 模型版本管理与 rollback——没有提到它们,不代表它们不重要。 只是一篇文章说不完,而我们选择讲那个作为一切之根的东西:瓶颈的物理学。

收尾

过去十章铺出的是一条完整的路:灌入知识(第 1 章)→ 教会格式(第 2 章)→ 用三种方法对齐 preference (第 3–5 章)→ 蒸馏变小(第 6–7 章)→ 加上护栏(第 8 章)→ 诚实地测量(第 9 章)→ 送上线并对照物理学定下的上限来测(本章)。 但我真正希望留在你身上的,不是其中任何一项技术,而是每一章都在反复强调的那些习惯: 写代码之前先列出公式;测什么都带上置信区间;并且永远在文章末尾那个黄框里,把自己的局限公开出来。 模型会换,库会换,显卡会快十倍——而这三个习惯,在这个系列里的一切都过时的那一天,依然管用。

这十个 notebook 都能在免费 Colab 上跑完——先别信我,去自己跑一遍,看看你的数字和我的在哪里不一样。 如果你是一头撞进这一章的:从第 1 章开始 —— Continue Pretraining, 然后沿着这条路一直走到这里。回头见。

参考文献(References)

  1. Kwon et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention — PagedAttention:vLLM 背后的 KV 缓存管理
  2. Yu et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI '22) — 连续批处理的原始论文,第 7 节手写了一个精简版
  3. Dao et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — FlashAttention——以及 T4 为何用不了
  4. Frantar et al. (2022). GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers — GPTQ:精确的训练后量化
  5. Dettmers et al. (2022). LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale — LLM.int8():bitsandbytes 8-bit 模式的基础
  6. Pope et al. (2022). Efficiently Scaling Transformer Inference — 系统层面的推理瓶颈分析
  7. Williams et al. (2009). Roofline: An Insightful Visual Performance Model for Multicore Architectures — 第 3 节 266 tok/s 上限所依据的 roofline 模型
  8. Pipatanakul et al. (2023). Typhoon: Thai Large Language Models — Typhoon:另一条泰语 LLM 路线
  9. Nguyen et al. (2023). SeaLLMs -- Large Language Models for Southeast Asia — SeaLLMs:面向东南亚语言的模型
  10. Pairatsuppawat et al. (2025). SiamGPT: Quality-First Fine-Tuning for Stable Thai Text Generation — SiamGPT:以质量优先的泰语微调

本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。