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.
|
LGTM, please squish all the commits |
The merge-base changed after approval.
| q_layout = config.q_layout | ||
| k_layout = config.k_layout | ||
| v_layout = config.v_layout | ||
| use_heads_per_tile = heads_per_tile > 1 |
There was a problem hiding this comment.
to make code more readable, you can move this to a method
| head_dim=[128], | ||
| dtype=[jnp.bfloat16], | ||
| ) | ||
| def test_heads_per_tile_matches_single_head(self, heads_per_tile, head_dim, dtype): |
There was a problem hiding this comment.
To make tests more robust you can also add tests cases for negative scenarios like MQA, GQA which you mentioned. Also minor nit, keep names more descriptive, like instead of run use may be run_multi_head_kernel
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.