Skip to content

feat(attention): fixed-m splash attention kernel with dynamic bounds and safety fallbacks - #477

Open
Perseus14 wants to merge 1 commit into
mainfrom
feat/fixed-m-kernel
Open

Perseus14 wants to merge 1 commit into
mainfrom
feat/fixed-m-kernel

Conversation

@Perseus14

@Perseus14 Perseus14 commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the single-device fixed-m splash attention kernel using Pallas on Cloud TPU.

Instead of tracking an online-softmax running max per KV block, eligible (head, Q-block) pairs
subtract a precomputed shift derived from a Cauchy-Schwarz bound on the logits, which lets the
numerator and denominator accumulate directly in FP32.

  • Dynamic eligibility bounding. The two constants are derived from the actual KV length rather
    than pinned to one operating point:
    $$C(N) = 127 - \lceil\log_2 N\rceil - \lceil\log_2 V_{\max}\rceil, \qquad W(N) = C(N) + 125, \qquad \text{gate} = \left\lfloor \frac{W(N)}{2} \right\rfloor$$
    With the shift $m_i = \lceil U_i \rceil - C(N)$ built from the Cauchy-Schwarz bound
    $U_i = \max_i \lVert q_i \rVert \max_j \lVert k_j \rVert$, every shifted exponent satisfies
    $z_j - m_i \ge C(N) - (U_i + \lceil U_i \rceil) \ge -125 > -126$, so no term flushes to subnormal
    and no accumulator overflows.
  • Virtual K-centering, opt-in. Centers logits via the register-level projection
    $q_i^\top \bar{k}$ without ever writing $(K - \bar{k})$ back to HBM. This shrinks $U_i$ (and so
    admits more heads); it does not loosen the gate — see below. On the ring path it activates
    only when the caller supplies k_mean; see the next section for why that is load-bearing.
  • Ragged sequence handling. Dynamic tail slicing in last_compute_body_fixed lets physically
    unpadded KV sequences run without out-of-bounds access.
  • Relayout optimization. Hoists the base-2 logit rescale $Q \cdot \log_2 e$ above the Ulysses
    all-to-all so XLA fuses the scalar multiply into the upstream projection, saving 185 MB of HBM
    traffic per layer.

This PR is additive: main's $R > 1$ behaviour is unchanged

Centering and the Cauchy-Schwarz bound are a matched pair. If the kernel exponentiates centered
logits $q \cdot (k_j - \bar{k})$ while the caller's eligibility bound was built from raw $K$, the
bound caps the wrong quantity — $\lVert k \rVert$ does not bound $\lVert k - \bar{k} \rVert$ — and a
tile can clear the gate while the logit it was supposed to cap overflows fp32.
RingRawKeyBoundUnsoundTest pins that down in pure arithmetic: keys all of norm 100 give a raw bound
of 100 against a gate of 116, while the centered max logit is ~200 and the shifted exponent exceeds
$2^{128}$.

An earlier revision of this PR handled that by having the ring kernel compute k_mean itself and
center unconditionally, then disabling fixed-m for $R > 1$ to stop the now-mismatched caller
norms from being used. Reviewers correctly pointed out that this made the PR a regression against
main (which does run fixed-m at $R > 1$) and left the disabled code path carrying a latent bug.

This revision instead makes centering opt-in: the kernel centers only when handed a k_mean.
Callers that supply one also supply centered norms; callers that do not keep the uncentered path,
which is sound against the two-sided $\lfloor W/2 \rfloor$ bound. main's $R > 1$ caller passes no
k_mean, so it is left running exactly as before and the restriction is gone. Net effect: this PR
adds the fixed-m kernel without changing any behaviour main already has, and merges safely on its
own
. #478 then turns centering on by supplying both halves of the pair.

Why the gate is halved, and why is_ring does not change it

Review raised that get_fixed_m_constants accepts is_ring but ignores it, and that the gate is
half the legacy _FIXED_M_SAFE_BOUND = 213. Both observations are correct; the conclusion that the
centered path should get the full window $W$ is not, and we verified that the hard way.

The appealing argument is that K-centering forces the realized row max $M \ge 0$, so only one side
needs absorbing. It fails because the shift is built from $U$, not from $M$: the worst case stays
$C - (U + \lceil U \rceil)$ whether or not the keys are centered. Wiring is_ring up to return $W$
for the Ulysses path reproduces exactly the bug the halving was introduced to fix — at $N = 4096$,
$W = 232$, so a query of norm $231.01$ against exactly-centered keys passes the gate and then
silently loses 11.8% of the softmax mass (0.882 vs 1.0) as its negative logits flush to zero.
test_adversarial_centered_keys_softmax_mass_loss and test_cpu_proof_invariant_bounds both pin
this down, and both fail under the loosened gate. The parameter is retained (documented, with the
derivation) because the two call sites differ in the $N$ they pass — the ring passes the whole
distributed length — and because it marks the seam where a future kernel that shifts by the realized
row max could legitimately diverge.

Measured cost of the strict gate. Instrumented over a full WAN 2.2 720p generation on v6e-8
(1280 metadata calls per gate, $N = 75{,}600$):

gate derivation entries falling back calls with any fallback
113 $\lfloor W/2 \rfloor$ (shipped) 0.73% 112 / 1280
201 relative mass loss $\le 2^{-10}$ incl. the $N$ multiplicity 0.45% 56 / 1280
218 relative mass loss $\le 2^{-10}$ per term 0.44% 56 / 1280
227 $W$ 0.43% 54 / 1280

So the strict gate costs about 0.3 percentage points of extra fallback over the loosest alternative.
The largest observed bound is 1134.58 — far above every candidate gate — so genuine sink heads exist
that fall back regardless, and no choice of gate removes the online-softmax path.

Verification

  • custom_splash_fixed_m_test.py: 22 passed on v6e-8 (dynamic bounds, non-divisible sequences, the
    adversarial centered-key 15/17 regression, hybrid fallbacks).
  • ring_fixed_m_test.py: 16 passed on v6e-8.
  • Both files are Pallas-only and therefore fail rather than skip on a CPU backend
    (Only interpret mode is supported on CPU backend); they need a TPU runner.
  • Linting: pyink --pyink-indentation=2 --line-length=125 and ruff check clean.

@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 introduces dynamic fixed-m constants and safe bounds calculation based on KV sequence length, adds support for virtual K-centering, and enhances input validation and dtype safety checks in the Pallas flash attention kernel. It also expands the test suite to cover various edge cases, including per-Q-block fallback, batched isolation, and virtual K-centering. The review feedback focuses on optimizing the TPU kernel performance by replacing expensive dynamic integer division with optimized BlockSpec mapping and introducing a static use_k_centering flag to conditionally compile the centering logic at trace time.

Comment thread src/maxdiffusion/kernels/custom_splash_attention.py
Comment thread src/maxdiffusion/kernels/custom_splash_attention.py
Comment thread src/maxdiffusion/kernels/custom_splash_attention.py
Comment thread src/maxdiffusion/kernels/custom_splash_attention.py
@Perseus14
Perseus14 force-pushed the feat/fixed-m-kernel branch 10 times, most recently from 3b9d7aa to cb1e4d1 Compare September 15, 2026 06:37
@syhuang22
syhuang22 self-requested a review September 15, 2026 20:04

@syhuang22 syhuang22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A few things before this can go in though:

  • If this lands on its own, fixed-m gets turned off for R>1 (use_fixed_m = False in _ulysses_ring_custom_attention, it only comes back in #478). That breaks the recipe we ship today. Can we split the stack so each PR is safe to merge by itself?
  • Cutting the Ulysses gate from 213 to 113 feels more conservative than we need. With centered keys, the mass you can lose is bounded by 2^(ceil(U)-C-126), so a gate around C+116 already keeps the loss under 0.1%. Could you share fallback rates so we can pick the number?
  • mk[0] means something different now, but a 2D mk still gets silently broadcast. Can we just raise on that?
  • The V check never fired in my runs on WAN 2.2 (every call passed), and it only exists because C moved from 88 to 102. Is it worth the extra complexity?
  • Please put back the comments that explain the Mosaic cliff and k-smoothing. They save the next person a lot of pain.

@syhuang22 syhuang22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Some line-level notes to go with my comment above.

"(R = context_shards // ulysses_shards); falling back to online softmax. "
"Set ulysses_shards == ici_context_parallelism for R=1 to use fixed-m."
)
use_fixed_m = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This turns off fixed-m for R>1, which is the ring2 x uly2 recipe we ship today. #478 turns it back on, so let's not land this one alone.

# This guarantees that negative logits never flush to zero in normal FP32, preventing silent
# loss of significant softmax probability mass even when keys are mean-centered.
# Both ring and non-ring paths strictly adhere to this two-sided bound.
safe_bound = float(int(safe_window // 2))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This halves the Ulysses gate (213 -> 113). With centered keys the row mean is 0, so Jensen caps the lost mass at 2^(ceil(U)-C-126). Something like C+116 keeps it under 2^-10 without halving. What do fallback rates look like at 113?


def get_fixed_m_constants(
kv_seq_len: int,
is_ring: bool = False,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is_ring isn't used. Drop it?

raise ValueError("`mk` metadata array is required when `use_fixed_m=True`.")
if mk is None:
mk = jnp.zeros((2, num_q_heads, grid_height), jnp.float32)
elif mk.ndim == 2:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

mk[0] used to be max||k||, now it's m. Broadcasting an old 2D mk would silently read one as the other. Can we just raise? (same at L797)

# block degrades the instruction schedule of the WHOLE grid -- measured 3x
# slower end to end, which is the cliff the design doc's D3 warns about.
# Keeping this one flag rather than two makes that combination unspellable.
fixed_only = use_fixed_m and uniform_fixed_m

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we keep the comment that was here? It's the only thing explaining why pinning and uniform_fixed_m must go together (two-body last block = Mosaic cliff, ~3x slower).

v_ok = 1.0 if dtype_safe else 0.0
if dtype_safe and value is not None:
v_max_sq = (value.astype(jnp.float32) ** 2).max()
v_ok = (v_max_sq <= (v_max_bound**2)).astype(jnp.float32)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This never tripped in my WAN 2.2 runs. It's only needed because C went 88 -> 102, which buys gate 106 -> 113. Worth it?

# precondition that keeps the fixed-m Cauchy-Schwarz bound flush-free.
key = key - jnp.mean(key, axis=2, keepdims=True)
k_mean = jnp.mean(real_key.astype(jnp.float32), axis=2)
if k_mean.shape[-1] < 128:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: hard-coded 128, can we use the padded head dim? (same at L1556)

use_base2_exp=context.get("use_base2_exp", True),
use_experimental_scheduler=context.get("use_experimental_scheduler", False),
use_fixed_m=True,
per_q_block=False,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Heads-up: ulysses_custom_fixed_m goes from per-row m to per-head m here. Fine for safety, but worth mentioning in the description.

@@ -1132,7 +1255,6 @@ def _lse_scan(_):


def make_custom_ring_attention(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Was removing the keyword-only * on purpose? With this many bool args it's a nice guard.

Comment thread src/maxdiffusion/generate_ltx_video.py Outdated
enhance_prompt = (
prompt_enhancement_words_threshold > 0 and prompt_word_count < prompt_enhancement_words_threshold
)
enhance_prompt = prompt_enhancement_words_threshold > 0 and prompt_word_count < prompt_enhancement_words_threshold

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unrelated formatting change, mind dropping it?

@Perseus14
Perseus14 force-pushed the feat/fixed-m-kernel branch 2 times, most recently from 3067e91 to 970a828 Compare September 16, 2026 05:31
…and safety fallbacks

Implements exact fixed-m splash attention in Pallas on TPU:
- Dynamic C(N) headroom constants guaranteeing FP32 accumulator safety
- fixed_m_dtype_is_safe checks rejecting FP16/FP8 exponent overflow
- Value bound validation (|V| <= 256) with safe online softmax fallback
- Unit tests covering all boundary conditions, dtypes, and scale factors

Explicit metadata contracts on the fixed-m ring path
----------------------------------------------------
Three implicit contracts are made explicit. Each failed silently rather than
loudly, and one of them produced non-finite output on TPU.

1. Norm representation is declared, not inferred. The gate previously guessed
   whether `fixed_m_norms` were squared with
   `(qn.max() * mk.max()) < 1000.0`. Magnitude cannot answer that question:
   legacy unsquared norms of (1000, 2) have a true bound of 2000, but read as
   already-squared they yield sqrt(2000) ~= 44.7 -- a ~45x under-estimate that
   admits fixed-m where it must fall back, and overflows. Replaced by
   `fixed_m_norms_squared` (default True, matching every in-tree caller); the
   test harness is migrated to squared norms.

2. The V-safety predicate is required, not assumed. Unlike the Cauchy-Schwarz
   norm bounds, the V-magnitude and dtype verdict is not re-derivable from a
   single hop's Q/K, so the kernel cannot reconstruct it. Omission previously
   meant "safe", which let fixed-m run on inputs it cannot represent: float16
   with Q=K=0 and V=1 returns inf instead of 1.0. The ring path now raises
   unless `v_ok` is passed, mirroring the existing `fixed_m_recenter` rule, and
   `_ulysses_ring_custom_attention` computes it -- dtype safety plus
   |V| <= DEFAULT_MAX_V_BOUND, reduced with pmin over BOTH internal axes, since
   after the all-to-all neither axis alone observes the whole V and the fixed-m
   branch must be taken uniformly by every ppermute participant.

3. Norm shape is validated against per_q_block. This is the defect behind the
   `test_sink_head_falls_back_everywhere` TPU failure. Both gates compute
   `qn * mk[:, None]`, so a (num_heads,) array supplied while per_q_block=True
   does not raise -- it broadcasts to (num_heads, num_heads), pairing head j's
   query norm with head h's key norm. A sink head then inherits a small bound
   from an unrelated head, is wrongly marked eligible, and the kernel evaluates
   exp2(large_logit - small_m) -> inf. The kernel now rejects the mismatch, and
   the test declares per_q_block=False to match the per-head norms it supplies,
   as the production ring caller already did.

Regression coverage: six backend-independent contract tests (both omissions
raise, v_ok=False is accepted, mis-shaped norms are rejected, correctly shaped
per-Q-block norms are accepted, fp16 is rejected while bf16/fp32 pass) plus two
TPU tests (the two declared norm representations must agree, and an explicit
unsafe verdict must force a finite fallback).

Verified on v6e-8: ring_fixed_m_test 13 passed; attention_test,
custom_splash_fixed_m_test, attention_block_sizes_test and ring_fixed_m_test
together 58 passed.
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.

2 participants