Repository navigation
[Engine] OpenAI logprobs / top_logprobs / allowed_token_ids for the CUDA/HIP engine - #3200
xiezhipeng-git wants to merge 17 commits into
Conversation
…ngine Adds per-request `logprobs` / `top_logprobs` to `/v1/chat/completions` (streamed or not) on the CUDA/HIP engine, plus `allowed_token_ids` with vLLM's semantics. Upstream already has this on SYCL (835d30b) and Metal (31f2668); CUDA/HIP is the backend still missing it, and this fills it in the same shape: `logprobs=K` and `allowed=a,b,c` on the engine's GEN line, and an `LP <logprob> [id:logprob ...]` line after each `T <id>`, written in the same flush -- the same protocol the SYCL port speaks, so one server-side reader serves both. Engine half (src/program/generate.cpp): - two request keys in the `--serve` key=value line, skipped as unknown by an engine that predates them; - `emit_lp_line()` takes the logprob from the verify window's head logits before sampling, penalties and temperature, a max, a logsumexp and, for `top_logprobs`, a partial_sort over the vocabulary -- one printf per token; - the pipeline path asks `V1(A)` (the verifier of window A, whose tokens that loop prints) and the serial path `ver`, matching each token's own window; - off by default: a request without `logprobs` produces today's output, byte for byte, and the per-token cost is one integer compare. Server half (serve/server.py): - `top_logprobs` / `allowed_ids` translate the request; `sampling_keys` appends `logprobs=K` and `allowed=` only when asked; - `generate` reads each token's `LP` line before yielding the token (want_lp off = the path is unchanged); a held line is consumed before the next read, so an engine that emits no LP lines loses no lines, and a DONE read while looking for one still ends the request; - `LogprobEntries` decodes the token bytes incrementally (a character split across tokens lands in the one that completes it), so the entries, joined, spell the reply; the stop token is not listed; - `allowed_token_ids` keeps only the named ids and renormalises over them, so a caller asking about specific candidates gets a distribution over exactly those; - `structured_chunks` re-aligns the entries when `response_format: json_schema` rewrites the content: if a run of tokens spells what is returned, or spells the model's own JSON when that is the same JSON value as the canonical form, the content is returned as the model wrote it so the tokens still spell it; otherwise there are no logprobs -- never a set that does not match. Tests (serve/test_logprobs.py, no GPU): 17 items in python -m unittest serve.test_logprobs -- the request keys, the LP read (including the drain), the reply shape byte for byte, streaming, thinking's two parts each spelled by its own tokens, allowed_token_ids renormalised to 1, the schema cases, and the span helper. serve's own suites (server, structured, responses, logit_bias, security) pass unchanged: 392 tests. Off by default, additive only: a request that does not ask for logprobs gets the same bytes as before.
|
A reply can be given the next request's logprobs. When a stop string or the parser holds back the end of a reply, that tail is sent after the request has released its turn. If the next request is generating by then, the tail takes the next request's LP lines: the first reply's tokens no longer spell its content, and the second reply loses those entries. Two Reproduction on 104becf: add Request A is "Café costs €5." with The path in serve/server.py at 104becf:
The fix:
Branch: https://github.com/leonardomurillo/Strata/tree/logprobs-own-lines (ad3cff8, one commit on 104becf). PR to Separate note, reproduced with the mock engine, not fixed on the branch: with |
…ded replies; unreadable LP lines skipped - LPLines: the engine's lp_lines deque is tagged with the request's cancel Event (one per request, passed unchanged from the handler through Service.run to Engine.generate). LogprobEntries is created with the same Event and reads only lines carrying it. A reply's tail (text held by the parser or a stop string) is spelled after the request has released the queue; by then the next request may have replaced lp_lines with its own. The tail now reads its own lines and never consumes the next request's. - logprobs require max_tokens / max_completion_tokens 1..4096 (400 otherwise): entries of a non-streamed or json_schema reply are held until it ends. - an LP line that does not parse (nan, -nan(ind), a cut line) or carries a non-finite value gives no entry for that token instead of failing the request. - tests: two requests on one engine where the first one's last "." is held by its stop string and sent while the second is generating (each reply's tokens spell that reply); unreadable lines; the max_tokens bound. Follows Niko1221#3200.
…e filter
Follows the logprobs-lines change; the one defect left open there.
`_renormalise` returned None when the filter left no candidate. The entry was appended
all the same, so the next `spell()` failed on `e["token"]`: an unstreamed reply came back
as 400 "the request is malformed (TypeError: 'NoneType' object is not subscriptable)", a
streamed one stopped after the role chunk.
- the chosen token is put back among the candidates when the caller named it. The line
carries its logprob on the head and never repeats it among the named ids, so
`allowed_token_ids` naming only the token the model chose used to leave nothing to
normalise over -- on a real engine too, not only under the mock. It is then the whole
distribution, at 1 (logprob 0), which is what vLLM's mask gives.
- an entry is never None and is never dropped: the reply's tokens have to keep spelling
its text. A line that carries none of the named ids keeps the chosen token with its own
logprob and an empty candidate list.
- the entry's `logprob` is the chosen token's own value. It used to be copied from the
first named alternative, which is another token's number.
- `top_logprobs` is sorted most likely first, as the unfiltered top-k already is: with the
chosen token put back, insertion order no longer says anything.
The class docstring said "vLLM's semantics". vLLM masks generation with these ids
(logits.masked_fill_(mask, -inf) before sampling) and reports the unmasked distribution;
here they filter what is reported, so the chosen token can be outside them. The docstring
says that now.
Tests (serve/test_logprobs.py, no GPU): a line whose named ids are all filtered out, and
one naming only the chosen token, at the LogprobEntries level; the same two over HTTP; a
choice of ten ids with the reply "11", so only the ended reply settles which one; and a
held tail of two tokens ("5." as the start of "5.!"). 26 tests in the file.
Not run: a real engine, and the parallel path (its batched generate never sets lp_lines:
a reading of the code, unchanged here).
…oken_ids does The CUDA/HIP engine has had logprobs since Niko1221#3200 and DETAILS.md said nothing about them, and the reply cap the logprobs path now needs (max_tokens 1..4096, a 400 without it) is something a client can hit without warning. One bullet in "Using it", the same voice as its neighbours: the keys and where the entries land, that the tokens spell the reply, the cap, what allowed_token_ids keeps and renormalises, and that a token whose probability could not be read gets no entry.
`GEN ... logprobs=K allowed=a,b,c` parsed the ids into `req_allowed` and then cleared the
vector three lines later, next to `req_imgs.clear()`, before either decode loop ran. So
`emit_lp_line` always took the top-k branch and `allowed_token_ids` only ever filtered what
was already in top-k. On Swift 1.5 IQ3_XXS, `allowed_token_ids: [16]` ("1") returned the
chosen id twice, at 0.5 each, and ten named digits (`0`-`9`, ids 15-24) for the reply `11`
covered 2 and then 8 of the ten in the two entries (2 and 5 with `top_logprobs: 5`): ids
outside top-k were simply lost.
Remove the clear. Measured after it, same model, same requests: `[16]` gives one entry per
token at logprob 0, and the ten digits give all ten candidates in every entry, sorted,
summing to 1.
Not run: HIP, SYCL and Metal builds (this machine has one CUDA card). The solo and
speculative decode loops are the only `emit_lp_line` call sites; the batch loop is unchanged.
The engine's batch loop prints `BT <slot> <id>` and never calls `emit_lp_line` (the only two call sites are the solo and speculative decode loops), so a server started with `--parallel` could only answer a logprobs request with the field missing -- which a client cannot tell from a reply that had no tokens. Refuse it with the same 400 the cap check raises, before anything is generated; a request that does not ask is untouched. Measured with `--parallel 2` on Swift 1.5 IQ3_XXS: logprobs, streamed and not, both 400; a plain request still 200 with the same content. Test: test_logprobs_are_refused_on_the_batch_path (red when the check is disabled). The docs bullet and the class docstring say it. serve: 908 tests, 0 errors, 20 skipped (`python -m unittest discover -s serve -t . -p "test_*.py"`).
Thanks for the review — I finally ran this on real hardware, and it changed two things. Your Your held-tail fix holds. With the ownership test in The one you left open. With that in place your four One more from the same path, which came out of reading the code rather than a crash: About "vLLM's semantics" in the docstring. I read vLLM's source before deciding:
Tests and speed. What I did not run: HIP, SYCL and Metal builds — one CUDA card here, and The docs change is one bullet under "Using it" in |
emit_lp_line indexes its logits row with each named id as given (src/program/generate.cpp), so an allowed_token_ids entry >= the model's vocabulary reads out of bounds. The server checks the ids against the tokenizer's vocabulary before anything is sent and raises the same 400 as the other logprobs checks; a request that asks for no logprobs, or names ids inside the vocabulary, is unchanged. Test: Api.test_allowed_token_ids_outside_the_vocabulary_is_a_400 (vocabulary 300: id 300 -> 400, id 299 -> 200). This one comes from reading the code -- no crash was seen -- and the docs bullet says it. serve: 909 tests, 0 errors, 20 skipped (python -m unittest discover -s serve -t . -p "test_*.py").
|
Thank you for running this on real hardware and for keeping the authorship, much appreciated. The real-engine race (20 of 20 replies spelling their own content) was the part I could not show from the mock, so that closes it for me. Nice catch on I'm closing my PR into your branch, since its commit is already in here. Nothing else from my side on this one. |
|
Tested on hardware: merged with #3332 onto
#4056 builds on this and adds logprobs on #3332's |
|
Thanks @gdevenyi — the Three things about the field shape, since #4056 has to reproduce them on
One scope note so it does not get carried into #4056: On the PR itself:
One thing that is visible in the checks and is not the code: |
The 1..4096 bound on max_tokens / max_completion_tokens was applied to every
request that asked for logprobs. Its reason only holds for a reply that keeps
its entries until it ends: a reply that is not streamed (openai_collect holds
the whole list to build the body) or that answers a json_schema
(structured_chunks buffers it to validate). A streamed reply hands each entry
out with its chunk and keeps none, so it is bounded by its own length.
So a streamed reply is bounded by the model's context, as on OpenAI, whose
request schema has max_tokens optional and no link between it and logprobs
(vLLM has none either: max_tokens defaults to 16 and logprobs only checks
itself). Two behaviours change, both of them requests that could not have
worked before:
- `"logprobs": true, "stream": true` without max_tokens is answered instead
of a 400. The OpenAI SDKs send no max_tokens by default.
- `"logprobs": true, "stream": true, "max_tokens": 5000` is answered instead
of a 400.
A reply that is not streamed, or that answers a json_schema, still needs
max_tokens of 1..4096 and gets a 400 without it; the message now says why and
which case it is.
Tests: serve.test_logprobs 30/30 (28 before; the two new ones fail with a 400
on the previous commit). test_a_streamed_reply_needs_no_bound covers 5000,
0 (the rest of the context) and no max_tokens at all, and checks that the
entries still spell the reply; test_a_streamed_json_schema_reply_is_still_bounded
keeps the cap on that path.
main moved to 41cf05a (992 files, +133057/-12580). The only file that conflicts is docs/DETAILS.md, which main restructured (-2162/+1069): the logprobs bullet sat in the part of "Using it" that was rewritten. It is resolved by taking main's version and putting the bullet back among the server-behaviour bullets, before the non-finite-logits one. The two code files merge without a conflict, and main's changes to them do not touch this branch's subject: its 695 lines in serve/server.py contain no `logprob`, and its 674 in src/program/generate.cpp touch no `LP`, `BT` or `GEN` line format. serve.test_logprobs: 30/30 on the merged tree.
|
Thanks, that's useful. #4056 now includes eca6513 ( On FYI: since ed4b4db on |
The 1..4096 bound on max_tokens / max_completion_tokens was ours alone: OpenAI's request
schema makes max_tokens optional and ties no limit to logprobs, and vLLM's defaults to 16 or
None and its logprobs only check themselves. A reply is already bounded by max_tokens -- or by
the rest of the context when it is not sent -- so the constant was a second bound on top of one
that exists, and it answered 400 to requests that would have worked.
What it was protecting is real, but it is the reply's own length: a reply that is not streamed,
or that answers a json_schema (buffered to be validated), holds every entry (its token, bytes
and up to 20 alternatives) until it ends, while a streamed reply hands each entry out with its
chunk and keeps none. Both are now answered at any length; a client that wants that memory
bounded streams.
Three behaviours change, all of them a 400 before:
- not streamed, no max_tokens (what the OpenAI SDKs send by default) is answered
- not streamed with max_tokens 0 / -1 ("the rest of the context") is answered
- not streamed, or a json_schema, with max_tokens above 4096 is answered
Tests: serve.test_logprobs 30/30. test_logprobs_take_any_max_tokens (200 / 5000 / 0 / -1 / not
sent, not streamed) and test_a_json_schema_reply_takes_any_max_tokens (200 / 0 / 5000, streamed
and not) replace the two that asserted the 400. The whole serve suite: 1064 tests, 1 error, 21
skipped -- the error is serve.test_winjob.test_children_end_with_a_killed_server, which fails
the same way on a clean upstream/main checkout here and touches no logprobs code.
Hardware, Swift-1.5 IQ3_XXS with --spec 4 --mtp, 22 cases: max_tokens absent / 0 / 200 / 4096 /
5000, streamed and not, each 200 with every token carrying an entry; json_schema at absent / 0 /
200 / 5000, streamed and not, each 200; allowed_token_ids over ten digits and [16] unchanged;
an id past the vocabulary still a 400.
main moved from 41cf05a to 4b9e05a (55 files, +422/-133: the 0.2.6 version bump, the uninstaller fixes, and an empty conversation now answered with a 400). Nothing conflicts: its 4 lines in serve/server.py are `require_messages` and the answer-speed guards, and its 1 line in src/program/generate.cpp is a comment. Against 4b9e05a this branch is still 4 files, +901/-8. serve.test_logprobs: 30/30 on the merged tree.
|
Second hardware run, now including eca6513 (tested through #4056 at
Both ran Qwen3.8-Flash-Next IQ3_S with the args The results were the same on both machines:
So the LP lines stay in step with their One thing for anyone comparing numbers: on monster (11 GB, so more experts move between RAM and VRAM), even a 28-token prompt's logprob drifts every few requests under the default settings. Eight identical requests gave |
|
Thanks — Pascal / sm_61 on CUDA 12.9 with One correction on the version, because two of those lines are
The reason is the one you pointed at earlier: So LOGPROBS_MAX_TOKENS = 4096
def check_logprobs(req, svc, max_req, kept=True):
if kept and not 0 < max_req <= LOGPROBS_MAX_TOKENS: ...
|
…plica server without slots A batch slot prints "BT <slot> <id>" and its decode loop never calls emit_lp_line, so logprobs was refused outright there. An admission does write one: a max_tokens 1 request is asked of the engine as BGEN and read as "GEN 1" (generate.cpp:12090), the single-token path that prints "T <id>" and the LP line after it in one flush. _control() now reads that line (want_lp) and keeps it on the request's own LPLines, held on its cancel Event -- the engine's lp_lines is one attribute and several slots read control lines at once, so it cannot be shared between them. A longer reply is still a 400: past that first token its entries would cover a prefix only, which a client cannot tell from the model's own answer. This is the line gt50 drew on Niko1221#1916 ("LP lines for the BT slot phase can be a later step"), and the read itself follows the server-only fix karls0r posted there (+57/-8, engine unchanged). The other half is a refusal that should not have been there at all. ReplicaEngine.batch totals how many requests its engines run at once and counts a replica without slots as one (serve/replicas.py), and the check asked it "does this engine use batch slots". A server started with --replicas 2 and no --parallel therefore answered 400 to every logprobs request, while reading every reply by the GEN path, which does write the lines. batch_slots_on() asks each replica's own figure instead. Tests: serve.test_logprobs 32/32 -- the two new ones in Replicas are no slots behind two replicas, and slots on one of them; serve.test_logprobs_parallel 6/6 -- a decision, allowed_token_ids on the admission, four decisions at once each with its own, not asked, a longer reply a 400, and an engine that writes no LP lines at all. Hardware, Swift-1.5 IQ3_XXS, --spec 4 --mtp, --parallel 2: max_tokens 1 + top_logprobs 5 -> 200, one entry spelling the reply (A -0.062427, then B -2.848, " A" -7.415); allowed_token_ids over 11 ids -> 200, the named ids as the candidates, sum(exp) 1.0, a chosen token outside them keeping its own logprob; max_tokens 5, max_tokens absent and max_completion_tokens 0 -> 400 naming max_tokens 1; max_completion_tokens 1 -> 200 with its entry; streamed -> one chunk carrying the entry for "Odd"; four decisions at once on two slots -> 200 each, 1.6 s for all four, every entry matching its reply; a plain request and a 64-token reply unchanged. The whole serve suite: 1073 tests, 1 failure, 1 error, 21 skipped. Both are known on a clean upstream/main here and touch no logprobs code: test_responses.test_server_close_ends_the_sampler (1 != 2, order-sensitive -- it passes when its file runs alone, which it did in the 83-test run just before this one) and test_winjob's running() on a pid this environment cannot read.
…eft off the LP line 26a2d48 refuses an allowed_token_ids id past the vocabulary on the server, but only when the tokenizer reports its size; with no size to check against, the request reaches the engine and emit_lp_line indexes its logits row with the id as given, reading out of bounds. The engine's own vocab() is authoritative, so the named ids are checked against it where they are printed: an id outside 0..vocab()-1 does not appear on the line, and a reply whose named ids are all out of range keeps the chosen token's own logprob with no alternatives -- the shape the server's "no named id observed" case already produces and documents (_renormalise). The cost is one integer compare per named id, a list the server bounds by the vocabulary; no other path moves. This one is from reading the code -- no out-of-bounds read was observed (the server's tokenizer always reports a size today). Incremental CUDA build on this machine (RTX 4090 config, build-lp) compiles and links: [157/158] Linking CXX executable strata.exe.
server.py's json_schema logprobs alignment reads the model's own JSON form through it, which imported a private name across module lines -- a rename in structured.py would break the server for no reason the compiler can name. The function keeps its behaviour, gains the docstring a public name owes, and the always-true "if _extract_json" at the call site (the import above it already fails when the name is missing) goes with it. No reply changes shape. python -m unittest serve.test_structured serve.test_logprobs serve.test_logprobs_parallel -- 48 tests, OK (structured 10, logprobs 32, parallel 6).
|
I'm following this PR along with #3332 and #4056, particularly for using Qwen3.8-Flash-Next for decision scoring. I'm running Strata on dual Titan Xp GPUs (Pascal, sm_61) and would be happy to help test CUDA logprobs compatibility, performance, and reproducibility on older hardware. If there are specific test cases that would be useful, I'm glad to contribute my time. |
Two commits from envy-ai, reviewed in this pull request: a2ea8f9 score generated token logprobs on GPU 7265fc7 allocate verifier logprobs buffers at startup Kept as a merge instead of a squash: the GPU scoring work keeps its author and can be reverted without dropping the feature. Effect: logprobs marginal cost +1.46 ms/token -> +0.17 ms/token (140.1 -> 171.0 tok/s against a 176.2 tok/s baseline). The allowed_token_ids path keeps CPU scoring by design; Metal and gfx906 builds exclude the kernel at configure time.
…etions (#3200) /v1/chat/completions on the CUDA and HIP engine accepted logprobs and returned an empty choices[0].logprobs.content. Now "logprobs": true with "top_logprobs" 0 to 20 (and the legacy "logprobs": N) returns one entry per token with its alternatives, streamed or not, and "allowed_token_ids" returns a distribution renormalised over exactly the ids the caller named. Engine (src/program/generate.cpp): the GEN line takes logprobs=K and allowed=a,b,c. After each "T id" the engine writes one "LP <logprob> [id:logprob ...]" line from the verify window's head logits before sampling, penalties and temperature. The SYCL engine already wrote LP lines for logprobs=K; it now reads allowed= as well, with the same line format. A request without logprobs reaches none of this: both call sites are behind "req_logprobs >= 0", a local int of the request, so the default path prints what it printed before and does no extra work per token. Changes from the PR: the call sites are guarded instead of calling the function for every token, the function is static, the token index is checked against the row, and the Chinese comment is in English. Server (serve/server.py): LP lines are read before the token is yielded and belong to the request that asked (tagged with its cancel Event), the entries spell the reply, json_schema replies return no logprobs rather than a mismatching set, an allowed id past the vocabulary is a 400, and logprobs on a --parallel server is answered for max_tokens 1 only (a slot's BT lines carry no LP line). serve/structured.py: _extract_json is now extract_json (the logprobs alignment uses it); the two uses added by the #2893 retry follow the rename. Left out: the PR's bench and result files (none in the diff). #3332 (/v1/completions) and #4056 (logprobs on it) build on this change; this one needs nothing from them. Tests: serve.test_logprobs and serve.test_logprobs_parallel (30 tests with the PR's), serve.test_structured, serve.test_responses: 94 tests OK. Not covered: no model ran here. The PR author reports 28 of 28 on an RTX 5080 with MTP and on a GTX 1080 Ti; the layer-split pipelined decode path (the first call site) was not run by anyone, and the LP values were not compared with a CPU softmax of dumped logits. Co-authored-by: Leonardo Murillo <4869242+leonardomurillo@users.noreply.github.com>
|
Thank you. Fixed in 0.2.7: OpenAI logprobs, top_logprobs and allowed_token_ids on /v1/chat/completions. Your name is on the commit. We could check it with unit tests and builds, but not on your hardware or with your workload. Please try 0.2.7 and tell us how it goes. Release: https://github.com/Niko1221/Strata/releases/tag/v0.2.7 |
|
0.2.7 works on the hardware and workload this was built for: one RTX 4090, Swift-1.5 IQ3_XXS, Released 0.2.7
@Niko1221 0.2.7's squash ( Fresh same-day A/B, three runs per cell, medians;
The useful comparison is the added cost within each row, not the difference between the two no-logprobs baselines. The first plain request on each engine is cold (9.6 and 9.4 ms/token against ~6.0 afterwards); dropping it moves the two added-cost figures to 1.75 and 0.57 ms/token, so the direction does not depend on that sample. An earlier run with a different bench harness, on the same card and over the same two variants, is in the #2 thread linked above and gives the same direction, 1.46 → 0.17 ms/token. Tests The same discovery command CI uses, on each tree and each platform:
The one error is already in 0.2.7 and has nothing to do with logprobs: On our side it is visibly not this change: a clean 0.2.7 checkout with no local modifications fails identically, The two Linux rows are the measurement behind that, not an inference from it: the Windows-only tests skip there and Still on the CPU scoring path: |
|
Thanks for porting it. I tried 0.2.7 on three Ada cards, an RTX 4090, an RTX 4080 and an RTX 4080 SUPER (Windows, the CUDA 13 engine package 0.1.52, driver 610.88). All three ran Qwen3.8-Flash-Next GSQ-RCO IQ3_XXS with the same engine arguments as our own build and What works, on all three cards:
One thing to know when comparing numbers: across a server restart, the same request gives a different logprob, e.g. −0.1382, −0.1023 and −0.1294 at 92 tokens in three sessions on the 4080. With Speed, compared with our 0.1.43-based build:
Nothing I can tie to the logprobs code, which isn't used by plain chat requests. |
Thanks — three Ada cards, 116 consecutive The cross-restart logprob differences are consistent with the documented behavior of the automatic CPU prefill share, rather than seed-based randomness or an LP-readout bug. The LP value is computed from the head logits before sampling. With Your fixed- For strict numerical comparisons, use the same build and hardware and fix the adaptive execution paths. In this recipe that means I also agree that the speed comparison should not be attributed to the logprobs field. A plain chat request does not enter the LP-readout path: the server adds For reference, the same-card A/B for the field itself on this machine was:
Those are medians of three runs per cell and measure the cost of requesting logprobs, not the 0.1.43-to-0.2.7 difference. The GPU-scoring follow-up is not in the 0.2.7 release. If the cross-version decode gap is investigated further, the 0.1.43 source build and the released 0.2.7 package should also be treated as a packaging/build-flag comparison, not assumed to differ only by version. |
|
Closing the loop on this one from my side, now that 0.2.7 carries it (thanks @xiezhipeng-git, and @Niko1221 for the release). Tested on current
Your point about one rule for both routes: #4056 is rebased onto Two follow-ups build on these LP lines:
|
OpenAI の allowed_token_ids のため、GEN 行の allowed=a,b,c を読み、LP 行には選ばれた token の後に名指しした id だけを並べる(server がその中で正規化し直す)。名指しがなければ前と同じく上位 K 個(upstream Niko1221#3200)。
の serve 側) XeStrata の serve は upstream の Intel 版(4ba35fd)から logprobs=K を返していたが、upstream Niko1221#3200 の 次のことは無かったので移す。 - "allowed_token_ids" を GEN 行の allowed=a,b,c で渡し、top_logprobs をその id だけにして合計が 1 に なるよう正規化し直す(名指しされた選ばれたトークンも入れる)。語彙の外の id は 400。 - 各項目の token を UTF-8 の逐次の復号で作り、チャンクの文字列を綴るトークンだけをそのチャンクに付ける (2 つのトークンに分かれた文字は書き終えるトークンに入る)。有限でない値の行(-nan(ind) など)は項目を作らない。 - LP の行は要求ごとの LPLines に入れ、cancel の Event に結び付ける。要求が待ち行列を出た後の最後の イベントが、次の要求の行を読まないように。 - json_schema の返事は、トークンが返す JSON を綴るときだけ logprobs を付ける(extract_json を公開の名に)。 - --parallel の server では、BT の行が LP を持たないので、max_tokens 1 の要求だけを受ける(それ以外は 400)。 エンジン側の allowed= は src/program/generate.cpp で別に入れる。
|
Closing this as landed elsewhere. 0.2.7 carries the feature, and @gdevenyi's last run (main Recording why this branch is not the shape that landed. The engine half here is a dedicated logprobs kernel ( If the kernel path is worth having on its own, it belongs in a fresh PR against current The validation record continues on #4056 (logprobs on |
AI assistance: the implementation, the tests and this description were drafted with an AI coding agent. I checked every claim below against the source, the command output and the logs it came from; every measurement was run on this machine unless the text says otherwise.
Problem and result.
/v1/chat/completionson the CUDA/HIP engine acceptedlogprobs/top_logprobsand returned HTTP 200, butchoices[0].logprobs.contentwas always[]. A client that ranks candidate tokens from the model's own distribution — constrained decoding, candidate scoring, tool-choice re-ranking — silently got nothing: the request succeeds, the content is correct, only the field is missing, so the failure is invisible unless the caller asserts on the field. After this change,logprobs: truewithtop_logprobs0..20 (the legacy completions formlogprobs: Nis also accepted) returns one entry per token with its top alternatives, andallowed_token_idsreturns a distribution renormalised over exactly the ids the caller named — including the case where the only id named is the one the model chose, which is the one a decision client asks for.Related discussion. This supersedes #2254, which the 0.2.0-beta tracker reset closed. GitHub will not reopen it (
state cannot be changed. The feat/openai-logprobs branch was force-pushed or recreated.), so this PR carries the same change, rebased ontomain. #1916 proposed the same feature for CUDA/HIP and is closed. This implementation follows the protocol the SYCL port already speaks (835d30b) and the Metal port (31f2668), so one server-side reader serves all three backends.It also carries the fix from the review,
ad3cff8onleonardomurillo:logprobs-own-lines, cherry-picked asa4349ec9with its authorship intact: anLPline belongs to the request that asked for it, so a reply whose tail is held by a stop string cannot read the next request's lines.What is already built on it. This is no longer only the field itself:
#4056adds logprobs to#3332's legacy/v1/completionsand states that it stacks on this branch and that one; a decision client reads its label's log-probabilities from that endpoint, so the two halves it needs are in two open PRs.#3743(typed decisions) lists this PR as prior art for the one-token case:allowed_token_idsgives a probability per field, one prompt read per field, and requires the value to be one token. Values longer than one token and several fields per read are what [Engine] typed decisions: score a schema's token paths instead of generating them #3743 adds on top.#675asked for one-token typed decisions via logprobs.So the surface this PR adds is the one-token baseline the two follow-ups measure against, and the cases below are on the engine they will run on.
Approach.
src/program/generate.cpp:logprobs=Kandallowed=a,b,care two extra keys on the--serveGEN line (an engine that predates them skips them as unknown), and oneLP <logprob> [id:logprob ...]line is written after eachT <id>in the same flush. The value comes from the verify window's head logits before sampling, penalties and temperature, so it is the model's distribution rather than the sampler's. Off by default: a request withoutlogprobsproduces the previous output byte for byte, and the per-token cost is one integer compare.22fe5184):GEN ... allowed=a,b,cparsed the ids intoreq_allowedand then cleared that vector three lines later, next toreq_imgs.clear(), before either decode loop ran.emit_lp_linetherefore always took the top-k branch, andallowed_token_idsonly ever filtered what was already in top-k — an id outside it was silently dropped. The clear is gone. On Swift 1.5 IQ3_XXS,allowed_token_ids: [16]used to return the chosen id twice at 0.5 each and now returns one entry at logprob 0; ten named digits (0-9, ids 15-24) for the reply11used to cover 2 and then 8 of the ten positions and now cover all ten in every entry, sorted, summing to 1.serve/server.py:sampling_keysappends the two keys only when asked;generatereads each token'sLPline before yielding the token (a held line is consumed before the next read, so an engine without LP lines loses no lines, and aDONEread while looking for one still ends the request);LogprobEntriesdecodes token bytes incrementally so the entries joined spell the reply;structured_chunksre-aligns the entries whenresponse_format: json_schemarewrites the content and returns no logprobs rather than a set that does not match.a4349ec9): the engine'slp_linesdeque is tagged with the request'scancelEvent — one per request, handed unchanged from the handler throughService.runtoEngine.generate— andLogprobEntries.take()reads only lines carrying its own. A reply's tail, held by the parser or a stop string, is spelled after the request has released the queue; by then the next request may have replacedlp_lineswith its own. The tail now reads its own lines and never consumes the next request's.900a5b82): whenallowed_token_idsleft no candidate,_renormalisereturnedNone, the entry was appended all the same, andspell()failed one["token"]— an unstreamed reply came back 400 "the request is malformed", a streamed one stopped after the role chunk. The chosen token is put back when the caller named it (the line carries its logprob on the head and never repeats it among the named ids, which is exactly why naming only the chosen id used to leave nothing to renormalise over); an entry is neverNoneand never dropped, because the reply's tokens have to keep spelling its text; the entry'slogprobis the chosen token's own value, not the first named alternative's; andtop_logprobsis sorted most likely first, as the unfiltered top-k already is.1878675f): a slot'sBTlines carry noLPline, but an admission does — amax_tokens1 request is asked of the engine asBGENand read asGEN 1, the single-token path that printsTand itsLPline in one flush (generate.cpp:12090)._control()takes that line and keeps it on the request's own lines, held on itscancelEvent: the engine'slp_linesis one attribute and several slots read control lines at once, so it cannot be shared between them. A longer reply there is still a 400, for the reason in Limits. The same commit stops refusing logprobs on--replicas:ReplicaEngine.batchtotals how many requests its engines run at once and counts a replica without slots as one, so the "does this engine use batch slots" check said yes to a server that has none — every logprobs request got a 400 while its reply was read by theGENpath that does write the lines.batch_slots_on()asks each replica's own figure instead.b55e9fdc,26a2d480, all 400 before anything is generated): logprobs on a--parallel/--batchserver unlessmax_tokensis 1 — a slot printsBT <slot> <id>and writes noLPline, so past the admission's first token a reply's entries would cover a prefix only, which a client cannot tell from the model's own answer (1878675fanswers themax_tokens1 case; see below); and anallowed_token_idsid at or past the vocabulary, whichemit_lp_linewould use to index a logits row out of bounds.4b7339e1): the 1..4096 bound onmax_tokens/max_completion_tokenswas ours alone — OpenAI's request schema makesmax_tokensoptional and ties no limit to logprobs, and vLLM's defaults to 16 orNoneand itslogprobsonly check themselves. A reply is already bounded bymax_tokens, or by the rest of the context when it is not sent, so the constant was a second bound on top of one that exists, and it answered 400 to requests that would have worked — the OpenAI SDKs send nomax_tokensby default. What the constant protected is real, but it is the reply's own length: a reply that is not streamed, or that answers ajson_schema(buffered to be validated), holds every entry until it ends, while a streamed one hands each entry out with its chunk and keeps none. A client that wants that memory bounded streams. The previous commit on this branch (eca6513b) had narrowed the bound to the held cases; this one removes it.max_tokens— 5000,0,-1, or the field absent, which is what the OpenAI SDKs send — where before it took a 400 unless it streamed, and logprobs on--parallelis answered for amax_tokens1 request (the typed decision) where before it was a 400 for every request, with a longer reply there still a 400 rather than a field that comes back missing.docs/DETAILS.mdunder "Using it" (597571c8, updated byeca6513b,4b7339e1and1878675f) — the keys, that a reply is bounded bymax_tokensand not by a cap of ours, whatallowed_token_idskeeps and renormalises, that a token whose probability could not be read gets no entry rather than a made-up one, and that--parallelanswers only amax_tokens1 request.Validation.
Branch: 12 commits on
e88ee39d, head1878675f, with two merges ofupstream/main(it moved twice today):d4e8211bonto41cf05a0(992 files, +133057/−12580) and7cbff4fdonto4b9e05a2(55 files, +422/−133, the 0.2.6 release). They had to happen — GitHub cannot merge a pull request that has conflicts, and the first one did:docs/DETAILS.mdhad been restructured (−2162/+1069) around the logprobs bullet, which was put back among the server-behaviour bullets of "Using it". The code never conflicted:serve/server.pyandsrc/program/generate.cppmerged on their own both times, and main's changes to them contain nologproband touch noLP,BTorGENline format. Against4b9e05a2the diff is 5 files, +1099/−27:src/program/generate.cpp+59/−0,serve/server.py+367/−27,serve/test_logprobs.py+541/−0,serve/test_logprobs_parallel.py+117/−0,docs/DETAILS.md+15/−0.src/program/generate.cpponly gains lines: the wholeemit_lp_linepath is new here, and thereq_allowedclear that22fe5184takes out was itself introduced by the first commit, so it never shows as a deletion against the base. Nothing else insrc/moves.No conflict with today's
main(7dd5cf18, 24 October): GitHub reports this PRmergeable: true, and the independent run below merged it onto exactly that revision.Passed:
104becfd:cmake --build build-lp --target strata— Release,CMAKE_CUDA_ARCHITECTURES=89, CUDA 13.3 (V13.3.73), MSVC 14.41, Windows 11, RTX 4090 (48 GB, driver 610.88).[154/154] Linking CXX executable strata.exe, exit 0. The engine that answered every hardware run below was rebuilt from this branch after22fe5184and is what the server was started with.eca6513b, the merged4e8211band the cap's removal touch no C++: that binary is what answered the runs added for them.python -m unittest serve.test_logprobs— 32 tests, OK (104becfdhad 17; the file now covers the two refusals, the ownership case, unreadable lines, a line whose named ids are all filtered out, naming only the chosen token, a ten-way choice that only the ended reply settles, a held tail of two tokens, anymax_tokenson a reply that holds its entries, and no batch slots behind two replicas). On26a2d480the oneseca6513badded fail with a 400: a streamed reply withmax_tokensof 5000,0and none at all.test_logprobs_take_any_max_tokens(200 / 5000 / 0 / −1 / not sent, not streamed) andtest_a_json_schema_reply_takes_any_max_tokens(200 / 0 / 5000, streamed and not) replace the two that asserted the 1..4096 400.python -m unittest serve.test_logprobs_parallel— 6 tests, OK: amax_tokens1 decision through a realStrataEngineandServiceover HTTP with the fake engine ofserve/test_parallel.pybehind them (writing anLPline after eachTwhen theGEN/BGENline asks), thenallowed_token_idson that admission, four decisions at once each getting its own logprob, a request that does not ask, a longer reply answered 400, and an engine that writes noLPlines at all.python -m unittest discover -s serve -t . -p "test_*.py"— 1073 tests in 386 s, 1 failure, 1 error, 21 skipped (the merged tree carriesmain's new tests, which is why the count is no longer 909). Both are known on a cleanupstream/maincheckout in this environment and touch no logprobs code:serve.test_winjob(running()on a pid this sandbox cannot read) andserve.test_responses.test_server_close_ends_the_sampler(1 != 2, order-sensitive — it passes when its file runs alone, which it did in the 83-test run just before this one). The skips are POSIX-only (signals,/proc), unrelated to this change. The only test files that mention logprobs are the two above.--spec 4 --spec-min-p 0.5 --kv int8 --kv-resident 32768,temperature: 0,chat_template_kwargs.enable_thinking: false:stream: true, plain request → 6 entries,"".join(token)equal to the content character for character.stop: [".!"], which holds the tail — non-streamed and streamed → contentCafe costs 5., 6 entries each, the held5.flushed after the request left the queue with its own entries.allowed_token_ids: [16](the digit1, the id the model chose) → 200, one entry, one candidate at0.0— previously the 400 "the request was malformed", streamed and not.0-9, reply11→ 2 entries, 10 candidates each, most likely first,sum(exp(logprob)) = 1. Only the ended reply settles which of1and11was meant.allowed_token_idsover{0,1}, reply111→ 3 entries, each over the two named ids, joint path probability 0.999870.4b7339e1, the reply's length is no longer capped:logprobs: true, top_logprobs: 5, the reply11,max_tokensabsent /0/200/4096/5000, each of them streamed and not → 200 ten times, 2 entries each, the tokens spelling11, the non-streamed replies carrying 5 candidates each.4b7339e1,allowed_token_idsover the ten digits15-24withmax_tokens: 5000, streamed → 200, 2 entries of 10 candidates spelling11;allowed_token_ids: [16](the digit the model chose), not streamed → 200, one entry, one candidate at0.0— previously the 400 "the request was malformed".4b7339e1,json_schemawithmax_tokensabsent /0/200/5000, streamed and not → 200 in all eight, 6 or 7 entries spelling{"n":11}/{"n": 11}; before this commit the streamed ones with absent or5000were 400.4b7339e1,allowed_token_ids: [248320](the vocabulary size) → 400, still.1878675f, the same machine and engine but--parallel 2:max_tokens: 1withtop_logprobs: 5→ 200, one entry spelling the reply (A−0.062427, thenB−2.848,A−7.415);allowed_token_idsover 11 ids → 200, the named ids as the candidates,sum(exp(logprob)) = 1.0, a chosen token outside them keeping its own logprob;max_completion_tokens: 1→ 200 with its entry;stream: true→ 200, one chunk carrying the entry forOdd; four decisions at once on two slots → 200 each, 1.6 s for all four, every entry matching its reply;max_tokens5,max_tokensabsent andmax_completion_tokens: 0→ 400 namingmax_tokens1; a plain request and a 64-token reply unchanged.#3332merged with this branch ontomain7dd5cf18(both merged cleanly), CUDA build fromsetup.py's flags, RTX 5080 (sm_120), CUDA 13.4, Linux, Qwen3.8-Flash-Next IQ3_S,--spec 4 --mtp,--parallel 1.python -m unittest serve.test_logprobs28/28 OK (the file had 28 tests at26a2d480);logprobs: true, top_logprobs: 5, max_tokens: 1returnedchoices[0].logprobs.content(B−0.018839, thenB−4.51,A−5.98) where the 0.1.48 engine returned no logprobs for the same request;allowed_token_idsagreed with the unrestricted top-K and the engine's ids matched the Hugging FaceQwen/Qwen3.8-Flash-Nexttokenizer. This is the only run on hardware that covers--mtp: the runs above used--spec 4 --spec-min-p 0.5without it.Failed: none observed.
Not run:
src/program/generate.cppis shared by the CUDA and HIP builds; only the CUDA configuration was built here (no AMD GPU or toolchain on this machine).CONTRIBUTING.mdasks for every backend a touched file is compiled into — if someone with an AMD card can run the HIP build, that closes the last gap in this section.install_logprobsinsycl/serve/server_intel.py), and no Intel or Apple toolchain is present here.BTslot phase of--parallel/--batch: a reply longer than one token there has no entries, and is a 400; see Limits. What is measured is themax_tokens1 decision that works and the 400 for anything longer.Limits.
--parallel/--batch, only amax_tokens1 request carries logprobs; a longer reply is a 400, not a reply whose entries stop halfway. A slot printsBT <slot> <id>and its decode loop never callsemit_lp_line, and the line carries no slot id (LP <logprob> [id:logprob ...]), so with N slots interleaved on one stdout anLPline could not be attributed to a request even if one were written: that needs a slot-tagged line in the engine plus the matching read in the server. The admission is the one token that does come with a line — the engine readsBGEN <slot> 1asGEN 1, the single-token path — which is exactly the typed-decision case this feature exists for. A request that stays solo on such a server does run theGENpath too, but a second request arriving moves the running one into a slot mid-reply (generate_batched), so its entries would stop with no way for the client to tell. This is the same line gt50 drew on OpenAI logprobs on /v1/chat/completions for the CUDA/HIP engine #1916: "LP lines for the BT slot phase can be a later step."response_format: json_schemathe returned content may not be spelled by the tokens; in that case no logprobs are returned, by design.LPline that does not parse (nan,-nan(ind), a cut line) or carries a non-finite value gives no entry for that token rather than failing the request:logprobs.contentcan be shorter than the reply, and a client must pair entries by token, not by index.json_schema, holds every entry (its token, bytes and up to 20 alternatives) until it ends, so a long one holds a lot of them in the server's memory. Nothing here caps that: the bound is the reply's ownmax_tokens, or the rest of the context when it is not sent, as on OpenAI and vLLM, neither of which ties a length limit to logprobs. Streaming is the way to keep that memory off the server's side.#4056adds logprobs to/v1/completionson top of this and#3332;#4059reports run-to-run variation on ~100-token prompts, which is a sampling-determinism matter and is not changed here.