完整的引擎
把前十九章的零件装成一个引擎:调度器、分页缓存、前缀复用、投机解码、流式服务器,最后跑一遍基准测试。
- 引擎核心
- 端到端串联
- 基准测试
- 消融实验
- 接下来做什么
为什么重要
问题
你有十九个能用的零件,却还没有引擎。装配远不止把所有东西 import 进一个文件:这些零件之间有顺序约束,有共享状态,还有几处真正的冲突。
前缀缓存和块管理器都拥有 block。调度器和投机解码器都在决定一步产出多少 token。CUDA Graph 要固定 shape,连续批处理偏偏不给。让它们共存,就是构建推理引擎这件事本身。
核心思路
解法
一个 step() 函数,在循环里被调用,顺序严格。其余一切都是它调用的组件。
def step(self) -> list[Output]:
# 1. SCHEDULE — who runs, and with what token budget (S10, S11)
batch = self.scheduler.schedule()
if not batch:
return []
# 2. MEMORY — resolve prefix hits, then allocate blocks (S06, S07)
for req in batch.new_requests:
reused, start = self.prefix_cache.match(req.token_ids)
req.block_table = reused + self.blocks.allocate(req, from_token=start)
# 3. PROPOSE — optional draft tokens for verification (S14)
if self.spec_enabled and batch.is_decode_only:
batch = self.proposer.attach_drafts(batch, k=self.adaptive_k())
# 4. EXECUTE — one forward pass over a ragged batch (S03, S12, S13)
runner = self.graph_runner if batch.can_use_graph else self.eager_runner
logits = runner.forward(batch)
# 5. SAMPLE — per-request params, grammar masks, verify (S04, S15, S14)
tokens = self.sampler.sample(logits, batch.sampling_metadata)
tokens = self.verifier.accept(tokens, batch) # no-op without speculation
# 6. RETIRE — stop checks, free blocks, publish to streams (S19)
return self.postprocess(batch, tokens)这个顺序不是风格问题
工作原理
工作原理
那些冲突,以及真实引擎如何化解它们
- CUDA Graph vs 连续批处理。 graph 需要固定 shape;batch 每步都在变。化解:捕获分桶并补齐,prefill 退回 eager 模式。
- 投机 vs 批处理。 两者花的是同一份闲置算力。化解:把投机长度做成当前 batch 大小的函数——空闲时激进,饱和时关闭。
- 前缀缓存 vs KV 容量。 为可能到来的未来请求保留的 block,正是运行中请求拿不到的 block。化解:引用计数加只淘汰叶子的 LRU,并让「已缓存但无人引用」的 block 排在淘汰队列最前面。
- 分块 prefill vs 前缀缓存。 两者组合得很好,但前提是:block 已被缓存的那个 chunk 要被完全跳过,而不是重新计算。
按什么顺序去建
如果你要写自己的引擎,收益排序大致是:
- KV 缓存 —— 在它存在之前,其它一切都无从谈起。
- 连续批处理 + 分页显存 —— 两者一起做,因为单独哪一个都不好使。
- 前缀缓存 —— 如果你的流量有共享前缀,这是每行代码收益最大的一项。
- 分块 prefill —— 便宜,而且能修掉最糟糕的尾延迟行为。
- 量化 —— 当显存或带宽是硬约束时。
- 其余一切 —— 先测量。CUDA Graph 和投机在低并发下收益很大,在高并发下接近于零。
动手观察
动手试试
课程里的每一项技术,都是一个开关。这些倍数取自公开测量,要看的是它们之间的相对量级。一个一个地关掉功能,看看你会想念哪些。
- 吞吐
- 173.2k tok/s
- TTFT
- 148 ms
- token 间延迟
- 0.6 ms
- 并发请求数
- 32
- 该特性之前的吞吐
- 该特性带来的增量
相对 S01 的朴素循环,整体提升 2062×。这些倍数是示意性的,取自公开测量;要看的是排序和相对量级,不是第三位有效数字。注意总收益中有多大一部分来自前两项。
- 每请求 KV
- 0.36 GB
- 能装下的请求数
- 175
- 显存利用率
- 18%
关掉 PagedAttention 和量化,看看有多少请求瞬间装不下了。十九章之后,S05 的结论依然成立:KV 缓存才是稀缺资源,吞吐就是「能装下多少」。
最有启发的实验:把并发设成 1,注意投机解码和 CUDA Graph 各贡献了多少。然后设成 128。它们的贡献几乎消失,扛起一切的换成了连续批处理和分页。大多数关于「哪项优化更重要」的争论,其实是关于「各自心里想的并发数不同」的争论。
亲手实现
实现
本章的参考实现就是整个引擎:约 420 行,从前面十九个文件里 import 各个组件。它还暴露了一套基准测试脚手架,让你可以在自己机器上复现模拟器里的消融实验。
def ablate(workload, features):
"""Turn one feature off at a time and measure what it was worth."""
baseline = benchmark(Engine(**features), workload)
rows = []
for name in features:
reduced = {**features, name: False}
result = benchmark(Engine(**reduced), workload)
rows.append((name, baseline.throughput / result.throughput))
return sorted(rows, key=lambda r: -r[1]) # most valuable first某项功能得分低于 1.00× 是结论,不是 bug
max_num_seqs=64 下投机解码是净亏损,关掉它引擎反而更快。这个结果是对的。投机花的是闲置算力,而在那个并发下没有闲置。你跑出来的任何消融结果,都是关于你的负载和你的并发的陈述,而不是一份普适排名。$ python code/s20_complete_engine.py只需要 NumPy — 查看环境准备.
生产实践
接下来去哪里
现在你可以去读真正的引擎了。具体地说,每一章在 vLLM 源码中的位置是:
vllm/v1/core/sched/scheduler.py—— S10、S11vllm/v1/core/kv_cache_manager.py—— S06、S07vllm/v1/worker/gpu_model_runner.py—— S09、S13vllm/v1/sample/—— S04、S15vllm/v1/spec_decode/—— S14vllm/distributed/—— S17、S18
在规模的另一头,picoLM 用约 2944 行 C 实现了 S01–S05、S08 和 S12,零依赖。一个下午就能读完,也是检验你是否理解了前向传播的好办法。
最后的练习
- 1在你自己的硬件和负载上跑一遍消融。排序会和模拟器不同,而搞清楚为什么不同,才是真正的练习。
- 2把引擎移植到真实模型上:加载 Llama-3.2-1B 权重,用 S03 的实现替换玩具前向,并在 temperature 0 下与 HuggingFace 的
generate()对拍。 - 3挑一件本课程略过、但对你的场景很重要的事(多模态输入、LoRA 适配器服务、embedding,或者 encoder-decoder 模型),把它加进去。你构建的调度器和显存管理器大体上应该能容纳它。它们能撑到哪一步,就是对这套抽象的检验。
继续学习
你构建了什么
二十章,而这条主线短到一段话就能讲完。推理引擎是一个调用模型的循环。这个循环很慢,因为它在重算过去,于是你把过去缓存起来。缓存变成了稀缺资源,于是你给它分页、共享、压缩。decode 期间 GPU 闲着,于是你做批处理;批处理需要调度器;调度器需要分块才能保持公平。然后你把剩下的闲置产能花在投机上;模型装不下时把它切到多张 GPU;两个阶段互相冲突时把负载切到多台机器;最后在前面放一个 API。
这个领域里其余的一切,都是对上述某一步的细化。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
在引擎的 step() 中,为什么前缀匹配必须发生在 block 分配之前?