Skip to content

Install each device stack only where its driver is claimed, adding CPU pools and AMD serving - #556

Open
haarchri wants to merge 19 commits into
modelplaneai:mainfrom
haarchri:feature/multi-accelerator-cpu-dra
Open

haarchri wants to merge 19 commits into
modelplaneai:mainfrom
haarchri:feature/multi-accelerator-cpu-dra

Conversation

@haarchri

@haarchri haarchri commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Description of your changes

Design: #432

The serving stack installed NVIDIA's device stack on every cluster and no other vendor's anywhere: every component list pinned the NVIDIA operator or DRA driver unconditionally, nothing checked that the devices a class declares match the stack its cluster gets, and a pool without a GPU couldn't be provisioned at all.

This implements the multi-accelerator design. The cluster composition collects the DRA drivers claimed across a cluster's referenced classes (claim: DRA only; Synthetic devices describe placement, not a stack) onto ServingStack spec.draDrivers; components carry the drivers they serve as dra_drivers tags - hand-written in place, fail-closed through ALLOW for the generated clouds and the join installs a tagged component only when one of its drivers is claimed. The supported-drivers table is derived from the tags (plus EKS's declared-external dra.net), mirrored by hand in
compose-inference-cluster, which gates ClusterReady=False / UnsupportedDevices before anything is provisioned.

On top of the mechanism:

  • CPU inference (Docs: Add design for CPU inference class support #509, rebased onto the mechanism): dra-driver-cpu is one chart tagged dra.cpu, pinned once in common.py for every cloud including Existing, with scoped tolerations, accelerator becomes optional in every provisioning block; a class without one composes a CPU-role pool with the modelplane.ai/cpu label and taint on all six clouds status.gpuPools becomes status.schedulablePools: nothing in it was GPU-specific.

  • AMD on BYO clusters: the AMD GPU operator (DRA mode, driver management off) is pinned on the Existing list tagged gpu.amd.com - the pin validated on 8x MI325X bare metal in Provision Vultr bare metal servers into k3s inference clusters, composing multi-vendor GPU serving stacks #434, which rebases onto this mechanism. NFD carries both GPU drivers' tags there; engine pods tolerate the per-driver taint list (the pool nodeSelector is what places them).

  • The cloud e2e legs shape one KWOK pool per claimed device stack, an NVIDIA and a CPU pool on every leg, an AMD pool on Existing and stamp spec.draDrivers, so the placement and toleration audits now check the filter itself: each stack must reach its own pool and stay off the others

  • AMD GPU metrics: the collector's GPU job also discovers the AMD operator's device-metrics-exporter (label metrics-exporter, port exporter-port), and a modelplane-gpu-amd builtin maps its series onto the same modelplane_gpu_* names - VRAM MiB to bytes, activity percent to ratio, and energy through a new Microjoules fromUnit. modelplane_gpu_tensor_active_ratio stays NVIDIA-only.

  • Two recipes: Qwen2.5-0.5B on a single MI325X on a bring-your-own cluster and Qwen2.5-0.5B on CPUs alone on a Civo g4p.kube.medium pool with vLLM's CPU image.

The recipe site needs two follow-ups in its repo: the gpus data key the ayouts read is now accelerators, and partials/icons.html needs brand-amd, brand-civo and cpu symbols. https://github.com/modelplaneai/docs-site/blob/main/themes/geekboot/layouts/partials/icons.html -> modelplaneai/docs-site#5

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.

Tested

AMD

curl -v "http://207.148.15.31/v1/chat/completions" 
-H "Content-Type: application/json" 
-d "{"model":"ml-team/qwen","messages":[{"role":"user","content":"Say Hello from VULTR BYO with AMD GPU"}],"max_tokens":100}"
{"id":"chatcmpl-4cbd10fe-697f-4712-b24b-4edf39dfa069","object":"chat.completion","created":1790793716,"model":"ml-team/qwen-demo","choices":[{"index":0,"message":{"role":"assistant","content":"Hello! It looks like you're looking to set up a machine learning model using a bare metal K3s instance on the VULTR server. Here's how you can do it:\n\n1. Install VULTR: First, ensure that your VULTR account is active and connected.\n\n2. Create an Instance: Use the AWS Management Console or the AWS CLI to create a new EC2 instance. Choose the appropriate AMI for your needs (e.g., Ubuntu Server 2","refusal","annotations","audio","function_call","tool_calls":[],"reasoning"},"logprobs","finish_reason":"length","stop_reason","token_ids","routed_experts"}],"service_tier","system_fingerprint":"vllm-0.23.1.dev1+g9ddef7117.d20260901-ebed204f","usage":{"prompt_tokens":46,"total_tokens":146,"completion_tokens":100,"prompt_tokens_details"},"prompt_logprobs","prompt_token_ids","prompt_text","kv_transfer_params"}pod "curl-test" deleted from default namespace
kubectl get pods -A                                                                    
NAMESPACE                 NAME                                                              READY   STATUS    RESTARTS      AGE
[...]
kube-amd-gpu              default-dra-driver-wsxv2                                          1/1     Running   0             9m56s
kube-amd-gpu              default-metrics-exporter-42kht                                    1/1     Running   0             9m56s
kube-amd-gpu              default-node-labeller-bs7cx                                       1/1     Running   0             12m
kube-amd-gpu              mp-gpu-operator-charts-controller-manager-58c4f8969f-lztzb        1/1     Running   0             22m
kube-amd-gpu              mp-gpu-operator-charts-kmm-controller-889d84758-4m5wd             1/1     Running   0             22m
kube-amd-gpu              mp-gpu-operator-charts-kmm-webhook-server-78b4c5944f-pmkk5        1/1     Running   0             22m
[...]
mp-ml-team-51733          qwen-demo-8f4f6-epp-6fb7699648-mjxfw                              1/1     Running   0             18m
mp-ml-team-51733          qwen-demo-8f4f6-qwen-1d9a7-544587cb94-cv6t7                       1/1     Running   0             

GPU

kubectl get pods -A                                                                      
NAMESPACE                 NAME                                                              READY   STATUS    RESTARTS      AGE
[...]
mp-ml-team-51733          qwen-cpu-5205d-epp-554d95bdf8-xqs87                               1/1     Running   0             4m28s
mp-ml-team-51733          qwen-cpu-5205d-qwen-becae-7d897dd7cc-qfv88                        1/1     Running   0             4m28s
kubectl get resourceslice -A
NAME                                                              NODE                                              DRIVER    POOL                                              AGE
00000-dra.cpu-k3s-civo-chris-2439-383cf1-node-pool-636a-gjrnc4r   k3s-civo-chris-2439-383cf1-node-pool-636a-gjgqv   dra.cpu   k3s-civo-chris-2439-383cf1-node-pool-636a-gjgqv   11m
(⎈|upbound:default)➜  Downloads KUBECONFIG=/Users/haarchri/Downloads/civo-civo-chris-kubeconfig kubectl get resourceslice -A -o yaml
apiVersion: v1
items:
- apiVersion: resource.k8s.io/v1
  kind: ResourceSlice
  metadata:
    creationTimestamp: "2026-10-27T16:50:06Z"
    generateName: 00000-dra.cpu-k3s-civo-chris-2439-383cf1-node-pool-636a-gjgqv-
    generation: 1
    name: 00000-dra.cpu-k3s-civo-chris-2439-383cf1-node-pool-636a-gjrnc4r
    ownerReferences:
    - apiVersion: v1
      controller: true
      kind: Node
      name: k3s-civo-chris-2439-383cf1-node-pool-636a-gjgqv
      uid: 266dde53-7f07-4da1-9f57-b85117f500f6
    resourceVersion: "7469"
    uid: 0c642aed-846a-4e38-8b24-4b9bf8a959b4
  spec:
    devices:
    - allowMultipleAllocations: true
      attributes:
        dra.cpu/numCPUs:
          int: 6
        dra.cpu/numaNodeID:
          int: 0
        dra.cpu/smtEnabled:
          bool: false
        dra.cpu/socketID:
          int: 0
        dra.net/numaNode:
          int: 0
        resource.kubernetes.io/numaNode:
          int: 0
      capacity:
        dra.cpu/cpu:
          value: "6"
      name: cpudevnuma000
    driver: dra.cpu
    nodeName: k3s-civo-chris-2439-383cf1-node-pool-636a-gjgqv
    pool:
      generation: 1
      name: k3s-civo-chris-2439-383cf1-node-pool-636a-gjgqv
      resourceSliceCount: 1
kind: List
metadata:
  resourceVersion: ""
 kubectl run curl --rm -it --image=curlimages/curl --restart=Never --   curl -s http://212.2.244.212/v1/completions -H 'Content-Type: application/json'   -d '{"model": "ml-team/qwen-cpu", "prompt": "Hello", "max_tokens": 16}'
{"id":"cmpl-a992b1ac-241a-4772-8810-790b0ffc6b45","object":"text_completion","created":1793121273,"model":"ml-team/qwen-cpu","choices":[{"index":0,"text":"\n\nI'm currently working on a project that involves creating an interface for a game","logprobs":null,"finish_reason":"length","stop_reason":null,"token_ids":null,"prompt_logprobs":null,"prompt_token_ids":null,"routed_experts":null}],"service_tier":null,"system_fingerprint":"vllm-0.32.0-006d02cd","usage":{"prompt_tokens":1,"total_tokens":17,"completion_tokens":16,"prompt_tokens_details":null,"completion_tokens_details":null},"kv_transfer_params":null,"ec_transfer_params":null,"metrics":null}

@github-actions

Copy link
Copy Markdown

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

@dennis-upbound dennis-upbound left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, the mechanism reads well and I checked a lot of it: the tags on every cloud (neutral components untagged, every NVIDIA-only one tagged), the dependency check, the mirror against supported_drivers() for all six clouds, stacks-current regenerating the aicr files byte for byte, the dra-driver-cpu 0.3.0 chart and its dra.cpu capacity key, and the AMD exporter's names, units and discovery labels against device-metrics-exporter v1.5.1. A pre-#556 ServingStack also keeps its full stack until the cluster composition stamps draDrivers, which is the right upgrade behavior.

One problem deletes running things, so I'd fix it before merge: the UnsupportedDevices gate deletes a live cluster (inline on compose-inference-cluster). I've withdrawn the gpuPools rename comment: it only matters on upgrade, which is fine before v1.

The rest are inline: the CPU role coming from a missing accelerator, the AMD operator values, the energy metric's type, the docs-site ordering, and what the two recipes and e2e legs actually validated. Also, the four commits use typed prefixes and have no bodies, and update for vale is a fixup; CONTRIBUTING asks for problem-first messages without prefixes.

Comment thread functions/compose-inference-cluster/function/fn.py
Comment thread apis/inferenceclusters/definition.yaml
Comment thread apis/inferenceclasses/definition.yaml
Comment thread functions/compose-serving-stack/function/stacks/clouds/existing.py Outdated
Comment thread functions/compose-serving-stack/function/stacks/metrics.py
Comment thread docs/data/recipes.yaml
Comment thread docs/content/recipes/qwen2.5-0.5b.md Outdated
Comment thread docs/manifests/recipes/qwen2.5-0.5b-cpu/model-deployment.yaml
Comment thread e2e/README.md Outdated
Comment thread functions/compose-serving-stack/function/fn.py Outdated
Comment thread functions/compose-inference-cluster/function/fn.py
Comment thread functions/compose-inference-cluster/function/fn.py
Comment thread docs/data/recipes.yaml
Comment thread functions/compose-serving-stack/function/stacks/__init__.py
Comment thread apis/inferenceclasses/definition.yaml Outdated
Comment thread functions/compose-serving-stack/function/stacks/metrics.py Outdated
haarchri and others added 8 commits October 29, 2026 17:06
Every cluster got NVIDIA's device stack and no other vendor's. Each
cloud's component list pinned the NVIDIA GPU operator or DRA driver
unconditionally, nothing checked that the devices a class declares
match the stack its cluster gets, and a pool without a GPU couldn't be
provisioned at all.

compose-inference-cluster now collects the DRA drivers its classes
claim onto ServingStack spec.draDrivers, and compose-serving-stack
installs a component tagged with drivers only when one of them is
listed. A class claiming a driver its cluster's cloud can't serve sets
ClusterReady=False with reason UnsupportedDevices. The gate is a fatal
result rather than an empty desired state, which Crossplane would
answer by garbage collecting a running cluster.

This also adds CPU inference and AMD GPUs. A class without an
accelerator gets a CPU pool served by dra-driver-cpu, on every cloud.
On Existing, a class claiming gpu.amd.com gets the AMD GPU operator in
DRA mode, and the collector maps its device-metrics-exporter onto the
same modelplane_* names as DCGM.

Designed in modelplaneai#432.

Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Signed-off-by: Nic Cope <nicc@rk0n.org>
Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Signed-off-by: Nic Cope <nicc@rk0n.org>
The cloud legs faked one NVIDIA GPU pool, so nothing checked that the
serving stack's driver filter puts each device stack on its own pool
and keeps it off the others.

Each leg now fakes an NVIDIA and a CPU pool, plus an AMD pool on
Existing, and stamps spec.draDrivers the way compose-inference-cluster
would. Those legs claim every stack their cloud carries, so a join that
ignored the filter would pass them all. A new existing-filtered leg
keeps the AMD pool but leaves gpu.amd.com unclaimed, and checks the AMD
operands don't exist.

Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Signed-off-by: Nic Cope <nicc@rk0n.org>
The recipes covered only NVIDIA GPUs. These add Qwen2.5-0.5B-Instruct on
one AMD Instinct MI325X on a bring-your-own cluster, and on CPUs alone
on a Civo pool with vLLM's CPU image. The recipe data's gpus list
becomes accelerators so it can carry AMD and CPU entries; the docs site
reads both (modelplaneai/docs-site#5).

Signed-off-by: Christopher Haar <christopher.haar@upbound.io>
Signed-off-by: Nic Cope <nicc@rk0n.org>
The cluster agent counts a node only if every NoSchedule taint on it is
nvidia.com/gpu. CPU pools carry modelplane.ai/cpu and BYO AMD pools
amd.com/gpu, so the agent reported their nodes as observed but left
them out of its free-device histogram. The scheduler then saw those
pools as full and never placed a replica on them.

This composes a PoolObservation only for a pool whose class claims
gpu.nvidia.com. Other pools go unobserved, and the scheduler falls back
to whole-node accounting for them, as it did for every pool before the
agent. Teaching the agent the other taints would let them use free-device
accounting too, but needs an agent release.

Signed-off-by: Nic Cope <nicc@rk0n.org>
MetricMapping gained a cumulative boolean for a component that reports
a running total as a gauge. A boolean leaves no room for the next type
mismatch, and reads apart from fromUnit, which already describes the
same kind of correction for units.

This replaces it with fromType, an enum whose only value is Gauge,
described the way fromUnit is: what the component reports, when that
isn't what the name claims. Because Gauge converts the series to a
counter, a rule requires the mapping's to name to end in _total.

Signed-off-by: Nic Cope <nicc@rk0n.org>
A CPU pool's cores are claimed as consumable capacity, which Kubernetes
enables by default only from 1.36. GKE defaults to 1.35, and AKS and
Nebius to 1.34, so a CPU pool on their defaults fails when its engine
pod's claim is prepared. Only Civo's version field said so.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Signed-off-by: Nic Cope <nicc@rk0n.org>
@negz
negz force-pushed the feature/multi-accelerator-cpu-dra branch from 10119d7 to d36e26c Compare October 30, 2026 00:14
negz added 11 commits October 29, 2026 17:41
The AMD recipe took the generic qwen2.5-0.5b slug while its CPU sibling
is qwen2.5-0.5b-cpu, so an NVIDIA recipe for the same model would have
had nowhere to go. Suffixing the AMD recipe matches the CPU one.

Signed-off-by: Nic Cope <nicc@rk0n.org>
compose-inference-cluster rejects a class claiming a DRA driver its
cloud can't serve, using a hand-kept copy of the serving stack's derived
supported-drivers table. Nothing compared the two. A copy that admitted
a driver the stack doesn't serve would skip the UnsupportedDevices
condition, and the ServingStack's join would raise on every reconcile
instead.

This adds a supported-drivers-mirror flake check. It imports the stacks
package from compose-serving-stack's venv and reads the copy from
compose-inference-cluster's source, since neither function can import
the other, and fails naming each cloud whose drivers differ.

Signed-off-by: Nic Cope <nicc@rk0n.org>
check_devices_supported skipped any cloud missing from
_SUPPORTED_DRA_DRIVERS, which is how Existing opted out, so a cloud
added without an entry would never be gated. Its UnsupportedDevices
message also stopped at the first offending class, and said the serving
stack installs every supported driver, which on EKS includes dra.net,
installed by compose-eks-cluster instead.

This skips Existing explicitly and indexes the table for every other
cloud, names every offending class in one message, and says which
drivers the cloud supports rather than what its stack installs. It also
replaces the "DRA" claim literals with a _CLAIM_DRA constant, as
compose-model-deployment does.

Signed-off-by: Nic Cope <nicc@rk0n.org>
The generated cloud modules declare the drivers that arrive outside the
serving stack as EXTERNAL_DRIVERS, while the stacks package hard-coded
an empty set for each hand-written cloud. This declares EXTERNAL_DRIVERS
in the hand-written modules too, so every cloud states its external
drivers in one place.

Signed-off-by: Nic Cope <nicc@rk0n.org>
The AMD GPU operator chart defaults the DeviceConfig's metrics exporter
image to a floating latest tag. The DRA driver, node labeller and
utility images were already pinned, but not the exporter, whose field
names the modelplane-gpu-amd MetricMapping reads. This pins it to
v1.5.1, the release that shipped with the v1.5.1 chart.

Signed-off-by: Nic Cope <nicc@rk0n.org>
dra-driver-cpu runs only on nodes labelled modelplane.ai/cpu, which the
cluster compositions put only on CPU pools. A provisioned class claiming
dra.cpu alongside a GPU driver gets a GPU pool, so its dra.cpu claim
could never allocate, and nothing said why. This rejects that class at
admission. A class without provisioning is unaffected, since its nodes
are the user's to label.

The descriptions of each cloud's machine shape still called the
accelerator block informational, though whether it's set now decides
whether a pool is provisioned as a GPU or a CPU pool. This corrects
them, and the InferenceCluster nodePools description that called every
pool a GPU pool.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Signed-off-by: Nic Cope <nicc@rk0n.org>
compose-model-replica sizes a dra.cpu claim by stamping the engine
container's cpu request on the claim as device capacity. It stamped it
on every device in the request, so a member claiming two CPU devices
took the full request from each, pinning twice the cores Kubernetes
accounts the pod for.

This fails the replica on a member whose dra.cpu requests total more
than one device, alongside the existing failure for a dra.cpu claim
with no cpu request, which both now report through invalid_cpu_claim.

Signed-off-by: Nic Cope <nicc@rk0n.org>
The cluster compositions declare their GPU taint's key, value and effect
as constants, but tainted CPU pools by reusing the CPU label constant
as the key, an inline "true", and the GPU taint's effect. GKE inlined
the effect too. This gives each composition _CPU_TAINT_KEY,
_CPU_TAINT_VALUE and _CPU_TAINT_EFFECT, matching AKS's _CPU_TAINT. The
e2e legs test now checks the CPU taint's value as well as its key, and
the CPU label against each leg's fake nodes.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Several comments still described the NVIDIA-only stack. The GPU
collector job's comment said Modelplane installs no GPU exporter, which
is no longer true on Existing. generate.py's toleration comment left
out the NODE class. On Existing, the NFD comment said the cluster
compositions taint GPU nodes, and the AMD operator comment called DRA
mode the chart's default when the values set it against the defaults.
This corrects them and rewraps the comments left ragged by earlier
edits.

Signed-off-by: Nic Cope <nicc@rk0n.org>
Several docs still described an NVIDIA-only stack. The existing-cluster
requirements said Modelplane always installs NVIDIA's DRA driver and
tolerates only the nvidia.com/gpu taint, and said nothing of the AMD and
CPU stacks' needs. The telemetry page said an existing cluster brings
every GPU exporter and that dashboards need no change across vendors,
though per-card labels differ, and didn't document fromType. The design
doc and the CPU recipe had drifted from the implementation.

This brings them in line with the code, and rewraps a few paragraphs
left ragged by earlier edits.

Signed-off-by: Nic Cope <nicc@rk0n.org>

@negz negz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving, but a fair chunk of this is mine now, so please review d36e26c0..4693b4b9 before merging. They're separate commits so the delta is easy to read; fold them into yours once you're happy.

The main changes:

  • The supported-drivers gate no longer fails open. It names every offending class, and a new flake check catches its table drifting from the serving stack's.
  • A provisioned class can't claim dra.cpu alongside a GPU driver, because that claim could never allocate on a GPU pool.
  • A dra.cpu claim across more than one device is rejected, because each device got the full CPU request. Please check I read that right.
  • The AMD metrics exporter image is pinned to v1.5.1 instead of latest.
  • CPU taint constants are consistent across clouds, the docs and stale comments are updated, and the AMD recipe is now qwen2.5-0.5b-amd.

Two follow-ups for issues: enforce the 1.36 minimum for CPU pools, and decide whether hard-coding dra.cpu in the InferenceClass validation is OK given the design doc.

I'll test the CPU path end to end, but I don't have access to AMD GPUs, so I can't test that side.

@negz

negz commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Tested the CPU path end to end on EKS, using the PR head plus my commits (4693b4b9), with an m6i.2xlarge CPU pool and the CPU recipe's ModelDeployment.

  • Both new InferenceClass validation rules reject bad classes at admission.
  • A class claiming gpu.amd.com gives UnsupportedDevices ("class eks-amd claims gpu.amd.com; EKS clusters support dra.cpu, dra.net, gpu.nvidia.com"), and nothing is provisioned.
  • The ServingStack gets draDrivers: [dra.cpu]. Only dra-driver-cpu installs, with no NVIDIA components.
  • The CPU node group gets the standard AL2023 image, Kubernetes 1.36.5, and the modelplane.ai/cpu label and taint.
  • The ResourceSlice publishes 6 claimable CPUs with SMT on. The engine's claim consumed dra.cpu/cpu: "5".
  • A chat completion through the gateway returned a sensible answer.

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