Compare commits

...
Author SHA1 Message Date
dmcc73andClaude Opus 4.7 7002cb79dc batch_generate: preserve 'mtp.' prefix in cached MTP weights (was a real bug)
MTPPredictor._load_weights expects keys with the 'mtp.' prefix preserved
(see mtp_module.py:202+), e.g. 'mtp.fc.weight', 'mtp.layers.0.self_attn.q_proj.weight'.
Both the original code (stripping 'model.mtp.' to leave 'fc.weight') and
my recent change (stripping 'mtp.') produced cached files MTPPredictor
couldn't read — manifesting as KeyError 'mtp.fc.weight' at MTPPredictor
init. The try/except in _resolve_mtp_weights swallowed it and silently
fell back to non-speculative.

Fix: only strip the optional 'model.' wrapper; keep 'mtp.' intact. Bump
cache filename to mtp_v2_<hash>.safetensors so any existing wrong cache
gets re-extracted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:56:21 +01:00
dmcc73andClaude Opus 4.7 2a02a6a029 batch_generate: byte-range fetch MTP tensors instead of whole shards
Three-tier fallback:
  1. Read index, byte-range fetch only the MTP tensors out of their shards
     via HfFileSystem. ~500 MB for Qwen/Qwen3.5-27B (vs 55 GB full repo or
     20 GB shard-level).
  2. Shard-level fallback if byte-range read fails.
  3. Full safetensors fallback if no index (single-shard repo).

The MTP head is small (~13 tensors) but scattered across 4 different shards
because HF packs by file size, not logical grouping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:44:45 +01:00
dmcc73andClaude Opus 4.7 810aaf5076 batch_generate: smart-download MTP head + accept 'mtp.' key prefix
Two fixes to _extract_mtp_from_hf:

1. Read model.safetensors.index.json first to find shards containing MTP
   tensors, download only those. For Qwen/Qwen3.5-27B the MTP head spans
   4 of 11 shards (~20 GB instead of ~55 GB full repo). Falls back to
   pulling all safetensors if the index is absent.

2. Accept both 'model.mtp.' (older convention) and 'mtp.' (Qwen/Qwen3.5-27B
   actual prefix) tensor keys. Previously the code only matched 'model.mtp.'
   so on Qwen/Qwen3.5-27B it found zero tensors, raised ValueError, and the
   caller silently fell back to non-speculative — meaning MTP has likely
   been disabled on this repo since the auto-detect logic was added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:39:45 +01:00
dmcc73andClaude Opus 4.7 68a2313b26 mtp_batch_generator: print n_accepted per speculative cycle
Mirrors the [DFlash] n_accepted print so MTP runs can be diffed against
DFlash runs by greppable log lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:31:54 +01:00
dmcc73andClaude Opus 4.7 678548042a dflash_module: thread rms_norm_eps from drafter config (was defaulting to 1e-5)
MLX nn.RMSNorm defaults eps to 1e-5 but the drafter's HF config specifies
1e-6. Applied to q/k_norm, input/post_attention layernorms, hidden_norm,
and norm — 6 RMSNorms per layer x 8 layers plus 2 top-level. Small per-op
drift that compounds through the drafter forward, likely contributing to
lower-than-expected acceptance on open-ended prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:19:35 +01:00
dmcc73andClaude Opus 4.7 ae03a01511 dflash_batch_generator: print n_accepted per speculative cycle (single-node)
Mirrors the split path's print. Same format so logs look comparable
between single-node and split runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:39:03 +01:00
dmcc73andClaude Opus 4.7 2cb61b987c qwen3_5_moe_split: restore per-stage eval_local / eval_gather timings
For 35B-A3B bring-up diagnostics. Each stage now evals local work and
gathered with explicit perf_counter brackets (graph build outside timer).
Role label per rank so attn vs MoE time can be compared side-by-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:22:06 +01:00
dmcc73andClaude Opus 4.7 849ff52afe patches: default EXO_FUSED_KERNELS to 0 (LpB is the safe default)
LpB covers both qwen3_5 and qwen3_5_moe; fused MoE kernels are now
opt-in via EXO_FUSED_KERNELS=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:50:48 +01:00
dmcc73andClaude Opus 4.7 b19c05bdf1 patches: fall back to LpB patches on MoE when EXO_FUSED_KERNELS=0
Previously EXO_FUSED_KERNELS=0 skipped all patching; single-node MoE runs
got zero projection patches. Now under EXO_FUSED_KERNELS=0 we apply
apply_lpb_patches to both qwen3_5 (dense) and qwen3_5_moe targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:47:30 +01:00
dmcc73andClaude Opus 4.7 4435a38aea qwen3_5_moe_split: strip per-stage prints, timings, and extra evals again
Back to the minimal state from commit 312229a4 (post-27B debug):
  stage 0: eval(gathered)
  B even-S: eval(my_out) on ATTN only
  A even-S: no eval
  drain: eval(gathered)
Keep the pipeline begin print at top. Odd-S paths keep their existing evals.
Remove unused `import time`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:38:38 +01:00
dmcc73andClaude Opus 4.7 d759f580ca qwen3_5 lpb_patch: also check model.language_model for lm_head
Qwen3.5 and Qwen3.5-MoE keep lm_head on the TextModel wrapper
(model.language_model.lm_head). The old holder loop only checked
model and inner (Qwen3_5TextModel), silently missing lm_head. Affected
both attn/moe split runs and the single-node single-device path.

Bumps 35B-A3B target LpB count 330 -> 331 and analogous +1 on 27B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:13:28 +01:00
dmcc73andClaude Opus 4.7 6b253f9866 qwen3_5_moe_split: drop unused weights even under EXO_SPECULATIVE=1
ATTN_RANK always drops mlp + post_attention_layernorm (never runs them).
MOE_RANK drops self_attn (never runs attention). Under speculative, MOE_RANK
keeps linear_attn + input_layernorm because _pipelined_dflash_forward's
post-loop conv_input reconstruction calls them on MOE_RANK.

Saves the biggest chunk on 35B-A3B: MLP has 256 experts x 3 projections
x 40 layers dead on ATTN_RANK previously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:07:51 +01:00
dmcc73andClaude Opus 4.7 d8b2096e09 qwen3_5_moe_split: restore eval_local / eval_gather timings in pipelined_layer_loop
For 35B-A3B bring-up diagnostics. Each stage now evals local work + gathered
with explicit perf_counter brackets (graph build outside timer). Prints role
label per rank so attn vs MoE time can be compared side-by-side. Strip later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:02:19 +01:00
dmcc73andClaude Opus 4.7 9c876ef1ab qwen3_5_moe_split: re-enable per-stage mean prints in pipelined_layer_loop
For Qwen3.5-35B-A3B bring-up. Will strip again once validated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:56:57 +01:00
dmcc73andClaude Opus 4.7 40a18fcaf8 qwen3_5 lpb_patch: extend LpB to MoE dense sub-modules (gate, shared_expert)
Reaches Qwen3NextSparseMoeBlock.{gate, shared_expert_gate, shared_expert.*}
so Qwen3.5-35B-A3B picks up the dynamic LpB kernels on MOE_RANK. Routed
experts (switch_mlp / SwitchLinear) stay on stock. Silent no-op on dense
27B via getattr fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:56:57 +01:00
dmcc73andClaude Opus 4.7 312229a454 qwen3_5_moe_split: drain — eval gathered instead of contribution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:28:19 +01:00
dmcc73andClaude Opus 4.7 95d4e66680 qwen3_5_moe_split: stage 0 — eval gathered instead of contribution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:27:53 +01:00
dmcc73andClaude Opus 4.7 4b344778c7 qwen3_5_moe_split: stage 0 eval(contribution) on both ranks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:26:19 +01:00
dmcc73andClaude Opus 4.7 6f623c5647 qwen3_5_moe_split: change async_eval to eval for matches/all_next/target_hidden
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:23:23 +01:00
dmcc73andClaude Opus 4.7 adfcbd8da1 qwen3_5_moe_split: eval zeros_like on ATTN_RANK in drain stage
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:26:52 +01:00
dmcc73andClaude Opus 4.7 b608759003 qwen3_5_moe_split: move stage 0 eval to MOE zeros_like instead of ATTN contribution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:21:34 +01:00
dmcc73andClaude Opus 4.7 050d6bab10 qwen3_5_moe_split: add back eval(contribution) in stage 0 on ATTN_RANK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:17:29 +01:00
dmcc73andClaude Opus 4.7 e8b0331a7e qwen3_5_moe_split: strip evals/timings/prints from startup and drain stages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:07:09 +01:00
dmcc73andClaude Opus 4.7 1a65eb9508 qwen3_5_moe_split: move B-stage eval(my_out) from MOE to ATTN side
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:59:33 +01:00
dmcc73andClaude Opus 4.7 ddb4cb6c57 qwen3_5_moe_split: drop A-stage ATTN eval(my_out); only B-stage MOE evals remain
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:39:37 +01:00
dmcc73andClaude Opus 4.7 addf9a621b qwen3_5_moe_split: strip A-stage prints/timings, keep only ATTN eval(my_out)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:35:34 +01:00
dmcc73andClaude Opus 4.7 e6ba9c9cb2 qwen3_5_moe_split: strip B-stage prints/timings, keep only MOE eval(my_out)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:31:19 +01:00
dmcc73andClaude Opus 4.7 a3d9217fc8 qwen3_5_moe_split: per-stage eval_local / eval_gather timings in pipelined_layer_loop
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:20:28 +01:00
dmcc73andClaude Opus 4.7 6a2abd8a62 qwen3_5_moe_split: PIPELINE.md — full per-stage print/eval list under pipelined loop
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:52:40 +01:00
dmcc73andClaude Opus 4.7 2fbf6af48d qwen3_5_moe_split: PIPELINE.md — inline Prints/Evals callouts in flow sections
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:44:10 +01:00
dmcc73andClaude Opus 4.7 964d006a63 qwen3_5_moe_split: PIPELINE.md — enumerate prints and mx.eval/async_eval sites
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:39:38 +01:00
dmcc73andClaude Opus 4.7 db1455f5ea qwen3_5_moe_split: add PIPELINE.md navigation reference for DFlash on split
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:13:14 +01:00
dmcc73andClaude Opus 4.7 e8276c1163 qwen3_5_moe_split: ArraysCache.extract tolerates None entries on MOE_RANK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:36:49 +01:00
dmcc73andClaude Opus 4.7 b1a5dccf3c qwen3_5_moe_split: populate DFlash _captured dict from pipelined prefill
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:36:44 +01:00
dmcc73andClaude Opus 4.6 f9a13bf95d qwen3_5_moe_split: sync _draft_position from ATTN_RANK before drafting
Cache offset on MOE_RANK stays at 0 (no attention runs there), while
ATTN_RANK's grows normally with prefill. _first_step_capture (stock)
reads cache offset for _draft_position[uid], so MOE_RANK's value is
always wrong (0). Since we sync drafts and take MOE_RANK's as truth,
the drafter was running with start=0 (wrong positional encoding) and
producing garbage drafts → n_accepted=0/7 consistently.

Fix: at the top of _split_speculative_next, all_gather the local
_draft_position and use ATTN_RANK's value on both ranks. Subsequent
updates (start + n_accepted + 1) stay synced naturally since both
ranks compute the same n_accepted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:37:22 +01:00
dmcc73andClaude Opus 4.6 b0ccc04182 qwen3_5_moe_split: print n_accepted per speculative cycle
One line per verify step from MOE_RANK showing how many of the V
drafts were accepted. Useful for tracking acceptance rate during
tuning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:31:52 +01:00
dmcc73andClaude Opus 4.6 461d9e0489 qwen3_5_moe_split: reconstruct conv_input for GDN rollback
Stock dflash_speculative_forward captures each GDN layer's input during
the layer loop, then post-loop computes `conv_input = concat([pre_conv,
in_proj_qkv(input_layernorm(layer_input))])` and assigns it to the
SpeculativeArraysCache for conv state rollback. Our earlier patch
skipped this because pipelined_layer_loop doesn't preserve per-layer
inputs.

Fix: extend the capture set passed to pipelined_layer_loop to include
`L-1` for every GDN layer L (layer L-1's output IS layer L's input).
Layer 0's input is the initial embedding, saved explicitly.

Post-loop now computes qkv and conv_input for each GDN layer exactly
matching stock. SpeculativeArraysCache.rollback will now correctly
restore both cache[0] (conv state) and cache[1] (recurrent state).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:26:32 +01:00
dmcc73andClaude Opus 4.6 857d01f4ed qwen3_5_moe_split: merge H0/H1 spec_all_states pairs for GDN rollback
Our cross-layer pipeline calls GDN.linear_attn TWICE per GDN layer
(H0 then H1) during the verify forward. The monkey-patched
gated_delta_update appends per-step states on each invocation, so
spec_all_states ends up with 2*N_gdn entries instead of N_gdn.

The post-loop assigns `spec_cache.all_states = spec_all_states[gdn_idx]`
one-per-layer — so under the split, layer 0 gets H0-of-layer-0's states,
layer 1 gets H1-of-layer-0's states (wrong!), etc. Rollback then
restores GDN recurrent state to the wrong value, corrupting subsequent
verifies.

Fix: if S>1 and we see 2*N entries, concatenate consecutive pairs along
the step dim to get one (B, S, ...) entry per GDN layer, matching what
stock dflash_speculative_forward would have produced with a single
per-layer call.

Also reverted the previous _idx rollback patch — stock DFlash works fine
with stock exo (user confirmed), so the rollback .offset-only path isn't
the bug. The bug is our 2-call-per-layer capture mismatch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:15:25 +01:00
dmcc73andClaude Opus 4.6 67a80cfb14 qwen3_5_moe_split: rollback BatchKVCache._idx too, not just .offset
BatchKVCache.update_and_fetch writes at [_idx : _idx + new_S] and
returns keys[:_idx]. Stock DFlash's rollback only decrements .offset,
leaving _idx at the post-verify value. Result: stale rejected-draft
keys stay in the cache, and the next verify's attention attends to
them, corrupting the output.

Fix: decrement _idx by the same amount as offset. Now
update_and_fetch overwrites stale entries cleanly.

This matches observed behavior: first speculative verify produces
fine output, second+ verifies produce gibberish due to stale cache
contamination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:13:15 +01:00
dmcc73andClaude Opus 4.6 945ba5bb2a qwen3_5_moe_split: add diagnostic prints to DFlash path
Print which branch _split_speculative_next takes (fallback vs
speculative cycle) and whether target_hidden is set. Helps diagnose
why output is gibberish — lets us see if the speculative path is
ever entered or if every token is coming from super()._next() fallback.

Note: _CapturingLayer in stock DFlashBatchGenerator never fires during
our pipelined_layer_loop (which calls layer.self_attn/linear_attn/mlp
directly, bypassing layer.__call__). So _last_target_hidden is never
populated and every _speculative_next call falls back to super()._next().
That SHOULD produce correct tokens; if it doesn't, something else is off.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:59:57 +01:00
dmcc73andClaude Opus 4.6 eabe1bbdc8 qwen3_5_moe_split: use BatchKVCache._idx for mask slicing offset
BatchKVCache.update_and_fetch returns keys[:, :, :_idx, :] — the actual
K buffer length. BatchKVCache.make_mask also uses _idx as the offset to
create_causal_mask. Our pipelined mask slicer was using .offset (which
is _idx - left_padding for positional encodings), producing a mask too
short by left_padding.

Fix: prefer _idx if the cache has it (BatchKVCache), else fall back to
.offset (plain KVCache, where they're equal).

Shape error was "(1,1,4,65) vs (1,24,4,72)" — 72 = actual _idx + mid,
65 = our offset + mid with offset=61. Difference 7 = left_padding of
one of the batch entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:49:24 +01:00
dmcc73andClaude Opus 4.6 636c6f1810 qwen3_5_moe_split: coerce BatchKVCache.offset to python int for mask slicing
BatchKVCache stores offset as an mx.array (1,). Slice indices require
Python ints, so take max().item() on the array case. KVCache's offset
is already a Python int (passthrough).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:35:37 +01:00
dmcc73andClaude Opus 4.6 a14ab9a66e qwen3_5_moe_split: call apply_lpb_patches on target before split
Applies the dynamic bf16/int8 loop-per-batch projection patches to the
target model's attention, MLP, and lm_head. Kernels are picked at call
time based on M = batch*seq; for M<=16 uses the LpB fast path. Benefits:
- decode S=1 (M=1)
- DFlash verify S=V+1=6 (M=6)
Prefill with long prompts (M>16) falls back to stock projections.

Most impactful on dense Qwen3.5-27B (patches MLP too). On MoE variants
only attn + lm_head get patched (MLP structure differs).

Gated by EXO_LPB_PATCHES env var (default "1").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:55:07 +01:00
dmcc73andClaude Opus 4.6 e1d048cf79 qwen3_5_moe_split: DFlash speculative on the attn/MoE split
Phase 2b: enable DFlash speculative decoding through the pipelined
2N+1 stage schedule. Both ranks load the drafter and draft
independently; drafts sync via all_gather (MOE_RANK's drafts win).
Both ranks then run the pipelined verify forward; acceptance is
deterministic at temp=0 on both ranks, or broadcast from MOE_RANK
at temp>0.

Changes:
- pipelined_layer_loop: accept capture_layers set; return
  (final, captured_dict) so DFlash target_hidden can be assembled
  from selected layer outputs. Captures at the end of each A stage
  (when layer T-1's full output is known as concat(x_H0, x_H1))
  and after drain for layer N-1.
- model_forward.make_pipelined_dflash_speculative_forward: wraps
  pipelined_layer_loop with DFlash pre/post-loop (cache wrap, GDN
  kernel swap, target_hidden assembly).
- dflash_split.make_split_speculative_next: rank-aware
  DFlashBatchGenerator._speculative_next — drafts independently,
  syncs via all_gather, runs pipelined verify, shared acceptance.
- apply.py: patches dflash_speculative_forward and
  DFlashBatchGenerator._speculative_next on top of existing
  attn/MoE split patches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:47:45 +01:00
dmcc73andClaude Opus 4.6 df9304a8a8 qwen3_5_moe_split: tensor mean prints for prefill pipeline stages too
Replace verbose step-by-step prints with one summary per stage showing
tensor means of the gathered results. Same format as the decoder.py
prints — same mean on both ranks = correct, divergence = bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:06:33 +01:00
dmcc73andClaude Opus 4.6 edc13eb1ef qwen3_5_moe_split: print tensor means after each gather in _split_call
Each layer prints h.mean() after gather-1 and result.mean() after
gather-2. Forces mx.eval before print so both ranks must sync at each
gather point. Same mean on both ranks = ok, divergence = bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:01:11 +01:00
dmcc73andClaude Opus 4.6 84d8122ee4 qwen3_5_moe_split: add per-layer debug prints to _split_call (S==1 decode)
Prints begin / pre-gather-1 / post-gather-1 / pre-gather-2 / done for
each layer call. Note post-gather-1 is before mx.eval — gather is still
lazy at that point; actual blocking is in the next step's compute or
gather.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:55:32 +01:00
dmcc73andClaude Opus 4.6 e77b9d8c94 qwen3_5_moe_split: add per-stage debug prints to pipelined_layer_loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:49:35 +01:00
dmcc73andClaude Opus 4.6 3ea7e8e452 qwen3_5_moe_split: eval contribution before each all_gather
Add mx.eval(my_contribution) before every all_gather in the pipelined
loop. Forces each rank to materialize its contribution to the GPU
before hitting the collective, so JACCL doesn't block waiting on a
lazy graph node to complete.

Covers: Stage 0, all main-loop stages (even-S and odd-S paths), and
Stage 2N drain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:42:32 +01:00
dmcc73andClaude Opus 4.6 53c144cf61 qwen3_5_moe_split: slice_fa_mask handles 2D mask
create_attention_mask(return_array=True) -> create_causal_mask returns
a 2D (S, offset+S) mask in the exo mlx-lm fork, not 4D. Update
slice_fa_mask to slice by the last two dims regardless of ndim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:34:59 +01:00
dmcc73andClaude Opus 4.6 5eb92df705 qwen3_5_moe_split: single-gather fast path for even S, two-gather for odd
Even S (H0.size == H1.size == S/2): one all_gather per stage — each
rank contributes its real output, both get both (2N+1 collectives total).

Odd S (H0.size != H1.size): two all_gathers per stage — each rank
contributes a zero placeholder of the other side's shape (4N collectives
total).

Spec verify uses even S (DFlash V+1=6 by default) and gets the fast path.
Prefill with odd-length prompts still works correctly via the slower
two-gather path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:18:55 +01:00
dmcc73andClaude Opus 4.6 4608e758d6 qwen3_5_moe_split: two all_gathers per stage for arbitrary S
Instead of requiring even S for the one-all_gather optimization,
use two all_gathers per stage (one per shape) so H0 and H1 can have
different sizes. Handles any S including odd (e.g. γ+1=3 for MTP
default).

Each rank contributes:
- attn_side: real attn output (ATTN) or zeros of same shape (MOE)
- moe_side:  real moe output (MOE) or zeros of same shape (ATTN)

Two collectives per stage, total 4N+2 collectives per forward (vs
2N+1 for the even-S single-gather approach). Still much better than
serial 2*N per-layer gathers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:15:26 +01:00
dmcc73andClaude Opus 4.6 76b19996a2 qwen3_5_moe_split: fall back to serial for odd S (all_gather shape symmetry)
The pipelined 2N+1 schedule does one all_gather per stage where both
ranks contribute tensors. For odd S, mid=S//2 means H0 and H1 have
different sizes (mid vs S-mid), so the two ranks would contribute
tensors of different shapes to the all_gather — shape mismatch.

Simplest fix: fall back to the stock serial layer loop when S is odd.
Even-S prompts still use the pipelined path. Future work: pad to even
length (requires mask-out of padding positions in the output).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:08:41 +01:00
dmcc73andClaude Opus 4.6 8ba1a878ad qwen3_5_moe_split: model-level pipelined forward
Move S>1 pipelining out of DecoderLayer.__call__ into a new
model_forward.py that replaces Qwen3_5TextModel.__call__ and
mtp_module.speculative_forward.

New approach:
- decoder.py: serial split for S==1 only (clean rewrite, no state)
- model_forward.py:
  - pipelined_layer_loop(): 2N+1 stage pipeline with loop-local state
    (no closures carrying cross-layer state, no correctness bugs)
  - make_pipelined_model_call(): replaces Qwen3_5TextModel.__call__
  - make_pipelined_speculative_forward(): replaces speculative_forward
- apply.py: patches both class-level __call__ and the speculative_forward
  function; skips _drop_unused_weights when EXO_SPECULATIVE=1

Pipeline schedule:
  Stage 0     : ATTN attn_0(H0).           MOE idle.
  Stage 2T+1  : ATTN attn_T(H1).           MOE moe_T(h_T_H0).
  Stage 2T(≥1): ATTN attn_T(H0).           MOE moe_{T-1}(h_{T-1}_H1).
  Stage 2N    : ATTN idle.                 MOE moe_{N-1}(h_{N-1}_H1).

One all_gather per stage (both ranks contribute their real output, both
receive both — same shape, single collective instead of two).

Mask slicing uses create_attention_mask(return_array=True) to force a
real tensor so H1 queries get correct columns (mid..S) instead of the
"causal" sentinel which would mis-align.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:00:54 +01:00
dmcc73andClaude Opus 4.6 6ae513b9ab qwen3_5_moe_split: handle GDN ssm_mask (2D) and "causal" sentinel
Qwen3.5-27B is mostly GDN layers. create_ssm_mask returns None or
a 2D (B, S) bool mask from ArraysCache.make_mask. create_attention_mask
often returns the "causal" string sentinel, not a 4D tensor.

Branch get_masks on type/dimension:
- None / str: pass through unchanged to both halves
- 4D tensor: slice rows + columns (needs cache.offset)
- 2D tensor: slice sequence dim only

Cache key changed to (cache id, S) so different layers with different
caches don't collide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:58:49 +01:00
dmcc73andClaude Opus 4.6 5503a6c496 qwen3_5_moe_split: ATTN_RANK placeholder for all_gather on out_H1_prev
Both ranks must participate in the Stage A all_gather. ATTN_RANK
contributes pending_h_H1 (already-gathered tensor) as a placeholder;
the all_gather returns MOE_RANK's real out_H1_prev. Fixes crash
where ATTN_RANK reached concatenate with out_H1 = None.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:57:53 +01:00
dmcc73andClaude Opus 4.6 11209bc9a5 qwen3_5_moe_split: cleaner decoder with helpers
Extract attention(), moe(), gather_from(), get_masks() helpers.
Split serial_decode() (S==1) and pipelined_forward() (S>1) into
separate functions. Closure state dict groups related fields:
layer_idx, mask_cache, prev_layer, pending_h_H1.

No behavior change — just readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:53:28 +01:00
dmcc73andClaude Opus 4.6 6e1476ed2a qwen3_5_moe_split: cross-layer pipelined S>1
Stage A: ATTN does H0 attention for layer L, MOE finishes H1 MoE
for layer L-1 (using prev_layer_self.mlp). Stage B: ATTN does H1
attention, MOE does H0 MoE. Both stages overlap.

Layer 0: startup bubble (MOE does H1 serially, no overlap).
Last layer: drain (MOE flushes H1 immediately).
Middle layers: full overlap — MOE processes prev layer's H1 during
ATTN's H0, saving min(attn, moe) per layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:43:44 +01:00
dmcc73andClaude Opus 4.6 f48a857f89 qwen3_5_moe_split: cache mask slices across layers
Compute mask_H0 and mask_H1 on first layer call, cache in closure
dict keyed by S. Subsequent layers reuse cached slices. Clear cache
on S==1 (decode) transitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:33:12 +01:00
dmcc73andClaude Opus 4.6 5f80fce3af qwen3_5_moe_split: within-layer pipelined S>1 path
For S>1 (prefill/verify), split sequence into H0 and H1:
1. ATTN does H0 attention → all_gather
2. ATTN does H1 attention || MOE does H0 MoE (OVERLAP) → all_gather
3. MOE does H1 MoE → all_gather

H0 mask sliced to (mid, offset+mid) columns. H1 mask uses full width
since H0's KV is already cached. GDN layers process H0 then H1
sequentially (recurrent state carries over in cache).

S==1 decode path unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:26:30 +01:00
dmcc73andClaude Opus 4.6 7d0473c418 qwen3_5_moe_split: step 1 eval every 2nd layer, step 2 eval every 2nd layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 19:24:11 +01:00
dmcc73andClaude Opus 4.6 5c5366ce99 qwen3_5_moe_split: first-layer eval uses n_layers modulo
Pass n_layers into make_split_decoder_call so the first-layer eval
fires on every forward pass, not just the very first one. Detected
from the model's layer count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 19:22:48 +01:00
dmcc73andClaude Opus 4.6 5400ac5589 qwen3_5_moe_split: add step 1 eval on first layer only
Step 1: eval MOE_RANK dummy on layer 1 only.
Step 2: eval ATTN_RANK dummy every 2nd layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 19:17:52 +01:00
dmcc73andClaude Opus 4.6 df7f5ae5ea qwen3_5_moe_split: eval only step 2 ATTN_RANK dummy, every 2nd layer
No eval in step 1. Step 2 eval on ATTN_RANK every 2nd layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:49:03 +01:00
dmcc73andClaude Opus 4.6 7aa7a5b97f qwen3_5_moe_split: both step 1 and step 2 eval every 4th layer
Both evals fire on the same layers (layer % 4 == 0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:40:35 +01:00
dmcc73andClaude Opus 4.6 81c81f7bd3 qwen3_5_moe_split: eval step 1 every 4th, step 2 every 4th offset by 2
Step 1 eval on MOE_RANK when layer % 4 == 0.
Step 2 eval on ATTN_RANK when layer % 4 == 2.
Interleaved sync points every 2 layers but alternating between steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:34:30 +01:00
dmcc73andClaude Opus 4.6 32234f85d3 qwen3_5_moe_split: eval every 3rd layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:27:35 +01:00
dmcc73andClaude Opus 4.6 14a5c81068 qwen3_5_moe_split: indent eval into MOE_RANK block
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:24:09 +01:00
dmcc73andClaude Opus 4.6 5a95920a96 qwen3_5_moe_split: 50x layernorm dummy, eval every 2nd layer
Eval on both ranks' h every 2nd layer (before step 1 all_gather).
Step 2 has no eval. 50 dummy layernorms per step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:23:31 +01:00
dmcc73andClaude Opus 4.6 df73e223d2 qwen3_5_moe_split: 1000x layernorm dummy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:14:20 +01:00
dmcc73andClaude Opus 4.6 d05c1b7007 qwen3_5_moe_split: 25x layernorm dummy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:07:43 +01:00
dmcc73andClaude Opus 4.6 00cf3e4317 qwen3_5_moe_split: 50x layernorm dummy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:05:14 +01:00
dmcc73andClaude Opus 4.6 c8009ac4f6 qwen3_5_moe_split: 100x layernorm + residual as dummy work
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:58:15 +01:00
dmcc73andClaude Opus 4.6 dd35ba6ded qwen3_5_moe_split: swap dummy norms to match available weights
MOE_RANK uses post_attention_layernorm (has it), ATTN_RANK uses
input_layernorm (has it). Each rank uses the norm it didn't drop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:56:11 +01:00
dmcc73andClaude Opus 4.6 d351698e1d qwen3_5_moe_split: eval MOE_RANK's dummy layernorm in step 1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:53:36 +01:00
dmcc73andClaude Opus 4.6 600feddbcc qwen3_5_moe_split: all_gather with layernorm dummy, no evals
Step 1: ATTN does attention, MOE does input_layernorm(x) as dummy.
Step 2: MOE does MoE, ATTN does post_attention_layernorm(h) as dummy.
all_gather picks the correct rank's output. No evals — dummy layernorm
keeps graph non-trivial on both sides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:53:05 +01:00
dmcc73andClaude Opus 4.6 b32d2560a9 qwen3_5_moe_split: all_sum with x-x, eval only step 1 zero side
Back to all_sum pattern. MOE_RANK evals its x-x in step 1 (the
known-working config). Step 2 has no eval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:48:38 +01:00
dmcc73andClaude Opus 4.6 1a489a446e qwen3_5_moe_split: remove both ATTN_RANK evals
Only MOE_RANK has evals: eval(h) after recv, async_eval(sent) after send.
ATTN_RANK has zero evals — send + recv are fully lazy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:44:44 +01:00
dmcc73andClaude Opus 4.6 cdb7fe7345 qwen3_5_moe_split: import ATTN_RANK in apply.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:27:14 +01:00
dmcc73andClaude Opus 4.6 6bdb012d56 qwen3_5_moe_split: drop unused weights per rank
ATTN_RANK sets mlp + post_attention_layernorm to None on each layer.
MOE_RANK sets self_attn + linear_attn + input_layernorm to None.
Frees ~50% memory on each machine. embed_tokens, norm, and lm_head
stay on both ranks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:24:50 +01:00
dmcc73andClaude Opus 4.6 361edffe06 opt_batch_gen: keep blocking mx.eval in decode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:03:28 +01:00
dmcc73andClaude Opus 4.6 800bbe9bb3 qwen3_5_moe_split: send/recv split, async sends + eval recvs
Restore proper two-step send/recv split: ATTN does attention → send h
→ recv out, MOE recv h → MoE → send out. async_eval after sends,
blocking eval after recvs. Revert opt_batch_gen.py back to async_eval.
Works on both Qwen3.5-35B-A3B (MoE) and Qwen3.5-27B (dense).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:01:36 +01:00
dmcc73andClaude Opus 4.6 4f0afc5bba qwen3_5_moe_split: attention only on both ranks, no branching
Both ranks run identical attention-only forward. No MoE, no all_sum,
no rank branching. Pure attention speed baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:47:11 +01:00
dmcc73andClaude Opus 4.6 6c99ca5d07 qwen3_5_moe_split: attention only, no MoE, no all_sum (speed baseline)
Comment out MoE and all_sum to measure pure attention-only decode
speed as a ceiling reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:42:50 +01:00
dmcc73andClaude Opus 4.6 4bbcfd4f7a qwen3_5_moe_split: eval MOE_RANK instead of ATTN_RANK
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:39:08 +01:00
dmcc73andClaude Opus 4.6 ec70e50f46 qwen3_5_moe_split: parallel attn+MoE on x, eval ATTN_RANK only
Single step per layer: ATTN does attention on x, MOE does MoE on x,
all_sum * 0.5. Only ATTN_RANK evals its result (the heavier compute).
Gibberish output but tests parallel compute with minimal sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:37:30 +01:00
dmcc73andClaude Opus 4.6 e4a2106252 qwen3_5_moe_split: single mx.eval on MOE_RANK's x-x in step 1 only
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:30:46 +01:00
dmcc73andClaude Opus 4.6 0161c2c203 qwen3_5_moe_split: async_eval(h) on both ranks before step 1 all_sum
De-indented async_eval so both ranks submit their graph before the
all_sum. ATTN_RANK submits the attention graph, MOE_RANK submits
the x-x graph. Both enter all_sum with their work already queued.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:01:44 +01:00
dmcc73andClaude Opus 4.6 ba8c7ebb4f qwen3_5_moe_split: async_eval on step 1 zero side
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:58:22 +01:00
dmcc73andClaude Opus 4.6 8d7edea67b qwen3_5_moe_split: eval x-x only in step 1, not step 2
Single eval per layer: MOE_RANK evals its zeros before step 1 all_sum.
Step 2 has no eval on either side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:54:56 +01:00
dmcc73andClaude Opus 4.6 6ff6c6d078 qwen3_5_moe_split: remove unused layer_idx counter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:52:23 +01:00
dmcc73andClaude Opus 4.6 79d4ec357d qwen3_5_moe_split: eval only the x-x zero side at each step
Back to asymmetric split with x-x zeros. The rank doing trivial
work (x-x) gets mx.eval to force materialization before the all_sum.
The rank doing real compute (attention/MoE) stays lazy. Tests whether
evaling the fast side prevents it from racing ahead into the all_sum.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:50:30 +01:00
dmcc73andClaude Opus 4.6 caed632482 qwen3_5_moe_split: remove ATTN_RANK eval, keep MOE_RANK eval only
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:54:28 +01:00
dmcc73andClaude Opus 4.6 efabd0f8e6 qwen3_5_moe_split: parallel attn+MoE, single all_sum*0.5, per-rank eval
Each rank does its own specialty on x simultaneously: ATTN does
attention, MOE does MoE. Both eval their result, then all_sum * 0.5.
Single all_sum per layer. Output is gibberish but tests whether
parallel compute + single collective works without hanging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:48:55 +01:00
dmcc73andClaude Opus 4.6 a75fa9b340 opt_batch_gen: blocking mx.eval instead of async_eval in decode
Temporary test: use mx.eval in _fast_next instead of mx.async_eval
to see if blocking eval at the end of each decode step prevents the
all_sum hang/queue overflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:39:46 +01:00
dmcc73andClaude Opus 4.6 f0fee1fbdc qwen3_5_moe_split: eval + print inside each rank's compute block
ATTN_RANK evals h after attention, MOE_RANK evals out after MoE.
Each prints its layer index + sum. No eval on the x-x zero side.
Tests whether evaling just the computing rank's result (not the
all_sum) is enough to keep things flowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:31:50 +01:00
dmcc73andClaude Opus 4.6 42cf0b791b qwen3_5_moe_split: eval + print x.sum at each layer entry
Debug: mx.eval(x) and print x.sum at the start of each layer call
to trace where the hang happens and whether ranks stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:27:58 +01:00
dmcc73andClaude Opus 4.6 3eb65f73db qwen3_5_moe_split: remove mx.stream, bare asymmetric all_sum
Plain asymmetric split with no evals, no streams. Baseline for
further experiments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:20:21 +01:00
dmcc73andClaude Opus 4.6 91cd740d20 qwen3_5_moe_split: all_sum on CPU stream, no evals
Place all_sum calls on mx.cpu stream via mx.stream context manager.
Back to asymmetric split (real compute vs x-x zeros). CPU stream
serializes the collectives naturally and may avoid the JACCL queue
overflow (MAX_SEND_WR=32) that causes the -12 ENOMEM crash.
No per-layer evals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:06:03 +01:00
dmcc73andClaude Opus 4.6 527c291942 qwen3_5_moe_split: two MoE calls on MOE_RANK for heavier graph
MOE_RANK runs MoE twice to increase graph weight on the MoE side.
Tests whether more compute on the non-attention side prevents hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:57:47 +01:00
dmcc73andClaude Opus 4.6 1bb4d9c253 qwen3_5_moe_split: single all_sum per layer, no eval
One step per layer: ATTN_RANK does attention on x, MOE_RANK does MoE
on x, both in parallel. Single all_sum combines results. No evals.
Output is gibberish (attn + moe of same input) but tests whether a
single all_sum per layer with comparable work on both sides avoids
the hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:50:53 +01:00
dmcc73andClaude Opus 4.6 4de70d2598 qwen3_5_moe_split: all_gather + eval every 4th layer
Switch back to all_gather with correct slicing, keep eval every 4th
layer. Tests whether all_gather works with periodic sync points and
cross-specialty dummy work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:23:10 +01:00
dmcc73andClaude Opus 4.6 c4461ed80d qwen3_5_moe_split: eval every 4th layer
12 sync points per forward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:20:43 +01:00
dmcc73andClaude Opus 4.6 e6356f67a5 qwen3_5_moe_split: eval every 6th layer
8 sync points per forward instead of 24.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:32:03 +01:00
dmcc73andClaude Opus 4.6 333966a5fb qwen3_5_moe_split: eval every 2nd layer
mx.eval(out) after every 2nd layer call. Tests whether reducing
sync points from 48 to 24 per forward still prevents the hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:28:54 +01:00
dmcc73andClaude Opus 4.6 3379d293d6 qwen3_5_moe_split: all_sum instead of all_gather, cross-specialty dummy
Same layout but all_sum forces both sides' values to be consumed
(no slice = no dead code elimination). Output will be numerically
wrong (sum of both ranks) but tests whether the hang is caused by
all_gather pruning the unused rank's contribution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:19:34 +01:00
dmcc73andClaude Opus 4.6 42c2498a24 qwen3_5_moe_split: step 1 cross-specialty dummy, step 2 same-work dummy
Step 1: ATTN_RANK does real attention, MOE_RANK does MoE on x (dummy).
Step 2: both ranks run MoE, ATTN_RANK skips residual. No cache
corruption risk since attention only runs in step 1 on ATTN_RANK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:10:03 +01:00
dmcc73andClaude Opus 4.6 c1a8675f97 qwen3_5_moe_split: each rank does its own specialty as dummy work
Step 1: ATTN_RANK does real attention, MOE_RANK runs MoE on x (wrong
input but comparable graph weight). Step 2: MOE_RANK does real MoE,
ATTN_RANK runs attention on h (dummy). all_gather picks the correct
rank's output. Each side does work proportional to its real task so
the graphs have comparable weight. No evals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:04:36 +01:00
dmcc73andClaude Opus 4.6 866f900696 qwen3_5_moe_split: rename _moe_out → moe_out
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:57:41 +01:00
dmcc73andClaude Opus 4.6 b07caf6ca2 qwen3_5_moe_split: simpler discard — h = r, out = _moe_out
Non-owning rank just skips the residual add: h = r (not x + r),
out = _moe_out (not h + _moe_out). Same real compute on both sides,
minimal branching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:56:51 +01:00
dmcc73andClaude Opus 4.6 4fd509c5ab qwen3_5_moe_split: both ranks run real attention + MoE, discard wrong half
Both ranks execute the real attention and real MoE to keep graph
structure nearly identical. The non-owning rank discards its result
(doesn't add the residual) and substitutes a lightweight op.
all_gather picks only the correct rank's output. No evals.
Tests whether matched graph weight prevents the hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:55:33 +01:00
dmcc73andClaude Opus 4.6 983ec13fd4 qwen3_5_moe_split: layernorm + residual in dummy loop
Use h = layernorm(h) + h so each iteration produces a different value
and MLX can't constant-fold the chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:49:00 +01:00
dmcc73andClaude Opus 4.6 9af63b6838 qwen3_5_moe_split: 100x layernorm dummy to test timing hypothesis
Non-computing rank runs 100 chained layernorms instead of 1, making
the dummy graph much heavier. Tests whether the hang is caused by the
non-computing rank's graph being too lightweight relative to the
computing rank (JACCL timing out waiting for the fast side).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:47:20 +01:00
dmcc73andClaude Opus 4.6 66273937c4 qwen3_5_moe_split: asymmetric split with all_gather + dummy ops
Back to rank-specific compute: ATTN_RANK runs attention, MOE_RANK runs
MoE. Non-computing rank runs a dummy op (input_layernorm /
post_attention_layernorm) to keep the graph non-trivial on both sides.
all_gather + slice picks only the real rank's output. No evals.
Tests whether all_gather handles asymmetric but non-trivial graphs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:23:45 +01:00
dmcc73andClaude Opus 4.6 665cb76855 qwen3_5_moe_split: all_gather instead of all_sum, symmetric graph
Same symmetric approach (both ranks compute everything) but use
all_gather + slice instead of all_sum * 0.5. Both ranks get identical
results by taking the last rank's copy ([-1:]). No evals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:20:09 +01:00
dmcc73andClaude Opus 4.6 2fc251404e qwen3_5_moe_split: fully symmetric — both ranks compute, all_sum * 0.5
Both ranks run the full decoder (attention + MoE) identically. all_sum
doubles the result (same value from each rank), multiply by 0.5 to
correct. Zero branching, zero per-layer evals, identical graph on both
ranks — exactly like tensor parallel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:14:37 +01:00
dmcc73andClaude Opus 4.6 4ec8b44983 qwen3_5_moe_split: symmetric graphs — both ranks run attention + MoE
Both ranks execute the full decoder layer (attention + MoE) to keep
identical graph structures. The non-owning rank zeros its result with
x-x before the all_sum so only the owning rank's value propagates.
No per-layer evals — fully lazy like tensor parallel. Tests whether
graph symmetry is what makes TP's all_sums work without evals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:12:56 +01:00
dmcc73andClaude Opus 4.6 dea05bfa0b qwen3_5_moe_split: try all_gather instead of all_sum, no evals
Use all_gather + slice to broadcast each step's result. No per-layer
eval or async_eval at all — relies on the end-of-step eval in
opt_batch_gen.py to drive everything. all_gather may handle the
asymmetric graph differently from all_sum.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:11:45 +01:00
dmcc73andClaude Opus 4.6 6684d85c2e qwen3_5_moe_split: x-x instead of zeros_like, no first async_eval
Use x-x and h-h to create zeros with a data dependency on the input
tensor, so the non-computing rank's all_sum graph node depends on the
same tensor as the computing rank. This should prevent MLX from
scheduling the zero side's all_sum before the compute side is ready.
Only the second async_eval remains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:52:04 +01:00
dmcc73andClaude Opus 4.6 bc5d962681 qwen3_5_moe_split: remove first async_eval, keep only second
Only one mx.async_eval remains — after the second all_sum (MoE result).
The first all_sum (attention result) flows lazily into step 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:38:14 +01:00
dmcc73andClaude Opus 4.6 cf97d85f9c qwen3_5_moe_split: async_eval on all_sums
Replace blocking mx.eval with mx.async_eval after each all_sum.
Lets the graph pipeline without blocking Python at each layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:35:31 +01:00
dmcc73andClaude Opus 4.6 c60e499049 qwen3_5_moe_split: use all_sum instead of send/recv
Replace send/recv_like with the all_sum pattern from
run_split_pure_lazy.py: computing rank contributes real tensor,
other rank contributes zeros, all_sum broadcasts to both. Two
all_sum + two mx.eval per layer. Avoids send/recv entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:24:35 +01:00
dmcc73andClaude Opus 4.6 d24cfc94d3 qwen3_5_moe_split: stable post-recv evals only (2 total)
Back to the known-working minimal config: blocking mx.eval after
each recv_like, nothing else. No warmup detection, no async_eval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:23:21 +01:00
dmcc73andClaude Opus 4.6 ec77d84aef qwen3_5_moe_split: recv evals during warmup only, async sends always
During warmup (first ~600 layer calls ≈ 12 forward passes): blocking
mx.eval after each recv to keep ranks in lockstep. After warmup: only
async_eval on sends, no blocking evals — lets the lazy graph pipeline
across layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:19:44 +01:00
dmcc73andClaude Opus 4.6 edc7cc3af9 qwen3_5_moe_split: async_eval sends + blocking eval recvs
Post-send: mx.async_eval (non-blocking, submits graph to GPU).
Post-recv: mx.eval (blocking, materializes the received tensor
before consuming it). 4 total: 2 async + 2 blocking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:15:30 +01:00
dmcc73andClaude Opus 4.6 c3ba91f4f5 qwen3_5_moe_split: async_eval post-send only (2 total)
Replace all evals with mx.async_eval after each send. No blocking
evals — Python races ahead while the GPU processes the lazy graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:56:15 +01:00
dmcc73andClaude Opus 4.6 99559871b8 qwen3_5_moe_split: only post-recv evals (2 total)
Keep only mx.eval(out) after rank 0's recv_like and mx.eval(h) after
rank 1's recv_like. Post-send evals removed. Testing minimal eval set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:51:42 +01:00
dmcc73andClaude Opus 4.6 b9f03df2ee qwen3_5_moe_split: post-send + post-recv evals (4 total)
Add post-recv evals back: mx.eval(out) after rank 0's recv_like,
mx.eval(h) after rank 1's recv_like. Now 4 evals: post-send and
post-recv on each rank.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:45:58 +01:00
dmcc73andClaude Opus 4.6 191a187023 qwen3_5_moe_split: only post-send evals (2 total)
Keep only mx.eval(h) after rank 0's send and mx.eval(sent) after
rank 1's send. All other evals removed. Testing whether the two
post-send evals alone are sufficient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:38:54 +01:00
dmcc73andClaude Opus 4.6 8bed56e94b qwen3_5_moe_split: remove post-send evals (#2 and #7)
Drop mx.eval after each send — the recv eval on the other rank
pulls the send through the graph. Remaining 5 evals: pre-send +
post-recv on rank 0, pre-recv + post-recv + pre-send on rank 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:33:50 +01:00
dmcc73andClaude Opus 4.6 8b9edb480c qwen3_5_moe_split: restore all evals (known-working baseline)
Back to the full-eval version that worked on both TCP and RDMA:
6 evals per layer (pre-send, post-send, post-recv on each rank).
Will remove one at a time from here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:31:04 +01:00
dmcc73andClaude Opus 4.6 86f269d46f qwen3_5_moe_split: remove duplicate return
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:20:51 +01:00
dmcc73andClaude Opus 4.6 dac3353fcf qwen3_5_moe_split: eval after recv instead of after send
Two evals per layer: mx.eval(out) after rank 0's recv_like,
mx.eval(h) after rank 1's recv_like. Sends are left lazy —
the recv eval on the other side forces the graph including the
matching send.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:19:37 +01:00
dmcc73andClaude Opus 4.6 27ac78704d qwen3_5_moe_split: eval only after each send
Two evals total per layer: mx.eval(h) after rank 0's send, mx.eval(sent)
after rank 1's send. No eval on recv side — let the recv stay lazy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:07:46 +01:00
dmcc73andClaude Opus 4.6 32d23d8b08 qwen3_5_moe_split: remove post-send, pre-recv, and pre-recv evals
Drop mx.eval at lines 45 (post-send on rank 0), 47 (post-recv on
rank 0), and 50 (pre-recv on rank 1). Lets the send→recv chain stay
in the lazy graph with only the final evals remaining: post-recv on
rank 0 (line 46) and post-send on rank 1 (line 53).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:55:11 +01:00
dmcc73andClaude Opus 4.6 2c020f26c9 qwen3_5_moe_split: remove pre-send evals (lines 44, 55)
Drop the mx.eval before each mx.distributed.send — lets the send
consume the lazy graph directly instead of forcing a GPU sync first.
Keeps the post-send and post-recv evals so the distributed op still
gets its own command buffer boundary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:35:56 +01:00
dmcc73andClaude Opus 4.6 e33e1c99ff qwen3_5_moe_split: no-op cache batching methods on rank 1
Continuous batching on exo calls cache.extract / filter / extend to
slice per-sample state. Rank 1 never populates caches, so each method
hits None subscripts. Patch ArraysCache / BatchKVCache /
BatchRotatingKVCache extract/filter/extend on rank 1 to short-circuit
when the cache is still uninitialised.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:25:46 +01:00
dmcc73andClaude Opus 4.6 b40f3e7457 qwen3_5_moe_split: also patch BatchKVCache / BatchRotatingKVCache on rank 1
The exo runtime path for real requests uses BatchGenerator which builds
BatchKVCache instances, not the KVCache used by warmup. Both crash at
`k.shape[2]` when keys is None (never populated on rank 1, the MoE
half). Patch BatchKVCache.state and BatchRotatingKVCache.state on rank 1
with the same zero-length placeholder idiom already used for KVCache and
ArraysCache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:20:26 +01:00
dmcc73andClaude Opus 4.6 722a351286 qwen3_5_moe_split: patch KVCache/ArraysCache on rank 1
Rank 1 (MoE) never runs attention, so its per-layer KVCache.keys and
ArraysCache entries stay as their initial None sentinels. mlx_lm.generate
calls mx.eval([c.state for c in prompt_cache]) once per step, which
invokes KVCache.state and crashes on `self.keys.shape[2]`
(AttributeError: 'NoneType' object has no attribute 'shape'). Patch the
state property on rank 1 only to return zero-length placeholder arrays
whose eval is a no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:12:00 +01:00
dmcc73andClaude Opus 4.6 b4db9eff62 Add AttnMoeSplit sharding mode for Qwen3.5 MoE
Cuts each Qwen3.5 MoE decoder layer across two ranks: rank 0 runs
input_layernorm + attention + first residual, rank 1 runs
post_attention_layernorm + MoE + second residual, with one cross-rank
send/recv pair per layer. Mirrors auto_parallel's PipelineFirstLayer /
PipelineLastLayer mx.eval idiom around every distributed op so the
send/recv stays on its own Metal command buffer and avoids GPU timeout.
No mx.depends cache anchoring in v1.

Plumbing:
- Sharding.AttnMoeSplit enum + AttnMoeSplitShardMetadata class
- get_shard_assignments_for_attn_moe_split (both ranks own [0, n_layers))
- Validator in master/placement.py: requires Qwen3.5 model + 2-node cycle,
  rejects single-node downgrade
- New patches/qwen3_5_moe_split/{apply,decoder}.py replaces
  DecoderLayer.__call__ at class level. Invoked from
  attn_moe_split_auto_parallel in auto_parallel.py, dispatched from
  utils_mlx.shard_and_load.
- Preview enumeration in api/main.py:477 now includes AttnMoeSplit
- Dashboard: PlacementPreview.sharding union extended; "Attn/MoE Split"
  button added to Advanced Options sharding picker

Phase 1a only: vanilla decoder split (no fused GDN / batched MoE under
this mode). maybe_apply_patches is only called in single-device mode so
the fused qwen3_5_moe patches do not collide with the split.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:07:58 +01:00
dmcc73andClaude Opus 4.6 051d60059e DFlash warmup: full S_ctx sweep to pre-compile every drafter kernel
Old warmup ran 3 draft+verify cycles at one (BS, V) combo, but only
hit S_ctx ∈ {1, V+1} — missing every intermediate value. That left
drafter fc / k_proj / v_proj uncompiled for S_ctx ∈ {2..V}, so their
kernels JIT-compiled during the first few real generation cycles and
inflated draft time by 15–25%.

New warmup explicitly sweeps S_ctx = 1..V+1 against the drafter with
a fresh draft KV cache per iteration, then runs one target verify at
M=V+1 for the target-side projections. Measured on bf16 27B with a
thinking prompt: draft ms/cycle drops 16.7→12.8 at BS=16 V=13 and
11.0→9.1 at BS=6 V=5.

Warmup is structured around a `modes` list so dynamic (BS, V)
switching at runtime won't hit a compilation stall on mode flip —
add the extra modes to `modes` and every kernel they need is
compiled up front.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:35:10 +01:00
dmcc73andClaude Opus 4.6 5072d73167 Add Qwen3.5-27B bf16 model card
Enables launching the bf16 target from the dashboard so the
thinking/no-thinking toggle is exposed for DFlash benchmarking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:44:01 +01:00
dmcc73andClaude Opus 4.6 c1f46bfa48 DFlash: dynamic matmul kernel picker + bf16 27B support
Ports the matmul kernel suite and dynamic picker from mlx_bench into exo
so both the DFlash drafter and the Qwen3.5-27B target can select the
fastest correct kernel at call time based on the actual M seen in the
forward pass.

- New matmul/ package: bf16 (lpb, lpb_twice, bm8, splitk_steel) and
  int8 (lpb, bm8, bm16, splitk) kernels plus Steel headers bundle
- kernel_picker.{pick_bf16,pick_int8}_kernel memoizes per projection and
  force-routes N>50000 (lm_head) away from the broken sk_steel path
- speculative/bf16_lpb_patch and patches/qwen3_5/lpb_patch rewritten to
  auto-detect nn.Linear vs nn.QuantizedLinear so the same target patch
  covers both bf16 and 8-bit Qwen3.5-27B; target patch now also wraps
  lm_head
- warmup_dflash runs three full draft+verify cycles at the real
  (block_size, verify_len) so every M the runtime hits is compiled once
  at startup instead of during the first generation step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:43:49 +01:00
dmcc73 b7cb2c563c Remove debug print from step() 2026-04-02 18:28:36 +01:00
dmcc73 9eddedf149 Add DFlash kernel warmup at startup 2026-04-02 18:26:48 +01:00
dmcc73andClaude Opus 4.6 1d33e80034 Add DFlash speculative decoding mode (EXO_SPECULATIVE_MODE=dflash)
DFlash drafts all tokens in one parallel pass through a 5-layer
bidirectional transformer, conditioned on target model hidden states.
60 TPS in mlx_bench (2.6x over baseline).

New files:
- speculative/dflash_module.py: DFlashDrafter (loads z-lab/Qwen3.5-27B-DFlash)
- speculative/dflash_speculative.py: dflash_speculative_forward with GDN rollback
- speculative/dflash_batch_generator.py: DFlashBatchGenerator (BS=1 speculative)
- speculative/bf16_lpb_patch.py: LpB patches for draft model projections
- patches/qwen3_5/custom_bf16_*.py: bf16 Loop-over-B GEMV kernels

Env vars:
  EXO_SPECULATIVE_MODE=dflash  Select DFlash (default: mtp)
  EXO_DFLASH_MODEL=...         DFlash model path (default: z-lab/Qwen3.5-27B-DFlash)
  EXO_DFLASH_VERIFY=5          Draft tokens to verify per cycle
  EXO_DFLASH_BLOCK_SIZE=6      Draft model block size

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:19:56 +01:00
dmcc73andClaude Opus 4.6 e262cd0998 Fix: disable opt_batch_gen fast path when speculative is enabled
The opt_batch_gen patch replaces BatchGenerator.next() with _fast_next()
which bypasses _next() entirely. Since MTPBatchGenerator overrides _next()
for speculative decoding, the fast path causes speculative to never fire.

Skip the patch when EXO_SPECULATIVE=1. The speculative path doesn't need
the fast_next optimization (it has its own optimized decode loop), and
EXO_DISABLE_LOGPROBS=1 already handles skipping logprobs extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:45:00 +01:00
dmcc73andClaude Opus 4.6 1a94972b23 MTP speculative decoding + kernel patches for Qwen3.5 (clean port to main)
Clean port from david/batched-kernels onto current main. Includes:

1. MTP Speculative Decoding (EXO_SPECULATIVE=1):
   - MTPBatchGenerator subclasses BatchGenerator for BS=1 speculative
   - Auto-extracts MTP weights from HuggingFace repos
   - Rejection sampling for T>0 with configurable alpha
   - Kernel warmup at startup (pre-compiles LpB custom Metal kernels)
   - EXO_DISABLE_LOGPROBS=1 to skip expensive logprobs extraction

2. Kernel Patches:
   - Qwen3.5 dense (27B, 9B): Loop-over-B GEMV patches (LpB)
   - Qwen3.5 MoE (35B, 397B): Batched fused Metal kernels
   - Auto-detected via model_type in config.json

3. Integration:
   - ExoBatchGenerator conditionally creates MTPBatchGenerator
   - MTP prefill in submit() captures prompt hidden states
   - maybe_apply_patches() called after model load

Env vars:
   EXO_SPECULATIVE=1          Enable MTP speculative
   EXO_SPECULATIVE_GAMMA=3    Draft tokens per cycle
   EXO_SPECULATIVE_TEMP=0.7   Sampling temperature
   EXO_SPECULATIVE_ALPHA=1.0  Rejection sampling strictness
   EXO_MTP_WEIGHTS=/path      Explicit MTP weights path
   EXO_DISABLE_LOGPROBS=1     Skip logprobs extraction
   EXO_FUSED_KERNELS=0        Disable kernel patches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:07:46 +01:00
71 changed files with 12257 additions and 22 deletions

No files matched your search

+1 -1
View File
@@ -167,7 +167,7 @@ export interface ModelDownloadStatus {
// Placement preview from the API
export interface PlacementPreview {
model_id: string;
sharding: "Pipeline" | "Tensor";
sharding: "Pipeline" | "Tensor" | "AttnMoeSplit";
instance_meta: "MlxRing" | "MlxJaccl";
instance: unknown | null;
memory_delta_by_node: Record<string, number> | null;
+28 -2
View File
@@ -885,14 +885,16 @@
sendMessage(content, files, thinkingEnabled());
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
let selectedSharding = $state<"Pipeline" | "Tensor" | "AttnMoeSplit">(
"Pipeline",
);
type InstanceMeta = "MlxRing" | "MlxJaccl";
// Launch defaults persistence
const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults-v2";
interface LaunchDefaults {
modelId: string | null;
sharding: "Pipeline" | "Tensor";
sharding: "Pipeline" | "Tensor" | "AttnMoeSplit";
instanceType: InstanceMeta;
minNodes: number;
}
@@ -5759,6 +5761,30 @@
</span>
Tensor
</button>
<button
onclick={() => {
selectedSharding = "AttnMoeSplit";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'AttnMoeSplit'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
title="Qwen3.5 MoE only, 2 nodes: rank 0 runs attention, rank 1 runs MoE"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'AttnMoeSplit'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "AttnMoeSplit"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
Attn/MoE Split
</button>
</div>
</div>
@@ -0,0 +1,15 @@
model_id = "mlx-community/Qwen3.5-27B-bf16"
n_layers = 64
hidden_size = 5120
num_key_value_heads = 4
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "bf16"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking", "thinking_toggle", "vision"]
context_length = 262144
[storage_size]
in_bytes = 54760833024
+1 -1
View File
@@ -474,7 +474,7 @@ class API:
status_code=400, detail=f"Failed to load model card: {exc}"
) from exc
instance_combinations: list[tuple[Sharding, InstanceMeta, int]] = []
for sharding in (Sharding.Pipeline, Sharding.Tensor):
for sharding in (Sharding.Pipeline, Sharding.Tensor, Sharding.AttnMoeSplit):
for instance_meta in (InstanceMeta.MlxRing, InstanceMeta.MlxJaccl):
instance_combinations.extend(
[
+23 -1
View File
@@ -153,6 +153,20 @@ def place_instance(
raise ValueError(
"Pipeline parallelism is not supported for DeepSeek V3.1 (8-bit)"
)
if command.sharding == Sharding.AttnMoeSplit:
model_id_lower = str(command.model_card.model_id).lower()
if "qwen3.5" not in model_id_lower and "qwen-3.5" not in model_id_lower:
raise ValueError(
"AttnMoeSplit sharding is only supported for Qwen3.5 MoE models, "
f"got {command.model_card.model_id}"
)
cycles_with_sufficient_memory = [
cycle for cycle in cycles_with_sufficient_memory if len(cycle) == 2
]
if not cycles_with_sufficient_memory:
raise ValueError(
"AttnMoeSplit sharding requires exactly 2 nodes in the cycle"
)
smallest_cycles = get_smallest_cycles(cycles_with_sufficient_memory)
@@ -191,8 +205,16 @@ def place_instance(
),
)
# Single-node: force Pipeline/Ring (Tensor and Jaccl require multi-node)
# Single-node: force Pipeline/Ring (Tensor and Jaccl require multi-node).
# AttnMoeSplit was already filtered to exactly-2-node cycles above, so
# this branch can never fire for AttnMoeSplit — if it somehow did we'd
# silently rewrite the sharding mode, which is the wrong behavior.
if len(selected_cycle) == 1:
if command.sharding == Sharding.AttnMoeSplit:
raise ValueError(
"AttnMoeSplit sharding requires 2 nodes but a single-node cycle "
"was selected"
)
command.instance_meta = InstanceMeta.MlxRing
command.sharding = Sharding.Pipeline
+44
View File
@@ -10,6 +10,7 @@ from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
from exo.shared.types.topology import Cycle, RDMAConnection, SocketConnection
from exo.shared.types.worker.runners import RunnerId, ShardAssignments
from exo.shared.types.worker.shards import (
AttnMoeSplitShardMetadata,
CfgShardMetadata,
PipelineShardMetadata,
Sharding,
@@ -273,6 +274,44 @@ def get_shard_assignments_for_tensor_parallel(
return shard_assignments
def get_shard_assignments_for_attn_moe_split(
model_card: ModelCard,
cycle: Cycle,
) -> ShardAssignments:
"""Create shard assignments for attention/MoE split execution.
Both ranks own the full layer range [0, n_layers). device_rank=0 runs
attention, device_rank=1 runs MoE. Requires exactly 2 nodes.
"""
if len(cycle) != 2:
raise ValueError(
f"AttnMoeSplit sharding requires exactly 2 nodes, got {len(cycle)}"
)
total_layers = model_card.n_layers
world_size = 2
runner_to_shard: dict[RunnerId, ShardMetadata] = {}
node_to_runner: dict[NodeId, RunnerId] = {}
for i, node_id in enumerate(cycle):
shard = AttnMoeSplitShardMetadata(
model_card=model_card,
device_rank=i,
world_size=world_size,
start_layer=0,
end_layer=total_layers,
n_layers=total_layers,
)
runner_id = RunnerId()
runner_to_shard[runner_id] = shard
node_to_runner[node_id] = runner_id
return ShardAssignments(
model_id=model_card.model_id,
runner_to_shard=runner_to_shard,
node_to_runner=node_to_runner,
)
def get_shard_assignments(
model_card: ModelCard,
cycle: Cycle,
@@ -291,6 +330,11 @@ def get_shard_assignments(
model_card=model_card,
cycle=cycle,
)
case Sharding.AttnMoeSplit:
return get_shard_assignments_for_attn_moe_split(
model_card=model_card,
cycle=cycle,
)
def get_mlx_jaccl_devices_matrix(
+16 -1
View File
@@ -10,6 +10,7 @@ from exo.utils.pydantic_ext import TaggedModel
class Sharding(str, Enum):
Tensor = "Tensor"
Pipeline = "Pipeline"
AttnMoeSplit = "AttnMoeSplit"
class BaseShardMetadata(TaggedModel):
@@ -79,6 +80,20 @@ class TensorShardMetadata(BaseShardMetadata):
pass
@final
class AttnMoeSplitShardMetadata(BaseShardMetadata):
"""Attention/MoE split shard meta.
Both ranks own the full layer range [0, n_layers). device_rank=0 runs
per-layer attention + first residual; device_rank=1 runs
post_attention_layernorm + MoE + second residual. One cross-rank send/recv
pair per layer ferries the hidden state. world_size must be 2.
"""
ShardMetadata: TypeAlias = (
PipelineShardMetadata | CfgShardMetadata | TensorShardMetadata
PipelineShardMetadata
| CfgShardMetadata
| TensorShardMetadata
| AttnMoeSplitShardMetadata
)
+46 -1
View File
@@ -57,7 +57,10 @@ from mlx_lm.models.step3p5 import Model as Step35Model
from mlx_lm.models.step3p5 import Step3p5MLP as Step35MLP
from mlx_lm.models.step3p5 import Step3p5Model as Step35InnerModel
from exo.shared.types.worker.shards import PipelineShardMetadata
from exo.shared.types.worker.shards import (
AttnMoeSplitShardMetadata,
PipelineShardMetadata,
)
from exo.worker.runner.bootstrap import logger
if TYPE_CHECKING:
@@ -430,6 +433,48 @@ def pipeline_auto_parallel(
return patch_pipeline_model(model, group)
def attn_moe_split_auto_parallel(
model: nn.Module,
group: mx.distributed.Group,
model_shard_meta: AttnMoeSplitShardMetadata,
on_layer_loaded: LayerLoadedCallback | None,
) -> nn.Module:
"""Install the attention/MoE split on a Qwen3.5 MoE model.
Both ranks own the full layer range (start_layer=0, end_layer=n_layers).
No layer wrapping: the split is installed via class-level
DecoderLayer.__call__ replacement in
patches/qwen3_5_moe_split/apply.py.
"""
if group.size() != 2:
raise ValueError(
f"AttnMoeSplit requires world_size==2, got {group.size()}"
)
# Qwen3.5 MoE uses Qwen3_5TextModelInner. Any other model type landing
# here is a placement bug.
inner_model_instance: nn.Module = get_inner_model(model)
if not isinstance(inner_model_instance, Qwen3_5TextModelInner):
raise ValueError(
"AttnMoeSplit sharding is only implemented for Qwen3.5 MoE models, "
f"got inner model of type {type(inner_model_instance).__name__}"
)
layers = get_layers(inner_model_instance)
total = len(layers)
for i, layer in enumerate(layers):
mx.eval(layer) # type: ignore
if on_layer_loaded is not None:
on_layer_loaded(i, total)
from exo.worker.engines.mlx.patches.qwen3_5_moe_split.apply import (
apply_qwen35_attn_moe_split_patches,
)
apply_qwen35_attn_moe_split_patches(model, group)
return model
def patch_pipeline_model[T](model: T, group: mx.distributed.Group) -> T:
# Patch __call__ on the model's class
cls = model.__class__
@@ -1,4 +1,5 @@
import contextlib
import os
import time
from dataclasses import dataclass, field
from typing import Callable, cast
@@ -96,13 +97,392 @@ class ExoBatchGenerator:
_active_tasks: dict[int, _EngineTask] = field(default_factory=dict, init=False)
def __post_init__(self) -> None:
self._mlx_gen = MlxBatchGenerator(
model=self.model,
stop_tokens=set(eos_ids_from_tokenizer(self.tokenizer)),
prefill_step_size=4096,
)
use_speculative = os.environ.get("EXO_SPECULATIVE", "0") == "1"
spec_mode = os.environ.get("EXO_SPECULATIVE_MODE", "mtp")
stop_tokens = set(eos_ids_from_tokenizer(self.tokenizer))
temp = float(os.environ.get("EXO_SPECULATIVE_TEMP", "0.7"))
alpha = float(os.environ.get("EXO_SPECULATIVE_ALPHA", "1.0"))
if use_speculative and spec_mode == "dflash":
try:
from exo.worker.engines.mlx.speculative.dflash_module import DFlashDrafter
from exo.worker.engines.mlx.speculative.dflash_batch_generator import DFlashBatchGenerator
from exo.worker.engines.mlx.speculative.bf16_lpb_patch import apply_bf16_lpb_patches
dflash_path = os.environ.get("EXO_DFLASH_MODEL", "z-lab/Qwen3.5-27B-DFlash")
verify_len = int(os.environ.get("EXO_DFLASH_VERIFY", "5"))
block_size = int(os.environ.get("EXO_DFLASH_BLOCK_SIZE", "6"))
drafter = DFlashDrafter(self.model, dflash_path)
apply_bf16_lpb_patches(drafter)
self._mlx_gen = DFlashBatchGenerator(
model=self.model,
drafter=drafter,
verify_len=verify_len,
block_size=block_size,
temp=temp,
alpha=alpha,
stop_tokens=stop_tokens,
prefill_step_size=4096,
)
logger.info(f"DFlash speculative decoding enabled (V={verify_len}, BS={block_size}, T={temp})")
self.warmup_dflash(self.model, self.tokenizer, drafter)
except Exception as e:
logger.warning(f"Failed to init DFlash speculative: {e}. Falling back.")
self._mlx_gen = MlxBatchGenerator(model=self.model, stop_tokens=stop_tokens, prefill_step_size=4096)
elif use_speculative and spec_mode == "mtp":
try:
from exo.worker.engines.mlx.speculative.mtp_module import MTPPredictor
from exo.worker.engines.mlx.speculative.mtp_batch_generator import MTPBatchGenerator
mtp_weights = self._resolve_mtp_weights()
gamma = int(os.environ.get("EXO_SPECULATIVE_GAMMA", "2"))
if mtp_weights:
mtp = MTPPredictor(self.model, mtp_weights, quantize=False)
self._mlx_gen = MTPBatchGenerator(
model=self.model,
mtp_predictor=mtp,
gamma=gamma,
temp=temp,
alpha=alpha,
stop_tokens=stop_tokens,
prefill_step_size=4096,
)
logger.info(f"MTP speculative decoding enabled (γ={gamma}, T={temp})")
self.warmup_speculative(self.model, self.tokenizer)
else:
logger.warning("EXO_SPECULATIVE=1 but could not find MTP weights. Falling back.")
self._mlx_gen = MlxBatchGenerator(model=self.model, stop_tokens=stop_tokens, prefill_step_size=4096)
except Exception as e:
logger.warning(f"Failed to init MTP speculative: {e}. Falling back.")
self._mlx_gen = MlxBatchGenerator(model=self.model, stop_tokens=stop_tokens, prefill_step_size=4096)
else:
self._mlx_gen = MlxBatchGenerator(model=self.model, stop_tokens=stop_tokens, prefill_step_size=4096)
self._mlx_gen._needs_topk = False # pyright: ignore[reportAttributeAccessIssue]
def _resolve_mtp_weights(self) -> str | None:
"""Find MTP weights: explicit path, explicit HF model, or auto-extract."""
explicit_path = os.environ.get("EXO_MTP_WEIGHTS", "")
if explicit_path and os.path.exists(explicit_path):
return explicit_path
mtp_model = os.environ.get("EXO_MTP_MODEL", "")
if not mtp_model:
try:
inner = getattr(self.model, 'model', None) or self.model.language_model.model
args = getattr(inner, 'args', None)
if args and getattr(args, 'mtp_num_hidden_layers', 0) > 0:
model_type = getattr(args, 'model_type', '')
if 'qwen3_5' in model_type or 'qwen3.5' in str(type(self.model).__module__):
mtp_model = "Qwen/Qwen3.5-27B"
logger.info(f"Auto-detected MTP model: {mtp_model}")
except Exception:
pass
if not mtp_model:
return None
try:
return self._extract_mtp_from_hf(mtp_model)
except Exception as e:
logger.warning(f"Failed to extract MTP weights from {mtp_model}: {e}")
return None
def _extract_mtp_from_hf(self, repo_id: str) -> str:
"""Download MTP tensors from HF repo and cache as a single safetensors file.
Three-tier strategy, smallest download first:
1. Index-aware byte-range fetch: read the safetensors index, find
MTP-bearing shards, then for each shard read only its header
(small JSON describing tensor byte offsets) and range-fetch
just the bytes belonging to MTP tensors. For Qwen/Qwen3.5-27B
this is ~500 MB instead of 55 GB.
2. Shard download (fallback if byte-range fails): pull only
MTP-bearing shards (~20 GB).
3. Full download (fallback if no index): pull every safetensors
file (~55 GB).
Both 'model.mtp.' and 'mtp.' tensor-key prefixes are accepted —
Qwen/Qwen3.5-27B's repo uses the bare 'mtp.' prefix; some variants
may use 'model.mtp.'.
"""
import hashlib
import json as _json
from pathlib import Path
from huggingface_hub import hf_hub_download, snapshot_download
from safetensors.torch import load_file, save_file
cache_dir = Path.home() / ".cache" / "exo" / "mtp_weights"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_key = hashlib.md5(repo_id.encode()).hexdigest()[:12]
# v2: preserve the 'mtp.' prefix in cached keys (MTPPredictor expects it).
# Older caches (mtp_<key>.safetensors) had the prefix stripped — invalidate
# them by using a new filename.
cached_path = cache_dir / f"mtp_v2_{cache_key}.safetensors"
if cached_path.exists():
logger.info(f"Using cached MTP weights: {cached_path}")
return str(cached_path)
def _is_mtp_key(k: str) -> bool:
return k.startswith("model.mtp.") or k.startswith("mtp.")
def _normalize_mtp_key(k: str) -> str:
# MTPPredictor._load_weights expects keys with the 'mtp.' prefix
# preserved. Strip the optional 'model.' wrapper only.
if k.startswith("model.mtp."):
return k[len("model."):]
return k
def _save_and_return(mtp_tensors: dict) -> str:
save_file(mtp_tensors, str(cached_path))
logger.info(f"Cached {len(mtp_tensors)} MTP tensors to {cached_path}")
return str(cached_path)
# ── Tier 1: read index, identify MTP shards
try:
index_path = hf_hub_download(repo_id, "model.safetensors.index.json")
with open(index_path) as f:
index = _json.load(f)
weight_map = index.get("weight_map", {})
mtp_shards = sorted(
{shard for name, shard in weight_map.items() if _is_mtp_key(name)}
)
if not mtp_shards:
raise ValueError(
f"No keys with prefix 'model.mtp.' or 'mtp.' in "
f"{repo_id}'s weight_map"
)
total_shards = len(set(weight_map.values()))
logger.info(
f"MTP head spans {len(mtp_shards)}/{total_shards} shard(s) of "
f"{repo_id}: {mtp_shards}"
)
except Exception as e:
logger.warning(
f"Couldn't read sharded index from {repo_id} ({e}); "
f"falling back to full safetensors download"
)
model_dir = snapshot_download(
repo_id, allow_patterns=["*.safetensors", "*.json"]
)
mtp_tensors = {}
for sf_file in sorted(Path(model_dir).glob("*.safetensors")):
tensors = load_file(str(sf_file))
for k, v in tensors.items():
if _is_mtp_key(k):
mtp_tensors[_normalize_mtp_key(k)] = v
if not mtp_tensors:
raise ValueError(f"No MTP tensors found in {repo_id}")
return _save_and_return(mtp_tensors)
# ── Tier 1 continued: byte-range fetch only the MTP tensors
try:
from huggingface_hub import HfFileSystem
import torch
ST_DTYPE_TO_TORCH = {
"BF16": torch.bfloat16,
"F16": torch.float16,
"F32": torch.float32,
"F64": torch.float64,
"I8": torch.int8,
"I16": torch.int16,
"I32": torch.int32,
"I64": torch.int64,
"U8": torch.uint8,
"BOOL": torch.bool,
}
fs = HfFileSystem()
mtp_tensors = {}
total_bytes = 0
for shard in mtp_shards:
with fs.open(f"{repo_id}/{shard}", mode="rb") as f:
header_size = int.from_bytes(f.read(8), "little")
header = _json.loads(f.read(header_size).decode("utf-8"))
data_start = 8 + header_size
in_shard = [
(name, meta)
for name, meta in header.items()
if name != "__metadata__" and _is_mtp_key(name)
]
logger.info(
f" {shard}: extracting {len(in_shard)} MTP tensor(s)"
)
for name, meta in in_shard:
start, end = meta["data_offsets"]
f.seek(data_start + start)
raw = f.read(end - start)
dtype = ST_DTYPE_TO_TORCH.get(meta["dtype"])
if dtype is None:
raise ValueError(
f"Unsupported safetensors dtype {meta['dtype']} "
f"for {name}"
)
t = torch.frombuffer(
bytearray(raw), dtype=dtype
).reshape(meta["shape"])
mtp_tensors[_normalize_mtp_key(name)] = t
total_bytes += len(raw)
if not mtp_tensors:
raise ValueError("Byte-range read returned no MTP tensors")
logger.info(
f"Byte-range fetched {len(mtp_tensors)} MTP tensors "
f"({total_bytes / 1e6:.1f} MB) from {len(mtp_shards)} shards"
)
return _save_and_return(mtp_tensors)
except Exception as e:
logger.warning(
f"Byte-range fetch failed ({e}); "
f"falling back to MTP-shard snapshot download"
)
# ── Tier 2: download just the MTP-bearing shards
model_dir = snapshot_download(
repo_id, allow_patterns=list(mtp_shards) + ["*.json"]
)
mtp_tensors = {}
for sf_file in sorted(Path(model_dir).glob("*.safetensors")):
tensors = load_file(str(sf_file))
for k, v in tensors.items():
if _is_mtp_key(k):
mtp_tensors[_normalize_mtp_key(k)] = v
if not mtp_tensors:
raise ValueError(f"No MTP tensors found in {repo_id} (shard fallback)")
return _save_and_return(mtp_tensors)
def warmup_speculative(self, model, tokenizer) -> None:
"""Warm up the speculative decoding path (MTP draft + verify kernels)."""
if not hasattr(self._mlx_gen, 'mtp'):
return
from mlx_lm.models import cache as cache_mod
from exo.worker.engines.mlx.speculative.mtp_module import speculative_forward, draft_tokens
logger.info("Warming up speculative decoding kernels...")
mtp = self._mlx_gen.mtp
gamma = self._mlx_gen.gamma
warmup_prompt = tokenizer.encode("Warm up speculative decoding.")
cache = cache_mod.make_prompt_cache(model)
mtp.reset_cache()
pre_norm, logits = speculative_forward(model, mx.array([warmup_prompt]), cache)
mx.eval(pre_norm, logits)
next_token = mx.argmax(logits[0, -1], axis=-1).item()
if pre_norm.shape[1] > 1:
_ = mtp.predict(pre_norm[:, :-1, :], mx.array([warmup_prompt[1:]]))
mx.eval(_)
last_pn = pre_norm[:, -1:, :]
next_arr = mx.array([[next_token]])
for _ in range(3):
draft_ids, _ = draft_tokens(mtp, last_pn, next_arr, gamma, 0.0)
draft_concat = mx.concatenate([d.reshape(1, 1) for d in draft_ids], axis=1)
verify_input = mx.concatenate([next_arr, draft_concat], axis=1)
vpn, vl = speculative_forward(model, verify_input, cache, speculative=True)
all_next = mx.argmax(vl[0], axis=-1)
mx.eval(vpn, all_next)
next_arr = all_next[0].reshape(1, 1)
last_pn = vpn[:, 0:1, :]
for i, c in enumerate(cache):
if hasattr(c, 'base'):
cache[i] = c.base
logger.info("Speculative warmup complete")
def warmup_dflash(self, model, tokenizer, drafter) -> None:
"""Warm up the DFlash speculative decoding path.
With the dynamic kernel picker every projection memoizes kernels
keyed by the actual M seen in its forward. At runtime the M set
each call-site can hit is:
target q/k/v/o, gate/up/down, in_proj_*, lm_head: M = V+1
drafter q/o/mlp_*: M = BS
drafter fc: M = S_ctx ∈ [1, V+1]
drafter k_proj/v_proj: M = BS + S_ctx ∈ [BS+1, BS+V+1]
drafter lm_head: M = BS - 1
To compile every one of those kernels up front we sweep
S_ctx = 1..V+1 against the drafter (calling draft() with
fresh caches and different target-hidden slice lengths),
then run a single target verify at M=V+1.
`modes` is a list of (block_size, verify_len) pairs; warmup is
run once per mode so dynamic-switching configurations can jump
between modes at runtime without triggering Metal compilation
stalls.
"""
from mlx_lm.models import cache as cache_mod
from exo.worker.engines.mlx.speculative.dflash_speculative import dflash_speculative_forward
logger.info("Warming up DFlash speculative decoding kernels...")
original_bs = drafter.block_size
verify_len = self._mlx_gen.verify_len # pyright: ignore[reportAttributeAccessIssue]
modes = [(original_bs, verify_len)]
max_v = max(v for _, v in modes)
ctx_len = max(64, max_v + 2)
prompt_tokens = [1] * ctx_len
cache = cache_mod.make_prompt_cache(model)
drafter.reset_draft_cache()
target_hidden_full, _, logits = dflash_speculative_forward(
model, mx.array([prompt_tokens]), cache, drafter.target_layer_ids)
mx.eval(target_hidden_full, logits)
next_token = mx.argmax(logits[0, -1], axis=-1).item()
try:
for m_bs, m_v in modes:
drafter.block_size = m_bs
# 1. Sweep S_ctx ∈ [1, V+1] so the drafter's fc, k_proj,
# v_proj compile every kernel the picker may return.
# Each iteration rebuilds the draft KV cache, so the
# fc/k/v projections see exactly s_ctx / s_ctx+m_bs.
for s_ctx in range(1, m_v + 2):
drafter.reset_draft_cache()
last_th = target_hidden_full[:, -s_ctx:, :]
block_ids = mx.full(
(1, m_bs), drafter.mask_token_id, dtype=mx.int32
)
block_ids[:, 0] = next_token
dl = drafter.draft(last_th, block_ids, ctx_len)
mx.eval(dl)
# 2. One target verify at M = V+1 to compile every target
# projection (q/k/v/o, gate/up/down, in_proj_*, lm_head).
verify_input = mx.array([[next_token] * (m_v + 1)])
target_hidden, _, vl = dflash_speculative_forward(
model, verify_input, cache, drafter.target_layer_ids,
speculative=True,
)
mx.eval(target_hidden, vl)
# 3. Fully undo the verify so the next mode (or the real
# generation) starts from the same cache offset.
for c in cache:
if hasattr(c, 'offset'):
c.offset -= (m_v + 1)
elif hasattr(c, 'rollback'):
c.rollback(0)
for i, c in enumerate(cache):
if hasattr(c, 'base'):
cache[i] = c.base
finally:
drafter.block_size = original_bs
drafter.reset_draft_cache()
logger.info("DFlash warmup complete")
@property
def has_work(self) -> bool:
return (
@@ -254,6 +634,31 @@ class ExoBatchGenerator:
uid = uids[0]
# MTP prefill: build MTP cache from prompt hidden states
if hasattr(self._mlx_gen, 'mtp'):
prompt_pre_norm = self._mlx_gen._captured.get('prompt_pre_norm')
if prompt_pre_norm is not None:
mx.eval(prompt_pre_norm)
self._mlx_gen.mtp.reset_cache()
S_pre = prompt_pre_norm.shape[1]
if S_pre > 1:
toks_list = all_prompt_tokens.tolist() if hasattr(all_prompt_tokens, 'tolist') else list(all_prompt_tokens)
mtp_tokens = toks_list[1:S_pre]
_ = self._mlx_gen.mtp.predict(
prompt_pre_norm[:, :-1, :],
mx.array([mtp_tokens])
)
mx.eval(_)
logger.info(f"MTP cache prefilled ({S_pre} positions)")
# Set per-request temperature for speculative
if hasattr(self._mlx_gen, '_request_temp'):
env_temp = os.environ.get("EXO_SPECULATIVE_TEMP")
if env_temp is not None:
self._mlx_gen._request_temp[uid] = float(env_temp)
elif task_params.temperature is not None:
self._mlx_gen._request_temp[uid] = task_params.temperature
self._active_tasks[uid] = _EngineTask(
uid=uid,
task_params=task_params,
@@ -337,7 +742,7 @@ class ExoBatchGenerator:
logprob: float | None = None
top_logprobs: list[TopLogprobItem] | None = None
if task_params.logprobs:
if task_params.logprobs and os.environ.get("EXO_DISABLE_LOGPROBS") != "1":
with mx.stream(generation_stream):
logprob, top_logprobs = extract_top_logprobs(
logprobs=response.logprobs,
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Custom bf16 GEMM with BM=8. Same pattern as custom_qmm_bm8 but no dequantization."""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_bf16_gemm_source(M_val, N_val, K_val, BM):
BN = 32
BK = 32
if BM <= 8:
WM, WN = 1, 2
else:
WM, WN = 2, 2
tgp_size = WM * WN * 32
BK_PAD = BK + 8
# BlockLoader for W: (BROWS=BN=32, BCOLS=BK=32, tgp_size)
w_n_reads = (BN * BK) // tgp_size
# BlockLoader for X: (BROWS=BM, BCOLS=BK=32, tgp_size)
x_total = BM * BK
x_n_reads = max(1, x_total // tgp_size)
x_TCOLS = BK // x_n_reads if x_n_reads > 0 else BK
x_TROWS = tgp_size // x_TCOLS if x_TCOLS > 0 else tgp_size
# MMA fragment layout
TM = BM // (8 * WM) # fragments per SG in M dimension
TN = BN // (8 * WN) # fragments per SG in N dimension
# Generate MMA accumulators, load, and store for TM×TN fragments
# C[tm_i][tn_j] for tm_i in 0..TM-1, tn_j in 0..TN-1
c_decl_lines = []
for ti in range(TM):
for tj in range(TN):
c_decl_lines.append(f" simdgroup_matrix<float, 8, 8> C{ti}{tj} = simdgroup_matrix<float, 8, 8>(0);")
c_decl = "\n".join(c_decl_lines)
# MMA inner loop: load A for each TM row, B for each TN col, multiply
mma_lines = []
for ti in range(TM):
mma_lines.append(f""" simdgroup_matrix<float, 8, 8> A{ti};
simdgroup_load(A{ti}, &Xs[(tm + {ti} * {WM} * 8) * BK_PAD + kk], BK_PAD);""")
for tj in range(TN):
mma_lines.append(f""" simdgroup_matrix<float, 8, 8> B{tj};
simdgroup_load(B{tj}, &Ws[(tn + {tj} * {WN} * 8) * BK_PAD + kk], BK_PAD, ulong2(0, 0), true);""")
for ti in range(TM):
for tj in range(TN):
mma_lines.append(f" simdgroup_multiply_accumulate(C{ti}{tj}, A{ti}, B{tj}, C{ti}{tj});")
mma_load = "\n".join(mma_lines)
# Store: each fragment at its (tm + ti*8, tn + tj*WN*8) position
store_lines = []
for ti in range(TM):
for tj in range(TN):
store_lines.append(f" simdgroup_store(C{ti}{tj}, &Ws[(tm + {ti} * {WM} * 8) * BN + tn + {tj} * {WN} * 8], BN);")
c_store = "\n".join(store_lines)
return f"""
const int BM = {BM};
const int BN = {BN};
const int BK = {BK};
const int BK_PAD = {BK_PAD};
const int K = {K_val};
const int N = {N_val};
const int M = {M_val};
uint3 tid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int thread_idx = sgid * 32 + slid;
int y_row = tid.y * BM;
int y_col = tid.x * BN;
threadgroup float Xs[{BM} * {BK_PAD}];
threadgroup float Ws[{BN} * {BK_PAD}];
// Pointer setup
const device bfloat16_t* x_base = (const device bfloat16_t*)x + y_row * K;
const device bfloat16_t* w_base = (const device bfloat16_t*)w + y_col * K;
// BlockLoader for X
int x_bi = thread_idx / {x_TCOLS};
int x_bj = {x_n_reads} * (thread_idx % {x_TCOLS});
const device bfloat16_t* x_src = x_base + x_bi * K + x_bj;
threadgroup float* x_dst = Xs + x_bi * BK_PAD + x_bj;
// BlockLoader for W (bf16, no dequant)
int w_bi = {w_n_reads} * thread_idx / {BK};
int w_bj = ({w_n_reads} * thread_idx) % {BK};
const device bfloat16_t* w_src = w_base + w_bi * K + w_bj;
threadgroup float* w_dst = Ws + w_bi * BK_PAD + w_bj;
// MMA setup
short sg_row = sgid / {WN};
short sg_col = sgid % {WN};
short tm = 8 * sg_row;
short tn = 8 * sg_col;
{c_decl}
// K-loop
for (int k = 0; k < K; k += BK) {{
// Load X tile (bf16 → float)
if (x_bi < BM) {{
for (int i = 0; i < {x_n_reads}; i++) {{
x_dst[i] = float(x_src[i]);
}}
}}
// Load W tile (bf16 → float, no dequant)
if (w_bi < BN) {{
for (int i = 0; i < {w_n_reads}; i++) {{
w_dst[i] = float(w_src[i]);
}}
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// MMA inner loop
for (short kk = 0; kk < BK; kk += 8) {{
{mma_load}
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
x_src += BK;
w_src += BK;
}}
// Store results via TG memory → device
{c_store}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
for (int i = thread_idx; i < BM * BN; i += {tgp_size}) {{
int r = i / BN;
int c = i % BN;
if (y_row + r < M && y_col + c < N) {{
y[(y_row + r) * N + y_col + c] = static_cast<bfloat16_t>(Ws[r * BN + c]);
}}
}}
"""
_cache = {}
def custom_bf16_gemm(x, w, M, N, K, BM=8):
key = (M, N, K, BM)
if key not in _cache:
WM = 1 if BM <= 8 else 2
WN = 2
sgs = WM * WN
_cache[key] = mx.fast.metal_kernel(
name=f"custom_bf16_gemm_bm{BM}_M{M}_N{N}_K{K}",
input_names=["x", "w"],
output_names=["y"],
source=_gen_bf16_gemm_source(M, N, K, BM),
)
kern = _cache[key]
WM = 1 if BM <= 8 else 2
WN = 2
sgs = WM * WN
n_tg_n = ceil_div(N, 32)
n_tg_m = ceil_div(M, BM)
result = kern(
inputs=[x, w],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(32 * n_tg_n, sgs * n_tg_m, 1),
threadgroup=(32, sgs, 1),
)
return result[0].reshape(M, N)
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Split-K bf16 GEMM using actual Steel GEMM infrastructure.
Bundles MLX's Steel headers and passes them via mx.fast.metal_kernel's
header parameter. The kernel source is just the template instantiation
of gemm_splitk — identical to stock MLX.
Only difference from stock: no K >= max(M, N) restriction.
Usage:
from gemm_splitk_steel import custom_bf16_gemm_splitk_steel
y = custom_bf16_gemm_splitk_steel(x, w, M=16, N=17408, K=5120, BM=16)
"""
import os
import re
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def next_power_of_2(n):
if n <= 0:
return 1
return 1 << (n - 1).bit_length()
def compute_partitions(M, N, K):
_tm = ceil_div(M, 32)
_tn = ceil_div(N, 32)
_tk = K // 16
tmtn = max(_tm * _tn, 1)
return min(max(2, next_power_of_2(_tk // tmtn)), 32)
def _load_steel_headers():
"""Read and concatenate Steel headers in dependency order."""
base = os.path.join(os.path.dirname(__file__), '..', 'steel_include')
files_in_order = [
'steel/defines.h',
'steel/utils/integral_constant.h',
'steel/utils/type_traits.h',
'steel/utils.h',
'steel/gemm/transforms.h',
'steel/gemm/params.h',
'steel/gemm/loader.h',
'steel/gemm/mma.h',
'steel/gemm/gemm.h',
'steel/gemm/kernels/steel_gemm_splitk.h',
]
parts = []
for f in files_in_order:
path = os.path.join(base, f)
with open(path) as fh:
content = fh.read()
# Strip #pragma once and #include directives (deps are manually ordered)
content = re.sub(r'#pragma once', '', content)
content = re.sub(r'#include\s+"[^"]*"', '', content)
content = re.sub(r'#include\s+<metal_simdgroup_matrix>', '', content)
content = re.sub(r'#include\s+<metal_simdgroup>', '', content)
content = re.sub(r'#include\s+<metal_stdlib>', '', content)
parts.append(f'// ── {f} ──\n' + content)
return '\n'.join(parts)
_steel_header = None
def _get_steel_header():
global _steel_header
if _steel_header is None:
_steel_header = _load_steel_headers()
return _steel_header
def _gen_splitk_instantiation(M_val, N_val, K_val, BM, split_k_partitions, BN=32, BK=16):
"""Generate the kernel source — just template instantiation + dispatch logic.
The actual GEMM code comes from the Steel headers in the header parameter."""
WM = 1 if BM <= 8 else 2
WN = 2
gemm_k_iterations = (K_val // BK) // split_k_partitions
split_k_partition_size = gemm_k_iterations * BK
mn_aligned_str = "true" if M_val % BM == 0 and N_val % BN == 0 else "false"
k_aligned_str = "true" if K_val % BK == 0 else "false"
# The kernel body is a direct copy of steel_gemm_splitk.h's gemm_splitk function
# with template parameters expanded
return f"""
using namespace mlx::steel;
using T = bfloat16_t;
using U = float;
const int BM_val = {BM};
const int BN_val = {BN};
const int BK_val = {BK};
using gemm_kernel = GEMMKernel<T, U, {BM}, {BN}, {BK}, {WM}, {WN}, false, true, {mn_aligned_str}, {k_aligned_str}>;
using loader_a_t = typename gemm_kernel::loader_a_t;
using loader_b_t = typename gemm_kernel::loader_b_t;
using mma_t = typename gemm_kernel::mma_t;
threadgroup T As[gemm_kernel::tgp_mem_size_a];
threadgroup T Bs[gemm_kernel::tgp_mem_size_b];
uint simd_lane_id = thread_index_in_simdgroup;
uint simd_group_id = simdgroup_index_in_threadgroup;
uint3 tid = threadgroup_position_in_grid;
const int tiles_n = {ceil_div(N_val, BN)};
const int tiles_m = {ceil_div(M_val, BM)};
const int tid_x = tid.x;
const int tid_y = tid.y;
const int tid_z = tid.z;
if (tiles_n <= tid_x || tiles_m <= tid_y) return;
const int M = {M_val};
const int N = {N_val};
const int K = {K_val};
const int lda = K; // A is (M, K) row-major
const int ldb = K; // B is (N, K) row-major, transposed
const int split_k_partitions = {split_k_partitions};
const int split_k_partition_size = {split_k_partition_size};
const int split_k_partition_stride = M * N;
const int gemm_k_iterations_aligned = {gemm_k_iterations};
const int c_row = tid_y * {BM};
const int c_col = tid_x * {BN};
const int k_start = split_k_partition_size * tid_z;
// Pointer setup (matching steel_gemm_splitk.h)
// transpose_a=false: A += k_start + c_row * lda
// transpose_b=true: B += k_start + c_col * ldb
const device T* A = (const device T*)x + (long)k_start + (long)c_row * lda;
const device T* B = (const device T*)w + (long)k_start + (long)c_col * ldb;
device U* C = (device U*)y + (long)split_k_partition_stride * tid_z + (long)c_row * N + c_col;
// Prepare loaders and MMA
thread loader_a_t loader_a(A, lda, As, simd_group_id, simd_lane_id);
thread loader_b_t loader_b(B, ldb, Bs, simd_group_id, simd_lane_id);
thread mma_t mma_op(simd_group_id, simd_lane_id);
int gemm_k_iters = gemm_k_iterations_aligned;
short tgp_bm = min((short){BM}, (short)(M - c_row));
short tgp_bn = min((short){BN}, (short)(N - c_col));
short leftover_bk = K % {BK};
// Main GEMM loop
if (tgp_bm == {BM} && tgp_bn == {BN}) {{
gemm_kernel::gemm_loop(
As, Bs, gemm_k_iters, loader_a, loader_b, mma_op,
tgp_bm, tgp_bn, leftover_bk,
LoopAlignment<true, true, true>{{}});
}} else if (tgp_bn == {BN}) {{
gemm_kernel::gemm_loop(
As, Bs, gemm_k_iters, loader_a, loader_b, mma_op,
tgp_bm, tgp_bn, leftover_bk,
LoopAlignment<false, true, true>{{}});
}} else if (tgp_bm == {BM}) {{
gemm_kernel::gemm_loop(
As, Bs, gemm_k_iters, loader_a, loader_b, mma_op,
tgp_bm, tgp_bn, leftover_bk,
LoopAlignment<true, false, true>{{}});
}} else {{
gemm_kernel::gemm_loop(
As, Bs, gemm_k_iters, loader_a, loader_b, mma_op,
tgp_bm, tgp_bn, leftover_bk,
LoopAlignment<false, false, true>{{}});
}}
threadgroup_barrier(mem_flags::mem_threadgroup);
// Last partition handles leftover K
if ((tid_z + 1) == split_k_partitions) {{
int gemm_k_iter_remaining = (K - (k_start + split_k_partition_size)) / {BK};
if (gemm_k_iter_remaining > 0)
gemm_kernel::gemm_loop(
As, Bs, gemm_k_iter_remaining, loader_a, loader_b, mma_op,
tgp_bm, tgp_bn, leftover_bk,
LoopAlignment<false, false, {k_aligned_str}>{{}});
}}
// Store results
if (tgp_bm == {BM} && tgp_bn == {BN}) {{
mma_op.store_result(C, N);
}} else {{
mma_op.store_result_safe(C, N, short2(tgp_bn, tgp_bm));
}}
"""
def _gen_accum_source(split_k_partitions):
return f"""
uint gid_x = thread_position_in_grid.x;
uint gid_y = thread_position_in_grid.y;
uint N_val = threads_per_grid.x;
int offset = gid_y * N_val + gid_x;
int stride = threads_per_grid.x * threads_per_grid.y;
float out = 0.0f;
for (int p = 0; p < {split_k_partitions}; p++) {{
out += ((const device float*)c_split)[offset + p * stride];
}}
y[gid_y * N_val + gid_x] = static_cast<bfloat16_t>(out);
"""
_gemm_cache = {}
_accum_cache = {}
def custom_bf16_gemm_splitk_steel(x, w, M, N, K, BM=16, BN=32, BK=16):
WM = 1 if BM <= 8 else 2
WN = 2
P = compute_partitions(M, N, K)
gemm_key = (M, N, K, BM, BN, BK, P)
if gemm_key not in _gemm_cache:
_gemm_cache[gemm_key] = mx.fast.metal_kernel(
name=f"splitk_steel_bm{BM}_bn{BN}_bk{BK}_M{M}_N{N}_K{K}_P{P}",
input_names=["x", "w"],
output_names=["y"],
header=_get_steel_header(),
source=_gen_splitk_instantiation(M, N, K, BM, P, BN=BN, BK=BK),
)
gemm_kern = _gemm_cache[gemm_key]
sgs = WM * WN
n_tg_n = ceil_div(N, BN)
n_tg_m = ceil_div(M, BM)
c_split = gemm_kern(
inputs=[x, w],
output_shapes=[(P * M * N,)],
output_dtypes=[mx.float32],
grid=(32 * n_tg_n, sgs * n_tg_m, P),
threadgroup=(32, sgs, 1),
)[0]
accum_key = P
if accum_key not in _accum_cache:
_accum_cache[accum_key] = mx.fast.metal_kernel(
name=f"splitk_accum_P{P}",
input_names=["c_split"],
output_names=["y"],
source=_gen_accum_source(P),
)
accum_kern = _accum_cache[accum_key]
y = accum_kern(
inputs=[c_split],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(N, M, 1),
threadgroup=(min(N, 256), 1, 1),
)[0]
return y.reshape(M, N)
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Loop-over-B GEMV for bf16 (non-quantized) matmul.
Same pattern as custom_qmv_loop_over_b.py but for bf16 weights:
no dequantization, no scales/biases — just bf16 weight reads.
Y = X @ W^T where W is (N, K) bf16.
TG: (32, 2, 1) = 64 threads = 2 SGs.
Each SG: 4 output rows. B loop inside row loop.
Usage:
from custom_bf16_qmv_loop_over_b import custom_bf16_qmv_loop_over_b
y = custom_bf16_qmv_loop_over_b(x, w, M=4, N=8192, K=2048)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_bf16_qmv_source(M_val, N_val, K_val):
B = M_val
# bf16 weights: each element is 2 bytes, read as bfloat16_t
# No quantization groups — just w[row * K + col]
# Each thread handles 8 K-elements per block (VALUES_PER_THREAD=8)
# Block size = 32 threads × 8 = 256 elements per K-iteration
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int K = {K_val};
const int N = {N_val};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int tg = tgid.y;
int out_row = tg * 8 + sgid * RESULTS_PER_SG;
if (out_row >= N) return;
// Weight pointer: w is (N, K) bf16
const device bfloat16_t* ws = (const device bfloat16_t*)w + (long)out_row * K + slid * VALUES_PER_THREAD;
// Result accumulators: 4 rows × B batches
float result[{4 * B}];
for (int i = 0; i < {4 * B}; i++) result[i] = 0;
int x_base = slid * VALUES_PER_THREAD;
// K-loop: loop over B inside row loop
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device bfloat16_t* wl = ws + row * K;
for (int b = 0; b < {B}; b++) {{
float accum = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(((const device bfloat16_t*)x)[b * K + x_base + i]);
accum += xi * float(wl[i]);
}}
result[b * 4 + row] += accum;
}}
}}
ws += BLOCK_SIZE;
x_base += BLOCK_SIZE;
}}
// Reduction
for (int i = 0; i < {4 * B}; i++) result[i] = simd_sum(result[i]);
// Write output (bf16)
if (slid < 4u) {{
for (int b = 0; b < {B}; b++) {{
int r = out_row + (int)slid;
if (r < N) {{
y[b * N + r] = static_cast<bfloat16_t>(result[b * 4 + slid]);
}}
}}
}}
"""
_cache = {}
def custom_bf16_qmv_loop_over_b(x, w, M, N, K):
key = (M, N, K)
if key not in _cache:
_cache[key] = mx.fast.metal_kernel(
name=f"custom_bf16_qmv_loop_b_M{M}_N{N}_K{K}",
input_names=["x", "w"],
output_names=["y"],
source=_gen_bf16_qmv_source(M, N, K),
)
kern = _cache[key]
n_tg = ceil_div(N, 8)
result = kern(
inputs=[x, w],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(32, n_tg * 2, 1),
threadgroup=(32, 2, 1),
)
return result[0].reshape(M, N)
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Two-pass LPB inside a single kernel for large M.
Same threadgroup processes batches 0..M/2-1 first, then M/2..M-1.
First pass results are written to threadgroup memory to free registers.
Second pass reuses the same registers. Both results written to device
memory at the end.
Register pressure: R = 4*(M/2) + 5 per pass (not 2x).
At M=16: R = 37 per pass instead of R = 69 single-pass.
Usage:
from gemv_loop_over_b_twice import custom_bf16_qmv_loop_over_b_twice
y = custom_bf16_qmv_loop_over_b_twice(x, w, M=16, N=17408, K=5120)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_bf16_qmv_twice_source(M_val, N_val, K_val):
# Pass 0 takes ceil(M/2) rows, pass 1 takes floor(M/2) rows.
# When M is odd, half_a > half_b by 1.
half_a = (M_val + 1) // 2
half_b = M_val - half_a
# Threadgroup memory sized for the larger pass (half_a)
tg_size = 2 * 4 * half_a
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int K = {K_val};
const int N = {N_val};
const int HALF_A = {half_a};
const int HALF_B = {half_b};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int tg = tgid.y;
int out_row = tg * 8 + sgid * RESULTS_PER_SG;
if (out_row >= N) return;
const device bfloat16_t* w_base = (const device bfloat16_t*)w + (long)out_row * K + slid * VALUES_PER_THREAD;
// Threadgroup memory to store pass 0 results (frees registers for pass 1)
threadgroup float tg_results[{tg_size}];
int tg_offset = sgid * {4 * half_a}; // each SG gets its own slice
// ══════ Pass 0: batches 0..HALF_A-1 ══════
{{
float result[{4 * half_a}];
for (int i = 0; i < {4 * half_a}; i++) result[i] = 0;
const device bfloat16_t* ws = w_base;
int x_base = slid * VALUES_PER_THREAD;
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device bfloat16_t* wl = ws + row * K;
for (int b = 0; b < {half_a}; b++) {{
float accum = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(((const device bfloat16_t*)x)[b * K + x_base + i]);
accum += xi * float(wl[i]);
}}
result[b * 4 + row] += accum;
}}
}}
ws += BLOCK_SIZE;
x_base += BLOCK_SIZE;
}}
// Reduce and store to threadgroup memory
for (int i = 0; i < {4 * half_a}; i++) {{
result[i] = simd_sum(result[i]);
}}
if (slid == 0u) {{
for (int i = 0; i < {4 * half_a}; i++) {{
tg_results[tg_offset + i] = result[i];
}}
}}
}}
// result[] goes out of scope here — registers freed
""" + (f"""
// ══════ Pass 1: batches HALF_A..M-1 (HALF_B rows) ══════
{{
float result[{4 * half_b}];
for (int i = 0; i < {4 * half_b}; i++) result[i] = 0;
const device bfloat16_t* ws = w_base;
int x_base = slid * VALUES_PER_THREAD;
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device bfloat16_t* wl = ws + row * K;
for (int b = 0; b < {half_b}; b++) {{
float accum = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(((const device bfloat16_t*)x)[(HALF_A + b) * K + x_base + i]);
accum += xi * float(wl[i]);
}}
result[b * 4 + row] += accum;
}}
}}
ws += BLOCK_SIZE;
x_base += BLOCK_SIZE;
}}
// Reduce
for (int i = 0; i < {4 * half_b}; i++) {{
result[i] = simd_sum(result[i]);
}}
// Write pass 1 results to device memory
if (slid < 4u) {{
for (int b = 0; b < {half_b}; b++) {{
int r = out_row + (int)slid;
if (r < N) {{
y[(HALF_A + b) * N + r] = static_cast<bfloat16_t>(result[b * 4 + slid]);
}}
}}
}}
}}
""" if half_b > 0 else "") + f"""
// Write pass 0 results from threadgroup memory to device memory
if (slid < 4u) {{
for (int b = 0; b < {half_a}; b++) {{
int r = out_row + (int)slid;
if (r < N) {{
y[b * N + r] = static_cast<bfloat16_t>(tg_results[tg_offset + b * 4 + slid]);
}}
}}
}}
"""
_cache = {}
def custom_bf16_qmv_loop_over_b_twice(x, w, M, N, K):
key = (M, N, K)
if key not in _cache:
_cache[key] = mx.fast.metal_kernel(
name=f"custom_bf16_qmv_loop_b_2x_M{M}_N{N}_K{K}",
input_names=["x", "w"],
output_names=["y"],
source=_gen_bf16_qmv_twice_source(M, N, K),
)
kern = _cache[key]
n_tg = ceil_div(N, 8)
result = kern(
inputs=[x, w],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(32, n_tg * 2, 1),
threadgroup=(32, 2, 1),
)
return result[0].reshape(M, N)
Whitespace-only changes.
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Custom quantized GEMM kernel with BM=16 (vs MLX's hardcoded BM=32).
Replicates MLX's affine_qmm_t exactly but with BM=16 to eliminate
50% compute waste when M=16. Fixed parameters: 8-bit, gs=64, bfloat16.
BM=16, BN=32, BK=32, WM=2, WN=2 → 4 SGs = 128 threads.
TM = BM/(8*WM) = 16/16 = 1, TN = BN/(8*WN) = 32/16 = 2.
Each SG: 1×2 output fragments (8×16).
Uses simdgroup_matrix<float, 8, 8> for hardware MMA.
Inlines QuantizedBlockLoader and BlockLoader for 8-bit gs=64.
Usage:
from custom_qmm_bm16 import custom_qmm_t_bm16
y = custom_qmm_t_bm16(x, w, scales, biases, M=16, N=8192, K=2048)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_custom_qmm_source(M_val, N_val, K_val, group_size=64):
gs = group_size
BM = 16
BN = 32
BK = 32
WM = 2
WN = 2
SIMD_SIZE = 32
tgp_size = WM * WN * SIMD_SIZE # 128
BK_PAD = BK + 16 // 2 # +8 for bfloat16 (16/sizeof(bf16)=8)
# QuantizedBlockLoader params for (BROWS=BN=32, BCOLS=BK=32, tgp_size=128, bits=8)
# pack_factor=1 for 8-bit, BCOLS_PACKED=32
# n_reads = (32*32)/128 = 8
w_n_reads = (BN * BK) // tgp_size # 8
# BlockLoader params for input X (BROWS=BM=16, BCOLS=BK=32, tgp_size=128)
# n_reads = (16*32)/128 = 4
# TCOLS = 32/4 = 8, TROWS = 128/8 = 16
x_n_reads = (BM * BK) // tgp_size # 4
x_TCOLS = BK // x_n_reads # 8
x_TROWS = tgp_size // x_TCOLS # 16
x_n_rows = ceil_div(BM, x_TROWS) # 1
K_groups = K_val // gs
return f"""
// ═══ Constants ═══
const int BM = {BM};
const int BN = {BN};
const int BK = {BK};
const int BK_PAD = {BK_PAD};
const int K = {K_val};
const int N = {N_val};
const int M = {M_val};
const int K_groups = {K_groups};
const int GROUP_SIZE = {gs};
uint3 tid = threadgroup_position_in_grid;
uint lid = thread_index_in_threadgroup;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int thread_idx = sgid * 32 + slid;
int y_row = tid.y * BM; // M-dimension tile start
int y_col = tid.x * BN; // N-dimension tile start
// ═══ TG memory ═══
threadgroup float Xs[{BM} * {BK_PAD}];
threadgroup float Ws[{BN} * {BK_PAD}];
// ═══ Pointer setup (follows qmm_t_impl lines 1137-1148) ═══
const device bfloat16_t* x_base = (const device bfloat16_t*)x + y_row * K;
const device uint8_t* w_base = (const device uint8_t*)w + y_col * K;
const device bfloat16_t* s_base = (const device bfloat16_t*)scales + y_col * K_groups;
const device bfloat16_t* b_base = (const device bfloat16_t*)biases + y_col * K_groups;
// ═══ BlockLoader for X: (BROWS=16, BCOLS=32, tgp_size=128) ═══
// n_reads=4, TCOLS=8, TROWS=16, n_rows=1
int x_bi = thread_idx / {x_TCOLS}; // row in tile (0..15)
int x_bj = {x_n_reads} * (thread_idx % {x_TCOLS}); // col start in tile
const device bfloat16_t* x_src = x_base + x_bi * K + x_bj;
threadgroup float* x_dst = Xs + x_bi * BK_PAD + x_bj;
// ═══ QuantizedBlockLoader for W: (BROWS=32, BCOLS=32, tgp_size=128, bits=8) ═══
// n_reads=8, pack_factor=1
int w_bi = {w_n_reads} * thread_idx / {BK}; // row in tile (0..31)
int w_bj = ({w_n_reads} * thread_idx) % {BK}; // col start in tile
const device uint8_t* w_src = w_base + w_bi * K + w_bj;
const device bfloat16_t* w_scales = s_base + w_bi * K_groups;
const device bfloat16_t* w_biases = b_base + w_bi * K_groups;
threadgroup float* w_dst = Ws + w_bi * BK_PAD + w_bj;
short w_group_step_cnt = 0;
const int w_group_steps = GROUP_SIZE / BK; // 64/32 = 2
// ═══ MMA setup (follows BlockMMA constructor, lines 488-505) ═══
// WM=2, WN=2: sgid layout: sgid/WN=row, sgid%WN=col
// TM=1, TN=2: each SG has 1×2 output fragments
short sg_row = sgid / {WN}; // 0 or 1
short sg_col = sgid % {WN}; // 0 or 1
short tm = 8 * sg_row; // M offset within BM (0 or 8)
short tn = 8 * sg_col; // N offset within BN (0 or 8)
// But TN=2, so each SG handles 2 N-fragments: tn and tn+16
// Actually: with WN=2, sg_col is 0 or 1, tn = 8*sg_col = 0 or 8
// But we need to cover BN=32 with WN=2 → TN=2 fragments per SG
// So SG col 0 handles N cols [0..7] and [16..23], SG col 1 handles [8..15] and [24..31]
// Wait, that's the serpentine ordering. Let me follow MLX exactly.
// sm/sn are the thread's position within the SG's output tile
// get_coord returns (col, row) within the 8x8 fragment
// Accumulators: TM=1 × TN=2 = 2 fragments per SG
simdgroup_matrix<float, 8, 8> C00 = simdgroup_matrix<float, 8, 8>(0);
simdgroup_matrix<float, 8, 8> C01 = simdgroup_matrix<float, 8, 8>(0);
// Offsets into TG memory for this SG's MMA reads
short As_offset_m = tm; // row offset in Xs
short Bs_offset_n0 = tn; // first N-fragment
short Bs_offset_n1 = tn + {WN} * 8; // second N-fragment (offset by WN*8=16)
// ═══ K-loop ═══
for (int k = 0; k < K; k += BK) {{
// ── Load X tile: BM×BK (bf16 → float) ──
// BlockLoader pattern: each thread loads n_reads={x_n_reads} elements
if (x_bi < BM) {{
for (int i = 0; i < {x_n_reads}; i++) {{
x_dst[i] = float(x_src[i]);
}}
}}
// ── Load + dequantize W tile: BN×BK (uint8 → float) ──
// QuantizedBlockLoader pattern: each thread loads n_reads={w_n_reads} elements
if (w_bi < BN) {{
float scale = float(*w_scales);
float bias = float(*w_biases);
for (int i = 0; i < {w_n_reads}; i++) {{
w_dst[i] = scale * float(w_src[i]) + bias;
}}
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// ── MMA inner loop: BK/8 = 4 iterations ──
for (short kk = 0; kk < BK; kk += 8) {{
simdgroup_matrix<float, 8, 8> A_frag;
simdgroup_matrix<float, 8, 8> B_frag0, B_frag1;
// Load A fragment: 8×8 from Xs at (As_offset_m, kk)
simdgroup_load(A_frag, &Xs[As_offset_m * BK_PAD + kk], BK_PAD);
// Load B fragments: transposed from Ws (stored as BN × BK_PAD)
// B_frag0: output cols [Bs_offset_n0..+8], K rows [kk..+8]
// B_frag1: output cols [Bs_offset_n1..+8], K rows [kk..+8]
simdgroup_load(B_frag0, &Ws[Bs_offset_n0 * BK_PAD + kk], BK_PAD, ulong2(0, 0), true);
simdgroup_load(B_frag1, &Ws[Bs_offset_n1 * BK_PAD + kk], BK_PAD, ulong2(0, 0), true);
simdgroup_multiply_accumulate(C00, A_frag, B_frag0, C00);
simdgroup_multiply_accumulate(C01, A_frag, B_frag1, C01);
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// ── Advance loaders ──
x_src += BK;
w_src += BK;
w_group_step_cnt++;
if (w_group_step_cnt == w_group_steps) {{
w_group_step_cnt = 0;
w_scales++;
w_biases++;
}}
}}
// ═══ Store results to device memory ═══
// Each SG stores its 1×2 fragments (two 8×8 blocks)
// Output layout: y[m * N + n] for m in [y_row..y_row+BM), n in [y_col..y_col+BN)
device bfloat16_t* y_ptr = (device bfloat16_t*)y + y_row * N + y_col;
// Store C00 at (tm, tn) and C01 at (tm, tn + WN*8)
simdgroup_store(C00, &Ws[tm * BN + tn], BN);
simdgroup_store(C01, &Ws[tm * BN + tn + {WN} * 8], BN);
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// Convert float → bf16 and write to device
for (int i = thread_idx; i < BM * BN; i += {tgp_size}) {{
int r = i / BN;
int c = i % BN;
if (y_row + r < M && y_col + c < N) {{
y_ptr[r * N + c] = static_cast<bfloat16_t>(Ws[r * BN + c]);
}}
}}
"""
_custom_qmm_cache = {}
def custom_qmm_t_bm16(x, w, scales, biases, M, N, K, group_size=64):
"""Custom quantized GEMM with BM=16.
Args:
x: (M, K) bfloat16 input
w: (N, K/4) uint32 packed 8-bit weights
scales: (N, K/gs) bfloat16
biases: (N, K/gs) bfloat16
M, N, K: matrix dimensions
Returns:
y: (M, N) bfloat16
"""
key = (M, N, K, group_size)
if key not in _custom_qmm_cache:
_custom_qmm_cache[key] = mx.fast.metal_kernel(
name=f"custom_qmm_t_bm16_M{M}_N{N}_K{K}",
input_names=["x", "w", "scales", "biases"],
output_names=["y"],
source=_gen_custom_qmm_source(M, N, K, group_size),
)
kern = _custom_qmm_cache[key]
BN = 32
BM = 16
n_tg_n = ceil_div(N, BN)
n_tg_m = ceil_div(M, BM)
result = kern(
inputs=[x, w, scales, biases],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(32 * n_tg_n, 4 * n_tg_m, 1),
threadgroup=(32, 4, 1),
)
return result[0].reshape(M, N)
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Custom quantized GEMM kernel with BM=8 (vs MLX's GEMV at BS=8).
At BS=8, MLX uses affine_qmv_fast (GEMV) which loads weights 8 times
via grid x. Our GEMM loads weights once via TG memory tiling.
BM=8, BN=32, BK=32, WM=1, WN=2 → 2 SGs = 64 threads.
TM = BM/(8*WM) = 8/8 = 1, TN = BN/(8*WN) = 32/16 = 2.
Each SG: 1×2 output fragments (8×16).
Uses simdgroup_matrix<float, 8, 8> for hardware MMA.
Inlines QuantizedBlockLoader and BlockLoader for 8-bit gs=64.
Usage:
from custom_qmm_bm8 import custom_qmm_t_bm8
y = custom_qmm_t_bm8(x, w, scales, biases, M=8, N=8192, K=2048)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_custom_qmm_source(M_val, N_val, K_val, group_size=64):
gs = group_size
BM = 8
BN = 32
BK = 32
WM = 1
WN = 2
SIMD_SIZE = 32
tgp_size = WM * WN * SIMD_SIZE # 64
BK_PAD = BK + 16 // 2 # +8 for bfloat16 (16/sizeof(bf16)=8)
# QuantizedBlockLoader params for (BROWS=BN=32, BCOLS=BK=32, tgp_size=64, bits=8)
# pack_factor=1 for 8-bit, BCOLS_PACKED=32
# n_reads = (32*32)/64 = 16
w_n_reads = (BN * BK) // tgp_size # 16
# BlockLoader params for input X (BROWS=BM=8, BCOLS=BK=32, tgp_size=64)
# n_reads = (8*32)/64 = 4
# TCOLS = 32/4 = 8, TROWS = 64/8 = 8
x_n_reads = (BM * BK) // tgp_size # 4
x_TCOLS = BK // x_n_reads # 8
x_TROWS = tgp_size // x_TCOLS # 8
x_n_rows = ceil_div(BM, x_TROWS) # 1
K_groups = K_val // gs
return f"""
// ═══ Constants ═══
const int BM = {BM};
const int BN = {BN};
const int BK = {BK};
const int BK_PAD = {BK_PAD};
const int K = {K_val};
const int N = {N_val};
const int M = {M_val};
const int K_groups = {K_groups};
const int GROUP_SIZE = {gs};
uint3 tid = threadgroup_position_in_grid;
uint lid = thread_index_in_threadgroup;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int thread_idx = sgid * 32 + slid;
int y_row = tid.y * BM; // M-dimension tile start
int y_col = tid.x * BN; // N-dimension tile start
// ═══ TG memory ═══
threadgroup float Xs[{BM} * {BK_PAD}];
threadgroup float Ws[{BN} * {BK_PAD}];
// ═══ Pointer setup (follows qmm_t_impl lines 1137-1148) ═══
const device bfloat16_t* x_base = (const device bfloat16_t*)x + y_row * K;
const device uint8_t* w_base = (const device uint8_t*)w + y_col * K;
const device bfloat16_t* s_base = (const device bfloat16_t*)scales + y_col * K_groups;
const device bfloat16_t* b_base = (const device bfloat16_t*)biases + y_col * K_groups;
// ═══ BlockLoader for X: (BROWS=8, BCOLS=32, tgp_size=64) ═══
// n_reads=4, TCOLS=8, TROWS=8, n_rows=1
int x_bi = thread_idx / {x_TCOLS}; // row in tile (0..15)
int x_bj = {x_n_reads} * (thread_idx % {x_TCOLS}); // col start in tile
const device bfloat16_t* x_src = x_base + x_bi * K + x_bj;
threadgroup float* x_dst = Xs + x_bi * BK_PAD + x_bj;
// ═══ QuantizedBlockLoader for W: (BROWS=32, BCOLS=32, tgp_size=64, bits=8) ═══
// n_reads=16, pack_factor=1
int w_bi = {w_n_reads} * thread_idx / {BK}; // row in tile (0..31)
int w_bj = ({w_n_reads} * thread_idx) % {BK}; // col start in tile
const device uint8_t* w_src = w_base + w_bi * K + w_bj;
const device bfloat16_t* w_scales = s_base + w_bi * K_groups;
const device bfloat16_t* w_biases = b_base + w_bi * K_groups;
threadgroup float* w_dst = Ws + w_bi * BK_PAD + w_bj;
short w_group_step_cnt = 0;
const int w_group_steps = GROUP_SIZE / BK; // 64/32 = 2
// ═══ MMA setup (follows BlockMMA constructor, lines 488-505) ═══
// WM=1, WN=2: sgid layout: sg_row=0 always, sg_col=sgid (0 or 1)
// TM=1, TN=2: each SG has 1×2 output fragments
short sg_row = 0;
short sg_col = sgid; // 0 or 1
short tm = 0; // M offset (always 0, only 8 rows = 1 fragment)
short tn = 8 * sg_col; // N offset: 0 or 8
// Accumulators: TM=1 × TN=2 = 2 fragments per SG
simdgroup_matrix<float, 8, 8> C00 = simdgroup_matrix<float, 8, 8>(0);
simdgroup_matrix<float, 8, 8> C01 = simdgroup_matrix<float, 8, 8>(0);
// Offsets into TG memory for this SG's MMA reads
short As_offset_m = tm; // row offset in Xs
short Bs_offset_n0 = tn; // first N-fragment
short Bs_offset_n1 = tn + {WN} * 8; // second N-fragment (offset by WN*8=16)
// ═══ K-loop ═══
for (int k = 0; k < K; k += BK) {{
// ── Load X tile: BM×BK (bf16 → float) ──
// BlockLoader pattern: each thread loads n_reads={x_n_reads} elements
if (x_bi < BM) {{
for (int i = 0; i < {x_n_reads}; i++) {{
x_dst[i] = float(x_src[i]);
}}
}}
// ── Load + dequantize W tile: BN×BK (uint8 → float) ──
// QuantizedBlockLoader pattern: each thread loads n_reads={w_n_reads} elements
if (w_bi < BN) {{
float scale = float(*w_scales);
float bias = float(*w_biases);
for (int i = 0; i < {w_n_reads}; i++) {{
w_dst[i] = scale * float(w_src[i]) + bias;
}}
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// ── MMA inner loop: BK/8 = 4 iterations ──
for (short kk = 0; kk < BK; kk += 8) {{
simdgroup_matrix<float, 8, 8> A_frag;
simdgroup_matrix<float, 8, 8> B_frag0, B_frag1;
// Load A fragment: 8×8 from Xs at (As_offset_m, kk)
simdgroup_load(A_frag, &Xs[As_offset_m * BK_PAD + kk], BK_PAD);
// Load B fragments: transposed from Ws (stored as BN × BK_PAD)
// B_frag0: output cols [Bs_offset_n0..+8], K rows [kk..+8]
// B_frag1: output cols [Bs_offset_n1..+8], K rows [kk..+8]
simdgroup_load(B_frag0, &Ws[Bs_offset_n0 * BK_PAD + kk], BK_PAD, ulong2(0, 0), true);
simdgroup_load(B_frag1, &Ws[Bs_offset_n1 * BK_PAD + kk], BK_PAD, ulong2(0, 0), true);
simdgroup_multiply_accumulate(C00, A_frag, B_frag0, C00);
simdgroup_multiply_accumulate(C01, A_frag, B_frag1, C01);
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// ── Advance loaders ──
x_src += BK;
w_src += BK;
w_group_step_cnt++;
if (w_group_step_cnt == w_group_steps) {{
w_group_step_cnt = 0;
w_scales++;
w_biases++;
}}
}}
// ═══ Store results to device memory ═══
// Each SG stores its 1×2 fragments (two 8×8 blocks)
// Output layout: y[m * N + n] for m in [y_row..y_row+BM), n in [y_col..y_col+BN)
device bfloat16_t* y_ptr = (device bfloat16_t*)y + y_row * N + y_col;
// Store C00 at (tm, tn) and C01 at (tm, tn + WN*8)
simdgroup_store(C00, &Ws[tm * BN + tn], BN);
simdgroup_store(C01, &Ws[tm * BN + tn + {WN} * 8], BN);
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// Convert float → bf16 and write to device
for (int i = thread_idx; i < BM * BN; i += {tgp_size}) {{
int r = i / BN;
int c = i % BN;
if (y_row + r < M && y_col + c < N) {{
y_ptr[r * N + c] = static_cast<bfloat16_t>(Ws[r * BN + c]);
}}
}}
"""
_custom_qmm_cache = {}
def custom_qmm_t_bm8(x, w, scales, biases, M, N, K, group_size=64):
"""Custom quantized GEMM with BM=8.
Args:
x: (M, K) bfloat16 input
w: (N, K/4) uint32 packed 8-bit weights
scales: (N, K/gs) bfloat16
biases: (N, K/gs) bfloat16
M, N, K: matrix dimensions
Returns:
y: (M, N) bfloat16
"""
key = (M, N, K, group_size)
if key not in _custom_qmm_cache:
_custom_qmm_cache[key] = mx.fast.metal_kernel(
name=f"custom_qmm_t_bm8_M{M}_N{N}_K{K}",
input_names=["x", "w", "scales", "biases"],
output_names=["y"],
source=_gen_custom_qmm_source(M, N, K, group_size),
)
kern = _custom_qmm_cache[key]
BN = 32
BM = 8
n_tg_n = ceil_div(N, BN)
n_tg_m = ceil_div(M, BM)
result = kern(
inputs=[x, w, scales, biases],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(32 * n_tg_n, 2 * n_tg_m, 1),
threadgroup=(32, 2, 1),
)
return result[0].reshape(M, N)
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Split-K int8 quantized GEMM (gs=64, bits=8).
Int8 analogue of matmul/kernels/bf16/gemm_splitk.py: partitions K across
P threadgroups per output tile, each writing a partial fp32 result to a
scratch buffer; a second dispatch sums the partials and casts to bf16.
Key modification from the bf16 version: the W loader dequantizes 8-bit
uint8 weights on the fly using per-group bf16 scales/biases, following
the same pattern as matmul/kernels/quantized/qmm_bm16.py.
BK is chosen equal to GROUP_SIZE (64) so each K-iteration advances the
scales/biases pointers by exactly one group, eliminating the need for an
inner group_step counter and keeping partition starts aligned to group
boundaries.
Usage:
from qmm_splitk import custom_qmm_splitk
y = custom_qmm_splitk(x, w, scales, biases, M=16, N=8192, K=5120, BM=16)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def next_power_of_2(n):
if n <= 0:
return 1
return 1 << (n - 1).bit_length()
def compute_partitions(M, N, K):
"""Same formula as gemm_splitk_steel.compute_partitions (bf16)."""
_tm = ceil_div(M, 32)
_tn = ceil_div(N, 32)
_tk = K // 16
tmtn = max(_tm * _tn, 1)
return min(max(2, next_power_of_2(_tk // tmtn)), 32)
def _gen_splitk_qmm_source(M_val, N_val, K_val, BM, split_k_partitions, group_size=64):
"""Int8 split-K GEMM source. BK is fixed to group_size so scales/biases
advance by exactly one group per K-iteration (no group_step counter).
"""
gs = group_size
BN = 32
BK = gs # = 64
if BM <= 8:
WM, WN = 1, 2
else:
WM, WN = 2, 2
tgp_size = WM * WN * 32 # 64 or 128
BK_PAD = BK + 8 # bank-conflict padding (matches Steel's tgp_padding for bf16)
gemm_k_iterations = (K_val // BK) // split_k_partitions
split_k_partition_size = gemm_k_iterations * BK
# Loader params
# X (bf16, BROWS=BM, BCOLS=BK): each thread reads x_n_reads values
x_total = BM * BK
x_n_reads = max(1, x_total // tgp_size)
x_TCOLS = BK // x_n_reads if x_n_reads > 0 else BK
# W (uint8, BROWS=BN, BCOLS=BK): each thread reads w_n_reads bytes
w_n_reads = (BN * BK) // tgp_size
TM = BM // (8 * WM)
TN = BN // (8 * WN)
# Unrolled accumulator declarations: TM*TN simdgroup_matrix<float,8,8> per SG
c_decl_lines = []
for ti in range(TM):
for tj in range(TN):
c_decl_lines.append(
f" simdgroup_matrix<float, 8, 8> C{ti}{tj} = simdgroup_matrix<float, 8, 8>(0);"
)
c_decl = "\n".join(c_decl_lines)
# Inner MMA step (inside the BK/8 loop over kk)
mma_lines = []
for ti in range(TM):
mma_lines.append(
f" simdgroup_matrix<float, 8, 8> A{ti};\n"
f" simdgroup_load(A{ti}, &Xs[(tm + {ti} * {WM} * 8) * BK_PAD + kk], BK_PAD);"
)
for tj in range(TN):
mma_lines.append(
f" simdgroup_matrix<float, 8, 8> B{tj};\n"
f" simdgroup_load(B{tj}, &Ws[(tn + {tj} * {WN} * 8) * BK_PAD + kk], BK_PAD, ulong2(0, 0), true);"
)
for ti in range(TM):
for tj in range(TN):
mma_lines.append(
f" simdgroup_multiply_accumulate(C{ti}{tj}, A{ti}, B{tj}, C{ti}{tj});"
)
mma_load = "\n".join(mma_lines)
# Final accumulator store into Ws (then copied to device y as partial result)
store_lines = []
for ti in range(TM):
for tj in range(TN):
store_lines.append(
f" simdgroup_store(C{ti}{tj}, &Ws[(tm + {ti} * {WM} * 8) * BN + tn + {tj} * {WN} * 8], BN);"
)
c_store = "\n".join(store_lines)
# Unrolled vectorized X load (per thread)
x_vec = "\n".join(
f" x_dst[{i}] = float(x_src[{i}]);" for i in range(x_n_reads)
)
# Unrolled W dequantize (per thread)
w_vec = "\n".join(
f" w_dst[{i}] = scale * float(w_src[{i}]) + bias;" for i in range(w_n_reads)
)
return f"""
const int BM = {BM};
const int BN = {BN};
const int BK = {BK};
const int BK_PAD = {BK_PAD};
const int K = {K_val};
const int N = {N_val};
const int M = {M_val};
const int GROUP_SIZE = {gs};
const int K_groups = {K_val // gs};
const int PARTITION_SIZE = {split_k_partition_size};
const int GEMM_K_ITERS = {gemm_k_iterations};
const int SPLIT_K_PARTS = {split_k_partitions};
const int PART_STRIDE = M * N;
uint3 tid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int thread_idx = sgid * 32 + slid;
int partition_idx = tid.z;
int y_row = tid.y * BM;
int y_col = tid.x * BN;
if (y_row >= M || y_col >= N) return;
threadgroup float Xs[{BM} * {BK_PAD}];
threadgroup float Ws[{BN} * {BK_PAD}];
int k_start = PARTITION_SIZE * partition_idx;
int k_group_start = k_start / GROUP_SIZE;
// Pointer setup. Scales/biases are stored as bf16 in real quantized
// models (the source Linear weight was bf16 before quantization), and the
// weight itself is a packed uint32 buffer — we reinterpret it as uint8*.
const device bfloat16_t* x_base = (const device bfloat16_t*)x + y_row * K + k_start;
const device uint8_t* w_base = (const device uint8_t*)w + y_col * K + k_start;
const device bfloat16_t* s_base = (const device bfloat16_t*)scales + y_col * K_groups + k_group_start;
const device bfloat16_t* b_base = (const device bfloat16_t*)biases + y_col * K_groups + k_group_start;
// BlockLoader X: (BROWS=BM, BCOLS=BK)
int x_bi = thread_idx / {x_TCOLS};
int x_bj = {x_n_reads} * (thread_idx % {x_TCOLS});
const device bfloat16_t* x_src = x_base + x_bi * K + x_bj;
threadgroup float* x_dst = Xs + x_bi * BK_PAD + x_bj;
// QuantizedBlockLoader W: (BROWS=BN, BCOLS=BK, bits=8, pack_factor=1)
int w_bi = ({w_n_reads} * thread_idx) / {BK};
int w_bj = ({w_n_reads} * thread_idx) % {BK};
const device uint8_t* w_src = w_base + w_bi * K + w_bj;
const device bfloat16_t* w_scales = s_base + w_bi * K_groups;
const device bfloat16_t* w_biases = b_base + w_bi * K_groups;
threadgroup float* w_dst = Ws + w_bi * BK_PAD + w_bj;
// MMA setup
short sg_row = sgid / {WN};
short sg_col = sgid % {WN};
short tm = 8 * sg_row;
short tn = 8 * sg_col;
{c_decl}
int k_iters = GEMM_K_ITERS;
if (partition_idx == SPLIT_K_PARTS - 1) {{
k_iters = (K - k_start) / BK;
}}
// K-loop: each iter advances by BK == GROUP_SIZE, so scales/biases
// advance by exactly one group per iteration.
for (int ki = 0; ki < k_iters; ki++) {{
// Load X tile (bf16 -> float)
if (x_bi < BM) {{
{x_vec}
}}
// Load + dequantize W tile (uint8 -> float via scale * v + bias)
if (w_bi < BN) {{
float scale = float(*w_scales);
float bias = float(*w_biases);
{w_vec}
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
// MMA: BK/8 simdgroup 8x8 accumulations
for (short kk = 0; kk < BK; kk += 8) {{
{mma_load}
}}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
x_src += BK;
w_src += BK;
w_scales += 1;
w_biases += 1;
}}
// Write partial fp32 result to y[partition_idx, y_row:y_row+BM, y_col:y_col+BN]
{c_store}
threadgroup_barrier(metal::mem_flags::mem_threadgroup);
for (int i = thread_idx; i < BM * BN; i += {tgp_size}) {{
int r = i / BN;
int c = i % BN;
if (y_row + r < M && y_col + c < N) {{
y[partition_idx * PART_STRIDE + (y_row + r) * N + y_col + c] = Ws[r * BN + c];
}}
}}
"""
def _gen_accum_source(split_k_partitions):
"""Identical to gemm_splitk._gen_accum_source — sum partitions, cast to bf16."""
return f"""
uint gid_x = thread_position_in_grid.x;
uint gid_y = thread_position_in_grid.y;
uint N_val = threads_per_grid.x;
int offset = gid_y * N_val + gid_x;
int stride = threads_per_grid.x * threads_per_grid.y;
float out = 0.0f;
for (int p = 0; p < {split_k_partitions}; p++) {{
out += ((const device float*)c_split)[offset + p * stride];
}}
y[gid_y * N_val + gid_x] = static_cast<bfloat16_t>(out);
"""
_gemm_cache = {}
_accum_cache = {}
def custom_qmm_splitk(x, w, scales, biases, M, N, K, BM=16, group_size=64):
"""Split-K int8 quantized GEMM.
Args:
x: (M, K) bfloat16 input
w: (N, K/4) uint32 packed int8 weights (pack_factor=1 for bits=8)
scales: (N, K/gs) bfloat16
biases: (N, K/gs) bfloat16
M, N, K: matrix dimensions
BM: 8, 16, or 32
group_size: quantization group size (must be 64 for now — BK is fixed to gs)
Returns:
y: (M, N) bfloat16
"""
assert group_size == 64, "qmm_splitk currently fixes BK=GROUP_SIZE=64"
BN = 32
BK = group_size
if BM <= 8:
WM, WN = 1, 2
else:
WM, WN = 2, 2
P = compute_partitions(M, N, K)
gemm_key = (M, N, K, BM, P, group_size)
if gemm_key not in _gemm_cache:
_gemm_cache[gemm_key] = mx.fast.metal_kernel(
name=f"qmm_splitk_bm{BM}_M{M}_N{N}_K{K}_P{P}",
input_names=["x", "w", "scales", "biases"],
output_names=["y"],
source=_gen_splitk_qmm_source(M, N, K, BM, P, group_size),
)
gemm_kern = _gemm_cache[gemm_key]
sgs = WM * WN
n_tg_n = ceil_div(N, BN)
n_tg_m = ceil_div(M, BM)
c_split = gemm_kern(
inputs=[x, w, scales, biases],
output_shapes=[(P * M * N,)],
output_dtypes=[mx.float32],
grid=(32 * n_tg_n, sgs * n_tg_m, P),
threadgroup=(32, sgs, 1),
)[0]
accum_key = P
if accum_key not in _accum_cache:
_accum_cache[accum_key] = mx.fast.metal_kernel(
name=f"qmm_splitk_accum_P{P}",
input_names=["c_split"],
output_names=["y"],
source=_gen_accum_source(P),
)
accum_kern = _accum_cache[accum_key]
y = accum_kern(
inputs=[c_split],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(N, M, 1),
threadgroup=(min(N, 256), 1, 1),
)[0]
return y.reshape(M, N)
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Isolated loop-over-B GEMV kernel for quantized matmul.
Extracts the loop-over-B pattern from batched_fused_gdn_projections_8bit
but without any epilogues — pure Y = X @ dequant(W)^T output.
For comparing our GEMV approach against MLX's affine_qmv_fast on
an isolated QuantizedLinear operation (e.g., in_proj_qkv: N=8192, K=2048).
TG: (32, 2, 1) = 64 threads = 2 SGs.
Each SG: 4 output rows.
B loop inside row loop for low register pressure (R = 4B + 5).
Usage:
from custom_qmv_loop_over_b import custom_qmv_loop_over_b
y = custom_qmv_loop_over_b(x, w, scales, biases, M=8, N=8192, K=2048)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_custom_qmv_source(M_val, N_val, K_val, group_size=64):
gs = group_size
sc_stride = 256 // gs
slid_div = gs // 8
K_groups = K_val // gs
B = M_val # batch size = M
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int K = {K_val};
const int N = {N_val};
const int M = {M_val};
const int K_groups = {K_groups};
const int SC_STRIDE = {sc_stride};
const int SLID_DIV = {slid_div};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int tg = tgid.y;
int out_row = tg * 8 + sgid * RESULTS_PER_SG;
if (out_row >= N) return;
// Weight pointers
const device uint8_t* ws = (const device uint8_t*)w + (long)out_row * K + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc = (const device bfloat16_t*)scales + (long)out_row * K_groups + slid / SLID_DIV;
const device bfloat16_t* bi = (const device bfloat16_t*)biases + (long)out_row * K_groups + slid / SLID_DIV;
// Result accumulators: 4 rows × B batches
float result[{4 * B}];
for (int i = 0; i < {4 * B}; i++) result[i] = 0;
int x_base = slid * VALUES_PER_THREAD;
// K-loop: loop over B inside row loop
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* wl = ws + row * K;
float s_val = float(sc[row * K_groups]);
float b_val = float(bi[row * K_groups]);
for (int b = 0; b < {B}; b++) {{
float accum = 0, xsum = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(((const device bfloat16_t*)x)[b * K + x_base + i]);
accum += xi * float(wl[i]);
xsum += xi;
}}
result[b * 4 + row] += s_val * accum + xsum * b_val;
}}
}}
ws += BLOCK_SIZE; sc += SC_STRIDE; bi += SC_STRIDE; x_base += BLOCK_SIZE;
}}
// Reduction
for (int i = 0; i < {4 * B}; i++) result[i] = simd_sum(result[i]);
// Write output (bf16)
if (slid < 4u) {{
for (int b = 0; b < {B}; b++) {{
int r = out_row + (int)slid;
if (r < N) {{
y[b * N + r] = static_cast<bfloat16_t>(result[b * 4 + slid]);
}}
}}
}}
"""
_custom_qmv_cache = {}
def custom_qmv_loop_over_b(x, w, scales, biases, M, N, K, group_size=64):
"""Loop-over-B GEMV for quantized matmul.
Args:
x: (M, K) bfloat16 input
w: (N, K/4) uint32 packed 8-bit weights
scales: (N, K/gs) bfloat16
biases: (N, K/gs) bfloat16
M, N, K: dimensions
Returns:
y: (M, N) bfloat16
"""
key = (M, N, K, group_size)
if key not in _custom_qmv_cache:
_custom_qmv_cache[key] = mx.fast.metal_kernel(
name=f"custom_qmv_loop_b_M{M}_N{N}_K{K}",
input_names=["x", "w", "scales", "biases"],
output_names=["y"],
source=_gen_custom_qmv_source(M, N, K, group_size),
)
kern = _custom_qmv_cache[key]
n_tg = ceil_div(N, 8)
result = kern(
inputs=[x, w, scales, biases],
output_shapes=[(M * N,)],
output_dtypes=[mx.bfloat16],
grid=(32, n_tg * 2, 1),
threadgroup=(32, 2, 1),
)
return result[0].reshape(M, N)
@@ -0,0 +1,7 @@
// Copyright © 2024 Apple Inc.
#pragma once
#define STEEL_CONST static constant constexpr const
#define STEEL_PRAGMA_UNROLL _Pragma("clang loop unroll(full)")
#define STEEL_PRAGMA_NO_UNROLL _Pragma("clang loop unroll(disable)")
@@ -0,0 +1,295 @@
// Copyright © 2024 Apple Inc.
#pragma once
#include "mlx/backend/metal/kernels/steel/gemm/loader.h"
#include "mlx/backend/metal/kernels/steel/gemm/mma.h"
#include "mlx/backend/metal/kernels/steel/gemm/params.h"
#include "mlx/backend/metal/kernels/steel/gemm/transforms.h"
#include "mlx/backend/metal/kernels/steel/utils.h"
using namespace metal;
///////////////////////////////////////////////////////////////////////////////
// GEMM kernel class
///////////////////////////////////////////////////////////////////////////////
namespace mlx {
namespace steel {
template <bool M_aligned, bool N_aligned, bool K_aligned>
struct LoopAlignment {};
template <
typename T,
typename U,
int BM,
int BN,
int BK,
int WM,
int WN,
bool transpose_a,
bool transpose_b,
bool MN_aligned,
bool K_aligned,
typename AccumType = typename AccumHelper<T>::accum_type,
typename Epilogue = TransformNone<U, AccumType>>
struct GEMMKernel {
STEEL_CONST short tgp_padding_a = 16 / sizeof(T);
STEEL_CONST short tgp_padding_b = 16 / sizeof(T);
STEEL_CONST short tgp_mem_size_a =
transpose_a ? BK * (BM + tgp_padding_a) : BM * (BK + tgp_padding_a);
STEEL_CONST short tgp_mem_size_b =
transpose_b ? BN * (BK + tgp_padding_b) : BK * (BN + tgp_padding_b);
STEEL_CONST short tgp_mem_size = tgp_mem_size_a + tgp_mem_size_b;
STEEL_CONST short tgp_size = WM * WN * 32;
using loader_a_t = BlockLoader<
T,
transpose_a ? BK : BM,
transpose_a ? BM : BK,
transpose_a ? BM + tgp_padding_a : BK + tgp_padding_a,
!transpose_a,
tgp_size>;
using loader_b_t = BlockLoader<
T,
transpose_b ? BN : BK,
transpose_b ? BK : BN,
transpose_b ? BK + tgp_padding_b : BN + tgp_padding_b,
transpose_b,
tgp_size>;
using mma_t = BlockMMA<
T,
U,
BM,
BN,
BK,
WM,
WN,
transpose_a,
transpose_b,
transpose_a ? BM + tgp_padding_a : BK + tgp_padding_a,
transpose_b ? BK + tgp_padding_b : BN + tgp_padding_b,
AccumType,
Epilogue>;
/* Main kernel function */
template <bool M_aligned, bool N_aligned, bool K_aligned_>
static METAL_FUNC void gemm_loop(
threadgroup T* As [[threadgroup(0)]],
threadgroup T* Bs [[threadgroup(1)]],
const int gemm_k_iterations,
thread loader_a_t& loader_a,
thread loader_b_t& loader_b,
thread mma_t& mma_op,
thread const short& tgp_bm,
thread const short& tgp_bn,
thread const short& lbk,
LoopAlignment<M_aligned, N_aligned, K_aligned_> l = {}) {
// Appease the compiler
(void)l;
short2 tile_dims_A = transpose_a ? short2(tgp_bm, BK) : short2(BK, tgp_bm);
short2 tile_dims_B = transpose_b ? short2(BK, tgp_bn) : short2(tgp_bn, BK);
for (int k = 0; k < gemm_k_iterations; k++) {
threadgroup_barrier(mem_flags::mem_threadgroup);
// Load elements into threadgroup
if (M_aligned) {
loader_a.load_unsafe();
} else {
loader_a.load_safe(tile_dims_A);
}
if (N_aligned) {
loader_b.load_unsafe();
} else {
loader_b.load_safe(tile_dims_B);
}
threadgroup_barrier(mem_flags::mem_threadgroup);
// Multiply and accumulate threadgroup elements
mma_op.mma(As, Bs);
// Prepare for next iteration
loader_a.next();
loader_b.next();
}
if (!K_aligned_) {
threadgroup_barrier(mem_flags::mem_threadgroup);
short2 tile_dims_A_last =
transpose_a ? short2(tgp_bm, lbk) : short2(lbk, tgp_bm);
short2 tile_dims_B_last =
transpose_b ? short2(lbk, tgp_bn) : short2(tgp_bn, lbk);
loader_a.load_safe(tile_dims_A_last);
loader_b.load_safe(tile_dims_B_last);
threadgroup_barrier(mem_flags::mem_threadgroup);
mma_op.mma(As, Bs);
}
}
/* Main kernel function */
static METAL_FUNC void run(
const device T* A [[buffer(0)]],
const device T* B [[buffer(1)]],
device U* D [[buffer(2)]],
const constant GEMMParams* params [[buffer(3)]],
threadgroup T* As [[threadgroup(0)]],
threadgroup T* Bs [[threadgroup(1)]],
uint simd_lane_id [[thread_index_in_simdgroup]],
uint simd_group_id [[simdgroup_index_in_threadgroup]],
uint3 tid [[threadgroup_position_in_grid]],
uint3 lid [[thread_position_in_threadgroup]]) {
// Pacifying compiler
(void)lid;
const int tid_y = ((tid.y) << params->swizzle_log) +
((tid.x) & ((1 << params->swizzle_log) - 1));
const int tid_x = (tid.x) >> params->swizzle_log;
if (params->tiles_n <= tid_x || params->tiles_m <= tid_y) {
return;
}
threadgroup_barrier(mem_flags::mem_none);
// Find block in A, B, C
const int c_row = tid_y * BM;
const int c_col = tid_x * BN;
const size_t c_row_long = size_t(c_row);
const size_t c_col_long = size_t(c_col);
A += transpose_a ? c_row_long : c_row_long * params->lda;
B += transpose_b ? c_col_long * params->ldb : c_col_long;
D += c_row_long * params->ldd + c_col_long;
// Prepare threadgroup loading operations
thread loader_a_t loader_a(A, params->lda, As, simd_group_id, simd_lane_id);
thread loader_b_t loader_b(B, params->ldb, Bs, simd_group_id, simd_lane_id);
// Prepare threadgroup mma operation
thread mma_t mma_op(simd_group_id, simd_lane_id);
int gemm_k_iterations = params->gemm_k_iterations_aligned;
///////////////////////////////////////////////////////////////////////////////
// MNK aligned loop
if (MN_aligned) {
for (int k = 0; k < gemm_k_iterations; k++) {
threadgroup_barrier(mem_flags::mem_threadgroup);
// Load elements into threadgroup
loader_a.load_unsafe();
loader_b.load_unsafe();
threadgroup_barrier(mem_flags::mem_threadgroup);
// Multiply and accumulate threadgroup elements
mma_op.mma(As, Bs);
// Prepare for next iteration
loader_a.next();
loader_b.next();
}
threadgroup_barrier(mem_flags::mem_none);
// Loop tail
if (!K_aligned) {
int lbk = params->K - params->gemm_k_iterations_aligned * BK;
short2 tile_dims_A = transpose_a ? short2(BM, lbk) : short2(lbk, BM);
short2 tile_dims_B = transpose_b ? short2(lbk, BN) : short2(BN, lbk);
loader_a.load_safe(tile_dims_A);
loader_b.load_safe(tile_dims_B);
threadgroup_barrier(mem_flags::mem_threadgroup);
mma_op.mma(As, Bs);
}
// Store results to device memory
mma_op.store_result(D, params->ldd);
return;
}
///////////////////////////////////////////////////////////////////////////////
// MN unaligned loop
else { // Loop over K - unaligned case
short tgp_bm = min(BM, params->M - c_row);
short tgp_bn = min(BN, params->N - c_col);
short leftover_bk = params->K - params->gemm_k_iterations_aligned * BK;
if (tgp_bm == BM && tgp_bn == BN) {
gemm_loop<true, true, K_aligned>(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk);
mma_op.store_result(D, params->ldd);
return;
} else if (tgp_bn == BN) {
gemm_loop<false, true, K_aligned>(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk);
mma_op.store_result_safe(D, params->ldd, short2(tgp_bn, tgp_bm));
return;
} else if (tgp_bm == BM) {
gemm_loop<true, false, K_aligned>(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk);
mma_op.store_result_safe(D, params->ldd, short2(tgp_bn, tgp_bm));
return;
} else {
gemm_loop<false, false, K_aligned>(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk);
mma_op.store_result_safe(D, params->ldd, short2(tgp_bn, tgp_bm));
return;
}
}
}
};
} // namespace steel
} // namespace mlx
@@ -0,0 +1,227 @@
// Copyright © 2024 Apple Inc.
using namespace mlx::steel;
///////////////////////////////////////////////////////////////////////////////
// GEMM kernels
///////////////////////////////////////////////////////////////////////////////
template <
typename T,
typename U,
int BM,
int BN,
int BK,
int WM,
int WN,
bool transpose_a,
bool transpose_b,
bool MN_aligned,
bool K_aligned>
[[kernel, max_total_threads_per_threadgroup(WM * WN * 32)]] void gemm_splitk(
const device T* A [[buffer(0)]],
const device T* B [[buffer(1)]],
device U* C [[buffer(2)]],
const constant GEMMSpiltKParams* params [[buffer(3)]],
uint simd_lane_id [[thread_index_in_simdgroup]],
uint simd_group_id [[simdgroup_index_in_threadgroup]],
uint3 tid [[threadgroup_position_in_grid]],
uint3 lid [[thread_position_in_threadgroup]]) {
(void)lid;
using gemm_kernel = GEMMKernel<
T,
U,
BM,
BN,
BK,
WM,
WN,
transpose_a,
transpose_b,
MN_aligned,
K_aligned>;
using loader_a_t = typename gemm_kernel::loader_a_t;
using loader_b_t = typename gemm_kernel::loader_b_t;
using mma_t = typename gemm_kernel::mma_t;
threadgroup T As[gemm_kernel::tgp_mem_size_a];
threadgroup T Bs[gemm_kernel::tgp_mem_size_b];
const int tid_x = tid.x;
const int tid_y = tid.y;
const int tid_z = tid.z;
if (params->tiles_n <= tid_x || params->tiles_m <= tid_y) {
return;
}
// Find block in A, B, C
const int c_row = tid_y * BM;
const int c_col = tid_x * BN;
const int k_start = params->split_k_partition_size * tid_z;
const size_t c_row_long = size_t(c_row);
const size_t c_col_long = size_t(c_col);
const size_t k_start_long = size_t(k_start);
A += transpose_a ? (c_row_long + k_start_long * params->lda)
: (k_start_long + c_row_long * params->lda);
B += transpose_b ? (k_start_long + c_col_long * params->ldb)
: (c_col_long + k_start_long * params->ldb);
C += (size_t(params->split_k_partition_stride) * tid_z) +
(c_row_long * params->ldc + c_col_long);
// Prepare threadgroup loading operations
thread loader_a_t loader_a(A, params->lda, As, simd_group_id, simd_lane_id);
thread loader_b_t loader_b(B, params->ldb, Bs, simd_group_id, simd_lane_id);
// Prepare threadgroup mma operation
thread mma_t mma_op(simd_group_id, simd_lane_id);
int gemm_k_iterations = params->gemm_k_iterations_aligned;
short tgp_bm = min(BM, params->M - c_row);
short tgp_bn = min(BN, params->N - c_col);
short leftover_bk = params->K % BK;
if (MN_aligned || (tgp_bm == BM && tgp_bn == BN)) {
gemm_kernel::gemm_loop(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk,
LoopAlignment<true, true, true>{});
} else if (tgp_bn == BN) {
gemm_kernel::gemm_loop(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk,
LoopAlignment<false, true, true>{});
} else if (tgp_bm == BM) {
gemm_kernel::gemm_loop(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk,
LoopAlignment<true, false, true>{});
} else {
gemm_kernel::gemm_loop(
As,
Bs,
gemm_k_iterations,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk,
LoopAlignment<false, false, true>{});
}
threadgroup_barrier(mem_flags::mem_threadgroup);
if ((tid_z + 1) == (params->split_k_partitions)) {
int gemm_k_iter_remaining =
(params->K - (k_start + params->split_k_partition_size)) / BK;
if (!K_aligned || gemm_k_iter_remaining > 0)
gemm_kernel::gemm_loop(
As,
Bs,
gemm_k_iter_remaining,
loader_a,
loader_b,
mma_op,
tgp_bm,
tgp_bn,
leftover_bk,
LoopAlignment<false, false, K_aligned>{});
}
if (MN_aligned || (tgp_bm == BM && tgp_bn == BN)) {
mma_op.store_result(C, params->ldc);
} else {
mma_op.store_result_safe(C, params->ldc, short2(tgp_bn, tgp_bm));
}
}
///////////////////////////////////////////////////////////////////////////////
// Split k accumulation kernel
///////////////////////////////////////////////////////////////////////////////
template <
typename AccT,
typename OutT,
typename Epilogue = TransformNone<OutT, AccT>>
[[kernel]] void gemm_splitk_accum(
const device AccT* C_split [[buffer(0)]],
device OutT* D [[buffer(1)]],
const constant int& k_partitions [[buffer(2)]],
const constant int& partition_stride [[buffer(3)]],
const constant int& ldd [[buffer(4)]],
uint2 gid [[thread_position_in_grid]]) {
// Ajust D and C
D += gid.x + gid.y * size_t(ldd);
C_split += gid.x + gid.y * size_t(ldd);
size_t offset = 0;
AccT out = 0;
for (int i = 0; i < k_partitions; i++) {
out += C_split[offset];
offset += partition_stride;
}
// Write output
D[0] = Epilogue::apply(out);
}
template <
typename AccT,
typename OutT,
typename Epilogue = TransformAxpby<OutT, AccT>>
[[kernel]] void gemm_splitk_accum_axpby(
const device AccT* C_split [[buffer(0)]],
device OutT* D [[buffer(1)]],
const constant int& k_partitions [[buffer(2)]],
const constant int& partition_stride [[buffer(3)]],
const constant int& ldd [[buffer(4)]],
const device OutT* C [[buffer(5)]],
const constant int& ldc [[buffer(6)]],
const constant int& fdc [[buffer(7)]],
const constant float& alpha [[buffer(8)]],
const constant float& beta [[buffer(9)]],
uint2 gid [[thread_position_in_grid]]) {
// Ajust D and C
C += gid.x * size_t(fdc) + gid.y * size_t(ldc);
D += gid.x + gid.y * size_t(ldd);
C_split += gid.x + gid.y * size_t(ldd);
size_t offset = 0;
AccT out = 0;
for (int i = 0; i < k_partitions; i++) {
out += C_split[offset];
offset += partition_stride;
}
// Write output
Epilogue op(alpha, beta);
D[0] = op.apply(out, *C);
}
@@ -0,0 +1,137 @@
// Copyright © 2024 Apple Inc.
#pragma once
#include "mlx/backend/metal/kernels/steel/defines.h"
///////////////////////////////////////////////////////////////////////////////
// Loading helper
///////////////////////////////////////////////////////////////////////////////
namespace mlx {
namespace steel {
template <
typename T,
short BROWS,
short BCOLS,
short dst_ld,
short reduction_dim,
short tgp_size,
short alignment = 1,
short n_reads = (BCOLS * BROWS) / (tgp_size),
short TCOLS = BCOLS / n_reads,
short TROWS = tgp_size / TCOLS>
struct BlockLoader {
STEEL_CONST short n_rows = (BROWS + TROWS - 1) / TROWS;
STEEL_CONST short vec_size = n_reads;
// Leading dimension for src
const int src_ld;
const int tile_stride;
// Thread location indices
const short thread_idx;
const short bi;
const short bj;
// threadgroup and device memory
threadgroup T* dst;
const device T* src;
struct alignas(alignment * sizeof(T)) ReadVector {
uint8_t v[sizeof(T) * vec_size];
};
/* Constructor */
METAL_FUNC BlockLoader(
const device T* src_,
const int src_ld_,
threadgroup T* dst_,
ushort simd_group_id [[simdgroup_index_in_threadgroup]],
ushort simd_lane_id [[thread_index_in_simdgroup]])
: src_ld(src_ld_),
tile_stride(reduction_dim ? BCOLS : BROWS * src_ld),
thread_idx(simd_group_id * 32 + simd_lane_id),
bi(thread_idx / TCOLS),
bj(vec_size * (thread_idx % TCOLS)),
dst(dst_ + bi * dst_ld + bj),
src(src_ + bi * src_ld + bj) {}
/* Apply operation to threadgroup without bound checking */
template <typename UnaryOp>
METAL_FUNC void apply_inplace_op(thread const UnaryOp& op) const {
STEEL_PRAGMA_UNROLL
for (short i = 0; i < BROWS; i += TROWS) {
STEEL_PRAGMA_UNROLL
for (short j = 0; j < vec_size; j++) {
dst[i * dst_ld + j] = op.apply(dst[i * dst_ld + j]);
}
}
}
/* Load from device memory into threadgroup memory - without bound checking */
METAL_FUNC void load_unsafe() const {
STEEL_PRAGMA_UNROLL
for (short i = 0; i < BROWS; i += TROWS) {
*((threadgroup ReadVector*)(&dst[i * dst_ld])) =
*((const device ReadVector*)(&src[i * src_ld]));
}
}
/* Load from device memory into threadgroup memory - with bound checking */
METAL_FUNC void load_safe(short2 src_tile_dim) const {
src_tile_dim = src_tile_dim - short2(bj, bi);
// Skip loading if thread has no valid reads
if (src_tile_dim.x <= 0 || src_tile_dim.y <= 0) {
STEEL_PRAGMA_UNROLL
for (short i = 0; i < BROWS; i += TROWS) {
STEEL_PRAGMA_UNROLL
for (short j = 0; j < vec_size; j++) {
dst[i * dst_ld + j] = T(0);
}
}
return;
}
// Use fast thread memory for bound checks
bool tmp_idx[vec_size];
T tmp_val[vec_size];
STEEL_PRAGMA_UNROLL
for (short i = 0; i < BROWS; i += TROWS) {
// Make sure tmp_idx only contains valid indices
STEEL_PRAGMA_UNROLL
for (short j = 0; j < vec_size; j++) {
tmp_idx[j] = (i < src_tile_dim.y) && (j < src_tile_dim.x);
}
// Read valid indices into tmp_val
STEEL_PRAGMA_UNROLL
for (short j = 0; j < vec_size; j++) {
tmp_val[j] = src[(tmp_idx[j] ? i * src_ld + j : 0)];
}
// Zero out unneeded values
STEEL_PRAGMA_UNROLL
for (short j = 0; j < vec_size; j++) {
tmp_val[j] = tmp_idx[j] ? tmp_val[j] : T(0);
}
// Copy values to threadgroup memory
STEEL_PRAGMA_UNROLL
for (short j = 0; j < vec_size; j++) {
dst[i * dst_ld + j] = tmp_val[j];
}
}
}
/* Iteration helper */
METAL_FUNC void next() {
src += tile_stride;
}
};
} // namespace steel
} // namespace mlx
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,65 @@
// Copyright © 2024 Apple Inc.
#pragma once
///////////////////////////////////////////////////////////////////////////////
// GEMM param classes
///////////////////////////////////////////////////////////////////////////////
namespace mlx {
namespace steel {
struct GEMMParams {
const int M;
const int N;
const int K;
const int lda;
const int ldb;
const int ldd;
const int tiles_n;
const int tiles_m;
const int64_t batch_stride_a;
const int64_t batch_stride_b;
const int64_t batch_stride_d;
const int swizzle_log;
const int gemm_k_iterations_aligned;
const int batch_ndim;
};
struct GEMMSpiltKParams {
const int M;
const int N;
const int K;
const int lda;
const int ldb;
const int ldc;
const int tiles_n;
const int tiles_m;
const int split_k_partitions;
const int split_k_partition_stride;
const int split_k_partition_size;
const int swizzle_log;
const int gemm_k_iterations_aligned;
};
struct GEMMAddMMParams {
const int ldc;
const int fdc;
const int64_t batch_stride_c;
const float alpha;
const float beta;
};
} // namespace steel
} // namespace mlx
@@ -0,0 +1,72 @@
// Copyright © 2024 Apple Inc.
#pragma once
#include "mlx/backend/metal/kernels/steel/utils.h"
///////////////////////////////////////////////////////////////////////////////
// Transforms and Epilogues
///////////////////////////////////////////////////////////////////////////////
namespace mlx {
namespace steel {
template <typename OutT, typename InT>
struct TransformNone {
static METAL_FUNC OutT apply(InT x) {
return static_cast<OutT>(x);
}
static METAL_FUNC OutT apply(InT x, OutT) {
return static_cast<OutT>(x);
}
};
template <typename OutT, typename InT>
struct TransformAdd {
TransformAdd(const float, const float) {}
static METAL_FUNC OutT apply(InT x) {
return static_cast<OutT>(x);
}
static METAL_FUNC OutT apply(InT x, OutT c) {
return static_cast<OutT>(x) + c;
}
};
template <typename OutT, typename InT>
struct TransformAxpby {
const float alpha;
const float beta;
TransformAxpby(const float alpha_, const float beta_)
: alpha(alpha_), beta(beta_) {}
static METAL_FUNC OutT apply(InT x) {
return static_cast<OutT>(x);
}
METAL_FUNC OutT apply(InT x, OutT c) const {
return static_cast<OutT>(
x * static_cast<InT>(alpha) + (static_cast<OutT>(beta) * c));
}
};
template <typename T>
struct AccumHelper {
typedef float accum_type;
};
struct BlockSwizzle {
static METAL_FUNC int2
swizzle(uint3 tid [[threadgroup_position_in_grid]], const int swizzle_log) {
const int tid_x = (tid.x) >> swizzle_log;
const int tid_y =
((tid.y) << swizzle_log) + ((tid.x) & ((1 << swizzle_log) - 1));
return int2(tid_x, tid_y);
}
};
} // namespace steel
} // namespace mlx
@@ -0,0 +1,42 @@
// Copyright © 2024 Apple Inc.
#pragma once
#include <metal_stdlib>
METAL_FUNC ulong2 elem_to_loc_broadcast(
uint elem,
constant const int* shape,
constant const int64_t* a_strides,
constant const int64_t* b_strides,
int ndim) {
ulong loc_a{0};
ulong loc_b{0};
for (int i = ndim - 1; i >= 0 && elem > 0; --i) {
int pos_in_dim = (elem % shape[i]);
elem /= shape[i];
loc_a += pos_in_dim * a_strides[i];
loc_b += pos_in_dim * b_strides[i];
}
return ulong2(loc_a, loc_b);
}
METAL_FUNC ulong3 elem_to_loc_broadcast(
uint elem,
constant const int* shape,
constant const int64_t* a_strides,
constant const int64_t* b_strides,
constant const int64_t* c_strides,
int ndim) {
ulong loc_a{0};
ulong loc_b{0};
ulong loc_c{0};
for (int i = ndim - 1; i >= 0 && elem > 0; --i) {
int pos_in_dim = (elem % shape[i]);
elem /= shape[i];
loc_a += pos_in_dim * a_strides[i];
loc_b += pos_in_dim * b_strides[i];
loc_c += pos_in_dim * c_strides[i];
}
return ulong3(loc_a, loc_b, loc_c);
}
@@ -0,0 +1,134 @@
// Copyright © 2024 Apple Inc.
#pragma once
#include <metal_stdlib>
#include "mlx/backend/metal/kernels/steel/utils/type_traits.h"
#pragma METAL internals : enable
namespace mlx {
namespace steel {
///////////////////////////////////////////////////////////////////////////////
// Integral constant with casting
///////////////////////////////////////////////////////////////////////////////
template <typename T, T v>
struct integral_constant {
static constexpr constant T value = v;
using value_type = T;
using type = integral_constant;
METAL_FUNC constexpr operator value_type() const noexcept {
return value;
}
// METAL_FUNC constexpr value_type operator()() const noexcept {
// return value;
// }
};
template <bool B>
using bool_constant = integral_constant<bool, B>;
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;
template <class T>
struct is_integral : bool_constant<metal::is_integral<T>::value> {};
template <class T, T v>
struct is_integral<integral_constant<T, v>>
: bool_constant<metal::is_integral<T>::value> {};
template <typename T>
constexpr constant bool is_integral_v = is_integral<T>::value;
template <int val>
using Int = integral_constant<int, val>;
///////////////////////////////////////////////////////////////////////////////
// Binary Operators on Integral constants
///////////////////////////////////////////////////////////////////////////////
#define integral_const_binop(__op__, __operator__) \
template <typename T, T tv, typename U, U uv> \
METAL_FUNC constexpr auto __operator__( \
integral_constant<T, tv>, integral_constant<U, uv>) { \
constexpr auto res = tv __op__ uv; \
return integral_constant<decltype(res), res>{}; \
}
integral_const_binop(+, operator+);
integral_const_binop(-, operator-);
integral_const_binop(*, operator*);
integral_const_binop(/, operator/);
integral_const_binop(==, operator==);
integral_const_binop(!=, operator!=);
integral_const_binop(<, operator<);
integral_const_binop(>, operator>);
integral_const_binop(<=, operator<=);
integral_const_binop(>=, operator>=);
integral_const_binop(&&, operator&&);
integral_const_binop(||, operator||);
template <typename T, typename = metal::enable_if_t<!is_integral_v<T>>>
METAL_FUNC constexpr auto operator||(true_type, T) {
return true_type{};
}
template <typename T, typename = metal::enable_if_t<!is_integral_v<T>>>
METAL_FUNC constexpr auto operator||(T, true_type) {
return true_type{};
}
template <typename T, typename = metal::enable_if_t<!is_integral_v<T>>>
METAL_FUNC constexpr auto operator&&(false_type, T) {
return false_type{};
}
template <typename T, typename = metal::enable_if_t<!is_integral_v<T>>>
METAL_FUNC constexpr auto operator&&(T, false_type) {
return false_type{};
}
// Dispatch utilities
template <typename F>
void dispatch_bool(bool v, F f) {
if (v) {
f(true_type{});
} else {
f(false_type{});
}
}
template <int start, int stop, int step, typename F>
constexpr void const_for_loop(F f) {
if constexpr (start < stop) {
constexpr auto idx = Int<start>{};
f(idx);
const_for_loop<start + step, stop, step, F>(f);
}
}
#undef integral_const_binop
///////////////////////////////////////////////////////////////////////////////
// Reduction operators
///////////////////////////////////////////////////////////////////////////////
template <typename T>
METAL_FUNC constexpr T sum(T x) {
return x;
}
template <typename T, typename... Us>
METAL_FUNC constexpr auto sum(T x, Us... us) {
return x + sum(us...);
}
} // namespace steel
} // namespace mlx
#pragma METAL internals : disable
@@ -0,0 +1,55 @@
// Copyright © 2024 Apple Inc.
#pragma once
#include <metal_stdlib>
#pragma METAL internals : enable
namespace metal {
template <typename T>
struct is_empty : metal::bool_constant<__is_empty(T)> {};
#ifdef __cpp_variable_templates
template <typename T>
constexpr constant bool is_empty_v = is_empty<T>::value;
#endif
template <typename... Ts>
struct make_void {
typedef void type;
};
template <typename... Ts>
using void_t = typename make_void<Ts...>::type;
template <class T>
struct is_static : metal::bool_constant<is_empty<remove_cv_t<T>>::value> {};
template <typename T>
struct pointer_element {};
template <typename T>
struct pointer_element<thread T*> {
using type = remove_cv_t<T>;
};
template <typename T>
struct pointer_element<device T*> {
using type = remove_cv_t<T>;
};
template <typename T>
struct pointer_element<constant T*> {
using type = remove_cv_t<T>;
};
template <typename T>
struct pointer_element<threadgroup T*> {
using type = remove_cv_t<T>;
};
template <typename T>
using pointer_element_t = typename pointer_element<remove_cv_t<T>>::type;
} // namespace metal
#pragma METAL internals : disable
Whitespace-only changes.
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Kernel picker — single source of truth for selecting optimal matmul
kernel per (N, K, M, dtype).
Used by both the drafter patch (speculative/bf16_lpb_patch.py) and the
target patch (patches/qwen3_5/lpb_patch.py). Selections are derived from
mlx_bench matmul/RESULTS.md winners rounded up to the nearest benchmark
M column (M ∈ {1, 2, 4, 8, 12, 16, 32, 64}).
Returns a uniform-signature callable:
- bf16: fn(x, w, M, N, K)
- int8: fn(x, w, scales, biases, M, N, K, group_size)
"""
from ..kernels.bf16.gemv_loop_over_b import custom_bf16_qmv_loop_over_b as _bf16_lpb
from ..kernels.bf16.gemv_loop_over_b_twice import custom_bf16_qmv_loop_over_b_twice as _bf16_lpb_twice
from ..kernels.bf16.gemm_bm8 import custom_bf16_gemm as _bf16_bm8 # noqa: F401
from ..kernels.bf16.gemm_splitk_steel import custom_bf16_gemm_splitk_steel as _bf16_sk_steel
from ..kernels.quantized.qmv_loop_over_b import custom_qmv_loop_over_b as _int8_lpb
from ..kernels.quantized.qmm_bm8 import custom_qmm_t_bm8 as _int8_bm8 # noqa: F401
from ..kernels.quantized.qmm_bm16 import custom_qmm_t_bm16 as _int8_bm16 # noqa: F401
from ..kernels.quantized.qmm_splitk import custom_qmm_splitk as _int8_qsk
def _round_up_to_bench_col(M):
for col in (1, 2, 4, 8, 12, 16, 32, 64):
if M <= col:
return col
return 64
def pick_bf16_kernel(N, K, M):
"""Return (name, fn) for the best bf16 kernel at this (N, K, M).
fn signature: fn(x, w, M, N, K) → y (M, N)
KNOWN BUG: sk_steel8/16 produce silently incorrect output at very large
N (verified broken at N=248320, K=5120 with 100% rel error). For
lm_head-sized projections (N > 50000) we force lpb_twice, which is
also the fastest CORRECT kernel at those dims.
"""
M_rnd = _round_up_to_bench_col(M)
# Safety guard for large-N projections (e.g. lm_head).
if N > 50000:
if M_rnd <= 8:
return "lpb", _bf16_lpb
return "lpb_twice", _bf16_lpb_twice
if M_rnd <= 8:
return "lpb", _bf16_lpb
if M_rnd == 12:
if max(N, K) <= 4096:
return "lpb_twice", _bf16_lpb_twice
fn = lambda x, w, M_, N_, K_: _bf16_sk_steel(x, w, M_, N_, K_, BM=16)
return "sk_steel16", fn
if M_rnd == 16:
fn = lambda x, w, M_, N_, K_: _bf16_sk_steel(x, w, M_, N_, K_, BM=16)
return "sk_steel16", fn
# M = 32, 64
fn = lambda x, w, M_, N_, K_: _bf16_sk_steel(x, w, M_, N_, K_, BM=32)
return "sk_steel32", fn
def pick_int8_kernel(N, K, M):
"""Return (name, fn) for the best int8 kernel at this (N, K, M).
fn signature: fn(x, w, scales, biases, M, N, K, group_size) → y (M, N)
"""
M_rnd = _round_up_to_bench_col(M)
if M_rnd <= 8:
return "lpb", _int8_lpb
if M_rnd == 12:
fn = lambda x, w, s, b, M_, N_, K_, gs: _int8_qsk(x, w, s, b, M_, N_, K_, BM=16, group_size=gs)
return "qsk16", fn
if M_rnd == 16:
fn = lambda x, w, s, b, M_, N_, K_, gs: _int8_qsk(x, w, s, b, M_, N_, K_, BM=16, group_size=gs)
return "qsk16", fn
# M = 32, 64
fn = lambda x, w, s, b, M_, N_, K_, gs: _int8_qsk(x, w, s, b, M_, N_, K_, BM=32, group_size=gs)
return "qsk32", fn
+54 -2
View File
@@ -1,3 +1,10 @@
import json
import os
from pathlib import Path
import mlx.nn as nn
from loguru import logger
from exo.worker.engines.mlx.patches.opt_batch_gen import apply_batch_gen_patch
from exo.worker.engines.mlx.patches.standard_yarn_rope import patch_yarn_rope
@@ -10,5 +17,50 @@ def apply_mlx_patches() -> None:
return
_applied = True
patch_yarn_rope()
# patch_gdn_softplus()
apply_batch_gen_patch()
# Skip fast_next patch when speculative is enabled — it bypasses _next()
# which MTPBatchGenerator overrides for speculative decoding
if os.environ.get("EXO_SPECULATIVE") != "1":
apply_batch_gen_patch()
def maybe_apply_patches(model: nn.Module, model_path: Path) -> None:
"""Detect model type and apply kernel fusion patches if available."""
fused_mode = os.environ.get("EXO_FUSED_KERNELS", "0")
config_path = model_path / "config.json"
if not config_path.exists():
return
with open(config_path) as f:
config = json.load(f)
model_type = config.get("model_type", "")
if fused_mode == "0":
# Fused kernels disabled — fall back to dynamic LpB patches for both
# dense and MoE Qwen3.5. apply_lpb_patches silently skips submodules
# it doesn't recognize (e.g. routed experts), so it's safe on either
# variant.
if model_type in ("qwen3_5", "qwen3_5_moe"):
from .qwen3_5.lpb_patch import apply_lpb_patches
logger.info(
f"Fused kernels disabled (EXO_FUSED_KERNELS=0); "
f"applying LpB patches for {model_type}"
)
apply_lpb_patches(model, batch_size=4)
else:
logger.info("Kernel fusion patches disabled (EXO_FUSED_KERNELS=0)")
return
if model_type == "qwen3_5_moe":
from .qwen3_5_moe.apply import apply_qwen35_batched_fused_patches
logger.info("Detected Qwen3.5 MoE model, applying batched fused kernel patches")
apply_qwen35_batched_fused_patches(model)
elif model_type == "qwen3_5":
from .qwen3_5.lpb_patch import apply_lpb_patches
logger.info("Detected Qwen3.5 dense model, applying LpB kernel patches")
apply_lpb_patches(model, batch_size=4)
@@ -85,7 +85,7 @@ def _fast_next(self: BatchGenerator) -> list[BatchGenerator.Response]:
_pending_topk_idx = mx.take_along_axis(_pending_topk_idx, sort_order, axis=1)
_pending_topk_val = mx.take_along_axis(_pending_topk_val, sort_order, axis=1)
_pending_selected_lps = logprobs[mx.arange(batch_size), batch.y]
mx.async_eval(
mx.eval(
batch.y,
*batch.logprobs,
*batch.tokens,
@@ -97,7 +97,7 @@ def _fast_next(self: BatchGenerator) -> list[BatchGenerator.Response]:
_pending_topk_idx = None
_pending_topk_val = None
_pending_selected_lps = None
mx.async_eval(batch.y, *batch.logprobs, *batch.tokens)
mx.eval(batch.y, *batch.logprobs, *batch.tokens)
prev_token_list: list[int] = cast(list[int], prev_tokens.tolist())
Whitespace-only changes.
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Dynamic per-call loop-over-B patches for Qwen3.5-27B (bf16 or 8-bit target).
For each projection, the kernel is picked at CALL time based on the actual
M seen in the forward pass. Memoized per projection so runtime overhead is
a dict lookup after the first call at a given M.
Target-side projections during verify all see M = verify_len + 1 (uniform),
but we use dynamic picking for consistency with the drafter patch and to
support arbitrary (BS, V) without re-patching.
Patches (every layer):
- MLP: gate_proj, up_proj, down_proj
- GQA attn: q_proj, k_proj, v_proj, o_proj
- GDN attn: in_proj_qkv, in_proj_z, out_proj
- lm_head (runs during verify forward)
"""
import mlx.nn as nn
from exo.worker.engines.mlx.matmul.patches.kernel_picker import (
pick_bf16_kernel,
pick_int8_kernel,
)
MAX_M = 16 # Above this (prefill), fall back to the original projection
def _make_bf16_forward(original, N, K):
cache = {}
def forward(self_unused, x):
M = 1
for d in x.shape[:-1]:
M *= d
if M > MAX_M:
return original(x)
fn = cache.get(M)
if fn is None:
_, fn = pick_bf16_kernel(N, K, M)
cache[M] = fn
orig_shape = x.shape
x_2d = x.reshape(-1, K)
y = fn(x_2d, original.weight, M, N, K)
return y.reshape(*orig_shape[:-1], N)
return forward
def _make_int8_forward(original, N, K, GS):
cache = {}
def forward(self_unused, x):
M = 1
for d in x.shape[:-1]:
M *= d
if M > MAX_M:
return original(x)
fn = cache.get(M)
if fn is None:
_, fn = pick_int8_kernel(N, K, M)
cache[M] = fn
orig_shape = x.shape
x_2d = x.reshape(-1, K)
y = fn(x_2d, original.weight, original.scales, original.biases,
M, N, K, GS)
return y.reshape(*orig_shape[:-1], N)
return forward
def _patch_proj(parent, proj_name):
proj = getattr(parent, proj_name, None)
if proj is None:
return 0
if isinstance(proj, nn.QuantizedLinear):
N = proj.weight.shape[0]
K = proj.weight.shape[1] * (32 // proj.bits)
GS = proj.group_size
forward = _make_int8_forward(proj, N, K, GS)
setattr(parent, proj_name, type('LpBQuant', (), {
'__call__': forward,
'weight': proj.weight,
'scales': proj.scales,
'biases': proj.biases,
})())
return 1
elif isinstance(proj, nn.Linear):
N = proj.weight.shape[0]
K = proj.weight.shape[1]
forward = _make_bf16_forward(proj, N, K)
setattr(parent, proj_name, type('LpBLinear', (), {
'__call__': forward,
'weight': proj.weight,
})())
return 1
return 0
def apply_lpb_patches(model, batch_size=None, verify_len=None):
"""Patch all Qwen3.5-27B projections with dynamic LpB kernels.
Note: batch_size / verify_len args are kept for backward compat but ignored.
Kernel is picked at call time based on actual M.
"""
inner = getattr(model, 'model', None) or model.language_model.model
patched = 0
for _li, layer in enumerate(inner.layers):
mlp = layer.mlp
for pn in ('gate_proj', 'up_proj', 'down_proj'):
patched += _patch_proj(mlp, pn)
# MoE (Qwen3NextSparseMoeBlock): dense sub-modules worth LpB-patching.
# Silent-skip on dense 27B (attributes don't exist). Routed experts
# (switch_mlp) use SwitchLinear which needs routing indices — left on
# stock.
patched += _patch_proj(mlp, 'gate')
patched += _patch_proj(mlp, 'shared_expert_gate')
shared = getattr(mlp, 'shared_expert', None)
if shared is not None:
for pn in ('gate_proj', 'up_proj', 'down_proj'):
patched += _patch_proj(shared, pn)
if layer.is_linear:
attn = layer.linear_attn
for pn in ('in_proj_qkv', 'in_proj_z', 'out_proj'):
patched += _patch_proj(attn, pn)
else:
attn = layer.self_attn
for pn in ('q_proj', 'k_proj', 'v_proj', 'o_proj'):
patched += _patch_proj(attn, pn)
# Qwen3.5 / Qwen3.5-MoE keep lm_head on the TextModel wrapper
# (model.language_model.lm_head), not on model or the inner Qwen3_5TextModel.
# Check all three levels so we cover both layouts.
for holder in (model, getattr(model, 'language_model', None), inner):
if holder is None:
continue
if hasattr(holder, 'lm_head') and holder.lm_head is not None:
patched += _patch_proj(holder, 'lm_head')
break
print(f" Patched {patched} target projections with dynamic LpB")
return patched
Whitespace-only changes.
@@ -0,0 +1,74 @@
"""Apply batched fused kernel patches to Qwen3.5 MoE models.
Entry point called from patches/__init__.py after model type detection.
"""
import time
import mlx.nn as nn
from loguru import logger
from .common import (
_patch_swiglu_weights,
_patch_shared_expert,
_patch_down_proj,
_patch_oproj_gate_rms,
_patch_gdn_proj_weights,
_patch_gqa_proj_weights,
)
from mlx_lm.models.qwen3_5 import DecoderLayer
from mlx_lm.models.qwen3_next import Qwen3NextAttention, Qwen3NextSparseMoeBlock
from mlx_lm.models.qwen3_5 import GatedDeltaNet
def apply_qwen35_batched_fused_patches(model: nn.Module) -> None:
"""Apply batched fused patches (GDN + GQA attention + oproj MoE) to all layers.
Fused GDN attention (3/4 layers) + fused GQA projections (1/4 layers)
+ batched oproj MoE (4 custom dispatches). Works with BatchGenerator for
any batch size 1..8. Falls back to vanilla for B>8 or S>1.
"""
layers = model.layers # type: ignore[attr-defined]
n_layers = len(layers)
t0 = time.time()
n_gdn = 0
n_gqa = 0
for li, layer in enumerate(layers):
moe = layer.mlp
if isinstance(moe, Qwen3NextSparseMoeBlock):
# MoE weight prep
_patch_swiglu_weights(moe)
_patch_shared_expert(moe)
_patch_down_proj(moe)
_patch_oproj_gate_rms(layer, gate_bm=8)
# Attention weight prep
if layer.is_linear:
_patch_gdn_proj_weights(layer.linear_attn)
n_gdn += 1
else:
_patch_gqa_proj_weights(layer.self_attn)
n_gqa += 1
if (li + 1) % 10 == 0 or li == 0:
logger.info(f" Patched layer {li+1}/{n_layers}")
# Import patched __call__ methods
from .fused_gdn_attention import _fused_gdn_call
from .batched_fused_gqa_attention import _batched_fused_gqa_call
from .batched_moe import _batched_oproj_moe_call
from .decoder import _fused_gdn_decoder_call
# Class-level method replacement
GatedDeltaNet.__call__ = _fused_gdn_call
Qwen3NextAttention.__call__ = _batched_fused_gqa_call
Qwen3NextSparseMoeBlock.__call__ = _batched_oproj_moe_call
DecoderLayer.__call__ = _fused_gdn_decoder_call
t_patch = time.time() - t0
logger.info(
f"Qwen3.5 batched fused: {n_gdn} GDN + {n_gqa} GQA layers, "
f"{n_layers} total in {t_patch:.1f}s"
)
@@ -0,0 +1,102 @@
"""Batched fused GQA attention for Qwen3.5 (projections + norm+rope fused, vanilla SDPA).
Dispatches:
1. batched_fused_gqa_projections — merged q+gate+k+v GEMV with register weight sharing
2. fused_qk_norm_rope — per-head RMSNorm + RoPE (already supports B>1 via grid z)
3. Vanilla cache update (BatchKVCache)
4. Vanilla SDPA (MLX built-in, handles batching natively)
5. Vanilla gate multiply
Returns pre-out_proj output for the oproj MoE block.
Falls back to vanilla (with o_proj) for B>8 or S>1.
"""
from typing import Any, Optional
import mlx.core as mx
from .kernels.batched_fused_gqa_projections_8bit import batched_fused_gqa_projections
def _batched_fused_gqa_call(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
"""Batched fused GQA attention with custom projection + norm/rope kernels.
For 1<=B<=8, S=1: fused projections + fused norm+rope + vanilla SDPA.
For B>8 or S>1: vanilla fallback.
Returns pre-out_proj output [B, S, H_q*D].
"""
B, S, _ = x.shape
if S > 1 or B > 8:
# Vanilla fallback
q_proj_output = self.q_proj(x)
queries, gate = mx.split(
q_proj_output.reshape(B, S, self.num_attention_heads, -1), 2, axis=-1
)
gate = gate.reshape(B, S, -1)
keys, values = self.k_proj(x), self.v_proj(x)
queries = self.q_norm(queries).transpose(0, 2, 1, 3)
keys = self.k_norm(
keys.reshape(B, S, self.num_key_value_heads, -1)
).transpose(0, 2, 1, 3)
values = values.reshape(B, S, self.num_key_value_heads, -1).transpose(
0, 2, 1, 3
)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
from mlx_lm.models.qwen3_next import scaled_dot_product_attention
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, S, -1)
return self.o_proj(output * mx.sigmoid(gate))
H_q = self.num_attention_heads
H_kv = self.num_key_value_heads
D = self.head_dim
# ── Dispatch 1: batched fused projections ──
queries, gate_sigmoid, keys, values = batched_fused_gqa_projections(
x,
self._merged_proj_w, self._merged_proj_s, self._merged_proj_b,
self._merged_proj_dims,
batch_size=B,
total_tg=getattr(self, '_d1_total_tg', None),
)
# ── Dispatch 2+: vanilla norm + rope (avoids mx.eval sync on BatchKVCache offset) ──
queries = self.q_norm(queries.reshape(B, 1, H_q, D)).transpose(0, 2, 1, 3)
keys = self.k_norm(keys.reshape(B, 1, H_kv, D)).transpose(0, 2, 1, 3)
values = values.reshape(B, 1, H_kv, D).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
else:
queries = self.rope(queries)
keys = self.rope(keys)
# ── Dispatch 3: KV cache update ──
if cache is not None:
keys, values = cache.update_and_fetch(keys, values)
# ── Dispatch 4: vanilla SDPA ──
from mlx_lm.models.qwen3_next import scaled_dot_product_attention
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, S, -1)
# ── Gate multiply ──
return output * gate_sigmoid.astype(output.dtype)
@@ -0,0 +1,94 @@
"""Batched oproj MoE with 4 custom Metal kernel dispatches.
Fuses o_proj + RMSNorm + gate GEMV + softmax + topk + SwiGLU + down_proj + epilogue
into 4 dispatches with register-level weight sharing for the shared expert.
Falls back to vanilla MoE when called without _residual (from vanilla decoder path).
"""
import mlx.core as mx
from .kernels.batched_merged_down_proj_8bit import batched_merged_down_proj_8bit
from .kernels.batched_oproj_gate_gemv_8bit import batched_oproj_gate_gemv
from .kernels.batched_softmax_topk_swiglu_8bit import batched_softmax_topk_swiglu_8bit
from .kernels.batched_moe_epilogue import batched_moe_epilogue
def _batched_oproj_moe_call(self, attn_out_3d, _residual=None):
"""Batched MoE with full oproj fusion (4 custom dispatches).
Receives raw attention output (pre-o_proj) and residual for B tokens.
All 4 dispatches use register-level weight sharing for shared weights.
When _residual is None, called from vanilla decoder — do vanilla MoE.
"""
if _residual is None:
# Vanilla MoE path (called from vanilla decoder for B>8 or S>1)
x = attn_out_3d
gates = self.gate(x)
gates = mx.softmax(gates, axis=-1, precise=True)
k = self.top_k
inds = mx.argpartition(gates, kth=-k, axis=-1)[..., -k:]
scores = mx.take_along_axis(gates, inds, axis=-1)
if self.norm_topk_prob:
scores = scores / scores.sum(axis=-1, keepdims=True)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2)
shared_y = self.shared_expert(x)
shared_y = mx.sigmoid(self.shared_expert_gate(x)) * shared_y
return y + shared_y
B_dim = attn_out_3d.shape[0]
K = self._oproj_M
K_attn = self._oproj_K_attn
n_active = self.top_k
E = self._oproj_n_experts
attn_out = attn_out_3d.reshape(B_dim, K_attn).astype(mx.bfloat16)
residual = _residual.reshape(B_dim, K).astype(mx.bfloat16)
# ── Dispatch 1: batched o_proj + gate GEMVs ──
h_scaled, h_out, x2_partials, gate_part_a, gate_part_b = \
batched_oproj_gate_gemv(
self._oproj_w, self._oproj_s, self._oproj_b,
attn_out, residual, self._oproj_rms_weight,
self._oproj_M1, self._oproj_W_fused,
M=K, K_attn=K_attn, batch_size=B_dim,
n_experts=E, gate_bm=self._oproj_gate_bm,
K_hidden=self._oproj_K_hidden,
)
n_oproj_tg = (K + 31) // 32
N_INTER = self.switch_mlp._fused_n_inter
SHARED_INTER = self._shared_inter
# ── Dispatch 2: batched softmax + topk + SwiGLU ──
y_routed, y_shared, out_inds, norm_scores, gate_raw = \
batched_softmax_topk_swiglu_8bit(
self.switch_mlp._fused_w_gu, self.switch_mlp._fused_s_gu,
self.switch_mlp._fused_b_gu,
self._shared_w_gu, self._shared_s_gu, self._shared_b_gu,
self._seg_w, self._seg_s, self._seg_b,
h_scaled, gate_part_a, gate_part_b, x2_partials,
n_inter=N_INTER, k_hidden=K, batch_size=B_dim,
n_active=n_active, n_oproj_tg=n_oproj_tg,
n_experts=E, shared_inter=SHARED_INTER,
)
# ── Dispatch 3: batched merged down_proj ──
d_routed, d_shared = batched_merged_down_proj_8bit(
self._down_w, self._down_s, self._down_b,
self._shared_down_w, self._shared_down_s, self._shared_down_b,
y_routed, y_shared.reshape(B_dim * SHARED_INTER), out_inds,
k_out=K, n_in=self._down_N, batch_size=B_dim,
n_active=n_active, shared_n_in=SHARED_INTER,
)
# ── Dispatch 4: batched epilogue ──
Y = batched_moe_epilogue(
d_routed, d_shared, norm_scores,
h_out, gate_raw,
k_val=K, batch_size=B_dim, n_active=n_active,
)
return Y.reshape(B_dim, 1, K).astype(attn_out_3d.dtype)
@@ -0,0 +1,500 @@
"""Common weight preparation functions for Qwen3.5 fused kernel patches.
Functions:
ceil_div — integer ceiling division
_patch_swiglu_weights — stack gate+up weights for fused SwiGLU kernel
_patch_down_proj — extract down_proj weights for merged kernel dispatch
_patch_shared_expert — prepare shared expert weights (8-bit)
dequantize_shared_expert — convert shared expert from 8-bit to bf16
_patch_oproj_gate_rms — precompute M1/W_fused for fused o_proj + gate GEMV
_patch_gdn_proj_weights — merge GDN projection weights for fused GEMV
_patch_gqa_proj_weights — merge GQA q/k/v weights with q_proj permutation
make_qwen_random_cache — create pre-filled cache for testing
build_model — build Qwen3.5 MoE layers with 8-bit quantization
"""
from types import SimpleNamespace
import mlx.core as mx
import mlx.nn as nn
from mlx_lm.models.qwen3_5 import (
DecoderLayer,
TextModelArgs,
)
from mlx_lm.models.qwen3_next import Qwen3NextSparseMoeBlock
def ceil_div(a, b):
return (a + b - 1) // b
def _patch_swiglu_weights(moe):
"""Stack gate+up weights for fused 8-bit SwiGLU kernel.
Creates concatenated (E, 2*N_INTER, K/4) weight, (E, 2*N_INTER, K/gs) scales/biases
from the separate gate_proj and up_proj QuantizedSwitchLinear layers.
"""
gate_proj = moe.switch_mlp.gate_proj
up_proj = moe.switch_mlp.up_proj
moe.switch_mlp._fused_w_gu = mx.concatenate(
[gate_proj.weight, up_proj.weight], axis=1)
moe.switch_mlp._fused_s_gu = mx.concatenate(
[gate_proj.scales, up_proj.scales], axis=1)
moe.switch_mlp._fused_b_gu = mx.concatenate(
[gate_proj.biases, up_proj.biases], axis=1)
moe.switch_mlp._fused_n_inter = gate_proj.output_dims
moe.switch_mlp._fused_k_hidden = gate_proj.input_dims
moe.switch_mlp._fused_group_size = gate_proj.group_size
mx.eval(moe.switch_mlp._fused_w_gu,
moe.switch_mlp._fused_s_gu,
moe.switch_mlp._fused_b_gu)
def _patch_shared_expert(moe):
"""Prepare shared expert quantized weights for fused 8-bit path.
Stacks shared gate+up quantized weights (weight, scales, biases).
Stores down_proj quantized weights separately.
Shared expert stays in 8-bit — same as vanilla MLX dispatch.
"""
shared = moe.shared_expert
gp = shared.gate_proj
up = shared.up_proj
dp = shared.down_proj
# Gate+up stacked: (2*SHARED_INTER, K/4) uint32, (2*SHARED_INTER, K/gs) bf16
moe._shared_w_gu = mx.concatenate([gp.weight, up.weight], axis=0)
moe._shared_s_gu = mx.concatenate([gp.scales, up.scales], axis=0)
moe._shared_b_gu = mx.concatenate([gp.biases, up.biases], axis=0)
# Down_proj: (K, SHARED_INTER/4) uint32, (K, SHARED_INTER/gs) bf16
moe._shared_down_w = dp.weight
moe._shared_down_s = dp.scales
moe._shared_down_b = dp.biases
# QuantizedLinear: weight is (out_features, in_features/pack_factor) uint32
# For 8-bit: pack_factor = 4, so in_features = weight.shape[1] * 4
moe._shared_inter = gp.weight.shape[0] # SHARED_INTER (= out_features)
moe._shared_gs = gp.group_size # gs (64)
mx.eval(moe._shared_w_gu, moe._shared_s_gu, moe._shared_b_gu,
moe._shared_down_w, moe._shared_down_s, moe._shared_down_b)
def _patch_down_proj(moe):
"""Extract down_proj weights for merged 8-bit kernel dispatch."""
dp = moe.switch_mlp.down_proj
moe._down_w = dp.weight # (E, K_OUT, N_IN/4) uint32
moe._down_s = dp.scales # (E, K_OUT, N_IN/gs) bf16
moe._down_b = dp.biases # (E, K_OUT, N_IN/gs) bf16
moe._down_K = dp.output_dims # K = 4096
moe._down_N = dp.input_dims # N = 1024
moe._down_gs = dp.group_size # gs = 64
mx.eval(moe._down_w, moe._down_s, moe._down_b)
def dequantize_shared_expert(moe):
"""Convert shared expert from 8-bit QuantizedLinear to bf16 weight wrappers.
The fused kernels expect bf16 shared expert weights. The real model (and our
random model) has shared expert quantized to 8-bit. This dequantizes in-place.
"""
shared = moe.shared_expert
for proj_name in ["gate_proj", "up_proj", "down_proj"]:
proj = getattr(shared, proj_name)
if hasattr(proj, 'scales') and hasattr(proj, 'biases'):
w_bf16 = mx.dequantize(
proj.weight, proj.scales, proj.biases,
group_size=proj.group_size, bits=proj.bits,
).astype(mx.bfloat16)
mx.eval(w_bf16)
setattr(shared, proj_name, SimpleNamespace(weight=w_bf16))
def _patch_oproj_gate_rms(layer, gate_bm=8):
"""Precompute M1/W_fused for fused o_proj + gate GEMV (oproj 4-dispatch mode).
Gate decomposition:
gate_score[e] = W_gate[e,:] @ rms_norm(h)
where h = residual + W_oproj @ attn_out
rms_norm(h) = h * w_rms * inv_rms
Expanding:
gate_score[e] = (W_fused @ residual + M1 @ attn_out) * inv_rms
Precomputed offline (per layer, stored on moe block):
W_fused = dequant(W_gate) · diag(w_rms) — (E, K) bf16
M1 = W_fused @ dequant(W_oproj) — (E, K_attn) bf16
Also stores o_proj quantized weights and shared_expert_gate weights
on the moe block for use by Dispatch 1 and Dispatch 2.
Args:
layer: DecoderLayer instance
gate_bm: SGs per gate TG in Dispatch 1 (1,2,4,8)
"""
moe = layer.mlp
# ── Get attention output projection (works for both attention types) ──
if layer.is_linear:
oproj = layer.linear_attn.out_proj
else:
oproj = layer.self_attn.o_proj
# ── Dequantize gate and o_proj (temporary, for M1 computation) ──
# Eval incrementally to limit peak memory (E=512: dequant temps are ~140 MB)
gate = moe.gate
W_gate_f32 = mx.dequantize(
gate.weight, gate.scales, gate.biases,
group_size=gate.group_size, bits=gate.bits,
).astype(mx.float32)
W_oproj_f32 = mx.dequantize(
oproj.weight, oproj.scales, oproj.biases,
group_size=oproj.group_size, bits=oproj.bits,
).astype(mx.float32)
mx.eval(W_gate_f32, W_oproj_f32)
# ── RMSNorm weight ──
rms_weight = layer.post_attention_layernorm.weight.astype(mx.bfloat16)
# ── W_fused = dequant(W_gate) · diag(w_rms) ──
w_rms_f32 = rms_weight.astype(mx.float32)
W_fused = (W_gate_f32 * w_rms_f32).astype(mx.bfloat16)
mx.eval(W_fused)
del W_gate_f32 # free ~8 MB (E=512) or ~1 MB (E=64)
# ── M1 = W_fused @ W_oproj — precomputed in f32, stored bf16 ──
M1 = (W_fused.astype(mx.float32) @ W_oproj_f32).astype(mx.bfloat16)
mx.eval(M1)
del W_oproj_f32 # free ~128 MB
# Store on moe block
moe._oproj_M1 = M1 # (E, K_attn) bf16
moe._oproj_W_fused = W_fused # (E, K) bf16
moe._oproj_rms_weight = rms_weight # (K,) bf16
# ── O_proj quantized weights (for 8-bit GEMV in Dispatch 1) ──
moe._oproj_w = oproj.weight # (K, K_attn/4) uint32
moe._oproj_s = oproj.scales # (K, K_attn/gs) bf16
moe._oproj_b = oproj.biases # (K, K_attn/gs) bf16
moe._oproj_K_attn = oproj.weight.shape[1] * 4 # 8192 (8-bit: pack_factor=4)
# ── Shared expert gate weights (for TG(0,0,0) fusion in Dispatch 2) ──
seg = moe.shared_expert_gate
moe._seg_w = seg.weight # (1, K/4) uint32
moe._seg_s = seg.scales # (1, K/gs) bf16
moe._seg_b = seg.biases # (1, K/gs) bf16
# ── Dimensions ──
M = oproj.weight.shape[0] # 4096 (hidden_size)
K_hidden = W_fused.shape[1] # 4096 (same as M for Qwen)
n_experts = W_fused.shape[0] # E
moe._oproj_M = M
moe._oproj_K_hidden = K_hidden
moe._oproj_n_experts = n_experts
moe._oproj_n_tg = ceil_div(M, 32) # 128 for M=4096
moe._oproj_gate_bm = gate_bm
mx.eval(moe._oproj_rms_weight)
def _patch_gdn_proj_weights(attn):
"""Merge all 4 GDN projection weights into contiguous buffers.
Concatenates in_proj_qkv/z/b/a weights, scales, biases into single
contiguous arrays for better memory locality in the fused GEMV kernel.
Stored on the GatedDeltaNet module as _merged_proj_*.
"""
W_merged = mx.concatenate([
attn.in_proj_qkv.weight,
attn.in_proj_z.weight,
attn.in_proj_b.weight,
attn.in_proj_a.weight,
], axis=0)
S_merged = mx.concatenate([
attn.in_proj_qkv.scales,
attn.in_proj_z.scales,
attn.in_proj_b.scales,
attn.in_proj_a.scales,
], axis=0)
B_merged = mx.concatenate([
attn.in_proj_qkv.biases,
attn.in_proj_z.biases,
attn.in_proj_b.biases,
attn.in_proj_a.biases,
], axis=0)
attn._merged_proj_w = W_merged
attn._merged_proj_s = S_merged
attn._merged_proj_b = B_merged
attn._merged_proj_dims = (
attn.in_proj_qkv.weight.shape[0], # N_QKV = 8192
attn.in_proj_z.weight.shape[0], # N_Z = 4096
attn.in_proj_b.weight.shape[0], # N_B = 32
attn.in_proj_a.weight.shape[0], # N_A = 32
)
mx.eval(W_merged, S_merged, B_merged)
def _patch_gqa_proj_weights(attn):
"""Merge GQA q_proj, k_proj, v_proj weights into contiguous buffers.
q_proj outputs (H_q * 2 * D) = interleaved [queries, gate] per head.
We permute rows so queries (H_q * D) come first, then gate (H_q * D),
then k_proj, then v_proj. This gives clean contiguous regions for
the fused GEMV kernel's TG routing.
Permutation for q_proj:
Original row layout: [head0_q[0:D], head0_gate[0:D], head1_q[0:D], ...]
After permutation: [head0_q, head1_q, ..., head0_gate, head1_gate, ...]
Stored on Qwen3NextAttention as _merged_proj_*.
"""
q = attn.q_proj
k = attn.k_proj
v = attn.v_proj
H_q = attn.num_attention_heads
D = attn.head_dim
# Permute q_proj weights: separate queries and gate rows
# q_proj.weight shape: (H_q * 2 * D, K / pack_factor) for 8-bit
# Reshape to (H_q, 2*D, ...), split into queries[:, :D, :] and gate[:, D:, :]
W_q = q.weight.reshape(H_q, 2 * D, -1)
S_q = q.scales.reshape(H_q, 2 * D, -1)
B_q = q.biases.reshape(H_q, 2 * D, -1)
W_queries = W_q[:, :D, :].reshape(H_q * D, -1)
W_gate = W_q[:, D:, :].reshape(H_q * D, -1)
S_queries = S_q[:, :D, :].reshape(H_q * D, -1)
S_gate = S_q[:, D:, :].reshape(H_q * D, -1)
B_queries = B_q[:, :D, :].reshape(H_q * D, -1)
B_gate = B_q[:, D:, :].reshape(H_q * D, -1)
# Merge: [queries, gate, keys, values]
W_merged = mx.contiguous(mx.concatenate([W_queries, W_gate, k.weight, v.weight], axis=0))
S_merged = mx.contiguous(mx.concatenate([S_queries, S_gate, k.scales, v.scales], axis=0))
B_merged = mx.contiguous(mx.concatenate([B_queries, B_gate, k.biases, v.biases], axis=0))
attn._merged_proj_w = W_merged
attn._merged_proj_s = S_merged
attn._merged_proj_b = B_merged
attn._merged_proj_dims = (
H_q * D, # N_Q = 4096 (queries)
H_q * D, # N_GATE = 4096 (gate)
k.weight.shape[0], # N_K = 512
v.weight.shape[0], # N_V = 512
)
mx.eval(W_merged, S_merged, B_merged)
# Pre-cache constant scalar arrays for kernel dispatch (avoid per-call creation)
N_Q, N_GATE, N_K, N_V = attn._merged_proj_dims
N_TOTAL = N_Q + N_GATE + N_K + N_V
K = q.weight.shape[1] * 4 # 8-bit: pack_factor=4
attn._kernel_scalars = {
# Dispatch 1: fused_gqa_projections
'K': mx.array(K, dtype=mx.int32),
'N_Q': mx.array(N_Q, dtype=mx.int32),
'N_GATE': mx.array(N_GATE, dtype=mx.int32),
'N_K': mx.array(N_K, dtype=mx.int32),
'N_TOTAL': mx.array(N_TOTAL, dtype=mx.int32),
'N_Q_TG': mx.array(ceil_div(N_Q, 8), dtype=mx.int32),
'N_GATE_TG': mx.array(ceil_div(N_GATE, 8), dtype=mx.int32),
'N_K_TG': mx.array(ceil_div(N_K, 8), dtype=mx.int32),
# Dispatch 4-5: custom SDPA
'scale': mx.array(attn.head_dim ** -0.5, dtype=mx.float32),
'H_Q': mx.array(attn.num_attention_heads, dtype=mx.int32),
'H_KV': mx.array(attn.num_key_value_heads, dtype=mx.int32),
'N_blocks': mx.array(128, dtype=mx.int32),
}
mx.eval(*attn._kernel_scalars.values())
# Precompute grid/TG dims for Dispatch 1
N_V_TG = ceil_div(N_V, 8)
attn._d1_total_tg = ceil_div(N_Q, 8) + ceil_div(N_GATE, 8) + ceil_div(N_K, 8) + N_V_TG
# Precompute RoPE inv_freq for fused norm+rope kernel (Dispatch 2)
# inv_freq[d] = theta^(-d / half_dims) for d in {0, ..., half_dims-1}
rope_dims = attn.rope.dims # 64 (partial_rotary_factor * head_dim)
half_dims = rope_dims // 2 # 32
theta = attn.rope.base # 10000000
d_indices = mx.arange(half_dims, dtype=mx.float32)
attn._rope_inv_freq = theta ** (-d_indices / half_dims)
mx.eval(attn._rope_inv_freq)
def make_qwen_random_cache(layer, config, prefill_len):
"""Create a pre-filled cache for a single Qwen3.5 decoder layer.
GatedDeltaNet layers get ArraysCache(size=2) with fixed-size state:
cache[0] = conv state: (B, conv_kernel_size-1, conv_dim) bf16
cache[1] = SSM state: (B, num_v_heads, head_k_dim, head_v_dim) bf16
GQA layers get KVCache with prefill_len tokens:
keys: (B, n_kv_heads, alloc_len, head_dim) bf16
values: (B, n_kv_heads, alloc_len, head_dim) bf16
"""
if layer.is_linear:
from mlx_lm.models.cache import ArraysCache
cache = ArraysCache(size=2)
attn = layer.linear_attn
cache[0] = mx.random.normal(
(1, attn.conv_kernel_size - 1, attn.conv_dim)
).astype(mx.bfloat16)
cache[1] = mx.random.normal(
(1, attn.num_v_heads, attn.head_k_dim, attn.head_v_dim)
).astype(mx.bfloat16)
return cache
else:
from mlx_lm.models.cache import KVCache
cache = KVCache()
n_steps = (prefill_len + KVCache.step - 1) // KVCache.step
alloc_len = n_steps * KVCache.step
n_kv = config.num_key_value_heads
hd = config.head_dim
cache.keys = mx.random.normal((1, n_kv, alloc_len, hd)).astype(mx.bfloat16)
cache.values = mx.random.normal((1, n_kv, alloc_len, hd)).astype(mx.bfloat16)
cache.offset = prefill_len
return cache
def build_model(n_experts=16, n_layers=1, top_k=4,
hidden_size=4096, moe_intermediate_size=1024,
shared_expert_intermediate_size=2048, tp=1,
n_attn_heads=32, n_kv_heads=2,
lin_v_heads=64, lin_k_heads=16,
head_dim=256):
"""Build Qwen3.5 MoE decoder layers with 8-bit gs=64 quantization.
Matches real mlx-community Qwen3.5 quantization:
Everything 8-bit gs=64 (gate, experts, shared expert, shared_expert_gate, attention/SSM).
RMSNorm weights: bf16.
Default dimensions are for Qwen3.5-397B-A17B. For 35B-A3B, pass:
n_attn_heads=16, lin_v_heads=32, hidden_size=2048
Uses qwen3_5.DecoderLayer (same class as real model) with hybrid attention:
3/4 GatedDeltaNet (SSM-like), 1/4 full attention (full_attention_interval=4).
TP=2 halves all column-parallel dimensions.
Returns:
layers: list of DecoderLayer instances
config: TextModelArgs
GROUP_SIZE: int (64)
"""
GROUP_SIZE = 64
BITS = 8
# Apply TP sharding
moe_inter = moe_intermediate_size // tp
shared_inter = shared_expert_intermediate_size // tp
n_attn_heads_tp = n_attn_heads // tp
n_kv_heads_tp = max(1, n_kv_heads // tp)
lin_v_heads_tp = lin_v_heads // tp
lin_k_heads_tp = lin_k_heads // tp
config = TextModelArgs(
model_type="qwen3_5_moe",
hidden_size=hidden_size,
num_hidden_layers=n_layers,
intermediate_size=moe_inter,
num_attention_heads=n_attn_heads_tp,
num_key_value_heads=n_kv_heads_tp,
linear_num_value_heads=lin_v_heads_tp,
linear_num_key_heads=lin_k_heads_tp,
linear_key_head_dim=128,
linear_value_head_dim=128,
linear_conv_kernel_dim=4,
num_experts=n_experts,
num_experts_per_tok=top_k,
decoder_sparse_step=1,
shared_expert_intermediate_size=shared_inter,
moe_intermediate_size=moe_inter,
norm_topk_prob=True,
rms_norm_eps=1e-6,
vocab_size=248320,
head_dim=head_dim,
full_attention_interval=4,
max_position_embeddings=262144,
rope_theta=10000000,
partial_rotary_factor=0.25,
rope_parameters={
"type": "default",
"rope_theta": 10000000,
"partial_rotary_factor": 0.25,
},
)
tp_str = f" (TP={tp})" if tp > 1 else ""
print(f" Config: {n_layers} layer(s), {n_experts} experts, top_k={top_k}, "
f"hidden={hidden_size}, inter={moe_inter}, shared={shared_inter}{tp_str}")
print(f" Quant: {BITS}-bit gs={GROUP_SIZE} (all weights)")
layers = [DecoderLayer(config, idx) for idx in range(n_layers)]
for li, layer in enumerate(layers):
# Cast all attention/SSM params to bf16 before quantizing, matching
# real safetensors model where all non-quantized params are bf16.
# nn.quantize only touches nn.Linear; other params (conv1d, dt_bias,
# norm, A_log) must be cast manually.
attn_mod = layer.linear_attn if layer.is_linear else layer.self_attn
for name, mod in attn_mod.named_modules():
if isinstance(mod, nn.Linear):
mod.weight = mod.weight.astype(mx.bfloat16)
elif isinstance(mod, nn.Conv1d):
mod.weight = mod.weight.astype(mx.bfloat16)
# Cast leaf parameters (dt_bias, norm.weight, q/k_norm) to bf16
# A_log stays f32 (matches real model)
if layer.is_linear:
gdn = layer.linear_attn
gdn.dt_bias = gdn.dt_bias.astype(mx.bfloat16)
gdn.norm.weight = gdn.norm.weight.astype(mx.bfloat16)
else:
gqa = layer.self_attn
gqa.q_norm.weight = gqa.q_norm.weight.astype(mx.bfloat16)
gqa.k_norm.weight = gqa.k_norm.weight.astype(mx.bfloat16)
nn.quantize(attn_mod, bits=BITS, group_size=GROUP_SIZE)
mx.eval(attn_mod.parameters())
# RMSNorm to bf16 (norms are never quantized)
layer.input_layernorm.weight = layer.input_layernorm.weight.astype(mx.bfloat16)
layer.post_attention_layernorm.weight = layer.post_attention_layernorm.weight.astype(mx.bfloat16)
mx.eval(layer.input_layernorm.weight, layer.post_attention_layernorm.weight)
# MoE block: quantize everything to 8-bit gs=64
moe = layer.mlp
if isinstance(moe, Qwen3NextSparseMoeBlock):
# Gate: random init (zeros get optimized away), then quantize.
# nn.quantize on a leaf nn.Linear is a no-op (walks children, finds none).
# Use QuantizedLinear.from_linear directly.
moe.gate.weight = (
mx.random.normal(moe.gate.weight.shape) * 0.01
).astype(mx.float32)
moe.gate = nn.QuantizedLinear.from_linear(
moe.gate, group_size=GROUP_SIZE, bits=BITS)
mx.eval(moe.gate.parameters())
# Routed experts: quantize per-projection to limit peak memory
nn.quantize(moe.switch_mlp, bits=BITS, group_size=GROUP_SIZE)
mx.eval(moe.switch_mlp.gate_proj.parameters())
mx.eval(moe.switch_mlp.up_proj.parameters())
mx.eval(moe.switch_mlp.down_proj.parameters())
# Shared expert: quantize to 8-bit (matching real model)
nn.quantize(moe.shared_expert, bits=BITS, group_size=GROUP_SIZE)
mx.eval(moe.shared_expert.parameters())
# shared_expert_gate: quantize to 8-bit gs=64 (leaf nn.Linear fix)
moe.shared_expert_gate = nn.QuantizedLinear.from_linear(
moe.shared_expert_gate, group_size=GROUP_SIZE, bits=BITS)
mx.eval(moe.shared_expert_gate.parameters())
if (li + 1) % 10 == 0 or li == 0:
print(f" Layer {li+1}/{n_layers} ready")
return layers, config, GROUP_SIZE
@@ -0,0 +1,199 @@
"""Decoder layer __call__ variants for Qwen3.5.
Two modes:
_fused_decoder_call: passes residual to fused MoE epilogue (~15 dispatches)
_oproj_decoder_call: fuses o_proj + RMSNorm + gate GEMV (4 dispatches)
Attention patches for oproj mode:
_pre_oproj_attention_call: Qwen3NextAttention.__call__ that skips o_proj
_pre_oproj_qwen35_linear_attn_call: qwen3_5.GatedDeltaNet.__call__ that skips out_proj
Note: qwen3_5.GatedDeltaNet (used by DecoderLayer) is a DIFFERENT class from
qwen3_next.Qwen3NextGatedDeltaNet. They have different projection layouts:
- qwen3_5.GatedDeltaNet: separate in_proj_qkv, in_proj_z, in_proj_b, in_proj_a
- qwen3_next.Qwen3NextGatedDeltaNet: merged in_proj_qkvz, in_proj_ba
The patch must match qwen3_5.GatedDeltaNet's __call__ structure.
"""
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.activations import silu as nn_silu
# Map moe block id → parent decoder layer (avoids circular refs in model tree)
_parent_layer_map = {}
def _fused_decoder_call(self, x, mask=None, cache=None):
"""Decoder layer with residual passed to fused MoE epilogue.
Replaces:
h = x + attn(norm(x))
out = h + mlp(norm(h)) # mlp returns MoE output, then adds h
With:
h = x + attn(norm(x))
out = mlp(norm(h), _residual=h) # epilogue fuses: moe_out + h
"""
if self.is_linear:
r = self.linear_attn(self.input_layernorm(x), mask, cache)
else:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
out = self.mlp(self.post_attention_layernorm(h), _residual=h)
return out # already includes residual add from epilogue
def _oproj_decoder_call(self, x, mask=None, cache=None):
"""Decoder with fused o_proj + RMSNorm + gate GEMV (oproj 4-dispatch mode).
Skips o_proj, addmm, and post_attention_layernorm — all fused into Dispatch 1.
Attention __call__ is patched to return pre-o_proj output.
Flow:
pre_oproj = attn(input_layernorm(x)) # returns BEFORE o_proj
MoE receives (pre_oproj, residual=x) and handles o_proj + RMSNorm + gate internally
"""
if self.is_linear:
pre_oproj = self.linear_attn(self.input_layernorm(x), mask, cache)
else:
pre_oproj = self.self_attn(self.input_layernorm(x), mask, cache)
_parent_layer_map[id(self.mlp)] = self
return self.mlp(pre_oproj, _residual=x)
def _vanilla_decoder_call(self, x, mask=None, cache=None):
"""Original vanilla DecoderLayer.__call__ (fallback for B>8 or S>1)."""
if self.is_linear:
r = self.linear_attn(self.input_layernorm(x), mask, cache)
else:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
out = self.mlp(self.post_attention_layernorm(h))
return h + out
def _fused_gdn_decoder_call(self, x, mask=None, cache=None):
"""Decoder with batched fused kernels. Falls back to vanilla for B>8 or S>1.
When fused: attention returns pre-out_proj output, MoE handles oproj + gate + experts.
When vanilla: original DecoderLayer flow (attention + residual + layernorm + MoE).
"""
B = x.shape[0]
S = x.shape[1]
# Full vanilla fallback for large batch or prefill
if B > 8 or S > 1:
return _vanilla_decoder_call(self, x, mask, cache)
# Fused path: attention returns pre-oproj, MoE handles the rest
if self.is_linear:
pre_oproj = self.linear_attn(self.input_layernorm(x), mask, cache)
else:
pre_oproj = self.self_attn(self.input_layernorm(x), mask, cache)
_parent_layer_map[id(self.mlp)] = self
return self.mlp(pre_oproj, _residual=x)
def _pre_oproj_attention_call(self, x, mask=None, cache=None):
"""Qwen3NextAttention.__call__ that returns pre-o_proj output.
Identical to original except final line returns output*sigmoid(gate)
instead of self.o_proj(output*sigmoid(gate)).
"""
B, L, D = x.shape
q_proj_output = self.q_proj(x)
queries, gate = mx.split(
q_proj_output.reshape(B, L, self.num_attention_heads, -1), 2, axis=-1
)
gate = gate.reshape(B, L, -1)
keys, values = self.k_proj(x), self.v_proj(x)
queries = self.q_norm(queries).transpose(0, 2, 1, 3)
keys = self.k_norm(
keys.reshape(B, L, self.num_key_value_heads, -1)
).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.num_key_value_heads, -1).transpose(
0, 2, 1, 3
)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
from mlx_lm.models.qwen3_next import scaled_dot_product_attention
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return output * mx.sigmoid(gate) # skip o_proj
def _pre_oproj_qwen35_linear_attn_call(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
"""qwen3_5.GatedDeltaNet.__call__ that returns pre-out_proj output.
Identical to qwen3_5.GatedDeltaNet.__call__ except final line returns
out.reshape(B,S,-1) instead of self.out_proj(out.reshape(B,S,-1)).
Note: this targets qwen3_5.GatedDeltaNet (separate projections), NOT
qwen3_next.Qwen3NextGatedDeltaNet (merged projections). They are
different classes with different __call__ bodies.
"""
from mlx_lm.models.gated_delta import gated_delta_update
B, S, _ = inputs.shape
qkv = self.in_proj_qkv(inputs)
z = self.in_proj_z(inputs).reshape(B, S, self.num_v_heads, self.head_v_dim)
b = self.in_proj_b(inputs)
a = self.in_proj_a(inputs)
if cache is not None and cache[0] is not None:
conv_state = cache[0]
else:
conv_state = mx.zeros(
(B, self.conv_kernel_size - 1, self.conv_dim),
dtype=inputs.dtype,
)
if mask is not None:
qkv = mx.where(mask[..., None], qkv, 0)
conv_input = mx.concatenate([conv_state, qkv], axis=1)
if cache is not None:
cache[0] = conv_input[:, -(self.conv_kernel_size - 1) :]
conv_out = nn.silu(self.conv1d(conv_input))
q, k, v = [
t.reshape(B, S, h, d)
for t, h, d in zip(
mx.split(conv_out, [self.key_dim, 2 * self.key_dim], -1),
[self.num_k_heads, self.num_k_heads, self.num_v_heads],
[self.head_k_dim, self.head_k_dim, self.head_v_dim],
)
]
state = cache[1] if cache else None
inv_scale = k.shape[-1] ** -0.5
q = (inv_scale**2) * mx.fast.rms_norm(q, None, 1e-6)
k = inv_scale * mx.fast.rms_norm(k, None, 1e-6)
out, state = gated_delta_update(
q, k, v, a, b,
self.A_log, self.dt_bias,
state, mask,
use_kernel=True,
)
if cache is not None:
cache[1] = state
out = self.norm(out, z)
return out.reshape(B, S, -1) # skip out_proj
@@ -0,0 +1,161 @@
"""Fused GDN attention __call__ for qwen3_5.GatedDeltaNet (Dispatches 2-5).
Replaces the vanilla GatedDeltaNet.__call__ with fused kernel dispatches:
Dispatch 2: fused_gdn_projections — merged 8-bit GEMV + conv1d + SiLU(qkv) + SiLU(z)
+ sigmoid(b)→beta + g=exp(-exp(A_log)*softplus(a+dt_bias))
Dispatch 3: fused_qk_rmsnorm — per-head L2-norm on q (×Dk^(-½)) and k
Dispatch 4: gated_delta_kernel — GDN recurrence (receives pre-computed g, beta)
Dispatch 5: fused_rms_norm_gated — RMSNorm(out, weight) × z_silu
All 4 projection weights are pre-merged into contiguous buffers at patch time
(_patch_gdn_proj_weights) for better memory locality.
g/beta computation is fused into Dispatch 2 epilogues, eliminating ~8 micro-
dispatches that gated_delta_update would otherwise generate.
Fused path is decode-only (S=1). For prefill (S>1), falls back to vanilla ops.
Returns pre-out_proj output (same interface as _pre_oproj_qwen35_linear_attn_call).
Dispatch 1 (input_layernorm) is handled by the decoder.
Dispatch 6 (oproj_gate_gemv) is handled by the MoE __call__.
"""
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .kernels.batched_fused_gdn_projections_8bit import batched_fused_gdn_projections as fused_gdn_projections
from .kernels.fused_qk_rmsnorm import fused_qk_rmsnorm
from .kernels.fused_rms_norm_gated import fused_rms_norm_gated
def _vanilla_gdn_call(self, inputs, mask, cache):
"""Vanilla GDN path for prefill (S>1). Returns pre-out_proj output."""
from mlx_lm.models.gated_delta import gated_delta_update
B, S, _ = inputs.shape
qkv = self.in_proj_qkv(inputs)
z = self.in_proj_z(inputs).reshape(B, S, self.num_v_heads, self.head_v_dim)
b = self.in_proj_b(inputs)
a = self.in_proj_a(inputs)
if cache is not None and cache[0] is not None:
conv_state = cache[0]
else:
conv_state = mx.zeros(
(B, self.conv_kernel_size - 1, self.conv_dim),
dtype=inputs.dtype,
)
if mask is not None:
qkv = mx.where(mask[..., None], qkv, 0)
conv_input = mx.concatenate([conv_state, qkv], axis=1)
if cache is not None:
cache[0] = conv_input[:, -(self.conv_kernel_size - 1):]
conv_out = nn.silu(self.conv1d(conv_input))
q, k, v = [
t.reshape(B, S, h, d)
for t, h, d in zip(
mx.split(conv_out, [self.key_dim, 2 * self.key_dim], -1),
[self.num_k_heads, self.num_k_heads, self.num_v_heads],
[self.head_k_dim, self.head_k_dim, self.head_v_dim],
)
]
state = cache[1] if cache else None
inv_scale = k.shape[-1] ** -0.5
q = inv_scale * q * mx.rsqrt(
(q * q).sum(axis=-1, keepdims=True) + 1e-6
)
k = k * mx.rsqrt(
(k * k).sum(axis=-1, keepdims=True) + 1e-6
)
out, state = gated_delta_update(
q, k, v, a, b,
self.A_log, self.dt_bias,
state, mask,
use_kernel=True,
)
if cache is not None:
cache[1] = state
out = self.norm(out, z)
return self.out_proj(out.reshape(B, S, -1)) # include out_proj for vanilla decoder
def _fused_gdn_call(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
"""Fused GDN attention: merged projections + existing GDN kernel.
Decode (S=1): uses fused kernels with merged weight buffers.
Prefill (S>1): falls back to vanilla ops.
Returns pre-out_proj output [B, S, value_dim] for Dispatch 6.
"""
B, S, _ = inputs.shape
# Vanilla fallback: fused kernels are decode-only (S=1, B<=8)
if S > 1 or B > 8:
return _vanilla_gdn_call(self, inputs, mask, cache)
from mlx_lm.models.gated_delta import gated_delta_kernel
# ── Cache: conv state ──
if cache is not None and cache[0] is not None:
conv_state = cache[0]
else:
conv_state = mx.zeros(
(B, self.conv_kernel_size - 1, self.conv_dim),
dtype=inputs.dtype,
)
# ── Dispatch 2: fused projections (merged GEMV + conv + SiLU + g/beta) ──
qkv_conv_silu, z_silu, beta, g, conv_state_out = fused_gdn_projections(
inputs,
self._merged_proj_w, self._merged_proj_s, self._merged_proj_b,
self._merged_proj_dims,
conv_state, self.conv1d.weight,
self.A_log, self.dt_bias,
batch_size=B,
)
if cache is not None:
cache[0] = conv_state_out
# ── Dispatch 3: fused Q/K L2-norm ──
qk_normed = fused_qk_rmsnorm(qkv_conv_silu, batch_size=B)
# ── Split q, k from normed output; v from conv output ──
q = qk_normed[:, :, :self.key_dim].reshape(B, S, self.num_k_heads, self.head_k_dim)
k = qk_normed[:, :, self.key_dim:].reshape(B, S, self.num_k_heads, self.head_k_dim)
v = qkv_conv_silu[:, :, 2 * self.key_dim:].reshape(B, S, self.num_v_heads, self.head_v_dim)
# ── Dispatch 4: GDN recurrence with pre-computed g/beta ──
state = cache[1] if cache else None
if state is None:
state = mx.zeros(
(B, self.num_v_heads, self.head_v_dim, self.head_k_dim),
dtype=inputs.dtype,
)
out, state_new = gated_delta_kernel(
q, k, v, g, beta, state, mask,
)
if cache is not None:
cache[1] = state_new
# ── Dispatch 5: fused RMSNorm × z_silu ──
norm_weight = self.norm.weight
result = fused_rms_norm_gated(out, z_silu, norm_weight, batch_size=B)
return result # [B, S, value_dim] — skip out_proj (handled by Dispatch 6)
Whitespace-only changes.
@@ -0,0 +1,265 @@
"""Batched fused GDN projections for Qwen3.5-35B-A3B, batch_size 1..8.
Register-level weight sharing: each TG loads weights once, computes B outputs.
Adapts fused_gdn_projections_8bit with the same pattern as
batched_fused_gqa_projections_8bit.
4 regions with different epilogues:
- QKV: GEMV → conv1d(4-tap) → SiLU → bf16 + cache update
- Z: GEMV → SiLU → f32
- B: GEMV → sigmoid → f32 (beta for GDN kernel)
- A: GEMV → g=exp(-exp(A_log)*softplus(a+dt_bias)) → f32
All constants baked into Metal source. B unrolled at code-generation time.
Grid: (32, total_tg * 2, 1), TG: (32, 2, 1)
No grid z for batch — batch is handled in registers.
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_batched_fused_gdn_proj_source(K, N_QKV, N_Z, N_B, N_A, B, group_size=64):
gs = int(group_size)
sc_stride = 256 // gs
slid_div = gs // 8
N_TOTAL = N_QKV + N_Z + N_B + N_A
K_groups = K // gs
N_QKV_TG = ceil_div(N_QKV, 8)
N_Z_TG = ceil_div(N_Z, 8)
N_B_TG = ceil_div(N_B, 8)
# Per-batch x loading (B unrolled)
x_load = "\n".join(f"""
float x{b}_thread[VALUES_PER_THREAD]; float xsum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(x[{b} * K + x_base + i]); x{b}_thread[i] = xi; xsum{b} += xi;
}}""" for b in range(B))
# Per-batch dot product with weights in registers
qdot = "\n".join(f"""
float accum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) accum{b} += x{b}_thread[i] * w_vals[i];
result{b}[row] += s_val * accum{b} + xsum{b} * b_val;""" for b in range(B))
result_decls = " ".join(f"float result{b}[4] = {{0,0,0,0}};" for b in range(B))
simd_reduce = "\n ".join(
f"for (int row = 0; row < 4; row++) result{b}[row] = simd_sum(result{b}[row]);" for b in range(B))
# QKV epilogue: conv1d + SiLU + cache update, per batch
qkv_write = "\n".join(f"""
if (slid < 4u && c < N_QKV) {{
float qkv_val = result{b}[slid];
long cs_base = (long){b} * 3 * conv_dim;
float s0 = float(conv_state[cs_base + 0 * conv_dim + c]);
float s1 = float(conv_state[cs_base + 1 * conv_dim + c]);
float s2 = float(conv_state[cs_base + 2 * conv_dim + c]);
float conv_out = float(conv_w[c * 4 + 0]) * s0
+ float(conv_w[c * 4 + 1]) * s1
+ float(conv_w[c * 4 + 2]) * s2
+ float(conv_w[c * 4 + 3]) * qkv_val;
float silu_out = conv_out / (1.0f + metal::exp(-conv_out));
conv_state_out[cs_base + 0 * conv_dim + c] = static_cast<bfloat16_t>(s1);
conv_state_out[cs_base + 1 * conv_dim + c] = static_cast<bfloat16_t>(s2);
conv_state_out[cs_base + 2 * conv_dim + c] = static_cast<bfloat16_t>(qkv_val);
qkv_out[{b} * conv_dim + c] = static_cast<bfloat16_t>(silu_out);
}}""" for b in range(B))
# Z epilogue: SiLU per batch
z_write = "\n".join(f"""
if (slid < 4u && z_row < N_Z) {{
float val = result{b}[slid];
z_silu_out[{b} * N_Z + z_row] = val / (1.0f + metal::exp(-val));
}}""" for b in range(B))
# B epilogue: sigmoid per batch
b_write = "\n".join(f"""
if (slid < 4u && b_row < N_B) {{
b_out[{b} * N_B + b_row] = 1.0f / (1.0f + metal::exp(-result{b}[slid]));
}}""" for b in range(B))
# A epilogue: g computation per batch
a_write = "\n".join(f"""
if (slid < 4u && a_row < N_A_val) {{
float a_val = result{b}[slid];
float dt = float(dt_bias_arr[a_row]);
float x_g = a_val + dt;
float sp = (x_g > 20.0f) ? x_g : metal::log(1.0f + metal::exp(x_g));
float g_val = metal::exp(-metal::exp(float(A_log_arr[a_row])) * sp);
a_out[{b} * N_A_val + a_row] = g_val;
}}""" for b in range(B))
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int GROUP_SIZE = {gs};
const int SC_STRIDE = {sc_stride};
const int SLID_DIV = {slid_div};
const int K = {K};
const int K_groups = {K_groups};
const int N_QKV = {N_QKV};
const int N_Z = {N_Z};
const int N_B = {N_B};
const int N_TOTAL = {N_TOTAL};
const int N_QKV_TG = {N_QKV_TG};
const int N_Z_TG = {N_Z_TG};
const int N_B_TG = {N_B_TG};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int tg = tgid.y;
int out_row, region;
if (tg < N_QKV_TG) {{
region = 0; out_row = tg * 8 + sgid * RESULTS_PER_SG;
}} else if (tg < N_QKV_TG + N_Z_TG) {{
region = 1; out_row = N_QKV + (tg - N_QKV_TG) * 8 + sgid * RESULTS_PER_SG;
}} else if (tg < N_QKV_TG + N_Z_TG + N_B_TG) {{
region = 2; out_row = N_QKV + N_Z + (tg - N_QKV_TG - N_Z_TG) * 8 + sgid * RESULTS_PER_SG;
}} else {{
region = 3; out_row = N_QKV + N_Z + N_B + (tg - N_QKV_TG - N_Z_TG - N_B_TG) * 8 + sgid * RESULTS_PER_SG;
}}
if (out_row >= N_TOTAL) return;
// Weight pointers (shared across all batch elements)
const device uint8_t* ws = (const device uint8_t*)W_merged + (long)out_row * K + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc = (const device bfloat16_t*)S_merged + (long)out_row * K_groups + slid / SLID_DIV;
const device bfloat16_t* bi = (const device bfloat16_t*)B_merged + (long)out_row * K_groups + slid / SLID_DIV;
{result_decls}
int x_base = slid * VALUES_PER_THREAD;
// K-loop: load weights into registers once, compute {B} batch elements
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
{x_load}
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* wl = ws + row * K;
float s_val = float(sc[row * K_groups]);
float b_val = float(bi[row * K_groups]);
float w_vals[VALUES_PER_THREAD];
for (int i = 0; i < VALUES_PER_THREAD; i++) w_vals[i] = float(wl[i]);
{qdot}
}}
ws += BLOCK_SIZE; sc += SC_STRIDE; bi += SC_STRIDE; x_base += BLOCK_SIZE;
}}
{simd_reduce}
// Region-specific epilogues for all {B} batches
if (region == 0) {{
int c = out_row + (int)slid;
int conv_dim = N_QKV;
{qkv_write}
}} else if (region == 1) {{
int z_row = out_row - N_QKV + (int)slid;
{z_write}
}} else if (region == 2) {{
int b_row = out_row - N_QKV - N_Z + (int)slid;
{b_write}
}} else {{
int a_row = out_row - N_QKV - N_Z - N_B + (int)slid;
int N_A_val = N_TOTAL - N_QKV - N_Z - N_B;
{a_write}
}}
"""
_batched_gdn_proj_cache = {}
def _get_batched_gdn_proj_kernel(K, N_QKV, N_Z, N_B, N_A, B, group_size=64):
key = (K, N_QKV, N_Z, N_B, N_A, B, group_size)
if key not in _batched_gdn_proj_cache:
_batched_gdn_proj_cache[key] = mx.fast.metal_kernel(
name=f"batched_fused_gdn_proj_K{K}_NQKV{N_QKV}_B{B}",
input_names=[
"x",
"W_merged", "S_merged", "B_merged",
"conv_state", "conv_w",
"A_log_arr", "dt_bias_arr",
],
output_names=["qkv_out", "z_silu_out", "b_out", "a_out", "conv_state_out"],
source=_gen_batched_fused_gdn_proj_source(K, N_QKV, N_Z, N_B, N_A, B, group_size),
)
return _batched_gdn_proj_cache[key]
def batched_fused_gdn_projections(
x,
W_merged, S_merged, B_merged,
proj_dims,
conv_state, conv_weights,
A_log, dt_bias,
batch_size=1,
):
"""Batched fused GDN projections with register-level weight sharing.
Same as fused_gdn_projections but loads weights once per TG and computes
B outputs from registers. No grid z for batch.
Args:
x: [B, 1, K] bf16 — post-RMSNorm hidden state
W_merged, S_merged, B_merged: merged quantized weights
proj_dims: (N_QKV, N_Z, N_B, N_A)
conv_state: [B, 3, conv_dim] bf16
conv_weights: [conv_dim, 4, 1] or [conv_dim, 4] bf16
A_log: [Hv] f32, dt_bias: [Hv] f32
batch_size: int (1..8)
Returns:
qkv_conv_silu: [B, 1, N_QKV] bf16
z_silu: [B, 1, N_Z] f32
beta: [B, 1, N_B] f32
g: [B, 1, N_A] f32
conv_state_out: [B, 3, N_QKV] bf16
"""
B = batch_size
N_QKV, N_Z, N_B, N_A = proj_dims
K = x.shape[-1]
kern = _get_batched_gdn_proj_kernel(K, N_QKV, N_Z, N_B, N_A, B)
N_QKV_TG = ceil_div(N_QKV, 8)
N_Z_TG = ceil_div(N_Z, 8)
N_B_TG = ceil_div(N_B, 8)
N_A_TG = ceil_div(N_A, 8)
total_tg = N_QKV_TG + N_Z_TG + N_B_TG + N_A_TG
conv_w_flat = conv_weights.reshape(-1, 4) if conv_weights.ndim == 3 else conv_weights
x_flat = x.reshape(B, K)
results = kern(
inputs=[
x_flat,
W_merged, S_merged, B_merged,
conv_state, conv_w_flat,
A_log, dt_bias,
],
output_shapes=[
(B * N_QKV,),
(B * N_Z,),
(B * N_B,),
(B * N_A,),
(B * 3 * N_QKV,),
],
output_dtypes=[mx.bfloat16, mx.float32, mx.float32, mx.float32, mx.bfloat16],
grid=(32, total_tg * 2, 1), # No grid z — batch in registers
threadgroup=(32, 2, 1),
)
qkv_out = results[0].reshape(B, 1, N_QKV)
z_silu = results[1].reshape(B, 1, N_Z)
beta = results[2].reshape(B, 1, N_B)
g = results[3].reshape(B, 1, N_A)
conv_state_out = results[4].reshape(B, 3, N_QKV)
return qkv_out, z_silu, beta, g, conv_state_out
@@ -0,0 +1,205 @@
"""Batched fused GQA projections (Dispatch 1) for batch_size 1..8.
Adapts fused_gqa_projections_8bit for B>1 with register-level weight sharing.
Each TG loads weights once, computes B outputs from registers.
4 regions with different epilogues (same as B=1):
- Queries: GEMV → raw bf16
- Gate: GEMV → sigmoid → f32
- Keys: GEMV → raw bf16
- Values: GEMV → raw bf16
All constants baked into Metal source. B unrolled at code-generation time.
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_batched_fused_gqa_proj_source(K, N_Q, N_GATE, N_K, N_V, B, group_size=64):
gs = int(group_size)
sc_stride = 256 // gs
slid_div = gs // 8
N_TOTAL = N_Q + N_GATE + N_K + N_V
K_groups = K // gs
N_Q_TG = ceil_div(N_Q, 8)
N_GATE_TG = ceil_div(N_GATE, 8)
N_K_TG = ceil_div(N_K, 8)
# Per-batch x loading
x_load = "\n".join(f"""
float x{b}_thread[VALUES_PER_THREAD]; float xsum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(x[{b} * K + x_base + i]); x{b}_thread[i] = xi; xsum{b} += xi;
}}""" for b in range(B))
# Per-batch qdot (weights in registers)
qdot = "\n".join(f"""
float accum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) accum{b} += x{b}_thread[i] * w_vals[i];
result{b}[row] += s_val * accum{b} + xsum{b} * b_val;""" for b in range(B))
result_decls = " ".join(f"float result{b}[4] = {{0,0,0,0}};" for b in range(B))
simd_reduce = "\n ".join(
f"for (int row = 0; row < 4; row++) result{b}[row] = simd_sum(result{b}[row]);" for b in range(B))
# Queries epilogue (bf16 write per batch)
q_write = "\n".join(f"""
if (slid < 4u && q_row < N_Q) q_out[{b} * N_Q + q_row] = static_cast<bfloat16_t>(result{b}[slid]);"""
for b in range(B))
# Gate epilogue (sigmoid → f32 per batch)
gate_write = "\n".join(f"""
if (slid < 4u && g_row < N_GATE) {{
float sig{b} = 1.0f / (1.0f + metal::exp(-result{b}[slid]));
gate_out[{b} * N_GATE + g_row] = sig{b};
}}""" for b in range(B))
# Keys epilogue
k_write = "\n".join(f"""
if (slid < 4u && k_row < N_K) k_out[{b} * N_K + k_row] = static_cast<bfloat16_t>(result{b}[slid]);"""
for b in range(B))
# Values epilogue
v_write = "\n".join(f"""
if (slid < 4u && v_row < N_V) v_out[{b} * N_V + v_row] = static_cast<bfloat16_t>(result{b}[slid]);"""
for b in range(B))
N_V_val = N_TOTAL - N_Q - N_GATE - N_K
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int GROUP_SIZE = {gs};
const int SC_STRIDE = {sc_stride};
const int SLID_DIV = {slid_div};
const int K = {K};
const int K_groups = {K_groups};
const int N_Q = {N_Q};
const int N_GATE = {N_GATE};
const int N_K = {N_K};
const int N_V = {N_V_val};
const int N_TOTAL = {N_TOTAL};
const int N_Q_TG = {N_Q_TG};
const int N_GATE_TG = {N_GATE_TG};
const int N_K_TG = {N_K_TG};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int b_idx = tgid.z;
int tg = tgid.y;
int out_row, region;
if (tg < N_Q_TG) {{
region = 0; out_row = tg * 8 + sgid * RESULTS_PER_SG;
}} else if (tg < N_Q_TG + N_GATE_TG) {{
region = 1; out_row = N_Q + (tg - N_Q_TG) * 8 + sgid * RESULTS_PER_SG;
}} else if (tg < N_Q_TG + N_GATE_TG + N_K_TG) {{
region = 2; out_row = N_Q + N_GATE + (tg - N_Q_TG - N_GATE_TG) * 8 + sgid * RESULTS_PER_SG;
}} else {{
region = 3; out_row = N_Q + N_GATE + N_K + (tg - N_Q_TG - N_GATE_TG - N_K_TG) * 8 + sgid * RESULTS_PER_SG;
}}
if (out_row >= N_TOTAL) return;
// Weight pointers (shared across all batch elements)
const device uint8_t* ws = (const device uint8_t*)W_merged + (long)out_row * K + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc = (const device bfloat16_t*)S_merged + (long)out_row * K_groups + slid / SLID_DIV;
const device bfloat16_t* bi = (const device bfloat16_t*)B_merged + (long)out_row * K_groups + slid / SLID_DIV;
{result_decls}
int x_base = slid * VALUES_PER_THREAD;
// K-loop: load weights once, compute {B} batch elements
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
{x_load}
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* wl = ws + row * K;
float s_val = float(sc[row * K_groups]);
float b_val = float(bi[row * K_groups]);
float w_vals[VALUES_PER_THREAD];
for (int i = 0; i < VALUES_PER_THREAD; i++) w_vals[i] = float(wl[i]);
{qdot}
}}
ws += BLOCK_SIZE; sc += SC_STRIDE; bi += SC_STRIDE; x_base += BLOCK_SIZE;
}}
{simd_reduce}
// Region-specific epilogues for all {B} batches
if (region == 0) {{
int q_row = out_row + (int)slid;
{q_write}
}} else if (region == 1) {{
int g_row = out_row - N_Q + (int)slid;
{gate_write}
}} else if (region == 2) {{
int k_row = out_row - N_Q - N_GATE + (int)slid;
{k_write}
}} else {{
int v_row = out_row - N_Q - N_GATE - N_K + (int)slid;
{v_write}
}}
"""
_batched_proj_cache = {}
def _get_batched_proj_kernel(K, N_Q, N_GATE, N_K, N_V, B, group_size=64):
key = (K, N_Q, N_GATE, N_K, N_V, B, group_size)
if key not in _batched_proj_cache:
_batched_proj_cache[key] = mx.fast.metal_kernel(
name=f"batched_fused_gqa_proj_K{K}_NQ{N_Q}_B{B}",
input_names=["x", "W_merged", "S_merged", "B_merged"],
output_names=["q_out", "gate_out", "k_out", "v_out"],
source=_gen_batched_fused_gqa_proj_source(K, N_Q, N_GATE, N_K, N_V, B, group_size),
)
return _batched_proj_cache[key]
def batched_fused_gqa_projections(x, W_merged, S_merged, B_merged, proj_dims,
batch_size, total_tg=None):
"""Batched fused GQA projections with register weight sharing.
Args:
x: [B, 1, K] bf16
W_merged, S_merged, B_merged: merged q+gate+k+v weights
proj_dims: (N_Q, N_GATE, N_K, N_V)
batch_size: B (1..8)
Returns:
queries (B, 1, N_Q) bf16, gate_sigmoid (B, 1, N_GATE) f32,
keys (B, 1, N_K) bf16, values (B, 1, N_V) bf16
"""
B = batch_size
N_Q, N_GATE, N_K, N_V = proj_dims
K = x.shape[-1]
kern = _get_batched_proj_kernel(K, N_Q, N_GATE, N_K, N_V, B)
if total_tg is None:
total_tg = ceil_div(N_Q, 8) + ceil_div(N_GATE, 8) + ceil_div(N_K, 8) + ceil_div(N_V, 8)
x_flat = x.reshape(B, K)
results = kern(
inputs=[x_flat, W_merged, S_merged, B_merged],
output_shapes=[
(B * N_Q,), (B * N_GATE,), (B * N_K,), (B * N_V,),
],
output_dtypes=[mx.bfloat16, mx.float32, mx.bfloat16, mx.bfloat16],
grid=(32, total_tg * 2, 1),
threadgroup=(32, 2, 1),
)
return (results[0].reshape(B, 1, N_Q),
results[1].reshape(B, 1, N_GATE),
results[2].reshape(B, 1, N_K),
results[3].reshape(B, 1, N_V))
@@ -0,0 +1,252 @@
"""Batched merged 8-bit down_proj GEMV for Qwen3.5 routed + shared experts.
Adapts merged_down_proj_8bit for batch_size B (1..8).
Grid z-dimension: B * n_active + 1
- tgid.z < B * n_active: routed experts (one TG per batch×expert pair)
Same structure as affine_gather_qmv — each TG independently indexes into
expert weights via inds[flat_idx].
- tgid.z == B * n_active: shared expert (ONE TG handles ALL B batch elements
with register-level weight sharing — loads weights once, computes B outputs)
All constants baked into Metal source (no scalar kernel inputs).
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_batched_merged_down_8bit_source(K_OUT, N_IN, SHARED_N_IN, n_active, B, group_size=64):
gs = int(group_size)
sc_stride = 256 // gs
slid_divisor = gs // 8
N_groups = N_IN // gs
SHARED_N_groups = SHARED_N_IN // gs
total_routed = B * n_active
# Shared expert: generate unrolled batch loops
shared_x_load_lines = []
for b in range(B):
shared_x_load_lines.append(f"""
float x{b}_thread[VALUES_PER_THREAD];
float xsum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = X_shared[{b} * SHARED_N_IN + x_base + i];
x{b}_thread[i] = xi;
xsum{b} += xi;
}}""")
shared_x_load = "\n".join(shared_x_load_lines)
shared_qdot_lines = []
for b in range(B):
shared_qdot_lines.append(f"""
float accum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
accum{b} += x{b}_thread[i] * w_vals[i];
}}
result{b}[row] += s_val * accum{b} + xsum{b} * b_val;""")
shared_qdot = "\n".join(shared_qdot_lines)
shared_result_decls = "\n ".join(
f"float result{b}[RESULTS_PER_SG] = {{0, 0, 0, 0}};"
for b in range(B)
)
shared_write_lines = []
for b in range(B):
shared_write_lines.append(f"""
for (int row = 0; row < RESULTS_PER_SG; row++) {{
float r{b} = simd_sum(result{b}[row]);
if (slid == 0) {{
Y_shared[{b} * K_OUT + out_row + row] = r{b};
}}
}}""")
shared_write = "\n".join(shared_write_lines)
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int K_OUT = {K_OUT};
const int N_IN = {N_IN};
const int SHARED_N_IN = {SHARED_N_IN};
const int N_GROUPS = {N_groups};
const int SHARED_N_GROUPS = {SHARED_N_groups};
const int N_ACTIVE = {n_active};
const int TOTAL_ROUTED = {total_routed};
const int BATCH_SIZE = {B};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
int out_row = tgid.y * 8 + sgid * RESULTS_PER_SG;
if (out_row >= K_OUT) return;
if (tgid.z < (uint)TOTAL_ROUTED) {{
// ═══════ ROUTED EXPERT PATH (same as gather_qmv) ═══════
// tgid.z indexes flat (batch, expert) pairs
int flat_idx = (int)tgid.z;
int expert = inds[flat_idx];
const device uint8_t* ws = (const device uint8_t*)W
+ (long)expert * K_OUT * N_IN + out_row * N_IN + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc = (const device bfloat16_t*)S
+ (long)expert * K_OUT * N_GROUPS + out_row * N_GROUPS + slid / {slid_divisor};
const device bfloat16_t* bi = (const device bfloat16_t*)B_q
+ (long)expert * K_OUT * N_GROUPS + out_row * N_GROUPS + slid / {slid_divisor};
const device float* x_ptr = (const device float*)X_routed
+ flat_idx * N_IN;
int x_base = slid * VALUES_PER_THREAD;
float result[4] = {{0, 0, 0, 0}};
for (int k = 0; k < N_IN; k += BLOCK_SIZE) {{
float x_thread[8];
float xsum = 0;
for (int i = 0; i < 8; i++) {{
float xi = x_ptr[x_base + i];
x_thread[i] = xi;
xsum += xi;
}}
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* wl = ws + row * N_IN;
float s = float(sc[row * N_GROUPS]);
float b = float(bi[row * N_GROUPS]);
float accum = 0;
for (int i = 0; i < 8; i++) {{
accum += x_thread[i] * float(wl[i]);
}}
result[row] += s * accum + xsum * b;
}}
ws += BLOCK_SIZE;
sc += {sc_stride};
bi += {sc_stride};
x_base += BLOCK_SIZE;
}}
device float* yp = Y_routed + flat_idx * K_OUT + out_row;
for (int row = 0; row < RESULTS_PER_SG; row++) {{
float r = simd_sum(result[row]);
if (slid == 0) {{
yp[row] = r;
}}
}}
}} else {{
// ═══════ SHARED EXPERT PATH (register-level weight sharing) ═══════
// ONE TG handles ALL {B} batch elements.
// Load shared expert weights once, compute {B} outputs from registers.
const device uint8_t* ws = (const device uint8_t*)W_shared_down
+ (long)out_row * SHARED_N_IN + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc = (const device bfloat16_t*)S_shared_down
+ (long)out_row * SHARED_N_GROUPS + slid / {slid_divisor};
const device bfloat16_t* bi = (const device bfloat16_t*)B_shared_down
+ (long)out_row * SHARED_N_GROUPS + slid / {slid_divisor};
int x_base = slid * VALUES_PER_THREAD;
{shared_result_decls}
for (int k = 0; k < SHARED_N_IN; k += BLOCK_SIZE) {{
// Load x for all {B} batch elements
{shared_x_load}
// Load weights once into registers, compute all {B} batches
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* wl = ws + row * SHARED_N_IN;
float s_val = float(sc[row * SHARED_N_GROUPS]);
float b_val = float(bi[row * SHARED_N_GROUPS]);
float w_vals[VALUES_PER_THREAD];
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
w_vals[i] = float(wl[i]);
}}
{shared_qdot}
}}
ws += BLOCK_SIZE;
sc += {sc_stride};
bi += {sc_stride};
x_base += BLOCK_SIZE;
}}
// Write all {B} outputs
{shared_write}
}}
"""
_batched_down_cache = {}
def _get_batched_down_kernel(K_OUT, N_IN, SHARED_N_IN, n_active, B, group_size=64):
key = (K_OUT, N_IN, SHARED_N_IN, n_active, B, group_size)
if key not in _batched_down_cache:
_batched_down_cache[key] = mx.fast.metal_kernel(
name=f"batched_down_K{K_OUT}_N{N_IN}_SN{SHARED_N_IN}_na{n_active}_B{B}",
input_names=["W", "S", "B_q",
"W_shared_down", "S_shared_down", "B_shared_down",
"X_routed", "X_shared", "inds"],
output_names=["Y_routed", "Y_shared"],
source=_gen_batched_merged_down_8bit_source(
K_OUT, N_IN, SHARED_N_IN, n_active, B, group_size),
)
return _batched_down_cache[key]
def batched_merged_down_proj_8bit(w_q, s, b_q,
w_shared_down, s_shared_down, b_shared_down,
x_routed, x_shared, inds,
k_out, n_in, batch_size,
n_active, group_size=64,
shared_n_in=None):
"""Batched merged down_proj for 8-bit routed + shared experts.
Args:
w_q: routed weights (E, K_OUT, N_IN/4) uint32
s: routed scales (E, K_OUT, N_IN/gs) bf16
b_q: routed biases (E, K_OUT, N_IN/gs) bf16
w_shared_down: shared weight (K_OUT, SHARED_N_IN/4) uint32
s_shared_down: shared scales (K_OUT, SHARED_N_IN/gs) bf16
b_shared_down: shared biases (K_OUT, SHARED_N_IN/gs) bf16
x_routed: (B * n_active, N_IN) f32
x_shared: (B * SHARED_N_IN,) f32
inds: (B * n_active,) uint32
k_out: output dimension
n_in: routed input dimension
batch_size: B
n_active: experts per token (top_k)
shared_n_in: shared input dim (defaults to n_in)
Returns:
Y_routed: (B * n_active, k_out) f32
Y_shared: (B, k_out) f32
"""
B = batch_size
k_out_val = int(k_out)
n_in_val = int(n_in)
shared_n_in_val = int(shared_n_in) if shared_n_in is not None else n_in_val
kern = _get_batched_down_kernel(k_out_val, n_in_val, shared_n_in_val, n_active, B)
y_groups = ceil_div(k_out_val, 8)
total_routed = B * n_active
Y = kern(
inputs=[w_q, s, b_q,
w_shared_down, s_shared_down, b_shared_down,
x_routed, x_shared, inds],
output_shapes=[(total_routed * k_out_val,), (B * k_out_val,)],
output_dtypes=[mx.float32, mx.float32],
grid=(32, y_groups * 2, total_routed + 1),
threadgroup=(32, 2, 1),
)
return Y[0].reshape(total_routed, k_out_val), Y[1].reshape(B, k_out_val)
@@ -0,0 +1,92 @@
"""Batched MoE epilogue for Qwen3.5: weighted sum + shared expert gate + residual.
Computes per batch element:
Y[b, j] = bf16( Σ_a(scores[b,a] * D_routed[b*n_active+a, j])
+ sigmoid(gate_raw[b]) * D_shared[b, j]
+ H[b, j] )
Grid z = B (one set of threads per batch element).
All constants baked into Metal source.
"""
import mlx.core as mx
def _gen_batched_epilogue_source(K, n_active, B):
return f"""
const int K_const = {K};
const int n_active_const = {n_active};
const int B_const = {B};
uint tid = thread_position_in_grid.x;
uint batch_id = thread_position_in_grid.z;
if (tid >= K_const || batch_id >= B_const) return;
// Weighted sum of routed expert outputs for this batch element
float acc = 0.0f;
int routed_base = (int)batch_id * n_active_const * K_const;
int score_base = (int)batch_id * n_active_const;
for (int a = 0; a < n_active_const; a++) {{
acc += scores[score_base + a] * D_routed[routed_base + a * K_const + tid];
}}
// Shared expert: sigmoid(gate_raw) * D_shared
float gate_raw_val = gate_raw[(int)batch_id];
float gate = 1.0f / (1.0f + metal::exp(-gate_raw_val));
float shared_val = D_shared[(int)batch_id * K_const + tid] * gate;
// Add residual and write
Y[(int)batch_id * K_const + tid] = static_cast<bfloat16_t>(
acc + shared_val + float(H[(int)batch_id * K_const + tid])
);
"""
_batched_epilogue_cache = {}
def _get_batched_epilogue_kernel(K, n_active, B):
key = (K, n_active, B)
if key not in _batched_epilogue_cache:
_batched_epilogue_cache[key] = mx.fast.metal_kernel(
name=f"batched_epilogue_K{K}_na{n_active}_B{B}",
input_names=["D_routed", "D_shared", "scores", "H", "gate_raw"],
output_names=["Y"],
source=_gen_batched_epilogue_source(K, n_active, B),
)
return _batched_epilogue_cache[key]
def batched_moe_epilogue(d_routed, d_shared, scores, h, gate_raw,
k_val, batch_size, n_active):
"""Batched MoE epilogue with fused sigmoid.
Args:
d_routed: (B * n_active, K) f32
d_shared: (B, K) f32
scores: (B * n_active,) f32
h: (B, K) bf16 — residual
gate_raw: (B,) f32 — raw shared expert gate (pre-sigmoid)
k_val: hidden dimension
batch_size: B
n_active: experts per token
Returns:
Y: (B, K) bf16
"""
K = int(k_val)
B = batch_size
kern = _get_batched_epilogue_kernel(K, n_active, B)
tg_size = min(K, 1024)
n_tg = (K + tg_size - 1) // tg_size
Y = kern(
inputs=[d_routed, d_shared.reshape(B * K), scores, h.reshape(B * K), gate_raw],
output_shapes=[(B * K,)],
output_dtypes=[mx.bfloat16],
grid=(n_tg * tg_size, 1, B),
threadgroup=(tg_size, 1, 1),
)
return Y[0].reshape(B, K)
@@ -0,0 +1,332 @@
"""Batched Dispatch 1: Fused o_proj (8-bit) + gate GEMV parts + x² partials.
Adapts custom_oproj_gate_gemv_8bit for batch_size B (1..8).
Register-level weight sharing: each TG loads weights once, computes B outputs.
Three GEMV regions (same as B=1):
TGs 0..N_OPROJ_TG-1: o_proj GEMV (8-bit) + residual + h_scaled + x²
TGs N_OPROJ_TG..+N_M1_TG-1: M1 × attn_out → gate_part_a (bf16 GEMV)
TGs +N_M1_TG..end: W_fused × residual → gate_part_b (bf16 GEMV)
All constants baked into Metal source. B is unrolled at code-generation time.
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_batched_oproj_source(n_experts, M, K_attn, K_hidden, B, group_size=64, gate_bm=8):
E = int(n_experts)
gs = group_size
oproj_slid_divisor = gs // 8
oproj_sc_stride = 256 // gs
blockM_gate = gate_bm * 4
n_m1_tg = ceil_div(E, blockM_gate)
# Generate unrolled per-batch code for o_proj epilogue
oproj_epilogue = []
for b in range(B):
oproj_epilogue.append(f"""
float x2_acc{b} = 0.0f;
for (int tm = 0; tm < TM; tm++) {{
int k = out_row + tm;
float h{b} = result{b}[tm] + float(residual[{b} * M_DIM + k]);
x2_acc{b} += h{b} * h{b};
h_scaled[{b} * M_DIM + k] = static_cast<bfloat16_t>(h{b} * float(w_rms[k]));
h_out[{b} * M_DIM + k] = static_cast<bfloat16_t>(h{b});
}}""")
oproj_epilogue_code = "\n".join(oproj_epilogue)
oproj_x2_write = []
for b in range(B):
oproj_x2_write.append(f"""
total{b} += tgp_x2[s * {B} + {b}];""")
oproj_x2_sum = "\n".join(oproj_x2_write)
oproj_x2_final = []
for b in range(B):
oproj_x2_final.append(f"""
x2_partials[{b} * N_OPROJ_TG_DIM + tg_x] = total{b};""")
oproj_x2_final_code = "\n".join(oproj_x2_final)
# o_proj K-loop: load weights once, compute B batch elements
oproj_x_load = "\n".join(f"""
float xv{b}[VPT]; float xsum{b} = 0.0f;
for (int i = 0; i < VPT; i++) {{ xv{b}[i] = float(attn_out[{b} * K_ATTN_DIM + xb + i]); xsum{b} += xv{b}[i]; }}""" for b in range(B))
oproj_qdot = "\n".join(f"""
acc{b}[row] += s_val * wdot(xv{b}, w_vals) + xsum{b} * b_val;""" for b in range(B))
oproj_result_decls = " ".join(f"float acc{b}[TM] = {{0,0,0,0}};" for b in range(B))
oproj_simd_reduce = "\n".join(f" float result{b}[TM]; for (int tm=0;tm<TM;tm++) result{b}[tm] = simd_sum(acc{b}[tm]);" for b in range(B))
oproj_tgp_write = "\n".join(f" tgp_x2[sgid * {B} + {b}] = x2_acc{b};" for b in range(B))
oproj_total_decls = " ".join(f"float total{b} = 0.0f;" for b in range(B))
# Gate M1 GEMV: load M1 weights once, compute B dot products with B attn_outs
gate_a_x_load = "\n".join(f"""
float v{b}[TN];
for (int tn = 0; tn < TN; tn++) v{b}[tn] = float(attn_out[{b} * K_ATTN_DIM + bn + tn]);""" for b in range(B))
gate_a_dot = "\n".join(f"""
float gacc{b} = 0.0f;
for (int tn = 0; tn < TN; tn++) gacc{b} += w_row[tn] * v{b}[tn];
gresult{b}[tm] += gacc{b};""" for b in range(B))
gate_a_decls = " ".join(f"float gresult{b}[TM] = {{0,0,0,0}};" for b in range(B))
gate_a_reduce = "\n".join(f" gresult{b}[tm] = simd_sum(gresult{b}[tm]);" for b in range(B))
gate_a_write = "\n".join(f"""
gate_part_a[{b} * E_CONST + e] = gresult{b}[tm];""" for b in range(B))
# Gate W_fused GEMV: same pattern but with residual input
gate_b_x_load = "\n".join(f"""
float rv{b}[TN];
for (int tn = 0; tn < TN; tn++) rv{b}[tn] = float(residual[{b} * K_HIDDEN_DIM + bn + tn]);""" for b in range(B))
gate_b_dot = "\n".join(f"""
float wdot{b} = 0.0f;
for (int tn = 0; tn < TN; tn++) wdot{b} += w_row[tn] * rv{b}[tn];
bresult{b}[tm] += wdot{b};""" for b in range(B))
gate_b_decls = " ".join(f"float bresult{b}[TM] = {{0,0,0,0}};" for b in range(B))
gate_b_reduce = "\n".join(f" bresult{b}[tm] = simd_sum(bresult{b}[tm]);" for b in range(B))
gate_b_write = "\n".join(f"""
gate_part_b[{b} * E_CONST + e] = bresult{b}[tm];""" for b in range(B))
return f"""
const int TM = 4;
const int TN = 4;
const int blockN = 128;
const int E_CONST = {E};
const int M_DIM = {M};
const int K_ATTN_DIM = {K_attn};
const int K_HIDDEN_DIM = {K_hidden};
const int N_OPROJ_TG_DIM = {ceil_div(M, 32)};
const int BATCH_SIZE = {B};
// Helper: dot product of x_thread and w_vals (8 elements)
auto wdot = [](thread float* x, thread float* w) -> float {{
float a = 0;
for (int i = 0; i < 8; i++) a += x[i] * w[i];
return a;
}};
const int N_OPROJ_TG = N_OPROJ_TG_DIM;
const int N_M1_TG = {n_m1_tg};
const int blockM_gate = {blockM_gate};
uint tg_x = threadgroup_position_in_grid.x;
uint sgid = simdgroup_index_in_threadgroup;
uint slid = thread_index_in_simdgroup;
if (tg_x < (uint)N_OPROJ_TG) {{
// ══════ O_PROJ GEMV (8-bit, register-sharing for {B} batches) ══════
const int blockM = 32;
const int VPT = 8;
const int BLOCK_SIZE = 256;
int out_row = int(tg_x) * blockM + int(sgid) * TM;
if (out_row >= M_DIM) return;
out_row = (out_row + TM <= M_DIM) ? out_row : (M_DIM - TM);
threadgroup float tgp_x2[8 * {B}];
{oproj_result_decls}
int K_groups = K_ATTN_DIM / {gs};
// Weight pointers (shared across batch)
const device uint8_t* ws = (const device uint8_t*)W_oproj
+ (long)out_row * K_ATTN_DIM + slid * VPT;
const device bfloat16_t* sc = (const device bfloat16_t*)S_oproj
+ (long)out_row * K_groups + slid / {oproj_slid_divisor};
const device bfloat16_t* bi = (const device bfloat16_t*)B_oproj
+ (long)out_row * K_groups + slid / {oproj_slid_divisor};
int xb = slid * VPT;
for (int k = 0; k < K_ATTN_DIM; k += BLOCK_SIZE) {{
// Load x for all {B} batches
{oproj_x_load}
// Load weights once, compute all batches
for (int row = 0; row < TM; row++) {{
const device uint8_t* wl = ws + row * K_ATTN_DIM;
float s_val = float(sc[row * K_groups]);
float b_val = float(bi[row * K_groups]);
float w_vals[VPT];
for (int i = 0; i < VPT; i++) w_vals[i] = float(wl[i]);
{oproj_qdot}
}}
ws += BLOCK_SIZE; sc += {oproj_sc_stride}; bi += {oproj_sc_stride};
xb += BLOCK_SIZE;
}}
// simd_sum for all batches
{oproj_simd_reduce}
// Epilogue: residual add + x² + h_scaled + h_out
if (slid == 0) {{
{oproj_epilogue_code}
{oproj_tgp_write}
}}
threadgroup_barrier(mem_flags::mem_threadgroup);
if (sgid == 0 && slid == 0) {{
{oproj_total_decls}
for (int s = 0; s < 8; s++) {{
{oproj_x2_sum}
}}
{oproj_x2_final_code}
}}
}} else if (tg_x < (uint)(N_OPROJ_TG + N_M1_TG)) {{
// ══════ M1 GEMV (bf16, register-sharing for {B} batches) ══════
int local_tg = int(tg_x) - N_OPROJ_TG;
int out_row = local_tg * blockM_gate + int(sgid) * TM;
if (out_row >= E_CONST) return;
out_row = (out_row + TM <= E_CONST) ? out_row : (E_CONST - TM);
{gate_a_decls}
int bn = int(slid) * TN;
int n_iter = K_ATTN_DIM / blockN;
for (int i = 0; i < n_iter; i++) {{
{gate_a_x_load}
for (int tm = 0; tm < TM; tm++) {{
float w_row[TN];
for (int tn = 0; tn < TN; tn++) w_row[tn] = float(M1[(out_row + tm) * K_ATTN_DIM + bn + tn]);
{gate_a_dot}
}}
bn += blockN;
}}
for (int tm = 0; tm < TM; tm++) {{
{gate_a_reduce}
}}
if (slid == 0) {{
for (int tm = 0; tm < TM; tm++) {{
int e = out_row + tm;
if (e < E_CONST) {{
{gate_a_write}
}}
}}
}}
}} else {{
// ══════ W_FUSED GEMV (bf16, register-sharing for {B} batches) ══════
int local_tg = int(tg_x) - N_OPROJ_TG - N_M1_TG;
int out_row = local_tg * blockM_gate + int(sgid) * TM;
if (out_row >= E_CONST) return;
out_row = (out_row + TM <= E_CONST) ? out_row : (E_CONST - TM);
{gate_b_decls}
int bn = int(slid) * TN;
int n_iter = K_HIDDEN_DIM / blockN;
for (int i = 0; i < n_iter; i++) {{
{gate_b_x_load}
for (int tm = 0; tm < TM; tm++) {{
float w_row[TN];
for (int tn = 0; tn < TN; tn++) w_row[tn] = float(W_fused[(out_row + tm) * K_HIDDEN_DIM + bn + tn]);
{gate_b_dot}
}}
bn += blockN;
}}
for (int tm = 0; tm < TM; tm++) {{
{gate_b_reduce}
}}
if (slid == 0) {{
for (int tm = 0; tm < TM; tm++) {{
int e = out_row + tm;
if (e < E_CONST) {{
{gate_b_write}
}}
}}
}}
}}
"""
_batched_oproj_cache = {}
def _get_batched_oproj_kernel(n_experts, M, K_attn, K_hidden, B, group_size=64, gate_bm=8):
key = (n_experts, M, K_attn, K_hidden, B, group_size, gate_bm)
if key not in _batched_oproj_cache:
_batched_oproj_cache[key] = mx.fast.metal_kernel(
name=f"batched_oproj_E{n_experts}_M{M}_Ka{K_attn}_Kh{K_hidden}_B{B}",
input_names=[
"W_oproj", "S_oproj", "B_oproj",
"attn_out", "residual", "w_rms",
"M1", "W_fused",
],
output_names=["h_scaled", "h_out", "x2_partials",
"gate_part_a", "gate_part_b"],
source=_gen_batched_oproj_source(n_experts, M, K_attn, K_hidden, B, group_size, gate_bm),
)
return _batched_oproj_cache[key]
def batched_oproj_gate_gemv(W_oproj, S_oproj, B_oproj,
attn_out, residual, w_rms,
M1, W_fused,
M, K_attn, batch_size,
n_experts=256, gate_bm=8,
K_hidden=None, group_size=64):
"""Batched fused 8-bit o_proj + bf16 gate GEMVs.
Args:
W_oproj/S_oproj/B_oproj: 8-bit o_proj weights
attn_out: (B, K_attn) bf16
residual: (B, K) bf16
w_rms: (K,) bf16 — RMSNorm weight (shared)
M1: (E, K_attn) bf16 (shared)
W_fused: (E, K) bf16 (shared)
M: hidden size
K_attn: attention output dim
batch_size: B
n_experts: E
gate_bm: SGs per gate TG
Returns:
h_scaled (B, M) bf16, h_out (B, M) bf16,
x2_partials (B, N_TG) f32, gate_part_a (B, E) f32, gate_part_b (B, E) f32
"""
B = batch_size
M_val = int(M)
K_attn_val = int(K_attn)
K_hidden_val = int(K_hidden) if K_hidden is not None else M_val
kern = _get_batched_oproj_kernel(n_experts, M_val, K_attn_val, K_hidden_val, B, group_size, gate_bm)
n_oproj_tg = ceil_div(M_val, 32)
blockM_gate = gate_bm * 4
n_m1_tg = ceil_div(n_experts, blockM_gate)
n_wf_tg = ceil_div(n_experts, blockM_gate)
total_tg = n_oproj_tg + n_m1_tg + n_wf_tg
results = kern(
inputs=[W_oproj, S_oproj, B_oproj,
attn_out.reshape(B * K_attn_val), residual.reshape(B * M_val), w_rms,
M1, W_fused],
output_shapes=[
(B * M_val,), (B * M_val,),
(B * n_oproj_tg,),
(B * n_experts,), (B * n_experts,),
],
output_dtypes=[mx.bfloat16, mx.bfloat16, mx.float32, mx.float32, mx.float32],
grid=(total_tg * 32, 8, 1),
threadgroup=(32, 8, 1),
)
return (results[0].reshape(B, M_val),
results[1].reshape(B, M_val),
results[2].reshape(B, n_oproj_tg),
results[3].reshape(B, n_experts),
results[4].reshape(B, n_experts))
@@ -0,0 +1,578 @@
"""Dispatch 2 (batched): Softmax prologue + 8-bit SwiGLU for Qwen3.5 MoE.
Combines the prologue from oproj_softmax_topk_swiglu_8bit (B=1) with the
batched body from batched_merged_swiglu_8bit. All constants are baked into
the Metal source at Python code-generation time.
Grid z-dimension: B * n_active + 1
- tgid.z < B * n_active: routed expert TGs
batch_id = tgid.z / n_active, local_z = tgid.z % n_active
- tgid.z == B * n_active: shared expert TG (register-level weight sharing
for B batch elements, including shared_expert_gate GEMV)
Prologue (all TGs):
Phase 1: distributed x2 partial sum -> inv_rms (per batch_id)
Phase 2 (routed TGs only): gate scores -> softmax -> parallel top-k ->
norm_topk_prob -> write out_inds / norm_scores
Phase 3 (shared TG, SG 0): shared_expert_gate 8-bit GEMV with
register-level weight sharing -> gate_raw[B]
Body (after TG barrier):
Routed: 8-bit gate+up+SwiGLU with h_scaled[batch_id] input
Shared: register-level weight sharing for B batch elements
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_batched_softmax_topk_swiglu_source(
N_INTER, SHARED_INTER, K, n_active, B,
n_experts=256, top_k=10, norm_topk=True, group_size=64,
n_oproj_tg=64,
):
"""Generate Metal source for batched softmax + top-k + SwiGLU.
All routed TGs compute the full softmax+topk for their batch_id into TG
memory. Only the TG with local_z==0 writes out_inds/norm_scores to device
memory. Each routed TG reads its own expert from tg_inds[local_z].
"""
gs = group_size
sc_stride = 256 // gs
slid_divisor = gs // 8
N_TOTAL = 2 * N_INTER
K_groups = K // gs
SHARED_K_groups = K // gs
total_routed = B * n_active
E = int(n_experts)
K_TOP = int(top_k)
SPT = (E + 63) // 64
# ── Shared expert body: unrolled per-batch code ──
shared_x_load = "\n".join(f"""
float x{b}_thread[VALUES_PER_THREAD];
float xsum{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) {{
float xi = float(X[{b} * K_DIM + x_base + i]);
x{b}_thread[i] = xi;
xsum{b} += xi;
}}""" for b in range(B))
shared_gate_qdot = "\n".join(f"""
float accum_g{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) accum_g{b} += x{b}_thread[i] * wg_vals[i];
gate{b}[row] += sg * accum_g{b} + xsum{b} * bg;""" for b in range(B))
shared_up_qdot = "\n".join(f"""
float accum_u{b} = 0;
for (int i = 0; i < VALUES_PER_THREAD; i++) accum_u{b} += x{b}_thread[i] * wu_vals[i];
up{b}[row] += su * accum_u{b} + xsum{b} * bu;""" for b in range(B))
shared_result_decls = "\n ".join(
f"float gate{b}[RESULTS_PER_SG] = {{0,0,0,0}}; float up{b}[RESULTS_PER_SG] = {{0,0,0,0}};"
for b in range(B))
shared_write_lines = []
for b in range(B):
shared_write_lines.append(f"""
for (int row = 0; row < RESULTS_PER_SG; row++) {{
float g{b} = simd_sum(gate{b}[row]) * inv_rms_{b};
float u{b} = simd_sum(up{b}[row]) * inv_rms_{b};
if (slid == 0) {{
float silu_g{b} = g{b} / (1.0f + metal::exp(-g{b}));
Y_shared[{b} * SHARED_INTER_DIM + out_row + row] = silu_g{b} * u{b};
}}
}}""")
shared_write = "\n".join(shared_write_lines)
# inv_rms for all B batches in the shared TG
shared_inv_rms_lines = []
for b in range(B):
shared_inv_rms_lines.append(f"""
float local_x2_{b} = 0.0f;
for (int i = x2_start; i < x2_end; i++) local_x2_{b} += x2_partials[{b} * N_OPROJ_TG_DIM + i];
float sg_x2_{b} = simd_sum(local_x2_{b});
if (slid == 0) tg_x2_sg[sgid] = sg_x2_{b};
threadgroup_barrier(mem_flags::mem_threadgroup);
float inv_rms_{b} = metal::precise::rsqrt((tg_x2_sg[0] + tg_x2_sg[1]) / (float)K_DIM + 1e-6f);""")
shared_inv_rms_block = "\n".join(shared_inv_rms_lines)
# Shared expert gate GEMV accumulator declarations
seg_acc_decls = "\n ".join(
f"float seg_gate_acc{b} = 0.0f;" for b in range(B))
seg_write = "\n".join(
f" gate_raw[{b}] = seg_gate_acc{b} * inv_rms_{b};"
for b in range(B))
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int N_INTER_DIM = {N_INTER};
const int SHARED_INTER_DIM = {SHARED_INTER};
const int K_DIM = {K};
const int K_GROUPS = {K_groups};
const int N_TOTAL = {N_TOTAL};
const int N_ACTIVE = {n_active};
const int TOTAL_ROUTED = {total_routed};
const int BATCH_SIZE = {B};
const int E_CONST = {E};
const int K_TOP_CONST = {K_TOP};
const int SPT = {SPT};
const int N_OPROJ_TG_DIM = {n_oproj_tg};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup; // 0 or 1
uint slid = thread_index_in_simdgroup; // 0..31
int tid = int(sgid) * 32 + int(slid); // 0..63
threadgroup float tg_x2_sg[2];
threadgroup int tg_inds[{K_TOP}];
threadgroup float tg_selected_scores[{K_TOP}];
if (tgid.z < (uint)TOTAL_ROUTED) {{
// ═══════════════════════════════════════════════════════════════
// ROUTED TG PROLOGUE
// ═══════════════════════════════════════════════════════════════
int flat_idx = (int)tgid.z;
int batch_id = flat_idx / N_ACTIVE;
int local_z = flat_idx % N_ACTIVE;
// Phase 1: distributed x2 sum -> inv_rms for batch_id
int chunk = (N_OPROJ_TG_DIM + 63) / 64;
int x2_start = tid * chunk;
int x2_end = min(x2_start + chunk, N_OPROJ_TG_DIM);
float local_x2 = 0.0f;
for (int i = x2_start; i < x2_end; i++)
local_x2 += x2_partials[batch_id * N_OPROJ_TG_DIM + i];
float sg_x2_sum = simd_sum(local_x2);
if (slid == 0) tg_x2_sg[sgid] = sg_x2_sum;
threadgroup_barrier(mem_flags::mem_threadgroup);
float total_x2 = tg_x2_sg[0] + tg_x2_sg[1];
float inv_rms = metal::precise::rsqrt(total_x2 / (float)K_DIM + 1e-6f);
// Phase 2: ALL routed TGs compute full softmax + top-k for their
// batch_id. Each TG gets its own copy in TG memory (tg_inds,
// tg_selected_scores). This avoids cross-TG communication.
{{
float my_scores[SPT];
for (int j = 0; j < SPT; j++) {{
int e = tid * SPT + j;
if (e < E_CONST)
my_scores[j] = (gate_part_a[batch_id * E_CONST + e]
+ gate_part_b[batch_id * E_CONST + e]) * inv_rms;
else
my_scores[j] = -1e30f;
}}
// Softmax: distributed max
float local_max = -1e30f;
for (int j = 0; j < SPT; j++)
local_max = max(local_max, my_scores[j]);
float sg_max_val = simd_max(local_max);
threadgroup float tg_softmax_sg[2];
if (slid == 0) tg_softmax_sg[sgid] = sg_max_val;
threadgroup_barrier(mem_flags::mem_threadgroup);
float tg_max = max(tg_softmax_sg[0], tg_softmax_sg[1]);
// Softmax: exp + distributed sum
float local_sum = 0.0f;
for (int j = 0; j < SPT; j++) {{
float e_val = metal::exp(my_scores[j] - tg_max);
my_scores[j] = e_val;
local_sum += e_val;
}}
float sg_sum_val = simd_sum(local_sum);
if (slid == 0) tg_softmax_sg[sgid] = sg_sum_val;
threadgroup_barrier(mem_flags::mem_threadgroup);
float tg_sum = tg_softmax_sg[0] + tg_softmax_sg[1];
// Softmax: normalize
float inv_sum = 1.0f / tg_sum;
for (int j = 0; j < SPT; j++)
my_scores[j] *= inv_sum;
// Parallel top-k: K_TOP rounds
threadgroup float tg_tk_val[2];
threadgroup int tg_tk_info[2];
for (int round = 0; round < K_TOP_CONST; round++) {{
float best = -1.0f;
int best_e = -1;
for (int j = 0; j < SPT; j++) {{
int e = tid * SPT + j;
if (e < E_CONST && my_scores[j] > best) {{
best = my_scores[j];
best_e = e;
}}
}}
float sg_best = simd_max(best);
int candidate = (best == sg_best && best > 0.0f) ? int(slid) : 999;
int sg_winner = simd_min(candidate);
if (slid == 0) {{
tg_tk_val[sgid] = sg_best;
tg_tk_info[sgid] = sg_winner;
}}
threadgroup_barrier(mem_flags::mem_threadgroup);
int winner_sg = (tg_tk_val[0] >= tg_tk_val[1]) ? 0 : 1;
int winner_lane = tg_tk_info[winner_sg];
int winner_tid = winner_sg * 32 + winner_lane;
if (tid == winner_tid) {{
tg_inds[round] = best_e;
tg_selected_scores[round] = best;
for (int j = 0; j < SPT; j++) {{
if (tid * SPT + j == best_e) {{
my_scores[j] = -1.0f;
break;
}}
}}
}}
threadgroup_barrier(mem_flags::mem_threadgroup);
}}
// Only local_z==0 writes norm_scores + out_inds to device memory
if (local_z == 0 && tid == 0) {{
float total_score = 0.0f;
for (int a = 0; a < {K_TOP}; a++) total_score += tg_selected_scores[a];
float inv_total = {"1.0f / total_score" if norm_topk else "1.0f"};
for (int a = 0; a < {K_TOP}; a++) {{
norm_scores[batch_id * {K_TOP} + a] = tg_selected_scores[a] * inv_total;
out_inds[batch_id * {K_TOP} + a] = (uint)tg_inds[a];
}}
}}
}}
threadgroup_barrier(mem_flags::mem_threadgroup);
// ═══════════════════════════════════════════════════════════════
// ROUTED BODY: 8-bit gate+up+SwiGLU
// ═══════════════════════════════════════════════════════════════
int out_row = tgid.y * 8 + sgid * RESULTS_PER_SG;
if (out_row >= N_INTER_DIM) return;
int expert = tg_inds[local_z];
const device uint8_t* ws_gate = (const device uint8_t*)W
+ (long)expert * N_TOTAL * K_DIM + out_row * K_DIM + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc_gate = (const device bfloat16_t*)S
+ (long)expert * N_TOTAL * K_GROUPS + out_row * K_GROUPS + slid / {slid_divisor};
const device bfloat16_t* bi_gate = (const device bfloat16_t*)B_q
+ (long)expert * N_TOTAL * K_GROUPS + out_row * K_GROUPS + slid / {slid_divisor};
const device uint8_t* ws_up = (const device uint8_t*)W
+ (long)expert * N_TOTAL * K_DIM + (out_row + N_INTER_DIM) * K_DIM + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc_up = (const device bfloat16_t*)S
+ (long)expert * N_TOTAL * K_GROUPS + (out_row + N_INTER_DIM) * K_GROUPS + slid / {slid_divisor};
const device bfloat16_t* bi_up = (const device bfloat16_t*)B_q
+ (long)expert * N_TOTAL * K_GROUPS + (out_row + N_INTER_DIM) * K_GROUPS + slid / {slid_divisor};
int x_base = batch_id * K_DIM + slid * VALUES_PER_THREAD;
float gate_result[4] = {{0, 0, 0, 0}};
float up_result[4] = {{0, 0, 0, 0}};
for (int k = 0; k < K_DIM; k += BLOCK_SIZE) {{
float x_thread[8];
float xsum = 0;
for (int i = 0; i < 8; i++) {{
float xi = float(X[x_base + i]);
x_thread[i] = xi;
xsum += xi;
}}
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* wg = ws_gate + row * K_DIM;
float sg = float(sc_gate[row * K_GROUPS]);
float bg = float(bi_gate[row * K_GROUPS]);
float accum_g = 0;
for (int i = 0; i < 8; i++) accum_g += x_thread[i] * float(wg[i]);
gate_result[row] += sg * accum_g + xsum * bg;
const device uint8_t* wu = ws_up + row * K_DIM;
float su = float(sc_up[row * K_GROUPS]);
float bu = float(bi_up[row * K_GROUPS]);
float accum_u = 0;
for (int i = 0; i < 8; i++) accum_u += x_thread[i] * float(wu[i]);
up_result[row] += su * accum_u + xsum * bu;
}}
ws_gate += BLOCK_SIZE; ws_up += BLOCK_SIZE;
sc_gate += {sc_stride}; sc_up += {sc_stride};
bi_gate += {sc_stride}; bi_up += {sc_stride};
x_base += BLOCK_SIZE;
}}
// Epilogue: apply inv_rms (factored), SwiGLU, write f32
device float* yp = Y_routed + flat_idx * N_INTER_DIM + out_row;
for (int row = 0; row < RESULTS_PER_SG; row++) {{
float g = simd_sum(gate_result[row]) * inv_rms;
float u = simd_sum(up_result[row]) * inv_rms;
if (slid == 0) {{
float silu_g = g / (1.0f + metal::exp(-g));
yp[row] = silu_g * u;
}}
}}
}} else {{
// ═══════════════════════════════════════════════════════════════
// SHARED TG (tgid.z == TOTAL_ROUTED)
// ═══════════════════════════════════════════════════════════════
// Phase 1: compute inv_rms for ALL B batch elements
int chunk = (N_OPROJ_TG_DIM + 63) / 64;
int x2_start = tid * chunk;
int x2_end = min(x2_start + chunk, N_OPROJ_TG_DIM);
{shared_inv_rms_block}
// Phase 3: shared_expert_gate 8-bit GEMV (SG 0 only)
// Load W_seg once, compute B dot products via register-level weight sharing
if (sgid == 0) {{
const int VPT = 8;
const int SEG_BLOCK = 256; // 32 * VPT
const device uint8_t* seg_w_ptr = (const device uint8_t*)W_seg
+ slid * VPT;
const device bfloat16_t* seg_sc = (const device bfloat16_t*)S_seg
+ slid / {slid_divisor};
const device bfloat16_t* seg_bi = (const device bfloat16_t*)B_seg
+ slid / {slid_divisor};
int seg_xb = slid * VPT;
{seg_acc_decls}
for (int k = 0; k < K_DIM; k += SEG_BLOCK) {{
// Load weight block once into registers
float seg_w_regs[VPT];
for (int i = 0; i < VPT; i++) seg_w_regs[i] = float(seg_w_ptr[i]);
float seg_sc_val = float(*seg_sc);
float seg_bi_val = float(*seg_bi);
// Compute B dot products from the same weight registers
{chr(10).join(f''' {{
float xsum{b} = 0.0f, wacc{b} = 0.0f;
for (int i = 0; i < VPT; i++) {{
float xi = float(X[{b} * K_DIM + seg_xb + i]);
xsum{b} += xi;
wacc{b} += xi * seg_w_regs[i];
}}
seg_gate_acc{b} += seg_sc_val * wacc{b} + xsum{b} * seg_bi_val;
}}''' for b in range(B))}
seg_w_ptr += SEG_BLOCK;
seg_sc += {sc_stride};
seg_bi += {sc_stride};
seg_xb += SEG_BLOCK;
}}
// Reduce across SG and write gate_raw[B]
{chr(10).join(f" seg_gate_acc{b} = simd_sum(seg_gate_acc{b});" for b in range(B))}
if (slid == 0) {{
{seg_write}
}}
}}
threadgroup_barrier(mem_flags::mem_threadgroup);
// ═══════════════════════════════════════════════════════════════
// SHARED BODY: register-level weight sharing for B batch elements
// ═══════════════════════════════════════════════════════════════
int out_row = tgid.y * 8 + sgid * RESULTS_PER_SG;
if (out_row >= SHARED_INTER_DIM) return;
const device uint8_t* ws_gate = (const device uint8_t*)W_shared
+ (long)out_row * K_DIM + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc_gate = (const device bfloat16_t*)S_shared
+ (long)out_row * {SHARED_K_groups} + slid / {slid_divisor};
const device bfloat16_t* bi_gate = (const device bfloat16_t*)B_shared
+ (long)out_row * {SHARED_K_groups} + slid / {slid_divisor};
const device uint8_t* ws_up = (const device uint8_t*)W_shared
+ (long)(out_row + SHARED_INTER_DIM) * K_DIM + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc_up = (const device bfloat16_t*)S_shared
+ (long)(out_row + SHARED_INTER_DIM) * {SHARED_K_groups} + slid / {slid_divisor};
const device bfloat16_t* bi_up = (const device bfloat16_t*)B_shared
+ (long)(out_row + SHARED_INTER_DIM) * {SHARED_K_groups} + slid / {slid_divisor};
int x_base = slid * VALUES_PER_THREAD;
{shared_result_decls}
for (int k = 0; k < K_DIM; k += BLOCK_SIZE) {{
// Load x for all {B} batch elements
{shared_x_load}
for (int row = 0; row < RESULTS_PER_SG; row++) {{
// Load gate weights once into registers
const device uint8_t* wg = ws_gate + row * K_DIM;
float sg = float(sc_gate[row * {SHARED_K_groups}]);
float bg = float(bi_gate[row * {SHARED_K_groups}]);
float wg_vals[VALUES_PER_THREAD];
for (int i = 0; i < VALUES_PER_THREAD; i++) wg_vals[i] = float(wg[i]);
// Compute gate for all {B} batches from registers
{shared_gate_qdot}
// Load up weights once into registers
const device uint8_t* wu = ws_up + row * K_DIM;
float su = float(sc_up[row * {SHARED_K_groups}]);
float bu = float(bi_up[row * {SHARED_K_groups}]);
float wu_vals[VALUES_PER_THREAD];
for (int i = 0; i < VALUES_PER_THREAD; i++) wu_vals[i] = float(wu[i]);
// Compute up for all {B} batches from registers
{shared_up_qdot}
}}
ws_gate += BLOCK_SIZE; ws_up += BLOCK_SIZE;
sc_gate += {sc_stride}; sc_up += {sc_stride};
bi_gate += {sc_stride}; bi_up += {sc_stride};
x_base += BLOCK_SIZE;
}}
// SwiGLU epilogue + write for all {B} batches (with inv_rms)
{shared_write}
}}
"""
_batched_softmax_topk_swiglu_cache = {}
def _get_batched_softmax_topk_swiglu_kernel(
N_INTER, SHARED_INTER, K, n_active, B,
n_experts=256, top_k=10, norm_topk=True, group_size=64,
n_oproj_tg=64,
):
key = (N_INTER, SHARED_INTER, K, n_active, B, n_experts, top_k, norm_topk, group_size, n_oproj_tg)
if key not in _batched_softmax_topk_swiglu_cache:
nt_tag = "_nt" if norm_topk else ""
_batched_softmax_topk_swiglu_cache[key] = mx.fast.metal_kernel(
name=(f"batched_softmax_topk_swiglu_8bit"
f"_NI{N_INTER}_SI{SHARED_INTER}_K{K}"
f"_na{n_active}_B{B}_E{n_experts}_k{top_k}"
f"_gs{group_size}{nt_tag}"),
input_names=[
"W", "S", "B_q", # routed expert weights
"W_shared", "S_shared", "B_shared", # shared expert weights
"W_seg", "S_seg", "B_seg", # shared_expert_gate weights
"X", # h_scaled (B, K) bf16
"gate_part_a", "gate_part_b", # (B, E) f32
"x2_partials", # (B, N_OPROJ_TG) f32
],
output_names=["Y_routed", "Y_shared", "out_inds",
"norm_scores", "gate_raw"],
source=_gen_batched_softmax_topk_swiglu_source(
N_INTER, SHARED_INTER, K, n_active, B,
n_experts, top_k, norm_topk, group_size, n_oproj_tg),
)
return _batched_softmax_topk_swiglu_cache[key]
def batched_softmax_topk_swiglu_8bit(
w_gu, s_gu, b_gu, # routed gate+up weights (E, 2*N_INTER, K/4)
w_shared, s_shared, b_shared, # shared gate+up weights (2*SHARED_INTER, K/4)
w_seg, s_seg, b_seg, # shared_expert_gate weights (1, K/4)
h_scaled, # (B, K) bf16 — from Dispatch 1
gate_part_a, # (B, E) f32 — from Dispatch 1
gate_part_b, # (B, E) f32 — from Dispatch 1
x2_partials, # (B, N_OPROJ_TG) f32 — from Dispatch 1
n_inter, k_hidden, batch_size, n_active,
n_oproj_tg, n_experts=256,
shared_inter=None, group_size=64,
):
"""Batched Dispatch 2: softmax + top-k + merged 8-bit SwiGLU with oproj prologue.
Prologue (per-batch):
Phase 1: distributed x2 -> inv_rms (all TGs, indexed by batch_id)
Phase 2: softmax(gate_scores) -> top-k -> norm_topk_prob (all routed TGs)
Phase 3: shared_expert_gate 8-bit GEMV -> gate_raw[B] (shared TG, SG 0)
Body:
Routed: 8-bit gate+up+SwiGLU with h_scaled[batch_id] input, inv_rms factored
Shared: register-level weight sharing for B batch elements
Args:
w_gu: stacked routed weights (E, 2*N_INTER, K/4) uint32
s_gu: routed scales (E, 2*N_INTER, K/gs) bf16
b_gu: routed biases (E, 2*N_INTER, K/gs) bf16
w_shared: shared gate+up stacked (2*SHARED_INTER, K/4) uint32
s_shared: shared scales (2*SHARED_INTER, K/gs) bf16
b_shared: shared biases (2*SHARED_INTER, K/gs) bf16
w_seg/s_seg/b_seg: shared_expert_gate 8-bit weights (1, K/4) uint32
h_scaled: (B, K) bf16 — h * w_rms from Dispatch 1
gate_part_a: (B, E) f32 — partial gate scores from Dispatch 1
gate_part_b: (B, E) f32 — partial gate scores from Dispatch 1
x2_partials: (B, N_OPROJ_TG) f32 — per-TG x2 sums from Dispatch 1
n_inter: routed intermediate size
k_hidden: hidden size K
batch_size: B (1..8)
n_active: experts per token (top_k)
n_oproj_tg: number of o_proj TGs (for x2 partial sum reduction)
n_experts: total number of experts E
shared_inter: shared intermediate size (defaults to n_inter)
group_size: quantization group size (default 64)
Returns:
(Y_routed, Y_shared, out_inds, norm_scores, gate_raw):
Y_routed: (B * n_active, n_inter) f32
Y_shared: (B, shared_inter) f32
out_inds: (B * n_active,) uint32
norm_scores: (B * n_active,) f32
gate_raw: (B,) f32 — raw shared expert gate values (sigmoid in epilogue)
"""
B = int(batch_size)
n_inter_val = int(n_inter)
shared_inter_val = int(shared_inter) if shared_inter is not None else n_inter_val
k_val = int(k_hidden)
n_active_val = int(n_active)
top_k = n_active_val
E = int(n_experts)
n_oproj_tg_val = int(n_oproj_tg)
kern = _get_batched_softmax_topk_swiglu_kernel(
n_inter_val, shared_inter_val, k_val, n_active_val, B,
E, top_k, True, int(group_size), n_oproj_tg_val,
)
max_inter = max(n_inter_val, shared_inter_val)
total_routed = B * n_active_val
results = kern(
inputs=[
w_gu, s_gu, b_gu,
w_shared, s_shared, b_shared,
w_seg, s_seg, b_seg,
h_scaled,
gate_part_a, gate_part_b,
x2_partials,
],
output_shapes=[
(total_routed * n_inter_val,), # Y_routed flat
(B * shared_inter_val,), # Y_shared flat
(total_routed,), # out_inds
(total_routed,), # norm_scores
(B,), # gate_raw
],
output_dtypes=[
mx.float32, # Y_routed
mx.float32, # Y_shared
mx.uint32, # out_inds
mx.float32, # norm_scores
mx.float32, # gate_raw
],
grid=(32, ceil_div(max_inter, 8) * 2, total_routed + 1),
threadgroup=(32, 2, 1),
)
Y_routed = results[0].reshape(total_routed, n_inter_val)
Y_shared = results[1].reshape(B, shared_inter_val)
out_inds = results[2]
norm_scores = results[3]
gate_raw = results[4]
return Y_routed, Y_shared, out_inds, norm_scores, gate_raw
@@ -0,0 +1,288 @@
"""Fused GDN projections for Qwen3.5-35B-A3B (Dispatch 2).
Single dispatch fuses 4 quantized 8-bit GEMVs + depthwise conv1d + activations:
- in_proj_qkv (8192×2048): GEMV → conv1d(4-tap) → SiLU → write bf16 + cache update
- in_proj_z (4096×2048): GEMV → SiLU → write f32
- in_proj_b (32×2048): GEMV → sigmoid → write f32 (beta for GDN kernel)
- in_proj_a (32×2048): GEMV → g=exp(-exp(A_log)*softplus(a+dt_bias)) → write f32
All 4 projection weight matrices are pre-merged into one contiguous buffer
(W_merged, S_merged, B_merged) for better memory locality and cache behavior.
Merging is done offline at patch time by _patch_gdn_proj_weights().
B/A epilogues compute g and beta in-kernel, eliminating ~8 micro-dispatches
that gated_delta_update would otherwise generate (sigmoid, exp, log, etc.).
The caller can pass g/beta directly to gated_delta_kernel.
TG-level multiplexing: tgid.y routes to different epilogues.
Each TG: 64 threads = 2 SGs of 32, produces 8 output rows (4 per SG).
Standard 8-bit affine GEMV: result = scale * Σ(x[i]*w[i]) + bias * Σ(x[i])
Grid: (32, total_tg * 2, B), TG: (32, 2, 1)
"""
import mlx.core as mx
def ceil_div(a, b):
return (a + b - 1) // b
def _gen_fused_gdn_projections_source(K, N_QKV, N_Z, N_B, N_A, group_size=64):
"""Generate Metal source for fused GDN projections with merged weights.
All constants baked into Metal source (no scalar kernel inputs).
"""
gs = int(group_size)
sc_stride = 256 // gs
slid_div = gs // 8
N_TOTAL = N_QKV + N_Z + N_B + N_A
K_groups = K // gs
N_QKV_TG = ceil_div(N_QKV, 8)
N_Z_TG = ceil_div(N_Z, 8)
return f"""
const int RESULTS_PER_SG = 4;
const int VALUES_PER_THREAD = 8;
const int BLOCK_SIZE = 256;
const int GROUP_SIZE = {gs};
const int SC_STRIDE = {sc_stride};
const int SLID_DIV = {slid_div};
const int K = {K};
const int K_groups = {K_groups};
const int N_QKV = {N_QKV};
const int N_Z = {N_Z};
const int N_B = {N_B};
const int N_TOTAL = {N_TOTAL};
const int N_QKV_TG = {N_QKV_TG};
const int N_Z_TG = {N_Z_TG};
const int N_B_TG = {ceil_div(N_B, 8)};
uint3 tgid = threadgroup_position_in_grid;
uint sgid = simdgroup_index_in_threadgroup; // 0 or 1
uint slid = thread_index_in_simdgroup; // 0..31
int b_idx = tgid.z;
int tg = tgid.y;
// ─── Determine region and absolute out_row in merged matrix ───
int out_row;
int region; // 0=QKV, 1=Z, 2=B, 3=A
if (tg < N_QKV_TG) {{
region = 0;
out_row = tg * 8 + sgid * RESULTS_PER_SG;
}} else if (tg < N_QKV_TG + N_Z_TG) {{
region = 1;
out_row = N_QKV + (tg - N_QKV_TG) * 8 + sgid * RESULTS_PER_SG;
}} else if (tg < N_QKV_TG + N_Z_TG + N_B_TG) {{
region = 2;
out_row = N_QKV + N_Z + (tg - N_QKV_TG - N_Z_TG) * 8 + sgid * RESULTS_PER_SG;
}} else {{
region = 3;
out_row = N_QKV + N_Z + N_B + (tg - N_QKV_TG - N_Z_TG - N_B_TG) * 8 + sgid * RESULTS_PER_SG;
}}
if (out_row >= N_TOTAL) return;
// ─── Single pointer into merged weight buffer ───
const device uint8_t* ws = (const device uint8_t*)W_merged + (long)out_row * K + slid * VALUES_PER_THREAD;
const device bfloat16_t* sc = (const device bfloat16_t*)S_merged + (long)out_row * K_groups + slid / SLID_DIV;
const device bfloat16_t* bi = (const device bfloat16_t*)B_merged + (long)out_row * K_groups + slid / SLID_DIV;
// ─── 8-bit GEMV K-loop (unified for all regions) ───
float result[4] = {{0, 0, 0, 0}};
int x_base = b_idx * K + slid * VALUES_PER_THREAD;
for (int k_off = 0; k_off < K; k_off += BLOCK_SIZE) {{
float x_thread[8];
float xsum = 0;
for (int i = 0; i < 8; i++) {{
float xi = float(x[x_base + i]);
x_thread[i] = xi;
xsum += xi;
}}
for (int row = 0; row < RESULTS_PER_SG; row++) {{
const device uint8_t* w = ws + row * K;
float s_val = float(sc[row * K_groups]);
float b_val = float(bi[row * K_groups]);
float accum = 0;
for (int i = 0; i < 8; i++) {{
accum += x_thread[i] * float(w[i]);
}}
result[row] += s_val * accum + xsum * b_val;
}}
ws += BLOCK_SIZE;
sc += SC_STRIDE;
bi += SC_STRIDE;
x_base += BLOCK_SIZE;
}}
// ─── Reduction ───
for (int row = 0; row < RESULTS_PER_SG; row++) {{
result[row] = simd_sum(result[row]);
}}
// ─── Region-specific epilogues ───
// After simd_sum, all 32 threads have result[0..3].
// Threads 0-3 each handle one output row.
if (region == 0) {{
// ═══ QKV: conv1d(4-tap) + SiLU + cache update ═══
int c = out_row + (int)slid; // channel index (= absolute row for QKV)
if (slid < (uint)RESULTS_PER_SG && c < N_QKV) {{
float qkv_val = result[slid];
int conv_dim = N_QKV;
long cs_base = (long)b_idx * 3 * conv_dim;
float s0 = float(conv_state[cs_base + 0 * conv_dim + c]);
float s1 = float(conv_state[cs_base + 1 * conv_dim + c]);
float s2 = float(conv_state[cs_base + 2 * conv_dim + c]);
float conv_out = float(conv_w[c * 4 + 0]) * s0
+ float(conv_w[c * 4 + 1]) * s1
+ float(conv_w[c * 4 + 2]) * s2
+ float(conv_w[c * 4 + 3]) * qkv_val;
float silu_out = conv_out / (1.0f + metal::exp(-conv_out));
conv_state_out[cs_base + 0 * conv_dim + c] = static_cast<bfloat16_t>(s1);
conv_state_out[cs_base + 1 * conv_dim + c] = static_cast<bfloat16_t>(s2);
conv_state_out[cs_base + 2 * conv_dim + c] = static_cast<bfloat16_t>(qkv_val);
qkv_out[b_idx * conv_dim + c] = static_cast<bfloat16_t>(silu_out);
}}
}} else if (region == 1) {{
// ═══ Z: SiLU → write f32 ═══
int z_row = out_row - N_QKV + (int)slid;
if (slid < (uint)RESULTS_PER_SG && z_row < N_Z) {{
float val = result[slid];
float silu_val = val / (1.0f + metal::exp(-val));
z_silu_out[b_idx * N_Z + z_row] = silu_val;
}}
}} else if (region == 2) {{
// ═══ B: sigmoid(result) → beta (f32) ═══
int b_row = out_row - N_QKV - N_Z + (int)slid;
if (slid < (uint)RESULTS_PER_SG && b_row < N_B) {{
float val = result[slid];
float beta = 1.0f / (1.0f + metal::exp(-val));
b_out[b_idx * N_B + b_row] = beta;
}}
}} else {{
// ═══ A: g = exp(-exp(A_log) * softplus(a + dt_bias)) → f32 ═══
int a_row = out_row - N_QKV - N_Z - N_B + (int)slid;
int N_A = N_TOTAL - N_QKV - N_Z - N_B;
if (slid < (uint)RESULTS_PER_SG && a_row < N_A) {{
float a_val = result[slid];
float dt = float(dt_bias_arr[a_row]);
float x_g = a_val + dt;
// softplus(x) = log(1 + exp(x)), with x>20 shortcut for numerical stability
float sp = (x_g > 20.0f) ? x_g : metal::log(1.0f + metal::exp(x_g));
float g_val = metal::exp(-metal::exp(float(A_log_arr[a_row])) * sp);
a_out[b_idx * N_A + a_row] = g_val;
}}
}}
"""
_fused_gdn_proj_cache = {}
def _get_fused_gdn_proj_kernel(K, N_QKV, N_Z, N_B, N_A, group_size=64):
key = (K, N_QKV, N_Z, N_B, N_A, group_size)
if key not in _fused_gdn_proj_cache:
_fused_gdn_proj_cache[key] = mx.fast.metal_kernel(
name=f"fused_gdn_proj_K{K}_NQKV{N_QKV}_NZ{N_Z}_NB{N_B}_NA{N_A}",
input_names=[
"x",
"W_merged", "S_merged", "B_merged",
"conv_state", "conv_w",
"A_log_arr", "dt_bias_arr",
],
output_names=["qkv_out", "z_silu_out", "b_out", "a_out", "conv_state_out"],
source=_gen_fused_gdn_projections_source(K, N_QKV, N_Z, N_B, N_A, group_size),
)
return _fused_gdn_proj_cache[key]
def fused_gdn_projections(
x,
W_merged, S_merged, B_merged,
proj_dims,
conv_state, conv_weights,
A_log, dt_bias,
batch_size=1,
):
"""Fused GDN projections: 4 GEMVs + conv1d + activations + g/beta.
Uses pre-merged contiguous weight buffers for all 4 projections.
B epilogue computes beta = sigmoid(b) in f32.
A epilogue computes g = exp(-exp(A_log) * softplus(a + dt_bias)) in f32.
Caller passes g/beta directly to gated_delta_kernel (no micro-dispatches).
Args:
x: [B, 1, K] bf16 — post-RMSNorm hidden state
W_merged: [N_TOTAL, K/4] uint32 — merged quantized weights
S_merged: [N_TOTAL, K/gs] bf16 — merged scales
B_merged: [N_TOTAL, K/gs] bf16 — merged biases
proj_dims: (N_QKV, N_Z, N_B, N_A) — per-projection output dims
conv_state: [B, 3, conv_dim] bf16 — previous 3 timesteps
conv_weights: [conv_dim, 4, 1] or [conv_dim, 4] bf16 — depthwise conv filters
A_log: [Hv] f32 — GDN decay log-parameter
dt_bias: [Hv] f32 — GDN time constant bias
batch_size: int
Returns:
qkv_conv_silu: [B, 1, N_QKV] bf16 — post-conv, post-SiLU
z_silu: [B, 1, N_Z] f32 — post-SiLU
beta: [B, 1, N_B] f32 — sigmoid(b), ready for GDN kernel
g: [B, 1, N_A] f32 — gating, ready for GDN kernel
conv_state_out: [B, 3, N_QKV] bf16
"""
B = batch_size
N_QKV, N_Z, N_B, N_A = proj_dims
K = x.shape[-1]
kern = _get_fused_gdn_proj_kernel(K, N_QKV, N_Z, N_B, N_A)
N_QKV_TG = ceil_div(N_QKV, 8)
N_Z_TG = ceil_div(N_Z, 8)
N_B_TG = ceil_div(N_B, 8)
N_A_TG = ceil_div(N_A, 8)
total_tg = N_QKV_TG + N_Z_TG + N_B_TG + N_A_TG
conv_w_flat = conv_weights.reshape(-1, 4) if conv_weights.ndim == 3 else conv_weights
x_flat = x.reshape(B, K)
results = kern(
inputs=[
x_flat,
W_merged, S_merged, B_merged,
conv_state, conv_w_flat,
A_log, dt_bias,
],
output_shapes=[
(B * N_QKV,), # qkv_out
(B * N_Z,), # z_silu_out
(B * N_B,), # beta_out (f32)
(B * N_A,), # g_out (f32)
(B * 3 * N_QKV,), # conv_state_out
],
output_dtypes=[mx.bfloat16, mx.float32, mx.float32, mx.float32, mx.bfloat16],
grid=(32, total_tg * 2, B),
threadgroup=(32, 2, 1),
)
qkv_out = results[0].reshape(B, 1, N_QKV)
z_silu = results[1].reshape(B, 1, N_Z)
beta = results[2].reshape(B, 1, N_B)
g = results[3].reshape(B, 1, N_A)
conv_state_out = results[4].reshape(B, 3, N_QKV)
return qkv_out, z_silu, beta, g, conv_state_out
@@ -0,0 +1,128 @@
"""Fused Q/K per-head L2-norm for GDN attention (Dispatch 3).
Performs per-head L2 normalization on q and k vectors with different scaling.
Matches vLLM and latest mlx-lm (qwen3_5.py) which use rsqrt(sum(x²) + eps),
NOT rms_norm which uses rsqrt(mean(x²) + eps).
From qwen3_5.py (updated to match vLLM):
inv_scale = Dk^(-0.5) = 128^(-0.5)
q = inv_scale * q * rsqrt(sum(q²) + 1e-6) → L2-normalize then scale by 1/√Dk
k = k * rsqrt(sum(k²) + 1e-6) → L2-normalize only (no extra scale)
Grid: (32 heads × 32 threads, 1, B).
Each TG = 32 threads = 1 SG, handles one 128-dim head.
Dk=128 = 32 threads × 4 elements → exactly 1 SG, no cross-SG reduction.
"""
import mlx.core as mx
def _gen_fused_qk_rmsnorm_source():
"""Generate Metal source for fused Q/K per-head L2-norm.
Input: qkv [B, 8192] bf16 (flattened from [B, 1, 8192])
- [0, 2048): q = 16 heads × 128
- [2048, 4096): k = 16 heads × 128
- [4096, 8192): v (untouched)
Output: qk_out [B, 4096] bf16
- [0, 2048): q L2-normalized then scaled by 1/√Dk
- [2048, 4096): k L2-normalized (no extra scale)
Grid: (32 * 32, 1, B), TG: (32, 1, 1)
tgid.x 0..15: q heads → scale = 1/√128
tgid.x 16..31: k heads → scale = 1.0
tgid.z: batch index
"""
return """
const int N_READS = 4;
const int DK = 128;
const int HK = 16;
const float EPS = 1e-6f;
const float Q_SCALE = rsqrt(128.0f); // inv_scale = Dk^(-0.5)
const float K_SCALE = 1.0f; // no extra scale for k
uint head_idx = threadgroup_position_in_grid.x;
uint slid = thread_index_in_simdgroup;
uint b_idx = thread_position_in_grid.z;
bool is_q = (head_idx < (uint)HK);
// Input offset: q heads at [0, 2048), k heads at [2048, 4096)
int in_base = is_q
? (b_idx * 8192 + head_idx * DK)
: (b_idx * 8192 + 2048 + (head_idx - HK) * DK);
// Output offset: q at [0, 2048), k at [2048, 4096)
int out_base = b_idx * 4096 + head_idx * DK;
// ── Phase 1: Load 4 elements + sum of squares ──
float vals[4];
float partial_sq = 0.0f;
int elem_base = slid * N_READS;
for (int i = 0; i < N_READS; i++) {
float xi = float(qkv[in_base + elem_base + i]);
vals[i] = xi;
partial_sq += xi * xi;
}
// ── Phase 2: simd reduction (32 threads → full sum of 128 elements) ──
float sum_sq = simd_sum(partial_sq);
// ── Phase 3: compute L2 inv-norm (NOT rms_norm — no /Dk) ──
float inv_rms = metal::precise::rsqrt(sum_sq + EPS);
// ── Phase 4: scale and write ──
float scale = is_q ? Q_SCALE : K_SCALE;
float combined = inv_rms * scale;
for (int i = 0; i < N_READS; i++) {
qk_out[out_base + elem_base + i] = static_cast<bfloat16_t>(vals[i] * combined);
}
"""
_fused_qk_rmsnorm_kernel = None
def _get_fused_qk_rmsnorm_kernel():
"""Get or compile the fused Q/K RMSNorm kernel."""
global _fused_qk_rmsnorm_kernel
if _fused_qk_rmsnorm_kernel is None:
_fused_qk_rmsnorm_kernel = mx.fast.metal_kernel(
name="fused_qk_rmsnorm",
input_names=["qkv"],
output_names=["qk_out"],
source=_gen_fused_qk_rmsnorm_source(),
)
return _fused_qk_rmsnorm_kernel
def fused_qk_rmsnorm(qkv_conv_silu, batch_size=1):
"""Fused Q/K per-head RMSNorm for GDN attention.
Args:
qkv_conv_silu: [B, 1, 8192] bf16 — post-conv, post-SiLU output from Dispatch 2.
First 2048 = q (16 heads × 128), next 2048 = k, last 4096 = v.
batch_size: int — batch dimension.
Returns:
qk_normed: [B, 1, 4096] bf16 — normalized q (first 2048) and k (next 2048).
v is NOT copied; Dispatch 4 reads v directly from qkv_conv_silu[:, :, 4096:].
"""
B = batch_size
kern = _get_fused_qk_rmsnorm_kernel()
# Flatten to [B, 8192] for kernel
qkv_flat = qkv_conv_silu.reshape(B, 8192)
n_heads = 32 # 16 q + 16 k
results = kern(
inputs=[qkv_flat],
output_shapes=[(B * 4096,)],
output_dtypes=[mx.bfloat16],
grid=(n_heads * 32, 1, B),
threadgroup=(32, 1, 1),
)
return results[0].reshape(B, 1, 4096)
@@ -0,0 +1,117 @@
"""Fused RMSNormGated for GDN attention (Dispatch 5).
Fuses RMSNorm(out, weight) × z_silu into one kernel.
SiLU on z was already applied in Dispatch 2, so z_silu arrives as f32.
From qwen3_next.py (Qwen3NextRMSNormGated):
x = rms_norm(hidden_states, weight, eps) # weight: [Dv=128]
gate = silu(z.float()) # already done in Dispatch 2
return (gate * x).to(hidden_states.dtype)
Grid: (32 heads × 32 threads, 1, B).
Each TG = 32 threads = 1 SG, handles one 128-dim head.
Dv=128 = 32 threads × 4 elements → exactly 1 SG.
"""
import mlx.core as mx
def _gen_fused_rms_norm_gated_source():
"""Generate Metal source for fused RMSNormGated.
Inputs:
gdn_out: [B, Hv*Dv] bf16 — GDN output, flattened (Hv=32, Dv=128)
z_silu: [B, Hv*Dv] f32 — post-SiLU z from Dispatch 2
weight: [Dv] f32 — RMSNormGated learned weight (128 elements)
Output:
out: [B, Hv*Dv] bf16 — result = z_silu * rms_norm(gdn_out, weight)
Grid: (32 * 32, 1, B), TG: (32, 1, 1)
tgid.x: head index (0..31)
tgid.z: batch index
"""
return """
const int N_READS = 4;
const int DV = 128;
const int HV = 32;
const float EPS = 1e-6f;
uint head_idx = threadgroup_position_in_grid.x;
uint slid = thread_index_in_simdgroup;
uint b_idx = thread_position_in_grid.z;
int base = b_idx * HV * DV + head_idx * DV;
int elem_base = slid * N_READS;
// ── Phase 1: Load gdn_out elements + sum of squares ──
float gdn_vals[4];
float partial_sq = 0.0f;
for (int i = 0; i < N_READS; i++) {
float xi = float(gdn_out[base + elem_base + i]);
gdn_vals[i] = xi;
partial_sq += xi * xi;
}
// ── Phase 2: simd reduction (32 threads → full sum of 128 elements) ──
float sum_sq = simd_sum(partial_sq);
// ── Phase 3: compute inv_rms ──
float inv_rms = metal::precise::rsqrt(sum_sq / float(DV) + EPS);
// ── Phase 4: RMSNorm × z_silu, write bf16 ──
for (int i = 0; i < N_READS; i++) {
int idx = elem_base + i;
float w = float(weight[idx]); // learned weight[Dv]
float normed = gdn_vals[i] * inv_rms * w; // RMSNorm
float z_val = z_silu[base + idx]; // already f32, post-SiLU
out[base + idx] = static_cast<bfloat16_t>(z_val * normed);
}
"""
_fused_rms_norm_gated_kernel = None
def _get_fused_rms_norm_gated_kernel():
"""Get or compile the fused RMSNormGated kernel."""
global _fused_rms_norm_gated_kernel
if _fused_rms_norm_gated_kernel is None:
_fused_rms_norm_gated_kernel = mx.fast.metal_kernel(
name="fused_rms_norm_gated",
input_names=["gdn_out", "z_silu", "weight"],
output_names=["out"],
source=_gen_fused_rms_norm_gated_source(),
)
return _fused_rms_norm_gated_kernel
def fused_rms_norm_gated(gdn_out, z_silu, weight, batch_size=1):
"""Fused RMSNormGated: RMSNorm(out, weight) × z_silu.
Args:
gdn_out: [B, 1, Hv, Dv] bf16 — GDN recurrence output (Hv=32, Dv=128).
z_silu: [B, 1, 4096] f32 — post-SiLU z from Dispatch 2.
weight: [128] f32 — RMSNormGated learned weight (Dv elements).
batch_size: int.
Returns:
out: [B, 1, 4096] bf16 — ready for out_proj in Dispatch 6.
"""
B = batch_size
kern = _get_fused_rms_norm_gated_kernel()
# Flatten to [B, 4096]
gdn_flat = gdn_out.reshape(B, 4096)
z_flat = z_silu.reshape(B, 4096)
n_heads = 32 # Hv
results = kern(
inputs=[gdn_flat, z_flat, weight],
output_shapes=[(B * 4096,)],
output_dtypes=[mx.bfloat16],
grid=(n_heads * 32, 1, B),
threadgroup=(32, 1, 1),
)
return results[0].reshape(B, 1, 4096)
@@ -0,0 +1,177 @@
"""GDN recurrence with pre-computed g and beta (Dispatch 4).
Modified version of gated_delta_step from mlx-lm-fork/mlx_lm/models/gated_delta.py.
Instead of computing g = exp(-exp(A_log) * softplus(a + dt_bias)) and beta = sigmoid(b)
inside the kernel, accepts them as pre-computed f32 inputs from Dispatch 2.
Non-vectorized only (Qwen3.5-35B-A3B uses scalar gating per head).
Grid: (32, Dv, B*Hv) = (32, 128, B*32), TG: (32, 4, 1)
"""
from typing import Optional, Tuple
import mlx.core as mx
def _make_gdn_precomputed_kernel(has_mask=False):
"""Build the GDN kernel with pre-computed g and beta."""
if not mx.metal.is_available():
return None
mask_source = "mask[b_idx * T + t]" if has_mask else "true"
source = f"""
auto n = thread_position_in_grid.z;
auto b_idx = n / Hv;
auto hv_idx = n % Hv;
auto hk_idx = hv_idx / (Hv / Hk);
constexpr int n_per_t = Dk / 32;
// q, k: [B, T, Hk, Dk]
auto q_ = q + b_idx * T * Hk * Dk + hk_idx * Dk;
auto k_ = k + b_idx * T * Hk * Dk + hk_idx * Dk;
// v, y: [B, T, Hv, Dv]
auto v_ = v + b_idx * T * Hv * Dv + hv_idx * Dv;
y += b_idx * T * Hv * Dv + hv_idx * Dv;
auto dk_idx = thread_position_in_threadgroup.x;
auto dv_idx = thread_position_in_grid.y;
// state_in, state_out: [B, Hv, Dv, Dk]
auto i_state = state_in + (n * Dv + dv_idx) * Dk;
auto o_state = state_out + (n * Dv + dv_idx) * Dk;
float state[n_per_t];
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = static_cast<float>(i_state[s_idx]);
}}
// g: [B, T, Hv] f32 — pre-computed decay gate
auto g_ = g + b_idx * T * Hv;
// beta: [B, T, Hv] f32 — pre-computed sigmoid(b)
auto beta_ = beta + b_idx * T * Hv;
for (int t = 0; t < T; ++t) {{
if ({mask_source}) {{
// Pre-computed g and beta (no softplus/exp/sigmoid needed)
float g_val = g_[hv_idx];
float beta_val = beta_[hv_idx];
float kv_mem = 0.0f;
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] * g_val;
kv_mem += state[i] * k_[s_idx];
}}
kv_mem = simd_sum(kv_mem);
auto delta = (v_[dv_idx] - kv_mem) * beta_val;
float out = 0.0f;
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] + k_[s_idx] * delta;
out += state[i] * q_[s_idx];
}}
out = simd_sum(out);
if (thread_index_in_simdgroup == 0) {{
y[dv_idx] = static_cast<InT>(out);
}}
}}
// Increment data pointers to next time step
q_ += Hk * Dk;
k_ += Hk * Dk;
v_ += Hv * Dv;
y += Hv * Dv;
g_ += Hv;
beta_ += Hv;
}}
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
o_state[s_idx] = static_cast<InT>(state[i]);
}}
"""
inputs = ["q", "k", "v", "g", "beta", "state_in", "T"]
if has_mask:
inputs.append("mask")
suffix = "_precomputed"
if has_mask:
suffix += "_mask"
return mx.fast.metal_kernel(
name=f"gated_delta_step{suffix}",
input_names=inputs,
output_names=["y", "state_out"],
source=source,
)
_gdn_precomputed_kernel = None
_gdn_precomputed_kernel_masked = None
def _get_gdn_precomputed_kernel(has_mask=False):
"""Get or compile the pre-computed GDN kernel."""
global _gdn_precomputed_kernel, _gdn_precomputed_kernel_masked
if has_mask:
if _gdn_precomputed_kernel_masked is None:
_gdn_precomputed_kernel_masked = _make_gdn_precomputed_kernel(has_mask=True)
return _gdn_precomputed_kernel_masked
else:
if _gdn_precomputed_kernel is None:
_gdn_precomputed_kernel = _make_gdn_precomputed_kernel(has_mask=False)
return _gdn_precomputed_kernel
def gated_delta_update_precomputed(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = None,
) -> Tuple[mx.array, mx.array]:
"""GDN recurrence with pre-computed g and beta.
Args:
q: [B, T, Hk, Dk] bf16 — normalized q from Dispatch 3
k: [B, T, Hk, Dk] bf16 — normalized k from Dispatch 3
v: [B, T, Hv, Dv] bf16 — v from Dispatch 2 (qkv_conv_silu[:, :, 4096:])
g: [B, T, Hv] f32 — pre-computed decay gate from Dispatch 2
beta: [B, T, Hv] f32 — pre-computed sigmoid(b) from Dispatch 2
state: [B, Hv, Dv, Dk] bf16 — recurrent state from cache
mask: [B, T] optional
Returns:
y: [B, T, Hv, Dv] bf16
new_state: [B, Hv, Dv, Dk] bf16
"""
B, T, Hk, Dk = k.shape
Hv, Dv = v.shape[2:]
input_type = q.dtype
kernel = _get_gdn_precomputed_kernel(has_mask=mask is not None)
inputs = [q, k, v, g, beta, state, T]
if mask is not None:
inputs.append(mask)
return kernel(
inputs=inputs,
template=[
("InT", input_type),
("Dk", Dk),
("Dv", Dv),
("Hk", Hk),
("Hv", Hv),
],
grid=(32, Dv, B * Hv),
threadgroup=(32, 4, 1),
output_shapes=[(B, T, Hv, Dv), state.shape],
output_dtypes=[input_type, input_type],
)
@@ -0,0 +1,440 @@
# DFlash Speculative Decoding on Qwen3.5 Attn/MoE Split — Pipeline Reference
Navigation aid for reading and debugging this code path. Every step has a `file:line` so it can be opened directly. Written for Qwen3.5 (both dense 27B and MoE variants) running with `world_size==2` on JACCL.
All paths below are relative to `exo/src/exo/worker/engines/mlx/` unless noted otherwise.
---
## 1. Executive summary
- **Two ranks.** `ATTN_RANK=0` runs attention (GQA `self_attn` and linear `linear_attn`). `MOE_RANK=1` runs the MLP / MoE block. Ranks are defined in `patches/qwen3_5_moe_split/decoder.py:16`.
- **Two independent layers of patches:**
- **Layer A (structural).** Replaces class methods so different ranks do different work. All installs in `patches/qwen3_5_moe_split/apply.py:220294`.
- **Layer B (kernel).** Instance-level swaps of each projection's `__call__` with a dynamic LpB kernel picker. Target: `patches/qwen3_5/lpb_patch.py:102`. Drafter: `speculative/bf16_lpb_patch.py:99`.
- **Three phases per request.** Prefill (S > 1) → speculative cycles (S = V+1, one-shot per cycle) → termination (stop token or length cap).
- **Drafter lives on both ranks.** Both ranks run the drafter in parallel; drafts are synced via `all_gather` and MOE_RANK's drafts win.
- **Rollback runs on both ranks.** Same `n_accepted` on both (deterministic at temp=0; broadcast at temp>0). ATTN_RANK uses the real speculative GDN states; MOE_RANK's `cache[1]` stays `None` because it never runs `linear_attn`.
---
## 2. Architecture overview
```
JACCL group (world_size=2)
▲ ▲
│ all_gather │ all_gather
│ │
┌───────────────┴─────────┐ ┌───────┴────────────────┐
│ ATTN_RANK (rank 0) │ │ MOE_RANK (rank 1) │
│ ───────────────────── │ │ ───────────────────── │
│ Target model: │ │ Target model: │
│ - self_attn (GQA) │ │ - mlp (dense/MoE) │
│ - linear_attn (GDN) │ │ - post_attention_ln │
│ - input_layernorm │ │ - input_layernorm† │
│ - KV cache real │ │ - KV cache empty* │
│ - GDN state real │ │ - GDN state empty* │
│ │ │ │
│ Drafter (full copy) │ │ Drafter (full copy) │
│ - 5 layers, KVCache │ │ - 5 layers, KVCache │
└─────────────────────────┘ └─────────────────────────┘
† kept under EXO_SPECULATIVE=1 (used for GDN conv_input reconstruction)
* rank-specific cache shims in apply.py:33 make empty-cache ops safe
```
**Draft-position authority.** ATTN_RANK's `cache.offset` is the truth — it runs attention and knows the real position. MOE_RANK's `cache.offset` is always 0. Every speculative cycle opens with an `all_gather` of `_draft_position` that overwrites MOE_RANK's copy (`dflash_split.py:6570`).
---
## 3. Patch inventory
### 3a. Layer A — structural patches
Installed in order by `apply_qwen35_attn_moe_split_patches` (`apply.py:220`). Invoked from `auto_parallel.py:474` under `attn_moe_split_auto_parallel`, gated by `isinstance(inner, Qwen3_5TextModelInner)` and `world_size==2`.
| # | Patched symbol | Replacement | Install site | Replacement body | Purpose |
|---|----------------|-------------|--------------|------------------|---------|
| 1 | `DecoderLayer.__call__` | `_split_call` | `apply.py:250` | `decoder.py:36` | Serial S==1 split, one all_gather per sub-step |
| 2 | `Qwen3_5TextModel.__call__` | `_pipelined_call` | `apply.py:253` | `model_forward.py:430` | S>1 routes through `pipelined_layer_loop`; S==1 falls through to stock loop → `_split_call` |
| 3 | `mtp_module.speculative_forward` | pipelined variant | `apply.py:259` | `model_forward.py:491` (factory), `:499` (body) | MTP speculative path (not used under `SPECULATIVE_MODE=dflash`) |
| 4 | `dflash_speculative.dflash_speculative_forward` | `_pipelined_dflash_forward` | `apply.py:270` | `model_forward.py:615` (factory), `:623` (body) | DFlash verify forward over the pipeline; captures target-layer hiddens + GDN speculative states |
| 5 | `DFlashBatchGenerator._speculative_next` | `_split_speculative_next` | `apply.py:279` | `dflash_split.py:29` | Draft/verify/accept cycle with cross-rank syncs |
| 6 | Cache shims on MOE_RANK | many | `apply.py:284` | `apply.py:33211` | Make `state` / `extract` / `filter` / `extend` safe on unpopulated caches |
| 7 | Weight dropping | `_drop_unused_weights` | `apply.py:288` | `apply.py:297` | Only runs when `EXO_SPECULATIVE!=1`; speculative keeps both sides' weights |
### 3b. Layer B — kernel patches
| # | Target | Function | Body | Notes |
|---|--------|----------|------|-------|
| 1 | Target model projections | `apply_lpb_patches(model)` | `patches/qwen3_5/lpb_patch.py:102` | Called from `apply.py:245` under `EXO_LPB_PATCHES=1`. Patches per-layer `mlp.{gate,up,down}_proj`, attn `{q,k,v,o}_proj` or `{in_proj_qkv,in_proj_z,out_proj}`, and `lm_head`. Instance-level wrapping via `setattr(parent, proj_name, _LpB*Linear(...))`. |
| 2 | Drafter projections | `apply_bf16_lpb_patches(drafter)` | `speculative/bf16_lpb_patch.py:99` | Called from `generator/batch_generate.py:117`. Patches per-layer `mlp_{gate,up,down}` + `self_attn.{q,k,v,o}_proj` + `drafter.fc` + `drafter.lm_head`. `DFLASH_LPB_ONLY=q_proj,k_proj,...` bisect knob at `bf16_lpb_patch.py:106`. |
| 3 | `gated_delta_update` | `_make_speculative_gdu(spec_all_states)` | `speculative/mtp_module.py:126` | Temporary swap on `mlx_lm.models.qwen3_5.gated_delta_update`. Installed at verify entry (`model_forward.py:667`), restored post-loop (`model_forward.py:732`). Captures per-step recurrent states for rollback. |
The LpB wrappers memoize `M → kernel_fn` per projection. Kernel selection logic lives in `matmul/patches/kernel_picker.py:33` (`pick_bf16_kernel`) and `:69` (`pick_int8_kernel`). `MAX_M=16` in both LpB patches; M>16 falls back to the original `__call__`.
### 3c. Target-layer capture (stock DFlash, no bypass)
Stock `DFlashBatchGenerator._setup_hidden_capture` (`speculative/dflash_batch_generator.py:53`) wraps target-layer indices in a `_CapturingLayer`. Its `__call__` is what populates `self._captured['{layer,prefill}_hiddens']`. Our pipelined path **never calls `layer.__call__`** — it dispatches `layer.self_attn` / `layer.linear_attn` / `layer.mlp` directly. Compensation: `_populate_dflash_captured` (`model_forward.py:387`) reaches into `_CapturingLayer`'s closure at the end of `_pipelined_call` to write the dict DFlash expects. See commit `b1a5dccf`.
---
## 4. Execution flow by phase
### 4a. Startup & warmup
1. Model loads via `auto_parallel.attn_moe_split_auto_parallel` (`auto_parallel.py:436`), which calls `apply_qwen35_attn_moe_split_patches(model, group)` (`auto_parallel.py:474`). Both ranks run patch install; differences are gated by `group.rank()`.
> *Prints:* `Patched X target projections with dynamic LpB` (`lpb_patch.py:130`) if `EXO_LPB_PATCHES!=0`; `Qwen3.5 attn/moe split patch applied on rank N/2` (`apply.py:291`).
2. `BatchGenerator` init reads `EXO_SPECULATIVE=1 EXO_SPECULATIVE_MODE=dflash` (`batch_generate.py:100`).
3. Drafter is instantiated: `DFlashDrafter(self.model, dflash_path)` (`batch_generate.py:116`). See `dflash_module.py:107` — weights loaded via `huggingface_hub.snapshot_download` if not cached.
> *Prints:* `DFlash config: N layers, hidden=..., heads=.../..., block=...` (`dflash_module.py:133`); `Target layers: [...], mask_token=...` (`:136`) — **this is the authoritative list of `target_layer_ids`**; `DFlash loaded: X tensors, Y.YM params` (`:164`).
4. Drafter is LpB-patched: `apply_bf16_lpb_patches(drafter)` (`batch_generate.py:117`).
> *Prints:* `DFLASH_LPB_ONLY=...` only if that env var is set (`bf16_lpb_patch.py:109`); `Patched X DFlash drafter projections with dynamic LpB` (`:143`).
5. `DFlashBatchGenerator.__init__` (`dflash_batch_generator.py:23`) runs `_setup_hidden_capture` (`dflash_batch_generator.py:53`) — wraps each index in `drafter.target_layer_ids` with `_CapturingLayer`.
6. **Warmup**`warmup_dflash` (`batch_generate.py:272`) sweeps drafter projections across every M they'll see: `S_ctx ∈ [1, V+1]` × `block_size`. Then one target verify at `M=V+1` to compile every target projection. Without this, Metal kernel compilation stalls the first real batch. Warmup also runs one non-speculative `dflash_speculative_forward` (`batch_generate.py:311`) for target-only kernel priming, then rolls back the cache.
> *Prints:* bookends `Warming up DFlash speculative decoding kernels...` (`batch_generate.py:298`) and `DFlash warmup complete` (`:357`). Each pipelined forward inside warmup emits the full `[rank N] pipeline begin ...` + per-stage stream from `model_forward.py:212, 225, 268, 306, 331`.
> *Evals:* `mx.eval(target_hidden_full, logits)` (`:313`), `mx.eval(dl)` per drafter sweep iteration (`:332`), `mx.eval(target_hidden, vl)` after the verify forward (`:341`).
### 4b. Prefill (first step for a uid)
Entry: `BatchGenerator.step``DFlashBatchGenerator._next` (`dflash_batch_generator.py:92`).
1. `_next` sees uid ∉ `_prefilled`, routes to `_first_step_capture` (`dflash_batch_generator.py:119`).
2. `super()._next()` → stock `BatchGenerator._next` processes the prompt, calls `self.model(prompt_tokens, cache)`.
3. That reaches our patched `Qwen3_5TextModel.__call__` = `_pipelined_call` (`model_forward.py:430`). S = prompt_len > 1, so it takes the pipelined branch (`:456481`):
1. `_dflash_capturing_target_ids(self)` (`model_forward.py:353`) detects the `_CapturingLayer` wrappers.
2. `pipelined_layer_loop(..., capture_layers=target_ids)` (`model_forward.py:146`) runs the 2N+1 stage pipeline — see section 4c step 7.5 for the full stage-by-stage print/eval inventory. Same structure applies here at `S = prompt_len`; whether the single-gather fast path or the two-gather slow path fires depends on `prompt_len % 2`.
3. `_populate_dflash_captured(self, layer_hiddens, S)` (`model_forward.py:387`) writes `_captured['layer_hiddens']` and `_captured['prefill_hiddens']` via closure introspection.
4. Back in `super()._next()`: `inner.norm``lm_head` (LpB wrapper, M = prompt_len, typically > 16 → fallback to stock GEMM). First token sampled.
5. Back in `_first_step_capture`: reads `_captured['prefill_hiddens']``_last_target_hidden[uid]` shape `(1, prompt_len, D·|target_layers|)`. Seeds `_draft_position[uid]` from `cache.offset` on one of the caches.
> *Evals:* `mx.eval(target_hidden)` (`dflash_batch_generator.py:125`) before caching — forces the concatenated prefill hiddens to materialize.
6. Falls into `_speculative_next` (first real cycle).
### 4c. Speculative cycle (per step)
Entry: `DFlashBatchGenerator._speculative_next` — patched to `_split_speculative_next` in `dflash_split.py:29`. Both ranks run identical code unless noted.
Numbered per the source, cross-referenced with `dflash_split.py` line numbers.
1. **Preamble** (`:3455`). Record `tic`, append `y_val` to `batch.tokens[0]`, read `_last_target_hidden[uid]`. If missing, fall back to `super()._next()` — this hits the S==1 decode path (section 4d). Logs `DFlash speculative cycle (...)` and any fallback.
> *Prints:* either `[rank N] DFlash: NO target_hidden -> fallback ... (y_val=...)` (`dflash_split.py:46`) OR `[rank N] DFlash speculative cycle (y_val=..., target_hidden.shape=...)` (`:51`) — one per cycle per rank.
2. **Draft-position sync** (`:6570`). `all_gather(_draft_position)` → take `ATTN_RANK`'s. ATTN_RANK's cache offset is authoritative; MOE_RANK's stays at 0 because it never runs attention.
3. **Draft** (`:7376`). Both ranks: `drafter.draft(last_target_hidden, block_ids, start)``draft_logits` of shape `(B, block_size-1, vocab)`. `block_ids` is `[y_val, MASK, MASK, ...]` of length `block_size`. The drafter implementation is `dflash_module.py:226` — see notes at the bottom of section 4c.
4. **Sample** (`:7991`). temp=0: `argmax` of `draft_logits`. temp>0: per-position `mx.random.categorical` with softmax. Both ranks sample **independently** (RNG seeds may diverge).
> *Evals:* `mx.eval(all_drafts_arr)` (`dflash_split.py:81`) before `.tolist()` on temp=0 path; temp>0 uses `.item()` per position which evals inline.
5. **Draft sync** (`:9498`). `all_gather(drafts_local)` → take MOE_RANK's `drafts_arr`. After this, both ranks have identical `drafts` of length `verify_len`.
> *Evals:* `mx.eval(drafts_arr)` (`dflash_split.py:97`) before `.tolist()` — required because the list is used Python-side to build `verify_input`.
6. **Build verify input** (`:101102`). `verify_input = concat([[y_val]], drafts_arr)`, shape `(1, V+1)`.
7. **Pipelined verify forward** (`:106112`). `dflash_speculative_forward(model, verify_input, cache, target_layer_ids, speculative=True)` → our `_pipelined_dflash_forward` (`model_forward.py:623`):
1. Wrap GDN caches in `SpeculativeArraysCache` (`speculative_cache.py:15`).
2. Monkey-patch `gated_delta_update``_make_speculative_gdu(spec_all_states)` (`mtp_module.py:126`, installed at `model_forward.py:667`). The speculative kernel returns `(y, state_out, all_states)` and appends `all_states` to the closure list. Shape: `(B, T, H_v, D_v, D_k)`.
3. Collect GDN pre-loop data: for each `is_linear` layer, take `spec_cache[0]` (existing conv state) or zeros, store `(pre_conv, c, layer, idx)`.
4. Compute `capture_set = target_layer_ids {L-1 : L is GDN, L ≥ 1}` (`model_forward.py:707`). The extra `L-1` outputs are needed to reconstruct conv_input post-loop.
5. Run `pipelined_layer_loop(..., capture_layers=capture_set)`. Stages inside the loop:
- **Stage 0 (startup)** — ATTN_RANK: `attn_0(H0)`; MOE_RANK: idle placeholder. 1 all_gather. (`model_forward.py:214228`)
- **Stages 1..2N-1 (main)** — alternating B/A:
- B (odd stage, `T = stage//2`): ATTN runs `attn_T(H1)`, MOE runs `moe_T(h_T_H0)`. Even S → 1 all_gather, odd S → 2. (`:236275`)
- A (even stage, T≥1): ATTN runs `attn_T(H0)`, MOE runs `moe_{T-1}(h_{T-1}_H1)`. Capture of layer T-1 at A-stage end: `capture[T-1] = concat(x_H0, x_H1)` (`:318319`). 1 or 2 all_gathers.
- **Stage 2N (drain)** — ATTN idle, MOE `moe_{N-1}(h_{N-1}_H1)`. 1 all_gather. (`:322334`)
- Capture of layer N-1 happens post-drain (`:342343`).
- Total collectives: even S → 2N+1; odd S → 4N-1.
- Per-stage `mx.eval` calls (`:221`, `:249`, `:262` etc.) break MLX's graph accumulation and keep JACCL's queue drained.
> *Prints per stage:* `[rank N] pipeline begin S=V+1 N=64 even=<bool>` at loop entry (`model_forward.py:212`), then per-stage lines each carrying `... [rank N:role] eval_local=X.XXms eval_gather=Y.YYms` at `:225` (startup), `:268` (B), `:306` (A), `:331` (drain). Total = 1 + (2N+1) = 2N+2 lines per rank per pipelined forward. `eval_local` times only `mx.eval(my_out)`; `eval_gather` times only `mx.eval(gathered)` — see section 8a for the role labels and what to read from them.
> *Evals per stage (graph-break + JACCL queue bound):* startup — `mx.eval(contribution)` (`:221`), `mx.eval(gathered)` (`:223`). Main-loop B stage — even S: `mx.eval(my_out)` (`:249`), `mx.eval(gathered)` (`:251`). Odd S: `mx.eval(attn_side)` (`:261`), `mx.eval(moe_side)` (`:262`), `mx.eval(attn_contrib)` (`:266`), `mx.eval(moe_contrib)` (`:267`). Main-loop A stage — even S: `mx.eval(my_out)` (`:287`), `mx.eval(gathered)` (`:289`). Odd S: `mx.eval(attn_side)` (`:299`), `mx.eval(moe_side)` (`:300`), `mx.eval(attn_contrib)` (`:304`), `mx.eval(moe_contrib)` (`:305`). Drain — `mx.eval(contribution)` (`:327`), `mx.eval(gathered)` (`:329`).
> *Async / no eval:* captured tensors written to the `capture` dict (`:319`, `:343`) and the final `concat([out_H0, out_H1])` (`:339`) are **not** eval'd here — they materialize implicitly when post-loop reconstruction reads them (step 7.7 / 7.8) and when `lm_head` runs in step 7.10.
6. Restore stock `gated_delta_update` (`:732`).
7. **Merge H0+H1 speculative states** (`:741752`). `spec_all_states` has `2 × len(gdn_spec_data)` entries (one per half per layer). Concat consecutive pairs along step dim → per-layer `all_states`. On MOE_RANK `spec_all_states` is empty because the monkey-patched GDU never ran there → `merged_states == []`.
8. **Reconstruct conv_input per GDN layer** (`:754788`). `layer_input = initial_embed` (for layer 0) or `layer_hiddens[layer_idx - 1]` (captured by the pipeline). Then `normed = input_layernorm(layer_input)`, `qkv = in_proj_qkv(normed)`, and `spec_cache.conv_input = concat([pre_conv, qkv], axis=1)`. On MOE_RANK we keep `input_layernorm` and `linear_attn` under `EXO_SPECULATIVE=1`, so this runs there too.
9. Concat captured target hiddens → `target_hidden` of shape `(1, V+1, D·|target_layers|)` (`:791`).
10. Final norm + lm_head (LpB wrapper, M = V+1 — LpB fires) → `verify_logits` of shape `(1, V+1, vocab)`.
8. **Acceptance** (`:116162`). temp=0: `argmax(verify_logits[:, :V, :]) == drafts_arr``matches`, count leading True → `n_accepted`. temp>0: MOE_RANK computes acceptance ratios + uniforms; `all_gather([n_accepted_local])` broadcasts MOE's value.
> *Async evals:* temp=0 — `mx.async_eval(matches, all_next, target_hidden)` (`dflash_split.py:120`) kicks off acceptance + next-step state; the Python `for i in range(V): if matches[i].item(): ...` loop blocks per-index on the already-scheduled compute. temp>0 — `mx.async_eval(accept_ratios, uniforms, corrections, bonus_token, target_hidden)` (`:146148`).
> *Prints:* `[DFlash] n_accepted=X/V` on MOE_RANK only (`:165`) — one line per cycle.
9. **Rollback** (`:171177`). `rollback = V - n_accepted`. If > 0: GQA `BatchKVCache``c.offset -= rollback`. GDN `SpeculativeArraysCache``c.rollback(n_accepted)` (`speculative_cache.py:81`): sets `base.cache[1] = all_states[0, n_accepted]` if `all_states` is set (ATTN_RANK only), and `base.cache[0] = conv_input[:, n_accepted+1 : n_accepted+1+3, :]` (both ranks).
10. **Unwrap speculative caches** (`:179181`). Replace each `SpeculativeArraysCache` with its `.base` for the next cycle's stock layer dispatch.
11. **Emit tokens** (`:184212`). Bonus/correction token (all accepted: `all_next[V]` or `bonus_token`; partial: `all_next[n_accepted]` or `corrections[n_accepted]`). Update `_last_target_hidden[uid] = target_hidden[:, :n_accepted+1, :]`, advance `_draft_position += n_accepted+1`. Buffer accepted draft tokens into `_token_buffer[uid]` (`:234` or `:248`).
> *Async evals:* `mx.async_eval(batch.y)` at `dflash_split.py:239` (stop-token path) or `:250` (normal return) — schedules the next-step bonus token without blocking the `Response` yield.
**Notes on the drafter.** `DFlashDrafter.draft` (`dflash_module.py:226`):
- `target_hidden` shape `(B, accepted_len, n_layers · hidden_size)` — compressed via `self.fc` (`dflash_module.py:243`) to `(B, accepted_len, hidden_size)`.
- `start` = `_draft_position[uid]` = prompt_len on first cycle, then `start + n_accepted + 1` each cycle.
- K/V are formed from `concat(target_hidden, draft_input)` (`dflash_module.py:50`), so `k_proj/v_proj` see `M = accepted_len + block_size`. On the first cycle `accepted_len = prompt_len`, so these projections usually fall back to stock GEMM. After the first cycle `accepted_len ≤ V+1`, so LpB fires at `M = V+1 + block_size` (if ≤ 16).
### 4d. Buffered drain and S==1 fallback decode
- `DFlashBatchGenerator._next` (`dflash_batch_generator.py:92`) yields from `_token_buffer[uid]` first via `_yield_buffered` (`:341`) — one token per call.
- Once the buffer is empty and a new forward is needed, `_speculative_next` fires again.
- If `_last_target_hidden[uid]` is missing (edge case — e.g. dropped between requests), `_split_speculative_next` calls `super()._next()`. That reaches `self.model(y, cache)` with `S=1``_pipelined_call` S==1 branch (`model_forward.py:446454`) → stock layer loop → `DecoderLayer.__call__``_split_call` (`decoder.py:36`):
- Step 1: ATTN runs `self_attn` or `linear_attn` on input; MOE burns `DUMMY_LN_ITERS=50` layernorms to keep JACCL balanced, evaled every 2 layers (`decoder.py:52`).
- 1st all_gather picks ATTN's output.
- Step 2: MOE runs MLP; ATTN burns layernorms.
- 2nd all_gather picks MOE's output.
- Two `all_gather`s per layer, N layers → `2N` total collectives for an S==1 decode.
> *Prints:* per layer per rank — `[rank N] L=lc after gather-1 h.mean=...` (`decoder.py:56`) and `[rank N] L=lc after gather-2 out.mean=...` (`:69`). 2·N lines per decode step per rank.
> *Evals:* per layer — `mx.eval(h)` (`:53`) on the idle rank every 2nd `lc` (JACCL queue bound); `mx.eval(h)` post-gather-1 (`:55`); `mx.eval(out)` (`:66`) idle-rank equivalent; `mx.eval(result)` post-gather-2 (`:68`).
- At M=1 every LpB wrapper fires its fast-path kernel.
### 4e. Termination
- `_yield_buffered` (`dflash_batch_generator.py:341`) drains `_token_buffer[uid]` one token at a time.
- Detects `finish_reason = "stop"` (`:348`) or `"length"` (`:350`).
- On finish: `cache = batch.extract_cache(0)` (`:356`) — this iterates `[c.extract(idx) for c in batch.cache]`.
- `BatchKVCache.extract` → our `_bkv_extract` (`apply.py:165`): returns empty `KVCache` if `self.keys is None`.
- `ArraysCache.extract` → our `_arrays_extract` (`apply.py:130`): builds a new `ArraysCache` preserving `None` per-element (`c[idx:idx+1] if c is not None else None`). Required because MOE_RANK's GDN `base.cache[1]` stays `None` after rollback (see section 6).
---
## 5. Kernel dispatch matrix
Rows = dispatch site. Columns = phase / M seen at call time. Cell = kernel actually dispatched. "stock" means MLX built-in GEMM via the original `nn.Linear.__call__`.
| Site | Prefill (M = prompt_len) | Verify (M = V+1) | Decode (M = 1) | Drafter first cycle | Drafter steady state |
|------|--------------------------|------------------|----------------|---------------------|----------------------|
| target `q/k/v/o_proj` | stock | LpB | LpB | — | — |
| target `mlp.{gate,up,down}_proj` | stock | LpB | LpB | — | — |
| target `linear_attn.{in_proj_*, out_proj}` | stock | LpB | LpB | — | — |
| target `gated_delta_update` | stock | **speculative** (captures all_states) | stock | — | — |
| target `lm_head` | stock | LpB | LpB | — | — |
| drafter `q_proj` | — | — | — | LpB (M=BS) | LpB (M=BS) |
| drafter `k_proj, v_proj` | — | — | — | stock (M=prompt_len+BS, typically >16) | LpB (M=accepted_len+BS) |
| drafter `o_proj` | — | — | — | LpB (M=BS) | LpB (M=BS) |
| drafter `mlp_{gate,up,down}` | — | — | — | LpB (M=BS) | LpB (M=BS) |
| drafter `fc` | — | — | — | stock (M=prompt_len, >16) | LpB (M=accepted_len ≤ V+1) |
| drafter `lm_head` | — | — | — | LpB (M=BS-1) | LpB (M=BS-1) |
**LpB kernel picker.** `pick_bf16_kernel` (`matmul/patches/kernel_picker.py:33`) / `pick_int8_kernel` (`:69`). Rounds M to `{1, 2, 4, 8, 12, 16, 32, 64}`. Decision summary:
- bf16, N > 50000 (lm_head): `lpb` (M ≤ 8) or `lpb_twice`.
- bf16, M_rnd ≤ 8: `lpb`.
- bf16, M_rnd = 12: `lpb_twice` if max(N, K) ≤ 4096 else `sk_steel16`.
- bf16, M_rnd = 16: `sk_steel16`.
- bf16, M_rnd ∈ {32, 64}: `sk_steel32`.
- int8, M_rnd ≤ 8: `lpb`.
- int8, M_rnd = 12 or 16: `qsk16`.
- int8, M_rnd ∈ {32, 64}: `qsk32`.
Note: `MAX_M=16` in both LpB patches means branches for `M_rnd ∈ {32, 64}` are never hit on this path — the picker supports them, the wrappers don't call at those M.
---
## 6. Invariants
### Shapes
- `_last_target_hidden[uid]`: `(1, k, D · |target_layer_ids|)`.
- `k = prompt_len` right after `_first_step_capture`.
- `k = n_accepted + 1` after each `_speculative_next` (`dflash_split.py:197`).
- `block_ids`: `(1, block_size)` with `block_ids[0, 0] = y_val`, rest `mask_token_id`.
- `verify_input`: `(1, V+1)`.
- `target_hidden` returned from verify: `(1, V+1, D · |target_layer_ids|)`.
- `verify_logits`: `(1, V+1, vocab)`.
- GDN `SpeculativeArraysCache.all_states`: `(B, V+1, H_v, D_v, D_k)` after the H0/H1 merge. `SpeculativeArraysCache.conv_input`: `(B, 3 + V+1, conv_dim)`.
### Cache offsets
- `BatchKVCache.offset == BatchKVCache._idx - left_padding`. Masks must slice against `_idx`, not `offset` (the pipelined loop uses `_idx` at `model_forward.py:185`).
- Rollback: `BatchKVCache.offset -= (V - n_accepted)` decrements both `offset` and `_idx` via the cache API. `ArraysCache` rollback rewrites `cache[0]` and `cache[1]` from speculative state.
- GDN base cache entries on MOE_RANK: `cache[0]` gets reconstructed by `_pipelined_dflash_forward`, `cache[1]` stays `None` because the speculative GDU never ran there. Downstream code must tolerate this — `_arrays_extract` does (`apply.py:130`).
### Stage indexing in `pipelined_layer_loop` (`model_forward.py:146`)
- Stage range: `0` (startup) + `1..2N-1` (main loop) + `2N` (drain) = `2N+1` stages.
- `T = stage // 2`. B-stage = odd stage (runs `attn_T(H1)` + `moe_T(h_T_H0)`). A-stage = even stage (runs `attn_T(H0)` + `moe_{T-1}(h_{T-1}_H1)`).
- Capture of layer L happens at the start of the A-stage for T = L+1 (i.e. stage `2(L+1)`), so `capture[L]` is set when we enter A-stage for layer L+1. Layer N-1 is captured post-drain (`model_forward.py:342`).
- Even S: 1 all_gather per stage. Odd S: 2 all_gathers per stage (`_gather_two` at `model_forward.py:110`).
### Draft-position authority
- ATTN_RANK's `cache.offset` is authoritative throughout.
- `_split_speculative_next` begins with `all_gather([_draft_position])` and takes index `ATTN_RANK` (`dflash_split.py:69`).
---
## 7. Environment variables
| Variable | Default | File:line | Effect |
|----------|---------|-----------|--------|
| `EXO_SPECULATIVE` | `"0"` | `batch_generate.py:100`, `apply.py:288` | Enable speculative decode. Also gates `_drop_unused_weights` — speculative keeps all weights on both ranks. |
| `EXO_SPECULATIVE_MODE` | `"mtp"` | `batch_generate.py:101` | `"dflash"` for this path, `"mtp"` for MTP. |
| `EXO_SPECULATIVE_TEMP` | `"0.7"` | `batch_generate.py:103` | Sampling temperature for the drafter; 0 means greedy/deterministic. |
| `EXO_SPECULATIVE_ALPHA` | `"1.0"` | `batch_generate.py:104` | Acceptance ratio exponent α (temp>0 only). |
| `EXO_SPECULATIVE_GAMMA` | `"2"` | `batch_generate.py:141` | MTP draft chain length. Not used on DFlash path. |
| `EXO_DFLASH_MODEL` | `"z-lab/Qwen3.5-27B-DFlash"` | `batch_generate.py:112` | HF repo for drafter weights. Pre-pull with `huggingface-cli download ...` to avoid first-run download. |
| `EXO_DFLASH_VERIFY` | `"5"` | `batch_generate.py:113` | V — number of drafts per cycle. `verify_input.shape = (1, V+1)`. Even `V+1` → single-all_gather fast path in `pipelined_layer_loop`. |
| `EXO_DFLASH_BLOCK_SIZE` | `"6"` | `batch_generate.py:114` | Drafter block size BS. Draft produces `BS-1` logits. |
| `EXO_MTP_WEIGHTS` | `""` | `batch_generate.py:169` | Explicit MTP weights path. Not used on DFlash path. |
| `EXO_MTP_MODEL` | `""` | `batch_generate.py:173` | HF repo for MTP extraction. Not used on DFlash path. |
| `EXO_LPB_PATCHES` | `"1"` | `apply.py:243` | Toggle target-side LpB kernel swaps. `"0"` disables. |
| `EXO_DISABLE_LOGPROBS` | `"0"` | `batch_generate.py:618` | Skip logprob computation (some speculative paths require this). |
| `DFLASH_LPB_ONLY` | `""` | `bf16_lpb_patch.py:106` | Comma-separated projection names (`mlp_gate,mlp_up,...,lm_head`) to restrict drafter LpB patching. Debugging bisect knob. |
---
## 8. Debugging guide
### 8a. Existing prints
Init-time (once per process):
| Print | Source | Notes |
|-------|--------|-------|
| `DFlash config: ...` | `dflash_module.py:133` | Drafter arch summary |
| `Target layers: [...], mask_token=...` | `dflash_module.py:136` | **Exact `target_layer_ids` the drafter expects** |
| `DFlash loaded: N tensors, M params` | `dflash_module.py:164` | Drafter weight load confirmation |
| `Patched X target projections with dynamic LpB` | `lpb_patch.py:130` | Target-side LpB swap count |
| `Patched X DFlash drafter projections with dynamic LpB` | `bf16_lpb_patch.py:143` | Drafter-side LpB swap count |
| `DFLASH_LPB_ONLY=...` | `bf16_lpb_patch.py:109` | Only if the bisect env var is set |
| `Qwen3.5 attn/moe split patch applied on rank N/2` | `apply.py:291` | Structural patches done |
| `Warming up DFlash speculative decoding kernels...` / `DFlash warmup complete` | `batch_generate.py:298, 357` | Kernel warmup bookends |
Per forward / per cycle (hot path — noisy, disable once stable):
| Print | Source | When |
|-------|--------|------|
| `[rank N] pipeline begin S=... N=... even=...` | `model_forward.py:212` | Once per pipelined forward (prefill + verify + warmup verify) |
| `[rank N] stage 0 (T=0 startup) h_H0.mean=... [rank N:role] eval_local=X.XXms eval_gather=Y.YYms` | `model_forward.py:225` | Startup stage. `role` is `attn_0(H0)` on rank 0 or `idle` on rank 1. |
| `[rank N] stage K (T=... B) ... [rank N:role] eval_local=... eval_gather=...` | `model_forward.py:268` | Per B stage. `role` is `attn_T(H1)` on rank 0 or `moe_T(h_T_H0)` on rank 1. |
| `[rank N] stage K (T=... A) ... [rank N:role] eval_local=... eval_gather=...` | `model_forward.py:306` | Per A stage. `role` is `attn_T(H0)` on rank 0 or `moe_{T-1}(h_{T-1}_H1)` on rank 1. |
| `[rank N] drain out_H1.mean=... [rank N:role] eval_local=... eval_gather=...` | `model_forward.py:331` | Drain stage. `role` is `idle` on rank 0 or `moe_{N-1}(h_{N-1}_H1)` on rank 1. |
**Per-stage timing.** Both numbers bracket **only `mx.eval`** — graph-building code (`attention(...)`, `moe(...)`, `all_gather(...)`) runs outside the timer, so the numbers reflect real Metal / network work, not Python overhead.
- `eval_local` = walltime of `mx.eval(my_out)` (or `mx.eval(contribution)` at startup/drain). On the active rank: attention or MoE Metal kernels. On the idle rank (startup for MOE_RANK, drain for ATTN_RANK): a trivial `x - x` — should be near zero.
- `eval_gather` = walltime of `mx.eval(gathered)`. This is the collective itself — network + the implicit barrier that forces both ranks to converge.
- **Compare `eval_local` across ranks at the same stage number** to see whether attn or MoE is the limiter for that layer. The slower side's `eval_local` is the critical-path work; the faster side's `eval_gather` absorbs the difference (it sits in the collective waiting).
| `[rank N] L=lc after gather-1 h.mean=...` | `decoder.py:56` | Per layer, mid-`_split_call` (S==1 decode) |
| `[rank N] L=lc after gather-2 out.mean=...` | `decoder.py:69` | Per layer, end of `_split_call` |
| `[rank N] DFlash speculative cycle (y_val=..., target_hidden.shape=...)` | `dflash_split.py:51` | Once per speculative cycle |
| `[rank N] DFlash: NO target_hidden -> fallback ...` | `dflash_split.py:46` | When `_last_target_hidden[uid]` is missing |
| `[DFlash] n_accepted=X/V` | `dflash_split.py:165` | MOE_RANK only, end of cycle |
### 8b. `mx.eval` / `mx.async_eval` inventory
Evals serve three distinct purposes in this pipeline, and removing the wrong one
can silently break correctness or JACCL flow control. Classification:
**(i) Pipeline graph-break evals.** Force the MLX graph to materialize between
stages of `pipelined_layer_loop` and between gathers of the S==1 `_split_call`.
Without these, MLX would fuse arbitrarily many stages into one graph, blowing
up memory and (more importantly) desynchronizing `all_gather` order with
JACCL's internal queue (`MAX_SEND_WR=32`). Keep these unless you're
intentionally testing graph fusion.
| File:line | Target | Location in schedule |
|-----------|--------|----------------------|
| `model_forward.py:221` | `contribution` | Before startup gather |
| `model_forward.py:223` | `gathered` | After startup gather |
| `model_forward.py:249, 251` | `my_out`, `gathered` | B stage, even-S (single gather) |
| `model_forward.py:261, 262` | `attn_side`, `moe_side` | B stage, odd-S (pre two-gather) |
| `model_forward.py:266, 267` | `attn_contrib`, `moe_contrib` | B stage, odd-S (post two-gather) |
| `model_forward.py:287, 289` | `my_out`, `gathered` | A stage, even-S |
| `model_forward.py:299, 300` | `attn_side`, `moe_side` | A stage, odd-S (pre) |
| `model_forward.py:304, 305` | `attn_contrib`, `moe_contrib` | A stage, odd-S (post) |
| `model_forward.py:327, 329` | `contribution`, `gathered` | Drain stage (pre / post gather) |
| `decoder.py:53` | `h` | Idle rank's dummy layernorm, every 2nd layer — bounds JACCL queue under S==1 |
| `decoder.py:55` | `h` | After per-layer gather-1 |
| `decoder.py:66` | `out` | Idle rank's dummy layernorm, every 2nd layer |
| `decoder.py:68` | `result` | After per-layer gather-2 |
**(ii) Sampling evals (pre-`.item()` / `.tolist()`).** Required because Python-side
control flow depends on concrete values (draft tokens, acceptance bits, `n_accepted`).
| File:line | Target | Used for |
|-----------|--------|----------|
| `dflash_split.py:81` | `all_drafts_arr` | `.tolist()` to build `drafts` (temp=0) |
| `dflash_split.py:97` | `drafts_arr` | `.tolist()` after draft-sync `all_gather` |
| `dflash_batch_generator.py:125` | `target_hidden` | Before caching as `_last_target_hidden[uid]` (stock capture path) |
| `dflash_batch_generator.py:145, 151` | `prompt_toks`, `(target_hidden, logits)` | "direct" prefill mode only (unused under our patched path) |
| `dflash_batch_generator.py:199` | `all_drafts_arr` | Same role as `dflash_split.py:81`, stock path |
**(iii) Async evals (kick off compute, keep the Python loop moving).** These don't
block; MLX schedules the compute and the next `.item()` will wait. Used to
overlap acceptance math with the next forward's dispatch.
| File:line | Targets | Purpose |
|-----------|---------|---------|
| `dflash_split.py:120` | `matches`, `all_next`, `target_hidden` | temp=0 acceptance loop reads `matches[i].item()`; kicked off early |
| `dflash_split.py:146148` | `accept_ratios`, `uniforms`, `corrections`, `bonus_token`, `target_hidden` | temp>0 acceptance |
| `dflash_split.py:239, 250` | `batch.y` | Emit next-token without blocking the `Response` return |
| `dflash_batch_generator.py:225, 243, 327, 338` | same four roles, stock path | Stock DFlash mirror — only fires if `_speculative_next` isn't patched |
| `mtp_batch_generator.py:115, 162, 182, 278, 291` | MTP equivalents | Not on DFlash path, here for cross-reference |
Note: `mtp_module.py:340, 359` also call `mx.eval` during MTP weight load; not on our hot path.
### 8c. Suggested instrumentation points
- Before/after each `all_gather` in `_split_speculative_next` (`dflash_split.py:66, 95, 159`) — shape + mean.
- `_populate_dflash_captured` (`model_forward.py:387`) — dump the set of keys written on each call.
- `SpeculativeArraysCache.rollback` (`speculative_cache.py:81`) — print `n_accepted`, whether `all_states` / `conv_input` were non-None, and the resulting `base.cache[*]` shapes.
- Entry to `_pipelined_dflash_forward` (`model_forward.py:623`) — print `inputs.shape`, length of `cache_list`, and whether any `SpeculativeArraysCache` already exists.
- Warmup completion — pair with `model_forward.py:212` to confirm one pipelined forward happens during warmup at `M=V+1`.
### 8d. Gotchas (bug history, one-liner each)
- Mask from `create_attention_mask(..., return_array=True)` is 2D `(S, offset+S)`, not 4D — `slice_fa_mask` handles both (`model_forward.py:63`).
- `BatchKVCache.offset` is sometimes an `mx.array`, sometimes `int`. Use `.max().item()` when unsure (`model_forward.py:188`).
- `_idx ≠ offset` on `BatchKVCache` — mask slicing uses `_idx` (actual K buffer length), positional encodings use `offset` (net of left_padding).
- `spec_all_states` has `2 × len(gdn_spec_data)` entries after our pipelined loop, not `len(gdn_spec_data)` — H0 and H1 each push. Merge post-loop (`model_forward.py:654`).
- Conv rollback only works if `conv_input` is reconstructed post-loop — the pipelined path destroys the pre-layer state that stock `dflash_speculative_forward` implicitly relies on.
- `_CapturingLayer.__call__` never fires on the pipelined path (we bypass `layer.__call__`). Compensated by `_populate_dflash_captured`.
- `ArraysCache.extract` stock crashes on mixed None / non-None `cache[i]` entries. MOE_RANK rollback produces exactly that — `[conv_input, None]`. Fixed by per-element `_arrays_extract` (`apply.py:130`).
- Drafter first cycle: `k_proj`, `v_proj`, `fc` see `M > MAX_M` and fall back to stock GEMM. Not a bug, but worth knowing when profiling cold-start latency.
- `_draft_position` on MOE_RANK stays at 0 because it never runs attention. Sync via `all_gather` at the top of every cycle (`dflash_split.py:66`).
- Drafter shares `embed_tokens` and `lm_head` with the target (`dflash_module.py:151, 153`) — weight changes to the target propagate automatically.
---
## 9. File index
Grouped by layer. Each entry: path, purpose, key symbols.
### 9a. Layer A (structural, `patches/qwen3_5_moe_split/`)
| File | Purpose | Key symbols |
|------|---------|-------------|
| `apply.py` | Install all structural patches | `apply_qwen35_attn_moe_split_patches:220`, `_patch_caches_for_moe_rank:33`, `_arrays_extract:130`, `_drop_unused_weights:297` |
| `decoder.py` | S==1 serial split `DecoderLayer.__call__` | `make_split_decoder_call:21`, `_split_call:36`, `ATTN_RANK:16`, `MOE_RANK:17`, `DUMMY_LN_ITERS:18` |
| `model_forward.py` | S>1 pipelined forward + DFlash capture plumbing | `attention:38`, `moe:50`, `slice_fa_mask:63`, `pipelined_layer_loop:146`, `_dflash_capturing_target_ids:353`, `_dflash_captured_dict:366`, `_populate_dflash_captured:387`, `make_pipelined_model_call:416`, `make_pipelined_speculative_forward:491` (MTP), `make_pipelined_dflash_speculative_forward:615` |
| `dflash_split.py` | Rank-aware `_speculative_next` for DFlash | `make_split_speculative_next:25`, `_split_speculative_next:29` |
| `PIPELINE.md` | This document | — |
### 9b. Layer B (kernel)
| File | Purpose | Key symbols |
|------|---------|-------------|
| `patches/qwen3_5/lpb_patch.py` | Target-side LpB projection patcher | `apply_lpb_patches:102`, `_make_bf16_forward:30`, `_make_int8_forward:51`, `_patch_proj:73`, `MAX_M:27` |
| `speculative/bf16_lpb_patch.py` | Drafter-side LpB patcher | `apply_bf16_lpb_patches:99`, `_BF16LpBLinear:27`, `_QuantizedLpBLinear:55`, `MAX_M:24` |
| `matmul/patches/kernel_picker.py` | (N, K, M) → kernel selection | `pick_bf16_kernel:33`, `pick_int8_kernel:69`, `_round_up_to_bench_col:26` |
| `matmul/kernels/bf16/*.py` | bf16 Metal kernels (lpb, lpb_twice, sk_steel, bm8) | `custom_bf16_qmv_loop_over_b`, `custom_bf16_qmv_loop_over_b_twice`, `custom_bf16_gemm_splitk_steel` |
| `matmul/kernels/quantized/*.py` | int8 Metal kernels (lpb, bm8, bm16, qsk) | `custom_qmv_loop_over_b`, `custom_qmm_splitk` |
### 9c. Stock DFlash (`speculative/`, unmodified)
| File | Purpose | Key symbols |
|------|---------|-------------|
| `dflash_batch_generator.py` | BatchGenerator subclass with DFlash logic | `DFlashBatchGenerator:20`, `_setup_hidden_capture:53`, `_next:92`, `_first_step_capture:119`, `_speculative_next:170`, `_yield_buffered:341` |
| `dflash_module.py` | DFlash drafter model | `DFlashAttention:19`, `DFlashDecoderLayer:73`, `DFlashDrafter:97`, `DFlashDrafter.draft:226`, `reset_draft_cache:186`, `crop_draft_cache:189` |
| `dflash_speculative.py` | Stock DFlash forward (replaced by our pipelined variant) | `dflash_speculative_forward:14` |
| `speculative_cache.py` | GDN rollback wrapper | `SpeculativeArraysCache:15`, `SpeculativeArraysCache.rollback:81` |
| `mtp_module.py` | MTP speculative machinery + shared speculative GDU factory | `speculative_forward:27`, `_make_speculative_gdu:126`, `MTPPredictor:154` |
| `speculative_gdn_kernel.py` | Speculative GDN Metal kernel | `speculative_gated_delta_kernel` |
### 9d. Driver / entry points
| File | Purpose | Key symbols |
|------|---------|-------------|
| `generator/batch_generate.py` | Orchestrator; drafter instantiation; warmup | DFlash init block at `:106130`, `warmup_dflash:272`, MTP init block at `:135170` |
| `auto_parallel.py` | Per-strategy model setup | `attn_moe_split_auto_parallel:436`, call site `:474` |
| `patches/__init__.py` | Single-device fused patch dispatch (not this path) | `apply_mlx_patches:14`, `maybe_apply_patches:26` |
### 9e. Stock mlx_lm touch points (read-only reference)
| File | Purpose |
|------|---------|
| `mlx_lm/models/qwen3_5.py` | `DecoderLayer`, `Qwen3_5TextModel`, `gated_delta_update`, attention modules |
| `mlx_lm/models/cache.py` | `ArraysCache:592`, `BatchKVCache`, `KVCache`, `ArraysCache.extract:630` |
| `mlx_lm/generate.py` | `BatchGenerator`, `Batch.extract_cache:882` |
---
_Document maintained on `david/attn-moe-split`. Last updated when `_arrays_extract` was made None-tolerant (commit `e8276c11`) and `_populate_dflash_captured` was added (commit `b1a5dccf`)._
Whitespace-only changes.
@@ -0,0 +1,348 @@
"""Install the attention/MoE split DecoderLayer.__call__ on a Qwen3.5 MoE model.
Unlike patches/qwen3_5_moe/apply.py (which installs fused local kernels),
this patch replaces DecoderLayer.__call__ with a two-rank split: rank 0 runs
attention + first residual, rank 1 runs post_attention_layernorm + MoE +
second residual, with one cross-rank send/recv pair per layer.
Must be called instead of apply_qwen35_batched_fused_patches — they both
replace DecoderLayer.__call__. In exo's current distributed load path
(utils_mlx.shard_and_load) maybe_apply_patches is only called in the
single-device branch, so there is no conflict for distributed AttnMoeSplit
runs — we call this directly from attn_moe_split_auto_parallel.
"""
import os
import mlx.core as mx
import mlx.nn as nn
from loguru import logger
from mlx_lm.models import cache as cache_module
from mlx_lm.models.qwen3_5 import DecoderLayer, Qwen3_5TextModel
from .decoder import ATTN_RANK, MOE_RANK, make_split_decoder_call
from .model_forward import (
make_pipelined_dflash_speculative_forward,
make_pipelined_model_call,
make_pipelined_speculative_forward,
)
_EMPTY_KV_PLACEHOLDER = mx.zeros((1, 1, 0, 1))
def _patch_caches_for_moe_rank() -> None:
"""Return zero-length placeholder arrays when caches are empty.
Rank 1 (MoE) never runs attention, so its per-layer KVCache /
BatchKVCache / BatchRotatingKVCache / ArraysCache never have their keys
populated. mlx_lm.generate and BatchGenerator both call
``mx.eval([c.state for c in prompt_cache])`` once per step, which
invokes the cache's state property and crashes on
``self.keys.shape[2]`` (AttributeError: 'NoneType' object has no
attribute 'shape'). Patch each used cache class's state property on
rank 1 only, so that an unpopulated cache reports empty placeholder
arrays whose eval is a no-op.
"""
# KVCache.state → (k, v)
def _kv_state(self): # type: ignore[no-untyped-def]
if self.keys is None:
return (_EMPTY_KV_PLACEHOLDER, _EMPTY_KV_PLACEHOLDER)
if self.offset == self.keys.shape[2]:
return self.keys, self.values
return (
self.keys[..., : self.offset, :],
self.values[..., : self.offset, :],
)
def _kv_state_setter(self, v): # type: ignore[no-untyped-def]
self.keys, self.values = v
self.offset = self.keys.shape[2]
cache_module.KVCache.state = property(_kv_state, _kv_state_setter) # type: ignore[method-assign]
# BatchKVCache.state → (k, v, offset, left_padding)
def _batch_kv_state(self): # type: ignore[no-untyped-def]
if self.keys is None:
return (
_EMPTY_KV_PLACEHOLDER,
_EMPTY_KV_PLACEHOLDER,
self.offset,
self.left_padding,
)
k, v = self.keys, self.values
if self._idx < k.shape[2]:
k = k[..., : self._idx, :]
v = v[..., : self._idx, :]
return k, v, self.offset, self.left_padding
def _batch_kv_state_setter(self, v): # type: ignore[no-untyped-def]
self.keys, self.values, self.offset, self.left_padding = v
self._idx = self.keys.shape[2]
cache_module.BatchKVCache.state = property( # type: ignore[method-assign]
_batch_kv_state, _batch_kv_state_setter
)
# BatchRotatingKVCache.state → (k, v, offset, left_padding)
def _batch_rot_state(self): # type: ignore[no-untyped-def]
if self.keys is None:
return (
_EMPTY_KV_PLACEHOLDER,
_EMPTY_KV_PLACEHOLDER,
self.offset,
self.left_padding,
)
k, v = self.keys, self.values
if self._offset < k.shape[2]:
k, v = k[..., : self._offset, :], v[..., : self._offset, :]
return k, v, self.offset, self.left_padding
def _batch_rot_state_setter(self, v): # type: ignore[no-untyped-def]
self.keys, self.values, self.offset, self.left_padding = v
cache_module.BatchRotatingKVCache.state = property( # type: ignore[method-assign]
_batch_rot_state, _batch_rot_state_setter
)
# ArraysCache.state → list that may contain None entries
original_arrays_state_getter = cache_module.ArraysCache.state.fget # type: ignore[attr-defined]
original_arrays_state_setter = cache_module.ArraysCache.state.fset # type: ignore[attr-defined]
def _arrays_state(self): # type: ignore[no-untyped-def]
raw = original_arrays_state_getter(self)
return [_EMPTY_KV_PLACEHOLDER if c is None else c for c in raw]
cache_module.ArraysCache.state = property( # type: ignore[method-assign]
_arrays_state, original_arrays_state_setter
)
# Batching methods (extract / filter / extend) short-circuit when the
# cache is still in its uninitialised state on rank 1. Every method
# defers to the stock implementation once keys/values are populated,
# which would only happen if future code actually ran attention on
# rank 1 — for now that never occurs, but the guards keep the patches
# safe under that change.
def _arrays_is_empty(self) -> bool: # type: ignore[no-untyped-def]
return all(c is None for c in self.cache)
def _arrays_extract(self, idx): # type: ignore[no-untyped-def]
# Stock extract does [c[idx:idx+1] for c in self.cache] which crashes
# on None. On MOE_RANK the GDN base.cache is mixed after speculative
# rollback: conv_input gets reconstructed (base.cache[0] non-None)
# but all_states stays None (the monkey-patched gated_delta_update
# never runs on MOE_RANK, so spec_all_states is empty). Handle any
# None/non-None mixture per-element.
new_cache = cache_module.ArraysCache(len(self.cache))
new_cache.cache = [
c[idx : idx + 1] if c is not None else None for c in self.cache
]
return new_cache
cache_module.ArraysCache.extract = _arrays_extract # type: ignore[method-assign]
_arrays_filter_orig = cache_module.ArraysCache.filter
def _arrays_filter(self, batch_indices): # type: ignore[no-untyped-def]
if _arrays_is_empty(self):
return
_arrays_filter_orig(self, batch_indices)
cache_module.ArraysCache.filter = _arrays_filter # type: ignore[method-assign]
_arrays_extend_orig = cache_module.ArraysCache.extend
def _arrays_extend(self, other): # type: ignore[no-untyped-def]
if _arrays_is_empty(self) and _arrays_is_empty(other):
return
_arrays_extend_orig(self, other)
cache_module.ArraysCache.extend = _arrays_extend # type: ignore[method-assign]
# BatchKVCache: keys/values stay None until attention runs.
_bkv_extract_orig = cache_module.BatchKVCache.extract
def _bkv_extract(self, idx): # type: ignore[no-untyped-def]
if self.keys is None:
return cache_module.KVCache()
return _bkv_extract_orig(self, idx)
cache_module.BatchKVCache.extract = _bkv_extract # type: ignore[method-assign]
_bkv_filter_orig = cache_module.BatchKVCache.filter
def _bkv_filter(self, batch_indices): # type: ignore[no-untyped-def]
if self.keys is None:
return
_bkv_filter_orig(self, batch_indices)
cache_module.BatchKVCache.filter = _bkv_filter # type: ignore[method-assign]
_bkv_extend_orig = cache_module.BatchKVCache.extend
def _bkv_extend(self, other): # type: ignore[no-untyped-def]
if self.keys is None and other.keys is None:
return
_bkv_extend_orig(self, other)
cache_module.BatchKVCache.extend = _bkv_extend # type: ignore[method-assign]
# BatchRotatingKVCache: same story.
_brkv_extract_orig = cache_module.BatchRotatingKVCache.extract
def _brkv_extract(self, idx): # type: ignore[no-untyped-def]
if self.keys is None:
return cache_module.RotatingKVCache(self.max_size)
return _brkv_extract_orig(self, idx)
cache_module.BatchRotatingKVCache.extract = _brkv_extract # type: ignore[method-assign]
_brkv_filter_orig = cache_module.BatchRotatingKVCache.filter
def _brkv_filter(self, batch_indices): # type: ignore[no-untyped-def]
if self.keys is None:
return
_brkv_filter_orig(self, batch_indices)
cache_module.BatchRotatingKVCache.filter = _brkv_filter # type: ignore[method-assign]
_brkv_extend_orig = cache_module.BatchRotatingKVCache.extend
def _brkv_extend(self, other): # type: ignore[no-untyped-def]
if self.keys is None and other.keys is None:
return
_brkv_extend_orig(self, other)
cache_module.BatchRotatingKVCache.extend = _brkv_extend # type: ignore[method-assign]
def apply_qwen35_attn_moe_split_patches(
model: nn.Module, group: mx.distributed.Group
) -> nn.Module:
"""Install the split DecoderLayer.__call__ on a Qwen3.5 MoE model."""
if group.size() != 2:
raise ValueError(
f"Qwen3.5 attn/moe split requires world_size==2, got {group.size()}"
)
inner = model
for attr in ("model", "language_model"):
if hasattr(inner, attr):
inner = getattr(inner, attr)
if hasattr(inner, "model"):
inner = inner.model
n_layers = len(inner.layers) if hasattr(inner, "layers") else 48
# Apply target-side LPB projection patches BEFORE the split patches.
# Dynamic kernel picking at call time (M <= 16 uses the LpB fast path).
# Benefits decode (S=1) and DFlash verify (S=V+1=6); prefill falls back.
# Only meaningful for dense Qwen3.5 variants (e.g. 27B); on MoE variants
# it is mostly a no-op (MLP structure differs, only attn + lm_head patched).
try:
if os.environ.get("EXO_LPB_PATCHES", "1") != "0":
from exo.worker.engines.mlx.patches.qwen3_5.lpb_patch import apply_lpb_patches
apply_lpb_patches(model)
except Exception as e:
logger.warning(f"LPB target patches skipped: {e}")
# S == 1 serial split lives in DecoderLayer.__call__.
DecoderLayer.__call__ = make_split_decoder_call(group, n_layers=n_layers) # type: ignore[method-assign]
# S > 1 pipelined split lives in Qwen3_5TextModel.__call__.
Qwen3_5TextModel.__call__ = make_pipelined_model_call(group) # type: ignore[method-assign]
# Speculative verify path gets its own pipelined forward.
try:
from exo.worker.engines.mlx.speculative import mtp_module
mtp_module.speculative_forward = make_pipelined_speculative_forward(group)
except ImportError:
pass # speculative not used
# DFlash has its own speculative_forward + batch generator step.
try:
from exo.worker.engines.mlx.speculative import dflash_speculative as _dfs
from exo.worker.engines.mlx.speculative.dflash_batch_generator import (
DFlashBatchGenerator,
)
_dfs.dflash_speculative_forward = make_pipelined_dflash_speculative_forward(group)
# The batch generator imports dflash_speculative_forward at module load,
# so also patch the attribute inside dflash_batch_generator.
import exo.worker.engines.mlx.speculative.dflash_batch_generator as _dfbg
_dfbg.dflash_speculative_forward = _dfs.dflash_speculative_forward
from .dflash_split import make_split_speculative_next
DFlashBatchGenerator._speculative_next = make_split_speculative_next(group) # type: ignore[method-assign]
except ImportError:
pass # DFlash not used
if group.rank() == MOE_RANK:
_patch_caches_for_moe_rank()
# Drop unused weights. Under speculative we keep a narrower subset on
# MOE_RANK because the speculative forward's post-loop reconstruction
# runs input_layernorm + linear_attn.in_proj_* there. ATTN_RANK always
# drops mlp + post_attention_layernorm since attention never runs them.
_drop_unused_weights(
model,
group,
speculative=os.environ.get("EXO_SPECULATIVE") == "1",
)
logger.info(
f"Qwen3.5 attn/moe split patch applied on rank {group.rank()}/{group.size()}"
)
return model
def _drop_unused_weights(
model: nn.Module,
group: mx.distributed.Group,
speculative: bool = False,
) -> None:
"""Free weights each rank doesn't need.
Non-speculative:
ATTN_RANK drops mlp + post_attention_layernorm.
MOE_RANK drops self_attn + linear_attn + input_layernorm.
Speculative (EXO_SPECULATIVE=1):
ATTN_RANK drops mlp + post_attention_layernorm (same as above).
MOE_RANK drops self_attn only. Keeps linear_attn + input_layernorm
because the speculative forward's post-loop conv_input
reconstruction runs input_layernorm + linear_attn.in_proj_*
on MOE_RANK too.
embed_tokens, norm, and lm_head stay on both ranks in all cases.
"""
import gc
inner = model
for attr in ("model", "language_model"):
if hasattr(inner, attr):
inner = getattr(inner, attr)
if hasattr(inner, "model"):
inner = inner.model
layers = inner.layers if hasattr(inner, "layers") else []
for layer in layers:
if group.rank() == ATTN_RANK:
layer.mlp = None # type: ignore[assignment]
layer.post_attention_layernorm = None # type: ignore[assignment]
else:
layer.self_attn = None # type: ignore[assignment]
if not speculative:
layer.linear_attn = None # type: ignore[assignment]
layer.input_layernorm = None # type: ignore[assignment]
gc.collect()
mx.clear_cache()
mode = "speculative" if speculative else "full"
logger.info(
f"Rank {group.rank()}: dropped unused weights from {len(layers)} layers ({mode})"
)
@@ -0,0 +1,72 @@
"""Serial attn/MoE split DecoderLayer.__call__ — S == 1 decode only.
For S > 1 (prefill / speculative verify), the model-level pipelined
forward in `model_forward.py` calls the layer primitives directly and
never invokes this function.
S == 1 path: ATTN rank runs attention + residual, MOE rank runs MoE +
residual. Each step ends in one all_gather; the idle rank does dummy
layernorm work, evaled every ~2 layers to prevent JACCL queue overflow.
"""
from collections.abc import Callable
import mlx.core as mx
ATTN_RANK = 0
MOE_RANK = 1
DUMMY_LN_ITERS = 50
def make_split_decoder_call(
group: mx.distributed.Group,
n_layers: int = 48,
) -> Callable[..., mx.array]:
"""Build a DecoderLayer.__call__ replacement for S == 1 decode."""
if group.size() != 2:
raise ValueError(f"world_size==2 required, got {group.size()}")
rank = group.rank()
layer_counter = 0
def gather_from(rank_to_pick: int, tensor: mx.array) -> mx.array:
"""all_gather and slice the tensor contributed by ``rank_to_pick``."""
gathered = mx.distributed.all_gather(tensor, group=group)
return gathered[rank_to_pick : rank_to_pick + 1]
def _split_call(self, x, mask=None, cache=None): # type: ignore[no-untyped-def]
nonlocal layer_counter
layer_counter += 1
lc = layer_counter
# Step 1: ATTN does attention + residual; MOE does dummy layernorm.
if rank == ATTN_RANK:
if self.is_linear:
r = self.linear_attn(self.input_layernorm(x), mask, cache)
else:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
else:
h = x
for _ in range(DUMMY_LN_ITERS):
h = self.post_attention_layernorm(h) + h
if lc % 2 == 0:
mx.eval(h)
h = gather_from(ATTN_RANK, h)
mx.eval(h)
print(f"[rank {rank}] L={lc} after gather-1 h.mean={h.mean().item():+.6f}", flush=True)
# Step 2: MOE does MoE + residual; ATTN does dummy layernorm.
if rank == MOE_RANK:
out = h + self.mlp(self.post_attention_layernorm(h))
else:
out = h
for _ in range(DUMMY_LN_ITERS):
out = self.input_layernorm(out) + out
if lc % 2 == 0:
mx.eval(out)
result = gather_from(MOE_RANK, out)
mx.eval(result)
print(f"[rank {rank}] L={lc} after gather-2 out.mean={result.mean().item():+.6f}", flush=True)
return result
return _split_call
@@ -0,0 +1,253 @@
"""Rank-aware DFlashBatchGenerator patches for the attn/MoE split.
Both ranks draft independently, then sync on MOE_RANK's drafts before the
pipelined verify forward. Acceptance is deterministic at temp=0 (both ranks
get the same verify_logits via the pipelined forward, so both compute the
same n_accepted). At temp>0, MOE_RANK's n_accepted wins via all_gather so
stochastic decisions stay consistent.
Stock `_first_step_capture` is kept — it reads `self._captured['prefill_hiddens']`
which is populated by `_CapturingLayer.__call__` during prefill. Our
pipelined prefill bypasses `layer.__call__` entirely, so `_CapturingLayer`
would never fire on its own. `make_pipelined_model_call` compensates by
detecting the `_CapturingLayer` wrappers and writing the captured layer
outputs from `pipelined_layer_loop` directly into the closure-captured
`captured` dict (see `_populate_dflash_captured` in `model_forward.py`).
"""
import time
import mlx.core as mx
from .decoder import ATTN_RANK, MOE_RANK
def make_split_speculative_next(group): # type: ignore[no-untyped-def]
"""Build a DFlashBatchGenerator._speculative_next replacement closed over group."""
rank = group.rank()
def _split_speculative_next(self): # type: ignore[no-untyped-def]
from exo.worker.engines.mlx.speculative.dflash_speculative import (
dflash_speculative_forward,
)
tic = time.perf_counter()
batch = self.active_batch
uid = batch.uids[0]
y = batch.y
y_val = y[0].item()
y_logprobs = batch.logprobs[0]
batch.tokens[0] = mx.concatenate((batch.tokens[0], y[0:1]))
last_target_hidden = self._last_target_hidden.get(uid)
if last_target_hidden is None:
print(
f"[rank {rank}] DFlash: NO target_hidden -> fallback to super()._next() "
f"(y_val={y_val})",
flush=True,
)
return super(type(self), self)._next()
print(
f"[rank {rank}] DFlash speculative cycle "
f"(y_val={y_val}, target_hidden.shape={last_target_hidden.shape})",
flush=True,
)
bs = self.drafter.block_size
verify_len = self.verify_len
temp = self._request_temp.get(uid, self.temp)
alpha = self.alpha
# ATTN_RANK's cache offset is correct (updated by attention); MOE_RANK's
# stays at 0 because it never runs attention. Sync _draft_position
# across ranks so the drafter uses the right positional encoding.
local_start = self._draft_position[uid]
gathered = mx.distributed.all_gather(
mx.array([local_start], dtype=mx.int32), group=group
)
start = int(gathered[ATTN_RANK].item())
self._draft_position[uid] = start
# 1. Draft — both ranks draft independently
block_ids = mx.full((1, bs), self.drafter.mask_token_id, dtype=mx.int32)
block_ids[:, 0] = y_val
draft_logits = self.drafter.draft(last_target_hidden, block_ids, start)
self.drafter.crop_draft_cache(start)
# 2. Sample — both ranks sample locally
if temp == 0:
all_drafts_arr = mx.argmax(draft_logits, axis=-1).squeeze(0)
mx.eval(all_drafts_arr)
all_drafts = all_drafts_arr.tolist()
draft_probs = None
else:
all_drafts = []
draft_probs = []
for i in range(bs - 1):
p = mx.softmax(draft_logits[0, i] / temp, axis=-1)
tok = mx.random.categorical(mx.log(p)).item()
all_drafts.append(tok)
draft_probs.append(p)
# 3. Sync drafts: take MOE_RANK's drafts as source of truth for both ranks.
drafts_local = mx.array([all_drafts[:verify_len]], dtype=mx.int32) # (1, V)
gathered = mx.distributed.all_gather(drafts_local, group=group) # (2, V)
drafts_arr = gathered[MOE_RANK : MOE_RANK + 1] # (1, V)
mx.eval(drafts_arr)
drafts = drafts_arr[0].tolist()
# 4. Build verify_input on BOTH ranks (identical tokens now)
y_val_tensor = mx.array([[y_val]], dtype=mx.int32)
verify_input = mx.concatenate([y_val_tensor, drafts_arr], axis=1)
# 5. Pipelined verify forward — both ranks run the 2N+1 stage pipeline
# via the patched dflash_speculative_forward.
target_hidden, _, verify_logits = dflash_speculative_forward(
self.model,
verify_input,
batch.cache,
self.drafter.target_layer_ids,
speculative=True,
)
# 6. Acceptance. temp==0: deterministic on both ranks.
# temp>0: MOE_RANK decides, all_gather broadcasts n_accepted.
if temp == 0:
target_tokens = mx.argmax(verify_logits[:, :verify_len, :], axis=-1)
matches = mx.equal(target_tokens, drafts_arr).squeeze(0)
all_next = mx.argmax(verify_logits[0], axis=-1)
mx.eval(matches, all_next, target_hidden)
n_accepted = 0
for i in range(verify_len):
if matches[i].item():
n_accepted += 1
else:
break
else:
accept_ratios = []
for i in range(verify_len):
p = mx.softmax(verify_logits[0, i] / temp, axis=-1)
q = draft_probs[i]
p_di = p[drafts[i]]
q_di = mx.maximum(q[drafts[i]], mx.array(1e-10))
ratio = p_di / q_di
accept_ratios.append(mx.minimum(ratio ** alpha, mx.array(1.0)))
uniforms = mx.random.uniform(shape=(verify_len,))
corrections = []
for i in range(verify_len):
p = mx.softmax(verify_logits[0, i] / temp, axis=-1)
q = draft_probs[i]
residual = mx.maximum(p - q, 0.0)
corrections.append(mx.random.categorical(mx.log(residual + 1e-10)))
bonus_token = mx.random.categorical(
verify_logits[0, verify_len] * (1.0 / temp)
)
mx.async_eval(
accept_ratios, uniforms, corrections, bonus_token, target_hidden
)
# Only MOE_RANK decides; broadcast n_accepted.
if rank == MOE_RANK:
n_accepted_local = 0
for i in range(verify_len):
if uniforms[i].item() < accept_ratios[i].item():
n_accepted_local += 1
else:
break
else:
n_accepted_local = 0
gathered_n = mx.distributed.all_gather(
mx.array([n_accepted_local], dtype=mx.int32), group=group
)
n_accepted = int(gathered_n[MOE_RANK].item())
if rank == MOE_RANK:
print(
f"[DFlash] n_accepted={n_accepted}/{verify_len}",
flush=True,
)
# 7. Rollback — same n_accepted on both ranks keeps caches consistent
rollback = verify_len - n_accepted
if rollback > 0:
for c in batch.cache:
if hasattr(c, "offset"):
c.offset -= rollback
elif hasattr(c, "rollback"):
c.rollback(n_accepted)
for i, c in enumerate(batch.cache):
if hasattr(c, "base"):
batch.cache[i] = c.base
# 8. Bonus / correction token
no_lp = mx.array(0.0)
if n_accepted == verify_len:
if temp == 0:
bonus_val = all_next[verify_len].item()
else:
bonus_val = bonus_token.item()
else:
if temp == 0:
bonus_val = all_next[n_accepted].item()
else:
bonus_val = corrections[n_accepted].item()
# 9. Update state
self._last_target_hidden[uid] = target_hidden[:, : n_accepted + 1, :]
self._draft_position[uid] = start + n_accepted + 1
# 10. Build token list (no logprobs)
all_tokens = [(y_val, y_logprobs)]
for i in range(n_accepted):
all_tokens.append((drafts[i], no_lp))
batch.y = mx.array([bonus_val])
batch.logprobs = [no_lp]
if n_accepted > 0:
batch.tokens[0] = mx.concatenate(
(batch.tokens[0], mx.array([t for t, _ in all_tokens[1:]]))
)
batch.num_tokens[0] += len(all_tokens)
# 11. Stop conditions (same as stock)
toc = time.perf_counter()
self._stats.generation_time += toc - tic
self._stats.generation_tokens += len(all_tokens)
stop_idx = None
for idx, (tok, _) in enumerate(all_tokens):
if tok in self.stop_tokens:
stop_idx = idx
break
if batch.num_tokens[0] >= batch.max_tokens[0]:
stop_idx = idx
break
first_tok, first_lp = all_tokens[0]
if stop_idx is not None:
valid_tokens = all_tokens[:stop_idx]
if valid_tokens:
if len(valid_tokens) > 1:
self._token_buffer[uid] = valid_tokens[1:]
stop_tok, stop_lp = all_tokens[stop_idx]
if uid not in self._token_buffer:
self._token_buffer[uid] = []
self._token_buffer[uid].append((stop_tok, stop_lp))
mx.async_eval(batch.y)
return [self.Response(uid, first_tok, first_lp, None, lambda: None)]
else:
cache = batch.extract_cache(0)
self.active_batch = None
self._cleanup_uid(uid)
return [self.Response(uid, first_tok, first_lp, "stop", cache)]
if len(all_tokens) > 1:
self._token_buffer[uid] = all_tokens[1:]
mx.async_eval(batch.y)
return [self.Response(uid, first_tok, first_lp, None, lambda: None)]
return _split_speculative_next
@@ -0,0 +1,833 @@
"""Model-level pipelined forward for the attention/MoE split.
Replaces ``Qwen3_5TextModel.__call__`` and ``mtp_module.speculative_forward``
to implement a 2N+1 stage pipeline when S>1 (prefill / speculative verify).
Pipeline schedule for N layers, two halves H0 (first S/2) and H1 (last S/2):
Stage 0 (startup) : ATTN attn_0(H0). MOE idle.
Stage 1 : ATTN attn_0(H1). MOE moe_0(h_0_H0).
Stage 2T (T≥1) : ATTN attn_T(H0), input out_{T-1}_H0.
MOE moe_{T-1}(h_{T-1}_H1).
Stage 2T+1 (T≥1) : ATTN attn_T(H1), input out_{T-1}_H1.
MOE moe_T(h_T_H0).
Stage 2N (drain) : ATTN idle. MOE moe_{N-1}(h_{N-1}_H1).
Each stage: both ranks compute in parallel on different GPUs, then ONE
all_gather shares both results (each rank contributes its real output,
both receive both — same shape, one collective). Idle ranks contribute a
zero placeholder of the right shape.
For S==1 decode this module is bypassed — the stock layer loop + the
serial _split_call in decoder.py handles it.
"""
import time
from typing import Any
import mlx.core as mx
from mlx_lm.models.base import create_attention_mask, create_ssm_mask
from .decoder import ATTN_RANK, MOE_RANK
# ---------------------------------------------------------------------------
# Primitive layer ops
# ---------------------------------------------------------------------------
def attention(layer, x, mask, cache): # type: ignore[no-untyped-def]
"""Run a layer's attention (GDN or GQA) with the input residual.
Returns h = x + attn(input_layernorm(x)).
"""
if layer.is_linear:
r = layer.linear_attn(layer.input_layernorm(x), mask, cache)
else:
r = layer.self_attn(layer.input_layernorm(x), mask, cache)
return x + r
def moe(layer, h): # type: ignore[no-untyped-def]
"""Run a layer's MoE with the post-attention residual.
Returns out = h + mlp(post_attention_layernorm(h)).
"""
return h + layer.mlp(layer.post_attention_layernorm(h))
# ---------------------------------------------------------------------------
# Mask slicing
# ---------------------------------------------------------------------------
def slice_fa_mask(fa_mask, mid: int, offset: int): # type: ignore[no-untyped-def]
"""Slice the GQA attention mask for H0 / H1 queries.
create_attention_mask(return_array=True) returns a 2D causal mask of
shape (S, offset+S) — the last two dims of a standard 4D attention mask.
scaled_dot_product_attention broadcasts 2D masks automatically.
mask_H0 shape: (mid, offset+mid) — queries [0,mid), keys [0,offset+mid)
mask_H1 shape: (S-mid, offset+S) — queries [mid,S), keys [0,offset+S)
Also handles 4D masks (B, 1, S, offset+S) defensively.
"""
if fa_mask is None:
return None, None
if fa_mask.ndim == 2:
return fa_mask[:mid, : offset + mid], fa_mask[mid:, :]
if fa_mask.ndim == 4:
return fa_mask[:, :, :mid, : offset + mid], fa_mask[:, :, mid:, :]
raise ValueError(f"unexpected fa_mask ndim={fa_mask.ndim}, shape={fa_mask.shape}")
def slice_ssm_mask(ssm_mask, mid: int): # type: ignore[no-untyped-def]
"""Slice the 2D SSM/GDN mask along the sequence dim."""
if ssm_mask is None:
return None, None
return ssm_mask[:, :mid], ssm_mask[:, mid:]
# ---------------------------------------------------------------------------
# Pipeline building blocks
# ---------------------------------------------------------------------------
def _gather_own(my_contribution: mx.array, group) -> mx.array:
"""all_gather + return the tensor contributed by the CURRENT rank.
Other ranks' slices are returned too but unused here — the caller runs
this separately on each rank's output so both sides see both tensors.
"""
return mx.distributed.all_gather(my_contribution, group=group)
def _zeros_like(x: mx.array) -> mx.array:
"""Return x - x (zeros with a data dependency on x so MLX can't const-fold)."""
return x - x
def _gather_two(
rank: int,
attn_side: mx.array,
moe_side: mx.array,
attn_shape_template: mx.array,
moe_shape_template: mx.array,
group,
) -> tuple[mx.array, mx.array]:
"""Two all_gathers per stage — one per shape.
Each rank contributes a placeholder of the OTHER rank's shape. One
all_gather for ATTN's output, one for MOE's. Handles odd S where
H0 and H1 have different sizes.
Args:
attn_side: what this rank is contributing for ATTN's all_gather.
On ATTN_RANK this is the real attention output; on
MOE_RANK it is a placeholder shaped like ATTN's output.
moe_side: same but for MOE's all_gather.
Returns (attn_result, moe_result) — both ranks get both after the
two collectives.
"""
attn_gathered = mx.distributed.all_gather(attn_side, group=group)
moe_gathered = mx.distributed.all_gather(moe_side, group=group)
return (
attn_gathered[ATTN_RANK : ATTN_RANK + 1],
moe_gathered[MOE_RANK : MOE_RANK + 1],
)
# ---------------------------------------------------------------------------
# Pipelined layer loop
# ---------------------------------------------------------------------------
def pipelined_layer_loop(
inner, # Qwen3_5TextModel instance
hidden_states: mx.array,
cache, # list of per-layer caches
group, # mx.distributed.Group
fa_mask, # 4D tensor or None (after return_array=True)
ssm_mask, # 2D tensor or None
capture_layers=None, # set[int] | None — DFlash target_layer_ids
):
"""Execute all decoder layers with the 2N+1 stage H0/H1 pipeline.
Args:
capture_layers: if not None, snapshot each listed layer's full
(B, S, D) output into a returned dict. Used by DFlash to collect
target_hidden.
Returns:
(final, captured) where final = concat(out_H0, out_H1) — layer N-1's
full output — and captured = {T: concat(out_T_H0, out_T_H1) for T
in capture_layers} or {} if capture_layers is None.
"""
rank = group.rank()
layers = inner.layers
N = len(layers)
S = hidden_states.shape[1]
mid = S // 2
even_S = (S % 2 == 0) # True -> single-gather fast path
capture: dict[int, mx.array] = {}
capture_set: set[int] = set(capture_layers) if capture_layers is not None else set()
# Compute cache offset for mask slicing (same for all GQA layers).
# For BatchKVCache, update_and_fetch returns keys[:_idx], so the mask must
# match _idx (actual K buffer length), NOT `offset` (which is _idx minus
# left_padding for positional encodings). Plain KVCache uses `offset`.
fa_cache = cache[inner.fa_idx] if cache[inner.fa_idx] is not None else None
if fa_cache is None:
offset = 0
elif hasattr(fa_cache, "_idx"):
# BatchKVCache: _idx is Python int tracking actual K length
offset = int(fa_cache._idx)
elif hasattr(fa_cache, "offset"):
raw = fa_cache.offset
offset = int(raw.max().item()) if isinstance(raw, mx.array) else int(raw)
else:
offset = 0
# Slice masks once (reused every stage)
fa_mask_H0, fa_mask_H1 = slice_fa_mask(fa_mask, mid, offset)
ssm_mask_H0, ssm_mask_H1 = slice_ssm_mask(ssm_mask, mid)
def mask_for(layer, half: str): # type: ignore[no-untyped-def]
if layer.is_linear:
return ssm_mask_H0 if half == "H0" else ssm_mask_H1
else:
return fa_mask_H0 if half == "H0" else fa_mask_H1
# Running state between stages (loop-local, no closure).
x_H0 = hidden_states[:, :mid, :] # (B, mid, D)
x_H1 = hidden_states[:, mid:, :] # (B, S-mid, D)
# Pending handoffs:
# h_H0_ready: output of most recent attn(H0) — awaiting moe
# h_H1_pending: output of most recent attn(H1) — awaiting moe
h_H0_ready = None
h_H1_pending = None
print(f"[rank {rank}] pipeline begin S={S} N={N} even={even_S}", flush=True)
# --- Stage 0 (startup bubble): ATTN attn_0(H0). MOE idle. ---
layer_0 = layers[0]
c0 = cache[0]
if rank == ATTN_RANK:
contribution = attention(layer_0, x_H0, mask_for(layer_0, "H0"), c0)
else:
contribution = _zeros_like(x_H0)
_t0 = time.perf_counter()
mx.eval(contribution)
_t_eval_local_ms = (time.perf_counter() - _t0) * 1000.0
gathered = mx.distributed.all_gather(contribution, group=group)
_t0 = time.perf_counter()
mx.eval(gathered)
_t_eval_gather_ms = (time.perf_counter() - _t0) * 1000.0
h_H0_ready = gathered[ATTN_RANK : ATTN_RANK + 1]
_role = "attn_0(H0)" if rank == ATTN_RANK else "idle"
print(
f"[rank {rank}] stage 0 (T=0 startup) "
f"[rank {rank}:{_role}] eval_local={_t_eval_local_ms:.2f}ms eval_gather={_t_eval_gather_ms:.2f}ms",
flush=True,
)
# --- Stages 1..2N-1: main pipeline ---
# Even S: attn_side and moe_side have the same shape (mid == S-mid), so
# we do ONE all_gather per stage — each rank contributes its real
# output, both get both.
# Odd S: shapes differ, so we do TWO all_gathers per stage — each rank
# contributes a zero placeholder of the OTHER side's shape.
for stage in range(1, 2 * N):
is_B_stage = (stage % 2 == 1)
T = stage // 2
if is_B_stage:
# Stage 2T+1: ATTN attn_T(H1) (B, S-mid, D) | MOE moe_T(h_T_H0) (B, mid, D)
layer_T = layers[T]
cT = cache[T]
if even_S:
if rank == ATTN_RANK:
my_out = attention(layer_T, x_H1, mask_for(layer_T, "H1"), cT)
else:
my_out = moe(layer_T, h_H0_ready)
_t0 = time.perf_counter()
mx.eval(my_out)
_t_eval_local_ms = (time.perf_counter() - _t0) * 1000.0
gathered = mx.distributed.all_gather(my_out, group=group)
_t0 = time.perf_counter()
mx.eval(gathered)
_t_eval_gather_ms = (time.perf_counter() - _t0) * 1000.0
attn_contrib = gathered[ATTN_RANK : ATTN_RANK + 1]
moe_contrib = gathered[MOE_RANK : MOE_RANK + 1]
else:
if rank == ATTN_RANK:
attn_side = attention(layer_T, x_H1, mask_for(layer_T, "H1"), cT)
moe_side = _zeros_like(h_H0_ready)
else:
attn_side = _zeros_like(x_H1)
moe_side = moe(layer_T, h_H0_ready)
_t0 = time.perf_counter()
mx.eval(attn_side)
mx.eval(moe_side)
_t_eval_local_ms = (time.perf_counter() - _t0) * 1000.0
attn_contrib, moe_contrib = _gather_two(
rank, attn_side, moe_side, attn_side, moe_side, group
)
_t0 = time.perf_counter()
mx.eval(attn_contrib)
mx.eval(moe_contrib)
_t_eval_gather_ms = (time.perf_counter() - _t0) * 1000.0
_role = f"attn_{T}(H1)" if rank == ATTN_RANK else f"moe_{T}(h_{T}_H0)"
print(
f"[rank {rank}] stage {stage} (T={T} B) "
f"[rank {rank}:{_role}] eval_local={_t_eval_local_ms:.2f}ms eval_gather={_t_eval_gather_ms:.2f}ms",
flush=True,
)
h_H1_pending = attn_contrib
x_H0 = moe_contrib
else:
# Stage 2T (T>=1): ATTN attn_T(H0) (B, mid, D) | MOE moe_{T-1}(h_{T-1}_H1) (B, S-mid, D)
layer_T = layers[T]
cT = cache[T]
prev_layer = layers[T - 1]
if even_S:
if rank == ATTN_RANK:
my_out = attention(layer_T, x_H0, mask_for(layer_T, "H0"), cT)
else:
my_out = moe(prev_layer, h_H1_pending)
_t0 = time.perf_counter()
mx.eval(my_out)
_t_eval_local_ms = (time.perf_counter() - _t0) * 1000.0
gathered = mx.distributed.all_gather(my_out, group=group)
_t0 = time.perf_counter()
mx.eval(gathered)
_t_eval_gather_ms = (time.perf_counter() - _t0) * 1000.0
attn_contrib = gathered[ATTN_RANK : ATTN_RANK + 1]
moe_contrib = gathered[MOE_RANK : MOE_RANK + 1]
else:
if rank == ATTN_RANK:
attn_side = attention(layer_T, x_H0, mask_for(layer_T, "H0"), cT)
moe_side = _zeros_like(h_H1_pending)
else:
attn_side = _zeros_like(x_H0)
moe_side = moe(prev_layer, h_H1_pending)
_t0 = time.perf_counter()
mx.eval(attn_side)
mx.eval(moe_side)
_t_eval_local_ms = (time.perf_counter() - _t0) * 1000.0
attn_contrib, moe_contrib = _gather_two(
rank, attn_side, moe_side, attn_side, moe_side, group
)
_t0 = time.perf_counter()
mx.eval(attn_contrib)
mx.eval(moe_contrib)
_t_eval_gather_ms = (time.perf_counter() - _t0) * 1000.0
_role = f"attn_{T}(H0)" if rank == ATTN_RANK else f"moe_{T - 1}(h_{T - 1}_H1)"
print(
f"[rank {rank}] stage {stage} (T={T} A) "
f"[rank {rank}:{_role}] eval_local={_t_eval_local_ms:.2f}ms eval_gather={_t_eval_gather_ms:.2f}ms",
flush=True,
)
h_H0_ready = attn_contrib
x_H1 = moe_contrib
# Capture layer T-1's full output: x_H0 = out_{T-1}_H0 (set in
# previous B stage) and x_H1 = out_{T-1}_H1 (just set above).
if (T - 1) in capture_set:
capture[T - 1] = mx.concatenate([x_H0, x_H1], axis=1)
# --- Stage 2N (drain bubble): ATTN idle. MOE moe_{N-1}(h_{N-1}_H1). ---
last_layer = layers[N - 1]
if rank == MOE_RANK:
contribution = moe(last_layer, h_H1_pending)
else:
contribution = _zeros_like(h_H1_pending)
_t0 = time.perf_counter()
mx.eval(contribution)
_t_eval_local_ms = (time.perf_counter() - _t0) * 1000.0
gathered = mx.distributed.all_gather(contribution, group=group)
_t0 = time.perf_counter()
mx.eval(gathered)
_t_eval_gather_ms = (time.perf_counter() - _t0) * 1000.0
out_H1 = gathered[MOE_RANK : MOE_RANK + 1]
_role = f"moe_{N - 1}(h_{N - 1}_H1)" if rank == MOE_RANK else "idle"
print(
f"[rank {rank}] drain "
f"[rank {rank}:{_role}] eval_local={_t_eval_local_ms:.2f}ms eval_gather={_t_eval_gather_ms:.2f}ms",
flush=True,
)
# After the final B stage (Stage 2N-1), out_{N-1}_H0 = x_H0 (MOE's contribution
# from Stage 2N-1 — stored in the x_H0 variable).
out_H0 = x_H0
final = mx.concatenate([out_H0, out_H1], axis=1)
# Capture layer N-1's output if requested (only now is out_{N-1}_H1 known).
if (N - 1) in capture_set:
capture[N - 1] = final
return final, capture
# ---------------------------------------------------------------------------
# DFlash _CapturingLayer detection / population
# ---------------------------------------------------------------------------
def _dflash_capturing_target_ids(inner) -> set[int]:
"""Return layer indices wrapped by DFlashBatchGenerator._CapturingLayer.
Detected by the presence of both ``_orig`` and ``_layer_idx`` attributes —
the two instance attrs set in _CapturingLayer.__init__.
"""
ids: set[int] = set()
for layer in inner.layers:
if hasattr(layer, "_orig") and hasattr(layer, "_layer_idx"):
ids.add(int(layer._layer_idx))
return ids
def _dflash_captured_dict(inner): # type: ignore[no-untyped-def]
"""Return the ``captured`` dict shared by all _CapturingLayer instances.
_CapturingLayer.__call__ writes to a closure variable ``captured`` which
is a reference to DFlashBatchGenerator._captured. We fish it out via
``__call__.__closure__`` so we can populate it from the pipelined path
(which bypasses _CapturingLayer.__call__ entirely).
"""
for layer in inner.layers:
if hasattr(layer, "_orig") and hasattr(layer, "_layer_idx"):
call_fn = type(layer).__call__
closure = getattr(call_fn, "__closure__", None)
if closure is None:
return None
for name, cell in zip(call_fn.__code__.co_freevars, closure):
if name == "captured":
return cell.cell_contents
return None
return None
def _populate_dflash_captured(
inner, layer_hiddens: dict, S: int
) -> None: # type: ignore[no-untyped-def]
"""Write captured layer outputs into the DFlash ``_captured`` dict.
Mirrors _CapturingLayer.__call__'s behavior: always writes to
``layer_hiddens``, and additionally to ``prefill_hiddens`` when S > 1.
"""
if not layer_hiddens:
return
captured = _dflash_captured_dict(inner)
if captured is None:
return
if "layer_hiddens" not in captured:
captured["layer_hiddens"] = {}
for idx, out in layer_hiddens.items():
captured["layer_hiddens"][idx] = out
if S > 1:
if "prefill_hiddens" not in captured:
captured["prefill_hiddens"] = {}
for idx, out in layer_hiddens.items():
captured["prefill_hiddens"][idx] = out
# ---------------------------------------------------------------------------
# Replacement for Qwen3_5TextModel.__call__
# ---------------------------------------------------------------------------
def make_pipelined_model_call(group): # type: ignore[no-untyped-def]
"""Build a Qwen3_5TextModel.__call__ replacement closed over ``group``.
For S==1 decode, falls through to the stock layer loop which calls
DecoderLayer.__call__ — i.e. our serial _split_call. For S>1, runs the
pipelined layer loop.
DFlash integration: if DFlashBatchGenerator._setup_hidden_capture has
wrapped any decoder layer in _CapturingLayer, we detect it and populate
its closure-captured ``captured`` dict with layer outputs directly —
because the pipelined path bypasses layer.__call__ and _CapturingLayer
never fires on its own during prefill.
"""
def _pipelined_call(
self,
inputs: mx.array,
cache=None,
input_embeddings=None,
) -> mx.array:
if input_embeddings is not None:
hidden_states = input_embeddings
else:
hidden_states = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
S = hidden_states.shape[1]
if S == 1:
# Decode: stock loop -> DecoderLayer.__call__ -> serial _split_call.
# _CapturingLayer.__call__ fires naturally here for S==1.
fa_mask = create_attention_mask(hidden_states, cache[self.fa_idx])
ssm_mask = create_ssm_mask(hidden_states, cache[self.ssm_idx])
for layer, c in zip(self.layers, cache):
mask = ssm_mask if layer.is_linear else fa_mask
hidden_states = layer(hidden_states, mask=mask, cache=c)
return self.norm(hidden_states)
# S > 1: pipelined path. Force fa_mask as a real tensor for slicing.
fa_mask = create_attention_mask(
hidden_states, cache[self.fa_idx], return_array=True
)
ssm_mask = create_ssm_mask(hidden_states, cache[self.ssm_idx])
# If DFlash has wrapped target layers, request their outputs so we can
# populate its captured dict (stock _CapturingLayer.__call__ never
# fires on the pipelined path).
target_ids = _dflash_capturing_target_ids(self)
capture_layers = target_ids if target_ids else None
hidden_states, layer_hiddens = pipelined_layer_loop(
self,
hidden_states,
cache,
group,
fa_mask,
ssm_mask,
capture_layers=capture_layers,
)
if target_ids:
_populate_dflash_captured(self, layer_hiddens, S)
return self.norm(hidden_states)
return _pipelined_call
# ---------------------------------------------------------------------------
# Replacement for exo.speculative.mtp_module.speculative_forward
# ---------------------------------------------------------------------------
def make_pipelined_speculative_forward(group): # type: ignore[no-untyped-def]
"""Build a speculative_forward replacement that uses pipelined_layer_loop.
Same pre-loop setup as the original (SpeculativeArraysCache wrapping,
gated_delta_update monkey-patch) and same post-loop GDN state capture,
but swaps the layer loop for our pipelined version.
"""
def _pipelined_speculative_forward(
model, inputs: mx.array, cache, speculative: bool = False
): # type: ignore[no-untyped-def]
inner = getattr(model, "model", None) or model.language_model.model
text_model = getattr(model, "model", None) or model.language_model
S = inputs.shape[1]
do_spec = speculative and S > 1
if hasattr(inner, "embed_tokens"):
hidden_states = inner.embed_tokens(inputs)
else:
hidden_states = inputs
cache_list: list[Any] = cache if cache is not None else [None] * len(inner.layers)
gdn_spec_data: list[Any] = []
if do_spec:
from exo.worker.engines.mlx.speculative.speculative_cache import (
SpeculativeArraysCache,
)
for i, c in enumerate(cache_list):
if c is not None and hasattr(c, "cache") and not hasattr(c, "offset"):
cache_list[i] = SpeculativeArraysCache(c, S=S)
if cache is not None:
for i in range(len(cache)):
cache[i] = cache_list[i]
spec_all_states: list[Any] = []
_orig_gdu = None
if do_spec:
from exo.worker.engines.mlx.speculative.mtp_module import (
_make_speculative_gdu,
)
import mlx_lm.models.qwen3_5 as _qwen3_5_mod
_orig_gdu = _qwen3_5_mod.gated_delta_update
_qwen3_5_mod.gated_delta_update = _make_speculative_gdu(spec_all_states)
fa_mask = create_attention_mask(
hidden_states, cache_list[inner.fa_idx], return_array=(S > 1)
)
ssm_mask = create_ssm_mask(hidden_states, cache_list[inner.ssm_idx])
if do_spec:
# Capture per-GDN-layer data for the post-loop state reconstruction
# (matches original speculative_forward lines 80-89).
from exo.worker.engines.mlx.speculative.speculative_cache import (
SpeculativeArraysCache as _SAC,
)
for layer, c in zip(inner.layers, cache_list):
if layer.is_linear and isinstance(c, _SAC):
pre_conv = c[0]
if pre_conv is None:
gdn = layer.linear_attn
pre_conv = mx.zeros(
(
hidden_states.shape[0],
gdn.conv_kernel_size - 1,
gdn.conv_dim,
),
dtype=hidden_states.dtype,
)
gdn_spec_data.append((None, pre_conv, c, layer))
# Pipelined layer loop for S>1; stock layer loop for S==1.
if S > 1:
hidden_states, _ = pipelined_layer_loop(
inner, hidden_states, cache_list, group, fa_mask, ssm_mask
)
else:
for layer, c in zip(inner.layers, cache_list):
mask = ssm_mask if layer.is_linear else fa_mask
hidden_states = layer(hidden_states, mask=mask, cache=c)
if do_spec:
import mlx_lm.models.qwen3_5 as _qwen3_5_mod
_qwen3_5_mod.gated_delta_update = _orig_gdu
gdn_idx = 0
for _layer_input, pre_conv, spec_cache, parent_layer in gdn_spec_data:
if gdn_idx < len(spec_all_states):
spec_cache.all_states = spec_all_states[gdn_idx]
gdn_idx += 1
gdn = parent_layer.linear_attn
# Recover the layer input from the original hidden_states before
# the layer loop — but we don't have it anymore because the
# pipeline overwrote it. We store the original at loop start.
# For now we reconstruct conv_input from pre_conv + qkv of the
# ORIGINAL embeddings (pre-layer). TODO revisit this.
# Keeping the conv_input reconstruction as-is for now but
# conv_input will NOT be valid after the pipelined forward.
# This is acceptable only if rollback path isn't exercised.
pass # conv_input reconstruction skipped under split pipeline
pre_norm = hidden_states
normed = inner.norm(hidden_states)
if hasattr(text_model, "lm_head"):
logits = text_model.lm_head(normed)
else:
logits = inner.embed_tokens.as_linear(normed)
return pre_norm, logits
return _pipelined_speculative_forward
# ---------------------------------------------------------------------------
# Replacement for exo.speculative.dflash_speculative.dflash_speculative_forward
# ---------------------------------------------------------------------------
def make_pipelined_dflash_speculative_forward(group): # type: ignore[no-untyped-def]
"""Build a dflash_speculative_forward replacement using pipelined_layer_loop.
Differences from make_pipelined_speculative_forward:
- Captures hidden states at ``target_layer_ids`` for the DFlash drafter
- Returns (target_hidden, pre_norm, logits) instead of (pre_norm, logits)
"""
def _pipelined_dflash_forward(
model,
inputs: mx.array,
cache,
target_layer_ids,
speculative: bool = False,
): # type: ignore[no-untyped-def]
inner = getattr(model, "model", None) or model.language_model.model
text_model = getattr(model, "model", None) or model.language_model
S = inputs.shape[1]
do_spec = speculative and S > 1
if hasattr(inner, "embed_tokens"):
hidden_states = inner.embed_tokens(inputs)
else:
hidden_states = inputs
cache_list: list[Any] = (
cache if cache is not None else [None] * len(inner.layers)
)
# Wrap GDN caches for rollback
if do_spec:
from exo.worker.engines.mlx.speculative.speculative_cache import (
SpeculativeArraysCache,
)
for i, c in enumerate(cache_list):
if c is not None and hasattr(c, "cache") and not hasattr(c, "offset"):
cache_list[i] = SpeculativeArraysCache(c, S=S)
if cache is not None:
for i in range(len(cache)):
cache[i] = cache_list[i]
# Swap in speculative GDN kernel
spec_all_states: list[Any] = []
_orig_gdu = None
if do_spec:
from exo.worker.engines.mlx.speculative.mtp_module import (
_make_speculative_gdu,
)
import mlx_lm.models.qwen3_5 as _qwen3_5_mod
_orig_gdu = _qwen3_5_mod.gated_delta_update
_qwen3_5_mod.gated_delta_update = _make_speculative_gdu(spec_all_states)
fa_mask = create_attention_mask(
hidden_states, cache_list[inner.fa_idx], return_array=(S > 1)
)
ssm_mask = create_ssm_mask(hidden_states, cache_list[inner.ssm_idx])
# Collect per-GDN-layer data for post-loop conv_input reconstruction
gdn_spec_data: list[Any] = []
if do_spec:
from exo.worker.engines.mlx.speculative.speculative_cache import (
SpeculativeArraysCache as _SAC,
)
for idx, (layer, c) in enumerate(zip(inner.layers, cache_list)):
if layer.is_linear and isinstance(c, _SAC):
pre_conv = c[0]
if pre_conv is None:
gdn = layer.linear_attn
pre_conv = mx.zeros(
(
hidden_states.shape[0],
gdn.conv_kernel_size - 1,
gdn.conv_dim,
),
dtype=hidden_states.dtype,
)
# Tuple order matches stock: (pre_conv, spec_cache, layer, layer_idx).
# layer_idx is used post-loop to look up the layer's input.
gdn_spec_data.append((pre_conv, c, layer, idx))
# Run layer loop; capture hidden states at target_layer_ids + GDN
# layer INPUTS (which equal layer L-1's outputs) for conv rollback.
# Initial embed is layer 0's input; we pass it explicitly below.
initial_embed = hidden_states
gdn_layer_idxs = [
i for i, layer in enumerate(inner.layers) if layer.is_linear
]
# For GDN layer L, we need layer L's input = layer L-1's output (or
# initial embed if L=0). Request capture of outputs for indices L-1.
extra_capture = {L - 1 for L in gdn_layer_idxs if L >= 1}
capture_set = set(target_layer_ids) | extra_capture
if S > 1:
hidden_states, layer_hiddens = pipelined_layer_loop(
inner,
hidden_states,
cache_list,
group,
fa_mask,
ssm_mask,
capture_layers=capture_set,
)
else:
# S==1 path: stock loop with manual capture
layer_hiddens = {}
for i, (layer, c) in enumerate(zip(inner.layers, cache_list)):
mask = ssm_mask if layer.is_linear else fa_mask
hidden_states = layer(hidden_states, mask=mask, cache=c)
if i in capture_set:
layer_hiddens[i] = hidden_states
# Post-loop: restore GDN kernel, reconstruct conv_input for rollback
if do_spec:
import mlx_lm.models.qwen3_5 as _qwen3_5_mod
_qwen3_5_mod.gated_delta_update = _orig_gdu
# Cross-layer pipeline calls GDN.linear_attn TWICE per GDN layer
# (once for H0, once for H1 with S>1). The monkey-patched
# gated_delta_update appends per-step states on each call, so
# spec_all_states has 2*N_gdn entries: [H0 states of layer 0,
# H1 states of layer 0, H0 states of layer 1, H1 states of layer 1, ...].
# Merge consecutive pairs so one entry per GDN layer.
# S==1 case: only one call per layer — no merging needed.
merged_states: list[Any] = []
if S > 1 and len(spec_all_states) == 2 * len(gdn_spec_data):
for i in range(0, len(spec_all_states), 2):
# all_states shape: (B, step_count, H_v, D_v, D_k).
# Concat H0 (step_count=mid) and H1 (step_count=S-mid) along step dim.
merged_states.append(
mx.concatenate(
[spec_all_states[i], spec_all_states[i + 1]], axis=1
)
)
else:
merged_states = spec_all_states
gdn_idx = 0
for pre_conv, spec_cache, parent_layer, layer_idx in gdn_spec_data:
if gdn_idx < len(merged_states):
spec_cache.all_states = merged_states[gdn_idx]
gdn_idx += 1
# Reconstruct conv_input for rollback. Mirrors stock
# dflash_speculative.py:97-113 but retrieves layer_input from
# our captured layer outputs (capture[L-1] is layer L's input)
# or the initial embedding for layer 0.
if layer_idx == 0:
layer_input = initial_embed
else:
layer_input = layer_hiddens.get(layer_idx - 1)
if layer_input is None:
continue # shouldn't happen given our capture_set, safety
gdn = parent_layer.linear_attn
normed = parent_layer.input_layernorm(layer_input)
if hasattr(gdn, "in_proj_qkv"):
qkv = gdn.in_proj_qkv(normed)
else:
q, k, v, z, b, a = gdn.fix_query_key_value_ordering(
gdn.in_proj_qkvz(normed), gdn.in_proj_ba(normed)
)
B_dim = normed.shape[0]
qkv = mx.concatenate(
[
q.reshape(B_dim, S, -1),
k.reshape(B_dim, S, -1),
v.reshape(B_dim, S, -1),
],
axis=-1,
)
spec_cache.conv_input = mx.concatenate([pre_conv, qkv], axis=1)
# Concatenate target_hidden from captured layers
selected = [layer_hiddens[i] for i in target_layer_ids]
target_hidden = mx.concatenate(selected, axis=-1)
pre_norm = hidden_states
normed = inner.norm(hidden_states)
if hasattr(text_model, "lm_head"):
logits = text_model.lm_head(normed)
else:
logits = inner.embed_tokens.as_linear(normed)
return target_hidden, pre_norm, logits
return _pipelined_dflash_forward
Whitespace-only changes.
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Dynamic per-call loop-over-B patches for the DFlash draft model.
For each patched projection, the kernel is picked at CALL time based on
the actual M seen in the forward pass (via matmul/patches/kernel_picker).
Each projection memoizes its kernel cache, so after warmup the pick is
O(1) dict lookup.
This is correct for any (block_size, verify_len) combo because each
projection sees a different M (e.g. k_proj sees M = S_ctx + block_size,
mlp_gate sees M = block_size, fc sees M = S_ctx, lm_head sees M = block_size - 1).
"""
import os
import mlx.nn as nn
from exo.worker.engines.mlx.matmul.patches.kernel_picker import (
pick_bf16_kernel,
pick_int8_kernel,
)
MAX_M = 16 # Above this (prefill), fall back to the original projection
class _BF16LpBLinear:
"""nn.Linear drop-in with dynamic per-call kernel selection."""
def __init__(self, original, N, K):
self._orig = original # kept for prefill fallback and weight access
self.weight = original.weight
self._N = N
self._K = K
self._cache = {} # M → fn
self._name_log = {} # M → kernel name (debug)
def __call__(self, x):
M = 1
for d in x.shape[:-1]:
M *= d
if M > MAX_M:
return self._orig(x)
fn = self._cache.get(M)
if fn is None:
name, fn = pick_bf16_kernel(self._N, self._K, M)
self._cache[M] = fn
self._name_log[M] = name
orig_shape = x.shape
x_2d = x.reshape(-1, self._K)
y = fn(x_2d, self.weight, M, self._N, self._K)
return y.reshape(*orig_shape[:-1], self._N)
class _QuantizedLpBLinear:
"""nn.QuantizedLinear drop-in with dynamic per-call kernel selection."""
def __init__(self, original, N, K, GS):
self._orig = original
self.weight = original.weight
self.scales = original.scales
self.biases = original.biases
self._N = N
self._K = K
self._GS = GS
self._cache = {}
self._name_log = {}
def __call__(self, x):
M = 1
for d in x.shape[:-1]:
M *= d
if M > MAX_M:
return self._orig(x)
fn = self._cache.get(M)
if fn is None:
name, fn = pick_int8_kernel(self._N, self._K, M)
self._cache[M] = fn
self._name_log[M] = name
orig_shape = x.shape
x_2d = x.reshape(-1, self._K)
y = fn(x_2d, self.weight, self.scales, self.biases, M, self._N, self._K, self._GS)
return y.reshape(*orig_shape[:-1], self._N)
def _wrap(proj):
if isinstance(proj, nn.QuantizedLinear):
N = proj.weight.shape[0]
K = proj.weight.shape[1] * (32 // proj.bits)
GS = proj.group_size
return _QuantizedLpBLinear(proj, N, K, GS)
elif isinstance(proj, nn.Linear):
N = proj.weight.shape[0]
K = proj.weight.shape[1]
return _BF16LpBLinear(proj, N, K)
return proj
def apply_bf16_lpb_patches(drafter):
"""Patch DFlash drafter's projections with dynamic LpB kernels.
Set env DFLASH_LPB_ONLY to a comma-separated list of projection names
to patch only those (for bisecting which patch breaks things).
Names: mlp_gate, mlp_up, mlp_down, q_proj, k_proj, v_proj, o_proj, fc, lm_head
"""
only_str = os.environ.get("DFLASH_LPB_ONLY", "")
only = set(only_str.split(",")) if only_str else None
if only:
print(f" DFLASH_LPB_ONLY={only_str}")
def should_patch(name):
return only is None or name in only
patched = 0
for layer in drafter.layers:
for attr in ('mlp_gate', 'mlp_up', 'mlp_down'):
if not should_patch(attr):
continue
proj = getattr(layer, attr, None)
if proj is not None and isinstance(proj, nn.Linear):
setattr(layer, attr, _wrap(proj))
patched += 1
for attr in ('q_proj', 'k_proj', 'v_proj', 'o_proj'):
if not should_patch(attr):
continue
proj = getattr(layer.self_attn, attr, None)
if proj is not None and isinstance(proj, nn.Linear):
setattr(layer.self_attn, attr, _wrap(proj))
patched += 1
if should_patch('fc') and hasattr(drafter, 'fc') and isinstance(drafter.fc, nn.Linear):
drafter.fc = _wrap(drafter.fc)
patched += 1
if should_patch('lm_head') and drafter.lm_head is not None and isinstance(
drafter.lm_head, (nn.Linear, nn.QuantizedLinear)
):
drafter.lm_head = _wrap(drafter.lm_head)
patched += 1
print(f" Patched {patched} DFlash drafter projections with dynamic LpB")
return patched
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""DFlash Speculative Decoding integrated with mlx_lm's BatchGenerator.
Two prefill approaches (PREFILL_MODE env var):
"capture" (default): Use _CapturingLayer to intercept hidden states during
super()._next() prefill. No extra forward pass.
"direct": Run dflash_speculative_forward for prefill ourselves,
bypassing super()._next() for the first step.
"""
import time
import mlx.core as mx
from mlx_lm.generate import BatchGenerator
from .dflash_module import DFlashDrafter
from .dflash_speculative import dflash_speculative_forward
class DFlashBatchGenerator(BatchGenerator):
"""BatchGenerator with DFlash speculative decoding for BS=1."""
def __init__(
self,
model,
drafter: DFlashDrafter,
verify_len: int = 5,
block_size: int = 6,
temp: float = 0.0,
alpha: float = 1.0,
prefill_mode: str = "capture",
**kwargs,
):
super().__init__(model, **kwargs)
self.drafter = drafter
self.verify_len = verify_len
self.temp = temp
self.alpha = alpha
self.prefill_mode = prefill_mode
drafter.block_size = block_size
self._token_buffer = {}
self._last_target_hidden = {}
self._draft_position = {}
self._prefilled = set()
self._request_temp = {}
self._captured = {}
if prefill_mode == "capture":
self._setup_hidden_capture()
def _setup_hidden_capture(self):
"""Replace target layers with wrappers that capture hidden states."""
inner = getattr(self.model, 'model', None) or self.model.language_model.model
target_ids = set(self.drafter.target_layer_ids)
captured = self._captured
class _CapturingLayer:
def __init__(self, orig, layer_idx):
self._orig = orig
self._layer_idx = layer_idx
def __call__(self, *args, **kwargs):
out = self._orig(*args, **kwargs)
if 'layer_hiddens' not in captured:
captured['layer_hiddens'] = {}
captured['layer_hiddens'][self._layer_idx] = out
if out.shape[1] > 1:
if 'prefill_hiddens' not in captured:
captured['prefill_hiddens'] = {}
captured['prefill_hiddens'][self._layer_idx] = out
return out
def __getattr__(self, name):
return getattr(self._orig, name)
for i in range(len(inner.layers)):
if i in target_ids:
inner.layers[i] = _CapturingLayer(inner.layers[i], i)
def _get_captured_target_hidden(self, key='layer_hiddens'):
hiddens = self._captured.get(key)
if hiddens is None:
return None
selected = [hiddens[i] for i in self.drafter.target_layer_ids if i in hiddens]
if len(selected) != len(self.drafter.target_layer_ids):
return None
self._captured[key] = {}
return mx.concatenate(selected, axis=-1)
def _next(self):
batch = self.active_batch
# Yield buffered tokens first
if batch is not None and len(batch) == 1:
uid = batch.uids[0]
if uid in self._token_buffer and self._token_buffer[uid]:
return self._yield_buffered(batch, uid)
# BS=1 speculative path
if (batch is not None
and len(batch) == 1
and self.verify_len > 0
and len(self.unprocessed_prompts) == 0):
uid = batch.uids[0]
if uid not in self._prefilled:
if self.prefill_mode == "direct":
return self._first_step_direct(batch, uid)
else:
return self._first_step_capture(batch, uid)
return self._speculative_next()
# Standard path (BS>1 or no batch)
return super()._next()
# ── Approach 1: "capture" ──
def _first_step_capture(self, batch, uid):
"""Use prefill hidden states captured by _CapturingLayer during super()._next()."""
target_hidden = self._get_captured_target_hidden(key='prefill_hiddens')
if target_hidden is None:
target_hidden = self._get_captured_target_hidden(key='layer_hiddens')
if target_hidden is not None:
mx.eval(target_hidden)
self._last_target_hidden[uid] = target_hidden
for c in batch.cache:
if hasattr(c, 'offset'):
off = c.offset
self._draft_position[uid] = off.item() if hasattr(off, 'item') else int(off)
break
self.drafter.reset_draft_cache()
self._prefilled.add(uid)
return self._speculative_next()
# ── Approach 2: "direct" ──
def _first_step_direct(self, batch, uid):
"""Run dflash_speculative_forward ourselves for prefill, then first speculative cycle."""
# batch.y has the first token from super()._next()'s prefill
# But we need target hidden from that prefill. Run dflash_speculative_forward
# on the prompt tokens to get them.
prompt_toks = batch.tokens[0] # full prompt token history
mx.eval(prompt_toks)
# Run target model forward on prompt to capture multi-layer hidden
target_hidden, _, logits = dflash_speculative_forward(
self.model, prompt_toks.reshape(1, -1), batch.cache,
self.drafter.target_layer_ids, speculative=False)
mx.eval(target_hidden, logits)
self._last_target_hidden[uid] = target_hidden
# First token from logits
first_token = mx.argmax(logits[0, -1], axis=-1).item()
batch.y = mx.array([first_token])
for c in batch.cache:
if hasattr(c, 'offset'):
off = c.offset
self._draft_position[uid] = off.item() if hasattr(off, 'item') else int(off)
break
self.drafter.reset_draft_cache()
self._prefilled.add(uid)
return self._speculative_next()
# ── Core speculative cycle ──
def _speculative_next(self):
tic = time.perf_counter()
batch = self.active_batch
uid = batch.uids[0]
y = batch.y
y_val = y[0].item()
y_logprobs = batch.logprobs[0]
batch.tokens[0] = mx.concatenate((batch.tokens[0], y[0:1]))
last_target_hidden = self._last_target_hidden.get(uid)
if last_target_hidden is None:
return super()._next()
bs = self.drafter.block_size
verify_len = self.verify_len
temp = self._request_temp.get(uid, self.temp)
alpha = self.alpha
start = self._draft_position[uid]
# 1. Draft
block_ids = mx.full((1, bs), self.drafter.mask_token_id, dtype=mx.int32)
block_ids[:, 0] = y_val
draft_logits = self.drafter.draft(last_target_hidden, block_ids, start)
self.drafter.crop_draft_cache(start)
# 2. Sample
if temp == 0:
all_drafts_arr = mx.argmax(draft_logits, axis=-1).squeeze(0)
mx.eval(all_drafts_arr)
all_drafts = all_drafts_arr.tolist()
draft_probs = None
else:
all_drafts = []
draft_probs = []
for i in range(bs - 1):
p = mx.softmax(draft_logits[0, i] / temp, axis=-1)
tok = mx.random.categorical(mx.log(p)).item()
all_drafts.append(tok)
draft_probs.append(p)
drafts = all_drafts[:verify_len]
# 3. Verify
verify_input = mx.array([[y_val] + drafts])
target_hidden, _, verify_logits = dflash_speculative_forward(
self.model, verify_input, batch.cache,
self.drafter.target_layer_ids, speculative=True)
# Build acceptance lazily (no logprobs — skip expensive logsumexp on 248K vocab)
if temp == 0:
target_tokens = mx.argmax(verify_logits[:, :verify_len, :], axis=-1)
draft_arr = mx.array([drafts])
matches = mx.equal(target_tokens, draft_arr).squeeze(0)
all_next = mx.argmax(verify_logits[0], axis=-1)
mx.async_eval(matches, all_next, target_hidden)
else:
accept_ratios = []
for i in range(verify_len):
p = mx.softmax(verify_logits[0, i] / temp, axis=-1)
q = draft_probs[i]
p_di = p[drafts[i]]
q_di = mx.maximum(q[drafts[i]], mx.array(1e-10))
ratio = p_di / q_di
accept_ratios.append(mx.minimum(ratio ** alpha, mx.array(1.0)))
uniforms = mx.random.uniform(shape=(verify_len,))
corrections = []
for i in range(verify_len):
p = mx.softmax(verify_logits[0, i] / temp, axis=-1)
q = draft_probs[i]
residual = mx.maximum(p - q, 0.0)
corrections.append(mx.random.categorical(mx.log(residual + 1e-10)))
bonus_token = mx.random.categorical(verify_logits[0, verify_len] * (1.0 / temp))
mx.async_eval(accept_ratios, uniforms, corrections, bonus_token, target_hidden)
# 4. Accept
n_accepted = 0
for i in range(verify_len):
if temp == 0:
if matches[i].item():
n_accepted += 1
else:
break
else:
if uniforms[i].item() < accept_ratios[i].item():
n_accepted += 1
else:
break
print(f"[DFlash] n_accepted={n_accepted}/{verify_len}", flush=True)
# 5. Rollback
rollback = verify_len - n_accepted
if rollback > 0:
for c in batch.cache:
if hasattr(c, 'offset'):
c.offset -= rollback
elif hasattr(c, 'rollback'):
c.rollback(n_accepted)
for i, c in enumerate(batch.cache):
if hasattr(c, 'base'):
batch.cache[i] = c.base
# 6. Bonus/correction
no_lp = mx.array(0.0) # placeholder — no logprobs computed
if n_accepted == verify_len:
if temp == 0:
bonus_val = all_next[verify_len].item()
else:
bonus_val = bonus_token.item()
else:
if temp == 0:
bonus_val = all_next[n_accepted].item()
else:
bonus_val = corrections[n_accepted].item()
# 7. Update state
self._last_target_hidden[uid] = target_hidden[:, :n_accepted + 1, :]
self._draft_position[uid] = start + n_accepted + 1
# 8. Build token list (no logprobs)
all_tokens = [(y_val, y_logprobs)]
for i in range(n_accepted):
all_tokens.append((drafts[i], no_lp))
batch.y = mx.array([bonus_val])
batch.logprobs = [no_lp]
if n_accepted > 0:
batch.tokens[0] = mx.concatenate(
(batch.tokens[0], mx.array([t for t, _ in all_tokens[1:]])))
batch.num_tokens[0] += len(all_tokens)
# 9. Stop conditions
toc = time.perf_counter()
self._stats.generation_time += toc - tic
self._stats.generation_tokens += len(all_tokens)
stop_idx = None
for idx, (tok, _) in enumerate(all_tokens):
if tok in self.stop_tokens:
stop_idx = idx
break
if batch.num_tokens[0] >= batch.max_tokens[0]:
stop_idx = idx
break
first_tok, first_lp = all_tokens[0]
if stop_idx is not None:
valid_tokens = all_tokens[:stop_idx]
if valid_tokens:
if len(valid_tokens) > 1:
self._token_buffer[uid] = valid_tokens[1:]
stop_tok, stop_lp = all_tokens[stop_idx]
if uid not in self._token_buffer:
self._token_buffer[uid] = []
self._token_buffer[uid].append((stop_tok, stop_lp))
mx.async_eval(batch.y)
return [self.Response(uid, first_tok, first_lp, None, lambda: None)]
else:
cache = batch.extract_cache(0)
self.active_batch = None
self._cleanup_uid(uid)
return [self.Response(uid, first_tok, first_lp, "stop", cache)]
if len(all_tokens) > 1:
self._token_buffer[uid] = all_tokens[1:]
mx.async_eval(batch.y)
return [self.Response(uid, first_tok, first_lp, None, lambda: None)]
def _yield_buffered(self, batch, uid):
tic = time.perf_counter()
buf = self._token_buffer[uid]
tok, lp = buf.pop(0)
if not buf:
del self._token_buffer[uid]
finish_reason = None
if tok in self.stop_tokens:
finish_reason = "stop"
elif batch.num_tokens[0] >= batch.max_tokens[0]:
finish_reason = "length"
cache = None
if finish_reason:
cache = batch.extract_cache(0)
self.active_batch = None
self._cleanup_uid(uid)
toc = time.perf_counter()
self._stats.generation_time += toc - tic
return [self.Response(uid, tok, lp, finish_reason, cache or (lambda: None))]
def _cleanup_uid(self, uid):
self._last_target_hidden.pop(uid, None)
self._draft_position.pop(uid, None)
self._prefilled.discard(uid)
self._token_buffer.pop(uid, None)
self._request_temp.pop(uid, None)
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""DFlash draft model for Qwen3.5 on MLX.
Port of z-lab's DFlash (Block Diffusion for Flash Speculative Decoding).
A 5-layer bidirectional transformer that generates all draft tokens in one
parallel forward pass, conditioned on target model hidden states.
Reference: z-lab/dflash benchmark.py
"""
import json
import os
import mlx.core as mx
import mlx.nn as nn
from mlx_lm.models.cache import KVCache
from mlx_lm.models.activations import swiglu
class DFlashAttention(nn.Module):
"""Bidirectional attention with target context injection.
Q from draft tokens. K/V from cat(target_context, draft_tokens).
Non-causal: every draft position attends to every other + context.
"""
def __init__(self, hidden_size, num_heads, num_kv_heads, head_dim, rope_theta,
rms_norm_eps=1e-6):
super().__init__()
self.num_heads = num_heads
self.num_kv_heads = num_kv_heads
self.head_dim = head_dim
self.scale = head_dim ** -0.5
self.q_proj = nn.Linear(hidden_size, num_heads * head_dim, bias=False)
self.k_proj = nn.Linear(hidden_size, num_kv_heads * head_dim, bias=False)
self.v_proj = nn.Linear(hidden_size, num_kv_heads * head_dim, bias=False)
self.o_proj = nn.Linear(num_heads * head_dim, hidden_size, bias=False)
self.q_norm = nn.RMSNorm(head_dim, eps=rms_norm_eps)
self.k_norm = nn.RMSNorm(head_dim, eps=rms_norm_eps)
self.rope = nn.RoPE(head_dim, base=rope_theta)
def __call__(self, hidden_states, target_hidden, q_offset, cache=None):
B, S, _ = hidden_states.shape
# Q from draft only
q = self.q_norm(
self.q_proj(hidden_states).reshape(B, S, self.num_heads, self.head_dim)
).transpose(0, 2, 1, 3)
# K/V from cat(context, draft) — single projection each
kv_input = mx.concatenate([target_hidden, hidden_states], axis=1)
kv_len = kv_input.shape[1]
k = self.k_norm(
self.k_proj(kv_input).reshape(B, kv_len, self.num_kv_heads, self.head_dim)
).transpose(0, 2, 1, 3)
v = self.v_proj(kv_input).reshape(
B, kv_len, self.num_kv_heads, self.head_dim
).transpose(0, 2, 1, 3)
# RoPE — fused mx.fast.rope, 1 dispatch each
q = self.rope(q, offset=q_offset)
k = self.rope(k, offset=cache.offset)
# KV cache — pre-allocated buffer, no concat copies
keys, values = cache.update_and_fetch(k, v)
# Bidirectional attention (no causal mask)
output = mx.fast.scaled_dot_product_attention(
q, keys, values, scale=self.scale)
output = output.transpose(0, 2, 1, 3).reshape(B, S, -1)
return self.o_proj(output)
class DFlashDecoderLayer(nn.Module):
def __init__(self, hidden_size, num_heads, num_kv_heads, head_dim,
intermediate_size, rope_theta, rms_norm_eps=1e-6):
super().__init__()
self.self_attn = DFlashAttention(
hidden_size, num_heads, num_kv_heads, head_dim, rope_theta,
rms_norm_eps=rms_norm_eps)
self.mlp_gate = nn.Linear(hidden_size, intermediate_size, bias=False)
self.mlp_up = nn.Linear(hidden_size, intermediate_size, bias=False)
self.mlp_down = nn.Linear(intermediate_size, hidden_size, bias=False)
self.input_layernorm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
def __call__(self, hidden_states, target_hidden, q_offset, cache=None):
residual = hidden_states
h = self.input_layernorm(hidden_states)
h = self.self_attn(h, target_hidden, q_offset, cache)
h = residual + h
residual = h
h = self.post_attention_layernorm(h)
h = residual + self.mlp_down(swiglu(self.mlp_gate(h), self.mlp_up(h)))
return h
class DFlashDrafter:
"""DFlash draft model for speculative decoding.
Maintains a KV cache for the draft model across iterations.
Each iteration:
1. Receives target_hidden for accepted positions
2. Processes [accepted_token, MASK, ...] with bidirectional attention
3. Draft model catches up on accepted positions via KV cache
"""
def __init__(self, target_model, dflash_model_path):
self.target = target_model
self._inner = getattr(target_model, 'model', None) or target_model.language_model.model
self._text_model = getattr(target_model, 'model', None) or target_model.language_model
# Load config
if os.path.isdir(dflash_model_path):
model_dir = dflash_model_path
else:
from huggingface_hub import snapshot_download
model_dir = snapshot_download(dflash_model_path)
with open(os.path.join(model_dir, 'config.json')) as f:
config = json.load(f)
self.hidden_size = config['hidden_size']
self.num_heads = config['num_attention_heads']
self.num_kv_heads = config['num_key_value_heads']
self.head_dim = config.get('head_dim', self.hidden_size // self.num_heads)
self.intermediate_size = config['intermediate_size']
self.num_layers = config['num_hidden_layers']
self.block_size = config['block_size']
self.target_layer_ids = config['dflash_config']['target_layer_ids']
self.mask_token_id = config['dflash_config']['mask_token_id']
self.rope_theta = config.get('rope_theta', 10000000)
self.rms_norm_eps = config.get('rms_norm_eps', 1e-6)
print(f" DFlash config: {self.num_layers} layers, hidden={self.hidden_size}, "
f"heads={self.num_heads}/{self.num_kv_heads}, head_dim={self.head_dim}, "
f"block={self.block_size}, rms_norm_eps={self.rms_norm_eps}")
print(f" Target layers: {self.target_layer_ids}, mask_token={self.mask_token_id}")
# Build layers
self.layers = []
for i in range(self.num_layers):
self.layers.append(DFlashDecoderLayer(
self.hidden_size, self.num_heads, self.num_kv_heads,
self.head_dim, self.intermediate_size, self.rope_theta,
rms_norm_eps=self.rms_norm_eps))
n_target = len(self.target_layer_ids)
self.fc = nn.Linear(n_target * self.hidden_size, self.hidden_size, bias=False)
self.hidden_norm = nn.RMSNorm(self.hidden_size, eps=self.rms_norm_eps)
self.norm = nn.RMSNorm(self.hidden_size, eps=self.rms_norm_eps)
# Shared from target
self.embed_tokens = self._inner.embed_tokens
if hasattr(self._text_model, 'lm_head'):
self.lm_head = self._text_model.lm_head
else:
self.lm_head = None
# Draft KV cache: KVCache per layer (pre-allocated buffers)
self.draft_cache = None
# Load weights
weights = mx.load(os.path.join(model_dir, 'model.safetensors'))
self._load_weights(weights)
total_params = sum(w.size for w in weights.values())
print(f" DFlash loaded: {len(weights)} tensors, {total_params / 1e6:.1f}M params")
def _load_weights(self, weights):
self.fc.weight = weights['fc.weight']
self.hidden_norm.weight = weights['hidden_norm.weight']
self.norm.weight = weights['norm.weight']
for i, layer in enumerate(self.layers):
p = f'layers.{i}'
attn = layer.self_attn
attn.q_proj.weight = weights[f'{p}.self_attn.q_proj.weight']
attn.k_proj.weight = weights[f'{p}.self_attn.k_proj.weight']
attn.v_proj.weight = weights[f'{p}.self_attn.v_proj.weight']
attn.o_proj.weight = weights[f'{p}.self_attn.o_proj.weight']
attn.q_norm.weight = weights[f'{p}.self_attn.q_norm.weight']
attn.k_norm.weight = weights[f'{p}.self_attn.k_norm.weight']
layer.mlp_gate.weight = weights[f'{p}.mlp.gate_proj.weight']
layer.mlp_up.weight = weights[f'{p}.mlp.up_proj.weight']
layer.mlp_down.weight = weights[f'{p}.mlp.down_proj.weight']
layer.input_layernorm.weight = weights[f'{p}.input_layernorm.weight']
layer.post_attention_layernorm.weight = weights[f'{p}.post_attention_layernorm.weight']
def reset_draft_cache(self):
self.draft_cache = [KVCache() for _ in range(self.num_layers)]
def crop_draft_cache(self, length):
"""Crop draft KV cache to given length (discard speculative entries)."""
for cache in self.draft_cache:
cache.offset = length
def get_target_hidden(self, inputs, cache):
"""Run target model, capture hidden states at specified layers + logits."""
model = self._inner
if hasattr(model, 'embed_tokens'):
hidden_states = model.embed_tokens(inputs)
else:
hidden_states = inputs
cache_list = cache if cache is not None else [None] * len(model.layers)
from mlx_lm.models.qwen3_5 import create_attention_mask, create_ssm_mask
fa_mask = create_attention_mask(hidden_states, cache_list[model.fa_idx])
ssm_mask = create_ssm_mask(hidden_states, cache_list[model.ssm_idx])
layer_hiddens = {}
for i, (layer, c) in enumerate(zip(model.layers, cache_list)):
mask = ssm_mask if layer.is_linear else fa_mask
hidden_states = layer(hidden_states, mask=mask, cache=c)
if i in self.target_layer_ids:
layer_hiddens[i] = hidden_states
selected = [layer_hiddens[i] for i in self.target_layer_ids]
target_hidden = mx.concatenate(selected, axis=-1)
normed = model.norm(hidden_states)
if self.lm_head is not None:
logits = self.lm_head(normed)
else:
logits = self.embed_tokens.as_linear(normed)
return target_hidden, logits
def draft(self, target_hidden, block_output_ids, start):
"""Run DFlash draft model.
Args:
target_hidden: (B, accepted_len, n_layers * hidden) — hidden states for
accepted positions from the previous verify step
block_output_ids: (B, block_size) — [accepted_token, MASK, ..., MASK]
start: int — position of the first token in the block
Returns:
draft_logits: (B, block_size-1, vocab) — logits for mask positions
"""
bs = self.block_size
# Noise embedding from target's embed_tokens
noise_embedding = self.embed_tokens(block_output_ids) # (B, bs, hidden)
# Project target hidden states
ctx = self.hidden_norm(self.fc(target_hidden)) # (B, accepted_len, hidden)
# Run through layers
h = noise_embedding
for i, layer in enumerate(self.layers):
h = layer(h, ctx, q_offset=start, cache=self.draft_cache[i])
# Final norm + lm_head on mask positions (1..bs-1)
h = self.norm(h)
draft_hidden = h[:, -(bs - 1):, :]
if self.lm_head is not None:
draft_logits = self.lm_head(draft_hidden)
else:
draft_logits = self.embed_tokens.as_linear(draft_hidden)
return draft_logits
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""DFlash-specific speculative forward pass.
Combines:
1. Multi-layer hidden state capture (for DFlash draft context)
2. GDN SpeculativeArraysCache wrapping + speculative kernel swap (for rollback)
Single forward pass returns (target_hidden, pre_norm, logits) with rollback-ready caches.
"""
import mlx.core as mx
def dflash_speculative_forward(model, inputs, cache, target_layer_ids, speculative=False):
"""Run model forward, capture multi-layer hidden states + optional GDN rollback.
Args:
model: the loaded model
inputs: (B, S) int token ids
cache: cache list from make_prompt_cache()
target_layer_ids: list of layer indices to capture hidden states from
speculative: if True, wraps GDN caches for rollback
Returns:
(target_hidden, pre_norm, logits)
- target_hidden: (B, S, n_layers * hidden) — concatenated hidden states from target layers
- pre_norm: (B, S, hidden) — pre-RMSNorm hidden states
- logits: (B, S, vocab) — output logits
"""
from .mtp_module import _make_speculative_gdu
inner = getattr(model, 'model', None) or model.language_model.model
text_model = getattr(model, 'model', None) or model.language_model
S = inputs.shape[1]
do_spec = speculative and S > 1
if hasattr(inner, 'embed_tokens'):
hidden_states = inner.embed_tokens(inputs)
else:
hidden_states = inputs
cache_list = cache if cache is not None else [None] * len(inner.layers)
# GDN rollback setup
gdn_spec_data = []
if do_spec:
from .speculative_cache import SpeculativeArraysCache
for i, c in enumerate(cache_list):
if c is not None and hasattr(c, 'cache') and not hasattr(c, 'offset'):
cache_list[i] = SpeculativeArraysCache(c, S=S)
if cache is not None:
for i in range(len(cache)):
cache[i] = cache_list[i]
spec_all_states = []
if do_spec:
import mlx_lm.models.qwen3_5 as _qwen3_5_mod
_orig_gdu = _qwen3_5_mod.gated_delta_update
_qwen3_5_mod.gated_delta_update = _make_speculative_gdu(spec_all_states)
from mlx_lm.models.qwen3_5 import create_attention_mask, create_ssm_mask
fa_mask = create_attention_mask(hidden_states, cache_list[inner.fa_idx])
ssm_mask = create_ssm_mask(hidden_states, cache_list[inner.ssm_idx])
# Layer loop — capture multi-layer hidden + GDN pre-conv state
target_layer_ids_set = set(target_layer_ids)
layer_hiddens = {}
for i, (layer, c) in enumerate(zip(inner.layers, cache_list)):
mask = ssm_mask if layer.is_linear else fa_mask
if do_spec and layer.is_linear:
from .speculative_cache import SpeculativeArraysCache as _SAC
if isinstance(c, _SAC):
pre_conv = c[0]
if pre_conv is None:
gdn = layer.linear_attn
pre_conv = mx.zeros(
(hidden_states.shape[0], gdn.conv_kernel_size - 1,
gdn.conv_dim), dtype=hidden_states.dtype)
gdn_spec_data.append((hidden_states, pre_conv, c, layer))
hidden_states = layer(hidden_states, mask=mask, cache=c)
# Capture hidden states at target layers
if i in target_layer_ids_set:
layer_hiddens[i] = hidden_states
# Restore original kernel + distribute all_states + conv_input
if do_spec:
_qwen3_5_mod.gated_delta_update = _orig_gdu
gdn_idx = 0
for layer_input, pre_conv, spec_cache, parent_layer in gdn_spec_data:
if gdn_idx < len(spec_all_states):
spec_cache.all_states = spec_all_states[gdn_idx]
gdn_idx += 1
gdn = parent_layer.linear_attn
normed = parent_layer.input_layernorm(layer_input)
if hasattr(gdn, 'in_proj_qkv'):
qkv = gdn.in_proj_qkv(normed)
else:
q, k, v, z, b, a = gdn.fix_query_key_value_ordering(
gdn.in_proj_qkvz(normed), gdn.in_proj_ba(normed))
B_dim = normed.shape[0]
qkv = mx.concatenate(
[q.reshape(B_dim, S, -1), k.reshape(B_dim, S, -1),
v.reshape(B_dim, S, -1)], axis=-1)
spec_cache.conv_input = mx.concatenate([pre_conv, qkv], axis=1)
# Concatenate multi-layer hidden states
selected = [layer_hiddens[i] for i in target_layer_ids]
target_hidden = mx.concatenate(selected, axis=-1)
# Final norm + lm_head
pre_norm = hidden_states
normed = inner.norm(hidden_states)
if hasattr(text_model, 'lm_head'):
logits = text_model.lm_head(normed)
else:
logits = inner.embed_tokens.as_linear(normed)
return target_hidden, pre_norm, logits
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""MTP Speculative Decoding integrated with mlx_lm's BatchGenerator.
Subclasses BatchGenerator to add MTP drafting + S>1 verification with
correct GDN state rollback via SpeculativeArraysCache.
At BS=1: drafts γ tokens with MTP, verifies at S=γ+1, buffers accepted tokens.
At BS>1: falls back to standard BatchGenerator (no speculative).
Usage:
from mtp_batch_generator import MTPBatchGenerator
gen = MTPBatchGenerator(model, mtp_predictor, gamma=2, ...)
gen.insert([prompt_tokens])
while True:
responses = gen.next()
"""
import time
import mlx.core as mx
from mlx_lm.generate import BatchGenerator, generation_stream
from .mtp_module import MTPPredictor, speculative_forward, draft_tokens
class MTPBatchGenerator(BatchGenerator):
"""BatchGenerator with MTP speculative decoding for BS=1."""
def __init__(
self,
model,
mtp_predictor: MTPPredictor,
gamma: int = 2,
temp: float = 0.0,
alpha: float = 1.0,
**kwargs,
):
super().__init__(model, **kwargs)
self.mtp = mtp_predictor
self.gamma = gamma
self.temp = temp
self.alpha = alpha
self._token_buffer = {} # uid → [(token, logprobs), ...]
self._captured = {} # pre_norm / prompt_pre_norm from norm wrapper
self._mtp_pre_norm = {} # uid → (B, 1, D) pre-norm hidden state
self._mtp_prefilled = set() # uids with MTP cache prefilled
self._request_temp = {} # uid → temperature from request
self._setup_hidden_capture()
def _setup_hidden_capture(self):
"""Monkey-patch model's final norm to capture pre-norm hidden state.
Captures:
- pre_norm: hidden states before final RMSNorm (for MTP input)
- prompt_pre_norm: same but only when S>1 (prefill)
"""
inner = getattr(self.model, 'model', None) or self.model.language_model.model
original_norm = inner.norm
captured = self._captured
class _CapturingNorm:
def __init__(self, orig):
self._orig = orig
self.weight = orig.weight
def __call__(self, x):
captured['pre_norm'] = x
if x.shape[1] > 1:
captured['prompt_pre_norm'] = x
return self._orig(x)
def __getattr__(self, name):
return getattr(self._orig, name)
inner.norm = _CapturingNorm(original_norm)
def _next(self):
batch = self.active_batch
# Yield buffered tokens first
if batch is not None and len(batch) == 1:
uid = batch.uids[0]
if uid in self._token_buffer and self._token_buffer[uid]:
return self._yield_buffered(batch, uid)
# BS=1 speculative path
if (batch is not None
and len(batch) == 1
and self.gamma > 0
and len(self.unprocessed_prompts) == 0):
uid = batch.uids[0]
if uid not in self._mtp_prefilled:
return self._first_step_and_prefill(batch, uid)
return self._speculative_next()
# Standard path (BS>1 or no batch)
responses = super()._next()
if responses and batch is not None and len(batch) == 1:
if 'pre_norm' in self._captured:
uid = batch.uids[0]
self._mtp_pre_norm[uid] = self._captured['pre_norm'][:, -1:, :]
return responses
def _first_step_and_prefill(self, batch, uid):
"""First decode step. MTP cache already prefilled by ExoBatchGenerator.submit()."""
responses = super()._next()
if not responses:
return responses
# Capture decode pre_norm from this standard step for first speculative cycle
decode_pre_norm = self._captured.get('pre_norm')
if decode_pre_norm is not None:
mx.eval(decode_pre_norm)
self._mtp_pre_norm[uid] = decode_pre_norm[:, -1:, :]
self._mtp_prefilled.add(uid)
return responses
def _speculative_next(self):
"""Core speculative cycle with correct GDN rollback."""
tic = time.perf_counter()
batch = self.active_batch
uid = batch.uids[0]
y = batch.y # (1,) — token from previous step, to be yielded
y_val = y[0].item()
y_logprobs = batch.logprobs[0]
# Append current y to token history
batch.tokens[0] = mx.concatenate((batch.tokens[0], y[0:1]))
pre_norm = self._mtp_pre_norm.get(uid)
if pre_norm is None:
return super()._next()
gamma = self.gamma
temp = self._request_temp.get(uid, self.temp)
alpha = self.alpha
# 1. Draft γ tokens (lazy chain, no eval)
next_token_arr = y.reshape(1, 1)
draft_ids, draft_probs = draft_tokens(
self.mtp, pre_norm, next_token_arr, gamma, temp)
# 2. Verify via speculative_forward (handles GDN cache wrapping + kernel swap)
draft_concat = mx.concatenate(
[d.reshape(1, 1) for d in draft_ids], axis=1) # (1, γ)
verify_input = mx.concatenate(
[next_token_arr, draft_concat], axis=1) # (1, γ+1)
verify_pre_norm, verify_logits = speculative_forward(
self.model, verify_input, batch.cache, speculative=True)
# 3. Build acceptance check lazily
target_tokens = mx.argmax(verify_logits[:, :gamma, :], axis=-1)
if temp == 0:
matches = mx.equal(target_tokens, draft_concat).squeeze(0)
all_next = mx.argmax(verify_logits[0], axis=-1)
logprobs_all = verify_logits[0] - mx.logsumexp(
verify_logits[0], axis=-1, keepdims=True)
mx.async_eval(matches, all_next, logprobs_all, verify_pre_norm)
else:
accept_ratios = []
for i in range(gamma):
p = mx.softmax(verify_logits[0, i] / temp, axis=-1)
q = draft_probs[i]
p_di = p[draft_ids[i].squeeze()]
q_di = q[0, draft_ids[i].squeeze()]
ratio = p_di / mx.maximum(q_di, 1e-10)
accept_ratios.append(mx.minimum(ratio ** alpha, 1.0))
uniforms = mx.random.uniform(shape=(gamma,))
corrections = []
for i in range(gamma):
p = mx.softmax(verify_logits[0, i] / temp, axis=-1)
q = draft_probs[i][0]
residual = mx.maximum(p - q, 0.0)
corrections.append(mx.random.categorical(mx.log(residual + 1e-10)))
bonus_token = mx.random.categorical(verify_logits[0, gamma] * (1.0 / temp))
logprobs_all = verify_logits[0] - mx.logsumexp(
verify_logits[0], axis=-1, keepdims=True)
mx.async_eval(accept_ratios, uniforms, corrections, bonus_token,
logprobs_all, verify_pre_norm, draft_concat)
# 4. Determine acceptance
n_accepted = 0
for i in range(gamma):
if temp == 0:
if matches[i].item():
n_accepted += 1
else:
break
else:
if uniforms[i].item() < accept_ratios[i].item():
n_accepted += 1
else:
break
print(f"[MTP] n_accepted={n_accepted}/{gamma}", flush=True)
# 5. Rollback cache
rollback = gamma - n_accepted
if rollback > 0:
for c in batch.cache:
if hasattr(c, 'offset'):
c.offset -= rollback
elif hasattr(c, 'rollback'):
c.rollback(n_accepted)
# Unwrap SpeculativeArraysCache
for i, c in enumerate(batch.cache):
if hasattr(c, 'base'):
batch.cache[i] = c.base
# 6. Bonus/correction token + logprobs
if n_accepted == gamma:
if temp == 0:
bonus_val = all_next[gamma].item()
else:
bonus_val = bonus_token.item()
bonus_lp = logprobs_all[gamma]
else:
if temp == 0:
bonus_val = all_next[n_accepted].item()
else:
bonus_val = corrections[n_accepted].item()
bonus_lp = logprobs_all[n_accepted]
# 7. Update MTP pre_norm for next cycle
self._mtp_pre_norm[uid] = verify_pre_norm[
:, (gamma if n_accepted == gamma else n_accepted):
(gamma if n_accepted == gamma else n_accepted) + 1, :]
# 8. Build token list: current y + accepted drafts
draft_int_values = draft_concat[0].tolist()
all_tokens = [(y_val, y_logprobs)]
for i in range(n_accepted):
all_tokens.append((draft_int_values[i], logprobs_all[i]))
# 9. Set batch.y = bonus for next cycle
batch.y = mx.array([bonus_val])
batch.logprobs = [bonus_lp]
# Append accepted drafts to token history
if n_accepted > 0:
batch.tokens[0] = mx.concatenate(
(batch.tokens[0], mx.array([t for t, _ in all_tokens[1:]])))
batch.num_tokens[0] += len(all_tokens)
# 10. Check stop conditions — truncate at stop token
toc = time.perf_counter()
self._stats.generation_time += toc - tic
self._stats.generation_tokens += len(all_tokens)
# Find first stop token or length limit in all_tokens
stop_idx = None
for idx, (tok, _) in enumerate(all_tokens):
if tok in self.stop_tokens:
stop_idx = idx
break
if batch.num_tokens[0] >= batch.max_tokens[0]:
stop_idx = idx
break
first_tok, first_lp = all_tokens[0]
if stop_idx is not None:
# Tokens before the stop are valid output — buffer them
# The stop token itself triggers finish_reason
valid_tokens = all_tokens[:stop_idx]
if valid_tokens:
# Yield first, buffer rest + a final stop entry
if len(valid_tokens) > 1:
self._token_buffer[uid] = valid_tokens[1:]
# Append stop marker as last buffered token
stop_tok, stop_lp = all_tokens[stop_idx]
if uid not in self._token_buffer:
self._token_buffer[uid] = []
self._token_buffer[uid].append((stop_tok, stop_lp))
mx.async_eval(batch.y)
return [self.Response(uid, first_tok, first_lp, None, lambda: None)]
else:
# Stop token is the first token — finish immediately
cache = batch.extract_cache(0)
self.active_batch = None
self._cleanup_uid(uid)
return [self.Response(uid, first_tok, first_lp, "stop", cache)]
# Buffer remaining tokens
if len(all_tokens) > 1:
self._token_buffer[uid] = all_tokens[1:]
mx.async_eval(batch.y)
return [self.Response(uid, first_tok, first_lp, None, lambda: None)]
def _yield_buffered(self, batch, uid):
"""Yield one buffered token from a previous speculative cycle."""
tic = time.perf_counter()
buf = self._token_buffer[uid]
tok, lp = buf.pop(0)
if not buf:
del self._token_buffer[uid]
finish_reason = None
if tok in self.stop_tokens:
finish_reason = "stop"
elif batch.num_tokens[0] >= batch.max_tokens[0]:
finish_reason = "length"
cache = None
if finish_reason:
cache = batch.extract_cache(0)
self.active_batch = None
self._cleanup_uid(uid)
toc = time.perf_counter()
self._stats.generation_time += toc - tic
return [self.Response(uid, tok, lp, finish_reason, cache or (lambda: None))]
def _cleanup_uid(self, uid):
"""Clean up MTP state for a finished request."""
self._mtp_pre_norm.pop(uid, None)
self._mtp_prefilled.discard(uid)
self._token_buffer.pop(uid, None)
self._request_temp.pop(uid, None)
@@ -0,0 +1,515 @@
#!/usr/bin/env python3
"""MTP (Multi-Token Prediction) module for Qwen3.5-27B.
Architecture (from llama.cpp build_mtp_head + HuggingFace config):
1. Normalize: pre_fc_norm_hidden(hidden_state) || pre_fc_norm_embedding(embed(token))
2. Combine: fc(concat([e_norm, h_norm])) 5120
3. 1 GQA decoder layer (same config as main model's full-attention layers)
- Attention with Q/K RMSNorm + partial RoPE + output gate
4. Final norm shared lm_head vocab logits
Predicts token t+2 given the main model's hidden state at position t
and the token sampled at position t+1.
Usage:
from .mtp_module import MTPPredictor
mtp = MTPPredictor(model, "mtp_weights.safetensors")
# During decode:
pre_norm, normed = mtp.get_hidden_state(input_tokens, cache)
logits_t1 = mtp.apply_lm_head(normed) # token t+1
logits_t2 = mtp.predict(pre_norm, token_t1) # token t+2
"""
import mlx.core as mx
import mlx.nn as nn
def speculative_forward(model, inputs, cache, speculative=False):
"""Run model forward pass, optionally capturing GDN per-step states for rollback.
This is the shared core for both MTP and draft-model speculative decoding.
It manually iterates model layers to:
1. Wrap GDN caches in SpeculativeArraysCache when speculative=True
2. Patch gated_delta_update to use the speculative kernel
3. Capture per-step recurrent states and reconstruct conv_input
Args:
model: the loaded model (e.g. from mlx_lm.load)
inputs: (B, S) int token ids
cache: cache list from make_prompt_cache()
speculative: if True, saves per-step GDN states for rollback
Returns:
(pre_norm, logits) pre-RMSNorm hidden states and vocab logits
"""
inner = getattr(model, 'model', None) or model.language_model.model
text_model = getattr(model, 'model', None) or model.language_model
S = inputs.shape[1]
do_spec = speculative and S > 1
if hasattr(inner, 'embed_tokens'):
hidden_states = inner.embed_tokens(inputs)
else:
hidden_states = inputs
cache_list = cache if cache is not None else [None] * len(inner.layers)
gdn_spec_data = []
if do_spec:
from .speculative_cache import SpeculativeArraysCache
for i, c in enumerate(cache_list):
if c is not None and hasattr(c, 'cache') and not hasattr(c, 'offset'):
cache_list[i] = SpeculativeArraysCache(c, S=S)
if cache is not None:
for i in range(len(cache)):
cache[i] = cache_list[i]
spec_all_states = []
if do_spec:
import mlx_lm.models.qwen3_5 as _qwen3_5_mod
_orig_gdu = _qwen3_5_mod.gated_delta_update
_qwen3_5_mod.gated_delta_update = _make_speculative_gdu(spec_all_states)
from mlx_lm.models.qwen3_5 import create_attention_mask, create_ssm_mask
fa_mask = create_attention_mask(hidden_states, cache_list[inner.fa_idx])
ssm_mask = create_ssm_mask(hidden_states, cache_list[inner.ssm_idx])
for layer, c in zip(inner.layers, cache_list):
mask = ssm_mask if layer.is_linear else fa_mask
if do_spec and layer.is_linear:
from .speculative_cache import SpeculativeArraysCache as _SAC
if isinstance(c, _SAC):
pre_conv = c[0]
if pre_conv is None:
gdn = layer.linear_attn
pre_conv = mx.zeros(
(hidden_states.shape[0], gdn.conv_kernel_size - 1,
gdn.conv_dim), dtype=hidden_states.dtype)
gdn_spec_data.append((hidden_states, pre_conv, c, layer))
hidden_states = layer(hidden_states, mask=mask, cache=c)
if do_spec:
_qwen3_5_mod.gated_delta_update = _orig_gdu
gdn_idx = 0
for layer_input, pre_conv, spec_cache, parent_layer in gdn_spec_data:
if gdn_idx < len(spec_all_states):
spec_cache.all_states = spec_all_states[gdn_idx]
gdn_idx += 1
gdn = parent_layer.linear_attn
normed = parent_layer.input_layernorm(layer_input)
if hasattr(gdn, 'in_proj_qkv'):
qkv = gdn.in_proj_qkv(normed)
else:
q, k, v, z, b, a = gdn.fix_query_key_value_ordering(
gdn.in_proj_qkvz(normed), gdn.in_proj_ba(normed))
B_dim = normed.shape[0]
qkv = mx.concatenate(
[q.reshape(B_dim, S, -1), k.reshape(B_dim, S, -1),
v.reshape(B_dim, S, -1)], axis=-1)
spec_cache.conv_input = mx.concatenate([pre_conv, qkv], axis=1)
pre_norm = hidden_states
normed = inner.norm(hidden_states)
if hasattr(text_model, 'lm_head'):
logits = text_model.lm_head(normed)
else:
logits = inner.embed_tokens.as_linear(normed)
return pre_norm, logits
def _make_speculative_gdu(all_states_list):
"""Create a gated_delta_update replacement that uses the speculative kernel.
The speculative kernel is identical to the original but also outputs
per-step recurrent states (all_states). These are appended to
all_states_list for later assignment to SpeculativeArraysCache wrappers.
Returns (y, state_out) same interface as original gated_delta_update.
"""
from .speculative_gdn_kernel import speculative_gated_delta_kernel
from mlx_lm.models.gated_delta import compute_g
def speculative_gated_delta_update(q, k, v, a, b, A_log, dt_bias,
state=None, mask=None, use_kernel=True):
beta = mx.sigmoid(b)
g = compute_g(A_log, a, dt_bias)
if state is None:
B, _, Hk, Dk = q.shape
Hv, Dv = v.shape[-2:]
state = mx.zeros((B, Hv, Dv, Dk), dtype=q.dtype)
y, state_out, all_states = speculative_gated_delta_kernel(
q, k, v, g, beta, state, mask)
all_states_list.append(all_states)
return y, state_out
return speculative_gated_delta_update
class MTPPredictor:
"""MTP draft predictor for speculative decoding.
Wraps the MTP module weights and provides:
- get_hidden_state(): extract pre-lm_head hidden state from main model
- predict(): run MTP to get next-next-token logits
"""
def __init__(self, model, mtp_weights_path, quantize=True):
"""Load MTP weights and attach to the main model.
Args:
model: loaded Qwen3.5-27B model
mtp_weights_path: path to mtp_weights.safetensors
quantize: quantize MTP linears to 8-bit gs=64
"""
self.model = model
self._inner = getattr(model, 'model', None) or model.language_model.model
self._text_model = getattr(model, 'model', None) or model.language_model
# Shared components
self.embed_tokens = self._inner.embed_tokens
if hasattr(self._text_model, 'lm_head'):
self.lm_head = self._text_model.lm_head
else:
# tie_word_embeddings case
self.lm_head = None
# Load MTP weights
weights = mx.load(mtp_weights_path)
# ---- Sanitize norm weights ----
# CRITICAL: Qwen3.5 HuggingFace format stores ALL norm weights as (actual - 1.0).
# mlx-lm's TextModel.sanitize() adds +1.0 back for the main model norms, but
# MTP weights are stripped before sanitize runs. We must apply the same shift
# to ALL 1-D norm weights in the MTP.
#
# Evidence: pre_fc_norm_hidden has mean=-0.17 raw → 0.83 after shift (plausible).
# Linear projection weights (2-D) are NOT shifted.
shifted = []
for k in list(weights.keys()):
if weights[k].ndim == 1:
weights[k] = weights[k] + 1.0
shifted.append(k)
if shifted:
print(f" Sanitized {len(shifted)} norm weights (+1.0 shift)")
# Infer all dimensions from weight shapes (works for any Qwen3.5 size)
fc_w = weights['mtp.fc.weight']
hidden_size = fc_w.shape[0] # 4096 (9B) or 5120 (27B)
fc_in = fc_w.shape[1] # 2 * hidden_size
q_w = weights['mtp.layers.0.self_attn.q_proj.weight']
q_out = q_w.shape[0] # num_heads * head_dim * 2 (gate)
k_w = weights['mtp.layers.0.self_attn.k_proj.weight']
kv_out = k_w.shape[0] # num_kv_heads * head_dim
o_w = weights['mtp.layers.0.self_attn.o_proj.weight']
o_in = o_w.shape[1] # num_heads * head_dim
# Detect MoE vs dense MLP
self.is_moe = 'mtp.layers.0.mlp.gate.weight' in weights
if not self.is_moe:
gate_w = weights['mtp.layers.0.mlp.gate_proj.weight']
intermediate = gate_w.shape[0]
else:
intermediate = 0 # MoE experts handle this
# head_dim from q_norm weight (always per-head)
head_dim = weights.get('mtp.layers.0.self_attn.q_norm.weight',
mx.ones(256)).shape[0]
num_heads = o_in // head_dim
num_kv_heads = kv_out // head_dim
print(f" Dims: hidden={hidden_size}, heads={num_heads}, kv_heads={num_kv_heads}, "
f"head_dim={head_dim}, MLP={'MoE' if self.is_moe else f'dense({intermediate})'}")
# Build layers from weights — all dimension-agnostic
def make_linear(w):
out_dim, in_dim = w.shape
l = nn.Linear(in_dim, out_dim, bias=False)
l.weight = w
return l
self.pre_fc_norm_hidden = nn.RMSNorm(hidden_size)
self.pre_fc_norm_hidden.weight = weights['mtp.pre_fc_norm_hidden.weight']
self.pre_fc_norm_embedding = nn.RMSNorm(hidden_size)
self.pre_fc_norm_embedding.weight = weights['mtp.pre_fc_norm_embedding.weight']
self.fc = make_linear(fc_w)
self.q_proj = make_linear(q_w)
self.k_proj = make_linear(k_w)
self.v_proj = make_linear(weights['mtp.layers.0.self_attn.v_proj.weight'])
self.o_proj = make_linear(o_w)
self.q_norm = nn.RMSNorm(head_dim)
self.k_norm = nn.RMSNorm(head_dim)
q_norm_key = 'mtp.layers.0.self_attn.q_norm.weight'
k_norm_key = 'mtp.layers.0.self_attn.k_norm.weight'
if q_norm_key in weights:
self.q_norm.weight = weights[q_norm_key]
self.k_norm.weight = weights[k_norm_key]
self.input_layernorm = nn.RMSNorm(hidden_size)
self.input_layernorm.weight = weights['mtp.layers.0.input_layernorm.weight']
self.post_attention_layernorm = nn.RMSNorm(hidden_size)
self.post_attention_layernorm.weight = weights['mtp.layers.0.post_attention_layernorm.weight']
if self.is_moe:
# Reuse mlx-lm's SparseMoeBlock from the target model
moe_layer = None
for layer in self._inner.layers:
if hasattr(layer, 'mlp') and hasattr(layer.mlp, 'gate'):
moe_layer = layer.mlp
break
if moe_layer is None:
raise RuntimeError("MTP has MoE weights but target model has no MoE layer")
# Create a new MoE block with same class/config as target
moe_class = type(moe_layer)
args = getattr(self._text_model, 'args', None)
if args is None and hasattr(self._text_model, 'model'):
args = getattr(self._text_model.model, 'args', None)
self.mlp = moe_class(args)
# Load MTP MoE weights — remap HF expert names to mlx-lm SwitchLinear
prefix = 'mtp.layers.0.mlp.'
# Direct weights: gate, shared_expert, shared_expert_gate
direct_keys = {}
expert_weights = {} # {proj_name: {expert_idx: weight}}
for k, v in weights.items():
if not k.startswith(prefix):
continue
name = k[len(prefix):]
# Check if it's an individual expert weight
if name.startswith('experts.'):
# experts.N.{gate,up,down}_proj.weight → stack into switch_mlp
parts = name.split('.')
idx = int(parts[1])
proj = parts[2] # gate_proj, up_proj, down_proj
key = f'{proj}.{parts[3]}' # gate_proj.weight
if key not in expert_weights:
expert_weights[key] = {}
expert_weights[key][idx] = v
else:
direct_keys[name] = v
# Stack individual expert weights into SwitchLinear format
moe_weights = []
for proj_key, idx_map in expert_weights.items():
n_experts = max(idx_map.keys()) + 1
stacked = mx.stack([idx_map[i] for i in range(n_experts)])
moe_weights.append((f'switch_mlp.{proj_key}', stacked))
# Add direct weights
for name, v in direct_keys.items():
moe_weights.append((name, v))
self.mlp.load_weights(moe_weights)
print(f" MoE MLP: {len(moe_weights)} weight groups loaded "
f"({len(expert_weights)} stacked expert projections)")
else:
self.gate_proj = make_linear(gate_w)
self.up_proj = make_linear(weights['mtp.layers.0.mlp.up_proj.weight'])
self.down_proj = make_linear(weights['mtp.layers.0.mlp.down_proj.weight'])
self.norm = nn.RMSNorm(hidden_size)
self.norm.weight = weights['mtp.norm.weight']
# RoPE from main model's GQA layers
for layer in self._inner.layers:
if not layer.is_linear:
self.rope = layer.self_attn.rope
break
# GQA config
self.num_heads = num_heads
self.num_kv_heads = num_kv_heads
self.head_dim = head_dim
self.scale = head_dim ** -0.5
# MTP KV cache (separate from main model)
self.kv_cache = None
mx.eval(self.pre_fc_norm_hidden.weight, self.pre_fc_norm_embedding.weight,
self.fc.weight, self.input_layernorm.weight,
self.post_attention_layernorm.weight, self.norm.weight,
self.q_norm.weight, self.k_norm.weight)
if quantize:
self._quantize_linears()
total_params = sum(w.size for w in weights.values())
print(f" MTP loaded: {len(weights)} tensors, {total_params / 1e6:.1f}M params"
f"{' (quantized 8-bit gs=64)' if quantize else ' (bf16)'}")
def _quantize_linears(self):
"""Quantize all MTP linear layers to 8-bit gs=64."""
for name in ['fc', 'q_proj', 'k_proj', 'v_proj', 'o_proj',
'gate_proj', 'up_proj', 'down_proj']:
linear = getattr(self, name)
linear.weight = linear.weight.astype(mx.bfloat16)
q = nn.QuantizedLinear.from_linear(linear, group_size=64, bits=8)
mx.eval(q.parameters())
setattr(self, name, q)
def reset_cache(self):
"""Reset the MTP KV cache (call at start of generation)."""
from mlx_lm.models.cache import KVCache
self.kv_cache = KVCache()
def get_hidden_state(self, inputs, cache, speculative=False):
"""Run main model and return pre-norm hidden states + logits.
Delegates to the shared speculative_forward() function.
"""
return speculative_forward(self.model, inputs, cache, speculative)
def _attn_mlp(self, h):
"""Run GQA attention + MLP. Shared by predict, predict_hidden, predict_from_hidden."""
B, S = h.shape[0], h.shape[1]
residual = h
h = self.input_layernorm(h)
q_out = self.q_proj(h)
q_out, gate = mx.split(
q_out.reshape(B, S, self.num_heads, -1), 2, axis=-1
)
gate = gate.reshape(B, S, -1)
queries = self.q_norm(q_out).transpose(0, 2, 1, 3)
keys = self.k_norm(
self.k_proj(h).reshape(B, S, self.num_kv_heads, self.head_dim)
).transpose(0, 2, 1, 3)
values = self.v_proj(h).reshape(
B, S, self.num_kv_heads, self.head_dim
).transpose(0, 2, 1, 3)
if self.kv_cache is not None:
offset = self.kv_cache.offset
queries = self.rope(queries, offset=offset)
keys = self.rope(keys, offset=offset)
keys, values = self.kv_cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
mask = None
if S > 1:
total_kv = keys.shape[2]
q_pos = mx.arange(S) + (total_kv - S)
k_pos = mx.arange(total_kv)
mask = mx.where(k_pos[None, :] <= q_pos[:, None],
mx.array(0, dtype=queries.dtype),
mx.array(-1e9, dtype=queries.dtype))
output = mx.fast.scaled_dot_product_attention(
queries, keys, values, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, S, -1)
h = residual + self.o_proj(output * mx.sigmoid(gate))
residual = h
h = self.post_attention_layernorm(h)
if self.is_moe:
h = residual + self.mlp(h)
else:
h = residual + self.down_proj(nn.silu(self.gate_proj(h)) * self.up_proj(h))
return h # post-FFN, pre-norm
def _combine(self, hidden_state, token_ids):
"""Combine hidden state + token embedding → fc input."""
B, S = hidden_state.shape[0], hidden_state.shape[1]
embed = self.embed_tokens(token_ids.reshape(B, S))
h_norm = self.pre_fc_norm_hidden(hidden_state)
e_norm = self.pre_fc_norm_embedding(embed)
return self.fc(mx.concatenate([e_norm, h_norm], axis=-1))
def predict(self, hidden_state, token_ids, return_hidden=False, draft_mode=False):
"""Predict next-next-token logits using MTP.
Args:
hidden_state: (B, S, D) bf16 PRE-NORM hidden states
token_ids: (B, S) or (S,) int tokens at each position
return_hidden: if True, also return pre-norm hidden for chaining
draft_mode: if True, use truncated lm_head (32K vocab) for speed
Returns:
logits: (B, S, vocab_size) if S>1, (B, vocab_size) if S=1
If return_hidden: (logits, hidden)
"""
S = hidden_state.shape[1]
h = self._combine(hidden_state, token_ids)
pre_norm_out = self._attn_mlp(h)
normed = self.norm(pre_norm_out)
if draft_mode:
logits = normed @ self.draft_lm_head_weight.T
elif self.lm_head is not None:
logits = self.lm_head(normed)
else:
logits = self.embed_tokens.as_linear(normed)
if S == 1:
logits = logits.squeeze(1)
if return_hidden:
return logits, pre_norm_out
return logits
def predict_hidden(self, hidden_state, token_ids):
"""Like predict() but returns only post-FFN hidden state (no lm_head)."""
h = self._combine(hidden_state, token_ids)
return self._attn_mlp(h)
def predict_from_hidden(self, prev_hidden):
"""MTP step using post_norm of prev_hidden instead of token embedding.
Replaces embed_tokens + pre_fc_norm_embedding with just norm(prev_hidden).
This skips the lm_head argmax embed_tokens roundtrip.
"""
post_norm = self.norm(prev_hidden)
h_norm = self.pre_fc_norm_hidden(prev_hidden)
h = self.fc(mx.concatenate([post_norm, h_norm], axis=-1))
return self._attn_mlp(h)
def draft_tokens(mtp_pred, hidden, first_token_arr, gamma, temp, fast_lm_head=False):
"""Draft γ tokens by chaining MTP predictions — fully lazy, no mx.eval.
The entire chain stays in the MLX computation graph. Draft token ids
are lazy mx.arrays (argmax/categorical results), not Python ints.
Args:
first_token_arr: mx.array of shape (1,1) the token to start from
Returns: (draft_ids, draft_probs) where draft_ids[i] is a lazy mx.array
scalar, draft_probs[i] is the full draft distribution (or None if greedy)
"""
draft_ids = []
draft_probs = []
h = hidden
tok_arr = first_token_arr
for i in range(gamma):
logits, h = mtp_pred.predict(h, tok_arr, return_hidden=True,
draft_mode=fast_lm_head)
if temp == 0:
tok_arr = mx.argmax(logits, axis=-1).reshape(1, 1)
draft_ids.append(tok_arr.reshape(-1))
draft_probs.append(None)
else:
q = mx.softmax(logits / temp, axis=-1)
tok_arr = mx.random.categorical(logits * (1.0 / temp)).reshape(1, 1)
draft_ids.append(tok_arr.reshape(-1))
draft_probs.append(q)
return draft_ids, draft_probs
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""SpeculativeArraysCache — wraps ArraysCache for correct GDN rollback.
During speculative verification (S>1), captures:
- all_states: per-step recurrent states from the speculative kernel
- conv_input: full conv_input tensor for conv state rollback
On rejection, rollback(n_accepted) restores both recurrent and conv
state to the correct intermediate position.
"""
import mlx.core as mx
class SpeculativeArraysCache:
"""Wrapper around ArraysCache that supports rollback for speculative decode.
Delegates all normal cache operations to the underlying ArraysCache.
Adds all_states/conv_input storage and a rollback() method.
"""
def __init__(self, base_cache, S, conv_kernel_size=4):
self.base = base_cache
self._S = S
self.n_keep = conv_kernel_size - 1 # typically 3
self.all_states = None # [B, T, Hv, Dv, Dk] from speculative kernel
self.conv_input = None # [B, n_keep+S, conv_dim] for conv rollback
# Delegate cache operations
def __getitem__(self, idx):
return self.base[idx]
def __setitem__(self, idx, val):
self.base[idx] = val
@property
def cache(self):
return self.base.cache
@cache.setter
def cache(self, v):
self.base.cache = v
@property
def state(self):
return self.base.state
@state.setter
def state(self, v):
self.base.state = v
@property
def lengths(self):
return self.base.lengths
@lengths.setter
def lengths(self, v):
self.base.lengths = v
@property
def left_padding(self):
return self.base.left_padding
@left_padding.setter
def left_padding(self, v):
self.base.left_padding = v
def advance(self, N):
self.base.advance(N)
def make_mask(self, N):
return self.base.make_mask(N)
def empty(self):
return self.base.empty()
@property
def nbytes(self):
return self.base.nbytes
def rollback(self, n_accepted):
"""Roll back to state after processing n_accepted+1 tokens.
Args:
n_accepted: number of accepted draft tokens (0 = all rejected,
only the first token 'y' was processed correctly)
"""
# Recurrent state: restore intermediate state at accepted position
if self.all_states is not None:
self.base.cache[1] = self.all_states[0, n_accepted]
# Conv state: slice conv_input to the correct window
if self.conv_input is not None:
self.base.cache[0] = self.conv_input[:, n_accepted + 1: n_accepted + 1 + self.n_keep, :]
# Clear stored states
self.all_states = None
self.conv_input = None
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Speculative variant of the GatedDeltaNet kernel.
Identical to the original gated_delta_kernel but also outputs per-step
recurrent states for rollback during speculative decoding.
The original kernel only writes the FINAL state. This variant writes
the state at EVERY timestep to an extra output buffer `all_states`.
"""
from typing import Optional, Tuple
import mlx.core as mx
def _make_speculative_gated_delta_kernel(has_mask=False, vectorized=False):
mask_source = "mask[b_idx * T + t]" if has_mask else "true"
if vectorized:
g_comment = "// g: [B, T, Hv, Dk]"
g_setup = "auto g_ = g + (b_idx * T * Hv + hv_idx) * Dk;"
g_access = "g_[s_idx]"
g_advance = "g_ += Hv * Dk;"
else:
g_comment = "// g: [B, T, Hv]"
g_setup = "auto g_ = g + b_idx * T * Hv;"
g_access = "g_[hv_idx]"
g_advance = "g_ += Hv;"
source = f"""
auto n = thread_position_in_grid.z;
auto b_idx = n / Hv;
auto hv_idx = n % Hv;
auto hk_idx = hv_idx / (Hv / Hk);
constexpr int n_per_t = Dk / 32;
// q, k: [B, T, Hk, Dk]
auto q_ = q + b_idx * T * Hk * Dk + hk_idx * Dk;
auto k_ = k + b_idx * T * Hk * Dk + hk_idx * Dk;
// v, y: [B, T, Hv, Dv]
auto v_ = v + b_idx * T * Hv * Dv + hv_idx * Dv;
y += b_idx * T * Hv * Dv + hv_idx * Dv;
auto dk_idx = thread_position_in_threadgroup.x;
auto dv_idx = thread_position_in_grid.y;
// state_in, state_out: [B, Hv, Dv, Dk]
auto i_state = state_in + (n * Dv + dv_idx) * Dk;
auto o_state = state_out + (n * Dv + dv_idx) * Dk;
// all_states: [B, T, Hv, Dv, Dk] per-step state output
auto a_state = all_states + (b_idx * T * Hv * Dv + hv_idx * Dv + dv_idx) * Dk;
auto a_stride = Hv * Dv * Dk;
float state[n_per_t];
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = static_cast<float>(i_state[s_idx]);
}}
{g_comment}
{g_setup}
auto beta_ = beta + b_idx * T * Hv;
for (int t = 0; t < T; ++t) {{
if ({mask_source}) {{
float kv_mem = 0.0f;
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] * {g_access};
kv_mem += state[i] * k_[s_idx];
}}
kv_mem = simd_sum(kv_mem);
auto delta = (v_[dv_idx] - kv_mem) * beta_[hv_idx];
float out = 0.0f;
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] + k_[s_idx] * delta;
out += state[i] * q_[s_idx];
}}
out = simd_sum(out);
if (thread_index_in_simdgroup == 0) {{
y[dv_idx] = static_cast<InT>(out);
}}
}}
// Save per-step state for speculative rollback
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
a_state[s_idx] = static_cast<InT>(state[i]);
}}
a_state += a_stride;
q_ += Hk * Dk;
k_ += Hk * Dk;
v_ += Hv * Dv;
y += Hv * Dv;
{g_advance}
beta_ += Hv;
}}
// Write final state (same as original kernel)
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
o_state[s_idx] = static_cast<InT>(state[i]);
}}
"""
inputs = ["q", "k", "v", "g", "beta", "state_in", "T"]
if has_mask:
inputs.append("mask")
suffix = "_spec"
if vectorized:
suffix += "_vec"
if has_mask:
suffix += "_mask"
return mx.fast.metal_kernel(
name=f"gated_delta_step{suffix}",
input_names=inputs,
output_names=["y", "state_out", "all_states"],
source=source,
)
# Pre-build kernel variants
_spec_kernel = _make_speculative_gated_delta_kernel(has_mask=False, vectorized=False)
_spec_kernel_masked = _make_speculative_gated_delta_kernel(has_mask=True, vectorized=False)
_spec_kernel_vec = _make_speculative_gated_delta_kernel(has_mask=False, vectorized=True)
_spec_kernel_vec_masked = _make_speculative_gated_delta_kernel(has_mask=True, vectorized=True)
def speculative_gated_delta_kernel(
q: mx.array,
k: mx.array,
v: mx.array,
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = None,
) -> Tuple[mx.array, mx.array, mx.array]:
"""Like gated_delta_kernel but also returns per-step states.
Returns:
y: [B, T, Hv, Dv] output (same as original)
state_out: [B, Hv, Dv, Dk] final state (same as original)
all_states: [B, T, Hv, Dv, Dk] state after each timestep
"""
B, T, Hk, Dk = k.shape
Hv, Dv = v.shape[2:]
input_type = q.dtype
if g.ndim == 4:
kernel = _spec_kernel_vec
inputs = [q, k, v, g, beta, state, T]
if mask is not None:
kernel = _spec_kernel_vec_masked
inputs.append(mask)
else:
kernel = _spec_kernel
inputs = [q, k, v, g, beta, state, T]
if mask is not None:
kernel = _spec_kernel_masked
inputs.append(mask)
return kernel(
inputs=inputs,
template=[
("InT", input_type),
("Dk", Dk),
("Dv", Dv),
("Hk", Hk),
("Hv", Hv),
],
grid=(32, Dv, B * Hv),
threadgroup=(32, 4, 1),
output_shapes=[(B, T, Hv, Dv), state.shape, (B, T, Hv, Dv, Dk)],
output_dtypes=[input_type, input_type, input_type],
)
+18 -5
View File
@@ -52,6 +52,7 @@ from exo.shared.types.worker.instances import (
MlxRingInstance,
)
from exo.shared.types.worker.shards import (
AttnMoeSplitShardMetadata,
CfgShardMetadata,
PipelineShardMetadata,
ShardMetadata,
@@ -60,6 +61,7 @@ from exo.shared.types.worker.shards import (
from exo.worker.engines.mlx.auto_parallel import (
LayerLoadedCallback,
TimeoutCallback,
attn_moe_split_auto_parallel,
eval_with_timeout,
get_inner_model,
get_layers,
@@ -72,15 +74,18 @@ Group = mx.distributed.Group
def get_weights_size(model_shard_meta: ShardMetadata) -> Memory:
# PipelineShardMetadata and AttnMoeSplitShardMetadata store disjoint or
# full-range layer slices per rank, so each rank's weights are already
# scoped by [start_layer, end_layer). TensorShardMetadata shards every
# weight across `world_size` so we divide by it.
full_layer_shard = isinstance(
model_shard_meta, (PipelineShardMetadata, AttnMoeSplitShardMetadata)
)
return Memory.from_float_kb(
(model_shard_meta.end_layer - model_shard_meta.start_layer)
/ model_shard_meta.n_layers
* model_shard_meta.model_card.storage_size.in_kb
/ (
1
if isinstance(model_shard_meta, PipelineShardMetadata)
else model_shard_meta.world_size
)
/ (1 if full_layer_shard else model_shard_meta.world_size)
)
@@ -193,6 +198,8 @@ def load_mlx_items(
mx.eval(model)
end_time = time.perf_counter()
logger.info(f"Time taken to load model: {(end_time - start_time):.2f}s")
from exo.worker.engines.mlx.patches import maybe_apply_patches
maybe_apply_patches(model, model_path)
tokenizer = get_tokenizer(model_path, bound_instance.bound_shard)
else:
@@ -281,6 +288,12 @@ def shard_and_load(
model, group, shard_metadata, on_layer_loaded=on_layer_loaded
)
eval_with_timeout(model.parameters(), timeout_seconds, on_timeout)
case AttnMoeSplitShardMetadata():
logger.info(f"loading model from {model_path} with attn/moe split")
model = attn_moe_split_auto_parallel(
model, group, shard_metadata, on_layer_loaded=on_layer_loaded
)
eval_with_timeout(model.parameters(), timeout_seconds, on_timeout)
case CfgShardMetadata():
raise ValueError(
"CfgShardMetadata is not supported for text model loading - "