Skip to content

sampling: index penalties by token id instead of scanning every candidate - #95

Open
danielhanchen wants to merge 2 commits into
penalties-upstream-basefrom
penalties-index-by-token-id
Open

sampling: index penalties by token id instead of scanning every candidate#95
danielhanchen wants to merge 2 commits into
penalties-upstream-basefrom
penalties-index-by-token-id

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 11, 2026

Copy link
Copy Markdown
Member

The problem

llama_sampler_penalties_apply walks the whole candidate array and probes the frequency map once per candidate:

for (size_t i = 0; i < cur_p->size; ++i) {
    const auto token_iter = ctx->token_count.find(cur_p->data[i].id);

token_count never holds more than penalty_last_n live entries (64 by default), so at most 64 of those probes can match. Everything else is a wasted hash lookup. The cost scales with the vocab, not with the penalty window, so it lands hardest on the recent large-vocab models: Qwen3.6-27B has 248320 tokens, which is 248320 lookups per token to penalize at most 64.

It only shows up when a penalty is actually enabled, so most default presets never see it. Ours do: the qwen3.5 and qwen3.6 family profiles in Unsloth Studio set presence_penalty: 1.5, and that alone was costing about 11% of generation throughput.

The fix

Walk token_count and index cur_p directly. That turns the loop from n_vocab into penalty_last_n.

Indexing by id is only valid while cur_p is still the untouched candidate array, so the fast path is guarded on the array being the identity layout, data[i].id == i for every i. Anything else, a truncated, reordered or hand-built array, falls back to the original scan. The check is one compare per candidate against a map probe per candidate, and it stops at the first mismatch.

The identity layout also guarantees each id appears exactly once, which matters: the first version of this patch checked only that each penalized token sat at its own index, and an array holding the same id twice then got one of the two entries penalized instead of both. The fuzz below caught it.

The arithmetic itself is untouched and each token is still penalized exactly once, so the result does not depend on which path runs or on map iteration order.

Measured

Both builds from b10359, single GPU, --parallel 4 --kv-unified --flash-attn on, 400 generated tokens, temperature 0, median of 5. Throughput is llama.cpp's own /metrics counters, not client wall clock.

Qwen3-0.6B-Q4_K_M, 151936 vocab:

penalty stock patched delta
none 610.69 603.32 -1.2%
presence_penalty 1.5 467.29 583.09 +24.8%
frequency_penalty 0.8 440.94 586.39 +33.0%
repeat_penalty 1.15 443.18 595.42 +34.4%

Qwen3.6-27B-UD-Q4_K_XL, 248320 vocab, --spec-type draft-mtp, presence_penalty 1.5: 130.29 to 144.59 t/s, up 11.0%, against 146.63 with the penalty switched off. So the penalty goes from costing 11% of the decode rate to costing about 1%.

The "none" row is noise. is_disabled() returns before any of this, so the patched path cannot execute there; over 9 runs each the medians were 600.60 and 598.80 with fully overlapping spreads.

The win grows with vocab size and shrinks with model size, which is why it has gone unnoticed: it is invisible on small-vocab models and largest exactly where it hurts, small fast models with modern tokenizers.

Output is unchanged

60 comparisons, stock server against patched server, both built from b10359, 4 prompts x 5 penalty combinations x greedy and two seeded sampled settings:

penalties temperature 0 temperature 0.8, seed 1234 temperature 1.0, seed 7
none 4/4 identical 4/4 identical 4/4 identical
presence 1.5 4/4 identical 4/4 identical 4/4 identical
frequency 0.8 4/4 identical 4/4 identical 4/4 identical
repeat 1.15 4/4 identical 4/4 identical 4/4 identical
all three 4/4 identical 4/4 identical 4/4 identical

60/60 byte identical. Repeating a generation five times on each build gave one distinct output per build, so both remain deterministic.

Tests

tests/test-sampling passes. The existing test_penalties cases already pin the expected probabilities for repeat, frequency and presence penalties and are unchanged. Two cases are added:

  • test_penalties_reordered applies the sampler to a normal candidate array and to a reversed one, which forces the fallback, and asserts the resulting logits match exactly.
  • test_penalties_duplicate_ids builds an array holding one id twice and asserts both entries end up with the same logit, which is what the scan does and what the first version of this patch got wrong.

Beyond the suite, a differential fuzz compared the shipped sampler against a reference implementation of the old scan over 33600 cases: 10 vocab sizes from 1 to 151936, 6 values of penalty_last_n including 0, 5 repeat x 4 frequency x 4 presence settings including negative ones, and 7 candidate layouts (canonical, reversed, shuffled, truncated, duplicated id, sorted by logit, single entry), with logits including 0, -0, inf, -inf, nan, denormals and FLT_MAX. Comparison is on the float bit patterns, so a nan or a signed zero landing in the wrong place fails.

  • against the first version of this patch: 209 mismatches, all on the duplicate-id layout
  • against this version: 0 mismatches
  • against the stock library, as a check that the reference is faithful: 0 mismatches

g++ -std=c++17 and -std=c++20, both with -Wall -Wextra -Werror, are clean, and the file produces no warnings the stock file does not.

danielhanchen added a commit that referenced this pull request Aug 11, 2026
Pins c26185b from #95 so the nightly prebuilds carry it. The pin stays listed
until the change lands upstream, since merging it into fork master would drop
it from the mix.
…idate

llama_sampler_penalties_apply probed token_count once per candidate, so a
model with a large vocab paid n_vocab hash lookups per token while at most
penalty_last_n of them can ever match.

Walk token_count instead and index cur_p directly. The direct index needs a
token id to be its own index, so it is guarded and falls back to the old scan
when an earlier sampler has dropped or reordered candidates.

Qwen3.6-27B (248320 vocab, RTX/B200, presence_penalty 1.5): 130.3 -> 146.7 t/s.
Output is unchanged: same arithmetic, applied to the same tokens.
@danielhanchen
danielhanchen force-pushed the penalties-index-by-token-id branch from c26185b to e2e842a Compare August 11, 2026 19:18
@danielhanchen
danielhanchen changed the base branch from master to penalties-upstream-base August 11, 2026 19:19
danielhanchen added a commit that referenced this pull request Aug 11, 2026
The first pin for #95 was cut from fork master, whose merge base with the
current base tag is from June 10, so merging it dragged in the whole
fork/upstream divergence and conflicted across ~1600 files.

Rebased the change onto b10359 and repinned to e2e842a. #95 now sits on
penalties-upstream-base, matching how #70 and #91 are carried.

Verified: all five pins merge onto b10359 in pr-set.json order, #70 via the
additive resolver as before, everything else clean.
The first version checked only that each penalized token sat at its own index.
A cur_p holding the same id twice then took the by-index path and penalized one
of the two entries, where the scan penalized both. llama.cpp's own samplers
never build such an array, but the old behaviour should not change for anyone
who does.

Check the whole array is the identity layout instead, which also rules out
duplicates. It is a compare per candidate against a map probe per candidate,
and it stops at the first mismatch.

Found by a differential fuzz over 33600 cases (vocab sizes, penalty settings,
reversed / shuffled / truncated / duplicated / sorted layouts, inf and nan
logits): 209 mismatches before, 0 after.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3db8cb5b2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/llama-sampler.cpp
// Apply frequency and presence penalties to the cur_p
if (by_index) {
for (const auto & it : ctx->token_count) {
penalize(cur_p->data[it.first], it.second);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate history IDs before direct indexing

When a caller has accepted LLAMA_TOKEN_NULL or another token outside [0, n_vocab), a full identity-layout candidate array still selects this path, and it.first indexes before or beyond cur_p->data, causing memory corruption. This can occur when integrations feed multimodal prompt histories containing the documented LLAMA_TOKEN_NULL placeholder into the public sampler API. The previous lookup path simply found no matching candidate and ignored the sentinel, so validate each history ID or fall back to lookup before indexing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant