Serve a model, then make it cheaper
A model behind an HTTP API that you have measured honestly, then made faster with batching, caching and quantization — with the numbers for what each one bought.
Inference economics is the part of this field that is asked about at every level, because it is where the money is. Prefill versus decode, what a KV cache costs per token, why batching is nearly free on a memory-bound workload, what a percentile is hiding — these are questions with arithmetic answers, and the arithmetic is much easier to hold onto once you have watched your own p99 move. Most candidates can name the optimisations. Far fewer can say what any of them did on a machine they ran.
Have these behind you first
You can start without them and look things up as you go. It will take longer and you will understand less of what you typed.
A model behind an endpoint
You build: An HTTP service that loads a model once at startup and generates on request.
If a line here loses you, this is where it is taught
- Deployment & serving: Running an AI service
What a serving process has to do besides call the model: startup, health, backpressure.
- HTTP & APIs: Requests, status codes and headers
Status codes and timeouts, and what the endpoint should say when it is already busy.
- Deployment & serving: Serving open models yourself
What a real serving stack does for you, so you know exactly what you are hand-rolling and why.
Use a small model. A half-billion-parameter instruct model fits on a laptop, runs in seconds, and exhibits every effect in this project except absolute speed. Swapping in something larger at the end changes the numbers, not the code.
# server.py — uvicorn server:app --port 8000
from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
state = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load once at startup. Loading is seconds; generating is milliseconds.
state["tok"] = AutoTokenizer.from_pretrained(MODEL)
state["model"] = AutoModelForCausalLM.from_pretrained(
MODEL, torch_dtype=torch.float16, device_map="auto"
).eval()
yield
state.clear()
app = FastAPI(lifespan=lifespan)
class Req(BaseModel):
prompt: str
max_new_tokens: int = 128
# A plain def, not async def: FastAPI runs it in a threadpool, so the blocking
# forward pass does not stall the event loop and every other request with it.
@app.post("/generate")
def generate(req: Req):
tok, model = state["tok"], state["model"]
enc = tok(req.prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(**enc, max_new_tokens=req.max_new_tokens, do_sample=False)
new = out[0][enc["input_ids"].shape[1]:]
return {
"text": tok.decode(new, skip_special_tokens=True),
"prompt_tokens": int(enc["input_ids"].shape[1]),
"output_tokens": int(new.shape[0]),
}An async def here serialises your whole server
model.generate blocks. Put it in an async def and it holds the event loop for the entire generation, so concurrency collapses to one and every latency number you collect afterwards is a measurement of that bug rather than of the model.
Add a streaming endpoint too, using TextIteratorStreamer. You need it in the next stage: without streaming there is no time to first token to measure, and time to first token is the number users feel.
Done when
curl against the endpoint returns generated text, the model loads exactly once per process, and a second request does not re-read the weights from disk.
Measure it honestly
You build: A load generator that drives the service at a chosen concurrency and reports latency percentiles, time to first token and time per output token.
If a line here loses you, this is where it is taught
- Cost & latency engineering: Find out where the money goes
Finding where the time and the money actually go, before touching anything.
- Deployment & serving: Capacity planning and load testing for an LLM service
Load testing an LLM service: arrival rate, concurrency, and what saturation looks like.
- Probability & statistics: Probability, and reading an experiment honestly
Reading a measurement honestly, including how many samples a p99 needs before it means anything.
Two numbers describe a generation request, and they behave differently. Time to first token is prefill: it scales with prompt length and it is compute-bound. Time per output token is decode: it barely depends on prompt length and it is memory-bound. Report them separately or you have averaged two unrelated things.
# load.py
import asyncio, math, time
import httpx
URL = "http://localhost:8000/stream"
PROMPT = "Summarise the following in two sentences: " + "the quick brown fox. " * 40
async def one(client):
t0 = time.perf_counter()
ttft, n = None, 0
async with client.stream("POST", URL, json={"prompt": PROMPT,
"max_new_tokens": 128}) as r:
r.raise_for_status()
async for _ in r.aiter_text():
if ttft is None:
ttft = time.perf_counter() - t0
n += 1
total = time.perf_counter() - t0
# Per-token decode cost excludes the first token, which was prefill.
tpot = (total - ttft) / max(1, n - 1)
return {"e2e": total, "ttft": ttft, "tpot": tpot, "tokens": n}
async def run(concurrency: int, requests: int):
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=120) as client:
async def guarded():
async with sem:
return await one(client)
t0 = time.perf_counter()
rows = await asyncio.gather(*(guarded() for _ in range(requests)))
wall = time.perf_counter() - t0
return rows, wall
def pct(values, p):
"""Nearest-rank percentile: the number you print is a request that
actually happened, rather than an interpolation between two."""
s = sorted(values)
return s[max(1, math.ceil(p / 100 * len(s))) - 1]
if __name__ == "__main__":
for c in (1, 4, 16):
rows, wall = asyncio.run(run(c, 64))
tokens = sum(r["tokens"] for r in rows)
print(f"concurrency={c:>2} "
f"e2e p50={pct([r['e2e'] for r in rows], 50):.2f}s "
f"p95={pct([r['e2e'] for r in rows], 95):.2f}s "
f"p99={pct([r['e2e'] for r in rows], 99):.2f}s "
f"ttft p95={pct([r['ttft'] for r in rows], 95)*1000:.0f}ms "
f"tput={tokens / wall:.1f} tok/s")Reporting a mean latency hides the problem
A service with a 400 ms mean and a 9 s p99 is a service where one request in a hundred times out, and the mean will never show you that. Worse, the mean improves when you make the fast requests faster, which is the one change that does not help anybody. Report p50, p95 and p99, and say how many samples the p99 came from — under a few hundred it is one unlucky request.
Sweep concurrency until throughput stops rising. Below that point you are wasting hardware; above it, queueing turns into latency and nothing else improves. That knee is the capacity number you would put in a design document, and finding it takes about ten minutes once the generator exists.
Discard the first few requests. The first generation pays for CUDA kernel autotuning and memory allocation, and folding that into your p50 makes every later comparison noisier than it needs to be.
Done when
A table of p50, p95 and p99 end-to-end latency, plus p50 and p95 TTFT and TPOT, at three concurrency levels — and you can point at the level where throughput stopped rising and latency started.
Compute-bound or memory-bound
You build: An arithmetic argument, checked against measurement, for which resource your decode phase is actually limited by.
If a line here loses you, this is where it is taught
- Quantization & inference optimisation: What makes inference expensive
What makes inference expensive: prefill versus decode, and which one your bill is made of.
- GPUs & accelerators: What the hardware is doing
Arithmetic intensity, and why reading the weights — not the multiplying — is the decode bottleneck.
- GPUs & accelerators: Measuring a GPU workload
Measuring utilisation and bandwidth, so "memory-bound" is something you checked rather than repeated.
Decode generates one token per step per sequence. To do that it reads every weight in the model. At batch size one that is an enormous amount of memory traffic for a trivial amount of arithmetic, which is why a single-stream decode leaves a modern GPU almost idle by its own compute metrics while being genuinely at its limit.
# Decode is a bandwidth problem. The floor on one step is: read the weights.
params = 8e9 # an 8B model
bytes_each = 2 # fp16
bandwidth = 2.0e12 # A100 80GB: about 2 TB/s of HBM bandwidth
step_seconds = params * bytes_each / bandwidth
print(f"{step_seconds*1000:.1f} ms per decode step") # 8.0 ms
print(f"{1/step_seconds:.0f} tokens/s ceiling, batch 1") # 125
# The same read serves every sequence in the batch, so batching multiplies
# throughput almost for free until something else binds.
for batch in (1, 8, 32):
print(batch, f"{batch/step_seconds:.0f} tok/s aggregate")The something else is usually the KV cache. Every token of every live sequence keeps a key and a value vector in memory, per layer, for the whole request.
def kv_bytes_per_token(n_layers, n_kv_heads, head_dim, dtype_bytes=2):
return 2 * n_layers * n_kv_heads * head_dim * dtype_bytes # K and V
# Llama-3.1-8B: 32 layers, 8 KV heads (grouped-query attention), head_dim 128
per_token = kv_bytes_per_token(32, 8, 128, 2)
print(per_token / 1024, "KiB per token") # 128.0
print(per_token * 4096 / 2**30, "GiB per 4096-token seq") # 0.5
# On an 80 GB card: 16 GB of fp16 weights, leave ~4 GB for activations.
free = (80 - 16 - 4) * 2**30
print(int(free / (per_token * 4096)), "concurrent 4k sequences") # 120Prefill and decode are different workloads on the same hardware
Prefill processes the whole prompt at once: thousands of tokens per weight read, compute-bound, and it is what your TTFT is made of. Decode does one token per weight read, memory-bound, and it is what your TPOT is made of. An optimisation that helps one often does nothing for the other, so a benchmark that reports a single latency number cannot tell you which you bought.
Now finish the arithmetic. Take your measured tokens per second at your best concurrency, divide the hourly price of the hardware by it, and you have cost per million output tokens. A GPU at two dollars an hour producing 4,000 tokens per second is 14.4 million tokens an hour, or about fourteen cents per million. That single number is what every optimisation from here on is trying to move.
Done when
You have a predicted milliseconds-per-decode-step from weight bytes divided by memory bandwidth, a measured one from the last stage, and a written sentence explaining the gap between them.
Batching
You build: Server-side batching that collects concurrent requests into one forward pass, with a bounded wait.
If a line here loses you, this is where it is taught
- GPUs & accelerators: Getting the most from the hardware
Why batching is nearly free on a memory-bound workload, and where that stops being true.
- Async & concurrency: Concurrency patterns for model calls
Collecting requests into a batch without blocking the ones already waiting.
- Distributed systems: Back-of-envelope numbers
The queueing arithmetic: batch window, arrival rate and the latency you are trading away.
The previous stage's arithmetic says a batch of 32 costs roughly what a batch of 1 costs, because the expensive part is reading the weights and that happens once either way. Batching converts that into throughput. The price is a wait: someone has to sit in a queue while the batch fills.
import asyncio
from dataclasses import dataclass, field
BATCH_WINDOW = 0.01 # seconds of latency you agree to pay to fill a batch
MAX_BATCH = 16
@dataclass
class Job:
prompt: str
max_new_tokens: int
future: asyncio.Future = field(default_factory=asyncio.get_event_loop().create_future)
queue: asyncio.Queue[Job] = asyncio.Queue()
async def batcher():
loop = asyncio.get_running_loop()
while True:
batch = [await queue.get()] # block until there is work
deadline = loop.time() + BATCH_WINDOW
while len(batch) < MAX_BATCH:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
batch.append(await asyncio.wait_for(queue.get(), remaining))
except asyncio.TimeoutError:
break
# to_thread keeps the blocking forward pass off the event loop, so the
# next batch is already filling while this one runs.
try:
texts = await asyncio.to_thread(generate_batch, batch)
for job, text in zip(batch, texts):
job.future.set_result(text)
except Exception as exc: # one bad batch, not a dead server
for job in batch:
if not job.future.done():
job.future.set_exception(exc)
def generate_batch(batch):
tok, model = state["tok"], state["model"]
enc = tok([j.prompt for j in batch], return_tensors="pt",
padding=True, padding_side="left").to(model.device)
with torch.inference_mode():
out = model.generate(**enc, max_new_tokens=max(j.max_new_tokens for j in batch),
do_sample=False)
return tok.batch_decode(out[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)Left padding, or your batch generates nonsense
Decoder-only models continue from the last position. Pad on the right and the last position is a pad token, so generation continues from padding and the output is garbage for every sequence in the batch except the longest. It fails silently — the request succeeds, the text is just wrong — which is why it survives a smoke test.
This is static batching and it has an obvious flaw: the batch runs until its longest member finishes, so a request asking for 20 tokens waits behind one asking for 500. Real servers do continuous batching, admitting and retiring sequences at every decode step. Implementing that is a project of its own; measuring how much it would win you is one line of arithmetic over your request length distribution, and worth doing before you decide.
Re-run the load generator and put the numbers next to the old ones. If throughput went up and p95 did not move, your batch window is smaller than your arrival gap and you have not tested it at real load yet.
Done when
Throughput at concurrency 16 is measurably higher than before, and you can state the p95 latency you paid for it — both numbers from the same load generator as stage two.
Caching
You build: A response cache for repeated requests and a measurement of what prefix reuse saves on prefill.
If a line here loses you, this is where it is taught
- Prompt caching: Prompt caching
What reusing a KV prefix actually skips, and the prefix-match rule that decides whether it happens.
- Prompt caching: The cost maths, and caching whole responses
The cost arithmetic, and when caching the whole response is the right cache instead.
- Cost & latency engineering: The levers, in order
Where caching sits in the order of levers, and what it is worth relative to the others.
There are two different caches here and they save different things. A prefix cache skips prefill for a shared prompt prefix, which cuts TTFT and nothing else. A response cache skips the whole generation for an identical request, which cuts everything — when it hits.
from collections import OrderedDict
from hashlib import blake2b
class LRU:
def __init__(self, capacity=1024):
self.capacity, self.store = capacity, OrderedDict()
self.hits = self.misses = 0
def get(self, key):
if key in self.store:
self.store.move_to_end(key)
self.hits += 1
return self.store[key]
self.misses += 1
return None
def put(self, key, value):
self.store[key] = value
self.store.move_to_end(key)
if len(self.store) > self.capacity:
self.store.popitem(last=False)
@property
def hit_rate(self):
total = self.hits + self.misses
return self.hits / total if total else 0.0
cache = LRU()
def cache_key(req) -> str:
# Every parameter that changes the output belongs in the key. Miss one
# and you will serve a 128-token answer to a request that asked for 512.
return blake2b(
f"{MODEL}|{req.prompt}|{req.max_new_tokens}|greedy".encode(), digest_size=16
).hexdigest()A response cache is only correct for deterministic decoding
With sampling on, two identical requests are supposed to give different answers, and a cache quietly removes that. If you cache sampled output, say so in the API — or key the cache on the seed. A cache that changes behaviour without appearing in the response is the kind of thing that gets found months later by someone debugging something else.
Prefix caching is the one that matters for real workloads, because production prompts are mostly a long fixed system prompt followed by a short variable question. Prefill cost is proportional to the tokens you have to process; if 2,000 tokens of a 2,100-token prompt are shared and cached, you have removed about 95 per cent of prefill and your TTFT should fall accordingly. Measure it: put a 2,000-token preamble in front of your test prompts and compare TTFT with the preamble constant versus varying.
Then be honest about applicability. Measure the hit rate on traffic that looks like your real traffic, not on a loop sending the same prompt sixty-four times. A cache with a 3 per cent hit rate is a cache you have to maintain for a 3 per cent saving.
Done when
Cache hit rate and the TTFT of a hit versus a miss are both printed by the load generator, and you can say what fraction of your test traffic is cacheable at all.
Quantize, and measure what it cost
You build: The same service running on 8-bit and 4-bit weights, with both the speed gain and the quality loss measured.
If a line here loses you, this is where it is taught
- Quantization & inference optimisation: Memory arithmetic and quantizing a model yourself
The memory arithmetic and the mechanics of quantizing a model rather than downloading one.
- Model evaluation & metrics: Choosing the metric that matches the cost of being wrong
Choosing a quality metric that would actually show the capability you just traded away.
- Compression & distillation: Making a model smaller
The alternatives — distillation, pruning, a smaller model — and what each costs in comparison.
Decode time is weight bytes divided by bandwidth, so halving the bytes roughly halves the time. That is the whole theory of weight-only quantization for inference, and the measurement should land close to it.
from transformers import BitsAndBytesConfig
four_bit = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # normal-float 4, not plain int4
bnb_4bit_compute_dtype=torch.float16, # weights stored 4-bit, maths in fp16
bnb_4bit_use_double_quant=True, # quantize the quantization constants too
)
model = AutoModelForCausalLM.from_pretrained(MODEL, quantization_config=four_bit,
device_map="auto")
print(model.get_memory_footprint() / 2**30, "GiB")
# Predicted decode step time from the footprint alone:
gib = model.get_memory_footprint() / 2**30
print(f"{gib * 2**30 / 2.0e12 * 1000:.1f} ms per step at 2 TB/s")Now the half everybody skips. Run a quality measurement on both, using the same inputs, and report the difference.
import math
@torch.inference_mode()
def perplexity(model, tok, text, window=1024):
"""Perplexity over held-out text. Cheap, sensitive, and not sufficient
on its own — see the note below."""
ids = tok(text, return_tensors="pt").input_ids.to(model.device)
total_nll, total_tokens = 0.0, 0
for i in range(0, ids.shape[1], window):
chunk = ids[:, i : i + window]
if chunk.shape[1] < 2:
break
# transformers shifts labels internally, so loss is mean NLL per token.
loss = model(chunk, labels=chunk).loss.item()
total_nll += loss * (chunk.shape[1] - 1)
total_tokens += chunk.shape[1] - 1
return math.exp(total_nll / total_tokens)
held_out = open("held_out.txt").read()
print("fp16 ", perplexity(fp16_model, tok, held_out))
print("4-bit ", perplexity(model, tok, held_out))A speed-up you did not measure the quality cost of is not a speed-up
Quantization damage is uneven. Perplexity can move by one per cent while the model stops producing valid JSON, stops following a format instruction, or degrades only on long inputs. So run your actual task alongside perplexity — a hundred real requests, graded the way you would grade them in production. "Two times faster at 0.4 per cent perplexity and 2 per cent task accuracy" is a result. "Two times faster" is a claim.
Note also what quantization does not fix. Smaller weights mean more room for KV cache, so concurrency goes up too — that gain is often larger than the per-step one, and it only appears in a benchmark run at load.
Done when
A table with fp16, int8 and 4-bit rows containing memory footprint, p50 TPOT, and a quality number from the same evaluation set — and the arithmetic showing why the speed-up is roughly the ratio of the weight sizes.
Containerise it, and decide what to watch
You build: An image that runs the service reproducibly, and a written list of the metrics you would page on.
If a line here loses you, this is where it is taught
- Containers: A Dockerfile that builds fast and runs safely
A Dockerfile that caches its layers properly and does not ship your whole toolchain.
- MLOps & model lifecycle: Serving a model: batch, online, shadow
Batch, online and shadow serving, and being able to say which of them this is.
- Tracing & observability: Seeing what the system did
What to record per request so a slow afternoon can be explained the following morning.
FROM python:3.11-slim
# Dependencies before code: this layer is cached across every code change,
# and it is the layer that takes four minutes.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
# Weights are not baked in. A 16 GB image is slow to pull and impossible to
# patch; mount a volume or download at startup behind the health check.
ENV HF_HOME=/cache
VOLUME /cache
RUN useradd --create-home app && chown -R app /cache
USER app
EXPOSE 8000
HEALTHCHECK --start-period=120s --interval=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]Ready is not the same as alive
The process accepts connections long before the weights finish loading. If your health check only asks whether the port is open, the orchestrator will route traffic to a container that cannot serve it, and every one of those requests times out. Have the health endpoint check that the model object exists, and set a start period long enough to cover the load.
Then write the monitoring list. Not a dashboard — a list, with a reason for each entry. The ones worth arguing for: p95 TTFT and p95 TPOT separately, because they fail for different reasons; queue depth and mean batch size, because together they tell you whether you are short of capacity or short of load; KV cache occupancy, because that is what you actually run out of; tokens per second and cost per million tokens, because that is the number anyone above you will ask for; and error rate split by cause, because a timeout and a bad request are different incidents.
Finish the repository with the benchmark table: baseline, batching, caching, quantization, one row each, with the quality column filled in. That table is the artefact. It is also the thing that lets you answer the follow-up question — why that order, and what you would do next — which is the part of the interview this project is really for.
Done when
docker run starts the service and passes its health check on a clean machine, the image builds in under a minute when only your code changed, and /metrics exposes the numbers on your list.
What you have at the end
A repository with a serving endpoint, a load generator, a benchmark table with percentiles rather than means, a written argument for whether the workload is compute-bound or memory-bound, and before-and-after numbers for batching, caching and quantization — including the quality cost of the last one. You can state your service's cost per million output tokens and show the working.
The other projects