Skip to content

FLUX.3 prompt upsampler block - #15058

Merged
yiyixuxu merged 3 commits into
mainfrom
flux3-prompt-upsampler
Oct 16, 2026
Merged

yiyixuxu merged 3 commits into
mainfrom
flux3-prompt-upsampler

Conversation

@yiyixuxu

@yiyixuxu yiyixuxu commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

This PR prompt upsampler block into official pipeline

"""FLUX.3 prompt upsampler examples, as they appear in the docs: the pipeline runs the upsampler on `prompt` by default,
for text-to-image, an edit, and the two layout forms (boxes on a blank canvas; a box over a reference); the upsampler
on its own; and the pipeline without it, for a caption to encode as given. The requests, references and boxes are the
ones of BFL's examples (github.com/black-forest-labs/flux-3-image-klein).

Standalone: writes the captions and five PNGs under `out/`.

    python flux3_upsampler_examples.py
"""

import json
import os

import torch

from diffusers import ModularPipeline
from diffusers.utils import load_image


REPO = "black-forest-labs/flux-3-image-klein-base"  # or black-forest-labs/flux-3-image-klein
REVISION = "refs/pr/17"  # the index with the upsampler components, until the PR is merged (klein: refs/pr/22)
INPUTS = "https://raw.githubusercontent.com/black-forest-labs/flux-3-image-klein/main/examples/inputs"
OUT = "out"
os.makedirs(OUT, exist_ok=True)
captions = {}

# the pipeline runs the prompt upsampler on `prompt` first; the task follows the inputs, like the rest of the pipeline
pipe = ModularPipeline.from_pretrained(REPO, revision=REVISION)
pipe.load_components(dtype={"vae": torch.float32, "default": torch.bfloat16})  # the image VAE runs in fp32
pipe.to("cuda")

# text-to-image
out = pipe(
    prompt="A man walking with his dog, afternoon sun",
    generator=torch.Generator().manual_seed(7),
    output=["images", "prompt"],
)
out["images"][0].save(os.path.join(OUT, "t2i.png"))
captions["t2i"] = out["prompt"]  # the caption the image model received

# an edit: the reference goes to the upsampler (which writes the caption from it) and to the image model
dog = load_image(f"{INPUTS}/3.png")
out = pipe(
    prompt="put a red bandana around the dog's neck",
    image=dog,
    generator=torch.Generator().manual_seed(22),
    output=["images", "prompt"],
)
out["images"][0].save(os.path.join(OUT, "i2i.png"))
captions["i2i"] = out["prompt"]

# layout: boxes are [y_min, x_min, y_max, x_max] in 0-1; the caption comes back with the box table appended and
# the given boxes kept exactly
boxes = [
    {"id": "obj_1", "desc": "a red and yellow striped hot-air balloon", "bbox": [0.08, 0.06, 0.48, 0.38]},
    {"id": "obj_2", "desc": "a small blue hot-air balloon", "bbox": [0.20, 0.62, 0.42, 0.80]},
]
out = pipe(
    prompt="hot-air balloons over a green valley at sunrise",
    boxes=boxes,
    generator=torch.Generator().manual_seed(1),
    output=["images", "prompt"],
)
out["images"][0].save(os.path.join(OUT, "bbox_t2i.png"))
captions["bbox_t2i"] = out["prompt"]

# layout edit: a box over the reference places a new element ('from': 'target'); 'from': 'ref_image_0' with
# `src_bbox` and `tgt_bbox` would move an existing one
mug = load_image(f"{INPUTS}/2.png")
boxes = [{"id": "obj_1", "desc": "a small potted cactus", "from": "target", "src_bbox": None, "tgt_bbox": [0.55, 0.62, 0.85, 0.85]}]
out = pipe(
    prompt="add a small potted cactus in the marked spot",
    image=mug,
    boxes=boxes,
    generator=torch.Generator().manual_seed(1),
    output=["images", "prompt"],
)
out["images"][0].save(os.path.join(OUT, "bbox_i2i.png"))
captions["bbox_i2i"] = out["prompt"]

# the upsampler on its own: its step of the pipeline, as a pipeline that writes captions (sharing the loaded components)
pup = pipe.blocks.sub_blocks["prompt_upsampler"].init_pipeline(REPO)
pup.update_components(prompt_upsampler=pipe.prompt_upsampler, prompt_upsampler_processor=pipe.prompt_upsampler_processor)
captions["standalone"] = pup(prompt="A man walking with his dog, afternoon sun", output="prompt")

# a caption to encode as given: the pipeline without the step
blocks = ModularPipeline.from_pretrained(REPO, revision=REVISION).blocks
blocks.sub_blocks.pop("prompt_upsampler")
plain = blocks.init_pipeline(REPO)
plain.load_components(dtype={"vae": torch.float32, "default": torch.bfloat16})
plain.to("cuda")
image = plain(prompt=captions["t2i"], generator=torch.Generator().manual_seed(7), output="images")[0]
image.save(os.path.join(OUT, "t2i_from_caption.png"))

json.dump(captions, open(os.path.join(OUT, "captions.json"), "w"), indent=1, ensure_ascii=False)
print("wrote", sorted(os.listdir(OUT)))

text to image
t2i

edit
i2i

layout
bbox_t2i

layout edit
bbox_i2i

text to image (from expanded prompt)
t2i_from_caption

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests modular-pipelines size/L PR with diff > 200 LOC labels Oct 15, 2026
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

yiyixuxu and others added 2 commits October 16, 2026 03:57
Adds Flux3PromptUpsampleStep, a conditional block that runs the FLUX.3 prompt
upsampler (black-forest-labs/flux-3-image-klein-pup) in front of the pipeline.
It expands `prompt_to_upsample` into `prompt`, picks the task from the inputs
(text-to-image or image-conditioned, each with optional layout boxes), and is
skipped when `prompt` is passed directly. The reference preparation and the
box handling follow the official inference code; the system prompts are
inlined from the upsampler repository.

Also fixes the Memory section of the FLUX.3 docs, which called
enable_auto_cpu_offload on the pipeline instead of the ComponentsManager.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@yiyixuxu
yiyixuxu force-pushed the flux3-prompt-upsampler branch from eec9cce to 4777180 Compare October 16, 2026 02:09
@yiyixuxu
yiyixuxu requested a review from sayakpaul October 16, 2026 02:16
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@yiyixuxu
yiyixuxu merged commit d42ebf9 into main Oct 16, 2026
17 checks passed
@yiyixuxu
yiyixuxu deleted the flux3-prompt-upsampler branch October 16, 2026 03:25
@vladmandic

Copy link
Copy Markdown
Contributor

prompt-upsampling may be strongly recommended, but this pr makes it mandatory as pipeline FAILS if prompt upsampler is not loaded. and making 9b upsampling model mandatory for model that is intended to run on lower-end gpus doesn't make sense. also, loader does not load upsampler automatically which makes upsampling mandatory, but must-be-loaded-by-user-separately - also not in line with any other model.

@yiyixuxu

yiyixuxu commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

it's by default, but not mandatory

you can pop out the block if yon't want it, the doc covers the use case https://huggingface.co/docs/diffusers/main/en/api/pipelines/flux3#prompt-upsampling

blocks.sub_blocks.pop("prompt_upsampler")
pipe = blocks.init_pipeline("")

and it's gone, the block and its components

this is the user experience BFL team preferred. like we explained in the doc, the model needs to take full scene captions and gives noticeably worse results without the upsampler - that's something specific to how this model is trained and we don't think it make any sense to overlook that simply to be in line with other models

@yiyixuxu

Copy link
Copy Markdown
Collaborator Author

also, loader does not load upsampler automatically which makes upsampling mandatory, but must-be-loaded-by-user-separately

also this, not sure what you're talking about
it loads automatically, so feel free to open an issue with reproducible script if not for you

danielhanchen added a commit to unslothai/unsloth that referenced this pull request Oct 18, 2026
…UF support (#13933)

## Problem

FLUX.3 Image Klein (black-forest-labs/flux-3-image-klein and -base)
cannot be loaded in Studio. Diffusers ships it only as Modular Diffusers
blocks (`Flux3DistilledAutoBlocks`, `Flux3AutoBlocks`), the repos carry
`modular_model_index.json` instead of `model_index.json`, and the
support is not in any Diffusers release yet
(huggingface/diffusers#15046).

## Change

- New `core/inference/diffusion_flux3.py`: loads the transformer, the
Qwen3-VL-4B text encoder and processor, and the FLUX.2 VAE per
subfolder, and puts a `DiffusionPipeline` face over the modular blocks.
Placement, model and group offload, the step callback (progress,
cancel), the decode phase hook and the scheduler swap all work through
the same component objects the blocks run.
- Two families: `flux.3-klein` (distilled, 16 steps, CFG 1, Euler) and
`flux.3-klein-base` (50 steps, CFG 3.5, negative prompt). Defaults
follow ComfyUI's current FLUX.3 templates (workflow templates 0.11.87):
the base text to image and edit templates run 50 steps at CFG 3.5 (euler
/ simple), raised from the launch templates' 25, which is also the BFL
reference setting (diffusers' FLUX.3 defaults: 50 steps, guidance 3.5);
both distilled templates run 16 steps at CFG 1. On hand-heavy prompts (6
prompts, short and long, 2 seeds, same reference noise) 50 steps fixed a
malformed handshake the 25-step render had and otherwise left the image
nearly unchanged. Both take up to 10 images through the existing
reference workflow. The `-pup` prompt upsampler repo is not matched as
an image model.
- Reference images are scaled to 1 megapixel (Lanczos, aspect kept)
before encoding, as ComfyUI's `ReferenceMultiLatent ["resize", 1]` does
in the FLUX.3 templates. Diffusers alone only downsizes above 16
megapixels.
- A seeded request starts from the BFL reference noise layout, so a
Studio seed renders what the BFL reference CLI renders for that seed.
The VAE stays in bf16, as ComfyUI runs the FLUX.2 VAE on NVIDIA (the BFL
reference decodes in fp32).
- Download plan: the modular index is the manifest and only
`transformer/`, `text_encoder/` and `vae/` are fetched, for full
pipelines and as the companions of a GGUF or single file (which take
only `transformer/config.json` from `transformer/`); the root BFL single
file, `ae.safetensors`, `variants/` and the prompt upsampler repo are
not. The plan also reports a pick of the other variant before anything
is staged.
- BFL added the optional prompt upsampler (`prompt_upsampler` /
`prompt_upsampler_processor`, from the separate ~19 GB
`black-forest-labs/flux-3-image-klein-pup` repo) to both official
manifests on 2026-10-16. Image loads ignore it: validation and the
locality check require only the components FLUX.3 loads (text encoder
and its processor, transformer, VAE). Checked on the current revision
through the real load path for a bf16 pipeline, a GGUF with staged
companions and the INT8 prequant: renders are pixel-identical to before
and the pup repo is never downloaded.
- Memory plan: bf16 size table entries, and the load-dtype sizing now
prices only `<component>/<file>` weights. The cached `variants/fp8r` and
`variants/nvfp4` packages were inflating the plan from 19.8 GB to 31.7
GB.
- Version gate: minimum 0.42.0, marked unreleased. Diffusers main and
the FLUX.3 branch both report `0.41.0.dev0`, which reads the same as the
0.41.0 release without FLUX.3, so the picker checks for the FLUX.3
module file in the installed package instead (no import).
- Quantisation: a new `FamilyProfile.quant_exclude_tokens` leaves named
layers dense for every transformer-quant scheme (int8, fp8, mxfp8,
nvfp4). It is empty by default, so other families are unchanged. FLUX.3
keeps the per-token modulations, timestep embedders, `norm_out`, the
text-stream per-mode blocks and the 20480-wide text projection in bf16,
which leaves 228 of 273 Linears quantised. Without it, mxfp8 and nvfp4
crash at the first step: the modulation inputs are a stride-0 `expand`,
and those kernels need contiguous inputs. int8 and fp8 also lose more
quality without it.
- nvfp4 is denied for FLUX.3: every keep-set measured 0.09 to 0.12
LPIPS, and so did BFL's own nvfp4 package.
- MX and NVFP4 Linears in every family now get a contiguous activation
through a forward pre-hook, installed by the shared post-quantisation
hook. torchao's activation quantisers assert contiguity, and a stride-0
broadcast such as `temb.expand(B, S, D)` otherwise crashes at the first
step; for contiguous inputs the hook does nothing. This is a
cross-family change. FLUX.2 klein 4B with mxfp8 (106 MX Linears,
compiled with CUDA graphs, the only way Studio runs a quantised
denoiser), checked in one process: 2 prompts at 1024x1024 rendered
without the hook, then again with it, give `torch.equal` decoded images,
and so does the denoiser output on a captured step. Two renders without
the hook are also `torch.equal` in that process. Across separate
processes the same renders differ by up to 2 of 255 with or without the
hook (before vs after this commit, and between two runs of the same
commit), so that comparison cannot show a difference either way. The
hook returns the arguments unchanged for any contiguous input. The
scheme smoke probe is unchanged from main, so no family's `auto` choice
moves.
- Regional compile and CUDA graphs are on for FLUX.3. With compile
available, `auto` resolves to int8 on sm80 and newer, as it does for the
other families.
- Single file and GGUF denoisers assemble around the base repo's
components, and the block graph follows the loaded denoiser. A GGUF
FLUX.3 reports `uint8` as `transformer.dtype`, because its only float
tensors are the fp32-kept QK norms. The denoise block casts the latents
to that dtype, so the loader pins the compute dtype with an empty
buffer. A checkpoint or pipeline of the other variant (distilled vs
base) is refused with the family to use, before the loaded model is
unloaded. BFL's own `variants/fp8r` and `variants/nvfp4` packages are
refused too: the nvfp4 one has the same tensor sizes as a ComfyUI NVFP4
file with a different scale layout, so it would decode silently wrong.
- Memory: measured activation rows for the distilled checkpoint. Each
reference is charged at 0.11 of the flat 8192 MiB per megapixel (about
900 MiB per 1 MP reference, the int8 / fp8 figure; bf16 measures about
700). Before this, FLUX.3 references were not charged at all. The
placement planner uses those rows too (denoise 1081, decode 2924 MiB at
bf16 instead of the flat 8192 headroom), so a 32 GiB card keeps a bf16
FLUX.3 resident instead of streaming the text encoder: measured peaks
21.2 GiB text to image and 21.9 GiB with 4 references, 5.7 s vs 7.0 s
per 1024x1024 text to image. 20 and 24 GiB still pick group offload. The
base checkpoint keeps the flat estimate (no measured rows).
- Auto-switch: a FLUX.3 cache snapshot downloaded around the hosted
INT8/FP8 file (no dense transformer shards) counts as downloaded when
that file is cached, and the switch loads it by repo id from the cache.
- LoRAs: the pipeline face uses the pinned build's
`Flux3LoraLoaderMixin` (transformer only). Checked on real weights with
a random rank-4 adapter on 215 attention and feed-forward Linears: bf16
eager, the adapter changes the render, weight 0 and removing it give the
base render bit for bit; int8 with the adapter baked in at load changes
the render too.
- Hosted artifacts: `flux.3-klein` lists
`unsloth/flux-3-image-klein-FP8` for int8 and fp8 (files
`flux-3-image-klein-INT8.safetensors` / `-FP8.safetensors`), the same
layout as `flux.2-klein`. `flux.3-klein-base` lists
`unsloth/flux-3-image-klein-base-FP8` the same way: its INT8 file is
BFL's own ComfyUI `int8_convrot` denoiser from
black-forest-labs/flux-3-image-klein-comfy, mirrored byte for byte and
read through the ComfyUI quant loader, and its FP8 file is ours. Checked
on real weights through the API load (50 steps, CFG 3.5, 2 prompts): the
seeded INT8 renders match BFL's single file loaded directly (LPIPS
0.0001 and 0.004), the seeded FP8 renders match our FP8 file loaded by
path (LPIPS 0.0001 and 0.0000). Without access to the repo the load logs
`prequant_repo_unavailable` and quantises on the fly.
- Ungated base files: `black-forest-labs/flux-3-image-klein` and `-base`
are gated, so a GGUF or FP8 pick (which carries only the denoiser)
fetched its text encoder and VAE through the gate. The upstream
Diffusers folders Studio loads (`modular_model_index.json`,
`text_encoder/`, `transformer/`, `vae/`) are now copied byte for byte
(21 files each, sha256 checked against the pinned upstream revision)
into `unsloth/flux-3-image-klein-FP8` and
`unsloth/flux-3-image-klein-base-FP8` beside the pre-quantized files,
and both are in the gated mirror table, so no separate mirror repo is
needed. Companion listings and the manifest check read those repos too,
so no request reaches the gated upstream. The picker and status still
show the BFL id.
- Hosted text encoders: both FP8 repos also carry the Qwen3-VL-4B text
encoder, the same bytes in each (the distilled and base `text_encoder/`
shards are identical), as
`flux-3-image-klein[-base]-text_encoder-INT8-ConvRot.safetensors` (5.25
GB, int8 ConvRot weight-only, built by
`scripts/build_te_int8_convrot_checkpoint.py`) and
`...-text_encoder-FP8.safetensors` (5.61 GB on disk, 4.83 GB resident:
layerwise fp8, bit-identical to the runtime cast), the same pattern as
Qwen-Image-2.1. An int8 request that cannot load the int8 file falls
back to the fp8 file, then to the dense encoder with the runtime fp8
cast, as on Qwen-Image-2.1. Unset requests get the int8 ConvRot encoder
(`te_quant_auto = "int8"`), as BFL's ComfyUI templates load
`qwen3vl_4b_int8_convrot` (4 of 5 workflows in
black-forest-labs/flux-3-image-klein-comfy); `none` still pins the bf16
encoder. Checked through Studio's load path: an unset request loads the
int8 file on both families and renders pixel-identical to the explicit
int8 render. Real weights, bf16 DiT, 19 cases against the bf16 encoder
on one B200 (A/A repeat bit-identical): int8 LPIPS-alex 0.017 mean /
0.098 max, SSIM 0.974 at 16 steps; 0.023 / 0.121, SSIM 0.973 for the
base at 25 steps, CFG 3.5. fp8 scores 0.038 / 0.220 and 0.035 / 0.132.
The encoder goes from 8.27 to 4.89 GiB resident and from 18.4 to 1.8 s
to load (warm page cache). Peak memory drops from 21.2 to 17.8 GiB. INT8
DiT with the int8 encoder: 0.033 / 0.134 (distilled) and 0.034 / 0.142
(base), 13.9 GiB peak. BFL's `qwen3vl_4b_int8_convrot` has the same int8
projections byte for byte but also quantises `embed_tokens`, which
Studio's loader cannot read. Repacked with that table dequantised, it
scored 0.018 / 0.150 and 0.027 / 0.113, a tie within per-image spread,
so the hosted file is our own build. Planner estimate for the encoder:
8487 MiB for bf16, 5517 MiB for fp8 or int8. Studio's own load of the
mirrored files (int8 distilled and base, fp8 distilled) renders bit for
bit what the harness renders.
- A GGUF pick with a transformer quant builds through `_assemble_pipe`,
which called `from_pretrained` on the modular pipeline class and
returned a pipeline with no transformer. It now assembles FLUX.3 through
the same per-component loader.
- Base checkpoint, checked on real weights (19 cases, 50 steps, CFG
3.5): Studio vs the BFL reference code LPIPS 0.038 mean, the diffusers
harness 0.037 against the same reference; 25 steps, text to image,
single and multi-reference edits and negative prompts render; every
distilled / base mismatch is refused before loading and in the loader.
Fixes this needed: a base single file on distilled companions takes
`flow_map` from the family instead of the companion config (it crashed
on meta tensors); BFL's own ComfyUI `int8_convrot` and `nvfp4` files
(black-forest-labs/flux-3-image-klein-comfy) now go to the ComfyUI quant
loader instead of being refused as `variants/` packages, with the fused
q/k/v split for FLUX.3; and the family, not the file name, picks the
base vs distilled recipe (BFL's base ComfyUI files have no "base" in
their names, so they were seeded 16 steps / CFG 1).
- Diffusers pin: `diffusers-main.txt` is re-enabled at Diffusers main
d42ebf9c (archive digest included), which carries the FLUX.3 merge
(huggingface/diffusers#15046) and the prompt upsampler block
(huggingface/diffusers#15058), the same route Qwen-Image-2.1 used before
0.41.0. Studio never loads the upsampler, so `flux3_blocks()` drops that
step from both FLUX.3 block graphs; a render at d42ebf9c matches the
merge commit pixel for pixel. A host that cannot fetch the pin keeps
0.41.0 and only FLUX.3 is refused. The pin tests check release mode on a
commented-out copy, so they pass with the pin on or off.
- Diffusers main now requires Python 3.11 (huggingface/diffusers#15030),
so the installer and the startup repair skip the pinned build below 3.11
instead of cloning something that cannot install. Python 3.10 keeps
0.41.0 and gets a FLUX.3 refusal that names the Python requirement; 3.9
keeps 0.36.0 as before.

## Results

Real weights, distilled, 16 steps, B200 shared lease. The 11 cases are
the BFL example prompts and seeds (8 text to image at 832x1248 to
1248x832, 3 edits with 1 or 2 references), scored with LPIPS (alex) and
SSIM.

| Comparison | LPIPS mean | LPIPS max | SSIM mean | Identical images |
|---|---|---|---|---|
| Studio vs Diffusers ModularPipeline given the same noise and
references | 0.0073 | 0.080 (edit_figurine) | 0.986 | 10 of 11 bitwise |
| Studio vs the BFL reference implementation | 0.0115 | 0.083
(edit_figurine) | 0.979 | 0 |
| Diffusers vs the BFL reference (same inputs, for scale) | 0.0046 |
0.026 | 0.991 | 0 |

Text to image cases against the BFL reference: LPIPS 0.0008 to 0.0018,
except riso_lido at 0.026 (the most chaotic prompt, which also moves
between two runs of the reference on different hosts). The one edit that
differs is edit_figurine: its 896x1120 input is just under 1 megapixel,
so the ComfyUI sizing scales it up to 912x1136 where the reference keeps
it as is. Same composition, different detail (LPIPS 0.08).

Tiny random models, distilled and base, text to image and edit,
resident, model offload and group offload: bitwise identical to the
Diffusers pipeline given the same inputs.

Speed features, the same 11 cases, against Studio bf16 eager:

| Arm | Quantised Linears | LPIPS mean | LPIPS max | SSIM mean |
|---|---|---|---|---|
| bf16, regional compile + CUDA graphs | 0 | 0.004 | 0.027 | 0.992 |
| auto (resolves to int8, compiled) | 228 | 0.024 | 0.056 | 0.958 |
| fp8, compiled | 228 | 0.039 | 0.215 (riso_lido) | 0.946 |
| mxfp8, compiled | 228 | 0.042 | 0.185 (riso_lido) | 0.939 |

Every case except riso_lido is at or below 0.048 for all three schemes.
For scale, compiled vs eager bf16 measured 0.0035 in the separate
quantisation survey.

Dependency check for the pinned build (`uv pip compile`, the installer's
requirement line):

| Python | Stack | Result |
|---|---|---|
| 3.11, 3.12, 3.13 | Studio pins: transformers 5.5.0, huggingface-hub
>=1.32,<2 | resolves; huggingface-hub 1.33.0, transformers 5.5.0 and
tokenizers 0.22.2 unchanged |
| 3.10 | same | no solution (the build needs Python 3.11), so the
installer skips it and keeps 0.41.0 |
| 3.11 | transformers 4.57.6 | no solution (it needs huggingface-hub
<1.0); the released 0.41.0 conflicts the same way, and Studio pins
4.57.6 only on Python 3.9, where it installs Diffusers 0.36.0 |

A dry run of the installer's install against an existing Python 3.12
stack swaps only the diffusers package.

Edits at 1024x1024, 16 steps, with 1 and 4 references (B200, 160 GB
free):

| Load | Placement | Peak allocated, t2i / 1 ref / 4 refs |
|---|---|---|
| bf16 | resident, no offload | 21.2 / 21.2 / 21.9 GiB |
| auto (int8) | resident, no offload | 16.8 / 15.9 / 18.0 GiB |
| auto (int8), capped at 16 GB | group offload, int8 denoiser and
encoders streamed | 3.2 / 3.6 / 5.7 GiB |

The 16 GB cap is simulated: the allocator fraction and `mem_get_info`
are both capped at 16 GiB. All three loads render all three requests.

GGUF and pre-quantised files, real weights, loaded through Studio's load
API (`begin_load`) from local copies of the hosted files. The same 11
cases, 16 steps, against Studio bf16 eager:

| Artifact | Quantised Linears | LPIPS mean | LPIPS max | SSIM mean |
Builder's gate (19 cases) |
|---|---|---|---|---|---|
| GGUF Q8_0 | 273 (GGUF) | 0.010 | 0.035 (edit_figurine) | 0.983 | 0.006
|
| GGUF UD-Q4_K_XL | 273 (GGUF) | 0.038 | 0.139 (riso_lido) | 0.947 |
0.038 |
| INT8 safetensors (seeded) | 228 | 0.024 | 0.056 (riso_lido) | 0.958 |
0.035 |
| FP8 safetensors (seeded) | 228 | 0.039 | 0.215 (riso_lido) | 0.946 |
0.036 |

A 3-case spot check (fox, man_dog, edit_coffee) also ran Q4_K_M: LPIPS
0.033 mean against 0.021 for UD-Q4_K_XL.

- All 273 Linears of the GGUF denoisers stay GGUF weights; the 1-D QK
norms are dequantized like other GGUF families. The real GGUF files hit
the `uint8` compute dtype problem described above, and render with the
pin.
- The INT8 and FP8 files quantise exactly the 228 Linears that
on-the-fly quantisation picks under the keep-list, and the seeded
renders are pixel identical to the on-the-fly int8 and fp8 renders.
- A GGUF pick that asks for int8 seeds the INT8 file and renders pixel
identical to the pipeline pick. Before the `_assemble_pipe` change the
same load came back with no transformer.
- CUDA graph capture fails on the K-quant GGUFs (a host-to-device copy
inside the dequant). The load logs the reason as a warning and runs
eager; every render completed and no error reaches the request.

Tiny Q8_0 GGUFs with BFL tensor names load for both variants and render
text to image and edit. Pairing the base GGUF with the distilled family
is refused.

Timing at 1024x1024, resident bf16, no compile: 1.3 to 2.5 s per text to
image, 2.8 to 3.9 s per edit.

## Tests

- `studio/backend/tests/test_diffusion_flux3.py`: 84 CPU tests covering
the download plan scope and variant verdict, the prompt upsampler
entries in the current official manifests, the modular manifest in the
gated-base probe, shard restore and staging, the pre-eviction variant
check (single files and pipelines), the local folder validation, the
FLUX.3 LoRA loader, per-image generation chunks, per-image negative
prompts, a local modular base for GGUF loads, the refusal of separate
encoder / VAE files, the hosted artifact rows and filenames, the
operator mirror, the quantized GGUF pick assembly, the nvfp4 deny, the
contiguous-input guard, the per-scheme keep-list (and that other
families are unchanged), the variant and BFL-package refusals, the GGUF
compute dtype pin, memory rows, detection, the `-pup` exclusion, modular
index detection, defaults, size table, the unreleased gate and
module-file probe, the modular download plan, plan sizing, reference
sizing, the reference noise layout, and the pipeline face (prompt and
generator splitting, step callback, interrupt).
- Frontend `model-catalog.check.ts`: passes with the new GGUF row.
- Family data freeze and autoopt parity fixtures updated for the two new
families (new rows only).
- Diffusion, image, inventory, quantisation and Diffusers-pin suites
(10,470 passed): no new failing test IDs against the base commit.
- Whole backend suite after the hosted-artifact commit: 74,977 passed,
and the failing test IDs (all present before it, environment related)
are the same set as on the previous commit.
- `tests/sh/test_clean_machine_notools_pinned_git.sh`: 44 passed.

## Not covered

- A dense bf16 cold load (`transformer_quant=none`). Cold anonymous
loads (fresh cache, no token) of a GGUF UD-Q4_K_XL pick, a base FP8 pick
and a bf16 pipeline pick (which auto-selects int8) each requested only
`unsloth/flux-3-image-klein[-base]-FP8` / `-GGUF` and rendered at
1024x1024; the GGUF pick downloads 9.53 GB with the int8 encoder.

- fp8r and nvfp4 variant files: they use Diffusers' new BFL quantizer
format, which no Studio prequant path loads yet.
- Upstream: Diffusers reports `uint8` as the dtype of a GGUF FLUX.3
transformer, and a plain ModularPipeline fails the same way. Studio pins
the compute dtype; the upstream fix is still to be filed.
- A script that exits while Studio's deferred background compile is
still running dies at interpreter shutdown (exit 245 or 250). This
reproduced on a bf16 FLUX.3 run after all renders had finished, and the
same way on FLUX.2 klein 4B (bf16, 3 renders, exit 245), so it is not in
the FLUX.3 code. Calling `unload()` first exits cleanly. The background
compile is shared by every family and is not changed here. I did not
check whether a Studio server shut down mid-compile hits the same thing.
- Follow-up: a smoke probe that also feeds a stride-0 activation, so a
scheme missing the contiguous guard fails the probe instead of the first
render. Left out of this PR.
- MLX, Windows and AMD paths for FLUX.3 were not run. The native sd.cpp
route is #14144, stacked on this PR.
- Hosted repos (public, ungated): `unsloth/flux-3-image-klein-FP8`,
`unsloth/flux-3-image-klein-base-FP8`, `unsloth/flux-3-image-klein-GGUF`
and `unsloth/flux-3-image-klein-base-GGUF`. The two GGUF repos are
picker rows (shown under their repo names); `model-catalog.check.ts
--network` checks every declared file against the Hub. If a FP8 repo
cannot be reached, an int8 or auto load logs `prequant_repo_unavailable`
and quantises on the fly (checked on real weights, same output as the
file).
- GGUF CUDA graphs on K-quants (falls back to eager).
- nvfp4 quality (excluded from `auto`) and step caching.
- Python 3.10 hosts cannot run FLUX.3 until a Diffusers release carries
it (the refusal says so).
- Timings: the runs above were on shared GPUs, so they are accuracy
checks, not benchmarks.
- A real 16 GB card (the cap was simulated in-process).
- Measured memory rows for the base checkpoint (it uses the flat
estimate).
- Trained FLUX.3 LoRAs (and their key formats); only a random adapter
was tried.
- Separate text encoder / VAE files beside a FLUX.3 GGUF or single file:
refused with a clear message for now (the supplied-component loader
reads a model_index.json, which FLUX.3 does not ship).
- Prompt upsampler integration: #14137, stacked on this PR.
- ComfyUI seeds do not reproduce (ComfyUI draws its own noise layout);
BFL seeds do.
- The single reference path differs from the BFL reference by design (1
megapixel resize instead of pad to 4 megapixels), following ComfyUI.
- Text encoder length cap: 8192 tokens (Diffusers) against 4096 in the
BFL reference; only prompts past 4096 tokens differ.

## Another GPU generation

On a Colab RTX PRO 6000 Blackwell (sm120, torch 2.14.1+cu130), Studio's
own `DiffusionBackend` from this branch loaded
`unsloth/flux-3-image-klein-FP8` with every setting left at its default
and rendered 1024x1024 images. It picked an int8 transformer (int8 GEMM
engaged), the hosted int8 ConvRot text encoder, compile with CUDA graphs
and no offload, and the load took 84 s. The merge base does not detect
the repo as FLUX.3 at all.

## UI

Images model picker searched for "FLUX", before (merge base 7382805)
and after (42e4b8a). After this PR, `flux-3-image-klein-GGUF (Slow)`
(5.5B) and `flux-3-image-klein-base-GGUF (Slow)` are curated rows in the
FLUX group, right after FLUX.2-klein-9B. Before, both only show up as
bare, unsized Hub search hits further down.

![Images model picker before and after: the two FLUX.3 GGUF
rows](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/13933/pr13933_picker_q-961628a88152.png)

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation modular-pipelines size/L PR with diff > 200 LOC tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants