Lab: Serve a Model to Twenty Concurrent Users
Validated on: written from the documentation cited above; not yet validated on hardware on any track. The engine versions, model file and backend each track was run with will be recorded here when the validation pass is done.
Objective
Section titled “Objective”Before executing, read the lab execution and evidence guide. Use this lesson's explicit working directories and track setup; keep each server in its own terminal. Record hardware validation as pass, fail or not run, with the evidence requested below.
By the end of this lab you will have a curve, not a number. For Qwen3-8B on your machine you will have output throughput, request throughput, goodput, time to first token and time per output token at concurrency one, five, ten and twenty, for llama-server’s parallel slots and for a second engine: vLLM on Tracks S and N (and X if it runs), the MLX server on Track M. You will have predicted the KV pool each engine allocates and checked the prediction against its log, found the concurrency at which your machine stops gaining throughput, measured what prefix caching is worth against a control of the same length, and changed slots, context, cache type and memory fraction one at a time.
Part 6 measured one user. This measures a service. The two produce different-shaped answers, and the second is the one every remaining level of this course cares about.
Architecture
Section titled “Architecture”One server at a time, in terminal 1. The load generator and every other command run in terminal 2. Two servers loaded together measure their competition for memory, not either engine.
What one load-test level does
- load-test.py starts c workersClosed loop: each worker sends a streamed chat completion, waits for the last token, and sends the next. c requests are in flight until the queue drains.
- The server admits up to its limitllama-server: one request per slot, extras deferred. vLLM: up to --max-num-seqs, and only while the block pool has room. mlx_lm.server: up to 32 decoding and 8 prefilling by default.
- Continuous batchingEvery step reads the weights once for all running sequences, plus each sequence's KV cache.
- One JSON line per levelThroughput, goodput, TTFT and TPOT percentiles, prompt and cached tokens, finish reasons, appended to labbook.md.
- summarise-load.pyScaling efficiency, knee, TPOT fit, operating point, comparisons and the validation check.
| Engine | Port | Started by | Weights it loads | Tracks |
|---|---|---|---|---|
| llama-server, 20 slots | 8080 | serve-llama-parallel.sh |
~/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf |
all |
| vLLM, NGC container | 8000 | serve-vllm-container.sh |
~/models/Qwen/Qwen3-8B-AWQ |
S |
| vLLM, native | 8000 | serve-vllm-native.sh |
~/models/Qwen/Qwen3-8B-AWQ (N), ~/models/Qwen/Qwen3-8B (X) |
N, X if it runs |
| mlx_lm.server | 8081 | Part 8’s serve-mlx.sh |
~/models/mlx-community/Qwen3-8B-4bit |
M |
Work in ~/llm-course, where Part 1 created labbook.md, and save every lab file on this page there
with its download link. Everything listens on 127.0.0.1 only.
Requirements
Section titled “Requirements”Qwen3-8B is Apache-2.0 licensed and ungated; the model reference records the
licence, and the AWQ and bf16 checkpoints below are Qwen’s own conversions under the same licence.
Every track needs llama.cpp built as in Part 6’s install
lesson (llama.cpp v0.4.0 · verified 2026-09-08),
Python 3.9 or later (both scripts are standard library only), the hf CLI from Part 4
(Hugging Face CLI 1.30.0 · verified 2026-09-08), and the GGUF file. On 2026-09-13 the repository listing gave
Qwen3-8B-Q4_K_M.gguf as 5,027,784,512 bytes; Part 6 downloaded it.
| Track | Second engine | Second-format download (Hub listing, 2026-09-13) | Attended | Unattended |
|---|---|---|---|---|
| S | vLLM, NGC container | Qwen/Qwen3-8B-AWQ, 12 files, 6,114,568,235 bytes; the container image if Part 9’s install lesson did not pull it |
about 100 minutes | the AWQ download; first vLLM start |
| X | vLLM from the ROCm wheels, if it starts | Qwen/Qwen3-8B bf16, 15 files, 16,397,461,266 bytes |
about 90 minutes, or 60 on llama-server alone | the bf16 download |
| M | mlx_lm.server | mlx-community/Qwen3-8B-4bit, 4.6 GB, from Part 8 |
about 80 minutes | none if Part 8 was done |
| N | vLLM, native | Qwen/Qwen3-8B-AWQ, as for S; Part 8 downloaded it |
about 100 minutes | first vLLM start |
The attended times include two to five minutes per sweep while the generator runs; you can read ahead during those.
Track S — NVIDIA DGX Spark
Pinned: vLLM 0.28.0 · verified 2026-09-08 for the documentation this page follows; the NGC image
nvcr.io/nvidia/vllm:26.08-py3 carries its own vLLM build, which Task 8 records. 128 GB of
unified memory holds every configuration on this page with room to spare, so the lab runs the
same 20 × 4,096 baseline as a 24 GB card and the extra memory goes into Task 9’s memory-fraction
experiment. nvidia-smi reports memory usage as not supported on this machine (NVIDIA’s DGX
Spark known issue, as Part 8 showed); read allocations from the engines’ logs instead.
Track X — AMD Ryzen AI Max+ 395Partial
vLLM's GPU installation page names gfx1151 among its ROCm GPUs, but the course has not run it and its quantisation matrix does not list AWQ for AMD GPUs; llama-server is the path this track can count on.
Pinned: llama.cpp v0.4.0 · verified 2026-09-08 with the Vulkan build from Part 6. If you installed vLLM
from the ROCm wheels in the install lesson, try it in Task 8 with the bf16 checkpoint; if it does
not start in ten minutes, skip Tasks 8 and 9 and record that it did not. The GPU-visible memory
cap from Part 5 applies: the KV pool comes
out of the visible share, not the machine total. Run this lab on Linux; on a Windows installation
run llama-server natively and this page’s scripts from WSL2 against localhost.
Track M — Apple silicon
Pinned: mlx-lm 0.31.3 · verified 2026-09-08. vLLM has no supported path here, for the reasons the
install lesson quotes, so the second engine is mlx_lm.server, started with Part 8’s
serve-mlx.sh. Its 0.31.3 source batches requests that carry no seed: up to 32 decoding at
once (--decode-concurrency) and 8 prefilling (--prompt-concurrency), allocating KV cache as
it goes rather than at load. Close the browser and other large applications first; on unified
memory they compete for the pool.
At 24 GB or less the baseline is reduced to 10 slots with an eight-bit cache (lab.env sets
it), so the concurrency-20 level measures queueing. That is your machine’s result, not a failure.
Track N — NVIDIA desktop or laptop
Pinned: vLLM 0.28.0 · verified 2026-09-08, installed with uv as in the install lesson. Video memory is
a hard ceiling. From 24 GB the baseline uses an f16 cache on both engines; a 16 GB card uses an
eight-bit cache on both (q8_0 in llama.cpp, fp8 in vLLM), which lab.env sets. Anything else on
the card, including the desktop, comes out of the same memory: check it in the preflight. Windows
readers work in WSL2 with every command on this page run inside the Linux distribution, following
Part 5’s CUDA-on-WSL setup.
Preflight
Section titled “Preflight”Download the four lab files embedded on this page into ~/llm-course first (load-test.py,
summarise-load.py, serve-llama-parallel.sh and the vLLM script for your track). Then:
RunnableAll tracks
cd ~/llm-coursepython3 --versionls labbook.md load-test.py summarise-load.py serve-llama-parallel.shls -l ~/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufdf -h ~/modelsfor port in 8000 8080 8081; do curl --silent --max-time 2 --output /dev/null "http://127.0.0.1:$port/" && echo "port $port is in use"done; echo "port check finished"Output — what you should see
Python 3.x.xlabbook.md load-test.py serve-llama-parallel.sh summarise-load.py-rw-r--r-- 1 you you 5027784512 ... /home/you/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufFilesystem Size Used Avail Use% Mounted on/dev/... xxxG xxxG xxxG xx% /port check finishedThe Python version must be 3.9 or later. If the GGUF file is missing, fetch it with Part 4’s script:
bash fetch-model.sh unsloth/Qwen3-8B-GGUF Qwen3-8B-Q4_K_M.gguf labbook.md. A port … is in use
line means a server from an earlier part is still running: stop it now.
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
~/llama.cpp/build/bin/llama-server --list-devicesdocker image inspect --format '{{.Id}}' nvcr.io/nvidia/vllm:26.08-py3 \ || docker pull nvcr.io/nvidia/vllm:26.08-py3ls ~/models/Qwen/Qwen3-8B-AWQ/config.json \ || hf download Qwen/Qwen3-8B-AWQ --local-dir ~/models/Qwen/Qwen3-8B-AWQfree -gOutput — what you should see
Available devices: CUDA0: NVIDIA GB10 (xxxxxx MiB, xxxxxx MiB free)sha256:xxxxxxxxxxxx.../home/you/models/Qwen/Qwen3-8B-AWQ/config.json total used free shared buff/cache availableMem: xxx xx xx xx xx xxxAvailable devices: (none) means a CPU-only build of llama.cpp: rebuild before going on. If the
download ran instead of ls, it ends with ✓ Downloaded and path: /home/you/models/Qwen/Qwen3-8B-AWQ.
Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
~/llama.cpp/build/bin/llama-server --list-devices~/vllm/.venv/bin/vllm --version 2>/dev/null || echo "no vLLM environment: Tasks 8 and 9 are skipped"Output — what you should see
Available devices: Vulkan0: AMD Radeon ... (xxxxx MiB, xxxxx MiB free)0.28.0The install lesson creates its environment as .venv in the directory you ran uv venv in;
this page assumes ~/vllm/.venv. If yours is elsewhere, use that path here and in lab.env.
The free MiB figure on the device line is the GPU-visible memory the KV pool comes out of. If you
will run vLLM, download the bf16 checkpoint now:
hf download Qwen/Qwen3-8B --local-dir ~/models/Qwen/Qwen3-8B.
Track M — Apple silicon
RunnableTrack M · Apple silicon
~/llama.cpp/build/bin/llama-server --list-devicescommand -v mlx_lm.serverls ~/llm-course/serve-mlx.sh ~/models/mlx-community/Qwen3-8B-4bit/config.jsonsysctl -n hw.memsizeOutput — what you should see
Available devices: MTL0: Apple M... (xxxxx MiB, xxxxx MiB free)/Users/you/.../bin/mlx_lm.server/Users/you/llm-course/serve-mlx.sh /Users/you/models/mlx-community/Qwen3-8B-4bit/config.jsonxxxxxxxxxxxhw.memsize is in bytes; it decides which lab.env you write below. If serve-mlx.sh is
missing, download it from Part 8’s lab.
Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
~/llama.cpp/build/bin/llama-server --list-devicesnvidia-smi --query-gpu=name,memory.total,memory.used --format=csv~/vllm/.venv/bin/vllm --versionls ~/models/Qwen/Qwen3-8B-AWQ/config.json \ || hf download Qwen/Qwen3-8B-AWQ --local-dir ~/models/Qwen/Qwen3-8B-AWQOutput — what you should see
Available devices: CUDA0: NVIDIA GeForce RTX ... (xxxxx MiB, xxxxx MiB free)name, memory.total [MiB], memory.used [MiB]NVIDIA GeForce RTX ..., xxxxx MiB, xxx MiB0.28.0/home/you/models/Qwen/Qwen3-8B-AWQ/config.jsonThe install lesson creates its environment as .venv in the directory you ran uv venv in;
this page assumes ~/vllm/.venv, so use your own path here and in lab.env if it differs.
memory.used above a few hundred MiB with no model loaded is memory the lab cannot have: close
what holds it.
Now write the variables every later command reads. Edit HOST_DESC to describe your machine. Each
terminal you open for this lab starts with cd ~/llm-course and source lab.env.
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cat > ~/llm-course/lab.env <<'EOF'export GGUF=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufexport LLAMA_BIN=$HOME/llama.cpp/build/binexport LLAMA_VERSION="$("$LLAMA_BIN/llama-server" --version 2>&1 | grep -m1 '^version:')"export HOST_DESC="Track S, DGX Spark GB10, 128 GB"export SLOTS=20 KV_TYPE=f16 ALT_KV_TYPE=q8_0export VLLM_IMAGE=nvcr.io/nvidia/vllm:26.08-py3export VLLM_MODEL=/models/Qwen/Qwen3-8B-AWQ VLLM_QUANT=AWQexport VLLM_KV_DTYPE=auto VLLM_KV_BYTES=12079595520export MEM_FRACTION_A=0.30 MEM_FRACTION_B=0.40EOFVLLM_MODEL is the path inside the container, where ~/models is mounted as /models.
Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cat > ~/llm-course/lab.env <<'EOF'export GGUF=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufexport LLAMA_BIN=$HOME/llama.cpp/build/binexport LLAMA_VERSION="$("$LLAMA_BIN/llama-server" --version 2>&1 | grep -m1 '^version:')"export HOST_DESC="Track X, Ryzen AI Max+ 395, 128 GB"export SLOTS=20 KV_TYPE=f16 ALT_KV_TYPE=q8_0export VLLM_MODEL=$HOME/models/Qwen/Qwen3-8B VLLM_QUANT=bf16export VLLM_KV_DTYPE=auto VLLM_KV_BYTES=12079595520export MEM_FRACTION_A=0.40 MEM_FRACTION_B=0.50export VLLM_VENV=$HOME/vllm/.venvEOFTrack M — Apple silicon
RunnableTrack M · Apple silicon
cat > ~/llm-course/lab.env <<'EOF'export GGUF=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufexport LLAMA_BIN=$HOME/llama.cpp/build/binexport LLAMA_VERSION="$("$LLAMA_BIN/llama-server" --version 2>&1 | grep -m1 '^version:')"export HOST_DESC="Track M, M4 Max, 64 GB"export SLOTS=20 KV_TYPE=f16 ALT_KV_TYPE=q8_0export MLX_MODEL=$HOME/models/mlx-community/Qwen3-8B-4bitEOFRunnableTrack M · Apple silicon
cat > ~/llm-course/lab.env <<'EOF'export GGUF=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufexport LLAMA_BIN=$HOME/llama.cpp/build/binexport LLAMA_VERSION="$("$LLAMA_BIN/llama-server" --version 2>&1 | grep -m1 '^version:')"export HOST_DESC="Track M, M4, 16 GB"export SLOTS=10 KV_TYPE=q8_0 ALT_KV_TYPE=q4_0export MLX_MODEL=$HOME/models/mlx-community/Qwen3-8B-4bitEOFTrack N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
cat > ~/llm-course/lab.env <<'EOF'export GGUF=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufexport LLAMA_BIN=$HOME/llama.cpp/build/binexport LLAMA_VERSION="$("$LLAMA_BIN/llama-server" --version 2>&1 | grep -m1 '^version:')"export HOST_DESC="Track N, RTX 4090 24 GB, Ubuntu 24.04"export SLOTS=20 KV_TYPE=f16 ALT_KV_TYPE=q8_0export VLLM_MODEL=$HOME/models/Qwen/Qwen3-8B-AWQ VLLM_QUANT=AWQexport VLLM_KV_DTYPE=auto VLLM_KV_BYTES=12079595520export MEM_FRACTION_A=0.90 MEM_FRACTION_B=0.80export VLLM_VENV=$HOME/vllm/.venvEOFRunnableTrack N · NVIDIA GPU
cat > ~/llm-course/lab.env <<'EOF'export GGUF=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.ggufexport LLAMA_BIN=$HOME/llama.cpp/build/binexport LLAMA_VERSION="$("$LLAMA_BIN/llama-server" --version 2>&1 | grep -m1 '^version:')"export HOST_DESC="Track N, RTX 4080 16 GB, Ubuntu 24.04"export SLOTS=20 KV_TYPE=q8_0 ALT_KV_TYPE=q4_0export VLLM_MODEL=$HOME/models/Qwen/Qwen3-8B-AWQ VLLM_QUANT=AWQexport VLLM_KV_DTYPE=fp8 VLLM_KV_BYTES=6039797760export MEM_FRACTION_A=0.90 MEM_FRACTION_B=0.85export VLLM_VENV=$HOME/vllm/.venvEOFEvery number in these files is derived in Task 1.
Record: the lab.env you wrote, python3 --version, and the device line from --list-devices.
1. Predict the pool, the fit and the ceiling
Section titled “1. Predict the pool, the fit and the ceiling”Two sums decide everything the curve will do. The first is memory: every concurrent sequence needs its own KV cache, and llama-server allocates all of it at load.
Pseudocode — not a real command
KV bytes per token = 2 (K and V) × layers × KV heads × head_dim × bytes per value = 2 × 36 × 8 × 128 × 2 = 147,456 for Qwen3-8B at f16KV pool = KV bytes per token × slots × context per slotfits = weights + KV pool + compute buffers (and, on vLLM, graphs) ≤ usable memoryThe second is bandwidth. A batched decode step reads the weights once for every running sequence, plus each sequence’s own cache:
Pseudocode — not a real command
bytes read per step (batch B) = weight bytes read + B × tokens in context × KV bytes per tokenper-user ceiling (tokens/s) = bandwidth (bytes/s) / bytes read per stepaggregate ceiling = B × per-user ceilingSave this as serving-arithmetic.py and run it. The inputs are Qwen3-8B’s config.json, the tensor
bytes of the two four-bit files (the GGUF tensor table, as Part 8 read it, and the AWQ safetensors
listing), and the vendor bandwidth figures in the hardware reference.
RunnableAll tracks
# Memory and bandwidth arithmetic for serving Qwen3-8B to many users. Stated inputs only.LAYERS, KV_HEADS, HEAD_DIM = 36, 8, 128 # Qwen3-8B config.jsonSLOTS, CTX = 20, 4096 # the lab's baseline: 20 sequences of 4,096ELEMENTS = 2 * LAYERS * KV_HEADS * HEAD_DIM # K and V values cached per tokenKV_BYTES = {"f16": ELEMENTS * 2, # llama.cpp f16; vLLM auto on an fp16 model "q8_0": ELEMENTS // 32 * 34, # llama.cpp: 34 bytes per block of 32 values "fp8": ELEMENTS} # vLLM --kv-cache-dtype fp8WEIGHTS = {"llama-server, Q4_K_M GGUF": (5_021_827_072, ["f16", "q8_0"]), "vLLM, AWQ safetensors": (6_098_581_864, ["f16", "fp8"])}GIB = 2**30
print(f"KV pool for {SLOTS} x {CTX:,} = {SLOTS * CTX:,} tokens")for name, per_token in KV_BYTES.items(): pool = per_token * SLOTS * CTX print(f" {name:<5} {per_token:>7,} B/token {pool:>15,} B = {pool / GIB:5.2f} GiB")print("Weights + pool, before compute buffers and graphs (GiB)")for engine, (weights, types) in WEIGHTS.items(): for name in types: print(f" {engine:<26} + {name:<5} = {(weights + KV_BYTES[name] * SLOTS * CTX) / GIB:5.2f}")
# Decode: every step reads the weights once plus each sequence's cache.READ_PER_STEP = 5_021_827_072 - 350_060_544 # Q4_K_M tensors minus the embedding lookupprint("Bandwidth ceiling, Q4_K_M, f16 cache: aggregate tok/s (per user) at batch B")for machine, gbps in {"Spark 273 GB/s": 273, "Strix 256 GB/s": 256, "M4 Max 546 GB/s": 546, "RTX 4090 1008 GB/s": 1008}.items(): for ctx in (100, 1500): # tokens each sequence attends over cells = [] for batch in (1, 5, 10, 20): step = READ_PER_STEP + batch * ctx * KV_BYTES["f16"] per_user = gbps * 1e9 / step cells.append(f"B={batch:<2}{batch * per_user:6.0f} ({per_user:5.1f})") print(f" {machine:<19} ctx {ctx:>5}: " + " ".join(cells))Output — what you should see
KV pool for 20 x 4,096 = 81,920 tokens f16 147,456 B/token 12,079,595,520 B = 11.25 GiB q8_0 78,336 B/token 6,417,285,120 B = 5.98 GiB fp8 73,728 B/token 6,039,797,760 B = 5.62 GiBWeights + pool, before compute buffers and graphs (GiB) llama-server, Q4_K_M GGUF + f16 = 15.93 llama-server, Q4_K_M GGUF + q8_0 = 10.65 vLLM, AWQ safetensors + f16 = 16.93 vLLM, AWQ safetensors + fp8 = 11.30Bandwidth ceiling, Q4_K_M, f16 cache: aggregate tok/s (per user) at batch B Spark 273 GB/s ctx 100: B=1 58 ( 58.3) B=5 288 ( 57.5) B=10 566 ( 56.6) B=20 1099 ( 55.0) Spark 273 GB/s ctx 1500: B=1 56 ( 55.8) B=5 236 ( 47.3) B=10 397 ( 39.7) B=20 600 ( 30.0) Strix 256 GB/s ctx 100: B=1 55 ( 54.6) B=5 270 ( 53.9) B=10 531 ( 53.1) B=20 1031 ( 51.5) Strix 256 GB/s ctx 1500: B=1 52 ( 52.3) B=5 222 ( 44.3) B=10 372 ( 37.2) B=20 563 ( 28.1) M4 Max 546 GB/s ctx 100: B=1 117 (116.5) B=5 575 (115.1) B=10 1133 (113.3) B=20 2199 (109.9) M4 Max 546 GB/s ctx 1500: B=1 112 (111.6) B=5 473 ( 94.5) B=10 793 ( 79.3) B=20 1201 ( 60.0) RTX 4090 1008 GB/s ctx 100: B=1 215 (215.1) B=5 1062 (212.4) B=10 2092 (209.2) B=20 4059 (203.0) RTX 4090 1008 GB/s ctx 1500: B=1 206 (206.0) B=5 872 (174.5) B=10 1464 (146.4) B=20 2216 (110.8)That output is arithmetic from stated inputs, not a measurement. Three things follow from it.
Why the tiers in lab.env differ. At f16 the llama-server pool plus weights is 15.93 GiB before
any compute buffer, so a 16 GB card cannot hold twenty slots of 4,096 tokens; at q8_0 it is 10.65 GiB.
vLLM’s AWQ weights are a gigabyte larger because Qwen’s AWQ build keeps both vocabulary tables at
16 bits. Two numbers in lab.env come straight from the first block: VLLM_KV_BYTES is the f16 or
fp8 pool in bytes, so vLLM is given exactly the tokens llama-server allocates.
What the logs should say. Each engine prints its own version of this arithmetic at startup, and Tasks 2 and 8 compare it with yours:
| Configuration | llama-server load log | vLLM startup log |
|---|---|---|
| 20 × 4,096, f16 / auto | llama_kv_cache: size = 11520.00 MiB ( 4096 cells, 36 layers, 20/20 seqs), K (f16): 5760.00 MiB, V (f16): 5760.00 MiB |
reserved 11.25 GiB memory for KV Cache … then GPU KV cache size: 81,920 tokens, Maximum concurrency for 4,096 tokens per request: 20.00x |
| 20 × 4,096, q8_0 / fp8 | llama_kv_cache: size = 6120.00 MiB ( 4096 cells, 36 layers, 20/20 seqs), K (q8_0): 3060.00 MiB, V (q8_0): 3060.00 MiB |
reserved 5.62 GiB memory for KV Cache … then the same 81,920 tokens and 20.00x |
| 10 × 4,096, q8_0 (Track M at 24 GB or less) | llama_kv_cache: size = 3060.00 MiB ( 4096 cells, 36 layers, 10/10 seqs), … |
not run |
The llama-server lines are the format string v0.4.0 printed when this page’s script was tested,
filled with the arithmetic. The vLLM tokens follow from its block arithmetic in
kv_cache_utils.py: blocks = pool bytes ÷ (16 tokens × bytes per token), 5,120 blocks here, and a
4,096-token request needs 256 of them, if the attention backend keeps the default block size of 16.
Where the curve should bend. With short prompts (context near 100 tokens) the bandwidth ceiling per user barely falls from B=1 to B=20: batching is nearly free as far as memory traffic goes, so any early flattening you measure is compute, scheduling or the client, not bandwidth. With a 1,500-token context the cache traffic at B=20 is comparable to the weights, and the per-user ceiling roughly halves on every track. That is why Task 6’s long prompts decode more slowly at high concurrency even when prefix caching removes their prefill.
Record: the pool for your configuration in GiB, the weights-plus-pool figure against your usable memory, and the four aggregate ceilings for your machine at context 100.
2. Start llama-server with twenty slots and read what it allocated
Section titled “2. Start llama-server with twenty slots and read what it allocated”RunnableAll tracks
#!/usr/bin/env bash# Purpose: start llama-server with a fixed number of parallel slots and continuous batching,# every setting that changes a load test stated on the command line, the load log# copied to a file you can search, and the process id written where you can find it# Platform: all (spark, strix, mac, nvidia; CUDA, Vulkan and Metal builds); the only served# path on Track M and the certain one on Track X# Minimum memory: 16 GB, because the context is allocated for every slot at load# Assumes: llama.cpp built as in Part 6 (v0.4.0 or a build with the same options), a GGUF# file in $MODEL, nothing else answering on $PORT; the server runs in the foreground# so that Ctrl-C stops it## Usage: MODEL=$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf bash serve-llama-parallel.sh# SLOTS=8 CACHE_TYPE=q8_0 MODEL=... bash serve-llama-parallel.sh## Environment:# MODEL path to the GGUF file (default: the Qwen3-8B Q4_K_M file)# LLAMA_BIN directory holding llama-server, or the binary itself# (default: $HOME/llama.cpp/build/bin)# ALIAS model name the API reports (default: local-chat)# HOST address to bind (default: 127.0.0.1)# PORT port to listen on (default: 8080)# SLOTS parallel slots, the most sequences decoded at once (default: 20)# CTX_PER_SLOT context per slot, in tokens (default: 4096)# CACHE_TYPE KV cache type for keys and values: f16, q8_0, ... (default: f16)# CACHE_RAM host-RAM prompt cache limit in MiB; 0 disables it (default: 0)# NGL layers to offload (default: 999, meaning all)# PIDFILE where to write the server's pid (default: ./llama-server.pid)# LOGFILE copy of the server log (default: ./llama-server.log)## What the options do, as read from llama-server --help at v0.4.0:# --ctx-size is the total across slots. With --parallel given explicitly the KV cache is not# unified ("default: enabled if number of slots is auto"), so each slot gets# ctx-size / parallel tokens and the load log says so: "n_slots = 20, n_ctx_slot = 4096,# kv_unified = 'false'". The whole cache is allocated when the model loads.# --cache-type-k/-v choose the bytes per cached token; a quantised value cache needs flash# attention, hence --flash-attn on.# --cache-prompt is on by default: each slot keeps its last prompt's KV and a new request# reuses the longest common prefix of the slot it lands on. That is the prefix caching the# lab measures. --cache-ram (default 8192 MiB) adds a second cache in host RAM that saves# idle slots and restores them; this script turns it off so that the baseline measures# batching rather than copies to and from host memory. Set CACHE_RAM=8192 to measure it.# --verbosity 4 is the level at which the load log prints the KV cache and buffer sizes.
set -euo pipefail
MODEL="${MODEL:-$HOME/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf}"LLAMA_BIN="${LLAMA_BIN:-$HOME/llama.cpp/build/bin}"ALIAS="${ALIAS:-local-chat}"HOST="${HOST:-127.0.0.1}"PORT="${PORT:-8080}"SLOTS="${SLOTS:-20}"CTX_PER_SLOT="${CTX_PER_SLOT:-4096}"CACHE_TYPE="${CACHE_TYPE:-f16}"CACHE_RAM="${CACHE_RAM:-0}"NGL="${NGL:-999}"PIDFILE="${PIDFILE:-./llama-server.pid}"LOGFILE="${LOGFILE:-./llama-server.log}"
die() { echo "serve-llama-parallel: $*" >&2; exit 1; }
if [ -f "$LLAMA_BIN" ] && [ -x "$LLAMA_BIN" ]; then SERVER="$LLAMA_BIN"elif [ -x "$LLAMA_BIN/llama-server" ]; then SERVER="$LLAMA_BIN/llama-server"else die "no llama-server at $LLAMA_BIN; set LLAMA_BIN, or build llama.cpp as in Part 6"fi[ -f "$MODEL" ] || die "model file not found: $MODEL"case "$SLOTS$CTX_PER_SLOT" in *[!0-9]*) die "SLOTS and CTX_PER_SLOT must be whole numbers" ;;esac[ "$SLOTS" -ge 1 ] || die "SLOTS must be at least 1"if command -v curl >/dev/null 2>&1 \ && curl --silent --max-time 2 --output /dev/null "http://$HOST:$PORT/"; then die "something is already answering on $HOST:$PORT; stop it first (one server at a time)"fi
TOTAL_CTX=$(( SLOTS * CTX_PER_SLOT ))
echo "==> llama-server, $SLOTS parallel slot(s)""$SERVER" --version 2>&1 | head -n 2 || truecat <<INFO model $MODEL served as $ALIAS listening on http://$HOST:$PORT/v1 slots $SLOTS context per slot $CTX_PER_SLOT total context $TOTAL_CTX (--ctx-size is the total across slots) KV cache type $CACHE_TYPE (keys and values) host prompt cache $CACHE_RAM MiB pid written to $PIDFILE, log copied to $LOGFILEINFO
rm -f "$LOGFILE"echo $$ > "$PIDFILE"
# exec keeps this shell's pid, so $PIDFILE names the server process itself.exec "$SERVER" \ --model "$MODEL" \ --alias "$ALIAS" \ --host "$HOST" \ --port "$PORT" \ --ctx-size "$TOTAL_CTX" \ --parallel "$SLOTS" \ --cont-batching \ --n-gpu-layers "$NGL" \ --flash-attn on \ --cache-type-k "$CACHE_TYPE" \ --cache-type-v "$CACHE_TYPE" \ --cache-ram "$CACHE_RAM" \ --metrics \ --jinja \ --verbosity 4 \ --log-file "$LOGFILE"The script’s header explains every option it passes. The two that shape this lab: --ctx-size is
the total across slots, and because --parallel is given explicitly the v0.4.0 build does not unify
the cache, so each slot gets exactly CTX_PER_SLOT tokens of its own. The host-RAM prompt cache is
off (CACHE_RAM=0) so the baseline measures batching rather than copies to host memory; per-slot
prompt caching stays on, and Task 6 measures it.
RunnableAll tracks
cd ~/llm-coursesource lab.envSLOTS=$SLOTS CACHE_TYPE=$KV_TYPE MODEL=$GGUF LLAMA_BIN=$LLAMA_BIN bash serve-llama-parallel.shOutput — what you should see
==> llama-server, 20 parallel slot(s)version: x.x.x (build xxxxx, commit xxxxxxx)built with ... for ... model /home/you/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf served as local-chat listening on http://127.0.0.1:8080/v1 slots 20 context per slot 4096 total context 81920 (--ctx-size is the total across slots) KV cache type f16 (keys and values) host prompt cache 0 MiB pid written to ./llama-server.pid, log copied to ./llama-server.log... load log ...x.xx.xxx.xxx I srv llama_server: listening on http://127.0.0.1:8080Wait for the listening on line, then pull the allocation out of the log copy in terminal 2:
RunnableAll tracks
cd ~/llm-coursesource lab.envgrep -E "offloaded|model buffer size|KV buffer size|llama_kv_cache: size|compute buffer size|n_slots" llama-server.logcurl -s http://127.0.0.1:8080/slots | python3 -c 'import json,sys; s=json.load(sys.stdin); print(len(s), "slots, n_ctx per slot:", sorted({x["n_ctx"] for x in s}))'Output — what you should see
x.xx.xxx.xxx I load_tensors: offloaded 37/37 layers to GPUx.xx.xxx.xxx I load_tensors: CUDA0 model buffer size = xxxx.xx MiBx.xx.xxx.xxx I load_tensors: CPU_Mapped model buffer size = xxx.xx MiBx.xx.xxx.xxx I llama_kv_cache: CUDA0 KV buffer size = 11520.00 MiBx.xx.xxx.xxx I llama_kv_cache: size = 11520.00 MiB ( 4096 cells, 36 layers, 20/20 seqs), K (f16): 5760.00 MiB, V (f16): 5760.00 MiBx.xx.xxx.xxx I sched_reserve: CUDA0 compute buffer size = xxx.xx MiBx.xx.xxx.xxx I srv load_model: initializing, n_slots = 20, n_ctx_slot = 4096, kv_unified = 'false'20 slots, n_ctx per slot: [4096]The device name is CUDA0 on Tracks S and N, Vulkan0 on X and MTL0 on M. On an eight-bit tier the
KV lines read 6120.00 MiB (or 3060.00 MiB with 10 slots), as Task 1 predicted. A KV size that
differs from your prediction means the configuration is not what you think: check n_ctx_slot and
the cache type before measuring anything. If layers read 0/37, stop; everything after this would
measure the CPU (Troubleshooting).
Record: LLAMA_VERSION, offloaded layers, both model buffers, the KV size line, the compute
buffer, and n_slots/n_ctx_slot.
3. Prove the endpoint before loading it
Section titled “3. Prove the endpoint before loading it”RunnableAll tracks
curl -s http://127.0.0.1:8080/v1/models | python3 -c 'import json,sys; d=json.load(sys.stdin)["data"][0]; print(d["id"], "n_ctx", d["meta"]["n_ctx"])'curl -s http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "local-chat", "messages": [{"role": "user", "content": "Reply with one word: ready"}], "max_tokens": 8}'Output — what you should see
local-chat n_ctx 4096{"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","content":"","reasoning_content":"Okay, the user wants a"}}],"created":xxxxxxxxxx,"model":"local-chat",...,"usage":{"completion_tokens":8,"prompt_tokens":14,"total_tokens":22,"prompt_tokens_details":{"cached_tokens":0}},...,"timings":{"cache_n":0,"prompt_n":14,...}}That is the shape the v0.4.0 server returned when this page’s scripts were tested against a CPU
build with Qwen3-0.6B; the words and counts will differ. Read it closely, because it explains two choices in the load generator. Qwen3 thinks before it answers:
all eight tokens went into reasoning_content and content is empty. A client that times only
content would report no first token at all, so load-test.py counts reasoning text too. And with
a 128-token budget almost every request stops on length while still thinking, which keeps output
length identical across requests and engines; the generator records the finish reasons so you can
confirm it.
A generator pointed at a broken endpoint produces a tidy table of failures; this check takes ten seconds.
Record: the n_ctx the server reports and the finish_reason.
4. The load generator, and the baseline sweep
Section titled “4. The load generator, and the baseline sweep”RunnableAll tracks
#!/usr/bin/env python3"""Concurrency load generator for an OpenAI-compatible chat completions endpoint.
Purpose: drive a served model at one or more concurrency levels and report the numbers Part 9 cares about - output token throughput, request throughput, goodput against a stated objective, time to first token and time per output token - with percentiles rather than means, plus the prompt and cached token counts the server reports.Platform: all (spark, strix, mac, nvidia). Pure Python standard library, 3.9 or later: no pip install.Minimum memory: 16 GB on the machine running the server. The generator itself is tiny and may run on a second machine on the same network.Assumes: a server already listening and answering POST /v1/chat/completions with streaming, reachable over plain HTTP (this is a localhost or LAN lab tool; it does not speak TLS). The model name must be the one the server reports at /v1/models. Results are appended to the lab notebook as one JSON line per concurrency level.
Usage: python3 load-test.py --base-url http://127.0.0.1:8080/v1 --model local-chat \ --concurrency 1,5,10,20 --requests 40 --min-rounds 4 --max-tokens 128 \ --prompt-set mixed --label llama-baseline --labbook labbook.md
python3 load-test.py --base-url http://127.0.0.1:8000/v1 --model local-chat \ --concurrency 1,10 --requests 40 --max-tokens 64 \ --prompt-set shared-prefix --label vllm-prefix-shared --labbook labbook.md
An API key, if the server needs one, is read from an environment variable named by --api-key-env. No key is ever written to this file or to the notebook.
Method, stated once: * Closed loop. Each level starts `concurrency` workers that each send a request, wait for it to finish and send the next, so that number of requests is in flight until the queue drains. Requests per level = max(--requests, --min-rounds x concurrency). * Before the first level, --warmup requests (default 1) with a short prompt that shares nothing with the prompt sets are sent and discarded. * Time to first token (TTFT) is measured to the first streamed delta carrying any text: answer text ("content") or reasoning text ("reasoning_content" or "reasoning"), so a model that thinks first is timed the same way on every engine. * Output tokens come from the server's usage block (requested with stream_options.include_usage); where a server sends none, one text delta is counted as one token and the notebook line says "deltas" instead of "usage". * Time per output token (TPOT) for one request = (end - TTFT) / (output tokens - 1), the same definition vllm bench serve uses. Percentiles are nearest-rank. * Output tokens per second = all output tokens at the level / wall-clock of the level. * Goodput = requests per second that completed with TTFT <= --slo-ttft and TPOT <= --slo-tpot. * Mean in flight = sum of request durations / wall-clock: close to `concurrency` when the loop held the load, lower when the client or the drain at the end did not. * Cached tokens: usage.prompt_tokens_details.cached_tokens (vLLM with --enable-prompt-tokens-details, llama-server, mlx_lm.server), or llama-server's timings.cache_n where that is the only count sent."""
from __future__ import annotations
import argparseimport asyncioimport jsonimport mathimport osimport secretsimport statisticsimport sysimport timefrom collections import Counterfrom urllib.parse import urlsplit
LAB_ID = "part-09/lab-serve-twenty-concurrent-users"TEXT_FIELDS = ("content", "reasoning_content", "reasoning")
# --------------------------------------------------------------------------- prompts
TOOLS = [ ("read_metrics", "Read the Prometheus metrics endpoint of one inference server and return the series whose names match a pattern.", {"server": "string: the server name from the inventory", "pattern": "string: a regular expression matched against metric names"}), ("tail_log", "Return the last lines of a server's log, optionally filtered by level.", {"server": "string", "lines": "integer: how many lines, at most 500", "level": "string: one of debug, info, warning, error"}), ("restart_server", "Stop and start one inference server with its recorded configuration. Refuses while requests are running unless force is true.", {"server": "string", "force": "boolean"}), ("set_option", "Change one recorded serving option for the next restart and return the previous value.", {"server": "string", "option": "string: for example max-num-seqs or ctx-size", "value": "string"}), ("memory_report", "Report total, used and free accelerator memory, and the part of it held by the key-value cache.", {"server": "string"}), ("list_models", "List the models a server reports at /v1/models, with their context length.", {"server": "string"}), ("run_load_test", "Start a load test against a server and return the path of the results once it finishes.", {"server": "string", "concurrency": "array of integers", "requests": "integer", "max_tokens": "integer"}), ("compare_runs", "Compare two recorded load-test runs level by level and return the differences in throughput and latency.", {"first_label": "string", "second_label": "string"}), ("disk_usage", "Report the disk space used by the model directory and by each model file in it.", {"path": "string"}), ("checksum_file", "Compute the SHA-256 of a model file and compare it with the checksum recorded beside it.", {"path": "string"}), ("gpu_temperature", "Return the accelerator temperature, power draw and any throttling reasons reported by the driver.", {"device": "integer"}), ("open_ticket", "Record an operational issue with a title, a severity and the evidence gathered so far.", {"title": "string", "severity": "string: one of low, medium, high", "evidence": "string"}), ("slot_status", "Return each parallel slot of a llama-server instance with its context size, whether it is processing and how many tokens it holds.", {"server": "string"}), ("queue_depth", "Return how many requests are running and how many are waiting on a server, sampled once per second for a number of seconds.", {"server": "string", "seconds": "integer: at most 60"}), ("prefix_cache_stats", "Return prefix cache queries, hits and the hit rate since the server started.", {"server": "string"}), ("download_model", "Download a model file from a named repository into the model directory and verify its checksum.", {"repository": "string", "file": "string"}), ("delete_model_file", "Delete one model file after confirming that no running server has it loaded.", {"path": "string", "confirm": "boolean"}), ("network_throughput", "Measure throughput between this machine and another on the local network for a number of seconds.", {"peer": "string: a host name from the inventory", "seconds": "integer"}), ("schedule_benchmark", "Schedule a benchmark to run at a quiet time and record it in the lab notebook when it finishes.", {"when": "string: an ISO 8601 time", "benchmark": "string"}), ("summarise_notebook", "Return the notebook lines recorded for one lab, grouped by label, as a table.", {"lab": "string", "label": "string"}),]
RULES = [ "Answer in at most four sentences unless the operator asks for a table.", "Quote numbers only from tool results, never from memory, and name the tool that produced them.", "Prefer the smallest change that tests one hypothesis, and say what result would refute it.", "Never restart a server that has requests running without asking the operator first.", "When memory is short, reduce concurrency or context before reducing model precision.", "Report latency as a median and a ninetieth percentile, never as a mean alone.",]
# About 1,365 tokens under the Qwen3 tokeniser: a system-prompt-and-tool-schema preamble# of the kind an agent sends on every step.SHARED_PREAMBLE = ( "You are the operations assistant for a small local inference service: one open-weight " "model served over an OpenAI-compatible interface on one machine. You can call the tools " "described below by answering with a JSON object naming the tool and its arguments.\n\n" "Rules:\n" + "\n".join(f"{i + 1}. {rule}" for i, rule in enumerate(RULES)) + "\n\nTools:\n" + json.dumps([{"name": n, "description": d, "parameters": p} for n, d, p in TOOLS], indent=2))
SHARED_QUESTIONS = [ "What happens to the key-value cache when I double the context length?", "Why is prompt processing so much faster than generation?", "Which measurement should I quote to a user who says the service feels slow?", "What does it mean when the number of waiting requests stays above zero?", "Why does a second concurrent request cost less than twice as much?", "How do I decide between more concurrent slots and a longer context?", "What is the first thing to check when memory runs out at startup?", "Why does the mean latency mislead me about a serving system?",]
MIXED_PROMPTS = [ "Explain in three sentences why decode speed is limited by memory bandwidth.", "Write a short shell function that reports how much disk a directory uses.", "Summarise the difference between tensor parallel and pipeline parallel.", "Give three reasons a language model server might refuse to start.", "What is a KV cache, and why does it grow with the conversation?", "Write a Python function that returns the median of a list, without imports.", "List four things that belong in a benchmark result besides the number itself.", "Explain quantisation to somebody who knows what a floating point number is.", "Describe what continuous batching does, in plain language.", "Name three failure modes of retrieval-augmented generation.", "Write a regular expression that matches an ISO date, and explain it.", "Why might a smaller model that fits beat a larger one that does not?", "Give a checklist for exposing a local API endpoint to a home network.", "What is the difference between a context window and a context length?", "Explain why two runs with the same seed can still produce different text.", "Write a short bash loop that retries a command three times with a pause.", "What does a high standard deviation in a benchmark run usually indicate?", "Describe the trade-off between batch size and per-user latency.", "Explain what a chat template does and what goes wrong without one.", "Give three signs that a machine is thermally limited during a benchmark.", "What is speculative decoding, and when does it not help?", "Explain the difference between total and active parameters.", "Write a JSON object describing a machine with a chip, memory and an operating system.", "Why is goodput a more useful headline number than raw throughput?",]
WARMUP_PROMPT = "Reply with one word: ready"
def build_prompts(prompt_set: str, count: int) -> list[str]: """Returns `count` prompts, cycling the chosen set.
mixed: 24 short, different questions, each behind a random tag, so that a prompt repeated later in the run (or in an earlier run) is not a cache hit. shared-prefix: the preamble, then one of eight questions; everything before the question is byte-identical across requests, so a prefix cache can reuse it. unique-prefix: the same text with a random tag as its first line, different for every request and every run, so nothing after the chat template's opening tokens can be reused. It is the control for shared-prefix: same length, no reusable prefix. """ if prompt_set == "shared-prefix": pool = [f"{SHARED_PREAMBLE}\n\nQuestion: {q}" for q in SHARED_QUESTIONS] return [pool[i % len(pool)] for i in range(count)] if prompt_set == "unique-prefix": return [ f"Request {secrets.token_hex(8)}.\n{SHARED_PREAMBLE}\n\n" f"Question: {SHARED_QUESTIONS[i % len(SHARED_QUESTIONS)]}" for i in range(count) ] if prompt_set == "mixed": return [ f"Request {secrets.token_hex(8)}. {MIXED_PROMPTS[i % len(MIXED_PROMPTS)]}" for i in range(count) ] raise ValueError(f"unknown prompt set: {prompt_set}")
# ------------------------------------------------------------------- minimal HTTP/1.1
class RequestFailed(Exception): """Raised when a request did not complete with a usable stream."""
async def _read_headers(reader: asyncio.StreamReader) -> tuple[int, bool]: """Reads the status line and headers. Returns (status, chunked).""" status_line = await reader.readline() if not status_line: raise RequestFailed("server closed the connection without a response") parts = status_line.decode("latin-1", "replace").split() if len(parts) < 2 or not parts[1].isdigit(): raise RequestFailed(f"unparseable status line: {status_line!r}") status = int(parts[1]) chunked = False while True: line = await reader.readline() if line in (b"\r\n", b"\n", b""): break lowered = line.lower() if lowered.startswith(b"transfer-encoding:") and b"chunked" in lowered: chunked = True return status, chunked
async def _body_lines(reader: asyncio.StreamReader, chunked: bool): """Yields body lines, decoding chunked transfer encoding where it is used.""" buf = b"" if chunked: while True: size_line = await reader.readline() if not size_line: break head = size_line.strip().split(b";")[0] if not head: continue try: size = int(head, 16) except ValueError: break if size == 0: await reader.readline() break buf += await reader.readexactly(size) await reader.readexactly(2) while b"\n" in buf: line, buf = buf.split(b"\n", 1) yield line.rstrip(b"\r") else: while True: chunk = await reader.read(65536) if not chunk: break buf += chunk while b"\n" in buf: line, buf = buf.split(b"\n", 1) yield line.rstrip(b"\r") if buf.strip(): yield buf.rstrip(b"\r")
def _cached_from(event: dict): """The cached prompt token count in a streamed event, if the server sent one.""" usage = event.get("usage") or {} details = usage.get("prompt_tokens_details") or {} if isinstance(details, dict) and details.get("cached_tokens") is not None: return int(details["cached_tokens"]) timings = event.get("timings") or {} if timings.get("cache_n") is not None: return int(timings["cache_n"]) return None
async def stream_completion(cfg: dict, prompt: str, max_tokens: int) -> dict: """Sends one streaming chat completion and times it.
Returns a dict with ok, ttft, total, tokens, token_source, prompt_tokens, cached, finish and (on failure) error. """ body = json.dumps( { "model": cfg["model"], "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": cfg["temperature"], "stream": True, "stream_options": {"include_usage": True}, } ).encode("utf-8")
headers = [ f"POST {cfg['path']} HTTP/1.1", f"Host: {cfg['host']}:{cfg['port']}", "Content-Type: application/json", "Accept: text/event-stream", f"Content-Length: {len(body)}", "Connection: close", ] if cfg["api_key"]: headers.append(f"Authorization: Bearer {cfg['api_key']}")
started = time.perf_counter() ttft = None deltas = 0 usage_tokens = None prompt_tokens = None cached = None finish = None writer = None try: reader, writer = await asyncio.wait_for( asyncio.open_connection(cfg["host"], cfg["port"]), timeout=cfg["timeout"] ) writer.write(("\r\n".join(headers) + "\r\n\r\n").encode("latin-1") + body) await writer.drain()
status, chunked = await asyncio.wait_for(_read_headers(reader), timeout=cfg["timeout"]) if status != 200: detail = b"" try: detail = await asyncio.wait_for(reader.read(300), timeout=5) except (OSError, asyncio.TimeoutError): pass raise RequestFailed(f"HTTP {status} {detail.decode('utf-8', 'replace').strip()}")
async for raw in _body_lines(reader, chunked): if not raw.startswith(b"data:"): continue payload = raw[5:].strip() if payload in (b"[DONE]", b""): continue try: event = json.loads(payload) except json.JSONDecodeError: continue if event.get("error"): raise RequestFailed(f"server error in stream: {str(event['error'])[:200]}") for choice in event.get("choices") or []: delta = choice.get("delta") or {} if any(delta.get(field) for field in TEXT_FIELDS) or delta.get("tool_calls"): deltas += 1 if ttft is None: ttft = time.perf_counter() - started if choice.get("finish_reason"): finish = choice["finish_reason"] usage = event.get("usage") if usage: if usage.get("completion_tokens") is not None: usage_tokens = int(usage["completion_tokens"]) if usage.get("prompt_tokens") is not None: prompt_tokens = int(usage["prompt_tokens"]) found = _cached_from(event) if found is not None: cached = found except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, RequestFailed) as exc: return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} finally: if writer is not None: writer.close() try: await writer.wait_closed() except OSError: pass
total = time.perf_counter() - started if deltas == 0 or ttft is None: return {"ok": False, "error": "stream produced no content deltas"} tokens = usage_tokens if usage_tokens else deltas return { "ok": True, "ttft": ttft, "total": total, "tokens": tokens, "token_source": "usage" if usage_tokens else "deltas", "prompt_tokens": prompt_tokens, "cached": cached, "finish": finish or "none", }
# ------------------------------------------------------------------------ measurement
def percentile(values: list[float], p: float) -> float: """Nearest-rank percentile: the smallest value with at least p% of values at or below it.""" if not values: return 0.0 ordered = sorted(values) rank = max(1, min(len(ordered), math.ceil(p / 100.0 * len(ordered)))) return ordered[rank - 1]
def _summary(values: list[float], digits: int) -> dict: return { "p50": round(percentile(values, 50), digits), "p90": round(percentile(values, 90), digits), "p99": round(percentile(values, 99), digits), "mean": round(statistics.fmean(values), digits) if values else 0.0, }
def _token_source(sources: Counter): """"usage" or "deltas" when every request agrees, the counts when they do not, "none" if empty.""" if not sources: return "none" if len(sources) == 1: return next(iter(sources)) return dict(sources)
def _mean_or_none(values: list) -> float | None: present = [v for v in values if v is not None] return round(statistics.fmean(present), 1) if present else None
async def warm_up(cfg: dict, count: int) -> None: """Sends `count` short requests one at a time and discards their timings.""" for _ in range(count): await stream_completion(cfg, WARMUP_PROMPT, 8)
async def run_level(cfg: dict, concurrency: int, prompts: list[str]) -> dict: """Runs every prompt through `concurrency` workers and aggregates the results.""" queue: asyncio.Queue = asyncio.Queue() for prompt in prompts: queue.put_nowait(prompt) results: list[dict] = []
async def worker() -> None: while True: try: prompt = queue.get_nowait() except asyncio.QueueEmpty: return results.append(await stream_completion(cfg, prompt, cfg["max_tokens"])) queue.task_done()
started = time.perf_counter() await asyncio.gather(*[worker() for _ in range(concurrency)]) wall = time.perf_counter() - started
ok = [r for r in results if r["ok"]] failed = [r for r in results if not r["ok"]] ttfts = [r["ttft"] for r in ok] tpots = {id(r): (r["total"] - r["ttft"]) / (r["tokens"] - 1) for r in ok if r["tokens"] > 1} out_tokens = sum(r["tokens"] for r in ok) good = [ r for r in ok if r["ttft"] <= cfg["slo_ttft"] and tpots.get(id(r), 0.0) <= cfg["slo_tpot"] ] sources = Counter(r["token_source"] for r in ok)
return { "concurrency": concurrency, "requests": len(results), "completed": len(ok), "failed": len(failed), "wall_s": round(wall, 3), "output_tokens": out_tokens, "output_tokens_per_s": round(out_tokens / wall, 2) if wall > 0 else 0.0, "requests_per_s": round(len(ok) / wall, 3) if wall > 0 else 0.0, "goodput_requests_per_s": round(len(good) / wall, 3) if wall > 0 else 0.0, "within_slo": len(good), "mean_in_flight": round(sum(r["total"] for r in ok) / wall, 2) if wall > 0 else 0.0, "ttft_s": _summary(ttfts, 4), "tpot_s": _summary(list(tpots.values()), 5), "prompt_tokens_mean": _mean_or_none([r["prompt_tokens"] for r in ok]), "cached_tokens_mean": _mean_or_none([r["cached"] for r in ok]), "token_counts": _token_source(sources), "finish_reasons": dict(Counter(r["finish"] for r in ok)), "first_error": failed[0]["error"] if failed else None, }
def print_level(row: dict) -> None: """Prints one concurrency level as two aligned lines.""" print( f" c={row['concurrency']:>3} " f"ok {row['completed']:>3}/{row['requests']:<3} " f"fail {row['failed']:<3} " f"wall {row['wall_s']:>7.2f}s " f"out/s {row['output_tokens_per_s']:>8.2f} " f"req/s {row['requests_per_s']:>6.3f} " f"TTFT p50 {row['ttft_s']['p50']:.3f} p90 {row['ttft_s']['p90']:.3f} " f"TPOT p50 {row['tpot_s']['p50']:.4f} p90 {row['tpot_s']['p90']:.4f}" ) cached = row["cached_tokens_mean"] prompt = row["prompt_tokens_mean"] print( f" goodput {row['goodput_requests_per_s']:.3f} req/s " f"({row['within_slo']}/{row['completed']} within SLO) " f"in flight {row['mean_in_flight']:.1f} " f"prompt {prompt if prompt is not None else '?'} tok, " f"cached {cached if cached is not None else '?'} " f"tokens from {row['token_counts']} finish {row['finish_reasons']}" ) if row["first_error"]: print(f" first error: {row['first_error']}")
def append_labbook(path: str, record: dict) -> None: """Appends one JSON line, in the course notebook format.""" with open(path, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, sort_keys=True) + "\n")
def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--base-url", default="http://127.0.0.1:8000/v1", help="OpenAI-compatible base URL, ending in /v1") parser.add_argument("--model", required=True, help="model name the server reports") parser.add_argument("--concurrency", default="1,5,10,20", help="comma-separated concurrency levels to run in order") parser.add_argument("--requests", type=int, default=40, help="requests sent at each concurrency level (at least)") parser.add_argument("--min-rounds", type=int, default=0, help="send at least this many requests per worker at each level") parser.add_argument("--max-tokens", type=int, default=128) parser.add_argument("--temperature", type=float, default=0.0) parser.add_argument("--timeout", type=float, default=300.0) parser.add_argument("--prompt-set", default="mixed", choices=["mixed", "shared-prefix", "unique-prefix"]) parser.add_argument("--warmup", type=int, default=1, help="discarded short requests sent before the first level") parser.add_argument("--slo-ttft", type=float, default=2.0, help="goodput objective for time to first token, in seconds") parser.add_argument("--slo-tpot", type=float, default=0.1, help="goodput objective for time per output token, in seconds") parser.add_argument("--label", default="run", help="tag written into the notebook line") parser.add_argument("--engine", default="unknown", help="engine name recorded in the notebook line, e.g. vllm") parser.add_argument("--engine-version", default="unknown", help="engine version recorded in the notebook line") parser.add_argument("--host-desc", default="unknown", help="machine description recorded in the notebook line") parser.add_argument("--quant", default="unknown", help="weight format recorded in the notebook line, e.g. Q4_K_M or AWQ") parser.add_argument("--context-length", type=int, default=0, help="context length per sequence the server was started with") parser.add_argument("--server-slots", type=int, default=0, help="sequences the server may run at once (slots or max-num-seqs)") parser.add_argument("--api-key-env", default="LOADTEST_API_KEY", help="environment variable holding the API key, if the server needs one") parser.add_argument("--labbook", default="labbook.md", help="file to append one JSON line per concurrency level to") return parser.parse_args(argv)
def main(argv: list[str]) -> int: args = parse_args(argv)
parts = urlsplit(args.base_url) if parts.scheme != "http": print("This tool speaks plain HTTP only; point it at a localhost or LAN endpoint.", file=sys.stderr) return 2 if not parts.hostname: print(f"Could not read a host from --base-url {args.base_url!r}.", file=sys.stderr) return 2 if args.requests < 1 or args.max_tokens < 2: print("--requests must be at least 1 and --max-tokens at least 2.", file=sys.stderr) return 2
cfg = { "host": parts.hostname, "port": parts.port or 80, "path": (parts.path.rstrip("/") or "/v1") + "/chat/completions", "model": args.model, "max_tokens": args.max_tokens, "temperature": args.temperature, "timeout": args.timeout, "slo_ttft": args.slo_ttft, "slo_tpot": args.slo_tpot, "api_key": os.environ.get(args.api_key_env, ""), }
try: levels = [int(x) for x in args.concurrency.split(",") if x.strip()] except ValueError: print(f"--concurrency must be a comma-separated list of integers, got {args.concurrency!r}", file=sys.stderr) return 2 if not levels or min(levels) < 1: print("--concurrency listed no levels, or a level below 1.", file=sys.stderr) return 2
print(f"==> {args.label}: {args.model} at {args.base_url}") print(f" prompt set {args.prompt_set}, at least {args.requests} request(s) per level" f"{f' and {args.min_rounds} per worker' if args.min_rounds else ''}, " f"max_tokens {args.max_tokens}, temperature {args.temperature}") print(f" SLO for goodput: TTFT <= {args.slo_ttft} s and TPOT <= {args.slo_tpot} s; " f"{args.warmup} warm-up request(s)")
if args.warmup > 0: asyncio.run(warm_up(cfg, args.warmup))
exit_code = 0 for level in levels: count = max(args.requests, args.min_rounds * level) prompts = build_prompts(args.prompt_set, count) row = asyncio.run(run_level(cfg, level, prompts)) print_level(row) if row["completed"] == 0: exit_code = 1 record = { "lab": LAB_ID, "label": args.label, "engine": args.engine, "engine_version": args.engine_version, "host": args.host_desc, "quant": args.quant, "context_length": args.context_length or None, "server_slots": args.server_slots or None, "model": args.model, "base_url": args.base_url, "prompt_set": args.prompt_set, "max_tokens": args.max_tokens, "temperature": args.temperature, "slo_ttft_s": args.slo_ttft, "slo_tpot_s": args.slo_tpot, "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), } record.update(row) append_labbook(args.labbook, record)
print(f" appended {len(levels)} line(s) to {args.labbook}") if exit_code: print(" at least one level completed no requests; see the error above.", file=sys.stderr) return exit_code
if __name__ == "__main__": sys.exit(main(sys.argv[1:]))The docstring states the method; here is what each number means, with one request worked through.
| Measure | Definition in load-test.py |
Worked example (illustrative inputs, not a measurement) |
|---|---|---|
| TTFT | time from sending the request to the first streamed delta carrying any text | first reasoning token after 0.40 s |
| TPOT | (end − TTFT) ÷ (output tokens − 1), the definition vllm bench serve uses |
ended at 7.40 s with 128 tokens: 7.00 ÷ 127 = 0.0551 s |
| Output tokens | usage.completion_tokens from the final streamed chunk, requested with stream_options.include_usage; one per text delta only if a server sends no usage |
128 |
| Output tokens/s | all output tokens at the level ÷ the level’s wall-clock | 40 requests × 128 tokens in 60 s = 85.3 |
| Goodput | requests per second with TTFT ≤ --slo-ttft and TPOT ≤ --slo-tpot (defaults 2.0 s and 0.1 s) |
36 of 40 within both, in 60 s: 0.600 |
| p50, p90 | nearest rank: the smallest value with at least that share of values at or below it | p90 of 40 values is the 36th smallest |
| Mean in flight | sum of request durations ÷ wall-clock | 20 workers, 80 requests, drain at the end: somewhat under 20 |
| Cached tokens | usage.prompt_tokens_details.cached_tokens, or llama-server’s timings.cache_n |
about 4 on a tagged prompt: the chat template’s opening tokens |
The objective is a choice, and the defaults are stated so you can change them: a first token within two seconds, and a pace of at least ten tokens per second, faster than anyone reads.
Concurrency is held by workers, not by an arrival rate. Concurrency 20 means twenty requests are in
flight until the queue runs out: the saturated case, deliberately, because it finds the limit. It is
not twenty users, who spend most of their time reading. vllm bench serve --request-rate models
arrivals, and Task 8 uses its --max-concurrency as a cross-check. Each worker’s first requests start
together, so the first round at a high level is also a burst of simultaneous prefills; --min-rounds 4
sends at least four requests per worker so that burst is a minority of the sample. The mixed
prompts start with a random tag, so a prompt repeated across levels is not a cache hit.
RunnableAll tracks
cd ~/llm-coursesource lab.envpython3 load-test.py \ --base-url http://127.0.0.1:8080/v1 \ --model local-chat \ --concurrency 1,5,10,20 \ --requests 40 \ --min-rounds 4 \ --max-tokens 128 \ --prompt-set mixed \ --engine llama.cpp \ --engine-version "$LLAMA_VERSION" \ --host-desc "$HOST_DESC" \ --quant Q4_K_M \ --context-length 4096 \ --server-slots "$SLOTS" \ --label llama-baseline \ --labbook labbook.mdOutput — what you should see
==> llama-baseline: local-chat at http://127.0.0.1:8080/v1 prompt set mixed, at least 40 request(s) per level and 4 per worker, max_tokens 128, temperature 0.0 SLO for goodput: TTFT <= 2.0 s and TPOT <= 0.1 s; 1 warm-up request(s) c= 1 ok 40/40 fail 0 wall xx.xxs out/s xx.xx req/s x.xxx TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx goodput x.xxx req/s (40/40 within SLO) in flight 1.0 prompt 3x.x tok, cached x.x tokens from usage finish {'length': 40} c= 5 ok 40/40 fail 0 wall xx.xxs out/s xxx.xx req/s x.xxx TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx goodput x.xxx req/s (xx/40 within SLO) in flight x.x prompt 3x.x tok, cached x.x tokens from usage finish {'length': 40} c= 10 ok 40/40 fail 0 wall xx.xxs out/s xxx.xx req/s x.xxx TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx goodput x.xxx req/s (xx/40 within SLO) in flight x.x prompt 3x.x tok, cached x.x tokens from usage finish {'length': 40} c= 20 ok 80/80 fail 0 wall xx.xxs out/s xxx.xx req/s x.xxx TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx goodput x.xxx req/s (xx/80 within SLO) in flight xx.x prompt 3x.x tok, cached x.x tokens from usage finish {'length': 80} appended 4 line(s) to labbook.mdCheck four things before trusting the numbers. fail 0 on every level. tokens from usage, so the
token counts are the server’s. finish {'length': N}: every request generated the full 128 tokens;
if some stopped early, their shorter answers inflate requests per second. And in flight close to
the level: a figure well below it at c=20 (below about 15) means the client could not keep the load
up, which is the “client is the bottleneck” row in Troubleshooting. The sweep takes a few minutes;
while it runs, watch the server from a third terminal:
RunnableAll tracks
while sleep 2; do curl -s http://127.0.0.1:8080/metrics | grep -E '^llamacpp:(requests_processing|requests_deferred|n_busy_slots_per_decode)' echo "--"doneOutput — what you should see
llamacpp:requests_processing xxllamacpp:requests_deferred 0llamacpp:n_busy_slots_per_decode xx.xxxx--requests_processing should track the level, requests_deferred should stay at 0 (a nonzero value
means more requests than slots), and n_busy_slots_per_decode is the batch size llama-server actually
achieved per decode call.
Record: nothing by hand; the four JSON lines are the record. Note in prose anything odd you saw in terminal 3.
5. Read the curve
Section titled “5. Read the curve”RunnableAll tracks
#!/usr/bin/env python3"""Turn Part 9 load-test notebook lines into curves, comparisons and a pass/fail check.
Purpose: read the JSON lines load-test.py appended to the lab notebook and print, for one label, the throughput and latency curve with the derived numbers the lab asks for (scaling efficiency, the knee, a straight-line fit of time per output token against concurrency, and the operating point under the recorded objective); for two labels, a level-by-level comparison; and for the whole notebook, the lab's validation rules.Platform: all (spark, strix, mac, nvidia). Python 3.9 or later, standard library only.Minimum memory: none worth stating; it reads a text file.Assumes: a notebook (default labbook.md) holding lines written by load-test.py, whose "lab" field is part-09/lab-serve-twenty-concurrent-users. Other lines, prose and other labs' records are skipped. When a label was run more than once, the most recent line per concurrency level is used.
Usage: python3 summarise-load.py --list python3 summarise-load.py --label llama-baseline python3 summarise-load.py --label llama-baseline --markdown --record python3 summarise-load.py --compare llama-baseline vllm-baseline python3 summarise-load.py --check"""
from __future__ import annotations
import argparseimport jsonimport sysimport time
LAB_ID = "part-09/lab-serve-twenty-concurrent-users"BASELINE_LEVELS = {1, 5, 10, 20}
def load_records(path: str) -> list[dict]: """Every load-test row for this lab, in file order.""" rows = [] try: with open(path, encoding="utf-8") as handle: for line in handle: line = line.strip() if not line.startswith("{"): continue try: obj = json.loads(line) except json.JSONDecodeError: continue if obj.get("lab") == LAB_ID and "concurrency" in obj and "ttft_s" in obj: rows.append(obj) except FileNotFoundError: sys.exit(f"summarise-load: {path} not found; run load-test.py first or pass --labbook") return rows
def by_label(rows: list[dict]) -> dict[str, dict[int, dict]]: """label -> concurrency -> the most recent row.""" out: dict[str, dict[int, dict]] = {} for row in rows: out.setdefault(str(row.get("label")), {})[int(row["concurrency"])] = row return out
def fit_line(xs: list[float], ys: list[float]): """Least-squares y = a + b x. Returns (a, b) or None with fewer than two distinct x.""" if len(set(xs)) < 2: return None n = len(xs) mx, my = sum(xs) / n, sum(ys) / n sxx = sum((x - mx) ** 2 for x in xs) b = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx return my - b * mx, b
def curve(levels: dict[int, dict]) -> dict: """Derived numbers for one label's curve.""" cs = sorted(c for c, r in levels.items() if r.get("completed")) derived = {"levels": cs, "knee": None, "operating_point": None, "fit": None} if not cs: return derived first = levels[cs[0]] per_request = first["output_tokens_per_s"] / cs[0] derived["per_request_tokens_per_s"] = per_request for prev, cur in zip(cs, cs[1:]): gain = (levels[cur]["output_tokens_per_s"] - levels[prev]["output_tokens_per_s"]) / (cur - prev) if gain < 0.5 * per_request: derived["knee"] = [prev, cur] break for c in cs: r = levels[c] if (r["failed"] == 0 and r["ttft_s"]["p90"] <= r.get("slo_ttft_s", 2.0) and r["tpot_s"]["p90"] <= r.get("slo_tpot_s", 0.1)): derived["operating_point"] = c slots = first.get("server_slots") fit_cs = [c for c in cs if not slots or c <= slots] line = fit_line([float(c) for c in fit_cs], [levels[c]["tpot_s"]["p50"] for c in fit_cs]) if line: a, b = line derived["fit"] = {"a_s": a, "b_s": b, "levels_used": fit_cs} return derived
def bar(value: float, top: float, width: int = 24) -> str: filled = int(round(width * value / top)) if top > 0 else 0 return "#" * filled + "." * (width - filled)
def print_curve(label: str, levels: dict[int, dict], markdown: bool) -> dict: d = curve(levels) cs = d["levels"] if not cs: print(f"{label}: no level completed any request") return d first = levels[cs[0]] print(f"==> {label}: {first.get('engine')} {first.get('engine_version')}, model {first.get('model')}, " f"prompt set {first.get('prompt_set')}, max_tokens {first.get('max_tokens')}") print(f" host {first.get('host', 'unknown')}, quant {first.get('quant', 'unknown')}, " f"context {first.get('context_length')}, server slots {first.get('server_slots')}, " f"SLO TTFT <= {first.get('slo_ttft_s')} s, TPOT <= {first.get('slo_tpot_s')} s") head = ["c", "ok", "out tok/s", "scaling", "req/s", "goodput", "TTFT p50", "TTFT p90", "TPOT p50", "TPOT p90", "in flight", "cached"] table = [] for c in cs: r = levels[c] scaling = r["output_tokens_per_s"] / (c * d["per_request_tokens_per_s"]) table.append([str(c), f"{r['completed']}/{r['requests']}", f"{r['output_tokens_per_s']:.1f}", f"{scaling:.2f}", f"{r['requests_per_s']:.3f}", f"{r.get('goodput_requests_per_s', 0.0):.3f}", f"{r['ttft_s']['p50']:.3f}", f"{r['ttft_s']['p90']:.3f}", f"{r['tpot_s']['p50']:.4f}", f"{r['tpot_s']['p90']:.4f}", f"{r.get('mean_in_flight', 0.0):.1f}", str(r.get("cached_tokens_mean"))]) if markdown: print("| " + " | ".join(head) + " |") print("|" + "---|" * len(head)) for row in table: print("| " + " | ".join(row) + " |") else: widths = [max(len(h), *(len(row[i]) for row in table)) for i, h in enumerate(head)] print(" " + " ".join(h.rjust(w) for h, w in zip(head, widths))) for row in table: print(" " + " ".join(v.rjust(w) for v, w in zip(row, widths)))
top_out = max(levels[c]["output_tokens_per_s"] for c in cs) top_tpot = max(levels[c]["tpot_s"]["p90"] for c in cs) print("\n output tokens/s TPOT p90 (s)") for c in cs: r = levels[c] print(f" c={c:>3} {bar(r['output_tokens_per_s'], top_out)} {r['output_tokens_per_s']:>8.1f}" f" {bar(r['tpot_s']['p90'], top_tpot, 16)} {r['tpot_s']['p90']:.4f}")
print() if d["knee"]: a, b = d["knee"] print(f" knee: between c={a} and c={b}, each added request gained less than half " f"of one request's throughput at c={cs[0]}") else: print(" knee: not reached at the levels run; every step still gained at least half " "of one request's throughput") if d["fit"]: a_s, b_s = d["fit"]["a_s"], d["fit"]["b_s"] print(f" TPOT p50 ~ {a_s * 1000:.2f} ms + {b_s * 1000:.3f} ms x c " f"(fitted on c = {', '.join(map(str, d['fit']['levels_used']))})") for c in d["fit"]["levels_used"]: predicted = c / (a_s + b_s * c) if a_s + b_s * c > 0 else float("nan") print(f" c={c:>3}: c / fitted TPOT = {predicted:8.1f} tok/s if every request were " f"always decoding; measured {levels[c]['output_tokens_per_s']:8.1f} tok/s") if b_s > 0: slo = first.get("slo_tpot_s", 0.1) print(f" ceiling as c grows: 1 / b = {1 / b_s:.1f} tok/s; " f"TPOT p50 reaches {slo} s at c = {(slo - a_s) / b_s:.1f}") else: print(" b <= 0: TPOT did not rise with concurrency at these levels, so no ceiling is implied") if not d["fit"]: print(" TPOT fit: needs at least two levels at or below the server's slot count") print(f" operating point (no failures, both p90s within the SLO): " f"{'c=' + str(d['operating_point']) if d['operating_point'] else 'none of the levels run'}") return d
def record_summary(path: str, label: str, levels: dict[int, dict], d: dict) -> None: first = levels[d["levels"][0]] line = { "lab": LAB_ID, "record": "curve-summary", "label": label, "engine": first.get("engine"), "engine_version": first.get("engine_version"), "host": first.get("host"), "quant": first.get("quant"), "model": first.get("model"), "context_length": first.get("context_length"), "server_slots": first.get("server_slots"), "levels": d["levels"], "knee_between": d["knee"], "operating_point": d["operating_point"], "peak_output_tokens_per_s": max(levels[c]["output_tokens_per_s"] for c in d["levels"]), "tpot_fit_ms": ({"a": round(d["fit"]["a_s"] * 1000, 3), "b": round(d["fit"]["b_s"] * 1000, 4)} if d["fit"] else None), "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), } with open(path, "a", encoding="utf-8") as handle: handle.write(json.dumps(line, sort_keys=True) + "\n") print(f" appended a curve-summary line for {label} to {path}")
def print_compare(a: str, b: str, labels: dict[str, dict[int, dict]]) -> int: for name in (a, b): if name not in labels: print(f"No rows for label {name}. Labels present: {', '.join(sorted(labels)) or 'none'}", file=sys.stderr) return 1 shared = sorted(set(labels[a]) & set(labels[b])) if not shared: print("The two labels share no concurrency level.", file=sys.stderr) return 1 ra, rb = labels[a][shared[0]], labels[b][shared[0]] for field in ("model", "max_tokens", "prompt_set", "context_length", "server_slots", "quant", "engine"): if ra.get(field) != rb.get(field): print(f" note: {field} differs: {ra.get(field)} vs {rb.get(field)}") print(f"==> {a} (A) against {b} (B)") head = ["c", "out/s A", "out/s B", "B/A", "goodput A", "goodput B", "TTFT p50 A", "TTFT p50 B", "TTFT p90 A", "TTFT p90 B", "TPOT p50 A", "TPOT p50 B", "cached A", "cached B"] rows = [] for c in shared: x, y = labels[a][c], labels[b][c] ratio = y["output_tokens_per_s"] / x["output_tokens_per_s"] if x["output_tokens_per_s"] else float("nan") rows.append([str(c), f"{x['output_tokens_per_s']:.1f}", f"{y['output_tokens_per_s']:.1f}", f"{ratio:.2f}", f"{x.get('goodput_requests_per_s', 0.0):.3f}", f"{y.get('goodput_requests_per_s', 0.0):.3f}", f"{x['ttft_s']['p50']:.3f}", f"{y['ttft_s']['p50']:.3f}", f"{x['ttft_s']['p90']:.3f}", f"{y['ttft_s']['p90']:.3f}", f"{x['tpot_s']['p50']:.4f}", f"{y['tpot_s']['p50']:.4f}", str(x.get("cached_tokens_mean")), str(y.get("cached_tokens_mean"))]) widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(head)] print(" " + " ".join(h.rjust(w) for h, w in zip(head, widths))) for r in rows: print(" " + " ".join(v.rjust(w) for v, w in zip(r, widths))) return 0
def check(labels: dict[str, dict[int, dict]]) -> int: results = []
def verdict(ok: bool, text: str, level: str = "FAIL") -> None: results.append("PASS" if ok else level) print(f" {'PASS' if ok else level:<4} {text}")
print("==> checking the notebook against the lab's validation rules") sweeps = [name for name, lv in labels.items() if BASELINE_LEVELS <= set(lv) and all(lv[c]["completed"] for c in BASELINE_LEVELS) and lv[1].get("prompt_set") == "mixed"] verdict(bool(sweeps), f"a mixed-prompt sweep with every level of {sorted(BASELINE_LEVELS)} completed: " f"{', '.join(sweeps) or 'none'}") for name in sweeps: lv = labels[name] failed = sum(lv[c]["failed"] for c in lv) verdict(failed == 0, f"{name}: failed requests across all levels = {failed}", "WARN") verdict(all(lv[c].get("token_counts") == "usage" for c in lv), f"{name}: output tokens counted from the server's usage block", "WARN") missing = [k for k in ("host", "quant", "context_length", "server_slots") if lv[1].get(k) in (None, "unknown")] verdict(not missing, f"{name}: context recorded ({', '.join(missing) + ' missing' if missing else 'complete'})", "WARN") shared = [n for n, lv in labels.items() if any(r.get("prompt_set") == "shared-prefix" for r in lv.values())] unique = [n for n, lv in labels.items() if any(r.get("prompt_set") == "unique-prefix" for r in lv.values())] verdict(bool(shared) and bool(unique), f"prefix pair present: shared {shared or 'none'}, unique {unique or 'none'}") for name in shared: for c, r in sorted(labels[name].items()): if r.get("cached_tokens_mean") is None or not r.get("prompt_tokens_mean"): verdict(False, f"{name} c={c}: the server reported no cached-token count", "WARN") continue frac = r["cached_tokens_mean"] / r["prompt_tokens_mean"] verdict(frac > 0.5, f"{name} c={c}: mean cached tokens are {frac:.0%} of the prompt") for name in unique: for c, r in sorted(labels[name].items()): if r.get("cached_tokens_mean") is not None and r.get("prompt_tokens_mean"): frac = r["cached_tokens_mean"] / r["prompt_tokens_mean"] verdict(frac < 0.1, f"{name} c={c}: mean cached tokens are {frac:.0%} of the prompt (control)") fails = results.count("FAIL") print(f" {results.count('PASS')} pass, {results.count('WARN')} warn, {fails} fail") return 1 if fails else 0
def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--labbook", default="labbook.md") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--list", action="store_true", help="list the labels in the notebook") group.add_argument("--label", help="print the curve for one label") group.add_argument("--compare", nargs=2, metavar=("A", "B"), help="compare two labels level by level") group.add_argument("--check", action="store_true", help="run the lab's validation rules") parser.add_argument("--markdown", action="store_true", help="print the curve table as Markdown") parser.add_argument("--record", action="store_true", help="with --label, append a curve-summary line to the notebook") args = parser.parse_args(argv)
labels = by_label(load_records(args.labbook)) if args.list: print(f"==> {len(labels)} label(s) in {args.labbook}") for name, lv in labels.items(): r = lv[max(lv)] print(f" {name:<28} {r.get('engine', '?'):<10} {r.get('prompt_set', '?'):<14} " f"levels {','.join(map(str, sorted(lv)))} last {r.get('recorded_at', '?')}") return 0 if args.check: return check(labels) if args.compare: return print_compare(args.compare[0], args.compare[1], labels) if args.label not in labels: print(f"No rows for label {args.label}. Labels present: {', '.join(sorted(labels)) or 'none'}", file=sys.stderr) return 1 d = print_curve(args.label, labels[args.label], args.markdown) if args.record and d["levels"]: record_summary(args.labbook, args.label, labels[args.label], d) return 0
if __name__ == "__main__": sys.exit(main(sys.argv[1:]))RunnableAll tracks
python3 summarise-load.py --label llama-baseline --recordOutput — what you should see
==> llama-baseline: llama.cpp version: x.x.x (build xxxxx, commit xxxxxxx), model local-chat, prompt set mixed, max_tokens 128 host Track ..., quant Q4_K_M, context 4096, server slots 20, SLO TTFT <= 2.0 s, TPOT <= 0.1 s c ok out tok/s scaling req/s goodput TTFT p50 TTFT p90 TPOT p50 TPOT p90 in flight cached 1 40/40 xx.x 1.00 x.xxx x.xxx x.xxx x.xxx x.xxxx x.xxxx 1.0 x.x 5 40/40 xxx.x x.xx x.xxx x.xxx x.xxx x.xxx x.xxxx x.xxxx x.x x.x 10 40/40 xxx.x x.xx x.xxx x.xxx x.xxx x.xxx x.xxxx x.xxxx x.x x.x 20 80/80 xxx.x x.xx x.xxx x.xxx x.xxx x.xxx x.xxxx x.xxxx xx.x x.x
output tokens/s TPOT p90 (s) c= 1 #####................... xx.x ####............ x.xxxx ... knee: between c=x and c=xx, each added request gained less than half of one request's throughput at c=1 TPOT p50 ~ xx.xx ms + x.xxx ms x c (fitted on c = 1, 5, 10, 20) c= 1: c / fitted TPOT = xx.x tok/s if every request were always decoding; measured xx.x tok/s ... ceiling as c grows: 1 / b = xxxx.x tok/s; TPOT p50 reaches 0.1 s at c = xx.x operating point (no failures, both p90s within the SLO): c=xx appended a curve-summary line for llama-baseline to labbook.mdThe script reduces the curve to four derived quantities, and each has a mechanism behind it.
Scaling is output throughput at c divided by c times the throughput at c=1. It is 1.00 when every added request is free and falls as the batch stops being free.
The TPOT fit models a decode step as a fixed cost plus a cost per sequence in the batch:
TPOT(c) ≈ a + b × c. a is roughly one sequence’s step, set by bandwidth as Task 1 computed; b is
what each extra sequence adds, mostly compute and cache traffic. It follows that throughput is
c ÷ (a + b × c), which rises towards a ceiling of 1 ÷ b and never reaches it, and that TPOT meets
an objective s at c = (s − a) ÷ b.
The knee is the first step between levels where each added request gained less than half of what one request produced alone.
The operating point is the highest level with no failures and both ninetieth percentiles inside the objective. It can sit past the knee: the knee is about efficiency, the operating point about what users feel.
Worked through with illustrative inputs, not a measurement, a = 20 ms and b = 1.5 ms:
| c | TPOT = a + b·c | c ÷ TPOT, tok/s | Scaling | Gain per added request, tok/s |
|---|---|---|---|---|
| 1 | 21.5 ms | 46.5 | 1.00 | |
| 5 | 27.5 ms | 181.8 | 0.78 | (181.8 − 46.5) ÷ 4 = 33.8 |
| 10 | 35.0 ms | 285.7 | 0.61 | (285.7 − 181.8) ÷ 5 = 20.8, below half of 46.5: knee between 5 and 10 |
| 20 | 50.0 ms | 400.0 | 0.43 | (400.0 − 285.7) ÷ 10 = 11.4 |
| any | approaches 1.5 ms · c | ceiling 1 ÷ b = 666.7 | ||
| 53 | 100 ms, the objective: c = (100 − 20) ÷ 1.5 |
However many requests that machine admits, its output never reaches the ceiling in the table, and its
median TPOT meets a 0.1 s objective at about c = 53. Measured output
throughput sits below c ÷ TPOT because it also pays for prefill, queueing and the drain at the end
of each level; a gap that widens sharply at one level is where admission, not compute, became the
limit.
Read your table against the shapes below. Each has a signature in two or three columns, which is why the generator records them all.
| Shape in your table | Mechanism | What to try |
|---|---|---|
| Throughput rises, TPOT p50 nearly flat, scaling above 0.8 | Decode steps are still bandwidth-bound; the extra sequences ride along | Nothing: you are below the knee; the next level is also cheap |
| Throughput flattens, TPOT rises in step with c | Compute per step is now the limit: b × c dominates a |
Operate at or below the knee; more slots will not help |
TTFT p90 climbs much faster than TPOT, requests_deferred above 0 |
Requests wait for a slot or for the block pool before prefill starts | More slots if memory allows (Task 7), or accept the queue |
| TPOT p90 far above p50 at high c | Other requests’ prefills are interleaved with your decode steps | On vLLM a smaller --max-num-batched-tokens; shorter prompts |
Throughput at 20 below 10, in flight normal |
Past saturation: scheduling and cache pressure cost more than batching gains; on vLLM, preemption | Record it; Task 9 shows the vLLM counter that confirms it |
in flight well below c, a Python process at a full core |
The client, not the server, is the limit | Run load-test.py on another machine and repeat |
Fill in the recording sheet from the table the script printed; --markdown prints the same table in
Markdown for the notebook.
| Concurrency | Output tokens/s | Scaling | Goodput req/s | TTFT p50 s | TTFT p90 s | TPOT p50 s | TPOT p90 s |
|---|---|---|---|---|---|---|---|
| 1 | — | 1.00 | — | — | — | — | — |
| 5 | — | — | — | — | — | — | — |
| 10 | — | — | — | — | — | — | — |
| 20 | — | — | — | — | — | — | — |
your machine: track, chip and memory, your operating system and version · llama.cpp llama-server LLAMA_VERSION from lab.env · Qwen3-8B, Q4_K_M, KV cache as in lab.env · 4,096 tokens of context · the date you ran it
Empty on purpose: fill it from summarise-load.py. Beside it, write the fitted a and b, the knee, the operating point, and the Task 1 aggregate ceiling at context 100 for comparison with your measured peak.
Record: the curve-summary line the script appended, plus one sentence: the knee, the operating
point, and how the measured peak compares with Task 1’s bandwidth ceiling.
6. Prefix caching, measured
Section titled “6. Prefix caching, measured”Prefix caching skips prefill for the part of a prompt the server has already computed. Each engine decides what “already computed” means differently:
| Engine | What is kept | What a new request reuses |
|---|---|---|
| llama-server v0.4.0 | Each slot keeps its last prompt’s KV (--cache-prompt, on by default) |
The longest common prefix with the slot it is assigned; a slot is chosen by prompt similarity (--slot-prompt-similarity, default 0.10). A slot that has never seen the preamble reuses nothing |
| vLLM 0.28.0 | Full 16-token blocks, hashed with everything before them, in the shared pool (enable_prefix_caching defaults to True) |
Every leading full block whose hash matches, from any earlier request; never a partial block |
| mlx_lm.server 0.31.3 | Up to --prompt-cache-size (default 10) finished caches, least recently used evicted |
The nearest cached prompt’s matching prefix |
Time to first token is where the saving appears:
Pseudocode — not a real command
TTFT ≈ queueing + (prompt tokens − cached tokens) ÷ prefill rate + one decode stepWith illustrative inputs, not a measurement: a 1,390-token prompt at a prefill rate of 1,000 tokens
per second costs about 1.39 s cold. If 1,376 tokens are cached, 14 are left, about 0.014 s plus one
decode step. On vLLM the cached count is a multiple of 16. Your own prefill rate is the pp512 figure
from Part 6’s lab. Decode does not change,
because the cache still has to be read at every step.
The shared-prefix set puts an identical 1,365-token preamble (a system prompt and a tool schema,
counted with the Qwen3 tokeniser) before one of eight short questions. The unique-prefix set is
the control: the same text behind a random first line, different for every request, so the prompts
are the same length and nothing past the chat template’s opening tokens can be reused. Run the
control first:
RunnableAll tracks
cd ~/llm-coursesource lab.envpython3 load-test.py --base-url http://127.0.0.1:8080/v1 --model local-chat \ --concurrency 1,10 --requests 40 --max-tokens 64 --prompt-set unique-prefix \ --engine llama.cpp --engine-version "$LLAMA_VERSION" --host-desc "$HOST_DESC" \ --quant Q4_K_M --context-length 4096 --server-slots "$SLOTS" \ --label llama-prefix-unique --labbook labbook.mdpython3 load-test.py --base-url http://127.0.0.1:8080/v1 --model local-chat \ --concurrency 1,10 --requests 40 --max-tokens 64 --prompt-set shared-prefix \ --engine llama.cpp --engine-version "$LLAMA_VERSION" --host-desc "$HOST_DESC" \ --quant Q4_K_M --context-length 4096 --server-slots "$SLOTS" \ --label llama-prefix-shared --labbook labbook.mdpython3 summarise-load.py --compare llama-prefix-unique llama-prefix-sharedOutput — what you should see
... c= 1 ok 40/40 fail 0 ... TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx goodput x.xxx req/s (xx/40 within SLO) in flight 1.0 prompt 14xx.x tok, cached x.x tokens from usage finish {'length': 40}... c= 1 ok 40/40 fail 0 ... TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx goodput x.xxx req/s (xx/40 within SLO) in flight 1.0 prompt 13xx.x tok, cached 13xx.x tokens from usage finish {'length': 40}... note: prompt_set differs: unique-prefix vs shared-prefix==> llama-prefix-unique (A) against llama-prefix-shared (B) c out/s A out/s B B/A goodput A goodput B TTFT p50 A TTFT p50 B TTFT p90 A TTFT p90 B TPOT p50 A TPOT p50 B cached A cached B 1 xx.x xx.x x.xx x.xxx x.xxx x.xxx x.xxx x.xxx x.xxx x.xxxx x.xxxx x.x 13xx.x 10 xxx.x xxx.x x.xx x.xxx x.xxx x.xxx x.xxx x.xxx x.xxx x.xxxx x.xxxx x.x 1xxx.xWhat the mechanism predicts, and what each deviation means:
| Observation | Prediction | If it does not hold |
|---|---|---|
| Cached tokens, control | Single digits at both levels | Higher: the tag is not first, or the file is an old copy of load-test.py |
| Cached tokens, shared, c=1 | Mean a little below the prompt length: every request but the first reuses the preamble | Near zero: prompt caching is off, or the server restarted between runs |
| Cached tokens, shared, c=10 | Lower mean than at c=1 on llama-server: the first requests land on slots that never saw the preamble | Equal to c=1 is what vLLM should show, because its blocks are shared across the pool |
| TTFT p50, shared against control | Falls by roughly the prefill time of the cached tokens | Unchanged: compare the cached column first; if cached is high and TTFT did not fall, prefill was not your TTFT’s main cost (queueing was) |
| TTFT p90, shared, c=10 | Still high on llama-server: the cold-slot requests are the tail | |
| TPOT p50 | About the same in both runs | Much lower in the shared run: the control’s long prefills were interrupting decodes at c=10, which is Task 5’s interleaving row |
The engine keeps its own count, which should agree with the client’s:
RunnableAll tracks
curl -s http://127.0.0.1:8080/metrics | grep -E '^llamacpp:prompt_tokens_(total|cached_total)'Output — what you should see
llamacpp:prompt_tokens_total xxxxxxllamacpp:prompt_tokens_cached_total xxxxxxThe metric’s own HELP line in v0.4.0 describes prompt_tokens_total as prompt tokens processed
“excluding cached tokens”, so the cached share is cached ÷ (cached + total) since the server started. The control
is also the demonstration of the batching lesson’s warning: a variable first line destroys prefix
reuse however much stable text follows it.
Record: the comparison table, and two sentences: the change in TTFT p50 at c=1 with the cached tokens that explain it, and why c=10 differs.
7. Slots, context and cache type: one change at a time
Section titled “7. Slots, context and cache type: one change at a time”Three settings decide what llama-server allocates and how many sequences it batches. Predict each before running it; the KV sizes are Task 1’s formula.
| Variant | Command change | KV size predicted (f16 tier) | What the c=20 level should do |
|---|---|---|---|
| Fewer slots | SLOTS=8 |
8 × 4,096 × 147,456 B = 4608.00 MiB | TPOT p50 falls (batch of 8), TTFT p90 climbs, requests_deferred reaches 12 |
| Less context per slot | CTX_PER_SLOT=2048 |
20 × 2,048 × 147,456 B = 5760.00 MiB | Nearly unchanged: these prompts use about 170 tokens of each slot, so half the reservation was never used |
| The other cache type | CACHE_TYPE=$ALT_KV_TYPE |
q8_0: 6120.00 MiB; q4_0 on the 16 GB tiers: 20 × 4,096 × 41,472 B = 3240.00 MiB | Memory halves; TPOT may rise or fall: fewer bytes to read per step against the work of dequantising them |
On the eight-bit tiers the first two rows’ sizes are 2448.00 MiB and 3060.00 MiB; with Track M’s 10 slots, variants B and C are 1530.00 MiB and 1620.00 MiB. Run each variant as two blocks, stopping the running server in terminal 1 with Ctrl-C first.
RunnableAll tracks
cd ~/llm-course && source lab.envSLOTS=8 CACHE_TYPE=$KV_TYPE MODEL=$GGUF LLAMA_BIN=$LLAMA_BIN bash serve-llama-parallel.shRunnableAll tracks
cd ~/llm-course && source lab.envgrep -E "llama_kv_cache: size|n_slots" llama-server.logpython3 load-test.py --base-url http://127.0.0.1:8080/v1 --model local-chat \ --concurrency 20 --requests 80 --max-tokens 128 --prompt-set mixed \ --engine llama.cpp --engine-version "$LLAMA_VERSION" --host-desc "$HOST_DESC" \ --quant Q4_K_M --context-length 4096 --server-slots 8 \ --label llama-slots8 --labbook labbook.mdRunnableAll tracks
cd ~/llm-course && source lab.envSLOTS=$SLOTS CTX_PER_SLOT=2048 CACHE_TYPE=$KV_TYPE MODEL=$GGUF LLAMA_BIN=$LLAMA_BIN bash serve-llama-parallel.shRunnableAll tracks
cd ~/llm-course && source lab.envgrep -E "llama_kv_cache: size|n_slots" llama-server.logpython3 load-test.py --base-url http://127.0.0.1:8080/v1 --model local-chat \ --concurrency 20 --requests 80 --max-tokens 128 --prompt-set mixed \ --engine llama.cpp --engine-version "$LLAMA_VERSION" --host-desc "$HOST_DESC" \ --quant Q4_K_M --context-length 2048 --server-slots "$SLOTS" \ --label llama-ctx2048 --labbook labbook.mdRunnableAll tracks
cd ~/llm-course && source lab.envSLOTS=$SLOTS CACHE_TYPE=$ALT_KV_TYPE MODEL=$GGUF LLAMA_BIN=$LLAMA_BIN bash serve-llama-parallel.shRunnableAll tracks
cd ~/llm-course && source lab.envgrep -E "llama_kv_cache: size|n_slots" llama-server.logpython3 load-test.py --base-url http://127.0.0.1:8080/v1 --model local-chat \ --concurrency 20 --requests 80 --max-tokens 128 --prompt-set mixed \ --engine llama.cpp --engine-version "$LLAMA_VERSION" --host-desc "$HOST_DESC" \ --quant "Q4_K_M, KV $ALT_KV_TYPE" --context-length 4096 --server-slots "$SLOTS" \ --label llama-kv-alt --labbook labbook.mdOutput — what you should see
x.xx.xxx.xxx I llama_kv_cache: size = xxxx.00 MiB ( xxxx cells, 36 layers, xx/xx seqs), K (xxxx): xxxx.00 MiB, V (xxxx): xxxx.00 MiBx.xx.xxx.xxx I srv load_model: initializing, n_slots = xx, n_ctx_slot = xxxx, kv_unified = 'false'==> llama-slots8: local-chat at http://127.0.0.1:8080/v1... c= 20 ok 80/80 fail 0 wall xx.xxs out/s xxx.xx req/s x.xxx TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxx...Compare each against the baseline’s c=20 row with python3 summarise-load.py --compare llama-baseline llama-slots8 (and the same for llama-ctx2048 and llama-kv-alt). Leave the server running in the
baseline configuration afterwards if you are on Track X without vLLM; otherwise stop it before Task 8.
Record: for each variant, the predicted and logged KV size, and the c=20 row beside the baseline’s, with one sentence saying whether the prediction held.
8. The same load against the other engine
Section titled “8. The same load against the other engine”Hold five things equal or the comparison says nothing: the model family and size, the context per sequence (4,096), the concurrency cap (20), a KV pool of the same number of tokens, and the same client, prompts and levels. Two things cannot be equal and go in the notebook instead: the weight format, because a Q4_K_M GGUF and an AWQ checkpoint are not the same bytes (Task 1 put the difference at about a gigabyte), and startup time.
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
#!/usr/bin/env bash# Purpose: start vLLM from NVIDIA's container image with the settings this lab measures,# publishing the port on the loopback address only, mounting the model library# read-only, and copying the log to a file on the host# Platform: spark (NVIDIA's NGC vLLM image, the documented path on DGX Spark); usable on# nvidia with IMAGE overridden. Track X uses serve-vllm-native.sh; Track M has no# vLLM path# Minimum memory: 16 GB of accelerator-visible memory for the four-bit 8B checkpoint# Assumes: docker able to reach the GPU (--gpus all), the image already pulled, the# checkpoint downloaded under $MODELS_DIR on the host, nothing else answering on# $PORT; runs in the foreground, and Ctrl-C or "docker rm -f vllm-lab" stops it## Usage: KV_BYTES=12079595520 bash serve-vllm-container.sh# MAX_SEQS=8 KV_BYTES=12079595520 bash serve-vllm-container.sh# KV_BYTES= MEM_FRACTION=0.30 bash serve-vllm-container.sh## Environment:# IMAGE container image (default: nvcr.io/nvidia/vllm:26.08-py3)# MODELS_DIR host model library, mounted at /models (default: $HOME/models)# MODEL checkpoint path inside the container (default: /models/Qwen/Qwen3-8B-AWQ)# SERVED_NAME model name clients send (default: local-chat)# PORT host port, bound to 127.0.0.1 (default: 8000)# CTX, MAX_SEQS, KV_BYTES, MEM_FRACTION, KV_DTYPE, PREFIX_CACHING# as in serve-vllm-native.sh (defaults: 4096, 20, empty, 0.90,# auto, on)# CONTAINER_NAME container name (default: vllm-lab)# LOGFILE copy of the server log on the host (default: ./vllm-server.log)## On a unified-memory machine --gpu-memory-utilization is a share of memory the operating# system also uses, so this lab sets KV_BYTES instead wherever it can. --ipc=host: vLLM's# Docker documentation says PyTorch needs the host's shared memory to pass data between# processes.
set -euo pipefail
IMAGE="${IMAGE:-nvcr.io/nvidia/vllm:26.08-py3}"MODELS_DIR="${MODELS_DIR:-$HOME/models}"MODEL="${MODEL:-/models/Qwen/Qwen3-8B-AWQ}"SERVED_NAME="${SERVED_NAME:-local-chat}"PORT="${PORT:-8000}"CTX="${CTX:-4096}"MAX_SEQS="${MAX_SEQS:-20}"KV_BYTES="${KV_BYTES:-}"MEM_FRACTION="${MEM_FRACTION:-0.90}"KV_DTYPE="${KV_DTYPE:-auto}"PREFIX_CACHING="${PREFIX_CACHING:-on}"CONTAINER_NAME="${CONTAINER_NAME:-vllm-lab}"LOGFILE="${LOGFILE:-./vllm-server.log}"
die() { echo "serve-vllm-container: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || die "docker is not on PATH"docker image inspect "$IMAGE" >/dev/null 2>&1 || die "image $IMAGE not present; run: docker pull $IMAGE"case "$MODEL" in /models/*) [ -f "$MODELS_DIR/${MODEL#/models/}/config.json" ] \ || die "$MODELS_DIR/${MODEL#/models/}/config.json not found; download the checkpoint first" ;;esaccase "$PREFIX_CACHING" in on) PREFIX_FLAG="--enable-prefix-caching" ;; off) PREFIX_FLAG="--no-enable-prefix-caching" ;; *) die "PREFIX_CACHING must be on or off" ;;esacif [ -n "$KV_BYTES" ]; then MEMORY_FLAG="--kv-cache-memory-bytes" MEMORY_VALUE="$KV_BYTES"else MEMORY_FLAG="--gpu-memory-utilization" MEMORY_VALUE="$MEM_FRACTION"fiif command -v curl >/dev/null 2>&1 \ && curl --silent --max-time 2 --output /dev/null "http://127.0.0.1:$PORT/"; then die "something is already answering on 127.0.0.1:$PORT; stop it first (one server at a time)"fiif docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then die "a container named $CONTAINER_NAME exists; remove it with: docker rm -f $CONTAINER_NAME"fi
cat <<INFO==> vllm serve, in a container image $IMAGE model $MODEL (host: $MODELS_DIR, read-only) served as $SERVED_NAME published on http://127.0.0.1:$PORT/v1 max model length $CTX tokens per sequence max sequences $MAX_SEQS KV cache memory $MEMORY_FLAG $MEMORY_VALUE KV cache dtype $KV_DTYPE prefix caching $PREFIX_CACHING log copied to $LOGFILE Stop it with Ctrl-C, or from another terminal: docker rm -f $CONTAINER_NAMEINFO
docker run --rm \ --name "$CONTAINER_NAME" \ --gpus all \ --ipc=host \ -v "$MODELS_DIR:/models:ro" \ -p "127.0.0.1:$PORT:8000" \ "$IMAGE" \ vllm serve "$MODEL" \ --host 0.0.0.0 \ --port 8000 \ --served-model-name "$SERVED_NAME" \ --max-model-len "$CTX" \ --max-num-seqs "$MAX_SEQS" \ "$MEMORY_FLAG" "$MEMORY_VALUE" \ --kv-cache-dtype "$KV_DTYPE" \ "$PREFIX_FLAG" \ --enable-prompt-tokens-details 2>&1 | tee "$LOGFILE"RunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envIMAGE=$VLLM_IMAGE MODEL=$VLLM_MODEL KV_BYTES=$VLLM_KV_BYTES KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-container.shRunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envgrep -E "Model loading took|memory for KV Cache|Available KV cache memory|GPU KV cache size|Application startup complete" vllm-server.logexport VLLM_VERSION="$(docker exec vllm-lab vllm --version | tail -n 1)"; echo "$VLLM_VERSION"Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
#!/usr/bin/env bash# Purpose: start vLLM as an OpenAI-compatible server with the settings this lab measures -# context per sequence, sequence cap, KV cache size or memory fraction, KV cache# dtype and prefix caching - printing the configuration first and copying the log# to a file, so the run is reproducible from the terminal and searchable afterwards# Platform: nvidia and strix (a native vLLM install in an activated virtual environment);# Track S uses serve-vllm-container.sh, Track M has no vLLM path# Minimum memory: 16 GB of accelerator-visible memory for the four-bit 8B checkpoint# Assumes: vllm on PATH (the install lesson's environment, activated), the checkpoint in# $MODEL (a local directory with config.json, or a Hugging Face repository id that# vLLM downloads), nothing else answering on $PORT; runs in the foreground## Usage: MODEL=$HOME/models/Qwen/Qwen3-8B-AWQ KV_BYTES=12079595520 bash serve-vllm-native.sh# MAX_SEQS=8 MODEL=... bash serve-vllm-native.sh# KV_BYTES= MEM_FRACTION=0.80 MODEL=... bash serve-vllm-native.sh## Environment:# MODEL checkpoint directory or repository id (default: $HOME/models/Qwen/Qwen3-8B-AWQ)# SERVED_NAME model name clients send (default: local-chat)# HOST address to bind (default: 127.0.0.1)# PORT port to listen on (default: 8000)# CTX --max-model-len, tokens per sequence (default: 4096)# MAX_SEQS --max-num-seqs, sequences in flight (default: 20)# KV_BYTES --kv-cache-memory-bytes; empty to size the cache from MEM_FRACTION# (default: empty)# MEM_FRACTION --gpu-memory-utilization, used when KV_BYTES is empty (default: 0.90)# KV_DTYPE --kv-cache-dtype: auto (the model's dtype) or fp8 (default: auto)# PREFIX_CACHING on or off (default: on, vLLM 0.28.0's default)# TP_SIZE --tensor-parallel-size (default: 1)# LOGFILE copy of the server log (default: ./vllm-server.log)## Read from vLLM 0.28.0's source: kv_cache_memory_bytes, when set, "ignores# gpu_memory_utilization" (config/cache.py), and the worker then logs "reserved ... GiB memory# for KV Cache as specified by kv_cache_memory_bytes config and skipped memory profiling";# without it the worker logs "Available KV cache memory: N GiB". Either way the engine logs# "GPU KV cache size: N tokens, Maximum concurrency for N tokens per request: N.NNx".# enable_prefix_caching defaults to True. --enable-prompt-tokens-details adds# usage.prompt_tokens_details.cached_tokens to responses, which load-test.py records.
set -euo pipefail
MODEL="${MODEL:-$HOME/models/Qwen/Qwen3-8B-AWQ}"SERVED_NAME="${SERVED_NAME:-local-chat}"HOST="${HOST:-127.0.0.1}"PORT="${PORT:-8000}"CTX="${CTX:-4096}"MAX_SEQS="${MAX_SEQS:-20}"KV_BYTES="${KV_BYTES:-}"MEM_FRACTION="${MEM_FRACTION:-0.90}"KV_DTYPE="${KV_DTYPE:-auto}"PREFIX_CACHING="${PREFIX_CACHING:-on}"TP_SIZE="${TP_SIZE:-1}"LOGFILE="${LOGFILE:-./vllm-server.log}"
die() { echo "serve-vllm-native: $*" >&2; exit 1; }
command -v vllm >/dev/null 2>&1 \ || die "vllm is not on PATH; activate the environment from the install lesson first"case "$MODEL" in /*|./*|../*) [ -f "$MODEL/config.json" ] || die "$MODEL/config.json not found; download the checkpoint first" ;;esaccase "$PREFIX_CACHING" in on) PREFIX_FLAG="--enable-prefix-caching" ;; off) PREFIX_FLAG="--no-enable-prefix-caching" ;; *) die "PREFIX_CACHING must be on or off" ;;esacif [ -n "$KV_BYTES" ]; then MEMORY_FLAG="--kv-cache-memory-bytes" MEMORY_VALUE="$KV_BYTES"else MEMORY_FLAG="--gpu-memory-utilization" MEMORY_VALUE="$MEM_FRACTION"fiif command -v curl >/dev/null 2>&1 \ && curl --silent --max-time 2 --output /dev/null "http://$HOST:$PORT/"; then die "something is already answering on $HOST:$PORT; stop it first (one server at a time)"fiif [ "$HOST" != "127.0.0.1" ] && [ "$HOST" != "localhost" ]; then echo "NOTE: binding to $HOST exposes an unauthenticated generation endpoint." >&2 echo " Set an API key and put it behind TLS before doing this on a real network." >&2fi
echo "==> vllm serve, $(vllm --version 2>/dev/null | tail -n 1)"cat <<INFO model $MODEL served as $SERVED_NAME listening on http://$HOST:$PORT/v1 max model length $CTX tokens per sequence max sequences $MAX_SEQS KV cache memory $MEMORY_FLAG $MEMORY_VALUE KV cache dtype $KV_DTYPE prefix caching $PREFIX_CACHING tensor parallel $TP_SIZE log copied to $LOGFILE The first start profiles memory and captures CUDA graphs; wait for "Application startup complete." before sending requests.INFO
# The log goes to the terminal and to $LOGFILE; exec keeps this shell's pid for the server.exec > >(tee "$LOGFILE") 2>&1exec vllm serve "$MODEL" \ --host "$HOST" \ --port "$PORT" \ --served-model-name "$SERVED_NAME" \ --max-model-len "$CTX" \ --max-num-seqs "$MAX_SEQS" \ "$MEMORY_FLAG" "$MEMORY_VALUE" \ --kv-cache-dtype "$KV_DTYPE" \ "$PREFIX_FLAG" \ --tensor-parallel-size "$TP_SIZE" \ --enable-prompt-tokens-detailsRunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"MODEL=$VLLM_MODEL KV_BYTES=$VLLM_KV_BYTES KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shRunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.envgrep -E "Model loading took|memory for KV Cache|Available KV cache memory|GPU KV cache size|Application startup complete" vllm-server.logexport VLLM_VERSION="$("$VLLM_VENV/bin/vllm" --version | tail -n 1)"; echo "$VLLM_VERSION"The bf16 weights make vLLM read about three times the bytes per token that llama-server’s Q4_K_M does, so its c=1 row should be the slower one before any batching difference appears.
Track M — Apple siliconPartial
vLLM has no mainline Apple silicon path; the comparison on this track is llama-server against mlx_lm.server, which batches but allocates its cache on demand, so there is no equal pool to set.
RunnableTrack M · Apple silicon
cd ~/llm-course && source lab.envPORT=8081 PROMPT_CACHE_SIZE=10 MODEL=$MLX_MODEL bash serve-mlx.shOutput — what you should see
==> mlx_lm.server mlx-lm 0.31.3 / mlx x.xx.x model /Users/you/models/mlx-community/Qwen3-8B-4bit... send "model": "/Users/you/models/mlx-community/Qwen3-8B-4bit" in every request... Starting httpd at 127.0.0.1 on port 8081...The MLX server resolves a request’s model field as a model to load, so every command below
sends the absolute path, $MLX_MODEL. PROMPT_CACHE_SIZE=10 restores the server’s default,
which Part 8’s script lowers to 1 for its own measurement.
Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
#!/usr/bin/env bash# Purpose: start vLLM as an OpenAI-compatible server with the settings this lab measures -# context per sequence, sequence cap, KV cache size or memory fraction, KV cache# dtype and prefix caching - printing the configuration first and copying the log# to a file, so the run is reproducible from the terminal and searchable afterwards# Platform: nvidia and strix (a native vLLM install in an activated virtual environment);# Track S uses serve-vllm-container.sh, Track M has no vLLM path# Minimum memory: 16 GB of accelerator-visible memory for the four-bit 8B checkpoint# Assumes: vllm on PATH (the install lesson's environment, activated), the checkpoint in# $MODEL (a local directory with config.json, or a Hugging Face repository id that# vLLM downloads), nothing else answering on $PORT; runs in the foreground## Usage: MODEL=$HOME/models/Qwen/Qwen3-8B-AWQ KV_BYTES=12079595520 bash serve-vllm-native.sh# MAX_SEQS=8 MODEL=... bash serve-vllm-native.sh# KV_BYTES= MEM_FRACTION=0.80 MODEL=... bash serve-vllm-native.sh## Environment:# MODEL checkpoint directory or repository id (default: $HOME/models/Qwen/Qwen3-8B-AWQ)# SERVED_NAME model name clients send (default: local-chat)# HOST address to bind (default: 127.0.0.1)# PORT port to listen on (default: 8000)# CTX --max-model-len, tokens per sequence (default: 4096)# MAX_SEQS --max-num-seqs, sequences in flight (default: 20)# KV_BYTES --kv-cache-memory-bytes; empty to size the cache from MEM_FRACTION# (default: empty)# MEM_FRACTION --gpu-memory-utilization, used when KV_BYTES is empty (default: 0.90)# KV_DTYPE --kv-cache-dtype: auto (the model's dtype) or fp8 (default: auto)# PREFIX_CACHING on or off (default: on, vLLM 0.28.0's default)# TP_SIZE --tensor-parallel-size (default: 1)# LOGFILE copy of the server log (default: ./vllm-server.log)## Read from vLLM 0.28.0's source: kv_cache_memory_bytes, when set, "ignores# gpu_memory_utilization" (config/cache.py), and the worker then logs "reserved ... GiB memory# for KV Cache as specified by kv_cache_memory_bytes config and skipped memory profiling";# without it the worker logs "Available KV cache memory: N GiB". Either way the engine logs# "GPU KV cache size: N tokens, Maximum concurrency for N tokens per request: N.NNx".# enable_prefix_caching defaults to True. --enable-prompt-tokens-details adds# usage.prompt_tokens_details.cached_tokens to responses, which load-test.py records.
set -euo pipefail
MODEL="${MODEL:-$HOME/models/Qwen/Qwen3-8B-AWQ}"SERVED_NAME="${SERVED_NAME:-local-chat}"HOST="${HOST:-127.0.0.1}"PORT="${PORT:-8000}"CTX="${CTX:-4096}"MAX_SEQS="${MAX_SEQS:-20}"KV_BYTES="${KV_BYTES:-}"MEM_FRACTION="${MEM_FRACTION:-0.90}"KV_DTYPE="${KV_DTYPE:-auto}"PREFIX_CACHING="${PREFIX_CACHING:-on}"TP_SIZE="${TP_SIZE:-1}"LOGFILE="${LOGFILE:-./vllm-server.log}"
die() { echo "serve-vllm-native: $*" >&2; exit 1; }
command -v vllm >/dev/null 2>&1 \ || die "vllm is not on PATH; activate the environment from the install lesson first"case "$MODEL" in /*|./*|../*) [ -f "$MODEL/config.json" ] || die "$MODEL/config.json not found; download the checkpoint first" ;;esaccase "$PREFIX_CACHING" in on) PREFIX_FLAG="--enable-prefix-caching" ;; off) PREFIX_FLAG="--no-enable-prefix-caching" ;; *) die "PREFIX_CACHING must be on or off" ;;esacif [ -n "$KV_BYTES" ]; then MEMORY_FLAG="--kv-cache-memory-bytes" MEMORY_VALUE="$KV_BYTES"else MEMORY_FLAG="--gpu-memory-utilization" MEMORY_VALUE="$MEM_FRACTION"fiif command -v curl >/dev/null 2>&1 \ && curl --silent --max-time 2 --output /dev/null "http://$HOST:$PORT/"; then die "something is already answering on $HOST:$PORT; stop it first (one server at a time)"fiif [ "$HOST" != "127.0.0.1" ] && [ "$HOST" != "localhost" ]; then echo "NOTE: binding to $HOST exposes an unauthenticated generation endpoint." >&2 echo " Set an API key and put it behind TLS before doing this on a real network." >&2fi
echo "==> vllm serve, $(vllm --version 2>/dev/null | tail -n 1)"cat <<INFO model $MODEL served as $SERVED_NAME listening on http://$HOST:$PORT/v1 max model length $CTX tokens per sequence max sequences $MAX_SEQS KV cache memory $MEMORY_FLAG $MEMORY_VALUE KV cache dtype $KV_DTYPE prefix caching $PREFIX_CACHING tensor parallel $TP_SIZE log copied to $LOGFILE The first start profiles memory and captures CUDA graphs; wait for "Application startup complete." before sending requests.INFO
# The log goes to the terminal and to $LOGFILE; exec keeps this shell's pid for the server.exec > >(tee "$LOGFILE") 2>&1exec vllm serve "$MODEL" \ --host "$HOST" \ --port "$PORT" \ --served-model-name "$SERVED_NAME" \ --max-model-len "$CTX" \ --max-num-seqs "$MAX_SEQS" \ "$MEMORY_FLAG" "$MEMORY_VALUE" \ --kv-cache-dtype "$KV_DTYPE" \ "$PREFIX_FLAG" \ --tensor-parallel-size "$TP_SIZE" \ --enable-prompt-tokens-detailsRunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"MODEL=$VLLM_MODEL KV_BYTES=$VLLM_KV_BYTES KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shRunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.envgrep -E "Model loading took|memory for KV Cache|Available KV cache memory|GPU KV cache size|Application startup complete" vllm-server.logexport VLLM_VERSION="$("$VLLM_VENV/bin/vllm" --version | tail -n 1)"; echo "$VLLM_VERSION"nvidia-smi --query-gpu=memory.used,memory.total --format=csvOutput — what you should see
... Model loading took x.xx GiB memory and xx.xxxxxx seconds... Initial free memory xx.xx GiB, reserved 11.25 GiB memory for KV Cache as specified by kv_cache_memory_bytes config and skipped memory profiling. ...... GPU KV cache size: 81,920 tokens, Maximum concurrency for 4,096 tokens per request: 20.00xINFO: Application startup complete.0.28.0Those lines are format strings in vLLM 0.28.0’s source; the prefix before them (process, time, file)
varies. On the 16 GB tier the reservation reads 5.62 GiB and the tokens and concurrency are the same.
A GPU KV cache size below 81,920 tokens or a concurrency below 20.00x means the pool is not
equal: stop and check KV_BYTES before measuring. The first start takes minutes (profiling, graph
capture); wait for Application startup complete.
Then the same sweep and prefix pair against port 8000 (8081 and --model "$MLX_MODEL" on Track M):
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envfor set in mixed unique-prefix shared-prefix; do case $set in mixed) levels=1,5,10,20; tokens=128; label=vllm-baseline ;; unique-prefix) levels=1,10; tokens=64; label=vllm-prefix-unique ;; shared-prefix) levels=1,10; tokens=64; label=vllm-prefix-shared ;; esac python3 load-test.py --base-url http://127.0.0.1:8000/v1 --model local-chat \ --concurrency $levels --requests 40 --min-rounds 4 --max-tokens $tokens --prompt-set $set \ --engine vllm --engine-version "$VLLM_VERSION" --host-desc "$HOST_DESC" \ --quant "$VLLM_QUANT" --context-length 4096 --server-slots 20 \ --label $label --labbook labbook.mddoneTrack X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.envfor set in mixed unique-prefix shared-prefix; do case $set in mixed) levels=1,5,10,20; tokens=128; label=vllm-baseline ;; unique-prefix) levels=1,10; tokens=64; label=vllm-prefix-unique ;; shared-prefix) levels=1,10; tokens=64; label=vllm-prefix-shared ;; esac python3 load-test.py --base-url http://127.0.0.1:8000/v1 --model local-chat \ --concurrency $levels --requests 40 --min-rounds 4 --max-tokens $tokens --prompt-set $set \ --engine vllm --engine-version "$VLLM_VERSION" --host-desc "$HOST_DESC" \ --quant "$VLLM_QUANT" --context-length 4096 --server-slots 20 \ --label $label --labbook labbook.mddoneTrack M — Apple silicon
RunnableTrack M · Apple silicon
cd ~/llm-course && source lab.envfor set in mixed unique-prefix shared-prefix; do case $set in mixed) levels=1,5,10,20; tokens=128; label=mlx-baseline ;; unique-prefix) levels=1,10; tokens=64; label=mlx-prefix-unique ;; shared-prefix) levels=1,10; tokens=64; label=mlx-prefix-shared ;; esac python3 load-test.py --base-url http://127.0.0.1:8081/v1 --model "$MLX_MODEL" \ --concurrency $levels --requests 40 --min-rounds 4 --max-tokens $tokens --prompt-set $set \ --engine mlx-lm --engine-version 0.31.3 --host-desc "$HOST_DESC" \ --quant "MLX 4-bit" --context-length 4096 --server-slots 32 \ --label $label --labbook labbook.mddoneReplace 0.31.3 with the version serve-mlx.sh printed if it differs. --server-slots 32 is the
server’s default decode concurrency, not a slot allocation.
Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.envfor set in mixed unique-prefix shared-prefix; do case $set in mixed) levels=1,5,10,20; tokens=128; label=vllm-baseline ;; unique-prefix) levels=1,10; tokens=64; label=vllm-prefix-unique ;; shared-prefix) levels=1,10; tokens=64; label=vllm-prefix-shared ;; esac python3 load-test.py --base-url http://127.0.0.1:8000/v1 --model local-chat \ --concurrency $levels --requests 40 --min-rounds 4 --max-tokens $tokens --prompt-set $set \ --engine vllm --engine-version "$VLLM_VERSION" --host-desc "$HOST_DESC" \ --quant "$VLLM_QUANT" --context-length 4096 --server-slots 20 \ --label $label --labbook labbook.mddoneThe loop runs the three sets in that order, so the prefix control again comes first. The output has the same shape as Tasks 4 and 6. Summarise and compare:
RunnableAll tracks
cd ~/llm-coursesecond=$(python3 summarise-load.py --list | awk '$1 ~ /^(vllm|mlx)-baseline$/ {print $1; exit}')python3 summarise-load.py --label "$second" --recordpython3 summarise-load.py --compare llama-baseline "$second"python3 summarise-load.py --compare "${second%-baseline}-prefix-unique" "${second%-baseline}-prefix-shared"Output — what you should see
==> vllm-baseline: vllm 0.28.0, model local-chat, prompt set mixed, max_tokens 128... note: quant differs: Q4_K_M vs AWQ note: engine differs: llama.cpp vs vllm==> llama-baseline (A) against vllm-baseline (B) c out/s A out/s B B/A goodput A goodput B TTFT p50 A TTFT p50 B ... 1 xx.x xx.x x.xx x.xxx x.xxx x.xxx x.xxx ... 5 xxx.x xxx.x x.xx x.xxx x.xxx x.xxx x.xxx ... 10 xxx.x xxx.x x.xx x.xxx x.xxx x.xxx x.xxx ... 20 xxx.x xxx.x x.xx x.xxx x.xxx x.xxx x.xxx ...Read B/A down the column rather than at one level. At c=1 it is mostly the weight formats and
kernels, which Part 8 measured for one user. How it moves from c=1 to c=20 is the scheduling and memory
management difference this part is about. In the prefix comparison, vLLM’s cached mean at c=10 should
be close to its c=1 value because its blocks are shared across the whole pool; set that beside
llama-server’s per-slot figure from Task 6.
Cross-check the client with vLLM’s own generator at the same concurrency cap:
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envdocker exec vllm-lab vllm bench serve --backend openai-chat --base-url http://127.0.0.1:8000 \ --endpoint /v1/chat/completions --model "$VLLM_MODEL" --served-model-name local-chat \ --dataset-name random --random-input-len 40 --random-output-len 128 --ignore-eos \ --num-prompts 80 --max-concurrency 20 \ --percentile-metrics ttft,tpot,itl --metric-percentiles 50,90 --goodput ttft:2000 tpot:100Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"vllm bench serve --backend openai-chat --base-url http://127.0.0.1:8000 \ --endpoint /v1/chat/completions --model "$VLLM_MODEL" --served-model-name local-chat \ --dataset-name random --random-input-len 40 --random-output-len 128 --ignore-eos \ --num-prompts 80 --max-concurrency 20 \ --percentile-metrics ttft,tpot,itl --metric-percentiles 50,90 --goodput ttft:2000 tpot:100Track M — Apple siliconNot supported
vllm bench serve ships with vLLM, which this track does not install; the client check on Track M is the in-flight column and a second run from another machine.
Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"vllm bench serve --backend openai-chat --base-url http://127.0.0.1:8000 \ --endpoint /v1/chat/completions --model "$VLLM_MODEL" --served-model-name local-chat \ --dataset-name random --random-input-len 40 --random-output-len 128 --ignore-eos \ --num-prompts 80 --max-concurrency 20 \ --percentile-metrics ttft,tpot,itl --metric-percentiles 50,90 --goodput ttft:2000 tpot:100Output — what you should see
============ Serving Benchmark Result ============Successful requests: 80Failed requests: 0Maximum request concurrency: 20Benchmark duration (s): xx.xx...Request throughput (req/s): x.xxRequest goodput (req/s): x.xxOutput token throughput (tok/s): xxx.xx...---------------Time to First Token----------------Mean TTFT (ms): xx.xxMedian TTFT (ms): xx.xxP90 TTFT (ms): xx.xx-----Time per Output Token (excl. 1st token)------...--model names the checkpoint the tool loads its tokeniser from, and --served-model-name the name
it sends. --ignore-eos makes every request generate exactly 128 tokens, which load-test.py gets
from Qwen3’s thinking instead. The two tools use the same TPOT definition, so the median TPOT and
output throughput should agree with your vllm-baseline c=20 row within a few per cent; if
load-test.py reports clearly less throughput, suspect the client. If the command reports a missing
Python module, vLLM’s bench extra (vllm[bench]) adds the optional dependencies.
Record: the engine’s version, its allocation lines, the comparison table, the prefix comparison, and the bench cross-check’s output throughput and median TPOT beside your c=20 row.
9. vLLM: admission and memory fraction
Section titled “9. vLLM: admission and memory fraction”Two more restarts, each with no other change. Admission: --max-num-seqs caps the batch the way
SLOTS did, but the pool stays the same size. Memory fraction: without KV_BYTES, vLLM claims
--gpu-memory-utilization of the device’s total memory, profiles what the weights and activations
take, and gives the rest to the pool. Stop the server in terminal 1 before each start.
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envMAX_SEQS=8 IMAGE=$VLLM_IMAGE MODEL=$VLLM_MODEL KV_BYTES=$VLLM_KV_BYTES KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-container.shRunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envKV_BYTES= MEM_FRACTION=$MEM_FRACTION_A IMAGE=$VLLM_IMAGE MODEL=$VLLM_MODEL KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-container.shRunnableTrack S · DGX Spark
cd ~/llm-course && source lab.envKV_BYTES= MEM_FRACTION=$MEM_FRACTION_B IMAGE=$VLLM_IMAGE MODEL=$VLLM_MODEL KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-container.shTrack X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"MAX_SEQS=8 MODEL=$VLLM_MODEL KV_BYTES=$VLLM_KV_BYTES KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shRunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"KV_BYTES= MEM_FRACTION=$MEM_FRACTION_A MODEL=$VLLM_MODEL KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shRunnableTrack X · Ryzen AI Max+
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"KV_BYTES= MEM_FRACTION=$MEM_FRACTION_B MODEL=$VLLM_MODEL KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shlab.env’s fractions assume the 128 GB machine. On 64 GB, fraction × the GPU-visible total the
device line reported must exceed the 15.26 GiB of bf16 weights with room for a pool; if the log
reports No available memory for the cache blocks, raise both by 0.2 and record the values.
Track M — Apple siliconNot supported
vLLM does not run on this track, and mlx_lm.server allocates its cache on demand, so there is no sequence cap or memory fraction to set; Task 7's slot and cache-type variants are Track M's memory experiments.
Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"MAX_SEQS=8 MODEL=$VLLM_MODEL KV_BYTES=$VLLM_KV_BYTES KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shRunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"KV_BYTES= MEM_FRACTION=$MEM_FRACTION_A MODEL=$VLLM_MODEL KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shRunnableTrack N · NVIDIA GPU
cd ~/llm-course && source lab.env && source "$VLLM_VENV/bin/activate"KV_BYTES= MEM_FRACTION=$MEM_FRACTION_B MODEL=$VLLM_MODEL KV_DTYPE=$VLLM_KV_DTYPE bash serve-vllm-native.shStart the eight-sequence server first, then run the c=20 level against it and sample the queue while it runs:
RunnableAll tracks
cd ~/llm-course && source lab.envpython3 load-test.py --base-url http://127.0.0.1:8000/v1 --model local-chat \ --concurrency 20 --requests 80 --max-tokens 128 --prompt-set mixed \ --engine vllm --engine-version "$VLLM_VERSION" --host-desc "$HOST_DESC" \ --quant "$VLLM_QUANT" --context-length 4096 --server-slots 8 \ --label vllm-seqs8 --labbook labbook.md &sleep 20curl -s http://127.0.0.1:8000/metrics | grep -E '^vllm:(num_requests_running|num_requests_waiting|num_preemptions_total|kv_cache_usage_perc)'waitOutput — what you should see
vllm:num_requests_running{model_name="local-chat",engine="0"} 8.0vllm:num_requests_waiting{model_name="local-chat",engine="0"} 12.0vllm:num_preemptions_total{model_name="local-chat",engine="0"} 0.0vllm:kv_cache_usage_perc{model_name="local-chat",engine="0"} 0.0x... c= 20 ok 80/80 fail 0 wall xx.xxs out/s xxx.xx req/s x.xxx TTFT p50 x.xxx p90 x.xxx TPOT p50 x.xxxx p90 x.xxxxRunning at the cap and waiting at 12 is admission as the limit, with almost all of the pool idle.
The periodic stats line in terminal 1 shows the same (Running: 8 reqs, Waiting: 12 reqs, GPU KV cache usage: x.x%, Prefix cache hit rate: x.x%) and adds Preemptions: N only when there were any. The
warning text vLLM’s optimisation page quotes for a preemption does not appear in the 0.28.0
scheduler’s source; that counter and that field are how preemption shows up.
Then stop it and start fraction A, and later fraction B. After each fraction start, wait for
Application startup complete. and read what the engine made of it:
RunnableAll tracks
cd ~/llm-coursegrep -E "Model loading took|Available KV cache memory|GPU KV cache size" vllm-server.logOutput — what you should see
... Model loading took x.xx GiB memory and xx.xxxxxx seconds... Available KV cache memory: x.xx GiB... GPU KV cache size: xx,xxx tokens, Maximum concurrency for 4,096 tokens per request: xx.xxxThe arithmetic behind the last line, with an illustrative log value rather than a measurement (use 73,728 bytes per token on the fp8 tier):
| Step | Formula | If the log says Available KV cache memory: 7.00 GiB |
|---|---|---|
| Blocks | available bytes ÷ (16 × 147,456) | 7,516,192,768 ÷ 2,359,296 = 3,185 (whole blocks) |
| Tokens | blocks × 16 | 50,960 |
| Maximum concurrency | blocks ÷ blocks per 4,096-token request (256) | 12.44x |
Moving from fraction A to B changes the pool by roughly the difference in fraction times the total,
because the weights and activations do not change. On a unified-memory machine the fraction is a
share of memory the operating system also uses, and vLLM refuses to start when free memory is below
it; that is why lab.env uses smaller fractions on Tracks S and X, and why the equal-pool runs set
bytes instead.
Record: the vllm-seqs8 row beside vllm-baseline at c=20, the metrics sample, and for each
fraction the model memory, available KV memory, tokens and maximum concurrency, with your calculation
from the available memory.
10. Record the results
Section titled “10. Record the results”Everything numeric is already in labbook.md as JSON lines. Two commands turn it into the record a
later part can use:
RunnableAll tracks
cd ~/llm-coursepython3 summarise-load.py --listpython3 summarise-load.py --label llama-baseline --markdownpython3 summarise-load.py --checkOutput — what you should see
==> x label(s) in labbook.md llama-baseline llama.cpp mixed levels 1,5,10,20 last 2026-xx-xxTxx:xx:xx+xxxx llama-prefix-unique llama.cpp unique-prefix levels 1,10 last ... ...| c | ok | out tok/s | scaling | req/s | goodput | TTFT p50 | TTFT p90 | TPOT p50 | TPOT p90 | in flight | cached ||---|---|---|---|---|---|---|---|---|---|---|---|| 1 | 40/40 | xx.x | 1.00 | ... |...==> checking the notebook against the lab's validation rules PASS a mixed-prompt sweep with every level of [1, 5, 10, 20] completed: llama-baseline, vllm-baseline PASS llama-baseline: failed requests across all levels = 0 PASS llama-baseline: output tokens counted from the server's usage block PASS llama-baseline: context recorded (complete) ... PASS prefix pair present: shared ['llama-prefix-shared', 'vllm-prefix-shared'], unique ['llama-prefix-unique', 'vllm-prefix-unique'] PASS llama-prefix-shared c=1: mean cached tokens are xx% of the prompt ... xx pass, 0 warn, 0 failPaste the Markdown tables under a Part 9 heading in the notebook. Beneath them write, in your own
words and with the numbers: the knee and operating point for each engine; the TTFT change prefix
caching produced and the cached tokens behind it; the variant from Task 7 whose result surprised you;
and the configuration you would run a service on, with the objective it meets. The capstone’s
evidence collector reads the JSON lines, and the host, quant and context_length fields now
carry the context that page asks for.
Validate the load generator’s denominator
Section titled “Validate the load generator’s denominator”Begin at one in-flight request. Confirm the response body, token accounting and latency fields before raising concurrency. Then use the same workload at each rung, preserving failures and completed request counts. The configured number of clients is not the number of successful simultaneous users.
Check the generator’s arrival model: a closed-loop client sends replacement work after a request finishes, which can reduce offered load when the server slows. Record this behaviour when interpreting the curve. Include the output-length distribution, since total completion time depends on how much text each model emits.
When the latency target is crossed, stop increasing load and inspect queueing, cache pressure and prefill interference. Choose an admission level below the unacceptable region and repeat a mixed short/long request workload. Finally stop the generator and verify the service returns to its idle state and answers a fresh smoke request. This demonstrates recovery from the experiment rather than leaving a saturated server behind. Keep the raw load file, server configuration and resulting admission recommendation for the gateway project.
Validation
Section titled “Validation”| Criterion | Command | Pass |
|---|---|---|
| A complete baseline curve with counts from the server | python3 summarise-load.py --check |
PASS on the sweep, failed-requests, usage and context lines for llama-baseline |
| The prefix measurement is controlled | same | PASS prefix pair present and every shared-prefix line above 50 %, every control line below 10 % |
| llama-server allocated what Task 1 predicted | grep "llama_kv_cache: size" llama-server.log after restarting the baseline |
the MiB figure from Task 1’s table for your tier |
| The comparison is at equal pool (S, N, X) | grep "GPU KV cache size" vllm-server.log from the equal-pool start |
81,920 tokens and 20.00x |
| A second engine’s curve exists | python3 summarise-load.py --list |
a vllm-baseline or mlx-baseline label with levels 1,5,10,20 (Track X without vLLM: state that in the notebook) |
| Each curve is summarised | grep -c '"record": "curve-summary"' labbook.md |
2 or more (1 on Track X without vLLM) |
| Task 7’s variants ran | python3 summarise-load.py --list |
llama-slots8, llama-ctx2048, llama-kv-alt |
--check exits with status 1 when any line reads FAIL; WARN lines are worth a sentence in the
notebook but do not fail the lab.
Expected outcome
Section titled “Expected outcome”A section of the notebook that describes your machine as a service: a throughput curve and a latency
curve against concurrency for two engines, with medians and ninetieth percentiles; a fitted a and b
for each with the ceiling and the knee they imply, set beside Task 1’s bandwidth ceiling; a controlled
measurement of prefix caching in cached tokens and TTFT; three one-change llama-server experiments
with predictions; and, on vLLM, the admission and memory-fraction readings.
The shapes the mechanism predicts, and which your numbers confirm or contradict:
- The first few concurrent requests are nearly free. Scaling above about 0.8 at c=5 with TPOT barely changed is the argument for a serving engine, and Task 1’s short-context ceiling says bandwidth allows it on every track.
- The curve bends well before twenty on a single accelerator, at the point where
b × cbecomes comparable toa. Twenty is chosen to take you past the knee, not because it is where to operate. - The tail grows faster than the median as concurrency rises, which is why every table here has both.
- Prefix caching moves TTFT and leaves TPOT close to where it was; where TPOT also improved, the control’s long prefills were interfering with decodes.
- At equal pool the engines diverge as concurrency rises. Whether vLLM’s scheduler and block pool
beat llama-server’s slots on your hardware, with your weight formats and prompts, is what the
B/Acolumn says; on Track X, with bf16 against Q4_K_M, the c=1 gap is mostly bytes per token.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
Every request fails: first error: stream produced no content deltas |
The endpoint answered with no text: wrong --model, or a template failure |
Compare --model with /v1/models (on Track M, the absolute path); read the server log for a template error |
first error: ConnectionRefusedError: [Errno 111] Connect call failed ('127.0.0.1', 8080) (the errno differs on macOS) |
Wrong port, or the server is still loading | llama-server 8080, vLLM 8000, MLX 8081; wait for listening on or Application startup complete. |
first error: RequestFailed: HTTP 400 ... mentioning context or length |
Prompt plus max_tokens exceeds the context per sequence, for example a prefix set (about 1,400 prompt tokens) against CTX_PER_SLOT=1024 |
Run the prefix sets only against 4,096 tokens per sequence |
offloaded 0/37 layers or no GPU device in the load log |
A CPU-only llama.cpp build, or LLAMA_BIN points at one |
Rebuild with the GPU backend (Part 6); check --list-devices |
| llama-server exits during load after the KV cache line | The pool plus weights did not fit | Use the eight-bit lab.env for your tier, or lower SLOTS; record that it did not fit |
vLLM: Free memory on device ... is less than desired GPU memory utilization |
The fraction asks for more than is free at startup | Lower MEM_FRACTION_A/_B, close what holds memory, or set KV_BYTES |
vLLM: No available memory for the cache blocks or To serve at least one request with the model's max seq len (4096) ... |
Weights and activations used the whole fraction | Raise the fraction, or set KV_BYTES; on Track X check the GPU-visible cap (Part 5) |
vLLM on the 16 GB tier rejects --kv-cache-dtype fp8 |
This GPU and attention backend combination lacks the kernel | Set VLLM_KV_DTYPE=auto and keep VLLM_KV_BYTES=6039797760: the log then reports 40,960 tokens and 10.00x; record that the pools differ |
GPU KV cache size lower than Task 1 predicted with KV_BYTES set |
A different block size or dtype than assumed | Check KV_DTYPE; record the logged tokens and compare at that pool |
| Throughput at c=20 below c=10 | Past saturation; on vLLM, preemption | Record it; on vLLM check vllm:num_preemptions_total and the Preemptions: field |
in flight well below c and a Python process at 100 % of a core |
The client is the bottleneck | Run load-test.py on another machine through an SSH tunnel, ssh -N -L 8080:127.0.0.1:8080 you@server, so the server stays bound to 127.0.0.1, and repeat against http://127.0.0.1:8080/v1 on the client |
| Numbers vary by more than a few per cent between identical runs | Something else is using the machine, or it is thermally limited | Close everything, plug the laptop in, re-run; if the variance persists, record it |
finish shows stop for some requests |
The model finished thinking and answering within 128 tokens | Shorter outputs raise requests per second; compare output tokens per second instead, or raise --max-tokens |
tokens from deltas instead of usage |
The server ignored stream_options.include_usage |
Counts are approximate; say so beside the numbers |
cached shows None on vLLM |
The server was started without --enable-prompt-tokens-details |
Use this page’s serve scripts, which pass it |
Cleanup
Section titled “Cleanup”Stop the server in terminal 1 with Ctrl-C, then confirm nothing is left:
RunnableAll tracks
cd ~/llm-coursedocker rm -f vllm-lab 2>/dev/null || truefor port in 8000 8080 8081; do curl --silent --max-time 2 --output /dev/null "http://127.0.0.1:$port/" && echo "port $port is still in use"done; echo "port check finished"ls -lh labbook.md llama-server.log vllm-server.log 2>/dev/nullOutput — what you should see
port check finished-rw-r--r-- 1 you you xxxK ... labbook.md-rw-r--r-- 1 you you xxxK ... llama-server.log-rw-r--r-- 1 you you xxxM ... vllm-server.logKeep labbook.md, lab.env and both scripts: Part 20 and Part 22 run load-test.py against larger
servers and compare against these numbers, Part 23 operates a service built on them, and the capstone
collects them. The logs can go once their lines are in the notebook. Keep the model files if you have
the disk; the gateway project on the next page loads the same GGUF.
What you learned
Section titled “What you learned”| Objective | The observation that shows it | Recorded as |
|---|---|---|
| Predict what concurrency costs in memory | Whether each load log’s KV size matched Task 1’s pool, and whether vLLM reported 81,920 tokens at 20.00x | Task 2 and Task 8 log lines beside the prediction |
| Build and trust a load generator | fail 0, tokens from usage, finish length and in flight near c, and how closely vllm bench serve agreed |
llama-baseline rows; bench figures |
| Read a throughput-against-latency curve | Scaling, the fitted a and b, the knee and the operating point, set against Task 1’s ceiling |
curve-summary lines and the recording sheet |
| Measure prefix caching | Cached tokens and TTFT against a same-length control, and how the c=10 figures differ between per-slot and pooled caches | The two prefix comparisons |
| Relate batch size, context and memory | What fewer slots, less context per slot and the other cache type did to the KV size and the c=20 row; what --max-num-seqs did to the queue; how far each fraction moved the pool |
llama-slots8, llama-ctx2048, llama-kv-alt, vllm-seqs8, the fraction readings |
| Compare two engines fairly | Equal pool, cap, context and client, and how B/A moves down the concurrency levels |
The comparison table and its notes |
Check your understanding
Sources for this lesson
15 verified · checked 2026-09-13
- 01vLLM — vllm bench serve§ Options; percentile reporting; goodputdocs.vllm.ai/en/latest/cli/bench/serve.html2026-09-09
- 02vLLM — Optimization and Tuning§ Preemption; chunked prefilldocs.vllm.ai/en/latest/configuration/optimization.html2026-09-09
- 03vLLM — Production metrics§ Metric names; endpointdocs.vllm.ai/en/latest/usage/metrics.html2026-09-09
- 04vLLM — vllm serve CLI reference§ Optionsdocs.vllm.ai/en/latest/cli/serve.html2026-09-09
- 05llama.cpp — llama-server README§ Parallel slots; continuous batching; metrics endpointgithub.com/ggml-org/llama.cpp/blob/master/tools/server/README.md2026-09-09
- 06llama.cpp v0.4.0 — llama-server README at the pinned tag§ --parallel; --kv-unified; --cache-prompt; --cache-ram; --slot-prompt-similarity; GET /slots; GET /metrics (available metrics); timings and cache_ngithub.com/ggml-org/llama.cpp/blob/v0.4.0/tools/server/README.md2026-09-13
- 07vLLM v0.28.0 — vllm/config/cache.py§ enable_prefix_caching default; kv_cache_memory_bytes; cache_dtype; DEFAULT_BLOCK_SIZEgithub.com/vllm-project/vllm/blob/v0.28.0/vllm/config/cache.py2026-09-13
- 08vLLM v0.28.0 — vllm/v1/core/kv_cache_utils.py§ get_num_blocks; get_max_concurrency_for_kv_cache_config; "GPU KV cache size" log line; insufficient-memory errorsgithub.com/vllm-project/vllm/blob/v0.28.0/vllm/v1/core/kv_cache_utils.py2026-09-13
- 09vLLM v0.28.0 — vllm/v1/worker/gpu_worker.py and vllm/v1/worker/utils.py§ kv_cache_memory_bytes path; "Available KV cache memory"; request_memory errorgithub.com/vllm-project/vllm/blob/v0.28.0/vllm/v1/worker/gpu_worker.py2026-09-13
- 10vLLM v0.28.0 — vllm/v1/metrics/loggers.py§ periodic stats line; metric names and labelsgithub.com/vllm-project/vllm/blob/v0.28.0/vllm/v1/metrics/loggers.py2026-09-13
- 11vLLM v0.28.0 — vllm/benchmarks/serve.py and vllm/benchmarks/datasets/datasets.py§ add_cli_args; TPOT definition; result printout; random dataset optionsgithub.com/vllm-project/vllm/blob/v0.28.0/vllm/benchmarks/serve.py2026-09-13
- 12vLLM v0.28.0 — vllm/entrypoints/openai/cli_args.py§ enable_prompt_tokens_detailsgithub.com/vllm-project/vllm/blob/v0.28.0/vllm/entrypoints/openai/cli_args.py2026-09-13
- 13vLLM v0.28.0 — docs/configuration/optimization.md and docs/features/automatic_prefix_caching.md§ Limitsgithub.com/vllm-project/vllm/blob/v0.28.0/docs/features/automatic_prefix_caching.md2026-09-13
- 14mlx-lm v0.31.3 — mlx_lm/server.py§ --decode-concurrency; --prompt-concurrency; --prompt-cache-size; batchable requests; usage and cached_tokensgithub.com/ml-explore/mlx-lm/blob/v0.31.3/mlx_lm/server.py2026-09-13
- 15Hugging Face Hub — file listings for unsloth/Qwen3-8B-GGUF, Qwen/Qwen3-8B-AWQ and Qwen/Qwen3-8B§ file sizes; Qwen3-8B-Q4_K_M.gguf size and sha256; AWQ config.jsonhuggingface.co/api/models/Qwen/Qwen3-8B-AWQ/tree/main2026-09-13
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.