Skip to content

[FA4] Harden block_logit: scaled logit, pack_gqa guard, docs, tests#164

Open
ForFishes wants to merge 4 commits into
PaddlePaddle:mainfrom
ForFishes:fa4-block-score-hardening
Open

[FA4] Harden block_logit: scaled logit, pack_gqa guard, docs, tests#164
ForFishes wants to merge 4 commits into
PaddlePaddle:mainfrom
ForFishes:fa4-block-score-hardening

Conversation

@ForFishes

@ForFishes ForFishes commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

Hardens the HySparse block-score (block_logit) fusion in the SM100/Blackwell FA4 forward kernel. Commits on this branch:

  1. 4a7d31a Fuse HySparse block-score (block_logit) into the SM100 forward epilogue.
  2. 6f7e843 Harden the fused block-score: guards, docs, and an accuracy/contract test suite.
  3. ef194c8 Unify block_logit to the scaled logit + reject pack_gqa.

What changed

  • Scaled logit semantics: block_logit now stores the SCALED attention logit softmax_scale * q·kᵀ (+ score_mod bias) — the exact value fed into softmax — instead of the raw q·kᵀ. This puts every head on one head-independent scale, so downstream block_logit - LSE gives log(max attention weight in the block), comparable across heads for cross-head Top-K block selection (HySparse eq(3)).
    • The score_mod path already folds softmax_scale into the logit (apply_score_mod_inner); the no-score_mod path carries raw q·kᵀ, so we multiply by softmax_scale (= scale_log2 · ln2) there only. -inf masked columns stay -inf.
  • pack_gqa guard: assert not pack_gqa when block_logit is requested. block_logit is indexed by the query head (head_idx) and query row (m_block*m_block_size+tidx); under pack_gqa=True the query heads are packed into the M/row dim and head_idx becomes the KV head, so writes would land in wrong locations. Since pack_gqa defaults to (qhead_per_kvhead > 1), GQA callers must pass pack_gqa=False explicitly.
  • Normalization stays downstream: the kernel keeps returning block_logit + LSE; no in-kernel normalization.

Downstream: computing the unified-scale Top-K block index

The kernel returns two tensors that together let you recover a head-independent per-block score:

  • block_logit : [B, H, S, num_blocks] fp32 — per-(query, key-block) max of the scaled logit
    m̃_block = max_{j∈block} ( softmax_scale · qᵀk_j (+ bias) ).
  • lse : [B, H, S] — the softmax normalizer over the full row (natural log):
    LSE = m_i + ln(ℓ_i), where m_i = max_j s_ij and ℓ_i = Σ_j exp(s_ij − m_i).

Why raw block_logit is NOT cross-head comparable. The raw per-block max logit has a per-row/per-head offset (each head has its own m_i, ℓ_i), so a larger logit in head A does not mean a larger attention weight than head B. You cannot max raw logits across heads.

The unifying step — subtract LSE (log-domain normalization). Because LSE is the exact softmax denominator of the same scaled logits,

log_weight = block_logit − LSE[..., None]
           = m̃_block − (m_i + ln ℓ_i)
           = ln( max_{j∈block} exp(s_ij − m_i) / ℓ_i )
           = ln( max_{j∈block} softmax_j )      # HySparse eq(3), in log space

i.e. log_weight is the log of the largest softmax attention weight inside the block. Softmax weights are probabilities in (0, 1], so log_weight ∈ (−∞, 0] lives on one common scale for every head → it can be maxed/compared across heads. exp(log_weight) recovers the actual peak weight. Masked / unvisited blocks are −inf and are never selected.

Top-K index (cross-head). log is monotonic, so Top-K on log_weight equals Top-K on the true weight — no need to exp first.

out, lse = flash_attn_fwd(..., block_logit=block_logit, block_size=BS, pack_gqa=False)
# block_logit: [B, H, S, num_blocks],  lse: [B, H, S]

log_weight = block_logit - lse.unsqueeze(-1)      # [B,H,S,nb] = log(max softmax weight/block)
block_score = log_weight.max(axis=1)              # [B,S,nb]  max across heads (unified scale)
topk_idx = block_score.topk(k, axis=-1)[1]        # [B,S,k]   selected key-block indices

Equivalent in the weight domain (same result): weight = paddle.exp(log_weight), then max-over-heads and topk.

Notes:

  • lse is natural-log and taken over the SCALED logits, matching block_logit's units exactly.
  • Caller must pre-fill block_logit with -inf for blocks the causal loop never visits (documented contract; see test_block_logit_unvisited_future_blocks_untouched_causal).

Tests

tests/test_block_score_fusion.py:

  • References now scale by softmax_scale; tolerances scale accordingly.
  • New negative test test_block_logit_rejects_pack_gqa (explicit pack_gqa=True and defaulted None) verifies the assert fires.
  • Accuracy / contract tests: multi-dim, block-size variants, corner cases (ragged seqlens, partial blocks, single token, seqlen_q≠seqlen_k), does-not-change-output, GQA, large multi-tile non-causal, and unvisited-future-block causal contract. All pass (pack_gqa=False).

Runs only on SM 10.x (Blackwell).

ForFishes and others added 3 commits July 11, 2026 00:26
…ogue

Add an optional block_logit output to the SM100 FlashAttention forward that,
computed in the softmax epilogue at near-zero extra cost, emits the per-(query,
key-block) max of the RAW (pre-scale, post-mask) q*k^T logit. This feeds the
non-differentiable Top-K block selection used by HySparse sparse attention,
letting the full-attention path produce both the attention output and the
block score in a single fused pass instead of a separate scoring kernel.

- interface._flash_attn_fwd: new block_logit / block_size args, fp32/CUDA
  validation + dlpack conversion, compile_key + constructor + kernel-call
  threading. Returned tuple unchanged; block_logit written in place.
- flash_fwd_sm100: thread mBlockLogit through __call__ -> kernel -> softmax_loop
  -> softmax_step; bucket each thread's row fragment into block_size sub-blocks
  and write per-block maxes mirroring the LSE addressing. No MMA/smem/cross-warp
  traffic. Handles split-D (write once) and non-split-D (both stages), and skips
  sub-blocks past seqlen_k to stay in bounds when seqlen_k is not a multiple of
  block_size.
- tests: block-score accuracy across head dims (64/128/256), head counts,
  block sizes (32/64/128), causal/non-causal, and corner cases (ragged
  seqlens, seqlen_k not a multiple of block_size, single token, seqlen_q !=
  seqlen_k), plus a check that the fused epilogue leaves out/lse bit-identical.
Follow-up review hardening for the SM100 fused HySparse block-score
(block_logit) added in 4a7d31a. No change to the fused reduction math.

- interface._flash_attn_fwd: add input guards for block_logit -- assert
  SM 10.x, non split-KV, fp32, ndim==4, and num_blocks >= ceil(seqlen_k/
  block_size) -- so misuse fails loudly instead of passing an extra arg to
  an SM90 kernel or silently indexing out of bounds.
- Docs/comments: correct the "RAW q*k^T" wording. The score is the
  post-score_mod, post-mask (still pre-softmax-scale) logit, i.e. the actual
  attention score INCLUDING any score_mod bias -- an attention-importance
  proxy for block selection, reducing to raw q*k^T only when no score_mod is
  set. Also document the write contract: the kernel only writes key-blocks
  the attention loop visits, so fully-masked/future blocks are never written
  and the caller MUST pre-initialize block_logit to -inf.
- tests: add GQA coverage (qhead_per_kvhead>1), a large multi-n-tile
  non-causal case (cross-tile column addressing + every block written), and
  a causal contract test pinning that unvisited future blocks are left
  untouched while reachable blocks are overwritten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Store the SCALED attention logit (softmax_scale * q.k + score_mod bias) in
block_logit instead of the raw q.k. This puts every head on one
head-independent scale, so a downstream `block_logit - LSE` gives
log(max attention weight in the block), comparable across heads for
cross-head Top-K block selection (HySparse). The score_mod path already
folds softmax_scale into the logit; the no-score_mod path carries raw q.k,
so multiply by softmax_scale (= scale_log2 * ln2) there only.

Add an assert rejecting pack_gqa=True with block_logit: block_logit is
indexed by the query head and query row, but under pack_gqa the query heads
are packed into the M/row dim and head_idx becomes the KV head, so writes
would land in wrong locations. pack_gqa defaults to (qhead_per_kvhead > 1),
so GQA callers must pass pack_gqa=False explicitly.

Update tests: reference now scales by softmax_scale, tolerances scale
accordingly, and add a negative test that block_logit + pack_gqa (explicit
True and defaulted) raises.
@ForFishes ForFishes changed the title [FA4] Harden block_logit fused block-score: guards, docs, and tests [FA4] Harden block_logit: scaled logit, pack_gqa guard, docs, tests Jul 13, 2026
…ence)

The fused HySparse block-score epilogue previously bucketed key columns by
their ABSOLUTE packed-sequence block (abs_col // block_size). Downstream
HySparse selection interprets block ids DOCUMENT-relative to each query row's
document start (bos), so packed multi-document inputs (bos > 0) were scored
against a shifted grid and selected the wrong blocks -- only bos == 0 matched.

Thread an optional per-query bos tensor (block_bos, [B, S] int32) into the
SM100 forward and bucket each column by its document-relative block
rel = floor((abs_col - bos) / block_size). Because an unaligned bos shifts the
block_size grid, one n-tile can straddle one extra relative block, so the
per-thread accumulator widens by one slot (n_block_slots = blocks_per_ntile + 1)
and the gmem write becomes a guarded same-thread fmax RMW across the n_block
loop. When block_bos is absent the kernel falls back to bos == 0, i.e. the
original absolute bucketing (byte-identical fast path). This makes packed
selection bit-identical to running each document alone (pack-equivalence).
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