feat(client): load models from the server cache when workers have no shared storage - #592
Conversation
782a3ac to
dd3fcc2
Compare
WalkthroughChangesServer-backed model caching
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
modelexpress_client/python/tests/test_model_prefetch.py (2)
65-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRename the
fake_clientfixture to reflect its return value.The fixture installs the fake client, but it returns the snapshot
Path. Assertions then readensure_metadata(REPO) == fake_client, which suggests a client comparison. A name such asinstalled_snapshotmakes the assertions self-describing.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@modelexpress_client/python/tests/test_model_prefetch.py` around lines 65 - 76, Rename the fake_client fixture to installed_snapshot, reflecting that it returns the snapshot Path rather than a client, and update all test parameters and assertions that reference fake_client accordingly.
177-218: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the sleep-based ordering with an explicit event.
The test depends on the second thread starting within the 0.3 s install window. On a loaded runner, that window can be missed, and
installs == [REPO]can then fail. Anthreading.Eventmakes the interleaving deterministic and removes the fixed sleeps.♻️ Proposed deterministic synchronization
snapshot = tmp_path / "models--org--model" / "snapshots" / ("a" * 40) snapshot.mkdir(parents=True) installs = [] + install_started = threading.Event() + release_install = threading.Event() class SlowClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *exc_info): return None def install_metadata_snapshot(self, repo_id, *args, **kwargs): installs.append(repo_id) - time.sleep(0.3) + install_started.set() + release_install.wait(timeout=5) return snapshot monkeypatch.setattr("modelexpress.model_client.ModelCacheClient", SlowClient) results = {} - def call(tag, delay): - time.sleep(delay) - results[tag] = model_prefetch.ensure_metadata(REPO) + def call(tag, wait_for_install): + if wait_for_install: + install_started.wait(timeout=5) + results[tag] = model_prefetch.ensure_metadata(REPO) threads = [ - threading.Thread(target=call, args=("first", 0.0)), - threading.Thread(target=call, args=("second", 0.05)), + threading.Thread(target=call, args=("first", False)), + threading.Thread(target=call, args=("second", True)), ] for thread in threads: thread.start() + install_started.wait(timeout=5) + release_install.set() for thread in threads: thread.join()Remove the now-unused
timeimport if no other test needs it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@modelexpress_client/python/tests/test_model_prefetch.py` around lines 177 - 218, Update test_second_caller_waits_and_gets_the_same_snapshot to coordinate the two threads with a threading.Event: signal when install_metadata_snapshot begins, then have the second caller wait for that signal before invoking ensure_metadata. Remove the delay-based time.sleep calls and delete the time import if unused, preserving the assertions that both callers receive the same snapshot and only one install occurs.modelexpress_client/python/tests/test_server_cache_strategy.py (1)
227-250: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the chain order from behavior instead of source text.
The test inspects the source of
LoadStrategyChain.runand compares substring positions. It fails on formatting-only edits, and it still passes if the ordering later moves out ofrun. Drive the chain instead and record the order in which strategies are attempted.One approach: patch
LoadStrategy.is_availableto returnTrueandLoadStrategy.loadto appendself.nameand raiseStrategyFailed(..., mutated=False), then assert the recorded names. The chain raisesRuntimeErrorat the end, which the test can expect.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@modelexpress_client/python/tests/test_server_cache_strategy.py` around lines 227 - 250, The test test_sits_between_rdma_and_local_strategies should verify runtime strategy-attempt order instead of inspecting LoadStrategyChain.run source text. Patch LoadStrategy.is_available to allow every strategy, patch LoadStrategy.load to record self.name and raise StrategyFailed with mutated=False, execute the chain while expecting its final RuntimeError, then assert the recorded names are RdmaStrategy, ServerCacheStrategy, InstantTensorStrategy, and DefaultStrategy in that order.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/DEPLOYMENT.md`:
- Around line 742-746: Add `MODEL_EXPRESS_URL` and `MX_SERVER_ADDRESS` as
separate rows in the deployment environment-variable table, documenting their
roles as the server address options required alongside
`MODEL_EXPRESS_NO_SHARED_STORAGE`. Preserve the existing rows and the
`MODEL_EXPRESS_TRANSFER_CHUNK_SIZE` default.
In `@modelexpress_client/python/modelexpress/model_client.py`:
- Around line 87-90: Update the channel options in the client initialization to
include gRPC keepalive settings that detect silent or dropped peers without
imposing fixed deadlines on the streaming RPCs used by ensure_downloaded and
_stream_into. Add an appropriate bounded deadline to the unary list_files call,
while preserving the existing message-size limits and streaming behavior.
- Around line 86-96: Update the channel construction in the ModelCacheClient
stub initialization so the token added by auth.with_auth is transmitted over
encrypted TLS transport instead of grpc.insecure_channel. Configure and pass
appropriate TLS credentials, or enforce an mTLS/TLS proxy requirement for
authenticated deployments, while preserving the existing message-size options
and ModelServiceStub setup.
In `@modelexpress_client/python/modelexpress/model_snapshot.py`:
- Around line 272-292: Update the exception handler surrounding the staged
snapshot replacement in the snapshot commit method to catch BaseException rather
than Exception, while preserving the existing stale_path restoration condition
and re-raising behavior so interruptions also restore the displaced snapshot.
In `@modelexpress_client/python/tests/test_model_snapshot.py`:
- Around line 144-151: Update test_discard_removes_staging to capture
staging.path before calling staging.discard(), then assert that captured path no
longer exists afterward. Remove the post-discard _staging_path guard while
preserving the existing snapshots-directory assertion.
---
Nitpick comments:
In `@modelexpress_client/python/tests/test_model_prefetch.py`:
- Around line 65-76: Rename the fake_client fixture to installed_snapshot,
reflecting that it returns the snapshot Path rather than a client, and update
all test parameters and assertions that reference fake_client accordingly.
- Around line 177-218: Update
test_second_caller_waits_and_gets_the_same_snapshot to coordinate the two
threads with a threading.Event: signal when install_metadata_snapshot begins,
then have the second caller wait for that signal before invoking
ensure_metadata. Remove the delay-based time.sleep calls and delete the time
import if unused, preserving the assertions that both callers receive the same
snapshot and only one install occurs.
In `@modelexpress_client/python/tests/test_server_cache_strategy.py`:
- Around line 227-250: The test test_sits_between_rdma_and_local_strategies
should verify runtime strategy-attempt order instead of inspecting
LoadStrategyChain.run source text. Patch LoadStrategy.is_available to allow
every strategy, patch LoadStrategy.load to record self.name and raise
StrategyFailed with mutated=False, execute the chain while expecting its final
RuntimeError, then assert the recorded names are RdmaStrategy,
ServerCacheStrategy, InstantTensorStrategy, and DefaultStrategy in that order.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 5203ad66-2fee-437f-8550-c44854f140d1
📒 Files selected for processing (22)
docs/ARCHITECTURE.mddocs/DEPLOYMENT.mdmodelexpress_client/python/generate_proto.shmodelexpress_client/python/modelexpress/__init__.pymodelexpress_client/python/modelexpress/engines/vllm/loader.pymodelexpress_client/python/modelexpress/engines/vllm/patches/__init__.pymodelexpress_client/python/modelexpress/engines/vllm/patches/patch_hf_snapshot_prefetch.pymodelexpress_client/python/modelexpress/envs.pymodelexpress_client/python/modelexpress/load_strategy/__init__.pymodelexpress_client/python/modelexpress/load_strategy/server_cache_strategy.pymodelexpress_client/python/modelexpress/model_client.pymodelexpress_client/python/modelexpress/model_pb2.pymodelexpress_client/python/modelexpress/model_pb2_grpc.pymodelexpress_client/python/modelexpress/model_prefetch.pymodelexpress_client/python/modelexpress/model_snapshot.pymodelexpress_client/python/tests/test_envs.pymodelexpress_client/python/tests/test_hf_snapshot_prefetch_patch.pymodelexpress_client/python/tests/test_model_client.pymodelexpress_client/python/tests/test_model_prefetch.pymodelexpress_client/python/tests/test_model_snapshot.pymodelexpress_client/python/tests/test_server_cache_strategy.pymodelexpress_client/python/tests/test_vllm_loader.py
|
/ok to test dd3fcc2 |
|
/ok to test 422df8b |
AndyDai-nv
left a comment
There was a problem hiding this comment.
Thanks @scydas, lgtm, need your quick look @zhengluo-nv as well to see whether it is good to close 569
…shared storage Signed-off-by: scyda <chenyang.shi@daocloud.io>
…acing it publish() swapped the whole snapshots/<commit>/ directory when the metadata manifest did not match what was on disk. The manifest covers non-weight files only, so an already-installed weight set was outside every check but inside the directory being deleted. The commit hash comes from the server resolving main, so a re-install targets the same directory: an in-place overwrite, not a new revision. A mirror gaining chat_template.jinja was enough to drop 15 GB of weights and force every worker to re-stream them. Same commit means same content, so the merge only ever adds files, one atomic rename at a time. Signed-off-by: scyda <chenyang.shi@daocloud.io>
- keepalive on the ModelService channel: no RPC here carries a deadline because a cold-cache download is legitimately slow, so a silently dropped connection would hang the engine's startup path with the pod neither ready nor crash-looping - restore the moved-aside snapshot on BaseException, not just Exception, so a KeyboardInterrupt cannot strand a .modelexpress-stale-* directory - fix a vacuous assertion in test_discard_removes_staging: the conditional expression reduced it to assert True once discard() cleared _staging_path - document MODEL_EXPRESS_URL and MX_SERVER_ADDRESS, which is_enabled() requires - docstrings for the remaining public entry points Signed-off-by: scyda <chenyang.shi@daocloud.io>
ai-dynamo#598 added an optional revision to ModelDownloadRequest and ModelFilesRequest and resolved_revision to ModelStatusUpdate. The committed Python binding predates that change, so regenerate it with generate_proto.sh. p2p_pb2 regenerates byte-identical, which confirms the diff is the proto change and not a toolchain difference. No client code reads the new fields yet. Signed-off-by: scyda <chenyang.shi@daocloud.io>
ai-dynamo#598 added an optional revision to ModelDownloadRequest and ModelFilesRequest, so "the model RPC carries no revision" is no longer true. ARCHITECTURE.md contradicted itself: one section described the server resolving a revision to an immutable commit, another said the protocol had none. The limitation itself stands, but its cause moved: the field exists and this client leaves it unset, so every request is unpinned. Restate it that way in both docs, in the _warn_on_revision_mismatch docstring, and in the warning it logs. Signed-off-by: scyda <chenyang.shi@daocloud.io>
SnapshotPatch recorded only the target path, so rollback unlinked what it had published and stopped there. Publishing a shard over an existing one destroys the old copy at the rename, which means a refresh that failed on a later shard left the snapshot short of a shard it already had -- worse than the partial weight set the rollback exists to prevent. _finalize now moves an existing target aside before the rename, rollback puts it back, and commit() drops the backups once the whole patch has landed. Committing also clears the published list, so a rollback after a successful commit cannot delete the files it just installed. Signed-off-by: scyda <chenyang.shi@daocloud.io>
The metadata phase called EnsureModelDownloaded with ignore_weights false, so a cold server fetched the whole weight set before the strategy chain could look for a P2P source. A live source then avoided the weight stream but not that upstream download, nor the startup delay it cost. Sending ignore_weights was previously unsafe: the registry keyed on the model name alone, so a metadata-only download registered the model as complete and short-circuited every later weight fetch. ai-dynamo#598 folded the weight mode into the entry key, so a metadata-only claim no longer satisfies a full-weight request and the weight phase gets its own download. Signed-off-by: scyda <chenyang.shi@daocloud.io>
422df8b to
7d77085
Compare
resolve_snapshot decided reuse from the paths and sizes in ListModelFiles, and that response carries no commit identity. A server whose default revision moved to one with the same file names and sizes was indistinguishable from the copy on disk, so the stale snapshot was returned and the stream whose first chunk would have caught it never opened. Reuse now takes the revision the server reported and refuses unless it equals refs/main; no reported revision means no reuse. Every call after the first in a phase is pinned to that revision, so the manifest and the stream cannot come from different commits. The weight path has the same hole: has_files compares names and sizes only, so weights left by an earlier revision satisfy its shortcut. When the server reports a revision it is now checked before that shortcut; when it names none, the shortcut still cannot tell revisions apart and the stream's first-chunk commit check remains the only guard there. A server that already holds an unpinned model reports no revision, so reuse does not fire there and the metadata is restreamed. That is correct but gives up the fast path; docs/ARCHITECTURE.md promises the resolved commit is always reported, which the already-downloaded path does not honour. Restoring the fast path is a server-side follow-up. Signed-off-by: scyda <chenyang.shi@daocloud.io>
The metadata-only claim is only safe against servers that key their registry entries on the weight mode. An older server records the claim against the model name alone, which marks the model complete: the weight phase then finds nothing left to fetch and an offline worker cannot start. The ensure_downloaded docstring stated the new keying as if every server had it, and DEPLOYMENT.md named no server requirement at all. Spell the requirement out in both places: the fallback needs a server from a release newer than v0.5.0. Signed-off-by: scyda <chenyang.shi@daocloud.io>
|
/ok to test 645d969 |
|
Approving as my understanding is the server_cache_strategy is disabled by default |
Docs-only conflict. ai-dynamo#592 inserted ServerCacheStrategy at p1, renumbering the load-strategy table to p0-p5, while this branch adds load_aware to the p0 row's selector list. Kept main's new table and restored load_aware in that list. Signed-off-by: Yixin Huang <yixinh@nvidia.com>
Docs-only conflict, same as on the load-aware branch: ai-dynamo#592 inserted ServerCacheStrategy at p1 and renumbered the load-strategy table to p0-p5, while this branch adds topology_aware to the p0 row's selector list. Kept main's new table and restored topology_aware in that list. Signed-off-by: Yixin Huang <yixinh@nvidia.com>
What
Part of #569 — deliberately not a full close. The issue lists five things the fallback should cover:
--load-format mx/modelexpressremote_instancewith the ModelExpress backendLets a vLLM worker get both the model metadata and the weights from the ModelExpress Server, so the fallback chain becomes P2P source → server cache/download → existing local/native fallback and a worker never needs direct Hugging Face access.
The metadata and the weights are fetched at different times, because the engine asks for them at different times:
EngineArgs.__post_init__→get_model_path→snapshot_downloadMxModelLoader.load_model→LoadStrategyChainOnly T2 reaches the strategy chain. T0 happens while engine args are still being parsed — before any loader object exists — so a strategy-chain-only implementation cannot serve it. P2P does not help either: it transfers GPU tensors and never repository files, so every worker needs local metadata regardless of whether P2P later hits.
Hence the split:
snapshot_download. No weight moves on this path, so P2P keeps first refusal on the part that matters.ServerCacheStrategyis inserted afterRdmaStrategy, so it only runs on a P2P miss.New modules (all under
modelexpress_client/python/modelexpress/):model_client.py—ModelCacheClient, wrappingEnsureModelDownloaded/ListModelFiles/StreamModelFiles, with stream-protocol validation (chunk offset continuity,is_last_filesemantics, commit-hash agreement) and two install entry points: metadata-only and weights-only.model_snapshot.py— the Hugging Face cache layout:models--<org>--<name>/{refs/main, snapshots/<commit>/},flock-guarded staging, per-file rename on publish, andSnapshotPatch.rollback()so an interrupted weight install leaves no partial files.model_prefetch.py— T0 orchestration, the enable check, and repo-id recovery from a cache path.load_strategy/server_cache_strategy.py— the strategy itself;requires = (EngineAdapter.load_via_native,).engines/vllm/patches/patch_hf_snapshot_prefetch.py— takes oversnapshot_downloadacrosshuggingface_hub._snapshot_download/hf_api/huggingface_hub; a prefetch error is logged as a warning and delegates to the original, never blocking startup.Also:
generate_proto.shnow generatesmodel_pb2as well asp2p_pb2(the Python client previously did not consumeModelServiceat all).MODEL_EXPRESS_NO_SHARED_STORAGE(the switch),MODEL_EXPRESS_CACHE_DIRECTORY,MODEL_EXPRESS_TRANSFER_CHUNK_SIZE.engines/vllm/loader.py:download_modelreturns early when the feature is on, since the snapshot is already in place.docs/ARCHITECTURE.md,docs/DEPLOYMENT.md.Everything is behind
MODEL_EXPRESS_NO_SHARED_STORAGE; with the switch off, none of this code runs (verified on hardware, see below).model_snapshot.pycarries its own weight-suffix list mirroringproviders.rs, because the split has to happen client-side: the metadata phase needs everything except weights and the weight phase needs the inverse, and only the former is expressible server-side. #581 has since landedis_weight_fileplus anignore_weightsflag onModelFilesRequest, so the metadata half could stop duplicating the list — worth a follow-up issue rather than widening this PR into a filtering-protocol change.SGLang is partly covered already: its
nixltransport runs the sameLoadStrategyChain, andSglangAdapterimplementsload_via_native, soServerCacheStrategyis live there with no extra code. Two gaps remain for a separate PR — thetransfer_enginetransport falls straight to a native load without consulting the chain, and SGLang has novllm.general_pluginsequivalent, so T0 needs an explicit entry point.The full list of requirements and operational limits lives in
docs/DEPLOYMENT.md(Server-Backed Model Cache), and the phase split and stream/rollback contract indocs/ARCHITECTURE.md. Repeating the three that need a reviewer's judgement rather than a reader's awareness — the first two are the open half of #569's fourth bullet:main. Reproduced on hardware: DGD pinneda338b55, server had7ae5576— the client warns and refuses to mix revisions rather than silently serving the wrong one. Fixing this needs a proto change plus a registry key change, which is why it is not folded in here.HF_TOKENand the worker never talks to Hugging Face, so the path should work by construction, but it has not been exercised on hardware and is not claimed.Why
Without this, a worker with no shared storage still has to reach Hugging Face itself for config and tokenizer even when the ModelExpress Server already holds the model, and it has no way to get weights from the server when P2P misses (cold start, first replica, no rank match). The server cache existed but nothing in the Python engine path could use it.
The design constraint that shaped this is that P2P and repository files are disjoint. It is tempting to make the whole thing one strategy, but the engine resolves the model path long before any strategy runs, and it fails at
parse_argswithLocalEntryNotFoundErrorif the cache layout is not already valid. So the metadata half has to be earlier, and it has to be unconditional.Testing
Unit: 1205 passed / 46 skipped for the full Python suite. New test files cover the client's stream protocol, the snapshot layout and rollback, the prefetch, the
snapshot_downloadpatch, and the strategy.On real hardware — 2 nodes, 1× A800 80GB each, vLLM via Dynamo DGD, ModelExpress Server 0.4.0 with an internal HF mirror. Nine scenarios, every one re-run against this branch's single commit after the review fixes were folded in. "upstream" counts Hugging Face fetches by the server; "streams" counts
StreamModelFilescalls served from the server's own cache, split into the metadata phase and the weight phase.COMPLETE in 11.83s, READYCOMPLETE in 1.75sflock, zero leftoversRows 6A and 6B show the dedup boundary: one upstream fetch no matter how many workers race, but one stream per worker in each phase. That is the cross-pod limit noted above, measured.
Row 3 is the one that carries the design claim.
server-cacheappears inEligible loaders, so it could have run, butrdmais tried first and wins — and the server's own log for that window shows exactly one stream,Found 7 files, the metadata set. Not one weight byte came from the server while a P2P source was alive. Fetching metadata early does not weaken P2P-first.Cross-process state loss
server-cacheinitially filtered itself out withNo Hugging Face repo id for '/home/dynamo/.cache/.../snapshots/7ae5576...', skipping server cache. vLLM rewritesModelConfig.modelin place with the resolved path and loads weights in a separate EngineCore process, so the in-processpath → repo idmap recorded at T0 is empty where the strategy runs. Unit tests could not catch this because they run in one process. Fixed by deriving the repo id deterministically from themodels--<org>--<name>directory name;TestRepoIdFromCachePathnow pins that behaviour with the reasoning in its docstring.Summary by CodeRabbit
New Features
ModelCacheClientfor managing cached models.Documentation
Tests