chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:06 +08:00
commit cddb07a176
3370 changed files with 685519 additions and 0 deletions
@@ -0,0 +1,143 @@
import torch
from invokeai.backend.model_manager.load.model_cache.cached_model.cached_model_only_full_load import (
CachedModelOnlyFullLoad,
)
from tests.backend.model_manager.load.model_cache.cached_model.utils import (
DummyModule,
parameterize_keep_ram_copy,
parameterize_mps_and_cuda,
)
class NonTorchModel:
"""A model that does not sub-class torch.nn.Module."""
def __init__(self):
self.linear = torch.nn.Linear(10, 32)
def run_inference(self, x: torch.Tensor) -> torch.Tensor:
return self.linear(x)
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_total_bytes(device: str, keep_ram_copy: bool):
model = DummyModule()
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device(device), total_bytes=100, keep_ram_copy=keep_ram_copy
)
assert cached_model.total_bytes() == 100
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_is_in_vram(device: str, keep_ram_copy: bool):
model = DummyModule()
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device(device), total_bytes=100, keep_ram_copy=keep_ram_copy
)
assert not cached_model.is_in_vram()
assert cached_model.cur_vram_bytes() == 0
cached_model.full_load_to_vram()
assert cached_model.is_in_vram()
assert cached_model.cur_vram_bytes() == 100
cached_model.full_unload_from_vram()
assert not cached_model.is_in_vram()
assert cached_model.cur_vram_bytes() == 0
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_full_load_and_unload(device: str, keep_ram_copy: bool):
model = DummyModule()
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device(device), total_bytes=100, keep_ram_copy=keep_ram_copy
)
assert cached_model.full_load_to_vram() == 100
assert cached_model.is_in_vram()
assert all(p.device.type == device for p in cached_model.model.parameters())
assert cached_model.full_unload_from_vram() == 100
assert not cached_model.is_in_vram()
assert all(p.device.type == "cpu" for p in cached_model.model.parameters())
@parameterize_mps_and_cuda
def test_cached_model_get_cpu_state_dict(device: str):
model = DummyModule()
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device(device), total_bytes=100, keep_ram_copy=True
)
assert not cached_model.is_in_vram()
# The CPU state dict can be accessed and has the expected properties.
cpu_state_dict = cached_model.get_cpu_state_dict()
assert cpu_state_dict is not None
assert len(cpu_state_dict) == len(model.state_dict())
assert all(p.device.type == "cpu" for p in cpu_state_dict.values())
# Full load the model into VRAM.
cached_model.full_load_to_vram()
assert cached_model.is_in_vram()
# The CPU state dict is still available, and still on the CPU.
cpu_state_dict = cached_model.get_cpu_state_dict()
assert cpu_state_dict is not None
assert len(cpu_state_dict) == len(model.state_dict())
assert all(p.device.type == "cpu" for p in cpu_state_dict.values())
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_full_load_and_inference(device: str, keep_ram_copy: bool):
model = DummyModule()
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device(device), total_bytes=100, keep_ram_copy=keep_ram_copy
)
assert not cached_model.is_in_vram()
# Run inference on the CPU.
x = torch.randn(1, 10)
output1 = model(x)
assert output1.device.type == "cpu"
# Full load the model into VRAM.
cached_model.full_load_to_vram()
assert cached_model.is_in_vram()
# Run inference on the GPU.
output2 = model(x.to(device))
assert output2.device.type == device
# The outputs should be the same for both runs.
assert torch.allclose(output1, output2.to("cpu"))
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_non_torch_model(device: str, keep_ram_copy: bool):
model = NonTorchModel()
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device(device), total_bytes=100, keep_ram_copy=keep_ram_copy
)
assert not cached_model.is_in_vram()
# The model does not have a CPU state dict.
assert cached_model.get_cpu_state_dict() is None
# Attempting to load the model into VRAM should have no effect.
cached_model.full_load_to_vram()
assert not cached_model.is_in_vram()
assert cached_model.cur_vram_bytes() == 0
# Attempting to unload the model from VRAM should have no effect.
cached_model.full_unload_from_vram()
assert not cached_model.is_in_vram()
assert cached_model.cur_vram_bytes() == 0
# Running inference on the CPU should work.
output1 = model.run_inference(torch.randn(1, 10))
assert output1.device.type == "cpu"
@@ -0,0 +1,341 @@
import itertools
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.cached_model.cached_model_with_partial_load import (
CachedModelWithPartialLoad,
)
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
apply_custom_layers_to_model,
)
from invokeai.backend.util.calc_tensor_size import calc_tensor_size
from tests.backend.model_manager.load.model_cache.cached_model.utils import (
DummyModule,
parameterize_keep_ram_copy,
parameterize_mps_and_cuda,
)
@pytest.fixture
def model():
model = DummyModule()
apply_custom_layers_to_model(model)
return model
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_total_bytes(device: str, model: DummyModule, keep_ram_copy: bool):
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
linear1_numel = 10 * 32 + 32
linear2_numel = 32 * 64 + 64
buffer1_numel = 64
# Note that the non-persistent buffer (buffer2) is not included in .total_bytes() calculation.
assert cached_model.total_bytes() == (linear1_numel + linear2_numel + buffer1_numel) * 4
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_cur_vram_bytes(device: str, model: DummyModule, keep_ram_copy: bool):
# Model starts in CPU memory.
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
assert cached_model.cur_vram_bytes() == 0
# Full load the model into VRAM.
cached_model.full_load_to_vram()
assert cached_model.cur_vram_bytes() > 0
assert cached_model.cur_vram_bytes() == cached_model.total_bytes()
assert all(p.device.type == device for p in model.parameters())
assert all(p.device.type == device for p in model.buffers())
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_partial_load(device: str, model: DummyModule, keep_ram_copy: bool):
# Model starts in CPU memory.
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Partially load the model into VRAM.
target_vram_bytes = int(model_total_bytes * 0.6)
loaded_bytes = cached_model.partial_load_to_vram(target_vram_bytes)
# Check that the model is partially loaded into VRAM.
assert loaded_bytes > 0
assert loaded_bytes < model_total_bytes
assert loaded_bytes == cached_model.cur_vram_bytes()
assert loaded_bytes == sum(
calc_tensor_size(p)
for n, p in itertools.chain(model.named_parameters(), model.named_buffers())
if p.device.type == device and n != "buffer2"
)
# Check that the model's modules have device autocasting enabled.
assert model.linear1.is_device_autocasting_enabled()
assert model.linear2.is_device_autocasting_enabled()
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_partial_unload(device: str, model: DummyModule, keep_ram_copy: bool):
# Model starts in CPU memory.
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Full load the model into VRAM.
cached_model.full_load_to_vram()
assert cached_model.cur_vram_bytes() == model_total_bytes
# Partially unload the model from VRAM.
bytes_to_free = int(model_total_bytes * 0.4)
freed_bytes = cached_model.partial_unload_from_vram(bytes_to_free)
# Check that the model is partially unloaded from VRAM.
assert freed_bytes >= bytes_to_free
assert freed_bytes < model_total_bytes
assert freed_bytes == model_total_bytes - cached_model.cur_vram_bytes()
assert freed_bytes == sum(
calc_tensor_size(p) for p in itertools.chain(model.parameters(), model.buffers()) if p.device.type == "cpu"
)
# Check that the model's modules still have device autocasting enabled.
assert model.linear1.is_device_autocasting_enabled()
assert model.linear2.is_device_autocasting_enabled()
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_partial_unload_keep_required_weights_in_vram(
device: str, model: DummyModule, keep_ram_copy: bool
):
# Model starts in CPU memory.
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Full load the model into VRAM.
cached_model.full_load_to_vram()
assert cached_model.cur_vram_bytes() == model_total_bytes
# Partially unload the model from VRAM, but request the required weights to be kept in VRAM.
bytes_to_free = int(model_total_bytes)
freed_bytes = cached_model.partial_unload_from_vram(bytes_to_free, keep_required_weights_in_vram=True)
# Check that the model is partially unloaded from VRAM.
assert freed_bytes < model_total_bytes
assert freed_bytes == model_total_bytes - cached_model.cur_vram_bytes()
assert freed_bytes == sum(
calc_tensor_size(p) for p in itertools.chain(model.parameters(), model.buffers()) if p.device.type == "cpu"
)
# The parameters should be offloaded to the CPU, because they are in Linear layers.
assert all(p.device.type == "cpu" for p in model.parameters())
# The buffer should still be on the device, because it is in a layer that does not support autocast.
assert all(p.device.type == device for p in model.buffers())
# Check that the model's modules still have device autocasting enabled.
assert model.linear1.is_device_autocasting_enabled()
assert model.linear2.is_device_autocasting_enabled()
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_full_load_and_unload(device: str, model: DummyModule, keep_ram_copy: bool):
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
# Model starts in CPU memory.
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Full load the model into VRAM.
loaded_bytes = cached_model.full_load_to_vram()
assert loaded_bytes > 0
assert loaded_bytes == model_total_bytes
assert loaded_bytes == cached_model.cur_vram_bytes()
assert all(p.device.type == device for p in itertools.chain(model.parameters(), model.buffers()))
assert not model.linear1.is_device_autocasting_enabled()
assert not model.linear2.is_device_autocasting_enabled()
# Full unload the model from VRAM.
unloaded_bytes = cached_model.full_unload_from_vram()
# Check that the model is fully unloaded from VRAM.
assert unloaded_bytes > 0
assert unloaded_bytes == model_total_bytes
assert cached_model.cur_vram_bytes() == 0
# Note that the non-persistent buffer (buffer2) is not required to be unloaded from VRAM.
assert all(
p.device.type == "cpu"
for n, p in itertools.chain(model.named_parameters(), model.named_buffers())
if n != "buffer2"
)
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_full_load_from_partial(device: str, model: DummyModule, keep_ram_copy: bool):
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
# Model starts in CPU memory.
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Partially load the model into VRAM.
target_vram_bytes = int(model_total_bytes * 0.6)
loaded_bytes = cached_model.partial_load_to_vram(target_vram_bytes)
assert loaded_bytes > 0
assert loaded_bytes < model_total_bytes
assert loaded_bytes == cached_model.cur_vram_bytes()
assert model.linear1.is_device_autocasting_enabled()
assert model.linear2.is_device_autocasting_enabled()
# Full load the rest of the model into VRAM.
loaded_bytes_2 = cached_model.full_load_to_vram()
assert loaded_bytes_2 > 0
assert loaded_bytes_2 < model_total_bytes
assert loaded_bytes + loaded_bytes_2 == cached_model.cur_vram_bytes()
assert loaded_bytes + loaded_bytes_2 == model_total_bytes
assert all(p.device.type == device for p in itertools.chain(model.parameters(), model.buffers()))
assert not model.linear1.is_device_autocasting_enabled()
assert not model.linear2.is_device_autocasting_enabled()
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_full_unload_from_partial(device: str, model: DummyModule, keep_ram_copy: bool):
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
# Model starts in CPU memory.
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Partially load the model into VRAM.
target_vram_bytes = int(model_total_bytes * 0.6)
loaded_bytes = cached_model.partial_load_to_vram(target_vram_bytes)
assert loaded_bytes > 0
assert loaded_bytes < model_total_bytes
assert loaded_bytes == cached_model.cur_vram_bytes()
# Full unload the model from VRAM.
unloaded_bytes = cached_model.full_unload_from_vram()
assert unloaded_bytes > 0
assert unloaded_bytes == loaded_bytes
assert cached_model.cur_vram_bytes() == 0
# Note that the non-persistent buffer (buffer2) is not required to be unloaded from VRAM.
assert all(
p.device.type == "cpu"
for n, p in itertools.chain(model.named_parameters(), model.named_buffers())
if n != "buffer2"
)
@parameterize_mps_and_cuda
def test_cached_model_get_cpu_state_dict(device: str, model: DummyModule):
cached_model = CachedModelWithPartialLoad(model=model, compute_device=torch.device(device), keep_ram_copy=True)
# Model starts in CPU memory.
assert cached_model.cur_vram_bytes() == 0
# The CPU state dict can be accessed and has the expected properties.
cpu_state_dict = cached_model.get_cpu_state_dict()
assert cpu_state_dict is not None
assert len(cpu_state_dict) == len(model.state_dict())
assert all(p.device.type == "cpu" for p in cpu_state_dict.values())
# Full load the model into VRAM.
cached_model.full_load_to_vram()
assert cached_model.cur_vram_bytes() == cached_model.total_bytes()
# The CPU state dict is still available, and still on the CPU.
cpu_state_dict = cached_model.get_cpu_state_dict()
assert cpu_state_dict is not None
assert len(cpu_state_dict) == len(model.state_dict())
assert all(p.device.type == "cpu" for p in cpu_state_dict.values())
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_full_load_and_inference(device: str, model: DummyModule, keep_ram_copy: bool):
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
# Model starts in CPU memory.
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Run inference on the CPU.
x = torch.randn(1, 10)
output1 = model(x)
assert output1.device.type == "cpu"
# Full load the model into VRAM.
loaded_bytes = cached_model.full_load_to_vram()
assert loaded_bytes > 0
assert loaded_bytes == model_total_bytes
assert loaded_bytes == cached_model.cur_vram_bytes()
assert all(p.device.type == device for p in itertools.chain(model.parameters(), model.buffers()))
# Run inference on the GPU.
output2 = model(x.to(device))
assert output2.device.type == device
# The outputs should be the same for both runs.
assert torch.allclose(output1, output2.to("cpu"))
@parameterize_mps_and_cuda
@parameterize_keep_ram_copy
def test_cached_model_partial_load_and_inference(device: str, model: DummyModule, keep_ram_copy: bool):
# Model starts in CPU memory.
cached_model = CachedModelWithPartialLoad(
model=model, compute_device=torch.device(device), keep_ram_copy=keep_ram_copy
)
model_total_bytes = cached_model.total_bytes()
assert cached_model.cur_vram_bytes() == 0
# Run inference on the CPU.
x = torch.randn(1, 10)
output1 = model(x)
assert output1.device.type == "cpu"
# Partially load the model into VRAM.
target_vram_bytes = int(model_total_bytes * 0.6)
loaded_bytes = cached_model.partial_load_to_vram(target_vram_bytes)
# Check that the model is partially loaded into VRAM.
assert loaded_bytes > 0
assert loaded_bytes < model_total_bytes
assert loaded_bytes == cached_model.cur_vram_bytes()
assert loaded_bytes == sum(
calc_tensor_size(p)
for n, p in itertools.chain(model.named_parameters(), model.named_buffers())
if p.device.type == device and n != "buffer2"
)
# Check that the model's modules have device autocasting enabled.
assert model.linear1.is_device_autocasting_enabled()
assert model.linear2.is_device_autocasting_enabled()
# Run inference on the GPU.
output2 = model(x.to(device))
assert output2.device.type == device
# The output should be the same as the output from the CPU.
assert torch.allclose(output1, output2.to("cpu"))
@@ -0,0 +1,47 @@
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.cached_model.cached_model_with_partial_load import (
CachedModelWithPartialLoad,
)
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
apply_custom_layers_to_model,
)
class ModelWithRequiredScale(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
self.scale = torch.nn.Parameter(torch.ones(4))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.linear(x) * self.scale
@pytest.mark.parametrize(
"device",
[
pytest.param(
torch.device("cuda"), marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA device")
),
pytest.param(
torch.device("mps"),
marks=pytest.mark.skipif(not torch.backends.mps.is_available(), reason="requires MPS device"),
),
],
)
@pytest.mark.parametrize("keep_ram_copy", [True, False])
@torch.no_grad()
def test_repair_required_tensors_on_compute_device(device: torch.device, keep_ram_copy: bool):
model = ModelWithRequiredScale()
apply_custom_layers_to_model(model, device_autocasting_enabled=True)
cached_model = CachedModelWithPartialLoad(model=model, compute_device=device, keep_ram_copy=keep_ram_copy)
cached_model._cur_vram_bytes = 0
repaired_tensors = cached_model.repair_required_tensors_on_compute_device()
assert repaired_tensors == 1
assert cached_model._cur_vram_bytes is None
assert model.scale.device.type == device.type
assert all(param.device.type == "cpu" for param in model.linear.parameters())
@@ -0,0 +1,41 @@
import os
import pytest
import torch
class DummyModule(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear1 = torch.nn.Linear(10, 32)
self.linear2 = torch.nn.Linear(32, 64)
self.register_buffer("buffer1", torch.ones(64))
# Non-persistent buffers are not included in the state dict. We need to make sure that this case is handled
# correctly by the partial loading code.
self.register_buffer("buffer2", torch.ones(64), persistent=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.linear1(x)
x = self.linear2(x)
x = x + self.buffer1
x = x + self.buffer2
return x
is_github_ci = os.getenv("GITHUB_ACTIONS") == "true"
parameterize_mps_and_cuda = pytest.mark.parametrize(
("device"),
[
pytest.param(
"mps",
marks=pytest.mark.skipif(
is_github_ci or not torch.backends.mps.is_available(),
reason="MPS is very flaky in CI" if is_github_ci else "MPS is not available.",
),
),
pytest.param("cuda", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available.")),
],
)
parameterize_keep_ram_copy = pytest.mark.parametrize("keep_ram_copy", [True, False])
@@ -0,0 +1,224 @@
"""Tests for `ModelCache.drop_model` — used by the model_manager API to invalidate cached
entries when a setting that changes how a model loads (e.g. `fp8_storage`, `cpu_only`) is
toggled. Without this, the toggle is silently a no-op until the entry is evicted by other
means (clear cache, eviction under memory pressure, restart).
Also covers:
- Locked entries are marked stale and evicted by `unlock()` — without that, a setting toggled
during an in-flight generation would survive on the locked entry and silently be reused.
- `stats.cleared` and the `cleared` callbacks fire on invalidation, mirroring the eviction
path through `_make_room_internal`, so observers and stats stay accurate.
"""
import logging
from unittest.mock import MagicMock
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.cache_stats import CacheStats
from invokeai.backend.model_manager.load.model_cache.model_cache import ModelCache
@pytest.fixture
def mock_logger():
logger = MagicMock()
logger.getEffectiveLevel.return_value = logging.INFO
return logger
@pytest.fixture
def cache(mock_logger):
cache = ModelCache(
execution_device_working_mem_gb=1.0,
enable_partial_loading=False,
keep_ram_copy_of_weights=True,
execution_device="cpu",
storage_device="cpu",
logger=mock_logger,
)
yield cache
cache.shutdown()
def test_drop_model_removes_all_submodel_entries(cache: ModelCache):
"""A model with multiple submodels has multiple cache keys (`<key>` and `<key>:<submodel>`);
drop_model must drop them all together so the next load rebuilds with the new settings.
"""
model_key = "abc123"
cache.put(model_key, torch.randn(4))
cache.put(f"{model_key}:unet", torch.randn(4))
cache.put(f"{model_key}:text_encoder", torch.randn(4))
cache.put("other_model", torch.randn(4))
cache.put("other_model:unet", torch.randn(4))
dropped = cache.drop_model(model_key)
assert dropped == 3
assert model_key not in cache._cached_models
assert f"{model_key}:unet" not in cache._cached_models
assert f"{model_key}:text_encoder" not in cache._cached_models
# Unrelated model is left alone.
assert "other_model" in cache._cached_models
assert "other_model:unet" in cache._cached_models
def test_drop_model_marks_locked_entries_stale_without_evicting(cache: ModelCache):
"""Locked entries are in active use; we must not yank them out from under inference.
But we also must not silently retain them after the lock releases — otherwise a setting
toggle that happened during inference would survive and the next generation would reuse
the pre-change cached module. drop_model marks locked entries `is_stale=True`; unlock
evicts them as soon as the last lock releases.
"""
model_key = "abc123"
cache.put(model_key, torch.randn(4))
cache.put(f"{model_key}:unet", torch.randn(4))
locked_entry = cache._cached_models[f"{model_key}:unet"]
locked_entry.lock()
dropped = cache.drop_model(model_key)
assert dropped == 1
assert model_key not in cache._cached_models
assert f"{model_key}:unet" in cache._cached_models
assert locked_entry.is_stale is True
def test_unlock_evicts_stale_entry(cache: ModelCache):
"""The flip side of `marks_locked_entries_stale`: the next `unlock` after a stale-marking
invalidation must actually remove the entry.
"""
model_key = "abc123"
cache.put(model_key, torch.randn(4))
entry = cache._cached_models[model_key]
entry.lock()
cache.drop_model(model_key)
# Entry still here while locked.
assert model_key in cache._cached_models
assert entry.is_stale is True
cache.unlock(entry)
assert model_key not in cache._cached_models
def test_unlock_does_not_evict_non_stale_entry(cache: ModelCache):
"""The stale-eviction path must not affect ordinary unlock — only stale-marked entries
should be evicted on unlock.
"""
model_key = "abc123"
cache.put(model_key, torch.randn(4))
entry = cache._cached_models[model_key]
entry.lock()
cache.unlock(entry)
# No drop_model was called, so entry should still be there.
assert model_key in cache._cached_models
def test_unlock_only_evicts_when_last_lock_releases(cache: ModelCache):
"""If the entry is held by multiple locks (the cache supports re-entrant locking via
`_locks`), eviction must wait until they all release. Otherwise we'd yank the entry out
from under a caller that still expects it loaded.
"""
model_key = "abc123"
cache.put(model_key, torch.randn(4))
entry = cache._cached_models[model_key]
entry.lock()
entry.lock()
cache.drop_model(model_key)
assert entry.is_stale is True
cache.unlock(entry)
# Still locked by one holder — must remain.
assert model_key in cache._cached_models
cache.unlock(entry)
# Now fully released — eviction happens.
assert model_key not in cache._cached_models
def test_drop_model_updates_stats_and_fires_callbacks(cache: ModelCache):
"""drop_model is a real eviction path — observers watching for cache changes (stats,
cleared callbacks) must see it just like the make_room eviction path. Otherwise the UI
cache-stats panel and any external observer would miss invalidations.
"""
model_key = "abc123"
# Use real nn.Modules so `total_bytes()` is non-zero (raw tensors are sized as 0 by
# `calc_model_size_by_data` since the cache doesn't know what they are).
cache.put(model_key, torch.nn.Linear(4, 4))
cache.put(f"{model_key}:unet", torch.nn.Linear(4, 4))
cache.stats = CacheStats()
callback = MagicMock()
cache.on_cache_models_cleared(callback)
dropped = cache.drop_model(model_key)
assert dropped == 2
assert cache.stats.cleared == 2
callback.assert_called_once()
kwargs = callback.call_args.kwargs
assert kwargs["models_cleared"] == 2
assert kwargs["bytes_requested"] == 0 # not a make-room call
assert kwargs["bytes_freed"] > 0
def test_unlock_stale_eviction_updates_stats_and_fires_callbacks(cache: ModelCache):
"""Stale-entry eviction is also a cache change observers care about."""
model_key = "abc123"
cache.put(model_key, torch.randn(4))
entry = cache._cached_models[model_key]
entry.lock()
cache.drop_model(model_key)
cache.stats = CacheStats()
callback = MagicMock()
cache.on_cache_models_cleared(callback)
cache.unlock(entry)
assert model_key not in cache._cached_models
assert cache.stats.cleared == 1
callback.assert_called_once()
def test_drop_model_with_no_matches_does_not_fire_callbacks(cache: ModelCache):
"""No-op invalidations should be silent — don't spam observers with empty events."""
cache.put("other_model", torch.randn(4))
callback = MagicMock()
cache.on_cache_models_cleared(callback)
dropped = cache.drop_model("does_not_exist")
assert dropped == 0
callback.assert_not_called()
def test_drop_model_with_no_matches_is_noop(cache: ModelCache):
cache.put("other_model", torch.randn(4))
dropped = cache.drop_model("does_not_exist")
assert dropped == 0
assert "other_model" in cache._cached_models
def test_drop_model_does_not_match_prefix_substring(cache: ModelCache):
"""`drop_model("abc")` must not drop `abcd` — only the exact key or `abc:<submodel>`."""
cache.put("abc", torch.randn(4))
cache.put("abcd", torch.randn(4))
cache.put("abc:unet", torch.randn(4))
dropped = cache.drop_model("abc")
assert dropped == 2
assert "abc" not in cache._cached_models
assert "abc:unet" not in cache._cached_models
assert "abcd" in cache._cached_models
@@ -0,0 +1,126 @@
"""Tests for model cache keep-alive timeout functionality."""
import logging
import time
from unittest.mock import MagicMock
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.model_cache import ModelCache
@pytest.fixture
def mock_logger():
"""Create a mock logger."""
logger = MagicMock()
# Configure the mock to return a valid log level for getEffectiveLevel()
logger.getEffectiveLevel.return_value = logging.INFO
return logger
@pytest.fixture
def model_cache_with_timeout(mock_logger):
"""Create a ModelCache instance with a short timeout for testing."""
cache = ModelCache(
execution_device_working_mem_gb=1.0,
enable_partial_loading=False,
keep_ram_copy_of_weights=True,
execution_device="cpu",
storage_device="cpu",
logger=mock_logger,
keep_alive_minutes=0.01, # 0.6 seconds for fast testing
)
yield cache
cache.shutdown()
@pytest.fixture
def model_cache_no_timeout(mock_logger):
"""Create a ModelCache instance without timeout (default behavior)."""
cache = ModelCache(
execution_device_working_mem_gb=1.0,
enable_partial_loading=False,
keep_ram_copy_of_weights=True,
execution_device="cpu",
storage_device="cpu",
logger=mock_logger,
keep_alive_minutes=0, # 0 means no timeout
)
yield cache
cache.shutdown()
def test_timeout_clears_cache(model_cache_with_timeout):
"""Test that the cache is cleared after the timeout expires."""
cache = model_cache_with_timeout
# Add a simple tensor to the cache
test_tensor = torch.randn(10, 10)
cache.put("test_model", test_tensor)
# Verify the model is in the cache
assert "test_model" in cache._cached_models
# Wait for the timeout to expire (0.01 minutes = 0.6 seconds + buffer)
time.sleep(1.5)
# Verify the cache has been cleared
assert len(cache._cached_models) == 0
def test_activity_resets_timeout(model_cache_with_timeout):
"""Test that model activity resets the timeout."""
cache = model_cache_with_timeout
# Add a simple tensor to the cache
test_tensor = torch.randn(10, 10)
cache.put("test_model", test_tensor)
# Wait half the timeout
time.sleep(0.4)
# Access the model to reset the timeout
cache.get("test_model")
# Wait another half timeout (model should still be in cache)
time.sleep(0.4)
# Verify the model is still in the cache
assert "test_model" in cache._cached_models
def test_no_timeout_keeps_models(model_cache_no_timeout):
"""Test that models are kept indefinitely when timeout is 0."""
cache = model_cache_no_timeout
# Add a simple tensor to the cache
test_tensor = torch.randn(10, 10)
cache.put("test_model", test_tensor)
# Verify the model is in the cache
assert "test_model" in cache._cached_models
# Wait longer than what would be a timeout
time.sleep(1.0)
# Verify the model is still in the cache
assert "test_model" in cache._cached_models
def test_shutdown_cancels_timer(model_cache_with_timeout):
"""Test that shutdown properly cancels the timeout timer."""
cache = model_cache_with_timeout
# Add a model to start the timer
test_tensor = torch.randn(10, 10)
cache.put("test_model", test_tensor)
# Shutdown the cache
cache.shutdown()
# Wait for what would be the timeout
time.sleep(1.0)
# The model should still be in the cache since shutdown was called
assert "test_model" in cache._cached_models
@@ -0,0 +1,763 @@
import copy
from collections.abc import Callable
import gguf
import pytest
import torch
from invokeai.backend.flux.modules.layers import RMSNorm
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
AUTOCAST_MODULE_TYPE_MAPPING,
AUTOCAST_MODULE_TYPE_MAPPING_INVERSE,
unwrap_custom_layer,
wrap_custom_layer,
)
from invokeai.backend.patches.layer_patcher import LayerPatcher
from invokeai.backend.patches.layers.base_layer_patch import BaseLayerPatch
from invokeai.backend.patches.layers.dora_layer import DoRALayer
from invokeai.backend.patches.layers.flux_control_lora_layer import FluxControlLoRALayer
from invokeai.backend.patches.layers.lokr_layer import LoKRLayer
from invokeai.backend.patches.layers.lora_layer import LoRALayer
from invokeai.backend.patches.layers.merged_layer_patch import MergedLayerPatch, Range
from invokeai.backend.util.original_weights_storage import OriginalWeightsStorage
from tests.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.test_custom_invoke_linear_8_bit_lt import (
build_linear_8bit_lt_layer,
)
from tests.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.test_custom_invoke_linear_nf4 import (
build_linear_nf4_layer,
)
from tests.backend.quantization.gguf.test_ggml_tensor import quantize_tensor
def build_linear_layer_with_ggml_quantized_tensor(orig_layer: torch.nn.Linear | None = None):
if orig_layer is None:
orig_layer = torch.nn.Linear(32, 64)
ggml_quantized_weight = quantize_tensor(orig_layer.weight, gguf.GGMLQuantizationType.Q8_0)
orig_layer.weight = torch.nn.Parameter(ggml_quantized_weight)
ggml_quantized_bias = quantize_tensor(orig_layer.bias, gguf.GGMLQuantizationType.Q8_0)
orig_layer.bias = torch.nn.Parameter(ggml_quantized_bias)
return orig_layer
parameterize_all_devices = pytest.mark.parametrize(
("device"),
[
pytest.param("cpu"),
pytest.param(
"mps", marks=pytest.mark.skipif(not torch.backends.mps.is_available(), reason="MPS is not available.")
),
pytest.param("cuda", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available.")),
],
)
parameterize_cuda_and_mps = pytest.mark.parametrize(
("device"),
[
pytest.param("cuda", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available.")),
pytest.param(
"mps", marks=pytest.mark.skipif(not torch.backends.mps.is_available(), reason="MPS is not available.")
),
],
)
LayerUnderTest = tuple[torch.nn.Module, torch.Tensor, bool]
@pytest.fixture(
params=[
"linear",
"conv1d",
"conv2d",
"group_norm",
"embedding",
"flux_rms_norm",
"linear_with_ggml_quantized_tensor",
"invoke_linear_8_bit_lt",
"invoke_linear_nf4",
]
)
def layer_under_test(request: pytest.FixtureRequest) -> LayerUnderTest:
"""A fixture that returns a tuple of (layer, input, supports_cpu_inference) for the layer under test."""
layer_type = request.param
if layer_type == "linear":
return (torch.nn.Linear(8, 16), torch.randn(1, 8), True)
elif layer_type == "conv1d":
return (torch.nn.Conv1d(8, 16, 3), torch.randn(1, 8, 5), True)
elif layer_type == "conv2d":
return (torch.nn.Conv2d(8, 16, 3), torch.randn(1, 8, 5, 5), True)
elif layer_type == "group_norm":
return (torch.nn.GroupNorm(2, 8), torch.randn(1, 8, 5), True)
elif layer_type == "embedding":
return (torch.nn.Embedding(4, 8), torch.tensor([0, 1], dtype=torch.long), True)
elif layer_type == "flux_rms_norm":
return (RMSNorm(8), torch.randn(1, 8), True)
elif layer_type == "linear_with_ggml_quantized_tensor":
return (build_linear_layer_with_ggml_quantized_tensor(), torch.randn(1, 32), True)
elif layer_type == "invoke_linear_8_bit_lt":
return (build_linear_8bit_lt_layer(), torch.randn(1, 32), False)
elif layer_type == "invoke_linear_nf4":
return (build_linear_nf4_layer(), torch.randn(1, 64), False)
else:
raise ValueError(f"Unsupported layer_type: {layer_type}")
def layer_to_device_via_state_dict(layer: torch.nn.Module, device: str):
"""A helper function to move a layer to a device by roundtripping through a state dict. This most closely matches
how models are moved in the app. Some of the quantization types have broken semantics around calling .to() on the
layer directly, so this is a workaround.
We should fix this in the future.
Relevant article: https://pytorch.org/tutorials/recipes/recipes/swap_tensors.html
"""
state_dict = layer.state_dict()
state_dict = {k: v.to(device) for k, v in state_dict.items()}
layer.load_state_dict(state_dict, assign=True)
def wrap_single_custom_layer(layer: torch.nn.Module):
custom_layer_type = AUTOCAST_MODULE_TYPE_MAPPING[type(layer)]
return wrap_custom_layer(layer, custom_layer_type)
def unwrap_single_custom_layer(layer: torch.nn.Module):
orig_layer_type = AUTOCAST_MODULE_TYPE_MAPPING_INVERSE[type(layer)]
return unwrap_custom_layer(layer, orig_layer_type)
class ZeroParamPatch(BaseLayerPatch):
"""A minimal parameter patch that exercises the aggregated sidecar patch path."""
def get_parameters(self, orig_parameters: dict[str, torch.Tensor], weight: float) -> dict[str, torch.Tensor]:
return {name: torch.zeros_like(param) for name, param in orig_parameters.items()}
def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None):
return self
def calc_size(self) -> int:
return 0
def _cpu_dtype_supported(
layer_factory: Callable[[], torch.nn.Module],
input_factory: Callable[[torch.dtype], torch.Tensor],
dtype: torch.dtype,
) -> bool:
try:
layer = layer_factory().to(dtype=dtype)
input_tensor = input_factory(dtype)
with torch.no_grad():
_ = layer(input_tensor)
return True
except (RuntimeError, TypeError, NotImplementedError):
return False
def _cpu_dtype_param(
dtype: torch.dtype,
layer_factory: Callable[[], torch.nn.Module],
input_factory: Callable[[torch.dtype], torch.Tensor],
):
supported = _cpu_dtype_supported(layer_factory, input_factory, dtype)
return pytest.param(
dtype,
id=str(dtype).removeprefix("torch."),
marks=pytest.mark.skipif(not supported, reason=f"CPU {dtype} is not supported for this op"),
)
LINEAR_CPU_MIXED_DTYPE_PARAMS = [
_cpu_dtype_param(torch.bfloat16, lambda: torch.nn.Linear(8, 16), lambda dtype: torch.randn(2, 8, dtype=dtype)),
_cpu_dtype_param(torch.float16, lambda: torch.nn.Linear(8, 16), lambda dtype: torch.randn(2, 8, dtype=dtype)),
]
CONV2D_CPU_MIXED_DTYPE_PARAMS = [
_cpu_dtype_param(
torch.bfloat16,
lambda: torch.nn.Conv2d(8, 16, 3),
lambda dtype: torch.randn(2, 8, 5, 5, dtype=dtype),
),
_cpu_dtype_param(
torch.float16,
lambda: torch.nn.Conv2d(8, 16, 3),
lambda dtype: torch.randn(2, 8, 5, 5, dtype=dtype),
),
]
def test_isinstance(layer_under_test: LayerUnderTest):
"""Test that isinstance() and type() behave as expected after wrapping a layer in a custom layer."""
orig_layer, _, _ = layer_under_test
orig_type = type(orig_layer)
custom_layer = wrap_single_custom_layer(orig_layer)
assert isinstance(custom_layer, orig_type)
assert type(custom_layer) is not orig_type
def test_wrap_and_unwrap(layer_under_test: LayerUnderTest):
"""Test that wrapping and unwrapping a layer behaves as expected."""
orig_layer, _, _ = layer_under_test
orig_type = type(orig_layer)
# Wrap the original layer and assert that attributes of the custom layer can be accessed.
custom_layer = wrap_single_custom_layer(orig_layer)
custom_layer.set_device_autocasting_enabled(True)
assert custom_layer._device_autocasting_enabled
# Unwrap the custom layer.
# Assert that the methods of the wrapped layer are no longer accessible.
unwrapped_layer = unwrap_single_custom_layer(custom_layer)
with pytest.raises(AttributeError):
_ = unwrapped_layer.set_device_autocasting_enabled(True)
# For now, we have chosen to allow attributes to persist. We may revisit this in the future.
assert unwrapped_layer._device_autocasting_enabled
assert type(unwrapped_layer) is orig_type
@parameterize_all_devices
def test_state_dict(device: str, layer_under_test: LayerUnderTest):
"""Test that .state_dict() behaves the same on the original layer and the wrapped layer."""
orig_layer, _, _ = layer_under_test
# Get the original layer on the test device.
orig_layer.to(device)
orig_state_dict = orig_layer.state_dict()
# Wrap the original layer.
custom_layer = copy.deepcopy(orig_layer)
custom_layer = wrap_single_custom_layer(custom_layer)
custom_state_dict = custom_layer.state_dict()
assert set(orig_state_dict.keys()) == set(custom_state_dict.keys())
for k in orig_state_dict:
assert orig_state_dict[k].shape == custom_state_dict[k].shape
assert orig_state_dict[k].dtype == custom_state_dict[k].dtype
assert orig_state_dict[k].device == custom_state_dict[k].device
assert torch.allclose(orig_state_dict[k], custom_state_dict[k])
@parameterize_all_devices
def test_load_state_dict(device: str, layer_under_test: LayerUnderTest):
"""Test that .load_state_dict() behaves the same on the original layer and the wrapped layer."""
orig_layer, _, _ = layer_under_test
orig_layer.to(device)
custom_layer = copy.deepcopy(orig_layer)
custom_layer = wrap_single_custom_layer(custom_layer)
# Do a state dict roundtrip.
orig_state_dict = orig_layer.state_dict()
custom_state_dict = custom_layer.state_dict()
orig_layer.load_state_dict(custom_state_dict, assign=True)
custom_layer.load_state_dict(orig_state_dict, assign=True)
orig_state_dict = orig_layer.state_dict()
custom_state_dict = custom_layer.state_dict()
# Assert that the state dicts are the same after the roundtrip.
assert set(orig_state_dict.keys()) == set(custom_state_dict.keys())
for k in orig_state_dict:
assert orig_state_dict[k].shape == custom_state_dict[k].shape
assert orig_state_dict[k].dtype == custom_state_dict[k].dtype
assert orig_state_dict[k].device == custom_state_dict[k].device
assert torch.allclose(orig_state_dict[k], custom_state_dict[k])
@parameterize_all_devices
def test_inference_on_device(device: str, layer_under_test: LayerUnderTest):
"""Test that inference behaves the same on the original layer and the wrapped layer when all weights are on the
device.
"""
orig_layer, layer_input, supports_cpu_inference = layer_under_test
if device == "cpu" and not supports_cpu_inference:
pytest.skip("Layer does not support CPU inference.")
layer_to_device_via_state_dict(orig_layer, device)
custom_layer = copy.deepcopy(orig_layer)
custom_layer = wrap_single_custom_layer(custom_layer)
# Run inference with the original layer.
x = layer_input.to(device)
orig_output = orig_layer(x)
# Run inference with the wrapped layer.
custom_output = custom_layer(x)
assert torch.allclose(orig_output, custom_output)
@parameterize_cuda_and_mps
def test_inference_autocast_from_cpu_to_device(device: str, layer_under_test: LayerUnderTest):
"""Test that inference behaves the same on the original layer and the wrapped layer when all weights are on the
device.
"""
orig_layer, layer_input, supports_cpu_inference = layer_under_test
if device == "cpu" and not supports_cpu_inference:
pytest.skip("Layer does not support CPU inference.")
# Make sure the original layer is on the device.
layer_to_device_via_state_dict(orig_layer, device)
x = layer_input.to(device)
# Run inference with the original layer on the device.
orig_output = orig_layer(x)
# Move the original layer to the CPU.
layer_to_device_via_state_dict(orig_layer, "cpu")
is_nf4_layer = type(orig_layer).__name__ == "InvokeLinearNF4"
# Inference should fail with an input on the device. Do not probe raw NF4 here: with CPU-stored weights and a
# single-row CUDA input, some bitsandbytes versions hit an unsafe gemv_4bit path instead of raising safely.
if not is_nf4_layer:
with pytest.raises((RuntimeError, ValueError)):
_ = orig_layer(x)
# Wrap the original layer.
custom_layer = copy.deepcopy(orig_layer)
custom_layer = wrap_single_custom_layer(custom_layer)
# Inference should still fail with autocasting disabled. See the raw NF4 note above.
custom_layer.set_device_autocasting_enabled(False)
if not is_nf4_layer:
with pytest.raises((RuntimeError, ValueError)):
_ = custom_layer(x)
# Run inference with the wrapped layer on the device.
custom_layer.set_device_autocasting_enabled(True)
custom_output = custom_layer(x)
assert custom_output.device.type == device
if is_nf4_layer:
assert torch.allclose(orig_output, custom_output, atol=1e-5)
else:
assert torch.allclose(orig_output, custom_output)
PatchUnderTest = tuple[list[tuple[BaseLayerPatch, float]], torch.Tensor]
def _has_dora_patch(patches: list[tuple[BaseLayerPatch, float]]) -> bool:
return any(isinstance(patch, DoRALayer) for patch, _ in patches)
def _is_bnb_quantized_linear(layer: torch.nn.Module) -> bool:
return type(layer).__name__ in {"InvokeLinear8bitLt", "InvokeLinearNF4"}
@pytest.fixture(
params=[
"single_lora",
"multiple_loras",
"concatenated_lora",
"flux_control_lora",
"single_lokr",
"single_dora",
]
)
def patch_under_test(request: pytest.FixtureRequest) -> PatchUnderTest:
"""A fixture that returns a tuple of (patches, input) for the patch under test."""
layer_type = request.param
torch.manual_seed(0)
# The assumed in/out features of the base linear layer.
in_features = 32
out_features = 64
rank = 4
if layer_type == "single_lora":
lora_layer = LoRALayer(
up=torch.randn(out_features, rank),
mid=None,
down=torch.randn(rank, in_features),
alpha=1.0,
bias=torch.randn(out_features),
)
input = torch.randn(1, in_features)
return ([(lora_layer, 0.7)], input)
elif layer_type == "multiple_loras":
lora_layer = LoRALayer(
up=torch.randn(out_features, rank),
mid=None,
down=torch.randn(rank, in_features),
alpha=1.0,
bias=torch.randn(out_features),
)
lora_layer_2 = LoRALayer(
up=torch.randn(out_features, rank),
mid=None,
down=torch.randn(rank, in_features),
alpha=1.0,
bias=torch.randn(out_features),
)
input = torch.randn(1, in_features)
return ([(lora_layer, 1.0), (lora_layer_2, 0.5)], input)
elif layer_type == "concatenated_lora":
sub_layer_out_features = [16, 16, 32]
# Create a MergedLayerPatch.
sub_layers: list[LoRALayer] = []
sub_layer_ranges: list[Range] = []
dim_0_offset = 0
for out_features in sub_layer_out_features:
down = torch.randn(rank, in_features)
up = torch.randn(out_features, rank)
bias = torch.randn(out_features)
sub_layers.append(LoRALayer(up=up, mid=None, down=down, alpha=1.0, bias=bias))
sub_layer_ranges.append(Range(dim_0_offset, dim_0_offset + out_features))
dim_0_offset += out_features
merged_layer_patch = MergedLayerPatch(sub_layers, sub_layer_ranges)
input = torch.randn(1, in_features)
return ([(merged_layer_patch, 0.7)], input)
elif layer_type == "flux_control_lora":
# Create a FluxControlLoRALayer.
patched_in_features = 40
lora_layer = FluxControlLoRALayer(
up=torch.randn(out_features, rank),
mid=None,
down=torch.randn(rank, patched_in_features),
alpha=1.0,
bias=torch.randn(out_features),
)
input = torch.randn(1, patched_in_features)
return ([(lora_layer, 0.7)], input)
elif layer_type == "single_lokr":
lokr_layer = LoKRLayer(
w1=torch.randn(rank, rank),
w1_a=None,
w1_b=None,
w2=torch.randn(out_features // rank, in_features // rank),
w2_a=None,
w2_b=None,
t2=None,
alpha=1.0,
bias=torch.randn(out_features),
)
input = torch.randn(1, in_features)
return ([(lokr_layer, 0.7)], input)
elif layer_type == "single_dora":
# Regression coverage for #8624: DoRA + partial-loading + CPU->device autocast.
# Scaled down so the patched weight stays well-conditioned for allclose comparisons.
# dora_scale has shape (1, in_features) to broadcast against direction_norm in
# DoRALayer.get_weight — see dora_layer.py:74-82.
dora_layer = DoRALayer(
up=torch.randn(out_features, rank) * 0.01,
down=torch.randn(rank, in_features) * 0.01,
dora_scale=torch.ones(1, in_features),
alpha=1.0,
bias=torch.randn(out_features) * 0.01,
)
input = torch.randn(1, in_features)
return ([(dora_layer, 0.7)], input)
else:
raise ValueError(f"Unsupported layer_type: {layer_type}")
@parameterize_all_devices
def test_linear_sidecar_patches(device: str, patch_under_test: PatchUnderTest):
patches, input = patch_under_test
torch.manual_seed(0)
# Build the base layer under test.
layer = torch.nn.Linear(32, 64)
# Move the layer and input to the device.
layer_to_device_via_state_dict(layer, device)
input = input.to(torch.device(device))
# Patch the LoRA layer into the linear layer.
layer_patched = copy.deepcopy(layer)
for patch, weight in patches:
LayerPatcher._apply_model_layer_patch(
module_to_patch=layer_patched,
module_to_patch_key="",
patch=patch,
patch_weight=weight,
original_weights=OriginalWeightsStorage(),
)
# Wrap the original layer in a custom layer and add the patch to it as a sidecar.
custom_layer = wrap_single_custom_layer(layer)
for patch, weight in patches:
patch.to(torch.device(device))
custom_layer.add_patch(patch, weight)
# Run inference with the original layer and the patched layer and assert they are equal.
output_patched = layer_patched(input)
output_custom = custom_layer(input)
assert torch.allclose(output_patched, output_custom, atol=1e-6)
@parameterize_cuda_and_mps
def test_linear_sidecar_patches_with_autocast_from_cpu_to_device(device: str, patch_under_test: PatchUnderTest):
"""Test that the output of a linear layer with sidecar patches is the same when the layer is on the device and
when the layer is on the CPU and the patches are autocasted to the device.
"""
patches, input = patch_under_test
# Build the base layer under test.
layer = torch.nn.Linear(32, 64)
# Move the layer and input to the device.
layer_to_device_via_state_dict(layer, device)
input = input.to(torch.device(device))
# Wrap the original layer in a custom layer and add the patch to it.
custom_layer = wrap_single_custom_layer(layer)
for patch, weight in patches:
patch.to(torch.device(device))
custom_layer.add_patch(patch, weight)
# Run inference with the custom layer on the device.
expected_output = custom_layer(input)
# Move the custom layer to the CPU.
layer_to_device_via_state_dict(custom_layer, "cpu")
# Move the patches to the CPU.
custom_layer.clear_patches()
for patch, weight in patches:
patch.to(torch.device("cpu"))
custom_layer.add_patch(patch, weight)
# Run inference with an input on the device, and all layer weights on the CPU. The weights should be autocasted to
# the device.
autocast_output = custom_layer(input)
assert autocast_output.device.type == device
# Assert that the outputs with and without autocasting are the same.
assert torch.allclose(expected_output, autocast_output, atol=1e-6)
@pytest.fixture(
params=[
"linear_ggml_quantized",
"invoke_linear_8_bit_lt",
"invoke_linear_nf4",
]
)
def quantized_linear_layer_under_test(request: pytest.FixtureRequest):
in_features = 32
out_features = 64
torch.manual_seed(0)
layer_type = request.param
orig_layer = torch.nn.Linear(in_features, out_features)
if layer_type == "linear_ggml_quantized":
return orig_layer, build_linear_layer_with_ggml_quantized_tensor(orig_layer)
elif layer_type == "invoke_linear_8_bit_lt":
return orig_layer, build_linear_8bit_lt_layer(orig_layer)
elif layer_type == "invoke_linear_nf4":
return orig_layer, build_linear_nf4_layer(orig_layer)
else:
raise ValueError(f"Unsupported layer_type: {layer_type}")
@parameterize_cuda_and_mps
def test_quantized_linear_sidecar_patches(
device: str,
quantized_linear_layer_under_test: tuple[torch.nn.Module, torch.nn.Module],
patch_under_test: PatchUnderTest,
):
"""Test that patches can be applied to quantized linear layers and that the output is the same as when the patch is
applied to a non-quantized linear layer.
"""
patches, input = patch_under_test
linear_layer, quantized_linear_layer = quantized_linear_layer_under_test
expect_dora_incompatible = _is_bnb_quantized_linear(quantized_linear_layer) and _has_dora_patch(patches)
# Move everything to the device.
layer_to_device_via_state_dict(linear_layer, device)
layer_to_device_via_state_dict(quantized_linear_layer, device)
input = input.to(torch.device(device))
# Wrap both layers in custom layers.
linear_layer_custom = wrap_single_custom_layer(linear_layer)
quantized_linear_layer_custom = wrap_single_custom_layer(quantized_linear_layer)
# Apply the patches to the custom layers.
for patch, weight in patches:
patch.to(torch.device(device))
linear_layer_custom.add_patch(patch, weight)
quantized_linear_layer_custom.add_patch(patch, weight)
# Run inference with the original layer and the patched layer and assert they are equal.
output_linear_patched = linear_layer_custom(input)
if expect_dora_incompatible:
with pytest.raises(RuntimeError, match="not compatible with DoRA patches"):
quantized_linear_layer_custom(input)
return
output_quantized_patched = quantized_linear_layer_custom(input)
assert torch.allclose(output_linear_patched, output_quantized_patched, rtol=0.2, atol=0.2)
@parameterize_cuda_and_mps
def test_quantized_linear_sidecar_patches_with_autocast_from_cpu_to_device(
device: str,
quantized_linear_layer_under_test: tuple[torch.nn.Module, torch.nn.Module],
patch_under_test: PatchUnderTest,
):
"""Test that the output of a linear layer with sidecar patches is the same when the layer is on the device and
when the layer is on the CPU and the patches are autocasted to the device.
"""
patches, input = patch_under_test
_, quantized_linear_layer = quantized_linear_layer_under_test
expect_dora_incompatible = _is_bnb_quantized_linear(quantized_linear_layer) and _has_dora_patch(patches)
# Move everything to the device.
layer_to_device_via_state_dict(quantized_linear_layer, device)
input = input.to(torch.device(device))
# Wrap the quantized linear layer in a custom layer and add the patch to it.
quantized_linear_layer_custom = wrap_single_custom_layer(quantized_linear_layer)
for patch, weight in patches:
patch.to(torch.device(device))
quantized_linear_layer_custom.add_patch(patch, weight)
# Run inference with the custom layer on the device.
if expect_dora_incompatible:
with pytest.raises(RuntimeError, match="not compatible with DoRA patches"):
quantized_linear_layer_custom(input)
return
expected_output = quantized_linear_layer_custom(input)
# Move the custom layer to the CPU.
layer_to_device_via_state_dict(quantized_linear_layer_custom, "cpu")
# Move the patches to the CPU.
quantized_linear_layer_custom.clear_patches()
for patch, weight in patches:
patch.to(torch.device("cpu"))
quantized_linear_layer_custom.add_patch(patch, weight)
# Run inference with an input on the device, and all layer weights on the CPU. The weights should be autocasted to
# the device.
autocast_output = quantized_linear_layer_custom(input)
assert autocast_output.device.type == device
# Assert that the outputs with and without autocasting are the same.
assert torch.allclose(expected_output, autocast_output, atol=1e-6)
@pytest.mark.parametrize("dtype", LINEAR_CPU_MIXED_DTYPE_PARAMS)
@torch.no_grad()
def test_linear_mixed_dtype_inference_without_patches(dtype: torch.dtype):
layer = wrap_single_custom_layer(torch.nn.Linear(8, 16))
input = torch.randn(2, 8, dtype=dtype)
output = layer(input)
assert output.dtype == input.dtype
assert output.shape == (2, 16)
@pytest.mark.parametrize("dtype", LINEAR_CPU_MIXED_DTYPE_PARAMS)
@torch.no_grad()
def test_linear_mixed_dtype_inference_without_patches_bias_only_mismatch(dtype: torch.dtype):
layer = torch.nn.Linear(8, 16).to(dtype=dtype)
layer.bias = torch.nn.Parameter(layer.bias.detach().to(torch.float32))
layer = wrap_single_custom_layer(layer)
input = torch.randn(2, 8, dtype=dtype)
output = layer(input)
assert output.dtype == input.dtype
assert output.shape == (2, 16)
@pytest.mark.parametrize("dtype", CONV2D_CPU_MIXED_DTYPE_PARAMS)
@torch.no_grad()
def test_conv2d_mixed_dtype_inference_without_patches(dtype: torch.dtype):
layer = wrap_single_custom_layer(torch.nn.Conv2d(8, 16, 3))
input = torch.randn(2, 8, 5, 5, dtype=dtype)
output = layer(input)
assert output.dtype == input.dtype
assert output.shape == (2, 16, 3, 3)
@pytest.mark.parametrize("dtype", LINEAR_CPU_MIXED_DTYPE_PARAMS)
@torch.no_grad()
def test_linear_mixed_dtype_sidecar_parameter_patch(dtype: torch.dtype):
layer = wrap_single_custom_layer(torch.nn.Linear(8, 16))
layer.add_patch(ZeroParamPatch(), 1.0)
input = torch.randn(2, 8, dtype=dtype)
output = layer(input)
assert output.dtype == input.dtype
assert output.shape == (2, 16)
@pytest.mark.parametrize("dtype", CONV2D_CPU_MIXED_DTYPE_PARAMS)
@torch.no_grad()
def test_conv2d_mixed_dtype_sidecar_parameter_patch(dtype: torch.dtype):
layer = wrap_single_custom_layer(torch.nn.Conv2d(8, 16, 3))
layer.add_patch(ZeroParamPatch(), 1.0)
input = torch.randn(2, 8, 5, 5, dtype=dtype)
output = layer(input)
assert output.dtype == input.dtype
assert output.shape == (2, 16, 3, 3)
@torch.no_grad()
def test_aggregate_patch_parameters_preserves_plain_tensor_with_dora():
"""Regression test for #8624: when partial-loading autocasts a CPU Parameter onto the
compute device, cast_to_device returns a plain torch.Tensor (not a Parameter). The
aggregator must treat that as a real tensor and not substitute a meta-device dummy —
otherwise DoRA's quantization guard falsely triggers on non-quantized base models.
This test is CPU-only and simulates the hand-off by constructing a plain torch.Tensor
directly; the equivalent CUDA/MPS E2E flow is exercised by the "single_dora" variant
of test_linear_sidecar_patches_with_autocast_from_cpu_to_device.
"""
layer = wrap_single_custom_layer(torch.nn.Linear(32, 64))
rank = 4
dora_patch = DoRALayer(
up=torch.randn(64, rank) * 0.01,
down=torch.randn(rank, 32) * 0.01,
dora_scale=torch.ones(1, 32),
alpha=1.0,
bias=None,
)
# Plain torch.Tensor — the shape _cast_weight_bias_for_input hands into
# _aggregate_patch_parameters after autocasting a Parameter across devices.
plain_weight = torch.randn(64, 32)
assert type(plain_weight) is torch.Tensor
orig_params = {"weight": plain_weight}
params = layer._aggregate_patch_parameters(
patches_and_weights=[(dora_patch, 1.0)],
orig_params=orig_params,
device=torch.device("cpu"),
)
# Pre-fix, orig_params["weight"] would have been replaced by a meta-device dummy,
# causing DoRALayer.get_parameters to raise "not compatible with DoRA patches".
assert orig_params["weight"].device.type == "cpu"
assert params["weight"].shape == (64, 32)
assert params["weight"].device.type == "cpu"
assert not torch.isnan(params["weight"]).any()
@@ -0,0 +1,31 @@
import torch
from invokeai.backend.flux.modules.layers import RMSNorm
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.custom_flux_rms_norm import (
CustomFluxRMSNorm,
)
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
wrap_custom_layer,
)
from invokeai.backend.patches.layers.set_parameter_layer import SetParameterLayer
def test_custom_flux_rms_norm_patch():
"""Test a SetParameterLayer patch on a CustomFluxRMSNorm layer."""
# Create a RMSNorm layer.
dim = 8
rms_norm = RMSNorm(dim)
# Create a SetParameterLayer.
new_scale = torch.randn(dim)
set_parameter_layer = SetParameterLayer("scale", new_scale)
# Wrap the RMSNorm layer in a CustomFluxRMSNorm layer.
custom_flux_rms_norm = wrap_custom_layer(rms_norm, CustomFluxRMSNorm)
custom_flux_rms_norm.add_patch(set_parameter_layer, 1.0)
# Run the CustomFluxRMSNorm layer.
input = torch.randn(1, dim)
expected_output = torch.nn.functional.rms_norm(input, new_scale.shape, new_scale, eps=1e-6)
output_custom = custom_flux_rms_norm(input)
assert torch.allclose(output_custom, expected_output, atol=1e-6)
@@ -0,0 +1,82 @@
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
wrap_custom_layer,
)
if not torch.cuda.is_available():
pytest.skip("CUDA is not available", allow_module_level=True)
else:
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.custom_invoke_linear_8_bit_lt import (
CustomInvokeLinear8bitLt,
)
from invokeai.backend.quantization.bnb_llm_int8 import InvokeLinear8bitLt
def build_linear_8bit_lt_layer(orig_layer: torch.nn.Linear | None = None):
if not torch.cuda.is_available():
pytest.skip("CUDA is not available")
torch.manual_seed(1)
if orig_layer is None:
orig_layer = torch.nn.Linear(32, 64)
orig_layer_state_dict = orig_layer.state_dict()
# Prepare a quantized InvokeLinear8bitLt layer.
quantized_layer = InvokeLinear8bitLt(
input_features=orig_layer.in_features, output_features=orig_layer.out_features, has_fp16_weights=False
)
quantized_layer.load_state_dict(orig_layer_state_dict)
quantized_layer.to("cuda")
# Assert that the InvokeLinear8bitLt layer is quantized.
assert quantized_layer.weight.CB is not None
assert quantized_layer.weight.SCB is not None
assert quantized_layer.weight.CB.dtype == torch.int8
return quantized_layer
@pytest.fixture
def linear_8bit_lt_layer():
return build_linear_8bit_lt_layer()
def test_custom_invoke_linear_8bit_lt_all_weights_on_cuda(linear_8bit_lt_layer: InvokeLinear8bitLt):
"""Test CustomInvokeLinear8bitLt inference with all weights on the GPU."""
# Run inference on the original layer.
x = torch.randn(1, 32).to("cuda")
y_quantized = linear_8bit_lt_layer(x)
# Wrap the InvokeLinear8bitLt layer in a CustomInvokeLinear8bitLt layer, and run inference on it.
custom_linear_8bit_lt_layer = wrap_custom_layer(linear_8bit_lt_layer, CustomInvokeLinear8bitLt)
y_custom = custom_linear_8bit_lt_layer(x)
# Assert that the quantized and custom layers produce the same output.
assert torch.allclose(y_quantized, y_custom, atol=1e-5)
def test_custom_invoke_linear_8bit_lt_all_weights_on_cpu(linear_8bit_lt_layer: InvokeLinear8bitLt):
"""Test CustomInvokeLinear8bitLt inference with all weights on the CPU (streaming to the GPU)."""
# Run inference on the original layer.
x = torch.randn(1, 32).to("cuda")
y_quantized = linear_8bit_lt_layer(x)
# Copy the state dict to the CPU and reload it.
state_dict = linear_8bit_lt_layer.state_dict()
state_dict = {k: v.to("cpu") for k, v in state_dict.items()}
linear_8bit_lt_layer.load_state_dict(state_dict)
# Inference of the original layer should fail.
with pytest.raises((RuntimeError, ValueError)):
linear_8bit_lt_layer(x)
# Wrap the InvokeLinear8bitLt layer in a CustomInvokeLinear8bitLt layer, and run inference on it.
custom_linear_8bit_lt_layer = wrap_custom_layer(linear_8bit_lt_layer, CustomInvokeLinear8bitLt)
custom_linear_8bit_lt_layer.set_device_autocasting_enabled(True)
y_custom = custom_linear_8bit_lt_layer(x)
# Assert that the quantized and custom layers produce the same output.
assert torch.allclose(y_quantized, y_custom, atol=1e-5)
@@ -0,0 +1,108 @@
from unittest.mock import patch
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
wrap_custom_layer,
)
if not torch.cuda.is_available():
pytest.skip("CUDA is not available", allow_module_level=True)
else:
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.custom_invoke_linear_nf4 import (
CustomInvokeLinearNF4,
)
from invokeai.backend.quantization.bnb_nf4 import InvokeLinearNF4
def build_linear_nf4_layer(orig_layer: torch.nn.Linear | None = None):
if not torch.cuda.is_available():
pytest.skip("CUDA is not available")
torch.manual_seed(1)
if orig_layer is None:
orig_layer = torch.nn.Linear(64, 16)
orig_layer_state_dict = orig_layer.state_dict()
# Prepare a quantized InvokeLinearNF4 layer.
quantized_layer = InvokeLinearNF4(input_features=orig_layer.in_features, output_features=orig_layer.out_features)
quantized_layer.load_state_dict(orig_layer_state_dict)
quantized_layer.to("cuda")
# Assert that the InvokeLinearNF4 layer is quantized.
assert quantized_layer.weight.bnb_quantized
return quantized_layer
@pytest.fixture
def linear_nf4_layer():
return build_linear_nf4_layer()
def test_custom_invoke_linear_nf4_all_weights_on_cuda(linear_nf4_layer: InvokeLinearNF4):
"""Test CustomInvokeLinearNF4 inference with all weights on the GPU."""
# Run inference on the original layer.
x = torch.randn(1, 64).to("cuda")
y_quantized = linear_nf4_layer(x)
# Wrap the InvokeLinearNF4 layer in a CustomInvokeLinearNF4 layer, and run inference on it.
custom_linear_nf4_layer = wrap_custom_layer(linear_nf4_layer, CustomInvokeLinearNF4)
custom_linear_nf4_layer.set_device_autocasting_enabled(True)
y_custom = custom_linear_nf4_layer(x)
# Assert that the quantized and custom layers produce the same output.
assert torch.allclose(y_quantized, y_custom, atol=1e-5)
def test_custom_invoke_linear_nf4_all_weights_on_cuda_uses_bnb_single_vector_path(
linear_nf4_layer: InvokeLinearNF4,
):
"""GPU-resident single-vector inference should keep using bnb's gemv_4bit path."""
x = torch.randn(1, 64).to("cuda")
custom_linear_nf4_layer = wrap_custom_layer(linear_nf4_layer, CustomInvokeLinearNF4)
custom_linear_nf4_layer.set_device_autocasting_enabled(True)
with patch("bitsandbytes.functional.dequantize_4bit") as mock_dequantize:
_ = custom_linear_nf4_layer(x)
mock_dequantize.assert_not_called()
# We run with two different input dimensions, because the NF4 layer follows a different code path depending on the
# input dimension, and this has caused issues in the past.
@pytest.mark.parametrize("input_dim_0", [1, 2])
def test_custom_invoke_linear_nf4_all_weights_on_cpu(linear_nf4_layer: InvokeLinearNF4, input_dim_0: int):
"""Test CustomInvokeLinearNF4 inference with all weights on the CPU (streaming to the GPU)."""
# Run inference on the original layer.
x = torch.randn(input_dim_0, 64).to(device="cuda")
y_quantized = linear_nf4_layer(x)
# Copy the state dict to the CPU and reload it.
state_dict = linear_nf4_layer.state_dict()
state_dict = {k: v.to("cpu") for k, v in state_dict.items()}
linear_nf4_layer.load_state_dict(state_dict)
# Do not call the raw bitsandbytes NF4 layer here. With CPU-stored weights and a single-row CUDA input, some
# bitsandbytes versions hit an unsafe gemv_4bit path instead of raising a Python exception. The custom layer below
# is the behavior under test.
# Wrap the InvokeLinearNF4 layer in a CustomInvokeLinearNF4 layer, and run inference on it.
custom_linear_nf4_layer = wrap_custom_layer(linear_nf4_layer, CustomInvokeLinearNF4)
custom_linear_nf4_layer.set_device_autocasting_enabled(True)
y_custom = custom_linear_nf4_layer(x)
# Assert that the state dict (and the tensors that it references) are still on the CPU.
assert all(v.device == torch.device("cpu") for v in state_dict.values())
# Assert that the weight, bias, and quant_state are all on the CPU.
assert custom_linear_nf4_layer.weight.device == torch.device("cpu")
assert custom_linear_nf4_layer.bias.device == torch.device("cpu")
assert custom_linear_nf4_layer.weight.quant_state.absmax.device == torch.device("cpu")
assert custom_linear_nf4_layer.weight.quant_state.code.device == torch.device("cpu")
# Assert that the quantized and custom layers produce the same output.
assert torch.allclose(y_quantized, y_custom, atol=1e-5)
@@ -0,0 +1,132 @@
import os
import gguf
import pytest
import torch
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
apply_custom_layers_to_model,
remove_custom_layers_from_model,
)
from tests.backend.quantization.gguf.test_ggml_tensor import quantize_tensor
try:
from invokeai.backend.quantization.bnb_llm_int8 import InvokeLinear8bitLt, quantize_model_llm_int8
except ImportError:
# This is expected to fail on MacOS
pass
cuda_and_mps = pytest.mark.parametrize(
"device",
[
pytest.param(
torch.device("cuda"), marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA device")
),
pytest.param(
torch.device("mps"),
marks=pytest.mark.skipif(not torch.backends.mps.is_available(), reason="requires MPS device"),
),
],
)
class ModelWithLinearLayer(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(32, 64)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.linear(x)
@pytest.fixture(params=["none", "gguf"])
def model(request: pytest.FixtureRequest) -> torch.nn.Module:
if request.param == "none":
return ModelWithLinearLayer()
elif request.param == "gguf":
# Initialize ModelWithLinearLayer and replace the linear layer weight with a GGML quantized weight.
model = ModelWithLinearLayer()
ggml_quantized_weight = quantize_tensor(model.linear.weight, gguf.GGMLQuantizationType.Q8_0)
model.linear.weight = torch.nn.Parameter(ggml_quantized_weight)
return model
else:
raise ValueError(f"Invalid quantization type: {request.param}")
@cuda_and_mps
@torch.no_grad()
def test_torch_module_autocast_linear_layer(device: torch.device, model: torch.nn.Module):
# Skip this test with MPS on GitHub Actions. It fails but I haven't taken the tie to figure out why. It passes
# locally on MacOS.
if os.environ.get("GITHUB_ACTIONS") == "true" and device.type == "mps":
pytest.skip("This test is flaky on GitHub Actions")
# Model parameters should start off on the CPU.
assert all(p.device.type == "cpu" for p in model.parameters())
torch.manual_seed(0)
# Run inference on the CPU.
x = torch.randn(1, 32, device="cpu")
expected = model(x)
assert expected.device.type == "cpu"
# Apply the custom layers to the model.
apply_custom_layers_to_model(model, device_autocasting_enabled=True)
# Run the model on the device.
autocast_result = model(x.to(device))
# The model output should be on the device.
assert autocast_result.device.type == device.type
# The model parameters should still be on the CPU.
assert all(p.device.type == "cpu" for p in model.parameters())
# Remove the custom layers from the model.
remove_custom_layers_from_model(model)
# After removing the custom layers, the model should no longer be able to run inference on the device.
with pytest.raises(RuntimeError):
_ = model(x.to(device))
# Run inference again on the CPU.
after_result = model(x)
assert after_result.device.type == "cpu"
# The results from all inference runs should be the same.
assert torch.allclose(autocast_result.to("cpu"), expected, atol=1e-5)
assert torch.allclose(after_result, expected, atol=1e-5)
@torch.no_grad()
def test_torch_module_autocast_bnb_llm_int8_linear_layer():
if not torch.cuda.is_available():
pytest.skip("requires CUDA device")
torch.manual_seed(0)
model = ModelWithLinearLayer()
model = quantize_model_llm_int8(model, modules_to_not_convert=set())
# The act of moving the model to the CUDA device will trigger quantization.
model.to("cuda")
# Confirm that the layer is quantized.
assert isinstance(model.linear, InvokeLinear8bitLt)
assert model.linear.weight.CB is not None
assert model.linear.weight.SCB is not None
# Run inference on the GPU.
x = torch.randn(1, 32)
expected = model(x.to("cuda"))
assert expected.device.type == "cuda"
# Move the model back to the CPU and add the custom layers to the model.
model.to("cpu")
apply_custom_layers_to_model(model, device_autocasting_enabled=True)
# Run inference with weights being streamed to the GPU.
autocast_result = model(x.to("cuda"))
assert autocast_result.device.type == "cuda"
# The results from all inference runs should be the same.
assert torch.allclose(autocast_result, expected, atol=1e-5)
@@ -0,0 +1,155 @@
"""Representative key layout of an official Anima transformer single-file checkpoint.
Captured from `anima-base-v1.0.safetensors`. The full checkpoint has 685 tensors under the
`net.` prefix; this fixture keeps `net.blocks.0.*` plus all non-block `net.*` keys. Used to
exercise `_strip_anima_bundle_prefix` (the `net.` -> unprefixed strip). The ComfyUI-bundled
`model.diffusion_model.*` variant is covered by a small synthetic dict in the test.
"""
state_dict_keys: dict[str, list[int]] = {
"net.blocks.0.adaln_modulation_cross_attn.1.weight": [256, 2048],
"net.blocks.0.adaln_modulation_cross_attn.2.weight": [6144, 256],
"net.blocks.0.adaln_modulation_mlp.1.weight": [256, 2048],
"net.blocks.0.adaln_modulation_mlp.2.weight": [6144, 256],
"net.blocks.0.adaln_modulation_self_attn.1.weight": [256, 2048],
"net.blocks.0.adaln_modulation_self_attn.2.weight": [6144, 256],
"net.blocks.0.cross_attn.k_norm.weight": [128],
"net.blocks.0.cross_attn.k_proj.weight": [2048, 1024],
"net.blocks.0.cross_attn.output_proj.weight": [2048, 2048],
"net.blocks.0.cross_attn.q_norm.weight": [128],
"net.blocks.0.cross_attn.q_proj.weight": [2048, 2048],
"net.blocks.0.cross_attn.v_proj.weight": [2048, 1024],
"net.blocks.0.mlp.layer1.weight": [8192, 2048],
"net.blocks.0.mlp.layer2.weight": [2048, 8192],
"net.blocks.0.self_attn.k_norm.weight": [128],
"net.blocks.0.self_attn.k_proj.weight": [2048, 2048],
"net.blocks.0.self_attn.output_proj.weight": [2048, 2048],
"net.blocks.0.self_attn.q_norm.weight": [128],
"net.blocks.0.self_attn.q_proj.weight": [2048, 2048],
"net.blocks.0.self_attn.v_proj.weight": [2048, 2048],
"net.final_layer.adaln_modulation.1.weight": [256, 2048],
"net.final_layer.adaln_modulation.2.weight": [4096, 256],
"net.final_layer.linear.weight": [64, 2048],
"net.llm_adapter.blocks.0.cross_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.0.cross_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.cross_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.cross_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.0.cross_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.cross_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.mlp.0.bias": [4096],
"net.llm_adapter.blocks.0.mlp.0.weight": [4096, 1024],
"net.llm_adapter.blocks.0.mlp.2.bias": [1024],
"net.llm_adapter.blocks.0.mlp.2.weight": [1024, 4096],
"net.llm_adapter.blocks.0.norm_cross_attn.weight": [1024],
"net.llm_adapter.blocks.0.norm_mlp.weight": [1024],
"net.llm_adapter.blocks.0.norm_self_attn.weight": [1024],
"net.llm_adapter.blocks.0.self_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.0.self_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.self_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.self_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.0.self_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.0.self_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.cross_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.1.cross_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.cross_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.cross_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.1.cross_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.cross_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.mlp.0.bias": [4096],
"net.llm_adapter.blocks.1.mlp.0.weight": [4096, 1024],
"net.llm_adapter.blocks.1.mlp.2.bias": [1024],
"net.llm_adapter.blocks.1.mlp.2.weight": [1024, 4096],
"net.llm_adapter.blocks.1.norm_cross_attn.weight": [1024],
"net.llm_adapter.blocks.1.norm_mlp.weight": [1024],
"net.llm_adapter.blocks.1.norm_self_attn.weight": [1024],
"net.llm_adapter.blocks.1.self_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.1.self_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.self_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.self_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.1.self_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.1.self_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.cross_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.2.cross_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.cross_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.cross_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.2.cross_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.cross_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.mlp.0.bias": [4096],
"net.llm_adapter.blocks.2.mlp.0.weight": [4096, 1024],
"net.llm_adapter.blocks.2.mlp.2.bias": [1024],
"net.llm_adapter.blocks.2.mlp.2.weight": [1024, 4096],
"net.llm_adapter.blocks.2.norm_cross_attn.weight": [1024],
"net.llm_adapter.blocks.2.norm_mlp.weight": [1024],
"net.llm_adapter.blocks.2.norm_self_attn.weight": [1024],
"net.llm_adapter.blocks.2.self_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.2.self_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.self_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.self_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.2.self_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.2.self_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.cross_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.3.cross_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.cross_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.cross_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.3.cross_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.cross_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.mlp.0.bias": [4096],
"net.llm_adapter.blocks.3.mlp.0.weight": [4096, 1024],
"net.llm_adapter.blocks.3.mlp.2.bias": [1024],
"net.llm_adapter.blocks.3.mlp.2.weight": [1024, 4096],
"net.llm_adapter.blocks.3.norm_cross_attn.weight": [1024],
"net.llm_adapter.blocks.3.norm_mlp.weight": [1024],
"net.llm_adapter.blocks.3.norm_self_attn.weight": [1024],
"net.llm_adapter.blocks.3.self_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.3.self_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.self_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.self_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.3.self_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.3.self_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.cross_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.4.cross_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.cross_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.cross_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.4.cross_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.cross_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.mlp.0.bias": [4096],
"net.llm_adapter.blocks.4.mlp.0.weight": [4096, 1024],
"net.llm_adapter.blocks.4.mlp.2.bias": [1024],
"net.llm_adapter.blocks.4.mlp.2.weight": [1024, 4096],
"net.llm_adapter.blocks.4.norm_cross_attn.weight": [1024],
"net.llm_adapter.blocks.4.norm_mlp.weight": [1024],
"net.llm_adapter.blocks.4.norm_self_attn.weight": [1024],
"net.llm_adapter.blocks.4.self_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.4.self_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.self_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.self_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.4.self_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.4.self_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.cross_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.5.cross_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.cross_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.cross_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.5.cross_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.cross_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.mlp.0.bias": [4096],
"net.llm_adapter.blocks.5.mlp.0.weight": [4096, 1024],
"net.llm_adapter.blocks.5.mlp.2.bias": [1024],
"net.llm_adapter.blocks.5.mlp.2.weight": [1024, 4096],
"net.llm_adapter.blocks.5.norm_cross_attn.weight": [1024],
"net.llm_adapter.blocks.5.norm_mlp.weight": [1024],
"net.llm_adapter.blocks.5.norm_self_attn.weight": [1024],
"net.llm_adapter.blocks.5.self_attn.k_norm.weight": [64],
"net.llm_adapter.blocks.5.self_attn.k_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.self_attn.o_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.self_attn.q_norm.weight": [64],
"net.llm_adapter.blocks.5.self_attn.q_proj.weight": [1024, 1024],
"net.llm_adapter.blocks.5.self_attn.v_proj.weight": [1024, 1024],
"net.llm_adapter.embed.weight": [32128, 1024],
"net.llm_adapter.norm.weight": [1024],
"net.llm_adapter.out_proj.bias": [1024],
"net.llm_adapter.out_proj.weight": [1024, 1024],
"net.t_embedder.1.linear_1.weight": [2048, 2048],
"net.t_embedder.1.linear_2.weight": [6144, 2048],
"net.t_embedding_norm.weight": [2048],
"net.x_embedder.proj.1.weight": [2048, 68],
}
@@ -0,0 +1,41 @@
"""Representative BFL-format key layout of a FLUX.2 transformer single-file checkpoint.
Captured from `flux-2-klein-9b-kv.safetensors` (FLUX.2 Klein 9B, bf16). The full
checkpoint has 201 tensors (8 double blocks, 24 single blocks); this
fixture keeps every top-level key plus block 0 of the double/single stacks, which is enough
to exercise `convert_flux2_bfl_to_diffusers` (fused-QKV split, block renames, adaLN
scale/shift swap) and validate against a single-layer `Flux2Transformer2DModel`.
BFL layout uses `double_blocks.*`, `single_blocks.*`, `img_in`, `txt_in`, `time_in`,
`*_modulation.lin`, `final_layer.*`; diffusers expects `transformer_blocks.*`,
`single_transformer_blocks.*`, `x_embedder`, `context_embedder`, `time_guidance_embed.*`,
`proj_out`, `norm_out`.
"""
state_dict_keys: dict[str, list[int]] = {
"double_blocks.0.img_attn.norm.key_norm.scale": [128],
"double_blocks.0.img_attn.norm.query_norm.scale": [128],
"double_blocks.0.img_attn.proj.weight": [4096, 4096],
"double_blocks.0.img_attn.qkv.weight": [12288, 4096],
"double_blocks.0.img_mlp.0.weight": [24576, 4096],
"double_blocks.0.img_mlp.2.weight": [4096, 12288],
"double_blocks.0.txt_attn.norm.key_norm.scale": [128],
"double_blocks.0.txt_attn.norm.query_norm.scale": [128],
"double_blocks.0.txt_attn.proj.weight": [4096, 4096],
"double_blocks.0.txt_attn.qkv.weight": [12288, 4096],
"double_blocks.0.txt_mlp.0.weight": [24576, 4096],
"double_blocks.0.txt_mlp.2.weight": [4096, 12288],
"double_stream_modulation_img.lin.weight": [24576, 4096],
"double_stream_modulation_txt.lin.weight": [24576, 4096],
"final_layer.adaLN_modulation.1.weight": [8192, 4096],
"final_layer.linear.weight": [128, 4096],
"img_in.weight": [4096, 128],
"single_blocks.0.linear1.weight": [36864, 4096],
"single_blocks.0.linear2.weight": [4096, 16384],
"single_blocks.0.norm.key_norm.scale": [128],
"single_blocks.0.norm.query_norm.scale": [128],
"single_stream_modulation.lin.weight": [12288, 4096],
"time_in.in_layer.weight": [4096, 256],
"time_in.out_layer.weight": [4096, 4096],
"txt_in.weight": [4096, 12288],
}
@@ -0,0 +1,266 @@
"""BFL-format key layout of a FLUX.2 VAE single-file checkpoint (full, 251 keys).
Captured from `flux2-vae.safetensors` (standard FLUX.2 VAE, block_out_channels=(128,256,512,512)).
The full key set is kept (the VAE is small), so `convert_flux2_vae_bfl_to_diffusers` can be
validated for *complete* coverage against `AutoencoderKLFlux2` — every converted key must be a
real parameter and every parameter must be covered.
BFL layout uses `encoder.down.*`, `decoder.up.*` (reversed order!), `{enc,dec}.mid.block_N`,
`{enc,dec}.mid.attn_1.*`, `norm_out`, `encoder.quant_conv`, `decoder.post_quant_conv`;
diffusers expects `down_blocks`/`up_blocks`/`mid_block.resnets`/`mid_block.attentions`/
`conv_norm_out` and top-level `quant_conv`/`post_quant_conv`.
"""
state_dict_keys: dict[str, list[int]] = {
"bn.num_batches_tracked": [],
"bn.running_mean": [128],
"bn.running_var": [128],
"decoder.conv_in.bias": [512],
"decoder.conv_in.weight": [512, 32, 3, 3],
"decoder.conv_norm_out.bias": [128],
"decoder.conv_norm_out.weight": [128],
"decoder.conv_out.bias": [3],
"decoder.conv_out.weight": [3, 128, 3, 3],
"decoder.mid_block.attentions.0.group_norm.bias": [512],
"decoder.mid_block.attentions.0.group_norm.weight": [512],
"decoder.mid_block.attentions.0.to_k.bias": [512],
"decoder.mid_block.attentions.0.to_k.weight": [512, 512],
"decoder.mid_block.attentions.0.to_out.0.bias": [512],
"decoder.mid_block.attentions.0.to_out.0.weight": [512, 512],
"decoder.mid_block.attentions.0.to_q.bias": [512],
"decoder.mid_block.attentions.0.to_q.weight": [512, 512],
"decoder.mid_block.attentions.0.to_v.bias": [512],
"decoder.mid_block.attentions.0.to_v.weight": [512, 512],
"decoder.mid_block.resnets.0.conv1.bias": [512],
"decoder.mid_block.resnets.0.conv1.weight": [512, 512, 3, 3],
"decoder.mid_block.resnets.0.conv2.bias": [512],
"decoder.mid_block.resnets.0.conv2.weight": [512, 512, 3, 3],
"decoder.mid_block.resnets.0.norm1.bias": [512],
"decoder.mid_block.resnets.0.norm1.weight": [512],
"decoder.mid_block.resnets.0.norm2.bias": [512],
"decoder.mid_block.resnets.0.norm2.weight": [512],
"decoder.mid_block.resnets.1.conv1.bias": [512],
"decoder.mid_block.resnets.1.conv1.weight": [512, 512, 3, 3],
"decoder.mid_block.resnets.1.conv2.bias": [512],
"decoder.mid_block.resnets.1.conv2.weight": [512, 512, 3, 3],
"decoder.mid_block.resnets.1.norm1.bias": [512],
"decoder.mid_block.resnets.1.norm1.weight": [512],
"decoder.mid_block.resnets.1.norm2.bias": [512],
"decoder.mid_block.resnets.1.norm2.weight": [512],
"decoder.up_blocks.0.resnets.0.conv1.bias": [512],
"decoder.up_blocks.0.resnets.0.conv1.weight": [512, 512, 3, 3],
"decoder.up_blocks.0.resnets.0.conv2.bias": [512],
"decoder.up_blocks.0.resnets.0.conv2.weight": [512, 512, 3, 3],
"decoder.up_blocks.0.resnets.0.norm1.bias": [512],
"decoder.up_blocks.0.resnets.0.norm1.weight": [512],
"decoder.up_blocks.0.resnets.0.norm2.bias": [512],
"decoder.up_blocks.0.resnets.0.norm2.weight": [512],
"decoder.up_blocks.0.resnets.1.conv1.bias": [512],
"decoder.up_blocks.0.resnets.1.conv1.weight": [512, 512, 3, 3],
"decoder.up_blocks.0.resnets.1.conv2.bias": [512],
"decoder.up_blocks.0.resnets.1.conv2.weight": [512, 512, 3, 3],
"decoder.up_blocks.0.resnets.1.norm1.bias": [512],
"decoder.up_blocks.0.resnets.1.norm1.weight": [512],
"decoder.up_blocks.0.resnets.1.norm2.bias": [512],
"decoder.up_blocks.0.resnets.1.norm2.weight": [512],
"decoder.up_blocks.0.resnets.2.conv1.bias": [512],
"decoder.up_blocks.0.resnets.2.conv1.weight": [512, 512, 3, 3],
"decoder.up_blocks.0.resnets.2.conv2.bias": [512],
"decoder.up_blocks.0.resnets.2.conv2.weight": [512, 512, 3, 3],
"decoder.up_blocks.0.resnets.2.norm1.bias": [512],
"decoder.up_blocks.0.resnets.2.norm1.weight": [512],
"decoder.up_blocks.0.resnets.2.norm2.bias": [512],
"decoder.up_blocks.0.resnets.2.norm2.weight": [512],
"decoder.up_blocks.0.upsamplers.0.conv.bias": [512],
"decoder.up_blocks.0.upsamplers.0.conv.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.0.conv1.bias": [512],
"decoder.up_blocks.1.resnets.0.conv1.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.0.conv2.bias": [512],
"decoder.up_blocks.1.resnets.0.conv2.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.0.norm1.bias": [512],
"decoder.up_blocks.1.resnets.0.norm1.weight": [512],
"decoder.up_blocks.1.resnets.0.norm2.bias": [512],
"decoder.up_blocks.1.resnets.0.norm2.weight": [512],
"decoder.up_blocks.1.resnets.1.conv1.bias": [512],
"decoder.up_blocks.1.resnets.1.conv1.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.1.conv2.bias": [512],
"decoder.up_blocks.1.resnets.1.conv2.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.1.norm1.bias": [512],
"decoder.up_blocks.1.resnets.1.norm1.weight": [512],
"decoder.up_blocks.1.resnets.1.norm2.bias": [512],
"decoder.up_blocks.1.resnets.1.norm2.weight": [512],
"decoder.up_blocks.1.resnets.2.conv1.bias": [512],
"decoder.up_blocks.1.resnets.2.conv1.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.2.conv2.bias": [512],
"decoder.up_blocks.1.resnets.2.conv2.weight": [512, 512, 3, 3],
"decoder.up_blocks.1.resnets.2.norm1.bias": [512],
"decoder.up_blocks.1.resnets.2.norm1.weight": [512],
"decoder.up_blocks.1.resnets.2.norm2.bias": [512],
"decoder.up_blocks.1.resnets.2.norm2.weight": [512],
"decoder.up_blocks.1.upsamplers.0.conv.bias": [512],
"decoder.up_blocks.1.upsamplers.0.conv.weight": [512, 512, 3, 3],
"decoder.up_blocks.2.resnets.0.conv1.bias": [256],
"decoder.up_blocks.2.resnets.0.conv1.weight": [256, 512, 3, 3],
"decoder.up_blocks.2.resnets.0.conv2.bias": [256],
"decoder.up_blocks.2.resnets.0.conv2.weight": [256, 256, 3, 3],
"decoder.up_blocks.2.resnets.0.conv_shortcut.bias": [256],
"decoder.up_blocks.2.resnets.0.conv_shortcut.weight": [256, 512, 1, 1],
"decoder.up_blocks.2.resnets.0.norm1.bias": [512],
"decoder.up_blocks.2.resnets.0.norm1.weight": [512],
"decoder.up_blocks.2.resnets.0.norm2.bias": [256],
"decoder.up_blocks.2.resnets.0.norm2.weight": [256],
"decoder.up_blocks.2.resnets.1.conv1.bias": [256],
"decoder.up_blocks.2.resnets.1.conv1.weight": [256, 256, 3, 3],
"decoder.up_blocks.2.resnets.1.conv2.bias": [256],
"decoder.up_blocks.2.resnets.1.conv2.weight": [256, 256, 3, 3],
"decoder.up_blocks.2.resnets.1.norm1.bias": [256],
"decoder.up_blocks.2.resnets.1.norm1.weight": [256],
"decoder.up_blocks.2.resnets.1.norm2.bias": [256],
"decoder.up_blocks.2.resnets.1.norm2.weight": [256],
"decoder.up_blocks.2.resnets.2.conv1.bias": [256],
"decoder.up_blocks.2.resnets.2.conv1.weight": [256, 256, 3, 3],
"decoder.up_blocks.2.resnets.2.conv2.bias": [256],
"decoder.up_blocks.2.resnets.2.conv2.weight": [256, 256, 3, 3],
"decoder.up_blocks.2.resnets.2.norm1.bias": [256],
"decoder.up_blocks.2.resnets.2.norm1.weight": [256],
"decoder.up_blocks.2.resnets.2.norm2.bias": [256],
"decoder.up_blocks.2.resnets.2.norm2.weight": [256],
"decoder.up_blocks.2.upsamplers.0.conv.bias": [256],
"decoder.up_blocks.2.upsamplers.0.conv.weight": [256, 256, 3, 3],
"decoder.up_blocks.3.resnets.0.conv1.bias": [128],
"decoder.up_blocks.3.resnets.0.conv1.weight": [128, 256, 3, 3],
"decoder.up_blocks.3.resnets.0.conv2.bias": [128],
"decoder.up_blocks.3.resnets.0.conv2.weight": [128, 128, 3, 3],
"decoder.up_blocks.3.resnets.0.conv_shortcut.bias": [128],
"decoder.up_blocks.3.resnets.0.conv_shortcut.weight": [128, 256, 1, 1],
"decoder.up_blocks.3.resnets.0.norm1.bias": [256],
"decoder.up_blocks.3.resnets.0.norm1.weight": [256],
"decoder.up_blocks.3.resnets.0.norm2.bias": [128],
"decoder.up_blocks.3.resnets.0.norm2.weight": [128],
"decoder.up_blocks.3.resnets.1.conv1.bias": [128],
"decoder.up_blocks.3.resnets.1.conv1.weight": [128, 128, 3, 3],
"decoder.up_blocks.3.resnets.1.conv2.bias": [128],
"decoder.up_blocks.3.resnets.1.conv2.weight": [128, 128, 3, 3],
"decoder.up_blocks.3.resnets.1.norm1.bias": [128],
"decoder.up_blocks.3.resnets.1.norm1.weight": [128],
"decoder.up_blocks.3.resnets.1.norm2.bias": [128],
"decoder.up_blocks.3.resnets.1.norm2.weight": [128],
"decoder.up_blocks.3.resnets.2.conv1.bias": [128],
"decoder.up_blocks.3.resnets.2.conv1.weight": [128, 128, 3, 3],
"decoder.up_blocks.3.resnets.2.conv2.bias": [128],
"decoder.up_blocks.3.resnets.2.conv2.weight": [128, 128, 3, 3],
"decoder.up_blocks.3.resnets.2.norm1.bias": [128],
"decoder.up_blocks.3.resnets.2.norm1.weight": [128],
"decoder.up_blocks.3.resnets.2.norm2.bias": [128],
"decoder.up_blocks.3.resnets.2.norm2.weight": [128],
"encoder.conv_in.bias": [128],
"encoder.conv_in.weight": [128, 3, 3, 3],
"encoder.conv_norm_out.bias": [512],
"encoder.conv_norm_out.weight": [512],
"encoder.conv_out.bias": [64],
"encoder.conv_out.weight": [64, 512, 3, 3],
"encoder.down_blocks.0.downsamplers.0.conv.bias": [128],
"encoder.down_blocks.0.downsamplers.0.conv.weight": [128, 128, 3, 3],
"encoder.down_blocks.0.resnets.0.conv1.bias": [128],
"encoder.down_blocks.0.resnets.0.conv1.weight": [128, 128, 3, 3],
"encoder.down_blocks.0.resnets.0.conv2.bias": [128],
"encoder.down_blocks.0.resnets.0.conv2.weight": [128, 128, 3, 3],
"encoder.down_blocks.0.resnets.0.norm1.bias": [128],
"encoder.down_blocks.0.resnets.0.norm1.weight": [128],
"encoder.down_blocks.0.resnets.0.norm2.bias": [128],
"encoder.down_blocks.0.resnets.0.norm2.weight": [128],
"encoder.down_blocks.0.resnets.1.conv1.bias": [128],
"encoder.down_blocks.0.resnets.1.conv1.weight": [128, 128, 3, 3],
"encoder.down_blocks.0.resnets.1.conv2.bias": [128],
"encoder.down_blocks.0.resnets.1.conv2.weight": [128, 128, 3, 3],
"encoder.down_blocks.0.resnets.1.norm1.bias": [128],
"encoder.down_blocks.0.resnets.1.norm1.weight": [128],
"encoder.down_blocks.0.resnets.1.norm2.bias": [128],
"encoder.down_blocks.0.resnets.1.norm2.weight": [128],
"encoder.down_blocks.1.downsamplers.0.conv.bias": [256],
"encoder.down_blocks.1.downsamplers.0.conv.weight": [256, 256, 3, 3],
"encoder.down_blocks.1.resnets.0.conv1.bias": [256],
"encoder.down_blocks.1.resnets.0.conv1.weight": [256, 128, 3, 3],
"encoder.down_blocks.1.resnets.0.conv2.bias": [256],
"encoder.down_blocks.1.resnets.0.conv2.weight": [256, 256, 3, 3],
"encoder.down_blocks.1.resnets.0.conv_shortcut.bias": [256],
"encoder.down_blocks.1.resnets.0.conv_shortcut.weight": [256, 128, 1, 1],
"encoder.down_blocks.1.resnets.0.norm1.bias": [128],
"encoder.down_blocks.1.resnets.0.norm1.weight": [128],
"encoder.down_blocks.1.resnets.0.norm2.bias": [256],
"encoder.down_blocks.1.resnets.0.norm2.weight": [256],
"encoder.down_blocks.1.resnets.1.conv1.bias": [256],
"encoder.down_blocks.1.resnets.1.conv1.weight": [256, 256, 3, 3],
"encoder.down_blocks.1.resnets.1.conv2.bias": [256],
"encoder.down_blocks.1.resnets.1.conv2.weight": [256, 256, 3, 3],
"encoder.down_blocks.1.resnets.1.norm1.bias": [256],
"encoder.down_blocks.1.resnets.1.norm1.weight": [256],
"encoder.down_blocks.1.resnets.1.norm2.bias": [256],
"encoder.down_blocks.1.resnets.1.norm2.weight": [256],
"encoder.down_blocks.2.downsamplers.0.conv.bias": [512],
"encoder.down_blocks.2.downsamplers.0.conv.weight": [512, 512, 3, 3],
"encoder.down_blocks.2.resnets.0.conv1.bias": [512],
"encoder.down_blocks.2.resnets.0.conv1.weight": [512, 256, 3, 3],
"encoder.down_blocks.2.resnets.0.conv2.bias": [512],
"encoder.down_blocks.2.resnets.0.conv2.weight": [512, 512, 3, 3],
"encoder.down_blocks.2.resnets.0.conv_shortcut.bias": [512],
"encoder.down_blocks.2.resnets.0.conv_shortcut.weight": [512, 256, 1, 1],
"encoder.down_blocks.2.resnets.0.norm1.bias": [256],
"encoder.down_blocks.2.resnets.0.norm1.weight": [256],
"encoder.down_blocks.2.resnets.0.norm2.bias": [512],
"encoder.down_blocks.2.resnets.0.norm2.weight": [512],
"encoder.down_blocks.2.resnets.1.conv1.bias": [512],
"encoder.down_blocks.2.resnets.1.conv1.weight": [512, 512, 3, 3],
"encoder.down_blocks.2.resnets.1.conv2.bias": [512],
"encoder.down_blocks.2.resnets.1.conv2.weight": [512, 512, 3, 3],
"encoder.down_blocks.2.resnets.1.norm1.bias": [512],
"encoder.down_blocks.2.resnets.1.norm1.weight": [512],
"encoder.down_blocks.2.resnets.1.norm2.bias": [512],
"encoder.down_blocks.2.resnets.1.norm2.weight": [512],
"encoder.down_blocks.3.resnets.0.conv1.bias": [512],
"encoder.down_blocks.3.resnets.0.conv1.weight": [512, 512, 3, 3],
"encoder.down_blocks.3.resnets.0.conv2.bias": [512],
"encoder.down_blocks.3.resnets.0.conv2.weight": [512, 512, 3, 3],
"encoder.down_blocks.3.resnets.0.norm1.bias": [512],
"encoder.down_blocks.3.resnets.0.norm1.weight": [512],
"encoder.down_blocks.3.resnets.0.norm2.bias": [512],
"encoder.down_blocks.3.resnets.0.norm2.weight": [512],
"encoder.down_blocks.3.resnets.1.conv1.bias": [512],
"encoder.down_blocks.3.resnets.1.conv1.weight": [512, 512, 3, 3],
"encoder.down_blocks.3.resnets.1.conv2.bias": [512],
"encoder.down_blocks.3.resnets.1.conv2.weight": [512, 512, 3, 3],
"encoder.down_blocks.3.resnets.1.norm1.bias": [512],
"encoder.down_blocks.3.resnets.1.norm1.weight": [512],
"encoder.down_blocks.3.resnets.1.norm2.bias": [512],
"encoder.down_blocks.3.resnets.1.norm2.weight": [512],
"encoder.mid_block.attentions.0.group_norm.bias": [512],
"encoder.mid_block.attentions.0.group_norm.weight": [512],
"encoder.mid_block.attentions.0.to_k.bias": [512],
"encoder.mid_block.attentions.0.to_k.weight": [512, 512],
"encoder.mid_block.attentions.0.to_out.0.bias": [512],
"encoder.mid_block.attentions.0.to_out.0.weight": [512, 512],
"encoder.mid_block.attentions.0.to_q.bias": [512],
"encoder.mid_block.attentions.0.to_q.weight": [512, 512],
"encoder.mid_block.attentions.0.to_v.bias": [512],
"encoder.mid_block.attentions.0.to_v.weight": [512, 512],
"encoder.mid_block.resnets.0.conv1.bias": [512],
"encoder.mid_block.resnets.0.conv1.weight": [512, 512, 3, 3],
"encoder.mid_block.resnets.0.conv2.bias": [512],
"encoder.mid_block.resnets.0.conv2.weight": [512, 512, 3, 3],
"encoder.mid_block.resnets.0.norm1.bias": [512],
"encoder.mid_block.resnets.0.norm1.weight": [512],
"encoder.mid_block.resnets.0.norm2.bias": [512],
"encoder.mid_block.resnets.0.norm2.weight": [512],
"encoder.mid_block.resnets.1.conv1.bias": [512],
"encoder.mid_block.resnets.1.conv1.weight": [512, 512, 3, 3],
"encoder.mid_block.resnets.1.conv2.bias": [512],
"encoder.mid_block.resnets.1.conv2.weight": [512, 512, 3, 3],
"encoder.mid_block.resnets.1.norm1.bias": [512],
"encoder.mid_block.resnets.1.norm1.weight": [512],
"encoder.mid_block.resnets.1.norm2.bias": [512],
"encoder.mid_block.resnets.1.norm2.weight": [512],
"post_quant_conv.bias": [32],
"post_quant_conv.weight": [32, 32, 1, 1],
"quant_conv.bias": [64],
"quant_conv.weight": [64, 64, 1, 1],
}
@@ -0,0 +1,77 @@
"""Representative key layout of a ComfyUI single-file Qwen2.5-VL encoder checkpoint.
Captured from `qwen_2.5_vl_7b_fp8_scaled.safetensors` (Qwen2.5-VL-7B, ComfyUI fp8_scaled).
The full checkpoint has 1446 tensors (32 visual blocks, 28 language layers); this
fixture keeps every top-level/structural key plus block 0 of each repeated stack, which is
enough to exercise the `visual.* -> model.visual.*` / `model.* -> model.language_model.*`
remap and the fp8 metadata stripping without shipping a ~1446-key dict.
Legacy ComfyUI layout uses `visual.*`, `model.*`, `lm_head.*` (transformers >=4.50 expects
`model.visual.*` and `model.language_model.*`). `scale_weight` / `scale_input` / `scaled_fp8`
are ComfyUI fp8 quantization metadata.
"""
state_dict_keys: dict[str, list[int]] = {
"visual.blocks.0.attn.proj.bias": [1280],
"visual.blocks.0.attn.proj.scale_input": [],
"visual.blocks.0.attn.proj.scale_weight": [],
"visual.blocks.0.attn.proj.weight": [1280, 1280],
"visual.blocks.0.attn.qkv.bias": [3840],
"visual.blocks.0.attn.qkv.scale_input": [],
"visual.blocks.0.attn.qkv.scale_weight": [],
"visual.blocks.0.attn.qkv.weight": [3840, 1280],
"visual.blocks.0.mlp.down_proj.bias": [1280],
"visual.blocks.0.mlp.down_proj.scale_input": [],
"visual.blocks.0.mlp.down_proj.scale_weight": [],
"visual.blocks.0.mlp.down_proj.weight": [1280, 3420],
"visual.blocks.0.mlp.gate_proj.bias": [3420],
"visual.blocks.0.mlp.gate_proj.scale_input": [],
"visual.blocks.0.mlp.gate_proj.scale_weight": [],
"visual.blocks.0.mlp.gate_proj.weight": [3420, 1280],
"visual.blocks.0.mlp.up_proj.bias": [3420],
"visual.blocks.0.mlp.up_proj.scale_input": [],
"visual.blocks.0.mlp.up_proj.scale_weight": [],
"visual.blocks.0.mlp.up_proj.weight": [3420, 1280],
"visual.blocks.0.norm1.weight": [1280],
"visual.blocks.0.norm2.weight": [1280],
"visual.merger.ln_q.weight": [1280],
"visual.merger.mlp.0.bias": [5120],
"visual.merger.mlp.0.scale_input": [],
"visual.merger.mlp.0.scale_weight": [],
"visual.merger.mlp.0.weight": [5120, 5120],
"visual.merger.mlp.2.bias": [3584],
"visual.merger.mlp.2.scale_input": [],
"visual.merger.mlp.2.scale_weight": [],
"visual.merger.mlp.2.weight": [3584, 5120],
"visual.patch_embed.proj.weight": [1280, 3, 2, 14, 14],
"model.embed_tokens.weight": [152064, 3584],
"model.layers.0.input_layernorm.weight": [3584],
"model.layers.0.mlp.down_proj.scale_input": [],
"model.layers.0.mlp.down_proj.scale_weight": [],
"model.layers.0.mlp.down_proj.weight": [3584, 18944],
"model.layers.0.mlp.gate_proj.scale_input": [],
"model.layers.0.mlp.gate_proj.scale_weight": [],
"model.layers.0.mlp.gate_proj.weight": [18944, 3584],
"model.layers.0.mlp.up_proj.scale_input": [],
"model.layers.0.mlp.up_proj.scale_weight": [],
"model.layers.0.mlp.up_proj.weight": [18944, 3584],
"model.layers.0.post_attention_layernorm.weight": [3584],
"model.layers.0.self_attn.k_proj.bias": [512],
"model.layers.0.self_attn.k_proj.scale_input": [],
"model.layers.0.self_attn.k_proj.scale_weight": [],
"model.layers.0.self_attn.k_proj.weight": [512, 3584],
"model.layers.0.self_attn.o_proj.scale_input": [],
"model.layers.0.self_attn.o_proj.scale_weight": [],
"model.layers.0.self_attn.o_proj.weight": [3584, 3584],
"model.layers.0.self_attn.q_proj.bias": [3584],
"model.layers.0.self_attn.q_proj.scale_input": [],
"model.layers.0.self_attn.q_proj.scale_weight": [],
"model.layers.0.self_attn.q_proj.weight": [3584, 3584],
"model.layers.0.self_attn.v_proj.bias": [512],
"model.layers.0.self_attn.v_proj.scale_input": [],
"model.layers.0.self_attn.v_proj.scale_weight": [],
"model.layers.0.self_attn.v_proj.weight": [512, 3584],
"model.norm.weight": [3584],
"lm_head.weight": [152064, 3584],
"scaled_fp8": [0],
}
@@ -0,0 +1,13 @@
"""Shared helpers for model-loader state-dict fixtures.
Mirrors `tests/backend/patches/lora_conversions/lora_state_dicts/utils.py`: a fixture module
exports `state_dict_keys: dict[str, list[int]]` (key name -> shape, captured from a real
checkpoint) and tests expand it to a mock state dict with `keys_to_mock_state_dict()`.
"""
import torch
def keys_to_mock_state_dict(keys: dict[str, list[int]]) -> dict[str, torch.Tensor]:
"""Build a state dict of empty tensors from a {key: shape} mapping."""
return {k: torch.empty(shape) for k, shape in keys.items()}
@@ -0,0 +1,69 @@
"""Representative ComfyUI key layout of a Z-Image transformer single-file checkpoint.
Captured from `zimageTurboBadmilk_v10.safetensors` (Z-Image Turbo) after stripping the
`model.diffusion_model.` prefix -- this is exactly the input `_convert_z_image_gguf_to_diffusers`
receives (the converter runs on both the checkpoint and GGUF paths after prefix stripping).
The full transformer has 453 keys (context_refiner / noise_refiner / layers stacks); this
fixture keeps block 0 of each stack plus all non-block keys, and adds a synthetic
`norm_final.weight` to exercise the skip branch.
Legacy layout uses fused `*.attention.qkv.*`, `*.attention.out.*`, `*.attention.q_norm/k_norm`,
`x_embedder.*`, `final_layer.*`, `x_pad_token`, `cap_pad_token`; diffusers expects split
`to_q/to_k/to_v`, `to_out.0`, `norm_q/norm_k`, `all_x_embedder.2-1.*`, `all_final_layer.2-1.*`.
"""
state_dict_keys: dict[str, list[int]] = {
"cap_embedder.0.weight": [2560],
"cap_embedder.1.bias": [3840],
"cap_embedder.1.weight": [3840, 2560],
"cap_pad_token": [1, 3840],
"context_refiner.0.attention.k_norm.weight": [128],
"context_refiner.0.attention.out.weight": [3840, 3840],
"context_refiner.0.attention.q_norm.weight": [128],
"context_refiner.0.attention.qkv.weight": [11520, 3840],
"context_refiner.0.attention_norm1.weight": [3840],
"context_refiner.0.attention_norm2.weight": [3840],
"context_refiner.0.feed_forward.w1.weight": [10240, 3840],
"context_refiner.0.feed_forward.w2.weight": [3840, 10240],
"context_refiner.0.feed_forward.w3.weight": [10240, 3840],
"context_refiner.0.ffn_norm1.weight": [3840],
"context_refiner.0.ffn_norm2.weight": [3840],
"final_layer.adaLN_modulation.1.bias": [3840],
"final_layer.adaLN_modulation.1.weight": [3840, 256],
"final_layer.linear.bias": [64],
"final_layer.linear.weight": [64, 3840],
"layers.0.adaLN_modulation.0.bias": [15360],
"layers.0.adaLN_modulation.0.weight": [15360, 256],
"layers.0.attention.k_norm.weight": [128],
"layers.0.attention.out.weight": [3840, 3840],
"layers.0.attention.q_norm.weight": [128],
"layers.0.attention.qkv.weight": [11520, 3840],
"layers.0.attention_norm1.weight": [3840],
"layers.0.attention_norm2.weight": [3840],
"layers.0.feed_forward.w1.weight": [10240, 3840],
"layers.0.feed_forward.w2.weight": [3840, 10240],
"layers.0.feed_forward.w3.weight": [10240, 3840],
"layers.0.ffn_norm1.weight": [3840],
"layers.0.ffn_norm2.weight": [3840],
"noise_refiner.0.adaLN_modulation.0.bias": [15360],
"noise_refiner.0.adaLN_modulation.0.weight": [15360, 256],
"noise_refiner.0.attention.k_norm.weight": [128],
"noise_refiner.0.attention.out.weight": [3840, 3840],
"noise_refiner.0.attention.q_norm.weight": [128],
"noise_refiner.0.attention.qkv.weight": [11520, 3840],
"noise_refiner.0.attention_norm1.weight": [3840],
"noise_refiner.0.attention_norm2.weight": [3840],
"noise_refiner.0.feed_forward.w1.weight": [10240, 3840],
"noise_refiner.0.feed_forward.w2.weight": [3840, 10240],
"noise_refiner.0.feed_forward.w3.weight": [10240, 3840],
"noise_refiner.0.ffn_norm1.weight": [3840],
"noise_refiner.0.ffn_norm2.weight": [3840],
"t_embedder.mlp.0.bias": [1024],
"t_embedder.mlp.0.weight": [1024, 256],
"t_embedder.mlp.2.bias": [256],
"t_embedder.mlp.2.weight": [256, 1024],
"x_embedder.bias": [3840],
"x_embedder.weight": [3840, 64],
"x_pad_token": [1, 3840],
"norm_final.weight": [2304],
}
@@ -0,0 +1,38 @@
"""Unit tests for the Anima single-file prefix-stripping helper."""
import torch
from invokeai.backend.model_manager.load.model_loaders.anima import _strip_anima_bundle_prefix
from tests.backend.model_manager.load.state_dicts.anima_comfyui_keys import state_dict_keys as anima_keys
from tests.backend.model_manager.load.state_dicts.utils import keys_to_mock_state_dict
class TestStripAnimaBundlePrefix:
def test_official_net_prefix_is_stripped(self):
sd = keys_to_mock_state_dict(anima_keys)
assert all(k.startswith("net.") for k in sd)
out = _strip_anima_bundle_prefix(sd)
assert len(out) == len(sd)
assert not any(k.startswith("net.") for k in out)
# Every key had exactly its `net.` prefix removed.
assert {"net." + k for k in out} == set(sd.keys())
def test_comfyui_bundle_keeps_only_transformer_keys(self):
# ComfyUI bundles the transformer under `model.diffusion_model.` alongside the VAE and
# text encoder, which must be dropped.
sd = {
"model.diffusion_model.blocks.0.attn.qkv.weight": torch.empty(1),
"model.diffusion_model.final_layer.weight": torch.empty(1),
"first_stage_model.encoder.conv_in.weight": torch.empty(1),
"cond_stage_model.transformer.embeddings.weight": torch.empty(1),
}
out = _strip_anima_bundle_prefix(sd)
assert set(out.keys()) == {"blocks.0.attn.qkv.weight", "final_layer.weight"}
def test_no_known_prefix_is_a_noop(self):
sd = {"blocks.0.attn.qkv.weight": torch.empty(1)}
assert _strip_anima_bundle_prefix(sd) is sd
@@ -0,0 +1,101 @@
"""Unit tests for the FLUX.2 BFL->diffusers state-dict converters.
Fixtures are captured from real single-file checkpoints (see the fixture module docstrings).
The meta-device tests instantiate the actual diffusers architectures with `init_empty_weights`
(no real weights, no GPU) and assert that every converted key is a real parameter -- the same
kind of check that would have caught the Qwen VL remap regression.
"""
import accelerate
import torch
from invokeai.backend.model_manager.load.model_loaders.flux2_state_dict_utils import (
_flux2_swap_scale_shift,
convert_flux2_bfl_to_diffusers,
convert_flux2_vae_bfl_to_diffusers,
)
from tests.backend.model_manager.load.state_dicts.flux2_transformer_bfl_keys import (
state_dict_keys as flux2_transformer_keys,
)
from tests.backend.model_manager.load.state_dicts.flux2_vae_bfl_keys import (
state_dict_keys as flux2_vae_keys,
)
from tests.backend.model_manager.load.state_dicts.utils import keys_to_mock_state_dict
class TestConvertFlux2Transformer:
def test_fused_qkv_is_split_and_blocks_renamed(self):
sd = keys_to_mock_state_dict(flux2_transformer_keys)
converted = convert_flux2_bfl_to_diffusers(sd)
# Fused img/txt QKV are split into separate projections.
assert "transformer_blocks.0.attn.to_q.weight" in converted
assert "transformer_blocks.0.attn.to_k.weight" in converted
assert "transformer_blocks.0.attn.to_v.weight" in converted
assert "transformer_blocks.0.attn.add_q_proj.weight" in converted
# No fused/BFL-named keys remain.
assert not any("img_attn.qkv" in k or "double_blocks." in k or "single_blocks." in k for k in converted)
# Top-level renames.
assert "x_embedder.weight" in converted
assert "context_embedder.weight" in converted
assert "proj_out.weight" in converted
def test_converted_keys_are_all_real_transformer_params(self):
"""Meta-device coverage: every converted key must exist in Flux2Transformer2DModel."""
from diffusers import Flux2Transformer2DModel
converted = convert_flux2_bfl_to_diffusers(keys_to_mock_state_dict(flux2_transformer_keys))
# The fixture keeps block 0 of each stack -> a single-layer model covers it.
with accelerate.init_empty_weights():
model = Flux2Transformer2DModel(num_layers=1, num_single_layers=1)
params = set(model.state_dict().keys())
unmatched = sorted(k for k in converted if k not in params)
assert not unmatched, f"converted keys with no matching model parameter: {unmatched}"
class TestConvertFlux2Vae:
def test_full_bijective_coverage_against_arch(self):
"""The full VAE fixture must convert to exactly the AutoencoderKLFlux2 parameter set."""
from diffusers import AutoencoderKLFlux2
converted = convert_flux2_vae_bfl_to_diffusers(keys_to_mock_state_dict(flux2_vae_keys))
with accelerate.init_empty_weights():
vae = AutoencoderKLFlux2(block_out_channels=(128, 256, 512, 512))
params = set(vae.state_dict().keys())
unmatched = sorted(k for k in converted if k not in params)
missing = sorted(k for k in params if k not in converted)
assert not unmatched, f"converted keys with no matching VAE parameter: {unmatched}"
assert not missing, f"VAE parameters not covered by the converted checkpoint: {missing}"
def test_up_block_order_is_reversed(self):
# BFL decoder.up.X maps to diffusers up_blocks.(3 - X).
sd = {
"decoder.up.0.block.0.norm1.weight": torch.empty(1),
"decoder.up.3.block.0.norm1.weight": torch.empty(1),
}
converted = convert_flux2_vae_bfl_to_diffusers(sd)
assert "decoder.up_blocks.3.resnets.0.norm1.weight" in converted
assert "decoder.up_blocks.0.resnets.0.norm1.weight" in converted
def test_mid_attention_conv_weights_are_squeezed_to_linear(self):
# BFL stores mid attention as Conv2d [out, in, 1, 1]; diffusers uses Linear [out, in].
sd = {"encoder.mid.attn_1.q.weight": torch.empty(8, 8, 1, 1)}
converted = convert_flux2_vae_bfl_to_diffusers(sd)
assert converted["encoder.mid_block.attentions.0.to_q.weight"].shape == (8, 8)
class TestSwapScaleShift:
def test_swaps_the_two_halves(self):
# First half = shift, second half = scale; diffusers wants them swapped.
weight = torch.cat([torch.zeros(2), torch.ones(2)]) # [shift=0, scale=1]
swapped = _flux2_swap_scale_shift(weight)
assert torch.allclose(swapped, torch.cat([torch.ones(2), torch.zeros(2)]))
def test_leaves_malformed_tensor_untouched(self):
weight = torch.ones(3) # odd length -> cannot be split
assert torch.allclose(_flux2_swap_scale_shift(weight), weight)
@@ -0,0 +1,371 @@
"""Tests for `ModelLoader` FP8 helpers.
Covers:
- `_should_use_fp8` excludes ControlLoRA (the LoRA loader never runs the layerwise
casting helper, and a LoRA isn't a standalone forward module — so a persisted
`fp8_storage=true` must be a no-op).
- `_wrap_forward_with_fp8_cast` uses pre/post hooks with `always_call=True`, so it is
exception-safe AND survives `apply_custom_layers_to_model`'s instance swap. Without
hooks, an instance-level `forward` override would be carried into the new CustomLinear
via the shared `__dict__` and silently bypass `CustomLinear.forward` — breaking LoRA
patch dispatch for FP8 checkpoint models.
- `_apply_fp8_to_nn_module` skips precision-sensitive layers (norm, pos_embed, etc.)
so FLUX RMSNorm.scale and friends aren't crushed to FP8.
"""
from logging import getLogger
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import torch
from invokeai.backend.model_manager.load.load_default import ModelLoader
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.custom_linear import (
CustomLinear,
)
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
apply_custom_layers_to_model,
)
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
def _make_loader(device: str = "cuda") -> ModelLoader:
"""Build a ModelLoader without going through dependency injection.
`_should_use_fp8` and `_wrap_forward_with_fp8_cast` only depend on `_torch_device`,
so we instantiate via __new__ and set the minimum state directly.
"""
loader = ModelLoader.__new__(ModelLoader)
loader._torch_device = torch.device(device)
loader._torch_dtype = torch.float16
loader._logger = getLogger("test")
return loader
def _make_config(model_type: ModelType, fp8: bool, base: BaseModelType = BaseModelType.Flux):
return SimpleNamespace(
type=model_type,
base=base,
name="test",
default_settings=SimpleNamespace(fp8_storage=fp8),
)
def test_should_use_fp8_excludes_control_lora():
"""ControlLoRA gets the FP8 toggle in the UI history but the LoRA loader never applies
layerwise casting (the model isn't run as a standalone forward pass — it patches into a
base model). The loader must silently ignore a persisted `fp8_storage=true` to avoid
misleading users who toggled it under a prior version.
"""
loader = _make_loader(device="cuda")
with patch("torch.cuda.is_available", return_value=True):
assert loader._should_use_fp8(_make_config(ModelType.ControlLoRa, fp8=True)) is False
def test_should_use_fp8_excludes_lora():
loader = _make_loader(device="cuda")
assert loader._should_use_fp8(_make_config(ModelType.LoRA, fp8=True)) is False
def test_should_use_fp8_returns_true_for_main_with_fp8():
loader = _make_loader(device="cuda")
assert loader._should_use_fp8(_make_config(ModelType.Main, fp8=True)) is True
def test_should_use_fp8_returns_false_for_main_without_fp8():
loader = _make_loader(device="cuda")
assert loader._should_use_fp8(_make_config(ModelType.Main, fp8=False)) is False
def test_should_use_fp8_returns_false_on_cpu():
loader = _make_loader(device="cpu")
assert loader._should_use_fp8(_make_config(ModelType.Main, fp8=True)) is False
class _RaisingModule(torch.nn.Module):
"""A module whose forward unconditionally raises — used to test that the FP8 wrapper's
storage-dtype cleanup runs even when forward fails."""
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(torch.zeros(4))
self.bias = torch.nn.Parameter(torch.zeros(4))
def forward(self, x: torch.Tensor) -> torch.Tensor:
raise RuntimeError("boom")
def _fp8_supported() -> bool:
return hasattr(torch, "float8_e4m3fn")
@pytest.mark.skipif(not _fp8_supported(), reason="torch.float8_e4m3fn not available")
def test_wrap_forward_restores_storage_dtype_on_exception():
"""When forward raises, params must be returned to storage dtype. Otherwise FP8 storage
savings silently revert to fp16/bf16 and the cache's size accounting becomes stale.
"""
storage_dtype = torch.float8_e4m3fn
compute_dtype = torch.bfloat16
module = _RaisingModule()
for p in module.parameters(recurse=False):
p.data = p.data.to(storage_dtype)
ModelLoader._wrap_forward_with_fp8_cast(module, storage_dtype, compute_dtype)
# Sanity: params start in storage dtype.
assert module.weight.dtype == storage_dtype
assert module.bias.dtype == storage_dtype
with pytest.raises(RuntimeError, match="boom"):
module(torch.zeros(4, dtype=compute_dtype))
# Critical assertion: cleanup ran despite the exception.
assert module.weight.dtype == storage_dtype
assert module.bias.dtype == storage_dtype
@pytest.mark.skipif(not _fp8_supported(), reason="torch.float8_e4m3fn not available")
def test_wrap_forward_casts_to_compute_then_back_on_success():
"""Happy-path sanity check: params are in compute dtype during forward, storage dtype after."""
storage_dtype = torch.float8_e4m3fn
compute_dtype = torch.bfloat16
seen_dtypes: list[torch.dtype] = []
class _CaptureModule(torch.nn.Module):
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(torch.zeros(4))
def forward(self, x: torch.Tensor) -> torch.Tensor:
seen_dtypes.append(self.weight.dtype)
return x + self.weight
module = _CaptureModule()
for p in module.parameters(recurse=False):
p.data = p.data.to(storage_dtype)
ModelLoader._wrap_forward_with_fp8_cast(module, storage_dtype, compute_dtype)
module(torch.zeros(4, dtype=compute_dtype))
assert seen_dtypes == [compute_dtype]
assert module.weight.dtype == storage_dtype
def test_apply_fp8_to_nn_module_uses_wrapper():
"""`_apply_fp8_to_nn_module` should delegate per-module wrapping to
`_wrap_forward_with_fp8_cast`, which encapsulates the hook registration.
"""
module = torch.nn.Linear(4, 4)
with patch.object(ModelLoader, "_wrap_forward_with_fp8_cast") as mock_wrap:
ModelLoader._apply_fp8_to_nn_module(module, torch.float16, torch.float32)
mock_wrap.assert_called_once_with(module, torch.float16, torch.float32)
def test_apply_fp8_to_nn_module_skips_norm_modules():
"""Modules whose path matches `norm` must not be cast — diffusers' `enable_layerwise_casting`
does the same. FLUX RMSNorm.scale is the canonical example: a tiny learned scalar that
breaks badly in FP8.
"""
class _Model(torch.nn.Module):
def __init__(self):
super().__init__()
self.norm1 = torch.nn.LayerNorm(4)
self.linear = torch.nn.Linear(4, 4)
storage_dtype = torch.float16
compute_dtype = torch.float32
model = _Model()
for p in model.parameters():
p.data = p.data.to(compute_dtype)
ModelLoader._apply_fp8_to_nn_module(model, storage_dtype, compute_dtype)
# Linear params get cast to storage dtype.
assert model.linear.weight.dtype == storage_dtype
# Norm params stay in compute dtype — they must not be cast.
assert model.norm1.weight.dtype == compute_dtype
assert model.norm1.bias.dtype == compute_dtype
def test_apply_fp8_to_nn_module_skips_pos_embed_and_proj_in_out():
"""Position embeddings and the in/out projection of transformer blocks are also on the
diffusers default skip list — they're precision-sensitive.
"""
class _Model(torch.nn.Module):
def __init__(self):
super().__init__()
self.pos_embed = torch.nn.Linear(4, 4)
self.proj_in = torch.nn.Linear(4, 4)
self.proj_out = torch.nn.Linear(4, 4)
self.attn = torch.nn.Linear(4, 4)
storage_dtype = torch.float16
compute_dtype = torch.float32
model = _Model()
for p in model.parameters():
p.data = p.data.to(compute_dtype)
ModelLoader._apply_fp8_to_nn_module(model, storage_dtype, compute_dtype)
assert model.attn.weight.dtype == storage_dtype
assert model.pos_embed.weight.dtype == compute_dtype
assert model.proj_in.weight.dtype == compute_dtype
assert model.proj_out.weight.dtype == compute_dtype
def test_apply_fp8_to_nn_module_skips_unsupported_layer_types():
"""Only the layer classes in `_FP8_SUPPORTED_PYTORCH_LAYERS` are cast — matches diffusers'
behavior. A custom RMSNorm-style module with a raw Parameter must be left alone, otherwise
its learned scalar gets clobbered.
"""
class _ScaleModule(torch.nn.Module):
"""Mimics FLUX RMSNorm — a tiny learned scalar that must not be cast to FP8."""
def __init__(self):
super().__init__()
self.scale = torch.nn.Parameter(torch.ones(4))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x * self.scale
class _Model(torch.nn.Module):
def __init__(self):
super().__init__()
self.rms = _ScaleModule()
self.linear = torch.nn.Linear(4, 4)
storage_dtype = torch.float16
compute_dtype = torch.float32
model = _Model()
for p in model.parameters():
p.data = p.data.to(compute_dtype)
ModelLoader._apply_fp8_to_nn_module(model, storage_dtype, compute_dtype)
assert model.linear.weight.dtype == storage_dtype
# Critical: the RMS-style scalar lives on a custom module type, not in the supported list.
assert model.rms.scale.dtype == compute_dtype
def test_wrap_forward_reaches_custom_linear_after_apply_custom_layers():
"""Production order: `_load_model` applies FP8 wrapping, THEN `ModelCache.put()` calls
`apply_custom_layers_to_model` which constructs a NEW `CustomLinear` object via
`CustomLinear.__new__` and points its `__dict__` at the original `Linear.__dict__`
(see `wrap_custom_layer`). The new object is installed on the parent in place of the
original Linear.
An instance-level `forward` override would be carried into the new CustomLinear via the
shared dict but would close over the OLD Linear instance — so calls to the new
CustomLinear would silently route to `Linear.forward(old_instance, ...)` and bypass
`CustomLinear.forward`, where LoRA/ControlLoRA patches are applied. This is the bug a
reviewer reproduced on a fresh worktree.
Hooks fix this because `nn.Module._call_impl` dispatches them with the *actual* called
instance, and `self.forward(...)` is resolved by normal class lookup — reaching
`CustomLinear.forward`. This test exercises the production wrapping path (real
`apply_custom_layers_to_model`) and asserts CustomLinear.forward is reached by attaching
a sentinel patch list and observing that the patch-aware branch runs.
"""
class Parent(torch.nn.Module):
def __init__(self):
super().__init__()
self.child = torch.nn.Linear(4, 4, bias=False)
parent = Parent()
original_linear = parent.child
ModelLoader._wrap_forward_with_fp8_cast(original_linear, torch.float16, torch.float32)
apply_custom_layers_to_model(parent)
new_child = parent.child
# Sanity: production wrapping replaced the child with a NEW CustomLinear instance.
assert isinstance(new_child, CustomLinear)
assert new_child is not original_linear
# Attach a sentinel patch so CustomLinear.forward routes through the LoRA-aware branch
# (see custom_linear.py: `if len(self._patches_and_weights) > 0`). If that branch fires,
# our FP8 wrapping is correctly dispatched through CustomLinear.forward.
patch_was_invoked = {"hit": False}
class _SentinelPatch:
def __init__(self):
self.hit = patch_was_invoked
def __call__(self, *_args, **_kwargs): # not actually called
pass
# Patch the CustomLinear's patch-handling branch to record that it was reached.
original_patch_branch = CustomLinear._autocast_forward_with_patches
def tracked_patch_branch(self, input):
patch_was_invoked["hit"] = True
# Return a same-shape tensor so the outer caller doesn't choke.
return torch.zeros_like(input @ self.weight.t())
new_child._patches_and_weights = [(_SentinelPatch(), 1.0)]
try:
CustomLinear._autocast_forward_with_patches = tracked_patch_branch
_ = new_child(torch.zeros(1, 4, dtype=torch.float32))
finally:
CustomLinear._autocast_forward_with_patches = original_patch_branch
new_child._patches_and_weights = []
assert patch_was_invoked["hit"] is True, (
"FP8-wrapped forward did not reach CustomLinear.forward — LoRA/ControlLoRA patches "
"would be silently bypassed on FP8 checkpoint models."
)
def test_apply_fp8_layerwise_casting_uses_hook_path_for_model_mixin():
"""Regression test for the FLUX.2 Klein 9B partial-load device-mismatch crash.
Diffusers' `enable_layerwise_casting()` registers a `LayerwiseCastingHook` whose
`pre_forward` only casts dtype (not device) and whose hook system replaces
`Linear.forward` with a wrapper that calls the *original* `Linear.forward` captured
before the hook was installed. `ModelCache.put()` later wraps Linear as CustomLinear
sharing `__dict__`, so the diffusers wrapper is carried into the new CustomLinear and
routes calls to the captured original Linear.forward — bypassing
`CustomLinear.forward`'s `cast_to_device`. On partial load (some weights on CPU,
input on cuda), this raises a device-mismatch error.
The fix routes ModelMixin through `_apply_fp8_to_nn_module` (hook-based,
`forward`-preserving). This test asserts that path is taken even when the model
inherits from ModelMixin.
"""
from diffusers.models.modeling_utils import ModelMixin
class _FakeModelMixin(ModelMixin):
# ModelMixin requires a config_name class attribute and a config dict for serialization.
# We never serialize, so we only need to satisfy isinstance() checks.
config_name = "config.json"
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4, bias=False)
def forward(self, x):
return self.linear(x)
loader = _make_loader(device="cuda")
config = _make_config(ModelType.Main, fp8=True)
model = _FakeModelMixin()
with (
patch.object(ModelLoader, "_should_use_fp8", return_value=True),
patch.object(ModelLoader, "_apply_fp8_to_nn_module") as mock_to_nn,
patch.object(_FakeModelMixin, "enable_layerwise_casting") as mock_enable,
):
loader._apply_fp8_layerwise_casting(model, config)
mock_to_nn.assert_called_once()
mock_enable.assert_not_called()
@@ -0,0 +1,82 @@
import pytest
import torch
from invokeai.backend.model_manager.load.load_base import LoadedModelWithoutConfig
from invokeai.backend.model_manager.load.model_cache.cache_record import CacheRecord
from invokeai.backend.model_manager.load.model_cache.cached_model.cached_model_only_full_load import (
CachedModelOnlyFullLoad,
)
from invokeai.backend.model_manager.load.model_cache.cached_model.cached_model_with_partial_load import (
CachedModelWithPartialLoad,
)
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.torch_module_autocast import (
apply_custom_layers_to_model,
)
class ModelWithRequiredScale(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4)
self.scale = torch.nn.Parameter(torch.ones(4))
class FakeCache:
def __init__(self):
self.lock_calls = 0
self.unlock_calls = 0
def lock(self, cache_record: CacheRecord, working_mem_bytes: int | None) -> None:
del cache_record, working_mem_bytes
self.lock_calls += 1
def unlock(self, cache_record: CacheRecord) -> None:
del cache_record
self.unlock_calls += 1
def test_model_on_device_repairs_required_tensors_for_partial_models():
model = ModelWithRequiredScale()
apply_custom_layers_to_model(model, device_autocasting_enabled=True)
cached_model = CachedModelWithPartialLoad(model=model, compute_device=torch.device("meta"), keep_ram_copy=False)
loaded_model = LoadedModelWithoutConfig(
cache_record=CacheRecord(key="test", cached_model=cached_model), cache=FakeCache()
)
with loaded_model.model_on_device():
assert model.scale.device.type == "meta"
assert all(param.device.type == "cpu" for param in model.linear.parameters())
def test_model_on_device_leaves_full_load_models_unchanged():
model = torch.nn.Linear(4, 4)
cached_model = CachedModelOnlyFullLoad(
model=model, compute_device=torch.device("meta"), total_bytes=1, keep_ram_copy=False
)
loaded_model = LoadedModelWithoutConfig(
cache_record=CacheRecord(key="test", cached_model=cached_model), cache=FakeCache()
)
with loaded_model.model_on_device() as (_, returned_model):
assert returned_model is model
assert all(param.device.type == "cpu" for param in model.parameters())
def test_enter_unlocks_if_repair_raises():
class BrokenCachedModel(CachedModelWithPartialLoad):
def repair_required_tensors_on_compute_device(self) -> int:
raise RuntimeError("repair failed")
model = ModelWithRequiredScale()
apply_custom_layers_to_model(model, device_autocasting_enabled=True)
cached_model = BrokenCachedModel(model=model, compute_device=torch.device("meta"), keep_ram_copy=False)
fake_cache = FakeCache()
loaded_model = LoadedModelWithoutConfig(
cache_record=CacheRecord(key="test", cached_model=cached_model), cache=fake_cache
)
with pytest.raises(RuntimeError, match="repair failed"):
loaded_model.__enter__()
assert fake_cache.lock_calls == 1
assert fake_cache.unlock_calls == 1
@@ -0,0 +1,169 @@
"""Unit tests for the pure state-dict helpers in the Qwen-Image / Qwen-VL loader.
These freeze the checkpoint key-surgery that the loaders perform before instantiating a model,
so a regression like the transformers-5.x one (where `_checkpoint_conversion_mapping` became
`{}` and the `visual.* -> model.visual.*` remap was silently skipped) fails here instead of at
the user's first load.
"""
import torch
from invokeai.backend.model_manager.load.model_loaders.qwen_image import (
_build_qwen_image_transformer_config,
_dequantize_comfyui_fp8,
_remap_qwen_vl_checkpoint_keys,
_strip_comfyui_prefix,
_strip_quantization_metadata,
)
from tests.backend.model_manager.load.state_dicts.qwen_vl_encoder_comfyui_keys import (
state_dict_keys as qwen_vl_keys,
)
from tests.backend.model_manager.load.state_dicts.utils import keys_to_mock_state_dict
# Prefixes the Qwen2.5-VL architecture (transformers >=4.50) actually exposes. Frozen here on
# purpose: if a remap regresses, converted keys stop matching this set.
_VALID_QWEN_VL_PREFIXES = ("model.visual.", "model.language_model.", "lm_head")
class TestRemapQwenVlCheckpointKeys:
def test_every_key_maps_to_the_transformers_layout(self):
"""Every legacy ComfyUI key must land under `model.visual.*` / `model.language_model.*`."""
sd = keys_to_mock_state_dict(qwen_vl_keys)
# The loader strips fp8 metadata (`scaled_fp8` etc.) before remapping; mirror that order.
_strip_quantization_metadata(sd)
remapped = _remap_qwen_vl_checkpoint_keys(sd)
assert len(remapped) == len(sd)
for key in remapped:
assert key.startswith(_VALID_QWEN_VL_PREFIXES), f"key not remapped to a known layout: {key}"
# No key may survive in the legacy layout.
assert not any(k.startswith("visual.") for k in remapped)
assert not any(k.startswith(("model.layers.", "model.embed_tokens", "model.norm")) for k in remapped)
def test_specific_keys_from_the_bug_report(self):
"""The exact keys that failed to load in the original bug report are remapped."""
sd = {
"visual.blocks.0.attn.qkv.weight": torch.empty(1),
"visual.patch_embed.proj.weight": torch.empty(1),
"model.layers.0.self_attn.q_proj.weight": torch.empty(1),
"model.embed_tokens.weight": torch.empty(1),
"lm_head.weight": torch.empty(1),
}
remapped = _remap_qwen_vl_checkpoint_keys(sd)
assert "model.visual.blocks.0.attn.qkv.weight" in remapped
assert "model.visual.patch_embed.proj.weight" in remapped
assert "model.language_model.layers.0.self_attn.q_proj.weight" in remapped
assert "model.language_model.embed_tokens.weight" in remapped
assert "lm_head.weight" in remapped # unchanged
def test_idempotent_on_already_converted_layout(self):
"""Re-running the remap on new-layout keys must not double-prefix them."""
sd = keys_to_mock_state_dict(qwen_vl_keys)
once = _remap_qwen_vl_checkpoint_keys(sd)
twice = _remap_qwen_vl_checkpoint_keys(once)
assert set(once.keys()) == set(twice.keys())
def test_fallback_when_transformers_mapping_is_empty(self, monkeypatch):
"""Even if transformers stops providing `_checkpoint_conversion_mapping`, the remap fires.
transformers 5.x returns `{}` here; forcing that value pins the fallback that fixes the
original bug.
"""
from transformers import Qwen2_5_VLForConditionalGeneration
monkeypatch.setattr(Qwen2_5_VLForConditionalGeneration, "_checkpoint_conversion_mapping", {})
remapped = _remap_qwen_vl_checkpoint_keys(
{
"visual.blocks.0.norm1.weight": torch.empty(1),
"model.layers.0.input_layernorm.weight": torch.empty(1),
}
)
assert "model.visual.blocks.0.norm1.weight" in remapped
assert "model.language_model.layers.0.input_layernorm.weight" in remapped
class TestStripQuantizationMetadata:
def test_drops_fp8_metadata_keeps_weights(self):
sd = keys_to_mock_state_dict(qwen_vl_keys)
# The captured checkpoint is fp8_scaled, so it really does ship this metadata.
assert any(k.endswith((".scale_weight", ".scale_input")) or k == "scaled_fp8" for k in sd)
n_weights_before = sum(1 for k in sd if k.endswith(".weight"))
_strip_quantization_metadata(sd)
assert not any(
k.endswith((".scale_weight", ".scale_input")) or "comfy_quant" in k or k == "scaled_fp8" for k in sd
)
# Real weights are untouched.
assert sum(1 for k in sd if k.endswith(".weight")) == n_weights_before
class TestDequantizeComfyuiFp8:
def test_scalar_scale(self):
sd = {
"l.weight": torch.full((2, 2), 2.0),
"l.scale_weight": torch.tensor(3.0),
"l.scale_input": torch.tensor(9.0), # activation scale, must be ignored
}
count = _dequantize_comfyui_fp8(sd, torch.float32)
assert count == 1
assert torch.allclose(sd["l.weight"], torch.full((2, 2), 6.0))
def test_block_wise_scale_is_broadcast(self):
# Per-block scale [2, 1] must be repeat_interleaved up to the weight shape [4, 2].
sd = {
"l.weight": torch.ones(4, 2),
"l.weight_scale": torch.tensor([[10.0], [20.0]]),
}
count = _dequantize_comfyui_fp8(sd, torch.float32)
assert count == 1
expected = torch.tensor([[10.0, 10.0], [10.0, 10.0], [20.0, 20.0], [20.0, 20.0]])
assert torch.allclose(sd["l.weight"], expected)
class TestStripComfyuiPrefix:
def test_strips_diffusion_model_prefix(self):
sd = {
"model.diffusion_model.transformer_blocks.0.img_mod.1.weight": torch.empty(1),
"model.diffusion_model.img_in.weight": torch.empty(1),
}
out = _strip_comfyui_prefix(sd)
assert set(out.keys()) == {"transformer_blocks.0.img_mod.1.weight", "img_in.weight"}
def test_no_prefix_is_a_noop(self):
sd = {"transformer_blocks.0.x": torch.empty(1)}
assert _strip_comfyui_prefix(sd) is sd
class TestBuildQwenImageTransformerConfig:
def test_infers_layer_count_and_dims_from_shapes(self):
# torch-order (logical) shapes, as the GGMLTensor.tensor_shape / safetensors path exposes.
sd = {
"img_in.weight": torch.empty(3072, 64),
"txt_in.weight": torch.empty(3072, 3584),
"transformer_blocks.0.img_mod.1.weight": torch.empty(1),
"transformer_blocks.1.img_mod.1.weight": torch.empty(1),
"transformer_blocks.5.img_mod.1.weight": torch.empty(1),
}
cfg = _build_qwen_image_transformer_config(sd, is_edit=False)
assert cfg["num_layers"] == 6 # max block index (5) + 1
assert cfg["in_channels"] == 64
assert cfg["num_attention_heads"] == 24 # 3072 // 128
assert cfg["joint_attention_dim"] == 3584
def test_empty_state_dict_falls_back_to_defaults(self):
cfg = _build_qwen_image_transformer_config({}, is_edit=False)
assert cfg["num_layers"] == 60
@@ -0,0 +1,66 @@
"""Unit tests for the Z-Image GGUF/ComfyUI -> diffusers state-dict converter."""
import torch
from invokeai.backend.model_manager.load.model_loaders.z_image import _convert_z_image_gguf_to_diffusers
from tests.backend.model_manager.load.state_dicts.utils import keys_to_mock_state_dict
from tests.backend.model_manager.load.state_dicts.z_image_transformer_comfyui_keys import (
state_dict_keys as z_image_keys,
)
class TestConvertZImageGgufToDiffusers:
def test_fused_qkv_split(self):
sd = keys_to_mock_state_dict(z_image_keys)
n_qkv = sum(1 for k in sd if k.endswith(".attention.qkv.weight"))
assert n_qkv > 0
out = _convert_z_image_gguf_to_diffusers(sd)
# Each fused qkv weight becomes three separate projections.
assert sum(1 for k in out if k.endswith(".attention.to_q.weight")) == n_qkv
assert sum(1 for k in out if k.endswith(".attention.to_k.weight")) == n_qkv
assert sum(1 for k in out if k.endswith(".attention.to_v.weight")) == n_qkv
assert not any(".attention.qkv." in k for k in out)
def test_key_renames(self):
out = _convert_z_image_gguf_to_diffusers(keys_to_mock_state_dict(z_image_keys))
# q_norm/k_norm -> norm_q/norm_k, attention.out -> attention.to_out.0
assert any(k.endswith(".attention.norm_q.weight") for k in out)
assert any(k.endswith(".attention.norm_k.weight") for k in out)
assert any(k.endswith(".attention.to_out.0.weight") for k in out)
assert not any(".q_norm." in k or ".k_norm." in k for k in out)
assert not any(".attention.out." in k for k in out)
def test_embedder_and_final_layer_renamed(self):
out = _convert_z_image_gguf_to_diffusers(keys_to_mock_state_dict(z_image_keys))
assert any(k.startswith("all_x_embedder.2-1.") for k in out)
assert any(k.startswith("all_final_layer.2-1.") for k in out)
assert not any(k.startswith("x_embedder.") or k.startswith("final_layer.") for k in out)
def test_norm_final_is_dropped(self):
# The diffusers model uses a non-learnable final LayerNorm, so norm_final.* is skipped.
assert any(k.startswith("norm_final.") for k in z_image_keys)
out = _convert_z_image_gguf_to_diffusers(keys_to_mock_state_dict(z_image_keys))
assert not any(k.startswith("norm_final.") for k in out)
def test_pad_tokens_are_2d_after_conversion(self):
# The diffusers model expects a leading batch dim on the pad tokens. The checkpoint
# already stores them 2D; GGUF ships them 1D (see the reshape test below).
out = _convert_z_image_gguf_to_diffusers(keys_to_mock_state_dict(z_image_keys))
for pad in ("x_pad_token", "cap_pad_token"):
assert out[pad].dim() == 2
assert out[pad].shape[0] == 1
def test_1d_pad_token_gains_batch_dim(self):
# GGUF stores pad tokens as [dim]; they must be reshaped to [1, dim].
out = _convert_z_image_gguf_to_diffusers({"x_pad_token": torch.arange(4.0)})
assert out["x_pad_token"].shape == (1, 4)
def test_qkv_split_preserves_values(self):
# A [6, 2] fused qkv splits into three [2, 2] chunks in order q, k, v.
qkv = torch.arange(12, dtype=torch.float32).reshape(6, 2)
out = _convert_z_image_gguf_to_diffusers({"blk.attention.qkv.weight": qkv})
assert torch.allclose(out["blk.attention.to_q.weight"], qkv[0:2])
assert torch.allclose(out["blk.attention.to_k.weight"], qkv[2:4])
assert torch.allclose(out["blk.attention.to_v.weight"], qkv[4:6])