Prhub

#34284 fix(scheduler): track max prefill batch size over recent real admissions

原始 PR 作者 hanwlax 合并时间 2026-08-15 10:48 文件变更 7 提交数 3 评论 6 代码增减 +222 / -10

执行摘要

滑动窗口跟踪 prefill 批大小高水位,修复 TPOT 劣化

PR body 明确指出:Scheduler.max_prefill_bs 原先每个 scheduler pass 衰减一次,而值在传给 PrefillDelayer 前被转成整数,因此像 2 这样小但有效的高水位在大量 decode-only 或 idle pass 之间会迅速变成 0。一旦 max_prefill_bs 变为 0all 路径的 slot_condition 就失去效果,prefill 工作会干扰 decode 批次,导致 TPOT 下降。作者在 issue 评论中也说明这是 #32880 的 follow-up,目标是保留让过期的大峰值失效的能力,同时把老化过程与 scheduler-pass 频率解耦。

该 PR 值得精读,尤其是 RecentPrefillBatchSizeTracker_estimate_attempted_prefill_bs 的设计:用滑动窗口替代指数衰减来老化高水位,并在无法获得真实批大小时用保守上界估计,是一种可复用的调度状态维护模式。建议关注 max_prefill_bs 类型变化对下游判断的影响,以及窗口大小在不同负载下的调参经验。

讨论亮点

作者 hanwlax 在 issue 评论中向 hanming-lu 请求 review,明确指出这是 #32880 的 follow-up,并解释了原衰减实现导致小高水位快速归零、slot_condition 失效、TPOT 劣化的现象。评论中强调新方案“保留让过期峰值失效的原始目标,同时把老化过程与 scheduler-pass 频率解耦”。没有实质性的反对意见;hanming-lu 最终批准该 PR。后续评论均为 CI 重跑指令(/rerun-failed-ci),说明主要关注点是 NPU 性能测试的稳定性。

实现拆解

  1. 新增 RecentPrefillBatchSizeTracker(python/sglang/srt/managers/prefill_delayer.py):用 collections.deque(maxlen=window_size) 保存最近 N 次非空 prefill 尝试的批大小;max_prefill_bs 属性返回窗口内最大值(默认 0);observe_attempt 校验输入必须为正整数,否则抛出 ValueError
  2. 改造 PrefillDelayerSinglePassExecutor(同文件)finalize 的入参从 actual_prefill: bool 改为 actual_prefill_bs: int,返回实际批大小;若被拒绝(传入 0),则返回 _attempted_prefill_bs 估计值。新增 _estimate_attempted_prefill_bs 方法:在非 DP attention 场景先把 max_running_requestsdp_size 折算为本地槽位数,然后取 min(waiting_queue_len, free_slots) 作为保守估计。negotiate_should_allow_prefilllocal_prefillable=True 时先更新该估计值的上界。
  3. 接入 Scheduler(python/sglang/srt/managers/scheduler.py)init_schedule_policy 中创建 RecentPrefillBatchSizeTracker,窗口大小取 envs.SGLANG_PREFILL_DELAYER_MAX_PREFILL_BS_WINDOW_SIZEmax_prefill_bs 类型从 float 改为 intget_new_batch_prefill 删除每 pass 的 self.max_prefill_bs *= 0.998 衰减,改为在 finalize 返回正数时调用 tracker.observe_attempt 更新高水位;_get_new_batch_prefill_raw 中删除旧的 self.max_prefill_bs = max(self.max_prefill_bs, len(can_run_list)) 行。
  4. 新增环境变量(python/sglang/srt/environ.py)SGLANG_PREFILL_DELAYER_MAX_PREFILL_BS_WINDOW_SIZE,默认 16。
  5. 测试配套:新增 test/registered/unit/managers/test_prefill_delayer_high_watermark.py,覆盖峰值过期、重复小峰值保持有效、被拒绝非空尝试推进窗口、估计值打印与空值拒绝;三个 NPU 性能测试(Kimi-K2.6、MiniMax-M2.5、Qwen3-6.27B)通过环境变量将窗口调大到 64。
文件 模块 状态 重要度
python/sglang/srt/managers/prefill_delayer.py 预填延迟器 modified 8.32
python/sglang/srt/managers/scheduler.py 调度器 modified 6.76
test/registered/unit/managers/test_prefill_delayer_high_watermark.py 预填延迟器 added 7.89
python/sglang/srt/environ.py 环境配置 modified 4.58
test/registered/npu/performance/kimi_k2_6/test_npu_kimi_k2_6_w4a8_8p_in3k5_out1k5_20ms.py 性能测试 modified 3.46
test/registered/npu/performance/minimax_m2_5/test_npu_minimax_m2_5_w8a8_4p_in64k_out1k_prefix90_50ms.py 性能测试 modified 3.46
test/registered/npu/performance/qwen3_6_27b/test_npu_qwen3_6_27b_1p_in1080p_30_out256_50ms.py 性能测试 modified 3.46

关键符号

RecentPrefillBatchSizeTracker.__init__ RecentPrefillBatchSizeTracker.observe_attempt RecentPrefillBatchSizeTracker.max_prefill_bs PrefillDelayerSinglePassExecutor.finalize PrefillDelayerSinglePassExecutor._estimate_attempted_prefill_bs Scheduler.init_schedule_policy Scheduler.get_new_batch_prefill

关键源码片段

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

移除每 pass 的 0.998 衰减,改为用 tracker 在真实 admission 后更新高水位,是行为变更的接入口。

# python/sglang/srt/managers/scheduler.py(节选)
# 初始化调度策略时构建高水位跟踪器,窗口大小由环境变量控制。
self.prefill_bs_tracker = RecentPrefillBatchSizeTracker(
    window_size=envs.SGLANG_PREFILL_DELAYER_MAX_PREFILL_BS_WINDOW_SIZE.get()
)
# max_prefill_bs 从 float 改为 int,避免小数衰减造成的精度丢失。
self.max_prefill_bs: int = 0
​
​
def get_new_batch_prefill(self, running_batch: ScheduleBatch) -> NextBatchPlan:
    prefill_delayer_single_pass = None
    if self.prefill_delayer:
        # 不再按调度 pass 衰减,而是等待真实 admission 后更新。
        max_pool_usage = self.pool_stats_observer.get_pool_stats().get_max_pool_usage()
        prefill_delayer_single_pass = PrefillDelayerSinglePassExecutor(
            self.prefill_delayer, token_usage=max_pool_usage
        )
​
    ret, running_batch = self._get_new_batch_prefill_raw(
        prefill_delayer_single_pass=prefill_delayer_single_pass,
        running_batch=running_batch,
    )
​
    if self.prefill_delayer:
        # finalize 返回实际准入批大小;若被拒绝则返回估算值(>0),
        # 两者都会进入滑动窗口,使旧峰值在窗口填满后被淘汰。
        observed_prefill_bs = prefill_delayer_single_pass.finalize(
            actual_prefill_bs=ret.batch_size() if ret is not None else 0
        )
        if observed_prefill_bs > 0:
            self.max_prefill_bs = self.prefill_bs_tracker.observe_attempt(
                observed_prefill_bs
            )
​
    return NextBatchPlan(batch_to_run=ret, running_batch=running_batch)
test/registered/unit/managers/test_prefill_delayer_high_watermark.py test-coverage

新增单元测试,覆盖窗口过期、重复小峰值保持、被拒尝试推进窗口等核心行为,是保障改动正确性的关键。

# test/registered/unit/managers/test_prefill_delayer_high_watermark.py
# 核心测试:一个 100 的大峰值在 4 次窗口中被后续小批次淘汰后,
# 高水位应降回当前小批次水平。
def test_peak_expires_after_recent_attempt_window(self):
    tracker = RecentPrefillBatchSizeTracker(window_size=4)
​
    self.assertEqual(tracker.observe_attempt(100), 100)
    for attempted_prefill_bs in [2, 1, 2]:
        self.assertEqual(tracker.observe_attempt(attempted_prefill_bs), 100)
​
    # 窗口满 4 次后,100 被挤出,最大值为当前窗口内的 2。
    self.assertEqual(tracker.observe_attempt(2), 2)
​
​
def test_rejected_non_empty_attempt_advances_window(self):
    tracker = RecentPrefillBatchSizeTracker(window_size=4)
    self.assertEqual(tracker.observe_attempt(10), 10)
​
    # 使用 MagicMock 模拟被拒绝的调度场景:waiting_queue_len=2,
    # 剩余槽位 5,估计批大小为 2。
    delayer = MagicMock()
    delayer.enable_dp_attention = True
    delayer.dp_size = 1
    delayer._metrics_collector = None
    delayer._debug_log_enabled = False
    delayer._negotiate_should_allow_prefill.return_value = _NegotiateOutput(
        next_state=None,
        input_estimation="all",
        output_allow=False,
        output_reason="delay",
        num_prefillable=1,
        num_token_watermark_force_allow=0,
    )
​
    for _ in range(4):
        executor = PrefillDelayerSinglePassExecutor(delayer, token_usage=0.9)
        self.assertFalse(
            executor.negotiate_should_allow_prefill(
                local_prefillable=True,
                running_batch=15,
                max_prefill_bs=tracker.max_prefill_bs,
                max_running_requests=20,
                waiting_queue_len=2,
            )
        )
        attempted_prefill_bs = executor.finalize(actual_prefill_bs=0)
        self.assertEqual(attempted_prefill_bs, 2)
        tracker.observe_attempt(attempted_prefill_bs)
​
    # 4 次被拒尝试后,10 被挤出,高水位更新为 2。
    self.assertEqual(tracker.max_prefill_bs, 2)

评论区精华

max_prefill_bs 衰减导致小高水位归零的修复方向 设计

hanwlax 在 issue 评论中说明:这是 #32880 的 follow-up,原实现在每次 scheduler pass 衰减 0.998,小高水位(如 2)会被大量 decode-only pass 拉低到 0,使 all 路径的 slot_condition 失效。新方案改为在最近 16 次非空 prefill 尝试上取最大值。

结论:hanming-lu 批准了该方案,认为滑动窗口在保留过期峰值淘汰能力的同时,将老化与 scheduler-pass 频率解耦。 · 已解决

风险与影响

  1. 核心调度热路径变更get_new_batch_prefill 是每次调度都会执行的路径,新增 finalize 返回值和 tracker 更新虽然是 O(1) 操作,但仍需关注对调度循环延迟的影响,尤其是 DP 场景下的 all_gather 交互。
  2. 估计值可能失真:被拒绝的非空尝试没有真实批大小,_estimate_attempted_prefill_bs 使用 min(等待队列长度, 剩余槽位),在等待队列或槽位变化剧烈时可能高估或低估实际候选批大小,进而影响 slot_condition 的松紧。
  3. 窗口大小敏感:默认窗口 16,但 NPU 性能测试普遍调成 64;不同工作负载下窗口大小直接影响高水位过期速度,默认值可能不是最优。
  4. 类型语义变化max_prefill_bsfloat 变为 int,所有相关比较和传参都从浮点退化到整数,需要确认无其他模块依赖其浮点精度。
  5. 回归风险PrefillDelayerSinglePassExecutor.finalize 的签名从 bool 改为 int,若有其他调用方未同步更新会直接出错;本 PR 已同步所有已知调用点,但需关注未来扩展。

对启用 --enable-prefill-delayer 的部署,修复了小 prefill 批场景下 max_prefill_bs 快速归零导致 prefill 干扰 decode、TPOT 恶化的缺陷,直接改善最终用户的首 Token 延迟和输出吞吐;对 NPU 平台影响尤其显著(三个性能测试用例同步调整了窗口大小)。对系统而言,调度器决策逻辑更贴近真实 admission 节奏,行为更可预期。对团队而言,该 PR 提供了新的环境变量和单元测试模板,后续优化调度窗口策略时可复用 RecentPrefillBatchSizeTracker 模式。

核心调度路径变更 行为语义变化(float->int) 估计值可能不准确 窗口大小默认值敏感

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论