Add experimental Apple silicon MPS inference support

This commit is contained in:
Evanlin committed 2026-10-18 17:35:04 +08:00
1 parent c1355e6b19
commit 3d433bfba6
13 files changed
+520 -11

No files matched your search

-2
View File
@@ -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/
+4
View File
@@ -139,6 +139,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
+4
View File
@@ -134,6 +134,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
+5 -3
View File
@@ -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
+6
View File
@@ -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
+83
View File
@@ -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.
+69
View File
@@ -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 和报错,不要绕过检查。
+16 -5
View File
@@ -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):
@@ -325,7 +331,7 @@ 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()
@@ -339,6 +345,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 +383,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 +538,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 +744,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)
+163
View File
@@ -0,0 +1,163 @@
"""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()
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
pipe.embeddings = OrderedDict()
pipe.use_transformer()
return pipe
+1 -1
View File
@@ -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
+17
View File
@@ -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 "$@"
+15
View File
@@ -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.")'
+137
View File
@@ -0,0 +1,137 @@
"""Regression checks for Mac adaptation; no weights or GPU required."""
import tempfile
import unittest
from collections import OrderedDict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import torch
import mps_runtime as runtime
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")
return torch.zeros(1, 2, 4), 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)
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()