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.
- moss_image_mlx/__init__.py +6 -0
- moss_image_mlx/cache.py +57 -0
- moss_image_mlx/cli.py +156 -0
- moss_image_mlx/dit.py +293 -0
- moss_image_mlx/export.py +135 -0
- moss_image_mlx/layout.py +105 -0
- moss_image_mlx/pipeline.py +260 -0
- moss_image_mlx/quantize.py +71 -0
- moss_image_mlx/scheduler.py +47 -0
- moss_image_mlx/text_encoder.py +211 -0
- moss_image_mlx/torch_randn.py +31 -0
- moss_image_mlx/vae.py +233 -0
- moss_image_mlx-0.1.0.dist-info/METADATA +156 -0
- moss_image_mlx-0.1.0.dist-info/RECORD +19 -0
- moss_image_mlx-0.1.0.dist-info/WHEEL +5 -0
- moss_image_mlx-0.1.0.dist-info/entry_points.txt +4 -0
- moss_image_mlx-0.1.0.dist-info/licenses/LICENSE +202 -0
- moss_image_mlx-0.1.0.dist-info/licenses/NOTICE +14 -0
- moss_image_mlx-0.1.0.dist-info/top_level.txt +1 -0
moss_image_mlx/cache.py
ADDED
|
@@ -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
|
moss_image_mlx/export.py
ADDED
|
@@ -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()
|