Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .zcodeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.DS_Store
.env

# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ python -m unittest tests.test_stream_loop # one file at a time in pra
| `schemas.py` | OpenAI/Anthropic → one internal request |
| `backends.py` | mlx-lm text backend, mlx-vlm omni backend, stop filtering, cross-turn prompt cache |
| `server.py` | routes, SSE + chunked streaming |
| `__main__.py` | CLI (`serve`, `tts`) |
| `__main__.py` | CLI (`serve`, `tts`, `image`) |
| `tts/` | Qwen3-TTS pipeline: `config`/`variants` (checkpoint typing), `service` (validation + lock), `generate` (buffered) + `stream_loop` (streaming fast path), `prompt_embeds` + `prefix_cache` (per-voice prompt state), `compiled_steps` (mx.compile'd decode closures), `code2wav`/`talker`/`code_predictor` seams |
| `docs/` | [architecture](docs/architecture.md) · [speech guide](docs/speech.md) · [profiling](docs/profiling.md) |
| `diffusion/` | Image generation seam over mflux (#91): `config` (model registry + loader, license surfaced) and `service` (validation + lock, PNG bytes out) — served on `/v1/images/generations` |
| `docs/` | [architecture](docs/architecture.md) · [speech guide](docs/speech.md) · [image guide](docs/image.md) · [profiling](docs/profiling.md) |

Architecture rationale (why no scheduler, why prefix caching has none of
upstream's paged-KV conflicts): [docs/architecture.md](docs/architecture.md).
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Easy, fast, and lightweight omni-modality model serving for Apple Silicon
</h3>

<p align="center">
| <a href="docs/architecture.md"><b>Architecture</b></a> | <a href="docs/speech.md"><b>Speech Guide</b></a> | <a href="docs/profiling.md"><b>Profiling Guide</b></a> | <a href="examples/"><b>Examples</b></a> | <a href="CONTRIBUTING.md"><b>Contributing</b></a> |
| <a href="docs/architecture.md"><b>Architecture</b></a> | <a href="docs/speech.md"><b>Speech Guide</b></a> | <a href="docs/image.md"><b>Image Guide</b></a> | <a href="docs/profiling.md"><b>Profiling Guide</b></a> | <a href="examples/"><b>Examples</b></a> | <a href="CONTRIBUTING.md"><b>Contributing</b></a> |
</p>

---
Expand Down Expand Up @@ -55,14 +55,14 @@ vllm-omni-mlx is flexible and easy to use with:
| **TTS** — text → speech | Qwen3-TTS (CustomVoice · Base · VoiceDesign, 0.6B/1.7B) | ✅ verified end-to-end, buffered + streaming — [speech guide](docs/speech.md) |
| **Omni** — any-to-any chat | Qwen3-Omni 30B-A3B | 🚧 chat works via mlx-vlm; speech-out chat in progress |
| **ASR** — speech → text | Whisper, Qwen3-ASR, Voxtral, … | 🚧 planned — `/v1/audio/transcriptions` (#68) |
| **Diffusion** — text/image → image | — | 🚧 planned — roadmap (#2) |
| **Diffusion** — text → image | Qwen-Image-2.1 4-bit (+ Viggle turbo fast lane) | ✅ verified end-to-end — [image guide](docs/image.md) (#101) |

| Modality | Examples | Status |
| --- | --- | --- |
| **TTS** — text → speech | Qwen3-TTS (CustomVoice · Base · VoiceDesign, 0.6B/1.7B); VoxCPM2 (zero-shot · cloned · described voice, 30+ languages, 48 kHz) | ✅ verified end-to-end, buffered + streaming — [speech guide](docs/speech.md) |
| **Omni** — any-to-any chat | Qwen3-Omni 30B-A3B | 🚧 chat works via mlx-vlm; speech-out chat in progress |
| **ASR** — speech → text | Whisper, Qwen3-ASR, Voxtral, … | 🚧 planned — `/v1/audio/transcriptions` (#68) |
| **Diffusion** — text/image → image | — | 🚧 planned — roadmap (#2) |
| **Diffusion** — text → image | Qwen-Image-2.1 4-bit (+ Viggle turbo fast lane) | ✅ verified end-to-end — [image guide](docs/image.md) (#101) |

Text-only LLMs and image-in/text-out VLMs load through their engines but are
not this server's target categories.
Expand All @@ -85,15 +85,19 @@ pip install -e '.[tts]' # + speech synthesis (mlx-audio)
| core | `mlx-lm`, `starlette`, `uvicorn` | 38 | ~440 MB |
| + `[tts]` | + `mlx-audio` | 44 | ~560 MB |
| + `[omni]` | + `mlx-vlm` | 59 | ~750 MB |
| + `[image]` | + `mflux` | 61 | ~1.3 GB |

The core install pulls in the MLX stack (`mlx` + `mlx-metal` kernels, `transformers`,
`tokenizers`, `huggingface_hub`) plus starlette/uvicorn and almost nothing else —
**no FastAPI, no pydantic, no torch**. The `[tts]` extra adds ~125 MB through
`mlx-audio` (miniaudio, sounddevice — still no torch). The `[omni]` extra adds
~315 MB through `mlx-vlm` (opencv, pillow, scipy — and mlx-audio, so `[omni]`
implies `[tts]`; that path does drag in fastapi/pydantic, contained to the
optional extra). Measured on macOS arm64 / Python 3.13 with mlx-lm 0.32,
mlx-vlm 0.7, and mlx-audio 0.5.7.
optional extra). The `[image]` extra adds ~750 MB through `mflux` — the first
extra that brings **torch** (the arm64 build, a hard mflux dependency) plus
matplotlib/opencv; keep it out of installs that don't serve images. Measured on
macOS arm64 with mlx-lm 0.32, mlx-vlm 0.7, mlx-audio 0.5.7, and mflux 0.21
(the `[image]` row on Python 3.12, the rest on 3.13).

### Run

Expand All @@ -104,6 +108,15 @@ vllm-omni-mlx serve mlx-community/Qwen3-Omni-30B-A3B-Instruct-4bit \

# speech-only server
vllm-omni-mlx serve mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-4bit --omni

# image server (solo — the working set wants the box to itself; #101)
pip install 'vllm-omni-mlx[image]'
vllm-omni-mlx serve mlx-community/Qwen-Image-2.1-mflux-q4

# 6-step fast lane: the Viggle turbo distilled LoRA + its fixed-sigma sampler
vllm-omni-mlx serve mlx-community/Qwen-Image-2.1-mflux-q4 \
--image-lora Viggle/Qwen-Image-2.1-viggle-turbo:Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors \
--image-scheduler viggle_turbo
```

Qwen3-Omni serves text-out chat today (speech-out chat is in progress); the 30B-A3B
Expand Down Expand Up @@ -134,6 +147,7 @@ re-prefill, so correctness never depends on the cache).
| `POST /v1/chat/completions` | OpenAI (SSE streaming, multimodal content parts) |
| `POST /v1/messages` | Anthropic (SSE streaming, image blocks) |
| `POST /v1/audio/speech` | OpenAI audio (`wav` / chunked `pcm` with `stream: true`) |
| `POST /v1/images/generations` | OpenAI images (`b64_json`; diffusion extras: `steps`, `guidance`, `seed`, `negative_prompt`) |
| `GET /v1/audio/voices` | preset speakers of the loaded TTS model |
| `GET /v1/models`, `GET /health` | model list, liveness |

Expand All @@ -153,6 +167,18 @@ cloning, streaming knobs, and per-checkpoint differences: the
**[speech guide](docs/speech.md)**; runnable scripts in [`examples/`](examples/);
one-shot synthesis without a server: `vllm-omni-mlx tts --voice ryan --text "..." --out out.wav`.

Image generation on the image server (`prompt` in, `b64_json` out; OpenAI
`size`/`n` plus `steps`, `guidance`, `seed`, `negative_prompt`):

```sh
curl -d '{"prompt": "a cozy harbor town at dawn", "size": "1024x1024", "seed": 42}' \
http://127.0.0.1:8000/v1/images/generations | python3 -c 'import base64,json,sys; print(base64.b64decode(json.load(sys.stdin)["data"][0]["b64_json"]), end="")' > out.png
```

Serving shapes, resolution/latency/memory numbers, the Viggle turbo fast lane,
and the non-commercial license note: the **[image guide](docs/image.md)**;
one-shot generation without a server: `vllm-omni-mlx image --prompt "..." --out out.png`.

## Design Notes & Limits

See [docs/architecture.md](docs/architecture.md) for the architecture diagram and rationale.
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ flowchart TD
TB["TextBackend — mlx-lm<br/>chat template · sampler · stream_generate<br/>single-entry cross-turn prefix cache"]
OB["OmniBackend — mlx-vlm (optional extra)<br/>image · audio input"]
TS["TTSService — tts/ (mlx-audio)<br/>Qwen3-TTS: presets · instruct · ICL cloning · voice design<br/>voice-prefix cache + compiled decode + first-chunk fast path"]
IS["ImageService — diffusion/ (mflux, optional extra)<br/>Qwen-Image-2.1: txt2img · b64_json out<br/>size/steps/guidance/seed validation"]
LOCK("generation lock<br/>batch-1 · serialized")
end

Expand All @@ -24,11 +25,13 @@ flowchart TD

GATE --> NORM --> SEL
GATE --> TS
GATE --> IS
SEL -->|"text LLM"| TB
SEL -->|"vision / audio model"| OB
TB --> LOCK
OB --> LOCK
TS --> LOCK
IS --> LOCK
LOCK --> MLX --> GPU

ASR["planned · issue #68: /v1/audio/transcriptions<br/>decoder-style ASR, born-compiled"]
Expand All @@ -44,6 +47,8 @@ flowchart TD
| Backends | `backends.py` | `TextBackend` (mlx-lm) and `OmniBackend` (mlx-vlm); auto-selection by model config; stop-sequence filtering; generation serialized under a lock |
| TTS serving | `tts/service.py` | Request validation (voice strings vs cloning objects vs design instructions → 400s with guidance), output formats (wav / raw pcm), the generation lock |
| TTS pipeline | `tts/` | `config`/`variants` (checkpoint typing + path×type dispatch), `generate` (buffered) and `stream_loop` (streaming fast path), `prompt_embeds` + `prefix_cache` (per-voice prompt pieces + static-prefix KV reuse), `compiled_steps` (mx.compile'd talker/predictor/sampler closures), `code2wav`/`talker`/`code_predictor` seams |
| Image serving | `diffusion/service.py` | The seam over mflux (#91): size/steps/guidance/seed validation (400s with guidance, multiples-of-16 enforced instead of silent rounding), the generation lock, prompt → PNG bytes on `/v1/images/generations` |
| Image pipeline | `diffusion/config.py` | Family registry + mflux loader (`[image]` extra, pinned), license surfaced per family, LoRA/scheduler pass-through for the fast lane |
| Runtime | MLX | Model execution on the Metal GPU over unified memory |

## Why it looks like this
Expand All @@ -69,4 +74,5 @@ flowchart TD
- **No native model code.** `tts/` is a seam layer: the loops vendor control
flow over mlx-audio's components (MIT), and the compiled closures mirror their
module bodies — model math stays in the library, which tracks upstream
Qwen3-TTS fixes for free.
Qwen3-TTS fixes for free. `diffusion/` goes further: it holds no vendored
model flow at all, only config + validation + serving over mflux.
171 changes: 171 additions & 0 deletions docs/image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Image Guide — Qwen-Image-2.1 on Apple Silicon

Text → image through the OpenAI images API, served by the same lightweight
server as chat and speech. The model math lives entirely in
[mflux](https://github.com/filipstrand/mflux) (pinned by the `[image]` extra,
`>=0.21,<0.22`) — this repo owns config, loading, validation, and serving only.

## License — read before serving

**Qwen-Image-2.1 and its fast variants ship under the
[Qwen Research License](https://huggingface.co/Qwen/Qwen-Image-2.1/blob/main/LICENSE):
non-commercial, research/evaluation purposes only.** Commercial use requires a
separate license from Qwen (`model-business@notice.qwencloud.com`). This
applies to the base checkpoint and equally to the Viggle turbo LoRA and Pruna
distillations (both are derivatives). The server prints the license at load;
if you serve this model, your operators and users inherit the terms.

The Apache-2.0 fast-lane tier (Z-Image-Turbo #99, FLUX.2-klein #100) is the
commercial-safe alternative on the same seam; this model's value is prompt
understanding/world knowledge + upstream vllm-omni parity, not latency.

## Install & run

```sh
pip install 'vllm-omni-mlx[image]' # +mflux (brings torch, ~750 MB)

# image-only server (solo — see memory below)
vllm-omni-mlx serve mlx-community/Qwen-Image-2.1-mflux-q4

# one-shot, no server
vllm-omni-mlx image --prompt "a cozy harbor town at dawn" --out out.png --seed 42
```

Weights: `mlx-community/Qwen-Image-2.1-mflux-q4` (~9.6 GB on disk, 4-bit
DiT + 4-bit text encoder in mflux layout). First call downloads via
`huggingface_hub` — set `HF_ENDPOINT=https://hf-mirror.com` if the Hub is
unreachable from your network. Dense repos also work with `--quantize 4|8`
(on-the-fly quantization at load).

## API

`POST /v1/images/generations` — OpenAI images shape, `b64_json` responses:

```sh
curl -d '{"prompt": "a cozy harbor town at dawn", "size": "1024x1024",
"steps": 40, "seed": 42}' http://127.0.0.1:8000/v1/images/generations
```

| Field | Default | Notes |
| --- | --- | --- |
| `prompt` | required | non-empty string |
| `size` | `1024x1024` | `WIDTHxHEIGHT`, sides multiples of 16, 256–2048 (rejected, not rounded) |
| `steps` | `40` | 1–100; the card's default is 40-step guidance-free |
| `guidance` | `1.0` | 1.0 = guidance-free; >1 with a `negative_prompt` enables true CFG |
| `seed` | random | with `n>1` images run seed, seed+1, … (deterministic per request) |
| `negative_prompt` | none | |
| `n` | `1` | 1–4, generated sequentially (batch-1 doctrine) |
| `response_format` | `b64_json` | only `b64_json` is served (no URL hosting) |

Generation is serialized by the single-user lock, same as TTS: concurrent
requests queue. Same seed + same parameters reproduce byte-identical output
(weight-gated test: `tests/test_image_e2e.py`).

## The Viggle turbo fast lane (6 steps)

The fast-variant decision recorded on #101: **Viggle turbo** — a DMD-distilled
LoRA over the base DiT, sampled on its 6 trained sigma nodes through mflux's
built-in `viggle_turbo` scheduler (6 steps fixed, no CFG). It won on
mflux-first-class support (the scheduler ships in mflux itself); Pruna ships
merged dense transformers with no mflux loader path, and the base model at 40
steps is the quality reference.

```sh
vllm-omni-mlx serve mlx-community/Qwen-Image-2.1-mflux-q4 \
--image-lora Viggle/Qwen-Image-2.1-viggle-turbo:Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors \
--image-scheduler viggle_turbo
```

`org/repo:filename` LoRA refs download on first use. The v0.3 LoRA edges out
v0.2.1 on ghosting; expect visible distillation artifacts at 6 steps (ghosted
structures) against the 40-step base — that is the latency/quality trade,
priced below. One-shot: `vllm-omni-mlx image --lora … --scheduler viggle_turbo`.
Steps must be 6 on this scheduler (validated; the sampler is the LoRA's trained
sigma table).

## Measured on Apple Silicon (M4, 24 GB)

Solo residency, `mlx-community/Qwen-Image-2.1-mflux-q4` (4-bit), guidance-free,
fixed seed, cool-down sleeps between runs; per-step cadence from mflux's
in-loop callback; MLX peak = `mx.get_peak_memory()` after
`mx.reset_peak_memory()`; RSS = process high-water. Repro:
`scripts/bench_image.py --preset base|turbo --out bench.json`.

### Base path — 40 steps, guidance-free (the quality reference)

| Size | Runs | Time-to-image (s) | Per-step (s) | Peak MLX (GiB) | RSS (GiB) | PNG (KiB) |
| --- | --- | --- | --- | --- | --- | --- |
| 512x512 | 3 | 190 / 192 / 192 | 4.6±0.7 | 13.6 | 8.4 | 388 |
| 768x768 | 1 | 449 | 10.9±1.7 | 18.1 | 8.8 | 980 |
| 1280x720 | 1 | 739 | 17.9±2.8 | 21.6 | 8.8 | 1312 |
| 1024x1024 | 2 | 852 / 852 | 20.6±3.3 | 23.3 | 8.8 | 1564 |

### Fast lane — Viggle turbo v0.3 LoRA, 6 steps

| Size | Runs | Time-to-image (s) | Per-step (s) | Peak MLX (GiB) | RSS (GiB) | PNG (KiB) |
| --- | --- | --- | --- | --- | --- | --- |
| 512x512 | 1 | 31 | 4.1±1.4 | 16.9 | 9.1 | 354 |
| 1024x1024 | 1 | 134 | 17.6±8.5 | 24.6 | 9.1 | 1420 |

Weights resident at load: 8.93 GiB (4-bit), 12.2 GiB with the baked r128 LoRA
(227 adapter-targeted layers re-quantized q8); load 7–11 s. 1328×1328 was not
measured: the 1024² fast lane already peaks at 24.6 GiB MLX-active on this 24 GB
machine — the base path at ≥1024² and any path at 1328² want more than 24 GB.

## Quantization (measured)

The served default is already the aggressive end: the pre-quantized
`mlx-community/Qwen-Image-2.1-mflux-q4` repo carries 4-bit DiT + 4-bit text
encoder + 4-bit VAE (8.93 GiB resident, 485 quantized tensors). Measured
alternatives (512², 8 steps, same seed):

| Path | Resident | Per-step | Peak @512² | Quality |
| --- | --- | --- | --- | --- |
| pre-quantized mflux-q4 (served) | 8.93 GiB | ~5.5 s | 13.6 GiB | faint crosshatch in flat gradients (4-bit VAE) |
| dense + `--quantize 8` | 22.4 GiB | ~18 s | 24.6 GiB | visibly cleaner — but not servable |
| dense + `--quantize 4` | 19.1 GiB | ~8 s | 23.6 GiB | same as q4 quality, worse numbers |

Why the dense paths lose: mflux's on-the-fly quantize covers **only the
transformer** — the 8B text encoder stays bf16 (14.1 GiB dense) and the VAE
stays bf16 (1.2 GiB), so `--quantize` on `Qwen/Qwen-Image-2.1` cannot fit the
solo budget and pays 2–3× per-step cost. The 8-bit quality win is real but the
actionable lever is a **hybrid repo (4-bit DiT+TE + bf16 VAE, +0.9 GiB)** —
tracked as follow-up together with an upstream mflux quantize-predicate fix.
`--quantize` remains exposed for experimentation; don't serve dense repos on a
16 GB Mac.

**fp4 follow-up (measured).** The faint crosshatch in flat gradients traces to
the DiT's int4 affine quantization (not the VAE: an int4-DiT + bf16-VAE run
keeps the artifact; int8 DiT removes it). MLX 0.32's floating-point 4-bit modes
fix exactly this at the same size class, measured on the flat-sky probe
(512², 8 steps, TE/VAE bf16): **nvfp4 ≈ int4 speed** (8.0 vs 7.6 s/step,
thermally interleaved) with the periodic streaks reduced to near-int8 levels;
**mxfp4 is ~2× slower** (Metal kernel path); int8 is 2.3× slower. Blocker:
mflux 0.21 hardcodes affine (`nn.quantize(..., bits)`, no `mode`) — the
experiment ran through a monkeypatch. Upstream ask: `mode`/group-size
pass-through + ModelSaver persistence, then a `*-mflux-nvfp4` community repo
becomes the quality-equivalent serving checkpoint at identical speed/memory.

## Design notes

- **Solo-only residency.** Weights are ~8.9 GiB resident and generation peaks
~14–17 GiB MLX-active; on a 16 GB Mac serve this model alone and keep chat
out of the same process. The 24 GB class runs it comfortably.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] "runs it comfortably" contradicts this page's own tables. The tables report 23.3 GiB peak (base 1024²) and 24.6 GiB (turbo 1024²), and the 1328² note says the base path at ≥1024² and any path at 1328² want more than 24 GB; the design-note figures (~14–17 GiB) match only the ≤768² cells. Reconcile the design note with the tables.

- **The step loop is the decode loop** (same doctrine as the TTS per-frame
work): latency scales with latent token count ((H/16)·(W/16)) — a 4×
pixel-count step costs ~4× (compute-bound at 4-bit), so resolution is the
primary latency knob, then steps. Phase breakdown of time-to-image
(instrumented at 512²/1024²): **~97% denoise loop** (N × DiT step), ~1–8%
VAE decode (scales with pixels: 1.6 s @512², 7 s @1024²), ~0.3 s prompt
encode per new prompt (free on repeats — mflux caches the text-prefix KV
in-process), <1% noise init + PNG encode. One-time per process: 7–8 s load,
plus first-generation kernel compile per resolution.
- **Prompt encoding is per-request** (~2 s at load-warm state): the text

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] This bullet contradicts the first bullet (and the code). mflux 0.21 already caches exact-prompt embeddings (model.prompt_cache), so repeats are free — bullet 1 correctly says ~0.3 s and free on repeats, while this bullet claims per-request ~2 s and proposes the cache as a follow-up. Delete or rewrite to match.

encoder runs on every generate. A prompt-embedding cache keyed by prompt
hash is the obvious follow-up (the TTS prefix-cache analog).
- **Zero native model code** — the architecture rule holds: mflux carries the
model; `diffusion/` is config + validation + serving.
- **Follow-ups**: `/v1/images/edits` + Qwen-Image-2.1 reference editing
(prefix-KV, mflux-native — second edit-native model after #100), Z-Image-Turbo
(#99) and FLUX.2-klein (#100) as Apache-2.0 entries on the same seam,
step-cache (`step_cache_ratio`) as a latency lever.
Loading
Loading