mirror of
https://github.com/vladmandic/automatic
synced 2026-09-19 01:04:32 +02:00
Merge pull request #5067 from vladmandic/feat/lora-sdnq-cache
perf(lora): factor cache and hosting improvements for sdnq exact apply
This commit is contained in:
+26
-14
@@ -252,7 +252,7 @@ class Bf16Repo:
|
||||
return f.get_tensor(key)
|
||||
|
||||
|
||||
def analyze_module(W_dq, deq_params, mods, calib_rms=None):
|
||||
def analyze_module(W_dq, deq_params, mods, calib_rms=None, step_live=None):
|
||||
"""Return fidelity metrics for one quantized module and the adapters targeting it.
|
||||
|
||||
Deltas come from each module's production calc_updown and sum the way the
|
||||
@@ -260,6 +260,9 @@ def analyze_module(W_dq, deq_params, mods, calib_rms=None):
|
||||
is measured as applied. A module is factor-path eligible only when every
|
||||
contribution is a plain additive lora. With ``calib_rms``, hosting mirrors
|
||||
the calibrated production path and its rho is scored in the weighted norm.
|
||||
With ``step_live`` (the layer's own pre-add scale), the production routing
|
||||
rule applies: a delta fat against the grid whose truncation capture is low
|
||||
reports the requantize path, the way the loader would route it.
|
||||
"""
|
||||
D = None
|
||||
for mod in mods:
|
||||
@@ -315,18 +318,24 @@ def analyze_module(W_dq, deq_params, mods, calib_rms=None):
|
||||
Dw = D * rms if rms is not None else D
|
||||
with torch.random.fork_rng(devices=[D.device] if D.device.type == 'cuda' else []):
|
||||
torch.manual_seed(0)
|
||||
U, S, V = torch.svd_lowrank(Dw, q=q, niter=2)
|
||||
Dk = (U * S) @ V.t()
|
||||
if rms is not None:
|
||||
Dk = Dk / rms
|
||||
base16 = W_dq.to(torch.bfloat16).float()
|
||||
realized = (W_dq.to(torch.bfloat16) + Dk.to(torch.bfloat16)).float() - base16
|
||||
if rms is not None: # weighted norm: the diagonal-covariance output-error proxy the calibrated truncation optimizes
|
||||
Dr = D * rms
|
||||
applied_rho = float((realized * rms).flatten() @ Dr.flatten() / Dr.square().sum())
|
||||
else:
|
||||
applied_rho = float(realized.flatten() @ D.flatten() / nD.square())
|
||||
hosted = True
|
||||
U, S, V = torch.svd_lowrank(Dw, q=min(q + 64, *D.shape), niter=8)
|
||||
energy = float(S[:q].square().sum() / Dw.square().sum().clamp(min=1e-30))
|
||||
routed = False
|
||||
if step_live is not None and not deq_params.get('use_svd', False):
|
||||
sr = float(D.square().mean().sqrt() / step_live.float().mean())
|
||||
routed = sr > lora_sdnq.REQUANT_RATIO and energy < lora_sdnq.REQUANT_ENERGY
|
||||
if not routed: # the loader routes fat, genuinely-truncated deltas back to requantize
|
||||
Dk = (U[:, :q] * S[:q]) @ V[:, :q].t()
|
||||
if rms is not None:
|
||||
Dk = Dk / rms
|
||||
base16 = W_dq.to(torch.bfloat16).float()
|
||||
realized = (W_dq.to(torch.bfloat16) + Dk.to(torch.bfloat16)).float() - base16
|
||||
if rms is not None: # weighted norm: the diagonal-covariance output-error proxy the calibrated truncation optimizes
|
||||
Dr = D * rms
|
||||
applied_rho = float((realized * rms).flatten() @ Dr.flatten() / Dr.square().sum())
|
||||
else:
|
||||
applied_rho = float(realized.flatten() @ D.flatten() / nD.square())
|
||||
hosted = True
|
||||
return dict(rank=getattr(mods[0], 'dim', None), rms_delta=float(D.pow(2).mean().sqrt()), rms_weight=float(W_dq.pow(2).mean().sqrt()),
|
||||
step_ratio=step_ratio, crossers=crossers, requant_rho=rho, requant_resid=resid,
|
||||
factor_eligible=factor_eligible, hosted=hosted, applied_rho=applied_rho,
|
||||
@@ -398,6 +407,7 @@ def main():
|
||||
skip_quantized_matmul=deq.use_quantized_matmul, dtype=torch.float32, skip_compile=True).to(device)
|
||||
params = dict(weights_dtype=deq.weights_dtype, group_size=deq.group_size, hadamard_group_size=deq.hadamard_group_size,
|
||||
use_hadamard=deq.use_hadamard, use_svd=layer.svd_up is not None, svd_rank=deq.svd_rank, svd_steps=deq.svd_steps)
|
||||
step_live = layer.scale.detach().to(device)
|
||||
sd_module = layer
|
||||
else:
|
||||
W = bf16_repo.get(f'{path}.weight')
|
||||
@@ -412,16 +422,18 @@ def main():
|
||||
if args.dtype == 'bf16':
|
||||
W_dq = W.to(device, torch.bfloat16).float()
|
||||
params = dict(weights_dtype='bf16', group_size=0, hadamard_group_size=0, use_hadamard=False)
|
||||
step_live = None
|
||||
else:
|
||||
deq0, data0 = sdnq_quantize_layer_weight(W.to(device, torch.float32), layer_class_name='Linear', weights_dtype=args.dtype,
|
||||
group_size=args.group, hadamard_group_size=args.hadamard_group, use_hadamard=args.hadamard_group > 0,
|
||||
use_svd=False, use_quantized_matmul=False, dequantize_fp32=False, torch_dtype=torch.bfloat16)
|
||||
W_dq = deq0(data0['weight'], data0['scale'], zero_point=data0['zero_point'], svd_up=None, svd_down=None, dtype=torch.float32, skip_compile=True)
|
||||
params = dict(weights_dtype=args.dtype, group_size=deq0.group_size, hadamard_group_size=deq0.hadamard_group_size, use_hadamard=deq0.use_hadamard)
|
||||
step_live = data0['scale'].detach()
|
||||
sd_module = make_stub(W.shape)
|
||||
try:
|
||||
mods = [build_module(fam, path, w, net, sd_module) for fam, w in entries]
|
||||
row = analyze_module(W_dq, params, mods, calib_rms=calib_stats.get(lname))
|
||||
row = analyze_module(W_dq, params, mods, calib_rms=calib_stats.get(lname), step_live=step_live)
|
||||
except Exception as e: # a family the tool cannot rebuild must not read as a clean module
|
||||
failed.append(f'{path}: {type(e).__name__}: {e}')
|
||||
del W_dq
|
||||
|
||||
+51
-15
@@ -13,11 +13,15 @@ MLP down projections whose inputs carry the largest outlier channels.
|
||||
Statistics come from the model's own forwards: when a sub-8-bit SDNQ model
|
||||
loads and no calibration is cached for it, streaming sum-of-squares hooks
|
||||
attach to its quantized linears, accumulate during normal generations,
|
||||
persist once enough tokens are seen, and go inert. Cached statistics load
|
||||
at model load and sit on each layer as ``sdnq_calib_rms``; the hosting path
|
||||
reads them through ``rms_for``. Capture is skipped when the model is
|
||||
compiled (hooks would break the graph) and everything is gated by the
|
||||
``lora_sdnq_host_calib`` option.
|
||||
persist, and go inert. Persist fires when every layer reaches the token
|
||||
quota, or at a bounded number of denoiser forwards for models where some
|
||||
projections take pooled or modulation vectors (a few tokens per forward)
|
||||
and could never reach an absolute quota; layers still under a small token
|
||||
floor at the deadline are omitted and stay on plain truncation. Cached
|
||||
statistics load at model load and sit on each layer as ``sdnq_calib_rms``;
|
||||
the hosting path reads them through ``rms_for``. Capture is skipped when
|
||||
the model is compiled (hooks would break the graph) and everything is
|
||||
gated by the ``lora_sdnq_host_calib`` option.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -40,12 +44,15 @@ class CaptureState(TypedDict):
|
||||
model: Optional[str]
|
||||
recs: dict[str, CaptureRecord]
|
||||
handles: list[torch.utils.hooks.RemovableHandle]
|
||||
forwards: int
|
||||
complete: bool
|
||||
|
||||
|
||||
TOKENS_DONE = 65536
|
||||
FORWARDS_DEADLINE = 48 # ~2 generations; token-rich layers normally finish their quota well inside it
|
||||
TOKENS_FLOOR = 32 # below this mass the rms estimate is noise; the layer is omitted and stays on plain truncation
|
||||
calib_root = os.path.join(paths.models_path, 'calibration')
|
||||
capture: CaptureState = {'model': None, 'recs': {}, 'handles': [], 'complete': False}
|
||||
capture: CaptureState = {'model': None, 'recs': {}, 'handles': [], 'forwards': 0, 'complete': False}
|
||||
|
||||
|
||||
def enabled():
|
||||
@@ -62,14 +69,20 @@ def checkpoint_name(sd_model):
|
||||
return getattr(info, 'name', None)
|
||||
|
||||
|
||||
def denoiser_root(sd_model):
|
||||
"""The model's denoiser component, transformer first, unet otherwise."""
|
||||
root = getattr(sd_model, 'transformer', None)
|
||||
return root if root is not None else getattr(sd_model, 'unet', None)
|
||||
|
||||
|
||||
def eligible_modules(sd_model):
|
||||
"""Sub-8-bit 2-D SDNQ linears of the model's transformer: the layers hosting applies to."""
|
||||
transformer = getattr(sd_model, 'transformer', None)
|
||||
if transformer is None:
|
||||
"""Sub-8-bit 2-D SDNQ linears of the model's denoiser: the layers hosting applies to."""
|
||||
root = denoiser_root(sd_model)
|
||||
if root is None:
|
||||
return []
|
||||
from sdnq.common import dtype_dict
|
||||
out = []
|
||||
for name, m in transformer.named_modules():
|
||||
for name, m in root.named_modules():
|
||||
deq = getattr(m, 'sdnq_dequantizer', None)
|
||||
if deq is None or len(deq.original_shape) != 2:
|
||||
continue
|
||||
@@ -85,9 +98,24 @@ def detach_capture():
|
||||
capture['handles'].clear()
|
||||
capture['recs'].clear()
|
||||
capture['model'] = None
|
||||
capture['forwards'] = 0
|
||||
capture['complete'] = False
|
||||
|
||||
|
||||
def deadline_hook(module, hook_args): # pylint: disable=unused-argument
|
||||
"""Count denoiser forwards and close capture at the deadline.
|
||||
|
||||
Layers taking pooled or modulation vectors see a few tokens per forward
|
||||
and can never reach the token quota; a global forward count bounds
|
||||
capture for them and for modules the generation path never runs.
|
||||
"""
|
||||
if capture['complete']:
|
||||
return
|
||||
capture['forwards'] += 1
|
||||
if capture['forwards'] >= FORWARDS_DEADLINE:
|
||||
persist()
|
||||
|
||||
|
||||
def hook_for(rec, in_features):
|
||||
def hook(module, hook_args): # pylint: disable=unused-argument
|
||||
if rec['done'] or capture['complete']:
|
||||
@@ -111,10 +139,12 @@ def hook_for(rec, in_features):
|
||||
|
||||
|
||||
def persist():
|
||||
"""Write completed statistics and stamp them onto the layers.
|
||||
"""Write accumulated statistics and stamp them onto the layers.
|
||||
|
||||
Runs from the last completing hook, inside a forward; the write is a few
|
||||
MB once per checkpoint ever. Handles stay registered but inert until the
|
||||
Runs from the last hook to complete its quota or from the forward
|
||||
deadline, inside a forward; the write is a few MB once per checkpoint
|
||||
ever. Layers under the token floor are omitted rather than saved with
|
||||
meaningless statistics. Handles stay registered but inert until the
|
||||
next safe point removes them (hook removal here would mutate the hook
|
||||
dict the forward is iterating).
|
||||
"""
|
||||
@@ -124,15 +154,20 @@ def persist():
|
||||
from safetensors.torch import save_file
|
||||
tensors, min_n = {}, None
|
||||
for name, rec in capture['recs'].items():
|
||||
rms = (rec['ss'] / max(rec['n'], 1)).sqrt().float().cpu().contiguous().clone()
|
||||
if rec['ss'] is None or rec['n'] < TOKENS_FLOOR:
|
||||
continue
|
||||
rms = (rec['ss'] / rec['n']).sqrt().float().cpu().contiguous().clone()
|
||||
tensors[name] = rms
|
||||
rec['m'].sdnq_calib_rms = rms
|
||||
min_n = rec['n'] if min_n is None else min(min_n, rec['n'])
|
||||
if not tensors:
|
||||
log.warning(f'Network calibration: model="{capture["model"]}" no layer reached {TOKENS_FLOOR} tokens; nothing saved')
|
||||
return
|
||||
path = calib_file(capture['model'])
|
||||
try:
|
||||
os.makedirs(calib_root, exist_ok=True)
|
||||
save_file(tensors, path, metadata={'version': '1', 'model': capture['model'], 'tokens': str(min_n)})
|
||||
log.info(f'Network calibration: model="{capture["model"]}" layers={len(tensors)} tokens={min_n} saved="{path}"')
|
||||
log.info(f'Network calibration: model="{capture["model"]}" layers={len(tensors)}/{len(capture["recs"])} tokens={min_n} saved="{path}"')
|
||||
except Exception as e:
|
||||
log.warning(f'Network calibration: save failed path="{path}" {e}')
|
||||
|
||||
@@ -172,6 +207,7 @@ def on_model_loaded(sd_model):
|
||||
if 'Model' in (getattr(shared.opts, 'cuda_compile', None) or []):
|
||||
return # hooks inside a compiled module graph-break or misbehave; skip capture entirely
|
||||
capture['model'] = name
|
||||
capture['handles'].append(denoiser_root(sd_model).register_forward_pre_hook(deadline_hook))
|
||||
for mod_name, m in modules_list:
|
||||
rec = {'m': m, 'ss': None, 'n': 0, 'done': False}
|
||||
capture['recs'][mod_name] = rec
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Disk cache for hosted svd factors.
|
||||
|
||||
Hosting a non-factorable adapter set costs one truncated svd per targeted
|
||||
layer (tens of ms each, seconds per file) every time the set is applied
|
||||
fresh. The resulting factors are deterministic in the checkpoint, the loaded
|
||||
set (files, multipliers, dyn_dim), the host rank and the calibration
|
||||
statistics, so they are cached on disk keyed by exactly that identity and
|
||||
replayed bit-identically on the next apply of the same configuration.
|
||||
|
||||
One safetensors file per configuration under ``models/lora-factor-cache``,
|
||||
holding every hosted layer's post-rotation factor pair as rowwise int8
|
||||
with fp32 scales (measured fidelity-free in output space, half the bytes
|
||||
of bf16). Files are named by the model and network set with an
|
||||
identity-hash suffix, and the exact signature is embedded in the file
|
||||
metadata. Factors are quantized before first use: ``store`` returns the
|
||||
dequantized round-trip for the caller to apply, so a fresh compute and a
|
||||
later cache hit attach bit-identical tensors. The ``lora_sdnq_host_cache``
|
||||
option is the size budget in GB (0 disables); least-recently-used entries
|
||||
are evicted past the budget. Any doubt about identity (unknown checkpoint,
|
||||
unreadable lora file, signature mismatch) disables caching for the pass
|
||||
rather than risking a stale hit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
import torch
|
||||
|
||||
from modules import paths, shared
|
||||
from modules.lora import lora_common as l
|
||||
from modules.logger import log
|
||||
|
||||
|
||||
cache_root = os.path.join(paths.models_path, 'lora-factor-cache')
|
||||
state = {'wn': None, 'sig': None, 'path': None, 'store': {}, 'dirty': False, 'hits': 0, 'misses': 0}
|
||||
FMT = '5' # bump on entry-layout changes so older files recompute instead of replaying short
|
||||
|
||||
|
||||
def budget_gb():
|
||||
try:
|
||||
return float(getattr(shared.opts, 'lora_sdnq_host_cache', 0) or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def signature(wanted_names):
|
||||
"""Content identity of a hosted-apply configuration, or None when caching is unsafe."""
|
||||
from modules.lora import lora_calib
|
||||
model_name = lora_calib.checkpoint_name(getattr(shared, 'sd_model', None))
|
||||
if model_name is None:
|
||||
return None
|
||||
calib_path = lora_calib.calib_file(model_name)
|
||||
parts = {
|
||||
'model': model_name,
|
||||
'rank': int(getattr(shared.opts, 'lora_sdnq_host_rank', 0) or 0),
|
||||
'calib': int(os.path.getmtime(calib_path)) if lora_calib.enabled() and os.path.isfile(calib_path) else None, # the toggle is part of the identity: factors computed under the other setting must not replay
|
||||
'nets': [],
|
||||
}
|
||||
for name, te, unet, dyn in wanted_names:
|
||||
net = next((n for n in l.loaded_networks if n.name == name), None)
|
||||
filename = getattr(getattr(net, 'network_on_disk', None), 'filename', None)
|
||||
try:
|
||||
st = os.stat(filename)
|
||||
except Exception:
|
||||
return None
|
||||
parts['nets'].append([name, repr(te), repr(unet), repr(dyn), filename, int(st.st_mtime), st.st_size])
|
||||
return parts
|
||||
|
||||
|
||||
def label(parts):
|
||||
"""Filename prefix from the model and net names, so the cache folder reads without tooling."""
|
||||
names = [parts['model'].replace('\\', '/').split('/')[-1]] + [n[0] for n in parts['nets']]
|
||||
text = '-'.join(names)
|
||||
text = ''.join(c if c.isalnum() or c in '._-' else '-' for c in text)
|
||||
return text[:96]
|
||||
|
||||
|
||||
def begin_pass(wanted_names):
|
||||
"""Bind the pass to its cache entry; identity-memoized on the wanted_names tuple."""
|
||||
if wanted_names is state['wn']:
|
||||
return
|
||||
state['wn'] = wanted_names
|
||||
state.update(sig=None, path=None, dirty=False)
|
||||
state['store'] = {}
|
||||
if budget_gb() <= 0 or wanted_names == ():
|
||||
return
|
||||
parts = signature(wanted_names)
|
||||
if parts is None:
|
||||
return
|
||||
sig = json.dumps(parts, sort_keys=True)
|
||||
key = hashlib.sha256(sig.encode()).hexdigest()[:24]
|
||||
path = os.path.join(cache_root, f'{label(parts)}-{key}.safetensors')
|
||||
entries = {}
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
from safetensors import safe_open
|
||||
with safe_open(path, framework='pt', device='cpu') as f:
|
||||
meta = f.metadata() or {}
|
||||
if meta.get('sig') == sig and meta.get('fmt') == FMT:
|
||||
for k in f.keys():
|
||||
entries[k] = f.get_tensor(k)
|
||||
os.utime(path, None) # freshness for LRU eviction
|
||||
except Exception as e:
|
||||
log.debug(f'Network cache: read failed path="{path}" {e}')
|
||||
entries = {}
|
||||
state.update(sig=sig, path=path)
|
||||
state['store'] = entries
|
||||
log.debug(f'Network cache: entry="{path}" keys={len(entries)}')
|
||||
|
||||
|
||||
def quantize_rowwise(t):
|
||||
t32 = t.detach().to(torch.float32)
|
||||
scale = t32.abs().amax(dim=1, keepdim=True).clamp(min=1e-12) / 127.0
|
||||
q = (t32 / scale).round().clamp(-127, 127).to(torch.int8)
|
||||
return q, scale
|
||||
|
||||
|
||||
def dequantize_rowwise(q, scale):
|
||||
# int8 * fp32 with a single fp32 rounding: identical on any device, so hit and miss replay the same values
|
||||
return q.to(torch.float32) * scale
|
||||
|
||||
|
||||
def lookup(network_layer_name):
|
||||
"""Cached (up, down, energy, calibrated, rms) for a layer, or None; factors return as fp32.
|
||||
|
||||
Pure lookup with no hit/miss accounting: the fast-path probe uses it so a
|
||||
layer is only counted once, by whichever caller consumes the answer.
|
||||
"""
|
||||
if state['sig'] is None:
|
||||
return None
|
||||
st = state['store']
|
||||
up_q, up_s = st.get(f'{network_layer_name}.up_q'), st.get(f'{network_layer_name}.up_s')
|
||||
down_q, down_s = st.get(f'{network_layer_name}.down_q'), st.get(f'{network_layer_name}.down_s')
|
||||
energy = st.get(f'{network_layer_name}.energy')
|
||||
calib = st.get(f'{network_layer_name}.calib')
|
||||
rms = st.get(f'{network_layer_name}.rms')
|
||||
if up_q is None or up_s is None or down_q is None or down_s is None or energy is None or calib is None or rms is None:
|
||||
return None
|
||||
return dequantize_rowwise(up_q, up_s), dequantize_rowwise(down_q, down_s), float(energy), bool(calib), float(rms)
|
||||
|
||||
|
||||
def note_hit():
|
||||
state['hits'] += 1
|
||||
|
||||
|
||||
def fetch(network_layer_name):
|
||||
"""``lookup`` with accounting: a usable entry counts a hit, anything else a miss."""
|
||||
entry = lookup(network_layer_name)
|
||||
if entry is None:
|
||||
if state['sig'] is not None:
|
||||
state['misses'] += 1
|
||||
return None
|
||||
state['hits'] += 1
|
||||
return entry
|
||||
|
||||
|
||||
def store(network_layer_name, up, down, energy, calibrated, rms):
|
||||
"""Quantize-before-use: returns the dequantized round-trip the caller must apply.
|
||||
|
||||
The factors quantize to rowwise int8 whether or not a cache entry can be
|
||||
written, so the factors applied now, the factors a later hit replays, and a
|
||||
cache-off apply are the same tensors (the round-trip also zeroes null-tail
|
||||
columns the attach-side trim relies on). ``rms`` is the assembled delta's
|
||||
rms, kept so replays can evaluate the requantize routing rule without
|
||||
assembling the delta.
|
||||
"""
|
||||
up_q, up_s = quantize_rowwise(up)
|
||||
down_q, down_s = quantize_rowwise(down)
|
||||
if state['sig'] is not None:
|
||||
st = state['store']
|
||||
st[f'{network_layer_name}.up_q'] = up_q.to('cpu').contiguous()
|
||||
st[f'{network_layer_name}.up_s'] = up_s.to('cpu').contiguous()
|
||||
st[f'{network_layer_name}.down_q'] = down_q.to('cpu').contiguous()
|
||||
st[f'{network_layer_name}.down_s'] = down_s.to('cpu').contiguous()
|
||||
st[f'{network_layer_name}.energy'] = torch.tensor(float(energy))
|
||||
st[f'{network_layer_name}.calib'] = torch.tensor(1 if calibrated else 0, dtype=torch.uint8)
|
||||
st[f'{network_layer_name}.rms'] = torch.tensor(float(rms))
|
||||
state['dirty'] = True
|
||||
return dequantize_rowwise(up_q, up_s).to(up.dtype), dequantize_rowwise(down_q, down_s).to(down.dtype)
|
||||
|
||||
|
||||
def evict():
|
||||
budget = budget_gb() * 2**30
|
||||
try:
|
||||
files = [os.path.join(cache_root, f) for f in os.listdir(cache_root) if f.endswith('.safetensors')]
|
||||
sizes = {p: os.path.getsize(p) for p in files}
|
||||
except Exception:
|
||||
return
|
||||
total = sum(sizes.values())
|
||||
for p in sorted(files, key=os.path.getmtime):
|
||||
if total <= budget:
|
||||
break
|
||||
if p == state['path']:
|
||||
continue # never evict the entry of the live pass
|
||||
try:
|
||||
os.remove(p)
|
||||
total -= sizes[p]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def flush():
|
||||
"""Persist a dirty pass store; returns (hits, misses) since the last flush."""
|
||||
hits, misses = state['hits'], state['misses']
|
||||
state['hits'] = state['misses'] = 0
|
||||
if not state['dirty'] or state['path'] is None:
|
||||
return hits, misses
|
||||
state['dirty'] = False
|
||||
try:
|
||||
from safetensors.torch import save_file
|
||||
os.makedirs(cache_root, exist_ok=True)
|
||||
tmp = state['path'] + '.tmp'
|
||||
save_file(state['store'], tmp, metadata={'sig': state['sig'], 'fmt': FMT})
|
||||
os.replace(tmp, state['path'])
|
||||
evict()
|
||||
except Exception as e:
|
||||
log.warning(f'Network cache: write failed path="{state["path"]}" {e}')
|
||||
return hits, misses
|
||||
+196
-30
@@ -25,7 +25,9 @@ Only additive low-rank modules ride the channel exactly (plain LoRA: no
|
||||
DoRA, no CP ``mid``, no LyCORIS dense-bias, no ``diff_b``). On sub-8-bit
|
||||
formats, sets with non-factorable contributions are hosted instead: the
|
||||
families' own ``calc_updown`` delta is truncated to its top singular
|
||||
directions and appended the same way. Truncation keeps the dominant part
|
||||
directions and appended the same way, stored at the delta's effective
|
||||
rank when the spectrum ends in a numerically null tail (dense-combined
|
||||
plain pairs, low-rank LyCORIS). Truncation keeps the dominant part
|
||||
of the effect and drops an orthogonal residual, where requantize keeps
|
||||
only the grid extrema and adds grid-shift noise of the delta's own
|
||||
magnitude. When activation statistics for the checkpoint exist (see
|
||||
@@ -33,18 +35,47 @@ magnitude. When activation statistics for the checkpoint exist (see
|
||||
error instead of weight error. At 8 bits and above requantize retains
|
||||
most of the delta, so hosting is skipped there and the requantize path
|
||||
remains.
|
||||
|
||||
A small tail of deltas inverts the tradeoff: when the delta is large
|
||||
against the grid step AND the truncation genuinely cuts it, requantize
|
||||
retains more than hosting drops, and the layer routes back to the
|
||||
requantize path (``REQUANT_RATIO``/``REQUANT_ENERGY``). Both terms must
|
||||
agree: a thin delta rounds away on the grid however low its capture, and
|
||||
a low-rank delta hosts exactly however fat it is.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
from modules import devices, shared
|
||||
from modules.lora import lora_calib
|
||||
from modules.lora import lora_calib, lora_factor_cache
|
||||
from modules.lora import lora_common as l
|
||||
from modules.logger import log
|
||||
|
||||
|
||||
fallback_layers: list[str] = []
|
||||
hosted_layers: list[tuple[str, float, bool]] = []
|
||||
hosted_ranks: list[int] = []
|
||||
routed_layers: list[str] = []
|
||||
|
||||
REQUANT_RATIO = 0.30 # delta rms over mean grid step above which requantize can retain the delta
|
||||
REQUANT_ENERGY = 0.90 # sketch capture below which truncation genuinely loses part of it
|
||||
NULL_TAIL_EPS = 1e-6 # spectrum tail below this fraction of the capture is numerically null; dropping it keeps stored rank at the delta's effective rank
|
||||
|
||||
def rank_bucket(r):
|
||||
"""Fixed rank ladder for compiled-graph reuse: powers of two up to 256, multiples of 64 above (hosted rank plus exact members)."""
|
||||
if r <= 8:
|
||||
return 8
|
||||
if r <= 256:
|
||||
return 1 << (r - 1).bit_length()
|
||||
return -(-r // 64) * 64
|
||||
|
||||
|
||||
def pad_rank(t, dim, bucket):
|
||||
if t.shape[dim] >= bucket:
|
||||
return t
|
||||
shape = list(t.shape)
|
||||
shape[dim] = bucket - t.shape[dim]
|
||||
return torch.cat([t, t.new_zeros(shape)], dim=dim)
|
||||
|
||||
|
||||
def enabled():
|
||||
@@ -57,6 +88,16 @@ def signature():
|
||||
return '' if enabled() else '|quant=requantize'
|
||||
|
||||
|
||||
def trim_null_tail(up_h, down_h):
|
||||
"""Cache entries stored before tail slicing carry null ranks as exact zero columns; trim to the effective rank on attach."""
|
||||
nz = (up_h != 0).any(dim=0)
|
||||
if not bool(nz.all()):
|
||||
k = max(1, int(nz.nonzero().max().item()) + 1) if bool(nz.any()) else 1
|
||||
if k < up_h.shape[1]:
|
||||
return up_h[:, :k].contiguous(), down_h[:k].contiguous()
|
||||
return up_h, down_h
|
||||
|
||||
|
||||
def get_module_factors(module, device, dtype, original_shape=None):
|
||||
"""Return ``(up_eff, down)`` reproducing ``calc_updown`` exactly, or None.
|
||||
|
||||
@@ -85,7 +126,7 @@ def get_module_factors(module, device, dtype, original_shape=None):
|
||||
return up_eff.to(dtype=dtype), down.to(device=device, dtype=dtype)
|
||||
|
||||
|
||||
def factor_candidate(self, network_layer_name, wanted_names, use_previous=False):
|
||||
def factor_candidate(self, network_layer_name, wanted_names):
|
||||
"""True when this layer should take the exact svd-append path.
|
||||
|
||||
Requires an SDNQ linear layer whose active networks all contribute plain
|
||||
@@ -100,9 +141,8 @@ def factor_candidate(self, network_layer_name, wanted_names, use_previous=False)
|
||||
return True
|
||||
if wanted_names == (): # nothing attached, nothing to remove
|
||||
return False
|
||||
loaded = l.loaded_networks if not use_previous else l.previously_loaded_networks
|
||||
seen = False
|
||||
for net in loaded:
|
||||
for net in l.loaded_networks:
|
||||
module = net.modules.get(network_layer_name, None)
|
||||
if module is None:
|
||||
continue
|
||||
@@ -134,7 +174,7 @@ def remove_factors(self):
|
||||
return True
|
||||
|
||||
|
||||
def apply_factors(self, network_layer_name, wanted_names, use_previous=False):
|
||||
def apply_factors(self, network_layer_name, wanted_names):
|
||||
"""Attach the active networks' LoRA factors to this layer's svd side-channel.
|
||||
|
||||
Replaces any previously attached factors (multiplier changes re-enter
|
||||
@@ -150,9 +190,9 @@ def apply_factors(self, network_layer_name, wanted_names, use_previous=False):
|
||||
|
||||
deq = self.sdnq_dequantizer
|
||||
dtype = deq.result_dtype
|
||||
loaded = l.loaded_networks if not use_previous else l.previously_loaded_networks
|
||||
|
||||
ups, downs = [], []
|
||||
for net in loaded:
|
||||
for net in l.loaded_networks:
|
||||
module = net.modules.get(network_layer_name, None)
|
||||
if module is None:
|
||||
continue
|
||||
@@ -187,12 +227,19 @@ def append_factors(self, ups, downs):
|
||||
parts_down = ([orig_down.to(device=devices.device, dtype=dtype)] if orig_down is not None else []) + downs
|
||||
new_up = torch.cat(parts_up, dim=1).contiguous()
|
||||
new_down = torch.cat(parts_down, dim=0).contiguous()
|
||||
from sdnq.common import use_torch_compile
|
||||
if use_torch_compile:
|
||||
# the compiled dequant specializes per factor rank; pad to a fixed bucket so set switches inside a bucket reuse the graph (zero columns contribute exactly nothing)
|
||||
dim_up, dim_down = (0, 1) if deq.use_quantized_matmul else (1, 0)
|
||||
bucket = rank_bucket(new_up.shape[dim_up])
|
||||
new_up = pad_rank(new_up, dim_up, bucket)
|
||||
new_down = pad_rank(new_down, dim_down, bucket)
|
||||
self.sdnq_lora_svd_stash = (orig_up, orig_down)
|
||||
self.svd_up = torch.nn.Parameter(new_up.to(device=device), requires_grad=False)
|
||||
self.svd_down = torch.nn.Parameter(new_down.to(device=device), requires_grad=False)
|
||||
|
||||
|
||||
def host_candidate(self, network_layer_name, wanted_names, use_previous=False):
|
||||
def host_candidate(self, network_layer_name, wanted_names):
|
||||
"""True when a non-factorable set on this layer should be hosted as a truncated svd."""
|
||||
if not enabled():
|
||||
return False
|
||||
@@ -205,11 +252,57 @@ def host_candidate(self, network_layer_name, wanted_names, use_previous=False):
|
||||
from sdnq.common import dtype_dict
|
||||
if dtype_dict[self.sdnq_dequantizer.weights_dtype]['num_bits'] >= 8:
|
||||
return False # requantize retains most of the delta at 8 bits and above; truncation would lose more than it saves
|
||||
loaded = l.loaded_networks if not use_previous else l.previously_loaded_networks
|
||||
return any(net.modules.get(network_layer_name, None) is not None for net in loaded)
|
||||
|
||||
return any(net.modules.get(network_layer_name, None) is not None for net in l.loaded_networks)
|
||||
|
||||
|
||||
def apply_hosted(self, network_layer_name, updown, wanted_names, use_previous=False):
|
||||
def apply_cached(self, network_layer_name, wanted_names):
|
||||
"""Attach a hosted set straight from the factor cache, before the delta exists.
|
||||
|
||||
Probed by the walk ahead of delta assembly: on a usable entry the routing
|
||||
rule is evaluated from the stored delta rms and the cached factors attach
|
||||
exactly as a fetch inside ``apply_hosted`` would, so the pass skips
|
||||
``calc_updown`` for the layer entirely. Returns True when the layer was
|
||||
served; None sends the caller down the assemble-and-host path (no entry,
|
||||
or the rule wants the grid).
|
||||
"""
|
||||
from sdnq.quant_utils import rotate_hadamard
|
||||
|
||||
lora_factor_cache.begin_pass(wanted_names)
|
||||
entry = lora_factor_cache.lookup(network_layer_name)
|
||||
if entry is None:
|
||||
return None
|
||||
up_h, down_h, energy, calibrated, rms = entry
|
||||
up_h, down_h = trim_null_tail(up_h, down_h)
|
||||
deq = self.sdnq_dequantizer
|
||||
dtype = deq.result_dtype
|
||||
remove_factors(self) # before the rule: the svd-channel check must see the checkpoint's own state, and a declined layer must fall through pristine
|
||||
members = []
|
||||
for net in l.loaded_networks:
|
||||
module = net.modules.get(network_layer_name, None)
|
||||
if module is None:
|
||||
continue
|
||||
factors = get_module_factors(module, devices.device, dtype, original_shape=deq.original_shape)
|
||||
if factors is not None:
|
||||
members.append(factors)
|
||||
if len(members) == 0 and self.svd_up is None:
|
||||
step = float(self.scale.detach().float().mean())
|
||||
if step > 0 and rms / step > REQUANT_RATIO and energy < REQUANT_ENERGY:
|
||||
return None # routed to the grid: the caller assembles the delta and requantizes
|
||||
ups, downs = [], []
|
||||
for up_eff, down in members:
|
||||
if deq.use_hadamard:
|
||||
down = rotate_hadamard(down.to(dtype=torch.float32), group_size=deq.hadamard_group_size).to(dtype=dtype)
|
||||
ups.append(up_eff)
|
||||
downs.append(down)
|
||||
lora_factor_cache.note_hit()
|
||||
append_factors(self, ups + [up_h.to(device=devices.device, dtype=dtype)], downs + [down_h.to(device=devices.device, dtype=dtype)])
|
||||
hosted_layers.append((network_layer_name, energy, calibrated))
|
||||
hosted_ranks.append(int(up_h.shape[1]))
|
||||
return True
|
||||
|
||||
|
||||
def apply_hosted(self, network_layer_name, updown, wanted_names):
|
||||
"""Host a set's delta on the svd channel: exact factors for factorable
|
||||
members, the top-k singular directions of the remainder for the rest.
|
||||
|
||||
@@ -218,9 +311,11 @@ def apply_hosted(self, network_layer_name, updown, wanted_names, use_previous=Fa
|
||||
appended exactly so they never compete with the hosted remainder for
|
||||
rank. When per-checkpoint activation statistics exist (``lora_calib``),
|
||||
input channels are weighted by their RMS before truncation so the kept
|
||||
directions minimize output error rather than weight error. Returns None
|
||||
when the delta cannot ride the channel (wrong shape); the caller falls
|
||||
back to requantize.
|
||||
directions minimize output error rather than weight error. Computed
|
||||
factors are disk-cached per configuration (``lora_factor_cache``) and
|
||||
replayed bit-identically on later applies. Returns None when the delta
|
||||
cannot ride the channel (wrong shape) or when the routing rule prefers
|
||||
the grid for it; the caller falls back to requantize.
|
||||
"""
|
||||
from sdnq.quant_utils import rotate_hadamard
|
||||
|
||||
@@ -231,24 +326,73 @@ def apply_hosted(self, network_layer_name, updown, wanted_names, use_previous=Fa
|
||||
if updown is None or updown.ndim != 2 or tuple(updown.shape) != tuple(deq.original_shape):
|
||||
return None
|
||||
dtype = deq.result_dtype
|
||||
D = updown.detach().to(devices.device, torch.float32)
|
||||
|
||||
ups, downs = [], []
|
||||
loaded = l.loaded_networks if not use_previous else l.previously_loaded_networks
|
||||
for net in loaded:
|
||||
members = []
|
||||
for net in l.loaded_networks:
|
||||
module = net.modules.get(network_layer_name, None)
|
||||
if module is None:
|
||||
continue
|
||||
factors = get_module_factors(module, devices.device, dtype, original_shape=deq.original_shape)
|
||||
if factors is None:
|
||||
continue
|
||||
up_eff, down = factors
|
||||
D = D.sub_(up_eff.to(torch.float32) @ down.to(torch.float32)) # factorable members ride exactly; host only the remainder
|
||||
if factors is not None:
|
||||
members.append(factors)
|
||||
|
||||
# requantize keeps a delta the grid can resolve and that truncation would genuinely
|
||||
# cut: both terms must agree, since a thin delta rounds away on the grid however
|
||||
# low its capture, and a low-rank delta hosts exactly however fat it is. Scoped to
|
||||
# sets the side-channel would otherwise carry whole: factorable members ride
|
||||
# exactly.
|
||||
delta_rms = float(updown.detach().float().square().mean().sqrt())
|
||||
maybe_requant = len(members) == 0 and self.svd_up is None
|
||||
if maybe_requant:
|
||||
step = float(self.scale.detach().float().mean())
|
||||
maybe_requant = step > 0 and delta_rms / step > REQUANT_RATIO
|
||||
|
||||
lora_factor_cache.begin_pass(wanted_names)
|
||||
cached = lora_factor_cache.fetch(network_layer_name)
|
||||
D = None if cached is not None else updown.detach().to(devices.device, torch.float32)
|
||||
|
||||
ups, downs = [], []
|
||||
for up_eff, down in members:
|
||||
if D is not None:
|
||||
D = D.sub_(up_eff.to(torch.float32) @ down.to(torch.float32)) # factorable members ride exactly; host only the remainder
|
||||
if deq.use_hadamard:
|
||||
down = rotate_hadamard(down.to(dtype=torch.float32), group_size=deq.hadamard_group_size).to(dtype=dtype)
|
||||
ups.append(up_eff)
|
||||
downs.append(down)
|
||||
|
||||
if cached is not None:
|
||||
up_h, down_h, energy, calibrated, _cached_rms = cached
|
||||
if maybe_requant and energy < REQUANT_ENERGY:
|
||||
routed_layers.append(network_layer_name)
|
||||
return None
|
||||
up_h, down_h = trim_null_tail(up_h, down_h)
|
||||
append_factors(self, ups + [up_h.to(device=devices.device, dtype=dtype)], downs + [down_h.to(device=devices.device, dtype=dtype)])
|
||||
hosted_layers.append((network_layer_name, energy, calibrated))
|
||||
hosted_ranks.append(int(up_h.shape[1]))
|
||||
return True
|
||||
|
||||
up_h, down_h, energy, calibrated = truncate_delta(self, D, dtype)
|
||||
up_h, down_h = lora_factor_cache.store(network_layer_name, up_h, down_h, energy, calibrated, delta_rms)
|
||||
if maybe_requant and energy < REQUANT_ENERGY:
|
||||
routed_layers.append(network_layer_name) # the stored entry memoizes the routing; replays skip the sketch
|
||||
return None
|
||||
up_h, down_h = trim_null_tail(up_h, down_h) # the int8 roundtrip zeroes the numeric tail the eps slice keeps; fresh and replayed attaches must trim alike
|
||||
append_factors(self, ups + [up_h], downs + [down_h])
|
||||
hosted_layers.append((network_layer_name, energy, calibrated))
|
||||
hosted_ranks.append(int(up_h.shape[1]))
|
||||
return True
|
||||
|
||||
|
||||
def truncate_delta(self, D, dtype):
|
||||
"""Truncate one dense fp32 delta to hosted factors in the layer's channel layout; consumes ``D``.
|
||||
|
||||
Calibration-weighted when statistics exist; the sketch is oversampled past
|
||||
the kept rank so the truncation sits within noise of exact svd. Returns
|
||||
``(up_h, down_h, energy, calibrated)`` with the down factor rotated into the
|
||||
layer's hadamard domain.
|
||||
"""
|
||||
from sdnq.quant_utils import rotate_hadamard
|
||||
deq = self.sdnq_dequantizer
|
||||
cap = int(shared.opts.lora_sdnq_host_rank)
|
||||
q = min(cap, *D.shape)
|
||||
rms = lora_calib.rms_for(self)
|
||||
@@ -261,7 +405,17 @@ def apply_hosted(self, network_layer_name, updown, wanted_names, use_previous=Fa
|
||||
# svd_lowrank draws random projections; fork so user generation seeds are untouched and re-applies are deterministic
|
||||
with torch.random.fork_rng(devices=[D.device] if D.device.type == 'cuda' else []):
|
||||
torch.manual_seed(0)
|
||||
U, S, V = torch.svd_lowrank(D, q=q, niter=2)
|
||||
# oversampled sketch with extra power iterations lands within noise of exact svd; only the top q columns are kept
|
||||
U, S, V = torch.svd_lowrank(D, q=min(q + 64, *D.shape), niter=8)
|
||||
U, S, V = U[:, :q], S[:q], V[:, :q]
|
||||
e = S.square()
|
||||
total_e = e.sum()
|
||||
if float(total_e) > 0:
|
||||
# an exactly low-rank delta (dense-combined plain pairs, low-rank LyCORIS) fills the tail with
|
||||
# numerical zeros; storing them would pad the channel to the cap for nothing
|
||||
k = int((torch.cumsum(e, 0) < (1.0 - NULL_TAIL_EPS) * total_e).sum().item()) + 1
|
||||
if k < q:
|
||||
U, S, V = U[:, :k], S[:k], V[:, :k]
|
||||
energy = float(S.square().sum() / D.square().sum().clamp(min=1e-30)) # captured fraction, in the weighted domain when calibrated
|
||||
up_h = (U * S).to(dtype=dtype)
|
||||
down_h = V.t()
|
||||
@@ -269,26 +423,38 @@ def apply_hosted(self, network_layer_name, updown, wanted_names, use_previous=Fa
|
||||
down_h = down_h / rms # unscale in the original input basis, before any rotation
|
||||
if deq.use_hadamard:
|
||||
down_h = rotate_hadamard(down_h, group_size=deq.hadamard_group_size)
|
||||
append_factors(self, ups + [up_h], downs + [down_h.to(dtype=dtype)])
|
||||
hosted_layers.append((network_layer_name, energy, rms is not None))
|
||||
return True
|
||||
down_h = down_h.to(dtype=dtype)
|
||||
return up_h, down_h, energy, rms is not None
|
||||
|
||||
|
||||
def note_fallback(self, network_layer_name):
|
||||
"""Record a quantized layer taking the lossy requantize path (summary-logged per pass)."""
|
||||
if getattr(self, 'sdnq_dequantizer', None) is not None:
|
||||
"""Record a quantized layer taking the requantize path (summary-logged per pass); layers the routing rule sent there are counted apart."""
|
||||
if getattr(self, 'sdnq_dequantizer', None) is not None and network_layer_name not in routed_layers:
|
||||
fallback_layers.append(network_layer_name)
|
||||
|
||||
|
||||
def report_fallbacks():
|
||||
hits, misses = lora_factor_cache.flush()
|
||||
if hits > 0 or misses > 0:
|
||||
log.info(f'Network load: type=LoRA quant=sdnq cache hits={hits} misses={misses}')
|
||||
if len(hosted_layers) > 0:
|
||||
energies = sorted(e for _name, e, _c in hosted_layers)
|
||||
median = energies[len(energies) // 2]
|
||||
calibrated = sum(1 for _name, _e, c in hosted_layers if c)
|
||||
log.info(f'Network load: type=LoRA quant=sdnq hosted={len(hosted_layers)} rank={int(shared.opts.lora_sdnq_host_rank)}{f" calib={calibrated}" if calibrated else ""} energy={median:.2f} min={energies[0]:.2f} non-factorable networks hosted on the svd side-channel')
|
||||
ranks = ''
|
||||
if len(hosted_ranks) > 0 and min(hosted_ranks) < int(shared.opts.lora_sdnq_host_rank):
|
||||
rs = sorted(hosted_ranks)
|
||||
ranks = f' k={rs[0]}-{rs[len(rs) // 2]}-{rs[-1]}' # realized rank spread; shown only when a spectrum collapsed below the cap
|
||||
log.info(f'Network load: type=LoRA quant=sdnq hosted={len(hosted_layers)} rank={int(shared.opts.lora_sdnq_host_rank)}{ranks}{f" calib={calibrated}" if calibrated else ""} energy={median:.2f} min={energies[0]:.2f} non-factorable networks hosted on the svd side-channel')
|
||||
if l.debug:
|
||||
log.debug(f'Network load: type=LoRA quant=sdnq hosted={[(n, round(e, 3)) for n, e, _c in hosted_layers[:8]]}{"..." if len(hosted_layers) > 8 else ""}')
|
||||
hosted_layers.clear()
|
||||
hosted_ranks.clear()
|
||||
if len(routed_layers) > 0:
|
||||
log.info(f'Network load: type=LoRA quant=sdnq apply=requantize layers={len(routed_layers)} routed=fat-delta')
|
||||
if l.debug:
|
||||
log.debug(f'Network load: type=LoRA quant=sdnq routed={routed_layers[:8]}{"..." if len(routed_layers) > 8 else ""}')
|
||||
routed_layers.clear()
|
||||
if len(fallback_layers) > 0:
|
||||
if enabled():
|
||||
log.warning(f'Network load: type=LoRA quant=sdnq layers={len(fallback_layers)} non-factorable networks requantized in place (reduced fidelity on quantized weights)')
|
||||
|
||||
+18
-14
@@ -106,6 +106,7 @@ def network_activate(include=None, exclude=None):
|
||||
continue
|
||||
if group_offload and component not in group_stripped and group_will_mutate(module, network_layer_name, l.loaded_networks):
|
||||
device = group_offload_strip(sd_model, component, group_stripped)
|
||||
calced = False # tracks whether this iteration assembled the delta, so the fallthrough reuses it instead of recomputing
|
||||
if lora_sdnq.factor_candidate(module, network_layer_name, component_wanted):
|
||||
weights_backup = getattr(module, "network_weights_backup", None)
|
||||
if weights_backup is not None and not isinstance(weights_backup, bool):
|
||||
@@ -124,22 +125,24 @@ def network_activate(include=None, exclude=None):
|
||||
weights_backup = getattr(module, "network_weights_backup", None)
|
||||
if weights_backup is not None and not isinstance(weights_backup, bool):
|
||||
network_apply_weights(module, None, None, device=device) # the hosted delta is measured against the pristine base
|
||||
batch_updown, batch_ex_bias = network_calc_weights(module, network_layer_name, elimit=elimit)
|
||||
if batch_ex_bias is None: # bias deltas need the plain path; weight-only sets ride the side-channel without a weight backup
|
||||
hosted = lora_sdnq.apply_hosted(module, network_layer_name, batch_updown, component_wanted)
|
||||
hosted = lora_sdnq.apply_cached(module, network_layer_name, component_wanted) # a stored entry serves the layer before the delta is assembled
|
||||
if hosted is None:
|
||||
batch_updown, batch_ex_bias = network_calc_weights(module, network_layer_name, elimit=elimit)
|
||||
calced = True
|
||||
if batch_ex_bias is None: # bias deltas need the plain path; weight-only sets ride the side-channel without a weight backup
|
||||
hosted = lora_sdnq.apply_hosted(module, network_layer_name, batch_updown, component_wanted)
|
||||
if hosted is not None:
|
||||
if hosted and component_wanted:
|
||||
applied_layers.append(network_layer_name)
|
||||
applied_weight += 1
|
||||
module.network_current_names = component_wanted
|
||||
module.network_current_stack = stack_sig
|
||||
batch_updown, batch_ex_bias = None, None
|
||||
del batch_updown, batch_ex_bias
|
||||
if task is not None:
|
||||
pbar.update(task, advance=1)
|
||||
continue
|
||||
batch_updown, batch_ex_bias = None, None
|
||||
del batch_updown, batch_ex_bias
|
||||
if hosted is not None:
|
||||
if hosted and component_wanted:
|
||||
applied_layers.append(network_layer_name)
|
||||
applied_weight += 1
|
||||
module.network_current_names = component_wanted
|
||||
module.network_current_stack = stack_sig
|
||||
if task is not None:
|
||||
pbar.update(task, advance=1)
|
||||
continue
|
||||
stripped = lora_sdnq.remove_factors(module) # the mechanism gate can decline a layer still carrying attached factors; the weight path must start from the pristine channel
|
||||
if stripped and not component_wanted: # factor-mode layers have no tensor backup, dropping the factors is the whole restore
|
||||
module.network_current_names = ()
|
||||
@@ -156,7 +159,8 @@ def network_activate(include=None, exclude=None):
|
||||
continue
|
||||
batch_updown, batch_ex_bias = None, None # restore-only pass, apply with no weights reverts to backup
|
||||
else:
|
||||
batch_updown, batch_ex_bias = network_calc_weights(module, network_layer_name, elimit=elimit)
|
||||
if not calced: # the host branch may have assembled the delta already; a declined layer reuses it
|
||||
batch_updown, batch_ex_bias = network_calc_weights(module, network_layer_name, elimit=elimit)
|
||||
if batch_updown is not None:
|
||||
lora_sdnq.note_fallback(module, network_layer_name) # only layers whose quantized weight actually takes a delta
|
||||
if fuse:
|
||||
|
||||
@@ -707,6 +707,7 @@ def create_settings(cmd_opts):
|
||||
"lora_sdnq_apply": OptionInfo("exact", "LoRA quantized apply method", gr.Radio, {"choices": ["exact", "requantize"]}),
|
||||
"lora_sdnq_host_rank": OptionInfo(256, "LoRA quantized host rank", gr.Slider, {"minimum": 0, "maximum": 1024, "step": 32}),
|
||||
"lora_sdnq_host_calib": OptionInfo(True, "LoRA quantized host calibration"),
|
||||
"lora_sdnq_host_cache": OptionInfo(10, "LoRA quantized host cache", gr.Slider, {"minimum": 0, "maximum": 100, "step": 1}),
|
||||
|
||||
"lora_meta_sep": OptionInfo("<h2>Metadata</h2>", "", gr.HTML),
|
||||
"lora_add_hashes_to_infotext": OptionInfo(False, "LoRA add hash info to metadata"),
|
||||
|
||||
@@ -34,6 +34,14 @@ for the per-model analyzer):
|
||||
plain truncation bit-exact, and the capture hooks accumulate, persist
|
||||
and reload statistics correctly, gated by option, format width and
|
||||
model compile.
|
||||
- Factor cache: hosted factors replay bit-identically from the disk cache
|
||||
without re-running the svd, a configuration change (multiplier) misses
|
||||
and writes a separate entry, and budget 0 writes nothing.
|
||||
- Compile: the factor add runs inside the single compiled dequant graph
|
||||
(fullgraph, no breaks) and matches the eager result; factor ranks pad to
|
||||
a fixed bucket ladder so set switches inside a bucket reuse the compiled
|
||||
graph while a novel bucket compiles exactly once, and padding changes
|
||||
the dequantized weight by nothing beyond reduction-order ulp.
|
||||
|
||||
All tensors are synthetic; no model files or running server required.
|
||||
|
||||
@@ -698,6 +706,34 @@ def test_hosted_dense_delta_beats_requant():
|
||||
return True
|
||||
|
||||
|
||||
def test_hosted_null_tail_collapses_to_effective_rank():
|
||||
layer = build_layer('uint4')
|
||||
_A, _B, D = make_delta(sigma=3e-3) # exact rank-8 content in a non-factorable container
|
||||
net = make_dense_net('nulltail', layer, D)
|
||||
with host_rank(256), mock_model(lin=layer):
|
||||
Wdq0 = dq(layer)
|
||||
activate(net)
|
||||
assert layer.svd_up.shape[1] == 8, f'rank-8 delta under cap 256 must store 8 ranks, got {layer.svd_up.shape[1]}'
|
||||
assert layer.svd_down.shape[0] == 8, f'down factor must slice with the up factor, got {layer.svd_down.shape[0]}'
|
||||
rho = rho_of(dq(layer) - Wdq0, D)
|
||||
assert rho > 0.95, f'collapsing the null tail must not cost fidelity: rho={rho:.4f}'
|
||||
activate()
|
||||
assert torch.equal(dq(layer), Wdq0), 'unload must restore bit-exact'
|
||||
return True
|
||||
|
||||
|
||||
def test_hosted_flat_spectrum_keeps_cap():
|
||||
layer = build_layer('uint4')
|
||||
torch.manual_seed(13)
|
||||
D = torch.randn(OUT_F, IN_F, device=DEVICE) * 3e-4 # full-rank gaussian: no null tail inside the cap
|
||||
net = make_dense_net('flattail', layer, D)
|
||||
with host_rank(64), mock_model(lin=layer):
|
||||
activate(net)
|
||||
assert layer.svd_up.shape[1] == 64, f'a flat spectrum must keep the full cap, got {layer.svd_up.shape[1]}'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
def test_hosted_skips_int8():
|
||||
layer = build_layer('int8')
|
||||
_A, _B, D = make_delta(sigma=3e-3)
|
||||
@@ -745,6 +781,116 @@ def test_hosted_transitions_and_rng_isolation():
|
||||
return True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def requant_rule(ratio, energy):
|
||||
old_r, old_e = lora_sdnq.REQUANT_RATIO, lora_sdnq.REQUANT_ENERGY
|
||||
lora_sdnq.REQUANT_RATIO, lora_sdnq.REQUANT_ENERGY = ratio, energy
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lora_sdnq.REQUANT_RATIO, lora_sdnq.REQUANT_ENERGY = old_r, old_e
|
||||
|
||||
|
||||
def test_route_fat_dense_delta_requantizes():
|
||||
layer = build_layer('uint4')
|
||||
torch.manual_seed(21)
|
||||
D = torch.randn(OUT_F, IN_F, device=DEVICE) * 1e-2 # full-rank and well above the grid step: the grid retains it, truncation would cut it
|
||||
net = make_dense_net('fatnet', layer, D)
|
||||
with host_rank(256), mock_model(lin=layer):
|
||||
Wdq0 = dq(layer)
|
||||
activate(net)
|
||||
assert not hasattr(layer, 'sdnq_lora_svd_stash'), 'a fat full-rank delta must route to requantize'
|
||||
assert isinstance(getattr(layer, 'network_weights_backup', None), torch.Tensor), 'the routed layer takes the requantize backup'
|
||||
rho = rho_of(dq(layer) - Wdq0, D)
|
||||
assert rho > 0.7, f'the grid must retain the routed delta: rho={rho:.3f}'
|
||||
activate()
|
||||
assert torch.equal(dq(layer), Wdq0), 'restore from backup must be bit-exact'
|
||||
return True
|
||||
|
||||
|
||||
def test_route_rule_terms_gate_both_ways():
|
||||
layer = build_layer('uint4')
|
||||
torch.manual_seed(23)
|
||||
D = torch.randn(OUT_F, IN_F, device=DEVICE) * 1e-2 # sr about 0.8, capture about 0.8 at cap 256: each term alone can hold it hosted
|
||||
net = make_dense_net('gatenet', layer, D)
|
||||
with host_rank(256), mock_model(lin=layer):
|
||||
with requant_rule(ratio=10.0, energy=0.90):
|
||||
activate(net)
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash'), 'sr below the ratio must host regardless of capture'
|
||||
activate()
|
||||
with requant_rule(ratio=0.30, energy=0.0):
|
||||
activate(net)
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash'), 'capture above the energy floor must host regardless of sr'
|
||||
activate()
|
||||
with requant_rule(ratio=0.30, energy=0.90):
|
||||
activate(net)
|
||||
assert not hasattr(layer, 'sdnq_lora_svd_stash'), 'both terms crossed must requantize'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
def test_route_low_rank_fat_delta_stays_hosted():
|
||||
layer = build_layer('uint4')
|
||||
_A, _B, D = make_delta(seed=22, sigma=3e-3) # rank-8: fat against the grid, exact under the cap
|
||||
net = make_dense_net('fatlow', layer, D)
|
||||
with host_rank(64), mock_model(lin=layer):
|
||||
Wdq0 = dq(layer)
|
||||
activate(net)
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash'), 'a low-rank delta hosts exactly at any magnitude'
|
||||
rho = rho_of(dq(layer) - Wdq0, D)
|
||||
assert rho > 0.95, f'rho={rho:.4f}'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
def test_route_mixed_set_keeps_hosting():
|
||||
layer = build_layer('uint4')
|
||||
A, B, _D1 = make_delta(seed=24)
|
||||
torch.manual_seed(25)
|
||||
D2 = torch.randn(OUT_F, IN_F, device=DEVICE) * 1e-2
|
||||
net1 = make_net('mixp', layer, A, B)
|
||||
net2 = make_dense_net('mixf', layer, D2)
|
||||
with host_rank(256), mock_model(lin=layer):
|
||||
Wdq0 = dq(layer)
|
||||
activate(net1, net2)
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash'), 'a set with factorable members keeps the side-channel'
|
||||
activate()
|
||||
assert torch.equal(dq(layer), Wdq0)
|
||||
return True
|
||||
|
||||
|
||||
def test_route_svd_checkpoint_keeps_hosting():
|
||||
layer = build_layer('uint4', use_svd=True)
|
||||
torch.manual_seed(26)
|
||||
D = torch.randn(OUT_F, IN_F, device=DEVICE) * 1e-2
|
||||
net = make_dense_net('svdfat', layer, D)
|
||||
with host_rank(256), mock_model(lin=layer):
|
||||
activate(net)
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash'), 'svd checkpoints keep hosting; the rule is not grounded there'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
def test_route_replay_from_cache():
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(256), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer, name='fatcache', sigma=1e-2, seed=28)
|
||||
activate(net)
|
||||
assert not hasattr(layer, 'sdnq_lora_svd_stash'), 'fat delta must route on the fresh-sketch path'
|
||||
activate()
|
||||
real_svd = torch.svd_lowrank
|
||||
torch.svd_lowrank = raise_no_svd
|
||||
try:
|
||||
activate(net) # the stored entry memoizes the routing: same decision, no sketch
|
||||
finally:
|
||||
torch.svd_lowrank = real_svd
|
||||
assert not hasattr(layer, 'sdnq_lora_svd_stash'), 'cache replay must route the same way'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
CAT_CALIB = category('calibration')
|
||||
|
||||
|
||||
@@ -850,7 +996,7 @@ def test_calib_capture_persist_roundtrip():
|
||||
lora_calib.calib_root = tmp
|
||||
lora_calib.TOKENS_DONE = 2048
|
||||
lora_calib.on_model_loaded(sd)
|
||||
assert len(lora_calib.capture['handles']) == 2, 'both sub-8-bit linears must hook'
|
||||
assert len(lora_calib.capture['handles']) == 3, 'both sub-8-bit linears plus the root forward counter must hook'
|
||||
torch.manual_seed(51)
|
||||
scale = torch.linspace(0.1, 4.0, IN_F, device=DEVICE)
|
||||
xs = []
|
||||
@@ -897,6 +1043,462 @@ def test_calib_capture_gates():
|
||||
return True
|
||||
|
||||
|
||||
class MockCalibDenoiser(torch.nn.Module):
|
||||
"""Denoiser whose forward feeds one token-rich linear and one token-starved one, like a DiT block beside its modulation projection."""
|
||||
def __init__(self, rich, starved, starved_tokens):
|
||||
super().__init__()
|
||||
self.rich = rich
|
||||
self.starved = starved
|
||||
self.starved_tokens = starved_tokens
|
||||
|
||||
def forward(self, x):
|
||||
self.rich(x)
|
||||
self.starved(x[:self.starved_tokens])
|
||||
return x
|
||||
|
||||
|
||||
def test_calib_deadline_persists_starved_layers():
|
||||
import tempfile
|
||||
from safetensors import safe_open
|
||||
from modules.lora import lora_calib
|
||||
rich = build_layer('uint4', seed=45)
|
||||
starved = build_layer('uint4', seed=46)
|
||||
root = MockCalibDenoiser(rich, starved, starved_tokens=8)
|
||||
sd = MockCalibSd('test/calib-deadline')
|
||||
sd.transformer = root
|
||||
old = (lora_calib.calib_root, lora_calib.TOKENS_DONE, lora_calib.FORWARDS_DEADLINE)
|
||||
with tempfile.TemporaryDirectory() as tmp, host_calib(True):
|
||||
try:
|
||||
lora_calib.calib_root = tmp
|
||||
lora_calib.TOKENS_DONE = 2048
|
||||
lora_calib.FORWARDS_DEADLINE = 6
|
||||
lora_calib.on_model_loaded(sd)
|
||||
assert len(lora_calib.capture['handles']) == 3, 'two layer hooks plus the root forward counter must attach'
|
||||
torch.manual_seed(52)
|
||||
xs = []
|
||||
for i in range(6):
|
||||
x = torch.randn(1024, IN_F, device=DEVICE).to(torch.bfloat16)
|
||||
if i < 5: # the deadline fires at the start of the sixth forward, before its layer hooks run
|
||||
xs.append(x[:8].float())
|
||||
root(x)
|
||||
assert lora_calib.capture['complete'], 'the forward deadline must close capture'
|
||||
assert lora_calib.capture['forwards'] == 6, f'root counter must track denoiser forwards, got {lora_calib.capture["forwards"]}'
|
||||
path = lora_calib.calib_file('test/calib-deadline')
|
||||
assert os.path.isfile(path), 'deadline persist must write the statistics file'
|
||||
assert getattr(starved, 'sdnq_calib_rms', None) is not None, 'the starved layer must carry statistics'
|
||||
expected = torch.cat(xs).square().mean(dim=0).sqrt().cpu()
|
||||
assert torch.allclose(starved.sdnq_calib_rms, expected, rtol=1e-3, atol=1e-5), 'starved rms must match exactly the tokens it saw'
|
||||
with safe_open(path, framework='pt', device='cpu') as f:
|
||||
assert set(f.keys()) == {'rich', 'starved'}, f'both layers must persist, got {sorted(f.keys())}'
|
||||
assert f.metadata()['tokens'] == '40', f'metadata must report the weakest saved layer, got {f.metadata()["tokens"]}'
|
||||
finally:
|
||||
lora_calib.calib_root, lora_calib.TOKENS_DONE, lora_calib.FORWARDS_DEADLINE = old
|
||||
lora_calib.detach_capture()
|
||||
return True
|
||||
|
||||
|
||||
def test_calib_deadline_omits_subfloor_layers():
|
||||
import tempfile
|
||||
from safetensors import safe_open
|
||||
from modules.lora import lora_calib
|
||||
rich = build_layer('uint4', seed=48)
|
||||
starved = build_layer('uint4', seed=49)
|
||||
root = MockCalibDenoiser(rich, starved, starved_tokens=2) # 2 tokens x 5 counted forwards = 10, under the floor of 32
|
||||
sd = MockCalibSd('test/calib-subfloor')
|
||||
sd.transformer = root
|
||||
old = (lora_calib.calib_root, lora_calib.TOKENS_DONE, lora_calib.FORWARDS_DEADLINE)
|
||||
with tempfile.TemporaryDirectory() as tmp, host_calib(True):
|
||||
try:
|
||||
lora_calib.calib_root = tmp
|
||||
lora_calib.TOKENS_DONE = 2048
|
||||
lora_calib.FORWARDS_DEADLINE = 6
|
||||
lora_calib.on_model_loaded(sd)
|
||||
torch.manual_seed(53)
|
||||
for _ in range(6):
|
||||
root(torch.randn(1024, IN_F, device=DEVICE).to(torch.bfloat16))
|
||||
assert lora_calib.capture['complete'], 'the forward deadline must close capture'
|
||||
path = lora_calib.calib_file('test/calib-subfloor')
|
||||
with safe_open(path, framework='pt', device='cpu') as f:
|
||||
assert set(f.keys()) == {'rich'}, f'a layer under the token floor must be omitted, got {sorted(f.keys())}'
|
||||
assert getattr(starved, 'sdnq_calib_rms', None) is None, 'an omitted layer must not carry statistics'
|
||||
del rich.sdnq_calib_rms
|
||||
lora_calib.on_model_loaded(sd) # second load takes the cached path with the partial file
|
||||
assert len(lora_calib.capture['handles']) == 0, 'a partial file still counts as cached; capture must not re-attach'
|
||||
assert getattr(rich, 'sdnq_calib_rms', None) is not None, 'the saved layer must reload from the partial file'
|
||||
assert getattr(starved, 'sdnq_calib_rms', None) is None, 'the omitted layer must stay on plain truncation after reload'
|
||||
finally:
|
||||
lora_calib.calib_root, lora_calib.TOKENS_DONE, lora_calib.FORWARDS_DEADLINE = old
|
||||
lora_calib.detach_capture()
|
||||
return True
|
||||
|
||||
|
||||
def test_calib_unet_root_walk():
|
||||
import tempfile
|
||||
from modules.lora import lora_calib
|
||||
layer = build_layer('uint4', seed=47)
|
||||
sd = MockCalibSd('test/calib-unet', lin=layer)
|
||||
sd.unet = sd.transformer
|
||||
sd.transformer = None
|
||||
mods = lora_calib.eligible_modules(sd)
|
||||
assert [n for n, _ in mods] == ['lin'], f'the unet root must be walked when no transformer exists, got {[n for n, _ in mods]}'
|
||||
both = MockCalibSd('test/calib-both')
|
||||
both.unet = sd.unet
|
||||
assert lora_calib.eligible_modules(both) == [], 'a transformer root wins even when it holds no eligible linears'
|
||||
old_root = lora_calib.calib_root
|
||||
with tempfile.TemporaryDirectory() as tmp, host_calib(True):
|
||||
try:
|
||||
lora_calib.calib_root = tmp
|
||||
lora_calib.on_model_loaded(sd)
|
||||
assert len(lora_calib.capture['handles']) == 2, 'the layer hook plus the root counter must attach on a unet model'
|
||||
finally:
|
||||
lora_calib.calib_root = old_root
|
||||
lora_calib.detach_capture()
|
||||
return True
|
||||
|
||||
|
||||
CAT_FCACHE = category('factor-cache')
|
||||
|
||||
|
||||
@contextmanager
|
||||
def host_cache(gb, root):
|
||||
from modules.lora import lora_factor_cache
|
||||
old_gb = getattr(shared.opts, 'lora_sdnq_host_cache', 0)
|
||||
old_root = lora_factor_cache.cache_root
|
||||
shared.opts.lora_sdnq_host_cache = gb
|
||||
lora_factor_cache.cache_root = root
|
||||
lora_factor_cache.state.update(wn=None, sig=None, path=None, dirty=False, hits=0, misses=0)
|
||||
lora_factor_cache.state['store'] = {}
|
||||
try:
|
||||
yield lora_factor_cache
|
||||
finally:
|
||||
shared.opts.lora_sdnq_host_cache = old_gb
|
||||
lora_factor_cache.cache_root = old_root
|
||||
lora_factor_cache.state.update(wn=None, sig=None, path=None, dirty=False, hits=0, misses=0)
|
||||
lora_factor_cache.state['store'] = {}
|
||||
|
||||
|
||||
def cache_fixture(tmp, layer, name='cachenet', sigma=3e-4, seed=61):
|
||||
"""Dense net whose on-disk file exists (signature needs a stat-able path) plus a mock checkpoint identity."""
|
||||
torch.manual_seed(seed)
|
||||
D = torch.randn(OUT_F, IN_F, device=DEVICE) * sigma
|
||||
net = make_dense_net(name, layer, D)
|
||||
lora_file = os.path.join(tmp, f'{name}.safetensors')
|
||||
with open(lora_file, 'wb') as f:
|
||||
f.write(b'0' * 64)
|
||||
net.network_on_disk.filename = lora_file
|
||||
from modules.modeldata import model_data
|
||||
model_data.sd_model.sd_checkpoint_info = MockCheckpointInfo('test/cache-model')
|
||||
return net, D
|
||||
|
||||
|
||||
def raise_no_svd(*_args, **_kwargs):
|
||||
raise AssertionError('svd must not run on a cache hit')
|
||||
|
||||
|
||||
def test_factor_cache_roundtrip_bitexact():
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer)
|
||||
Wdq0 = dq(layer)
|
||||
activate(net)
|
||||
first_up = layer.svd_up.detach().clone()
|
||||
first_down = layer.svd_down.detach().clone()
|
||||
activate() # pass end flushed the entry; unload restores the base
|
||||
files = os.listdir(os.path.join(tmp, 'cache'))
|
||||
assert len(files) == 1, f'one cache entry expected, got {files}'
|
||||
bf16_bytes = (first_up.numel() + first_down.numel()) * 2
|
||||
entry_bytes = os.path.getsize(os.path.join(tmp, 'cache', files[0]))
|
||||
assert entry_bytes < bf16_bytes * 0.62 + 8192, f'int8 entry must be about half the bf16 factor bytes: {entry_bytes} vs {bf16_bytes}'
|
||||
real_svd = torch.svd_lowrank
|
||||
torch.svd_lowrank = raise_no_svd
|
||||
try:
|
||||
activate(net) # same configuration: must replay from disk without touching the svd
|
||||
finally:
|
||||
torch.svd_lowrank = real_svd
|
||||
assert torch.equal(layer.svd_up, first_up), 'cache hit must replay bit-identical up factors'
|
||||
assert torch.equal(layer.svd_down, first_down), 'cache hit must replay bit-identical down factors'
|
||||
activate()
|
||||
assert torch.equal(dq(layer), Wdq0), 'unload must restore bit-exact'
|
||||
return True
|
||||
|
||||
|
||||
def test_factor_cache_invalidates_on_multiplier():
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer)
|
||||
activate(net)
|
||||
up_full = layer.svd_up.detach().clone()
|
||||
activate()
|
||||
net.te_multiplier = 0.7
|
||||
net.unet_multiplier = [0.7] * 3
|
||||
activate(net) # different multiplier: different signature, fresh svd, second entry
|
||||
assert not torch.equal(layer.svd_up, up_full), 'multiplier change must produce different factors'
|
||||
activate()
|
||||
files = os.listdir(os.path.join(tmp, 'cache'))
|
||||
assert len(files) == 2, f'two cache entries expected, got {files}'
|
||||
return True
|
||||
|
||||
|
||||
def test_attach_trims_stored_null_tail():
|
||||
"""Entries written before tail slicing pad the channel with null ranks: zero up
|
||||
columns (and junk down rows behind them). Attach must trim to the effective rank
|
||||
and replay the same resident tensors and weights as the unpadded entry."""
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer, name='padnet')
|
||||
activate(net)
|
||||
up0 = layer.svd_up.detach().clone()
|
||||
down0 = layer.svd_down.detach().clone()
|
||||
Wl0 = dq(layer)
|
||||
activate()
|
||||
cache_dir = os.path.join(tmp, 'cache')
|
||||
entry = os.path.join(cache_dir, os.listdir(cache_dir)[0])
|
||||
from safetensors import safe_open
|
||||
from safetensors.torch import save_file
|
||||
with safe_open(entry, framework='pt', device='cpu') as f:
|
||||
meta = dict(f.metadata())
|
||||
tensors = {k: f.get_tensor(k) for k in f.keys()}
|
||||
for k in [k for k in tensors if k.endswith('.up_q')]:
|
||||
base = k[: -len('.up_q')]
|
||||
torch.manual_seed(5)
|
||||
tensors[f'{base}.up_q'] = torch.cat([tensors[k], torch.zeros(tensors[k].shape[0], 64, dtype=torch.int8)], dim=1)
|
||||
tensors[f'{base}.down_q'] = torch.cat([tensors[f'{base}.down_q'], torch.randint(-127, 128, (64, IN_F), dtype=torch.int8)], dim=0)
|
||||
tensors[f'{base}.down_s'] = torch.cat([tensors[f'{base}.down_s'], torch.ones(64, 1)], dim=0)
|
||||
save_file(tensors, entry, metadata=meta)
|
||||
real_svd = torch.svd_lowrank
|
||||
torch.svd_lowrank = raise_no_svd
|
||||
try:
|
||||
activate(net)
|
||||
finally:
|
||||
torch.svd_lowrank = real_svd
|
||||
assert layer.svd_up.shape[1] == 64, f'attach must trim the padded tail back to the effective rank, got {layer.svd_up.shape[1]}'
|
||||
assert torch.equal(layer.svd_up, up0) and torch.equal(layer.svd_down, down0), 'trimmed factors must match the unpadded entry'
|
||||
assert torch.equal(dq(layer), Wl0), 'trimmed attach must materialize the same weight'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
def test_factor_cache_int8_quantization():
|
||||
from modules.lora import lora_factor_cache as fc
|
||||
torch.manual_seed(71)
|
||||
t = torch.randn(64, 128, device=DEVICE) * torch.logspace(-3, 0, 64, device=DEVICE)[:, None] # rows spanning magnitudes
|
||||
q, s = fc.quantize_rowwise(t)
|
||||
assert q.dtype == torch.int8
|
||||
dq = fc.dequantize_rowwise(q, s)
|
||||
err = (dq - t).abs().max(dim=1).values
|
||||
assert bool((err <= s.squeeze(1) * 0.51).all()), 'rowwise int8 error must stay within half a step'
|
||||
cos = torch.nn.functional.cosine_similarity(dq.flatten(), t.flatten(), dim=0)
|
||||
assert float(cos) > 0.99995, f'int8 roundtrip cosine {float(cos):.6f}'
|
||||
return True
|
||||
|
||||
|
||||
def test_factor_cache_disabled_at_zero():
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(0, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer)
|
||||
activate(net)
|
||||
activate()
|
||||
assert not os.path.isdir(os.path.join(tmp, 'cache')), 'budget 0 must write nothing'
|
||||
return True
|
||||
|
||||
|
||||
def test_factor_cache_invalidates_on_calib_toggle():
|
||||
import tempfile
|
||||
from modules.lora import lora_calib
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer)
|
||||
old_root = lora_calib.calib_root
|
||||
lora_calib.calib_root = os.path.join(tmp, 'calib')
|
||||
os.makedirs(lora_calib.calib_root, exist_ok=True)
|
||||
with open(lora_calib.calib_file('test/cache-model'), 'wb') as f:
|
||||
f.write(b'0' * 64) # the signature stats this file; its content is never read here
|
||||
torch.manual_seed(77)
|
||||
layer.sdnq_calib_rms = torch.rand(IN_F) * 4 + 0.1
|
||||
try:
|
||||
with host_calib(True):
|
||||
activate(net)
|
||||
up_cal = layer.svd_up.detach().clone()
|
||||
activate()
|
||||
with host_calib(False):
|
||||
activate(net) # same set with calibration off: the entry keyed under the other setting must miss
|
||||
up_plain = layer.svd_up.detach().clone()
|
||||
activate()
|
||||
assert not torch.equal(up_cal, up_plain), 'toggling calibration must not replay factors computed under the other setting'
|
||||
assert len(os.listdir(os.path.join(tmp, 'cache'))) == 2, 'the two settings must key separate cache entries'
|
||||
finally:
|
||||
del layer.sdnq_calib_rms
|
||||
lora_calib.calib_root = old_root
|
||||
return True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def counting_calc():
|
||||
"""Count NetworkModuleFull.calc_updown calls: zero on a pass proves the walk skipped delta assembly."""
|
||||
from modules.lora import network_full
|
||||
calls = {'n': 0}
|
||||
real = network_full.NetworkModuleFull.calc_updown
|
||||
def wrapper(self, *args, **kwargs):
|
||||
calls['n'] += 1
|
||||
return real(self, *args, **kwargs)
|
||||
network_full.NetworkModuleFull.calc_updown = wrapper
|
||||
try:
|
||||
yield calls
|
||||
finally:
|
||||
network_full.NetworkModuleFull.calc_updown = real
|
||||
|
||||
|
||||
def test_cache_fastpath_skips_calc():
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net, _D = cache_fixture(tmp, layer)
|
||||
Wdq0 = dq(layer)
|
||||
with counting_calc() as calls:
|
||||
activate(net)
|
||||
assert calls['n'] > 0, 'a fresh apply must assemble the delta'
|
||||
first = dq(layer)
|
||||
activate()
|
||||
calls['n'] = 0
|
||||
activate(net)
|
||||
assert calls['n'] == 0, f'a cache replay must not assemble the delta: calc_updown ran {calls["n"]} times'
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash'), 'the fast path must attach the cached factors'
|
||||
assert torch.equal(dq(layer), first), 'fast-path replay must be bit-identical to the fresh apply'
|
||||
activate()
|
||||
assert torch.equal(dq(layer), Wdq0)
|
||||
return True
|
||||
|
||||
|
||||
def test_cache_fastpath_serves_mixed_set():
|
||||
import tempfile
|
||||
layer = build_layer('uint4')
|
||||
A, B, _D1 = make_delta(seed=63)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with host_rank(64), host_cache(10, os.path.join(tmp, 'cache')), mock_model(lin=layer):
|
||||
net_full, _D2 = cache_fixture(tmp, layer, name='mixfull', seed=64)
|
||||
net_plain = make_net('mixlora', layer, A, B)
|
||||
lora_file = os.path.join(tmp, 'mixlora.safetensors')
|
||||
with open(lora_file, 'wb') as f:
|
||||
f.write(b'0' * 64)
|
||||
net_plain.network_on_disk.filename = lora_file
|
||||
activate(net_plain, net_full)
|
||||
first = dq(layer)
|
||||
activate()
|
||||
with counting_calc() as calls:
|
||||
activate(net_plain, net_full) # the factorable member re-extracts from its own weights; the hosted remainder replays
|
||||
assert calls['n'] == 0, 'a mixed-set replay must not assemble the delta'
|
||||
assert hasattr(layer, 'sdnq_lora_svd_stash')
|
||||
assert torch.equal(dq(layer), first), 'mixed-set replay must be bit-identical to the fresh apply'
|
||||
activate()
|
||||
return True
|
||||
|
||||
|
||||
CAT_COMPILE = category('compile')
|
||||
|
||||
|
||||
def dq_compiled(layer):
|
||||
# the production entry: skip_compile left at its default so the shared compiled dequant runs
|
||||
return layer.sdnq_dequantizer(layer.weight, layer.scale, zero_point=layer.zero_point,
|
||||
svd_up=layer.svd_up, svd_down=layer.svd_down,
|
||||
skip_quantized_matmul=layer.sdnq_dequantizer.use_quantized_matmul,
|
||||
dtype=torch.float32)
|
||||
|
||||
|
||||
def graph_stats():
|
||||
from torch._dynamo.utils import counters
|
||||
return int(counters['stats']['unique_graphs']), sum(counters['graph_break'].values())
|
||||
|
||||
|
||||
def test_factor_add_inside_compiled_graph():
|
||||
from sdnq.common import use_torch_compile
|
||||
if not use_torch_compile:
|
||||
return True # compile disabled at sdnq import (no triton); nothing to pin
|
||||
import torch._dynamo
|
||||
from torch._dynamo.utils import counters
|
||||
layer = build_layer('uint4')
|
||||
A, B, _D = make_delta()
|
||||
dtype = layer.sdnq_dequantizer.result_dtype
|
||||
torch._dynamo.reset()
|
||||
counters.clear()
|
||||
lora_sdnq.append_factors(layer, [B.to(dtype)], [A.to(dtype)])
|
||||
W_c = dq_compiled(layer)
|
||||
graphs, breaks = graph_stats()
|
||||
assert breaks == 0, f'graph breaks in the compiled dequant: {breaks}'
|
||||
assert graphs == 1, f'factor-bearing dequant must be one compiled region, got {graphs} graphs'
|
||||
W_e = dq(layer)
|
||||
assert torch.allclose(W_c, W_e, rtol=1e-3, atol=1e-4), f'compiled vs eager dequant diverged, max {float((W_c - W_e).abs().max()):.3e}'
|
||||
lora_sdnq.remove_factors(layer)
|
||||
return True
|
||||
|
||||
|
||||
def test_rank_bucket_graph_reuse():
|
||||
from sdnq.common import use_torch_compile
|
||||
if not use_torch_compile:
|
||||
return True
|
||||
import torch._dynamo
|
||||
from torch._dynamo.utils import counters
|
||||
import sdnq.common as sdnq_common
|
||||
layer = build_layer('uint4', use_hadamard=False)
|
||||
dtype = layer.sdnq_dequantizer.result_dtype
|
||||
torch.manual_seed(13)
|
||||
mk = lambda r: (torch.randn(OUT_F, r, device=DEVICE, dtype=dtype) * 0.01, torch.randn(r, IN_F, device=DEVICE, dtype=dtype) * 0.01)
|
||||
B8, A8 = mk(8)
|
||||
B6, A6 = mk(6)
|
||||
B24, A24 = mk(24)
|
||||
|
||||
torch._dynamo.reset()
|
||||
counters.clear()
|
||||
lora_sdnq.append_factors(layer, [B8], [A8])
|
||||
assert layer.svd_up.shape[1] == 8, f'rank 8 must bucket to 8, got {layer.svd_up.shape[1]}'
|
||||
dq_compiled(layer)
|
||||
g_first, _ = graph_stats()
|
||||
|
||||
lora_sdnq.remove_factors(layer)
|
||||
lora_sdnq.append_factors(layer, [B6], [A6])
|
||||
assert layer.svd_up.shape[1] == 8, f'rank 6 must pad to bucket 8, got {layer.svd_up.shape[1]}'
|
||||
assert float(layer.svd_up[:, 6:].abs().sum()) == 0.0, 'pad columns must be exact zeros'
|
||||
dq_compiled(layer)
|
||||
g_same, _ = graph_stats()
|
||||
assert g_same == g_first, f'same bucket must reuse the graph: {g_first} -> {g_same}'
|
||||
|
||||
lora_sdnq.remove_factors(layer)
|
||||
lora_sdnq.append_factors(layer, [B24], [A24])
|
||||
assert layer.svd_up.shape[1] == 32, f'rank 24 must pad to bucket 32, got {layer.svd_up.shape[1]}'
|
||||
dq_compiled(layer)
|
||||
g_novel, _ = graph_stats()
|
||||
assert g_novel == g_first + 1, f'novel bucket must compile exactly one new graph: {g_first} -> {g_novel}'
|
||||
|
||||
lora_sdnq.remove_factors(layer)
|
||||
lora_sdnq.append_factors(layer, [B8], [A8])
|
||||
dq_compiled(layer)
|
||||
g_back, _ = graph_stats()
|
||||
assert g_back == g_novel, f'returning to a seen bucket must be free: {g_novel} -> {g_back}'
|
||||
|
||||
W_padded = dq(layer)
|
||||
lora_sdnq.remove_factors(layer)
|
||||
old_flag = sdnq_common.use_torch_compile
|
||||
sdnq_common.use_torch_compile = False
|
||||
try:
|
||||
lora_sdnq.append_factors(layer, [B8], [A8])
|
||||
assert layer.svd_up.shape[1] == 8
|
||||
W_unpadded = dq(layer)
|
||||
finally:
|
||||
sdnq_common.use_torch_compile = old_flag
|
||||
lora_sdnq.remove_factors(layer)
|
||||
assert torch.allclose(W_padded, W_unpadded, rtol=0.0, atol=1e-6), f'padding must be inert beyond reduction-order ulp, max {float((W_padded - W_unpadded).abs().max()):.3e}'
|
||||
return True
|
||||
|
||||
|
||||
CAT_ROBUST = category('robustness')
|
||||
|
||||
|
||||
@@ -964,12 +1566,25 @@ def run_tests():
|
||||
run_test(CAT_TRANS, fn)
|
||||
log.warning('=== Hosting ===')
|
||||
for fn in [test_hosted_low_rank_delta_is_kept, test_hosted_dense_delta_beats_requant, test_hosted_skips_int8,
|
||||
test_hosted_disabled_by_option, test_hosted_transitions_and_rng_isolation]:
|
||||
test_hosted_disabled_by_option, test_hosted_transitions_and_rng_isolation,
|
||||
test_route_fat_dense_delta_requantizes, test_route_rule_terms_gate_both_ways, test_route_low_rank_fat_delta_stays_hosted,
|
||||
test_route_mixed_set_keeps_hosting, test_route_svd_checkpoint_keeps_hosting,
|
||||
test_route_replay_from_cache, test_hosted_null_tail_collapses_to_effective_rank, test_hosted_flat_spectrum_keeps_cap]:
|
||||
run_test(CAT_HOST, fn)
|
||||
log.warning('=== Calibration ===')
|
||||
for fn in [test_calibrated_hosting_beats_plain, test_calibrated_low_rank_delta_survives, test_calib_option_off_matches_plain,
|
||||
test_calib_capture_persist_roundtrip, test_calib_capture_gates]:
|
||||
test_calib_capture_persist_roundtrip, test_calib_capture_gates, test_calib_deadline_persists_starved_layers,
|
||||
test_calib_deadline_omits_subfloor_layers, test_calib_unet_root_walk]:
|
||||
run_test(CAT_CALIB, fn)
|
||||
log.warning('=== Factor cache ===')
|
||||
for fn in [test_factor_cache_roundtrip_bitexact, test_factor_cache_invalidates_on_multiplier,
|
||||
test_factor_cache_int8_quantization, test_factor_cache_disabled_at_zero, test_factor_cache_invalidates_on_calib_toggle,
|
||||
test_cache_fastpath_skips_calc, test_cache_fastpath_serves_mixed_set,
|
||||
test_attach_trims_stored_null_tail]:
|
||||
run_test(CAT_FCACHE, fn)
|
||||
log.warning('=== Compile ===')
|
||||
for fn in [test_factor_add_inside_compiled_graph, test_rank_bucket_graph_reuse]:
|
||||
run_test(CAT_COMPILE, fn)
|
||||
log.warning('=== Robustness ===')
|
||||
for fn in [test_remove_factors_after_device_move, test_stacked_shape_mismatch_falls_back]:
|
||||
run_test(CAT_ROBUST, fn)
|
||||
|
||||
@@ -864,9 +864,10 @@
|
||||
{"id":"","label":"LoRA native apply to text encoder","localized":"","hint":"","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA native fuse with model","localized":"","hint":"Merge LoRA into the model for lower memory usage.<br><br><b style=\"color: #ef4444\">Warning:</b> After removing or switching a LoRA, you may still see its style in generated images. To get a clean model, reload it from the model selector.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA diffusers fuse with model","localized":"","hint":"Merge LoRA into the model for lower memory usage and torch.compile compatibility.<br><br><b style=\"color: #ef4444\">Warning:</b> After removing or switching a LoRA, you may still see its style in generated images. To get a clean model, reload it from the model selector.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA quantized apply method","localized":"","hint":"How networks are applied to SDNQ-quantized model weights:<br>- <b>exact</b>: adapters are carried alongside the quantized weights at full precision; apply and removal are exact and the quantized weights are never modified. The carried factors take additional VRAM, growing with adapter rank, size and count<br>- <b>requantize</b>: adapters are merged into the quantized weights, matching the behavior of earlier releases. Uses no additional VRAM (a weight backup for network removal is held in system RAM); on models quantized below 8 bits rounding typically loses much of the adapter effect, with strong adapters retaining more<br><br>With <b>requantize</b> selected, the host rank and calibration options below have no effect.<br><br>Default is <b>exact</b>.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA quantized apply method","localized":"","hint":"How networks are applied to SDNQ-quantized model weights:<br>- <b>exact</b>: adapters are carried alongside the quantized weights at full precision; apply and removal are exact and the quantized weights are never modified. The carried factors take additional VRAM, growing with adapter rank, size and count<br>- <b>requantize</b>: adapters are merged into the quantized weights, matching the behavior of earlier releases. Uses no additional VRAM (a weight backup for network removal is held in system RAM); on models quantized below 8 bits rounding typically loses much of the adapter effect, with strong adapters retaining more<br><br>With <b>requantize</b> selected, the host rank, calibration and cache options below have no effect.<br><br>Default is <b>exact</b>.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA quantized host rank","localized":"","hint":"Maximum rank used to carry non-factorable adapter types (LoKR, LoHA, OFT, DoRA) on the side-channel of SDNQ models quantized below 8 bits, where merging would erase most of the adapter. Higher values keep more of the adapter at proportionally higher memory cost. Set to 0 to disable and merge into the quantized weights instead.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA quantized host calibration","localized":"","hint":"Collection of per-channel activation statistics during normal generations on models quantized below 8 bits, cached per checkpoint. When available, the statistics weight side-channel hosting of non-factorable adapter types toward the channels carrying the most activation energy, improving delivered fidelity at the same host rank.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA quantized host cache","localized":"","hint":"Disk budget in GB for caching the side-channel factors computed when hosting non-factorable adapter types on quantized models. A cached configuration skips the truncation math on reapply; least recently used entries are evicted past the budget. Set to 0 to disable.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA auto-apply tags","localized":"","hint":"Automatically add trigger words/tags from LoRA metadata to your prompt.<br>Set to the number of tags to auto-apply, e.g., 3 = add top 3 trigger tags.<br>Set to 0 to disable, -1 to add all available tags.","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA memory cache","localized":"","hint":"How many LoRAs to keep in network for future use before requiring reloading from storage","ui":"settings_lora"},
|
||||
{"id":"","label":"LoRA add hash info to metadata","localized":"","hint":"Include LoRA file hashes in generated image metadata.<br>Useful for reproducibility and tracking which exact LoRA versions were used.","ui":"settings_lora"},
|
||||
|
||||
Reference in New Issue
Block a user