Skip to content

server: Support logprobs with speculative decoding - #27196

Merged
ngxson merged 4 commits into
ggml-org:masterfrom
pnb:spec_logprobs
Oct 20, 2026
Merged

ngxson merged 4 commits into
ggml-org:masterfrom
pnb:spec_logprobs

Conversation

@pnb

@pnb pnb commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Overview

I use logprobs with /completion. It is very useful. Recently, it seemed to stop working, but eventually I realized that was because I finally started using speculative decoding.

There was a feature request about it (#24271) from @drazdra, auto-closed due to inactivity. I also found this comment from @ServeurpersoCom that made it seem probably feasible to add. With the help of Qwen 3.8-27B and llama.cpp itself, it was.

Example:

curl http://localhost:6666/completion -H "Content-Type: application/json" \
  -d '{"prompt": "The capital of France is", "n_predict": 10, "logprobs": 3, "seed": 1}'

On master, this will produce correct output for the first token only:

      "logprob": -4.361362457275391,
      "top_logprobs": [
        {
          "id": 11751,
          "token": " Paris",
          "bytes": [
            32,
            80,
            97,
            114,
            105,
            115
          ],
          "logprob": -0.2336617261171341
        },
        {
          "id": 264,
          "token": " a",
          "bytes": [
            32,
            97
          ],
          "logprob": -3.882844924926758
        },
        {
          "id": 3750,
          "token": " known",
          "bytes": [
            32,
            107,
            110,
            111,
            119,
            110
          ],
          "logprob": -4.30119514465332
        }
      ]

After that first token, logprobs are missing. Like:

    {
      "id": 13,
      "token": ".",
      "bytes": [
        46
      ],
      "logprob": 0.0,
      "top_logprobs": []
    },

Whereas on this PR, they are correctly returned for all tokens. From my tests, the logprobs match the same values given by master branch without speculative decoding.

Additional information

Fixes #24271. Also there was a TODO in the source code about this issue.

Note that it still does not work for post_sampling_probs because that is a whole more complicated beast.

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: YES, pi + Qwen 3.8-27B + llama.cpp. Changes are very minimal and I've reviewed (and slightly edited) them myself.

@pnb
pnb requested a review from a team as a code owner August 16, 2026 18:01
@ngxson

ngxson commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

/bot review

@ggml-gh-bot

ggml-gh-bot Bot commented Aug 16, 2026 •

Copy link
Copy Markdown
Automated code review

Reviewed tools/server/server-context.cpp (the only changed file) plus the surrounding code paths it depends on: populate_token_probs / get_token_probabilities, common_sampler_sample_and_accept_n, llama_get_logits_ith, the sub-batch decode loop, post_decode, and the response serialization in server-task.cpp.

Verdict

The change is small, correct, and reuses existing infrastructure rather than inventing new machinery. I traced the indexing, the off-by-one risk, the move semantics, and the partial-accept/replay path; all check out. No blocking findings. A couple of non-blocking suggestions below.

Correctness notes (verified, no action needed)

  • Indexing is consistent with the existing spec sampler. spec_i_batch[i] is the same index handed to common_sampler_sample inside common_sampler_sample_and_accept_n (which already works on master), and get_token_probabilities -> llama_get_logits_ith resolves that index through the same output_ids mapping. The post_decode sub-batch check at server-context.cpp:3698 already guarantees all spec_i_batch entries lie in the current sub-batch, which for a spec slot resolves to off == 0, so absolute and sub-batch-relative indexing coincide. This matches the non-spec path's tok_idx = slot.i_batch - off.
  • No off-by-one. ids[i] (= accepted[i]) is the token sampled from logits row spec_i_batch[i], so populating result.tok = ids[i] from spec_i_batch[i] is the right distribution - including the diverging token on partial acceptance and the final fully-accepted token.
  • Bounds are safe. spec_i_batch.size() == n_draft + 1 >= ids.size(), and the loop is bounded by ids.size(), so spec_i_batch[i] is always in range.
  • The std::move(slot.spec_i_batch) at the top is equivalent to the old explicit .clear(): the moved-from vector is empty, satisfying the GGML_ASSERT(spec_i_batch.empty()) at server-context.cpp:479 on the next round. common_sampler_sample_and_accept_n receives the local copy, so the move-before-clone ordering is fine.
  • First-token behavior is unchanged: the first token after prompt still goes through the non-spec path (spec_draft empty), which already populated logprobs - consistent with the PR description's claim that only tokens 2..N were missing on master.
  • The partial-acceptance + checkpoint-restore (use_ckpt_tgt) path returns before the ids loop, so no probs are emitted that round; this matches master behavior (replay re-emits them later). No regression.

Will slow the review (non-blocking)

(point 1) Missing regression test. tools/server/tests/unit/test_speculative.py exists and test_completion.py already has test_n_probs / test_n_probs_stream. A test asserting top_logprobs is populated for every token under spec decoding (ideally comparing logprob values against a no-draft run, mirroring test_with_and_without_draft) would lock in this fix and follow the existing patterns. Without it, a future refactor of the spec path could silently regress this again.

(point 2) post_sampling_probs=true + spec is silently misleading. With the new guard, that combination skips populate_token_probs, leaving result.prob = 1.0 and result.probs empty, which serializes as prob: 1.0 / top_probs: [] (server-task.cpp:284). That reads as genuine 100% confidence rather than "unsupported". The PR description documents the limitation, but server users won't see the description. Consider a one-time SRV_WRN when a slot runs spec decoding with post_sampling_probs set, or a note in tools/server/README.md. This is no worse than master, just an unimproved case.

Nits

(point 3) The two added comments are concise and explain why, which is good. The one at server-context.cpp:3909 could optionally say "logprobs use pre-sampling logits only here" to make the false argument self-documenting, but it is fine as is.

(point 4) Pre-existing TODO at server-context.cpp:3784 ("set it here instead of doing inside populate_token_probs") is untouched by this PR and not its responsibility; leaving it is correct.

This review was generated automatically by pi coding agent using zai-org/GLM-5.2. It may contain mistakes. Maintainers make the final call.

@pnb

pnb commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

The only thing the bot suggests that seems potentially interesting to me is a regression test. Although it is of course a tradeoff for increasing CI time, since this would probably not be a very fast test to run, so I am slightly inclined not to include it. But happy to if others think so.

@sigjhl

sigjhl commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

I ran into the same problem while using gemma 4 e4b with MTP as a transcription model.

I did an independent fix with Fable 5, then discovered your PR. Here is the regression test the bot asked for, cherry-pickable, on this branch: https://github.com/sigjhl/llama.cpp/tree/pr27196-regression-test. The results:

# master (f1357e4), test added:
FAILED unit/test_speculative.py::test_draft_token_probs - AssertionError: assert 0 == 4
# this PR (c22562c71) + test:
7 passed in 14.30s

My independent fix has post_sampling_probs via a sampler callback and happy to open it as a follow-up after this PR merges. It will probably settle the bot's point 2.

pnb and others added 2 commits August 31, 2026 18:50
Asserts accepted draft tokens report the target model's logprobs,
comparing a draft-enabled run against a no-draft baseline (ggml-org#24271).
Fails on master (top_logprobs empty, logprob 0.0), passes with the
fix from ggml-org#27196.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KARBWpPcijcBXzJEvttuwk
@pnb

pnb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@sigjhl The test seems reasonably straightforward so I cherry-picked that.

I also rebased onto master since a merge conflict had come up in the meantime. @ngxson is there anything to be done for this PR or is it possible to consider merging?

@ggerganov ggerganov added the merge ready A maintainer can use this label to indicate that they consider the changes final and ready to merge. label Oct 20, 2026
@ngxson
ngxson merged commit ead9f32 into ggml-org:master Oct 20, 2026
18 checks passed
adderek pushed a commit to adderek/llama.cpp that referenced this pull request Oct 21, 2026
297 upstream commits. Conflicts and how they were resolved:

- ggml ops: upstream added GGML_OP_TOP_K_MASK next to the fork's
  GGML_OP_TURBO_WHT; both are kept, upstream's first, GGML_OP_COUNT 103.
- MoE down projection: upstream replaced the MISTRAL4 F32 src1 precision
  hint (its own, ggml-org#29029) with a per-row amax rescale (ggml-org#30861) for MISTRAL4
  and MELLUM. It now sits in build_moe_ffn_exps, so every tiered call
  (hot, cold, each arena window) rescales its own rows; rows skipped by a
  partial mul_mat_id are zero, so their scale is 1.
- MoE post_exp_norm (new upstream): applied in build_moe_ffn after the
  hot/cold merge and before the router weights; the two halves fill
  disjoint expert slots, so the norm sees whole rows.
- build_lora_mm_id: upstream's Hadamard input transform (prism) after the
  fork's partial/slots assert. Expert tensors split by LLAMA_MOE_HOT are
  not in the Hadamard map, so prism and tiering do not stack on one layer.
- kv-cache attention rotation: the fork's default (off unless
  LLAMA_ATTN_ROT_DISABLE=0) is kept; upstream's llama_model_set_attn_rot
  can still turn it off per model.
- model loader: hugetlb per-tensor populate kept before upstream's
  multi-buffer lookup of the mmap buffer.
- llama-model: MoE arena setup and upstream's load_tensors_hadamard both
  run at the end of load_tensors.
- qwen4exp: turbo Q pre-rotation kept; the selection mask is now `sel`.
- server: upstream added draft-token logprobs (ggml-org#27196) read back from the
  batch logits, without post_sampling_probs. The fork's on_sample path,
  which records them during verification and covers post_sampling_probs,
  is kept; upstream's test_draft_token_probs passes with it. Upstream's
  local spec_i_batch is taken.
- mmq: fork's IQ1_M MMQ gains upstream's prec_src1 parameter.
- fattn-tile: the fork's D=640 case and HIP guard kept, upstream's
  default-case style.
- argsort.cu: upstream's CCCL guard; the fork's hipcub arm was dead since
  HIP does not define GGML_CUDA_USE_CUB.
- tests/CMakeLists.txt: fork tests kept, upstream's LINK and consolidated
  test-llama-context taken.

Tested on gfx1100: ctest 78/80 (the two failures are environmental: the
ggml-vocabs clone has git-lfs pointers, and system python has no jinja2).
All fork tests pass (turbo-kv x8, moe-tier, fattn-turbo4, fattn-long-kv,
mmvq-src1-cache, fusions, mul-mat-f16-range, top-k-graph-capture,
op-offload-devices). test-llama-archs 390/390. Server
test_speculative.py 10/10, completion/probs pytests 14/14. Needle at 20.5k
tokens, Qwen3.8-27B IQ4_NL, turbo4 K/V: correct, prefill 925 tok/s vs 909
before the merge (single run).
adderek pushed a commit to adderek/llama.cpp that referenced this pull request Oct 21, 2026
…feature list

The header now explains why the fork's logprobs for draft-accepted tokens
differ from upstream's ggml-org#27196 (post_sampling_probs, alignment check,
replay, per-token cost, streamed tools, row text, per-request type),
each point checked against upstream/master 7775ffb, and lists what the
fork has that upstream does not.

FORK.md: MoE tiering has been in master since 2026-10-01 (5d2e5db),
not only on the moe-tier branch; section 4 points to the logprobs work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge ready A maintainer can use this label to indicate that they consider the changes final and ready to merge. server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: logprobs working for gemma MTP

5 participants