Skip to content

Add custom mask-free fused backward & ring backward attention kernels (1.35x speedup on WAN 76k CP=4) - #485

Closed
csgoogle wants to merge 1 commit into
mainfrom
sagarchapara/custom-splash-fused-bwd-ring
Closed

csgoogle wants to merge 1 commit into
mainfrom
sagarchapara/custom-splash-fused-bwd-ring

Conversation

@csgoogle

Copy link
Copy Markdown
Collaborator

Summary

Implements custom mask-free backward attention kernels (_flash_attention_bwd_fused_kernel, _flash_attention_dq_kernel, _flash_attention_dkv_kernel) and custom ring backward attention (_custom_ring_attention_backward) with dynamic KV tail slicing and zero segment masks.

Key Features

  1. Mask-Free Dynamic Tail Slicing (orig_kv_seq_len):
    • Eliminates SegmentIds and mask overhead by dynamically slicing the last KV block (slice_k_len = kv_seq_len % bkv_compute) in both forward and backward passes.
    • Automatically pads unpadded HBM inputs up to active_q_len / active_kv_len and slices outputs back to exact orig_q_seq_len / orig_kv_seq_len, supporting arbitrary sequence lengths not divisible by block sizes.
    • Any garbage or NaN values in the padded KV tail are completely ignored.
  2. Fused Backward Kernel (use_fused_bwd_kernel=True):
    • Switches grid loop order to KV outer (grid_width), Q inner (num_q_heads, grid_height), computing dQ, dK, and dV in a single Pallas kernel pass.
    • Accumulates dK and dV in VMEM scratch across Q blocks and GQA heads (q_heads_per_kv_head) with zero transposes.
    • Supports 3-buffer ring input-output aliasing (dq_reduction_steps=3) to reduce dQ across KV blocks with minimal HBM memory footprint.
  3. Custom Ring Attention Backward (_custom_ring_attention_backward):
    • Implements full custom VJP backward pass across the context parallelism ring (ring_axis), rotating K, V, dK, dV across devices via lax.ppermute and accumulating local dQ, dK, dV.

Performance Benchmarks (TPU v6e, 128 MB VMEM)

Workload: WAN 14B 76k (75,600 total tokens, 40 heads, head_dim = 128, Context Parallelism CP = 4 $\implies$ 18,900 local tokens/shard).

Kernel & Block Size Configuration Active Q (fwd / bwd) Q Padding Waste Forward (ms) Backward (ms) Total (ms) Speedup vs Tokamax Prod
Tokamax Prod (fwd=1024x1024, bwd=2048x2048, SegMask) 20,480 / 20,480 +1,580 (+8.4%) 150.29 ms 212.87 ms 363.16 ms 1.00x (Baseline)
Tokamax Prod (fwd=1024x1024, bwd=2048x2048, NoMask) 20,480 / 20,480 +1,580 (+8.4%) 140.22 ms 212.12 ms 352.33 ms 1.03x
Custom Fused (fwd=4096x1024, bwd=4096x1024) 20,480 / 20,480 +1,580 (+8.4%) 98.48 ms 201.41 ms 299.89 ms 1.21x
Custom Fused (fwd=4736x1024, bwd=4736x1024, in=512) 18,944 / 18,944 +44 (+0.23%) 94.89 ms 185.76 ms 280.65 ms 1.29x
Custom Fused (fwd=9472x1024, bwd=4736x2048, in=512) 18,944 / 18,944 +44 (+0.23%) 88.46 ms 182.72 ms 271.19 ms 1.34x
Custom Fused (fwd=18944x1024, bwd=4736x2048, in=512) 18,944 / 18,944 +44 (+0.23%) 86.56 ms 182.80 ms 269.35 ms 1.35x (-93.8 ms)
  • Forward Pass: 1.74x faster (86.56 ms vs 150.29 ms) using block_q = 18944 (grid_height = 1, only 44 padded tokens), keeping the entire shard's Q resident in VMEM across the ring.
  • Backward Pass: 1.16x faster (182.80 ms vs 212.87 ms) using fused backward with block_q_dkv = 4736, block_kv_dkv = 2048.

Testing

Added src/maxdiffusion/tests/custom_splash_backward_test.py covering:

  • Exact multiple sequence lengths vs JAX reference attention gradients (dQ, dK, dV).
  • Ragged non-divisible sequence lengths (orig_kv_seq_len = 1350) with vmap and GQA.
  • Fused backward (dq_reduction_steps=3 and None) vs unfused backward (use_fused_bwd_kernel=False).
  • Unpadded non-divisible input tensors (sq=1350, skv=1777 with block_q=512, block_kv=512).
  • Padded inputs with 1000.0 garbage in the KV tail verifying exact gradient isolation.
  • 4-device custom ring attention forward + backward with non-divisible local sequence lengths (sq_local=650, skv_local=733).

@csgoogle
csgoogle requested a review from entrpn as a code owner September 16, 2026 18:40
@github-actions

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the backward pass for custom splash attention and custom ring attention on TPU using Pallas, including the necessary VJP registrations, backward kernels (for dQ, dK, and dV), and comprehensive unit tests. The reviewer identified several critical correctness issues and race conditions across the backward kernels. Specifically, the kernels rely on specific execution orders of grid blocks (e.g., for initialization, accumulation, and writing results) despite the grid dimensions being configured with 'arbitrary' semantics, which allows the compiler to schedule them in any order. Refactoring these grid-level accumulations into sequential loops inside the kernels is required to prevent silent numerical corruption.

Comment on lines +1233 to +1244
if use_dq_aliasing:

@pl.when(j < 3)
def write_dq_first():
dq_ref[...] = dq_val

@pl.when(j >= 3)
def write_dq_acc():
dq_ref[...] = dq_alias_ref[...] + dq_val

else:
dq_ref[...] = dq_val

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Correctness Bug / Race Condition in DQ Aliasing:

When use_dq_aliasing is enabled, the kernel relies on the grid dimension j executing in strict sequential order (specifically, that j < 3 executes before j >= 3 for any given alias index j % 3).

However, because j is a grid dimension with "arbitrary" semantics, the compiler is free to schedule the blocks in any order.

If a block with j >= 3 (e.g., j = 3, which maps to alias index 0) is scheduled and executed before j = 0 (which also maps to alias index 0):

  1. write_dq_acc will read from dq_alias_ref[...], which at that point contains uninitialized garbage memory from lax.empty.
  2. Later, when j = 0 executes, write_dq_first will overwrite dq_ref[...] with dq_val, completely wiping out the accumulated gradient from j = 3.

This will result in silent numerical corruption and incorrect gradients. To fix this, you must avoid relying on grid-level scheduling order for accumulation and aliasing. All accumulation across KV blocks must be handled via sequential loops inside the kernel or via deterministic reduction patterns.

Comment on lines +898 to +900
@pl.when(j == 0)
def init():
dq_scratch_ref[...] = jnp.zeros_like(dq_scratch_ref)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Critical Correctness Issue / Race Condition:

The grid dimension j (KV block index) is defined with "arbitrary" semantics in dimension_semantics. In the Pallas programming model, "arbitrary" semantics means the compiler is free to schedule and execute the grid blocks in any order, or even concurrently.

Assuming that j == 0 executes first to initialize the scratchpad and j == grid_width - 1 executes last to write the results is unsafe. If the compiler schedules these blocks out of order, the scratchpad will be cleared mid-computation, or incomplete gradients will be written to dq_ref.

To fix this, you should perform the accumulation loop over KV blocks sequentially inside the kernel (e.g., using lax.fori_loop) rather than mapping it to a grid dimension.

Comment on lines +1002 to +1006
@pl.when(step_q == 0)
def init():
dk_scratch_ref[...] = jnp.zeros_like(dk_scratch_ref)
dv_scratch_ref[...] = jnp.zeros_like(dv_scratch_ref)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Critical Correctness Issue / Race Condition:

The grid dimension step_q is defined with "arbitrary" semantics in dimension_semantics. There is no guarantee that step_q == 0 will execute first to initialize the scratchpads or that step_q == total_q_steps - 1 will execute last to write the results.

If the compiler schedules these blocks out of order, the scratchpads will be cleared mid-computation or incomplete gradients will be written to dk_ref and dv_ref.

Consider refactoring the accumulation over Q steps to be a sequential loop inside the kernel instead of a grid dimension.

Comment on lines +1128 to +1131
should_init_dkv = jnp.logical_and(i == 0, q_head_in_group == 0)
should_write_dkv = jnp.logical_and(
i == grid_height - 1, q_head_in_group == q_heads_per_kv_head - 1
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Critical Correctness Issue / Race Condition:

In _flash_attention_bwd_fused_kernel, both i (Q block index) and h_q (Q head index) are grid dimensions with "arbitrary" semantics.

The conditions should_init_dkv and should_write_dkv assume a specific sequential execution order across the grid blocks (i.e., that i == 0 and q_head_in_group == 0 runs first, and i == grid_height - 1 and q_head_in_group == q_heads_per_kv_head - 1 runs last).

Since the compiler is free to schedule "arbitrary" grid dimensions in any order, this assumption is unsafe and will lead to silent data corruption or incorrect gradients if the blocks are executed out of order.

To resolve this, you should perform the accumulation over Q blocks and GQA heads sequentially inside the kernel using loops rather than relying on grid-level scheduling.

@csgoogle
csgoogle marked this pull request as draft September 16, 2026 18:44
@csgoogle csgoogle closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant