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

Roy Wang committed Aug 21, 2026 at 13:07 UTC df344bf36f107b5f801b2c058c8ea8e922101f6a
8 files changed +721 -172
rust/Cargo.lock
+133
@@ -15,6 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
15 checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
16 dependencies = [
17 "cfg-if",
18 + "const-random",
19 "getrandom 0.3.4",
20 "once_cell",
21 "serde",
@@ -31,6 +32,21 @@ dependencies = [
32 "memchr",
33 ]
34
35 +[[package]]
36 +name = "alloc-no-stdlib"
37 +version = "2.0.4"
38 +source = "registry+https://github.com/rust-lang/crates.io-index"
39 +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
40 +
41 +[[package]]
42 +name = "alloc-stdlib"
43 +version = "0.2.4"
44 +source = "registry+https://github.com/rust-lang/crates.io-index"
45 +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
46 +dependencies = [
47 + "alloc-no-stdlib",
48 +]
49 +
50 [[package]]
51 name = "android_system_properties"
52 version = "0.1.5"
@@ -379,6 +395,12 @@ version = "0.22.1"
395 source = "registry+https://github.com/rust-lang/crates.io-index"
396 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
397
398 +[[package]]
399 +name = "base64"
400 +version = "0.23.1"
401 +source = "registry+https://github.com/rust-lang/crates.io-index"
402 +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
403 +
404 [[package]]
405 name = "base64ct"
406 version = "1.8.3"
@@ -450,6 +472,27 @@ dependencies = [
472 "generic-array",
473 ]
474
475 +[[package]]
476 +name = "brotli"
477 +version = "8.0.4"
478 +source = "registry+https://github.com/rust-lang/crates.io-index"
479 +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
480 +dependencies = [
481 + "alloc-no-stdlib",
482 + "alloc-stdlib",
483 + "brotli-decompressor",
484 +]
485 +
486 +[[package]]
487 +name = "brotli-decompressor"
488 +version = "5.0.3"
489 +source = "registry+https://github.com/rust-lang/crates.io-index"
490 +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
491 +dependencies = [
492 + "alloc-no-stdlib",
493 + "alloc-stdlib",
494 +]
495 +
496 [[package]]
497 name = "bstr"
498 version = "1.12.1"
@@ -671,6 +714,26 @@ dependencies = [
714 "windows-sys 0.61.2",
715 ]
716
717 +[[package]]
718 +name = "const-random"
719 +version = "0.1.18"
720 +source = "registry+https://github.com/rust-lang/crates.io-index"
721 +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
722 +dependencies = [
723 + "const-random-macro",
724 +]
725 +
726 +[[package]]
727 +name = "const-random-macro"
728 +version = "0.1.16"
729 +source = "registry+https://github.com/rust-lang/crates.io-index"
730 +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
731 +dependencies = [
732 + "getrandom 0.2.17",
733 + "once_cell",
734 + "tiny-keccak",
735 +]
736 +
737 [[package]]
738 name = "constant_time_eq"
739 version = "0.4.2"
@@ -1584,6 +1647,7 @@ dependencies = [
1647 "bytemuck",
1648 "cfg-if",
1649 "crunchy",
1650 + "num-traits",
1651 "zerocopy",
1652 ]
1653
@@ -1617,6 +1681,12 @@ version = "0.16.1"
1681 source = "registry+https://github.com/rust-lang/crates.io-index"
1682 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1683
1684 +[[package]]
1685 +name = "hashbrown"
1686 +version = "0.17.1"
1687 +source = "registry+https://github.com/rust-lang/crates.io-index"
1688 +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1689 +
1690 [[package]]
1691 name = "heck"
1692 version = "0.5.0"
@@ -2270,6 +2340,15 @@ version = "0.4.29"
2340 source = "registry+https://github.com/rust-lang/crates.io-index"
2341 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
2342
2343 +[[package]]
2344 +name = "lz4_flex"
2345 +version = "0.14.0"
2346 +source = "registry+https://github.com/rust-lang/crates.io-index"
2347 +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
2348 +dependencies = [
2349 + "twox-hash",
2350 +]
2351 +
2352 [[package]]
2353 name = "logos"
2354 version = "0.15.1"
@@ -2648,6 +2727,16 @@ dependencies = [
2727 "windows-sys 0.61.2",
2728 ]
2729
2730 +[[package]]
2731 +name = "num-bigint"
2732 +version = "0.5.1"
2733 +source = "registry+https://github.com/rust-lang/crates.io-index"
2734 +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
2735 +dependencies = [
2736 + "num-integer",
2737 + "num-traits",
2738 +]
2739 +
2740 [[package]]
2741 name = "num-complex"
2742 version = "0.4.6"
@@ -2866,6 +2955,31 @@ dependencies = [
2955 "windows-link",
2956 ]
2957
2958 +[[package]]
2959 +name = "parquet"
2960 +version = "59.2.0"
2961 +source = "registry+https://github.com/rust-lang/crates.io-index"
2962 +checksum = "7065842956a20c2a536924ce8e4d9955f7422451511b9eb7500d7bfe5077e59c"
2963 +dependencies = [
2964 + "ahash",
2965 + "base64 0.23.1",
2966 + "brotli",
2967 + "bytes",
2968 + "chrono",
2969 + "flate2",
2970 + "half",
2971 + "hashbrown 0.17.1",
2972 + "lz4_flex",
2973 + "num-bigint",
2974 + "num-integer",
2975 + "num-traits",
2976 + "seq-macro",
2977 + "serde_json",
2978 + "snap",
2979 + "twox-hash",
2980 + "zstd",
2981 +]
2982 +
2983 [[package]]
2984 name = "paste"
2985 version = "1.0.15"
@@ -4044,6 +4158,12 @@ version = "1.0.27"
4158 source = "registry+https://github.com/rust-lang/crates.io-index"
4159 checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
4160
4161 +[[package]]
4162 +name = "seq-macro"
4163 +version = "0.3.6"
4164 +source = "registry+https://github.com/rust-lang/crates.io-index"
4165 +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
4166 +
4167 [[package]]
4168 name = "serde"
4169 version = "1.0.228"
@@ -4316,6 +4436,12 @@ dependencies = [
4436 "version_check",
4437 ]
4438
4439 +[[package]]
4440 +name = "snap"
4441 +version = "1.1.2"
4442 +source = "registry+https://github.com/rust-lang/crates.io-index"
4443 +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
4444 +
4445 [[package]]
4446 name = "socket2"
4447 version = "0.6.3"
@@ -5341,6 +5467,12 @@ dependencies = [
5467 "utf-8",
5468 ]
5469
5470 +[[package]]
5471 +name = "twox-hash"
5472 +version = "2.1.3"
5473 +source = "registry+https://github.com/rust-lang/crates.io-index"
5474 +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9"
5475 +
5476 [[package]]
5477 name = "typenum"
5478 version = "1.19.0"
@@ -5636,6 +5768,7 @@ dependencies = [
5768 "image",
5769 "indicatif",
5770 "mimalloc",
5771 + "parquet",
5772 "rand 0.9.2",
5773 "rand_distr",
5774 "rayon",
rust/Cargo.toml
+1
@@ -70,6 +70,7 @@ openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmon
70 openai-protocol = "1.6.0"
71 openssl = "0.10"
72 parking_lot = "0.12.5"
73 +parquet = { version = "59.2.0", default-features = false, features = ["json", "snap", "zstd", "flate2", "flate2-rust_backend", "lz4", "brotli"] }
74 paste = "1.0.15"
75 prometheus-client = "0.24.0"
76 prometheus-client-derive-encode = "0.5.0"
rust/src/bench/AGENTS.md
+3 -3
@@ -4,7 +4,7 @@
4
5 Rust rewrite of `vllm bench serve` — a high-performance benchmark client for vLLM serving endpoints. Standalone binary, no Python dependency at runtime.
6
7 -Member crate `vllm-bench` of the `rust/` workspace. Uses workspace dependencies and lints; the workspace `[profile.release]` (thin LTO, `panic = "abort"`) applies. Note the workspace bans rustls/ring (`rust/deny.toml`) — all HTTP must stay on native-tls, which is why HF Hub downloads go through `src/hub.rs` (async hf-hub API bridged to sync) instead of hf-hub's ureq backend.
7 +Member crate `vllm-bench` of the `rust/` workspace. Uses workspace dependencies and lints; the workspace `[profile.release]` (thin LTO, `panic = "abort"`) applies. Note the workspace bans rustls/ring (`rust/deny.toml`) — all HTTP must stay on native-tls, which is why HF Hub downloads go through `src/hub.rs` (hf-hub's async reqwest/native-tls API) instead of hf-hub's ureq backend.
8
9 ## Build & Test
10
@@ -34,7 +34,7 @@ cargo test -p vllm-bench -- --ignored
34 - `src/compare.rs` — Side-by-side diff of two result JSON files (`--compare`)
35 - `src/tokenizer.rs` — `TokenizerKind` enum: Local(HuggingFace), Tiktoken, OR Server-side `/tokenize`+`/detokenize` fallback
36 - `src/tiktoken.rs` — Tiktoken BPE loader (`.tiktoken`/`.model` files; built-in encodings o200k_base/cl100k_base; pat_str extraction from Python source)
37 -- `src/hub.rs` — `HubRepo`: sync facade over hf-hub's async (reqwest/native-tls) API — per-download thread with its own runtime; the sync ureq backend is unusable here because it pulls rustls, which `rust/deny.toml` bans
37 +- `src/hub.rs` — `HubRepo`: thin wrapper over hf-hub's async (reqwest/native-tls) API — file download and repo listing, optional revision; the sync ureq backend is unusable here because it pulls rustls, which `rust/deny.toml` bans
38 - `src/rate_control.rs` — Gamma/Poisson request scheduling + linear/exponential ramp-up
39 - `src/ready_checker.rs` — Endpoint readiness with retry
40 - `src/backends/` — Backend implementations (enum dispatch, not trait objects)
@@ -48,7 +48,7 @@ cargo test -p vllm-bench -- --ignored
48 - `src/datasets/sharegpt.rs` — ShareGPT JSON loader + HuggingFace Hub auto-download with caching
49 - `src/datasets/sonnet.rs` and `src/datasets/sonnet.txt` — Sonnet dataset (built-in Shakespeare sonnets via `include_str!("sonnet.txt")`; controllable token length + shared prefix; mirrors Python `SonnetDataset`)
50 - `src/datasets/speed_bench.rs` — NVIDIA SPEED-Bench loader (HF datasets-server API, 6 configs, 11 categories, local cache)
51 -- `src/datasets/hf_dataset.rs` — Generic HuggingFace dataset loader (datasets-server API, column auto-detection)
51 +- `src/datasets/hf_dataset.rs` — Generic HuggingFace dataset loader (parquet shards via hf-hub into the standard HF hub cache; datasets-server for config/split discovery; native main-branch parquet/JSON fallback for private datasets; column auto-detection)
52 - `src/datasets/custom.rs` — Custom JSONL dataset (`{"prompt": ..., "output_tokens": ...}` per line; `--custom-output-len -1` uses per-line output_tokens; prompts always sent raw — no client-side chat template)
53 - `src/datasets/prefix_repetition.rs` — Prefix repetition dataset (N shared prefixes × fresh random suffixes, standard prefix-cache stress; mirrors Python `PrefixRepetitionRandomDataset`)
54 - `src/datasets/random_rerank.rs` — Random rerank dataset (one query + batched documents per request for `vllm-rerank`; `--no-reranker` for embedding-based scoring; mirrors Python `RandomDatasetForReranking`)
rust/src/bench/Cargo.toml
+1
@@ -17,6 +17,7 @@ hf-hub.workspace = true
17 image.workspace = true
18 indicatif.workspace = true
19 mimalloc.workspace = true
20 +parquet.workspace = true
21 rand.workspace = true
22 rand_distr.workspace = true
23 rayon.workspace = true
rust/src/bench/README.md
+9 -5
@@ -148,7 +148,8 @@ vllm-bench \
148 --sonnet-input-len 550 --sonnet-output-len 150 --sonnet-prefix-len 200 \
149 --num-prompts 500
150
151 -# Any public HuggingFace dataset (auto-downloads, auto-detects columns)
151 +# Any HuggingFace dataset (downloads parquet shards into the standard HF hub
152 +# cache — shared with `hf download` and Python tooling — auto-detects columns)
153 vllm-bench \
154 --backend openai-chat --base-url http://127.0.0.1:8000 --model <model-name> \
155 --dataset-name hf --dataset-path allenai/WildChat-4.8M \
@@ -160,7 +161,8 @@ vllm-bench \
161 --dataset-name hf --dataset-path THUDM/LongBench \
162 --hf-subset narrativeqa --hf-split test --hf-output-len 512 --num-prompts 200
163
163 -# Gated HuggingFace dataset (requires HF_TOKEN)
164 +# Gated HuggingFace dataset (auth via HF_TOKEN or the `hf auth login` token file).
165 +# Private datasets work too when the repo stores native parquet/json/jsonl files.
166 HF_TOKEN=hf_xxx vllm-bench \
167 --backend openai-chat --base-url http://127.0.0.1:8000 --model <model-name> \
168 --dataset-name hf --dataset-path lmsys/lmsys-chat-1m \
@@ -431,7 +433,7 @@ Pooling backends are non-streaming and report E2EL (end-to-end latency) only. Us
433 | `sharegpt` | Real conversations from ShareGPT (auto-downloads from HuggingFace, or use `--dataset-path`) |
434 | `sonnet` | Built-in Shakespeare sonnets; controllable token length + shared prefix, no dataset file needed |
435 | `speed-bench` | NVIDIA SPEED-Bench for speculative decoding evaluation (auto-downloads, 11 categories) |
434 -| `hf` | Any HuggingFace dataset (auto-downloads via datasets-server API, auto-detects chat/text columns) |
436 +| `hf` | Any HuggingFace dataset with parquet data (downloads parquet shards via hf-hub into the standard HF hub cache, auto-detects chat/text columns) |
437
438 ## Metrics
439
@@ -507,7 +509,7 @@ Run `vllm-bench --help` for the authoritative list. Grouped reference below.
509 | `--random-input-len` | `1024` | Input token length |
510 | `--random-output-len` | `128` | Output token length |
511 | `--random-prefix-len` | `0` | Shared prefix length |
510 -| `--random-range-ratio` | `1.0` | Length jitter, range `(0, 1]`. Lengths sampled from `[ratio × target, target]`; `1.0` = fixed length |
512 +| `--random-range-ratio` | `0.0` | Length jitter `r`, range `[0, 1)`. Lengths sampled uniformly from `[len×(1−r), len×(1+r)]`; `0.0` = exact target lengths. Also accepts `'{"input": r1, "output": r2}'` for independent input/output jitter |
513 | `--prompt-token-ids` | `false` | Send prompts as token-ID arrays (skips server-side tokenization, exact counts). Random dataset only |
514 | **Random multimodal** | | |
515 | `--random-mm-base-items-per-request` | `1` | Base number of multimodal items (images) per request |
@@ -795,6 +797,7 @@ The Rust implementation matches Python `vllm bench serve` in:
797 - Rate control (Gamma distribution, normalization, burstiness, linear/exponential ramp-up)
798 - Metrics (TTFT/TPOT/ITL/E2EL percentiles, peak tokens/sec, peak concurrency, goodput)
799 - Sampling parameters merged into the request body via `extra_body` (same precedence rules)
800 +- HF dataset prompt construction (token-identical input totals vs. Python on full GSM8k / MT-Bench splits; Python-only per-dataset chat templating is intentionally not applied — prompts are sent raw)
801 - CLI flags: a superset of Python's, enforced by `tests/cli_parity.rs` against a
802 snapshot of the Python parser (`tests/python_serve_flags.txt`, regenerated by
803 `tests/benchmarks/test_rust_bench_cli_parity.py` in the repo root) with an
@@ -806,7 +809,8 @@ The Rust implementation matches Python `vllm bench serve` in:
809 | Variable | Description |
810 | ---------- | ------------- |
811 | `OPENAI_API_KEY` | API key for authenticated endpoints (cached, not read per-request) |
809 -| `HF_TOKEN` | HuggingFace token for gated model tokenizers and gated datasets |
812 +| `HF_TOKEN` | HuggingFace token for gated/private tokenizers and datasets (falls back to the `hf auth login` token file) |
813 +| `HF_HOME` | Overrides the HuggingFace cache location (default `~/.cache/huggingface`) used for tokenizers and dataset parquet shards |
814 | `TOKIO_WORKER_THREADS` | Override tokio worker thread count (default: physical cores) |
815
816 ## License
rust/src/bench/src/benchmark.rs
+10 -9
@@ -534,17 +534,18 @@ pub async fn run_benchmark(config: &BenchConfig) -> Result<serde_json::Value> {
534 let dataset_id = config.dataset_path.as_deref().ok_or_else(|| {
535 BenchError::Config("--dataset-path is required for --dataset-name hf".into())
536 })?;
537 - let (downloaded_path, _config, _split) =
538 - crate::datasets::hf_dataset::download_hf_dataset(
539 - dataset_id,
540 - config.hf_subset.as_deref(),
541 - config.hf_split.as_deref(),
542 - config.num_prompts,
543 - )
544 - .await?;
537 + let (entries, _config, _split) = crate::datasets::hf_dataset::download_hf_dataset(
538 + dataset_id,
539 + config.hf_subset.as_deref(),
540 + config.hf_split.as_deref(),
541 + config.num_prompts,
542 + config.seed,
543 + config.disable_shuffle,
544 + )
545 + .await?;
546 crate::datasets::hf_dataset::load_hf_dataset(
547 tok,
547 - &downloaded_path,
548 + &entries,
549 config.num_prompts,
550 config.hf_output_len,
551 config.seed,
rust/src/bench/src/datasets/hf_dataset.rs
+553 -154
@@ -1,38 +1,18 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +use std::path::Path;
5 use std::sync::Arc;
6
7 +use parquet::file::reader::{FileReader, SerializedFileReader};
8 use rand::rngs::StdRng;
9 use rand::seq::SliceRandom;
10 use rand::{Rng, SeedableRng};
11
12 use super::SampleRequest;
11 -use super::progress::RowDownloadReporter;
13 use crate::error::{BenchError, Result};
14 use crate::tokenizer::TokenizerKind;
15
15 -/// Cache directory for downloaded HF datasets.
16 -fn cache_dir() -> std::path::PathBuf {
17 - dirs::cache_dir()
18 - .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
19 - .join("vllm-bench")
20 - .join("datasets")
21 -}
22 -
23 -/// Sanitize a dataset name for use in filenames (replace `/` and other unsafe chars).
24 -fn sanitize_name(name: &str) -> String {
25 - name.chars()
26 - .map(|c| {
27 - if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
28 - c
29 - } else {
30 - '_'
31 - }
32 - })
33 - .collect()
34 -}
35 -
16 /// Detected column format for extracting prompts from HF dataset rows.
17 enum ColumnFormat {
18 /// Column contains chat messages (array of {role, content} or {from, value}).
@@ -103,28 +83,39 @@ async fn get_with_retry(
83
84 /// Download an arbitrary HuggingFace dataset via the datasets-server REST API.
85 ///
106 -/// Returns `(cache_path, resolved_config, resolved_split)`.
86 +/// Returns `(rows, resolved_config, resolved_split)`.
87 ///
88 /// If both `subset` and `split` are provided, the `/info` call is skipped as an optimization.
109 -/// Paginated download fetches rows in pages of 100 until `num_rows_needed` are collected
110 -/// or the dataset is exhausted.
89 +/// Rows come from data files downloaded via hf-hub into the standard HF hub cache: the
90 +/// dataset-viewer's `refs/convert/parquet` export when available, otherwise native
91 +/// parquet/JSON/JSONL files on the main branch (e.g. private datasets the viewer does
92 +/// not process). Shard order is shuffled with `seed` (unless `disable_shuffle`) so
93 +/// samples are not always drawn from the start of the split, and up to twice
94 +/// `num_rows_needed` rows are read so downstream validity filtering can backfill from
95 +/// real rows.
96 pub async fn download_hf_dataset(
97 dataset: &str,
98 subset: Option<&str>,
99 split: Option<&str>,
100 num_rows_needed: usize,
116 -) -> Result<(String, String, String)> {
101 + seed: u64,
102 + disable_shuffle: bool,
103 +) -> Result<(Vec<serde_json::Value>, String, String)> {
104 let encoded_dataset: String =
105 url::form_urlencoded::byte_serialize(dataset.as_bytes()).collect();
106
107 let mut client_builder =
108 reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
109
123 - // Add HF_TOKEN auth header if available
124 - if let Ok(token) = std::env::var("HF_TOKEN") {
110 + // Match hf-hub's auth for shard downloads: HF_TOKEN env var, then the hub token file
111 + let token = std::env::var("HF_TOKEN")
112 + .ok()
113 + .filter(|t| !t.is_empty())
114 + .or_else(|| hf_hub::Cache::from_env().token());
115 + if let Some(token) = token {
116 let mut headers = reqwest::header::HeaderMap::new();
117 let header_value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
127 - .map_err(|e| BenchError::Config(format!("Invalid HF_TOKEN: {e}")))?;
118 + .map_err(|e| BenchError::Config(format!("Invalid HF token: {e}")))?;
119 headers.insert(reqwest::header::AUTHORIZATION, header_value);
120 client_builder = client_builder.default_headers(headers);
121 }
@@ -137,12 +128,24 @@ pub async fn download_hf_dataset(
128 let (resolved_config, resolved_split) = if let (Some(cfg), Some(spl)) = (subset, split) {
129 // Both provided — skip /info call
130 (cfg.to_string(), spl.to_string())
140 - } else {
141 - // Call /info to discover available configs and splits
131 + } else if let Some(info) = {
132 + // Call /info to discover available configs and splits. Private datasets are not
133 + // processed by the dataset viewer; fall back to datasets-library defaults.
134 let info_url =
135 format!("https://datasets-server.huggingface.co/info?dataset={encoded_dataset}");
144 - let info = get_with_retry(&client, &info_url, "HF dataset /info").await?;
145 -
136 + match get_with_retry(&client, &info_url, "HF dataset /info").await {
137 + Ok(info) => Some(info),
138 + Err(e) => {
139 + tracing::warn!(
140 + dataset,
141 + error = %e,
142 + "datasets-server /info unavailable (private dataset?); \
143 + assuming config 'default' and split 'train'"
144 + );
145 + None
146 + }
147 + }
148 + } {
149 let dataset_info =
150 info.get("dataset_info").and_then(|d| d.as_object()).ok_or_else(|| {
151 BenchError::Config(format!(
@@ -202,6 +205,11 @@ pub async fn download_hf_dataset(
205 };
206
207 (resolved_config, resolved_split)
208 + } else {
209 + (
210 + subset.unwrap_or("default").to_string(),
211 + split.unwrap_or("train").to_string(),
212 + )
213 };
214
215 tracing::info!(
@@ -211,96 +219,299 @@ pub async fn download_hf_dataset(
219 "resolved Hugging Face dataset"
220 );
221
214 - // Check cache
215 - let dir = cache_dir();
216 - std::fs::create_dir_all(&dir)?;
217 - let cache_path = dir.join(format!(
218 - "hf-{}-{}-{}.json",
219 - sanitize_name(dataset),
220 - sanitize_name(&resolved_config),
221 - sanitize_name(&resolved_split)
222 - ));
222 + let (revision, mut shard_paths) =
223 + match convert_parquet_shards(&client, dataset, &resolved_config, &resolved_split).await {
224 + Ok(paths) => (PARQUET_REVISION, paths),
225 + Err(e) => {
226 + tracing::info!(
227 + dataset,
228 + error = %e,
229 + "no dataset-viewer parquet export; trying native parquet files on main"
230 + );
231 + (
232 + "main",
233 + native_data_files(dataset, &resolved_config, &resolved_split).await?,
234 + )
235 + }
236 + };
237
224 - if cache_path.exists() {
225 - let path_str = cache_path.to_string_lossy().to_string();
226 - tracing::info!(dataset, path = %path_str, "using cached Hugging Face dataset");
227 - return Ok((path_str, resolved_config, resolved_split));
238 + // Sort for determinism, then shuffle shard order so samples are not always drawn
239 + // from the start of the split (mirrors the datasets library's streaming shuffle).
240 + shard_paths.sort();
241 + if !disable_shuffle {
242 + shard_paths.shuffle(&mut StdRng::seed_from_u64(seed));
243 }
244
230 - tracing::info!(
231 - dataset,
232 - config = resolved_config,
233 - split = resolved_split,
234 - "downloading Hugging Face dataset"
235 - );
245 + // Read up to 2x the requested rows so validity filtering during sampling backfills
246 + // from real rows instead of duplicating samples.
247 + let target_rows = num_rows_needed.saturating_mul(2);
248
237 - let encoded_config: String =
238 - url::form_urlencoded::byte_serialize(resolved_config.as_bytes()).collect();
239 - let encoded_split: String =
240 - url::form_urlencoded::byte_serialize(resolved_split.as_bytes()).collect();
241 -
242 - let mut all_rows: Vec<serde_json::Value> = Vec::new();
243 - let mut offset = 0usize;
244 - let page_size = 100usize;
245 - let mut progress = RowDownloadReporter::new();
246 -
247 - loop {
248 - let url = format!(
249 - "https://datasets-server.huggingface.co/rows\
250 - ?dataset={encoded_dataset}\
251 - &config={encoded_config}\
252 - &split={encoded_split}\
253 - &offset={offset}\
254 - &length={page_size}"
255 - );
249 + let repo =
250 + crate::hub::HubRepo::dataset_with_revision(dataset.to_string(), revision.to_string())
251 + .map_err(BenchError::Config)?;
252 + let rows = read_shards(&repo, dataset, shard_paths, target_rows).await?;
253 + Ok((rows, resolved_config, resolved_split))
254 +}
255
257 - let data = get_with_retry(&client, &url, "HF dataset /rows").await?;
256 +/// Hub revision holding the dataset-viewer's auto-converted parquet export.
257 +const PARQUET_REVISION: &str = "refs/convert/parquet";
258
259 - let rows = data["rows"]
260 - .as_array()
261 - .ok_or_else(|| BenchError::Config("No 'rows' in API response".into()))?;
259 +/// Extract the shard's repo path within `refs/convert/parquet` from its resolve URL.
260 +fn shard_repo_path(url: &str) -> Option<String> {
261 + url.split_once("refs%2Fconvert%2Fparquet/")
262 + .or_else(|| url.split_once("refs/convert/parquet/"))
263 + .map(|(_, path)| path.to_string())
264 +}
265
263 - if rows.is_empty() {
264 - break;
266 +/// List the dataset-viewer's auto-converted parquet shards for a config/split.
267 +async fn convert_parquet_shards(
268 + client: &reqwest::Client,
269 + dataset: &str,
270 + config: &str,
271 + split: &str,
272 +) -> Result<Vec<String>> {
273 + let encoded_dataset: String =
274 + url::form_urlencoded::byte_serialize(dataset.as_bytes()).collect();
275 + let url = format!("https://datasets-server.huggingface.co/parquet?dataset={encoded_dataset}");
276 + let listing = get_with_retry(client, &url, "HF dataset /parquet").await?;
277 +
278 + // Partially converted datasets (>5GB) export under a "partial-" split prefix.
279 + let partial_split = format!("partial-{split}");
280 + let shard_paths: Vec<String> = listing["parquet_files"]
281 + .as_array()
282 + .map(|files| {
283 + files
284 + .iter()
285 + .filter(|f| {
286 + f["config"].as_str() == Some(config)
287 + && matches!(f["split"].as_str(), Some(s) if s == split || s == partial_split)
288 + })
289 + .filter_map(|f| shard_repo_path(f["url"].as_str()?))
290 + .collect()
291 + })
292 + .unwrap_or_default();
293 +
294 + if shard_paths.is_empty() {
295 + return Err(BenchError::Config(format!(
296 + "no parquet export for config '{config}' split '{split}'"
297 + )));
298 + }
299 + Ok(shard_paths)
300 +}
301 +
302 +/// File extensions the native main-branch fallback can decode.
303 +const NATIVE_DATA_EXTENSIONS: &[&str] = &[".parquet", ".json", ".jsonl", ".ndjson"];
304 +
305 +/// Discover native data files on the main branch matching a config/split, following
306 +/// the datasets library's default layout conventions (config directories, split-named
307 +/// files, unlabeled data files = train). Used when the dataset viewer has no parquet
308 +/// export, e.g. for private datasets.
309 +async fn native_data_files(dataset: &str, config: &str, split: &str) -> Result<Vec<String>> {
310 + let repo = crate::hub::HubRepo::dataset(dataset.to_string()).map_err(BenchError::Config)?;
311 + let files = repo.list_files().await.map_err(|e| {
312 + BenchError::Config(format!(
313 + "failed to list files of HF dataset '{dataset}': {e}"
314 + ))
315 + })?;
316 +
317 + let matched = select_native_files(files, config, split)?;
318 + if matched.is_empty() {
319 + return Err(BenchError::Config(format!(
320 + "HF dataset '{dataset}' has no usable data for config '{config}' split \
321 + '{split}': no dataset-viewer parquet export and no native data files matching \
322 + the split on the main branch. Check the dataset page or the \
323 + --hf-subset/--hf-split values; private datasets must store parquet, json, or \
324 + jsonl files."
325 + )));
326 + }
327 + Ok(matched)
328 +}
329 +
330 +/// Pure selection logic for [`native_data_files`].
331 +///
332 +/// Mirrors the datasets library's defaults: files may be scoped under a config
333 +/// directory; split-named files are preferred; a repo whose data files carry no split
334 +/// names at all maps everything to the "train" split.
335 +///
336 +/// Errors when the config matches no directory but the selection still spans several
337 +/// directories: the config never got resolved (e.g. a private dataset whose `/info`
338 +/// lookup failed falls back to the literal "default"), and pooling those directories
339 +/// would silently mix subsets.
340 +fn select_native_files(files: Vec<String>, config: &str, split: &str) -> Result<Vec<String>> {
341 + let data: Vec<String> = files
342 + .into_iter()
343 + .filter(|f| NATIVE_DATA_EXTENSIONS.iter().any(|ext| f.ends_with(ext)))
344 + .collect();
345 +
346 + // Multi-config layout scopes files under a config directory (e.g. "main/test-*.parquet")
347 + let config_prefix = format!("{config}/");
348 + let config_scoped = data.iter().any(|f| f.starts_with(&config_prefix));
349 + let scoped: Vec<String> = if config_scoped {
350 + data.into_iter().filter(|f| f.starts_with(&config_prefix)).collect()
351 + } else {
352 + data
353 + };
354 +
355 + let matched: Vec<String> =
356 + scoped.iter().filter(|f| path_matches_split(f, split)).cloned().collect();
357 +
358 + let selected = if !matched.is_empty() {
359 + matched
360 + } else {
361 + // No file carries the requested split name. If no file carries any split name,
362 + // the datasets library maps them all to "train".
363 + let split_keywords = ["train", "test", "validation", "valid", "dev"];
364 + let any_labeled =
365 + scoped.iter().any(|f| split_keywords.iter().any(|s| path_matches_split(f, s)));
366 + if split == "train" && !any_labeled {
367 + scoped
368 + } else {
369 + Vec::new()
370 }
371 + };
372
267 - for row in rows {
268 - if let Some(row_data) = row.get("row") {
269 - all_rows.push(row_data.clone());
270 - }
373 + if !config_scoped {
374 + let dirs = top_level_dirs(&selected);
375 + if dirs.len() > 1 {
376 + return Err(BenchError::Config(format!(
377 + "config '{config}' matches no directory in this dataset, and split '{split}' \
378 + data spans multiple config directories {dirs:?}. Pass --hf-subset to pick one."
379 + )));
380 }
381 + }
382 + Ok(selected)
383 +}
384
273 - let fetched = rows.len();
274 - offset += fetched;
385 +/// Distinct leading path segments of files that live in a directory.
386 +fn top_level_dirs(files: &[String]) -> Vec<&str> {
387 + let mut dirs: Vec<&str> =
388 + files.iter().filter_map(|f| f.split_once('/').map(|(d, _)| d)).collect();
389 + dirs.sort_unstable();
390 + dirs.dedup();
391 + dirs
392 +}
393
276 - let total = data["num_rows_total"].as_u64().unwrap_or(0);
277 - progress.update(offset, total);
394 +/// Split match on a repo file path: a split-named file ("{split}-00000-of-00001.parquet",
395 +/// "{split}.jsonl", "{split}_0.parquet") or a "{split}/" directory segment.
396 +fn path_matches_split(path: &str, split: &str) -> bool {
397 + let base = path.rsplit('/').next().unwrap_or(path);
398 + base.strip_prefix(split)
399 + .is_some_and(|rest| rest.starts_with('.') || rest.starts_with('-') || rest.starts_with('_'))
400 + || path.starts_with(&format!("{split}/"))
401 + || path.contains(&format!("/{split}/"))
402 +}
403
279 - // Stop if we have enough rows or reached end of dataset
280 - if all_rows.len() >= num_rows_needed || fetched < page_size {
404 +/// Fetch shards via hf-hub (cached under `$HF_HOME/hub`, shared with other Hub tooling)
405 +/// and decode rows in order until `target_rows` are collected or all shards are read.
406 +async fn read_shards(
407 + repo: &crate::hub::HubRepo,
408 + dataset: &str,
409 + shard_paths: Vec<String>,
410 + target_rows: usize,
411 +) -> Result<Vec<serde_json::Value>> {
412 + let total_shards = shard_paths.len();
413 + let mut rows: Vec<serde_json::Value> = Vec::new();
414 + for (i, shard_path) in shard_paths.into_iter().enumerate() {
415 + if rows.len() >= target_rows {
416 break;
417 }
418 + tracing::info!(
419 + dataset,
420 + shard = i + 1,
421 + total_shards,
422 + path = %shard_path,
423 + "fetching dataset shard"
424 + );
425 + let local_path = repo.get(&shard_path).await.map_err(BenchError::Config)?;
426 + let limit = target_rows - rows.len();
427 + let is_parquet = shard_path.ends_with(".parquet");
428 + let shard_rows = tokio::task::spawn_blocking(move || {
429 + if is_parquet {
430 + read_parquet_rows(&local_path, limit)
431 + } else {
432 + read_json_rows(&local_path, limit)
433 + }
434 + })
435 + .await
436 + .map_err(|e| BenchError::Config(format!("shard read task failed: {e}")))??;
437 + rows.extend(shard_rows);
438 }
284 - progress.finish();
439
286 - if all_rows.is_empty() {
440 + if rows.is_empty() {
441 return Err(BenchError::Config(format!(
288 - "HF dataset '{dataset}' download returned no rows"
442 + "HF dataset '{dataset}' data files contained no rows"
443 )));
444 }
291 -
292 - // Save to cache
293 - let json_str = serde_json::to_string(&all_rows)?;
294 - std::fs::write(&cache_path, &json_str)?;
295 -
296 - let path_str = cache_path.to_string_lossy().to_string();
445 tracing::info!(
446 dataset,
299 - rows = all_rows.len(),
300 - path = %path_str,
301 - "saved Hugging Face dataset"
447 + rows = rows.len(),
448 + "loaded HF dataset from data files"
449 );
303 - Ok((path_str, resolved_config, resolved_split))
450 + Ok(rows)
451 +}
452 +
453 +/// Read up to `limit` rows from a parquet file as JSON objects.
454 +fn read_parquet_rows(path: &Path, limit: usize) -> Result<Vec<serde_json::Value>> {
455 + let file = std::fs::File::open(path)?;
456 + let reader = SerializedFileReader::new(file)
457 + .map_err(|e| BenchError::Config(format!("failed to open parquet shard: {e}")))?;
458 + let iter = reader
459 + .get_row_iter(None)
460 + .map_err(|e| BenchError::Config(format!("failed to read parquet shard: {e}")))?;
461 + let mut rows = Vec::new();
462 + for row in iter {
463 + if rows.len() >= limit {
464 + break;
465 + }
466 + let row =
467 + row.map_err(|e| BenchError::Config(format!("failed to decode parquet row: {e}")))?;
468 + rows.push(row.to_json_value());
469 + }
470 + Ok(rows)
471 +}
472 +
473 +/// Read up to `limit` rows from a native JSON data file: a JSON array of objects, an
474 +/// object wrapping a single array field, or JSON Lines.
475 +fn read_json_rows(path: &Path, limit: usize) -> Result<Vec<serde_json::Value>> {
476 + let content = std::fs::read_to_string(path)?;
477 +
478 + let doc: Option<serde_json::Value> = serde_json::from_str(&content).ok();
479 + let mut rows = match doc {
480 + Some(serde_json::Value::Array(rows)) => rows,
481 + // e.g. {"data": [...]} — a single array-of-objects field wrapping the rows
482 + Some(serde_json::Value::Object(mut obj)) => {
483 + let mut arrays: Vec<serde_json::Value> = obj
484 + .iter_mut()
485 + .filter(|(_, v)| {
486 + v.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|e| e.is_object()))
487 + })
488 + .map(|(_, v)| v.take())
489 + .collect();
490 + match (arrays.len(), arrays.pop()) {
491 + (1, Some(serde_json::Value::Array(rows))) => rows,
492 + _ => {
493 + return Err(BenchError::Config(
494 + "unsupported JSON data file layout: expected an array of rows, \
495 + an object with a single array-of-objects field, or JSON Lines"
496 + .into(),
497 + ));
498 + }
499 + }
500 + }
501 + // Not a single JSON document — try JSON Lines
502 + _ => content
503 + .lines()
504 + .filter(|l| !l.trim().is_empty())
505 + .take(limit)
506 + .map(|l| {
507 + serde_json::from_str(l)
508 + .map_err(|e| BenchError::Config(format!("invalid JSON line in data file: {e}")))
509 + })
510 + .collect::<Result<Vec<serde_json::Value>>>()?,
511 + };
512 +
513 + rows.truncate(limit);
514 + Ok(rows)
515 }
516
517 /// Check if a JSON value looks like a chat message (has role+content or from+value).
@@ -472,10 +683,10 @@ fn extract_chat_completion(messages: &[serde_json::Value]) -> Option<String> {
683 None
684 }
685
475 -/// Load an HF dataset from the cached JSON file and convert to SampleRequests.
686 +/// Convert downloaded HF dataset rows to SampleRequests.
687 pub fn load_hf_dataset(
688 tokenizer: &TokenizerKind,
478 - dataset_path: &str,
689 + entries: &[serde_json::Value],
690 num_requests: usize,
691 hf_output_len: Option<usize>,
692 seed: u64,
@@ -484,19 +695,8 @@ pub fn load_hf_dataset(
695 no_oversample: bool,
696 disable_shuffle: bool,
697 ) -> Result<Vec<SampleRequest>> {
487 - let content = std::fs::read_to_string(dataset_path).map_err(|e| {
488 - BenchError::Config(format!(
489 - "Failed to read HF dataset file '{dataset_path}': {e}"
490 - ))
491 - })?;
492 -
493 - let entries: Vec<serde_json::Value> = serde_json::from_str(&content)
494 - .map_err(|e| BenchError::Config(format!("Invalid JSON in HF dataset file: {e}")))?;
495 -
698 if entries.is_empty() {
497 - return Err(BenchError::Config(
498 - "HF dataset file contains no rows".into(),
499 - ));
699 + return Err(BenchError::Config("HF dataset contains no rows".into()));
700 }
701
702 // Detect column format from first row
@@ -638,7 +838,6 @@ pub fn load_hf_dataset(
838 } else {
839 if !warned_no_output {
840 tracing::warn!(
641 - path = dataset_path,
841 default_output_tokens = 128,
842 "no dataset output column or --hf-output-len; using default output length"
843 );
@@ -649,7 +848,6 @@ pub fn load_hf_dataset(
848 } else {
849 if !warned_no_output {
850 tracing::warn!(
652 - path = dataset_path,
851 default_output_tokens = 128,
852 "no dataset output column or --hf-output-len; using default output length"
853 );
@@ -965,16 +1163,6 @@ mod tests {
1163 assert!(!is_chat_array(&serde_json::json!("not an array")));
1164 }
1165
968 - #[test]
969 - fn test_sanitize_name() {
970 - assert_eq!(
971 - sanitize_name("allenai/WildChat-4.8M"),
972 - "allenai_WildChat-4.8M"
973 - );
974 - assert_eq!(sanitize_name("simple-name"), "simple-name");
975 - assert_eq!(sanitize_name("org/sub/deep"), "org_sub_deep");
976 - }
977 -
1166 // --- extract_chat_prompt edge cases ---
1167
1168 #[test]
@@ -1043,13 +1231,6 @@ mod tests {
1231 )
1232 }
1233
1046 - /// Write JSON data to a unique temp file and return the path string.
1047 - fn write_temp_json(name: &str, data: &serde_json::Value) -> String {
1048 - let path = std::env::temp_dir().join(format!("vllm-bench-test-{name}.json"));
1049 - std::fs::write(&path, serde_json::to_string(data).unwrap()).unwrap();
1050 - path.to_string_lossy().to_string()
1051 - }
1052 -
1234 #[test]
1235 fn test_load_hf_dataset_chat_format() {
1236 let tok = builtin_tokenizer();
@@ -1067,10 +1248,19 @@ mod tests {
1248 ]
1249 }
1250 ]);
1070 - let path = write_temp_json("chat-format", &data);
1251
1072 - let result =
1073 - load_hf_dataset(&tok, &path, 2, Some(50), 42, "test-", None, false, false).unwrap();
1252 + let result = load_hf_dataset(
1253 + &tok,
1254 + data.as_array().unwrap(),
1255 + 2,
1256 + Some(50),
1257 + 42,
1258 + "test-",
1259 + None,
1260 + false,
1261 + false,
1262 + )
1263 + .unwrap();
1264
1265 assert_eq!(result.len(), 2);
1266 assert!(
@@ -1087,9 +1277,19 @@ mod tests {
1277 {"prompt": "Explain the difference between machine learning and deep learning in simple terms.", "completion": "Machine learning is a subset of AI."},
1278 {"prompt": "How does photosynthesis work in plants and what role does chlorophyll play?", "completion": "Photosynthesis converts light to energy."}
1279 ]);
1090 - let path = write_temp_json("plain-text-format", &data);
1280
1092 - let result = load_hf_dataset(&tok, &path, 3, None, 0, "req-", None, false, false).unwrap();
1281 + let result = load_hf_dataset(
1282 + &tok,
1283 + data.as_array().unwrap(),
1284 + 3,
1285 + None,
1286 + 0,
1287 + "req-",
1288 + None,
1289 + false,
1290 + false,
1291 + )
1292 + .unwrap();
1293
1294 assert_eq!(result.len(), 3);
1295 // Without hf_output_len override, output len is derived from the completion tokens
@@ -1111,10 +1311,19 @@ mod tests {
1311 "answers": ["Albert Einstein"]
1312 }
1313 ]);
1114 - let path = write_temp_json("combined-format", &data);
1314
1116 - let result =
1117 - load_hf_dataset(&tok, &path, 2, Some(64), 1, "comb-", None, false, false).unwrap();
1315 + let result = load_hf_dataset(
1316 + &tok,
1317 + data.as_array().unwrap(),
1318 + 2,
1319 + Some(64),
1320 + 1,
1321 + "comb-",
1322 + None,
1323 + false,
1324 + false,
1325 + )
1326 + .unwrap();
1327
1328 assert_eq!(result.len(), 2);
1329 assert!(result.iter().all(|r| r.expected_output_len == 64));
@@ -1126,9 +1335,18 @@ mod tests {
1335 fn test_load_hf_dataset_empty_returns_error() {
1336 let tok = builtin_tokenizer();
1337 let data = serde_json::json!([]);
1129 - let path = write_temp_json("empty-dataset", &data);
1338
1131 - let result = load_hf_dataset(&tok, &path, 5, None, 0, "test-", None, false, false);
1339 + let result = load_hf_dataset(
1340 + &tok,
1341 + data.as_array().unwrap(),
1342 + 5,
1343 + None,
1344 + 0,
1345 + "test-",
1346 + None,
1347 + false,
1348 + false,
1349 + );
1350
1351 assert!(result.is_err(), "empty dataset should return an error");
1352 let err = result.unwrap_err().to_string();
@@ -1147,9 +1365,18 @@ mod tests {
1365 {"prompt": "Yes", "completion": "No"},
1366 {"prompt": "Ok", "completion": "Fine"}
1367 ]);
1150 - let path = write_temp_json("all-filtered", &data);
1368
1152 - let result = load_hf_dataset(&tok, &path, 3, None, 0, "test-", None, false, false);
1369 + let result = load_hf_dataset(
1370 + &tok,
1371 + data.as_array().unwrap(),
1372 + 3,
1373 + None,
1374 + 0,
1375 + "test-",
1376 + None,
1377 + false,
1378 + false,
1379 + );
1380
1381 assert!(
1382 result.is_err(),
@@ -1165,12 +1392,11 @@ mod tests {
1392 {"prompt": "What are the key differences between supervised and unsupervised machine learning?", "completion": "Supervised learning uses labeled data while unsupervised does not."},
1393 {"prompt": "Explain how neural networks are inspired by the human brain structure and function.", "completion": "Neural networks mimic brain neurons with layers of nodes."}
1394 ]);
1168 - let path = write_temp_json("output-len-override", &data);
1395
1396 let fixed_len = 77usize;
1397 let result = load_hf_dataset(
1398 &tok,
1173 - &path,
1399 + data.as_array().unwrap(),
1400 3,
1401 Some(fixed_len),
1402 42,
@@ -1196,11 +1422,10 @@ mod tests {
1422 {"prompt": "Explain how photosynthesis converts sunlight to energy in plant cells.", "completion": "Plants use chlorophyll to absorb sunlight."},
1423 {"prompt": "What is the difference between a virus and a bacterium in terms of biology?", "completion": "Viruses need host cells while bacteria are self-sufficient."}
1424 ]);
1199 - let path = write_temp_json("no-oversample", &data);
1425
1426 let result = load_hf_dataset(
1427 &tok,
1203 - &path,
1428 + data.as_array().unwrap(),
1429 10,
1430 Some(32),
1431 0,
@@ -1227,11 +1452,10 @@ mod tests {
1452 {"prompt": "Beta prompt about the second topic continuing from our ordered sequence here.", "completion": "Beta answer"},
1453 {"prompt": "Gamma prompt about the third item in our clearly ordered alphabetical sequence.", "completion": "Gamma answer"}
1454 ]);
1230 - let path = write_temp_json("disable-shuffle", &data);
1455
1456 let result = load_hf_dataset(
1457 &tok,
1234 - &path,
1458 + data.as_array().unwrap(),
1459 3,
1460 Some(20),
1461 99,
@@ -1264,13 +1488,188 @@ mod tests {
1488 {"prompt": "What is the boiling point of water at sea level under standard atmospheric pressure?", "completion": "Water boils at 100 degrees Celsius."},
1489 {"prompt": "Describe the structure of DNA and how genetic information is encoded within it.", "completion": "DNA is a double helix with base pairs."}
1490 ]);
1267 - let path = write_temp_json("request-id-prefix", &data);
1491
1269 - let result =
1270 - load_hf_dataset(&tok, &path, 2, Some(10), 0, "myprefix-", None, false, false).unwrap();
1492 + let result = load_hf_dataset(
1493 + &tok,
1494 + data.as_array().unwrap(),
1495 + 2,
1496 + Some(10),
1497 + 0,
1498 + "myprefix-",
1499 + None,
1500 + false,
1501 + false,
1502 + )
1503 + .unwrap();
1504
1505 assert_eq!(result.len(), 2);
1506 assert_eq!(result[0].request_id.as_deref(), Some("myprefix-0"));
1507 assert_eq!(result[1].request_id.as_deref(), Some("myprefix-1"));
1508 }
1509 +
1510 + // Network test: the full GSM8k test split must arrive via parquet shards regardless
1511 + // of how many rows earlier runs requested (run with --ignored).
1512 + #[tokio::test]
1513 + #[ignore]
1514 + async fn test_download_gsm8k_full_split_via_parquet() {
1515 + let (rows, config, split) =
1516 + download_hf_dataset("openai/gsm8k", None, Some("test"), 1319, 0, false)
1517 + .await
1518 + .unwrap();
1519 + assert_eq!(config, "main");
1520 + assert_eq!(split, "test");
1521 + assert_eq!(rows.len(), 1319);
1522 + assert!(rows[0].get("question").is_some_and(|q| q.is_string()));
1523 + assert!(rows[0].get("answer").is_some_and(|a| a.is_string()));
1524 + }
1525 +
1526 + // Network test: native parquet discovery on the main branch (private-dataset path)
1527 + // must find GSM8k's config-dir layout (run with --ignored).
1528 + #[tokio::test]
1529 + #[ignore]
1530 + async fn test_native_data_files_main_branch() {
1531 + let shards = native_data_files("openai/gsm8k", "main", "test").await.unwrap();
1532 + assert_eq!(shards, vec!["main/test-00000-of-00001.parquet".to_string()]);
1533 +
1534 + // Single unlabeled JSON file maps to the train split (private-dataset layout)
1535 + let shards = native_data_files("Inferact/codex_swebenchpro_traces", "default", "train")
1536 + .await
1537 + .unwrap();
1538 + assert_eq!(shards, vec!["codex_swebenchpro.json".to_string()]);
1539 + }
1540 +
1541 + // --- native data file selection ---
1542 +
1543 + #[test]
1544 + fn test_path_matches_split() {
1545 + assert!(path_matches_split(
1546 + "data/train-00000-of-00042.parquet",
1547 + "train"
1548 + ));
1549 + assert!(path_matches_split(
1550 + "main/test-00000-of-00001.parquet",
1551 + "test"
1552 + ));
1553 + assert!(path_matches_split("test.parquet", "test"));
1554 + assert!(path_matches_split("test.jsonl", "test"));
1555 + assert!(path_matches_split("test_0.parquet", "test"));
1556 + assert!(path_matches_split("data/test/0000.parquet", "test"));
1557 + assert!(path_matches_split("test/0000.parquet", "test"));
1558 +
1559 + assert!(!path_matches_split(
1560 + "data/train-00000-of-00042.parquet",
1561 + "test"
1562 + ));
1563 + assert!(!path_matches_split("testing-00000.parquet", "test"));
1564 + assert!(!path_matches_split("contest/0000.parquet", "test"));
1565 + assert!(!path_matches_split("data/latest/0000.parquet", "test"));
1566 + }
1567 +
1568 + #[test]
1569 + fn test_select_native_files_split_labeled() {
1570 + let files = vec![
1571 + ".gitattributes".to_string(),
1572 + "README.md".to_string(),
1573 + "data/train-00000-of-00002.parquet".to_string(),
1574 + "data/train-00001-of-00002.parquet".to_string(),
1575 + "data/test-00000-of-00001.parquet".to_string(),
1576 + ];
1577 + let selected = select_native_files(files, "default", "test").unwrap();
1578 + assert_eq!(
1579 + selected,
1580 + vec!["data/test-00000-of-00001.parquet".to_string()]
1581 + );
1582 + }
1583 +
1584 + #[test]
1585 + fn test_select_native_files_unlabeled_maps_to_train() {
1586 + let files = vec![
1587 + "README.md".to_string(),
1588 + "codex_swebenchpro.json".to_string(),
1589 + ];
1590 + let selected = select_native_files(files.clone(), "default", "train").unwrap();
1591 + assert_eq!(selected, vec!["codex_swebenchpro.json".to_string()]);
1592 + // ...but only for the train split
1593 + assert!(select_native_files(files, "default", "test").unwrap().is_empty());
1594 + }
1595 +
1596 + #[test]
1597 + fn test_select_native_files_labeled_files_do_not_leak_to_train() {
1598 + // A repo with only test-labeled files has no train split
1599 + let files = vec!["data/test-00000-of-00001.parquet".to_string()];
1600 + assert!(select_native_files(files, "default", "train").unwrap().is_empty());
1601 + }
1602 +
1603 + #[test]
1604 + fn test_select_native_files_config_dir_scoping() {
1605 + let files = vec![
1606 + "main/test-00000-of-00001.parquet".to_string(),
1607 + "socratic/test-00000-of-00001.parquet".to_string(),
1608 + ];
1609 + let selected = select_native_files(files, "socratic", "test").unwrap();
1610 + assert_eq!(
1611 + selected,
1612 + vec!["socratic/test-00000-of-00001.parquet".to_string()]
1613 + );
1614 + }
1615 +
1616 + #[test]
1617 + fn test_select_native_files_unresolved_config_does_not_pool_configs() {
1618 + // A private dataset whose /info lookup failed falls back to config "default";
1619 + // pooling both config directories would silently mix subsets.
1620 + let files = vec![
1621 + "main/test-00000-of-00001.parquet".to_string(),
1622 + "socratic/test-00000-of-00001.parquet".to_string(),
1623 + ];
1624 + let err = select_native_files(files, "default", "test").unwrap_err().to_string();
1625 + assert!(err.contains("--hf-subset"), "{err}");
1626 + }
1627 +
1628 + #[test]
1629 + fn test_select_native_files_split_dirs_are_not_ambiguous() {
1630 + // Split directories are not config directories
1631 + let files = vec![
1632 + "train/0000.parquet".to_string(),
1633 + "test/0000.parquet".to_string(),
1634 + ];
1635 + let selected = select_native_files(files, "default", "test").unwrap();
1636 + assert_eq!(selected, vec!["test/0000.parquet".to_string()]);
1637 + }
1638 +
1639 + // --- native JSON data file reading ---
1640 +
1641 + fn write_temp_file(name: &str, content: &str) -> std::path::PathBuf {
1642 + let path = std::env::temp_dir().join(format!("vllm-bench-test-json-{name}"));
1643 + std::fs::write(&path, content).unwrap();
1644 + path
1645 + }
1646 +
1647 + #[test]
1648 + fn test_read_json_rows_array() {
1649 + let path = write_temp_file("array.json", r#"[{"a": 1}, {"a": 2}, {"a": 3}]"#);
1650 + let rows = read_json_rows(&path, 2).unwrap();
1651 + assert_eq!(rows.len(), 2);
1652 + assert_eq!(rows[0]["a"], 1);
1653 + }
1654 +
1655 + #[test]
1656 + fn test_read_json_rows_wrapped_object() {
1657 + let path = write_temp_file("wrapped.json", r#"{"data": [{"a": 1}, {"a": 2}]}"#);
1658 + let rows = read_json_rows(&path, 10).unwrap();
1659 + assert_eq!(rows.len(), 2);
1660 + }
1661 +
1662 + #[test]
1663 + fn test_read_json_rows_jsonl() {
1664 + let path = write_temp_file("rows.jsonl", "{\"a\": 1}\n{\"a\": 2}\n\n{\"a\": 3}\n");
1665 + let rows = read_json_rows(&path, 10).unwrap();
1666 + assert_eq!(rows.len(), 3);
1667 + assert_eq!(rows[2]["a"], 3);
1668 + }
1669 +
1670 + #[test]
1671 + fn test_read_json_rows_unsupported_layout() {
1672 + let path = write_temp_file("scalar.json", r#"{"a": 1, "b": 2}"#);
1673 + assert!(read_json_rows(&path, 10).is_err());
1674 + }
1675 }
rust/src/bench/src/hub.rs
+11 -1
@@ -3,8 +3,8 @@
3
4 use std::path::PathBuf;
5
6 -use hf_hub::Repo;
6 use hf_hub::api::tokio::{ApiBuilder, ApiRepo};
7 +use hf_hub::{Repo, RepoType};
8
9 /// A handle to a HuggingFace Hub repo, downloading via hf-hub's on-disk cache.
10 pub struct HubRepo {
@@ -20,6 +20,10 @@ impl HubRepo {
20 Self::new(Repo::dataset(repo_id))
21 }
22
23 + pub fn dataset_with_revision(repo_id: String, revision: String) -> Result<Self, String> {
24 + Self::new(Repo::with_revision(repo_id, RepoType::Dataset, revision))
25 + }
26 +
27 fn new(repo: Repo) -> Result<Self, String> {
28 let mut builder = ApiBuilder::from_env();
29 if let Ok(token) = std::env::var("HF_TOKEN") {
@@ -36,4 +40,10 @@ impl HubRepo {
40 pub async fn get(&self, filename: &str) -> Result<PathBuf, String> {
41 self.repo.get(filename).await.map_err(|e| format!("{e}"))
42 }
43 +
44 + /// List all file paths in the repo at its revision.
45 + pub async fn list_files(&self) -> Result<Vec<String>, String> {
46 + let info = self.repo.info().await.map_err(|e| format!("{e}"))?;
47 + Ok(info.siblings.into_iter().map(|s| s.rfilename).collect())
48 + }
49 }