Skip to content

Commit bd3a1f9

Browse files
CUDA-parity rollback (Option 2): all-KVCache + native trim (keep accepted, drop rejected)
Eliminates the v3 carry re-forward. Root cause: RotatingKVCache not trimmable once wrapped (is_trimmable -> offset<max_size), so v3 rolls the block back + re-forwards carried accepted tokens. Fix: prefill all-KVCache layout (sliding on full KVCache too -- byte-exact, window mask applies regardless of capacity) -> trim_prompt_cache is a sound O(1) slice on every layer. - restored_prefill_cache: +cache_factory; fused_specdecode.make_full_kv_prompt_cache; fused_specdecode_generate_mlx_trim (forward L, keep accepted, trim L-k, no carry); adapter.prefill +full_kv; harness --cuda-trim; manifest k3-fused-allmlx-code-trim. Linux: compiles; +1 UT; 4 pre-existing b876 failures unchanged. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 7c0b92b commit bd3a1f9

5 files changed

Lines changed: 204 additions & 5 deletions

File tree

inference_engine/backends/mlx/cross_model_dlm_verifier.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ def restored_prefill_cache(
376376
restored_v_per_layer: Dict[int, Any],
377377
evicted_positions: Sequence[int],
378378
prefill_chunk_size: int = 0,
379+
cache_factory: Optional[Callable[[Any], Any]] = None,
379380
):
380381
"""Prefill ONCE with restoration, capturing the restored K/V into a
381382
persistent mlx_lm prompt cache; return ``(cache, last_logits)``.
@@ -397,7 +398,15 @@ def restored_prefill_cache(
397398
text_model = resolve_mlx_text_model(mlx_model)
398399
T = len(list(input_ids))
399400
evicted = set(int(p) for p in evicted_positions if 0 <= int(p) < T)
400-
cache = make_prompt_cache(mlx_model)
401+
# cache_factory lets the caller swap the model's native hybrid cache for an
402+
# all-`KVCache` layout (full store for sliding layers too) so that the
403+
# spec-decode accept/reject rollback can use mlx_lm's native, SOUND
404+
# `trim_prompt_cache` (keep accepted K/V, drop only rejected) instead of the
405+
# full re-forward carry — `RotatingKVCache` is not trimmable once wrapped.
406+
# Sliding attention stays byte-exact: the window mask is applied regardless
407+
# of cache capacity. (Costs O(T) sliding KV during decode; fine for the
408+
# short-context code/agent workloads this targets.)
409+
cache = (cache_factory or make_prompt_cache)(mlx_model)
401410

402411
def _slice_restored(a, start: int, end: int):
403412
if a is None:

inference_engine/backends/mlx/fused_specdecode.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def __init__(
157157
self._last_aux_mx: Optional[List[Any]] = None
158158
self._capture_aux = False
159159
self._block_snapshot: Optional[List[Dict[str, Any]]] = None
160+
self._full_kv = False
160161

161162
def reset(self) -> None:
162163
self._cache = None
@@ -174,16 +175,22 @@ def prefill(
174175
restored_v_per_layer: Dict[int, Any],
175176
evicted_positions: Sequence[int],
176177
prefill_chunk_size: int = 0,
178+
full_kv: bool = False,
177179
) -> None:
178180
if not prompt_ids:
179181
raise ValueError("prompt_ids must be non-empty")
180182
self.reset()
183+
# full_kv=True → all-`KVCache` layout so accept/reject rollback can use
184+
# SOUND native trim (keep accepted, drop rejected) with no re-forward.
185+
self._full_kv = bool(full_kv)
186+
factory = make_full_kv_prompt_cache if full_kv else None
181187
self._cache, self.next_token_logits = restored_prefill_cache(
182188
self.mlx_model, list(prompt_ids),
183189
restored_k_per_layer=restored_k_per_layer,
184190
restored_v_per_layer=restored_v_per_layer,
185191
evicted_positions=evicted_positions,
186192
prefill_chunk_size=prefill_chunk_size,
193+
cache_factory=factory,
187194
)
188195
self._past_len = len(prompt_ids)
189196

@@ -336,6 +343,129 @@ def lm_head_fn(h: Any) -> Any:
336343
# Single-sync all-MLX fused loop (levers ① ② ③ of the Step-2 throughput plan;
337344
# docs/mlx-port-lessons.md "Step-2 rescue status").
338345
# --------------------------------------------------------------------------- #
346+
def make_full_kv_prompt_cache(mlx_model: Any) -> List[Any]:
347+
"""Build a prompt cache that uses a full append-only ``KVCache`` for EVERY
348+
layer (including the sliding-attention ones, which the model's native
349+
``make_cache`` would give a ``RotatingKVCache``).
350+
351+
Why: ``RotatingKVCache`` is not trimmable once the ring has wrapped
352+
(``is_trimmable`` → ``offset < max_size``), so spec-decode accept/reject
353+
rollback cannot keep the accepted K/V via a cheap trim — it must re-forward
354+
(the v3 carry penalty). With an all-``KVCache`` layout, ``trim_prompt_cache``
355+
is a sound O(1) slice on every layer, so the loop keeps accepted K/V and
356+
drops only the rejected tail (CUDA `DynamicCache` parity). Sliding attention
357+
remains byte-exact because the per-layer window mask is applied regardless
358+
of cache capacity; the only cost is O(T) sliding KV during decode.
359+
"""
360+
from mlx_lm.models.cache import make_prompt_cache, KVCache # type: ignore
361+
362+
n = len(make_prompt_cache(mlx_model))
363+
return [KVCache() for _ in range(n)]
364+
365+
366+
def fused_specdecode_generate_mlx_trim(
367+
adapter: "MLXRestoredIncrementalVerifier",
368+
drafter: Any,
369+
*,
370+
aux_prompt: Sequence[Any],
371+
embed_fn: Callable[[Any], Any],
372+
lm_head_fn: Callable[[Any], Any],
373+
gen_tokens: int,
374+
block_size: int,
375+
eos_ids: Sequence[int] = (),
376+
) -> Dict[str, Any]:
377+
"""CUDA-parity fused spec decode: KEEP accepted K/V, TRIM only the rejected
378+
tail (no rollback, no carry re-forward). Requires the adapter to be
379+
prefilled with ``full_kv=True`` (all-``KVCache`` layout) so the native
380+
``trim_prompt_cache`` is sound. Levers ①②③ retained (lazy draft+verify
381+
single graph, in-graph cumprod acceptance, carried correction).
382+
383+
Per block: forward ``[bonus + drafts]`` (L tokens) → cache = base+L; accept
384+
the leading match count ``k`` (bonus always accepts); ``trim_prompt_cache``
385+
drops the L−k rejected tokens; advance ``_past_len`` by ``k``. The accepted
386+
tokens' K/V (computed in this forward) stay in the cache — never recomputed.
387+
"""
388+
import mlx.core as mx # type: ignore
389+
from mlx_lm.models.cache import trim_prompt_cache # type: ignore
390+
391+
eos = set(int(t) for t in eos_ids)
392+
C = adapter._past_len
393+
ctx_kv = drafter.make_context_kv(list(aux_prompt), mx.arange(0, C))
394+
mx.async_eval([t for kv in ctx_kv for t in kv])
395+
timing = {"ctx_kv_build_s": 0.0, "build_s": 0.0, "eval_s": 0.0, "extend_s": 0.0}
396+
adapter._capture_aux = True
397+
398+
generated: List[int] = []
399+
accepts: List[int] = []
400+
ctx_len = C
401+
try:
402+
while len(generated) < gen_tokens:
403+
L = min(block_size, gen_tokens - len(generated))
404+
base = adapter._past_len
405+
t_build = time.perf_counter()
406+
bonus_id = mx.argmax(adapter.next_token_logits) # lazy scalar
407+
n_draft = max(L - 1, 0)
408+
if n_draft:
409+
drafts = drafter.draft_block_ids(
410+
ctx_kv, bonus_id, embed_fn, lm_head_fn,
411+
n_masks=n_draft, context_len=base)
412+
check_ids = mx.concatenate([bonus_id[None], drafts]) # [L]
413+
mx.eval(check_ids) # two-phase (drafter graph before 26B graph)
414+
else:
415+
check_ids = bonus_id[None]
416+
block_logits = adapter.forward_block_lazy(check_ids[None]) # [L, V]
417+
# in-graph greedy acceptance over the check region
418+
pred_rows = mx.concatenate(
419+
[adapter.next_token_logits[None], block_logits[:max(L - 1, 0)]],
420+
axis=0)
421+
matches = (mx.argmax(pred_rows, axis=-1) == check_ids)
422+
accepted_mx = mx.sum(mx.cumprod(matches.astype(mx.int32)))
423+
rows = mx.concatenate(
424+
[adapter.next_token_logits[None], block_logits], axis=0) # [L+1,V]
425+
next_row = mx.take(rows, accepted_mx[None], axis=0)[0] # [V]
426+
timing["build_s"] += time.perf_counter() - t_build
427+
t_eval = time.perf_counter()
428+
mx.eval(accepted_mx, check_ids)
429+
timing["eval_s"] += time.perf_counter() - t_eval
430+
accepted = int(accepted_mx.item())
431+
check = [int(x) for x in check_ids.tolist()]
432+
commit = check[:accepted]
433+
generated += commit
434+
accepts.append(accepted)
435+
adapter.next_token_logits = next_row
436+
aux_rows = adapter._last_aux_mx
437+
# KEEP accepted (positions base..base+accepted-1), TRIM rejected.
438+
drop = L - accepted
439+
if drop > 0:
440+
trim_prompt_cache(adapter._cache, drop)
441+
adapter._past_len = base + accepted
442+
S_new = adapter._past_len
443+
lo, hi = ctx_len - base, S_new - base
444+
if hi > lo and aux_rows is not None:
445+
t_extend = time.perf_counter()
446+
new_aux = [a[lo:hi][None] for a in aux_rows]
447+
ctx_kv = drafter.extend_context_kv(
448+
ctx_kv,
449+
drafter.make_context_kv(new_aux, mx.arange(ctx_len, S_new)))
450+
mx.async_eval([t for kv in ctx_kv for t in kv])
451+
ctx_len = S_new
452+
timing["extend_s"] += time.perf_counter() - t_extend
453+
if any(t in eos for t in commit):
454+
break
455+
finally:
456+
adapter._capture_aux = False
457+
generated = generated[:gen_tokens]
458+
return {
459+
"tokens": generated,
460+
"blocks": len(accepts),
461+
"mean_accept_len": (round(sum(accepts) / len(accepts), 3)
462+
if accepts else 0.0),
463+
"decode_tokens": len(generated),
464+
"loop": "mlx_trim_keep_accepted_cuda_parity",
465+
"time_breakdown_s": {k: round(v, 3) for k, v in timing.items()},
466+
}
467+
468+
339469
def fused_specdecode_generate_mlx(
340470
adapter: "MLXRestoredIncrementalVerifier",
341471
drafter: Any,

inference_engine/bridge/manifest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,36 @@ def _harness_preset(
235235
timeout_minutes=45,
236236
params={"path": ("path:tests", None)},
237237
),
238+
Preset(
239+
name="k3-fused-allmlx-code-trim",
240+
description="CUDA-parity rollback test: all-MLX fused + --cuda-trim "
241+
"(all-KVCache + native trim, keep accepted / drop rejected, "
242+
"no re-forward) on the code-completion workload. Compare "
243+
"decode-only tok/s vs k3-fused-allmlx-code (v3 carry).",
244+
command_templates=(
245+
(
246+
"python3", "scripts/research/k3_integrated_niah_eval_mac.py",
247+
"--verifier-path", "${ENV:KAKEYA_MAC_VERIFIER_PATH}",
248+
"--drafter-id", "${ENV:KAKEYA_MAC_DRAFTER_ID}",
249+
"--f-theta-dir", "${ENV:KAKEYA_MAC_FTHETA_DIR}",
250+
"--s5-exact-full-attn", "--fused-specdecode",
251+
"--all-mlx-drafter", "--code-prompts", "--cuda-trim",
252+
"--n-samples", "{n_samples}",
253+
"--max-new-tokens", "{max_new_tokens}",
254+
"--block-size", "{block_size}",
255+
"--prefill-chunk-size", "512",
256+
"--output",
257+
"results/research/k3_mac_bridge_k3_fused_allmlx_code_trim.json",
258+
),
259+
),
260+
timeout_minutes=120,
261+
params={
262+
"n_samples": ("int:n_samples", "8"),
263+
"max_new_tokens": ("int:max_new_tokens", "128"),
264+
"block_size": ("int:block_size", "4"),
265+
},
266+
validate_reports=False,
267+
),
238268
Preset(
239269
name="k3-fused-allmlx-code",
240270
description="HONEST spec-decode throughput probe: all-MLX fused on a "

scripts/research/k3_integrated_niah_eval_mac.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ def parse_args() -> argparse.Namespace:
112112
"crossings per block. Requires --s5-exact-full-attn "
113113
"(the all-MLX path uses native-S5 injection; the "
114114
"f_theta sliding restoration path stays torch).")
115+
ap.add_argument("--cuda-trim", action="store_true",
116+
help="All-MLX fused with the CUDA-parity rollback: all-KVCache "
117+
"verifier layout + native trim_prompt_cache (keep accepted "
118+
"K/V, drop only rejected) instead of the v3 carry "
119+
"re-forward. Requires --all-mlx-drafter --fused-specdecode.")
115120
ap.add_argument("--code-prompts", action="store_true",
116121
help="Replace the NIAH dataset with code-completion prompts "
117122
"(naturally-long, predictable generation = the spec-decode "
@@ -184,7 +189,7 @@ def main() -> int:
184189
from inference_engine.backends.mlx.fused_specdecode import (
185190
MLXRestoredIncrementalVerifier, capture_aux_hidden,
186191
make_bridge_embed_lm_head, fused_specdecode_generate,
187-
fused_specdecode_generate_mlx,
192+
fused_specdecode_generate_mlx, fused_specdecode_generate_mlx_trim,
188193
)
189194
from inference_engine.v04.kv_compressor import make_default_compressor
190195
from inference_engine.bench.k3_report_gate import (
@@ -715,12 +720,20 @@ def eval_fused_specdecode() -> Tuple[List[str], List[float], List[int]]:
715720
restored_k_per_layer=_pad(rk, tsrc, T),
716721
restored_v_per_layer=_pad(rv, tsrc, T),
717722
evicted_positions=evicted,
718-
prefill_chunk_size=args.prefill_chunk_size)
723+
prefill_chunk_size=args.prefill_chunk_size,
724+
full_kv=args.cuda_trim)
719725
prefill_s = time.perf_counter() - prefill_t0
720726
t0 = time.perf_counter()
721727
if args.force_fused_specdecode:
722-
if mlx_drafter is not None:
723-
# Single-sync all-MLX loop (levers ①②③).
728+
if mlx_drafter is not None and args.cuda_trim:
729+
# CUDA-parity: keep accepted K/V, trim only rejected.
730+
res = fused_specdecode_generate_mlx_trim(
731+
adapter, active_drafter, aux_prompt=aux_prompt,
732+
embed_fn=embed_fn, lm_head_fn=lm_head_fn,
733+
gen_tokens=args.max_new_tokens,
734+
block_size=args.block_size, eos_ids=end_ids)
735+
elif mlx_drafter is not None:
736+
# Single-sync all-MLX loop (levers ①②③) + v3 carry rollback.
724737
res = fused_specdecode_generate_mlx(
725738
adapter, active_drafter, aux_prompt=aux_prompt,
726739
embed_fn=embed_fn, lm_head_fn=lm_head_fn,

tests/backends/mlx/test_fused_specdecode.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,23 @@ def test_adapter_prefill_rejects_empty_prompt(monkeypatch):
310310
evicted_positions=[])
311311

312312

313+
def test_make_full_kv_prompt_cache_all_kvcache(monkeypatch):
314+
# Fake mlx_lm.models.cache with make_prompt_cache (count) + a KVCache class.
315+
import types as _t
316+
class _FakeKV:
317+
instances = 0
318+
def __init__(self): type(self).instances += 1
319+
cache_mod = _t.ModuleType("mlx_lm.models.cache")
320+
cache_mod.make_prompt_cache = lambda model, **k: ["a", "b", "c", "d"] # 4 layers
321+
cache_mod.KVCache = _FakeKV
322+
monkeypatch.setitem(sys.modules, "mlx_lm", _t.ModuleType("mlx_lm"))
323+
monkeypatch.setitem(sys.modules, "mlx_lm.models", _t.ModuleType("mlx_lm.models"))
324+
monkeypatch.setitem(sys.modules, "mlx_lm.models.cache", cache_mod)
325+
out = fsd.make_full_kv_prompt_cache(object())
326+
assert len(out) == 4 and all(isinstance(c, _FakeKV) for c in out)
327+
assert _FakeKV.instances == 4 # every layer is a fresh full KVCache
328+
329+
313330
def test_patched_decoder_layers_empty_is_noop(monkeypatch):
314331
_install_mlx(monkeypatch)
315332
tm = _TextModel(0)

0 commit comments

Comments
 (0)