Add heads_per_tile (multi-head-per-tile) support to Ulysses ring attention#444
Open
edgexyz wants to merge 4 commits into
Open
Add heads_per_tile (multi-head-per-tile) support to Ulysses ring attention#444edgexyz wants to merge 4 commits into
heads_per_tile (multi-head-per-tile) support to Ulysses ring attention#444edgexyz wants to merge 4 commits into
Conversation
…s for ulysses ring attention
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds a
heads_per_tile(mhpt — multi-head-per-tile) option to the tokamax / Ulysses ring splash-attention forward kernel (_splash_attention_forward_ring_raw). Instead of processing a single attention head per Pallas grid program, the kernel can now processheads_per_tileheads per program, amortizing per-tile overhead (grid launch, mask/scalar prefetch, VMEM pipelining) across several heads. This mirrors the existing mhpt fast-path that already exists for the non-ring custom splash kernel (ulysses_custom), extending the same optimization to theulysses_ring/tokamax_ringattention modes used by Wan2.2 I2V on TPU v6e.heads_per_tileis a pure tiling/scheduling choice: for a fixed input it must produce numerically identical results toheads_per_tile=1. The correctness evidence below shows it does (bit-identical output), and the perf motivation is that it lets a v6e-16 run tile 40 DiT heads into 20 (hpt=2) grid programs.ulysses-ring-vmem-passthrough— basemain@f62927fmain):049210a—feat(splash-attention): heads_per_tile + configurable vmem_limit_bytes for ulysses ring attention. The feature itself; the block-index detail below is already correct here, and the mhpt invariance unit test ships in this same commit.b47433e—docs(splash-ring): document mhpt kernel + dispatch design rationale.0d5d0f8—test(splash-ring): make mhpt heads_per_tile test pass under multi-host.main): 5 files,+372 / −25(feature commit049210aalone: 4 files,+311 / −25).Motivation
The Wan2.2 DiT (
num_attention_heads = 40) runs self/cross attention through splash attention. On the ring path each head was launched as its own grid program along grid dim 0. Batching multiple heads per program reduces launch/prefetch overhead and improves VMEM reuse. The non-ring custom kernel already had an mhpt path; this PR brings feature parity to the ring kernel soattention=ulysses_ring flash_block_sizes={... "heads_per_tile":N}is usable.What changed
src/maxdiffusion/kernels/splash_attention/splash_attention_kernel.pyflash_attention_kernel_mhpt(per-tile inner loop overheads_per_tileheads with per-headm/l/oscratch);SplashConfig.heads_per_tilefield + validation;_splash_attention_forward_ring_rawnow selects the mhpt kernel, sizes the head-dimBlockSpecblock toheads_per_tile, adjusts the grid tonum_q_heads // heads_per_tile, and appends_hpt<N>to the kernel name.src/maxdiffusion/max_utils.pyTokamaxRingFlashBlockSizeshashable carrier and a branch inget_flash_block_sizessoulysses_ring/tokamax_ringpropagateheads_per_tile(plus block sizes) fromconfig.flash_block_sizes.src/maxdiffusion/models/attention_flax.pyconvert_to_tokamax_splash_confignow threadsheads_per_tile(default 1) into theSplashConfig.The three files above are the core mechanism. The rest of the branch: the mhpt invariance unit test in
ring_attention_kernel_test.py(added in feature commit049210a) and theassert_allclose_mcjaxhelper insplash_attention_test_utils.py(0d5d0f8) — see Correctness evidence; the in-code kernel/dispatch rationale docstring (b47433e); and a configurablevmem_limit_bytesbundled into the feature commit (out of scope for this summary).Feature scope / guards
The mhpt ring path deliberately raises
NotImplementedErroroutside the validated regime, so misconfigurations fail rather than silently miscomputing:num_q_heads == num_kv_heads; MQA/GQA rejected),q_sequence, nomask_function),HEAD_DIM_MINORQ/K/V layouts only.heads_per_tile=1is the default everywhere and is completely unaffected (the mhpt kernel and block-size changes are only engaged whenheads_per_tile > 1).Key correctness detail — Pallas block-index vs. element-index
The subtlest part of
heads_per_tile>1is a Pallas block-index vs. element-index trap: getting it wrong produces broken output while still exiting cleanly (exit_status=0), so it is documented here as the crux of the correctness story. The kernel in this PR gets it right; the explanation below is the rationale (and a guard against reintroducing it).In a Pallas
BlockSpec, the index map returns a block index; Pallas multiplies it by the block size to get the element offset. The head-dim block size isheads_per_tile, so the index maps must not also multiply the program id —his already the head-tile index:Failure mode if done wrong (Wan DiT,
num_q_heads=40,heads_per_tile=2, grid dim 0 =40/2 = 20programs): programhwould write output heads starting ath*2*2 = 4hinstead of2h. Only heads{0,1,4,5,8,9,…,36,37}plus the clamped tail{38,39}would ever be written — 18 of 40 heads would stay at their uninitialized/zero value, with reads scrambled the same way. The pipeline still runs to completion (exit_status=0) but the decoded video is effectively static — which is why this is called out explicitly.The index maps align with the already-correct reference mhpt kernel in
src/maxdiffusion/kernels/custom_splash_attention.py(theulysses_custompath), whoseq_index_mapreturns(h, i, 0). Theq_heads_per_kv_head != 1guard matches that file'sassert num_q_heads == num_kv_heads(mhpt requires no GQA).Correctness evidence
Verified on a live TPU v6e-16 (
ici_context_parallelism=4 ici_tensor_parallelism=4,ulysses_shards=4), Wan2.2-I2V-A14B, running the same generate config three times and changing only the attention setting / code:attentionheads_per_tilemd5sumulysses_ringc8a0ff4182551867cb0bb75c768c6d720ulysses_ringc8a0ff4182551867cb0bb75c768c6d720ulysses_ringb31f17bf126bfc41844b092e176c741e0The
heads_per_tile=2output is byte-for-byte identical (same md5) to theheads_per_tile=1baseline. Because mhpt is a pure tiling optimization, bit-identical output against the single-head baseline is the strongest correctness signal available — it proves the tiling changes scheduling only, not results. The third row is a development-time contrast: with the wrong block-index (see Key correctness detail) the run still exits0but produces a differing md5 and a 5 KB, ≈40 B/frame (i.e. static) video — the failure mode the exit code alone hid.Additional cross-checks performed during the investigation:
mask_padding_tokens=Falsevariant of the wrong-index run stayed 5 KB, ruling out segment-id / padding masking as the cause and isolating it to head addressing._hpt2suffix, confirming the run actually exercised the ring mhpt kernel (not theulysses_custompath).Automated unit test —
heads_per_tileinvariance (multi-host)RingAttentionHeadsPerTileTest.test_heads_per_tile_matches_single_head(src/maxdiffusion/kernels/splash_attention/ring_attention_kernel_test.py) locks in the invariance this PR relies on: for fixed random MHA inputs it runs the ring kernel withheads_per_tile ∈ {2, 4}and asserts the output matches theheads_per_tile=1baseline (rtol = atol = 5e-3;ring_size=2,num_heads=4,seq_len=2048,HEAD_DIM=128, bf16, staticFullMask).Run it — on all workers at once (the v6e-16 slice only initializes when every host starts together; a lone host hangs in
jax.devices()):Verified on a live TPU v6e-16, launched on all 4 workers simultaneously (the slice cannot initialize on a single host):
2 passed, 16 deselected· exit 0 · ≈11 s eachBoth parameter cases (
hpt=2,hpt=4) pass on every host. Making the test green under multi-controller (multi-host) JAX required one fix (commit0d5d0f8onulysses-ring-vmem-passthrough): the test builds its mesh fromjax.devices()[:ring_size](all on process 0), so the stocknp.testing.assert_allclose— which pulls the shardedjax.Arrayback to host — succeeded only on the owning process and raisedRuntimeError: ... spans non-addressable deviceson the other three, failing the test on all hosts but one. A newSplashAttentionTestCase.assert_allclose_mcjaxhelper evaluates the comparison on-device, reads the scalar verdict only on the owner, and broadcasts it to every process viamultihost_utils.broadcast_one_to_all(one matching collective per process → no deadlock). The test therefore passes on all hosts while still genuinely running the kernel on TPU — noskipTest.Reproduce (end-to-end video correctness)
Wan2.2-I2V-A14B
generate_wan.pyon a v6e-16, run once withheads_per_tile=1(baseline) and once with
=2, everything else identical, then compare the producedmp4s. Launch on
--worker=allso all 4 hosts start together (single-host hangs):Smoke config used for the evidence above:
576x1024,num_inference_steps=1,per_device_batch_size=0.0625,guidance_scale_low=guidance_scale_high=1.0, theflash_block_sizesshown (withheads_per_tile= 1 or 2). Cold compile 213–423 s, warmgeneration ≈15.6 s/step, all 4 workers
exit_status=0. (Internally these runs were drivenby the
run_generate_wan_profiler.shlauncher, which wraps exactly thegenerate_wan.pyinvocation above and fans it out to every worker.)
Scope, limitations & follow-ups
ulysses_ringserves here).test_heads_per_tile_matches_single_headassertsmhpt(N) == mhpt(1)forN ∈ {2, 4}, guarding against exactly the block-index class of error in Key correctness detail. It ships in the feature commit (049210a) and was made multi-host-safe in0d5d0f8(the newassert_allclose_mcjaxhelper); it passes on a live TPU v6e-16 across all 4 hosts (see Automated unit test) — i.e. it runs on the real cluster, not justinterpret=True.