Skip to content

CUDA backend: lower fused attention variants - #24426

Merged
shoumikhin merged 1 commit into
pytorch:mainfrom
shoumikhin:cuda-fused-sdpa
Nov 2, 2026
Merged

shoumikhin merged 1 commit into
pytorch:mainfrom
shoumikhin:cuda-fused-sdpa

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

Decomposing scaled_dot_product_attention on a CUDA device turns it into one of its fused variants: _scaled_dot_product_flash_attention without a mask, or _scaled_dot_product_efficient_attention with one. torch.export's default decompositions do this. So does Torch-TensorRT, for an attention it leaves to the CUDA backend, for example one whose keys are a constant. The CUDA backend has no kernel for these ops:

RuntimeError: Method forward missing fallback kernels (1 total):
  - at::_ops::_scaled_dot_product_flash_attention::call

What this change does

A new pass, ReplaceFusedSdpaWithSdpaPass, turns the flash, efficient and cuDNN variants back into scaled_dot_product_attention, with the same query, key, value, bias, dropout, causal flag and scale. The Triton SDPA kernel or Inductor's math decomposition then lowers it, as it lowers any other attention. The pass also looks inside control flow branches.

  • It sets enable_gqa unless the query and key head counts are known to be equal, because the fused variants group fewer key heads the same way.
  • It leaves out a scale of None, because the Triton SDPA kernels take a float scale.

The pass rewrites a node only when scaled_dot_product_attention computes the same result:

  • Only the attention output may be used, which is the case for inference.
  • A causal call must have no bias, because scaled_dot_product_attention takes no mask together with is_causal.
  • A causal call must have query and key lengths that are known to be equal. Flash attention aligns a causal mask to the bottom right, the other variants and scaled_dot_product_attention to the top left, and the Triton decode kernel for one query ignores it.

Otherwise the node stays, the pass logs why, and the error above still names it.

What was tested

With torch 2.14 on an A100:

  • test_fused_sdpa decomposes attention on a CUDA device into a causal flash attention with fewer key heads and a custom scale, an efficient attention with a mask, and a flash attention of one query over 256 keys. It then lowers the program, checks that the first and last calls get the Triton kernels, runs it, and compares each output with eager.
  • test_replace_fused_sdpa.py checks on the CPU which calls the pass rewrites and with which keyword arguments, which calls it keeps, that the program stays valid, and that the pass runs before the Triton kernel replacement.

They fail without this change. The other CUDA tests in the same CI job pass.

Copilot AI balanced review requested due to automatic review settings November 2, 2026 13:26
@pytorch-bot

pytorch-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24426

Note: Links to docs will display an error until the docs builds have been completed.

✅ You can merge normally! (1 Unrelated Failure)

As of commit 4a201ca with merge base ab68460 (image):

BROKEN TRUNK - The following job failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 2, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 27m 57s —— View job


No blockers; the main gaps are that the tests neither check what the pass emits nor go through the CUDA decomposition this PR fixes, that the causal length guard keeps calls scaled_dot_product_attention expresses exactly, and that kept nodes fail only after a full AOTInductor compile.

PR Review: #24426

Summary

This PR adds ReplaceFusedSdpaWithSdpaPass, which turns the flash, efficient and cuDNN attention variants back into scaled_dot_product_attention so the CUDA backend can lower them. I found no case where the rewritten graph computes a different result. The findings below cover a causal guard that is stricter than it needs to be, a late failure for nodes the pass keeps, masked calls that never get the Triton kernel, and gaps in the tests. Nothing was run, because Python needs approval in this job. Statements about PyTorch's own kernels that I couldn't check against code here are marked as such.

Code Quality

  • The causal length check keeps calls that scaled_dot_product_attention expresses exactly (backends/cuda/passes/replace_fused_sdpa.py:90-95).
    • As the comment there says, only flash aligns the causal mask to the bottom right. Efficient and cuDNN align it to the top left, the same as scaled_dot_product_attention. So for those two variants the check only protects the split-K decode kernel.
    • That kernel treats is_causal as a no-op (backends/cuda/triton/kernels/sdpa.py:1846-1848) and is exempt from the causal check (backends/cuda/triton/replacement_pass.py:181). But it only takes a static L_q == 1, a static L_kv >= 256 and a power-of-two head size (replacement_pass.py:113-122).
    • With static lengths, line 181 rejects every other non-square causal call, so it goes to the math path, where the rewrite would be exact.
    • Trigger: F.scaled_dot_product_attention(q, k, v, is_causal=True) with L_q=4, L_kv=16, decomposed on CUDA. Flash refuses non-square causal calls (from PyTorch's dispatch, not checked here), so this becomes the efficient or cuDNN variant with is_causal=True. The pass keeps it, and the export still fails with the missing-kernel error.
    • Suggestion: for efficient and cuDNN, also rewrite calls whose lengths are statically known to differ, unless L_q is statically 1. Point the comment at _pick_sdpa_kernel so the two stay in sync.
    • Flash with a static L_q == 1 can also be rewritten with is_causal=False, because a bottom-right mask over one query masks nothing.
    • Fix this →
  • A kept node fails only after the full AOTInductor compile (replace_fused_sdpa.py:63-68).
    • get_supported_fallback_kernels has no attention entry (backends/cuda/cuda_backend.py:960-973), so every node the pass keeps ends in the "missing fallback kernels" error.
    • AotiBackend.preprocess raises that error only after torch._inductor.aot_compile returns (backends/aoti/aoti_backend.py:337-352).
    • So for a kept call, such as a causal decode step with L_q = 1, the user waits through the whole compile. The reason is only in an earlier warning, and the final error names just the op.
    • Raising here with the node and the reason fails fast and puts the reason in the error.
    • Fix this →

Testing

  • test_fused_sdpa_rewrite only checks that the fused node is gone (backends/cuda/tests/test_cuda_export.py:385).
    • assertEqual(not fused, rewritten) still passes if the pass emits scaled_dot_product_attention with the wrong bias, is_causal, scale or enable_gqa.
    • No row has fewer key heads or a custom scale, so neither the scale branch nor the enable_gqa branch (replace_fused_sdpa.py:108-115) is taken. Every row also passes k as both key and value.
    • On ROCm this is the only check of the rewrite: test_fused_sdpa is skipped there, while this test runs (.ci/scripts/test-rocm-aoti.sh:154-157).
    • Fix this →
  • Neither test goes through the decomposition this PR is about (test_cuda_export.py:808-844).
    • Both tests call the private fused ops directly.
    • test_fused_sdpa also exports with CPU tensors (export(module, inputs) at line 429). Its docstring describes decomposing attention on CUDA, but the test never decomposes anything.
    • The real path, F.scaled_dot_product_attention exported with CUDA tensors and then run_decompositions(), builds a different graph around the fused op (from PyTorch's C++ SDPA, not checked here):
      • flash gets an explicit float scale, and padded inputs with a sliced output when the head size isn't a multiple of 8;
      • a bool mask becomes a float bias that is padded and expanded.
    • TestCudaExport.setUp already requires CUDA (line 391), and SdpaModule is already imported (line 19). A case that decomposes on CUDA, asserts that a fused variant is present, then lowers and compares with eager would reproduce the error quoted in the description.
    • Fix this →
  • The pass test doesn't follow the backends/cuda/passes/tests/ pattern (test_cuda_export.py:30-56, 328).
    • The other passes are tested in backends/cuda/passes/tests/, which CI already runs (.github/workflows/cuda.yml:622, .ci/scripts/test-rocm-aoti.sh:157).
    • test_replace_int64_floordiv.py:51 also checks that its pass stays registered in CudaBackend.get_custom_passes.
    • This test instead sits in TestCudaBackendCompileOptions, with two module-level helpers that only it uses. Only the CUDA-only test_fused_sdpa would notice if the pass were dropped from get_custom_passes.
    • Fix this →

Performance

  • Masked rewrites and rewrites inside torch.cond never reach a Triton kernel (replace_fused_sdpa.py:124).
    • The efficient and cuDNN variants take an additive float bias. _prepare_mask_params rejects any mask that isn't bool, and any mask with more than one head (backends/cuda/triton/kernels/sdpa.py:999-1007).
    • So _sdpa_kernel_supports sends every rewritten masked call to the MATH backend (cuda_backend.py:1185-1189), which materializes the full L_q × L_kv score matrix.
    • Rewrites inside a torch.cond branch also go to the math path, because ReplaceEdgeOpWithTritonOpPass.call only walks the top-level graph (replacement_pass.py:70).
    • Result: a masked LLM attention that was decomposed before lowering loses the Triton kernel, which the same model gets when exported undecomposed with a head-broadcast bool mask.
    • At minimum, say so in the module docstring. It currently says the Triton SDPA kernel or Inductor's math decomposition lowers the result.

Recommendation

Approve. Nothing blocks merging. The two test gaps are the ones worth fixing first: as written, no test would catch a rewrite with wrong arguments on ROCm, or a mismatch with the graph the CUDA decomposition really produces.

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 29m 51s —— View job


No blockers. The one gap is that no test checks that the rewritten calls reach the Triton kernels, so a change that sends them to the slower math path would still pass every test.

PR Review: #24426

Summary

This PR adds ReplaceFusedSdpaWithSdpaPass, which turns the flash, efficient and cuDNN attention variants back into scaled_dot_product_attention so the CUDA backend can lower them. I found no case where the rewrite computes a different result. Which corner each variant aligns its causal mask to is PyTorch behavior, though, and I couldn't check it here. The one finding is a gap in the tests. Nothing was run, because Python needs approval in this job.

Testing

  • No test checks that the rewritten calls reach the Triton kernels (backends/cuda/passes/tests/test_replace_fused_sdpa.py:49-55, backends/cuda/tests/test_cuda_export.py:734-736).
    • test_runs_before_triton_replacement says the rewritten calls "must still reach the Triton SDPA kernels". It only checks the order of the pass list.
    • test_fused_sdpa says the decode call runs in the Triton decode kernel. It only compares the outputs with eager.
    • ReplaceEdgeOpWithTritonOpPass hands every SDPA call it declines to Inductor's math lowering (backends/cuda/cuda_backend.py:1189). That path matches eager within the test's tolerance. A decline is logged at INFO at most (backends/cuda/triton/replacement_pass.py:165-166, 187-189).
    • Trigger: a change to either pass that makes _sdpa_kernel_supports decline the rewritten calls. They lose the Triton kernels, and both tests still pass.
    • Fix: in test_fused_sdpa, capture the replacement pass logs during lowering, the way _capture_pass_logs does (backends/cuda/tests/test_sdpa_splitk_replacement.py:103-116). Then assert "Using split-K decode SDPA" and the replaced count.
      • Only the grouped and decode calls should be replaced. The masked call keeps a float bias. _prepare_mask_params rejects it (backends/cuda/triton/kernels/sdpa.py:999-1000), so that call stays on the math path.
    • Fix this →

Recommendation

Approve. Nothing blocks merging. Closing the test gap takes a few lines in test_fused_sdpa.

Decomposing scaled_dot_product_attention on a CUDA device turns it into
one of its fused variants: _scaled_dot_product_flash_attention without a
mask, or _scaled_dot_product_efficient_attention with one. torch.export's
default decompositions do this, and so does Torch-TensorRT for an
attention it leaves to the CUDA backend, for example one whose keys are a
constant. The CUDA backend had no kernel for these ops:

    RuntimeError: Method forward missing fallback kernels (1 total):
      - at::_ops::_scaled_dot_product_flash_attention::call

A new pass, ReplaceFusedSdpaWithSdpaPass, turns the flash, efficient and
cuDNN variants back into scaled_dot_product_attention with the same
query, key, value, bias, dropout, causal flag and scale. It sets
enable_gqa unless the query and key head counts are known to be equal,
since the fused variants group fewer key heads the same way. It leaves
out a scale of None, because the Triton SDPA kernels take a float scale.
The Triton SDPA kernel or Inductor's math decomposition then lowers the
attention, as it does any other. The pass also looks inside control flow
branches.

The pass rewrites a node only when scaled_dot_product_attention computes
the same result. Only its attention output may be used, which is the case
for inference. A causal call must have no bias, since
scaled_dot_product_attention takes no mask together with is_causal. Its
query and key lengths must also be known to be equal: flash attention
aligns a causal mask to the bottom right, the other variants and
scaled_dot_product_attention to the top left, and the Triton decode
kernel for one query ignores it. Otherwise the node stays, the pass logs
why, and the error above still names it.

test_fused_sdpa decomposes attention on a CUDA device into a causal flash
attention with fewer key heads and a custom scale, an efficient attention
with a mask and a flash attention of one query over 256 keys. It lowers
the program, checks that the first and last calls get the Triton kernels,
runs it, and compares the outputs with eager. The tests in
passes/tests/test_replace_fused_sdpa.py check on the CPU which calls the
pass rewrites and with which keyword arguments (cuDNN, causal with equal
static or dynamic lengths, fewer key heads, inside a torch.cond branch),
which it keeps (causal with lengths that differ or may differ, causal with
a bias, a used log-sum-exp), that the program stays valid, and that the
pass runs before the Triton kernel replacement. They fail without this
change.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 15:23

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 25m 36s —— View job


No blockers. The main gap is that the CPU test of the new pass only checks the keyword arguments of the call it emits. So on ROCm, and for cuDNN and torch.cond calls everywhere, no test would catch a rewrite that passes the wrong value, bias or causal flag.

PR Review: #24426

Summary

This PR adds ReplaceFusedSdpaWithSdpaPass, which turns the flash, efficient and cuDNN attention variants back into scaled_dot_product_attention, so the CUDA backend can lower attention that was decomposed on a CUDA device. I found no case where a rewritten call computes a different result. There are two findings:

  • the CPU test can't catch a wrong rewrite;
  • the causal length check keeps some calls that the rewrite would compute exactly.

Nothing was run, because Python needs approval in this job. Statements about which variant PyTorch picks for a given attention come from PyTorch's SDPA dispatch, which I couldn't check here.

Code Quality

  • The causal length check also keeps efficient and cuDNN calls that the rewrite computes exactly (backends/cuda/passes/replace_fused_sdpa.py:90-95).
    • As the comment at lines 90-92 says, only flash aligns the causal mask to the bottom right. Efficient and cuDNN align it to the top left, like scaled_dot_product_attention.
    • Suppose the lengths are known to differ and the query length isn't statically 1:
      • ReplaceEdgeOpWithTritonOpPass rejects the rewritten call (backends/cuda/triton/replacement_pass.py:181).
      • It can't pick split-K, which ignores is_causal (backends/cuda/triton/kernels/sdpa.py:1846-1848), because split-K needs a static query length of 1 (replacement_pass.py:113-122).
      • So the call goes to the math path, which also aligns the mask to the top left.
    • Trigger: F.scaled_dot_product_attention(q, k, v, is_causal=True) with a static query length of 4 and a key length of 16, decomposed on CUDA. Flash refuses causal calls with different lengths, so this becomes the efficient variant. The pass keeps it, and lowering still fails with the missing-kernel error that this PR fixes for other calls.
    • Fix:
      • For efficient and cuDNN, also rewrite causal calls whose lengths are statically known to differ, unless the query length is statically 1. The dynamic (length, keys) row still stays under that rule.
      • Add an efficient row with lengths 4 and 16 that gets rewritten.
      • Add a flash row with different lengths that stays. Once the check depends on the variant, no current row covers flash.
    • Fix this →

Testing

  • test_rewrite only checks the keyword arguments of the new call (backends/cuda/passes/tests/test_replace_fused_sdpa.py:117-118).
    • Query, key, value, bias, dropout_p and is_causal are positional arguments (replace_fused_sdpa.py:119-127). So the test still passes if the pass swaps key and value, drops the bias, or always passes is_causal=False.
    • Running the rows wouldn't expose most of these mistakes either. Every row passes the key as the value (q, k, k), and no rewritten row has a bias.
    • Where these mistakes are caught elsewhere:
      • test_fused_sdpa catches them in the outputs, but only in the CUDA job and only for flash and efficient. It has no cuDNN call and no torch.cond.
      • On ROCm, test_fused_sdpa is skipped (backends/cuda/tests/test_cuda_export.py:711-714). This file, which .ci/scripts/test-rocm-aoti.sh:154-157 runs, is then the only test of the pass.
    • Fix:
      • Use distinct query, key and value tensors.
      • Add rewritten rows with a bias for efficient and cuDNN.
      • Run each rewritten program on the CPU and compare it with F.scaled_dot_product_attention called with the intended arguments, the way test_replace_int64_floordiv.py:78-80 compares with eager.
    • Fix this →

Recommendation

Approve. Nothing blocks merging. Fix the test gap first.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Answers to the two items:

Causal calls with different lengths in the efficient and cuDNN variants. I agree that these two variants align the causal mask to the top left, like scaled_dot_product_attention. I am keeping the stricter rule on purpose. With it, every rewrite is exact, whichever kernel lowers it later. If the rule were widened, the result would stay correct only as long as the Triton replacement keeps these shapes away from the split-K decode kernel, which ignores is_causal. That choice also depends on the machine that exports, since split-K is off on ROCm. A call the pass keeps fails at export, as it did before this change, so it never gives a wrong result. A warning in the log gives the reason. If a model needs these calls, the rule can be widened then.

Positional arguments in the CPU test. The CPU test checks what the pass decides: which calls it rewrites, the scale, and enable_gqa. The query, key, value, bias and causal flag are copied by name in the same way for every variant, inside a torch.cond branch too. test_fused_sdpa checks them in the outputs, with distinct key and value tensors and a mask, on the real decomposition path. A swapped key and value, a dropped bias, or a dropped causal flag each make it fail. The pass has no device-specific code, so the CUDA job checks the same code that runs on ROCm.

@shoumikhin
shoumikhin merged commit e40e64c into pytorch:main Nov 2, 2026
274 of 275 checks passed
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@pytorchbot cherry-pick --onto cherry-pick-24420-by-pytorch_bot_bot_ -c critical

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24426

The cherry pick PR is at #24455 and it is recommended to link a critical cherry pick PR with an issue.

Details for Dev Infra team Raised by workflow job

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: backends

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants