Serving engines: vLLM & TGI
A serving engine ships every technique in this track behind one OpenAI-compatible API. Stand up vLLM, tune it, and your existing client code just works — now on hardware you control.
- a GPU +
pip install vllm(serving engine)
Learning objectives
- Stand up a production serving engine (vLLM) with an OpenAI-compatible API.
- Explain what the engine gives you: PagedAttention, continuous batching, quantization, spec-decoding.
- Reason about tensor/pipeline parallelism for models that don't fit one GPU.
- Point the course code at your own endpoint with a one-line change.
30-second recap
- The KV-cache: During decoding the model stores each token's attention keys and values so it doesn't recompute them; that cache is what fills GPU memory.
- Continuous batching: Serving many requests together keeps the GPU busy; continuous batching adds and removes requests every step instead of waiting for a whole batch to finish.
code/ic6-serving/ in the course, with a README. Run the scripts or copy the configs directly.The engine ties it together advanced
Picture the last five chapters as a workshop full of specialist power tools. Quantization shrinks the weights, the paged KV-cache stops memory fragmenting, continuous batching keeps the GPU busy, speculative decoding drafts tokens ahead. Each one is real and each one is finicky — and if you tried to wire them together yourself, by hand, in your own inference loop, you'd spend months on plumbing and still ship something slower and buggier than what a team of specialists already maintains. That is the mistake this section exists to prevent: reinventing the serving stack instead of adopting one.
A serving engine is the pre-assembled workbench. Think of it the way you think about a database: you don't write your own B-tree, page cache and query planner every time you need to store rows — you install PostgreSQL and spend your energy on the schema. A serving engine is that same bargain for inference. vLLM and TGI (Text Generation Inference) are the two you'll meet most; TensorRT-LLM (NVIDIA's, squeezing the last drop of throughput out of their GPUs with compiled kernels) and SGLang (strong on structured output and prefix reuse) round out the field. What they share is the important part: they bundle PagedAttention (IC3), continuous batching (IC4), quantization (IC2), and speculative decoding (IC5) behind a single server — no single engine invented those techniques, they package and productionize them.
Here's the detail that makes the whole thing click, and it's easy to undersell: most of these engines expose an OpenAI-compatible API. That means the HTTP shape of a request — the /v1/chat/completions path, the messages array, the streaming format — is the same one the hosted providers use. So the client code you already wrote against a hosted API keeps working; you change the address it points at and nothing else. The engine is doing wildly sophisticated work behind that door (paging attention memory, reshuffling batches every single decode step), but the door itself is boring and familiar on purpose. Your job shifts from implementing optimizations to configuring them — picking flags, sizing memory, choosing how many GPUs — which is exactly the leverage you want. The diagram below traces one request through that stack: familiar API in, all the tricks in the middle, GPU at the end.
IC2–IC5 were techniques; a serving engine is where they ship. vLLM and TGI (Text Generation Inference) bundle PagedAttention (IC3), continuous batching (IC4), quantization (IC2), and speculative decoding (IC5) behind a single server — usually with an OpenAI-compatible API, so your existing client code just works.
This shows where all the earlier tricks actually live. A serving engine (vLLM or TGI) sits between your app and the GPU, speaking the same API a hosted service would — so your existing code doesn't change, but now you run the model yourself.
- Your app (unchanged client) — the same client code you'd use against a hosted API. Nothing special is needed.
- OpenAI-compatible API (/v1/chat) — the engine offers the same HTTP interface most hosted providers use. That compatibility is why you can switch by changing one URL.
- vLLM / TGI (batching + paging) — the engine itself. This box quietly runs everything from IC2–IC5: PagedAttention (IC3), continuous batching (IC4), quantization (IC2), speculative decoding (IC5).
- GPU(s) (quantized weights) — the actual hardware doing the math, holding the (often quantized) model weights and the KV-cache.
- Read left to right: your request enters through a familiar API, the engine applies all the optimizations, and the GPU does the work.
In short: A serving engine = one server that bundles every technique in this track behind a standard API. You configure it; you don't rebuild the optimizations yourself.
Stand up vLLM advanced
Brace yourself for an anticlimax — the good kind. After a whole track of KV-cache paging and batch schedulers, you might expect standing up a production LLM server to be a weekend of YAML and CUDA errors. It's one command. That gap between "how hard it sounds" and "how hard it is" is the entire value proposition of a serving engine, and it's worth pausing on: the difficulty didn't vanish, it got absorbed — moved from your keyboard into code the vLLM team maintains and battle-tests across thousands of deployments.
The mental model to hold is a car dashboard, not an engine rebuild. You're not machining pistons; you're turning knobs. Each flag on the launch line is one dial, and — this is the satisfying part — every dial maps back to a chapter you've already read. --quantization is IC2. --max-num-seqs is the continuous-batch width from IC4. --gpu-memory-utilization is how much VRAM you're handing to weights plus the paged KV-cache from IC3. You already understand what each knob does physically; here you just learn its name on this particular dashboard. So read the lab below less like new material and more like a legend that labels controls you can already reason about.
The common mistake here is a memory one, and it bites on day one. The instinct is to crank --gpu-memory-utilization toward 1.0 to "use the whole card" — and then the server either refuses to start or crashes under a burst of concurrent requests. Remember why from IC3: that budget covers the model weights and the KV-cache that grows with every active sequence and every token they generate. Leave it maxed and there's no headroom for the cache when traffic spikes. The engine defaults are conservative for exactly this reason; nudge them up while watching real load, don't slam them to the ceiling and hope. The same discipline applies to --max-num-seqs: a bigger batch is more throughput right up until the KV-cache for all those sequences won't fit, at which point requests queue or get preempted. Configuring a serving engine well is mostly this one skill — spending your fixed VRAM budget wisely across weights, cache, and concurrency — which is precisely why the earlier chapters came first.
--gpu-memory-utilization sets how much of the card you hand over — leaving the red headroom band is what keeps a burst from crashing the server.
Read it left-to-right: the launch flags on the left are dials, and the arrow shows them dividing up the single VRAM budget stacked on the right.
- The left blue boxes are the flags from the lab — each is a knob, and each maps back to an earlier chapter (IC2, IC4, IC3). You turn dials; you don't machine the engine.
- The right stacked bands are one GPU's fixed memory carved into three uses: green model weights (smaller when quantized), amber KV-cache (grows with every concurrent sequence and token), and red safety headroom.
- The payoff is the red band: leave it there. Cranking
--gpu-memory-utilizationto 1.0 eats the headroom, so a burst of requests has nowhere to grow its KV-cache — and the server crashes.
In short: Serving well is spending one fixed VRAM budget across weights, cache, and headroom — the flags are how you spend it.
serve.sh# Serve any HF model with an OpenAI-compatible API on :8000
pip install vllm
python -m vllm.entrypoints.openai.api_server \
--model mistralai/Mistral-7B-Instruct-v0.3 \
--quantization awq \ # IC2: 4-bit
--max-num-seqs 128 \ # IC4: continuous-batch width
--gpu-memory-utilization 0.90 # IC3: how much VRAM for weights+KV-cache
This one command starts a full production server for a model, with the key optimizations turned on via flags. Each flag maps to a chapter you've already read — so you're configuring the techniques, not coding them.
pip install vllminstalls the engine. The next command launches its built-in OpenAI-compatible web server (vllm.entrypoints.openai.api_server).--model mistralai/Mistral-7B-Instruct-v0.3chooses which model to serve — any model from Hugging Face by name.--quantization awqturns on 4-bit quantization (IC2) to shrink the model and speed up decode.--max-num-seqs 128sets how many requests the continuous batcher (IC4) will pack together at once.--gpu-memory-utilization 0.90lets the engine use up to 90% of GPU memory for weights plus the KV-cache (IC3) — higher fits more concurrent requests but leaves less safety headroom.
What the output means: A server listening on port 8000 that any OpenAI-style client can call. It's applying quantization, batching, and paged KV-cache automatically.
Try this: Drop --quantization awq to serve the full-precision model instead. It needs more GPU memory and is a bit slower per token — a direct feel for what IC2 buys you.
client.pyfrom openai import OpenAI
# The ONLY change from a hosted API: the base_url. Same client, same calls.
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
resp = client.chat.completions.create(
model="mistralai/Mistral-7B-Instruct-v0.3",
messages=[{"role": "user", "content": "Say hello from my own GPU."}],
)
print(resp.choices[0].message.content)
This proves the payoff of an OpenAI-compatible engine: your client code is identical to calling a hosted API — you only swap the address it points at.
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")is the whole difference:base_urlnow points at your own server, and the key can be a dummy value because your local server doesn't check it.client.chat.completions.create(...)is the exact same call you'd make to a hosted provider — same method, same arguments.print(resp.choices[0].message.content)reads the reply the same way too.
What the output means: The model's greeting, generated on your own GPU instead of a vendor's — with zero changes to how you call it.
Try this: Take any earlier lab in the course that used a hosted client and change only its base_url to this one. It should just work — that's the point of the compatible API.
When the model doesn't fit one GPU expert
Sooner or later you hit a wall that no flag can tune away: the model is simply bigger than the card. A 70B model in FP16 wants roughly 140 GB of weights before you've stored a single token of KV-cache — and the biggest GPUs today hold 80 GB. You can't page your way out of this one; the weights themselves don't fit. Quantization (IC2) buys you a lot of runway here — INT4 can drop that 70B model onto a single card — but eventually a model arrives that overflows even the best-quantized single GPU, and then you have to split the model itself across several. That splitting is what this section is about, and there are two fundamentally different ways to do it.
The analogy that makes them stick is a restaurant kitchen. Tensor parallelism is the whole line of cooks working on the same dish at the same time — one chops, one sears, one plates — all on one order simultaneously. Each layer of the model is sliced across the GPUs, and they compute that single layer together, which means they're constantly passing partial results back and forth. That chatter is relentless, so tensor parallelism demands a fast interconnect (NVLink) between the GPUs; put them on a slow link and the talking swamps the working. Pipeline parallelism is the assembly line instead: dish one starts at station A, moves to station B, then C — each GPU owns a different stretch of the model's layers, and activations flow down the chain. Far less cross-talk, so it tolerates slower links (even across separate machines), but it introduces "bubbles" where downstream GPUs idle waiting for upstream work.
Here's the part people get backwards, so say it plainly: you do not implement any of this. You will never write the code that slices a weight matrix across four devices or schedules activations down a pipeline — that is exactly the sort of thing the serving engine exists to hide. In vLLM, tensor parallelism is a single number: --tensor-parallel-size 4. Your entire job is sizing — figuring out how many GPUs the model needs (weights ÷ card capacity, rounded up, with overhead) and picking the split that matches your hardware's interconnect. The rule of thumb worth memorizing: tensor-parallel within one machine where NVLink is fast, pipeline-parallel across machines where the link is slower. The common trap is reaching for multi-GPU too early — always try quantization first, because turning a 3-GPU model into a 1-GPU model is cheaper and simpler than any parallelism scheme. Only split when a single quantized card genuinely can't hold the weights.
Big models exceed a single GPU's memory. Tensor parallelism splits each layer across GPUs (fast interconnect needed); pipeline parallelism puts different layers on different GPUs. vLLM exposes both as flags — you size, you don't implement.
Compare the two panels side by side: the left is tensor parallelism, the right is pipeline parallelism — same goal (fit a model bigger than one card), opposite trade-offs.
- On the left, each amber layer is sliced across GPU A and GPU B, and the two arrows show them swapping partial results every layer. That constant back-and-forth is why tensor parallel demands a fast NVLink — the red note.
- On the right, each green box is a whole stretch of layers living on one GPU; the single arrow shows activations flowing one direction down the stack. Little cross-talk, so it tolerates slower links — the green note — even across separate machines.
- The bottom bar is the payoff: both splits are a single vLLM flag (
--tensor-parallel-size), and the reminder to try quantization first — turning a multi-GPU model into a one-GPU model is cheaper than any parallelism scheme.
In short: Tensor parallel = split each layer (chatty, NVLink); pipeline parallel = split the layer stack (calm, slow-link-friendly) — and you only pick one after quantization can't shrink it to a single card.
multi_gpu.shpython -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-70B-Instruct \
--tensor-parallel-size 4 # split each layer across 4 GPUs
Some models are too big to fit in one GPU's memory. This command spreads a 70B model across 4 GPUs with a single flag — the engine handles the splitting.
--model meta-llama/Llama-3.1-70B-Instructis a large model that won't fit on one typical GPU.--tensor-parallel-size 4turns on tensor parallelism: each layer of the model is sliced across 4 GPUs that work on it together. This needs a fast link between the GPUs (NVLink) because they exchange data constantly.- You choose the number to match how many GPUs you have; you never write the splitting logic yourself — the engine does it.
What the output means: The same OpenAI-compatible server as before, but now big enough to host a 70B model by pooling the memory of 4 GPUs.
Try this: The alternative is pipeline parallelism (different layers on different GPUs), which tolerates slower links. Tensor parallel = split each layer; pipeline = split the stack of layers.
| Parallelism | Splits | Needs |
|---|---|---|
| Tensor | each layer across GPUs | fast interconnect (NVLink) |
| Pipeline | layers across GPUs | tolerates slower links |
Exercise IC6.1 — Serve and repoint
Context: The capstone of the track is standing up your own engine and proving an earlier lab talks to it with a single base-URL change — then comparing its cost/1k-tokens to a hosted API.
Your task: Serve an open model with vLLM (quantized, continuous batching), point any earlier course lab at it by changing only base_url, confirm it works, then load-test it and compare cost/1k-tokens to a hosted API.
Requirements:
- Serve an open model with vLLM (quantized, continuous batching on)
- Repoint an earlier lab by changing only
base_url - Confirm it works end-to-end
- Load-test it (IC4) to find capacity
- Compare cost per 1k tokens against a hosted API
💡 Hint: Reuse the launch flags and the base-URL swap from the ladder; the only new work is the load test and the cost-per-1k comparison.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: vLLM exposes an OpenAI-compatible API, so migrating from a hosted API to a self-hosted engine is a single base-URL change — the request path and body are unchanged.
Your task: Show the before/after client config (concept-level, stdlib) and print the two base URLs.
Requirements:
- Hold hosted and self-hosted config as two dicts (base_url + key)
- Write a selector that returns one or the other
- Print both base URLs
- Show the same
/v1/chat/completionspath and body apply - Client code is unchanged — only the base URL differs
💡 Hint: The whole migration is swapping base_url; the endpoint path and payload stay identical, so a selector over two dicts is enough.
Show solution
The whole migration is one line — modeled with plain dicts:
hosted = {"base_url": "https://api.provider.com/v1", "key": "sk-hosted-..."}
selfhosted = {"base_url": "http://localhost:8000/v1", "key": "not-needed-locally"}
def client_config(use_self_host):
return selfhosted if use_self_host else hosted
print(client_config(False)["base_url"]) # https://api.provider.com/v1
print(client_config(True)["base_url"]) # http://localhost:8000/v1
# same /v1/chat/completions path, same request body -> code is unchanged
Because the engine speaks the same API, switching from a hosted service to your own vLLM server is a config change, not a rewrite.
Context: A serving engine isn't a new algorithm — it packages the whole IC track (PagedAttention, continuous batching, quantization, speculative decoding) behind one server.
Your task: Map each optimization to the chapter that introduced it and print a capability report so a newcomer sees the engine is IC2–IC5 behind one API.
Requirements:
- Map PagedAttention→IC3, continuous batching→IC4, quantization→IC2, speculative decoding→IC5, OpenAI API→IC6
- Store the mapping and iterate it
- Print a formatted capability report
- Show the engine is packaging, not a new algorithm
💡 Hint: It's a dict from feature to chapter — the report is just its formatted iteration, driving home that you've already seen every piece.
Show solution
Make the bundling explicit as data:
engine = {
"PagedAttention": "IC3 — KV-cache as paged virtual memory",
"continuous batching": "IC4 — swap finished slots every step",
"quantization": "IC2 — AWQ/GPTQ 4-bit weights",
"speculative decoding": "IC5 — draft+verify",
"OpenAI-compatible API":"IC6 — client code unchanged",
}
for feature, why in engine.items():
print(f"- {feature:22} {why}")
A serving engine is not a new algorithm; it is the packaging that ships every technique in the track behind a single configurable server.
Context: When a model exceeds one GPU's VRAM you split it: tensor parallelism splits each layer across GPUs, pipeline parallelism splits layers across GPUs — and quantization can drop the GPU count first.
Your task: Write a helper that computes GPUs needed and recommends a parallelism strategy.
Requirements:
- Required VRAM =
params × bytes/param × overhead - Ceil-divide by per-GPU memory for the GPU count
- Recommend single GPU (1), tensor parallelism w/ NVLink (≤8), or pipeline across nodes (>8)
- Show INT4 vs FP16 dropping a 70B model from several GPUs to one
💡 Hint: Compute the memory need, divide by the card size, and let the resulting count pick the strategy — quantizing the weights first can make the count 1.
Show solution
Size the model, then choose a split:
import math
def plan_parallelism(params_b, bytes_per, gpu_gb, overhead=1.3):
need = params_b * 1e9 * bytes_per / 1024**3 * overhead # params_b in billions
n = max(1, math.ceil(need / gpu_gb)) # ceil-div GPUs
if n == 1:
strat = "single GPU"
elif n <= 8:
strat = f"tensor parallel (TP={n}) — fast intra-node NVLink"
else:
strat = f"pipeline parallel across nodes (TP*PP={n})"
return round(need,1), n, strat
print(plan_parallelism(70, 2, 80)) # 70B FP16 on 80GB cards
# (169.5, 3, 'tensor parallel (TP=3) — fast intra-node NVLink')
print(plan_parallelism(70, 0.5, 80)) # 70B INT4
# (42.4, 1, 'single GPU')
Quantization (IC2) can drop a model from needing 3 GPUs to fitting on 1. When it still overflows, tensor parallelism within a node is the first split; pipeline parallelism across nodes is for the very largest models.
Context: The serving engine gives you a capacity envelope that stitches the track together — quantized weights (IC2), KV-cache per request (IC3), and batching (IC4).
Your task: Given quantized weight size, GPU VRAM, and per-request KV, estimate max concurrent sequences and rough throughput.
Requirements:
- Free memory = VRAM − weights
- Max concurrency = free memory // per-request KV-cache
- Throughput ≈ concurrency / step time (tok/s at full batch)
- Combine IC2 weights + IC3 KV + IC4 batching into one envelope
💡 Hint: Subtract the weights, divide the rest by per-request KV for concurrency, then divide by step time for throughput — the three chapters chained.
Show solution
One calculator that stitches IC2 + IC3 + IC4 together:
def capacity(gpu_gb, weight_gb, kv_per_req_gb, step_ms=25, ):
free = gpu_gb - weight_gb
concurrency = int(free // kv_per_req_gb)
throughput = concurrency / (step_ms/1000) # tok/s at full batch
return free, concurrency, throughput
free, conc, thru = capacity(gpu_gb=80, weight_gb=3.3, kv_per_req_gb=1.0)
print(f"free for KV: {free:.1f} GB") # 76.7 GB
print(f"max concurrency: {conc}") # 76
print(f"peak throughput: {thru:.0f} tok/s") # 3040 tok/s
An INT4 7B model leaves almost the whole 80 GB card for KV-cache, so concurrency (and thus throughput) is high — the payoff of stacking quantization, paging, and batching in one engine.
Context: The production launch turns on the track's optimizations through flags — you configure the engine, you don't reimplement the optimizations.
Your task: Show the production vLLM launch that enables the optimizations via flags, mapping each to its chapter, using only real flags and labelling it as needing a GPU.
Requirements:
- Launch
vllm.entrypoints.openai.api_server - Set
--quantization(IC2),--max-num-seqs(IC4) - Set
--max-model-lenand--gpu-memory-utilization(IC3) - Set
--tensor-parallel-size(IC6) - Verify with a
curlto/v1/models
💡 Hint: Each flag is one of the earlier chapters made operational — annotate the launch line and confirm it's up with a models query.
Show solution
The real serve command — needs a GPU + pip install vllm:
# serve.sh — every flag maps to a chapter
python -m vllm.entrypoints.openai.api_server \
--model mistralai/Mistral-7B-Instruct-v0.3 \
--quantization awq \ # IC2: 4-bit weights
--max-num-seqs 128 \ # IC4: continuous-batch width
--max-model-len 8192 \ # IC3: caps KV-cache per request
--gpu-memory-utilization 0.90 \ # IC3: VRAM for weights + KV
--tensor-parallel-size 1 # IC6: 1 GPU here; raise to split
# Verify: curl http://localhost:8000/v1/models
One command starts a full production server with quantization, paging, and continuous batching enabled. You configure the engine; you do not reimplement the optimizations.
Context: Choosing a serving topology is a constrained minimization: serve a 70B model at the QPS and p95 TTFT budget on the fewest GPUs — quantization often drops it from several cards to one.
Your task: You must serve a 70B model at 8 QPS under a p95 TTFT budget, minimizing GPU count. Given quantization and parallelism options, write the decision logic that lands on the cheapest topology that fits and meets the SLO.
Requirements:
- Size GPUs needed per option (fp16 TP, awq-int4 single, awq-int4 TP2)
- Filter to options meeting the TTFT SLO
- Pick the minimum by GPU count, breaking ties on TTFT
- Show FP16 needs several GPUs while INT4 fits on one
- Among INT4 ties, the lower-latency topology wins
💡 Hint: Filter on the SLO first, then minimize on (GPU count, TTFT) — quantizing collapses the count, and the tie-break picks the faster layout.
Show solution
Search topologies for the cheapest one that both fits and meets latency:
import math
def gpus_needed(params_b, bytes_per, gpu_gb=80, overhead=1.3):
need = params_b * 1e9 * bytes_per / 1024**3 * overhead # params_b in billions
return max(1, math.ceil(need / gpu_gb))
options = [
# name, bytes_per, est_ttft_ms
("fp16 TP", 2, 300),
("awq-int4 single", 0.5, 460),
("awq-int4 TP2", 0.5, 320), # int4 fits 1 GPU; TP2 is optional, not needed
]
SLO_TTFT = 500
plans = []
for name, bpp, ttft in options:
g = gpus_needed(70, bpp)
if ttft <= SLO_TTFT:
plans.append((g, ttft, name))
best = min(plans) # fewest GPUs, then fastest TTFT
print("feasible:", [(name, g) for g, _, name in plans])
# feasible: [('fp16 TP', 3), ('awq-int4 single', 1), ('awq-int4 TP2', 1)]
print("PICK:", best[2], f"({best[0]} GPU(s), {best[1]} ms TTFT)")
# PICK: awq-int4 TP2 (1 GPU, 320 ms TTFT) — cheapest GPU count, best TTFT among ties
FP16 needs 3 GPUs; INT4 quantization drops the 70B model onto a single card. Both INT4 options tie at one GPU, so the tie-break is latency — TP2 gives the better TTFT for the same GPU count. Quantization is what makes the single-GPU topology possible at all.
✓ Checkpoint — you can move on when you can…
- Stand up vLLM with an OpenAI-compatible API.
- Explain which IC techniques the engine bundles.
- Choose tensor vs pipeline parallelism for a large model.
- Repoint existing client code to your endpoint with one line.
Knowledge check check yourself
A serving engine like vLLM exposes an OpenAI-compatible API. What does that compatibility buy you, and which IC techniques does the engine bundle behind it?
Show answer
Contrast tensor parallelism and pipeline parallelism for serving a model too big for one GPU, including their interconnect needs.