mirror of
https://github.com/huggingface/diffusers.git
synced 2026-10-23 09:09:01 +03:00
280 lines
12 KiB
Python
280 lines
12 KiB
Python
# coding=utf-8
|
|
# Copyright 2026 HuggingFace Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import gc
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from diffusers import ComponentsManager, ModularPipeline
|
|
from diffusers.hooks import apply_group_offloading
|
|
from diffusers.utils import is_accelerate_available
|
|
|
|
from ...testing_utils import (
|
|
backend_empty_cache,
|
|
is_cpu_offload,
|
|
is_group_offload,
|
|
is_memory,
|
|
require_accelerate,
|
|
require_accelerator,
|
|
torch_device,
|
|
)
|
|
from .common import BaseModularPipelineOutputMixin
|
|
from .utils import backend_memory_allocated, patch_free_memory
|
|
|
|
|
|
if is_accelerate_available():
|
|
from diffusers.modular_pipelines.components_manager import AutoOffloadStrategy
|
|
|
|
|
|
# More free memory than any tiny test checkpoint could ever need, so the strategy never
|
|
# decides to offload. Used to assert the *negative*: no eviction without memory pressure.
|
|
_AMPLE_FREE_BYTES = 1024**4
|
|
|
|
|
|
@is_cpu_offload
|
|
class ModularOffloadTesterMixin(BaseModularPipelineOutputMixin):
|
|
"""Auto CPU offload driven by a `ComponentsManager`: inference stays correct, and unloading a component
|
|
re-applies the offload hooks to the ones that are left."""
|
|
|
|
@require_accelerator
|
|
def test_components_auto_cpu_offload_inference_consistent(self, base_pipe_output):
|
|
cm = ComponentsManager()
|
|
cm.enable_auto_cpu_offload(device=torch_device)
|
|
offload_pipe = self.get_pipeline(components_manager=cm)
|
|
|
|
image = offload_pipe(**self.get_dummy_inputs(), output=self.output_name)
|
|
|
|
expected_slice = base_pipe_output[0, -3:, -3:, -1].flatten()
|
|
image_slice = image[0, -3:, -3:, -1].flatten()
|
|
assert torch.abs(expected_slice - image_slice).max() < 1e-3
|
|
|
|
@require_accelerator
|
|
def test_unload_components_auto_cpu_offload(self, base_pipe_output):
|
|
cm = ComponentsManager()
|
|
cm.enable_auto_cpu_offload(device=torch_device)
|
|
pipe = self.get_pipeline(components_manager=cm)
|
|
name = next(
|
|
name for name in pipe.pretrained_component_names if isinstance(pipe.components.get(name), torch.nn.Module)
|
|
)
|
|
component_id = f"{name}_{id(pipe.components[name])}"
|
|
|
|
pipe.unload_components(name)
|
|
|
|
# removing a component re-applies auto offload to the ones that are left
|
|
assert component_id not in cm.components
|
|
assert component_id not in {hook.model_id for hook in cm.model_hooks}
|
|
remaining = [component for component in cm.components.values() if isinstance(component, torch.nn.Module)]
|
|
assert all(hasattr(component, "_hf_hook") for component in remaining)
|
|
|
|
# the reloaded component is hooked up again, so the pipeline still runs
|
|
pipe.load_components(names=name, dtype=torch.float32)
|
|
image = pipe(**self.get_dummy_inputs(), output=self.output_name)
|
|
assert torch.abs(base_pipe_output - image).max() < 1e-3
|
|
|
|
|
|
@is_group_offload
|
|
class ModularGroupOffloadTesterMixin(BaseModularPipelineOutputMixin):
|
|
"""Group offloading applied to the pipeline's components."""
|
|
|
|
@require_accelerator
|
|
def test_group_offloading_execution_device(self):
|
|
pipe = self.get_pipeline().to("cpu")
|
|
assert pipe._execution_device.type == "cpu"
|
|
|
|
offloaded = None
|
|
for name, component in pipe.components.items():
|
|
if not isinstance(component, torch.nn.Module):
|
|
continue
|
|
if not getattr(component, "_supports_group_offloading", True):
|
|
continue
|
|
apply_group_offloading(
|
|
component,
|
|
onload_device=torch.device(torch_device),
|
|
offload_device=torch.device("cpu"),
|
|
offload_type="leaf_level",
|
|
)
|
|
offloaded = name
|
|
break
|
|
|
|
if offloaded is None:
|
|
pytest.skip("No component supports group offloading.")
|
|
|
|
assert pipe._execution_device.type == torch.device(torch_device).type
|
|
|
|
|
|
@is_memory
|
|
class ModularMemoryTesterMixin(ModularOffloadTesterMixin, ModularGroupOffloadTesterMixin):
|
|
"""Combined mixin for the memory optimizations every modular pipeline is expected to support: auto CPU offload,
|
|
group offload, and reclaiming device memory on `unload_components`."""
|
|
|
|
@require_accelerator
|
|
def test_unload_components_frees_device_memory(self):
|
|
pipe = ModularPipeline.from_pretrained(self.pretrained_model_name_or_path)
|
|
pipe.load_components(dtype=torch.float32)
|
|
name = next(
|
|
name for name in pipe.pretrained_component_names if isinstance(pipe.components.get(name), torch.nn.Module)
|
|
)
|
|
pipe.to(torch_device)
|
|
|
|
component = pipe.components[name]
|
|
footprint = sum(t.numel() * t.element_size() for t in [*component.parameters(), *component.buffers()])
|
|
del component
|
|
|
|
gc.collect()
|
|
backend_empty_cache(torch_device)
|
|
allocated_before = backend_memory_allocated(torch_device)
|
|
|
|
pipe.unload_components(name)
|
|
freed = allocated_before - backend_memory_allocated(torch_device)
|
|
|
|
assert freed >= 0.9 * footprint, (
|
|
f"Unloading '{name}' freed {freed} bytes on {torch_device}, expected around {footprint}"
|
|
)
|
|
|
|
|
|
@is_cpu_offload
|
|
class ModularAutoOffloadTesterMixin(BaseModularPipelineOutputMixin):
|
|
"""
|
|
Auto-CPU-offload *decisions* for a modular pipeline's components, driven by simulated device memory.
|
|
|
|
Opt-in on top of `ModularMemoryTesterMixin`: these tests spy on `AutoOffloadStrategy` to assert how models are
|
|
sequenced onto the device, which is only meaningful for pipelines with several offloadable model components.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _managed_models(cm):
|
|
"""The registered components that the offloader actually manages (parameterized
|
|
`nn.Module`s)."""
|
|
models = []
|
|
for component in cm.components.values():
|
|
if isinstance(component, torch.nn.Module) and next(component.parameters(), None) is not None:
|
|
models.append(component)
|
|
return models
|
|
|
|
@staticmethod
|
|
def _is_resident(model):
|
|
return next(model.parameters()).device.type == torch.device(torch_device).type
|
|
|
|
def _run_offloaded(self, free_bytes):
|
|
"""
|
|
Run the pipeline with auto offload on and `free_bytes` of *simulated* device
|
|
memory, recording every offload decision the strategy makes.
|
|
|
|
Each record is `{"incoming", "resident_before", "offloaded"}` (lists of model
|
|
ids), captured by spying on `AutoOffloadStrategy.__call__`, which the hooks call
|
|
each time a model is about to be moved onto the device.
|
|
"""
|
|
cm = ComponentsManager()
|
|
pipe = self.get_pipeline(components_manager=cm)
|
|
cm.enable_auto_cpu_offload(device=torch_device, memory_reserve_margin=0)
|
|
|
|
records = []
|
|
original_call = AutoOffloadStrategy.__call__
|
|
|
|
def spy_call(strategy, hooks, model_id, model, execution_device):
|
|
selected = original_call(
|
|
strategy, hooks=hooks, model_id=model_id, model=model, execution_device=execution_device
|
|
)
|
|
records.append(
|
|
{
|
|
"incoming": model_id,
|
|
"resident_before": [hook.model_id for hook in hooks],
|
|
"offloaded": [hook.model_id for hook in selected],
|
|
}
|
|
)
|
|
return selected
|
|
|
|
with patch_free_memory(free_bytes), mock.patch.object(AutoOffloadStrategy, "__call__", spy_call):
|
|
output = pipe(**self.get_dummy_inputs(), output=self.output_name)
|
|
return cm, records, output
|
|
|
|
@staticmethod
|
|
def _peak_co_residency(records):
|
|
"""
|
|
Largest number of models simultaneously on the device, reconstructed from the
|
|
strategy's view of residency just before each load.
|
|
"""
|
|
peak = 0
|
|
for record in records:
|
|
resident = (set(record["resident_before"]) - set(record["offloaded"])) | {record["incoming"]}
|
|
peak = max(peak, len(resident))
|
|
return peak
|
|
|
|
@require_accelerate
|
|
@require_accelerator
|
|
def test_auto_cpu_offload_serializes_models_under_memory_pressure(self):
|
|
# Zero simulated free memory: every model that runs must first evict whatever is
|
|
# currently resident (comfy-style serialized execution).
|
|
cm, records, _ = self._run_offloaded(free_bytes=0)
|
|
try:
|
|
distinct_models = {record["incoming"] for record in records}
|
|
if len(distinct_models) < 2:
|
|
pytest.skip("pipeline has fewer than two offloadable model components")
|
|
|
|
# Offloading actually fired (at least one eviction happened).
|
|
assert any(record["offloaded"] for record in records), "expected at least one eviction"
|
|
|
|
# Sequencing: models run one at a time, never two co-resident on the device.
|
|
peak = self._peak_co_residency(records)
|
|
assert peak == 1, f"expected serialized execution under pressure, saw {peak} models co-resident"
|
|
|
|
# Device placement after the run: at most the last-run model stays on the
|
|
# accelerator, and at least one managed model was pushed back to the CPU.
|
|
models = self._managed_models(cm)
|
|
resident = [m for m in models if self._is_resident(m)]
|
|
assert len(resident) <= 1
|
|
assert any(not self._is_resident(m) for m in models), "expected some model offloaded to CPU"
|
|
finally:
|
|
cm.disable_auto_cpu_offload()
|
|
|
|
@require_accelerate
|
|
@require_accelerator
|
|
def test_auto_cpu_offload_keeps_models_resident_without_memory_pressure(self):
|
|
# Negative case: with ample simulated memory the strategy is still consulted on
|
|
# every load, but it must never decide to evict anything.
|
|
cm, records, _ = self._run_offloaded(free_bytes=_AMPLE_FREE_BYTES)
|
|
try:
|
|
distinct_models = {record["incoming"] for record in records}
|
|
if len(distinct_models) < 2:
|
|
pytest.skip("pipeline has fewer than two offloadable model components")
|
|
|
|
# Nothing was ever offloaded...
|
|
assert all(record["offloaded"] == [] for record in records), "no model should be evicted"
|
|
|
|
# ...and models accumulate on the device instead of being serialized.
|
|
peak = self._peak_co_residency(records)
|
|
assert peak >= 2, f"expected models to co-reside without pressure, saw peak {peak}"
|
|
|
|
models = self._managed_models(cm)
|
|
assert sum(self._is_resident(m) for m in models) >= 2, "expected multiple models resident on device"
|
|
finally:
|
|
cm.disable_auto_cpu_offload()
|
|
|
|
@require_accelerate
|
|
@require_accelerator
|
|
def test_auto_cpu_offload_inference_consistent_under_memory_pressure(
|
|
self, base_pipe_output, expected_max_diff=1e-3
|
|
):
|
|
# Sensible results: forcing offload (zero simulated free memory) must not change
|
|
# the output relative to an ordinary, non-offloaded run.
|
|
cm, _, offloaded = self._run_offloaded(free_bytes=0)
|
|
try:
|
|
max_diff = torch.abs(base_pipe_output - offloaded).max()
|
|
assert max_diff < expected_max_diff, f"offloaded output diverged from baseline (max diff {max_diff})"
|
|
finally:
|
|
cm.disable_auto_cpu_offload()
|