
Intro
AI capex 2026 is $401B, while average GPU utilization across 23k+ Kubernetes clusters sits near 5%. That gap is the ugly truth no one’s talking about.
In my recent blog vLLM DeepSeek deployment, we sharded a 643GB whale across 16 H100s because it was too big to fit on one node. Today we’re facing the opposite problem: one GPU, more than one model, in Kubernetes with no way to split it. Take two vLLM pods with small models that could both fit on one H100. Kubernetes won’t let them share: nvidia.com/gpu:1 is the smallest unit it understands, so each grabs a whole card and barely touches it. On an 8-GPU node, the ninth model sits Pending while eight cards run half empty.
So how do you put more than one model on that card without wrecking the others? That’s what we’ll show in this post.
You’ve Been Lied to About GPU Sharing
A CPU swaps threads thousands of times a second, so everyone gets a fair slice. A GPU can’t afford to: a running kernel’s state spans every SM (registers, shared memory, caches) and runs to tens of megabytes, so pausing it mid-run is virtually impossible. That’s why a GPU’s unit of work holds the card until it’s done, and everything else waits.
Kubernetes makes it worse. It caps CPU and memory with cgroups, but there is no GPU cgroup, so it hands out whole cards or nothing. And a vLLM process takes what it’s given: the HBM for weights and KV cache, all the compute, until it dies. One card, one tenant. Every tool that “shares” GPUs fakes it. Some enforce the split, some just hope you behave.
GPU Sharing: What People Try, Why It Fails
How a Pod Gets a GPU
Before you can share a GPU, here’s how a pod gets one. Three layers decide it, and only the top one is Kubernetes:
| Layer | Set by | What it does |
|---|---|---|
| nvidia.com/gpu | you, enforced by the Kubernetes scheduler | reserves a whole GPU on a node and will not hand the same one to another pod |
| NVIDIA_VISIBLE_DEVICES | the NVIDIA container toolkit, set to the reserved GPU’s UUID | masks every other card so the pod sees only its own. this is the real isolation |
| CUDA_VISIBLE_DEVICES | you, or the engine (vLLM, PyTorch) | picks among the cards already visible. sub-selection inside one pod, never isolation across pods |
The isolation you actually lean on is NVIDIA_VISIBLE_DEVICES: NVIDIA’s toolkit hides every card except the one you’re assigned, which is why nvidia-smi in a healthy pod shows one GPU (i.e gpt-oss-20b on an H100 taking 75 of 81 GB):
root@vllm-gpu-stack-gpt-oss-20b:/vllm-workspace$ nvidia-smi # output trimmed
+-----------------------------------------+------------------------+----------------------+
| 0 NVIDIA H100 80GB HBM3 On | 00000000:5D:00.0 Off | 0 |
| N/A 29C P0 114W / 700W | 75079MiB / 81559MiB | 0% Default |
+-----------------------------------------+------------------------+----------------------+
| 0 N/A N/A 272 C VLLM::EngineCore 75070MiB |
+-----------------------------------------------------------------------------------------+privileged: true and the pod sees every /dev/nvidiaX on the box, no matter what it was assigned. That is how two models end up on one GPU without anyone asking. Many Helm charts turn it on by default for RDMA or InfiniBand, or when a replica fits the whole node, but these cases are very specific. โTime-Slicing Doesn’t Slice Time
Where the lie starts. In CPU land, time-slicing means the kernel forcibly pauses a process at fixed intervals so everyone gets a fair turn. GPU time-slicing does none of that. It just tells the device plugin to advertise one card as N GPUs so Kubernetes packs N pods onto it. One pod launches a five-second kernel, the other three wait five seconds. That’s all.
You don’t even need the device plugin for that. My vLLM Production Stack hack pulled the same stunt by hand:
drop the GPU request so K8s never locks the card, and let vLLM manage VRAM itself.
# vllm-production-stack Helm values
modelSpec:
- name: "tinyllama-gpu"
replicaCount: 2
# requestGPU: 1 # REMOVED: bypasses the K8s device lock
nodeSelectorTerms:
- matchExpressions:
- key: workload-type
operator: "In"
values: ["gpu"]Two replicas, one GPU, zero isolation. It works right up until one replica’s context grows and the other one dies. That is the pain the next three tools were built to solve, each at a different layer of the stack (scheduling, software, physical).
The Field At A Glance
| KAI Scheduler | HAMi | MIG | |
|---|---|---|---|
| Layer | Scheduler | Runtime (CUDA intercept) | Hardware (silicon) |
| Enforces? | No, orchestrates only | Yes, memory + compute | Yes, physically |
| Hardware | Any NVIDIA GPU | Any CUDA GPU (+ NPU/MLU/DCU) | NVIDIA data-center Ampere+ only |
| License | Apache 2.0 (NVIDIA) | Apache 2.0 (CNCF) | NVIDIA, free on supported HW |
The gist of it: three levels of enforcement, orchestrate only, enforce in software, enforce in silicon. The choice is yours.
I. KAI Scheduler: The Same Lie With Lipstick
KAI, NVIDIA’s open-source Kubernetes scheduler for AI workloads, is no better than time-slicing. It schedules the sharing neatly, it’s the core scheduling engine of NVIDIA’s Run:ai, but at runtime nobody is refereeing. K8s only sees whole cards, so KAI fakes fractions: a reservation pod in the kai-resource-reservation namespace claims the card with a normal nvidia.com/gpu request, kube-scheduler sees the card as taken, and KAI decides which pods share it.
Look at the gpu-memory: 10GB annotation, it only decides where the pod lands. Nothing enforces it after. The pod hits the real driver, still sees all 80GB, and vLLM at 0.9 takes 72GB. The two neighbours that booked 10GB each get bumped with an OOM. No eviction, no throttling, nothing steps in.
Installing KAI is its own chart, not the GPU Operator:
helm upgrade -i kai-scheduler oci://ghcr.io/kai-scheduler/kai-scheduler/kai-scheduler \
-n kai-scheduler --create-namespace \
--set "global.gpuSharing=true" # sharing is off by defaultRouting a pod to KAI looks like this, and notice what is missing:
apiVersion: v1
kind: Pod
metadata:
name: gemma-4-e4b
labels:
kai.scheduler/queue: default-queue # which queue this bills against
annotations:
gpu-memory: "10000" # the booking, in MiB
# gpu-fraction: "0.5" # or book a share of the card instead
spec:
schedulerName: kai-scheduler # send it to KAI, not kube-scheduler
containers:
- name: vllm
image: vllm/vllm-openai:latest
args: ["--model", "google/gemma-4-E4B-it"]The only way to use resources.limits: nvidia.com/gpu is for a whole GPU. For a slice, metadata.annotations is required, because Kubernetes has no way to express a fraction. And the pod never schedules without the queue label.
Enforcement is optional and not native: a fence can be added via a HAMi-core, which is the subject of the next section.
Repogithub.com/kai-scheduler/KAI-Scheduler→Pros
- Kubernetes-native, runs alongside the default scheduler
- Any fraction or exact MiB, no fixed tiers
- Queues and quotas for multi-team governance
- Auto-detects Kubeflow, Ray, and Argo workloads
- PodGroups and topology-aware placement for multi-GPU jobs
Cons
- Zero runtime enforcement, a pod can blow past its “share”
- No compute isolation, one long kernel stalls the rest
- No fairness, first-to-launch dominates
- Soft isolation only, unsafe for untrusted tenants
- NVIDIA-only in practice, the fractioning assumes NVIDIA’s device plugin
II. HAMi: The Software Fence
HAMi is GPU virtualization done in software: if the silicon will not enforce the split, a library will. It is a CNCF Incubating project, and the only one here that is not NVIDIA-only: its device matrix spans AMD, Ascend NPUs, Cambricon MLUs and a handful of other accelerators. Where MIG needs data-center silicon, HAMi runs on any CUDA GPU you already own.
How HAMi Catches Every CUDA Call
HAMi ships a library, libvgpu.so, and preloads it into your container with LD_PRELOAD. Every CUDA call from your app passes through it on the way to the driver. Every allocation gets checked against the container’s limit. Under the limit, the call goes through. Beyond it, your app gets a CUDA OOM and the driver never hears about it.
The limits themselves are plain environment variables, which is the whole enforcement layer in two lines:
# what HAMi injects into your K8s container
CUDA_DEVICE_MEMORY_LIMIT_0=3000m # hard memory ceiling, one var per GPU (_0, _1, ...)
CUDA_DEVICE_SM_LIMIT=50 # percentage of SMs, set once for the containerThe number in your pod spec becomes that env var, and HAMi-core checks it on every cudaMalloc your app makes.
DesignRead how HAMi-core hooks the CUDA call pathโSlicing a GPU in Kubernetes with HAMi
No profiles, no carving, no drain. A slice is resource limits on the pod, any size you like:
apiVersion: v1
kind: Pod
metadata:
name: qwen3-5-9b
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args: ["--model", "Qwen/Qwen3.5-9B"]
resources:
limits:
nvidia.com/gpu: 1 # physical GPUs this pod needs
nvidia.com/gpumem: 12000 # hard memory ceiling, in MiB (i.e 12G)
nvidia.com/gpucores: 30 # 30% of the card's SMs
# nvidia.com/gpumem-percentage: 50 # or ask by share instead of MiBThese are real resources the scheduler counts, not annotations like KAI Scheduler. And 12G is a size MIG can’t cut, no fixed shapes, so gpt-oss-20b and Qwen3.5-9B can share one H100 with the headroom each needs. They’re extended resources, so unlike CPU there’s no overcommit: you can’t request 4GB and cap 12GB, request and limit are the same.
nvidia-smi reports a slice, so --gpu-memory-utilization 0.9 means 90% of it not the card. Each physical card advertises 10 vGPUs by default (deviceSplitCount), so one card shows up as 10 schedulable devices. Both halves of that are checkable:
# the node side: one physical H100, ten schedulable vGPUs
kubectl get node gpu-node-1 -o jsonpath='{.status.allocatable}' | grep -o 'nvidia.com/gpu":"[0-9]*'
nvidia.com/gpu":"10
# the container side: nvidia-smi reports the slice, not the card (output trimmed)
kubectl exec -it qwen3-5-9b -- nvidia-smi
[HAMi-core Msg]: Initializing ...
| 0 NVIDIA H100 80GB HBM3 | 64MiB / 12000MiB |The Fence Costs a Little Latency
HAMi isolation used to cost ~10% in latency. It got much better lately, v2.9 numbers on an A100 confirm the drop trend:
See the v2.9 latency numbers
| Metric | Native (no HAMi) | With HAMi |
|---|---|---|
| TTFT p50 | 0.0621s | 0.0629s +1% |
| TTFT p99 | 0.0652s | 0.0674s +3% |
| Per-token latency | 0.0285s | 0.0291s +2% |
LD_PRELOAD and walk past it, and a CUDA or driver bump can break interception until HAMi catches up. What About AMD?
HAMi carries an AMD backend (amd.com/gpu), sized today around the MI300X. Scheduling and allocation work, with LD_AUDIT in place of LD_PRELOAD: memory and CU limits will ride a bitmask enforced by the ROCm runtime and the KFD driver. See below:
See the AMD syntax
apiVersion: v1
kind: Pod
metadata:
name: amd-gpu-pod
spec:
containers:
- name: rocm
image: ubuntu:22.04
command: ["sleep", "infinity"]
resources:
limits:
amd.com/gpu: 1 # scheduled and allocated today
amd.com/gpu-memory: 3000 # in MB, and note the name: gpu-memory, not gpumem
# not enforced yet, scheduling onlyPros
- Real memory and compute ceilings, enforced in the CUDA path
- Works on any CUDA GPU, no MIG-capable silicon needed
- Not NVIDIA-only: AMD, Ascend, Cambricon and others through one workflow
- Any MiB or percentage, no fixed profiles, no node drain
- Standard resource limits, zero application changes
- Can also orchestrate MIG, and NVIDIA’s KAI Scheduler uses HAMi-core for enforcement
Cons
- Not a security boundary, the fence is inside the container
- AMD is scheduling only today, ROCm isolation is still a prototype (not upstream yet)
- Interception overhead, small now but non-zero and workload-dependent
- Coupled to CUDA and driver versions, bumps can break the hook
- More moving parts: webhook, scheduler extender, device plugin, in-container library
- Physical usage and enforced limits are two different views to monitor
HAMi-core is now supported by NVIDIA’s KAI Scheduler to enforce memory: read the announcement.
III. NVIDIA MIG: The Silicon Wall
Multi-Instance GPU (MIG) is NVIDIA’s answer in hardware. It carves the GPU into as many as 7 hardware partitions (drawn from 7 compute slices and 8 memory chunks), each with its own compute, memory, bandwidth, and fault domain. Partitioning happens in silicon, so a crash in one slice cannot reach another, and each slice shows up as an independent CUDA device. Strongest isolation on the list, the only one that survives a hostile tenant, and the only one that strands silicon you already paid for.
MIG runs on NVIDIA data-center chips only (A/H100, …, B200, GB200, etc), not the 4090/5090 or the L40S/L4 (AMD equivalent is MxGPU). Beware that enabling MIG wants an idle card, so changing topology means draining the whole node.
MIG Profiles for Dummies
MIG profile names look like serial numbers, but let’s simplify it. An H100 keeps two separate budgets. Compute is cut into 7 slices, memory into 8 chunks of 10GB. A profile name is just the receipt, 3g.40gb means 3 of the 7 compute slices plus 40 of the 80GB. Two withdrawals, one from each budget, and whichever budget runs dry first decides how many instances fit on the card.
ProfilesSee the full list of NVIDIA MIG profiles, per GPU→Same rule, real models. B200 tells the same story with bigger memory units, which is why its slices read 23, 45, 90:
| Slice tier | H100 80GB example | Slice size (GB) | B200 180GB example | Slice size (GB) |
|---|---|---|---|---|
| 1g1/7 compute | Gemma 4 E4B (~8GB BF16) | 10GB | gpt-oss-20b (~13GB MXFP4) | 23GB |
| Qwen3.5-9B (~6GB Q4_K_M) | 10GB | |||
| 2g2/7 compute | gpt-oss-20b (~13GB MXFP4) | 20GB | Qwen3.6-27B (~27GB FP8) | 45GB |
| 3g3/7 compute | — | — | Gemma 4 26B-A4B (~50GB BF16, 4B active) | 90GB |
Notice in the table: on the H100, two small models each take their own 1g slice of one card, while on the B200 Gemma rides a single 3g.90gb MIG, 3 compute slices + 90GB covering weights and KV cache. And carve carefully: two 3g.40gb slices use six of seven slots, roughly 14% of an H100 idle (about $718/mo of paid silicon you can’t schedule).
--gpu-memory-utilization 0.9 grabs 90% of whatever card it sees. MIG right-sizes that automatically: 90% of the slice, not 90% of the card (i.e. gpt-oss-20b on a 2g slice: 90% of 20GB). MIG Carving in Kubernetes
Under the hood a slice is two objects: a GPU Instance (the memory) and a Compute Instance (its compute slots). Let’s see how the carving works in Kubernetes:
# flip MIG on (Hopper+: driver toggle, no reset)
nvidia-smi -i 0 -mig 1 # -i 0: target GPU 0 | -mig 1: enable MIG
# carve two half-card instances on GPU 0, compute included (-C)
nvidia-smi mig -i 0 -cgi 3g.40gb,3g.40gb -C
# GPU indexes count up from 0: carve the next card, with its own layout
nvidia-smi mig -i 1 -cgi 4g.40gb,2g.20gb,1g.10gb -CSlices then surface as their own resource names, and a pod claims one like any other resource:
apiVersion: v1
kind: Pod
metadata:
name: gpt-oss-20b
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args: ["--model", "openai/gpt-oss-20b"]
resources:
limits:
nvidia.com/mig-2g.20gb: 1 # one hardware-isolated sliceNote the pod asks for a shape, not an address: if a node has six 2g.20gb slices, there is no resource syntax to say “give me the 2g.20gb on GPU 3″ (inside the pod, nvidia-smi tells you which one you got). With mig.strategy: mixed, a custom config can carve different GPUs on the same node differently (heterogeneous shapes), and you can still route pods to a specific node’s MIG config through a nodeSelector.
Automating It: GPU Operator’s MIG Manager
You don’t have to nvidia-smi every node. The GPU Operator ships a MIG Manager that reconciles each node to a declared layout which lives in a ConfigMap: a node label picks which one applies, and MIG Manager does the carving. All of it fits in Helm values and manifests, so the whole thing is GitOps-friendly:
# install the operator with profile-named resources and your own config
helm upgrade -i gpu-operator nvidia/gpu-operator -n gpu-operator \
--set mig.strategy=mixed \
--set migManager.config.name=custom-mig-configLet’s configure our previous example declaratively:
See the full layout ConfigMap
# custom-mig-config: the layouts. This one carves our H100 example fleet
apiVersion: v1
kind: ConfigMap
metadata:
name: custom-mig-config
data:
config.yaml: |
version: v1
mig-configs:
all-disabled:
- devices: all # applies to every GPU on the node
mig-enabled: false
h100-fleet:
- devices: all # or a list like [0,1] to target specific GPUs
mig-enabled: true
mig-devices:
"1g.10gb": 5 # Gemma 4 E4B + Qwen3.5-9B take two, three spare
"2g.20gb": 1 # gpt-oss-20b# point a node at a layout: MIG Manager carves it for you
kubectl label node gpu-node-1 nvidia.com/mig.config=h100-fleet --overwrite
# the operator also ships built-in uniform layouts, named all-:
# all-3g.40gb = every GPU on the node carved identically into 3g.40gb slices (2 per H100)
kubectl label node gpu-node-2 nvidia.com/mig.config=all-3g.40gb --overwritenvidia-smi. The MIG Manager gotcha comes from Saiyam Pathak’s Kubesimplify deep-dive linked above, worth the full read.
Pros
- True hardware isolation, safe for untrusted and regulated tenants
- Fault isolation and guaranteed QoS, deterministic latency
- No interception overhead, each slice is a real CUDA device
- Backed by NVIDIA, free to use on supported hardware
Cons
- NVIDIA data-center Ampere+ only: nothing for AMD, cheaper, or older cards
- Fixed profiles, no live resize
- Profile changes need a full node drain and redeploy
- Slot grid and placement rules strand unused capacity
๐ก HAMi x MIG: Best of Both Worlds
Yes, itโs possible to have both. Flip a node into MIG mode and HAMi’s device plugin creates the MIG slices itself through NVML, no drain needed, so a single pool can hold software-fenced nodes and hardware-partitioned ones. The pod spec barely changes, and no MIG profile name is needed (HAMi picks the profile for you):
See how HAMi drives MIG
apiVersion: v1
kind: Pod
metadata:
name: gpt-oss-20b
annotations:
nvidia.com/vgpu-mode: "mig" # satisfy this with real MIG instances
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args: ["--model", "openai/gpt-oss-20b"]
resources:
limits:
nvidia.com/gpu: 2 # HAMi's resources, no profile name anywhere
nvidia.com/gpumem: 8000 # at least 8GB per instanceHead To Head
| KAI Scheduler | HAMi | MIG | |
|---|---|---|---|
| Granularity | Any fraction or MiB | MiB or %, plus core % | Fixed profiles, max 7 |
| Runtime overhead | None (scheduler only) | A few percent on inference | None (native slices) |
| K8s integration | Reservation pods, queues, quotas | Webhook + scheduler extender + device plugin | GPU Operator + mig-parted, mig-* resources |
| When to use | Trusted teams, self-limiting apps | Enforcement on non-MIG cards | Untrusted tenants, guaranteed QoS |
When To Pick Which
Reach for KAI when
You run a trusted multi-team cluster, want utilization and queue governance, and your apps already cap their own VRAM. You are buying smarter placement, not a fence.
Reach for HAMi when
You need enforced VRAM ceilings but your cards have no MIG (L40S, consumer, older Hopper-minus). You want a real limit without buying slicing silicon.
Reach for MIG when
Your tenants are untrusted or regulated and you need hardware-guaranteed isolation with deterministic QoS, and you are on A100, H100, or B200-class GPUs.
What’s Coming: Dynamic Resource Allocation (DRA)
Dynamic Resource Allocation hit GA in Kubernetes 1.34, and it is the native device API the field is converging on. KAI, HAMi, and MIG are not replaced by it, they move onto it: typed ResourceClaims instead of opaque nvidia.com/gpumem strings. Typed means the claims are fields both the scheduler and the vendor driver understand, memory and cores, instead of abstract integers:
See the difference in YAML
# Today: opaque extended resource
resources:
limits:
nvidia.com/gpu: 1
nvidia.com/gpumem: 4000 # just a number
# DRA: typed request, scheduler does the accounting
kind: ResourceClaim
spec:
devices:
requests:
- name: gpu-0
exactly:
capacity:
requests:
cores: 30 # % of SMs
memory: "4Gi" Final Thoughts
In retrospect, this is one question: how much do you trust the workloads sharing the card? Trust them fully and KAI buys utilization for almost nothing. Trust them halfway and HAMi holds the line on any GPU you own. If you don’t trust them at all, only MIG’s silicon wall will do, stranded capacity included.
You will probably run more than one, each at its own layer (scheduling, software, silicon), so hoping this gave you enough breadth in GPU sharing to pick the one your workloads actually need.
What’s Next
Slicing a GPU is half the battle. The other half is scaling the pods on those slices, and KEDA does not do it the way it scales CPU. That’s our next article. Stay tuned!

Run AI Your Way โ In Your Cloud
Want full control over your AI backend? The CloudThrill VLLM Private Inference POC is still open โ but not forever.
๐ข Secure your spot (only a few left), ๐๐ฝ๐ฝ๐น๐ ๐ป๐ผ๐!
Run AI assistants, RAG, or internal models on an AI backend ๐ฝ๐ฟ๐ถ๐๐ฎ๐๐ฒ๐น๐ ๐ถ๐ป ๐๐ผ๐๐ฟ ๐ฐ๐น๐ผ๐๐ฑ –
โ
No external APIs
โ
No vendor lock-in
โ
Total data control