Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/diffusers/models/attention_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,22 @@ def dispatch_attention_fn(
) -> torch.Tensor:
attention_kwargs = attention_kwargs or {}

# Under autocast, RMSNorm QK norms return fp32 while value stays bf16/fp16. Torch SDPA casts
# its inputs to the autocast dtype internally, but backends calling external kernels
# (flash-attn, sage, ...) receive the mix as-is and crash, so do the same cast here.
# float64 is not autocast-eligible. See https://github.com/huggingface/diffusers/issues/14104.
# is_compiling() short-circuits the is_autocast_available guard (it protects eager runs on
# devices without autocast, e.g. "meta"; Dynamo can't trace it before torch 2.12).
device_type = query.device.type
if (torch.compiler.is_compiling() or torch.amp.is_autocast_available(device_type)) and torch.is_autocast_enabled(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it have to depend on torch.compiler.is_compiling()?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two stacked constraints. In eager, torch.is_autocast_enabled(device_type) raises for device types without an autocast dispatch key ("meta" is the one diffusers actually hits, e.g. during meta-device init), so it needs the torch.amp.is_autocast_available(device_type) guard. But Dynamo cannot trace that guard before torch 2.12 (it only entered the trace rules there): on torch 2.6, the floor in setup.py, compiling this function with fullgraph=True fails with

Unsupported: Graph break due to unsupported builtin torch._C._is_autocast_available

torch.compiler.is_compiling() resolves both sides: it folds to a constant inside the graph, so compiled code skips the untraceable guard, and nothing is lost because compiled graphs never execute on meta anyway. Eager keeps the guard. test_torch_compile_fullgraph_under_autocast locks the fullgraph behavior in; verified on 2.6.0 and 2.12.1.

Once the minimum torch reaches 2.12 the short-circuit can be dropped and the condition becomes just is_autocast_available(...) and is_autocast_enabled(...). Happy to restructure if you would rather have it factored into a small helper.

@sayakpaul sayakpaul Jul 10, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all feels very very LLM-ish. Could you please cut the jargon and explain things out of your own understanding? It's just easier for everyone to follow. Otherwise, it's too much unnecessary cognitive load.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, plain version: torch.is_autocast_enabled crashes on device types without autocast support ("meta" is the real case). The is_autocast_available check protects against that, but torch 2.6, our minimum, can't trace that check under torch.compile(fullgraph=True). So is_compiling() skips it inside compiled code, which is safe: compiled graphs never execute on meta tensors. Eager keeps the protection, compile skips the untraceable call. Once the minimum torch is 2.12, this collapses to just the two autocast calls.

I also tightened the comment block in the code itself (049feef), same story in fewer lines.

One note: happy to simplify whenever it helps, and a plain "please make this simpler" does the job. The "LLM-ish" framing wasn't necessary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we are saying:

Torch SDPA casts its inputs to the autocast dtype internally, but backends calling external kernels

How does SDPA handle it in a manner that doesn't offend Dynamo?

torch.is_autocast_enabled crashes on device types without autocast support ("meta" is the real case).

But is there any real merit to consider that? One would probably never initialize a model on a meta device and then switch between attention backends, I think. If we consider that, does the following still apply?

but torch 2.6, our minimum, can't trace that check under torch.compile(fullgraph=True).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair challenge, and it made the code simpler.

On SDPA: it never checks anything at the Python level. scaled_dot_product_attention has autocast kernels registered in C++ (torch._C._dispatch_dump("aten::scaled_dot_product_attention") lists AutocastCUDA/CPU/XPU/MPS entries from aten/src/ATen/autocast_mode.cpp), so the dispatcher casts the inputs before Dynamo ever sees a branch. When AOTAutograd traces through the dispatcher, the casts just show up in the graph as ordinary ops. Our cast lives in Python, which is why traceability became a topic at all.

On meta: agreed that nobody switches attention backends on a meta model. I swept the codebase to be sure, and every meta usage in src/ and tests/ is weight init or offload bookkeeping, none reaches attention. The catch is that torch.is_autocast_enabled("meta") raises whether autocast is active or not, so an unguarded call would break plain meta-device forwards (shape or memory tracing) that work on main today. That protection doesn't require is_autocast_available, though. A string check covers it, and the guard is now

if device_type != "meta" and torch.is_autocast_enabled(device_type):

Which also answers the last question: with is_autocast_available gone there is nothing left that Dynamo can't trace. is_autocast_enabled and get_autocast_dtype compile fine under fullgraph=True on torch 2.6 (zero graph breaks per torch._dynamo.explain, correct recompile when autocast toggles between calls), so is_compiling() is gone too. Done in 38a870d; re-validated on an A4000 (torch 2.12.1+cu126): flash_hub under cuda autocast passes, the full test class is green on CUDA, and the fullgraph test passes on 2.6.0 and 2.12.1.

device_type
):
autocast_dtype = torch.get_autocast_dtype(device_type)
if query.dtype != torch.float64:
query, key, value = query.to(autocast_dtype), key.to(autocast_dtype), value.to(autocast_dtype)
if attn_mask is not None and torch.is_floating_point(attn_mask) and attn_mask.dtype != torch.float64:
attn_mask = attn_mask.to(autocast_dtype)

if backend is None:
# If no backend is specified, we either use the default backend (set via the DIFFUSERS_ATTN_BACKEND environment
# variable), or we use a custom backend based on whether user is using the `attention_backend` context manager
Expand Down
135 changes: 134 additions & 1 deletion tests/models/test_attention_processor.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import importlib.metadata
import tempfile
import unittest
Expand All @@ -8,9 +9,14 @@
from packaging import version

from diffusers import DiffusionPipeline
from diffusers.models.attention_dispatch import (
AttentionBackendName,
_AttentionBackendRegistry,
dispatch_attention_fn,
)
from diffusers.models.attention_processor import Attention, AttnAddedKVProcessor

from ..testing_utils import torch_device
from ..testing_utils import is_torch_compile, require_torch_accelerator, torch_device


class AttnAddedKVProcessorTests(unittest.TestCase):
Expand Down Expand Up @@ -133,3 +139,130 @@ def test_conversion_when_using_device_map(self):

self.assertTrue(np.allclose(pre_conversion, conversion, atol=1e-3))
self.assertTrue(np.allclose(conversion, after_conversion, atol=1e-3))


class TestAttentionDispatchAutocast:
"""Regression tests for https://github.com/huggingface/diffusers/issues/14104.

Under an active torch.autocast context, ops with an fp32 cast policy (e.g. the `torch.nn.RMSNorm`
QK norms in Flux-family models) return float32 while `value` keeps the autocast dtype. Native SDPA
downcasts all of its inputs itself, but backends calling external kernels (flash-attn, sage, ...)
receive the tensors as-is and reject the mismatch — so `dispatch_attention_fn` must normalize
dtypes the same way SDPA's autocast registration does.
"""

def _qkv(self, qk_dtype=torch.float32, v_dtype=torch.bfloat16):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the qk_type set to torch.float32 to emulate the QKnorm behaviour?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. fp32 q/k with bf16 v is what the aten::rms_norm policy produces on the QK-norm path, and value keeps the autocast dtype because it never passes through the norm. The helper now says this in a comment (e864233).

query = torch.randn(1, 8, 2, 16, device=torch_device, dtype=qk_dtype)
key = torch.randn(1, 8, 2, 16, device=torch_device, dtype=qk_dtype)
value = torch.randn(1, 8, 2, 16, device=torch_device, dtype=v_dtype)
return query, key, value

@contextlib.contextmanager
def _record_native_backend_input_dtypes(self):
original = _AttentionBackendRegistry._backends[AttentionBackendName.NATIVE]
received = {}

def recording_backend(query, key, value, **kwargs):
received["query"], received["key"], received["value"] = query.dtype, key.dtype, value.dtype
if kwargs.get("attn_mask") is not None:
received["attn_mask"] = kwargs["attn_mask"].dtype
return original(query, key, value, **kwargs)

_AttentionBackendRegistry._backends[AttentionBackendName.NATIVE] = recording_backend
try:
yield received
finally:
_AttentionBackendRegistry._backends[AttentionBackendName.NATIVE] = original

def test_autocast_casts_inputs_to_autocast_dtype(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would mark these tests with require_torch_accelerator because I don't think they need to run on our fast PR CI.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e864233. The class is gated now, so all of it skips on the CPU-only fast PR CI and runs on the GPU workflows instead.

query, key, value = self._qkv()
device_type = torch.device(torch_device).type

with self._record_native_backend_input_dtypes() as received:
with torch.autocast(device_type, dtype=torch.bfloat16):
out = dispatch_attention_fn(query, key, value, backend=AttentionBackendName.NATIVE)

assert received["query"] == torch.bfloat16
assert received["key"] == torch.bfloat16
assert received["value"] == torch.bfloat16
assert out.dtype == torch.bfloat16

def test_autocast_casts_floating_point_mask_but_not_bool_mask(self):
query, key, value = self._qkv()
device_type = torch.device(torch_device).type
float_mask = torch.zeros(1, 2, 8, 8, device=torch_device, dtype=torch.float32)
bool_mask = torch.ones(1, 2, 8, 8, device=torch_device, dtype=torch.bool)

with self._record_native_backend_input_dtypes() as received:
with torch.autocast(device_type, dtype=torch.bfloat16):
dispatch_attention_fn(query, key, value, attn_mask=float_mask, backend=AttentionBackendName.NATIVE)
assert received["attn_mask"] == torch.bfloat16

with self._record_native_backend_input_dtypes() as received:
with torch.autocast(device_type, dtype=torch.bfloat16):
dispatch_attention_fn(query, key, value, attn_mask=bool_mask, backend=AttentionBackendName.NATIVE)
assert received["attn_mask"] == torch.bool

def test_autocast_passes_backend_dtype_checks(self, monkeypatch):
# The opt-in debugging checks (DIFFUSERS_ATTN_CHECKS) validate the tensors the backend will
# actually receive, so mixed autocast inputs must not trip them.
query, key, value = self._qkv()
device_type = torch.device(torch_device).type

monkeypatch.setattr(_AttentionBackendRegistry, "_checks_enabled", True)
with torch.autocast(device_type, dtype=torch.bfloat16):
out = dispatch_attention_fn(query, key, value, backend=AttentionBackendName.NATIVE)
assert out.dtype == torch.bfloat16

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already tested in test_autocast_casts_inputs_to_autocast_dtype

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They overlapped on the happy path, agreed. Merged in e864233: the checks toggle now lives inside test_autocast_casts_inputs_to_autocast_dtype and the standalone test is gone. What it guarded is the opt-in DIFFUSERS_ATTN_CHECKS validation, which runs before the backend is invoked and false-raised under autocast on main; keeping the toggle enabled in the surviving test covers that with one test fewer.


def test_no_autocast_dtypes_pass_through_unchanged(self):
# Outside autocast the dispatcher must not touch dtypes: mismatched inputs keep raising
# (from SDPA itself for the native backend), exactly as before.
query, key, value = self._qkv()
with pytest.raises(RuntimeError):
dispatch_attention_fn(query, key, value, backend=AttentionBackendName.NATIVE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would even be specific and check that there's a dtype mismatch error in the RuntimeError message (assuming that's the case).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e864233: match="Expected query, key, and value to have the same dtype". That's SDPA's shared input validation message, identical on torch 2.6.0 and 2.12.1, and the same on CUDA (checked on an A4000).


@require_torch_accelerator
def test_rms_norm_qk_pattern_under_autocast(self):
# End-to-end shape of the Flux-family bug: bf16 q/k go through `torch.nn.RMSNorm` under
# autocast and come out fp32, value stays bf16. The fp32 autocast policy for `aten::rms_norm`
# only registers for CUDA/XPU and only on recent torch, so probe for it instead of assuming.
device_type = torch.device(torch_device).type
if device_type not in ("cuda", "xpu"):
pytest.skip("aten::rms_norm only registers an fp32 autocast policy for CUDA/XPU.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit): Would use pytest.mark.skipif and use if torch_device not in ["cuda", "xpu"] as the reason.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e864233. The runtime probe right below it stays: the fp32 policy also depends on the torch version (new in 2.12), so the version half can't be a static skipif.


norm = torch.nn.RMSNorm(16, eps=1e-6, device=torch_device, dtype=torch.bfloat16)
query, key, value = self._qkv(qk_dtype=torch.bfloat16)

with torch.autocast(device_type, dtype=torch.bfloat16):
if norm(query).dtype != torch.float32:
pytest.skip("this torch version has no fp32 autocast policy for aten::rms_norm.")

with self._record_native_backend_input_dtypes() as received:
with torch.autocast(device_type, dtype=torch.bfloat16):
query, key = norm(query), norm(key)
out = dispatch_attention_fn(query, key, value, backend=AttentionBackendName.NATIVE)

assert received["query"] == torch.bfloat16
assert received["key"] == torch.bfloat16
assert received["value"] == torch.bfloat16
assert out.dtype == torch.bfloat16

@is_torch_compile
def test_torch_compile_fullgraph_under_autocast(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool test!

# The autocast branch must stay fullgraph-traceable — probing autocast state with APIs that
# Dynamo cannot trace would graph-break every compiled model at each attention call.
# `backend` is left unset (default active backend, i.e. native): passing the enum explicitly
# takes the `AttentionBackendName(backend)` path, which Dynamo cannot trace before torch 2.12,
# and model processors compile with `backend=None` anyway.
query, key, value = self._qkv(qk_dtype=torch.bfloat16)
device_type = torch.device(torch_device).type

compiled = torch.compile(dispatch_attention_fn, fullgraph=True)
with torch.autocast(device_type, dtype=torch.bfloat16):
out = compiled(query, key, value)
assert out.dtype == torch.bfloat16

torch.compiler.reset()
compiled = torch.compile(dispatch_attention_fn, fullgraph=True)
out = compiled(query, key, value)
assert out.dtype == torch.bfloat16
Loading