第 22 讲 · LLM 服务:请求调度、连续批处理与 RadixAttention · LLM Serving: Scheduling, Continuous Batching and RadixAttention

Large Language Model Systems · Lecture 22。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:CMU 11-868 / 11-968 Large Language Model Systems(Spring 2026,Lei Li),Lecture 22 · Mon 4/6 · LLM serving with SGL(slides:llmsys-22-llm-serving-scheduler-radixattention.pdf,47 页)。 阅读材料:Yu et al. (2022) Orca: A Distributed Serving System for Transformer-Based Generative Models(OSDI);Zheng et al. (2024) SGLang: Efficient Execution of Structured Language Model Programs(NeurIPS);SGLang v0.4 的博客(零开销调度器、缓存感知负载均衡器)。 依据说明:slides 覆盖:LLM 推理服务器概览(服务规模、三种服务模式、设计目标、SGLang / vLLM 的架构);请求调度器(主循环与示例);连续批处理(等最长的请求、迭代级调度、选择性批处理、SGLang 调度器的 get_next_batch_to_run、内存检查与撤回、new_token_ratio);KV 缓存与 RadixAttention(KV 缓存的大小、基数树、新请求 / 下一轮对话 / 节点分裂 / 少样本请求 / LRU 淘汰);缓存感知调度与负载均衡(按匹配长度排序、命中率、路由器的实测);零开销调度(CPU 开销、调度与计算重叠、性能);其他功能(推测解码、约束解码、DeepEP、MLA)与总结。DFS 顺序的最优性定理、批内前缀检查、LPM 在长队列时退回 FCFS 取自 SGLang 论文与开源实现;ORCA 的 36.9 倍取自 ORCA 论文。本讲义的迷你推理服务器(选择性批处理的前向、基数树、调度器、撤回、重叠调度的模拟)、在城市语言模型上的所有实验、GPU 上的屋顶线时间模型与大规模模拟、多工作者路由的模拟,都是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture22/(5 个脚本 + serving.py;复用第 19 讲的模型;每个 1–10 秒) 预计阅读时间:3 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一讲,你应该能够:

  1. 画出一个 LLM 推理服务器的结构(API 服务器、分词器、调度器、模型工作者、显存池、基数树缓存、注意力后端),写出调度器的主循环。
  2. 算出一个模型每个 token 的 KV 缓存有多大,解释为什么”批处理”是推理吞吐的关键、批大小受什么限制。
  3. 说清楚连续批处理(迭代级调度)和选择性批处理各解决了什么问题;描述 SGLang 调度器怎样接纳新请求、什么时候撤回请求。
  4. 解释 RadixAttention:基数树怎样存放和查找 KV 前缀、什么时候插入、怎样淘汰、引用计数保护了什么。
  5. 理解缓存感知调度(最长前缀优先、DFS 顺序的最优性、批内前缀检查)和缓存感知的负载均衡。
  6. 说明零开销调度怎样把 CPU 调度藏到 GPU 计算后面,以及它的代价。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
自回归解码、KV 缓存(第 9 讲);GQA、RoPE(第 7 讲)KV 缓存的分页存储(第 24 讲 PagedAttention)
屋顶线、解码是访存受限的(第 4 讲、第 10 讲)预填充与解码分离(第 26 讲)、KV 缓存的分层存储(第 27 讲)
排队论的基本直觉(前置讲 P4);FlashAttention、Flash-Decoding(第 21 讲)推测解码(第 9 讲已讲)、约束解码

0.3 本讲地图 Map

§1  推理服务器的全貌:规模、服务模式、设计目标、架构                               ← 代码 01
§2  KV 缓存与批处理:为什么必须批处理,批大小受什么限制;调度器的主循环              ← 代码 01
§3  连续批处理:迭代级调度、选择性批处理、SGLang 的调度器、内存与撤回                 ← 代码 02
§4  RadixAttention:用基数树复用 KV 前缀                                           ← 代码 03
§5  缓存感知的调度与负载均衡:LPM、DFS 定理、批内检查、路由器                         ← 代码 04
§6  零开销调度:CPU 调度与 GPU 计算重叠                                            ← 代码 05
§7  其他功能   §8 小结   §9 常见误区   §10 练习   §11 延伸阅读   §12 下一讲预告

0.4 关于本讲的代码

serving.py 是一个真的能跑的迷你推理服务器,模型是第 19 讲在城市语言上训练的 LLaMA 式小模型(4 层、d = 128、4 个查询头 / 2 组 KV)。它在 CPU 上跑,所以”时间”大多用迭代数和算了多少 token来衡量;需要 GPU 时间的地方用屋顶线模型估算(H100 + LLaMA-3-8B)。每个实验都检查输出与”每个请求单独生成”逐 token 相同——调度、批处理、缓存只应该改变”什么时候算、算几遍”,不应该改变”算出什么”。


1. 推理服务器的全貌 · Overview of an LLM inference server

1.1 规模与服务模式

slides 第 3 页给了一个大型 LLM 服务的量级:2 亿日活用户、每天 25 亿个提示、每秒 3 万个请求。在这个规模上,服务器面对的请求有三种典型的模式(slides 第 4–6 页):

  • 多用户、单轮生成:很多用户各问一个问题——不同的用户可能发出相同的提示(或相同的系统提示)。
  • 多轮对话:同一个用户的第 2 轮提示 = 第 1 轮的提示 + 回答 + 新问题。slides 的问题是:服务器要不要把用户的聊天历史留在 GPU 显存里,等下一轮?
  • 一个提示生成多个回答:采样多次、或者带上下文示例(in-context examples)的提示。要把同一个提示的生成执行三遍吗?

这三种模式有一个共同点:大量的计算是重复的——相同的前缀被一遍遍地预填充。

1.2 设计目标

slides 第 7 页:一个高效的 LLM 服务器要

  • 维护多用户的对话会话;
  • 同时处理海量请求:生成长度各不相同、很多请求有共同的提示前缀;
  • 同时优化吞吐和时延;
  • 利用异构设备(CPU / GPU)。

代表性的系统:ORCA、SGLang、vLLM。

1.3 架构

slides 第 9 页:SGLang 和 vLLM 的架构大致相同。

客户端 API(原生生成接口 / OpenAI 兼容接口 / 结构化语言前端)
   │
FastAPI 服务器 → 分词器 → 调度器 ──→ 模型工作者
                            │          ├ 显存池(KV 缓存)
                            │          ├ 基数树缓存(RadixAttention)
                            │          └ 注意力后端(FlashInfer / FlashAttention …)
                 去分词器 ←──┘

整个代码库是轻量的 Python / PyTorch。调度器是这一讲的主角:它决定每一步把哪些请求放进 GPU、显存怎么分。


2. KV 缓存与批处理 · KV cache and batching

2.1 KV 缓存有多大

生成每个新 token 都要用到之前所有 token 在每一层的 K、V。普通的神经网络推理只保留当前层的输出;Transformer 必须把所有层、所有历史 token 的 K、V 都存在显存里——这就是 KV 缓存(slides 第 18–19 页)。每个 token 的大小是

2×层数×KV 头数×头维度×每个数的字节数2 \times \text{层数} \times \text{KV 头数} \times \text{头维度} \times \text{每个数的字节数}

slides 第 20 页的例子是 LLaMA-3 70B:80 层、d = 8192,“每个 token 2.5 MB,8K 上下文 20 GB / 请求”。01 号脚本第 1 部分把这个数拆开:

存法每个 token8K 上下文80 GB 卡能放几个 8K 请求
所有 64 个头都存 K、V,FP325.00 MiB40 GiB2
所有 64 个头,FP162.50 MiB20 GiB4
GQA 8 组 KV(LLaMA-3 70B 的实际结构),FP160.31 MiB2.5 GiB32
GQA 8 组,FP80.16 MiB1.25 GiB64

slides 的 2.5 MB 对应的是”所有头都存、每个数 2 字节”(标注的 FP32 会再翻一倍);LLaMA-3 70B 实际用 GQA,64 个查询头共用 8 组 K、V,缓存小 8 倍。即便如此,KV 缓存仍然是推理显存的大头,而且每个请求一份、随长度增长。

2.2 为什么必须批处理

解码时每个请求每一步只算 1 个 token。单独算一个请求,GPU 要把全部权重从显存读一遍,只为了做一次”矩阵 × 向量”——这是第 4、9 讲说过的访存受限。01 号脚本第 2 部分用屋顶线模型估算 LLaMA-3-8B 在 H100 上解码(每个请求上下文 1024):

批大小一步吞吐
14.83 ms207 token/s
165.44 ms2,944 token/s
647.36 ms8,698 token/s
25615.05 ms17,009 token/s

批大小 1 时,一步的时间几乎全花在读 16 GB 权重上;批大小 64 时一步只慢了一半,吞吐却是 42 倍——同一份权重被 64 个请求共用。批再大,读 KV 缓存的时间开始显现,吞吐增长变慢。真正限制批大小的是显存:上下文 1024 时最多放 476 个请求,8K 时只剩约 59 个。所以:显存管理直接决定吞吐——这是本讲(前缀复用)和第 24 讲(分页)的出发点。

在 CPU 上用迷你服务器量一量(第 3 部分,⏱):一次前向放 16 个解码请求,每个请求分摊的时间约为单独算的一半。CPU 上分摊掉的主要是 Python 和每个算子的固定开销;注意力部分仍然按请求循环,时间随批大小线性增长。

2.3 调度器的主循环

slides 第 10 页:

while True:
    recv_reqs = recv_requests()                # 1. 接收新请求
    process_input_requests(recv_reqs)
    batch = get_next_batch_to_run()            # 4. 给请求排序、准备一个批;5. 为这个批分配显存
    result = run_batch(batch)                  #    GPU 跑一次前向
    process_batch_result(batch, result)        # 2. 流式输出;3. 检查停止条件

01 号脚本第 4 部分把 3 个请求(最多同时运行 2 个,第 3 个在第 2 步才到达)的每一步记录下来:第 0 步预填充请求 0、1;第 1–2 步解码;第 2 步请求 0 结束;第 3 步预填充新到的请求 2——这一步请求 1 原地等待(SGLang 默认”有预填充就只做预填充”,§3.3);第 4–5 步解码直到全部结束。


3. 连续批处理 · Continuous batching

3.1 问题:等最长的那个

把一批请求一起送进 GPU,它们的生成长度各不相同。朴素的实现(静态批处理,按请求组批)要等这一批里最长的请求结束,才能接下一批(slides 第 13 页):短请求早早结束,位置却空着;新来的请求只能在队列里等。

3.2 迭代级调度(ORCA)

ORCA(OSDI 2022)的回答:按迭代(每生成一个 token 的一步)而不是按请求调度。每一步结束后,结束了的请求立即离开,空出来的位置立即由新请求补上(slides 第 14 页)。

02 号脚本第 2 部分:16 个请求、输出长度 2–40 不等、最多同时运行 4 个:

方式迭代数平均每步在跑的请求请求平均完成于第几步输出与单独生成相同
静态批处理1301.4561.816/16
连续批处理782.4125.816/16

放大到 GPU 规模(第 5 部分的离散事件模拟:2000 个请求、提示 100–1000、输出长度均值约 200、批上限 64、泊松到达):到达率低时两者吞吐一样,但静态批处理的时延已经翻倍;到达率 64 个/秒时,静态批处理的平均批大小只有 15.1(请求陆续结束,位置空着等最长的那个),吞吐卡在 2,557 token/s;连续批处理把批保持在 58.0,吞吐 7,449(2.9 倍),平均时延 61.8 → 10.6 秒。ORCA 论文在 GPT-3 175B 上报告:同样的时延水平下吞吐是 FasterTransformer 的 36.9 倍——我们的”静态批处理”已经很宽容(只算没结束的请求、不为不等长的提示补齐),所以差距小一些。

3.3 选择性批处理

连续批处理带来一个麻烦:同一步里的请求处于不同的阶段——有的在预填充(一次带来几百个新 token),有的在解码(只带 1 个),而且每个请求的历史长度都不同。没法把它们拼成一个规整的 [批, 长度, d] 张量。

ORCA 的办法是选择性批处理(slides 第 15 页):线性层、LayerNorm、GeLU 这些运算对每个 token 独立,就把所有请求的所有新 token 拼成一个 [T_total, d] 的矩阵一起算;注意力依赖每个请求自己的历史,就按请求拆开、各算各的(今天由 PagedAttention、FlashInfer 这类注意力内核一次处理一批不等长的请求)。

serving.forward_selective 就是这样实现的。02 号脚本第 1 部分在 float64 下把”2 个预填充 + 3 个解码”放进一次前向:线性层一次处理 27 个 token,与 5 个请求各自单独算的结果差 ≤ 7.1e-15。

3.4 SGLang 的调度器:预填充优先

slides 第 16 页描述了 SGLang 的 get_next_batch_to_run():

  • 如果上一步刚做完预填充,把这些请求并入解码批;
  • 如果有新请求可以预填充:get_new_batch_prefill——按优先级从等待队列里取请求,直到批满或显存不够,这一步只做预填充;
  • 否则继续解码:update_running_batch(),先检查显存(check_decode_mem)——够就准备解码、调低 new_token_ratio;不够就撤回一些请求(retract_decode)、调高 new_token_ratio,把它们放回等待队列;
  • process_batch_result:检查哪些请求结束了,释放它们的 KV(只是减引用计数,不真的清显存——§4)。

预填充优先 vs ORCA 的混合(02 号脚本第 3 部分,请求每 3 步到达一个):

做法迭代数解码被暂停的次数平均首 token(步)每个输出 token(步)
预填充优先(SGLang)93301.811.13
混合(ORCA)8600.941.00

按”步数”看混合更好;但混合步要同时算一大段预填充和一批解码,这一步比纯解码步慢得多,正在解码的请求会感到 token 间时延的尖峰。这正是第 26 讲的分块预填充和预填充 / 解码分离要解决的问题。

3.5 显存不够时:接纳与撤回

接纳一个新请求时,调度器不知道正在运行的请求最终会生成多少 token(max_new 只是上限)。SGLang 用 new_token_ratio 估计”它们还要多少 KV”:剩余的最大长度 × ratio。估计得乐观,就能同时运行更多请求,但解码到一半显存可能不够,只好撤回(retract)一些请求:作废它们的 KV、放回队列,之后重新预填充。02 号脚本第 4 部分(KV 池只能放 120 个 token):

ratio 初值撤回次数迭代数重算的 token输出相同
0.058014116/16
0.33827816/16
0.71853616/16
1.0087016/16

这是”并发度”和”撤回的浪费”之间的折中,SGLang 让它自适应:撤回时调大、平稳时慢慢调小。撤回的请求保留已经生成的 token,重新预填充时把它们当作提示的一部分,所以输出不变。


4. RadixAttention:在请求之间复用 KV · Reusing KV across requests

4.1 基数树

§1.1 的三种服务模式都有大量重复的前缀。RadixAttention(SGLang)把所有缓存的 KV 组织成一棵基数树(radix tree,压缩的前缀树;slides 第 21–24 页):

  • 每条边是一段 token(一个”字符串”);
  • 从根到某个节点的路径拼起来就是一个前缀;
  • 节点里存着指向 GPU 显存的指针——这条边上那些 token 的 KV;
  • 树本身在 CPU 上维护。

查找一个提示的 KV:沿着树匹配最长的前缀(按边匹配,很快),返回匹配了多少个 token 和最后的节点。这些 token 的 KV 直接复用,只需要预填充剩下的部分。

4.2 一棵真的树

03 号脚本第 1 部分按 slides 第 25–32 页的顺序操作一棵真的树(KV 池容量 105 个 token):

  1. 新请求(slides 第 25 页):用户 A 的”系统提示 + 问题”——插入一条边。
  2. 下一轮对话(第 26 页):A 的第 2 轮提示 = 第 1 轮提示 + 回答 + 新问题,43 个 token 里命中了 34 个(第 1 轮的提示和回答),只需要算新问题。
  3. 节点分裂(第 27 页):用户 B 的系统提示与 A 相同、问题不同——命中 17 个 token(系统提示),原来的一条长边在系统提示的末尾被切开,两个用户共用前半段的 KV。
  4. 少样本请求(第 29–30 页):两个用户用同样的上下文示例、问不同的问题——第二个用户命中 15 个 token,共用示例的 KV。
  5. 淘汰(第 28、31–32 页):容量不够时,从最久没被用到、没有被正在运行的请求锁住的叶子开始淘汰(LRU)。脚本里用户 E 到达时淘汰了 14 个 token——用户 A 第 2 轮的那两段;被反复用到的系统提示总是最近才被访问,自然留在缓存里。

两个细节:

  • 引用计数:正在运行的请求用到的前缀节点被”锁住”(lock_ref > 0),不能淘汰;请求结束时解锁。所以 slides 说”释放 KV 只是减引用,不真的清显存”——结束的请求的 KV 留在树里,变成可以被复用、也可以被淘汰的缓存。
  • 什么时候插入:SGLang 在请求预填充一结束就把提示的 KV 放进树(之后再来的请求马上能复用),请求结束时再把完整的”提示 + 输出”放进去(下一轮对话能复用)。我们的第一版只在结束时插入,同时到达的请求互相命中不了(见 README 的”计划 vs 实际”)。

4.3 四种负载

03 号脚本第 2 部分(最多同时运行 8 个请求,KV 池不限):

负载缓存预填充算的 token命中率
少样本(4 组 × 8 个,共用 4 句示例)关15800.0%
开,FCFS15402.5%
开,LPM + 批内检查42573.1%
一个提示 4 次采样(× 4)开,LPM + 批内检查121(关:436)72.2%
多轮对话(4 个会话 × 4 轮)开,FCFS165(关:562)70.6%
互不相关的 16 个请求开,LPM + 批内检查413(关:436)5.3%

(命中率 = 命中的提示 token / 全部提示 token,slides 第 35 页的定义。)有两点值得注意:

  • 只开缓存不够。少样本负载里同组的 8 个请求一起到达,FCFS 让它们在同一步里一起预填充——那时共同的示例还没有算好、不在树里,于是被算了 8 遍,命中率只有 2.5%。要靠下一节的缓存感知调度。
  • 多轮对话天然友好:第 1 轮只能命中开头的 <bos>,之后每一轮都命中之前全部的历史(各轮 0%、57%、74%、83%),历史越长省得越多。

所有情况下输出都与单独生成相同:复用的 KV 与重新算的 KV 在数学上一样(同样的 token、同样的位置)。

4.4 聊天历史要不要留在 GPU 上

回到 slides 第 5 页的问题。03 号脚本第 3 部分(8 个会话 × 4 轮):容量不限时命中率 72.0%;容量 400 时 55.1%;250 时只剩 23.0%——会话之间互相挤占。RadixAttention 的回答是:留着,但只用空闲的显存。留在树里的历史是没有被锁住的叶子,随时可以为新请求让路。真实系统还会把被淘汰的 KV 放到 CPU 内存或 SSD 上,而不是直接扔掉(第 27 讲 LMCache)。


5. 缓存感知的调度与负载均衡 · Cache-aware scheduling and load balancing

5.1 最长前缀优先(LPM)

slides 第 35 页:对等待队列里的请求,按匹配到的前缀长度排序,先服务匹配最长的,以最大化命中率。SGLang 的实现还做批内前缀检查:如果一个请求与本批已经选中的某个请求共享一大段尚未缓存的前缀,就把它推迟一步——等那个请求把共同前缀算好、放进树再来(§4.3 的 2.5% → 73.1% 就是它的效果)。

容量紧张时差别更大(04 号脚本第 1 部分,少样本 4 组交错到达,4 组共同前缀合计 164 个 token):KV 池 180 个 token 时,FCFS 交错地服务 4 个组,每来一个请求就要把别组的示例挤掉,命中率 3.2%、预填充 1530 个 token;LPM 先把缓存里那一组的请求做完,命中率 68.9%、预填充 491 个。但这不是免费的,也不是处处更好:容量中等(400、250)时 LPM 反而略差(71% / 68% vs 73%);每一步都要对整个队列做前缀匹配和排序(SGLang 在队列超过 128 个请求时退回到 FCFS);不命中的请求可能等很久。

5.2 DFS 顺序是最优的

SGLang 论文证明了一个漂亮的结论:对一批请求,只要缓存至少能放下最长的请求,按基数树的深度优先(DFS)顺序处理就能达到最优的命中率;而 LPM 等价于 DFS 顺序。 直觉是:DFS 让共享前缀的请求挨在一起处理,一个前缀在用完之前不会被挤掉,用完之后再也不需要。

04 号脚本第 2 部分在一个三层前缀树上验证(4 个系统提示 × 4 组示例 × 8 个问题 = 128 个请求,每个 80 个 token;一次处理一个;LRU):

缓存容量随机顺序LPMDFS(按 token 序列的字典序)
80(= 最长请求)14.1%81.3%81.3%
40052.8%81.3%81.3%
100079.5%81.3%81.3%
不限81.3%81.3%81.3%

最优值可以直接算:所有请求共 10240 个 token,前缀树里不同的 token 只有 1918 个(每个至少算一次),最优命中率 1 − 1918/10240 = 81.3%。LPM 和 DFS 在最小的缓存下就达到了它;随机顺序要缓存大到放下几乎整棵树。(按 token 序列的字典序排序,就是前缀树的一个 DFS 顺序。)

5.3 多个工作者:缓存感知的负载均衡

一个服务通常有很多个副本(工作者),每个有自己的 KV 缓存(slides 第 36–38 页)。路由器把请求分给谁?轮流分(round robin)让每个工作者都要见到所有的前缀,谁的缓存都放不下;缓存感知的路由器预测每个工作者的命中率(SGLang 的路由器为每个工作者维护一棵近似的基数树),把请求发给匹配最长的那个。slides 的实测:两组前缀、四个副本,轮流分命中率 20%、吞吐 82,665 token/s;缓存感知 75%、158,596 token/s。

04 号脚本第 3 部分模拟了 4 个工作者(各有 10,000 token 的缓存)、16 组 2000 token 的共同前缀、4000 个请求(时间按屋顶线模型估算):

前缀的热度路由命中率吞吐(估算)最忙 / 平均负载
均匀轮流分23.3%108,5631.01
均匀缓存感知86.1%221,2591.01
Zipf 1.0轮流分41.8%126,7041.02
Zipf 1.0缓存感知83.5%135,5731.58
Zipf 1.0缓存感知 + 负载均衡81.6%206,5751.01

均匀时缓存感知吞吐 2.0 倍,与 slides 的 1.9 倍同一量级。但前缀的热度不均时,只看缓存会把热门前缀全压到一个工作者上(负载比 1.58),吞吐被它拖住。加上负载均衡——负载差得太多时退回到最空闲的工作者——命中率几乎不降,负载恢复均衡。这就是路由器在”命中率”和”负载”之间的切换。


6. 零开销调度 · Zero-overhead scheduling

6.1 CPU 也是瓶颈

调度器每一步在 CPU 上要做很多事:接收请求、流式输出、检查停止条件、维护基数树和前缀匹配、为下一批分配显存(slides 第 41 页)。在 05 号脚本第 1 部分的迷你服务器里(⏱),这些工作占每一步的约 4–10%;而我们的”模型”也在 CPU 上、还很慢。在真正的 GPU 上,解码一步只要几毫秒,同样的 Python 开销占比会高得多。更糟的是,它们是串行的:CPU 排批时 GPU 在等,GPU 算时 CPU 在等(slides 第 40 页)。

6.2 把调度藏到计算后面

办法是让 CPU 在 GPU 算第 i 批的时候就去排第 i + 1 批(slides 第 41–42 页,思路来自 NanoFlow)。时间线模型(05 号脚本第 2 部分,GPU 一步 7.36 ms):

CPU 调度 / 步阻塞式 GPU 利用率重叠式吞吐提升
1 ms88%100%1.14×
2 ms79%100%1.27×
8 ms48%92%1.92×

只要调度时间不超过 GPU 时间,重叠后调度开销就完全藏起来了——“零开销”。slides 第 43 页:调度与计算重叠后,比当时最好的开源基线快 1.3 倍。

6.3 代价:停止检查晚一步

重叠有两个依赖要解决:

  • 第 i + 1 批的输入就是第 i 批的输出,CPU 排批时还不知道。SGLang 用”未来 token”的占位符:CPU 只记下”用第 i 批第 k 个输出”,GPU 上用一次索引把真实的 token 填进去,CUDA 流和事件保证顺序。
  • CPU 不知道第 i 批里谁结束了。SGLang 的做法是把停止检查推迟一步(slides 第 41 页”delaying the stop condition check”):已经结束的请求在下一批里多算一个 token,然后丢掉。

05 号脚本第 3 部分在真实模型上模拟了这一点:每个请求多算 1 个 token(输出长度 8 时占 12.5%;输出越长占比越小),输出与阻塞式完全相同。


7. 其他功能 · Additional features

slides 第 45 页列出现代推理服务器的其他功能:推测解码(SGLang 的 SpecForge;第 9 讲)、高效的约束解码(XGrammar:按语法 / JSON 模式屏蔽不合法的 token,并把语法上确定的一段直接跳过、不必逐个生成)、专家并行(DeepEP;第 17 讲)、针对具体模型的支持(DeepSeek 的 MLA)。slides 第 44 页把完整的执行流程串起来:HTTP 服务器 → 分词器管理器 → 调度器(get_next_batch_to_run → 模型执行 → process_batch_result)→ 去分词器管理器,注意力交给专门的后端。


8. 小结 · Summary

  1. 推理服务器 = 调度器 + 模型工作者 + KV 缓存管理。 调度器每一步决定”跑哪些请求”;它的主循环是:收请求 → 选批 → 跑模型 → 处理结果。
  2. 批处理是吞吐的关键,KV 缓存的显存是批大小的上限。 解码时同一份权重可以被一批请求共用;每个请求的 KV 却要单独存。
  3. 连续批处理(迭代级调度)让结束的请求立即离开、新请求立即进入;选择性批处理把线性层合在一起算、注意力按请求分开算。SGLang 默认预填充优先,用 new_token_ratio 估计未来的显存需求,必要时撤回请求。
  4. RadixAttention 用基数树在请求之间复用 KV 前缀:最长前缀匹配、预填充后立即插入、引用计数保护正在用的节点、LRU 淘汰没人用的叶子。
  5. 缓存感知调度:LPM(+ 批内检查)把共享前缀的请求排在一起;缓存不小于最长请求时,DFS 顺序(≡ LPM)是最优的。多工作者时按前缀路由,同时要防止负载失衡。
  6. 零开销调度:CPU 排下一批与 GPU 算这一批重叠;代价是停止检查晚一步,每个请求多算一个 token。

9. 常见误区 · Common pitfalls

  1. “批越大越好。” 批的上限由 KV 显存决定;批很大时读 KV 的时间也会成为瓶颈(01 号脚本第 2 部分)。
  2. “连续批处理会改变生成结果。” 不会。每个请求的计算只依赖它自己的 token 和 KV;我们的所有实验都逐 token 对比过。
  3. “开了前缀缓存就自动省计算。” 同时到达、共享前缀的请求如果在同一步里一起预填充,谁也命中不了(2.5%);要配合缓存感知的调度(批内检查)。
  4. “请求结束就该释放它的 KV。” 结束的请求的 KV 留在树里(只减引用计数),下一轮对话、相同前缀的新请求都能复用;显存不够时再按 LRU 淘汰。
  5. “LPM 总比 FCFS 好。” 容量中等时它可能略差;队列很长时排序本身是开销;还有饥饿的风险。
  6. “缓存感知的路由就是把相同前缀发到同一台机器。” 热度不均时这会压垮一台机器;必须和负载均衡结合。
  7. “重叠调度没有代价。” 停止检查晚一步,每个结束的请求多算一个 token;输出短时这个浪费不可忽略。
  8. “KV 缓存的大小 = 2 × 层数 × d_model × 字节数。” 只对多头注意力成立;GQA / MQA 要用 KV 头数 × 头维度(LLaMA-3 70B 差 8 倍)。

10. 练习 · Exercises

  1. LLaMA-3-8B:32 层、8 组 KV、头维度 128。FP16 时每个 token 的 KV 是多少?一个 32K 上下文的请求呢?H100(80 GB,权重 16 GB)最多能同时放几个 32K 的请求?
  2. 用 §2.2 的屋顶线模型解释:为什么解码时批大小从 1 加到 16,一步的时间几乎不变?什么时候它开始明显变长?
  3. 一批 4 个请求,输出长度 [5, 10, 20, 40],静态批处理和连续批处理(队列里总有新请求)各需要多少个”请求 × 步”的位置?利用率各是多少?
  4. 选择性批处理里,为什么注意力不能像线性层那样直接把所有 token 拼在一起算?如果硬拼会算出什么?
  5. new_token_ratio 设成 0 和 1 各有什么风险?为什么 SGLang 让它随运行情况变化?
  6. 在一棵基数树里依次插入(每个字母是一个 token)“abcde”、“abcxy”、“abfg”,画出树;再查找 “abcxz”,返回匹配多少个 token?查找时会发生节点分裂吗?
  7. 为什么正在运行的请求要”锁住”它用到的前缀节点?如果不锁,会发生什么?
  8. 用 §5.2 的方法算最优命中率:3 个系统提示(各 100 个 token),每个下面 5 个问题(各 20 个 token),共 15 个请求。缓存至少要多大,DFS 顺序才能达到这个最优值?
  9. 缓存感知的路由在什么情况下会比轮流分还差?给出一个例子,并说明负载均衡的阈值怎样防止它。
  10. 重叠调度里,GPU 一步 5 ms、CPU 调度一步 3 ms,平均输出长度 100。阻塞式和重叠式的吞吐之比是多少?算上”多算一个 token”的浪费呢?

参考答案

  1. 2 × 32 × 8 × 128 × 2 = 131,072 字节 = 128 KiB / token;32K 上下文 4 GiB。可用显存约 64 GB ≈ 59.6 GiB,最多约 14 个。
  2. 一步的时间 = max(计算, 读写)。解码时读写 = 16 GB 权重 + 所有请求的 KV;批 16、上下文 1024 时 KV 只有 2 GB,远小于权重,所以时间几乎不变(4.83 → 5.44 ms)。当 Σ(每个请求的 KV)与权重同一量级(批 128 时 KV 已有 17 GB),或者计算时间(2 × 参数 × 批大小 / 峰值)接近读写时间时,一步开始明显变长。
  3. 静态:这批占 4 × 40 = 160 个位置,实际使用 5 + 10 + 20 + 40 = 75,利用率 47%。连续:每个请求结束后位置立即被新请求占用,位置利用率接近 100%(这 4 个请求只占 75 个位置)。
  4. 每个请求的 query 只能和它自己的历史 K、V 做注意力;拼在一起算就成了一个长序列,第二个请求的 token 会”看到”第一个请求的 token(因果掩码只挡住未来,不挡住别的请求),结果是错的。正确的拼法需要块对角的掩码,而且每个请求的历史长度不同、KV 不连续——这就是专门的注意力内核要处理的。
  5. ratio = 0:假设正在运行的请求不再需要显存,会过度接纳,解码到一半频繁撤回,浪费重算;ratio = 1:按最大长度预留,多数请求提前结束,预留的显存白白空着,并发度低。真实的输出长度事先未知且随负载变化,所以让它自适应。
  6. 树:根 → “ab” → {“c” → {“de”, “xy”}, “fg”}(插入 “abcxy” 时把 “abcde” 在 “abc” 处切开;插入 “abfg” 时再在 “ab” 处切开)。查找 “abcxz” 匹配 “ab” + “c” + “x” = 4 个 token;匹配停在 “xy” 这条边的中间,SGLang 的 match_prefix 会在这里把 “xy” 切成 “x” → “y”,让匹配的终点正好是一个节点(这样才能给”恰好这段前缀”加锁)。
  7. 请求在运行期间要一直读取前缀的 KV。如果不锁,显存紧张时 LRU 可能把它淘汰掉、那块显存被别的请求覆盖,这个请求后面的注意力就读到了错误的数据。锁(引用计数)保证”有人在用的节点不会被淘汰”。
  8. 全部 token:15 × 120 = 1800;不同的 token:3 × 100 + 15 × 20 = 600;最优命中率 1 − 600/1800 = 66.7%。按定理,缓存至少能放下最长的请求(120 个 token)即可。
  9. 前缀热度很不均时:所有热门请求都去同一个工作者,它排起长队,其他工作者空闲——即使命中率高,整体吞吐和时延都可能比轮流分差(04 号脚本:负载比 1.58)。负载均衡的阈值(例如”最忙的比最闲的多出一定的绝对量、且超过一定的比例”)触发时,改发给最空闲的工作者,牺牲一点命中率换回均衡。
  10. 阻塞式每步 8 ms,重叠式每步 max(5, 3) = 5 ms,吞吐之比 1.6。多算一个 token:每个请求平均 100 个有效 token 要 101 步,有效吞吐乘以 100/101,约 1.58 倍。

11. 延伸阅读 · Further reading

  • ORCA:Yu et al. (2022),迭代级调度与选择性批处理的出处。
  • SGLang:Zheng et al. (2024)——前端语言(gen、select、fork)、RadixAttention、压缩有限状态机的约束解码;SGLang v0.4 的博客介绍了零开销调度器和缓存感知负载均衡器。
  • vLLM:Kwon et al. (2023),第 24 讲;它的自动前缀缓存用”按块哈希”实现了类似的复用。
  • NanoFlow:Zhu et al. (2024),在一块 GPU 内部重叠计算、访存、通信与调度。
  • Sarathi-Serve / 分块预填充:Agrawal et al. (2024),解决”混合批里预填充拖慢解码”的问题,第 26 讲。
  • XGrammar:Dong et al. (2024),高效的结构化(约束)生成。

12. 下一讲预告 · Next lecture

服务的问题解决了一大半,可是用户常常想要一个”自己的”模型:在私有数据上微调过的版本。全量微调一个 70B 模型要上千 GB 显存,每个用户存一份完整的权重也不现实。第 23 讲讲参数高效微调:只训练很少的参数——adapter(以及 Lei Li 组的 CIAT)、LoRA 的低秩更新、QLoRA 的 4 位 NormalFloat 基座——为什么效果几乎不打折,以及一个服务器怎样同时为成百上千个 LoRA 适配器服务。

← 返回课程目录