Skip to content
Level 3 · Model BuilderLessonPart 17 · page 1 of 425 min
25Minutes
2Tools
8Sources
Tools used on this page2

Speculative Decoding Revisited: Acceptance Rates and When It Pays

Part 9 showed you where the speculative-decoding switch is. This lesson answers the question that comes next and that nobody answers for you: should it be on, for your model, on your machine, for the work you actually do. By the end you will be able to derive the speed-up rather than quote it, find the acceptance rate below which speculation costs you time, say why the setting that helps one workload hurts another, and measure all of it with a script that also proves the answers did not change.

Why a checked guess is not an approximation

Section titled “Why a checked guess is not an approximation”

The claim from Part 9 was that speculative decoding changes the speed and not the output. It is worth seeing why, because the reason is the whole reason to trust the technique.

Take greedy decoding first, where the model always takes its highest-probability token. The draft proposes some tokens; the target model runs once over all of them and reports what it would have chosen at each position. Wherever the proposal matches the target’s own choice, keeping it is by definition the same as having generated it. At the first mismatch the proposal is thrown away, and the target’s choice at that position is taken instead. Nothing about this can produce a token the target would not have produced. It is bookkeeping.

Sampling is the interesting case, because now the target has a distribution rather than a choice, and naively accepting whatever the draft sampled would bias the output towards the draft. Both founding papers solve this the same way. Leviathan, Kalman and Matias describe “a novel sampling method” that makes “exact decoding from the large models faster… without changing the distribution”. Chen and colleagues name the mechanism: “a novel modified rejection sampling scheme which preserves the distribution of the target model within hardware numerics”.

The scheme itself is short. Let q be the draft’s distribution at a position and p the target’s. The draft samples a token x from q. Accept it with probability min(1, p(x) / q(x)): if the target liked the token at least as much as the draft did, keep it unconditionally; if the target liked it less, keep it in proportion. When it is rejected, do not fall back to sampling from p, because the rejected mass has to be accounted for. Sample instead from the normalised positive part of p − q, the residual distribution of everything the draft under-weighted. The two branches together are exactly p.

The acceptance test at one position

  1. The draft samples x from its own distribution qThis is the cheap step, and it is the only place the draft has any influence at all.
  2. The target pass reports p, its distribution at the same positionOne pass over the expensive weights produces p at every proposed position at once. That parallelism is the source of the speed-up.
  3. Draw u uniformly between 0 and 1The randomness that makes the acceptance rule exact rather than approximate.
  4. If u is at most p(x)/q(x), accept x and move onA token the target rates at least as highly as the draft did is always accepted; one it rates lower is accepted in proportion to the ratio.
  5. Otherwise reject, and sample from the normalised positive part of p minus qNot from p. The residual distribution puts back exactly the mass the rejection removed, which is what makes the two branches together identical to sampling from p.
  6. Discard every proposal after the first rejectionThey were conditioned on a token that will not exist, so they carry no information about the sequence that does.
The greedy case is the same picture with the distributions collapsed onto single tokens, which is why greedy speculation can be checked byte for byte and sampled speculation cannot. Chen and colleagues state the preservation holds 'within hardware numerics': floating-point reductions over a different batch shape can still shift a logit in the last place.

Two quantities decide everything. The acceptance rate, written α, is the probability that a proposal survives verification. The draft cost, written c, is what one drafted token costs as a fraction of one target step. The chain length k is how many tokens are drafted per iteration, and it is yours to choose.

Treat acceptance as independent at each position, which is the standard simplification and is wrong in a way we return to below. The chain survives the first position with probability α, the first two with α², and so on, so the expected number of accepted proposals is α + α² + … + α^k. The target pass also produces one token at the first rejected position, and that token is free: it is the token ordinary decoding would have produced. So the expected yield of one iteration is

E(tokens) = 1 + α + α² + … + α^k = (1 − α^(k+1)) / (1 − α)

and its cost, in units of one target step, is 1 + kc. The speed-up over ordinary decoding is the ratio:

S = (1 − α^(k+1)) / ((1 − α)(1 + kc))

Every property people find surprising about speculative decoding falls out of this one expression. Setting S = 1 and solving for α gives the break-even: the acceptance rate below which speculation is a loss. The table below is arithmetic, not measurement, and it is worth reading as a map of where you have to land.

Chain length k Draft cost c = 0.10 c = 0.20 c = 0.35
2 break even near α = 0.17 α = 0.31 α = 0.48
4 α = 0.29 α = 0.46 α = 0.62
8 α = 0.45 α = 0.62 α = 0.76

The numbers in that table are solutions of the equation above, rounded, and no machine was involved in producing them. Two things follow immediately. A cheap draft has a low bar: at a tenth of a target step and a chain of two, a draft that agrees about one time in six already pays for itself. And an expensive draft has a punishing one: at roughly a third of a target step and a chain of eight, a draft has to be right about three times in four before it breaks even, which is a standard almost nothing meets on open-ended text.

The numerator of S saturates. As k grows, (1 − α^(k+1)) / (1 − α) approaches 1 / (1 − α) and stops moving, because the probability of getting that far down the chain is α^k and decays geometrically. The denominator, meanwhile, grows without limit: every drafted token costs c whether it survives or not.

So there is an interior optimum, and it depends on α. At an acceptance rate around a half the best chain is one or two tokens long; at an acceptance rate around nine in ten, chains of six to ten still earn their keep. This is why engines expose the chain length as a knob and why a value copied from someone else’s blog post is a guess. llama.cpp’s server README, as read on 2026-09-09, gives --spec-draft-n-max a default of three, which is a sensible starting point for a chain rather than an answer. SGLang’s advice is to tune its three related options together, and its documentation warns that speculative decoding “may increase GPU memory usage because draft tree, CUDA graphs, and verification buffers consume additional VRAM”.

The derivation assumed α is a constant. It is not, in three separate ways, and each one matters when you go to measure.

It falls along the chain. The first proposal is conditioned only on real text; the fifth is conditioned on four guesses. Later positions are accepted less often, which is why vLLM publishes vllm:spec_decode_num_accepted_tokens_per_pos rather than a single average. Watching acceptance per position tells you where to cut the chain: if position four is almost never accepted, you are paying c for it every iteration and getting nothing.

It depends on the workload. Code editing where the model quotes back a function, structured extraction, and summarisation that reuses the source’s phrasing all have high acceptance, because the next token is nearly determined. Open-ended writing at a high temperature has low acceptance, because the target’s distribution is genuinely broad and the draft has to guess which broad thing it will pick. The same server, the same draft and the same chain length will land in different rows of the table above depending on what you ask it.

It depends on the context, not only the position. This is the finding behind EAGLE-2. Its authors write that most methods, EAGLE included, “use a static draft tree, implicitly assuming that the acceptance rate of draft tokens depends only on their position”, and that they “found that the acceptance rate of draft tokens is also context-dependent”. Their answer is a context-aware dynamic draft tree that spends its budget where the draft is confident; they report speed-up ratios of 3.05 to 4.26 times and an improvement of twenty to forty per cent over EAGLE. The practical lesson for a reader with one machine is smaller: an average acceptance rate over mixed traffic hides most of what you need to know, so measure per workload.

The parameter ratio is a first estimate of c and usually an optimistic one. A draft with a tenth of the target’s parameters does not cost a tenth of a target step, because a decode step is not pure weight reading. There is a fixed cost per step in kernel launches, sampling and Python-level scheduling that does not shrink with the model, and on a small draft that fixed cost is a larger fraction of the total. On a machine where the target is large enough to be firmly bandwidth-bound and the draft is small enough to be latency-bound, the measured c can be several times the parameter ratio.

There is a memory cost too, and Part 9’s lesson priced it: a draft’s weights come out of the same pool as the key-value cache, so a draft bought with gigabytes is concurrency sold. What this lesson adds is that the draft also needs its own key-value cache for the chain it is generating, and the verification pass holds activations for k + 1 positions rather than one. Neither is large beside the weights. Both are enough to stop a server starting that started yesterday.

The single most counter-intuitive property of speculative decoding is that it helps least exactly where a busy server needs help most, and the reason is that it and continuous batching are spending the same resource.

Decode is bandwidth-bound: the arithmetic units idle while weights stream in. Batching fills that idle capacity with other people’s requests. Speculation fills it with the same conversation’s future tokens. At a concurrency of one there is nothing else to spend the capacity on and speculation is nearly free; at a concurrency of twenty the capacity is already sold, and the verification pass over k + 1 positions per sequence is real work competing with real requests.

vLLM’s documentation puts this in a method-selection table with separate columns for low and high request rates: EAGLE is rated “High gain” at low QPS and “Medium to high gain” at high QPS, a draft model “High gain” then “Medium gain”, and the n-gram and suffix methods “Low to medium gain” then “Medium gain”. The page then adds the sentence to keep: “Real gains depend on your model family, traffic pattern, hardware, and sampling settings.” The gain does not vanish at scale, and EAGLE-3’s authors report a throughput improvement of about 1.38 times at a batch size of 64 in SGLang, but the shape of the curve is downwards, and the arithmetic above explains why.

The measurement is a comparison, not a reading. Start two servers on two ports with the same weights at the same quantisation and the same context length, one with a draft and one without, and send both the identical prompt set at temperature zero and concurrency one. The script below does that, and it does the three things the arithmetic says you need.

RunnableAll tracks

measure-speculative.py
#!/usr/bin/env python3
"""Measure what speculative decoding does to one server, and prove it changed nothing else.
Purpose: run one fixed prompt set through an OpenAI-compatible endpoint with speculation
off and again with speculation on, and report the three things that decide whether
speculation earns its place: output tokens per second, whatever acceptance statistics
the server exposes on its Prometheus endpoint, and a byte-for-byte comparison of the
greedy completions. Speculative decoding is a latency technique, not a quality trade,
so a run whose text changed is a run to investigate rather than to publish.
Platform: all (spark, strix, mac, nvidia). Pure Python standard library: no pip install.
Minimum memory: 8 GB on the machine running this script; the memory floor is set by the
server, not by the generator, and the generator may run on another machine on the LAN.
Assumes: one or two servers already listening and answering POST /v1/chat/completions over
plain HTTP (this is a localhost or LAN tool and does not speak TLS). Both endpoints
must be serving the same weights at the same quantisation, differing only in whether
speculation is enabled, or the comparison means nothing. Results are appended to the
lab notebook as one JSON line per endpoint plus one for the comparison.
Usage:
# measure a single endpoint, e.g. before you have a draft at all
python3 measure-speculative.py --baseline-url http://127.0.0.1:8080/v1 \\
--model local-chat --label llama-server-no-draft --labbook labbook.md
# compare two endpoints: one without a draft, one with
python3 measure-speculative.py \\
--baseline-url http://127.0.0.1:8080/v1 \\
--speculative-url http://127.0.0.1:8081/v1 \\
--model local-chat --max-tokens 192 --repeats 2 \\
--label qwen3-8b-draft-0.6b --labbook labbook.md
# your own prompts, one per line, instead of the built-in mixed set
python3 measure-speculative.py --baseline-url http://127.0.0.1:8080/v1 \\
--model local-chat --prompts my-prompts.txt --prompt-set file
An API key, if a 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.
Counting note: token counts come from the "usage" object the server returns, so they are
the server's own count rather than an estimate. A server that omits "usage" is
reported with a null token count and its tokens-per-second figure is left out rather
than guessed.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import statistics
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlsplit
# --------------------------------------------------------------------------- prompts
# Three shapes of work, because speculation behaves completely differently on each.
# "copy" repeats material that is already in the prompt, which is where lookup-based
# drafting wins; "closed" has one predictable answer; "open" is high-entropy writing,
# where a draft agrees with the target least often.
BUILTIN_PROMPTS = [
# copy-heavy
"Here is a function:\n\ndef total(rows):\n out = 0\n for r in rows:\n out += r['amount']\n return out\n\nRewrite it to skip rows whose 'amount' key is missing, and show the whole function.",
"Reformat this list as a JSON array of objects with keys name and port: "
"llama-server 8080, vllm 8000, sglang 30000, litellm 4000.",
"Copy this sentence exactly, then explain it in one sentence: "
"decode is bandwidth-bound because every active weight is read once per token.",
"Take this shell line and add error handling, showing the complete result: "
"curl -s http://127.0.0.1:8080/v1/models | jq .data",
# closed
"In two sentences, what does a KV cache hold and why does it grow with the conversation?",
"Name the two halves of a generation step and say which one is compute-bound.",
"What is an acceptance rate in speculative decoding? Answer in one sentence.",
"List four fields that must accompany a tokens-per-second measurement.",
# open
"Write a short paragraph, in your own words, about why a smaller model that fits in memory can beat a larger one that does not.",
"Describe, as if to a colleague over coffee, what surprised you most about running language models on your own hardware.",
"Invent a plausible name and one-line description for a tool that records benchmark context automatically.",
"Write three sentences of encouragement for somebody whose first fine-tune made their model worse.",
]
def load_prompts(prompt_set: str, path: str | None) -> list[str]:
"""The built-in mixed set, or one prompt per non-blank line of a file."""
if prompt_set == "builtin":
return list(BUILTIN_PROMPTS)
if not path:
raise SystemExit("--prompt-set file needs --prompts pointing at a file")
with open(path, encoding="utf-8") as handle:
prompts = [line.strip() for line in handle if line.strip()]
if not prompts:
raise SystemExit(f"{path} contained no prompts")
return prompts
# ------------------------------------------------------------------------ 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(s) 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(s) only
return response.read().decode("utf-8", "replace")
except (urllib.error.URLError, OSError):
return None
# ------------------------------------------------------------- Prometheus scraping
# Deliberately generic. Engines rename their metrics between releases, so this script
# reports whatever the server publishes whose name mentions speculation or the draft,
# rather than asserting that a particular metric name exists. An empty result means
# "this server exposed nothing under those names", which the page tells you to record.
SPEC_METRIC = re.compile(r"(spec_decode|speculat|draft|accept)", 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, keep: re.Pattern[str]) -> dict[str, float]:
"""Reads a Prometheus exposition page and keeps the samples whose name matches."""
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 keep.search(match.group("name")):
continue
try:
value = float(match.group("value"))
except ValueError:
continue
key = match.group("name") + (match.group("labels") or "")
out[key] = value
return out
def metrics_delta(before: dict[str, float], after: dict[str, float]) -> dict[str, float]:
"""What the run itself 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 run_endpoint(cfg: dict, prompts: list[str], repeats: int) -> dict:
"""Sends every prompt `repeats` times, sequentially, and times each request.
Sequential and single-stream on purpose: speculation spends idle arithmetic
capacity, so concurrency one is where it has the most to gain, and it is the
condition under which the acceptance arithmetic on the lesson page applies.
"""
completions: list[str] = []
latencies: list[float] = []
token_counts: list[int | None] = []
timings: list[dict] = []
errors: list[str] = []
metrics_before = scrape_metrics(cfg["metrics_url"], cfg["api_key"], cfg["timeout"], SPEC_METRIC)
started = time.perf_counter()
for _ in range(repeats):
for prompt in prompts:
payload = {
"model": cfg["model"],
"messages": [{"role": "user", "content": prompt}],
"max_tokens": cfg["max_tokens"],
"temperature": 0.0,
"top_p": 1.0,
"seed": cfg["seed"],
"stream": False,
}
t0 = time.perf_counter()
try:
body = post_json(cfg["chat_url"], payload, cfg["api_key"], cfg["timeout"])
except ServerError as exc:
errors.append(str(exc))
continue
latencies.append(time.perf_counter() - t0)
choices = body.get("choices") or [{}]
message = choices[0].get("message") or {}
completions.append(message.get("content") or "")
usage = body.get("usage") or {}
completion_tokens = usage.get("completion_tokens")
token_counts.append(int(completion_tokens) if isinstance(completion_tokens, int) else None)
if isinstance(body.get("timings"), dict):
timings.append(body["timings"])
wall = time.perf_counter() - started
metrics_after = scrape_metrics(cfg["metrics_url"], cfg["api_key"], cfg["timeout"], SPEC_METRIC)
counted = [t for t in token_counts if t is not None]
total_tokens = sum(counted) if counted else None
complete = len(counted) == len(latencies) and bool(counted)
return {
"label": cfg["label"],
"base_url": cfg["base_url"],
"model": cfg["model"],
"requests": len(prompts) * repeats,
"completed": len(latencies),
"failed": len(errors),
"first_error": errors[0] if errors else None,
"wall_s": round(wall, 3),
"latency_s": {
"p50": round(statistics.median(latencies), 4) if latencies else None,
"mean": round(statistics.fmean(latencies), 4) if latencies else None,
"max": round(max(latencies), 4) if latencies else None,
},
"output_tokens": total_tokens,
"output_tokens_per_s": (
round(total_tokens / wall, 2) if complete and total_tokens and wall > 0 else None
),
"tokens_counted_by_server": complete,
"server_timings_sample": timings[0] if timings else None,
"spec_metrics_delta": metrics_delta(metrics_before, metrics_after),
"completions": completions,
}
def compare_text(baseline: list[str], speculative: list[str]) -> dict:
"""Byte-for-byte comparison of the two sets of greedy completions.
Verification means the accepted tokens are the ones the target model would have
produced, so at temperature zero the two runs should agree exactly. They can still
differ for reasons that have nothing to do with the draft - a different batch
composition changes the order of floating-point reductions, and the engines say so -
which is why this reports where the first divergence is rather than only that there
was one.
"""
pairs = list(zip(baseline, speculative))
identical = [i for i, (a, b) in enumerate(pairs) if a == b]
divergent = []
for i, (a, b) in enumerate(pairs):
if a == b:
continue
cut = 0
for cut, (ca, cb) in enumerate(zip(a, b)):
if ca != cb:
break
else:
cut = min(len(a), len(b))
divergent.append(
{
"prompt_index": i,
"first_difference_at_char": cut,
"baseline_tail": a[cut : cut + 60],
"speculative_tail": b[cut : cut + 60],
}
)
return {
"compared": len(pairs),
"identical": len(identical),
"divergent": len(divergent),
"divergences": divergent[:5],
}
# ---------------------------------------------------------------------------- output
def print_endpoint(row: dict) -> None:
"""One endpoint's result, as an aligned block."""
print(f" {row['label']} ({row['base_url']})")
print(f" requests {row['completed']}/{row['requests']} completed, {row['failed']} failed")
print(f" wall clock {row['wall_s']:.2f} s")
if row["output_tokens_per_s"] is not None:
print(f" output {row['output_tokens']} tokens, {row['output_tokens_per_s']:.2f} tokens/s")
else:
print(" output the server did not return a usage object; tokens/s not computed")
if row["latency_s"]["p50"] is not None:
print(f" latency p50 {row['latency_s']['p50']:.3f} s, max {row['latency_s']['max']:.3f} s")
if row["spec_metrics_delta"]:
print(" acceptance statistics from the server's /metrics during this run:")
for name, value in row["spec_metrics_delta"].items():
print(f" {name} += {value}")
else:
print(" acceptance the server exposed no speculation metrics; record that as the finding")
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 endpoint_config(base_url: str, args: argparse.Namespace, label: str) -> dict:
"""Derives the chat and metrics URLs from an OpenAI-compatible base URL."""
parts = urlsplit(base_url)
if parts.scheme != "http":
raise SystemExit("This tool speaks plain HTTP only; point it at a localhost or LAN endpoint.")
if not parts.hostname:
raise SystemExit(f"Could not read a host from {base_url!r}.")
root = f"{parts.scheme}://{parts.netloc}"
path = parts.path.rstrip("/") or "/v1"
return {
"base_url": base_url,
"chat_url": f"{root}{path}/chat/completions",
"metrics_url": f"{root}/metrics",
"model": args.model,
"max_tokens": args.max_tokens,
"seed": args.seed,
"timeout": args.timeout,
"api_key": os.environ.get(args.api_key_env, ""),
"label": label,
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--baseline-url", required=True,
help="OpenAI-compatible base URL of the server WITHOUT speculation, ending in /v1")
parser.add_argument("--speculative-url", default=None,
help="base URL of the server WITH speculation; omit to measure one endpoint only")
parser.add_argument("--model", required=True, help="model name the server reports at /v1/models")
parser.add_argument("--max-tokens", type=int, default=192)
parser.add_argument("--seed", type=int, default=0,
help="sent as the request seed; ignored by servers that do not accept one")
parser.add_argument("--repeats", type=int, default=1, help="times to send the whole prompt set")
parser.add_argument("--timeout", type=float, default=300.0)
parser.add_argument("--prompt-set", default="builtin", choices=["builtin", "file"])
parser.add_argument("--prompts", default=None, help="file of prompts, one per line, for --prompt-set file")
parser.add_argument("--label", default="speculative-run", help="tag written into the notebook lines")
parser.add_argument("--engine", default="unknown", help="engine name recorded in the notebook, e.g. llama.cpp")
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 servers need one")
parser.add_argument("--labbook", default="labbook.md")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
prompts = load_prompts(args.prompt_set, args.prompts)
print(f"==> {args.label}: {args.model}, {len(prompts)} prompt(s) x {args.repeats} repeat(s), "
f"max_tokens {args.max_tokens}, temperature 0")
baseline = run_endpoint(endpoint_config(args.baseline_url, args, f"{args.label}/baseline"), prompts, args.repeats)
print_endpoint(baseline)
speculative = None
comparison = None
if args.speculative_url:
speculative = run_endpoint(
endpoint_config(args.speculative_url, args, f"{args.label}/speculative"), prompts, args.repeats
)
print_endpoint(speculative)
comparison = compare_text(baseline["completions"], speculative["completions"])
print(" comparison")
print(f" text {comparison['identical']}/{comparison['compared']} completions identical, "
f"{comparison['divergent']} divergent")
for d in comparison["divergences"]:
print(f" prompt {d['prompt_index']}: first difference at character {d['first_difference_at_char']}")
if baseline["output_tokens_per_s"] and speculative["output_tokens_per_s"]:
ratio = speculative["output_tokens_per_s"] / baseline["output_tokens_per_s"]
print(f" speed {ratio:.2f}x the baseline token rate on this prompt set")
stamp = time.strftime("%Y-%m-%dT%H:%M:%S%z")
for row in (baseline, speculative):
if row is None:
continue
record = dict(row)
# The completions are the evidence for the comparison, not part of the record:
# a notebook line has to stay readable.
record.pop("completions", None)
record.update({
"lab": "part-17/measure-speculative",
"engine": args.engine,
"engine_version": args.engine_version,
"max_tokens": args.max_tokens,
"repeats": args.repeats,
"prompt_count": len(prompts),
"recorded_at": stamp,
})
append_labbook(args.labbook, record)
if comparison is not None:
append_labbook(args.labbook, {
"lab": "part-17/measure-speculative",
"label": f"{args.label}/comparison",
"engine": args.engine,
"engine_version": args.engine_version,
"model": args.model,
"baseline_tokens_per_s": baseline["output_tokens_per_s"],
"speculative_tokens_per_s": speculative["output_tokens_per_s"] if speculative else None,
"text_comparison": comparison,
"recorded_at": stamp,
})
print(f" appended {2 if comparison else 1} line(s) to {args.labbook}")
if baseline["completed"] == 0 or (speculative is not None and speculative["completed"] == 0):
print(" at least one endpoint completed no requests; see the error above.", file=sys.stderr)
return 1
if comparison is not None and comparison["divergent"]:
print(" completions differed between the two servers; the page explains what to check.",
file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

Download measure-speculative.py434 lines

It reports the token rate from each server’s own usage object rather than estimating it. It scrapes each server’s Prometheus endpoint before and after the run and reports what the run added to any counter whose name mentions speculation, drafting or acceptance, which is how you get an acceptance rate without trusting a log line. And it compares the completions byte for byte and tells you where the first difference is, so a run that changed the answers announces itself.

RunnableAll tracks

one endpoint now, before you have any draft at all
python3 measure-speculative.py \
--baseline-url http://127.0.0.1:8080/v1 \
--model local-chat \
--engine llama.cpp \
--label baseline-no-draft \
--labbook labbook.md

The prompt set is deliberately three kinds of work: copy-heavy prompts that quote the input back, closed questions with one predictable answer, and open-ended writing. Run the comparison once per kind, with --prompt-set file and your own prompts, and you will have the only table that answers the question this lesson opened with.

Pending validationSpeculative decoding on your own track: the table this lesson asks you to fill in
WorkloadBaseline tokens/sWith draft tokens/sRatioAcceptanceCompletions identical
Copy-heavy (quote a function back)pendingpendingpendingpendingexpected: yes
Closed questionspendingpendingpendingpendingexpected: yes
Open-ended writingpendingpendingpendingpendingexpected: yes
Concurrency 10, mixedpendingpendingpendingpendingexpected: yes

your track, as installed · llama.cpp or vLLM the build you measured · Qwen3-8B or your fine-tune, the same on both servers · 4,096 tokens of context · the day you ran it

Filled in by the reader from measure-speculative.py. The last two columns are the ones that make the run reproducible: a ratio without an acceptance rate cannot be compared with anybody else's, and a run whose completions differ is a bug report rather than a result.

Suppose a target-only step takes time T. A speculative iteration spends D drafting and V verifying, then keeps an average of K tokens including any correction or bonus token defined by the algorithm. The measured speed ratio is approximately K × T / (D + V) for that portion of generation. It exceeds one only when the saved target work outweighs both costs.

Use measured iteration totals where possible instead of substituting a parameter-count ratio for draft time. Verification cost changes with draft length, kernels and batch shape; acceptance changes with prompt domain and position in the chain. A single average acceptance rate can obscure several different operating regimes.

Repeat the comparison under the concurrency your application will admit. Include memory used by the draft and any reduction in available cache capacity. If speculative mode wins for one interactive request but loses under load, both observations can be correct. Choose the configuration that serves the workload, and record the crossover instead of promoting a universal speedup claim.

Speculative decoding is exact because the target model decides every token: greedily by agreement, and under sampling by an acceptance rule that keeps a proposal with probability min(1, p/q) and, on rejection, samples the normalised residual of p − q. The speed-up is (1 − α^(k+1)) / ((1 − α)(1 + kc)), and it explains everything: the break-even acceptance rate rises steeply with the draft’s cost, the chain length has an interior optimum because the yield saturates while the cost does not, and a cheaper draft beats a cleverer one more often than not. Acceptance is not a single number; it falls along the chain, changes with the workload, and, as EAGLE-2’s authors showed, depends on the context rather than only the position. And the gain shrinks as concurrency rises, because batching and speculation are both spending the accelerator’s idle arithmetic. All of which is measurable in about twenty minutes with two servers, one prompt set and a byte-for-byte comparison.

Check your understanding

Question 1. Under sampling, the draft proposes token x and the target gives it a lower probability than the draft did. What happens?
Show the answer and why

Answer: The token is accepted with probability p(x)/q(x), and on rejection the next token is sampled from the normalised positive part of p − q

Falling back to p on rejection would over-count the tokens p and q agree about, because those were already likely to be accepted. Sampling the residual p − q puts back exactly the mass the rejection removed. That is the "modified rejection sampling scheme" Chen and colleagues describe, and it is why sampled speculative decoding preserves the target distribution rather than approximating it.

Question 2. Your draft costs about a third of a target step and you are drafting eight tokens per iteration. Measurement gives an acceptance rate of about six in ten. What does the arithmetic say?
Show the answer and why

Answer: A loss: at that draft cost and chain length the break-even acceptance rate is around three in four, so the drafting cost exceeds what it saves

Substituting into (1 − α^(k+1)) / ((1 − α)(1 + kc)) gives a ratio below one. The fix is not a longer chain, which makes the denominator worse. It is a cheaper draft, a shorter chain, or no draft. This is the row of the break-even table that catches most people who copied a configuration from somewhere else.

Question 3. Which of these would you expect to raise the measured acceptance rate? Select all that apply.
Show the answer and why

Answer: Switching from open-ended creative writing to a task where the model quotes the prompt back, Shortening the chain so that only the earliest, best-conditioned positions are proposed, Replacing a draft from a different model family with one that shares the target's tokeniser and training lineage

Acceptance falls along the chain, so an average taken over a short chain is higher than one over a long chain, even though the per-position rates are unchanged. Copy-heavy work and a well-matched draft both raise it genuinely. A higher temperature broadens the target distribution and lowers it. Note that shortening the chain raises the average without necessarily raising the speed-up, which is why the two must be recorded together.

Question 4. True or false: if speculative decoding is working correctly, greedy completions from the speculative server should be identical, byte for byte, to those from the baseline server.
Show the answer and why

Answer: True

True, and it is the cheapest test in this part. Verification means each accepted token is one the target itself would have produced. The caveat is the one Chen and colleagues state: preservation holds "within hardware numerics", and a different batch shape changes the order of floating-point reductions, so a rare last-place difference is possible. A systematic divergence is not numerics; it is a mismatched tokeniser or chat template, and it is a bug.

Question 5. Speculation improved your single-user chat noticeably and made your twenty-concurrent evaluation sweep slower. Which explanation fits?
Show the answer and why

Answer: Speculation and continuous batching monetise the same idle arithmetic capacity; at high concurrency batching has already taken it, so verification work competes with real requests

Both techniques exist because decode leaves the arithmetic units idle while weights stream in. One fills that gap across conversations, the other along the time axis of one conversation. They compete for the same slack. This is why vLLM's method table separates gain at low request rates from gain at high ones, and why the right answer is often to enable speculation on the interactive endpoint and not on the batch one.

Sources for this lesson

8 verified · checked 2026-09-09

  1. 01Fast Inference from Transformers via Speculative Decoding§ Abstract; the speculative sampling methodarxiv.org/abs/2211.171922026-09-09
  2. 02Accelerating Large Language Model Decoding with Speculative Sampling§ Abstract; modified rejection samplingarxiv.org/abs/2302.013182026-09-09
  3. 03vLLM — Speculative Decoding§ Configuration keys; method selection; limitationsdocs.vllm.ai/en/latest/features/speculative_decoding2026-09-09
  4. 04vLLM — Production metrics§ Speculative decoding metricsdocs.vllm.ai/en/latest/usage/metrics.html2026-09-09
  5. 05SGLang — Speculative Decoding§ EAGLE and EAGLE3 launch commands; memory notesdocs.sglang.io/advanced_features/speculative_decoding.html2026-09-09
  6. 06EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees§ Abstract; context-dependent acceptancearxiv.org/abs/2406.168582026-09-09
  7. 07EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test§ Abstract; throughput at batch size 64arxiv.org/abs/2503.018402026-09-09
  8. 08llama.cpp — llama-server README§ Speculative decoding options; metrics endpointgithub.com/ggml-org/llama.cpp/blob/master/tools/server/README.md2026-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.