Add heads_per_tile (multi-head-per-tile) support to Ulysses ring attention#444
Add heads_per_tile (multi-head-per-tile) support to Ulysses ring attention#444edgexyz wants to merge 6 commits into
heads_per_tile (multi-head-per-tile) support to Ulysses ring attention#444Conversation
…s for ulysses ring attention
|
|
||
|
|
||
| @dataclasses.dataclass(frozen=True) | ||
| class TokamaxRingFlashBlockSizes: |
There was a problem hiding this comment.
Do you think it's better to rename the CustomFlashBlockSizes and reuse that for all code paths
There was a problem hiding this comment.
Both carriers existed for the same reason — the upstream jax splash_attention_kernel.BlockSizes is frozen + slotted and can't hold the extra fields (block_kv_compute_in for custom; block_*_dkv / heads_per_tile / vmem_limit_bytes for ring), and a plain dict isn't hashable.
Merged TokamaxRingFlashBlockSizes into CustomFlashBlockSizes (field union) and dropped the separate class; the ring path now returns CustomFlashBlockSizes too. Both consumers read by field name via getattr, so no consumer changes were needed — only the two returns in get_flash_block_sizes, which keep their own populate logic (custom uses lenient .get(...); ring requires the dkv keys and forces use_fused_bwd_kernel=True). Done in 0cd23dd.
| self.assertTupleEqual(x.shape, y.shape) | ||
| ok = jnp.all(jnp.abs(x - y) <= atol + rtol * jnp.abs(y)) | ||
| max_err = jnp.max(jnp.abs(x - y)) | ||
| is_owner = jax.process_index() == 0 |
There was a problem hiding this comment.
Isn't it better to do all gather and verify it?
There was a problem hiding this comment.
The test now builds the mesh from all devices (ring_size = jax.device_count()), so the outputs are sharded across every process; both results are then gathered with multihost_utils.process_allgather(..., tiled=True) and compared with the standard assert_allclose. The bespoke assert_allclose_mcjax helper is deleted.
Verified on a live v6e-16 (4 hosts x 4 chips, launched with --worker=all): all 4 hosts report 2 passed (hpt=2 and hpt=4 vs the hpt=1 baseline) in ~34s each. Done in 758cb1f.
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.