AI Infra Interviews logo
💻 Coding for Infra
Foundational

The GPU Credit Scheduler Pattern

The most widely reported coding problem in AI infrastructure loops is a small scheduler: accounts hold credits, jobs arrive with a cost and a priority, and you must decide which jobs run, in what order, without letting any account overspend, then extend it under follow-ups (refunds, reservations, concurrency limits, fairness). It is not a trick question; it is a test of whether you can model state cleanly, pick the right data structures, keep invariants under mutation, and talk about complexity while typing. This page works the problem from the first line to the fourth follow-up, with the code, the invariants, and the derivations.

TL;DR: Model three things: an account (id, balance, reserved), a job (id, account, cost, priority, submit time), and the scheduler's state (a max-heap of pending jobs keyed by priority then age, a map of running jobs, the accounts). Submit reserves the cost against the account's balance so the invariant "balance ≥ reserved ≥ 0" holds at every moment; start pops the best affordable job; complete converts reserved into spent (or refunds on failure). Heap operations are O(log n); the follow-ups (per-account concurrency caps, fairness by spend, cancellation, time-based credit refill) each add one field and one check, and the invariant is what you re-state after every change. The interviewer is scoring your model, your invariants and your tests, not the heap.

The problem, as it is usually posed

"We have accounts with credit balances. Jobs are submitted with a cost and a priority. Write a scheduler that decides which job to run next: highest priority first, oldest first among equals, and never run a job the account cannot pay for. Then handle completion and failure." Follow-ups arrive one at a time.

Ask two questions before typing: is the cost charged at submit, at start, or at completion (the answer shapes the ledger), and can a job's cost change after submission (usually no, and saying "I'll assume fixed cost and revisit" is fine).

The model

import heapq
import itertools
from dataclasses import dataclass, field

@dataclass
class Account:
    id: str
    balance: int          # credits the account owns
    reserved: int = 0     # credits held for submitted-but-unfinished jobs
    running: int = 0      # jobs currently running (for the concurrency follow-up)

    @property
    def available(self) -> int:
        return self.balance - self.reserved

@dataclass(order=True)
class Job:
    sort_key: tuple = field(compare=True)           # (-priority, seq): highest priority, then oldest
    id: str = field(compare=False)
    account_id: str = field(compare=False)
    cost: int = field(compare=False)
    priority: int = field(compare=False)

class Scheduler:
    def __init__(self):
        self.accounts: dict[str, Account] = {}
        self.pending: list[Job] = []                 # heap
        self.running: dict[str, Job] = {}
        self.cancelled: set[str] = set()             # lazy deletion from the heap
        self._seq = itertools.count()                # ties broken by submission order

    def submit(self, job_id, account_id, cost, priority) -> bool:
        acct = self.accounts[account_id]
        if cost > acct.available:
            return False                             # reject at submit: the account cannot pay
        acct.reserved += cost                        # invariant: balance >= reserved >= 0
        heapq.heappush(self.pending, Job((-priority, next(self._seq)), job_id, account_id, cost, priority))
        return True

    def start_next(self) -> Job | None:
        while self.pending:
            job = heapq.heappop(self.pending)
            if job.id in self.cancelled:
                self.cancelled.discard(job.id)
                continue                             # skip a tombstone
            self.running[job.id] = job
            self.accounts[job.account_id].running += 1
            return job
        return None

    def complete(self, job_id, success: bool):
        job = self.running.pop(job_id)
        acct = self.accounts[job.account_id]
        acct.reserved -= job.cost
        acct.running -= 1
        if success:
            acct.balance -= job.cost                 # reserved becomes spent
        # on failure, the reservation is released and nothing is charged

    def cancel(self, job_id):
        if job_id in self.running:
            raise ValueError("cannot cancel a running job")
        self.cancelled.add(job_id)                   # the heap entry is skipped when popped
        # the reservation is released lazily when the tombstone is popped, or eagerly if we track jobs by id

The decisions worth saying out loud:

  • Reserve at submit. Charging at submit is unfair on failure; charging at completion lets an account overcommit by submitting many jobs. Reserving at submit and settling at completion keeps the invariant balance ≥ reserved ≥ 0 and never lets an account run jobs it cannot pay for, even with many pending.
  • Heap key. (-priority, seq) gives highest priority first and oldest first within a priority in O(log n) per push and pop. A sorted list is O(n) per insert; a dict of lists per priority works if priorities are few and is worth mentioning.
  • Lazy cancellation. Heaps cannot delete from the middle cheaply; a tombstone set costs O(1) at cancel and O(log n) when the entry is finally popped. Eager release of the reservation needs a jobs_by_id map, which is a one-line addition.

The invariants, stated

for every account:   balance ≥ reserved ≥ 0;   reserved = Σ cost of its jobs in pending ∪ running (minus tombstones)
for the scheduler:   a job is in exactly one of {pending, running, done}; the heap's tombstones are never running
after complete(success):  balance decreases by cost, reserved decreases by cost, available unchanged
after complete(failure):  balance unchanged, reserved decreases by cost, available increases by cost

One account through one job makes the invariant visible, and shows why start is the transition that does nothing to the balance:

ONE ACCOUNT, 100 CREDITS, ONE JOB COSTING 30 available reserved initial balance 100 · reserved 0 submit(cost 30) balance 100 · reserved 30 start() unchanged: already reserved complete(actual 22) balance 78 · reserved 0 balance ≥ reserved ≥ 0 holds after every one of these, including a crash between any two.

Re-stating these after each follow-up is what the interviewer is listening for; a candidate who types the change and then says "and the invariant still holds because..." is doing the job.

The follow-ups, and the one-line answers

"Cap concurrent jobs per account at k." In start_next, skip a job whose account has running ≥ k. Skipping means the popped job must go back: push it to a side list and re-push after the loop, or keep one heap per account and pick among heads (O(accounts) per start, or a second heap of account heads). State the cost: with a single heap and many blocked accounts, a start can pop and re-push O(blocked) entries.

"Be fair across accounts, not just priority-first." Change the key to (-priority, spend_ratio, seq) where spend_ratio = recent_spend / quota computed at push time, or a deficit-round-robin over per-account queues. Say the trade: fairness keyed at push time goes stale; per-account queues make it exact at O(accounts) per pick (Multi-Tenancy, Quotas and Fair Share).

"Credits refill at a rate per hour." Add last_refill and rate to the account; on every access, balance = min(cap, balance + rate × elapsed). That is the token bucket (Rate-Limiting Algorithms), and naming it wins a point.

"Jobs can be partially refunded on failure." complete(success=False, refund_fraction) charges cost × (1 − refund_fraction); the invariant on reserved is unchanged.

"Make it safe for many threads." One lock around the scheduler's mutations is correct and enough for a scheduler that makes hundreds of decisions per second; say why finer locking is not worth it here, and what you would do at higher rates (shard by account) (Concurrency in Python, Go and C++).

The tests you write before being asked

def test_reject_when_unaffordable():
    s = Scheduler(); s.accounts["a"] = Account("a", balance=10)
    assert s.submit("j1", "a", cost=8, priority=1)
    assert not s.submit("j2", "a", cost=5, priority=9)   # 8 reserved, 2 available

def test_priority_then_age():
    s = Scheduler(); s.accounts["a"] = Account("a", balance=100)
    s.submit("low", "a", 1, priority=1); s.submit("high1", "a", 1, priority=5); s.submit("high2", "a", 1, priority=5)
    assert [s.start_next().id for _ in range(3)] == ["high1", "high2", "low"]

def test_failure_refunds_reservation():
    s = Scheduler(); s.accounts["a"] = Account("a", balance=10)
    s.submit("j", "a", 7, 1); s.start_next(); s.complete("j", success=False)
    assert s.accounts["a"].available == 10 and s.accounts["a"].balance == 10

def test_cancel_pending_is_skipped():
    s = Scheduler(); s.accounts["a"] = Account("a", balance=10)
    s.submit("j1", "a", 3, 1); s.submit("j2", "a", 3, 1); s.cancel("j1")
    assert s.start_next().id == "j2"

Four tests, each named for an invariant, written in the first fifteen minutes. They are the difference between "it seems to work" and "it works," and they make every follow-up safe to attempt.

Complexity, derived

submit: O(log n) heap push;  start_next: O(log n) amortized (tombstones are popped at most once each)
complete: O(1);  cancel: O(1) plus the deferred pop
memory: O(n) jobs plus O(tombstones), bounded by the number of cancels since the last pop
with per-account concurrency caps on a single heap: start_next is O(b log n) where b is the number of blocked heads skipped;
  per-account heaps with a heap of heads make it O(log a) where a is the number of accounts

Working it in the room

Fifteen minutes to the working core with tests, five per follow-up. Narrate the model before typing, state the invariant after each change, and give the complexity unasked. The follow-up held back is usually "what if the process restarts?", answered with a write-ahead log of submit, start, complete and cancel events that rebuilds the state on replay. The answer that sounds right and fails is charging at submit "because it's simpler": it makes failures unfair and the interviewer's second follow-up is exactly that.

What to remember

  • Model: Account (balance, reserved), Job (cost, priority, seq), Scheduler (heap, running, tombstones). Reserve at submit, settle at completion.
  • Invariant: balance ≥ reserved ≥ 0; a job is in exactly one state; say it after every change.
  • Heap key (−priority, seq); lazy cancellation with tombstones; O(log n) per operation.
  • Follow-ups add one field and one check each: concurrency caps, fairness by spend ratio, token-bucket refill, partial refunds, one lock.
  • Four tests named for invariants, written before the first follow-up.
RELATED CONCEPTS
PRACTICE THIS IN REAL QUESTIONS