Fix cuDNN SDPA score_mod cache collision, sm_12x arch gates, per-device plan cache - #3289
Open
YangXu1990uiuc wants to merge 1 commit into
Open
Fix cuDNN SDPA score_mod cache collision, sm_12x arch gates, per-device plan cache#3289YangXu1990uiuc wants to merge 1 commit into
YangXu1990uiuc wants to merge 1 commit into
Conversation
- jax/cpp_extensions/flex_attention.py:200 - every module-level lambda
score_mod had __qualname__ == "<lambda>", no closure and no "<locals>",
so all of them collapsed onto the key ("function", module, "<lambda>")
and silently replayed each other's compiled cuDNN graph. Key on the code
object (co_code/co_consts/co_names) and on __defaults__/__kwdefaults__ --
a default argument's value is not in the code object, and baking a scalar
in via a default is the usual way to write a soft-cap score_mod -- falling
back to the uncacheable key if the result is not hashable.
- common/fused_attn/fused_attn_f16_arbitrary_seqlen.cu:92,121,686,699,902,
1243,1253 and common/fused_attn/fused_attn.cpp:507 - the SM120 workarounds
tested sm_arch_ == 120 exactly. The cudnn-frontend SDPA validation gates on
the compute-capability major version, not on an exact SM: with
prop_major = sm_version / 10, scaled_dot_product_flash_attention.h:1391
rejects ragged Stats offsets for prop_major 8 or 12, and :1387 rejects the
deterministic algorithm on the same families. So sm_121 (GB10) took the
ragged-stats path and failed graph validation. Use
is_sm12x = (sm_arch_ >= 120 && sm_arch_ < 130).
Note two of the gates widened alongside it are deliberately conservative
rather than traced to a family-wide cuDNN check: the
cudnn_runtime_version < 91801 cutoff and the T3HD/TH3D rejection in
fused_attn.cpp are TE-side policy. If sm_121 is in fact fine with cuDNN
9.17.x or with T3HD, these can be narrowed back to sm_arch_ == 120 - I have
no GB10 hardware to confirm either way, so I took the safe direction.
- pytorch/attention/dot_product_attention/utils.py:464,714,834,838,901,1059
and context_parallel.py:1621 - same family test in the backend
eligibility matrix and in the packed softmax-LSE decision.
- common/fused_attn/utils.h:293 - FADescriptor_v1 carried no device id, so
the thread_local plan caches replayed a plan built on one device on
another device's cuDNN handle. Add device_id to the struct and to
operator<, populated from cuda::current_device() at all four
construction sites (fused_attn_f16_arbitrary_seqlen.cu:185,751 and
fused_attn_fp8.cu:121,590).
- tests/jax/test_fused_attn_score_mod.py:741 - regression test asserting
two module-level lambdas get distinct graph cache keys.
Signed-off-by: Yang Xu <yanxu@nvidia.com>
YangXu1990uiuc
requested review from
cyanguwa and
jberchtold-nvidia
as code owners
July 30, 2026 18:54
Contributor
Greptile SummaryThis PR corrects three fused-attention cache and architecture issues.
Confidence Score: 5/5The PR appears safe to merge, with the cache partitioning and SM12x producer-consumer layout changes applied consistently. All four FADescriptor construction sites populate the new device field, the descriptor ordering partitions every associated plan cache, score_mod key generation safely distinguishes changed callback topology, and the Python and C++ SM12x stats-layout decisions agree. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Attention request] --> B{Backend eligibility}
B -->|SM12x| C[Apply SM12x restrictions]
B -->|Supported| D[Build fused-attention descriptor]
D --> E[Include CUDA device ID]
E --> F{Per-thread graph cache}
F -->|Miss| G[Build cuDNN graph on current device]
F -->|Hit for same device| H[Reuse cached graph]
I[JAX score_mod callback] --> J[Derive key from code and defaults]
J --> K{Serialized graph cache}
K -->|Distinct callback| L[Build distinct score_mod graph]
K -->|Same callback| M[Reuse graph]
Reviews (1): Last reviewed commit: "Fix cuDNN SDPA score_mod cache collision..." | Re-trigger Greptile |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix cuDNN SDPA integration bugs: score_mod graph-cache collision, sm_12x arch gates, per-device plan cache
Summary
Three defects found while auditing TransformerEngine's use of the cuDNN SDPA API. The most
serious one is silent: every module-level
lambdascore_mod in a JAX module collapses onto asingle cuDNN graph-cache key, so the second one silently replays the first one's compiled graph
and produces wrong attention outputs with no error. The other two are hardware/topology bugs:
the SM120 workarounds use exact compute-capability equality and therefore orphan sm_121 (GB10 /
DGX Spark), where every THD/varlen attention layer aborts with
GRAPH_NOT_SUPPORTED; and thefused-attention plan caches are keyed without a device id, so in a single-threaded multi-GPU
loop a plan built on device 0 is executed on device 1's cuDNN handle.
1. Every module-level lambda
score_modshares one cuDNN graph cache keytransformer_engine/jax/cpp_extensions/flex_attention.py:200keyed a plain function score_mod by("function", __module__, __qualname__).A module-level
lambdahas__qualname__ == "<lambda>"exactly, has no closure(
__closure__ is None), and contains no"<locals>"— so it satisfies all three conditions ofthat branch, and every module-level lambda in a module produces the identical key. The key
flows into
_FusedAttnScoreModConfig.__hash__/__eq__and then into_graph_cache_key()(
flex_attention.py:591), which is what selects the serialized, already-built cuDNN frontendgraph. Two different score_mods therefore hit the same cache entry.
Failure scenario: a module defines a soft-cap score_mod and a relative-bias score_mod as
module-level lambdas — which is exactly how the cuDNN frontend's score_mod API is idiomatically
used. The first attention layer compiles and caches its graph. The second layer looks up the
cache, finds the first layer's key, and executes the first score_mod's fused kernel against the
second layer's tensors. There is no exception, no warning, and no shape mismatch (the tensor
signature is part of the key, but the score_mod body is not) — just wrong numerics, in fprop and
in bprop.
Fix: keep the key cacheable but derive it from the code object
(
co_code,co_consts,co_names) in addition to module + qualname. This was preferred overreturning
_UncacheableScoreModKey()for lambdas because it keeps the cache useful for thecommon case (a lambda score_mod reused across many layers still hits) while giving distinct
bodies distinct keys. If the assembled key is ever unhashable, it degrades to the safe
uncacheable path rather than guessing.
Caveat, unchanged by this PR: the key still does not capture the values of globals a score_mod
closes over via
co_names. That was already true for named module-level functions, and mutatinga global between two attention calls with the same score_mod remains outside the key's scope.
2. SM12x guards use exact equality and orphan sm_121 (GB10 / DGX Spark)
cuDNN classifies support by compute-capability family, not exact compute capability, so
sm_121 behaves like sm_120 for every one of these workarounds. TE tested
sm_arch_ == 120/device_compute_capability == (12, 0), which is false on sm_121.Concrete failure on sm_121, THD/varlen attention:
fused_attn_f16_arbitrary_seqlen.cu:92computesuse_ragged_stats = is_ragged_q && cudnn >= 9.6 && sm_arch_ != 120→true, so the Stats tensorgets a ragged offset; and the guard at
fused_attn_f16_arbitrary_seqlen.cu:121fails to switchto BHSD-like dims/strides with
max_seqlen. cuDNN then sees the layout the in-tree comment atfused_attn_f16_arbitrary_seqlen.cu:118-120already describes as rejected (stride[0] > dim[1]*dim[2]*dim[3]treated as interleaved) and the graph fails validation withGRAPH_NOT_SUPPORTED. There is no fallback path at that point — the layer aborts. Theaux-tensor shapes at
fused_attn_f16_arbitrary_seqlen.cu:1243,1253disagree with the built graphfor the same reason.
Fix: a single
is_sm12x = (sm_arch_ >= 120 && sm_arch_ < 130)per function, used at all sevensites in
fused_attn_f16_arbitrary_seqlen.cu(lines 92, 121, 686/699, 902, 1243, 1253), and thesame family test in the backend-selection gate at
fused_attn.cpp:507(cuDNN version floor,deterministic-training rejection, T3HD/TH3D rejection).
Mirrored on the PyTorch side, where the same limitations are encoded as an eligibility matrix:
pytorch/attention/dot_product_attention/utils.pynow computesis_sm12xonce next todevice_compute_capability(line 464) and uses it at the FP8 gate (line 714), the KV-cache gates(lines 834-838), the MLA-backward gate (line 901) and the THD gate (line 1059); and
context_parallel.py:1621uses the family test to decidesoftmax_lse_in_packed_format, whichmust agree with
use_ragged_statsin the C++ layer or the CP softmax-LSE unpacking reads thewrong layout.
Only the SM120-family workarounds were converted. The FlashAttention-2 head-dim support list
at
utils.py:920also mentions(12, 0), but it is a support allowlist rather than a cuDNNworkaround and excluding sm_121 there is the conservative behaviour, so it is left alone.
3. Fused-attention plan cache is not keyed by device
fused_attn_f16_arbitrary_seqlen.cu:212(and the three sibling caches) arestatic thread_local std::map<FADescriptor_v1, graph_and_tensors>, butFADescriptor_v1(
common/fused_attn/utils.h:252) carries no device id and no SM arch, while the cuDNN handle isper-device.
Failure scenario: a single-threaded process loops over GPUs —
torch.cuda.set_device(0), runattention;
torch.cuda.set_device(1), run the same shape/dtype/layout attention.thread_localdoes not help because it is the same thread. The descriptor is identical, so the cache returns
the execution plan built against device 0's handle, and it is executed on device 1's handle.
This is undefined behaviour even on a homogeneous node; on a heterogeneous node (e.g. an SM90 and
an SM100 in the same box) the cached plan is for the wrong architecture entirely.
Fix: add
int device_idtoFADescriptor_v1, include it inoperator</std::tie, and populateit from
cuda::current_device()at all four construction sites(
fused_attn_f16_arbitrary_seqlen.cu:185and:751,fused_attn_fp8.cu:121and:590).fused_attn_fp8.cugains the../util/cuda_runtime.hinclude it needs for that. Keying on thedevice id rather than the SM arch is the stricter choice: two devices of the same architecture
still get separate plans, which is what the per-device handle requires.
Evidence
Standalone reproduction of the key function for fix 1, no GPU required:
__qualname__<lambda>('function', '__main__', '<lambda>')<lambda>('function', '__main__', '<lambda>')relu_scorerelu_score('function', '__main__', 'relu_score')make_closure(30)make_closure.<locals>.<lambda>The two lambdas compute different functions (A(10)=20.0, B(10)=50.0) yet share one key. Named
functions key correctly and closures correctly fall through to uncacheable — the hole is exactly
the module-level lambda, which is how soft-cap / relative-bias score_mods are idiomatically
written.
No GPU measurements are claimed for fixes 2 and 3.
Testing
Added
tests/jax/test_fused_attn_score_mod.py::test_fused_attn_score_mod_config_separates_module_level_lambdas— a pure-Python test (no GPU, no cuDNN) that asserts two module-level lambdas with identical
__qualname__get distinct cache keys and distinct_graph_cache_key()results, that the key isstable across repeated calls, and that named module-level functions stay cacheable. It fails on
main(both keys are('function', <module>, '<lambda>')) and passes with this change.Could not run in the audit sandbox: TransformerEngine is not built/installed there, so
import transformer_engine.jaxfails and pytest cannot collect the file. The key-derivationlogic was verified standalone against the extracted function (table above). Fixes 2 and 3 are
compile-only changes reviewed by inspection; they were not built or run — no sm_121 hardware and
no multi-GPU build were available.
clang-format -style=fileandblack --line-length=100areclean on the changed files.
Found by an integration audit of cuDNN SDPA consumers by the NVIDIA cuDNN team.