Volcano and Kueue: gang scheduling, queues and GPU quotas for distributed workloads on Kubernetes

Contents

TL;DR

Volcano (volcano-sh, CNCF incubating) is a complete batch scheduler that replaces or complements the kube-scheduler: it places pods with gang semantics (all-or-nothing via PodGroup/minMember), manages queues with priority, DRF fair-share and preemption between queues, and understands network and NUMA topology.

Kueue (kubernetes-sigs/kueue) is a queue and quota manager at the Job level: it does NOT place pods (it delegates to the kube-scheduler or to Volcano), but it decides when a workload can be admitted according to available quota (ClusterQueue/LocalQueue/Cohort), with fair sharing, borrowing between teams and preemption by priority. It natively integrates Job, JobSet, RayJob, all the Kubeflow operators and more.

The winning combination in production for multi-tenant GPU workloads is: Kueue for quota and queues + Volcano (or the sig-scheduler coscheduling plugin) for the job’s gang.


The analogy

Imagine a club with limited capacity and a dance floor inside.

Kueue is the doorman and the booking manager: it checks whether the team’s quota (its reserved capacity) allows the group in, applies the fair waiting list, borrows capacity from other teams if any are idle, and takes the space back when the owner needs it. But the doorman does not decide where each person sits inside the venue.

Volcano is the head waiter: once the group has permission to come in, he decides which tables they sit at, makes sure the whole group sits down at once or nobody comes in (gang), picks the tables according to topology (who needs to talk to whom) and throws out lower-priority groups to make room if necessary.

Without a doorman (Kueue), the head waiter does not know how many groups he can take at once or whether a team is exceeding its capacity. Without a head waiter (Volcano), the doorman lets the group in but its members scatter across the available tables on their own, and the party of 8 that needs to sit together never manages it.


The problem neither solves by default: the kube-scheduler

Kubernetes’ kube-scheduler is a pod scheduler, not a job scheduler. It assigns pods one by one to the most suitable node according to available resources and affinity constraints. For a distributed training workload that needs, say, 8 pods with 4 GPUs each (32 GPUs in total across 8 nodes of 4×H100), the standard scheduler does the following:

  1. It looks for a node with 4 available GPUs. It finds one. It schedules pod 1.
  2. It looks for another node with 4 GPUs. It finds one. It schedules pod 2.
  3. It carries on until it reaches pod 6 and it turns out there are no longer any nodes with 4 free GPUs: the cluster has exactly 32 GPUs and other workloads are using some of them.
  4. Pods 1–5 are Running. Pods 6–8 are Pending.
  5. Pods 1–5 can do nothing without the others: a distributed PyTorch job needs all the workers to start before the torchrun process can begin. It waits with the resources occupied. Deadlock.

This is not a bug, it is the design: the kube-scheduler has no concept of “schedule this group of pods only if you can schedule all of them”. As a result:

  • The resources of pods 1–5 are locked up without producing work.
  • Other jobs that could run with the partial resources also wait.
  • If several jobs are in this situation, the cluster can end up with fragmented resources, no job running and everyone in circular deadlock.

On top of that, the kube-scheduler has no notion of:

  • Queues per team or project with relative priority.
  • Quotas of resources per team with the ability to borrow from idle teams.
  • Fair-share: if team A has spent weeks using 80 % of the cluster, it should wait longer than team B, which has spent weeks idle.
  • Inter-queue preemption: evicting a lower-priority job from another team to make room for this team’s urgent job.

Solving any of these problems requires adding a layer on top of the scheduler. Volcano and Kueue are the two dominant OSS solutions in 2026, with complementary architectural approaches.


Volcano: the batch scheduler

What it is and what it replaces

Volcano (volcano-sh) is a Kubernetes-native batch scheduler accepted by the CNCF as its first and only official container batch scheduling project (volcano.sh/en/docs). At version v1.15.x as of June 2026, with CNCF incubating status.

Volcano is not an addon to the kube-scheduler: it is an alternative scheduler (or a complementary one, depending on the configuration) that places pods. It is installed as a deployment, and jobs that want to benefit from its capabilities must use the volcano scheduler class in their pod spec (schedulerName: volcano) or use the VolcanoJob CRD.

Volcano’s central value proposition is that it treats groups of pods as atomic scheduling units, not individual pods. That is what makes it possible to solve the deadlock described above.

Gang scheduling via PodGroup

Volcano’s central mechanism is the PodGroup: a CRD that groups a job’s pods and defines how many must be schedulable before Volcano starts any of them.

# PodGroup for a distributed PyTorch training job: 8 workers, minimum 8
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: pytorch-train-pg
  namespace: ml-training
spec:
  minMember: 8          # all-or-nothing: if there is no room for 8, none starts
  minResources:
    nvidia.com/gpu: "32" # 8 pods × 4 GPUs = 32 GPUs minimum in the cluster
  queue: team-datos      # which Volcano queue this job is assigned to
  priorityClassName: high-priority

The minMember parameter implements all-or-nothing semantics: Volcano only assigns nodes to the group’s pods when it can assign at least minMember pods simultaneously. If the cluster does not have capacity for 8 GPU pods at this moment, no pod in the group moves out of Pending. Nothing is blocked, nothing is fragmented.

minMember can be lower than the job’s total pod count: this allows elastic gang scheduling, where the job can start with fewer workers and scale up, useful for jobs that tolerate a reduced worker count.

For a job’s pods to be associated with the PodGroup, they carry the annotation:

# Pod spec of the PyTorch worker
metadata:
  annotations:
    scheduling.volcano.sh/pod-group-name: pytorch-train-pg
spec:
  schedulerName: volcano
  containers:
    - name: trainer
      image: pytorch/pytorch:2.5-cuda12.4
      resources:
        limits:
          nvidia.com/gpu: "4"

Queue: queues with quotas and priority

Volcano introduces the Queue CRD to manage multiple tenants with independent quotas:

apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: team-datos
spec:
  weight: 4           # relative weight for fair-share between queues (proportion plugin)
  capability:         # absolute ceiling of resources this queue can use
    nvidia.com/gpu: "16"
  guarantee:          # guaranteed resource, never lent to other queues
    resource:
      nvidia.com/gpu: "8"
  reclaimable: true   # if true, others can reclaim the resources it lends when they need them
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: team-ia
spec:
  weight: 2
  capability:
    nvidia.com/gpu: "8"
  guarantee:
    resource:
      nvidia.com/gpu: "4"
  reclaimable: true

The weight field feeds the proportion plugin: queues compete for the cluster’s available resources in proportion to their weight. A cluster with 32 GPUs and two queues of weight 4 and 2 splits the GPUs in a 4:2 ratio (≈21 and 11 GPUs respectively) when both are saturated.

Scheduler plugins: DRF, binpack, topology-aware

Volcano implements its scheduling logic as a pipeline of actions and plugins:

Actions (what the scheduler does in each cycle):

  • enqueue: moves jobs from the waiting queue to schedulable when quota is available.
  • allocate: assigns nodes to schedulable pods.
  • preempt: evicts lower-priority pods to make room for higher-priority ones within the same queue.
  • reclaim: evicts pods from other queues that are using more than their guarantee to return resources to the owner.
  • backfill: fills idle resources with best-effort jobs that do not interfere with the rest.

Plugins relevant to GPU workloads:

PluginWhat it does
gangImplements the PodGroup’s all-or-nothing semantics
proportionFair-share by queue weight (proportional quota)
capacityQuotas with guarantee/capability and reclaim; a more expressive alternative to proportion
drfDominant Resource Fairness: multi-dimensional fair-share (CPU, memory, GPU)
binpackPacks pods onto the fullest nodes; reduces GPU fragmentation
priorityOrders jobs by priority within the same queue
nodeorderNode scoring according to multiple criteria (affinity, resources, spread)
task-topologyAffinity between pods of the same job (inter-GPU communication)
numa-awareNUMA affinity: aligns pods with the node’s NUMA socket to reduce memory latency

Network and NUMA topology (v1.11+)

Volcano v1.11 (February 2025) introduced Network Topology Aware Scheduling as a first-class feature (CNCF blog, March 2025). Distributed training jobs in a datacenter with a hierarchical network structure (spine/leaf, blocks of nodes with NVSwitch) can declare topology constraints:

# VolcanoJob with a network topology constraint
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: llm-pretrain
  namespace: ml-training
spec:
  minAvailable: 8
  schedulerName: volcano
  queue: team-datos
  plugins:
    ssh: []
    env: []
    svc: []
  networkTopology:
    mode: hard                 # hard: the job MUST satisfy the constraint
    highestTierAllowed: block  # pods cannot span beyond a network "block"
  tasks:
    - replicas: 8
      name: worker
      template:
        spec:
          containers:
            - name: trainer
              image: nvcr.io/nvidia/pytorch:25.01-py3
              resources:
                limits:
                  nvidia.com/gpu: "4"

The highestTierAllowed: block semantics instruct Volcano to place the 8 pods within the same network block (for example, all the nodes under the same access switch), minimising the inter-block traffic that degrades a distributed all-reduce.

NUMA awareness works in a similar way: with the numa-aware plugin, pods request a NUMA policy (single-numa-node, restricted, best-effort) and Volcano selects nodes where the requested CPU, memory and GPU resources are in the same NUMA domain, avoiding the remote memory access overhead (NUMA-crossing) that can degrade training throughput by 15-30 % on multi-socket nodes.

GPU virtualization in v1.11+

Volcano v1.11 also introduces support for dynamic MIG and vCUDA: instead of declaring nvidia.com/gpu: 1 for a whole GPU, workloads can declare nvidia.com/gpu-memory: 20Gi and Volcano (with the corresponding device plugin) dynamically provisions the MIG instance or the vCUDA partition. This is [project marketing with no independent benchmarks published as of June 2026], but the feature’s architecture is documented in the code.

What it replaces or adds to the default scheduler

Capabilitykube-schedulerVolcano
Placing pods on nodesYesYes (it replaces it for workloads marked with schedulerName: volcano)
Gang scheduling (all-or-nothing)NoYes (PodGroup + minMember)
Queues with priorityNoYes (Queue CRD)
Inter-queue fair-shareNoYes (DRF, proportion, capacity plugins)
Inter-queue preemptionNoYes (reclaim action)
Topology-aware (NUMA, network)Partial (node affinity)Yes (dedicated plugins)
Elastic gang (minMember < total)NoYes
Best-effort backfillNoYes
Framework integrationsPartialMPI, PyTorch, Ray, TensorFlow, Spark, Flink, Horovod

Kueue: the queue and quota manager

What it is and what it does NOT do

Kueue (kubernetes-sigs/kueue) is a Kubernetes-native system that manages quotas and how jobs consume them (kueue.sigs.k8s.io). Kueue decides when a job must wait, when it should be admitted (pods can be created) and when it should be evicted (active pods must be deleted).

Kueue’s central design principle is explicit in its documentation: avoid duplicating mature functionality of Kubernetes components. Autoscaling is the cluster-autoscaler’s responsibility. Pod-to-node scheduling is the kube-scheduler’s responsibility. Job lifecycle management is the kube-controller-manager’s responsibility. Kueue replaces none of them: it sits on top as a layer of admission control and quota management at the Job level.

This is the fundamental distinction: Kueue does not place pods on nodes. When Kueue admits a workload, it simply allows the corresponding job controller to create the pods, and those pods are scheduled by the kube-scheduler (or by Volcano, if it is configured as the scheduler).

The four core objects

ResourceFlavor: maps abstract resources to concrete groups of physical nodes.

apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: h100-sxm
spec:
  nodeLabels:
    accelerator: h100-sxm
    node-pool: gpu-training
  tolerations:
    - key: "nvidia.com/gpu"
      operator: "Exists"
      effect: "NoSchedule"

ClusterQueue: defines the resource quota per flavor for a tenant. A cluster-scoped object.

apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: cq-team-datos
spec:
  cohort: llm-platform           # cohort it belongs to (can lend/borrow)
  queueingStrategy: BestEffortFIFO
  namespaceSelector:
    matchLabels:
      kubernetes.io/metadata.name: ns-datos
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu", "cpu", "memory"]
      flavors:
        - name: h100-sxm
          resources:
            - name: "nvidia.com/gpu"
              nominalQuota: 16        # GPUs guaranteed for this team
              borrowingLimit: 8       # can take up to 8 additional GPUs from the cohort
              lendingLimit: 8         # can lend up to 8 of its 16 nominal GPUs
            - name: "cpu"
              nominalQuota: "128"
            - name: "memory"
              nominalQuota: "512Gi"
  preemption:
    reclaimWithinCohort: LowerPriority   # reclaims lent quota by evicting lower-priority jobs
    borrowWithinCohort:
      policy: LowerPriority
    withinClusterQueue: LowerPriority

LocalQueue: the namespace-scoped entry point for a team’s workloads. Jobs point to their LocalQueue; Kueue maps them to the corresponding ClusterQueue.

apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: lq-datos
  namespace: ns-datos
spec:
  clusterQueue: cq-team-datos

Cohort: groups ClusterQueues that can lend quota to each other. It is not a standalone CRD; it is declared as a field in the ClusterQueue (spec.cohort: name). Kueue aggregates the available quota of all the ClusterQueues in the cohort and lets any of them borrow what the others are not using, respecting the borrowingLimit and lendingLimit.

Fair sharing and preemption

Kueue implements Fair Sharing as the ordering policy for the queue of pending workloads (kueue.sigs.k8s.io/docs/concepts/fair_sharing): when several workloads compete for quota in the cohort, those belonging to ClusterQueues with higher accumulated historical usage have lower admission priority. This implements equitable sharing without permanently blocking any team.

Preemption in Kueue operates along two dimensions:

  • reclaimWithinCohort: the ClusterQueue that lends quota reclaims it by evicting workloads that are using it on loan, according to a priority policy.
  • withinClusterQueue: within the same ClusterQueue, lower-priority workloads are evicted to make way for higher-priority workloads from the same team.

Gang semantics in Kueue: all-or-nothing with ready Pods

Kueue provides gang admission at the Job level: it admits the complete workload only when all the necessary quota is available. If a RayJob needs 8 GPUs (1 head + 7 workers), Kueue does not admit the workload until 8 GPUs are available in the ClusterQueue (or borrowed from the cohort). waitForPodsReady with a timeout adds a second guarantee: if the created pods do not become Ready within the configured time, Kueue re-queues the workload and releases the quota (kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready).

This is gang semantics at the admission level, not at the pod placement level. It guarantees that the quota is available before creating the pods, but it does not guarantee that the kube-scheduler can place them all on concrete nodes at the same time. For that second guarantee you need Volcano or the coscheduling plugin.

Topology-Aware Scheduling (TAS)

Kueue v0.10+ introduces Topology-Aware Scheduling (kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling): it allows node topologies (blocks, subblocks, hosts) to be defined and workloads to request co-location levels. Kueue only admits the workload when it can satisfy the topological constraint, and it adds node selectors and taints at admission time so that the scheduler places the pods in the right topology.

TAS is configured with the Topology CRD:

apiVersion: kueue.x-k8s.io/v1beta1
kind: Topology
metadata:
  name: datacenter-topology
spec:
  levels:
    - nodeLabel: "topology.kubernetes.io/block"
    - nodeLabel: "topology.kubernetes.io/rack"
    - nodeLabel: "kubernetes.io/hostname"

And the ResourceFlavor references the topology:

apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: h100-sxm
spec:
  nodeLabels:
    accelerator: h100-sxm
  topologyName: datacenter-topology

Framework integrations

Kueue has built-in integration (no additional code) for the following workload types, activated with an annotation on the job:

metadata:
  labels:
    kueue.x-k8s.io/queue-name: lq-datos  # points to the team's LocalQueue

The supported workload types include: batch/Job, JobSet, RayJob, RayCluster, PytorchJob, TFJob, MPIJob, JAXJob, PaddleJob, XGBoostJob, TrainJob, AppWrapper, LeaderWorkerSet, Deployment, StatefulSet, and plain Pod/PodGroup.

For LLM workloads, the directly relevant cases:

  • RayJob (distributed training with Ray Train): Kueue admits the RayJob when there is quota for the whole Ray cluster (head + workers). Documented at docs.ray.io.
  • PyTorchJob (Kubeflow Training Operator): gang admission of the complete job.
  • JobSet: for coordinated multi-replica jobs (LWS, multi-step pipelines).
  • Deployment/StatefulSet: for continuous inference, allowing inference GPU quota to be managed the same way as training quota.

The key distinction: Volcano places, Kueue admits

This table sums up the fundamental architectural difference:

DimensionVolcanoKueue
Main roleScheduler (places pods on nodes)Admission controller + quota manager (decides when to create pods)
Gang schedulingYes, at the placement level (PodGroup/minMember)Yes, at the admission level (all-or-nothing on quota)
Multi-tenant quotasYes (Queue with capability/guarantee)Yes (ClusterQueue with nominalQuota/borrowingLimit)
Cohorts / borrowingPartial (reclaimable between queues)Yes (Cohort with explicit lendingLimit/borrowingLimit)
Fair sharingYes (DRF plugin)Yes (Fair Sharing based on historical usage)
PreemptionYes (preempt + reclaim actions)Yes (reclaimWithinCohort, withinClusterQueue)
Topology/NUMAYes (dedicated plugins, network topology, NUMA-aware)Yes (TAS, topology levels in ResourceFlavor)
Framework integrationsVolcano Job (MPI, PyTorch, Ray, TF, Spark, Flink)Native: Job, JobSet, RayJob, Kubeflow, LWS, AppWrapper, Deployment, StatefulSet
Who places the podsVolcanokube-scheduler (or Volcano if configured)
Installation footprintMedium-high (its own scheduler, CRDs, webhook, metrics)Light (controller, CRDs, webhook; does not replace the scheduler)
Maturity / statusCNCF incubating; v1.15 (June 2026); production at Huawei, Baidu, DiDikubernetes-sigs; API v1beta2; production at Google GKE, Red Hat OpenShift 4.20, Runway ML
Adoption curveSteeper (requires changing schedulerName or using the VolcanoJob CRD)Gentler (adds labels to existing jobs; does not change the scheduler)

How they coexist: the production pattern

In production, Kueue and Volcano are complementary, not mutually exclusive. The most common pattern in 2026 for multi-tenant GPU clusters is:

  1. Kueue manages the global quota: which team can use how many GPUs, how much it can borrow, when a job goes into a queue versus being admitted.
  2. Volcano does the gang scheduling at the pod level: once Kueue admits the job (the quota is available), Volcano places the pods, making sure they are all placed simultaneously on compatible nodes.

The integration is configured by specifying schedulerName: volcano in the pod specs of the workloads managed by Kueue. Kueue sees the Job/RayJob/PyTorchJob and manages its quota; when it admits it, the pods are created and Volcano places them with gang semantics. Volcano’s PodGroups are created automatically by the Volcano Job controller or by the Kubeflow Training Operator itself when it detects that the scheduler is Volcano.

# PyTorchJob managed by Kueue (quota) + Volcano (gang placement)
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: llm-finetune-70b
  namespace: ns-datos
  labels:
    kueue.x-k8s.io/queue-name: lq-datos   # Kueue manages the quota
  annotations:
    scheduling.volcano.sh/queue-name: team-datos  # Volcano uses its own Queue
spec:
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      restartPolicy: OnFailure
      template:
        spec:
          schedulerName: volcano             # Volcano does the placement
          containers:
            - name: pytorch
              image: nvcr.io/nvidia/pytorch:25.01-py3
              resources:
                limits:
                  nvidia.com/gpu: "4"
    Worker:
      replicas: 7
      restartPolicy: OnFailure
      template:
        spec:
          schedulerName: volcano
          containers:
            - name: pytorch
              image: nvcr.io/nvidia/pytorch:25.01-py3
              resources:
                limits:
                  nvidia.com/gpu: "4"

The third way: the sig-scheduler coscheduling plugin

If you do not want to deploy a complete alternative scheduler but you need gang scheduling, there is the coscheduling plugin from kubernetes-sigs/scheduler-plugins. This plugin extends the kube-scheduler with a PodGroup mechanism similar to Volcano’s, implemented as a scheduling framework plugin (permit plugin). The advantage is that it does not replace the scheduler; the disadvantage is that it has less functionality than Volcano (no DRF, no Queue/fair-share, no network topology). It is the right option for simple clusters that only need gang and do not want Volcano’s operational complexity. Kueue can also work alongside this plugin.


Complete YAML examples

Volcano: Queue + PodGroup + VolcanoJob

# 1. Queue for the data team (16 nominal GPUs, ceiling at 24)
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: team-datos
spec:
  weight: 4
  capability:
    nvidia.com/gpu: "24"
    cpu: "192"
    memory: "768Gi"
  guarantee:
    resource:
      nvidia.com/gpu: "16"
  reclaimable: true
---
# 2. Queue for the AI team (8 nominal GPUs, ceiling at 16)
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: team-ia
spec:
  weight: 2
  capability:
    nvidia.com/gpu: "16"
    cpu: "64"
    memory: "256Gi"
  guarantee:
    resource:
      nvidia.com/gpu: "8"
  reclaimable: true
---
# 3. PodGroup: 70B fine-tuning job, 8 workers × 4 GPUs = 32 GPUs
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: finetune-70b-pg
  namespace: ns-datos
spec:
  minMember: 8
  minResources:
    nvidia.com/gpu: "32"
  queue: team-datos
  priorityClassName: training-high
---
# 4. VolcanoJob (wrapper that Volcano understands natively)
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: finetune-70b
  namespace: ns-datos
spec:
  minAvailable: 8
  schedulerName: volcano
  queue: team-datos
  priorityClassName: training-high
  plugins:
    env: []
    svc: []
  policies:
    - event: PodEvicted
      action: RestartJob
  tasks:
    - replicas: 8
      name: worker
      policies:
        - event: TaskCompleted
          action: CompleteJob
      template:
        metadata:
          annotations:
            scheduling.volcano.sh/pod-group-name: finetune-70b-pg
        spec:
          schedulerName: volcano
          containers:
            - name: trainer
              image: nvcr.io/nvidia/pytorch:25.01-py3
              command: ["torchrun", "--nproc_per_node=4", "--nnodes=8",
                        "--node_rank=$(RANK)", "--master_addr=$(MASTER_ADDR)",
                        "--master_port=23456", "train.py"]
              env:
                - name: NCCL_DEBUG
                  value: "INFO"
              resources:
                requests:
                  nvidia.com/gpu: "4"
                  cpu: "16"
                  memory: "64Gi"
                limits:
                  nvidia.com/gpu: "4"
                  cpu: "16"
                  memory: "64Gi"
          restartPolicy: Never

Kueue: ResourceFlavor + ClusterQueue + LocalQueue + annotated Job

# 1. ResourceFlavor: nodes with H100 SXM
apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: h100-sxm
spec:
  nodeLabels:
    accelerator: h100-sxm
  tolerations:
    - key: "nvidia.com/gpu"
      operator: "Exists"
      effect: "NoSchedule"
---
# 2. ClusterQueue for the data team: 16 nominal GPUs, can borrow 8
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: cq-team-datos
spec:
  cohort: llm-platform
  queueingStrategy: BestEffortFIFO
  namespaceSelector:
    matchLabels:
      kubernetes.io/metadata.name: ns-datos
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu", "cpu", "memory"]
      flavors:
        - name: h100-sxm
          resources:
            - name: "nvidia.com/gpu"
              nominalQuota: 16
              borrowingLimit: 8
              lendingLimit: 8
            - name: "cpu"
              nominalQuota: "128"
            - name: "memory"
              nominalQuota: "512Gi"
  preemption:
    reclaimWithinCohort: LowerPriority
    borrowWithinCohort:
      policy: LowerPriority
    withinClusterQueue: LowerPriority
---
# 3. ClusterQueue for the AI team: 8 nominal GPUs
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: cq-team-ia
spec:
  cohort: llm-platform
  queueingStrategy: BestEffortFIFO
  namespaceSelector:
    matchLabels:
      kubernetes.io/metadata.name: ns-ia
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu", "cpu", "memory"]
      flavors:
        - name: h100-sxm
          resources:
            - name: "nvidia.com/gpu"
              nominalQuota: 8
              borrowingLimit: 8
              lendingLimit: 4
            - name: "cpu"
              nominalQuota: "64"
            - name: "memory"
              nominalQuota: "256Gi"
  preemption:
    reclaimWithinCohort: LowerPriority
    withinClusterQueue: LowerPriority
---
# 4. LocalQueue in the data team's namespace
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: lq-datos
  namespace: ns-datos
spec:
  clusterQueue: cq-team-datos
---
# 5. LocalQueue in the AI team's namespace
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: lq-ia
  namespace: ns-ia
spec:
  clusterQueue: cq-team-ia
---
# 6. Batch inference RayJob managed by Kueue
apiVersion: ray.io/v1
kind: RayJob
metadata:
  name: batch-eval-llama70b
  namespace: ns-datos
  labels:
    kueue.x-k8s.io/queue-name: lq-datos    # Kueue manages the admission
spec:
  entrypoint: "python batch_eval.py --model /models/llama-70b"
  rayClusterSpec:
    headGroupSpec:
      rayStartParams:
        num-gpus: "4"
      template:
        spec:
          containers:
            - name: ray-head
              image: rayproject/ray-ml:2.40.0-gpu
              resources:
                limits:
                  nvidia.com/gpu: "4"
                  cpu: "16"
                  memory: "64Gi"
    workerGroupSpecs:
      - replicas: 3
        minReplicas: 3        # gang: Kueue does not admit if there is no quota for 3 workers
        maxReplicas: 3
        groupName: gpu-worker
        rayStartParams:
          num-gpus: "4"
        template:
          spec:
            containers:
              - name: ray-worker
                image: rayproject/ray-ml:2.40.0-gpu
                resources:
                  limits:
                    nvidia.com/gpu: "4"
                    cpu: "16"
                    memory: "64Gi"

Kueue + Volcano together: PyTorchJob with Kueue quota and Volcano gang placement

# PyTorchJob: Kueue controls the quota, Volcano does the gang placement
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: distributed-finetune
  namespace: ns-datos
  labels:
    kueue.x-k8s.io/queue-name: lq-datos      # Kueue: quota and admission
  annotations:
    # Volcano automatically creates the PodGroup when schedulerName=volcano
    scheduling.volcano.sh/queue-name: team-datos
spec:
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      restartPolicy: OnFailure
      template:
        spec:
          schedulerName: volcano              # Volcano: gang placement
          containers:
            - name: pytorch
              image: nvcr.io/nvidia/pytorch:25.01-py3
              resources:
                limits:
                  nvidia.com/gpu: "4"
                  cpu: "16"
                  memory: "64Gi"
    Worker:
      replicas: 7
      restartPolicy: OnFailure
      template:
        spec:
          schedulerName: volcano
          containers:
            - name: pytorch
              image: nvcr.io/nvidia/pytorch:25.01-py3
              resources:
                limits:
                  nvidia.com/gpu: "4"
                  cpu: "16"
                  memory: "64Gi"

For LLM workloads: training, fine-tuning and batch inference

Distributed multi-GPU training and fine-tuning

Gang scheduling is essential for any distributed training job that uses NCCL all-reduce (PyTorch DDP, FSDP, DeepSpeed ZeRO). If a single worker in the group does not start, the torchrun coordinator waits indefinitely; with the standard kube-scheduler this scenario happens every time the cluster is under contention.

In a generic cluster of 4 nodes of 4×H100 SXM (16 GPUs in total), a fine-tuning job for a 70B model typically requires 8 GPUs in tensor-parallel 8 (TP=8) or 16 GPUs in TP=4 plus data-parallel 4. With Volcano, the PodGroup with minMember: 8 guarantees that either the 8 pods are placed at once or none blocks resources. With Kueue on top, the quota guarantees that the team does not exceed its 16 nominal GPUs and that other teams with available quota are not blocked by a waiting job.

The cross-link with inference capacity planning is direct: the model’s VRAM budget (weights plus KV-cache) determines the minimum TP and therefore the PodGroup’s minMember.

Batch inference and evaluations (evals)

Batch inference jobs, generating responses for an evaluation dataset, processing embeddings in bulk, offline re-ranking, are naturally parallel workloads that do not necessarily require strict gang scheduling (each request is independent), but they do benefit from quota and fair-share.

For these workloads, Kueue alone is enough: a batch/Job with multiple independent pods is managed with the ClusterQueue’s quota with no need for Volcano. If several teams are submitting evaluation jobs simultaneously, Kueue orders the admission by fair-share and priority, and with cohort borrowing the jobs of teams with free quota do not have to wait for the quota of busy teams.

The chargeback of these workloads connects directly with what is described in GPU chargeback and showback: the ClusterQueue’s nominalQuota is the expression of the GPU budget in Kubernetes, and OpenCost can attribute the cost per namespace or label for the monthly report.

Multi-tenant GPU quota and chargeback

The alignment between Kueue and the chargeback system is direct:

FinOps conceptKueue mechanism
Guaranteed GPU budgetnominalQuota per ClusterQueue
Maximum spending ceilingnominalQuota + borrowingLimit
Lending idle capacityCohort + lendingLimit
Reclaiming your own quotapreemption.reclaimWithinCohort: LowerPriority
Fair-share between teamsFair Sharing policy in the ClusterQueue
Chargeback of the loanborrowed GPU hours × cost per GPU-hour (OpenCost)

For the utilisation dimension as a FinOps lever, see GPU utilisation as a FinOps lever: Kueue and Volcano together make it possible to maximise utilisation without sacrificing the quota guarantees, which is exactly the FinOps objective.

Managing MIG partitions within this system (declaring nvidia.com/mig-4g.40gb as a resource in the ClusterQueue) integrates naturally: the ResourceFlavor can map to nodes with a specific MIG profile, as explained in sharing a GPU: time-slicing, MPS and MIG.


Diagram: the flow of a training job with Kueue + Volcano

Distributed training job: Kueue (quota) + Volcano (gang placement)User / CIkubectl apply PyTorchJobKueue controllerquota available in the CQ?Waiting queuefair-share / priorityAdmission (quota OK)pods allowed; CQ reserves GPUVolcano schedulerPodGroup: minMember=8 gangs4×H100 SXM nodes8 pods × 4 GPU — all at onceIf there are no nodesno pod is placed → it waitsKueue: manages quota, cohorts, fair-share, preemption between queuesVolcano: gang placement (all-or-nothing), topology-aware, NUMA, DRF between QueuesThe two levels are orthogonal: Kueue sees no nodes, Volcano sees no team quota

Full comparison table

CriterionVolcanoKueueCoscheduling plugin
RoleScheduler (placement)Admission + quota (no placement)kube-scheduler plugin (placement)
Gang schedulingYes, pod level (PodGroup/minMember)Yes, admission level (quota gang)Yes, pod level (PodGroup)
Multi-tenant quotasYes (Queue capability/guarantee)Yes (ClusterQueue nominalQuota)No
Cohorts / borrowingLimited (reclaimable)Yes (Cohort with borrowingLimit/lendingLimit)No
Fair-shareYes (DRF, proportion)Yes (Fair Sharing by historical usage)No
Inter-queue preemptionYes (reclaim action)Yes (reclaimWithinCohort)No
Network / NUMA topologyYes (v1.11+, dedicated plugins)Yes (TAS, Topology CRD)No
Native integrationsMPI, PyTorch, Ray, TF, Spark, Flink, HorovodJob, JobSet, RayJob, Kubeflow, LWS, AppWrapper, Deployment, StatefulSetAny job with a PodGroup
Who places the podsVolcanokube-scheduler (or Volcano)kube-scheduler
Elastic gangYes (minMember < total replicas)Partial (partial admission in batch/Job)Limited
FootprintMedium-high (its own scheduler)Light (an additional controller)Minimal (a scheduler plugin)
Compatibility with KueueYes (as the scheduler under Kueue)Yes (a complementary option)
CNCF status / maturityCNCF incubating, v1.15kubernetes-sigs, v1beta2, adopted in GKE/OpenShiftkubernetes-sigs/scheduler-plugins, experimental
When to choose itHPC-like distributed training, NUMA, network topology, MPIMulti-tenancy with flexible quota, heterogeneous workloads, inference + batch togetherSimple clusters that only need gang without their own scheduler

Operational pitfalls and honest scepticism

1. Deadlock from a badly configured gang

The most frequent scenario: minMember set equal to the total replica count in a cluster where several jobs compete for the same resources. If two jobs of 8 pods each try to use a cluster with 8 GPU nodes and job A has 4 pods placed (not 8, so Volcano holds them pending), and job B also has 4 pods held, nobody moves forward. Volcano is doing its job properly: it places none of them until there is room for all 8. But if the queues’ nominalQuota values are badly sized against the cluster’s real capacity, this produces indefinite waits.

Solution: size the queue quotas so that the sum of guarantee does not exceed the real capacity, and so that the minMember values of the active jobs fit inside the available quota. Node autoscaling with ProvisioningRequest (Kueue + cluster-autoscaler) helps, but it introduces provisioning latency that has to be taken into account in the job’s SLA.

2. Quota versus real capacity: the silent drift

Kueue’s nominalQuota and Volcano’s guarantee are administrative declarations. They do not guarantee that the nodes with those GPUs are available, healthy or that the device plugin has registered them correctly. A node in NotReady with 4 GPUs reduces the real capacity without Kueue knowing: the ClusterQueue will carry on admitting workloads that then cannot be placed.

Recommended monitoring: cross Kueue’s metrics (kueue_admitted_workloads_total, kueue_pending_workloads) with the cluster’s real capacity metrics (GPUs registered in the device plugin) to detect the drift. Kueue exposes native Prometheus metrics; so does Volcano.

3. GPU resource naming: MIG, time-slicing and ResourceFlavor

If the cluster uses MIG, the resources in the pod specs change from nvidia.com/gpu to nvidia.com/mig-Xg.Ygb (for example, nvidia.com/mig-3g.40gb). Kueue’s ResourceFlavors and Volcano’s Queues must declare the right resource, or the quota will not match the pods. With time-slicing, the resource is still nvidia.com/gpu but the device plugin advertises more instances than there are physical GPUs; the quota is expressed in virtual replicas, which can lead to over-admission if the VRAM budget is not taken into account (see sharing a GPU: time-slicing, MPS and MIG).

4. Preemption in production: the evicted workload loses progress

When Kueue or Volcano evicts a training job that has been running for hours, that job loses its progress if it has no checkpointing configured. The preemption is correct from the quota point of view, but it destroys work if the job is not prepared for it. Before enabling aggressive preemption, verify that all training jobs have periodic checkpointing with automatic restoration. PyTorch plus Torchrun have native support; so does DeepSpeed. Stateless batch inference jobs do not have this problem.

5. Volcano as the sole scheduler versus coexistence with the kube-scheduler

Volcano can be configured as the cluster’s default scheduler (all pods go through it) or as an alternative scheduler (only pods with schedulerName: volcano). The first option simplifies the configuration but breaks system pods that assume kube-scheduler behaviour. The second, the recommended one, requires ML jobs to explicitly set schedulerName: volcano, which can be a non-trivial operator or chart change for existing workloads. Kueue solves this more transparently: it only requires a label on the job, with no change of scheduler.

6. Real operational complexity in 2026

Running Kueue plus Volcano plus the Training Operator plus the GPU Operator in production means four components with their own CRDs, webhooks, versions and release cycles. A Kubernetes upgrade may require updating all four in sequence. The operational debt is real. For a small team without the capacity to maintain this stack, a managed Kubernetes provider (GKE with native Kueue, OpenShift with the Red Hat build of Kueue) may be more pragmatic than assembling the full stack from scratch.

The sig-scheduler coscheduling plugin is a deliberately simpler option when all you need is gang: fewer features, less complexity, fewer things to maintain.


See also


Sources