跳到正文
LLM 推理
S20分布式服务·421

完整的引擎

把前十九章的零件装成一个引擎:调度器、分页缓存、前缀复用、投机解码、流式服务器,最后跑一遍基准测试。

  • 引擎核心
  • 端到端串联
  • 基准测试
  • 消融实验
  • 接下来做什么

为什么重要

问题

你有十九个能用的零件,却还没有引擎。装配远不止把所有东西 import 进一个文件:这些零件之间有顺序约束,有共享状态,还有几处真正的冲突。

前缀缓存和块管理器都拥有 block。调度器和投机解码器都在决定一步产出多少 token。CUDA Graph 要固定 shape,连续批处理偏偏不给。让它们共存,就是构建推理引擎这件事本身。

核心思路

解法

一个 step() 函数,在循环里被调用,顺序严格。其余一切都是它调用的组件。

完整的引擎循环python
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)

这个顺序不是风格问题

调度必须在分配之前,因为是调度器决定谁拿到显存。前缀匹配也必须在分配之前,因为命中会改变需要多少块。投机跟在调度之后,因为它消耗的是调度器定下的预算。退出放在最后,释放的 block 才能被下一步的调度器用上。把其中任何一步调换顺序,你都会得到微妙的、依赖负载的 bug。

工作原理

工作原理

示意图所有章节,一张图
完整的引擎服务层 · S19OpenAI HTTP APIchat templatetokenizer · S02增量解码SSE 流 + 指标调度器 · S09–S11等待队列FCFS + 老化运行队列decode 优先token 预算分块 prefill抢占重算 / 换出准入水位线显存 · S05–S08block 管理器分页 KV,引用计数前缀缓存哈希链,LRUINT4 权重INT8 KV cache模型执行 · S03、S12、S13、S16融合 decoder blockRMSNorm、RoPE、GQA、SwiGLU分页 flash attention在线 softmax + block tableCUDA graph 重放MoE 分发(可选)输出 · S04、S14、S15批量采样器每请求参数语法掩码FSM / 下推自动机投机验证拒绝采样停止条件跨 GPU:节点内张量并行,跨节点流水线并行(S17)。跨机器:prefill 池与 decode 池(S18)。这张图里的每一个方块,都是你已经亲手写过的一章。整门课就是这些。
自上而下的分层:服务、调度、显存与模型、输出。回到 API 的那条虚线是流式路径。

那些冲突,以及真实引擎如何化解它们

  • 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)21
+ KV cache882
+ PagedAttention2.3k
+ prefix caching3.4k
+ INT4 weights + INT8 KV7.2k
+ continuous batching23.1k
+ chunked prefill25.9k
+ FlashAttention35.0k
+ CUDA graphs + fusion41.2k
+ speculative decoding43.3k
  • 该特性之前的吞吐
  • 该特性带来的增量

相对 S01 的朴素循环,整体提升 2062×。这些倍数是示意性的,取自公开测量;要看的是排序和相对量级,不是第三位有效数字。注意总收益中有多大一部分来自前两项。

显存仍然是约束
权重
32 × KV
每请求 KV
0.36 GB
能装下的请求数
175
显存利用率
18%

关掉 PagedAttention 和量化,看看有多少请求瞬间装不下了。十九章之后,S05 的结论依然成立:KV 缓存才是稀缺资源,吞吐就是「能装下多少」。

最有启发的实验:把并发设成 1,注意投机解码和 CUDA Graph 各贡献了多少。然后设成 128。它们的贡献几乎消失,扛起一切的换成了连续批处理和分页。大多数关于「哪项优化更重要」的争论,其实是关于「各自心里想的并发数不同」的争论。

亲手实现

实现

本章的参考实现就是整个引擎:约 420 行,从前面十九个文件里 import 各个组件。它还暴露了一套基准测试脚手架,让你可以在自己机器上复现模拟器里的消融实验。

code/s20_complete_engine.py(节选)python
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 下投机解码是净亏损,关掉它引擎反而更快。这个结果是对的。投机花的是闲置算力,而在那个并发下没有闲置。你跑出来的任何消融结果,都是关于你的负载和你的并发的陈述,而不是一份普适排名。
在本地运行
把 S01–S19 的所有组件装成一个引擎,让一份合成对话负载跑过去,并做留一消融,报告每项功能在你机器上值多少,外加一次显存扫描,展示请求从哪里开始装不下。
$ python code/s20_complete_engine.py
预期输出: 一份端到端的吞吐与延迟报告、一张给功能排序的消融表(高并发下投机解码得分低于 1×),以及一次 KV 池扫描:256 块只完成 120 个请求中的 16 个,2048 块则全部完成。

只需要 NumPy — 查看环境准备.

生产实践

接下来去哪里

现在你可以去读真正的引擎了。具体地说,每一章在 vLLM 源码中的位置是:

  • vllm/v1/core/sched/scheduler.py —— S10、S11
  • vllm/v1/core/kv_cache_manager.py —— S06、S07
  • vllm/v1/worker/gpu_model_runner.py —— S09、S13
  • vllm/v1/sample/ —— S04、S15
  • vllm/v1/spec_decode/ —— S14
  • vllm/distributed/ —— S17、S18

在规模的另一头,picoLM 用约 2944 行 C 实现了 S01–S05、S08 和 S12,零依赖。一个下午就能读完,也是检验你是否理解了前向传播的好办法。

最后的练习

  1. 1
    在你自己的硬件和负载上跑一遍消融。排序会和模拟器不同,而搞清楚为什么不同,才是真正的练习。
  2. 2
    把引擎移植到真实模型上:加载 Llama-3.2-1B 权重,用 S03 的实现替换玩具前向,并在 temperature 0 下与 HuggingFace 的 generate() 对拍。
  3. 3
    挑一件本课程略过、但对你的场景很重要的事(多模态输入、LoRA 适配器服务、embedding,或者 encoder-decoder 模型),把它加进去。你构建的调度器和显存管理器大体上应该能容纳它。它们能撑到哪一步,就是对这套抽象的检验。

继续学习

你构建了什么

二十章,而这条主线短到一段话就能讲完。推理引擎是一个调用模型的循环。这个循环很慢,因为它在重算过去,于是你把过去缓存起来。缓存变成了稀缺资源,于是你给它分页、共享、压缩。decode 期间 GPU 闲着,于是你做批处理;批处理需要调度器;调度器需要分块才能保持公平。然后你把剩下的闲置产能花在投机上;模型装不下时把它切到多张 GPU;两个阶段互相冲突时把负载切到多台机器;最后在前面放一个 API。

这个领域里其余的一切,都是对上述某一步的细化。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

在引擎的 step() 中,为什么前缀匹配必须发生在 block 分配之前?

得分 0/6