Prhub

#51863 [Bugfix][Benchmark] Check readiness before tokenizer init in rust vllm-bench

原始 PR 作者 tlrmchlsmth 合并时间 2026-08-19 10:52 文件变更 4 提交数 2 评论 11 代码增减 +159 / -81

执行摘要

vllm-bench 启动依赖请求接入就绪超时重试

PR body 说明有两条启动依赖请求会先于完整就绪探测发出:省略 --model 时的 /v1/models 发现、以及本地无 tokenizer 时回退到服务端 /tokenize 的探针。这些请求原本不做重试,服务端冷启动期间会直接 connection refused 或 400,导致 benchmark 立即退出,--ready-check-timeout-sec 形同虚设。修复策略是让 --ready-check-timeout-sec 覆盖这些请求,同时保留数据集派生的原位就绪探测,以保证探测载荷与真实 benchmark 请求一致(token IDs、multimodal 内容、pooling 输入),并让本地 tokenizer 加载与数据集生成继续和服务端启动并行。

值得精读。这是一个典型的“review 驱动重构”案例:第一版直接把就绪探测提前,被 AI 审查发现合成 probe 的载荷代表性问题后,第二版改为让启动依赖请求自带重试,方案更稳。重点学习 retry_with_timeout 的泛型封装(FnMut + Future 的写法)、零超时语义设计,以及“保留原位探测以保证载荷真实”的取舍逻辑。

讨论亮点

esmeetu 用 Claude Code 对第一版提交做了 AI 辅助审查,核心交锋如下,这些发现直接促成了第二版重构。关于 /v1/models 未纳入重试:

“Model auto-resolution still hits the server before this ready check... the benchmark exits immediately with a connection-refused error instead of waiting --ready-check-timeout-sec.”

关于合成探测与 min_tokens 冲突:

“The synthetic probe hardcodes output_len: 1... so --extra-body '{"min_tokens": 32}' makes every probe fail with 400 min_tokens must be less than or equal to max_tokens against a perfectly healthy server.”

关于探测载荷代表性缺失:

“replacing the dataset-derived probe with a synthetic one ("test", output_len: 1) makes the check fail forever against healthy servers with --extra-body min_tokens or --skip-tokenizer-init, while also passing trivially when the real request shape (multimodal / token-id prompts) would be rejected — converting an upfront abort into a full run that saves completed=0 garbage results.”

关于串行化等待的效率问题:

“This serializes the server-boot wait before tokenizer load and dataset generation... now wall-clock startup is boot_time + generation_time instead of roughly max of the two.”

结论:第二版 commit “Retry startup-dependent requests” 放弃合成探测、回归“数据集派生探测 + 启动依赖请求自带重试”的路线,回应了上述正确性与效率问题;esmeetu 最终 APPROVED(LGTM)。标注为 confirmed 的若干正确性发现均针对第一版实现,在合并版本中已不适用。

实现拆解

  1. 提炼通用重试原语:在 rust/src/bench/src/ready_checker.rs 新增泛型函数 retry_with_timeout,接受 timeout_secondsretry_interval 与闭包 operation,循环执行直到成功或超时,超时后返回 BenchError::EndpointTimeouttimeout_seconds == 0 时只执行一次并直接透传结果或错误,保证与旧行为兼容。同时把 wait_for_endpoint 的错误输出从 e.to_string() 改为 e.as_report(),日志格式更稳定。
  2. 收敛并重试模型发现benchmark.rsget_first_model_from_servermulti_turn.rsget_first_model 两份私有实现被删除,统一为 ready_checker::get_first_model。新函数在 GET {base_url}/v1/models 外层包上 retry_with_timeout(timeout_seconds, 5, ...),支持 extra_headersOPENAI_API_KEY 注入,并在响应无 data[0] 时返回 BenchError::Config
  3. 服务端 tokenizer 探针接入重试tokenizer.rsServerTokenizer::new 新增 timeout_seconds 参数,构造后把 /tokenize 探针 encode_async("test") 包进 retry_with_timeoutload_tokenizerserver_infoOption<(&str, &str)> 扩展为 Option<(&str, &str, u64)>,透传超时配置。
  4. 编排器透传配置run_benchmarkrun_multi_turn_benchmark 在省略 --model 分支调用 get_first_model(..., config.ready_check_timeout_sec),在加载 tokenizer 分支构造 server_info 时带上 config.ready_check_timeout_sec。原有的数据集派生就绪探测(wait_for_endpoint,携带真实首条请求的 token / multimodal / pooling 输入)保留在计时运行之前,未被改动。
  5. 测试配套:在 ready_checker.rs 内新增 #[cfg(test)] 模块,包含 retry_succeeds_after_transient_failures(前两次失败、第三次成功,验证重试收敛)与 zero_timeout_does_not_retry(超时 0 时仅执行一次)两个单元测试。PR 说明中验证了 cargo test -p vllm-bench(150 passed)、cargo clippycargo fmtpre-commit
文件 模块 状态 重要度
rust/src/bench/src/ready_checker.rs 就绪检查 modified 7.96
rust/src/bench/src/tokenizer.rs 分词器 modified 6.44
rust/src/bench/src/benchmark.rs 基准编排 modified 6.85
rust/src/bench/src/multi_turn.rs 多轮基准 modified 6.44

关键符号

retry_with_timeout get_first_model ServerTokenizer::new load_tokenizer run_benchmark run_multi_turn_benchmark

关键源码片段

rust/src/bench/src/tokenizer.rs core-logic

`ServerTokenizer::new` 新增 `timeout_seconds` 参数,`/tokenize` 探针接入 `retry_with_timeout`;`load_tokenizer` 的 `server_info` 由二元组扩展为三元组,是本 PR 覆盖的第二条启动依赖请求。

// rust/src/bench/src/tokenizer.rs —— 服务端 tokenizer 探针接入同一重试助手
impl ServerTokenizer {
    /// 创建服务端 tokenizer,并在 `timeout_seconds` 内重试 `/tokenize` 探针。
    ///
    /// 探针同时承担“验证连通性 + 估算 vocab size”两个职责;
    /// 服务端可能仍在加载模型,因此必须复用 `retry_with_timeout`。
    pub async fn new(base_url: &str, model: &str, timeout_seconds: u64) -> Result<Self> {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .map_err(|e| BenchError::Tokenizer(format!("Failed to build HTTP client: {e}")))?;        let tokenize_url = format!("{base_url}/tokenize");
        let detokenize_url = format!("{base_url}/detokenize");        let st = Self {
            client,
            runtime: tokio::runtime::Handle::current(),
            tokenize_url,
            detokenize_url,
            model: model.to_string(),
            cached_vocab_size: 0,
        };        // Probe the endpoint to verify it works and discover vocab size
        let test_tokens = crate::ready_checker::retry_with_timeout(timeout_seconds, 5, || {
            st.encode_async("test")
        })
        .await?;
        // 由最大 token id 反推 vocab size 下限,供 `get_allowed_tokens` 采样使用
        let max_id = test_tokens.iter().copied().max().unwrap_or(0);
        let estimated_vocab = (max_id * 2).max(131072);        Ok(Self {
            cached_vocab_size: estimated_vocab,
            ..st
        })
    }    // ... 其余编码逻辑保持不变 ...
}

评论区精华

`/v1/models` 自动发现未纳入就绪超时 正确性

esmeetu 指出省略 `--model` 时 `GET /v1/models` 仍以单次 `request.send().await?` 先行发出,服务端启动中会立即 connection refused,`--ready-check-timeout-sec` 不生效;建议把该请求也纳入同一超时重试。

结论:最终版把 `/v1/models` 抓取包进 `retry_with_timeout`,问题在合并版本中解决。 · 已解决

合成探测 output_len=1 与 `--extra-body min_tokens` 冲突 正确性

第一版将探测改为合成请求并硬编码 `output_len: 1`,同时合并 `extra_body`,使 `min_tokens: 32` 这类常见配置在健康服务器上稳定返回 400 `min_tokens must be less than or equal to max_tokens`,直到超时中止。

结论:最终版弃用合成探测、保留数据集派生探测,用首条请求的 `expected_output_len` 作为 `max_tokens`,规避该冲突。 · 已解决

合成文本探测破坏 `--skip-tokenizer-init` 服务器 正确性

合成探测发送文本 prompt 而非 `prompt_token_ids`,对 `--skip-tokenizer-init` 的服务端会被 400 拒绝,探测永久失败直至超时。

结论:合成探测被移除后不再影响该场景;数据集派生探测保留 `prompt_token_ids` 字段。 · 已解决

探测丢失 multimodal / token-id 等载荷形状 正确性

合成探测丢弃 `multi_modal_content`、`chat_messages_json`、`prompt_token_ids`,形状不兼容会从提前中止变成整轮运行后保存 `completed=0` 的垃圾结果。

结论:最终版保留原位数据集派生探测,载荷仍与真实请求一致;作为代价,就绪探测的完整性以数据集生成为前提。 · 已解决

提前就绪等待串行化 boot 与数据集生成 性能

第一版把就绪等待挪到 tokenizer 与数据集生成之前,wall-clock 从近似 `max`(并行)退化为 `boot_time + generation_time` 的串行;审查建议用并发任务或退一步让启动依赖请求自身重试。

结论:最终版保留原位探测,本地 tokenizer 加载与数据集生成仍可与服务端启动并行,同时重试覆盖了启动依赖请求。 · 已解决

rerank 特例重复 backend 载荷契约 设计

第一版的合成探测为 `VllmRerank` 特判 `prompt_list = [query, doc1]`,重复了 `backends/pooling.rs::build_payload` 的契约;若 rerank 最小输入要求变化,探测会静默失效。

结论:随合成探测移除而失效,未在最终代码中保留该特例。 · 已解决

`run_initial_ready_check` 缺少单测覆盖 测试

审查指出第一版新增的 `run_initial_ready_check` 对 `dry_run` 与 `ready_check_timeout_sec == 0` 的跳过分支、rerank 特例均无单测,且 `--dry-run` 离线不变式缺乏保障。

结论:该函数最终被删除,测试关注点转移到 `retry_with_timeout` 上并补充了两个单元测试。 · 已解决

风险与影响

最终实现是增量式修复,主要风险点包括:

  • 重试间隔在 get_first_modelServerTokenizer::new 内硬编码为 5 秒,与 wait_for_endpoint 调用方传参一致,但未来若需可配置需统一处理。
  • 新增单测只覆盖 retry_with_timeout 本身,未覆盖 get_first_model 的 header / API key 注入与 ServerTokenizer::new 的网络路径;这两处是否有回归依赖 CI 集成测试兜底。
  • timeout_seconds == 0 的语义是“单次执行、失败即返回”,虽然与旧的“无重试”行为一致,但未来复用者可能误认为 0 表示无限等待,需要留意文档说明。
  • 错误日志从 e.to_string() 改为 e.as_report(),输出格式有细微变化,不影响行为。
  • review 中提到的“就绪探测陈旧”风险(reqwest 连接池 90 秒 idle 回收、服务端在数据集生成期间重启)在最终版依然存在,因为探测位置保留在计时前一刻;但这是原有行为,并非本次引入的回归。

用户侧:rust vllm-bench 在服务端慢启动、省略 --model、本地无 tokenizer 文件需回退服务端 /tokenize 的场景不再提前崩溃,--ready-check-timeout-sec 真正生效。系统侧:纯客户端工具改动,不影响 vLLM 服务端任何路径。团队侧:retry_with_timeout 成为 rust/bench 可复用的等待原语,消除两份重复的模型发现代码,降低后续维护成本。整体影响程度中等偏低,属于工具健壮性改进。

启动竞态修复 重试原语复用 硬编码重试间隔 测试覆盖有限

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论