Skip to content

perf(serverless): range-parallel VolumeCache hydrate - #621

Open
deanq wants to merge 5 commits into
mainfrom
feature/volume-cache-parallel-hydrate
Open

deanq wants to merge 5 commits into
mainfrom
feature/volume-cache-parallel-hydrate

Conversation

@deanq

@deanq deanq commented Oct 14, 2026

Copy link
Copy Markdown
Member

Stacked on #620 (fix/volume-cache-metadata-eperm). Merge that first; this PR then retargets to main.

Bug

Even with #620, VolumeCache.hydrate() on a Global Store volume silently restores only part of a model. Hydrate copies whole big files in parallel with shutil.copyfile, which uses sendfile on Linux. Under that load the geesefs mount fails reads with ENOMEM. _copy_file logs a warning and drops the file, nothing retries it, and the worker falls back to a cold download.

It's also slow when it works. The mount serves one sequential stream at ~140 MiB/s, and reading several whole files at once doesn't add up.

Measured on a 4090 pod in EU-RO-1. A 15.2 GB Qwen2.5-7B was synced to a global volume, then hydrated into an empty dir, alternating implementations, with the page cache dropped between runs:

hydrate files restored time
#620 9 of 11 (2 of 4 weight shards lost to ENOMEM), both runs 53.5s, 24.2s
this PR 11 of 11, byte-identical to the source (diff -r) 21.7s, 17.2s (670–844 MiB/s)

Fix

Hydrate splits every big file into hydrate_chunk_size ranges (default 8 MiB). One pool of hydrate_workers threads (default 64) copies the ranges of all files with pread/pwrite, each range on its own descriptors. Independent range reads scale to the host NIC (~1.1 GiB/s measured), where whole-file copies don't.

  • Ranges go in offset-major order, so concurrent readers spread across every file instead of queuing on one.
  • Each range retries ENOMEM/EIO/EAGAIN with exponential backoff (8 attempts, ~6s total). Any other error, or exhausted retries, fails the file, and its remaining ranges are skipped.
  • A file is assembled in a preallocated temp file and os.replaced into place only after every range has copied, stamped with the manifest's source mtime as in fix(serverless): VolumeCache copies files on volumes that reject utime/chmod #620. A failed file leaves any existing local copy untouched and is not counted as restored.
  • A short read (a mirror file smaller than its manifest entry) fails the file instead of restoring zero padding. It isn't retried.
  • hydrate_workers and hydrate_chunk_size are validated like max_workers.
  • Bytes in flight (workers × chunk) is the knob that matters. The default is 512 MiB, and that's also the peak hydrate memory. In earlier testbed runs, much past ~1 GiB in flight triggered ENOMEM storms on busy hosts.
  • sync() is unchanged. Writes to the mount scale with the number of parallel files (~400 MiB/s at 8), and range writes into one geesefs object haven't been validated.
  • Cleanup: dropped the now-unused mtime_of/mtime plumbing from _copy_parallel/_copy_file, extracted a _tmp_path helper (4 call sites), and added _positive_or_default for constructor validation.
  • docs/serverless/volume_cache.md documents the new options and the hydrate path.

Tests

14 new tests in test_rp_volume_cache.py, written first and confirmed failing:

  • The range split covers every byte, including a final partial chunk.
  • Ranges interleave across files.
  • Transient ENOMEM is retried and the file restored.
  • A non-transient error is not retried and aborts that file only.
  • A persistently failing file is skipped while the others restore, with no temp files left.
  • A failure leaves an existing local file untouched.
  • A truncated mirror is rejected.
  • Failures creating the local temp file and failures on rename are handled.
  • Argument validation and defaults.

make quality-check passes: 1492 tests, 91.7% total coverage, and rp_volume_cache.py is at 97%. The remaining uncovered lines predate this change.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Concurrent source replacement can corrupt restored files, and scheduling memory and argument validation need correction.

3 open findings
What changed in this PR

Builds on #620 to improve VolumeCache hydration speed and reliability on Global Store volumes.

Changes:

  • Replaces whole-file hydration with configurable parallel range reads and transient-error retries.
  • Assembles files in temporary storage before atomic publication.
  • Adds regression tests and documents hydration tuning.
File Description
tests/​test_serverless/​test_utils/​test_rp_volume_cache.py Tests range copying, retries, cleanup, and configuration.
runpod/​serverless/​utils/​rp_volume_cache.py Implements range-parallel hydration and shared helpers.
docs/​serverless/​volume_cache.md Documents hydration behavior and tuning options.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread runpod/serverless/utils/rp_volume_cache.py
Comment thread runpod/serverless/utils/rp_volume_cache.py Outdated
Comment thread runpod/serverless/utils/rp_volume_cache.py Outdated
@deanq
deanq requested a review from a team October 15, 2026 01:24
Base automatically changed from fix/volume-cache-metadata-eperm to main October 15, 2026 06:07
deanq and others added 2 commits October 14, 2026 23:12
Hydrate now splits every big file into 8 MiB ranges and copies the ranges
of all files from one pool of 64 threads, each range on its own fds.

Object-store mounts (Global Store's geesefs) serve one sequential stream
at ~140 MiB/s, and whole-file parallelism doesn't add up. Worse, parallel
whole-file copies there fail with ENOMEM, and hydrate dropped those
files without retry. Measured on a 4090 pod in EU-RO-1, hydrating a
15.2 GB 7B model from a global volume:

  before: 9 of 11 files (2 of 4 weight shards lost to ENOMEM), 24-53s
  after:  11 of 11 files, byte-identical, 17-22s (670-844 MiB/s)

ENOMEM/EIO/EAGAIN are retried per range with backoff. Files are
assembled in a temp file and renamed only when every range copied, so
a failure leaves the local file untouched. A mirror shorter than its
manifest entry fails instead of restoring zero padding.

Tunable via hydrate_workers and hydrate_chunk_size. Sync is unchanged.
Address review feedback on #621:
- Pin each mirror file's size+mtime before its first range and reject
  the file if a range opens a replaced source, or if the mirror size
  disagrees with the manifest (a sync that landed after the manifest
  was read), instead of publishing mixed or truncated bytes.
- Generate ranges lazily and pull them from hydrate_workers threads so
  scheduling stays O(workers) instead of one future per range.
- Stop remaining ranges and remove pre-sized temp files when an
  unexpected (non-OSError) error aborts the hydrate.
- Reject non-integer and bool tuning values at construction.
@deanq
deanq force-pushed the feature/volume-cache-parallel-hydrate branch from 0037705 to 51ffc0a Compare October 15, 2026 07:14
Comment thread runpod/serverless/utils/rp_volume_cache.py Fixed
Comment thread runpod/serverless/utils/rp_volume_cache.py Fixed
Hoist the last attempt out of the retry loop so _with_range_retry has
no implicit fall-through return (CodeQL py/mixed-returns). Adds a test
pinning the attempt count when transient errors persist.
Comment thread runpod/serverless/utils/rp_volume_cache.py Fixed
Comment thread runpod/serverless/utils/rp_volume_cache.py Fixed
Comment thread runpod/serverless/utils/rp_volume_cache.py Fixed
Comment thread runpod/serverless/utils/rp_volume_cache.py Fixed
deanq added 2 commits October 15, 2026 17:41
runpod-flash 1.21.0 emits a FutureWarning on first public-API access,
which pytest turns into a conftest ImportError. Set
RUNPOD_FLASH_SILENCE_DEPRECATION=1 for the e2e step.
_with_range_retry only wraps _copy_range, which returns None, so stop
forwarding its result (CodeQL py/procedure-return-value-used).
@deanq
deanq requested a review from justinwlin October 16, 2026 00:56

This branch has not been deployed

No deployments
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.

3 participants