@@ -223,6 +223,33 @@ def last_aux_torch_slice(self, start: int = 0, end: Optional[int] = None) -> Lis
223223 bridge = self ._bridge or (lambda a : a )
224224 return [bridge (a [start :end ]) for a in self ._last_aux_mx ]
225225
226+ def forward_block_lazy (self , ids_mx : Any ) -> Any :
227+ """LAZY incremental verify: ``ids_mx`` is an mx ``[1, L]`` (typically
228+ the in-graph concatenation of the carried bonus + lazy draft ids —
229+ lever ② of the single-sync loop). Returns ``mx [L, V]`` logits with
230+ NO evaluation; aux hidden (when ``_capture_aux``) stays lazy in
231+ ``_last_aux_mx`` and is consumed lazily by the drafter-context
232+ extension."""
233+ if self ._cache is None :
234+ raise RuntimeError ("verifier not prefilled" )
235+ want_aux = self ._capture_aux and bool (self .aux_layer_ids )
236+ if want_aux :
237+ sink : Dict [int , Any ] = {}
238+ with _patched_decoder_layers (self .text_model ):
239+ for layer in self .text_model .layers :
240+ layer ._kakeya_aux_sink = sink
241+ layer ._aux_record = sink
242+ logits = self .mlx_model (ids_mx , cache = self ._cache )
243+ aux = _build_aux (self .text_model , ids_mx , sink ,
244+ self .embed_scale , self .aux_layer_ids )
245+ self ._last_aux_mx = [a [0 ] for a in aux ] # [L, hidden] each, lazy
246+ self ._last_aux = None
247+ else :
248+ logits = self .mlx_model (ids_mx , cache = self ._cache )
249+ self ._last_aux = None
250+ self ._last_aux_mx = None
251+ return logits [0 ]
252+
226253 def commit_or_truncate (self , * , forwarded : int , accepted : int ) -> None :
227254 if accepted < 0 or accepted > forwarded :
228255 raise ValueError ("accepted must satisfy 0 <= accepted <= forwarded" )
@@ -277,6 +304,125 @@ def lm_head_fn(h: Any) -> Any:
277304 return embed_fn , lm_head_fn
278305
279306
307+ # --------------------------------------------------------------------------- #
308+ # Single-sync all-MLX fused loop (levers ① ② ③ of the Step-2 throughput plan;
309+ # docs/mlx-port-lessons.md "Step-2 rescue status").
310+ # --------------------------------------------------------------------------- #
311+ def fused_specdecode_generate_mlx (
312+ adapter : "MLXRestoredIncrementalVerifier" ,
313+ drafter : Any ,
314+ * ,
315+ aux_prompt : Sequence [Any ],
316+ embed_fn : Callable [[Any ], Any ],
317+ lm_head_fn : Callable [[Any ], Any ],
318+ gen_tokens : int ,
319+ block_size : int ,
320+ eos_ids : Sequence [int ] = (),
321+ ) -> Dict [str , Any ]:
322+ """All-MLX fused spec decode with ONE host sync per block.
323+
324+ * ② draft+verify single graph: the drafter's lazy draft ids
325+ (:meth:`MLXDFlashDrafter.draft_block_ids`) are concatenated with the
326+ carried bonus in-graph and fed straight into the verifier forward —
327+ no draft token ever crosses to python before verification.
328+ * ① in-graph acceptance: the leading-match count is
329+ ``sum(cumprod(argmax(pred_rows) == candidate))``; the next-position
330+ logits row is gathered with the lazy count (``mx.take``). The block's
331+ single ``mx.eval`` materialises exactly three things: the accept
332+ count, the candidate ids, and nothing else. Drafter-context
333+ extensions are pushed with ``mx.async_eval`` so Metal works while
334+ python does bookkeeping.
335+ * ③ carried correction: on rejection there is NO correction forward.
336+ ``next_token_logits`` is set to the gathered next-position row, so
337+ the verifier's own argmax (the correction) becomes the next block's
338+ bonus — guaranteed-accepted at position 0 of the next verify, where
339+ its K/V and aux are computed as part of the batched forward.
340+
341+ Per-block commit = the accepted candidate prefix (position 0, the
342+ carried bonus/correction, always accepts by construction — every block
343+ commits >= 1 token, so the loop degrades to AR pace, never below).
344+ """
345+ import mlx .core as mx # type: ignore
346+
347+ eos = set (int (t ) for t in eos_ids )
348+ C = adapter ._past_len
349+ t_ctx = time .perf_counter ()
350+ ctx_kv = drafter .make_context_kv (list (aux_prompt ), mx .arange (0 , C ))
351+ mx .async_eval ([t for kv in ctx_kv for t in kv ])
352+ timing = {
353+ "ctx_kv_build_s" : time .perf_counter () - t_ctx ,
354+ "build_s" : 0.0 , # lazy graph construction (python-side)
355+ "eval_s" : 0.0 , # the per-block single sync (Metal compute)
356+ "extend_s" : 0.0 ,
357+ }
358+ adapter ._capture_aux = True
359+
360+ generated : List [int ] = []
361+ accepts : List [int ] = []
362+ next_logits = adapter .next_token_logits # mx [V], may be lazy
363+ try :
364+ while len (generated ) < gen_tokens :
365+ L = min (block_size , gen_tokens - len (generated ))
366+ cstart = adapter ._past_len
367+ t_build = time .perf_counter ()
368+ bonus_id = mx .argmax (next_logits ) # lazy scalar
369+ n_draft = max (L - 1 , 0 )
370+ if n_draft :
371+ drafts = drafter .draft_block_ids (
372+ ctx_kv , bonus_id , embed_fn , lm_head_fn ,
373+ n_masks = n_draft , context_len = cstart )
374+ candidate = mx .concatenate ([bonus_id [None ], drafts ]) # [L]
375+ else :
376+ candidate = bonus_id [None ]
377+ block_logits = adapter .forward_block_lazy (candidate [None ]) # [L, V]
378+ # In-graph greedy acceptance: row i of pred_rows predicts
379+ # candidate[i]; leading-match count via cumprod.
380+ pred_rows = mx .concatenate (
381+ [next_logits [None ], block_logits [:- 1 ]], axis = 0 ) # [L, V]
382+ matches = (mx .argmax (pred_rows , axis = - 1 ) == candidate )
383+ accepted_mx = mx .sum (mx .cumprod (matches .astype (mx .int32 )))
384+ # Logits predicting position cstart+accepted (the carried
385+ # bonus/correction source for the next block).
386+ rows = mx .concatenate ([next_logits [None ], block_logits ], axis = 0 )
387+ next_row = mx .take (rows , accepted_mx [None ], axis = 0 )[0 ] # [V]
388+ timing ["build_s" ] += time .perf_counter () - t_build
389+ # ---- the block's single host sync ----
390+ t_eval = time .perf_counter ()
391+ mx .eval (accepted_mx , candidate )
392+ timing ["eval_s" ] += time .perf_counter () - t_eval
393+ accepted = int (accepted_mx .item ())
394+ cand = [int (x ) for x in candidate .tolist ()]
395+ adapter .commit_or_truncate (forwarded = L , accepted = accepted )
396+ commit = cand [:accepted ]
397+ generated += commit
398+ accepts .append (accepted )
399+ next_logits = next_row
400+ adapter .next_token_logits = next_row
401+ if accepted and adapter ._last_aux_mx is not None :
402+ t_extend = time .perf_counter ()
403+ new_aux = [a [0 :accepted ][None ] for a in adapter ._last_aux_mx ]
404+ ctx_kv = drafter .extend_context_kv (
405+ ctx_kv ,
406+ drafter .make_context_kv (
407+ new_aux , mx .arange (cstart , cstart + accepted )))
408+ mx .async_eval ([t for kv in ctx_kv for t in kv ])
409+ timing ["extend_s" ] += time .perf_counter () - t_extend
410+ if any (t in eos for t in commit ):
411+ break
412+ finally :
413+ adapter ._capture_aux = False
414+ generated = generated [:gen_tokens ]
415+ return {
416+ "tokens" : generated ,
417+ "blocks" : len (accepts ),
418+ "mean_accept_len" : (round (sum (accepts ) / len (accepts ), 3 )
419+ if accepts else 0.0 ),
420+ "decode_tokens" : len (generated ),
421+ "loop" : "mlx_single_sync_v2" ,
422+ "time_breakdown_s" : {k : round (v , 3 ) for k , v in timing .items ()},
423+ }
424+
425+
280426# --------------------------------------------------------------------------- #
281427# The fused spec-decode loop (control flow; MLX/torch ops via injected fns).
282428# --------------------------------------------------------------------------- #
0 commit comments