Conversation
The gated delta layers fall back to gated_delta_ops whenever the Metal kernel is unavailable, which includes every training step (use_kernel=not self.training, since the kernel has no VJP). That path is a sequential loop over timesteps, so autograd retains one state per token: about 8 MB per token per layer, growing linearly, and superlinear in time. This adds the chunkwise parallel form. Splitting the sequence into chunks of 64 and solving one unit lower-triangular system per chunk keeps O(T / chunk) states instead of O(T) and replaces the per-step loop with matmuls. The dispatch prefers it for scalar gating without a mask; vectorized gating and masked sequences keep using the reference loop. One GDN layer (Hk=16, Hv=32, Dk=Dv=128), forward and backward, M4 Pro: seq sequential chunkwise gain 512 4.29 GB / 0.36 s 0.33 GB / 0.04 s 13x mem, 10x speed 1024 10.18 GB / 1.16 s 0.97 GB / 0.09 s 10x mem, 12x speed 2048 31.44 GB / 4.20 s 1.74 GB / 0.23 s 18x mem, 19x speed 4096 108.33 GB / 39.02 s 4.73 GB / 0.60 s 23x mem, 65x speed Two details worth noting. mx.linalg.tri_inv is CPU-only and has no VJP, so the unit-triangular inverse is built from matmuls by 2x2 block recursion in log2(n) levels, which also parallelizes over the batch. And the textbook derivation divides by the cumulative decay G_j, which underflows and sends the intermediate writes to infinity on strongly decaying heads; the implementation carries bounded ratios G_j / G_i instead, and the tests cover that regime explicitly. Outputs match the sequential path to ~1e-6 relative in fp32, gradients to ~1e-4, across mild and strong decay, grouped-query heads and sequences that are not a multiple of the chunk size.
5ef05c7 to
ea88fdc
Compare
|
this is already implemented in MLX-LM-LoRA which already provides chunked and efficient long context training for all linear models within MLX-LM. you can also manually change the chunk size via: from mlx_lm_lora.trainer.sft_trainer import SFTTrainingArgs
args = SFTTrainingArgs(..., recurrence_chunk_size=512, ...)works with the other algorithms like DPO, ORPO etc. too. |
|
Thanks for the pointer — I hadn't seen
This PR changes the arithmetic. The two compose: your checkpoint wrapper could sit around |
Problem
The gated delta layers fall back to
gated_delta_opswhenever the Metal kernel is unavailable, and that includes every training step —use_kernel=not self.training, because the kernel has no VJP.gated_delta_opsis a sequential loop over timesteps, so autograd retains one state per token.Measured on one layer (Hk=16, Hv=32, Dk=Dv=128), forward and backward, M4 Pro:
About 8 MB per token per layer, growing linearly, and superlinear in time. Five model families route through this during training:
qwen3_5,qwen3_next,kimi_k3,kimi_linear,bailing_moe_v3.Change
gated_delta_opsbecomes a thin dispatcher: scalar gating without a mask takes a newgated_delta_chunkwise, everything else goes togated_delta_sequential, which is the existing loop under a name that says what it is. The existing kernel tests now compare againstgated_delta_sequentialso they keep testing what they tested before.gated_delta_chunkwiseis the chunkwise parallel form of the gated delta rule (arXiv:2406.06484, arXiv:2412.06464). Splitting the sequence into chunks of 64 and solving one unit lower-triangular system per chunk keepsO(T / chunk)states instead ofO(T)and replaces the per-step loop with matmuls.Same setup, with the new path:
Inference is untouched: that path uses the Metal kernel and never reaches this code.
Scope
The dispatch prefers the chunkwise path only for scalar gating without a mask. Vectorized gating (
g.ndim == 4) and masked sequences continue to use the reference loop, so the change cannot alter results for cases it does not cover.Two implementation notes
mx.linalg.tri_invis CPU-only and has no VJP, so it cannot appear in a training graph. The unit-triangular inverse is instead built from matmuls by 2x2 block recursion in log2(n) levels, which autodiff handles natively and which parallelizes across the batch — for the batched 64x64 case here that also measured faster than the LAPACK path (5.9 ms vs 7.4 ms for 1024 matrices, before counting the device round trip).The textbook derivation divides by the cumulative decay
G_j, which underflows and sends the intermediate writes to infinity on strongly decaying heads. This implementation carries bounded ratiosG_j / G_i(at most 1 fori <= j) instead. The tests cover that regime explicitly.Testing
tests/test_gated_delta.pyadds aTestGatedDeltaChunkwisecase covering:gated_delta_opsat mild (0.2) and strong (4.0) decayHv // Hk == 4)Outputs match to ~1e-6 relative in fp32 and gradients to ~1e-4. The existing kernel tests pass unchanged (11 passed, 32 subtests).
Verified to merge cleanly into current
main, and the merged tree passes the suite (training dispatch agrees with the kernel to 1.5e-6).