Prhub

#37166 fix(staging): make empty staging rings reusable

原始 PR 作者 YAMY1234 合并时间 2026-08-31 11:59 文件变更 3 提交数 1 评论 2 代码增减 +67 / -17

执行摘要

修复空暂存环水位不前进导致的 PD 暂存转发卡死

PR body 描述了完整失败链路:开启 staging 后,decode 侧分配器可能把空环留在上一次分配的 head 位置;后续分配 wrap 进新一轮并越过陈旧 head 时,prefill 侧 readiness 检查会等待一个永远无法推进的水位。例如 100 字节环中 [0, 60) 释放后旧状态把 watermark 留在 (0, 60),后续 70 字节分配变成 (round=1, offset=0, end=70),在 70 <= 60 上永远等待,即使环内已无存活分配。该问题在异构 attention-TP PD 环境(SGLANG_DISAGG_STAGING_BUFFER=1)中复现,warmup 阶段完成 4/11 请求后停止。

值得精读。两个设计点值得学习:一是环形缓冲区水位的经典解法——用 round 的单调性抵消 tail 归零带来的回退,保证异步 readiness 检查始终可推进;二是作者用"allocator-reset-only 仍失败"的对照实验把问题拆成两个正交缺陷,这种验证方法可复用到其他分布式状态同步类 bug。对维护 staging 传输层的同学,建议后续关注 _send_watermark 静默异常路径与订阅快照丢失时的补偿重试。

讨论亮点

本 PR 没有正式的 Review 评论,唯一的外部交互是作者触发 /rerun-group disaggregation 重跑 CI。值得提炼的"讨论"其实沉淀在 PR body 的验证方案里:作者明确指出"Advancing the round is necessary to keep the watermark monotonic while its tail returns to zero",并设计了 allocator-reset-only 对照臂——该臂仍停在 4/11,从而证明"初始订阅者水位快照"与"空环重启"是两个正交缺陷,必须同时修复。这种把一个问题拆成两个独立根因并用对照实验确认的做法,比单纯贴测试结果更有说服力。

实现拆解

步骤 1:空环状态机重置(staging_buffer.py)

StagingAllocator.free()if not self.allocations 分支中新增 self.round += 1self.head = 0,并把 watermark 置为 (self.round, 0)。原实现把 watermark_tail 取为 self.head(即最后一次分配的末尾,例如 60),在空环场景下该值已无意义且会卡住后续 wrap 分配。推进 round 是为了在 tail 归零时仍保持水位单调递增,这样 prefill 侧 is_watermark_readyprev_round < wm_round 分支就能放行;同时 head 归零后下一次 assign 直接从 offset 0 起步,无需再次 wrap。

步骤 2:订阅者快照 + 发送逻辑复用(staging_handler.py)

register_wm_subscriber() 改为:key 已存在时直接 return,注册成功后立刻调用 _send_watermark 发送 self.staging_allocator.get_watermark() 当前快照。原实现只把订阅者记录进 _wm_subscribers,新会话会以启动值 (0, 0) 等待,错过 decode 侧已经推进的水位。同时把 _free_and_send_watermark() 内联的发送代码抽成静态方法 _send_watermark(receiver, session_id, watermark),释放后广播与订阅快照共用同一发送路径;_free_and_send_watermark 不再使用 decode_req(参数改名 _decode_req),改为遍历 _wm_subscribers.values() 直接广播。

步骤 3:回归测试(test_disaggregation_wire.py)

新增 TestStagingWatermark 两个用例:test_empty_ring_restarts_at_zero 用 100 字节环分配 60 再释放,断言 watermark 为 (1, 0),再分配 70 得到 (0, 1)(offset 0、round 1),精确复现 PR body 中的卡死场景;test_new_watermark_subscriber_receives_current_allocator_state mock 分配器返回 (3, 0),注册新订阅者后断言发送 [b"WATERMARK", b"3", b"0", b"session-new"]。测试文件新增 StagingAllocatorDecodeStagingHandler 导入。

步骤 4:验证与 CI

作者在异构 attention-TP PD 环境(prefill --tp-size 4 --dp-size 4 --ep-size 4 --enable-dp-attention,decode --tp-size 8 --dp-size 1 --ep-size 1 --disable-attn-tp-gather)做了三臂对比:上游基线 warmup 停在 4/11(3 个请求在途);只叠加 allocator 重置仍停在 4/11;两个生产文件同时叠加后 warmup 11/11、profile 846/846 完成、TTFT 与 ITL 覆盖 100%。CI 侧通过 /rerun-group disaggregation 重跑,2-gpu-h100 与 8-gpu-h20 共 10 个测试全部通过。

文件 模块 状态 重要度
python/sglang/srt/disaggregation/common/staging_buffer.py 暂存分配 modified 5.59
python/sglang/srt/disaggregation/common/staging_handler.py 水位广播 modified 7.07
test/registered/unit/disaggregation/test_disaggregation_wire.py 单元测试 modified 5.94

关键符号

StagingAllocator.free StagingAllocator.assign StagingAllocator.get_watermark DecodeStagingHandler.register_wm_subscriber DecodeStagingHandler._send_watermark DecodeStagingHandler._free_and_send_watermark is_watermark_ready handle_watermark_msg

关键源码片段

python/sglang/srt/disaggregation/common/staging_buffer.py core-logic

核心修复点之一:`StagingAllocator.free()` 在环空时推进 round 并把水位重置为 `(round, 0)`,是消除 prefill 侧永久等待的第一半修复。

# python/sglang/srt/disaggregation/common/staging_buffer.py
class StagingAllocator:
    """环状暂存分配器:分配必须连续,放不下剩余尾段时就整体跳到下一轮 offset 0。"""
​
    def assign(self, required_bytes: int):
        """分配一段连续区域,返回 (alloc_id, offset, round) 或 None。"""
        with self.lock:
            if required_bytes > self.total_size:
                return None
​
            space_at_end = self.total_size - self.head
            if required_bytes <= space_at_end:
                offset = self.head
                self.head += required_bytes
            else:
                # 尾段放不下,进入新一轮并从 offset 0 重新开始
                self.round += 1
                offset = 0
                self.head = required_bytes
​
            alloc_id = self.next_alloc_id
            self.next_alloc_id += 1
            self.allocations[alloc_id] = (offset, required_bytes, self.round)
            self.alloc_order.append(alloc_id)
            return (alloc_id, offset, self.round)
​
    def free(self, alloc_id: int):
        """释放分配,并把水位推进到最早存活分配之前。"""
        with self.lock:
            if alloc_id not in self.allocations:
                return
            self.allocations.pop(alloc_id)
​
            # 弹出队首已释放的分配,保证 alloc_order 队首是存活分配
            while self.alloc_order and self.alloc_order[0] not in self.allocations:
                self.alloc_order.pop(0)
​
            if not self.allocations:
                # 空环意味着整轮都可复用:必须推进 round 让水位保持单调,
                # 同时把 tail 归零,否则旧水位 (round, 60) 会让后续 wrap
                # 分配在 70 <= 60 上永远等待(100 字节环、先分配 60 的场景)
                self.round += 1
                self.head = 0
                self.watermark_round = self.round
                self.watermark_tail = 0
            elif self.alloc_order:
                off, _, rnd = self.allocations[self.alloc_order[0]]
                self.watermark_round = rnd
                self.watermark_tail = off
python/sglang/srt/disaggregation/common/staging_handler.py entrypoint

水位协议入口:新 prefill 订阅者注册时立即收到当前水位快照,并抽出 `_send_watermark` 复用释放后广播与订阅快照两条发送路径,是第二半修复。

# python/sglang/srt/disaggregation/common/staging_handler.py
class DecodeStagingHandler:
    """decode 侧暂存处理器:持有分配器,并向所有 prefill 会话广播水位。"""
​
    def register_wm_subscriber(self, receiver, session_id: str) -> None:
        """注册一个 prefill 水位订阅连接。"""
        if receiver is None or not receiver.bootstrap_infos:
            return
        key = tuple(str(bi) for bi in receiver.bootstrap_infos)
        if key in self._wm_subscribers:
            return
​
        self._wm_subscribers[key] = (receiver, session_id)
        # 分配器跨请求常驻,而每个 prefill session 独立学习水位;
        # 新订阅者必须立刻拿到当前快照,否则首个 wrap 分配会一直
        # 等在早已过去的旧水位 (0, 0) 上
        self._send_watermark(
            receiver,
            session_id,
            self.staging_allocator.get_watermark(),
        )
​
    @staticmethod
    def _send_watermark(receiver, session_id: str, watermark) -> None:
        """向一个 prefill 会话发送一条 WATERMARK 消息。"""
        wm_round, wm_tail = watermark
        wm_round_b = str(wm_round).encode("ascii")
        wm_tail_b = str(wm_tail).encode("ascii")
        sid_b = session_id.encode("ascii")
        for bootstrap_info in receiver.bootstrap_infos:
            try:
                sock, lock = receiver._connect_to_bootstrap_server(bootstrap_info)
                with lock:
                    sock.send_multipart([b"WATERMARK", wm_round_b, wm_tail_b, sid_b])
            except Exception:
                # 与既有行为一致:发送失败静默跳过,靠后续广播补偿
                pass

评论区精华

disaggregation CI 组重跑结果 测试

作者触发 `/rerun-group disaggregation`,bot 回复:2-gpu-h100 的 5 个测试(basic、decode_offload、optimistic_prefill、rust_server、unified_memory)全部通过;8-gpu-h20 的 5 个测试(decode_radix_cache、different_tp、dp_attention 等)全部通过;4-gpu-gb300 的 test_disaggregation_aarch64 任务被创建运行。

结论:disaggregation 组回归重跑通过,未发现本 PR 引入的功能回退。 · 已解决

风险与影响

  • 水位协议语义变更:空环时 watermark 会直接跳到新 round,依赖 prefill 端 is_watermark_ready 已有的 prev_round < wm_round 分支放行;该分支原本就存在,此次只是让 round 真正推进。但仍需关注 decode 侧广播与 prefill 侧处理之间的异步时序,若某个 prefill 仍持有旧 (0, 60) 且新消息因网络问题丢失,会继续卡住。
  • _send_watermark 静默吞异常:发送失败仅 pass,无重试;新订阅者快照若发送失败,该会话会以旧值等待。这是既有协议缺陷的延续,本 PR 未引入重试机制。
  • 快照竞态:register_wm_subscriberget_watermark() 与后续 alloc/free 之间有时序窗口,但 prefill 端 handle_watermark_msg 只接受更大的 (round, tail)(单调推进),过期消息不会回退水位,因此竞态方向是安全的。
  • 覆盖范围:新增测试为单元级,未覆盖真实多 rank、异构 TP 的集成路径;E2E 为作者手工验证,仓库 CI 仅有 disaggregation 组的回归测试兜底。

用户影响:启用 staging 的 PD 部署(SGLANG_DISAGG_STAGING_BUFFER=1)不再在 warmup 或运行中因空环水位停滞而完全卡死,这是该特性上线前的阻塞性问题。系统影响:decode 侧分配器状态机与 prefill 订阅协议行为发生变化,但无公开 API、无配置参数变更、无模型数学改动。团队影响:改动仅 3 个文件、67 行新增,附带两个针对性单元测试,ci 重跑 disaggregation 组全部通过,回归风险可控。

水位协议语义变更 异步订阅发送无重试 快照时序依赖单调推进 集成路径仅手工 E2E 验证

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论