fix(studio): img2img/inpaint on GGUF image models — from_pipe fallback with no recast (#9186) by yzxcj797 · Pull Request #9193 · unslothai/unsloth · GitHub
Skip to content

fix(studio): img2img/inpaint on GGUF image models — from_pipe fallback with no recast (#9186) - #9193

Merged
oobabooga merged 2 commits into
unslothai:mainfrom
yzxcj797:fix/gguf-img2img-from-pipe-recast-9186
Aug 19, 2026
Merged

fix(studio): img2img/inpaint on GGUF image models — from_pipe fallback with no recast (#9186)#9193
oobabooga merged 2 commits into
unslothai:mainfrom
yzxcj797:fix/gguf-img2img-from-pipe-recast-9186

Conversation

@yzxcj797

@yzxcj797 yzxcj797 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes #9186
Closes #9241

The bug

from_pipe hands the new pipeline the resident pipeline's own module objects, then casts them. It resolves its dtype argument to float32 whenever the caller names none — and on the pinned Diffusers revision an explicit torch_dtype = None no longer counts as naming one, which is exactly what both call sites passed, under comments calling it load-bearing.

So the cast lands on the loaded model rather than on a copy of it, and it fails in two different ways:

  • Quantized denoiserModelMixin.to() refuses any dtype once is_quantized is set, so image-conditioned workflows die with Casting a quantized model to a new dtype is unsupported. This is the reported crash. Txt2img never calls from_pipe, which is why only photo workflows broke.
  • Nothing quantized — no error at all. Every component is silently upcast to float32 in place, including the resident text-to-image pipeline's, since the objects are shared. A 2x memory and speed regression that persists for the rest of the session.

Both reach ControlNet too, not just img2img/inpaint/upscale: _controlnet_pipe made the same call.

The fix

Build the workflow and ControlNet pipelines through a class whose .to() drops the dtype and keeps the device. from_pipe still performs its own assembly — optional components, type-hint filtering, config re-registration — and its terminal cast becomes a no-op. No dtype argument is passed at all.

Two properties this buys over suppressing the cast per call site:

  • It is version-independent. Diffusers 0.39.0 also recasts when no dtype is named — its torch_dtype default is torch.float32, not None — so the suppression is load-bearing on the released Diffusers as well as the pin. And if huggingface/diffusers#12762 or a successor ever lands, from_pipe stops casting and the override simply never fires. Nothing needs to change here either way; the test suite pins both cases.
  • No error-message matching, so upstream rewording cannot silently disable it.

Why not catch the error instead

An earlier revision of this PR kept from_pipe(torch_dtype = None) as a fast path and rebuilt from pipe.components when the quantized-cast error fired. That is not sufficient, and measurement against a real GGUF Qwen-Image load is what showed it: DiffusionPipeline.to() walks components in sorted() order — scheduler, text_encoder, tokenizer, transformer, vae — so the Qwen2.5-VL text encoder is already float32 by the time the transformer refuses. Catching the error does not undo it, and the module is shared, so txt2img afterwards runs a 33 GB fp32 encoder. It also left the unquantized silent-upcast case entirely unaddressed, since nothing raises there.

Validation

Reproduced and fixed end to end against the pinned Diffusers revision in an isolated environment, using a real unsloth/Qwen-Image-2512-GGUF Q4_K_M transformer with its companion VAE, text encoder, tokenizer and scheduler. The helper is lifted out of diffusion.py by AST at run time rather than retyped, so the validation exercises the source that ships.

Checked on both Diffusers 0.39.0 and the pinned 0.40.0.dev0:

  • img2img and inpaint pipelines build off the resident GGUF pipeline
  • every resident component keeps its loaded dtype, and the modules are the same objects
  • an explicit to(dtype = torch.float32) on a workflow pipeline is a no-op; a device move still works
  • a 2-step img2img produces an image

Test-side: every added test was mutation-checked. Ten mutations of the production change — including bypassing the no-recast class at each call site, and removing each individual line of to() — each fail a test that names the behaviour. test_from_pipe_no_recast_leaves_every_component_at_its_loaded_dtype is parametrized over quantized/unquantized and over a from_pipe that recasts versus one that has stopped.

pytest studio/backend/tests/test_diffusion_backend.py studio/backend/tests/test_diffusion_controlnet.py \
       studio/backend/tests/test_diffusion_img2img_dtype.py studio/backend/tests/test_diffusion_transformer_quant.py \
       studio/backend/tests/test_diffusion_precision.py studio/backend/tests/test_diffusion_cache.py \
       studio/backend/tests/test_diffusion_memory.py
582 passed, 2 skipped

Note on the tests

The standalone test_diffusion_from_pipe_no_recast.py from the first revision is gone; its coverage lives in test_diffusion_backend.py, which already owns _workflow_pipe, and in test_diffusion_controlnet.py for the ControlNet path. One pre-existing assertion there changed: it asserted that Studio passes torch_dtype = None to from_pipe, which is the belief that caused this bug. It now asserts that the terminal cast never reaches the components.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e704b33c6c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread .github/scripts/boot-studio-api-only.sh Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the repair opt-in for the MLX capability smoke

Do not force UNSLOTH_DISABLE_MLX_AUTOREPAIR=1 for every caller of this shared boot script. The no-mlx-repair scenario in tests/studio/mac_capability_verdict_smoke.py explicitly removes this variable and waits for the repair installer to start, and .github/workflows/mlx-ci.yml invokes that scenario through this script. This assignment reintroduces the variable in the server process, so the installer is never invoked and the scenario waits up to 600 seconds before failing.

Useful? React with 👍 / 👎.

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.

Fixed and pushed (6ac313f incl. pre-commit formatting): quantized residents are now detected BEFORE the fast path (quantization_config probe on every component) and go straight to the manual re-wire — from_pipe's internal .to(dtype) recasts components in place before reaching the quantized transformer and raising, so skipping it entirely is the only way to keep the resident txt2img pipeline unpolluted. New AST test proves the mutating path is never entered when a quantized component is resident.

The env-scoping P1 from the other thread is already fixed on #9191 (the boot script is back to neutral; the opt-out lives in the smoke job's env).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ac313f5d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

components manually: same resident modules, same references, no cast.
"""
try:
return pipe_cls.from_pipe(pipe, torch_dtype = None, **extra)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid mutating resident modules before falling back

When the bundled from_pipe reaches the quantized transformer after visiting another shared component, those earlier components have already been recast to float32 before the exception is raised. The fallback then reuses these mutated references rather than restoring their original dtypes, so GGUF Qwen img2img/inpaint can still incur a large memory increase or fail from mixed dtypes, and the resident txt2img pipeline is modified as well. Detect the quantized path before calling the mutating fast path, or restore every component's dtype before constructing the fallback.

Useful? React with 👍 / 👎.

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.

Fixed and pushed (6ac313f incl. pre-commit formatting): quantized residents are now detected BEFORE the fast path (quantization_config probe on every component) and go straight to the manual re-wire — from_pipe's internal .to(dtype) recasts components in place before reaching the quantized transformer and raising, so skipping it entirely is the only way to keep the resident txt2img pipeline unpolluted. New AST test proves the mutating path is never entered when a quantized component is resident.

The env-scoping P1 from the other thread is already fixed on #9191 (the boot script is back to neutral; the opt-out lives in the smoke job's env).

if (!prompt) {
return false;
}
return /{{\s*\$now\s*}}|{{\s*\$time\s*}}/i.test(prompt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match the detector to the resolver's case sensitivity

The /i flag makes prompts such as {{$NOW}} and {{$TIME}} trigger the warning, but resolveSystemPromptVariables performs a case-sensitive lookup against only $now and $time; uppercase names are therefore left unchanged (or can resolve as custom variables) and do not invalidate the cache each request. This produces a false performance warning for those inputs, including the uppercase case asserted by the new test.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member

Confirmed the call in studio/backend/core/inference/diffusion.py still passes torch_dtype=None into from_pipe, so the no-recast fallback is aimed at the right place. The branch also carries the mlx_repair import retry, the prompt time variable UI warning and the CI timeout changes, so could you narrow it to the diffusion fix and its test and split the rest out?

@danielhanchen

Copy link
Copy Markdown
Member

@Lyxot could you take a look at this one?

yzxcj797 and others added 2 commits August 19, 2026 22:05
…k with no recast (unslothai#9186)

Loading a GGUF-quantized image model and generating with an input photo
failed with 'Casting a quantized model to a new dtype is unsupported':
the app already passes torch_dtype=None to from_pipe intending 'reuse
resident modules, no recast', but the bundled diffusers resolves None to
float32 inside from_pipe and calls .to(dtype) on every component, which
hard-crashes the GGUF-quantized transformer. Txt2img never calls from_pipe,
which is why only photo workflows broke.

_from_pipe_no_recast keeps from_pipe as the fast path and, when the
quantized-cast error fires, re-wires the resident components directly
(same module references, same quantized state, no cast) — matching what
from_pipe does minus the dtype step. Strict constructor signatures get
exactly the parameters they declare; **kwargs-style pipelines accept the
full component set. Both the workflow pipe and the ControlNet pipe route
through it.

Four duck-typed tests (AST-extracted helper, the repo's established
no-torch convention): the fast path still calls from_pipe with
torch_dtype=None; the quantized crash falls back and preserves module
identity; unrelated errors still raise; extra components (controlnet)
forward through the fallback.
…lothai#9186)

from_pipe hands the workflow pipeline the resident pipeline's own module objects and then casts them, so the cast rewrites the loaded model rather than a copy of it. It resolves its dtype argument to float32 whenever the caller names none, and an explicit torch_dtype=None stopped counting as naming one, so the argument both call sites passed to prevent the cast no longer prevents anything.

On a quantized denoiser that raises "Casting a quantized model to a new `dtype` is unsupported", which is why img2img, inpaint, upscale and ControlNet failed on GGUF image models while txt2img, which never calls from_pipe, kept working. With nothing quantized it does not raise at all: every component is silently upcast to float32, including the resident text-to-image pipeline's, since the objects are shared.

Catching the error is not enough. Components are cast in name order, so on Qwen-Image the text encoder is already float32 by the time the transformer refuses, and the resident pipeline is left holding it.

Build the workflow and ControlNet pipelines through a class whose .to() drops the dtype and keeps the device instead. from_pipe still does its own assembly, its final cast becomes a no-op, and no dtype argument is passed, which behaves the same on the released diffusers and on the pinned revision.
@Lyxot
Lyxot force-pushed the fix/gguf-img2img-from-pipe-recast-9186 branch from 6ac313f to c3e223c Compare August 19, 2026 14:10
@Lyxot

Lyxot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Force-pushed a rebase onto current main. Two changes: the branch is scoped down to this fix, and the fix itself is reworked.

Dropped six unrelated commits that were riding along on this branch — the MLX gate race (#9120), the prompt time-variable warning (#9177), and three CI self-heal fixes (#9183 and two follow-ups). The PR is now 2 commits over 3 files, and no longer conflicting. The original fix commit is kept as-authored.

Reworked the fix. The fast-path-then-catch approach turns out to be insufficient, and it took a real GGUF load to see why. DiffusionPipeline.to() walks components in sorted() order, so on Qwen-Image the sequence is scheduler, text_encoder, tokenizer, transformer, vae — the Qwen2.5-VL text encoder is already cast to float32 before the transformer raises. Catching the error does not undo that, and because from_pipe reuses module objects rather than copying them, the resident text-to-image pipeline is left holding a 33 GB fp32 encoder. Measured, not inferred:

before: text_encoder=torch.bfloat16  vae=torch.bfloat16  transformer_quantized=True
caught the quantized-cast error
after : text_encoder=torch.float32   vae=torch.bfloat16  transformer_quantized=True

There is also a second failure the catch could never have reached: when nothing is quantized, from_pipe raises nothing at all and silently upcasts every component — including the resident pipeline's. That is the majority path, and it presents as "the app got slower", not as an error.

So the recast is now prevented rather than caught: the workflow and ControlNet pipelines are built through a class whose .to() drops the dtype and keeps the device, and no dtype argument is passed at all. from_pipe still does its own assembly and its terminal cast becomes a no-op.

One thing worth flagging for reviewers, because it changes how this reads: Diffusers 0.39.0 recasts here too. Its torch_dtype default is torch.float32 rather than None, so omitting the argument recasts on the released Diffusers exactly as it does on the pin — the suppression is load-bearing on both, not a workaround for one bad revision. Conversely, if huggingface/diffusers#12762 or a successor lands and from_pipe stops casting, the override simply never fires. Both directions are pinned by test parametrization rather than by a runtime version branch.

Validated on both revisions against a real unsloth/Qwen-Image-2512-GGUF Q4_K_M load with its companions: img2img and inpaint build, every resident component keeps its dtype, the modules are the same objects, an explicit dtype cast on a workflow pipeline is a no-op, a device move still works, and generation produces an image. Every added test was mutation-checked — ten mutations, including bypassing the new class at each call site, each fail a test that names the behaviour. 582 passed, 2 skipped.

@Lyxot

Lyxot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

@oobabooga
oobabooga merged commit a341b96 into unslothai:main Aug 19, 2026
28 of 31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

4 participants