Hunyuan3D-2.2-mrp-MLX/hy3dpaint/tests/comparison_report.md
modelbeast e4cfa9d1e9 Clean MLX build for MODELBEAST (inference-only)
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
2026-07-16 14:38:14 +10:00

144 lines
6.5 KiB
Markdown

# 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
1. **MLX UNet built at `in_channels=12`** with `attention_head_dim=(5, 10, 20, 20)` (matching the
PyTorch config), no `_enhance_unet()` — we compare the vanilla UNet topology.
2. **PyTorch UNet rebuilt from `config.json` with `in_channels=12`** (the config says 4 but the
actual checkpoint has 12-channel `conv_in`). Loading via `from_pretrained` silently drops most
weights because the checkpoint's keys are nested inside `unet.` / `unet_dual.` with a
`.transformer.` wrapper for `BasicTransformerBlock`. We now:
- load the `.bin` file manually,
- strip the `unet.` prefix,
- collapse `transformer_blocks.0.transformer.X` to `transformer_blocks.0.X`,
- drop 2.5D-only tensors (`attn_multiview`, `attn_refview`, `attn_dino`, `.processor.`).
- This yields **686 weights loaded, 0 missing, 0 unexpected**.
## 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.GroupNorm` in the model patched to `pytorch_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.py` lines 64, 66 (ResnetBlock2D)
- `hunyuanpaintpbr_mlx/unet/blocks_mlx.py` line 403 (Transformer2DModel.norm)
- `hunyuanpaintpbr_mlx/unet/modules_mlx.py` line 338 (2.5D Transformer2DModel.norm)
- `hunyuanpaintpbr_mlx/unet/unet_mlx.py` line 153 (conv_norm_out)
- `hunyuanpaintpbr_mlx/vae_mlx.py` lines 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:
```python
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
```bash
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`