Prhub

#39109 [Router] Expose the KV storage-tier stream and tree occupancy on /metrics (2/4)

原始 PR 作者 Kangyan-Zhou 合并时间 2026-09-16 04:30 文件变更 9 提交数 1 评论 2 代码增减 +1085 / -41

执行摘要

路由器 /metrics 暴露 KV 分层存储事件与树占用指标

PR body 明确指出:#39108 修复的 tier bug 存活数月而无人察觉,因为此前没有任何指标把 router 视野中的集群 KV 缓存与引擎实际持有的内容对照起来("nothing plotted the router's view of the fleet's KV cache against what the engines actually held")。router 丢弃 medium 标签与引擎从不发送标签产生完全相同的症状——低 cache-hit 率——且二者都无法与普通引擎侧驱逐区分。本 PR 新增的覆盖率系列(kv_tree_blocks × kv_block_size ÷ 引擎自身占用)让 ~1 表示树镜像引擎、~0 表示引擎持有路由看不见的 tier、>1 表示传输丢批遗留了 tier 位,从而把这三种故障变成可直接告警的数字。

值得精读。该 PR 是“为观测而设计”的样板:固定基数标签折叠、零值即结论、编译期断言钉死三表一致性、穷举 match 替代判别式转型、pull-on-scrape 与结构性零的取舍,每一处都有明确故障场景支撑。建议与 #39108(1/4)、#39110(3/4)连读以获得完整上下文;若团队后续要扩展新的存储 tier,本 PR 中 Tiers::SLOTS 与 MEDIUM_LABELS 的编译期约束是必须复用的模式。

讨论亮点

唯一一条 review 评论来自 sherlockwu(P2,位于 monitoring/README.md 第 57 行):文档中用于覆盖率换算的引擎侧指标名 sglang_kv_used_tokens、sglang_kv_evictable_tokens、sglang_hicache_host_used_tokens 与引擎实际导出名不一致——SchedulerMetricsCollector 声明的是带冒号的 sglang:kv_used_tokens 等名称,且标准 Prometheus 导出器会保留冒号。该建议未被强制修改,PR 随后被 approve;合入后 README 的正文命名仍为下划线写法,运维按文档配置面板时需按引擎真实导出名(冒号)对齐。

实现拆解

1. 变更入口与装配

main.rs 在启动装配时调用 kv_index.metrics_source() 并把返回值写入 app_ctx.kv_metrics;metrics_source() 仅在 maintain_tree == true(本地维护树)时返回 KvIndexMetrics(tree + tally 两个只读句柄,比 KvEventIndex 窄,路由拿不到 add_worker / remove_worker / shutdown)。metadata-only 模式(外部 Indexer 作为路由信号)返回 None,整个 KV 系列从 scrape 中省略,避免输出会被 HELP 文本误读成“tier 流未到达 router”的结构性全零。

2. 新增 tally.rs 计数模块

定义 EventKind(BlockStored / BlockRemoved / AllBlocksCleared)与 EventTally。medium 标签折叠为固定 6 值(4 个 Tiers::WIRE_MEDIA 线上字符串 + untagged + unknown),发布方无法伪造新 series;record() 常见路径无锁无分配,只有未知 medium 才进入带锁的 warn-once 分支;snapshot() 输出所有单元格,0 也保留,“零本身就是发现”。EventKind::slot() / label() 用穷举 match 而非判别式转型加长度断言,新增枚举变体将直接变成 2 个编译错误,而不是 pump 长驻任务里的运行时越界 panic。

3. tree.rs 增量记账与加固

TreeState 新增 occupancy: HashMap<KvWorkerId, TierCounts> 与 accounting_errors: [u64; 2](missing_row / underflow 两个原因槽),在 insert / remove / clear_worker / evict_lru 四个突变点记账,scrape 时一次读锁即可。clear_worker 通过 account_remove_counts() 一次整 carrier 释放(而非每 node 一行),避免失败路径在持有树写锁时打出数十万行 error! 阻塞所有路由决策。add_tiers 改为返回“本次新设置的位差”供记账;TreeState::insert 的两个不变量违反退出路径补记了它们已写入的占用增量(此前永久少计)。新增常量断言把 Tiers::ALL 钉死为 Tiers::SLOTS 的并集,防止新 tier 在覆盖率 gauge 中不可见——debug_recount_occupancy oracle 因遍历同一张 SLOTS 表而无法捕获此类问题。

4. pump 接线与丢批检测

index.rs 把 EventTally 传入 pump_loop,BlockStored / BlockRemoved 按 medium 计数并携带 block 哈希数,AllBlocksCleared 计为 untagged;seq 缺口记入 record_lost_batches() 并打 warn(命名的 worker 可见)。乱序 batch 在计数之前被过滤,不计入 tally;游标重置后重新加入的 worker 合法地从 seq 1 重启,不会误报丢批。

5. /metrics 渲染

metrics.rs 新增 render_kv_tiers(),输出 6 个系列 + 无条件输出的 sgl_router_kv_tree_maintained(区分 metadata-only 模式 / 接线断裂 / 端点回归)。sgl_router_kv_block_size 在第一个 worker 上报前为 0,覆盖率面板乘出 0 而非 NaN。escape_label 提升为 pub(crate) 供 worker_url 标签转义。所有系列均为 pull-on-scrape,worker 下线即停止输出。

6. 文档与测试配套

monitoring/README.md 文档化每个系列、覆盖率公式及其三种读数、未知 medium 行的升级信号;kv_events/mod.rs 补齐 tally 模块与新类型导出;server/metrics.rs 调整可见性。测试全部内嵌在对应源码的 #[cfg(test)] 中,cargo test -p sgl-router 718 个用例全部通过(566 lib + 152 integration),包括随机游走不变量测试(16 seeds × 400 步混合操作后逐断言增量占用等于全量 recount)、/metrics 端点双方向门控测试、丢批与未知 medium 折叠测试。

文件 模块 状态 重要度
experimental/sgl-router/src/policies/kv_events/tally.rs 路由指标 added 9.28
experimental/sgl-router/src/policies/kv_events/tree.rs 路由树 modified 8.86
experimental/sgl-router/src/server/routes/metrics.rs 路由指标 modified 8.94
experimental/sgl-router/src/policies/kv_events/index.rs 路由指标 modified 8.79
experimental/sgl-router/src/server/app_context.rs 路由指标 modified 5.29
experimental/sgl-router/src/policies/kv_events/mod.rs 路由指标 modified 4.86
experimental/sgl-router/src/main.rs 启动装配 modified 4.16
experimental/sgl-router/src/server/metrics.rs 路由指标 modified 4.56
experimental/sgl-router/monitoring/README.md 监控文档 modified 3.7

关键符号

EventTally::record EventTally::snapshot EventTally::record_lost_batches EventTally::warn_unknown_medium EventKind::slot EventKind::label TreeState::account_add TreeState::account_remove TreeState::account_remove_counts TreeState::tier_occupancy TreeState::debug_recount_occupancy add_tiers render_kv_tiers KvEventIndex::metrics_source pump_loop

关键源码片段

experimental/sgl-router/src/policies/kv_events/tree.rs core-logic

树在 4 个突变点增量记账按 worker × tier 的占用,并修复 insert 不变量路径漏记账;新增 Tiers::SLOTS 及编译期断言,是本次改动风险最集中的文件。

/// TierCounts 以 Tiers::SLOTS 的位置为索引:device / host / disk / external。
/// 一个 node 同时落在 device 与 host 时,两个槽位都计数。
pub type TierCounts = [u64; TIER_SLOT_COUNT];
​
/// 把一个 node 的 tier 位累加进 counts。
fn tally_tiers(counts: &mut TierCounts, bits: Tiers) {
    for (slot, (tier, _)) in Tiers::SLOTS.iter().enumerate() {
        if bits.contains(*tier) {
            counts[slot] += 1;
        }
    }
}
​
/// 树内可变状态:新增 occupancy 与 accounting_errors 两个账本。
/// occupancy 在四个突变点(insert / remove / clear_worker / evict_lru)
/// 记账,因此 /metrics 抓取只需一次读锁,不需要整树遍历。
struct TreeState {
    nodes: HashMap<NodeId, Node>,
    by_hash: HashMap<i64, HashSet<NodeId>>,
    next_id: NodeId,
    occupancy: HashMap<KvWorkerId, TierCounts>,
    /// 记账自相矛盾的次数,按原因分槽。release 构建下 debug_assert
    /// 被编译掉,计数器是区分 "worker 无上报" 与 "记账漏记" 的唯一途径。
    accounting_errors: [u64; ACCOUNTING_REASONS.len()],
}
​
impl TreeState {
    /// 按整个 chain 记账:delta 含一条链上所有 node 的 tier 增量,
    /// 一次行查找完成,而不是每 block 一次。
    fn account_add(&mut self, worker: &KvWorkerId, delta: TierCounts) {
        if delta.iter().all(|&c| c == 0) {
            return;
        }
        match self.occupancy.get_mut(worker) {
            Some(counts) => {
                for (acc, d) in counts.iter_mut().zip(delta) {
                    *acc += d;
                }
            }
            None => {
                self.occupancy.insert(worker.clone(), delta);
            }
        }
    }
​
    /// 释放整个 carrier 的记账:clear_worker 一次调用完成整棵树的释放。
    /// 若按 node 逐行记账,失败路径会在持有树写锁时每 node 打一行
    /// error! ——数十万行日志把每个路由决策都堵在后面。
    fn account_remove_counts(&mut self, worker: &KvWorkerId, delta: TierCounts) {
        if delta.iter().all(|&c| c == 0) {
            return;
        }
        let Some(counts) = self.occupancy.get_mut(worker) else {
            // 释放一个没有占用行的 carrier:属于记账漏记的 bug。
            // 计数 + error! 而不是 panic,避免 pump 长驻任务整体宕掉。
            self.accounting_errors[REASON_MISSING_ROW] += 1;
            error!(
                worker = %worker.url,
                dp_rank = worker.dp_rank,
                "tree invariant violation: releasing tiers for a carrier with no occupancy row",
            );
            return;
        };
        let mut underflowed = false;
        for (acc, d) in counts.iter_mut().zip(delta) {
            underflowed |= *acc < d;
            // 饱和减:绝不让 gauge 回绕成负数;但若 underflow 导致
            // 全零,下面的 all-zero 判断会删掉一个仍持有 node 的 carrier
            // 的行——所以必须有 accounting_errors 兜底解释这一点。
            *acc = acc.saturating_sub(d);
        }
        if underflowed {
            self.accounting_errors[REASON_UNDERFLOW] += 1;
            error!(
                worker = %worker.url,
                dp_rank = worker.dp_rank,
                "tree invariant violation: occupancy underflow",
            );
        }
        if counts.iter().all(|&c| c == 0) {
            self.occupancy.remove(worker);
        }
    }
}

评论区精华

README 中引擎指标名未使用带冒号的真实导出名 documentation

sherlockwu 指出:覆盖率换算指导里引用的 `sglang_kv_used_tokens`、`sglang_kv_evictable_tokens`、`sglang_hicache_host_used_tokens` 与引擎实际导出名不一致——`SchedulerMetricsCollector` 声明的是 `sglang:kv_used_tokens`(带冒号),标准 Prometheus 导出器会保留冒号,按文档配置面板会对不上。

结论:P2 级别建议,未强制修改;PR 随后被 Approve。README 中仍为下划线写法,运维落地时需按带冒号的真实导出名对齐。 · approved(P2 建议未修改)

风险与影响

  1. 热路径记账开销:TreeState 的 insert / remove / clear_worker / evict_lru 四个突变点都新增了占用记账。设计上按 chain 而非按 block 记账(account_add 一次行查找),但仍属于路由决策核心路径上的额外写操作;若未来分层缓存的备份密度上升,需要复查该处的分配与锁开销。
  2. 丢批检测依赖 seq 稠密假设:sgl_router_kv_event_batches_lost_total 由发布方稠密序号缺口推断,若某版本发布方改变序号语义(如跳跃式)会产生误报;游标在 remove_worker 时重置,重连后从 1 重启是合法场景,pump 已按 last_applied 比较处理。
  3. 覆盖率口径为人工换算:kv_tree_blocks × kv_block_size ÷ 引擎占用 需要运维自行保证两侧同一 deployment,block size 与成员都可能不同,跨部署相除会得到无意义比值;HELP 文本与 README 已强调但无法强制。
  4. 记账错误是静默类故障:underflow 饱和减到 0 后会删除 carrier 行,面板会读成“worker 无上报”,只有 accounting_errors_total 能区分;release 构建下 debug_assert 被编译掉,依赖计数器兜底。
  5. CI:PR Test (Base) 通过,但 PR Test (Extra) 运行 #34999679570 失败,材料未给出失败原因,合入前建议确认与本次 Rust 改动无关。
  6. PR body 提到 TreeState::insert 两个不变量退出路径补记了占用增量(修复了 gauge 永久少记),这是对既有路径的修复,虽不改变路由行为,但改变了计数器语义,属于需要回归关注的点。

对运维用户:首次获得 router 视野内 KV 分层缓存的完整时间序列,可将 tier 失配、丢批、未知 medium(需升级 router)从“低命中率”这一模糊症状中分离出来;这是本 4 连 PR 栈能否落地 Grafana 面板(4/4)与真实 GPU e2e 测试(3/4)的观测基础。对系统:抓取时对树持一次读锁读取占用快照,pump 路径新增无锁原子计数,路由决策行为零变化——PR body 明确“Pure addition: no routing behaviour changes”。对团队:确立了“HELP 文本即契约”的指标设计风格(零行也输出、模式开关无条件输出、未知标签折叠为固定基数),后续新增存储 tier 时必须同步维护 SLOTS / WIRE_MEDIA / MEDIUM_LABELS 三表一致性,编译期断言已把这一步变成强制。影响范围集中在 experimental/sgl-router crate,对 python/sglang 服务端无直接改动。

热路径新增记账 seq 缺口推断依赖稠密序号 覆盖率口径需人工换算 P2 指标名未对齐 Extra CI 运行失败待确认

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论