Prhub

#51570 [Rust][Benchmark] Load HF datasets from parquet shards via hf-hub, fixing truncated-cache sampling

原始 PR 作者 esmeetu 合并时间 2026-08-21 13:07 文件变更 8 提交数 6 评论 11 代码增减 +721 / -172

执行摘要

Rust bench 改用 parquet 分片加载 HF 数据集,修复缓存截断少采样

PR body 明确指出这是一个静默少采样 bug:HF 数据集被缓存为 JSON 快照,行数被"whatever run happened first"截断,且只按 dataset-config-split 键控。后续运行请求更多提示词时静默复用截断文件——请求完整 1319 条 GSM8k test split 时出现 samples=100 requested=1319 加 skipping dataset oversampling,benchmark 实际只跑 100 条却声称是 GSM8k 全量运行。此外 /rows 分页接口本身只服务于有 parquet 导出的数据集(官方文档如此),删除该回退、并把缓存单位改为不可变分片是让"物理截断的分片 loud fail(缺 parquet footer)而不是静默少采样"的关键设计。

值得精读。核心看点:(1) 用"不可变分片作为缓存单元"从根上消除缓存污染,替代"失败前先怀疑缓存"的思维;(2) 采样语义向 Python 实现对齐的取舍(shard shuffle + 2x 读取),并明确声明对 num_prompts < split size 结果的影响;(3) review 反馈闭环的示范——brotli codec 与 config 回退歧义均在后续 commit 中修复而非拒绝。对 vllm-bench 用户:建议升级后重新跑分以获取正确的全量数据基准。

讨论亮点

核心讨论围绕两条 review 意见展开:

  • parquet crate 缺 brotli codec(P2):chatgpt-codex-connector 与 claude[bot] 同时指出 default-features = false 且未启用 brotli feature 时,SerializedFileReader 会拒绝 Brotli 压缩的分片(最可能出现在私有数据集原生文件),导致整个 HF benchmark 中止,而非原先 /rows 路径的服务端解压;claude[bot] 强调这是一行修复且纯 Rust,不引入 rustls/ring,仍符合 deny.toml。提交 a58b63a 已修复(features 列表加入 brotli)。
  • "default" config 回退静默合并多 config 数据:claude[bot] 指出私有数据集 /info 失败且未传 --hf-subset 时,resolved_config 回退为字面 "default",在 select_native_files 中 config 过滤被静默跳过,多 config 布局(如 gsm8k 的 main/socratic 目录)下同名 split 的行会被混合——与 PR 声称的"失败要响亮"目标相反。提交 a58b63a "Address review: brotli parquet codec, ambiguous config" 一并处理了该歧义。
  • partial 分片核查(无问题确认):claude[bot] 顺带核查了 convert_parquet_shards 对 >5GB 数据集 partial-{split} 分片的匹配逻辑,确认这些分片被正确包含,split 行数不会在此处被静默截断。

实现拆解

整体按 6 步拆解:

  1. 缓存管线重构(hf_dataset.rs):删除自定义 cache_dir()、sanitize_name() 与 JSON 快照逻辑,改为经 HubRepo 通过 hf-hub 下载到标准 HF hub 缓存($HF_HOME/hub)。缓存单位从"可被请求大小污染的快照"变成"不可变 shard",缓存内容不再依赖前一次运行的请求量;物理截断的分片会因缺失 parquet footer 而显式报错。
  2. 主路径:dataset-viewer parquet 分片(hf_dataset.rs):新增 convert_parquet_shards,列出 refs/convert/parquet revision 下的分片并逐一用 SerializedFileReader 读取,Row::to_json_value() 喂给既有 serde_json 列检测管线,ColumnFormat 检测逻辑保持不变;claude[bot] 核查确认 partial-{split}(>5GB 数据集)分片会被正确包含而非跳过。
  3. 回退路径:私有数据集原生文件发现(hf_dataset.rs + hub.rs):/info 失败时走 native_data_files + select_native_files,借助 HubRepo::list_files()(新增,基于 repo info 的 siblings 列表)枚举 main 分支文件,按 datasets 库布局约定(top_level_dirs config 目录、path_matches_split split 命名文件)匹配 *.parquet/*.json/*.jsonl;HubRepo::dataset_with_revision 让 shard 下载可指定 revision。
  4. 采样语义与 Python 对齐(hf_dataset.rs + benchmark.rs):download_hf_dataset 新签名接收 seed 与 disable_shuffle,分片顺序按 seed 打乱(对应 datasets 库 streaming 的 shard shuffle),并读取最多 2 倍 num_rows_needed 行以支持有效性过滤回填;benchmark.rs 中 load_hf_dataset 的输入从缓存路径改为解析后的 entries 向量。
  5. 认证链路统一(hf_dataset.rs):token 解析改为 HF_TOKEN 环境变量优先、hf_hub::Cache::from_env().token()(hf auth login token 文件)兜底,discovery 与 shard 下载共用同一认证链。
  6. 配置与文档配套:workspace 及 bench crate 的 Cargo.toml 引入 parquet = { version = "59.2.0", default-features = false, features = [...] }(无 arrow 依赖;Brotli 等 codec feature 在 review 后补齐);Cargo.lock 新增 133 行传递依赖(thrift、snap、lz4_flex、brotli 等);README 修正 --random-range-ratio 的过时描述(实际默认 0.0、采样 [len*(1-r), len*(1+r)]),AGENTS.md 更新模块描述。

测试配套:本次变更未新增独立测试文件;依赖 cargo test -p vllm-bench(148 passed)与 --ignored 网络测试 test_download_gsm8k、test_native_parquet(full-split 回归守护 + main 分支发现)来覆盖下载链路。

文件 模块 状态 重要度
rust/src/bench/src/datasets/hf_dataset.rs 数据集加载 modified 8.84
rust/src/bench/src/hub.rs 下载层 modified 6.88
rust/src/bench/src/benchmark.rs 基准入口 modified 6.12
rust/Cargo.toml 依赖配置 modified 2.93
rust/src/bench/Cargo.toml 依赖配置 modified 2.53
rust/Cargo.lock 依赖锁定 modified 4.03
rust/src/bench/README.md 文档 modified 2.28
rust/src/bench/AGENTS.md 文档 modified 1.62

关键符号

download_hf_dataset convert_parquet_shards native_data_files select_native_files path_matches_split top_level_dirs read_parquet_rows load_hf_dataset dataset_with_revision list_files

关键源码片段

rust/src/bench/src/datasets/hf_dataset.rs core-logic

PR 核心文件:重写下载管线,删除 JSON 快照缓存,新增 parquet 分片读取、原生文件发现与采样对齐逻辑。

// rust/src/bench/src/datasets/hf_dataset.rs —— HF 数据集下载主入口(改动核心)
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
​
use std::path::Path;
use std::sync::Arc;
​
use parquet::file::reader::{FileReader, SerializedFileReader};
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
​
use super::SampleRequest;
use crate::error::{BenchError, Result};
use crate::tokenizer::TokenizerKind;
​
/// 下载并解析 HF 数据集,返回行数据而非缓存文件路径。
///
/// 行数据来自经 hf-hub 下载到标准 HF hub 缓存(`$HF_HOME/hub`)的数据文件:
/// 优先用 dataset-viewer 的 `refs/convert/parquet` 导出,否则退回 main 分支上
/// 的原生 parquet/JSON/JSONL 文件(dataset viewer 不处理的私有数据集走此路径)。
/// 分片顺序按 `seed` 打乱(除非 `disable_shuffle`),并读取最多 2 倍
/// `num_rows_needed` 的行,使下游有效性过滤能回填真实行而不是复制样本。
pub async fn download_hf_dataset(
    dataset: &str,
    subset: Option<&str>,
    split: Option<&str>,
    num_rows_needed: usize,
    seed: u64,
    disable_shuffle: bool,
) -> Result<(Vec<serde_json::Value>, String, String)> {
    let encoded_dataset: String =
        url::form_urlencoded::byte_serialize(dataset.as_bytes()).collect();
​
    let mut client_builder =
        reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
​
    // 与 hf-hub 下载一致的认证链路:HF_TOKEN 环境变量优先,其次读
    // `hf auth login` 写入的 token 文件。此前 discovery 只认环境变量,
    // 导致已登录但未设环境变量的用户访问 gated 数据集时在发现阶段失败。
    let token = std::env::var("HF_TOKEN")
        .ok()
        .filter(|t| !t.is_empty())
        .or_else(|| hf_hub::Cache::from_env().token());
    if let Some(token) = token {
        let mut headers = reqwest::header::HeaderMap::new();
        let header_value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
            .map_err(|e| BenchError::Config(format!("Invalid HF token: {e}")))?;
        headers.insert(reqwest::header::AUTHORIZATION, header_value);
        client_builder = client_builder.default_headers(headers);
    }
​
    let client = client_builder
        .build()
        .map_err(|e| BenchError::Config(format!("Failed to build HTTP client: {e}")))?;
​
    // 解析 config 与 split:两者都显式给出时跳过 /info 发现调用;
    // 否则调 /info 发现可用 configs 与 splits,私有数据集未被 viewer
    // 处理时该调用失败,会走原生文件发现的回退路径。
    let (resolved_config, resolved_split) = if let (Some(cfg), Some(spl)) = (subset, split) {
        (cfg.to_string(), spl.to_string())
    } else if let Some(info) = {
        // ...(/info 请求与返回值解析,略)
        None
    } {
        // ...(按 /info 结果解析 resolved_config / resolved_split,略)
        unreachable!()
    } else {
        // ...(缺省 config/split 的解析,含 review 中 "default" 歧义的处理,略)
        return Err(BenchError::Config("cannot resolve config/split".into()));
    };
​
    // 后续主流程(本片段之外):
    // 1. convert_parquet_shards:列出 refs/convert/parquet revision 的分片
    // (含 >5GB 数据集的 partial-{split} 分片),逐个经 SerializedFileReader
    // 读取,按 seed 打乱后取最多 2 * num_rows_needed 行;
    // 2. 无 parquet 导出时走 native_data_files + select_native_files 的
    // 原生文件发现回退;
    // 3. Row::to_json_value() 喂给既有 serde_json 列检测管线(ColumnFormat)。
    let _ = (client, resolved_config, resolved_split);
    unimplemented!("完整实现见 convert_parquet_shards / native_data_files 流程")
}
rust/src/bench/src/hub.rs core-logic

为分片下载与原生文件发现新增 dataset_with_revision 与 list_files 能力,是私有数据集回退路径的基础。

// rust/src/bench/src/hub.rs —— hf-hub 仓库句柄(新增能力)
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
​
use std::path::PathBuf;
​
use hf_hub::api::tokio::{ApiBuilder, ApiRepo};
use hf_hub::{Repo, RepoType};
​
/// 对 HuggingFace Hub 仓库的句柄,下载统一走 hf-hub 的磁盘缓存
/// (`$HF_HOME/hub`),与 `hf download` 和 Python `huggingface_hub` 互通。
pub struct HubRepo {
    repo: ApiRepo,
}
​
impl HubRepo {
    pub fn model(model_id: String) -> Result<Self, String> {
        Self::new(Repo::model(model_id))
    }
​
    pub fn dataset(repo_id: String) -> Result<Self, String> {
        Self::new(Repo::dataset(repo_id))
    }
​
    /// 以指定 revision 打开数据集仓库,用于下载 dataset-viewer 的
    /// `refs/convert/parquet` 自动转换分片。
    pub fn dataset_with_revision(repo_id: String, revision: String) -> Result<Self, String> {
        Self::new(Repo::with_revision(repo_id, RepoType::Dataset, revision))
    }
​
    fn new(repo: Repo) -> Result<Self, String> {
        let mut builder = ApiBuilder::from_env();
        if let Ok(token) = std::env::var("HF_TOKEN") {
            builder = builder.with_token(Some(token));
        }
        let api = builder.build().map_err(|e| format!("Failed to init HF API: {e}"))?;
        Ok(Self { repo: api.repo(repo) })
    }
​
    /// 下载(或从缓存取回)仓库中的单个文件,认证由 hf-hub 依 HF_TOKEN
    /// 或登录 token 文件处理。
    pub async fn get(&self, filename: &str) -> Result<PathBuf, String> {
        self.repo.get(filename).await.map_err(|e| format!("{e}"))
    }
​
    /// 列出仓库在该 revision 下的全部文件路径,供私有数据集的原生
    /// parquet/JSON/JSONL 文件发现使用。
    pub async fn list_files(&self) -> Result<Vec<String>, String> {
        let info = self.repo.info().await.map_err(|e| format!("{e}"))?;
        Ok(info.siblings.into_iter().map(|s| s.rfilename).collect())
    }
}

评论区精华

parquet crate 缺 brotli codec,Brotli 压缩分片会中止整个 benchmark 正确性

chatgpt-codex-connector 标注 P2:`SerializedFileReader` 会拒绝 Brotli 压缩的分片(最可能来自私有数据集原生文件),且此前 `/rows` 路径由服务端解压不存在此问题,属于回归;claude[bot] 补充一行即可修复,纯 Rust 无 rustls/ring,仍符合 deny.toml。

结论:提交 a58b63a 修复,parquet features 加入 `brotli`,同时保留其余 codec。 · 已解决

私有数据集无 --hf-subset 时 "default" config 回退会静默合并多 config 数据 正确性

claude[bot] 指出 `/info` 失败且未传 `--hf-subset` 时 resolved_config 回退为字面 "default",`select_native_files` 的 config 过滤被静默跳过,多 config 布局(如 gsm8k 的 main/socratic)下同名 split 行被混合,与 PR 宣称的 " 失败要响亮 " 目标相反。

结论:提交 a58b63a "Address review: brotli parquet codec, ambiguous config" 处理了该歧义。 · 已解决

>5GB 数据集的 partial-{split} 分片不会静默少采样 正确性

claude[bot] 顺带核查了 `convert_parquet_shards` 中 `partial-{split}` 分片匹配,确认这些分片被正确包含而非跳过,split 行数不会在此处被静默截断。

结论:无需修复,仅确认当前实现正确。 · 已解决

风险与影响

  1. 回归风险(hf_dataset.rs):从 JSON 快照切到 parquet 直读后,read_parquet_rows 的整行物化(Row::to_json_value())对嵌套 schema、大 cell 或稀疏列的内存占用高于之前的分页 JSON(尽管此前被截断);select_native_files 的布局启发式(top_level_dirs、path_matches_split)对非常规仓库布局可能误选或漏选文件。
  2. 兼容性风险(采样行为变更):num_prompts < split size 时,分片打乱 + 2x 读取改变了实际被 benchmark 的行集合,与历史 Rust bench 结果不能直接对比(PR 声明与 Python 对齐是有意为之,全量 split 运行不受影响)。
  3. 依赖风险(Cargo.toml/Cargo.lock):parquet crate 引入 thrift、snap、lz4_flex、brotli 等传递依赖(Cargo.lock +133),增加首次编译时间与二进制体积;Codec feature 集若再缺某种压缩格式仍可能中止。
  4. 测试覆盖风险:没有新增独立测试文件,关键下载路径只靠 #[ignored] 网络测试守护,常规 CI 不覆盖,回归检测偏弱。
  5. 安全/认证:discovery 调用现在也会使用 token 文件,与 hf-hub 下载的暴露面一致,属于可接受的对齐。

影响范围集中在 Rust 侧 benchmark 客户端(vllm-bench 的 --dataset-name hf 路径),不触碰模型推理与 serving 代码。对使用者而言:GSM8k 等 split 超过 100 条的数据集不再少采样,结果与 Python vllm bench serve 逐 token 对齐(GSM8k 全 split 输入 token 均为 77527);冷缓存加载从约 14 次分页 JSON 调用降到单分片下载(GSM8k 冷加载 1.2s、热加载 0.4s)。缓存与 hf download、Python huggingface_hub 双向共享 $HF_HOME/hub,跨语言复用且 blob mtime 不变(零重复下载)。对团队而言,Rust 前端后续需要维护 parquet 直读路径与原生文件发现启发式。

采样行为变更影响历史对比 新依赖引入编译成本 多 config 匹配启发式 缺少独立测试文件 parquet 直读内存占用

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论