Prhub

#39169 [Router] Pin to the prefix owner when the whole fleet is queueing (--saturation-queue-floor)

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

执行摘要

Router 新增饱和下限,全队排队时钉住前缀拥有者

PR body 指出:在舰队饱和时继续分流,会把一次流量突发放大成「持续的缓存命中率崩塌」;但「每个 worker 都超限」在大舰队上几乎不成立 —— 少数 worker 短暂低于限额就让「免费 worker 永远存在」,分流于是永远触发。--saturation-queue-floor 把这个条件变成可用形式:饱和 = 没有任何可证明低于 floor 的目的地。作者进一步论证等待在前缀拥有者占优:同样的等待,但从缓存预填充,而不是一次「驱逐其他前缀并制造下一轮 miss」的全量冷预填充。

值得精读:policies/admission.rs 的 pin 分支是完整的「何时不该分流」判据实现,policies/engine_load.rs 的极性注释解释了为什么缺采样两个方向的默认动作都是留在前缀拥有者。重点关注三个设计决策:floor 与 limit 的取值关系如何决定标签口径;压力排序与缓存局部性排序之间的取舍(作者明确标记为未决);all_queued 语义扩张对现有看板的影响。若计划在生产启用,建议先确认 prefill 舰队负载采样覆盖率,并观察 saturation_pin 与 all_queued 计数是否出现预期外的增长。

讨论亮点
  • 唯一 review 来自 ShangmingCai(APPROVED):「Logic is correct as we discussed and verified before, looks good.」说明该逻辑在合入前已被线下复核,PR 内没有逐行 review 争议。
  • Issue 评论中 ShangmingCai 触发 /tag-and-rerun-ci;PR body 的 CI 状态显示 Latest PR Test (Extra) 为失败、Latest PR Test (Base) 通过、AMD ROCm 无对应 run。
  • 作者在 body 里主动标记的待定设计问题:pin 的 tiebreak 只按压力、不按匹配前缀深度(替代方案是 locality-first 的 compare_cache_candidates),队列拥塞的 owner 之间「更浅但更闲」的那个可能胜出;all_queued 标签会被 floor 放宽;当所有被拒 owner 同时容量耗尽时 pin 空转,标签由 off-owner 回退记账。

实现拆解

  1. 配置面与不变量(config/cli.rs、config/types.rs、policies/mod.rs):Cli 新增 --saturation-queue-floor(Option<u64>),AffinityConfig 新增同名字段、默认 None。校验里加三条不变量:必须同时给出 --worker-queue-limit(否则「没有分流可取消」)、floor >= 1、floor <= limit(floor 高于 limit 会在闸门仍能放行部分 worker 时宣称饱和);该 flag 计入 tuned_cache_candidates,从而继承 --policy cache_aware 约束。CacheCandidateProposal 增加 saturation_queue_floor 字段,CacheAwarePolicy 构造 proposal 时透传。
  2. 请求侧传递(server/routes/chat.rs → policies/selection.rs):chat.rs 从 ctx.config.model.affinity 读出 floor 传入 PrefillSelectionInputs;selection.rs 新增同名字段(None 即关闭 pin),并新增 SATURATION_PIN_LOG_SAMPLE = 64 的采样日志计数器。
  3. 核心判定(policies/admission.rs::resolve_cache_candidates):函数签名新增 fleet: &[Arc<Worker>](该模型可路由的 prefill 池);queue_gate_rejected 由计数器改为 Vec<&CacheCandidate>,从而保留被拒 owner 供排序;queue_gate_fell_back 增加 proposal.saturation_queue_floor.is_none(),使 floor 取代 #39168 的舰队饱和第二级回退;在 work_floor 为 None 的早退分支中插入 pin:先 any_fresh_queue_below 判断是否还有可证明更闲的目的地,再对被拒 owner 单独构建 FreshLoadLookup、过滤硬准入、按 compare_prefill_pressure 取最小者(同压按 worker id)。新增 DecisionReason::SaturationPin。
  4. 饱和证据(policies/engine_load.rs::any_fresh_queue_below):新增舰队范围查询,只统计 fresh 且 num_waiting_reqs < floor 的 worker;无采样不算空闲(与 queue_gate_admits 的 fail-open 相反,两者组合后信号缺失时都留在前缀拥有者),且只扫描传入的可路由舰队 URL 而非 router 全量负载表,避免 decode 对端或其它模型的空闲 worker 否决 pin。
  5. 观测与标签(server/metrics.rs、policies/selection.rs):prefill_policy_reason 新增 DecisionReason::SaturationPin → "saturation_pin",cache candidate 分支的 record_policy_decision 从固定字面量改为走该函数;pin 命中时记 CacheAwareDecision::AllQueued 并按 1/64 打 info 日志。metrics.rs 顶部文档重写 all_queued 语义:两种触发条件、floor 低于 limit 会放宽标签、owner 容量耗尽时由 off-owner 回退记账。
  6. 测试配套:admission.rs 内新增 saturation_proposal 辅助与 9 个饱和 pin 单测(最小压力 owner 获胜、floor 未设置保持闸门穷尽行为、有 worker 低于 floor 时让位、未知队列不算空闲、舰队外空闲 worker 不影响、跳过容量耗尽 owner);config/cli.rs 新增 saturation_queue_floor_requires_the_queue_gate_and_stays_below_it 覆盖四条约束。PR body 报告 cargo test 799 通过、cargo fmt --check 与 pre-commit 干净;没有新增 tests/proxy 或 e2e 级集成测试文件。
文件 模块 状态 重要度
experimental/sgl-router/src/policies/admission.rs 路由策略 modified 9.05
experimental/sgl-router/src/policies/engine_load.rs 负载采样 modified 6.96
experimental/sgl-router/src/config/cli.rs 配置解析 modified 7.6
experimental/sgl-router/src/policies/selection.rs 选择策略 modified 6.77
experimental/sgl-router/src/config/types.rs 配置模型 modified 5.85
experimental/sgl-router/src/server/metrics.rs 指标观测 modified 5.45
experimental/sgl-router/src/policies/mod.rs 策略接口 modified 4.91
experimental/sgl-router/src/server/routes/chat.rs 请求入口 modified 4.48
experimental/sgl-router/src/policies/cache_aware.rs 缓存策略 modified 4.16

关键符号

resolve_cache_candidates any_fresh_queue_below queue_gate_admits fleet_is_all_queued prefill_policy_reason saturation_proposal CacheAwarePolicy::proposal Cli::into_config

关键源码片段

experimental/sgl-router/src/policies/admission.rs core-logic

核心实现与全部 pin 单测所在:`resolve_cache_candidates` 新增 fleet 参数、把被拒候选改为保留列表、加入饱和 pin 分支并新增 `DecisionReason::SaturationPin`。

resolve_cache_candidates 中新增的饱和 pin 分支(相对完整实现单元):

// 饱和钉选(SaturationPin):没有候选能通过队列闸门与硬准入,且至少一个候选
// 被闸门拒绝过。此时分流只有在存在「可证明更闲」的目的地时才有收益。
let pinned = if queue_gate_rejected.is_empty() {
    None
} else {
    proposal.saturation_queue_floor.and_then(|floor| {
        // 舰队范围扫描:只要有任一可路由 worker 的 fresh 队列读数严格低于
        // floor,就说明存在一个分流真能少排队的落点,pin 让位。
        if snapshot
            .any_fresh_queue_below(fleet.iter().map(|worker| worker.url.as_str()), floor)
        {
            return None;
        }
        // 独立的 FreshLoadLookup,而不是共享的 `loads`:`loads` 只覆盖闸门
        // 「通过」的候选集合,而 pin 触发时这个集合恰好是空的。空 lookup 会
        // 报告无引擎覆盖,使所有压力比较回退到 router 本地负载、并列为零,
        // 最终按 worker id 决出 —— 一个按字母序 pin 的实现比不 pin 更糟。
        let pin_loads = FreshLoadLookup::new(
            Some(snapshot),
            queue_gate_rejected.iter().map(|candidate| &candidate.worker),
        );
        queue_gate_rejected
            .iter()
            .copied()
            // 跳过同时通不过硬准入的 owner:容量耗尽的 worker 接不下请求。
            .filter(|candidate| {
                is_cache_candidate_admitted(candidate, request_input_tokens, &pin_loads)
            })
            // 仅按压力排序,不按匹配前缀深度重排;同压时按 worker id 稳定排序。
            .min_by(|left, right| {
                pin_loads
                    .compare_prefill_pressure(&left.worker, &right.worker)
                    .then_with(|| left.worker.id.0.cmp(&right.worker.id.0))
            })
    })
};
// 有 pin 则返回带 SaturationPin 理由的决策,否则返回 None 交给上层回退路径。
return CacheCandidateResolution {
    decision: pinned.map(|pinned| FinalDecision {
        selected: Arc::clone(&pinned.worker),
        primary: Arc::clone(&pinned.worker),
        backup: None,
        reason: DecisionReason::SaturationPin,
        candidate_range_id: pinned.candidate_range_id.clone(),
        load_snapshot_version: snapshot.version,
    }),
    prefill_pressure_source: loads.prefill_pressure_source(),
    admission_evaluated_candidates,
    admission_rejected_candidates,
    queue_gate_rejected_candidates,
    queue_gate_best_rejected_blocks,
    queue_gate_fell_back,
    fleet_all_queued,
    pressure_guard_compared_pairs: 0,
    pressure_guard_overrides: 0,
};
experimental/sgl-router/src/policies/engine_load.rs core-logic

新增舰队级饱和判定原语 `any_fresh_queue_below`,其 fail-closed 极性是 pin 安全性的关键,也是与闸门 fail-open 组合语义的落点。

饱和证据的判定原语,以及与闸门 fail-open 的对照:

impl EngineLoadSnapshot {
    /// 舰队里是否存在「可证明更闲」的目的地:某个 worker 的 fresh 队列读数
    /// 严格小于 `floor`。没有 fresh 采样的 worker 不计入 —— pin 问的是
    /// 「是否存在可证明更好的目的地」,未知队列不是证明。
    ///
    /// 这与队列闸门 `queue_gate_admits` 的 fail-open 极性相反;两者组合的
    /// 效果是:信号缺失时闸门保持亲和、pin 也把请求留给前缀拥有者。
    ///
    /// 调用方传入可路由舰队而非让本函数扫描全表:router 全量负载表还包含
    /// decode 对端、其它模型的 worker 以及已不可路由的 worker,PD 部署下
    /// 一个空闲的 decode 对端就足以否决每一次 pin。
    pub fn any_fresh_queue_below<'u>(
        &self,
        fleet_urls: impl IntoIterator<Item = &'u str>,
        floor: u64,
    ) -> bool {
        fleet_urls.into_iter().any(|url| {
            self.workers
                .get(url)
                .is_some_and(|load| load.num_waiting_reqs < floor)
        })
    }
}
​
/// 闸门判定的对照实现:`limit` 未设置或该 worker 没有 fresh 采样时一律放行。
/// `is_none_or` 就是 fail-open —— 未知队列不阻止一次缓存亲和选择。
pub(crate) fn queue_gate_admits(
    snapshot: &EngineLoadSnapshot,
    worker: &Worker,
    limit: Option<u64>,
) -> bool {
    let Some(limit) = limit else {
        return true;
    };
    snapshot
        .fresh_load_for_url(&worker.url)
        .is_none_or(|load| load.num_waiting_reqs < limit)
}
experimental/sgl-router/src/config/cli.rs configuration

新增 CLI 旋钮并定义三条不变量(依赖 gate、floor ≥ 1、floor ≤ limit),配套 `saturation_queue_floor_requires_the_queue_gate_and_stays_below_it` 测试。

CLI 校验块:数值检查先于 policy 检查,避免误报原因:

// 数值校验先于 policy 校验:在任何 policy 下都错的取值应该先报取值本身。
if self.worker_queue_limit == Some(0) {
    return Err(anyhow!("--worker-queue-limit must be at least 1"));
}
if let Some(floor) = self.saturation_queue_floor {
    // floor 修改的是闸门的分流行为;没有闸门就没有分流可取消,
    // 这个旋钮会变成死配置。
    let Some(limit) = self.worker_queue_limit else {
        return Err(anyhow!(
            "--saturation-queue-floor requires --worker-queue-limit (there is no \
             diversion to cancel without it)"
        ));
    };
    if floor == 0 {
        return Err(anyhow!("--saturation-queue-floor must be at least 1"));
    }
    // floor <= limit 让饱和标签保持可读:floor 高于 limit 会在闸门仍能
    // 放行部分 worker 时宣称舰队饱和。
    if floor > limit {
        return Err(anyhow!(
            "--saturation-queue-floor ({floor}) must be at most --worker-queue-limit ({limit})"
        ));
    }
}

评论区精华

饱和下限取代队列闸门的第二级回退 设计

作者在 body 中解释:floor 不与 #39168 的舰队饱和 tier 叠加,而是在设置时替换它 —— gate 的 tier 会按 uncached work 重排被重新放行的候选,pin 只按压力排序,两者只能有一个做决定,所以显式旋钮优先,gate 的 tier 覆盖 floor 未设置的情况。代码里体现为 `queue_gate_fell_back` 增加 `proposal.saturation_queue_floor.is_none()`。

结论:采用「显式 knob 覆盖隐式 tier」,floor 未设置时完全保持 #39168 行为,默认向后兼容。 · 已解决

无 fresh 采样不算空闲(fail-closed 极性) 设计

`any_fresh_queue_below` 不把缺采样的 worker 视为空闲,与 `queue_gate_admits` 的 fail-open 相反。作者在 body 与代码注释中论证:闸门保持亲和是安全默认动作,而 pin 问的是「是否存在可证明更好的目的地」,未知队列不是证明;两者组合后信号缺失时请求都留在前缀拥有者。推论是部分插桩的舰队会被读成饱和。

结论:极性差异是有意设计并被文档固化,代价是采样不完整的舰队 pin 触发率偏高。 · 已解决

pin 的 tiebreak 只按压力、不按匹配前缀深度 设计

作者明确标注为设计问题:pin 用 `compare_prefill_pressure` 排序,不重排 locality,队列拥塞的 owner 之间「更浅但更闲」的那个可能胜出;替代方案是 locality-first 加 near-tie 压力带的 `compare_cache_candidates`。

结论:当前实现选择压力优先,未解决 / 未定论,留待后续评估。 · 待处理

`all_queued` 标签语义扩张 设计

作者列出两条 caveat:floor 低于 limit 时标签会变宽(舰队里仍存在低于 limit 的 worker,却没有任何 worker 低于 floor);当所有被拒 owner 同时容量耗尽时 pin 不产出决策,标签由 off-owner 回退绘制。metrics.rs 文档同步重写。

结论:文档已更新口径,但依赖该标签的看板 / 告警需要人工重新解读,且没有测试断言渲染输出。 · 已解决

Review 结论:逻辑已复核并批准 设计

ShangmingCai 给出 APPROVED:「Logic is correct as we discussed and verified before, looks good.」未提出逐行修改意见。

结论:PR 按原样合入(merged_by ShangmingCai)。 · 已解决

CI 重跑与测试状态 测试

PR body 显示 Latest PR Test (Base) 通过、Latest PR Test (Extra) 失败、AMD ROCm 无对应 run;ShangmingCai 随后评论 `/tag-and-rerun-ci`。作者报告本地 `cargo test` 799 通过、fmt 与 pre-commit 干净。

结论:经重跑后合入;Extra 测试的失败原因未在材料中说明。 · 已解决

风险与影响

  • 既有行为被开关式替换:启用 floor 后 queue_gate_fell_back 恒为 false,即 #39168 的舰队饱和第二级回退不再生效(admission.rs)。floor 配得远低于 limit 时,pin 会比原 tier 更频繁地保持亲和,把请求压在排队 owner 上,TTFT 可能上升;唯一的回退手段是取消配置。
  • 配置耦合:floor 只能与 gate 同开且 ≤ limit,--dp-size 部署需同步放大两个值(样本按 DP rank 求和),否则在多 DP rank 下容易配错;CLI 只做静态校验,没有运行时合理性告警。
  • 未知队列按非空闲处理(fail-closed):any_fresh_queue_below 不把缺采样的 worker 算作空闲,部分插桩或采样中断的舰队会被整体读成饱和,pin 触发率上升;文档说明 floor 只在舰队全量发布负载样本时才有意义,但没有运行时检测。
  • 指标契约变化:all_queued 不再等价于「每个 worker ≥ limit」,依赖该 label 的命中率看板/告警需同步改口径;metrics.rs 只更新了文档,没有断言渲染输出的测试。
  • 测试覆盖偏单元:验证集中在 policies/admission.rs 与 config/cli.rs 的内联单测,缺少 proxy/e2e 级用例(同仓库 #39001、#39015 等 Router 特性 PR 会在 tests/proxy/* 补集成测试),真实路由路径与 metrics 输出没有端到端冒烟。
  • 性能:pin 只在候选集清空时触发,额外成本是一次 O(fleet) 扫描加一个 FreshLoadLookup 与 min_by,不在热路径,风险低。
  • 兼容性:floor 为 None 时 resolve_cache_candidates 行为与 #39168 等价,默认配置向后兼容。
  • 用户/运维:只有显式配置 --saturation-queue-floor 的 cache_aware 部署行为改变;未配置的舰队零影响。开启后饱和期的请求更倾向留在前缀拥有者,减少冷预填充引发的 KV 驱逐连锁,代价是排队时间被显式化而非被分流掩盖。
  • 系统:改动落在 Router 选择主路径 resolve_cache_candidates 与指标契约上;any_fresh_queue_below(fail-closed)与 queue_gate_admits(fail-open)的极性组合成为后续饱和类策略的语义基准。
  • 团队:作为 Router 饱和策略栈(#39322 → #39168 → 本 PR → #39170)的第三层,确立了「显式旋钮取代隐式 tier」「饱和标签与命中率查询隔离」的两种模式,后续策略改动需要沿用同一口径。
核心路径变更 配置耦合 缺少端到端测试 指标标签语义扩张 采样缺失误判饱和

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论