Skip to content

speculative: give the draft context the target's recurrent-state slots - #174

Closed
bri-prism wants to merge 1 commit into
prismfrom
fix/spec-draft-n-rs-seq
Closed

bri-prism wants to merge 1 commit into
prismfrom
fix/spec-draft-n-rs-seq

Conversation

@bri-prism

Copy link
Copy Markdown

What

Two lines of behaviour, in two files. The draft context now reserves the same per-sequence recurrent-state slots as the target, and DRAFT_SIMPLE is recognised as a type that needs them.

Why

On a hybrid (recurrent + attention) target, every speculative round was falling back to a whole-KV checkpoint save and restore instead of a cheap suffix drop. Two defects had to hold together:

  1. common/common.hneed_n_rs_seq() tested DRAFT_MTP, DRAFT_EAGLE3, DRAFT_DFLASH and DRAFT_DSPARK, but not DRAFT_SIMPLE. A plain drafter plus target therefore got n_rs_seq = 0, and common_context_can_seq_rm() classified the context as SEQ_RM_TYPE_FULL, which is the "cannot remove part of a sequence" case that selects the checkpoint path in speculative-simple.cpp. The arch was never the blocker: LLM_ARCH_QWEN35 is already in llm_arch_supports_rs_rollback().

  2. common/speculative.cpp — the draft context hardcoded cparams.n_rs_seq = 0 regardless of type, with a comment wondering whether it should. Its llama_memory_seq_rm() then silently failed and it retained stale positions. This half affects every drafter type, not only DRAFT_SIMPLE.

What is deliberately not here

Nothing in examples/speculative-simple/speculative-simple.cpp. The partial-acceptance path there already removes rejected draft tokens once the checkpoint branch is skipped:

n_past += ids.size() - 1;                                   // accepted prefix
...
llama_memory_seq_rm(llama_get_memory(ctx_tgt), seq_id, n_past, -1);
llama_memory_seq_rm(llama_get_memory(ctx_dft), seq_id, n_past, -1);

unconditional, no use_ckpt_tgt guard, always reached when use_ckpt_tgt is false. An earlier draft of this fix added a third hunk there; it was removed after an ablation showed it duplicated the above and changed nothing (identical acceptance, 33.190%).

Because those models already had use_ckpt_tgt == false and always took that path, there is no behaviour change for pure-attention targets.

Evidence

Measured on an Intel Arc B390 (Vulkan) against a matched 2B/27B PTQ1_0 pair: k=2 went 0.482x to 1.085x, with the checkpoint save/restore previously accounting for 74-77% of each round on the 27B. The k-sweep optimum at k=1 reached 1.137x/1.356x against a 4.43 t/s AR baseline, output byte-identical to a k=1 reference over the common prefix.

That measurement is not mine and I have not reproduced it. It was made by a colleague on the B390; I verified the two defects in-tree at 6ac5eb087 and confirmed the third claimed defect did not exist.

What still needs doing before merge

  • This has not been built or run by me. The machine I am on is busy with an unrelated Metal sweep and I did not want to contend with it for a compile. CI covers the build; it does not cover behaviour.
  • One arch, one backend, one drafter pair. All of the evidence above is qwen35-hybrid on Vulkan with a single pair. A Metal A/B on the same target class is the obvious next check and I can run it once this machine is free.
  • The n_rs_seq allocation is now non-zero for DRAFT_SIMPLE, which costs recurrent-state memory that these configurations did not previously reserve. On a small drafter that should be negligible, but it is a real allocation change and worth a look from someone who knows the memory budget.

How to tell if you are affected

If a speculative run logs

speculative decoding will use checkpoints (context does not support partial sequence removal)

at startup, it is taking the fallback, and any throughput number from it is measuring this bug rather than the method.

Two related defects made every speculative round on a hybrid (recurrent +
attention) target fall back to a whole-KV checkpoint save and restore.

need_n_rs_seq() tested MTP, EAGLE3, DFLASH and DSPARK but not DRAFT_SIMPLE, so a
plain drafter plus target got n_rs_seq = 0 and common_context_can_seq_rm()
classified the context as SEQ_RM_TYPE_FULL. The arch was never the blocker:
LLM_ARCH_QWEN35 is already in llm_arch_supports_rs_rollback().

The draft context then hardcoded cparams.n_rs_seq = 0 regardless of type, so its
llama_memory_seq_rm() silently failed and it retained stale positions. That half
affects every drafter type, not only DRAFT_SIMPLE.

Nothing is needed in speculative-simple.cpp: the partial-acceptance path there
already drops the rejected draft tokens unconditionally once the checkpoint
branch is skipped, via llama_memory_seq_rm(ctx, seq_id, n_past, -1) on both
contexts.
@bri-prism

Copy link
Copy Markdown
Author

Duplicate of #173, closing in favour of it.

We opened these three minutes apart against the same base, working the same two defects. The code is the same change: the common/common.h hunks are byte-identical, and both set cparams.n_rs_seq = params.speculative.need_n_rs_seq() in place of the hardcoded 0. Only the comment differs, and #173's is better, since it names the M-RoPE monotonic-position check as the symptom a stale draft position actually produces.

#173 also carries the original B390 measurements and the ablation history first-hand, so it is the one that should carry the change.

My fault for not checking for an open PR before pushing. Nothing here is lost: everything in this description that is worth keeping is already in #173.

@bri-prism bri-prism closed this Sep 14, 2026
@bri-prism
bri-prism deleted the fix/spec-draft-n-rs-seq branch September 14, 2026 17:47
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