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
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:
+143
@@ -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"
|
||||
+341
@@ -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"))
|
||||
+47
@@ -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
|
||||
+763
@@ -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()
|
||||
+31
@@ -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)
|
||||
+82
@@ -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)
|
||||
+108
@@ -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)
|
||||
+132
@@ -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)
|
||||
Reference in New Issue
Block a user