预计阅读 6 分钟

动手 3:调试 Scheduler

谁该读这一篇? 想看 SGLang scheduler 实际在干啥、排查"为什么我的请求慢"的工程师。 前置阅读: 03-code-walkthrough/03-scheduler.md耗时: 30 分钟 学完能: 1. 给 scheduler 加几行 print 看 batch 演化; 2. 用环境变量打开调试日志而不改源码; 3. 看 /metrics 和日志结合定位"waiting_queue 涨"等问题; 4. 用 py-spy 抓 scheduler 的 hot path; 5. 知道一个慢请求该去哪里看。


1. 开启 debug 日志

最低侵入的方式:

python -m sglang.launch_server --log-level debug ...

或者更细粒度:

SGLANG_LOG_LEVEL=debug \
SGLANG_LOG_BATCH_STATS=1 \
python -m sglang.launch_server ...

环境变量列表见 srt/environ.py

打开后日志变多,主要有:

[INFO] Prefill batch. #new-seq: 5, #new-token: 1024, #cached-token: 4096, #queue-req: 2
[INFO] Decode batch. #running-req: 32, #token: 16384, gen throughput: 234 token/s

看每行的"#cached-token" 占比 → cache 命中率。 "#queue-req" → waiting queue 长度。


2. 插桩 scheduler(看 batch 演化)

修改 scheduler.py:event_loop_normal(1372):

def event_loop_normal(self):
    """A normal scheduler loop."""
    while True:
        recv_reqs = self.request_receiver.recv_requests()
        self.process_input_requests(recv_reqs)

        # ───── DEBUG ─────
        if recv_reqs:
            print(f"[recv] +{len(recv_reqs)} reqs, waiting={len(self.waiting_queue)} running={self.running_batch.batch_size()}", flush=True)
        # ─────────────────

        batch = self.get_next_batch_to_run()
        self.cur_batch = batch

        # ───── DEBUG ─────
        if batch:
            mode = "prefill" if batch.forward_mode.is_extend() else "decode"
            tokens = batch.input_ids.numel() if batch.input_ids is not None else 0
            print(f"[batch] {mode} n_reqs={batch.batch_size()} tokens={tokens}", flush=True)
        # ─────────────────

        if batch:
            result = self.run_batch(batch)
            self.process_batch_result(batch, result)
        else:
            self.on_idle()

        self.last_batch = batch

跑一次小 benchmark,你会看到类似:

[recv] +3 reqs, waiting=3 running=0
[batch] prefill n_reqs=3 tokens=3000
[batch] decode n_reqs=3 tokens=3
[batch] decode n_reqs=3 tokens=3
[recv] +1 reqs, waiting=1 running=3
[batch] prefill n_reqs=1 tokens=200
[batch] decode n_reqs=4 tokens=4
...

立刻能看清楚 prefill 和 decode 是怎么穿插的。


3. 看 cache 命中率

get_next_batch_to_run 之后加:

if batch and batch.forward_mode.is_extend():
    cached = sum(req.prefix_indices.shape[0] for req in batch.reqs if req.prefix_indices is not None)
    total = batch.extend_seq_lens.sum().item()
    print(f"[cache] cached={cached}/{total} ({100*cached/(cached+total):.1f}%)", flush=True)

跑相同请求两次,第二次应该看到 cache 命中接近 100%。


4. 看 RadixTree

最直观的方式:把 tree 打印出来。

# 在 Scheduler 的某个空闲点调用:
def dump_tree(node, prefix=""):
    print(f"{prefix}└── tokens={len(node.key.token_ids) if node.key else 0} value_len={node.value.shape[0] if node.value is not None else 0} lock_ref={node.lock_ref}")
    for child in node.children.values():
        dump_tree(child, prefix + "    ")

# 在 event_loop 里隔几轮调用一次
if self.forward_ct % 100 == 0:
    print("[tree]")
    dump_tree(self.tree_cache.root_node)

你会看到一棵树长什么样、共享 prefix 的节点在哪。 高 QPS 时 tree 节点会涨到几百到几千个。


5. 看 metrics(生产排障常用)

# 实时刷新
watch -n 1 'curl -s localhost:30000/metrics | grep -E "cache_hit|waiting|running|kv_pool"'

关键指标动态:

看到 意味着
cache_hit_rate 突降 业务变了 / cache 被刷了 / evict 多
waiting_queue_size 持续涨 容量不够 / chunk prefill 节奏不对
running_requests 卡上限 max_running_requests 设小了
kv_pool_used 接近 100% evict 频繁
gen_throughput 突降 大 prompt 进来抢算力 / cuda graph fallback

6. 用 py-spy 抓 hot path

pip install py-spy
sudo py-spy top --pid <scheduler_pid>
# 或者抓 profile:
sudo py-spy record -o profile.svg --pid <scheduler_pid> --duration 30

profile.svg 是火焰图,看 scheduler 主循环里时间花在哪。 常见 hot path:

  • match_prefix 调用太频繁 → tree 太深;考虑加 prefix cache hash 提示。
  • pickle.dumps → io_struct 太大;优化 fields。
  • process_batch_result 慢 → metric 计算 / log 太多。

7. 用 nvprof / nsys 抓 GPU 时间线

nsys profile --gpu-metrics-device=0 -o sglang.qdrep \
    python -m sglang.launch_server ... &

# 跑请求...

# 用 nsys-ui 打开 sglang.qdrep 看时间轴

能看到:

  • 每个 forward 由哪些 kernel 组成。
  • Kernel 之间的空隙(host 调度开销)。
  • AllReduce 在 TP 下的位置和耗时。

8. 调试一个慢请求

完整诊断顺序:

1. 客户端实测:TTFT、TPOT、E2E 各多少?
2. 看 /metrics 端:
   - cache_hit_rate
   - waiting_queue_size
   - gen_throughput
   - kv_pool_used
3. 看 scheduler log:
   - 这个 rid 是什么时候进 queue、什么时候开始 prefill、什么时候开始 decode
4. 如果 server 端时间和客户端基本一致 → 网络问题
   如果 server 端时间 << 客户端 → 客户端代码或网络
5. 如果 prefill 慢:
   - cache 没命中?
   - prompt 太长被 chunked prefill 切了多步?
   - 排队等其它请求?
6. 如果 decode 慢:
   - cuda graph fallback?
   - 并发太大 KV pool 紧?
   - quantization / attention backend 配错?

9. 实用的环境变量

Env 作用
SGLANG_LOG_LEVEL=debug 总日志级别
SGLANG_LOG_BATCH_STATS=1 每轮打印 batch 统计
SGLANG_DUMP_REQS=1 dump 请求详情到文件
SGLANG_DISABLE_CUDA_GRAPH=1 关 graph 看 eager 行为
NCCL_DEBUG=INFO NCCL 通信日志
TORCH_LOGS=+dynamo,inductor torch.compile 日志
CUDA_LAUNCH_BLOCKING=1 CUDA 同步执行(排查不一致)

10. 远程 attach pdb

如果 server 卡死了:

# 找到 scheduler pid
ps -ef | grep sglang | head

# 用 py-spy dump 一下栈
sudo py-spy dump --pid <pid>

输出会显示每个线程当前在哪行代码。 比硬 attach gdb 简单得多。


11. 小结

  • 加 print 是最快的调试手段,scheduler 主循环只有 6 件事,挑点位置插桩。
  • 环境变量 SGLANG_LOG_LEVEL=debug 能拿到大量内置日志。
  • /metrics 实时反映 cache / queue / kv pool 状态。
  • py-spy + nsys 分别抓 CPU 和 GPU 时间线。
  • 慢请求诊断从客户端 → metrics → log → 网络 / GPU 这个顺序。

12. 自检

  1. 看到 waiting_queue_size 持续涨,应该先看 metrics 还是 log?
答案 先看 **metrics**,再看 log。 metrics 几秒能给出全局判断:`cache_hit_rate`(命中低 → cache miss 多)、`prefill_tokens_per_iter`(接近 max_prefill_tokens → budget 饱和)、`kv_pool_used`(高 → 显存紧 evict 多)、`gen_throughput`(低 → GPU 瓶颈)。 组合判断后再带着假设去 log 里查具体请求行为(哪个 rid 排队多久、是不是被 chunk prefill 切片)。 先看 log 反过来:几万行扫不动,没有方向。
  1. py-spy 火焰图里发现 pickle.dumps 占 20%,怎么优化?
答案 pickle 占用大说明 ZMQ 消息体序列化是瓶颈: (1) **减消息体大小**:检查 io_struct 是否传了大张量。多模态 image_inputs 不要传 raw bytes 应该传 encoded embedding handle。 (2) **用 protocol=5**:pickle protocol 5 支持 out-of-band buffer,大张量零拷贝。 (3) **共享内存**:把张量放 `multiprocessing.shared_memory`,消息体只传 handle + shape。 (4) **CUDA IPC**:GPU 张量用 IPC handle,跨进程零拷贝。 (5) **批合并**:高 QPS 时合多个小请求成一个 batch 消息(`BatchTokenizedGenerateReqInput`),分摊 pickle 开销。 监控 ZMQ 消息体平均大小:超过 10 KB 就要优化。
  1. 一个请求的 TTFT 客户端测 3s, scheduler log 测 1s, 差额在哪?
答案 2s 差额可能来自: (1) **网络 + ingress**:客户端到 server 跨地域 / 跨 K8s namespace,TCP RTT + nginx buffer 几百 ms。 (2) **TokenizerManager 队列**:HTTP server 接收后 TM 还没拉起协程;高 QPS 时 asyncio event loop 排队。 (3) **Tokenize 耗时**:长 prompt(10k+)tokenize 几百 ms。 (4) **ZMQ 推送延迟**:TM → Scheduler 的 ZMQ socket 高水位时阻塞。 (5) **客户端 SDK 重试 / TCP 慢启动**。 定位:开 OTel 全链路 trace,看每段时间花在哪;或者在 TM 进 generate_request 立刻打时间戳对比客户端发送时间。
  1. cache_hit_rate 突然从 60% 降到 10%,可能原因?
答案 按概率: (1) **业务变更**:system prompt 加了 timestamp / user_id / session_id 等可变字段;同 prompt 物理上变成 100% 唯一。**最常见**。 (2) **流量结构变化**:突然新业务上线,新 prompt 模板还没沉淀进 cache。 (3) **KV pool evict 加剧**:max_running_requests 升 / KV pool 缩,热 prefix 被频繁 evict。 (4) **router 路由策略变化**:从 cache-aware 切到 round-robin,请求被打散到不同副本。 (5) **RadixCache bug**:罕见,但版本升级时 regression。 定位:业务侧问"最近改 prompt 了吗";自己 diff 最近的 Helm / config;看 `kv_pool_used` 和 evict 频率。
  1. CUDA Graph fallback 多怎么诊断?
答案 metric: `cuda_graph_fallback_count / cuda_graph_replay_count > 5%` 是黄牌。 常见原因: (1) **Prefill batch 多**:chunked prefill 关掉时长 prompt 跑完整 prefill 不能走 graph;开 chunked + piecewise CUDA graph。 (2) **LoRA adapter 切换**:每切 adapter 都 fallback;查 `lora_swap_count`。 (3) **Speculative draft tree 形状变**:spec_v2 默认动态 tree,graph 命中率低。 (4) **batch_size 落"洞"**:实际 batch=5 但 graph 列表只有 [1,2,4,8,16],padding 到 8 浪费;或者 padding 关了就 fallback。 (5) **grammar mask 切换**:受约束输出请求 + 自由 gen 请求同 batch 时 fallback。 解决:调 `--cuda-graph-bs` 覆盖业务实际 batch size 分布;分开部署 LoRA / 受约束输出业务。

13. 下一步

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