Files
automatic/modules/sd_offload_group.py
Vladimir Mandic 0963bf54ac reduce group offload logging
Signed-off-by: Vladimir Mandic <mandic00@live.com>
2026-08-16 12:45:50 +02:00

255 lines
14 KiB
Python

import time
import itertools
import torch
import accelerate.hooks
import accelerate.utils.modeling
from modules.logger import log
from modules import shared, devices, sd_models
from modules.timer import process as process_timer
from modules.sd_offload_utils import get_pipe_variants, get_module_names, get_module_size, set_accelerate, offload_excluded, report_model_stats
import modules.sd_offload_state as s
def group_offload_config(main: bool) -> dict:
"""Effective group offload settings for one component. Components that run once per
generation take the leaf no-stream policy regardless of the main settings, so their
weights are never held in pinned host memory."""
stream = shared.opts.group_offload_stream if main else False
blocks = max(1, int(shared.opts.group_offload_blocks))
if stream and blocks != 1:
blocks = 1 # streamed prefetch supports one block per group; upstream clamps with a warning otherwise
return {
'offload_type': shared.opts.group_offload_type if main else 'leaf_level',
'num_blocks_per_group': blocks,
'non_blocking': shared.opts.diffusers_offload_nonblocking,
'use_stream': stream,
'record_stream': shared.opts.group_offload_record and stream, # record without streams is rejected upstream
'low_cpu_mem_usage': stream and not shared.opts.group_offload_pin,
}
def remove_group_offload_component(module) -> bool:
if getattr(module, 'sdnext_group_offload_sig', None) is None:
module = getattr(module, 'model', None) # wrapper components carry the hooks on the inner model
if module is None or getattr(module, 'sdnext_group_offload_sig', None) is None:
return False
from diffusers.hooks.group_offloading import _GROUP_OFFLOADING, _LAYER_EXECUTION_TRACKER, _LAZY_PREFETCH_GROUP_OFFLOADING
from diffusers.hooks.hooks import HookRegistry
registry = HookRegistry.check_if_exists_or_initialize(module)
registry.remove_hook(_GROUP_OFFLOADING, recurse=True)
registry.remove_hook(_LAYER_EXECUTION_TRACKER, recurse=True)
registry.remove_hook(_LAZY_PREFETCH_GROUP_OFFLOADING, recurse=True)
module.sdnext_group_offload_sig = None
return True
def remove_group_offload(sd_model):
removed = []
for module_name in get_module_names(sd_model):
module = getattr(sd_model, module_name, None)
if isinstance(module, torch.nn.Module) and remove_group_offload_component(module):
removed.append(module_name)
for module_name in getattr(sd_model, 'sdnext_ondemand_modules', None) or []:
module = getattr(sd_model, module_name, None)
if module is not None:
module.sdnext_ondemand = False
if hasattr(module, '_hf_hook'):
module = accelerate.hooks.remove_hook_from_module(module, recurse=True)
removed.append(f'{module_name}:ondemand')
if getattr(sd_model, 'sdnext_ondemand_modules', None):
sd_model.sdnext_ondemand_modules = []
if removed:
log.debug(f'Offload: type=group op=remove modules={removed}')
def apply_group_offload_component(module, module_name: str, main: bool) -> bool:
"""Apply group offload to one component. Re-application with unchanged settings is a no-op:
the hooks silently keep their original config when re-applied and raise before the first
forward, so a changed config must remove the old hooks first."""
from diffusers.hooks import apply_group_offloading
cfg = group_offload_config(main)
if cfg['use_stream'] and not cfg['low_cpu_mem_usage']:
size_gb, _params = get_module_size(module)
pin_ok = getattr(module, 'sdnext_group_offload_pin', None)
if pin_ok is None: # decide once per module: a granted pin moves the weights into locked memory, so re-reading available on the next apply would see it lower by the pinned size and revoke its own grant
from modules import memstats
avail_gb = memstats.ram_stats().get('avail', 0)
reserve_gb = max(8.0, 0.25 * shared.cpu_memory) # pinned pages cannot be reclaimed or swapped, so a quarter of the machine, floored at 8 GB, stays pageable for the process and page cache
limit_gb = (avail_gb - reserve_gb) if avail_gb > 0 else (0.5 * shared.cpu_memory) # budget from memory free right now; total-derived ceiling only when psutil cannot say
pin_ok = size_gb <= limit_gb
module.sdnext_group_offload_pin = pin_ok
module.sdnext_group_offload_pin_limit = limit_gb
if not pin_ok:
# unpinned streaming degrades to per-transfer staging and leaf groups make that a per-module cost,
# so the whole leaf+stream shape goes with the pin: few large synchronous groups instead
cfg['low_cpu_mem_usage'] = True
cfg['use_stream'] = False
cfg['record_stream'] = False
cfg['offload_type'] = 'block_level'
cfg['num_blocks_per_group'] = max(4, int(shared.opts.group_offload_blocks))
log.warning(f'Offload: type=group module={module_name} size={size_gb:.3f} limit={getattr(module, "sdnext_group_offload_pin_limit", 0):.3f} pin=denied type=block_level blocks={cfg["num_blocks_per_group"]} expect ~{size_gb:.0f} GB transferred per step')
sig = f'{devices.device}:{main}:' + ':'.join(str(v) for v in cfg.values())
if getattr(module, 'sdnext_group_offload_sig', None) == sig:
return False
if hasattr(module, '_hf_hook'): # leftover accelerate hooks from a previous offload mode abort the group apply upstream
module = accelerate.hooks.remove_hook_from_module(module, recurse=True)
module.sdnext_ondemand = False # group placement replaces any on-demand hook
remove_group_offload_component(module)
module.requires_grad_(False)
s.debug_move(f'Offload: type=group op=apply type={shared.opts.group_offload_type} module={module_name} pin={cfg["use_stream"] and not cfg["low_cpu_mem_usage"]}') # before the apply: pinning large components takes a while and would otherwise run silently
module.sdnext_group_offload_sig = 'partial' # a raise below leaves hooks that only a non-empty signature will remove
apply_group_offloading(module, onload_device=devices.device, offload_device=devices.cpu, **cfg)
module.sdnext_group_offload_sig = sig
return True
def set_group_resident(module) -> bool:
"""Keep a component on the accelerator with no hooks of any kind."""
changed = False
if hasattr(module, '_hf_hook'):
module = accelerate.hooks.remove_hook_from_module(module, recurse=True)
changed = True
if remove_group_offload_component(module):
changed = True
module.sdnext_ondemand = False
module.requires_grad_(False)
if any(not devices.same_device(t.device, devices.device) for t in itertools.chain(module.parameters(), module.buffers())): # an interrupted generation can leave a group-hooked module split across devices
module.to(devices.device)
changed = True
return changed
def group_offload_role(module_name: str, module) -> str:
"""Placement role for one component: resident to stay put, ondemand for whole-module onload, main for per-step denoisers, aux for the rest."""
if offload_excluded(module_name, module):
return 'resident'
if has_entry_bridge(module):
return 'ondemand' # encode and decode bypass the forward that group hooks scope to
if callable(getattr(module, 'encode', None)) or callable(getattr(module, 'decode', None)):
s.debug_move(f'Offload: type=group module={module_name} cls={module.__class__.__name__} bridge=missing role=resident') # decorate the entry points with apply_forward_hook to make the component offloadable
return 'resident' # nothing fires an onload for an undecorated entry point, so any hook placement strands the weights on cpu
if not getattr(module, '_supports_group_offloading', True):
return 'ondemand' # upstream marks modules that read submodule weights outside those submodules' forward
if module_name in s.group_offload_main:
return 'main'
return 'aux'
def has_entry_bridge(module) -> bool:
"""Entry points decorated with diffusers' apply_forward_hook fire _hf_hook.pre_forward,
which is what carries the on-demand onload for encode and decode calls that bypass forward."""
for name in ('decode', 'encode'):
fn = getattr(module, name, None)
if fn is not None and getattr(fn, '__qualname__', '').startswith('apply_forward_hook'):
return True
return False
class OnDemandHook(accelerate.hooks.ModelHook):
"""Whole-module onload for components entered through decode or encode rather than forward.
Tiled calls re-enter inside one entry point, so the module is on device before the first
tile; the return to cpu happens at the processing seams once outputs are materialized."""
def pre_forward(self, module, *args, **kwargs):
param = next(module.parameters(), None)
if param is not None and not devices.same_device(param.device, devices.device):
t0 = time.time()
module.to(devices.device, non_blocking=shared.opts.diffusers_offload_nonblocking)
t1 = time.time()
process_timer.add('onload', t1 - t0)
s.debug_move(f'Offload: type=ondemand op=onload module={module.__class__.__name__} nonblocking={shared.opts.diffusers_offload_nonblocking} time={t1 - t0:.3f}') # working so no need to log
return args, kwargs
def apply_group_offload_ondemand(module) -> bool:
"""Placement for components that never take group hooks: they onload whole at their entry point."""
if getattr(module, 'sdnext_ondemand', False) and hasattr(module, '_hf_hook'):
return False
if hasattr(module, '_hf_hook'):
module = accelerate.hooks.remove_hook_from_module(module, recurse=True)
remove_group_offload_component(module)
module.requires_grad_(False)
accelerate.hooks.add_hook_to_module(module, OnDemandHook(), append=False)
module.sdnext_ondemand = True
module.to(devices.cpu)
return True
def offload_ondemand(sd_model, include=[], exclude=[], reason='', force=False):
"""Return on-demand components to cpu once their outputs are materialized."""
if sd_model is None:
return
moved = []
for pipe in get_pipe_variants(sd_model):
names = get_module_names(pipe) if force else (getattr(pipe, 'sdnext_ondemand_modules', None) or []) # force enumerates the pipe rather than the list a load pass left on it
for module_name in names:
if include and module_name not in include:
continue
if exclude and module_name in exclude:
continue
module = getattr(pipe, module_name, None)
if not isinstance(module, torch.nn.Module) or not getattr(module, 'sdnext_ondemand', False):
continue # nothing else has an onload to bring it back
param = next(module.parameters(), None)
if param is None or devices.same_device(param.device, devices.cpu):
continue
try:
t0 = time.time()
module.to(devices.cpu, non_blocking=shared.opts.diffusers_offload_nonblocking)
dt = time.time() - t0
process_timer.add('offload', dt)
moved.append(module_name)
s.debug_move(f'Offload: type=ondemand op=offload module={module_name} nonblocking={shared.opts.diffusers_offload_nonblocking} reason="{reason}" time={dt:.3f}')
except Exception as e:
log.warning(f'Offload: type=ondemand op=offload module={module_name} {e}')
if moved:
devices.torch_gc(reason='ondemand')
def report_group_stats(sd_model, module_names):
"""Per-component stats block once per loaded model; balanced mode prints its own from the hook map."""
checkpoint_name = sd_model.sd_checkpoint_info.name if getattr(sd_model, "sd_checkpoint_info", None) is not None else sd_model.__class__.__name__
if checkpoint_name in s.group_stats_reported: # keyed by checkpoint since a task switch rebuilds the pipe object
return
s.group_stats_reported.add(checkpoint_name)
total = 0.0
counted = []
for module_name in module_names:
module = getattr(sd_model, module_name, None)
if isinstance(module, torch.nn.Module):
total += get_module_size(module)[0]
counted.append(module_name)
report_model_stats(module_name, module)
log.info(f'Model class={sd_model.__class__.__name__} modules={len(counted)} size={total:.3f}')
def apply_group_offload(sd_model):
"""Per-component group offload for classic and modular pipelines."""
changed = False
placements = []
module_names = get_module_names(sd_model)
for module_name in module_names:
module = getattr(sd_model, module_name, None)
if not isinstance(module, torch.nn.Module):
continue
try:
role = group_offload_role(module_name, module)
placements.append(f'{module_name}:{role}')
if role == 'resident':
applied = set_group_resident(module)
elif role == 'ondemand':
applied = apply_group_offload_ondemand(module)
else:
applied = apply_group_offload_component(module, module_name, main=role == 'main')
changed = changed or applied
except Exception as e:
log.error(f'Offload: type=group module={module_name} {e}')
sd_model.sdnext_ondemand_modules = [name for name in module_names if getattr(getattr(sd_model, name, None), 'sdnext_ondemand', False)]
if sd_models.get_diffusers_task(sd_model) != sd_models.DiffusersTaskType.MODULAR: # group hooks are not accelerate hooks, so modular pipelines stay unstamped
set_accelerate(sd_model)
if changed:
log.info(f'Offload: type=group modules={placements}')
else:
log.debug(f'Offload: type=group modules={placements}')
report_group_stats(sd_model, module_names)
return sd_model