Skip to content

server: shift the speculative batch indices on the sub-batch retry - #182

Open
danielhanchen wants to merge 4 commits into
masterfrom
fix/spec-index-sub-batch
Open

server: shift the speculative batch indices on the sub-batch retry#182
danielhanchen wants to merge 4 commits into
masterfrom
fix/spec-index-sub-batch

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 4, 2026

Copy link
Copy Markdown
Member

--spec-type draft-mtp with --parallel N --kv-unified throws
speculative batch index %d is not inside the current sub-batch and kills the request,
whenever the KV cache is full enough that llama-server has to fall back to a smaller batch.

The bug

When a decode fails to fit, the server retries the same batch in smaller pieces. On that
path it already does two things for slot.i_batch: skips a slot whose index lies outside
the current view, and shifts the index by the view's offset, with the comment "shifted
according to the current sub-batch". It does neither for slot.spec_i_batch.

That asymmetry is the whole defect. The speculative indices stay absolute while the view
they are read against becomes relative, so the bounds check fires on indices that were
correct when they were recorded.

The check is also the only thing standing between this and a wrong answer: with it removed
the same indices would sample from another slot's logits.

What this changes

Three commits, smallest first:

  1. Keep a speculative block inside one sub-batch, and shift its indices. A slot's
    spec_i_batch entries are contiguous, so a block that would straddle the end of a view
    cuts the view short instead, and the surviving indices are shifted by the offset exactly
    as i_batch is.
  2. Never halve n_batch below one speculative block. The retry ladder halves the batch
    each time; without a floor it can end up smaller than a single block, which no amount of
    cutting can fit.
  3. Do not let that floor trap the ladder. A block starting at the view's own offset and
    reaching past its end cannot be cut, so the floor must not stop the ladder from
    descending, or the retry never terminates.

The error message that remains is deliberately different: a block that genuinely cannot be
placed now says speculative block [%d, %d] straddles the current sub-batch [%d, %d), so
the two situations are distinguishable in a log.

Measurement

Two independent runs, because they answer different questions.

The branch, against its own base. Both arms built from this tree, differing only by these
three commits, run as a bare llama-server with no caller in front of it. Four concurrent
chats, -c 16384 --parallel 4 --kv-unified --spec-type draft-mtp --spec-draft-n-max 2,
3000-token prompts, driven until the pool overflows:

arm is not inside the current sub-batch straddles the current sub-batch chats lost
master 8 0 4 of 4
this branch 0 4 4 of 4

The same change on the base Unsloth Studio actually ships (b10715-mix, 92cedc867),
interleaved control and treatment over two rounds with the order reversed, each arm confirmed
from /proc/PID/maps of the live server: 18 occurrences of the original error on control,
0 on treatment.

What this does and does not fix

It removes the bounds violation this issue is about. It is NOT a complete fix, and the table
above says so: two of the four chats still fail on this branch, with a different error.

The remaining case is a speculative block that begins exactly at the view's own offset and
reaches past its end, [4, 5] against [4, 5). It cannot be cut, because cutting the view
before it leaves an empty view, and the retry ladder has already driven n_batch below one
block by the time it appears. The code reports it rather than sampling from logits that were
never computed, which is the safe half of the choice.

Sampling only the part of the block inside the view would look like an obvious fix and is
not one: the drafted tokens beyond the view are already in the batch at positions this
sub-batch does not decode, so discarding them mid-flight risks leaving stale cells, and the
failure mode of getting that wrong is a wrong answer rather than an error. I have not
convinced myself of a correct version, so I have not written one.

What the change buys, then, is narrower than "the bug is gone" and still worth having:

  • the failures that remain are a distinct, self-describing message, so the two situations
    can be told apart in a log rather than presenting as one bounds error;
  • on Studio's own base, under its own load, the count went 18 to 0, because the cases it
    handles are the common ones;
  • for a caller that keeps the cache below the line, as Unsloth Studio's preemption now
    does, none of these are reached at all. The value there is that the residual overshoot
    degrades to Context size has been exceeded, which a caller can see coming.

The natural next step is to make the unfittable case terminate that one slot through the
ordinary context-exceeded path instead of throwing, which composes with
#183. That is a separate change and is not in this PR.

Notes

server-context.cpp only. Builds clean, and the measurements above are from that build. No
effect without --spec-type: every new branch is inside a block guarded on a non-empty
spec_i_batch.

There is no automated CI on this PR, and that is expected rather than a gap: the only
pull_request-triggered workflow in this fork is the pr-set.json lint, which is gated on
paths this change does not touch. Prebuild checks arrive when a PR is pinned into
scripts/unsloth/pr-set.json, which also ships it into the nightly builds, so I have left
that decision to a maintainer.

…indices

Fixes ggml-org#24840. When a decode fails for want of KV cache, update_slots
halves n_batch and retries the same offset with a smaller view. The
non-speculative path accounts for that (`tok_idx = slot.i_batch - off`),
but the speculative path passed slot.spec_i_batch to
common_sampler_sample_and_accept_n unshifted, so with a non-zero offset
it addressed the wrong logits. Rather than sample wrongly, post_decode
threw, which aborts every slot in flight:

    speculative batch index 4 is not inside the current sub-batch [0, 4)

Three parts, because shifting alone is not enough.

A slot's spec_i_batch entries are contiguous and their logits must all
come from ONE decode, so a view that cuts through a block leaves half of
them in a decode that has already happened. The view loop now ends a
view just before a block it would otherwise bisect, which costs one
extra decode call and keeps every block whole.

post_decode then treats a block that is not in this view as ordinary,
because after the change it belongs entirely to another view and will be
sampled when that view is decoded. It still throws for a block that is
split anyway, which needs n_batch below n_draft + 1 and so is only
reachable at the bottom of the retry ladder.

The speculative sampling loop skips a slot whose block is not in this
view; without that the shift below it produces negative indices.

Finally the indices are shifted into view-local space before sampling,
which is the part the issue describes.

Reached in practice whenever several long conversations share one cache
under --parallel N --kv-unified: the KV-full retry is common there, and
every occurrence killed every chat on the server rather than one.
Second half of ggml-org#24840, and the half that matters in production.

Keeping a block inside one sub-batch only works while a sub-batch can
hold one. The KV-full retry halves n_batch without a floor, so it walks
2048, 1024, ... 4, 2, 1, and a view of 1 or 2 cannot serve a 3-index
block however it is positioned. post_decode is then left with nothing to
do but abort every slot on the server.

Of 78 occurrences recorded in production logs at --spec-type draft-mtp
--spec-draft-n-max 2, thirty were exactly that: a block starting at the
view's own offset and reaching past its end, against views of 1 and 2.
The other 48 are blocks that straddle or sit beyond a wider view, which
the previous commit handles. Together they cover all 78.

Halving past one block buys no memory worth having, since the difference
is a couple of cells, and costs every conversation in flight.

Note this is measured against the shape of the failures, not against a
run: the CPU reproduction in the workspace uses ngram-simple, which
ignores --spec-draft-n-max and drafts blocks of 49, so it exercises a
regime no view-fitting fix can serve. Validating this properly needs an
MTP draft model on a GPU.
Bug in the previous commit, mine. Flooring n_batch at one speculative
block clamped it there permanently, and the ladder reaching n_batch == 1
is precisely what terminates this retry: decode() reports "Context size
has been exceeded" only for n_batch == 1 && ret == 1. A cache that
genuinely cannot fit anything would therefore retry forever instead of
reporting, which is worse than the crash the floor was added to prevent.

Pause AT the floor once, then carry on halving. A block still gets one
whole-view attempt, and the terminating case is still reachable.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T20:36:59.797734Z 945cdfc New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

… the view

The previous commit stopped the retry ladder from cutting through a speculative
block, and reports the case it cannot fix by throwing. That throw reaches
abort_all_slots, which ends every conversation on the server because one of them
was drafting into a full cache: the same shape as the KV-full send_error path.

spec_i_batch[0] is the index of the token that was actually sampled last round
rather than a draft, and in this case it is inside the current view, so the slot
can still sample its next token the ordinary way. Give up the prediction and
keep the conversation: the cost is this one step's speedup.

Only reachable when the ladder has driven n_batch below one block, which needs a
cache full enough that the view is narrower than the draft.
@danielhanchen

Copy link
Copy Markdown
Member Author

Added 945cdfc9, which removes the last way this path can still end every conversation on the server.

What was left

The commit before it stops the retry ladder from cutting through a speculative block, and reports the case it cannot fix by throwing. That throw reaches abort_all_slots, so one slot drafting into a full cache still ends every conversation. Measured on this branch against its own base: control 8 occurrences of the index error and 0 of 4 chats completing, treatment 0 index errors and 4 of its own throws, still 0 of 4 completing.

What it does now

spec_i_batch[0] is the index of the token that was actually sampled last round rather than a draft, and in the straddling case it is inside the current view. So the slot gives up the prediction and samples its next token the ordinary way. The cost is that one step's speedup. It is only reachable once the ladder has driven n_batch below one block, which needs a cache full enough that the view is narrower than the draft.

Measurement

Four concurrent chats, 3000-token prompts, -c 16384 --parallel 4 --kv-unified --spec-type draft-mtp --spec-draft-n-max 2, on this fork's own base:

build index errors own throws drafts dropped chats completing
master 8 - - 0 of 4
this branch before 945cdfc9 0 4 - 0 of 4
this branch 0 0 - 0 of 4
this branch + #183 0 0 2 1 of 4

The solid result is the third row and the drafts dropped column: the fallback fires and nothing aborts. The 1 of 4 is suggestive rather than measured, since this harness varies widely run to run (single-chat token counts range from 1540 to 13924 across otherwise identical runs).

What remains in every row is genuine cache exhaustion. Four chats each holding a 3000-token prompt plus a long answer do not fit in 16384 cells, and a bare llama-server has no admission control to say so, which is what #183 narrows from "every slot" to "one slot". With a scheduler in front that reserves the drafts, the same four chats complete 4 of 4 with zero context-exceeded errors.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 945cdfc915

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3774 to +3776
slot.spec_draft.clear();
slot.spec_i_batch.clear();
slot.i_batch = first;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Roll back draft state before one-token sampling

When KV pressure reduces the view below the speculative block size, clearing these vectors does not actually discard the draft: handle_last_sampled_token() has already appended every draft token to slot.prompt.tokens, and the successful partial decode may already have written some of them to both target and draft caches; the outer loop will also decode the remaining batch entries afterward. The ordinary sample taken from first is therefore subsequently positioned after unverified draft tokens, silently making them part of the conversation context and producing incorrect continuations. This fallback must remove the drafts from the prompt and cache/batch state (or fail safely) before sampling normally.

Useful? React with 👍 / 👎.

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.

1 participant