Skip to content

perf: multi-slot prefetch eliminates parallel pread serialization#22

Closed
XciD wants to merge 5 commits into
mainfrom
perf/multi-slot-prefetch
Closed

perf: multi-slot prefetch eliminates parallel pread serialization#22
XciD wants to merge 5 commits into
mainfrom
perf/multi-slot-prefetch

Conversation

@XciD

@XciD XciD commented Mar 7, 2026

Copy link
Copy Markdown
Member

Problem

Concurrent pread() on a shared file descriptor collapsed to ~1/30 of sequential throughput (2637 MB/s single-thread, 85 MB/s with 2 threads).

This matches the exact access pattern used by transformers/vllm when loading safetensors shards: they open one fd per file, then dispatch N worker threads each calling pread() at different tensor offsets.

Root cause: OpenFile::Lazy held a single Mutex<PrefetchState> per file handle. The mutex was held for the entire network fetch (including async sleep/await). All concurrent readers on the same fd blocked on this one lock.

Fix

Replace the single slot with N=8 independent Mutex<PrefetchState> slots per file handle. Each concurrent reader selects its own slot with a non-blocking try_lock pass, so independent streams never contend.

Slot selection priority (all via try_lock, no blocking until fallback):

  1. Buffer cache hit (offset already fetched in this slot)
  2. Near-sequential stream (offset just past current buffer end)
  3. Empty slot (best for a fresh independent stream)
  4. Any unlocked slot (evict its buffered data)
  5. Blocking fallback keyed by 8 MiB offset bucket (deterministic, avoids thundering herd)

The slot selection uses a Rust labeled block 'slot: { ... } that holds the MutexGuard from the successful try_lock() call. This eliminates the TOCTOU race that would arise from returning an index and locking separately (multiple tasks could select the same empty slot before any locked it).

Results (EC2 m6i.2xlarge, release build)

Threads  Wall (ms)     MB/s
-------  ---------  -------
      1        270    730 MB/s
      2         20   9813 MB/s   (13x speedup, warm cache)
      4         10  23626 MB/s   (32x speedup, warm cache)

Unit test with 50ms injected first-chunk delay:

  • Before fix: 4 threads take ~28,771ms (366x worse than 1 thread)
  • After fix: 4 threads take ~116ms (< 150ms threshold = 75% of serial time)

Changes

  • src/prefetch.rs: add N_PREFETCH_SLOTS = 8 constant
  • src/virtual_fs/mod.rs: OpenFile::Lazy and ReadTarget::Remote now hold Arc<Vec<Mutex<PrefetchState>>>, slot selection via labeled block
  • src/test_mocks.rs: MockXet now uses Arc<Vec<u8>> for O(1) clone in concurrent tests, adds injectable first_chunk_delay_ms
  • src/virtual_fs/tests.rs: concurrent_pread_scaling test with timing assertion
  • tests/pread_scaling.rs: integration test for real FUSE mount (requires HF_TOKEN)

@github-actions

github-actions Bot commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results

============================================================
  Benchmark — 50MB
------------------------------------------------------------
  Metric                                 FUSE          NFS
  ------------------------------ ------------ ------------
  Sequential read                    198.3 MB/s     189.2 MB/s
  Sequential re-read                2191.8 MB/s    2383.7 MB/s
  Range read (1MB@25MB)                0.4 ms         0.2 ms
  Random reads (100x4KB avg)           0.0 ms         0.0 ms
  Sequential write (FUSE)           1095.9 MB/s
  Close latency (CAS+Hub)            0.087 s
  Write end-to-end                   376.3 MB/s
  Dedup write                       1132.3 MB/s
  Dedup close latency                0.099 s
  Dedup end-to-end                   349.0 MB/s
============================================================
============================================================
  Benchmark — 200MB
------------------------------------------------------------
  Metric                                 FUSE          NFS
  ------------------------------ ------------ ------------
  Sequential read                    990.7 MB/s    1297.6 MB/s
  Sequential re-read                2178.4 MB/s    2410.1 MB/s
  Range read (1MB@25MB)                0.2 ms         0.2 ms
  Random reads (100x4KB avg)           0.0 ms         0.0 ms
  Sequential write (FUSE)           1120.4 MB/s
  Close latency (CAS+Hub)            0.146 s
  Write end-to-end                   615.9 MB/s
  Dedup write                       1101.5 MB/s
  Dedup close latency                0.092 s
  Dedup end-to-end                   731.1 MB/s
============================================================
============================================================
  Benchmark — 500MB
------------------------------------------------------------
  Metric                                 FUSE          NFS
  ------------------------------ ------------ ------------
  Sequential read                   1387.6 MB/s    1405.4 MB/s
  Sequential re-read                2181.5 MB/s    2405.0 MB/s
  Range read (1MB@25MB)                0.2 ms         0.2 ms
  Random reads (100x4KB avg)           0.0 ms         0.0 ms
  Sequential write (FUSE)           1296.0 MB/s
  Close latency (CAS+Hub)            0.109 s
  Write end-to-end                  1010.6 MB/s
  Dedup write                       1099.9 MB/s
  Dedup close latency                0.121 s
  Dedup end-to-end                   869.3 MB/s
============================================================

@github-actions

github-actions Bot commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

POSIX Compliance (pjdfstest)

============================================================
  pjdfstest POSIX Compliance Results
------------------------------------------------------------
  Files: 130/130 passed    Tests: 832 total (0 subtests failed)
  Result: PASS
------------------------------------------------------------
  Category               Passed    Total   Status
  -------------------- -------- -------- --------
  chflags                     5        5       OK
  chmod                       8        8       OK
  chown                       6        6       OK
  ftruncate                  13       13       OK
  granular                    5        5       OK
  mkdir                       9        9       OK
  open                       19       19       OK
  posix_fallocate             1        1       OK
  rename                     10       10       OK
  rmdir                      11       11       OK
  symlink                    10       10       OK
  truncate                   13       13       OK
  unlink                     11       11       OK
  utimensat                   9        9       OK
============================================================

@XciD XciD changed the base branch from main to perf/reconstruction-cache March 8, 2026 09:40
@XciD XciD force-pushed the perf/reconstruction-cache branch from a9c241d to b30ca81 Compare March 8, 2026 09:45
@XciD XciD force-pushed the perf/multi-slot-prefetch branch 2 times, most recently from 96eb6d8 to 1772c6f Compare March 8, 2026 09:50
@XciD XciD force-pushed the perf/reconstruction-cache branch from b30ca81 to 98d25dc Compare March 8, 2026 10:05
@XciD XciD force-pushed the perf/multi-slot-prefetch branch from 1772c6f to f11828d Compare March 8, 2026 10:06
@XciD XciD changed the base branch from perf/reconstruction-cache to main March 8, 2026 10:10
@XciD XciD force-pushed the perf/multi-slot-prefetch branch from f11828d to a94e965 Compare March 8, 2026 10:13
XciD added 4 commits March 8, 2026 11:26
…OS FUSE

- Add safetensor concurrent pread benchmark (tests/bench.rs)
- Add concurrent_pread_scaling unit test reproducing multi-thread pread
- Fix macOS FUSE: disable clone_fd and force n_threads=1 (fuser limitation)
- Remove macos-no-mount feature to allow FUSE mount on macOS
Replace the single PrefetchState mutex per file handle with N=8
independent slots. Concurrent pread() calls on a shared fd each
select their own slot via a lock-free 'slot: { ... } pass, so
independent streams never block each other.

Root cause: the old single Mutex<PrefetchState> was held for the
entire network fetch (including sleep/await). All threads on a
shared fd serialized on this one lock, collapsing 8-thread pread
throughput to ~1/30 of sequential (2637 MB/s -> 85 MB/s measured).

Slot selection priority (all via try_lock to avoid blocking):
  1. Buffer cache hit (offset already fetched)
  2. Near-sequential stream (offset just past buf_start+len)
  3. Empty slot (best for a fresh independent stream)
  4. Any unlocked slot (evict its buffered data)
  5. Blocking fallback by 8 MiB offset bucket

Unit test: inject 50ms first-chunk delay, assert 4-thread wall
time < 150ms (75% of serial 200ms). Without fix: ~28 000ms.

Integration test: tests/pread_scaling.rs uploads 200 MB, opens
one fd, runs 1/2/4-thread strided pread, asserts 2-thread/1-thread
ratio >= 0.3.
…ow 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.
@XciD XciD force-pushed the perf/multi-slot-prefetch branch from 61b0275 to 9db6d54 Compare March 8, 2026 10:26
With 4 KiB chunks and an 8 MiB prefetch window, fetch_data runs 2048
non-yielding next() iterations. On 2-core CI runners with 2 tokio worker
threads, pairs of tasks (1&2, then 3&4) saturate both workers without
yielding, effectively serialising the 4-thread concurrent_pread_scaling
test. Bumping chunk_size to 512 KiB reduces the loop to 16 iterations,
keeping per-task overhead well under 10 ms and letting all 4 tasks
complete their fetches in parallel within the 150 ms threshold.
@XciD XciD marked this pull request as ready for review March 10, 2026 07:55
@XciD XciD closed this Mar 10, 2026
@XciD XciD deleted the perf/multi-slot-prefetch branch March 23, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant