Skip to content

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 into
889580fc/seq-state-across-stream-countsfrom
cormac/mul-mat-id-backward
Oct 31, 2026
Merged

joelteply merged 3 commits into
889580fc/seq-state-across-stream-countsfrom
cormac/mul-mat-id-backward

Conversation

@joelteply

@joelteply joelteply commented Oct 31, 2026 •

Copy link
Copy Markdown

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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE

…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
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
joelteply force-pushed the cormac/mul-mat-id-backward branch from 74b3649 to 083f261 Compare October 31, 2026 20:41
…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
joelteply merged commit 7640a26 into 889580fc/seq-state-across-stream-counts Oct 31, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant