Prhub

#39168 [Router] Add --worker-queue-limit: stop sending cache-affinity traffic to a queueing worker

原始 PR 作者 Kangyan-Zhou 合并时间 2026-09-16 13:24 文件变更 11 提交数 2 评论 2 代码增减 +1354 / -74

执行摘要

Router 新增队列闸门:排队 worker 不再凭缓存亲和夺冠

Cache-Aware 路由此前只按前缀命中和压力/容量选 worker,会把请求送到一个正在让请求排队的引擎上。PR body 指出闸门必须看队列而不是总深度:「on long-prompt traffic they diverge. An engine can start queueing at 7-8 running, far below its running cap, so a depth threshold either fires on healthy busy workers or misses the workers actually making requests wait」,而 num_waiting_reqs 来自 #34608 的基础负载采样,正好回答「我会不会先排队」。另一个动机是可观测性:PR body 反复强调「a fully saturated fleet reads as a healthy one, which is the exact blind spot this series exists to remove」,因此本 PR 同时引入了决策标签与分流代价直方图。

值得精读,尤其是做路由 / KV 局部性 / 负载均衡的同学。重点看三处设计决策:(1) 为什么用 num_waiting_reqs 而不是 in-flight 或运行深度,以及 fail-open 的取舍(queue_gate_admits 的注释本身就是设计说明);(2) 两条「第二层」为何不是同一个条件——这是把闸门从「一个 if」变成「一个可上线的特性」的关键,PR body 里「with a single prefix owner the gate rejects it, the set empties, the ungated tier re-admits that same queueing owner, and affinity traffic keeps flowing exactly where the gate meant to divert it」是最值得记住的一段;(3) cache_aware_fallback_decision 的判定顺序(fleet_all_queued 先于容量)如何决定「饱和 fleet 会不会读成健康 fleet」,这条正是 review 抓出来的 bug。上线前建议先确认引擎确实发布负载样本,并在目标 fleet 上评估 fleet_is_all_queued 的每请求开销。

讨论亮点

两条 review 意见都来自 ShangmingCai,且都指向「指标报告的与它自己文档承诺的不一致」,都在第二个 commit 修掉。

  1. metrics.rs:48 的不变量不成立:

    "This invariant doesn't match the code: the recording block in Selector::run also runs when the ladder returns Err, so a selection that ends in a 503 still books a decision... Should we fix this doc, or gate the record on selected.is_some()?"

作者选择后者:记账块加上 selected.is_some(),并在代码注释里说明「没有落地的分流不是闸门代价的证据」,同时避免污染 sgl_router_diverted_overlap_blocks。

  1. all_queued 分支从 resolver 不可达:

    "Seems unreachable: queue_gate_fell_back re-admits every candidate, so admission_evaluated > 0 whenever fleet_all_queued && rejected > 0... Also means a saturated fleet whose re-admitted owners then fail capacity books cache_miss, dropping the saturation signal — intended?"

结论:把 fleet_all_queued 的判断提前到容量问题之前,并把单元测试的输入从 resolver 不可能产生的 (2, 0, true) 改到 (2, 2, true),另加三个端到端接线测试。这条评论实质上指出「饱和 fleet 会被记成健康 fleet」,正是本系列要消除的盲区。

实现拆解

  1. 配置面(config/types.rs、config/cli.rs、server/routes/chat.rs):AffinityConfig 新增 worker_queue_limit: Option<u64>(默认 None),文档注释解释「为什么看队列而不是总深度」以及阈值随 --dp-size 放大(样本按 worker 的 DP rank 求和);cli.rs 新增 #[arg(long)] worker_queue_limit,纳入 tuned_cache_candidates 以便强制要求 --policy cache_aware,并把 Some(0) 的值检查放在 policy 检查之前(注释写明「值错误优先于策略错误」);routes/chat.rs 仅 +13 行把配置值传入 PrefillSelectionInputs——PR body 对比指出没有 #39322 的 selection seam 时这需要 +112/-7。
  2. 唯一判据(policies/admission.rs):新增 queue_gate_admits(snapshot, worker, limit),边界用 <(等于 limit 的 worker 已经在让请求等待),无新鲜样本时通过 is_none_or 返回 true(fail open),读的是 EngineWorkerLoad::num_waiting_reqs;配套 fleet_is_all_queued 要求「limit 已设 + fleet 非空 + 全 fleet 都有新鲜样本且都超限」才返回 true,即未知队列不等于已证明满队列。
  3. 候选解析:闸门先于硬准入(admission.rs resolve_cache_candidates):新增 fleet 参数并计算 fleet_all_queued;单次遍历把候选拆成 evaluated 与被闸门摘掉的集合,分别累计 queue_gate_rejected_candidates 与 queue_gate_best_rejected_blocks;FreshLoadLookup 改为围绕 evaluated 构建,避免被摘候选缺 monitor 数据而把压力比较静默降级为 router-local;admission_evaluated_candidates 改为只统计真正进入硬准入的候选,从而与闸门拒绝天然不相交。
  4. 第二层兜底与提案降级:queue_gate_fell_back = evaluated.is_empty() && rejected > 0 && fleet_all_queued 时重新接纳未过闸门的候选集;resolve_prefill / resolve_prefill_admitted 新增 queue_limit 参数,把排队中的 primary/backup 降级到两级 range fallback(而不是拒绝),只有 CapacityFallbackPowerOfTwo 这一最后手段有意不接闸门。
  5. 指标与记账(server/metrics.rs、policies/selection.rs):新增 CacheAwareDecision(cache_hit / cache_miss / cache_worker_queued / all_queued)、计数器 sgl_router_cache_aware_decisions_total{model_id,decision} 与直方图 sgl_router_diverted_overlap_blocks{model_id}(OVERLAP_BLOCK_BUCKETS 取 2 的幂到 8192);selection.rs 用纯函数 cache_aware_fallback_decision 做标签映射,并把 cache_gate_audit 审计随选择阶梯传递;另加 1/64 采样告警(QUEUE_GATE_BLIND_LOG_SAMPLE)提示「配了闸门但全 fleet 无新鲜样本,闸门失效」。
  6. 数据面与测试配套:policies/mod.rs 给 CacheCandidate 加 matched_prefix_blocks、给 CacheCandidateProposal 加 worker_queue_limit;policies/cache_aware.rs 抽出 cap_matched_prefix_blocks,让 token 估计与分流直方图共用一个 clamp(避免直方图看到被估计丢弃的块数)。测试分布在 admission.rs 内嵌的 queue_gate_* 系列、selection.rs 内的 cache_aware_fallback_decision_* / booked_decisions 系列,以及 bucket_domains.rs、shared_prefill_admission.rs 的字段补齐与 scoring/mod.rs 的签名同步。

两条「第二层」触发条件不同,这是本特性的关键:

路径 候选池 第二层触发条件
range fallback 整个合法 fleet 池为空——确实没有更好的落点
cache candidates 仅前缀 owner prefill fleet 内没有任何未排队 worker(fleet 饱和)
文件 模块 状态 重要度
experimental/sgl-router/src/policies/admission.rs 准入策略 modified 9.15
experimental/sgl-router/src/policies/selection.rs 路由选择 modified 8.96
experimental/sgl-router/src/server/metrics.rs 指标采集 modified 8.91
experimental/sgl-router/src/config/cli.rs 命令行配置 modified 7.17
experimental/sgl-router/src/policies/cache_aware.rs 缓存亲和 modified 6.71
experimental/sgl-router/src/config/types.rs 配置类型 modified 5.84
experimental/sgl-router/src/policies/mod.rs 路由策略 modified 5.93
experimental/sgl-router/src/server/routes/chat.rs 请求路由 modified 5.07
experimental/sgl-router/src/policies/scoring/mod.rs 评分配置 modified 4.76
experimental/sgl-router/tests/component/policies/bucket_domains.rs 分桶域 modified 3.09
experimental/sgl-router/tests/proxy/shared_prefill_admission.rs 共享准入 modified 3.09

关键符号

queue_gate_admits fleet_is_all_queued resolve_cache_candidates resolve_prefill resolve_prefill_admitted cache_aware_fallback_decision record_cache_aware_decision observe_diverted_overlap_blocks cap_matched_prefix_blocks Selector::run chat_completions

关键源码片段

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

把闸门审计翻译成指标标签的地方:新增纯函数 `cache_aware_fallback_decision`(review 修复的核心,`fleet_all_queued` 必须早于容量判断)、`cache_gate_audit` 随选择阶梯传递,以及「只对真正选出 worker 的选择记账」的门控;还有 1/64 采样的闸门失效告警。

/// 把一次「没有选出 winner」的 Cache-Aware 选择映射到 decision 标签。
/// 纯函数,因此每个边界都能被单元测试钉住,而不是靠调用它的阶梯反推:
///
/// - `queue_gate_rejected == 0`:闸门什么都没摘,候选集变空与闸门无关。
/// - `fleet_all_queued` 在容量问题之前询问:`all_queued` 的语义是
///   「fleet 饱和」,而不是「请求落在哪里」。若先问容量,最坏情况下的
///   饱和信号会被丢掉——一个排队中的 fleet 如果它的 owner 又恰好 KV 不足,
///   就只记 `cache_miss`,满载 fleet 读起来像健康 fleet,
///   正是这个标签要消除的盲区。
/// - `admission_evaluated == 0` 才是「空集由闸门而非容量造成」的判据;
///   没有它,只要有一个 owner 在排队,容量耗尽就会被记成闸门分流。
fn cache_aware_fallback_decision(
    queue_gate_rejected: u64,
    admission_evaluated: u64,
    fleet_all_queued: bool,
) -> CacheAwareDecision {
    if queue_gate_rejected == 0 {
        return CacheAwareDecision::CacheMiss;
    }
    if fleet_all_queued {
        return CacheAwareDecision::AllQueued;
    }
    if admission_evaluated > 0 {
        // 是容量问题而非闸门:owner 过了闸门,却没过容量准入
        return CacheAwareDecision::CacheMiss;
    }
    CacheAwareDecision::CacheWorkerQueued
}
​
// 节选自 `Selector::run` 的记账块:只有解析出 worker 的选择才记账。
// 走完所有阶梯仍无 worker 是 503,已由
// `sgl_router_policy_selection_failures_total` 统计;在这里再记一次会破坏
// 「标签之和 = cache-aware 请求速率 - 503」的约定,更糟的是会让一个什么都没
// 选到的请求记成 `cache_worker_queued` 并喂给
// `sgl_router_diverted_overlap_blocks`——没有落地的分流不是闸门代价的证据。
// 这一门控正是 review 意见 1 的修复。
if inputs.policy_kind == PolicyKind::CacheAware && !cache_winner_hit && selected.is_some() {
    let (rejected, evaluated, fleet_all_queued, blocks) =
        self.cache_gate_audit.unwrap_or((0, 0, false, 0));
    let decision = cache_aware_fallback_decision(rejected, evaluated, fleet_all_queued);
    if matches!(decision, CacheAwareDecision::CacheWorkerQueued) {
        // 真正发生分流:存在未排队落点,闸门放弃了 `blocks` 个已匹配前缀块
        inputs
            .metrics
            .observe_diverted_overlap_blocks(&inputs.model_id.0, u64::from(blocks));
    }
    inputs
        .metrics
        .record_cache_aware_decision(&inputs.model_id.0, decision);
}
/// 闸门失效告警按 1/64 采样:它跑在每请求路径上,而触发条件
/// (配了闸门 + 全 fleet 零新鲜负载样本)是稳态的,采样足够暴露问题
/// 又不会刷屏日志。
const QUEUE_GATE_BLIND_LOG_SAMPLE: u64 = 64;
static QUEUE_GATE_BLIND_LOG_COUNTER: AtomicU64 = AtomicU64::new(0);
​
// 节选自 `Selector::cache_winner`:闸门逐 worker fail open,当全 fleet
// 都没有新鲜样本时闸门整体失效,而 `cache_worker_queued` 恒为 0 与
// 「健康 fleet」无法区分,因此必须显式告警。
if inputs.worker_queue_limit.is_some()
    && !inputs.workers.is_empty()
    && inputs
        .workers
        .iter()
        .all(|worker| snapshot.fresh_load_for_url(&worker.url).is_none())
    && QUEUE_GATE_BLIND_LOG_COUNTER
        .fetch_add(1, AtomicOrdering::Relaxed)
        .is_multiple_of(QUEUE_GATE_BLIND_LOG_SAMPLE)
{
    tracing::warn!(
        model = %inputs.model_id,
        worker_queue_limit = inputs.worker_queue_limit,
        workers = inputs.workers.len(),
        "--worker-queue-limit is set but no worker has a fresh engine load \
         sample, so the queue gate is inert. Check that engines advertise a \
         load port and publish LoadStat",
    );
}
experimental/sgl-router/src/server/metrics.rs observability

新增 `CacheAwareDecision` 四态标签、`sgl_router_cache_aware_decisions_total` 计数器与 `sgl_router_diverted_overlap_blocks` 直方图(`OVERLAP_BLOCK_BUCKETS`),并在模块文档里定义了各标签的读法;review 的第一条意见正是针对这里的文档不变量。

/// `sgl_router_diverted_overlap_blocks` 的分桶上界(单位:块)。
/// 2 的幂直到 8192 块;块大小由引擎配置(常见 16–64 token),
/// 因此这套阶梯覆盖约 16 token 到约 512K token 的「被放弃前缀」。
const OVERLAP_BLOCK_BUCKETS: &[f64] = &[
    1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0, 1024.0, 2048.0, 4096.0, 8192.0,
];
​
/// 每个 cache-aware prefill 选择的最终决策,只记一次。
/// 标签语义见模块文档。
#[derive(Debug, Clone, Copy)]
pub enum CacheAwareDecision {
    CacheHit,
    CacheMiss,
    CacheWorkerQueued,
    AllQueued,
}
​
impl CacheAwareDecision {
    fn as_str(self) -> &'static str {
        match self {
            Self::CacheHit => "cache_hit",
            Self::CacheMiss => "cache_miss",
            Self::CacheWorkerQueued => "cache_worker_queued",
            // 故意不写成 `cache_hit_*`:`decision=~"cache_hit.*"` 的命中率
            // 查询不能把它吸收进去,否则满载 fleet 会读成健康 fleet。
            Self::AllQueued => "all_queued",
        }
    }
}
​
// 节选自 `MetricsRegistry`:决策计数器按 `model_id` + `decision` 建键,
// 一次 cache-aware 选择恰好调用一次,因此各标签之和就是 cache-aware 的
// 请求速率(减去记为 503 的选择)。
pub fn record_cache_aware_decision(&self, model_id: &str, decision: CacheAwareDecision) {
    let key = CacheAwareDecisionKey {
        model_id: model_id.to_owned(),
        decision: decision.as_str(),
    };
    let mut guard = self.cache_aware_decisions_total.lock();
    let counter = guard
        .entry(key)
        .or_insert_with(|| Arc::new(AtomicU64::new(0)))
        .clone();
    drop(guard);
    counter.fetch_add(1, Ordering::Relaxed);
}
​
/// 观测一次闸门分流放弃的匹配前缀深度(块),喂给
/// `sgl_router_diverted_overlap_blocks`。只在闸门清空候选集
/// (`cache_worker_queued`)时记录,因此直方图度量的是「牺牲」而不是流量;
/// 只摘掉最深 owner、更浅 owner 仍胜出的部分分流不计入这里,
/// 那种情况会被记成 `cache_hit`。
pub fn observe_diverted_overlap_blocks(&self, model_id: &str, blocks: u64) {
    let mut guard = self.diverted_overlap_blocks.lock();
    let hist = guard
        .entry(model_id.to_owned())
        .or_insert_with(|| Histogram::new(OVERLAP_BLOCK_BUCKETS));
    hist.observe(blocks as f64);
}

评论区精华

`sgl_router_cache_aware_decisions_total` 的文档不变量与实现不一致(503 也记账) 正确性

ShangmingCai 在 `metrics.rs:48` 指出:模块文档声称「标签之和 = cache-aware 请求速率 - 503」,但 `Selector::run` 的记账块在阶梯返回 `Err` 时同样执行,因此 503 也会记一条决策,并追问「Should we fix this doc, or gate the record on `selected.is_some()`?」。

结论:作者选择门控代码而不是改文档:记账块加上 `selected.is_some()`,并补三个测试覆盖 resolver 审计到标签的端到端接线;`sgl_router_diverted_overlap_blocks` 也因此不再被「没有落地的分流」污染。 · 已解决

`all_queued` 分支从 resolver 不可达,且饱和 fleet 会记成 `cache_miss` 正确性

ShangmingCai 指出:`queue_gate_fell_back` 会重新接纳全部候选,所以只要 `fleet_all_queued && rejected > 0`,`admission_evaluated` 必大于 0,`all_queued` 只能从 winner 路径产生;单元测试钉住的是 `resolve_cache_candidates` 不可能产生的状态。后果是「被重新接纳的 owner 又恰好 KV 不足」时只记 `cache_miss`,饱和信号丢失,「intended?」。

结论:修复为在 `cache_aware_fallback_decision` 中先问 `fleet_all_queued`、再问容量,并把纯映射测试从 `(2, 0, true)` 迁到 resolver 真能产生的 `(2, 2, true)`,另加端到端测试。PR body 将其归为「a fully saturated fleet reading as a healthy one」这一类盲区。 · 已解决

风险与影响

  1. 闸门可能静默失效(fail-open 的代价):queue_gate_admits 在无新鲜负载样本时一律准入,因此只要引擎没有发布 load port / LoadStat,--worker-queue-limit 就是一个空操作。唯一的提示是 selection.rs 里 1/64 采样的 tracing::warn!,且代码注释承认「a fleet that never advertised a load port would otherwise report zero rejections, which reads as healthy」。PR body 也明说「There is no exported counter for them yet」,即该 flag 当前无法只靠指标调参。
  2. 阈值与 dp_size 的隐式耦合:样本把各 DP rank 的 waiting 求和,而请求只落到其中一个 rank,因此 limit 未随 --dp-size 缩放时会系统性偏严或偏松。文档只写在注释里,没有运行时校验或告警。
  3. 部分分流不可观测:cache_aware.rs / metrics.rs 注释与 PR body 都说明——若闸门只摘掉最深 owner 而更浅 owner 仍胜出,会记为 cache_hit,不进入 sgl_router_diverted_overlap_blocks。命中率面板可能看不出请求正在离开前缀,而实际已付出局部性代价。
  4. 每请求的 fleet 级扫描:fleet_is_all_queued 在每次 cache-aware 选择中对整个 prefill fleet 做一次 all(...) 遍历,闸门开启后这条路径的成本从「候选集规模」变成「fleet 规模 + 候选集规模」。大 fleet 高 QPS 下需要实测,代码里只做了「单次遍历、不重复读快照」的局部优化。
  5. 压力比较覆盖面的语义变化:FreshLoadLookup 从「全部候选」改为围绕 evaluated 构建。这是有意为之(代码注释解释了原因),但闸门开启后 prefill_pressure_source 与压力比较的覆盖率会随闸门结果变化,属于隐式行为差异,回归测试不一定覆盖。
  6. 指标语义变更:sgl_router_cache_aware_decisions_total 的「标签之和 = cache-aware 请求速率 − 503」是本 PR 才成立的新约定;已有看板若按旧行为(503 也记账)搭建会出现台阶。

用户/线上流量:只影响 --policy cache_aware 的部署,且未设置 --worker-queue-limit 时行为完全不变(PR body 的 Compatibility 一节明确「Unset, the flag changes nothing」)。开启后,长 prompt / agentic 场景下首 token 等待时间应下降,代价是可能放弃部分 KV 前缀复用(更多 prefill 计算)。
系统:改动集中在 experimental/sgl-router 的 prefill 选择路径,decode 选择与 CapacityFallbackPowerOfTwo 最后手段不受闸门约束,因此不会出现「无处可去」的请求风暴。
团队/后续开发:resolve_cache_candidates、resolve_prefill、resolve_prefill_admitted 三个公共函数签名变化,调用点与测试需同步(本 PR 已同步 policies/mod.rs、policies/scoring/mod.rs、两个测试文件)。PR 栈中 #39169(saturation pin)与 #39170(candidate sampling)以本分支为 base,需各自 rebase 后再评审。
可观测性:运维首次拿到按 model_id 拆分的 cache-aware 决策分布和「分流放弃的块数」直方图,但闸门拒绝计数、部分分流尚未导出,调参仍依赖工程判断。

核心路由选择路径变更 闸门 fail-open 且缺少拒绝计数 阈值需手动随 dp-size 缩放 每请求全 fleet 扫描开销 部分分流不可观测

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论