Repository navigation
perf(gated_delta): chunk-parallel differentiable training path for Ga… - #2021
kru2710shna wants to merge 5 commits into
Conversation
…tedDeltaNet During fine-tuning, GatedDeltaNet runs gated_delta_update with use_kernel=not self.training, so training fell back to gated_delta_ops -- a per-token recurrence whose backward scales with sequence length T. The Metal kernel has no VJP, so that loop was the only differentiable path, and step time grew with the number of trained GatedDeltaNet layers (ml-explore#1956). Adds gated_delta_chunked: a chunkwise UT (WY) transform mathematically equivalent to gated_delta_ops that cuts the sequential dependency from T to T/chunk_size. The per-chunk inverse uses a Newton-Schulz iteration (exact in ceil(log2(C)) steps for the unipotent I + strictly-lower matrix), which stays on the GPU and is differentiable by plain autograd -- no custom VJP, no CPU round-trip. gated_delta_update routes training to this path for a scalar gate with no padding mask; decode, masks, vector gates and inference are unchanged. Correctness vs gated_delta_ops (forward and gradients) matches to ~1e-6 across GQA, ragged tails, decay/beta/value extremes and a full-sequence single chunk. Requires normalized keys (the normalize_qk contract). Measured on M-series: ~20x faster at the Qwen3-Next recurrence shape; a full-model Qwen3.5 training step (6/8 GatedDeltaNet layers) is 7.5x faster at T=64 and 9.3x at T=128, growing with T, and trains at T=1024 where the per-token path runs out of memory. Adds six tests to tests/test_gated_delta.py. Fixes ml-explore#1956
|
Hi, thanks for this PR — the chunked training path is exactly what's needed to make Root cause 1: TF32 is enabled by default for fp32 matmulsOn Apple GPUs MLX runs fp32 matmuls through TF32 unless Root cause 2: Newton–Schulz
|
|
Let me take a look and see what can be done in this case !! |
The chunked path computes decay_ratio = exp(log_gamma[i] - log_gamma[j]) for the full CxC block, then masks the upper triangle (i<j) with where(). Under hard gate decay (sum(log g) ~ -170 over a 64-chunk) those masked entries overflow to +inf; the forward is fine, but exp's backward (exp'(x) = exp(x) = inf) meets the zero cotangent from where() and yields 0*inf = NaN, poisoning every q/k/g gradient in real LoRA training. Clamp the exponent with minimum(., 0.0) before exp: a no-op on the used lower triangle (i>=j, where the exponent is always <=0), and the dead upper branch becomes exp(0)=1 with a finite gradient. Also guard log(gc) against zero gates with maximum(gc, 1e-8). Adds a regression test with hard-decaying gates that reproduces the NaN gradient on the unclamped code and passes with the clamp.
|
Thanks again @lgh-hub — really appreciate the hardware testing and the detailed root-cause write-up. Root cause 3 (decay NaN) was real and I’ve pushed a fix (f824cc1): the masked upper triangle of decay_ratio overflows to inf under hard gate decay, and its backward poisoned the gradients. I clamp the exponent with minimum(., 0.0), a no-op on the used lower triangle + a maximum(g, 1e-8) guard before log. Added a regression test that reproduces the NaN on the unclamped code (fails before, passes after). Thank you for catching this — it’s the one that actually breaks real training. On root causes 1 (TF32) and 2 (_tri_inv divergence): I couldn’t reproduce either on my setup (M-series, MLX 0.32.4). My suite passes with TF32 at its default, and the chunked path matches gated_delta_ops to ~1e-6 forward+backward including allow_neg_eigval; the NS inverse and a block-Schur inverse I tried agree to ~1e-6 everywhere fp32 can represent the result (they only diverge together at ‖N‖ large enough that the true inverse overflows fp32, which the normalize_qk contract keeps us well clear of). Could you share the exact shapes/seed/config where you saw the 1e13 _tri_inv error and the 44 TF32 failures? If there’s a real device-dependent regime, I’d rather add the block-Schur inverse + TF32 guard with a test that actually reproduces it than ship a change I can’t trigger. |
|
I’m not a programmer, just an AI enthusiast. The issue analysis, patch, and text in my previous comment were actually all completed by an AI coding assistant (Claude). I apologize for not clarifying this earlier, and I realize this violated your AI usage policy. |
_tri_inv used a Newton-Schulz iteration from the identity. That diverges when A = I + N is near-singular even at modest ||N||: with near-duplicate keys (KKt close to all-ones) at chunk_size 64 its error reaches ~1e9, silently corrupting the chunk output and its gradients. Replace it with a block-Schur recursion: for unit lower-triangular A = [[A11, 0], [A21, A22]] the exact inverse is [[A11^-1, 0], [-A22^-1 @ A21 @ A11^-1, A22^-1]], recursing to 1x1 blocks (trivially [[1]] for a unit diagonal) plus two Newton polish steps. Still pure matmuls -- GPU-friendly and differentiable by autograd, no custom VJP. It is exact (~1e-14) on the near-singular case where NS reached 1e9, unchanged on well-conditioned inputs, and both methods only lose accuracy once the true inverse itself overflows fp32 (which the normalize_qk contract keeps real inputs clear of). Adds a deterministic regression test (identical keys, g=0.95, C=64) that reaches ~1e9 error on the old NS inverse and ~1e-14 on the recursion. Reported by @lgh-hub with a deterministic reproduction.
|
@lgh-hub On TF32 (root cause 1): agreed it’s a benchmark-comparison artifact on Apple GPUs, not a training issue — my suite passes with TF32 at its default, so I’ve left the global default alone. The decay-NaN fix (root cause 3) is in f824cc1. Really appreciate the deterministic repro — it’s what made the inverse fix testable. Credited your report in the commit. |
…tedDeltaNet
During fine-tuning, GatedDeltaNet runs gated_delta_update with use_kernel=not self.training, so training fell back to gated_delta_ops -- a per-token recurrence whose backward scales with sequence length T. The Metal kernel has no VJP, so that loop was the only differentiable path, and step time grew with the number of trained GatedDeltaNet layers (#1956).
Adds gated_delta_chunked: a chunkwise UT (WY) transform mathematically equivalent to gated_delta_ops that cuts the sequential dependency from T to T/chunk_size. The per-chunk inverse uses a Newton-Schulz iteration (exact in ceil(log2(C)) steps for the unipotent I + strictly-lower matrix), which stays on the GPU and is differentiable by plain autograd -- no custom VJP, no CPU round-trip. gated_delta_update routes training to this path for a scalar gate with no padding mask; decode, masks, vector gates and inference are unchanged.
Correctness vs gated_delta_ops (forward and gradients) matches to ~1e-6 across GQA, ragged tails, decay/beta/value extremes and a full-sequence single chunk. Requires normalized keys (the normalize_qk contract).
Measured on M-series: ~20x faster at the Qwen3-Next recurrence shape; a full-model Qwen3.5 training step (6/8 GatedDeltaNet layers) is 7.5x faster at T=64 and 9.3x at T=128, growing with T, and trains at T=1024 where the per-token path runs out of memory.
Adds six tests to tests/test_gated_delta.py.
Fixes #1956