mirror of https://github.com/razor-ai/soup.git
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""ReLoRA — periodic LoRA adapter magnitude-prune + optimizer reset.
|
|
|
|
Mirrors the technique described in the ReLoRA paper / Axolotl
|
|
``monkeypatch/relora.py``: every N steps, magnitude-prune the LoRA
|
|
adapter weights and reset the optimizer state. Useful for very long
|
|
training runs where the LoRA capacity saturates.
|
|
|
|
The callback is a no-op when ``policy`` is ``None``. Pass a
|
|
:class:`ReLoRAPolicy` to enable it. Lazy imports keep the module CLI-fast.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional
|
|
|
|
# Cap relora_steps to a sane upper bound.
|
|
MAX_RELORA_STEPS = 10**7
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReLoRAPolicy:
|
|
"""Frozen policy for the ReLoRA callback.
|
|
|
|
Attributes:
|
|
steps: fire every N global steps (must be > 0).
|
|
warmup_ratio: fraction of total steps to skip at the start ([0, 1]).
|
|
reset_optimizer: if True, clears optimizer state for the pruned
|
|
LoRA parameters after pruning so momentum doesn't fight the
|
|
new sparse weights.
|
|
prune_ratio: fraction of LoRA weights to zero out, by magnitude
|
|
(0 < x <= 1; e.g. 0.9 keeps the top 10%).
|
|
"""
|
|
|
|
steps: int
|
|
warmup_ratio: float = 0.1
|
|
reset_optimizer: bool = True
|
|
prune_ratio: float = 0.9
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.steps, int) or isinstance(self.steps, bool):
|
|
raise ValueError("ReLoRAPolicy.steps must be int")
|
|
if self.steps <= 0 or self.steps > MAX_RELORA_STEPS:
|
|
raise ValueError(
|
|
f"ReLoRAPolicy.steps must be in (0, {MAX_RELORA_STEPS}], got {self.steps}"
|
|
)
|
|
if not (0.0 <= self.warmup_ratio <= 1.0):
|
|
raise ValueError(
|
|
f"ReLoRAPolicy.warmup_ratio must be in [0, 1], got {self.warmup_ratio}"
|
|
)
|
|
# Mirror magnitude_prune_tensor's strict (0, 1) bound. prune_ratio=1.0
|
|
# would zero every weight on first fire — that's a footgun, not a feature.
|
|
if not (0.0 < self.prune_ratio < 1.0):
|
|
raise ValueError(
|
|
f"ReLoRAPolicy.prune_ratio must be in (0, 1), got {self.prune_ratio}"
|
|
)
|
|
|
|
def should_fire(self, global_step: int, total_steps: Optional[int] = None) -> bool:
|
|
if global_step <= 0:
|
|
return False
|
|
if global_step % self.steps != 0:
|
|
return False
|
|
if total_steps is not None and total_steps > 0:
|
|
warmup_cutoff = int(total_steps * self.warmup_ratio)
|
|
if global_step < warmup_cutoff:
|
|
return False
|
|
return True
|
|
|
|
|
|
def magnitude_prune_tensor(tensor: Any, prune_ratio: float) -> Any:
|
|
"""Zero out the smallest-magnitude entries of ``tensor`` in place.
|
|
|
|
``prune_ratio=0.9`` keeps the top 10% of weights by absolute value.
|
|
``prune_ratio`` of exactly 0.0 or 1.0 is rejected to avoid silent
|
|
no-ops or wholesale zeroing.
|
|
"""
|
|
if not (0.0 < prune_ratio < 1.0):
|
|
raise ValueError(
|
|
f"magnitude_prune_tensor prune_ratio must be in (0, 1), got {prune_ratio}"
|
|
)
|
|
import torch # lazy
|
|
|
|
if not isinstance(tensor, torch.Tensor):
|
|
raise TypeError(f"magnitude_prune_tensor expects torch.Tensor, got {type(tensor)}")
|
|
|
|
flat = tensor.detach().abs().reshape(-1)
|
|
# Empty / 1-element tensor: nothing meaningful to prune.
|
|
if flat.numel() <= 1:
|
|
return tensor
|
|
k = max(1, int(flat.numel() * prune_ratio))
|
|
if k >= flat.numel():
|
|
# keep the single largest
|
|
k = flat.numel() - 1
|
|
# The k-th smallest absolute value: anything <= it gets zeroed
|
|
threshold = torch.kthvalue(flat, k).values
|
|
mask = tensor.detach().abs() > threshold
|
|
tensor.detach().mul_(mask.to(tensor.dtype))
|
|
return tensor
|
|
|
|
|
|
def _is_lora_param_name(name: str) -> bool:
|
|
"""Match PEFT's lora_A / lora_B parameter naming."""
|
|
return ("lora_A" in name) or ("lora_B" in name)
|
|
|
|
|
|
class ReLoRACallback:
|
|
"""HF TrainerCallback that magnitude-prunes LoRA weights every N steps.
|
|
|
|
We don't subclass ``transformers.TrainerCallback`` here so importing
|
|
this module never loads transformers. The Trainer's callback dispatch
|
|
works structurally — any object with the right method names is fine.
|
|
"""
|
|
|
|
def __init__(self, policy: Optional[ReLoRAPolicy], console: Any = None) -> None:
|
|
self.policy = policy
|
|
self.console = console
|
|
self.fire_count = 0
|
|
|
|
def on_step_end(
|
|
self,
|
|
args: Any,
|
|
state: Any,
|
|
control: Any,
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
if self.policy is None:
|
|
return control
|
|
global_step = int(getattr(state, "global_step", 0) or 0)
|
|
total_steps = getattr(state, "max_steps", None)
|
|
try:
|
|
total_steps_int = int(total_steps) if total_steps else None
|
|
except (TypeError, ValueError):
|
|
total_steps_int = None
|
|
if not self.policy.should_fire(global_step, total_steps_int):
|
|
return control
|
|
model = kwargs.get("model")
|
|
optimizer = kwargs.get("optimizer")
|
|
if model is None:
|
|
return control
|
|
self._prune_and_reset(model, optimizer)
|
|
self.fire_count += 1
|
|
if self.console is not None:
|
|
try:
|
|
self.console.print(
|
|
f"[yellow]ReLoRA[/yellow] fired at step {global_step} "
|
|
f"(prune_ratio={self.policy.prune_ratio})"
|
|
)
|
|
except Exception: # noqa: BLE001 — console.print is best-effort in callback
|
|
pass
|
|
return control
|
|
|
|
def _prune_and_reset(self, model: Any, optimizer: Any) -> None:
|
|
import torch # lazy
|
|
|
|
pruned_params = []
|
|
for name, param in model.named_parameters():
|
|
if not _is_lora_param_name(name):
|
|
continue
|
|
if param.requires_grad and param.numel() > 0:
|
|
with torch.no_grad():
|
|
magnitude_prune_tensor(param.data, self.policy.prune_ratio)
|
|
pruned_params.append(param)
|
|
|
|
if optimizer is None or not self.policy.reset_optimizer:
|
|
return
|
|
# Reset optimizer state only for the pruned parameters.
|
|
try:
|
|
state = getattr(optimizer, "state", None)
|
|
if state is None:
|
|
return
|
|
for param in pruned_params:
|
|
if param in state:
|
|
state[param] = type(state[param])() if state[param] else {}
|
|
except Exception:
|
|
# Optimizer state structure varies (DeepSpeed / FSDP wrap it);
|
|
# silent best-effort is the documented Axolotl behaviour too.
|
|
return
|