Assisted/speculative decoding generates past an EOS that is accepted mid-block
System info
transformers 5.15.0 (reproduced on the v5.15.0 tag)
torch 2.13.0 — the minimal repro is CPU-only
- Originally observed on
meta-models/Muse-Glimmer-30B + -assistant (DFlash), A100 80GB, bf16
Who can help?
@Cyrilvallez (generate). The DFlash generator came in with #47867.
Description
_assisted_decoding commits n_matches + 1 tokens per round. When an accepted
token in the middle of that block is an EOS, generation does not stop: the whole
block is appended (and streamed) first, and the stopping check afterwards only
looks at the last committed token. The result is output that continues past EOS
and does not match plain greedy.
It is position-dependent, which makes it look intermittent — an EOS that happens
to land on the target's bonus token stops correctly.
Minimal reproduction (CPU, tiny random model, no drafter needed)
The bug needs the EOS to be accepted, i.e. the target's own argmax must be EOS
at that position. Instead of hunting for such a prompt, run greedy first and then
declare one of the tokens the model actually produced to be the EOS.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.generation.candidate_generator import CandidateGenerator
import transformers.generation.utils as gu
NAME, BLOCK, EOS_OFFSET = "hf-internal-testing/tiny-random-LlamaForCausalLM", 8, 3
model = AutoModelForCausalLM.from_pretrained(NAME).eval()
tok = AutoTokenizer.from_pretrained(NAME)
ids = tok("Hello world", return_tensors="pt").input_ids
plen = ids.shape[1]
cont = model.generate(ids, max_new_tokens=BLOCK * 2, do_sample=False,
eos_token_id=-1, pad_token_id=0)[0, plen:].tolist()
EOS = cont[EOS_OFFSET] # a token the model really emits
class BlockStub(CandidateGenerator): # proposes the model's own continuation
requires_model_outputs = False
def get_candidates(self, input_ids, **kw):
n = input_ids.shape[1] - plen
nxt = cont[n:n + BLOCK]
if not nxt:
return input_ids, None
return torch.cat([input_ids, torch.tensor([nxt])], dim=-1), None
def update_candidate_strategy(self, input_ids, scores, num_matches):
pass
base = model.generate(ids, max_new_tokens=BLOCK * 2, do_sample=False,
eos_token_id=EOS, pad_token_id=0)
gu.GenerationMixin._get_candidate_generator = lambda self, **kw: BlockStub()
spec = model.generate(ids, max_new_tokens=BLOCK * 2, do_sample=False,
eos_token_id=EOS, pad_token_id=0, prompt_lookup_num_tokens=BLOCK)
print(base[0, plen:].tolist()) # [30016, 7356, 9023, 25068] <- stops at EOS
print(spec[0, plen:].tolist()) # [30016, 7356, 9023, 25068, 22665, ... ] <- 12 tokens past EOS
Observed: plain greedy returns 4 tokens, assisted returns 16 — 12 tokens after
the EOS.
On a real model
meta-models/Muse-Glimmer-30B + its DFlash drafter, greedy, max_new_tokens=128,
prompt "List the first eight prime numbers, one per line, with no commentary.":
|
length |
EOS index |
ends with EOS |
| plain greedy |
122 |
121 |
yes |
speculation_type="dflash" |
128 |
121 |
no |
The first 122 tokens are identical; the speculative stream then emits
<|start|>assistant to=self<|message|>We. Instrumenting the candidate generator
to record (proposed, num_matches) per round and locating the EOS in its commit
round explains the intermittency:
- EOS at offset 6 of an 8-token commit (an accepted proposal) → ran 6 tokens past it.
- On another prompt, EOS at offset 1 of a 2-token commit (the bonus token) → stopped correctly.
Expected behavior
Greedy assisted decoding should return the same token stream as plain greedy, and
stop at the first EOS. Instead it continues past an EOS that was accepted inside a
committed block, so the two disagree on both content and length.
Cause
Two things compound, both in _assisted_decoding:
- The committed block is appended and streamed before any stopping check:
input_ids = torch.cat((input_ids, valid_tokens), dim=-1) # utils.py:3808
if streamer is not None:
streamer.put(valid_tokens.cpu())
- The check that follows uses the default
new_token_length=1:
unfinished_sequences = unfinished_sequences & ~stopping_criteria(input_ids, scores) # utils.py:3859
and EosTokenCriteria only inspects the last new_token_length tokens
(stopping_criteria.py:580):
is_done = torch.isin(input_ids[:, -new_token_length:], self.eos_token_id).any(dim=-1)
The existing guard at utils.py:3777 does not cover it: it fires only when the
entire candidate block was accepted (n_matches == candidate_length), and its
is_done_candidate (utils.py:3696) is itself computed at new_token_length=1.
Classic autoregressive drafters largely avoid this because the draft stops at EOS,
leaving it last. Block proposers (DFlash block_size 16, MTP) hit it routinely.
This failure mode is already known in-repo — PromptLookupCandidateGenerator
truncates its candidates at the first EOS for exactly this reason
(candidate_generator.py:1129):
# remove remaining candidate ids if an "eos" token is found, otherwise the target model may
# accept eos and the rest as valid, thus not stopping generation after "eos"
DFlashTokenCandidateGenerator has no equivalent.
Fix
Trim the committed block at the first EOS, mirroring what
PromptLookupCandidateGenerator already does to its drafts. That also leaves EOS
as the last token, so the existing criteria fire unchanged:
eos_tensor = generation_config._eos_token_tensor
if eos_tensor is not None and valid_tokens.shape[1] > 1:
eos_hits = torch.nonzero(torch.isin(valid_tokens[0], eos_tensor.to(valid_tokens.device)))
if eos_hits.numel() > 0:
valid_tokens = valid_tokens[:, : eos_hits[0].item() + 1]
n_matches = valid_tokens.shape[1] - 1
placed just after the existing length-budget clamp, recomputing n_matches the way
that clamp already does so number_of_tokens_to_crop stays consistent.
Note the obvious one-liner — passing new_token_length=n_matches + 1 to the
stopping criteria at utils.py:3859, as generation_diffusion_gemma.py:1088 does
for its canvas — is not sufficient on its own. Measured on the repro above it
takes the output from 16 tokens to 9, but 5 post-EOS tokens remain: it ends the
loop without undoing the commit.
min_new_tokens is unaffected: the logits processors suppress EOS until the
minimum is reached, so the target never accepts one for the trim to find (verified
at min_new_tokens=0 → 4 tokens and =10 → 16).
Verification
Branch is 61 added lines (11 source, 50 test) on top of main (dfb1af6).
pytest tests/generation/test_utils.py tests/generation/test_candidate_generator.py \
tests/generation/test_stopping_criteria.py
# 115 passed, 21 skipped, 3 failed
The 3 failures (test_default_max_length_warning,
test_inputs_embeds_warn_without_ids_for_token_based_logit_processors,
test_validate_stopping_criteria) fail identically on clean main without this
change.
The new test is armed: reverting only generation/utils.py to main makes it fail
with generation continued past an accepted EOS: [750, 297, 297, 860, 860, 860, 860, 860], and it passes with the fix. ruff check / ruff format --check clean
on both touched files.
AI-assisted contribution, per CONTRIBUTING.md: the patch, the repro and the test
were drafted with Claude Code, and the results above are from actual local runs.
The changed lines and the behaviour have been reviewed by me before submitting.
Would you like a PR for this? I have the fix and the regression test above ready on
a branch off main (dfb1af6) — happy to open it, or to leave it if you would
rather handle the stopping logic differently.
Assisted/speculative decoding generates past an EOS that is accepted mid-block
System info
transformers5.15.0 (reproduced on thev5.15.0tag)torch2.13.0 — the minimal repro is CPU-onlymeta-models/Muse-Glimmer-30B+-assistant(DFlash), A100 80GB, bf16Who can help?
@Cyrilvallez (generate). The DFlash generator came in with #47867.
Description
_assisted_decodingcommitsn_matches + 1tokens per round. When an acceptedtoken in the middle of that block is an EOS, generation does not stop: the whole
block is appended (and streamed) first, and the stopping check afterwards only
looks at the last committed token. The result is output that continues past EOS
and does not match plain greedy.
It is position-dependent, which makes it look intermittent — an EOS that happens
to land on the target's bonus token stops correctly.
Minimal reproduction (CPU, tiny random model, no drafter needed)
The bug needs the EOS to be accepted, i.e. the target's own argmax must be EOS
at that position. Instead of hunting for such a prompt, run greedy first and then
declare one of the tokens the model actually produced to be the EOS.
Observed: plain greedy returns 4 tokens, assisted returns 16 — 12 tokens after
the EOS.
On a real model
meta-models/Muse-Glimmer-30B+ its DFlash drafter, greedy,max_new_tokens=128,prompt
"List the first eight prime numbers, one per line, with no commentary.":speculation_type="dflash"The first 122 tokens are identical; the speculative stream then emits
<|start|>assistant to=self<|message|>We. Instrumenting the candidate generatorto record
(proposed, num_matches)per round and locating the EOS in its commitround explains the intermittency:
Expected behavior
Greedy assisted decoding should return the same token stream as plain greedy, and
stop at the first EOS. Instead it continues past an EOS that was accepted inside a
committed block, so the two disagree on both content and length.
Cause
Two things compound, both in
_assisted_decoding:new_token_length=1:EosTokenCriteriaonly inspects the lastnew_token_lengthtokens(
stopping_criteria.py:580):The existing guard at
utils.py:3777does not cover it: it fires only when theentire candidate block was accepted (
n_matches == candidate_length), and itsis_done_candidate(utils.py:3696) is itself computed atnew_token_length=1.Classic autoregressive drafters largely avoid this because the draft stops at EOS,
leaving it last. Block proposers (DFlash
block_size16, MTP) hit it routinely.This failure mode is already known in-repo —
PromptLookupCandidateGeneratortruncates its candidates at the first EOS for exactly this reason
(
candidate_generator.py:1129):DFlashTokenCandidateGeneratorhas no equivalent.Fix
Trim the committed block at the first EOS, mirroring what
PromptLookupCandidateGeneratoralready does to its drafts. That also leaves EOSas the last token, so the existing criteria fire unchanged:
placed just after the existing length-budget clamp, recomputing
n_matchesthe waythat clamp already does so
number_of_tokens_to_cropstays consistent.Note the obvious one-liner — passing
new_token_length=n_matches + 1to thestopping criteria at
utils.py:3859, asgeneration_diffusion_gemma.py:1088doesfor its canvas — is not sufficient on its own. Measured on the repro above it
takes the output from 16 tokens to 9, but 5 post-EOS tokens remain: it ends the
loop without undoing the commit.
min_new_tokensis unaffected: the logits processors suppress EOS until theminimum is reached, so the target never accepts one for the trim to find (verified
at
min_new_tokens=0→ 4 tokens and=10→ 16).Verification
Branch is 61 added lines (11 source, 50 test) on top of
main(dfb1af6).The 3 failures (
test_default_max_length_warning,test_inputs_embeds_warn_without_ids_for_token_based_logit_processors,test_validate_stopping_criteria) fail identically on cleanmainwithout thischange.
The new test is armed: reverting only
generation/utils.pytomainmakes it failwith
generation continued past an accepted EOS: [750, 297, 297, 860, 860, 860, 860, 860], and it passes with the fix.ruff check/ruff format --checkcleanon both touched files.
AI-assisted contribution, per CONTRIBUTING.md: the patch, the repro and the test
were drafted with Claude Code, and the results above are from actual local runs.
The changed lines and the behaviour have been reviewed by me before submitting.
Would you like a PR for this? I have the fix and the regression test above ready on
a branch off
main(dfb1af6) — happy to open it, or to leave it if you wouldrather handle the stopping logic differently.