soup draft (v0.71.33)

Speculative decoding is sold as free speed: a small draft model proposes tokens, the big target verifies a whole batch of them in one pass, and you keep the target's exact output. The question nobody answers first is whether the draft would actually propose the tokens your model is about to emit. soup draft measure answers it before you ship, and on the pair we validated the honest answer was no.

Measure first

bash
soup draft measure --target my-tuned-model \
    --draft HuggingFaceTB/SmolLM2-135M-Instruct \
    --prompts prod-prompts.jsonl

# CI gate: exit 2 below the floor, plus a JSON report
soup draft measure ... --min-acceptance 0.6 -o report.json

It reports two things: the acceptance rate and real plain-vs-assisted throughput measured on your box, not estimated.

Acceptance rate is the fraction of the target's own greedy tokens the draft would have proposed correctly (teacher-forced argmax agreement, the metric the Medusa and EAGLE papers report). Higher is better; roughly, 70% and up is where speculative decoding starts paying for the draft's forward pass on realistic hardware. Exit codes are 0 = ok, 2 = below --min-acceptance, 1 = error, so CI can gate on it.

The remaining options: --max-new-tokens (1 to 4096, default 64) is how many tokens the target generates per prompt, --num-assistant-tokens (1 to 64, default 5) is how many tokens the draft proposes per step in the throughput arm, plus --device cpu|cuda and --trust-remote-code. The target is decoded greedily with repetition_penalty pinned to 1.0, so a penalty baked into a checkpoint's generation config does not change which token counts as the target's choice.

The report, and how the assisted arm can end

The throughput measurement has two arms. Acceptance and plain throughput run first and are written to -o straight away; the assisted arm runs after them and the report is rewritten when it finishes. That order matters because the assisted arm can still fail inside transformers, and a failure there no longer throws away a result that already succeeded. The JSON records how the arm ended in assisted_status:

assisted_statusMeaning
pendingWritten before the assisted arm starts; a report that still says this means the process died during that arm without recording an outcome.
completeA positive tokens-per-second figure was measured.
untimedThe arm returned no usable number.
crashThe arm raised. A warning says the assisted throughput could not be measured; tok_s_assisted and speedup stay empty.
interruptedYou pressed Ctrl-C during the arm.

A crash does not change the exit code: the command still exits 0, or 2 if the acceptance is below --min-acceptance. A CI job that depends on the speedup has to read assisted_status, not the exit code.

Distil your own draft

bash
soup draft distill --target my-tuned-model \
    --draft-base HuggingFaceTB/SmolLM2-135M-Instruct \
    --data traffic.jsonl -o draft/

soup serve --model my-tuned-model --auto-spec   # picks up ./draft
soup draft list

distill runs logit KD through the existing task: distill trainer and emits a dense model, because a PEFT adapter directory cannot be loaded as an assistant_model. Before v0.73.3 --steps N delivered roughly N/4.44 optimiser steps, silently: the epoch count that realised the request ignored both the validation split removing rows and gradient accumulation turning several micro-batches into one step. Epochs are now derived from the effective steps per epoch, the emitted config pins the run shape so the arithmetic and the trainer cannot drift, and the pre-flight prints the resolved step count before training starts. Flags: --steps N (training budget), --device cpu, --force (overwrite -o), --plan-only (render the config and exit).

A soup shrink output makes a good draft base: it shares the target's tokenizer by construction.

The local draft registry

soup draft distill registers the draft in ~/.soup/drafts.json, and soup serve --auto-spec consults that registry before the built-in pairing table. A draft you trained yourself is therefore picked up automatically, with no flag to remember. Pass --no-register to soup draft distill to skip the registration; the draft is still written to -o but --auto-spec will not find it. SOUP_DRAFT_REGISTRY_PATH moves the registry file and is used exactly as given; see Environment variables and state files.

Tokenizers: same by default, cross-tokenizer since v0.74.0

The reason the pair used to be refused is not pedantry: speculative decoding proposes draft token ids into the target's vocabulary, so a mismatched pair silently produces garbage instead of failing.

v0.74.0 opened the mismatched case on all three surfaces rather than removing the check. soup draft distill routes a mismatched pair through Universal Logit Distillation (uld_strategy: wasserstein_aligned) instead of refusing it; soup draft measure accepts a pair with the same config.vocab_size but different tokenizers, prints "Cross-tokenizer draft detected" and measures acceptance by aligning decoded character spans rather than token ids (a pair whose config.vocab_size differs is refused with exit 1 before any model loads); and soup serve --speculative-decoding serves such a pair through Transformers Universal Assisted Decoding, raising a clear error when the installed transformers is too old for it. A same-tokenizer pair keeps the existing native fast path exactly as it was, which is the point: the slower alignment only runs where it has to.

One caveat worth carrying: whether a cross-tokenizer draft pays off is a separate question from whether it runs, and the measured answer below is about a same-family pair. Measure before you enable it.

soup draft measure only runs the cross-tokenizer path for a narrow set of pairs, and its acceptance rate there is an approximation. It compares the two models' config.vocab_size first and exits 1 when they differ ("Draft and target must share a tokenizer"), so a pair with different vocabulary sizes is refused here even though soup draft distill accepts it. Only a pair with equal config.vocab_size and different tokenizers reaches the span-aligned measurement. There, token ids cannot be compared, so soup draft measure decodes both sides and counts a target token as accepted when its whole character span falls inside a region where the draft's proposed text and the target's text align. A token that the draft's tokenizer merges across the prompt and continuation boundary cannot match, which can only pull the number down, and the text compared per prompt is capped at 8,192 characters, so the tail of a very long continuation counts as not accepted (the default 64 new tokens never gets near it). Read it as a floor to compare drafts against each other, not as the exact agreement count of the same-tokenizer path.

Honesty: the measured result, including ours

The "~1.5 to 2x faster serving" pitch was withdrawn, not shipped. On SmolLM2-360M-Instruct (target) with a SmolLM2-135M-Instruct draft:

DraftAcceptance
Stock, no distillation69.3%
Distilled, 2 epochs69.7%
Distilled, 10 epochs69.3%

Distillation bought nothing beyond noise. A small same-family draft is already near its capacity ceiling for agreeing with the target, and logit KD cannot buy capacity it does not have. Assisted decoding on that pair measured 0.55 to 0.64x: a net slowdown, because the draft's forward pass costs more than the tokens it saves at this size.

So the feature ships as the honest gate rather than the speedup claim. soup draft measure correctly says "do not enable this" on that pair, and that is the point of shipping the measurement. Whether distillation materially raises acceptance for a genuinely diverged fine-tune, or a larger target and draft pair, is unproven on a 4 GB box and tracked as a scale issue. Run soup draft measure on your pair rather than assuming, including when the assumption comes from us.

Acceptance is teacher-forced greedy agreement. It is exact, deterministic, and the right number for comparing drafts, but it is not the accepted-token count of a sampling run, which also depends on the rejection-resample cascade.

See also

  • Smart Inference Server — --auto-spec, the built-in pairing table, prefix cache.
  • soup shrink — depth-prune a model into a same-tokenizer draft base.
  • soup ship — the other place Soup refuses to guess for you.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.