Add gather_qmm for mixture-of-experts layers - #135
Conversation
mx::gather_qmm is a quantized matmul where each row selects which weight matrix to use. It is the primitive a mixture-of-experts layer needs: the expert weights stay in one stacked tensor and the gather happens inside the kernel, so no per-token copy of the weights is ever materialised. Without it, an MoE layer has to loop over experts and call quantized_matmul once per expert, which scales with the expert count — unusable at the 128 or 256 experts current models ship with. Verified against mx.gather_qmm through the Python bindings on the same machine: maximum absolute difference 6e-8 over 4 experts, 6 rows, 2 experts per row, 4-bit affine weights with group size 64.
|
Thanks! Let's also expose the function in the quantization module as gather_quantized_matmul emlx/emlx/lib/emlx/quantization.ex Line 286 in 09dcaae There's a pattern there for proper fusion in the compiler |
Adds the Nx.Tensor level entry point for gather_qmm, mirroring quantized_matmul/2: a deftransform that computes the output shape and type, builds an Nx.template and goes through Nx.runtime_call, so it fuses in the compiler and is safe inside Nx.Defn.jit-traced forward passes. The batch axes of the activation are broadcast against rhs_indices with Nx.Shape.binary_broadcast, the same right-aligned rule the kernel applies internally, and the output type follows quantized_matmul/2: the scales dtype for "affine", the activation dtype for microscaled modes. The tests build the stacked per-expert weights the way a checkpoint delivers them, through EMLX.quantize/4 and quantized_tensor/5, and cover the reference result, repeated expert indices, sorted_indices, execution inside Nx.Defn.compile, and the dense-second-argument error.
|
Pushed One question about the intended path for building the input, rather than guessing at it.
Is that the path you have in mind, or should |
|
You can increase the shape support to quantize and dequantize! The shape restriction is likely not intentional |
mx::gather_qmmis a quantized matmul where each row selects which weightmatrix to use. It is the primitive a mixture-of-experts layer needs: the expert
weights stay in one stacked
{experts, out, in}tensor and the gather happensinside the kernel, so no per-token copy of the weights is ever materialised.
Without it, an MoE block has to loop over experts and call
quantized_matmulonce per expert. That scales with the expert count, which is 128 in Gemma 4 and
256 in the current Qwen MoE checkpoints — far past the point where a loop is
usable.
What this adds
NIF(gather_qmm)inemlx_nif.cpp, written against the existingquantized_matmulbinding. Same parameters pluslhs_indices,rhs_indicesand
sorted_indices; registered at arity 13 throughASYNC_NIF.EMLX.gather_qmm/11, following the shape ofEMLX.quantized_matmul/8—optional tensors unwrapped the same way, device merged across all six inputs,
dispatched through the same command queue.
test/emlx/gather_qmm_test.exs.Nothing existing is touched: 96 added lines, no deletions.
Correctness
Checked against
mx.gather_qmmthrough the Python bindings on the same machineand the same libmlx build: maximum absolute difference 6e-8 over 4 experts,
6 rows, 2 experts per row, 4-bit affine weights with group size 64.
The tests here cover it independently of Python:
quantized_matmulagainst the expert it namessorted_indices: trueandfalseagreeThe full suite passes: 2672 tests, 825 doctests.
Notes
The shape protocol is the one
mlx_lm'sSwitchGLUuses — input expanded to{..., 1, 1, in}, indices{..., experts_per_row}, output{..., experts_per_row, 1, out}— so an MoE layer built on this can follow thereference implementation directly.
EMLX.quantize/2is rank-2 only, so stacked expert weights have to go throughEMLX.quantize/4. That is a separate matter and is left alone here.