动手 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. 自检
- 看到
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 反过来:几万行扫不动,没有方向。- 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 就要优化。- 一个请求的 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 立刻打时间戳对比客户端发送时间。- 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 频率。- 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. 下一步
04-custom-frontend.md— 写自己的 DSL 程序。08-production-deployment/05-incident-playbook.md— 生产 runbook。08-production-deployment/03-monitoring.md— 监控搭建。- 源码:
scheduler.py、environ.py。