Fork of dgrauet/Hunyuan3D-2.1-mlx + our generate_e2e.py CLI, env-tunable remesh (HY3D_REMESH_FACES), and HARDWARE.md. Upstream training data (mini_trainset) and demo images stripped — inference needs none of it. Full upstream history: github.com/dgrauet/Hunyuan3D-2.1-mlx
6.5 KiB
MLX vs PyTorch UNet Numerical Comparison Report
Test configuration
- Latent spatial size: 8 x 8
- Input channels: 12 (4 latent + 4 normal + 4 position)
- Encoder text: zeros (1, 77, 1024)
- Timestep: 500
- Both models cast to fp32
- Tolerance: 0.01 (abs)
Setup fixes made to the script
- MLX UNet built at
in_channels=12withattention_head_dim=(5, 10, 20, 20)(matching the PyTorch config), no_enhance_unet()— we compare the vanilla UNet topology. - PyTorch UNet rebuilt from
config.jsonwithin_channels=12(the config says 4 but the actual checkpoint has 12-channelconv_in). Loading viafrom_pretrainedsilently drops most weights because the checkpoint's keys are nested insideunet./unet_dual.with a.transformer.wrapper forBasicTransformerBlock. We now:- load the
.binfile manually, - strip the
unet.prefix, - collapse
transformer_blocks.0.transformer.Xtotransformer_blocks.0.X, - drop 2.5D-only tensors (
attn_multiview,attn_refview,attn_dino,.processor.). - This yields 686 weights loaded, 0 missing, 0 unexpected.
- load the
Weight spot-check
All 18 spot-checked weight tensors (conv_in, time_embedding, conv_out, conv_norm_out, resnet 0 weights, attention.0 norm/proj_in, attn1 to_q/to_k/to_v for both down_blocks[0] and mid_block) are bit-identical between the PT state_dict and the MLX safetensors after layout transposition. Conclusion: the converter is fine; the bug is in the MLX modules themselves.
Forward-pass divergence (plain run, same weights, same input)
| layer | shape | max_abs | status |
|---|---|---|---|
| conv_in | (1, 8, 8, 320) | 1.5e-07 | MATCH |
| down_block_0 | (1, 4, 4, 320) | 6.29 | MISMATCH |
| mid_block | (1, 1, 1, 1280) | 15.18 | MISMATCH |
| up_block_0 | (1, 2, 2, 1280) | 6.19 | MISMATCH |
| conv_out / final | (1, 8, 8, 4) | 0.68 | MISMATCH |
First divergent layer: down_blocks[0] (while conv_in still matches).
Drill-down inside down_blocks[0]
PyTorch forward hooks captured each sub-module output; MLX sub-modules were run in the same order.
| sub-module | max_abs (plain) | max_abs (GN fix) | max_abs (PT temb) |
|---|---|---|---|
| resnet 0 | 1.83 | 0.042 | 1.85 |
| attention 0 | 2.33 | 0.75 | 2.31 |
| resnet 1 | 2.51 | 0.78 | 2.49 |
| attention 1 | 2.70 | 0.99 | 2.68 |
Each experimental column changes one variable vs the "plain" column:
- GN fix: every
nn.GroupNormin the model patched topytorch_compatible=True. - PT temb: the timestep embedding for the MLX forward is replaced by the exact
numpy tensor produced by PyTorch's
time_embedding.
The GN fix collapses resnet-0 error by ~44x (from 1.83 to 0.042 — roughly at fp32 tolerance). Substituting the PT timestep embedding alone barely moves the needle at this stage.
Bugs identified
Bug 1 (primary, high impact): nn.GroupNorm uses MLX-native mode, not PyTorch-compatible mode
mlx.nn.GroupNorm.__init__ defaults to pytorch_compatible=False, which implements a
different normalization scheme. Standalone test with identity affine parameters on a
(1, 8, 8, 320) tensor:
MLX default vs PyTorch GroupNorm: max |diff| = 3.45e-01
MLX pytorch_compatible=True vs PyTorch GroupNorm: max |diff| = 7.15e-07
Every nn.GroupNorm in the MLX port is affected. Locations:
hunyuanpaintpbr_mlx/unet/blocks_mlx.pylines 64, 66 (ResnetBlock2D)hunyuanpaintpbr_mlx/unet/blocks_mlx.pyline 403 (Transformer2DModel.norm)hunyuanpaintpbr_mlx/unet/modules_mlx.pyline 338 (2.5D Transformer2DModel.norm)hunyuanpaintpbr_mlx/unet/unet_mlx.pyline 153 (conv_norm_out)hunyuanpaintpbr_mlx/vae_mlx.pylines 24, 26, 59, 221, 263
Fix: pass pytorch_compatible=True on every nn.GroupNorm(...) construction site. The
drill-down showed that applying this fix at runtime shrinks the resnet-0 max_abs from 1.83
to 0.042 (with the remaining 0.042 attributable to Bug 2).
Bug 2 (secondary, clearly measurable): timestep-embedding frequency denominator
hunyuanpaintpbr_mlx/unet/blocks_mlx.py line 20 uses:
emb = math.log(10000) / (half_dim - 1)
Diffusers' get_timestep_embedding with downscale_freq_shift=0 (the value SD 2.1 uses) divides by
half_dim, not half_dim - 1. Direct comparison at dim=320, t=500:
[timestep_sinusoidal] max_abs=1.06 mean_abs=0.21 [MISMATCH]
The first element matches (frequency 0) but all others drift — by up to 1.06 on individual entries. Swapping MLX's timestep embedding for PyTorch's only moved resnet-0 max_abs by ~0.02 at this stage, so Bug 2 is quantitatively smaller than Bug 1 for this particular input, but it is still present and will stack with Bug 1 after many layers / non-trivial text inputs.
Fix: change to emb = math.log(10000) / half_dim (and in principle take a
downscale_freq_shift argument, but 0 is what we need for SD 2.1).
Non-bug: the residual attention error (~0.75 after GN fix)
After fixing GroupNorm, attention-0 still shows max_abs ~0.75 at down_blocks[0]. This is
explained by:
- Its input already carries ~0.042 error from resnet-0.
- The first attention's softmax and proj_in amplify small input perturbations.
- Fixing Bug 2 removes a further small component.
No obvious structural bug in the attention code was found during the drill-down; the QKV
weights compare bit-identically, heads=5 matches PT's per-block head-dim config, and
to_out path matches. Recommend re-running this comparison after fixing Bugs 1 and 2 to
confirm attention falls within tolerance.
How to reproduce
cd /Users/dgrauet/Work/Hunyuan3D-2.1-mlx/hy3dpaint
python3 tests/compare_mlx_pytorch.py
The script prints MATCH/MISMATCH verdicts with max absolute difference, max relative difference, PT-norm and MLX-norm for every comparison, and writes a summary here.
Key files referenced:
/Users/dgrauet/Work/Hunyuan3D-2.1-mlx/hy3dpaint/tests/compare_mlx_pytorch.py/Users/dgrauet/Work/Hunyuan3D-2.1-mlx/hy3dpaint/hunyuanpaintpbr_mlx/unet/blocks_mlx.py/Users/dgrauet/Work/Hunyuan3D-2.1-mlx/hy3dpaint/hunyuanpaintpbr_mlx/unet/modules_mlx.py/Users/dgrauet/Work/Hunyuan3D-2.1-mlx/hy3dpaint/hunyuanpaintpbr_mlx/unet/unet_mlx.py/Users/dgrauet/Work/Hunyuan3D-2.1-mlx/hy3dpaint/hunyuanpaintpbr_mlx/vae_mlx.py