Prhub

#39283 [HiCache] Rework the buffer-mode storage prefetch pipeline and retry bookkeeping

原始 PR 作者 xiezhq-hermann 合并时间 2026-09-16 01:48 文件变更 43 提交数 2 评论 7 代码增减 +3058 / -865

执行摘要

重构 HiCache buffer 预取管线,修复命中记账错乱与重试失控

PR body 明确指出:buffer-mode HiCache 此前在 prefill adder 内部决定 staged L3 hit 的命运,但 admission 已先按 host-hit token 记账,一旦设备前缀在排队期间被收缩,这些已记账的 token 无法再拼接,负载下产生 phantom host-hit accounting、holds 卡住准入以及无界 storage-prefetch 重试;此外 PD-prefill 与 PP 调度器在一次调度器重构后停止了 HiCache 事件排空。作者的目标是让 L3 hit 在准入选择前完成规划、只物化一次,并把重试路径变为有界行为。

值得精读。这是 HiCache buffer-mode 预取管线的核心重构,建议重点关注三个设计决策:一是 add_one_req 的选择/提交分离模式,是“先记账后确认”型准入缺陷的通用解法;二是 FULL-only pin 与 SWA segment lock 的分离理由;三是 Rust/Python 双实现共享测试时的显式覆盖回退管理方式(NotImplementedError stub + 测试 skip)。对缓存子系统的维护者,本 PR 的 rebase 历史(适配 #38483、#32888、#39318)也是理解项目演化脉络的很好素材。

讨论亮点

该 PR 没有产生实质 code review 讨论(两位 reviewer 直接 approve,其中一位评论 “Awesome”),设计权衡主要由作者在 PR body 与 issue 评论中自述。值得记录的几个要点:

  • “The last one passed under Rust before this PR, so this is a deliberate coverage regression for buffer-only + SWA + L3 on the Rust core until the helpers are ported; the Python core is unaffected.”

作者明确承认 Rust 后端 3 个共享测试跳过是一次有意的覆盖回退,而非无意的遗漏。

  • “StoragePrefetchRetries stays keyed by rid (it paces requests in the waiting queue, so an attempt change must not orphan a pending retry).”

在 rebase 到 #39318(attempt-scoped CacheRequestHandle)时,作者决策:预取管控按 rid 键控,避免 attempt 变化导致等待队列中的重试孤儿化。

  • “Fixtures now unregister the hook via addCleanup; peak device memory for the suite drops from ~33 GiB to ~7 GiB.”

1-GPU CI shard OOM 的根因是 init_hicache 用 atexit 注册 cache.shutdown,钉住所有 HiCache fixture 直到进程退出;修复后显存峰值大幅下降。

实现拆解

该 PR 的实现分五个层次推进:

  1. 调度器与准入流程解耦:PrefillAdder.add_one_req 拆分为“选择”与“提交”两阶段——_select_prefill_admission 在节点锁下只计算 prefill shape(不分配槽位、不物化 host hit),_commit_prefill_admission 再通过 tree_cache.init_load_back 物化 host hit 并提交预算。init_load_back 可返回 None 触发重新选择;FULL 超额交付时收缩 extend range,欠额交付则按零 host hit 重新选择,部分交付会抛 RuntimeError 阻止半提交。SWA 预算判断收敛进 _swa_admission_gate,保留上游 #38192 的 ring-slot 精确匹配语义。

  2. 有界重试与事件排空:新增 mem_cache/storage_prefetch.py 的 StoragePrefetchRetries,统一管理 miss 的节流轮询 poll_miss、已知 hit 锚点移动后的立即重查 refetch 以及等待队列顺序化 pop_ready,配合 --hicache-storage-prefetch-retry-max-attempts(默认 8)封顶;scheduler._process_storage_prefetch_retries 消费到期项,_prefetch_after_device_hit_loss 处理排队期间设备前缀收缩后的重查。新增 _process_hicache_events 在 get_next_batch_to_run 开头统一执行事件排空,恢复 PD-prefill 与 PP 循环的 HiCache 事件处理。

  3. Buffer-mode 预取管线生命周期重构:staged prefetch 锚点改为通过 match_full_device_prefix 在活树上重新匹配,并用 inc_full_pin / dec_full_pin 对 FULL 槽位加 FULL-only pin(有意不动 SWA segment lock,因为其 receipt 在窗口重挂后失效);prepare_prefetch 只返回 staging_tokens 大小,命中确认后才由 alloc_prefetch_staging 分配 staging 缓冲;准入期设备容量 deferral 设上限 max_staged_admission_defers = 32,超限即丢弃 hold 并重算。

  4. SWA 墓碑窗口修复与 Rust 同步:Python tree core 新增 _walk_span、swa_tombstone_ranges、attach_swa_window 支撑 buffer-mode 消费时修补墓碑化的 SWA 窗口;Rust unified_tree_core.rs 补齐 match_full_device_prefix、inc_full_pin、dec_full_pin 以及 staging_tokens 参数,但两个 SWA 修复助手暂以 NotImplementedError stub 挂载,3 个共享测试在 Rust 后端跳过。

  5. 测试与配置配套:新增 test_scheduler_hicache_events.py(4 个场景验证 drain 时机与 feature gate)和 test_storage_prefetch_lifecycle.py(498 行覆盖 trim 边界、双 rank 重试轨迹、锚点丢失);test_unified_radix_cache_unittest.py 增加 653 行并修复 atexit 显存钉死问题(峰值 33 → 7 GiB);新配置项 hicache_storage_prefetch_retry_max_attempts 与 hicache_storage_prefetch_retry_poll_interval 落入 arg_groups/fields/memory.py。

文件 模块 状态 重要度
python/sglang/srt/managers/scheduler.py 调度器 modified 8.96
python/sglang/srt/managers/schedule_policy.py 预填准入 modified 8.93
python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py 缓存树 modified 8.96
python/sglang/srt/mem_cache/buffer_mode/pipeline.py 预取管线 modified 8.93
python/sglang/srt/mem_cache/storage_prefetch.py 预取重试 added 8.76
python/sglang/srt/mem_cache/unified_radix_cache.py 缓存集成 modified 8.93
rust/sglang-radix-tree/src/unified_tree_core.rs 缓存树 modified 8.35
python/sglang/srt/mem_cache/rust_tree_core/adapter.py Rust 适配 modified 8.35
test/registered/unit/managers/test_scheduler_hicache_events.py 调度测试 added 8.16
test/registered/unit/mem_cache/test_storage_prefetch_lifecycle.py 预取测试 added 8.14

关键符号

_select_prefill_admission _commit_prefill_admission _swa_admission_gate _process_hicache_events _process_storage_prefetch_retries _retry_storage_prefetch _prefetch_after_device_hit_loss match_full_device_prefix inc_full_pin dec_full_pin swa_tombstone_ranges attach_swa_window prepare_staged_prefetch try_lock_anchor plan_staged_splice pop_ready poll_miss refetch cancel alloc_prefetch_staging swa_transient_size

关键源码片段

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

调度主路径:新增 _process_hicache_events 统一事件排空,重试逻辑改为 _process_storage_prefetch_retries / _retry_storage_prefetch / _prefetch_after_device_hit_loss,恢复 PD-prefill 与 PP 循环的 HiCache 事件处理。

def _process_hicache_events(self) -> None:
    # HiCache 事件排空是 TP 全域共识:必须在 rank 本地决策(如
    # _should_defer_prefill)之前执行,否则各 rank 会进入不同集合通信
    if (self.enable_hierarchical_cache or get_memory().enable_flexkv
            or self.enable_unified_cache_external_linker):
        self.tree_cache.check_hicache_events()
        if self.enable_hicache_storage:
            self._process_storage_prefetch_retries()
​
​
def _retry_storage_prefetch(self, req, storage_hit_end=None) -> None:
    req.storage_prefetch_retry_attempts += 1
    max_attempts = get_memory().hicache_storage_prefetch_retry_max_attempts
    if req.storage_prefetch_retry_attempts >= max_attempts:
        # 达到重试上限:不再发起 L3 查询,直接按设备已有内容准入
        logger.warning(
            "HiCache storage prefetch reissue cap reached req=%s attempts=%d; "
            "the request is admitted without further L3 lookups",
            req.rid, req.storage_prefetch_retry_attempts)
    else:
        logger.debug("HiCache storage prefetch re-issue req=%s attempt=%d",
                     req.rid, req.storage_prefetch_retry_attempts)
    self._prefetch_kvcache(req, storage_hit_end)
​
​
def _prefetch_after_device_hit_loss(self, req: Req) -> bool:
    # 排队期间设备前缀被换出后,重新查询新暴露的 L3 区间;
    # 已有 staged hold 时不再重查,避免与已停靠的 fetch 冲突
    previous_match_len = req.storage_prefetch_last_match_len
    buffer_pipeline = self.tree_cache.buffer_pipeline
    if not previous_match_len or (
        buffer_pipeline is not None
        and buffer_pipeline.has_staged(req.cache_request_handle)
    ):
        return False
    current_match_len = len(req.prefix_indices) + req.host_hit_length
    if current_match_len >= previous_match_len:
        return False
    if req.storage_prefetch_retry_attempts >= get_memory().hicache_storage_prefetch_retry_max_attempts:
        # 超过重试上限:接受较短的实际匹配,不再重试
        req.storage_prefetch_last_match_len = current_match_len
        return False
    logger.warning(
        "HiCache device prefix shrank before admission req=%s "
        "lookup_match=%d current_match=%d; reissuing storage lookup",
        req.rid, previous_match_len, current_match_len)
    self._retry_storage_prefetch(req)
    return True
python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py core-logic

Python 树核新增 FULL-only pin(inc_full_pin / dec_full_pin)、只读 FULL 设备前缀匹配 match_full_device_prefix,以及 SWA 墓碑区间扫描与窗口重挂(swa_tombstone_ranges / attach_swa_window)。

def swa_tombstone_ranges(self, key: RadixKey, start: int, end: int) -> list[tuple[int, int]]:
    """返回 ``[start, end)`` 内被 SWA 墓碑覆盖的最大连续区间。"""
    ranges: list[tuple[int, int]] = []
    for child, pos, prefix_len in self._walk_span(key, end):
        seg_end = pos + prefix_len
        if seg_end <= start:
            continue
        # SWA 设备 value 为 None 即墓碑段,需要从负载中补回
        if child.component_data[ComponentType.SWA].value is not None:
            continue
        lo, hi = max(start, pos), min(end, seg_end)
        if ranges and ranges[-1][1] == lo:
            ranges[-1] = (ranges[-1][0], hi) # 相邻区间合并
        else:
            ranges.append((lo, hi))
        if hi >= end:
            break
    return ranges
​
​
def attach_swa_window(self, key, window_start, window_end, swa_values):
    """把 L3 加载回来的 SWA 片段挂回墓碑化的树区间,返回产生的切分动作。"""
    assert len(swa_values) == window_end - window_start, (
        f"attach_swa_window size mismatch: got {len(swa_values)} "
        f"for [{window_start}, {window_end})")
    actions: list = []
    covered = window_start
    for child, pos, prefix_len in list(self._walk_span(key, window_end)):
        seg_end = pos + prefix_len
        if seg_end <= window_start:
            continue
        seg_start = max(pos, window_start)
        assign_end = min(seg_end, window_end)
        # 只能挂到墓碑段上,覆盖活 SWA 说明状态已不一致
        assert child.component_data[ComponentType.SWA].value is None, (
            f"attach_swa_window over live SWA at [{seg_start}, {assign_end}) "
            f"of [{window_start}, {window_end})")
        self._attach_swa_segment(
            child, pos, seg_start, assign_end,
            swa_values[seg_start - window_start: assign_end - window_start],
            actions)
        covered = assign_end
        if covered >= window_end:
            break
    assert covered == window_end, (
        f"attach_swa_window covered {covered} of [{window_start}, {window_end})")
    return actions
python/sglang/srt/mem_cache/storage_prefetch.py core-logic

新增模块:用 StoragePrefetchRetries 统一管理 paced miss 轮询与锚点移动后的立即重查,按等待队列顺序和 step 时钟保证 TP rank 同步,是本次“有界重试”的核心簿记。

class StoragePrefetchRetries:
    """新 L3 尝试的统一簿记:step 时钟让 TP 各 rank 保持同步,
    重试按 waiting 队列顺序发出,超过预算的请求直接按现有设备内容准入。"""
​
    def __init__(self):
        self._pending: dict[str, _StoragePrefetchRetry] = {}
        self._step = 0
​
    def poll_miss(self, req_id: str, storage_hit_end=None) -> None:
        # 非 immediate 的 miss:按 poll 间隔等待,避免空转
        self._pending[req_id] = _StoragePrefetchRetry(False, storage_hit_end)
​
    def refetch(self, req_id: str, storage_hit_end=None) -> None:
        # 已知 hit 但锚点移动:下一 pass 立即重查
        self._pending[req_id] = _StoragePrefetchRetry(True, storage_hit_end)
​
    def cancel(self, req_id: str) -> None:
        self._pending.pop(req_id, None)
​
    def pop_ready(self, waiting_queue, interval, max_attempts):
        self._step += 1
        if not waiting_queue or not self._pending:
            return []
        # 推测性 miss 绝不能阻塞队头:队头的 spec miss 直接取消
        head_id = waiting_queue[0].rid
        head_retry = self._pending.get(head_id)
        if head_retry is not None and not head_retry.immediate:
            self.cancel(head_id)
        if not any(
            retry.due_step is None or retry.due_step <= self._step
            for retry in self._pending.values()
        ):
            return []
​
        ready = []
        for req in waiting_queue:
            retry = self._pending.get(req.rid)
            if retry is None:
                continue
            if req.storage_prefetch_retry_attempts >= max_attempts:
                # 达到重试上限:放弃该 L3 查询,按设备现状准入
                self.cancel(req.rid)
                continue
            if not retry.immediate:
                # paced miss:按 interval 节流,interval <= 0 表示不重试
                if interval <= 0:
                    self.cancel(req.rid)
                    continue
                if retry.due_step is None:
                    retry.due_step = self._step + interval
                if retry.due_step > self._step:
                    continue
            self.cancel(req.rid)
            ready.append((req, retry.storage_hit_end))
        return ready

评论区精华

准入期 load-back 交付与 admission 预算不一致的处理 正确性

选择 / 提交分离后,init_load_back 可能出现三种交付:全部、部分、超额。作者选择:部分交付抛 RuntimeError 拒绝半提交;超额交付保留已批预算但收缩 extend 范围;未交付则重新选择 shape。

结论:全量提交或全不提交;部分交付属于契约违例,必须 fail-loud。 · 已解决

Rust tree core 未移植 SWA 窗口修复助手 设计

buffer-mode 消费需要 swa_tombstone_ranges 与 attach_swa_window 修补墓碑化的 SWA 窗口,但 Rust 侧没有实现;作者在 adapter 中显式抛 NotImplementedError,并跳过 3 个共享测试。

结论:承认是有意的覆盖回退,Python core 不受影响,等待后续 Rust port。 · 待处理

测试套件 atexit 导致 1-GPU shard OOM 测试

init_hicache 注册 cache.shutdown 到 atexit,使每个 HiCache fixture 的设备与宿主池存活到进程退出;1-GPU unit shard 在 main 上已接近 30 GiB,本 PR 新增 L3 测试后超过 32 GiB 显存。

结论:fixture 改用 addCleanup 注销钩子,峰值设备内存从约 33 GiB 降到约 7 GiB。 · 已解决

rebase 三次的上游适配决策 设计

PR 经历三次 rebase:采用 #38483 的 buffer-pipeline anchor release、在 #32888 exact-chunk-fill 上重表达 _select/_commit、以及 #39318 CacheRequestHandle 后保持重试按 rid 键控。

结论:保留本 PR 逻辑并按 handle 键控管线状态;重试簿记必须按 rid,避免 attempt 变化孤儿化等待队列中的重试。 · 已解决

风险与影响

本 PR 存在四类主要风险:

  1. 核心调度路径变更:schedule_policy.py 的 add_one_req 和 scheduler.py 的 _process_hicache_events 是请求准入主路径,选择/提交拆分的任何边界错误都可能表现为吞吐回退或准入卡死;init_load_back 返回 None 触发重新选择的循环路径需要调度器确保不活锁。

  2. Rust 后端覆盖回退:rust_tree_core/adapter.py 中 swa_tombstone_ranges 与 attach_swa_window 是显式 NotImplementedError stub,启用 Rust 树核的 buffer-only + SWA + L3 部署将无法执行窗口修补路径,test_buffer_only_load_back_trims_head_published_by_sibling 等用例在 Rust 后端被跳过。

  3. 新配置默认值的语义影响:max_attempts = 8 对超预算请求直接“按设备现状准入”,极端 L3 拥塞下可能牺牲部分命中率;poll_interval 默认改为 8 会改变重试节奏,需要监控命中率指标。

  4. 双树核同步维护成本:staging_tokens、FULL pin 等契约同时在 Python 与 Rust 侧维护,任何一侧漏改都会导致共享套件失败或行为分叉,本 PR 已因该问题在 CI 修复过一次 test_swa_prefetch_commit_end_to_end。

影响范围集中在启用 HiCache buffer_only 主机内存模式并接入 L3 存储的部署:修复了高并发下 phantom host-hit 记账、准入 holds 卡死和重试无界三个问题,内部验证显示 warm-cache 前缀命中率不再塌陷。对团队而言,调度器与缓存子系统的内部接口(admission 两阶段、FULL-only pin、staging 契约)发生变化,后续 HiCache 改动需同步 Python 与 Rust 两侧;Rust 树核的 SWA 窗口修补 port 是明确的待办技术债。未启用 HiCache 的普通路径不受影响,因为事件排空与预取重试都受 feature gate 控制。

核心调度路径变更 Rust 后端覆盖回退 新配置参数影响行为 显存占用回归风险 Python/Rust 双 core 耦合

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论