Skip to content
Cort Fisher
Go back

HuggingFace's greedy decoding isn't always greedy

I’m building an LLM inference engine from scratch: continuous batching and a paged KV cache. The exit criterion for the model layer is a golden test: my engine and stock HuggingFace, greedy decoding, token-identical for 50 tokens on real Qwen2.5-1.5B-Instruct weights. Greedy is deterministic, so any divergence means a bug.

It diverged at token 3.

Three forwards outvote a wrapper

The first three tokens matched, then my engine said is (token 374) and generate() said can (token 646). Early divergence usually means something structural: a RoPE position off by one, or a GQA head grouping bug. So before touching code I checked which side moved, by computing the same next-token logits three independent ways:

computation pathargmaxnotes
HF, full recompute of the whole prefix is (374)top-2 margin 0.27
my engine, incremental paged decode is (374)max Δlogit vs full: 4.0e-05
HF, cached stepwise forward is (374)max Δlogit vs full: 4.2e-05
hf.generate(..., do_sample=False) can (646)

A margin of 0.27 is not a floating-point tie. All three forward passes, including HuggingFace’s own cached path (the thing generate() supposedly wraps), agreed with each other to within 4e-05 and disagreed with generate(). When three forwards outvote the wrapper, the model math is fine and the wrapper is doing something to the logits.

The mechanism: processors vs warpers

It was: Qwen2.5-1.5B-Instruct ships a generation_config.json in the checkpoint, and it contains repetition_penalty: 1.1.

The part worth internalizing is why do_sample=False doesn’t save you. transformers splits generation-time logit manipulation into two categories:

So do_sample=False gives you argmax, but over logits that have already been modified. The repetition penalty divides positive logits by the penalty and multiplies negative ones (the CTRL-paper formulation transformers implements). Here, is appears in my prompt (“…paged attention is”) and its logit was +21.16. Positive, so it gets divided: 21.16 / 1.1 = 19.24, which drops it below can’s untouched 20.89. The “greedy reference” was greedy over different numbers than the model produced.

To be clear about whose choice is whose: Qwen shipping the penalty is legitimate, and generation_config.json exists for exactly this. The trap is that generate() applies it silently under a flag whose name promises otherwise, and that trap is checkpoint-agnostic: swap in any model that ships any processor (no-repeat-ngram, min-length, plenty do) and the same silent divergence fires.

Repro

Ten lines, checkable (transformers 5.13.1, float32, CPU):

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig

MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
m = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32).eval()
tok = AutoTokenizer.from_pretrained(MODEL)
ids = tok("The key idea behind paged attention is", return_tensors="pt").input_ids

print(tok.decode(m.generate(ids, max_new_tokens=10, do_sample=False)[0, ids.shape[1]:]))
m.generation_config = GenerationConfig()  # neutralize the checkpoint's defaults
print(tok.decode(m.generate(ids, max_new_tokens=10, do_sample=False)[0, ids.shape[1]:]))

Output:

 that the brain can only hold a limited amount of
 that the brain is not a passive receiver of information

Same model, same weights, same do_sample=False. Two different “greedy” continuations.

The fix, and where it goes

One line, in the test fixture, not the engine:

hf.generation_config = GenerationConfig()  # pure argmax, no penalties

After that: 50/50 tokens identical, and the golden test means what it says.

Nothing changes in the engine itself, and that’s a deliberate position, not a gap. A checkpoint’s generation_config.json is the model author’s suggested client-side defaults: useful for a chat demo, not part of the model’s contract. A serving engine executes the sampling parameters the request asked for, exactly. It doesn’t silently inject a checkpoint’s opinions into every request; silently injecting them is precisely what made generate() an unreliable reference here.

To be fair to transformers: this is arguably working as designed. The checkpoint asked for the penalty and generate() honored it. The trap is the expectation gap: nothing about do_sample=False suggests “…but scores may still be edited first,” and the failure mode it produces is the worst kind: coherent output, plausibly greedy, quietly not.

The takeaway

If you’re validating an inference engine against HuggingFace (which is everyone who builds one), “compare against generate()” means nothing until you’ve neutralized the checkpoint’s shipped generation config. Otherwise you can burn days concluding your engine is broken when the reference is the thing that’s lying. The defensive check is two lines:

print(model.generation_config)
# suspicious: repetition_penalty, no_repeat_ngram_size, anything non-default

If there’s a repetition_penalty in there, your greedy isn’t greedy.


Share this post:

Previous Post
Static batching is slow even when your server is idle