Speculative decoding: guess, then check
Free speed with no change to the output, which sounds like a lie until you print the distribution yourself.
Download speculative_decoding_from_scratch.py — everything below runs from it. NumPy only.
The KV-cache page ended on an uncomfortable finding: decoding is memory-bound. Every token you generate drags all twelve-and-a-half gigabytes of weights across the memory bus to do about five billion multiply-accumulates, and the chip finishes that arithmetic while it is still waiting for the next chunk of weights. The arithmetic units idle. The compute is, for practical purposes, free.
This paper spends it. That's the entire idea. I ignored it for a year because "speculative" sounded like it meant "approximate", and I don't ship approximate. It doesn't. The output distribution is provably, exactly the big model's — and that guarantee is four lines of code you can verify by counting.
It's branch prediction
Your CPU does this on every conditional. It doesn't wait to know whether the branch is taken. It guesses, runs ahead speculatively, and when the real answer lands it either keeps the work or throws it away. You never get a wrong result. You only ever get wasted work.
# what decoding does today: one expensive call, one token
for _ in range(n):
token = big_model(context) # 12.5 GiB of weights read, 1 token out
context.append(token)
Now guess ahead. A small cheap model drafts k tokens. Then the
big model checks all of them in one call, because a forward pass
over k+1 positions costs about the same as a forward pass over one — you
read the weights once either way.
# speculative: k cheap calls, then ONE expensive call that checks them all
guesses = [small_model(context + guesses) for _ in range(k)] # cheap, serial
p = big_model(context + guesses) # k+1 distributions, one pass
keep = accept(guesses, p) # a coin flip per token, then STOP
context.extend(keep) # 1 to k+1 tokens for one big call
The clever part is not the drafting. Anyone would think of the drafting.
The clever part is accept, which is rigged so that the tokens
coming out the other end are distributed exactly as if the big
model had generated them alone. Not approximately. Exactly.
| Term | Means | Programmer's analogy |
|---|---|---|
| Target model | The big one whose answers you promised your users | The real branch outcome |
| Draft model | A small cheap model, same tokeniser | The branch predictor |
| p, q | Target's and draft's probability for the same next token | Two dicts you compare key by key |
| Accept | Keep the guess, with a probability that corrects for the draft's bias | Prediction hit — commit the work |
| Reject | Throw away this guess and everything after it | Pipeline flush |
The one formula
Two lines, and the second is the one everybody forgets.
on rejection, sample from p′(x) = norm( max(0, p(x) − q(x)) )
Read it as a pipeline:
| Step | Cost | What it does |
|---|---|---|
draft k tokens | k cheap serial calls | Guess ahead. Each guess conditioned on the previous guess |
one target call | 1 expensive call | Returns k+1 distributions — one per prefix, all at once |
min(1, p/q) | a coin flip | The draft over-proposed x? Keep only p/q of them. Under-proposed? Keep all |
norm(max(0, p−q)) | one sample | The residual. Exactly the mass the accept step failed to deliver |
stop | free | Everything after a rejection was conditioned on a token that no longer exists |
Accept plus residual add back up to p. That's the proof — an identity you can check with a calculator, not a limit argument.
The rule on one token
My models here are not neural nets. They are 6×6 probability tables — a row per current token, the row being the next-token distribution. That's deliberate. The paper's claim is about distributions, and a table is the only model whose true distribution I can print and compare against. A 70B target and a 6×6 table obey the same acceptance rule.
Don't take my word for it. Here's the actual output:
context token : 'a'
target p(.|ctx) : [0.0943 0.31 0.0908 0.0015 0.3821 0.1213]
draft q(.|ctx) : [0.0823 0.2638 0.0777 0.0013 0.4479 0.127 ]
draft proposes 'b'
q[b] = 0.2638, p[b] = 0.3100
accept with min(1, p/q) = min(1, 0.3100/0.2638) = 1.0000 ✓
draft under-proposes it -> always accept
draft proposes 'e'
q[e] = 0.4479, p[e] = 0.3821
accept with min(1, p/q) = min(1, 0.3821/0.4479) = 0.8532
draft over-proposes it -> reject 14.7% of the time
on rejection, resample from residual = norm(max(0, p - q)):
p - q : [ 0.0119 0.0461 0.0131 0.0002 -0.0657 -0.0057]
residual : [0.1673 0.6457 0.1838 0.0032 0. 0. ]
Follow it by hand. The draft says "e" too often, so 14.7% of the time we throw that guess away. But then we can't just resample from p — those rejections are not spread evenly across the vocabulary, they're concentrated on exactly the tokens the draft over-proposed. So we sample from what's missing: p minus q, negatives clipped to zero, renormalised. Notice "e" and "f" have probability 0 in the residual. The draft already delivered enough of those.
The correctness guarantee, counted
This is the section that matters. Everything else is performance engineering; this is the reason you're allowed to turn it on in production. I run the whole scheme 60,000 times from a fixed context, keep the first two emitted tokens, and compare the empirical joint against the target model's true joint. Then I break the rule three ways and measure how far the distribution drifts.
The measure is total variation distance: half the sum of absolute differences. It's the largest probability two distributions can disagree about on any event. Zero means identical.
60000 rounds, k=4 drafted tokens per round, first two tokens kept
first-token marginal, target vs speculative decoding:
token target speculative diff
a 0.0943 0.0905 -0.0038
b 0.3100 0.3155 +0.0056
c 0.0908 0.0920 +0.0012
d 0.0015 0.0015 -0.0001
e 0.3821 0.3801 -0.0020
f 0.1213 0.1204 -0.0010
total variation distance on the two-token joint:
correct rule : 0.0079 ✓
BROKEN: accept on argmax agreement : 0.2735
BROKEN: resample from p, not residual : 0.0349
BROKEN: keep drafts after a rejection : 0.0333
sampling-error floor (target model sampled directly): 0.0059
Read the last line first. If I sample 60,000 pairs from the target model itself and compare that to the target model, I get 0.0059 — that's pure counting noise, the price of a finite sample. Speculative decoding with the correct rule sits at 0.0079. Same floor. It is not "close to" the target model; it is the target model, and the residue is my sample size.
The three broken rules sit four to thirty times above that floor. That is a different model answering your users, and no eval you own will tell you which one it was.
Acceptance rate is the whole game
Two numbers decide whether this pays: how often the draft is accepted, and how much the draft costs. Nothing else. I sweep the draft from a perfect copy of the target down to junk, with the draft priced at 10% of a target call per token, drafting 4 tokens a round:
k=4 drafted tokens per round, draft cost c=0.10 of a target call
draft quality accept rate tokens/target call speedup
alpha=1.00 (perfect) 1.0000 5.0000 3.571x ✓
alpha=0.95 0.9204 4.6814 3.344x
alpha=0.85 0.7769 4.1076 2.934x
alpha=0.60 0.4964 2.9856 2.133x
alpha=0.30 0.2698 2.0792 1.485x
alpha=0.00 (junk) 0.1316 1.5265 1.090x
temperature mismatch (same model, wrong sampling temperature):
draft T=1.0 vs target T=1.0 : accept 1.0000, tokens/call 5.0000, 3.571x
draft T=1.3 vs target T=1.0 : accept 0.8625, tokens/call 4.4501, 3.179x
draft T=2.0 vs target T=1.0 : accept 0.6658, tokens/call 3.6631, 2.617x
draft T=0.5 vs target T=1.0 : accept 0.6743, tokens/call 3.6970, 2.641x
A perfect draft never gets rejected, so you emit all 4 drafted tokens plus the free bonus token the target already computed at the last position: 5 tokens for one target call. A junk draft emits 1.5 and, after paying for the drafting, buys you 9%. These are my toy model's numbers, not the paper's; the shape is what transfers, not the magnitudes.
The temperature rows are the ones I'd put on an incident postmortem. Nothing is wrong with either model. The draft is the target, exactly, just sampled at a different temperature — and acceptance falls from 1.00 to 0.67. Acceptance rate is a property of the pair, not of either model. It's also why the draft must share the target's tokeniser: without that, p and q aren't distributions over the same set and the ratio p/q is meaningless.
How far ahead to guess
Draft one token and you've barely used the free compute. Draft sixteen and you're paying for fifteen guesses that get flushed the moment the first one is rejected. There is an optimum, and it moves with acceptance rate:
middling draft (alpha=0.55), draft cost c=0.10
k accept rate tokens/target call cost 1+k*c speedup
1 0.7078 1.7078 1.10 1.553x
2 0.5997 2.1995 1.20 1.833x
3 0.5194 2.5583 1.30 1.968x
4 0.4494 2.7974 1.40 1.998x
5 0.4002 3.0009 1.50 2.001x ✓
6 0.3490 3.0937 1.60 1.934x
8 0.2829 3.2634 1.80 1.813x
12 0.1987 3.3845 2.20 1.538x
16 0.1486 3.3774 2.60 1.299x
best k here: 5 at 2.001x
Watch the two middle columns pull against each other. Tokens per call saturates — going from k=8 to k=16 buys 0.11 of a token, because the run almost never survives eight acceptances. Meanwhile the cost column keeps climbing linearly, 0.10 per extra draft, whether the token survives or not. That's the whole shape of the curve: a saturating numerator over a linear denominator.
When the draft model makes you slower
The cost model is one line. A round costs one target call plus k draft calls, where c is the draft's cost per token as a fraction of a target call. So speedup = tokens per round ÷ (1 + k·c), and you break even when c = (tokens per round − 1) / k.
middling draft, k=4, tokens per target call = 2.8064
break-even draft cost c* = (tokens - 1)/k = 0.4516
i.e. the draft model must cost less than 45.2% of the target per token.
draft cost c 1 + k*c speedup
0.0100 1.0400 2.698x
0.0500 1.2000 2.339x
0.1000 1.4000 2.005x ✓
0.4516 2.8064 1.000x <-- break-even
0.6000 3.4000 0.825x SLOWER than no speculation
1.0000 5.0000 0.561x SLOWER than no speculation
a 'draft' that is a quarter of the target (c=0.25) and only
as good as our poor draft:
tokens/call 1.5523, speedup 0.776x
That last block is the failure mode people actually ship. You reach for a bigger draft model because it gets accepted more often — and a draft at a quarter of the target's cost, with a poor acceptance rate, lands at 0.776x. You made inference slower and the output distribution is still perfectly correct, so nothing alerts. The only symptom is the p50 latency graph.
In practice c is a ratio of parameter counts, roughly: a 7B drafting for a 70B is around 0.1. A model drafting for itself is c = 1 and can never win.
Where the free compute comes from
Back to the KV-cache finding, but spent instead of merely observed. The weights you drag off memory for a forward pass are the same weights whether that pass covers one token or sixteen. Only the arithmetic scales.
Llama-2-7B-shaped, 512 tokens of context, fp16 weights
weights dragged off memory per forward pass: 13,480,000,000 bytes (12.55 GiB)
tokens in the pass MACs MACs per byte moved
1 5,167,382,528 0.38
2 10,334,765,056 0.77
4 20,669,530,112 1.53
8 41,339,060,224 3.07
16 82,678,120,448 6.13 ✓
An accelerator needs roughly 100–300 MACs per byte of memory traffic to keep its arithmetic units busy. One-token decoding sits at 0.38 — off by nearly three orders of magnitude. Verifying sixteen tokens in the same pass moves the identical bytes, does sixteen times the arithmetic, and still doesn't saturate the chip. The k extra tokens are close to free.
What came after
Chen et al. published the same acceptance rule independently a couple of months later (arXiv 2302.01318), applied to Chinchilla — worth knowing because half the blog posts you'll read cite one and half cite the other.
The obvious annoyance with all of this is that you now have to serve two models, keep them on the same tokeniser, and keep them in sync through every fine-tune. Later work removes the second model: bolt extra prediction heads onto the target model itself so it drafts its own continuations, Medusa being the well-known example. Same acceptance machinery, one set of weights. Different paper, different page.
KV-cache பக்கம் ஒரு அசௌகரியமான கண்டுபிடிப்புடன் முடிந்தது: decoding memory-bound. நீங்கள் உருவாக்கும் ஒவ்வொரு token-க்கும் பன்னிரண்டரை gigabyte weights-ஐ memory bus வழியாக இழுத்து வர வேண்டும் — செய்வதோ சுமார் ஐந்து பில்லியன் multiply-accumulate மட்டும். அடுத்த weight துண்டு வருவதற்குள்ளாகவே chip அந்தக் கணக்கை முடித்துவிடுகிறது. Arithmetic units சும்மா உட்கார்ந்திருக்கின்றன. நடைமுறையில் compute இலவசம்.
இந்தக் கட்டுரை அந்த இலவச compute-ஐச் செலவழிக்கிறது. அவ்வளவுதான் கருத்து. "speculative" என்றால் "தோராயமானது" என்று அர்த்தம் என நினைத்து ஒரு வருடம் இதை ஒதுக்கிவைத்தேன் — நான் தோராயமானதை ship செய்வதில்லை. ஆனால் இது தோராயமானது இல்லை. வெளியீட்டு distribution நிரூபிக்கக்கூடிய அளவுக்கு, சரியாகப் பெரிய model-ஓடுதே — அந்த உத்தரவாதம் நீங்கள் எண்ணிப் பார்த்து சரிபார்க்கக்கூடிய நான்கு வரி code.
இது branch prediction தான்
ஒவ்வொரு conditional-லும் உங்கள் CPU இதைத்தான் செய்கிறது. branch எடுக்கப்படுமா என்று தெரியும்வரை அது காத்திருப்பதில்லை. ஊகிக்கிறது, speculative-ஆக முன்னால் ஓடுகிறது, உண்மையான விடை வந்தபிறகு அந்த வேலையை வைத்துக்கொள்கிறது அல்லது தூக்கி எறிகிறது. உங்களுக்கு ஒருபோதும் தவறான விடை கிடைக்காது. வீணான வேலை மட்டுமே கிடைக்கும்.
# இன்று decoding செய்வது: ஒரு விலையுயர்ந்த call, ஒரு token
for _ in range(n):
token = big_model(context) # 12.5 GiB weights read, 1 token out
context.append(token)
இப்போது முன்னால் ஊகியுங்கள். ஒரு சிறிய மலிவான model k tokens
draft செய்கிறது. பிறகு பெரிய model அவை அனைத்தையும் ஒரே call-ல்
சரிபார்க்கிறது — ஏனெனில் k+1 positions மீதான ஒரு forward pass-ன் விலை, ஒரு
position மீதான pass-ன் விலையளவுதான்: இரண்டிலும் weights-ஐ ஒரு முறைதான்
படிக்கிறீர்கள்.
# speculative: k மலிவான calls, பிறகு எல்லாவற்றையும் சரிபார்க்கும் ஒரே call
guesses = [small_model(context + guesses) for _ in range(k)] # மலிவு, serial
p = big_model(context + guesses) # k+1 distributions, ஒரே pass
keep = accept(guesses, p) # token-க்கு ஒரு coin flip, பிறகு STOP
context.extend(keep) # ஒரு big call-க்கு 1 முதல் k+1 tokens
புத்திசாலித்தனமான பகுதி drafting இல்லை. drafting யாருக்கும் தோன்றும்.
புத்திசாலித்தனமான பகுதி accept — அது எப்படி அமைக்கப்பட்டிருக்கிறது
என்றால், மறுமுனையில் வெளிவரும் tokens, பெரிய model தனியாக உருவாக்கியிருந்தால்
எப்படி இருந்திருக்குமோ அப்படியே distribute ஆகின்றன. தோராயமாக இல்லை.
சரியாக.
| சொல் | அர்த்தம் | Programmer-க்கான உதாரணம் |
|---|---|---|
| Target model | உங்கள் users-க்கு வாக்குறுதி அளித்த விடைகளைத் தரும் பெரிய model | உண்மையான branch outcome |
| Draft model | சிறிய மலிவான model, அதே tokeniser | Branch predictor |
| p, q | அதே அடுத்த token-க்கு target-ன், draft-ன் probability | key வாரியாக ஒப்பிடும் இரு dicts |
| Accept | ஊகத்தை வைத்துக்கொள்: draft-ன் சாய்வைத் திருத்தும் ஒரு probability-யுடன் | Prediction hit — வேலையை commit செய் |
| Reject | இந்த ஊகத்தையும் அதற்குப் பிறகு வந்த அனைத்தையும் தூக்கி எறி | Pipeline flush |
Draft model munnadi k tokens guess pannudhu, target model ellathaiyum ore call-la verify pannudhu. Clever part drafting illa — accept rule dhaan.
அந்த ஒரு சூத்திரம்
இரண்டு வரிகள். இரண்டாவதுதான் எல்லோரும் மறப்பது.
on rejection, sample from p′(x) = norm( max(0, p(x) − q(x)) )
இதை ஒரு pipeline-ஆகப் படியுங்கள்:
| படி | விலை | என்ன செய்கிறது |
|---|---|---|
draft k tokens | k மலிவான serial calls | முன்னால் ஊகி. ஒவ்வொரு ஊகமும் முந்தைய ஊகத்தின் மீது நிபந்தனை |
one target call | 1 விலையுயர்ந்த call | k+1 distributions திருப்பித் தரும் — prefix-க்கு ஒன்று, ஒரே நேரத்தில் |
min(1, p/q) | ஒரு coin flip | draft x-ஐ அதிகமாக முன்மொழிந்ததா? p/q பங்கை மட்டும் வை. குறைவாகவா? எல்லாவற்றையும் வை |
norm(max(0, p−q)) | ஒரு sample | Residual. accept படி தர தவறிய அளவு, சரியாக அதுவே |
stop | இலவசம் | rejection-க்குப் பிறகு வந்த அனைத்தும், இப்போது இல்லாத ஒரு token மீது நிபந்தனை கொண்டவை |
Accept-ம் residual-ம் சேர்ந்து மீண்டும் p ஆகின்றன. அதுதான் நிரூபணம் — limit வாதம் அல்ல, calculator-ல் சரிபார்க்கக்கூடிய ஒரு அடையாளம்.
ஒரு token மீது இந்த விதி
இங்கு என் models neural nets இல்லை. அவை 6×6 probability tables — தற்போதைய token-க்கு ஒரு row, அந்த row-தான் அடுத்த token-ன் distribution. இது வேண்டுமென்றே. கட்டுரையின் கூற்று distributions பற்றியது; உண்மையான distribution-ஐ print செய்து ஒப்பிடக்கூடிய ஒரே model, ஒரு table மட்டுமே. ஒரு 70B target-ம் ஒரு 6×6 table-ம் ஒரே acceptance rule-க்குக் கீழ்ப்படிகின்றன.
என் வார்த்தையை நம்ப வேண்டாம். இதோ உண்மையான output:
context token : 'a'
target p(.|ctx) : [0.0943 0.31 0.0908 0.0015 0.3821 0.1213]
draft q(.|ctx) : [0.0823 0.2638 0.0777 0.0013 0.4479 0.127 ]
draft proposes 'b'
q[b] = 0.2638, p[b] = 0.3100
accept with min(1, p/q) = min(1, 0.3100/0.2638) = 1.0000 ✓
draft under-proposes it -> always accept
draft proposes 'e'
q[e] = 0.4479, p[e] = 0.3821
accept with min(1, p/q) = min(1, 0.3821/0.4479) = 0.8532
draft over-proposes it -> reject 14.7% of the time
on rejection, resample from residual = norm(max(0, p - q)):
p - q : [ 0.0119 0.0461 0.0131 0.0002 -0.0657 -0.0057]
residual : [0.1673 0.6457 0.1838 0.0032 0. 0. ]
கையால் பின்தொடருங்கள். draft "e"-ஐ அடிக்கடி சொல்கிறது, எனவே 14.7% நேரம் அந்த ஊகத்தை நாம் தூக்கி எறிகிறோம். ஆனால் பிறகு p-லிருந்து வெறுமனே resample செய்ய முடியாது — அந்த rejections vocabulary முழுக்கச் சமமாகப் பரவவில்லை, draft அதிகமாக முன்மொழிந்த tokens மீதுதான் குவிந்திருக்கின்றன. எனவே விடுபட்டதில் இருந்து sample செய்கிறோம்: p கழித்தல் q, negatives பூஜ்ஜியமாக clip செய்யப்பட்டு, மறு-normalise. residual-ல் "e"-ம் "f"-ம் probability 0 என்பதைக் கவனியுங்கள். அவற்றை draft ஏற்கனவே போதுமான அளவு தந்துவிட்டது.
Reject aana odane p-la irundhu resample panna koodadhu — adhu double count. Residual = max(0, p−q) normalise pannadhu; accept + residual = p. Adhu dhaan proof.
Correctness உத்தரவாதம், எண்ணிப் பார்த்தது
இதுதான் முக்கியமான பகுதி. மற்ற எல்லாம் performance engineering; production-ல் இதை ON செய்ய உங்களுக்கு அனுமதி தருவது இதுதான். ஒரு நிலையான context-லிருந்து முழுத் திட்டத்தையும் 60,000 முறை ஓட்டி, வெளியான முதல் இரண்டு tokens-ஐ வைத்துக்கொண்டு, அனுபவப்பூர்வ joint-ஐ target model-ன் உண்மையான joint-உடன் ஒப்பிடுகிறேன். பிறகு விதியை மூன்று வழிகளில் உடைத்து, distribution எவ்வளவு விலகுகிறது என்று அளக்கிறேன்.
அளவீடு: total variation distance — முழுமையான வேறுபாடுகளின் கூட்டுத்தொகையில் பாதி. எந்த ஒரு நிகழ்வைப் பற்றியும் இரு distributions எவ்வளவு அதிகபட்சமாக முரண்படலாம் என்பதே அது. பூஜ்ஜியம் என்றால் ஒரே மாதிரி.
60000 rounds, k=4 drafted tokens per round, first two tokens kept
first-token marginal, target vs speculative decoding:
token target speculative diff
a 0.0943 0.0905 -0.0038
b 0.3100 0.3155 +0.0056
c 0.0908 0.0920 +0.0012
d 0.0015 0.0015 -0.0001
e 0.3821 0.3801 -0.0020
f 0.1213 0.1204 -0.0010
total variation distance on the two-token joint:
correct rule : 0.0079 ✓
BROKEN: accept on argmax agreement : 0.2735
BROKEN: resample from p, not residual : 0.0349
BROKEN: keep drafts after a rejection : 0.0333
sampling-error floor (target model sampled directly): 0.0059
கடைசி வரியை முதலில் படியுங்கள். target model-லிருந்தே 60,000 ஜோடிகளை sample செய்து அதை target model-உடன் ஒப்பிட்டால் எனக்கு 0.0059 கிடைக்கிறது — அது வெறும் எண்ணிக்கை noise, ஒரு வரையறுக்கப்பட்ட sample-ன் விலை. சரியான விதியுடன் speculative decoding 0.0079-ல் அமர்கிறது. அதே தரை. இது target model-க்கு "அருகில்" இல்லை; இது target model-ஆகவே இருக்கிறது, மீதி என் sample size மட்டுமே.
உடைந்த மூன்று விதிகளும் அந்தத் தரையை விட நான்கு முதல் முப்பது மடங்கு மேலே இருக்கின்றன. அது உங்கள் users-க்கு பதில் சொல்லும் வேறொரு model — அது எது என்று உங்களிடம் இருக்கும் எந்த eval-ம் சொல்லாது.
Acceptance rate தான் முழு விளையாட்டு
இது லாபகரமா என்பதை இரண்டு எண்கள் தீர்மானிக்கின்றன: draft எத்தனை முறை accept ஆகிறது, draft எவ்வளவு செலவாகிறது. வேறு எதுவும் இல்லை. target-ன் சரியான நகலிலிருந்து குப்பை வரை draft-ஐ sweep செய்கிறேன்; draft-ன் விலை ஒரு token-க்கு ஒரு target call-ல் 10%, ஒரு round-க்கு 4 tokens draft:
k=4 drafted tokens per round, draft cost c=0.10 of a target call
draft quality accept rate tokens/target call speedup
alpha=1.00 (perfect) 1.0000 5.0000 3.571x ✓
alpha=0.95 0.9204 4.6814 3.344x
alpha=0.85 0.7769 4.1076 2.934x
alpha=0.60 0.4964 2.9856 2.133x
alpha=0.30 0.2698 2.0792 1.485x
alpha=0.00 (junk) 0.1316 1.5265 1.090x
temperature mismatch (same model, wrong sampling temperature):
draft T=1.0 vs target T=1.0 : accept 1.0000, tokens/call 5.0000, 3.571x
draft T=1.3 vs target T=1.0 : accept 0.8625, tokens/call 4.4501, 3.179x
draft T=2.0 vs target T=1.0 : accept 0.6658, tokens/call 3.6631, 2.617x
draft T=0.5 vs target T=1.0 : accept 0.6743, tokens/call 3.6970, 2.641x
சரியான draft ஒருபோதும் reject ஆகாது, எனவே draft செய்த 4 tokens-ஓடு, கடைசி position-ல் target ஏற்கனவே கணக்கிட்ட இலவச bonus token-ம் சேர்த்து வெளியிடுகிறீர்கள்: ஒரு target call-க்கு 5 tokens. குப்பை draft 1.5 வெளியிடுகிறது, drafting-க்குச் செலவழித்த பிறகு உங்களுக்கு 9% கிடைக்கிறது. இவை என் toy model-ன் எண்கள், கட்டுரையினுடையவை அல்ல; மாறுவது வடிவம், அளவுகள் அல்ல.
Temperature வரிசைகளைத்தான் நான் ஒரு incident postmortem-ல் வைப்பேன். இரு model-லும் எதுவும் தவறில்லை. draft சரியாக target-தான், வெறும் வேறு ஒரு temperature-ல் sample செய்யப்பட்டது — acceptance 1.00-லிருந்து 0.67-க்கு விழுகிறது. Acceptance rate என்பது ஜோடியின் பண்பு, எந்த ஒரு model-னுடையதும் அல்ல. draft, target-ன் tokeniser-ஐப் பகிர்ந்துகொள்ள வேண்டியதற்கும் இதுவே காரணம்: இல்லாவிட்டால் p-ம் q-ம் ஒரே தொகுப்பின் மீதான distributions அல்ல, p/q விகிதத்திற்கு அர்த்தமே இல்லை.
எவ்வளவு முன்னால் ஊகிப்பது
ஒரு token draft செய்தால் இலவச compute-ஐ கிட்டத்தட்ட பயன்படுத்தவே இல்லை. பதினாறு draft செய்தால், முதல் ஊகம் reject ஆன கணத்தில் flush ஆகும் பதினைந்து ஊகங்களுக்குப் பணம் கொடுக்கிறீர்கள். ஒரு உகந்த புள்ளி உண்டு, அது acceptance rate-உடன் நகர்கிறது:
middling draft (alpha=0.55), draft cost c=0.10
k accept rate tokens/target call cost 1+k*c speedup
1 0.7078 1.7078 1.10 1.553x
2 0.5997 2.1995 1.20 1.833x
3 0.5194 2.5583 1.30 1.968x
4 0.4494 2.7974 1.40 1.998x
5 0.4002 3.0009 1.50 2.001x ✓
6 0.3490 3.0937 1.60 1.934x
8 0.2829 3.2634 1.80 1.813x
12 0.1987 3.3845 2.20 1.538x
16 0.1486 3.3774 2.60 1.299x
best k here: 5 at 2.001x
நடுவில் உள்ள இரு columns ஒன்றையொன்று இழுப்பதைக் கவனியுங்கள். Call-க்கான tokens நிறைவடைகிறது — k=8-லிருந்து k=16-க்குப் போவது 0.11 token மட்டுமே தருகிறது, ஏனெனில் எட்டு acceptances-ஐ அந்த run கிட்டத்தட்ட தாண்டுவதே இல்லை. அதே நேரத்தில் cost column நேர்கோட்டில் ஏறிக்கொண்டே இருக்கிறது — கூடுதல் draft-க்கு 0.10, அந்த token தப்பிப்பிழைத்தாலும் இல்லாவிட்டாலும். வளைவின் முழு வடிவம் அதுதான்: நிறைவடையும் தொகுதி, நேர்கோட்டுப் பகுதி.
Draft model உங்களை மெதுவாக்கும்போது
Cost model ஒரே வரி. ஒரு round-க்கு ஒரு target call கூட்டல் k draft calls; இங்கு c என்பது ஒரு target call-ன் பகுதியாக draft-ன் token விலை. எனவே speedup = round-க்கான tokens ÷ (1 + k·c); c = (round-க்கான tokens − 1) / k என்னும்போது break-even.
middling draft, k=4, tokens per target call = 2.8064
break-even draft cost c* = (tokens - 1)/k = 0.4516
i.e. the draft model must cost less than 45.2% of the target per token.
draft cost c 1 + k*c speedup
0.0100 1.0400 2.698x
0.0500 1.2000 2.339x
0.1000 1.4000 2.005x ✓
0.4516 2.8064 1.000x <-- break-even
0.6000 3.4000 0.825x SLOWER than no speculation
1.0000 5.0000 0.561x SLOWER than no speculation
a 'draft' that is a quarter of the target (c=0.25) and only
as good as our poor draft:
tokens/call 1.5523, speedup 0.776x
அந்தக் கடைசி block-தான் மக்கள் உண்மையில் ship செய்யும் failure mode. அதிக முறை accept ஆகும் என்பதற்காக ஒரு பெரிய draft model-ஐ எடுக்கிறீர்கள் — target-ன் விலையில் கால் பங்கு draft, மோசமான acceptance rate-உடன், 0.776x-ல் வந்து நிற்கிறது. Inference-ஐ மெதுவாக்கிவிட்டீர்கள், வெளியீட்டு distribution இன்னும் முற்றிலும் சரியாகவே இருக்கிறது, எனவே எந்த alert-ம் வராது. ஒரே அறிகுறி p50 latency graph மட்டுமே.
நடைமுறையில் c என்பது தோராயமாக parameter எண்ணிக்கைகளின் விகிதம்: 70B-க்கு draft செய்யும் 7B சுமார் 0.1. தனக்குத்தானே draft செய்யும் model-ன் c = 1, அது ஒருபோதும் ஜெயிக்க முடியாது.
இலவச compute எங்கிருந்து வருகிறது
மீண்டும் KV-cache கண்டுபிடிப்புக்கு — ஆனால் இப்போது வெறுமனே கவனிக்கப்படாமல் செலவழிக்கப்படுகிறது. ஒரு forward pass-க்காக memory-லிருந்து இழுக்கும் weights, அந்த pass ஒரு token-ஐ மூடினாலும் பதினாறை மூடினாலும் அதே weights. Arithmetic மட்டுமே அளவில் வளர்கிறது.
Llama-2-7B-shaped, 512 tokens of context, fp16 weights
weights dragged off memory per forward pass: 13,480,000,000 bytes (12.55 GiB)
tokens in the pass MACs MACs per byte moved
1 5,167,382,528 0.38
2 10,334,765,056 0.77
4 20,669,530,112 1.53
8 41,339,060,224 3.07
16 82,678,120,448 6.13 ✓
ஒரு accelerator-ன் arithmetic units வேலையில் இருக்க, memory traffic-ன் ஒவ்வொரு byte-க்கும் தோராயமாக 100–300 MACs தேவை. ஒரு-token decoding 0.38-ல் அமர்ந்திருக்கிறது — கிட்டத்தட்ட மூன்று அளவுக் கோடுகள் குறைவு. அதே pass-ல் பதினாறு tokens-ஐ verify செய்தால் அதே bytes நகர்கின்றன, பதினாறு மடங்கு arithmetic நடக்கிறது, இருந்தும் chip நிறைவடையவில்லை. அந்த k கூடுதல் tokens கிட்டத்தட்ட இலவசம்.
அதற்குப் பிறகு வந்தவை
Chen மற்றும் பலர் இதே acceptance rule-ஐ சில மாதங்கள் கழித்துத் தனியாக வெளியிட்டார்கள் (arXiv 2302.01318), Chinchilla-வில் பயன்படுத்தி — நீங்கள் படிக்கும் blog posts-ல் பாதி ஒன்றையும் பாதி மற்றொன்றையும் மேற்கோள் காட்டுவதால் இதை அறிந்திருப்பது நல்லது.
இதிலெல்லாம் உள்ள வெளிப்படையான தொல்லை: இப்போது இரண்டு models-ஐ serve செய்ய வேண்டும், இரண்டையும் ஒரே tokeniser-ல் வைத்திருக்க வேண்டும், ஒவ்வொரு fine-tune-லும் ஒத்திசைவாக வைத்திருக்க வேண்டும். பிற்கால வேலைகள் இரண்டாவது model-ஐ நீக்குகின்றன: target model மீதே கூடுதல் prediction heads பொருத்தி, அது தன் தொடர்ச்சிகளையே draft செய்யும் — Medusa அதற்கு நன்கு அறியப்பட்ட உதாரணம். அதே acceptance இயந்திரம், ஒரே தொகுப்பு weights. வேறு கட்டுரை, வேறு பக்கம்.
KV-cache पेज एक असहज नतीजे पर खत्म हुआ था: decoding memory-bound है। आप जो भी एक token बनाते हैं, उसके लिए साढ़े बारह gigabyte weights को memory bus पर खींचना पड़ता है, और काम होता है लगभग पाँच अरब multiply-accumulate का। अगला weight टुकड़ा आने से पहले ही chip वह गणित खत्म कर चुका होता है। Arithmetic units खाली बैठे रहते हैं। व्यावहारिक रूप से compute मुफ़्त है।
यह पेपर उसी मुफ़्त compute को खर्च करता है। पूरा विचार बस इतना है। मैंने इसे एक साल नज़रअंदाज़ किया क्योंकि "speculative" सुनने में "approximate" जैसा लगता था, और मैं approximate चीज़ें ship नहीं करता। पर यह approximate है ही नहीं। output distribution प्रमाणित रूप से, बिल्कुल वही है जो बड़े model की होती — और वह गारंटी चार लाइन का code है, जिसे आप गिनकर जाँच सकते हैं।
यह branch prediction ही है
आपका CPU हर conditional पर यही करता है। वह यह जानने के लिए नहीं रुकता कि branch लिया जाएगा या नहीं। वह अनुमान लगाता है, speculative तरीके से आगे दौड़ता है, और असली जवाब आने पर या तो काम रख लेता है या फेंक देता है। आपको गलत नतीजा कभी नहीं मिलता। सिर्फ़ बेकार गया काम मिलता है।
# आज decoding जो करता है: एक महँगी call, एक token
for _ in range(n):
token = big_model(context) # 12.5 GiB weights पढ़े, 1 token बाहर
context.append(token)
अब आगे का अनुमान लगाइए। एक छोटा सस्ता model k tokens draft
करता है। फिर बड़ा model उन सबको एक ही call में जाँचता है, क्योंकि
k+1 positions पर एक forward pass की लागत लगभग एक position वाले pass जितनी
ही है — weights तो दोनों में एक ही बार पढ़े जाते हैं।
# speculative: k सस्ती calls, फिर एक महँगी call जो सबकी जाँच करती है
guesses = [small_model(context + guesses) for _ in range(k)] # सस्ता, serial
p = big_model(context + guesses) # k+1 distributions, एक ही pass
keep = accept(guesses, p) # हर token पर coin flip, फिर STOP
context.extend(keep) # एक big call पर 1 से k+1 tokens
होशियारी वाला हिस्सा drafting नहीं है। drafting तो किसी को भी सूझ जाएगी।
होशियारी वाला हिस्सा accept है, जिसे इस तरह गढ़ा गया है कि दूसरी
तरफ़ से निकलने वाले tokens बिल्कुल उसी तरह distribute होते हैं जैसे
बड़ा model अकेले उन्हें बनाता। लगभग नहीं। बिल्कुल।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Target model | वही बड़ा model जिसके जवाबों का वादा आपने users से किया है | असली branch outcome |
| Draft model | छोटा सस्ता model, वही tokeniser | Branch predictor |
| p, q | उसी अगले token के लिए target और draft की probability | दो dicts, key दर key तुलना |
| Accept | अनुमान रख लो, ऐसी probability से जो draft के झुकाव को सुधार दे | Prediction hit — काम commit करो |
| Reject | यह अनुमान और उसके बाद का सब कुछ फेंक दो | Pipeline flush |
वह एक सूत्र
दो लाइनें, और दूसरी वही है जिसे सब भूल जाते हैं।
on rejection, sample from p′(x) = norm( max(0, p(x) − q(x)) )
इसे एक pipeline की तरह पढ़िए:
| चरण | लागत | यह क्या करता है |
|---|---|---|
draft k tokens | k सस्ती serial calls | आगे का अनुमान। हर अनुमान पिछले अनुमान पर conditioned |
one target call | 1 महँगी call | k+1 distributions लौटाती है — हर prefix के लिए एक, सब एक साथ |
min(1, p/q) | एक coin flip | draft ने x को ज़्यादा प्रस्तावित किया? सिर्फ़ p/q हिस्सा रखो। कम किया? सब रखो |
norm(max(0, p−q)) | एक sample | Residual. ठीक उतना ही द्रव्यमान जितना accept चरण नहीं दे पाया |
stop | मुफ़्त | rejection के बाद का सब कुछ ऐसे token पर conditioned था जो अब मौजूद ही नहीं |
Accept और residual मिलकर वापस p बन जाते हैं। यही प्रमाण है — कोई limit वाला तर्क नहीं, बल्कि एक पहचान जिसे आप calculator से जाँच सकते हैं।
एक token पर यह नियम
यहाँ मेरे models neural nets नहीं हैं। वे 6×6 probability tables हैं — हर मौजूदा token के लिए एक row, और वह row ही अगले token का distribution है। यह जानबूझकर है। पेपर का दावा distributions के बारे में है, और table ही वह इकलौता model है जिसका सच्चा distribution मैं print करके मिला सकता हूँ। एक 70B target और एक 6×6 table, दोनों एक ही acceptance rule मानते हैं।
मेरी बात पर भरोसा मत कीजिए। यह रहा असली output:
context token : 'a'
target p(.|ctx) : [0.0943 0.31 0.0908 0.0015 0.3821 0.1213]
draft q(.|ctx) : [0.0823 0.2638 0.0777 0.0013 0.4479 0.127 ]
draft proposes 'b'
q[b] = 0.2638, p[b] = 0.3100
accept with min(1, p/q) = min(1, 0.3100/0.2638) = 1.0000 ✓
draft under-proposes it -> always accept
draft proposes 'e'
q[e] = 0.4479, p[e] = 0.3821
accept with min(1, p/q) = min(1, 0.3821/0.4479) = 0.8532
draft over-proposes it -> reject 14.7% of the time
on rejection, resample from residual = norm(max(0, p - q)):
p - q : [ 0.0119 0.0461 0.0131 0.0002 -0.0657 -0.0057]
residual : [0.1673 0.6457 0.1838 0.0032 0. 0. ]
इसे हाथ से follow कीजिए। draft "e" को ज़रूरत से ज़्यादा बार कहता है, इसलिए 14.7% बार हम वह अनुमान फेंक देते हैं। लेकिन फिर हम सीधे p से resample नहीं कर सकते — वे rejections पूरी vocabulary पर समान रूप से फैले नहीं हैं, वे ठीक उन्हीं tokens पर केंद्रित हैं जिन्हें draft ने ज़्यादा प्रस्तावित किया। इसलिए हम वहाँ से sample करते हैं जो छूट गया: p घटा q, ऋणात्मक मान शून्य पर clip, फिर renormalise. ध्यान दीजिए कि residual में "e" और "f" की probability 0 है। draft उन्हें पहले ही पर्याप्त मात्रा में दे चुका है।
Correctness की गारंटी, गिनकर
यही वह हिस्सा है जो मायने रखता है। बाकी सब performance engineering है; यह वह वजह है जिसके कारण आपको इसे production में चालू करने की इजाज़त है। मैं पूरी योजना को एक तय context से 60,000 बार चलाता हूँ, पहले दो निकले tokens रखता हूँ, और अनुभवजन्य joint की तुलना target model के सच्चे joint से करता हूँ। फिर नियम को तीन तरीकों से तोड़ता हूँ और नापता हूँ कि distribution कितना खिसकता है।
माप है total variation distance: निरपेक्ष अंतरों के योग का आधा। यह सबसे बड़ी probability है जिस पर दो distributions किसी भी घटना को लेकर असहमत हो सकते हैं। शून्य का मतलब समान।
60000 rounds, k=4 drafted tokens per round, first two tokens kept
first-token marginal, target vs speculative decoding:
token target speculative diff
a 0.0943 0.0905 -0.0038
b 0.3100 0.3155 +0.0056
c 0.0908 0.0920 +0.0012
d 0.0015 0.0015 -0.0001
e 0.3821 0.3801 -0.0020
f 0.1213 0.1204 -0.0010
total variation distance on the two-token joint:
correct rule : 0.0079 ✓
BROKEN: accept on argmax agreement : 0.2735
BROKEN: resample from p, not residual : 0.0349
BROKEN: keep drafts after a rejection : 0.0333
sampling-error floor (target model sampled directly): 0.0059
आखिरी लाइन पहले पढ़िए। अगर मैं target model से ही 60,000 जोड़े sample करूँ और उनकी तुलना target model से करूँ, तो मुझे 0.0059 मिलता है — यह शुद्ध गिनती का शोर है, सीमित sample की कीमत। सही नियम के साथ speculative decoding 0.0079 पर बैठता है। वही फ़र्श। यह target model के "करीब" नहीं है; यह target model है, और बाकी बचा अंतर मेरा sample size है।
तीनों टूटे नियम उस फ़र्श से चार से तीस गुना ऊपर बैठते हैं। वह आपके users को जवाब देता कोई दूसरा model है, और आपका कोई भी eval आपको नहीं बताएगा कि वह कौन-सा था।
Acceptance rate ही पूरा खेल है
यह फ़ायदे का सौदा है या नहीं, यह दो संख्याएँ तय करती हैं: draft कितनी बार accept होता है, और draft की लागत कितनी है। और कुछ नहीं। मैं draft को target की एकदम सही नकल से लेकर कचरे तक sweep करता हूँ, draft की कीमत प्रति token एक target call का 10%, हर round में 4 tokens draft:
k=4 drafted tokens per round, draft cost c=0.10 of a target call
draft quality accept rate tokens/target call speedup
alpha=1.00 (perfect) 1.0000 5.0000 3.571x ✓
alpha=0.95 0.9204 4.6814 3.344x
alpha=0.85 0.7769 4.1076 2.934x
alpha=0.60 0.4964 2.9856 2.133x
alpha=0.30 0.2698 2.0792 1.485x
alpha=0.00 (junk) 0.1316 1.5265 1.090x
temperature mismatch (same model, wrong sampling temperature):
draft T=1.0 vs target T=1.0 : accept 1.0000, tokens/call 5.0000, 3.571x
draft T=1.3 vs target T=1.0 : accept 0.8625, tokens/call 4.4501, 3.179x
draft T=2.0 vs target T=1.0 : accept 0.6658, tokens/call 3.6631, 2.617x
draft T=0.5 vs target T=1.0 : accept 0.6743, tokens/call 3.6970, 2.641x
एकदम सही draft कभी reject नहीं होता, इसलिए आप चारों drafted tokens के साथ वह मुफ़्त bonus token भी निकालते हैं जो target ने आखिरी position पर पहले ही गिन लिया था: एक target call पर 5 tokens। कचरा draft 1.5 निकालता है और drafting की कीमत चुकाने के बाद आपको 9% देता है। ये मेरे toy model के आँकड़े हैं, पेपर के नहीं; जो चीज़ आगे काम आती है वह आकार है, परिमाण नहीं।
Temperature वाली पंक्तियाँ ही वे हैं जिन्हें मैं किसी incident postmortem में रखूँगा। दोनों models में कुछ भी गलत नहीं है। draft बिल्कुल target ही है, बस किसी और temperature पर sample किया गया — और acceptance 1.00 से गिरकर 0.67 हो जाता है। Acceptance rate जोड़ी का गुण है, किसी एक model का नहीं। यही वजह है कि draft को target का tokeniser साझा करना ही होगा: उसके बिना p और q एक ही समुच्चय पर distributions नहीं हैं और अनुपात p/q निरर्थक है।
कितना आगे तक अनुमान लगाएँ
एक token draft कीजिए तो मुफ़्त compute का इस्तेमाल लगभग हुआ ही नहीं। सोलह draft कीजिए तो आप पंद्रह अनुमानों की कीमत चुका रहे हैं जो पहला अनुमान reject होते ही flush हो जाएँगे। एक optimum है, और वह acceptance rate के साथ खिसकता है:
middling draft (alpha=0.55), draft cost c=0.10
k accept rate tokens/target call cost 1+k*c speedup
1 0.7078 1.7078 1.10 1.553x
2 0.5997 2.1995 1.20 1.833x
3 0.5194 2.5583 1.30 1.968x
4 0.4494 2.7974 1.40 1.998x
5 0.4002 3.0009 1.50 2.001x ✓
6 0.3490 3.0937 1.60 1.934x
8 0.2829 3.2634 1.80 1.813x
12 0.1987 3.3845 2.20 1.538x
16 0.1486 3.3774 2.60 1.299x
best k here: 5 at 2.001x
बीच के दोनों columns को एक-दूसरे के खिलाफ़ खिंचते देखिए। प्रति call tokens संतृप्त हो जाता है — k=8 से k=16 जाने पर 0.11 token ही मिलता है, क्योंकि आठ acceptances तक run लगभग कभी टिकता ही नहीं। उधर cost column रैखिक रूप से चढ़ता रहता है, हर अतिरिक्त draft पर 0.10, चाहे वह token बचे या न बचे। पूरे वक्र का आकार यही है: संतृप्त होता अंश, और रैखिक हर।
जब draft model आपको धीमा कर देता है
Cost model एक लाइन का है। एक round की लागत एक target call जमा k draft calls, जहाँ c एक target call के अंश के रूप में draft की प्रति token लागत है। तो speedup = प्रति round tokens ÷ (1 + k·c), और break-even तब होता है जब c = (प्रति round tokens − 1) / k.
middling draft, k=4, tokens per target call = 2.8064
break-even draft cost c* = (tokens - 1)/k = 0.4516
i.e. the draft model must cost less than 45.2% of the target per token.
draft cost c 1 + k*c speedup
0.0100 1.0400 2.698x
0.0500 1.2000 2.339x
0.1000 1.4000 2.005x ✓
0.4516 2.8064 1.000x <-- break-even
0.6000 3.4000 0.825x SLOWER than no speculation
1.0000 5.0000 0.561x SLOWER than no speculation
a 'draft' that is a quarter of the target (c=0.25) and only
as good as our poor draft:
tokens/call 1.5523, speedup 0.776x
वह आखिरी block ही असल में लोगों द्वारा ship किया जाने वाला failure mode है। आप बड़ा draft model उठाते हैं क्योंकि वह ज़्यादा बार accept होता है — और target की एक चौथाई लागत वाला draft, खराब acceptance rate के साथ, 0.776x पर आ गिरता है। आपने inference धीमा कर दिया और output distribution फिर भी पूरी तरह सही है, इसलिए कोई alert नहीं बजता। इकलौता लक्षण p50 latency का graph है।
व्यवहार में c मोटे तौर पर parameter संख्याओं का अनुपात है: 70B के लिए draft करता 7B लगभग 0.1 है। खुद के लिए draft करता model c = 1 है और कभी जीत नहीं सकता।
मुफ़्त compute आता कहाँ से है
वापस उसी KV-cache नतीजे पर, पर इस बार सिर्फ़ देखा नहीं, खर्च किया गया। एक forward pass के लिए आप memory से जो weights खींचते हैं, वे वही weights हैं चाहे वह pass एक token पर हो या सोलह पर। सिर्फ़ arithmetic बढ़ता है।
Llama-2-7B-shaped, 512 tokens of context, fp16 weights
weights dragged off memory per forward pass: 13,480,000,000 bytes (12.55 GiB)
tokens in the pass MACs MACs per byte moved
1 5,167,382,528 0.38
2 10,334,765,056 0.77
4 20,669,530,112 1.53
8 41,339,060,224 3.07
16 82,678,120,448 6.13 ✓
अपनी arithmetic units को व्यस्त रखने के लिए एक accelerator को memory traffic के हर byte पर लगभग 100–300 MACs चाहिए। एक-token decoding 0.38 पर बैठा है — लगभग तीन order of magnitude कम। उसी pass में सोलह tokens verify करने पर वही bytes चलते हैं, सोलह गुना arithmetic होता है, और फिर भी chip संतृप्त नहीं होता। वे k अतिरिक्त tokens लगभग मुफ़्त हैं।
इसके बाद क्या आया
Chen और साथियों ने यही acceptance rule कुछ महीने बाद स्वतंत्र रूप से प्रकाशित किया (arXiv 2302.01318), Chinchilla पर लागू करके — यह जानना काम का है क्योंकि आप जो blog posts पढ़ेंगे उनमें आधे एक का हवाला देते हैं और आधे दूसरे का।
इस पूरी चीज़ की ज़ाहिर झंझट यह है कि अब आपको दो models serve करने हैं, दोनों को एक ही tokeniser पर रखना है, और हर fine-tune के बाद उन्हें sync में रखना है। बाद के काम दूसरे model को हटा देते हैं: target model पर ही अतिरिक्त prediction heads जोड़ दो ताकि वह अपनी ही continuations draft करे — Medusa इसका जाना-पहचाना उदाहरण है। वही acceptance मशीनरी, weights का एक ही सेट। अलग पेपर, अलग पेज।
The code
Pure NumPy. No torch, no autograd, no neural network at all — the models are explicit probability tables, so the distribution the paper promises is a thing you can print and subtract. Every function under 20 lines. Below is the acceptance rule and the verify loop; the drafting loop, the cost model and the six demos are in the file.
Download the full script — about 400 lines plus 6 runnable demos. Then:
python3 speculative_decoding_from_scratch.py
# ---------------------------------------------------------------------------
# STAGE 2 -- THE WHOLE PAPER, part 1: the acceptance rule.
#
# The draft model proposed token x, and it proposed it with probability q[x].
# The target model would have said p[x]. Then:
#
# accept x with probability min(1, p[x] / q[x])
#
# q[x] > p[x] means the draft over-proposes x, so we keep only p/q of those.
# q[x] <= p[x] means the draft under-proposes x, so we keep every one.
# ---------------------------------------------------------------------------
def accept_prob(p, q, x):
return min(1.0, p[x] / q[x])
# ---------------------------------------------------------------------------
# STAGE 3 -- THE WHOLE PAPER, part 2: the residual distribution.
#
# Accepting alone under-samples the tokens the draft was too shy about. On a
# rejection we do NOT resample from p -- that would double-count. We resample
# from the positive part of (p - q), normalised. That is exactly the mass the
# accept step failed to deliver, so the two steps sum back to p. This one line
# is the difference between "provably identical output" and "usually fine".
# ---------------------------------------------------------------------------
def residual(p, q):
r = np.clip(p - q, 0.0, None)
s = r.sum()
if s <= 0: # degenerate: p is fully covered by q
return p / p.sum()
return r / s
# ---------------------------------------------------------------------------
# STAGE 5 -- verify all k drafts in ONE target call, then emit.
#
# The target sees the prefix plus all k drafted tokens at once, so it returns
# k+1 next-token distributions for the price of one forward pass (the same
# trick as training-time teacher forcing). Then walk them left to right:
#
# accept -> emit the drafted token, move on
# reject -> emit one token from residual(p, q) and STOP. Everything after
# the rejection was conditioned on a token that no longer exists.
# all k accepted -> you also get a free bonus token from the last
# distribution, because the target already computed it.
#
# `rule` selects deliberate breakages; "correct" is the paper.
# ---------------------------------------------------------------------------
def verify_round(P, Q, ctx, drafted, rng, rule="correct"):
prefixes = [ctx] + drafted # k+1 positions, one target call
out, n_accepted = [], 0
for i, x in enumerate(drafted):
p, q = P[prefixes[i]], Q[prefixes[i]]
if rule == "greedy":
ok = (x == int(np.argmax(p))) # BREAK: argmax agreement
else:
ok = rng.random() < accept_prob(p, q, x)
if ok:
out.append(x)
n_accepted += 1
continue
if rule == "no_resample":
out.append(sample_from(p, rng.random())) # BREAK
else:
out.append(sample_from(residual(p, q), rng.random()))
if rule == "continue":
out.extend(drafted[i + 1:]) # BREAK
return out, n_accepted
out.append(sample_from(P[prefixes[-1]], rng.random())) # bonus token
return out, n_accepted
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug
for a living — reverse-engineering from broken behaviour is your native mode.
Every row below is a rule already wired into the script: pass it as
rule=, or edit the function and rerun demo 2.
| Break this | Watch for |
|---|---|
Accept greedily: ok = (x == int(np.argmax(p))) | Faster, and the output distribution is no longer the target's. TV distance goes from 0.0079 to 0.2735 against a 0.0059 noise floor. |
In verify_round, replace residual(p, q) with plain p | Subtle bias — nothing crashes, nothing looks wrong. TV distance 0.0349, four times the noise floor. The tokens the draft over-proposed stay over-represented. |
Keep the remaining drafts after a rejection: out.extend(drafted[i+1:]) | TV 0.0333. Every kept token was conditioned on a token you just threw away — a stale prefix, silently emitted. |
Price the draft at c = 0.60 in demo_5_cost_model | Speedup 0.825x. Past the break-even c* = 0.4516, speculation costs more than it saves and the only symptom is latency. |
Draft with temper(TARGET, 2.0) — same model, wrong temperature | Acceptance falls 1.0000 → 0.6658, speedup 3.571x → 2.617x. A pure configuration bug that halves your gain. |
Push k to 16 in demo_4_draft_length | Tokens per call rises to 3.3774 (from 3.0009 at k=5) but speedup falls to 1.299x. You paid for 16 drafts to keep 3. |
On the maths anxiety
The word "provably" scared me off this paper for a long time. It shouldn't have. The proof needs one idea: two ways of producing a token, whose probabilities add up to the distribution you wanted. Accept gives you min(p, q) of the mass. The residual gives you the rest, max(0, p − q). Add them: p. That's it. Rejection sampling in one sentence, and you can confirm it by counting outcomes in a loop, which is what demo 2 does.
You need to be comfortable with two things and no more. A probability
distribution is a vector that sums to 1 — an array with an invariant. Total
variation distance is 0.5 * np.abs(a - b).sum(), a diff between
two such arrays. Neither is research maths.
What you actually need for this in production is engineering: measuring your real acceptance rate on your real traffic, pricing the draft model, tuning k per workload, and knowing that a distribution bug here is invisible to every eval you own. The arithmetic is the easy half.
Where to go next
This page gave you one thing: the acceptance rule as code you can break, and the cost model that decides whether it's worth it. It skipped the proof written out properly, the tree-structured variants, and everything about running it in a real serving stack.
-
Fast Inference from Transformers via Speculative Decoding
Read the method section and the correctness theorem — you have the code for both now, so the notation resolves. The analysis of expected accepted tokens is the k-sweep above, done with algebra instead of 40,000 trials.
-
Accelerating Large Language Model Decoding with Speculative Sampling
The same rule derived a second time, on Chinchilla. Useful mostly as a second pass over the same proof in different notation — if one write-up doesn't land, the other might.
-
Speculative Sampling
Where I said "accept plus residual add up to p, that's the proof", he actually does the algebra, case by case, slowly. Go here when you want to see why it holds rather than count that it does.
-
Assisted Generation: a new direction toward low-latency text generation
This is the version you'd actually ship: two real checkpoints, a real tokeniser constraint, and the
assistant_model=argument that turns it on in transformers. My toy tables become weights here.