mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-07 15:10:50 +08:00
On Apple Silicon vram_state is VRAMState.SHARED (unified memory), but text_encoder_device() only returned the GPU for HIGH_VRAM/NORMAL_VRAM, so text encoders ran on the CPU. For LM-style encoders like ACE-Step 1.5 the text encode stage dominates generation time on Mac. This re-lands #12809, which was reverted in #13070 because it broke quantized text encoders on Mac: the MPS backend cannot cast float8 dtypes (pytorch/pytorch#132624), and the existing supports_cast() fallback in CLIP.__init__ only inspects declared model dtypes, which never reflect fp8 weights behind comfy_quant quantization metadata (QuantizedTensor reports the compute dtype, not the fp8 storage dtype). To keep quantized text encoders working, CLIP.__init__ now decides the device before any weights are allocated: it checks supports_cast() on the resolved dtype, and when the load device cannot cast fp8 it scans the incoming state dict for fp8 tensors or comfy_quant markers and keeps those text encoders on the offload device. Devices that can cast fp8 (cuda etc.) skip the scan entirely. The pre-existing shift-back path now also updates the current device so the load log stays accurate. Adds MPS unit tests: fp16 placement on the GPU, fp8 fallback via declared dtype, secondary dtype (dtype_llama style), state-dict fp8 weights, and comfy_quant markers, plus a canary that fails when a torch release adds fp8 casts on MPS so the fallback can be relaxed.
132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""Text encoder device placement on Apple Silicon (MPS).
|
|
|
|
MPS machines run in VRAMState.SHARED. Text encoders the device supports
|
|
(fp16/bf16/fp32) should load on the GPU, while fp8/quantized ones must
|
|
stay on the CPU because MPS cannot cast float8 dtypes.
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
import comfy.model_management as mm
|
|
import comfy.ops
|
|
import comfy.sd
|
|
|
|
mps_only = pytest.mark.skipif(
|
|
not (torch.backends.mps.is_available() and mm.is_device_mps(mm.get_torch_device())),
|
|
reason="requires an Apple Silicon MPS device",
|
|
)
|
|
|
|
FP8_DTYPES = [torch.float8_e4m3fn, torch.float8_e5m2]
|
|
|
|
# Big enough that text_encoder_initial_device() picks the load device.
|
|
LARGE_PARAM_COUNT = 2 * 1024 * 1024 * 1024
|
|
|
|
|
|
class DummyTokenizer:
|
|
def __init__(self, embedding_directory=None, tokenizer_data={}):
|
|
pass
|
|
|
|
|
|
class DummyTEModel(torch.nn.Module):
|
|
def __init__(self, device=None, dtype=None, model_options={}):
|
|
super().__init__()
|
|
operations = model_options.get("custom_operations", comfy.ops.manual_cast)
|
|
self.linear = operations.Linear(8, 8, dtype=dtype, device=device)
|
|
self.dtypes = set([dtype])
|
|
self.construct_device = torch.device(device) if device is not None else None
|
|
|
|
def load_sd(self, sd):
|
|
return ([], [])
|
|
|
|
def forward(self, x):
|
|
return self.linear(x)
|
|
|
|
|
|
def make_clip(dtype, state_dict=[], clip_class=DummyTEModel):
|
|
class Target:
|
|
params = {}
|
|
clip = clip_class
|
|
tokenizer = DummyTokenizer
|
|
|
|
return comfy.sd.CLIP(
|
|
target=Target(),
|
|
parameters=LARGE_PARAM_COUNT,
|
|
model_options={"dtype": dtype},
|
|
state_dict=state_dict,
|
|
disable_dynamic=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def unload_models():
|
|
yield
|
|
mm.unload_all_models()
|
|
|
|
|
|
@mps_only
|
|
class TestTextEncoderDeviceMPS:
|
|
def test_vram_state_is_shared(self):
|
|
assert mm.vram_state == mm.VRAMState.SHARED
|
|
|
|
def test_text_encoder_device_is_mps(self):
|
|
assert mm.is_device_mps(mm.text_encoder_device())
|
|
|
|
def test_fp16_clip_loads_on_mps(self):
|
|
clip = make_clip(torch.float16)
|
|
assert mm.is_device_mps(clip.patcher.load_device)
|
|
weight = clip.cond_stage_model.linear.weight
|
|
assert weight.device.type == "mps"
|
|
x = torch.ones((1, 8), dtype=torch.float16, device=weight.device)
|
|
assert clip.cond_stage_model(x).device.type == "mps"
|
|
|
|
@pytest.mark.parametrize("fp8_dtype", FP8_DTYPES)
|
|
def test_fp8_clip_falls_back_to_cpu(self, fp8_dtype):
|
|
clip = make_clip(fp8_dtype)
|
|
assert clip.patcher.load_device == clip.patcher.offload_device
|
|
assert clip.cond_stage_model.construct_device.type == "cpu"
|
|
weight = clip.cond_stage_model.linear.weight
|
|
assert weight.device.type == "cpu"
|
|
x = torch.ones((1, 8), dtype=torch.float16, device=weight.device)
|
|
assert clip.cond_stage_model(x).device.type == "cpu"
|
|
|
|
@pytest.mark.parametrize("fp8_dtype", FP8_DTYPES)
|
|
def test_fp8_state_dict_falls_back_to_cpu(self, fp8_dtype):
|
|
# fp8 weights in the state dict aren't reflected in the declared
|
|
# model dtypes, e.g. quantized checkpoints with fp16 norm layers.
|
|
sd = {"linear.weight": torch.zeros((8, 8), dtype=fp8_dtype)}
|
|
clip = make_clip(torch.float16, state_dict=[sd])
|
|
assert not mm.is_device_mps(clip.patcher.load_device)
|
|
assert clip.cond_stage_model.construct_device.type == "cpu"
|
|
|
|
def test_comfy_quant_state_dict_falls_back_to_cpu(self):
|
|
sd = {
|
|
"linear.weight": torch.zeros((8, 8), dtype=torch.uint8),
|
|
"linear.comfy_quant": torch.zeros(16, dtype=torch.uint8),
|
|
}
|
|
clip = make_clip(torch.float16, state_dict=[sd])
|
|
assert not mm.is_device_mps(clip.patcher.load_device)
|
|
assert clip.cond_stage_model.construct_device.type == "cpu"
|
|
|
|
@pytest.mark.parametrize("fp8_dtype", FP8_DTYPES)
|
|
def test_mixed_declared_dtypes_fall_back_to_cpu(self, fp8_dtype):
|
|
# A secondary declared dtype (e.g. dtype_llama) can be fp8 while the
|
|
# primary dtype is fp16.
|
|
class MixedDtypeTEModel(DummyTEModel):
|
|
def __init__(self, device=None, dtype=None, model_options={}):
|
|
super().__init__(device=device, dtype=dtype, model_options=model_options)
|
|
self.dtypes = set([dtype, fp8_dtype])
|
|
|
|
clip = make_clip(torch.float16, clip_class=MixedDtypeTEModel)
|
|
assert not mm.is_device_mps(clip.patcher.load_device)
|
|
assert clip.cond_stage_model.linear.weight.device.type == "cpu"
|
|
|
|
@pytest.mark.parametrize("fp8_dtype", FP8_DTYPES)
|
|
def test_fp8_cast_still_unsupported_on_mps(self, fp8_dtype):
|
|
# If a torch release adds fp8 casts on MPS, supports_cast() can be
|
|
# updated to let fp8 text encoders onto the GPU (pytorch#132624).
|
|
assert not mm.supports_cast(mm.get_torch_device(), fp8_dtype)
|
|
t = torch.zeros(4, dtype=fp8_dtype, device="mps")
|
|
with pytest.raises((RuntimeError, TypeError)):
|
|
t.to(torch.float16)
|