…oute (#625)
`1bit forward-serve`, the route `1bit serve -m <NPU model dir> --device npu` runs in
process, sampled with a plain argmax and honoured no output constraint at all: it parsed
`max_tokens`/`max_completion_tokens` and nothing else, so `temperature`, `top_p`, `top_k`,
`seed`, `stop` and `response_format` were accepted and silently ignored, and
`grep -ril 'gbnf|grammar' app npu tools` found nothing (issue #625).
Add the controls a client expects, mirroring what this tree's llama.cpp already does rather
than inventing a sampler:
- app/npu_sampling.h: top_k, then top_p, then temperature, then the cumulative-sum draw that
third_party/llama.cpp/common/sampling.cpp's chain ends with, with the seed that run
reproducibility needs; `stop` sequences (4) are matched over the whole pending tail so one
split across two tokens still matches. llama.cpp's own sampler needs a llama_context and a
llama_vocab and this route has raw logits and npu::Tokenizer, and llama.cpp is only built
under ONEBIT_HRX, so this repeats its semantics instead of linking it.
- app/npu_grammar.h: GBNF, llama.cpp's rule model (one flat element vector per rule, ALT/END
between alternatives) as a byte-level pushdown automaton. A token is sampled only when its
decoded text leaves the grammar alive; `next_bytes` rejects almost every token on its first
byte, so only the survivors pay a full feed(). `response_format {"type":"json_object"}`
compiles third_party/llama.cpp/grammars/json.gbnf verbatim; `json_schema` is a 400 because
the schema-to-GBNF converter lives in llama.cpp's common/, which this route does not link.
Greedy stays the default: no field means the argmax decode the route has always done, an
absent field is not a behaviour change, and a field the route cannot honour is a 400 whose
`error.message` says why instead of a silent ignore. `finish_reason` now separates `length`
(the token budget ran out) from `stop` (end-of-sequence, a stop sequence, a grammar with
nothing left) -- it was hard-coded to "stop" before.
Both headers are device-free, so tests/npu_sampling_test.cpp runs the sampler, the parser, the
JSON grammar and the request parser in CI with no model and no NPU (rule 1). Checked on
Strix Halo, engine 5c2aee2 + this commit, Qwen3-0.6B-NPU2 (653 MB model.q4nx), host GEMM
(NPU_INFER_HOST_GEMM=1: reproduces the forward offline, no device), greedy prefix unchanged
from the pre-change binary, seed 42 twice identical, seed 7 different, grammar output
` Paris` out of three alternatives and `{"city": "Paris"}` out of a GBNF object.
Issue #625:
1bit forward-serve(the route1bit serve -m <NPU model dir> --device npurunsin-process) decodes with a plain argmax and constrains nothing. It parsed
max_completion_tokens/max_tokensand ignored everything else the OpenAI API puts next to acompletion:
temperature,top_p,top_k,seed,stopandresponse_formatwere accepted andsilently dropped, and
grep -ril 'gbnf|grammar' app npu toolsfound nothing.This adds the controls, and constrained output, by mirroring what this tree's llama.cpp already
does rather than inventing a sampler.
What
temperature0or absent = argmax, as before. Above0divides the logits before the drawtop_kkhighest logits (0/absent = all)top_p1/absent = all),min_keepcandidates always stayseed-1seeds from the clockstopfinish_reasonbecomesstopgrammarthird_party/llama.cpp/grammars/*.gbnf) the answer cannot leaveresponse_format{"type":"json_object"}= the answer must be JSON.{"type":"json_schema"}is a 400app/npu_sampling.h— top_k, then top_p, then temperature, then the cumulative-sum draw thatthird_party/llama.cpp/common/sampling.cpp's chain ends with (src/llama-sampler.cpp'sllama_sampler_top_k_apply/top_p_apply/temp_apply/dist_applysemantics, includingk <= 0andp >= 1turning a stage off, and the max-logit-subtracted softmax). The chain order isthe one
third_party/llama.cpp/common/common.h:260-270defaults to — top_k, top_p, temperature —and temperature is a division of logits (
llama-sampler.cpp:1822,logit /= temp), which is whytop_p's distribution is a scratch vector and not a write-back over the candidates. llama.cpp's own
sampler needs a
llama_contextand allama_vocab; this route has raw logits andnpu::Tokenizer, and llama.cpp is only built underONEBIT_HRX, so the semantics are repeatedhere instead of linked. Plus
stop:find_stopwithstop_holdback, so a stop sequence splitacross two tokens still matches and no held-back text is ever dropped.
app/npu_grammar.h— GBNF: llama.cpp's rule model (one flat element vector per rule,ALTand
ENDbetween alternatives, theS{m,n}/S*rewrite rules, char classes, escapes,( )groups,
#comments) compiled to a byte-level pushdown automaton (src/llama-grammar.cpp'sstack discipline: a frame points at a
E_CHARor a rule reference, never atALT/END; anempty alternative leaves the caller's frame; an empty stack is a complete match).
response_format {"type":"json_object"}compilesthird_party/llama.cpp/grammars/json.gbnfverbatim.
json_schemais a 400 — the schema-to-GBNF converter (common/json-schema-to-grammar.cpp)lives in llama.cpp's
common/, which this route does not link, and hand-rolling one is thelarger change the issue says to leave for later.
app/forward_serve.cpp— the decode loop samples instead of argmax'ing, tests each vocabularytoken against the grammar state (
Grammar::next_bytesrejects almost every token on its firstbyte, so only the survivors pay a full
feed()), holds back the stop-sequence tail, and returnsfinish_reasonlengthwhen the token budget ran out vsstopfor an end-of-sequence token, astop sequence or a grammar that allows nothing more. It was hard-coded to
"stop"before.docs/npu.md— "## Serving" → "### Sampling and structured output": the fields, the default,the finish reasons, and where the semantics come from.
Greedy is the default and the parser stays backward compatible: a request that names none of
these fields takes the same argmax path as before (argmax with the same lowest-index tie-break),
and a field the route cannot honour is a 400 whose
error.messagesays why(
temperature: must be zero or more (zero decodes greedily),gbnf: unterminated literal at line 1,response_format: json_schema needs a schema-to-GBNF converter this route does not have; ...)rather than another silent ignore.
One change to the device path, for the verification below
run_forward_servenow opensxrt::deviceonly when it is going to use it: withNPU_INFER_HOST_GEMM=1it callsQ4nxNpuForward::init_hostinstead(
npu/forward/include/q4nx_forward.h:64-70, the offline mode that "validates the whole forwardagainst the Python reference offline" and never touches the device). This box has no
/dev/accel*, and that mode is what lets the three runs below exercise the real model, the realtokenizer and the real decode loop here. With the variable unset the code path is unchanged.
Say the word and I will drop that hunk and re-verify on the box that has the NPU.
How tested
Device-free, and it runs in CI now:
tests/npu_sampling_test.cpp, registered as ctestnpu_samplingnext tohttp_guard/think_split, no model and no NPU, and its target is listed in.github/workflows/ci.yml's explicit build list — the list was missing it, so CI built everythingelse and reported
22 - npu_sampling (Not Run), i.e.98% tests passed, 1 tests failed out of 43without ever executing the test. That line is fixed in this commit.
Those are the re-run numbers at the current head,
815408e:44/44in thebuild-nputree, with22 - npu_samplingamong the 44 and passing (0.00 sec). A local reproduction of CI's ownconfigure (
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=gcc-16 -DCMAKE_CXX_COMPILER=g++-16, no-DONEBIT_NPU, the same target list asci.yml; the twoFetchContentdependencies were pointed at the sources already fetched here rather than re-cloned)gives
100% tests passed, 0 tests failed out of 43withnpu_samplingexecuted andnpu_repack_pathskipped — 43, not 44, because that tree has no
ONEBIT_NPUtests. (The reviewer's CI log is that sametree before this commit:
22 - npu_sampling (Not Run)and98% tests passed, 1 tests failed out of 43.)The test covers: greedy == argmax including the tie by lowest id; temperature 1 able to draw the
second logit;
top_k 1== greedy andtop_k 2restricting the choice;top_p 0.5keeping only thehead; the draw matching the hand-computed distribution over 20000 draws per case —
softmax({10,0,0})'s4.6e-5tail token,softmax({ln 3, 0, 0}) = {0.6, 0.2, 0.2}, and9/11attemperature 0.5; the same seed giving the same 12-token sequence and another seed not; a token mask honoured
and an empty mask returning -1; stop matching (position, longest-at-a-tie, holdback, split across
tokens); every proper prefix of
{"a": [1, true, "x"]}alive and only the full string accepted,with malformed inputs dead; GBNF alternation,
[a-z]+,[^a-c],"a"{2,3},"a"*, non-ASCIIliterals as UTF-8, and the parse errors as 400 text; and the request parser's defaults, overrides
and every 400 message.
Model runs: Qwen3-0.6B-NPU2 (
model.q4nx, 653 MB) +tokenizer.json, host GEMM, one server,this commit. Exact commands and outputs (the
text/finish_reasonof each answer, whole bodiesare longer):
1. Greedy, no sampling fields — the command from the issue, text identical to the pre-change
binary (the pre-change binary answered
"text":" Paris. The capital",finish_reason"stop";only the finish reason changed, see above):
2. Sampled, reproducible by seed, and a different seed changes it:
(The 0.6B base model rambles at temperature 0.7;
temperature 0.01on the same prompt stillanswers
Paris. The capital of France is also, which is the greedy text, so the draw is notmangling the head of the distribution.)
3. Grammar-constrained, on the same prompt:
4. Stop sequences, and the stream route still frames:
5. The 400s (whole bodies):
Review follow-up: the three defects
Reviewer's list, one by one. Head
815408e.1. The sampler applied temperature to probabilities.
top_pwrote its softmax back over thecandidate array (
x.logit = exp(x.logit - max_l)), so the temperature stage that follows dividedexp(probability)byT.exp()of a value in[0, 1]is flat to withine^-1, so the draw wasnearly uniform over whatever
top_k/top_phad kept, and every tail token that survived got~1/n_keptinstead of its ~0 probability. Measured on{10, 0, 0}at temperature 1 withtop_p = 0.5, min_keep = 3(so the4.6e-5tail tokens are kept and drawable), 20000 draws:4.6e-5tokenlogits {10, 0, 0}logits {ln 3, 0, 0}, 0.2 tokenThe fix keeps the logits: the top_p distribution lives in a
std::vector<double> probscratchvector and
c[i].logitstays a logit, so the chain is top_k on logits → top_p on the distributioncomputed from them → temperature dividing the survivors → cumulative-sum draw. Top_p's softmax is
taken on the logits as they stand at that point, i.e. before the temperature, which is exactly what
llama.cpp's chain does (
top_p_applysoftmaxescur_p;tempcomes aftertop_pincommon/common.h:260-270) — atT = 1, the temperature the tests use, the two readings coincide.New test
test_sampler_distributionin the same file: three cases of 20000 draws againsthand-computed softmax probabilities. It fails on the old header with four
FAILlines and passes onthe new one.
2. CI never ran the test.
.github/workflows/ci.yml'sBuildstep has an explicit target list(
ctestruns whatever binaries exist, so a target missing from that list reports "Not Run"). The wholediff is the target name, typed next to the neighbouring NPU tests:
3. The body overclaimed. The
(...) in CIand44/44lines were wrong as wired; the sectionabove now states the command, the tree it ran in and the number that tree really reports (
44/44inbuild-npu;43/43in theONEBIT_NPU=OFFtree CI configures, where the test is #22).The RFC question, for the maintainer
CONTRIBUTING.mdasks for an RFC before "a change to1bit serve's command line or HTTP API thatexisting users would notice". This is additive — no field, no flag, the same greedy text when
nobody asks for anything — so I read it as the pull-request half, and #625 asks for the change. If
you would rather see it as a serving-mode change, say so and I will open the discussion and park
this branch until it is accepted.
Not touched: the private add-on route's
repetition_penalty(app/unified.cpp,npu/generate.cpp) is a different decode path and still does whatdocs/npu.mdsays; this routehas no repetition penalty, before or after. No kernel module, systemd unit, llama-server or
1bit-canonicalwas restarted, and the model runs above are short and finished.