Skip to content

Assisted/speculative decoding generates past an EOS that is accepted mid-block #47912

Description

@pjordanandrsn

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:

  1. 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())
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions