Skip to content

S1-28: GLU_BACK — a VJP for every GLU variant#22

Open
dillon-blake wants to merge 3 commits into
ticket/S1-26-out-prod-idfrom
ticket/S1-28-glu-backward
Open

S1-28: GLU_BACK — a VJP for every GLU variant#22
dillon-blake wants to merge 3 commits into
ticket/S1-26-out-prod-idfrom
ticket/S1-28-glu-backward

Conversation

@dillon-blake

Copy link
Copy Markdown
Owner

Stacked on #21 (S1-26).

ggml could differentiate exactly one member of the GLU family: split SwiGLU, via a SILU_BACK
composite. Fused SwiGLU tripped GGML_ASSERT(src1 && "only split swiglu"), and REGLU / GEGLU /
GEGLU_ERF / GEGLU_QUICK / SWIGLU_OAI hit GGML_ABORT("unsupported glu op for backward pass").
Every Gemma (GEGLU) and gpt-oss (SWIGLU_OAI) model was untrainable.

New op GGML_OP_GLU_BACK at the enum tail. One kernel, one row pass, whichever way the caller
packed its operands. Split SwiGLU keeps its existing composite untouched — it is the FFN of
every dense llama and the one path this project cannot afford to regress.

Derivatives are taken against ggml's own scalar forwards in vec.h, not against a paper: if
ggml's gelu uses the tanh approximation, the correct derivative is the derivative of that.

The ticket's headline blocker is refuted by measurement

The ticket says the GGML_GELU_FP16 lookup table makes GEGLU's MODE_GRAD unpassable — the F32
forward reads an F16 table, so the FD would differentiate a step function.

Tested it. Made the F32 GEGLU forward exact: the error got worse (0.024 vs 0.0049), and the
table-free variants moved just as much. The table is not the cause. That saves a risky change to
inference numerics on every Gemma.

MODE_GRAD is a wiring check for this op, and the code says so

mean_abs_asymm divides by (gn + ga), not (|gn| + |ga|), so a near-zero gradient element sends
that ratio to infinity — and every GLU gradient is a product (dx = dy·g·act'(x)), so near-zero
elements are ordinary. Measured:

MAA
FD noise, 12 runs, conditioned init up to 0.80
drop act'(x) from dx 0.52
drop act(x) from dg 0.59
SWIGLU: drop the x(1-s) term 0.18

The noise overlaps the defects. No tolerance separates them, so none is pretended — the bound
asserts the backward builds, schedules, has the right shapes and does not abort. That much was
impossible before this commit.

Correcting the metric to |gn| + |ga| drops the noise floor to 0.022 and catches every defect
with 3.6–12× margin. It is written and measured and deliberately not in this PR: it also removes
a NaN-based free pass that TANH, SIGMOID and CROSS_ENTROPY_LOSS have relied on since S1-19
(0/0 = NaN; NaN > tol is false; they pass unconditionally). That is ticket S1-37.

So the numerics get a real oracle

tests/test-glu-back.cpp — a new ctest binary that differentiates ggml's own scalar forwards in
float64. All six variants match to ~1e-7.

It catches what MODE_GRAD cannot: perturbing GEGLU's tanh argument by 5% is invisible to MODE_GRAD
and fails here instantly. (It also caught a stale library mid-session, which is how I know it works.)

Also

test_glu never called ggml_set_param, so the fused backward was completely unexercised while
grad -o REGLU still printed OK. It does now — on the base tensor, never the view
(ggml_set_param asserts op == GGML_OP_NONE).

An odd-width fused a was the first thing to bite: ggml_glu halves the width with integer
division
, so ne_a[0]=5 leaves a trailing element the forward never reads. It gets a zero gradient
— and it must be written zero, or d_a isn't a->ne[0] wide and compute_backward's same-shape
assert fires.

ggml could differentiate exactly ONE member of the GLU family: split SwiGLU, via a
SILU_BACK composite. Fused SwiGLU tripped `GGML_ASSERT(src1 && "only split swiglu")`,
and REGLU / GEGLU / GEGLU_ERF / GEGLU_QUICK / SWIGLU_OAI hit
`GGML_ABORT("unsupported glu op for backward pass")`. Every Gemma (GEGLU) and gpt-oss
(SWIGLU_OAI) model was untrainable.

New op GGML_OP_GLU_BACK at the enum tail. One kernel, one row pass, whichever way the
caller packed its operands: dst carries d_a's shape, and a split forward views half 0 as
d_a and half 1 as d_b. Split SwiGLU keeps its existing SILU_BACK composite untouched --
it is the FFN of every dense llama, it is covered today, and routing it through the new
op would change nothing mathematically while risking a regression in the one place this
project cannot afford one.

Derivatives are taken against ggml's OWN scalar forwards in vec.h, not against a paper:
if ggml's gelu uses the tanh approximation, the correct derivative is the derivative OF
THAT, and a finite difference will insist on it.

An odd-width fused `a` was the first thing to bite. ggml_glu computes its output width as
a->ne[0]/2 with INTEGER division, so the sweep's ne_a[0]=5 leaves a trailing element the
forward never reads. It gets a zero gradient -- and it has to be WRITTEN zero, not left as
whatever the allocator handed us, or d_a is not a->ne[0] wide and compute_backward's
same-shape assert fires.

THE TICKET'S HEADLINE BLOCKER IS REFUTED BY MEASUREMENT.

It says the GGML_GELU_FP16 lookup table makes GEGLU's MODE_GRAD unpassable, because the
F32 forward reads an F16 table and the FD would differentiate a step function. Tested by
making the F32 GEGLU forward exact: the error got WORSE (0.024 vs 0.0049), and the
table-free variants moved around just as much. The table is not the cause. That saves a
risky change to inference numerics on every Gemma.

MODE_GRAD IS A WIRING CHECK FOR THIS OP, AND THE COMMENT SAYS SO.

mean_abs_asymm divides by (gn + ga), not (|gn| + |ga|), so a near-zero gradient element
sends the ratio to infinity -- and every GLU gradient is a PRODUCT (dx = dy*g*act'(x)), so
near-zero elements are ordinary, not exotic. Measured: FD noise reaches 0.80 while a
genuinely broken kernel measures 0.18-0.60. They OVERLAP. No tolerance separates them, so
none is pretended: the bound asserts the backward builds, schedules, has the right shapes
and does not abort. That much was impossible before this commit.

Correcting the metric to |gn| + |ga| drops the noise floor to 0.022 and catches every
defect with 3.6-12x margin. It is written and measured and it is NOT here, because it also
removes a NaN-based free pass that TANH, SIGMOID and CROSS_ENTROPY_LOSS have relied on
since S1-19 (0/0 = NaN; `NaN > tol` is false; they pass unconditionally). That is ticket
S1-37.

So the NUMERICS get a real oracle: tests/test-glu-back.cpp, a new ctest binary that
differentiates ggml's own scalar forwards in float64. All six variants match to ~1e-7. It
catches what MODE_GRAD cannot -- perturbing GEGLU's tanh argument by 5% is invisible to
MODE_GRAD and fails here instantly. (It also caught a stale library mid-session, which is
how I know it works.)

test_glu now calls ggml_set_param -- it never did, so the FUSED backward was completely
unexercised while `grad -o REGLU` still printed OK. The param is the BASE tensor, never the
view: ggml_set_param asserts op == GGML_OP_NONE.
“Dillon added 2 commits July 14, 2026 13:41
Two bugs in MODE_GRAD's error metric, and they compound.

1. THE DENOMINATOR WAS THE SIGNED SUM.

       const float asymm = (a[i] - b[i]) / (a[i] + b[i]);

   So the ratio goes to infinity whenever the two gradients nearly cancel, and in
   particular whenever the TRUE gradient is near zero and both are rounding noise. That
   is not exotic: any op whose output is a product has such elements for ordinary inputs
   (every GLU: dx = dy * g * act'(x)), and any op summing over a selected subset can
   manufacture them (MUL_MAT_ID's expert routing).

   Measured on GLU_BACK, with the kernel verified exact against a float64 reference the
   whole time: the "noise" reached MAA 1.80 while a genuinely broken kernel measures
   0.18-0.60. The noise OVERLAPPED the defects, and because the metric is unbounded, NO
   tolerance was safe. S1-28 had to ship its grad cases with a 0.9 bound -- i.e. a test
   that asserted almost nothing -- and it STILL flaked.

   Now |a| + |b|. That is what a symmetric relative error is, it is bounded in [-1, 1],
   and since |a|+|b| >= |a+b| it can only ever make MAA smaller. GLU's noise floor drops
   from 1.80 to 0.022, and its bound is now 5e-2: 2.3x above the noise, 3.6-90x below
   every one of six injected defects, all six caught.

2. AN EXACTLY-ZERO GRADIENT WAS AN UNCONDITIONAL PASS.

   a == b == 0 gives 0/0 = NaN. `NaN > max_maa_err()` is FALSE. The case passes.

   tanh(150) and sigmoid(150) are 1.0 to float precision -- derivative exactly zero -- and
   test_unary initializes in [-150, 150]. TANH AND SIGMOID HAVE BEEN ON THE VENDOR-BUMP
   ALLOWLIST SINCE S1-19 ON THE STRENGTH OF A NaN. So has CROSS_ENTROPY_LOSS, whose
   softmax is one-hot at [-100, 100]. Their gradients had never once been compared.

   With the metric fixed they are checked for the first time and fail: MAA 0.14, 0.37 and
   0.60. The kernels are fine -- a grad_eps of 15 against a range of 150 measures nothing
   a derivative would recognise. Conditioned (range +-3, eps 0.02 for the saturating
   unaries; range +-3 for CE) the residual drops to 5.5e-4, 1.1e-2 and 3.8e-3, and each
   gets a measured bound. The EVAL sweep keeps its wide range: it is hunting NaNs in the
   tails, which is the opposite requirement.

Also pinned, both measured, both previously flaking about 1 run in 15 against the default
1e-4: ADD_ID (identity VJP, noise 1.2e-4 -> 1e-3) and CONCAT (routing VJP, noise 2e-4 ->
1e-3).

Full 16-op grad sweep: 0/15 runs failing.
Found by training one. Neither could have been found any other way, and that is the
point of the end-to-end test.

1. DIV'S BACKWARD DID NOT HANDLE A BROADCAST src1. MUL's always has.

   d/d(b) of (a/b) is -a/b^2, which has a's shape. When b is broadcast against a, that
   gradient must be REDUCED back onto b's shape with repeat_back -- exactly as MUL does
   six lines above it. Without that, ggml_build_backward_expand aborts on its own
   same-shape assert.

   build_moe_ffn normalizes the top-k router weights with

       weights = div(weights[n_used, n_tok], sum_rows(weights)[1, n_tok])

   so EVERY MoE model with norm_w -- which is every Mixtral -- hits it the moment a
   gradient reaches the router. MUL_MAT_ID having a backward was necessary and nowhere
   near sufficient.

2. ggml_get_rows_back REQUIRED A MATRIX GRAD AND A VECTOR INDEX TENSOR.

       GGML_ASSERT(ggml_is_matrix(a) && ggml_is_vector(b) && ...)

   while ggml_get_rows itself has always been fully general:

       out[i0, i10, i11, i12] = a[i0, b[i10,i11,i12], i11, i12]

   So any get_rows with a 3D source and a 2D index tensor had a forward and no backward.
   build_moe_ffn gathers each token's top-k router probabilities with exactly that:

       weights = get_rows(probs[1, n_expert, n_tok], selected_experts[n_used, n_tok])

   Generalized to the real semantics; the old 2-D loop is the i11 == i12 == 0 slice of the
   new one. Its flat indexing of src0 was also only correct by accident -- it ignored the
   structure entirely, which is why test_get_rows_back could build a grad of [n, r, b]
   against a [r/2, b] index tensor and still pass. Those shapes are now consistent.

WHY NO TEST COULD SEE EITHER OF THESE.

test_bin_bcast gated MODE_GRAD on

    // The backward pass supports broadcasting only for GGML_ADD:
    const bool grad_supported = op == ggml_add && ggml_are_same_shape(a, b[0]) && ...

Two consequences. MUL and DIV were never grad-tested AT ALL, at any shape. And no
BROADCASTING case was ever grad-tested, for any op -- the same-shape clause excluded them.
So DIV's broadcast backward was structurally invisible.

The class was already tuned for it: grad_eps, grad_precise and max_maa_err all branch on
`op == ggml_div` / `op == ggml_mul`. All of that was dead code behind the gate.

Now: ADD 59 grad cases, MUL 59 (previously ZERO), DIV 57, GET_ROWS 111, GET_ROWS_BACK 55.
DIV's bound is 5e-3, measured -- its gradient is second order in b, so its finite
difference is intrinsically noisier than ADD's or MUL's.

Full 21-op grad sweep: 0/12 runs failing. Forwards unregressed.
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