GPUStack Operator turns raw node hardware into a Kueue-based scheduling chain. It builds on two well-known Kubernetes projects:
- Node Feature Discovery (NFD): detects hardware features and system configuration, and publishes them as Node labels.
- Kueue: a Kubernetes-native job queueing system that manages workload admission and queuing across ClusterQueues, and — through its AdmissionCheck extension point — lets an external controller gate admission on per-card feasibility.
cmd/gpustack-operator/main.go wires a single binary with three cobra subcommands:
worker(aliasw,pkg/worker) — the control-plane process. Runs an aggregated extension API server and a controller-runtime manager in one process, installs the NFD / Kueue / Device-Manager / CSI applications, and runs the scheduling-chain controllers (see Stage 4).worker-gateway(pkg/workergateway) — aggregates resources from upstream Kubernetes clusters.device-manager(pkg/devicemanager) — the per-node DaemonSet. Subcommandsserve/detect/monitor: detects and monitors local accelerators, reports aNodeFeature+DevicesCR, and runs the device-plugin allocator for device injection.
pkg/worker/worker.go runs Prepare (install system namespace → CRDs → extension API services →
webhook configs → settings → NFD/Kueue/DM/CSI apps) then Start. In Start, the controller
manager is deliberately started only after the extension API services report ready, so
controllers can index extension-API resources. Preserve this ordering when adding startup steps.
Detection (pkg/devicemanager/detector/<mfr>) and allocation (pkg/devicemanager/allocator/<mfr>)
have one subpackage per manufacturer: nvidia, amd, ascend, cambricon, hygon, iluvatar, metax,
mthreads, thead. Platform-specific code is split into _linux.go / _other.go build-constrained
files. The set of supported manufacturers and their PCI vendor IDs / resource names live in
pkg/nodefeature (overridable via GPUSTACK_* env vars).
Generated Go bindings to vendor GPU runtime/management libraries (nvml, rsmi/amdsmi/amdgpu, cndev,
dcmi, hgml, ixml, mtml/mxsml, hsa, dl). The generators read gen/binding/<runtime>/config.yaml and
emit into binding/<runtime>/ via make generate binding (c-for-go is vendored in .sbin/). The
top-level binding/helper*.go files are hand-written CPU/NUMA topology helpers — those are not
generated.
Kubernetes label values cap at 63 chars. Long names (ClusterQueue names, queue references) are
stored in schedule.gpustack.ai/* annotations, not labels; LocalQueues are named
gpustack-fnv64-<hash> (always 31 chars — see Stage 4). When
generating any name that flows into a label value, check this limit.
The chain is built in four stages:
- Bootstrap — install NFD and the Device Manager (DM) DaemonSets.
- Device discovery — DM detects accelerators, reports per-device feature labels, and maintains the
DevicesCR ledger. - Capacity profiling — the Worker (WK) derives per-node capacity labels (CPU cores + the four
.sliced.*accelerator capacities). - Queue construction & admission — WK controllers materialize the labels into Kueue
ResourceFlavor→ClusterQueue(one isolated queue per pool, no Cohort) and a materializedInstanceTypeCRD, and gate admission with a per-cardAdmissionCheckread from theDevicesledger.
flowchart TD
subgraph node["Each Node"]
NFDW["NFD worker"]
DM["Device Manager Pod<br/>(DaemonSet per manufacturer)"]
end
subgraph control["Control Plane"]
NFDM["NFD master"]
WK["GPUStack Worker controllers"]
KUEUE["Kueue objects<br/>ResourceFlavor / ClusterQueue / LocalQueue<br/>+ AdmissionCheck"]
IT["InstanceType CRD<br/>(materialized three-view status)"]
end
NFDW -- "NodeFeature (PCI + CPU scan)<br/>pci-VENDOR.present / cpu-model.*" --> NFDM
NFDM -- "apply labels" --> NODE["Node labels"]
NODE -- "nodeSelector schedules DM" --> DM
DM -- "NodeFeature NODE-gpustack-device-manager<br/>acceleratable.feature.gpustack.ai/* + Devices CR ledger" --> NFDM
NODE -- "watched by" --> WK
WK -- "NodeFeature NODE-gpustack-worker<br/>general./acceleratable. capacity labels" --> NFDM
NODE -- "capacity labels drive" --> KUEUE
WK -- "owns / materializes" --> IT
DM -- "Devices ledger feeds" --> IT
At startup, pkg/worker/kuberess installs the NFD Helm chart. NFD performs three jobs:
-
Labels every Node that carries a PCI display/accelerator-class device (PCI classes
02,03,0b,12, overridable viaGPUSTACK_PCI_CLASS_PREFIXES— see Settings & Environment Variables) with:feature.node.kubernetes.io/pci-${PCI_VENDOR_ID}.present: "true"For example, a node with an NVIDIA device gets
feature.node.kubernetes.io/pci-10de.present: "true". -
Labels every Node with its CPU model identity (the
cpulabel source), and annotates it with the CPU details through thegpustack-cpu-infoNodeFeatureRule:feature.node.kubernetes.io/cpu-model.vendor_id: "AMD" feature.node.kubernetes.io/cpu-model.family: "25" feature.node.kubernetes.io/cpu-model.id: "1"feature.gpustack.ai/cpu-name: "AMD EPYC 7763 64-Core Processor" feature.gpustack.ai/cpu-family: "25" feature.gpustack.ai/cpu-physical-cores: "64" feature.gpustack.ai/cpu-threads-per-core: "2" feature.gpustack.ai/cpu-logical-cores: "128" feature.gpustack.ai/cpu-stepping: "1" feature.gpustack.ai/cpu-cache-line: "64" feature.gpustack.ai/cpu-hz: "2450000000" feature.gpustack.ai/cpu-boost-freq: "3500000000" feature.gpustack.ai/cpu-cache-l1i: "32768" feature.gpustack.ai/cpu-cache-l1d: "32768" feature.gpustack.ai/cpu-cache-l2: "524288" feature.gpustack.ai/cpu-cache-l3: "33554432"An annotation keeps its
@cpu.model.*template reference verbatim when NFD cannot resolve the attribute, so values leading with@are treated as unreported.The WK later normalizes these into the node's general(CPU) node key (
nodefeature.ExtractGeneralNodeKey), which is always non-empty and always blends the node's real CPU identity as${cpuManufacturer}-${id}: the id leads with the sanitizedfeature.gpustack.ai/cpu-nameannotation when reported (trademark markers and the trailing" CPU @ …"frequency part are dropped, a leading manufacturer prefix is deduplicated, and the result is truncated to fit the naming budget — e.g.amd-epyc-7763), or with the cpu-model family and id labels as the fallback when the annotation is unavailable (e.g.amd-25-1). The manufacturer is the lowercasedcpu-model.vendor_idlabel — reported as a cpuid vendor enum name, soIntel→intel,AMD→amd— falling back togenericwhen the vendor is unknown or unreported (and the whole key degrades togenericonly when NFD reports no CPU identity at all). Whether the CPU manufacturer actually subdivides the scheduling pools is a separate, runtime decision made at the aggregation layer byinstance-type-aware-cpu-manufacturer(Stage 4) — the node key itself is always CPU-accurate so the finest-grainedResourceFlavors can be re-grouped without rewriting them.The key deliberately does not encode os/arch. Instead, os/arch is appended in full to every ResourceFlavor / ClusterQueue / InstanceType name (
…-linux-arm64, never an abbreviation) and pinned explicitly on the ResourceFlavor'sspec.nodeLabels(kubernetes.io/os,kubernetes.io/arch). This is a correctness safeguard, not cosmetics: the cpu-model family/id labels are independent numbering spaces on x86 (CPUID) versus arm64 (MIDR), so a small value like25-1can legitimately appear on both architectures — amd64 and arm64 binaries are not interchangeable, so their capacity must never pool into one flavor/queue. Keeping os/arch out of the key (while pinning it on the name + nodeLabels) also reclaims label-length budget the old abbreviated-ln-x64suffix consumed. -
Labels Nodes that have no accelerator device from any known manufacturer (see
nodefeature.GetKnownAcceleratableManufacturers()) with:feature.gpustack.ai/acceleratable: "false"This forms an explicit contrast with the
acceleratable: "true"label reported later by the Device Manager, which also corrects false negatives if they occur.
For each known manufacturer, a DaemonSet named gpustack-operator-device-manager-${manufacturer} is created with a node selector on the NFD PCI label. For example, nodes labeled feature.node.kubernetes.io/pci-10de.present: "true" receive a Pod from the gpustack-operator-device-manager-nvidia DaemonSet. These DaemonSets are normally rendered by the Helm chart itself (deviceManager.enabled=true, the default — the worker is then started with --disable-applications=device-manager so it does not install them again); when the chart is deployed with deviceManager.enabled=false, pkg/worker/kuberess instead installs them at runtime from the bundled operator chart (gpustack-operator-<ver>.tgz) as a separate Helm release gpustack-operator-device-manager.
Once running, the DM detect loop (pkg/devicemanager/detector/detector.go) periodically detects accelerators and reports a NodeFeature object named ${NODE_NAME}-gpustack-device-manager (owned by the Node), whose labels are built by nodefeature.ConstructAcceleratableNodeLabels. Each detected accelerator model is keyed by the accelerated device key ${aKey} = ${manufacturer}-${id}, where id is the product name sanitized to satisfy Kubernetes label naming rules:
| Label | Meaning |
|---|---|
${prefix}acceleratable=true |
Node has usable accelerators; overrides the NFD false marker |
acceleratable.${prefix}${manufacturer}=true |
Accelerator manufacturer |
acceleratable.${prefix}${manufacturer}.driver-version=${dv} |
Device driver version (omitted when undetected) |
acceleratable.${prefix}${manufacturer}.runtime-version=${rv} |
Device runtime version (omitted when undetected) |
acceleratable.${prefix}${aKey}=true |
Concrete device model marker |
acceleratable.${prefix}${aKey}.product=${name} |
Product name |
acceleratable.${prefix}${aKey}.memory=${memory} |
Per-card VRAM size, formatted at the largest binary unit (e.g. 16Gi) |
acceleratable.${prefix}${aKey}.cores=${cores} |
Accelerator core count |
acceleratable.${prefix}${aKey}.count=${acc} |
Number of accelerators of this model on the node |
acceleratable.${prefix}${aKey}.family=${family} |
Product family (omitted when undetected) |
acceleratable.${prefix}${aKey}.comcap=${cc} |
Compute capability (omitted when undetected) |
where prefix is feature.gpustack.ai/ — so the device labels live under the dedicated acceleratable.feature.gpustack.ai/ key namespace — and manufacturer is one of the manufacturers supported by pkg/devicemanager/detector (NVIDIA, AMD, Ascend, Cambricon, Hygon, Iluvatar, MetaX, MThreads, T-Head). The per-manufacturer PCI vendor IDs, resource names, and runtime class names can all be overridden — see Settings & Environment Variables.
The NodeFeature object is owned by the Node. The DM also reports a Devices custom resource named after the node (owned by that NodeFeature), stamped with the accelerator flavors' selector labels (the feature key + kubernetes.io/os|arch) so the pool's queue can reverse-look-up its Devices. Its .status holds the per-card AcceleratorAllocation ledger — every card's mode (free / exclusive / shared / sliced) and Remaining credit budget — which is the single authoritative accounting of accelerator occupancy: it drives the InstanceType three-view display and feeds the per-card AdmissionCheck (Stage 4). The DM keeps monitoring devices, re-detecting whenever the device set or health changes; a separate NodeDevicesReconciler syncs the gpustack.ai/managed mark from the Node onto the same-named Devices so the per-node DM never asserts a node-management decision it does not own.
Alongside detection, the DM runs the device-plugin allocator (pkg/devicemanager/allocator/<mfr>, on pkg/deviceplugin): it registers per-mode resources (exclusive / shared / sliced) with the kubelet and, on Allocate, returns the container injection and records the allocation into the Devices ledger. For exclusive/shared the injection is just the device-visibility env (NVIDIA_VISIBLE_DEVICES / ASCEND_VISIBLE_DEVICES / …). For sliced (soft slicing) it additionally applies runtime isolation with decoupled compute and memory budgets: the compute (SM / aicore) budget comes from .sliced.cores-percentage (defaulting to 100 %) and the VRAM budget from the per-card memory request (.sliced.memory-percentage preferred over .sliced.memory-mib, floored and capped at the card VRAM), so a slice can cap SM independently of VRAM. The container is started with a vendor preload library — NVIDIA HAMi-core libvgpu.so, Ascend vcann-rt libvruntime.so — activated via /etc/ld.so.preload, plus per-container quota (NVIDIA env CUDA_DEVICE_SM_LIMIT / CUDA_DEVICE_MEMORY_LIMIT_*; Ascend an npu_info.config carrying aicore-quota / memory-quota). It also quiets each preload library's verbose per-call logging by default — injecting LIBCUDA_LOG_LEVEL=0 (HAMi-core) / ENPU_LOG_LEVEL=1 (vcann-rt) unless the workload already sets that variable — so a normal run is not buried in interception-library noise. The preload libraries are compiled into the operator image per runtime version (cloned inline at pinned commits — no git submodule — built in the xbuild-nvidia-cuda-* / xbuild-ascend-cann-* Dockerfile stages, scripts under pack/gpustack-operator/external/{nvidia,ascend}) and staged onto the host (/var/lib/gpustack/operator/lib) by a device-manager init container; the allocator mounts the matching library + a per-pod working directory into each sliced container and reclaims those directories once their pods are gone.
For an SSH-enabled Instance the workload runs in a two-container Pod (main = the user image, sshd = an Alpine sidecar that nsenters into main). The accelerator request and its runtime-isolation artifacts are placed on main — where the workload (and the SSH shell, which enters main's namespaces) actually runs — while sshd requests an internal-only device.gpustack.ai/<manufacturer>.visibility resource (quantity = main's card count). The allocator serves that resource from the same ResourceServer under an internal Visibility mode: its Allocate selects no fresh device but reuses the physical device(s) main was already allocated — correlated by an in-process, pod-keyed reservation recorded at main's Allocate (kubelet allocates main before sshd, sequentially, in Pod spec order) — and returns only the vendor visible-devices env, so the sidecar gets a narrow device-cgroup grant to the same card with no slicing artifacts and no ledger consumption. The visibility resource is advertised per card as a pool of SlicedResourceMaxSize tokens outside the known-acceleratable families, so it never gates scheduling and admission never reads it as a second accelerator mode. It is registered by every accelerator backend. The per-card AdmissionCheck (Stage 4) re-checks feasibility only before admission — once a Workload is admitted its own allocation is already in the ledger, so re-evaluating it is skipped to avoid counting a slice against itself.
Pod identification, cross-mode exclusion, and the one-allocation-per-Pod constraint. The device-plugin Allocate call carries the assigned device IDs but not the pod identity, so the allocator maps a call to the pod being admitted by matching the oldest Pending pod on the node that requests the resource. Every node's Allocates run in that node's single device-manager process, so a per-node in-process mutex serializes each workload Allocate's identify → cross-mode check → reserve section (the durable-annotation patch runs outside it), and identification skips a pod that already holds a reservation. Together this (a) enforces the per-card exclusive/shared/sliced cross-mode invariant — a card kubelet assigned that another mode already holds, per the ledger Status or the in-process reservation, is refused with FailedPrecondition, so an exclusive tenant truly owns its card on every path, Kueue or raw (ListAndWatch itself withholds nothing — it reflects hardware health only, keeping kubelet's device accounting stable); and (b) maps a batch of identical GPU Pods admitted together (e.g. by Kueue) one-to-one to distinct pods, so their annotations and the per-card ledger stay correct instead of one pod being double-attributed and another lost. A consequence is that the allocator attributes at most one workload allocation per Pod: a Pod must request the whole-card resource (nvidia.com/gpu[.shared|.sliced]) in a single container (plus the optional sshd visibility sidecar). A second workload container requesting the same resource in the same Pod is skipped by identification (its Pod already holds a reservation), so its Allocate either finds no matching pod and fails, or — if an unrelated single-card Pod is pending on the node at the same instant — misattributes the card to that pod; this Pod shape is unsupported, and rejecting it at admission is a tracked follow-up. A related known limitation: the sshd visibility path must re-find its own already-reserved Pod (so it cannot use the reservation-skip), and identification alone cannot tell two SSH-enabled Instances apart when both are admitted at the same instant — a sidecar can then be granted the other Instance's card. Hardening the visibility path is a separate tracked follow-up.
A node hosts a single driver/runtime stack per manufacturer, so every DevicesGroup of a given manufacturer on that node shares the same driver and runtime version. The per-runtime-version library subdir (cuda-<major> / cann-<major>-<family>) the allocator picks from the first allocated card is therefore correct for every card in a sliced allocation. The allocator nonetheless guards against a mismatch defensively (NVIDIA rejects a sliced allocation spanning different CUDA majors; Ascend rejects a multi-card sliced allocation, since vcann-rt's npu_info.config models a single physical NPU), so any future regression fails the Allocate loudly instead of silently mounting an incompatible library.
Two WK controllers turn Node + Devices signals into the capacity labels the scheduling chain consumes:
-
NodeFeatureReconciler(node_feature.go) watches Nodes and reports a NodeFeature object named${NODE_NAME}-gpustack-worker. It stamps the node-management markergpustack.ai/managed=true— unlessGPUSTACK_NODE_MANAGEMENT_MANUAL=true(switch ①, read per-reconcile), in which case auto-injection is skipped and only an explicit admin-setmanagedlabel is honored, so onboarding can be gated node-by-node. -
NodeCapacityReconciler(node_capacity.go) builds the capacity labels vianodefeature.ConstructNodeCapacityLabels, reading both the Node and the same-namedDevicesCR: the general(CPU) key presence marker plus, for each accelerator model whose.slicedresource is present and > 0 in the Node capacity, the four per-card sliced capacities that the default scheduler / kubelet count at admission time:Label suffix ( acceleratable.${prefix}${aKey}.…)Value .sliced.unitscount × M(M = 1,600,000 credit units per whole card).sliced.cores-percentagecount × maxSlices × 100(compute overcommit) orcount × 100(compute partitioned).sliced.memory-percentagecount × 100.sliced.memory-mibΣ count × per-model VRAM MiB(weighted per model so mixed-VRAM models sum correctly)maxSlicesis the per-vendor maximum soft-slice count the DeviceManager detects into theDevicesgroup'sAcceleratorsFeature(NVIDIA 128, Ascend 63, Cambricon 16, Hygon 4, MThreads 16, MetaX 16 — each bounded by the vendor runtime's per-device user-process limit, seepkg/devicemanager/detector). A vendor whose runtime time-shares compute (NVIDIA / Ascend) advertises.sliced.cores-percentage = count × maxSlices × 100— each slice may claim a full 100 % — while one whose compute is not overcommittable (Cambricon / Hygon / MThreads / MetaX) caps it atcount × 100. The non-overcommit form varies by vendor: Hygon is a hard spatial partition (itsvdev.confassigns each slice a disjoint CU bitmask, so the sum stays within one card), whereas MThreads'cores%is a best-effort relative weight — not a hard partition — that is nonetheless accounted as non-overcommit. The four.sliced.*keys are presence-gated: the reconciler patches them only while the model's.slicedresource is present and positive inNode.status.capacity, and reverse-patches (removes) them when it disappears or reaches 0 — so a model with no slicing capability advertises no.slicedand gets none of the four capacities. Stale cleanup covers all four.sliced.*suffixes.
The unit spec (unitCPU / unitRAM / localStorage) is not derived from node capacity at all — the InstanceType carries a fixed default chosen by acceleratable-ness (non-accelerated 1c / 2Gi / 100Gi, accelerated 4c / 16Gi / 100Gi), which an admin overrides through the InstanceType API without touching any Node (Stage 4).
The capacity labels drive a scheduling chain built on Kueue by the controllers in pkg/worker/controllers/worker. There is one isolated ClusterQueue per pool and no Cohort — with exclusive / shared / sliced folded into one queue there is no cross-queue borrowing to broker, so spec.cohortName is empty and the old CohortReconciler / z-cohort are gone.
The ResourceFlavor is the finest grain and is setting-independent: it always encodes the CPU key (and, when accelerated, the accelerator key too), so the aggregation layer can be re-grouped without ever rewriting a flavor. The aggregation layer (ClusterQueue / InstanceType / InstanceTypeFlavor) is then grouped by the editable instance-type-aware-cpu-manufacturer (default false):
ResourceFlavor (always the finest grain, setting-independent):
gpustack--${gKey}-${os}-${arch}-${cpu}c # non-accelerated (c = CPU cores)
gpustack--${gKey}--${aKey}-${os}-${arch}-${acc}d # accelerated (d = devices)
ClusterQueue / InstanceType — grouped by instance-type-aware-cpu-manufacturer:
aware=false (default): gpustack--generic-${os}-${arch} # all CPUs collapse into one generic pool
gpustack--${aKey}-${os}-${arch} # one pool per accelerator (CPU ignored)
aware=true: gpustack--${gKey}-${os}-${arch} # split per CPU
gpustack--${gKey}--${aKey}-${os}-${arch} # split per (CPU, accelerator)
InstanceTypeFlavor (catalog, no os/arch): mirrors the same grouping —
gpustack--generic / gpustack--${aKey} (aware=false)
gpustack--${gKey} / gpustack--${gKey}--${aKey} (aware=true)
Two discriminators keep the pools clean. Every flavor and queue carries feature.gpustack.ai/acceleratable=true|false, so a collapsed generic queue selects "all non-accelerated flavors", and — critically — an aware generic queue (general.${gKey}=true) never matches an accelerated flavor that also carries general.${gKey}=true. The raw CPU detail rides in each flavor's note.gpustack.ai/cpuDetail (always on a CPU flavor; on an accelerated flavor only when awareness is on), and the InstanceType defaulting webhook folds it back into the type's spec.cpu / spec.accelerator.cpu when awareness is on.
flowchart LR
NODE["Node<br/>(general./acceleratable. capacity labels + gpustack.ai/managed)"]
DEV["Devices CR<br/>(per-card AcceleratorAllocation ledger)"]
subgraph controllers["WK controllers"]
NFR["NodeFlavorReconciler"]
ITR["InstanceTypeReconciler"]
NQR["NodeQueueReconciler"]
NQE["NodeQueueEntranceReconciler"]
AC["NodeDevicesAdmissionReconciler"]
end
NODE --> NFR
NFR -- "one per (key,os,arch,count)<br/>capacity = nodes × count" --> RF["ResourceFlavor"]
NFR -- "authors (create-only)<br/>when derived-from-node" --> IT["InstanceType CRD"]
IT --> ITR
DEV --> ITR
ITR -- "owns: existence + schedule labels<br/>+ isolation + admin Hold↔None sync;<br/>Devices-driven .status; recreate-on-delete;<br/>delete-then-wait teardown" --> CQ["ClusterQueue<br/>(isolated, no cohort)"]
RF --> NQR
NQR -- "owns: resource groups + HoldAndDrain<br/>+ AdmissionCheck ref; drain-on-delete;<br/>drain/empty when no live flavors" --> CQ
CQ --> ITR
CQ --> NQE
NS["Namespace (non-system)"] --> NQE
NQE -- "one per Namespace<br/>named gpustack-fnv64-HASH" --> LQ["LocalQueue"]
DEV --> AC
AC -- "per-card feasibility<br/>Retry when over-admitted" --> CQ
NodeFlavorReconciler(node_flavor.go) indexes managed nodes by(key, os, arch, count)and creates oneResourceFlavorper group. The flavor pins workloads throughspec.nodeLabels— the feature key{general.|acceleratable.}feature.gpustack.ai/${key}=true,kubernetes.io/os|arch(full), and a blanket{Operator: Exists}toleration (eligibility is by nodeLabels, not taints) — and carries the pool identity in labels (.count,.capacity = contributing nodes × count) plus the per-card VRAM and device descriptors innote.gpustack.ai/*annotations (device information only — no unit spec). A flavor whose group has no contributing node is deleted — there is no drain-tombstone anymore. The flavor identity is read from the first contributing node — every contributor to a flavor name shares it — so there is no min-capacity-node selection. After it syncs a flavor, and only underinstance-type-derived-from-node=true(default), it authors the pool'sInstanceType— create-only, at the setting-correct name and identity (generalGroup/acceleratorGroup/acceleratable/os/arch; the CPU key is thegenericsentinel when awareness is off) and the fixed default unit spec (non-accelerated1c/2Gi/100Gi, accelerated4c/16Gi/100Gi); an existing type (admin- or operator-authored) is left untouched, so theInstanceTypeReconcilerstays the sole owner of an InstanceType's lifecycle.InstanceTypeReconciler(instance_type.go) owns the backingClusterQueue's existence and metadata and the materializedInstanceTypeCRD's status — not its quota.ensureClusterQueuecreates the name-identical queue when missing, stamping the pool's schedule labels (nodefeature.PoolScheduleLabels— thefeature.gpustack.ai/acceleratableboolean, the general/accelerator feature key(s) selected byinstance-type-aware-cpu-manufacturer, andkubernetes.io/os|arch, all derived from the InstanceType spec identity) and the fixed no-borrow isolation (empty cohort, never-reclaim/borrow preemption) at creation; it never fills the resource groups or references the AdmissionCheck (theNodeQueueReconcilerowns those), and it prunes a stale feature-key label when the group/acceleratable changes so the re-pointed queue's selectors match the new pool. It watches the queue to keep the InstanceType.statusfresh (the three-view / CPU projection below +status.entrance, DeepEqual-guarded) and to recreate the queue if an admin accidentally deletes it while the InstanceType still lives. It does not author InstanceTypes (theNodeFlavorReconcilerdoes) and never deletes one for lack of flavors. On delete, agpustack.ai/controlledfinalizer runs a delete-then-wait teardown: mark the InstanceTypeInactive, delete the backing queue once, and hold the finalizer until Kueue has actually removed it — it does not drain the queue itself; theNodeQueueReconcilerobserves the deletion and drives the drain. Separately, it keepsit.Spec.Inactiveand the queue'sStopPolicyin sync for the adminInactivepath — settingStopPolicy=HoldwhenInactive(blocks new admission without evicting running workloads, neverHoldAndDrain), clearing toNonewhen an admin reactivates, and one-way/stickily backfillingInactive=truewhenever the queue is stopped by any means — so theHold↔Nonetoggle is owned here whileHoldAndDrainstays owned by theNodeQueueReconciler.NodeQueueReconciler(node_queue.go) owns the backingClusterQueue's quota and admission gating — resource groups, theHoldAndDraindrain policy (the adminHold↔Nonetoggle is owned by theInstanceTypeReconciler), and the node-devices AdmissionCheck reference — resolved from the pool's ResourceFlavors alone (it never looks at the owning InstanceType). It fills the groups from the live flavors, smallest per-node count first so Kueue packs small nodes first — an accelerated queue advertises onlycredits.gpustack.ai/${manufacturer}(nominal =capacity × M, one whole card =M = 1,600,000credit units so Kueue's int64 accounting never rounds fractional shared/sliced credits up to 1), a non-accelerated queue only CPU — and references thegpustack-node-devicesAdmissionCheck on an accelerated derived queue once it is Active. A flavor Kueue is still finalizing (its nodes left, soNodeFlavorReconcilerdeleted it but Kueue holds itsresource-in-usefinalizer until no ClusterQueue references it) is treated as absent: dropping it from the groups is the very ClusterQueue update Kueue waits for to release that finalizer — a workload still admitted on a dropped partial-pool flavor is evicted by Kueue and re-admitted on the pool's remaining live flavors (its node has left the pool, so it must move regardless). When a queue is being deleted (an admin's delete or the InstanceType teardown) it drivesHoldAndDrainunconditionally so Kueue evicts the admitted workloads and can then drop its own finalizer and remove the queue — Kueue never evicts on delete by itself. When a pool loses all live flavors while the queue still carries quota, gated byinstance-type-drain-when-no-flavors(switch ④, default true) it drivesHoldAndDrainand requeues until every reservation clears, then empties the groups (so Kueue's counters never go negative); it reactivates (StopPolicyNone) a queue it drained to empty — aHoldAndDrain, never an adminHold— once its flavors return, though theInstanceTypeReconciler's stickyInactivebackfill then re-holds a type that had been drained, so a recovered pool stays inactive until an admin clearsInactive.NodeQueueEntranceReconciler(node_queue_entrance.go) watches ClusterQueues and Namespaces, creating aLocalQueuein every non-system Namespace so workloads can submit from anywhere. Because workloads reference the LocalQueue through thekueue.x-k8s.io/queue-namelabel (63-char limit) while ClusterQueue names may be longer, the LocalQueue is namedgpustack-fnv64-${fnv64a(ClusterQueue name)}— always 31 characters — and records the full ClusterQueue name in theschedule.gpustack.ai/queueannotation.NodeDevicesAdmissionReconciler(node_devices_admission.go) provides the per-card AdmissionCheck.installKueueapplies thegpustack-node-devicesAdmissionCheck object right after the Kueue install (Kueue's CRD is runtime-installed, so the chart cannot); the reconciler keeps itActive, and theNodeQueueReconcilermakes the accelerated queue reference it inspec.admissionChecksStrategyonly once it is Active. After Kueue reserves quota, the check reads the assigned pool'sDevicesledger (uncached, viaAPIReader) and computes per-card feasibility (Remaining ≥ demand: a whole card for exclusive,.sliced.unitsfor sliced, an owner share for shared). Since the ledger seeds every card atM, an exclusive over-admit that coarsecreditslet through (a scalar total can hide that no single card is free) is caught exactly and held withRetry— a transient state that self-heals when Kueue re-admits after the backoff. This is a check-only gate: it never preempts and neverRejected.
Five-gate admission. Together these form a layered admission model where Kueue is a coarse gate, not the ledger — each gate produces or consumes the .sliced.* values at a distinct point along the path:
- GPUStack Operator Worker — Pod webhook — for a sliced request it folds
.sliced.memory-percentage/.sliced.memory-mibinto.sliced.units, dividing the memory demand by the pool InstanceType's per-cardMemory(never the user-writable LocalQueue), so a slice's memory participates in the Kueuecreditsfold-down before Kueue ever scores it. - Kueue
credits— coarse total admission by fractional scoring (capacity × M); ensures the pool has enough aggregate capacity, but a scalar total cannot see per-card fragmentation. NodeDevicesAdmissionAdmissionCheck — introspects every node of the assigned ResourceFlavor through theDevicesledger for per-card feasibility, closing the credits "over-admit exclusive" gap (a scalar total cannot see that 8 cards each 50 %-sliced satisfy no 5-exclusive request).- Default scheduler / kubelet — node-level counting of the remaining
.sliced/.sliced.units/.sliced.cores-percentage/.sliced.memory-*capacities picks the best-fitting node among that ResourceFlavor's nodes. - GPUStack Operator DeviceManager — allocator — at
Allocate, performs the actual per-card device selection, container injection (visibility env / runtime-isolation preload), and records the allocation into theDevicesledger. Real per-slice runtime isolation exists today for NVIDIA and Ascend (preload library + per-container compute/VRAM quota), for MThreads (MTHREADS_QOS_*env vars consumed by the host sGPU kmod), and for Hygon (a per-podvdev.conf— acores%-derived CU bitmask + VRAM cap — mounted read-only at/etc/vdev/docker/and read by the host DTK/hyhal runtime); the two remaining sliceable vendors (Cambricon / MetaX) advertise sliced capacity but their slicedAllocateis still a whole-card passthrough stub with no isolation — a tracked follow-up..sliced > 1support is vendor-specific: only NVIDIA meaningfully serves multiple slices co-located in one container; the other vendors emit one slice per physical card (dedup by device), so two same-card tokens are under-served while over-charged — the Pod webhook should reject.sliced > 1for non-NVIDIA vendors (a tracked follow-up).
The Devices CR AcceleratorAllocation ledger is the single authoritative accounting, written below the kubelet by the device-plugin Allocate for every allocation (Kueue-routed or not); it drives the three-view and is the store beneath gate 3, not a numbered gate itself.
Three-view status. InstanceType.status carries three per-card bin-packing projections computed from the Devices ledger (not a credits fold-down): Accelerator = free whole cards; AcceleratorShared = shareable ownership slots; AcceleratorSliced = sliceable VRAM-percent units. Because the reconciler watches the Devices CR and writes the result into a real CRD's .status, a kubectl get instancetype -w observes capacity move as pods allocate and free — a read-only projection over the ClusterQueue could not (it borrows the CQ resourceVersion, unchanged on a Devices-only allocation). The v1 (worker.gpustack.ai/v1) InstanceType is a thin proxy + conversion over the real v1alpha1 CRD; the unit spec lives only on the InstanceType — a derived type is stamped with the fixed default at creation, and an admin edit changes only the InstanceType (never a Node or the ClusterQueue notes). The InstanceType validating webhook requires the complete input on create — acceleratorGroup (only when acceleratable) / os / arch plus the unit triple (unitResources.cpu/.ram + localStorage; an empty or partial spec is rejected), read independently of any editable setting, and a CPU-only (acceleratable=false) type's unit CPU must be exactly 1 core (an accelerated type keeps any unitless positive integer) — and freezes the spec on update: every field is immutable except displayName (rename) and inactive (take in/out of service), so to re-size or re-point a pool you delete and re-create the type. Create-time shape checks are not re-run on update (only immutability is), so a legacy type stored before a tightened rule can still be renamed or deactivated. The defaulting webhook defaults an empty generalGroup to the generic sentinel, stamps the pool's schedule labels (nodefeature.PoolScheduleLabels, grouped by instance-type-aware-cpu-manufacturer) and the schedule.gpustack.ai/queue-entrance label (nodefeature.FormatLocalQueueName(name)), enriches the descriptors from a matching ResourceFlavor, and — when awareness is on — folds that flavor's cpuDetail note into spec.cpu (generic) or spec.accelerator.cpu (accelerated). The Instance validating webhook enforces the unit spec on Create and Update: a submission's RAM must not exceed unitRAM × count and its local storage must not exceed the InstanceType's LocalStorage. The Pod webhook reads the InstanceType for a slice's per-card VRAM divisor — reverse-looked-up by the schedule.gpustack.ai/queue-entrance label to spec.memory (Stage 4, Pod admission webhook) — never the user-writable LocalQueue.
Pod admission webhook. A pods CREATE webhook (objectSelector on kueue.x-k8s.io/queue-name, failurePolicy: Fail) mutates a sliced request — defaulting .sliced.cores-percentage to 100 and folding .sliced.memory-percentage / .sliced.memory-mib into .sliced.units (only when units are absent; memory-percentage wins) — and validates it (rejecting no-memory / both-memory-keys / non-positive .sliced.* / mixed modes). The per-card VRAM divisor for the fold is read from the operator-owned InstanceType's spec.memory (reverse-looked-up by the schedule.gpustack.ai/queue-entrance label), never from the user-writable LocalQueue. Its MutatingWebhookConfiguration name sorts before kueue-mutating-webhook-configuration so the fold runs before Kueue hashes container resources.
Running-instance stop. Before (re)creating an Instance's Pod, the InstanceReconciler (instance.go) reads the backing ClusterQueue's StopPolicy and stops the Instance (spec.stop=true) — rather than recreating a Pod the queue can never admit — when that queue is HoldAndDrain (a pool drain or a teardown that evicts admitted workloads), or when the InstanceType is being deleted or gone. An admin Hold (the Inactive switch) is deliberately not a stop: already-running Pods keep running and a new Instance simply stays pending. The InstanceType phase cannot drive this — it collapses both Hold and a fully-drained HoldAndDrain to Inactive, and a fast drain clears the reservation before a durable Draining phase is ever observed — so the stop keys on the StopPolicy. A ClusterQueue watch (on its StopPolicy) re-enqueues the type's Instances so the stop is prompt even when no Pod event fires; the InstanceType watch is narrowed to the deletion signal for a prompt teardown stop.
Known behavior: the Kueue feature gate
AssignQueueLabelsForPodsis disabled at installation (pkg/worker/kuberess/apps_kueue.go), so Kueue never copies cluster/local queue names onto Pod labels — long ClusterQueue names would not fit a label value. The deployed Kueue Configuration also setsresources.quotaCheckStrategy: IgnoreUndeclared, so a single-dimension queue (onlycpu, or only the manufacturercredits) does not reject a Workload for the other Pod resources (memory/ephemeral-storage) it does not cover.
For a recorded end-to-end run on a live cluster — the materialized ResourceFlavor / ClusterQueue /
InstanceType / LocalQueue objects with real YAML, then removing/re-adding a node, requesting a sliced
GPU (with nvidia-smi showing the capped VRAM), authoring a custom InstanceType, and switching
CPU-manufacturer awareness on — see the walkthrough.