ML EngineerAI Engineer24h

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.

Stage 1 of 7, 3h

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

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]),
    }
Return the token counts. Every measurement in the next stage needs them.

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.

Stage 2 of 7, 4h

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

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.

Stage 3 of 7, 3h

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

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")   # 120
Grouped-query attention is why this is 128 KiB and not a megabyte. Multi-head attention with 32 KV heads would be four times worse.

Prefill 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.

Stage 4 of 7, 4h

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

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.

Stage 5 of 7, 3h

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

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.

Stage 6 of 7, 4h

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

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.

Stage 7 of 7, 3h

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

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