Serve a model at scale
The ML Systems track, assembled into one decision. Given a model, a latency SLO, and a traffic level, you will size the VRAM, decide sharding, size the fleet, pick a batch width, and build a $/1M-token cost model — every step a stdlib-only simulation you can run on your laptop, then one plan_deployment() that prints the whole plan.
Learning objectives
- Compose the whole MS track into one sizing pipeline: VRAM → shard → KV-cache → fleet → cost.
- Compute the VRAM a model needs at a chosen precision (MS1 memory math).
- Decide when a model must be tensor-parallel across GPUs, and across how many (MS4).
- Turn leftover VRAM into a KV-cache budget and a concurrency ceiling (MS4).
- Size a replica fleet to hit a target QPS under a p99 SLO (MS4).
- Build a $/1M-token cost model and compare self-hosting to an API baseline (MS5).
This is the capstone for the ML Systems Internals track. You learned the pieces one lesson at a time — GPU memory and the model (MS1), distributed training (MS2), attention & GPU kernels (MS3), multi-GPU serving (MS4), and throughput economics (MS5). Here you wire them into one decision: somebody hands you a model, an SLO, and a traffic level, and you produce a defensible deployment plan with a price tag on it.
python3 file.py. The one block that needs real hardware — a vLLM multi-GPU serve command — is labelled ▶ needs GPUs: read it, don't run it.Architecture — the sizing pipeline on one line essential
A request for capacity flows left to right: you start from a model + SLO + QPS, compute how much VRAM the weights need, decide whether it fits on one GPU or must shard across several, turn the leftover memory into a KV-cache budget that caps concurrency, size a fleet of replicas to hit the target QPS under the SLO, and finally roll it all into a $/1M-token cost model you can defend.
This is the whole capstone on one line: capacity requirements enter at the left and flow right through five sizing stages. Each box is one build step on this page, and the output of each feeds the next.
- Model + SLO + QPS is the input: which model, how fast it must answer (the p99 latency SLO), and how much traffic (queries per second).
- Size VRAM turns the parameter count and precision into gigabytes of GPU memory (MS1). This decides everything downstream.
- Shard decision asks: does that fit on one GPU, or must we split the model tensor-parallel across G GPUs (MS4)? The leftover memory becomes the KV-cache budget.
- Fleet sizing converts one replica's safe throughput into a replica count that hits the target QPS without breaking the SLO, and Batching is the concurrency ceiling the KV-cache allows.
- Cost model rolls it all into $/1M tokens and compares self-hosting to an API price (MS5) — the one number a decision hangs on.
In short: requirements in → VRAM → shard? → fleet → batch → price out. The rest of the page builds that sentence one step at a time, and Step 6 runs the whole thing as one function.
Read it as one sentence: requirements in → VRAM → shard? → fleet → batch → price out. The rest of this page builds that sentence one step at a time; by Step 6 the pieces snap together into a single plan_deployment() that prints the whole plan.
Step 1 · Size the VRAM the model needs essential
Start where MS1 starts: a model's weights are just parameters × bytes-per-parameter. At serving time you also carry activation and framework overhead, so we add a headroom factor. This one function answers "how many gigabytes of VRAM do the weights want?" for any model at any precision.
step1_vram.pyBYTES_PER_PARAM = {"fp32": 4, "fp16": 2, "bf16": 2, "int8": 1, "int4": 0.5}
def weight_vram_gb(params_billion, precision="fp16", overhead=1.2):
"""VRAM (GiB) for model weights at a precision, with a serving overhead factor.
params_billion : parameter count in billions (70 for a 70B model)
precision : one of fp32/fp16/bf16/int8/int4 (MS1: bytes per parameter)
overhead : multiplier for activations + framework (~1.2 is typical)
"""
bytes_per = BYTES_PER_PARAM[precision]
raw_gb = params_billion * 1e9 * bytes_per / (1024 ** 3)
return round(raw_gb * overhead, 1)
for prec in ("fp16", "int8", "int4"):
print(f"70B @ {prec:<4}: {weight_vram_gb(70, prec)} GiB (with overhead)")
print("8B @ fp16:", weight_vram_gb(8, "fp16"), "GiB")
70B @ fp16: 156.5 GiB (with overhead)
70B @ int8: 78.2 GiB (with overhead)
70B @ int4: 39.1 GiB (with overhead)
8B @ fp16: 17.9 GiB
This is the MS1 memory identity in code: a model's weights are just parameters × bytes-per-parameter, plus a little overhead for activations and the framework. It answers the first question of any deployment — will this even fit?
BYTES_PER_PARAMmaps a precision to bytes: fp16 is 2 bytes, int8 is 1, int4 is half a byte. Lower precision = fewer bytes per weight.weight_vram_gbmultipliesparams × 1e9 × bytes_per, divides by1024**3to get GiB, then multiplies byoverhead(~1.2) for the activation/framework tax at serving time.- The loop prints the same 70B model at three precisions, then an 8B model at fp16 — so you can see precision, not just size, driving the footprint.
What the output means: A 70B model needs ~157 GiB at fp16 but only ~39 GiB at int4 — a 4x swing from precision alone. The 8B model fits comfortably at ~18 GiB.
Try this: Change overhead to 1.0 to see the raw weight size, then to 1.4 for a heavier framework. This number is the input to the shard decision in Step 2.
params × bytes/param; everything else (KV-cache, activations) sits on top. Quantizing from fp16 to int4 quarters the weight footprint — that is the single biggest lever on whether a model fits, and it is why Step 2's shard decision depends on the precision you pick here.Step 2 · Decide sharding — one GPU, or tensor-parallel across G? intermediate
A GPU has a fixed VRAM budget (an 80 GiB H100, say). If the weights fit with room to spare for the KV-cache, you serve on one GPU. If not, you split the model across G GPUs with tensor parallelism (MS4): each GPU holds 1/G of the weights. We pick the smallest power-of-two G that leaves a target fraction of each GPU free for the KV-cache.
step2_shard.pyfrom math import ceil
def _wv(params_billion, precision="fp16", overhead=1.2): # from Step 1
bpp = {"fp32": 4, "fp16": 2, "bf16": 2, "int8": 1, "int4": 0.5}[precision]
return round(params_billion * 1e9 * bpp / (1024 ** 3) * overhead, 1)
def shard_plan(params_billion, precision, gpu_vram_gb=80, kv_reserve=0.30):
"""Smallest tensor-parallel degree G (power of two) that fits the weights and
still leaves `kv_reserve` of each GPU free for the KV-cache. Returns a dict."""
weights = _wv(params_billion, precision)
usable_per_gpu = gpu_vram_gb * (1 - kv_reserve) # leave room for KV-cache
g = 1
while weights / g > usable_per_gpu:
g *= 2 # 1, 2, 4, 8, ...
return {
"weights_gb": weights,
"tensor_parallel": g,
"weights_per_gpu_gb": round(weights / g, 1),
"kv_headroom_per_gpu_gb": round(gpu_vram_gb - weights / g, 1),
"verdict": "single GPU" if g == 1 else f"tensor-parallel x{g}",
}
for params, prec in [(8, "fp16"), (70, "fp16"), (70, "int4")]:
plan = shard_plan(params, prec)
print(f"{params}B @ {prec}: TP={plan['tensor_parallel']} "
f"({plan['verdict']}), {plan['weights_per_gpu_gb']} GiB/GPU, "
f"KV headroom {plan['kv_headroom_per_gpu_gb']} GiB")
8B @ fp16: TP=1 (single GPU), 17.9 GiB/GPU, KV headroom 62.1 GiB
70B @ fp16: TP=4 (tensor-parallel x4), 39.1 GiB/GPU, KV headroom 40.9 GiB
70B @ int4: TP=1 (single GPU), 39.1 GiB/GPU, KV headroom 40.9 GiB
A GPU has a fixed VRAM budget. This step decides whether the model fits on one GPU or must be split — tensor-parallel — across several (MS4), and picks the smallest split that still leaves room for the KV-cache.
usable_per_gpuis the GPU's VRAM minus a reserve (30% here) held back for the KV-cache — you can't fill a GPU entirely with weights and still serve requests.- The
while weights / g > usable_per_gpuloop doublesg(1, 2, 4, 8…) until each GPU's slice of the weights fits. That's the tensor-parallel degree. - It returns the degree, the weights-per-GPU, and the KV headroom left over — the exact number Step 3 turns into a concurrency ceiling.
What the output means: The 8B model serves on a single GPU; 70B at fp16 needs TP x4; but 70B at int4 collapses back to a single GPU — quantization changed the sharding decision.
Try this: Lower gpu_vram_gb to 40 (an A100-40G) and watch the TP degree climb. Sharding is forced by memory, so a smaller GPU needs more of them.
Step 3 · KV-cache budget → max concurrent requests advanced
The VRAM you left free in Step 2 is not decoration — it is the KV-cache, and it sets how many requests you can decode at once (MS4). Each token in flight stores a key and a value for every layer and every attention head. Divide the free VRAM by the per-request KV footprint and you get a hard concurrency ceiling — the real cap on batch width.
step3_kvcache.pydef kv_bytes_per_token(layers, hidden, precision="fp16"):
"""KV-cache bytes for ONE token: 2 (K and V) x layers x hidden x bytes."""
bytes_per = {"fp32": 4, "fp16": 2, "bf16": 2, "int8": 1, "int4": 0.5}[precision]
return 2 * layers * hidden * bytes_per
def max_concurrency(kv_headroom_gb, layers, hidden, seq_len, precision="fp16"):
"""How many concurrent requests fit in the KV-cache headroom, each holding
`seq_len` tokens of context."""
per_token = kv_bytes_per_token(layers, hidden, precision)
per_request = per_token * seq_len
headroom_bytes = kv_headroom_gb * (1024 ** 3)
return int(headroom_bytes // per_request)
# Llama-3-70B geometry: 80 layers, hidden 8192. KV headroom from Step 2 (TP x4): ~40.9 GiB/GPU.
per_tok = kv_bytes_per_token(80, 8192)
print("KV bytes/token :", per_tok)
for seq in (2048, 4096, 8192):
c = max_concurrency(40.9, 80, 8192, seq)
print(f"seq={seq:<5} -> max concurrent requests: {c}")
KV bytes/token : 2621440
seq=2048 -> max concurrent requests: 8
seq=4096 -> max concurrent requests: 4
seq=8192 -> max concurrent requests: 2
The VRAM you reserved in Step 2 is the KV-cache, and it caps how many requests you can decode at once (MS4). This step converts free memory into a hard concurrency ceiling.
kv_bytes_per_tokenis2 × layers × hidden × bytes: the 2 is for the Key and the Value, stored for every layer, for every token in flight.max_concurrencymultiplies that byseq_lento get the per-request footprint, then divides the free VRAM by it — how many full-context requests fit at once.- The loop shows the ceiling shrinking as context grows: the same headroom holds fewer long requests than short ones.
What the output means: At 2k context 8 requests fit; doubling to 4k halves that to 4, and 8k halves it again to 2. Longer context directly buys you less concurrency — the cache, not compute, is the limit.
Try this: Cut seq_len to 1024 and watch concurrency jump. This ceiling is the maximum batch width Step 4 gets to schedule against.
seq_len halves how many requests fit, because each one now pins twice the KV memory. This ceiling is the batch width Step 4 gets to work with — you cannot batch wider than the cache allows.Step 4 · Size the fleet to hit target QPS under a p99 SLO professional
One replica can only push so many tokens per second, and pushing it toward its concurrency ceiling grows the queue and blows the p99 SLO (MS4). So we run each replica at a safe utilization, compute its safe QPS, and take ceil(target_qps / per_replica_qps) replicas. We also flag when the SLO forces us to leave throughput on the table.
step4_fleet.pyfrom math import ceil
def replica_qps(max_concurrency, tokens_per_req, decode_tok_per_s, safe_util=0.7):
"""Safe requests/sec for ONE replica: run at `safe_util` of the concurrency
ceiling so the queue stays short enough to meet a p99 SLO."""
safe_concurrency = max_concurrency * safe_util
# each request occupies a decode slot for tokens_per_req / decode_tok_per_s seconds
seconds_per_req = tokens_per_req / decode_tok_per_s
return safe_concurrency / seconds_per_req
def fleet_size(target_qps, per_replica_qps):
return max(1, ceil(target_qps / per_replica_qps))
# From Step 3: 8 concurrent @ 2k context. A TP x4 70B replica decodes ~2500 tok/s aggregate.
r_qps = replica_qps(max_concurrency=8, tokens_per_req=400,
decode_tok_per_s=2500, safe_util=0.7)
print(f"per-replica safe QPS: {r_qps:.2f}")
for target in (10, 50, 120):
n = fleet_size(target, r_qps)
print(f"target {target:>3} QPS -> {n} replicas "
f"(headroom {n * r_qps - target:.1f} QPS)")
per-replica safe QPS: 35.00
target 10 QPS -> 1 replicas (headroom 25.0 QPS)
target 50 QPS -> 2 replicas (headroom 20.0 QPS)
target 120 QPS -> 4 replicas (headroom 20.0 QPS)
One replica can only serve so many requests per second, and pushing it to its concurrency ceiling blows the p99 SLO (MS4). This step sizes a fleet that hits the target QPS while staying safely below that ceiling.
replica_qpsruns each replica atsafe_util(70%) of its concurrency ceiling, then divides by how long each request occupies a decode slot (tokens_per_req / decode_tok_per_s) to get safe requests/sec.fleet_sizeis justceil(target_qps / per_replica_qps)— round up, because you can't run a fraction of a replica.- The loop sizes the fleet for three traffic levels and prints the leftover headroom, so you can see how much spare capacity each rounding-up bought.
What the output means: One replica safely serves 35 QPS, so 50 QPS needs 2 replicas and 120 QPS needs 4 — with a little headroom each time from rounding up.
Try this: Raise safe_util to 0.9 and the fleet shrinks — but your p99 tail gets worse. That trade is the utilization tax the SLO charges you.
safe_util=0.7 is the price of a tight tail. Lowering the SLO (accepting a worse p99) is a real lever: it lets you raise safe_util and shrink the fleet.Step 5 · Cost model — $/1M tokens vs an API baseline professional
Now put a price on it (MS5). The fleet costs replicas × GPUs/replica × $/GPU-hour; the throughput is replicas × per-replica tokens/sec. Divide dollars-per-hour by tokens-per-hour and you get $/1M tokens — the one number that lets you compare self-hosting to an API list price on equal footing.
step5_cost.pydef cost_per_1m_tokens(replicas, gpus_per_replica, gpu_hourly, tokens_per_sec):
"""Blended $/1M output tokens for the whole fleet at steady state."""
fleet_cost_per_hour = replicas * gpus_per_replica * gpu_hourly
tokens_per_hour = tokens_per_sec * 3600
return fleet_cost_per_hour / tokens_per_hour * 1_000_000
# 3 replicas, TP x4 (4 GPUs each), $3.50/GPU-hour, ~2500 tok/s per replica.
replicas, gpus_each, hourly, tok_s = 3, 4, 3.50, 2500
fleet_tok_s = replicas * tok_s
selfhost = cost_per_1m_tokens(replicas, gpus_each, hourly, fleet_tok_s)
api_baseline = 3.00 # $/1M output tokens, list price
print(f"fleet throughput : {fleet_tok_s} tok/s")
print(f"self-host $/1M tok : ${selfhost:.2f}")
print(f"API baseline $/1M : ${api_baseline:.2f}")
cheaper = "self-host" if selfhost < api_baseline else "API"
print(f"cheaper at this load: {cheaper} "
f"({abs(selfhost - api_baseline) / api_baseline * 100:.0f}% delta)")
fleet throughput : 7500 tok/s
self-host $/1M tok : $1.56
API baseline $/1M : $3.00
cheaper at this load: self-host (48% delta)
Now put a price on the fleet (MS5). This step turns GPUs-per-hour and tokens-per-second into $/1M tokens — the one unit that lets you compare self-hosting to an API list price fairly.
fleet_cost_per_hourisreplicas × gpus_per_replica × $/GPU-hour: what you pay whether or not the GPUs are busy.tokens_per_houris the fleet's aggregate decode rate × 3600. Dollars-per-hour ÷ tokens-per-hour × 1,000,000 gives the blended $/1M tokens.- It compares that against a fixed
api_baselineand prints which is cheaper at this load, plus the percentage delta.
What the output means: At this throughput self-hosting lands at ~$1.56/1M tokens against a $3.00 API baseline — self-host wins by ~48%, and the winner plus gap are printed so the call is explicit.
Try this: Halve tok_s (simulate idle GPUs) and watch $/1M double. Self-hosting only wins when the fleet stays busy — idle GPUs are the silent cost.
Step 6 · Put it together — plan_deployment() tech-lead
Now compose all five steps into one function. This lab imports nothing external — it inlines the pieces from Steps 1–5 and wires them into plan_deployment(): size the VRAM, decide sharding, derive the concurrency ceiling, size the fleet for the target QPS, and price it against an API baseline — then print a full deployment plan. This is the whole MS track running as one decision, offline.
step6_plan.pyfrom math import ceil
BPP = {"fp32": 4, "fp16": 2, "bf16": 2, "int8": 1, "int4": 0.5}
def weight_vram_gb(params_b, prec, overhead=1.2): # Step 1
return round(params_b * 1e9 * BPP[prec] / (1024 ** 3) * overhead, 1)
def shard_plan(params_b, prec, gpu_vram=80, kv_reserve=0.30): # Step 2
w = weight_vram_gb(params_b, prec); usable = gpu_vram * (1 - kv_reserve); g = 1
while w / g > usable:
g *= 2
return g, round(w / g, 1), round(gpu_vram - w / g, 1)
def kv_per_token(layers, hidden, prec): # Step 3
return 2 * layers * hidden * BPP[prec]
def max_concurrency(kv_headroom_gb, layers, hidden, seq_len, prec): # Step 3
return int(kv_headroom_gb * (1024 ** 3) // (kv_per_token(layers, hidden, prec) * seq_len))
def replica_qps(conc, tokens_per_req, decode_tok_s, safe_util=0.7): # Step 4
return (conc * safe_util) / (tokens_per_req / decode_tok_s)
def cost_per_1m(replicas, gpus_each, gpu_hourly, tok_s): # Step 5
return replicas * gpus_each * gpu_hourly / (tok_s * 3600) * 1_000_000
def plan_deployment(name, params_b, layers, hidden, prec,
target_qps, seq_len, tokens_per_req,
decode_tok_s, gpu_hourly, api_baseline, gpu_vram=80):
"""Full end-to-end plan: VRAM -> shard -> concurrency -> fleet -> cost."""
g, w_per_gpu, kv_headroom = shard_plan(params_b, prec, gpu_vram) # 1 + 2
conc = max_concurrency(kv_headroom, layers, hidden, seq_len, prec) # 3
r_qps = replica_qps(conc, tokens_per_req, decode_tok_s) # 4
replicas = max(1, ceil(target_qps / r_qps)) # 4
fleet_tok_s = replicas * decode_tok_s # 5
selfhost = cost_per_1m(replicas, g, gpu_hourly, fleet_tok_s) # 5
winner = "self-host" if selfhost < api_baseline else "API"
print(f"=== deployment plan: {name} @ {prec} ===")
print(f" weights VRAM : {weight_vram_gb(params_b, prec)} GiB")
print(f" sharding : TP x{g} ({w_per_gpu} GiB/GPU, {kv_headroom} GiB KV headroom)")
print(f" max concurrency : {conc} req/replica @ {seq_len} ctx")
print(f" per-replica QPS : {r_qps:.2f}")
print(f" fleet : {replicas} replicas x {g} GPU = {replicas * g} GPUs")
print(f" fleet throughput : {fleet_tok_s} tok/s (target {target_qps} QPS)")
print(f" self-host $/1M : ${selfhost:.2f} API baseline ${api_baseline:.2f}")
print(f" recommendation : {winner} at this load")
return {"tp": g, "replicas": replicas, "gpus": replicas * g, "usd_per_1m": round(selfhost, 2)}
plan_deployment(
name="Llama-3-70B", params_b=70, layers=80, hidden=8192, prec="fp16",
target_qps=50, seq_len=4096, tokens_per_req=400,
decode_tok_s=2500, gpu_hourly=3.50, api_baseline=3.00)
=== deployment plan: Llama-3-70B @ fp16 ===
weights VRAM : 156.5 GiB
sharding : TP x4 (39.1 GiB/GPU, 40.9 GiB KV headroom)
max concurrency : 4 req/replica @ 4096 ctx
per-replica QPS : 17.50
fleet : 3 replicas x 4 GPU = 12 GPUs
fleet throughput : 7500 tok/s (target 50 QPS)
self-host $/1M : $1.56 API baseline $3.00
recommendation : self-host at this load
This is the capstone: Steps 1–5 inlined and wired into one plan_deployment() that takes a model + SLO + QPS and prints a full, priced deployment plan — offline, in one call.
- The top of the file is the five steps in compact form:
weight_vram_gb,shard_plan,max_concurrency,replica_qps, andcost_per_1m. Nothing is imported — the composition is self-contained. plan_deploymentruns them in order: size VRAM → decide sharding → derive the concurrency ceiling → size the fleet for the target QPS → price it against the API.- It prints one block — VRAM, TP degree, concurrency, per-replica QPS, fleet GPU count, throughput, $/1M, and a recommendation — and returns the key numbers as a dict for a caller to act on.
What the output means: A full plan for Llama-3-70B at 50 QPS: TP x4, 3 replicas (12 GPUs), 7500 tok/s, and a $1.56 vs $3.00 self-host-vs-API price with a recommendation — the whole MS track in one printout.
Try this: Change prec to "int4" or push target_qps to 200 and re-run. Every downstream number moves the right way — that coherence is the proof the pieces compose.
int4, raise target_qps, lengthen seq_len — and every downstream number moves the right way: int4 collapses the TP degree, more QPS adds replicas, longer context shrinks concurrency and grows the fleet. The whole MS track, running as one function on your laptop.Going real — serve the sharded model on vLLM tech-lead
The simulation and a production deploy share one plan; only the launch changes. Once plan_deployment() tells you the tensor-parallel degree and the batch width, you hand those numbers to a real serving engine. Here is the TP x4 70B replica the plan above describes — ▶ needs GPUs, shown for orientation, not to run:
serve_tp2.sh# ▶ needs GPUs: 2x H100 (80 GiB), pip install vllm
# Serve Llama-3-70B tensor-parallel across 2 GPUs, matching the plan above.
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-70B-Instruct \
--tensor-parallel-size 4 \ # TP degree from Step 2
--max-model-len 4096 \ # context length from Step 3
--max-num-seqs 4 \ # concurrency ceiling from Step 3
--gpu-memory-utilization 0.90 \ # leave headroom for the KV-cache
--enable-prefix-caching # reuse shared prefixes' KV (MS3/MS4)
# Then run a load test at rising QPS and confirm p99 holds under the SLO
# you sized the fleet for in Step 4 (this is one replica; run N of them).
--tensor-parallel-size is Step 2's G; --max-num-seqs is Step 3's concurrency ceiling; --max-model-len is the context you sized the KV-cache for. The fleet (Step 4) is N of these replicas behind a load balancer. Verify real model geometry and current GPU prices before you commit — the simulation is a planning tool, not a substitute for a load test.Grade your plan — the staff rubric tech-lead
A capstone isn't done because it runs — it's done because it survives review. Score your plan against every dimension below before you call it shippable.
| Dimension | Meets bar | Above bar |
|---|---|---|
| Correctness of sizing | VRAM, TP degree, and concurrency are computed from real model geometry, not guessed. | Sizing is validated against a real load test; assumptions (overhead, decode rate) are measured, not assumed. |
| SLO satisfied | Fleet is sized at a safe utilization so p99 holds at the target QPS. | SLO is defended with a load-test tail curve; you can state the QPS at which p99 breaks and the headroom margin. |
| Cost optimized | $/1M tokens is computed and compared to an API baseline; the cheaper option is chosen. | Precision, batch width, and instance type were swept; break-even utilization vs the API is quantified. |
| Failure modes considered | You name what happens on a GPU/replica loss and on a traffic spike beyond the SLO. | Autoscaling, graceful degradation, and a spillover-to-API fallback are designed and priced. |
| Observability planned | You list the signals to watch: TTFT, TPOT, queue depth, KV-cache utilization, $/1M. | Dashboards + alerts are wired to those signals and tied to the SLO and cost budget. |
Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–4: a sizing sketch — keep going. 5–7: a solid plan you could take to review. 8–10: staff-level — you have sized, served, and priced it and planned for the day it breaks. Any dimension at 0 blocks shipping regardless of the total.
Capstone exercise — extend the plan
Context: A real sizing model is never finished — leadership always asks the follow-up: what if we quantize, what happens above capacity, and how does a daily traffic curve change the fleet hour to hour.
Your task: Extend plan_deployment() with one capability end-to-end and re-price the plan: a quantization sweep, an API spillover fallback, or an hourly autoscaler.
Requirements:
- Quantization sweep reports TP, concurrency, and $/1M for fp16/int8/int4 and picks the cheapest that meets the SLO
- Spillover routes QPS above safe fleet capacity to the API and blends the cost
- Autoscaler sizes the fleet per hour from a daily traffic curve
- Whichever you pick, the plan is re-priced with the new capability
- Still runs offline as pure Python
💡 Hint: Reuse the existing five functions unchanged; the extension is a loop or a comparison wrapped around plan_deployment, not a rewrite of it.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every self-hosting decision starts from one number: how much VRAM the raw weights occupy. That figure drives sharding, concurrency, fleet size, and cost downstream.
Your task: Write weight_vram_gb(params_billion, precision) from params × bytes/param × overhead, and print the VRAM for a 70B model at fp16 and int8.
Requirements:
- Bytes-per-param constants for fp16/int8/int4 (2 / 1 / 0.5)
- Include a modest overhead factor for activations and fragmentation
- Report the same model at more than one precision
- Show a 70B fp16 model exceeds a single 80 GiB GPU (motivating sharding)
💡 Hint: Everything else sizes off this; the notable observation is that a 70B fp16 model (~156 GiB) simply cannot fit one card, which is why the next rung shards.
Show solution
Everything downstream (sharding, concurrency, fleet, cost) starts from this one number:
BYTES = {"fp16": 2, "int8": 1, "int4": 0.5}
def weight_vram_gb(params_billion, precision="fp16", overhead=1.2):
return params_billion * 1e9 * BYTES[precision] / 1024**3 * overhead
print(f"{weight_vram_gb(70,'fp16'):.0f} GiB") # ~156 GiB fp16
print(f"{weight_vram_gb(70,'int8'):.0f} GiB") # ~78 GiB int8
The 1.2x overhead covers activations and fragmentation. A 70B fp16 model needs ~156 GiB — more than any single 80 GiB GPU, which is exactly why the next step is sharding.
Context: Tensor parallelism splits each layer across GPUs. You want the smallest shard count that fits, because extra GPUs just add communication overhead and cost.
Your task: Write shard_plan(params_b, precision, gpu_vram=80) that returns the smallest power-of-two tensor-parallel degree whose per-GPU weight share fits, plus the leftover KV headroom.
Requirements:
- Search powers of two only
- Leave a runtime margin (don't fill the card to 100%)
- Return the TP degree, per-GPU weight GiB, and KV headroom GiB
- A model that fits on one GPU returns TP=1
- The leftover-per-GPU headroom is what feeds the concurrency step
💡 Hint: Double the shard count until the per-GPU weight share fits under ~90% of the card; the headroom you report is gpu_vram − per_gpu_weights.
Show solution
Pick the smallest shard count that fits — more GPUs than needed just wastes money and adds comms overhead:
def shard_plan(params_b, precision="fp16", gpu_vram=80):
total = weight_vram_gb(params_b, precision)
tp = 1
while total / tp > gpu_vram * 0.9 and tp < 64: # leave 10% for runtime
tp *= 2 # powers of 2 only
per_gpu = total / tp
return {"tp": tp, "weights_per_gpu_gb": round(per_gpu,1),
"kv_headroom_gb": round(gpu_vram - per_gpu, 1)}
print(shard_plan(70, "fp16")) # tp=4, ~39 GiB/GPU, ~41 GiB KV headroom
print(shard_plan(8, "fp16")) # tp=1, fits on one GPU
Tensor parallelism splits each layer across GPUs; the leftover VRAM per GPU (KV headroom) is what caps how many requests you can serve at once — the input to the next step.
Context: It is the KV cache, not the weights, that usually caps how many requests you can serve at once. It grows with layers, hidden size, sequence length, and the factor of two for keys and values.
Your task: Write kv_bytes_per_token(...) and max_concurrency(kv_headroom_gb, layers, hidden, seq_len) to turn leftover VRAM into a simultaneous-request ceiling.
Requirements:
- Per-token KV bytes = 2 · layers · hidden · bytes
- Per-request bytes scale with sequence length
- Concurrency = headroom bytes / per-request bytes, floored to an int
- Show that doubling sequence length roughly halves concurrency
- Use the headroom from the sharding step as the input
💡 Hint: The factor of 2 (K and V) and the linear growth in both layers and sequence length are the details people forget; concurrency is headroom divided by per-request cost.
Show solution
KV cache grows with layers x hidden x sequence length x 2 (keys+values) — it, not weights, usually caps concurrency:
def kv_bytes_per_token(layers, hidden, precision="fp16"):
return 2 * layers * hidden * BYTES[precision] # keys + values
def max_concurrency(kv_headroom_gb, layers, hidden, seq_len, precision="fp16"):
per_tok = kv_bytes_per_token(layers, hidden, precision)
per_req = per_tok * seq_len
return int(kv_headroom_gb * 1024**3 / per_req)
# 70B-ish: 80 layers, hidden 8192, 41 GiB headroom, 4k context
c = max_concurrency(41, layers=80, hidden=8192, seq_len=4096)
print("max concurrent requests:", c)
Longer context or more layers eats headroom fast: doubling seq_len halves concurrency. This ceiling — not GPU FLOPs — is what your batch width and fleet size are built on.
Context: A replica's throughput sets how big a fleet you need — but sizing at 100% utilization guarantees SLO misses under normal variance. A safe-utilization factor buys slack for the p99 tail.
Your task: Write replica_qps(...) from concurrency, tokens/request, and decode speed at a safe utilization, then fleet_size(target_qps, per_replica_qps) to hit a target QPS.
Requirements:
- Seconds-per-request = tokens/request ÷ decode tokens-per-second
- Raw QPS = concurrency ÷ seconds-per-request
- Apply a safe-utilization factor (e.g. 0.7) below raw QPS
- Fleet size is a ceiling division, never fewer than one replica
- Show how many replicas a target QPS needs
💡 Hint: Run replicas below full tilt so a burst doesn't blow p99; the classic mistake is sizing at 100% because it looks fine in the spreadsheet and pages you in prod.
Show solution
Sizing at 100% utilization guarantees SLO violations under normal variance — the safe-util factor is the point:
def replica_qps(max_concurrency, tokens_per_req, decode_tok_per_s, safe_util=0.7):
secs_per_req = tokens_per_req / decode_tok_per_s
raw = max_concurrency / secs_per_req # reqs/s at full tilt
return raw * safe_util # back off for the p99 tail
def fleet_size(target_qps, per_replica_qps):
return max(1, -(-int(target_qps) // max(int(per_replica_qps), 1)))
pr = replica_qps(max_concurrency=48, tokens_per_req=300, decode_tok_per_s=2400)
print(f"per-replica QPS: {pr:.1f}")
print("replicas for 100 QPS:", fleet_size(100, pr))
Running at 70% leaves slack so a burst doesn't blow p99. Skipping that factor is the classic sizing mistake — the fleet looks fine in the spreadsheet and pages you in production.
Context: The number that justifies the whole deployment is $/1M tokens: total GPU spend divided by tokens served, compared against the API price you'd otherwise pay.
Your task: Write cost_per_1m_tokens(replicas, gpus_per_replica, gpu_hourly, tokens_per_sec) and compare it to an API baseline.
Requirements:
- Total GPUs = replicas × GPUs per replica
- Cost/hour = total GPUs × hourly rate
- Tokens/hour = tokens/sec × 3600
- $/1M = cost-per-hour ÷ tokens-per-hour × 1,000,000
- Print the self-host figure, the API baseline, and which is cheaper
💡 Hint: At high steady throughput self-hosting undercuts the API; at low or bursty load the API wins because idle GPUs still bill — use your real utilization, not peak.
Show solution
Fleet cost per token is total GPU spend divided by tokens served — the metric a serving decision lives or dies on:
def cost_per_1m_tokens(replicas, gpus_per_replica, gpu_hourly, tokens_per_sec):
gpus = replicas * gpus_per_replica
cost_per_hour = gpus * gpu_hourly
tokens_per_hour = tokens_per_sec * 3600
return cost_per_hour / tokens_per_hour * 1_000_000
self = cost_per_1m_tokens(replicas=3, gpus_per_replica=4,
gpu_hourly=2.0, tokens_per_sec=7200)
print(f"self-host: ${self:.2f} / 1M tokens")
print(f"API baseline: $0.60 / 1M tokens")
print("cheaper:", "self-host" if self < 0.60 else "API")
At high steady throughput self-hosting undercuts the API; at low or bursty load the API wins because you don't pay for idle GPUs. The comparison must use your real utilization, not peak.
Context: The capstone turns a stakeholder's spec — model, SLO, traffic — into one defensible plan by composing every prior step: VRAM → shard → concurrency → fleet → cost.
Your task: Write plan_deployment(...) that composes all five steps and prints the sizing plus a build-vs-buy verdict.
Requirements:
- Chain shard_plan → max_concurrency → replica_qps → fleet_size → cost
- Return TP degree, max concurrency, replica count, and $/1M
- Emit a self-host-vs-API verdict from the cost comparison
- Run end-to-end for a 70B-class config with no external calls
- Stay pure Python so inputs can be swept
💡 Hint: Each function's output is the next one's input; keep it a single pure function so a stakeholder can re-run it with different traffic assumptions.
Show solution
The capstone: one function turns a spec into a defensible deployment plan:
def plan_deployment(name, params_b, layers, hidden, prec, target_qps,
seq_len, tokens_per_req, decode_tok_s, gpu_hourly,
api_baseline, gpu_vram=80):
sp = shard_plan(params_b, prec, gpu_vram)
conc = max_concurrency(sp["kv_headroom_gb"], layers, hidden, seq_len, prec)
pr = replica_qps(conc, tokens_per_req, decode_tok_s)
replicas = fleet_size(target_qps, pr)
cpm = cost_per_1m_tokens(replicas, sp["tp"], gpu_hourly,
decode_tok_s * conc)
return {"model": name, "tp": sp["tp"], "max_concurrency": conc,
"replicas": replicas, "cost_per_1m": round(cpm, 2),
"verdict": "self-host" if cpm < api_baseline else "use API"}
print(plan_deployment("llama-70b", 70, 80, 8192, "fp16",
target_qps=100, seq_len=4096, tokens_per_req=300,
decode_tok_s=2400, gpu_hourly=2.0, api_baseline=0.60))
Because it's pure Python, you can sweep SLOs, precisions, and GPU types in seconds to find the cheapest plan that meets the SLO — the whole point of doing the sizing before you provision anything.
✓ Checkpoint — you can move on when you can…
- Compute the VRAM a model's weights need at a given precision (MS1).
- Decide whether a model fits on one GPU or must go tensor-parallel, and across how many (MS4).
- Turn KV-cache headroom into a concurrency ceiling, and explain why long context shrinks it (MS4).
- Size a replica fleet to hit a target QPS under a p99 SLO, and name the utilization tax (MS4).
- Build a $/1M-token cost model and compare self-hosting to an API baseline (MS5).
- Run
plan_deployment()end to end and score the result against the staff rubric.
You quantize the 70B model from fp16 to int4. Name two numbers in the plan that move, and which direction — and one that does not change.
Show answer
Your p99 SLO tightens. With everything else fixed, what happens to the fleet size and cost, and why?