Prhub

#36933 [2/N][Mixed] Mixed chunk prefill with spec enabled

原始 PR 作者 Oasis-Git 合并时间 2026-09-01 01:48 文件变更 13 提交数 28 评论 2 代码增减 +321 / -35

执行摘要

打通 mixed chunk 与投机解码的组合降级路径

PR body 基本是模板占位,动机主要体现在 commit 叙事与代码注释中。改动前 speculative_hook.py 对 dflash、dspark、eagle 三处硬编码 declare_resolution(..., enable_mixed_chunk=False),因为 mixed step 与 spec 的 draft/verify 循环不兼容;本 PR 把该组合升级为一等公民。作者在 commit 中说明关键权衡:“Mixed chunking already saturates compute, so drafting adds latency for little gain there”,即混合步骤计算已饱和,draft 只增加延迟,因此降级 running 请求为纯 1-token extend 并无损失;同时降级契约是算法无关的,应表达为 worker 能力而非硬编码算法名。ngram 因 overlap relay 发布到 accept 缓冲区而非 output_tokens_buf 暂不支持,STANDALONE 因 draft-KV 恢复路径未验证保持关闭。

值得精读,尤其关注两条设计决策:一是把混合 step 的降级契约表达为 supports_mixed_chunk() 能力位而非算法字符串硬编码;二是 overlap 下“调度期占位 + forward 入口晚期绑定”的尾部重建模式(resolve_mixed_spec_tails),它绕开了 CPU 无法得知在飞 accept 数的根本矛盾。阅读建议:先看 mix_with_runningresolve_mixed_spec_tails,再读测试文件中的三个 cell,最后按 commit 顺序浏览 [Fix] 记录理解每个坑的成因。

讨论亮点

PR 没有 review 评论(review_comments_count = 0),issue 评论仅为两条 CI 链接(base pass / extra pass)。最有价值的技术交锋内嵌在 28 个 commit 的 [Fix] 叙事里,相当于作者自审的 review 记录:

  • “the spec seq_lens convention zeroed the tail's qo len - a hard crash on flashinfer, silent kv-span truncation elsewhere” —— 同一 bug 在不同 attention 后端的两种表现,flashinfer 直接崩溃、无校验后端静默截断 KV 跨度。
  • “stale schedule-time tail state under overlap (0.970 -> 0.390 gsm8k before late binding)” —— 过期 tail 状态的精度灾难,是晚期绑定方案的直接动因。
  • “pre-fix 0.655 + pool-leak aborts, post-fix 0.975 clean” —— 非 spec 路径被误伤的量化证据,说明 spec 分支的 seq_lens_cpu 重建必须严格 scope。
  • “garbage token id -> embedding OOB -> cublas failure” —— 非 overlap relay 未写 output_tokens_buf 时的完整故障链。

实现拆解

整个实现分为 5 步:

  1. 能力门控(spec_info.pyspec_registry.pyspeculative_hook.pyvalidation_hook.py
    - SpeculativeAlgorithm.supports_mixed_chunk() 新增,EAGLE/EAGLE3/DFLASH/DSPARK 返回 True;spec_registry.py 插件基类默认 False,插件算法必须显式实现能力位。
    - _handle_dflash_handle_dspark_handle_eagle_family 中删除三处硬编码禁用,改为按能力判断并输出 warning;check_server_args 断言同步放开,报错文案改为指明具体算法不支持。
    - 影响:后续新增 spec 算法只需实现一个方法即可加入该组合,默认安全关闭。

  2. 混合批次构造(schedule_batch.pymix_with_running
    - spec 分支不再直接拼接 running batch 的 out_cache_loc,而是按 tail_base = r.seqlen - 1req_to_token_pool.req_to_token gather 尾部 bonus 槽。
    - spec 的 seq_lens 停在提交长度(bonus 未提交),本 step 提交该 token,尾部行必须 carry base + 1,否则 attention 元数据算出 qo_len = 0。
    - 因 spec relay 调度期未解析,merge_batch 会置空 seq_lens_cpu,改为从请求状态重建 CPU 镜像;delta 在 spec 下恒为 -1(两种模式下尾部请求状态都无延迟)。
    - 新增 mix_running_indices_cpu,供 overlap 尾部解析在 CPU 侧取 pinned 镜像。

  3. 尾部 token relay 与 overlap 晚期绑定(scheduler.pyoverlap_utils.py
    - 非 overlap 路径:mix 时用新增的 FutureMap.stash_bonus_tokens()output_ids[-1] 写入 running 请求的 pool 行,否则混合输入解析会 gather 到未初始化行。
    - overlap 路径:resolve_forward_inputs 在 forward 入口调用新增的 FutureMap.resolve_mixed_spec_tails(batch),在 publish 栅栏后从 new_seq_lens_buf 读取已提交长度,重建尾部 seq_lens(+1)、out_cache_loc(提交长度处槽位)以及 seq_lens_cpu / seq_lens_sum / prefix_lensFutureMap 因此持有 req_to_token 引用。
    - 设计动机:调度期 CPU 无法得知在飞 verify 的 accept 数,任何调度期 tail 值都必然过期,所以采用“调度期占位 + forward 入口晚期绑定”。

  4. 结果处理与 worker 适配(batch_result_processor.pydflash_worker_v2.pydspark_worker_v2.py
    - mixed spec tail 提交 bonus token 后 req.kv.kv_committed_len += 1,使下一轮 spec prepare 从正确 base 预留槽位;该逻辑依赖上游 Req → req.kv bookkeeping 移动(commit 7d93b30 曾因上游移动而崩溃后修复)。
    - 两个 dflash 家族 worker 在 forward_batch_generation 中改用 new_seq_lens = batch.seq_lens 生成 next draft input,保证发布的提交长度一致。

  5. 测试配套
    - 新增 test/registered/spec/test_spec_mixed_chunk.py:每个算法一个 cell(TestEagle3MixedChunk / TestDFlashMixedChunk / TestDSparkMixedChunk),全部走 overlap、chunk 128,覆盖三个 bring-up 失败模式;注册 base-b 阶段、1-gpu-large、约 800s。
    - 单元测试适配:test_schedule_batch_out_of_place.py_FakeReq 补齐 seqlen 属性与 spec_algorithm 初始化;test_decode_bookkeeping_ownership.py 记录 mixed-tail 的 kv_committed_len owner。

文件 模块 状态 重要度
python/sglang/srt/managers/overlap_utils.py 重叠调度 modified 7.67
python/sglang/srt/managers/schedule_batch.py 批次管理 modified 6.61
python/sglang/srt/speculative/spec_info.py 推测解码 modified 6.05
python/sglang/srt/arg_groups/speculative_hook.py 参数解析 modified 6.45
python/sglang/srt/managers/scheduler.py 调度器 modified 6.29
python/sglang/srt/managers/scheduler_components/batch_result_processor.py 结果处理 modified 5.72
python/sglang/srt/arg_groups/validation_hook.py 参数校验 modified 5.6
python/sglang/srt/speculative/spec_registry.py 插件注册 modified 5.22
python/sglang/srt/speculative/dflash_worker_v2.py 推测解码 modified 5.13
python/sglang/srt/speculative/dspark_components/dspark_worker_v2.py 推测解码 modified 5.13
test/registered/spec/test_spec_mixed_chunk.py 推测解码 added 7.57
test/registered/unit/managers/test_schedule_batch_out_of_place.py 批次管理 modified 4.76
test/registered/unit/spec/test_decode_bookkeeping_ownership.py 推测解码 modified 3.99

关键符号

supports_mixed_chunk stash_bonus_tokens resolve_mixed_spec_tails mix_with_running process_batch_result_prefill forward_batch_generation _get_new_batch_prefill_raw _handle_eagle_family check_server_args

关键源码片段

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

核心实现:新增 `stash_bonus_tokens` 与 `resolve_mixed_spec_tails`,后者是 overlap 下尾部晚期绑定的关键路径,在 publish 栅栏后重建 seq_lens 与 out_cache_loc。

def stash_bonus_tokens(self, indices: torch.Tensor, bonus_tokens: torch.Tensor) -> None:
    """仅写 output_tokens_buf 行;用于不携带 draft 额外信息的 relay。
    普通 stash() 会按 payload 惰性初始化 spec 缓冲区,这里不需要。"""
    self.output_tokens_buf[indices] = bonus_tokens.to(self.output_tokens_buf.dtype)def resolve_mixed_spec_tails(self, batch: ScheduleBatch) -> None:
    """在 overlap 下晚期绑定 spec 混合 batch 的 decode 尾部:调度期的长度
    落后于在飞 step 的 accept 数,因此要在 publish 栅栏之后,从已发布
    的提交长度重建尾部行。"""
    idx = batch.mix_running_indices
    n = int(idx.shape[0])
    if n == 0:
        return
    # 等待最近一次 forward publish 完成,保证 new_seq_lens_buf 已写入
    if self.publish_ready is not None:
        if _is_hip:
            # AMD MI355 上 Event.wait() 拖慢 TPOT,暂时改用同步
            self.publish_ready.synchronize()
        else:
            self.publish_ready.wait()
    fresh = self.new_seq_lens_buf[idx]
    # 尾部 seq_lens 取提交长度 + 1(本 step 提交了 pending 的 bonus
    # token),否则 attention 元数据算出 qo_len = 0,flashinfer 硬崩溃
    seq_lens = batch.seq_lens.clone()
    seq_lens[-n:] = fresh + 1
    batch.seq_lens = seq_lens
    # out_cache_loc 重建:尾部 KV 槽位于提交长度处(预留槽),
    # 按提交长度从 req_to_token gather,而非使用调度期的过期值
    out_cache_loc = batch.out_cache_loc.clone()
    out_cache_loc[-n:] = self.req_to_token[idx.long(), fresh.long()].to(
        out_cache_loc.dtype
    )
    batch.out_cache_loc = out_cache_loc
​
    # CPU 镜像走独立 D2H 流,避免阻塞调度流;非 CUDA 平台退回 .cpu()
    if self.fwd_prepare_d2h_stream is None or self.publish_ready is None:
        fresh_cpu = fresh.cpu() # bootstrap / non-CUDA
    else:
        self.fwd_prepare_d2h_stream.wait_event(self.publish_ready)
        with torch.get_device_module(self.device).stream(
            self.fwd_prepare_d2h_stream
        ):
            self.new_seq_lens_cpu_pinned.copy_(
                self.new_seq_lens_buf, non_blocking=True
            )
        self.fwd_prepare_d2h_stream.synchronize()
        fresh_cpu = self.new_seq_lens_cpu_pinned[batch.mix_running_indices_cpu]
    # seq_lens_cpu / seq_lens_sum / prefix_lens 同步 +1,供 extend 元数据使用
    if batch.seq_lens_cpu is not None:
        seq_lens_cpu = batch.seq_lens_cpu.clone()
        seq_lens_cpu[-n:] = fresh_cpu + 1
        batch.seq_lens_cpu = seq_lens_cpu
        batch.seq_lens_sum = int(batch.seq_lens_cpu.sum())
    batch.prefix_lens = batch.prefix_lens[:-n] + [
        int(x) for x in fresh_cpu.tolist()
    ]
python/sglang/srt/managers/schedule_batch.py core-logic

`mix_with_running` 改为 spec 感知:按提交长度 gather 尾部 out_cache_loc、重建 seq_lens_cpu、seq_lens 尾部 +1,并新增 `mix_running_indices_cpu` 字段。

def mix_with_running(self, running_batch: ScheduleBatch):
    self.forward_mode = ForwardMode.MIXED
    running_bs = running_batch.batch_size()
​
    for req in running_batch.reqs:
        req._refresh_fill_ids()
        full_len = len(req.full_untruncated_fill_ids)
        req.set_extend_range(full_len - 1, full_len)
​
    # running 部分的 decode token 存放在 future_map.output_tokens_buf 中
    self.input_ids = None
    self.mix_running_indices = running_batch.req_pool_indices
    self.mix_running_indices_cpu = running_batch.req_pool_indices_cpu
    if not self.spec_algorithm.is_none():
        # spec 下 running batch 不保留每步 out_cache_loc;按提交长度
        # gather 尾部 bonus 槽(overlap 下 forward 入口还会再晚期绑定)
        tail_base = torch.tensor(
            [r.seqlen - 1 for r in running_batch.reqs],
            dtype=torch.int64,
            device=self.seq_lens.device,
        )
        running_out_cache_loc = self.req_to_token_pool.req_to_token[
            running_batch.req_pool_indices.long(),
            tail_base,
        ].to(self.out_cache_loc.dtype)
        # spec relay 调度期未解析,merge_batch 会置空 seq_lens_cpu;
        # 改从请求状态重建 CPU 镜像,供 extend 元数据路径使用
        running_seq_lens_cpu = torch.tensor(
            [int(r.seqlen) for r in running_batch.reqs], dtype=torch.int64
        )
        if self.seq_lens_cpu is None:
            merged_seq_lens_cpu = running_seq_lens_cpu
        else:
            merged_seq_lens_cpu = torch.cat(
                [self.seq_lens_cpu, running_seq_lens_cpu]
            )
    else:
        # 非 spec:running batch 自带准备好的 seq_lens_cpu,直接拼接;
        # 若被 spec 分支误覆盖,会让尾部 CPU 长度短一步(qo_len = 0)
        tail_base = None
        running_out_cache_loc = running_batch.out_cache_loc
        merged_seq_lens_cpu = None
    out_cache_loc = torch.cat([self.out_cache_loc, running_out_cache_loc])
​
    self.merge_batch(running_batch)
    self.out_cache_loc = out_cache_loc
    if merged_seq_lens_cpu is not None:
        self.seq_lens_cpu = merged_seq_lens_cpu
    if tail_base is not None:
        # spec seq_lens 停在提交长度(bonus token 未提交);本 step 提交它,
        # 尾部必须 carry base + 1,否则 attention 会丢弃这一行
        merged = self.seq_lens.clone()
        merged[-running_bs:] = tail_base + 1
        self.seq_lens = merged
​
    # overlap 调度下 output_ids 延迟一步;spec 尾部请求状态两种模式都不延迟
    if self.spec_algorithm.is_none():
        delta = 0 if self.enable_overlap else -1
    else:
        delta = -1
​
    # NOTE: prefix_indices 表示已缓存内容,但 decode 步不做缓存
    self.prefix_lens = self.prefix_lens + [
        len(r.origin_input_ids) + len(r.output_ids) + delta
        for r in running_batch.reqs
    ]
    self.extend_lens = self.extend_lens + [1] * running_bs
    self.extend_num_tokens = self.extend_num_tokens + running_bs
    self.extend_logprob_start_lens = (
        self.extend_logprob_start_lens + [0] * running_bs
    )
    self.is_prefill_only = False

评论区精华

spec seq_lens 停在提交长度导致 tail qo_len = 0 正确性

commit e7047685 指出:spec decode batch 的 seq_lens 停在提交长度(bonus token pending),mixed step 提交该 token 后合并批次仍携带 base 值,attention 元数据算出 qo_len = seq_lens - prefix_lens = 0,flashinfer 直接拒绝该 shape(ragged prefill q rows != qo_indptr[-1]),无校验的后端则静默丢弃 tail 行,DSV4-Flash MTP gsm8k 0.965 → 0.895 疑似由此引入。

结论:mix 时尾部 seq_lens 统一 carry base + 1(`mix_with_running` 中 `merged[-running_bs:] = tail_base + 1`),overlap 下由 `resolve_mixed_spec_tails` 在 forward 入口按发布长度重建。 · 已解决

非 overlap spec 的 relay 未写 output_tokens_buf,混合输入解析读到垃圾 token 正确性

commit 105abed4:非 overlap spec 的 bonus token 只挂在 draft input 上,从不写 output_tokens_buf,混合 input resolve 对 decode 尾部 gather 到未初始化行,故障链为垃圾 token id → embedding OOB → cublas 失败。

结论:调度期在 mix 时用 `stash_bonus_tokens` 把 `output_ids[-1]` 写入尾部 pool 行;overlap 模式保持发布值不动。 · 已解决

Overlap 下调度期 tail 状态必然过期,settled-tails 门在负载下永不生效 设计

commit 34c3a308 先尝试“只 mix 已 settle 的 tail”门控,但负载下总有请求在飞,门永不生效;commit a4e430f4 改为晚期绑定:调度期用过期请求状态占位(依赖 KV 超额预留保证槽位存在),forward 入口在 publish 栅栏后从 new_seq_lens_buf 重建尾部 seq_lens / out_cache_loc / CPU 镜像,恢复 0.970 基线(此前过期状态导致 gsm8k 0.970 → 0.390)。

结论:采用晚期绑定方案,`FutureMap` 持有 req_to_token 引用以支持 reserved-slot gather。 · 已解决

能力表达方式:硬编码字符串 vs supports_mixed_chunk 能力位 设计

commit dc76b1f3 将 enablement 从 EAGLE/EAGLE3 字符串元组改为 `SpeculativeAlgorithm.supports_mixed_chunk()`:降级契约算法无关,应表达为 worker 能力;插件算法默认 False,STANDALONE 与 ngram 各有明确原因保持关闭。

结论:以能力位 + 默认 False 收敛,speculative_hook 与 validation_hook 统一按能力判断并输出 warning。 · 已解决

风险与影响

1) 核心调度路径变更:mix_with_runningprocess_batch_result_prefill 是每个 prefill/decode 周期必经路径,spec 分支新增 tensor 构造、gather 与 clone;overlap 入口新增一次栅栏等待与 D2H 拷贝,对 TPOT 有额外开销(AMD 路径用 synchronize 规避 MI355 上 Event.wait() 的退化)。
2) seq_lens 约定敏感:spec seq_lens 停在提交长度、mixed step 尾部必须 +1 的约定横跨所有 attention 后端,后端对 qo_len = 0 的容忍度不一,后续后端扩展需回归该组合。
3) 上游 bookkeeping 耦合:kv_committed_len 已迁至 req.kv,本 PR 的尾部 +1 依赖该结构,上游进一步重构会直接击穿(commit 7d93b30 即此类事故的现场修复)。
4) 非 spec 回归风险:seq_lens_cpu 重建与 delta 计算必须严格 scope 到 spec 分支,历史上曾导致非 spec mixed chunk 0.655 + pool-leak。
5) 支持矩阵有限:NGRAM / STANDALONE 与未实现能力位的插件算法会被降级(有 warning),与旧的“直接禁用”语义不同,需要用户注意行为差异。

对用户:开启 --enable-mixed-chunk 后不再需要关闭投机解码,混合负载(长 prefill 与 decode 并存)下可同时获得 chunked prefill 的调度收益与 spec 的 decode 加速;不支持的算法组合会得到明确的 warning 或参数校验报错。对系统:影响调度器批次构造、overlap 前向解析、批次结果处理三条核心路径,以及 EAGLE3 / DFLASH / DSPARK 三个 worker 的输入构建。对团队:CI 新增约 800s 的 base-b 用例(1-gpu-large),并确立“算法能力位”这一可扩展门控模式,后续插件算法只需实现 supports_mixed_chunk() 即可接入。

核心调度路径变更 seq_lens 约定敏感 上游 bookkeeping 耦合 多 attention 后端兼容风险

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论