Lessons from Production

Multi-Tenant GPU Isolation: What We Learned After the Third Outage

Markell Rawls June 26, 2026 42 min read
! TL;DR
Quick Reference: 5 Metrics to Monitor for Multi-Tenant GPU Isolation
Per-tenant p95 TTFT
Time to first token at the 95th percentile, per tenant. The single most important SLO signal for latency-sensitive workloads. Alert when it exceeds 80% of the threshold.
KV cache utilization %
Percentage of GPU memory consumed by KV caches across all in-flight requests. Alert at 70% with a rate-of-change trigger. At 90%+, new requests queue and recovery requires killing work.
Token bucket drain rate
How quickly each tenant's rate-limit bucket is draining. A sustained drain rate above the fill rate signals an imminent burst of 429 rejections and possible contention.
SLO compliance % per tier
Rolling percentage of requests meeting the tenant's SLO target. Acme Corp's target is above 99.9%. Anything below triggers proactive capacity borrowing from lower-priority tiers.
GPU-seconds cost per tenant
Cumulative GPU time consumed per tenant, the foundation for chargeback billing. Track daily trends to catch runaway batch jobs before they impact cluster health.

01 The 2:47 AM Wake-Up Call#

I still remember the exact Slack message that woke me up. It was 2:47 AM, the PagerDuty alert had fired, and the on-call engineer had already posted a screenshot of the Grafana dashboard in #gpu-cluster-incidents: a single red line climbing past 2,400ms where Acme Corp's p95 TTFT was supposed to live comfortably below 200ms. The root cause? CodeGen Platform's nightly batch job had quietly consumed 78% of cluster GPU capacity, and our shared FIFO queue could not tell the difference between a $50M revenue pipeline and a speculative code completion experiment.

That was the incident that changed everything. We had five tenants on a shared 12-pod GPU cluster: Acme Corp (production API, p95 TTFT < 200ms), MedAI Labs (healthcare partner API, p95 TTFT < 400ms), FinServ Team (internal financial tools, p95 e2e < 1500ms), CodeGen Platform (batch code generation, p95 e2e < 30s), and Research Squad (experimental workloads, best-effort). We thought a shared FIFO queue with some basic rate limiting would be enough. We were wrong.

Here is what we did not understand at first: if you have operated multi-tenant CPU web services, you have a mental model for resource isolation that does not transfer to GPU inference. The differences are not cosmetic - they are structural, and they drove every design decision we eventually made with llm-d's isolation architecture.

CPU web services are fundamentally friendly to multi-tenancy. A typical API request arrives, does some computation for 5 to 50 milliseconds, reads from a database, and returns. The operating system provides virtual memory with hardware-enforced isolation between processes. Containers add a namespace boundary. Kubernetes enforces CPU and memory limits via cgroups. If one tenant's request consumes too much memory, the OOM killer terminates that process without affecting neighbors. The blast radius of any single request is small, and the recovery is fast.

GPU inference breaks every one of these assumptions. The night CodeGen Platform took down Acme Corp's API taught us that lesson viscerally. The first time we saw this dashboard, I think three of us on the call said the same thing simultaneously: "We need a completely different approach."

5–120s
Typical LLM request duration
2–16 GB
KV cache per long-context req
0
GPU swap / virtual memory
$25–40
Per hour per H100 node

Long-running requests were the first thing that burned us. A single LLM inference call generating 2,000 output tokens at 40 tokens per second takes 50 seconds. CodeGen Platform's batch requests were processing 128K-context documents, each one running for over two minutes. During that entire time, each request held GPU memory and occupied a slot in the inference engine's continuous batching scheduler. This is not a 5ms HTTP roundtrip that frees resources almost instantly.

Massive KV cache memory was the second surprise. Every in-flight request maintains a key-value cache that grows linearly with sequence length. For our 70B-parameter model serving a 32K-token context, the KV cache for a single request consumed over 2 GB of GPU memory. An 80 GB GPU could hold fewer than 40 such requests simultaneously, and that was before accounting for model weights, activation memory, and other overhead. When CodeGen Platform submitted thousands of 128K-context requests, each one requiring 5+ GB of KV cache, those pods filled up fast.

Non-preemptable GPU memory made recovery impossible without killing requests. There is no virtual memory on GPUs. There is no swap partition. When a request allocates KV cache space, that memory is locked until the request completes or is explicitly cancelled. You cannot page it out to make room. You cannot overcommit. If the GPU is full, new requests queue, including Acme Corp's requests, which had no way to tell the GPU "I am more important."

The autoregressive decode loop meant we could not just kill long-running requests cheaply. LLM text generation is autoregressive: each output token depends on all previous tokens. If you kill a request mid-stream to free resources, you lose all the computation done so far. The request must restart from scratch. During our incident, we considered manually killing CodeGen Platform's requests, but each restart would waste the 30 to 60 seconds of prefill compute already consumed.

Batch interference via continuous batching was the root cause we kept missing. Modern inference engines like vLLM use continuous batching to maximize GPU utilization - they dynamically add and remove requests from the running batch. But CodeGen Platform flooding the system with high-throughput, long-sequence requests dominated the scheduler, consuming batch slots that Acme Corp's latency-sensitive requests needed. The scheduler sees requests, not tenants. It did not know that Acme Corp's $50M API should take priority over CodeGen Platform's speculative experiment.

The Economic Reality That Forced Our Hand

GPUs are 10 to 50x more expensive per compute-hour than CPU instances. Our 12-pod H100 cluster cost approximately $54,000/month. At those prices, giving every team its own dedicated cluster would have cost $270,000/month for what amounts to the same total compute. Sharing was not optional - it was an economic necessity. But as we learned at 2:47 AM, sharing without proper isolation is operationally catastrophic.

KEY TAKEAWAY

GPU multi-tenancy cannot rely on CPU-era isolation primitives. There is no virtual memory, no swap, and no OS-level process isolation on GPUs. A shared FIFO queue treats a $50M production API identically to a speculative batch experiment, and at GPU prices ($54,000/month for a 12-pod cluster), sharing without isolation is operationally catastrophic.

02 GPU as a Shared Resource: Why CPU Intuitions Fail#

After the third outage, we did a deep-dive into why our CPU-era isolation strategies had failed so completely. The answer took us down to the hardware level, and it reshaped how we thought about the problem. I remember sitting in a conference room with our platform team, whiteboarding the GPU memory model, and having that sinking realization: none of our existing playbooks applied here.

GPU Memory Is Not CPU Memory#

CPU RAM benefits from decades of virtual memory engineering. Each process gets its own virtual address space. The MMU translates virtual addresses to physical addresses with hardware-enforced isolation. Pages can be swapped to disk. Memory can be overcommitted. The kernel mediates all access.

GPU memory (HBM on modern accelerators) has none of this. The CUDA driver allocates physical memory regions directly. There is no address space isolation between CUDA contexts running on the same GPU, beyond what the driver enforces in software. There is no swap. When 80 GB of HBM is allocated, it is gone. The allocator cannot reclaim it without the owning process releasing it.

For LLM inference, the KV cache is the dominant consumer of GPU memory after model weights. A single inference engine process loads the model weights once (consuming 35 to 70 GB for a 70B-parameter model in FP16/FP8), and the remaining memory is used for KV caches across all concurrent requests. During the CodeGen Platform incident, we watched our monitoring dashboards as KV cache utilization climbed from 40% to 94% in under two minutes. By the time Acme Corp's requests started queuing, there was literally no memory left to allocate them into.

Compute Scheduling on the GPU#

GPU compute is scheduled at the kernel (GPU kernel, not Linux kernel) level. CUDA streams provide a mechanism for concurrent execution, but the GPU's streaming multiprocessors (SMs) are shared. CodeGen Platform's batch of 64 requests would launch a large matrix multiplication that saturated most or all SMs, leaving Acme Corp's smaller, latency-sensitive operations waiting in the hardware queue. The GPU does not preempt running kernels mid-execution the way a CPU preempts threads.

In practice, LLM inference engines run one process per GPU that handles all requests through a single set of CUDA streams. There is no per-tenant process isolation at the GPU level. Acme Corp's requests and CodeGen Platform's requests are batched together in the same forward pass. This is by design - it is what makes GPU inference efficient - but it means isolation must happen before the request reaches the GPU.

Hardware Approaches and Their Limitations#

The outage taught us to evaluate every hardware-level isolation option available. None of them solved our problem.

NVIDIA Multi-Instance GPU (MIG) partitions a single GPU into up to seven isolated instances, each with dedicated SMs, memory, and L2 cache. MIG provides strong hardware isolation, but the partitions are coarse-grained (a 7-way split of an A100 gives each instance roughly 10 GB of memory and a fraction of compute). For our 70B model, which required the full GPU's memory just for weights, MIG partitions were far too small.

NVIDIA Multi-Process Service (MPS) allows multiple CUDA contexts to share a GPU with better concurrency than default time-slicing. However, MPS does not provide memory isolation - a misbehaving process can still exhaust GPU memory and affect neighbors. This was exactly the failure mode that had been waking us up at 2:47 AM.

Time-slicing (the default when multiple processes use a GPU) simply round-robins GPU access among processes. It provides no memory isolation and no priority scheduling. Context switches are expensive on GPUs, making this approach even less efficient for LLM inference workloads.

The Design Decision That Changed Everything

Hardware-level GPU partitioning is too coarse for LLM inference multi-tenancy. The practical solution is software-level isolation at the routing layer: control which requests reach which GPU, when, and with what priority. This is the approach llm-d takes through its Endpoint Picker (EPP). If we had understood this before the first outage, we would have saved ourselves three incident reviews and a lot of lost sleep.

KEY TAKEAWAY

Hardware-level GPU partitioning (MIG, MPS, time-slicing) is too coarse for LLM inference multi-tenancy. The practical solution is software-level isolation at the routing layer, controlling which requests reach which GPU, when, and with what priority. This is the level where tenant identity is visible and GPU signals are available.

03 Priority Scheduling: The Algorithm That Saved Us#

The scheduling algorithm is the heart of multi-tenant isolation. Get it wrong and either Acme Corp's production API suffers or Research Squad's experiments starve completely. After three outages, we deployed llm-d's EPP with a combination of scheduling primitives that finally gave us both fairness and strict priority enforcement. I noticed during our rollout that the shift was almost immediate: the Grafana dashboards went from a chaotic tangle of latency spikes to clean, predictable curves within hours of turning on the new scheduler.

Why Our FIFO Queue Was Killing Acme Corp#

Our original scheduler was first-in-first-out: requests processed in arrival order. FIFO works fine when all requests are equal, but with five tenants running wildly different workloads, they were never equal.

Scenario: The Noisy Neighbor Incident

CodeGen Platform submits 10,000 batch requests at 2:00 AM. Each request processes 128K context documents. Within 90 seconds, every shared pod queue is 40+ deep. Acme Corp's p95 latency spikes from 180ms to 2,400ms. MedAI Labs' healthcare API starts returning timeouts. FinServ Team's morning reports fail to generate. The FIFO queue treats CodeGen Platform's speculative code completion experiments identically to Acme Corp's $50M revenue pipeline.

With FIFO, every Acme Corp request queued behind thousands of CodeGen Platform batch requests. Each batch request took 5 to 60 seconds. Across 12 pods, the per-pod queue depth averaged 833 requests. Acme Corp's SLO required p95 TTFT under 200ms. The queue wait time alone exceeded 10 minutes. Our SLO was not "breached" - it was obliterated.

Weighted Fair Queuing#

Weighted Fair Queuing (WFQ) assigns each tenant a weight proportional to their allocated share of capacity. When we deployed WFQ, we gave Acme Corp a weight of 8, MedAI Labs a weight of 4, FinServ Team a weight of 2, and CodeGen Platform and Research Squad a weight of 1 each. Under contention, Acme Corp gets eight times the throughput of Research Squad. When the cluster is lightly loaded, all tenants get served immediately regardless of weight - WFQ only activates when there is contention.

The EPP implements WFQ at the routing layer. Each tenant's weight maps to their priority level and configured capacity share. When selecting which queued request to route next, the EPP uses a virtual-time-based algorithm that ensures each tenant's actual throughput converges to their weighted share over time. This prevents starvation: even Research Squad, at the lowest weight, makes progress - just at a proportionally lower rate.

Priority Preemption: The Recovery#

WFQ handles steady-state contention, but sometimes you need hard priority: Acme Corp's production request arrives and every pod is busy with CodeGen Platform batch work. Waiting for a batch request to complete naturally might take 30 to 60 seconds. Acme Corp's SLO requires p95 TTFT under 200ms and p95 end-to-end under 500ms. This is where priority preemption saved us.

Scenario: Preemption Recovery in Action

llm-d's EPP detects the SLO breach within 3 seconds. It preempts 23 CodeGen Platform requests across 8 pods. The preempted requests are gracefully re-queued at their original priority level - no data loss, just a retry delay. Acme Corp's p95 drops from 2,400ms back to 210ms in under 12 seconds. MedAI Labs recovers to within SLO 4 seconds later. CodeGen Platform's batch job completes 8 minutes later than it would have without preemption - well within its 30-second p95 e2e SLO.

When a priority-5 (critical) request from Acme Corp arrives and the EPP determines that no pod can serve it within the 200ms TTFT window, it signals a pod to preempt its lowest-priority in-flight request. The preemption is graceful: the preempted request's partial output is discarded, and the request is re-queued at its original priority level. CodeGen Platform experiences a retry delay, not a failure.

Incoming
Acme Corp P5
EPP Check
All pods busy (P1)
Preempt
CodeGen re-queued
Result
Acme at 210ms
Preemption Is a Last Resort

Preemption is expensive. The preempted CodeGen Platform request loses all compute done so far and must restart from the prefill phase - potentially wasting 30 to 60 seconds of GPU time. Under normal operating conditions, priority queuing and WFQ are sufficient to keep Acme Corp within its p95 TTFT < 200ms SLO. Preemption activates only when the cluster is saturated and a high-priority request has no other path to timely service. If preemption is happening frequently, the cluster is undersized. We learned this when preemption events exceeded 50/day and realized we needed to add 2 more pods.

Token Bucket Rate Limiting#

Rate limiting prevents any single tenant from flooding the system, regardless of their priority. We use the token bucket algorithm - a well-understood rate limiting primitive from networking - and if we had configured it properly before the CodeGen Platform incident, the incident would never have happened.

Each tenant has a bucket with a defined capacity (the burst allowance) and a fill rate (the sustained rate). Each request consumes one token from the bucket. When the bucket is empty, requests are rejected with a 429 status code until tokens replenish. CodeGen Platform's bucket has a burst size of 10 and a sustained rate of 100 req/s - enough for their batch processing needs, but physically incapable of the 10,000-request burst that caused the original outage.

Token Bucket Rate Limiting: CodeGen Platform Example
Idle (Full)
10/10
Fill: +100/s
Burst (Draining)
3/10
Drain: -140/s
Over Limit
Result: 429
Recovery
5/10
Fill: +100/s

Priority Queue Visualization#

The EPP maintains separate queues per priority level. Requests are dequeued in strict priority order: all Acme Corp P5 requests are served before any MedAI Labs P4 request, all P4 before FinServ Team's P3, and so on. Within the same priority level, requests follow WFQ ordering based on tenant weights.

EPP Multi-Priority Request Ordering
P5 Acme
R1
R2
Served first - p95 TTFT < 200ms
P4 MedAI
R3
R4
R5
p95 TTFT < 400ms
P3 FinServ
R6
R7
p95 e2e < 1500ms
P2 Research
R8
R9
R10
R11
Best effort
P1 CodeGen
R12
R13
R14
R15
R16
R17
R18
Fills unused capacity

Configuration: How We Set Up Our Five Tenants#

YAML tenant-priorities.yaml
apiVersion: inference.networking.x-k8s.io/v1alpha2
kind: InferencePool
metadata:
  name: llama-70b-shared
spec:
  targetPortNumber: 8000
  selector:
    app: llama-70b

  tenants:
    - name: "acme-corp"
      priority: 5              # Critical: preempts all others
      weight: 8               # WFQ weight for fair share
      rateLimit:
        requestsPerSecond: 200
        burstSize: 50
      maxConcurrency: 100
      podAffinity: "dedicated"  # 4 dedicated pods
      slo:
        ttft_p95_ms: 200
        e2e_p95_ms: 500
        availability: 99.9

    - name: "medai-labs"
      priority: 4              # High: yields only to P5
      weight: 4
      rateLimit:
        requestsPerSecond: 50
        burstSize: 20
      maxConcurrency: 30
      slo:
        ttft_p95_ms: 400
        e2e_p95_ms: 800
        availability: 99.5

    - name: "finserv-team"
      priority: 3              # Normal: standard service level
      weight: 2
      rateLimit:
        requestsPerSecond: 30
        burstSize: 10
      maxConcurrency: 20
      slo:
        e2e_p95_ms: 1500
        availability: 99.0

    - name: "research-squad"
      priority: 2              # Low: best-effort access
      weight: 1
      rateLimit:
        requestsPerSecond: 10
        burstSize: 5
      maxConcurrency: 10
      slo:              # Best effort - no hard SLO
        enforcement: "none"

    - name: "codegen-platform"
      priority: 1              # Batch: fills unused capacity
      weight: 1
      rateLimit:
        requestsPerSecond: 100
        burstSize: 10          # Minimal burst - the 10K spike that caused the outage is now impossible
      maxConcurrency: 40
      slo:
        e2e_p95_ms: 30000      # 30s - batch tolerant
        availability: 95.0
KEY TAKEAWAY

Three primitives work together to enforce tenant isolation: Weighted Fair Queuing for proportional throughput under contention, token bucket rate limiting to cap burst traffic (preventing the 10,000-request flood scenario), and priority preemption as a last resort to recover SLOs within 12 seconds.

04 Deep Dive: Fairness Algorithms and Their Trade-Offs#

After we had the basic priority scheduling and WFQ in place, I spent two straight weeks going deeper into the fairness algorithm space. Our team needed to understand not just what WFQ does, but why it works, where it breaks, and what alternatives we should consider. This section is the result of that investigation, and I wish I had written it before we chose our initial configuration instead of after.

Weighted Fair Queuing: The Virtual Time Algorithm#

WFQ is built on the concept of "virtual time" - a clock that advances differently for each tenant based on their weight. The core idea is elegant: imagine a hypothetical bit-by-bit round-robin system where each tenant sends data one bit at a time, weighted by their allocation. WFQ simulates this ideal system by computing when each request would have finished under that ideal model, then serving requests in order of their virtual finish times.

The virtual time calculation works as follows. The system maintains a global virtual time V(t) that represents the progress of the ideal fair scheduler. Each tenant i has a per-tenant virtual time S_i (the virtual start time of the next request) and a weight w_i. When a new request arrives from tenant i, the scheduler computes:

Pseudocode wfq-virtual-time.py
class WFQScheduler:
    def __init__(self):
        self.global_virtual_time = 0.0
        self.tenant_virtual_time = {}  # per-tenant virtual clock
        self.active_tenants = set()
        self.queue = PriorityQueue()  # ordered by virtual_finish_time

    def on_request_arrival(self, request, tenant_id, weight):
        # Virtual start time: max of tenant's own clock and global time
        # This prevents a tenant that was idle from "banking" credits
        S_i = max(
            self.tenant_virtual_time.get(tenant_id, 0.0),
            self.global_virtual_time
        )

        # Virtual finish time: start + (cost / weight)
        # Lower weight = higher finish time = served later
        cost = request.estimated_gpu_seconds
        F_i = S_i + (cost / weight)

        # Update tenant's virtual clock
        self.tenant_virtual_time[tenant_id] = F_i
        self.active_tenants.add(tenant_id)

        # Enqueue with virtual finish time as sort key
        self.queue.push(request, priority=F_i)

    def select_next_request(self):
        # Serve the request with the LOWEST virtual finish time
        request = self.queue.pop_min()

        # Advance global virtual time
        # Rate = 1 / sum(weights of active tenants)
        total_active_weight = sum(
            w for t, w in self.weights.items()
            if t in self.active_tenants
        )
        self.global_virtual_time += (
            request.actual_gpu_seconds / total_active_weight
        )

        return request

The critical subtlety is in the max() call on the virtual start time. When a tenant goes idle (say, Research Squad runs no experiments for an hour) and then returns, their virtual start time is set to the current global virtual time, not left at the stale value from when they were last active. Without this, a returning tenant would have a very low virtual finish time and effectively "jump the queue," getting a burst of service that starves other tenants. We discovered this behavior during testing when Research Squad would submit nothing for 45 minutes, then dump 200 requests at once and briefly starve FinServ Team's P3 queue. Setting the virtual start time to max(tenant_clock, global_clock) eliminates this problem entirely.

In our deployment, Acme Corp's weight of 8 means their requests accumulate virtual time at 1/8th the rate of CodeGen Platform's weight-1 requests. If both submit a request with the same estimated GPU cost, Acme Corp's virtual finish time will be 8x closer to the current virtual time, so it gets served first. Under contention with all five tenants active, Acme Corp gets 8/(8+4+2+1+1) = 50% of throughput, MedAI Labs gets 25%, FinServ Team gets 12.5%, and CodeGen Platform and Research Squad each get 6.25%.

Token Bucket Rate Limiting: The Burst Allowance Math#

The token bucket algorithm is deceptively simple but the math around burst allowance is worth understanding deeply, because getting it wrong is exactly what allowed CodeGen Platform to crash our cluster.

A token bucket has two parameters: the fill rate r (tokens per second) and the bucket capacity b (maximum tokens the bucket can hold). Tokens accumulate at rate r up to the maximum b. Each request consumes one token. The maximum burst duration - how long a tenant can sustain a burst above the fill rate - is calculated as:

burst_duration = b / (burst_rate - r)

For CodeGen Platform with b = 10 and r = 100/s: if they try to burst at 200 req/s, the burst lasts 10 / (200 - 100) = 0.1 seconds before they start getting 429s. If they try to burst at 110 req/s, the burst lasts 10 / (110 - 100) = 1 second. The bucket capacity is the control knob for how much above-rate traffic you tolerate.

Before the outage, CodeGen Platform had no rate limit at all. Their batch job client submitted requests as fast as the network would carry them, which peaked at roughly 3,000 req/s. With a bucket capacity of 10, their maximum burst above the 100/s sustained rate is physically limited to 10 extra requests. The 10,000-request spike scenario becomes structurally impossible.

Nested rate limits add another layer that we learned to appreciate. We run both per-tenant rate limits and a global cluster rate limit. The global limit is set at 400 req/s across all tenants combined. This prevents a scenario where all five tenants simultaneously burst to their individual limits (200 + 50 + 30 + 10 + 100 = 390 req/s sustained, but with bursts totaling 50 + 20 + 10 + 5 + 10 = 95 extra requests). The global bucket catches combined bursts that per-tenant limits would allow individually. In practice, we set the global bucket capacity to 60, roughly 2/3 of the sum of per-tenant burst sizes, because we observed that simultaneous bursts from more than three tenants were rare.

Priority Preemption: Cost Analysis#

Preemption is powerful but expensive. I have learned to think of every preemption event as a direct cost in wasted GPU-seconds. When a CodeGen Platform request is preempted after running for t seconds, you lose exactly t GPU-seconds of compute. For their 128K-context batch requests, the average preemption happens at the 45-second mark (roughly 60% through a typical 75-second request), wasting 45 GPU-seconds per event. At $0.007 per GPU-second, each preemption costs about $0.32 in wasted compute.

That sounds small until you consider preemption cascades. A preemption cascade occurs when preempting one request causes a chain reaction. Here is how it happens: the EPP preempts CodeGen Platform request C1 on pod 7 to make room for Acme Corp request A1. C1 is re-queued and assigned to pod 9. But pod 9 is also full, so the EPP preempts Research Squad request R1 on pod 9. R1 is re-queued to pod 3. If pod 3 is also full, another preemption occurs. We observed cascades of up to 4 deep during peak contention, wasting a total of 180 GPU-seconds (4 requests at an average of 45 seconds each) to serve a single Acme Corp request.

We tuned preemption thresholds to prevent cascades. The key parameter is the preemption depth limit, which we set to 2: the EPP will preempt at most 2 requests in a chain. If a 2-deep preemption chain does not free sufficient resources, the Acme Corp request is queued normally rather than triggering further cascades. We also added a preemption cooldown of 5 seconds per pod: after a pod has a request preempted, it is immune from further preemptions for 5 seconds. This prevents the same pod from being repeatedly targeted. After tuning these thresholds, our average wasted GPU-seconds per preemption event dropped from 90 to 52, and cascade events dropped from 12/day to fewer than 2/day.

Deficit Round Robin: The Alternative We Considered#

Before settling on WFQ, we seriously evaluated Deficit Round Robin (DRR) as an alternative. DRR is simpler to implement and has O(1) per-request overhead compared to WFQ's O(log n) priority queue operations. In DRR, each tenant gets a "quantum" proportional to their weight and a "deficit counter" that tracks unused allocation.

Each scheduling round, the scheduler visits each tenant in turn, adds their quantum to their deficit counter, and serves requests until the deficit counter is exhausted or the tenant's queue is empty. Unused deficit carries forward to the next round (up to a maximum to prevent unbounded accumulation).

DRR's advantage is predictable CPU overhead in the scheduler itself. Its disadvantage, and the reason we chose WFQ instead, is that DRR has worse latency behavior under bursty workloads. In DRR, a newly arrived high-weight request must wait for the current round to complete before it can be served. With 5 tenants and round times of 10 to 50ms depending on load, this adds up to 50ms of scheduling delay. For Acme Corp's 200ms TTFT target, burning 50ms in scheduler delay before the request even reaches a pod was unacceptable. WFQ, by contrast, immediately computes the virtual finish time and can serve the request next if its virtual finish time is the lowest. The trade-off is higher scheduler CPU cost, but at our scale of 400 req/s, the scheduler CPU is not the bottleneck - GPU is.

When to Choose DRR Over WFQ

If your highest-priority tenant has a relaxed TTFT target (say, 2 seconds or more) and you are running at very high request rates (10,000+ req/s) where the scheduler's O(log n) per-request cost becomes meaningful, DRR is the better choice. For latency-sensitive workloads like our Acme Corp tier, WFQ's ability to immediately prioritize newly arrived requests is worth the extra scheduler overhead. We run WFQ for P3-P5 tenants and a simpler round-robin for P1-P2 tenants, since CodeGen Platform and Research Squad do not need sub-second scheduling precision.

KEY TAKEAWAY

WFQ's virtual-time algorithm prevents idle tenants from banking credits and "jumping the queue" on return. Choose WFQ over Deficit Round Robin when your top-priority tenant needs sub-second scheduling precision; use DRR only when all tenants can tolerate 50ms+ scheduling delay and request rates exceed 10,000/s.

05 SLO Enforcement: From Aspirational to Contractual#

Priority scheduling determines who goes first. SLO enforcement determines whether "first" is actually fast enough. Before the outages, our SLOs were aspirational - "Acme Corp should be fast." After, they became contracts with specific numbers, real-time tracking, and automated enforcement. The difference between those two things is the difference between hoping and knowing. I remember the meeting where we switched from aspirational SLOs to contractual ones. Our engineering director asked, "What number would make you comfortable going back to sleep when paged?" That question cut through months of vague discussions.

The SLO Contracts We Run Today#

A meaningful SLO specifies a metric, a percentile, and a threshold. "Average latency should be low" is not an SLO. Here is what we actually commit to for each tenant:

Tenant p95 TTFT p95 E2E Availability Enforcement
Acme Corp < 200ms < 500ms 99.9% Proactive + Preemption
MedAI Labs < 400ms < 800ms 99.5% Proactive
FinServ Team N/A < 1500ms 99.0% Reactive
CodeGen Platform N/A < 30s 95.0% Best-effort
Research Squad N/A N/A N/A None

The distinction between "proactive" and "reactive" enforcement is something we learned to care about deeply. Averages hide tail latency. A service with 50ms average latency might have a p99 of 5 seconds if a small percentage of requests get stuck behind long-running batch work. During the CodeGen Platform incident, Acme Corp's average latency was only 380ms - but the p95 had spiked to 2,400ms. If we had been watching the average, we would not have known there was a problem until customers started calling.

Real-Time Latency Tracking#

The EPP tracks per-tenant latency percentiles in real-time using a sliding window of the last 60 seconds. For each tenant, it maintains:

These metrics feed directly into routing decisions. When the EPP needs to assign a request to a pod, it considers not just the pod's current load, but whether routing to that pod would push the tenant's latency percentiles past their SLO threshold.

Proactive vs. Reactive: The Insight That Changed Our Architecture#

Here is what we did not understand until the second outage: a reactive system detects an SLO breach after it happens and then scrambles to recover. By the time you see "Acme Corp p95 TTFT = 350ms" in your dashboard, you have already breached, and dozens of requests have been slow. A proactive system detects that a breach is imminent and adjusts routing before it occurs.

Concretely: when Acme Corp's p95 TTFT is currently 160ms (against a 200ms threshold, with 20% headroom triggering at 160ms), and the EPP observes that CodeGen Platform's queue depth is rising rapidly, it starts steering Acme Corp requests to less-loaded pods, even if those pods are not the optimal choice for KV cache reuse. The SLO takes precedence over cache efficiency. If pressure continues, the EPP begins borrowing capacity from CodeGen Platform and Research Squad by throttling their request rates. If the situation still does not resolve, preemption activates.

SLO Configuration#

YAML slo-configuration.yaml
tenants:
  - name: "acme-corp"
    slo:
      timeToFirstToken:
        p95Ms: 200            # The number that woke us up at 2:47 AM
        p99Ms: 500            # Hard limit - page if breached
      endToEndLatency:
        p95Ms: 500
        p99Ms: 1200
      availability: 99.9
      throughput:
        minRequestsPerSecond: 100  # Guaranteed minimum
      enforcement:
        mode: "proactive"     # Adjust before breach, not after
        headroomPercent: 20  # Start adjusting at 160ms (80% of 200ms)

  - name: "medai-labs"
    slo:
      timeToFirstToken:
        p95Ms: 400
        p99Ms: 800
      endToEndLatency:
        p95Ms: 800
        p99Ms: 1500
      availability: 99.5
      enforcement:
        mode: "proactive"

  - name: "finserv-team"
    slo:
      endToEndLatency:
        p95Ms: 1500
      availability: 99.0
      enforcement:
        mode: "reactive"

  - name: "codegen-platform"
    slo:
      endToEndLatency:
        p95Ms: 30000          # 30 seconds - batch is tolerant
      availability: 95.0
      enforcement:
        mode: "best-effort"   # No preemption or capacity borrowing

  - name: "research-squad"
    slo:
      enforcement:
        mode: "none"          # Best effort - no hard SLO

Prometheus Metrics: The Dashboard We Wish We Had from Day One#

If we had this instrumentation during the first outage, we would have caught the problem 45 minutes before it woke anyone up. The EPP exposes per-tenant metrics through a standard Prometheus endpoint:

Prometheus EPP metrics endpoint
# Per-tenant latency histogram
llmd_epp_request_duration_seconds{tenant="acme-corp",phase="ttft"}
llmd_epp_request_duration_seconds{tenant="medai-labs",phase="e2e"}

# Per-tenant active concurrency
llmd_epp_active_requests{tenant="acme-corp"}
llmd_epp_active_requests{tenant="codegen-platform"}

# Per-tenant rate limit state
llmd_epp_rate_limit_tokens_remaining{tenant="codegen-platform"}
llmd_epp_rate_limit_rejections_total{tenant="codegen-platform"}

# Per-tenant SLO compliance - the number we watch most closely
llmd_epp_slo_compliance_ratio{tenant="acme-corp",metric="ttft_p95"}  # Target: > 0.999
llmd_epp_slo_compliance_ratio{tenant="medai-labs",metric="ttft_p95"}  # Target: > 0.995

# Preemption events - non-zero means cluster is saturated
llmd_epp_preemptions_total{preempted_tenant="codegen-platform",preempting_tenant="acme-corp"}

# Cost attribution counter
llmd_epp_gpu_seconds_total{tenant="acme-corp"}
llmd_epp_gpu_seconds_total{tenant="codegen-platform"}
KEY TAKEAWAY

Proactive SLO enforcement detects that a breach is imminent and adjusts before it occurs. Reactive enforcement only scrambles after the damage is done. Set a headroom trigger (e.g., start adjusting at 80% of the threshold) and track p95 latency per tenant in real time, not averages.

06 Cost Attribution: What the CFO Wants to See#

Before we implemented cost attribution, our GPU infrastructure was a $54,000/month black box line item. Every team wanted the best GPUs, the highest priority, and the most capacity - because someone else was paying for it. The CodeGen Platform team had no idea their nightly batch job consumed 47% of total GPU-hours. The Research Squad team assumed their experiments were "basically free." Nobody had any incentive to use resources efficiently because nobody could see what they were using. The first time we showed a team lead the actual numbers, the room went quiet for about ten seconds.

Cost attribution closed this loop. It made each team's consumption visible and chargeable, and it fundamentally changed how every team lead thought about GPU time.

Why GPU-Seconds#

The natural unit for GPU cost attribution is the GPU-second: the amount of time a tenant's requests occupied GPU compute and memory resources. One GPU-second on an H100 costs roughly $0.007 - which seems small until you realize that CodeGen Platform consumed 44,640,000 GPU-seconds in a single month. At that scale, even small efficiency improvements translate to meaningful cost savings.

GPU-seconds capture both compute and memory occupancy. A request that is actively decoding tokens consumes GPU compute. A request that is waiting in the continuous batching scheduler with its KV cache loaded consumes GPU memory. Both are real resource consumption, and both are tracked.

How llm-d Tracks Consumption#

The EPP logs the start time, end time, pod assignment, and tenant identity for every request it routes. From these logs, you can compute:

The Monthly Cost Report That Changed Everything#

Before this report existed, GPU infrastructure was a black box line item on the platform team's budget. After, every team lead could see exactly what they were spending and why. The first time we circulated this report, three things happened: CodeGen Platform's team lead immediately asked about off-peak scheduling to reduce their rate, Research Squad voluntarily reduced their experiment frequency, and Acme Corp stopped arguing about the infrastructure surcharge on their P5 priority tier because they could see the dedicated pod allocation they were paying for.

Tenant Priority GPU-Hours Rate Monthly Cost
Acme Corp P5 Critical 8,200 $3.50/GPU-hr $28,700
MedAI Labs P4 High 3,100 $2.80/GPU-hr $8,680
FinServ Team P3 Normal 1,800 $2.00/GPU-hr $3,600
CodeGen Platform P1 Batch 12,400 $1.00/GPU-hr $12,400
Research Squad P2 Low 600 $1.50/GPU-hr $900
Total 26,100 $54,280

Notice that CodeGen Platform consumes the most GPU-hours (12,400) but pays the least per hour ($1.00). Their work runs at the lowest priority, filling capacity that would otherwise sit idle. This pricing structure incentivizes teams to honestly classify their workloads: if CodeGen Platform requested P5 priority for a job that can tolerate P1 latency, they would pay $3.50/hr instead of $1.00/hr - $43,400/month instead of $12,400/month - for no meaningful performance benefit.

We ran shadow billing for the first month before turning on real chargeback. Every team lead received a "what you would have paid" report alongside the cluster-wide cost breakdown. This gave teams time to optimize their usage patterns before the costs became real. CodeGen Platform moved their heaviest batch jobs to the 10 PM to 6 AM window (when Acme Corp's traffic drops 90%) and reduced their effective cost by 22% without changing their priority tier.

Try It in the Simulator

The interactive simulator includes an "Export Cost Report" button that generates a monthly cost breakdown for all tenants based on your configured priority tiers, rate limits, and simulated traffic. Use it to model what chargeback would look like for your organization before deploying to production.

KEY TAKEAWAY

Making GPU consumption visible through per-tenant GPU-second tracking fundamentally changes team behavior. Before cost attribution, CodeGen Platform had no idea they consumed 47% of cluster GPU-hours. After, they voluntarily moved their heaviest jobs to off-peak windows and cut their costs by 22%.

07 Chargeback Models: Billing Internal Teams for GPU Usage#

Cost attribution tells you who used what. Chargeback determines who pays for what and how. These are fundamentally different problems, and getting chargeback wrong can be worse than having no chargeback at all. I learned this lesson when our first chargeback model, a flat per-request fee, caused CodeGen Platform to optimize for fewer, larger requests (cramming multiple documents into single 128K-context calls) which actually made our noisy neighbor problem worse, not better.

Tiered Pricing: Higher Priority Equals Higher Rate#

Our production chargeback model uses tiered pricing where the rate per GPU-hour increases with priority level. This is not arbitrary markup. Higher priority tiers consume more real resources because they come with guarantees: dedicated pod capacity, preemption rights, proactive SLO enforcement, and the operational overhead of monitoring and alerting infrastructure. The pricing reflects these costs.

Priority Tier Base Rate What It Includes Effective Multiplier
P5 Critical $3.50/GPU-hr Dedicated pods, preemption rights, proactive SLO, 99.9% availability 3.5x
P4 High $2.80/GPU-hr Pod affinity, proactive SLO, 99.5% availability 2.8x
P3 Normal $2.00/GPU-hr Shared pods, reactive SLO, 99.0% availability 2.0x
P2 Low $1.50/GPU-hr Shared pods, best-effort, may be throttled 1.5x
P1 Batch $1.00/GPU-hr Shared pods, first to be preempted, fills idle capacity 1.0x (baseline)

Billing Granularity: Per-Token vs. Per-Request vs. Per-GPU-Second#

We evaluated three billing granularities before settling on GPU-seconds as our primary metric. Each has trade-offs that matter depending on your tenants' workload profiles.

Per-token billing charges based on the number of input and output tokens processed. This is the model most cloud LLM APIs use (OpenAI, Anthropic, etc.) and it has the advantage of being intuitive to developers. But it poorly reflects actual GPU cost: a 100-token request on a congested GPU takes far more wall-clock time (and thus more GPU-seconds) than the same request on an idle GPU. Per-token billing does not capture the contention cost, so tenants have no incentive to avoid peak hours.

Per-request billing charges a flat fee per API call regardless of token count. This is the simplest model to implement and explain, but it creates perverse incentives. CodeGen Platform started cramming multiple documents into single requests to reduce their per-request charges, which resulted in longer-running requests that were harder to preempt and worse for cluster health. We abandoned per-request billing after two weeks.

Per-GPU-second billing charges based on actual GPU time consumed. This aligns costs with real resource usage. A 50-second request costs 50x more than a 1-second request. A request that runs during congestion and takes longer costs more than one during off-peak. This naturally incentivizes tenants to optimize request efficiency and shift workloads to off-peak hours. It is the model we run in production.

Handling Shared Overhead#

Not all GPU time is attributable to a specific tenant. The shared overhead includes model loading (the initial model weight load takes 45 to 90 seconds per pod restart and consumes GPU memory during the entire pod lifecycle), warmup inference (the first 10 to 20 requests after model load run slower due to CUDA kernel compilation and cache warmup), and idle capacity (time when a GPU is powered on but no tenant has active requests).

We allocate shared overhead proportionally to each tenant's usage. If Acme Corp consumed 31.4% of total billable GPU-seconds in a month, they are charged 31.4% of the shared overhead pool. The overhead pool for our cluster is approximately $2,800/month (model reloads, idle time during the 3 AM to 6 AM lull, and warmup cycles). This adds roughly $880 to Acme Corp's monthly bill and $35 to Research Squad's, which feels proportionally fair.

Shadow Billing vs. Real Chargeback Rollout#

We strongly recommend running shadow billing for at least one full billing cycle before turning on real chargeback. During shadow billing, every team receives a detailed invoice showing exactly what they would have been charged, but no actual money changes hands. This gives teams time to react and optimize.

Our shadow billing month revealed several surprises. CodeGen Platform discovered that 23% of their GPU-hours were consumed by retry storms caused by aggressive timeout settings in their batch client. Fixing their timeout configuration reduced their projected bill by $2,852/month. Research Squad found that their hyperparameter sweep experiments were accidentally running on the production cluster instead of their development cluster - a routing misconfiguration that accounted for 40% of their usage. FinServ Team realized their morning report generation could be batched more efficiently by combining similar queries, reducing their request count by 35% and their projected cost by $540/month.

After the shadow month, we rolled out real chargeback with a 30-day grace period where overages triggered warnings but not actual charges. This phased approach converted every team into an active participant in resource optimization rather than a passive consumer.

Detailed Monthly Invoice Breakdown#

Here is what an actual monthly invoice looks like for CodeGen Platform, our highest-volume tenant. We send this level of detail to every team lead monthly.

Line Item Quantity Unit Rate Subtotal
P1 Batch GPU-Hours (Peak: 6AM-10PM) 4,960 hrs $1.00/hr $4,960.00
P1 Batch GPU-Hours (Off-Peak: 10PM-6AM) 7,440 hrs $0.70/hr $5,208.00
Preemption Waste (re-queued requests) 312 events $0.32/event avg $99.84
Rate Limit Overhead (429 handling) 8,420 rejects $0.001/reject $8.42
Shared Overhead Allocation (23.8%) 1 month $2,800 * 23.8% $666.40
Off-Peak Discount Applied 7,440 hrs -$0.30/hr -$2,232.00
Total (CodeGen Platform - June 2026) $8,710.66

Notice the off-peak discount. We offer a 30% rate reduction for GPU-hours consumed between 10 PM and 6 AM, when Acme Corp's traffic is at its lowest. This directly incentivized CodeGen Platform to shift their heaviest batch processing to overnight hours, which reduced contention during peak hours and improved Acme Corp's p95 TTFT by 12ms on average. The discount costs us $2,232/month in reduced revenue but saves us far more in avoided preemption waste and SLO breach risk.

How Chargeback Changed Team Behavior#

The behavioral changes from chargeback were more dramatic than I expected. Within two months of turning on real billing:

Integration with FinOps Tools#

Our chargeback data integrates with Kubecost and OpenCost through the EPP's Prometheus metrics endpoint. The key metrics for FinOps integration are:

Kubecost scrapes these metrics and displays them alongside standard Kubernetes resource costs (CPU, memory, storage) in its cost allocation dashboard. This gives finance teams a single pane of glass for all infrastructure costs, with GPU inference costs broken out per tenant. OpenCost provides similar functionality as a CNCF Sandbox project, which aligned well with our commitment to open-source tooling.

FinOps Integration

The EPP's per-tenant consumption logs can be exported to FinOps platforms (Kubecost, CloudHealth, Apptio) through standard metrics pipelines. The Prometheus metrics endpoint exposes llmd_epp_gpu_seconds_total{tenant="acme-corp"} as a counter, which your billing system can scrape and aggregate into the same monthly cost report we circulate to our team leads.

KEY TAKEAWAY

Use per-GPU-second billing, not per-request or per-token, for chargeback. Per-request billing creates perverse incentives (cramming more into each request makes noisy neighbor problems worse). Per-GPU-second billing aligns costs with real resource usage and naturally incentivizes efficiency and off-peak scheduling.

08 Security Isolation: The Question Every CISO Asks#

When we onboarded MedAI Labs - a healthcare partner handling HIPAA-regulated patient data - their CISO asked the question we knew was coming: "Can Acme Corp's prompts leak to MedAI Labs' responses, or vice versa?" We had done the engineering analysis, but until you can answer this question precisely and with evidence, you will not clear compliance review. I remember rehearsing our answer three times before the meeting because we knew one vague response would kill the deal. Here is the answer we gave, and the architecture that backs it up.

Inference Context Isolation#

LLM inference serving is stateless per-request at the application level. Each request gets its own KV cache allocation, its own attention computation, and its own output generation. There is no shared mutable state between Acme Corp's requests and MedAI Labs' requests. The model weights are read-only - they are loaded once and shared across all requests, but no request can modify them.

This is fundamentally different from training, where data can leak through gradient updates to model weights (the "memorization" problem). During inference, the model weights do not change. Acme Corp's prompt cannot influence the weights, and therefore cannot influence MedAI Labs' output. Each request's KV cache is allocated in a separate memory region and is deallocated when the request completes.

What About Continuous Batching?#

Continuous batching processes multiple requests in the same GPU kernel invocation for efficiency. Does this create a data leakage path? No. The attention computation is per-request: each request attends only to its own KV cache. Requests are concatenated along the batch dimension for compute efficiency, but the attention mask ensures that no cross-request attention occurs. This is a fundamental property of how transformer inference works, not something that needs to be bolted on. We provided MedAI Labs' security team with a detailed walkthrough of the vLLM attention kernel code to verify this independently.

The Practical Answer We Gave MedAI Labs

LLM inference serving provides strong per-request data isolation by construction. Each request has its own KV cache, its own attention mask, and its own output. The model weights are immutable during inference. Cross-tenant data leakage at the application level is a non-issue. We isolate 5 tenants across 12 GPU pods with per-tenant SLO compliance above 99.9% for Acme Corp and 99.5% for MedAI Labs, with zero cross-tenant data incidents in 14 months of production operation.

Network-Level Isolation#

While the inference engine provides application-level isolation, defense-in-depth adds network-level controls. We deployed all of these after the MedAI Labs onboarding review:

Compliance Considerations#

For organizations operating under SOC 2, HIPAA, or similar frameworks, the key documentation requirement is demonstrating that tenant data isolation is maintained throughout the request lifecycle. The combination of per-request KV cache isolation (application level), NetworkPolicies (network level), and audit logging (operational level) provides the evidence chain that auditors require. MedAI Labs additionally required dedicated pod pools for their most sensitive workloads, which we configured through pod affinity rules - 2 of our 12 pods are in MedAI Labs' affinity group, accessible to other tenants only when MedAI Labs' queue is empty.

KEY TAKEAWAY

LLM inference serving provides strong per-request data isolation by construction. Each request has its own KV cache, its own attention mask, and its own output. Model weights are immutable during inference. Cross-tenant data leakage at the application level is a non-issue. Layer network isolation (NetworkPolicies, mTLS, audit logging) on top for defense-in-depth.

09 Architectural Diagrams: How the Pieces Connect#

One of the things I wish we had done earlier was draw clear architectural diagrams showing how all the isolation layers interact. When I was explaining our architecture to MedAI Labs' security team, I kept sketching the same three diagrams on a whiteboard. Here they are, formalized, so you do not have to recreate them from scratch.

Request Flow: From Client to Response#

Every request follows this path. Understanding the flow is critical for debugging latency issues, because each stage adds measurable time that shows up differently in our metrics.

Request Flow Through llm-d Multi-Tenant Architecture Client Request Response | ^ v | +--------------------+ JWT/API Key +-------------------+ | | API Gateway | ─────────────> | Auth + Tenant ID | | | (Envoy/Istio) | validation | Extraction | | +--------------------+ +-------------------+ | | | | v v | +------------------------------------------------------------+ | | Endpoint Picker (EPP) | | | | | | +--------------+ +--------------+ +------------------+ | | | | Rate Limiter | | WFQ Scheduler| | SLO Monitor | | | | | Token Bucket | | Virtual Time | | Real-time p95 | | | | | per tenant | | per tenant | | per tenant | | | | +--------------+ +--------------+ +------------------+ | | | | | | | | | v v v | | | +------------------------------------------------------+ | | | | Pod Selection Engine | | | | | - KV cache affinity - GPU memory pressure | | | | | - Priority preemption - Load balancing | | | | +------------------------------------------------------+ | | +------------------------------------------------------------+ | | | | | | v v v v | +---------+ +---------+ +---------+ +---------+ | | Pod 1-4 | | Pod 5-6 | | Pod 7-12| | Pod N | | |Acme Ded.| |MedAI Aff| |Shared | | ... | ─────────────+ | vLLM | | vLLM | | vLLM | | vLLM | streamed tokens +---------+ +---------+ +---------+ +---------+

The key insight from this diagram is that tenant identity is resolved at the API Gateway and tenant isolation is enforced at the EPP. The vLLM pods themselves are tenant-unaware. They process whatever requests the EPP sends them. This separation of concerns is deliberate: vLLM focuses on efficient inference, the EPP focuses on multi-tenant isolation. Neither needs to understand the other's internals.

Multi-Layer Isolation#

Our isolation is not a single wall. It is three concentric layers, each protecting against different failure modes. When we explained this to MedAI Labs' CISO, the layered model was what finally got us past the compliance review.

Multi-Layer Isolation Architecture +==================================================================+ | LAYER 1: NETWORK ISOLATION | | | | Kubernetes NetworkPolicies mTLS (EPP <-> Pods) | | Namespace boundaries JWT tenant identity | | Audit logging at every hop Encrypted in transit | | | | +============================================================+ | | | LAYER 2: APPLICATION ISOLATION (EPP) | | | | | | | | Per-tenant rate limits Per-tenant priority queues | | | | Per-tenant SLO tracking Per-tenant concurrency limits | | | | Pod affinity rules Capacity borrowing controls | | | | Preemption policies Cost attribution metering | | | | | | | | +========================================================+| | | | | LAYER 3: INFERENCE ISOLATION (vLLM) || | | | | || | | | | Per-request KV cache Per-request attention mask || | | | | Read-only model weights No cross-request state || | | | | Memory dealloc on Continuous batching with || | | | | request completion strict attention boundaries || | | | | || | | | +========================================================+| | | +============================================================+ | +==================================================================+

Capacity Borrowing Flow#

Capacity borrowing is one of the more subtle mechanisms in the system. When Acme Corp needs more capacity than their dedicated 4 pods provide, the EPP borrows from lower-priority tenants. This diagram shows the decision tree.

Capacity Borrowing Decision Flow Acme Corp load exceeds dedicated capacity (4 pods) | v EPP checks: Are shared pods available? | | YES NO | | v v Route to least- Throttle P1 (CodeGen) loaded shared pod | | v v Throttle P2 (Research) Acme served | within SLO v Reduce P3 (FinServ) to 50% | v Shared pods freed? | | YES NO | | v v Route Acme PREEMPT lowest-priority to freed pod in-flight request | | v v Acme served Re-queue preempted req within SLO | v Acme served within SLO CodeGen retries later

The borrowing cascade is strictly ordered by priority: P1 is throttled first, then P2, then P3. P4 (MedAI Labs) is never throttled for Acme Corp's benefit because MedAI Labs has their own proactive SLO enforcement. If MedAI Labs also needed capacity borrowing, their P4 requests would borrow from P1/P2/P3 before Acme Corp's P5 requests trigger preemption. The priority stack ensures that borrowing pressure flows downward, never upward.

KEY TAKEAWAY

Tenant identity is resolved at the API Gateway, and tenant isolation is enforced at the EPP. The vLLM pods are tenant-unaware by design. This separation of concerns means vLLM focuses on efficient inference while the EPP focuses on multi-tenant isolation, and capacity borrowing flows strictly downward through the priority stack.

10 Incident Retrospective: When CodeGen's Batch Job Starved the Production Chatbot#

This is the full story of the incident that changed how we build GPU infrastructure. I have written it as a detailed timeline because the details matter. Every minute of this incident taught us something, and I want you to learn from our pain instead of experiencing your own. This was the second of our three outages - the one that was most preventable and most expensive.

Incident INC-2026-0147: Production API SLO Breach

Severity: SEV-1 | Duration: 47 minutes | Impact: Acme Corp production API p95 TTFT exceeded 200ms SLO for 47 minutes, peaking at 2,400ms. MedAI Labs healthcare API experienced 12 minutes of degraded service. Estimated customer impact: 23,400 Acme Corp API calls with latency above SLO. Estimated cost: $18,000 in engineering time, customer credits, and follow-up remediation.

The Timeline#

01:45 AM - T-62 minutes
CodeGen Platform's nightly cron job kicks off
The CodeGen Platform team's batch job scheduler submits its nightly document processing run. This is a standard job that runs every night, processing code repositories for their AI code completion product. Tonight's run includes a backlog of 14,200 documents accumulated over a long weekend. The job client is configured with no rate limiting and aggressive parallelism: 64 concurrent workers, each submitting requests as fast as the API accepts them.
01:47 AM - T-60 minutes
Request rate spikes to 2,800 req/s
Within two minutes, CodeGen Platform's 64 workers are submitting requests at a combined rate of 2,800 req/s. Each request carries a 128K-token context (full repository files). The shared FIFO queue across all 12 pods begins filling rapidly. At this point, Acme Corp's traffic is at its overnight low: 12 req/s. MedAI Labs' traffic is zero. FinServ Team and Research Squad are both idle. There is no contention yet because the cluster has ample capacity.
02:15 AM - T-32 minutes
KV cache utilization crosses 80% on all shared pods
CodeGen Platform's 128K-context requests each allocate 5+ GB of KV cache. After 28 minutes of sustained submission, KV cache utilization on pods 7 through 12 (shared pods) has climbed from 25% to 82%. Pods 1 through 4 (Acme Corp's "dedicated" pods, which at this point are not actually dedicated because we have not configured pod affinity) are at 78%. No alert fires because our KV cache monitoring threshold is set at 90%. This is the first thing we should have caught.
02:31 AM - T-16 minutes
KV cache hits 94% cluster-wide; new requests begin queuing
At 94% KV cache utilization, the vLLM continuous batching scheduler can no longer admit new requests into the running batch. Incoming requests queue in the engine's waiting queue. Because we are running a FIFO scheduler with no tenant awareness, CodeGen Platform's queued requests and Acme Corp's queued requests sit in the same line. The per-pod queue depth is now averaging 40 requests, each with an estimated service time of 45 to 120 seconds.
02:47 AM - T-0 (Incident Start)
PagerDuty fires: Acme Corp p95 TTFT exceeds 200ms SLO
Acme Corp's p95 TTFT crosses 200ms for the first time, triggering the PagerDuty alert. The actual value is 847ms and climbing. The on-call engineer (Sarah from the platform team) is paged. She opens her laptop, connects to VPN, and pulls up the Grafana dashboard. Initial assessment takes 4 minutes. She sees high latency but does not immediately identify the root cause because the dashboard shows "GPU utilization: 94%" which looks like healthy, busy infrastructure - not a tenant isolation failure.
02:51 AM - T+4 minutes
Sarah identifies CodeGen Platform as the source
Sarah checks per-tenant request rates and sees CodeGen Platform at 2,800 req/s while all other tenants are near zero. She checks the pod queue depths and finds queues 40 to 60 deep across all pods. She posts in #gpu-cluster-incidents: "CodeGen batch job is flooding the cluster. Acme p95 at 1,200ms and rising. Looking at options." At this point, Acme Corp's p95 has climbed to 1,200ms.
02:55 AM - T+8 minutes
MedAI Labs' first requests start failing
An automated health check from MedAI Labs' monitoring system sends a test request every 5 minutes. The 02:55 health check times out after 30 seconds. MedAI Labs' own alerting does not fire yet (they check 3 consecutive failures), but our dashboard now shows two tenants affected. Sarah escalates to the platform team lead (James) via Slack DM.
02:58 AM - T+11 minutes
Acme Corp p95 TTFT peaks at 2,400ms
This is the worst it gets. Acme Corp's p95 TTFT reaches 2,400ms, twelve times their 200ms SLO. Every new Acme Corp request is sitting behind dozens of CodeGen Platform 128K-context requests, each taking 60+ seconds to process. The FIFO queue gives them no way to jump ahead. Sarah and James discuss options on a quick Slack huddle: manually kill CodeGen requests (risky, wasteful), scale up pods (takes 10+ minutes for GPU node provisioning), or contact CodeGen Platform's on-call to pause the job.
03:02 AM - T+15 minutes
Sarah manually kills CodeGen Platform's oldest queued requests
Unable to reach CodeGen Platform's on-call (their pager goes to a shared channel that nobody is watching at 3 AM), Sarah makes the call to manually delete CodeGen Platform's queued requests from the vLLM scheduler. She runs a script that identifies and cancels 847 CodeGen Platform requests across all 12 pods. However, the 64 in-flight requests (one per pod plus additional in the continuous batch) continue running because killing them mid-decode would waste their accumulated compute and might destabilize the vLLM process.
03:08 AM - T+21 minutes
In-flight CodeGen requests begin completing; Acme Corp starts recovering
The oldest in-flight CodeGen Platform requests begin completing naturally, freeing KV cache space. As each request completes, the vLLM scheduler admits the next queued request, which is now an Acme Corp request (since Sarah cleared the CodeGen queue). Acme Corp's p95 TTFT begins dropping: 1,800ms at 03:08, 1,200ms at 03:12, 650ms at 03:18.
03:15 AM - T+28 minutes
CodeGen Platform's batch client detects mass failures and starts retrying
CodeGen Platform's batch client, which has aggressive retry logic with exponential backoff, detects that 847 requests were cancelled. It begins resubmitting them with a 30-second backoff. The first retry wave hits at 03:15 with 200 requests. Sarah has to manually block CodeGen Platform at the API gateway level by adding a temporary deny rule. This is the moment she later described as "the most stressful 30 seconds of my on-call rotation."
03:22 AM - T+35 minutes
Acme Corp p95 TTFT drops below 200ms; MedAI Labs recovers
With CodeGen Platform blocked at the gateway, the cluster drains its remaining in-flight CodeGen requests over the next 7 minutes. Acme Corp's p95 TTFT drops to 165ms. MedAI Labs' health checks start passing again. Sarah removes the API gateway deny rule for CodeGen Platform at 03:34 AM and adds a temporary rate limit of 10 req/s at the gateway level.
03:34 AM - T+47 minutes
Incident resolved; CodeGen Platform resumes at throttled rate
All tenants are within SLO. CodeGen Platform's batch job resumes at the gateway-enforced 10 req/s rate limit and completes its backlog by 06:45 AM, roughly 3 hours later than its normal finish time. Sarah documents the incident and schedules a post-mortem for 10 AM.

The Detection Gap#

The incident started at 02:15 AM when KV cache crossed 80%, but the first alert did not fire until 02:47 AM - a 32-minute detection gap. During those 32 minutes, the cluster was sliding toward the cliff edge and nobody knew. Our KV cache alert threshold was set at 90%, and by the time it would have fired, the damage was already done. We now alert at 70% KV cache utilization with a rate-of-change trigger: if KV cache increases by more than 20 percentage points in 5 minutes, we page immediately regardless of the absolute level.

Root Cause Analysis#

The post-mortem identified five contributing causes, each of which was necessary for the incident to occur:

  1. No per-tenant rate limiting: CodeGen Platform could submit unlimited requests because there was no rate limit configured. The token bucket algorithm would have capped their burst to 10 requests.
  2. No per-tenant priority: The FIFO queue treated CodeGen Platform's batch work identically to Acme Corp's production API. WFQ with priority levels would have ensured Acme Corp's requests were served first.
  3. No pod affinity: Acme Corp's "dedicated" pods were not actually dedicated. CodeGen Platform's requests were routed to all 12 pods, including the 4 we informally thought of as "Acme Corp's." Pod affinity rules would have reserved those pods.
  4. Insufficient monitoring thresholds: The KV cache alert threshold of 90% was too high. By 90%, recovery is already impossible without killing requests. The rate-of-change alert we now run would have fired at 02:17 AM, 30 minutes before the first SLO breach.
  5. No preemption capability: Even after detecting the problem, Sarah had no graceful way to prioritize Acme Corp's requests. She had to manually kill CodeGen Platform requests and block them at the gateway - a manual process that took 15 minutes.

The Fix: llm-d Configuration Changes#

Within 72 hours of the incident, we deployed llm-d's EPP with the tenant configuration shown in Section 3 of this post. The specific changes that would have prevented this incident:

Follow-Up: The Monthly Chaos Engineering Drill#

After this incident, we started running monthly "Noisy Neighbor" chaos engineering drills. On the first Wednesday of every month at 2 PM (during business hours, when the team is awake and ready), we simulate the exact conditions that caused INC-2026-0147:

  1. A test script submits 5,000 CodeGen Platform-priority requests in a 30-second burst
  2. Simultaneously, we inject synthetic Acme Corp traffic at 200 req/s (above their normal peak)
  3. We verify that the EPP's rate limiter rejects the bulk of CodeGen Platform's burst (success criteria: fewer than 10 admitted)
  4. We verify that Acme Corp's p95 TTFT stays below 200ms throughout the drill (success criteria: no breach)
  5. We verify that preemption activates if needed and recovers Acme Corp within 12 seconds (success criteria: recovery under 15 seconds)
  6. We verify that CodeGen Platform's rate-limited requests eventually complete within their 30-second SLO

Every drill has caught at least one issue: a Prometheus rule that stopped scraping after a config change, a PagerDuty routing rule that was pointing to a former employee's email, a pod affinity label that was accidentally removed during a Helm chart upgrade, and once, a regression in the EPP's preemption logic that would have allowed cascades deeper than our 2-level limit. The drill takes 20 minutes to run and about an hour to review results. It has prevented at least three incidents that we know of. Production will surprise you. Surprise it first.

KEY TAKEAWAY

The 32-minute detection gap (KV cache at 80% but alert threshold set at 90%) allowed the incident to escalate from recoverable to catastrophic. Alert on rate-of-change, not just absolute thresholds: if KV cache increases by more than 20 percentage points in 5 minutes, page immediately.

11 Production Patterns We Wish We'd Known Earlier#

The isolation primitives described above are building blocks. Deploying them in production required operational patterns we learned through experience, some of it painful. If we were onboarding our five tenants again from scratch, here is exactly what we would do and in what order.

Tenant Onboarding Workflow#

When FinServ Team requested access to the shared inference cluster, we followed a structured onboarding workflow that we developed after the mistakes we made with Acme Corp and CodeGen Platform (where we skipped half these steps and paid for it with three outages):

Tenant Onboarding Checklist

Capacity Planning#

Sizing a shared cluster for 5 tenants with wildly different workload profiles requires understanding that not all tenants peak simultaneously. Our over-subscription ratio is 2.4x - the total allocated capacity across all tenants exceeds actual cluster capacity by 2.4 times. This is sustainable because:

The rule of thumb we developed: provision enough capacity to handle the simultaneous peak of all P5 and P4 tenants (Acme Corp + MedAI Labs), plus 50% of P3 tenants' average load (FinServ Team). Let P1 and P2 tenants (CodeGen Platform + Research Squad) fill the remainder. If Acme Corp and MedAI Labs alone would saturate the cluster at their peaks, you need more GPUs. We hit this threshold once and added 2 pods within 48 hours.

Burst Handling and Capacity Borrowing#

When Acme Corp experiences a traffic spike - as happened during their product launch when peak traffic hit 310 req/s, 60% above normal - the EPP automatically borrows capacity from CodeGen Platform and Research Squad. The mechanism is straightforward: lower-priority requests are throttled (their effective rate limit is temporarily reduced) to free pod capacity for Acme Corp. When the burst subsides, rate limits return to normal.

This capacity borrowing is invisible to Acme Corp - their requests are served normally within their p95 TTFT < 200ms SLO. CodeGen Platform sees increased latency and potentially 429 responses during the burst period. During Acme Corp's product launch, CodeGen Platform experienced a 23-minute window where 18% of their requests received 429 responses. Their batch job retried automatically and completed 35 minutes later than usual - well within their 30-second p95 e2e SLO and 95% availability target.

Graceful Degradation#

Eventually, every cluster hits its actual capacity limit. When even capacity borrowing and preemption cannot free enough resources, the EPP degrades gracefully in strict priority order:

  1. CodeGen Platform (P1) is fully throttled (all requests receive 429) - their batch job queues locally and retries
  2. Research Squad (P2) is throttled to 25% of their normal rate - experiments slow but do not fail
  3. FinServ Team (P3) is throttled to 50% of their normal rate - reports take longer but generate
  4. MedAI Labs (P4) and Acme Corp (P5) continue at full rate as long as physically possible

The EPP emits a llmd_epp_cluster_saturation metric and fires a PagerDuty alert when this state is entered. We have entered full degradation mode twice in 14 months of production operation - both times during Acme Corp traffic spikes that coincided with MedAI Labs' quarterly data processing runs.

The Metrics That Would Have Saved Us Three Outages

Watch these metrics per tenant to catch problems before they become incidents: slo_compliance_ratio for Acme Corp (should stay above 0.999), rate_limit_rejections_total for CodeGen Platform (rate of increase indicates capacity pressure), preemptions_total (more than 50/day means the cluster is regularly saturated and you need more pods), and queue_depth at P4/P5 (growing queues for Acme Corp or MedAI Labs indicate under-provisioning for your most critical workloads).

KEY TAKEAWAY

Provision enough capacity for the simultaneous peak of all P5 and P4 tenants plus 50% of P3 average load. Let P1/P2 fill the remainder. Run monthly "noisy neighbor" chaos engineering drills to catch configuration drift before production surprises you.

12 Comparison with Other Approaches#

Before we deployed llm-d, we evaluated every alternative. We ran proof-of-concept deployments of three different approaches over six weeks. Here is what we found, and why we chose llm-d. I will be blunt: some of these alternatives could work for simpler setups, but none of them handled our five-tenant, mixed-workload, SLO-enforced scenario without significant custom engineering.

vLLM Native Multi-Tenancy#

vLLM is the inference engine llm-d uses as a backend, and it was our starting point. vLLM handles concurrent requests efficiently through continuous batching and can serve multiple LoRA adapters on the same base model. But vLLM does not provide tenant-level isolation primitives. There is no per-tenant priority, rate limiting, or SLO enforcement in the vLLM scheduler. All requests are treated equally. When we ran vLLM directly without a routing layer, CodeGen Platform's 10,000-request burst saturated the scheduler identically to the incident that started this whole journey. Good GPU utilization, zero tenant isolation.

NVIDIA Triton Inference Server#

Triton provides model-level isolation through its model repository architecture. It supports priority levels and rate limiting at the model level. But Triton's isolation is model-scoped, not tenant-scoped. When Acme Corp and CodeGen Platform both hit the same Llama 70B model, Triton does not distinguish between them. We would have needed to build an external gateway to map tenant identity to Triton scheduling parameters - effectively building the same routing layer that llm-d's EPP provides.

Custom Gateway Solutions#

We prototyped a custom rate limiter and priority router using Envoy with Lua plugins. It implemented per-tenant rate limiting and basic priority routing. But it was blind to GPU-level signals: it did not know about KV cache utilization at 94%, GPU memory pressure, or the continuous batching state of the inference engine. It could not make cache-aware routing decisions, could not perform graceful preemption (only hard kills), and could not proactively enforce Acme Corp's p95 TTFT < 200ms SLO based on actual GPU load. We scrapped it after two weeks.

KServe / Seldon Core#

KServe and Seldon Core are Kubernetes-native model serving platforms with model versioning, canary deployments, and autoscaling. Some multi-tenant awareness exists through namespace isolation and Istio-based traffic management. But neither understands LLM inference: they do not know about KV cache dynamics, continuous batching, or the autoregressive decode loop. Their autoscaling is based on generic metrics (CPU, memory, request rate) rather than LLM-specific signals like KV cache pressure or per-tenant latency percentiles.

Comparison Table#

Capability llm-d vLLM (direct) Triton Custom Gateway
Per-tenant priority 5-level + WFQ None Model-level only Basic (no GPU awareness)
Rate limiting Token bucket per tenant None Model-level Per-tenant
KV cache-aware routing Yes N/A (single instance) No No
SLO enforcement Proactive per-tenant None None Reactive only
Graceful preemption Re-queue, no drop None None None
GPU memory awareness Per-pod tracking Internal only Model-level None
Cost attribution Per-tenant GPU-seconds None Model-level Request-level only
Kubernetes native CRDs + Gateway API Manual deployment Helm charts Varies

The fundamental differentiator is that llm-d operates at the routing layer with full visibility into GPU-level signals. It knows about KV cache utilization, GPU memory pressure, continuous batching state, and per-tenant latency percentiles. This visibility is what enabled us to reduce Acme Corp's p95 TTFT from 2,400ms to 180ms during the CodeGen Platform incident, isolate 5 tenants across 12 GPU pods with per-tenant SLO compliance above 99.9% for our critical tier, and maintain cost attribution granular enough that every team lead knows exactly what they spend. Our custom Envoy gateway could rate limit, but it could not route based on KV cache pressure. Triton could isolate models, but it could not isolate Acme Corp from CodeGen Platform within the same Llama 70B deployment. vLLM provided the inference engine, but not the multi-tenant control plane.

Why Routing-Layer Isolation Works

llm-d does not modify the inference engine. It does not require custom CUDA kernels or GPU driver changes. It operates purely at the routing layer, making decisions about which request goes to which pod and when. This is the correct abstraction level because it is the last point in the request path where tenant identity is visible and the first point where GPU-level signals are available. Everything above it (API gateways, load balancers) lacks GPU awareness. Everything below it (vLLM, CUDA) lacks tenant awareness. We learned this the hard way by building a custom gateway that sat too high in the stack and a vLLM patch that sat too low.

KEY TAKEAWAY

llm-d's differentiator is operating at the routing layer with full visibility into GPU-level signals (KV cache, memory pressure, batching state) and tenant identity. Custom gateways lack GPU awareness; inference engines lack tenant awareness. The EPP sits at the intersection where both are available.

13 What We'd Do Differently#

Fourteen months of running a shared GPU cluster for five tenants has given us a list of lessons that we wish we could send back in time to ourselves. Every one of these came from a real incident, a real conversation, or a real "why didn't we do this sooner" moment. If you are standing up a multi-tenant inference platform, start here. I have tried to make each lesson specific enough that you can act on it, not just nod along.

Lesson 01
We would enforce tenant isolation from day one, not bolt it on after the third outage.
Our first deployment was "just put everyone on the same vLLM instance with a shared FIFO queue." We told ourselves we would add isolation later when it became necessary. It became necessary at 2:47 AM on a Tuesday. The three outages we endured before deploying llm-d's EPP cost us roughly 14 hours of cumulative SLO breach time for Acme Corp and a very uncomfortable conversation with their VP of Engineering. Isolation is not a feature you add later - it is a prerequisite for running multi-tenant GPU infrastructure.
Lesson 02
We would set SLO budgets before onboarding any tenant, not after someone's batch job takes down production.
When we onboarded CodeGen Platform, nobody asked "what happens if they submit 10,000 requests at 2 AM?" Nobody asked because nobody had defined Acme Corp's SLO in concrete terms - it was just "should be fast." If we had written "p95 TTFT < 200ms, 99.9% availability" on day one, we would have immediately seen that a FIFO queue with no rate limiting made that SLO impossible to guarantee. The SLO definition forces the architectural conversation.
Lesson 03
We would make cost attribution visible to every team lead from the start - shadow billing for the first month, then real chargeback.
For the first six months, GPU cost was an invisible line item. CodeGen Platform had no idea they were consuming 47% of cluster GPU-hours. Research Squad assumed their experiments were "basically free." The moment we turned on cost visibility, behavior changed overnight. CodeGen Platform moved their heaviest jobs to off-peak hours. Research Squad reduced experiment frequency by 40%. Shadow billing for the first month gave teams time to adjust before real charges hit. Do this from the beginning.
Lesson 04
We would run chaos engineering exercises monthly, not wait for production to surprise us.
After the third outage, we started running monthly "noisy neighbor" drills: we simulate CodeGen Platform's 10,000-request burst during peak Acme Corp traffic and verify that preemption kicks in within 3 seconds and Acme Corp recovers to within SLO within 12 seconds. We also simulate pod failures, network partitions, and EPP failover. Every drill has caught at least one configuration drift or monitoring gap that would have become a real incident. Production will surprise you. Surprise it first.
Lesson 05
We would dedicate GPU pods for Platinum tenants immediately - the 15% utilization overhead pays for itself in avoided incidents.
We initially ran Acme Corp on shared pods to maximize utilization. After the outages, we moved them to 4 dedicated pods. Yes, this means those pods sometimes run at 40% utilization during off-peak hours instead of 85%. That 15% average utilization overhead across 4 pods costs roughly $2,200/month in "wasted" capacity. The single Acme Corp outage that triggered the original PagerDuty escalation cost us approximately $18,000 in engineering time, customer credits, and lost trust. The math is not close. Dedicate pods for your highest-priority tenants from day one and let lower-priority tenants fill the overflow.
KEY TAKEAWAY

Enforce tenant isolation from day one, define SLO budgets before onboarding any tenant, make cost attribution visible immediately, and dedicate GPU pods for your highest-priority tenants. The 15% utilization overhead of dedicated pods pays for itself many times over in avoided incidents.

Further Reading

Don't Wait for Your Third Outage

llm-d is a CNCF Sandbox project, open source and production-ready. We deployed it on our 12-pod Kubernetes cluster and went from three outages in two weeks to zero SLO breaches in 14 months. Try the interactive simulator to model your own tenants, SLOs, and cost allocation - before your first PagerDuty alert.