Repository navigation
feat(imagegen): port FLUX.2 Klein text-to-image and editing - #2660
Merged
Merged
Conversation
Adds the flux2 image-generation family, ported from mlx-vlm models/flux2 (git 73d366ec): the Klein 4B/9B distilled and base checkpoints, the 9b-kv reference K/V cache, Diffusers bf16 trees and MLX affine 4-bit exports. The family registers in the image-generation registry and in `mlxcel arch`, so `mlxcel generate-image` and `mlxcel-server --image-model` (`/v1/images/generations`, `/v1/images/edits`) load it. The shared latent helpers gain packed-noise and grid-id preparation, and the shared VAE exposes its batch-norm normalize and denormalize steps.
Fixtures come from mlx-vlm 0.7.8 (git 73d366ec) on mlx 0.32.4 CUDA (NVIDIA GB10, MLX_ENABLE_TF32=0). The checkpoint-gated tests check the port in three layers: the transformer in f32 against upstream in f32 on identical inputs (bit-identical), the VAE decoding upstream's own final latents (57.6 dB tiled, 58.5 dB untiled), and the bf16 pipeline end to end (generate 32.3 dB, edit 40.5 dB, initial noise bit-identical). The fixture generator now reads bf16 bits through f32 instead of `mx.array.view`, which reinterprets the physical buffer of a strided array such as the transposed packed latents.
With f16 weights the transformer's activations overflow: the 4B generate parity case decodes NaN latents (PNG PSNR 10.2 dB). The transformer now always runs in bf16, as upstream does, while the VAE follows the project's load policy (`Flux2Vae::default_dtype`) and decode casts the latents to the VAE's dtype. Under the f16 policy (MLXCEL_CUDA_F16_NORMALIZE=1 on GB10) the VAE and text encoder hold: generate 34.47 dB, edit 38.54 dB, VAE-only 54.76 dB tiled and 52.37 dB untiled. Also adds a checkpoint-gated test that the local Klein directories resolve to their variant, steps and guidance.
A family's own limits (FLUX.2's 256 to 2048 size range, its `max_sequence_length` and `tiled_vae` options, an edit without references) surfaced as `500 image generation failed` because every model error maps to an internal error. `ImageGenerationModel` gains a default-accepting `check_request` hook that `validate_request` runs before the model, and the image worker maps a failure there to `InvalidInput` (`400`) without starting GPU work. FLUX.2 implements the hook.
Adds the FLUX.2 Klein section to docs/image-generation.md (checkpoints and their defaults, sizes, prompt handling, tiling, `--gen-kwargs`, batched seeds, measured parity and per-component precision) and an image-generation section to docs/supported-models.md. The status note no longer says that no family is registered.
The example named a sibling family that will be registered soon, so it would go stale; the rule alone stays true.
Upstream FLUX.2 `edit_array` resolves an unsized edit through `_resolve_edit_dimensions`: the first reference is scaled to at most 1024x1024 pixels of area and each side is floored to a multiple of 16 within 256..=2048. The port had that function but never called it; the CLI and the `/v1/images/edits` route used the reference's native size instead, so a FLUX.2 edit of a 1000x667 or 4000x3000 photo without a size failed validation where upstream downscales and rounds. `ImageGenerationModel` gains `default_edit_size`, a function pointer whose default is the reference's own size, so other families keep their behavior. FLUX.2 returns `resolve_edit_dimensions`. The server stores the pointer in the worker snapshot and exposes it on `ImageModelProvider`, and the edits route applies it to the first reference, so the response `size` reports the resolved size. The CLI keeps `build_request` model-free and applies the model's function after the load when `--size` is absent; the pre-load check skips the reference-derived size, which the family may still change. Also notes in `pipeline.rs` that a family may batch from one noise tensor and report the request seed for every image, and documents the new edit default and the provenance of the FLUX.2 edit reference fixture. Tests: FLUX.2 maps 4000x3000 to 1168x880 and 1000x667 to 992x656; a stub family with a non-identity edit size drives both the edits route and the CLI. Filtered lib tests (imagegen, server::image, server::routes::images, models::registry), the generate_image bin tests, clippy -D warnings and fmt pass. Refs #2424
Member
Author
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Acceptance deviationsBoth are sound and documented honestly.
Other checks:
Verification
Also checked against upstream, no change needed:
|
Upstream decodes a batch whole and untiled (decode_image_tiled skips tiling when batch != 1), so a /v1/images/generations request with n > 1 ran the full-resolution VAE decoder on every image at once. Measured on GB10 at 2048x2048: 30 GiB peak for n = 2 (about 15 GiB per image), so n = 10 needs about 150 GiB and takes down a 121 GB host. Flux2Pipeline::decode now slices the batch and decodes each image with the tiling a single image gets, evaluating each before the next: 0.55 GiB peak for the same n = 2. A new real-checkpoint test asserts a batched decode is byte-identical to decoding each image alone. The transformer also stops reading the timestep back from the device on every forward call to decide the x1000 sigma scale; the host value decides it, with a device read kept only for values in (1, 1.01] where a bf16 or f16 cast can round down to 1.0. flux2, imagegen::, server::image and server::routes::images unit tests pass; the six ignored FLUX.2 parity tests pass with unchanged numbers (generate 32.27 dB, edit 40.54 dB, f32 transformer bit-identical). Refs #2424
Member
Author
Security and performance reviewGB10, CUDA, Fixed in c75cffd
Parity numbers unchanged (generate 32.27 dB, edit 40.54 dB, f32 transformer bit-identical). Unit filters Checked, no change needed
Remaining (below the auto-fix threshold)
|
The ignored FLUX.2 real-checkpoint parity tests read MLXCEL_TEST_FLUX2_DIR, which verify-env-var-docs flagged as undocumented. It is a test-only checkpoint path that operators never set, so it joins the allowlist next to the other test-only checkpoint variables. Validation: - make verify-env-var-docs: OK - cargo fmt --check and clippy (test-fast, cuda, lib/bins/tests, -D warnings): clean - lib imagegen::, flux2, server::image, server::routes::images, models::registry and bin generate_image filters pass after merging origin/main Refs #2424
inureyes
marked this pull request as ready for review
October 27, 2026 05:23
This was referenced Oct 27, 2026
inureyes
added a commit
that referenced
this pull request
Oct 27, 2026
## Summary Adds the `bonsai` image-generation family for `prism-ml/bonsai-image-ternary-4B-mlx-2bit`: FLUX.2 Klein 4B with ternary transformer linears packed as 2-bit MLX affine weights and a 4-bit Qwen3 text encoder. It runs through `mlxcel generate-image`, `mlxcel-server --image-model` (`/v1/images/generations`) and is listed by `mlxcel arch --json` (`image_generate`, output `image`). Generation only. - **Loader.** `src/imagegen/bonsai/` reads `transformer-packed-mflux/quantization_config.json`. `format` must be `mlx-packed-affine`, and `bits` and `group_size` must be in mlxcel-core's affine sets; a bad value is rejected by name (the 1-bit binary variant fails on `bits (1)`). Listed modules load as affine `UnifiedLinear` (2-bit, group 128, bf16 scales and biases as stored) and everything else loads dense. A listed module without `.scales` fails, and so does an unlisted module with `.scales`. Every packed layer also passes mlxcel-core's `validate_quantized_packing` at load, so mismatched weight/scales/biases shapes fail with an error instead of aborting the server on the first forward pass. - **Pipeline reuse.** `Flux2Pipeline::with_linear_source` injects the Bonsai `LinearSource` and turns off prompt bucketing (upstream `bucketed_seq_len=False`). `LinearSource::check_weights` is a default-accepting hook that `Flux2Transformer::load` calls. FLUX.2 construction is unchanged, and its parity tests reproduce #2660's numbers exactly. - **`n > 1`.** Runs image by image with seed `seed + i`, which is how upstream's server loops for Bonsai. - **Probe.** The manifest rule was already on main from #2341, covered by `bonsai_manifest_beats_the_flux2_class_name` and `partial_bonsai_manifest_falls_back_to_the_class_name`. This PR adds `flux2_klein_index_without_a_manifest_is_flux2` for the no-manifest case, plus the registration in `FAMILIES` and `STANDALONE_ARCHITECTURE_FAMILIES`. **Deviation: the VAE.** Upstream Bonsai ignores the checkpoint's `vae/` and downloads `black-forest-labs/FLUX.2-small-decoder` at load time, which is a different decoder with channels 96/192/384/384. This port uses the checkpoint's own `vae/`, as the issue specifies, and fetches no second repository. The parity fixtures decode with `vae/` in both arms. Measured against upstream as shipped (small decoder), the result is 29.82 dB; the two decoders differ by 34.96 dB from each other. ## Parity (CUDA oracle) Oracle: mlx-vlm 0.7.8 at git 73d366ec, mlx 0.32.4, CUDA on GB10, `MLX_ENABLE_TF32=0`. The case is "A tiny glass bonsai tree on a moonlit desk" at 512x512, 4 steps, seed 9909. `tests/fixtures/bonsai/generate_bonsai_fixtures.py` runs upstream's own `BonsaiImage` pipeline, with the Metal-only fused norm-RoPE kernels replaced by their arithmetic (f32 RMSNorm times the gain, interleaved-pair RoPE, cast to bf16). `--flux2-transformer` loads Bonsai's weights into upstream's FLUX.2 transformer for the f32 check. That check dequantizes the weights, because mlx 0.32.4 CUDA fails f32 2-bit `quantized_matmul` with M > 1. Committed fixtures total 1.45 MB. | Check | Result | | --- | --- | | Initial noise | bit-identical | | f32 transformer vs upstream f32, same inputs | rel RMS 1.19e-6 | | One bf16 step on upstream's exact step-0 inputs | rel RMS 8.1e-3 | | VAE decoding upstream's final latents | 57.61 dB | | End-to-end PNG | 31.18 dB | | Recorded drift: final latents / prompt embeds | 1.0e-1 / 2.3e-2 | Following the orchestrator's revised gate, the tests assert PSNR >= 30 dB, bit-identical noise, a 0.2 latent bound (a wrong port scores about 1.4) and 0.1 on the embeddings. The drift is bf16 rounding, not a port error. Upstream's bf16 4-bit conditioner is 1.95e-2 from its own f32 run, and this port is 1.74e-2 from that f32 run, with the same growth along the 448 padding rows. Upstream also disagrees with itself by 3.5e-2 in the final latents when its FLUX.2 transformer replaces `klein_fast` on the same weights. The megakernel cannot be run against `quantized_matmul` here because it is Metal-only. By code reading, `QuantizedLinearKernel` calls `mx.quantized_matmul`, and the fused kernels compute the arithmetic described above. **Precision.** The transformer is bf16. The VAE and the text encoder follow FLUX.2's policy. Under `MLXCEL_CUDA_F16_NORMALIZE=1` the case gives 31.21 dB. ## Real checkpoint (GB10) | Run | Result | PNG | | --- | --- | --- | | CLI acceptance command | 512x512, 11.5 s wall including load, 3.6 GB RSS, 31.18 dB | A leafy green bonsai in a dark pot with a glass bulb at the trunk, on a wooden desk in blue moonlight with books at the edges. | | Server, same request | 200, pixel-identical to the CLI (PSNR inf) | Same image. | | Server `n=2`, 256x256, seed 5 | seeds 5 and 6, distinct images (10.3 dB apart) | A small bonsai under a desk lamp, in two compositions. | | Server `4096x512`, `520x512`, `max_sequence_length: 0` | 400 with the range, multiple-of-16, or option message | | | CLI `--task edit` | `image model family bonsai does not support task "edit"` | | ## Changes during review - Merged origin/main twice: Z-Image (#2662) conflicted in both registries and both docs pages; every main row was kept and Bonsai appended (`FAMILIES` is now flux2, z_image, bonsai). - Security: packed layers are now shape-validated with `validate_quantized_packing` (three malformed-checkpoint rejection tests added). Open MEDIUM, shared with FLUX.2 and out of scope: a checkpoint whose tensors agree with each other but were packed for a different `hidden_size` than `config.json` still fails on the first forward pass; fixing it needs the expected input width passed through `LinearSource::load`. - Removed the unused `PackedLinears::config()`; pinned the default `LinearSource::check_weights` with a unit test. - `docs/supported-models.md` now gives both the 31.18 dB (`vae/` on both sides) and 29.82 dB (upstream as shipped) figures. ## Test plan - [x] `imagegen::` lib-test filter (220 pass, 15 ignored, includes the bonsai unit tests); `models::registry` passes 16. Removing the `FAMILIES` row fails `bonsai_is_registered_in_both_tables` and `a_bonsai_checkpoint_resolves_to_the_bonsai_loader`. The loader and config tests do not exist on main. - [x] `bonsai::parity --ignored` 5/5 and `flux2::parity --ignored` 6/6 on the real checkpoints, `MLX_ENABLE_TF32=0`. - [x] clippy (`--lib --bins --tests --no-deps -D warnings`), `cargo fmt --check`, and `make verify-env-var-docs` pass. The two test-only variables are allowlisted. - Not verifiable here: Metal runs, and the megakernel against `quantized_matmul`. Closes #2428
This was referenced Oct 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2424
Summary
Ports FLUX.2 Klein (image-generation family
flux2) from mlx-vlmmodels/flux2(git 73d366ec) intosrc/imagegen/flux2/. It covers text-to-image and reference-image editing for the Klein 4B and 9B distilled and base checkpoints, the9b-kvreference K/V cache, Diffusers bf16 trees, and MLX affine 4-bit exports. The family is registered inimagegen::FAMILIESand inmlxcel arch(image_generateruntime,imageoutput), so bothmlxcel generate-imageandmlxcel-server --image-model(/v1/images/generations,/v1/images/edits) serve it.muscheduler, the Qwen-template prompt encoding (sequence-length buckets, layers 9/18/27), the double- and single-stream transformer with joint RoPE and modulation, the tiled VAE decode with cosine blending, and editing with reference latents att = 10 + 10 * i. The 9b-kv path stores reference K/V on step 0 and uses a zero-timestep modulation blend for the reference tokens.nimages is decoded one image at a time with single-image tiling, son > 1at 2048x2048 peaks at 0.55 GiB in the VAE instead of about 15 GiB per image.n, and every image reportsseed. Edits loop withseed + i._resolve_edit_dimensions(first reference scaled to at most 1 MP, sides floored to 16 within 256..=2048) on the CLI and/v1/images/edits, through a newImageGenerationModel::default_edit_sizehook whose default keeps the reference size for other families.--gen-kwargs.max_sequence_length(1 to 512, default 512) andtiled_vae.LinearSourcetrait, so a ternary source can reuse every block.latent::prepare_packed_latentsandprepare_grid_ids.Flux2Vae::bn_normalizeanddenormalize_packed_latents.ImageGenerationModel::check_requesthook. The image worker maps a failure there to400instead of500, so FLUX.2's size range and option errors are client errors.Precision
MLXCEL_CUDA_F16_NORMALIZE=1)Flux2Vae::default_dtype(bf16 on CUDA, f16 on Apple Silicon)Edits run the transformer in f32 by promotion from the f32 reference latents, as upstream does.
Parity against the CUDA oracle
Oracle: mlx-vlm 0.7.8 at git 73d366ec, mlx 0.32.4, CUDA backend on this GB10 host,
MLX_ENABLE_TF32=0. Fixtures are intests/fixtures/flux2/(about 2.6 MB), reproducible withgenerate_flux2_fixtures.py. Tests are insrc/imagegen/flux2/parity_tests.rs(#[ignore], checkpoint-gated).ModelConfig.precision = f32), identical inputsDeviation from the issue: the 1e-2 final-latent bound does not hold in bf16, and the test does not assert it. The first divergent tensor is the context embedder output, a single 7680-wide bf16 matmul on identical inputs and weights; the image embedder (128-wide) is bit-identical. The cause is reduction order in bf16 GEMMs, which differs between this build's MLX (pin 7ca40033) and mlx 0.32.4. Four Euler steps then amplify it. Upstream's own f32 run differs from its bf16 run by 7.1e-2 on the same case, and the f32 transformer check shows the port's graph is exact. The end-to-end test therefore asserts PSNR >= 30 dB and a 0.2 sanity bound on the latents, which a wrong port (about 1.4) cannot pass. Generate is about 2 dB over the gate, so a future MLX pin change can move it. TF32 is off in every run here.
Edit fixture and upstream's encoder. Upstream's
_match_conv_layoutleaves the VAE encoderconv_inweight (128, 3, 3, 3) in PyTorch order. The edit fixture was generated with that tensor transposed (--fix-conv-in, the default), which is what this port computes.edit_reference.pngis itself an upstream FLUX.2-klein-4B generation (prompt "A studio photo portrait of a smiling man with short brown hair, plain grey background", seed 7, 512x512, 4 steps) written by the same script; it shows no real person, and the 4B checkpoint is Apache-2.0.Fixture generator fix:
mx.array.viewreinterprets the physical buffer of a strided array, so the generator now reads bf16 bits through f32.Real-checkpoint runs (CUDA, GB10)
/v1/images/generations, same request/v1/images/edits, 4B, "Add black sunglasses", seed 123n=2, 256x256The issue asks that the 9b-kv edit match the non-cached 9B edit within 35 dB. That cannot hold, because the 9b-kv checkpoint has its own weights (none of the transformer tensors compared are identical to 9B). Upstream's 9b-kv and 9B edits differ by 25.15 dB, and this build's differ by 25.09 dB. The meaningful check is 9b-kv against upstream 9b-kv: 42.94 dB.
Edge errors (CLI and server).
4096x512and128x128are 400width must be in [256, 2048], got ....520x512is 400multiples of 16.max_sequence_length0 or 600, and a non-booleantiled_vae, are 400.--task editwithout--imageerrors.vae/fails at load:FLUX.2 checkpoint is missing .../vae.Tests
prepare_text_idslayout;--gen-kwargsparsing, and registration;check_requestfailure is 400 and never runs the model.FAMILIESrow removed, four tests fail:flux2_is_registered_in_both_tablesevery_shipping_row_is_a_probe_familyload_reports_a_flux2_checkpoint_missing_a_component(family flux2 is not supported by this build)no_registered_family_claims_image_generation_before_a_model_ports_itVerification
All commands ran with
MLX_CUDA_ARCHITECTURES=121andMLX_ENABLE_TF32=0. Test binaries ran undergpu-lockwith--test-threads=1.cargo clippy --profile test-fast --features cuda -p mlxcel --lib --bins --tests --no-deps -- -D warnings: clean.cargo fmt --check: clean.Lib test binary filters:
imagegen::flux2server::imageserver::routes::imagesmodels::registryflux2::parity_tests --ignored(real checkpoints)Bin test binary:
generate_image15 passed.Changes during review
resolve_edit_dimensionswas ported but unused; it is now wired throughImageGenerationModel::default_edit_sizeon the CLI and/v1/images/edits(a 1000x667 reference gives a 992x656 output). For every family, an edit without a size is now size-checked after model load instead of before.n = 10could exhaust host memory.Flux2Pipeline::decodenow decodes per image; a checkpoint-gated test asserts the batched decode is byte-identical to decoding each image alone. Forn > 1this differs from upstream's untiled batch decode by the tiled vs untiled gap (57.59 vs 58.45 dB VAE-only).MLXCEL_TEST_FLUX2_DIR(test-only checkpoint path) is allowlisted inscripts/ci/check_env_var_docs.py.Not covered here
archrow says Metal partial.