Skip to content

Commit 5801cfd

Browse files
committed
test: xorb reconstruction cache warm-up benchmark and unit test
Add a unit test verifying that warm_reconstruction_cache is triggered on open() and that subsequent reads on the same handle are served without re-waiting (watch value stays true). Add an EC2 integration benchmark (warm_cache_bench) that measures TTFB and sequential throughput for 5 consecutive opens of a 100 MB file: - Open 1 (cold): CAS round-trip for reconstruction plan - Open 2+ (warm): plan served from in-process cache, data from xorb cache Use /dev/shm (tmpfs) as the xorb cache directory on Linux so warm reads hit RAM rather than EBS, demonstrating the actual benefit: cold TTFB 201 ms / 432 MB/s warm TTFB 1 ms / 6.7 GB/s
1 parent d013988 commit 5801cfd

3 files changed

Lines changed: 248 additions & 1 deletion

File tree

src/test_mocks.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ pub struct MockXet {
222222
range_fail_count: AtomicU32,
223223
/// Number of range download calls that should return empty before succeeding.
224224
range_empty_count: AtomicU32,
225+
/// How many times warm_reconstruction_cache was called.
226+
pub warm_call_count: AtomicU32,
227+
/// Optional delay injected into warm_reconstruction_cache (simulates CAS round-trip).
228+
warm_delay_ms: AtomicU64,
225229
}
226230

227231
impl MockXet {
@@ -235,9 +239,15 @@ impl MockXet {
235239
writer_fail_after: AtomicU64::new(u64::MAX),
236240
range_fail_count: AtomicU32::new(0),
237241
range_empty_count: AtomicU32::new(0),
242+
warm_call_count: AtomicU32::new(0),
243+
warm_delay_ms: AtomicU64::new(0),
238244
})
239245
}
240246

247+
pub fn set_warm_delay_ms(&self, ms: u64) {
248+
self.warm_delay_ms.store(ms, Ordering::SeqCst);
249+
}
250+
241251
pub fn add_file(&self, hash: &str, content: &[u8]) {
242252
self.files.lock().unwrap().insert(hash.to_string(), content.to_vec());
243253
}
@@ -312,7 +322,13 @@ impl XetOps for MockXet {
312322
Ok(results)
313323
}
314324

315-
async fn warm_reconstruction_cache(&self, _xet_hash: &str) {}
325+
async fn warm_reconstruction_cache(&self, _xet_hash: &str) {
326+
self.warm_call_count.fetch_add(1, Ordering::SeqCst);
327+
let delay = self.warm_delay_ms.load(Ordering::SeqCst);
328+
if delay > 0 {
329+
tokio::time::sleep(Duration::from_millis(delay)).await;
330+
}
331+
}
316332

317333
fn download_stream_boxed(&self, file_info: &XetFileInfo, offset: u64) -> Result<Box<dyn DownloadStreamOps>> {
318334
let prev_fail = self.range_fail_count.load(Ordering::SeqCst);

src/virtual_fs/tests.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,3 +2230,86 @@ fn shutdown_flushes_dirty() {
22302230
let logs = hub.take_batch_log();
22312231
assert!(!logs.is_empty());
22322232
}
2233+
2234+
// ── Reconstruction cache warm-up tests ──────────────────────────────
2235+
2236+
/// `open()` must trigger exactly one `warm_reconstruction_cache` call per file handle,
2237+
/// and that call must complete before the first `read()` is served.
2238+
///
2239+
/// We inject a 50 ms delay into the warm call to ensure the warm task is still
2240+
/// running when `read()` is called. If `read()` did NOT wait for the watch, the
2241+
/// first read would race with the warm task and the assertion on `warm_call_count`
2242+
/// at read time might pass trivially — so we also verify timing:
2243+
/// - with the warm delay the first read takes ≥ 50 ms (waited for warm)
2244+
/// - subsequent reads on the same handle take < 10 ms (watch already true)
2245+
/// - a second `open()` on the same inode triggers a second warm call.
2246+
#[test]
2247+
fn warm_cache_triggered_on_open_sequential_read() {
2248+
const WARM_DELAY_MS: u64 = 50;
2249+
const FILE_SIZE: u64 = 1024 * 1024; // 1 MiB — small so data fetch is trivial
2250+
2251+
let hub = MockHub::new();
2252+
hub.add_file("file.bin", FILE_SIZE, Some("hash_abc"), None);
2253+
let xet = MockXet::new();
2254+
let data: Vec<u8> = (0..FILE_SIZE as usize).map(|i| (i % 251) as u8).collect();
2255+
xet.add_file("hash_abc", &data);
2256+
xet.set_warm_delay_ms(WARM_DELAY_MS);
2257+
2258+
let (rt, vfs) = vfs_readonly(&hub, &xet);
2259+
2260+
rt.block_on(async {
2261+
let attr = vfs.lookup(ROOT_INODE, "file.bin").await.unwrap();
2262+
2263+
// ── open #1 ──────────────────────────────────────────────────
2264+
// Warm task starts in background with 50 ms delay.
2265+
// We yield briefly so the task is definitely running before read().
2266+
let fh1 = vfs.open(attr.ino, false, false, None).await.unwrap();
2267+
tokio::task::yield_now().await;
2268+
2269+
// First read must wait for warm to complete — expect ≥ WARM_DELAY_MS.
2270+
let t0 = std::time::Instant::now();
2271+
let (chunk, _) = vfs.read(fh1, 0, 4096).await.unwrap();
2272+
let elapsed_first = t0.elapsed();
2273+
2274+
assert_eq!(chunk.len(), 4096);
2275+
assert_eq!(chunk[0], 0u8); // pattern byte at offset 0
2276+
assert!(
2277+
elapsed_first.as_millis() >= WARM_DELAY_MS as u128,
2278+
"first read finished in {}ms — should have waited for warm ({} ms)",
2279+
elapsed_first.as_millis(),
2280+
WARM_DELAY_MS,
2281+
);
2282+
2283+
// Warm was called exactly once so far.
2284+
assert_eq!(xet.warm_call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
2285+
2286+
// Subsequent reads on the same handle are immediate (watch already true).
2287+
let t1 = std::time::Instant::now();
2288+
let (chunk2, _) = vfs.read(fh1, 4096, 4096).await.unwrap();
2289+
let elapsed_second = t1.elapsed();
2290+
assert_eq!(chunk2.len(), 4096);
2291+
assert_eq!(chunk2[0], (4096 % 251) as u8);
2292+
assert!(
2293+
elapsed_second.as_millis() < 10,
2294+
"second read took {}ms — should be near-instant (no warm wait)",
2295+
elapsed_second.as_millis(),
2296+
);
2297+
2298+
vfs.release(fh1).await;
2299+
2300+
// ── open #2 ──────────────────────────────────────────────────
2301+
// Each open() is an independent handle with its own warm task.
2302+
let fh2 = vfs.open(attr.ino, false, false, None).await.unwrap();
2303+
tokio::task::yield_now().await;
2304+
2305+
vfs.read(fh2, 0, 4096).await.unwrap();
2306+
2307+
assert_eq!(
2308+
xet.warm_call_count.load(std::sync::atomic::Ordering::SeqCst),
2309+
2,
2310+
"second open should trigger a second warm call"
2311+
);
2312+
2313+
vfs.release(fh2).await;
2314+
});
2315+
}

tests/warm_cache_bench.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/// Benchmark: xorb reconstruction cache warm-up benefit on sequential reads.
2+
///
3+
/// Each open() reads the full file and reports:
4+
/// TTFB — time to first byte (blocked on warm_reconstruction_cache)
5+
/// MB/s — end-to-end sequential throughput including TTFB
6+
///
7+
/// Open #1 = cold: FUSE process fetches the reconstruction plan from CAS.
8+
/// Open #2+ = warm: in-process CachedXetClient cache hit, plan already stored.
9+
///
10+
/// Run on EC2:
11+
/// cargo test --release --test warm_cache_bench -- --nocapture
12+
mod common;
13+
14+
use std::io::Read;
15+
use std::time::Instant;
16+
17+
const FILE_SIZE: usize = 100 * 1024 * 1024; // 100 MB
18+
const N_OPENS: usize = 5;
19+
const READ_CHUNK: usize = 4 * 1024 * 1024; // 4 MB per read syscall
20+
21+
#[tokio::test]
22+
async fn bench_xorb_reconstruction_cache() {
23+
let (token, bucket_id, hub) = match common::setup_bucket("warm-cache-bench").await {
24+
Some(cfg) => cfg,
25+
None => return,
26+
};
27+
28+
let filename = "seq_100mb.bin";
29+
let data = common::generate_pattern(FILE_SIZE);
30+
let write_config = common::build_write_config(&hub).await;
31+
32+
let tmp = std::env::temp_dir().join(format!("hf-warm-bench-{}", std::process::id()));
33+
std::fs::create_dir_all(&tmp).ok();
34+
let staging = tmp.join(filename);
35+
std::fs::write(&staging, &data).expect("write staging");
36+
37+
let file_info = common::upload_file(write_config, &staging).await;
38+
let xet_hash = file_info.hash().to_string();
39+
eprintln!(
40+
"Uploaded {} ({} MB), xet_hash={}",
41+
filename,
42+
FILE_SIZE / (1024 * 1024),
43+
xet_hash
44+
);
45+
std::fs::remove_dir_all(&tmp).ok();
46+
47+
hub.batch_operations(&[hf_mount::hub_api::BatchOp::AddFile {
48+
path: filename.to_string(),
49+
xet_hash,
50+
mtime: std::time::SystemTime::now()
51+
.duration_since(std::time::UNIX_EPOCH)
52+
.unwrap_or_default()
53+
.as_millis() as u64,
54+
content_type: None,
55+
}])
56+
.await
57+
.expect("batch add");
58+
59+
let pid = std::process::id();
60+
let mount = format!("/tmp/hf-warm-bench-{}", pid);
61+
// Use /dev/shm (tmpfs) on Linux for RAM-speed cache reads, falling back to /tmp.
62+
let shm_dir = format!("/dev/shm/hf-warm-cache-{}", pid);
63+
let cache = if cfg!(target_os = "linux") && std::fs::create_dir_all(&shm_dir).is_ok() {
64+
shm_dir
65+
} else {
66+
format!("/tmp/hf-warm-cache-{}", pid)
67+
};
68+
69+
eprintln!("Cache dir: {}", cache);
70+
71+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
72+
let child = common::mount_bucket(&bucket_id, &mount, &cache, &["--read-only"]);
73+
let file_path = format!("{}/{}", mount, filename);
74+
let size_mb = FILE_SIZE as f64 / (1024.0 * 1024.0);
75+
76+
eprintln!(
77+
"\n=== Xorb reconstruction cache: sequential read ({} MB) ===",
78+
FILE_SIZE / (1024 * 1024)
79+
);
80+
eprintln!(
81+
" {:>6} {:>10} {:>10} {:>10} {}",
82+
"Open#", "TTFB (ms)", "Total (s)", "MB/s", "cache"
83+
);
84+
eprintln!(" {:-<6} {:-<10} {:-<10} {:-<10} {:-<5}", "", "", "", "", "");
85+
86+
let mut results: Vec<(f64, f64, f64)> = Vec::new(); // (ttfb_ms, total_s, mbps)
87+
let mut buf = vec![0u8; READ_CHUNK];
88+
89+
for i in 0..N_OPENS {
90+
let t0 = Instant::now();
91+
let mut f = std::fs::File::open(&file_path).expect("open");
92+
93+
// First read: blocks until warm_reconstruction_cache completes (cold = CAS round-trip)
94+
let n = f.read(&mut buf).expect("first read");
95+
let ttfb = t0.elapsed();
96+
assert!(n > 0, "empty first read");
97+
assert_eq!(buf[0], 0u8, "data mismatch at open {}", i + 1);
98+
99+
// Drain the rest
100+
let mut total = n;
101+
loop {
102+
let n = f.read(&mut buf).expect("seq read");
103+
if n == 0 {
104+
break;
105+
}
106+
total += n;
107+
}
108+
let elapsed = t0.elapsed();
109+
assert_eq!(total, FILE_SIZE, "size mismatch at open {}", i + 1);
110+
111+
let ttfb_ms = ttfb.as_secs_f64() * 1000.0;
112+
let total_s = elapsed.as_secs_f64();
113+
let mbps = size_mb / total_s;
114+
let label = if i == 0 { "cold" } else { "warm" };
115+
116+
eprintln!(
117+
" {:>6} {:>10.1} {:>10.2} {:>10.1} {}",
118+
i + 1,
119+
ttfb_ms,
120+
total_s,
121+
mbps,
122+
label,
123+
);
124+
results.push((ttfb_ms, total_s, mbps));
125+
}
126+
127+
common::unmount(&mount, child, 10);
128+
results
129+
}));
130+
131+
std::fs::remove_dir_all(&mount).ok();
132+
std::fs::remove_dir_all(&cache).ok();
133+
common::delete_bucket(&common::endpoint(), &token, &bucket_id).await;
134+
135+
let results = match result {
136+
Ok(r) => r,
137+
Err(e) => std::panic::resume_unwind(e),
138+
};
139+
140+
let (cold_ttfb, _, cold_mbps) = results[0];
141+
let warm_ttfb_avg = results[1..].iter().map(|(t, _, _)| t).sum::<f64>() / (results.len() - 1) as f64;
142+
let warm_mbps_avg = results[1..].iter().map(|(_, _, m)| m).sum::<f64>() / (results.len() - 1) as f64;
143+
144+
eprintln!(
145+
"\nSummary: cold TTFB {:.0} ms / {:.1} MB/s → warm TTFB {:.0} ms / {:.1} MB/s",
146+
cold_ttfb, cold_mbps, warm_ttfb_avg, warm_mbps_avg,
147+
);
148+
}

0 commit comments

Comments
 (0)