Merge pull request #7 from EdwinjJ1/codex/apple-silicon-mps
Add experimental Apple silicon MPS inference support
This commit is contained in:
13 files changed
+619
-14
No files matched your search
@@ -16,10 +16,8 @@ outputs/
|
||||
.env.*
|
||||
*credential*
|
||||
github_ssh_public_key.txt
|
||||
/tests/
|
||||
/.github/workflows/tests.yml
|
||||
/examples/
|
||||
/docs/
|
||||
/export_diffusers.py
|
||||
.ruff_cache/
|
||||
/ckpt/
|
||||
|
||||
@@ -167,6 +167,10 @@ source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Apple Silicon / MPS
|
||||
|
||||
[Apple silicon Mac setup and limitations](docs/macos.md) · M1 Max / 32GB
|
||||
|
||||
<a id="python"></a>
|
||||
|
||||
### 🐍 Python
|
||||
|
||||
@@ -162,6 +162,10 @@ source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Apple Silicon / MPS
|
||||
|
||||
[Apple Silicon Mac 安装与限制](docs/macos_zh.md) · M1 Max / 32GB
|
||||
|
||||
<a id="python"></a>
|
||||
|
||||
### 🐍 Python
|
||||
|
||||
@@ -199,9 +199,11 @@ class MOSSImageTransformer(nn.Module):
|
||||
times = times.expand(batch)
|
||||
# Training computes the sinusoid in float64 and feeds float32 into its MLP.
|
||||
half = self.freq_dim // 2
|
||||
freq = 10000 ** (-torch.arange(half, device=times.device, dtype=torch.float64) / half)
|
||||
phase = times.double()[:, None] * freq
|
||||
embedding = torch.cat((phase.cos(), phase.sin()), -1).float()
|
||||
# MPS has no float64; keep the training sinusoid precision on CPU.
|
||||
sinusoid_device = torch.device("cpu") if times.device.type == "mps" else times.device
|
||||
freq = 10000 ** (-torch.arange(half, device=sinusoid_device, dtype=torch.float64) / half)
|
||||
phase = times.to(sinusoid_device).double()[:, None] * freq
|
||||
embedding = torch.cat((phase.cos(), phase.sin()), -1).float().to(times.device)
|
||||
time = self.time_embedding(embedding.to(self.time_embedding[0].weight.dtype)).float()
|
||||
mod = self.shared_block_modulation(time.to(self.shared_block_modulation.weight.dtype)).view(
|
||||
batch, 6, -1
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Versions used on the maintainer's M1 Max / 32 GB Mac.
|
||||
torch==2.14.1
|
||||
diffusers==0.41.0
|
||||
transformers==5.20.1
|
||||
accelerate==1.15.0
|
||||
gradio==6.30.0
|
||||
@@ -0,0 +1,83 @@
|
||||
# Experimental Apple silicon inference (MPS)
|
||||
|
||||
[简体中文](macos_zh.md)
|
||||
|
||||
This optional path runs MOSS-Image through PyTorch Metal/MPS. Recorded real-weight validation
|
||||
is on an **M1 Max / 32 GB unified-memory Mac**, with SFT. Other chips, lower-memory systems,
|
||||
Base and older dependency versions need testing. No quantization is applied.
|
||||
|
||||
## Install and launch
|
||||
|
||||
Use native arm64 Python 3.12 and a compatible macOS/PyTorch installation. Allow about 40 GB
|
||||
free disk for one full model bundle (~25 GB), the environment and download files.
|
||||
From the cloned repository root:
|
||||
|
||||
```bash
|
||||
MOSS_PYTHON=python3.12 bash scripts/setup-mac.sh
|
||||
.venv/bin/hf download OpenMOSS-Team/MOSS-Image-SFT --local-dir models/MOSS-Image-SFT
|
||||
bash scripts/gradio-mac.sh
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:7860. Start with 512×512, 10 steps and one image. Keep the terminal
|
||||
running; loading and inference can take minutes. Original PNGs and parameters.json are saved
|
||||
under `outputs/gradio/`. Stop with Ctrl+C. Subsequent runs only need the launch command.
|
||||
The Mac launcher uses local files only and disables Gradio analytics.
|
||||
|
||||
`constraints-mac.txt` lists the versions used for the recorded local runs: torch 2.14.1,
|
||||
diffusers 0.41.0, transformers 5.20.1, accelerate 1.15.0 and Gradio 6.30.0. These constraints
|
||||
apply to the optional Mac setup script, not to the existing installation path.
|
||||
|
||||
The launcher defaults the DiT to FP16. To compare BF16:
|
||||
|
||||
```bash
|
||||
MOSS_MPS_DIT_DTYPE=bfloat16 bash scripts/gradio-mac.sh
|
||||
```
|
||||
|
||||
The text encoder remains BF16 and the VAE FP32. Direct `mps_runtime.load_pipeline` defaults
|
||||
to BF16 unless `MOSS_MPS_DIT_DTYPE` is set before import. CUDA offload modes and Compile DiT
|
||||
do not apply to this staged MPS path; the UI disables those controls on MPS.
|
||||
|
||||
## Implementation and review points
|
||||
|
||||
The Qwen encoder (~17 GB) and DiT (~10 GB) are staged so only one is resident on MPS at a
|
||||
time. Positive and negative prompts are encoded before the DiT is loaded. An eight-entry
|
||||
LRU prompt cache permits reuse when only seed, dimensions or sampling settings change.
|
||||
|
||||
The downloaded custom pipeline computes a timestep sinusoid in FP64, unsupported by MPS.
|
||||
The small sinusoid operation is moved to CPU at FP64 and its result copied back. Because
|
||||
the standalone pipeline is distributed in the model bundle, this currently uses a narrowly
|
||||
matched source replacement and stops explicitly if that source changes. The ComfyUI model
|
||||
has the equivalent conditional CPU calculation; the full ComfyUI path was not validated here.
|
||||
|
||||
A locally observed loading deadlock involved pending Metal copies and cache/synchronization
|
||||
calls holding the GIL. The current workaround drains copies through a CPU transfer before
|
||||
calling those methods. It wraps `torch.mps.empty_cache` and `torch.mps.synchronize` when MPS
|
||||
is available, including other callers in the process; that process-wide effect is an explicit
|
||||
review point for this experimental implementation.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
Recorded SFT calls on M1 Max / 32 GB:
|
||||
|
||||
| Output | Settings | Full recorded generation call |
|
||||
| --- | --- | --- |
|
||||
| Chinese chalkboard | 1024×1024, 20 steps, BF16, seed 42 | 320.362 s |
|
||||
| English character posters | 768×1024, 28 steps, FP16 | 395.150 / 367.596 / 344.072 s |
|
||||
|
||||
[Images, original metadata and prompts](https://github.com/EdwinjJ1/moss-image-mac/tree/main/assets/samples)
|
||||
are available as validation evidence. These are end-to-end records, not controlled throughput,
|
||||
precision-equivalence or peak-memory measurements. CUDA execution is retained but was not
|
||||
hardware-regression-tested by this Mac contribution.
|
||||
|
||||
CPU regression checks, without downloading weights:
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.[ui]' -c constraints-mac.txt
|
||||
python -m unittest discover -s tests -p test_mps_runtime.py -v
|
||||
```
|
||||
|
||||
The checks cover staging order, prompt cache eviction, CPU sinusoid arithmetic, source-change
|
||||
failure, MPS routing and UI construction. Real MPS generation remains a separate hardware check.
|
||||
If MPS is unavailable, verify native arm64 Python and macOS compatibility. For memory pressure,
|
||||
reduce resolution/count and close heavy applications. For a source-patch mismatch, report the
|
||||
model revision and error rather than bypassing the check.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Apple Silicon / MPS 实验性支持
|
||||
|
||||
[English](macos.md)
|
||||
|
||||
通过 PyTorch Metal/MPS 在苹果芯片 Mac 上运行。本次有真实权重出图记录的配置为
|
||||
**M1 Max / 32GB 统一内存 / SFT**。其他芯片、小内存配置、Base 和更早的依赖组合尚需实测。
|
||||
没有做权重量化,32GB 也不是已证明的最低内存门槛。
|
||||
|
||||
## 安装与启动
|
||||
|
||||
使用原生 arm64 Python 3.12 和兼容的 macOS/PyTorch,建议为一个完整模型、Python 环境和
|
||||
下载文件预留约 40GB 磁盘。从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
MOSS_PYTHON=python3.12 bash scripts/setup-mac.sh
|
||||
.venv/bin/hf download OpenMOSS-Team/MOSS-Image-SFT --local-dir models/MOSS-Image-SFT
|
||||
bash scripts/gradio-mac.sh
|
||||
```
|
||||
|
||||
打开 http://127.0.0.1:7860,先用 512×512、10 步、1 张图试跑。保持终端运行,加载和推理可能
|
||||
需要数分钟。原图和 `parameters.json` 保存到 `outputs/gradio/`,按 Ctrl+C 停止服务。
|
||||
再次使用只需启动脚本,不必重复安装下载。脚本只读本地模型并关闭 Gradio 分析上报。
|
||||
|
||||
可选安装脚本使用 `constraints-mac.txt` 中的本地实测版本:torch 2.14.1、diffusers 0.41.0、
|
||||
transformers 5.20.1、accelerate 1.15.0、Gradio 6.30.0,不影响现有安装入口的依赖范围。
|
||||
|
||||
启动脚本默认 DiT FP16;比较 BF16 时:
|
||||
|
||||
```bash
|
||||
MOSS_MPS_DIT_DTYPE=bfloat16 bash scripts/gradio-mac.sh
|
||||
```
|
||||
|
||||
文本编码器使用 BF16,VAE 使用 FP32。直接调用 `mps_runtime.load_pipeline` 时,若导入前未设置
|
||||
环境变量,默认 DiT BF16。MPS 页面自动采用分阶段加载,并禁用 CUDA offload 选择和 Compile DiT。
|
||||
|
||||
## 实现与待评审项
|
||||
|
||||
约 17GB 的 Qwen 编码器和约 10GB 的 DiT 轮流驻留 MPS。先处理正负提示词,释放编码器后再加载
|
||||
DiT;最近八组提示词编码采用 LRU 缓存,仅改变 seed、尺寸或步数时可复用。
|
||||
|
||||
MPS 不支持时间步正弦编码中的 FP64,因此将这一小段计算放到 CPU,保留精度后传回 MPS。
|
||||
独立 pipeline 位于下载的模型包中,当前用严格匹配的源码替换适配;源码变化会明确报错。
|
||||
ComfyUI 模型包含相同的条件 CPU 计算修复,但本次未验证完整 ComfyUI 出图链路。
|
||||
|
||||
针对本地观察到的模型加载死锁,在清理缓存/同步前,通过回传 CPU 等待 Metal 复制完成。
|
||||
当前方案会在 MPS 可用时包装 `torch.mps.empty_cache` 和 `torch.mps.synchronize`,影响进程内其他
|
||||
调用者;这是实验方案中明确需要评审的部分。
|
||||
|
||||
## 实测与检查
|
||||
|
||||
| 样例 | 参数 | 完整生成调用耗时 |
|
||||
| --- | --- | --- |
|
||||
| 中文黑板 | 1024×1024、20 步、BF16、seed 42 | 320.362 秒 |
|
||||
| 三张英文角色海报 | 768×1024、28 步、FP16 | 395.150 / 367.596 / 344.072 秒 |
|
||||
|
||||
[真实图片、提示词及原始参数](https://github.com/EdwinjJ1/moss-image-mac/tree/main/assets/samples)
|
||||
仅作为验证证据,不代表同条件速度、精度对照或峰值内存测量。本贡献保留 CUDA 路径,但未进行
|
||||
CUDA 硬件回归验证。
|
||||
|
||||
不下载模型的 CPU 回归检查:
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.[ui]' -c constraints-mac.txt
|
||||
python -m unittest discover -s tests -p test_mps_runtime.py -v
|
||||
```
|
||||
|
||||
覆盖模型切换、缓存淘汰、时间步运算、源码变更检查、设备路由及页面构建。实际 MPS 出图需另行
|
||||
硬件验证。MPS 不可用时检查 arm64 Python 和系统兼容性;内存不足先降尺寸和张数;补丁不匹配时
|
||||
请提供模型 revision 和报错,不要绕过检查。
|
||||
+27
-8
@@ -21,6 +21,7 @@ from uuid import uuid4
|
||||
|
||||
import torch
|
||||
|
||||
import mps_runtime
|
||||
from gradio_presets import (
|
||||
PIXELS,
|
||||
fit_size,
|
||||
@@ -217,8 +218,12 @@ def parse_parameters(parameters, version):
|
||||
|
||||
|
||||
def load_pipeline(model, offload, compile_transformer=False, local_files_only=False):
|
||||
if mps_runtime.is_available():
|
||||
if compile_transformer:
|
||||
raise ValueError("Compile DiT is unavailable on Apple silicon. Disable it to use staged MPS inference.")
|
||||
return mps_runtime.load_pipeline(model, local_files_only=local_files_only)
|
||||
if not torch.cuda.is_available():
|
||||
raise ValueError("A CUDA GPU is required for generation. The interface can be previewed without one.")
|
||||
raise ValueError("A CUDA GPU or Apple silicon is required for generation. The interface can be previewed without one.")
|
||||
pipe = load_hf_pipeline(model, local_files_only=local_files_only)
|
||||
return configure_pipeline(pipe, offload=offload, compile_transformer=compile_transformer)
|
||||
|
||||
@@ -240,6 +245,7 @@ class ModelRunner:
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
mps_runtime.empty_cache()
|
||||
return "Model released. It will load again on the next generation."
|
||||
|
||||
def generate(self, model, config, progress=None):
|
||||
@@ -300,6 +306,11 @@ class ModelRunner:
|
||||
raise ValueError(
|
||||
"GPU memory exhausted. Try fewer/smaller images or a lower-memory offload mode."
|
||||
) from exc
|
||||
except Exception:
|
||||
# A failed MPS prompt stage leaves the pipeline without its DiT; reload it next time.
|
||||
if self.pipe is not None and self.pipe.transformer is None:
|
||||
self.unload()
|
||||
raise
|
||||
report(0, desc="Saving images and parameters")
|
||||
folder = self.output_dir / uuid4().hex
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
@@ -318,6 +329,10 @@ class ModelRunner:
|
||||
p for p in (root / "tokenizer").iterdir() if p.suffix in (".json", ".jinja", ".txt")
|
||||
)
|
||||
)
|
||||
# On MPS the pipeline class is mps_runtime's wrapper around the bundle's pipeline source.
|
||||
pipeline_file = getattr(self.pipe, "pipeline_file", None)
|
||||
wrapper = Path(mps_runtime.__file__) if pipeline_file else None
|
||||
pipeline_file = Path(pipeline_file or inspect.getfile(type(self.pipe)))
|
||||
record = {
|
||||
"schema_version": 2,
|
||||
"model": root.name if root.is_dir() else model,
|
||||
@@ -325,11 +340,10 @@ class ModelRunner:
|
||||
"pipeline_module": type(self.pipe).__module__,
|
||||
"parameters": asdict(config),
|
||||
"sampler": "Euler",
|
||||
"weight_dtype": "bfloat16",
|
||||
"weight_dtype": str(self.pipe.transformer.dtype).removeprefix("torch."),
|
||||
"vae_dtype": "float32",
|
||||
"pipeline_sha256": hashlib.sha256(
|
||||
Path(inspect.getfile(type(self.pipe))).read_bytes()
|
||||
).hexdigest(),
|
||||
"pipeline_sha256": hashlib.sha256(pipeline_file.read_bytes()).hexdigest(),
|
||||
"mps_runtime_sha256": hashlib.sha256(wrapper.read_bytes()).hexdigest() if wrapper else None,
|
||||
"model_config_sha256": {
|
||||
str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in configs
|
||||
@@ -339,6 +353,7 @@ class ModelRunner:
|
||||
name: importlib.metadata.version(name)
|
||||
for name in ("torch", "diffusers", "transformers", "accelerate")
|
||||
},
|
||||
"device": "cuda" if torch.cuda.is_available() else "mps" if mps_runtime.is_available() else "cpu",
|
||||
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
|
||||
"elapsed_seconds": round(time.monotonic() - start, 3),
|
||||
"images": [Path(p).name for p in paths],
|
||||
@@ -376,6 +391,9 @@ def build_app(
|
||||
choices.append(model)
|
||||
initial = model or (choices[0] if choices else None)
|
||||
offload_default, height_default, width_default = profile_defaults(profile)
|
||||
on_mps = mps_runtime.is_available()
|
||||
if on_mps:
|
||||
offload_default = "model"
|
||||
pixels_default, ratio_default = size_selection(width_default, height_default)
|
||||
min_budget, max_budget = PIXELS[0] / 1048576, PIXELS[-1] / 1048576
|
||||
defaults = GenerationConfig()
|
||||
@@ -528,19 +546,20 @@ def build_app(
|
||||
shift = gr.Number(value=defaults.shift, label="Shift", minimum=0.01)
|
||||
with gr.Row():
|
||||
offload = gr.Dropdown(
|
||||
choices=[
|
||||
choices=[("Staged · Apple silicon", "model")] if on_mps else [
|
||||
("GPU resident", "none"),
|
||||
("Component", "model"),
|
||||
("Sequential · fallback", "sequential"),
|
||||
],
|
||||
value=offload_default,
|
||||
interactive=not on_mps,
|
||||
label="Memory mode",
|
||||
scale=2,
|
||||
min_width=140,
|
||||
)
|
||||
compile_transformer = gr.Checkbox(
|
||||
value=False,
|
||||
interactive=offload_default != "sequential",
|
||||
interactive=not on_mps and offload_default != "sequential",
|
||||
label="Compile DiT",
|
||||
scale=0,
|
||||
min_width=0,
|
||||
@@ -733,7 +752,7 @@ def build_app(
|
||||
)
|
||||
|
||||
def update_compile(mode):
|
||||
if mode == "sequential":
|
||||
if on_mps or mode == "sequential":
|
||||
return gr.update(value=False, interactive=False)
|
||||
return gr.update(interactive=True)
|
||||
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
"""Apple-silicon (MPS) loading that keeps one large model on the GPU at a time.
|
||||
|
||||
The Qwen text encoder (~17 GB) and the DiT (~10 GB) do not fit in a 32 GB Mac's
|
||||
GPU working set together. The text encoder is loaded from disk only to encode a
|
||||
new prompt and is released before sampling; the DiT then comes back. Prompt
|
||||
embeddings are cached, so a new seed, size or step count skips the text encoder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from diffusers import AutoencoderKLFlux2
|
||||
from huggingface_hub import snapshot_download
|
||||
from transformers import AutoModel, AutoTokenizer
|
||||
|
||||
DEVICE = "mps"
|
||||
CACHED_PROMPTS = 8
|
||||
# MOSS_MPS_DIT_DTYPE=float16 runs the DiT in fp16, which M1/M2 GPUs compute natively.
|
||||
DIT_DTYPE = getattr(torch, os.environ.get("MOSS_MPS_DIT_DTYPE", "bfloat16"), None)
|
||||
if DIT_DTYPE not in (torch.float16, torch.bfloat16):
|
||||
raise ValueError("MOSS_MPS_DIT_DTYPE must be float16 or bfloat16.")
|
||||
|
||||
# MPS has no float64. The DiT computes its timestep sinusoid in float64, so
|
||||
# that one calculation runs on the CPU with the same precision.
|
||||
_FLOAT64_TIMESTEP = (
|
||||
" freq = 10000 ** (-torch.arange(half, device=times.device, dtype=torch.float64) / half)\n"
|
||||
" phase = times.double()[:, None] * freq\n"
|
||||
" embedding = torch.cat((phase.cos(), phase.sin()), -1).float()\n"
|
||||
)
|
||||
_CPU_TIMESTEP = (
|
||||
" freq = 10000 ** (-torch.arange(half, dtype=torch.float64) / half)\n"
|
||||
" phase = times.cpu().double()[:, None] * freq\n"
|
||||
" embedding = torch.cat((phase.cos(), phase.sin()), -1).float().to(times.device)\n"
|
||||
)
|
||||
|
||||
|
||||
def _wait_without_gil(original):
|
||||
# torch.mps.empty_cache() and synchronize() wait on the GPU while holding the GIL.
|
||||
# After a non_blocking copy from a CPU tensor whose storage belongs to a Python object
|
||||
# (safetensors, frombuffer), Metal's completion handler needs the GIL to release it, so
|
||||
# the two deadlock; Diffusers does exactly this when loading with device_map.
|
||||
# Copying a value back to the CPU waits with the GIL released and drains them first.
|
||||
def wait(*args, **kwargs):
|
||||
torch.zeros(1, device=DEVICE).cpu()
|
||||
return original(*args, **kwargs)
|
||||
return wait
|
||||
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache = _wait_without_gil(torch.mps.empty_cache)
|
||||
torch.mps.synchronize = _wait_without_gil(torch.mps.synchronize)
|
||||
|
||||
|
||||
def is_available():
|
||||
return not torch.cuda.is_available() and torch.backends.mps.is_available()
|
||||
|
||||
|
||||
def empty_cache():
|
||||
gc.collect()
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
|
||||
|
||||
def _bundle_root(model, local_files_only):
|
||||
path = Path(model)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path(snapshot_download(model, local_files_only=local_files_only))
|
||||
|
||||
|
||||
def _import_pipeline_module(root):
|
||||
path = root / "pipeline_moss_image.py"
|
||||
source = path.read_text()
|
||||
if _FLOAT64_TIMESTEP not in source:
|
||||
raise ValueError(f"{path} changed; update the float64 patch in mps_runtime.py.")
|
||||
name = "pipeline_moss_image_mps"
|
||||
module = importlib.util.module_from_spec(importlib.util.spec_from_loader(name, loader=None, origin=str(path)))
|
||||
sys.modules[name] = module
|
||||
exec(compile(source.replace(_FLOAT64_TIMESTEP, _CPU_TIMESTEP), str(path), "exec"), module.__dict__)
|
||||
return module
|
||||
|
||||
|
||||
def _staged_pipeline_class(module):
|
||||
class StagedMOSSImagePipeline(module.MOSSImagePipeline):
|
||||
"""Swaps the text encoder and the DiT on the GPU instead of holding both."""
|
||||
|
||||
@property
|
||||
def _execution_device(self):
|
||||
return torch.device(DEVICE)
|
||||
|
||||
def _release(self, name):
|
||||
if getattr(self, name) is not None:
|
||||
setattr(self, name, None)
|
||||
empty_cache()
|
||||
|
||||
def use_text_encoder(self):
|
||||
if self.text_encoder is None:
|
||||
self._release("transformer")
|
||||
self.text_encoder = AutoModel.from_pretrained(
|
||||
self.root / "text_encoder", dtype=torch.bfloat16, device_map=DEVICE
|
||||
).eval()
|
||||
|
||||
def use_transformer(self):
|
||||
if self.transformer is None:
|
||||
self._release("text_encoder")
|
||||
self.transformer = module.MOSSImageTransformer.from_pretrained(
|
||||
self.root / "transformer", torch_dtype=DIT_DTYPE, device_map=DEVICE
|
||||
).eval()
|
||||
|
||||
def encode_prompt(self, prompt, max_sequence_length=4096):
|
||||
key = ((prompt,) if isinstance(prompt, str) else tuple(prompt), max_sequence_length)
|
||||
if key not in self.embeddings:
|
||||
self.use_text_encoder()
|
||||
# An autograd graph would keep the released text encoder alive in the cache.
|
||||
with torch.no_grad():
|
||||
self.embeddings[key] = super().encode_prompt(prompt, max_sequence_length)
|
||||
while len(self.embeddings) > CACHED_PROMPTS:
|
||||
self.embeddings.popitem(last=False)
|
||||
self.embeddings.move_to_end(key)
|
||||
return self.embeddings[key]
|
||||
|
||||
def __call__(self, prompt=None, *, negative_prompt="", guidance_scale=3.0, max_sequence_length=4096,
|
||||
prompt_embeds=None, prompt_attention_mask=None,
|
||||
negative_prompt_embeds=None, negative_prompt_attention_mask=None, **kwargs):
|
||||
# Encode both prompts before the DiT returns to the GPU.
|
||||
if prompt is not None and prompt_embeds is None:
|
||||
prompt_embeds, prompt_attention_mask = self.encode_prompt(prompt, max_sequence_length)
|
||||
prompt = None
|
||||
if guidance_scale != 1.0 and negative_prompt_embeds is None and prompt_embeds is not None:
|
||||
batch = prompt_embeds.shape[0]
|
||||
negatives = [negative_prompt] * batch if isinstance(negative_prompt, str) else negative_prompt
|
||||
negative_prompt_embeds, negative_prompt_attention_mask = self.encode_prompt(
|
||||
negatives, max_sequence_length
|
||||
)
|
||||
self.use_transformer()
|
||||
return super().__call__(
|
||||
prompt, negative_prompt=negative_prompt, guidance_scale=guidance_scale,
|
||||
max_sequence_length=max_sequence_length,
|
||||
prompt_embeds=prompt_embeds, prompt_attention_mask=prompt_attention_mask,
|
||||
negative_prompt_embeds=negative_prompt_embeds,
|
||||
negative_prompt_attention_mask=negative_prompt_attention_mask, **kwargs,
|
||||
)
|
||||
|
||||
return StagedMOSSImagePipeline
|
||||
|
||||
|
||||
def load_pipeline(model, local_files_only=False):
|
||||
root = _bundle_root(model, local_files_only)
|
||||
module = _import_pipeline_module(root)
|
||||
pipe = _staged_pipeline_class(module)(
|
||||
transformer=None,
|
||||
scheduler=module.MOSSImageScheduler.from_pretrained(root / "scheduler"),
|
||||
tokenizer=AutoTokenizer.from_pretrained(root / "tokenizer"),
|
||||
vae=AutoencoderKLFlux2.from_pretrained(root / "vae", torch_dtype=torch.float32).to(DEVICE).eval(),
|
||||
)
|
||||
pipe.root = root
|
||||
# The pipeline class is defined here; provenance records the bundle source it patches and runs.
|
||||
pipe.pipeline_file = root / "pipeline_moss_image.py"
|
||||
pipe.embeddings = OrderedDict()
|
||||
pipe.use_transformer()
|
||||
return pipe
|
||||
+1
-1
@@ -20,7 +20,7 @@ ui = ["gradio>=6.30"]
|
||||
|
||||
[tool.setuptools]
|
||||
license-files = ["LICENSE", "LICENSES/*.txt"]
|
||||
py-modules = ["inference_runtime", "gradio_app", "gradio_presets"]
|
||||
py-modules = ["inference_runtime", "mps_runtime", "gradio_app", "gradio_presets"]
|
||||
packages = ["assets"]
|
||||
include-package-data = false
|
||||
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gradio UI on an Apple-silicon Mac, using the bundles downloaded into models/.
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export HF_HUB_OFFLINE=1 GRADIO_ANALYTICS_ENABLED=False
|
||||
# Use native fp16 DiT arithmetic on M1/M2; set bfloat16 to compare.
|
||||
export MOSS_MPS_DIT_DTYPE="${MOSS_MPS_DIT_DTYPE:-float16}"
|
||||
if [[ ! -x "${repo_dir}/.venv/bin/python" ]]; then
|
||||
echo "Run bash scripts/setup-mac.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
cd "$repo_dir"
|
||||
exec "${repo_dir}/.venv/bin/python" "${repo_dir}/gradio_app.py" \
|
||||
--model-root "${repo_dir}/models" \
|
||||
--model "${repo_dir}/models/MOSS-Image-SFT" \
|
||||
--local-files-only "$@"
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create an isolated Python environment; model download is a separate step.
|
||||
set -euo pipefail
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_dir"
|
||||
if [[ $(uname -s) != Darwin || $(uname -m) != arm64 ]]; then
|
||||
echo "This installer requires an Apple silicon Mac and a native arm64 terminal." >&2
|
||||
exit 1
|
||||
fi
|
||||
python_bin=${MOSS_PYTHON:-python3}
|
||||
"$python_bin" -c 'import sys; assert sys.version_info >= (3, 10), "Python 3.10+ is required; 3.12 is recommended"'
|
||||
"$python_bin" -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -e '.[ui]' -c constraints-mac.txt
|
||||
.venv/bin/python -c 'import torch; assert torch.backends.mps.is_available(), "PyTorch cannot access MPS. Check macOS and the Python architecture."; print("MPS available. Next: download the SFT bundle using the macOS guide.")'
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Regression checks for Mac adaptation; no weights or GPU required."""
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
import mps_runtime as runtime
|
||||
|
||||
MODEL = "OpenMOSS-Team/MOSS-Image-Base"
|
||||
|
||||
|
||||
def fake_pipe(**attributes):
|
||||
transformer = SimpleNamespace(config=SimpleNamespace(num_layers=32), dtype=torch.bfloat16)
|
||||
return SimpleNamespace(transformer=transformer, remove_all_hooks=Mock(), **attributes)
|
||||
|
||||
|
||||
def fake_output():
|
||||
return SimpleNamespace(images=[Image.new("RGB", (16, 16))])
|
||||
|
||||
|
||||
class TimestepTests(unittest.TestCase):
|
||||
def test_cpu_patch_preserves_upstream_float64_math(self):
|
||||
source = (
|
||||
"import torch\nclass MOSSImagePipeline:\n"
|
||||
" def embedding(self, times, half):\n"
|
||||
+ runtime._FLOAT64_TIMESTEP + " return embedding\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "pipeline_moss_image.py").write_text(source)
|
||||
module = runtime._import_pipeline_module(root)
|
||||
times = torch.tensor([0.0, 0.125, 999.0], dtype=torch.float32)
|
||||
actual = module.MOSSImagePipeline().embedding(times, 128)
|
||||
phase = times.double()[:, None] * (10000 ** (-torch.arange(128, dtype=torch.float64) / 128))
|
||||
expected = torch.cat((phase.cos(), phase.sin()), -1).float()
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
def test_changed_upstream_source_fails_explicitly(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "pipeline_moss_image.py").write_text("# incompatible upstream pipeline\n")
|
||||
with self.assertRaisesRegex(ValueError, "changed; update"):
|
||||
runtime._import_pipeline_module(root)
|
||||
|
||||
|
||||
class StagingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.events = []
|
||||
events = self.events
|
||||
|
||||
class BasePipeline:
|
||||
def encode_prompt(self, prompt, max_sequence_length):
|
||||
if self.text_encoder is None:
|
||||
raise AssertionError("Encoding requires the text encoder")
|
||||
events.append("encode")
|
||||
weight = torch.ones(4, requires_grad=True) # stands in for text-encoder parameters
|
||||
return torch.zeros(1, 2, 4) * weight, torch.ones(1, 2)
|
||||
|
||||
def __call__(self, prompt, **kwargs):
|
||||
if self.transformer is None or self.text_encoder is not None:
|
||||
raise AssertionError("Sampling must retain only the DiT")
|
||||
events.append("sample")
|
||||
return kwargs
|
||||
|
||||
self.loader = Mock()
|
||||
module = SimpleNamespace(MOSSImagePipeline=BasePipeline, MOSSImageTransformer=self.loader)
|
||||
self.pipe = runtime._staged_pipeline_class(module)()
|
||||
self.pipe.root = Path("unused-model")
|
||||
self.pipe.text_encoder = None
|
||||
self.pipe.transformer = object()
|
||||
self.pipe.embeddings = OrderedDict()
|
||||
|
||||
def load_text(*args, **kwargs):
|
||||
self.assertIsNone(self.pipe.transformer)
|
||||
self.events.append("load-text")
|
||||
return Mock()
|
||||
|
||||
def load_dit(*args, **kwargs):
|
||||
self.assertIsNone(self.pipe.text_encoder)
|
||||
self.events.append("load-dit")
|
||||
return Mock()
|
||||
|
||||
self.loader.from_pretrained.side_effect = load_dit
|
||||
self.text_patch = patch.object(runtime.AutoModel, "from_pretrained", side_effect=load_text)
|
||||
self.cache_patch = patch.object(runtime, "empty_cache")
|
||||
self.text_patch.start()
|
||||
self.cache_patch.start()
|
||||
self.addCleanup(self.text_patch.stop)
|
||||
self.addCleanup(self.cache_patch.stop)
|
||||
|
||||
def test_both_prompts_encoded_before_sampling_and_cached(self):
|
||||
first = self.pipe("coffee", negative_prompt="blur")
|
||||
self.assertEqual(self.events, ["load-text", "encode", "encode", "load-dit", "sample"])
|
||||
self.events.clear()
|
||||
second = self.pipe("coffee", negative_prompt="blur", num_inference_steps=10, width=512)
|
||||
self.assertEqual(self.events, ["sample"])
|
||||
self.assertIs(first["prompt_embeds"], second["prompt_embeds"])
|
||||
|
||||
def test_prompt_cache_evicts_least_recently_used(self):
|
||||
for index in range(runtime.CACHED_PROMPTS):
|
||||
self.pipe.encode_prompt(f"prompt-{index}")
|
||||
self.pipe.encode_prompt("prompt-0")
|
||||
self.pipe.encode_prompt("new")
|
||||
self.assertEqual(len(self.pipe.embeddings), runtime.CACHED_PROMPTS)
|
||||
self.assertIn((("prompt-0",), 4096), self.pipe.embeddings)
|
||||
self.assertNotIn((("prompt-1",), 4096), self.pipe.embeddings)
|
||||
|
||||
def test_cached_embeddings_hold_no_autograd_graph(self):
|
||||
# A graph would keep the released text encoder's weights and activations alive.
|
||||
embeds, _ = self.pipe.encode_prompt("coffee")
|
||||
self.assertFalse(embeds.requires_grad)
|
||||
self.assertIsNone(embeds.grad_fn)
|
||||
|
||||
def test_load_pipeline_records_the_bundle_pipeline_it_executes(self):
|
||||
class BasePipeline:
|
||||
def __init__(self, **components):
|
||||
self.text_encoder = None
|
||||
self.__dict__.update(components)
|
||||
|
||||
module = SimpleNamespace(
|
||||
MOSSImagePipeline=BasePipeline, MOSSImageTransformer=Mock(), MOSSImageScheduler=Mock()
|
||||
)
|
||||
root = Path("bundle")
|
||||
with patch.object(runtime, "_bundle_root", return_value=root), \
|
||||
patch.object(runtime, "_import_pipeline_module", return_value=module), \
|
||||
patch.object(runtime.AutoTokenizer, "from_pretrained"), \
|
||||
patch.object(runtime.AutoencoderKLFlux2, "from_pretrained"):
|
||||
pipe = runtime.load_pipeline("model")
|
||||
self.assertEqual(pipe.pipeline_file, root / "pipeline_moss_image.py")
|
||||
|
||||
|
||||
class RunnerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import gradio_app
|
||||
|
||||
self.app = gradio_app
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.runner = gradio_app.ModelRunner(directory.name, local_files_only=True)
|
||||
self.config = gradio_app.GenerationConfig(prompt="coffee")
|
||||
for target, name in ((gradio_app, "set_scheduler"), (runtime, "empty_cache")):
|
||||
patcher = patch.object(target, name)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_failed_prompt_stage_releases_pipeline_and_reloads_it(self):
|
||||
broken, fresh = fake_pipe(), fake_pipe()
|
||||
|
||||
def run(pipe, *args, **kwargs):
|
||||
if pipe is broken:
|
||||
pipe.transformer = None # released for the text encoder, which then failed
|
||||
raise RuntimeError("MPS backend out of memory")
|
||||
return fake_output()
|
||||
|
||||
with patch.object(self.app, "load_pipeline", side_effect=[broken, fresh]) as load, \
|
||||
patch.object(self.app, "generate_with_progress", side_effect=run):
|
||||
with self.assertRaisesRegex(RuntimeError, "out of memory"):
|
||||
self.runner.generate(MODEL, self.config)
|
||||
self.assertIsNone(self.runner.pipe)
|
||||
broken.remove_all_hooks.assert_called_once()
|
||||
self.runner.generate(MODEL, self.config)
|
||||
self.assertEqual(load.call_count, 2)
|
||||
|
||||
def test_mps_provenance_hashes_bundle_pipeline_and_wrapper(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "pipeline_moss_image.py"
|
||||
source.write_text("# bundle pipeline\n")
|
||||
with patch.object(self.app, "load_pipeline", return_value=fake_pipe(pipeline_file=source)), \
|
||||
patch.object(self.app, "generate_with_progress", return_value=fake_output()):
|
||||
_, metadata, _ = self.runner.generate(MODEL, self.config)
|
||||
record = json.loads(Path(metadata).read_text())
|
||||
self.assertEqual(record["pipeline_sha256"], hashlib.sha256(b"# bundle pipeline\n").hexdigest())
|
||||
self.assertEqual(
|
||||
record["mps_runtime_sha256"], hashlib.sha256(Path(runtime.__file__).read_bytes()).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
class IntegrationTests(unittest.TestCase):
|
||||
def test_mac_routing_and_unsupported_compile(self):
|
||||
import gradio_app
|
||||
|
||||
with patch.object(runtime, "is_available", return_value=True), patch.object(runtime, "load_pipeline") as load:
|
||||
gradio_app.load_pipeline("model", "model", local_files_only=True)
|
||||
load.assert_called_once_with("model", local_files_only=True)
|
||||
with self.assertRaisesRegex(ValueError, "Compile DiT"):
|
||||
gradio_app.load_pipeline("model", "model", compile_transformer=True)
|
||||
|
||||
def test_cuda_routing_retains_existing_loader_and_configuration(self):
|
||||
import gradio_app
|
||||
|
||||
with patch.object(runtime, "is_available", return_value=False), \
|
||||
patch.object(torch.cuda, "is_available", return_value=True), \
|
||||
patch.object(gradio_app, "load_hf_pipeline") as load, \
|
||||
patch.object(gradio_app, "configure_pipeline") as configure:
|
||||
result = gradio_app.load_pipeline("model", "sequential", local_files_only=True)
|
||||
load.assert_called_once_with("model", local_files_only=True)
|
||||
configure.assert_called_once_with(load.return_value, offload="sequential", compile_transformer=False)
|
||||
self.assertIs(result, configure.return_value)
|
||||
|
||||
def test_gradio_ui_builds_for_mac_without_loading_weights(self):
|
||||
import gradio_app
|
||||
|
||||
with patch.object(runtime, "is_available", return_value=True), patch.object(runtime, "load_pipeline") as load:
|
||||
app = gradio_app.build_app("missing-model-directory")
|
||||
self.assertEqual(app.title, "MOSS-Image")
|
||||
controls = {item["props"].get("label"): item["props"] for item in app.config["components"]}
|
||||
self.assertFalse(controls["Memory mode"]["interactive"])
|
||||
self.assertFalse(controls["Compile DiT"]["interactive"])
|
||||
load.assert_not_called()
|
||||
app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user