Keep the sd.cpp text encoder on CPU under Metal

macos-14 loads FLUX.2-klein-4B Q2_K natively on mps and then dies on the first
generation with exit code -6:

    ggml_metal_op_encode_impl: error: unsupported op 'RMS_NORM' -> ggml_abort
    LLMEmbedder::encode_prompt -> LLMRunner::compute -> GGMLRunner::compute

ggml's Metal backend gates RMS_NORM on contiguous rows and aborts the process
when that does not hold, with no per-op CPU fallback, so any LLM text encoder
(Qwen3 for FLUX.2 and Z-Image, T5 for FLUX.1) takes sd-server down. The encoder
runs once per prompt while the DiT runs every step, so pinning only the encoder
keeps Metal for the part that matters. UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU=1
opts back in once ggml grows the kernel.
This commit is contained in:
Daniel Han 2026-07-27 06:58:36 +00:00
commit ca5ae684ad
2 changed files with 77 additions and 1 deletions

View file

@ -120,6 +120,34 @@ def native_speed_flags(speed_mode: Optional[str]) -> list[str]:
raise ValueError(f"native speed_mode must be one of {NATIVE_SPEED_MODES}, got '{speed_mode}'")
# Kill switch for the Metal text-encoder placement below (1/true keeps the encoder on Metal).
_METAL_TE_ON_GPU_ENV = "UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU"
def metal_text_encoder_flags() -> list[str]:
"""Keep the TEXT ENCODER on CPU when sd.cpp runs on Apple Metal, else nothing.
ggml's Metal backend gates RMS_NORM on contiguous rows and calls ``GGML_ABORT`` when that does
not hold, with no per-op CPU fallback, so an LLM text encoder (Qwen3 for FLUX.2 / Z-Image, T5
for FLUX.1) takes the whole sd-server process down mid-generation:
ggml_metal_op_encode_impl: error: unsupported op 'RMS_NORM' -> ggml_abort
LLMEmbedder::encode_prompt -> LLMRunner::compute -> GGMLRunner::compute
Observed on macos-14 arm64 with FLUX.2-klein-4B Q2_K: the model loads on ``mps`` and the first
generation dies with exit code -6. The encoder runs once per prompt while the DiT runs every
step, so pinning only the encoder keeps Metal for the part that matters. Set
``UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU=1`` to opt back in once ggml grows the kernel."""
import os
import sys
if sys.platform != "darwin":
return []
if os.environ.get(_METAL_TE_ON_GPU_ENV, "").strip().lower() in ("1", "true", "yes", "on"):
return []
return ["--clip-on-cpu"]
def offload_flags(
policy: str,
*,
@ -239,8 +267,10 @@ def build_sd_cpp_command(
cmd += ["--output", output_path]
if threads is not None:
cmd += ["--threads", str(int(threads))]
offload = list(offload or [])
if offload:
cmd += list(offload)
cmd += offload
cmd += [f for f in metal_text_encoder_flags() if f not in offload]
if verbose:
cmd += ["-v"]
if extra_args:
@ -344,6 +374,7 @@ def build_sd_cpp_server_command(
cmd += offload
# De-dup speed flags against offload (may already include --diffusion-fa).
cmd += [f for f in native_speed_flags(native_speed) if f not in offload]
cmd += [f for f in metal_text_encoder_flags() if f not in offload]
if verbose:
cmd += ["-v"]
if extra_args:

View file

@ -25,6 +25,7 @@ from core.inference.sd_cpp_args import (
build_sd_cpp_command,
build_sd_cpp_server_command,
build_sd_cpp_upscale_command,
metal_text_encoder_flags,
native_speed_flags,
offload_flags,
text_encoder_flags_for_family,
@ -47,6 +48,50 @@ def test_te_flags_by_family():
assert text_encoder_flags_for_family("unknown") == ()
# ── Metal text-encoder placement ────────────────────────────────────────────
def test_metal_keeps_the_text_encoder_off_the_gpu(monkeypatch):
# ggml's Metal backend aborts the process on RMS_NORM with non-contiguous rows and has no
# per-op CPU fallback, so an LLM text encoder killed sd-server mid-generation on macOS
# (observed on macos-14 with FLUX.2-klein-4B Q2_K: loads on mps, first generation exits -6).
monkeypatch.delenv("UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU", raising = False)
monkeypatch.setattr("sys.platform", "darwin")
assert metal_text_encoder_flags() == ["--clip-on-cpu"]
for other in ("linux", "win32"):
monkeypatch.setattr("sys.platform", other)
assert metal_text_encoder_flags() == []
# Opt back in once ggml grows the kernel.
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setenv("UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU", "1")
assert metal_text_encoder_flags() == []
def test_metal_text_encoder_flag_reaches_both_command_builders(monkeypatch):
monkeypatch.delenv("UNSLOTH_DIFFUSION_SD_CPP_METAL_TE_GPU", raising = False)
monkeypatch.setattr("sys.platform", "darwin")
files = SdCppModelFiles(diffusion_model = "/m/x.gguf")
server = build_sd_cpp_server_command(
binary = "sd-server", files = files, host = "127.0.0.1", port = 1234
)
cli = build_sd_cpp_command(
binary = "sd-cli", files = files, params = SdCppGenParams(prompt = "x"),
output_path = "/o/x.png",
)
assert server.count("--clip-on-cpu") == 1
assert cli.count("--clip-on-cpu") == 1
# An offload policy that already pins the encoder must not emit it twice.
dual = build_sd_cpp_server_command(
binary = "sd-server", files = files, host = "127.0.0.1", port = 1234,
offload = offload_flags("model"),
)
assert dual.count("--clip-on-cpu") == 1
monkeypatch.setattr("sys.platform", "linux")
assert "--clip-on-cpu" not in build_sd_cpp_server_command(
binary = "sd-server", files = files, host = "127.0.0.1", port = 1234
)
# ── offload policy -> sd-cli flags ──────────────────────────────────────────