Files
lora-lite/tests/smoke.py
T
2026-04-27 06:23:30 +08:00

476 lines
18 KiB
Python

"""Smoke test: current variants on a tiny synthetic transformer-like model.
Verifies:
1. Identity at t=0 (delta ~ 0, output close to base).
2. Save/load round-trip preserves outputs.
3. A few SGD steps reduce a random loss (gradients flow).
Run:
cd lora-lite
python -m pip install -e .
python tests/smoke.py
BLUF format:
SHOULD: max|y_adapter - y_base| < tol_init for all variants. ELSE init or hook bug.
SHOULD: loss decreases > 5% over 20 SGD steps for all variants. ELSE grad/wiring bug.
"""
from __future__ import annotations
import argparse
import os, sys, math
from pathlib import Path
import torch
from torch import nn
# allow running as `python tests/smoke.py` without install
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
import lora_lite as ll # noqa: E402
ARTIFACT_DIR = Path(__file__).parent / "_artifacts"
def assert_no_base_grads(model: nn.Module) -> None:
leaked = [name for name, p in model.named_parameters() if "lora_" not in name and p.grad is not None]
assert leaked == [], f"base params received grads: {leaked}"
# ---- a tiny transformer-like stack: 4 blocks of (q,k,v,o, gate,up,down) Linears ----
class TinyBlock(nn.Module):
def __init__(self, d=64, ff=128):
super().__init__()
self.q_proj = nn.Linear(d, d, bias=False)
self.k_proj = nn.Linear(d, d, bias=False)
self.v_proj = nn.Linear(d, d, bias=False)
self.o_proj = nn.Linear(d, d, bias=False)
self.gate_proj = nn.Linear(d, ff, bias=False)
self.up_proj = nn.Linear(d, ff, bias=False)
self.down_proj = nn.Linear(ff, d, bias=False)
def forward(self, x):
h = self.o_proj(self.q_proj(x) + self.k_proj(x) + self.v_proj(x))
m = self.down_proj(torch.nn.functional.silu(self.gate_proj(x)) * self.up_proj(x))
return x + h + m
class TinyModel(nn.Module):
def __init__(self, n_layers=4, d=64, ff=128, vocab=100):
super().__init__()
self.embed_tokens = nn.Embedding(vocab, d)
self.layers = nn.ModuleList([TinyBlock(d, ff) for _ in range(n_layers)])
self.lm_head = nn.Linear(d, vocab, bias=False)
class Cfg: # mimic HF .config.hidden_size
hidden_size = d
self.config = Cfg()
def forward(self, ids):
x = self.embed_tokens(ids)
for blk in self.layers:
x = blk(x)
return self.lm_head(x)
class FakeLinearLike(nn.Module):
"""Not nn.Linear, but structurally bnb-like enough for target discovery."""
def __init__(self, d_in=8, d_out=8):
super().__init__()
self.in_features = d_in
self.out_features = d_out
self.weight = nn.Parameter(torch.empty(d_out, d_in))
nn.init.kaiming_uniform_(self.weight, a=5 ** 0.5)
def forward(self, x):
return torch.nn.functional.linear(x, self.weight)
class FakeBnbModel(nn.Module):
def __init__(self):
super().__init__()
self.config = type("Cfg", (), {"hidden_size": 8})()
self.layers = nn.ModuleList([FakeLinearLike(8, 8)])
def forward(self, x):
return self.layers[0](x)
_CFG_BY_VARIANT = {
"lora": ll.LoRAConfig,
"pissa": ll.PiSSAConfig,
"delora": ll.DeLoRAConfig,
"ia3": ll.IA3Config,
"ia3_ff": ll.IA3FFConfig,
"dora": ll.DoRAConfig,
"hra": ll.HRAConfig,
"eva": ll.EVAConfig,
"antipasto": ll.AntiPaSTOConfig,
}
def variant_test(variant: str, dtype=torch.float32):
print(f"\n=== variant={variant} dtype={dtype} ===")
torch.manual_seed(0)
model = TinyModel().to(dtype)
ids = torch.randint(0, 100, (2, 16))
with torch.no_grad():
y_base = model(ids).clone()
cfg_cls = _CFG_BY_VARIANT[variant]
extra = {"lambda0": 15.0} if variant == "delora" else {}
cfg = cfg_cls(
r=4,
alpha=4 if variant == "pissa" else 8, # PiSSA needs scale==1 for clean recon
dtype=dtype,
# delora identity holds via B=0 init (peft semantics); use peft default lambda0=15.
**extra,
)
handles = ll.attach(model, cfg)
n_targets = len(handles)
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f" attached {n_targets} targets, trainable params={n_trainable}")
assert n_targets == 28, f"expected 28 TinyModel targets, got {n_targets}"
with torch.no_grad():
y_adapt = model(ids)
err = (y_adapt - y_base).abs().max().item()
base_scale = y_base.abs().max().item()
print(f" t=0 identity: max|y_adapt - y_base| = {err:.3e} (base scale {base_scale:.3e})")
# variant-specific identity tolerance
tol = {
"lora": 1e-6,
"pissa": 5e-4, # SVD recon in fp32 is tight; bf16 would be ~1e-2
"delora": 1e-6, # B=0 -> delta=0 regardless of lambda
"ia3": 1e-6,
"dora": 5e-5, # m * V/||V|| with V=W -> rounding in norm/divide
"hra": 1e-6, # gate=0 -> exact identity
"antipasto": 5e-4, # SVD truncation + W_res reconstruction in fp32
}[variant] * max(1.0, base_scale)
assert err < tol, f" FAIL identity: err {err} > tol {tol}"
print(f" SHOULD: err<{tol:.1e}. PASS.")
# save/load round-trip
ARTIFACT_DIR.mkdir(exist_ok=True)
p = ARTIFACT_DIR / f"{variant}_smoke_adapter.pt"
ll.save(model, str(p))
# detach + fresh model + load
ll.detach(model)
torch.manual_seed(0)
model2 = TinyModel().to(dtype)
# for PiSSA, base weights got mutated; load() re-runs PiSSA init on the fresh
# same-seed base, then overwrites lora_A/B with saved values.
ll.load(model2, str(p))
with torch.no_grad():
y_loaded = model2(ids)
err2 = (y_loaded - y_adapt).abs().max().item()
print(f" save/load: max|y_loaded - y_adapt| = {err2:.3e}")
assert err2 < tol, f" FAIL save/load: {err2} > {tol}"
print(f" SHOULD: err2<{tol:.1e}. PASS.")
ll.detach(model2)
# gradient flow: 20 SGD steps on random target.
# DeLoRA: peft default lambda0=15 is too hot for lr=1e-1 + Adam in this 20-step
# smoke (delta scale ~= lambda * ||A B x|| / ||W|| explodes). Drop to lambda0=0.1
# for training only; identity already validated above.
torch.manual_seed(0)
model = TinyModel().to(dtype)
train_cfg = cfg
if variant == "delora":
train_cfg = ll.DeLoRAConfig(
r=cfg.r, alpha=cfg.alpha, dtype=cfg.dtype, lambda0=0.1,
)
ll.attach(model, train_cfg)
target = torch.randn(2, 16, 100, dtype=dtype) * 0.1
trainable = [p for p in model.parameters() if p.requires_grad]
# delora has tightly-normalised updates; use Adam with higher lr to see signal in 20 steps
if variant in ("delora", "ia3", "hra"):
opt = torch.optim.Adam(trainable, lr=1e-1)
elif variant == "dora":
opt = torch.optim.Adam(trainable, lr=1e-3) # m near ||W||_c, bigger lr blows up
elif variant == "antipasto":
opt = torch.optim.Adam(trainable, lr=1e-2) # delta_s + rot_T, sensitive
else:
opt = torch.optim.SGD(trainable, lr=1e-2)
losses = []
for step in range(20):
opt.zero_grad()
loss = (model(ids) - target).pow(2).mean()
loss.backward()
assert_no_base_grads(model)
opt.step()
losses.append(loss.item())
drop = (losses[0] - losses[-1]) / max(losses[0], 1e-12)
print(f" loss[0]={losses[0]:.4f} loss[-1]={losses[-1]:.4f} drop={100*drop:.1f}%")
assert drop > 0.05, f" FAIL: loss drop only {drop:.2%}, expected >5%"
print(f" SHOULD: drop>5%. PASS.")
def structural_linear_like_test():
print("\n=== structural linear-like target test (bnb-style, not nn.Linear) ===")
torch.manual_seed(0)
model = FakeBnbModel()
x = torch.randn(2, 3, 8)
y_base = model(x).detach()
ll.attach(model, ll.LoRAConfig(r=2, alpha=4, dtype=torch.float32, target_roles=()))
layer = model.layers[0]
assert hasattr(layer, "lora_A") and hasattr(layer, "lora_B")
y = model(x)
err = (y.detach() - y_base).abs().max().item()
loss = y.pow(2).mean()
loss.backward()
grad_nonzero = layer.lora_B.grad.abs().sum().item() > 0
print(f" attached lora_A={tuple(layer.lora_A.shape)} lora_B={tuple(layer.lora_B.shape)}")
print(f" identity_err={err:.3e} grad_nonzero={grad_nonzero}")
assert err == 0.0
assert grad_nonzero
print(" SHOULD: structural target attaches and lora_B receives grad. PASS.")
def bitsandbytes_cuda_smoke(require_bnb: bool):
label = "required" if require_bnb else "optional"
print(f"\n=== {label} bitsandbytes CUDA smoke (every variant) ===")
if not torch.cuda.is_available():
if require_bnb:
raise RuntimeError("CUDA unavailable; required real bnb 4/8-bit smoke cannot run.")
print(" SKIP: CUDA unavailable; real bnb 4/8-bit forward needs GPU on this machine.")
return
try:
import bitsandbytes as bnb
except ImportError:
if require_bnb:
raise RuntimeError("bitsandbytes unavailable; install the bnb-test extra.")
print(" SKIP: bitsandbytes unavailable.")
return
class BnbModel(nn.Module):
def __init__(self, Layer):
super().__init__()
self.config = type("Cfg", (), {"hidden_size": 8})()
self.layers = nn.ModuleList([Layer(8, 8, bias=False)]).cuda()
def forward(self, x):
return self.layers[0](x)
# bnb-compatible: hook-only variants that never read layer.weight in a way
# that depends on dequant.
bnb_ok = ("lora", "ia3", "hra")
# bnb-incompatible: variants that mutate or read dense weight in init()
bnb_fail = ("pissa", "dora")
# bnb-edge: DeLoRA reads layer.weight in init() to capture ||W||_2. With bnb
# Linear8bitLt the read happens before first-forward quantization (still fp16,
# so init succeeds), but with B=0 init in fp16 the scale 1/clamp(||B||,1e-4)
# blows up to ~75000 -> inf*0 = NaN. Real bnb usage should dequantize first.
# Keep delora out of the strict pass/fail check.
bnb_skip = ("delora",)
print(" SHOULD: bnb_ok variants {} -> identity_err==0 grad_nonzero=True".format(bnb_ok))
print(" SHOULD: bnb_fail variants {} -> attach() raises (dequant required)".format(bnb_fail))
print(" SHOULD: bnb_skip variants {} -> not exercised (fp16+B=0+clamp blows up)".format(bnb_skip))
for layer_cls in (bnb.nn.Linear8bitLt, bnb.nn.Linear4bit):
for variant in bnb_ok:
torch.manual_seed(0)
model = BnbModel(layer_cls)
x = torch.randn(2, 3, 8, device="cuda")
y_base = model(x).detach()
cfg_cls = _CFG_BY_VARIANT[variant]
extra = {"lambda0": 0.1} if variant == "delora" else {}
# In fp16 + bnb, peft default lambda0=15 + B=0 + clamp(min=1e-4) gives
# scale=lambda/(r*1e-4) ~ 75000 > fp16 max -> inf*0 = NaN. Use small
# lambda0 for the fp16 test.
cfg = cfg_cls(r=2, alpha=4, dtype=torch.float16, target_roles=(), **extra)
ll.attach(model, cfg)
y = model(x)
err = (y.detach() - y_base).abs().max().item()
y.pow(2).mean().backward()
# find any trainable lora_* with a grad
grads = [(n, p.grad) for n, p in model.named_parameters() if "lora_" in n and p.requires_grad and p.grad is not None]
grad_nonzero = any(g.abs().sum().item() > 0 for _, g in grads)
print(f" {layer_cls.__name__:14s} {variant:6s}: identity_err={err:.3e} grad_nonzero={grad_nonzero}")
assert err < 1e-2, f" bnb identity err too large for {variant}"
assert grad_nonzero, f" no nonzero grad for {variant}"
ll.detach(model)
del model
for variant in bnb_fail:
model = BnbModel(layer_cls)
cfg = _CFG_BY_VARIANT[variant](r=2, alpha=2, dtype=torch.float16, target_roles=())
try:
ll.attach(model, cfg)
except (TypeError, RuntimeError, AttributeError, ValueError) as e:
print(f" {layer_cls.__name__:14s} {variant:6s}: fail-loud OK ({type(e).__name__})")
else:
raise AssertionError(f" {variant} on {layer_cls.__name__} should have failed loudly")
del model
def eva_smoke():
"""EVA needs calibration data: drives forward + per-target SVD on inputs."""
print("\n=== variant=eva (data-driven init via group_init+calibration_data) ===")
torch.manual_seed(0)
model = TinyModel().to(torch.float32)
ids = torch.randint(0, 100, (2, 16))
with torch.no_grad():
y_base = model(ids).clone()
cfg = ll.EVAConfig(r=4, alpha=8, dtype=torch.float32)
# 4 calibration batches of random ids
calib = [torch.randint(0, 100, (2, 16)) for _ in range(4)]
ll.attach(model, cfg, calibration_data=calib)
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f" trainable params={n_trainable} (lora_A AND lora_B both trainable per peft EVA)")
# peft EVA keeps A as a trainable Parameter; SVD only changes the INIT.
eva_layers = [m for m in model.modules() if hasattr(m, "lora_A")]
assert all(layer.lora_A.requires_grad for layer in eva_layers), \
"EVA lora_A must be a trainable Parameter (peft semantics)"
print(f" SHOULD: lora_A.requires_grad==True on every EVA layer. PASS.")
with torch.no_grad():
y_adapt = model(ids)
err = (y_adapt - y_base).abs().max().item()
print(f" t=0 identity: max|y_adapt - y_base| = {err:.3e}")
assert err < 1e-6, f"EVA should be exact identity (B=0); got {err}"
print(" SHOULD: err==0 (B=0 init). PASS.")
# check A buffer is non-zero (data-driven)
a_norms = [layer.lora_A.norm().item() for layer in [m for m in model.modules() if hasattr(m, "lora_A")]]
assert all(n > 0 for n in a_norms), "EVA lora_A buffers all zero -> group_init never ran"
print(f" SHOULD: lora_A buffers populated. PASS (mean ||A||={sum(a_norms)/len(a_norms):.3f}).")
# save/load round-trip WITHOUT calibration data on load (load path uses _skip_group_init)
ARTIFACT_DIR.mkdir(exist_ok=True)
p = ARTIFACT_DIR / "eva_smoke_adapter.pt"
ll.save(model, str(p))
ll.detach(model)
torch.manual_seed(0)
model2 = TinyModel().to(torch.float32)
ll.load(model2, str(p)) # must NOT require calibration_data
with torch.no_grad():
y_loaded = model2(ids)
err2 = (y_loaded - y_adapt).abs().max().item()
print(f" save/load (no calibration on load): max err = {err2:.3e}")
assert err2 < 1e-6, f"EVA save/load mismatch {err2}"
print(" SHOULD: load without calibration_data works (uses _skip_group_init). PASS.")
ll.detach(model2)
# re-attach model for training section below
ll.attach(model, cfg, calibration_data=calib)
# gradient flow: only B trains
target = torch.randn(2, 16, 100, dtype=torch.float32) * 0.1
trainable = [p for p in model.parameters() if p.requires_grad]
opt = torch.optim.SGD(trainable, lr=1e-2)
losses = []
for _ in range(20):
opt.zero_grad()
loss = (model(ids) - target).pow(2).mean()
loss.backward()
assert_no_base_grads(model)
opt.step()
losses.append(loss.item())
drop = (losses[0] - losses[-1]) / max(losses[0], 1e-12)
print(f" loss[0]={losses[0]:.4f} loss[-1]={losses[-1]:.4f} drop={100*drop:.1f}%")
assert drop > 0.05
print(" SHOULD: drop>5%. PASS.")
ll.detach(model)
def dora_bias_smoke():
"""V3 review caught: DoRA was scaling bias by m/||V||. Fixed; bias passes through."""
print("\n=== dora bias passthrough (V3 fix) ===")
torch.manual_seed(0)
d = 16
layer = nn.Linear(d, d, bias=True).to(torch.float32)
x = torch.randn(2, d)
y_base = layer(x).detach()
class Wrap(nn.Module):
def __init__(self, lin):
super().__init__()
self.config = type("Cfg", (), {"hidden_size": d})()
self.layers = nn.ModuleList([lin])
def forward(self, x):
return self.layers[0](x)
model = Wrap(layer)
cfg = ll.DoRAConfig(r=2, alpha=4, dtype=torch.float32, target_roles=())
ll.attach(model, cfg)
with torch.no_grad():
y_adapt = model(x)
err = (y_adapt - y_base).abs().max().item()
print(f" identity with bias=True: max err = {err:.3e}")
assert err < 1e-5, f"DoRA bias-passthrough broken: err {err} (likely bias being scaled)"
print(" SHOULD: identity err < 1e-5 even with bias. PASS.")
ll.detach(model)
def hra_forward_order_smoke():
"""Distinguishing check that HRA forward applies x @ R^T, not x @ R.
Build R = H_0 H_1 ... H_{r-1} explicitly from U, and compare the adapted
output to F.linear(x, W @ R). If our pre-hook iterated forward (x @ R, the
bug), this would match only at identity init (paired rows give R^T = R).
"""
print("\n=== hra forward-order vs F.linear(x, W @ R) ===")
torch.manual_seed(0)
d = 8
layer = nn.Linear(d, d, bias=False)
x = torch.randn(2, 3, d)
cfg = ll.HRAConfig(r=4, alpha=4, dtype=torch.float32, target_roles=())
class Wrap(nn.Module):
def __init__(self_, lin):
super().__init__()
self_.config = type("Cfg", (), {"hidden_size": d})()
self_.layers = nn.ModuleList([lin])
def forward(self_, x):
return self_.layers[0](x)
model = Wrap(layer)
ll.attach(model, cfg)
# break paired symmetry so order matters
with torch.no_grad():
layer.lora_U.add_(0.1 * torch.randn_like(layer.lora_U))
# build R = H_0 H_1 ... H_{r-1}
U = layer.lora_U
R = torch.eye(d)
for i in range(U.shape[0]):
u = U[i]
sq = (u * u).sum().clamp_min(1e-12)
R = R - (2.0 / sq) * torch.outer(R @ u, u)
with torch.no_grad():
y_adapt = model(x)
y_ref = torch.nn.functional.linear(x, layer.weight @ R)
err = (y_adapt - y_ref).abs().max().item()
print(f" ||y_adapt - F.linear(x, W @ R)||_inf = {err:.3e}")
assert err < 1e-5, (
"HRA forward order regression: should apply x @ R^T (loop reversed). "
"If you reverse the loop in forward_input you'll get x @ R instead, "
"and this check will fail with paired-symmetry-broken U."
)
print(" SHOULD: err < 1e-5 (proves loop applies x @ R^T not x @ R). PASS.")
ll.detach(model)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--require-bnb", action="store_true")
args = parser.parse_args()
for v in ("lora", "pissa", "delora", "ia3", "dora", "hra", "antipasto"):
variant_test(v, dtype=torch.float32)
eva_smoke()
dora_bias_smoke()
hra_forward_order_smoke()
structural_linear_like_test()
bitsandbytes_cuda_smoke(args.require_bnb)
print("\nALL PASS.")
if __name__ == "__main__":
main()