Skip to content

[model] support FlashAttention-4 (flash_attn: fa4)#10639

Open
yangzhou24 wants to merge 1 commit into
hiyouga:mainfrom
yangzhou24:dev_flash_attention_4
Open

[model] support FlashAttention-4 (flash_attn: fa4)#10639
yangzhou24 wants to merge 1 commit into
hiyouga:mainfrom
yangzhou24:dev_flash_attention_4

Conversation

@yangzhou24

Copy link
Copy Markdown

What does this PR do?

Adds FlashAttention-4 as a selectable attention backend via flash_attn: fa4, mirroring the
existing fa2 dispatch path so it applies uniformly to every supported model. It also resolves the
pre-existing # FIXME compatibility fa3/fa4 in data/collator.py.

What's included

  • AttentionFunction.FA4 enum value + dispatch to transformers' "flash_attention_4"
    implementation, guarded by is_flash_attn_4_available() (graceful warning + fallback when fa4 or a
    new-enough transformers is unavailable — never a hard crash).
  • Generalized vision-tower fallback. flash-attn-4 (v4.0.0.beta21, the CuTe-DSL backend) crashes in its
    backward-preprocess kernel for any attention head_dim that is not a multiple of 32 (the kernel's
    head-dim padding granularity). Empirically verified on B30Z: d=72/80 fail at the first backward; d=64/128/256
    pass; the forward is fine for all — the bug is backward-only. Root cause traced in the flash-attention source
    (see "Root-cause analysis" below): the preprocess kernel builds an out-of-bounds head-dim copy predicate but
    passes it to cute.copy without slicing it per row-tile, unlike every other (forward and main-backward)
    kernel, producing a CuTe shape-mismatch ('cute.copy' op expects pred to have compatible shape with (1,(3)) but got (1,(2,3))). Models whose vision tower has such a head_dim (e.g. Qwen3-VL, Qwen3.5: ViT
    head_dim=72) route only the vision tower to fa2 and the language model
    to fa4
    via the transformers dict-form _attn_implementation; text / LLM-only models run entirely on fa4.
    Detection is generic (_fa4_vision_needs_fa2), not a model-type allow-list, so it covers current and future
    VLMs.
  • Varlen packing speed fix. On packed / position-ids paths transformers passes max_seqlen_q/k as
    a CUDA tensor, but fa4's cute API is typed Optional[int] and builds its backward JIT compile-key
    from it. A tensor hashes by object identity, so the backward kernel recompiles every step (~12×
    slowdown vs fa2). We coerce it to int at the fa4 entry point (_patch_fa4_varlen_int_seqlen,
    idempotent, no-op when fa4 is absent).
  • Collator FLASH_ATTN_IMPLS refactor (fa2/fa4 share the varlen unpadded-packing contract), Qwen3.5
    GPU packing-patch gate extended to fa4, two example configs, and GPU-free unit tests.

Experiments (seed-fixed fa2-vs-fa4 A/B)

Hardware: NVIDIA B30Z (Blackwell / B300-class, sm_103a); flash-attn-4 4.0.0b21; transformers 5.6.0;
bf16; seed=42; batch size 1; identical data ordering per pair (0 token-mismatches, verified).

Speed (Qwen3-4B, text, neat_packing, uniform sequences, 1000 steps each). fa4 accelerates
attention, whose cost scales O(L²), so its benefit grows with sequence length:

tokens/step fa2 fa4 fa4 speedup throughput
8,192 (packed) 0.827 s/step, 9.7k tok/s 0.721 s/step, 11.2k tok/s +12.9% +14.8%
16,384 (packed) 1.449 s/step, 10.9k tok/s 1.189 s/step, 13.3k tok/s +18.0% +21.9%

At short sequence lengths the gap is small (attention is a minor fraction of the step): a separate
Qwen3.5-9B multimodal SFT run at ~580 tokens/step measured only ~+3% steady-state — expected, and the
reason the benchmark above targets long packed sequences where fa4 is designed to help.

Numerics (Qwen3-VL, 1000 steps, real multimodal SFT). fa4 matches fa2 with no drift:

model ViT head_dim routing mean loss fa2 → fa4 loss-curve corr drift
Qwen3-VL-8B-Instruct 72 ViT→fa2, LLM→fa4 (log-confirmed) 0.4879 → 0.4908 Pearson 0.974 slope −3e-5, signed Δ +0.003
Qwen3-VL-4B-Instruct 64 whole model → fa4 (no fallback) 0.5045 → 0.5046 Pearson 0.974 slope +9e-6, signed Δ +0.0001

The 8B run confirms the generalized ViT→fa2 routing fires (and the model trains without the fa4-backward
crash); the 4B run is a negative control — same qwen3_vl architecture, head_dim=64, so the whole
model runs on fa4 with no fallback line. Per-step loss differences are bf16 attention-kernel-reorder
noise (concentrated on near-zero-loss steps); the aggregate trajectories are essentially identical.

Notes / limitations

  • fa4 requires a Hopper or Blackwell GPU and the flash-attn-4 wheel. For VLMs whose vision tower has a
    head_dim not divisible by 32, fa4 currently accelerates only the language model (the ViT stays on
    fa2 — a flash-attn-4 backward-kernel bug, analyzed below, not a LlamaFactory one).
  • The v1 engine (src/llamafactory/v1/) has a separate AttentionFunction and is intentionally left
    out of scope; fa4 there can be a follow-up.

Before submitting

Files changed (8)

File Change
src/llamafactory/extras/constants.py FA4 = "fa4" enum member
src/llamafactory/hparams/model_args.py flash_attn help text lists fa4
src/llamafactory/model/model_utils/attention.py fa4 dispatch, _fa4_vision_needs_fa2 generalized routing, _patch_fa4_varlen_int_seqlen, print arm
src/llamafactory/data/collator.py FLASH_ATTN_IMPLS constant; fa2/fa4 share packing path; removed stale FIXME
src/llamafactory/model/patcher.py Qwen3.5 GPU packing gate accepts fa4
tests/model/model_utils/test_attention.py 3 GPU-free fa4 tests
examples/train_full/qwen3vl_full_sft_fa4.yaml, qwen3_full_sft_fa4.yaml example configs

Verification commands (reviewer can reproduce)

# style + tests (GPU-free)
make style && make quality && make license
WANDB_DISABLED=true pytest -vv --import-mode=importlib tests/model/model_utils/test_attention.py

# fa4 training (needs Hopper/Blackwell + flash-attn-4 wheel)
llamafactory-cli train examples/train_full/qwen3vl_full_sft_fa4.yaml   # VLM: ViT->fa2, LLM->fa4
llamafactory-cli train examples/train_full/qwen3_full_sft_fa4.yaml     # text: whole model fa4

Appendix — Root-cause analysis of the fa4 vision-tower crash (source-backed)

All line numbers are from the official flash-attention repo at tag fa4-v4.0.0.beta21
(flash_attn/cute/), the wheel used in these experiments.

Symptom. With flash_attn: fa4, a Qwen3-VL / Qwen3.5 run aborts at the first backward with a CuTe
DSL error:

'cute.copy' op expects pred to have compatible shape with: (1,(3)) but got actual predShape: (1,(2,3))
  raised from flash_attn/cute/flash_bwd_preprocess.py:381

Where the head_dim gate is set. FlashAttentionBackwardPreprocess.__init__ pads head_dim to a
multiple of 32 and flags the out-of-bounds (OOB) case:

# flash_attn/cute/flash_bwd_preprocess.py:67-71
hdim_multiple_of = 32
self.head_dim_padded   = int(math.ceil(head_dim   / hdim_multiple_of) * hdim_multiple_of)
self.head_dim_v_padded = int(math.ceil(head_dim_v / hdim_multiple_of) * hdim_multiple_of)
self.check_hdim_v_oob  = head_dim_v != self.head_dim_v_padded   # True iff head_dim_v % 32 != 0

So check_hdim_v_oob is True exactly when head_dim % 32 != 0 — e.g. 72 → padded 96 (OOB), 80 → 96
(OOB); 64/128/256 are already multiples of 32 (not OOB). can_implement only checks head_dim % 8 == 0
(line 97), so 72/80 are accepted at build time and only fail at runtime.

The bug. In the OOB path the kernel builds a copy predicate once and binds it to a partial without
slicing it per row-tile (m)
, then applies that partial to per-m tensor slices:

# flash_attn/cute/flash_bwd_preprocess.py:365-382
tOpO = None
if const_expr(self.check_hdim_v_oob):
    tOpO = copy_utils.predicate_k(tOcO, limit=headdim_v)   # 3-mode predicate (v, m=broadcast, k)
copy = partial(copy_utils.copy, pred=tOpO)                 # <-- full predicate bound, NOT sliced by m
...
for m in cutlass.range(cute.size(tOrO.shape[1]), unroll_full=True):
    if t0OcO[0, m, 0][0] < seqlen_limit - tOcO[0][0]:
        copy(tOgO[None, m, None], tOrO[None, m, None])     # line 381: src is a single-m slice
        copy(tOgdO[None, m, None], tOrdO[None, m, None])

copy_utils.copy forwards pred straight to cute.copy unchanged
(quack/copy_utils.py:224-236). The predicate from predicate_k carries an extra v mode
(shape (2,3) in the k-modes), but the single-m source slice expects a predicate of shape (3)
the (1,(3)) vs (1,(2,3)) mismatch.

Why the forward and the main backward are unaffected. Every other kernel slices the predicate by m
before the copy, so the shapes match:

# flash_attn/cute/flash_fwd.py:474-478  (forward)
cute.copy(gmem_thr_copy, tQgQ[None, m, None], tQsQ[None, m, None],
          pred=tQpQ[None, m, None] if const_expr(self.check_hdim_oob) else None)   # sliced by m

# flash_attn/cute/flash_bwd.py:1161,1169  (main backward)
pred=tdKpdK[None, rest_m, None] if const_expr(self.check_hdim_oob) else None       # sliced by m

The forward also pads to a multiple of 16 (flash_fwd.py:82), a different granularity. Hence: forward
works for all head_dims; the main backward works; only flash_bwd_preprocess.py has the unsliced-predicate
bug, and only on the check_hdim_v_oob (head_dim % 32 != 0) path. This matches the empirical result
exactly (64/128/256 pass, 72/80 fail, forward-only always compiles).

How the PR handles it. _fa4_vision_needs_fa2() routes a vision tower to fa2 whenever its head_dim is
not a multiple of _FA4_HEAD_DIM_MULTIPLE = 32 (the true kernel trigger), leaving the LLM on fa4. This is
minimal and precise: it does not needlessly downgrade vision towers whose head_dim is a multiple of 32
(e.g. 96), and it catches the real offenders (Qwen3-VL / Qwen3.5 ViT head_dim 72). A fix could instead be
sent upstream to flash-attention (slice the predicate per-m in flash_bwd_preprocess.py as the other
kernels do); until a fixed wheel ships, routing the vision tower to fa2 is the safe path.

Add FlashAttention-4 as a selectable attention backend, mirroring the existing
fa2 dispatch path so it applies uniformly to all supported models. Resolves the
pre-existing `# FIXME compatibility fa3/fa4` in data/collator.py.

- AttentionFunction.FA4 + dispatch to transformers' "flash_attention_4"
  (guarded by is_flash_attn_4_available(); graceful message on old transformers).
- Generalized vision-tower fallback: fa4 (v4.0.0.beta21) crashes in its
  backward-preprocess kernel for any attention head_dim not a multiple of 32.
  Root cause (flash_attn/cute/flash_bwd_preprocess.py): head_dim is padded to a
  multiple of 32 (line 68); when the real head_dim differs, the OOB copy-predicate
  (check_hdim_v_oob, line 71) is passed to cute.copy WITHOUT slicing it per row-tile
  (lines 367-369, 381-382), unlike the forward (flash_fwd.py:478) and main-backward
  (flash_bwd.py:1161) kernels which slice it -- producing a CuTe shape mismatch.
  Verified on B30Z: head_dim 64/128/256 pass, 72/80 fail, forward-only always works.
  Vision towers with such head_dim (Qwen3-VL / Qwen3.5 ViT: 1152/16=72) route the
  vision tower to fa2 and the LLM to fa4 via the transformers dict-form
  _attn_implementation; text / LLM-only models run fully on fa4. Detection is
  generic (_fa4_vision_needs_fa2, %32), not a model-type allow-list.
- Varlen speed fix: transformers passes max_seqlen_q/k as a CUDA tensor on packed
  paths, but fa4's cute API is typed Optional[int] and builds its backward JIT
  compile-key from it -- a tensor hashes by identity and recompiles every step
  (~12x slowdown). Coerce to int at the fa4 entry point (idempotent; no-op if fa4
  absent).
- collator FLASH_ATTN_IMPLS, Qwen3.5 packing-patch gate extended to fa4, two
  example configs, GPU-free unit tests.

Validated with seed-fixed fa2-vs-fa4 A/B runs on B30Z (Blackwell/B300-class,
sm_103a), flash-attn-4 4.0.0b21, transformers 5.6.0. Numerics aligned (Pearson
0.974, mean |dloss|<0.01, no drift) on Qwen3-VL-8B (ViT->fa2 routing) and
Qwen3-VL-4B (whole model fa4) over 1000 steps. Speed on Qwen3-4B text with packing:
fa4 +12.9% at 8192 tokens/step and +18.0% at 16384 tokens/step (attention speedup
grows with sequence length).

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

Copy link
Copy Markdown
Contributor

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 support for FlashAttention-4 (fa4), including a fallback mechanism to route vision towers with unsupported head dimensions to FlashAttention-2 and a patch to prevent per-step backward recompiles by coercing sequence length tensors to integers. The review feedback suggests two robust improvements: adding a safety check to prevent a potential division-by-zero error when calculating the attention head dimension, and using inspect.signature to dynamically bind arguments in the FlashAttention-4 patch instead of relying on hardcoded positional indices.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +58 to +61
hidden_size = getattr(sub_config, "hidden_size", None)
num_heads = getattr(sub_config, "num_attention_heads", None) or getattr(sub_config, "num_heads", None)
if hidden_size and num_heads:
return int(hidden_size) // int(num_heads)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If num_heads is configured as '0' (as a string) or if it is somehow parsed as 0, bool(num_heads) might evaluate to True (for '0'), leading to a ZeroDivisionError when calculating hidden_size // num_heads. Adding a robust check to ensure int(num_heads) > 0 prevents potential runtime crashes.

Suggested change
hidden_size = getattr(sub_config, "hidden_size", None)
num_heads = getattr(sub_config, "num_attention_heads", None) or getattr(sub_config, "num_heads", None)
if hidden_size and num_heads:
return int(hidden_size) // int(num_heads)
hidden_size = getattr(sub_config, "hidden_size", None)
num_heads = getattr(sub_config, "num_attention_heads", None) or getattr(sub_config, "num_heads", None)
if hidden_size and num_heads and int(num_heads) > 0:
return int(hidden_size) // int(num_heads)

Comment on lines +100 to +119
# Public signature: flash_attn_varlen_func(q, k, v, qv=None, cu_seqlens_q=None,
# cu_seqlens_k=None, max_seqlen_q=None, max_seqlen_k=None, ...) -> positional indices 6, 7.
max_seqlen_q_pos, max_seqlen_k_pos = 6, 7

@functools.wraps(orig)
def flash_attn_varlen_func_int_seqlen(*args, **kwargs):
for name in ("max_seqlen_q", "max_seqlen_k"):
value = kwargs.get(name)
if isinstance(value, torch.Tensor):
kwargs[name] = int(value.item())

if args:
args = list(args)
for pos in (max_seqlen_q_pos, max_seqlen_k_pos):
if len(args) > pos and isinstance(args[pos], torch.Tensor):
args[pos] = int(args[pos].item())

args = tuple(args)

return orig(*args, **kwargs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Hardcoding the positional indices 6 and 7 for max_seqlen_q and max_seqlen_k is fragile and highly sensitive to any future signature changes in flash-attn-4 (especially since it is currently in beta). Using inspect.signature to dynamically bind the arguments is a much more robust and maintainable approach.

Suggested change
# Public signature: flash_attn_varlen_func(q, k, v, qv=None, cu_seqlens_q=None,
# cu_seqlens_k=None, max_seqlen_q=None, max_seqlen_k=None, ...) -> positional indices 6, 7.
max_seqlen_q_pos, max_seqlen_k_pos = 6, 7
@functools.wraps(orig)
def flash_attn_varlen_func_int_seqlen(*args, **kwargs):
for name in ("max_seqlen_q", "max_seqlen_k"):
value = kwargs.get(name)
if isinstance(value, torch.Tensor):
kwargs[name] = int(value.item())
if args:
args = list(args)
for pos in (max_seqlen_q_pos, max_seqlen_k_pos):
if len(args) > pos and isinstance(args[pos], torch.Tensor):
args[pos] = int(args[pos].item())
args = tuple(args)
return orig(*args, **kwargs)
# Public signature: flash_attn_varlen_func(q, k, v, qv=None, cu_seqlens_q=None,
# cu_seqlens_k=None, max_seqlen_q=None, max_seqlen_k=None, ...)
import inspect
sig = inspect.signature(orig)
@functools.wraps(orig)
def flash_attn_varlen_func_int_seqlen(*args, **kwargs):
try:
bound_args = sig.bind(*args, **kwargs)
for name in ("max_seqlen_q", "max_seqlen_k"):
if name in bound_args.arguments:
value = bound_args.arguments[name]
if isinstance(value, torch.Tensor):
bound_args.arguments[name] = int(value.item())
return orig(*bound_args.args, **bound_args.kwargs)
except TypeError:
return orig(*args, **kwargs)

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