Skip to content

sampling: backend samplers match the CPU samplers, also under draft verification; ggml-alloc re-plans when a tensor becomes an output - #316

Open
danielhanchen wants to merge 37 commits into
base/upstream-ead9f3205from
pin/gpu-sampler-fixes
Open

danielhanchen wants to merge 37 commits into
base/upstream-ead9f3205from
pin/gpu-sampler-fixes

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Overview

The backend (GPU) samplers behind --backend-sampling now pick the same token as the CPU samplers for the same logits and seed, and penalties run on the GPU when a step verifies several draft tokens per sequence (speculative decoding). On base b11642 (2639a3b) several samplers crash the server, drop every token, or diverge from the CPU, which is why Studio has to keep the CPU sampler for many chains. Not added to scripts/unsloth/pr-set.json in this PR.

Changes

  1. ggml-alloc.c (85d7920): ggml_gallocr_alloc_graph() reused its last plan whenever the graph had the same nodes, without checking which tensors are inputs or outputs. The sampling graph marks only the active chains' results as outputs, so when the rows a step samples change (draft rows split across ubatches, several sequences in a ubatch), a later tensor could reuse an active chain's output memory and overwrite its sampled token or probabilities. The plan now records the input / output / view-of-output flags and is recomputed only when a tensor gains one of them; losing a flag only means a tensor is allocated earlier or freed later, which is safe, so the plan settles once it covers the largest output set.
  2. llama-sampler.cpp, llama-graph.cpp, common/sampling.cpp (28dd780), each to the CPU sampler's semantics:
    • top-k: clamp k to the candidates (k past the vocabulary, past 131072 on the probe graph, or a temperature-0 argmax before top-k aborted in ggml_top_k), and sort the k results by logit (the ggml_top_k order is unspecified, so a fixed seed neither reproduced nor matched the CPU);
    • min-p: keep logit >= max + log(p) (p = 1 dropped every token); min-p and top-p honour min_keep;
    • dynamic temperature: a positive delta is dynamic also at temp <= 0, the entropy counts only kept candidates, same order as the CPU;
    • logit bias: a token listed several times gets the sum of its biases;
    • dist: never picks a zero-probability candidate, picks the first candidate whose running sum reaches u;
    • greedy: the first maximum on ties;
    • CPU samplers after a backend prefix no longer see the candidates the backend masked (XTC at threshold 0 picked a token top-p had excluded);
    • CPU softmax and top-p accumulate in double: an f32 sum over a 150k vocabulary is off by about 1e-3 and moved the CPU top-p cut by dozens of candidates (this is why test_penalties failed on base);
    • penalties under draft verification: chain g of a sequence uses the accepted history plus the draft tokens of rows 1..g, the server's accept rule on the CPU; draws and history stay correct when a draft is split across ubatches; one input tensor per sampler for all chains.
  3. Tests (af9b01c, 008a165, ad9e40a, 640a4ff): test-backend-sampler runs 31 whole chains (Studio defaults and every edge value above) through common_sampler on the backend and on the CPU with identical logits and seeds, single draws and draft-verification rows at n_ubatch 512 and 8, and a split-ubatch case; test-alloc adds an output-flag case. In the unfiltered full-vocabulary cases a mismatch is accepted only as a near-tie of the running sum: u within sqrt(n) * FLT_EPSILON * total of the CPU boundary of both tokens and both probabilities equal within that bound, counted and printed.

Review fixes (0c71fe1, 30d3ac1, 60c4fe2, c165be6)

Each fix below has a test that fails before it and passes after it, unless noted.

  • Logit bias: the CPU search path (candidates already reordered) summed only the first matching bias; it now sums all of them, like the direct path and the backend.
  • Fully masked rows: both CPU import paths (common/sampling.cpp set_logits, llama_sampler_sample) kept default-initialised entries; a fully masked row now keeps its own candidates at -INF, as on base. The logits/ids null checks are added too.
  • min-p / min_keep fallback: candidates at rank min_keep or beyond are dropped, so a tie at the cut no longer keeps extra candidates.
  • Builds with fatal warnings: default member initialisers for the new llama_sampler_dist fields and the test structs; a CPU LLAMA_FATAL_WARNINGS=ON build of the library, tests and server passes.
  • Dynamic temperature: a range that is <= 0 everywhere is greedy on the backend; a single remaining candidate keeps its logit; the CPU now counts only candidates that are not -INF for the maximum entropy, as the backend does. That last point changes CPU output when tokens were masked to -INF (logit bias, grammar) before dynatemp.
  • Penalties: llama_sampler_penalties_clone copies the per-row state and chain count, and reset clears it; dist reset clears its draws. The row rule itself is unchanged: the output rows of a sequence are treated as draft-verification rows, the same rule the base dist sampler uses for its draws, now documented next to n_outputs_max_per_seq in include/llama.h.
  • ggml-cpu cumsum accumulates in double (ggml_vec_cumsum_f32): the f32 sum moved CPU-backend draws over a full vocabulary (test_cpu_parity --device cpu on Qwen3-0.6B: 46/48 before, 1488/1488 after).
  • ggml-alloc: a re-plan of the same graph keeps the outputs the previous plan covered (no re-plan thrash when the output set alternates), and the reserve graph marks every sampling chain's results as outputs. This only adds compute-buffer memory when n_ubatch < n_seqs x n_outputs_max_per_seq (not measured).
  • Tests: both candidate lists are validated (NaN, out-of-range probabilities, missing candidates); top-k with temperature exports 1-D logits.

After the fixes: test-backend-sampler 26/26 on Qwen3-0.6B and Qwen3.5-0.8B, 25/26 on gemma-4-E2B (test_logit_bias, same on base); parity 1488 single and 5952 multi draws per model, 0 failing (2 tolerated near-ties on Qwen3.5); the same parity tests pass on the CPU backend; a broken dist still fails them on all three models; test-alloc 34/34; test-backend-ops on CUDA0 all pass (MUL_MAT + MUL_MAT_ID 3380, FLASH_ATTN_EXT 3993, the other 123 ops 8464); server 4800/4800 draws and 112/112 generations identical with and without --spec-default. Timing was not rerun after these fixes.

Review round 2 (a606099, 992d747, 4df4462)

  • Penalties with several output rows per sequence are now an explicit opt-in: llama_sampler_chain_params.backend_draft_rows / common_params_sampling.backend_draft_rows. Without it, penalties with n_outputs_max_per_seq > 1 stay on the CPU as on base (ordinary multi-output prompt sampling, embedding rows). The server sets it only for slots that speculate (slot.can_speculate()), so draft verification keeps backend penalties. Embedding rows in a mixed batch are no longer read as token ids. test_multi_output_prompt_penalties differed before (candidate set diff 1, probability diff 2.9e-2) and matches after.
  • Dynamic temperature: a computed temperature <= 0 keeps only the first maximum, as the CPU does; a positive one divides the logits without a clamp.
  • Candidate shapes: top-k uses ggml_nelements and exports 1-D candidates; temperature sampling's greedy path is 1-D; the logit-bias gather checks the candidate count (min_p_top_k_top_k aborted before).
  • ggml-alloc: outputs are carried into a new plan only when the graph's structure (op and type of every node and leaf, op of every source) matches, and an empty previous plan is skipped.
  • Tests: CPU top-p compared at float precision, parity contexts sized to the batch, --device cpu honoured, near-ties never accept a zero-probability token, NaN rejected, clean exit on a failed model load.

After round 2: test-backend-sampler 27/27 on Qwen3 and Qwen3.5, 26/27 on gemma-4 (test_logit_bias, same on base); parity 1488 + 5952 draws per model, 0 failing; synthetic parity 0 failing on CUDA and CPU; test-alloc 34/34; test-backend-ops CUDA all pass; server 4800/4800 draws and 112/112 generations identical with and without --spec-default; LLAMA_FATAL_WARNINGS=ON build passes. On the CPU backend, Qwen3.5 parity fails because the plain CPU reference context gives NaN logits above about 4 sequences (same with base ggml-alloc.c and vec.h, so not this PR).

Review round 3 and a CI fix (fb5fc0d, e3aa756, c649dae)

  • Server abort with a grammar after backend sampling on a speculating slot (common/sampling.cpp:639): round 2 set backend_draft_rows on a copy of the request's sampling params, so the grammar check disabled backend sampling in the copy while the server still attached the backend chain. The flag is now set on the request's own params. New test_backend_sampling_then_grammar_same_slot aborted before and passes; test_synth_ignores_target_tokens and test_completion_with_response_format pass with LLAMA_ARG_BACKEND_SAMPLING=1.
  • Dynamic temperature selects exactly between the scaled, greedy and unchanged rows, so an unused infinite temperature no longer produces NaN or selects a masked token; fully masked rows pass through unchanged (dynatemp_one_cand_neg_exp, dynatemp_all_masked failed before).
  • backend_draft_rows reaches penalty samplers in nested chains; the logit-bias sampler runs the same device-support check as the others; the argmax index is clamped before the gather (CUDA returns -1 for an all -INF row).
  • The CPU adds each token's merged bias once, as the backend does (logit_bias_dup_cancel differed before); the split-ubatch check also rejects an invalid CPU reference; row-token discovery is one pass over the ubatch; ggml-alloc keeps its lifetime-bit buffer capacity.

After round 3: test-backend-sampler 27/27 on Qwen3 and Qwen3.5, 26/27 on gemma-4 (test_logit_bias, as on base); parity 0 failing on all three; 16 synthetic cases on CUDA and CPU; broken dist still fails; test-alloc 34/34; test-backend-ops CUDA all pass; server 4800/4800 draws and 112/112 generations identical with and without --spec-default; -Werror build passes. A CPU server suite run on the build host (load 580-1550) had only timeouts and one pre-existing failure (test_systemone_shared_prompt, which fails the same way on base there).

Review round 4 (167e12c, 5e8b7b4)

  • min-p clamps the argmax index into the row before its gather, as greedy does.
  • A nested chain that cannot run as a backend unit now runs entirely on the CPU, so its penalties are no longer skipped (test_nested_chain_cpu_fallback: before, the backend picked the penalised top token).
  • Exact candidates after a backend prefix: on the CPU, top-k / top-p / min-p remove candidates, while a -INF logit bias and temperature <= 0 only set logits to -INF and keep them. The backend writes both as -INF, so a CPU sampler after it cannot tell them apart; a chain whose backend prefix contains such a masking sampler and that also has CPU samplers after it now runs on the CPU. Fully backend chains and filter-only prefixes keep the offload. test_masking_bias_cpu_suffix (-INF biases keeping three tokens, then XTC with min_keep 3) differed before (probability difference 0.459) and matches now.
  • The CPU bias search stops at the first match again (merged biases list each token once); parity also checks each token appears once, probabilities sum to 1 on both sides, and the total differing probability is at most 1e-4.

After round 4: test-backend-sampler 29/29 on Qwen3 and Qwen3.5, 28/29 on gemma-4 (test_logit_bias, as on base); parity 0 failing; broken dist still fails; test-alloc 34/34; test-backend-ops CUDA all pass; server 4800/4800 draws and 112/112 generations identical with and without --spec-default; -Werror build passes; server sampling tests (test_completion.py, test_chat_completion.py, test_speculative.py) in both CI modes pass apart from a load timeout that passes alone and the slow Phi-3.5 test whose model did not download here.

Review round 5 and audit (afa2856 .. b8fea57)

  • Dynamic temperature matches the CPU: the backend sorts before scaling (candidates that tie after an infinite temperature keep the CPU order), keeps filtered candidates at -INF when the computed temperature is infinite (no -INF / INF = NaN), and the CPU sums the entropy in double (a 150k vocabulary picked a different temperature and token in float). The CPU logit bias clears sorted, so a later top-p does not trust a stale order. Four synthetic cases fail before (8 of 8 on CUDA0 and CPU) and pass after; PARITY_SEEDS=1 no longer builds an empty batch.
  • Merged upstream b11776 (the first tag with ggml-org#27196, which edits test_speculative.py next to the new test): upstream's test is kept, the new one follows it. The pin-set sim (master pr-set plus this PR at the new head, aged and newest tags: b11804 and b11815 at the merge, b11819 and b11827 at b8fea57) merges this PR clean on every tag; the refusals left are other pins (Model arch: Flint #314, ggml-org#25731), as on master.
  • GGML_SCHED_NO_REALLOC=ON: the plan check compared the flags of every source slot, so a consumer switching between a computed tensor and an input leaf (the input embeddings built with ggml_build_forward_select: token rows at reserve, raw embeddings at decode) re-planned and aborted test-llama-archs on the first architecture (CPU and CUDA). A source is a node or a leaf of the same graph, so the check now uses only node and leaf flags. test_plan_holds_for_selected_source fails before and passes after; test-llama-archs -s 1 with NO_REALLOC passes 139/139 on the CPU, as base.
  • Full-vocabulary chains (top_k 0, Studio's gpt-oss / llama-3 / deepseek / mistral / glm / phi defaults) were slower than base on an L4: +0.2 ms per step at 1 stream and +0.9 ms at 4 on Qwen3.5-0.8B. Two causes, both fixed: the dist zero-probability bounds added seven passes over the row (now one comparison against the float just below a target floored at FLT_MIN, the passes of base), and common_sampler::set_logits compacted the ~250k candidates every step even after the backend sampled the token, storing each twice (about 0.17 ms per sequence and step on the CPU). It now copies them as base does when the token is already sampled; the compaction for a CPU suffix stores each kept candidate once.
  • CPU numerics: KLD on the CPU backend, Qwen3.5-0.8B Q8_0, wikitext-2 40 x 512, one sequence per batch, against base logits: base A/A, this head and this head without the double cumsum all give mean KLD 0.000000, max 6.0e-5 (the precision of the saved logits), same top token 99.99%, PPL 16.6301. The double cumsum stays: without it --device cpu parity fails again on Qwen3-0.6B (1 single-output and 3 multi-output cases).

Review round 6 (b8fea57..805fa03)

problem fix test
a custom (unknown) backend sampler that masks to -INF without removing candidates was treated as removing them, so a CPU sampler after it saw the masked tokens as candidates unknown backend samplers count as masking, so such a chain runs on the CPU when a CPU sampler follows; the built-in ones that remove (top-k, top-p, min-p, penalties, greedy, dist) are unchanged new test_custom_masking_cpu_suffix (custom sampler keeping tokens 10-12, then XTC, then dist, 8 seeds): 5 of 8 differ before, 8 of 8 match after
plain temperature at infinity (and the CPU temperature path) turned a -INF bias into NaN (-INF / INF, -INF * 0) the CPU path leaves -INF logits alone; the backend uses the dynamic path's mask (finite logits to 0, masked stay -INF) new synthetic cases dynatemp_inf_bias and temp_inf_bias: fail before, pass on CUDA and CPU after
min_keep was converted to a signed dimension before clamping, so SIZE_MAX aborted in ggml_view_1d clamped as size_t first (min-p and top-p) new synthetic case min_p(1.1, SIZE_MAX): aborted before, keeps all candidates after, as the CPU does

On 805fa03: test-backend-sampler Qwen3 30/30, Qwen3.5 30/30, gemma-4 29/30 (test_logit_bias, which also fails on base); GPU vs CPU parity 0 failing; --device cpu parity passes; a deliberately broken dist still fails every model; server parity 4800/4800 draws and 112/112 generations; -Werror CPU build passes; CPU server sampling tests 104 passed in both modes.

Review round 7 (805fa03..bf75187)

problem fix test
the CPU-suffix fallback missed backend samplers that mask arithmetically: a finite logit pushed to -INF by a large negative bias, a temperature below 1 (division can overflow), or penalties large enough to overflow a backend sampler counts as masking when it can leave a candidate at -INF that the CPU keeps: logit bias <= -2^103 (including -INF), temp < 1, temp-ext whose lowest temperature is < 1, penalties with repeat > 1 or last_n x freq + present >= 2^103, any sampler from outside llama.cpp; top-k / top-p / min-p / greedy / dist never. A chain with one of them in its GPU prefix and a CPU sampler after it runs on the CPU new test_masking_classes_cpu_suffix (two -FLT_MAX biases, temperature 1e-37, FLT_MAX frequency penalty, each before XTC): 3 of 6 seeds differ per class before, 0 after
a chain whose first sampler is CPU-only was still attached, so its logits were exported and the hand-off dropped -INF candidates the CPU path keeps set_sampler attaches only a chain whose first sampler runs on the backend; such chains (and those forced to the CPU above) get the model logits exactly as without backend sampling new test_cpu_only_chain_not_exported (XTC then dist): exported before, not after
the all-masked test aborted in Debug builds on the CPU dist sampler's assert(found) the test selects greedily passes in a Debug build

The masking rule is conservative: with a CPU-only sampler (XTC, DRY, typical, ...) after it, a temperature below 1 or a repeat penalty above 1 now sends the whole chain to the CPU. Chains that run fully on the backend are unaffected, and the server's Studio chains (presence 1.5, repeat 1.0) still run penalties on the backend.

On bf75187: test-backend-sampler Qwen3 32/32, Qwen3.5 32/32, gemma-4 31/32 (test_logit_bias, which also fails on base); GPU vs CPU parity 0 failing; --device cpu parity passes; a deliberately broken dist fails every model; server parity 4800/4800 draws and 112/112 generations; -Werror CPU build passes; CPU server sampling tests 104 passed in both modes.

Review round 8 (bf75187..77e244e)

problem fix test
the hand-off to a CPU suffix dropped every -INF entry as if a backend filter had removed it, but the model itself can emit -INF (reduced-vocabulary outputs, e.g. EAGLE3 / DFlash heads), and the CPU sampler keeps those as candidates, so a CPU XTC saw 3 candidates instead of 32 the backend row is handed to a CPU suffix unchanged, with no -INF dropped; top-p and min-p (which would now leave entries the CPU removes) join the samplers that send a chain with a CPU suffix fully to the CPU; top-k stays on the backend because it shrinks the row exactly as the CPU does. The hand-off is a plain copy with the CPU's values for any model output new test_model_inf_logits_cpu_suffix (model output forced to 0.3 / 0.2 / -1 on three tokens and -INF elsewhere, temperature(1.5) then XTC(1, 0.2, min_keep 3) then dist, 8 seeds): 5 of 8 differ before, 0 after

Fully-backend chains are unchanged (they have only copied the row since b8fea57, so the L4 numbers above still apply; not re-timed). Chains with top-p or min-p before a CPU-only sampler (XTC, DRY, typical, ...) now sample on the CPU at base speed.

On 77e244e: test-backend-sampler Qwen3 33/33, Qwen3.5 33/33, gemma-4 32/33 (test_logit_bias, which also fails on base); GPU vs CPU parity 0 failing; --device cpu parity passes; a deliberately broken dist fails every model; server parity 4800/4800 draws and 112/112 generations; -Werror CPU build passes; CPU server sampling tests (GGML_SCHED_NO_REALLOC build) 104 passed in both modes.

Review round 9 (77e244e..5b14d53)

problem fix test
dynamic temperature sorted the row before its live-candidate check; with at most one live candidate the CPU returns without sorting, so on a fully masked row a following greedy pick differed (CPU token 0, CPU backend token 16) the live-candidate count comes first and the sort runs only with two or more live candidates, otherwise the row passes through unchanged; selected inside the graph, no host round trip the synthetic parity harness now also compares the selected token (it compared finite entries only); new dynatemp_all_masked_greedy (32 -INF logits, temp_ext(0.5, 0.5, 1), greedy): 0 vs 16 on the CPU backend before, equal after (CUDA0 already matched)
a cloned penalties sampler copied the draft-row opt-in, so cloning it out of an opted-in chain into a default chain still offloaded it the clone does not copy draft_rows; the chain it is added to decides (cloning a whole opted-in chain still offloads, the chain reapplies the opt-in) new test_penalties_clone_draft_rows: penalties cloned into a default chain with 4 outputs per sequence was offloaded before, on the CPU after
a logit bias listing a token more than once was summed before it was applied, which can overflow (two 2e38 biases on -3e38 gave +INF) a list with a repeated token stays on the CPU, which applies the entries one at a time as base b11776 does; lists without repeats run on the backend as before new test_logit_bias_duplicates (logits -3e38 / -2e38 / -1e38, bias 2e38 on token 0 twice): +INF before, about 1e38 after, not offloaded; the two synthetic repeated-bias cases are removed (that sampler no longer runs on the backend)
outputs were reserved for sampling chains at index n_ubatch or above, which can never be active in a ubatch those chains are still built but get no place in the reserve's outputs existing ubatch-split test unchanged; the saving (about 519 MiB at 16 sequences x 64 rows, n_ubatch 8, from the review) was not re-measured

On 5b14d53: test-backend-sampler Qwen3 35/35, Qwen3.5 35/35, gemma-4 34/35 (test_logit_bias, which also fails on base); GPU vs CPU parity 0 failing (one tolerated near-tie each on Qwen3 and Qwen3.5); --device cpu parity passes; a deliberately broken dist fails every model (25 single-output and 50 multi-output cases); server parity 4800/4800 draws and 112/112 generations; -Werror CPU build passes; CPU server sampling tests (GGML_SCHED_NO_REALLOC build) 104 passed in both modes (the Phi-3.5 test whose model cannot load here excluded, as in every round).

Review round 10 (5b14d53..230c8b5)

Round 10 on 5b14d53: 8 APPROVE, 4 REQUEST_CHANGES.

problem fix test
a logit bias listing a token more than once (on the CPU since round 9) applied only the first entry when the candidates were not in vocabulary order: the reordered-candidate search stopped at the first match every matching entry is added, one at a time test_logit_bias_duplicates gains a reordered case (temp_ext(1, 0.1, 0), biases {1: +5, 1: -6}, greedy on [2, 1, 3]): token 1 before, 2 after
round 9 bounded only the reserved outputs by n_ubatch; the penalty and dist inputs were still sized, filled and uploaded for every chain (64 columns where at most 8 can be active at n_outputs_max_per_seq 64, n_ubatch 8) the context builds min(n_outputs_max_per_seq, n_ubatch) chains per sampler and sizes the inputs to that; gating, per-sequence row validation and dist's row g / draw g keep the logical count new test_chain_columns_n_ubatch (64 outputs per sequence, n_ubatch 8) reads the column count from the graph: 64 before, 8 after; rows sampled at n_ubatch 8 match those at 512
the draft-row opt-in was a new field of llama_sampler_chain_params, which is passed by value, so a client built against the old header could turn it on with garbage register bits the public struct and its defaults are as base; the opt-in is llama_sampler_chain_set_backend_draft_rows(chain, bool), called before the chain is attached (asserted) and copied by a chain clone; common_sampler_init and the tests use it existing draft-row tests through the setter
ggml_gallocr_needs_realloc treated a leaf that only became an input as a changed plan, so a scheduler with GGML_SCHED_NO_REALLOC aborted; leaves are allocated before every node whatever their flags the leaf check ignores the input bit (an output still invalidates the plan; node checks unchanged) new test_plan_holds_when_leaf_becomes_input: abort before, pass after; test-alloc 36/36 in the CUDA build and a GGML_SCHED_NO_REALLOC=ON CPU build
test gaps: the synthetic parity accepted a missing selection on either side, candidate ids were not checked against n_vocab, and the all-masked row test ran wholly on the CPU so neither backend import path ran the selection must match exactly (plus a greedy_finite case); ids >= n_vocab are rejected; the all-masked row comes from the model output through an eval callback, with a backend temperature before XTC, and both rows are asserted to export backend logits clearing greedy's selection on purpose now fails 2 cases on CUDA0 and CPU (passed before); the all-masked tokens match the CPU reference on all 3 models

On 230c8b5: test-backend-sampler Qwen3 36/36, Qwen3.5 36/36, gemma-4 35/36 (test_logit_bias, which also fails on base); GPU vs CPU parity 0 failing on all 3 models; --device cpu parity 0 failing (2 seeds); a deliberately broken dist fails every model (25 single-output and 50 multi-output cases); server parity 4800/4800 draws and 112/112 generations; -Werror CPU build passes; CPU server sampling tests (GGML_SCHED_NO_REALLOC build) 104 passed in both modes (the Phi-3.5 test whose model cannot load here excluded).

Review round 11 (230c8b5..3817569)

Round 11 on 230c8b5 (4 reviewers): 4 REQUEST_CHANGES.

problem fix test
round 10 builds min(n_outputs_max_per_seq, n_ubatch) chains, so with n_ubatch 1 there is one, and the penalties input took its single-row shortcut on n_chains == 1: later draft rows of a sequence lost the draft inputs of the rows before them (presence penalty 10 on the next draft token: base and CPU 43, backend 42) the shortcut is taken only for a single row; every other use of the physical chain count was checked to mean it (dist's row g / draw g, the draft-rows gate, the per-sequence row check and the reserve all use the logical count) test_chain_columns_n_ubatch also runs n_ubatch 1 and 3 against one ubatch on fixed model logits: 7 of 14 rows differed at n_ubatch 1 before, 0 rows at 1, 3 and 8 on all 3 models after
llama_sampler_chain_remove() handed a penalties sampler over with the draft-row opt-in of the chain it came from (round 9 made clone drop it; remove was missed) the removed sampler (nested chains included) has the opt-in cleared; the chain it is added to applies its own new case in test_penalties_clone_draft_rows (penalties removed from an opted-in [XTC, penalties] chain into a default chain with 4 outputs per sequence): offloaded before, on the CPU after
test_multi_output_multi_sequence_dist's reference summed in float while the kernel sums in double, so it aborted on the CPU backend with stories15M although the token was right the reference sums in double; the kernel is unchanged passes on stories15M-q8_0 (CPU backend) and Qwen3

On 3817569 (one GPU job per card, each under its own timeout and teardown check): test-backend-sampler Qwen3 36/36, Qwen3.5 36/36, gemma-4 35/36 (test_logit_bias, which also fails on base); GPU vs CPU parity 0 failing on all 3 models; --device cpu parity 0 failing (2 seeds); a deliberately broken dist fails every model (25 single-output and 50 multi-output cases); server parity 4800/4800 draws and 112/112 generations. The -Werror CPU build and the CPU GGML_SCHED_NO_REALLOC server tests were not rerun this round.

Timing and memory at b8fea57 (Colab L4, one session, 3 interleaved rounds, base = b11776, -np 4, ms)

model / chain arm 1-stream ITL 4-stream ITL TTFT 1 / 4 streams peak VRAM MiB
Qwen3.5-0.8B, Studio chain base / head / head A/A 4.84 / 4.85 / 4.85 6.99 / 7.03 / 7.02 237-239 / 204-210 1208 / 1202 / 1202
Qwen3-0.6B, Studio chain base / head / head A/A 4.06 / 4.10 / 4.12 6.10 / 6.16 / 6.16 364-367 / 157-158 1396 all
gemma-4-E2B, Studio chain base / head / head A/A 8.74 / 8.76 / 8.75 11.25 / 11.30 / 11.30 168-169 / 335-340 1874 all
Qwen3.5-0.8B, full vocabulary base / head 5.23 / 5.24 8.98 / 9.00 238 / 209 1202 / 1204
Qwen3.5-0.8B, full vocabulary, --spec-default base / head / head A/A 5.77 / 5.78 / 5.79 confounded by acceptance 247 / 248-260 2140-2146 / 2142-2150 / 2144-2150

Backend sampling without speculation (what Studio turns on) is within the head A/A spread of base on every model (largest delta +0.06 ms, Qwen3-0.6B at 4 streams). The reserve that plans every chain's result costs 2-8 MiB of peak VRAM with --spec-default -np 4 on a 248k vocabulary, not hundreds of MB. With speculation on the full-vocabulary chain the 4-stream TTFT is about +12 ms (248 vs 260, the same on the A/A arm).

Tests (CUDA, B200 unless noted)

  • Split-ubatch repro (Qwen3-0.6B, n_ubatch 8, 3 runs each): base 1 of 16 rows wrong, the alloc commit alone 0, the sampler commits without it 3 of 16, this head 0. test-alloc: base aborts at !memory_overlap, head 33/33.
  • test-backend-ops on CUDA0: MUL_MAT + MUL_MAT_ID 3380/3380, FLASH_ATTN_EXT 3993/3993, the other 125 ops 8464/8464.
  • test-backend-sampler parity on Qwen3-0.6B, Qwen3.5-0.8B and gemma-4-E2B: single-output 1488 draws per model, 0 failing; multi-output 0 failing, with 2 tolerated near-ties on Qwen3.5 (distances 9.8e-9 and 8.6e-8, probability differences below 1e-13) and 0 elsewhere. The upstream suite passes on all three except test_logit_bias on gemma-4, which fails the same way on base (it assumes a +10 bias wins).
  • A deliberately broken dist (index + 1) fails both parity tests on all three models (22 single-output and 42-44 multi-output failing cases per model).
  • Server, Studio chains, seeded, with and without --spec-default: 4800/4800 first-token draws and 112/112 generations identical between backend and CPU sampling; the server log shows +penalties on the backend under speculation (base: -penalties). Every crash and divergence repro passes; on base the three top-k cases crash the server.
  • Re-plan cost (Colab L4, 3 rounds, head vs head without the allocator check): paired deltas 0.00 to +0.05 ms per step against 0.10-0.16 ms round-to-round spread with speculation; 0 re-plans over 54k decode steps.
  • git merge-tree is clean against ggml-org master (9b56ccf) and against the composed Pin unslothai#269 (server-side preemption and exact concurrency) at 18365c117 #211 pin-set tree.

Timing (Colab L4, 3 interleaved rounds, 4 streams, ms per step)

model CPU sampling backend sampling CPU + spec backend + spec base backend + spec
Qwen3.5-0.8B 23.09 7.12 25.82 17.09 27.42
Qwen3-0.6B 7.22 6.25 7.83 9.35 8.75
gemma-4-E2B 13.59 11.62 20.29 23.05 24.54

Without speculation backend sampling wins on all three. With speculation it pays for chains with penalties (Qwen3.5, -34%) and costs 14-19% on Qwen3 and gemma-4, most likely from the per-slot sampler chains built under speculation (not profiled).

Not run

  • Draft-model, MTP and rejection-sampling speculation with backend penalties; more than 4 slots with long drafts.
  • Backends other than CUDA; timing on GPUs other than the L4; LFM2 and Qwen3.6 timing.
  • llama_pinset.py verify of a pin set containing this PR (the merge sim is in the round 5 section); CUDA GGML_SCHED_NO_REALLOC test-llama-archs and server suite (host congestion); multi-GPU legs.
  • Exactly tied logits at a cut keep an arbitrary order, as on the CPU.

ggml_gallocr_alloc_graph() reused the previous plan whenever the graph had
the same number of nodes and leafs and every tensor still fit its slot.
The plan also depends on tensor flags: ggml_gallocr_alloc_graph_impl()
allocates inputs before any node runs, never frees an output and never
lets a later tensor reuse an output (or a view of one). A graph of the
same shape in which a tensor became an output therefore kept a plan where
that tensor shares memory with a later tensor, and its data is overwritten
before it is read back. The backend sampler hits this: it builds the same
graph for every step and marks as outputs only the chains of the rows the
step samples, so the outputs change with the number of draft rows.

The plan now records, for every tensor slot it was computed from (each
node, its sources, the leafs), the flags that decide a lifetime: input,
output, view of an output. ggml_gallocr_needs_realloc() requests a new
plan when a tensor has a flag the plan was not made with. A tensor that
lost a flag keeps the plan: it is only allocated earlier or kept longer
than needed, so a graph whose outputs shrink is not re-planned, and once
the plan covers the largest set of outputs it holds in steady state.

test-alloc: test_plan_follows_output_flags plans a chain while its
middle tensor is not an output, then the same shape with it marked as an
output, and checks that it no longer overlaps the tensors after it; and
that the plan made with the output is kept when the flag is dropped.
…rification

Backend (GPU) sampler fixes, each to the CPU sampler's semantics:
- top-k: clamp k to the candidates (k > n_vocab, k > 131072 on the 128k
  probe graph, or a temperature-0 argmax before top-k aborted in
  ggml_top_k), and sort the k results by descending logit (ggml_top_k
  order is unspecified, on CUDA not even deterministic, so a fixed seed
  neither reproduced nor matched the CPU).
- top-p: honour min_keep; keep the first max(n, min_keep) candidates
  where n is the first index whose cumulative probability reaches p.
- min-p: keep logit >= max + log(p) (ggml_step is > 0, so p = 1 dropped
  every token); honour min_keep (the n-th best logit, selected exactly),
  and sort when fewer than min_keep reach the threshold, as the CPU does.
- temp-ext: a positive delta is dynamic temperature also at temp <= 0;
  count only the kept (finite) candidates for the maximum entropy; no
  1e-10 floor on masked probabilities; exponent 0; sort like the CPU.
- logit-bias: a token listed several times gets the sum of its biases;
  candidates already reordered by an earlier sampler are biased by id.
- dist: scale the uniform by the cumulative total and clamp the index
  to the candidates with a non-zero probability (never pick a masked
  candidate); pick the first candidate whose running sum reaches u, as
  the CPU.
- greedy / temperature 0: the first maximum on ties, as the CPU.
- hand-off to CPU samplers after a backend prefix (common_sampler and
  llama_sampler_sample): drop the candidates the backend masked to -inf,
  the CPU samplers would have removed them (XTC at threshold 0 picked a
  token top-p had excluded).
- CPU softmax and top-p accumulate in double: a sequential f32 sum over
  a 150k+ vocabulary is off by ~1e-3, which moved the CPU top-p cut by
  dozens of candidates away from the backend and from an exact softmax.

Draft verification (several outputs per sequence, any speculative
decoding):
- penalties run on the backend with n_outputs_max_per_seq > 1: chain g
  of a sequence uses the accepted history plus the input tokens of rows
  1..g of this decode (the tokens the server accepts before it samples
  row g in common_sampler_sample_and_accept_n), with the CPU accept rule
  (ring buffer, counts). The rows' tokens reach the samplers through a
  new internal llama_sampler_backend_set_rows() called from the sampling
  graph input.
- dist and penalties track the rows of a sequence across ubatches, so
  a draft split over two ubatches uses the right draw and history.
- one input tensor per sampler for all chains (uniforms, penalty ids and
  counts) instead of one per chain: a single host-to-device copy each.

Tests (test-backend-sampler): test_cpu_parity runs 29 whole chains
(Studio's defaults and every edge value above) through common_sampler
on the backend and on the CPU with identical logits and seeds, and
requires the same token for every draw and the same post-sampling
candidates and probabilities; test_cpu_parity_multi_output does the same
for draft verification rows, with n_ubatch 512 and 8 (rows split across
ubatches). test_temp_ext expects dynamic temperature at temp 0.
…of a mismatch

full_nofilter (no sampler before dist) and full_nofilter_k (top-k past the
vocabulary, so a full sort before dist) draw over the whole vocabulary,
where a CPU and a backend cumulative sum differ most.
test_multi_output_ubatch_split decodes two sequences whose draft
verification rows straddle ubatches (n_ubatch 8) with a dist-only chain
and checks that every row's sampled probabilities are the softmax of its
own logits (CPU context, identical batch). It needs no other sampler
fix, so it runs against older trees as well.
…rity tests

The CPU dist sampler accumulates in double, the backend one on an fp32
cumulative sum. On an unfiltered 248k-token vocabulary the two can pick
different tokens when u * total falls on the boundary between two
candidates (Qwen3.5-0.8B: 1 of 96 draws in two cases at n_ubatch 8).

A mismatch is now tolerated only when it is a provable near-tie: with u
replayed from the seed, u * total lies within eps = n * FLT_EPSILON *
total (the fp32 summation error bound over the n candidates) of the CPU
cumulative interval of both tokens, and both tokens' probabilities agree
within eps. Every other mismatch still fails. Each mismatch prints u, the
two intervals, the distances and eps, and every case prints its count of
tolerated near-ties.
The worst-case bound n * FLT_EPSILON * total is 3e-2 at a 248k
vocabulary: it tolerated draws of a deliberately broken backend dist
(index + 1) whose token had less mass than the bound. Use the expected
error of a sum of n floats, sqrt(n) * FLT_EPSILON * total (6e-5 at
248k), which still covers the observed fp32 ties (1e-8 to 9e-8).
@danielhanchen
danielhanchen requested a review from CISC as a code owner October 21, 2026 17:11
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T17:22:32.566911Z 640a4ff PR opened
🔒 Security Review ⚠️ Failed 2026-10-21T17:11:07.345039Z
We couldn’t complete this review. Comment @codex security review to try again.
640a4ff PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

The account paying for this security review has reached its Codex usage limits. The payer can check the Codex usage dashboard. For personal accounts, using credits requires enabling “Use credits for security reviews” in Code review settings. If you do not manage the paying account, contact this repository's admins.

@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: 640a4ff6f7

ℹ️ 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
Comment on lines +4101 to +4105
if (it == pos.end()) {
pos[lb.token] = res.size();
res.push_back(lb);
} else {
res[it->second].bias += lb.bias;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Align duplicate biases after candidate reordering

When a custom sampler chain places logit bias after a reordering sampler such as top-k or top-p, the CPU path at llama_sampler_logit_bias_apply stops after the first matching bias, while this backend merge sums every duplicate entry. A repeated token therefore receives different logits and can produce different sampled tokens depending on whether backend sampling is enabled; either make the reordered CPU path accumulate all matching entries or preserve its behavior here.

Useful? React with 👍 / 👎.

Comment thread src/llama-sampler.cpp
Comment on lines +1175 to +1176
// draws of the current transaction: the output row g of the sequence in this decode uses draws[g], whatever
// ubatch the row lands in, so the draws match the CPU sampler that samples the rows one after the other

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep comments concise instead of hard-wrapping them

This explanatory sentence is split mid-sentence across fixed-width lines, a pattern repeated throughout the added sampler and allocator code. Consolidate or shorten these comments so the patch follows the repository's explicit requirement that comments usually remain one or two lines and are not hard-wrapped.

AGENTS.md reference: AGENTS.md:L77-L81

Useful? React with 👍 / 👎.

Comment thread src/llama-sampler.cpp
Comment on lines +1937 to +1939
struct ggml_tensor * pair = ggml_concat(ctx, ggml_reshape_1d(ctx, threshold, 1), ggml_reshape_1d(ctx, kth_logit, 1), 0);
struct ggml_tensor * pick = ggml_cast(ctx, ggml_step(ctx, ggml_sub(ctx, threshold, kth_logit)), GGML_TYPE_I32);
threshold_keep = ggml_get_rows(ctx, ggml_reshape_2d(ctx, pair, 1, 2), ggml_reshape_1d(ctx, pick, 1));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Limit forced min-p retention by position

When fewer than min_keep candidates satisfy the min-p threshold and multiple logits tie at the min_keep boundary, the CPU fallback sorts and stops after exactly the first min_keep entries, but lowering the backend threshold to the kth logit keeps every candidate tied at that value. This changes the candidate set and can change the sampled token; forced retention needs to select the first positions rather than widening the scalar threshold across all ties.

Useful? React with 👍 / 👎.

Comment thread src/llama-sampler.cpp Outdated
Comment on lines +2409 to +2411
// a zero temperature is greedy on the CPU: it only happens when the entropy is 0, i.e. one candidate holds
// all the probability in f32, and dividing by a tiny temperature keeps exactly that; keep -INF / inf finite
dyn_temp = ggml_clamp(ctx, dyn_temp, 1e-20f, 1e30f);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve greedy semantics for negative dynamic temperatures

For the public llama_sampler_init_temp_ext API, a caller can pass temp < 0 with a positive delta; when the resulting dynamic temperature is nonpositive, the CPU path invokes llama_sampler_temp_impl and uses first-maximum greedy semantics. Clamping that value to a tiny positive number instead leaves tied maxima equiprobable, so the backend distribution can randomly choose a later tied token. The backend needs to mask to the first maximum whenever the computed dynamic temperature is nonpositive rather than merely clamping it.

Useful? React with 👍 / 👎.

…ous plan covered

A plan made again because a tensor became an output covered only the
current outputs, so outputs that alternate between two sets (the rows of
the sequences of one decode split across ubatches) made a new plan on
every alternation. When the graph has the same tensor slots as the
previous plan, the tensors that plan had as outputs are marked as
outputs while planning (the graph gets its flags back afterwards), so
the plan grows to the union of the outputs seen and then holds.

test-alloc: test_plan_keeps_union_of_outputs alternates the output
between two tensors of one chain and checks that neither shares memory
with a later tensor once both were outputs.
A running f32 sum over a large row (the probabilities of a 150k+
vocabulary) drifts by up to ~n * FLT_EPSILON. The backend dist sampler
compares it with u * total, and on the CPU backend picked a neighbour of
the CPU sampler's token (which sums in double): test_cpu_parity with
--device cpu failed full_nofilter_k (46 / 48) on Qwen3-0.6B.
A decode outputs the results of the chains of the rows it samples, which
can be any chain of a sequence, but the reserve ubatch has fewer rows
than n_seqs * n_outputs_max_per_seq when n_ubatch is small, so it
planned only some chains as outputs and the allocator planned again on
the first decode that sampled another chain (an unexpected reallocation
with GGML_SCHED_DEBUG_REALLOC=1 in test_multi_output_ubatch_split).
The reserve graph now marks the results of every chain as outputs; the
decode graphs keep marking only the sampled rows, which the reserve plan
covers.
- candidates handed to the CPU samplers: a row the backend masked
  entirely keeps this row's candidates as they are (it kept the resized,
  default or previous-row entries); no dereference of a missing logits
  or ids array (common/sampling.cpp and llama_sampler_sample).
- logit bias (CPU): the search path after a reordering sampler sums the
  biases of a token listed several times, as the direct path and the
  backend do (it stopped at the first one).
- min-p: in the min_keep fallback keep exactly min_keep candidates by
  rank; the threshold lowered to the min_keep-th logit kept its ties.
- dynamic temperature: a range that is <= 0 everywhere is greedy, as on
  the CPU; one candidate left keeps its logit; the CPU counts only the
  candidates that are not -INF for the maximum entropy, as the backend.
- top-k exports 1-D logits.
- penalties: clone copies the row state and the chains per sequence,
  reset clears the row state; dist reset clears its draws.
- default member initializers for the new members (-Werror with
  -Wmissing-field-initializers, LLAMA_FATAL_WARNINGS).
- llama.h: document that the output rows of a sequence are verification
  rows for the backend samplers (dist draws, penalty history).

tests: test_synthetic_parity (exact logits on the GPU and CPU backends:
min-p tie, negative dynamic temperature range, one candidate, masked
candidate entropy, duplicate biases after top-k, 1-D top-k logits),
test_all_masked_row, and the parity comparator checks both candidate
lists alike (NaN, missing candidates at equal sizes).
…he lifetime bits

The outputs of the previous plan were kept for any graph with the same
number of nodes and leafs. A signature of the graph structure (op and
type of every node and leaf, op of every source; not the sizes, which
change with the number of tokens) now has to match too, so an unrelated
graph never inherits outputs. No union for a plan without tensor slots
(malloc(0) may return NULL). The lifetime bits get names.
…emperature, 1-D candidates

- penalties: several outputs per sequence run on the backend only when
  the chain declares them draft-verification rows
  (llama_sampler_chain_params.backend_draft_rows, common_params_sampling
  .backend_draft_rows); otherwise the penalties stay on the CPU, as
  before this branch, so prompt outputs of which only some are sampled
  and rows without token ids keep the history the caller accepted. The
  server declares it for the slots that speculate. Embedding rows of a
  mixed batch are not read as token ids.
- dynamic temperature: a computed temperature <= 0 is greedy (only the
  first maximum is kept) and a positive one divides the logits without a
  clamp, as llama_sampler_temp_impl.
- top-k and greedy temperature export 1-D candidate ids and logits (a
  2-D layout aborted the gather of a following top-k or logit bias); the
  logit bias gather checks the candidate count.
- top-p (CPU): the double running sum is compared with p at float
  precision, as the backend's cumulative sum.
- test_synthetic_parity: zero and tiny dynamic temperatures, candidate
  layouts through min-p / top-k / top-k and dynamic temperature / top-k /
  min-p / logit bias, greedy after top-k, top-p at a rounding boundary;
  honours --device cpu and skips a device that lacks an op of the chain
  (the CPU backend must run every case).
- test_multi_output_prompt_penalties: several prompt outputs, only the
  last sampled, without draft rows: the backend candidates equal the CPU.
- the multi-output parity cases declare draft rows, as the server does.
- near-ties never accept a token without probability; the split-ubatch
  oracle rejects NaN probabilities; the parity contexts fit their
  batches; main fails cleanly when the model does not load.
The previous commit passed common_sampler_init a copy of the task's
sampling params with backend_draft_rows set. common_sampler_init turns
backend_sampling off in the params it gets when the chain has a grammar
or a reasoning budget, and the server reads task.params.sampling
.backend_sampling afterwards to decide whether the slot gets a backend
sampler: with the copy it still read true, attached the backend chain
to a slot with a grammar, and common_sampler_sample aborted ("using
grammar in combination with backend sampling is not supported"). Set
the flag on the task's params as before.

test: test_backend_sampling_then_grammar_same_slot runs a backend
sampling request on a speculating slot, then a grammar request and
another plain one on the same slot.
…sked rows, merged biases on the CPU

- dynamic temperature: the result row (scaled, greedy, or unchanged for
  one candidate or none) is selected exactly; the arithmetic gate
  multiplied an unused infinite temperature by 0 (NaN, and a masked token
  could be sampled). A row with fewer than two candidates computes its
  (unused) entropy on zeros, so a fully masked row stays as it is.
- argmax_first: the index of ggml_argmax is clamped into the row before
  the gather (CUDA returns -1 for a row of -INF).
- logit bias: the CPU sampler adds the merged bias of each token once, as
  the backend (a token listed several times rounded differently); the
  merged biases are computed once; backend_init checks the device like
  the other backend samplers.
- backend_draft_rows reaches the penalty samplers of nested chains.
- the sampling input collects the row tokens of every sequence in one
  pass over the ubatch.
- ggml-alloc: lifetime_bits keeps its capacity.
…atch test

test_synthetic_parity: one live candidate with a negative exponent, a
fully masked row through dynamic temperature and through greedy, and
duplicate biases that cancel before greedy. The split-ubatch oracle also
rejects an invalid CPU reference.
… the CPU as a whole, min-p argmax bound

- a sampler that sets logits to -INF and keeps the candidates on the CPU
  (logit bias of -INF, temperature <= 0, a dynamic temperature that can
  reach 0) cannot be told apart from a backend filter in the candidates
  handed to the CPU samplers, which drop the -INF ones; a chain with such
  a sampler in its backend prefix and a CPU suffix now runs on the CPU,
  so CPU samplers that count candidates (XTC min_keep) see the same array.
- a nested chain that is not a backend unit has every sampler (and nested
  chain) marked CPU: its own backend prefix would otherwise be skipped on
  the CPU too and run nowhere (penalties lost with draft rows).
- min-p clamps the argmax index into the row before the gather, as greedy.
- the CPU logit bias search stops at the first match again (the merged
  biases list a token once).
…cter candidate comparison

- test_masking_bias_cpu_suffix: -INF biases keep three tokens, XTC with
  min_keep 3 follows: the backend chain gives the CPU token and
  probabilities.
- test_nested_chain_cpu_fallback: a nested chain with penalties and XTC
  under draft rows applies the penalties.
- synthetic min-p on a fully masked row.
- compare_candidates: each token once and a total of 1 on both sides,
  and the total probability that differs is bounded, not only each
  candidate.
…ered candidates at infinite temperature, double entropy), logit bias clears sorted
# Conflicts:
#	tools/server/tests/unit/test_speculative.py
A source is a node or a leaf of the same graph, so its flags are checked at
its own entry. Checking every source slot as well re-planned a graph whose
consumer switched between a computed tensor and an input leaf (llama's input
embeddings built with ggml_build_forward_select: token rows at reserve,
raw embeddings at decode), which aborts with GGML_SCHED_NO_REALLOC
(test-llama-archs, models-check).
The zero-probability bounds took seven extra passes over the row (two
step + sum pairs and their subtractions), which on a full-vocabulary chain
(top_k 0) cost +0.2 ms per step at 1 stream and +0.9 ms at 4 on an L4 with
Qwen3.5-0.8B. A target clamped to at least FLT_MIN already never lands on a
zero-probability candidate and u < 1 keeps it at or below the total, so one
comparison against the float just below the target (sub, step, sum) gives
the first candidate whose running sum reaches it, as the CPU sampler.
common_sampler::set_logits copies the backend candidates every step, also
when the backend already sampled the token and no CPU sampler reads them.
The compaction (drop -INF) stored every candidate twice, which on a
full-vocabulary chain (top_k 0, ~250k candidates) cost about 0.17 ms per
sequence and step on the CPU (+0.2 ms at 1 stream, +0.8 ms at 4 on an L4).
Copy them as base does when the token is sampled; the compaction for a CPU
suffix stores each kept candidate once and refills an all-masked row.
@danielhanchen
danielhanchen requested a review from ngxson as a code owner October 22, 2026 21:43
@danielhanchen
danielhanchen changed the base branch from base/upstream-2639a3b4e to base/upstream-ead9f3205 October 22, 2026 21:44
…masks, unsigned min_keep clamp

- a backend sampler implemented outside llama.cpp may set logits to -INF and
  keep the candidates: treat it as masking, so its chain with a CPU suffix
  runs on the CPU instead of losing candidates at the hand-off
- an infinite temperature (dynamic with entropy 0 and a negative exponent,
  or plain) keeps -INF logits at -INF on the CPU and on the backend
  (-INF / INF and -INF * 0 are NaN)
- top-p and min-p clamp min_keep as size_t before the signed conversion
  (SIZE_MAX turned into -1 and aborted in ggml_view_1d)
…efix stay on the CPU

A backend sampler masks without removing when it can leave a candidate at
-INF that the CPU sampler keeps, also by overflowing a finite logit (any
finite input, an earlier sampler can set it):
- logit bias <= -2^103 (half an ulp of FLT_MAX), -INF included
- temperature < 1 (greedy at <= 0), for temp-ext the lowest it applies
- penalties with repeat > 1, or last_n * freq + present >= 2^103
A chain with such a sampler in its backend prefix and a CPU suffix runs on
the CPU, as before for explicit -INF biases and greedy.

set_sampler does not attach a chain whose first sampler does not run on the
backend: its CPU samplers get the model logits exactly as without backend
sampling, instead of the exported logits with -INF candidates dropped.
A -INF logit in the row can be a candidate the CPU keeps (the model itself
outputs -INF for a reduced vocabulary, eagle3 / dflash) or one a CPU filter
removes, so dropping every -INF at the hand-off changed CPU samplers that
count candidates. The hand-off now copies the row unchanged, and a chain
with a CPU suffix keeps a backend prefix only when the prefix leaves the
CPU's candidates exactly: top-p and min-p (which write -INF where the CPU
removes) join the inexact samplers that send such a chain to the CPU;
top-k shrinks the row as the CPU does. Fully backend chains are unchanged
(the token is sampled on the backend and the row is only copied).
…t draft rows, repeated biases on the CPU, no reserve for inactive chains

- backend dynamic temperature sorts the row only when it has two live
  candidates or more, as the CPU sampler, which returns before sorting
  otherwise (a later greedy picked another token on a fully masked row)
- a cloned penalties sampler does not copy backend_draft_rows: the opt-in
  belongs to the chain the clone is added to
- a logit bias listing a token more than once stays on the CPU, which adds
  the biases one at a time as base (their float sum can overflow where the
  sequence does not); lists without repeats run on the backend as before
- chains at or past n_ubatch can never be active: their results are not
  reserved as outputs
…natemp + greedy, repeated logit biases, penalties clone
Leaves are allocated before any node whatever their flags, so the input
flag does not change the plan of a leaf; only becoming an output does.
A reserve of a graph whose leaf is set as input only later no longer
makes a new plan (GGML_SCHED_NO_REALLOC aborted there).
…atch, every repeated bias applies

- llama_sampler_chain_params is unchanged from upstream: the draft rows
  opt-in is llama_sampler_chain_set_backend_draft_rows, kept by clones
- the context builds min(n_outputs_max_per_seq, n_ubatch) chains per
  sampler and the penalties and dist inputs have that many columns; the
  validation and the per-row draws keep n_outputs_max_per_seq
- the CPU logit bias adds every entry of a repeated token also when the
  candidates are not in vocabulary order
- tests: reordered repeated biases, chain input columns at a small
  n_ubatch, the synthetic parity requires both sides to select, the
  candidate ids are checked against n_vocab, the all-masked row comes
  from the model output after an exact backend prefix
… remove drops the draft rows opt-in

- one physical chain (n_ubatch 1) no longer means one row: the rows of a
  decode come one ubatch at a time and each gets the draft inputs of the
  rows before it, as with several chains
- a sampler taken out of a chain with llama_sampler_chain_remove does not
  keep the chain's draft rows opt-in, as for a clone
- tests: chain columns at n_ubatch 1 and 3 against one ubatch, penalties
  removed from an opted-in chain stay on the CPU, the multi-sequence dist
  reference sums in double as the samplers do

This branch has not been deployed

No deployments
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