Skip to content

Rebase the fork onto v0.32.4; qmv_quad ragged-tile scale/bias read (gemma4-qat#302) - #12

Merged
jeethu merged 34 commits into
prism-v0.32.4-fixesfrom
fork/v0.32.4-sync
Oct 16, 2026
Merged

jeethu merged 34 commits into
prism-v0.32.4-fixesfrom
fork/v0.32.4-sync

Conversation

@jeethu

@jeethu jeethu commented Oct 15, 2026

Copy link
Copy Markdown
Member

Rebases the fork onto ml-explore/mlx v0.32.4 and adds one kernel fix on top: qmv_quad read scales and biases for rows past N on a ragged last tile (gemma4-qat#302).

Base

prism-v0.32.4-fixes = v0.32.4 = 36969c09 (PyPI mlx 0.32.4). This branch is that tag plus 23 commits: the 22 fork patches from prism-main-06eb7483-fixes (06eb7483..317df531), replayed in order with their original authorship and messages, then patch 23.

The 22 replayed patches

All 22 cherry-picked cleanly. git range-diff 06eb7483..317df531 36969c09..ee0b1bcfc shows 20 =, 2 !, no < or >.

The two ! are patch 1 (metal: 1-bit uint32 wide-load qdot over upstream #3161; ...) and patch 20 (metal: condense the qmv partial-last-block comment). Both differ only in a context line. Upstream ml-explore#4675 rewrote the in_bounds line under the fork's comment to static_cast<int>(simd_lid). The patch content is unchanged.

# Subject
1 metal: 1-bit uint32 wide-load qdot over upstream ml-explore#3161; non-aligned-K qmv_fast tail test (gemma4-qat#282)
2 python/tests: make test_qmv_fast_tail_non_aligned_k honest and future-proof (gemma4-qat#282)
3 quantized: bias-free symmetric 1/2-bit affine (affine_sym) on upstream main (gemma4-qat#179, ml-explore#282)
4 quantized: implied-bias affine quantized_matmul/dequantize on upstream main (gemma4-qat#190, ml-explore#282)
5 quantized: qmv_fast for affine 4-bit K % 256 and 1-bit K % 512 on upstream main (gemma4-qat#192, ml-explore#282)
6 quantized: affine qmv_wide by bit width and batch, single-tile 6-7 row verify (gemma4-qat#191, ml-explore#282)
7 metal: dispatch/commit/sync/wait counters (gemma4-qat#181, ml-explore#282)
8 metal_kernel: build and hash each kernel variant once, bounded memo (gemma4-qat#189, ml-explore#282)
9 metal: gate NAX off for gen-17 (M5-class) at every NAX dispatch site (ml-explore#282)
10 metal: keep transcendentals precise unless MathMode::Fast (ml-explore#282)
11 fast: spec_decode_verify op (CPU composition + fused Metal) (ml-explore#282)
12 python/tests: clarify qmv tail/qmv_wide test comments (ml-explore#282)
13 fast: spec_decode_verify materializes non-row-contiguous drafts (ml-explore#282)
14 fast: spec_decode_verify CPU path handles K = 0 like Metal (ml-explore#282)
15 fast: spec_decode_verify returns empty results for B = 0 without dispatching (ml-explore#282)
16 fast: spec_decode_verify takes the composition for K = 0 on every backend (ml-explore#282)
17 metal: condense the gen-17 NAX gate comment (ml-explore#282)
18 fast: spec_decode_verify rejects non-integer draft tokens (ml-explore#282)
19 export: serialize SpecDecodeVerify (ml-explore#282)
20 metal: condense the qmv partial-last-block comment (ml-explore#282)
21 metal: default D512 vector attention to GPU generation 17+ (ml-explore#291)
22 metal: condense the D512 vector-attention gate comment (ml-explore#291)

Overlap review

Each file that both 06eb7483..v0.32.4 and the fork touch:

NAX site list

The fork's gate lives inside metal::is_nax_available() (device.cpp:1004-1022, can_use_nax &= gen >= 18;), so every site that asks that function is gen-17-gated. From git grep -n -iE "nax" mlx/backend/metal/*.cpp at ee0b1bcfc:

  • matmul.cpp:964: steel_matmul_axpby use_nax (thin :995, NAX split-K :1050, regular fused :1087)
  • matmul.cpp:2964: gather_mm_rhs_nax
  • matmul.cpp:3043: segmented_mm use_nax (:3060, :3103, :3149)
  • quantized.cpp:1252: qmm -> qmm_nax (has_nax_kernel)
  • quantized.cpp:1532: gather_qmm -> gather_qmm_nax
  • quantized.cpp:1927: gather_qmm_rhs -> gather_qmm_rhs_nax, now also non-transposed after Enable non-transposed NAX gather_qmm_rhs ml-explore/mlx#4684. It is gated, so it needs no fix.
  • quantized.cpp:2478: global-scale gather_qqmm K alignment
  • scaled_dot_product_attention.cpp:29 (the pre-M5 D512 path, negated), :412, :436, :964, :1447, :1456, :1463 (negated)
  • gated_delta_update.cpp:18, :24 (chunk C = 16 selects the _nax kernels at :135/:339/:375/:422)
  • jit_kernels.cpp / nojit_kernels.cpp: get_*_nax_kernel getters only, called from the functions above

Every one of these goes through is_nax_available(). No site picks a NAX kernel with a raw architecture check, so no NAX fix commit was needed. The test host is an M3 Max (gen 15), so the NAX paths are checked statically only.

Patch 23: qmv_quad reads no scale or bias for rows past N (gemma4-qat#302)

qmv_quad (K = 128, or K = 64 at bits >= 2; power-of-two bits; not affine_sym) runs ceil(N / 64) tiles with no N % 64 restriction. On a ragged last tile, the quad rows past out_vec_size loaded their scale, and their bias when it is explicit, before the row predicate, so they read past the end of the scales and biases buffers. The qdot and the store were already guarded, so the output was never wrong. The patch moves both loads inside the existing predicate. The pointer arithmetic stays outside, since forming a pointer reads nothing. v0.32.4's qmv_quad_impl has the same unguarded structure, so nothing here comes from upstream.

The new test is test_qmv_quad_ragged_n. It runs N = 65 (one valid row in tile 2) and N = 72 (eight) at bits 1/2/4/8, gs 32/64 and K 64/128 (1-bit at K = 128 only), with an explicit and an implied (0-d factor) bias, against a CPU dequantize reference. That is 56 subtests. affine_sym never reaches qmv_quad; test_qmv_affine_sym covers it on its own path.

Validation outcome. Metal validation (MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 MTL_SHADER_VALIDATION_GLOBAL_MEMORY=1) could not see the read:

  • On the unfixed kernel it reported nothing at the test shapes, and nothing at fp32 K = 128 gs = 32 N = 1025, a shape where the scales buffer is 16400 bytes.
  • As a control, a deliberately out-of-bounds mx.fast.metal_kernel (it reads 1M elements past a 16-element input) was not reported either. "Metal API/GPU Validation Enabled" was printed each time.
  • So in this setup GPU validation does not flag out-of-bounds device loads, and the fix rests on the bounds argument above.
  • The test passes before and after the fix, because an allclose cannot see a read whose value is discarded. It pins the ragged-tile values and shapes.

Tests (at the patch-23 head, M3 Max)

Gate Result
pytest python/tests/test_quantized.py 73 passed, 1 skipped (CUDA-only), 10608 subtests
pytest python/tests/test_quantized.py python/tests/test_fast.py 110 passed, 2 skipped, 10715 subtests (Task 1 sync head: 109 passed)
C++ ./build/tests/tests (MLX_METAL_JIT=ON) 311/311 test cases, 6534/6534 assertions
C++ ./build-lib/tests/tests (metallib) 311/311 test cases, 6534/6534 assertions

At the bare v0.32.4 tag, test_quantized.py + test_fast.py gave 84 passed, 2 skipped. The extra tests are the fork's own.

Review

Codex round (whole v0.32.4..HEAD diff, source-only): no fork-introduced defects; the 22 replays match the old base; no adverse interaction with ml-explore#4689/ml-explore#4673/ml-explore#4675/ml-explore#4672/ml-explore#4687/ml-explore#4660/ml-explore#4004; gen 17 stays off every NAX path. One inherited P2 in upstream v0.32.4 (ml-explore#4684: the non-transposed NAX gather_qmm_rhs checks only tile rows before loading scales/biases/weights for N not a multiple of the tile, reachable on gen 18+ only) is tracked as a gemma4-qat follow-up, not fixed here

jeethu added 23 commits October 15, 2026 01:19
…-aligned-K qmv_fast tail test (gemma4-qat#282)

Patch 1 of the numen-tech/mlx rebase onto upstream main (06eb748), which
carries upstream's final 1-bit affine quantization (ml-explore#3161,
e0408d4). It replaces the fork's five pre-review ml-explore#3161 snapshot commits
(2fb3d1a..20bf2eb) with only the deltas we still carry over upstream.

- qdot<bits=1>: read the packed codes as uint32 words, 32 values per load,
  instead of upstream's byte-at-a-time loop. Same little-endian bit order
  and accumulation order, so bit-exact with the per-byte path.
  Provenance: mlx-swift bc96a51 "metal: uint32 wide load in 1-bit qmv qdot
  (large mobile decode win)", sourced in the fork by b7e6d4d
  (numen-tech/gemma4-qat#179). Every 1-bit qdot caller has
  values_per_thread == 32 and 4-byte aligned weights (qmv_fast, qmv,
  qmv_quad at D == 128); qdot_safe, qouter and dequantize keep upstream's
  byte path.
- qmv_fast_impl packs_per_thread: upstream already took the fork's
  `bits <= 2 ? 1 : 2`; restore the fork's comment (#3).
- qmv_fast_impl partial tail: keep upstream's lane predicate
  `aligned_end + simd_lid * vpt < K` and the partial_rows clamp from
  ml-explore#4516. The fork's `aligned_end + (simd_lid + 1) * vpt <= K`
  is equivalent whenever K % vpt == 0, which the host K gate guarantees.
  Upstream's comment there was stale (it said the 1-bit block is 2048; it
  is 1024 with 1 pack/thread); replace it with the fork's OOB reasoning,
  reworded for this predicate.
- dispatch_qmv: document why the qmv_quad K == 64 route excludes 1-bit
  (a quad lane would hold 16 values, less than one uint32 pack). The
  fork's affine_sym fall-through comment lands with affine_sym (patch 2).

Test: test_qmv_fast_tail_non_aligned_k (GPU vs CPU, 1/4-bit, K = 17/33
groups, N = 67/256). It passes before and after the qdot port. Note that
on this base the qmv_fast tail is unreachable from the host (the 1-bit K
gate is 2048 = 2 * block; other widths gate at block), so these shapes
route to qmv. With an experimental (uncommitted) 1-bit K gate of 512 the
tail runs, the ported qdot plus upstream's predicate matched the CPU on
1-bit K = 1536/2560 tail shapes, and a predicate letting one lane read
past K failed them.
…-proof (gemma4-qat#282)

The comment claimed the shapes run qmv_fast's partial tail and the
partial_rows clamp. They do not on this base: the host K gate is 2048 for
1-bit and 512 for 4-bit, so every shape here routes to plain qmv (or
qmv_wide for M = 3 on gen 15+). Say so, and say when it becomes the tail
guard: once the routing patch widens the gate to 1-bit K % 512 and affine
4-bit K % 256 (gemma4-qat#192).

Add the shapes that the widened gate makes tail-reachable, keeping the
existing ones: 1-bit K in {1536, 2560} x gs {32, 64, 128} and 4-bit
K in {768, 1280} x gs {32, 64}, with N in {67, 256} and M in {1, 3}.
Same GPU-vs-CPU comparison and tolerances (atol = rtol = 1e-2). Skip
without Metal like the neighbouring tests; ruff-format.
…tream main (gemma4-qat#192, ml-explore#282)

Port of numen-tech/mlx PR #8 (923aef6, 7d565ec) onto upstream main.
qmv_fast_k_alignment takes the mode and returns half a kernel block for
affine / affine_sym 1-bit and 4-bit (1-bit K % 512, 4-bit K % 256), the
whole block otherwise. nvfp4 / mxfp4 share bits == 4 but fp_qmv_fast_impl
has no tail, so they keep the whole block; every other width keeps
upstream's routing. qmv keeps upstream's gate shape from ml-explore#4516
(fast = K-aligned, partial_rows = fast && N % 8 != 0); gather_qmv keeps
N % 8 == 0.

Tail predicate: kept upstream's lane predicate
  aligned_end + simd_lid * values_per_thread < K
and the ml-explore#4516 partial_rows row clamp, not the fork's
  aligned_end + (simd_lid + 1) * values_per_thread <= K.
The two agree whenever K % values_per_thread == 0, which the host gate
guarantees (block / 2 = 16 * values_per_thread, and K % group_size
already implies it). Evidence on M3 Max (applegpu_g15s):
- GREEN: test_qmv_fast_tail_non_aligned_k and test_qmv_fast_half_block
  pass; a kernel-name probe (removed) showed the 1-bit K 1536/2560 and
  4-bit K 768/1280 M = 1 subtests on affine_qmv_fast (pr_1 at N = 67).
- RED: with the predicate loosened to `< K + values_per_thread` (one extra
  lane reads past K) both tests fail: 20/20 tail-reachable subtests of
  test_qmv_fast_tail_non_aligned_k and every tail subtest of
  test_qmv_fast_half_block (165 + 3 gather), errors up to ~78.
No read past K shows with upstream's predicate, so the fork's stricter
form is not needed; the OOB-reasoning comment stays.

Tests: test_qmv_fast_half_block (fork's, with the generic baseline moved
to K + gs zero-padded because N % 8 != 0 no longer leaves qmv_fast after
ml-explore#4516; the fast call's x is a view with non-zero memory past K so the test
is a tail guard; fp32 allows 4 ulps for accumulation order, measured
<= 1.1), K = 768 in test_implied_bias_matmul, the "affine quantized_matmul
qmv_fast tail block" doctest (renamed from the fork's "test affine
qmv_fast tail block" so the *quantiz* filter runs it), updated bias-free
doctest case comments. Also: the stale path="qmv_generic" subTest label
is now "n_not_mult_8", and affine_sym_qmv[_fast] note their unused
has_global_scale / results_per_simdgroup slots.
…w verify (gemma4-qat#191, ml-explore#282)

Port of cddb7be (route affine qmv_wide by bit width and batch) and
numen-tech/mlx PR #9 (071a92a..43e6613: run an affine qmv_wide verify of
6 or 7 rows as one tile) onto upstream main.

- use_qmv_wide takes bits and M: affine 1-bit, and affine 2-bit at M < 3,
  stay on qmv; other affine widths take qmv_wide on gen 15+ as before; fp
  modes unchanged; affine_sym still has no qmv_wide kernel.
- qmv_wide runs affine M <= 7 as one tile (vecs_per_tg = M); M >= 8 keeps
  upstream's ceil(M / 5) tiling. quantized.metal instantiates the affine
  and implied-bias qmv_wide kernels for vecs_per_tg 6 and 7.

No conflict with ml-explore#4516, ml-explore#4629 or ml-explore#4641: none touches use_qmv_wide or the
qmv_wide tiling, and ml-explore#4629's M1 batch limit only applies below gen 15,
where affine never takes qmv_wide.

Tests: test_qmv_wide gains 1-bit affine (full sweep and tiny shapes);
test_qmv_wide_tile_matches_split (M = 6/7/8/11/12 bit-identical to the
split calls, verbatim from the fork); the "affine quantized_matmul qmv_wide
single tile of 6 and 7 vectors" doctest (renamed from the fork's "test
affine qmv_wide single tile of 6 and 7 vectors" so the *quantiz* filter
runs it). Routing comments in the tests updated for the 1/2-bit gate.
…#282)

Process-wide Metal work counters (metal::counters() / metal::reset()):
dispatches, commits, explicit stream synchronizations, and host waits on
GPU work. Source: numen-tech/mlx PR #3, `git diff 08121fd 0f4257d`
(5447675, aaefa50, 2e5d6cc, 1fee32b, 9cb5069).

Conflicts with upstream main since v0.32.2:
- fence.cpp: upstream dropped ~FenceImpl (fence is now an
  std::optional<allocator::Data>) and Fence::wait takes the target value
  as a parameter. The fork's `Device device` member and the host-wait
  count in the CPU spin-wait are kept, spinning on upstream's `value`.
- tests/CMakeLists.txt: upstream renamed METAL_TEST_SOURCES to
  GPU_TEST_SOURCES; metal_counters_tests.cpp is appended to the new list.

No new host-wait or dispatch site upstream: CommandEncoder::
dispatch_threadgroups/dispatch_threads and CommandEncoder::synchronize
are still the only dispatch and waitUntilCompleted sites in
mlx/backend/metal, and eval error recovery still flushes via
gpu::synchronize(s, false).

Tests: ./build/tests/tests -tc='*counters*' -> 4 cases, 26 assertions
pass. test_quantized.py + test_fast.py: 102 passed, 2 skipped.
…gemma4-qat#189, ml-explore#282)

fast::metal_kernel's closure keeps a per-kernel memo (shared by all copies
of the closure, mutex-guarded) from each call variant (input dtypes and
pass mode, output dtypes, template argument kind + name + value) to its
kernel name, source and source hash. CustomKernel holds the source in a
shared_ptr<const std::string> with a precomputed hash. The memo holds at
most metal_kernel_max_cached_variants (64) entries and is cleared when
full; entries are shared_ptrs, so a pending array whose entry was evicted
still evaluates. Source: numen-tech/mlx PR #5, f5428f8 + b2c93bd.

Merged with upstream ml-explore#4584 (908148e, `-` sanitized in
make_template_hash): the memo calls make_template_hash unchanged when it
builds a variant, so negative template values still give a valid kernel
name, and the memo key encodes the value's bytes, so -1 and 1 stay
separate variants. Both behaviors are kept; the only textual conflict
was tests/CMakeLists.txt (upstream's METAL_TEST_SOURCES ->
GPU_TEST_SOURCES rename).

Tests: test_fast.py -k "custom_kernel or metal_kernel" -> 11 passed
(incl. ml-explore#4584's test_custom_metal_kernel_negative_template_int and the
fork's test_custom_kernel_many_template_values). ./build/tests/tests
-tc='*metal kernel*,*counters*' -> 8 cases, 778 assertions pass.
test_quantized.py + test_fast.py: 103 passed, 2 skipped.
…l-explore#282)

Source: numen-tech/mlx 73db231 ("gate nax off for gen-17 devices",
fork PR #4). M5-class gen-17 parts (applegpu_g17*) compute wrong
results in the NAX steel-gemm and qmm_t kernels; the fallback bit-matches
stock mlx. is_nax_available() now requires gen >= 18 on every device
class (upstream: gen >= (arch == 'p' ? 18 : 17)). The unused `arch`
local is dropped instead of the fork's `(void)arch`.

The gate is one predicate. Audit of every NAX dispatch decision on
upstream main 06eb748 + patches 1-7 (`git grep -n -iE nax
mlx/backend/metal`, including kernels/*.h, jit/ and the JIT/no-JIT
kernel getters), each site -> the predicate it asks:

  matmul.cpp:964    steel_matmul_axpby use_nax (thin ml-explore#4654 :995, NAX
                    split-K :1050, regular fused :1087) -> is_nax_available()
  matmul.cpp:2964   gather_mm_rhs_nax                    -> is_nax_available()
  matmul.cpp:3043   segmented_mm use_nax (:3060, :3103)  -> is_nax_available()
  quantized.cpp:1243 qmm -> qmm_nax (qmm_t_nax/qmm_n_nax, incl. the _ib
                    and affine_sym variants from patches 2-3)
                                                        -> is_nax_available()
  quantized.cpp:1522 gather_qmm -> gather_qmm_nax      -> is_nax_available()
  quantized.cpp:1917 gather_qmm_rhs -> gather_qmm_rhs_nax
                                                        -> is_nax_available()
  quantized.cpp:2468 global-scale gather_qqmm K alignment (ml-explore#4481; the
                    matrix kernels then dispatch via :1917)
                                                        -> is_nax_available()
  scaled_dot_product_attention.cpp:29   pre-M5 D512 non-NAX path (ml-explore#4518)
                                                        -> !is_nax_available()
  scaled_dot_product_attention.cpp:412  sdpa_full_self_attention_nax
                                                        -> is_nax_available()
  scaled_dot_product_attention.cpp:436  D72/D80 pad to NAX (ml-explore#4455)
                                                        -> is_nax_available()
  scaled_dot_product_attention.cpp:964  D512 support check (ml-explore#4487)
                                                        -> is_nax_available()
  scaled_dot_product_attention.cpp:1436 D512 fused eligibility (ml-explore#4487)
                                                        -> is_nax_available()
  scaled_dot_product_attention.cpp:1445 D256 masked/causal fused NAX
                    (ml-explore#3842, array mask ml-explore#4416)           -> is_nax_available()
  scaled_dot_product_attention.cpp:1452 D256 non-NAX fused (ml-explore#4505)
                                                        -> !is_nax_available()
  gated_delta_update.cpp:18, :24  chunk C = 16 (the only value that
                    selects gated_delta_*_nax kernels, :133/:335/:369/:416)
                    only when NAX (ml-explore#4020, ml-explore#4565)        -> is_nax_available()

No other path selects a NAX kernel: the get_*_nax_kernel getters
(jit_kernels.cpp, nojit_kernels.cpp) are only called from the functions
above, get_sdpa_vjp_nax_kernel has no caller, the MPP tensor_ops code in
kernels/ lives only in *_nax.h/.metal, and nothing outside
mlx/backend/metal mentions NAX. The remaining raw
get_architecture_gen()/get_architecture() uses pick tiles or non-NAX
kernels (qmv batch limits quantized.cpp:99-134, nvfp4 narrow qmv
quantized.cpp:550, affine qmv_wide gen 15+ quantized.cpp:633, gemv
stream matmul.cpp:1416, GEMM_TPARAM/devc tile tunings incl. ml-explore#4447).

This host is an M3 Max (gen 15, no NAX), so the change is a no-op here;
the gen-17 runtime check is the iPad M5 leg (gemma4-qat ml-explore#282 T9/T11).
test_quantized/test_fast/test_blas/test_fast_sdpa: 162 passed, 5 skipped.
…#282)

Source: numen-tech/mlx c731626 (applied unchanged). On macOS 15 /
iOS 18+, set_compile_options set only mathMode, and
MTLCompileOptions.mathFloatingPointFunctions (independent, default Fast)
stayed Fast, so a Safe/Relaxed runtime compile (JIT kernels, compiled
fusions, custom kernels) still emitted the approximate exp/log/sin/cos/pow.
It is now Fast only for MathMode::Fast and Precise otherwise; the
pre-macOS-15 branch keeps setFastMathEnabled.

Checked against upstream ml-explore#4461 (365bd0f, "Use precise::exp in Sigmoid
so compiled and eager sigmoid agree"): that fix only respells Sigmoid's
exp as metal::precise::exp. It does not change compile options, and
other runtime-compiled ops still spell unqualified metal:: transcendentals
(binary_ops.h LogAddExp exp/log1p, Power pow, complex log/exp/sin/cos/
atan2, ...), so upstream covers one op of what this rule covers. Nothing
is dropped. The two compose: an explicit metal::precise:: call stays
precise under every mode, including Fast.

Tests: test_ops.py -k "exp or log or sin or cos or tanh or erf or softmax
or sigmoid or power or sqrt" -> 29 passed. test_compile.py (incl. ml-explore#4461's
test_compile_sigmoid_matches_eager) + test_quantized.py + test_fast.py
(incl. test_custom_metal_kernel_math_mode) -> 174 passed, 2 skipped.
…re#282)

mx.fast.spec_decode_verify(draft_tokens [B, K], target_logits
[B, K+1, V]) -> (n_accepted [B], committed [B, K+1]): greedy speculative
verify. The target argmax runs as a regular op; the SpecDecodeVerify
primitive does the per-row prefix match in a one-thread-per-row Metal
kernel, with an op-composition fallback on CPU / no-GPU / CUDA. Source:
3e1d206 (v1 composition), 28efbe6 (fused Metal v2), 36030f6
(docs), 49840c9 (fuzz), e197191 (test moved into test_fast.py),
taken as `git diff 3e1d206^ e197191` minus quantized.cpp /
test_quantized.py (cddb7be in that range is patch 5).

Applied unchanged, except:
- add/add conflicts with upstream: kernels/CMakeLists.txt (after the
  sdpa_vjp / gated_delta build_kernel lines), no_gpu/primitives.cpp
  (after GatedDeltaUpdate[VJP]::use_fallback), test_fast.py (after
  ml-explore#4584's test_custom_metal_kernel_negative_template_int); both sides kept;
- clang-format (fast.cpp, fast.h) and ruff 0.16.10 format (test_fast.py)
  of the new code, whitespace only.

No caller in NumenKit or Python; carried as is (spec §2.10).
Tests: test_fast.py -k spec -> 1 passed, 76 subtests. test_quantized.py
+ test_fast.py -> 104 passed, 2 skipped.
Comment-only (gemma4-qat ml-explore#282 ruling R12, Task 3 review minors):
- test_qmv_fast_half_block: the "x is a view whose memory past K is
  non-zero" claim holds for B = 0 only. At B = 2
  ensure_row_contiguous_matrix copies the (2, 1, K) slice, so past K
  batch 0 reads batch 1's x (still non-zero) and batch 1 reads past the
  buffer.
- test_implied_bias_matmul: affine qmv_wide routing needs gen 15+.
…xplore#282)

Codex round 1, P1 (pre-existing in the fork's 28efbe6). The fused
kernel indexes dense rows (draft + row * K, target + row * (K + 1)), but
eval_gpu bound its inputs as given, and the wrapper's astype(int32) keeps
an int32 view a view. A transposed draft (logically [[1,2],[3,4]]) gave
n_accepted [1, 0] on Metal vs [2, 2] on CPU; a broadcast [1,2] -> [2,2]
draft made row 1 read past the stored elements; a column slice read the
wrong elements.

eval_gpu now copies a non-row-contiguous input (draft or target) into a
dense temporary (contiguous_copy_gpu + add_temporary, as in
gated_delta_update.cpp) before setting the kernel's pipeline. A
row-contiguous input is returned untouched before the encoder is used:
same path as before, no extra copy or dispatch. Targets come from argmax,
which already returns dense rows from strided logits; the check covers
them anyway.

Test: test_spec_decode_verify_strided_inputs (transposed, broadcast,
sliced and mismatching transposed drafts, plus a transposed logits view;
Metal and CPU vs explicit expected results).
RED (before): 4 failed -- every Metal draft-view subtest, e.g.
  case='transposed' path='default': Lists differ: [1, 0] != [2, 2]
  case='broadcast'  path='default': Lists differ: [2, 0] != [2, 2]
  case='sliced'     path='default': Lists differ: [2, 0] != [2, 2]
  case='mismatch-transposed' path='default': [1, 0] != [1, 2]
(CPU and the transposed-logits subtests passed.)
GREEN: test_fast.py -k spec -> 2 passed, 86 subtests passed.
…e#282)

Codex round 1, P2 (pre-existing in the fork's 3e1d206). With K = 0
the CPU / no-GPU composition took min over an empty axis, which
ops.cpp rejects, so draft [B, 0] with logits [B, 1, V] threw on
stream=mx.cpu while the fused kernel returns a result. Per ruling R16,
K = 0 is not rejected: the composition now returns what Metal returns,
measured first: n_accepted = 0 for every row and committed [B, 1] = the
target argmax token (Metal: B=1 -> [0], [[5]]; B=2 -> [0, 0], [[5], [3]]).

Test: test_spec_decode_verify_empty_draft (B = 1 and 2; Metal and CPU
vs the explicit result, shapes included).
RED (before): 2 failed -- both CPU subtests:
  ValueError: [min] Cannot min reduce over axis 1 with size 0.
(The Metal subtests passed.)
GREEN: test_fast.py -k spec -> 3 passed, 90 subtests passed; CPU and
Metal both give int32 [0] / [[5]].
…atching (ml-explore#282)

Ruling R17 (fix round 1). With B = 0 (drafts [0, K], logits
[0, K+1, V], K = 3 and K = 0) the op threw on every backend before
reaching the fused kernel: `[argmax] Cannot argmax reduce zero size
array.` (also under MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1, where
nothing else is reported: the throw precedes any encode). The op now
returns int32 zeros [0] and [0, K+1] right after the shape checks, with
no argmax and no SpecDecodeVerify primitive, so nothing is dispatched.
B > 0 inputs take exactly the previous path.

Test: test_spec_decode_verify_empty_batch (K = 3 and 0; default device
and CPU; shape and dtype of both outputs; default vs CPU equal).
RED (before): 6 failed -- the 4 path subtests with the argmax ValueError,
the 2 compare subtests with KeyError (no result to compare).
GREEN: test_fast.py -k spec -> 4 passed, 96 subtests passed; both
backends give n (0,) int32 and committed (0, K+1) int32, also clean
under the Metal debug/shader-validation layers.
…kend (ml-explore#282)

Found while running the R17 B = 0 check under the Metal validation
layer (fix round 1; pre-existing in the fork's 28efbe6). With K = 0
and B > 0 the fused kernel was dispatched with an empty draft, which has
no buffer, so index 0 was left unbound. Without validation the kernel
never reads it (K = 0) and the result was right, but
MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 aborts:
  validateComputeFunctionArguments:1043: failed assertion `Compute
  Function(spec_decode_verify): missing Buffer binding at index 0 for
  draft[0].'
K = 0 now takes the op composition on every backend (n_accepted = 0,
committed = the argmax bonus token, the K = 0 result from 52d3ef5),
so the kernel is never bound to an empty draft. K > 0 is unchanged.

Test: test_spec_decode_verify_empty_draft (from 52d3ef5), run under
the validation layer.
RED (before): pytest -k empty_draft under MTL_DEBUG_LAYER=1
MTL_SHADER_VALIDATION=1 -> rc 134, the assertion above.
GREEN: the whole -k spec suite under the validation layer -> 4 passed,
96 subtests (rc 0); without it -> 4 passed, 96 subtests.
Review feedback on #10 (AGENTS.md code standards: 1-2 line comments). The measurements behind the gate are in 0bf8503 and the source commit 73db231.
…#282)

Review feedback on #10: astype(int32) silently truncated floating drafts (1.9 -> 1) and accepted bool. Integer widths are still cast to int32. test_spec_decode_verify_rejects_non_integer_drafts: RED 8 failed (float32/float16/bfloat16/bool x default/cpu), GREEN after the fix.
Review feedback on #10: on Metal with B > 0 and K > 0, mx.export_function of a function calling mx.fast.spec_decode_verify threw "Unable to serialize primitive SpecDecodeVerify" because the PrimitiveFactory did not list it. Registered like the other fast primitives: state() already puts nullptr in the fallback slot, so the imported primitive has no fallback, as RMSNorm, RoPE and ScaledDotProductAttention do after import. The fallback is not needed there: eval_gpu never calls it, and the CPU, K = 0 and B = 0 paths are chosen when the op is built, so they export as plain op compositions.
test_export_import_spec_decode_verify (full accept, reject at 0, batch of 2, K = 0, B = 0; imported results equal the eager call): RED 3 failed (the three B > 0, K > 0 rows), GREEN after the fix, also under MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1.
Review feedback on #10 (AGENTS.md code standards: 1-2 line comments). Comment-only. The block is reached only when the host's K gate (qmv_fast_k_alignment) is finer than block_size: affine 1/4-bit take K % (block_size / 2) == 0. Masking the out-of-row lanes instead (zeroed x_thread, scale and bias) was rejected: qdot would still run, and its weight reads land past the row end and, for the last output rows, past the end of the weight buffer. A K gate that did not keep in_vec_size a multiple of values_per_thread would need the stricter predicate aligned_end + (simd_lid + 1) * values_per_thread <= in_vec_size. The comment came in with 7c1362e (numen-tech/gemma4-qat#179, ml-explore#282).
…e#291)

On an M3 Max (applegpu_g15s) the D512 sdpa_vector_2pass path from ml-explore#4459
decodes Gemma 4 E2B (8 query heads, 1 KV head) 3-4% slower than the
unfused fallback beyond 1024 keys; forcing the fallback with
MLX_SDPA_D512_MIN_KL recovers it. Upstream measured gains on M5 only.
Keep the default on generation 17+; elsewhere use the vector kernel only
when MLX_SDPA_D512_MIN_KL is set.
…tile (gemma4-qat#302)

qmv_quad runs ceil(N / 64) tiles with no N % 64 restriction. On a ragged
last tile, the quad rows past out_vec_size loaded their scale (and, for an
explicit bias, their bias) before the row predicate, reading past the end
of the scales/biases buffers. The qdot and the store were already guarded,
so the output was never wrong. Move both loads inside the existing
predicate; the pointer arithmetic stays outside (forming a pointer reads
nothing). v0.32.4's qmv_quad_impl has the same unguarded structure, so this
is not an upstream change taken over.

test_qmv_quad_ragged_n covers N = 65 (one valid row in tile 2) and N = 72
(eight), at bits 1/2/4/8, gs 32/64, K 64/128 (1-bit K = 128 only), with an
explicit and an implied (0-d factor) bias, against a CPU dequantize
reference. affine_sym never reaches qmv_quad.

Validation: with MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1
MTL_SHADER_VALIDATION_GLOBAL_MEMORY=1, the unfixed kernel reported nothing,
at those shapes nor at fp32 K = 128 gs = 32 N = 1025. A deliberately
out-of-bounds custom kernel (1M elements past a 16-element input) was not
reported either, so GPU validation does not see out-of-bounds device loads
in this setup; the fix rests on the bounds argument above. The test passes
before and after the fix (an allclose cannot see a discarded read).
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T12:24:56.553201Z bb8fa5f New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33ef381f3b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mlx/fast_primitives.h Outdated
Comment on lines +613 to +617
// Fused greedy speculative-decoding verify. Inputs: draft_tokens int32 [B, K]
// and target_tokens int32 [B, K+1] (the target argmax). Outputs: n_accepted
// [B] int32 and committed [B, K+1] int32. The op-level fallback (a plain
// composition) runs on CPU and for autodiff; the Metal eval_gpu runs a trivial
// per-row prefix-match kernel.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the redundant primitive comment

Condense this five-line comment to the required one or two concise lines. It repeats the input/output types already expressed by the class API and adds implementation detail available in the methods, contrary to the repository’s explicit standards for concise, nonredundant comments.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c4d61c: the class comment is one line now.

Comment thread mlx/fast_primitives.h Outdated
bool is_equivalent(const Primitive& other) const override;
std::vector<Shape> output_shapes(const std::vector<array>& inputs) override;
auto state() const {
return std::make_tuple(nullptr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the fallback across SpecDecodeVerify export

When a Metal trace containing this primitive is exported and imported, this state reconstructs SpecDecodeVerify with an empty fallback_. Applying mx.vmap to that imported function—an explicitly supported imported-function transformation—then reaches Custom::vmap, invokes the empty function, and throws std::bad_function_call instead of vectorizing. Serialize enough state to rebuild the fallback or provide a transform override that does not depend on it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c4d61c. The composition now takes B and K from its input shapes, and SpecDecodeVerify builds its fallback from the stream alone, so an imported primitive has a working fallback. state() is gone; the serialized form is unchanged, because nullptr wrote no bytes. test_export_import_spec_decode_verify has a vmap case: it threw std::bad_function_call before the fix, and it passes now.

…#282)

Review feedback on #12: an imported SpecDecodeVerify had an empty fallback (state() put nullptr in its slot), so mx.vmap over an imported function threw std::bad_function_call from Custom::vmap. The composition now takes B and K from its input shapes, and the primitive builds its fallback from the stream alone, so import needs no state. The serialized form does not change: nullptr wrote no bytes. Also condenses the class comment to one line.
test_export_import_spec_decode_verify vmap case (vmap of the imported function equals vmap of the eager call): RED std::bad_function_call, GREEN after the fix. test_fast + test_quantized + test_vmap + test_export_import 167 passed, 2 skipped; C++ JIT and metallib 311/311.
jeethu added a commit to numen-tech/mlx-swift that referenced this pull request Oct 15, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c4d61c5c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mlx/fast.cpp
Comment on lines +1264 to +1265
auto j = broadcast_to(
reshape(arange(K, int32, s), Shape{1, K}, s), Shape{B, K}, s);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the CPU fallback shape-dynamic

When spec_decode_verify is wrapped in mx.compile(..., shapeless=True) on CPU, these Shape{B, K} values and arange(K) are fixed using the first invocation. Reusing the compiled function with another draft length therefore builds comparisons and outputs using the old K, causing shape errors or incorrect results; construct this fallback without embedding trace-time dimensions, or specialize the compiled graph by shape.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e1f993 (test gate in c134ee2). The K > 0 CPU case threw 'Slice cannot infer output shapes' on the first call rather than giving wrong results. A K = 0 first trace did give wrong results, on both CPU and Metal: it returned n_accepted = [0] for every later shape. SpecDecodeVerify now has a native eval_cpu, and the Metal kernel takes K = 0, so every B > 0 call builds the one primitive, whose output shapes come from its inputs. B = 0 keeps its early return, because argmax rejects a zero-size input. CUDA GPU streams still use the composition. test_spec_decode_verify_shapeless_compile traces K > 0 first and K = 0 first, over 5 shapes, on CPU and on Metal: 13 subtests failed before the fix, all pass now.

Comment thread mlx/backend/metal/kernels/quantized.h Outdated
Comment on lines +236 to +240
// 1-bit: read 32 packed weights as one 32-bit word (one load instead of
// four byte loads). Little-endian bit order and accumulation order are
// those of the per-byte path, so the result is bit-exact with it. Needs
// values_per_thread % 32 == 0 and a 4-byte aligned w, which every 1-bit
// caller (qmv_fast, qmv, qmv_quad with D == 128) provides.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the qdot explanation

Condense this six-line implementation note to one or two lines; it repeats the load behavior and caller constraints in excessive detail, contrary to the repository's explicit requirement for concise, nonredundant comments.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cbbbe2f: the comment is two lines now.

…l-explore#282)

Review feedback on #12: the CPU path and the K = 0 path built the op composition, whose shapes (Shape{B, K}, arange(K)) are fixed at trace time. Under mx.compile(shapeless=True) a K = 0 first trace then returned n_accepted = [0] for every later shape on CPU and Metal, and a K > 0 CPU trace threw "Slice cannot infer output shapes". SpecDecodeVerify now has a native eval_cpu and the Metal kernel takes K = 0 (target is bound at the empty draft's slot; the kernel reads no draft for K = 0), so every B > 0 call is the one primitive, whose output shapes come from its inputs. The composition stays as the transform fallback and for CUDA GPU streams. B = 0 keeps its early return (argmax rejects a zero-size input).
test_spec_decode_verify_shapeless_compile (cpu and gpu, K > 0 and K = 0 traced first, 5 shapes each): RED 13 failed, GREEN after the fix, also under MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1. test_fast + test_quantized + test_vmap + test_export_import + test_compile 239 passed, 2 skipped; C++ JIT and metallib 311/311.
Review feedback on #12 (AGENTS.md: concise comments). Comment only; test_quantized 73 passed, 1 skipped.
…ith Metal (ml-explore#282)

Codex on 1e1f993: mx.stream(mx.gpu) throws on a CPU-only build, and a CUDA GPU stream still takes the shape-dependent composition. CPU coverage stays unconditional. test_fast -k spec_decode 6 passed.
jeethu added a commit to numen-tech/mlx-swift that referenced this pull request Oct 15, 2026
…e_verify primitive for every K and on CPU; qdot comment)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c134ee26d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mlx/fast.cpp Outdated
Comment on lines +1318 to +1319
if (B == 0) {
return {zeros(Shape{0}, int32, s), zeros(Shape{0, K + 1}, int32, s)};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep empty-batch handling shape-dynamic

When a shapeless-compiled function is first traced with B == 0, this trace-time branch records two fixed empty zeros results with no dependency on the inputs. Shapeless compilation reuses that graph for later inputs of the same rank and dtype, so a subsequent call with B > 0 still returns empty arrays instead of verifying its tokens. Route empty batches through a shape-dynamic primitive or otherwise avoid branching on the concrete trace-time batch size.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 560cd02. B = 0 now takes the same primitive path as every other B, so one shapeless graph serves every batch size. The argmax op rejects any zero-size input, so the target ids now come from an ArgReduce (ArgMax) primitive built directly, and V = 0 is rejected up front with a clear error.

The B > 0-first order had a second bug, which only the Metal validation layer showed. Replaying that trace at B = 0 dispatched a zero-size grid in both ArgReduce and SpecDecodeVerify. Both evals, CPU and Metal, now return early on an empty output.

CUDA GPU streams still use the composition and its B = 0 early return. That path bakes B and K in as constants, which predates this change; a comment now says so. test_spec_decode_verify_shapeless_compile now also traces B = 0 first and B > 0 first, on CPU and on Metal. Its 10 failing subtests now pass.

Results: Python 965 passed, 57 skipped; C++ 311/311 on Metal, 311/311 on Metal JIT, 274/274 on CPU-only; the new test is clean under MTL_DEBUG_LAYER=1.

ml-explore#282)

The B = 0 early return recorded two constant empty results, so a shapeless
compile first traced with B = 0 returned empty arrays for every later B.
B = 0 now takes the same primitive path as any B: the target ids come from
an ArgMax primitive built directly (the argmax op rejects any zero-size
input), V = 0 is rejected up front, and SpecDecodeVerify's CPU/Metal evals
and Metal ArgReduce return early on an empty output instead of dispatching
a zero-size grid (a B > 0-first trace replayed at B = 0 tripped the Metal
validation layer). CUDA GPU streams keep the composition and its B = 0
early return; that path is not shapeless-safe for a change of B or K.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 560cd028a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mlx/backend/metal/kernels/quantized.h Outdated
Comment on lines +877 to +879
// Rows past out_vec_size exist only on a ragged last tile (N % 64 != 0):
// read no scale or bias for them (gemma4-qat#302; the store below was
// already guarded).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the ragged-tile comment

Condense this three-line note to one or two lines. It repeats the bounds predicate and guarded load behavior visible directly below and includes issue-tracking detail, contrary to the repository requirement for concise, nonredundant code comments.

AGENTS.md reference: AGENTS.md:L31-L32

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7b7e8d2. The comment is now two lines, without the issue reference.

Comment thread mlx/backend/cpu/quantized.cpp Outdated
if (inputs.size() < 4) {
throw std::runtime_error(
"[QuantizedMatmul::eval_cpu] Bias-free affine is Metal-only for "
"now; pass biases (e.g. derive_biases) on CPU.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the nonexistent derive_biases recommendation

When a 1/2-bit bias-free affine matmul is evaluated on CPU, this error directs users to derive_biases, but a repo-wide search finds no definition or binding with that name, so the suggested recovery cannot be followed through MLX. Recommend an actual supported operation for materializing the biases or omit the nonexistent helper name.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9482135. derive_biases doesn't exist. The CPU kernels already accept a 0-d implied-bias factor, so the error now names it: -0.5 for 1-bit and -1 for 2-bit. Those values give the same bias that the Metal bias-free path derives in-kernel (sym_derived_bias). Checked on random 1- and 2-bit weights: on Metal, the bias-free and implied-factor results are bit-identical; on CPU, the implied factor and explicitly materialized biases are bit-identical. test_quantized.py and test_fast.py pass: 112 tests, 2 skipped.

…explore#282)

derive_biases does not exist. The CPU kernels take a 0-d implied-bias factor, and -0.5 (1-bit) / -1 (2-bit) gives the same bias the Metal bias-free path derives (sym_derived_bias).
@jeethu

jeethu commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b7e8d2b1f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +8 to +12
// draft [B, K] int32 — drafted tokens
// target [B, K+1] int32 — target argmax tokens over [last] + draft
// Writes:
// n_accepted [B] — longest matching prefix length (0..K)
// committed [B, K+1] — draft prefix (< n) then the corrected token at n.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the speculative-decode kernel comment

Condense this six-line header to one or two concise lines. The argument names and types directly below already express most of the input/output layout, so the current block is redundant and exceeds the repository's explicit comment-length standard.

AGENTS.md reference: AGENTS.md:L31-L32

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c52689c. The header is now two lines.

Comment thread mlx/fast.cpp
Comment on lines +1344 to +1345
if (fallback) {
return spec_decode_verify_composition(prim_inputs, s);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the CUDA fallback shape-dynamic

On a CUDA GPU, a function wrapped in mx.compile(..., shapeless=True) records this composition with the trace-time B and K; a later call with another batch size or draft length reuses fixed slice, arange, broadcast_to, and zeros shapes, causing shape errors or stale results. In particular, tracing first with B == 0 records the constant-empty return above, so every later nonempty batch remains empty; use a shape-dynamic primitive on CUDA or specialize the compiled graph by shape.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7474d2c, by refusing a shapeless trace on that path rather than making it shape-dynamic. Making the CUDA path shape-dynamic needs a SpecDecodeVerify CUDA kernel, and this fork has no CUDA build or test machine to verify one on. Until it has one, the CUDA fallback throws a clear invalid_argument under detail::in_dynamic_tracing(), so mx.compile(..., shapeless=True) on a CUDA GPU stream now fails loudly instead of returning stale or empty results. That covers the B = 0-first trace too. A plain mx.compile and eager calls are unchanged.

Metal and CPU never reach this path: they take the primitive for every B and K, and test_spec_decode_verify_shapeless_compile already covers them. The guard itself can't be exercised here, because the fallback is CUDA-only.

Comment thread mlx/backend/metal/quantized.cpp Outdated
Comment on lines +159 to +163
// Block of qmv_fast_impl (kernels/quantized.h): pack_factor *
// packs_per_thread * SIMD_SIZE, with one pack per lane for bits <= 2 (must
// match the kernel). The affine kernel's partial last block only needs
// K % values_per_thread == 0, which K % group_size already implies; block / 2
// is the cutoff measured for 1/4-bit, and the other widths stay whole-block

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the qmv alignment explanation

Condense this seven-line tuning note to one or two concise lines. It mixes the expression's derivation, kernel synchronization requirements, benchmark history, and behavior of a separate FP implementation, exceeding the repository's explicit standard for concise inline comments.

AGENTS.md reference: AGENTS.md:L31-L32

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c52689c. The note is now two lines: the block must match the kernel, and affine 1/4-bit takes half a block.

Comment on lines +367 to +368
if (!variant) {
std::string kernel_name = "custom_kernel_" + name;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Serialize first construction of each kernel variant

When multiple threads request the same previously unseen variant concurrently, they can all miss the initial locked lookup and independently execute write_template, write_signature, source allocation, and hashing before the second lock discards every result but one. This defeats the new build-once memoization precisely for concurrent first use, potentially multiplying large source-generation work by the number of racing callers; coordinate construction per key rather than doing it outside the protected section.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713bf19. A missing variant is now built while cache->mutex is held, so concurrent first callers build it once and the rest reuse it. The lock covers only source generation and hashing: the Metal library is compiled later, in CustomKernel::eval_gpu, so the extra serialization is small. Python suite: 965 passed, 57 skipped. C++: 311/311 on Metal, 311/311 on Metal JIT, 274/274 on CPU-only.

…ition (ml-explore#282)

The CUDA GPU composition fixes B and K at trace time (slice, arange, full, zeros), so a shapeless compile replayed at another B or K gave shape errors or stale results (a B = 0-first trace kept returning empty arrays). It now throws under detail::in_dynamic_tracing(); Metal and CPU take the primitive for every B and K.
…e#282)

Concurrent first callers of one variant each built its source and hash before the second lookup kept one. The build now runs under cache->mutex, so it happens once. It covers source generation only; the Metal library is compiled later in CustomKernel::eval_gpu.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 713bf19828

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

bool fast::SpecDecodeVerify::use_fallback(Stream s) {
return s.device == Device::gpu;
}
NO_GPU_MULTI(fast::SpecDecodeVerify)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the fallback for deserialized CUDA primitives

When a function is exported on Metal, SpecDecodeVerify is serialized as a primitive; importing that file in a CUDA build maps its GPU stream to CUDA and evaluates the primitive directly. This NO_GPU_MULTI implementation then throws without consulting use_fallback, even though a newly traced CUDA call supports the operation through its composition. Implement the composition in CUDA evaluation or export a backend-portable graph so Metal-produced .mlxfn files remain usable on CUDA.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one, because exported fast primitives are backend-specific upstream too. An import keeps each primitive and its device and never swaps in the composition. Upstream's GatedDeltaUpdate is the same case: use_fallback is true on CUDA and its CUDA eval_gpu is NO_GPU_MULTI, so a Metal-exported gated_delta_update throws on CUDA in the same way. Fixing it needs a SpecDecodeVerify CUDA kernel, and this fork has no CUDA build or test machine to verify one on. Until it does, the import fails with a clear error (fast::SpecDecodeVerify has no CUDA implementation.) rather than a wrong result. Exporting on CUDA records the composition and works there.

@jeethu

jeethu commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 713bf19828

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mlx/fast.cpp Outdated
Comment on lines +1332 to +1336
// Reuse mlx's fast argmax on-device (result stays on the GPU); the fused
// primitive does only the tiny per-row prefix-match. Primitive inputs are
// int32 token arrays, so the kernel needs no large-V reduction. The ArgMax
// primitive is built directly because the argmax op rejects any zero-size
// input, B = 0 included; V > 0 is checked above.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the argmax implementation note

Condense this five-line comment to one or two lines. It repeats details visible in the direct ArgReduce construction and mixes the optimization rationale with the empty-batch workaround, contrary to the repository requirement for concise, nonredundant comments.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bb8fa5f. The note is now two lines: why ArgMax is built directly, and what the fused kernel does.

Comment thread mlx/backend/metal/quantized.cpp Outdated
Comment on lines +612 to +616
// affine qmv_wide only beats qmv on gen-15+; fp benefits on every gen.
inline bool use_qmv_wide(const std::string& mode, metal::Device& d) {
return mode != "affine" || d.get_architecture_gen() >= 15;
// affine_sym has no qmv_wide kernel. The 1-bit path has so little weight
// traffic that unpacking into registers dominates even when several input
// rows reuse the result. The 2-bit path breaks even after two rows and wins
// once three or more rows share a block.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the qmv_wide tuning explanation

Condense this five-line tuning note to one or two lines. The branches below already express the architecture, bit-width, and row-count gates, while the comment adds detailed performance rationale that exceeds the repository's concise-comment standard.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bb8fa5f. The note is now two lines and keeps only the measured gates.

Comment thread mlx/backend/metal/device.cpp Outdated
Comment on lines +62 to +65
// MTLCompileOptions.mathFloatingPointFunctions is independent of mathMode
// and defaults to Fast, so a Safe/Relaxed request would still compile the
// fast (approximate) transcendental variants. Keep them Precise unless
// fast math was explicitly asked for.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the floating-point mode explanation

Condense this four-line implementation note to one or two lines. The assignment below already shows the Fast versus Precise selection, so the current block repeats implementation detail and exceeds the repository's explicit concise-comment standard.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bb8fa5f. The note is now two lines.

Comment thread mlx/backend/common/metal_kernel.cpp Outdated
Comment on lines +377 to +380
// The generated source depends on the dtypes of the inputs and outputs
// and on how each input is passed (see `write_signature`). Include them
// in the kernel name so that a given name always maps to the same
// source.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Condense the generated-source explanation

Condense this four-line comment to one or two lines. The dtype and input-location suffix construction immediately below already shows what distinguishes generated sources, so the block repeats the implementation and exceeds the repository's concise-comment standard.

AGENTS.md reference: AGENTS.md:L31-L33

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bb8fa5f. The note is now two lines. The same commit condenses every other source comment in this PR that was longer than two lines.

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.

1 participant