Fuse the Gated Delta Net inter-chunk scan into a Pallas TPU kernel - #4348
Fuse the Gated Delta Net inter-chunk scan into a Pallas TPU kernel#4348bzantium wants to merge 3 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
c9e1654 to
71b072a
Compare
|
This PR has been automatically marked as stale because it has not had recent activity. It will be closed soon if no further activity occurs. Thank you for your contributions. |
|
This PR was closed because it has been inactive for a while. Please reopen it if you are still working on it. |
|
Independent reproduction, on a dense GatedDeltaNet config: d_model 4096, 32 layers, one full-attention layer in four,
Loss matches the control to three decimals in every pair. It rebases onto current main with one conflict, an import block in Two things I hit that aren't in the description. Peak memory falls by about a third. The description says peak memory is unchanged. On this config sequence 16384 runs on a v6e-8 with the kernel and OOMs without it. Where both OOM, the reported requirement falls 31% at 32768 (86.39G to 59.59G) and 35% at 65536 (259.68G to 169.83G). Worth claiming, since it buys a sequence length main can't reach. The Would you consider reopening? The gain is large and it's a rebase rather than new work. Happy to carry that. |
|
cc @mmcsa |
|
@WandLZhang Reopened. Thanks for taking the time to reproduce this, and for offering to carry the rebase. On the numbers, our original run was v5e-256 at around +8%, so it's good to see +19-24% on v6e/v5p. Nice that it holds up on the newer chips. On peak memory, you're right that "unchanged" was too conservative. The recurrent state stays in VMEM across the inter-chunk walk instead of getting written back to HBM every scan step, so the drop makes sense. A 31-35% reduction that lets you fit a sequence length main OOMs on is worth calling out, I'll update the description. On the context-parallel issue, good catch. The use_pallas branch returns before the code below it, so the fused path only threads a single initial_state/final_h and never composes state across sequence-sharded shards. Each shard just starts from its own h0. It doesn't surface in this PR's configs because main keeps the sequence replicated (batch/head sharding only), so there's no upstream state for a shard to inherit, which is why ctx=1 loss matches to three decimals. It only shows up once you shard the sequence, like your ctx=2 comparison. I added a note on the branch (dd5f9b0) so anyone running context parallelism knows to compose per-shard state around the call. If you want to upstream your affine-composition patch on top of this, happy to coordinate. Branch is rebased and pushed. I'll take the import conflict in models/qwen3.py. |
dd5f9b0 to
4e030c1
Compare
The Qwen3-Next GDN has two sequential computations that dominate TPU step time and cannot overlap other work: the inter-chunk recurrence (a lax.scan whose recurrent state round-trips HBM every chunk) and the UT transform inverse A = (I+S)^-1 (solve_triangular, row-sequential, barely uses the MXU). This fuses both into Pallas kernels. - Inter-chunk scan kernel: one kernel per (batch, heads) with the state resident in VMEM; backward walks the chunks in reverse with FLA-style checkpointing, initial/final state first-class so the custom_vjp covers the full state chain. The chunk-parallel WY math stays in XLA (TPU grid cells run sequentially per core; fusing batched matmuls measured slower). Output emitted in the compute dtype. - Triangular inversion kernel: blockwise doubling ladder X <- X - X @ S_l @ X, which never forms powers of S (those overflow f32) so it is stable wherever the inverse is representable; manual bf16x3 operand splitting recovers f32-grade products on the bf16 MXU (Mosaic's default f32 dot truncates to bf16 and breaks (I+S)A = I); analytic backward dS = -(A^T dA A^T). - Head/tile batching: the walks are sequential but heads (scan) and chunk tiles (inversion) are independent, so batching them per grid cell turns each dot into a batched dot_general the MXU can pipeline. Engaged automatically for training on TPU backends; prefill, decode, and non-TPU paths keep the existing lax.scan/solve_triangular implementation. Measured on v5e-256 (Qwen3-Next dense-8B, seq 4096, bs 1, FSDP, remat full): 3.373s -> 2.571s/step (+31% throughput), loss trajectories matching to bf16 rounding level. tests/unit/gated_delta_network_kernel_test.py: 10 CPU (interpret) tests covering both kernels against lax.scan/solve_triangular references (forward, final state, gradients, non-zero initial state, final-state cotangent, bf16, and the use_pallas dispatch end-to-end).
The backward dS = -A^T dA A^T used plain jnp.matmul (TPU default: one bf16 pass) while the forward ladder uses bf16x3 and the other GDN matmuls use HIGHEST; A's large dynamic range made the bf16 truncation leak gradient error on TPU, invisible to the CPU interpret tests.
…tion The use_pallas branch returns before the code below it, so the fused kernel threads a single initial_state / final_h but does not compose recurrent state across context-parallel (sequence-sharded) shards. Note this on the branch so callers that shard the sequence know to compose per-shard state around the call.
4e030c1 to
9955588
Compare
|
Thanks for reopening, and for the NOTE. Re-verified against your rebase. Applying our patch to the new base On the context-parallel side, the note covers it for callers. The composition itself is in #4932. The inter-chunk step is
I can send that as a PR on top once yours merges. Let me know. |
|
@WandLZhang thanks for re-verifying against the rebase, glad the numbers held. The affine-composition approach makes sense to me. Folding each device's local chunks into a single Please do send the context-parallel PR on top once this lands, that's a natural follow-up and I'd be happy to review it. I'll ping the maintainers to take another look here so it can merge. |
|
Thanks. I'll send the context-parallel PR once this lands. One design question:
Two options: Return it. Or form I lean to the second: your signature stays untouched and the composition stays where the collective already is. If returning |
|
Rethinking the ordering. At 1M the two can't both be live. The composition needs each device's So they're disjoint rather than stacked. I can send the context-parallel PR against |
|
@WandLZhang makes sense, disjoint is the right call. Gating I checked the fallback is loss-preserving before agreeing: the kernel path and the XLA scan path match to about 6e-6 relative on the output with no initial state, and 7e-6 output / 2e-5 final-state with an On ordering, since they're disjoint either way works and it's just the one |
Description
Fuses the sequential parts of the Qwen3-Next Gated Delta Net into Pallas TPU kernels: the inter-chunk recurrence, the UT-transform triangular inversion, and head/tile batching to keep the MXU pipelined through both. Together: 3.373s → 2.571s/step (+31% throughput) at the reference config.
FIXES: #4347
Changes
lax.scanwhose state round-trips HBM every chunk. Backward walks the chunks in reverse (FLA-style checkpointing); initial/final state are first-class, so thecustom_vjpcovers the full state chain. The chunk-parallel WY math stays in XLA on purpose — TPU grid cells run sequentially per core, and fusing batched matmuls into the walk measured slower.solve_triangular(row-sequential, no MXU use) forA = (I+S)^-1. Blockwise ladder: withX = (I+S_b)^-1at block sizeb, the size-2binverse is exactlyX - X @ S_l @ X, so doubling fromb=1needs2·log2(C)full-width matmuls. It never forms powers ofS(those overflow f32 once|S| > 1), so intermediates stay at the true inverse's scale — stable wherever the answer is representable. Products use manual bf16x3 operand splitting (Mosaic's default f32 dot truncates to bf16, which breaks(I+S)A = Ienough to destabilize the recurrence;Precision.HIGHis unsupported). Backward is analytic:dS = -(A^T dA A^T), strict-lower masked.dot_generalthe MXU can pipeline instead of paying fill/drain per short matmul.Peak memory is unchanged — the eliminated HBM traffic was latency-hidden, which is also why the gains come specifically from the sequential paths that cannot overlap.
Performance
v5e-256, Qwen3-Next dense-8B (emb 4096, 36 layers, GDN 16/32 heads, head dim 128), seq 4096, bs 1, bf16, FSDP,
gdn_chunk_size=128, remat full, synthetic data:Loss trajectories match to bf16 rounding level at every step.
Tests
python3 -m pytest tests/unit/gated_delta_network_kernel_test.py— 9 tests on CPU (interpret mode): scan kernel vs alax.scanreference (forward, final state, all gradients, non-zero initial state and final-state cotangent, bf16 dtypes); inversion vssolve_triangular(random, all-ones, unnormalized gaussian; forward + gradients). End-to-end training verified on v5e-256 (table above). pyink / pylint clean.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.