预计阅读 11 分钟

源码精读:Scheduler 主循环

谁该读这一篇? 想真正啃 SGLang 源码、或者准备给调度逻辑提 PR / 排查生产事故的工程师。 前置阅读: 02-core-concepts/01-radix-attention.md02-core-concepts/03-cache-aware-scheduling.md01-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 行的初始化主要在:

  1. 解析参数ServerArgs / PortArgs / model config / TP 拓扑等。
  2. 绑定 ZMQ socket:和 TokenizerManager、DetokenizerManager 的通信端口。
  3. 建 ModelWorker:在 GPU 上初始化 model runner、attention backend、sampler。
  4. 建内存池和 RadixCachepython self.tree_cache: BasePrefixCache = RadixCache(params) 注意类型是 BasePrefixCache,运行时可能根据配置换成 HiRadixCacheChunkCacheMambaRadixCache 等。

  5. 建调度策略python self.policy = PrefillAdder(...) 引入 schedule_policy.py,cache-aware 排序的逻辑在那里。

  6. 初始化队列: - self.waiting_queue: List[Req] —— 排队等 prefill 的请求。 - self.running_batch: ScheduleBatch —— 正在 decode 的请求。 - self.chunked_req —— 当前被 chunk-prefill 切碎、还没跑完的请求。

  7. 可选功能: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.pyPrefillAdder.add_one_req,它按"命中长度"作为分数排序。

这里有大量 mixin / 分支处理(dllm、hisparse、disaggregation),初读时全部跳过。 等需要排查具体场景再回来读。

5.1 token budget 的两条线

调度的"上限"由两个量控制:

  • max_running_requests:同时能跑的请求数(防止 ZMQ / sampler 资源耗尽)。
  • max_total_num_tokensmax_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_refsbatch_record_buf 的来历。


7. process_batch_result:回流(3084+)

主要工作:

  1. 取 token idbatch_result.next_token_ids 是采样器吐出的下一个 token。
  2. 写入 RadixCache:finished 的请求 / 新算的 KV → tree_cache.insert(...)
  3. 更新 running_batch:finished 的请求从 batch 里摘除;ref_count--;显存回收。
  4. 发给 Detokenizer:通过 ZMQ 把 token id 推给 DetokenizerManager 进程。
  5. 记录 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:3837run_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. 自检

不看上文:

  1. 一次 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 切换。
  1. 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)。
  1. 同 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 时延,必须分开。
  1. 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 时延。
  1. 我看到 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. 下一步

上游源码起点:sglang/python/sglang/srt/managers/scheduler.py