[Rust][Benchmark] Use async HTTP clients (#49295)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jul 21, 2026 at 16:53 UTC
eb44b3aaa40b134ffb9030c19671964c8d0602e3
15 files changed
+200
-122
rust/Cargo.lock
-1
@@ -3446,7 +3446,6 @@ dependencies = [
3446
"base64 0.22.1",
3447
"bytes",
3448
"encoding_rs",
3449
- "futures-channel",
3449
"futures-core",
3450
"futures-util",
3451
"h2",
rust/src/bench/Cargo.toml
+1
-1
@@ -20,7 +20,7 @@ mimalloc.workspace = true
20
rand.workspace = true
21
rand_distr.workspace = true
22
rayon.workspace = true
23
-reqwest = { workspace = true, features = ["json", "stream", "blocking", "http2"] }
23
+reqwest = { workspace = true, features = ["json", "stream", "http2"] }
24
rlimit.workspace = true
25
rustc-hash.workspace = true
26
serde = { workspace = true, features = ["rc"] }
rust/src/bench/src/benchmark.rs
+7
-4
@@ -365,7 +365,8 @@ pub async fn run_benchmark(config: &BenchConfig) -> Result<serde_json::Value> {
365
let tid = config.tokenizer_id.as_deref().unwrap_or(&model_id);
366
tracing::info!(tokenizer = tid, "loading tokenizer");
367
let server_info = Some((config.base_url.as_str(), model_id.as_str()));
368
- let t = crate::tokenizer::load_tokenizer(tid, config.trust_remote_code, server_info)?;
368
+ let t =
369
+ crate::tokenizer::load_tokenizer(tid, config.trust_remote_code, server_info).await?;
370
Some(t)
371
};
372
let has_tokenizer = tokenizer.is_some();
@@ -481,7 +482,7 @@ pub async fn run_benchmark(config: &BenchConfig) -> Result<serde_json::Value> {
482
let path = match config.dataset_path.as_deref() {
483
Some(p) => p,
484
None => {
484
- downloaded = crate::datasets::sharegpt::download_sharegpt_dataset()?;
485
+ downloaded = crate::datasets::sharegpt::download_sharegpt_dataset().await?;
486
downloaded.as_str()
487
}
488
};
@@ -521,7 +522,8 @@ pub async fn run_benchmark(config: &BenchConfig) -> Result<serde_json::Value> {
522
None => {
523
downloaded = crate::datasets::speed_bench::download_speed_bench(
524
config.speed_bench_config,
524
- )?;
525
+ )
526
+ .await?;
527
downloaded.as_str()
528
}
529
};
@@ -552,7 +554,8 @@ pub async fn run_benchmark(config: &BenchConfig) -> Result<serde_json::Value> {
554
config.hf_subset.as_deref(),
555
config.hf_split.as_deref(),
556
config.num_prompts,
555
- )?;
557
+ )
558
+ .await?;
559
crate::datasets::hf_dataset::load_hf_dataset(
560
tok,
561
&downloaded_path,
rust/src/bench/src/datasets/custom.rs
+4
-2
@@ -128,8 +128,10 @@ mod tests {
128
129
/// gpt2 via built-in tiktoken encoding — loads without network access.
130
fn test_tokenizer() -> TokenizerKind {
131
- crate::tokenizer::load_tokenizer("gpt2", false, None)
132
- .expect("gpt2 built-in tiktoken should always load without network")
131
+ TokenizerKind::Tiktoken(
132
+ crate::tiktoken::load_builtin_tiktoken("gpt2")
133
+ .expect("gpt2 built-in tiktoken should always load without network"),
134
+ )
135
}
136
137
#[test]
rust/src/bench/src/datasets/hf_dataset.rs
+15
-11
@@ -51,18 +51,19 @@ enum ColumnFormat {
51
52
/// Make a GET request with retry logic (3 retries with exponential backoff).
53
/// Returns the parsed JSON response.
54
-fn get_with_retry(
55
- client: &reqwest::blocking::Client,
54
+async fn get_with_retry(
55
+ client: &reqwest::Client,
56
url: &str,
57
label: &str,
58
) -> Result<serde_json::Value> {
59
let max_retries = 3;
60
for attempt in 0..=max_retries {
61
- let resp = match client.get(url).send() {
61
+ let resp = match client.get(url).send().await {
62
Ok(r) => r,
63
Err(e) => {
64
if attempt < max_retries {
65
- std::thread::sleep(std::time::Duration::from_secs(2 * (attempt as u64 + 1)));
65
+ tokio::time::sleep(std::time::Duration::from_secs(2 * (attempt as u64 + 1)))
66
+ .await;
67
continue;
68
}
69
return Err(BenchError::Config(format!(
@@ -81,7 +82,7 @@ fn get_with_retry(
82
}
83
84
if status.is_server_error() && attempt < max_retries {
84
- std::thread::sleep(std::time::Duration::from_secs(2 * (attempt as u64 + 1)));
85
+ tokio::time::sleep(std::time::Duration::from_secs(2 * (attempt as u64 + 1))).await;
86
continue;
87
}
88
@@ -93,6 +94,7 @@ fn get_with_retry(
94
95
let data: serde_json::Value = resp
96
.json()
97
+ .await
98
.map_err(|e| BenchError::Config(format!("Failed to parse {label} response: {e}")))?;
99
return Ok(data);
100
}
@@ -106,7 +108,7 @@ fn get_with_retry(
108
/// 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.
109
-pub fn download_hf_dataset(
111
+pub async fn download_hf_dataset(
112
dataset: &str,
113
subset: Option<&str>,
114
split: Option<&str>,
@@ -116,7 +118,7 @@ pub fn download_hf_dataset(
118
url::form_urlencoded::byte_serialize(dataset.as_bytes()).collect();
119
120
let mut client_builder =
119
- reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
121
+ reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
122
123
// Add HF_TOKEN auth header if available
124
if let Ok(token) = std::env::var("HF_TOKEN") {
@@ -139,7 +141,7 @@ pub fn download_hf_dataset(
141
// Call /info to discover available configs and splits
142
let info_url =
143
format!("https://datasets-server.huggingface.co/info?dataset={encoded_dataset}");
142
- let info = get_with_retry(&client, &info_url, "HF dataset /info")?;
144
+ let info = get_with_retry(&client, &info_url, "HF dataset /info").await?;
145
146
let dataset_info =
147
info.get("dataset_info").and_then(|d| d.as_object()).ok_or_else(|| {
@@ -252,7 +254,7 @@ pub fn download_hf_dataset(
254
&length={page_size}"
255
);
256
255
- let data = get_with_retry(&client, &url, "HF dataset /rows")?;
257
+ let data = get_with_retry(&client, &url, "HF dataset /rows").await?;
258
259
let rows = data["rows"]
260
.as_array()
@@ -1035,8 +1037,10 @@ mod tests {
1037
1038
/// Build a gpt2 tokenizer using built-in tiktoken encoding (no network required).
1039
fn builtin_tokenizer() -> crate::tokenizer::TokenizerKind {
1038
- crate::tokenizer::load_tokenizer("gpt2", false, None)
1039
- .expect("gpt2 built-in tiktoken should always load without network")
1040
+ crate::tokenizer::TokenizerKind::Tiktoken(
1041
+ crate::tiktoken::load_builtin_tiktoken("gpt2")
1042
+ .expect("gpt2 built-in tiktoken should always load without network"),
1043
+ )
1044
}
1045
1046
/// Write JSON data to a unique temp file and return the path string.
rust/src/bench/src/datasets/multi_turn.rs
+25
-15
@@ -526,10 +526,12 @@ mod tests {
526
len
527
}
528
529
- #[test]
529
+ #[tokio::test]
530
#[ignore]
531
- fn test_prefix_sharing_structure() {
532
- let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None).unwrap();
531
+ async fn test_prefix_sharing_structure() {
532
+ let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None)
533
+ .await
534
+ .unwrap();
535
536
let cfg = MultiTurnRandomConfig {
537
num_conversations: 5,
@@ -611,10 +613,12 @@ mod tests {
613
println!("All prefix sharing checks passed!");
614
}
615
614
- #[test]
616
+ #[tokio::test]
617
#[ignore]
616
- fn test_per_turn_input_len_default_mode() {
617
- let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None).unwrap();
618
+ async fn test_per_turn_input_len_default_mode() {
619
+ let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None)
620
+ .await
621
+ .unwrap();
622
623
let cfg = MultiTurnRandomConfig {
624
num_conversations: 4,
@@ -651,10 +655,12 @@ mod tests {
655
println!("per_turn_input_len default-mode checks passed!");
656
}
657
654
- #[test]
658
+ #[tokio::test]
659
#[ignore]
656
- fn test_variable_turns_range() {
657
- let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None).unwrap();
660
+ async fn test_variable_turns_range() {
661
+ let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None)
662
+ .await
663
+ .unwrap();
664
665
let cfg = MultiTurnRandomConfig {
666
num_conversations: 50,
@@ -685,10 +691,12 @@ mod tests {
691
println!("variable_turns_range checks passed! counts: {distinct_counts:?}");
692
}
693
688
- #[test]
694
+ #[tokio::test]
695
#[ignore]
690
- fn test_variable_turns_fixed() {
691
- let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None).unwrap();
696
+ async fn test_variable_turns_fixed() {
697
+ let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None)
698
+ .await
699
+ .unwrap();
700
701
let cfg = MultiTurnRandomConfig {
702
num_conversations: 10,
@@ -710,10 +718,12 @@ mod tests {
718
println!("variable_turns_fixed checks passed!");
719
}
720
713
- #[test]
721
+ #[tokio::test]
722
#[ignore]
715
- fn test_per_turn_input_len_prefix_sharing() {
716
- let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None).unwrap();
723
+ async fn test_per_turn_input_len_prefix_sharing() {
724
+ let tok = crate::tokenizer::load_tokenizer("nvidia/Kimi-K2.5-NVFP4", false, None)
725
+ .await
726
+ .unwrap();
727
728
// Turn 0 input_len=1000, turns 1+ per_turn_input_len=600
729
// global_len ≈ 100 (10%), conv_len ≈ 800 (80%), unique ≈ 100
rust/src/bench/src/datasets/prefix_repetition.rs
+4
-2
@@ -111,8 +111,10 @@ mod tests {
111
112
/// gpt2 via built-in tiktoken encoding — loads without network access.
113
fn test_tokenizer() -> TokenizerKind {
114
- crate::tokenizer::load_tokenizer("gpt2", false, None)
115
- .expect("gpt2 built-in tiktoken should always load without network")
114
+ TokenizerKind::Tiktoken(
115
+ crate::tiktoken::load_builtin_tiktoken("gpt2")
116
+ .expect("gpt2 built-in tiktoken should always load without network"),
117
+ )
118
}
119
120
#[test]
rust/src/bench/src/datasets/random.rs
+12
-9
@@ -308,7 +308,8 @@ mod tests {
308
#[test]
309
#[ignore]
310
fn test_generate_random_dataset_token_ids() {
311
- let tokenizer = tokenizer::load_tokenizer("gpt2", false, None).unwrap();
311
+ let tokenizer =
312
+ TokenizerKind::Tiktoken(crate::tiktoken::load_builtin_tiktoken("gpt2").unwrap());
313
let requests = generate_random_dataset(
314
&tokenizer,
315
10, // num_requests
@@ -340,7 +341,8 @@ mod tests {
341
#[test]
342
#[ignore]
343
fn test_generate_random_dataset_text() {
343
- let tokenizer = tokenizer::load_tokenizer("gpt2", false, None).unwrap();
344
+ let tokenizer =
345
+ TokenizerKind::Tiktoken(crate::tiktoken::load_builtin_tiktoken("gpt2").unwrap());
346
let requests = generate_random_dataset(
347
&tokenizer,
348
10, // num_requests
@@ -374,7 +376,8 @@ mod tests {
376
#[test]
377
#[ignore]
378
fn test_token_length_exact_local() {
377
- let tokenizer = tokenizer::load_tokenizer("gpt2", false, None).unwrap();
379
+ let tokenizer =
380
+ TokenizerKind::Tiktoken(crate::tiktoken::load_builtin_tiktoken("gpt2").unwrap());
381
let target_len = 512;
382
let requests = generate_random_dataset(
383
&tokenizer,
@@ -408,11 +411,11 @@ mod tests {
411
}
412
413
/// Test that tiktoken tokenizer produces exact target token lengths (token ID mode).
411
- #[test]
414
+ #[tokio::test]
415
#[ignore]
413
- fn test_token_length_exact_tiktoken() {
416
+ async fn test_token_length_exact_tiktoken() {
417
// Use Qwen2.5 which has a tiktoken-format tokenizer
415
- let tokenizer = tokenizer::load_tokenizer("Qwen/Qwen2.5-0.5B", false, None);
418
+ let tokenizer = tokenizer::load_tokenizer("Qwen/Qwen2.5-0.5B", false, None).await;
419
let tokenizer = match tokenizer {
420
Ok(t) => t,
421
Err(e) => {
@@ -456,10 +459,10 @@ mod tests {
459
460
/// Test encode/decode roundtrip stability for tiktoken.
461
/// After one decode→encode cycle with UTF-8-safe tokens, length must not drift.
459
- #[test]
462
+ #[tokio::test]
463
#[ignore]
461
- fn test_tiktoken_roundtrip_stability() {
462
- let tokenizer = tokenizer::load_tokenizer("Qwen/Qwen2.5-0.5B", false, None);
464
+ async fn test_tiktoken_roundtrip_stability() {
465
+ let tokenizer = tokenizer::load_tokenizer("Qwen/Qwen2.5-0.5B", false, None).await;
466
let tokenizer = match tokenizer {
467
Ok(t) => t,
468
Err(e) => {
rust/src/bench/src/datasets/random_rerank.rs
+4
-2
@@ -139,8 +139,10 @@ mod tests {
139
140
/// gpt2 via built-in tiktoken encoding — loads without network access.
141
fn test_tokenizer() -> TokenizerKind {
142
- crate::tokenizer::load_tokenizer("gpt2", false, None)
143
- .expect("gpt2 built-in tiktoken should always load without network")
142
+ TokenizerKind::Tiktoken(
143
+ crate::tiktoken::load_builtin_tiktoken("gpt2")
144
+ .expect("gpt2 built-in tiktoken should always load without network"),
145
+ )
146
}
147
148
fn fixed_ratio() -> RangeRatio {
rust/src/bench/src/datasets/sharegpt.rs
+4
-3
@@ -22,14 +22,15 @@ const DEFAULT_SHAREGPT_FILE: &str = "ShareGPT_V3_unfiltered_cleaned_split.json";
22
23
/// Download the default ShareGPT dataset from HuggingFace Hub.
24
/// Uses hf-hub's built-in cache — subsequent calls return the cached path instantly.
25
-pub fn download_sharegpt_dataset() -> Result<String> {
25
+pub async fn download_sharegpt_dataset() -> Result<String> {
26
tracing::info!(
27
repository = DEFAULT_SHAREGPT_REPO,
28
file = DEFAULT_SHAREGPT_FILE,
29
"downloading ShareGPT dataset"
30
);
31
- let repo = crate::hub::HubRepo::dataset(DEFAULT_SHAREGPT_REPO.to_string());
32
- let path = repo.get(DEFAULT_SHAREGPT_FILE).map_err(|e| {
31
+ let repo = crate::hub::HubRepo::dataset(DEFAULT_SHAREGPT_REPO.to_string())
32
+ .map_err(BenchError::Config)?;
33
+ let path = repo.get(DEFAULT_SHAREGPT_FILE).await.map_err(|e| {
34
BenchError::Config(format!(
35
"Failed to download ShareGPT dataset from '{DEFAULT_SHAREGPT_REPO}': {e}"
36
))
rust/src/bench/src/datasets/speed_bench.rs
+8
-7
@@ -26,7 +26,7 @@ fn cache_dir() -> std::path::PathBuf {
26
27
/// Download SPEED-Bench dataset from HuggingFace datasets-server API.
28
/// Results are cached as JSON locally for subsequent runs.
29
-pub fn download_speed_bench(config: SpeedBenchConfig) -> Result<String> {
29
+pub async fn download_speed_bench(config: SpeedBenchConfig) -> Result<String> {
30
let config_name = config.as_str();
31
32
let dir = cache_dir();
@@ -42,7 +42,7 @@ pub fn download_speed_bench(config: SpeedBenchConfig) -> Result<String> {
42
43
tracing::info!(config = config_name, "downloading SPEED-Bench dataset");
44
45
- let client = reqwest::blocking::Client::builder()
45
+ let client = reqwest::Client::builder()
46
.timeout(std::time::Duration::from_secs(120))
47
.build()
48
.map_err(|e| BenchError::Config(format!("Failed to build HTTP client: {e}")))?;
@@ -66,13 +66,14 @@ pub fn download_speed_bench(config: SpeedBenchConfig) -> Result<String> {
66
let max_retries = 3;
67
let mut data: Option<serde_json::Value> = None;
68
for attempt in 0..=max_retries {
69
- let resp = match client.get(&url).send() {
69
+ let resp = match client.get(&url).send().await {
70
Ok(r) => r,
71
Err(e) => {
72
if attempt < max_retries {
73
- std::thread::sleep(std::time::Duration::from_secs(
73
+ tokio::time::sleep(std::time::Duration::from_secs(
74
2 * (attempt as u64 + 1),
75
- ));
75
+ ))
76
+ .await;
77
continue;
78
}
79
return Err(BenchError::Config(format!(
@@ -82,7 +83,7 @@ pub fn download_speed_bench(config: SpeedBenchConfig) -> Result<String> {
83
};
84
85
if resp.status().is_server_error() && attempt < max_retries {
85
- std::thread::sleep(std::time::Duration::from_secs(2 * (attempt as u64 + 1)));
86
+ tokio::time::sleep(std::time::Duration::from_secs(2 * (attempt as u64 + 1))).await;
87
continue;
88
}
89
@@ -93,7 +94,7 @@ pub fn download_speed_bench(config: SpeedBenchConfig) -> Result<String> {
94
)));
95
}
96
96
- data = Some(resp.json().map_err(|e| {
97
+ data = Some(resp.json().await.map_err(|e| {
98
BenchError::Config(format!("Failed to parse SPEED-Bench API response: {e}"))
99
})?);
100
break;
rust/src/bench/src/hub.rs
+20
-35
@@ -1,54 +1,39 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-//! Sync facade over the async `hf_hub` API.
5
-//!
6
-//! The workspace bans rustls (`rust/deny.toml`), but hf-hub's sync `ureq`
7
-//! backend unconditionally pulls ureq's default rustls feature. So we use the
8
-//! reqwest/native-tls tokio API instead, and bridge blocking callers (dataset
9
-//! loaders, tokenizer fallback in rayon threads) by running each download on a
10
-//! dedicated thread with its own single-threaded runtime.
11
-
4
use std::path::PathBuf;
5
6
+use hf_hub::Repo;
7
+use hf_hub::api::tokio::{ApiBuilder, ApiRepo};
8
+
9
/// A handle to a HuggingFace Hub repo, downloading via hf-hub's on-disk cache.
10
pub struct HubRepo {
16
- repo: hf_hub::Repo,
11
+ repo: ApiRepo,
12
}
13
14
impl HubRepo {
20
- pub fn model(model_id: String) -> Self {
21
- Self {
22
- repo: hf_hub::Repo::model(model_id),
23
- }
15
+ pub fn model(model_id: String) -> Result<Self, String> {
16
+ Self::new(Repo::model(model_id))
17
}
18
26
- pub fn dataset(repo_id: String) -> Self {
27
- Self {
28
- repo: hf_hub::Repo::dataset(repo_id),
19
+ pub fn dataset(repo_id: String) -> Result<Self, String> {
20
+ Self::new(Repo::dataset(repo_id))
21
+ }
22
+
23
+ fn new(repo: Repo) -> Result<Self, String> {
24
+ let mut builder = ApiBuilder::from_env();
25
+ if let Ok(token) = std::env::var("HF_TOKEN") {
26
+ builder = builder.with_token(Some(token));
27
}
28
+ let api = builder.build().map_err(|e| format!("Failed to init HF API: {e}"))?;
29
+ Ok(Self {
30
+ repo: api.repo(repo),
31
+ })
32
}
33
34
/// Download (or fetch from cache) a single file from the repo.
35
/// Auth is handled by hf-hub via HF_TOKEN / the cached login token.
34
- pub fn get(&self, filename: &str) -> Result<PathBuf, String> {
35
- let repo = self.repo.clone();
36
- let filename = filename.to_string();
37
- std::thread::spawn(move || {
38
- let rt = tokio::runtime::Builder::new_current_thread()
39
- .enable_all()
40
- .build()
41
- .map_err(|e| format!("Failed to build download runtime: {e}"))?;
42
- rt.block_on(async move {
43
- let mut builder = hf_hub::api::tokio::ApiBuilder::from_env();
44
- if let Ok(token) = std::env::var("HF_TOKEN") {
45
- builder = builder.with_token(Some(token));
46
- }
47
- let api = builder.build().map_err(|e| format!("Failed to init HF API: {e}"))?;
48
- api.repo(repo).get(&filename).await.map_err(|e| format!("{e}"))
49
- })
50
- })
51
- .join()
52
- .map_err(|_| "HF Hub download thread panicked".to_string())?
36
+ pub async fn get(&self, filename: &str) -> Result<PathBuf, String> {
37
+ self.repo.get(filename).await.map_err(|e| format!("{e}"))
38
}
39
}
rust/src/bench/src/multi_turn.rs
+3
-2
@@ -90,7 +90,8 @@ pub async fn run_multi_turn_benchmark(config: &BenchConfig) -> Result<serde_json
90
let tid = config.tokenizer_id.as_deref().unwrap_or(&model_id);
91
tracing::info!(tokenizer = tid, "loading tokenizer");
92
let server_info = Some((config.base_url.as_str(), model_id.as_str()));
93
- let t = crate::tokenizer::load_tokenizer(tid, config.trust_remote_code, server_info)?;
93
+ let t =
94
+ crate::tokenizer::load_tokenizer(tid, config.trust_remote_code, server_info).await?;
95
Some(t)
96
};
97
@@ -139,7 +140,7 @@ pub async fn run_multi_turn_benchmark(config: &BenchConfig) -> Result<serde_json
140
let path = match config.dataset_path.as_deref() {
141
Some(p) => p,
142
None => {
142
- downloaded = crate::datasets::sharegpt::download_sharegpt_dataset()?;
143
+ downloaded = crate::datasets::sharegpt::download_sharegpt_dataset().await?;
144
downloaded.as_str()
145
}
146
};
rust/src/bench/src/tiktoken.rs
+22
-16
@@ -209,7 +209,7 @@ pub fn load_builtin_tiktoken(encoding: &str) -> Result<TiktokenTokenizer> {
209
}
210
211
/// Try to load a tiktoken tokenizer from a local directory or HuggingFace model repo.
212
-pub fn try_load_tiktoken(model_id: &str) -> Result<TiktokenTokenizer> {
212
+pub async fn try_load_tiktoken(model_id: &str) -> Result<TiktokenTokenizer> {
213
// Phase 1: If model_id is a local directory, look for tiktoken files there
214
let local_dir = Path::new(model_id);
215
if local_dir.is_dir() {
@@ -217,7 +217,7 @@ pub fn try_load_tiktoken(model_id: &str) -> Result<TiktokenTokenizer> {
217
}
218
219
// Phase 2: Fall back to HuggingFace Hub download
220
- try_load_tiktoken_from_hf(model_id)
220
+ try_load_tiktoken_from_hf(model_id).await
221
}
222
223
/// Common tiktoken model filenames to search for.
@@ -252,25 +252,28 @@ fn try_load_tiktoken_from_dir(dir: &Path, model_id: &str) -> Result<TiktokenToke
252
}
253
254
/// Load a tiktoken tokenizer from a HuggingFace model repo.
255
-fn try_load_tiktoken_from_hf(model_id: &str) -> Result<TiktokenTokenizer> {
256
- let repo = crate::hub::HubRepo::model(model_id.to_string());
257
-
258
- let model_path = repo
259
- .get("tiktoken.model")
260
- .or_else(|_| repo.get("qwen.tiktoken"))
261
- .or_else(|_| repo.get("vocab.tiktoken"))
262
- .map_err(|_| {
263
- BenchError::Tokenizer(format!("No tiktoken model file found for '{model_id}'"))
264
- })?;
255
+async fn try_load_tiktoken_from_hf(model_id: &str) -> Result<TiktokenTokenizer> {
256
+ let repo = crate::hub::HubRepo::model(model_id.to_string()).map_err(BenchError::Tokenizer)?;
257
+
258
+ let mut model_path = None;
259
+ for filename in TIKTOKEN_MODEL_FILENAMES {
260
+ if let Ok(path) = repo.get(filename).await {
261
+ model_path = Some(path);
262
+ break;
263
+ }
264
+ }
265
+ let model_path = model_path.ok_or_else(|| {
266
+ BenchError::Tokenizer(format!("No tiktoken model file found for '{model_id}'"))
267
+ })?;
268
269
let num_base_tokens = count_base_tokens(&model_path)?;
270
268
- let config = match repo.get("tokenizer_config.json") {
271
+ let config = match repo.get("tokenizer_config.json").await {
272
Ok(config_path) => read_tokenizer_config(&config_path),
273
Err(_) => None,
274
};
275
273
- let pattern = extract_pat_str_from_repo(&repo);
276
+ let pattern = extract_pat_str_from_repo(&repo).await;
277
278
build_tiktoken(model_id, &model_path, config, pattern, num_base_tokens)
279
}
@@ -403,9 +406,12 @@ fn extract_pat_str_from_local_dir(dir: &Path) -> Option<String> {
406
407
/// Try to download the Python tokenizer source file and extract pat_str via regex.
408
/// Returns None if unavailable or unparsable.
406
-fn extract_pat_str_from_repo(repo: &crate::hub::HubRepo) -> Option<String> {
409
+async fn extract_pat_str_from_repo(repo: &crate::hub::HubRepo) -> Option<String> {
410
// Try common Python tokenizer filenames
408
- let py_path = repo.get("tokenization_kimi.py").or_else(|_| repo.get("tokenizer.py")).ok()?;
411
+ let py_path = match repo.get("tokenization_kimi.py").await {
412
+ Ok(path) => path,
413
+ Err(_) => repo.get("tokenizer.py").await.ok()?,
414
+ };
415
416
let source = std::fs::read_to_string(&py_path).ok()?;
417
rust/src/bench/src/tokenizer.rs
+71
-12
@@ -2,6 +2,7 @@
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
use std::collections::HashSet;
5
+use std::future::Future;
6
use std::path::Path;
7
8
use thiserror_ext::AsReport as _;
@@ -19,7 +20,8 @@ pub enum TokenizerKind {
20
21
/// Server-side tokenizer using vLLM's /tokenize and /detokenize endpoints.
22
pub struct ServerTokenizer {
22
- client: reqwest::blocking::Client,
23
+ client: reqwest::Client,
24
+ runtime: tokio::runtime::Handle,
25
tokenize_url: String,
26
detokenize_url: String,
27
model: String,
@@ -28,8 +30,8 @@ pub struct ServerTokenizer {
30
31
impl ServerTokenizer {
32
/// Create a new server tokenizer and verify connectivity.
31
- pub fn new(base_url: &str, model: &str) -> Result<Self> {
32
- let client = reqwest::blocking::Client::builder()
33
+ pub async fn new(base_url: &str, model: &str) -> Result<Self> {
34
+ let client = reqwest::Client::builder()
35
.timeout(std::time::Duration::from_secs(30))
36
.build()
37
.map_err(|e| BenchError::Tokenizer(format!("Failed to build HTTP client: {e}")))?;
@@ -39,6 +41,7 @@ impl ServerTokenizer {
41
42
let st = Self {
43
client,
44
+ runtime: tokio::runtime::Handle::current(),
45
tokenize_url,
46
detokenize_url,
47
model: model.to_string(),
@@ -46,7 +49,7 @@ impl ServerTokenizer {
49
};
50
51
// Probe the endpoint to verify it works and discover vocab size
49
- let test_tokens = st.encode_inner("test")?;
52
+ let test_tokens = st.encode_async("test").await?;
53
let max_id = test_tokens.iter().copied().max().unwrap_or(0);
54
let estimated_vocab = (max_id * 2).max(131072);
55
@@ -57,6 +60,10 @@ impl ServerTokenizer {
60
}
61
62
fn encode_inner(&self, text: &str) -> Result<Vec<u32>> {
63
+ self.block_on(self.encode_async(text))
64
+ }
65
+
66
+ async fn encode_async(&self, text: &str) -> Result<Vec<u32>> {
67
let payload = serde_json::json!({
68
"model": self.model,
69
"prompt": text,
@@ -67,6 +74,7 @@ impl ServerTokenizer {
74
.post(&self.tokenize_url)
75
.json(&payload)
76
.send()
77
+ .await
78
.map_err(|e| BenchError::Tokenizer(format!("Server tokenize failed: {e}")))?;
79
80
if !resp.status().is_success() {
@@ -76,7 +84,7 @@ impl ServerTokenizer {
84
)));
85
}
86
79
- let data: serde_json::Value = resp.json().map_err(|e| {
87
+ let data: serde_json::Value = resp.json().await.map_err(|e| {
88
BenchError::Tokenizer(format!("Failed to parse tokenize response: {e}"))
89
})?;
90
@@ -96,6 +104,10 @@ impl ServerTokenizer {
104
}
105
106
fn decode_inner(&self, ids: &[u32]) -> Result<String> {
107
+ self.block_on(self.decode_async(ids))
108
+ }
109
+
110
+ async fn decode_async(&self, ids: &[u32]) -> Result<String> {
111
let payload = serde_json::json!({
112
"model": self.model,
113
"tokens": ids,
@@ -106,6 +118,7 @@ impl ServerTokenizer {
118
.post(&self.detokenize_url)
119
.json(&payload)
120
.send()
121
+ .await
122
.map_err(|e| BenchError::Tokenizer(format!("Server detokenize failed: {e}")))?;
123
124
if !resp.status().is_success() {
@@ -115,7 +128,7 @@ impl ServerTokenizer {
128
)));
129
}
130
118
- let data: serde_json::Value = resp.json().map_err(|e| {
131
+ let data: serde_json::Value = resp.json().await.map_err(|e| {
132
BenchError::Tokenizer(format!("Failed to parse detokenize response: {e}"))
133
})?;
134
@@ -124,6 +137,26 @@ impl ServerTokenizer {
137
.map(|s| s.to_string())
138
.ok_or_else(|| BenchError::Tokenizer("Missing 'prompt' in detokenize response".into()))
139
}
140
+
141
+ fn block_on<T>(&self, future: impl Future<Output = Result<T>>) -> Result<T> {
142
+ if matches!(
143
+ self.runtime.runtime_flavor(),
144
+ tokio::runtime::RuntimeFlavor::CurrentThread
145
+ ) {
146
+ return Err(BenchError::Tokenizer(
147
+ "Server tokenizer fallback requires a multi-thread Tokio runtime".into(),
148
+ ));
149
+ }
150
+
151
+ // Sync tokenizer calls can come from a Tokio worker or a Rayon worker.
152
+ // Tokio workers must enter a blocking region before re-entering the runtime;
153
+ // Rayon workers can drive the future directly with the saved runtime handle.
154
+ if tokio::runtime::Handle::try_current().is_ok() {
155
+ tokio::task::block_in_place(|| self.runtime.block_on(future))
156
+ } else {
157
+ self.runtime.block_on(future)
158
+ }
159
+ }
160
}
161
162
// --- TokenizerKind methods ---
@@ -193,7 +226,7 @@ impl TokenizerKind {
226
/// 3. Server-side /tokenize + /detokenize endpoints
227
///
228
/// `server_info` is `Some((base_url, model))` to enable server-side fallback.
196
-pub fn load_tokenizer(
229
+pub async fn load_tokenizer(
230
model_id: &str,
231
_trust_remote_code: bool,
232
server_info: Option<(&str, &str)>,
@@ -213,7 +246,7 @@ pub fn load_tokenizer(
246
}
247
248
// 1. Try local HuggingFace tokenizer (tokenizer.json)
216
- match try_load_local(model_id) {
249
+ match try_load_local(model_id).await {
250
Ok(tok) => {
251
tracing::info!(
252
model = model_id,
@@ -230,7 +263,7 @@ pub fn load_tokenizer(
263
error = %local_err.as_report(),
264
"local tokenizer unavailable; trying tiktoken"
265
);
233
- match crate::tiktoken::try_load_tiktoken(model_id) {
266
+ match crate::tiktoken::try_load_tiktoken(model_id).await {
267
Ok(tok) => {
268
tracing::info!(
269
model = model_id,
@@ -248,7 +281,7 @@ pub fn load_tokenizer(
281
error = %tiktoken_err.as_report(),
282
"tiktoken unavailable; trying server-side tokenization"
283
);
251
- match ServerTokenizer::new(base_url, model) {
284
+ match ServerTokenizer::new(base_url, model).await {
285
Ok(srv) => {
286
tracing::info!(
287
model = model_id,
@@ -282,7 +315,7 @@ pub fn load_tokenizer(
315
}
316
317
/// Try loading tokenizer.json from local path or HuggingFace Hub.
285
-fn try_load_local(model_id: &str) -> Result<Tokenizer> {
318
+async fn try_load_local(model_id: &str) -> Result<Tokenizer> {
319
// 1. Try local directory with tokenizer.json
320
let local_path = Path::new(model_id).join("tokenizer.json");
321
if local_path.exists() {
@@ -308,11 +341,37 @@ fn try_load_local(model_id: &str) -> Result<Tokenizer> {
341
}
342
343
// 4. Download from HuggingFace Hub (hf-hub handles auth via HF_TOKEN / cached token)
311
- let repo = crate::hub::HubRepo::model(model_id.to_string());
344
+ let repo = crate::hub::HubRepo::model(model_id.to_string()).map_err(BenchError::Tokenizer)?;
345
let tokenizer_path = repo
346
.get("tokenizer.json")
347
+ .await
348
.map_err(|e| BenchError::Tokenizer(format!("No tokenizer.json for '{model_id}': {e}")))?;
349
350
Tokenizer::from_file(&tokenizer_path)
351
.map_err(|e| BenchError::Tokenizer(format!("Failed to load downloaded tokenizer: {e}")))
352
}
353
+
354
+#[cfg(test)]
355
+mod tests {
356
+ use super::*;
357
+
358
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
359
+ async fn test_server_tokenizer_sync_bridge() {
360
+ let tokenizer = std::sync::Arc::new(ServerTokenizer {
361
+ client: reqwest::Client::new(),
362
+ runtime: tokio::runtime::Handle::current(),
363
+ tokenize_url: String::new(),
364
+ detokenize_url: String::new(),
365
+ model: String::new(),
366
+ cached_vocab_size: 0,
367
+ });
368
+
369
+ assert_eq!(tokenizer.block_on(async { Ok(1) }).unwrap(), 1);
370
+
371
+ let (tx, rx) = tokio::sync::oneshot::channel();
372
+ rayon::spawn(move || {
373
+ let _ = tx.send(tokenizer.block_on(async { Ok(2) }));
374
+ });
375
+ assert_eq!(rx.await.unwrap().unwrap(), 2);
376
+ }
377
+}