Skip to content

Commit f11828d

Browse files
committed
perf: RANGE_WINDOW 64 MiB for non-sequential downloads, preserve window on far-seek
Non-sequential reads (e.g. safetensors work-stealing across N threads) were issuing one HTTP request per tensor (~3 MiB avg), causing 7x more requests than sequential reads. Each CAS request has ~50ms latency, so 92 requests vs 13 added ~4s of serial latency overhead. Fix: use a 64 MiB minimum fetch window for RangeDownload strategy so that each HTTP request covers the full stride (N x avg_tensor_size) and reduces request count from 92 to ~18 for 8-thread safetensors access. Also stop resetting window_size to INITIAL_WINDOW on far-seek. Slot eviction by another thread should not undo accumulated sequential read headroom. Also adds tests/cas_parallel_download.rs: an integration benchmark that measures raw xet-core CAS throughput for N concurrent download streams without the FUSE layer, isolating xet-core bottlenecks from prefetch logic.
1 parent 1af9347 commit f11828d

3 files changed

Lines changed: 195 additions & 8 deletions

File tree

src/prefetch.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ pub(crate) const FORWARD_SKIP: u64 = 16 * 1_048_576; // 16 MiB
2424
/// Each concurrent reader (e.g. safetensors pread threads) gets its own slot,
2525
/// eliminating the global-mutex serialization that collapses throughput.
2626
pub(crate) const N_PREFETCH_SLOTS: usize = 8;
27+
/// Minimum fetch size for range (non-sequential) downloads.
28+
/// Each CAS range request has ~50ms of latency overhead regardless of size.
29+
/// With N threads doing work-stealing on a sorted tensor file, the stride
30+
/// between accesses is N × avg_tensor_size (≈27 MiB for 8 threads on GPT-2).
31+
/// A 64 MiB minimum covers the stride, turning 7× more HTTP requests (92 vs 13)
32+
/// into ~18 requests, cutting per-request latency overhead from 4.6s to 0.9s.
33+
pub(crate) const RANGE_WINDOW: u64 = 64 * 1_048_576; // 64 MiB
2734

2835
// ── FetchPlan ────────────────────────────────────────────────────────
2936

@@ -111,9 +118,9 @@ impl PrefetchState {
111118
debug!("prefetch window doubled to {}", self.window_size);
112119
true
113120
} else {
114-
// Far seek: reset to initial window, cancel stream
115-
self.window_size = INITIAL_WINDOW;
116-
debug!("prefetch window reset to {}", self.window_size);
121+
// Far seek: preserve window_size rather than resetting to INITIAL_WINDOW.
122+
// Window growth is driven by sequential reads; random access (e.g. slot
123+
// eviction by another thread) should not undo that accumulated headroom.
117124
false
118125
};
119126

@@ -134,7 +141,14 @@ impl PrefetchState {
134141
};
135142

136143
let needed = (size as u64).min(self.file_size - offset);
137-
let fetch_size = needed.max(self.window_size).min(self.file_size - offset);
144+
// Sequential reads use the adaptive window; range downloads use at least
145+
// RANGE_WINDOW to amortise HTTP request latency across many tensors.
146+
let min_window = if is_sequential {
147+
self.window_size
148+
} else {
149+
self.window_size.max(RANGE_WINDOW)
150+
};
151+
let fetch_size = needed.max(min_window).min(self.file_size - offset);
138152

139153
FetchPlan { strategy, fetch_size }
140154
}

src/virtual_fs/mod.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,10 +1464,11 @@ impl VirtualFs {
14641464
}
14651465
// Pass 3: empty slot — best for a fresh independent stream
14661466
for i in 0..prefetch.len() {
1467-
if let Ok(g) = prefetch[i].try_lock() {
1468-
if g.chunks_len == 0 && g.stream.is_none() {
1469-
break 'slot g;
1470-
}
1467+
if let Ok(g) = prefetch[i].try_lock()
1468+
&& g.chunks_len == 0
1469+
&& g.stream.is_none()
1470+
{
1471+
break 'slot g;
14711472
}
14721473
}
14731474
// Pass 4: any unlocked slot (evict its buffered data)

tests/cas_parallel_download.rs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Integration test: parallel CAS/S3 downloads without any FUSE/VFS layer.
2+
//
3+
// Purpose: determine whether xet-core's FileDownloadSession can serve N
4+
// concurrent download streams in parallel, or whether there is internal
5+
// serialisation (e.g. a shared lock on the reconstruction cache or the
6+
// S3 HTTP client connection pool).
7+
//
8+
// If N tasks downloading independent 50 MB slices of gpt2/model.safetensors
9+
// achieve ~N× the throughput of 1 task, the bottleneck is NOT in xet-core and
10+
// lies elsewhere (e.g. in our prefetch slot logic). If throughput stays flat,
11+
// xet-core has internal serialisation that needs to be investigated upstream.
12+
//
13+
// Run on EC2 with cold cache for accurate results:
14+
// echo 3 | sudo tee /proc/sys/vm/drop_caches
15+
// cargo test --release --test cas_parallel_download -- --nocapture
16+
17+
mod common;
18+
19+
use std::sync::Arc;
20+
use std::time::Instant;
21+
22+
use hf_mount::xet::{DownloadStreamOps, XetOps, XetSessions};
23+
24+
const REPO_ID: &str = "openai-community/gpt2";
25+
const SAFETENSOR_FILE: &str = "model.safetensors";
26+
const CHUNK_SIZE: u64 = 50 * 1024 * 1024; // 50 MB per concurrent task
27+
const TASK_COUNTS: &[usize] = &[1, 2, 4, 8];
28+
29+
#[tokio::test(flavor = "multi_thread")]
30+
async fn test_cas_parallel_download() {
31+
let token = match std::env::var("HF_TOKEN") {
32+
Ok(t) => t,
33+
Err(_) => {
34+
eprintln!("Skipping: HF_TOKEN not set");
35+
return;
36+
}
37+
};
38+
39+
let ep = common::endpoint();
40+
41+
// Mirror hf-mount's production settings so the adaptive concurrency controller
42+
// starts at 16 concurrent S3 connections instead of the default 1.
43+
// Without this the controller serialises all downloads on startup, making the
44+
// parallelism benchmark meaningless.
45+
for (k, v) in [
46+
("HF_XET_CLIENT_AC_INITIAL_DOWNLOAD_CONCURRENCY", "16"),
47+
("HF_XET_CLIENT_AC_MIN_BYTES_REQUIRED_FOR_ADJUSTMENT", "4194304"),
48+
("HF_XET_RECONSTRUCTION_MIN_RECONSTRUCTION_FETCH_SIZE", "8388608"),
49+
("HF_XET_RECONSTRUCTION_MIN_PREFETCH_BUFFER", "8388608"),
50+
("HF_XET_RECONSTRUCTION_TARGET_BLOCK_COMPLETION_TIME", "30"),
51+
// Do NOT cap the download buffer — hf-mount's 256 MiB limit causes the first
52+
// task to monopolize the buffer semaphore, starving all other concurrent tasks.
53+
// Use the xet-core defaults (2 GiB base, 8 GiB limit) for a fair benchmark.
54+
] {
55+
if std::env::var(k).is_err() {
56+
// SAFETY: called before any threads read these vars.
57+
unsafe { std::env::set_var(k, v) };
58+
}
59+
}
60+
61+
// Create a HubApiClient for the gpt2 model repo.
62+
let hub = hf_mount::hub_api::HubApiClient::from_source(
63+
&ep,
64+
Some(&token),
65+
hf_mount::hub_api::SourceKind::Repo {
66+
repo_id: REPO_ID.to_string(),
67+
repo_type: hf_mount::hub_api::RepoType::Model,
68+
revision: "main".to_string(),
69+
},
70+
)
71+
.await
72+
.expect("Failed to create hub client");
73+
74+
// Get xet_hash + size for the safetensors file.
75+
let head = hub
76+
.head_file(SAFETENSOR_FILE)
77+
.await
78+
.expect("head_file failed")
79+
.expect("file not found on Hub");
80+
let xet_hash = head.xet_hash.expect("file has no xet_hash (not stored in xet)");
81+
let file_size = head.size.expect("file has no size in HEAD response");
82+
let file_info = Arc::new(data::XetFileInfo::new(xet_hash.clone(), file_size));
83+
84+
eprintln!("\n=== CAS parallel download benchmark (no FUSE) ===");
85+
eprintln!(
86+
" {}/{} — {:.1} MB, hash={}…",
87+
REPO_ID,
88+
SAFETENSOR_FILE,
89+
file_size as f64 / 1e6,
90+
&xet_hash[..8]
91+
);
92+
93+
// Build a read-only CAS session via hub token refresher.
94+
let refresher = hub.token_refresher(true);
95+
let jwt = refresher.fetch_initial().await.expect("fetch_initial failed");
96+
97+
let config = Arc::new(
98+
data::data_client::default_config(
99+
jwt.cas_url,
100+
None,
101+
Some((jwt.access_token, jwt.exp)),
102+
Some(refresher),
103+
None,
104+
)
105+
.expect("default_config failed"),
106+
);
107+
108+
let raw_client = data::create_remote_client(&config, &uuid::Uuid::new_v4().to_string(), false)
109+
.await
110+
.expect("create_remote_client failed");
111+
let cached_client = hf_mount::cached_xet_client::CachedXetClient::new(raw_client);
112+
let session = data::FileDownloadSession::from_client(cached_client.clone(), None, None);
113+
// XetSessions::new() already wraps in Arc.
114+
let xet_sessions: Arc<XetSessions> = XetSessions::new(session, None, cached_client);
115+
116+
eprintln!(" {:>8} {:>10} {:>10} {:>10}", "Tasks", "Wall (s)", "MB/s", "Scale");
117+
eprintln!(" {:-<8} {:-<10} {:-<10} {:-<10}", "", "", "", "");
118+
119+
let mut mbps_1 = 0.0f64;
120+
121+
for &n in TASK_COUNTS {
122+
let t0 = Instant::now();
123+
let mut handles = Vec::new();
124+
125+
for i in 0..n {
126+
let sessions = xet_sessions.clone();
127+
let fi = file_info.clone();
128+
// Each task starts at a distinct 50 MB offset so they download independent slices.
129+
let offset = (i as u64 * CHUNK_SIZE).min(file_size.saturating_sub(1));
130+
131+
handles.push(tokio::spawn(async move {
132+
let mut stream: Box<dyn DownloadStreamOps> = sessions
133+
.download_stream_boxed(&fi, offset)
134+
.expect("download_stream_boxed failed");
135+
let mut total = 0u64;
136+
loop {
137+
match stream.next().await {
138+
Ok(Some(chunk)) => {
139+
total += chunk.len() as u64;
140+
if total >= CHUNK_SIZE {
141+
break;
142+
}
143+
}
144+
Ok(None) => break,
145+
Err(e) => panic!("stream error at offset={offset}: {e}"),
146+
}
147+
}
148+
total
149+
}));
150+
}
151+
152+
let total_bytes: u64 = futures::future::join_all(handles)
153+
.await
154+
.into_iter()
155+
.map(|r| r.expect("task panicked"))
156+
.sum();
157+
158+
let elapsed = t0.elapsed().as_secs_f64();
159+
let mbps = total_bytes as f64 / 1e6 / elapsed;
160+
161+
if n == 1 {
162+
mbps_1 = mbps;
163+
eprintln!(" {:>8} {:>10.3} {:>10.1} {:>10}", n, elapsed, mbps, "1.00x");
164+
} else {
165+
let scale = if mbps_1 > 0.0 { mbps / mbps_1 } else { 0.0 };
166+
eprintln!(" {:>8} {:>10.3} {:>10.1} {:>9.2}x", n, elapsed, mbps, scale);
167+
}
168+
}
169+
170+
eprintln!("===");
171+
eprintln!();
172+
}

0 commit comments

Comments
 (0)