220 lines
7.7 KiB
Python
220 lines
7.7 KiB
Python
import importlib
|
|
import sys
|
|
import types
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import torch
|
|
from unifiedefficientloader import IncrementalSafetensorsWriter, MemoryEfficientSafeOpen
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
PACKAGE_NAME = "modelutils_adaptive_extraction_tests"
|
|
|
|
|
|
def _load_module(name):
|
|
if PACKAGE_NAME not in sys.modules:
|
|
package = types.ModuleType(PACKAGE_NAME)
|
|
package.__path__ = [str(REPO_ROOT)]
|
|
sys.modules[PACKAGE_NAME] = package
|
|
return importlib.import_module(f"{PACKAGE_NAME}.{name}")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def adaptive():
|
|
return _load_module("nodes.adaptive_svd")
|
|
|
|
|
|
def _exact_partial(weight, q, niter):
|
|
del niter
|
|
U, S, Vh = torch.linalg.svd(weight, full_matrices=False)
|
|
return U[:, :q], S[:q], Vh[:q, :].T
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode", "parameter", "expected"),
|
|
[("ratio", 2.0, 2), ("quantile", 0.7, 2), ("sv_fro", 0.8, 1)],
|
|
)
|
|
def test_adaptive_partial_rank_matches_reference(monkeypatch, adaptive, mode, parameter, expected):
|
|
monkeypatch.setattr(adaptive.torch, "svd_lowrank", _exact_partial)
|
|
weight = torch.diag(torch.tensor([8.0, 5.0, 2.0, 1.0, 0.5, 0.25]))
|
|
_, _, _, rank = adaptive.adaptive_partial_svd(
|
|
weight, mode, parameter, max_rank=4, probe_offset=1, niter=2
|
|
)
|
|
assert rank == expected
|
|
|
|
|
|
def test_unresolved_ratio_expands_and_caps(monkeypatch, adaptive):
|
|
probes = []
|
|
|
|
def tracked(weight, q, niter):
|
|
probes.append(q)
|
|
return _exact_partial(weight, q, niter)
|
|
|
|
monkeypatch.setattr(adaptive.torch, "svd_lowrank", tracked)
|
|
weight = torch.diag(torch.linspace(10.0, 8.0, 12))
|
|
_, _, _, rank = adaptive.adaptive_partial_svd(
|
|
weight, "ratio", 100.0, max_rank=4, probe_offset=2, niter=2
|
|
)
|
|
assert probes == [6, 8]
|
|
assert rank == 4
|
|
|
|
|
|
def test_frobenius_uses_energy_outside_output_cap(monkeypatch, adaptive):
|
|
monkeypatch.setattr(adaptive.torch, "svd_lowrank", _exact_partial)
|
|
weight = torch.eye(10)
|
|
_, _, _, rank = adaptive.adaptive_partial_svd(
|
|
weight, "sv_fro", 0.9, max_rank=3, probe_offset=2, niter=2
|
|
)
|
|
assert rank == 3
|
|
|
|
|
|
class _FakeStream:
|
|
def __init__(self, batches):
|
|
self._batches = iter(batches)
|
|
self.closed = False
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def __next__(self):
|
|
return next(self._batches)
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self, values):
|
|
self.values = values
|
|
self.calls = []
|
|
self.marked = []
|
|
self.streams = []
|
|
|
|
def async_stream(self, keys, **kwargs):
|
|
self.calls.append((tuple(keys), kwargs))
|
|
stream = _FakeStream([[(key, self.values[key])] for key in keys])
|
|
self.streams.append(stream)
|
|
return stream
|
|
|
|
def mark_processed(self, key):
|
|
self.marked.append(key)
|
|
|
|
|
|
def test_paired_stream_is_bounded_ordered_and_releases_once():
|
|
streaming = _load_module("nodes.extraction_stream")
|
|
handler_a = _FakeHandler({"a1": torch.tensor(1), "a2": torch.tensor(2)})
|
|
handler_b = _FakeHandler({"b1": torch.tensor(3)})
|
|
units = [("one", "a1", "b1"), ("two", "a2", None)]
|
|
|
|
observed = []
|
|
for key, tensor_a, tensor_b in streaming.paired_async_tensors(
|
|
handler_a, handler_b, units, pin_memory=True
|
|
):
|
|
observed.append((key, tensor_a.item(), None if tensor_b is None else tensor_b.item()))
|
|
|
|
assert observed == [("one", 1, 3), ("two", 2, None)]
|
|
assert handler_a.calls == [(('a1', 'a2'), {"batch_size": 1, "prefetch_batches": 1, "pin_memory": True})]
|
|
assert handler_b.calls == [(('b1',), {"batch_size": 1, "prefetch_batches": 1, "pin_memory": True})]
|
|
assert Counter(handler_a.marked) == Counter({"a1": 1, "a2": 1})
|
|
assert Counter(handler_b.marked) == Counter({"b1": 1})
|
|
assert all(stream.closed for stream in handler_a.streams + handler_b.streams)
|
|
|
|
|
|
def test_cuda_oom_compute_retries_on_cpu(monkeypatch):
|
|
streaming = _load_module("nodes.extraction_stream")
|
|
monkeypatch.setattr(streaming, "release_cuda_oom", lambda: None)
|
|
calls = []
|
|
|
|
def fail_cuda():
|
|
calls.append("cuda")
|
|
raise RuntimeError("CUDA out of memory")
|
|
|
|
def run_cpu():
|
|
calls.append("cpu")
|
|
return 7
|
|
|
|
assert streaming.retry_cuda_oom_on_cpu(fail_cuda, run_cpu, "cuda:0") == 7
|
|
assert calls == ["cuda", "cpu"]
|
|
|
|
|
|
def test_adaptive_controls_cover_all_public_families():
|
|
modules_and_nodes = [
|
|
(_load_module("nodes.lora_extract_svd"), prefix)
|
|
for prefix in ("LoRAExtractRatio", "LoRAExtractQuantile", "LoRAExtractFrobenius")
|
|
]
|
|
modules_and_nodes += [
|
|
(_load_module("nodes.dora_extract_wd"), prefix)
|
|
for prefix in ("DoRAExtractRatio", "DoRAExtractQuantile", "DoRAExtractFrobenius")
|
|
]
|
|
modules_and_nodes += [
|
|
(_load_module("nodes.dora_learned_wd"), prefix)
|
|
for prefix in ("DoRALearnedExtractRatio", "DoRALearnedExtractQuantile", "DoRALearnedExtractFrobenius")
|
|
]
|
|
modules_and_nodes += [
|
|
(_load_module("nodes.text_encoder_extract"), prefix)
|
|
for prefix in (
|
|
"TextEncoderLoRAExtractRatio", "TextEncoderLoRAExtractQuantile",
|
|
"TextEncoderLoRAExtractFrobenius", "TextEncoderDoRAExtractRatio",
|
|
"TextEncoderDoRAExtractQuantile", "TextEncoderDoRAExtractFrobenius",
|
|
)
|
|
]
|
|
|
|
for module, node_name in modules_and_nodes:
|
|
names = [item.id for item in getattr(module, node_name).define_schema().inputs]
|
|
probe_index = names.index("probe_offset")
|
|
assert names[probe_index + 1] == "linear_max_rank"
|
|
|
|
|
|
def _write_uel(path, tensors):
|
|
with IncrementalSafetensorsWriter(str(path), max_workers=1) as writer:
|
|
writer.write_dict(tensors)
|
|
|
|
|
|
def test_lora_extraction_uses_only_async_uel_and_releases_sources(monkeypatch, tmp_path):
|
|
extraction = _load_module("nodes.lora_extract_svd")
|
|
model_a = tmp_path / "a.safetensors"
|
|
model_b = tmp_path / "b.safetensors"
|
|
output = tmp_path / "out.safetensors"
|
|
key = "model.diffusion_model.layer.weight"
|
|
_write_uel(model_a, {key: torch.eye(3)})
|
|
_write_uel(model_b, {key: torch.zeros(3, 3)})
|
|
monkeypatch.setattr(extraction, "prepare_for_large_operation", lambda *args: None)
|
|
monkeypatch.setattr(extraction, "cleanup_after_operation", lambda: None)
|
|
original_async = extraction.MemoryEfficientSafeOpen.async_stream
|
|
original_mark = extraction.MemoryEfficientSafeOpen.mark_processed
|
|
async_calls = []
|
|
marked = []
|
|
|
|
def track_async(self, keys, **kwargs):
|
|
async_calls.append((self.filename, tuple(keys), kwargs))
|
|
yield from original_async(self, keys, **kwargs)
|
|
|
|
def track_mark(self, source_key):
|
|
marked.append((self.filename, source_key))
|
|
return original_mark(self, source_key)
|
|
|
|
monkeypatch.setattr(extraction.MemoryEfficientSafeOpen, "async_stream", track_async)
|
|
monkeypatch.setattr(extraction.MemoryEfficientSafeOpen, "mark_processed", track_mark)
|
|
monkeypatch.setattr(
|
|
extraction.MemoryEfficientSafeOpen,
|
|
"get_tensor",
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("synchronous tensor load")),
|
|
)
|
|
|
|
extraction.extract_lora_from_files(
|
|
str(model_a), str(model_b), "fixed", 1, 1, "cpu", "fp32", str(output),
|
|
linear_max_rank=1, conv_max_rank=1, force_clear_cache=False,
|
|
)
|
|
|
|
assert output.is_file()
|
|
assert len(async_calls) == 2
|
|
assert all(options == {"batch_size": 1, "prefetch_batches": 1, "pin_memory": False} for _, _, options in async_calls)
|
|
assert Counter(marked) == Counter((filename, key) for filename, keys, _ in async_calls for key in keys)
|
|
with MemoryEfficientSafeOpen(str(output), low_memory=True) as result:
|
|
assert sorted(result.keys()) == [
|
|
"diffusion_model.layer.lora_A.weight",
|
|
"diffusion_model.layer.lora_B.weight",
|
|
]
|