AI EngineeringZero to ProductionHome·About·Contact
ML Systems Internals · Capstone

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.

⏱️ ~3 hours🧪 6 steps🎯 Capstone

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.

⚙️ Everything here runs offlineEvery lab is stdlib-only Python — no GPU, no CUDA, no SDK. We model the hardware with small deterministic functions so the full sizing pipeline runs with plain 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.

Model + SLO + QPS requirements Size VRAM MS1 math Shard decision 1 GPU or TP Fleet sizing replicas for QPS Batching concurrency Cost model $/1M tokens
🗺️ How to read this diagram

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.

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 · VRAM sizing (runs)
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
▶ How this works

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?

  1. BYTES_PER_PARAM maps a precision to bytes: fp16 is 2 bytes, int8 is 1, int4 is half a byte. Lower precision = fewer bytes per weight.
  2. weight_vram_gb multiplies params × 1e9 × bytes_per, divides by 1024**3 to get GiB, then multiplies by overhead (~1.2) for the activation/framework tax at serving time.
  3. 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.

This is the MS1 identityWeights in bytes = 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.

Python · shard decision (runs)
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
▶ How this works

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.

  1. usable_per_gpu is 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.
  2. The while weights / g > usable_per_gpu loop doubles g (1, 2, 4, 8…) until each GPU's slice of the weights fits. That's the tensor-parallel degree.
  3. 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.

Sharding is forced by memory, not chosen for speedYou go tensor-parallel because the model doesn't fit, and every extra GPU adds cross-GPU communication on every token (MS3). So the cheapest correct answer is the smallest G that fits — which is exactly why quantizing (Step 1) can collapse a 70B model from TP x4 down to a single GPU.

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.

Python · KV-cache → concurrency (runs)
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
▶ How this works

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.

  1. kv_bytes_per_token is 2 × layers × hidden × bytes: the 2 is for the Key and the Value, stored for every layer, for every token in flight.
  2. max_concurrency multiplies that by seq_len to get the per-request footprint, then divides the free VRAM by it — how many full-context requests fit at once.
  3. 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.

KV-cache — not FLOPs — is why serving is memory-boundLonger context means fewer concurrent requests: doubling 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.

Python · fleet sizing (runs)
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)
▶ How this works

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.

  1. replica_qps runs each replica at safe_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.
  2. fleet_size is just ceil(target_qps / per_replica_qps) — round up, because you can't run a fraction of a replica.
  3. 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.

The SLO is a utilization taxIf you ran every replica at 100% of its concurrency ceiling you'd need fewer boxes — but the queue would explode and p99 latency with it. Running at 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.

Python · cost model (runs)
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)
▶ How this works

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.

  1. fleet_cost_per_hour is replicas × gpus_per_replica × $/GPU-hour: what you pay whether or not the GPUs are busy.
  2. tokens_per_hour is the fleet's aggregate decode rate × 3600. Dollars-per-hour ÷ tokens-per-hour × 1,000,000 gives the blended $/1M tokens.
  3. It compares that against a fixed api_baseline and 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.

$/1M tokens is the great equalizerSelf-hosting is a fixed cost (you pay for GPUs whether they're busy or idle) while an API is usage-priced. That means self-hosting only wins above a break-even utilization — which is exactly why the cost number depends on the fleet throughput, not just the hourly rate. Idle GPUs are the silent killer of a self-host business case.

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.

Python · plan_deployment (runs end-to-end)
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
▶ How this works

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.

  1. The top of the file is the five steps in compact form: weight_vram_gb, shard_plan, max_concurrency, replica_qps, and cost_per_1m. Nothing is imported — the composition is self-contained.
  2. plan_deployment runs them in order: size VRAM → decide sharding → derive the concurrency ceiling → size the fleet for the target QPS → price it against the API.
  3. 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.

This is the capstone workingOne call turns a model + SLO + QPS into a GPU count and a price. Change any input — quantize to 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:

bash · vLLM multi-GPU serve (needs GPUs)
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).
Same plan, real parts--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.

📋 Staff-level self-scoring — is this deployment plan defensible?
DimensionMeets barAbove bar
Correctness of sizingVRAM, 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 satisfiedFleet 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 consideredYou 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 plannedYou 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.

Exercise 1 · Size the weight VRAMBeginner

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.

Exercise 2 · Decide tensor-parallel shardingIntermediate

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.

Exercise 3 · Turn KV headroom into a concurrency ceilingAdvanced

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.

Exercise 4 · Per-replica QPS and fleet size under a p99 SLOExpert

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.

Exercise 5 · $/1M-token cost model: self-host vs APIProfessional

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.

Exercise 6 · plan_deployment(): wire all five steps end-to-endIndustry scenario

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.
✓ Knowledge check

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
Weights VRAM drops ~4x, so the TP degree can fall (e.g. TP x4 → single GPU) and the KV headroom per GPU grows, which raises max concurrency and can shrink the fleet / $/1M. What does not change from precision alone is the target QPS — that's set by traffic, not by the model. (Quality may change; that's a separate eval, per MS5.)
✓ Knowledge check

Your p99 SLO tightens. With everything else fixed, what happens to the fleet size and cost, and why?

Show answer
A tighter p99 forces a lower safe utilization (you run each replica further from its concurrency ceiling to keep the queue short), so per-replica safe QPS falls, the fleet needs more replicas, and $/1M tokens rises. The SLO is a utilization tax (Step 4): a tighter tail costs more GPUs.
© 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