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)