Repository navigation
ggml: MUL_MAT_ID has a backward pass through frozen experts, so a LoRA trains on a MoE base (GGML_OP_MUL_MAT_ID_BACK; continuum card 14b64aa9) - #82
Merged
joelteply merged 3 commits intoOct 31, 2026
Conversation
…A trains on a MoE base (GGML_OP_MUL_MAT_ID_BACK; continuum card 14b64aa9)
The resident base on the M5 (Qwen3.6-35B-A3B) could not be trained: MUL_MAT_ID had no
backward, so building the gradient graph of any MoE model aborted in
ggml_build_backward_expand ("no backward through MUL_MAT_ID"), inside the serving engine
that /train runs in. A LoRA on attention needs the activations' gradient through every later
expert FFN, with the experts themselves frozen (and usually quantized).
- GGML_OP_MUL_MAT_ID_BACK / ggml_mul_mat_id_back(as, grad, ids):
dst[:, i, t] = sum_r as[:, r, ids[i, t]] * grad[r, i, t], one row per routed slot, so each
dst row has exactly one writer. A forward whose b broadcast over the slots sums them back with
ggml_repeat_back (whose tiling is mul_mat_id's i % n_b).
- CPU kernel: task 0 groups the (slot, token) pairs by expert, as the forward does; each task
takes whole experts, dequantizes one in GGML_OUT_PROD_KT-row tiles once (the out_prod tiling)
and adds each tile row, scaled by its gradient, into the slots routed to it. F32, F16 and
every type with to_float. The gather form (src[3] expert pointers) is refused, as for the
forward.
- MUL_MAT_ID backward: only the activations' gradient. A gradient for the experts asserts,
because they train frozen. ids (src[2]) and the gather's pointer table (src[3]) are never
differentiated.
- GET_ROWS_BACK on batches: a MoE gathers its router weights per token
([1, n_expert, n_tokens] by [n_used, n_tokens]); the constructor and the CPU F32 kernel
scatter-add batch for batch (2-D unchanged). Metal and Vulkan supports_op accept only the 2-D
form their kernels compute; CUDA already did.
Upstream ggml-org#22704 (closed) was not ported: with quantized experts it ignores src1 too, cutting
the gradient through the MoE (a truncated, biased gradient), and it turns the in-place assert
into warn-and-skip.
Tests (test-backend-ops):
- grad: test_mul_mat_id_grad (per-slot and broadcast b, two KT tiles) and test_get_rows_grad
(batched, every row gathered), each weighted by a fixed random tensor before the SUM loss.
- eval: test_mul_mat_id_back across F32, F16, Q4_0, Q8_0, Q4_K and Q6_K experts, and batched
test_get_rows_back.
- Mutations (enrico, CPU, 2026-10-31):
- dropping the tile offset k0: 8/8 grad lines FAIL;
- writing every slot to slot 0: 8/8 FAIL;
- dropping get_rows_back's batch index: FAIL, MAA 0.94.
- Full MUL_MAT_ID and GET_ROWS grad suites on CPU: 0 FAIL.
Found on the way, not changed here: the grad metric (a-b)/(a+b) is NaN where both are 0, and a
NaN mean never exceeds the threshold. So any case with an untouched row passes vacuously,
including every existing GET_ROWS grad case. The tests above gather every row, so they can't.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
joelteply
changed the base branch from
master
to
889580fc/seq-state-across-stream-counts
October 31, 2026 20:33
joelteply
added a commit
that referenced
this pull request
Oct 31, 2026
…d the backward asserts on the pointer-table gather (Fable on #82) - The routed list was n_expert x n_slots pairs (256 experts x 8 used x 4k tokens: ~64 MB per plan); a counting sort (count, prefix sum, fill) keeps one entry per pair, n_slots x 2 int32. - No graph builds ggml_mul_mat_id_gather for training (no caller in src/, tools/ or common/), so the backward asserts on a forward with src[3] instead of passing a pointer table to an op no backend computes in that form; its backward is designed when a caller exists. Grad (enrico CPU): test_mul_mat_id_grad 8/8 OK, 0 FAIL after the change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
…d the backward asserts on the pointer-table gather (Fable on #82) - The routed list was n_expert x n_slots pairs (256 experts x 8 used x 4k tokens: ~64 MB per plan); a counting sort (count, prefix sum, fill) keeps one entry per pair, n_slots x 2 int32. - No graph builds ggml_mul_mat_id_gather for training (no caller in src/, tools/ or common/), so the backward asserts on a forward with src[3] instead of passing a pointer table to an op no backend computes in that form; its backward is designed when a caller exists. Grad (enrico CPU): test_mul_mat_id_grad 8/8 OK, 0 FAIL after the change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
joelteply
force-pushed
the
cormac/mul-mat-id-backward
branch
from
October 31, 2026 20:41
74b3649 to
083f261
Compare
…col's patch version moves to 2 Fork CI on #82 failed every platform at ggml-rpc.h:14's static assert: a new op changes what an RPC server and client agree on, so the protocol patch version moves with the count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
joelteply
merged commit Oct 31, 2026
7640a26
into
889580fc/seq-state-across-stream-counts
21 of 41 checks passed
joelteply
added a commit
that referenced
this pull request
Oct 31, 2026
…PU (stacked on #82; continuum card 14b64aa9) #82 adds GGML_OP_MUL_MAT_ID_BACK with a CPU kernel; on Metal the scheduler would run that one op on the CPU inside a GPU graph. This is the Metal kernel. - kernels/out_prod.metal: kernel_mul_mat_id_back, in kernel_out_prod's template shape <T0, block_q, nl, dequantize_func>. One threadgroup per routed slot (i, t); each thread owns columns of that slot's row and walks the expert's rows, so every dst element has one writer and no expert is copied or transposed in device memory. nl == 0 reads F32/F16/BF16 element by element; nl > 0 dequantizes 16 consecutive elements of a row in registers (ne00 % 16 == 0). Instantiated for f32, f16, bf16 (GGML_METAL_HAS_BF16), q4_0, q4_1, q5_0, q5_1, q8_0 and q2_K-q6_K: out_prod's set. - ggml-metal-ops.cpp: - ggml_metal_op_mul_mat_id_back dispatches (n_used, n_tokens) threadgroups; - ggml_metal_op_mul_mat_id_back_supported refuses the pointer-table gather (src[3]); - out_prod's weight-type switch is now one predicate, ggml_metal_frozen_weight_readable, shared by both training kernels. - ggml-metal-device.*: - the pipeline getter (kernel_mul_mat_id_back_<type>); - supports_op, with bf16 requiring has_bfloat. Not run here: enrico's Metal driver is wedged, and the Metal toolchain could not be installed to compile-check the shader. The C++ side compiles (clang -fsyntax-only on ggml-metal-ops.cpp). Proof is on the M5: - `test-backend-ops test -o MUL_MAT_ID_BACK -b Metal` against CPU, for every expert type in test_mul_mat_id_back; - `test-backend-ops grad -o MUL_MAT_ID -b Metal`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE
joelteply
added a commit
that referenced
this pull request
Oct 31, 2026
…PU (stacked on #82; continuum card 14b64aa9) (#83) #82 adds GGML_OP_MUL_MAT_ID_BACK with a CPU kernel; on Metal the scheduler would run that one op on the CPU inside a GPU graph. This is the Metal kernel. - kernels/out_prod.metal: kernel_mul_mat_id_back, in kernel_out_prod's template shape <T0, block_q, nl, dequantize_func>. One threadgroup per routed slot (i, t); each thread owns columns of that slot's row and walks the expert's rows, so every dst element has one writer and no expert is copied or transposed in device memory. nl == 0 reads F32/F16/BF16 element by element; nl > 0 dequantizes 16 consecutive elements of a row in registers (ne00 % 16 == 0). Instantiated for f32, f16, bf16 (GGML_METAL_HAS_BF16), q4_0, q4_1, q5_0, q5_1, q8_0 and q2_K-q6_K: out_prod's set. - ggml-metal-ops.cpp: - ggml_metal_op_mul_mat_id_back dispatches (n_used, n_tokens) threadgroups; - ggml_metal_op_mul_mat_id_back_supported refuses the pointer-table gather (src[3]); - out_prod's weight-type switch is now one predicate, ggml_metal_frozen_weight_readable, shared by both training kernels. - ggml-metal-device.*: - the pipeline getter (kernel_mul_mat_id_back_<type>); - supports_op, with bf16 requiring has_bfloat. Not run here: enrico's Metal driver is wedged, and the Metal toolchain could not be installed to compile-check the shader. The C++ side compiles (clang -fsyntax-only on ggml-metal-ops.cpp). Proof is on the M5: - `test-backend-ops test -o MUL_MAT_ID_BACK -b Metal` against CPU, for every expert type in test_mul_mat_id_back; - `test-backend-ops grad -o MUL_MAT_ID -b Metal`. Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The resident base on the M5 (Qwen3.6-35B-A3B) could not be trained: MUL_MAT_ID had no
backward, so building the gradient graph of any MoE model aborted in
ggml_build_backward_expand ("no backward through MUL_MAT_ID"), inside the serving engine
that /train runs in. A LoRA on attention needs the activations' gradient through every later
expert FFN, with the experts themselves frozen (and usually quantized).
dst[:, i, t] = sum_r as[:, r, ids[i, t]] * grad[r, i, t], one row per routed slot, so each
dst row has exactly one writer. A forward whose b broadcast over the slots sums them back with
ggml_repeat_back (whose tiling is mul_mat_id's i % n_b).
takes whole experts, dequantizes one in GGML_OUT_PROD_KT-row tiles once (the out_prod tiling)
and adds each tile row, scaled by its gradient, into the slots routed to it. F32, F16 and
every type with to_float. The gather form (src[3] expert pointers) is refused, as for the
forward.
because they train frozen. ids (src[2]) and the gather's pointer table (src[3]) are never
differentiated.
([1, n_expert, n_tokens] by [n_used, n_tokens]); the constructor and the CPU F32 kernel
scatter-add batch for batch (2-D unchanged). Metal and Vulkan supports_op accept only the 2-D
form their kernels compute; CUDA already did.
Upstream ggml-org#22704 (closed) was not ported: with quantized experts it ignores src1 too, cutting
the gradient through the MoE (a truncated, biased gradient), and it turns the in-place assert
into warn-and-skip.
Tests (test-backend-ops):
(batched, every row gathered), each weighted by a fixed random tensor before the SUM loss.
test_get_rows_back.
Found on the way, not changed here: the grad metric (a-b)/(a+b) is NaN where both are 0, and a
NaN mean never exceeds the threshold. So any case with an untouched row passes vacuously,
including every existing GET_ROWS grad case. The tests above gather every row, so they can't.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE