Lessons
20 — Training/inference scheduling / Kueue / Volcano

Lesson 20 — AI/ML platform: training/inference scheduling — gang, Kueue/Volcano, fair-share queues

2026-09-28

What interviewers are really probing

Lesson 18 gave you SKU pools. Lesson 19 made NVLink domains and RDMA rails schedulable. This lesson asks the operator question that kills GPU utilization in interviews and in production: can you admit multi-pod training without half-placing a gang — and share the fleet fairly without starving inference SLOs?

Interviewers at hyperscale AI platforms (fuel: wafer-ai/gpu-perf-engineering-resources (opens in a new tab)) want evidence you have run batch queues under contention, not only “we use the default scheduler”:

  • Vanilla kube-scheduler + Job parallelism fails for training — partial place → idle GPUs holding ranks → NCCL hang / watchdog abort (L1, L4)
  • Gang scheduling semantics — all-or-nothing / minAvailable; topology from L19 is a packing constraint, not a hope
  • Kueue — ClusterQueue, LocalQueue, ResourceFlavor, Cohort, borrowing/lending, fair-sharing, preemption, Workload CR; Job / JobSet / Kubeflow MPIJob / PyTorchJob admission
  • Volcano — Queue, PodGroup, gang plugins, queue priority — when it shows up vs Kueue in interviews
  • Fair-share / hierarchical queues — team quotas vs capacity planning; reclaim loaned capacity
  • Training vs inference — exclusive gang train ≠ latency-SLO inference; don’t share without isolation (L14)
  • Topology-aware + queue — flavors for NVLink/RDMA SKUs; don’t admit a gang that can’t pack into one fabric island
  • Orchestration vs batch queue — Temporal/Argo (L16) own workflow DAG / retries; Kueue/Volcano own when GPU capacity is reserved
  • Security floor on queues — queue admins ≠ weight writers; admission on CQ/LQ; signed controller images; preemption must not leave weight-writable sidecars (L11–L14)

Interview sentence: “I admit training as a gang through Kueue (or Volcano PodGroup) against topology-aware flavors, fair-share cohorts reclaim loaned GPUs, train and inference never share without isolation, and queue RBAC never implies weight-bucket IAM.”

Core diagram: Jobs → LocalQueue → ClusterQueue/Cohort → Flavors → schedule → nodes (+ Volcano path, security floor)

Training/inference scheduling: Jobs/JobSets to LocalQueue to ClusterQueue/Cohort fair-share to ResourceFlavors to kube-scheduler and GPU nodes; Volcano PodGroup side path; security floor

Read it left-to-right: A training Job / JobSet / MPIJob / PyTorchJob lands on a namespace LocalQueue (label kueue.x-k8s.io/queue-name). That queue points at a cluster-scoped ClusterQueue inside a Cohort that fair-shares and can borrow/lend with preemption on reclaim. Admission picks a ResourceFlavor (SKU + topology labels). Only then does kube-scheduler (optionally with Kueue TAS) place pods — ideally into one fabric island from L19. Volcano is the alternate path: its Queue + PodGroup gang-binds via plugins and replaces default scheduling for those pods. Inference is a different product: latency SLOs, not exclusive gangs — isolate pools/queues. Everything sits on a security floor: who can mutate queues, who can write weights, signed controller + train images, WI/IRSA, NetPol, and preemption that tears down the whole gang cleanly.

Idiosyncrasies / operator depth

1. Why vanilla kube-scheduler + Job parallelism fails for multi-pod training

What you didWhat happensWhy it hurts
Job.spec.parallelism: 8Scheduler places pods independently5 pods Running, 3 Pending forever → 5×8 GPUs idle in NCCL wait
“Enough” free GPUs cluster-wideFragmented across racks/blocksPartial place + bad topology (L19)
Rely on completions / restartHang looks like framework bugWatchdog / NCCL timeout; ticket says “PyTorch” — root cause is admission
No timeout on partial placeHead-of-line wasteOther gangs blocked behind stuck partials

Interview sentence: “Job parallelism is not gang scheduling — without all-or-nothing admission you donate GPUs to a hang.”

2. Gang scheduling semantics (all-or-nothing / minAvailable)

ConceptOperator meaning
All-or-nothingEither the gang’s minimum members bind (or are ready), or none keep capacity
minAvailable / minMemberThreshold for “enough ranks to start” (often = world size; sometimes loosely for elastic)
Admission vs bindKueue: reserve quota (+ TAS domains) then schedule; waitForPodsReady requeues on timeout. Volcano: PodGroup gang plugin holds until minMember can schedule together
Topology interactionGang that cannot pack into one NVLink/RDMA island should not admit — don’t hope affinity fixes it later (L19)
PreemptionEvict whole lower-priority gangs (or reclaim borrowers); never leave orphan ranks

Interview sentence: “Gang is a capacity contract: admit only when the full ring fits — including topology, not just GPU count.”

3. Kueue building blocks

ObjectScopeRole
LocalQueueNamespaceUser-facing queue; Job label kueue.x-k8s.io/queue-name
ClusterQueueClusterQuota, flavors, preemption policy, cohort membership
ResourceFlavorClusterNode labels / taints mapping (SKU, spot vs on-demand, topology)
CohortLogical group of CQsBorrowing / lending unused nominal quota
Fair-sharingCohortWeights + share values decide who gets burst; reclaim via preemption
WorkloadCreated by kueueInternal CR tracking admission / eviction for a Job/JobSet/…
AdmissionControllersSuspend Job until Admitted; inject flavor node selectors

Integrations interviewers name-drop: plain batch/v1 Job, JobSet, Kubeflow MPIJob / PyTorchJob, RayJob, LeaderWorkerSet — kueue admits them as Workloads when the framework controller is wired.

Interview sentence: “LocalQueue is the team UX; ClusterQueue is the quota contract; ResourceFlavor is how SKU and topology become admit-time constraints.”

4. Volcano vs Kueue — when each shows up

AxisKueueVolcano
ModelAdmission + queueing in front of kube-schedulerReplacement scheduler (schedulerName: volcano)
GangAll-or-nothing via TAS + waitForPodsReady (timeout requeue)PodGroup minMember / gang plugin — classic interview “gang” word
Fair-shareCohort + fair sharing / borrowingQueue weight, DRF / proportion plugins
API surfaceClusterQueue, LocalQueue, ResourceFlavor, WorkloadQueue, PodGroup, volcano Job, plugins
Interview tellMulti-tenant ML platform on stock kube-scheduler + autoscalerBatch/HPC lineage, “we run Volcano for gang”

Interview sentence: “Kueue is quota admission that keeps kube-scheduler; Volcano is a batch scheduler with PodGroup gang — I pick based on whether we own scheduling or only admission.”

5. Fair-share, hierarchical queues, team quotas vs capacity planning

PracticeOperator truth
Nominal quotaGuaranteed GPUs per team CQ — capacity plan #
Borrow / lendBurst into unused cohort capacity; must reclaim when owner needs it
Fair-share weightsBurst allocation by share value, not first-come forever
Hard limits onlyLeaves idle GPUs on the table; constant quota thrash
Hierarchical org queuesPlatform → org → team CQs; don’t flatten politics into one FIFO
Capacity planningNominal sums ≤ real SKU pools (L18); oversubscribe deliberately with preemption policy

Interview sentence: “Quotas without reclaim are lies; fair-share without preemption is a suggestion.”

6. Training vs inference scheduling (don’t share blindly)

WorkloadScheduling shapeIsolation
Distributed trainExclusive gang; long-lived; topology-sensitiveTrain CQ + train flavors + train NetPol/NAD
Batch fine-tuneGang or single-node; preemptible often OKSame train cohort with lower priority
Online inferenceDeployment / LWS; latency SLO (TTFT/TPOT → Lesson 21)Separate CQ/pool; never borrow train NVLink islands for “just one more replica” without policy
Offline batch inferJob-like; can share train cohort carefullyPriorityClass + flavor that excludes train-only rails

Interview sentence: “Training buys atomic GPU sets; inference buys tail latency — one ClusterQueue for both is how you get both angry.”

7. Topology-aware flavors — admit only what packs

Flavor patternLabels / constraintsFootgun
h100-nvlinksku=h100, nvlink-domain, block/rackAdmitting 16-GPU gang across two blocks
h100-rdmardma-rail-count≥4, train pool taintFlavor matches A100 MIG nodes “with a GPU”
TAS (Kueue)Topology domains at admit timeQuota free but domains fragmented → should stay Pending at queue, not half-run
Spot flavorlifecycle=spotTrain checkpoints + spot without reclaim story (L13)

Interview sentence: “ResourceFlavor is where L18 SKUs and L19 topology become admission predicates — if the island can’t fit the gang, don’t Admit.”

8. Orchestration vs batch queue (L16)

LayerOwnsDoes not own
Temporal / ArgoDAG, retries, artifacts, human stepsGPU quota fairness across teams
Kueue / VolcanoWhen capacity is reserved / preemptedBusiness workflow semantics
TogetherArgo step submits Job labeled for LocalQueue; Temporal activity waits on Workload AdmittedDon’t invent a second GPU scheduler inside the workflow engine

Interview sentence: “Argo starts the Job; Kueue decides if the fleet can afford the gang today.”

Concrete commands & config

Inspect Kueue objects & Workload state

# Queues & flavors
kubectl get clusterqueues.kueue.x-k8s.io -o wide
kubectl get localqueues.kueue.x-k8s.io -A
kubectl get resourceflavors.kueue.x-k8s.io
kubectl describe clusterqueue team-a-cq
 
# Workloads: Admitted / Evicted / Finished
kubectl get workloads.kueue.x-k8s.io -A \
  -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CQ:.status.admission.clusterQueue,STATE:.status.conditions[-1:].type
 
kubectl describe workload -n model-training JOB_NAME
kubectl get events -n model-training --field-selector reason=Admitted,reason=Evicted,reason=QuotaReserved
 
# Optional CLI (if installed)
kueuectl list workload -n model-training
kueuectl list clusterqueue

Volcano inspect

kubectl get queues.scheduling.volcano.sh
kubectl get podgroups.scheduling.volcano.sh -A
kubectl describe podgroup -n model-training train-gang
# volcano-cli where available:
# vcctl queue list && vcctl job list -n model-training
kubectl get pods -n model-training -o wide --field-selector=status.phase=Pending
kubectl describe pods -n model-training -l volcano.sh/job-name=train-gang | sed -n '/Events:/,$p'

YAML: ResourceFlavor + ClusterQueue + LocalQueue + Job

apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: h100-nvlink
spec:
  nodeLabels:
    sku: h100
    pool: train-exclusive
    # Topology island — tighten to your inventory ([L19])
    block: cell-a-block-3
  # optional: nodeTaints + tolerations to match train taints
---
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: team-a-cq
spec:
  cohort: ml-platform
  queueingStrategy: BestEffortFIFO   # or StrictFIFO — know HOL blocking
  namespaceSelector: {}               # restrict in prod
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu", "cpu", "memory"]
      flavors:
        - name: h100-nvlink
          resources:
            - name: nvidia.com/gpu
              nominalQuota: 64
              borrowingLimit: 32
            - name: cpu
              nominalQuota: "512"
            - name: memory
              nominalQuota: 4Ti
  preemption:
    withinClusterQueue: LowerPriority
    reclaimWithinCohort: Any
  # fairSharing:
  #   weight: 25
---
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: team-a-train
  namespace: model-training
spec:
  clusterQueue: team-a-cq
---
apiVersion: batch/v1
kind: Job
metadata:
  name: train-gang-demo
  namespace: model-training
  labels:
    kueue.x-k8s.io/queue-name: team-a-train
  annotations:
    # Framework-specific: JobSet / MPIJob similarly labeled
spec:
  parallelism: 4
  completions: 4
  # kueue suspends until Admitted when managed
  template:
    metadata:
      labels:
        app: train-gang
        trust-domain: train-prod
      annotations:
        k8s.v1.cni.cncf.io/networks: rdma-train-rail
    spec:
      runtimeClassName: nvidia
      serviceAccountName: weight-writer
      restartPolicy: OnFailure
      # Flavor injects selectors; still pin topology intent
      nodeSelector:
        sku: h100
        pool: train-exclusive
      containers:
        - name: trainer
          image: registry.example.com/ml/trainer@sha256:REPLACE
          resources:
            limits:
              nvidia.com/gpu: "8"
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              add: ["IPC_LOCK"]
              drop: ["ALL"]

YAML: Volcano PodGroup sketch

apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: ml-training
spec:
  weight: 1
  reclaimable: true
  capability:
    nvidia.com/gpu: 128
---
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: train-gang
  namespace: model-training
spec:
  minMember: 4
  minResources:
    nvidia.com/gpu: "32"
  queue: ml-training
  priorityClassName: train-high
---
# Pods / volcano Job set schedulerName: volcano and
# annotation scheduling.k8s.io/group-name: train-gang (pattern varies by version)

Admission sketch: queue mutation ≠ weight IAM

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: kueue-queue-admin
rules:
  - apiGroups: ["kueue.x-k8s.io"]
    resources: ["clusterqueues", "resourceflavors", "localqueues"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  # Explicitly NOT: secrets, IAM bindings, bucket policies
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: kueue-job-floor
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: ["batch"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["jobs"]
  validations:
    - expression: >
        !has(object.metadata.labels) ||
        !has(object.metadata.labels["kueue.x-k8s.io/queue-name"]) ||
        (has(object.spec.template.spec.serviceAccountName) &&
         object.spec.template.spec.serviceAccountName != "default" &&
         has(object.spec.template.spec.runtimeClassName) &&
         object.spec.template.spec.runtimeClassName == "nvidia")
      message: "Kueue train Jobs require non-default SA + nvidia RuntimeClass"

Failures & fixes

SymptomLikely causeFix / mitigation
5/8 train pods Running, rest Pending; NCCL hangNo gang / no waitForPodsReady; vanilla Job parallelismKueue all-or-nothing + TAS; or Volcano PodGroup minMember; don’t start ranks until Admitted
Workload Pending forever; GPUs “free”Flavor mismatch (A100 job → H100-only CQ); wrong LocalQueueAlign ResourceFlavor labels; kubectl describe workload / CQ status
Team B idle while Team A stuckHead-of-line StrictFIFO; huge gang aheadBestEffortFIFO / priority; split interactive vs train queues
Borrowed GPUs never returnNo reclaimWithinCohort / preemption disabledEnable reclaim; PriorityClass; alert on CQ borrowing age
Preemption stormAggressive reclaim + short jobs thrashingDampen; borrowingLimit; separate inference CQ
Inference p99 explodes during train burstShared CQ/pool; train borrowed inference GPUsSeparate flavors/pools; NetPol + taints (L14, L18)
Admitted then Evicted on timeoutNodes slow / image pull / RDMA device pluginwaitForPodsReady backoff; mirror registry; pin digests (L12)
Gang packs but 10× slowTopology ignored at admitFlavors + TAS for block/rack; required affinity (L19)
Loaned capacity “returned” but ranks lingerPartial preemption left sidecarsPreempt whole Workload/PodGroup; forbid weight-writable sidecars on preemptible templates
Security finding: queue admin can s3:*Conflated platform RoleSplit RBAC; WI only on weight-writer SA (L13)
Unsigned kueue controller imageSupply-chain gap on admit pathCosign verify controllers + webhooks (L12)

Security & hardening

Queues and gangs concentrate power. Whoever admits a 64-GPU Workload can also become a weight exfil path if SA, images, and NetPol are sloppy. Preemption without a clean gang teardown leaves orphans that still mount checkpoints.

ControlWhyPractice
Admission on ClusterQueue / LocalQueueCreating a CQ is creating money + blast radiusRestrict CQ/Flavor mutate to platform; teams only create LocalQueue in their NS (L11)
Queue admins ≠ weight writersQueue RBAC must not imply object-store adminSeparate ClusterRoles; weight-writer SA via WI/IRSA only on Jobs that need checkpoints (L13)
Bucket IAM / CMEKAdmitted gangs pull/push checkpoints hardPer-Workload WI; CMEK; no public buckets; no node-wide s3:* / gs://* because “train nodes need storage”
Registries + signing / provenanceCompromised train image × gang = fleet-widePrivate registry; cosign verify; digest-pin Job images; also sign kueue/volcano controller + webhook images (L12)
SecretsHF tokens / registry creds in preempted podsExternal Secrets / CSI; short-lived OIDC; never bake tokens into images
Network isolationTrain gangs on RDMA + weight endpointsDefault-deny NetPol; allow DNS, private registry, weight VPC endpoints, train NAD; isolate inference NS (L9, L14)
Namespace isolation for teamsLocalQueue is per-NS for a reasonOne team NS ↔ one (or few) LocalQueues; no cross-NS Job create
Preemption safetyEviction mid-checkpointEvict whole Workload/PodGroup; drain sidecars; forbid privileged / hostPath / weight-writable sidecars on preemptible templates; checkpoint to CMEK with fsync story (L13)
Admission on Workloads / JobsLabel alone is not a security boundaryVAP/Kyverno: require non-default SA, RuntimeClass, digest, queue-name allowlist
Controller least privilegekueue/volcano SAs are high valueNarrow ClusterRoles; PSS on system NS; audit webhook configs

AI/ML weight & registry hardening on queued gangs (required)

ScenarioWeight / registry controls
Admitted train gangDigested trainer image; WI to CMEK checkpoint prefix; no shared “train-node” IAM
Preempted / Evicted WorkloadTear down all ranks; revoke short-lived creds; don’t leave PVC with world-readable weights
Borrowed cohort capacitySame IAM as nominal — borrowing GPUs ≠ borrowing another team’s bucket
Queue admin break-glassStill cannot push to prod weight registry without separate provenance gate
Inference vs trainInference SA read-only weights; train SA write checkpoints; never one SA for both (L14)
# Sketch: queue + weight floor (policy-as-code shape)
apiVersion: policy.platform/v1
kind: MlQueueGate
metadata:
  name: kueue-weight-floor
spec:
  match:
    labels:
      kueue.x-k8s.io/queue-name: "*"
  require:
    imageDigestPin: true
    cosignVerify: true
    runtimeClass: nvidia
    serviceAccountNotDefault: true
    workloadIdentity: true
    weightBucketPublicAccess: false
    cmek: true
    forbidNodeWideObjectIAM: true
    networkPolicy: deny-by-default
    preemptibleSidecarWeightWrite: false
    kueueControllerImageSigned: true
    volcanoControllerImageSigned: true
    queueAdminImpliesWeightIAM: false

Punchline: Fair-share and gang admission are capacity features. RBAC splits, signed images (including controllers), WI/IRSA, NetPol, and whole-gang preemption are what stop a Cohort from becoming a multi-tenant weight breach.

Interview soundbites

  • “Job parallelism is not a gang — partial place is how you waste a rack.”
  • “Kueue admits quota; kube-scheduler places; Volcano does both for its pods.”
  • “LocalQueue is UX; ClusterQueue is the contract; ResourceFlavor is SKU + topology.”
  • “Borrow without reclaim is theft with good intentions.”
  • “Fair-share needs preemption or it’s a story you tell finance.”
  • “Don’t admit a gang that can’t pack into one fabric island.”
  • “Train gangs and inference SLOs don’t share a ClusterQueue without isolation.”
  • “Argo orchestrates steps; Kueue orchestrates GPU scarcity.”
  • “Queue admin ≠ weight writer ≠ registry pusher.”
  • “Preempt the whole Workload — orphan ranks still hold checkpoints.”
  • “Sign kueue controllers like you sign trainers.”
  • “Next: inference SLOs — TTFT/TPOT, goodput, KV-cache as capacity.”

How this maps to the JD

Hyperscale + AI/ML platform JDs that mention GPU fleets, multi-tenant training, and fair capacity are asking whether you can operate admission under contention: gang semantics, Kueue/Volcano, fair-share cohorts, topology-aware flavors, and the train vs inference split — with the same security bar as any other cell. This lesson extends L18/L19 (pools + topology) with L4/L1 (fleet packing / Borg instincts), L16 (orchestration ≠ batch queue), and L11–L14 (admission, supply chain, weights, deploy isolation). Fuel: wafer-ai/gpu-perf-engineering-resources (opens in a new tab). Next up — Lesson 21: inference serving SLOs — TTFT/TPOT/goodput, continuous batching, KV-cache as capacity.

Watch (talks & demos)

Real talks — watch after Failures & fixes.

Multitenancy and Fairness at Scale with Kueue: A Case Study (Aldo Culquicondor & Rajat Phull)

Cohorts, borrowing, fair-share weights, and reclaim-via-preemption in production ML platforms — the interview core for “how do teams share GPUs without lying about quotas.”

Open on YouTube (opens in a new tab)

Building a Batch System for the Cloud with Kueue (Aldo Culquicondor & Kante Yin)

ClusterQueue / LocalQueue / ResourceFlavor, flavor injection, and preemption when reclaiming loaned capacity — how kueue sits in front of kube-scheduler without replacing it.

Open on YouTube (opens in a new tab)

Lightning Talk: Optimizing AI Workload Scheduling at Scale with Kueue (P. Matam)

Practical cluster-queue / local-queue / flavor / cohort vocabulary for AI training admission — tight interview flashcard after the two deeper talks.

Open on YouTube (opens in a new tab)

Volcano — Advanced Batch Scheduling for Kubernetes (CNCF Incubation series)

Gang scheduling, multi-queue fair-share, and topology-aware batch placement — the Volcano side of “Kueue vs Volcano” interview contrasts.

Open on YouTube (opens in a new tab)

References

Quick self-check

Out loud, in under two minutes:

  1. Why does Job parallelism without gang semantics waste GPUs on multi-pod training?
  2. Name Kueue’s LocalQueue, ClusterQueue, ResourceFlavor, Cohort — and what each decides.
  3. How does Volcano’s PodGroup minMember differ from Kueue’s admit + waitForPodsReady?
  4. What breaks if a team borrows cohort GPUs forever with no reclaim/preemption?
  5. Why must train gangs and latency inference not share a ClusterQueue without isolation?
  6. List four security controls that must hold when an admitted gang pulls model weights — including why queue admins must not be weight writers.