Repository navigation
Fix torch.compile graph break in torch backend's dot_product_attention - #23327
Conversation
There was a problem hiding this comment.
Code Review
This pull request prevents graph breaks during torch.compile in the PyTorch backend's dot_product_attention by bypassing the flash-attention support probe when compiling is active, deferring the kernel choice to PyTorch's native scaled_dot_product_attention. It also adds comprehensive compilation tests. The feedback suggests making the compilation check more robust by verifying that torch has the compiler attribute before accessing it, which avoids potential AttributeError in older PyTorch environments.
Codecov Reportโ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #23327 +/- ##
==========================================
+ Coverage 70.91% 71.16% +0.24%
==========================================
Files 496 496
Lines 84908 85536 +628
Branches 13893 14086 +193
==========================================
+ Hits 60215 60870 +655
+ Misses 21664 21638 -26
+ Partials 3029 3028 -1
Flags with carried forward coverage won't be shown. Click here to find out more. โ View full report in Codecov by Harness. ๐ New features to boost your workflow:
|
hertschuh
left a comment
There was a problem hiding this comment.
Thanks for looking into this!
| if ( | ||
| hasattr(torch.compiler, "is_compiling") | ||
| and torch.compiler.is_compiling() | ||
| ): |
There was a problem hiding this comment.
Instead of replicating the code to scaled_dot_product_attention, I would simply move this test at the beginning of _can_use_flash_attention.
This would be more true to the spirit of what the flash_attention argument means. If you compile, you cannot use flash attention, and the user should be aware of that.
There was a problem hiding this comment.
Moved it, dot_product_attention is now identical to master, and the change is just a guard at the top of _can_use_flash_attention.
Two corrections from measuring it on an RTX 4050 (sm_89), though:
"If you compile, you cannot use flash attention" isn't the case. With the probe skipped and fp16 inputs, scaled_dot_product_attention still dispatches to pytorch_flash::flash_fwd_kernel (torch.profiler, probe executions 0). Only the pybind11 SDPAParams construction is untraceable, not the kernel, so returning False here costs no flash attention.
Making the user aware via raise_error doesn't work either; I tried it first. Dynamo discards an exception raised while tracing and re-runs the frame eagerly, where is_compiling() is False. So the probe runs anyway, the message never surfaces, and op_count drops to 1 from master's 16. Worse than the break it was removing.
So the guard only covers auto-detection (not raise_error). An explicit flash_attention=True still behaves exactly as on master and reports "Flash attention is not supported with the provided inputs".
There was a problem hiding this comment.
"If you compile, you cannot use flash attention" isn't the case. With the probe skipped and fp16 inputs,
scaled_dot_product_attentionstill dispatches topytorch_flash::flash_fwd_kernel (torch.profiler, probe executions 0). Only the pybind11SDPAParamsconstruction is untraceable, not the kernel, so returning False here costs no flash attention.
So what you're saying is that scaled_dot_product_attention is always and automatically "upgraded" to flash attention when you compile? So we cannot really apply flash_attention=True/False, it's always going to be True?
There was a problem hiding this comment.
Short answer: no. flash_attention=True still works exactly as before, and
flash_attention=False never turned flash attention off โ that is already true
on master, in eager. This PR changes neither of it.
What the flag does
Trueโ pins the flash kernel and raises if the input cannot use it. This PR
leaves it alone (the guard isnot raise_error), so it still raises under
torch.compile.Falseโ does not forbid flash. It skips the pin and calls
scaled_dot_product_attentionwith nosdpa_kernel(...)context, so PyTorch's
dispatcher chooses โ and for fp16/bf16 it chooses flash.Noneโ probes, then does one of the above. Under compile this PR skips the
probe, landing on exactly the same code path asFalse.
Measured dispatch, classified by the CUDA kernel that actually runs:
| input | None |
True |
False |
|---|---|---|---|
| fp16/bf16, no mask | flash | flash | flash |
| fp16/bf16, masked | mem-efficient | raises | mem-efficient |
| fp32 | mem-efficient | raises | mem-efficient |
Same in eager and compiled. I ran all 72 combinations โ 3 dtypes ร mask/no-mask ร
head_dim 64/128 ร 3 flag values ร eager/compiled โ on master and on this branch:
both pick the same kernel in every one, 0 differences.
Two things follow from the bold cell:
- It is master, in eager:
flash_attention=Falseruns
pytorch_flash::flash_fwd_kernel. So "Falsedoes not disable flash" is
pre-existing behavior, not something this PR introduces. - fp32 never uses flash at all (flash requires fp16/bf16), so on the default
float32 path this change alters the selected kernel in zero cases.
If False is meant to genuinely forbid flash, that is a real gap โ but a
separate one on master, and I am happy to open a PR for it.
Wall clock. The PR body omits this because my previous machine could not hold
a stable measurement. This one can. Six-layer transformer forward, float32,
median of 8 alternating A/B pairs against the merge-base:
| eager | compiled | |
|---|---|---|
| without this change | 5.98 ms | 8.05 ms |
| with this change | 5.98 ms | 0.98 ms |
- compiled: 8.2x faster, 8/8 pairs agreeing, per-pair spread 1.03x
- eager: unchanged (1.005x, inside a 2.5% noise floor); the guard costs 0.1 ยตs
per call - without the change,
torch.compilemakes this model slower than eager
`_can_use_flash_attention` constructs a pybind11 `SDPAParams` object to probe kernel support, and dynamo cannot trace pybind11 constructors. The probe runs whenever `flash_attention` is `None`, which is the default, so every attention call splits the graph. A six-layer transformer produces 13 graph breaks, which is enough to make compiled inference slower than eager. Skip the probe while tracing and let `scaled_dot_product_attention` dispatch to the fastest kernel its inputs allow. Eager is unaffected: `torch.compiler.is_compiling()` is `False` outside `torch.compile`, so the probe and the forced flash-attention kernel context still run as before.
The root-cause explanation already lives on the nn.py fix; restating it here just duplicated it. Dropped the mask-shape comment too, to match the zero-comment convention of the other torch backend test files (core_test.py, distribution_lib_test.py).
Moves the check into _can_use_flash_attention instead of duplicating the scaled_dot_product_attention call, so dot_product_attention goes back to being identical to master and the compiling case resolves through the flash_attention argument it belongs to. _can_use_flash_attention does not raise for raise_error=True while compiling. Dynamo discards an exception raised during tracing and re-runs the frame eagerly, where is_compiling() is False, so raising would run the untraceable probe anyway and leave the frame uncompiled: measured 1 op captured against 16 on master for flash_attention=True. Folds the graph-break test into test_compiled_matches_eager, now parameterized over the six input combinations, and passes the inputs as arguments so they are traced rather than inlined as constants.
Verified on CUDA that scaled_dot_product_attention still selects the flash kernel once the probe is skipped, so nothing is lost by reporting flash attention as unavailable while compiling. Keep the probe for an explicit flash_attention=True. Skipping it there replaced the RuntimeError naming the unsupported input with a dynamo fake tensor failure reporting only "No available kernel".
9aafe4f to
b150544
Compare
Summary
torch.compilecannot trace the flash attention probe in the torch backend'sdot_product_attention, so it breaks the graph at every attention call. On asix-layer transformer forward pass this produces 19 graph breaks and 20 separate
graphs.
MultiHeadAttentionroutes throughops.dot_product_attentionwhen dropout is0, attention scores are not returned, and inputs are 4D โ the default path for
most models.
Root cause
dot_product_attentioncalls_can_use_flash_attentionwhenflash_attentionis
None(the default), and again withraise_error=Truewhen it is explicitlyTrue. That helper constructs a pybind11SDPAParamsobject to probe kernelsupport:
Dynamo cannot trace a pybind11 constructor, so tracing stops there and resumes
afterwards โ once per attention call.
Change
Report flash attention as unavailable while tracing, so the probe is never
reached and the call takes its ordinary non-flash branch.
scaled_dot_product_attentionthen selects the kernel through its own dispatcher, which is what it does on the
non-flash branch already.
dot_product_attentionitself is unchanged โ the wholechange is a 12-line early return at the top of
_can_use_flash_attention.This applies to auto-detection only. An explicit
flash_attention=Truestillruns the probe, still breaks the graph, and still raises
Flash attention is not supported with the provided inputsโ deliberately. Makingthe guard raise instead does not work: dynamo discards an exception raised while
tracing and re-runs the frame eagerly, where
is_compiling()isFalse, so themessage never reaches the user and the frame is de-optimized rather than split.
Eager is unchanged by construction:
torch.compiler.is_compiling()isFalseoutside
torch.compile, so the probe and the pinned flash-attention kernelcontext still run exactly as before.
Results
torch._dynamo.explain, float32 unless noted. These counts are deterministic,and are identical on an RTX 4050 (sm_89, torch 2.13.0+cu130) and an RTX PRO 6000
Blackwell (sm_120, torch 2.13.0+cu132):
flash_attentionNone(default)None(default)TrueTrueOp count rises because a fragmented graph only captures a fraction of the model;
with one graph, dynamo captures all of it.
The
Nonerows are the six-layer model below. TheTruerows are a singleops.dot_product_attentioncall in float16, since float32 withflash_attention=Trueraises on both master and this branch.harness
Wall clock for the same six-layer forward, float32, on the RTX PRO 6000
Blackwell. Median of 8 A/B pairs with the order alternated per pair, each pair
30 repeats of 20 iterations timed with CUDA events. Running the base against an
identical checkout of itself measures run-to-run noise at 1.7% eager and 7.6%
compiled, putting the significance threshold at 2.5% and 11.4% respectively:
Compiled is 8.2x faster, all 8 pairs agreeing in direction, 1.03x spread across
them. Eager lands at 1.005x, inside its threshold. Before this change,
torch.compilemakes this model slower than its own eager forward.Behavior
Kernel dispatch is unchanged. Measured across fp16 / bf16 / fp32, with and without
a mask, and at head_dim 64, 128 and 320, the kernel selected by
scaled_dot_product_attentionis identical to master in every case, in both eagerand compiled mode. On sm_120 that spans 72 configurations, with no difference in
any of them.
Under compile the auto-detect path no longer pins the kernel via
sdpa_kernel([FLASH_ATTENTION]), leaving the selection to the dispatcher. Wherethe dispatcher lands on the kernel the pin would have chosen, output is
bit-identical: on sm_120,
max|compiled โ eager|is exactly 0.0 across 18configurations spanning fp32 / fp16 / bf16, head_dim 64 and 128, plain, masked
and causal. Where it lands elsewhere the two can differ slightly; on sm_89 fp16
shows about 1.2e-04 and fp32 about 6e-08.
Tests
keras/src/backend/torch/nn_test.pyadds one parameterized test that compilesops.dot_product_attentionwithfullgraph=True, so a graph break becomes anerror, and compares against eager across six argument shapes: plain, causal,
masked, masked-and-causal, bias, and custom scale.
All six fail against master and pass with this change, both on a CPU-only torch
build as CI runs it and on CUDA (sm_120).
Scope, stated honestly: the cases are float32 and
flash_attention=None, so theycover the auto-detect path only. Grouped-query inputs, explicit
flash_attentionvalues, and the hardware-dependent flash-vs-dispatcher divergence above are not
covered, and CI runs CPU-only so the kernel-dispatch matrix is not covered there
either.