Files
2026-10-07 03:21:45 -07:00

414 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Training-side LoRA utilities for ``fastvideo.train`` model plugins."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from functools import partial
import hashlib
import math
from typing import Any
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.distributed.device_mesh import DeviceMesh
from torch.distributed.tensor import DTensor, Replicate
from fastvideo.distributed import get_local_torch_device
from fastvideo.layers.lora.linear import (
BaseLayerWithLoRA,
get_lora_layer,
replace_submodule,
)
from fastvideo.logger import init_logger
logger = init_logger(__name__)
DEFAULT_LORA_TARGET_MODULES = [
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"to_q",
"to_k",
"to_v",
"to_out",
"to_qkv",
"to_gate_compress",
]
_LORA_CONFIG_KEYS = ("enable", "rank", "alpha", "target_modules")
# Private loader attributes intentionally live on the transformed model. The
# component loader constructs the model on ``meta`` before FSDP sees it, so the
# structural transform and the checkpoint-loading plan have to travel together.
LORA_CHECKPOINT_KEY_ALIASES_ATTR = "_fastvideo_checkpoint_key_aliases"
LORA_MISSING_PARAMETER_INITIALIZER_ATTR = "_fastvideo_missing_parameter_initializer"
@dataclass
class LoraConfig:
"""Structured LoRA settings for one ``fastvideo.train`` model role.
Parsed from the nested ``models.<role>.lora`` YAML block::
lora:
enable: true # default false
rank: 16
alpha: 32 # defaults to rank when omitted
target_modules: [to_q, to_k, to_v, to_out]
``enable`` is an explicit on/off switch so a config states its intent
plainly: the presence of ``rank`` alone never silently flips a run into
LoRA-only training. When ``enable`` is false a still-present ``rank`` is
ignored (with an INFO log), so a configured-but-off block is valid.
"""
enable: bool = False
rank: int | None = None
alpha: int | None = None
target_modules: list[str] | None = None
def __post_init__(self) -> None:
if self.rank is not None:
self.rank = int(self.rank)
if self.alpha is not None:
self.alpha = int(self.alpha)
if self.target_modules is not None:
self.target_modules = list(self.target_modules)
if self.enable:
if self.rank is None:
raise ValueError("models.<role>.lora.enable is true but lora.rank is unset "
"— an explicit positive rank is required to enable LoRA")
if self.rank <= 0:
raise ValueError(f"models.<role>.lora.rank must be > 0, got {self.rank!r}")
elif self.rank is not None:
logger.info(
"models.<role>.lora.rank=%s is set but lora.enable is false — "
"LoRA will NOT be applied (model trains on its normal "
"trainable path).", self.rank)
@classmethod
def coerce(
cls,
obj: LoraConfig | dict[str, Any] | None,
) -> LoraConfig | None:
"""Normalize a raw YAML mapping (or existing config) into a LoraConfig.
Returns ``None`` when no ``lora`` block was given, which callers treat
as "LoRA not configured" — identical in effect to ``enable: false``.
"""
if obj is None:
return None
if isinstance(obj, LoraConfig):
return obj
if not isinstance(obj, dict):
raise TypeError("models.<role>.lora must be a mapping or LoraConfig, got "
f"{type(obj).__name__}")
unknown = set(obj) - set(_LORA_CONFIG_KEYS)
if unknown:
logger.warning("LoraConfig: ignoring unrecognized lora keys %s "
"(valid keys: %s)", sorted(unknown), list(_LORA_CONFIG_KEYS))
return cls(
enable=bool(obj.get("enable", False)),
rank=obj.get("rank"),
alpha=obj.get("alpha"),
target_modules=obj.get("target_modules"),
)
def _is_target_layer(
module_name: str,
target_modules: Sequence[str],
) -> bool:
return any(target_name in module_name for target_name in target_modules)
def _is_excluded_layer(
module_name: str,
excluded_modules: Sequence[str],
) -> bool:
return any(excluded in module_name for excluded in excluded_modules)
def _replicated_dims(parameter: DTensor) -> list[int]:
return [
mesh_dim for mesh_dim, placement in enumerate(parameter.placements)
if isinstance(placement, Replicate) and parameter.device_mesh.size(mesh_dim) > 1
]
def sync_replicated_lora_gradients(transformer: torch.nn.Module) -> int:
"""Average every replicated LoRA gradient over its mesh, on every rank.
A per-parameter ``register_post_accumulate_grad_hook`` is not enough: the
hook only fires on ranks where that parameter actually received a
gradient, so a rank whose loss skipped a layer issues fewer collectives
than its peers -- the ranks then pair the wrong tensors together, or hang
outright when the totals differ. This pass runs after backward and before
clipping/the optimizer step, walks the LoRA parameters in a stable order,
and joins the collective on every rank, zero-filling the gradient where the
rank contributed none (a rank that skipped the layer contributes exactly
zero to the average).
Returns the number of parameters synchronized.
"""
if not dist.is_available() or not dist.is_initialized():
return 0
synced = 0
for module in transformer.modules():
for attr_name in ("lora_A", "lora_B"):
parameter = getattr(module, attr_name, None)
if not isinstance(parameter, DTensor) or not parameter.requires_grad:
continue
replicated_dims = _replicated_dims(parameter)
if not replicated_dims:
continue
grad = parameter.grad
if grad is None:
local_grad = torch.zeros_like(parameter.to_local())
parameter.grad = DTensor.from_local(
local_grad,
device_mesh=parameter.device_mesh,
placements=parameter.placements,
run_check=False,
)
else:
local_grad = grad.to_local() if isinstance(grad, DTensor) else grad
for mesh_dim in replicated_dims:
dist.all_reduce(
local_grad,
group=parameter.device_mesh.get_group(mesh_dim),
)
local_grad.div_(parameter.device_mesh.size(mesh_dim))
synced += 1
return synced
def _make_replicated_lora_parameter(
parameter: nn.Parameter,
mesh: DeviceMesh,
) -> nn.Parameter:
"""Create a synchronized replicated DTensor for a late-added LoRA weight."""
placements = [Replicate()] * mesh.ndim
replicated = DTensor.from_local(
parameter.detach(),
device_mesh=mesh,
placements=placements,
run_check=True,
)
replicated_parameter = nn.Parameter(
replicated,
requires_grad=parameter.requires_grad,
)
return replicated_parameter
def _replicate_lora_parameters(transformer: torch.nn.Module, ) -> None:
"""Wrap LoRA params in replicated DTensors when distributed is active.
The training loaders shard the base transformer with FSDP/HSDP before the
model plugin sees it. Newly-added LoRA parameters therefore need to be
explicit replicated DTensors so optimizers/checkpointing can treat them the
same way across ranks. Replicated values are broadcast during creation,
and their rank-local gradients are averaged before the optimizer step.
The mesh is reused from the FSDP-wrapped base_layer parameters rather than
rebuilt via ``init_device_mesh`` — building a parallel mesh with a different
topology than the one FSDP already registered can conflict with the
existing mesh init. ``placements=[Replicate()] * mesh.ndim`` is passed
explicitly so the local tensor is treated as a replicated copy across all
mesh dimensions (instead of falling back to a default Shard layout).
"""
if not dist.is_available() or not dist.is_initialized():
return
device = get_local_torch_device()
if device.type != "cuda":
return
# Look up the mesh that FSDP/HSDP already attached to a base_layer
# parameter. Non-FSDP runs (e.g. single-GPU / non-distributed) won't have
# any DTensor params here; in that case we leave LoRA params as plain
# tensors, which is the correct local-only behavior.
mesh: DeviceMesh | None = None
for module in transformer.modules():
if not isinstance(module, BaseLayerWithLoRA):
continue
for p in module.base_layer.parameters():
if isinstance(p, DTensor):
mesh = p.device_mesh
break
if mesh is not None:
break
if mesh is None:
return
for module in transformer.modules():
if not isinstance(module, BaseLayerWithLoRA):
continue
module.base_layer.requires_grad_(False)
for attr_name in ("lora_A", "lora_B"):
param = getattr(module, attr_name, None)
if param is None:
continue
param.requires_grad_(True)
if isinstance(param, DTensor):
continue
setattr(
module,
attr_name,
_make_replicated_lora_parameter(param, mesh),
)
def _initialize_lora_parameter(
name: str,
shape: torch.Size,
dtype: torch.dtype,
*,
parameter_names: frozenset[str],
seed: int,
) -> torch.Tensor | None:
"""Build one deterministic full LoRA tensor for the FSDP loader.
Every rank constructs the same full tensor and lets the loader distribute
it according to the parameter's FSDP placement. Deriving a seed from the
fully-qualified name keeps initialization independent of state-dict/set
iteration order.
"""
if name not in parameter_names:
return None
if name.endswith(".lora_B"):
return torch.zeros(tuple(shape), dtype=dtype, device="cpu")
if not name.endswith(".lora_A") or len(shape) != 2:
raise ValueError(f"Unsupported LoRA parameter initialization request: {name} {tuple(shape)}")
name_seed = int.from_bytes(hashlib.sha256(name.encode("utf-8")).digest()[:8], "little")
generator = torch.Generator(device="cpu").manual_seed((int(seed) + name_seed) % (2**63 - 1))
# nn.init.kaiming_uniform_(a=sqrt(5)) simplifies to +/- 1/sqrt(fan_in).
bound = 1.0 / math.sqrt(int(shape[1]))
value = torch.empty(tuple(shape), dtype=torch.float32, device="cpu")
value.uniform_(-bound, bound, generator=generator)
return value.to(dtype=dtype)
def finalize_lora_training(transformer: torch.nn.Module) -> int:
"""Freeze base weights and enable only adapter parameters after loading."""
transformer.requires_grad_(False)
count = 0
for module in transformer.modules():
if not isinstance(module, BaseLayerWithLoRA):
continue
if module.lora_A is None or module.lora_B is None:
raise RuntimeError("Training LoRA wrapper is missing adapter parameters")
module.base_layer.requires_grad_(False)
module.lora_A.requires_grad_(True)
module.lora_B.requires_grad_(True)
count += 1
if count == 0:
raise ValueError("No training LoRA wrappers were found after checkpoint loading")
transformer.train()
return count
def enable_lora_training(
transformer: torch.nn.Module,
*,
lora_rank: int,
lora_alpha: int | None = None,
lora_target_modules: Sequence[str] | None = None,
prepare_for_fsdp: bool = False,
initialization_seed: int = 0,
) -> int:
"""Replace supported linear layers with trainable LoRA wrappers.
Returns the number of layers converted to LoRA.
"""
rank = int(lora_rank)
if rank <= 0:
raise ValueError(f"lora_rank must be > 0, got {lora_rank!r}")
alpha = int(lora_alpha) if lora_alpha is not None else rank
target_modules = list(lora_target_modules or DEFAULT_LORA_TARGET_MODULES)
arch_config = getattr(
getattr(transformer, "config", None),
"arch_config",
None,
)
excluded_modules = list(getattr(arch_config, "exclude_lora_layers", []), )
transformer.requires_grad_(False)
replacements: list[tuple[str, BaseLayerWithLoRA]] = []
checkpoint_key_aliases: dict[str, str] = {}
lora_parameter_names: set[str] = set()
for module_name, module in transformer.named_modules():
if not module_name:
continue
if not _is_target_layer(module_name, target_modules):
continue
if _is_excluded_layer(module_name, excluded_modules):
continue
lora_layer = get_lora_layer(
module,
lora_rank=rank,
lora_alpha=alpha,
training_mode=True,
)
if lora_layer is None:
continue
replacements.append((module_name, lora_layer))
for state_name in module.state_dict():
checkpoint_key_aliases[f"{module_name}.{state_name}"] = f"{module_name}.base_layer.{state_name}"
lora_parameter_names.update({f"{module_name}.lora_A", f"{module_name}.lora_B"})
if not replacements:
raise ValueError("No LoRA-compatible layers were found for the requested "
f"target modules: {target_modules}")
for module_name, lora_layer in replacements:
replace_submodule(transformer, module_name, lora_layer)
if prepare_for_fsdp:
# The FSDP loader consumes these aliases after the normal HF-to-native
# mapping, then initializes adapter tensors that are intentionally absent
# from the base checkpoint. FSDP therefore owns base and adapter params
# in the same parameter groups from the outset.
setattr(transformer, LORA_CHECKPOINT_KEY_ALIASES_ATTR, checkpoint_key_aliases)
setattr(
transformer,
LORA_MISSING_PARAMETER_INITIALIZER_ATTR,
partial(
_initialize_lora_parameter,
parameter_names=frozenset(lora_parameter_names),
seed=int(initialization_seed),
),
)
else:
# Retain the legacy post-load path for existing model plugins. New
# distributed integrations must use ``prepare_for_fsdp=True``.
_replicate_lora_parameters(transformer)
finalize_lora_training(transformer)
logger.info(
"Enabled LoRA training with rank=%d alpha=%d on %d layers",
rank,
alpha,
len(replacements),
)
return len(replacements)