Prhub

#39280 [HiCache] Label radix-cache metrics per rank and split the "shrunk" prefetch reason

原始 PR 作者 xiezhq-hermann 合并时间 2026-09-16 08:54 文件变更 7 提交数 1 评论 6 代码增减 +86 / -18

执行摘要

HiCache 指标按 rank 打标并拆分 shrunk 归因,修复多 rank 求和失真

PR body 明确指出两个可观测性缺陷:其一,"Every scheduler rank runs its own cache over its own KV shard, so the multiprocess registry summed the ranks and the series reported TP x the logical token count",且 storage (L3) collector 已带 tp_rank / pp_rank / dp_rank 标签,两层指标无法对齐;其二,"storage_prefetch_unfulfilled_tokens_total{reason="shrunk"} covered four different terminal outcomes, which made it impossible to tell an anchor loss apart from a splice-time trim"。因此目标是让 L2 序列带上与 L3 一致的 rank 键,并把模糊的 shrunk 桶拆成可区分的诊断原因。

值得精读。如果团队将 HiCache 作为生产级分层缓存使用,本 PR 的指标语义约定(per-shard vs logically replicated、attempt 级诊断 vs 最终 miss 计数)是解读后续 HiCache 面板和告警的前提。特别值得学习的设计决策:一是 rank 标签与 storage collector 键约定对齐,避免 L2/L3 两层指标无法关联;二是在重构导致某些 reason 失去代码路径后,保留 label 预置以维持 dashboard 稳定、同时明确文档口径;三是把"窗口字节 delta / duration sum"定义为活跃带宽,区分纯 rate 含空闲时间的口径。

讨论亮点

本 PR 没有 reviewer 行内评论,hzh0425 直接 APPROVED(无正文)。核心设计取舍记录在作者 xiezhq-hermann 的两条 rebase 评论中:

  • 第一次 rebase 到 main(2fca6d69aa)解决与 #39318(attempt-scoped CacheRequestHandle)的冲突:"the split discard reasons (aux_window_trim / device_overlap / anchor_lost) now key their release and finish calls by the request handle",即 reason 记账按 request handle 组织。
  • 第二次 rebase 到 #39283 / #39480 合并后的 main:"#39283 replaced the admission-time splice trim with a re-fetch (prepare_staged_prefetch / _refetch_staged)... the aux_window_trim / device_overlap outcomes this PR originally introduced have no code path left; this rebase drops the helper and keeps the two remaining splits (anchor_lost at IO-commit anchor loss, cache_admission_shortfall in the cache-mode adder) plus the per-rank L2 labels"。

关键结论:label 预置保留但无代码路径,是为了让既有 dashboard 的 label 集合保持稳定;_refetch_staged 仍会把 shrunk 重取报为 shrunk。这两条评论是作者在 rebase 过程中主动收缩方案范围,避免提交死代码。

实现拆解

  1. 新增 rank 标签派生函数:在 python/sglang/srt/observability/metrics_collector.py 中新增 radix_cache_metric_labels()。DP attention 开启时取 parallel.attn_tp_rank / parallel.attn_dp_rank,否则取 parallel.tp_rank 且 dp_rank=0,并带上 pp_rank,键名与 storage collector 的 DP-aware 约定完全一致,这样同一 rank 的 L2 与 L3 序列可直接对齐合并。
  2. 接入缓存初始化:python/sglang/srt/mem_cache/base_prefix_cache.py 的 BasePrefixCache.init_metrics_collector() 改为调用 radix_cache_metric_labels(self.__class__.__name__, get_parallel(), is_dp_attention_enabled()) 构造 labels,其中 is_dp_attention_enabled() 延迟导入以避免循环依赖,extra_metric_labels 仍可叠加。
  3. 拆分 shrunk reason:python/sglang/srt/mem_cache/unified_radix_cache.py 的 _handle_storage_prefetch_anchor_loss() 将 reason="shrunk" 改为 reason="anchor_lost"(设备 anchor 在 IO 提交前消失,buffer 模式);python/sglang/srt/managers/schedule_policy.py 的 _account_prefill_cache_admission() 将非 device_capacity 的准入缺口 reason 从 shrunk 改为 cache_admission_shortfall(L3 加载到 L2 的 span 在 cache-mode 准入时已不可复用)。metrics_collector.py 同步扩展 reason 预置集合,并保留 aux_window_trim / device_overlap 两个无代码路径的 label 预置以维持 dashboard 稳定,文档改写为 attempt 级诊断口径,明确它不是最终 prefill cache-miss 计数。
  4. 重写 L2 字节计数器文档:load_back_bytes_total 与 hicache_backup_bytes_total 明确为 per-shard(sum ranks 得物理总量),token 计数器(load_back_tokens_total / hicache_backup_tokens_total)为 logically replicated(read one rank),并给出"窗口字节 delta 除以同窗口 duration sum 为字节加权活跃带宽,plain rate 含空闲时间"的计算口径。
  5. 测试配套:test/registered/unit/observability/test_stat_loggers_di.py 新增 TestRadixCacheMetricLabels 覆盖 DP attention 开/关两种标注派生;test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 将 anchor-loss 用例改名为 test_positive_hit_with_lost_anchor_reports_anchor_lost 并断言新 reason;test/registered/unit/managers/test_prefill_adder.py 断言 cache_admission_shortfall。作者本地跑通 65 passed / 1 skipped。
文件 模块 状态 重要度
python/sglang/srt/observability/metrics_collector.py 指标采集 modified 7.33
python/sglang/srt/mem_cache/base_prefix_cache.py 缓存基类 modified 5.68
python/sglang/srt/managers/schedule_policy.py 调度策略 modified 4.89
python/sglang/srt/mem_cache/unified_radix_cache.py 统一缓存 modified 4.52
test/registered/unit/observability/test_stat_loggers_di.py 指标测试 modified 5.99
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 缓存单测 modified 4.65
test/registered/unit/managers/test_prefill_adder.py 准入测试 modified 3.62

关键符号

radix_cache_metric_labels BasePrefixCache.init_metrics_collector PrefillAdder._account_prefill_cache_admission UnifiedRadixCache._handle_storage_prefetch_anchor_loss UnifiedRadixCache.finish_storage_prefetch_admission

关键源码片段

python/sglang/srt/observability/metrics_collector.py core-logic

核心改动文件:新增 radix_cache_metric_labels() 派生 rank 标签,扩展 storage_prefetch_unfulfilled_tokens_total 的 reason 预置集合(shrunk 拆分为 anchor_lost / aux_window_trim / device_overlap / cache_admission_shortfall),并重写 L2 字节计数器的 per-shard 与带宽文档口径。

# metrics_collector.py —— rank 标签派生与 reason 预置
​
def radix_cache_metric_labels(
    cache_type: str, parallel: Any, dp_attention_enabled: bool
) -> Dict[str, Any]:
    # 每个 scheduler rank 都维护自己的 cache 分片;若不携带 rank 标签,
    # multiprocess registry 会把所有 rank 的 series 求和,上报值变成
    # TP x 逻辑 token 数,且与带 rank 标签的 L3 storage 序列无法对齐。
    # 这里复用 storage collector 的 rank 键约定,保证同一 rank 的
    # L2 与 L3 序列能直接合并计算。
    if dp_attention_enabled:
        # DP attention 下 KV 按注意力域切分,rank 取注意力域内编号。
        tp_rank, dp_rank = parallel.attn_tp_rank, parallel.attn_dp_rank
    else:
        # 非 DP attention:直接用 TP rank,dp_rank 恒为 0。
        tp_rank, dp_rank = parallel.tp_rank, 0
    return {
        "cache_type": cache_type,
        "tp_rank": tp_rank,
        "pp_rank": parallel.pp_rank,
        "dp_rank": dp_rank,
    }
​
​
# RadixCacheMetricsCollector.__init__ 内的 reason 预置:
# 预置全部 reason 标签,保证各路终态在首次上报前就有稳定 series。
# anchor_lost / cache_admission_shortfall 是本 PR 从 shrunk 拆分出的
# attempt 级诊断:前者表示设备侧 anchor 在 IO 提交前被驱逐,后者表示
# L3 加载到 L2 的 span 在 cache-mode 准入时已不可复用。
# aux_window_trim / device_overlap 是历史语义,当前重构后已无代码路径,
# 保留预置是为了让既有 dashboard 的 label 集合保持稳定。
for reason in (
    "below_threshold",
    "host_capacity",
    "device_capacity",
    "device_covered",
    "storage_transfer",
    "anchor_lost",
    "aux_window_trim",
    "device_overlap",
    "cache_admission_shortfall",
    "dropped",
):
    self.storage_prefetch_unfulfilled_tokens_total.labels(
        **self.labels, reason=reason
    )
python/sglang/srt/mem_cache/base_prefix_cache.py dependency-wiring

radix-cache collector 的初始化入口:init_metrics_collector() 从固定 cache_type 标签改为调用 radix_cache_metric_labels() 注入 rank 标签,并延迟导入 is_dp_attention_enabled() 避免循环依赖。

# base_prefix_cache.py —— 所有 radix 缓存(含 UnifiedRadixCache)共用
# 的指标初始化入口,本 PR 在此注入 rank 标签。
​
def init_metrics_collector(self):
    # 延迟导入避免模块级循环依赖;is_dp_attention_enabled 决定
    # 取 attn_tp_rank/attn_dp_rank 还是普通 tp_rank + dp_rank=0。
    from sglang.srt.layers.dp_attention import is_dp_attention_enabled
​
    labels = radix_cache_metric_labels(
        self.__class__.__name__, get_parallel(), is_dp_attention_enabled()
    )
    # extra_metric_labels 仍可叠加,兼容既有自定义标签能力。
    if get_observability().extra_metric_labels:
        labels.update(get_observability().extra_metric_labels)
    radix_cache_cls = resolve_collector_class(
        STAT_LOGGER_ROLE_RADIX_CACHE,
        RadixCacheMetricsCollector,
    )
    self.metrics_collector = radix_cache_cls(labels=labels)
python/sglang/srt/managers/schedule_policy.py core-logic

cache-mode 准入侧 reason 拆分:_account_prefill_cache_admission() 将非 device_capacity 的准入缺口从 shrunk 改为 cache_admission_shortfall,与 anchor_lost 形成两个可区分的 attempt 级诊断。

# schedule_policy.py —— cache-mode 准入后的统计结算:
# L3 命中的 token 若未全部兑现为可用 prefill 前缀,按缺口原因记账。
​
def _account_prefill_cache_admission(self, req: Req, prefix_len: int) -> None:
    if req.retracted_stain:
        # 回缩归属有意暂不计入统计;丢弃其生命周期状态,避免后续
        # abort 误报为 drop。
        self.tree_cache.discard_storage_prefetch_accounting(
            req.cache_request_handle
        )
        return
​
    if prefix_len > 0:
        device_hit, host_hit, storage_hit = split_cached_prefix_by_tier(
            prefix_len=prefix_len,
            host_hit_len=req.materialized_host_hit_len(),
            storage_hit_len=req.storage_hit_length,
            storage_hit_start=req.storage_hit_start,
            host_hit_is_storage=req.host_hit_is_storage,
        )
        self.log_device_hit_tokens += device_hit
        self.log_host_hit_tokens += host_hit
        self.log_storage_hit_tokens += storage_hit
​
    fulfilled_storage_hit = req.fulfilled_storage_hit_len(prefix_len)
    reason = None
    if fulfilled_storage_hit < req.storage_hit_length:
        # 拆分后的语义:host 容量不足仍记 device_capacity;其余不足
        # 路径统一归为 cache_admission_shortfall,表示 L3 加载到 L2 的
        # span 在 cache-mode 准入时已不可复用。这是 attempt 级诊断,
        # 不是最终 prefill cache-miss 计数。
        reason = (
            "device_capacity"
            if req.needs_host_load_back()
            and req.host_loaded_length < req.host_hit_length
            else "cache_admission_shortfall"
        )
    self.tree_cache.finish_storage_prefetch_admission(
        req.cache_request_handle,
        fulfilled_tokens=fulfilled_storage_hit,
        reason=reason,
    )

评论区精华

aux_window_trim / device_overlap 因 #39283 重构失去代码路径的处理 设计

作者在 rebase 评论中说明:#39283 将 admission-time splice trim 替换为 re-fetch(prepare_staged_prefetch / _refetch_staged),导致本 PR 最初引入的 aux_window_trim / device_overlap 两个 split reason 不再有代码路径。此时有三种选择:彻底删除、保留 label 但删除文档、保留 label 与文档。作者选择 drop 掉 _staged_splice_discard_reason helper,但保留 label pre-seed 和文档说明,理由是 dashboards keep a stable label set。

结论:保留 aux_window_trim / device_overlap 的 label 预置与文档描述(作为历史语义),实际只保留 anchor_lost 与 cache_admission_shortfall 两个新拆分;_refetch_staged 继续把 shrunk 重取报为 shrunk。 · 已解决

与 #39318 attempt-scoped CacheRequestHandle 的冲突解决 设计

第一次 rebase 到 main(2fca6d69aa)时,#39318 把缓存请求生命周期改为 attempt-scoped CacheRequestHandle。作者说明 split discard reasons(aux_window_trim / device_overlap / anchor_lost)的 release 与 finish 调用现在按 request handle 组织,相关测试也改为断言 handle。

结论:rebase 完成,discard reason 记账统一以 CacheRequestHandle 为键,测试同步更新。 · 已解决

指标语义文档化:per-shard vs logically replicated、attempt-level vs final miss documentation

PR body 与代码文档明确:字节计数器是 per-shard(sum ranks 得物理总量),token 计数器是 logically replicated(read one rank);storage_prefetch_unfulfilled_tokens_total 是 attempt 级诊断,不是最终 prefill cache-miss 计数。这是对旧文档口径的修正,避免面板读者误解数值含义。

结论:文档已按新口径重写,并在每个计数器上标明读取方式。 · 已解决

风险与影响

  1. 指标 series 基数上升:每个 tp/pp/dp rank 组合都会产生独立 series,在大规模 TP/PP/DP 集群下 Prometheus 序列数量会成倍增长;这是修复指标正确性所必需的代价,但监控存储与查询开销需关注。
  2. 既有 dashboard 不兼容:旧 reason="shrunk" 被拆分为 anchor_lost / cache_admission_shortfall,依赖旧标签的告警规则和面板需要同步更新;好在 _refetch_staged 仍输出 shrunk,系列不会完全断流。
  3. 文档与代码路径偏差隐患:aux_window_trim / device_overlap 仅存在于计数器文档和 label 预置中,当前没有任何代码路径产生这两个值;未来若相关行为回归,需同步清理文档,否则会误导排障人员。
  4. 初始化时序:BasePrefixCache.init_metrics_collector() 依赖 get_parallel() 与 is_dp_attention_enabled() 在缓存初始化时已就绪;若未来缓存初始化早于 parallel 状态装配,可能取到错误的 rank。
  5. 影响面有限:纯指标路径变更,不触碰推理数值(PR 声明 metrics-only),回归风险集中在 collector 初始化和 reason 字符串的测试断言上。

对运维与平台团队:HiCache L2(本机 DRAM)与 L3(远端存储)指标首次可按 rank 对齐,能正确计算物理总量、逐 rank 差异和活跃带宽,为容量规划与带宽瓶颈定位提供准确数据基础;但 dashboard 与告警需适配新 rank 标签和拆分后的 reason。对系统:纯可观测性改动,无推理路径数值影响。对团队:这是 HiCache 分层缓存可观测性建设的关键一环,与 Router 侧存储感知路由指标形成互补。

指标 series 基数随 rank 组合增长 reason 标签拆分影响既有 dashboard 保留无代码路径的 label 预置 纯指标改动无数值回归面

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论