moss-image-mlx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """MOSS-Image on Apple Silicon (MLX)."""
2
+
3
+ from .pipeline import MOSSImagePipeline, Request, save
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["MOSSImagePipeline", "Request", "save", "__version__"]
@@ -0,0 +1,57 @@
1
+ """On-disk cache of prompt embeddings.
2
+
3
+ Re-rendering a prompt (new seed, steps, size or guidance) then skips the 9B text encoder entirely: no ~16 GB (bf16)
4
+ load and no encode. Entries are keyed by the encoder's identity (config, weight files, quantization, the
5
+ implementation's numerics version), the token limit and the exact prompt text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import mlx.core as mx
16
+
17
+ # Bump when the encoder numerics change, so stale embeddings are never reused.
18
+ ENCODER_NUMERICS_VERSION = "transformers-faithful-1"
19
+
20
+
21
+ def default_cache_dir() -> Path:
22
+ return Path(os.environ.get("MOSS_MLX_CACHE", Path.home() / ".cache" / "moss-image-mlx")) / "prompt-embeds"
23
+
24
+
25
+ def encoder_id(text_encoder_dir: str | Path) -> str:
26
+ d = Path(text_encoder_dir)
27
+ h = hashlib.sha256(ENCODER_NUMERICS_VERSION.encode())
28
+ h.update((d / "config.json").read_bytes())
29
+ for f in sorted(d.glob("*.safetensors")):
30
+ h.update(f"{f.name}:{f.stat().st_size}".encode())
31
+ return h.hexdigest()[:16]
32
+
33
+
34
+ class PromptCache:
35
+ def __init__(self, text_encoder_dir: str | Path, root: str | Path | None = None, tag: str = "bf16"):
36
+ """tag distinguishes encoder variants built from the same files (e.g. quantized at load)."""
37
+ self.dir = Path(root or default_cache_dir()) / f"{encoder_id(text_encoder_dir)}-{tag}"
38
+
39
+ def _path(self, prompt: str, max_tokens: int) -> Path:
40
+ key = hashlib.sha256(json.dumps([prompt, max_tokens], ensure_ascii=False).encode()).hexdigest()
41
+ return self.dir / f"{key}.safetensors"
42
+
43
+ def get(self, prompt: str, max_tokens: int) -> mx.array | None:
44
+ path = self._path(prompt, max_tokens)
45
+ if not path.exists():
46
+ return None
47
+ try:
48
+ return mx.load(str(path))["hidden"]
49
+ except Exception:
50
+ return None
51
+
52
+ def put(self, prompt: str, max_tokens: int, hidden: mx.array):
53
+ self.dir.mkdir(parents=True, exist_ok=True)
54
+ path = self._path(prompt, max_tokens)
55
+ tmp = path.with_suffix(".tmp.safetensors")
56
+ mx.save_safetensors(str(tmp), {"hidden": hidden})
57
+ tmp.replace(path)
moss_image_mlx/cli.py ADDED
@@ -0,0 +1,156 @@
1
+ """moss-mlx: MOSS-Image text-to-image on Apple Silicon (MLX).
2
+
3
+ Examples:
4
+ moss-mlx "A red ceramic teapot on a wooden table" --model ~/models/MOSS-Image-SFT -o teapot.png
5
+ moss-mlx "A poster titled HELLO" --model ... --seeds 1,2,3,4 -o poster.png # one load, four images
6
+ moss-mlx --parameters assets/images/005/parameters.json --model ... --transformer .../MOSS-Image-Base/transformer
7
+ moss-mlx --batch jobs.jsonl --model ... # one JSON object per line (Request fields, or {"parameters": path})
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from dataclasses import replace
15
+ from pathlib import Path
16
+
17
+ from .pipeline import MOSSImagePipeline, Request, save
18
+
19
+ TIERS = {"8bit": "VisualInference/MOSS-Image-SFT-MLX-8bit", "compact": "VisualInference/MOSS-Image-SFT-MLX-compact"}
20
+
21
+
22
+ def default_model() -> str:
23
+ """8bit on Macs with >= 24 GB of memory (9.2 GB peak at 1024^2), compact below (7.7 GB peak)."""
24
+ import subprocess
25
+
26
+ try:
27
+ mem = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True).stdout)
28
+ except Exception:
29
+ mem = 0
30
+ return TIERS["8bit"] if mem >= 24 * 1024**3 else TIERS["compact"]
31
+
32
+
33
+ def resolve_model(model: str | None) -> str:
34
+ """A local directory, a tier name (8bit, compact), or a Hugging Face repo id (downloaded and cached)."""
35
+ model = model or default_model()
36
+ model = TIERS.get(model, model)
37
+ if Path(model).expanduser().is_dir():
38
+ return str(Path(model).expanduser())
39
+ import subprocess
40
+
41
+ print(f"fetching {model} (cached after the first run)", file=sys.stderr)
42
+ # Download in a child process: the Hub client keeps several GB of transfer buffers that this process would
43
+ # otherwise still hold when the text encoder loads (measured +2.7 GB on the first run's peak).
44
+ code = "import sys; from huggingface_hub import snapshot_download; print(snapshot_download(sys.argv[1]))"
45
+ done = subprocess.run([sys.executable, "-c", code, model], stdout=subprocess.PIPE, text=True)
46
+ if done.returncode:
47
+ sys.exit(f"could not download {model}")
48
+ return done.stdout.strip().splitlines()[-1]
49
+
50
+
51
+ def _layers(text: str) -> list[int]:
52
+ return [int(x) for x in str(text).replace(",", " ").split()] if str(text).strip() else []
53
+
54
+
55
+ def quant_spec(text: str | None):
56
+ """'8' -> 8-bit g64 affine; '4g32' -> 4-bit g32; 'nvfp4' / 'mxfp8'; or a JSON spec."""
57
+ if not text or text in ("bf16", "none"):
58
+ return None
59
+ if text.startswith("{"):
60
+ return json.loads(text)
61
+ if text == "nvfp4":
62
+ return {"bits": 4, "group_size": 16, "mode": "nvfp4"}
63
+ if text == "mxfp8":
64
+ return {"bits": 8, "group_size": 32, "mode": "mxfp8"}
65
+ bits, _, group = text.partition("g")
66
+ return {"bits": int(bits), "group_size": int(group or 64)}
67
+
68
+
69
+ def _from_parameters(path) -> dict:
70
+ params = json.load(open(path))["parameters"]
71
+ return {"prompt": params["prompt"], "negative_prompt": params.get("negative_prompt", ""),
72
+ "width": params["width"], "height": params["height"], "steps": params["steps"],
73
+ "guidance": float(params["cfg"]), "shift": float(params["shift"]), "seed": params["seed"],
74
+ "uncond_skip_layers": _layers(params.get("uncond_skip_layers", "22"))}
75
+
76
+
77
+ def main(argv=None):
78
+ p = argparse.ArgumentParser(prog="moss-mlx", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
79
+ p.add_argument("prompt", nargs="?", help="Prompt text or layout JSON")
80
+ p.add_argument("--model", help="Tier (8bit, compact), Hugging Face repo id or local directory. Default: 8bit on "
81
+ ">= 24 GB Macs, compact below")
82
+ p.add_argument("--transformer", help="Override the transformer directory (e.g. Base with the shared encoder)")
83
+ p.add_argument("--parameters", help="Upstream parameters.json (official example format); CLI flags override it")
84
+ p.add_argument("--batch", help="JSONL file of jobs; rendered with each model component loaded once")
85
+ p.add_argument("--negative-prompt")
86
+ p.add_argument("--width", type=int)
87
+ p.add_argument("--height", type=int)
88
+ p.add_argument("--steps", type=int)
89
+ p.add_argument("--guidance", type=float)
90
+ p.add_argument("--shift", type=float)
91
+ p.add_argument("--seed", type=int)
92
+ p.add_argument("--seeds", help="Comma-separated seeds: one image per seed, loaded once")
93
+ p.add_argument("--uncond-skip-layers", help="e.g. '22'; empty string disables")
94
+ p.add_argument("-o", "--output", default="moss.png", help="Output PNG (several images get -<n> suffixes)")
95
+ p.add_argument("--dit-dtype", choices=("bfloat16", "float32"), help="Transformer precision (default: as stored)")
96
+ p.add_argument("--dit-quant", help="Quantize the transformer at load: 8, 6, 4g32, nvfp4, mxfp8 or JSON")
97
+ p.add_argument("--te-quant", help="Quantize the text encoder at load (same forms)")
98
+ p.add_argument("--precise", action="store_true", help="fp32 residual stream and elementwise ops (bf16 matmuls)")
99
+ p.add_argument("--no-cache", action="store_true", help="Do not read or write the prompt-embedding cache")
100
+ p.add_argument("--keep-resident", action="store_true", help="Do not free components between stages")
101
+ args = p.parse_args(argv)
102
+ args.model = resolve_model(args.model)
103
+
104
+ base = _from_parameters(args.parameters) if args.parameters else {}
105
+ for name in ("prompt", "negative_prompt", "width", "height", "steps", "guidance", "shift", "seed"):
106
+ if getattr(args, name) is not None:
107
+ base[name] = getattr(args, name)
108
+ if args.uncond_skip_layers is not None:
109
+ base["uncond_skip_layers"] = _layers(args.uncond_skip_layers)
110
+
111
+ jobs = []
112
+ if args.batch:
113
+ for line in Path(args.batch).read_text().splitlines():
114
+ if line.strip():
115
+ job = json.loads(line)
116
+ fields = _from_parameters(job.pop("parameters")) if "parameters" in job else {}
117
+ out = job.pop("output", None)
118
+ jobs.append((Request(**{**base, **fields, **job}), out))
119
+ else:
120
+ if "prompt" not in base:
121
+ p.error("a prompt, --parameters or --batch is required")
122
+ req = Request(**base)
123
+ seeds = [int(s) for s in args.seeds.split(",")] if args.seeds else [req.seed]
124
+ jobs = [(replace(req, seed=s), None) for s in seeds]
125
+ from .layout import check
126
+
127
+ for r, _ in jobs:
128
+ if r.prompt.lstrip().startswith("{"):
129
+ for problem in check(r.prompt, r.width, r.height):
130
+ print(f"layout warning: {problem}", file=sys.stderr)
131
+ out = Path(args.output)
132
+ jobs = [(r, o or (str(out) if len(jobs) == 1 else str(out.with_name(f"{out.stem}-{i}{out.suffix}"))))
133
+ for i, (r, o) in enumerate(jobs)]
134
+
135
+ import mlx.core as mx
136
+
137
+ dtype = getattr(mx, args.dit_dtype) if args.dit_dtype else None
138
+ pipe = MOSSImagePipeline(args.model, args.transformer, low_memory=not args.keep_resident, dit_dtype=dtype,
139
+ dit_quant=quant_spec(args.dit_quant), te_quant=quant_spec(args.te_quant),
140
+ cache_dir=False if args.no_cache else None, precise=args.precise)
141
+ start = time.perf_counter()
142
+
143
+ def progress(i, n):
144
+ print(f"\rstep {i + 1}/{n} {time.perf_counter() - start:7.1f} s", end="", file=sys.stderr, flush=True)
145
+
146
+ paths = dict((id(r), o) for r, o in jobs)
147
+ for req, image in pipe.generate_many([r for r, _ in jobs], on_step=progress):
148
+ print(file=sys.stderr)
149
+ save(image, req, paths[id(req)], {"timings": dict(pipe.timings), "precise": args.precise,
150
+ "dit_quant": args.dit_quant or "bf16", "te_quant": args.te_quant or "bf16"})
151
+ print(paths[id(req)])
152
+ print(json.dumps(pipe.timings), file=sys.stderr)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
moss_image_mlx/dit.py ADDED
@@ -0,0 +1,293 @@
1
+ """MOSS-Image diffusion transformer in MLX.
2
+
3
+ Mirrors MOSSImageTransformer in the official pipeline_moss_image.py (OpenMOSS, Apache-2.0) operation by operation:
4
+ bf16 weights and residual stream, RMSNorm reduced in fp32 then cast back before the weight multiply, RoPE phases in
5
+ fp32 (computed on the CPU, cast to bf16), the timestep sinusoid in float64, and a single shared adaLN modulation
6
+ for every block. Parameter names equal the reference state-dict keys (except time_embedding, see sanitize).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import mlx.core as mx
15
+ import mlx.nn as nn
16
+ import numpy as np
17
+
18
+
19
+ class RMSNorm(nn.Module):
20
+ """torch: (x.float() * rsqrt(mean(x.float()**2) + 1e-6)).to(x.dtype) * weight"""
21
+
22
+ def __init__(self, dim: int):
23
+ super().__init__()
24
+ self.weight = mx.ones((dim,))
25
+
26
+ def __call__(self, x):
27
+ return mx.fast.rms_norm(x.astype(mx.float32), None, 1e-6).astype(x.dtype) * self.weight
28
+
29
+
30
+ @mx.compile
31
+ def situ(hidden, gate):
32
+ return (4.0 * mx.tanh(gate / 4.0) * mx.sigmoid(gate)) * (25.0 * mx.tanh(hidden / 25.0))
33
+
34
+
35
+ @mx.compile
36
+ def _norm_modulate(x, weight, scale, shift):
37
+ """RMSNorm (fp32 reduction, cast, weight) then adaLN modulation, each op rounding like the reference."""
38
+ return (mx.fast.rms_norm(x.astype(mx.float32), None, 1e-6).astype(x.dtype) * weight) * (1 + scale) + shift
39
+
40
+
41
+ @mx.compile
42
+ def _gated_add(x, y, gate):
43
+ return x + y * gate
44
+
45
+
46
+ def silu_like_torch(x):
47
+ """torch's fused silu on bf16 computes in fp32 and rounds once."""
48
+ x32 = x.astype(mx.float32)
49
+ return (x32 * mx.sigmoid(x32)).astype(x.dtype)
50
+
51
+
52
+ @mx.compile
53
+ def rotate(x, sin, cos):
54
+ half = x.shape[-1] // 2
55
+ return x * cos + mx.concatenate([-x[..., half:], x[..., :half]], axis=-1) * sin
56
+
57
+
58
+ def attention(q, k, v, rope, heads: int):
59
+ """q, k, v: (B, N, dim); q and k are already per-head RMS-normalised. rope: (sin, cos), each (1, N, 1, hd)."""
60
+ b, n, dim = q.shape
61
+ hd = dim // heads
62
+ sin, cos = rope
63
+ q = rotate(q.reshape(b, n, heads, hd), sin, cos).transpose(0, 2, 1, 3)
64
+ k = rotate(k.reshape(b, n, heads, hd), sin, cos).transpose(0, 2, 1, 3)
65
+ v = v.reshape(b, n, heads, hd).transpose(0, 2, 1, 3)
66
+ out = mx.fast.scaled_dot_product_attention(q, k, v, scale=hd**-0.5)
67
+ return out.transpose(0, 2, 1, 3).reshape(b, n, dim)
68
+
69
+
70
+ def _head_norm(norm: RMSNorm, x, heads: int):
71
+ b, n, dim = x.shape
72
+ return norm(x.reshape(b, n, heads, dim // heads)).reshape(b, n, dim)
73
+
74
+
75
+ # "Precise" mode: matmuls and attention keep bf16 inputs (speed), everything elementwise runs in fp32 and the
76
+ # residual stream stays fp32, so bf16 rounding enters only at matmul/attention inputs.
77
+ F32, BF16 = mx.float32, mx.bfloat16
78
+
79
+
80
+ def _norm32(norm: RMSNorm, x):
81
+ return mx.fast.rms_norm(x.astype(F32), norm.weight.astype(F32), 1e-6)
82
+
83
+
84
+ def _head_norm32(norm: RMSNorm, x, heads: int):
85
+ b, n, dim = x.shape
86
+ return _norm32(norm, x.reshape(b, n, heads, dim // heads)).reshape(b, n, dim)
87
+
88
+
89
+ def _attention32(q, k, v, rope, heads: int):
90
+ """q, k fp32 (normalised), v bf16; RoPE in fp32, attention in bf16."""
91
+ b, n, dim = q.shape
92
+ hd = dim // heads
93
+ sin, cos = rope
94
+ q = rotate(q.reshape(b, n, heads, hd), sin, cos).astype(BF16).transpose(0, 2, 1, 3)
95
+ k = rotate(k.reshape(b, n, heads, hd), sin, cos).astype(BF16).transpose(0, 2, 1, 3)
96
+ v = v.reshape(b, n, heads, hd).transpose(0, 2, 1, 3)
97
+ out = mx.fast.scaled_dot_product_attention(q, k, v, scale=hd**-0.5)
98
+ return out.transpose(0, 2, 1, 3).reshape(b, n, dim)
99
+
100
+
101
+ class TextBlock(nn.Module):
102
+ def __init__(self, dim: int, ffn_dim: int, heads: int):
103
+ super().__init__()
104
+ self.heads = heads
105
+ self.norm1, self.norm2 = RMSNorm(dim), RMSNorm(dim)
106
+ self.q_norm, self.k_norm = RMSNorm(dim // heads), RMSNorm(dim // heads)
107
+ self.qkv = nn.Linear(dim, 3 * dim)
108
+ self.proj = nn.Linear(dim, dim)
109
+ self.ffn_gate, self.ffn_up = nn.Linear(dim, ffn_dim), nn.Linear(dim, ffn_dim)
110
+ self.ffn_down = nn.Linear(ffn_dim, dim)
111
+
112
+ def __call__(self, x, rope, precise: bool = False):
113
+ if precise:
114
+ q, k, v = mx.split(self.qkv(_norm32(self.norm1, x).astype(BF16)), 3, axis=-1)
115
+ q, k = _head_norm32(self.q_norm, q, self.heads), _head_norm32(self.k_norm, k, self.heads)
116
+ x = x + self.proj(_attention32(q, k, v, rope, self.heads)).astype(F32)
117
+ y = _norm32(self.norm2, x).astype(BF16)
118
+ h = situ(self.ffn_up(y).astype(F32), self.ffn_gate(y).astype(F32))
119
+ return x + self.ffn_down(h.astype(BF16)).astype(F32)
120
+ q, k, v = mx.split(self.qkv(self.norm1(x)), 3, axis=-1)
121
+ q, k = _head_norm(self.q_norm, q, self.heads), _head_norm(self.k_norm, k, self.heads)
122
+ x = x + self.proj(attention(q, k, v, rope, self.heads))
123
+ y = self.norm2(x)
124
+ return x + self.ffn_down(situ(self.ffn_up(y), self.ffn_gate(y)))
125
+
126
+
127
+ class TextProjection(nn.Module):
128
+ def __init__(self, text_dim: int, dim: int, ffn_dim: int, heads: int):
129
+ super().__init__()
130
+ self.proj = nn.Linear(text_dim, dim)
131
+ self.blocks = [TextBlock(dim, ffn_dim, heads) for _ in range(2)]
132
+
133
+ def __call__(self, x, rope, precise: bool = False):
134
+ x = self.proj(x.astype(BF16 if precise else self.proj.weight.dtype))
135
+ if precise:
136
+ x = x.astype(F32)
137
+ for block in self.blocks:
138
+ x = block(x, rope, precise)
139
+ return x
140
+
141
+
142
+ class Block(nn.Module):
143
+ def __init__(self, dim: int, ffn_dim: int, heads: int):
144
+ super().__init__()
145
+ self.heads = heads
146
+ self.norm1, self.norm2 = RMSNorm(dim), RMSNorm(dim)
147
+ self.q_norm, self.k_norm = RMSNorm(dim // heads), RMSNorm(dim // heads)
148
+ self.proj = {name: nn.Linear(dim, dim, bias=False) for name in ("q", "k", "v", "o")}
149
+ self.proj["ffn_up_gate"] = nn.Linear(dim, 2 * ffn_dim, bias=False)
150
+ self.proj["ffn_down"] = nn.Linear(ffn_dim, dim, bias=False)
151
+
152
+ def __call__(self, x, rope, mod, precise: bool = False):
153
+ shift_a, scale_a, gate_a, shift_f, scale_f, gate_f = (mod[:, i : i + 1] for i in range(6))
154
+ p = self.proj
155
+ if precise: # x and mod are fp32 here
156
+ y = (_norm32(self.norm1, x) * (1 + scale_a) + shift_a).astype(BF16)
157
+ q = _head_norm32(self.q_norm, p["q"](y), self.heads)
158
+ k = _head_norm32(self.k_norm, p["k"](y), self.heads)
159
+ x = x + p["o"](_attention32(q, k, p["v"](y), rope, self.heads)).astype(F32) * gate_a
160
+ y = (_norm32(self.norm2, x) * (1 + scale_f) + shift_f).astype(BF16)
161
+ hidden, gate = mx.split(p["ffn_up_gate"](y).astype(F32), 2, axis=-1)
162
+ return x + p["ffn_down"](situ(hidden, gate).astype(BF16)).astype(F32) * gate_f
163
+ y = _norm_modulate(x, self.norm1.weight, scale_a, shift_a)
164
+ q = _head_norm(self.q_norm, p["q"](y), self.heads)
165
+ k = _head_norm(self.k_norm, p["k"](y), self.heads)
166
+ x = _gated_add(x, p["o"](attention(q, k, p["v"](y), rope, self.heads)), gate_a)
167
+ y = _norm_modulate(x, self.norm2.weight, scale_f, shift_f)
168
+ hidden, gate = mx.split(p["ffn_up_gate"](y), 2, axis=-1)
169
+ return _gated_add(x, p["ffn_down"](situ(hidden, gate)), gate_f)
170
+
171
+
172
+ def rope_table(height: int, width: int, text_length: int, head_dim: int, base: float, dtype) -> tuple:
173
+ """(sin, cos), each (1, N, 1, head_dim), for image tokens then text tokens; coordinates (r, t, h, w)."""
174
+ bands_n = head_dim // 8
175
+ exponent = np.arange(bands_n, dtype=np.float32) / np.float32(bands_n)
176
+ bands = (np.float32(1) / (np.float32(base) ** exponent)).astype(np.float32)
177
+ h, w = np.meshgrid(np.arange(height), np.arange(width), indexing="ij")
178
+ image = np.stack([np.zeros_like(h), np.full_like(h, text_length), h, w], -1).reshape(-1, 4).astype(np.float32)
179
+ image[:, 2] -= np.float32((height - 1) / 2)
180
+ image[:, 3] -= np.float32((width - 1) / 2)
181
+ text = np.zeros((text_length, 4), dtype=np.float32)
182
+ text[:, 1] = np.arange(text_length, dtype=np.float32)
183
+ phase = np.concatenate([image, text], 0)[..., None] * bands # (N, 4, bands)
184
+ n = phase.shape[0]
185
+ sin = np.sin(phase).reshape(n, -1) # (N, head_dim / 2): axes r, t, h, w
186
+ cos = np.cos(phase).reshape(n, -1)
187
+ sin = mx.array(np.concatenate([sin, sin], -1)).astype(dtype)[None, :, None, :]
188
+ cos = mx.array(np.concatenate([cos, cos], -1)).astype(dtype)[None, :, None, :]
189
+ return sin, cos
190
+
191
+
192
+ def timestep_embedding(timestep: float, dim: int) -> np.ndarray:
193
+ """float64 sinusoid as in the reference (cos first), returned as float32."""
194
+ t = np.float64(np.float32(timestep))
195
+ half = dim // 2
196
+ freq = 10000.0 ** (-np.arange(half, dtype=np.float64) / half)
197
+ phase = t * freq
198
+ return np.concatenate([np.cos(phase), np.sin(phase)]).astype(np.float32)[None]
199
+
200
+
201
+ class MOSSImageTransformer(nn.Module):
202
+ def __init__(self, dim=3072, ffn_dim=8192, num_heads=24, num_layers=42, text_dim=4096, freq_dim=256,
203
+ latent_channels=128, rope_base=10000.0):
204
+ super().__init__()
205
+ self.config = dict(dim=dim, ffn_dim=ffn_dim, num_heads=num_heads, num_layers=num_layers, text_dim=text_dim,
206
+ freq_dim=freq_dim, latent_channels=latent_channels, rope_base=rope_base)
207
+ self.video_embedder = nn.Linear(latent_channels, dim)
208
+ self.text_embedder = TextProjection(text_dim, dim, ffn_dim, num_heads)
209
+ self.time_embedding = [nn.Linear(freq_dim, dim), nn.Linear(dim, dim)]
210
+ self.shared_block_modulation = nn.Linear(dim, 6 * dim)
211
+ self.layers = [Block(dim, ffn_dim, num_heads) for _ in range(num_layers)]
212
+ self.final_norm_video = RMSNorm(dim)
213
+ self.final_linear_video = nn.Linear(dim, latent_channels, bias=False)
214
+ self.video_head_modulation = nn.Linear(dim, 2 * dim)
215
+ self._rope_cache = {}
216
+
217
+ def modulation(self, timestep: float):
218
+ """Shared block modulation (1, 6, dim) and head modulation (1, 2, dim), both in the weight dtype."""
219
+ dtype = self.time_embedding[0].weight.dtype
220
+ emb = mx.array(timestep_embedding(timestep, self.config["freq_dim"])).astype(dtype)
221
+ time = self.time_embedding[1](silu_like_torch(self.time_embedding[0](emb)))
222
+ dim = self.config["dim"]
223
+ mod = self.shared_block_modulation(time).reshape(1, 6, dim)
224
+ head = self.video_head_modulation(time).reshape(1, 2, dim)
225
+ return mod, head
226
+
227
+ def rope(self, height: int, width: int, text_length: int, dtype):
228
+ key = (height, width, text_length, dtype)
229
+ if key not in self._rope_cache:
230
+ self._rope_cache = {key: rope_table(height, width, text_length, self.config["dim"] // self.config["num_heads"],
231
+ self.config["rope_base"], dtype)}
232
+ return self._rope_cache[key]
233
+
234
+ def __call__(self, latents, timestep: float, context, skip_layers=()):
235
+ """latents (1, 128, H, W) fp32; context (1, L, text_dim) with no padding. Returns velocity (1, 128, H, W)."""
236
+ b, c, height, width = latents.shape
237
+ if b != 1 or context.shape[0] != 1:
238
+ raise ValueError("The reference evaluates each sample separately; pass batch 1.")
239
+ dtype = self.video_embedder.weight.dtype
240
+ precise = bool(getattr(self, "precise", False)) and dtype != F32
241
+ mod, head = self.modulation(timestep)
242
+ video = self.video_embedder(latents.reshape(1, c, height * width).transpose(0, 2, 1).astype(dtype))
243
+ act = F32 if precise else dtype
244
+ if precise:
245
+ mod, head, video = mod.astype(F32), head.astype(F32), video.astype(F32)
246
+ sin, cos = self.rope(height, width, context.shape[1], act)
247
+ n_img = height * width
248
+ text = self.text_embedder(context, (sin[:, n_img:], cos[:, n_img:]), precise)
249
+ x = mx.concatenate([video, text], axis=1)
250
+ skip = set(skip_layers or ())
251
+ for index, layer in enumerate(self.layers):
252
+ if index not in skip:
253
+ x = layer(x, (sin, cos), mod, precise)
254
+ if precise:
255
+ x = _norm32(self.final_norm_video, x[:, :n_img]) * (1 + head[:, 1:2]) + head[:, 0:1]
256
+ x = self.final_linear_video(x.astype(BF16))
257
+ else:
258
+ x = self.final_norm_video(x[:, :n_img])
259
+ x = x * (1 + head[:, 1:2]) + head[:, 0:1]
260
+ x = self.final_linear_video(x)
261
+ return x.transpose(0, 2, 1).reshape(1, c, height, width)
262
+
263
+ @staticmethod
264
+ def sanitize(weights: dict) -> dict:
265
+ out = {}
266
+ for key, value in weights.items():
267
+ if key == "rope.bands": # derived constant, recomputed in fp32
268
+ continue
269
+ out[key.replace("time_embedding.2.", "time_embedding.1.")] = value
270
+ return out
271
+
272
+
273
+ def load_transformer(path: str | Path, dtype=None) -> MOSSImageTransformer:
274
+ """Load a `transformer/` directory: the original diffusers layout, or an exported MLX one (possibly quantized,
275
+ marked by "moss_image_mlx" in config.json)."""
276
+ path = Path(path)
277
+ config = json.loads((path / "config.json").read_text())
278
+ keys = ("dim", "ffn_dim", "num_heads", "num_layers", "text_dim", "freq_dim", "latent_channels", "rope_base")
279
+ model = MOSSImageTransformer(**{k: config[k] for k in keys if k in config})
280
+ exported = config.get("moss_image_mlx")
281
+ if exported and exported.get("quantization"):
282
+ from .quantize import quantize_dit
283
+
284
+ quantize_dit(model, exported["quantization"]) # build QuantizedLinear modules before loading their weights
285
+ weights = {}
286
+ for file in sorted(path.glob("*.safetensors")):
287
+ weights.update(mx.load(str(file)))
288
+ if not exported:
289
+ weights = model.sanitize(weights)
290
+ if dtype is not None:
291
+ weights = {k: v.astype(dtype) if mx.issubdtype(v.dtype, mx.floating) else v for k, v in weights.items()}
292
+ model.load_weights(list(weights.items()), strict=True)
293
+ return model
@@ -0,0 +1,135 @@
1
+ """Export a MOSS-Image checkpoint to the MLX release layout, optionally quantized.
2
+
3
+ moss-mlx-export --model ~/hf/MOSS-Image-SFT --dit-quant 8 --te-quant 4 --out ~/out/MOSS-Image-SFT-MLX-compact
4
+
5
+ Layout (each component config.json carries {"moss_image_mlx": {"format": 1, "quantization": spec | null}}):
6
+ config.json top-level summary (also what the Hub counts as a download for library "mlx")
7
+ model_index.json diffusers-style index of the components
8
+ transformer/ MLX parameter names, sharded safetensors
9
+ text_encoder/ Qwen3.5 language backbone only (vision tower dropped), norms stored as fp32 (1 + w)
10
+ tokenizer/, scheduler/ copied
11
+ vae/ decoder only, MLX conv layout, fp32
12
+ Quantization runs on the CPU stream and is evaluated module by module, so the bf16 source never sits in memory whole.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+ import mlx.core as mx
23
+ from mlx.utils import tree_flatten
24
+
25
+ from .cli import quant_spec
26
+ from .dit import load_transformer
27
+ from .quantize import describe, quantize_dit, quantize_text_encoder
28
+ from .text_encoder import TextEncoder
29
+ from .vae import load_vae_decoder
30
+
31
+ FORMAT = 1
32
+ SHARD_BYTES = 4 << 30
33
+
34
+
35
+ def _eval_by_module(model):
36
+ for _, module in model.named_modules():
37
+ params = [v for _, v in tree_flatten(module.parameters())]
38
+ if params:
39
+ mx.eval(params)
40
+
41
+
42
+ def _save_sharded(params: dict, out: Path, prefix: str = "model"):
43
+ out.mkdir(parents=True, exist_ok=True)
44
+ shards, cur, size = [], {}, 0
45
+ for k, v in params.items():
46
+ if cur and size + v.nbytes > SHARD_BYTES:
47
+ shards.append(cur)
48
+ cur, size = {}, 0
49
+ cur[k] = v
50
+ size += v.nbytes
51
+ shards.append(cur)
52
+ index = {}
53
+ for i, shard in enumerate(shards):
54
+ name = f"{prefix}-{i + 1:05d}-of-{len(shards):05d}.safetensors" if len(shards) > 1 else f"{prefix}.safetensors"
55
+ mx.save_safetensors(str(out / name), shard, metadata={"format": "mlx"})
56
+ index.update({k: name for k in shard})
57
+ if len(shards) > 1:
58
+ total = sum(v.nbytes for v in params.values())
59
+ (out / f"{prefix}.safetensors.index.json").write_text(json.dumps({"metadata": {"total_size": total},
60
+ "weight_map": index}, indent=1))
61
+
62
+
63
+ def _component_config(src: Path, spec, drop=()):
64
+ config = json.loads(src.read_text())
65
+ for key in drop:
66
+ config.pop(key, None)
67
+ config["moss_image_mlx"] = {"format": FORMAT, "quantization": spec}
68
+ return config
69
+
70
+
71
+ def export(model_dir, out, transformer_dir=None, dit_spec=None, te_spec=None, source_id=None, tier=None):
72
+ model_dir, out = Path(model_dir), Path(out)
73
+ transformer_dir = Path(transformer_dir) if transformer_dir else model_dir / "transformer"
74
+ out.mkdir(parents=True, exist_ok=True)
75
+ mx.set_default_device(mx.cpu)
76
+
77
+ dit = load_transformer(transformer_dir)
78
+ quantize_dit(dit, dit_spec)
79
+ _eval_by_module(dit)
80
+ _save_sharded(dict(tree_flatten(dit.parameters())), out / "transformer")
81
+ cfg = _component_config(transformer_dir / "config.json", dit_spec)
82
+ (out / "transformer" / "config.json").write_text(json.dumps(cfg, indent=2) + "\n")
83
+ del dit
84
+ mx.clear_cache()
85
+
86
+ te = TextEncoder(model_dir / "text_encoder", model_dir / "tokenizer")
87
+ quantize_text_encoder(te.model, te_spec)
88
+ _eval_by_module(te.model)
89
+ _save_sharded(dict(tree_flatten(te.model.parameters())), out / "text_encoder")
90
+ cfg = _component_config(model_dir / "text_encoder" / "config.json", te_spec, drop=("vision_config",))
91
+ cfg["architectures"] = ["Qwen3_5TextModel"]
92
+ (out / "text_encoder" / "config.json").write_text(json.dumps(cfg, indent=2) + "\n")
93
+ del te
94
+ mx.clear_cache()
95
+
96
+ vae = load_vae_decoder(model_dir / "vae")
97
+ _save_sharded(dict(tree_flatten(vae.parameters())), out / "vae", prefix="decoder")
98
+ cfg = _component_config(model_dir / "vae" / "config.json", None)
99
+ cfg["include_encoder"] = False
100
+ (out / "vae" / "config.json").write_text(json.dumps(cfg, indent=2) + "\n")
101
+
102
+ for name in ("tokenizer", "scheduler"):
103
+ (out / name).mkdir(exist_ok=True)
104
+ for f in (model_dir / name).iterdir():
105
+ if f.is_file() and f.suffix in (".json", ".jinja", ".txt", ".model"):
106
+ shutil.copy2(f, out / name / f.name)
107
+ index = json.loads((model_dir / "model_index.json").read_text())
108
+ index["moss_image_mlx"] = {"format": FORMAT}
109
+ (out / "model_index.json").write_text(json.dumps(index, indent=2) + "\n")
110
+ if (model_dir / "LICENSE").exists():
111
+ shutil.copy2(model_dir / "LICENSE", out / "LICENSE")
112
+ summary = {"model_type": "moss_image", "library_name": "moss-image-mlx", "format": FORMAT, "tier": tier,
113
+ "source_model": source_id, "transformer_quantization": describe(dit_spec),
114
+ "text_encoder_quantization": describe(te_spec),
115
+ "components": ["transformer", "text_encoder", "tokenizer", "vae", "scheduler"]}
116
+ (out / "config.json").write_text(json.dumps(summary, indent=2) + "\n")
117
+ return out
118
+
119
+
120
+ def main(argv=None):
121
+ p = argparse.ArgumentParser(prog="moss-mlx-export", description=__doc__,
122
+ formatter_class=argparse.RawDescriptionHelpFormatter)
123
+ p.add_argument("--model", required=True)
124
+ p.add_argument("--transformer")
125
+ p.add_argument("--dit-quant")
126
+ p.add_argument("--te-quant")
127
+ p.add_argument("--source-id")
128
+ p.add_argument("--tier")
129
+ p.add_argument("--out", required=True)
130
+ a = p.parse_args(argv)
131
+ print(export(a.model, a.out, a.transformer, quant_spec(a.dit_quant), quant_spec(a.te_quant), a.source_id, a.tier))
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()