Add attention_backend="flex" for packed sequences - #206
timothyngo wants to merge 6 commits into
Conversation
Document packing built a dense (B, 1, S, S) boolean mask in every layer and handed it to F.scaled_dot_product_attention. An explicit attn_mask is not a FlashAttention-2 shape, so SDPA fell back to the mem-efficient/math kernel: packing gave up the fast kernel to save padding, and rebuilt an O(B*S^2) mask per layer to do it. "flex" instead builds one FlexAttention BlockMask per forward, shared across layers, so the mask predicate compiles into the kernel and fully-masked blocks are skipped rather than computed. GQA goes through enable_gqa instead of repeat_interleave, so the repeated K/V heads are never materialized. Default stays "sdpa" and is bit-identical to before. The flag only does anything when data.pack_sequences is on; unpacked batches take the is_causal SDPA fast path under either backend. Requires train.seq_len >= 128, FlexAttention's mask block size. Below one block the compiled kernel silently returns wrong results -- documents leak into each other while eager stays correct -- measured exact (0.0 drift) at 128/129/130/ 200/300/1000/2048 and broken at 32/64/96/120/127. Sub-block sequences have no block sparsity to exploit either, so the restriction costs nothing: every shipped config uses seq_len 512, 1024, or 4096. Verified against an fp64 CPU reference: flex is closer to exact than the dense-mask SDPA path on every parameter (relative error 3.1e-7..5.4e-7 vs 3.2e-7..9.1e-7), so the gradient test bounds disagreement relative to each parameter's own scale rather than with a fixed atol. Tests: 1843 unit (CPU) and 14 integration (H200) passing, including a compiled-mode cross-document isolation regression test, ragged sequence lengths either side of the block size, and a no-recompilation guard on the per-step mask_mod closure.
Two things this PR got wrong, both found by measuring rather than reasoning.
head_dim <= 256 was never verified -- it came from a design review and a comment
in a sibling repo, and it is false. head_dim 256, 320, 384 and 512 all compute
correctly on H200 / torch 2.11, forward and backward, to ~2e-6 against a dense
reference. The cap was rejecting working configurations.
It is not replaced with a wider range, because the usable set is not an
interval: head_dim 192 fails to compile ("No valid triton configs",
shared-memory exhaustion) while 128 and 256 on either side of it are fine. A
config-time check cannot predict which sizes Triton can tile on a given GPU,
and that failure is a loud compile-time error rather than silent corruption --
so 192 is documented as known-bad rather than guarded. The floor stays:
head_dim 8 fails to lower with "NYI: embedding dimension".
The seq_len >= 128 bound is correct and stays, but the reason given for it was
wrong. This PR claimed "below one block the compiled kernel silently returns
incorrect results". The kernel is fine: bare compiled flex_attention matches a
dense reference at seq_len 32/64/96 for head_dim 16/32/64/128, every
combination. The leak appears only once the call sits inside a compiled
Transformer graph, and it reproduces for head_dim 32, 64 and 128 alike, so it
is a sequence-length effect rather than a head-dimension one. Root cause
unestablished; the bound is empirical and now says so. That distinction matters
for whoever picks this up: the kernel is not the place to look.
Tests: 1846 unit, 14 integration on H200.
|
Pushed
|
| dim | head_dim | S=32 | S=128 | S=256 |
|---|---|---|---|---|
| 128 | 32 | leak 3.3e-02 | 0.0 | 0.0 |
| 256 | 64 | leak 8.6e-02 | 0.0 | 0.0 |
| 512 | 128 | leak 3.7e-01 | 0.0 | 0.0 |
So it is a sequence-length effect, not a head-dimension one, and the bound guards the right variable. But the cause is not established — and there is an unresolved contradiction behind it: a minimal torch.compiled wrapper calling flex_attention at S=32 is correct, while the full model at S=32 is not. Something about the larger fused graph matters and I did not isolate what.
The error message, the FLEX_BLOCK_SIZE comment, the masking.py docstring, the CHANGELOG and validation-rules.md now all say what was observed, state that the bound is empirical, and say the root cause is unknown. That distinction is load-bearing for whoever revisits this: "the kernel is broken" sends someone upstream to PyTorch, when the kernel is provably fine and the unexplored territory is our compiled graph.
Tests: 1846 unit, 14 integration on H200.
Swapping the torch.compile backend splits the stack cleanly at seq_len 32:
eager 0.00e+00 OK
aot_eager 0.00e+00 OK
inductor 3.32e-02 LEAK
Dynamo tracing and AOTAutograd both produce correct results on the identical
model, so the fault is in Inductor's code generation -- not tracing, not
functionalization, not autograd, not the mask construction, and not the
FlexAttention kernel, which is separately exact at these lengths.
It also needs graph scale rather than any particular neighbouring op: a whole
attention block (proj + rope + flex + o_proj) compiled by Inductor is exact at
seq_len 32, while a one-layer Transformer is not.
No behaviour change -- the seq_len >= 128 guard was already correct. This only
replaces "somewhere in the compiled graph, cause unknown" with the actual
layer, which is what someone would need to file this upstream or to know when
it is safe to lift the bound.
Ran the isolation test against three torch builds across two CUDA majors, with
the 2.11 baseline as a must-leak control so a clean table could not be mistaken
for a fix:
2.11.0+cu128 seq 32: 3.32e-02 seq 64: 2.45e-02 LEAK
2.13.0+cu129 seq 32: 3.32e-02 seq 64: 2.45e-02 LEAK
2.14.0+cu130 seq 32: 3.32e-02 seq 64: 2.45e-02 LEAK
Bit-identical drift every time, so this is deterministic codegen behaviour
rather than a flaky race, and the seq_len >= 128 bound is not a temporary
workaround waiting on a release.
Also adds the control that matters most for anyone already packing: the
dense-mask "sdpa" path does not leak at any sequence length, eager or compiled.
The bug arrives only with attention_backend="flex", so existing packed runs on
the default backend are not at risk.
No behaviour change.
Same treatment as the packing + pipeline-parallelism guard: the error says what happened and what to do, and the reasoning -- which layer of the compile stack is at fault, that it survives a torch upgrade, that the bound costs nothing -- lives in a comment above the raise for whoever reads the guard.
Summary
Adds
model.attention_backend = "flex", routing packed batches through FlexAttention's block-sparse mask instead of the dense(B, 1, S, S)mask the packed path builds today.Why
attention.py:214-227materializes a dense boolean mask in every layer and hands it toF.scaled_dot_product_attention. An explicitattn_maskis not a FlashAttention-2 shape, so SDPA silently falls back to the mem-efficient/math kernel. Packing therefore gave up the fast kernel in order to save padding, and rebuilt an O(B·S²) mask per layer to do it — atseq_len=8192, B=4that is a 256 MB bool tensor constructed 32 times per forward. The comment already atattention.py:201names FlexAttention as the intended fix; this is it.What
kempnerforge/model/masking.py(new) —build_doc_causal_block_mask(block-diagonal causal;H=Noneso the mask broadcasts over heads, which is what keeps it correct under TP's sharded head counts) andflex_attention_fn, which compilesflex_attentiononce per process on CUDA and stays eager on CPU.attention.py— ablock_maskbranch placed after thecapture_attention_weightscheck and before the dense-mask path. Ordering is load-bearing:MoMaBlockbuilds this same class and passes bothdoc_idsandkey_padding_mask, so a flex branch placed first would silently drop the padding mask. GQA usesenable_gqarather thanrepeat_interleave, so repeated K/V heads are never materialized.transformer.py— builds the BlockMask once per forward and threads it to every block, replacing the per-layer dense mask. Raises ifdoc_idsis shorter than the sequence reaching attention (the VLM image-prefix case).config/model.py/config/job.py— theattention_backendfield,FLEX_BLOCK_SIZE, and validation.Behavior change
Default
"sdpa"is bit-identical to before — verified, not assumed:test_no_doc_ids_is_bit_identical_to_sdpaandtest_doc_ids_none_is_exact_noop. The flag only does anything whendata.pack_sequencesis on; unpacked batches take theis_causalSDPA fast path under either backend.The
seq_len >= 128restrictionThis one is worth a close look. Under
torch.compile, flex silently leaks across documents whenseq_lenis below FlexAttention's 128 block size, while eager stays correct. Measured drift in document-1 logits when document-0 tokens change (must be exactly 0):Ruled out along the way: a torch bug (a minimal pure-torch repro is clean in every variant, ~7e-7), the nested
torch.compile(raw and inner-compiled flex fail identically), the dynamo graph break (building the mask outside the model fails the same), out-of-bounds indexing in the padded tail (S=160 has a padded tail and is exact; clamping indices changed nothing), and layer count (1 layer fails like 2).JobConfig.validaterejects the configuration rather than papering over it. The restriction costs nothing real: every shipped config usesseq_len512/1024/4096, the default is 2048, and below one block there is no block sparsity for flex to exploit, so it would be pure overhead even if it were correct.Numerics
Checked against an fp64 CPU reference rather than trusting a tolerance: flex is closer to exact than the dense-mask SDPA path on every parameter (relative error 3.1e-7..5.4e-7 vs SDPA's 3.2e-7..9.1e-7). The gradient test therefore bounds disagreement relative to each parameter's own scale — gradients reach ~1.8e3 here, so a fixed
atolis meaningless — with both an elementwise and a whole-tensor-norm bound, so a single bad element cannot hide in the norm.Testing
uv run ruff check/ruff format --checkpassuv run pyright kempnerforge/— only the pre-existingvideo_io.pyimport averror (optionalvideogroup, not installed); zero in touched filesuv run pytest tests/unit/— 1843 passed, 13 skipped (1808 on main)pytest tests/integration/test_compile.pyon 1×H200 — 14 passed, including:test_cross_document_isolation[compiled]— direct regression test for the bug abovetest_sequence_lengths_around_the_block_size[128/200/300]— ragged lengths with a partial trailing blocktest_no_recompilation_across_document_layouts— the per-stepmask_modclosure must not retrigger compilation, since that would blowcache_size_limitand fall back to eager flex, which is slower and hungrier than what it replaces, with no error to noticeruff,ruff-format) on all changed filesNot run:
tests/distributed/— this PR adds no distributed code path. TP interaction is argued fromH=None(head-broadcast mask) andn_repbeing computed from global head counts, but a 2-GPU TP+flex parity test is in the follow-up rather than asserted here.Known limitations
"flex"is CUDA-only in practice; CPU coverage is forward-only undertorch.no_grad().capture_attention_weightsis unsupported under flex (the kernel never forms an attention-weight matrix) and raises with a clear message.key_padding_mask(VLM video) and MoT self-attention stay on the dense SDPA path, by scope.Follow-ups
"flex"for packed runs, once benchmarked.Refs #204