Skip to content

Commit 46db9e1

Browse files
MLX port (Step 1): incremental restored decode to kill throughput collapse
Port CUDA Gap-A to MLX. The existing MLX restored-attention dispatch already calls cache.update_and_fetch/cache.offset, so the per-token re-forward collapse is fixed by prefilling WITH a cache then decoding incrementally: - restored_prefill_cache: prefill once with restored-K/V injection into the model's native hybrid cache (full/global layers -> exact own K/V (S5); sliding -> f_theta-restored, window-bounded by RotatingKVCache). - restored_incremental_generate: greedy decode via mlx_lm generate_step over the prefilled cache (O(L)/token, async-pipelined). Recall carried by S5 full-attn. - k3_integrated_niah_eval_mac.py: --incremental flag selects the new path. - docs/mlx-port-lessons.md: Step 1 marked implemented + Mac validation command. Linux: compiles, funcs import (mlx lazy), MLX helper tests pass. End-to-end decode requires Apple Silicon -> Mac validation. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent ce911bb commit 46db9e1

3 files changed

Lines changed: 152 additions & 6 deletions

File tree

docs/mlx-port-lessons.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,24 @@ speed** (on CUDA: 1.3–2.8 tok/s re-forward → ~21 tok/s incremental = AR).
5050

5151
## MLX port plan (ordered; each gates the next)
5252

53-
1. **Incremental decode (kills the collapse).** Add an MLX analog of
54-
`CrossModelRestoredSinkWindowVerifier(incremental=True)`: prefill → capture
55-
restored K/V into `SinkWindowKVCache` (full-attn = own/exact; sliding = f_θ or
56-
window-masked) → decode via `generate_step(prompt_cache=…)`. **Gate: decode
57-
tok/s ≈ native mlx_lm AR; recall 1.0** (carried by S5).
53+
1. **Incremental decode (kills the collapse). [IMPLEMENTED — needs Mac validation]**
54+
`backends/mlx/cross_model_dlm_verifier.py`: `restored_prefill_cache` (prefill
55+
once with injection **into the model's native hybrid cache** → full-attn/global
56+
layers store exact own K/V, sliding store f_θ-restored + window-bounded) +
57+
`restored_incremental_generate` (decode via `mlx_lm.generate_step` over that
58+
cache, O(L)/token, async-pipelined). Wired into the Mac harness via
59+
`--incremental`:
60+
```bash
61+
PYTHONPATH=.:sdks/python python scripts/research/k3_integrated_niah_eval_mac.py \
62+
--verifier-path models/gemma-4-26B-A4B-it-mlx-4bit \
63+
--drafter-id z-lab/gemma-4-26B-A4B-it-DFlash \
64+
--f-theta-dir results/research/f_theta_v5_s5_sliding \
65+
--s5-exact-full-attn --incremental --n-samples 5 --max-new-tokens 32
66+
```
67+
**Gate: decode tok/s ≫ the per-token re-forward (toward native mlx_lm AR);
68+
recall == oracle (1.0)** (carried by S5). Mechanism mirrors CUDA Gap-A: the
69+
existing MLX dispatch already calls `cache.update_and_fetch`, so prefill *with*
70+
a cache populates it; decode then runs native incremental attention.
5871
2. **Drop the extra build forward.** Capture full-attn own K/V at prefill; do not
5972
re-run a clean verifier forward per request beyond prefill. **Gate:
6073
`build_restoration` from ~12s → ~prefill cost.**

inference_engine/backends/mlx/cross_model_dlm_verifier.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,96 @@ def restored_logits(
360360
logits = mlx_model(ids) # full Model.__call__ → tied embed + softcap
361361
mx.eval(logits)
362362
return logits[0] if return_all else logits[0, -1]
363+
364+
365+
# ---------------------------------------------------------------------------
366+
# Incremental decode (MLX port of CUDA Gap-A) — kills the per-token re-forward
367+
# throughput collapse. See docs/mlx-port-lessons.md.
368+
# ---------------------------------------------------------------------------
369+
370+
371+
def restored_prefill_cache(
372+
mlx_model: Any,
373+
input_ids: Sequence[int],
374+
*,
375+
restored_k_per_layer: Dict[int, Any],
376+
restored_v_per_layer: Dict[int, Any],
377+
evicted_positions: Sequence[int],
378+
):
379+
"""Prefill ONCE with restoration, capturing the restored K/V into a
380+
persistent mlx_lm prompt cache; return ``(cache, last_logits)``.
381+
382+
Same injection as :func:`restored_logits`, but run **with a cache** so the
383+
patched attention's ``cache.update_and_fetch`` stores the post-injection
384+
K/V (full-attention/S5 layers → exact own K/V; sliding → f_θ-restored,
385+
window-bounded by the model's native RotatingKVCache). After this the
386+
verifier can decode new tokens incrementally over the cache — O(L)/step —
387+
instead of re-forwarding the whole sequence each token.
388+
389+
Returns the model's native hybrid cache (full `KVCache` for global layers,
390+
`RotatingKVCache(sliding_window)` for sliding layers) populated to the
391+
prompt, plus the last-row logits (``mx [V]``) predicting the first token.
392+
"""
393+
import mlx.core as mx # type: ignore
394+
from mlx_lm.models.cache import make_prompt_cache # type: ignore
395+
396+
text_model = resolve_mlx_text_model(mlx_model)
397+
T = len(list(input_ids))
398+
evicted = set(int(p) for p in evicted_positions if 0 <= int(p) < T)
399+
evicted_mask = mx.array([p in evicted for p in range(T)])
400+
401+
cache = make_prompt_cache(mlx_model)
402+
with _patched_attention_class(text_model):
403+
for idx, layer in enumerate(text_model.layers):
404+
attn = layer.self_attn
405+
if not bool(getattr(attn, "has_kv", True)):
406+
continue # sharers inherit injected K/V via shared_kv
407+
rk = restored_k_per_layer.get(idx)
408+
rv = restored_v_per_layer.get(idx)
409+
if rk is None:
410+
continue
411+
attn._kakeya_inject = {
412+
"mode": "inject",
413+
"evicted_mask": evicted_mask,
414+
"restored_k": rk,
415+
"restored_v": rv,
416+
}
417+
ids = mx.array([list(input_ids)])
418+
logits = mlx_model(ids, cache=cache)
419+
mx.eval(logits)
420+
# Context manager restored the original Attention.__call__ → subsequent
421+
# decode steps run NATIVE incremental attention over this cache.
422+
return cache, logits[0, -1]
423+
424+
425+
def restored_incremental_generate(
426+
mlx_model: Any,
427+
cache: Any,
428+
first_logits: Any,
429+
*,
430+
max_tokens: int,
431+
eos_ids: Sequence[int] = (),
432+
) -> List[int]:
433+
"""Greedy-decode up to ``max_tokens`` tokens over a restored prefill cache
434+
using mlx_lm's native ``generate_step`` (chunked + async-pipelined) — the
435+
throughput-critical incremental loop. Recall is carried by the cache's
436+
full-attention (S5) layers.
437+
"""
438+
import mlx.core as mx # type: ignore
439+
from mlx_lm.generate import generate_step # type: ignore
440+
441+
eos = set(int(t) for t in eos_ids)
442+
nxt = int(mx.argmax(first_logits).item())
443+
out: List[int] = [nxt]
444+
if nxt in eos or max_tokens <= 1:
445+
return out
446+
# generate_step with a 1-token prompt + prefilled cache skips re-prefill
447+
# (its chunked-prefill loop needs >1 prompt token) and decodes incrementally.
448+
for tok, _ in generate_step(
449+
mx.array([nxt]), mlx_model, prompt_cache=cache, max_tokens=max_tokens - 1,
450+
):
451+
t = int(tok)
452+
out.append(t)
453+
if t in eos:
454+
break
455+
return out

scripts/research/k3_integrated_niah_eval_mac.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ def parse_args() -> argparse.Namespace:
6363
ap.add_argument("--sink-size", type=int, default=4)
6464
ap.add_argument("--window-size", type=int, default=64)
6565
ap.add_argument("--max-new-tokens", type=int, default=16)
66+
ap.add_argument("--incremental", action="store_true",
67+
help="Use the INCREMENTAL restored decode (MLX Gap-A): "
68+
"prefill captures restored K/V into a persistent cache, "
69+
"decode via mlx_lm generate_step (O(L)/token). Fixes the "
70+
"per-token re-forward throughput collapse. Free-gen only.")
6671
ap.add_argument("--teacher-forced", action="store_true",
6772
help="DIAGNOSTIC ONLY (under-measures retrieval): single "
6873
"restored forward per sample, check argmax at the "
@@ -108,6 +113,7 @@ def main() -> int:
108113
resolve_mlx_text_model, mlx_full_attention_layer_indices,
109114
kv_source_layer_map, capture_own_kv, restored_logits,
110115
per_layer_kv_geometry, kv_memory_report,
116+
restored_prefill_cache, restored_incremental_generate,
111117
)
112118
from inference_engine.v04.kv_compressor import make_default_compressor
113119
from scripts.research.k3_dflash_mlx_bridge import (
@@ -326,6 +332,37 @@ def eval_free_gen_cross() -> Tuple[List[str], List[float], List[int]]:
326332
file=sys.stderr)
327333
return decoded, lats, toks
328334

335+
def eval_free_gen_cross_incremental() -> Tuple[List[str], List[float], List[int]]:
336+
"""INCREMENTAL restored free generation (MLX port of CUDA Gap-A):
337+
prefill ONCE capturing restored K/V into a persistent cache, then
338+
decode with mlx_lm's native incremental step (O(L)/token). Fixes the
339+
per-token re-forward throughput collapse. Recall via S5 full-attn."""
340+
decoded, lats, toks = [], [], []
341+
for i, pid in enumerate(sample_ids):
342+
rk, rv, tsrc = build_restoration(pid)
343+
T = len(pid)
344+
evicted = compute_evicted_positions(T, args.sink_size, args.window_size)
345+
t0 = time.perf_counter()
346+
if not evicted:
347+
cache = (getattr(mlx_model, "make_cache", lambda: None)())
348+
out = mlx_model(mx.array([pid]), cache=cache); mx.eval(out)
349+
first = out[0, -1]
350+
else:
351+
cache, first = restored_prefill_cache(
352+
mlx_model, pid,
353+
restored_k_per_layer=_pad(rk, tsrc, T),
354+
restored_v_per_layer=_pad(rv, tsrc, T),
355+
evicted_positions=evicted)
356+
gen = restored_incremental_generate(
357+
mlx_model, cache, first,
358+
max_tokens=args.max_new_tokens,
359+
eos_ids=([eos_id] if eos_id is not None else ()))
360+
lats.append(time.perf_counter() - t0)
361+
decoded.append(tokenizer.decode(gen)); toks.append(len(gen))
362+
print(f"[mac] incr {i}: T={seq_lens[i]} -> {decoded[-1][:48]!r}",
363+
file=sys.stderr)
364+
return decoded, lats, toks
365+
329366
def eval_free_gen_oracle() -> Tuple[List[str], List[float], List[int]]:
330367
"""Oracle free generation using mlx's NATIVE incremental KV cache
331368
(fast + correct reference; confirms the metric/dataset)."""
@@ -356,11 +393,14 @@ def oracle_logits_all(prompt_ids, full_ids):
356393

357394
label = "identity" if args.identity_restore else (
358395
"s5" if args.s5_exact_full_attn else "f_theta_all")
359-
eval_mode = "teacher_forced" if args.teacher_forced else "free_gen"
396+
eval_mode = ("teacher_forced" if args.teacher_forced
397+
else "free_gen_incremental" if args.incremental else "free_gen")
360398
print(f"[mac] running restored cross-model verifier ({label}, {eval_mode})",
361399
file=sys.stderr, flush=True)
362400
if args.teacher_forced:
363401
cross_dec, cross_lat, cross_tok = eval_teacher_forced(cross_logits_all)
402+
elif args.incremental:
403+
cross_dec, cross_lat, cross_tok = eval_free_gen_cross_incremental()
364404
else:
365405
cross_dec, cross_lat, cross_tok = eval_free_gen_cross()
366406
cross_res = aggregate_recall("k3_cross_model_mac", samples, cross_dec, cross_lat, cross_tok)

0 commit comments

Comments
 (0)