Skip to content

feat(server): OpenAI-compatible logprobs for chat and legacy completions - #224

Open
Artemowka22 wants to merge 2 commits into
FlashML-org:mainfrom
Artemowka22:feat/openai-logprobs
Open

feat(server): OpenAI-compatible logprobs for chat and legacy completions#224
Artemowka22 wants to merge 2 commits into
FlashML-org:mainfrom
Artemowka22:feat/openai-logprobs

Conversation

@Artemowka22

Copy link
Copy Markdown

Summary

Implements logprobs for sampled tokens across the whole pipeline; today /v1/completions rejects the field ("logprobs is not supported") and /v1/chat/completions silently swallows it (extra="allow").

  • chat: logprobs: true + top_logprobs: 0..20 → each choice carries logprobs.content[] entries {token, logprob, bytes, top_logprobs[]}, streaming and non-streaming.
  • completions (legacy): logprobs: 0..5{tokens, token_logprobs, top_logprobs, text_offset}. echo together with logprobs stays rejected (prompt logprobs need prefill logits and are out of scope here).

Design

  • Reported values are raw model logprobs: log_softmax over the pre-temperature logits, so temperature/top-k/top-p do not change what is reported (matches vLLM's default) and greedy eval harnesses get the true model distribution.
  • Zero cost when off: the sampler computes nothing unless some request in the batch asked; per-step cost when on is one log_softmax + topk + a small D2H copy that rides the existing copy_done_event.
  • Engine → scheduler → detokenizer → API plumbing via optional fields (DetokenizeMsg.chosen_logprob/top_ids/top_logprobs, UserReply.logprobs), all defaulting to None — wire-compatible with older peers.
  • Token strings for top-k alternatives come from the detokenizer worker (tokenizer.decode([id])); the bytes field carries UTF-8 so clients can reassemble partial-UTF-8 pieces, same trade-off OpenAI documents.
  • Stop-string trimming can drop the visible text of final tokens; logprob entries still cover every sampled token.

Why

Any evaluation gate worth trusting (teacher-forced agreement/KL against a reference checkpoint, perplexity tracking of quantized variants) needs token logprobs from the OpenAI endpoint; with the radix prefix cache, per-position 1-token continuation calls make teacher-forced scoring practical without echo support.

Test plan

  • CPU-only unit tests for the sampler math (tests/engine/test_sample_logprobs.py) and the entry builder (tests/tokenizer/test_logprobs_entry.py)
  • FakeState API tests for both endpoints, streaming and not, plus the validation matrix (tests/server/test_logprobs_api.py) — no GPU, no weights, no network
  • full server/engine/tokenizer suites pass

Adds the engine half of OpenAI logprobs support. SamplingParams gains
logprobs/top_logprobs; the sampler computes log_softmax over the PRE-temperature
logits (raw model distribution, so temperature/top-k/top-p do not change reported
values), gathers the chosen token and batch-max top-k, and ships CPU copies
covered by the existing copy_done_event. The scheduler attaches per-request
values (cut to each request's own top_logprobs) to DetokenizeMsg; the detokenizer
builds a neutral entry (token text via single-id decode + UTF-8 bytes, so clients
can reassemble partial-UTF-8 pieces) onto UserReply.logprobs.

Zero cost when off: no row asked -> no mask tensor, no log_softmax, ForwardOutput
carries None. Message fields default to None, so old and new peers interoperate.
Stop-string trimming can hide final visible text; entries still cover every
sampled token.
API half of logprobs support. Chat: logprobs + top_logprobs (0..20) yield
choice.logprobs.content entries {token, logprob, bytes, top_logprobs[]},
streaming and non-streaming. Completions: the legacy integer field (0..5) yields
{tokens, token_logprobs, top_logprobs, text_offset} with absolute offsets across
the stream; echo+logprobs stays rejected (prompt logprobs need prefill logits
and are out of scope here).

The protocol-neutral event layer carries entries on ContentDelta (a list -- parser
buffering can release several tokens' text in one delta); entries always
accumulate on GenResult for the non-streaming path, and reasoning/tool-call
buffering carries pending entries onto the next content delta. Formatting lives
in server/logprobs.py; wire compatibility follows the OpenAI shapes.
@HaileyStorm

Copy link
Copy Markdown

Fresh compatibility check from current main (58f4b9ec0e166205c4dfd0c6ec184ea83b5957e6): git merge-tree --write-tree origin/main origin/pr224 completed without conflicts. CPU-only focused tests on PR head ea7ae6d6d718ecd2c648474145aab8cf72f5148a passed: tests/engine/test_sample_logprobs.py, tests/server/test_logprobs_api.py, and tests/tokenizer/test_logprobs_entry.py — 12 passed in 3.63s. I was about to build the same surface for Flash-Next/ECS, found this PR during duplicate checking, and am avoiding a competing implementation. The raw pre-temperature semantics and no-work-when-disabled boundary are exactly the useful general primitive.

@HaileyStorm

Copy link
Copy Markdown

RTX 5090 / torch 2.11 cu130 microbenchmark on Qwen3.8 vocab size 248,320: the PR implementation shape (log_softmax(float logits) + chosen gather + top-20) measured 97.7 us/call at BS1, 109.1 us BS2, 115.8 us BS4 (30 warmups, 300 timed iterations, CUDA events). A split topk(logits) + logsumexp variant was not better at BS1/2 (112.6/113.6 us for top-20; 112.0 us at BS4). So the current straightforward implementation is a reasonable fast default; no speculative kernel rewrite recommended without end-to-end evidence.

@HaileyStorm

Copy link
Copy Markdown

One correctness issue before merge: streaming semantic parsing currently misattributes hidden-token logprobs to later visible content. In _generate_events_impl, every ack.logprobs is appended to pending_logprobs, but _content_delta() drains the entire pending list only when visible content is emitted. test_reasoning_logprob_is_carried_to_next_content_delta explicitly expects the logprob for thought inside <think>...</think> to be attached to the later visible answer delta. That breaks token/content alignment, can expose hidden reasoning token strings, and differs from non-streaming (which retains every sampled-token entry) and from the end-of-stream path (which drops undrained entries). I recommend fail-closing chat logprobs when semantic reasoning/tool parsing can hide/reclassify tokens, or adding token-aware routing so each public logprob entry is emitted only with its corresponding public content token. Please do not carry hidden entries onto the next visible delta. The raw legacy-completions path does not have this semantic-layer ambiguity.

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.

2 participants