feat(lora): reconstruct the lycoris sparse bias residual triplet

LyCORIS extraction with use_sparse_bias saves bias_indices/bias_values/
bias_size per module: the sparse weight-shaped remainder of the SVD
extraction, named bias for historical reasons. The keys were dropped by
both loader paths, so extracted adapters applied without the residual
correction; the dense-bias branch in finalize_updown that consumes it
was unreachable.

- rebuild the COO tensor in NetworkModule.__init__ (int16 indices cast
  to long), shared by the native and generic loaders; kept sparse so
  the dense += sparse in finalize_updown materializes per module at
  apply instead of near-model-size densification at load
- accept the triplet suffixes in LORA_SUFFIXES; fused targets skip
  with the weight-shaped-bias warning
- cover an extraction-faithful numeric round-trip and the fused skip
  in the offline suite
This commit is contained in:
CalamitousFelicitousness
2026-07-13 00:00:10 +01:00
parent 9c85902ee7
commit 72511f1bd7
3 changed files with 67 additions and 4 deletions
+6 -4
View File
@@ -104,8 +104,10 @@ LORA_SUFFIXES = (
# diff_b: bias delta some saves pair with the weight LoRA, applied as ex_bias.
# magnitude / lora_magnitude_vector: DoRA row norms (ai-toolkit / PEFT key
# names); converted onto the dora_scale path by try_load_lora.
# bias_indices/values/size: LyCORIS extraction sparse residual triplet,
# reconstructed into the dense-bias path by network.NetworkModule.
".alpha", ".dora_scale", ".magnitude", ".lora_magnitude_vector",
".bias", ".diff_b", ".scale",
".bias", ".bias_indices", ".bias_values", ".bias_size", ".diff_b", ".scale",
)
LOKR_SUFFIXES = (
".lokr_w1", ".lokr_w2",
@@ -586,9 +588,9 @@ def try_load_lora(name, network_on_disk, lora_scale, *,
target_w = w
if chunk is not None:
if "bias" in w:
# Legacy weight-shaped bias (LyCORIS sparse-residual heritage)
# has no defined partition on a fused target.
if "bias" in w or "bias_indices" in w:
# Weight-shaped bias residuals (dense or LyCORIS sparse
# triplet) are not partitioned onto fused targets.
log.warning(f'Network load: type=LoRA name="{name}" arch={arch_name} key={network_key} weight-shaped bias on fused target skipped (unsupported)')
skipped += 1
continue
+11
View File
@@ -1,6 +1,7 @@
import os
import enum
from collections import namedtuple
import torch
from modules import hashes, shared, sd_checkpoint
@@ -174,6 +175,16 @@ class NetworkModule:
self.shape = self.sd_module.weight.shape
self.dim = None
self.bias = weights.w.get("bias")
if self.bias is None and "bias_indices" in weights.w:
# LyCORIS extraction with use_bias: the sparse weight-shaped
# remainder of the SVD extraction ("bias" is historical naming),
# stored COO with int16 indices. Kept sparse; finalize_updown's
# dense += sparse materializes it per module at apply time.
self.bias = torch.sparse_coo_tensor(
weights.w["bias_indices"].to(torch.long),
weights.w["bias_values"],
tuple(weights.w["bias_size"]),
)
self.alpha = weights.w["alpha"].item() if "alpha" in weights.w else None
self.scale = weights.w["scale"].item() if "scale" in weights.w else None
self.dora_scale = weights.w.get("dora_scale", None)
+50
View File
@@ -1071,6 +1071,54 @@ def test_lora_peft_magnitude_vector():
return True
def test_lora_sparse_bias_residual():
"""LyCORIS use_bias extraction triplet reconstructs into the dense-bias path.
Mirrors the extraction save exactly: residual sparsified COO with int16
indices, values from the weight-shaped remainder, alpha == rank (scale 1).
Reference: total delta = up @ down + residual.
"""
torch.manual_seed(0)
down = torch.randn(RANK_LORA, QKV_OUT)
up = torch.randn(HIDDEN, RANK_LORA)
residual = torch.randn(HIDDEN, QKV_OUT)
residual[torch.rand_like(residual) < 0.98] = 0.0 # extraction sparsity default
sparse = residual.to_sparse().coalesce()
sd = {
'diffusion_model.double_blocks.1.img_attn.proj.lora_A.weight': down,
'diffusion_model.double_blocks.1.img_attn.proj.lora_B.weight': up,
'diffusion_model.double_blocks.1.img_attn.proj.alpha': torch.tensor(float(RANK_LORA)),
'diffusion_model.double_blocks.1.img_attn.proj.bias_indices': sparse.indices().to(torch.int16),
'diffusion_model.double_blocks.1.img_attn.proj.bias_values': sparse.values(),
'diffusion_model.double_blocks.1.img_attn.proj.bias_size': torch.tensor(residual.shape).to(torch.int16),
}
net = _load_via(F.try_load_lora, sd)
assert net is not None and len(net.modules) == 1, f'got {net.modules if net else None}'
mod = next(iter(net.modules.values()))
assert mod.bias is not None and mod.bias.is_sparse, f'bias={type(mod.bias)}'
w_base = torch.randn(HIDDEN, QKV_OUT)
updown, _ex_bias = mod.calc_updown(w_base)
ref = up @ down + residual
rel = ((updown - ref).norm() / (ref.norm() + 1e-12)).item()
assert torch.allclose(updown, ref, rtol=1e-4, atol=1e-5), f'rel err {rel:.4f}'
return True
def test_lora_sparse_bias_fused_skipped():
"""The sparse residual triplet on a fused target is skipped like dense bias."""
sd = dict(sd_lora_kohya_qkv())
residual = torch.zeros(3 * QKV_OUT, HIDDEN)
residual[0, 0] = 1.0
sparse = residual.to_sparse().coalesce()
base = 'lora_unet_double_blocks_0_img_attn_qkv'
sd[f'{base}.bias_indices'] = sparse.indices().to(torch.int16)
sd[f'{base}.bias_values'] = sparse.values()
sd[f'{base}.bias_size'] = torch.tensor(residual.shape).to(torch.int16)
net = _load_via(F.try_load_lora, sd)
assert net is None, f'expected skip, got {net.modules if net else None}'
return True
def test_lokr_shape_mismatch_rejected():
"""Kron dims that disagree with the module are rejected at load, not at apply."""
sd = sd_lokr_bfl_proj()
@@ -1560,6 +1608,8 @@ def run_tests():
test_lora_aitk_magnitude_dora,
test_lora_magnitude_fused_qkv_sliced,
test_lora_peft_magnitude_vector,
test_lora_sparse_bias_residual,
test_lora_sparse_bias_fused_skipped,
test_lokr_shape_mismatch_rejected,
test_lokr_fused_shape_mismatch_rejected,
test_lokr_bfl_non_fused,