all_lessons/kubernetes_genai/15lesson 15 / 18

Part IV - Customization and training jobs

Batch Scheduling and GPU Fairness

Lesson 14 got a single fine-tuning job running on Kubernetes — a distributed training workload that asks for several GPUs at once and runs for hours. But the moment a second team submits a job, you stop running a job and start running a shared GPU platform, and the questions change shape entirely. Who gets the scarce accelerators? Can a distributed job wait until all its workers are ready, or does it half-start and hold GPUs idle? Should a research experiment be allowed to block a production incident? And when finance asks what the cluster cost, can anyone answer? This lesson turns Kubernetes from a pod placer into a fair, accountable GPU platform — a scheduling contract welded to a financial one.

Source coverage
PDF Chapter 7: job scheduling optimization, gang scheduling, topology, quota, multitenancy, networking, and storage.
Linear position
Prerequisite: Lesson 14 (Fine-Tuning Jobs on Kubernetes) — you can launch a single distributed training job that requests N GPUs and runs to completion, and you saw the straggler problem where a job stalls if not all its workers arrive together.
New capability: Operate GPUs as a shared platform resource across many teams — admitting jobs by quota and priority, placing distributed jobs all-or-nothing on the right topology, and pricing the result — without starving teams, fragmenting topology, or hiding cost.
The plan
Five moves. (1) Frame GPU fairness as two contracts at once — a scheduling contract (who waits, who preempts, which topology survives) and a financial contract (who pays). (2) Define the scheduling primitives the chapter relies on: gang scheduling, bin packing, descheduling, and queues+quotas, each with the failure it prevents. (3) Work a concrete multitenancy scenario — three teams sharing 64 GPUs — and show how a best-effort job blocks a production incident until priority classes and preemption fix it. (4) Work the central tension — bin packing hits 95% utilization but leaves no NVLink island for the next big job — and introduce topology-aware scheduling as the cure. (5) Close the financial loop with chargeback/showback in dollars per GPU-hour and per useful token, then failure modes, a checklist, and the hand-off to RAG.

1 · Scheduling is now a product decision

When one team owns a GPU node, the scheduler's job is trivial: place the pod, done. The default Kubernetes scheduler does exactly this — it finds the first node that fits the pod's resource request and binds it. That policy, "first pod that fits," is perfectly fine for stateless web pods. It is a disaster for expensive accelerators shared by many teams, for three reasons that this lesson unpacks: it places pods one at a time (bad for jobs that need all their workers together), it is greedy about packing (bad for topology), and it knows nothing about who owns what budget (bad for fairness and cost).

The mental model to carry through the lesson: GPU fairness is a scheduling contract plus a financial contract. The scheduling contract decides, mechanically, who waits in a queue, who is allowed to preempt whom, and which hardware topology a job is guaranteed. The financial contract decides who pays for the GPU-hours consumed and whether that spend bought anything useful. The platform fails the moment either contract is implicit — and, as the chapter stresses, GPU fairness usually fails socially before it fails technically: a team that does not know the preemption rules will be furious the first time their job is killed, even if the kill was correct.

This is why projects like Kueue (a Kubernetes-native job queueing and quota controller) and Volcano (a batch scheduler with gang and fair-share semantics) exist. They are not competing "better schedulers"; they each move a piece of policy out of the implicit default and make it explicit and auditable. The real design question the chapter poses is not "which project is best?" but where should each policy live — in the batch scheduler, in a queue/admission controller, or in a platform API your teams call.

2 · The four scheduling primitives, defined

Four mechanisms do the structural work. Each one exists to prevent a specific failure you would otherwise hit on shared GPUs.

Gang scheduling
All-or-nothing placement: a distributed job gets all its workers admitted together or none. Prevents the lesson-14 straggler problem — half-placed pods sitting idle on scarce GPUs while they wait (and rendezvous-block) for siblings that may never be scheduled.
Bin packing
Pack pods tightly onto as few nodes as possible to raise utilization and free whole nodes. Great for the average — but it scatters small jobs across nodes and can fragment the contiguous topology a big job needs.
Descheduling
A controller that evicts already-running pods to rebalance the cluster — e.g. consolidating fragmented small jobs to reopen a contiguous GPU island. The repair pass that undoes the damage greedy packing does over time.
Queues + quotas
Kueue / Volcano admit jobs by team, priority class, and a quota (a team's guaranteed share, e.g. 16 of 64 GPUs). Jobs that exceed quota wait in a queue rather than starving others. Cohorts let idle quota be borrowed.

Why gang scheduling is non-negotiable for training. A PyTorch distributed job needs rendezvous: every worker discovers the others, agrees on ranks, and only then begins collective operations (the all-reduce that averages gradients, covered in the System ML track). If only half the pods are scheduled, the job is not half-useful — it is stuck, holding scarce GPUs while blocked at the rendezvous barrier waiting for ranks that the default scheduler may never place because the GPUs are gone. Gang scheduling admits the group only when enough resources are free for all of it, so a job either runs or politely waits in the queue. Volcano-style schedulers center on this; Kueue expresses it through all-or-nothing admission of a job's pods.

The data plane can starve the GPUs too
Scheduling decides where the work lands, but a distributed job's speed often depends on the network path as much as the GPU count. Secondary network interfaces and RDMA-capable fabrics (defined in lesson 07's topology discussion) carry the gradient all-reduce; if the scheduler places workers across a slow path, synchronization dominates and you pay for GPUs that mostly wait. Likewise, storage must stream training data and checkpoints fast enough to keep GPUs busy — a slow data plane turns a $30/hr GPU into an idle heater. The scheduler cannot fix a data plane that cannot keep up; topology-aware placement (§4) is partly about keeping the network short.

3 · A multitenancy scenario: when best-effort blocks production

Make it concrete. A cluster has 64 GPUs. Three teams share it, each with a quota:

Team Serving (production inference) — quota 32 GPUs, jobs run at priorityClass: production
Team Research (experiments) — quota 24 GPUs, jobs run at priorityClass: research
Team Batch (nightly evals) — quota 8 GPUs, jobs run at priorityClass: best-effort

One night the cluster is quiet, so Team Batch's quotas let them borrow idle capacity (a cohort feature) and a best-effort eval sweep spreads across 40 GPUs — far above their 8-GPU quota, which is allowed precisely because nothing else wanted them. Then a production incident hits: Team Serving needs to scale inference and submits a job for 16 more GPUs. But the 64 are already accounted for — Serving's own steady-state is using 16, Batch's borrowed sweep holds 40, and only 8 are truly free. The job needs 16 and can see only 8. With no preemption policy, the production job queues behind a best-effort eval sweep for the other 8 it needs. The incident drags on while a nightly job no one is watching holds the accelerators.

The fix is two pieces of explicit policy. Priority classes rank workloads (production > research > best-effort). Preemption lets a higher-priority job that cannot otherwise be admitted evict lower-priority running pods. With both configured, the production job's admission triggers preemption of enough best-effort eval pods to free the missing 8 GPUs (the 16-GPU job already sees 8 free, so 8 more must be reclaimed); Batch's borrowed-over-quota work is the first to be reclaimed because it was running on capacity it had no guarantee to.

# Kueue: a ClusterQueue gives each team a guaranteed quota and a borrowing cohort,
# and admits jobs by priority. Higher-priority jobs preempt lower ones when needed.
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata: { name: serving-cq }
spec:
  cohort: shared-64gpu              # lets teams borrow each other's idle quota
  resourceGroups:
  - coveredResources: ["nvidia.com/gpu"]
    flavors:
    - name: a100
      resources:
      - name: "nvidia.com/gpu"
        nominalQuota: 32            # Serving's guaranteed share
  preemption:
    reclaimWithinCohort: Any        # reclaim borrowed GPUs from lower priority
    withinClusterQueue: LowerPriority

The lesson the chapter draws: best-effort work must be able to use idle GPUs (utilization) but must be the first to lose them (fairness). Borrowing without reclaim is how a nightly eval blocks an incident. The contract — Serving is guaranteed 32, can borrow more, and reclaims its 32 by preempting borrowers — must be published so Team Batch is not surprised when their job is killed mid-sweep.

4 · Utilization versus topology: the central tension

Now the harder trade-off, the one that makes GPU scheduling genuinely different from packing web pods. Bin packing maximizes a scalar — utilization — but large training and tensor-parallel jobs care about a shape: they need several GPUs that are physically close, connected by NVLink (NVIDIA's high-bandwidth intra-node GPU interconnect) within a node, or rack-local across nodes, so their collectives are fast. Bin packing and topology pull in opposite directions.

Worked example. Take 8 nodes, each with 8 NVLink-connected GPUs — 64 GPUs total. A flurry of small 2-GPU jobs arrives. A greedy bin-packer, optimizing utilization, scatters them to fill gaps wherever they fit:

NodeAfter greedy bin packingFree GPUsLargest free NVLink island
node-13 × 2-GPU jobs22
node-23 × 2-GPU jobs22
node-33 × 2-GPU jobs22
............
node-83 × 2-GPU jobs22

Cluster utilization is 48 / 64 ≈ 75% running, and if a few more small jobs land it climbs toward 95%. By the utilization dashboard, the cluster looks healthy. But now the next big job arrives: an 8-GPU tensor-parallel fine-tune that needs 8 NVLink-connected GPUs on one node. There are 16 GPUs free in total — more than enough by count — yet no single node has 8 free. The largest contiguous NVLink island is 2 GPUs. The big job cannot be placed despite 25% of the fleet being idle. Utilization optimized the average and starved the shape.

  greedy bin packing                 topology-aware (islands reserved)
  node-1 [#][#][#][#][#][#][.][.]    node-1 [#][#][#][#][#][#][.][.]
  node-2 [#][#][#][#][#][#][.][.]    node-2 [#][#][#][#][#][#][.][.]
  node-3 [#][#][#][#][#][#][.][.]    node-3 [#][#][#][#][#][#][#][#]  <- still
  ...                                ...                                whole
  16 free, largest island = 2        16 free, largest island = 8
  8-GPU job: CANNOT place            8-GPU job: places on node-3
  (# = used GPU, . = free GPU)

Same 16 free GPUs, two different shapes. The greedy layout maximized a number; the topology-aware layout deliberately kept node-3 whole so the next big job has a home. The difference is invisible on a utilization gauge and decisive for the job that matters.

Bin packing vs. topology — utilization is not the whole story
A cluster of 8 nodes × 8 NVLink GPUs (64 total). Drag the number of small 2-GPU jobs to fill the fleet, and toggle whether the scheduler reserves whole-node islands for big jobs. Watch utilization climb — but also watch how many 8-GPU NVLink islands survive for the next tensor-parallel job. Greedy packing wins on the gauge and loses the islands; reserving costs a little utilization and keeps the big job placeable.
GPUs used
Utilization
Free 8-GPU islands
Next 8-GPU job
Worked number — the cost of fragmentation
That stranded 8-GPU job is the expensive one. If it would have run a 6-hour fine-tune on 8 A100s at $3/GPU-hour, the job's compute is 8 × 6 × $3 = $144 — but the real cost is the delay: it sits in the queue until a descheduler consolidates the small jobs (evicting and re-placing several, each a checkpoint-and-restart) to reopen one 8-GPU island. Two cures. (1) Topology-aware scheduling: tell the scheduler that the 2-GPU jobs prefer to co-locate on already-fragmented nodes, deliberately keeping some nodes whole — you trade a few points of peak utilization to preserve large-job islands. (2) Descheduling: a controller periodically evicts and re-packs small jobs to defragment, the way a disk defragmenter reopens contiguous space. The platform that runs at 95% utilization with zero free islands is less useful than one at 85% that can still admit the big job.

Topology-aware scheduling makes the scheduler reason about the interconnect, not just GPU counts. It keeps a distributed job inside a topology domain — same NVLink node, then same rack, then same secondary-network fabric — so its collectives stay on the fast path, and it reserves islands so large jobs are placeable. The cost: the scheduler has fewer feasible placements, so it is slower to admit and can leave GPUs idle waiting for the right-shaped hole. That is the deliberate trade — a worse average for a better worst case on the jobs that matter.

5 · Closing the financial loop: chargeback and showback

The scheduling contract decides who runs. The financial contract decides who pays, and it is the half most platforms skip — which is why "no one can explain GPU spend" is a near-universal failure. The chapter's prescription: expose cost at three granularities.

$ / GPU-hour
The raw input cost. An A100 at ~$3/hr; 64 of them is ~$192/hr, ~$138k/month if fully on. The denominator everyone starts with — but allocation, not usefulness.
$ / useful token
For serving: cost divided by tokens actually generated for users. A job at 95% allocation but 30% GPU-busy is paying full price for idle silicon — this metric exposes it; raw GPU-hours hides it.
$ / training-run
For training: total GPU-hours × rate for one completed run, attributed to the owning team. Makes "was this experiment worth it?" answerable, and makes a failed run's wasted spend visible.

The distinction between chargeback (teams are actually billed their share) and showback (teams are shown their share without a real invoice) is a maturity choice: showback first, to build the accounting and trust; chargeback once the numbers are trusted enough to drive a budget. Either way, the critical design point is that platform metrics must not stop at allocation. Allocation says a job held 8 GPUs; usefulness says whether those GPUs did anything. A team whose chargeback is in $/useful-token has a direct incentive to fix the batching and KV-cache settings from lessons 08–10; a team billed only in $/GPU-hour has no signal that their job ran at 30% utilization.

Worked number — why $/useful-token catches what $/GPU-hour hides
A serving team holds 8 A100s at $3/GPU-hour = $24/hr. By $/GPU-hour the cost is flat regardless of what the GPUs do. Now measure usefulness. At a healthy batch size the deployment serves 2,000 tokens/s; over an hour that is 7.2M tokens, so $24 / 7.2M ≈ $3.33 per 1M useful tokens. But suppose batching is misconfigured and the GPUs sit at 30% busy, serving 600 tokens/s = 2.16M tokens/hr: now $24 / 2.16M ≈ $11.10 per 1M tokens3.3× more expensive for identical hardware. The $/GPU-hour bill is unchanged at $24/hr and looks fine; only $/useful-token reveals the waste and points the team straight at the batching and KV-cache knobs from lessons 08–10.

The HPC bridge — a real ownership decision
The chapter flags that batch GPU scheduling is exactly what HPC schedulers like Slurm were built for, and many organizations already run Slurm for tightly coupled GPU jobs. Kubernetes-native platforms can integrate with Slurm or use Slinky-style bridges so research batch workflows and cloud-native services coexist. Treat it as an ownership decision, not a religious one: do you want Kubernetes to be the batch scheduler (Kueue/Volcano), or do you want Kubernetes to host the services around an HPC scheduler that owns the batch queue? Picking one keeps the scheduling contract in a single place.

Failure modes

  • Best-effort jobs block production. A low-priority job grabs scarce accelerators and an urgent production job queues behind it. Signal: high cluster utilization yet a production job stuck in Pending with no idle GPUs — the fix is priority classes plus preemption, not more hardware.
  • Bin packing destroys topology. Greedy packing hits 95% utilization but leaves no contiguous NVLink/rack island, so the next large job cannot be placed despite many free GPUs. Signal: "GPUs free > job request, but job won't schedule" — count is fine, shape is gone. Cure with topology-aware placement and descheduling.
  • Cost is invisible. No one can explain GPU spend because metrics stop at allocation, not useful work. Signal: finance asks "what did this cluster do?" and the only answer is GPU-hours, with no $/useful-token or $/run to tie spend to value — so idle-but-allocated GPUs go unnoticed.
  • Rigid quotas idle GPUs. Hard per-team quotas with no borrowing leave a team's share idle while another queues. Signal: low utilization and pending jobs at the same time — quotas need cohorts/borrowing with reclaim.
  • Unpublished policy. Teams don't know the preemption, quota, or wait-time rules, so the first preemption feels like betrayal. Signal: escalations and side-channel "can you bump my job?" requests — GPU fairness failing socially before it fails technically.

Implementation checklist

  • Use queues and quotas (Kueue/Volcano) keyed to teams, workloads, and explicit priority classes, with cohorts so idle quota can be borrowed and reclaimed.
  • Use gang scheduling for any distributed job that needs all replicas to rendezvous before useful work begins — admit the group all-or-nothing, never half.
  • Use topology-aware scheduling for jobs depending on NVLink, rack locality, or secondary/RDMA networks, and reserve islands so large jobs stay placeable.
  • Run a descheduler to defragment over time, reopening contiguous islands greedy packing closed.
  • Expose chargeback/showback in $/GPU-hour and $/useful-token or $/training-run, so spend ties to value and idle-but-allocated GPUs are visible.
  • Decide and publish the scheduling contract: priority order, preemption policy, quotas, expected wait time, chargeback, and the incident-override rule.
  • Confirm the data plane (network, storage) can feed the GPUs, so placement decisions aren't undone by a starved fabric.
Further reading

Checkpoint exercise

Try it
You run 64 A100s shared by three teams. Write the scheduling contract: assign each team a quota and a priority class; decide whether best-effort work may borrow over quota and what reclaims it; choose where gang scheduling and topology-awareness apply (which jobs need an NVLink island vs. which can scatter). Then pick the chargeback metric you'd publish first ($/GPU-hour, $/useful-token, or $/training-run) and justify it. Finally, write the one sentence you'd post for the team whose job gets preempted during a production incident.

Where this points next

You can now run GPUs as a fair, accountable, multi-tenant platform — training and serving workloads share hardware under a published scheduling and financial contract. Part IV's customization arc is done. Lesson 16, RAG on Kubernetes, shifts from training a model on your data to retrieving your data at inference time: it adds the machinery — a vector database, an embedding service, and a retrieval step in front of the model server — that lets a model answer from a knowledge base it was never trained on. The scheduling discipline you just built still applies (the embedding and retrieval services are pods competing for the same nodes), but the new question becomes a data and latency one: how to keep the retrieval hop fast and the index fresh.

Takeaway
Once teams share GPUs, scheduling becomes a product decision governed by two contracts at once: a scheduling contract (who waits, who preempts, which topology survives) and a financial contract (who pays). The default "first pod that fits" fails on all three axes, so you reach for gang scheduling (all-or-nothing admission so distributed jobs don't half-start and stall at rendezvous), queues + quotas + priority classes with borrowing and preemption (so best-effort work can use idle GPUs but is the first to lose them to a production incident), and topology-aware scheduling + descheduling (so bin packing's 95% utilization doesn't strand the next 8-GPU NVLink job behind a fragmented fleet). Close the loop with chargeback/showback in $/GPU-hour and $/useful-token or $/training-run, because metrics that stop at allocation hide idle silicon. And publish the contract — GPU fairness fails socially before it fails technically.

Interview prompts