Prhub

#56990 [Rust Frontend] Add iteration token histogram

原始 PR 作者 BugenZhao 合并时间 2026-09-15 20:12 文件变更 5 提交数 2 评论 4 代码增减 +90 / -14

执行摘要

Rust 前端新增迭代 token 直方图指标

PR body 明确目标是「Expose vllm:iteration_tokens_total in the Rust frontend, including its _count, _sum, and _bucket series」,即在 Rust 前端复刻 Python 前端的迭代级 token 吞吐指标,让运维用同一套 Prometheus 查询即可观测每步 token 量(含 prefill chunk 大小)。值得注意的是,rust/src/metrics/src/request.rs 中原本留有相反的结论:「We intentionally don't support iteration-level histograms for now, since it seems to make more sense if the engine maintains these metrics and frontend simply forwards.」本 PR 删除该注释并反转了此前的设计判断,将口径锚定为「frontend-visible output batches」(PR body 原话)。同时为了让 mock 引擎支撑两个前端的指标对账,mock 也必须能产生 prefill 统计。

值得精读,尤其是只关注 Rust 前端的工程师:它示范了「指标句柄按 engine 预缓存 + 零值 series 初始化 + 在 dispatcher 单点采样」的完整落地套路,也是理解 vLLM 前端/引擎指标职责边界的一手材料。阅读时建议重点看三处:一是 rust/src/metrics/src/request.rs 中被删除的那段设计注释,理解为什么这次决定把统计放在前端;二是 rust/src/engine-core-client/src/client/imp.rs 中 has_outputs 与 sender 过滤的错位,判断是否需要用 iteration_tokens > 0 或 sender 命中数来收紧观测条件;三是 PR body 里对验证边界的坦诚说明,直接对应「缺少落库测试」这一后续待补项。

讨论亮点

本次 review 讨论量极低:review_comments_count 为 0,唯一的技术性评语来自 claude[bot] 的「This pull request is from a fork — automated review is disabled.」,esmeetu 给出无正文的 APPROVED,最终由作者 BugenZhao 自行合并。因此真正的「讨论」主要发生在代码注释和 PR body 里:其一,删除的那段「应由引擎维护、前端只转发」的注释实质上是一次未被复核的设计反转;其二,PR body 用较长篇幅交代口径(「Retain per-request output processing; the histogram measures frontend-visible output batches.」)与验证边界(「Validation uses mock outputs」),可视为作者对可能质疑的自我回应。CI 评论也值得一记:首次 /ci run 因「2 commits behind upstream main」被拒,改用 /ci run --allow-stale 后 Bot 明确警告「This PR is 4 commits behind upstream main. Running CI at your own risk」。

实现拆解

  1. 指标定义与注册(rust/src/metrics/src/request.rs):新增 ITERATION_TOKENS_BUCKETS(1、8、16…16384,共 13 个上界,加 +Inf 即 14 个 bucket)与构造器 iteration_tokens_histogram();RequestMetrics 新增 iteration_tokens_total: HistogramFamily 字段,并在 RequestMetrics::register 中以 vllm:iteration_tokens_total 注册。同时删除原先「前端不做 iteration 级直方图」的注释与占位字段,这是本 PR 最关键的设计反转。
  2. 句柄缓存(rust/src/engine-core-client/src/metrics.rs):新增 IterationMetricHandles,用 metrics.iteration_tokens_total.get_or_create_owned(&EngineLabels { model_name, engine }) 为每个 engine 预创建 child series,因此零流量时 /metrics 也会输出 0 值 series(PR body 的「initialize the series at zero」)。
  3. 客户端接入(rust/src/engine-core-client/src/client/imp.rs):ClientInner 新增 iteration_metrics: BTreeMap<u32, IterationMetricHandles>,在 ClientInner::new 中按 engine.engine_id.engine_index() 组装(filter_map 跳过拿不到索引的引擎);run_output_dispatcher_loop 处理 EngineCoreOutputs::RequestBatch 时累加 output.new_token_ids.len() 与 prefill_stats.num_computed_tokens,批次非空且 engine 可查时 observe 一次。累加位于 sender 校验之后,所以只统计被本 frontend 跟踪的活跃请求。
  4. Mock 引擎与测试配套(rust/src/mock-engine/src/engine.rs、rust/src/mock-engine/src/tests.rs):ActiveRequest::step 在 self.generated == 0 时构造 PrefillStats { num_prompt_tokens, num_computed_tokens, ..Default::default() }(两者都等于 prompt 长度,mock 不模拟前缀缓存)挂到首个输出,后续步为 None;chunk_size_one_outputs_one_token_per_update 增加三条断言,校验首个输出带 3/3 的 prefill stats、第二、三个输出为 None,使「prefill stats 只出现一次」成为自动化契约。
  5. 验证配套(未落库):PR body 描述用独立 Rust mock 引擎对两个 frontend 跑 84 次 HTTP 请求(chunk size 1/4/8,流式与非流式交叉,prompt 长度 1–20000、输出长度 1–17),比对 _sum(52,845)、_count(96/32/21)与全部 bucket,并自陈「concurrent batching, cache simulation, and GPU model evaluation remain outside this check」。
文件 模块 状态 重要度
rust/src/engine-core-client/src/client/imp.rs 引擎客户端 modified 7.03
rust/src/metrics/src/request.rs 指标模块 modified 6.92
rust/src/engine-core-client/src/metrics.rs 引擎客户端 modified 6.19
rust/src/mock-engine/src/engine.rs 模拟引擎 modified 6.73
rust/src/mock-engine/src/tests.rs 模拟引擎 modified 5.55

关键符号

ClientInner::new run_output_dispatcher_loop IterationMetricHandles::new RequestMetrics::register iteration_tokens_histogram ActiveRequest::step chunk_size_one_outputs_one_token_per_update

关键源码片段

rust/src/engine-core-client/src/client/imp.rs core-logic

指标的唯一记录点:ClientInner 新增 iteration_metrics 缓存,并在输出分发循环中按批次累加生成 token 与 prefill 计算 token 后 observe 一次,是整条链路的收口处,也是正确性风险(has_outputs 与 sender 过滤错位)所在。

// rust/src/engine-core-client/src/client/imp.rs
// ClientInner 构造阶段:按已连接引擎预创建指标句柄,
// 这样即使还没有任何请求,/metrics 中也会出现 0 count / 0 sum 的 series。
let iteration_metrics = engines
    .iter()
    .filter_map(|engine| {
        // engine_index() 返回 Option:拿不到索引的引擎直接跳过,不参与指标打点。
        let engine = engine.engine_id.engine_index()?;
        let handles = IterationMetricHandles::new(&METRICS.request, &model_name, engine);
        Some((engine, handles))
    })
    .collect();
​
// 输出分发循环:每个 batch 只做一次 observe。
EngineCoreOutputs::RequestBatch(batch) => {
    // 空批次(只带 scheduler_stats)不计入;注意这里用的是原始 outputs 判断。
    let has_outputs = !batch.outputs.is_empty();
    let mut iteration_tokens = 0_u64;
    let senders = inner.take_senders_for_outputs(&batch.outputs);
    for (output, sender) in batch.outputs.into_iter().zip(senders) {
        let request_id = output.request_id.clone();
        // sender 为 None 表示该请求不由本 frontend 跟踪,直接丢弃且不计入统计,
        // 避免与其它 frontend 对同一批次重复计数。
        let Some(sender) = sender else {
            debug!(request_id, "dropping output for inactive request");
            continue;
        };
​
        // 本次迭代的 token 数 = 本步解码出的新 token + 首批输出携带的 prefill 计算量。
        iteration_tokens += output.new_token_ids.len() as u64;
        if let Some(prefill) = &output.prefill_stats {
            // 引擎约定只在首个输出上发一次 prefill_stats;
            // 用 num_computed_tokens 而非 num_prompt_tokens,前缀缓存命中的部分不计入计算量。
            iteration_tokens += u64::from(prefill.num_computed_tokens);
        }
​
        let wrapped_output = EngineCoreStreamOutput {
            engine_index: batch.engine_index,
            timestamp: batch.timestamp,
            output,
        };
        if sender.send(Ok(wrapped_output)).is_err() {
            debug!(request_id, "request output stream receiver dropped");
        }
    }
​
    // 只要批次非空且该 engine 有缓存句柄就记一次观测。
    if has_outputs
        && let Some(handles) = inner.iteration_metrics.get(&batch.engine_index)
    {
        handles.iteration_tokens_total.observe(iteration_tokens as f64);
    }
    // ... 后续 finished_requests / scheduler_stats / lora 统计保持不变
}
rust/src/metrics/src/request.rs core-logic

定义了 vllm:iteration_tokens_total 的名字、描述、分桶与注册方式,并删除了原先「不在前端维护 iteration 级直方图」的注释——这是本次设计反转的物证。分桶(2 的幂次到 16384)直接决定 `_bucket` 序列的可读性。

// rust/src/metrics/src/request.rs
// 迭代 token 数按 2 的幂次分桶:下界覆盖 decode 单步的 1 token,
// 上界 16384 足以覆盖长 prompt 的 prefill chunk,超过则落入 +Inf 桶。
const ITERATION_TOKENS_BUCKETS: [f64; 13] = [
    1.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0, 1024.0, 2048.0, 4096.0, 8192.0, 16384.0,
];
​
fn iteration_tokens_histogram() -> Histogram {
    Histogram::new(ITERATION_TOKENS_BUCKETS.iter().copied())
}
​
/// Request and output-batch Prometheus families.
pub struct RequestMetrics {
    /// Computed prompt and generated tokens per nonempty engine output batch,
    /// recorded by the engine-core client for requests tracked by this frontend.
    pub iteration_tokens_total: HistogramFamily,
​
    // Request-derived counters.
    pub num_preemptions: Family<EngineLabels, U64Counter>,
    pub prompt_tokens: Family<EngineLabels, U64Counter>,
    pub prompt_tokens_by_source: PromptTokenSourceCounterFamily,
    pub prompt_tokens_cached: Family<EngineLabels, U64Counter>,
    pub generation_tokens: Family<EngineLabels, U64Counter>,
​
    // 原本这里写着「前端暂不支持 iteration 级直方图,交给引擎维护更合理」,
    // 本 PR 删除该结论并改为前端侧观测,口径是 frontend-visible output batch。
    // Request lifecycle counters and histograms.
    pub request_success: FinishedReasonCounterFamily,
    // ... 其余请求生命周期指标不变
}
​
impl RequestMetrics {
    /// Register the request-oriented metric families into the shared registry.
    pub(crate) fn register(registry: &mut Registry) -> Self {
        // Family::new_with_constructor 保证每个新 label 组合都拿到同一套分桶。
        let iteration_tokens_total =
            Family::new_with_constructor(iteration_tokens_histogram as fn() -> Histogram);
        registry.register(
            "vllm:iteration_tokens_total",
            "Histogram of computed prompt and generated tokens per nonempty engine output batch.",
            iteration_tokens_total.clone(),
        );
        // ... 其余 counter / histogram 注册保持不变
        Self { iteration_tokens_total, /* ... */ }
    }
}
rust/src/engine-core-client/src/metrics.rs core-logic

新增 IterationMetricHandles,把 RequestMetrics 的直方图族在 (model_name, engine) 标签下物化成可复用的 owned 句柄,既避免热路径重复做 Family 查找,也实现「零值 series 预初始化」的设计目标。

// rust/src/engine-core-client/src/metrics.rs
/// Cached output-batch metric handles for one model and engine index.
pub(crate) struct IterationMetricHandles {
    pub iteration_tokens_total: HistogramMetric,
}
​
impl IterationMetricHandles {
    pub(crate) fn new(metrics: &RequestMetrics, model_name: &str, engine: u32) -> Self {
        let labels = EngineLabels {
            model_name: model_name.to_string(),
            engine,
        };
        Self {
            // get_or_create_owned 在此刻就把 child 建出来:
            // 1) 后续 observe 不再经过 Family 的哈希查找,热路径更轻;
            // 2) 未产生任何流量的 engine 也会以 0 count / 0 sum 暴露,便于告警与对账。
            iteration_tokens_total: metrics.iteration_tokens_total.get_or_create_owned(&labels),
        }
    }
}

评论区精华

前端侧记录 iteration 直方图的设计取舍 设计

代码中原本明确写着「We intentionally don't support iteration-level histograms for now, since it seems to make more sense if the engine maintains these metrics and frontend simply forwards.」,本 PR 删除该注释并把统计搬到 engine-core-client。作者在 PR body 中给出新口径:「Retain per-request output processing; the histogram measures frontend-visible output batches.」

结论:决定由前端按「可见输出批次」自行记账,引擎只负责提供一次性 prefill_stats;该反转随 PR 合入生效,但没有 review 评论对其展开讨论。 · 已合入,未经同行讨论

has_outputs 与 sender 过滤之间的口径错位 正确性

审读发现:`has_outputs` 基于原始 `batch.outputs` 是否非空,而 `iteration_tokens` 只在 sender 有效时累加。若某批次所有输出对应的请求都不被本 frontend 跟踪,代码仍会 `observe(0)`。该点未被任何 review 评论提及。

结论:暂无结论;潜在影响是给 `le="1"` 桶贡献 count 而 `_sum` 不变,使 `_count`/`_sum` 口径在异常批次下略失真,建议后续用 `iteration_tokens > 0` 或有效输出计数收紧条件。 · 待处理

验证覆盖边界与测试落库 测试

PR body 自陈「Validation uses mock outputs; concurrent batching, cache simulation, and GPU model evaluation remain outside this check.」,所有 sum/count/bucket 对账(52,845 / 96、32、21)都来自手工跑的 84 次 HTTP 请求,仓库内只更新了 mock engine 的 prefill 断言。

结论:接受当前覆盖范围并合入;直方图记录路径缺少自动化回归保护,后续 dispatcher 重构可能悄悄改变该指标。 · 待处理

fork PR 的评审与 CI 流程约束 other

`claude[bot]` 提示「This pull request is from a fork — automated review is disabled.」,仅 `esmeetu` 给出无正文 APPROVED;CI 侧首次 `/ci run` 因落后 2 个 commit 被拒,作者改用 `/ci run --allow-stale`,Bot 警告「This PR is 4 commits behind upstream `main`. Running CI at your own risk」。

结论:在自动化 review 缺席、仅单人 approve 的情况下由作者自行合并,设计反转与口径选择缺少第二双眼睛确认。 · noted

风险与影响

  1. 计数口径错位(正确性):rust/src/engine-core-client/src/client/imp.rs 中 has_outputs 取自原始 batch.outputs.is_empty(),而 iteration_tokens 只累加 sender 有效的输出。若某批次内所有输出都因请求不被本 frontend 跟踪而被 continue 跳过,仍会执行 observe(0),给 le="1" 桶增加一次 count 而 _sum 不变,使 _count 与 _sum 的可解释性下降。
  2. 协议契约固化在客户端:累加依赖 prefill_stats.num_computed_tokens,注释直接写明「The engine emits prefill_stats once, on the first output」。若引擎侧改为 chunked prefill 多次上报,或改为只上报 num_prompt_tokens,前端计数会立刻与 Python 侧口径分叉,而当前没有断言保护这一跨语言契约。
  3. 热路径开销与指标基数:run_output_dispatcher_loop 是每 engine 每 step 的必经路径,新增了每 output 的 len()、Option 检查与每批次的 map 查找 + observe,绝对开销很小但属高频;get_or_create_owned 按 (model_name, engine) 预建 series,DP 规模大时标签基数随引擎数线性增长。
  4. mock 行为变更的外溢:rust/src/mock-engine/src/engine.rs 现在会在首个输出携带 prefill_stats,任何假设「mock 输出只有 token id 与 finish_reason」的下游测试或脚本都可能受影响(本 PR 只同步更新了 rust/src/mock-engine/src/tests.rs)。
  5. 测试覆盖缺口:直方图的 sum/count/bucket 正确性只通过 PR body 中的手工 84 次 HTTP 对比验证,仓库内没有新增针对 iteration_metrics 记录路径的 Rust 测试;rust/src/mock-engine/src/tests.rs 只覆盖 prefill stats 出现一次。
  6. 评审风险:fork PR 关闭了自动 review,仅 1 个无正文 approve 且作者自合并,设计反转缺少同行确认。

对用户(SRE/运维):Rust 前端现在可以导出与 Python 前端同名的 vllm:iteration_tokens_total 及其 _count/_sum/_bucket,混杂部署时可用同一 Grafana 面板对比两侧每步 token 吞吐与 prefill chunk 规模。对系统:engine-core-client 输出分发循环多了一次无锁 histogram 采样,单次成本可忽略;指标基数按 engine 维度增长,需留意大 DP 场景。对团队/前端一致性:这是 Rust 前端向 Python 前端指标 parity 靠拢的一步,也是对先前「前端不维护 iteration 直方图」结论的推翻,后续其它迭代级指标可能沿用同一模式(前端记账 + 引擎只提供 prefill_stats)。对 mock 引擎:协议行为变化会让 mock 更贴近真实引擎,双前端对账测试因此可以覆盖 uncached prefill 场景。

缺少针对性测试 口径依赖引擎协议 热路径逐批次计数 评审覆盖不足 mock 行为变更外溢

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论