推理引擎
第 18 课:推理引擎
前面几课我们把模型"调好"了——切分、省显存、量化、微调。这一课换个视角:把它发出去服务几百个并发用户。一个请求要分两段跑(prefill 与 decode),两段的瓶颈完全不同;把请求一条条串起来处理会把 GPU 饿死,于是有了批量、连续批处理、分页 KV。这一课把吞吐翻倍背后的四件机制算清,最后回答一个工程问题:vLLM(即用分页 KV 与块级前缀缓存做高吞吐的通用推理引擎)/ SGLang(即用 token 级前缀树复用共享前缀的推理引擎)/ TensorRT-LLM 到底怎么选。
② 算出批量、连续批处理、分页 KV 各自把吞吐抬高了多少,并说清代价
③ 按负载特征(共享前缀 / 模型是否固定 / 延迟还是吞吐)选对推理引擎
上一课我们把模型"调"成了自己的;从这一课开始,我们把它发出去服务。一个请求进推理引擎,会依次过:分词 → 排队调度 → prefill → decode → 反分词 → 流式返回。要扛住并发,第一件事是看清这条路里哪一段在吃 GPU 的什么资源。
因为prefill 一次吃下整段 prompt(一大块矩阵-矩阵乘),decode 每次只吃 1 个 token(退化成矩阵-向量乘)——两者对 GPU 的压力完全不是一回事。可以把它类比成搬运:prefill 是一次装满一整卡车货(算力吃紧),decode 是一次只搬一个箱子、却要把整个仓库的目录重读一遍(带宽吃紧)。混在一起"加算力",对 decode 几乎没用。
下面把两段的算术强度各自算出来,就会看到那条分界线。
| 项 | 数值 | 说明 |
|---|---|---|
| 7B fp16 权重 | 14 GB | 每个 decode 步都要整份读一遍 |
| 每 token 的 FLOPs | 2 × 7e9 = 1.4e10 | 约 2 × 参数量 |
| 算术强度 | 1.4e10 ÷ 1.4e10 = 1 | 单位:FLOP/Byte |
| 项 | 数值 | 说明 |
|---|---|---|
| prompt 长度 s | 1024 token | 一次并行算完 |
| FLOPs(约 2×N×s) | 2 × 7e9 × 1024 ≈ 1.4e13 | 约 14 TFLOPs |
| 仍只读一份权重 | 14 GB | 输入侧几乎不占带宽 |
| 算术强度 | 1.4e13 ÷ 1.4e10 ≈ 1000 | 单位:FLOP/Byte |
| 阶段 | 每次处理 token 数 | 算术强度 | 瓶颈 |
|---|---|---|---|
| prefill | 整段(1024) | ≈1000 | 算力 |
| decode | 1 | 1 | 显存带宽 |
把 prefill 与 decode 混为一谈,会做出错误的优化决策:给 decode 堆算力(没用,它等的是数据),或给 prefill 堆带宽(也没用,它等的是计算)。"先分清一段计算卡在哪个资源上",是推理引擎里所有取舍(批量、调度、分离)的前置判断。
上一张卡片看清了 prefill 与 decode 卡在不同资源上。这一张从 decode 的"固定成本"入手,说明为什么逐条处理会把算力白白闲置。
因为decode 每生成 1 个 token 都要把整份模型权重从显存读进计算单元——这段读取时间对批里是 1 条还是 64 条几乎一样。把多条请求凑成一批,就像让这趟"读权重的车"顺路多捎几个 token。所以批越大,单位时间内吐出的 token 越多,而每条请求的延迟几乎不变。反过来,一条条串行处理(更自然的做法是让一条请求从头跑到尾、再接下一条),等于每读一次 14 GB 只换来 1 个 token——单卡吞吐只有约 143 token/s,而同一张卡批 64 时可达约 9,143 token/s(第 2 步算例),64 倍差距里大部分算力都在等数据。
下面把"批大小"对吞吐的放大效应算出来,同时看清静态批的隐藏浪费。
| 项 | 数值 | 说明 |
|---|---|---|
| 要读的权重 | 14 GB | 7B fp16 |
| 显存带宽 | 2 TB/s | A100 级(估算) |
| 单步时间下限 | 14 ÷ 2000 ≈ 7 ms | 带宽受限 |
| 批大小 | 单步时间(只算权重) | 吞吐(理想) |
|---|---|---|
| 1 | ≈7 ms | 1 ÷ 0.007 ≈ 143 token/s |
| 64 | ≈7 ms | 64 ÷ 0.007 ≈ 9,143 token/s |
| 项 | 数值 |
|---|---|
| 一批 8 条,最长输出 | 512 token |
| 其余 7 条输出 | 各 8 token |
| 整批实际步数 | 512 |
| 有效输出 | 512 + 7×8 = 568 |
| 槽位利用率 | 568 ÷ (512×8) ≈ 13.9% |
批量靠"摊薄读权重的固定成本"把吞吐抬高一个数量级。但批量本身有代价:一旦按"一批"为单位调度,批里最长的那条就成了整批的枷锁。所以真正的解法不是"批得更大",而是"批得更聪明"——让请求按步进出,而不是按批被钉死。
上一张卡片看到静态批被最长序列绑架。这一张回答解法:把批次从"一批"降级到"一步"来管。
因为自回归生成天然是"一步一个 token"的循环,调度器完全可以在每一步结束时重新决定:哪些序列已经吐完(遇到停止符或到长度上限)就立刻让位,排队里的新请求立刻补进空槽。所以短请求不再陪跑——它一吐完就腾位置给下一个,8 个槽位几乎每步都满。就像流水线上的工位:谁手里的活做完就下、新任务立刻补上空位。这就是"连续"的含义:批次是流动的,不是固定的。
更自然的做法是静态批:凑齐 8 条一起跑、等最长的那条跑完再收工——但上一张卡片的算例里,8 个槽位只有 13.9% 的时间在干活,剩下 86% 全浪费在陪跑上。连续批处理要解决的,正是这 86%。
下面用同一个负载,把静态批与连续批的吞吐摆在一起。
| 项 | 数值 |
|---|---|
| 槽位 | 8 |
| 整批步数 | 512 |
| 有效输出 | 568 token |
| 吞吐 | 568 ÷ 512 ≈ 1.11 token/步 |
| 项 | 数值 |
|---|---|
| 槽位 | 8 |
| 每步在跑 | 8 条(吐完即补位) |
| 吞吐 | 8 token/步 |
| 批法 | 吞吐 | 相对 |
|---|---|---|
| 静态批 | 1.11 token/步 | 1.0× |
| 连续批处理 | 8 token/步 | ≈7.2× |
连续批处理没有让单条请求变快(它仍是带宽受限),它改变的是槽位利用率——把"陪跑浪费掉的时间"还给真正在生成的请求。它也是后面分页 KV、前缀缓存能生效的前提:只有批能动态进出,才需要"按需分配与回收显存"。
上一张卡片让批次能动态进出,前提是显存里同时装得下很多条请求。这一张回答:KV cache 到底占了多少、又被浪费了多少。
因为KV cache 的大小随序列长度增长,而每条要生成多长事先并不知道。朴素做法是"给每条按最大长度 max_len 预留一块连续显存"——结果实际只用了其中一部分,剩下的空着(内部碎片);不同长度的请求交错分配,又会留下零散的、装不下新请求的空洞(外部碎片)。PagedAttention 的做法是:不再要求连续,把 KV 切成固定大小的块,用到哪块分配哪块,用一张"块表"把逻辑位置映射到任意物理块。
朴素做法正是按最大长度预留:一条序列最多可能生成 2048 token,就一次性给它留足 2048 个位置;可它实际可能只生成 512——剩下的 1536 个位置一直空着,白占 75%。不改掉这套「一次连续预留」的做法,显存就永远装不下更多请求。
下面把"预留浪费"与"分页浪费"各算一遍。
| 项 | 数值 | 说明 |
|---|---|---|
| 层数 × 头数 × 头维 | 40 × 40 × 128 | LLaMA-13B |
| 每 token 的 KV 字节 | 2 × 40 × 40 × 128 × 2 B = 819,200 B | 2 = K 与 V 两份 |
| 约合每 token | 0.82 MB | |
| 2048 token 全长 | 1.68 GB | vLLM 报约 1.7 GB |
| 项 | 数值 |
|---|---|
| 预留 max_len | 2048 token |
| 实际生成 | 512 token |
| 浪费比例 | (2048 − 512) ÷ 2048 = 75% |
| 方案 | 块大小 | 浪费 |
|---|---|---|
| 按 max_len 预留 | — | 最高 ~75% |
| PagedAttention | 16 token/块 | <4%(仅末块可能不满) |
PagedAttention 解决的不是"算得快",而是"装得下":显存一省,批就能更大,吞吐自然上去(vLLM 报告 2-4× 相对 FasterTransformer / Orca)。把 KV 从"一整块连续数组"改成"按块拼装",是这一课里最关键的一次抽象转换——后面调度、前缀共享全部建立在它之上。
上一张卡片解决了"装得下"。这一张处理真的装不下时——调度器要在两条代价路线上做取舍,顺便把"重复计算"也省掉。
因为显存是硬的:当新请求要进来而 KV 池已满,调度器必须腾地方。腾地方有两条路(就像仓库满了:先把最不常用的箱子搬到隔壁房间,或者干脆扔掉、需要时再重新组装):把某条请求的 KV 换出到 CPU 内存(swap,付出带宽代价),或者干脆丢掉它的 KV、等它再次被调度时重算一遍 prefill(recompute,付出算力代价)。选哪条,取决于此刻带宽和算力谁的性价比更高。另一头是前缀缓存:很多请求共享同一段 system prompt / 工具说明,把它们算过的 KV cache 缓存起来复用,就能省掉大量重复 prefill——这就是 SGLang RadixAttention 的主场。
不缓存就只剩更笨的一条路——每条请求各算各的:一段 2000 token 的 system prompt 被 100 条并发请求共享时,不做前缀复用就要重复 prefill 100 × 2000 = 200,000 token,其中 99% 是纯重复。前缀缓存要省掉的正是这 99%。
下面把 swap、recompute、前缀缓存的账各算一次。
| 项 | 数值 |
|---|---|
| 一条序列的 KV(13B,2048 token) | 1.7 GB |
| PCIe 带宽 | 32 GB/s(估算) |
| 换出一次 | 1.7 ÷ 32 ≈ 53 ms |
| 换出 + 换回 | ≈ 106 ms |
| 项 | 数值 |
|---|---|
| 重算的 prefill 长度(同一条 13B 序列) | 2048 token |
| FLOPs(2×13e9×2048) | ≈ 5.33e13(53.3 TFLOPs) |
| A100 有效算力 | ≈ 150 TFLOPs(估算,含利用率折损) |
| 重算耗时 | 53.3 ÷ 150 ≈ 355 ms |
| 项 | 数值 |
|---|---|
| 共享 system prompt | 2000 token |
| 并发请求数 | 100 |
| 不缓存要算的 prefill | 100 × 2000 = 200,000 token |
| 缓存后 | 只算 1 次 2000 → 省 99% |
调度器的目标从"公平"变成了"不浪费":显存紧张时在 swap(带宽)与 recompute(算力)之间选便宜的那条,平时则把共享前缀缓存下来避免重复计算。这也解释了为什么"共享前缀多"的负载(多轮对话、RAG(即检索增强,把外部资料检索出来塞进上下文)、Agent 循环)特别吃前缀缓存——它们的重复计算占比高得惊人。
前面五张卡片把单机引擎的机制讲完了:两段瓶颈、批量、连续批处理、分页 KV、调度取舍。这一张回答落地时最实际的问题——选哪个引擎。
因为引擎的差异不体现在"谁绝对更快",而体现在"你的负载长什么样"。四个判断维度:① 共享前缀多不多(多 → SGLang 的 token 级前缀树收益最大);② 模型是否固定、是否只用 NVIDIA(固定 → TensorRT-LLM 的编译优化能榨出更低延迟);③ 是否要覆盖多模型/多硬件(要 → vLLM 更通用);④ TTFT(首 token 延迟)与 TBT(Time Between Tokens,相邻两个输出 token 的间隔,即逐字延迟)哪个是硬约束(长 prompt 会打断在跑的 decode,可考虑 PD 分离)。这就好比选运输工具:通用货车、冷链专车、还是固定线路的直达快线。
下面把选型矩阵与"分离 vs 混跑"的账算一遍。
| 负载特征 | 更合适的引擎 | 为什么 |
|---|---|---|
| 共享前缀多(多轮/RAG/Agent) | SGLang | token 级前缀树,命中率高 |
| 模型固定 + 只用 NVIDIA + 追延迟 | TensorRT-LLM | 编译期算子优化 |
| 多模型 / 多硬件 / 通用 | vLLM | 覆盖广、块级前缀缓存 |
| 项 | 说明 |
|---|---|
| 收益 | prefill 不再打断在跑的 decode,TBT 更稳(TBT 即相邻输出 token 的间隔) |
| 代价①:多一份权重 | 7B 就要多 14 GB/卡 |
| 代价②:传 KV | 每请求把 KV 从 prefill 卡传到 decode 卡(KB~GB 级) |
| 项 | 数值 |
|---|---|
| 7B 每 token KV | 2 × 32 × 32 × 128 × 2 B = 524,288 B ≈ 0.524 MB |
| 每请求(1024 token) | 524,288 × 1024 ≈ 0.537 GB |
| 单卡 KV 池(80 GB − 14 GB 权重 − 余量) | ≈ 60 GB |
| 可并发条数 | 60 ÷ 0.537 ≈ 112 条 |
选型三步:① 先量自己负载的前缀命中率与 SLO(Service Level Objective,服务水平目标,即 TTFT / TBT 这类硬指标);② 按"共享前缀多不多、模型是否固定、要不要多硬件"挑引擎;③ 只有当长 prefill 的干扰真的踩到 TBT 红线时,才上 PD 分离。到此,"训练与推理工程"这条线收口——下一课我们把"模型好不好"这件事本身,变成可核验的评测。