Skip to content

[Other] serve: logprobs on /v1/completions (stacks #3332) - #4056

Open
gdevenyi wants to merge 6 commits into
Niko1221:mainfrom
gdevenyi:anyjev-completions-logprobs
Open

gdevenyi wants to merge 6 commits into
Niko1221:mainfrom
gdevenyi:anyjev-completions-logprobs

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Oct 25, 2026 •

Copy link
Copy Markdown

Problem and result

Decision clients such as AnyJev render their own chat prompt and read the label tokens' log-probabilities from vllm serve's legacy /v1/completions. Strata 0.2.7 has logprobs on /v1/chat/completions (#3200, landed as 9ea04496), and #3332 adds /v1/completions but refuses logprobs. So a client can have its own prompt or the logprobs, but not both.

This PR puts the 0.2.7 logprobs on #3332's route. /v1/completions takes logprobs: N (or true with top_logprobs), allowed_token_ids and return_tokens_as_token_ids, and the prompt can be a list of token ids. It returns vLLM's legacy logprobs object.

 POST /v1/completions                         (#3332)
   prompt: str
+  prompt: [token ids]                         # vLLM form; one prompt per request
-  logprobs → 400 "logprobs are not supported"
+  logprobs: N | true + top_logprobs           # GEN … logprobs=K, as chat
+  allowed_token_ids: [ids]                    # full-vocabulary values, not renormalised
+  return_tokens_as_token_ids: true            # keys "token_id:<id>"
 → choices[0].logprobs
+    {tokens, token_logprobs, top_logprobs: [{tok: lp}], text_offset}

Related discussion

Approach

  • The same rules as chat, in one place. check_logprobs(req, svc, max_req) holds 0.2.7's two checks, now shared by both routes:
  • CompletionLogprobs. It reads only the LP lines tagged with this request's cancel Event, the ownership rule 0.2.7's LogprobEntries uses. That includes the lines a batch admission keeps on the Event (_strata_lp_lines). It lists every generated token, the stop token too, so a one-token read whose top token is <|im_end|> still has its row.
  • No renormalising. allowed_token_ids reports the named ids' full-vocabulary values. For those ids these are the numbers vLLM's default raw_logprobs gives. A decision client checks how much of the mass its labels hold, and renormalised values always sum to 1. Chat keeps 0.2.7's renormalised shape. Both are documented in docs/DETAILS.md.
  • One limit of this route: logprobs with stream: true are refused (400) to keep the change small.
  • Compatibility. No engine change. Requests that don't ask for logprobs are unchanged.

Validation

d032be99, engine 0.1.53 built from this branch, with Qwen3.8-Flash-Next IQ3_S and the args strata launch writes (--expert-cache auto --prefill auto --spec 4 --spec-min-p 0.5 --mtp … --kv int8 --ple-io ram --kv-resident 32768, no --vision). Two machines:

epic monster
GPU RTX 5080 16 GB, sm_120 GTX 1080 Ti 11 GB, sm_61
Build CUDA 13.4 CUDA 12.9, -DSTRATA_EXPERIMENTAL_SM60=ON, g++-14
OS Linux Linux (CachyOS)

Passed:

  • python -m unittest discover -s serve -p 'test_*.py' (as CI runs it): 1,234 tests, 2 failures.
    • GpuChoice.test_hip_ordinal fails on main here too.
    • LiveRate.test_the_live_number_is_a_rate failed once while the machine was under memory pressure; it passes 3/3 on this branch and on main when re-run.
  • serve.test_logprobs: 41 OK. That is main's tests plus 11 for completions:
    • the legacy shape, the chat form of the field, echo offsets;
    • un-renormalised allowed_token_ids, token-id keys, token-id prompts;
    • logprobs: false, no length cap, the refusals;
    • two overlapping requests that each read only their own LP lines.
  • Hardware, 12 checks on both machines:
    • chat logprobs, streamed and not, with no max_tokens and with a json_schema;
    • completions shape, allowed_token_ids raw values equal to the top-K read, token-id prompt, max_tokens: 0, logprobs: false;
    • the stream refusal.
    • With --adapt-swaps 0 and STRATA_PREFILL_CPU_SHARE=0.5: 12/12 on both. Chat, text-prompt completions and token-id completions give the same values to 6 decimals (epic B −0.027356, monster −0.035613).
    • With the launch defaults: 11/12 on both. The one miss is that chat-vs-completions value comparison, because reads drift between requests there ([Engine] identical temperature-0 requests give different logprobs on ~100-token prompts, even with the #410 switches #4059), not because the routes differ.
  • --parallel 2 on epic: completions max_tokens: 1 with allowed_token_ids → 200 with both ids. max_tokens: 2 → 400 naming --parallel. Two overlapping one-token reads (fruit/vegetable) each returned their own answer (B, A) through batch admission.
  • AnyJev's StrataBackend (New: StrataBackend (draft, needs Strata logprobs on /v1/completions) nokia-applied-research/AnyJev#20), on epic with --adapt-swaps 0 and STRATA_PREFILL_CPU_SHARE=0.5: engine smoke test 7/7. Its parity check (named-id read vs the server's own top-20) gives max_abs_diff 0.000000 in two runs, and the Decider at L0 decides 5 states × 3 questions in 14 s.

Failed:

  • None beyond the pre-existing test_hip_ordinal.

Not run:

  • HIP, SYCL and Metal builds. No C++ changes here, but this route ran on CUDA only.
  • --parallel on monster (11 GB).
  • The full suite on Windows.

Limits

Merge danger

Door: two-way. It is server-only Python behind an opt-in request field.

Blast radius: small. Callers of /v1/completions that send logprobs used to get a 400 and now get logprobs. Chat logprobs go through the moved, otherwise unchanged checks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cix1XJhNhNNuKXi26KvXnU

@gdevenyi

Copy link
Copy Markdown
Author

Hardware rerun at e0eb04e6 (with #3200's eca6513) on two machines:

  • epic: RTX 5080, sm_120, CUDA 13.4.
  • monster: GTX 1080 Ti, sm_61, CUDA 12.9 with -DSTRATA_EXPERIMENTAL_SM60=ON.

Both ran Qwen3.8-Flash-Next IQ3_S with --spec 4 --mtp … --kv int8, --parallel 1. The checks gave the same results on both machines:

@ibbobud

ibbobud commented Oct 27, 2026 •

Copy link
Copy Markdown

@gdevenyi Really interested in this PR and would love to see this functionality make it upstream!

I'm running Strata with Qwen3.8-Flash-Next on dual Titan Xp GPUs (Pascal, sm_61), and I'm happy to offer some time for testing, benchmarking, or troubleshooting on older NVIDIA hardware.

I also noticed #4059 and would be happy to help investigate whether the logprob consistency issue appears on Pascal.

If there are any tests or validation that would be useful, I'm glad to help however I can. Appreciate the work going into this!

@gdevenyi

Copy link
Copy Markdown
Author

Make sure to express your interest in the interrelated PRs and issues. So far the real blocker is reproducibility.

Ideally I'd like dynamic response to respond properly to certain calls rather than disabling some performance caching features as suggested in the issue.

Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…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>
gdevenyi and others added 4 commits October 28, 2026 22:33
…ken-id prompts

/v1/completions now takes `logprobs: N` (or `logprobs: true` with
`top_logprobs`), `allowed_token_ids` and `return_tokens_as_token_ids`, and
returns vLLM's legacy `logprobs` object: tokens, token_logprobs,
top_logprobs and text_offset. Every generated token is listed, including
the stop token. `allowed_token_ids` reports the named ids'
full-vocabulary values without renormalising them. The prompt can also be
a list of token ids.

The logprobs checks from /v1/chat/completions (a bounded max_tokens, no
batch slots, named ids inside the vocabulary) are moved into
check_logprobs() and shared by both routes. Logprobs are refused with
stream: true on this route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cix1XJhNhNNuKXi26KvXnU
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cix1XJhNhNNuKXi26KvXnU
…two-request ownership test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cix1XJhNhNNuKXi26KvXnU
@gdevenyi
gdevenyi force-pushed the anyjev-completions-logprobs branch from e0eb04e to d032be9 Compare October 29, 2026 02:50
@gdevenyi gdevenyi changed the title [Other] serve: logprobs on /v1/completions (stacks #3200 + #3332) [Other] serve: logprobs on /v1/completions (stacks #3332) Oct 29, 2026
@gdevenyi

Copy link
Copy Markdown
Author

Rebased onto main after 0.2.7 (#3200 landed as 9ea04496), now d032be99. It stacks on #3332 only. The description is updated.

@xiezhipeng-git, following your point: the length bound is gone. /v1/completions now goes through the same check_logprobs() as chat, with 0.2.7's rules:

  • no cap of its own;
  • under --parallel, logprobs only for max_tokens: 1;
  • a named id outside the vocabulary is a 400.

The one deliberate difference is that this route refuses logprobs with stream: true. It also reads a batch admission's _strata_lp_lines, as LogprobEntries.take does.

Tested on epic (RTX 5080) and monster (GTX 1080 Ti, CUDA 12.9 SM60), engine 0.1.53 built from this branch. Under --parallel 2, two overlapping one-token completions each got their own row. The #4059 matrix on both cards is posted there. With --adapt-swaps 0 and STRATA_PREFILL_CPU_SHARE=0.5, chat and both completions prompt forms give identical values, and AnyJev's parity check is exact.

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.

3 participants