SGLang 源码完全拆解(三):Scheduler 请求状态机如何闭环

8601 字
43 分钟
SGLang 源码完全拆解(三):Scheduler 请求状态机如何闭环

从入口消息进入调度器内部#

前两篇已经建立了两层坐标。第一篇 SGLang 源码完全拆解(一):整体架构、进程拓扑与请求数据流 说明 HTTP Server、TokenizerManager、Scheduler 与 DetokenizerManager 分属哪条进程流水;第二篇 SGLang 源码完全拆解(二):入口、分词与流式输出如何闭环 沿 request ID(简称 rid)追踪了协议适配、分词、IPC、增量反分词与客户端断连。现在请求已经以 TokenizedGenerateReqInput 的形态抵达 Scheduler,接下来要回答的是:它怎样成为一条可以跨越数百轮 GPU 前向的长期请求,又怎样在结束时把每一份资源交还给正确的所有者。

SGLang 最早由 LMSYS 团队在 2023 年 12 月公开,论文后续发表于 NeurIPS 2024。论文强调 RadixAttention、缓存感知调度与结构化程序执行;当前生产源码在此基础上增加了分页 KV、分块 prefill、overlap、投机解码和 P/D 分离等大量分支。功能一直在扩展,但 Scheduler 请求状态机仍围绕四个问题运转:请求是否可被选入本轮、已经计算到哪个 token、持有哪些 KV 与缓存引用、是否已经形成不可逆的完成原因。

SGLang 论文中的前端与运行时边界
SGLang 论文中的前端与运行时边界

图源:SGLang 论文 Figure 1。论文图把整个后端概括成 Runtime;在当前源码中,这个方框要继续展开成 Scheduler、ScheduleBatch、内存池、前缀缓存、模型执行器和结果处理器。本文只追普通自回归生成主线,并在必要处说明 chunked prefill、overlap 和 speculative decoding 为什么会让边界变复杂。

本文固定使用 SGLang 仓库提交 d88d5c。这很重要:主分支已经把部分结果提交逻辑从 scheduler.py 拆到 scheduler_components/batch_result_processor.py,若把不同版本的函数位置拼在一起,会得到一条实际上不存在的调用链。本文引用类名与不变量,不依赖容易漂移的行号。

先建立三层状态模型#

源码没有一个名为 RequestState.WAITING 的枚举。waiting、running 与 finished 是由“对象位于哪里”和“哪些字段已经提交”共同表达的逻辑状态。阅读时至少要分清三层:

层次代表对象生命周期回答的问题
长期请求Req从接收 tokenized message 到最终回收这条请求累计生成了什么、命中了什么、是否结束
本轮批次ScheduleBatch一次 extend 或 decode 前向这一轮选谁、算哪些 token、使用哪些 KV 位置
物理资源request/token pool 与 tree cache可短于或长于 Req请求槽、KV 槽和可复用前缀由谁拥有

Req 不是模型输入 DTO 的简单复制。它是 Scheduler 内部的长期账本,核心字段可以按职责分组:

class Req:
rid: str
origin_input_ids: array[int]
output_ids: array[int]
sampling_params: SamplingParams
prefix_len: int
last_node: object
kv: ReqKvInfo
finished_reason: BaseFinishReason | None
to_finish: BaseFinishReason | None
inflight_middle_chunks: int
is_retracted: bool

这里是按源码压缩后的结构,不是可直接运行的完整定义。origin_input_ids 是稳定输入,output_ids 按提交顺序只追加;prefix_len 与 last_node 记录缓存匹配;ReqKvInfo 保存请求槽、已保护缓存边界、已提交 KV 长度和已分配长度;finished_reason 表示完成已经生效,to_finish 表示完成意图尚待安全提交。

同一条 Req 可以先在 waiting_queue,再进入一次 extend ScheduleBatch,随后并入 running_batch,资源紧张时又被 retract 回 waiting。它的 Python 身份可以不变,但持有的物理资源会变化。因此状态机不能只画成容器之间的移动,还必须画上资源账本:

TokenizedGenerateReqInput
│ construct + validate
▼
Req in waiting_queue
│ admitted by PrefillAdder
▼
Req in EXTEND ScheduleBatch ── chunk incomplete ──> parked chunked_req
│ final prefill result committed
▼
Req in running_batch
│ each decode result commits token/KV
├── stop/length/abort ──> finished + release/checkpoint
└── KV pressure ────────> retract + release + waiting_queue

“finished”也不是离开列表那一刻才发生。结果处理器先把 token 写入 output_ids,再调用 update_finish_state() 形成 finished_reason,随后释放或缓存 KV;下一次 filter_batch() 才把该请求从活动批次移除。这个短暂窗口允许输出、指标与资源清理读取同一份完成证据。

可以用一个守恒式检查整个设计。设一条请求持有的运行时资源为 RR,其中包括请求槽 RreqR_{req}、私有 KV RkvR_{kv}、前缀树锁或引用 RtreeR_{tree}、多模态特征 RmmR_{mm} 和输出侧状态 RoutR_{out}:

R=Rreq+Rkv+Rtree+Rmm+RoutR = R_{req} + R_{kv} + R_{tree} + R_{mm} + R_{out}

请求从一个逻辑状态转到另一个逻辑状态时,不要求所有分量同时归零,但必须明确每个分量是转移、保留、变为可驱逐缓存,还是释放。所谓“状态机正确”,本质是控制状态与资源所有权始终一致。

TokenizedGenerateReqInput 如何变成 Req#

Scheduler.handle_generate_request() 接收的已经不是原始文本,而是入口层确定好的 token、采样参数、多模态描述、LoRA 身份、优先级与返回选项。普通非 session 请求的骨架可以缩成:

def handle_generate_request(msg):
req = Req(
rid=msg.rid,
origin_input_text=msg.input_text,
origin_input_ids=msg.input_ids,
sampling_params=msg.sampling_params,
stream=msg.stream,
lora_id=msg.lora_id,
priority=msg.priority,
vocab_size=model_config.vocab_size,
# 省略多模态、logprob、P/D 分离等参数
)
req.tokenizer = tokenizer
init_req_max_new_tokens(req)
validate_input_length(req)
process_grammar_or_enqueue(req)

这一步看似只是构造对象,实际划定了两个权威边界。第一,Scheduler 接受 input_ids 为计算事实,不再重新解释 chat template;第二,从此以后 token 计数、完成判断、KV 映射和调度优先级都以 Req 为准,入口层的 ReqState 只负责等待与响应聚合。

构造之后会先做不会消耗模型前向的验证:输入长度是否越界、logprob_start_len 是否合理、采样 mask 是否被当前后端支持、grammar 是否需要异步编译、多模态占位符展开后是否仍在限制内。为什么错误请求有时仍会被放进队列?因为某些失败路径使用统一的输出与回收协议:给 Req 设置 abort 原因,再让它通过便宜路径产生标准完成消息,比在每个校验分支各实现一套返回格式更容易保持一致。

不过,能够立即安全响应的错误会直接走 output streamer,不进入 GPU。判断标准不是“错误发生早晚”,而是请求是否已经把资源或异步工作交给其他组件。若 grammar 编译队列、缓存预取或多模态 transport 已经接手,就必须由对应组件参与收尾,不能简单 return 后丢掉引用。

_add_request_to_queue() 也不只是 append。在普通非 P/D 分离模式,它会完成优先级默认值或合法性检查、排队上限处理、可选 KV 预取,然后记录等待时间和到达时的已处理 token 计数:

def _add_request_to_queue(req, is_retracted=False):
validate_or_fill_priority(req)
maybe_reject_when_queue_is_full(req)
maybe_prefetch_kv(req)
waiting_queue.append(req)
req.time_stats.set_wait_queue_entry_time()
req.arrival_processed_tokens = processed_tokens_counter

最后两个字段服务于排队延迟与 aging。调度策略若只看静态优先级,持续到来的高优先级或高缓存命中请求可能让旧请求饥饿;保存进入队列时的进度坐标,策略才能估算等待期间系统已经完成了多少工作。

waiting queue:尚未运行不等于没有占用状态#

waiting queue 的含义是“尚未被当前 extend 批次接纳”,不是“完全没有准备工作”。队列中的请求可能已经完成 grammar 编译、发起层次缓存预取、获得 session 关联,甚至在 P/D 分离的 decode 节点上持有已传输的 KV。普通单机主线最简单,但源码必须兼容更宽的资源状态。

每次尝试构造新 prefill batch 时,Scheduler 先调用调度策略计算优先级,再让 PrefillAdder 做容量接纳。对每个候选请求,req.init_next_round_input(tree_cache) 会刷新完整输入并执行前缀匹配:

full_untruncated_fill_ids = origin_input_ids + output_ids
prefix match -> prefix_len, last_node, cached token locations
extend span -> [prefix_len, extend_end)

为什么要把 output_ids 也并入下一轮输入?第一次 prefill 时它通常为空;retract 后再次入队时,请求已经生成了一段输出,新一轮 extend 必须重建或加载“原 prompt + 已生成 token”的上下文,才能从相同位置继续解码。若只重新 prefill 原 prompt,模型会忘掉已经生成的后缀;若保留旧 KV 地址但这些地址已经释放,又会产生悬空引用。

前缀匹配最多通常只匹配到输入倒数第二个 token。最后一个 token 必须进入本轮计算,才能产生下一 token 的 logits;若整段输入全部只作为缓存读取而不执行任何新位置,就没有新的隐藏状态可供采样。返回 prompt logprob 时还要进一步限制最大可匹配长度,因为目标区间需要真正经过 logits 计算。

RadixAttention 中前缀插入、分裂、共享与驱逐
RadixAttention 中前缀插入、分裂、共享与驱逐

图源:SGLang 论文 Figure 3。蓝色路径表示被访问的缓存前缀,绿色表示新插入部分,红色表示被驱逐节点。对状态机而言,最关键的不是树形外观,而是节点引用计数:running 请求正在使用的节点不能被驱逐;waiting 请求只完成匹配还不等于已经永久锁住所有 KV。锁定时机若过早,会让大量排队请求压住缓存;若过晚,接纳过程中又可能发现命中节点已被驱逐。因此前缀匹配、容量检查与批次接纳必须由 Scheduler 串在同一个决策阶段。

PrefillAdder 同时检查至少三类预算:本轮 prefill token 数、request pool 槽位、KV pool 容量。用简化符号表示,候选集合 AA 必须满足:

∑i∈Aei≤Bprefill\sum_{i \in A} e_i \le B_{prefill}∣running∣+∣A∣≤Brequest|running| + |A| \le B_{request}Kused+Kextend(A)+Kdecode_reserve(A)≤KcapacityK_{used} + K_{extend}(A) + K_{decode\_reserve}(A) \le K_{capacity}

eie_i 是请求 ii 本轮未缓存的 extend token 数,BprefillB_{prefill} 是计算预算,BrequestB_{request} 是请求槽上限,最后一式则要求当前占用、新扩展 KV 与为未来 decode 预留的空间共同不越界。只检查第一式会造出“计算批次能跑,但写 KV 时溢出”的错误计划。

缓存感知排序试图让相近前缀的请求连续运行,减少反复驱逐。论文报告该策略在所测工作负载上平均达到最优缓存命中率的 96%,但它并不意味着所有线上请求都应该永久按最长前缀优先。生产调度还要加入优先级、公平性、LoRA 分组与等待 aging,否则热门前缀会持续挤压不相似请求。

从 waiting 到 running:一次 extend 提交改变了什么#

请求被 PrefillAdder 接纳后,会从 waiting_queue 移除并进入新的 ScheduleBatch。随后 prepare_for_extend() 分配 request pool 行和 KV 位置,构造本轮 input_ids、各请求的 extend 长度、序列长度与采样信息。此刻请求已经“在执行”,但还不能立即视为 running decode 请求,因为 GPU 结果尚未提交。

这个时间差可以分成四个时刻:

admit allocate launch commit
│ │ │ │
├─离开 waiting│ │ │
├─────────────┼─持有槽位/KV──┼─结果尚未可见─────┤
│ │ │ └─追加首个输出 token

ScheduleBatch 是这段在途状态的载体。普通串行循环里,launch 后立刻等待结果,时间差较短;overlap 模式会在结果未提交时开始安排下一轮,因此 last_batch、结果队列和 inflight_middle_chunks 都不能省略。下一篇之后会单独拆 overlap,本篇只坚持一个原则:只有结果处理器完成 commit,Req.output_ids 与 finished_reason 才代表已发生事实。

普通生成请求的最后一段 prefill 不只计算 prompt KV,还从最后位置 logits 中采样第一个输出 token。结果处理器的主顺序是:

req.output_ids.append(next_token_id)
req.update_finish_state()
if req.finished():
release_kv_cache(req, tree_cache, checkpoint=True)
else:
checkpoint_kv_cache(req, tree_cache)

为什么首 token 要在 prefill 结果处理中写入,而不是等第一次 decode?因为 prefill 已经产生了下一 token 分布,再做一次 decode 才取首 token会重复计算或错开位置。提交后,未结束请求随 extend batch 在下一轮 get_next_batch_to_run() 中被过滤,再合并进 running_batch;已在首 token 就命中 EOS、长度上限或停止条件的请求不会进入持续 decode。

分块 prefill 会打破“一次 extend 后就 running”的直觉。长 prompt 可能被切成多段,中间 chunk 只提交 KV,不应采样用户可见 token,也不能流式输出。inflight_middle_chunks 记录尚未结清的中间段;chunked_req 把暂未完成的请求停放在独立位置。只有最后一段完成,结果处理器才追加首 token并允许它并入 running batch。

这里还隐藏着一个重要所有权转变:prefill 前命中的前缀属于 tree cache,请求新写出的后缀先属于活动请求;checkpoint 或完成释放时,适合复用的部分可以插入树,变成缓存持有。KV tensor 未必移动,变化的是“谁有权阻止它被回收”。

running batch:每轮 decode 怎样推进长期请求#

running_batch 保存已经完成 prefill、可以持续 decode 的请求集合。每轮 update_running_batch() 先过滤已完成或不应再执行的行,检查下一轮 KV 是否装得下,必要时 retract,再调用 prepare_for_decode() 更新张量元数据。普通非投机解码中,每个请求本轮扩展一个位置;投机解码可能一次提交多个被验证 token,但状态提交原则相同。

结果返回后,SchedulerBatchResultProcessor.process_batch_result_decode() 才推进长期状态。简化后的关键循环是:

for req, accepted_ids in zip(batch.reqs, next_token_ids):
if overlap and (req.finished() or req.is_retracted):
continue
req.output_ids.extend(accepted_ids)
req.update_finish_state(len(accepted_ids))
if req.finished():
release_or_checkpoint_kv(req)
stream_output(batch.reqs)

output_ids 采用只追加契约。refresh_fill_ids() 会根据长度判断完整输入里已经包含多少输出 token;如果某个优化在原地改写等长后缀,长度仍然一致,刷新逻辑却无法察觉内容变化,retract 后重建上下文就会静默错误。因此 speculative verification 可以决定“接受多少个”,但一旦提交到 output_ids,历史 token 不能随意改写。

一次 decode 前向还会同时推进三本账:

  • 序列账:output_ids 增长,下一轮 position 与 sequence length 随之变化;
  • KV 账:新位置从已分配变成已提交,可能还存在预分配但未使用的尾部;
  • 输出账:只有通过停止条件与增量解码边界的部分才能发给客户端。

三者不能简单用一个 len(output_ids) 代替。投机解码可能预留多个 KV 位置却只接受一部分;overlap 可能已经为下一轮分配空间,而上一轮 token 尚未在 CPU 提交;停止串可能在一组 accepted token 中间命中,后面的 token 已计算但不应对用户可见。源码中的 finished_len、kv_committed_len、kv_allocated_len 与 output streamer offset 正是在表达这些不同边界。

完成判断:谁有权宣布请求结束#

完成判断属于 Req,调用时机属于结果处理器。原因很直接:停止条件依赖请求自己的采样参数、tokenizer、已提交输出和 grammar 状态,但只有结果处理器知道新 token 已经从 GPU 结果变成可提交事实。模型执行器不能提前释放资源,HTTP 层也不能靠看到一段文本来猜 Scheduler 是否结束。

当前 update_finish_state(new_accepted_len) 的优先顺序值得逐项理解:

  1. 若 to_finish 已设置,先把延迟完成意图转成 finished_reason;
  2. 检查越界或非法 token,防止 tokenizer 接收无效 ID;
  3. 检查 stop string 与 stop regex;
  4. 检查 EOS 和 stop token ID;
  5. 检查 max_new_tokens;
  6. 检查 grammar 是否到达终态。

stop string 为什么排在 stop token 和长度上限之前?普通单 token decode 时差别不大,投机解码一次却可能接受多个 token。假设一组接受 token 先拼出停止串,稍后又遇到 EOS;若先以最后一个 EOS 结束,只裁掉 EOS,就可能把本应隐藏的停止串泄漏给客户端。finished_len 记录真正应显示到哪个输出 token,而不是盲目采用 len(output_ids)。

同理,停止条件若落在 max_new_tokens 之后,最终仍要把可见边界压回长度上限。完成原因不仅是一个给 API 显示的字符串,它决定输出裁剪、KV 可保留长度和统计口径。源码中的 _cap_finished_len_at_max_new_tokens() 就是在处理“一次接受多个 token 同时跨过多个边界”的冲突。

finished_reason 与 to_finish 的分离尤其关键。finished_reason is not None 会让 filter_batch() 直接移除该请求;若在事件循环中途处理 abort 时立即设置它,而这一轮仍有在途 GPU 结果,后续结果提交和输出确认可能再也看不到该请求。to_finish 先记录意图,等统一结果提交点调用 update_finish_state(),清理路径便能复用正常完成流程。

完成后的顺序通常是:收集必要的 routed-expert 或 indexer 输出,处理多模态特征,释放或 checkpoint KV,记录 completion time,最后由 output streamer 发送完成消息。随后下一次 ScheduleBatch.filter_batch() 才删除已完成行,并同步裁剪 req_pool_indices、seq_lens、采样状态与 speculative metadata。只从 reqs 列表删除而不裁剪张量,会让下一轮批次第 ii 行指向错误请求。

Abort:从外部控制消息收敛到内部清理#

Abort 的困难不是设置一个布尔值,而是同一个 rid 可能位于多个位置:普通 waiting queue、grammar queue、正在分块的 chunked_req、running batch、overlap 的 last_batch 或 result queue;P/D 分离还会增加 bootstrap、prealloc 与 transfer queue。Scheduler.abort_request() 因此按可能的所有位置查找,而不是维护一个假定永远准确的单一字典位置。

对普通主线,abort 至少有三种处理方式。

waiting 请求:直接弹出并回传#

尚未开始模型计算的请求可以从 waiting_queue 直接移除。Scheduler 释放缓存预取句柄与多模态 transport,向 TokenizerManager 发送 abort 输出。此时通常没有 request pool 行和私有 KV,因此不需要伪造一次 GPU 前向。

for req in matched_waiting_reqs:
waiting_queue.remove(req)
release_aborted_request(req)
send_abort_output(req)

“waiting 没有 KV”只对基础单机路径大体成立。decode 分离模式下,waiting 请求可能已经持有传输来的 KV,源码会额外调用 release_kv_cache();Mamba 状态、多模态特征和缓存预取任务也各有独立释放逻辑。判断是否释放资源应查看字段所有权,而不能只看队列名称。

running 请求:设置 to_finish,等待安全提交点#

正在 running 或仍位于 overlap 在途批次的请求不能粗暴删除。源码给它设置 FINISH_ABORT 到 to_finish:

for req in collect_inflight_reqs():
if matches(req, abort_message) and not req.finished():
req.to_finish = FINISH_ABORT()

这条请求通常还会经过一个结果提交点。update_finish_state() 先消费 to_finish,随后与自然停止共用 KV 释放、完成时间和输出回传路径。注释里所说“仍会跑一个 decode forward”并非为了得到有用 token,而是为了保持已经发出的批次、collective 顺序和资源账本闭合。尤其在 TP 或 overlap 下,单个 rank 擅自把请求从本轮张量中删除,可能让各 rank batch 形状不一致。

grammar 或 chunked 请求:保持中间账本可排空#

grammar 尚未准备好时,请求不在普通 waiting queue;实现可以把它改造成极短的 abort 请求,让统一输出路径返回错误。chunked prefill 更棘手:某个 chunk 可能已经 launch,inflight_middle_chunks 尚未归零。源码会记录 pending chunked abort,等在途 chunk 的统计和 KV 边界可安全处理后再释放。若立即把 chunked_req 设为 None,已经返回的结果会找不到所属请求,或者中间 chunk 计数永远无法减回零。

Abort 因而是一种跨组件资源回收协议。入口发送 AbortReq 只是提出取消;Scheduler 形成 FINISH_ABORT 并完成自己的账本;Detokenizer 与入口层看到最终消息后,再删除各自按 rid 保存的状态。TCP 断开本身不会自动完成后三步。

Retract:资源不足时为什么不是请求失败#

decode 每轮都要为每个活跃请求追加 KV。即使 prefill 接纳时预留了余量,实际输出长度仍不可预测;随着 running batch 推进,下一步可能无法为所有请求分配新位置。update_running_batch() 会在 prepare_for_decode() 之前调用 check_decode_mem(),容量不足时执行 retract_decode()。

Retract 的语义是“暂时把一部分请求从 running 退回 waiting,以后重建上下文继续”,并不等于 abort。基础流程如下:

running Req
│ choose victim
├─ release private KV without checkpointing
├─ release request pool row and cache lock
├─ reset prefix/allocation metadata
├─ keep origin_input_ids + committed output_ids
└─ append back to waiting_queue

释放时使用 checkpoint=False,因为 Scheduler 正是为了立刻获得空间。若先把受害请求的全部新 KV 插入 radix tree,再期待 LRU 稍后驱逐,内存可能仍无法及时腾出,而且额外树操作没有收益。实现还会按剩余请求数主动驱逐一定量的可驱逐缓存,使保留下来的 decode 请求至少能再前进若干步,而不是下一轮立刻再次触发 retract。

reset_for_retract() 会清零 prefix_len、last_node、request/KV 分配状态、批次计数与临时 logprob buffer,并把 is_retracted、retracted_stain 和 retraction_count 更新为相应值。它不会清空普通 token 输入与已提交 output_ids。重新入队后,init_next_round_input() 形成:

Tresume=Tprompt∥TgeneratedT_{resume} = T_{prompt} \Vert T_{generated}

TpromptT_{prompt} 是原始输入 token,TgeneratedT_{generated} 是 retract 前已经提交并可能已经发送给客户端的输出 token,∥\Vert 表示连接。Scheduler 对 TresumeT_{resume} 再做前缀匹配;仍在 tree cache 的部分可以命中,其余部分重新 extend。这样恢复不会改变用户已经看到的输出。

input_embeds 是例外。自定义 embedding 与后来生成的 token ID 不能简单拼成同一种输入表示;当前源码在 retract reset 时会丢弃已生成输出并从头开始,以保证 KV 索引与 embedding 长度一致。这也暴露了一个工程风险:流式输出若已经向客户端发送,内部重启生成必须同步重置发送 offset,否则可能把两次生成拼成一个看似成功的响应。阅读 retract 分支时,不能只验证 GPU 不 OOM,还要验证用户可见输出是否保持单调一致。

受害者选择默认参考已生成长度与原 prompt 长度,并可叠加 priority policy。直觉是保留已经生成更久的请求,因为撤回它会浪费更多重算工作;在相同生成进度下,较短 prompt 的恢复代价通常更小。源码将排序索引从末尾弹出,因此一定要连同排序方向一起读,不能看到 key 就断言谁先被撤回。

至少保留一个请求并非绝对保证。如果只剩最后一条请求仍无法为下一 token 分配 KV,继续执行只会在 allocator 中崩溃;当前实现会把它转成带 OOM 信息的 FINISH_ABORT,释放资源并返回服务错误。Retract 是容量调节手段,不可能让单请求上下文本身突破物理容量。

在 decode 分离模式下,retract 还可以先把 KV 备份到 host,之后恢复而不是重算;若 host backup 失败,请求可能被 abort。这个分支改变恢复成本,却不改变状态机原则:退回 waiting 前,设备 KV 的所有权必须解除,恢复所需事实必须由 token 序列或备份之一可靠保存。

正常完成、Abort 与 Retract 的资源账本#

三条路径最容易混淆之处在于,它们都会让请求离开当前 running batch,但对输出与缓存的语义不同:

路径用户输出是否完成output_ids 是否保留私有 KV前缀树/缓存后续是否再入队
自然完成是保留到最终响应结束释放可 checkpoint 为可复用缓存否
Abort是,以 abort 原因结束只保留清理所需边界释放通常按 abort 语义结束,不应留下错误状态否
Retract否保留已提交输出立即释放或先备份重新匹配,不能继续持有旧锁是

“释放 KV”也有两种不同结果。物理页可以回到 allocator,或者请求完成后的有效前缀被登记进 radix tree,仍占物理页但变成可驱逐缓存。后者不是泄漏:它失去运行请求的强引用后,可在新请求需要空间时被 LRU 驱逐。相反,若 request pool 行已经释放但树引用计数没有解锁,那才会形成不可驱逐的泄漏。

SGLang 的缓存命中率与组件消融
SGLang 的缓存命中率与组件消融

图源:SGLang 论文 Figure 8。左侧展示不同工作负载下的缓存命中率,右侧展示 RadixAttention、压缩 FSM 等组件对吞吐的贡献。它提示了状态机设计中的核心张力:完成后保留 KV 能提高后续命中,但运行请求又必须在内存压力下优先获得容量。Scheduler 通过“活动引用不可驱逐、完成缓存可驱逐、retract 私有 KV 立即释放”把两类目标放入同一内存池。

可以为每条请求做四项不变量检查:

  • 若 Req 在 running batch 中,则它的 request pool 行必须有效,所需 KV 映射必须可读;
  • 若 finished_reason 已设置,则不会再为它安排新的有效 decode;
  • 若 retract 后回到 waiting,则不能继续持有旧 request pool 行或旧 tree lock;
  • 若请求已向入口发送最终消息,则 Scheduler 最终不能再保留不可驱逐的请求私有资源。

这四项比“队列长度看起来正常”更能发现泄漏和 use-after-free。

用伪代码串起状态机#

下面的伪代码省略 grammar、beam、P/D 分离和 overlap,却保留状态迁移与提交顺序:

def on_generate(msg):
req = build_and_validate_req(msg)
waiting_queue.append(req)
while True:
ingest_control_messages() # generate / abort / management
# 先让上轮完成的 extend 请求具备进入 running 的资格
merge_committed_extend_into_running()
prefill = build_prefill_batch(waiting_queue, running_batch)
if prefill is not None:
result = run_forward(prefill)
for req, token in zip(prefill.reqs, result.next_tokens):
if req.is_final_prefill_chunk:
req.output_ids.append(token)
req.update_finish_state()
release_if_finished_else_checkpoint(req)
stream_outputs(prefill.reqs)
continue
remove_finished_rows(running_batch)
if not decode_capacity_ok(running_batch):
victims = running_batch.retract_decode()
for req in victims:
waiting_queue.append(req)
if running_batch:
prepare_for_decode(running_batch)
result = run_forward(running_batch)
for req, accepted in zip(running_batch.reqs, result.accepted_tokens):
req.output_ids.extend(accepted)
req.update_finish_state(len(accepted))
release_if_finished(req)
stream_outputs(running_batch.reqs)

真实 get_next_batch_to_run() 通常优先尝试新 prefill;若没有可运行的新 batch,再推进 running decode。所谓“prefill 优先”并非无条件让长 prompt 独占 GPU:PrefillAdder 的 token 预算、chunking、batch_is_full、prefill delayer 与 mixed chunk 策略共同约束干扰。下一篇会专门拆这些调度策略。

这段伪代码还刻意把 remove_finished_rows() 放在下一轮。结果处理、输出与资源释放需要访问刚完成的 Req;立即从所有结构消失,反而会让收尾缺少证据。批次过滤是一个同步更新 reqs 与各行 tensor 的结构操作,应当发生在明确的调度边界。

关键源码阅读与调试路径#

第一遍建议关闭 overlap、投机解码、P/D 分离和 chunked prefill,只发一条短请求。沿固定 rid 观察以下函数:

Scheduler.handle_generate_request
-> Scheduler._add_request_to_queue
-> Req.init_next_round_input
-> Scheduler.get_new_batch_prefill
-> PrefillAdder.add_one_req
-> ScheduleBatch.prepare_for_extend
-> SchedulerBatchResultProcessor.process_batch_result_prefill
-> Scheduler.get_next_batch_to_run # merge into running
-> Scheduler.update_running_batch
-> ScheduleBatch.prepare_for_decode
-> SchedulerBatchResultProcessor.process_batch_result_decode
-> Req.update_finish_state
-> ScheduleBatch.filter_batch

不要一开始进入 Transformer layer。每站记录这些字段就能验证控制流:

观察点字段应满足的关系
刚入队len(origin_input_ids)、len(output_ids)输出通常为 0
前缀匹配后prefix_len、extend_end0 <= prefix_len < extend_end
extend 分配后request pool index、KV allocated len所有批内行都有合法映射
prefill 提交后output_ids[-1]、finished_reason首 token 已提交或请求已结束
decode 准备后seq_lens、out cache location每个活跃请求有本轮新位置
decode 提交后output/KV committed length仅按已接受 token 推进
完成后finished_len、finish reason可见边界不超过长度上限
过滤后batch size 与所有行 tensor第一维保持一致

可以用符号搜索而非固定行号导航:

Terminal window
rg -n "class Req|def (init_next_round_input|update_finish_state|reset_for_retract)" \
python/sglang/srt/managers/schedule_batch.py
rg -n "def (handle_generate_request|get_next_batch_to_run|get_new_batch_prefill|update_running_batch|abort_request)" \
python/sglang/srt/managers/scheduler.py
rg -n "def process_batch_result_(prefill|decode)" \
python/sglang/srt/managers/scheduler_components/batch_result_processor.py

为了验证 retract,可以临时使用源码中的测试开关或人为缩小 KV 容量,但应固定采样,使重算前后 token 序列可比较。至少验证三点:已发送文本没有重复,retract 前的 output_ids 被恢复路径保留,旧 request pool 行已经释放且重新接纳后得到的行可以不同。

为了验证 abort,应覆盖 waiting、running 和 chunked prefill 三个时刻。记录 abort 消息到达时请求所在容器、to_finish 何时变成 finished_reason、最终输出是否只发送一次、KV available size 是否恢复。只测试 waiting abort 会漏掉最危险的在途结果竞态。

内存问题要同时看三组数字:request pool 可用行数、KV allocator 可用 token/page 数、tree cache 中可驱逐 token 数。request pool 耗尽与 KV pool 耗尽会触发不同路径;大量缓存页存在也不代表能释放,因为活动引用锁住的节点不可驱逐。

设计边界与常见误读#

误读一:Req 自己存着 waiting/running 枚举#

基础源码主要用容器归属表达调度状态,用字段表达完成与资源边界。同一个 Req 可以在 retract 后回到 waiting。调试“它是什么状态”时,要同时查看 waiting_queue、running_batch、last_batch、result queue 和 chunked_req,不能只打印对象字段。

误读二:prefill 完成后,首 token 还没生成#

生成模型的最后一个 prompt 位置已经产生下一 token logits。普通生成路径会在 prefill 结果处理中追加首个输出 token。后续 decode 才是一轮轮以已生成 token 推进 KV。把首 token 错放到第一次 decode 会造成 position 与 KV 长度整体错一位。

误读三:finished 后立刻从 batch 删除最干净#

完成请求仍要参与结果输出、finish reason 映射、KV 释放、指标记录和可能的缓存 checkpoint。统一在下一调度边界过滤,能让这些动作读取一致状态;关键是过滤时同步裁剪所有按 batch 行排列的 metadata。

误读四:Abort 可以复用 Retract#

Abort 是终止,必须给上游明确完成原因且不能再次调度;retract 是暂停,必须保留恢复所需 token 或 KV 备份并重新入队。二者都释放设备资源,但输出协议和持久状态完全不同。

误读五:Retract 后从原位置继续,不需要 prefill#

如果设备 KV 已释放且没有可恢复备份,模型无法只凭最后一个 token 继续。请求必须把 prompt 与已生成 token 组成完整上下文,再通过缓存命中或重算恢复 KV。output_ids 保留的是语义历史,不是注意力状态本身。

误读六:tree cache 与 running KV 是两块固定显存#

SGLang 让缓存和活动请求共享物理池。区别主要在所有权与可驱逐性:活动请求依赖的 KV 不可驱逐,完成前缀缓存可以驱逐。这样空闲容量能自动转成缓存,流量上升时缓存又能让位给 batch。

误读七:check_decode_mem 只是一个估算提示#

它是 allocator 前的容量门。若不满足就必须缩小 batch、retract 或 abort;继续 prepare_for_decode() 会把可恢复的调度压力变成分配失败。容量检查与实际 allocator 必须使用一致的 token/page 口径,特别是投机解码一次可能需要多个位置。

边界条件#

下面几类情况最容易破坏状态机假设:

  • max_new_tokens=0 的 prefill-only 请求不会进入普通 decode;
  • prompt 首轮就命中停止或错误条件,可能从未进入 running;
  • speculative decoding 一轮接受多个 token,完成位置可能位于接受序列中间;
  • overlap 中请求已经逻辑完成,但旧批次仍有一轮 overshoot 结果返回;
  • chunked prefill 被 abort 时,中间 chunk 的计数必须先排空;
  • input_embeds retract 无法直接与生成 token IDs 拼接;
  • P/D 分离的 waiting 请求可能已经持有 KV 或 host backup;
  • beam group 可能不支持普通 retract,只能整组 abort;
  • session 请求的缓存与多模态历史有独立生命周期,不能按普通请求全部清空。

这些分支看似零散,其实都在挑战同一条规则:一个逻辑请求可以跨多轮与多组件存在,但任何物理资源在任一时刻只能有明确所有者。

小结与后续路线#

SGLang Scheduler 没有把请求状态机压进一个枚举,而是让 Req、ScheduleBatch 和资源池共同表达状态。TokenizedGenerateReqInput 到达后被固化为长期 Req;waiting 阶段完成验证、缓存匹配与容量候选准备;extend 结果提交首 token 后,请求才具备进入 running decode 的条件;每轮 decode 都按“分配、执行、提交、完成检查、输出”的顺序推进。

正常完成、Abort 与 Retract 是三条不同的资源收敛路径。正常完成产生最终输出,并可把有效 KV 变成可驱逐缓存;Abort 产生终止原因,通过 to_finish 在安全提交点复用统一清理;Retract 保留语义历史、释放设备状态并重新入队。只要分清“token 已提交”“KV 已分配”“KV 已提交”“文本已发送”四条边界,就能解释大部分看似复杂的条件分支。

SGLang 源码系列当前进度如下:

序号主题状态
一整体架构、进程拓扑与请求数据流已完成
二Entrypoints、TokenizerManager 与 DetokenizerManager已完成
三Scheduler 请求状态机本篇
四PrefillAdder、调度策略与 chunked prefill下一篇
五KV 内存池与当前 Radix Cache 源码待写
六Overlap Scheduler待写
七ForwardBatch、TpModelWorker 与 ModelRunner待写
八Attention Backend待写
九CUDA Graph 与编译路径待写
十Sampling、Grammar 与结构化输出待写
十一投机解码待写
十二TP 与 PP待写
十三DP Attention、EP 与 MoE待写
十四Prefill/Decode 分离与分布式 KV待写

下一篇将把本篇刻意压缩的“接纳”过程完全展开:SchedulePolicy 如何排序 waiting queue,PrefillAdder 怎样同时核算 prefill token、request slot 与未来 decode KV,长 prompt 为什么要 chunk,以及 mixed chunked prefill 怎样在同一 forward 中容纳 extend 与 decode。

参考资料#

  1. SGLang: Efficient Execution of Structured Language Model Programs(arXiv)
  2. SGLang 论文(NeurIPS 2024)
  3. SGLang 官方 GitHub 仓库
  4. Scheduler 源码(固定提交)
  5. Req 与 ScheduleBatch 源码(固定提交)
  6. PrefillAdder 与调度策略源码(固定提交)
  7. Batch Result Processor 源码(固定提交)
  8. Mini-SGLang 官方结构说明
  9. RadixAttention 完全拆解:用前缀树自动复用 KV Cache
  10. 连续批处理完全拆解:Orca 的迭代级调度与选择性批处理

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

SGLang 源码完全拆解(三):Scheduler 请求状态机如何闭环
https://pinghaoyang.com.cn/aigc/posts/sglang-scheduler-state-machine/
作者
平昊阳
发布于
2026-10-13
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
212
分类
26
标签
291
总字数
2,407,536
运行时长
0 天
最后活动
0 天前

文章目录