源码精读:Scheduler 主循环
谁该读这一篇? 想真正啃 SGLang 源码、或者准备给调度逻辑提 PR / 排查生产事故的工程师。 前置阅读:
02-core-concepts/01-radix-attention.md、02-core-concepts/03-cache-aware-scheduling.md、01-overview/02-architecture.md。最好先看03-code-walkthrough/02-tokenizer-manager.md了解请求怎么进入 Scheduler。 耗时: 60 分钟 学完能: 1. 在 IDE 里打开scheduler.py时不会被 3900 行吓退,能直接定位到主线; 2. 讲出一次event_loop_normal迭代里发生的 6 件事; 3. 区分 normal loop 和 overlap loop 的设计动机; 4. 看到一个调度异常(队列堆积、抢占、cache miss)能在源码里定位到位置; 5. 知道哪些字段 / 数据结构是关键,哪些是历史包袱可以暂时跳过。
1. 第一步:把 3900 行的导航地图建起来
打开 sglang/python/sglang/srt/managers/scheduler.py,先 grep "^class \|^def " 一遍。
你会看到:
287 class Scheduler( ... multiple mixins ... )
297 def __init__(...)
1351 def run_event_loop(self) -> None:
1372 def event_loop_normal(self):
1399 def event_loop_overlap(self):
2348 def get_next_batch_to_run(self) -> Optional[ScheduleBatch]:
2907 def run_batch(...)
3084 def process_batch_result(...)
3749 def dispatch_event_loop(scheduler: Scheduler):
3780 def configure_scheduler_process(...)
3837 def run_scheduler_process(...)
主线只有 5 个函数:
run_scheduler_process (3837) ← 进程入口
└── dispatch_event_loop (3749)
└── event_loop_normal / event_loop_overlap ← 主循环
├── recv_requests + process_input_requests
├── get_next_batch_to_run (2348) ← 决定本步喂啥
├── run_batch (2907) ← 触发 GPU 前向
└── process_batch_result (3084) ← 把结果回流
其他 3000+ 行都是这 5 个函数的展开 + mixin 的辅助代码(disaggregation、PP、tokenizer 控制等)。 先理解这条主线,剩下的随用随查。
2. Scheduler 类签名(看一眼就好)
class Scheduler(
SchedulerInputBlockerMixin,
SchedulerPPMixin,
SchedulerRecvSkipperMixin,
DisaggService,
...
):
...
多重继承的 mixin 在这里是功能切片——不同部署模式(PP、PD 分离、tokenizer 控制等)需要不同的辅助方法。 初读时,这些 mixin 都先当不存在。等读到某个具体功能时再去 mixin 里找。
3. __init__:调度器的"出生证"(287-1351)
这一大段 ~1000 行的初始化主要在:
- 解析参数:
ServerArgs/PortArgs/ model config / TP 拓扑等。 - 绑定 ZMQ socket:和 TokenizerManager、DetokenizerManager 的通信端口。
- 建 ModelWorker:在 GPU 上初始化 model runner、attention backend、sampler。
-
建内存池和 RadixCache:
python self.tree_cache: BasePrefixCache = RadixCache(params)注意类型是BasePrefixCache,运行时可能根据配置换成HiRadixCache、ChunkCache、MambaRadixCache等。 -
建调度策略:
python self.policy = PrefillAdder(...)引入schedule_policy.py,cache-aware 排序的逻辑在那里。 -
初始化队列: -
self.waiting_queue: List[Req]—— 排队等 prefill 的请求。 -self.running_batch: ScheduleBatch—— 正在 decode 的请求。 -self.chunked_req—— 当前被 chunk-prefill 切碎、还没跑完的请求。 - 可选功能:spec decoding、disaggregation、PP、HiCache 异步线程等。
初读时把
__init__当黑盒;第二遍读时再回来按需深入。
4. event_loop_normal:主循环只有 6 件事(1372-1397)
def event_loop_normal(self):
"""A normal scheduler loop."""
while True:
# 1. Receive requests
recv_reqs = self.request_receiver.recv_requests()
self.process_input_requests(recv_reqs)
if self._engine_paused:
continue
# 2. Get the next batch to run
batch = self.get_next_batch_to_run()
self.cur_batch = batch
# 3. Launch the current batch
if batch:
result = self.run_batch(batch)
self.process_batch_result(batch, result)
else:
# 4. When the server is idle, do self-check
self.on_idle()
# 5. Update last_batch
self.last_batch = batch
if envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.get():
self.invariant_checker.self_check_during_busy()
逐行解读:
| # | 步骤 | 关键调用 | 干什么 |
|---|---|---|---|
| 1 | 收新请求 | request_receiver.recv_requests() + process_input_requests |
从 ZMQ 取新请求,做 match_prefix,加入 waiting_queue |
| 2 | 决定本步 batch | get_next_batch_to_run() |
cache-aware 排队 + token budget 控制,产出 ScheduleBatch |
| 3 | 跑前向 | run_batch(batch) |
调 ModelWorker 在 GPU 上做 forward + sampler |
| 3' | 处理结果 | process_batch_result(batch, result) |
把 token id 送回 DetokenizerManager;finished 的请求 ref_count-- |
| 4 | 空闲自检 | on_idle() |
metric flush、warmup 检查、显存碎片整理等 |
| 5 | 状态更新 | self.last_batch = batch |
给下一轮判断 prefill→decode 切换用 |
记住这 6 件事。接下来章节里你看到任何细节,都能映射到这 6 件事之一。
5. get_next_batch_to_run:调度的核心决策(2348+)
主线(节选):
def get_next_batch_to_run(self) -> Optional[ScheduleBatch]:
self._abort_on_waiting_timeout()
self._abort_on_running_timeout()
# 1. 处理 chunked req:上一步没跑完的,要不要继续切?
chunked_req_to_exclude = set()
if self.chunked_req is not None:
chunked_req_to_exclude.add(self.chunked_req)
if self._chunked_req_scheduled_last_iter:
self.stash_chunked_request(self.chunked_req)
# 2. 把 last_batch(上一轮的 prefill)和 running_batch 合并
if self.last_batch and self.last_batch.forward_mode.is_extend():
...
self.running_batch.merge_batch(self.last_batch)
# 3. 决定本轮要不要做新的 prefill
new_batch = self.get_new_batch_prefill() # ← cache-aware policy 在这里
# 4. 如果有新 prefill,就跑 prefill;否则继续 decode running_batch
if new_batch is not None:
return new_batch
return self.update_running_batch(self.running_batch)
关键设计点:
-
Prefill 优先于 Decode:只要 waiting_queue 有请求能塞进 token budget,本轮就做 prefill; 剩下能力才给 decode。这与 vLLM v1 的策略一致。
-
Chunked Prefill 状态机:长 prompt 切成多 chunk,每 chunk 占一轮,chunk 之间穿插 decode。
self.chunked_req记录"正在被切的请求"。 -
Cache-aware 排序发生在
get_new_batch_prefill内部,最终调用schedule_policy.py的PrefillAdder.add_one_req,它按"命中长度"作为分数排序。
这里有大量 mixin / 分支处理(dllm、hisparse、disaggregation),初读时全部跳过。 等需要排查具体场景再回来读。
5.1 token budget 的两条线
调度的"上限"由两个量控制:
max_running_requests:同时能跑的请求数(防止 ZMQ / sampler 资源耗尽)。max_total_num_tokens或max_prefill_tokens:本步喂进 GPU 的 token 数(防止显存爆 / 时延超 SLO)。
每轮 get_new_batch_prefill 先估算"running batch 已经占了多少 token",剩下的预算才给新 prefill。
这就是为什么 chunked prefill 存在 —— 一个 100k token 的 prompt 不能一口气塞进一轮(会饿死所有 decode),切成 8 段、每段 12k 让 decode 间隙穿插。
5.2 prefill 和 decode 的"耦合"
SGLang 默认 同 batch 内 prefill + decode 共跑(chunked prefill 模式):
prefill 部分提供算力压力,decode 部分提供带宽压力 —— 两者互补、GPU 利用率最高。
这是 SGLang 和 vLLM 共享的设计,也是为什么 P/D 分离(disaggregation)只在特定 SLO 场景才值得做(见 05-distributed/04-disaggregated.md)。
6. run_batch:触发 GPU 前向(2907+)
def run_batch(self, batch, pp_proxy_tensors=None):
self.forward_ct += 1
batch.forward_iter = self.forward_ct
self.profiler_manager._profile_batch_predicate(batch)
if batch.forward_mode.is_prebuilt():
return self._run_batch_prebuilt(batch)
if self.is_generation:
if self.enable_overlap:
...
batch_result = self.model_worker.forward_batch_generation(batch, ...)
else:
batch_result = self.model_worker.forward_batch_generation(batch, pp_proxy_tensors)
主要工作 不在这一层——它只是个调度器到 ModelWorker 的薄包装。真正的前向在 model_runner.py,详见 04-model-runner.md。
有意思的点:overlap 模式。
默认 enable_overlap 时,run_batch 在前向开始后立刻返回,让调度器在 GPU 还在跑的同时去准备下一批。这要靠两件事:
future_map:记录"下一批要用到的张量",用 stream event 等到上一批结束才真正用。- 双 CUDA stream:
schedule_stream(调度计算)和forward_stream(前向计算)并行。
sequenceDiagram
participant S as Scheduler
participant FS as forward_stream (GPU)
participant SS as schedule_stream (GPU)
S->>FS: launch forward(batch_1)
Note over S: 立即返回,开始准备 batch_2
S->>SS: prepare batch_2 (match_prefix, alloc 等)
Note over SS: batch_2 prep 与 batch_1 forward 并行
FS-->>S: batch_1 done
S->>S: process_batch_result(batch_1)
S->>FS: launch forward(batch_2)
收益:TPOT 降一截(调度准备的开销不再阻塞 GPU 前向)。
代价:内存生命周期复杂,容易出"用了被回收的张量"的并发 bug——这是 extra_keep_alive_refs 和 batch_record_buf 的来历。
7. process_batch_result:回流(3084+)
主要工作:
- 取 token id:
batch_result.next_token_ids是采样器吐出的下一个 token。 - 写入 RadixCache:finished 的请求 / 新算的 KV →
tree_cache.insert(...)。 - 更新 running_batch:finished 的请求从 batch 里摘除;ref_count--;显存回收。
- 发给 Detokenizer:通过 ZMQ 把 token id 推给 DetokenizerManager 进程。
- 记录 metrics:TPOT 直方图、batch size、cache hit 等。
读到这里你就能回答:"为什么 process_batch_result 必须在 run_batch 之后立即跑?"
—— 因为 next_token_ids 是下一轮 decode 的输入;不立刻塞回 running_batch.input_ids,下一轮就没东西可算了。
8. 一次完整迭代的时序图
sequenceDiagram
participant Q as ZMQ recv
participant S as Scheduler
participant RC as RadixCache
participant SP as SchedulePolicy
participant W as ModelWorker
participant DT as Detokenizer
loop event_loop_normal
Q->>S: recv_requests()
S->>RC: match_prefix(new req)
RC-->>S: device_indices + last_node
S->>S: waiting_queue.append(req)
S->>SP: get_new_batch_prefill()
SP->>RC: 查命中长度算 score
SP-->>S: ScheduleBatch (排序后)
S->>W: run_batch(batch)
W->>W: forward + sampler
W-->>S: next_token_ids
S->>RC: insert(new KV)
S->>DT: forward token ids
S->>S: update running_batch
end
这张图请反复回看——后续所有源码章节都是这张图的某条边的展开。
9. 常见"看不懂"的代码段:给你预警
读源码时下面这些会让你卡住,提前知道它们存在就不会迷路:
| 段落特征 | 它在干啥 | 初读时怎么办 |
|---|---|---|
if self.dllm_config is not None: |
Diffusion LLM(DLLM)支持,少数模型 | 跳 |
if self.enable_hisparse: |
HiSparse 长上下文优化,少数场景 | 跳 |
if self.enable_overlap: |
重叠调度(默认开) | 第二遍精读 |
Disagg* / disagg_* |
P/D 分离 / encoder 分离 | 跳到 05-distributed/04 再读 |
_pp_* / pp_proxy_tensors |
Pipeline Parallel 支持 | 跳到 05-distributed/01-tensor-parallelism.md 再读 |
future_map / forward_stream |
Overlap 调度的张量生命周期管理 | 关心 TPOT 时再读 |
_chunked_req_scheduled_last_iter |
chunked prefill 状态机 | 关心长 prompt 时再读 |
10. 给 Scheduler 加 print 的最小套路
读源码不如插桩。最小可观察实验:
# 在 event_loop_normal 顶上加几句
def event_loop_normal(self):
while True:
recv_reqs = self.request_receiver.recv_requests()
self.process_input_requests(recv_reqs)
if recv_reqs:
print(f"[recv] {len(recv_reqs)} new reqs; waiting={len(self.waiting_queue)} running={self.running_batch.batch_size()}")
batch = self.get_next_batch_to_run()
if batch:
mode = "prefill" if batch.forward_mode.is_extend() else "decode"
print(f"[batch] {mode} n_reqs={batch.batch_size()} n_tokens={batch.input_ids.numel() if batch.input_ids is not None else 0}")
...
跑一次小 benchmark,看输出就能立刻理解:
- waiting_queue 怎么涨怎么消。
- prefill 和 decode 怎么交替。
- chunked prefill 把一个请求切成几轮。
完整实验在 07-hands-on/03-debug-scheduler.md。
11. 关键源码索引
| 内容 | 位置 |
|---|---|
| 进程入口 | scheduler.py:3837(run_scheduler_process) |
| 类定义 | scheduler.py:287 |
__init__ |
scheduler.py:297 |
| Normal loop | scheduler.py:1372 |
| Overlap loop | scheduler.py:1399 |
get_next_batch_to_run |
scheduler.py:2348 |
run_batch |
scheduler.py:2907 |
process_batch_result |
scheduler.py:3084 |
| 调度策略 | schedule_policy.py |
| ScheduleBatch 定义 | schedule_batch.py |
| I/O 消息体 | io_struct.py |
12. 小结
- Scheduler 主循环只有 6 件事:收请求 → 决定 batch → 跑前向 → 回流结果 → 空闲自检 → 更新状态。
- 调度核心在
get_next_batch_to_run:prefill 优先,cache-aware 排序,token budget 控住一步喂的 token 数。 - Overlap 模式让"调度准备"和"GPU 前向"并行,降 TPOT;代价是张量生命周期复杂。
- 3000+ 行的剩余部分是 mixin 和分支场景,按需深入。
13. 自检
不看上文:
- 一次
event_loop_normal迭代发生哪 6 件事?
答案
(1) `recv_requests` 从 ZMQ 取新请求;(2) `process_input_requests` 做 match_prefix 加入 waiting queue;(3) `get_next_batch_to_run` 决定本步 batch(prefill 优先 + cache-aware 排序 + token budget 控制);(4) `run_batch` 触发 ModelWorker GPU forward + sampler;(5) `process_batch_result` 把新算的 KV `insert` 进 RadixCache、把 token id 发给 Detokenizer、清理 finished 请求;(6) `on_idle` 空闲自检(metric flush、显存碎片整理)。最后更新 `last_batch` 供下一轮判断 prefill→decode 切换。get_next_batch_to_run决定本步是做 prefill 还是 decode 的依据是什么?
答案
主要依据:**waiting queue 是否还有请求能塞进 token budget**。 - 有 → 优先做 prefill(`get_new_batch_prefill`),把可以塞下的请求 prefill 起来,给 decode 池补血; - 没有 → 继续 decode 当前 running_batch(`update_running_batch`)。 细节:chunked prefill 状态机会把"正在被切片的请求"拆成多步 prefill 分散到多轮;Disagg / DLLM / HiSparse 等特殊模式有额外分支。 逻辑入口:[`scheduler.py:2348`](../sglang/python/sglang/srt/managers/scheduler.py)。- 同 batch 内 prefill + decode 共跑的好处?为什么不分开?
答案
好处:prefill 是 **算力 bound**、decode 是 **带宽 bound**,混在一起算力和带宽都饱和;分开跑则 GPU 在 decode 时算力闲、prefill 时带宽闲,利用率掉 30-50%。 也是为什么 SGLang / vLLM 都默认开 chunked prefill —— 把长 prompt 切成 4k chunk 塞进 decode batch 边角。 分开的场景:(a) **P/D 分离**([`05-distributed/04-disaggregated.md`](../05-distributed/04-disaggregated.md))—— 当 TTFT 和 TPOT 双 SLO 都极紧时,宁可牺牲利用率换 SLO 可控;(b) 长上下文场景中 prefill 太大压垮 decode 时延,必须分开。- Overlap 模式的两个 CUDA stream 各做什么?为什么要双 stream?
答案
`forward_stream`:跑 ModelWorker.forward(attention + MLP + sampler 等 GPU 计算)。 `schedule_stream`:跑下一批的 **准备工作**——`init_forward_metadata`(FlashInfer wrapper.plan)、`tree_cache.match_prefix`(device 端张量构造)、KV slot 分配(Triton kernel)。 双 stream 让"调度准备" 和 "上一批的 forward" 并行:用 future_map 标记下一批所需张量、靠 stream event 同步。TPOT 能再降 10-15%。 单 stream 时这两件事串行,调度准备的几 ms 直接累加进 forward 时延。- 我看到 waiting_queue 持续涨,应该怎么定位是 cache miss 多还是 token budget 太小?
答案
看几个指标组合: - **`cache_hit_rate`**:< 20% → cache miss 多,请求 prefill 都得算完整 prompt,吃光 token budget; - **`prefill_tokens_per_iter`**:接近 `max_prefill_tokens` → budget 已饱和,不是因为 cache miss 而是单纯算不过来; - **`waiting_time_p99`** + **`kv_pool_used`**:KV pool 95%+ 时 evict 频繁,进一步降命中率; - **`new_token` 长度分布**:如果都是大值(4k+),说明长 prompt 多,要么加 max_prefill_tokens 要么加副本。 组合判断:cache_hit < 20% 且 prefill_tokens_per_iter < budget → 单纯流量多,扩容;cache_hit 高但 queue 涨 → budget 紧或 KV pool 紧,调参或扩容。14. 下一步
- 前一步:
02-tokenizer-manager.md— 请求怎么变成GenerateReqInput送进 Scheduler 的。 - 下一步:
04-model-runner.md—run_batch之后 GPU 上具体怎么算。 - 横向:
05-radix-tree.md— Scheduler 用的tree_cache的实现细节。 - 策略:
02-core-concepts/03-cache-aware-scheduling.md—schedule_policy.py的具体算法。 - 动手:
07-hands-on/03-debug-scheduler.md— 把上面的 print 套路落到代码。 - 对比 vLLM:
vllm-learning/03-code-walkthrough/02-scheduler.md—— vLLM v1 调度器走读。