llama.cpp offloads sampling to a ggml graph as an experimental feature. Its backend implementations of top_p and
min_p build the graph without ever encoding min_keep, so when a caller sets min_keep >= 2 and enables backend
sampling, the offloaded path returns a strictly smaller set of candidate tokens than the CPU path does from the
same logits. The min_keep floor is silently dropped.
Every offloadable sampler ships two implementations that must select the same survivor set from the same logits: a
CPU _apply and a ggml-graph _backend_apply driven when sampling is offloaded through the public
llama_set_sampler (marked [EXPERIMENTAL]).
The CPU paths honor the min_keep floor (src/llama-sampler.cpp, build 9760, commit 6ee0f65793):
// top_p CPU (L1384): stop only once enough mass AND at least min_keep tokens
if (cum_sum >= p && i + 1 >= ctx->min_keep) { ... break; }
// min_p CPU (L1587): keep while above threshold OR under the floor
if (logit >= min_logit || i < ctx->min_keep) { ... }The backend graphs do not. llama_sampler_top_p_backend_apply (L1427-1498) and llama_sampler_min_p_backend_apply
(L1618-1655) build argsort / softmax / cumsum / ggml_step / set_rows / log / add, and never reference
ctx->min_keep. The offload gates do not compensate either: llama_sampler_backend_support checks only whether the
backend supports the ops, and can_offload in src/llama-context.cpp checks only that the iface exposes
backend_init / backend_apply. Nothing keeps a min_keep >= 2 sampler off the backend, and once there the floor
is gone.
This is a backend-graph omission, the same class as a clone that copies one field but not its coupled twin.
The audit links the real samplers and the real ggml backend and compares the two survivor sets.
- CPU set: the real
llama_sampler_apply, read from the truncatedcur_p. - Backend set: the real
iface->backend_applydriven on a ggml CPU backend, mirroring the shippedllama_sampler_backend_supportgraph driver (ggml_init,llama_sampler_data,backend_apply,build_forward_expand,gallocralloc,backend_graph_compute). A dropped token gets+log(0) = -INF, so a token survives iff its output logit is finite. - Grid: sampler in
{top_p, min_p}, three distinct-logit profiles,p in {0.5, 0.7, 0.9, 0.95},min_keep in {0, 1, 2, 3, 5, 8}.
cells 144
cells where the backend drops the min_keep floor 78 (top_p 31, min_p 47)
cpu law |cpu| = max(threshold_count, min_keep) holds on every cell
the dropped tokens are exactly the lowest-logit min_keep floor on every bug cell
backend set is a subset of the cpu set on every cell
The closed-form law |cpu| = max(threshold_count, min(min_keep, n)) versus |backend| = threshold_count holds on
all 144 cells, so the backend set is the CPU set minus exactly max(0, min(min_keep, n) - threshold_count) floor
tokens. Example (repro.cpp):
top_p p = 0.50, min_keep = 3, 12 distinct logits:
CPU llama_sampler_apply -> keeps 3 tokens (honors the min_keep floor)
backend backend_apply -> keeps 1 tokens (min_keep dropped)
- Two real implementations. The harness links the shipped
libllamaandlibggmland drives the realllama_sampler_applyand the realiface->backend_applyon a ggml CPU backend. No model, no vocab, no decode. Build 9760, commit6ee0f65793, ggml 0.15.2. - Self-validating control. Every
min_keep in {0, 1}cell must show zero divergence, and does (48/48). Those cells run the identical sort / softmax / cumsum / step graph, so any floating-point or argsort artifact would surface there. Their agreement attributes allmin_keep >= 2divergence to themin_keepomission alone, not to the harness or to numerics. - Exact structure, not just a count. On every bug cell the backend set is a subset of the CPU set and the missing
tokens are exactly the lowest-logit floor (
top_poutput is a verified contiguous sorted prefix;min_psurvivors are compared as sets directly), and divergence occurs on a cell if and only ifmin_keep > threshold_count. - Both samplers. The drop is realized on
top_p(31 cells) andmin_p(47 cells). - The mechanism is confirmed at the source line: the backend graphs contain no reference to
min_keep, while the CPU paths gate on it.
min_keep is a public parameter of llama_sampler_init_top_p / llama_sampler_init_min_p (and the server
min_keep field). Backend sampling is the public llama_set_sampler, marked [EXPERIMENTAL] and not the default
(common's default min_keep is 0). The divergence needs min_keep >= 2 AND backend offload enabled: a public
but non-default, experimental configuration. The two paths are contractually required to agree, so a divergence
there is a genuine correctness bug rather than misuse; it is scoped as an experimental opt-in, not a default-path
break. A separate > vs >= boundary difference (the backend ggml_step mask is strict-positive while CPU min_p
keeps logit >= threshold) is noted structurally but is not the load-bearing claim.
./run.sh
Needs the Homebrew llama.cpp at /opt/homebrew. Fully CPU, deterministic; no model, no decode; the ggml graphs
are a dozen f32 elements and run in milliseconds.
keepfloor.cpp: links the real CPU sampler and the real backend graph; the survivor-set differential, the closed-form law check, the self-validatingmin_keep in {0,1}control, the subset and floor-structure checks, and the per-sampler breakdown.repro.cpp: minimal standalone reproduction.PREREG.md: the pre-registration.results/main.json: machine-checked output;results/summary.txt: the one-line summary.
Fully CPU, deterministic, exact; no model, no decode, no GPU. The audited quantity is the survivor set of each path.
The finding is a backend-graph omission (min_keep never encoded). The interaction with a live offloaded
sampler chain, the strict-boundary rider, and non-CPU backends are out of scope; the reachability boundary is stated
above.
MIT.