@@ -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+
339469def fused_specdecode_generate_mlx (
340470 adapter : "MLXRestoredIncrementalVerifier" ,
341471 drafter : Any ,
0 commit comments