Skip to content

Add packed gated delta kernel, bitwise-pinned by an explicit-tree comparator - #1559

Open
wyanzhao wants to merge 4 commits into
ml-explore:mainfrom
wyanzhao:mlx-lm-pr-packed-gdn
Open

Add packed gated delta kernel, bitwise-pinned by an explicit-tree comparator#1559
wyanzhao wants to merge 4 commits into
ml-explore:mainfrom
wyanzhao:mlx-lm-pr-packed-gdn

Conversation

@wyanzhao

@wyanzhao wyanzhao commented Jul 13, 2026

Copy link
Copy Markdown

Summary

Add a packed Metal kernel for the scalar-gated, unmasked Dk == 128
gated-delta prefill path.

Area Behavior
SIMD layout 8 value rows per SIMD-group, 4 lanes per row
State ownership 32 contiguous state elements per lane
Numerical contract Bitwise equality with an explicit-tree comparator
Default behavior Enabled for supported shapes
Escape hatch MLX_GDN_PACKED=0 restores the existing kernel

Motivation

The existing gated-delta prefill kernel assigns one full 32-lane
SIMD-group to each value row. With Dk == 128, each lane owns only four
state elements, while every timestep still performs two full-SIMD
reductions per row:

  1. reduce the recurrent state/K product;
  2. update the state; and
  3. reduce the updated state/Q product.

This makes the kernel latency-bound on the target Qwen3.5/3.6 shapes.

Implementation

The packed kernel maps eight independent value rows onto one SIMD-group:

  • four lanes cooperate on each value row;
  • each lane retains 32 contiguous state elements in registers; and
  • the final two reduction levels use simd_shuffle_xor(..., 1) and
    simd_shuffle_xor(..., 2) within each four-lane row group.

The first three butterfly levels are evaluated locally inside each packed
lane. Each four-element partial preserves the sequential accumulation order
of the original lane.

Numerical contract

The packed kernel is pinned against a test-only comparator that explicitly
writes the ascending butterfly tree:

shuffle_xor(1) -> shuffle_xor(2) -> shuffle_xor(4)
               -> shuffle_xor(8) -> shuffle_xor(16)

This gives the packed implementation a device-independent, source-level
reduction contract. Tests require array_equal between the packed kernel
and the comparator across shapes, dtypes, and sequence lengths.

On the validated M5 toolchain, simd_sum lowers to the same tree, so the
packed kernel is also bit-identical to the existing kernel, including
full-model logits. A canary test compares the explicit-tree comparator with
the existing simd_sum kernel and will flag a device or toolchain whose
lowering differs. metal_kernel uses MathMode::Safe, preserving the
written operation order.

Routing and fallback behavior

Condition Behavior
Dk == 128 Required for packed path
Dv % 8 == 0 Required for packed path
Scalar gate, no padding mask Packed path
g.dtype == float32, state.dtype == float32 Packed path
Vector gate, padding mask, or unsupported shape Existing kernel, unchanged
MLX_GDN_PACKED=0 Existing kernel, unchanged

The specialization is otherwise generic in batch size, head counts, Dv,
sequence length, and input element type.

Performance

M5 Max, Qwen3.6-35B-A3B shapes:

B=1, Hk=16, Hv=32, Dk=Dv=128, T=2048
Measurement Existing kernel Packed kernel Result
Per gated-delta layer 2.54-2.56 ms 1.34-1.38 ms approximately 1.8-2.0x
End-to-end prefill baseline optimized 1.064x / +6.4%

The end-to-end result is the median of six saturated adjacent A/B pairs;
five of six pairs were positive.

Validation

  • Packed output and state are array_equal to the explicit-tree comparator
    across:
    • bfloat16, float16, and float32 inputs;
    • batch and head-count variations;
    • Dv values that differ from Dk; and
    • T in {1, 7, 64, 257}.
  • A canary test checks the comparator against the existing simd_sum kernel.
  • Ops-reference tests cover:
    • the packed path;
    • padding masks;
    • vector gates; and
    • the generic Dk == 64 path.
  • The kill switch is verified to restore the existing kernel bitwise.
  • Local results:
    • tests/test_gated_delta.py: 8/8 passing;
    • existing gated-delta model regressions: 3/3 passing.
  • Full-repository pre-commit run --all-files: passing.

Checklist

  • I have read the CONTRIBUTING document.
  • I have run pre-commit run --all-files.
  • Added tests for packed, fallback, masked, vector-gate, and unsupported-shape paths.
  • Verified the MLX_GDN_PACKED=0 kill switch.
  • No documentation changes are required for this internal kernel specialization.

wyanzhao added 2 commits July 10, 2026 13:40
…parator

The gated delta prefill kernel is latency-bound: each timestep performs two
full 32-lane SIMD reductions per value row, and with Dk=128 each lane holds
only four state elements.

This packs eight independent value rows into a SIMD-group: four lanes own
each row and each lane keeps 32 contiguous state elements in registers.

Numerics contract: the packed kernel reproduces, bitwise and by
construction on any device, an explicit-tree comparator kernel that writes
out the ascending butterfly (shuffle_xor 1,2,4,8,16) instead of calling
simd_sum; the tests pin packed == comparator with array_equal across
shapes, dtypes and lengths. On all current Apple GPUs simd_sum lowers to
this same tree (a canary test asserts comparator == existing kernel and
would flag any future device or toolchain where the lowering differs, and
metal_kernel compiles with MathMode::Safe so the written order is
preserved). On the validated M5 toolchain the packed kernel is therefore
also bit-identical to the existing kernel, including full-model logits.

Blast radius is limited to the shapes the packed kernel serves
(Dk == 128, Dv % 8 == 0, scalar gating, no padding mask; generic in batch,
head counts and element type). Masked, vector-gate and other-shape paths
keep the pre-existing simd_sum kernels byte-for-byte, and the
MLX_GDN_PACKED=0 kill-switch routes back to the pre-existing kernel as
well. New ops-reference tests cover the masked, vector-gate and Dk=64
generic paths.

Timings on M5 Max, Qwen3.6-35B-A3B shapes (B=1, Hk=16, Hv=32, D=128,
T=2048): ~1.8-2.0x per layer across measurement rounds (e.g. 2.54-2.56ms
-> 1.34-1.38ms on layers 0/20/38); end-to-end prefill 1.064x (median of
six saturated adjacent A/B pairs, 5/6 positive).
@wyanzhao
wyanzhao marked this pull request as ready for review July 13, 2026 20:57
@pierre427

Copy link
Copy Markdown

Ran this on an M5 (Apple, Neural-Accelerator / NAX path active) against Qwen3-Next-80B-A3B-Instruct (MLX-4bit), same as we did for the SDPA NAX kernels. TL;DR: lossless and a clean ~1.9–2.0× on the GDN step kernel.

NAX active (canary)

bf16 4096³ GEMM, mlx 0.32.0 wheel (NAX-class), one build/venv per side, mlx-lm varied only:

  • base (unpacked): 2.49–3.58 ms
  • head (packed): 2.49–2.99 ms

Both well under the ~9 ms NAX-off floor, so the NAX path is live in both. (A couple of the whole-model runs drifted a bit hot from unrelated background I/O on the box — flagged below; it doesn't touch the correctness verdict.)

Losslessness — token-identical ✅ (the headline)

Decoded 96 greedy tokens from the same prompt under base (original simd_sum kernel) and head (packed kernel), and cross-checked each against a batched-greedy reference (full forward over the growing prefix each step, argmax — not stepwise-with-cache, per our batch-invariance discipline).

All four sequences are byte-identical: base-stepwise == base-batched == head-stepwise == head-batched. Packed GDN is a drop-in, no decode divergence.

Kernel-level numerics on model-realistic (L2-normalized q/k) inputs confirm it: packed == xtree == unpacked, max |Δ| = 0.0 on both y and state. Your tests/test_gated_delta.py also passes here: 8 passed, 32 subtests.
(Heads-up for anyone reproducing: feeding unnormalized random q/k into the step kernel overflows the delta recurrence to NaN over a few hundred steps and makes the equality checks spuriously fail — normalize q/k like the model does.)

Kernel A/B — packed vs unpacked (isolated, one process, canary-clean)

Whole-model prefill A/B was too noisy to read a magnitude off — the GDN step is a fraction of total prefill (MoE GEMMs dominate) and thermal/background-I/O drift swamped the signal (base and head traded places across reps). So the timing below is the step kernel in isolation, gated_delta_kernel (packed) vs gated_delta_kernel_unpacked, back-to-back in one process on the model's own GDN dims — B=1, Hk=16, Dk=128, Hv=32, Dv=128. Canary 2.77 ms on this run.

T (prefill) unpacked packed speedup
2048 2.56 ms 1.33 ms 1.93×
4096 4.91 ms 2.41 ms 2.04×
8192 9.67 ms 4.87 ms 1.99×

Consistent ~2× on the changed kernel, lossless, no regression on the routing that stays on the original path (masked / vector-gate / Dk≠128 and the MLX_GDN_PACKED=0 kill-switch are untouched). LGTM from M5.

nyx-rattapoom added a commit to nyx-rattapoom/mlx-lm that referenced this pull request Aug 8, 2026
Port the packed Dk=128 prefill specialization from upstream PR
ml-explore#1559 onto this fork's fused
gated-delta convention, and make it the default path for the shapes it
supports.

Why
---
The generic kernel assigns one 32-lane SIMD-group to each value row. At
Dk=128 that leaves every lane holding only four state elements while still
spending two full-SIMD reductions per value row per token, so prefill is
dominated by reduction traffic rather than by arithmetic.

What the packed kernel does
---------------------------
It packs eight value rows into a SIMD-group: four lanes own each row, and
each lane keeps Dk/4 = 32 contiguous state elements in registers. Both
reductions shrink from full 32-lane sums to two shuffles inside a four-lane
row group. Launch geometry changes accordingly, from grid (32, Dv, B*Hv) /
threadgroup (32, 4, 1) to grid (32, Dv//8, B*Hv) / threadgroup (32, 2, 1).

Bitwise contract
----------------
The packed kernel and a new unpacked comparator
(_make_gated_delta_kernel_xtree) both write the ascending butterfly
(shuffle_xor 1,2,4,8,16) out in source instead of calling simd_sum, so the
reduction order is a contract of this file rather than of the simd_sum
lowering. The butterfly's first three levels combine partials that live in
a single packed lane, and the last two map onto shuffle_xor(1) and
shuffle_xor(2) within the four-lane row group; each 4-element partial keeps
the comparator's sequential accumulation order. The packed kernel is
therefore bit-identical to the comparator on any device by construction,
and measured bit-identical to the pre-existing simd_sum kernel on M4.

Eligibility and fallback
------------------------
The packed path requires mask is None, scalar gating (a.ndim == 3),
Dk == 128, Dv % 8 == 0 and a float32 state. It is otherwise generic in B,
Hk, Hv and the input element type. Masked, vector-gate and Dk != 128 shapes
keep the original kernels untouched.

Unlike the unfused upstream variant there is no g.dtype == mx.float32
eligibility condition: in this fork's API g is not an array, it is computed
in-kernel in float32 from (a, A_log, dt_bias). The fused gate arithmetic is
lifted verbatim from the generic kernel's g_compute (_FUSED_G) so both
paths evaluate the same expression.

Kill switch
-----------
MLX_GDN_PACKED=0 routes everything back to the pre-existing simd_sum
kernels. It is read once at import time, so it must be set before mlx_lm is
imported and cannot be flipped from inside a running process.

Tests
-----
tests/test_gated_delta.py pins packed == comparator and packed ==
simd_sum kernel across eight shape/dtype cases x T in (1, 7, 64, 257,
2048), plus a canary that simd_sum still lowers to the written-out
butterfly, an ops-reference tolerance check, and default-routing,
kill-switch and fallback-on-unsupported-shape cases. Random inputs only, no
model download required.

Context
-------
This replaces exo's runtime monkeypatch
(src/exo/worker/engines/mlx/patches/gdn_packed.py), whose packed Metal
source this kernel is byte-identical to. That patch has been serving since
2026-08-04, measured at +3.7% prompt_tps / +2.6% gen_tps on a 2x M4
pipeline; moving the kernel here measured performance-neutral against it.

Ported-from: ml-explore#1559
nyx-rattapoom added a commit to nyx-rattapoom/mlx-lm that referenced this pull request Aug 8, 2026
The README was upstream verbatim, with nothing saying this is a fork or
what it changes. Add FORK.md covering the lineage (base branch
leo/deepseek-v4, tracking ml-explore/mlx-lm), why the fork exists, the
packed gated-delta kernel ported from ml-explore#1559, the
MLX_GDN_PACKED kill switch, and the branch map.

README.md gets a three-line pointer and is otherwise untouched, so
future upstream merges stay conflict-free.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants