Skip to content

Commit 4a4965e

Browse files
Step-2 levers 1+2+3: single-sync all-MLX fused loop
fused_specdecode_generate_mlx — one host sync per block: - (2) draft ids stay lazy mx tensors and feed the verifier forward in-graph (drafter.draft_block_ids + adapter.forward_block_lazy) - (1) in-graph greedy acceptance (cumprod leading-match) + lazy gather of the next-position logits row; per block mx.eval materialises only the accept count and candidate ids; drafter-context extensions go through mx.async_eval - (3) no correction forward: the gathered next-row makes the verifier's correction the next block's carried bonus, verified (and aux-captured) as position 0 of the next batched forward — guaranteed-accepted by construction, so every block commits >= 1 token and the loop can never run below AR pace Harness uses the new loop automatically on --all-mlx-drafter. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 7cf1988 commit 4a4965e

3 files changed

Lines changed: 199 additions & 19 deletions

File tree

inference_engine/backends/mlx/dflash_drafter.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -280,31 +280,56 @@ def _run_layers(self, hidden: Any, query_positions: Any, ctx_kv) -> Any:
280280
hidden = layer(hidden, query_positions, ck, cv)
281281
return self.norm(hidden)
282282

283-
def draft_block_cached(
283+
def draft_block_ids(
284284
self,
285285
ctx_kv,
286-
bonus_token_id: int,
286+
bonus_id_mx: Any,
287287
embed_fn: Callable[[Any], Any],
288288
lm_head_fn: Callable[[Any], Any],
289289
*,
290-
block_size: int,
290+
n_masks: int,
291291
context_len: int,
292-
) -> List[int]:
293-
"""Single non-causal pass over ``[bonus, mask×block_size]`` against the
294-
cached context K/V → ``block_size`` draft token ids. All-MLX: the
295-
embed/lm_head fns are the verifier's native MLX weights — no bridge."""
292+
) -> Any:
293+
"""LAZY draft: ``[bonus, mask×n_masks]`` → mx ``[n_masks]`` draft ids.
294+
295+
Nothing is evaluated and nothing crosses to python — the returned
296+
ids feed the verifier forward inside the same lazy graph (lever ②
297+
of the single-sync block loop). ``bonus_id_mx`` is an mx scalar
298+
(e.g. ``mx.argmax(next_token_logits)``).
299+
"""
296300
mx = _mx()
297301
cfg = self.cfg
298-
query_ids = mx.array(
299-
[[int(bonus_token_id)] + [cfg.mask_token_id] * block_size])
300-
query_positions = mx.arange(context_len, context_len + 1 + block_size)
302+
mask_ids = mx.full((n_masks,), cfg.mask_token_id, dtype=bonus_id_mx.dtype)
303+
query_ids = mx.concatenate([bonus_id_mx[None], mask_ids])[None]
304+
query_positions = mx.arange(context_len, context_len + 1 + n_masks)
301305
h = embed_fn(query_ids).astype(self.fc.weight.dtype)
302306
h = self._run_layers(h, query_positions, ctx_kv)
303-
logits = lm_head_fn(h) # [1, 1+block, vocab]
307+
logits = lm_head_fn(h) # [1, 1+n_masks, vocab]
304308
vocab = logits.shape[-1]
305309
never_mask = mx.arange(vocab) == cfg.mask_token_id
306310
logits = mx.where(never_mask, mx.array(-float("inf")), logits)
307-
drafts = mx.argmax(logits[0, 1:1 + block_size], axis=-1)
311+
return mx.argmax(logits[0, 1:1 + n_masks], axis=-1)
312+
313+
def draft_block_cached(
314+
self,
315+
ctx_kv,
316+
bonus_token_id: int,
317+
embed_fn: Callable[[Any], Any],
318+
lm_head_fn: Callable[[Any], Any],
319+
*,
320+
block_size: int,
321+
context_len: int,
322+
) -> List[int]:
323+
"""Single non-causal pass over ``[bonus, mask×block_size]`` against the
324+
cached context K/V → ``block_size`` draft token ids (materialised).
325+
Compatibility surface for the generic fused loop / parity gate; the
326+
single-sync loop uses :meth:`draft_block_ids` instead."""
327+
mx = _mx()
328+
drafts = self.draft_block_ids(
329+
ctx_kv, mx.array(int(bonus_token_id), dtype=mx.uint32),
330+
embed_fn, lm_head_fn,
331+
n_masks=block_size, context_len=context_len,
332+
)
308333
mx.eval(drafts)
309334
return [int(t) for t in drafts.tolist()]
310335

inference_engine/backends/mlx/fused_specdecode.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
# --------------------------------------------------------------------------- #

scripts/research/k3_integrated_niah_eval_mac.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def main() -> int:
179179
from inference_engine.backends.mlx.fused_specdecode import (
180180
MLXRestoredIncrementalVerifier, capture_aux_hidden,
181181
make_bridge_embed_lm_head, fused_specdecode_generate,
182+
fused_specdecode_generate_mlx,
182183
)
183184
from inference_engine.v04.kv_compressor import make_default_compressor
184185
from inference_engine.bench.k3_report_gate import (
@@ -684,13 +685,21 @@ def eval_fused_specdecode() -> Tuple[List[str], List[float], List[int]]:
684685
prefill_s = time.perf_counter() - prefill_t0
685686
t0 = time.perf_counter()
686687
if args.force_fused_specdecode:
687-
res = fused_specdecode_generate(
688-
adapter, active_drafter, aux_prompt=aux_prompt,
689-
embed_fn=embed_fn, lm_head_fn=lm_head_fn,
690-
gen_tokens=args.max_new_tokens, block_size=args.block_size,
691-
eos_ids=end_ids,
692-
argmax_fn=argmax_fn, arange_fn=arange_fn, cat_aux_fn=cat_aux_fn,
693-
allow_greedy_fallback=False)
688+
if mlx_drafter is not None:
689+
# Single-sync all-MLX loop (levers ①②③).
690+
res = fused_specdecode_generate_mlx(
691+
adapter, active_drafter, aux_prompt=aux_prompt,
692+
embed_fn=embed_fn, lm_head_fn=lm_head_fn,
693+
gen_tokens=args.max_new_tokens,
694+
block_size=args.block_size, eos_ids=end_ids)
695+
else:
696+
res = fused_specdecode_generate(
697+
adapter, active_drafter, aux_prompt=aux_prompt,
698+
embed_fn=embed_fn, lm_head_fn=lm_head_fn,
699+
gen_tokens=args.max_new_tokens, block_size=args.block_size,
700+
eos_ids=end_ids,
701+
argmax_fn=argmax_fn, arange_fn=arange_fn, cat_aux_fn=cat_aux_fn,
702+
allow_greedy_fallback=False)
694703
res["drafter_runtime"] = "mlx" if mlx_drafter is not None else "torch"
695704
else:
696705
t_greedy = time.perf_counter()

0 commit comments

Comments
 (0)