Repository navigation
Schedule replicas by free GPUs rather than free nodes - #542
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The multinode reservation issue remains unresolved in a broad scheduling and infrastructure change.
2 open issues
What changed in this PR
Implements GPU-aware scheduling using per-node free-device histograms, with cluster-agent observations, schema updates, and documentation.
Changes:
- Adds pool and replica observation resources.
- Updates scheduling to account for free GPUs.
- Installs the cluster agent in serving stacks.
- Updates schemas, tests, and documentation.
| File | Summary |
|---|---|
schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py |
Adds free-device histogram models. |
schemas/.lock.json |
Updates schema lock data. |
functions/compose-serving-stack/tests/test_fn.py |
Updates serving-stack fixtures. |
functions/compose-serving-stack/function/stacks/common.py |
Installs the cluster-agent chart. Moderate finding (4 votes): update the PR description/status to reflect the implementation. |
functions/compose-model-replica/tests/test_observation.py |
Tests replica observations. |
functions/compose-model-replica/tests/test_fn.py |
Updates replica composition tests. |
functions/compose-model-replica/function/observation.py |
Handles replica observations and scheduling status. |
functions/compose-model-replica/function/fn.py |
Integrates replica scheduling conditions. |
functions/compose-model-replica/function/backends/base.py |
Exposes replica-index labels. |
functions/compose-model-deployment/tests/test_scheduling.py |
Tests histogram-based scheduling. |
functions/compose-model-deployment/function/scheduling.py |
Implements histogram-based placement. Moderate finding (2 votes): preserve distinct-node reservations for multinode gangs. |
functions/compose-model-deployment/function/fn.py |
Supplies scheduling time input. |
functions/compose-inference-cluster/tests/test_fn.py |
Tests pool observation behavior. |
functions/compose-inference-cluster/function/fn.py |
Publishes pool observations. |
docs/content/platform/inference-cluster.md |
Documents agent pool labels. |
docs/content/overview/how-it-works.md |
Updates the scheduling overview. |
docs/content/architecture/scheduling.md |
Documents histogram accounting. |
design/free-gpu-scheduling.md |
Adds the scheduling design. |
apis/inferenceclusters/definition.yaml |
Extends the InferenceCluster status schema. |
Review effort: Lite
a3dbd92 to
1fd0c8f
Compare
dennis-upbound
left a comment
There was a problem hiding this comment.
Nice approach. The histogram of nodes by free devices is a clean answer to #172, and the allocation times neatly settle when a pool's view counts a replica. Most of this is about what node sharing newly exposes. I've left out what Chris and Copilot already raised.
Design
- Mixed replica sizes can overcommit a pool permanently, because neither scheduler packs pods. The fleet scheduler predicts each pod goes to the node with the most free devices, and the cluster's scheduler doesn't pack DRA devices. Take a pool of four 4×L4 nodes running four 1-GPU replicas, probably one per node. 12 GPUs are free, but a 4-GPU replica fits on no node. It stays Pending, and Modelplane doesn't move it. Whole-node accounting couldn't overcommit like this. A P/D pair also isn't held to the one 8-GPU node the scheduler counted it against. Could the design state how pods are packed, and consider preferred pod affinity on
kubernetes.io/hostnameamong Modelplane GPU pods, and affinity for P/D pairs? Either way, I'd bring "move a replica whose pods can't schedule" forward from Future improvements, or keep a known-limitations entry inscheduling.mdto replace the #172 one this PR removes. - Sharing makes the CPU, memory and surge gaps real. Engine pods don't request CPU or memory, so two engines on one node compete for host memory (
/dev/shm, offload), and the kubelet evicts them first under pressure. Engine workloads also roll with the default 25% surge, so on a node that sharing has filled to all four GPUs, any template change needs a surge pod with a GPU that doesn't exist. On a pool that can't grow, the rollout sticks. I'd do proportional CPU and memory requests in this PR, and size or disable surge for GPU pods. - Growth is counted even when the agent knows the pool can't grow. On GKE and Nebius, DRA pods can't trigger scale-up from zero (#521). A pool with
observedNodes: 0looks likenodesempty nodes. Every replica placed there is Pending until something else grows the pool. The agent already reportsScaleUpBlocked. I'd count no growth while it's True. - Other tenants and taints. GPUs that other workloads hold through the device plugin (
nvidia.com/gpu) aren't counted as taken. Nodes with NoSchedule or NoExecute taints Modelplane pods don't tolerate, such asToBeDeletedByClusterAutoscaler, count as free.design.mdstill says Modelplane has the cluster's GPUs to itself, which this relaxes. - Partitioned and shared devices. Free devices are counted by device ID, ignoring shared counters. So a GPU with one MIG slice allocated counts as free, and a device that allows multiple allocations counts as full after one claim. I'd declare both out of scope, or mark such pools as unobservable.
- The timestamp scheme assumes one agent writes. The chart runs one replica with no leader election. After a node partition, two agents can run at once. Each records allocation times in its own memory, and together they can double-book a replica's GPUs. A Lease, and a sentence stating the single-writer requirement, would cover it.
Code (details inline)
- The fit depends on the order of a copy's pods.
- A partly scheduled replica is charged on top of the histogram.
PodsScheduledcounts finished pods.- More than 256 buckets fails the whole status write rather than falling back.
- A settled replica whose
countgrows isn't charged for the extra devices. - PoolObservation
Objects can block InferenceCluster deletion. - Smaller ones:
- A pool name that isn't a valid object name, like
GPU_pool, silently falls back to whole-node accounting. - A new node whose DRA plugin hasn't published ResourceSlices yet briefly doesn't show any free devices.
- A pool name that isn't a valid object name, like
Before merge
- The agent can't be pulled anonymously.
oci://xpkg.upbound.io/modelplane/cluster-agent:0.1.0returns 404, and every InferenceCluster goes NotReady until it resolves. - The agent repo is private. An open-source Modelplane would depend on it.
- The fields this reads exist only on the agent's
joint-accountbranch. Itsmainhas nolastAllocationTime. - The branch is behind
main. The diff againstmainrevertsdcgm.pyand other files.
I reproduced 7 and 8 with small scripts against the scheduler, and checked 14 to 16 directly. The rest is from reading the code here and on the agent's joint-account branch.
1fd0c8f to
f04ee93
Compare
negz
left a comment
There was a problem hiding this comment.
Replies to the design points in your review. I've replied to the code points inline.
-
You're right that this PR lets a pool overcommit where whole-node accounting refused. The scheduler refuses your four-replica example, because the histogram doesn't show a node with four free. But placing a 4-GPU replica and two 1-GPU replicas on two nodes before any schedule can strand the big one. I'd rather not steer the cluster with preferred pod affinity. The cluster's scheduler only weighs it against its other scores, so a pod can still go to another node, and inter-pod affinity slows scheduling in large clusters. I've added a known limitation to
scheduling.md. If mispredictions like this turn out to be common, I'd rather rethink how the fleet scheduler places replicas than move them after the fact. -
Agreed on both, though neither is new. The cluster could already pack pods onto one node, and a replica claiming a whole node already needed a second node to surge. Proportional CPU and memory requests stay the future improvement the design lists. The agent would have to report each pool's allocatable CPU and memory, and they'd need a policy for how much to request. I'd keep surge too. A Deployment whose surge pod can't fit stalls with the old pod still serving. Disabling surge would take every replica of a deployment down at once, since they all roll together. I've noted both in
scheduling.md. -
The scheduler counted growth this way before this PR too. #521 isn't quite this. The pool did scale from zero, then stopped adding nodes without an event saying why, so the agent would report
ScaleUpBlockedUnknown rather than True.ScaleUpBlockedis also True when one pinned pod couldn't use a new node, like one claiming more than a node has, so counting no growth would stop the pool growing for every deployment on it. I'd leave this until we know how often pools stock out. -
Good catch on taints. The agent now leaves out nodes with a NoSchedule or NoExecute taint that Modelplane's GPU pods don't tolerate, which is anything but
nvidia.com/gpuwith NoSchedule. That coversToBeDeletedByClusterAutoscaler. GPUs held through the device plugin are outside what Modelplane supports, and the serving stack turns the plugin off where it installs the GPU operator. With both running, two allocators keep separate books over the same GPUs, which the cluster's own scheduler can't account for either. The agent reports a pool's extended resources, so it's visible. Counting other workloads' DRA claims as taken tolerates them, but Modelplane still assumes exclusive control of the cluster's GPUs, so I'd leavedesign.mdas it is. -
Modelplane doesn't support MIG or other partitioned GPUs yet, so I'd leave both until it does. Counting by name is right for whole GPUs, which is all Modelplane claims today. A device that allows several allocations counts as taken after the first, which errs toward the pool looking full.
-
Good catch, I've enabled leader election.
f5def04 to
3f27922
Compare
|
@dennis-upbound following up on the rest of your review. On 13, pool names must now be DNS labels, so the InferenceCluster's schema rejects a name like On 14 to 17, the agent's v0.1.0 is released and its chart and image pull anonymously, the repo is public, |
3f27922 to
e574dcf
Compare
|
I tested this on EKS: a control plane built from this branch with
provider-kubernetes stops receiving watch events once the EKS kubeconfig token expires, after 15 minutes (crossplane-contrib/provider-kubernetes#598). After that the agent's reports only arrived on the 10-minute poll, so a freed GPU took about 8 minutes to become schedulable. c2fdafa polls the observations every 30 seconds until that's fixed. I haven't tested that commit on a cluster. As expected, two deployments waiting on a full pool both placed a replica on the same freed GPU, and one stayed Pending. |
There was a problem hiding this comment.
Thanks for working through the last round. Leaving out tainted nodes and turning on leader election close the gaps I was most worried about, and the known limitations in scheduling.md read well. I agree the scheduler follow-ups can come later. There's one thing I'd fix before merge and one small one, both inline, plus a nit below.
One nit outside the diff: in functions/compose-model-replica/function/backends/base.py, the comment on LABEL_DEPLOYMENT, LABEL_REPLICA and LABEL_ENGINE (line 261) says nothing selects on these labels, but the ReplicaObservation's pod selector now selects on all three. If they're renamed in a telemetry change, every replica on a pool the agent reports on would quietly be charged twice. Worth updating the comment.
The fleet scheduler charges a whole node for every pod that claims a GPU, so it stops placing replicas on a pool while its nodes still have GPUs free. This design proposes fitting pods against a per-pool histogram of nodes by free devices, reported by an agent on each InferenceCluster, while leaving the choice of node to each cluster's scheduler. Towards modelplaneai#172. Signed-off-by: Nic Cope <nicc@rk0n.org>
An InferenceCluster publishes the most nodes each of its pools can have, but not which of their devices are free. This commit adds the nodes a pool has now, a histogram of its schedulable nodes by free devices, and the latest time the agent first saw a claim allocated on the pool, all of which the cluster agent reports. It adds to ModelReplica the pools its devices are on, with when the agent first saw the replica's latest claim allocated on each, so the scheduler can tell whether a pool's histogram accounts for a replica's devices yet. A pool's name must be both a label value, for its nodes' modelplane.ai/pool label, and an object name, for the PoolObservation the agent observes it through. This commit requires the DNS label pattern other Modelplane XRDs use for names, which satisfies both. It also adds the agent's CRDs as a schema dependency, so the functions can read what it reports through generated models. Signed-off-by: Nic Cope <nicc@rk0n.org>
Signed-off-by: Nic Cope <nicc@rk0n.org>
The fleet scheduler can only see what an InferenceCluster declares about its pools, not which of their devices are free. The cluster agent observes that on the cluster, along with whether each replica's pods have scheduled, and reports both in the status of PoolObservations and ReplicaObservations. This commit installs the agent on every cluster, including those Modelplane didn't provision. Signed-off-by: Nic Cope <nicc@rk0n.org>
The fleet scheduler reads an InferenceCluster's pools from its status, which only says how many nodes each pool can have. This commit has the InferenceCluster create a PoolObservation on the cluster for each of its GPU pools, once the serving stack has installed the agent, and publish what the agent reports in it. A pool the agent hasn't reported on, can't observe, or reports a status for that doesn't match the agent's schema publishes none of it, so the pool can still be counted in whole nodes as before. Signed-off-by: Nic Cope <nicc@rk0n.org>
The fleet scheduler will fit replicas against each pool's histogram of free devices, which counts the devices a replica's pods hold only once they've scheduled and the agent has observed the pool since. It needs to know which replicas that is, so it can count the rest against the pool itself. This commit has each ModelReplica create a ReplicaObservation for its engine pods, selected by the deployment and replica index they carry. It relays what the cluster agent reports as a ModelPodsScheduled condition, and the pools the agent saw the replica's claims allocated on, with when, as status.pools. A status that doesn't match the agent's schema counts as no observation. The replica creates its ReplicaObservation only once its cluster publishes an observation of one of the replica's pools, because the agent installs the ReplicaObservation CRD, and an Object of a kind the cluster doesn't serve can't be deleted. It would hold up the replica's deletion. Signed-off-by: Nic Cope <nicc@rk0n.org>
The fleet scheduler charged a whole node for every pod that claims a GPU, however many of the node's GPUs it claims. A one-GPU replica on a pool of four-GPU nodes left three idle, and a prefill and decode replica that fits on one 8-GPU node needed two. This commit fits each pod against how many of a pool's nodes have each set of free devices, so pods can share a node with the devices for them, including a gang's pods. Where the cluster agent reports on a pool, that's its histogram plus the nodes the pool can still grow by. The histogram counts the devices scheduled pods hold as taken, so to avoid counting them twice the ledger charges a replica's pods on a pool only until they've all scheduled and the replica's time for the pool is no later than the pool's. Where the agent doesn't report on a pool, every node starts empty and a pod that claims any device takes a whole one, as before. The scheduler can't know which node the cluster's scheduler will bind a pod to. It assumes the one with the most of the claimed devices free, and fits a replica's smallest pods first, approximating the worst order the cluster's scheduler could bind them in. A wrong guess leaves a pod Pending until devices free up. Fixes modelplaneai#172. Signed-off-by: Nic Cope <nicc@rk0n.org>
The architecture and overview pages said the scheduler counts capacity in whole nodes, and listed charging a whole node per pod as a known limitation. This commit describes counting the free devices the cluster agent reports instead, including the fallback to whole nodes for a pool it doesn't report on. It drops the old limitation and lists four more. A wrong prediction can leave a replica Pending, a pool looks fuller than it is while its agent is down, engines that don't request CPU and memory compete for them on a shared node, and a rollout can need room for a new pod. Signed-off-by: Nic Cope <nicc@rk0n.org>
provider-kubernetes stops receiving watch events for an Object once the token in its cluster's kubeconfig expires, which on EKS is after 15 minutes (crossplane-contrib/provider-kubernetes#598). The PoolObservations and ReplicaObservations it watches then only update when it polls them, every 10 minutes by default, so the scheduler can work from what the agent reported up to 10 minutes ago. Testing on EKS, a GPU freed by scaling down took about 8 minutes to become schedulable. Signed-off-by: Nic Cope <nicc@rk0n.org>
c2fdafa to
6f95335
Compare
|
@dennis-upbound thanks! I've also fixed the comment on |
There was a problem hiding this comment.
🔵 Needs a closer look
Approval should be deferred because the change introduces complex cluster-wide agent, Helm/CRD, DRA allocation, and fleet-placement behavior that static review cannot validate without live EKS runtime testing.
No open issues 🎉
2 resolved since last review
Review effort: Lite


Description of your changes
Fixes #172.
The fleet scheduler charges a whole node for every pod that claims a GPU, however many of the node's GPUs it claims. A one-GPU replica on a pool of four-GPU nodes leaves three idle, and a prefill and decode replica that fits on one eight-GPU node needs two.
This PR proposes scheduling by free GPUs in
design/free-gpu-scheduling.md, and builds it. The serving stack installs the cluster agent on every InferenceCluster. The agent reports a histogram of each pool's nodes by free devices, which the InferenceCluster publishes instatus.gpuPools. The scheduler fits each pod against it. A pool the agent doesn't report on falls back to whole-node accounting.The histogram counts the devices scheduled pods hold as taken. To avoid counting them twice, the scheduler charges a replica's pods on a pool only until the histogram accounts for them. The agent records when it first saw each ResourceClaim allocated, and publishes the latest such time per pool on both the InferenceCluster and the ModelReplica. The histogram accounts for a replica once its
ModelPodsScheduledcondition is true and its time for the pool is no later than the pool's.The scheduler can't know which node the cluster's scheduler binds each pod to, so a wrong guess can leave a pod Pending until devices free up.
docs/content/architecture/scheduling.mdlists this and the other known limitations. InferenceCluster pool names must now be DNS labels, because each is also the name of the PoolObservation the agent reports through.I tested an earlier version on EKS with one g5.12xlarge (four A10Gs). Four one-GPU replicas shared the node, the scheduler refused a fifth, and scaling down freed a GPU for the next replica. That version stopped charging replicas on a one-minute timer rather than by allocation time, so it needs testing again.
I have:
nix flake check(or./nix.sh flake check) and made sure it passes.git commit -s.