Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

AI System Design

Design a GPU Cluster Scheduler

Learn AI system design by building a scheduler for a shared pool of expensive GPUs across many teams — the constraints that make this different from a generic job scheduler.

The numbers to beatper-team quotabaseline guaranteed shareprioritydecides order within that sharefair-shareprotects against legitimate flooding

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

GPUs are not fungible, interchangeable slots

A generic job scheduler assumes any worker can run any job. GPU scheduling breaks that assumption in several specific ways: distributed training needs MANY GPUs allocated at once, not incrementally; which specific GPUs matter (interconnect distance affects real performance); and GPUs are so expensive that fair allocation across competing teams is a first-class requirement, not an afterthought.

Usersubmits a job
New in this step: User.

We'll build the scheduler around these specific constraints: all-or-nothing gang scheduling, topology-aware placement, priority-based preemption with checkpointing, and fair-share quotas so scarce capacity doesn't silently concentrate in one team's hands.

What the new pieces do

Userclient
A researcher or engineer submitting a training or inference job, competing for a shared pool of genuinely scarce, expensive GPUs.

Step 1 · The baseline

FIFO, first free GPU

Simplest scheduler: jobs are served in the order they arrive, each grabbing whatever GPU is currently free. What breaks specifically for GPU workloads?

Scheduler APIjob intakeGPU Inventorycluster-wide state
New in this step: Scheduler API, GPU Inventory.

A FIFO scheduler grants each job whatever single GPU is free next. What goes wrong for GPU workloads specifically?

  1. FIFO is fair in ARRIVAL ORDER, but that's not the same as being workable for GPU jobs — a large distributed job needing 8 GPUs simultaneously can't make progress if it only ever gets offered ONE free GPU at a time between a stream of smaller jobs.

  2. Two distinct problems: STARVATION (a big job never gets a chance to accumulate all the GPUs it needs at once, because small jobs keep consuming them one at a time) and FRAGMENTATION (even when enough total GPUs are free, they can be spread across the cluster in a way that's bad for a job that needs fast communication between its workers).

  3. Scheduler compute overhead for a queue is a minor concern — the real failures are starvation of large jobs and fragmentation-driven poor placement, which are correctness/performance problems for the actual GPU workloads.

FIFO with "grab whichever GPU is free" creates two GPU-specific failures: starvation (a large multi-GPU job never gets to accumulate everything it needs while smaller jobs keep taking individual free GPUs) and fragmentation (available capacity spread inconveniently across the cluster, hurting distributed jobs' communication performance even once scheduled).

Why this piece earns its place

FIFO is worth building properly rather than skipping past, because its shape is what every later policy gets bolted onto: a loop that takes a snapshot of the cluster, walks the queue in some order, and either places a job or records why it did not. That second half is the one people leave out, and it is the one you want at three in the morning. A scheduling decision is unreproducible by nature — the cluster state that produced it is gone seconds later — so the only way to answer someone who wants to know why their job is still sitting there is a per-pass record of what was considered and what was passed over, written at the time. Settle the loop's trigger early too. A periodic tick is simple and puts a floor under how long a freed GPU sits unused; re-running on every release is prompt and re-scores the whole backlog under churn. Either way the snapshot has to be consistent, or two passes hand the same GPU to two jobs. And treat the submit schema as an API from day one: every team's launch script and CI pipeline hardcodes whatever the first version accepted, so a field you never collected is a field no later policy can read.

What the new pieces do

Scheduler APIgateway
Accepts job requests (how many GPUs, for how long, what priority) and orchestrates everything that decides where and when they actually run.
GPU Inventorydata
The real-time record of every GPU in the cluster and whether it's free, allocated, or reserved — what every scheduling decision ultimately checks against.

Step 2 · Gang scheduling

All the GPUs at once, or none of them

A distributed training job needs, say, 8 GPUs to even start — with only 5 allocated, the other 3 worker processes have nothing to talk to, and the job can't make progress at all. Should the scheduler allocate GPUs to it incrementally as they free up?

Scheduler APIjob intakeGPU Inventorycluster-wide statePending Jobsgang-scheduled
New in this step: Pending Jobs.

A distributed job needs 8 GPUs simultaneously to function at all. Allocate them one at a time as they free up?

  1. For a distributed training job, partial allocation isn't partial progress — a job with only 5 of 8 required GPUs typically can't run AT ALL, since the missing workers break the distributed computation. Incremental allocation just holds resources hostage without enabling any actual work.

  2. Gang scheduling treats a multi-GPU job's full resource requirement as one atomic unit: either all of it is allocated together, letting the job actually start, or none of it is, and it waits. This avoids the failure mode of holding some resources uselessly while waiting on the rest.

  3. Distributed training jobs are typically not decomposable into independent single-GPU units without changing the actual training algorithm — the workers need to communicate with each other as part of one coordinated computation, not run as unrelated separate jobs.

Implement gang scheduling: a job's full resource requirement is treated as one atomic unit. It waits in the Pending Jobs queue until every GPU it needs is simultaneously available, then all of them are allocated together — never partially, which would hold resources without enabling any actual progress.

Why this piece earns its place

All-or-nothing has to cover the end of a job as well as the start. Eight workers allocated together are one thing while they run: if a single worker segfaults, the other seven keep their GPUs and keep spending their team's share while making no progress, because the computation they belonged to no longer exists. So a gang needs one lifecycle identity — a record the scheduler can fail as a unit and reclaim all eight GPUs from — rather than eight independent allocations that happen to share a name. Nothing downstream catches this for you, either: the survivors are live processes on live hardware, so anything looking for something dead finds them perfectly healthy, and only the gang's own membership record knows the job it belonged to is over. Gang scheduling also forces an admission check. A job asking for more GPUs than the cluster physically holds, or more than its team could ever be allocated, waits forever and is indistinguishable from ordinary starvation in the queue. Reject it at submit, with that reason attached, instead of accepting a request that can never be satisfied.

What the new pieces do

Pending Jobsevents
Jobs waiting for their FULL resource requirement to become available simultaneously — a multi-GPU job doesn't start with only some of its GPUs.

Step 3 · Topology-aware placement

Which GPUs, not just how many

8 GPUs are free cluster-wide — but they're scattered across 8 different physical nodes, each pair connected only by a relatively slow network link rather than a fast direct interconnect. Does it matter which 8 the job actually gets?

Scheduler APIjob intakeGPU Inventorycluster-wide statePlacement Enginetopology-aware
New in this step: Placement Engine.

8 free GPUs exist, but scattered far apart across the cluster's topology. Does WHICH specific GPUs a job gets matter?

  1. For single-GPU jobs this is true, but distributed training workers communicate constantly (gradient synchronization, activations) — GPUs connected by a fast direct interconnect (like NVLink) communicate far faster than GPUs that have to route through a slower network link between separate nodes.

  2. A distributed job's effective performance depends heavily on how fast its workers can exchange data — placing all 8 of its GPUs on well-connected, nearby hardware can mean substantially faster training than the same 8 GPU-count scattered across poorly-connected nodes, even though "8 GPUs" is identical in both cases on paper.

  3. The opposite is closer to true — placement matters LEAST for single-GPU jobs (which have no cross-GPU communication at all) and matters MOST for multi-GPU distributed jobs, where inter-worker communication is a core part of the workload.

Add a Placement Engine that considers cluster topology, not just raw GPU count: for a multi-GPU job, prefer allocating GPUs that are close together (same node, fast interconnect) over scattering them across poorly-connected nodes. The same "8 GPUs" can perform very differently in practice depending on how well-connected they are to each other.

Why this piece earns its place

The engine's hardest calls are about the jobs that do not care where they land. Drop one small job into the middle of an otherwise empty machine and it performs exactly as it would have anywhere, while destroying the only place an eight-GPU gang could have gone — nothing failed, utilization went up, and the cluster quietly lost the ability to run its largest job. So the policy for small jobs is really a packing policy: put them on machines that are already dirty and keep whole machines clean, even when a cleaner one is nearer to hand. Get that backwards and you manufacture, one GPU at a time, exactly the scattering Step 1 warned about, and gang scheduling spends its life waiting on a set that keeps being broken. The topology model is also data, and data drifts. Nodes get added, a link degrades, a host comes back from repair wired differently, and the engine goes on scoring confidently against the graph it was handed. Treat it like inventory — discovered from the nodes, refreshed, versioned — because an engine reasoning about a topology that no longer exists is worse than any-free-GPU, since you believe it.

What the new pieces do

Placement Engineservice
Decides WHICH specific GPUs a job gets, not just how many — accounting for interconnect locality so multi-GPU communication stays fast.

Step 4 · Priority and preemption

An urgent interactive request beats a long batch job

The cluster is fully allocated to long-running batch training jobs. A latency-sensitive interactive inference request arrives and needs a GPU right now. Should it simply wait in line behind hours of batch work?

Scheduler APIGPU InventoryPlacement EnginePreemptor
New in this step: Preemptor. · swipe to pan the diagram

The cluster is full of long batch jobs. An urgent, latency-sensitive request arrives. Should it wait in the normal queue?

  1. Treating a latency-sensitive interactive request identically to an hours-long batch job ignores that the two have fundamentally different urgency — a strict FIFO queue would make interactive workloads unusably slow whenever the cluster is busy with batch work, which is most of the time on a well-utilized cluster.

  2. Priority-based preemption reflects that not all work is equally urgent: an interactive request that a person or system is actively waiting on can reclaim GPUs from a batch job that has no similar time pressure, as long as the preempted job can cleanly resume later rather than losing its progress.

  3. A statically reserved interactive-only pool sits idle whenever interactive demand is low, wasting expensive capacity that batch jobs could otherwise be using — dynamic preemption uses the SAME shared pool efficiently rather than partitioning it permanently.

Introduce a Preemptor: jobs carry a priority level, and a high-priority request can reclaim GPUs from a running lower-priority job, which is signaled to checkpoint its state and pause rather than being abruptly killed. This lets latency-sensitive work get served immediately from a SHARED pool, without permanently partitioning capacity.

Why this piece earns its place

Priority is a number a human types, so the real design work is deciding who may type what. Left open it only ratchets upward, because the one piece of evidence anybody has that their level was set right is that their jobs start — and once everything is urgent you are back to FIFO with extra steps, with no cheap way out, since demoting a team is a conversation rather than a config change. The ladder therefore belongs in config and needs an owner: what each level means, which groups may submit at it, whether it is permitted to preempt at all. Only the level itself rides on the request. The other thing the word "preempt" hides is that it rarely means one victim. Freeing eight well-placed GPUs usually means stopping several jobs on the same machine, and if you stop them one after another the GPUs freed first get taken by something else before the set is whole. The preemption and the allocation it exists to enable have to commit together, or you pay the full cost of interrupting work and still fail to start the job.

What the new pieces do

Preemptorservice
Reclaims GPUs from lower-priority jobs (checkpointing them first) to serve higher-priority, latency-sensitive requests immediately.

Step 5 · Fair-share across teams

Priority alone can let one team dominate

Multiple teams share the same GPU pool. If priority is the only allocation mechanism, could one team flood the scheduler with high-priority jobs and effectively starve every other team, even without doing anything technically against the rules?

Scheduler APIPending JobsPlacement EnginePreemptorFair-Share Quotas
New in this step: Fair-Share Quotas. · swipe to pan the diagram

Multiple teams compete for the same shared GPU pool, with only priority deciding allocation. What's the risk?

  1. Priority determines WHICH job runs next in a given moment, but says nothing about aggregate fairness across teams over time — a team that simply submits more high-priority jobs than others could legitimately, within the rules, consume a wildly disproportionate share of total cluster capacity.

  2. Priority alone optimizes for "serve the most urgent thing right now" but has no concept of "and make sure this doesn't let one team consume everyone else's share over time" — that requires a SEPARATE mechanism, fair-share quotas, layered on top of priority.

  3. The starvation risk exists even under entirely good-faith, policy-compliant usage — a team simply having more urgent work than others, with no malicious intent, can still exhaust shared capacity under a priority-only allocation scheme.

Layer fair-share quotas on top of priority: each team gets a guaranteed baseline share of cluster capacity (GPU-hours, or a percentage), and even legitimate high-priority demand from one team is capped relative to that quota, protecting other teams' access. Priority decides ordering WITHIN what a team is entitled to; fair-share decides how much each team is entitled to in the first place.

Why this piece earns its place

The interesting choice is what the quota is denominated in, and the options behave nothing alike. A cap on concurrent GPUs is simple to explain and lets a team sit at its ceiling indefinitely. A budget of GPU-hours over a fixed window lets them burst far above that ceiling and then go quiet, and turns every window boundary into a stampede as everyone's balance resets at the same instant. Usage with a decay — older consumption counting for less as it ages — avoids both the cliff and the permanent penalty. Then decide what a job is charged to and enforce it at submit rather than trusting a field the submitter fills in, or the policy is advisory and the numbers in the report are fiction. The consequence nobody plans for is that a team is not the last level. Inside one team's share, whoever submits first takes all of it, and the crowding-out this step exists to stop reappears between colleagues with nothing left to catch it. So the quota wants to be a tree with weights rather than one flat number per team — and the moment it is a tree, every node in it needs somebody who owns the split.

  • per-team quotabaseline guaranteed share
  • prioritydecides order within that share
  • fair-shareprotects against legitimate flooding

What the new pieces do

Fair-Share Quotasdata
Caps how much of the shared cluster any single team can consume, even under legitimate high-priority demand — preventing one team from starving everyone else.

Step 6 · Checkpoint, don't restart

A preempted job shouldn't lose hours of work

A batch training job running for 6 hours gets preempted by a high-priority interactive request. If it simply loses all progress and has to restart from scratch when it resumes, preemption becomes extremely costly for whoever's work keeps getting bumped.

Preemptorpriority-basedFair-Share Quotasper teamCheckpointsresume, not restart
New in this step: Checkpoints.

Preemption signals the job to checkpoint its current state (model weights, optimizer state, training step) to durable storage before its GPUs are reclaimed — not an abrupt kill. When capacity frees up again, the job resumes FROM that checkpoint, continuing roughly where it left off rather than restarting from step zero. This makes preemption's real cost the checkpoint/resume overhead (typically minutes), not the hours of lost progress a hard kill would cause.

Why this piece earns its place

The guarantee is only as strong as the deadline attached to it. Preemption is a signal plus a grace window, and a job that cannot finish writing inside that window is killed where it stands and comes back from its last periodic save instead — so the window has to be a published number rather than an internal one, because it is what framework authors size their save interval against. Leave it undocumented and every preemption becomes a lottery over how much recent work survives. The failure that teaches this is a checkpoint storm: a preemption wave sends many jobs writing their state to the same durable store in the same minute, so write time climbs precisely when the scheduler is leaning on preemption hardest, and jobs that normally save comfortably start missing the window. Preemption cost is therefore coupled to storage contention, which nothing in the scheduler models. Worth knowing too: sharded state is usually written per worker, so a job checkpointed across eight workers resumes onto eight unless the framework can reshard — the checkpoint constrains the shape of the resume, not only the count.

What the new pieces do

Checkpointsdata
Saved job state that lets a preempted job resume from where it left off instead of starting its (often hours-long) work over from scratch.

Step 7 · Elastic scaling and backfill

Use idle GPUs opportunistically

Some training jobs can dynamically grow or shrink their worker count (elastic training). If GPUs are briefly idle waiting for the next gang-scheduled job to accumulate its full requirement, is there useful work they could be doing in the meantime?

UserScheduler APIGPU InventoryPending JobsPlacement EnginePreemptorFair-Share QuotasCheckpoints
The system as it stands at this step. · swipe to pan the diagram

For jobs that support elastic scaling, the scheduler can opportunistically grow them onto temporarily-idle GPUs (backfilling capacity that would otherwise sit unused while waiting for the next gang-scheduled allocation) and shrink them back down when that capacity is needed elsewhere. This squeezes real utilization out of gaps the gang-scheduling and preemption policies would otherwise leave idle.

Why this piece earns its place

Resizing a job changes the run, not just the schedule. World size sets the effective batch size, which the learning-rate schedule and the step count per epoch were tuned against, so a job quietly grown mid-training produces a result its owner cannot reproduce and may not be able to explain. That makes elasticity opt-in, with a declared minimum and maximum the framework has agreed to handle, rather than something the scheduler does on its own because capacity appeared. Shrink is the half the scheduler should care about: borrowed GPUs are only useful if they come back on the timescale a waiting gang needs them, so a resize that costs as much as a checkpoint is not reclaimable capacity, it is a second squatter. And the feature lives or dies on accounting. If opportunistic hours are billed against a team's guaranteed share at the same rate as scheduled work, no team enables elasticity, and the utilization this whole step exists to recover never materializes.

Step 8 · The sharp edges

Zombie processes and scheduler drift

A job crashes ungracefully without releasing its GPU memory — the scheduler's bookkeeping says those GPUs are still allocated, but nothing useful is actually running on them. Over time, does the scheduler's view of the cluster stay accurate?

UserScheduler APIGPU InventoryPending JobsPlacement EnginePreemptorFair-Share QuotasCheckpoints
The system as it stands at this step. · swipe to pan the diagram

Run continuous health checks that verify actual GPU utilization and process health against the scheduler's own allocation records, forcibly reclaiming GPUs held by crashed or zombie processes that never properly released them. This closes the gap between what the scheduler THINKS is allocated and what's actually true on the cluster — a reconciliation loop, not a one-time assumption that bookkeeping stays accurate forever.

Why this piece earns its place

The word doing the work here is reclaim, and it means more than editing a row. Mark the GPUs free while a dead job's memory is still resident, and the next job to land there fails on allocation with an error that looks entirely like its own fault — the same drift, now wearing somebody else's incident. So reclamation is two operations rather than one: clear away what is left of the old process, then confirm the device memory genuinely came back before those GPUs return to inventory. Where that confirmation cannot be obtained, the honest state is quarantined, not free. That also settles the case where a node has simply stopped answering, which from the scheduler's side is indistinguishable from a node that died. Treat silence as proof and you will eventually place a second job on top of one that is running fine behind a network fault, and two jobs sharing memory each assumed was theirs lose more work than the zombie ever cost. Close the allocation after a bounded wait so its owner stops being billed for it, but leave the hardware out of service until the node checks back in clean. And put every reclamation in front of the job's owner, because from their side the job simply vanished.

You did it

You just designed a GPU cluster scheduler.

UserScheduler APIGPU InventoryPending JobsPlacement EnginePreemptorFair-Share QuotasCheckpoints
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • FIFO with "grab any free GPU" starves large distributed jobs and fragments capacity — GPU scheduling needs coordinated, atomic allocation.
  • Gang scheduling allocates a multi-GPU job's full requirement atomically, all at once, since partial allocation often enables no progress at all.
  • Topology-aware placement matters because WHICH specific GPUs a distributed job gets — not just how many — directly affects real training throughput.
  • Priority-based preemption lets urgent, latency-sensitive work reclaim GPUs from lower-priority jobs immediately, from a shared pool.
  • Fair-share quotas layer on top of priority to prevent one team's legitimate high-priority volume from starving every other team over time.
  • Checkpointing makes preemption cheap for the preempted job — resume from where it left off, not restart from scratch.
  • Elastic jobs can opportunistically backfill idle capacity that gang scheduling and preemption would otherwise leave temporarily unused.
  • Continuous health checks reconcile the scheduler's bookkeeping against real cluster state, reclaiming GPUs from crashed or zombie processes.

Deep cut · 21:51

Waited 19 hours. GPUs sat free.

The interactive build above lays out the GPU cluster scheduler: a queue of jobs, gang scheduling for all-or-nothing distributed training, topology-aware placement, priority and preemption with checkpoints, fair-share quotas across teams, and reconciliation against the real cluster. This film is an autopsy. At Tansy, a made-up AI company, Noor’s eight-GPU training job waited nineteen hours to start and her team missed its launch, while some of the company’s GPUs sat free the whole time. The film builds the scheduler one reasonable fix at a time, with the scheduling loop written out line by line as pseudo code, then replays the Tuesday one freed GPU at a time to find how a big job can wait in a cluster that is never full.

  • See the wreck: the scheduler’s backfill rule said that if a waiting job fits the free GPUs, it starts now, so every GPU that freed up went to whichever one-GPU job fitted right then. All day the free count went five, six, four, seven, five, and never reached eight. The cluster was never full, only shattered, and the scheduler logged its busiest day ever. With a reservation, the same Tuesday starts Noor’s job in forty minutes.
  • Take it into the interview: start a big job only when all its GPUs are free at once; place its workers inside one machine, where the fast link is; let urgent jobs preempt low-priority ones that checkpoint first, so only the minutes since the last save are redone; cap each team with fair-share quotas; reconcile the scheduler’s records against the real cluster so crashed jobs give their GPUs back; and give the job at the front of the line a reservation on the machine that empties soonest, letting small jobs backfill a held GPU only if their time limit ends before the big job is due to start.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why is gang scheduling specifically necessary for GPU clusters when generic job schedulers usually don't need it?

    Most generic scheduled work (a web request, a batch ETL task) is genuinely independent — one worker can make useful progress on its own, without needing other workers to simultaneously exist. Distributed training is fundamentally different: the workers communicate as part of ONE coordinated computation (synchronizing gradients, sharing activations), so a job with only PART of its required workers typically can't function at all, not just function more slowly. That "all workers or none are useful" property is specific to tightly-coupled distributed computation, which is common in GPU training but rare in typical generic scheduled work — hence gang scheduling being a GPU-scheduler-specific concern.

  2. Could topology-aware placement conflict with gang scheduling — what if the ideal topology isn't available when a job's GPUs become free?

    Yes, this is a genuine tension: gang scheduling wants "allocate as soon as the full count is available," while placement wants "allocate the BEST-positioned set of GPUs," and the best-positioned set might not be what happens to free up first. In practice, schedulers typically resolve this by giving placement quality a bounded, tunable weight rather than an absolute requirement — the job proceeds once its full GPU count is available even on suboptimal topology, rather than waiting indefinitely for a theoretically perfect placement that might never arrive, but the placement engine still prefers better topology when multiple valid allocations are simultaneously available.

  3. How would you prevent fair-share quotas from wasting capacity when a team with a guaranteed share simply has no jobs to run right now?

    A pure static reservation (this team's quota sits idle if unused) wastes exactly the same way a permanently-partitioned interactive pool would in Step 4 — the standard fix is to let OTHER teams opportunistically use idle quota that isn't currently claimed, but with the quota-owning team able to reclaim it (via preemption, using the same mechanism from Step 4/6) if they submit real work later. This turns "guaranteed share" into "guaranteed share that's never permanently blocked from others, but always reclaimable," getting both fairness over time AND good utilization in the moment — rather than choosing one at the expense of the other.

  4. What happens if a job repeatedly gets preempted right as it's about to finish, over and over?

    This is a real fairness/liveness risk worth naming explicitly: a lower-priority job could theoretically be preempted just before completion repeatedly, making genuine forward progress impossible even though it keeps getting SOME GPU time. A robust scheduler typically adds protections against this — for example, a minimum guaranteed runtime before a job becomes eligible for preemption again, or weighting preemption selection to avoid repeatedly targeting the same job. Without some such safeguard, priority-based preemption alone doesn't guarantee LIVENESS (that every job eventually completes), only that higher-priority work is served promptly — both properties matter for a scheduler to actually be usable.

  5. Why does checkpoint overhead matter enough to be a first-class scheduling concern, rather than just an implementation detail of the job itself?

    Because the checkpoint/resume overhead directly determines how CHEAP preemption actually is, which in turn determines how aggressively the scheduler can use preemption as a tool without imposing unacceptable cost on preempted teams. A job with a slow, expensive checkpoint process makes preemption costly even with checkpointing in place — the scheduler may reasonably need to factor expected checkpoint overhead into its preemption decisions (preferring to preempt jobs with cheap, fast checkpoints over ones with slow, expensive ones, all else equal), which means checkpoint cost isn't purely an implementation detail of the job — it's an input the scheduler's own decision-making legitimately needs to consider.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. FIFO scheduling with "grab any free GPU" specifically fails for GPU workloads because…

    Starvation of multi-GPU jobs and topology fragmentation are specific to coordinated, communication-heavy GPU workloads.

  2. Gang scheduling allocates a distributed job's GPUs…

    Partial allocation is often useless for distributed training, since workers need each other to make any progress.

  3. Topology-aware placement matters because…

    Well-connected GPUs (same node, fast interconnect) can perform substantially better than the same GPU count scattered across the cluster.

  4. Fair-share quotas exist alongside priority because…

    Priority and aggregate fairness across teams are different axes; fair-share protects the second, which priority alone doesn't address.

  5. Checkpointing before preemption exists to…

    Cheap resume via checkpointing is what makes preemption an acceptable scheduling tool rather than a costly penalty.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Submit a job: request N GPUs, for a duration, at a priority.
  • Gang schedule: start a distributed job only when all its GPUs are free at once.
  • Place well: pick which specific GPUs, honoring interconnect locality.
  • Preempt: let an urgent job reclaim GPUs from a lower-priority one.
  • Share fairly: cap each team’s slice of the shared pool.

The qualities that shape everything

Each one names the mechanism that buys it.

Large distributed jobs never starve
Gang scheduling holds a job in the pending queue until every GPU it needs is free at once, then allocates them atomically — never partially, which would pin resources without enabling progress.
Multi-GPU jobs aren’t crippled by distance
A topology-aware placement engine prefers GPUs close together (same node, fast interconnect) so a distributed job’s workers exchange gradients quickly.
Latency-sensitive work served now
Priority-based preemption lets an urgent request reclaim GPUs from a lower-priority job — checkpoint and pause, not kill — so the shared pool stays responsive without static partitioning.
No team can starve the others
Fair-share quotas layer on top of priority, capping each team’s slice of capacity so even legitimate high-priority volume from one team can’t exhaust everyone else’s share.
Preemption without losing hours of work
A preempted job checkpoints weights/optimizer/step to durable storage and resumes from there, making the real cost minutes of overhead rather than hours restarted from scratch.
Bookkeeping matches the real cluster
Continuous health checks compare actual GPU utilization against allocation records and forcibly reclaim GPUs held by crashed or zombie processes — a reconciliation loop, not a one-time assumption.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Coordinated, atomic allocation over FIFO grab-any-free-GPU

FIFO is fair in arrival order but starves an 8-GPU job that only ever gets offered one free GPU at a time, and fragments capacity across poorly-connected nodes. GPU workloads need a coordinated set allocated together and placed thoughtfully.

Gang scheduling over incremental allocation as GPUs free

A job with 5 of 8 required GPUs can’t run at all — the missing workers break the distributed computation, so partial allocation just holds resources hostage. All-or-nothing lets the job actually start or cleanly wait.

Topology-aware placement over any free GPU is interchangeable

Distributed workers synchronize constantly; GPUs on a fast direct interconnect communicate far faster than ones routed across a slow network link. The same "8 GPUs" trains substantially faster when they’re well-connected.

Preemption from a shared pool over a statically reserved interactive pool

A dedicated interactive pool sits idle whenever interactive demand is low, wasting scarce capacity. Preempting a lower-priority batch job — which checkpoints and pauses — serves urgent work now from the same shared pool.

Fair-share quotas + priority over priority alone

Priority decides what runs next but says nothing about aggregate fairness — a team submitting enough high-priority jobs can legitimately dominate the pool. Quotas cap each team’s share; priority only orders work within it.

The answer, out loud

What a strong answer to “Design a GPU Cluster Scheduler” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    Say what makes this different from a job scheduler

    I'd start by naming why this is not just queueing, because ordinary scheduling is solved and this part is not. Three things break the usual assumptions. Training work needs a whole set of GPUs live at the same instant, so one machine coming free is not progress. Which hardware it lands on changes how fast it runs, so the number in the request is not the whole request. And the hardware is scarce enough that who gets how much of it is an engineering requirement, not a policy written up later. I'd design against those three constraints.

  2. 3–8 min

    Build the naive version and name what breaks

    Then I'd put up the simplest thing that could work — serve jobs in the order they arrived, hand each one whatever is free right then — and watch it fail twice. First, a large job never gets its turn: every GPU that comes free is taken by something small before enough are loose at once, and it sits there while the cluster looks fully employed. Second, when it does start, the hardware it ended up with is spread out, and a spread-out set runs the same training more slowly. Neither failure is unfairness in the ordinary sense. The queue behaved exactly as designed.

    Built in step 1: FIFO, first free GPU
  3. 8–14 min

    The whole set, or nothing at all

    The fix for the first one is gang scheduling: treat what a job asked for as one indivisible thing, handed over in one move or not at all, with the job holding nothing while it waits. Half an allocation is worse than none — the workers are one program, so the ones that exist sit waiting on peers that were never started, and you have spent real capacity to buy zero progress. I'd head off the obvious follow-up, which is why not run it as eight separate one-GPU jobs. They exchange data constantly. That is a different training algorithm, not a scheduling trick.

    Built in step 2: All the GPUs at once, or none of them
  4. 14–19 min

    Which GPUs, not just how many

    Next I'd add placement, because a count is an incomplete request. The workers talk to each other constantly, and two sets of identical size can be far apart in how quickly they manage that — one inside a single machine on its fast internal link, the other spread over several and going across the network between them. Same number written down, materially different training speed. So the scheduler chooses sets rather than quantities, preferring well-connected ones for anything multi-GPU. The price is a model of how the hardware is wired, which is one more thing that can be wrong.

    Built in step 3: Which GPUs, not just how many
  5. 19–26 min

    Urgency, without carving up the cluster

    Now urgency. The cluster fills with long training runs, and then something arrives that a person is sitting in front of, waiting. Making it queue behind hours of that is unusable, and on a cluster worth what this one costs, busy is the normal state. I'd resist the tempting answer of fencing off a block of GPUs for interactive work, because that block is empty exactly when nobody wants it, and this hardware is too expensive to hold in reserve. So every job carries a priority, and an urgent one can take GPUs back from something lower down and run on the same shared pool.

    Built in step 4: An urgent interactive request beats a long batch job
  6. 26–31 min

    Fair share is a different axis from priority

    Priority on its own is not enough, and I'd make that point carefully because it sounds like it should be. Priority answers what goes next. It says nothing about how much of the month a team ends up with. A team whose work genuinely is urgent, breaking no rule, can keep the pool full of its own jobs while everybody else lives in the gaps. So I'd put a per-team share above priority: the share fixes how big a team's slice is, and priority sorts that team's own work inside it.

    Built in step 5: Priority alone can let one team dominate
  7. 31–36 min

    Make being interrupted cheap

    None of that is acceptable unless being interrupted is cheap, so I would never have preemption simply kill anything. The job is told its GPUs are about to go, writes enough state somewhere durable to pick up from where it stopped, and resumes there later. Being bumped then costs the save and the reload rather than the run. And once jobs can stop and start on demand, the ones that can also change size become useful: something elastic spreads onto GPUs that are only free for a while, and hands them back when they are wanted.

    Built in step 6: A preempted job shouldn't lose hours of work
  8. 36–42 min

    The failures I would plan for

    Two failures I'd raise before being asked. Losing the placement engine is a bad day, not an outage: jobs keep being scheduled, they just land badly until it returns, and I'd take that over a scheduler that stops. The one that really costs you is the books going wrong. A job dies without handing its GPUs back, the records still show them in use, and the cluster gets quietly smaller with nothing broken and no alarm anywhere. So I'd run something continuously that compares the records against what is genuinely on the machines and takes back anything held by nothing. Any design that assumes jobs exit tidily has not met one.

    Built in step 8: Zombie processes and scheduler drift
  9. 42–45 min

    Close on the trade-off

    So: allocate the set atomically, place it with the wiring in mind, priority and preemption above that with checkpointing making preemption survivable, per-team shares across all of it, elastic work filling the gaps, and a loop keeping the records honest. The tension running through the design is utilization against fairness — every rule I've added protects somebody by leaving GPUs empty for a while, and every rule chasing the last of the utilization makes waiting harder to predict. With more time I'd work on ordering the queue when several large jobs wait at once, and on what happens once the pool stops being one kind of GPU: with two generations of hardware in it, asking for eight stops being a single request.

What this teaches

Learn AI system design by building a scheduler for a shared pool of expensive GPUs across many teams — the constraints that make this different from a generic job scheduler. An interactive guide covering why naive FIFO scheduling starves large distributed jobs and fragments capacity, gang scheduling for all-or-nothing distributed training, topology-aware placement so multi-GPU jobs aren't crippled by interconnect distance, priority and preemption for latency-sensitive inference versus long-running batch training, fair-share quotas across competing teams, checkpoint-and-resume, and reconciling the scheduler's bookkeeping against real cluster state.

Key takeaways

  • FIFO with "grab any free GPU" starves large distributed jobs and fragments capacity — GPU scheduling needs coordinated, atomic allocation.
  • Gang scheduling allocates a multi-GPU job's full requirement atomically, all at once, since partial allocation often enables no progress at all.
  • Topology-aware placement matters because WHICH specific GPUs a distributed job gets — not just how many — directly affects real training throughput.
  • Priority-based preemption lets urgent, latency-sensitive work reclaim GPUs from lower-priority jobs immediately, from a shared pool.
  • Fair-share quotas layer on top of priority to prevent one team's legitimate high-priority volume from starving every other team over time.
  • Checkpointing makes preemption cheap for the preempted job — resume from where it left off, not restart from scratch.
  • Elastic jobs can opportunistically backfill idle capacity that gang scheduling and preemption would otherwise leave temporarily unused.
  • Continuous health checks reconcile the scheduler's bookkeeping against real cluster state, reclaiming GPUs from crashed or zombie processes.

Concepts covered

  • GPUs are not fungible, interchangeable slots
  • FIFO, first free GPU
  • All the GPUs at once, or none of them
  • Which GPUs, not just how many
  • An urgent interactive request beats a long batch job
  • Priority alone can let one team dominate
  • A preempted job shouldn't lose hours of work
  • Use idle GPUs opportunistically
  • Zombie processes and scheduler drift
RUN IT YOURSELF

The 8-GPU job that never once saw 8 free GPUs

A 16-GPU cluster, one stream of 400 small jobs and one 8-GPU training job, packed two ways — first-fit backfill with no reservation, and EASY backfill that holds a reservation for the head of the queue — running for real in your browser. It prints utilisation beside the big job's wait, then doubles the workload and re-runs both to prove the wait belongs to the rule and not to the day ending. Change MEAN_GAP, hit Run, and find the load where starvation switches on.

HOW TO READ THE CODE — 5 IDEAS
  1. Backfill is step 7 squeezing the gaps that gang scheduling (step 2) leaves open. With no reservation it squeezes them shut: across 404 scheduling decisions the 8-GPU job never saw 8 GPUs loose at once — the most was 7, and 251 small jobs started ahead of it, each perfectly legal under the rule.
  2. Utilisation reads 96.0% for first-fit against 96.3% for the reservation. The rule that starves the job runs the cluster slightly emptier, so the dashboard cannot even be read backwards — the two are 0.2 points apart on the number people watch and 815 minutes apart on the one they do not.
  3. The file checks its own headline: doubling the stream to 800 small jobs leaves both waits exactly where they were (859 → 859 and 44 → 44). The wait is the packing rule's, not the workload's. Set NSMALL back to 200 and watch first-fit print 531 → 859 instead — at that size the arrival stream ran out and rescued the job.
  4. One seed is an anecdote, so it sweeps 12: first-fit's wait ranges 0–1293 min (median 859), the reservation's 0–77 (median 43), and first-fit is faster on none of them. The reservation does not just shorten the wait, it bounds it — which is the property step 1's starvation actually needs.
  5. The reservation is not free, and the file prints the invoice: small jobs' mean wait goes 18.3 → 38.2 min. Holding GPUs for the head of the line is paid for by everyone behind it (step 5's axis, one level down).
CPython · WebAssembly
built so a big distributed job doesn't starve forever behind a stream of small ones — make the calls, flood it with interactive jobs, run the gauntlet.
Finished this one? 0 / 65 AI System Designs done
NextDesign ChatGPT Closest AI system design to this one

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More AI System Designs