Prhub

#35198 [Spec] Relay ngram accept tokens through the FutureMap

原始 PR 作者 hnyls2002 合并时间 2026-08-18 05:21 文件变更 4 提交数 2 评论 3 代码增减 +56 / -9

执行摘要

ngram 接受状态改走 FutureMap 中继,修复跨流竞态

PR body 明确描述了竞态成因:ngram 的 accept_tokens / accept_lens 由 forward 流在 verify 末尾写入,但当请求结束时 NgramVerifyInput.filter_batch 会在 schedule 流上 gather 这些张量,而 forward 可能仍在飞行中——这是对共享 buffer 的跨流先读后写。陈旧值到达 host 后会在下一轮 embedding gather 时崩溃。此前仅靠 #35059 引入的粗粒度 WAR fence 偶然兜底,作者希望通过 FutureMap 中继从根源修复,并顺带移除这个临时特例。

值得精读,尤其是 overlap_utils.py 里 stash / resolve 的时序设计。它展示了一个典型的跨流竞态修复范式:让写者流负责读者流的数据准备,中间层只传索引。建议配合 #35059 一起阅读,理解 coarse / fine fence 的取舍,以及如何用 FutureMap 中继模式收敛多种 speculative 算法的状态传递。

讨论亮点

PR 没有 Review 评论线程。Issue 评论区仅有作者触发的 /rerun-test 命令与 CI 结果回传:test_spec_ngram.pytest_spec_ngram_extra.py 均在 1-gpu-h100 上通过。PR body 中作者说明了验证手段:test_spec_ngram.py 18 个用例通过,其中包含一个 stall 放大的复现用例,改动前 100% 复现竞态崩溃。

实现拆解

  1. 中继 payload 扩展overlap_utils.pyRelayPayload 新增 accept_tokens / accept_lens 可选字段,bonus_tokens 从必填降级为可选(ngram 不产生 bonus)。新增工厂方法 RelayPayload.from_ngram(),将 NgramVerifyInput 的扁平 accept_tokensdraft_token_num 重塑为 (batch, draft_token_num) 二维布局,便于按请求行 scatter 到 pool 索引 buffer。
  2. FutureMap 增加 ngram 中继 bufferFutureMap.__init__ 增加 accept_tokens_buf / accept_lens_buf_maybe_init_ngram_bufs() 按首个 payload 形状惰性初始化,特意用 torch.zeros 而非 empty,保证未 stash 的行 resolve 为 accept_len == 0 的空 splice 而非垃圾长度。stash() 中原先的 ngram 直接 return 改为真实写入两个 buffer。
  3. resolve 阶段接管消费_resolve_spec_extras() 的 ngram 分支不再直接返回,而是按 draft_input.future_indices 从 buffer gather,回填 draft_input.accept_tokens / accept_lens。该 resolve 在 forward 流上按序执行,天然排在上一轮 stash 之后,跨流读写被消除。
  4. filter/merge 只碰索引ngram_info.pyfilter_batch() / merge_batch()future_indices 非 None 时仅切片 future_indices 并立即返回,不再触碰 accept_tokens / accept_lens,确保 schedule 流不读 forward 流仍在写的数据。
  5. 调度入口与 fence 收尾scheduler.py_relay_forward_payload() 在 ngram 分支构造 RelayPayload.from_ngram() 并调用 future_map.stash()ngram_worker.py 删除 shared_read_done_event = None 粗粒度 fence 特例,ngram 回归细粒度 shared-read fence。测试无新增文件,依赖现有 test_spec_ngram.py(18 个用例)与 test_spec_ngram_extra.py 回归,作者用 stall 放大法在改动前 100% 复现了竞态。
文件 模块 状态 重要度
python/sglang/srt/managers/overlap_utils.py 重叠执行 modified 7.66
python/sglang/srt/managers/scheduler.py 调度器 modified 5.96
python/sglang/srt/speculative/ngram_info.py 投机解码 modified 4.9
python/sglang/srt/speculative/ngram_worker.py 投机解码 modified 4.49

关键符号

RelayPayload.from_ngram FutureMap._maybe_init_ngram_bufs FutureMap._resolve_spec_extras FutureMap.stash Scheduler._relay_forward_payload NgramVerifyInput.filter_batch NgramVerifyInput.merge_batch

关键源码片段

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

承载 ngram 中继的全部核心机制:RelayPayload.from_ngram 工厂方法、FutureMap 新增 accept_tokens_buf / accept_lens_buf、_maybe_init_ngram_bufs 惰性初始化、stash 的 ngram 写入分支、_resolve_spec_extras 的 ngram gather 分支。这是本次修复的主战场。

# ---- 生产侧:把 ngram verify 的接受结果打包成中继 payload ----
@classmethod
def from_ngram(cls, draft_input: NgramVerifyInput) -> RelayPayload:
    # accept_tokens 是一维连续 buffer,按 draft_token_num 重塑成
    # (batch, draft_token_num),后续可以按 req 行号直接 scatter 进 pool buf
    return cls(
        bonus_tokens=None,
        accept_tokens=draft_input.accept_tokens.reshape(
            -1, draft_input.draft_token_num
        ),
        accept_lens=draft_input.accept_lens,
    )
​
​
# ---- 存储侧:pool 索引的 ngram 中继 buffer,zeros 兜底 ----
def _maybe_init_ngram_bufs(self, payload: RelayPayload) -> None:
    if self.accept_tokens_buf is not None:
        return
    # 用 zeros 而不是 empty:未 stash 的行 resolve 出来 accept_len == 0,
    # 在 draft prep 里是 " 空 splice",不会拿到垃圾长度
    self.accept_tokens_buf = torch.zeros(
        (self.req_pool_size, payload.accept_tokens.shape[1]),
        dtype=payload.accept_tokens.dtype,
        device=self.device,
    )
    self.accept_lens_buf = torch.zeros(
        (self.req_pool_size,),
        dtype=payload.accept_lens.dtype,
        device=self.device,
    )
​
​
# ---- 中继主路径:forward 流上先写后读,schedule 流只碰索引 ----
def stash(self, future_indices: torch.Tensor, payload: RelayPayload) -> None:
    indices = future_indices
    if indices.shape[0] == 0:
        return # DP idle:payload 是空桩,提前退出避免 shape peek 越界
    if self.spec_algo.is_ngram():
        # ngram 分支只写 accept 相关 buffer,直接落 pool 索引行
        self._maybe_init_ngram_bufs(payload)
        self.accept_tokens_buf[indices] = payload.accept_tokens
        self.accept_lens_buf[indices] = payload.accept_lens
        return
    # eagle 等其他 spec 的 topk / hidden_states 中继保持不变……
​
​
# ---- 消费侧:下一轮迭代在 forward 流上按序 gather ----
def _resolve_spec_extras(self, batch: ScheduleBatch) -> None:
    if self.spec_algo.is_ngram():
        draft_input = batch.spec_info
        if draft_input is None or draft_input.future_indices is None:
            # FIXME:当前只支持连续 verify;混合 prefill/verify 模式未覆盖
            return
        indices = draft_input.future_indices
        if indices.shape[0] == 0:
            return
        # 在 forward 流上按序执行,stash 必然先于本 resolve 完成
        draft_input.accept_tokens = self.accept_tokens_buf[indices].flatten()
        draft_input.accept_lens = self.accept_lens_buf[indices]
        return
    #…… eagle 路径保持不变……
python/sglang/srt/managers/scheduler.py core-logic

调度入口:_relay_forward_payload 的 ngram 分支从 " 直接跳过 " 改为构造 RelayPayload.from_ngram 并调用 future_map.stash,是 ngram 状态真正进入 FutureMap 中继管线的触发点。

def _relay_forward_payload(
    self, future_indices: torch.Tensor, batch_result: GenerationBatchResult
) -> None:
    """Stash 本轮的中继 payload,供下一轮 resolve_forward_inputs 使用。"""
    if self.spec_algorithm.is_ngram():
        # ngram 之前直接跳过(状态走 batch.spec_info),现在也走 FutureMap:
        # 避免 schedule 流在 forward 还在飞行时读取共享 buffer
        if batch_result.next_draft_input is not None:
            payload = RelayPayload.from_ngram(batch_result.next_draft_input)
            self.future_map.stash(future_indices, payload)
        return
    # eagle / 非 spec 路径保持不变:
    # 有 draft 则从 draft_input 构造,否则用采样的 bonus token
    if batch_result.next_draft_input is not None:
        payload = RelayPayload.from_draft_input(batch_result.next_draft_input)
    elif batch_result.has_sampled_token_ids:
        payload = RelayPayload(bonus_tokens=batch_result.next_token_ids)
    else:
        return
    self.future_map.stash(future_indices, payload)
python/sglang/srt/speculative/ngram_info.py core-logic

filter_batch / merge_batch 的行为变化是修复正确性的关键:future_indices 非 None 时只切片索引并立即返回,不再触碰 accept_tokens / accept_lens,避免 schedule 流读取 forward 流仍在写的数据。

def filter_batch(
    self,
    new_indices: torch.Tensor,
    new_indices_cpu: Optional[List[int]] = None,
):
    if self.future_indices is not None:
        # 走 FutureMap 中继时,accept_tokens / accept_lens 不再在这里切片:
        # 这些 buffer 由 forward 流写入,schedule 流上读取会触发跨流竞态;
        # 值会在下一轮 verify 的 resolve 阶段按新索引重新 gather
        self.future_indices = self.future_indices[new_indices]
        return
​
    # 非 overlap 的旧路径:本地直接维护 accept 状态
    if self.new_seq_lens is not None:
        self.new_seq_lens = self.new_seq_lens[new_indices]
    self.accept_tokens = self.accept_tokens.reshape(-1, self.draft_token_num)[
        new_indices, :
    ]
    self.accept_tokens = self.accept_tokens.flatten()
    self.accept_lens = self.accept_lens[new_indices]
​
​
def merge_batch(self, spec_info: NgramVerifyInput):
    if self.future_indices is not None:
        # 同上:合并时只拼接索引,accept 数据由 FutureMap 统一管理
        assert spec_info.future_indices is not None
        self.future_indices = torch.cat(
            (self.future_indices, spec_info.future_indices), dim=0
        )
        return
​
    # 非 overlap 的旧路径保持不变
    if self.new_seq_lens is not None:
        assert spec_info.new_seq_lens is not None
        self.new_seq_lens = torch.cat(
            (self.new_seq_lens, spec_info.new_seq_lens), dim=0
        )
    self.accept_tokens = torch.cat(
        (self.accept_tokens, spec_info.accept_tokens), dim=0
    )
    self.accept_lens = torch.cat((self.accept_lens, spec_info.accept_lens), dim=0)

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. filter/merge 行为变化ngram_info.pyfuture_indices 非 None 时跳过 accept 字段切片,任何在 filter 之后、resolve 之前读取 accept_tokens / accept_lens 的新增路径都会拿到上一轮旧值。当前流程中下游读取都在下一轮 forward 流的 resolve 之后,顺序安全,但后续迭代间新增读取点需要警惕。
  2. buffer 未初始化风险_resolve_spec_extras 的 ngram 分支直接索引 accept_tokens_buf,若出现 future_indices 非空但从未 stash 的路径(如混合 prefill/verify 模式),会触发 AttributeError。代码中的 FIXME 注释已标注 mixed mode 尚未支持,这与 eagle 路径的既有限制一致。
  3. fence 语义变化:移除粗粒度 shared_read_done_event = None 后,ngram verify 的共享状态读取改由框架细粒度 fence 保护。grammar 路径走 host tree 同步读取(注释确认无 pending 异步),但其他共享设备状态若未被 fence 覆盖,可能暴露新竞态。
  4. 测试覆盖:没有新增回归测试文件,stall 放大复现用例未固化进测试套件,后续改动可能重新引入同类竞态。
  5. 平台差异:FutureMap 的 pinned host copy 与私有 D2H 流是 CUDA-only 的,非 CUDA 平台走 plain .cpu() 路径,本改动未对 NPU/XPU 单独验证。

启用 overlap + ngram 组合的用户将不再遇到偶发的 embedding gather 崩溃或错误 token;ngram 从粗粒度 WAR fence 回到细粒度 shared-read fence,理论上减少 schedule 流与 forward 流之间的同步点,恢复部分 overlap 收益,但 PR body 未给出量化数据。架构层面,ngram 与 eagle 的状态中继统一到 FutureMap,为代码中 FIXME 标注的 precomputed draft 支持铺路;团队层面,这是 speculative 路径持续演进的一环,与 #35059 的 shared-read fence 重构、#34478 的 DSpark spec 支持属于同一功能线。

跨流同步竞态修复 无新增回归测试 依赖 resolve 时序 移除粗粒度 fence 混合模式未覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论