Skip to content

Exact speculative rollback for hybrid caches (model-agnostic cache API)#1486

Open
lBroth wants to merge 4 commits into
ml-explore:mainfrom
lBroth:rollback-standalone
Open

Exact speculative rollback for hybrid caches (model-agnostic cache API)#1486
lBroth wants to merge 4 commits into
ml-explore:mainfrom
lBroth:rollback-standalone

Conversation

@lBroth

@lBroth lBroth commented Jul 6, 2026

Copy link
Copy Markdown

Up front: I'm not a professional ML engineer and this was done with heavy AI
assistance — I've validated everything I could on-device (the matrix below is
all real measured runs), and I'd welcome expert scrutiny on anything I've missed.

Split of #1456 as @pierre427 proposed: this is the rollback cache-API alone
(the shared dependency), landed standalone. The qwen3.5 MTP-head loading follows
as a separate PR on top. Co-developed with @pierre427, who contributed the
RotatingKVCache support and reviews.

What & why

Speculative decoding needs to rewind the KV/state cache when a draft is
rejected. mlx-lm's existing --draft-model path does this by trim()ing the
cache — but that silently corrupts any cache that isn't trimmable:

This PR adds an exact rollback protocol so speculative decoding is
lossless on these caches.

The API (model-agnostic — proven on two cache families)

A small protocol on the cache objects: start/stop_speculation(),
record_rollback(num_tokens, fn, snapshot), is_trimmable(), strict trim().
Two independent implementations demonstrate the abstraction isn't cache-specific:

  1. GDN / ArraysCache (lBroth): rollback by exact state replay — snapshot
    the pre-block conv_state + recurrent S, capture (via hooks) the exact
    tensors the GatedDeltaNet kernel consumed, and reconstruct S_m by re-running
    gated_delta_update on the committed prefix. Exact by construction (no
    approximation), because the recurrence can't be truncated.
  2. RotatingKVCache (pierre427): rollback by copying the (≤ window) buffers
    at snapshot time, since trim() can't shrink them.

Same protocol, opposite mechanisms — that's the evidence the API is right.

Correctness (the first thing you'll ask)

  • Rollback is exact, not approximate. Contract tests assert token-for-token
    identity between speculative and non-speculative greedy decode.
  • Losslessness bar: every emitted token equals the target's batched-greedy
    argmax
    at that position (the accept rule always commits the target's own
    token) — not bit-identity with sequential generate_step, which differs by
    inherent FP noise regardless of speculation.
  • Tests: tests/test_speculative_rollback.py (8) + tests/test_rotating_rollback.py
    (4) — 12 total, all green. Cover cache-reuse across turns, early-close, and
    offset-exactness after each step.

No overhead when unused, no regressions

  • The path is inert unless speculation is active: start_speculation() is only
    called on the speculative branch; normal generation is byte-identical.
  • Cross-hardware validation (fresh envs, python 3.13, mlx 0.31.2): M5 Pro 48GB /
    M2 Pro 16GB / M3 16GB / CPU(ops) — unit tests 15/15, cache regression 22/22
    (failure set ⊆ main's), real-weights e2e identical (80B Coder-Next + 0.8B
    draft, token-for-token vs non-speculative). No regressions found anywhere.

Scope

Rollback cache-API + speculative wiring + GDN & RotatingKVCache support + tests.
No MTP head (separate follow-up). Diff is +639/-6 across 7 files.

lBroth and others added 4 commits July 6, 2026 20:15
Speculative decoding currently raises for models with recurrent
ArraysCache layers ("requires a trimmable prompt cache", ml-explore#980): the
recurrent state summarizes the whole past and cannot be trimmed.

This adds an exact, cheap rollback instead of forbidding the trim:

- ArraysCache: start/stop_speculation + record_rollback(T, fn, snapshot);
  while speculating the cache is trimmable, and trim(n) restores the
  entries via the recorded fn (or the snapshot for a full rollback).
- GDN layers (qwen3_5, qwen3_next): while speculating, record a rollback
  closure over the exact per-token tensors the kernel consumed. Replaying
  the recurrence from the pre-forward state over the first m stashed
  inputs reproduces the post-m state bit-for-bit (causality: rows of a
  chunk do not depend on later rows), and the conv state is a slice of
  conv_input. No deepcopy, no re-forward, no extra weight streaming —
  the rollback costs one tiny kernel launch per GDN layer.
- speculative_generate_step: allow models declaring
  supports_speculative_rollback, and start/stop recording around the
  decode loop (recording is off during prefill so no prompt-sized
  tensors are retained).

Tests: tail-independence bit-exactness (same m-prefix, different tails
=> identical caches and logits after rollback), greedy speculative ==
greedy vanilla end-to-end with a mismatched draft (rollback every
round), and the unsupported-model error path.
Addresses findings from an adversarial audit of the first version:

- ArraysCache keeps a small stack of rollback records (bounded by a
  64-token window) instead of a single record, so multi-forward
  windows can be rewound — enabling HYBRID DRAFT models: a Qwen3.5
  draft records one entry per T=1 draft step and its cache is now
  rewound exactly (the setup reported in ml-explore#1446 uses a hybrid draft).
  Partial rollbacks re-record the still-valid prefix.
- trim() raises when asked to trim beyond the recorded window instead
  of silently clamping (a clamp would desync ArraysCache layers from
  KVCache layers, which trim the full amount).
- speculative_generate_step: per-cache capability check (trimmable OR
  rollback-capable with model support) instead of a model-wide bypass;
  start/stop speculation on the draft cache too; n is initialized to
  num_draft at the top of each round so a mid-round exception or
  generator close makes the finally-block rewind a no-op rather than
  trimming with stale values — previously the rollback RuntimeError
  could mask the original exception.

Tests: hybrid draft == vanilla greedy (rollback across stacked T=1
records), mid-round draft exception surfaces unmasked, trim beyond the
window raises. 8/8 in this file; full suite failure set identical to
main (pre-existing failures only). Real-weights e2e: dense draft
1.37x identical; hybrid draft pair 1.33x identical.
Extends the exact-rollback protocol to the OTHER non-trimmable cache:
sliding-window RotatingKVCache. Once it wraps (offset >= max_size) the
tokens a verify step pushes out of the window are gone, so stock trim
can't rewind. Same protocol as ArraysCache: start/stop_speculation +
record_rollback; update_and_fetch stashes the pre-forward window + new K/V
while speculating, trim restores + re-appends the accepted prefix. No
model-file hook (KV is not recurrent). is_trimmable() is True while
speculating; gpt_oss declares supports_speculative_rollback=True, so
speculative_generate_step accepts it unchanged -> --draft-model spec now
works on gpt-oss-family sliding-window models, not just GDN hybrids.

4 tests incl. bit-exact tail-independence (two verify chunks sharing an
m-prefix with different tails leave identical windows after rollback-to-m)
+ stacked draft records. 37/37 across prompt_cache + all rollback + MTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pierre Lamy <pierre@userid.org>
@pierre427

Copy link
Copy Markdown

Thanks for cutting this out into its own PR, @lBroth — clean split. Confirming from my side: the RotatingKVCache half is validated on an M5 Max (gpt-oss-family sliding-window layers, bit-exact tail-independence check against a fresh sequential recompute, mod the bf16 ULP ties we both saw). Reads good to me and matches what I had on the staged branch. 👍

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