Repository navigation
[Rust][Benchmark] Load HF datasets from parquet shards via hf-hub, fixing truncated-cache sampling - #51570
Conversation
The Rust bench cached HF datasets as JSON snapshots truncated to the number of rows the first run requested, keyed only by dataset/config/split. A later run requesting more prompts silently got the truncated cache (e.g. GSM8k capped at 100 of 1319 requested prompts with --no-oversample). Rework the loader to read the dataset-viewer's auto-converted parquet shards (refs/convert/parquet) downloaded via hf-hub into the standard HF hub cache, shared with `hf download` and Python tooling: - Cache unit is now the immutable parquet shard; contents no longer depend on the requested row count, eliminating the truncation bug structurally. A truncated shard fails loudly (missing parquet footer) instead of silently under-sampling. - Drop the /rows pagination fallback: /rows itself requires a parquet export, so it can never succeed where the parquet path fails. - Fall back to native *.parquet files on the main branch (hf-hub repo listing + datasets-library layout conventions) for private datasets the dataset viewer does not process. - Align sampling with Python `vllm bench serve`: shuffle shard order with the seed, and read up to 2x the requested rows so validity filtering backfills from real rows instead of duplicating samples. - Match hf-hub auth in datasets-server discovery calls: HF_TOKEN env var, then the `hf auth login` token file. - parquet crate is added with default-features = false (no arrow); Row::to_json_value feeds the existing serde_json pipeline unchanged. Verified against a live endpoint: 1319/1319 GSM8k prompts (previously 100), token-identical input totals vs the Python bench on full GSM8k and MT-Bench splits, and byte-identical blob reuse of a cache pre-populated by `hf download --revision refs/convert/parquet`. Also fix the stale --random-range-ratio row in the README (documented the removed [len*r, len] semantics; the flag defaults to 0.0 and samples [len*(1-r), len*(1+r)]). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: esmeetu <jasonailu87@gmail.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcc2216690
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| openai-protocol = "1.6.0" | ||
| openssl = "0.10" | ||
| parking_lot = "0.12.5" | ||
| parquet = { version = "59.2.0", default-features = false, features = ["json", "snap", "zstd", "flate2", "flate2-rust_backend", "lz4"] } |
There was a problem hiding this comment.
Enable Brotli before accepting arbitrary Parquet shards
When a dataset-viewer export or native private-dataset shard uses Parquet's Brotli compression, SerializedFileReader will reject it because default-features are disabled and the brotli codec feature is omitted here. This regresses those datasets from the previous /rows path, where decompression happened server-side, and causes the entire HF benchmark to abort; enable the Parquet Brotli feature or retain a compatible fallback.
Useful? React with 👍 / 👎.
… datasets The main-branch fallback for datasets without a dataset-viewer parquet export only matched *.parquet, so private datasets stored as JSON (e.g. a single unlabeled data.json) could not be benchmarked while Python `load_dataset` handles them fine. The loader pipeline already consumes serde_json::Value rows, so decoding JSON natively needs no new dependencies. - Extend native file discovery to .json/.jsonl/.ndjson and mirror the datasets library's defaults: data files carrying no split name at all map to the "train" split; split-labeled files never leak into other splits. - Decode JSON arrays, single-array-field wrapper objects, and JSON Lines, dispatched per file extension alongside parquet. - When datasets-server /info is unavailable (private repos are not processed by the dataset viewer), fall back to config "default" and split "train" with a warning instead of failing resolution. Verified end-to-end on a real 218MB single-file JSON traces dataset: hub-cached download, row decode, and chat-column detection all work; selection logic covered by unit tests and an --ignored network test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: esmeetu <jasonailu87@gmail.com>
|
@claude review |
There was a problem hiding this comment.
Beyond the inline nits, this run also checked the partial-parquet handling for >5GB datasets (the partial-{split} shard matching in convert_parquet_shards) for silent under-sampling — it correctly includes those shards rather than skipping them, so the split's row count isn't silently truncated there.
Extended reasoning...
This is an informational-only note about one additional area examined and ruled out this run; it is not a guarantee of correctness and doesn't replace review of the two inline nits already posted (missing brotli codec feature, and the 'default' config fallback silently merging multi-config data for private datasets without --hf-subset).
| ))); | ||
| } | ||
| Ok(shard_paths) | ||
| } | ||
|
|
||
| let fetched = rows.len(); | ||
| offset += fetched; | ||
| /// File extensions the native main-branch fallback can decode. | ||
| const NATIVE_DATA_EXTENSIONS: &[&str] = &[".parquet", ".json", ".jsonl", ".ndjson"]; | ||
|
|
||
| let total = data["num_rows_total"].as_u64().unwrap_or(0); | ||
| progress.update(offset, total); | ||
| /// Discover native data files on the main branch matching a config/split, following | ||
| /// the datasets library's default layout conventions (config directories, split-named |
There was a problem hiding this comment.
🟡 When a private dataset's /info lookup fails and --hf-subset is omitted, resolved_config falls back to the literal string "default". In select_native_files (hf_dataset.rs:297-306), no file in a multi-config layout (e.g. gsm8k's main//socratic/ dirs) starts with "default/", so the config-scoping filter is silently skipped and rows from every config sharing that split name get pooled together with no warning — the opposite of the PR's stated goal of failing loudly instead of silently returning wrong data. A safer fallback would be to error out (or warn explicitly) when multiple config directories exist and none matches the resolved config.
Extended reasoning...
The bug: download_hf_dataset resolves (config, split) via the datasets-server /info endpoint, but private datasets aren't processed by the dataset viewer, so /info (and later /parquet) both fail. In that case the code falls into the else branch at hf_dataset.rs (around line 232): subset.unwrap_or("default").to_string(). If the user didn't pass --hf-subset, resolved_config becomes the literal string "default" — not an actual discovered config name.
That bogus config then flows into native_data_files(dataset, "default", split) → select_native_files (hf_dataset.rs:297-306):
let config_prefix = format!("{config}/");
let scoped: Vec<String> = if data.iter().any(|f| f.starts_with(&config_prefix)) {
data.into_iter().filter(|f| f.starts_with(&config_prefix)).collect()
} else {
data
};For a repo laid out like openai/gsm8k (config directories main/ and socratic/), no file starts with "default/", so the if is false and scoped falls through to all data files, unfiltered by config. The subsequent matched filter only checks path_matches_split(f, split), which matches split-named files in every config directory (e.g. both main/test-*.parquet and socratic/test-*.parquet). The function returns the pooled set with no error and no explicit 'merging configs' warning — the closest the user gets is the earlier, generic assuming config 'default' and split 'train' log line, which doesn't say config resolution failed to disambiguate multiple config directories.
Why nothing else catches this: The scoping logic in select_native_files is specifically designed to isolate a single config directory (see test_select_native_files_config_dir_scoping), but it only activates when the config string actually matches a directory prefix. The fallback literal "default" was never meant to be a real directory name, so it silently defeats the scoping mechanism it was supposed to invoke.
Impact: For a private, multi-config dataset accessed without --hf-subset, the benchmark silently samples prompts from multiple unrelated configs/subsets pooled together, corrupting the intended sample distribution (e.g. mixing gsm8k's main and socratic variants) without any indication to the user that this happened. This directly contradicts this PR's stated design goal: 'a physically truncated shard fails loudly ... instead of silently under-sampling' — here, instead, an ambiguous config fails quietly by merging data instead of erroring or asking the user to disambiguate.
Step-by-step proof:
- User runs against a private dataset repo laid out like gsm8k (
main/test-00000-of-00001.parquet,socratic/test-00000-of-00001.parquet), passing--hf-split testbut no--hf-subset. /infofails (401/403, private repo) →resolved_config = "default",resolved_split = "test"./parquetalso fails (viewer doesn't process private repos) → falls tonative_data_files(dataset, "default", "test").select_native_filesfilters files by extension → both parquet files remain indata.config_prefix = "default/"— neithermain/test-...norsocratic/test-...starts with it, so theifbranch is false andscoped = data(both files).matchedfilters bypath_matches_split(f, "test")— both files match (both are namedtest-...), so both are returned.- Rows from
mainandsocraticare downloaded and pooled into one sample set, with no warning that two distinct configs were merged.
Suggested fix: When the config falls back to the literal "default"" and multiple config-like directories exist in the file listing (i.e. scopedends up empty from config filtering but multiple distinct top-level dirs are present), fail loudly with an error asking the user to pass--hf-subset`, rather than silently keeping all files.
| openai-protocol = "1.6.0" | ||
| openssl = "0.10" | ||
| parking_lot = "0.12.5" | ||
| parquet = { version = "59.2.0", default-features = false, features = ["json", "snap", "zstd", "flate2", "flate2-rust_backend", "lz4"] } |
There was a problem hiding this comment.
🟡 rust/Cargo.toml:72 enables the parquet crate with default-features = false and only [json, snap, zstd, flate2, flate2-rust_backend, lz4], omitting brotli. A parquet shard (native private-dataset file, most plausibly) compressed with BROTLI will fail to decode in read_parquet_rows, aborting the whole HF benchmark run instead of the graceful per-codec handling the old /rows path provided. Fix is a one-line addition of "brotli" to the feature list (pure-Rust, no rustls/ring, so it stays deny.toml-clean).
Extended reasoning...
What the bug is. rust/Cargo.toml:72 declares:
parquet = { version = "59.2.0", default-features = false, features = ["json", "snap", "zstd", "flate2", "flate2-rust_backend", "lz4"] }The parquet (arrow-rs) crate gates each compression codec behind a Cargo feature that is normally pulled in via default-features. By disabling defaults and hand-picking [json, snap, zstd, flate2, flate2-rust_backend, lz4], the brotli codec feature (and lzo) is left out. Cargo.lock confirms this: the compiled parquet dependency set is ahash, base64, bytes, chrono, flate2, half, hashbrown, lz4_flex, num-bigint, num-integer, num-traits, seq-macro, serde_json, snap, twox-hash, zstd — no brotli crate anywhere in the graph.
Code path that triggers it. read_parquet_rows in rust/src/bench/src/datasets/hf_dataset.rs opens a shard with SerializedFileReader::new(file) and iterates rows via reader.get_row_iter(None). When a column's compression codec is BROTLI and the codec feature is compiled out, the parquet crate returns an error at row-decode time (a "disabled feature" / unsupported-codec error) instead of a decoded row. That Result::Err is mapped to BenchError::Config(...) inside read_parquet_rows, which propagates up through read_shards (called per shard inside download_hf_dataset) with no per-codec fallback or retry — the entire HF-dataset benchmark run aborts rather than skipping/reporting just the offending shard.
Why existing code doesn't prevent it. The PR's whole design assumes shards downloaded via hf-hub can always be decoded locally by the embedded parquet reader. There is no codec-capability check before download, no catch-and-skip around get_row_iter/row decode, and no fallback to another shard or dataset path if one shard uses an unsupported codec. Previously, the (now-removed) /rows datasets-server pagination endpoint decompressed server-side for any codec, so this class of failure did not exist for any HF dataset regardless of client capabilities — this is a genuine capability regression introduced by moving to a local parquet reader with a shrunken feature set.
Impact. For a dataset whose parquet columns are Brotli-compressed, vllm-bench --dataset-name hf fails hard with a config error and produces no benchmark results at all, rather than degrading gracefully. This is most likely to bite the new native-parquet fallback path added by this PR (used for private datasets not processed by the HF dataset-viewer), since those files can be written with arbitrary compression by whoever authored the dataset, whereas the primary dataset-viewer-converted path is auto-generated by HuggingFace's parquet converter using pyarrow's default (Snappy) compression — so the common case is unaffected, but the fallback path this PR specifically adds is exactly the one most exposed to this gap.
Step-by-step proof:
- A user has a private HF dataset repo with a
train.parquetfile whose columns were written withcompression='BROTLI'(e.g. viapyarrow.parquet.write_table(..., compression='BROTLI')). - User runs
vllm-bench --dataset-name hf --dataset-path <private-org>/<private-repo> --hf-split train --num-prompts 100. download_hf_datasetcallsconvert_parquet_shards, which fails (dataset-viewer has no export for private datasets) and falls back tonative_data_files, which findstrain.parquetonmainvia hf-hub repo listing.read_shardsdownloads the file and callsread_parquet_rows(&local_path, limit)in a blocking task.SerializedFileReader::new(file)succeeds (footer/metadata parse doesn't need codec support), butreader.get_row_iter(None)'s row iteration hits the BROTLI-compressed column and returnsErr(ParquetError::General("Disabled feature at compile time: brotli codec"))(or equivalent).read_parquet_rowsmaps this toErr(BenchError::Config(...)), whichread_shardspropagates via?, whichdownload_hf_datasetreturns as the overallResult, whichbenchmark.rs::run_benchmarkpropagates out ofrun_benchmarkentirely — the whole benchmark invocation exits with an error and zero results, even though 99% of the dataset's other columns/rows might be perfectly readable.
Fix. Add "brotli" to the feature list on rust/Cargo.toml:72:
parquet = { version = "59.2.0", default-features = false, features = ["json", "snap", "zstd", "flate2", "flate2-rust_backend", "lz4", "brotli"] }The parquet crate's brotli feature depends on the pure-Rust brotli crate (not brotli-decompressor's C bindings, and critically not rustls/ring/aws-lc/boring/s2n), so this stays clean against the rust/deny.toml crypto/TLS bans documented in rust/src/bench/AGENTS.md. This is a low-risk, mechanical dependency addition.
Two review findings on the HF parquet loader: - Enabling the parquet crate with default-features = false dropped the brotli codec, so a Brotli-compressed shard (possible for native files in private dataset repos) aborts the whole run with a "disabled feature" decode error. Re-enable the feature; brotli 8.0 is pure Rust and keeps the graph clean of the rustls/ring bans. - When the datasets-server /info lookup fails (private dataset) and --hf-subset is omitted, the config falls back to the literal "default". In a multi-config repo layout no file starts with "default/", so config scoping was silently skipped and rows from every config sharing the split name were pooled. Fail loudly with a --hf-subset hint when the config matches no directory and the selection still spans several directories; split directories stay unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: esmeetu <jasonailu87@gmail.com>
|
/ci run |
|
✅ Triggered Buildkite CI #84794 for commit |
|
This pull request has merge conflicts that must be resolved before it can be |
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
|
/ci run |
|
✅ Triggered Buildkite CI #84922 for commit |
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
|
/ci run |
|
✅ Triggered Buildkite CI #84954 for commit |
…xing truncated-cache sampling (vllm-project#51570) Signed-off-by: Zhu, Zufang <zufang.zhu@intel.com>
…xing truncated-cache sampling (vllm-project#51570)
Purpose
Fix a silent under-sampling bug in the Rust bench's
hfdataset loader and rework the download path.The bug: HF datasets were cached as JSON snapshots truncated to the row count of whatever run happened first, keyed only by
dataset-config-split. A later run requesting more prompts silently reused the truncated file — e.g. requesting the full 1319-prompt GSM8k test split yieldedsamples=100 requested=1319+skipping dataset oversampling, so the benchmark ran on 100 prompts while claiming to be a GSM8k run.The rework:
refs/convert/parquet), downloaded via hf-hub into the standard HF hub cache ($HF_HOME/hub) — shared bidirectionally withhf downloadand Pythonhuggingface_hub. The cache unit is the immutable shard, so cache contents can never depend on a previous run's request size; a physically truncated shard fails loudly (missing parquet footer) instead of silently under-sampling./rowspagination fallback is removed: per the dataset-viewer docs,/rowsitself only works for datasets with parquet exports, so it could never succeed where the parquet path fails (verified empirically on script-based/gated datasets — both endpoints return identical errors).*.parquetfiles on the main branch via hf-hub repo listing, following the datasets library's layout conventions (config dirs, split-named files).vllm bench serve: shard order is shuffled with--seed(mirrors the datasets library's streaming shard shuffle instead of always sampling the split prefix), and up to 2x the requested rows are read so validity filtering backfills from real rows instead of duplicating samples.HF_TOKENenv var, then thehf auth logintoken file (previously only the env var, so gated datasets failed at discovery for logged-in users without the env var).parquetcrate added withdefault-features = false+json— no arrow dependency;Row::to_json_value()feeds the existingserde_json-based column-detection pipeline unchanged. New deps in the graph:thrift,snap,lz4_flex(no rustls/ring;cargo metadataclean againstdeny.tomlbans).Also fixes the stale
--random-range-ratioREADME row, which still documented the removed[len*r, len]/ default-1.0 semantics (the flag defaults to0.0and samples[len*(1-r), len*(1+r)]), and staleAGENTS.mdmodule descriptions.Why this is not duplicating an existing PR
Checked open PRs touching this area:
HF_ENDPOINTvia hf-hub, with only the two discovery calls remaining ondatasets-server.huggingface.co. Will conflict textually inhf_dataset.rs; happy to coordinate.--hf-splitis omitted — different codebase path.hf_dataset.rs.Test commands and results
Live-endpoint verification (Kimi-K3 @ 1M ctx, tiktoken tokenizer):
--num-prompts 1319: 1319/1319 successful (bug scenario previously produced 100); cold-cache dataset load 1.2s (one 419KB shard vs 14 paginated JSON calls before), warm 0.4s.hf download openai/gsm8k --repo-type dataset --revision refs/convert/parquet, then running the bench → blob mtimes byte-identical before/after (zero re-download).Cross-implementation comparison vs Python
vllm bench serve(same endpoint/tokenizer/seed,--skip-chat-templateon Python):Known intentional divergence (pre-existing, documented in README): the Rust loader sends prompts raw and derives per-row output lengths from the answer column, while Python's per-dataset classes may apply chat templates and fixed default output lengths (GSM8k: 193599 input tokens with template vs 77527 raw).
Model evaluation
N/A — benchmark client change; no model/serving code touched. Note the sampling-semantics alignment (shard shuffle + 2x read) changes which rows are benchmarked for
num_prompts < split size; full-split runs are unaffected.AI assistance
This PR was developed with AI assistance (Claude Code). The submitter has reviewed the changes and run the tests above.
🤖 Generated with Claude Code