From f6ed27c446829e0874281ec7744359c082188d07 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Mon, 27 Jul 2026 11:07:09 +0200 Subject: [PATCH 01/29] [PyTorch][torch.compile] Support for DotProductAttention Make DotProductAttention itself traceable, so that torch.compile captures the whole module -- input unpacking, qkv layout, backend selection and the backend -- on the FlashAttention and UnfusedDotProductAttention backends. FusedAttention stays an eager island, as before. DotProductAttention.forward no longer carries @no_torch_dynamo. What that uncovered, and the fixes: - init_fp8_metadata unconditionally called get_fp8_recipe(), which builds a default Recipe when no autocast is active -- not traceable. It now returns early when FP8 is off everywhere, which is also what the rest of the function did in that case. - get_qkv_layout detects packed q/k/v from data pointers and storage offsets, neither of which dynamo can trace (and neither is meaningful on fake tensors). While tracing, the layout is built from the format instead; packed inputs are declared explicitly via qkv_layer/kv_layer, and non-contiguous q/k/v are made contiguous so the layout stays truthful. - get_full_cu_seqlens keyed its cache on torch.is_inference_mode_enabled(), a fundamental graph break; while tracing it builds the tensor directly. - get_padding_mask read sequence lengths on the host (a device sync, and data-dependent under torch.compile). It is now vectorized. - The fused sub-backend enum member does not survive a graph break: dynamo reconstructs the assume_constant_result value by re-emitting the call that produced it, so the resumed frame received the function itself instead of the enum -- which then reached fused_attn_fwd. get_attention_backend now keeps a plain int while tracing and casts back to FusedAttnBackend in eager mode only. - Backend selection logging is skipped while tracing (logging.Logger methods graph-break, and int() of the sub-backend enum is untraceable too). Tests: test_dpa_torch_compile covers 7 configurations (bshd/sbhd/thd, causal/no_mask/sliding window, GQA, cross attention, packed qkv_layer) against both backends with fullgraph=True, comparing forward and backward against eager; plus a CUDA-graphs (reduce-overhead) test and a test that the FusedAttention path stays correct around its graph break. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 243 ++++++++++++++++++ .../dot_product_attention.py | 47 +++- .../attention/dot_product_attention/utils.py | 58 ++++- .../pytorch/cpp_extensions/fused_attn.py | 11 +- 4 files changed, 330 insertions(+), 29 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index d720ca7a4a..ed2ad90c76 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -550,6 +550,249 @@ def fn(q, k, v, extra): assert v.grad is not None +# --------------------------------------------------------------------------- +# DotProductAttention under torch.compile +# --------------------------------------------------------------------------- + + +# Each config is a model configuration that both the FlashAttention and the +# UnfusedDotProductAttention backend support, so that every one of them runs +# against both backends. +_DPA_COMPILE_CONFIGS = { + "self_bshd_causal": dict(qkv_format="bshd", attn_mask_type="causal"), + "self_sbhd_no_mask": dict(qkv_format="sbhd", attn_mask_type="no_mask"), + "self_bshd_swa": dict(qkv_format="bshd", attn_mask_type="causal", window_size=(16, 0)), + "gqa_bshd_causal": dict( + qkv_format="bshd", attn_mask_type="causal", num_heads=8, num_gqa_groups=2 + ), + "cross_bshd_no_mask": dict( + qkv_format="bshd", attn_mask_type="no_mask", attention_type="cross", max_seqlen_kv=256 + ), + "self_thd_padding_causal": dict(qkv_format="thd", attn_mask_type="padding_causal"), + "packed_qkv_bs3hd": dict(qkv_format="bshd", attn_mask_type="causal", packed="bs3hd"), +} + + +def _dpa_config(**overrides): + cfg = dict( + batch_size=2, + num_heads=4, + num_gqa_groups=None, + head_dim=64, + max_seqlen_q=128, + max_seqlen_kv=128, + qkv_format="bshd", + attn_mask_type="causal", + attention_type="self", + window_size=None, + packed=None, + ) + cfg.update(overrides) + if cfg["num_gqa_groups"] is None: + cfg["num_gqa_groups"] = cfg["num_heads"] + return cfg + + +def _make_dpa(cfg, dtype: torch.dtype) -> te.DotProductAttention: + return te.DotProductAttention( + num_attention_heads=cfg["num_heads"], + kv_channels=cfg["head_dim"], + num_gqa_groups=cfg["num_gqa_groups"], + attention_dropout=0.0, + qkv_format=cfg["qkv_format"], + attn_mask_type=cfg["attn_mask_type"], + window_size=cfg["window_size"], + attention_type=cfg["attention_type"], + ).to(dtype=dtype, device="cuda") + + +def _make_dpa_inputs(cfg, dtype: torch.dtype): + """Build the (args, kwargs) that `DotProductAttention.forward` is called + with, plus the list of tensors whose gradients the test compares.""" + b = cfg["batch_size"] + s_q, s_kv = cfg["max_seqlen_q"], cfg["max_seqlen_kv"] + h, g, d = cfg["num_heads"], cfg["num_gqa_groups"], cfg["head_dim"] + fmt = cfg["qkv_format"] + + def _shape(s, heads): + return { + "bshd": (b, s, heads, d), + "sbhd": (s, b, heads, d), + "thd": (b * s, heads, d), + }[fmt] + + def _randn(shape): + return torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) + + kwargs = {} + if cfg["packed"] is not None: + # Declarative packed QKV: q/k/v are derived from one buffer by DPA + # itself, and the layout comes from the declaration -- the only packed + # layout that torch.compile supports (pointer-based detection of packed + # q/k/v is untraceable). + assert cfg["packed"] == "bs3hd" and fmt == "bshd" and h == g + qkv = _randn((b, s_q, 3, h, d)) + kwargs["qkv_layer"] = qkv + kwargs["qkv_interleave_dim"] = -3 + grad_tensors = [qkv] + args = () + else: + q, k, v = _randn(_shape(s_q, h)), _randn(_shape(s_kv, g)), _randn(_shape(s_kv, g)) + grad_tensors = [q, k, v] + args = (q, k, v) + + if fmt == "thd": + # All sequences have the maximum length, i.e. no padding. max_seqlen_* + # is passed explicitly: deriving it from cu_seqlens costs a `.item()` + # (a device sync, and an unbacked SymInt under torch.compile). + kwargs["cu_seqlens_q"] = torch.arange( + 0, (b + 1) * s_q, step=s_q, dtype=torch.int32, device="cuda" + ) + kwargs["cu_seqlens_kv"] = torch.arange( + 0, (b + 1) * s_kv, step=s_kv, dtype=torch.int32, device="cuda" + ) + kwargs["max_seqlen_q"] = s_q + kwargs["max_seqlen_kv"] = s_kv + + return args, kwargs, grad_tensors + + +def _force_dpa_backend(monkeypatch, backend: str) -> None: + """Restrict DotProductAttention to a single backend.""" + from transformer_engine.pytorch.attention.dot_product_attention.dot_product_attention import ( + _attention_backends, + ) + from transformer_engine.pytorch.attention.dot_product_attention.utils import ( + FlashAttentionUtils, + ) + + if backend == "flash" and not FlashAttentionUtils.is_installed: + pytest.skip("flash-attn is not installed") + + for name, var in ( + ("flash", "NVTE_FLASH_ATTN"), + ("fused", "NVTE_FUSED_ATTN"), + ("unfused", "NVTE_UNFUSED_ATTN"), + ): + monkeypatch.setenv(var, "1" if name == backend else "0") + # Backend selection is cached on the attention params only, so the env vars + # above are not enough to invalidate it. + _attention_backends["backend_selection_requires_update"] = True + + +def _assert_dpa_backend(backend: str) -> None: + from transformer_engine.pytorch.attention.dot_product_attention.dot_product_attention import ( + _attention_backends, + ) + + assert _attention_backends[f"use_{backend}_attention"], ( + f"expected the {backend} backend to run, selected:" + f" flash={_attention_backends['use_flash_attention']}," + f" fused={_attention_backends['use_fused_attention']}," + f" unfused={_attention_backends['use_unfused_attention']}" + ) + + +@pytest.mark.parametrize("backend", ["flash", "unfused"]) +@pytest.mark.parametrize("config", _DPA_COMPILE_CONFIGS.keys()) +def test_dpa_torch_compile(monkeypatch, backend, config): + """`DotProductAttention` must trace under `torch.compile(fullgraph=True)` + on the FlashAttention and UnfusedDotProductAttention backends, and match + eager in forward and backward. + + `fullgraph=True` makes the test fail on any graph break, i.e. it covers the + whole module: input unpacking, qkv layout, backend selection and the + backend itself. The FusedAttention backend is not covered -- it is an eager + island (`@no_torch_dynamo` on `FusedAttention.forward`).""" + dtype = torch.bfloat16 + cfg = _dpa_config(**_DPA_COMPILE_CONFIGS[config]) + _force_dpa_backend(monkeypatch, backend) + + module = _make_dpa(cfg, dtype) + args, kwargs, grads = _make_dpa_inputs(cfg, dtype) + + ref = module(*args, **kwargs) + ref.sum().backward() + _assert_dpa_backend(backend) + ref_grads = [t.grad.clone() for t in grads] + for t in grads: + t.grad = None + + torch._dynamo.reset() + # Force backend selection to be re-run (and traced) inside the compiled + # region instead of being served from the cache the eager call populated. + _force_dpa_backend(monkeypatch, backend) + compiled = torch.compile(module, fullgraph=True) + + out = compiled(*args, **kwargs) + out.sum().backward() + torch.cuda.synchronize() + + _assert_dpa_backend(backend) + torch.testing.assert_close(out, ref) + for tensor, ref_grad in zip(grads, ref_grads): + assert tensor.grad is not None + torch.testing.assert_close(tensor.grad, ref_grad) + + +@pytest.mark.parametrize("backend", ["flash", "unfused"]) +def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): + """`mode="reduce-overhead"`: forward and backward of DotProductAttention + are captured into CUDA graphs and replayed on subsequent iterations.""" + dtype = torch.bfloat16 + cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + _force_dpa_backend(monkeypatch, backend) + + module = _make_dpa(cfg, dtype) + + torch._dynamo.reset() + compiled = torch.compile(module, fullgraph=True, mode="reduce-overhead") + + for _ in range(3): + args, kwargs, grads = _make_dpa_inputs(cfg, dtype) + out = compiled(*args, **kwargs) + out.sum().backward() + torch.cuda.synchronize() + assert torch.isfinite(out).all() + for tensor in grads: + assert tensor.grad is not None + _assert_dpa_backend(backend) + + +def test_dpa_torch_compile_fused_backend(monkeypatch): + """The FusedAttention backend is an eager island, so it graph-breaks rather + than compiles; DotProductAttention must still be correct around it.""" + dtype = torch.bfloat16 + cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + _force_dpa_backend(monkeypatch, "fused") + + module = _make_dpa(cfg, dtype) + args, kwargs, grads = _make_dpa_inputs(cfg, dtype) + + ref = module(*args, **kwargs) + from transformer_engine.pytorch.attention.dot_product_attention.dot_product_attention import ( + _attention_backends, + ) + + if not _attention_backends["use_fused_attention"]: + pytest.skip("FusedAttention does not support this config on this device") + ref.sum().backward() + ref_grads = [t.grad.clone() for t in grads] + for t in grads: + t.grad = None + + torch._dynamo.reset() + _force_dpa_backend(monkeypatch, "fused") + out = torch.compile(module)(*args, **kwargs) + out.sum().backward() + torch.cuda.synchronize() + + _assert_dpa_backend("fused") + torch.testing.assert_close(out, ref) + for tensor, ref_grad in zip(grads, ref_grads): + torch.testing.assert_close(tensor.grad, ref_grad) + + # --------------------------------------------------------------------------- # get_attention_backend under torch.compile # --------------------------------------------------------------------------- diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 4cc4cab1b8..0b21adb15c 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -42,7 +42,6 @@ CudaRNGStatesTracker, graph_safe_rng_available, ) -from transformer_engine.pytorch.jit import no_torch_dynamo from transformer_engine.pytorch.graph import is_graph_capturing from transformer_engine.pytorch.attention.inference import InferenceParams @@ -715,6 +714,22 @@ def init_fp8_metadata(self, num_gemms: int = 1) -> None: """ _original_recipe = self.fp8_meta.get("recipe", None) + fp8 = FP8GlobalStateManager.is_fp8_enabled() + fp8_calibration = FP8GlobalStateManager.is_fp8_calibration() + fp8_parameters = FP8GlobalStateManager.with_fp8_parameters() + if not (fp8 or fp8_calibration or fp8_parameters): + # Nothing below has an effect when FP8 is off everywhere -- the recipe + # juggling ends in the "turn off and return" branch. Bailing out here + # also keeps DotProductAttention traceable by torch.compile: with no + # autocast active, get_fp8_recipe() below builds a default Recipe, + # whose construction is not traceable. + self.fast_setattr("fp8_parameters", fp8_parameters) + self.fast_setattr("fp8", fp8) + self.fast_setattr("fp8_calibration", fp8_calibration) + self.fp8_meta["fp8_checkpoint"] = False + self.fast_setattr("fp8_initialized", False) + return + # global recipe set in autocast() fp8_recipe = FP8GlobalStateManager.get_fp8_recipe() if fp8_recipe.custom(): @@ -1090,7 +1105,6 @@ def get_quantizer_roles( ] return base[:num_quantizers] - @no_torch_dynamo(recursive=False) def forward( self, query_layer: Optional[torch.Tensor] = None, @@ -1820,18 +1834,23 @@ def forward( _attention_backends["fused_attention_backend"] = fused_attention_backend _attention_backends["use_unfused_attention"] = use_unfused_attention _attention_backends["backend_selection_requires_update"] = False - if use_flash_attention: - self.logger.info( - "Running with FlashAttention backend (version %s)", - flash_attention_backend, - ) - elif use_fused_attention: - self.logger.info( - "Running with FusedAttention backend (sub-backend %s)", - int(fused_attention_backend), - ) - elif use_unfused_attention: - self.logger.info("Running with UnfusedDotProductAttention backend") + # Backend selection is only logged in eager mode: dynamo + # graph-breaks on logging.Logger methods, and even the + # arguments below are untraceable (int() of the sub-backend + # enum). Same restriction as in get_attention_backend. + if not torch.compiler.is_compiling(): + if use_flash_attention: + self.logger.info( + "Running with FlashAttention backend (version %s)", + flash_attention_backend, + ) + elif use_fused_attention: + self.logger.info( + "Running with FusedAttention backend (sub-backend %s)", + fused_attention_backend, + ) + elif use_unfused_attention: + self.logger.info("Running with UnfusedDotProductAttention backend") else: use_flash_attention = _attention_backends["use_flash_attention"] flash_attention_backend = _attention_backends["flash_attention_backend"] diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 8a4c6ce286..4a8b7beb14 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -361,11 +361,17 @@ def _get_fused_attn_backend( *args, ): """Constant-foldable tex.get_fused_attn_backend: the result depends only on - the attention config, and the python-side enum keeps it traceable by - torch.compile (see the FusedAttnBackend docstring). Layout/bias/mask/softmax - are taken as their string keys and resolved to the pybind enums here, so - that every argument is a python literal or a python enum.""" - return FusedAttnBackend.cast( + the attention config. Layout/bias/mask/softmax are taken as their string + keys and resolved to the pybind enums here, so that every argument is a + python literal or a python enum. + + Returns a plain int rather than a FusedAttnBackend member: dynamo + reconstructs the result of an assume_constant_result call by re-emitting the + call, which is only valid inside the frame that made it. An int survives a + graph break because it is baked into the graph as a literal, while an enum + member comes out of the reconstruction corrupted (see the cast at the call + site, which restores the enum).""" + return int( tex.get_fused_attn_backend( is_training, q_type, @@ -397,6 +403,9 @@ def get_attention_backend( Whether the `FusedAttention` backend has been selected. fused_attention_backend : FusedAttnBackend If `use_fused_attention = True`, one of `FusedAttention` three sub-backends, else `None`. + Under `torch.compile` this is the sub-backend's plain integer value instead of the enum + member (see the cast below); `FusedAttnBackend.cast` accepts both, as does every consumer + in `transformer_engine.pytorch`. use_unfused_attention : bool Whether the `UnfusedDotProductAttention` backend has been selected. available_backends : List[bool] @@ -1455,11 +1464,13 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt cuda_graph, deterministic, ) - if fused_attention_backend == FusedAttnBackend["No_Backend"]: + if fused_attention_backend == FusedAttnBackend.No_Backend.value: logger.debug("Disabling FusedAttention as no backend supports the provided input") use_fused_attention = False fused_attention_backend = None - elif has_score_mod and fused_attention_backend != FusedAttnBackend["F16_arbitrary_seqlen"]: + elif ( + has_score_mod and fused_attention_backend != FusedAttnBackend.F16_arbitrary_seqlen.value + ): logger.debug( "Disabling FusedAttention for score_mod because sub-backend %s is not " "F16/BF16 arbitrary-seqlen", @@ -1509,7 +1520,7 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt use_fused_attention = False fused_attention_backend = None if ( - fused_attention_backend == FusedAttnBackend["FP8"] + fused_attention_backend == FusedAttnBackend.FP8.value and is_training and (device_compute_capability < (9, 0) or cudnn_version < (9, 19, 0)) ): @@ -1520,7 +1531,7 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt use_fused_attention = False fused_attention_backend = None if ( - fused_attention_backend == FusedAttnBackend["F16_arbitrary_seqlen"] + fused_attention_backend == FusedAttnBackend.F16_arbitrary_seqlen.value and is_training and ( device_compute_capability < (9, 0) @@ -1589,6 +1600,13 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt use_flash_attention_4 = False use_flash_attention = use_flash_attention_2 or use_flash_attention_3 or use_flash_attention_4 available_backends = [use_flash_attention, use_fused_attention, use_unfused_attention] + if fused_attention_backend is not None and not torch.compiler.is_compiling(): + # The fused sub-backend is kept as a plain int while tracing: it has to + # survive a graph break (DotProductAttention hands it to FusedAttention, + # which is an eager island) and an enum member does not -- dynamo + # reconstructs the constant-folded member into the resumed frame as the + # function that produced it. Eager callers get the enum, as before. + fused_attention_backend = FusedAttnBackend.cast(fused_attention_backend) if use_flash_attention_2: flash_attention_backend = FlashAttentionUtils.version if use_flash_attention_3: @@ -2025,7 +2043,10 @@ def _get_cu_seqlens(batch_size, max_seqlen, device): device=device, ) - if is_in_onnx_export_mode(): + if is_in_onnx_export_mode() or torch.compiler.is_compiling(): + # torch.is_inference_mode_enabled() (the cache key below) is a fundamental + # graph break under torch.compile; building the tensor is a single arange + # that the compiler captures into the graph anyway. return _get_cu_seqlens(batch_size, max_seqlen, device) is_inference = torch.is_inference_mode_enabled() @@ -2538,7 +2559,22 @@ def run_iteratively(q, k, v): return qkv_layout - if not is_in_onnx_export_mode(): + if torch.compiler.is_compiling(): + # The detection in run_iteratively is untraceable by dynamo -- it reads + # UntypedStorage.data_ptr and Tensor.storage_offset -- and pointers are + # meaningless on the fake tensors used while tracing anyway. Under + # torch.compile, packed q/k/v must therefore be declared explicitly, via + # DotProductAttention's qkv_layer/kv_layer arguments (which bypass this + # function entirely); q/k/v passed separately are treated as three + # independent tensors. Views into a packed buffer are non-contiguous, so + # making the inputs contiguous keeps the returned layout truthful for + # every backend. + q, k, v = [x if x.is_contiguous() else x.contiguous() for x in [q, k, v]] + if is_same_q_kv_format: + qkv_layout = "_".join([qkv_format] * 3) + else: + qkv_layout = q_format + "_" + kv_format + "_" + kv_format + elif not is_in_onnx_export_mode(): qkv_layout = run_iteratively(q, k, v) else: qkv_layout = "not_supported" diff --git a/transformer_engine/pytorch/cpp_extensions/fused_attn.py b/transformer_engine/pytorch/cpp_extensions/fused_attn.py index 046019ee58..3a4e5aac66 100644 --- a/transformer_engine/pytorch/cpp_extensions/fused_attn.py +++ b/transformer_engine/pytorch/cpp_extensions/fused_attn.py @@ -107,10 +107,13 @@ class FusedAttnBackend(IntEnum): ``transformer_engine_torch.NVTE_Fused_Attn_Backend`` (pybind11) enum value-for-value, and instances of the two enums compare equal when they share the same integer value. Unlike the pybind enum, a plain-python - ``IntEnum`` is traceable by ``torch.compile``: comparisons constant-fold - cleanly and instances safely cross the ``assume_constant_result`` boundary - in ``get_attention_backend``. Lookup by name (``FusedAttnBackend["FP8"]``) - works the same way as with the dict this used to be. + ``IntEnum`` is traceable by ``torch.compile``: comparisons against a member + constant-fold cleanly. Lookup by name (``FusedAttnBackend["FP8"]``) works + the same way as with the dict this used to be. + + Members do not survive a graph break, though, so ``get_attention_backend`` + hands out plain ints while tracing -- ``cast`` normalizes both back to a + member. """ No_Backend = int(NVTE_Fused_Attn_Backend.NVTE_No_Backend) From 3053d76f97dba1e4b1abba8ed009c9ff176a4310 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Tue, 28 Jul 2026 12:51:11 +0200 Subject: [PATCH 02/29] Delegate the no-FP8 case of DPA's init_fp8_metadata to the base class The override hand-rolled the state that TransformerEngineBaseModule already sets when FP8 is off everywhere (the three flags, fp8_checkpoint and fp8_initialized). Call super() instead, and read the flags straight off FP8GlobalStateManager.quantization_state, as the other hot paths do. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention.py | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 0b21adb15c..41aef33280 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -714,20 +714,12 @@ def init_fp8_metadata(self, num_gemms: int = 1) -> None: """ _original_recipe = self.fp8_meta.get("recipe", None) - fp8 = FP8GlobalStateManager.is_fp8_enabled() - fp8_calibration = FP8GlobalStateManager.is_fp8_calibration() - fp8_parameters = FP8GlobalStateManager.with_fp8_parameters() - if not (fp8 or fp8_calibration or fp8_parameters): - # Nothing below has an effect when FP8 is off everywhere -- the recipe - # juggling ends in the "turn off and return" branch. Bailing out here - # also keeps DotProductAttention traceable by torch.compile: with no - # autocast active, get_fp8_recipe() below builds a default Recipe, - # whose construction is not traceable. - self.fast_setattr("fp8_parameters", fp8_parameters) - self.fast_setattr("fp8", fp8) - self.fast_setattr("fp8_calibration", fp8_calibration) - self.fp8_meta["fp8_checkpoint"] = False - self.fast_setattr("fp8_initialized", False) + qstate = FP8GlobalStateManager.quantization_state + if not (qstate.fp8_enabled or qstate.fp8_calibration or qstate.fp8_parameters): + # Without FP8 the recipe juggling below is a no-op, and its + # get_fp8_recipe() call would build a default Recipe -- which + # torch.compile cannot trace. + super().init_fp8_metadata(num_gemms=num_gemms) return # global recipe set in autocast() From d6b285ed1cf5ff6d036acadd0cc7b517d0af2002 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Tue, 28 Jul 2026 15:42:46 +0200 Subject: [PATCH 03/29] Run DPA eagerly for undeclared packed q/k/v instead of guessing the layout get_qkv_layout recognizes packed q/k/v by inspecting data pointers and storage offsets, which dynamo cannot trace. Rather than assume such inputs are three separate tensors -- a layout that would not describe memory -- detect the case from what is traceable (a strided tensor that does not own its whole storage) and run the whole module as an eager island, with a warning. Declaring the packing via qkv_layer/kv_layer keeps the call on the compiled path. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 41 ++++++++++++++++ .../dot_product_attention.py | 49 +++++++++++++++++++ .../attention/dot_product_attention/utils.py | 33 ++++++++----- 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index ed2ad90c76..af67ea8605 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -759,6 +759,47 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): _assert_dpa_backend(backend) +@pytest.mark.parametrize("backend", ["flash", "unfused"]) +@pytest.mark.parametrize("interleave_dim", [-3, -2]) +def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interleave_dim): + """Packed q/k/v passed as plain views cannot be recognized while tracing (the + detection reads data pointers), so DotProductAttention runs that detection + eagerly instead -- correct results, at the cost of a graph break. Declaring + the packing via qkv_layer/kv_layer keeps it on the compiled path, which + test_dpa_torch_compile covers.""" + dtype = torch.bfloat16 + cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + _force_dpa_backend(monkeypatch, backend) + + module = _make_dpa(cfg, dtype) + b, s = cfg["batch_size"], cfg["max_seqlen_q"] + h, d = cfg["num_heads"], cfg["head_dim"] + shape = (b, s, 3, h, d) if interleave_dim == -3 else (b, s, h, 3, d) + qkv = torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) + q, k, v = [qkv.select(interleave_dim, i) for i in range(3)] + + ref = module(q, k, v) + ref.sum().backward() + ref_grad = qkv.grad.clone() + qkv.grad = None + + torch._dynamo.reset() + _force_dpa_backend(monkeypatch, backend) + with pytest.warns(UserWarning, match="Falling back to eager execution"): + out = torch.compile(module)(q, k, v) + out.sum().backward() + torch.cuda.synchronize() + + torch.testing.assert_close(out, ref) + torch.testing.assert_close(qkv.grad, ref_grad) + + # The same graph break is an error when the user asked for a full graph. + torch._dynamo.reset() + _force_dpa_backend(monkeypatch, backend) + with pytest.raises(Exception, match="torch.compiler.disable"): + torch.compile(module, fullgraph=True)(q, k, v) + + def test_dpa_torch_compile_fused_backend(monkeypatch): """The FusedAttention backend is an eager island, so it graph-breaks rather than compiles; DotProductAttention must still be correct around it.""" diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 41aef33280..d00d2c3849 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -4,6 +4,7 @@ """Attention.""" from contextlib import nullcontext +from functools import wraps import math import os from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -42,6 +43,7 @@ CudaRNGStatesTracker, graph_safe_rng_available, ) +from transformer_engine.pytorch.jit import no_torch_dynamo from transformer_engine.pytorch.graph import is_graph_capturing from transformer_engine.pytorch.attention.inference import InferenceParams @@ -195,6 +197,50 @@ def _trim_output(attn_out, num_attention_heads, padded_head_dim_v, orig_head_dim return attn_out[..., :orig_head_dim_v].reshape(*out_shape, -1) +def _needs_eager_dpa( + _self, + query_layer: Optional[torch.Tensor] = None, + key_layer: Optional[torch.Tensor] = None, + value_layer: Optional[torch.Tensor] = None, + *_args, + qkv_layer: Optional[torch.Tensor] = None, + kv_layer: Optional[torch.Tensor] = None, + **_kwargs, +) -> bool: + """Whether this DotProductAttention call has to run outside the graph, i.e. + whether it passes packed q/k/v without declaring them.""" + if qkv_layer is not None or kv_layer is not None: + return False + return dpa_utils.qkv_layout_needs_detection(query_layer, key_layer, value_layer) + + +def _eager_under_compile_if(needs_eager: Callable[..., bool], reason: str): + """Decorator running the wrapped method eagerly when `needs_eager` says the + call is unsupported on the compiled path.""" + + def decorator(fn): + # The warning belongs inside the dynamo-disabled function: warnings.warn + # graph-breaks on its own, masking the break that matters. + @no_torch_dynamo() + def eager_fn(*args, **kwargs): + warnings.warn( + f"Falling back to eager execution under torch.compile: {reason} is" + " unsupported on the compiled path (graph-breaks under fullgraph=True).", + stacklevel=3, + ) + return fn(*args, **kwargs) + + @wraps(fn) + def wrapper(*args, **kwargs): + if torch.compiler.is_compiling() and needs_eager(*args, **kwargs): + return eager_fn(*args, **kwargs) + return fn(*args, **kwargs) + + return wrapper + + return decorator + + def _unpack_packed_qkv( qkv_layer: Optional[torch.Tensor], kv_layer: Optional[torch.Tensor], @@ -1097,6 +1143,9 @@ def get_quantizer_roles( ] return base[:num_quantizers] + @_eager_under_compile_if( + _needs_eager_dpa, "detecting packed q/k/v that were not declared via qkv_layer/kv_layer" + ) def forward( self, query_layer: Optional[torch.Tensor] = None, diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 4a8b7beb14..6ea15942d1 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -59,7 +59,7 @@ ) from transformer_engine.pytorch.export import is_in_onnx_export_mode -from transformer_engine.pytorch.jit import jit_fuser +from transformer_engine.pytorch.jit import jit_fuser, no_torch_dynamo # NVTE_DEBUG = 0/1 # disables/enables debug mode, default = 0 _NVTE_DEBUG = int(os.getenv("NVTE_DEBUG", "0")) @@ -2386,6 +2386,20 @@ def get_qkv_format( return qkv_format, q_format, kv_format +def qkv_layout_needs_detection(*qkv: Optional[torch.Tensor]) -> bool: + """Whether the layout of these q/k/v can only be told by inspecting memory. + + True for tensors that may be slices of a packed buffer, i.e. strided ones + that do not own their whole storage. + """ + return any( + x is not None + and not x.is_contiguous() + and x.untyped_storage().size() != x.numel() * x.element_size() + for x in qkv + ) + + def get_qkv_layout( q: torch.Tensor, k: torch.Tensor, @@ -2560,16 +2574,13 @@ def run_iteratively(q, k, v): return qkv_layout if torch.compiler.is_compiling(): - # The detection in run_iteratively is untraceable by dynamo -- it reads - # UntypedStorage.data_ptr and Tensor.storage_offset -- and pointers are - # meaningless on the fake tensors used while tracing anyway. Under - # torch.compile, packed q/k/v must therefore be declared explicitly, via - # DotProductAttention's qkv_layer/kv_layer arguments (which bypass this - # function entirely); q/k/v passed separately are treated as three - # independent tensors. Views into a packed buffer are non-contiguous, so - # making the inputs contiguous keeps the returned layout truthful for - # every backend. - q, k, v = [x if x.is_contiguous() else x.contiguous() for x in [q, k, v]] + # run_iteratively reads data pointers and storage offsets, which dynamo + # cannot trace; unpacked q/k/v need no detection anyway. + assert not qkv_layout_needs_detection(q, k, v), ( + "q/k/v may be views into a packed buffer, whose layout cannot be detected under" + " torch.compile. Pass the packed buffer explicitly via DotProductAttention's" + " qkv_layer/kv_layer (with qkv_interleave_dim)." + ) if is_same_q_kv_format: qkv_layout = "_".join([qkv_format] * 3) else: From 1cd6704912eff4d3fc2fe4841f49aa85bcfa431a Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Tue, 28 Jul 2026 16:23:11 +0200 Subject: [PATCH 04/29] Log backend selection in eager only; shorten comments Log arguments are evaluated regardless of the logger, so the no-op logger from #3189 is not enough here -- skip the whole block while tracing. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention/dot_product_attention.py | 9 ++++----- .../pytorch/attention/dot_product_attention/utils.py | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index d00d2c3849..fa261f0c61 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -1875,10 +1875,9 @@ def forward( _attention_backends["fused_attention_backend"] = fused_attention_backend _attention_backends["use_unfused_attention"] = use_unfused_attention _attention_backends["backend_selection_requires_update"] = False - # Backend selection is only logged in eager mode: dynamo - # graph-breaks on logging.Logger methods, and even the - # arguments below are untraceable (int() of the sub-backend - # enum). Same restriction as in get_attention_backend. + # Logged in eager only: dynamo graph-breaks on logging.Logger + # methods, and log arguments are evaluated regardless of the + # logger, so a no-op logger would not be enough. if not torch.compiler.is_compiling(): if use_flash_attention: self.logger.info( @@ -1888,7 +1887,7 @@ def forward( elif use_fused_attention: self.logger.info( "Running with FusedAttention backend (sub-backend %s)", - fused_attention_backend, + int(fused_attention_backend), ) elif use_unfused_attention: self.logger.info("Running with UnfusedDotProductAttention backend") diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 6ea15942d1..84868f7740 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -2044,9 +2044,8 @@ def _get_cu_seqlens(batch_size, max_seqlen, device): ) if is_in_onnx_export_mode() or torch.compiler.is_compiling(): - # torch.is_inference_mode_enabled() (the cache key below) is a fundamental - # graph break under torch.compile; building the tensor is a single arange - # that the compiler captures into the graph anyway. + # The cache only saves an allocation, and its key -- is_inference_mode_enabled() + # -- is a fundamental graph break. return _get_cu_seqlens(batch_size, max_seqlen, device) is_inference = torch.is_inference_mode_enabled() From 42a9e6cf209a3a10134a92bf2aec28795513e0af Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 09:04:37 +0200 Subject: [PATCH 05/29] Fix lint: unused import and keyword-arg-before-vararg - utils.py no longer needs no_torch_dynamo: the eager island moved to dot_product_attention.py. - _needs_eager_dpa reads q/k/v out of *args/**kwargs instead of naming them before *args, which pylint flags (W1113). Signed-off-by: Pawel Gadzinski --- .../dot_product_attention.py | 25 +++++++++---------- .../attention/dot_product_attention/utils.py | 2 +- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index fa261f0c61..d842a8e55b 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -197,21 +197,20 @@ def _trim_output(attn_out, num_attention_heads, padded_head_dim_v, orig_head_dim return attn_out[..., :orig_head_dim_v].reshape(*out_shape, -1) -def _needs_eager_dpa( - _self, - query_layer: Optional[torch.Tensor] = None, - key_layer: Optional[torch.Tensor] = None, - value_layer: Optional[torch.Tensor] = None, - *_args, - qkv_layer: Optional[torch.Tensor] = None, - kv_layer: Optional[torch.Tensor] = None, - **_kwargs, -) -> bool: +def _needs_eager_dpa(*args, **kwargs) -> bool: """Whether this DotProductAttention call has to run outside the graph, i.e. - whether it passes packed q/k/v without declaring them.""" - if qkv_layer is not None or kv_layer is not None: + whether it passes packed q/k/v without declaring them. + + Takes `DotProductAttention.forward`'s arguments as they were passed, so q/k/v + are read from either side: `args[0]` is `self`, followed by the positional + query/key/value. + """ + if kwargs.get("qkv_layer") is not None or kwargs.get("kv_layer") is not None: return False - return dpa_utils.qkv_layout_needs_detection(query_layer, key_layer, value_layer) + qkv = list(args[1:4]) + [ + kwargs.get(name) for name in ("query_layer", "key_layer", "value_layer") + ] + return dpa_utils.qkv_layout_needs_detection(*qkv) def _eager_under_compile_if(needs_eager: Callable[..., bool], reason: str): diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 84868f7740..95b5a7f15f 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -59,7 +59,7 @@ ) from transformer_engine.pytorch.export import is_in_onnx_export_mode -from transformer_engine.pytorch.jit import jit_fuser, no_torch_dynamo +from transformer_engine.pytorch.jit import jit_fuser # NVTE_DEBUG = 0/1 # disables/enables debug mode, default = 0 _NVTE_DEBUG = int(os.getenv("NVTE_DEBUG", "0")) From af11f8f13b0d5e3e99aef2f6067634faf94da8cb Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 09:59:30 +0200 Subject: [PATCH 06/29] Compare against eager under CUDA graphs, and tighten the tolerances The CUDA-graphs test only checked that the output was finite and that gradients existed. A replay that reuses stale buffers -- the failure mode that test is there to catch -- satisfies both, so it now compares against eager on fresh inputs every iteration. The remaining comparisons used the dtype defaults (rtol=1.6e-2 for bfloat16), which is far looser than what these paths deliver: compiled and eager agree bit-for-bit on every config and backend. FlashAttention now has to match exactly, as it runs the same kernel either way; the unfused backend is allowed a single bfloat16 rounding, since inductor may reassociate the softmax sums. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 45 +++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index af67ea8605..050f0f25a5 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -693,6 +693,19 @@ def _assert_dpa_backend(backend: str) -> None: ) +def _dpa_tolerances(backend: str) -> dict: + """How closely the compiled backend has to match eager. + + FlashAttention calls the same kernel either way, so it has to match exactly. + The unfused backend is compiled by inductor, which may fuse the softmax + chain differently and reassociate its sums; allow a single bfloat16 rounding + (eps = 2**-8) for that, which is still 4x tighter than the dtype default. + """ + if backend == "flash": + return {"rtol": 0.0, "atol": 0.0} + return {"rtol": 2**-8, "atol": 1e-5} + + @pytest.mark.parametrize("backend", ["flash", "unfused"]) @pytest.mark.parametrize("config", _DPA_COMPILE_CONFIGS.keys()) def test_dpa_torch_compile(monkeypatch, backend, config): @@ -729,10 +742,11 @@ def test_dpa_torch_compile(monkeypatch, backend, config): torch.cuda.synchronize() _assert_dpa_backend(backend) - torch.testing.assert_close(out, ref) + tols = _dpa_tolerances(backend) + torch.testing.assert_close(out, ref, **tols) for tensor, ref_grad in zip(grads, ref_grads): assert tensor.grad is not None - torch.testing.assert_close(tensor.grad, ref_grad) + torch.testing.assert_close(tensor.grad, ref_grad, **tols) @pytest.mark.parametrize("backend", ["flash", "unfused"]) @@ -748,14 +762,26 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): torch._dynamo.reset() compiled = torch.compile(module, fullgraph=True, mode="reduce-overhead") + tols = _dpa_tolerances(backend) for _ in range(3): + # Fresh inputs every iteration: a replay that reuses stale buffers would + # still produce finite values and non-None gradients, so only comparing + # against eager catches it. args, kwargs, grads = _make_dpa_inputs(cfg, dtype) - out = compiled(*args, **kwargs) + # CUDA graphs hand back tensors owned by their memory pool, which the + # next replay overwrites. + out = compiled(*args, **kwargs).clone() out.sum().backward() torch.cuda.synchronize() - assert torch.isfinite(out).all() + compiled_grads = [t.grad.clone() for t in grads] for tensor in grads: - assert tensor.grad is not None + tensor.grad = None + + ref = module(*args, **kwargs) + ref.sum().backward() + torch.testing.assert_close(out, ref, **tols) + for compiled_grad, tensor in zip(compiled_grads, grads): + torch.testing.assert_close(compiled_grad, tensor.grad, **tols) _assert_dpa_backend(backend) @@ -790,8 +816,9 @@ def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interlea out.sum().backward() torch.cuda.synchronize() - torch.testing.assert_close(out, ref) - torch.testing.assert_close(qkv.grad, ref_grad) + tols = _dpa_tolerances(backend) + torch.testing.assert_close(out, ref, **tols) + torch.testing.assert_close(qkv.grad, ref_grad, **tols) # The same graph break is an error when the user asked for a full graph. torch._dynamo.reset() @@ -829,9 +856,9 @@ def test_dpa_torch_compile_fused_backend(monkeypatch): torch.cuda.synchronize() _assert_dpa_backend("fused") - torch.testing.assert_close(out, ref) + torch.testing.assert_close(out, ref, rtol=0.0, atol=0.0) for tensor, ref_grad in zip(grads, ref_grads): - torch.testing.assert_close(tensor.grad, ref_grad) + torch.testing.assert_close(tensor.grad, ref_grad, rtol=0.0, atol=0.0) # --------------------------------------------------------------------------- From 0b64c9695abc27aab434326c258e0edc5c4dac02 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 10:16:55 +0200 Subject: [PATCH 07/29] Support one cu_seqlens tensor for both q and kv under torch.compile Self attention on thd naturally passes the same tensor as cu_seqlens_q and cu_seqlens_kv, and flash-attn's varlen entry point forwards both to the same autograd.Function -- which dynamo refuses to trace ("duplicate tensor input"), failing the compilation under fullgraph=True. Hand the varlen call a copy of one of them while tracing. The clone is b + 1 int32 elements, only on that path, and only under torch.compile. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 43 +++++++++++++++++++ .../dot_product_attention/backends.py | 15 ++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 050f0f25a5..71eb58a10d 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -785,6 +785,49 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): _assert_dpa_backend(backend) +@pytest.mark.parametrize("backend", ["flash", "unfused"]) +def test_dpa_torch_compile_shared_cu_seqlens(monkeypatch, backend): + """Self attention on `thd` naturally passes one cu_seqlens tensor for both q + and kv, which flash-attn hands to two inputs of the same autograd.Function -- + something dynamo cannot trace. The other tests build two tensors, so this + covers the shape a caller is most likely to write.""" + dtype = torch.bfloat16 + cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_thd_padding_causal"]) + _force_dpa_backend(monkeypatch, backend) + + module = _make_dpa(cfg, dtype) + b, s = cfg["batch_size"], cfg["max_seqlen_q"] + h, d = cfg["num_heads"], cfg["head_dim"] + q, k, v = [ + torch.randn(b * s, h, d, dtype=dtype, device="cuda", requires_grad=True) for _ in range(3) + ] + cu_seqlens = torch.arange(0, (b + 1) * s, step=s, dtype=torch.int32, device="cuda") + kwargs = dict( + cu_seqlens_q=cu_seqlens, + cu_seqlens_kv=cu_seqlens, # the same tensor, not a copy + max_seqlen_q=s, + max_seqlen_kv=s, + ) + + ref = module(q, k, v, **kwargs) + ref.sum().backward() + ref_grads = [t.grad.clone() for t in (q, k, v)] + for t in (q, k, v): + t.grad = None + + torch._dynamo.reset() + _force_dpa_backend(monkeypatch, backend) + out = torch.compile(module, fullgraph=True)(q, k, v, **kwargs) + out.sum().backward() + torch.cuda.synchronize() + + _assert_dpa_backend(backend) + tols = _dpa_tolerances(backend) + torch.testing.assert_close(out, ref, **tols) + for tensor, ref_grad in zip((q, k, v), ref_grads): + torch.testing.assert_close(tensor.grad, ref_grad, **tols) + + @pytest.mark.parametrize("backend", ["flash", "unfused"]) @pytest.mark.parametrize("interleave_dim", [-3, -2]) def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interleave_dim): diff --git a/transformer_engine/pytorch/attention/dot_product_attention/backends.py b/transformer_engine/pytorch/attention/dot_product_attention/backends.py index 6a60160f77..f0382be30c 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/backends.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/backends.py @@ -1122,12 +1122,15 @@ def forward( else: func = flash_attn_with_kvcache_v3 # pylint: disable=possibly-used-before-assignment if not use_flash_attn_4 and (not use_flash_attn_3 or inference_params is None): - fa_optional_forward_args_thd.append( - cu_seqlens_q_padded if pad_between_seqs else cu_seqlens_q - ) - fa_optional_forward_args_thd.append( - cu_seqlens_kv_padded if pad_between_seqs else cu_seqlens_kv - ) + cu_q = cu_seqlens_q_padded if pad_between_seqs else cu_seqlens_q + cu_kv = cu_seqlens_kv_padded if pad_between_seqs else cu_seqlens_kv + if cu_q is cu_kv and torch.compiler.is_compiling(): + # Self attention passes one tensor for both, and dynamo + # cannot trace autograd.Function.apply with the same + # tensor on two of its inputs. + cu_kv = cu_kv.clone() + fa_optional_forward_args_thd.append(cu_q) + fa_optional_forward_args_thd.append(cu_kv) fa_optional_forward_args_thd.append(max_seqlen_q) fa_optional_forward_args_thd.append(max_seqlen_kv) if use_flash_attn_4: From 7f480c6d07a9d04bca718f581e6a5e6b3e9bb113 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 10:21:49 +0200 Subject: [PATCH 08/29] Apply the shared-cu_seqlens workaround to the FlashAttention v4 path too Pull the deduplication into a helper and use it wherever both cumulative sequence lengths are handed to flash-attn: the varlen path shared by v2 and v3 training, and v4's varlen kwargs. The v3 KV-cache path derives the kv lengths by subtraction, so its two arguments are distinct by construction. v4 is not installed in the environment this was tested in, so that call site is covered by construction rather than by a test; the helper is a no-op unless the two arguments are the same object. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention/backends.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/backends.py b/transformer_engine/pytorch/attention/dot_product_attention/backends.py index f0382be30c..2c47ff8a4b 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/backends.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/backends.py @@ -793,6 +793,18 @@ def backward( return dq, dk, dv +def _distinct_cu_seqlens(cu_q: torch.Tensor, cu_kv: torch.Tensor): + """Make q's and kv's cumulative sequence lengths distinct objects. + + Self attention passes one tensor for both, and flash-attn forwards them to + two inputs of the same autograd.Function -- which dynamo cannot trace. In + eager the duplicate is harmless, so nothing is copied there. + """ + if cu_q is cu_kv and torch.compiler.is_compiling(): + return cu_q, cu_kv.clone() + return cu_q, cu_kv + + class FlashAttention(torch.nn.Module): """Dot product attention, using HazyResearch flash-attn package: https://github.com/Dao-AILab/flash-attention @@ -1122,13 +1134,10 @@ def forward( else: func = flash_attn_with_kvcache_v3 # pylint: disable=possibly-used-before-assignment if not use_flash_attn_4 and (not use_flash_attn_3 or inference_params is None): - cu_q = cu_seqlens_q_padded if pad_between_seqs else cu_seqlens_q - cu_kv = cu_seqlens_kv_padded if pad_between_seqs else cu_seqlens_kv - if cu_q is cu_kv and torch.compiler.is_compiling(): - # Self attention passes one tensor for both, and dynamo - # cannot trace autograd.Function.apply with the same - # tensor on two of its inputs. - cu_kv = cu_kv.clone() + cu_q, cu_kv = _distinct_cu_seqlens( + cu_seqlens_q_padded if pad_between_seqs else cu_seqlens_q, + cu_seqlens_kv_padded if pad_between_seqs else cu_seqlens_kv, + ) fa_optional_forward_args_thd.append(cu_q) fa_optional_forward_args_thd.append(cu_kv) fa_optional_forward_args_thd.append(max_seqlen_q) @@ -1141,8 +1150,9 @@ def forward( if inference_params is None: fa_4_optional_forward_kwargs["deterministic"] = self.deterministic if func is flash_attn_varlen_func_v4: - fa_4_optional_forward_kwargs["cu_seqlens_q"] = cu_seqlens_q - fa_4_optional_forward_kwargs["cu_seqlens_k"] = cu_seqlens_kv + cu_q, cu_kv = _distinct_cu_seqlens(cu_seqlens_q, cu_seqlens_kv) + fa_4_optional_forward_kwargs["cu_seqlens_q"] = cu_q + fa_4_optional_forward_kwargs["cu_seqlens_k"] = cu_kv fa_4_optional_forward_kwargs["max_seqlen_q"] = max_seqlen_q fa_4_optional_forward_kwargs["max_seqlen_k"] = max_seqlen_kv output = func( From adc96c48a9b455678acbda96e367e8a0c63d16b2 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 10:58:31 +0200 Subject: [PATCH 09/29] Run FP8 attention eagerly under torch.compile FP8 attention brings quantizers, fp8_meta mutation and Float8Tensor across the graph boundary, and none of it is exercised by this PR's tests. Rather than trace it untested, route it to the eager island that packed q/k/v already use. The predicate is deliberately narrow: it asks the recipe for fp8_dpa/fp8_mha rather than whether an autocast is active, so FP8 GEMMs with attention in high precision -- the common training setup -- stay on the compiled path. The decorator now takes a predicate that returns the reason, so the warning names which of the two paths was taken. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 34 ++++++++++++++ .../dot_product_attention.py | 44 +++++++++++-------- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 71eb58a10d..61e78e58b0 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -3,6 +3,7 @@ # See LICENSE for license information. import abc +import warnings import pytest import torch @@ -785,6 +786,39 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): _assert_dpa_backend(backend) +@pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8) +@pytest.mark.parametrize("fp8_attention", [False, True], ids=["fp8_gemms_only", "fp8_attention"]) +def test_dpa_torch_compile_fp8(monkeypatch, fp8_attention): + """FP8 attention is not supported on the compiled path and falls back to + eager. FP8 elsewhere in the model with attention in high precision -- the + common training setup -- must stay compiled.""" + dtype = torch.bfloat16 + cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + _force_dpa_backend(monkeypatch, "unfused") + + module = _make_dpa(cfg, dtype) + args, kwargs, _ = _make_dpa_inputs(cfg, dtype) + fp8_recipe = recipe.DelayedScaling(fp8_dpa=fp8_attention) + + def fn(*args, **kwargs): + with te.autocast(enabled=True, recipe=fp8_recipe): + return module(*args, **kwargs) + + torch._dynamo.reset() + _force_dpa_backend(monkeypatch, "unfused") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + torch.compile(fn)(*args, **kwargs) + except Exception: # pylint: disable=broad-except + # FP8 attention is not available on every device; what matters here + # is which path was taken, which the warning below reports. + pass + fell_back = any("Falling back to eager" in str(w.message) for w in caught) + + assert fell_back == fp8_attention + + @pytest.mark.parametrize("backend", ["flash", "unfused"]) def test_dpa_torch_compile_shared_cu_seqlens(monkeypatch, backend): """Self attention on `thd` naturally passes one cu_seqlens tensor for both q diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index d842a8e55b..f82aa91c13 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -197,31 +197,39 @@ def _trim_output(attn_out, num_attention_heads, padded_head_dim_v, orig_head_dim return attn_out[..., :orig_head_dim_v].reshape(*out_shape, -1) -def _needs_eager_dpa(*args, **kwargs) -> bool: - """Whether this DotProductAttention call has to run outside the graph, i.e. - whether it passes packed q/k/v without declaring them. +def _needs_eager_dpa(*args, **kwargs) -> Optional[str]: + """Why this DotProductAttention call has to run outside the graph, or None. Takes `DotProductAttention.forward`'s arguments as they were passed, so q/k/v are read from either side: `args[0]` is `self`, followed by the positional query/key/value. """ - if kwargs.get("qkv_layer") is not None or kwargs.get("kv_layer") is not None: - return False - qkv = list(args[1:4]) + [ - kwargs.get(name) for name in ("query_layer", "key_layer", "value_layer") - ] - return dpa_utils.qkv_layout_needs_detection(*qkv) + # FP8 GEMMs with the attention itself in high precision -- the common + # training setup -- stay on the compiled path; only FP8 attention bails out. + qstate = FP8GlobalStateManager.quantization_state + fp8_recipe = qstate.fp8_recipe + if qstate.fp8_enabled and fp8_recipe is not None: + if fp8_recipe.fp8_dpa or fp8_recipe.fp8_mha: + return "FP8 attention" + + if kwargs.get("qkv_layer") is None and kwargs.get("kv_layer") is None: + qkv = list(args[1:4]) + [ + kwargs.get(name) for name in ("query_layer", "key_layer", "value_layer") + ] + if dpa_utils.qkv_layout_needs_detection(*qkv): + return "detecting packed q/k/v that were not declared via qkv_layer/kv_layer" + return None -def _eager_under_compile_if(needs_eager: Callable[..., bool], reason: str): - """Decorator running the wrapped method eagerly when `needs_eager` says the - call is unsupported on the compiled path.""" +def _eager_under_compile_if(needs_eager: Callable[..., Optional[str]]): + """Decorator running the wrapped method eagerly whenever `needs_eager` gives + a reason why the call is unsupported on the compiled path.""" def decorator(fn): # The warning belongs inside the dynamo-disabled function: warnings.warn # graph-breaks on its own, masking the break that matters. @no_torch_dynamo() - def eager_fn(*args, **kwargs): + def eager_fn(reason, *args, **kwargs): warnings.warn( f"Falling back to eager execution under torch.compile: {reason} is" " unsupported on the compiled path (graph-breaks under fullgraph=True).", @@ -231,8 +239,10 @@ def eager_fn(*args, **kwargs): @wraps(fn) def wrapper(*args, **kwargs): - if torch.compiler.is_compiling() and needs_eager(*args, **kwargs): - return eager_fn(*args, **kwargs) + if torch.compiler.is_compiling(): + reason = needs_eager(*args, **kwargs) + if reason is not None: + return eager_fn(reason, *args, **kwargs) return fn(*args, **kwargs) return wrapper @@ -1142,9 +1152,7 @@ def get_quantizer_roles( ] return base[:num_quantizers] - @_eager_under_compile_if( - _needs_eager_dpa, "detecting packed q/k/v that were not declared via qkv_layer/kv_layer" - ) + @_eager_under_compile_if(_needs_eager_dpa) def forward( self, query_layer: Optional[torch.Tensor] = None, From 737904f09cc1957db238e73a68c7e1edeb231158 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 11:23:04 +0200 Subject: [PATCH 10/29] Drive the compile tests off ModelConfig instead of a hand-picked intersection The configurations were chosen so that both backends could run all of them, which meant nothing backend-specific was ever exercised: no biases, no arbitrary masks, no MLA head dims, no sink softmax. Describe them with the ModelConfig the eager attention tests already use, and let get_available_attention_backends() decide which backend runs which, so a configuration only one backend supports is covered rather than avoided. Five such configurations are added. One of them immediately showed that the tolerance for the unfused backend was too tight: an off-by-one softmax differs from eager by two bfloat16 roundings on a single element out of 65536, because inductor reassociates the softmax sums. Only FlashAttention, which runs the same kernel either way, keeps the exact comparison. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 208 +++++++++++++++++----------- 1 file changed, 125 insertions(+), 83 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 61e78e58b0..77c6c31a3b 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -4,6 +4,7 @@ import abc import warnings +from typing import Optional import pytest import torch @@ -37,7 +38,7 @@ MXFP8Quantizer, NVFP4Quantizer, ) -from utils import recipe_id +from utils import ModelConfig, get_available_attention_backends, recipe_id from transformer_engine.pytorch.attention.dot_product_attention.backends import ( UnfusedDotProductAttention, ) @@ -556,93 +557,117 @@ def fn(q, k, v, extra): # --------------------------------------------------------------------------- -# Each config is a model configuration that both the FlashAttention and the -# UnfusedDotProductAttention backend support, so that every one of them runs -# against both backends. +# Model configurations, described with the same ModelConfig the eager attention +# tests use. Which backends can run each of them is not hardcoded here -- +# get_available_attention_backends() answers that, so configurations only one +# backend supports (arbitrary masks, biases, MLA head dims, ...) are covered +# rather than avoided. _DPA_COMPILE_CONFIGS = { - "self_bshd_causal": dict(qkv_format="bshd", attn_mask_type="causal"), - "self_sbhd_no_mask": dict(qkv_format="sbhd", attn_mask_type="no_mask"), - "self_bshd_swa": dict(qkv_format="bshd", attn_mask_type="causal", window_size=(16, 0)), - "gqa_bshd_causal": dict( - qkv_format="bshd", attn_mask_type="causal", num_heads=8, num_gqa_groups=2 + # name: (ModelConfig, qkv_format, packed layout or None) + "self_bshd_causal": (ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), "bshd", None), + "self_sbhd_no_mask": (ModelConfig(2, 128, 4, 64, attn_mask_type="no_mask"), "sbhd", None), + "self_bshd_swa": ( + ModelConfig(2, 128, 4, 64, attn_mask_type="causal", window_size=(16, 0)), + "bshd", + None, ), - "cross_bshd_no_mask": dict( - qkv_format="bshd", attn_mask_type="no_mask", attention_type="cross", max_seqlen_kv=256 + "gqa_bshd_causal": ( + ModelConfig(2, 128, 8, 64, num_gqa_groups=2, attn_mask_type="causal"), + "bshd", + None, + ), + "cross_bshd_no_mask": ( + ModelConfig(2, 128, 4, 64, max_seqlen_kv=256, attn_mask_type="no_mask"), + "bshd", + None, + ), + "self_thd_padding_causal": ( + ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal"), + "thd", + None, + ), + "packed_qkv_bs3hd": (ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), "bshd", "bs3hd"), + # Configurations below are supported by one backend only, or take a code + # path of their own inside DotProductAttention. + "alibi_bshd_causal": ( + ModelConfig(2, 128, 4, 64, attn_mask_type="causal", attn_bias_type="alibi"), + "bshd", + None, + ), + "post_scale_bias_bshd": ( + ModelConfig(2, 128, 4, 64, attn_bias_type="post_scale_bias", bias_shape="1hss"), + "bshd", + None, + ), + "arbitrary_mask_bshd": (ModelConfig(2, 128, 4, 64, attn_mask_type="arbitrary"), "bshd", None), + "mla_bshd_causal": ( + ModelConfig(2, 128, 4, 128, head_dim_v=64, attn_mask_type="causal"), + "bshd", + None, + ), + "sink_softmax_bshd": ( + ModelConfig(2, 128, 4, 64, attn_mask_type="causal", softmax_type="off-by-one"), + "bshd", + None, ), - "self_thd_padding_causal": dict(qkv_format="thd", attn_mask_type="padding_causal"), - "packed_qkv_bs3hd": dict(qkv_format="bshd", attn_mask_type="causal", packed="bs3hd"), } -def _dpa_config(**overrides): - cfg = dict( - batch_size=2, - num_heads=4, - num_gqa_groups=None, - head_dim=64, - max_seqlen_q=128, - max_seqlen_kv=128, - qkv_format="bshd", - attn_mask_type="causal", - attention_type="self", - window_size=None, - packed=None, - ) - cfg.update(overrides) - if cfg["num_gqa_groups"] is None: - cfg["num_gqa_groups"] = cfg["num_heads"] - return cfg +def _qkv_layout(qkv_format: str, packed: Optional[str]) -> str: + return packed if packed is not None else "_".join([qkv_format] * 3) -def _make_dpa(cfg, dtype: torch.dtype) -> te.DotProductAttention: +def _make_dpa(config: ModelConfig, qkv_format: str, dtype: torch.dtype) -> te.DotProductAttention: return te.DotProductAttention( - num_attention_heads=cfg["num_heads"], - kv_channels=cfg["head_dim"], - num_gqa_groups=cfg["num_gqa_groups"], - attention_dropout=0.0, - qkv_format=cfg["qkv_format"], - attn_mask_type=cfg["attn_mask_type"], - window_size=cfg["window_size"], - attention_type=cfg["attention_type"], + num_attention_heads=config.num_heads, + kv_channels=config.kv_channels, + num_gqa_groups=config.num_gqa_groups, + attention_dropout=config.dropout_p, + qkv_format=qkv_format, + attn_mask_type=config.attn_mask_type, + window_size=config.window_size, + attention_type=config.attn_type, + softmax_type=config.softmax_type, ).to(dtype=dtype, device="cuda") -def _make_dpa_inputs(cfg, dtype: torch.dtype): +def _make_dpa_inputs(config: ModelConfig, qkv_format: str, packed: Optional[str], dtype): """Build the (args, kwargs) that `DotProductAttention.forward` is called with, plus the list of tensors whose gradients the test compares.""" - b = cfg["batch_size"] - s_q, s_kv = cfg["max_seqlen_q"], cfg["max_seqlen_kv"] - h, g, d = cfg["num_heads"], cfg["num_gqa_groups"], cfg["head_dim"] - fmt = cfg["qkv_format"] + b = config.batch_size + s_q, s_kv = config.max_seqlen_q, config.max_seqlen_kv + h, g = config.num_heads, config.num_gqa_groups + d_qk, d_v = config.head_dim_qk, config.head_dim_v - def _shape(s, heads): + def _shape(s, heads, head_dim): return { - "bshd": (b, s, heads, d), - "sbhd": (s, b, heads, d), - "thd": (b * s, heads, d), - }[fmt] + "bshd": (b, s, heads, head_dim), + "sbhd": (s, b, heads, head_dim), + "thd": (b * s, heads, head_dim), + }[qkv_format] def _randn(shape): return torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) kwargs = {} - if cfg["packed"] is not None: + if packed is not None: # Declarative packed QKV: q/k/v are derived from one buffer by DPA # itself, and the layout comes from the declaration -- the only packed - # layout that torch.compile supports (pointer-based detection of packed - # q/k/v is untraceable). - assert cfg["packed"] == "bs3hd" and fmt == "bshd" and h == g - qkv = _randn((b, s_q, 3, h, d)) + # layout that torch.compile supports. + assert packed == "bs3hd" and qkv_format == "bshd" and h == g and d_qk == d_v + qkv = _randn((b, s_q, 3, h, d_qk)) kwargs["qkv_layer"] = qkv kwargs["qkv_interleave_dim"] = -3 grad_tensors = [qkv] args = () else: - q, k, v = _randn(_shape(s_q, h)), _randn(_shape(s_kv, g)), _randn(_shape(s_kv, g)) + q = _randn(_shape(s_q, h, d_qk)) + k = _randn(_shape(s_kv, g, d_qk)) + v = _randn(_shape(s_kv, g, d_v)) grad_tensors = [q, k, v] args = (q, k, v) - if fmt == "thd": + if qkv_format == "thd": # All sequences have the maximum length, i.e. no padding. max_seqlen_* # is passed explicitly: deriving it from cu_seqlens costs a `.item()` # (a device sync, and an unbacked SymInt under torch.compile). @@ -655,9 +680,25 @@ def _randn(shape): kwargs["max_seqlen_q"] = s_q kwargs["max_seqlen_kv"] = s_kv + if config.attn_mask_type == "arbitrary": + kwargs["attention_mask"] = torch.zeros(b, 1, s_q, s_kv, dtype=torch.bool, device="cuda") + if config.attn_bias_type != "no_bias": + kwargs["core_attention_bias_type"] = config.attn_bias_type + if config.attn_bias_type == "post_scale_bias": + kwargs["core_attention_bias"] = torch.randn(1, h, s_q, s_kv, dtype=dtype, device="cuda") + return args, kwargs, grad_tensors +def _skip_unsupported(config: ModelConfig, qkv_layout: str, backend: str, dtype) -> None: + """Skip configurations the backend under test cannot run at all.""" + available, _, _ = get_available_attention_backends(config, dtype, qkv_layout) + flash_supported, _, unfused_supported = available + supported = {"flash": flash_supported, "unfused": unfused_supported}[backend] + if not supported: + pytest.skip(f"the {backend} backend does not support this configuration") + + def _force_dpa_backend(monkeypatch, backend: str) -> None: """Restrict DotProductAttention to a single backend.""" from transformer_engine.pytorch.attention.dot_product_attention.dot_product_attention import ( @@ -698,13 +739,13 @@ def _dpa_tolerances(backend: str) -> dict: """How closely the compiled backend has to match eager. FlashAttention calls the same kernel either way, so it has to match exactly. - The unfused backend is compiled by inductor, which may fuse the softmax - chain differently and reassociate its sums; allow a single bfloat16 rounding - (eps = 2**-8) for that, which is still 4x tighter than the dtype default. + The unfused backend is compiled by inductor, which fuses the softmax chain + differently and reassociates its sums: with an off-by-one softmax that costs + up to two bfloat16 roundings on individual elements, so it keeps the dtype + defaults. A systematic difference -- a wrong mask or scale -- lands far + outside them anyway. """ - if backend == "flash": - return {"rtol": 0.0, "atol": 0.0} - return {"rtol": 2**-8, "atol": 1e-5} + return {"rtol": 0.0, "atol": 0.0} if backend == "flash" else {} @pytest.mark.parametrize("backend", ["flash", "unfused"]) @@ -719,11 +760,12 @@ def test_dpa_torch_compile(monkeypatch, backend, config): backend itself. The FusedAttention backend is not covered -- it is an eager island (`@no_torch_dynamo` on `FusedAttention.forward`).""" dtype = torch.bfloat16 - cfg = _dpa_config(**_DPA_COMPILE_CONFIGS[config]) + model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS[config] + _skip_unsupported(model_config, _qkv_layout(qkv_format, packed), backend, dtype) _force_dpa_backend(monkeypatch, backend) - module = _make_dpa(cfg, dtype) - args, kwargs, grads = _make_dpa_inputs(cfg, dtype) + module = _make_dpa(model_config, qkv_format, dtype) + args, kwargs, grads = _make_dpa_inputs(model_config, qkv_format, packed, dtype) ref = module(*args, **kwargs) ref.sum().backward() @@ -755,10 +797,10 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): """`mode="reduce-overhead"`: forward and backward of DotProductAttention are captured into CUDA graphs and replayed on subsequent iterations.""" dtype = torch.bfloat16 - cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS["self_bshd_causal"] _force_dpa_backend(monkeypatch, backend) - module = _make_dpa(cfg, dtype) + module = _make_dpa(model_config, qkv_format, dtype) torch._dynamo.reset() compiled = torch.compile(module, fullgraph=True, mode="reduce-overhead") @@ -768,7 +810,7 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): # Fresh inputs every iteration: a replay that reuses stale buffers would # still produce finite values and non-None gradients, so only comparing # against eager catches it. - args, kwargs, grads = _make_dpa_inputs(cfg, dtype) + args, kwargs, grads = _make_dpa_inputs(model_config, qkv_format, packed, dtype) # CUDA graphs hand back tensors owned by their memory pool, which the # next replay overwrites. out = compiled(*args, **kwargs).clone() @@ -793,11 +835,11 @@ def test_dpa_torch_compile_fp8(monkeypatch, fp8_attention): eager. FP8 elsewhere in the model with attention in high precision -- the common training setup -- must stay compiled.""" dtype = torch.bfloat16 - cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS["self_bshd_causal"] _force_dpa_backend(monkeypatch, "unfused") - module = _make_dpa(cfg, dtype) - args, kwargs, _ = _make_dpa_inputs(cfg, dtype) + module = _make_dpa(model_config, qkv_format, dtype) + args, kwargs, _ = _make_dpa_inputs(model_config, qkv_format, packed, dtype) fp8_recipe = recipe.DelayedScaling(fp8_dpa=fp8_attention) def fn(*args, **kwargs): @@ -826,12 +868,12 @@ def test_dpa_torch_compile_shared_cu_seqlens(monkeypatch, backend): something dynamo cannot trace. The other tests build two tensors, so this covers the shape a caller is most likely to write.""" dtype = torch.bfloat16 - cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_thd_padding_causal"]) + model_config, qkv_format, _ = _DPA_COMPILE_CONFIGS["self_thd_padding_causal"] _force_dpa_backend(monkeypatch, backend) - module = _make_dpa(cfg, dtype) - b, s = cfg["batch_size"], cfg["max_seqlen_q"] - h, d = cfg["num_heads"], cfg["head_dim"] + module = _make_dpa(model_config, qkv_format, dtype) + b, s = model_config.batch_size, model_config.max_seqlen_q + h, d = model_config.num_heads, model_config.head_dim_qk q, k, v = [ torch.randn(b * s, h, d, dtype=dtype, device="cuda", requires_grad=True) for _ in range(3) ] @@ -871,12 +913,12 @@ def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interlea the packing via qkv_layer/kv_layer keeps it on the compiled path, which test_dpa_torch_compile covers.""" dtype = torch.bfloat16 - cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + model_config, qkv_format, _ = _DPA_COMPILE_CONFIGS["self_bshd_causal"] _force_dpa_backend(monkeypatch, backend) - module = _make_dpa(cfg, dtype) - b, s = cfg["batch_size"], cfg["max_seqlen_q"] - h, d = cfg["num_heads"], cfg["head_dim"] + module = _make_dpa(model_config, qkv_format, dtype) + b, s = model_config.batch_size, model_config.max_seqlen_q + h, d = model_config.num_heads, model_config.head_dim_qk shape = (b, s, 3, h, d) if interleave_dim == -3 else (b, s, h, 3, d) qkv = torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) q, k, v = [qkv.select(interleave_dim, i) for i in range(3)] @@ -908,11 +950,11 @@ def test_dpa_torch_compile_fused_backend(monkeypatch): """The FusedAttention backend is an eager island, so it graph-breaks rather than compiles; DotProductAttention must still be correct around it.""" dtype = torch.bfloat16 - cfg = _dpa_config(**_DPA_COMPILE_CONFIGS["self_bshd_causal"]) + model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS["self_bshd_causal"] _force_dpa_backend(monkeypatch, "fused") - module = _make_dpa(cfg, dtype) - args, kwargs, grads = _make_dpa_inputs(cfg, dtype) + module = _make_dpa(model_config, qkv_format, dtype) + args, kwargs, grads = _make_dpa_inputs(model_config, qkv_format, packed, dtype) ref = module(*args, **kwargs) from transformer_engine.pytorch.attention.dot_product_attention.dot_product_attention import ( From 9026a41c3303a1e602b6be6e99d2cefe5e4da64d Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 11:31:36 +0200 Subject: [PATCH 11/29] Scale the unfused comparison to the tensor, and take tolerances from utils An off-by-one softmax exposed that comparing the unfused backend elementwise does not hold up: inductor's reassociation of the softmax sums produces an error proportional to those sums, which for gradient elements near zero is far outside any relative tolerance -- while being one bfloat16 rounding at the tensor's own scale. Derive the absolute tolerance from the reference tensor's magnitude, and take the dtype tolerances from tests/pytorch/utils.py rather than restating them. FlashAttention and FusedAttention keep the exact comparison. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 53 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 77c6c31a3b..b88222be60 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -38,7 +38,7 @@ MXFP8Quantizer, NVFP4Quantizer, ) -from utils import ModelConfig, get_available_attention_backends, recipe_id +from utils import ModelConfig, dtype_tols, get_available_attention_backends, recipe_id from transformer_engine.pytorch.attention.dot_product_attention.backends import ( UnfusedDotProductAttention, ) @@ -735,17 +735,26 @@ def _assert_dpa_backend(backend: str) -> None: ) -def _dpa_tolerances(backend: str) -> dict: - """How closely the compiled backend has to match eager. +def _assert_matches_eager(actual, expected, backend: str, dtype: torch.dtype) -> None: + """Assert a compiled result matches the eager one. - FlashAttention calls the same kernel either way, so it has to match exactly. - The unfused backend is compiled by inductor, which fuses the softmax chain - differently and reassociates its sums: with an off-by-one softmax that costs - up to two bfloat16 roundings on individual elements, so it keeps the dtype - defaults. A systematic difference -- a wrong mask or scale -- lands far - outside them anyway. + FlashAttention and FusedAttention run the same kernel either way, so they + have to match exactly. Inductor reassociates the unfused backend's softmax + sums, and that error scales with the magnitude of those sums rather than + with each output element -- so the absolute tolerance comes from the + tensor's own scale, an elementwise relative tolerance being meaningless for + elements near zero. """ - return {"rtol": 0.0, "atol": 0.0} if backend == "flash" else {} + if backend != "unfused": + torch.testing.assert_close(actual, expected, rtol=0.0, atol=0.0) + return + tols = dtype_tols(dtype) + torch.testing.assert_close( + actual, + expected, + rtol=tols["rtol"], + atol=max(tols["atol"], 1e-3 * expected.abs().max().item()), + ) @pytest.mark.parametrize("backend", ["flash", "unfused"]) @@ -785,11 +794,10 @@ def test_dpa_torch_compile(monkeypatch, backend, config): torch.cuda.synchronize() _assert_dpa_backend(backend) - tols = _dpa_tolerances(backend) - torch.testing.assert_close(out, ref, **tols) + _assert_matches_eager(out, ref, backend, dtype) for tensor, ref_grad in zip(grads, ref_grads): assert tensor.grad is not None - torch.testing.assert_close(tensor.grad, ref_grad, **tols) + _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) @pytest.mark.parametrize("backend", ["flash", "unfused"]) @@ -805,7 +813,6 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): torch._dynamo.reset() compiled = torch.compile(module, fullgraph=True, mode="reduce-overhead") - tols = _dpa_tolerances(backend) for _ in range(3): # Fresh inputs every iteration: a replay that reuses stale buffers would # still produce finite values and non-None gradients, so only comparing @@ -822,9 +829,9 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): ref = module(*args, **kwargs) ref.sum().backward() - torch.testing.assert_close(out, ref, **tols) + _assert_matches_eager(out, ref, backend, dtype) for compiled_grad, tensor in zip(compiled_grads, grads): - torch.testing.assert_close(compiled_grad, tensor.grad, **tols) + _assert_matches_eager(compiled_grad, tensor.grad, backend, dtype) _assert_dpa_backend(backend) @@ -898,10 +905,9 @@ def test_dpa_torch_compile_shared_cu_seqlens(monkeypatch, backend): torch.cuda.synchronize() _assert_dpa_backend(backend) - tols = _dpa_tolerances(backend) - torch.testing.assert_close(out, ref, **tols) + _assert_matches_eager(out, ref, backend, dtype) for tensor, ref_grad in zip((q, k, v), ref_grads): - torch.testing.assert_close(tensor.grad, ref_grad, **tols) + _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) @pytest.mark.parametrize("backend", ["flash", "unfused"]) @@ -935,9 +941,8 @@ def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interlea out.sum().backward() torch.cuda.synchronize() - tols = _dpa_tolerances(backend) - torch.testing.assert_close(out, ref, **tols) - torch.testing.assert_close(qkv.grad, ref_grad, **tols) + _assert_matches_eager(out, ref, backend, dtype) + _assert_matches_eager(qkv.grad, ref_grad, backend, dtype) # The same graph break is an error when the user asked for a full graph. torch._dynamo.reset() @@ -975,9 +980,9 @@ def test_dpa_torch_compile_fused_backend(monkeypatch): torch.cuda.synchronize() _assert_dpa_backend("fused") - torch.testing.assert_close(out, ref, rtol=0.0, atol=0.0) + _assert_matches_eager(out, ref, "fused", dtype) for tensor, ref_grad in zip(grads, ref_grads): - torch.testing.assert_close(tensor.grad, ref_grad, rtol=0.0, atol=0.0) + _assert_matches_eager(tensor.grad, ref_grad, "fused", dtype) # --------------------------------------------------------------------------- From bd2c5066fd871f4b3cad37166c6a441250c0c489 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 11:53:47 +0200 Subject: [PATCH 12/29] Merge the per-backend compile tests, and give padding masks real padding The configurations built cu_seqlens with every sequence at the maximum length, so a padding mask was all-False: the padding branches ran, but on data that could not tell a difference. They now use varying sequence lengths, which immediately showed that FlashAttention on bshd/sbhd with a padding mask does not trace: it packs the tensors first, and get_indices built the index list with a Python comprehension over sequence lengths held in a GPU tensor -- a host synchronization per sequence in eager, untraceable while compiling. It is now built on the device, verified exhaustively against the previous implementation (10304 cases, 0 mismatches) and free of synchronization. The separate tests for one shared cu_seqlens tensor and for the fused backend folded into the main one: the first is a configuration, and the second is the backend axis, which now runs flash, fused and unfused with the graph break that FusedAttention's eager island implies. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 306 +++++++----------- .../attention/dot_product_attention/utils.py | 24 +- 2 files changed, 136 insertions(+), 194 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index b88222be60..397db816f4 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -562,68 +562,61 @@ def fn(q, k, v, extra): # get_available_attention_backends() answers that, so configurations only one # backend supports (arbitrary masks, biases, MLA head dims, ...) are covered # rather than avoided. +def _cfg(model_config, qkv_format="bshd", packed=None, share_cu_seqlens=False): + return dict( + model_config=model_config, + qkv_format=qkv_format, + packed=packed, + share_cu_seqlens=share_cu_seqlens, + ) + + _DPA_COMPILE_CONFIGS = { - # name: (ModelConfig, qkv_format, packed layout or None) - "self_bshd_causal": (ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), "bshd", None), - "self_sbhd_no_mask": (ModelConfig(2, 128, 4, 64, attn_mask_type="no_mask"), "sbhd", None), - "self_bshd_swa": ( - ModelConfig(2, 128, 4, 64, attn_mask_type="causal", window_size=(16, 0)), - "bshd", - None, - ), - "gqa_bshd_causal": ( - ModelConfig(2, 128, 8, 64, num_gqa_groups=2, attn_mask_type="causal"), - "bshd", - None, + "self_bshd_causal": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal")), + "self_sbhd_no_mask": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="no_mask"), "sbhd"), + "self_bshd_swa": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal", window_size=(16, 0))), + "gqa_bshd_causal": _cfg(ModelConfig(2, 128, 8, 64, num_gqa_groups=2, attn_mask_type="causal")), + "cross_bshd_no_mask": _cfg( + ModelConfig(2, 128, 4, 64, max_seqlen_kv=256, attn_mask_type="no_mask") ), - "cross_bshd_no_mask": ( - ModelConfig(2, 128, 4, 64, max_seqlen_kv=256, attn_mask_type="no_mask"), - "bshd", - None, + "self_bshd_padding_causal": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal")), + "self_thd_padding_causal": _cfg( + ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal"), "thd" ), - "self_thd_padding_causal": ( - ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal"), - "thd", - None, + # Self attention naturally passes one cu_seqlens tensor for both q and kv, + # which flash-attn hands to two inputs of the same autograd.Function. + "self_thd_shared_cu_seqlens": _cfg( + ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal"), "thd", share_cu_seqlens=True ), - "packed_qkv_bs3hd": (ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), "bshd", "bs3hd"), + "packed_qkv_bs3hd": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), packed="bs3hd"), # Configurations below are supported by one backend only, or take a code # path of their own inside DotProductAttention. - "alibi_bshd_causal": ( - ModelConfig(2, 128, 4, 64, attn_mask_type="causal", attn_bias_type="alibi"), - "bshd", - None, + "alibi_bshd_causal": _cfg( + ModelConfig(2, 128, 4, 64, attn_mask_type="causal", attn_bias_type="alibi") ), - "post_scale_bias_bshd": ( - ModelConfig(2, 128, 4, 64, attn_bias_type="post_scale_bias", bias_shape="1hss"), - "bshd", - None, + "post_scale_bias_bshd": _cfg( + ModelConfig(2, 128, 4, 64, attn_bias_type="post_scale_bias", bias_shape="1hss") ), - "arbitrary_mask_bshd": (ModelConfig(2, 128, 4, 64, attn_mask_type="arbitrary"), "bshd", None), - "mla_bshd_causal": ( - ModelConfig(2, 128, 4, 128, head_dim_v=64, attn_mask_type="causal"), - "bshd", - None, - ), - "sink_softmax_bshd": ( - ModelConfig(2, 128, 4, 64, attn_mask_type="causal", softmax_type="off-by-one"), - "bshd", - None, + "arbitrary_mask_bshd": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="arbitrary")), + "mla_bshd_causal": _cfg(ModelConfig(2, 128, 4, 128, head_dim_v=64, attn_mask_type="causal")), + "sink_softmax_bshd": _cfg( + ModelConfig(2, 128, 4, 64, attn_mask_type="causal", softmax_type="off-by-one") ), } -def _qkv_layout(qkv_format: str, packed: Optional[str]) -> str: - return packed if packed is not None else "_".join([qkv_format] * 3) +def _qkv_layout(spec: dict) -> str: + return spec["packed"] or "_".join([spec["qkv_format"]] * 3) -def _make_dpa(config: ModelConfig, qkv_format: str, dtype: torch.dtype) -> te.DotProductAttention: +def _make_dpa(spec: dict, dtype: torch.dtype) -> te.DotProductAttention: + config = spec["model_config"] return te.DotProductAttention( num_attention_heads=config.num_heads, kv_channels=config.kv_channels, num_gqa_groups=config.num_gqa_groups, attention_dropout=config.dropout_p, - qkv_format=qkv_format, + qkv_format=spec["qkv_format"], attn_mask_type=config.attn_mask_type, window_size=config.window_size, attention_type=config.attn_type, @@ -631,25 +624,51 @@ def _make_dpa(config: ModelConfig, qkv_format: str, dtype: torch.dtype) -> te.Do ).to(dtype=dtype, device="cuda") -def _make_dpa_inputs(config: ModelConfig, qkv_format: str, packed: Optional[str], dtype): +def _cu_seqlens(seqlens: torch.Tensor) -> torch.Tensor: + cu = torch.zeros(seqlens.numel() + 1, dtype=torch.int32, device="cuda") + cu[1:] = torch.cumsum(seqlens, dim=0) + return cu + + +def _make_dpa_inputs(spec: dict, dtype: torch.dtype): """Build the (args, kwargs) that `DotProductAttention.forward` is called with, plus the list of tensors whose gradients the test compares.""" + config = spec["model_config"] + qkv_format, packed = spec["qkv_format"], spec["packed"] b = config.batch_size s_q, s_kv = config.max_seqlen_q, config.max_seqlen_kv h, g = config.num_heads, config.num_gqa_groups d_qk, d_v = config.head_dim_qk, config.head_dim_v + padded = "padding" in config.attn_mask_type - def _shape(s, heads, head_dim): + kwargs = {} + if padded: + # Sequences shorter than the maximum, so the padding mask is not + # degenerate: with all sequences full it would be all-False, and any + # difference in how masking is compiled would be invisible. + seqlens_q = torch.randint(1, s_q, [b], dtype=torch.int32, device="cuda") + seqlens_kv = ( + seqlens_q + if config.attn_type == "self" + else (torch.randint(1, s_kv, [b], dtype=torch.int32, device="cuda")) + ) + cu_q = _cu_seqlens(seqlens_q) + cu_kv = cu_q if spec["share_cu_seqlens"] else _cu_seqlens(seqlens_kv) + kwargs.update(cu_seqlens_q=cu_q, cu_seqlens_kv=cu_kv, max_seqlen_q=s_q, max_seqlen_kv=s_kv) + t_q, t_kv = int(cu_q[-1]), int(cu_kv[-1]) + else: + t_q, t_kv = b * s_q, b * s_kv + + def _shape(s, t, heads, head_dim): return { "bshd": (b, s, heads, head_dim), "sbhd": (s, b, heads, head_dim), - "thd": (b * s, heads, head_dim), + "thd": (t, heads, head_dim), }[qkv_format] def _randn(shape): return torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) - kwargs = {} if packed is not None: # Declarative packed QKV: q/k/v are derived from one buffer by DPA # itself, and the layout comes from the declaration -- the only packed @@ -658,27 +677,12 @@ def _randn(shape): qkv = _randn((b, s_q, 3, h, d_qk)) kwargs["qkv_layer"] = qkv kwargs["qkv_interleave_dim"] = -3 - grad_tensors = [qkv] - args = () + grad_tensors, args = [qkv], () else: - q = _randn(_shape(s_q, h, d_qk)) - k = _randn(_shape(s_kv, g, d_qk)) - v = _randn(_shape(s_kv, g, d_v)) - grad_tensors = [q, k, v] - args = (q, k, v) - - if qkv_format == "thd": - # All sequences have the maximum length, i.e. no padding. max_seqlen_* - # is passed explicitly: deriving it from cu_seqlens costs a `.item()` - # (a device sync, and an unbacked SymInt under torch.compile). - kwargs["cu_seqlens_q"] = torch.arange( - 0, (b + 1) * s_q, step=s_q, dtype=torch.int32, device="cuda" - ) - kwargs["cu_seqlens_kv"] = torch.arange( - 0, (b + 1) * s_kv, step=s_kv, dtype=torch.int32, device="cuda" - ) - kwargs["max_seqlen_q"] = s_q - kwargs["max_seqlen_kv"] = s_kv + q = _randn(_shape(s_q, t_q, h, d_qk)) + k = _randn(_shape(s_kv, t_kv, g, d_qk)) + v = _randn(_shape(s_kv, t_kv, g, d_v)) + grad_tensors, args = [q, k, v], (q, k, v) if config.attn_mask_type == "arbitrary": kwargs["attention_mask"] = torch.zeros(b, 1, s_q, s_kv, dtype=torch.bool, device="cuda") @@ -690,11 +694,17 @@ def _randn(shape): return args, kwargs, grad_tensors -def _skip_unsupported(config: ModelConfig, qkv_layout: str, backend: str, dtype) -> None: +def _skip_unsupported(spec: dict, backend: str, dtype) -> None: """Skip configurations the backend under test cannot run at all.""" - available, _, _ = get_available_attention_backends(config, dtype, qkv_layout) - flash_supported, _, unfused_supported = available - supported = {"flash": flash_supported, "unfused": unfused_supported}[backend] + available, _, _ = get_available_attention_backends( + spec["model_config"], dtype, _qkv_layout(spec) + ) + flash_supported, fused_supported, unfused_supported = available + supported = { + "flash": flash_supported, + "fused": fused_supported, + "unfused": unfused_supported, + }[backend] if not supported: pytest.skip(f"the {backend} backend does not support this configuration") @@ -757,39 +767,22 @@ def _assert_matches_eager(actual, expected, backend: str, dtype: torch.dtype) -> ) -@pytest.mark.parametrize("backend", ["flash", "unfused"]) -@pytest.mark.parametrize("config", _DPA_COMPILE_CONFIGS.keys()) -def test_dpa_torch_compile(monkeypatch, backend, config): - """`DotProductAttention` must trace under `torch.compile(fullgraph=True)` - on the FlashAttention and UnfusedDotProductAttention backends, and match - eager in forward and backward. - - `fullgraph=True` makes the test fail on any graph break, i.e. it covers the - whole module: input unpacking, qkv layout, backend selection and the - backend itself. The FusedAttention backend is not covered -- it is an eager - island (`@no_torch_dynamo` on `FusedAttention.forward`).""" - dtype = torch.bfloat16 - model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS[config] - _skip_unsupported(model_config, _qkv_layout(qkv_format, packed), backend, dtype) - _force_dpa_backend(monkeypatch, backend) - - module = _make_dpa(model_config, qkv_format, dtype) - args, kwargs, grads = _make_dpa_inputs(model_config, qkv_format, packed, dtype) - +def _compare_compiled_to_eager( + module, args, kwargs, grads, monkeypatch, backend: str, dtype: torch.dtype, **compile_kwargs +) -> None: + """Run the module eagerly and compiled on the same inputs, and compare both + the output and every input gradient.""" ref = module(*args, **kwargs) ref.sum().backward() - _assert_dpa_backend(backend) ref_grads = [t.grad.clone() for t in grads] - for t in grads: - t.grad = None + for tensor in grads: + tensor.grad = None torch._dynamo.reset() # Force backend selection to be re-run (and traced) inside the compiled # region instead of being served from the cache the eager call populated. _force_dpa_backend(monkeypatch, backend) - compiled = torch.compile(module, fullgraph=True) - - out = compiled(*args, **kwargs) + out = torch.compile(module, **compile_kwargs)(*args, **kwargs) out.sum().backward() torch.cuda.synchronize() @@ -800,15 +793,39 @@ def test_dpa_torch_compile(monkeypatch, backend, config): _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) +@pytest.mark.parametrize("backend", ["flash", "fused", "unfused"]) +@pytest.mark.parametrize("config", _DPA_COMPILE_CONFIGS.keys()) +def test_dpa_torch_compile(monkeypatch, backend, config): + """`DotProductAttention` under `torch.compile` must match eager in forward + and backward, for every backend that supports the configuration. + + FlashAttention and UnfusedDotProductAttention are compiled with + `fullgraph=True`, so the test fails on any graph break -- covering the whole + module: input unpacking, qkv layout, backend selection and the backend + itself. FusedAttention is an eager island (`@no_torch_dynamo` on its + forward), so it is expected to graph-break and only has to stay correct. + """ + dtype = torch.bfloat16 + spec = _DPA_COMPILE_CONFIGS[config] + _skip_unsupported(spec, backend, dtype) + _force_dpa_backend(monkeypatch, backend) + + module = _make_dpa(spec, dtype) + args, kwargs, grads = _make_dpa_inputs(spec, dtype) + _compare_compiled_to_eager( + module, args, kwargs, grads, monkeypatch, backend, dtype, fullgraph=backend != "fused" + ) + + @pytest.mark.parametrize("backend", ["flash", "unfused"]) def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): """`mode="reduce-overhead"`: forward and backward of DotProductAttention are captured into CUDA graphs and replayed on subsequent iterations.""" dtype = torch.bfloat16 - model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS["self_bshd_causal"] + spec = _DPA_COMPILE_CONFIGS["self_bshd_causal"] _force_dpa_backend(monkeypatch, backend) - module = _make_dpa(model_config, qkv_format, dtype) + module = _make_dpa(spec, dtype) torch._dynamo.reset() compiled = torch.compile(module, fullgraph=True, mode="reduce-overhead") @@ -817,7 +834,7 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): # Fresh inputs every iteration: a replay that reuses stale buffers would # still produce finite values and non-None gradients, so only comparing # against eager catches it. - args, kwargs, grads = _make_dpa_inputs(model_config, qkv_format, packed, dtype) + args, kwargs, grads = _make_dpa_inputs(spec, dtype) # CUDA graphs hand back tensors owned by their memory pool, which the # next replay overwrites. out = compiled(*args, **kwargs).clone() @@ -842,11 +859,11 @@ def test_dpa_torch_compile_fp8(monkeypatch, fp8_attention): eager. FP8 elsewhere in the model with attention in high precision -- the common training setup -- must stay compiled.""" dtype = torch.bfloat16 - model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS["self_bshd_causal"] + spec = _DPA_COMPILE_CONFIGS["self_bshd_causal"] _force_dpa_backend(monkeypatch, "unfused") - module = _make_dpa(model_config, qkv_format, dtype) - args, kwargs, _ = _make_dpa_inputs(model_config, qkv_format, packed, dtype) + module = _make_dpa(spec, dtype) + args, kwargs, _ = _make_dpa_inputs(spec, dtype) fp8_recipe = recipe.DelayedScaling(fp8_dpa=fp8_attention) def fn(*args, **kwargs): @@ -868,48 +885,6 @@ def fn(*args, **kwargs): assert fell_back == fp8_attention -@pytest.mark.parametrize("backend", ["flash", "unfused"]) -def test_dpa_torch_compile_shared_cu_seqlens(monkeypatch, backend): - """Self attention on `thd` naturally passes one cu_seqlens tensor for both q - and kv, which flash-attn hands to two inputs of the same autograd.Function -- - something dynamo cannot trace. The other tests build two tensors, so this - covers the shape a caller is most likely to write.""" - dtype = torch.bfloat16 - model_config, qkv_format, _ = _DPA_COMPILE_CONFIGS["self_thd_padding_causal"] - _force_dpa_backend(monkeypatch, backend) - - module = _make_dpa(model_config, qkv_format, dtype) - b, s = model_config.batch_size, model_config.max_seqlen_q - h, d = model_config.num_heads, model_config.head_dim_qk - q, k, v = [ - torch.randn(b * s, h, d, dtype=dtype, device="cuda", requires_grad=True) for _ in range(3) - ] - cu_seqlens = torch.arange(0, (b + 1) * s, step=s, dtype=torch.int32, device="cuda") - kwargs = dict( - cu_seqlens_q=cu_seqlens, - cu_seqlens_kv=cu_seqlens, # the same tensor, not a copy - max_seqlen_q=s, - max_seqlen_kv=s, - ) - - ref = module(q, k, v, **kwargs) - ref.sum().backward() - ref_grads = [t.grad.clone() for t in (q, k, v)] - for t in (q, k, v): - t.grad = None - - torch._dynamo.reset() - _force_dpa_backend(monkeypatch, backend) - out = torch.compile(module, fullgraph=True)(q, k, v, **kwargs) - out.sum().backward() - torch.cuda.synchronize() - - _assert_dpa_backend(backend) - _assert_matches_eager(out, ref, backend, dtype) - for tensor, ref_grad in zip((q, k, v), ref_grads): - _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) - - @pytest.mark.parametrize("backend", ["flash", "unfused"]) @pytest.mark.parametrize("interleave_dim", [-3, -2]) def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interleave_dim): @@ -919,12 +894,13 @@ def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interlea the packing via qkv_layer/kv_layer keeps it on the compiled path, which test_dpa_torch_compile covers.""" dtype = torch.bfloat16 - model_config, qkv_format, _ = _DPA_COMPILE_CONFIGS["self_bshd_causal"] + spec = _DPA_COMPILE_CONFIGS["self_bshd_causal"] + config = spec["model_config"] _force_dpa_backend(monkeypatch, backend) - module = _make_dpa(model_config, qkv_format, dtype) - b, s = model_config.batch_size, model_config.max_seqlen_q - h, d = model_config.num_heads, model_config.head_dim_qk + module = _make_dpa(spec, dtype) + b, s = config.batch_size, config.max_seqlen_q + h, d = config.num_heads, config.head_dim_qk shape = (b, s, 3, h, d) if interleave_dim == -3 else (b, s, h, 3, d) qkv = torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) q, k, v = [qkv.select(interleave_dim, i) for i in range(3)] @@ -951,40 +927,6 @@ def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interlea torch.compile(module, fullgraph=True)(q, k, v) -def test_dpa_torch_compile_fused_backend(monkeypatch): - """The FusedAttention backend is an eager island, so it graph-breaks rather - than compiles; DotProductAttention must still be correct around it.""" - dtype = torch.bfloat16 - model_config, qkv_format, packed = _DPA_COMPILE_CONFIGS["self_bshd_causal"] - _force_dpa_backend(monkeypatch, "fused") - - module = _make_dpa(model_config, qkv_format, dtype) - args, kwargs, grads = _make_dpa_inputs(model_config, qkv_format, packed, dtype) - - ref = module(*args, **kwargs) - from transformer_engine.pytorch.attention.dot_product_attention.dot_product_attention import ( - _attention_backends, - ) - - if not _attention_backends["use_fused_attention"]: - pytest.skip("FusedAttention does not support this config on this device") - ref.sum().backward() - ref_grads = [t.grad.clone() for t in grads] - for t in grads: - t.grad = None - - torch._dynamo.reset() - _force_dpa_backend(monkeypatch, "fused") - out = torch.compile(module)(*args, **kwargs) - out.sum().backward() - torch.cuda.synchronize() - - _assert_dpa_backend("fused") - _assert_matches_eager(out, ref, "fused", dtype) - for tensor, ref_grad in zip(grads, ref_grads): - _assert_matches_eager(tensor.grad, ref_grad, "fused", dtype) - - # --------------------------------------------------------------------------- # get_attention_backend under torch.compile # --------------------------------------------------------------------------- diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 95b5a7f15f..383afebd69 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -2005,21 +2005,21 @@ def get_indices(max_seqlen: int, cu_seqlens: torch.Tensor) -> torch.Tensor: tensor of shape [batch_size * max_seqlen, 1, 1] containing the indices for the valid tokens in a batch. """ + # Built with device-side ops only: reading the sequence lengths on the host + # would synchronize the device once per sequence. bs = len(cu_seqlens) - 1 seqlens = cu_seqlens[1:] - cu_seqlens[:-1] - indices = [i * max_seqlen + ii for i, j in enumerate(seqlens) for ii in range(j)] - indices = torch.Tensor(indices).unsqueeze(1).unsqueeze(1).to(dtype=torch.int64, device="cuda") - - num_nonzeros = indices.shape[0] - pad_amount = bs * max_seqlen - num_nonzeros - indices = F.pad( - input=indices, - pad=(0, 0, 0, 0, 0, pad_amount), - mode="constant", - value=float(bs * max_seqlen), + positions = torch.arange(max_seqlen, device=cu_seqlens.device) + valid = (positions.unsqueeze(0) < seqlens.unsqueeze(1)).flatten() + # Sorting the mask is stable, so the valid positions come first in order, + # and the invalid tail is replaced by the out-of-range padding index. + ordered = torch.argsort(~valid, stable=True) + indices = torch.where( + torch.arange(bs * max_seqlen, device=cu_seqlens.device) < valid.sum(), + ordered, + bs * max_seqlen, ) - - return indices + return indices.to(dtype=torch.int64, device="cuda").unsqueeze(1).unsqueeze(1) def get_full_cu_seqlens( From 725931ec24db7425e0f008444b65b89571692a07 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 12:11:58 +0200 Subject: [PATCH 13/29] Run eagerly when max_seqlen has to be derived from cu_seqlens On thd inputs without max_seqlen, DotProductAttention reads the sequence lengths off cu_seqlens to derive it. Unlike the padding mask and the packing indices, this one cannot be moved to the device: max_seqlen sizes tensors downstream, so it has to be a Python int, and reading it is a device synchronization -- a data-dependent value dynamo cannot trace. Route those calls to the eager island, so passing max_seqlen is what keeps a call compiled. Arguments are now looked up by name or position, so the predicate sees them whichever way the caller passed them. The two eager-fallback tests merge into one: they set up different inputs but assert the same thing -- a warning, results matching eager, and an error under fullgraph. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 70 +++++++++++++------ .../dot_product_attention.py | 44 ++++++++++-- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 397db816f4..ea21cdd2ed 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -885,46 +885,76 @@ def fn(*args, **kwargs): assert fell_back == fp8_attention -@pytest.mark.parametrize("backend", ["flash", "unfused"]) -@pytest.mark.parametrize("interleave_dim", [-3, -2]) -def test_dpa_torch_compile_packed_views_fall_back(monkeypatch, backend, interleave_dim): - """Packed q/k/v passed as plain views cannot be recognized while tracing (the - detection reads data pointers), so DotProductAttention runs that detection - eagerly instead -- correct results, at the cost of a graph break. Declaring - the packing via qkv_layer/kv_layer keeps it on the compiled path, which - test_dpa_torch_compile covers.""" - dtype = torch.bfloat16 - spec = _DPA_COMPILE_CONFIGS["self_bshd_causal"] +def _packed_views_inputs(spec, dtype, interleave_dim=-3): + """Packed q/k/v handed over as plain views, without declaring the packing.""" config = spec["model_config"] - _force_dpa_backend(monkeypatch, backend) - - module = _make_dpa(spec, dtype) b, s = config.batch_size, config.max_seqlen_q h, d = config.num_heads, config.head_dim_qk shape = (b, s, 3, h, d) if interleave_dim == -3 else (b, s, h, 3, d) qkv = torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) q, k, v = [qkv.select(interleave_dim, i) for i in range(3)] + return (q, k, v), {}, [qkv] + + +def _thd_without_max_seqlen_inputs(spec, dtype): + """thd inputs that leave max_seqlen to be derived from cu_seqlens.""" + args, kwargs, grads = _make_dpa_inputs(spec, dtype) + del kwargs["max_seqlen_q"], kwargs["max_seqlen_kv"] + return args, kwargs, grads + + +_EAGER_FALLBACK_CASES = { + # name: (config name, input builder) + "packed_views_bs3hd": ( + "self_bshd_causal", + lambda spec, dtype: _packed_views_inputs(spec, dtype, -3), + ), + "packed_views_bsh3d": ( + "self_bshd_causal", + lambda spec, dtype: _packed_views_inputs(spec, dtype, -2), + ), + "thd_without_max_seqlen": ("self_thd_padding_causal", _thd_without_max_seqlen_inputs), +} - ref = module(q, k, v) + +@pytest.mark.parametrize("backend", ["flash", "unfused"]) +@pytest.mark.parametrize("case", _EAGER_FALLBACK_CASES.keys()) +def test_dpa_torch_compile_eager_fallback(monkeypatch, backend, case): + """Calls that cannot be traced run as an eager island instead, with a + warning: recognizing packed q/k/v takes the data pointers dynamo cannot + read, and deriving max_seqlen off cu_seqlens is a device synchronization. + Both keep working, and both are avoidable -- by declaring the packing via + qkv_layer/kv_layer, or by passing max_seqlen.""" + dtype = torch.bfloat16 + config_name, make_inputs = _EAGER_FALLBACK_CASES[case] + spec = _DPA_COMPILE_CONFIGS[config_name] + _force_dpa_backend(monkeypatch, backend) + + module = _make_dpa(spec, dtype) + args, kwargs, grads = make_inputs(spec, dtype) + + ref = module(*args, **kwargs) ref.sum().backward() - ref_grad = qkv.grad.clone() - qkv.grad = None + ref_grads = [t.grad.clone() for t in grads] + for tensor in grads: + tensor.grad = None torch._dynamo.reset() _force_dpa_backend(monkeypatch, backend) with pytest.warns(UserWarning, match="Falling back to eager execution"): - out = torch.compile(module)(q, k, v) + out = torch.compile(module)(*args, **kwargs) out.sum().backward() torch.cuda.synchronize() _assert_matches_eager(out, ref, backend, dtype) - _assert_matches_eager(qkv.grad, ref_grad, backend, dtype) + for tensor, ref_grad in zip(grads, ref_grads): + _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) - # The same graph break is an error when the user asked for a full graph. + # The same eager island is an error when the user asked for a full graph. torch._dynamo.reset() _force_dpa_backend(monkeypatch, backend) with pytest.raises(Exception, match="torch.compiler.disable"): - torch.compile(module, fullgraph=True)(q, k, v) + torch.compile(module, fullgraph=True)(*args, **kwargs) # --------------------------------------------------------------------------- diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index f82aa91c13..4e0697fcd7 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -197,12 +197,32 @@ def _trim_output(attn_out, num_attention_heads, padded_head_dim_v, orig_head_dim return attn_out[..., :orig_head_dim_v].reshape(*out_shape, -1) +# Positions of the `DotProductAttention.forward` arguments the predicate below +# reads, so that they are found whether they were passed by name or by position. +_FORWARD_ARG_POSITIONS = { + "query_layer": 1, + "key_layer": 2, + "value_layer": 3, + "qkv_format": 5, + "max_seqlen_q": 10, + "max_seqlen_kv": 11, + "qkv_layer": 28, + "kv_layer": 29, +} + + +def _forward_arg(name: str, args: tuple, kwargs: dict) -> Any: + if name in kwargs: + return kwargs[name] + position = _FORWARD_ARG_POSITIONS[name] + return args[position] if len(args) > position else None + + def _needs_eager_dpa(*args, **kwargs) -> Optional[str]: """Why this DotProductAttention call has to run outside the graph, or None. - Takes `DotProductAttention.forward`'s arguments as they were passed, so q/k/v - are read from either side: `args[0]` is `self`, followed by the positional - query/key/value. + Takes `DotProductAttention.forward`'s arguments as they were passed, with + `args[0]` being `self`. """ # FP8 GEMMs with the attention itself in high precision -- the common # training setup -- stay on the compiled path; only FP8 attention bails out. @@ -212,9 +232,21 @@ def _needs_eager_dpa(*args, **kwargs) -> Optional[str]: if fp8_recipe.fp8_dpa or fp8_recipe.fp8_mha: return "FP8 attention" - if kwargs.get("qkv_layer") is None and kwargs.get("kv_layer") is None: - qkv = list(args[1:4]) + [ - kwargs.get(name) for name in ("query_layer", "key_layer", "value_layer") + qkv_format = _forward_arg("qkv_format", args, kwargs) or args[0].qkv_format + if qkv_format == "thd" and ( + _forward_arg("max_seqlen_q", args, kwargs) is None + or _forward_arg("max_seqlen_kv", args, kwargs) is None + ): + # Deriving it reads the sequence lengths off cu_seqlens, which is a + # device synchronization and a data-dependent value while tracing. + return "deriving max_seqlen from cu_seqlens" + + if ( + _forward_arg("qkv_layer", args, kwargs) is None + and _forward_arg("kv_layer", args, kwargs) is None + ): + qkv = [ + _forward_arg(name, args, kwargs) for name in ("query_layer", "key_layer", "value_layer") ] if dpa_utils.qkv_layout_needs_detection(*qkv): return "detecting packed q/k/v that were not declared via qkv_layer/kv_layer" From b2ab7ebad5034690f51ee8d6064ff0f3fbfa20fb Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 13:54:50 +0200 Subject: [PATCH 14/29] Share the run-and-compare code between the compile tests Both tests ran a callable, went backward, copied the gradients out and cleared them. That is now _run_and_capture(), with _assert_run_matches() comparing two of its results, which halves the CUDA-graphs test. The two stay separate tests: what CUDA graphs risk -- a replay reading stale buffers -- does not depend on the attention configuration, so folding the mode into the configuration matrix would multiply the cases without covering anything new, and would report a replay bug as a failure of some unrelated configuration. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 59 ++++++++++++++++------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index ea21cdd2ed..4be4ec9fea 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -767,30 +767,48 @@ def _assert_matches_eager(actual, expected, backend: str, dtype: torch.dtype) -> ) +def _run_and_capture(fn, args, kwargs, grads): + """Run `fn` and backward through its output, returning the output and a copy + of every input gradient. + + The output is cloned because CUDA graphs hand back tensors owned by their + memory pool, which the next replay overwrites, and the gradients are cleared + so that the same inputs can be reused for the other run. + """ + out = fn(*args, **kwargs).clone() + out.sum().backward() + torch.cuda.synchronize() + captured = [] + for tensor in grads: + assert tensor.grad is not None + captured.append(tensor.grad.clone()) + tensor.grad = None + return out, captured + + +def _assert_run_matches(actual, expected, backend: str, dtype: torch.dtype) -> None: + """Compare an (output, gradients) pair produced by `_run_and_capture`.""" + (out, out_grads), (ref, ref_grads) = actual, expected + _assert_matches_eager(out, ref, backend, dtype) + for out_grad, ref_grad in zip(out_grads, ref_grads): + _assert_matches_eager(out_grad, ref_grad, backend, dtype) + + def _compare_compiled_to_eager( module, args, kwargs, grads, monkeypatch, backend: str, dtype: torch.dtype, **compile_kwargs ) -> None: """Run the module eagerly and compiled on the same inputs, and compare both the output and every input gradient.""" - ref = module(*args, **kwargs) - ref.sum().backward() - ref_grads = [t.grad.clone() for t in grads] - for tensor in grads: - tensor.grad = None + eager = _run_and_capture(module, args, kwargs, grads) torch._dynamo.reset() # Force backend selection to be re-run (and traced) inside the compiled # region instead of being served from the cache the eager call populated. _force_dpa_backend(monkeypatch, backend) - out = torch.compile(module, **compile_kwargs)(*args, **kwargs) - out.sum().backward() - torch.cuda.synchronize() + compiled = _run_and_capture(torch.compile(module, **compile_kwargs), args, kwargs, grads) _assert_dpa_backend(backend) - _assert_matches_eager(out, ref, backend, dtype) - for tensor, ref_grad in zip(grads, ref_grads): - assert tensor.grad is not None - _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) + _assert_run_matches(compiled, eager, backend, dtype) @pytest.mark.parametrize("backend", ["flash", "fused", "unfused"]) @@ -835,20 +853,9 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): # still produce finite values and non-None gradients, so only comparing # against eager catches it. args, kwargs, grads = _make_dpa_inputs(spec, dtype) - # CUDA graphs hand back tensors owned by their memory pool, which the - # next replay overwrites. - out = compiled(*args, **kwargs).clone() - out.sum().backward() - torch.cuda.synchronize() - compiled_grads = [t.grad.clone() for t in grads] - for tensor in grads: - tensor.grad = None - - ref = module(*args, **kwargs) - ref.sum().backward() - _assert_matches_eager(out, ref, backend, dtype) - for compiled_grad, tensor in zip(compiled_grads, grads): - _assert_matches_eager(compiled_grad, tensor.grad, backend, dtype) + replayed = _run_and_capture(compiled, args, kwargs, grads) + eager = _run_and_capture(module, args, kwargs, grads) + _assert_run_matches(replayed, eager, backend, dtype) _assert_dpa_backend(backend) From 50d19848ba97743cfe06ed619984792fbe9b3ecb Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 13:57:46 +0200 Subject: [PATCH 15/29] Use the shared run-and-compare code in the remaining compile tests The eager-fallback test hand-rolled the same run, backward and gradient copy. The backend-level unfused test only checked that the output was finite and that gradients existed, which a CUDA-graph replay reading stale buffers also satisfies -- the weakness the DotProductAttention CUDA-graphs test was given an eager comparison for. It now compares against eager as well. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 4be4ec9fea..8e3617af92 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -542,14 +542,13 @@ def fn(q, k, v, extra): compiled = torch.compile(fn, fullgraph=True, mode="reduce-overhead") for _ in range(3): + # Compared against eager rather than only checked for finiteness: a + # replay reusing stale buffers would pass the latter. q, k, v, extra, _ = _make_unfused_qkv(qkv_layout, dtype, requires_grad=True) - out = compiled(q, k, v, extra) - out.sum().backward() - torch.cuda.synchronize() - assert torch.isfinite(out).all() - assert q.grad is not None - assert k.grad is not None - assert v.grad is not None + inputs = (q, k, v, extra) + replayed = _run_and_capture(compiled, inputs, {}, [q, k, v]) + eager = _run_and_capture(fn, inputs, {}, [q, k, v]) + _assert_run_matches(replayed, eager, "unfused", dtype) # --------------------------------------------------------------------------- @@ -940,22 +939,13 @@ def test_dpa_torch_compile_eager_fallback(monkeypatch, backend, case): module = _make_dpa(spec, dtype) args, kwargs, grads = make_inputs(spec, dtype) - ref = module(*args, **kwargs) - ref.sum().backward() - ref_grads = [t.grad.clone() for t in grads] - for tensor in grads: - tensor.grad = None + eager = _run_and_capture(module, args, kwargs, grads) torch._dynamo.reset() _force_dpa_backend(monkeypatch, backend) with pytest.warns(UserWarning, match="Falling back to eager execution"): - out = torch.compile(module)(*args, **kwargs) - out.sum().backward() - torch.cuda.synchronize() - - _assert_matches_eager(out, ref, backend, dtype) - for tensor, ref_grad in zip(grads, ref_grads): - _assert_matches_eager(tensor.grad, ref_grad, backend, dtype) + fell_back = _run_and_capture(torch.compile(module), args, kwargs, grads) + _assert_run_matches(fell_back, eager, backend, dtype) # The same eager island is an error when the user asked for a full graph. torch._dynamo.reset() From f19c52d6e58f0c4eeca4a557df39c91a718f64fc Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 14:05:10 +0200 Subject: [PATCH 16/29] Read the predicate's arguments by name, not by hardcoded position The positions of forward's parameters were written out as a table of indices, which anyone inserting a parameter into a thirty-argument signature would silently invalidate: the predicate would go on reading whatever now sits at index 5 or 10 and decide the fallback on it, with nothing to notice. The decorator already has the function it wraps, so it takes the parameter names from its signature and hands the predicate the call's arguments keyed by name. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention.py | 57 +++++++------------ 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 4e0697fcd7..4d7c66d22d 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -5,6 +5,7 @@ """Attention.""" from contextlib import nullcontext from functools import wraps +import inspect import math import os from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -197,32 +198,11 @@ def _trim_output(attn_out, num_attention_heads, padded_head_dim_v, orig_head_dim return attn_out[..., :orig_head_dim_v].reshape(*out_shape, -1) -# Positions of the `DotProductAttention.forward` arguments the predicate below -# reads, so that they are found whether they were passed by name or by position. -_FORWARD_ARG_POSITIONS = { - "query_layer": 1, - "key_layer": 2, - "value_layer": 3, - "qkv_format": 5, - "max_seqlen_q": 10, - "max_seqlen_kv": 11, - "qkv_layer": 28, - "kv_layer": 29, -} - - -def _forward_arg(name: str, args: tuple, kwargs: dict) -> Any: - if name in kwargs: - return kwargs[name] - position = _FORWARD_ARG_POSITIONS[name] - return args[position] if len(args) > position else None - - -def _needs_eager_dpa(*args, **kwargs) -> Optional[str]: +def _needs_eager_dpa(call: Dict[str, Any]) -> Optional[str]: """Why this DotProductAttention call has to run outside the graph, or None. - Takes `DotProductAttention.forward`'s arguments as they were passed, with - `args[0]` being `self`. + `call` maps `DotProductAttention.forward`'s parameter names to the arguments + this call passed, including `self`. """ # FP8 GEMMs with the attention itself in high precision -- the common # training setup -- stay on the compiled path; only FP8 attention bails out. @@ -232,32 +212,33 @@ def _needs_eager_dpa(*args, **kwargs) -> Optional[str]: if fp8_recipe.fp8_dpa or fp8_recipe.fp8_mha: return "FP8 attention" - qkv_format = _forward_arg("qkv_format", args, kwargs) or args[0].qkv_format + qkv_format = call.get("qkv_format") or call["self"].qkv_format if qkv_format == "thd" and ( - _forward_arg("max_seqlen_q", args, kwargs) is None - or _forward_arg("max_seqlen_kv", args, kwargs) is None + call.get("max_seqlen_q") is None or call.get("max_seqlen_kv") is None ): # Deriving it reads the sequence lengths off cu_seqlens, which is a # device synchronization and a data-dependent value while tracing. return "deriving max_seqlen from cu_seqlens" - if ( - _forward_arg("qkv_layer", args, kwargs) is None - and _forward_arg("kv_layer", args, kwargs) is None - ): - qkv = [ - _forward_arg(name, args, kwargs) for name in ("query_layer", "key_layer", "value_layer") - ] + if call.get("qkv_layer") is None and call.get("kv_layer") is None: + qkv = [call.get(name) for name in ("query_layer", "key_layer", "value_layer")] if dpa_utils.qkv_layout_needs_detection(*qkv): return "detecting packed q/k/v that were not declared via qkv_layer/kv_layer" return None -def _eager_under_compile_if(needs_eager: Callable[..., Optional[str]]): +def _eager_under_compile_if(needs_eager: Callable[[Dict[str, Any]], Optional[str]]): """Decorator running the wrapped method eagerly whenever `needs_eager` gives - a reason why the call is unsupported on the compiled path.""" + a reason why the call is unsupported on the compiled path. + + `needs_eager` is handed the call's arguments keyed by parameter name, taken + from the wrapped signature, so it does not have to care whether the caller + passed them by name or by position. + """ def decorator(fn): + parameter_names = list(inspect.signature(fn).parameters) + # The warning belongs inside the dynamo-disabled function: warnings.warn # graph-breaks on its own, masking the break that matters. @no_torch_dynamo() @@ -272,7 +253,9 @@ def eager_fn(reason, *args, **kwargs): @wraps(fn) def wrapper(*args, **kwargs): if torch.compiler.is_compiling(): - reason = needs_eager(*args, **kwargs) + call = dict(zip(parameter_names, args)) + call.update(kwargs) + reason = needs_eager(call) if reason is not None: return eager_fn(reason, *args, **kwargs) return fn(*args, **kwargs) From fbc7146e78d465c118ed7312d58983460cf7559f Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 14:12:38 +0200 Subject: [PATCH 17/29] Move the eager-fallback decorator to jit.py Nothing about it is specific to attention: it takes a predicate, and runs the wrapped method as an eager island whenever that predicate gives a reason. It belongs next to no_torch_dynamo, which it builds on, rather than buried in the attention module where the modules that will need it next -- MultiheadAttention and TransformerLayer, both of which still have blockers of their own -- would not find it. What stays in the attention module is the predicate, which is where the attention-specific knowledge lives. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention.py | 44 +------------------ transformer_engine/pytorch/jit.py | 42 +++++++++++++++++- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 4d7c66d22d..5f6628c951 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -4,8 +4,6 @@ """Attention.""" from contextlib import nullcontext -from functools import wraps -import inspect import math import os from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -44,7 +42,7 @@ CudaRNGStatesTracker, graph_safe_rng_available, ) -from transformer_engine.pytorch.jit import no_torch_dynamo +from transformer_engine.pytorch.jit import eager_under_compile_if from transformer_engine.pytorch.graph import is_graph_capturing from transformer_engine.pytorch.attention.inference import InferenceParams @@ -227,44 +225,6 @@ def _needs_eager_dpa(call: Dict[str, Any]) -> Optional[str]: return None -def _eager_under_compile_if(needs_eager: Callable[[Dict[str, Any]], Optional[str]]): - """Decorator running the wrapped method eagerly whenever `needs_eager` gives - a reason why the call is unsupported on the compiled path. - - `needs_eager` is handed the call's arguments keyed by parameter name, taken - from the wrapped signature, so it does not have to care whether the caller - passed them by name or by position. - """ - - def decorator(fn): - parameter_names = list(inspect.signature(fn).parameters) - - # The warning belongs inside the dynamo-disabled function: warnings.warn - # graph-breaks on its own, masking the break that matters. - @no_torch_dynamo() - def eager_fn(reason, *args, **kwargs): - warnings.warn( - f"Falling back to eager execution under torch.compile: {reason} is" - " unsupported on the compiled path (graph-breaks under fullgraph=True).", - stacklevel=3, - ) - return fn(*args, **kwargs) - - @wraps(fn) - def wrapper(*args, **kwargs): - if torch.compiler.is_compiling(): - call = dict(zip(parameter_names, args)) - call.update(kwargs) - reason = needs_eager(call) - if reason is not None: - return eager_fn(reason, *args, **kwargs) - return fn(*args, **kwargs) - - return wrapper - - return decorator - - def _unpack_packed_qkv( qkv_layer: Optional[torch.Tensor], kv_layer: Optional[torch.Tensor], @@ -1167,7 +1127,7 @@ def get_quantizer_roles( ] return base[:num_quantizers] - @_eager_under_compile_if(_needs_eager_dpa) + @eager_under_compile_if(_needs_eager_dpa) def forward( self, query_layer: Optional[torch.Tensor] = None, diff --git a/transformer_engine/pytorch/jit.py b/transformer_engine/pytorch/jit.py index 1b93b8254c..2d0ec02d8d 100644 --- a/transformer_engine/pytorch/jit.py +++ b/transformer_engine/pytorch/jit.py @@ -3,9 +3,11 @@ # See LICENSE for license information. """NVFuser functions and JIT utilities""" +import inspect import os +import warnings from functools import wraps -from typing import Callable, Optional, Tuple +from typing import Any, Callable, Dict, Optional, Tuple import torch from .torch_version import torch_version @@ -77,6 +79,44 @@ def no_torch_dynamo(recursive=True): # pylint: disable=unused-argument return lambda func: func +def eager_under_compile_if(needs_eager: Callable[[Dict[str, Any]], Optional[str]]): + """Decorator running the wrapped method eagerly whenever `needs_eager` gives + a reason why the call is unsupported on the compiled path. + + `needs_eager` is handed the call's arguments keyed by parameter name, taken + from the wrapped signature, so it does not have to care whether the caller + passed them by name or by position. Returning None keeps the call compiled. + """ + + def decorator(fn): + parameter_names = list(inspect.signature(fn).parameters) + + # The warning belongs inside the dynamo-disabled function: warnings.warn + # graph-breaks on its own, masking the break that matters. + @no_torch_dynamo() + def eager_fn(reason, *args, **kwargs): + warnings.warn( + f"Falling back to eager execution under torch.compile: {reason} is" + " unsupported on the compiled path (graph-breaks under fullgraph=True).", + stacklevel=3, + ) + return fn(*args, **kwargs) + + @wraps(fn) + def wrapper(*args, **kwargs): + if torch.compiler.is_compiling(): + call = dict(zip(parameter_names, args)) + call.update(kwargs) + reason = needs_eager(call) + if reason is not None: + return eager_fn(reason, *args, **kwargs) + return fn(*args, **kwargs) + + return wrapper + + return decorator + + def set_jit_fusion_options() -> None: """Set PyTorch JIT layer fusion options.""" # flags required to enable jit fusion kernels From 6b246303b847db73c1a71312915bccec1c3514e0 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Wed, 29 Jul 2026 14:40:19 +0200 Subject: [PATCH 18/29] Skip FusedAttention rather than special-casing it in the compile tests The test compiled every backend with fullgraph=True except fused, which was passed fullgraph=False because its forward is an eager island. That put an assumption about one backend into the comparison logic. FusedAttention is skipped instead, next to the configurations a backend cannot run, with the reason spelled out. Everything else is compiled the same way, and supporting fused later is deleting the skip. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index 8e3617af92..e32304af1b 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -694,7 +694,12 @@ def _randn(shape): def _skip_unsupported(spec: dict, backend: str, dtype) -> None: - """Skip configurations the backend under test cannot run at all.""" + """Skip what the backend under test cannot run, or cannot compile.""" + if backend == "fused": + # FusedAttention's forward carries @no_torch_dynamo, so there is nothing + # to compile: it runs as an eager island. Drop this skip once it traces, + # and the tests below cover it as they do the others. + pytest.skip("FusedAttention is an eager island and does not compile") available, _, _ = get_available_attention_backends( spec["model_config"], dtype, _qkv_layout(spec) ) @@ -813,14 +818,13 @@ def _compare_compiled_to_eager( @pytest.mark.parametrize("backend", ["flash", "fused", "unfused"]) @pytest.mark.parametrize("config", _DPA_COMPILE_CONFIGS.keys()) def test_dpa_torch_compile(monkeypatch, backend, config): - """`DotProductAttention` under `torch.compile` must match eager in forward - and backward, for every backend that supports the configuration. - - FlashAttention and UnfusedDotProductAttention are compiled with - `fullgraph=True`, so the test fails on any graph break -- covering the whole - module: input unpacking, qkv layout, backend selection and the backend - itself. FusedAttention is an eager island (`@no_torch_dynamo` on its - forward), so it is expected to graph-break and only has to stay correct. + """`DotProductAttention` under `torch.compile(fullgraph=True)` must match + eager in forward and backward, for every backend that supports the + configuration. + + `fullgraph=True` makes the test fail on any graph break, so it covers the + whole module: input unpacking, qkv layout, backend selection and the backend + itself. """ dtype = torch.bfloat16 spec = _DPA_COMPILE_CONFIGS[config] @@ -830,7 +834,7 @@ def test_dpa_torch_compile(monkeypatch, backend, config): module = _make_dpa(spec, dtype) args, kwargs, grads = _make_dpa_inputs(spec, dtype) _compare_compiled_to_eager( - module, args, kwargs, grads, monkeypatch, backend, dtype, fullgraph=backend != "fused" + module, args, kwargs, grads, monkeypatch, backend, dtype, fullgraph=True ) From 9a204b8e0a97237a85ee7a99ea85c7091b0fc6e4 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 11:39:18 +0200 Subject: [PATCH 19/29] Cover the compiled path around FusedAttention FusedAttention is skipped from the backend matrix because it does not compile, but it is the default on Hopper and Blackwell, and this change does affect it: what used to be one eager island is now a traced prologue, a graph break at the backend, and a traced remainder. Whatever crosses that break has to survive it, which the sub-backend enum did not. One test covers it, without a fullgraph and outside the matrix. Verified to fail with the enum restored -- TypeError: int() argument must be ... not 'function', from cuDNN's entry point. Also rename _distinct_cu_seqlens: it returns its arguments untouched in eager and whenever they already differ, so it promised more than it does. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 24 ++++++++++++++++--- .../dot_product_attention/backends.py | 8 +++---- .../dot_product_attention.py | 3 --- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index e32304af1b..d20a0e70dc 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -693,9 +693,10 @@ def _randn(shape): return args, kwargs, grad_tensors -def _skip_unsupported(spec: dict, backend: str, dtype) -> None: - """Skip what the backend under test cannot run, or cannot compile.""" - if backend == "fused": +def _skip_unsupported(spec: dict, backend: str, dtype, compiled: bool = True) -> None: + """Skip what the backend under test cannot run, or -- for a test that + compiles it -- cannot be compiled.""" + if compiled and backend == "fused": # FusedAttention's forward carries @no_torch_dynamo, so there is nothing # to compile: it runs as an eager island. Drop this skip once it traces, # and the tests below cover it as they do the others. @@ -838,6 +839,23 @@ def test_dpa_torch_compile(monkeypatch, backend, config): ) +def test_dpa_torch_compile_around_fused(monkeypatch): + """FusedAttention itself is an eager island, but everything around it is + compiled: DotProductAttention traces up to the backend call, breaks the + graph there and resumes afterwards. What crosses that break has to survive + it -- the sub-backend enum did not, and reached cuDNN as the function that + produced it.""" + dtype = torch.bfloat16 + spec = _DPA_COMPILE_CONFIGS["self_bshd_causal"] + _skip_unsupported(spec, "fused", dtype, compiled=False) + _force_dpa_backend(monkeypatch, "fused") + + module = _make_dpa(spec, dtype) + args, kwargs, grads = _make_dpa_inputs(spec, dtype) + # No fullgraph: the graph break at the eager island is the point here. + _compare_compiled_to_eager(module, args, kwargs, grads, monkeypatch, "fused", dtype) + + @pytest.mark.parametrize("backend", ["flash", "unfused"]) def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): """`mode="reduce-overhead"`: forward and backward of DotProductAttention diff --git a/transformer_engine/pytorch/attention/dot_product_attention/backends.py b/transformer_engine/pytorch/attention/dot_product_attention/backends.py index 2c47ff8a4b..04bcabe7c4 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/backends.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/backends.py @@ -793,8 +793,8 @@ def backward( return dq, dk, dv -def _distinct_cu_seqlens(cu_q: torch.Tensor, cu_kv: torch.Tensor): - """Make q's and kv's cumulative sequence lengths distinct objects. +def _maybe_unshare_cu_seqlens(cu_q: torch.Tensor, cu_kv: torch.Tensor): + """Copy kv's cumulative sequence lengths when they are q's as well. Self attention passes one tensor for both, and flash-attn forwards them to two inputs of the same autograd.Function -- which dynamo cannot trace. In @@ -1134,7 +1134,7 @@ def forward( else: func = flash_attn_with_kvcache_v3 # pylint: disable=possibly-used-before-assignment if not use_flash_attn_4 and (not use_flash_attn_3 or inference_params is None): - cu_q, cu_kv = _distinct_cu_seqlens( + cu_q, cu_kv = _maybe_unshare_cu_seqlens( cu_seqlens_q_padded if pad_between_seqs else cu_seqlens_q, cu_seqlens_kv_padded if pad_between_seqs else cu_seqlens_kv, ) @@ -1150,7 +1150,7 @@ def forward( if inference_params is None: fa_4_optional_forward_kwargs["deterministic"] = self.deterministic if func is flash_attn_varlen_func_v4: - cu_q, cu_kv = _distinct_cu_seqlens(cu_seqlens_q, cu_seqlens_kv) + cu_q, cu_kv = _maybe_unshare_cu_seqlens(cu_seqlens_q, cu_seqlens_kv) fa_4_optional_forward_kwargs["cu_seqlens_q"] = cu_q fa_4_optional_forward_kwargs["cu_seqlens_k"] = cu_kv fa_4_optional_forward_kwargs["max_seqlen_q"] = max_seqlen_q diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 5f6628c951..34aa6cf403 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -746,9 +746,6 @@ def init_fp8_metadata(self, num_gemms: int = 1) -> None: qstate = FP8GlobalStateManager.quantization_state if not (qstate.fp8_enabled or qstate.fp8_calibration or qstate.fp8_parameters): - # Without FP8 the recipe juggling below is a no-op, and its - # get_fp8_recipe() call would build a default Recipe -- which - # torch.compile cannot trace. super().init_fp8_metadata(num_gemms=num_gemms) return From 2188f183a41bd0cdd6dc662d03169c10a6685c4c Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 11:47:26 +0200 Subject: [PATCH 20/29] Log backend selection through the no-op logger, as get_attention_backend does Skipping the block with is_compiling() introduced a second mechanism for a problem this module already solves one way: get_attention_backend swaps in a no-op logger while tracing. The skip was needed while the sub-backend argument was an enum that did not survive tracing, and is not any more. The logger, which was private to the utils module, is now named without the underscore, since it is used from outside it. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention.py | 35 ++++++++++--------- .../attention/dot_product_attention/utils.py | 4 +-- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 34aa6cf403..d72eb6e98d 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -1854,22 +1854,25 @@ def forward( _attention_backends["fused_attention_backend"] = fused_attention_backend _attention_backends["use_unfused_attention"] = use_unfused_attention _attention_backends["backend_selection_requires_update"] = False - # Logged in eager only: dynamo graph-breaks on logging.Logger - # methods, and log arguments are evaluated regardless of the - # logger, so a no-op logger would not be enough. - if not torch.compiler.is_compiling(): - if use_flash_attention: - self.logger.info( - "Running with FlashAttention backend (version %s)", - flash_attention_backend, - ) - elif use_fused_attention: - self.logger.info( - "Running with FusedAttention backend (sub-backend %s)", - int(fused_attention_backend), - ) - elif use_unfused_attention: - self.logger.info("Running with UnfusedDotProductAttention backend") + # logging.Logger methods graph-break under torch.compile, so + # selection is only logged in eager -- as in + # get_attention_backend. Note the arguments below are + # evaluated either way, so they have to stay traceable. + logger = ( + dpa_utils.no_op_logger if torch.compiler.is_compiling() else self.logger + ) + if use_flash_attention: + logger.info( + "Running with FlashAttention backend (version %s)", + flash_attention_backend, + ) + elif use_fused_attention: + logger.info( + "Running with FusedAttention backend (sub-backend %s)", + int(fused_attention_backend), + ) + elif use_unfused_attention: + logger.info("Running with UnfusedDotProductAttention backend") else: use_flash_attention = _attention_backends["use_flash_attention"] flash_attention_backend = _attention_backends["flash_attention_backend"] diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 383afebd69..ccf46fda22 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -346,7 +346,7 @@ def error(self, *args, **kwargs): """No-op.""" -_no_op_logger = _NoOpLogger() +no_op_logger = _NoOpLogger() @torch.compiler.assume_constant_result @@ -461,7 +461,7 @@ def get_attention_backend( if torch.compiler.is_compiling(): # logging.Logger methods graph-break under torch.compile; backend # selection logs are only emitted in eager mode. - logger = _no_op_logger + logger = no_op_logger else: logger = logging.getLogger("DotProductAttention") logger.setLevel(AttentionLogging._log_level) From 10d141b3fc70fe5602a8a15bd84a71d25732fcef Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 11:51:46 +0200 Subject: [PATCH 21/29] Return the fused sub-backend as an int, in eager as well get_attention_backend cast it back to a FusedAttnBackend member unless it was tracing, which made its return type depend on the execution mode -- something every reader of that function then has to keep in mind, for no benefit inside transformer_engine: fused_attn_fwd/bwd call cast() on whatever they are given, and every comparison against a member works with an int on either side. It now returns an int always, and the docstring says so. Signed-off-by: Pawel Gadzinski --- .../attention/dot_product_attention/utils.py | 17 +++++------------ .../pytorch/cpp_extensions/fused_attn.py | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index ccf46fda22..9e055ef6e6 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -401,11 +401,11 @@ def get_attention_backend( Whether the `FlashAttention` backend has been selected. use_fused_attention : bool Whether the `FusedAttention` backend has been selected. - fused_attention_backend : FusedAttnBackend - If `use_fused_attention = True`, one of `FusedAttention` three sub-backends, else `None`. - Under `torch.compile` this is the sub-backend's plain integer value instead of the enum - member (see the cast below); `FusedAttnBackend.cast` accepts both, as does every consumer - in `transformer_engine.pytorch`. + fused_attention_backend : int + If `use_fused_attention = True`, the integer value of one of `FusedAttention`'s three + sub-backends, else `None`. It is not a `FusedAttnBackend` member because that does not + survive a graph break under `torch.compile`; `FusedAttnBackend.cast` turns it into one, + and comparing it against a member works either way. use_unfused_attention : bool Whether the `UnfusedDotProductAttention` backend has been selected. available_backends : List[bool] @@ -1600,13 +1600,6 @@ def _is_fa3_supported(num_heads, num_gqa_groups, head_dim_qk, head_dim_v, qkv_dt use_flash_attention_4 = False use_flash_attention = use_flash_attention_2 or use_flash_attention_3 or use_flash_attention_4 available_backends = [use_flash_attention, use_fused_attention, use_unfused_attention] - if fused_attention_backend is not None and not torch.compiler.is_compiling(): - # The fused sub-backend is kept as a plain int while tracing: it has to - # survive a graph break (DotProductAttention hands it to FusedAttention, - # which is an eager island) and an enum member does not -- dynamo - # reconstructs the constant-folded member into the resumed frame as the - # function that produced it. Eager callers get the enum, as before. - fused_attention_backend = FusedAttnBackend.cast(fused_attention_backend) if use_flash_attention_2: flash_attention_backend = FlashAttentionUtils.version if use_flash_attention_3: diff --git a/transformer_engine/pytorch/cpp_extensions/fused_attn.py b/transformer_engine/pytorch/cpp_extensions/fused_attn.py index 3a4e5aac66..9a33df7634 100644 --- a/transformer_engine/pytorch/cpp_extensions/fused_attn.py +++ b/transformer_engine/pytorch/cpp_extensions/fused_attn.py @@ -112,7 +112,7 @@ class FusedAttnBackend(IntEnum): the same way as with the dict this used to be. Members do not survive a graph break, though, so ``get_attention_backend`` - hands out plain ints while tracing -- ``cast`` normalizes both back to a + returns the sub-backend as a plain int and ``cast`` turns it back into a member. """ From 93a201d42e02f01bba21a67ad9c3e6f44f78ce05 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 11:57:12 +0200 Subject: [PATCH 22/29] Rename eager_under_compile_if to fallback_to_eager_when The old name read as a run-on, and its '_if' suffix suggested a boolean condition where the argument actually returns the reason. The new one reads as a sentence where it is used and matches the warning the decorator emits. Signed-off-by: Pawel Gadzinski --- .../attention/dot_product_attention/dot_product_attention.py | 4 ++-- transformer_engine/pytorch/jit.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index d72eb6e98d..ba6c5a4769 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -42,7 +42,7 @@ CudaRNGStatesTracker, graph_safe_rng_available, ) -from transformer_engine.pytorch.jit import eager_under_compile_if +from transformer_engine.pytorch.jit import fallback_to_eager_when from transformer_engine.pytorch.graph import is_graph_capturing from transformer_engine.pytorch.attention.inference import InferenceParams @@ -1124,7 +1124,7 @@ def get_quantizer_roles( ] return base[:num_quantizers] - @eager_under_compile_if(_needs_eager_dpa) + @fallback_to_eager_when(_needs_eager_dpa) def forward( self, query_layer: Optional[torch.Tensor] = None, diff --git a/transformer_engine/pytorch/jit.py b/transformer_engine/pytorch/jit.py index 2d0ec02d8d..f6bbf55bde 100644 --- a/transformer_engine/pytorch/jit.py +++ b/transformer_engine/pytorch/jit.py @@ -79,7 +79,7 @@ def no_torch_dynamo(recursive=True): # pylint: disable=unused-argument return lambda func: func -def eager_under_compile_if(needs_eager: Callable[[Dict[str, Any]], Optional[str]]): +def fallback_to_eager_when(needs_eager: Callable[[Dict[str, Any]], Optional[str]]): """Decorator running the wrapped method eagerly whenever `needs_eager` gives a reason why the call is unsupported on the compiled path. From 0866319fe05e2d17d1f768ca817919c53fa9f08d Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 12:06:44 +0200 Subject: [PATCH 23/29] Fold the eager fallback into no_torch_dynamo as a `when` predicate It was a decorator of its own, which meant a second name for what is the same thing this module already does: disable dynamo for a frame. It is now the conditional form of no_torch_dynamo -- @no_torch_dynamo(when=predicate) -- so there is one decorator to find and one docstring to read. The predicate receives the call's arguments keyed by parameter name and returns the reason this call cannot be traced, or None to have it traced. Signed-off-by: Pawel Gadzinski --- .../dot_product_attention.py | 4 +- transformer_engine/pytorch/jit.py | 94 +++++++++---------- 2 files changed, 48 insertions(+), 50 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index ba6c5a4769..774f6a44d1 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -42,7 +42,7 @@ CudaRNGStatesTracker, graph_safe_rng_available, ) -from transformer_engine.pytorch.jit import fallback_to_eager_when +from transformer_engine.pytorch.jit import no_torch_dynamo from transformer_engine.pytorch.graph import is_graph_capturing from transformer_engine.pytorch.attention.inference import InferenceParams @@ -1124,7 +1124,7 @@ def get_quantizer_roles( ] return base[:num_quantizers] - @fallback_to_eager_when(_needs_eager_dpa) + @no_torch_dynamo(when=_needs_eager_dpa) def forward( self, query_layer: Optional[torch.Tensor] = None, diff --git a/transformer_engine/pytorch/jit.py b/transformer_engine/pytorch/jit.py index f6bbf55bde..164610f5a5 100644 --- a/transformer_engine/pytorch/jit.py +++ b/transformer_engine/pytorch/jit.py @@ -7,7 +7,7 @@ import os import warnings from functools import wraps -from typing import Any, Callable, Dict, Optional, Tuple +from typing import Callable, Optional, Tuple import torch from .torch_version import torch_version @@ -51,22 +51,58 @@ def wrapper(*args, **kwargs): if torch.__version__ >= "2": import torch._dynamo - def no_torch_dynamo(recursive=True): - """Decorator to disable Torch Dynamo, except during ONNX export.""" + def no_torch_dynamo(recursive=True, when=None): + """Decorator to disable Torch Dynamo, except during ONNX export. - def decorator(f): + `when` makes it conditional: it is called with the arguments of the call + keyed by parameter name, and returns the reason this particular call + cannot be traced, or None to have it traced as usual. The reason is + reported once per distinct message. + """ + + def _disable(f): # no "recursive" option in pyTorch 2.0 - it acts as if recursive was True - disabled_f = ( + return ( torch._dynamo.disable(f, recursive=recursive) if torch.__version__ >= "2.1" else torch._dynamo.disable(f) ) - @wraps(f) - def wrapper(*args, **kwargs): - if is_in_onnx_export_mode(): + def decorator(f): + disabled_f = _disable(f) + if when is None: + + @wraps(f) + def wrapper(*args, **kwargs): + if is_in_onnx_export_mode(): + return f(*args, **kwargs) + return disabled_f(*args, **kwargs) + + else: + parameter_names = list(inspect.signature(f).parameters) + + # The warning belongs inside the disabled function: warnings.warn + # graph-breaks on its own, which would mask the break that matters. + def report_and_run(reason, *args, **kwargs): + warnings.warn( + f"Falling back to eager execution under torch.compile: {reason} is" + " unsupported on the compiled path (graph-breaks under fullgraph=True).", + stacklevel=3, + ) return f(*args, **kwargs) - return disabled_f(*args, **kwargs) + + disabled_report_and_run = _disable(report_and_run) + + @wraps(f) + def wrapper(*args, **kwargs): # pylint: disable=function-redefined + if is_in_onnx_export_mode() or not torch.compiler.is_compiling(): + return f(*args, **kwargs) + call = dict(zip(parameter_names, args)) + call.update(kwargs) + reason = when(call) + if reason is None: + return f(*args, **kwargs) + return disabled_report_and_run(reason, *args, **kwargs) return wrapper @@ -74,49 +110,11 @@ def wrapper(*args, **kwargs): else: # Fallback for PyTorch < 2.0: no-op decorator - def no_torch_dynamo(recursive=True): # pylint: disable=unused-argument + def no_torch_dynamo(recursive=True, when=None): # pylint: disable=unused-argument """No-op decorator for PyTorch < 2.0.""" return lambda func: func -def fallback_to_eager_when(needs_eager: Callable[[Dict[str, Any]], Optional[str]]): - """Decorator running the wrapped method eagerly whenever `needs_eager` gives - a reason why the call is unsupported on the compiled path. - - `needs_eager` is handed the call's arguments keyed by parameter name, taken - from the wrapped signature, so it does not have to care whether the caller - passed them by name or by position. Returning None keeps the call compiled. - """ - - def decorator(fn): - parameter_names = list(inspect.signature(fn).parameters) - - # The warning belongs inside the dynamo-disabled function: warnings.warn - # graph-breaks on its own, masking the break that matters. - @no_torch_dynamo() - def eager_fn(reason, *args, **kwargs): - warnings.warn( - f"Falling back to eager execution under torch.compile: {reason} is" - " unsupported on the compiled path (graph-breaks under fullgraph=True).", - stacklevel=3, - ) - return fn(*args, **kwargs) - - @wraps(fn) - def wrapper(*args, **kwargs): - if torch.compiler.is_compiling(): - call = dict(zip(parameter_names, args)) - call.update(kwargs) - reason = needs_eager(call) - if reason is not None: - return eager_fn(reason, *args, **kwargs) - return fn(*args, **kwargs) - - return wrapper - - return decorator - - def set_jit_fusion_options() -> None: """Set PyTorch JIT layer fusion options.""" # flags required to enable jit fusion kernels From a80a41aa6d89d8922d27a4e725c6172e8b61c3ce Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 12:13:25 +0200 Subject: [PATCH 24/29] Cover the declared packed layouts beyond bs3hd Packing was pinned to one layout by an assert in the input builder, so of the twelve declarations DotProductAttention accepts -- qkv_layer or kv_layer, interleaved at -3 or -2, in each of the three formats -- exactly one was tested. The builder now takes what is packed and where it is interleaved, and derives the shapes and the layout string the way DotProductAttention does. Three configurations are added, so both packed inputs and both interleave dimensions are covered. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 69 +++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index d20a0e70dc..f65bb8cb95 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -561,15 +561,34 @@ def fn(q, k, v, extra): # get_available_attention_backends() answers that, so configurations only one # backend supports (arbitrary masks, biases, MLA head dims, ...) are covered # rather than avoided. -def _cfg(model_config, qkv_format="bshd", packed=None, share_cu_seqlens=False): +def _cfg( + model_config, + qkv_format="bshd", + packed=None, + interleave_dim=-3, + share_cu_seqlens=False, +): + """A model configuration plus what DotProductAttention is handed it as. + + `packed` is "qkv" or "kv" for the declarative packed inputs, interleaved at + `interleave_dim`, or None for separate q/k/v. + """ return dict( model_config=model_config, qkv_format=qkv_format, packed=packed, + interleave_dim=interleave_dim, share_cu_seqlens=share_cu_seqlens, ) +def _packed_layout(qkv_format: str, packed_dim: int, interleave_dim: int) -> str: + """bshd + 3 @ -3 -> bs3hd; bshd + 2 @ -2 -> bsh2d; as DotProductAttention + derives it from the declaration.""" + position = len(qkv_format) + interleave_dim + 1 + return qkv_format[:position] + str(packed_dim) + qkv_format[position:] + + _DPA_COMPILE_CONFIGS = { "self_bshd_causal": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal")), "self_sbhd_no_mask": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="no_mask"), "sbhd"), @@ -587,7 +606,17 @@ def _cfg(model_config, qkv_format="bshd", packed=None, share_cu_seqlens=False): "self_thd_shared_cu_seqlens": _cfg( ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal"), "thd", share_cu_seqlens=True ), - "packed_qkv_bs3hd": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), packed="bs3hd"), + "packed_qkv_bs3hd": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), packed="qkv"), + "packed_qkv_bsh3d": _cfg( + ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), packed="qkv", interleave_dim=-2 + ), + "packed_kv_bshd_bs2hd": _cfg(ModelConfig(2, 128, 4, 64, attn_mask_type="causal"), packed="kv"), + "packed_kv_thd_th2d": _cfg( + ModelConfig(2, 128, 4, 64, attn_mask_type="padding_causal"), + "thd", + packed="kv", + interleave_dim=-2, + ), # Configurations below are supported by one backend only, or take a code # path of their own inside DotProductAttention. "alibi_bshd_causal": _cfg( @@ -605,7 +634,12 @@ def _cfg(model_config, qkv_format="bshd", packed=None, share_cu_seqlens=False): def _qkv_layout(spec: dict) -> str: - return spec["packed"] or "_".join([spec["qkv_format"]] * 3) + qkv_format, packed = spec["qkv_format"], spec["packed"] + if packed is None: + return "_".join([qkv_format] * 3) + if packed == "qkv": + return _packed_layout(qkv_format, 3, spec["interleave_dim"]) + return f"{qkv_format}_{_packed_layout(qkv_format, 2, spec['interleave_dim'])}" def _make_dpa(spec: dict, dtype: torch.dtype) -> te.DotProductAttention: @@ -669,14 +703,31 @@ def _randn(shape): return torch.randn(shape, dtype=dtype, device="cuda", requires_grad=True) if packed is not None: - # Declarative packed QKV: q/k/v are derived from one buffer by DPA + # Declarative packed inputs: q/k/v are derived from one buffer by DPA # itself, and the layout comes from the declaration -- the only packed # layout that torch.compile supports. - assert packed == "bs3hd" and qkv_format == "bshd" and h == g and d_qk == d_v - qkv = _randn((b, s_q, 3, h, d_qk)) - kwargs["qkv_layer"] = qkv - kwargs["qkv_interleave_dim"] = -3 - grad_tensors, args = [qkv], () + assert h == g and d_qk == d_v, "packed inputs require uniform heads and head dims" + interleave_dim = spec["interleave_dim"] + + def _packed(seqlen, tokens, packed_dim): + leading = { + "bshd": (b, seqlen), + "sbhd": (seqlen, b), + "thd": (tokens,), + }[qkv_format] + trailing = (packed_dim, h, d_qk) if interleave_dim == -3 else (h, packed_dim, d_qk) + return _randn(leading + trailing) + + kwargs["qkv_interleave_dim"] = interleave_dim + if packed == "qkv": + qkv = _packed(s_q, t_q, 3) + kwargs["qkv_layer"] = qkv + grad_tensors, args = [qkv], () + else: + q = _randn(_shape(s_q, t_q, h, d_qk)) + kv = _packed(s_kv, t_kv, 2) + kwargs["kv_layer"] = kv + grad_tensors, args = [q, kv], (q,) else: q = _randn(_shape(s_q, t_q, h, d_qk)) k = _randn(_shape(s_kv, t_kv, g, d_qk)) From 9eb1449daf1d40e349d27ad50835169f02c9e9c1 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 12:21:13 +0200 Subject: [PATCH 25/29] Let ONNX export keep its own path in get_qkv_layout The ONNX exporter runs through dynamo, so torch.compiler.is_compiling() is true during an export as well -- measured: inside torch.onnx.export(dynamo=True) both it and is_in_onnx_export_mode() are set. The compile branch was written first and took the export over: the layout came from the format instead of the detection ONNX relies on, tensors were no longer made contiguous, and packed views hit an assert where they used to be copied. Export mode is the narrower context, so it is checked first. This restores the previous behaviour for export while leaving the compiled path as it was. Signed-off-by: Pawel Gadzinski --- .../pytorch/attention/dot_product_attention/utils.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/utils.py b/transformer_engine/pytorch/attention/dot_product_attention/utils.py index 9e055ef6e6..a2d260c6b2 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/utils.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/utils.py @@ -2565,7 +2565,11 @@ def run_iteratively(q, k, v): return qkv_layout - if torch.compiler.is_compiling(): + if is_in_onnx_export_mode(): + # Checked first: the ONNX exporter runs through dynamo, so it also sets + # is_compiling(), and it has its own handling below. + qkv_layout = "not_supported" + elif torch.compiler.is_compiling(): # run_iteratively reads data pointers and storage offsets, which dynamo # cannot trace; unpacked q/k/v need no detection anyway. assert not qkv_layout_needs_detection(q, k, v), ( @@ -2577,10 +2581,8 @@ def run_iteratively(q, k, v): qkv_layout = "_".join([qkv_format] * 3) else: qkv_layout = q_format + "_" + kv_format + "_" + kv_format - elif not is_in_onnx_export_mode(): - qkv_layout = run_iteratively(q, k, v) else: - qkv_layout = "not_supported" + qkv_layout = run_iteratively(q, k, v) if qkv_layout == "not_supported": # force q,k,v to be contiguous and run get_layout again q, k, v = [x.contiguous() for x in [q, k, v]] From 5c5b451264637a5726868043bee551be71cb88d4 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 12:31:08 +0200 Subject: [PATCH 26/29] Guard the assumption the argument binding rests on Arguments are matched to parameter names by position, which is only right while the signature has no *args in between -- and would misalign silently if one were added. It is checked when the decorator is applied. The docstring now also says that an argument the call left out is simply absent, so a predicate reading it with .get() sees its default only as long as that default is None. Signed-off-by: Pawel Gadzinski --- transformer_engine/pytorch/jit.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/transformer_engine/pytorch/jit.py b/transformer_engine/pytorch/jit.py index 164610f5a5..62e6950154 100644 --- a/transformer_engine/pytorch/jit.py +++ b/transformer_engine/pytorch/jit.py @@ -55,9 +55,11 @@ def no_torch_dynamo(recursive=True, when=None): """Decorator to disable Torch Dynamo, except during ONNX export. `when` makes it conditional: it is called with the arguments of the call - keyed by parameter name, and returns the reason this particular call - cannot be traced, or None to have it traced as usual. The reason is - reported once per distinct message. + keyed by parameter name -- an argument the call left out is absent, so + reading it with .get() yields its default as long as that default is + None -- and returns the reason this particular call cannot be traced, or + None to have it traced as usual. The reason is reported once per + distinct message. """ def _disable(f): @@ -79,7 +81,13 @@ def wrapper(*args, **kwargs): return disabled_f(*args, **kwargs) else: - parameter_names = list(inspect.signature(f).parameters) + parameters = inspect.signature(f).parameters + # Arguments are matched to names by position, which only holds + # without a *args in between. + assert not any( + p.kind is inspect.Parameter.VAR_POSITIONAL for p in parameters.values() + ), f"no_torch_dynamo(when=...) does not support *args, which {f.__name__} takes" + parameter_names = list(parameters) # The warning belongs inside the disabled function: warnings.warn # graph-breaks on its own, which would mask the break that matters. From 95030f9a3140e7b62067bbf903610562a55462f2 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 13:49:53 +0200 Subject: [PATCH 27/29] Check that CUDA graphs were actually captured, and generalize a docstring fullgraph=True says dynamo did not break the graph, but inductor can still decline to capture -- a mutated input, a CPU scalar -- and run the thing normally, which would leave the CUDA-graphs test passing while measuring nothing. Its skip counter is asserted to be zero. Measured on this branch: nothing is skipped, the tree has one root, and interleaving the eager reference call between replays does not disturb it. The tolerance docstring described which backends are compared exactly by naming them; it now states the rule -- a backend whose kernel is the same either way -- so it still holds once FusedAttention traces. Signed-off-by: Pawel Gadzinski --- tests/pytorch/test_torch_compile.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/pytorch/test_torch_compile.py b/tests/pytorch/test_torch_compile.py index f65bb8cb95..6ae070b70d 100644 --- a/tests/pytorch/test_torch_compile.py +++ b/tests/pytorch/test_torch_compile.py @@ -21,6 +21,8 @@ except ImportError: _opaque_available = False +from torch._dynamo.utils import counters + import transformer_engine.pytorch as te import transformer_engine_torch as tex from transformer_engine.common import recipe @@ -804,12 +806,11 @@ def _assert_dpa_backend(backend: str) -> None: def _assert_matches_eager(actual, expected, backend: str, dtype: torch.dtype) -> None: """Assert a compiled result matches the eager one. - FlashAttention and FusedAttention run the same kernel either way, so they - have to match exactly. Inductor reassociates the unfused backend's softmax - sums, and that error scales with the magnitude of those sums rather than - with each output element -- so the absolute tolerance comes from the - tensor's own scale, an elementwise relative tolerance being meaningless for - elements near zero. + A backend that calls the same kernel either way has to match exactly: + compiling changes what surrounds it, not its arithmetic. The unfused + backend is instead built from PyTorch ops, which inductor fuses and + reassociates; that error scales with the softmax sums rather than with each + output element, hence an absolute tolerance taken from the tensor's scale. """ if backend != "unfused": torch.testing.assert_close(actual, expected, rtol=0.0, atol=0.0) @@ -918,6 +919,7 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): module = _make_dpa(spec, dtype) torch._dynamo.reset() + counters.clear() compiled = torch.compile(module, fullgraph=True, mode="reduce-overhead") for _ in range(3): @@ -929,6 +931,9 @@ def test_dpa_torch_compile_cudagraphs(monkeypatch, backend): eager = _run_and_capture(module, args, kwargs, grads) _assert_run_matches(replayed, eager, backend, dtype) _assert_dpa_backend(backend) + # Without this, inductor declining to capture -- a mutated input, a CPU + # scalar -- would leave the test passing while measuring nothing. + assert not counters["inductor"]["cudagraph_skips"], "inductor skipped CUDA graphs" @pytest.mark.skipif(not fp8_available, reason=reason_for_no_fp8) From b0e56d450bac0300da046cc7e56ea6b098ec20b3 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 14:51:20 +0200 Subject: [PATCH 28/29] Skip lazy compilation while already tracing Filling lazy_compile's closure cell is a side effect Dynamo rejects inside a higher-order op, so compiling a module whose autograd.Function backward makes the first call to a jit_fuser'd function fails with Unsupported: HOP: Unsafe side effect Attempted to mutate CellVariable() An earlier eager run hides this by filling the cell beforehand. The nested torch.compile is inlined into the outer graph either way, so calling the function directly while tracing is equivalent. Signed-off-by: Pawel Gadzinski --- transformer_engine/pytorch/jit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/transformer_engine/pytorch/jit.py b/transformer_engine/pytorch/jit.py index 62e6950154..22e9c9ae3e 100644 --- a/transformer_engine/pytorch/jit.py +++ b/transformer_engine/pytorch/jit.py @@ -28,6 +28,8 @@ def lazy_compile(func): @wraps(func) def wrapper(*args, **kwargs): nonlocal compiled_func + if torch.compiler.is_compiling(): + return func(*args, **kwargs) if compiled_func is None: compiled_func = torch.compile(func) return compiled_func(*args, **kwargs) From 2cf695bb59a551efb8e6ad49dbe3ef85af2d54f4 Mon Sep 17 00:00:00 2001 From: Pawel Gadzinski Date: Thu, 30 Jul 2026 16:49:43 +0200 Subject: [PATCH 29/29] Run context parallel attention eagerly The context parallel implementation uses P2P communication and its own CUDA stream, neither of which dynamo traces, and FlashAttention reaches it from a forward that is no longer excluded from the graph. Signed-off-by: Pawel Gadzinski --- .../attention/dot_product_attention/dot_product_attention.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py index 774f6a44d1..0ed01a9c9e 100644 --- a/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py +++ b/transformer_engine/pytorch/attention/dot_product_attention/dot_product_attention.py @@ -210,6 +210,9 @@ def _needs_eager_dpa(call: Dict[str, Any]) -> Optional[str]: if fp8_recipe.fp8_dpa or fp8_recipe.fp8_mha: return "FP8 attention" + if call["self"].cp_group is not None: + return "context parallelism" + qkv_format = call.get("qkv_format") or call["self"].qkv_format if qkv_format == "thd" and ( call.get("max_seqlen_q") is None or call.get("max_seqlen_kv") is None