Skip to content

Let a ModelCache download only the files a model needs - #578

Draft
dennis-upbound wants to merge 9 commits into
modelplaneai:mainfrom
dennis-upbound:dennis/modelcache-file-filters
Draft

dennis-upbound wants to merge 9 commits into
modelplaneai:mainfrom
dennis-upbound:dennis/modelcache-file-filters

Conversation

@dennis-upbound

@dennis-upbound dennis-upbound commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Description of your changes

Fixes #530.

A ModelCache downloads every file in a HuggingFace repository. Many repositories publish the same weights more than once, as a consolidated checkpoint beside the sharded safetensors or one copy per format. mistralai/Mistral-Small-3.2-24B-Instruct-2506 holds about 90 GiB, and vLLM can serve it from the 45 GiB in Mistral's format, which leaves the cache with twice the storage and download time it needs (#457). The docs also don't say how to start a cached model's engine when HuggingFace isn't reachable (#530).

This builds on #574 and should merge after it. It's based on main, so GitHub shows #574's commits here too until #574 merges.

spec.huggingFace gets optional include and exclude lists of glob patterns. The hydration Job passes each to hf download as --include or --exclude, quoted for the shell. The Job now quotes repo and revision too. A repo ID or revision made of letters, digits, dots, dashes and slashes comes out unchanged, so an existing cache's Job doesn't change.

spec:
  source: HuggingFace
  huggingFace:
    repo: mistralai/Mistral-Small-3.2-24B-Instruct-2506
    include: ["consolidated.safetensors", "params.json", "tekken.json"]
    sizeGiB: 50

Deleting a cache before its download finished left the hydration Job on the cluster, because the Job is composed without the Delete management policy so that its own TTL can clean up its completed pod. A cache created again under the same name gets a claim of the same name, and the old Job downloaded into it. In a live run, a cache whose filters left out the weights went Ready with every weight file of the repository staged. This happens on main too, without filters: a cache recreated with another revision gets the old revision. A hydrating Job now has every management policy, so deleting the cache deletes it. Once the cluster is Ready the Job is composed without Delete until its Object reflects that, then dropped. The TTL cleanup works as before.

This is a breaking change: repo, revision, include and exclude can't change once the cache exists. The cache downloads once. An edit afterwards did nothing, and an edit during the download failed on the Job's immutable template. CEL transition rules on spec.huggingFace now reject the edit with "Create a new ModelCache to change spec.huggingFace.." I checked the rules with kubectl replace --dry-run=server against a 1.34 kube-apiserver. Changing, adding or removing any of the four is rejected. The same spec, a sizeGiB or authSecret change, a metadata update and a status update are accepted.

The docs show the filters and say to size the cache to the files it downloads. To start an engine without contacting HuggingFace, they say to set HF_HUB_OFFLINE=1 in the engine's env and to make sure the cache contains every file the engine reads. Modelplane still doesn't set the variable itself, because it breaks engines that fetch a second repository. I checked the documented setup with the hydration Job's exact command. With no network, a staged model loads by repo ID with HF_HUB_OFFLINE=1. Without it, the load retries huggingface.co for 93 seconds first.

Decisions. The source is immutable because it can't change in place without downloading again and restarting every engine that reads the cache. Creating a new cache under a new name is explicit, and makes the swap a deliberate rollout. The engines keep the old cache until the new one is ready, then restart onto it once. The docs say not to recreate a cache under its old name while engines mount it, because the new cache gets the old claim's name and can't stage until those engines stop. sizeGiB stays required, so this is towards #457, not a fix.

Towards #457.

compose-model-cache has new unit tests for the filters, the quoting, and the two steps that drop a finished Job. The end-to-end test from #574 now filters its repository and checks that the cache staged none of the weights the filters leave out. It passed on kind, and so did a manual check that the API server rejects an edit to the source. The Job deletion change has unit tests but hasn't run live yet.

An include that matches no file still marks the cache Ready with an empty snapshot. That's a follow-up.

I have:

  • Read and followed Modelplane's contribution process.
  • Run nix flake check (or ./nix.sh flake check) and made sure it passes.
  • Added or updated tests covering any composition function changes.
  • Signed off every commit with git commit -s.

🤖 Generated with Claude Code

dennis-upbound and others added 4 commits October 26, 2026 11:40
A ModelReplica that references a ModelCache can't tell from the cache
whether the model is readable on its cluster, or how to read it. It
works out the claim name itself, with a copy of the code that names the
claim, and mounts it whether or not the hydration Job has finished.

Give each cluster entry a mount: the volumes, volume mounts and
environment that read the model there. The cache sets it only while the
cluster's phase is Ready, so a replica can copy it into its engine pods
and wait until it appears. Describe the existing message field, which
says why a cluster isn't Ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A ModelCache reports each cluster's phase, but not how to read the
model there, nor why a cluster is stuck. When a claim waited on a node
the cloud had replaced, the cache said Pending for 40 minutes, and
finding the cause took someone with access to the workload cluster.

Write each cluster's mount once its phase is Ready: the claim as a
volume at /mnt/models, and HF_HUB_CACHE so an engine finds the model by
its repo ID. These are the volume, mount and variable replicas already
use, so pods built from them don't change.

Write a message for every other phase. A Pending cluster names the
claim it waits on, and the node the scheduler chose for it. A
Hydrating cluster names the repo and claim. A Failed cluster gives the
Job, how many attempts failed, and the Job's own message. A missing
token names the Secret and key.

Towards modelplaneai#494.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A replica that references a ModelCache started its engines as soon as
it was scheduled. On a fresh cluster the hydration Job was still writing
the cache, so the first engine found no model there and downloaded all
464 GB itself, in parallel with the Job.

Require the ModelCache, and read how to mount it from the entry its
status publishes for the replica's cluster. Until that entry has a
mount, a new replica composes nothing and its ModelAccepted condition
says why, using the cache's own message: hydrating, a claim waiting on
a node, a failed Job, a cluster the cache doesn't stage to, or no cache
at all.

A replica whose engines already run doesn't wait. Until its cache
publishes a mount, it keeps the cache volume, volume mount and
HF_HUB_CACHE its workload already has, read from the workload it last
composed, or no cache if it has none, and warns. So a cache that stops
being Ready or is deleted doesn't take a serving model down, and adding
a cache or switching to another doesn't restart the engines until the
new cache is Ready. MX_MODEL_REVISION and the IPC_LOCK capability
follow the mount the engines use.

The mount is the volume, mount and variable replicas used before, so
running engines don't restart on upgrade.

Fixes modelplaneai#495.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A replica now holds its engines until its cache publishes a mount, but
the docs didn't say so, or where to read why a replica is waiting.

Say how a replica waits for its cache, the messages it and the cache
report while it waits, how soon it notices the cache is ready, and that
a running replica keeps the cache it mounts until the new one is ready.

Add the design for the mount each cache publishes, the replica's wait
for it, and the messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
@github-actions

Copy link
Copy Markdown

Docs preview: https://modelplane-docs-pr-578.vercel.app (ready once the site's Content workflow finishes)

@dennis-upbound
dennis-upbound force-pushed the dennis/modelcache-file-filters branch from c3bb955 to 8ed4ce1 Compare October 26, 2026 20:59
dennis-upbound and others added 5 commits October 26, 2026 20:34
Unit tests show what the functions compose, but not that a replica
really holds its engines until the hydration Job has staged the model,
or that the pod mounts what the cache publishes.

Add an end-to-end test that binds the cache's claim to a hostPath
volume only after the replica exists. It checks that the replica waits
while the claim is Pending, then that the engine mounts the published
claim, sets HF_HUB_CACHE, and reads the staged snapshot. Each run uses
a new hostPath, so a previous run's completion marker can't make the
Job skip.

The local pool has one fake GPU, which mock-demo holds, so the test
scales mock-demo to zero while it runs. It runs only with
E2E_MODEL_CACHE=1, after the rest of the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The hydration Job downloads every file in a HuggingFace repository. Many
repositories publish the same weights more than once, such as a
consolidated checkpoint beside the sharded safetensors, or one copy per
format. Mistral-Small-3.2-24B-Instruct-2506 holds about 90 GiB, and vLLM
can serve it from the 45 GiB in Mistral's format, so the cache needs
twice the storage and twice the download time.

Add include and exclude to spec.huggingFace. Each pattern goes to
hf download as --include or --exclude, quoted for the shell. sizeGiB is
still required. The end-to-end test filters its tiny repository to
its config and tokenizer files, and checks that the cache staged none
of the weights the filters leave out.

The cache downloads once, so an edit to the source after that never
reached the cache, and an edit during the download failed on the Job's
immutable template. Make repo, revision, include and exclude immutable
with CEL transition rules, so the API server rejects the edit and says
to create a new ModelCache. repo and revision were editable before;
this is a deliberate API change.

Towards modelplaneai#457.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The hydration Job runs hf download in a shell. The include and exclude
patterns were quoted, but the repo and revision went into the command
as written, so a revision with a shell character split the command or
ran a second one.

Quote both with shlex.quote. A repo ID or revision made of letters,
digits, dots, dashes and slashes comes out unchanged, so the Job of an
existing cache doesn't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The hydration Job is composed without the Delete management policy, so
its completed pod is cleaned up by the Job's own TTL once the cache is
Ready. That also kept the Job when the ModelCache was deleted before the
download finished. The Job and the claim are named from the cache's
namespace and name, so a cache created again under the same name got a
claim of the same name, and the old Job downloaded into it. A cache whose
include and exclude left out the weights went Ready holding every weight
file of the repository, and a cache recreated with another revision got
the old revision.

Compose a hydrating Job with every management policy, so deleting the
cache deletes the Job. Once the cluster is Ready, compose the Job
without Delete until its Object reflects that, then drop it. Dropping it
then orphans the Job to its TTL, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A user running a cached model wanted its startup not to depend on
HuggingFace being reachable, and found no documented way to do it. The
docs also told users to size a cache to the model, though the cache
downloads every file in the repository.

Show include and exclude with a repository that publishes its weights
in two formats, with the vLLM flags that load that format, and say to
size the cache to the files it downloads. Say the source can't change
once the cache exists, because a change would mean downloading again
and restarting every engine that reads it. To change it, create a cache
under a new name and point the deployment at it. A cache recreated
under its old name gets its old claim's name, and can't stage while
engines still mount that claim.

Say how to start an engine without contacting HuggingFace: set
HF_HUB_OFFLINE=1 in the engine's env, and make sure the cache contains
every file the engine reads. Modelplane doesn't set it, because it
breaks an engine that fetches a second repository.

Add the file filters and the immutable source to the mount design.

Fixes modelplaneai#530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
@dennis-upbound
dennis-upbound force-pushed the dennis/modelcache-file-filters branch from 8ed4ce1 to 65f1777 Compare October 27, 2026 03:59

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.

Run a model from its ModelCache without contacting Hugging Face

1 participant