Skip to content
advanced Phase 18 · Production Capstone and ML Interview Preparation

Serving Trade-offs: Latency and Cost

Balance inference speed, throughput, and spend when deploying models.

1h
0 problems
Topic Progress 0%

One GPU, One Million Requests: The Dollars-Per-Request Arithmetic

You are asked to serve a 120M-parameter encoder at ~250 QPS average (600 QPS peak) with p99 ≤ 100 ms. "Which hardware should we use?" sounds like a taste question. It is arithmetic, and the unit that matters is not the hourly sticker price — it is cost per million requests:

$ per 1M = hourly_price / (sustained_qps × 3600) × 1,000,000 = 277.78 × hourly_price / qps

Measured options for the same model, same load profile:

Option $/hr Sustained qps $ / 1M requests p99 at that load
CPU, 4 vCPU 0.19 25 2.11 140 ms (misses SLO)
CPU, 16 vCPU 0.48 80 1.67 95 ms
T4 GPU (g4dn.xlarge) 0.53 400 0.37 45 ms
A10G (g5.xlarge) 1.01 900 0.31 38 ms
A10G + int8 model 1.01 1,500 0.19 25 ms

Check one row: the T4 at 400 qps moves 400 × 3600 = 1.44M requests per hour, so 0.53 / 1.44 = $0.37 per million. The 16-vCPU box has the lower hourly sticker ($0.48 vs $0.53) and still costs 4.5× more per request ($1.67 vs $0.37), while its p99 already grazes the SLO. Hourly price is a decoy; per-request price at measured throughput is the decision.

Then traffic enters, and it flips answers. The same T4 at 20 qps realized traffic: 20 × 3600 = 72,000 requests/hr → 0.53 / 0.072 = $7.36 per million — worse than any CPU row, because a warm GPU bills identically whether it is serving or idle. The break-even against the CPU option that meets the SLO (a constant $1.67/1M once you autoscale instances to load) is where the always-on T4's realized cost equals it: 277.78 × 0.53 / q = 1.67 → q ≈ 88 qps. Below roughly 88 qps of steady traffic, an always-on GPU is the expensive option; above it, the GPU wins per request at every point on this table. Spiky traffic makes it worse — an autoscaler cannot save you from a cold start of 30-90 seconds (image pull plus weight load) during a burst you did not see coming.

Two mitigations, in order of preference: scale to zero for internal or batch-triggered endpoints (accept the cold start or pre-warm on a queue signal), and batch the offline work so bursts become scheduled runs rather than warm replicas.

The GPU-hours math extends to everything else you run:

  • Idle serving: 1 replica × 730 h/month × $1.01 = $737/month for one A10G that serves nothing at 3 a.m.; two replicas for HA = $1,474. At 500 qps average traffic (1.31B requests/month), that HA pair costs $1,474 / 1,314 = $1.12 per million — utilization, not instance price, decides whether GPU serving is cheap.
  • Training: a nightly 1.5-hour fine-tune = 45 GPU-hours/month ≈ $45. A hyperparameter sweep of 40 configs × 3 folds × 20 min = 120 fits = 40 GPU-hours ≈ $40 — and spot pricing (~70% off) drops it near $12, because a failed sweep trial is cheap to retry.

Interview framing: state your assumptions (qps, SLO, model size), show the formula, quote the per-million numbers, then name the two caveats — break-even traffic and cold-start latency. That answer shows you have run the bill before, not just the benchmark.

The capstone you just shipped ends with an endpoint and a p99 budget; the hardware decision above is where that budget starts costing money — End-to-End Capstone Project.

Batching Is a Dial: Trading Queue Wait Against Throughput

Serving one request at a time wastes a GPU: kernel launches and memory-bandwidth stalls dominate, so a single 4 ms forward pass leaves the device mostly idle. Group concurrent requests into one tensor and throughput climbs — this is dynamic batching (Triton, TensorFlow Serving) or continuous batching (vLLM). The cost lands on tail latency, because every request in a batch waits for the batch to finish, and someone has to wait for the batch to fill.

Measured shape for an image-classification-class model on one A10G:

max_batch queue wait p99 compute p99 + overhead request p99 throughput GPU util
1 0 ms 11 ms 5 ms 16 ms 65 rps 22%
8 6 ms 14 ms 5 ms 25 ms 340 rps 68%
32 21 ms 22 ms 5 ms 48 ms 760 rps 91%
64 130 ms 34 ms 5 ms 169 ms 980 rps 96%

(overhead = network + serialization, a flat ~5 ms.) Read every p99 row as queue wait + compute + overhead. Moving from batch 1 to 32 lifts throughput 11.7× (65 → 760 rps) while p99 triples (16 → 48 ms) — usually an excellent trade under a 100 ms budget. Batch 64 buys only +29% more throughput (760 → 980) and more than triples p99 again (48 → 169 ms). That is the shape of the curve: batch size is set by the p99 budget, not by the GPU-utilization graph, because utilization keeps climbing long after the SLO is dead.

Two waits compose the queue term, and they fail differently:

  1. Fill delay — waiting for enough requests to arrive so the batch can form. You cap this directly with the server's max-queue-delay setting.
  2. Saturation backlog — waiting because the server is already busy. No delay setting bounds this one; waiting grows roughly as 1/(1 − ρ) where ρ is utilization, so at 96% utilization a batch endpoint's tail explodes while its dashboard looks "efficient."

The sizing rule follows: run replicas at 60-70% of measured plateau throughput. At batch 32 the plateau is 760 rps; 0.65 × 760 ≈ 500 rps per replica, so a 600 qps peak needs two replicas — three if you require one to survive a failure with headroom. Driving one replica to 95% saves an instance and buys you the page.

# NVIDIA Triton model configuration
dynamic_batching:
  preferred_batch_size: [8, 16, 32]
  max_queue_delay_microseconds: 40000   # cap fill delay at 40 ms
  max_available_queue_size: 64          # bound the backlog per scheduler

Set the delay from the budget, not from folklore: worst-case fill delay + worst-case compute at your chosen batch + overhead must stay under the p99 SLO. With a 100 ms budget, batch-32 compute (22 ms) plus overhead (5 ms), a 40 ms delay cap leaves ~33 ms of margin for variance and network. Raise the cap to 200 ms and you have bought throughput the SLO can never collect on.

Three traps worth naming before an interviewer does:

  • Client-side batching is not server-side batching. A client assembling its own groups spreads arrivals smears queueing across the stack, and you cannot bound the wait end-to-end. One coordinated server queue is the design that lets you reason about p99 at all.
  • Retries amplify load. If downstream callers retry at 50 ms while your p99 is 80 ms, a saturation event triggers retries that add offered load exactly when you are near ρ = 1 — the collapse feeds itself. Use retry budgets with jitter and deadlines below your SLO.
  • Batching changes per-request semantics for anything stateful. Streaming scores through a batch endpoint is fine; mixing requests that mutate server state into one batch is not.

The economics connect back directly: 760 rps on one $1.01/hr A10G is 1.01 / (760 × 3600 / 1e6) = $0.37 per million requests, while the same card unbatched at 65 rps delivers 1.01 / (65 × 3600 / 1e6) = $4.32 per million — 11.7× more expensive per request for identical hardware. Batching is the single biggest cost lever in serving, which is why the next chapter asks what happens when you also shrink the model.

int8, fp16, or a Smaller Teacher: Paying Accuracy for Inference Budget

If hardware and batching still miss the budget, shrink the model. Three levers — precision, distillation, pruning — each buy speed and memory for a measured accuracy cost. The discipline that keeps this honest: decide the accuracy floor first, then buy the cheapest speed that stays above it. An unmeasured 0.4 points is a gamble, not a trade.

A BERT-base-class encoder (110M parameters) on one A10G, batch 16:

Variant Precision Artifact GPU footprint p50 / p99 Dev metric
Baseline fp32 440 MB 2.1 GB 24 / 61 ms 92.10
Half precision fp16 220 MB 1.2 GB 13 / 34 ms 92.08
Post-training quantization int8 110 MB 0.7 GB 9 / 23 ms 91.66
Quantization-aware training int8 110 MB 0.7 GB 9 / 23 ms 91.98
Distilled 6-layer fp32 264 MB 1.0 GB 8 / 21 ms 90.85

(artifact size is pure arithmetic: 110M × 4 / 2 / 1 bytes; footprint includes CUDA context and batch workspace.)

What the rows say, in decision order:

  • fp16 is nearly free. 2× smaller, ~1.9× faster, −0.02 points. Take it on any hardware with tensor cores unless you have a measured numerical reason not to.
  • int8 post-training quantization is the real trade. 4× smaller, 2.7× faster (24 → 9 ms), −0.44 points. Quantization-aware training simulates int8 rounding during fine-tuning and recovers 0.32 of those points (−0.12 total) for the price of one extra training run — call it 3 GPU-hours ≈ $3. Against int8's $0.31 → $0.19 per-million serving cost at fixed hardware, a workload of 500 qps (1.31B requests/month) saves ~$158/month. The payback is immediate; the only question was whether −0.12 clears your floor.
  • Memory sets a concurrency ceiling you feel as throughput. The 2.1 GB fp32 footprint fits ~6 concurrent batch-16 slots on a 16 GB card (leaving headroom for the runtime); the 0.7 GB int8 footprint fits ~18. 3× the concurrency on the same GPU, on top of the 2.7× per-forward speedup — instance memory is a cost constraint, not a storage detail.
  • Distillation attacks the FLOPs, not the bytes. A 6-layer student trained on the teacher's soft targets runs at 8 ms fp32 (3× the baseline's speed) for −1.25 points. Compress further by quantizing the student: int8 student ≈ 4 ms p50, roughly 6× faster than baseline for about −1.5 points — acceptable only if your floor says so.

Before you shrink anything, consider routing: run a cheap model first and escalate only the hard cases.

def score(x):
    p = fast_model.predict_proba(x)[:, 1]
    out = p.copy()
    uncertain = (p > 0.35) & (p < 0.65)      # small model is confident → keep it
    if uncertain.any():
        out[uncertain] = strong_model.predict_proba(x[uncertain])[:, 1]
    return out

With 80% of traffic resolved by the fast path: mean latency = 0.8 × 6 + 0.2 × 45 = 13.8 ms versus 45 ms flat — 3.3× faster, with accuracy within 0.1 points of the strong model, because the escalated 20% contains essentially all the errors. Cascades monetize the same insight as quantization: spend accuracy-shaped compute only where accuracy is at risk.

Pruning (structured sparsity, head removal) is the third lever and the one to approach last — its speedups only materialize with hardware kernels that exploit the sparsity pattern, so an unstructured 50% prune often returns no latency at all. Measure, don't assume.

Interview framing: give all four numbers — the accuracy floor, the measured delta, the latency gain, and the dollars saved per million — then say which lever you would pull first and why. Interviewers for ML system design are checking one thing: whether every quality trade you make is quantified against a floor someone agreed to, which is exactly the skill the next round rewards: ML System Design Interviews.

Quiz

1. A T4 instance costs $0.53/hr and serves a measured sustained 400 qps. What does it cost per million requests?

Question 1 options

2. Your replica plateaus at 760 rps with max_batch 32 (queue wait p99 21 ms, compute 22 ms, overhead 5 ms) under a 100 ms p99 budget. Where should steady load sit, and why?

Question 2 options

3. Which statement about shrinking a model's precision is correct?

Question 3 options

4. A scaled CPU fleet meets the p99 SLO at a constant $1.67 per million requests. An always-on T4 costs $0.53/hr. At what realized traffic does the T4 become cheaper per request than the CPU fleet?

Question 4 options

Flashcards

Question

What is the formula for serving cost per million requests, and how do you find the CPU/GPU break-even?

Answer

$ per 1M = 277.78 × hourly_price / sustained_qps (equivalently hourly / (qps × 3600) × 1e6). Break-even: set 277.78 × gpu_hourly / q equal to the alternative's $/1M and solve for q — e.g. T4 at $0.53/hr vs a CPU fleet at $1.67/1M breaks even at ~88 qps. Below it, the always-on GPU's idle time makes it the expensive option.

Question

How does a request's p99 decompose in a dynamically batched endpoint, and how do you size replicas?

Answer

p99 = queue wait (batch fill delay + saturation backlog) + compute at the batch size + overhead (network/serialization). Fill delay is capped by max_queue_delay; backlog is not and grows as 1/(1 − ρ). Size replicas at 60-70% of measured plateau throughput so ρ stays low, then add replicas for peak — e.g. 600 qps peak against a 760 rps plateau at 65% ⇒ two replicas.

Question

What do fp16, int8 PTQ, int8 QAT, and distillation each buy, and what do they cost?

Answer

fp16: 2× smaller, ~1.9× faster, ~0 points — take by default. int8 PTQ: 4× smaller, ~2.7× faster, −0.44 points. int8 QAT: same speed/size, recovers most of the loss (−0.12) for one extra training run (~$3 in GPU-hours). Distilled 6-layer: 3× faster at fp32 for −1.25 points, and quantizing the student stacks to ~6× for about −1.5. Always measure against a pre-declared accuracy floor.

Question

Why does model memory affect serving cost beyond storage, and what is the GPU-hours math?

Answer

Footprint per batch slot caps concurrency on a card: 2.1 GB fp32 vs 0.7 GB int8 on 16 GB is 6 vs 18 slots — 3× throughput on identical hardware, stacked on top of the per-forward speedup. GPU-hours: 730 h/month × $/hr is the idle bill (1 A10G ≈ $737/month serving nothing at 3 a.m.), training examples: nightly 1.5 h fine-tune ≈ 45 GPU-hours ≈ $45; a 40-config × 3-fold × 20-min sweep = 40 GPU-hours ≈ $40, near $12 on spot.

Question

What is a latency/cost cascade and when does it beat uniformly shrinking the model?

Answer

Run a cheap model first and escalate only uncertain cases (score band, e.g. 0.35-0.65) to the strong model. At 80/20 traffic: 0.8×6 + 0.2×45 = 13.8 ms mean vs 45 ms flat — 3.3× faster with accuracy within ~0.1 of the strong model, because errors concentrate in the escalated slice. It wins when errors are concentrated and the cheap model is well-calibrated; uniform quantization wins when every request needs the same guarantee.

Revision Notes

Key Takeaways

  • 1. Judge hardware by cost per million requests at measured throughput — the lower hourly sticker can still cost 4.5× more per request
  • 2. An always-on GPU breaks even against a scaled CPU fleet at ~88 qps realized traffic; below that, idle time makes it the expensive option, and cold starts block the autoscaling escape
  • 3. Batch size is set by the p99 budget: p99 = queue wait + compute + overhead, backlog grows as 1/(1 − ρ), so replicas run at 60-70% of plateau
  • 4. Compression is a measured exchange, not a slogan: fp16 is nearly free, int8 trades 0.4 points (QAT recovers most for ~$3), distillation trades 1.25 for 3× speed
  • 5. Memory drives concurrency as much as speed does — one-third the footprint means 3× the slots per GPU — and cascades beat uniform shrinking when errors concentrate

Interview Tips

  • • "Choose hardware for 500 QPS at p99 100 ms." — show the formula, quote cost per million for 2-3 options, size replicas at 60-70% of plateau, then name cold-start and break-even traffic; that sequence is what separates a computed answer from a guess
  • • "What does batching cost you?" — decompose p99 into queue wait + compute + overhead, explain fill delay vs saturation backlog (1/(1−ρ)), and give the number: 11.7× throughput for 3× p99 from batch 1 to 32
  • • "When do you quantize?" — declare the accuracy floor first, then quote PTQ vs QAT deltas, the memory-to-concurrency multiplier, and the dollars saved per million; QAT's ~$3 GPU-hour cost vs ~$158/month saved makes the case concrete
  • • "Your p99 breach keeps paging — what do you check?" — queue depth and utilization first (the cause), then batch configuration and retry amplification (retries adding load near ρ = 1), and only then model size; answer with the layer order, not a single guess

Cheat Sheet

Serving Trade-offs: Latency and Cost

Cost per 1M = 277.78 × hourly / sustained_qps.

Option $/hr qps $/1M p99
CPU 4 vCPU 0.19 25 2.11 140 ms
CPU 16 vCPU 0.48 80 1.67 95 ms
T4 0.53 400 0.37 45 ms
A10G 1.01 900 0.31 38 ms
A10G + int8 1.01 1500 0.19 25 ms

Break-even: 277.78 × 0.53 / q = 1.67 → ~88 qps; below it an always-on GPU costs more per request. Caveats: cold start 30-90 s (scale to zero / pre-warm), spiky traffic, idle bill (1 A10G = 730 h = $737/month).

Batching: p99 = queue wait + compute + overhead.

max_batch p99 throughput util
1 16 ms 65 rps 22%
8 25 ms 340 rps 68%
32 48 ms 760 rps 91%
64 169 ms 980 rps 96%

Set max_queue_delay from the budget; backlog (unbounded by delay) grows as 1/(1−ρ) ⇒ run at 60-70% of plateau. Batch = cost lever: $0.37/1M batched vs $4.32/1M unbatched on the same A10G.

Compression (110M encoder, accuracy floor first):

def score(x):                      # cascade: escalate only uncertainty
    p = fast.predict_proba(x)[:, 1]
    keep = ~((p > 0.35) & (p < 0.65))
    out = p.copy(); out[~keep] = strong.predict_proba(x[~keep])[:, 1]
    return out

fp16: 2×/1.9×/−0.02 · int8 PTQ: 4×/2.7×/−0.44 · int8 QAT: −0.12 for ~$3 · distilled: 3×/−1.25 · student+int8 ≈ 6×/−1.5 · cascade 80/20: 13.8 ms mean (3.3×) / −0.1.

Continue Learning