Prhub

#38935 [PD] Do not admit intake-rejected requests to a PD handoff

原始 PR 作者 ShangmingCai 合并时间 2026-09-16 12:44 文件变更 5 提交数 3 评论 10 代码增减 +395 / -0

执行摘要

PD 准入门口直接退休被拒请求,decode 超时降为即时 400

PR body 明确指出这是「a waste-and-latency fix, not a correctness fix」:Req.set_finish_with_abort() 记录拒绝在 to_finish 而非 finished_reason,finished() 读后者,因此请求仍报告为 live,所有准入门放行。关联 issue #36988 已添加 post-forward 退休路径,客户端最终能拿到 400——但在此之前请求已完成 bootstrap 握手、预留 metadata buffer、初始化 RDMA sender、在 stub 上跑 forward pass,decode worker 还把它放入 prealloc 队列。目标是把退休提前到「请求尚未取得任何资源」的准入门口,消除浪费与延迟。

值得精读。核心设计决策包括:a) 以「资源所有权」判别 intake 拒绝与请求重入——prefill 门口嗅探资源、decode 门口信任调用方标志的差异化策略;b) 退休收尾集中到 Scheduler.retire_unadmitted_request 单一入口,与 #36988 的 post-forward 路径共享同一所有权测试从而保证互斥;c) 测试用 spy 包装真实退休方法,让门口与退休两半代码相遇;d) 明确拒绝「统一 _check_if_req_exceed_kv_capacity 与退休逻辑」这类表面合理的合并。该 PR 也展示了高价值自审 review 的写法:先验证两个 load-bearing 论断,再列出「明确不是问题」的排除项,避免后人重复争论。

讨论亮点

审核全部由作者本人以「Agent review」形式完成,质量较高。阻塞项是 retire_unadmitted_request 缺失缓存侧释放:「Every sibling retire path releases the request's cache-side state: _retire_aborted_prefill_result (prefill.py:1120), handle_bootstrap_failure (prefill.py:1152), abort method 1 (scheduler.py:5222). This one doesn't.」PREFILL 下 _prefetch_kvcache 已在门口之前把 rid 放入 _storage_prefetch_missed_rids(单 token stub 必走 too-short 分支,且该分支在 prefetch_length > 0 守卫之外),该集合只有 release_aborted_request 与 waiting_queue 扫描能清,门口退休的请求两者都到不了——commit 2 修复。另一个关键修正是 req.is_retracted 属性:「The PR description rules is_retracted out, but that's the parameter passed to add(). The attribute on the request is set by reset_for_retract(), which release_req always calls」——priority preemption 与 pause/retract-all 走裸 _add_request_to_queue 不带任何标志,只有该属性能兜住 seqlen <= 1 抢占的「资源标记全空」形状,否则 retire_group 的「member rows were released by the caller」契约被破坏。作者还静态验证了两条承重论断:prefill 嗅探与 _retire_aborted_prefill_result(prefill.py:1118)是同一所有权测试,两退休路径天然互斥;metadata_buffer_index 只由 ensure_metadata_buffer 写入、maybe_release_metadata_buffer 清除,重入论证成立。同时明确排除两个误判:trace_ctx.abort() 后置的 set_prefill_bootstrap_queue_entry_time() 无害(trace.py:819 置 thread_context = None 后 span 短路);「统一 _check_if_req_exceed_kv_capacity 与退休逻辑」是错误方向——该路径也会命中重入请求,session detach 与 retire_group 会造成本 PR 试图避免的伤害。

实现拆解

  1. 新增 is_unadmitted_reject 谓词(python/sglang/srt/disaggregation/utils.py):判定「intake 阶段被拒且未持有任何资源」。表达式为「is_aborted(req) 且不持有 {req.is_retracted 属性、KV row(kv.holds_kv)、mamba state(kv.holds_mamba)、metadata buffer(metadata_buffer_index >= 0)、host retraction backup(kv.retraction_backup)}」。资源标记区分「新被拒请求」与「抢占/恢复重入请求」;seqlen <= 1 抢占例外由 req.is_retracted 属性兜底——release_req 总是调用 reset_for_retract 设置它,且其三个清除点(decode.py:862、decode_schedule_batch_mixin.py:73、schedule_batch.py:2746)都位于两个门口之后。
  2. 两个 PD 准入门口加守卫:PrefillBootstrapQueue.add(python/sglang/srt/disaggregation/prefill.py)在 create_sender 之前拦截,跳过握手、metadata buffer 分配与 RDMA sender 初始化;DecodePreallocQueue.add(python/sglang/srt/disaggregation/decode.py)不能嗅探资源——retracted 请求此时已释放全部标记——因此改用调用方声明的 is_retracted / is_rebootstrap 标志门控,守卫条件为「非 retracted 且非 rebootstrap 且 is_unadmitted_reject」,保持 retracted_queue 的释放契约。
  3. 新增 Scheduler.retire_unadmitted_request(python/sglang/srt/managers/scheduler.py):一次完成全部收尾——streaming session 的 pre-abort detach(否则 session 永久 in-flight、后续请求全部失败)、beam_coordinator.retire_group(req)(validate_and_init 提前计数,无 group 时 no-op)、_release_aborted_request(req)(释放缓存侧 paced-retry 状态;review 发现首版缺失此行会造成 _storage_prefetch_missed_rids 进程级泄漏)、以真实原因关闭 trace span、update_finish_state() 与 stream_output 让客户端拿到保留 BAD_REQUEST 状态码的 400。
  4. _add_request_to_queue 保持纯 dispatcher:它是 retracted / preempted / resumed 请求的重入路径,这些请求可能已持有 KV、metadata buffer 或 host retraction backup,在 dispatcher 层退休会丢资源。
  5. 测试配套:新增 test/registered/unit/disaggregation/test_admission_abort_not_enqueued.py(326 行,注册进 base-a-test-cpu CPU CI 套件),覆盖两门口拒绝即退休、prefill 重入(metadata buffer / retraction backup 两种标记)、decode retracted / rebootstrap 重入(含 backup=None 短序列)、无标志抢占重入不被误退休、有效请求仍准入、NULL 模式刻意不碰、退休副作用全链路;_make_scheduler 用 spy 包装真实 retire_unadmitted_request,保证门口测试同时执行退休体。
  6. 演进中的适配:commit 2 落实 review 的缓存侧释放与 is_retracted 兜底;commit 3 适配 #39318 把 _release_aborted_request 从接收 rid 改为接收 Req 的签名变化——初版仍传 req.rid(str),会在调度器事件循环抛 AttributeError。
文件 模块 状态 重要度
python/sglang/srt/managers/scheduler.py 调度器 modified 6.8
python/sglang/srt/disaggregation/utils.py 准入判断 modified 6.09
python/sglang/srt/disaggregation/prefill.py 预填队列 modified 4.87
python/sglang/srt/disaggregation/decode.py 解码队列 modified 4.87
test/registered/unit/disaggregation/test_admission_abort_not_enqueued.py 准入测试 added 7.49

关键符号

retire_unadmitted_request is_unadmitted_reject PrefillBootstrapQueue.add DecodePreallocQueue.add

关键源码片段

python/sglang/srt/managers/scheduler.py core-logic

新增 `Scheduler.retire_unadmitted_request`——门口退休的全部收尾集中于此:streaming session 的 pre-abort detach、beam group 退休、缓存侧 `_storage_prefetch_missed_rids` 释放、以真实原因关闭 trace span 并输出 400。review 发现首版缺失 `_release_aborted_request` 会造成进程级 rid 泄漏。

def retire_unadmitted_request(self, req: Req) -> None:
    """Finish a request the disaggregation queues rejected at their door."""
    # `create_req` 会把 streaming session 标记为 in-flight,而 pre-abort
    # detach 只在 `StreamingSession.find_active_slot` 中执行(调度路径内);
    # 被退休的请求到不了那里,不分离则 session 永久 in-flight,
    # 该 session 上的后续请求会全部报 "already has an active request"。
    if req.session is not None and req.session.streaming:
        req.session.abort_req()
        req.session = None
    # `beam_coordinator.validate_and_init` 在拒绝检查之前就把 group 计入,
    # 这里对应退休;请求没有 group 时是 no-op。
    self.beam_coordinator.retire_group(req)
    # PREFILL 门口之前已运行 `_prefetch_kvcache`,单 token stub 也会被注册
    # 进缓存侧 paced-retry 集合(`_storage_prefetch_missed_rids`),只有
    # `finish`/ABORT 与 `waiting_queue` 扫描能清除;缺失此行会进程级泄漏。
    self._release_aborted_request(req)
    # `update_finish_state` 在 `finished()` 为真时提前返回,客户端实际收到
    # 的是已设置的 `finished_reason`(400);这里以真实原因关闭 trace span,
    # 避免 span 在 GC 时以无状态方式被自动关闭。
    reason = req.finished_reason or req.to_finish
    req.time_stats.trace_ctx.abort(abort_info={"reason": reason.message})
    req.update_finish_state()
    self.output_streamer.stream_output([req], req.return_logprob)
python/sglang/srt/disaggregation/prefill.py core-logic

`PrefillBootstrapQueue.add` 增加门口守卫:intake 被拒请求直接退休,跳过 `create_sender`——bootstrap 握手、metadata buffer 预留与 RDMA sender 初始化全部不再发生。

def add(self, req: Req, num_kv_heads: int) -> None:
    # intake 阶段被拒的请求:`set_finish_with_abort()` 把判决留在 `to_finish`
    #(`finished()` 不读它)并把 prompt 换成单 token stub。若放行,一次
    # bootstrap 会消耗握手、metadata buffer、RDMA sender 初始化与一次
    # stub forward pass 之后才会被 #36988 的 post-forward 路径回卷。
    if is_unadmitted_reject(req):
        self.scheduler.retire_unadmitted_request(req)
        return
    if not self.create_sender(req, num_kv_heads):
        return
    self.queue.append(req)

评论区精华

retire_unadmitted_request 缺失缓存侧释放,rid 泄漏至进程结束 正确性

作者自审指出每个兄弟退休路径(`_retire_aborted_prefill_result` prefill.py:1120、`handle_bootstrap_failure` prefill.py:1152、abort method 1 scheduler.py:5222)都释放缓存侧状态,而新路径不释放。PREFILL 下 `_prefetch_kvcache` 在门口之前已把 rid 加入 `_storage_prefetch_missed_rids`(单 token stub 必走 too-short 分支,且该分支在 `prefetch_length > 0` 守卫之外),该集合只有 `release_aborted_request` 与 `waiting_queue` 扫描能清,门口退休的请求两者都到不了。

结论:在 `retire_unadmitted_request` 中加入 `self._release_aborted_request(req)`;commit 2「release cache state」落实。 · 已解决

req.is_retracted 属性兜住无标志重入路径 正确性

PR 描述把 `is_retracted` 排除,但那是 `add()` 的参数;请求上的属性由 `reset_for_retract()` 设置且 `release_req` 总是调用它,priority preemption(scheduler.py:3983)与 pause/retract-all 走裸 `_add_request_to_queue` 不带任何标志。没有它,`seqlen <= 1` 的抢占重入请求在资源标记全空时会被误退休,`retire_group` 的「member rows were released by the caller」契约被破坏。三个清除点(decode.py:862、decode_schedule_batch_mixin.py:73、schedule_batch.py:2746)都在两个门口之后,属性可靠。

结论:在 `is_unadmitted_reject` 中加入 `req.is_retracted` 判断,并新增 `test_preempted_reentry_is_not_retired` 用例。 · 已解决

门口测试与退休测试分离导致漏测,用 spy 合体 测试

原拆分下门口测试 mock 掉退休、退休测试用 SimpleNamespace 手工构造 scheduler,两半永远不相遇,漏掉 `_release_aborted_request` 调用也不会被任何测试发现。作者建议用 `side_effect` 包装真实方法,让门口测试既断言调用又执行退休体;同时两个 SimpleNamespace 缺 `_release_aborted_request` 属性会在退休体内抛 `AttributeError`。

结论:`_make_scheduler` 采用 spy 包装(`side_effect=lambda req: Scheduler.retire_unadmitted_request(sched, req)`),SimpleNamespace 补上 `_release_aborted_request = MagicMock()`。 · 已解决

两条 load-bearing 论断的静态验证与两个「明确不是问题」 设计

Agent review 验证:prefill 嗅探与 `_retire_aborted_prefill_result`(prefill.py:1118)是同一所有权测试,两退休路径互斥;`metadata_buffer_index` 只由 `ensure_metadata_buffer` 写入、`maybe_release_metadata_buffer` 清除,`reset_for_retract` 不碰它。确认 `trace_ctx.abort()` 后置的 `set_prefill_bootstrap_queue_entry_time()` 无害(trace.py:819 置 `thread_context = None` 后 span 短路);并明确「统一 `_check_if_req_exceed_kv_capacity` 与退休逻辑」是错误方向——该路径也命中重入请求,session detach 与 `retire_group` 会造成本 PR 试图避免的伤害。

结论:无代码变更;排除两个误判,划定「不合并 KV 容量检查与退休逻辑」的边界。 · 已解决

风险与影响

依赖请求状态属性契约:is_unadmitted_reject 的正确性依赖 req.is_retracted 由 reset_for_retract 设置、且清除点全部位于两个门口之后;未来若在门之前新增清除点,会误退休重入请求,破坏 retire_group 契约。新调用方暴露的签名敏感:_release_aborted_request 已被 #39318 改过一次签名(rid → Req),初版传 req.rid 会在调度器事件循环抛 AttributeError;同类接口变更需同步检查 retire_unadmitted_request。不对称拒绝场景未覆盖:仅 prefill 或仅 decode 单侧拒绝时,对端持有效请求仍等满传输超时(500 @ 300 s),PR 自述不在范围内,需配合 #38704 的 abort_before_send 才能快速失败。验证范围:无更广 GPU/传输套件验证(checklist 明示未完成),仅 1P1D 实测与 CPU 单元测试(1011 passed / 4 skipped)。

用户在 PD 模式下提交超长 prompt(如超过 max_req_input_len)的请求:decode 侧从「300 s 传输超时」变为「即时 400(0.0 s)」,prefill 侧省去 bootstrap 握手、metadata buffer、RDMA sender 初始化与一次 stub forward pass。NULL 模式刻意不受影响:stub 单次 forward 成本低、batch 边界即返回 400,且在该门口加守卫会跳过 find_active_slot 的 pre-abort detach,反而引入回归。与 #38704 都编辑 DecodePreallocQueue.add,后落地者需 rebase 而非 take-theirs。

PD 准入核心路径变更 依赖 is_retracted 状态属性契约 不对称拒绝场景仍等满超时 与 #38704 同函数冲突 新增路径缺 GPU 级回归验证

关联 Issue

#36988 [VLM] Retire aborted disaggregated prefill results

完整报告

参与讨论