Prhub

#35154 fix(unified-memory): four boot/correctness fixes on the hybrid model paths

原始 PR 作者 caihuali95 合并时间 2026-09-01 06:08 文件变更 17 提交数 7 评论 0 代码增减 +1214 / -55

执行摘要

统一内存混合模型路径 4 项启动与正确性修复

PR body 开宗明义:"Four independent bugs on the hybrid model paths, each of which either prevents a model from booting or silently degrades behaviour." 四个 bug 分别是:① 既 mambaish 又 hybrid-SWA 的模型(Inkling 类)在 --attention-backend triton 下无法启动,TritonAttnBackend.init 的中间分支调用 token_to_kv_pool.get_v_head_dim(),而 SWAKVPool 从未实现该方法,纯 hybrid-SWA 模型不是 mambaish 走不到该分支、mamba-hybrid 池自己有实现,所以一直未被发现;② conv-state matcher 默认 is_contiguous() 检查严格强于内核真实契约(内核体按 cache.stride(0)/stride(1) 索引,只需 channel 维连续),统一三池的 page-major 视图天然非连续;③ 统一池 free 路径两次 host 同步——t[idx] = -1 标量 RHS 触发 pageable H2D 阻塞拷贝,torch.unique 因数据依赖形状必须 D2H 读计数,测试 docstring 记录 gpt-oss/Qwen3.5 ps=256 上 77 次 _free_lazy 调用全部在同步;④ RecoverSWAWithLockedFull 假定静态分配器形状去索引统一池不存在的 full_to_swa_index_mapping 张量,而跳过恢复不可行:insert 路径的 new_prefix_len <= len(new_indices) 断言会把跳过变成正确性错误。

值得精读。这是 unified-memory 混合模型路径上线后第一批系统性缺陷修复,对以下读者尤其有价值:① 维护 mem_cache / 分配器相关代码的工程师——释放路径的 host 同步分析(标量 RHS H2D 阻塞、torch.unique D2H 计数)与 free_segment 步长推导是通用 GPU 内存管理经验;② 关注内核契约与 matcher 关系的读者——「matcher 强于内核真实契约」的判断方法可迁移到其他 JIT 内核;③ 测试设计爱好者——AST 源码扫描 + mock.patch 拦截 torch.unique + 功能测试三层护栏的组合非常值得借鉴。建议重点阅读 multi_ended_allocator.py 的 free/free_segment/_page_reps_pieces 与 swa_component.py 的 _page_pairs/_transfer_swa_pages,以及两个对应测试文件的 docstring(它们本身就是完整的设计文档)。

讨论亮点

该 PR 全程没有 inline review 评论(comments_count = 0,review_comments_count = 0),由 ch-wan 合入,并在作者 4 个修复 commit 之外追加了 3 个清理 commit:移除 FlashAttention/Aiter 后端 _apply_cuda_graph_metadata 的两个无效参数、压缩两处过长注释、删除 inkling matcher 上 7 份重复注释。真正有分量的设计论证沉淀在 PR body 与测试 docstring 中,几个关键判断:

  • 「The matcher was strictly stronger than the kernels' real contract」——matcher 默认 is_contiguous() 比内核体(按 stride 索引)的实际约束更严,这是内核契约分析的标准范例。
  • 「torch.unique sorts by id value, and allocation hands out virtual ids in no particular order, so sorting would pair page k of one range with an unrelated page of the other — silent wrong-KV」——这是 _page_pairs 必须用首次出现掩码而非 torch.unique 的根本原因。
  • 「a pageable H2D copy BLOCKS the host until the stream drains」——标量 RHS 赋值与 index_fill_ 的设备行为差异是释放路径去同步的核心洞察,约 16 ms/次。
  • 测试自检:「-1 parses as UnaryOp(USub, Constant(1)), NOT Constant」——AST 扫描测试专门钉住了负数字面量的解析坑,避免扫描静默漏检所有墓碑。
  • 取舍原则:「Plain free() keeps torch.unique — without a position there is nothing to derive from」,即无位置信息时正确性优先于速度。

实现拆解

本 PR 按「一个 commit 一个修复」组织,5 个步骤拆解如下:

  1. 启动修复(swa_memory_pool.py,+9 行):新增 get_v_head_dim(),返回 full 侧 value buffer 在 full_kv_pool.start_layer 处的最后一维,与 HybridLinearKVPool 的既有实现对齐。读 start_layer 而非 layer 0 是正确性关键:第 0 层可能是 SWA 层(fixture 中 layers_mapping[0] 即 SWA),且 pipeline parallelism 下 start_layer > 0 也必须正确。UnifiedSWAKVPool 通过继承自动获得该方法,无需第二份实现。

  2. 内核契约放松(inkling 目录 6 个 .cuh 文件):7 处 TensorMatcher 的 .verify(cache) 全部追加 .with_strides({-1, -1, 1})——slot/window stride 通配、channel 维固定为 1,这是向量化 state 加载唯一依赖的契约。涉及 update_sconv_cache.cuh、causal_conv1d.cuh、draft_extend_sconv.cuh、fused_decode_update.cuh、gather_scatter_sconv.cuh、inkling_ar_fused_decode.cuh。配套测试 test_inkling_sconv_strided_conv_state.py 用两层护栏:CPU 可跑的源码扫描保证任意站点回归即失败(含已知站点数完整性检查、channel 维禁止全通配检查),CUDA + JIT 功能测试用 page-major strided 视图驱动真实 update_sconv_cache 内核并断言与 contiguous 克隆 bit 级一致。

  3. 释放路径去 host 同步(multi_ended_allocator.py,+181/-40,本 PR 最大改动):free() 新增 pages 关键字参数携带位置推导好的 page id 以跳过 torch.unique;新增 _page_reps_pieces() 与 free_segment(),按 start_pos % page_size 推导代表元切片(头部不满页取 free_index[:1],其余按 stride 步进);新增 free_page_reps_group 缓冲,free-group 内缓存 page 代表元而非原始 token,避免 free_group_end 拼接时破坏每段形状;墓碑写入在 free、_free_lazy、_commit_move_batch 三处统一改为 index_fill(0, idx, -1);删除 SGLANG_SORT_FREE_LIST_AFTER_MERGE 开关(environ.py 同步转入 _DeprecatedEnv)。base 与 Mamba/SWA 两个复合分配器全部覆写 free_segment,测试逐一验证三个类未继承 base 的丢弃 start_pos 版本。

  4. SWA locked-full 恢复(swa_component.py,+85/-1):apply_component_action 的 RecoverSWAWithLockedFull 分支先经 unified_allocator() 判断分配器类型。unified 分支用 _page_pairs 按「首次出现」掩码配对两个区间的 page(刻意不用 torch.unique,它按 id 值排序会配错页),_transfer_swa_pages 做 v2p rebind——节点 virtual page 绑定 incoming 的物理页、incoming 表项 index_fill 置 -1、清 inverse history,再经复合池 free() 释放时其 swa_v2p_pages > 0 过滤只退还 full 侧;并断言 incoming 页全部存活(physical > 0),防止把 padding sink 交给节点。静态分支保持原配方,但映射写入统一走 set_full_to_swa_mapping / clear_full_to_swa_mapping API 而非直接索引张量。

  5. 配套清理:flashattention_backend.py 与 aiter_backend.py 删除 _apply_cuda_graph_metadata 两个从未被读取的参数(seq_lens_sum、encoder_lens);test_unified_radix_cache_unittest.py 补 3 行。新增 5 个测试文件全部注册 CPU CI,其中 test_swa_locked_full_recover_unified.py 用真实 UnifiedSWATokenToKVPoolAllocator + stub TreeCore 做端到端断言,并用 _StaticAllocRecorder 验证静态池路径未被 unified 分支劫持。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/multi_ended_allocator.py 分配器 modified 8.8
python/sglang/srt/mem_cache/unified_cache/components/swa_component.py SWA 组件 modified 8.06
python/sglang/srt/mem_cache/swa_memory_pool.py 内存池 modified 5.94
test/registered/unit/mem_cache/test_unified_free_no_host_sync.py 缓存测试 added 7.76
test/registered/unit/mem_cache/test_swa_locked_full_recover_unified.py 缓存测试 added 7.76
test/registered/unit/mem_cache/test_inkling_sconv_strided_conv_state.py 缓存测试 added 7.36
test/registered/unit/mem_cache/test_swa_pool_v_head_dim.py 缓存测试 added 7.2
python/sglang/kernels/jit/csrc/inkling/update_sconv_cache.cuh JIT 内核 modified 2.94
python/sglang/srt/environ.py 环境配置 modified 4.73
python/sglang/srt/layers/attention/flashattention_backend.py 注意力后端 modified 4.19

关键符号

get_v_head_dim free _free_lazy free_segment _page_reps_pieces free_group_begin free_group_end _release_phys_pages_batch clear _unified_allocator _page_pairs _transfer_swa_pages apply_component_action

关键源码片段

python/sglang/srt/mem_cache/multi_ended_allocator.py core-logic

统一池释放路径核心:free/_free_lazy/free_segment/_page_reps_pieces/free_group 重构,移除 torch.unique 与标量墓碑两次 host 同步,是调度线程停顿问题的根治点。

def free(self, free_index: torch.Tensor, *, _pages: Optional[torch.Tensor] = None) -> None:
    """释放 virtual TOKEN id,回收 virtual PAGE id 并解除 v2p / p2v 映射。    `_pages` 携带 `free_segment` 已按 `start_pos` 步长推导好的 page id,
    提供时跳过依赖数据形状的 `torch.unique` 去重;free-group 路径有独立
    的代表元缓冲,不接收该参数。
    """
    with record_function("MultiEndedAlloc.free"):
        if free_index is None or free_index.numel() == 0:
            return
        if self.free_group is not None:
            self.free_group.append(self._copy_for_free_group(free_index))
            return
        if self.lazy_compaction:
            self._free_lazy(free_index, pages=_pages)
            return
        # --- EAGER 路径 ---
        if self.forward_stream is not None:
            torch.cuda.current_stream().wait_stream(self.forward_stream)
        with record_function("MultiEndedAlloc.free.v2p_lookup"):
            free_v_pages = (
                _pages
                if _pages is not None
                else torch.unique(free_index.detach().to(torch.int64) // self.page_size)
            )
            freed_p_pages = self.virtual_to_physical[free_v_pages]
        if bool((freed_p_pages < 0).any().item()):
            self._raise_stale_slot_assertion(free_v=free_v_pages, freed_p=freed_p_pages)
        # 墓碑写入用 `index_fill_` 而不是 `t[idx] = -1`:标量 RHS 会让 torch 在
        # CPU 物化 -1 张量并走 pageable H2D 拷贝,而 pageable H2D 拷贝是 host
        # 阻塞的 —— 调度线程会停在在飞 forward 后面(8192-token prefill 场景
        # 约 16 ms/ 次)。`index_fill_` 走 ATen Scalar 重载:单个设备内核,无
        # host 同步。
        self.virtual_to_physical.index_fill_(0, free_v_pages, -1)
        if self.is_id_owner:
            self.free_virtual_ids = torch.cat([self.free_virtual_ids, free_v_pages])
        self._compact_pending(freed_p_pages)
​
​
def _page_reps_pieces(
    self, free_index: torch.Tensor, start_pos: int
) -> Tuple[torch.Tensor, ...]:
    """取一条 kv-row 段内代表各 page 的 token 切片。    与 `PagedTokenToKVPoolAllocator.free_segment` 同理:一个 page 的 token
    在 kv row 中连续存放,host 侧已知 `start_pos` 时代表元就是 stride 切片,
    无需 `torch.unique` —— 后者输出形状依赖数据,必须先 D2H 读计数再同步。
    对任意段形状精确:不满页的头部是 `[:1]` 项,不满页的尾部是最后一个
    stride 步。
    """
    ps = self.page_size
    offset = start_pos % ps
    if offset == 0:
        return (free_index[::ps],)
    return (free_index[:1], free_index[ps - offset :: ps])
​
​
def free_segment(self, free_index: torch.Tensor, *, start_pos: int) -> None:
    """`free()` 的固定形状版本,见 `_page_reps_pieces`;每页在每个 group 内
    只能由一个调用释放。
    """
    if free_index is None or free_index.numel() == 0:
        return
    if self.page_size == 1:
        self.free(free_index) # token == page,无需去重,普通路径已精确
        return
    pieces = self._page_reps_pieces(free_index.detach().to(torch.int64), start_pos)
    if self.free_page_reps_group is None:
        reps = pieces[0] if len(pieces) == 1 else torch.cat(pieces)
        self.free(reps, _pages=reps // self.page_size)
    else:
        # free-group 内缓冲 PAGE 代表元而非原始 token:`torch.cat` 拼接原始
        # token 会破坏每段形状,group end 时只能退回带同步的去重路径
        self.free_page_reps_group.extend(pieces)
python/sglang/srt/mem_cache/unified_cache/components/swa_component.py core-logic

SWA locked-full 恢复在统一池下的崩溃修复:以 v2p 页属主重绑定替代静态映射张量配方,并用首次出现掩码保证配对位置语义。

def _page_pairs(
    self, full_value: torch.Tensor, incoming_full_value: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
    """取两个 token 区间中指向同一逻辑 token 的 page id 对。    用「首次出现」掩码去重而不是 `torch.unique`:unique 按 id 值排序,
    而分配器发放的 virtual id 并无顺序,排序会把两个区间里互不相关的
    page 配成一对 —— 静默写错 KV。单个共享掩码保证配对是位置性的,
    因而是逻辑性的。
    """
    page_size = self.tree_core.page_size
    kept = full_value.detach().to(torch.int64) // page_size
    incoming = incoming_full_value.detach().to(torch.int64) // page_size
    assert kept.numel() == incoming.numel(), (
        f"locked-full recovery 需要 1:1 的 token 对应关系,"
        f"实际 kept={kept.numel()} vs incoming={incoming.numel()}"
    )
    # 相邻不等检测每个区间的 page 起点,两个区间必须按同一偏移断页
    starts = torch.ones_like(kept, dtype=torch.bool)
    starts[1:] = kept[1:] != kept[:-1]
    incoming_starts = torch.ones_like(incoming, dtype=torch.bool)
    incoming_starts[1:] = incoming[1:] != incoming[:-1]
    assert torch.equal(starts, incoming_starts), (
        "两个区间断页偏移不同,page 粒度的属主转移无法表达该 token 映射"
    )
    return kept[starts], incoming[starts]
​
​
def _transfer_swa_pages(
    self,
    allocator,
    full_value: torch.Tensor,
    incoming_full_value: torch.Tensor,
) -> None:
    """把 swa page 属主从 incoming id 转移到节点自己的 id 上。    静态池配方通过 `full_to_swa_index_mapping` 重指向;unified 池下 swa
    子池的 v2p 表本身就是这份映射,所以同样一次移动就是一次 rebind:
    节点 virtual page 绑定 incoming 的物理页,再把 incoming 表项置为
    tombstone。不分配也不释放任何 page,容量不变,只有属主变化。
    """
    swa = allocator.swa_attn_allocator
    kept_pages, incoming_pages = self._page_pairs(full_value, incoming_full_value)
    physical = swa.virtual_to_physical[incoming_pages]
    # `> 0` 严格判断:-1 = 已 tombstone,0 = padding sink。incoming id 刚被
    # 在飞请求分配,每页必须存活;违反即相当于把 sink 交给节点去服务
    # 全零数据,值得硬失败而不是静默损坏。
    assert bool((physical > 0).all()), (
        f"incoming swa pages 必须全部存活,实际得到 {physical.tolist()}"
    )
    swa.bind(kept_pages, physical)
    swa.virtual_to_physical.index_fill_(0, incoming_pages, -1)
    swa.clear_inverse_history()
if isinstance(action, RecoverSWAWithLockedFull):
    # 保留锁定的 full;把 incoming id 的 swa page 交给节点,只释放
    # incoming 的 full 侧,再把 swa 值写回节点。
    unified = self._unified_allocator()
    if unified is not None:
        # unified 复合池没有 `full_to_swa_index_mapping`:swa 子池的 v2p
        # 就是映射。先 rebind 页属主,再经复合池 free —— 其
        # `swa_v2p_pages > 0` 过滤会跳过刚被 tombstone 的 swa 侧,只释放
        # full 侧。
        self._transfer_swa_pages(
            unified, action.kept_full, action.incoming_full
        )
        unified.free(action.incoming_full)
        self.tree_core.set_component_device_value(
            action.node_id,
            self.component_type,
            self._translate_full_to_swa(action.kept_full),
        )
        return
    # 静态池保持原配方,映射写入一律走分配器 API 而非直接索引张量
    swa_value = self._translate_full_to_swa(action.incoming_full)
    alloc.set_full_to_swa_mapping(action.kept_full, swa_value)
    alloc.clear_full_to_swa_mapping(action.incoming_full)
    alloc.free_full(action.incoming_full)
    self.tree_core.set_component_device_value(
        action.node_id, self.component_type, swa_value
    )
    return
python/sglang/srt/mem_cache/swa_memory_pool.py core-logic

启动崩溃修复点:为 SWAKVPool 增加 get_v_head_dim,从 full 子池的 start_layer 读取维度,UnifiedSWAKVPool 继承复用。

def get_v_head_dim(self):
    # 返回 FULL 侧的维度,与 `HybridLinearKVPool.get_v_head_dim()` 对齐:
    # 向池子询问 v_head_dim 的调用方要的是 full-attention 几何
    # (`TritonAttnBackend.__init__` 的 mambaish 分支)。
    # 读 `start_layer` 而不是 layer 0:第 0 层不一定是 full-attention 层,
    # 且 pipeline parallelism 下 start_layer > 0 也必须正确。
    return self.full_kv_pool.get_value_buffer(self.full_kv_pool.start_layer).shape[-1]

评论区精华

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

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

风险与影响

  1. 释放路径重构(multi_ended_allocator.py):free_segment 的步长推导依赖调用方保证 segment 来自同一 kv-row 且 start_pos 准确。若未来新增分配器子类未覆写 free_segment,base 版本会丢弃 start_pos 静默退回 torch.unique 同步路径——测试 TestEveryUnifiedAllocatorOverridesFreeSegment 已用完整性守卫覆盖现有三个类,但新类仍可能遗漏。另外普通 free() 与 free-group 内的无位置 free 仍保留 torch.unique,属有意的正确性优先。
  2. SWA 恢复重绑定(swa_component.py):_page_pairs 要求两个区间按同一偏移断页,否则断言失败(不会静默错配);physical > 0 断言防止把 padding sink 交给节点服务全零数据。风险在于断言失败即调度器报错,依赖 _page_pairs 的前置条件(incoming 与 kept 1:1 token 对应)在调用链上始终成立。
  3. 内核契约放松(inkling .cuh):slot/window stride 通配后,若未来某个内核体实际依赖完整连续性(例如引入按 slot 连续假设的向量化),将静默接收 strided 数据。测试通过固定 {-1, -1, 1} 并禁止 {-1, -1, -1} 来守住 channel 维契约,但功能测试仅覆盖 update_sconv_cache 一个内核的真实执行。
  4. 环境变量删除:SGLANG_SORT_FREE_LIST_AFTER_MERGE 被移除并转入 _DeprecatedEnv,外部显式设置该变量的部署会收到废弃告警,但不会硬失败。
  5. 无效参数删除(flashattention/aiter):无行为影响,仅签名清理;PR 声明 Musa FA3 子类未覆盖这两个方法,风险极低,但未附测试证明。

影响范围集中在 unified-memory 子系统与混合模型(mamba/SWA/diffusion 系)路径:① Inkling 类(mambaish + hybrid-SWA 且 full/SWA value head dim 相等)模型现在可在 triton 后端正常启动,静态池与统一池同时受益;② 统一池释放路径不再发生调度线程阻塞,实测 gpt-oss、Qwen3.5 ps=256 上原本 77/77 次 _free_lazy 同步降至 0,长 prefill 场景每次释放可省约 16 ms;③ SWA locked-full 恢复在 --enable-unified-memory 下不再崩溃调度器,radix 缓存插入/匹配的一致性断言(new_prefix_len <= len(new_indices))得以维持;④ 对静态池用户,行为保持不变(映射 API 路由相同、性能开关默认关闭)。PR body 给出的 GSM8K n=200 对比:各模型 mean Δ 在 -0.31 ~ +0.06 pt,TPOT 相对静态池 -0.7% ~ +2.4%,精度无回退、性能基本持平。团队层面,5 个新测试文件 +929 行为该子系统建立了可持续回归护栏,特别是 AST 源码扫描测试为同类「防止再引入 host 同步/契约回退」的问题提供了可复用的测试方法论。

统一池释放路径重构 调度关键路径 内核契约放松 位置语义前置条件 AST 扫描回归护栏

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论