Skip to content

feat(image): mflux seam + Qwen-Image-2.1 serving — /v1/images/generations, Viggle turbo fast lane (#91, #101) - #109

Open
hsliuustc0106 wants to merge 5 commits into
mainfrom
feat/image-qwen21
Open

hsliuustc0106 wants to merge 5 commits into
mainfrom
feat/image-qwen21

Conversation

@hsliuustc0106

Copy link
Copy Markdown
Contributor

What

Closes #101 — Qwen-Image-2.1 served on the OpenAI images API, and with it the mflux
diffusion seam from #91 (that seam had not landed yet, so this PR lays it down together with
the first mflux backend; #91's Z-Image-Turbo reference-path acceptance remains open for #99).

  • vllm_omni_mlx/diffusion/ — the seam (Image generation engine: /v1/images/generations on MLX — mflux seam, [image] extra, time-to-image doctrine #91 design): config.py (model registry + loader;
    license surfaced per family) and service.py (validation + single-user lock, prompt → PNG
    bytes). Zero native model code — the architecture rule holds; model math is mflux's,
    pinned >=0.21.0,<0.22 under a new [image] extra.
  • POST /v1/images/generations on the Starlette server: OpenAI images shape (b64_json),
    OpenAI size/n plus diffusion extras steps, guidance, seed, negative_prompt.
    size sides must be multiples of 16 — rejected with a 400 rather than silently rounded.
  • CLI: serve <image-repo> (checkpoint sniffing via diffusers/mflux layout markers),
    serve --image-model <repo> (solo image serving), --image-lora/--image-scheduler
    (the fast lane), and a one-shot vllm-omni-mlx image --prompt … --out out.png.
  • scripts/bench_image.py — the Image generation engine: /v1/images/generations on MLX — mflux seam, [image] extra, time-to-image doctrine #91 metrics doctrine for images: time-to-image, per-step
    cadence (mflux in-loop callback), MLX peak/active/cache + process RSS, per-resolution
    cells, cool-down sleeps; JSON + markdown output. Repro for every number below.

Verdicts (recorded first, per the issue's acceptance)

License — non-commercial, all paths. Qwen/Qwen-Image-2.1 ships the Qwen Research
License
(2026-09-20): use, reproduction, and derivatives for non-commercial
research/evaluation only
; commercial use needs a separate Qwen license. The fast variants
inherit it (Viggle turbo LICENSE and Pruna LICENSE are the same agreement). Served with the
license printed at load and flagged in README/docs. The Apache-2.0 tier (#99/#100) is the
commercial-safe path on the same seam.

Fast variant — Viggle turbo wins. mflux carries first-class support: a DMD-distilled
LoRA over the base DiT + a built-in viggle_turbo scheduler sampling the LoRA's 6 trained
sigma nodes (6 steps, no CFG — enforced in our service validation). A/B at 512²/seed 1000:
v0.3 LoRA shows less ghosting than v0.2.1 → v0.3 is the default fast lane. Pruna ships
merged dense transformers (p_qwen_image_2.1_{5,8}step_v0.1.safetensors) with no mflux
loader path — recorded as follow-up, not selected. Base-40-step remains the quality
reference and the serving default.

Evaluation (M4 24 GB, solo, 4-bit mflux-q4, guidance-free, fixed seeds, cool-down sleeps)

Weights resident at load: 8.93 GiB MLX-active (4-bit DiT + 4-bit TE + VAE), load 7–8 s
from page cache. ru_maxrss high-water stays ~8.9–9.5 GiB.

Base path — 40 steps, guidance-free (time-to-image; min/median/max of N runs)

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

Reading: latency scales with latent tokens ((H/16)·(W/16)) — ~4× pixels ≈ 4× per-step
(compute-bound at 4-bit). The turbo lane is 6.3× faster end-to-end at 1024² (134 s vs
852 s) with visible distillation ghosting; the base path is the quality reference. The
issue's "~10–30 s per image" expectation for the official base holds only on
Max/Ultra-class bandwidth — on a base M4 the 40-step path is minutes, which is exactly why
the fast lane matters as the servable default. Weights resident: 8.93 GiB (base), 12.2 GiB
(with the baked LoRA). 1328² not measured: the 1024² fast lane already peaks at 24.6 GiB
MLX-active on this 24 GB machine — ≥1024² base and any 1328² path want >24 GB.

Gates

  • Weight-gated e2e (tests/test_image_e2e.py, 3/3 green locally): same-seed
    byte-identical output; non-degenerate PNG stats (stddev band, channel means, size
    floor); the issue's acceptance cell — a valid 1024² image from the default 40-step path.
  • CI-safe units (tests/test_image_service.py, 13 tests): size/steps/guidance/n/seed
    validation, seed incrementing for n>1, the viggle_turbo steps=6 gate, lock behavior,
    HTTP surface (OpenAI shape, 400 mapping, response_format: url rejected, model listing).
  • Full existing suite: green with image additions (weight-gated TTS tests skip where
    checkpoints are absent, unchanged).
  • Serve-path e2e: vllm-omni-mlx serve + client round-trip (see comment below).

Footprint note

[image] is the first extra that drags torch (arm64 build, a hard mflux dependency)
plus matplotlib/opencv: 61 resolved packages, ~1.3 GB installed, ~750 MB over [tts].
README's footprint table gains the row with an explicit "keep it out of installs that
don't serve images".

Follow-ups (not in this PR)

Reference editing + /v1/images/edits (mflux prefix-KV — second edit-native model after
#100) · Z-Image-Turbo (#99) and FLUX.2-klein (#100) as Apache-2.0 entries on the same seam ·
prompt-embedding cache (the TTS prefix-cache analog; prompt encode costs ~2 s/request) ·
step-cache (step_cache_ratio) as a latency lever · Pruna merged-transformer loader.

…ions, Viggle turbo fast lane (#91, #101)

The diffusion seam from #91 lands here with its first mflux backend (that seam
had not landed yet); #91's Z-Image-Turbo reference-path acceptance stays open
for #99.

- diffusion/ seam: config (family registry + mflux loader, license surfaced)
  + service (validation, single-user lock, prompt → PNG bytes). Zero native
  model code; mflux pinned >=0.21,<0.22 under the new [image] extra.
- POST /v1/images/generations: OpenAI images shape, b64_json; size/n plus
  steps/guidance/seed/negative_prompt; multiples-of-16 enforced (no silent
  rounding); 'url' response_format rejected.
- CLI: serve <image-repo> (layout-marker sniffing), --image-model, --image-lora,
  --image-scheduler, one-shot 'vllm-omni-mlx image'.
- Verdicts recorded on #101: license non-commercial (Qwen Research License,
  all paths incl. Viggle/Pruna); fast variant = Viggle turbo v0.3 LoRA via the
  built-in viggle_turbo scheduler (6 steps enforced); Pruna not selectable
  (merged transformers, no mflux loader path).
- Eval (M4 24 GB, solo, 4-bit, fixed seeds): base 40-step 512²→1024²
  190→852 s tti (peak MLX 13.6→23.3 GiB); turbo 6-step 31 s @512², 134 s
  @1024² (6.3× vs base; peak 24.6 GiB). scripts/bench_image.py reproduces.
- Tests: 13 CI-safe unit tests (validation/seed/lock/HTTP shapes) +
  weight-gated e2e (byte-identical same-seed repro, output stats gates,
  IMAGE_E2E_FULL=1-gated 1024²/40-step acceptance cell). Serve-path e2e:
  boot + client round-trip + 400/401 shapes verified.
@hsliuustc0106

Copy link
Copy Markdown
Contributor Author

Serve-path e2e (weight-gated, on the M4 box):

  • vllm-omni-mlx serve mlx-community/Qwen-Image-2.1-mflux-q4 --api-key demo boots; /v1/models lists the checkpoint; license printed at load.
  • examples/image_generations.py round-trip: POST /v1/images/generations {512x512, 4 steps, seed 42} → valid PNG (magic bytes verified), 20.0 s wall.
  • Error shapes: size: "1000x1000" → 400 "size width must be a multiple of 16 (got 1000); mflux rounds silently and we refuse to"; missing API key → 401 authentication_error. response_format: "url" → 400 (b64_json only).

One process note for reviewers running the suite: the 1024²/40-step acceptance cell is gated behind IMAGE_E2E_FULL=1 so a plain unittest discover stays light (~2.5 min of image e2e); the full acceptance ran green on this machine (~14 min) and is what the base-path table's 1024² rows reproduce.

Also, per the maintainer's 1328² concern: agreed and recorded in the tables — 1328² was not measured because the 1024² fast lane already peaks at 24.6 GiB MLX-active here; any 1328² path needs >24 GB. Extrapolating per-token puts base-40-step at roughly 29–30 GiB, so it would not have completed on this box.

…h; on-the-fly quantize covers the DiT only

Measured on the M4 (512², 8 steps, same seed): dense + --quantize 8 → 22.4 GiB
resident, ~18 s/step, 24.6 GiB peak (visibly cleaner output but not servable);
dense + --quantize 4 → 19.1 GiB, ~8 s/step. Root cause: mflux's on-the-fly
quantize predicate covers only the transformer — the 8B text encoder stays
bf16 (14.1 GiB). Follow-up lever: hybrid 4-bit DiT+TE + bf16 VAE repo.
@hsliuustc0106

Copy link
Copy Markdown
Contributor Author

Quantization A/B added (docs/image.md updated, commit on the branch):

Path Resident Per-step @512² 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 — not servable
dense + --quantize 4 19.1 GiB ~8 s 23.6 GiB same quality class as q4, worse numbers

So: the served default is the quantization answer — 4-bit everywhere, and the pre-quantized repo is the only path that both fits (the on-the-fly predicate leaves the 8B text encoder bf16 at 14.1 GiB) and is fastest. The 8-bit quality win is real but the actionable lever is a hybrid 4-bit DiT+TE + bf16 VAE repo (+0.9 GiB) plus an upstream mflux quantize-predicate fix — filed as follow-up, not in this PR.

…E decode scales with pixels, prompt encode ~0.3s (cached)
…t4 affine DiT; nvfp4 matches int4 speed and near-int8 flat gradients (mflux monkeypatch experiment)
@hsliuustc0106

Copy link
Copy Markdown
Contributor Author

fp4 quantization research (docs/image.md updated): where does the faint crosshatch come from, and would mxfp4/nvfp4 fix it?

Root cause ablation (same seed/prompt, DiT-only quantization, TE/VAE bf16): the artifact is the int4 affine DiT, not the 4-bit VAE — int4-DiT + bf16-VAE keeps the crosshatch, int8-DiT removes it.

Format ablation (MLX 0.32 nn.quantize(mode=…), patched into mflux 0.21, which hardcodes affine):

DiT format s/step (thermally interleaved) flat-sky periodic texture
affine int4 g64 (served class) 7.6 directional streaks (worst)
affine int4 g32 ≈same marginally better, still patterned
mxfp4 ~2× slower different trajectory, no win
nvfp4 8.0 (≈int4) near-int8: periodic structure gone
affine int8 17.7 (2.3× slower) clean reference

Conclusion: nvfp4 is the same size/speed class as the served int4 with visibly better flat gradients — the right target format for the next community quant repo. Blocker is upstream: mflux 0.21 passes no mode to nn.quantize and ModelSaver doesn't persist fp4 formats, so this is an mflux feature ask (mode/group-size pass-through + saver support) rather than a PR-side change. Experiment scripts: monkeypatched nn.quantize + the flat-sky probe (a perfectly smooth clear blue sky… makes the artifact unambiguous — natural-image FFT metrics failed to isolate it, content dominates).

Comment thread vllm_omni_mlx/__main__.py
if lora_paths:
config = replace(config, lora_paths=tuple(lora_paths))
if scheduler:
config = config.with_overrides(scheduler=scheduler)

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.

[P1] The Viggle turbo fast lane rejects every default request — both documented invocations are broken. _load_image_service overrides only scheduler; ImageConfig.steps keeps its default 40 (config.py:35), and the service gate rejects any steps != 6 under viggle_turbo. So the documented serve command (README + docs/image.md, ending --image-scheduler viggle_turbo) builds a server that 400s every request unless the client explicitly sends steps: 6 — and there is no --image-steps serve flag to fix it server-side. The one-shot path fails the same way: image --scheduler viggle_turbo without --steps 6 hits the same gate → ImageError → exit 1 (the --steps default is None, which with_overrides drops). scripts/bench_image.py is the only caller that sets steps=VIGGLE_TURBO_STEPS, which is why the bench worked while the shipped CLI paths don't. Fix: when scheduler == viggle_turbo, default steps to VIGGLE_TURBO_STEPS unless explicitly overridden (serve and one-shot paths), or add --image-steps.


from .config import VIGGLE_TURBO_SCHEDULER, VIGGLE_TURBO_STEPS, ImageConfig

MIN_SIDE, MAX_SIDE = 256, 2048

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.

[P2] MAX_SIDE = 2048 admits sizes the PR itself documents as unservable. The docs/image.md tables show 1024² turbo peaking 24.6 GiB on the 24 GB host and state any 1328² path wants more than 24 GB — yet validation accepts sides up to 2048 (4× the pixels of 1328², certain swap/OOM), and even the default 1024x1024 sits at 23.3 GiB peak on exactly that class of box. Oversized requests are documented, not guarded. Fix: cap MAX_SIDE at the measured envelope (e.g. 1328) or make it a config knob tied to the box class.

Comment thread tests/test_image_e2e.py
cls.model = load_image_model(cls.config)
cls.service = ImageService(cls.model, cls.config)

def test_same_seed_is_byte_identical(self):

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.

[P2] No worker-thread coverage with the real engine. The repo's serving bar requires it — main-thread tests structurally miss thread-binding bugs. test_image_service.py exercises the HTTP→asyncio.to_thread path only with FakeMfluxModel, and this e2e drives service.generate on the main thread. mflux builds mx.compile closures lazily on first generate and mutates shared state (prompt_cache, _text_caches) — serialized by the service lock, but never exercised on a serving worker thread with the real engine. Fix: add a gated serve smoke here (TestClient over create_app(image_service=…) or asyncio.to_thread(service.generate, …)).

if not isinstance(n, int) or not 1 <= n <= 4:
raise ImageError("n must be an integer within [1, 4]")
if seed is not None:
seed = self._int(seed, "seed")

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] Negative seed yields a 500, not a 400. _int checks only isinstance(value, int); mx.random.key(-1) raises TypeError (verified against installed mlx 0.32.3), which escapes as the route's generic 500 instead of a validation error. Fix: reject seed < 0 (or bound to [0, 2**32)) alongside the int check.

seed_i = seed if seed is not None else random.randint(0, 2**31 - 1)
seed_i += i
started = time.perf_counter()
generated = self._model.generate_image(

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] mflux's prompt-embedding cache grows unbounded in a long-running server. mflux 0.21 writes every unique prompt's embeddings into model.prompt_cache and never evicts (verified in the installed 0.21.0). The per-run finally clears transformer._text_caches but not prompt_cache, so each unique prompt permanently adds resident memory on a box the docs say peaks at 23.3–24.6 of 24 GiB. Fix: cap or periodically clear prompt_cache from the seam (docs/image.md already treats the prompt cache as a known lever).

Comment thread vllm_omni_mlx/__main__.py
help="also serve image generation on /v1/images/generations (mflux via the [image] extra); the only model when <model> is omitted",
)
parser.add_argument(
"--image-lora",

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] --image-lora / --image-scheduler are silently dropped when no image model is served. They are consumed only inside _load_image_service, so serve --tts-model X --image-scheduler viggle_turbo starts a server with the flags ignored. Fix: error or warn in _serve when these flags are set without an image model being loaded.

Comment thread docs/image.md
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.

Comment thread docs/image.md

- **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.

Comment thread scripts/bench_image.py

# default cells: "SIZE:runs" — three runs up to 1024²-side, two for the
# multi-minute big cells (min/median/max still meaningful at n=2/3)
BASE_CELLS = "512x512:3,768x768:3,1024x1024:3,1280x720:2,1328x1328:2"

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] The documented repro does not reproduce the published tables. The default preset includes 1328x1328:2, which the docs explicitly say was not measured and wants more than 24 GB (the default preset would attempt unservable cells on the measurement machine), and the table run counts (768²=1, 1280×720=1, turbo cells=1) don't match the preset defaults (3/2/3). Fix: align preset defaults with the published cells or record the --cells/run overrides actually used.

@hsliuustc0106 hsliuustc0106 left a comment

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.

Delegated review @ ee177496 — verdict: request-changes (9 findings posted inline; the P1 blocks).

The P1: the Viggle turbo fast lane — the headline feature — rejects every default request. Both documented invocations (serve --image-scheduler viggle_turbo, one-shot image --scheduler viggle_turbo) leave steps at the config default 40, and the service gate requires exactly 6 under that scheduler; only bench_image.py sets it. Plus a P2 size cap (2048 admits documented-unservable requests) and a P2 missing worker-thread coverage for the real engine.

Verified against the installed stack (mflux 0.21.0 / mlx 0.32.3, read-only):

  • every QwenImage21 / generate_image kwarg used by the service is valid in 0.21.0; viggle_turbo is a registered scheduler with fixed 6-step sigma nodes (its own loader raises if steps ≠ 6, mirroring our 400 gate);
  • license print at load present (3 sites); CI gating is structural (local_snapshot gate precedes the lazy mflux import; no ungated mflux/torch imports in CI-collected tests);
  • text-prefix KV caching and the exact-prompt prompt_cache exist as claimed (grounds the unbounded-growth P3);
  • mx.random.key(-1) raises TypeError (grounds the seed P3).

Not executed (weight-gated): all eval/battery/e2e runs — evidence reviewed only.

Questions (could not verify offline):

  1. Thread affinity — generation runs on asyncio.to_thread pool threads against a model loaded on the main thread, with lazily-built mx.compile closures. The lock serializes access, but was any serve-path generation (HTTP round-trip, not CLI/bench) actually run?
  2. _looks_like_image routes any repo with transformer/config.json and no _class_name to the image path — known text/vlm repos that would be misrouted?
  3. turbo_lora_path()'s bare-repo-id fallback (Viggle/Qwen-Image-2.1-viggle-turbo without :filename) — does mflux resolve that as a single adapter or a full-repo download?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image model: Qwen-Image-2.1 + fast variants (tier 1) — the currently-trending image family (3M+/mo wave), upstream flagship parity

1 participant