Route non-causal windowed prefill to AITER's masked kernel - #339
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a correctness defect in the ROCm AITER prefill backend where causal=False sliding-window requests incorrectly routed to the unmasked (_nmask) kernel variant, silently producing unwindowed results. The change aligns FlashInfer’s AITER variant selection and mask arguments with AITER’s “anything masked” split, and adds targeted regression tests plus capability-matrix documentation updates.
Changes:
- Route non-causal windowed prefill calls through AITER’s masked (
_mask) variant by keying onneeds_mask = causal || window_left >= 0, and pass masking args accordingly. - Add ROCm regression tests covering
causal × window_left × dtype × backendacross single, ragged, and paged prefill entry points. - Update arch capability notes and README support matrix to advertise sliding-window support for AITER prefill.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
include/flashinfer/rocm/attention/aiter/aiter_loader.h |
Rename VariantKey::causal to needs_mask and update equality/hash to key variant selection on “any masking”. |
csrc/rocm/aiter_loader.cc |
Switch _mask/_nmask segment selection to needs_mask and update dlopen-failure hint text accordingly. |
include/flashinfer/rocm/attention/aiter/single_prefill.cuh |
Normalize overly-wide windows, compute needs_mask, and ensure masked variant + args are used for non-causal windowed calls. |
include/flashinfer/rocm/attention/aiter/batch_prefill.cuh |
Compute needs_mask and ensure both flat-gather and native-paged args select masking when needed. |
flashinfer/prefill_rocm.py |
Add _aiter_needs_mask() and thread needs_mask through AITER bootstrap/probe paths without changing public signatures. |
tests/rocm/test_prefill_sliding_window.py |
New regression suite validating window semantics (including non-causal windowed) across backends and wrappers. |
flashinfer/arch_caps.py |
Update AITER prefill capability notes to include sliding window. |
README.md |
Update the generated support matrix rows to advertise sliding-window support for AITER prefill. |
Suppressed comments (2)
csrc/rocm/aiter_loader.cc:195
- The dlopen failure hint prints
causal_or_windowed=..., but users must pass the actual AITER keyword argument name (is_causal) when callingaiter.ops.mha.mha_varlen_fwd. Using a non-existent parameter name makes the hint harder to follow.
return " Hint: trigger AITER's lazy JIT build by importing aiter.ops.mha and "
"calling mha_varlen_fwd with matching (dtype=" +
std::string(key.dtype == VariantKey::Dtype::kFp16 ? "fp16" : "bf16") +
", causal_or_windowed=" + (key.needs_mask ? "true" : "false") +
", has_lse=" + (key.has_lse ? "true" : "false") + ")." + kAbiPinNote;
csrc/rocm/aiter_loader.cc:207
- The dlopen failure hint prints
causal_or_windowed=..., butmha_batch_prefill_func()takes the keywordcausal. Matching the real AITER argument name makes the hint directly actionable, while the added note clarifies that this value is used to select the_maskvariant.
return " Hint: trigger AITER's lazy JIT build by calling "
"aiter.ops.mha.mha_batch_prefill_func() once with matching (dtype=" +
std::string(key.dtype == VariantKey::Dtype::kFp16 ? "fp16" : "bf16") +
", causal_or_windowed=" + (key.needs_mask ? "true" : "false") +
", has_lse=" + (key.has_lse ? "true" : "false") +
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A prefill with causal=False and window_left >= 0 returned the unwindowed
result -- 0.66-0.69 abs error against an fp32 windowed reference, on the
flat-gather and native-paged routes, on gfx942 and gfx950. Nothing raised
and the numbers were plausible.
Our variant key drove the .so mask axis off causal alone, so the request
loaded a _nmask binary -- built with SimplifiedGenericAttentionMask<false>,
which has no masking logic compiled in. AITER splits that .so on "is
anything masked", not on causality:
no_mask = (not causal) and window_size_left == -1 and window_size_right == -1
use_mask = not no_mask
-- aiter/ops/mha.py:766-769, 0.1.20
So VariantKey::causal becomes needs_mask, fed causal || window_left >= 0,
and the three args sites set mask_type from it. window_size_right keeps the
real causal: 0 blocks future tokens, while -1 saturates to the full extent
and leaves the left-bound-only band FlashInfer defines at
include/flashinfer/attention/variants.cuh:89.
Verified on the installed amd-aiter 0.1.20 wheel, not a source checkout:
$ nm -DC mha_varlen_fwd_bf16_nlogits_nbias_mask_lse_ndropout_skip_nqscale.so \
| grep -oE '(Simplified)?GenericAttentionMask<[^>]*>' | sort | uniq -c
120 SimplifiedGenericAttentionMask<true>
$ nm -DC ..._nmask_... | ...
120 SimplifiedGenericAttentionMask<false>
Zero causal-specialised GenericAttentionMask<true,false> instances, so the
one _mask binary serves causal, left-only, right-only and two-sided bands;
its IsOutOfBound enforces i_x < x_start || i_x >= x_end independently.
Adding a separate has_window field would have been dead weight: build_so_name
only ever ORs it with causal, so it could never select a different .so while
breaking every existing probe caller. Reusing the field keeps the Python
signatures untouched -- the same OR goes into the existing parameter at all
eight call sites, including the four else-branch bootstraps that an explicit
backend="aiter" takes, which would otherwise miss the dlopen.
The single-prefill shim also normalizes window_left >= kv_len to -1, as
AITER's own torch path does (mha_fwd_kernels.cu:253), so a window wider than
the sequence stays on _nmask and off a cold ~35-minute CK build. The batch
shims cannot: plan() knows only a maximum kv_len.
The combination that broke was untested everywhere in the tree: upstream's
prefill window tests all pass causal=True, and tests/rocm/test_sliding_window.py
is decode-only. That is why a silently-unwindowed result shipped.
Covers causal x window_left {-1, 0, 31, 127} x {fp16, bf16} x {fa2, aiter}
across all three entry points, and the paged wrapper at both a native page
size (1024, mha_batch_prefill) and one that degrades to flat-gather (16) --
different args sites in batch_prefill.cuh.
test_aiter_and_fa2_agree_on_a_non_causal_window first asserts the windowed
and unwindowed references differ by more than 0.1, so the test cannot pass by
both backends ignoring the window -- which is exactly the failure it guards.
gfx942: 138 passed, 0 skipped, 0 failed.
batch_decode already claimed it; both prefill rows now honour a window for causal and non-causal alike, so the capability notes should say so. README.md is regenerated from arch_caps.py by scripts/gen_arch_support_matrix.py.
Two of these are coverage defects that only show up on the box you did not run on: - The batch tests gated AITER on the arch string alone. is_aiter_supported() does not consult the capability table, so on gfx950 + ROCm 7.2.x -- where batch_prefill/aiter is known_bad -- the wrapper constructor raises ArchCapabilityError and 48 cases ERROR instead of skipping. _skip_unless_aiter now takes the op and asks capability_available(). - test_paged_prefill_window assumed page_size=1024 takes the native-paged kernel. If _aiter_native_paging_available() degrades, both page sizes take flat-gather, batch_prefill.cuh's native args site goes untested, and the suite stays green while a comment claims otherwise. Asserted directly. _aiter_needs_mask's kv_len loses its default. Omitting it at the single-prefill site is not a safe over-approximation: Python would bootstrap _mask while the shim dlopens _nmask, and AITER pre-ships zero mha_fwd*.so, so that is a load failure rather than a wasted build. The batch sites pass None explicitly. The dlopen hint named causal_or_windowed=, which is not an AITER keyword; it now reports is_causal= and notes that a window selects the same variant. Remaining stale "causal" comments in prefill.py and backends.md updated. Rebased onto #338, which moved the Python layer to flashinfer/rocm/; the test imports move with it.
34e08ec to
fa691dc
Compare
|
Suppressed comments, review 5072018324 (2) — both are the The second is right further than it states: Note |
The batch-prefill hint said is_causal=, but that entry point takes causal;
only mha_fwd and mha_varlen_fwd use is_causal. Measured against the installed
0.1.20 wheel rather than the review comment:
mha_fwd aiter/ops/mha.py:205 is_causal: bool,
mha_varlen_fwd aiter/ops/mha.py:929 is_causal: bool,
mha_batch_prefill_func aiter/ops/mha.py:3881 causal=False,
A hint that names a parameter the function does not accept is worse than no
hint, since it sends someone debugging a missing .so down a TypeError.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
csrc/rocm/aiter_loader.cc:185
- The dlopen failure hint suggests passing
has_lse=...toaiter.ops.mha.mha_fwd, but the AITER Python API usesreturn_softmax_lse(see flashinfer/rocm/prefill.py where the bootstrap callsmha_fwd(..., return_softmax_lse=...)). Using a non-existent kwarg makes the hint harder to follow.
This issue also appears in the following locations of the same file:
- line 195
- line 210
", is_causal=" + (key.needs_mask ? "true" : "false") +
" (window_size_left>=0 selects the same variant)" +
", has_lse=" + (key.has_lse ? "true" : "false") + ")." + kAbiPinNote;
csrc/rocm/aiter_loader.cc:197
- The dlopen failure hint suggests passing
has_lse=...toaiter.ops.mha.mha_varlen_fwd, but the AITER API usesreturn_softmax_lse. The current hint kwarg name is likely invalid for users trying to bootstrap the variant.
", is_causal=" + (key.needs_mask ? "true" : "false") +
" (window_size_left>=0 selects the same variant)" +
", has_lse=" + (key.has_lse ? "true" : "false") + ")." + kAbiPinNote;
csrc/rocm/aiter_loader.cc:212
- The dlopen failure hint for
mha_batch_prefill_funcprintshas_lse=..., but the callable usesreturn_lse(see flashinfer/rocm/prefill.py bootstrap). Using the real kwarg name in the hint would reduce confusion when users try to trigger the lazy JIT build.
", causal=" + (key.needs_mask ? "true" : "false") +
" (a window selects the same variant)" +
", has_lse=" + (key.has_lse ? "true" : "false") +
|
Suppressed comments, review 5073008867 (3) — all valid, fixed in #342 after this merged.
My earlier disposition comment covered only review 5072018324; reviews 5072933371/5072952604/5073008867 landed after later pushes and I did not re-sweep before merge. |
## Summary The three dlopen-failure hints in `aiter_loader.cc` tell the reader to call an AITER entry point "with matching `(dtype=..., is_causal=..., has_lse=...)`". `has_lse` is not a parameter of any of them, so someone debugging a missing `.so` follows the hint and gets a `TypeError` rather than the JIT build they wanted. Raised as suppressed comments on #339 after it merged. Verified against the installed amd-aiter 0.1.20 wheel rather than the review text: ``` mha_fwd aiter/ops/mha.py:209 return_softmax_lse: bool, mha_varlen_fwd aiter/ops/mha.py:933 return_softmax_lse: bool, mha_batch_prefill_func aiter/ops/mha.py:3885 return_lse=False, ``` `dtype` is the same defect one step further, which the review did not reach: it is not a keyword either — it comes from the q/k/v tensors. Relabelled `q dtype` so everything inside the parentheses is a real keyword and the next reader is not misled by a mixed list. This is the second round of the same class on this file; #339 corrected `causal` vs `is_causal`. The cause is that the hint reads as prose while being a literal call recipe. ## What changed - **`csrc/rocm/aiter_loader.cc`** — `has_lse=` becomes `return_softmax_lse=` for the `mha_fwd` and `mha_varlen_fwd` hints and `return_lse=` for `mha_batch_prefill_func`; `dtype=` becomes `q dtype=` in all three. Error-message text only, no behaviour change. ## Test plan - [x] Each keyword read off the installed 0.1.20 wheel in the ROCm 10.0 container, not from a source checkout. - [x] `pre-commit run -a`. - No test covers these strings — they are the text of a `std::runtime_error` raised only when a variant `.so` is absent.
Summary
AITER prefill silently ignored
window_leftwhenevercausal=False: it returned the unwindowed result, 0.66-0.69 abs error against an fp32 windowed reference, on both the flat-gather and native-paged routes and on both architectures. Nothing raised and nothing warned — the numbers were plausible, and only a reference comparison caught it. Found while measuring #337 and deferred to its own PR because it predates that work; it reproduces unchanged on the older ROCm 7.14 / amd-aiter 0.1.16 image.The defect
Our variant key drove the
.somask axis offcausalalone (aiter_loader.cc,name += key.causal ? "_mask" : "_nmask"), so a non-causal windowed request loaded a_nmaskbinary. Those are compiled withSimplifiedGenericAttentionMask<false>and have no masking logic at all, sowindow_size_leftarrives and has nothing to act on. AITER splits that.soon a broader condition — "is anything masked":We compounded it by setting
mask_type = causal ? bottom_right : noneat the three args sites, so even the right binary would have been told not to mask.Design: reuse the key field rather than add one
The obvious shape — add
bool has_windowtoVariantKey— is wrong.build_so_namewould only ever OR it withcausal, so it could never select a different.so: a dead hash axis whose only effect is breaking every existing caller of the probes. InsteadVariantKey::causalbecomesneeds_mask, fedcausal || window_left >= 0.mask_typewants the same OR; onlywindow_size_rightwants the realcausal. On the Python side that same value goes into the existing parameter, so no signature changes, no newlru_cacheaxis, and no broken callers.Merging the two cache entries is correct rather than a collision: a
causal=Truerequest and acausal=False, windowedrequest need the same.so, and the bootstrap exists only to make AITER emit it.Why
window_size_rightstays-1FlashInfer's window is a left-bound-only, bottom-right-anchored band applied independently of
causal(include/flashinfer/attention/variants.cuh:89,mask &= (kv_idx + qo_len + window_left >= kv_len + qo_idx)). CK'smake_generic_attention_mask_coordinates_from_lr_windowwithis_top_left=falsesaturates a negative right bound toy_total - 1, givingx = x_total— no right constraint — andy = 1 + window_left + qo_len - kv_len, which is that inequality exactly.0is the causal convention and is unchanged.What changed
include/flashinfer/rocm/attention/aiter/aiter_loader.h—VariantKey::causal→needs_mask, in the field,operator==and the hash.BatchPrefillVariantKeyis an alias and follows.csrc/rocm/aiter_loader.cc—build_so_namekeys the mask segment offneeds_mask; the three dlopen-failure hints no longer print acausal=that would contradict the_maskvariant they ask the user to bootstrap.single_prefill.cuh,batch_prefill.cuh— the three key sites and three args sites. The single-prefill shim also normalizeswindow_left >= kv_lento-1, as AITER's own torch path does, so a window wider than the sequence stays on_nmaskand off a cold ~35-minute CK build. The batch shims cannot:plan()knows only a maximumkv_len. Thestatic_cast<int32_t>on that comparison is load-bearing —params.kv_lenisuint32_t, so without it-1 >= kv_lenpromotes unsigned and strips every window.flashinfer/prefill_rocm.py— one_aiter_needs_mask()helper, called at all eight sites. Four are theresolved_from_autoprobe branches; the other four are theelsebranches that bootstrap directly, which an explicitbackend="aiter"takes. Missing that second group is what would leave a windowed non-causal call failing the C++ dlopen rather than merely being slow.flashinfer/arch_caps.py,README.md— both prefill rows now advertise sliding window, asbatch_decodealready did. README is regenerated byscripts/gen_arch_support_matrix.py.Verification against the installed 0.1.20 wheel
Read out of the container, not a source checkout —
~/devel/aitertracks master and has a different C ABI.Zero causal-specialised
GenericAttentionMask<true,false>instances, so the single_maskbinary serves causal, left-only, right-only and two-sided bands alike; itsIsOutOfBoundenforcesi_x < x_start || i_x >= x_endindependently. Also confirmed on 0.1.20: theuse_maskrule above, the three-branchif (is_causal) / else if (both == -1) / elsemapping whoseelseemits"b:left,right,sink", and thatsink_sizestill sits betweenwindow_size_rightandmask_typein all four arg structs, matching our vendored headers.Test plan
tests/rocm/test_prefill_sliding_window.py:causal×window_left∈ {-1, 0, 31, 127} × {fp16, bf16} × {fa2, aiter} across all three entry points, plus the paged wrapper at a native page size (1024) and one that degrades to flat-gather (16). gfx942: 138 passed, 0 skipped, 0 failed.mha_fwd_fp16_nbias_mask_...loaded in both halves, so the defect was isolated tomask_type. The causal rows are unaffected by construction —causal ? X : Yandneeds_mask ? X : Yare identical whencausalis true.pytest -n auto --reruns 2 -m "not slow" tests/rocm/on gfx942 from a cleared JIT cache: 29072 passed, 3621 skipped, 0 failed on ROCm 7.15.26333 / torch 2.12.0+rocm10.0.0 / amd-aiter 0.1.20 — 172 more than the pre-change collection, which is this PR's new file. Measured at34e08ecb0, before the rebase onto refactor(rocm): remove the CUDA/HIP dual-platform abstraction #338.fa691dcea— refactor(rocm): remove the CUDA/HIP dual-platform abstraction #338 relocated the Python layer underflashinfer/rocm/, so the gfx942 number above no longer describes the pushed tree and is not being carried forward as a pass.pre-commit run -a.