Skip to content

Commit 92bd5b5

Browse files
Fix NaN attention scores on MPS from uninitialized baddbmm buffer
On MPS, torch.baddbmm does not honor the documented beta=0 semantics: NaN/Inf present in the input buffer propagate to the output. Both copies of get_attention_scores (Attention and AttentionModuleMixin) pass torch.empty() as that buffer, so recycled allocator pages containing NaN poison the attention scores, producing all-black images with SlicedAttnProcessor (e.g. SDXL + enable_model_cpu_offload + enable_attention_slicing). Use a buffer-free scaled bmm on MPS when there is no attention mask. This avoids relying on the beta=0 contract, skips the scores-sized buffer allocation entirely (lower peak memory on the memory-constrained devices the sliced path targets), and benchmarks ~35% faster than the baddbmm+empty path on Apple Silicon. Other backends are unchanged. Fixes #14438
1 parent 3a2f35d commit 92bd5b5

3 files changed

Lines changed: 107 additions & 32 deletions

File tree

src/diffusers/models/attention.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -420,23 +420,30 @@ def get_attention_scores(
420420
query = query.float()
421421
key = key.float()
422422

423-
if attention_mask is None:
424-
baddbmm_input = torch.empty(
425-
query.shape[0], query.shape[1], key.shape[1], dtype=query.dtype, device=query.device
426-
)
427-
beta = 0
423+
if attention_mask is None and query.device.type == "mps":
424+
# On MPS, baddbmm does not honor the documented beta=0 semantics: NaN/Inf in the
425+
# uninitialized `input` buffer propagate to the output, producing NaN attention
426+
# scores (https://github.com/huggingface/diffusers/issues/14438). A buffer-free
427+
# scaled bmm avoids relying on that contract (and skips the buffer allocation).
428+
attention_scores = torch.bmm(query * self.scale, key.transpose(-1, -2))
428429
else:
429-
baddbmm_input = attention_mask
430-
beta = 1
431-
432-
attention_scores = torch.baddbmm(
433-
baddbmm_input,
434-
query,
435-
key.transpose(-1, -2),
436-
beta=beta,
437-
alpha=self.scale,
438-
)
439-
del baddbmm_input
430+
if attention_mask is None:
431+
baddbmm_input = torch.empty(
432+
query.shape[0], query.shape[1], key.shape[1], dtype=query.dtype, device=query.device
433+
)
434+
beta = 0
435+
else:
436+
baddbmm_input = attention_mask
437+
beta = 1
438+
439+
attention_scores = torch.baddbmm(
440+
baddbmm_input,
441+
query,
442+
key.transpose(-1, -2),
443+
beta=beta,
444+
alpha=self.scale,
445+
)
446+
del baddbmm_input
440447

441448
if self.upcast_softmax:
442449
attention_scores = attention_scores.float()

src/diffusers/models/attention_processor.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -675,23 +675,30 @@ def get_attention_scores(
675675
query = query.float()
676676
key = key.float()
677677

678-
if attention_mask is None:
679-
baddbmm_input = torch.empty(
680-
query.shape[0], query.shape[1], key.shape[1], dtype=query.dtype, device=query.device
681-
)
682-
beta = 0
678+
if attention_mask is None and query.device.type == "mps":
679+
# On MPS, baddbmm does not honor the documented beta=0 semantics: NaN/Inf in the
680+
# uninitialized `input` buffer propagate to the output, producing NaN attention
681+
# scores (https://github.com/huggingface/diffusers/issues/14438). A buffer-free
682+
# scaled bmm avoids relying on that contract (and skips the buffer allocation).
683+
attention_scores = torch.bmm(query * self.scale, key.transpose(-1, -2))
683684
else:
684-
baddbmm_input = attention_mask
685-
beta = 1
686-
687-
attention_scores = torch.baddbmm(
688-
baddbmm_input,
689-
query,
690-
key.transpose(-1, -2),
691-
beta=beta,
692-
alpha=self.scale,
693-
)
694-
del baddbmm_input
685+
if attention_mask is None:
686+
baddbmm_input = torch.empty(
687+
query.shape[0], query.shape[1], key.shape[1], dtype=query.dtype, device=query.device
688+
)
689+
beta = 0
690+
else:
691+
baddbmm_input = attention_mask
692+
beta = 1
693+
694+
attention_scores = torch.baddbmm(
695+
baddbmm_input,
696+
query,
697+
key.transpose(-1, -2),
698+
beta=beta,
699+
alpha=self.scale,
700+
)
701+
del baddbmm_input
695702

696703
if self.upcast_softmax:
697704
attention_scores = attention_scores.float()

tests/models/test_attention_processor.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,64 @@ def test_conversion_when_using_device_map(self):
132132

133133
assert np.allclose(pre_conversion, conversion, atol=1e-3)
134134
assert np.allclose(conversion, after_conversion, atol=1e-3)
135+
136+
137+
class TestGetAttentionScoresMPS:
138+
# Regression tests for https://github.com/huggingface/diffusers/issues/14438.
139+
# On MPS, baddbmm propagates NaN/Inf from `input` even with beta=0, so
140+
# get_attention_scores must not pass uninitialized memory as the buffer.
141+
# Poison the allocator pool so a subsequent torch.empty of the same shape
142+
# recycles NaN-bearing pages, then verify scores stay finite and correct.
143+
144+
batch, tokens, dim_head = 8, 4096, 32
145+
146+
def _make_qk(self):
147+
query = torch.randn(self.batch, self.tokens, self.dim_head, device="mps", dtype=torch.float16)
148+
key = torch.randn(self.batch, self.tokens, self.dim_head, device="mps", dtype=torch.float16)
149+
return query, key
150+
151+
def _poison_pool(self):
152+
# Fill and free a buffer of exactly the attention-scores shape so the
153+
# allocator hands its NaN-bearing pages to the next torch.empty call.
154+
junk = torch.full((self.batch, self.tokens, self.tokens), float("nan"), device="mps", dtype=torch.float16)
155+
del junk
156+
157+
@pytest.mark.skipif(torch_device != "mps", reason="regression test for an MPS-specific baddbmm issue")
158+
def test_get_attention_scores_no_nan_from_recycled_buffer(self):
159+
from types import SimpleNamespace
160+
161+
from diffusers.models.attention import AttentionModuleMixin
162+
163+
# Exercise both duplicated implementations of get_attention_scores in one
164+
# process: allocator page-recycling on MPS is position-dependent, so a
165+
# sequence of poisoned calls across both paths is what detects the leak
166+
# deterministically.
167+
attn = Attention(query_dim=64, heads=2, dim_head=32)
168+
holder = SimpleNamespace(upcast_attention=False, upcast_softmax=False, scale=0.125)
169+
query, key = self._make_qk()
170+
171+
score_fns = [
172+
("Attention", lambda: attn.get_attention_scores(query, key, attention_mask=None)),
173+
(
174+
"AttentionModuleMixin",
175+
lambda: AttentionModuleMixin.get_attention_scores(holder, query, key, attention_mask=None),
176+
),
177+
]
178+
for round_idx in range(3):
179+
for name, scores_fn in score_fns:
180+
self._poison_pool()
181+
scores = scores_fn()
182+
assert not torch.isnan(scores).any(), (
183+
f"NaN leaked from uninitialized baddbmm buffer on MPS ({name}, round {round_idx})"
184+
)
185+
186+
@pytest.mark.skipif(torch_device != "mps", reason="regression test for an MPS-specific baddbmm issue")
187+
def test_get_attention_scores_matches_cpu_reference(self):
188+
# The MPS path must stay numerically equivalent to the CPU baddbmm path,
189+
# not merely NaN-free.
190+
attn = Attention(query_dim=64, heads=2, dim_head=32)
191+
query, key = self._make_qk()
192+
self._poison_pool()
193+
probs_mps = attn.get_attention_scores(query, key, attention_mask=None).cpu()
194+
probs_cpu = attn.get_attention_scores(query.cpu(), key.cpu(), attention_mask=None)
195+
assert torch.allclose(probs_mps, probs_cpu, atol=2e-3), "MPS attention probs diverge from CPU reference"

0 commit comments

Comments
 (0)