Prhub

#35130 Fix NIXL cleaner grouping for hybrid cache keys

原始 PR 作者 yangweigbh 合并时间 2026-08-19 02:55 文件变更 2 提交数 2 评论 8 代码增减 +75 / -18

执行摘要

NIXL cleaner 修复 hybrid 键分组解析

issue #32702 明确指出 L3 cleaner 的设计保证是“按逻辑缓存条目原子驱逐”,即一个条目的所有物理分片(per-rank 文件、K/V 分片、hybrid 池组件)必须作为一个组一起删除,以维持 batch_exists 的全有或全无不变量。但 _parse_group_key() 只处理尾部裸 _k/_v 与尾部 _{tp_rank}_{tp_size} 两种形态,hybrid 组件位于 rank 后缀之后,锚定匹配失败,一个逻辑页会碎片化为多个组。后果正如 issue 所述:在 watermark 压力下 cleaner 按 mtime 独立删除碎片,batch_exists 报条目缺失而大部分字节仍占磁盘——“the worst of both: the space is not reclaimed proportionally and the entry is gone”。PR body 第一句也强调:“The NIXL FILE L3 cleaner must evict every physical shard of a logical cache entry atomically.”

值得精读。虽然 diff 很小,但包含三个可借鉴的设计点:(1)从 PoolName 枚举派生正则模式而非硬编码字符串,用“数据驱动规则”减小解析器与键生成之间的漂移;(2)按长度降序排列交替模式,优雅解决 swadraft_swa 的前缀重叠;(3)测试用 subTest + 全形态枚举 + 原子删组断言,把“单测正确性”与“端到端驱逐语义”都锁死。建议同时阅读 issue #32702 中三个候选方案的权衡讨论——如果后续 NIXL cleaner 再做重构,可考虑向方案 1(构造时注入命名信息)演进,从根上消除解析器追逐键生成的问题。

讨论亮点

本 PR 的审查讨论不多但流程清晰:

  • ishandhanani 审阅后直接 APPROVED(无 inline review comment),并主动触发 /tag-and-rerun-ci/rerun-group hicache 验证。
  • /rerun-group hicache 结果由 github-actions 机器人汇总:1-gpu-h100(2 tests)、2-gpu-h100(4 tests)、1-gpu-5090(1 test)、4-gpu-h100(2 tests)全部 ✅,覆盖 test_hicache_spec_file_storage.pytest_hicache_variants.pytest_hicache_storage_* 等存储与规格测试。
  • yangweigbh 在评论中提醒“ishandhanani already approved, still needs a mem_cache CODEOWNER approval”,ishandhanani 随后以“only nixl l3 code was touched”说明改动仅限 NIXL L3 cleaner,帮助收敛审批范围。
  • 值得注意:issue #32702 作者(yangweigbh)在 issue 中提出三种候选方案,其中更偏好“方案 1:构造时把命名后缀集传给 cleaner”,而本 PR 实际采用“方案 2:扩展解析器”的小 diff 路线,这一取舍在代码 review 中没有被单独挑战。

实现拆解

实现分四步:

  1. 构建 hybrid 组件后缀模式python/sglang/srt/mem_cache/storage/nixl/nixl_cleaner.py):新增 from sglang.srt.mem_cache.hicache_storage import PoolName 导入,从 PoolName 枚举值派生 _POOL_NAME_PATTERN(用 re.escape按长度降序排序,保证 draft_swa 优先于 swa 参与匹配,避免前缀重叠误剥离),再构造编译一次的正则 _HYBRID_COMPONENT_SUFFIX_RE,匹配 _{pool}_{pool}_k/v_{pool}_temporal_{pool}_conv_\d+_{pool}_\d+ 等形态。

  2. 调整 _parse_group_key 解析顺序:在既有的 K/V 后缀循环与 _RANK_SUFFIX_RE 剥离之前,先执行 _HYBRID_COMPONENT_SUFFIX_RE.sub("", stem)。因为 hybrid 物理名是 {hash}{config_suffix}{component} 且组件在 rank 之后,必须先剥组件才能让 rank 的锚定 $ 匹配重新生效。docstring 同步补充 HiCacheNixl._get_hybrid_component_keys 这一命名来源。

  3. 测试重构与新增覆盖test/registered/unit/mem_cache/test_hicache_nixl_cleaner.py):提取 _run_single_group_cleanup() 辅助方法避免重复构造 cleaner;保留并强化原有 rank/K/V 形态测试;新增 test_parse_group_key_strips_hybrid_component_suffix(用 subTest 覆盖 kv_kswa_k/vmamba_temporalmamba_conv_0indexer_2draft_swa、无 rank 的 deepseek_v4_c4_indexer_state_2 等 8 种形态,全部收敛到 page-a_model)与 test_tick_deletes_hybrid_components_atomically(构造 2 个 rank × 9 种物理后缀共 18 个旧键,验证一次 _tick() 全部删除且新组全部保留)。

  4. 验证方式与 CI:单测在无 CUDA 依赖的 Python 3.10 harness 中运行(macOS checkout,9 passed / 8 subtests,cleaner 模块覆盖率 76%);合并前由 ishandhanani 触发 /tag-and-rerun-ci/rerun-group hicache,1-gpu-h100、2-gpu-h100、1-gpu-5090、4-gpu-h100 四组 hicache 测试全部通过。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/storage/nixl/nixl_cleaner.py 清理器 modified 6.28
test/registered/unit/mem_cache/test_hicache_nixl_cleaner.py 单元测试 modified 6.85

关键符号

_parse_group_key test_parse_group_key_strips_hybrid_component_suffix test_tick_deletes_hybrid_components_atomically _run_single_group_cleanup

关键源码片段

python/sglang/srt/mem_cache/storage/nixl/nixl_cleaner.py core-logic

核心修复文件:`_parse_group_key()` 是 L3 cleaner 按逻辑条目分组驱逐的唯一入口,本次在此新增 hybrid 组件后缀剥离逻辑,并引入从 `PoolName` 派生的编译正则。

# 从 PoolName 枚举派生池名交替模式,并按长度降序排列,
# 保证 draft_swa 在 swa 之前参与匹配,避免前缀重叠导致误剥离。
_POOL_NAME_PATTERN = "|".join(
    sorted((re.escape(pool.value) for pool in PoolName), key=len, reverse=True)
)# hybrid 组件后缀包括 _swa_k / _mamba_temporal / _mamba_conv_0 /
# _indexer_2 / _draft_swa 等形态;该正则负责在 rank 与 K/V 解析
# 之前先剥离组件名,使 rank 后缀重新获得锚定匹配的机会。
_HYBRID_COMPONENT_SUFFIX_RE = re.compile(
    rf"_(?:{_POOL_NAME_PATTERN})(?:_(?:temporal|conv_\d+|[kv]|\d+))?$"
)
​
​
def _parse_group_key(name: str) -> str:
    """把 NIXL FILE 物理文件名归一到逻辑缓存键分组。    物理名由 HiCacheNixl._get_suffixed_key(含 rank 与 K/V 后缀)以及
    HiCacheNixl._get_hybrid_component_keys(含 pool 组件后缀)生成。
    剥离顺序必须固定:先 hybrid 组件,再 K/V 后缀,最后 TP-rank 后缀。
    """
    stem = name
    stem = _HYBRID_COMPONENT_SUFFIX_RE.sub("", stem)
    for suffix in _KV_SUFFIXES:
        if stem.endswith(suffix):
            stem = stem[: -len(suffix)]
            break
​
    match = _RANK_SUFFIX_RE.search(stem)
    if match is not None:
        stem = stem[: match.start()]
    return stem
test/registered/unit/mem_cache/test_hicache_nixl_cleaner.py test-coverage

回归测试主力:新增 hybrid 全形态解析断言与跨桶 / 跨组件原子删组断言,并抽取 `_run_single_group_cleanup()` 辅助方法消除重复构造,是防本次缺陷回归的关键屏障。

def test_parse_group_key_strips_hybrid_component_suffix(self):
    """所有 hybrid 组件形态都应归入同一逻辑页的清理组。"""
    names = [
        "page-a_model_7_8_kv_k", # K/V 池的非零拷贝分量
        "page-a_model_7_8_swa_k", # SWA 池 K 分量
        "page-a_model_7_8_swa_v", # SWA 池 V 分量
        "page-a_model_7_8_mamba_temporal", # Mamba 时域文件
        "page-a_model_7_8_mamba_conv_0", # Mamba 卷积片
        "page-a_model_7_8_indexer_2", # 多分量池的第 2 片
        "page-a_model_7_8_draft_swa", # 命名单分量池
        "page-a_model_deepseek_v4_c4_indexer_state_2", # 无 rank 的 DSV4 变体
    ]
​
    for name in names:
        with self.subTest(name=name):
            self.assertEqual(_parse_group_key(name), "page-a_model")
​
​
def test_tick_deletes_hybrid_components_atomically(self):
    """一次清理必须同时删除某个逻辑页的所有池分量与 TP rank。"""
    physical_suffixes = [
        "", # 基础文件
        "_k", "_v", # 零拷贝 K/V
        "_kv_k", "_kv_v", # K/V 池分量
        "_swa_k", "_swa_v", # SWA 池分量
        "_mamba_temporal", # Mamba 时域
        "_mamba_conv_0", # Mamba 卷积
    ]
    old_keys = [
        f"page-old_model_{rank}_2{suffix}"
        for rank in range(2)
        for suffix in physical_suffixes
    ]
    new_keys = [
        f"page-new_model_{rank}_2{suffix}"
        for rank in range(2)
        for suffix in physical_suffixes
    ]
    old_paths = [self._write_key(key, mtime=100.0) for key in old_keys]
    new_paths = [self._write_key(key, mtime=200.0) for key in new_keys]
​
    self._run_single_group_cleanup()
    # 旧组全部消失、新组全部保留,证明驱逐按逻辑条目原子进行
    self.assertFalse(any(os.path.exists(path) for path in old_paths))
    self.assertTrue(all(os.path.exists(path) for path in new_paths))

评论区精华

hicache CI 组回归验证 测试

ishandhanani 触发 /tag-and-rerun-ci 与 /rerun-group hicache;github-actions 机器人汇总 1-gpu-h100、2-gpu-h100、1-gpu-5090、4-gpu-h100 四组 hicache 测试全部通过,覆盖 spec 存储、variants、storage file/mooncake backend 等用例。

结论:hicache 相关 CI 全绿,验证改动未破坏 NIXL 存储与规格加载路径。 · 已解决

mem_cache CODEOWNER 审批范围 other

yangweigbh 提示 ishandhanani 已 APPROVED 但仍需 mem_cache CODEOWNER 批准;ishandhanani 回应 "only nixl l3 code was touched",说明改动面仅限 NIXL L3 cleaner。

结论:通过明确改动范围收敛审批范围,PR 最终合并。 · 已解决

修复方案选型(方案 1 与方案 2 的取舍) 设计

issue #32702 中作者提出三种方案:1) 构造时把 config_suffix 与组件后缀集传给 cleaner,杜绝漂移;2) 扩展解析器剥组件尾缀,diff 小但需持续追逐键生成;3) 仅按 hash 前缀分组,最简单但会跨实例耦合驱逐。作者首选方案 1,本 PR 实际采用方案 2。

结论:PR 选择小 diff 的方案 2,但通过从 PoolName 枚举派生模式缓解了漂移风险;review 中未被进一步挑战。 · 已解决

风险与影响

主要风险点如下:

  1. 解析器与键生成仍可能漂移:issue 作者明确表示“方案 2 的解析器必须一直追逐键生成的变化”,本 PR 采用方案 2。虽然 _POOL_NAME_PATTERNPoolName 枚举派生、已显著缩小漂移面,但未来新增组件形态(如新的池后缀组合)时仍需要同步更新 _HYBRID_COMPONENT_SUFFIX_RE
  2. 正则误匹配边界_HYBRID_COMPONENT_SUFFIX_RE 要求名字以 _{poolname} 结尾,对 MHA/MLA/DSA 命名(无池名)不匹配、行为不变;但若未来某个 config_suffix 恰好以池名字面结尾,存在理论误剥离风险(当前测试未覆盖该形状,例如 xxx_swaswa 是配置后缀而非组件的情况)。
  3. 测试环境限制:PR body 说明单测只在无 CUDA 的 Python 3.10 CPU harness 中运行,CPU 覆盖率 76%;GPU CI 的 hicache 组跑的是存储后端集成测试,并未直接执行该 cleaner 单测,存在“CI 绿但单测未在正式环境跑过”的空档。
  4. 性能影响可忽略:正则模块导入时编译一次,每个文件仅多一次匹配,位于 watermark 触发的后台扫描路径,不在推理热路径。

影响范围:

  • 用户/系统:仅影响使用 hybrid 模型(Mamba、SWA、DSA 等多组件命名)+ --hicache-storage-backend nixl 且启用 L3 cleaner 的场景。修复后,逻辑缓存条目按组原子驱逐,batch_exists 不再出现“条目缺失但磁盘未被按比例回收”的最坏状态。对 MHA/MLA/DSA 纯非 hybrid 场景,正则不匹配、行为完全不变。
  • 性能:不在推理热路径,后台扫描每文件一次正则匹配,开销可忽略。
  • 团队:改动量小(2 文件 +75/-18),依赖新增仅 PoolName 枚举导入;审查与审批流程规范,CI hicache 组全绿。后续维护者需要意识到 cleaner 解析与键生成之间的隐式契约,新增池类型时应同步检查本正则。
解析器与键生成存在再次漂移风险 测试仅在 CPU harness 运行 正则误匹配边界未完全覆盖

关联 Issue

#32702 [Bug] HiCache NIXL L3 cleaner group-key parsing misses hybrid pool suffixes, so eviction is not atomic per logical entry

完整报告

参与讨论