DevOpsIndex

GPU Scheduling on Kubernetes

GPUs are exposed to Kubernetes as extended resources — not built-in like CPU/memory. A chain of components bridges the hardware to the pod spec.


How GPUs Become K8s Resources

sequenceDiagram
    participant D as NVIDIA Driver (host)
    participant P as Device Plugin (DaemonSet)
    participant K as kubelet
    participant S as API Server
    participant Pod as Your Pod

    D->>P: /dev/nvidia0, /dev/nvidia1 visible on node
    P->>K: Register via gRPC socket /var/lib/kubelet/device-plugins/
    K->>S: Advertise node capacity: nvidia.com/gpu: 2
    Note over S: Node allocatable: nvidia.com/gpu=2

    Pod->>S: Request resources.limits: nvidia.com/gpu: 1
    S->>K: Schedule pod to this node
    K->>P: Allocate(containerID, deviceIDs)
    P-->>K: Env: CUDA_VISIBLE_DEVICES=0, mount /dev/nvidia0
    K->>Pod: Start container with GPU device access

The Device Plugin API is a gRPC socket at /var/lib/kubelet/device-plugins/. Any hardware can be exposed via this interface — GPUs, FPGAs, InfiniBand NICs.


NVIDIA Device Plugin

Install

# Via Helm (preferred — handles DaemonSet + RBAC)
helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
helm install nvdp nvdp/nvidia-device-plugin \
  --namespace kube-system \
  --set failOnInitError=false

# Verify: node should show nvidia.com/gpu in allocatable
kubectl describe node <gpu-node> | grep -A5 "Allocatable:"
# Allocatable:
#   cpu:                15600m
#   memory:             60Gi
#   nvidia.com/gpu:     4       ← 4 GPUs available

Pod requesting a GPU

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: training
    image: nvcr.io/nvidia/pytorch:24.01-py3
    resources:
      limits:
        nvidia.com/gpu: 1       # request exactly 1 GPU
        memory: "16Gi"
        cpu: "4"
      requests:
        nvidia.com/gpu: 1       # must equal limits for GPU (no overcommit)
        memory: "16Gi"
        cpu: "2"
    env:
    - name: CUDA_VISIBLE_DEVICES   # set by device plugin automatically
      value: "0"                   # don't set manually — let the plugin do it

GPU resources are not overcommittable. requests must equal limits for nvidia.com/gpu. The scheduler guarantees one pod per GPU slot.


Node Taints for GPU Nodes

GPU instances are expensive. Prevent non-GPU workloads from landing on them:

# Taint GPU nodes — only pods with the matching toleration can schedule here
kubectl taint node <gpu-node> nvidia.com/gpu=present:NoSchedule

# GPU pods must have this toleration
tolerations:
- key: "nvidia.com/gpu"
  operator: "Exists"
  effect: "NoSchedule"
# Node selector to target GPU nodes specifically
nodeSelector:
  node.kubernetes.io/instance-type: p3.8xlarge   # AWS GPU instance type
  # or use a custom label:
  accelerator: "nvidia-tesla-v100"

MIG — Multi-Instance GPU

MIG (Multi-Instance GPU) partitions a single A100/H100 into up to 7 hardware-isolated instances. Each instance has its own:

  • CUDA engines
  • L2 cache partition
  • Memory bandwidth slice
  • Memory isolation — other instances cannot see this instance's memory
graph TD
    A100["A100 80GB GPU"] --> MIG1["MIG 1g.10gb<br/>1 CUDA engine<br/>10GB memory"]
    A100 --> MIG2["MIG 2g.20gb<br/>2 CUDA engines<br/>20GB memory"]
    A100 --> MIG3["MIG 2g.20gb<br/>2 CUDA engines<br/>20GB memory"]
    A100 --> MIG4["MIG 1g.10gb<br/>1 CUDA engine<br/>10GB memory"]
    A100 --> IDLE["remaining capacity<br/>(partial usage)"]

vs. CUDA_VISIBLE_DEVICES (soft isolation): Using env vars to restrict a container to one GPU still allows the process to see the full GPU memory — another process on the same GPU can interfere. MIG provides hardware-enforced isolation.

MIG profiles on A100

Profile CUDA engines Memory Instances max
1g.10gb 1/7 10GB 7
2g.20gb 2/7 20GB 3
3g.40gb 3/7 40GB 2
7g.80gb 7/7 80GB 1 (full GPU)

Expose MIG slices as K8s resources

# Check current MIG mode
nvidia-smi --query-gpu=mig.mode.current --format=csv

# Enable MIG mode on GPU 0
sudo nvidia-smi -i 0 -mig 1

# Create 7x 1g.10gb instances
sudo nvidia-smi mig -cgi 1g.10gb,1g.10gb,1g.10gb,1g.10gb,1g.10gb,1g.10gb,1g.10gb -C

With GPU Operator (automated), MIG resources appear as:

nvidia.com/mig-1g.10gb: 7
nvidia.com/mig-2g.20gb: 3

Pod requests a specific slice:

resources:
  limits:
    nvidia.com/mig-1g.10gb: 1   # one 10GB MIG slice

GPU Operator

The GPU Operator automates the entire GPU software stack via Kubernetes operators:

graph LR
    GO["GPU Operator<br/>(single Helm install)"] --> DRIVER["NVIDIA Driver<br/>DaemonSet (no host driver needed)"]
    GO --> DP["Device Plugin<br/>DaemonSet"]
    GO --> DCGM["DCGM Exporter<br/>GPU metrics --> Prometheus"]
    GO --> MIG_MGMT["MIG Manager<br/>DaemonSet (A100/H100 only)"]
    GO --> GFD["GPU Feature Discovery<br/>auto-labels nodes"]
    GO --> CT["Container Toolkit<br/>runtime config"]
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm install gpu-operator nvidia/gpu-operator \
  --namespace gpu-operator --create-namespace \
  --set mig.strategy=mixed   # 'single' = all GPUs same profile, 'mixed' = different per GPU

Why GPU Operator over manual installation?

  • No GPU driver installed on the host required — operator manages driver as a container
  • Automatic node labeling (nvidia.com/gpu.product=A100-SXM4-80GB)
  • DCGM exporter automatically deployed for GPU metrics
  • MIG configuration managed declaratively

GPU Metrics with DCGM Exporter

DCGM (Data Center GPU Manager) exposes GPU metrics to Prometheus:

# Key metrics
DCGM_FI_DEV_GPU_UTIL          # GPU utilization % (0-100)
DCGM_FI_DEV_MEM_COPY_UTIL     # Memory bandwidth utilization %
DCGM_FI_DEV_FB_USED           # Framebuffer (VRAM) used MB
DCGM_FI_DEV_FB_FREE           # VRAM free MB
DCGM_FI_DEV_POWER_USAGE       # Power draw (watts)
DCGM_FI_DEV_GPU_TEMP          # Temperature (celsius)
DCGM_FI_DEV_SM_CLOCK          # Streaming multiprocessor clock MHz
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE  # Tensor core utilization % (training efficiency)
# GPU utilization per pod
avg by (pod, gpu) (DCGM_FI_DEV_GPU_UTIL)

# VRAM usage %
DCGM_FI_DEV_FB_USED / (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE) * 100

# Alert: GPU idle > 5 min on a running pod (wasted $$$)
DCGM_FI_DEV_GPU_UTIL < 5

Alert thresholds:

Metric Warning Critical
GPU util < 20% sustained (idle waste) > 95% sustained (saturation)
VRAM used > 85% > 95% → OOM kill
Temperature > 80°C > 87°C (throttling starts)
Power > 90% TDP

Gang Scheduling — All-or-Nothing Pod Groups

Distributed training requires ALL pods to start simultaneously (otherwise one waits forever for others that are stuck pending). Standard K8s scheduler doesn't guarantee this.

# Install Volcano or Coscheduler (scheduler-plugins)
kubectl apply -f https://raw.githubusercontent.com/volcano-sh/volcano/master/installer/volcano-development.yaml

# PodGroup: schedule all 4 pods atomically
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: training-job
spec:
  minMember: 4       # all 4 GPUs must be available, or none start
---
apiVersion: v1
kind: Pod
metadata:
  annotations:
    scheduling.volcano.sh/pod-group: training-job
spec:
  schedulerName: volcano
  containers:
  - resources:
      limits:
        nvidia.com/gpu: 1

Without gang scheduling: 3/4 pods start, 4th can't schedule → deadlock (3 GPUs held hostage, 4th waiting forever).