| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Export the Moebius UNet to a CoreAI .aimodel. |
| |
| WHY UNET-ONLY: the UNet is 38 of the 40 forwards per image and it IS the hypothesis under test |
| (depthwise-separable + MBConv + linear attention on ANE vs Metal — memory `mlx-no-grouped-conv3d`). |
| The VAE is 2 calls and does not move the measurement; it can follow using coreai-models' existing |
| VAEEncoder/VAEDecoder wrappers if the answer is favourable. |
| |
| STATIC SHAPES throughout — required for ANE residency, and free here: Moebius is structurally |
| locked to 512² (spatially-baked `rel_pos_emb` + a √n reshape in the attention wrapper), so the |
| usual static-shape constraint costs nothing. |
| |
| Run: uv run coreai/export_unet.py --dtype fp16 |
| """ |
| import argparse |
| import importlib |
| import shutil |
| import sys |
| import time |
| import types |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import yaml |
|
|
| ROOT = Path(__file__).resolve().parent.parent |
| REF = ROOT / "reference" |
| sys.path.insert(0, str(REF)) |
|
|
| CKPT = ROOT / "weights/Moebius/ft_places2/diffusion_pytorch_model.bin" |
| CFG = REF / "config/model_cfg/moebius.yaml" |
| NUM_EMBEDDINGS = 20 |
|
|
|
|
| def load_unet(): |
| """The reference UNet, without executing `model_lib/__init__.py` (it eagerly imports a GLA |
| variant needing flash-linear-attention — CUDA-first and unused by Moebius).""" |
| for name, path in [ |
| ("model_lib", REF / "model_lib"), |
| ("model_lib.nets", REF / "model_lib/nets"), |
| ("model_lib.nets.layers", REF / "model_lib/nets/layers"), |
| ]: |
| m = types.ModuleType(name) |
| m.__path__ = [str(path)] |
| sys.modules[name] = m |
| mod = importlib.import_module("model_lib.nets.unet_lambda_prune_lite") |
|
|
| cfg = yaml.safe_load(CFG.read_text()) |
| model_cfg = dict(cfg["model"]) |
| model_type = model_cfg.pop("model_type") |
| model_cfg["sample_size"] = cfg["data"]["image_size"] // cfg["vae"]["downsample_ratio"] |
| model_cfg["num_embeddings"] = NUM_EMBEDDINGS |
| net = getattr(mod, model_type)(**model_cfg) |
|
|
| sd = torch.load(CKPT, map_location="cpu", weights_only=True) |
| |
| unet_sd = {k[len("diff_model."):]: v for k, v in sd.items() if k.startswith("diff_model.")} |
| missing, unexpected = net.load_state_dict(unet_sd, strict=True) |
| print(f"[export] unet load: missing={len(missing)} unexpected={len(unexpected)}") |
| net.eval() |
| embedding = sd["embedding_layer.weight"] |
| return net, embedding |
|
|
|
|
| def patch_nearest_upsample(module: torch.nn.Module) -> int: |
| """Replace nearest-neighbour interpolate with repeat_interleave in `Upsample2D`. |
| |
| LIFTED FROM coreai-models (`diffusion/components.py::_patch_nearest_upsample`) — and it is |
| load-bearing, not cosmetic: MPSGraph's segmenter REJECTS `coreai.interpolate` with |
| nearest_neighbor mode and routes those ops to the BNNS (CPU) backend. That both breaks |
| single-backend execution and inserts GPU→CPU→GPU copies at every upsample boundary. Exporting |
| without this yields a graph that quietly falls off the accelerator — and then a benchmark that |
| measures the wrong thing. |
| |
| `repeat_interleave` is mathematically identical for integer scale factors. |
| """ |
| from diffusers.models.upsampling import Upsample2D |
|
|
| patched = 0 |
| for mod in module.modules(): |
| if isinstance(mod, Upsample2D) and not mod.use_conv_transpose: |
| def _forward(hidden_states, output_size=None, _mod=mod): |
| h = hidden_states.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1) |
| return _mod.conv(h) |
| mod.forward = _forward |
| patched += 1 |
| return patched |
|
|
|
|
| def patch_lambda_einsums() -> None: |
| """Rewrite the two λ positional einsums to rank-≤4 matmul form, for ANE eligibility. |
| |
| WHY (measured 2026-08-01): requesting `neuralEngine` on the unpatched export fails to compile — |
| 17× `MPS-ANEC conversion failure: mps.reshape input/output rank 6 exceeds the max rank 5`, all |
| from `vanillaλ.py:146-147`, then `_ANECompiler: ANECCompile() FAILED`. torch.export decomposes |
| `einsum('n m k u, b u v m -> b n k v')` (six distinct indices) through rank-6 reshapes, and |
| **ANE's maximum tensor rank is 5**. The GPU delegate doesn't care; the ANE hard-rejects it. |
| |
| Both equations fold to plain (batched) matmuls with NO change in value — same trick the MLX |
| port's `applyPositionalLambda` uses for memory reasons. One structural quirk, two backends, |
| two different symptoms. |
| |
| SEAM: `_einsum` is a module-level lambda in `layers/utils.py`, but `vanillaλ.py` binds the NAME |
| at import (`from ..utils import _einsum`), so patching utils after the fact would be a no-op. |
| Rebinding the vanillaλ module global covers all four call sites (self- and cross-lambda) in one |
| move. Dispatch on the equation string; everything else falls through to the original — the |
| remaining λ einsums are rank ≤ 4 already and drew no validation warnings. |
| |
| The export flow numerically gates this patch (fp32 eager, pre- vs post-patch) before casting. |
| """ |
| vλ = importlib.import_module("model_lib.nets.layers.λ.vanillaλ") |
| original = vλ._einsum |
|
|
| def _patched(eq, *ops): |
| if eq == 'n m k u, b u v m -> b n k v': |
| |
| |
| |
| rel, V = ops |
| N, M, K, U = rel.shape |
| B, _, Vd, _ = V.shape |
| A = rel.permute(0, 2, 1, 3).reshape(1, N, K, M * U) |
| Bm = V.permute(0, 3, 1, 2).reshape(B, 1, M * U, Vd) |
| return (A @ Bm).contiguous() |
| if eq == 'b h k n, b n k v -> b h v n': |
| Q, lam = ops |
| Qbn = Q.permute(0, 3, 1, 2) |
| Y = Qbn @ lam |
| return Y.permute(0, 2, 3, 1).contiguous() |
| return original(eq, *ops) |
|
|
| vλ._einsum = _patched |
|
|
|
|
| def patch_self_lambda_forward() -> None: |
| """Replace MultiQuerySelfLambda.forward with a rank-5-free, ANE-eligible formulation. |
| |
| WHY (stage-bisected, probe_ane_selflambda.py): the self-λ takes the LOCAL positional branch — |
| `pos_conv = Conv3d(u, k, (1, r, r))` over V as [b,u,v,hh,ww]. The Conv3d itself compiles for |
| ANE (s4a: OK) — but any reshape/flatten CONSUMING its rank-5 output does not (s4e/s4f: FAIL; |
| s4d, the same matmul fed rank-4 tensors: OK). The fix never materialises rank 5: with u=1 and |
| depth-kernel 1, the Conv3d IS a Conv2d over each v-slice, so fold v into the conv batch and |
| land the output directly in matmul layout. The positional application then runs as a batched |
| matmul over n (the same rewrite as the MLX port's `applyPositionalLambda` — third appearance |
| of this contraction, third backend-specific formulation). |
| |
| Numerically gated by the export's fp32 pre/post-patch eager comparison, same as the einsums. |
| """ |
| import torch.nn.functional as F |
|
|
| vλ = importlib.import_module("model_lib.nets.layers.λ.vanillaλ") |
|
|
| def forward(self, x): |
| b, hh, ww, _ = x.shape |
| n = hh * ww |
| xc = x.permute(0, 3, 1, 2) |
| q = self.to_q(xc) |
| k = self.to_k(xc) |
| v = self.to_v(xc) |
| Q = self.norm_q(q) |
| V = self.norm_v(v) |
| h, u = self.heads, self.u |
| dk = q.shape[1] // h |
| dv = V.shape[1] // u |
| Q = Q.reshape(b, h, dk, n) |
| k = k.reshape(b, u, dk, n).softmax(dim=-1) |
| V = V.reshape(b, u, dv, n) |
|
|
| lam_c = torch.einsum('b u k m, b u v m -> b k v', k, V) |
| Yc = torch.einsum('b h k n, b k v -> b h v n', Q, lam_c) |
|
|
| assert self.local_contexts and u == 1 and self.pos_conv.weight.shape[2] == 1, \ |
| "rank-5-free fold assumes the local branch with u=1 and depth-kernel 1" |
| w2d = self.pos_conv.weight.squeeze(2) |
| Vb = V.reshape(b * dv, u, hh, ww) |
| lam = F.conv2d(Vb, w2d, self.pos_conv.bias, padding=self.pos_conv.padding[1]) |
| lam = lam.reshape(b, dv, dk, n).permute(0, 3, 2, 1) |
| Yp = (Q.permute(0, 3, 1, 2) @ lam).permute(0, 2, 3, 1) |
|
|
| Y = Yc + Yp |
| out = Y.reshape(b, h * dv, n).permute(0, 2, 1) |
| return out.reshape(b, hh, ww, h * dv) |
|
|
| vλ.MultiQuerySelfLambda.forward = forward |
|
|
|
|
| class PrecomputedBN(torch.nn.Module): |
| """BatchNorm replaced by per-channel scale/shift, constants computed at fp64 THEN cast. |
| |
| WHY: the fp16 export sits at 41.4 dB vs the golden while MLX fp16 manages rel 9.3e-04 on the |
| same checkpoint. 25 of the 124 running_var tensors are below fp16's min-normal; evaluating |
| (x-mean)·rsqrt(var+eps) in fp16 arithmetic mangles those channels. The COMPOSITE constants |
| scale = γ/√(var+ε) and shift = β − mean·scale are fp16-representable even where var is not |
| (γ/√(8e-07) ≈ 3000γ ≪ 65504), so fold the four tensors into two at full precision first. |
| Numerically this is the same inference function — only the evaluation order changes. |
| """ |
|
|
| def __init__(self, bn: torch.nn.Module, spatial: bool): |
| super().__init__() |
| var = bn.running_var.data.double() |
| mean = bn.running_mean.data.double() |
| gamma = bn.weight.data.double() |
| beta = bn.bias.data.double() |
| scale = gamma / torch.sqrt(var + bn.eps) |
| shift = beta - mean * scale |
| shape = (1, -1, 1, 1) if spatial else (1, -1, 1) |
| self.register_buffer("scale", scale.float().reshape(shape)) |
| self.register_buffer("shift", shift.float().reshape(shape)) |
| |
| |
| |
| |
| self.act = getattr(bn, "act", None) or torch.nn.Identity() |
| self.drop = getattr(bn, "drop", None) or torch.nn.Identity() |
|
|
| def forward(self, x): |
| return self.act(self.drop(x * self.scale + self.shift)) |
|
|
|
|
| def patch_batchnorms_precomputed(module: torch.nn.Module) -> int: |
| replaced = 0 |
| for parent in module.modules(): |
| for name, child in list(parent.named_children()): |
| if isinstance(child, (torch.nn.BatchNorm2d, torch.nn.BatchNorm1d)): |
| setattr(parent, name, |
| PrecomputedBN(child, spatial=isinstance(child, torch.nn.BatchNorm2d))) |
| replaced += 1 |
| return replaced |
|
|
|
|
| class MoebiusUNetWrapper(torch.nn.Module): |
| """Export surface: `(sample, timestep, encoder_hidden_states) -> noise prediction`. |
| |
| The 20×3072 category table is deliberately left OUTSIDE the graph. Its lookup is a constant |
| gather (CFG always indexes rows 10–19 then 0–9), so the projected conditioning is identical on |
| every call — feeding it as an input keeps the graph free of an int64 embedding op, which is |
| friendlier to the accelerator, and lets the host hoist the lookup out of the 19-step loop |
| entirely. |
| """ |
|
|
| def __init__(self, unet: torch.nn.Module) -> None: |
| super().__init__() |
| self.model = unet |
| n = patch_nearest_upsample(self.model) |
| print(f"[export] patched {n} Upsample2D module(s) → repeat_interleave") |
|
|
| def forward(self, sample, timestep, encoder_hidden_states): |
| return self.model(sample, timestep=timestep, |
| encoder_hidden_states=encoder_hidden_states).sample |
|
|
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--dtype", default="fp16", choices=["fp16", "fp32"]) |
| ap.add_argument("--batch", type=int, default=2, help="2 = CFG-doubled, the production shape") |
| ap.add_argument("--out", default=str(ROOT / "coreai/exports")) |
| args = ap.parse_args() |
|
|
| from coreai_torch import TorchConverter, get_decomp_table |
|
|
| net, embedding = load_unet() |
| wrapper = MoebiusUNetWrapper(net).eval() |
|
|
| |
| |
| b = args.batch |
| torch.manual_seed(0) |
| probe = (torch.randn(b, 9, 64, 64), torch.full((b,), 900, dtype=torch.float32), |
| torch.randn(b, 10, 3072)) |
| with torch.no_grad(): |
| pre = wrapper(*probe) |
| patch_lambda_einsums() |
| patch_self_lambda_forward() |
| n_bn = patch_batchnorms_precomputed(wrapper) |
| print(f"[export] replaced {n_bn} BatchNorms with fp64-precomputed scale/shift") |
| with torch.no_grad(): |
| post = wrapper(*probe) |
| gap = (pre - post).abs().max().item() / (pre.abs().max().item() + 1e-12) |
| print(f"[export] λ einsum rewrite gate: rel {gap:.3e} (fp32 eager, pre vs post)") |
| if gap > 1e-5: |
| raise SystemExit("[export] λ rewrite diverged from the original — refusing to export.") |
|
|
| dtype = torch.float16 if args.dtype == "fp16" else torch.float32 |
| if dtype == torch.float16: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| wrapper = wrapper.half() |
|
|
| sample = torch.randn(b, 9, 64, 64, dtype=dtype) |
| timestep = torch.full((b,), 900, dtype=torch.float32) |
| context = torch.randn(b, 10, 3072, dtype=dtype) |
|
|
| print(f"[export] tracing — sample{tuple(sample.shape)} t{tuple(timestep.shape)} " |
| f"ctx{tuple(context.shape)} dtype={args.dtype}") |
| with torch.no_grad(): |
| reference = wrapper(sample, timestep, context) |
| print(f"[export] eager forward ok → {tuple(reference.shape)}") |
|
|
| started = time.time() |
| ep = torch.export.export(wrapper, args=(sample, timestep, context)) |
| ep = ep.run_decompositions(get_decomp_table()) |
| print(f"[export] torch.export + decompositions: {time.time() - started:.1f}s") |
|
|
| started = time.time() |
| program = ( |
| TorchConverter() |
| .add_exported_program( |
| ep, |
| input_names=["sample", "timestep", "encoder_hidden_states"], |
| output_names=["noise_pred"], |
| ) |
| .to_coreai() |
| ) |
| program.optimize() |
| print(f"[export] to_coreai + optimize: {time.time() - started:.1f}s") |
|
|
| out = Path(args.out) / f"moebius-unet-{args.dtype}-b{b}.aimodel" |
| out.parent.mkdir(parents=True, exist_ok=True) |
| if out.exists(): |
| shutil.rmtree(out) |
| program.save_asset(out) |
| size = sum(f.stat().st_size for f in out.rglob("*") if f.is_file()) / 1e6 |
| print(f"[export] saved {out} ({size:.0f} MB)") |
|
|
| |
| np.save(Path(args.out) / "embedding_table.npy", embedding.float().numpy()) |
| print(f"[export] wrote embedding_table.npy {tuple(embedding.shape)} (host-side constant gather)") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|