diff --git a/cli/lora-quant-fidelity.py b/cli/lora-quant-fidelity.py index 74bd214cd..8aba5675c 100644 --- a/cli/lora-quant-fidelity.py +++ b/cli/lora-quant-fidelity.py @@ -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 diff --git a/modules/lora/lora_calib.py b/modules/lora/lora_calib.py index 032148944..622fa50e9 100644 --- a/modules/lora/lora_calib.py +++ b/modules/lora/lora_calib.py @@ -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 diff --git a/modules/lora/lora_factor_cache.py b/modules/lora/lora_factor_cache.py new file mode 100644 index 000000000..2b00bbcc7 --- /dev/null +++ b/modules/lora/lora_factor_cache.py @@ -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 diff --git a/modules/lora/lora_sdnq.py b/modules/lora/lora_sdnq.py index 63597111b..d95849527 100644 --- a/modules/lora/lora_sdnq.py +++ b/modules/lora/lora_sdnq.py @@ -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)') diff --git a/modules/lora/networks.py b/modules/lora/networks.py index 95c7675a5..9e3b2e7a0 100644 --- a/modules/lora/networks.py +++ b/modules/lora/networks.py @@ -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: diff --git a/modules/ui_definitions.py b/modules/ui_definitions.py index d59970138..a873ea68d 100644 --- a/modules/ui_definitions.py +++ b/modules/ui_definitions.py @@ -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("