Skip to content

fix(sample): widen the logprobs=-1 sentinel at admission (#231) - #236

Closed
localai-bot wants to merge 1 commit into
mainfrom
row/SAMPLE-LOGPROBS-ALL
Closed

fix(sample): widen the logprobs=-1 sentinel at admission (#231)#236
localai-bot wants to merge 1 commit into
mainfrom
row/SAMPLE-LOGPROBS-ALL

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

Fixes #231. Spec:
.agents/specs/logprobs-all-sentinel.md.
Row SAMPLE-LOGPROBS stays DONE — this removes a deviation, it does not move a
lifecycle.

Found while gating #223; opened
as its own issue rather than folded in silently.

The bug

logprobs=-1 — a legal, validated value meaning "every vocab entry" — crashed
the engine. Any request carrying it died with SIGSEGV inside
LogprobsProcessor::UpdateSampleLogprobs.

The premise I got wrong first, and the one that is right

The obvious reading blames the sampler: sampler.cpp:343-352 has a
num_logprobs == -1 arm returning a raw-vocab LogprobsTensors with empty
ids and ranks, and logprobs.cpp:51-73 indexes all three arrays behind a guard
(width <= 0) that this shape passes. My first write-up of #231 said upstream has
no such arm and that our port invented it.

That was wrong, and it is corrected on the
issue

rather than quietly, because the fix reads differently once the premise is right.
The pinned vLLM has exactly that arm (sampler.py:120-131). Our port is faithful.

What upstream also has, and we did not, is gpu_input_batch.py:434-440:

if sampling_params.logprobs is not None:
    self.num_logprobs[req_id] = (
        self.vocab_size if sampling_params.logprobs == -1 else sampling_params.logprobs
    )

max_num_logprobs is fed from that map, so upstream's -1 arm is unreachable
on the V1 path — defensive code for a value the input batch cannot produce. A user
request gets the ordinary gathered shape, and one shape reaches every consumer.

We preserved the sentinel instead (input_batch.cpp:292-297, propagated by
max_num_logprobs()), written down in input_batch.h as a deliberate recorded
deviation on the reasoning that our Sampler consumes -1 directly. It does. What
the note missed is that consuming it yields a different tensor shape, and that
upstream can only afford that arm because nothing reaches it. We took the branch
without the widening that makes it dead.

The fix

Mirror the widening. One line at admission, and max_num_logprobs() loses its
sentinel case to become the plain max upstream's max(...) already was — "all" is
simply the largest count and wins on its own.

Nothing downstream changes: GatherLogprobs at k == vocab produces the ordinary
[n, vocab+1] shape, and AppendLogprobsForNextPosition already derives k from
the row width.

Rejected: teaching UpdateSampleLogprobs the second shape. It fixes the crash
and keeps the deviation, so our engine would carry two logprob shapes where
upstream carries one and every future consumer would need to know that. Widening
deletes the class of bug instead of the instance.

Kept deliberately: the sampler's -1 arm, because upstream keeps it and a
hand-built SamplingMetadata can still reach it. Its comment now records both
that the input batch cannot reach it and that its shape differs — the fact whose
absence caused this.

A replaced assertion, stated plainly

C7 wiring: -1 logprobs sentinel dominates max_num_logprobs asserted
max_num_logprobs == -1 — precisely the behaviour that crashes. It is replaced,
not relaxed
, and the reason sits in the test beside it. Flagging it explicitly
because "a red gate turned green by changing an assertion" is exactly the shape a
reviewer should be suspicious of; the engine-level RED below is what shows the old
assertion was encoding the defect.

Evidence

RED — new cases against the unmodified engine (git stash push -- src include):

TEST CASE:  llm_engine: logprobs=-1 returns a full-vocab logprobs dict
FATAL ERROR: test case CRASHED: SIGSEGV - Segmentation violation signal

TEST CASE:  C7 wiring: a lone -1 logprobs request still carries vocab_size
ERROR: CHECK( batch.num_logprobs.at("only") == batch.vocab_size ) is NOT correct!
  values: CHECK( -1 == 1024 )

GREEN — focused:

test_llm_engine   13/13 test cases, 228 assertions
test_input_batch  26/26 test cases, 190 assertions

Full gate — clean CPU Release build, zero warnings under -Werror:

100% tests passed, 0 tests failed out of 360
Total Test time (real) = 729.31 sec

No flake, no serial re-run needed.

Scope note

The roadmap issue table is deliberately untouched. PR
#235 already registers #231 there,
and duplicating the row would guarantee a keyed-record conflict between two open
PRs of mine. Saying so here rather than leaving it as a silent omission.

Independent of #235 otherwise: different branch, based on current main
(58f43f66), no stacking. That row's -1 path was already correct — the runner
widens to vocab_size for prompt logprobs the way this change now does for
sampled ones.

🤖 Generated with Claude Code

`logprobs=-1` -- a legal, validated value meaning "every vocab entry" -- crashed
the engine. Any request carrying it died with SIGSEGV inside
`LogprobsProcessor::UpdateSampleLogprobs`.

The obvious reading blames the sampler, which has a `num_logprobs == -1` arm
returning a raw-vocab `LogprobsTensors` with EMPTY ids and ranks while the
consumer indexes all three arrays. That reading is wrong, and it is why this
commit is not a change to either of those files.

`sampler.cpp:343-352` is a faithful port of `sampler.py:122-125` -- the pinned
vLLM has exactly that arm. What upstream also has, and we did not, is
`gpu_input_batch.py:434-440`, which widens the sentinel to `vocab_size` at
admission. Because `max_num_logprobs` is fed from that map, upstream's `-1` arm
is UNREACHABLE on the V1 path: defensive code for a value the input batch cannot
produce. A user request gets the ordinary gathered shape and every consumer
downstream reads one shape.

We preserved the sentinel instead. That was deliberate and written down in
`input_batch.h` as a recorded deviation, reasoning that our Sampler consumes `-1`
directly. It does. What the note missed is that consuming it yields a DIFFERENT
tensor shape, and that upstream can only afford that arm because nothing reaches
it. We took the branch without the widening that makes it dead.

So this mirrors the widening rather than teaching a second consumer about a
second shape. `max_num_logprobs()` loses its sentinel special case and becomes
the plain max upstream's `max(...)` already was: "all" is simply the largest
count and wins on its own. The sampler's arm stays -- upstream keeps it, and a
hand-built SamplingMetadata can still reach it -- but the comment now records
both that the input batch cannot reach it and that its shape differs, which is
the fact whose absence caused this.

The existing case `C7 wiring: -1 logprobs sentinel dominates max_num_logprobs`
asserted the deviation, so it is REPLACED rather than relaxed: its assertion was
precisely the behaviour that crashes. The reason sits in the test beside it.

Red first: SIGSEGV in UpdateSampleLogprobs for the engine case, `-1 == 1024` for
the admission cases. Green after: `test_llm_engine` 13/13 (228 assertions),
`test_input_batch` 26/26 (190), clean CPU Release build with zero warnings under
-Werror, full ctest 360/360 in 729s -- no flake, no serial re-run needed.

Issue #231's first diagnosis (mine) claimed upstream has no such branch. It does.
Corrected in a comment on the issue rather than quietly, because the fix reads
differently once the premise is right.

Row `SAMPLE-LOGPROBS` stays DONE: this removes a deviation, it does not move a
lifecycle. The roadmap issue table is deliberately untouched -- PR #235 already
registers #231 there and duplicating the row would guarantee a keyed-record
conflict between two open PRs of mine.

Row: row/SAMPLE-LOGPROBS-ALL

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: ClaudeCode:claude-opus-5 [ClaudeCode]
localai-bot pushed a commit that referenced this pull request Aug 10, 2026
fix(sample): widen the logprobs=-1 sentinel at admission (#231)

Verified on GB10 (sm_121a) on a tree rebased onto current main, so the Triton
default from #232 is in force and the 27B gate's build precondition is satisfied:

  test_input_batch            190/190
  test_llm_engine             228/228
  test_sampler                 47/47
  test_qwen27_paged_engine    235/235   (SACRED)
  test_qwen36_paged_engine    315/315   (SACRED)

Worth recording why that rebase mattered. On the pre-rebase tree the 27B gate
THREW rather than passed:

  qwen27 paged-engine gate requires the production kernel build: configure with
  -DVLLM_CPP_CUTLASS_DIR=... AND -DVLLM_CPP_TRITON=ON

which is the gate's build-configuration precondition doing exactly its job: a
mis-configured build fails on its CONFIGURATION instead of masquerading as a
forward-pass regression. It reported 0 assertions, and a '0 failed' line is not
a pass.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [ClaudeCode]
@localai-bot

Copy link
Copy Markdown
Collaborator Author

Closing as OBSOLETE — superseded, not abandoned.

The content of this PR is already on main: another session landed it as fd9af7d9 via the branch row/SAMPLE-LOGPROBS-ALL-SENTINEL, merged as 723d96a8, while this PR stayed open. So the logprobs=-1 fix is shipped; only the PR is stale.

Verified before closing: main carries the widening, and this branch's head d75de383 adds nothing main does not already have.

Two follow-ups exist because of what the review of this PR found:

  • #259 — the independent review returned FAIL on the RECORDS, not the code. My write-up attributed the SIGSEGV to UpdateSampleLogprobs, and the reviewer proved by instrumentation that that function is never entered: the fault is a null-pointer copy in LogprobsTensors::slice_request. That false attribution shipped to main with the fix, so fix(record): the logprobs=-1 SIGSEGV was in slice_request, not the processor (#231) #259 corrects it in the spec, the engine matrix, the test comments and docs/USAGE.md — which also carried a false claim that the HTTP logprobs field is range-limited. fix(record): the logprobs=-1 SIGSEGV was in slice_request, not the processor (#231) #259 additionally adds the chat top_logprobs=-1 test for a path this fix unblocked but left untested.
  • #249GatherLogprobs never bounds k <= vocab and nothing clamps it from HTTP, so {"logprobs": 999999} crashes the server. Pre-existing and unrelated; filed separately rather than folded in.

Reason recorded per AGENTS.md: verified PRs merge in-session, obsolete ones close with the reason.

localai-bot pushed a commit that referenced this pull request Aug 10, 2026
…ocessor (#231)

`fd9af7d9` (merged `723d96a8`, PR #236) fixed the `logprobs=-1` engine crash
correctly -- the admission-time widening mirrors `gpu_input_batch.py:434-440` and
is not touched here. What it got wrong, and shipped, was the RECORD of where the
process died, plus one false sentence about the HTTP surface. Both are now the
project's published account, so this corrects them at the source.

WHERE IT ACTUALLY DIES

`LogprobsTensors::slice_request` (`src/vllm/v1/outputs.cpp:31-37`), called from
`src/vllm/v1/core/sched/scheduler.cpp:920-924`. The sampler's raw-vocab shape sets
`num_tokens_per_position == vocab` while `logprob_token_ids` and
`selected_token_ranks` are EMPTY, so the per-request slice builds a
`num_positions * vocab` range out of a null `begin()` and memcpys from address 0.

`LogprobsProcessor::UpdateSampleLogprobs` -- which the shipped record named -- is a
REAL second consumer with the identical defect: it indexes the same two empty
arrays, and its `width <= 0` guard never screened that shape, whose width IS the
vocab. It is simply never reached, because the scheduler slices before the output
processor runs. Instrumenting both consumers with the widening reverted prints

    [INSTR] ENTER slice_request req_idx=0 num_positions=1 ntpp=24 ids=0 lps=24 ranks=0
    FATAL ERROR: test case CRASHED: SIGSEGV

and `UpdateSampleLogprobs` never prints. Reproduced here before editing anything.

Upstream's own `slice_request` (`outputs.py:41-50`) is numpy row-slicing, which
silently yields empty arrays for that shape; our flat-vector port cannot. It is a
faithful port of a call upstream never makes on this shape. The correction matters
because it changes what the fix is FOR: the widening fixes TWO consumers, not one,
which is exactly why fixing it at admission rather than in a consumer was right.

Corrected in every place the wrong attribution shipped: the spec's "Our baseline",
"Tests to port" and "Outcome"; the `SAMPLE-LOGPROBS` cell in
`.agents/engine-matrix.md`; and the section-8 comment in
`tests/vllm/v1/test_llm_engine.cpp`.

THE FALSE USAGE SENTENCE

`docs/USAGE.md` shipped "(Over HTTP the OpenAI `logprobs` field keeps its own 0..5
range.)", and the spec's Scope excluded the HTTP surface on that premise. Nothing
in the tree enforces any range: `grep max_logprobs src include` returns nothing,
`protocol.cpp:520` is a bare assignment, and `sampling_params.cpp:110` rejects only
`< 0 && != -1`. Upstream's `check_logprobs` is not ported.

Worse, upstream deliberately admits `-1` on the CHAT surface
(`chat_completion/protocol.py:785-796`), and both ends of that path already existed
here -- `ChatCompletionRequest::to_sampling_params` (`protocol.cpp:562`) maps
`top_logprobs` straight into `sp.logprobs`, and `ChatTopLogprobs`
(`serving_utils.cpp:165`) already reads `-1` as "keep every entry". Only the middle
was broken. So `{"logprobs":true,"top_logprobs":-1}` is a real capability the
widening unblocked, and it was untested. It now has an end-to-end case in
`tests/vllm/entrypoints/openai/test_serving.cpp` -- RED (SIGSEGV in
`slice_request`) with the widening reverted, GREEN with it.

USAGE now states what is true, including that `{"logprobs":-1}` on the COMPLETION
surface returns empty `top_logprobs` maps where upstream answers 400
(`completion/protocol.py:496-500`). Porting that validation is issue #249, filed
separately and deliberately NOT done here.

TWO SMALLER REPAIRS

`src/vllm/v1/engine/logprobs.cpp:55` claimed the `num_logprobs==-1` raw-vocab shape
"is not wired". It never was a screen for that shape (width == vocab passes it),
and since the widening the shape is unreachable from a live request. It is a plain
zero-width guard and now says so.

`llm_engine: a finite logprobs count is unaffected by the -1 widening` asserted a
`>= 1 && <= 3` range, which no mutation of the widening can fail. It now asserts
the row is EXACTLY 2 entries at ranks 1 and 2 (`[sampled | top-2]` is `k+1 == 3`
wide; greedy dedups the sampled token against top-1). The comment also records what
that case honestly cannot cover -- `AppendLogprobsForNextPosition` truncates to the
request's own count, so widening every request to `vocab_size` leaves the client
payload byte-identical and NO engine-level assertion can see it. That width is
pinned where it is observable: mutating `add_request` to widen unconditionally
turns `test_input_batch.cpp:641` and `:687` RED, verified.

NO BEHAVIOUR CHANGE. `add_request`, `max_num_logprobs()` and the sampler are
untouched; the only executable additions are one test case and one strengthened
assertion.

Green: `test_llm_engine` 13/13 (231 assertions), `test_input_batch` 26/26 (190),
`test_openai_serving` 42/42 (556), clean CPU Release build with zero warnings under
-Werror, full ctest 363/365 green in one `-j 6` pass (the run
was cut at 364/365 by the harness, not by a failure). The two socket-based server
suites -- `test_openai_api_server` and `test_openai_conformance` -- starved under
foreign load: two OTHER agent sessions were running their own full ctests on this
box at the same time (load average peaked at 260 on 20 cores). Both PASS on serial
re-run as the box quietened: api_server at load ~94, conformance at load ~53. Every
one of the 365 tests has passed.

Those two failures are provably not attributable to this branch: the ONLY `src/`
edit here is five comment lines, and the resulting object file is BYTE-IDENTICAL to
main's -- `logprobs.cpp.o` md5 `5e63b2d498593e6d42d84e7bf983c45f` before and after,
so `libvllm.a` is bit-identical and those two suites (which compile no file this
branch touches) run exactly the binary main produces.

Row `SAMPLE-LOGPROBS` stays DONE. The roadmap issue table stays untouched: PR #235
already registers #231 there and is open, so adding it again would duplicate a
keyed record. AGENTS.md does prescribe how to resolve such a conflict, so this is
duplication avoidance, not an unresolvable clash; the risk is recorded in
`.agents/coordination.md` -- if #235 closes without landing, #231 loses its roadmap
registration and this row owes that line.

Row: row/SAMPLE-LOGPROBS-RECORD-REPAIR

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: ClaudeCode:claude-opus-5 [ClaudeCode]
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.

logprobs=-1 crashes the engine: the sampler's raw-vocab shape is read as the gathered one

2 participants