Skip to content

Commit d013988

Browse files
authored
perf: cache reconstruction plans and pre-warm on open (#21)
## Summary Two complementary optimizations to eliminate redundant CAS API round-trips for reconstruction plan lookups. **`derive_range_response` in `CachedXetClient`** When a range-query (`get_reconstruction(hash, Some(range))`) misses the cache but the full-file plan (`get_reconstruction(hash, None)`) is already cached, derive the range response locally by slicing the term list -- no network call. The mutex is released before the derivation so N concurrent `pread()` calls don't serialize on it. **`warm_reconstruction_cache` at `open()`** A fire-and-forget task fetches the full-file reconstruction plan at `open()` time. By the time the first `read()` arrives, the plan is typically already in the in-memory cache. Errors are silently dropped (best-effort). Together these mean that after the first `open()`, all reconstruction lookups for that file are served from memory.
1 parent 415d6ba commit d013988

7 files changed

Lines changed: 241 additions & 38 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ xorb_object = { git = "https://github.com/huggingface/xet-core.git", rev = "c805
1616
merklehash = { git = "https://github.com/huggingface/xet-core.git", rev = "c805591" }
1717

1818
# External crates
19-
fuser = "0.17"
19+
fuser = { version = "0.17", features = ["macos-no-mount"] }
2020
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
2121
reqwest = { version = "0.12", features = ["json", "stream"] }
2222
futures = "0.3"

src/cached_xet_client.rs

Lines changed: 183 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,188 @@
11
use std::collections::HashMap;
22
use std::sync::{Arc, Mutex};
3+
use std::time::{Duration, Instant};
34

45
use bytes::Bytes;
56
use cas_client::adaptive_concurrency::ConnectionPermit;
67
use cas_client::{Client, ProgressCallback, URLProvider};
7-
use xorb_object::SerializedXorbObject;
8-
use cas_types::{BatchQueryReconstructionResponse, FileRange, QueryReconstructionResponse};
8+
use cas_types::{
9+
BatchQueryReconstructionResponse, FileRange, HexMerkleHash, QueryReconstructionResponse, XorbReconstructionTerm,
10+
};
911
use mdb_shard::file_structs::MDBFileInfo;
1012
use merklehash::MerkleHash;
11-
use tracing::debug;
13+
use xorb_object::SerializedXorbObject;
1214

1315
type Result<T> = std::result::Result<T, cas_client::CasClientError>;
1416

1517
const MAX_CACHE_ENTRIES: usize = 1024;
18+
/// Presigned URLs returned by the CAS server expire after 1 hour.
19+
/// Evict cache entries just before expiry to avoid serving stale URLs.
20+
const CACHE_TTL: Duration = Duration::from_secs(59 * 60);
1621

17-
type CacheKey = (MerkleHash, Option<FileRange>);
22+
struct CacheEntry {
23+
response: QueryReconstructionResponse,
24+
inserted_at: Instant,
25+
}
26+
27+
impl CacheEntry {
28+
fn new(response: QueryReconstructionResponse) -> Self {
29+
Self {
30+
response,
31+
inserted_at: Instant::now(),
32+
}
33+
}
34+
35+
fn is_valid(&self, ttl: Duration) -> bool {
36+
self.inserted_at.elapsed() < ttl
37+
}
38+
}
1839

1940
// TODO: move this into xet-core (cas_client or file_reconstruction) so all consumers benefit.
2041
pub struct CachedXetClient {
2142
inner: Arc<dyn Client>,
22-
cache: Mutex<HashMap<CacheKey, QueryReconstructionResponse>>,
43+
/// Only full-file reconstruction plans are cached (key = file hash, no range).
44+
/// Range responses are derived on the fly via `derive_range_response`.
45+
cache: Mutex<HashMap<MerkleHash, CacheEntry>>,
46+
ttl: Duration,
2347
}
2448

2549
impl CachedXetClient {
2650
pub fn new(inner: Arc<dyn Client>) -> Arc<Self> {
51+
Self::new_with_ttl(inner, CACHE_TTL)
52+
}
53+
54+
fn new_with_ttl(inner: Arc<dyn Client>, ttl: Duration) -> Arc<Self> {
2755
Arc::new(Self {
2856
inner,
2957
cache: Mutex::new(HashMap::new()),
58+
ttl,
3059
})
3160
}
3261
}
3362

63+
/// Derive a range-scoped `QueryReconstructionResponse` from a cached full-file response.
64+
///
65+
/// The full-file response lists all terms in file order with their unpacked byte lengths.
66+
/// We walk the terms, track cumulative byte offsets, and keep only terms that overlap
67+
/// `[range.start, range.end)`. The `offset_into_first_range` is the byte offset within
68+
/// the first overlapping term at which the requested range starts.
69+
///
70+
/// # Limitation: over-fetch vs CAS server trimming (P2)
71+
///
72+
/// When the CAS server handles a range query directly, it trims each term's `ChunkRange`
73+
/// at chunk granularity: it advances `range.start` past whole chunks whose data falls
74+
/// entirely before the requested offset, and cuts `range.end` after the last needed chunk.
75+
/// This produces tight presigned S3 URLs covering only the necessary compressed bytes.
76+
///
77+
/// This function cannot do the same trimming because `XorbReconstructionTerm` only carries
78+
/// `unpacked_length` for the whole term, not per-chunk sizes. As a result, derived responses
79+
/// keep the full `ChunkRange` of each overlapping term — the caller downloads and decompresses
80+
/// up to one extra chunk at the start and one at the end per term.
81+
///
82+
/// In practice the over-fetch is small: at most ~2 × chunk_size (~128 KB) per term per
83+
/// range query. For typical safetensors workloads this is negligible (<1% of total data).
84+
///
85+
/// TODO: fix P2 — add `chunk_uncompressed_sizes: Vec<u32>` (and `chunk_compressed_sizes`)
86+
/// to `XorbReconstructionTerm` in xet-core/xetcas so that chunk-level trimming can be
87+
/// replicated client-side, reducing over-fetch to zero.
88+
fn derive_range_response(full: &QueryReconstructionResponse, range: FileRange) -> QueryReconstructionResponse {
89+
let mut cur_offset: u64 = 0;
90+
let mut result_terms: Vec<XorbReconstructionTerm> = Vec::new();
91+
let mut offset_into_first: u64 = 0;
92+
let mut needed_hashes: std::collections::HashSet<HexMerkleHash> = std::collections::HashSet::new();
93+
94+
for term in &full.terms {
95+
let term_start = cur_offset;
96+
let term_end = cur_offset + term.unpacked_length as u64;
97+
98+
if term_end <= range.start {
99+
cur_offset = term_end;
100+
continue;
101+
}
102+
if term_start >= range.end {
103+
break;
104+
}
105+
106+
if result_terms.is_empty() {
107+
offset_into_first = range.start.saturating_sub(term_start);
108+
}
109+
needed_hashes.insert(term.hash);
110+
result_terms.push(term.clone());
111+
cur_offset = term_end;
112+
}
113+
114+
let fetch_info = full
115+
.fetch_info
116+
.iter()
117+
.filter(|(k, _)| needed_hashes.contains(*k))
118+
.map(|(k, v)| (*k, v.clone()))
119+
.collect();
120+
121+
QueryReconstructionResponse {
122+
offset_into_first_range: offset_into_first,
123+
terms: result_terms,
124+
fetch_info,
125+
}
126+
}
127+
34128
#[async_trait::async_trait]
35129
impl Client for CachedXetClient {
36130
async fn get_reconstruction(
37131
&self,
38132
file_id: &MerkleHash,
39133
bytes_range: Option<FileRange>,
40134
) -> Result<Option<QueryReconstructionResponse>> {
41-
let key = (*file_id, bytes_range);
42-
43-
{
44-
let cache = self.cache.lock().expect("cache poisoned");
45-
if let Some(response) = cache.get(&key) {
46-
debug!("reconstruction cache hit for {file_id}");
47-
return Ok(Some(response.clone()));
135+
// Clone the full-file plan under a brief lock, then derive outside the lock so
136+
// concurrent pread() calls from N threads don't serialize on this mutex.
137+
let cached_full = {
138+
let mut cache = self.cache.lock().expect("cache poisoned");
139+
match cache.get(file_id) {
140+
Some(entry) if entry.is_valid(self.ttl) => Some(entry.response.clone()),
141+
Some(_) => {
142+
// Expired — evict so the next caller fetches fresh URLs from CAS.
143+
cache.remove(file_id);
144+
None
145+
}
146+
None => None,
147+
}
148+
}; // ← lock released here
149+
150+
if let Some(full) = cached_full {
151+
if let Some(range) = bytes_range {
152+
let resp = derive_range_response(&full, range);
153+
// Mirror the CAS server's EOF contract:
154+
// - Non-empty file, range past EOF → None
155+
// - Empty file, range.start == 0 → Some(empty_terms) (only valid range on empty file)
156+
// - Empty file, range.start > 0 → None (past EOF on empty file)
157+
if resp.terms.is_empty() && (!full.terms.is_empty() || range.start > 0) {
158+
return Ok(None);
159+
}
160+
tracing::debug!(
161+
"recon: DERV file={:.8} range={:?} terms={}",
162+
file_id,
163+
bytes_range,
164+
resp.terms.len()
165+
);
166+
return Ok(Some(resp));
167+
} else {
168+
tracing::debug!("recon: HIT file={:.8} range=None terms={}", file_id, full.terms.len());
169+
return Ok(Some(full));
48170
}
49171
}
50172

173+
tracing::debug!("recon: CAS file={:.8} range={:?}", file_id, bytes_range);
51174
let result = self.inner.get_reconstruction(file_id, bytes_range).await?;
52175

53-
if let Some(ref response) = result {
176+
// Only cache full-file plans. Range responses are never stored: they are always
177+
// derived from the full-file plan, and the full-file plan is warmed at open() time.
178+
if bytes_range.is_none()
179+
&& let Some(response) = &result
180+
{
54181
let mut cache = self.cache.lock().expect("cache poisoned");
55182
if cache.len() >= MAX_CACHE_ENTRIES {
56183
cache.clear();
57184
}
58-
cache.insert(key, response.clone());
185+
cache.insert(*file_id, CacheEntry::new(response.clone()));
59186
}
60187

61188
Ok(result)
@@ -134,7 +261,7 @@ mod tests {
134261

135262
struct MockClient {
136263
mode: MockMode,
137-
calls: Mutex<HashMap<CacheKey, usize>>,
264+
calls: Mutex<HashMap<(MerkleHash, Option<FileRange>), usize>>,
138265
total_calls: AtomicUsize,
139266
barrier: Option<Arc<tokio::sync::Barrier>>,
140267
}
@@ -158,7 +285,7 @@ mod tests {
158285
}
159286
}
160287

161-
fn call_count(&self, key: CacheKey) -> usize {
288+
fn call_count(&self, key: (MerkleHash, Option<FileRange>)) -> usize {
162289
self.calls
163290
.lock()
164291
.expect("calls lock poisoned")
@@ -297,23 +424,60 @@ mod tests {
297424
}
298425

299426
#[tokio::test]
300-
async fn cache_key_includes_bytes_range() {
427+
async fn range_derived_from_full_file_plan() {
301428
let inner_impl = Arc::new(MockClient::new(MockMode::ReturnSome));
302429
let inner: Arc<dyn Client> = inner_impl.clone();
303430
let client = CachedXetClient::new(inner);
304431
let key = hash_for(4);
305432
let r = Some(FileRange::new(10, 20));
306433

434+
// Fetch full-file plan first (caches it).
307435
client.get_reconstruction(&key, None).await.unwrap();
308436
client.get_reconstruction(&key, None).await.unwrap();
437+
438+
// Range queries are derived from the cached full-file plan — never hit backend.
309439
client.get_reconstruction(&key, r).await.unwrap();
310-
client.get_reconstruction(&key, r).await.unwrap();
440+
client
441+
.get_reconstruction(&key, Some(FileRange::new(20, 30)))
442+
.await
443+
.unwrap();
311444

312445
assert_eq!(inner_impl.call_count((key, None)), 1);
313-
assert_eq!(inner_impl.call_count((key, r)), 1);
446+
assert_eq!(inner_impl.call_count((key, r)), 0); // derived, never hit backend
447+
assert_eq!(inner_impl.total_calls(), 1);
448+
}
449+
450+
#[tokio::test]
451+
async fn range_query_hits_backend_when_no_full_file_cached() {
452+
let inner_impl = Arc::new(MockClient::new(MockMode::ReturnSome));
453+
let inner: Arc<dyn Client> = inner_impl.clone();
454+
let client = CachedXetClient::new(inner);
455+
let key = hash_for(40);
456+
let r = Some(FileRange::new(10, 20));
457+
458+
// Range query with no full-file plan cached → hits backend.
459+
// The response is NOT cached (range responses are never stored).
460+
client.get_reconstruction(&key, r).await.unwrap();
461+
client.get_reconstruction(&key, r).await.unwrap();
462+
463+
assert_eq!(inner_impl.call_count((key, r)), 2); // no cache, each hits backend
314464
assert_eq!(inner_impl.total_calls(), 2);
315465
}
316466

467+
#[tokio::test]
468+
async fn expired_entries_are_evicted_and_refetched() {
469+
let inner_impl = Arc::new(MockClient::new(MockMode::ReturnSome));
470+
let inner: Arc<dyn Client> = inner_impl.clone();
471+
let client = CachedXetClient::new_with_ttl(inner, Duration::ZERO);
472+
let key = hash_for(50);
473+
474+
// With TTL=0 every entry is immediately expired — each call hits the backend.
475+
client.get_reconstruction(&key, None).await.unwrap();
476+
client.get_reconstruction(&key, None).await.unwrap();
477+
478+
assert_eq!(inner_impl.call_count((key, None)), 2);
479+
}
480+
317481
#[tokio::test]
318482
async fn cache_clears_when_capacity_is_reached() {
319483
let inner_impl = Arc::new(MockClient::new(MockMode::ReturnSome));

src/fuse.rs

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -462,17 +462,8 @@ pub fn mount_fuse(
462462
config.mount_options.push(fuser::MountOption::RO);
463463
}
464464
config.acl = fuser::SessionACL::All;
465-
#[cfg(not(target_os = "macos"))]
466-
{
467-
config.clone_fd = true;
468-
config.n_threads = Some(max_threads);
469-
}
470-
#[cfg(target_os = "macos")]
471-
{
472-
// macFUSE only supports single-threaded session (fuser limitation).
473-
let _ = max_threads;
474-
config.n_threads = Some(1);
475-
}
465+
config.clone_fd = true;
466+
config.n_threads = Some(max_threads);
476467

477468
let session = match fuser::Session::new(adapter, mount_point, &config) {
478469
Ok(s) => s,

src/setup.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,9 @@ pub fn setup(is_nfs: bool) -> MountSetup {
219219
))
220220
.expect("Failed to create CAS client");
221221
let cached_client = CachedXetClient::new(raw_client);
222-
let download_session = FileDownloadSession::from_client(cached_client, None, Some(xorb_cache));
222+
let download_session = FileDownloadSession::from_client(cached_client.clone(), None, Some(xorb_cache));
223223
let upload_config = if read_only { None } else { Some(cas_config) };
224-
let xet_sessions = XetSessions::new(download_session, upload_config);
224+
let xet_sessions = XetSessions::new(download_session, upload_config, cached_client);
225225

226226
let advanced_writes = args.advanced_writes || (is_nfs && !read_only);
227227
// Repos need a staging dir for HTTP download cache (open_readonly),

src/test_mocks.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ impl XetOps for MockXet {
312312
Ok(results)
313313
}
314314

315+
async fn warm_reconstruction_cache(&self, _xet_hash: &str) {}
316+
315317
fn download_stream_boxed(&self, file_info: &XetFileInfo, offset: u64) -> Result<Box<dyn DownloadStreamOps>> {
316318
let prev_fail = self.range_fail_count.load(Ordering::SeqCst);
317319
if prev_fail > 0 {

0 commit comments

Comments
 (0)