xocialize commited on
Commit
09cb542
·
verified ·
1 Parent(s): 6ddf602

Moebius 0.22B diffusion inpainting fp16/fp32 — first diffusion pipeline in coreai-community

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ moebius-unet-fp16-b2.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
37
+ moebius-vae-decoder-fp16-b1.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
38
+ moebius-vae-encoder-fp32-b2.aimodel/main.mlirb filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - coreai
5
+ - image-inpainting
6
+ - image-to-image
7
+ - diffusion
8
+ - apple-silicon
9
+ - moebius
10
+ library_name: coreai
11
+ ---
12
+
13
+ # Moebius-CoreAI
14
+
15
+ [Moebius](https://github.com/hustvl/Moebius) — the 0.22B lightweight diffusion inpainting model
16
+ (object removal / image completion, Places2 fine-tune) — as **CoreAI `.aimodel` assets** for
17
+ Apple silicon, exported from the original [hustvl checkpoints](https://huggingface.co/hustvl/Moebius)
18
+ (MIT weights).
19
+
20
+ To our knowledge the first diffusion pipeline in `coreai-community`.
21
+
22
+ | asset | role | dtype | size | PSNR vs PyTorch golden |
23
+ |---|---|---|---|---|
24
+ | `moebius-unet-fp16-b2.aimodel` | denoiser (CFG batch-2) | fp16 | 452 MB | **68.3 dB** |
25
+ | `moebius-vae-encoder-fp32-b2.aimodel` | VAE posterior mean | fp32 | 137 MB | **104.7 dB** |
26
+ | `moebius-vae-decoder-fp16-b1.aimodel` | VAE decoder | fp16 | 99 MB | **68.5 dB** |
27
+ | `embedding_table.npy` | 20×3072 category conditioning | fp32 | 246 KB | exact |
28
+
29
+ ## Numbers (measured, M5 Max, macOS 27)
30
+
31
+ - **UNet forward (fp16, GPU delegate): 49.8 ms** — 19-step CFG-2 projection **0.95 s**
32
+ (the MLX port of the same checkpoint: 117.6 ms / 2.23 s).
33
+ - **Accuracy**: rel 9.338e-04 vs the shared PyTorch golden — the same fp16 floor as the MLX port
34
+ (9.257e-04). The export folds all 124 BatchNorms into fp64-precomputed per-channel scale/shift
35
+ (the checkpoint carries subnormal `running_var` channels that do not survive a naive fp16 cast).
36
+ - The exported UNet carries exact, rank-safe rewrites of the LambdaNetworks attention (einsum →
37
+ broadcast/batched matmul; the positional Conv3d folded to a per-slice Conv2d) — numerically
38
+ gated at export (fp32 pre/post rel ≤ 5e-07).
39
+ - The VAE **encoder ships fp32**: the SD-VAE encoder exceeds fp16 activation range (45.6 dB and
40
+ CPU-lane NaN at fp16 — the classic `sdxl-vae-fp16-fix` problem). One encode per image makes
41
+ fp32's cost invisible next to the denoise loop.
42
+
43
+ ## Placement — GPU today, honestly
44
+
45
+ These assets run on the **GPU delegate**. Full-model Neural Engine compilation is currently
46
+ blocked by an ANECCompiler bug we filed with a validated repro —
47
+ [apple/coreai-models#138](https://github.com/apple/coreai-models/issues/138) (two 64²-level
48
+ transformer instances per graph break the input-channel-split pass, value-dependently). 17/18
49
+ model components already compile for ANE individually; when the OS compiler fixes #138 these
50
+ assets inherit the ANE by re-export, no consumer change. Note the failure mode: an ANE request
51
+ that fails **silently falls back to GPU** — verify placement with the GPU-idle signature, never
52
+ by "it ran".
53
+
54
+ ## Usage
55
+
56
+ Pipeline: encode `[image, masked_image]` (fp32, `[2,3,512,512]`, `[-1,1]`) → posterior mean ×
57
+ 0.13025 → DDIM (`scaled_linear` betas 0.00085–0.012, 20 steps, strength 0.99 → 19 steps from
58
+ t=900, CFG 2.5, noise offset 0.0357) over the UNet (`sample` `[2,9,64,64]` fp16 =
59
+ noisy(4)+mask(1)+masked(4), `timestep` `[2]` fp32, `encoder_hidden_states` `[2,10,3072]` fp16 =
60
+ table rows [10..19; 0..9]) → decode `latents / 0.13025` (fp16, `[1,4,64,64]`) → `(x+1)/2`.
61
+
62
+ A ready-made Swift package that does exactly this — scheduler, conditioning, image I/O,
63
+ mask compositing, MLXEngine integration, tests —
64
+ [`xocialize/coreai-moebius-swift`](https://github.com/xocialize/coreai-moebius-swift).
65
+
66
+ ```swift
67
+ import CoreAI
68
+ let model = try await AIModel(contentsOf: unetURL,
69
+ options: SpecializationOptions(preferredComputeUnitKind: .gpu))
70
+ let fn = try model.loadFunction(named: "main")!
71
+ // first load pays E5RT specialization (~40 s for the UNet, OS-cached after)
72
+ ```
73
+
74
+ ## Reproducibility
75
+
76
+ `export_unet.py` and `export_vae.py` (in this repo) re-create every asset from the original
77
+ checkpoints: PyTorch → `torch.export` → `coreai-torch` `TorchConverter` → `.aimodel`, with every
78
+ graph rewrite numerically gated in-line. No opaque binaries.
79
+
80
+ ## Provenance & license
81
+
82
+ Model: [hustvl/Moebius](https://github.com/hustvl/Moebius) (paper:
83
+ [arXiv:2606.19195](https://arxiv.org/abs/2606.19195)) — **MIT weights**, Apache-2.0 reference
84
+ code. VAE: the SD KL-f8 autoencoder distributed with PixelHacker (MIT). This repo redistributes
85
+ the weights in a converted container under MIT, with the conversion scripts included.
embedding_table.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2c07143837314b0b5835d00011bd5f68f2bbf42e1c657012e4d7a0871a33cfc
3
+ size 245888
export_unet.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = [
4
+ # "coreai-core==1.0.0b2",
5
+ # "coreai-torch==0.4.1",
6
+ # "diffusers",
7
+ # "timm",
8
+ # "einops",
9
+ # "pyyaml",
10
+ # "numpy",
11
+ # ]
12
+ #
13
+ # [tool.uv]
14
+ # index-url = "https://pypi.org/simple"
15
+ # prerelease = "allow"
16
+ # index-strategy = "unsafe-best-match"
17
+ # ///
18
+ """Export the Moebius UNet to a CoreAI .aimodel.
19
+
20
+ WHY UNET-ONLY: the UNet is 38 of the 40 forwards per image and it IS the hypothesis under test
21
+ (depthwise-separable + MBConv + linear attention on ANE vs Metal — memory `mlx-no-grouped-conv3d`).
22
+ The VAE is 2 calls and does not move the measurement; it can follow using coreai-models' existing
23
+ VAEEncoder/VAEDecoder wrappers if the answer is favourable.
24
+
25
+ STATIC SHAPES throughout — required for ANE residency, and free here: Moebius is structurally
26
+ locked to 512² (spatially-baked `rel_pos_emb` + a √n reshape in the attention wrapper), so the
27
+ usual static-shape constraint costs nothing.
28
+
29
+ Run: uv run coreai/export_unet.py --dtype fp16
30
+ """
31
+ import argparse
32
+ import importlib
33
+ import shutil
34
+ import sys
35
+ import time
36
+ import types
37
+ from pathlib import Path
38
+
39
+ import numpy as np
40
+ import torch
41
+ import yaml
42
+
43
+ ROOT = Path(__file__).resolve().parent.parent
44
+ REF = ROOT / "reference"
45
+ sys.path.insert(0, str(REF))
46
+
47
+ CKPT = ROOT / "weights/Moebius/ft_places2/diffusion_pytorch_model.bin"
48
+ CFG = REF / "config/model_cfg/moebius.yaml"
49
+ NUM_EMBEDDINGS = 20
50
+
51
+
52
+ def load_unet():
53
+ """The reference UNet, without executing `model_lib/__init__.py` (it eagerly imports a GLA
54
+ variant needing flash-linear-attention — CUDA-first and unused by Moebius)."""
55
+ for name, path in [
56
+ ("model_lib", REF / "model_lib"),
57
+ ("model_lib.nets", REF / "model_lib/nets"),
58
+ ("model_lib.nets.layers", REF / "model_lib/nets/layers"),
59
+ ]:
60
+ m = types.ModuleType(name)
61
+ m.__path__ = [str(path)]
62
+ sys.modules[name] = m
63
+ mod = importlib.import_module("model_lib.nets.unet_lambda_prune_lite")
64
+
65
+ cfg = yaml.safe_load(CFG.read_text())
66
+ model_cfg = dict(cfg["model"])
67
+ model_type = model_cfg.pop("model_type")
68
+ model_cfg["sample_size"] = cfg["data"]["image_size"] // cfg["vae"]["downsample_ratio"]
69
+ model_cfg["num_embeddings"] = NUM_EMBEDDINGS
70
+ net = getattr(mod, model_type)(**model_cfg)
71
+
72
+ sd = torch.load(CKPT, map_location="cpu", weights_only=True)
73
+ # The checkpoint is the RemovalModel state dict: `diff_model.*` + `embedding_layer.weight`.
74
+ unet_sd = {k[len("diff_model."):]: v for k, v in sd.items() if k.startswith("diff_model.")}
75
+ missing, unexpected = net.load_state_dict(unet_sd, strict=True)
76
+ print(f"[export] unet load: missing={len(missing)} unexpected={len(unexpected)}")
77
+ net.eval() # the 124 BatchNorms must use running statistics
78
+ embedding = sd["embedding_layer.weight"] # [20, 3072]
79
+ return net, embedding
80
+
81
+
82
+ def patch_nearest_upsample(module: torch.nn.Module) -> int:
83
+ """Replace nearest-neighbour interpolate with repeat_interleave in `Upsample2D`.
84
+
85
+ LIFTED FROM coreai-models (`diffusion/components.py::_patch_nearest_upsample`) — and it is
86
+ load-bearing, not cosmetic: MPSGraph's segmenter REJECTS `coreai.interpolate` with
87
+ nearest_neighbor mode and routes those ops to the BNNS (CPU) backend. That both breaks
88
+ single-backend execution and inserts GPU→CPU→GPU copies at every upsample boundary. Exporting
89
+ without this yields a graph that quietly falls off the accelerator — and then a benchmark that
90
+ measures the wrong thing.
91
+
92
+ `repeat_interleave` is mathematically identical for integer scale factors.
93
+ """
94
+ from diffusers.models.upsampling import Upsample2D
95
+
96
+ patched = 0
97
+ for mod in module.modules():
98
+ if isinstance(mod, Upsample2D) and not mod.use_conv_transpose:
99
+ def _forward(hidden_states, output_size=None, _mod=mod):
100
+ h = hidden_states.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1)
101
+ return _mod.conv(h)
102
+ mod.forward = _forward
103
+ patched += 1
104
+ return patched
105
+
106
+
107
+ def patch_lambda_einsums() -> None:
108
+ """Rewrite the two λ positional einsums to rank-≤4 matmul form, for ANE eligibility.
109
+
110
+ WHY (measured 2026-08-01): requesting `neuralEngine` on the unpatched export fails to compile —
111
+ 17× `MPS-ANEC conversion failure: mps.reshape input/output rank 6 exceeds the max rank 5`, all
112
+ from `vanillaλ.py:146-147`, then `_ANECompiler: ANECCompile() FAILED`. torch.export decomposes
113
+ `einsum('n m k u, b u v m -> b n k v')` (six distinct indices) through rank-6 reshapes, and
114
+ **ANE's maximum tensor rank is 5**. The GPU delegate doesn't care; the ANE hard-rejects it.
115
+
116
+ Both equations fold to plain (batched) matmuls with NO change in value — same trick the MLX
117
+ port's `applyPositionalLambda` uses for memory reasons. One structural quirk, two backends,
118
+ two different symptoms.
119
+
120
+ SEAM: `_einsum` is a module-level lambda in `layers/utils.py`, but `vanillaλ.py` binds the NAME
121
+ at import (`from ..utils import _einsum`), so patching utils after the fact would be a no-op.
122
+ Rebinding the vanillaλ module global covers all four call sites (self- and cross-lambda) in one
123
+ move. Dispatch on the equation string; everything else falls through to the original — the
124
+ remaining λ einsums are rank ≤ 4 already and drew no validation warnings.
125
+
126
+ The export flow numerically gates this patch (fp32 eager, pre- vs post-patch) before casting.
127
+ """
128
+ vλ = importlib.import_module("model_lib.nets.layers.λ.vanillaλ")
129
+ original = vλ._einsum
130
+
131
+ def _patched(eq, *ops):
132
+ if eq == 'n m k u, b u v m -> b n k v':
133
+ # Broadcast-matmul form: [1,N,K,MU] @ [B,1,MU,Vd] → [B,N,K,Vd]. The earlier
134
+ # [NK, MU]-flattened form put N·K on one axis — 65536 at the 64² level, past the
135
+ # ANE's per-axis limit; this keeps every axis ≤ max(N, MU, K, Vd).
136
+ rel, V = ops # [N,M,K,U], [B,U,Vd,M]
137
+ N, M, K, U = rel.shape
138
+ B, _, Vd, _ = V.shape
139
+ A = rel.permute(0, 2, 1, 3).reshape(1, N, K, M * U)
140
+ Bm = V.permute(0, 3, 1, 2).reshape(B, 1, M * U, Vd)
141
+ return (A @ Bm).contiguous() # [B,N,K,Vd]
142
+ if eq == 'b h k n, b n k v -> b h v n':
143
+ Q, lam = ops # [B,H,K,N], [B,N,K,Vd]
144
+ Qbn = Q.permute(0, 3, 1, 2) # [B,N,H,K]
145
+ Y = Qbn @ lam # [B,N,H,Vd] — batched, rank 4
146
+ return Y.permute(0, 2, 3, 1).contiguous() # [B,H,Vd,N]
147
+ return original(eq, *ops)
148
+
149
+ vλ._einsum = _patched
150
+
151
+
152
+ def patch_self_lambda_forward() -> None:
153
+ """Replace MultiQuerySelfLambda.forward with a rank-5-free, ANE-eligible formulation.
154
+
155
+ WHY (stage-bisected, probe_ane_selflambda.py): the self-λ takes the LOCAL positional branch —
156
+ `pos_conv = Conv3d(u, k, (1, r, r))` over V as [b,u,v,hh,ww]. The Conv3d itself compiles for
157
+ ANE (s4a: OK) — but any reshape/flatten CONSUMING its rank-5 output does not (s4e/s4f: FAIL;
158
+ s4d, the same matmul fed rank-4 tensors: OK). The fix never materialises rank 5: with u=1 and
159
+ depth-kernel 1, the Conv3d IS a Conv2d over each v-slice, so fold v into the conv batch and
160
+ land the output directly in matmul layout. The positional application then runs as a batched
161
+ matmul over n (the same rewrite as the MLX port's `applyPositionalLambda` — third appearance
162
+ of this contraction, third backend-specific formulation).
163
+
164
+ Numerically gated by the export's fp32 pre/post-patch eager comparison, same as the einsums.
165
+ """
166
+ import torch.nn.functional as F
167
+
168
+ vλ = importlib.import_module("model_lib.nets.layers.λ.vanillaλ")
169
+
170
+ def forward(self, x): # x: [b, hh, ww, c]
171
+ b, hh, ww, _ = x.shape
172
+ n = hh * ww
173
+ xc = x.permute(0, 3, 1, 2) # 'b h w c -> b c h w'
174
+ q = self.to_q(xc)
175
+ k = self.to_k(xc)
176
+ v = self.to_v(xc)
177
+ Q = self.norm_q(q)
178
+ V = self.norm_v(v)
179
+ h, u = self.heads, self.u
180
+ dk = q.shape[1] // h
181
+ dv = V.shape[1] // u
182
+ Q = Q.reshape(b, h, dk, n)
183
+ k = k.reshape(b, u, dk, n).softmax(dim=-1)
184
+ V = V.reshape(b, u, dv, n)
185
+
186
+ lam_c = torch.einsum('b u k m, b u v m -> b k v', k, V)
187
+ Yc = torch.einsum('b h k n, b k v -> b h v n', Q, lam_c)
188
+
189
+ assert self.local_contexts and u == 1 and self.pos_conv.weight.shape[2] == 1, \
190
+ "rank-5-free fold assumes the local branch with u=1 and depth-kernel 1"
191
+ w2d = self.pos_conv.weight.squeeze(2) # [k, u, r, r]
192
+ Vb = V.reshape(b * dv, u, hh, ww) # u=1: ONE rank-4 reshape, no rank-5
193
+ lam = F.conv2d(Vb, w2d, self.pos_conv.bias, padding=self.pos_conv.padding[1])
194
+ lam = lam.reshape(b, dv, dk, n).permute(0, 3, 2, 1) # [b,n,k,v]
195
+ Yp = (Q.permute(0, 3, 1, 2) @ lam).permute(0, 2, 3, 1) # [b,h,v,n]
196
+
197
+ Y = Yc + Yp
198
+ out = Y.reshape(b, h * dv, n).permute(0, 2, 1) # 'b h v (hh ww) -> b (hh ww) c'
199
+ return out.reshape(b, hh, ww, h * dv) # module contract: 'b h w c'
200
+
201
+ vλ.MultiQuerySelfLambda.forward = forward
202
+
203
+
204
+ class PrecomputedBN(torch.nn.Module):
205
+ """BatchNorm replaced by per-channel scale/shift, constants computed at fp64 THEN cast.
206
+
207
+ WHY: the fp16 export sits at 41.4 dB vs the golden while MLX fp16 manages rel 9.3e-04 on the
208
+ same checkpoint. 25 of the 124 running_var tensors are below fp16's min-normal; evaluating
209
+ (x-mean)·rsqrt(var+eps) in fp16 arithmetic mangles those channels. The COMPOSITE constants
210
+ scale = γ/√(var+ε) and shift = β − mean·scale are fp16-representable even where var is not
211
+ (γ/√(8e-07) ≈ 3000γ ≪ 65504), so fold the four tensors into two at full precision first.
212
+ Numerically this is the same inference function — only the evaluation order changes.
213
+ """
214
+
215
+ def __init__(self, bn: torch.nn.Module, spatial: bool):
216
+ super().__init__()
217
+ var = bn.running_var.data.double()
218
+ mean = bn.running_mean.data.double()
219
+ gamma = bn.weight.data.double()
220
+ beta = bn.bias.data.double()
221
+ scale = gamma / torch.sqrt(var + bn.eps)
222
+ shift = beta - mean * scale
223
+ shape = (1, -1, 1, 1) if spatial else (1, -1, 1)
224
+ self.register_buffer("scale", scale.float().reshape(shape))
225
+ self.register_buffer("shift", shift.float().reshape(shape))
226
+ # ⚠️ 32 of the 124 "BatchNorms" are timm BatchNormAct2d — a subclass whose forward
227
+ # appends drop + activation (ReLU here). isinstance(BatchNorm2d) matches it, and a
228
+ # replacement that drops the activation diverges by rel ~1.0. The numeric gate caught
229
+ # this; carry the epilogue through.
230
+ self.act = getattr(bn, "act", None) or torch.nn.Identity()
231
+ self.drop = getattr(bn, "drop", None) or torch.nn.Identity()
232
+
233
+ def forward(self, x):
234
+ return self.act(self.drop(x * self.scale + self.shift))
235
+
236
+
237
+ def patch_batchnorms_precomputed(module: torch.nn.Module) -> int:
238
+ replaced = 0
239
+ for parent in module.modules():
240
+ for name, child in list(parent.named_children()):
241
+ if isinstance(child, (torch.nn.BatchNorm2d, torch.nn.BatchNorm1d)):
242
+ setattr(parent, name,
243
+ PrecomputedBN(child, spatial=isinstance(child, torch.nn.BatchNorm2d)))
244
+ replaced += 1
245
+ return replaced
246
+
247
+
248
+ class MoebiusUNetWrapper(torch.nn.Module):
249
+ """Export surface: `(sample, timestep, encoder_hidden_states) -> noise prediction`.
250
+
251
+ The 20×3072 category table is deliberately left OUTSIDE the graph. Its lookup is a constant
252
+ gather (CFG always indexes rows 10–19 then 0–9), so the projected conditioning is identical on
253
+ every call — feeding it as an input keeps the graph free of an int64 embedding op, which is
254
+ friendlier to the accelerator, and lets the host hoist the lookup out of the 19-step loop
255
+ entirely.
256
+ """
257
+
258
+ def __init__(self, unet: torch.nn.Module) -> None:
259
+ super().__init__()
260
+ self.model = unet
261
+ n = patch_nearest_upsample(self.model)
262
+ print(f"[export] patched {n} Upsample2D module(s) → repeat_interleave")
263
+
264
+ def forward(self, sample, timestep, encoder_hidden_states):
265
+ return self.model(sample, timestep=timestep,
266
+ encoder_hidden_states=encoder_hidden_states).sample
267
+
268
+
269
+ def main() -> None:
270
+ ap = argparse.ArgumentParser()
271
+ ap.add_argument("--dtype", default="fp16", choices=["fp16", "fp32"])
272
+ ap.add_argument("--batch", type=int, default=2, help="2 = CFG-doubled, the production shape")
273
+ ap.add_argument("--out", default=str(ROOT / "coreai/exports"))
274
+ args = ap.parse_args()
275
+
276
+ from coreai_torch import TorchConverter, get_decomp_table
277
+
278
+ net, embedding = load_unet()
279
+ wrapper = MoebiusUNetWrapper(net).eval()
280
+
281
+ # Gate the λ einsum rewrite numerically BEFORE any cast: fp32 eager, pre- vs post-patch.
282
+ # The rewrite is algebraically exact; this catches a transcription slip, not a design flaw.
283
+ b = args.batch
284
+ torch.manual_seed(0)
285
+ probe = (torch.randn(b, 9, 64, 64), torch.full((b,), 900, dtype=torch.float32),
286
+ torch.randn(b, 10, 3072))
287
+ with torch.no_grad():
288
+ pre = wrapper(*probe)
289
+ patch_lambda_einsums()
290
+ patch_self_lambda_forward()
291
+ n_bn = patch_batchnorms_precomputed(wrapper)
292
+ print(f"[export] replaced {n_bn} BatchNorms with fp64-precomputed scale/shift")
293
+ with torch.no_grad():
294
+ post = wrapper(*probe)
295
+ gap = (pre - post).abs().max().item() / (pre.abs().max().item() + 1e-12)
296
+ print(f"[export] λ einsum rewrite gate: rel {gap:.3e} (fp32 eager, pre vs post)")
297
+ if gap > 1e-5:
298
+ raise SystemExit("[export] λ rewrite diverged from the original — refusing to export.")
299
+
300
+ dtype = torch.float16 if args.dtype == "fp16" else torch.float32
301
+ if dtype == torch.float16:
302
+ # UNIFORM fp16 — including BatchNorm statistics, which DIFFERS from the MLX side.
303
+ #
304
+ # convert_weights.py pins BN running stats to fp32 as a precaution against a running_var
305
+ # rounding toward zero (rsqrt then explodes). That precaution is free on MLX. Here it is
306
+ # not: mixing fp32 BatchNorm into an fp16 graph makes the lowering fail outright —
307
+ # "failed to legalize unresolved materialization from tensor<*xf32> to
308
+ # tensor<2x1280x16x16xf16>" inside the λ cross-attention, because norm_q/norm_v emit
309
+ # fp32 into fp16 einsums and PyTorch's silent promotion has no lowering equivalent.
310
+ #
311
+ # So the precaution was MEASURED rather than carried over: across all 124 running_var
312
+ # tensors the global minimum is 8.281e-07 — subnormal at fp16 but representable, and
313
+ # ZERO tensors round to zero. Even under flush-to-zero the result is bounded by
314
+ # eps (1/sqrt(1e-5) = 316), not infinite. Uniform fp16 is safe for THIS checkpoint;
315
+ # re-measure for any sibling before assuming it transfers.
316
+ wrapper = wrapper.half()
317
+
318
+ sample = torch.randn(b, 9, 64, 64, dtype=dtype)
319
+ timestep = torch.full((b,), 900, dtype=torch.float32)
320
+ context = torch.randn(b, 10, 3072, dtype=dtype)
321
+
322
+ print(f"[export] tracing — sample{tuple(sample.shape)} t{tuple(timestep.shape)} "
323
+ f"ctx{tuple(context.shape)} dtype={args.dtype}")
324
+ with torch.no_grad():
325
+ reference = wrapper(sample, timestep, context)
326
+ print(f"[export] eager forward ok → {tuple(reference.shape)}")
327
+
328
+ started = time.time()
329
+ ep = torch.export.export(wrapper, args=(sample, timestep, context))
330
+ ep = ep.run_decompositions(get_decomp_table())
331
+ print(f"[export] torch.export + decompositions: {time.time() - started:.1f}s")
332
+
333
+ started = time.time()
334
+ program = (
335
+ TorchConverter()
336
+ .add_exported_program(
337
+ ep,
338
+ input_names=["sample", "timestep", "encoder_hidden_states"],
339
+ output_names=["noise_pred"],
340
+ )
341
+ .to_coreai()
342
+ )
343
+ program.optimize()
344
+ print(f"[export] to_coreai + optimize: {time.time() - started:.1f}s")
345
+
346
+ out = Path(args.out) / f"moebius-unet-{args.dtype}-b{b}.aimodel"
347
+ out.parent.mkdir(parents=True, exist_ok=True)
348
+ if out.exists():
349
+ shutil.rmtree(out)
350
+ program.save_asset(out) # wants a Path, not a str
351
+ size = sum(f.stat().st_size for f in out.rglob("*") if f.is_file()) / 1e6
352
+ print(f"[export] saved {out} ({size:.0f} MB)")
353
+
354
+ # The conditioning is constant — bake it next to the asset so the runtime never recomputes it.
355
+ np.save(Path(args.out) / "embedding_table.npy", embedding.float().numpy())
356
+ print(f"[export] wrote embedding_table.npy {tuple(embedding.shape)} (host-side constant gather)")
357
+
358
+
359
+ if __name__ == "__main__":
360
+ main()
export_vae.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = [
4
+ # "coreai-core==1.0.0b2",
5
+ # "coreai-torch==0.4.1",
6
+ # "diffusers",
7
+ # "numpy",
8
+ # ]
9
+ #
10
+ # [tool.uv]
11
+ # index-url = "https://pypi.org/simple"
12
+ # prerelease = "allow"
13
+ # index-strategy = "unsafe-best-match"
14
+ # ///
15
+ """Export the Moebius VAE (AutoencoderKL, KL-f8) to CoreAI .aimodel assets.
16
+
17
+ Two assets, shaped for the pipeline's exact call pattern:
18
+ * encoder, batch 2, [2,3,512,512] -> posterior MEAN [2,4,64,64]
19
+ (one forward encodes image + masked_image together, as the pipeline does; the mean is the
20
+ deterministic moment the oracle/MLX ports gate on — no sampling in the graph)
21
+ * decoder, batch 1, [1,4,64,64] -> [1,3,512,512]
22
+
23
+ scaling_factor stays OUT of the graph (host-side scalar), matching oracle semantics.
24
+
25
+ `patch_nearest_upsample` is load-bearing here: the decoder carries 3 nearest-x2 Upsample2D
26
+ modules, exactly the op MPSGraph's segmenter rejects (routes to BNNS/CPU) — same fix as the UNet.
27
+
28
+ Run: uv run coreai/export_vae.py
29
+ """
30
+ import shutil
31
+ import time
32
+ from pathlib import Path
33
+
34
+ import torch
35
+
36
+ ROOT = Path(__file__).resolve().parent.parent
37
+ VAE_DIR = ROOT / "weights/PixelHacker/vae"
38
+ OUT = ROOT / "coreai/exports"
39
+
40
+
41
+ def patch_nearest_upsample(module: torch.nn.Module) -> int:
42
+ from diffusers.models.upsampling import Upsample2D
43
+
44
+ patched = 0
45
+ for mod in module.modules():
46
+ if isinstance(mod, Upsample2D) and not mod.use_conv_transpose:
47
+ def _forward(hidden_states, output_size=None, _mod=mod):
48
+ h = hidden_states.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1)
49
+ return _mod.conv(h)
50
+ mod.forward = _forward
51
+ patched += 1
52
+ return patched
53
+
54
+
55
+ class EncoderMean(torch.nn.Module):
56
+ """image [b,3,512,512] -> posterior mean [b,4,64,64] (deterministic; sf applied host-side)."""
57
+
58
+ def __init__(self, vae):
59
+ super().__init__()
60
+ self.encoder = vae.encoder
61
+ self.quant_conv = vae.quant_conv
62
+
63
+ def forward(self, image):
64
+ moments = self.quant_conv(self.encoder(image))
65
+ mean, _logvar = moments.chunk(2, dim=1)
66
+ return mean
67
+
68
+
69
+ class Decoder(torch.nn.Module):
70
+ """latents [b,4,64,64] (UNSCALED — divide by sf host-side first) -> image [b,3,512,512]."""
71
+
72
+ def __init__(self, vae):
73
+ super().__init__()
74
+ self.post_quant_conv = vae.post_quant_conv
75
+ self.decoder = vae.decoder
76
+
77
+ def forward(self, latents):
78
+ return self.decoder(self.post_quant_conv(latents))
79
+
80
+
81
+ def export(wrapper, example, name: str, dtype=torch.float16) -> None:
82
+ from coreai_torch import TorchConverter, get_decomp_table
83
+
84
+ # ⚠️ Eager sanity runs at fp32: torch's CPU fp16 conv path is `slow_conv2d` and a single
85
+ # 512² encoder forward at fp16 ground for 20+ CPU-MINUTES before being killed (the
86
+ # quantized-forward-on-CPU trap family). torch.export itself traces with fake tensors —
87
+ # no real compute — so only this sanity call ever executes kernels.
88
+ wrapper = wrapper.eval()
89
+ with torch.no_grad():
90
+ out = wrapper(*example)
91
+ print(f"[export] {name}: eager fp32 ok {tuple(example[0].shape)} -> {tuple(out.shape)}")
92
+ wrapper = wrapper.to(dtype)
93
+ example = tuple(t.to(dtype) for t in example)
94
+
95
+ started = time.time()
96
+ ep = torch.export.export(wrapper, args=example)
97
+ ep = ep.run_decompositions(get_decomp_table())
98
+ program = (TorchConverter()
99
+ .add_exported_program(ep, input_names=["x"], output_names=["out"])
100
+ .to_coreai())
101
+ program.optimize()
102
+ path = OUT / f"{name}.aimodel"
103
+ if path.exists():
104
+ shutil.rmtree(path)
105
+ program.save_asset(path)
106
+ size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1e6
107
+ print(f"[export] saved {path.name} ({size:.0f} MB, {time.time() - started:.1f}s)")
108
+
109
+
110
+ def main() -> None:
111
+ from diffusers.models import AutoencoderKL
112
+
113
+ vae = AutoencoderKL.from_pretrained(str(VAE_DIR)).eval()
114
+ print(f"[export] vae scaling_factor={vae.config.scaling_factor}")
115
+ n = patch_nearest_upsample(vae)
116
+ print(f"[export] patched {n} Upsample2D module(s) -> repeat_interleave")
117
+
118
+ # Encoder ships fp32: at fp16 it reads 45.6 dB (investigate) and produces NaN on the CPU
119
+ # lane — the classic SD-VAE fp16 activation-range problem, and mixed precision does not
120
+ # lower in a CoreAI graph (measured). One encode per image makes fp32's ~2x cost invisible.
121
+ export(EncoderMean(vae), (torch.randn(2, 3, 512, 512),), "moebius-vae-encoder-fp32-b2",
122
+ dtype=torch.float32)
123
+ # Decoder ships fp16: 68.5 dB [PASS] vs the shared golden.
124
+ export(Decoder(vae), (torch.randn(1, 4, 64, 64),), "moebius-vae-decoder-fp16-b1")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
moebius-unet-fp16-b2.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ ¡:��b_Hrc�?�����$G,�S�����g
moebius-unet-fp16-b2.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2a13af3c2625f487263a87f3f81dacbdde324472cd753b61d950ec818ece067
3
+ size 452411987
moebius-unet-fp16-b2.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "assetVersion" : "2.0",
3
+ "producer" : "coreai-core 1.0.0b2",
4
+ "creationDate" : "20260801T200746Z"
5
+ }
moebius-vae-decoder-fp16-b1.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ '����,�lJ�{��[�%�#�̹p�}d����)
moebius-vae-decoder-fp16-b1.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:27e915aab7982ca66c4abc7b93d81f5b8325d723fcccb9708b7d64c095d1ea29
3
+ size 99062610
moebius-vae-decoder-fp16-b1.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "assetVersion" : "2.0",
3
+ "creationDate" : "20260801T211110Z",
4
+ "producer" : "coreai-core 1.0.0b2"
5
+ }
moebius-vae-encoder-fp32-b2.aimodel/main.hash ADDED
@@ -0,0 +1 @@
 
 
1
+ �� !^9���~;�yy�b<qյ|`�a�fe?*�]
moebius-vae-encoder-fp32-b2.aimodel/main.mlirb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ced620215e39e8e6e67e3be6a57979f8623c71d5b57c60af61ef66653f2aef5d
3
+ size 136721157
moebius-vae-encoder-fp32-b2.aimodel/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "assetVersion" : "2.0",
3
+ "creationDate" : "20260801T211107Z",
4
+ "producer" : "coreai-core 1.0.0b2"
5
+ }