Fairness in Multi-Tenant GPU Scheduling
You've got 512 A100s, four teams, and a fight brewing over who gets them. I've been there. At SIVARO we ran into this wall in early 2025 when two of our clients — a fintech doing real-time fraud detection and an LLM fine-tuning shop — started stepping on each other. The fintech needed low latency, guaranteed access. The fine-tuning team wanted burst capacity for a day, then nothing for a week. Classic multi-tenant problem.
Fairness in multi-tenant GPU scheduling isn't about equal shares. It's about making sure every tenant gets what they need when they need it, without one tenant's workload starving another. It's a resource allocation strategy that balances isolation, priority, and utilization across teams or customers sharing one GPU pool.
What you'll learn here: the difference between admission control and queueing, how to set up weighted fair sharing that actually works, and the hard trade-offs I've seen play out in production. I'll show you the configs we use, the metrics we track, and the mistakes I've made so you don't have to.
Why Most Fairness Models Fail
Most people think fairness means proportional allocation based on static quotas. They're wrong because GPU workloads are spiky, heterogeneous, and often have hard deadlines.
Let me give you a concrete example. In March 2026, a healthcare imaging customer of ours needed 40 GPUs for seven hours to process a backlog of MRI scans. Their quota was 16. We had 200 free GPUs sitting idle. Strict quotas would have said "no" — and a hospital would have waited a day for critical diagnostics.
Quotas are a snapshot, not a strategy.
Real fairness in multi-tenant GPU scheduling is dynamic. It's about detecting when a tenant's share is underutilized and redistributing that capacity temporarily — then reclaiming it when the owner comes back. Think of it as a library where you can borrow someone's book while they're on vacation, but you have to give it back the moment they walk in.
What this looks like in practice:
python
# Pseudocode for dynamic fairness policy
def schedule_request(tenant, requested_gpus, gpu_pool):
quota = tenant.base_quota
available_burst = gpu_pool.idle - calculate_reserved(tenant.priority)
if requested_gpus <= quota:
return allocate(requested_gpus, priority="normal")
elif requested_gpus <= quota + available_burst:
# Tenant can borrow, but marks it as preemptible
return allocate(requested_gpus - quota, priority="preemptible")
else:
# Falls back to queueing with fair ordering
return queue(tenant, requested_gpus)
This is where gpu admission control vs request queueing matters. You need both, but for different phases of the request lifecycle.
Admission Control vs. Queueing: It's Not Either/Or
I see this question come up constantly. Should I use admission control to reject requests that exceed quotas, or should I queue them and hope capacity frees up?
The answer is: both, but at different layers.
Admission control is the gatekeeper. It asks: "Should this request even enter the system?" It protects against thundering herds, validates quotas, and rejects requests that would destabilize the cluster. In 2024, a trading client of ours sent 10,000 pod requests in 300 milliseconds during a market event. Without strict admission control, that would have flattened our API server.
Queueing is the traffic regulator. It asks: "Given that I've admitted this request, when should it run?" It handles the "not now, but soon" cases.
Here's the framework I've settled on after testing both approaches at SIVARO:
yaml
# GPU admission control best practices - YAML policy example
apiVersion: sched.sivaro.io/v1
kind: AdmissionPolicy
metadata:
name: production-policy
spec:
admissionRules:
- name: "HardQuotaCheck"
action: "reject"
condition: "request.gpus > tenant.quota * 1.5"
- name: "FairShareCheck"
action: "reject"
condition: "tenant.share_current > tenant.share_fair + margin"
queueRules:
- name: "Backlog"
strategy: "weighted_fair"
maxQueueDepth: 1000
preemption: "true"
GPU admission control best practices from what we've built:
- Always reject requests that violate hard quotas at admission time. Don't kick this can down the road.
- Queue requests that are within 150% of quota but exceed fair share.
- Never admit a request you'll have to kill within 60 seconds. The churn costs more than the wait.
In production, admission control catches about 35% of requests that would fail anyway. Another 25% get queued briefly (under 90 seconds). The rest flow through. This pattern matters because it prevents your scheduler from making bad allocation decisions under load.
Weighted Fair Queuing Is Your Foundation
The algorithm that underpins most fairness in multi-tenant GPU scheduling is weighted fair queuing (WFQ) — borrowed from networking. Each tenant gets a weight. A startup with 5 GPUs of quota gets weight 5. Your core platform team with 200 GPUs gets weight 200.
The scheduler interleaves requests from different tenants proportionally. But GPUs aren't network packets. A training job can't be sliced into a few iterations and resumed later. So the classical WFQ needs adaptation.
At SIVARO, we built our scheduler around a virtual time approach — like the networking classic from Keshav 1991 — but with a key modification: we calculate finish time based on job duration estimates, not just job size. This handles the fact that a 4-GPU job running for 3 hours should weigh differently than a 4-GPU job running for 30 minutes.
Let me show you what we actually run:
python
class GPUWeightedFairScheduler:
def __init__(self, tenants):
self.tenants = {t.id: t for t in tenants}
self.virtual_time = 0.0
self.active_jobs = []
def compute_fair_share(self, tenant, gpu_hours_requested):
# virtualization of time: normalize by weight
total_weight = sum(t.weight for t in self.tenants.values() if t.active)
normalized = gpu_hours_requested / (tenant.weight / total_weight)
return normalized
def schedule(self, pending_jobs):
for job in pending_jobs:
tenant = self.tenants[job.tenant_id]
job.finish_time = self.virtual_time + self.compute_fair_share(tenant, job.gpu_hours)
# Sort by finish time — earliest first
return sorted(pending_jobs, key=lambda j: j.finish_time)
This is the mechanics, but the real insight is how you handle weight changes.
Priority Inversion and the "Important Tenant" Problem
Here's the pattern that breaks naive implementations.
You have a tenant called "Research" with weight 100. That's a good size. They're running an experiment that will complete in 2 days using 64 GPUs. Meanwhile, "Production Inference" — weight 50 — needs 8 GPUs immediately for a spike on a new model rollout. If you only use WFQ, Research's long job will dominate the timeline, and Production Inference gets starved.
The fix is hierarchical scheduling. Two tiers:
- Quota tier: Ensure each tenant gets at least their base allocation
- Priority tier: Within quota, handle precedence — but only when quotas are contested
Let me be blunt about something: fairness in multi-tenant GPU scheduling requires some concept of starvation protection. Otherwise "production inference" and "batch training" don't mix well. You'll see a batch job that runs for 100 hours blocking an inference pod that needs 50ms latency.
The approach that works — I learned this from production Kubernetes scheduling patterns — is dynamic preemption with grace periods. A low-priority job gets preempted, but only if it's been running less than 30 minutes. For batch training jobs that have checkpointing, this is fine. For interactive sessions, you request consent first.
yaml
apiVersion: sched.sivaro.io/v1
kind: FairnessPolicy
metadata:
name: production-latency-first
spec:
tiers:
- name: "production"
weight: 100
preemption: "never"
- name: "batch"
weight: 40
preemption: "allowed"
gracePeriod: "30m"
I can't stress enough: preemption without checkpointing is a disaster. You'll burn cycles re-running work. That's what happened to us with a Spark-on-GPU workload in 2023. Tens of thousands of lost core-hours. We now require checkpoint state for any job longer than 10 minutes.
Right-Sizing Your Isolation: Is It a Fairness Problem or a Fragmentation Problem?
You can't have fairness if the GPUs are scattered in unusable chunks.
In a multi-tenant cluster, you have a choice between GPU sharing (MIG or time-slicing) and exclusive allocation. I've seen this debate play out in the financial sector where compliance prevents any sharing of infrastructure between tenants.
There's a fragmentation problem that follows. Two tenants each get 6 GPUs. But one wants 8 ideally — and they could've taken 2 from the other tenant's idle 4. Now you're stuck at 88% utilization with unhappy people.
A fairness mechanism that doesn't include some consolidation pass is incomplete. I wrote about this earlier in my piece on GPU scheduling fragmentation issues but the short version is: periodic rebalancing helps.
Here's our policy:
python
def rebalance_cluster(gpu_manager, threshold=0.85):
"""Move idle GPUs from tenants under-using to those over-using."""
utilization = gpu_manager.get_utilization()
for node in gpu_manager.nodes:
if utilization[node] < threshold:
# Find tenants with idle GPUs
idle_owners = gpu_manager.get_idle_allocations(node)
# Find requests that could use them
pending_requests = sorted(gpu_manager.pending_requests,
key=lambda r: r.priority, reverse=True)
for owner in idle_owners:
if owner.utilization < owner.fair_share * 0.7:
continue # still respecting their future needs
for request in pending_requests:
if can_place(request, node):
gpu_manager.preempt_idle(owner, request,
safety_margin=30)
break
Rebalancing every 5 minutes is fine. Every 1 minute adds overhead. Less frequently than 10 minutes and you waste cycles.
What Does Fairness Actually Look Like in Practice?
Here are metrics I track daily for every tenant:
- Actual share vs. fair share ratio: This is the core. Above 1.0 means they're getting more than their weight justifies. Below 0.9 means they're being underserved.
- Queue wait time p95: The 95th percentile of time between request and scheduling.
- Preemption count: How often their jobs get killed.
I look at p95 over p99 intentionally. p99 latency in a shared scheduler is a zoo — outlier tenants can inflate it dramatically. I care about the bulk experience, not edge cases.
Here's what you should consider "acceptable":
Tenant A (weight 50): p95 queue wait = under 15 seconds
Tenant B (weight 10): p95 queue wait = under 60 seconds
Cluster utilization = 85-92%
If you see p95 queue wait exceeding 5 minutes for any tenant, fairness is broken. No question. Fix your weights or add capacity.
The Contrarian Take: Workload-Aware Is Better Than Tenant-Aware
Most fairness systems calibrate based on the tenant's identity. Over the past two years I've become convinced this is the wrong approach.
Two different workloads from the same tenant have entirely different latency sensitivities and interruption tolerances. A tenant running a real-time recommendation service has nothing in common with the same tenant running an offline analytics job. Penalizing or rewarding at the tenant level makes one job type suffer needlessly.
Workload types:
| Workload | Tolerance for delay | Tolerance for preemption | Typical GPU duration |
|---|---|---|---|
| Real-time inference | None | None | seconds |
| Training | Medium | High | hours-days |
| Batch/ETL | Medium-High | Medium | minutes-hours |
| Interactive dev | Low | Low | hours |
| Data prep | High | High | minutes |
In our system, we assign both tenant weights and workload types. The scheduling decision uses weights from both dimensions.
yaml
apiVersion: sched.sivaro.io/v1
kind: TenantOverride
metadata:
name: research-team-inference
spec:
tenant: "research"
workloadType: "real-time"
effectiveWeight: 90 # Higher than their batch default
This gives a tenant predictable behavior without letting one workload type dominate.
Admission Control in Depth: Setting the Right Hooks
Going back to admission control — I keep getting questions about how to configure this properly. Here's the example policy framework we publish for clients:
yaml
apiVersion: v1
kind: AdmissionControlConfig
spec:
checks:
- type: "RequestedOverQuota"
params: { ratio: 1.5 }
- type: "TotalGPUsAvailable"
params: { reserve: 20 } # Keep 20% idle
- type: "TenantLatencySLO"
params: { maxQueuePredict: 0.8 }
fallback: "queue"
The "fallback: queue" is the signal that distinguishes good admission control from naive quota enforcement.
Because here's the truth: if you hard-reject everything over quota, you'll eventually annoy the wrong person. Some requests can wait. If you let everything into the queue, you'll get thundering herd logjams.
I've found the following split works best:
- Reject if hard quota * 1.5 is exceeded (no exceptions)
- Reject if the cluster is out of total capacity with reserve
- Queue everything else, sorted by finish-time first
That's the gist of how to do admission control right. You're not playing gatekeeper for everything. You're just keeping the obvious problems out.
Fairness Is Not Static: Scheduling Across Time
Here's a scenario we handled in May 2026 that taught me a lot.
Two tenants, "AutoRetail" and "MedScan". AutoRetail had a flash sale coming — they needed 20% of the cluster for 36 hours. MedScan had a regular batch job of 12 hours. Both had equal weights of 50.
If the scheduler only looks at the current instant, it sees the flash sale demand and adjusts weights. That's wrong. Those 36 hours could have been scheduled days in advance.
Fairness in multi-tenant GPU scheduling needs to span time. You need a two-stage scheduler: one that forecasts demand and adjusts future weights, plus one that handles the immediate.
We built a component called "ReservationForecast" that:
- Predicts weekly GPU demand per tenant (based on their historical schedules)
- Adjusts the fairness window — a tenant who underused their share last week gets priority this week
- Marks known reservations (like the flash sale) as temporal blocks
python
def adjust_weight_with_foresight(tenant, planned_events, current_schedule):
horizon = 7 * 24 # hours into the future
hours_needed_next_week = sum(e.gpu_hours for e in planned_events
if e.start_time < now + timedelta(hours=horizon))
base_weight = tenant.weight
# If they've been under target, give them a boost
historical_usage = tenant.get_last_7_days_usage()
target_usage = tenant.weight_share * cluster_capacity
if historical_usage < target_usage * 0.8:
return base_weight * 1.15
# If they're bursting this week but not next, don't over-penalize
return base_weight
This approach reduces the "end-of-month scramble" problem where tenants hoard GPUs to avoid losing their allocation. Claimed idle GPUs are the enemy of fairness and utilization.
When Fairness Hurts Throughput
Let me be honest about trade-offs. Strict fairness can kill your total throughput by up to 20%.
Here's why: small jobs from low-weight tenants keep getting skipped if you only follow virtual time ordering. They barely use resources, but their queue wait time explodes.
In networking WFQ, a packet is a single entity — the cost of forwarding is identical regardless of tenant. GPUs are different. A 1-GPU, 5-minute job is fundamentally lighter than a 32-GPU, 3-hour job.
What we do: add a "small job boost" if queue wait time exceeds fair threshold. A 1-GPU, 10-minute job gets its effective weight doubled. This sacrifices pure proportion for better interactive experience. In our monitoring, this reduced p95 wait time for small jobs from 18 minutes to 1 minute without measurably starving large jobs. Similar findings in the Mesos resource allocation research support this direction.
It's a band-aid, but I'll take a 2% throughput loss for a 90% latency improvement for devs hunting for bugs.
Building the Autoscaling Feedback Loop
Eventually, fairness needs to talk to autoscaling. When a Fairness violation persists for more than 15 minutes, you have two options:
- Preempt some tenants (as discussed)
- Scale up capacity
Our autoscaler responds to persistent unfairness by adding more GPU nodes to the pool. It's not a perfect solution — you can't scale up without a cloud provider giving you inventory — but it's the right pressure release valve.
yaml
apiVersion: autoscaling.sivaro.io/v1
kind: FairnessDrivenAutoscaler
spec:
minNodes: 20
maxNodes: 80
threshold: "queue_p95 > 30s"
cooldown: "5m"
provider: "AWS"
instanceType: "p4d.24xlarge"
If you're on-prem and can't scale, your options are tighter. But at least the alarm tells your infrastructure team about the problem.
In December 2025 we autoscaled out during a spike in research workloads after a major release from a client in the autonomous vehicle space. The elastic node pool grew by 12 nodes, absorbed the load, and scaled back down three days later. That's how fairness operates when economics allow — temporary expansion beats permanent fights.
The Role of Culture: You Can't Enforce Your Way to Fairness
I could write a whole other article about the human dimension. Quick version:
The teams that accept scheduling policies without constant complaints all have one thing in common — they trust that the system won't screw them over. That trust comes from transparency.
We expose to every tenant:
- Their current fair share (compared to actual)
- Their weight relative to other tenants (aggregated, anonymized)
- Why specific jobs got preempted or delayed
Invest engineering time in making that visibility easy to consume. In October 2025, we switched from a CLI-only tool to a Slack bot that gives on-demand status. Complaints about "unfair scheduling" dropped by 60% in one quarter. It wasn't that we became more fair. We just let people see the fairness.
The Bottom Line
Fairness in multi-tenant GPU scheduling is achievable with three things:
- Weighted fair queuing at its core — as I showed above, it's your foundation.
- Smart admission control — reject what's impossible, queue what's plausible.
- Dynamic adjustments for real workloads — include workload type, temporal reservation, and small job boost logic.
I've seen teams try to build this from scratch and fail — the subtle interaction between preemption grace periods and checkpointed state is deceptively complex. And I've seen teams adopt Kubernetes native defaults and then deal with tenant rage.
Fairness is a design philosophy. It's about setting the rules so that even when someone loses a round (and someone always will), they see the rationale and know the rules will protect them next week.
In our cluster at SIVARO, the two client groups I mentioned in the intro — fintech and LLM fine-tuning — now coexist fine. They still push boundaries, but the scheduler holds its ground. It's not popular all the time. It's fair.
That's the best you can hope for.
FAQ
Q: Should I use GPU sharing like MIG or time-slicing for fairness?
It depends. MIG gives hard isolation — better for latency-sensitive workloads. Time-slicing allows higher utilization but introduces variable latency. Fairness-wise, MIG is easier to reason about, but you'll see more idle capacity. You can couple MIG with time-slicing on leftover capacity, but it's complex. I ran time-slicing for inference workloads with a 100ms latency budget; never again. It's too unpredictable.
Q: What's the difference between GPU admission control and request queueing in practice?
Admission control is the request gate. It rejects or accepts into the system. Queueing is the schedule — it determines when admitted requests run. We found they need to be separate policy domains because admission protects overall cluster health, while queueing handles fairness among healthy requests.
Q: How do I handle a tenant that consistently uses less than 50% of their share?
Let the capacity go. Don't hoard for them — just track it in their historical utilization. If they complain about losing allocation, show them the data. In our system, a tenant below 60% utilization over 7 days gets their weight reduced by 20% for the next cycle. We let them bounce back by demonstrating demand.
Q: Is hierarchical scheduling with quotas enough for fairness?
No. A quota system enforces ceilings but not entitlement. Fairness requires sharing the ceiling of others when they're idle. Without dynamic sharing, max utilization tops out around 60% in my experience. If you only do one thing, set up dynamic borrowing with preemption.
Q: How do I implement fairness if I'm using Kubernetes with a GPUs-only pool?
Use kube-scheduler's RequestedToCapacityRatioPriority as a baseline, but extend it with weights per namespace. Check out Kueue — it's actively maintained and gives you a cohort abstraction for fair sharing.
Q: What role does autoscaling play in fairness?
Autoscaling is the pressure release valve. When fairness violations persist after preemption and rebalancing, autoscaling adds capacity. We use it as a third-level defense. Air-gapped or on-prem clusters without autoscaling need stricter admission control; otherwise, the cluster degrades under overload.
Q: Can fairness policies handle spot instances or preemptible GPUs?
Yes, but default them to a lower weight. Treat preemptible as "best effort." Anything important goes on guaranteed, on-demand capacity. If your critical workloads run on spot, that's a whole separate reliability conversation.
Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.