Stable Diffusion: Transformer2D's GroupNorm uses MLX's default eps (1e-5) where diffusers uses 1e-6

Author: andreinknvCreated Jul 31, 2026Updated Jul 31, 2026

Summary

In stable_diffusion/unet.py, Transformer2D.__init__ builds its input GroupNorm without an eps:

python
self.norm = nn.GroupNorm(norm_num_groups, in_channels, pytorch_compatible=True)

mlx.nn.GroupNorm defaults to eps=1e-5. The reference implementation this mirrors, diffusers' Transformer2DModel, hardcodes 1e-6:

python
# diffusers/models/transformers/transformer_2d.py, _init_continuous_input
self.norm = torch.nn.GroupNorm(
    num_groups=self.config.norm_num_groups, num_channels=self.in_channels, eps=1e-6, affine=True
)

The epsilon is genuinely inconsistent across the UNet in diffusers, which is what makes it easy to miss: the resnets use 1e-5, the LayerNorms inside each transformer block use 1e-5, and only this spatial GroupNorm uses 1e-6. Taking the framework default unifies all three, which is tidier and does not match the reference.

This affects every model the example supports, since Transformer2D is shared: 16 modules in an SD 1.5-shaped UNet, and correspondingly more in SDXL.

Impact

Against reference activations dumped from the diffusers UNet on the same weights and the same inputs, the assembled UNet output moves by more than an order of magnitude:

  eps=1e-5 (current)   max_abs vs diffusers   5.628e-4
  eps=1e-6 (fixed)     max_abs vs diffusers   1.061e-5

The error is introduced at the first transformer and accumulates through the network. Comparing intermediates entry by entry, with the current default the first block's output is already 2.425e-4 off and the mid-block output reaches 6.208e-3; with eps=1e-6 those become 7.153e-6 and 1.224e-4. conv_in is unaffected either way, which is what localises it to the transformer rather than to weight loading or layout.

For calibration, an independent Rust implementation of the same UNet, verified against the same diffusers reference, sits at 1.1e-5 — so eps=1e-6 brings this example to the same agreement, and the current default is roughly 50x further out than a correct port.

It is a small absolute error and will not make images obviously wrong. It does put the example outside the tolerance anyone would use to verify a port against diffusers, and it costs nothing to fix: same operations, same shapes, one different scalar inside a normalisation that is already being computed.

Reproducer

No reference model or fixtures needed — run the UNet twice on identical random inputs, changing only the epsilon:

python
import sys
import mlx.core as mx
import numpy as np

sys.path.insert(0, ".")  # run from mlx-examples/stable_diffusion
from stable_diffusion.model_io import load_unet
from stable_diffusion.unet import Transformer2D

MODEL = "stabilityai/stable-diffusion-2-1-base"


def run(eps):
    unet = load_unet(key=MODEL, float16=False)
    n = 0
    if eps is not None:
        for _, m in unet.named_modules():
            if isinstance(m, Transformer2D):
                m.norm.eps = eps
                n += 1
    rng = np.random.default_rng(0)
    cross = unet.down_blocks[0].attentions[0].transformer_blocks[0].attn2.key_proj.weight.shape[1]
    x = mx.array(rng.standard_normal((1, 32, 32, 4)).astype(np.float32))
    t = mx.array(np.array([500.0], dtype=np.float32))
    ctx = mx.array(rng.standard_normal((1, 77, cross)).astype(np.float32))
    out = unet(x, t, encoder_x=ctx)
    mx.eval(out)
    return np.array(out, copy=False).astype(np.float64), n


default, _ = run(None)      # MLX default, eps=1e-5
fixed, n = run(1e-6)        # diffusers, eps=1e-6
d = np.abs(default - fixed)
print(f"Transformer2D modules affected: {n}")
print(f"output peak                   : {np.abs(fixed).max():.4f}")
print(f"max |eps=1e-5 - eps=1e-6|     : {d.max():.3e}")
print(f"mean|eps=1e-5 - eps=1e-6|     : {d.mean():.3e}")

Observed on an SD 1.5-shaped UNet at float32:

  Transformer2D modules affected: 16
  output peak                   : 3.9855
  max |eps=1e-5 - eps=1e-6|     : 6.249e-04
  mean|eps=1e-5 - eps=1e-6|     : 8.880e-05

(The measurements above were taken on SD 1.5, which is not in _MODELS and was added locally. Transformer2D is shared by every supported model, so stable-diffusion-2-1-base and sdxl-turbo are affected identically; the reproducer above is written against the default model.)

Fix

diff
-        self.norm = nn.GroupNorm(norm_num_groups, in_channels, pytorch_compatible=True)
+        # diffusers' Transformer2DModel hardcodes eps=1e-6 here, while the
+        # resnets and the LayerNorms inside the blocks use 1e-5.
+        self.norm = nn.GroupNorm(
+            norm_num_groups, in_channels, pytorch_compatible=True, eps=1e-6
+        )

Happy to open a PR if useful.

Environment

  • mlx 0.29.3, mlx-metal 0.29.3
  • macOS (Darwin 25.6.0), arm64
  • mlx-examples at current main