AI EngineeringZero to ProductionHome·About·Contact
ML Systems Internals · Part 4

Multi-GPU serving

A model too big for one GPU forces two independent decisions: shard it across GPUs to make it fit, and replicate it to serve the traffic. Confuse the two and you pay for hardware that can't scale. This is the systems view of multi-GPU serving — tensor parallelism's latency tax, KV-cache as the true capacity ceiling, and the cost-per-token math that picks the topology.

⏱️ ~2 hours🧪 5 labs🎯 Advanced→Tech-lead

Learning objectives

  • Separate the two orthogonal scaling axes: replicas (throughput/availability) vs sharding (fit a model that doesn't).
  • Explain tensor parallelism at inference — the per-layer all-reduce and its latency cost.
  • Treat KV-cache memory, not weights, as the real concurrent-request ceiling.
  • Set vLLM/TGI multi-GPU flags and autoscale on the right signal.
  • Compute cost-per-token across GPU counts and defend a topology as a tech-lead.

1 · Two orthogonal axes advanced

Once a model is in production you scale it along two independent axes, and conflating them is the most common multi-GPU mistake. Replicas add throughput and availability: each replica is a complete copy of the model, and a load balancer fans requests across them. Sharding (tensor / pipeline parallelism) splits one model across several GPUs because it does not fit on one — it buys capacity to run the model at all, not throughput. You size them separately: sharding first (can one copy even load?), then replicas (how many copies for the traffic?).

AxisWhat it buysUnitWhen you reach for it
Replicasthroughput + availabilitya full model copytraffic exceeds one copy's capacity
Sharding (tensor/pipeline)the ability to fit the modelone model split across G GPUsweights + KV-cache exceed one GPU

A production deployment is the product of the two: total GPUs = replicas × GPUs-per-replica. Eight GPUs might be 8 replicas of a model that fits one card, or 2 replicas of a model sharded across 4 cards — very different throughput and cost profiles for the same hardware bill.

Request from client Router / LB picks a replica Replica 1 = model / G GPUs Replica N = model / G GPUs
🗺️ How to read this diagram

This is the whole shape of a multi-GPU deployment. Two different things are happening at once, and the picture keeps them separate — read left to right along the arrows.

  • The Request comes from a client (a user, another service). It doesn't know or care how many GPUs are behind the endpoint.
  • The Router / LB (load balancer) picks one of the replicas to handle it. This is the throughput/availability axis: more replicas means more requests handled in parallel and a spare if one dies.
  • Replica 1 … Replica N are identical, complete copies of the model. Any of them can answer any request — that's why the load balancer can freely choose.
  • The = model / G GPUs under each replica is the other axis: inside a single replica the model is sharded (split) across G GPUs because it's too big for one. That's sharding / tensor parallelism — it makes the model fit, it doesn't add replicas.

In short: Two knobs, not one. N (across the diagram) scales traffic; G (inside each box) just makes one copy fit. Total GPUs = N × G — pick each for its own reason.

Read it left to right: a request hits the router / load balancer, which picks one of N replicas. Inside each replica the model is sharded across G GPUs with tensor parallelism — the horizontal axis (N) is throughput/availability, the depth inside a box (G) is the fit-the-model axis. The two multiply.

2 · Tensor parallelism at inference advanced

Tensor parallelism splits each weight matrix across G GPUs: every GPU holds a slice of each layer and computes a partial result on the full activation. Because a layer's output is a sum over those slices, the GPUs must all-reduce (sum-and-broadcast) their partials twice per transformer layer — once after attention, once after the MLP. That collective runs over NVLink and adds latency to every token: TP raises capacity but never comes free. It is why TP is kept within a single node (fast NVLink) and pipeline parallelism spans nodes (slower links, tolerates it).

TP is a latency tax, not a throughput winSharding one model across 4 GPUs does not make it 4× faster — the per-layer all-reduce means 4-way TP is often slower per token than 1 GPU would be if the model fit. You shard to make the model runnable, then add replicas for throughput. Never reach for TP as a speed lever.
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · sharding calculator: how many GPUs to fit the model (runs)
gpus_needed.pydef gpus_needed(model_gb, gpu_gb, kv_overhead=1.4, usable=0.90):
    """GPUs to hold weights + KV-cache/activations.
    kv_overhead: multiply weights to reserve room for KV-cache + activations.
    usable: fraction of each GPU you dare use (rest = fragmentation/headroom).
    Tensor parallelism needs a power-of-two shard count in practice."""
    need_gb = model_gb * kv_overhead
    per_gpu = gpu_gb * usable
    raw = -(-need_gb // per_gpu)            # ceil division
    g = 1
    while g < raw:                          # round up to a power of two
        g *= 2
    return {"need_gb": round(need_gb, 1), "per_gpu_gb": round(per_gpu, 1),
            "raw_gpus": int(raw), "tensor_parallel_size": g}

# Llama-3.1-70B in fp16 is ~140 GB of weights; an A100/H100 is 80 GB.
print(gpus_needed(model_gb=140, gpu_gb=80))
# A 4-bit (AWQ) 70B is ~40 GB and fits two cards.
print(gpus_needed(model_gb=40, gpu_gb=80))
{'need_gb': 196.0, 'per_gpu_gb': 72.0, 'raw_gpus': 3, 'tensor_parallel_size': 4}
{'need_gb': 56.0, 'per_gpu_gb': 72.0, 'raw_gpus': 1, 'tensor_parallel_size': 1}
▶ How this works

This calculator answers the first sizing question: how many GPUs does one copy of the model need to even load? It's the sharding axis — before you think about traffic at all.

  1. need_gb = model_gb * kv_overhead — the weights aren't the whole story. The kv_overhead (1.4×) reserves room for the KV-cache and activations that also live in GPU memory, so you don't size for weights alone and then run out.
  2. per_gpu = gpu_gb * usable — you can't use 100% of a card (fragmentation, headroom), so 90% is the realistic budget per GPU.
  3. raw = -(-need_gb // per_gpu) is a ceiling divide — a Python trick where negating twice rounds up instead of down, giving the minimum GPU count that fits.
  4. The while g < raw: g *= 2 loop rounds that up to a power of two (1, 2, 4, 8…), because tensor parallelism splits layers evenly and in practice wants a power-of-two shard count — tensor_parallel_size.

What the output means: A 70B model in fp16 (~140 GB) needs 4 GPUs (tensor_parallel_size: 4) even though 3 would technically fit — it rounds to 4. Quantized to 4-bit (~40 GB) it fits 1 GPU, so no sharding at all.

Try this: Change model_gb to 16 (a 7B model) — it returns tensor_parallel_size: 1, meaning don't shard, just replicate. Small models never need this whole axis.

3 · KV-cache is your capacity ceiling expert

Weights decide whether the model loads; the KV-cache decides how many requests you can serve at once. Every in-flight request holds a per-token key/value tensor for every layer for its whole context. Once the weights are resident, all remaining GPU memory is a KV-cache budget — and that budget, divided by per-request KV size, is the hard cap on concurrency. Run past it and vLLM preempts/queues requests; latency spikes. This is the number that actually limits a serving box, not FLOPs.

KV cache is your capacity limitOn a served model, throughput is gated by how many concurrent requests fit in KV-cache, not by raw compute. Long contexts blow it up fast — KV grows linearly with sequence length × batch. When you need more concurrency the levers are: shorter contexts, quantized/paged KV, MQA/GQA models (fewer KV heads), or another replica. Adding GPUs to one replica via TP barely helps concurrency — it mostly buys room for the weights.
Python · KV-cache capacity model: max concurrent requests (runs)
kv_capacity.pydef per_request_kv_gb(layers, kv_heads, head_dim, seq_len, bytes_per=2):
    """KV bytes = 2 (K and V) * layers * kv_heads * head_dim * seq_len * dtype_bytes.
    kv_heads is the GROUPED count (GQA/MQA) — far smaller than attention heads."""
    kv_bytes = 2 * layers * kv_heads * head_dim * seq_len * bytes_per
    return kv_bytes / 1e9

def max_concurrent_requests(kv_cache_budget_gb, per_request_kv_gb):
    """Concurrency ceiling = KV budget / per-request KV footprint."""
    return int(kv_cache_budget_gb // per_request_kv_gb)

# Llama-3-70B shape: 80 layers, 8 GQA KV-heads, head_dim 128, 8k context, fp16.
one_req = per_request_kv_gb(layers=80, kv_heads=8, head_dim=128, seq_len=8192)
print(f"per request @8k ctx: {one_req:.2f} GB")

# After 140 GB of weights across 4x80GB cards, ~116 GB is left for KV.
print("max concurrent:", max_concurrent_requests(kv_cache_budget_gb=116, per_request_kv_gb=one_req))
# Halve the context and you nearly double concurrency:
half = per_request_kv_gb(layers=80, kv_heads=8, head_dim=128, seq_len=4096)
print("max concurrent @4k:", max_concurrent_requests(116, half))
per request @8k ctx: 2.68 GB
max concurrent: 43
max concurrent @4k: 86
▶ How this works

This is the number that actually limits a serving box: how many requests can run at the same time. It's set by KV-cache memory, not by compute — and it's the ceiling most teams forget to measure.

  1. per_request_kv_gb(...) computes the memory one in-flight request holds: 2 (a Key and a Value) × layers × KV-heads × head_dim × sequence length × dtype bytes. It grows linearly with context length — long prompts are expensive to keep resident.
  2. kv_heads is the grouped head count (GQA/MQA), which is far smaller than the attention head count — that's precisely the trick modern models use to shrink KV-cache.
  3. max_concurrent_requests(budget, per_request) is just budget ÷ per-request: after the weights are loaded, whatever GPU memory is left is a KV budget, and dividing gives the hard cap on concurrency.
  4. The two calls show the lever: halving the context from 8k to 4k doubles how many requests fit — because per-request KV halved.

What the output means: At 8k context one request needs 2.68 GB of KV, so ~116 GB of free memory serves 43 concurrent requests; at 4k context that jumps to 86. That concurrency number is your real capacity, not FLOPs.

Try this: Set seq_len=32768 and watch concurrency collapse. This is why a 'long-context' endpoint serves far fewer users on the same GPUs — the KV-cache is the budget being spent.

4 · vLLM / TGI multi-GPU flags expert

You configure parallelism; you never implement it. In vLLM the shard count is --tensor-parallel-size (a.k.a. tensor_parallel_size in the LLM(...) Python API), with --pipeline-parallel-size for spanning nodes. In TGI (Text Generation Inference) the equivalent is --num-shard. Both expose --gpu-memory-utilization (vLLM) / --cuda-memory-fraction (TGI) to tune the weights-vs-KV split from section 3. The command below is real but needs the hardware.

vLLM · shard one model across 4 GPUs, then call it ▶ needs multiple GPUs
serve_tp4.sh# ▶ needs multiple GPUs (>=4) on one node with NVLink + `pip install vllm`.
# NOT run by this lesson's offline labs — it talks to real hardware.

# Serve a 70B model sharded 4 ways (tensor parallel), OpenAI-compatible on :8000
python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Llama-3.1-70B-Instruct \
  --tensor-parallel-size 4 \          # split each layer across 4 GPUs (all-reduce/layer)
  --gpu-memory-utilization 0.90 \     # leave the rest as KV-cache budget (section 3)
  --max-num-seqs 128 \                # continuous-batch width; bounded by KV, not this
  --host 0.0.0.0 --port 8000

# TGI equivalent shard flag would be:  text-generation-launcher --num-shard 4

# --- client: identical to any OpenAI call, one replica behind the port ---
# from openai import OpenAI
# client = OpenAI(base_url="http://localhost:8000/v1", api_key="none")
# r = client.chat.completions.create(
#     model="meta-llama/Llama-3.1-70B-Instruct",
#     messages=[{"role": "user", "content": "Hello from 4 sharded GPUs."}])
# print(r.choices[0].message.content)

Note what the flags do not do: --tensor-parallel-size 4 makes the 70B model fit and share its layers across 4 cards; it does not multiply throughput. To serve more traffic you run this same command on more nodes and put a load balancer in front — that is the replicas axis, orchestrated outside vLLM (Kubernetes, a router).

5 · Autoscaling & load balancing professional

Replicas are stateless copies, so a plain load balancer fans requests across them — but for LLM serving, least-outstanding-requests (or queue-aware) routing beats round-robin, because request cost varies wildly with output length and a naive round-robin piles long generations onto one replica. Autoscale on the right signal: CPU is useless (GPUs are busy while CPU idles); scale on queue depth, in-flight requests, or p95 latency / time-to-first-token. Each new replica is a full model reload (tens of seconds to minutes), so scale ahead of demand and keep warm headroom — you cannot cold-start a 70B replica inside a latency SLO.

Autoscale on queue depth, never CPUA GPU replica pinned at 100% utilization can still show low CPU. CPU-based HPA will refuse to scale a saturated fleet. Wire your autoscaler to pending queue length or p95 latency — the signals that actually track whether requests are waiting.
Python · replicas-vs-sharding cost / throughput comparator (runs)
topology_cost.pydef plan_cost(name, replicas, gpus_per_replica, rps_per_replica,
              gpu_hourly, target_rps):
    """Compare two topologies for the SAME model+hardware budget.
    Sharding (high gpus_per_replica) fits big models but each replica is pricey
    and TP overhead caps its rps; replicas multiply throughput linearly."""
    total_gpus = replicas * gpus_per_replica
    capacity_rps = replicas * rps_per_replica
    cost_hr = total_gpus * gpu_hourly
    tokens_hr = capacity_rps * 3600 * 300          # assume ~300 tok/response
    cost_per_1k = cost_hr / (tokens_hr / 1000) if tokens_hr else float("inf")
    return {"plan": name, "gpus": total_gpus, "cap_rps": capacity_rps,
            "meets_target": capacity_rps >= target_rps,
            "cost/hr": round(cost_hr, 2), "cost/1k_tok": round(cost_per_1k, 5)}

TARGET, PRICE = 40, 4.0                              # rps needed, $/GPU-hr
# Plan A: model fits 1 GPU -> 8 lean replicas (TP overhead = none, high rps each)
print(plan_cost("A: 8x TP1", replicas=8, gpus_per_replica=1,
                rps_per_replica=6, gpu_hourly=PRICE, target_rps=TARGET))
# Plan B: same 8 GPUs but model sharded 4-way -> 2 replicas, TP tax lowers rps each
print(plan_cost("B: 2x TP4", replicas=2, gpus_per_replica=4,
                rps_per_replica=9, gpu_hourly=PRICE, target_rps=TARGET))
{'plan': 'A: 8x TP1', 'gpus': 8, 'cap_rps': 48, 'meets_target': True, 'cost/hr': 32.0, 'cost/1k_tok': 0.00062}
{'plan': 'B: 2x TP4', 'gpus': 8, 'cap_rps': 18, 'meets_target': False, 'cost/hr': 32.0, 'cost/1k_tok': 0.00165}
▶ How this works

This ties the two axes to money. It compares two ways to spend the same 8 GPUs and shows why sharding is a last resort, not a speed choice — the punchline of the whole lesson in numbers.

  1. total_gpus = replicas * gpus_per_replica — the two axes multiply. Both plans below use 8 GPUs, so the hourly cost is identical; only the split differs.
  2. capacity_rps = replicas * rps_per_replica — throughput scales with replicas. Plan A (8 replicas that each fit one GPU) sustains far more requests than Plan B (2 replicas sharded 4 ways), whose per-replica rps is dragged down by the tensor-parallel all-reduce tax.
  3. cost_per_1k divides the hourly bill by tokens produced per hour — the number you actually report to the business.
  4. The result: for a model that fits one card, replicas win on both throughput and cost-per-token. You'd only pick Plan B's sharding if the model simply won't load on one GPU.

What the output means: Plan A (8× TP1) meets the 40-rps target at $0.00062 / 1k tokens; Plan B (2× TP4) misses the target and costs $0.00165 / 1k tokens — ~2.7× worse on the same hardware. Sharding was pure overhead here because the model didn't need it.

Try this: Raise Plan B's rps_per_replica until it meets the target — you'll find you can't get there on 8 GPUs, because TP overhead caps each sharded replica. The only fix is more GPUs, which proves sharding-for-speed is a losing trade.

Same 8 GPUs, same hourly bill — but if the model fits one card, 8 lean replicas serve ~2.7× the traffic at ~2.7× lower cost-per-token than 2 sharded replicas. Sharding is what you do when Plan A is impossible because the model won't fit — not a topology you pick for speed.

6 · Tech-lead — choosing the topology tech-lead

A lead owns the whole sizing decision and writes it down: (1) can one replica load? — use gpus_needed to set the minimum shard count, preferring 4-bit weights and a single node so TP stays on NVLink; (2) what concurrency does one replica sustain? — bound it with the kv_capacity model at the real p95 context length, not the max; (3) how many replicas for the traffic + an availability margin? — from the load test, plus one for headroom; (4) autoscale on queue depth with warm capacity because replicas cold-start slowly; (5) cost per 1k tokens from topology_cost is the number you defend to the business. The senior instinct: shard only as much as you must, replicate for everything else.

Shard to fit, replicate to scaleThe whole lesson in one line. Tensor parallelism is a fit mechanism with a latency tax; replicas are the scale mechanism. Minimize GPUs-per-replica (just enough to load the model), then add replicas behind a queue-aware load balancer for throughput and availability. The capacity you can actually sell is set by KV-cache, so measure it at your real context length before you promise an SLO.

Exercise MS4.1 — Size a 70B deployment end to end

Context: Sizing a real deployment is a chain of decisions, each moving a specific axis: how many GPUs per replica, how much concurrency one replica holds, and how many replicas the traffic needs.

Your task: For Llama-3.1-70B at your target traffic, pick a shard count (fp16 vs 4-bit), compute one replica's concurrency at your real p95 context, choose a replica count, and report cost per 1k tokens.

Requirements:

  • Pick a shard count and compare fp16 vs 4-bit weight fit
  • Compute one replica's concurrency at your real p95 context length
  • Choose replica count for the target traffic and report cost per 1k tokens
  • State which axis (shard vs replica) each decision moved

💡 Hint: Shard only enough to fit; every throughput decision is a replica decision.

Exercise MS4.2 — Prove sharding is not a speed lever

Context: Sharding is a fit mechanism, not a speed lever — and the cleanest way to internalize that is to build a case where more small replicas beat fewer sharded ones, then change the model so sharding becomes mandatory.

Your task: Construct a case where a model that fits one GPU is cheaper and higher-throughput as many TP1 replicas than as few TP-sharded replicas on the same GPU count, then grow the model until sharding is the only option.

Requirements:

  • Show TP1 replicas beat TP-sharded replicas on cost and throughput when the model fits one card
  • Attribute the sharded penalty to the per-layer all-reduce tax
  • Increase model size until it no longer fits one card
  • Explain the sharded plan is then forced by capacity, not chosen for speed

💡 Hint: TP adds an all-reduce per layer with no throughput gain; it only earns its place when a single replica cannot load the weights plus KV.

✓ Knowledge check

You have 8 GPUs and a model that comfortably fits on one. A teammate proposes --tensor-parallel-size 8 "to make it faster." What's wrong, and what should you do?

Show answer
Tensor parallelism does not add throughput — it adds a per-layer all-reduce tax, so TP8 on a model that already fits would be slower per token and serve less traffic than running 8 independent TP1 replicas. Sharding is a fit mechanism for models too big for one GPU; throughput comes from replicas. Correct move: 8 replicas (each TP1) behind a queue-aware load balancer. Reserve TP only for when a single replica can't load the weights + KV.
✓ Knowledge check

Your served model loads fine on 4 GPUs, but under load requests start queueing and latency spikes even though GPU compute isn't maxed. What is the likely limit and the fix?

Show answer
You've hit the KV-cache capacity ceiling: the concurrent requests in flight need more key/value memory than is left after the weights, so vLLM preempts/queues. It's a memory limit, not a FLOPs limit. Fixes: shrink per-request KV (shorter contexts, paged/quantized KV, a GQA/MQA model with fewer KV heads), or add a replica to spread concurrency. Adding GPUs to the same replica via TP mostly buys weight room, not concurrency.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Two orthogonal axesBeginner

Context: Scaling serving runs on two orthogonal axes: split one model across GPUs (tensor parallel) to make it fit, or run many replicas to add throughput. The classic mistake is reaching for the first to solve a problem that needs the second.

Your task: Write a classifier that maps a serving symptom to the axis it needs: tensor parallel, more replicas, or both.

Requirements:

  • Model won't load on one GPU → tensor parallel (tp_size > 1)
  • Load too high but model fits one GPU → more replicas
  • Big model and high load → both (TP within a replica, then replicate)
  • Frame TP as a capacity fix and replicas as a throughput fix

💡 Hint: The two axes multiply; don't reach for TP to solve a load problem.

Show solution

They are orthogonal: you can do both. Runnable:

def axis(symptom):
    if symptom == "model won't load on one GPU":
        return "tensor parallel (split the model, tp_size > 1)"
    if symptom == "load too high, model fits one GPU":
        return "more replicas (horizontal scale)"
    if symptom == "big model AND high load":
        return "both: TP within a replica, then replicate the replica"
    return "measure first"

for s in ["model won't load on one GPU", "load too high, model fits one GPU"]:
    print(s, "->", axis(s))

Tensor parallel is a capacity fix (make it fit / faster per request); replicas are a throughput fix. Don't reach for TP to solve a load problem.

Exercise 2 · Tensor parallelism at inferenceIntermediate

Context: Tensor parallelism shards each layer's weights across GPUs so the model fits and a single request speeds up — but it adds a per-layer all-reduce on the critical path of every token, so it only pays off on fast interconnect.

Your task: Model TP's per-GPU weight memory (~1/tp of the weights) and the added per-token communication (an all-reduce of the hidden activation per layer) for tp of 1, 2, 4, 8.

Requirements:

  • Per-GPU weights = params × bytes / tp
  • Comms per token ≈ n_layers × 2·(tp−1)/tp · hidden × bytes
  • Show weights fall as 1/tp while comms rises with tp
  • Conclude TP needs high-bandwidth interconnect (NVLink) to pay off

💡 Hint: The all-reduce is on every token's critical path, so bandwidth — not GPU count — decides whether TP helps.

Show solution

Weights shard 1/tp; comms is an all-reduce of the activation (hidden × tokens) per layer. Runnable:

def tp_weight_gib(params_b, tp, bytes_per=2):
    return params_b * 1e9 * bytes_per / tp / (1024 ** 3)

def tp_comm_gib_per_tok(n_layers, hidden, tp, bytes_per=2):
    # one all-reduce per layer over the hidden vector, ~2*(tp-1)/tp volume
    per = 2 * (tp - 1) / tp * hidden * bytes_per
    return n_layers * per / (1024 ** 3)

for tp in (1, 2, 4, 8):
    w = tp_weight_gib(70, tp)
    c = tp_comm_gib_per_tok(80, 8192, tp)
    print(f"tp={tp}: weights/GPU={w:6.1f} GiB, comms={c*1e6:6.2f} KiB/token")
# weights fall as 1/tp; comms rises with tp -- fast interconnect (NVLink) required

TP makes a 70B model fit and speeds a single request, but only pays off on high-bandwidth interconnect — the per-layer all-reduce is on the critical path of every token.

Exercise 3 · KV-cache is your capacity ceilingAdvanced

Context: Once the weights are placed, the leftover VRAM is the KV budget, and that budget divided by per-request KV is your real capacity ceiling — usually before GPU compute is anywhere near maxed.

Your task: Compute how many 4k-context requests an 80 GiB card serves after loading a 70B int4 model, and show how the number changes with context length.

Requirements:

  • KV budget = usable_VRAM − weight_GiB
  • Concurrency = KV_budget // KV_per_request
  • KV per request uses the same 2·n_layers·n_kv_heads formula at the request's context
  • Show doubling context roughly halves concurrency

💡 Hint: Concurrency is set by how many KV slots fit in the memory the weights leave behind, which is why dashboards track KV utilization.

Show solution

Concurrency is set by how many KV slots fit in the memory the weights leave behind. Runnable:

def kv_per_req_gib(n_layers=80, n_kv_heads=8, head_dim=128, seq=4096, bytes_per=2):
    return 2 * n_layers * n_kv_heads * head_dim * seq * bytes_per / (1024 ** 3)

def concurrency(card_gib=80, weight_gib=32.6, headroom=0.10, seq=4096):
    budget = card_gib * (1 - headroom) - weight_gib
    per = kv_per_req_gib(seq=seq)
    return max(0, int(budget // per)), per

for seq in (2048, 4096, 8192):
    n, per = concurrency(seq=seq)
    print(f"ctx={seq:>5}: KV/req={per:.2f} GiB -> ~{n} concurrent requests")
# doubling context halves concurrency -- the KV cache IS the capacity ceiling

This is why serving dashboards track KV-cache utilization: it, not GPU compute, is usually what caps concurrent users.

Exercise 4 · vLLM / TGI flags, decodedExpert

Context: Every vLLM/TGI serving flag maps to a term in the KV-memory equation, so an invalid config can be caught offline instead of via an expensive crash-loop on a multi-GPU node.

Your task: Map --tensor-parallel-size, --max-model-len, --gpu-memory-utilization, and --max-num-seqs to the math, then write a validator that flags a config that will OOM.

Requirements:

  • Weights shard by tp; budget = card × gpu_mem_util − weights
  • KV need = KV_per_seq(max_model_len) × max_num_seqs
  • Return OK when need ≤ budget, else a WILL-OOM message with the lever to lower
  • Keep the real vLLM launch as reference labeled needs-GPU

💡 Hint: Each flag is one variable in the memory equation; validate the product of concurrency and per-seq KV against the post-weights budget.

Show solution

The flags set weight sharding, context, VRAM headroom, and concurrency; validate them against the KV budget. Runnable:

def validate(tp, max_model_len, gpu_mem_util, max_num_seqs,
             params_b=70, card_gib=80, bytes_per=2):
    weight = params_b * 1e9 * bytes_per / tp / (1024 ** 3)
    budget = card_gib * gpu_mem_util - weight
    kv_per = 2 * 80 * 8 * 128 * max_model_len * bytes_per / (1024 ** 3)
    need = kv_per * max_num_seqs
    ok = need <= budget
    return (f"tp={tp} len={max_model_len} util={gpu_mem_util} seqs={max_num_seqs}: "
            f"KV need={need:.1f} / budget={budget:.1f} GiB -> "
            f"{'OK' if ok else 'WILL OOM -- lower max_num_seqs or max_model_len'}")

print(validate(tp=4, max_model_len=8192, gpu_mem_util=0.90, max_num_seqs=64))
print(validate(tp=4, max_model_len=8192, gpu_mem_util=0.90, max_num_seqs=8))
# needs GPU -- the real launch (documented vLLM CLI):
# vllm serve meta-llama/Llama-3.1-70B --tensor-parallel-size 4 #   --max-model-len 8192 --gpu-memory-utilization 0.90 --max-num-seqs 8

Every flag maps to a term in the memory equation; validating offline saves a crash-loop on an expensive multi-GPU node.

Exercise 5 · Autoscaling on the right signalProfessional

Context: LLM serving saturates on KV-cache utilization and queue depth long before CPU, so autoscaling on CPU misses the saturation entirely — the right signal fills first.

Your task: Write a replica autoscaler driven by KV-cache utilization and queue depth, with hysteresis so it scales up early and down slowly.

Requirements:

  • Scale up when KV utilization is high or the queue is deep
  • Scale down only when utilization is low, the queue is empty, and replicas > 1
  • Use different up/down thresholds (hysteresis) to avoid flapping
  • Note CPU-based autoscaling misses LLM saturation

💡 Hint: The KV cache fills before CPU does; scale on that, and separate the up and down thresholds so it doesn't oscillate.

Show solution

Scale up early (KV util high) and down slowly (hysteresis) to avoid flapping. Runnable:

def autoscale(replicas, kv_util, queue, up=0.80, down=0.40, max_r=20):
    if kv_util > up or queue > 5:
        return min(max_r, replicas + 1), "scale UP (KV/queue high)"
    if kv_util < down and queue == 0 and replicas > 1:
        return replicas - 1, "scale DOWN (idle)"
    return replicas, "hold"

r = 2
for kv, q in [(0.85, 2), (0.9, 8), (0.5, 0), (0.3, 0)]:
    r, why = autoscale(r, kv, q)
    print(f"kv={kv} queue={q} -> replicas={r} ({why})")

CPU-based autoscaling misses LLM saturation entirely; the KV cache fills long before CPU does, so that is the signal to scale on.

Exercise 6 · Choose the topology for an SLOIndustry scenario

Context: Delivering an SLO means combining both axes: use the smallest TP that fits and meets latency, then replicate for concurrency — and cost the result before promising anything.

Your task: For 70B at p95 < 2s and 200 concurrent users on 80 GiB cards, recommend a TP×replica topology with users-per-replica, GPU count, and a monthly cost, flagging the measurement that still needs a GPU.

Requirements:

  • Pick the smallest tp that fits the weights and meets latency
  • users_per_replica = (budget) // KV_per_request at the target context
  • replicas = ceil(users / users_per_replica); GPUs = replicas × tp
  • Cost = GPUs × GPU-hour × hours; note p95 must be load-tested (needs GPU)

💡 Hint: TP is the fit/latency lever, replicas are the concurrency lever; the offline model sets topology and bill but not the measured tail latency.

Show solution

Pick smallest tp that fits and meets latency, then replicate for concurrency. Runnable:

import math

def kv_per_req(seq=8192):
    return 2*80*8*128*seq*2 / (1024**3)

def topology(users, params_b=70, card_gib=80, tp=4, gpu_hr=2.0, seq=8192):
    weight = params_b*1e9*2/tp/(1024**3)
    budget = card_gib*0.9 - weight
    per_replica_users = max(1, int(budget // kv_per_req(seq)))
    replicas = math.ceil(users / per_replica_users)
    gpus = replicas * tp
    monthly = gpus * gpu_hr * 24 * 30
    return (f"tp={tp} x {replicas} replicas = {gpus} GPUs; "
            f"{per_replica_users} users/replica; ~${monthly:,.0f}/mo")

print(topology(200))
# p95 latency must be MEASURED under load (locust/vegeta + real weights) -- needs GPU

The offline model fixes the topology and the bill; the p95 SLO is only confirmed by a load test on the real weights (needs GPU) because kernel and batching effects dominate tail latency.

✓ Checkpoint — you can move on when you can…

  • Separate replicas (throughput/availability) from sharding (fit the model) and multiply them.
  • Explain the per-layer all-reduce of tensor parallelism and why TP is a latency tax, not a speedup.
  • Compute a KV-cache concurrency ceiling and name it as the real capacity limit.
  • Set vLLM --tensor-parallel-size / TGI --num-shard and autoscale on queue depth.
  • Compare cost-per-token across topologies and defend a shard×replica plan as a lead.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in