Prefix Caching and KV Reuse
Speculative decoding attacks the second half of a request. This lesson attacks the first half, and on the workloads most people actually run it is the larger win. By the end you will be able to say exactly what a prefix cache can and cannot reuse, turn it on and prove it is being hit on all four tracks, arrange your prompts so that hits happen instead of hoping they do, name the flags that quantise the cache and what they cost, and save a conversation’s cache to disk and bring it back.
What is actually reusable
Section titled “What is actually reusable”A key-value cache holds, for every token the model has read, the key and value vectors each attention layer computed for it. Those vectors depend on the token and on every token before it, and on nothing else. So if two requests begin with the identical sequence of token ids, the cache entries for that shared beginning are identical too, and the second request does not need to compute them again. It only needs to prefill the part that differs.
Three consequences follow, and every practical detail in this lesson is one of them.
The match has to be a prefix, from position zero. A document that appears in the middle of two prompts shares nothing reusable if the text before it differs, because every key and value in the document depends on what preceded it. The match has to be on tokens, not on characters: a prompt that differs by one space may tokenise differently from that point on. And because the reuse is exact, the output cannot change. vLLM’s usage page says so plainly: caching “won’t change model outputs” while avoiding “redundant prompt computations”.
The same long document, asked two questions
Four engines, four names for the same idea
Section titled “Four engines, four names for the same idea”llama.cpp calls it prompt caching and has it on already. The server README, as read on
2026-09-09, documents --cache-prompt and --no-cache-prompt as “whether to enable prompt
caching (default: enabled)”, with a matching cache_prompt parameter on the completion
endpoint. It works per slot: llama-server divides its context into slots, one per concurrent
conversation, and a slot keeps the tokens it last processed so that the next request into that
slot reuses the common prefix. The practical consequence is that the reuse is tied to a slot,
so the number of parallel slots you configure is also the number of distinct conversations
that can stay warm at once.
vLLM calls it automatic prefix caching, and its design page explains the mechanism: “we hash each kv-cache block by the tokens in the block and the tokens in the prefix before the block”, with the parent block’s hash folded in, plus extra hashes for things like adapter ids and multimodal inputs. Blocks are held in a free queue and evicted least-recently-used first, which the page describes as popping “the block from the head of the free queue. This is the LRU block to be evicted.” Because the hash covers the prefix, a hit is exact by construction rather than by coincidence. The usage page names the two workloads it is for: a “Long Document Query”, where “the user repeatedly queries the same long document (e.g. software manual or annual report) with different queries”, and a “Multi-Round Conversation”. It also sets expectations honestly, saying caching “in general does not reduce the performance of vLLM” and that it only reduces prefill time, with limited benefit when answers are long or prompts share no prefix.
SGLang calls it RadixAttention, and it is the most general of the four. The announcement
post describes retaining “the KV cache for both prompts and generation results in a radix
tree”, a structure that “enables efficient prefix search, insertion, and eviction”, with “a
Least Recently Used (LRU) eviction policy, complemented by a cache-aware scheduling policy, to
enhance the cache hit rate”. Caching the generated tokens as well as the prompt is what makes
it fit branching workloads: the post names few-shot examples, questions in self-consistency,
chat history in multi-turn chat, and search history in tree-of-thought as the reuse patterns
it handles. The switch is negative: --disable-radix-cache turns it off.
mlx-lm does it differently, and the difference is instructive. There is no server-managed
pool; there is a file. The README shows mlx_lm.cache_prompt writing a cache with
--prompt-cache-file, and mlx_lm.generate reading it back with the same option. That is a
weaker mechanism for a busy server and a stronger one for a workstation: the cache survives the
process, and you can build it once for a document you will ask about for a week.
RunnableTrack M · Apple silicon
mlx_lm.cache_prompt \ --model mlx-community/Qwen3-8B-4bit \ --prompt - \ --prompt-cache-file manual-cache.safetensors < manual.txt
mlx_lm.generate \ --prompt-cache-file manual-cache.safetensors \ --prompt "Summarise the section on power limits."Proving a cache is being hit
Section titled “Proving a cache is being hit”A prefix cache that is enabled and never hit behaves exactly like one that is switched off, and the failure is silent. So measure it two ways and read them against each other.
The first way is the counters. vLLM’s production metrics page documents
vllm:prefix_cache_hits (“Prefix cache hits, in terms of number of cached tokens”) and
vllm:prefix_cache_queries (“Prefix cache queries, in terms of number of queried tokens”); the
ratio between them over a run is the hit rate in tokens. SGLang publishes Prometheus metrics
when started with --enable-metrics, and its running log line reports token usage against the
cache pool. llama-server exposes /metrics when started with --metrics and a /slots
endpoint when started with --slots.
The second way is the clock, and it works even where an engine exposes nothing. Send a long shared prefix followed by a short different question, with the answer capped at a single token so that almost all of the measured time is prefill, and compare the first request against the ones after it. A warm request should be dramatically shorter than the cold one; if it is not, either the cache is off, the prefix is not matching, or the prefix is too short to matter.
RunnableAll tracks
#!/usr/bin/env python3"""Measure whether a server is really reusing the prefix it already computed.
Purpose: send one long shared prefix followed by several different short questions, with max_tokens set to 1 so that almost all of the measured time is prefill, and compare the first (cold) request against the later (warm) ones. Where the server exposes prefix-cache counters on a Prometheus endpoint, the script reports what the run added to them, so the timing and the counters can be read against each other. A cache that is on but never hit looks exactly like a cache that is off, and only these two measurements together tell them apart.Platform: all (spark, strix, mac, nvidia). Pure Python standard library: no pip install.Minimum memory: 8 GB on the machine running this script; the floor is set by the server.Assumes: a server listening and answering POST /v1/chat/completions over plain HTTP (a localhost or LAN tool; it does not speak TLS). Run it twice, once with the engine's prefix cache enabled and once with it disabled, and compare the two notebook lines. Results are appended to the lab notebook as one JSON line per run.
Usage: python3 measure-prefix-cache.py --base-url http://127.0.0.1:8000/v1 \\ --model local-chat --label vllm-apc-on --labbook labbook.md
python3 measure-prefix-cache.py --base-url http://127.0.0.1:8080/v1 \\ --model local-chat --prefix-words 1200 --questions 6 \\ --label llama-server-cache-prompt --labbook labbook.md
# a prefix of your own: a document, a system prompt, a tool schema python3 measure-prefix-cache.py --base-url http://127.0.0.1:8000/v1 \\ --model local-chat --prefix-file manual.txt --label rag-manual
An API key, if the server needs one, is read from the environment variable named by--api-key-env. No key is written to this file, to the output or to the notebook."""
from __future__ import annotations
import argparseimport jsonimport osimport reimport statisticsimport sysimport timeimport urllib.errorimport urllib.requestfrom urllib.parse import urlsplit
# --------------------------------------------------------------------------- prompts
# The synthetic prefix is deliberately dull and self-similar: what is being measured is# how long the engine takes to read tokens it has already read, not what it makes of them.PREFIX_SENTENCES = [ "The service runs one open-weight language model on a single machine.", "Memory is divided between the model weights, the key-value cache and the engine's working buffers.", "Prefill reads the prompt and is compute-bound; decode writes the answer and is bandwidth-bound.", "The key-value cache is charged per token and comes out of a pool reserved at start-up.", "Every measurement is recorded with the hardware, the engine version and the date.", "A benchmark without its context is a number somebody will misquote.", "Concurrency is the size of the pool divided by the context length each conversation is allowed.", "The operator cares about time to first token, time per output token and goodput.",]
QUESTIONS = [ "Reply with the single word: one.", "Reply with the single word: two.", "Reply with the single word: three.", "Reply with the single word: four.", "Reply with the single word: five.", "Reply with the single word: six.", "Reply with the single word: seven.", "Reply with the single word: eight.",]
def build_prefix(words: int, path: str | None) -> str: """A file's contents, or a synthetic block of roughly `words` words.""" if path: with open(path, encoding="utf-8") as handle: text = handle.read().strip() if not text: raise SystemExit(f"{path} is empty") return text out: list[str] = [] count = 0 i = 0 while count < words: sentence = PREFIX_SENTENCES[i % len(PREFIX_SENTENCES)] out.append(f"[{i:04d}] {sentence}") count += len(sentence.split()) + 1 i += 1 return "\n".join(out)
# ------------------------------------------------------------------------ HTTP
class ServerError(Exception): """The server did not answer in a way this script can use."""
def post_json(url: str, payload: dict, api_key: str, timeout: float) -> dict: """One non-streaming POST. Returns the decoded JSON body.""" body = json.dumps(payload).encode("utf-8") request = urllib.request.Request(url, data=body, method="POST") request.add_header("Content-Type", "application/json") if api_key: request.add_header("Authorization", f"Bearer {api_key}") try: with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - http only, checked in main return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:200] raise ServerError(f"HTTP {exc.code} from {url}: {detail}") from exc except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc: raise ServerError(f"{type(exc).__name__} talking to {url}: {exc}") from exc
def get_text(url: str, api_key: str, timeout: float) -> str | None: """GET a text body, or None if the endpoint is absent or refuses.""" request = urllib.request.Request(url, method="GET") if api_key: request.add_header("Authorization", f"Bearer {api_key}") try: with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - http only return response.read().decode("utf-8", "replace") except (urllib.error.URLError, OSError): return None
# ------------------------------------------------------------- Prometheus scraping
# Generic on purpose: engines rename metrics between releases, so this reports whatever# the server publishes whose name mentions a cache, rather than asserting that a# particular counter exists. Nothing matched is itself a result worth recording.CACHE_METRIC = re.compile(r"(prefix_cache|cache_hit|cache_quer|radix|kv_cache)", re.IGNORECASE)SAMPLE = re.compile(r"^(?P<name>[A-Za-z_:][A-Za-z0-9_:]*)(?P<labels>\{[^}]*\})?\s+(?P<value>[-+0-9.eE]+|NaN)$")
def scrape_metrics(metrics_url: str, api_key: str, timeout: float) -> dict[str, float]: """Reads a Prometheus exposition page and keeps the cache-related samples.""" text = get_text(metrics_url, api_key, timeout) if text is None: return {} out: dict[str, float] = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue match = SAMPLE.match(line) if not match or not CACHE_METRIC.search(match.group("name")): continue try: value = float(match.group("value")) except ValueError: continue out[match.group("name") + (match.group("labels") or "")] = value return out
def metrics_delta(before: dict[str, float], after: dict[str, float]) -> dict[str, float]: """What this run added, so a warm server's history does not pollute the record.""" keys = set(before) | set(after) return {k: round(after.get(k, 0.0) - before.get(k, 0.0), 4) for k in sorted(keys)}
# ------------------------------------------------------------------------ measurement
def one_request(cfg: dict, prefix: str, question: str) -> dict: """One prefix-plus-question request with max_tokens 1, timed end to end.
max_tokens is 1 so that the measurement is dominated by prefill. It does not isolate prefill perfectly - the response still travels back over HTTP and one decode step still happens - but the constant is the same for every request in the run, so the cold-to-warm difference is the cache and not the overhead. """ payload = { "model": cfg["model"], "messages": [ {"role": "system", "content": prefix}, {"role": "user", "content": question}, ], "max_tokens": 1, "temperature": 0.0, "stream": False, } started = time.perf_counter() body = post_json(cfg["chat_url"], payload, cfg["api_key"], cfg["timeout"]) elapsed = time.perf_counter() - started usage = body.get("usage") or {} return { "elapsed_s": elapsed, "prompt_tokens": usage.get("prompt_tokens"), "timings": body.get("timings") if isinstance(body.get("timings"), dict) else None, }
def run(cfg: dict, prefix: str, questions: list[str]) -> dict: """Cold request, then the warm ones, with the cache counters read either side.""" before = scrape_metrics(cfg["metrics_url"], cfg["api_key"], cfg["timeout"])
try: cold = one_request(cfg, prefix, questions[0]) except ServerError as exc: return {"error": str(exc)}
warm: list[dict] = [] errors: list[str] = [] for question in questions[1:]: try: warm.append(one_request(cfg, prefix, question)) except ServerError as exc: errors.append(str(exc))
after = scrape_metrics(cfg["metrics_url"], cfg["api_key"], cfg["timeout"]) warm_times = [w["elapsed_s"] for w in warm]
return { "prefix_chars": len(prefix), "prompt_tokens": cold["prompt_tokens"], "cold_s": round(cold["elapsed_s"], 4), "warm_s": { "count": len(warm_times), "p50": round(statistics.median(warm_times), 4) if warm_times else None, "mean": round(statistics.fmean(warm_times), 4) if warm_times else None, "min": round(min(warm_times), 4) if warm_times else None, "max": round(max(warm_times), 4) if warm_times else None, }, "warm_over_cold": ( round(statistics.median(warm_times) / cold["elapsed_s"], 4) if warm_times and cold["elapsed_s"] > 0 else None ), "server_timings_sample": cold["timings"], "cache_metrics_delta": metrics_delta(before, after), "failed": len(errors), "first_error": errors[0] if errors else None, }
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 at /v1/models") parser.add_argument("--prefix-words", type=int, default=800, help="approximate length of the synthetic shared prefix, in words") parser.add_argument("--prefix-file", default=None, help="use this file as the shared prefix instead of the synthetic one") parser.add_argument("--questions", type=int, default=6, help="requests to send: the first is cold, the rest should hit the cache") parser.add_argument("--timeout", type=float, default=300.0) parser.add_argument("--label", default="prefix-cache", help="tag written into the notebook line") parser.add_argument("--engine", default="unknown", help="engine name recorded in the notebook") parser.add_argument("--engine-version", default="unknown", help="engine version recorded in the notebook") parser.add_argument("--api-key-env", default="SPEC_API_KEY", help="environment variable holding the API key, if the server needs one") parser.add_argument("--labbook", default="labbook.md") 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
root = f"{parts.scheme}://{parts.netloc}" path = parts.path.rstrip("/") or "/v1" cfg = { "chat_url": f"{root}{path}/chat/completions", "metrics_url": f"{root}/metrics", "model": args.model, "timeout": args.timeout, "api_key": os.environ.get(args.api_key_env, ""), }
if args.questions < 2: print("--questions must be at least 2: one cold request and one warm one.", file=sys.stderr) return 2 questions = [QUESTIONS[i % len(QUESTIONS)] for i in range(args.questions)] prefix = build_prefix(args.prefix_words, args.prefix_file)
print(f"==> {args.label}: {args.model} at {args.base_url}") print(f" shared prefix of {len(prefix)} characters, {args.questions} request(s), max_tokens 1")
result = run(cfg, prefix, questions) if "error" in result: print(f" the first request failed: {result['error']}", file=sys.stderr) return 1
print(f" prompt {result['prompt_tokens']} tokens (as counted by the server)") print(f" cold {result['cold_s']:.3f} s") if result["warm_s"]["p50"] is not None: print(f" warm p50 {result['warm_s']['p50']:.3f} s over {result['warm_s']['count']} request(s)") print(f" ratio warm/cold {result['warm_over_cold']:.3f}") if result["cache_metrics_delta"]: print(" cache counters this run added:") for name, value in result["cache_metrics_delta"].items(): print(f" {name} += {value}") else: print(" cache the server exposed no cache counters; the timing is your only evidence") if result["first_error"]: print(f" first error {result['first_error']}")
record = dict(result) record.update({ "lab": "part-17/measure-prefix-cache", "label": args.label, "engine": args.engine, "engine_version": args.engine_version, "model": args.model, "base_url": args.base_url, "prefix_source": args.prefix_file or f"synthetic-{args.prefix_words}-words", "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), }) append_labbook(args.labbook, record) print(f" appended 1 line to {args.labbook}") return 0
if __name__ == "__main__": sys.exit(main(sys.argv[1:]))RunnableAll tracks
python3 measure-prefix-cache.py \ --base-url http://127.0.0.1:8000/v1 \ --model local-chat \ --prefix-words 1200 \ --questions 6 \ --engine vllm \ --label apc-on \ --labbook labbook.mdRun it twice, once with the cache enabled and once with it disabled, and compare the two notebook lines. That pair is the evidence; a single warm-looking number is not.
RunnableTrack N · NVIDIA GPU
vllm serve Qwen/Qwen3-8B \ --served-model-name local-chat \ --port 8000 \ --max-model-len 8192 \ --no-enable-prefix-cachingArranging prompts so that hits happen
Section titled “Arranging prompts so that hits happen”This is the part that pays for the lesson. A cache hit needs an exact token prefix, so anything that varies early destroys every hit behind it. The fix is always the same: put the stable material first and the varying material last.
- A timestamp, a request id or a session id at the top of a system prompt invalidates the whole prompt on every request. Move it to the end, or drop it.
- A tool or function list serialised from a dictionary with unstable ordering changes bytes without changing meaning. Sort it once and keep the order fixed.
- Per-user preamble before a shared corpus splits the cache by user. Put the corpus first and the user’s context after it.
- Retrieval that re-ranks the same passages into a different order on each turn shares nothing with the previous turn. Keep an order that is stable when the retrieved set is stable.
- A trailing whitespace difference or a re-wrapped paragraph re-tokenises from that point on. Build prompts from a template, not by string concatenation at three call sites.
Quantising the cache
Section titled “Quantising the cache”The cache is charged per token, and on a long context it can rival the weights. For Qwen3-8B, whose recorded shape is thirty-six layers with eight key-value heads of head dimension 128, one token of cache at 16 bits costs both a key and a value in every layer.
Qwen3-8B at Q4_K_M with a 32,768-token context, on a 24 GB machine
- Weights (Q4_K_M)
- 5 GB
- KV cache at 16-bit, 32k tokens
- 4.8 GB
- Engine buffers and activations
- 1.5 GB
- Free
- 12.7 GB
- Total
- 24 GB
Every engine will store those tensors in fewer bits if you ask. llama.cpp’s server README
documents --cache-type-k and --cache-type-v, both defaulting to f16, with q8_0, q4_0,
q4_1, iq4_nl, q5_0, q5_1, bf16 and f32 among the accepted types. vLLM and SGLang
both take --kv-cache-dtype; SGLang’s server arguments page lists auto, fp8_e5m2,
fp8_e4m3, bf16, nvfp4 and fp4_mx_block16, with auto meaning the model’s own type.
mlx-lm’s generate takes --kv-bits (“Number of bits for KV cache quantization. Defaults to no
quantization”) and --quantized-kv-start, which delays quantisation until a given step so that
the earliest, most-attended tokens stay at full precision.
RunnableAll tracks
llama-server \ --model ~/models/unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf \ --alias local-chat \ --port 8080 \ --ctx-size 32768 \ --n-gpu-layers 999 \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ --metrics \ --slotsSaving and restoring a cache
Section titled “Saving and restoring a cache”An in-memory cache dies with the process, and on a workstation that is a real cost: reopening a
long conversation after a restart means paying for the whole prefill again. llama-server can
write a slot’s cache to disk. Started with --slot-save-path, it accepts
POST /slots/{id_slot}?action=save, ?action=restore and ?action=erase, alongside
GET /slots for the current state.
RunnableAll tracks
curl -s -X POST "http://127.0.0.1:8080/slots/0?action=save" \ -H "Content-Type: application/json" \ -d '{"filename": "review-session.bin"}'
curl -s -X POST "http://127.0.0.1:8080/slots/0?action=restore" \ -H "Content-Type: application/json" \ -d '{"filename": "review-session.bin"}'Who benefits most
Section titled “Who benefits most”Two later parts are built on workloads this lesson makes cheap.
Agents, in the part on tool calling and the agent loop, resend the entire transcript on every turn: the system prompt, the tool schemas, and every observation so far, with one new message on the end. That is the ideal shape for a prefix cache, because the growing part is appended and everything before it is unchanged. An agent loop without prefix caching pays for the whole history at every step, and the cost grows with the square of the number of steps. With it, each step prefills roughly what was added. This is why the arrangement rules above matter more for agents than anywhere else: one timestamp in the system prompt turns the good case back into the bad one.
Retrieval, in the part on putting models to work, sends the same corpus chunks to the model again and again as different users ask about the same documents. Ordering the retrieved context stably, and putting it before the user’s question rather than after it, is the difference between a corpus that is prefilled once and one that is prefilled per request.
Cache identity includes the computation that produced it
Section titled “Cache identity includes the computation that produced it”Identical text is insufficient if tokenisation, special tokens, model weights, adapter, position handling or cache representation changed. The cached tensors represent a particular computation on a particular token prefix. Record these identities when testing persistence or transfer between processes.
Use a cold request, an identical-prefix repeat and a changed-prefix control. Compare prompt latency and engine cache-hit evidence. Keep model residency and warm-up separate: loading the model once can accelerate all later requests without proving prefix reuse. Test eviction by adding competing prefixes, then repeating the original request.
For multi-user systems, define whether cache sharing is allowed across users and how sensitive prompts are isolated and expired. A performance cache can retain derived information even if the original request log is deleted. Follow the engine’s documented isolation controls and test the intended boundaries. Prefix caching preserves useful work when its identity and lifecycle are correct; it should not be treated as a semantic memory that recognises paraphrases or an unrestricted store shared by every caller.
A prefix cache reuses the key and value vectors for a shared token prefix, which is exact and
cannot change the output, but only ever matches from position zero and only on identical
tokens. llama.cpp enables prompt caching by default and works per slot; vLLM hashes each block
together with its prefix and evicts least-recently-used; SGLang keeps prompts and generations in
a radix tree with LRU eviction and cache-aware scheduling; mlx-lm writes an explicit cache file
you carry between runs. Prove a hit with the engine’s counters and with a cold-versus-warm
timing, because an unhit cache is invisible. Arrange prompts so the stable material comes first,
which is the single change that turns a theoretical cache into a real one. Quantise the cache
with --cache-type-k and --cache-type-v, --kv-cache-dtype or --kv-bits when you need the
memory, remembering that unlike prefix caching this one does change outputs. And save a slot to
disk when a long conversation is worth more than the file it occupies.
Check your understanding
Sources for this lesson
8 verified · checked 2026-09-09
- 01llama.cpp — llama-server README§ Prompt caching; KV cache types; slots endpoints; metricsgithub.com/ggml-org/llama.cpp/blob/master/tools/server/README.md2026-09-09
- 02vLLM — Automatic Prefix Caching (design)§ Block hashing; evictiondocs.vllm.ai/en/latest/design/prefix_caching2026-09-09
- 03vLLM — Automatic Prefix Caching (usage)§ Long document query; multi-round conversation; limitsdocs.vllm.ai/en/latest/features/automatic_prefix_caching2026-09-09
- 04vLLM — Production metrics§ Prefix cache metricsdocs.vllm.ai/en/latest/usage/metrics.html2026-09-09
- 05Fast and Expressive LLM Inference with RadixAttention and SGLang§ RadixAttention; cache reuse patternslmsys.org/blog/2024-01-17-sglang2026-09-09
- 06SGLang — Server Arguments§ KV cache dtype; radix cache; metricsdocs.sglang.io/advanced_features/server_arguments.html2026-09-09
- 07mlx-lm — README§ Prompt cachinggithub.com/ml-explore/mlx-lm2026-09-09
- 08mlx-lm — generate.py argument parser§ setup_arg_parsergithub.com/ml-explore/mlx-lm/blob/main/mlx_lm/generate.py2026-09-09
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.