[bugfix] Restore CPU-backed NVFP4 weights for LoRA lifecycle updates (#2076)
Co-authored-by: Yixing Wang <269992774+leo2ultimate@users.noreply.github.com> Co-authored-by: leo <yixingwang@YIXINGs-MacBook-Pro.local>
This commit is contained in:
co-authored by
Yixing Wang
leo
parent
ec9145ab3d
commit
8570302850
@@ -67,9 +67,9 @@ The FastH3 example scripts take the same options. `basic_fasth3_lora_preview.py`
|
||||
`--lora-strength`, and `examples/serving/openai_fasth3_lora.yaml` shows the YAML form.
|
||||
|
||||
`lora_strength` multiplies the adapter delta. `1.0` is the published scale and `0` is exactly the model without the
|
||||
adapter. Values above `1.0` exaggerate the adapter and usually degrade quality. After construction, you can change
|
||||
the strength with `generator.set_lora_adapter(nickname, strength=...)` only when the adapter runs as a side branch, described
|
||||
next. Otherwise, create a new generator.
|
||||
adapter. Values above `1.0` exaggerate the adapter and usually degrade quality. For a pure low-rank adapter,
|
||||
`generator.set_lora_adapter(nickname, strength=...)` changes the strength at runtime. Adapters with dense
|
||||
replacement or additive weights require a new generator to change their strength.
|
||||
|
||||
## LoRAs trained on base H3
|
||||
|
||||
@@ -94,11 +94,13 @@ The extra cost is two small matmuls per adapted linear.
|
||||
|
||||
A dense bf16 checkpoint that FastVideo quantizes at load time (for example `transformer_quant: NVFP4` on a bf16
|
||||
export) still merges the adapter first and quantizes afterwards, so the quantized weight includes the adapter.
|
||||
If quantization retains the original dense weight, adapters continue to merge into that weight and FastVideo
|
||||
rebuilds the packed buffers after each adapter change.
|
||||
If the NVFP4 layer releases its working weight after packing, its merge-mode LoRA wrapper can restore it from
|
||||
the original unsharded CPU snapshot. Changing a pure low-rank adapter rebuilds the packed weights and scales.
|
||||
Accumulating another adapter first reapplies the previous contributions in order. Unmerging multiple accumulated
|
||||
adapters currently drops earlier contributions from inference ([#2096](https://github.com/hao-ai-lab/FastVideo/issues/2096)).
|
||||
|
||||
To make the adapter strength changeable at runtime on a dense checkpoint, set `FASTVIDEO_LORA_SIDE_BRANCH=1`. The
|
||||
adapter then runs as a side branch for every layer, at the cost of the extra matmuls. See
|
||||
To change strength on a dense checkpoint without remerging, set `FASTVIDEO_LORA_SIDE_BRANCH=1`. The
|
||||
adapter then runs as a side branch on supported layers, at the cost of the extra matmuls. See
|
||||
[Environment variables](../contributing/env_vars.md).
|
||||
|
||||
## When the whole model does not fit
|
||||
|
||||
@@ -23,7 +23,7 @@ _BUFFER_OFFLOAD_MIN_BYTES = 1 << 20
|
||||
|
||||
|
||||
def _offload_tensors(module: nn.Module, names: dict[str, torch.Tensor] | None = None):
|
||||
"""``(name, tensor)`` for every parameter and, when enabled, every large buffer.
|
||||
"""``(name, tensor)`` for parameters, placement markers and optionally large buffers.
|
||||
|
||||
``names`` restricts the walk to the tensors chosen at init: an offloaded buffer is a
|
||||
zero-element placeholder afterwards and would fail the size test.
|
||||
@@ -34,10 +34,12 @@ def _offload_tensors(module: nn.Module, names: dict[str, torch.Tensor] | None =
|
||||
yield name, tensor
|
||||
return
|
||||
yield from module.named_parameters()
|
||||
if envs.FASTVIDEO_LAYERWISE_OFFLOAD_BUFFERS.get():
|
||||
for name, buf in module.named_buffers():
|
||||
if buf is not None and buf.numel() * buf.element_size() >= _BUFFER_OFFLOAD_MIN_BYTES:
|
||||
yield name, buf
|
||||
offload_buffers = envs.FASTVIDEO_LAYERWISE_OFFLOAD_BUFFERS.get()
|
||||
for name, buf in module.named_buffers():
|
||||
# Empty placement markers follow weights, even when other buffers stay resident.
|
||||
is_weight_device = name.rsplit(".", 1)[-1] == "weight_device" and buf.numel() == 0
|
||||
if is_weight_device or (offload_buffers and buf.numel() * buf.element_size() >= _BUFFER_OFFLOAD_MIN_BYTES):
|
||||
yield name, buf
|
||||
|
||||
|
||||
class LayerwiseOffloadState:
|
||||
|
||||
+112
-13
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Code adapted from SGLang https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/lora/layers.py
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
import torch
|
||||
@@ -65,6 +66,15 @@ def requires_side_branch(layer: nn.Module) -> bool:
|
||||
return getattr(layer, "weight", None) is None
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class LoRAContribution:
|
||||
"""A scaled low-rank update for one layer."""
|
||||
|
||||
lora_A: torch.Tensor
|
||||
lora_B: torch.Tensor
|
||||
scale: float
|
||||
|
||||
|
||||
class BaseLayerWithLoRA(nn.Module):
|
||||
# Wrappers whose forward does not add the unmerged delta must keep merging.
|
||||
supports_side_branch: bool = True
|
||||
@@ -84,7 +94,12 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
# A frozen CPU snapshot is needed only to undo inference-time merging.
|
||||
# Training never merges adapters; retaining every H3 projection here
|
||||
# otherwise adds several GiB of host memory per HSDP rank.
|
||||
self.cpu_weight = (None if training_mode or self.side_branch else base_layer.weight.detach().to("cpu").clone())
|
||||
if training_mode or self.side_branch:
|
||||
self.cpu_weight = None
|
||||
else:
|
||||
self.cpu_weight = base_layer.weight.detach().to("cpu", copy=True)
|
||||
# None disables history; [] starts an eligible layer.
|
||||
self._merged_loras: list[LoRAContribution] | None = [] if self._should_save_merged_loras() else None
|
||||
# indicates adapter weights don't contain this layer
|
||||
# (which shouldn't normally happen, but we want to separate it from the case of erroneous merging)
|
||||
self.disable_lora: bool = False
|
||||
@@ -122,6 +137,16 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
self.lora_A = None
|
||||
self.lora_B = None
|
||||
|
||||
def _should_save_merged_loras(self) -> bool:
|
||||
if self.cpu_weight is None or isinstance(self.cpu_weight, DTensor):
|
||||
return False
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4QuantizeMethod
|
||||
|
||||
method = getattr(self.base_layer, "quant_method", None)
|
||||
return (isinstance(method, NVFP4QuantizeMethod)
|
||||
and getattr(method, "_retain_original_weights", None) is not True
|
||||
and not getattr(method, "_is_refine_only_layer", False))
|
||||
|
||||
@property
|
||||
def lora_strength(self) -> float:
|
||||
return self._lora_strength
|
||||
@@ -207,6 +232,23 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
lora_path: str | None = None,
|
||||
strength: float = 1.0,
|
||||
accumulate: bool = False) -> None:
|
||||
if not math.isfinite(strength) or (lora_alpha is not None and not math.isfinite(lora_alpha)):
|
||||
raise ValueError("LoRA alpha and strength must be finite")
|
||||
if (isinstance(self.base_layer, LinearBase | nn.Linear)
|
||||
and not isinstance(self.base_layer, MergedColumnParallelLinear | QKVParallelLinear)):
|
||||
if A.ndim != 2 or B.ndim != 2 or A.shape[0] == 0 or B.shape[1] != A.shape[0]:
|
||||
raise ValueError("LoRA factors must be matrices with matching nonzero rank")
|
||||
if isinstance(self.base_layer, nn.Linear):
|
||||
input_size, output_size = self.base_layer.in_features, self.base_layer.out_features
|
||||
else:
|
||||
input_size, output_size = self.base_layer.input_size, self.base_layer.output_size
|
||||
if A.shape[1] != input_size or B.shape[0] != output_size:
|
||||
raise ValueError("LoRA factor dimensions do not match the base weight")
|
||||
if not training_mode and not self.side_branch and self.base_layer.weight is None:
|
||||
if accumulate and self.merged:
|
||||
self._restore_merged_weight()
|
||||
else:
|
||||
self._reset_to_base_weight()
|
||||
if self.side_branch:
|
||||
if accumulate and self.lora_A is not None:
|
||||
raise ValueError("Accumulating adapters is unsupported on a side-branch LoRA layer (packed or "
|
||||
@@ -236,9 +278,20 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
if self.disable_lora or self.side_branch:
|
||||
return
|
||||
|
||||
assert self.lora_A is not None and self.lora_B is not None, "LoRA weights not set. Please set them first."
|
||||
if self.merged and not accumulate:
|
||||
self.unmerge_lora_weights()
|
||||
assert self.lora_A is not None and self.lora_B is not None, "LoRA weights not set. Please set them first."
|
||||
elif self.base_layer.weight is None:
|
||||
if self.merged:
|
||||
self._restore_merged_weight()
|
||||
else:
|
||||
self._reset_to_base_weight()
|
||||
contribution = None
|
||||
if self._merged_loras is not None:
|
||||
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
|
||||
contribution = LoRAContribution(lora_A=self.lora_A.detach().to("cpu", copy=True),
|
||||
lora_B=self.lora_B.detach().to("cpu", copy=True),
|
||||
scale=scale * self.lora_strength)
|
||||
if isinstance(self.base_layer.weight, DTensor):
|
||||
mesh = self.base_layer.weight.data.device_mesh
|
||||
unsharded_base_layer = ReplicatedLinear(
|
||||
@@ -274,18 +327,43 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
self.base_layer.weight.data = self._merged_weight(data).to(current_device, non_blocking=True)
|
||||
|
||||
self.merged = True
|
||||
if contribution is not None:
|
||||
self._merged_loras.append(contribution)
|
||||
|
||||
def _merged_weight(self, weight: torch.Tensor) -> torch.Tensor:
|
||||
def _merged_weight(self, weight: torch.Tensor, contribution: LoRAContribution | None = None) -> torch.Tensor:
|
||||
"""``weight`` plus the scaled adapter delta, in ``weight``'s dtype.
|
||||
|
||||
An adapter stored wider than the base (an FP32 adapter on a BF16 DiT) is multiplied and added at its own
|
||||
precision and rounded once, as a merge-then-cast reference does; with FSDP or without.
|
||||
"""
|
||||
accumulate_dtype = torch.promote_types(weight.dtype, self.lora_A.dtype)
|
||||
lora_B = self.slice_lora_b_weights(self.lora_B.to(device=weight.device, dtype=accumulate_dtype))
|
||||
lora_A = self.slice_lora_a_weights(self.lora_A.to(device=weight.device, dtype=accumulate_dtype))
|
||||
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
|
||||
return (weight.to(accumulate_dtype) + (lora_B @ lora_A) * (scale * self.lora_strength)).to(weight.dtype)
|
||||
if contribution is None:
|
||||
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
|
||||
adapter_a, adapter_b, scale = self.lora_A, self.lora_B, scale * self.lora_strength
|
||||
else:
|
||||
adapter_a, adapter_b, scale = contribution.lora_A, contribution.lora_B, contribution.scale
|
||||
accumulate_dtype = torch.promote_types(weight.dtype, adapter_a.dtype)
|
||||
lora_B = self.slice_lora_b_weights(adapter_b.to(device=weight.device, dtype=accumulate_dtype))
|
||||
lora_A = self.slice_lora_a_weights(adapter_a.to(device=weight.device, dtype=accumulate_dtype))
|
||||
return (weight.to(accumulate_dtype) + (lora_B @ lora_A) * scale).to(weight.dtype)
|
||||
|
||||
@torch.no_grad()
|
||||
def _restore_merged_weight(self) -> None:
|
||||
"""Rebuild the current merged weight from immutable CPU contributions."""
|
||||
contributions = self._merged_loras
|
||||
if not contributions:
|
||||
raise RuntimeError("Restoring accumulated LoRA requires saved CPU contributions")
|
||||
previous_weight = self.base_layer.weight
|
||||
try:
|
||||
self._restore_base_weight()
|
||||
current_device = self.base_layer.weight.device
|
||||
data = self.base_layer.weight.data.to(get_local_torch_device())
|
||||
for contribution in contributions:
|
||||
data = self._merged_weight(data, contribution)
|
||||
self.base_layer.weight.data = data.to(current_device)
|
||||
except Exception:
|
||||
self.base_layer.weight = previous_weight
|
||||
raise
|
||||
self.merged = True
|
||||
|
||||
@torch.no_grad()
|
||||
# @torch.compile(dynamic=True)
|
||||
@@ -298,18 +376,39 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
return
|
||||
raise ValueError("unmerge_lora_weights called but no LoRA is currently merged")
|
||||
|
||||
# avoid precision loss
|
||||
self._reset_to_base_weight()
|
||||
|
||||
@torch.no_grad()
|
||||
def _reset_to_base_weight(self) -> None:
|
||||
self._restore_base_weight()
|
||||
self.merged = False
|
||||
if self._merged_loras is not None:
|
||||
self._merged_loras = []
|
||||
|
||||
@torch.no_grad()
|
||||
def _restore_base_weight(self) -> None:
|
||||
"""Restore the base tensor from the immutable CPU snapshot.
|
||||
|
||||
The pipeline calls this inside the block's offload mutation scope; the placement buffer follows its weights.
|
||||
"""
|
||||
if self.cpu_weight is None:
|
||||
raise RuntimeError("Training-mode LoRA layers do not retain an inference unmerge snapshot")
|
||||
if isinstance(self.base_layer.weight, DTensor):
|
||||
if self.base_layer.weight is None:
|
||||
if isinstance(self.cpu_weight, DTensor):
|
||||
raise RuntimeError("Restoring a removed LoRA weight requires an unsharded CPU base snapshot.")
|
||||
weight_device = getattr(self.base_layer, "weight_device", None)
|
||||
if weight_device is None:
|
||||
raise RuntimeError("Restoring a removed LoRA weight requires its weight_device placement buffer.")
|
||||
restored_weight = self.cpu_weight.to(weight_device.device, non_blocking=True, copy=True)
|
||||
self.base_layer.weight = nn.Parameter(restored_weight, requires_grad=False)
|
||||
elif isinstance(self.base_layer.weight, DTensor):
|
||||
device = self.base_layer.weight.data.device
|
||||
self.base_layer.weight = nn.Parameter(self.cpu_weight.to(device, non_blocking=True, copy=True))
|
||||
self.base_layer.weight = nn.Parameter(self.cpu_weight.to(device, non_blocking=True, copy=True),
|
||||
requires_grad=self.base_layer.weight.requires_grad)
|
||||
else:
|
||||
# copy=True: on the snapshot's own device .to() would alias it, and the next merge would write into it.
|
||||
self.base_layer.weight.data = self.cpu_weight.data.to(self.base_layer.weight, non_blocking=True, copy=True)
|
||||
|
||||
self.merged = False
|
||||
|
||||
|
||||
class VocabParallelEmbeddingWithLoRA(BaseLayerWithLoRA):
|
||||
"""
|
||||
|
||||
@@ -884,6 +884,10 @@ def convert_model_to_nvfp4(model: torch.nn.Module) -> None:
|
||||
else:
|
||||
purged_bytes += weight.numel() * weight.element_size()
|
||||
purged += 1
|
||||
# Preserve weight placement after purge.
|
||||
mod.register_buffer("weight_device",
|
||||
torch.empty(0, device=weight.device, dtype=weight.dtype),
|
||||
persistent=False)
|
||||
mod.register_parameter("weight", None)
|
||||
|
||||
if purged or retained:
|
||||
|
||||
@@ -32,49 +32,6 @@ from fastvideo.utils import maybe_download_lora
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _merge_mode_modules(pipeline: Any, transformer_modules: dict[str, nn.Module]) -> Iterable[nn.Module]:
|
||||
"""Yield the modules an adapter change has to rewrite in place.
|
||||
|
||||
Side-branch LoRA layers leave their base weights alone, so packed NVFP4/FP8 linears behind them never
|
||||
block an adapter or strength change. In a transformer that has LoRA layers only the merge-mode ones can
|
||||
block it; in a transformer without them, every module is a candidate.
|
||||
"""
|
||||
lora_layers = getattr(pipeline, "lora_layers", None) or {}
|
||||
for transformer_name, transformer in transformer_modules.items():
|
||||
if is_lazy_module(transformer) and not transformer.is_materialized:
|
||||
# Nothing resident to rewrite, and touching the proxy would load it, or reload it from inside its own
|
||||
# materialize transform. A reload re-applies the adapter to fresh layers.
|
||||
continue
|
||||
transformer_layers = lora_layers.get(transformer_name)
|
||||
if transformer_layers is None:
|
||||
yield from transformer.modules()
|
||||
continue
|
||||
for _, layers in transformer_layers.lora_layers_by_block():
|
||||
for layer in layers.values():
|
||||
if not getattr(layer, "side_branch", False):
|
||||
yield getattr(layer, "base_layer", layer)
|
||||
|
||||
|
||||
def _has_nvfp4_weights_without_bf16(pipeline: Any, transformer_modules: dict[str, nn.Module]) -> bool:
|
||||
"""Return whether NVFP4 quantization removed the BF16 weight of a layer an adapter must merge into."""
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4QuantizeMethod
|
||||
|
||||
return any(
|
||||
isinstance(getattr(module, "quant_method", None), NVFP4QuantizeMethod)
|
||||
and getattr(module, "_nvfp4_weight", None) is not None and getattr(module, "weight", None) is None
|
||||
for module in _merge_mode_modules(pipeline, transformer_modules))
|
||||
|
||||
|
||||
def _has_quantized_nvfp4_weights(pipeline: Any, transformer_modules: dict[str, nn.Module]) -> bool:
|
||||
"""Return whether a layer an adapter must merge into contains an initialized NVFP4 weight."""
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4QuantizeMethod
|
||||
|
||||
return any(
|
||||
isinstance(getattr(module, "quant_method", None), NVFP4QuantizeMethod)
|
||||
and getattr(module, "_nvfp4_weight", None) is not None
|
||||
for module in _merge_mode_modules(pipeline, transformer_modules))
|
||||
|
||||
|
||||
def _get_hook_ctx(module: nn.Module | None):
|
||||
if module is None:
|
||||
return nullcontext()
|
||||
@@ -626,16 +583,8 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
return
|
||||
|
||||
if not self.lora_initialized:
|
||||
# Wrap the layers first, so the NVFP4 check skips packed linears that will run as side branches.
|
||||
self.convert_to_lora_layers()
|
||||
|
||||
if not self._setting_constructor_adapter and _has_nvfp4_weights_without_bf16(
|
||||
self, self.trainable_transformer_modules):
|
||||
# TODO(David): Restore the BF16 weights and requantize them after an NVFP4 LoRA adapter change.
|
||||
raise RuntimeError(
|
||||
"Runtime LoRA adapter changes are unsupported after NVFP4 quantization removed the BF16 weights. "
|
||||
"Create a new VideoGenerator with the desired LoRA adapter.")
|
||||
|
||||
if not self._setting_constructor_adapter:
|
||||
if self._constructor_dense_lora_path is not None:
|
||||
raise RuntimeError(
|
||||
@@ -806,16 +755,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
self._runtime_adapter = (lora_nickname, strength)
|
||||
|
||||
def merge_lora_weights(self) -> None:
|
||||
"""Merge LoRA weights, then repack the packed quantized buffers for the merged state.
|
||||
|
||||
NVFP4 layers are only supported while their BF16 weight is retained: a purged
|
||||
BF16 weight cannot be unmerged and remerged.
|
||||
"""
|
||||
if _has_nvfp4_weights_without_bf16(self, self.trainable_transformer_modules):
|
||||
# TODO(David): Merge from the packed FP4 weight instead of rejecting.
|
||||
raise RuntimeError(
|
||||
"LoRA merge is unsupported after NVFP4 weight quantization removed the BF16 weights because the "
|
||||
"FP4 weights would not reflect the merged LoRA adapter.")
|
||||
"""Merge LoRA weights, then repack the packed quantized buffers for the merged state."""
|
||||
for (
|
||||
transformer_name,
|
||||
transformer_lora_layers,
|
||||
@@ -857,15 +797,7 @@ class LoRAPipeline(ComposedPipelineBase):
|
||||
self._apply_constructor_adapter()
|
||||
|
||||
def unmerge_lora_weights(self) -> None:
|
||||
"""Unmerge LoRA weights, then repack the packed quantized buffers for the unmerged state.
|
||||
|
||||
NVFP4 is rejected: its FP4 buffers cannot be rebuilt from a purged BF16 weight.
|
||||
"""
|
||||
if _has_quantized_nvfp4_weights(self, self.trainable_transformer_modules):
|
||||
# TODO(David): Preserve BF16 weights and requantize NVFP4 weights after LoRA unmerge.
|
||||
raise RuntimeError(
|
||||
"LoRA unmerge is unsupported after NVFP4 weight quantization because the quantized weights would "
|
||||
"not reflect the unmerged LoRA adapter.")
|
||||
"""Unmerge LoRA weights, then repack the packed quantized buffers for the unmerged state."""
|
||||
for (
|
||||
transformer_name,
|
||||
transformer_lora_layers,
|
||||
|
||||
@@ -6,11 +6,47 @@ import torch.nn as nn
|
||||
|
||||
from fastvideo.hooks.layerwise_offload import (
|
||||
LayerwiseOffloadHook,
|
||||
LayerwiseOffloadState,
|
||||
enable_layerwise_offload,
|
||||
)
|
||||
from fastvideo.hooks.hooks import ModuleHookManager
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Placement tracking needs a real CUDA offload stream")
|
||||
@pytest.mark.parametrize("offload_buffers", [False, True])
|
||||
def test_empty_weight_device_tracks_offload_mutation_and_module_moves(env_overrides, offload_buffers: bool):
|
||||
import fastvideo.envs as envs
|
||||
|
||||
env_overrides.enter_context(envs.FASTVIDEO_LAYERWISE_OFFLOAD_BUFFERS.override(offload_buffers))
|
||||
device = torch.device("cuda", torch.cuda.current_device())
|
||||
module = nn.Linear(16, 16, dtype=torch.bfloat16)
|
||||
module.register_buffer("weight_device", torch.empty(0, dtype=torch.bfloat16), persistent=False)
|
||||
module.register_buffer("packed", torch.ones(1 << 20, dtype=torch.uint8))
|
||||
state = LayerwiseOffloadState(torch.cuda.Stream(), device)
|
||||
hook = LayerwiseOffloadHook(state)
|
||||
manager = ModuleHookManager.get_from_or_default(module)
|
||||
manager.append_forward_hook(hook)
|
||||
try:
|
||||
assert module.weight_device.device == module.weight.device == device
|
||||
assert "weight_device" in state.cpu_named_parameters
|
||||
assert ("packed" in state.cpu_named_parameters) is offload_buffers
|
||||
module.to(device)
|
||||
assert module.weight_device.device == device
|
||||
module.to("cpu")
|
||||
assert module.weight_device.device.type == "cpu"
|
||||
with hook.mutate_params_scope():
|
||||
assert module.weight_device.device == module.weight.device == device
|
||||
state.prefetch_params()
|
||||
state.wait_and_replace_params()
|
||||
assert module.weight_device.device == module.weight.device == device
|
||||
state.release_gpu_params()
|
||||
assert module.weight_device.device == module.weight.device == device
|
||||
assert module.weight_device.untyped_storage().nbytes() == 0
|
||||
assert "weight_device" not in module.state_dict()
|
||||
finally:
|
||||
manager.remove_forward_hook(hook.name())
|
||||
|
||||
|
||||
class SimpleBlock(nn.Module):
|
||||
"""A simple block with linear layers for testing."""
|
||||
|
||||
|
||||
@@ -331,6 +331,67 @@ def test_side_branch_rejects_accumulating_a_second_adapter() -> None:
|
||||
layer.set_lora_weights(a, b, accumulate=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["dense", "training", "side_branch"])
|
||||
def test_invalid_replacement_keeps_the_current_adapter(mode: str) -> None:
|
||||
training = mode == "training"
|
||||
base = _packed_linear() if mode == "side_branch" else _dense_linear()
|
||||
layer = get_lora_layer(base, lora_rank=RANK, lora_alpha=RANK, training_mode=training)
|
||||
a, b = _factors()
|
||||
layer.set_lora_weights(a, b, lora_alpha=RANK, strength=0.5, training_mode=training)
|
||||
previous_a, previous_b = layer.lora_A, layer.lora_B
|
||||
inputs = _x()
|
||||
output = layer(inputs)[0].detach().clone()
|
||||
assert (layer.cpu_weight is None) == (training or mode == "side_branch")
|
||||
|
||||
invalid = [
|
||||
(a, b, float("nan"), 1.0),
|
||||
(a, b, RANK, float("inf")),
|
||||
(a.unsqueeze(0), b, RANK, 1.0),
|
||||
(a[:0], b[:, :0], RANK, 1.0),
|
||||
(a, b[:, :-1], RANK, 1.0),
|
||||
(a[:, :-1], b, RANK, 1.0),
|
||||
(a, b[:-1], RANK, 1.0),
|
||||
]
|
||||
for new_a, new_b, alpha, strength in invalid:
|
||||
with pytest.raises(ValueError):
|
||||
layer.set_lora_weights(new_a, new_b, lora_alpha=alpha, strength=strength, training_mode=training)
|
||||
assert layer.lora_A is previous_a and layer.lora_B is previous_b
|
||||
assert layer.lora_strength == 0.5 and layer.lora_alpha == RANK
|
||||
torch.testing.assert_close(layer(inputs)[0], output, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("axis", ["row", "column"])
|
||||
def test_parallel_layer_merges_full_factors_into_local_partition(monkeypatch, axis: str) -> None:
|
||||
from fastvideo.layers import linear
|
||||
|
||||
monkeypatch.setattr(linear, "get_tp_world_size", lambda: 2)
|
||||
monkeypatch.setattr(linear, "get_tp_rank", lambda: 1)
|
||||
monkeypatch.setattr(lora_linear, "get_tp_rank", lambda: 1)
|
||||
projection = linear.RowParallelLinear if axis == "row" else linear.ColumnParallelLinear
|
||||
base = projection(IN, OUT, bias=False, params_dtype=torch.float32)
|
||||
base.weight.data.zero_()
|
||||
layer = get_lora_layer(base)
|
||||
a, b = _factors()
|
||||
layer.set_lora_weights(a, b)
|
||||
full_delta = b @ a
|
||||
expected = full_delta[:, IN // 2:] if axis == "row" else full_delta[OUT // 2:, :]
|
||||
torch.testing.assert_close(layer.base_layer.weight, expected)
|
||||
|
||||
|
||||
def test_fused_training_layer_accepts_stacked_projection_factors(monkeypatch) -> None:
|
||||
from fastvideo.layers import linear
|
||||
|
||||
monkeypatch.setattr(linear, "get_tp_world_size", lambda: 1)
|
||||
base = linear.MergedColumnParallelLinear(IN, [OUT, OUT], bias=False, params_dtype=torch.bfloat16)
|
||||
layer = get_lora_layer(base, lora_rank=RANK, lora_alpha=RANK, training_mode=True)
|
||||
a, b = _factors()
|
||||
fused_b = torch.stack([b, b * 2])
|
||||
layer.set_lora_weights(a, fused_b, training_mode=True)
|
||||
torch.testing.assert_close(layer.lora_B, fused_b)
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
layer.set_lora_weights(a, fused_b, strength=float("nan"), training_mode=True)
|
||||
|
||||
|
||||
def test_dense_linear_still_merges_and_unmerges_exactly() -> None:
|
||||
base = _dense_linear()
|
||||
original = base.weight.detach().clone()
|
||||
@@ -351,28 +412,16 @@ def test_parallel_wrappers_keep_merging() -> None:
|
||||
assert not RowParallelLinearWithLoRA.supports_side_branch
|
||||
|
||||
|
||||
def _pipeline_over(layer: BaseLayerWithLoRA) -> "SimpleNamespace":
|
||||
from types import SimpleNamespace
|
||||
transformer = torch.nn.Module()
|
||||
transformer.layer = layer
|
||||
layers = SimpleNamespace(lora_layers_by_block=lambda: iter([(None, {"layer": layer})]))
|
||||
return SimpleNamespace(lora_layers={"transformer": layers}, trainable_transformer_modules={"transformer": transformer})
|
||||
|
||||
|
||||
def test_packed_nvfp4_layers_behind_a_side_branch_do_not_block_adapter_changes() -> None:
|
||||
def test_packed_nvfp4_side_branch_can_skip_repacking() -> None:
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4QuantizeMethod
|
||||
from fastvideo.pipelines.lora_pipeline import _all_side_branch, _has_nvfp4_weights_without_bf16
|
||||
from fastvideo.pipelines.lora_pipeline import _all_side_branch
|
||||
|
||||
base = _packed_linear()
|
||||
base.quant_method = object.__new__(NVFP4QuantizeMethod)
|
||||
pipeline = _pipeline_over(get_lora_layer(base))
|
||||
|
||||
assert not _has_nvfp4_weights_without_bf16(pipeline, pipeline.trainable_transformer_modules)
|
||||
assert _all_side_branch([pipeline.lora_layers["transformer"].lora_layers_by_block().__next__()[1]["layer"]])
|
||||
assert _all_side_branch([get_lora_layer(base)])
|
||||
|
||||
|
||||
def test_runtime_adapter_on_packed_nvfp4_without_constructor_lora_is_not_blocked() -> None:
|
||||
"""Wrapping runs before the NVFP4 check, so packed linears that become side branches do not block it."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastvideo.layers.quantization.nvfp4_config import NVFP4QuantizeMethod
|
||||
@@ -400,7 +449,6 @@ def test_runtime_adapter_on_packed_nvfp4_without_constructor_lora_is_not_blocked
|
||||
pipeline.lora_initialized = True
|
||||
|
||||
pipeline.convert_to_lora_layers = convert_to_lora_layers
|
||||
# The adapter is unknown and has no path, so the call stops at the lookup that follows the NVFP4 check.
|
||||
with pytest.raises(ValueError, match="not found in the pipeline"):
|
||||
LoRAPipeline.set_lora_adapter(pipeline, "runtime-adapter")
|
||||
assert transformer.layer.side_branch
|
||||
|
||||
@@ -47,8 +47,9 @@ def test_dense_transformer_safetensors_drops_packed_export(tmp_path) -> None:
|
||||
assert nv.find_minimax_h3_nvfp4_dit_export([str(shard)]) == str(packed)
|
||||
|
||||
|
||||
def test_load_minimax_h3_nvfp4_dit_export_overlays_and_purges_weight(tmp_path) -> None:
|
||||
model = _model()
|
||||
@pytest.mark.parametrize("dense_device", ["cpu", "meta"])
|
||||
def test_load_minimax_h3_nvfp4_dit_export_overlays_and_purges_weight(tmp_path, dense_device: str) -> None:
|
||||
model = _model().to(dense_device)
|
||||
linear = model.transformer_blocks[0].attn.to_q
|
||||
prefix = "transformer_blocks.0.attn.to_q"
|
||||
tensors = {
|
||||
@@ -64,6 +65,7 @@ def test_load_minimax_h3_nvfp4_dit_export_overlays_and_purges_weight(tmp_path) -
|
||||
|
||||
assert loaded == 1
|
||||
assert linear.weight is None
|
||||
assert linear._nvfp4_weight.device.type == "cpu"
|
||||
assert linear._nvfp4_weight.dtype is torch.uint8
|
||||
assert tuple(linear._nvfp4_weight.shape) == (8, 8)
|
||||
assert linear._nvfp4_alpha.item() == 0.5
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CPU contracts for replaying active LoRA contributions after an NVFP4 purge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.layers.lora.linear import get_lora_layer
|
||||
from fastvideo.layers.quantization.mxfp8_config import MXFP8QuantizeMethod
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
from fastvideo.tests.ops.quantization.test_nvfp4_minimax_h3_wiring import (
|
||||
_assert_cpu_packed_weight,
|
||||
_CpuPackedNVFP4Method,
|
||||
_cpu_pack_nvfp4,
|
||||
_nvfp4_lifecycle_pipeline,
|
||||
cpu_nvfp4_lifecycle,
|
||||
)
|
||||
|
||||
|
||||
def _reference_update(weight, factors, *, alpha=None, strength=1.0):
|
||||
a, b = factors
|
||||
rank = a.shape[0]
|
||||
scale = ((alpha / rank) if alpha is not None else 1.0) * strength
|
||||
dtype = torch.promote_types(weight.dtype, a.dtype)
|
||||
delta = b.to(dtype) @ a.to(dtype)
|
||||
return (weight.to(dtype) + delta * scale).to(weight.dtype)
|
||||
|
||||
|
||||
def _install(pipeline, name, factors, *, alpha=None, strength=1.0, accumulate=True):
|
||||
a, b = factors
|
||||
pipeline.lora_adapters[name] = {"linear.lora_A": a, "linear.lora_B": b}
|
||||
if alpha is not None:
|
||||
pipeline.lora_adapters[name]["linear.lora_alpha"] = alpha
|
||||
LoRAPipeline.set_lora_adapter(pipeline, name, strength=strength, accumulate=accumulate)
|
||||
|
||||
|
||||
def test_accumulation_replays_multiple_adapters_with_original_dtype_and_scale(cpu_nvfp4_lifecycle):
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(False)
|
||||
third = (torch.randn(2, 32) / 8, torch.randn(32, 2) / 4)
|
||||
expected = _reference_update(original, first)
|
||||
|
||||
for name, factors, alpha, strength in [
|
||||
("second", second, 4.0, 0.5),
|
||||
("third", third, 3.5, -0.75),
|
||||
("third", third, 3.5, -0.75),
|
||||
]:
|
||||
expected = _reference_update(expected, factors, alpha=alpha, strength=strength)
|
||||
_install(pipeline, name, factors, alpha=alpha, strength=strength)
|
||||
_assert_cpu_packed_weight(wrapped, expected, False)
|
||||
|
||||
records = wrapped._merged_loras
|
||||
assert len(records) == 4
|
||||
assert records[0].lora_A.dtype == torch.bfloat16
|
||||
assert records[2].lora_A.dtype == records[2].lora_B.dtype == torch.float32
|
||||
assert records[2].scale == (3.5 / 2) * -0.75
|
||||
assert all(record.lora_A.device.type == record.lora_B.device.type == "cpu" for record in records)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
records[0].scale = 0.0
|
||||
torch.testing.assert_close(wrapped.cpu_weight, original, rtol=0, atol=0)
|
||||
|
||||
_install(pipeline, "replacement", third, alpha=3.5, strength=-0.75, accumulate=False)
|
||||
_assert_cpu_packed_weight(wrapped, _reference_update(original, third, alpha=3.5, strength=-0.75), False)
|
||||
assert len(wrapped._merged_loras) == 1
|
||||
|
||||
|
||||
def test_direct_accumulating_merge_after_each_purge_keeps_previous_updates(cpu_nvfp4_lifecycle):
|
||||
_, wrapped, original, first, _ = _nvfp4_lifecycle_pipeline(False)
|
||||
expected = _reference_update(original, first)
|
||||
|
||||
for _ in range(2):
|
||||
wrapped.merge_lora_weights(accumulate=True)
|
||||
expected = _reference_update(expected, first)
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, expected, rtol=0, atol=0)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
_assert_cpu_packed_weight(wrapped, expected, False)
|
||||
|
||||
assert len(wrapped._merged_loras) == 3
|
||||
|
||||
|
||||
def test_merge_after_clear_preserves_purged_state(cpu_nvfp4_lifecycle):
|
||||
pipeline, wrapped, original, _, _ = _nvfp4_lifecycle_pipeline(False)
|
||||
LoRAPipeline.clear_lora_adapter(pipeline)
|
||||
assert wrapped.base_layer.weight is None
|
||||
previous_flags = (wrapped.merged, wrapped.disable_lora, wrapped.side_branch)
|
||||
previous_records = wrapped._merged_loras
|
||||
buffers = {name: (value, value.clone()) for name, value in wrapped.base_layer.named_buffers()}
|
||||
|
||||
with pytest.raises(AssertionError, match="LoRA weights not set"):
|
||||
LoRAPipeline.merge_lora_weights(pipeline)
|
||||
|
||||
assert wrapped.base_layer.weight is None
|
||||
assert (wrapped.merged, wrapped.disable_lora, wrapped.side_branch) == previous_flags
|
||||
assert wrapped.lora_A is None and wrapped.lora_B is None
|
||||
assert wrapped._merged_loras is previous_records
|
||||
torch.testing.assert_close(wrapped.cpu_weight, original, rtol=0, atol=0)
|
||||
for name, (previous_buffer, expected) in buffers.items():
|
||||
assert getattr(wrapped.base_layer, name) is previous_buffer
|
||||
torch.testing.assert_close(getattr(wrapped.base_layer, name), expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_accumulated_round_trip_repacking_matches_dense_lifecycle(cpu_nvfp4_lifecycle):
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(False)
|
||||
base = ReplicatedLinear(32, 32, bias=False, params_dtype=torch.bfloat16)
|
||||
base.weight.data.copy_(original)
|
||||
dense = get_lora_layer(base)
|
||||
dense.set_lora_weights(*(factor.clone() for factor in first), lora_alpha=4)
|
||||
dense.set_lora_weights(*(factor.clone() for factor in second), lora_alpha=4, strength=0.5, accumulate=True)
|
||||
_install(pipeline, "second", second, alpha=4, strength=0.5)
|
||||
_assert_cpu_packed_weight(wrapped, dense.base_layer.weight, False)
|
||||
|
||||
for _ in range(2):
|
||||
dense.unmerge_lora_weights()
|
||||
LoRAPipeline.unmerge_lora_weights(pipeline)
|
||||
assert wrapped.merged == dense.merged
|
||||
_assert_cpu_packed_weight(wrapped, dense.base_layer.weight, False)
|
||||
|
||||
dense.merge_lora_weights()
|
||||
LoRAPipeline.merge_lora_weights(pipeline)
|
||||
assert wrapped.merged == dense.merged
|
||||
_assert_cpu_packed_weight(wrapped, dense.base_layer.weight, False)
|
||||
|
||||
|
||||
def test_replay_rounds_to_bf16_after_each_contribution(cpu_nvfp4_lifecycle):
|
||||
base = ReplicatedLinear(32, 32, bias=False, params_dtype=torch.bfloat16)
|
||||
base.weight.data.fill_(256)
|
||||
base.quant_method = _CpuPackedNVFP4Method(False)
|
||||
wrapped = get_lora_layer(base)
|
||||
factors = (torch.ones(1, 32, dtype=torch.bfloat16), torch.ones(32, 1, dtype=torch.bfloat16))
|
||||
|
||||
wrapped.set_lora_weights(*factors, lora_alpha=1)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
wrapped.set_lora_weights(*factors, lora_alpha=1, accumulate=True)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
wrapped.set_lora_weights(*factors, lora_alpha=1, strength=0.0, accumulate=True)
|
||||
|
||||
# 256 + 1 rounds back to 256 at each BF16 merge; combining the two deltas gives 258.
|
||||
expected = torch.full((32, 32), 256, dtype=torch.bfloat16)
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, expected, rtol=0, atol=0)
|
||||
assert not torch.equal(wrapped.base_layer.weight, expected + 2)
|
||||
|
||||
|
||||
def test_cpu_contributions_do_not_alias_adapter_cache_or_follow_scalar_changes(cpu_nvfp4_lifecycle):
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(False)
|
||||
applied_first = tuple(t.clone() for t in first)
|
||||
records = wrapped._merged_loras
|
||||
saved_a, saved_b, saved_scale = records[0].lora_A, records[0].lora_B, records[0].scale
|
||||
assert saved_a.data_ptr() != first[0].data_ptr()
|
||||
assert saved_b.data_ptr() != first[1].data_ptr()
|
||||
|
||||
first[0].fill_(9)
|
||||
first[1].fill_(-7)
|
||||
wrapped.lora_strength = 11.0
|
||||
wrapped.lora_alpha = 13.0
|
||||
_install(pipeline, "second", second, alpha=4, strength=0.5)
|
||||
|
||||
expected = _reference_update(_reference_update(original, applied_first), second, alpha=4, strength=0.5)
|
||||
_assert_cpu_packed_weight(wrapped, expected, False)
|
||||
torch.testing.assert_close(saved_a, applied_first[0], rtol=0, atol=0)
|
||||
torch.testing.assert_close(saved_b, applied_first[1], rtol=0, atol=0)
|
||||
assert saved_scale == 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain", [False, True])
|
||||
def test_tensor_restore_preserves_state_until_explicit_reset(cpu_nvfp4_lifecycle, retain: bool):
|
||||
_, wrapped, original, _, _ = _nvfp4_lifecycle_pipeline(retain)
|
||||
previous_records = wrapped._merged_loras
|
||||
previous_a, previous_b = wrapped.lora_A, wrapped.lora_B
|
||||
|
||||
wrapped._restore_base_weight()
|
||||
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, original, rtol=0, atol=0)
|
||||
assert wrapped.merged
|
||||
assert wrapped._merged_loras is previous_records
|
||||
assert wrapped.lora_A is previous_a and wrapped.lora_B is previous_b
|
||||
|
||||
wrapped._reset_to_base_weight()
|
||||
|
||||
assert not wrapped.merged
|
||||
if retain:
|
||||
assert wrapped._merged_loras is None
|
||||
else:
|
||||
assert wrapped._merged_loras == [] and wrapped._merged_loras is not previous_records
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, original, rtol=0, atol=0)
|
||||
torch.testing.assert_close(wrapped.cpu_weight, original, rtol=0, atol=0)
|
||||
assert wrapped.lora_A is previous_a and wrapped.lora_B is previous_b
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["rank", "width", "alpha", "strength"])
|
||||
@pytest.mark.parametrize("retain", [False, True])
|
||||
def test_invalid_accumulation_preserves_previous_factors_history_and_packed_weights(cpu_nvfp4_lifecycle, invalid,
|
||||
retain):
|
||||
pipeline, wrapped, _, _, second = _nvfp4_lifecycle_pipeline(retain)
|
||||
_install(pipeline, "second", second, strength=0.5)
|
||||
previous_a, previous_b = wrapped.lora_A, wrapped.lora_B
|
||||
previous_records = wrapped._merged_loras
|
||||
previous_weight = wrapped.base_layer.weight.detach().clone() if retain else None
|
||||
packed = {name: value.clone() for name, value in wrapped.base_layer.named_buffers()}
|
||||
a, b = torch.randn(3, 32), torch.randn(32, 3)
|
||||
alpha, strength = 3.0, 1.0
|
||||
if invalid == "rank":
|
||||
b = torch.randn(32, 2)
|
||||
elif invalid == "width":
|
||||
a = torch.randn(3, 31)
|
||||
elif invalid == "alpha":
|
||||
alpha = float("nan")
|
||||
else:
|
||||
strength = float("inf")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
wrapped.set_lora_weights(a, b, lora_alpha=alpha, strength=strength, accumulate=True)
|
||||
|
||||
assert wrapped.merged
|
||||
if retain:
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, previous_weight, rtol=0, atol=0)
|
||||
else:
|
||||
assert wrapped.base_layer.weight is None and len(previous_records) == 2
|
||||
assert wrapped.lora_A is previous_a and wrapped.lora_B is previous_b
|
||||
assert wrapped._merged_loras is previous_records
|
||||
for name, value in packed.items():
|
||||
torch.testing.assert_close(getattr(wrapped.base_layer, name), value, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_failed_replay_restores_previous_state_and_can_be_retried(cpu_nvfp4_lifecycle,
|
||||
monkeypatch: pytest.MonkeyPatch):
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(False)
|
||||
_install(pipeline, "second", second, strength=0.5)
|
||||
third = (torch.randn(2, 32) / 8, torch.randn(32, 2) / 4)
|
||||
previous_a, previous_b = wrapped.lora_A, wrapped.lora_B
|
||||
previous_records = wrapped._merged_loras
|
||||
packed = {name: value.clone() for name, value in wrapped.base_layer.named_buffers()}
|
||||
real_merge = wrapped._merged_weight
|
||||
replayed = 0
|
||||
|
||||
def fail_on_second_contribution(weight, contribution=None):
|
||||
nonlocal replayed
|
||||
if contribution is not None:
|
||||
replayed += 1
|
||||
if replayed == 2:
|
||||
raise RuntimeError("injected replay failure")
|
||||
return real_merge(weight, contribution)
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(wrapped, "_merged_weight", fail_on_second_contribution)
|
||||
with pytest.raises(RuntimeError, match="injected replay failure"):
|
||||
wrapped.set_lora_weights(*third, lora_alpha=2, strength=-0.25, accumulate=True)
|
||||
|
||||
assert replayed == 2
|
||||
assert wrapped.base_layer.weight is None and wrapped.merged
|
||||
assert wrapped.lora_A is previous_a and wrapped.lora_B is previous_b
|
||||
assert wrapped._merged_loras is previous_records
|
||||
assert len(previous_records) == 2
|
||||
for name, value in packed.items():
|
||||
torch.testing.assert_close(getattr(wrapped.base_layer, name), value, rtol=0, atol=0)
|
||||
|
||||
wrapped.set_lora_weights(*third, lora_alpha=2, strength=-0.25, accumulate=True)
|
||||
expected = _reference_update(_reference_update(original, first), second, strength=0.5)
|
||||
expected = _reference_update(expected, third, alpha=2, strength=-0.25)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
_assert_cpu_packed_weight(wrapped, expected, False)
|
||||
assert len(wrapped._merged_loras) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["bf16", "mxfp8", "nvfp4_retained", "nvfp4_refine", "training", "side_branch"])
|
||||
def test_other_paths_do_not_keep_cpu_contribution_snapshots(cpu_nvfp4_lifecycle, mode):
|
||||
base = ReplicatedLinear(32, 32, bias=False, params_dtype=torch.bfloat16)
|
||||
base.weight.data.fill_(0.125)
|
||||
if mode == "mxfp8":
|
||||
base.quant_method = object.__new__(MXFP8QuantizeMethod)
|
||||
elif mode in ("nvfp4_retained", "nvfp4_refine", "training", "side_branch"):
|
||||
base.quant_method = _CpuPackedNVFP4Method(mode == "nvfp4_retained")
|
||||
if mode == "nvfp4_refine":
|
||||
base.quant_method._is_refine_only_layer = True
|
||||
if mode == "side_branch":
|
||||
_cpu_pack_nvfp4(base)
|
||||
training = mode == "training"
|
||||
wrapped = get_lora_layer(base, lora_rank=2, lora_alpha=2, training_mode=training)
|
||||
factors = (torch.ones(2, 32, dtype=torch.bfloat16) / 8,
|
||||
torch.ones(32, 2, dtype=torch.bfloat16) / 8)
|
||||
wrapped.set_lora_weights(*factors, lora_alpha=2, training_mode=training)
|
||||
assert wrapped._merged_loras is None
|
||||
@@ -252,8 +252,9 @@ def test_unmerge_then_merge_lora_weights_mxfp8_requantizes_each_step(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain_bf16_weight", [False, True])
|
||||
def test_unmerge_lora_weights_rejects_quantized_nvfp4(retain_bf16_weight: bool) -> None:
|
||||
"""Reject NVFP4 LoRA unmerge whether the BF16 weight was removed or retained."""
|
||||
@pytest.mark.parametrize("operation", ["merge_lora_weights", "unmerge_lora_weights"])
|
||||
def test_nvfp4_without_lora_layers_is_a_lifecycle_noop(retain_bf16_weight: bool, operation: str,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
transformer = _TaggedModel(object.__new__(nvfp4.NVFP4QuantizeMethod))
|
||||
@@ -262,32 +263,17 @@ def test_unmerge_lora_weights_rejects_quantized_nvfp4(retain_bf16_weight: bool)
|
||||
nn.Parameter(torch.empty(1), requires_grad=False) if retain_bf16_weight else None,
|
||||
)
|
||||
transformer.linear.register_buffer("_nvfp4_weight", torch.empty(1))
|
||||
pipeline = SimpleNamespace(trainable_transformer_modules={"transformer": transformer})
|
||||
pipeline = SimpleNamespace(trainable_transformer_modules={"transformer": transformer}, lora_layers={})
|
||||
previous_weight = transformer.linear.weight
|
||||
previous_packed = transformer.linear._nvfp4_weight
|
||||
|
||||
with pytest.raises(RuntimeError, match="LoRA unmerge is unsupported after NVFP4 weight quantization"):
|
||||
LoRAPipeline.unmerge_lora_weights(pipeline)
|
||||
def unexpected_pack(model):
|
||||
pytest.fail("An adapter-free lifecycle operation must not repack the model")
|
||||
|
||||
|
||||
def test_set_lora_adapter_rejects_nvfp4_without_bf16_weight() -> None:
|
||||
"""Reject runtime adapter changes after NVFP4 removes the BF16 weight."""
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
transformer = _TaggedModel(object.__new__(nvfp4.NVFP4QuantizeMethod))
|
||||
transformer.linear.register_parameter("weight", None)
|
||||
transformer.linear.register_buffer("_nvfp4_weight", torch.empty(1))
|
||||
pipeline = SimpleNamespace(
|
||||
_setting_constructor_adapter=False,
|
||||
cur_adapter_name="first-adapter",
|
||||
cur_adapter_path="/first-adapter",
|
||||
cur_adapter_strength=1.0,
|
||||
lora_adapter_paths={},
|
||||
lora_initialized=True,
|
||||
lora_layers={},
|
||||
trainable_transformer_modules={"transformer": transformer},
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Runtime LoRA adapter changes are unsupported after NVFP4 quantization"):
|
||||
LoRAPipeline.set_lora_adapter(pipeline, "second-adapter", "/second-adapter")
|
||||
monkeypatch.setattr(nvfp4, "convert_model_to_nvfp4", unexpected_pack)
|
||||
getattr(LoRAPipeline, operation)(pipeline)
|
||||
assert transformer.linear.weight is previous_weight
|
||||
assert transformer.linear._nvfp4_weight is previous_packed
|
||||
|
||||
|
||||
def test_set_lora_adapter_mxfp8_requantizes_after_adapter_merge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -320,24 +306,455 @@ def test_set_lora_adapter_mxfp8_requantizes_after_adapter_merge(monkeypatch: pyt
|
||||
assert events == ["adapter merged", "weights requantized"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain_bf16_weight", [False, True])
|
||||
def test_merge_lora_weights_rejects_nvfp4_without_bf16(retain_bf16_weight: bool) -> None:
|
||||
"""Reject the NVFP4 LoRA merge only once the BF16 weight is gone."""
|
||||
class _CpuPackedNVFP4Method(nvfp4.NVFP4QuantizeMethod):
|
||||
"""CPU stand-in for packed storage and parameter purge, not an NVFP4 kernel emulator."""
|
||||
|
||||
def __init__(self, retain: bool) -> None:
|
||||
self.retain = retain
|
||||
self._retain_original_weights = retain
|
||||
self._is_refine_only_layer = False
|
||||
|
||||
def apply(self, layer, x, bias=None):
|
||||
weight = layer._nvfp4_weight.to(x.dtype) * layer._nvfp4_weight_scale.to(x.dtype)
|
||||
return torch.nn.functional.linear(x, weight, bias)
|
||||
|
||||
|
||||
def _cpu_packed_state(weight: torch.Tensor) -> dict[str, torch.Tensor]:
|
||||
return {
|
||||
"_nvfp4_weight": torch.round(weight.float() * 32).to(torch.int16),
|
||||
"_nvfp4_weight_scale": torch.full((), 1 / 32),
|
||||
"_weight_global_sf": weight.float().abs().amax().to(torch.bfloat16),
|
||||
"_nvfp4_alpha": weight.float().abs().amax().reciprocal(),
|
||||
}
|
||||
|
||||
|
||||
def _cpu_pack_nvfp4(model: nn.Module) -> None:
|
||||
for layer in model.modules():
|
||||
if isinstance(getattr(layer, "quant_method", None), _CpuPackedNVFP4Method) and layer.weight is not None:
|
||||
for name, value in _cpu_packed_state(layer.weight.detach()).items():
|
||||
layer.register_buffer(name, value, persistent=False)
|
||||
if not layer.quant_method.retain:
|
||||
layer.register_buffer("weight_device", torch.empty(0, device=layer.weight.device), persistent=False)
|
||||
layer.register_parameter("weight", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cpu_nvfp4_lifecycle(monkeypatch: pytest.MonkeyPatch):
|
||||
from fastvideo.layers.lora import linear as lora_linear
|
||||
|
||||
monkeypatch.setattr(lora_linear, "get_local_torch_device", lambda: torch.device("cpu"))
|
||||
monkeypatch.setattr(nvfp4, "convert_model_to_nvfp4", _cpu_pack_nvfp4)
|
||||
monkeypatch.setattr("fastvideo.pipelines.lora_pipeline.dist.get_rank", lambda: 0)
|
||||
with torch._dynamo.config.patch(disable=True):
|
||||
yield
|
||||
|
||||
|
||||
def _nvfp4_lifecycle_pipeline(retain: bool):
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.layers.lora.linear import get_lora_layer
|
||||
|
||||
torch.manual_seed(7)
|
||||
base = ReplicatedLinear(32, 32, bias=False, params_dtype=torch.bfloat16)
|
||||
with torch.no_grad():
|
||||
base.weight.copy_(torch.randn_like(base.weight) / 8)
|
||||
original = base.weight.detach().clone()
|
||||
base.quant_method = _CpuPackedNVFP4Method(retain)
|
||||
wrapped = get_lora_layer(base)
|
||||
first = (torch.randn(4, 32, dtype=torch.bfloat16) / 8, torch.randn(32, 4, dtype=torch.bfloat16) / 4)
|
||||
second = (torch.randn(4, 32, dtype=torch.bfloat16) / 4, torch.randn(32, 4, dtype=torch.bfloat16) / 4)
|
||||
wrapped.set_lora_weights(*first, lora_alpha=4)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
transformer = nn.Module()
|
||||
transformer.linear = wrapped
|
||||
layer_group = SimpleNamespace(lora_layers_by_block=lambda: iter([(None, {"linear": wrapped})]))
|
||||
pipeline = SimpleNamespace(
|
||||
lora_layers={"transformer": layer_group},
|
||||
trainable_transformer_modules={"transformer": transformer},
|
||||
_setting_constructor_adapter=False,
|
||||
_constructor_dense_lora_path=None,
|
||||
_apply_constructor_adapter=lambda: None,
|
||||
cur_adapter_name="first",
|
||||
cur_adapter_path=None,
|
||||
cur_adapter_strength=1.0,
|
||||
lora_initialized=True,
|
||||
lora_adapter_paths={},
|
||||
lora_adapters={
|
||||
"first": {"linear.lora_A": first[0], "linear.lora_B": first[1]},
|
||||
"second": {"linear.lora_A": second[0], "linear.lora_B": second[1]},
|
||||
},
|
||||
fastvideo_args=SimpleNamespace(training_mode=False),
|
||||
)
|
||||
return pipeline, wrapped, original, first, second
|
||||
|
||||
|
||||
def _assert_cpu_packed_weight(wrapped, expected: torch.Tensor, retain: bool) -> None:
|
||||
for name, value in _cpu_packed_state(expected).items():
|
||||
torch.testing.assert_close(getattr(wrapped.base_layer, name), value, rtol=0, atol=0)
|
||||
assert (wrapped.base_layer.weight is not None) is retain
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain", [False, True])
|
||||
def test_nvfp4_cpu_backed_unmerge_remerge_rebuilds_every_buffer(cpu_nvfp4_lifecycle, retain: bool) -> None:
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
transformer = _TaggedModel(object.__new__(nvfp4.NVFP4QuantizeMethod))
|
||||
transformer.linear.register_parameter(
|
||||
"weight",
|
||||
nn.Parameter(torch.empty(1), requires_grad=False) if retain_bf16_weight else None,
|
||||
)
|
||||
transformer.linear.register_buffer("_nvfp4_weight", torch.empty(1))
|
||||
pipeline = SimpleNamespace(
|
||||
trainable_transformer_modules={"transformer": transformer},
|
||||
lora_layers={},
|
||||
)
|
||||
|
||||
if retain_bf16_weight:
|
||||
pipeline, wrapped, original, first, _ = _nvfp4_lifecycle_pipeline(retain)
|
||||
merged = original + first[1] @ first[0]
|
||||
_assert_cpu_packed_weight(wrapped, merged, retain)
|
||||
inputs = torch.randn(3, 32, dtype=torch.bfloat16)
|
||||
for _ in range(2):
|
||||
LoRAPipeline.unmerge_lora_weights(pipeline)
|
||||
_assert_cpu_packed_weight(wrapped, original, retain)
|
||||
assert not wrapped.merged
|
||||
if retain:
|
||||
assert not wrapped.base_layer.weight.requires_grad
|
||||
packed = _cpu_packed_state(original)
|
||||
expected = torch.nn.functional.linear(
|
||||
inputs, packed["_nvfp4_weight"].to(inputs) * packed["_nvfp4_weight_scale"].to(inputs))
|
||||
expected += inputs @ first[0].T @ first[1].T
|
||||
torch.testing.assert_close(wrapped(inputs)[0], expected, rtol=0, atol=0)
|
||||
LoRAPipeline.merge_lora_weights(pipeline)
|
||||
_assert_cpu_packed_weight(wrapped, merged, retain)
|
||||
torch.testing.assert_close(wrapped.cpu_weight, original, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_nvfp4_restore_recreates_a_frozen_inference_parameter(cpu_nvfp4_lifecycle) -> None:
|
||||
_, wrapped, original, _, _ = _nvfp4_lifecycle_pipeline(False)
|
||||
wrapped.unmerge_lora_weights()
|
||||
|
||||
assert not wrapped.base_layer.weight.requires_grad
|
||||
assert wrapped.base_layer.weight.data_ptr() != wrapped.cpu_weight.data_ptr()
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, original, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_lora_training_keeps_adapter_gradients_without_inference_restore(cpu_nvfp4_lifecycle,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The shared wrapper trains A/B while freezing the base and never restoring inference weights."""
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.layers.lora.linear import get_lora_layer
|
||||
|
||||
torch.manual_seed(11)
|
||||
base = ReplicatedLinear(8, 6, bias=False, params_dtype=torch.float32)
|
||||
base.weight.requires_grad_(True)
|
||||
wrapped = get_lora_layer(base, lora_rank=2, lora_alpha=2, training_mode=True)
|
||||
assert wrapped.cpu_weight is None
|
||||
|
||||
def unexpected_restore():
|
||||
pytest.fail("LoRA training must not restore or merge inference weights")
|
||||
|
||||
monkeypatch.setattr(wrapped, "_restore_base_weight", unexpected_restore)
|
||||
wrapped.set_lora_weights(torch.randn(2, 8), torch.randn(6, 2), training_mode=True)
|
||||
assert not wrapped.merged and not wrapped.base_layer.weight.requires_grad
|
||||
assert wrapped.lora_A.requires_grad and wrapped.lora_B.requires_grad
|
||||
original = wrapped.base_layer.weight.detach().clone()
|
||||
adapter_before = [wrapped.lora_A.detach().clone(), wrapped.lora_B.detach().clone()]
|
||||
optimizer = torch.optim.SGD([wrapped.lora_A, wrapped.lora_B], lr=0.01)
|
||||
inputs = torch.randn(3, 8, requires_grad=True)
|
||||
|
||||
wrapped(inputs)[0].square().mean().backward()
|
||||
|
||||
assert wrapped.base_layer.weight.grad is None
|
||||
assert inputs.grad is not None and inputs.grad.abs().sum() > 0
|
||||
for parameter in (wrapped.lora_A, wrapped.lora_B):
|
||||
assert parameter.grad is not None and parameter.grad.abs().sum() > 0
|
||||
optimizer.step()
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, original, rtol=0, atol=0)
|
||||
for parameter, previous in zip((wrapped.lora_A, wrapped.lora_B), adapter_before, strict=True):
|
||||
assert not torch.equal(parameter, previous)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain", [False, True])
|
||||
def test_nvfp4_cpu_backed_adapter_and_strength_changes_replace_old_delta(cpu_nvfp4_lifecycle, retain: bool) -> None:
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(retain)
|
||||
for nickname, strength, factors in [("second", 0.5, second), ("second", 1.5, second), ("first", 1.0, first)]:
|
||||
LoRAPipeline.set_lora_adapter(pipeline, nickname, strength=strength)
|
||||
_assert_cpu_packed_weight(wrapped, original + (factors[1] @ factors[0]) * strength, retain)
|
||||
torch.testing.assert_close(wrapped.cpu_weight, original, rtol=0, atol=0)
|
||||
LoRAPipeline.clear_lora_adapter(pipeline)
|
||||
_assert_cpu_packed_weight(wrapped, original, retain)
|
||||
assert wrapped.lora_A is None and wrapped.lora_B is None
|
||||
|
||||
|
||||
def test_nvfp4_retained_bf16_accumulation_requantizes(cpu_nvfp4_lifecycle) -> None:
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(True)
|
||||
LoRAPipeline.set_lora_adapter(pipeline, "second", strength=0.5, accumulate=True)
|
||||
_assert_cpu_packed_weight(wrapped, (original + first[1] @ first[0]) + (second[1] @ second[0]) * 0.5, True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["pipeline", "layer", "merge"])
|
||||
def test_nvfp4_purged_accumulation_reconstructs_before_appending(cpu_nvfp4_lifecycle, entrypoint: str) -> None:
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
pipeline, wrapped, original, first, second = _nvfp4_lifecycle_pipeline(False)
|
||||
expected = original + first[1] @ first[0]
|
||||
if entrypoint == "pipeline":
|
||||
LoRAPipeline.set_lora_adapter(pipeline, "second", accumulate=True)
|
||||
elif entrypoint == "layer":
|
||||
wrapped.set_lora_weights(*second, accumulate=True)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="LoRA merge is unsupported after NVFP4 weight quantization"):
|
||||
LoRAPipeline.merge_lora_weights(pipeline)
|
||||
wrapped.merge_lora_weights(accumulate=True)
|
||||
_cpu_pack_nvfp4(wrapped)
|
||||
added = first if entrypoint == "merge" else second
|
||||
_assert_cpu_packed_weight(wrapped, expected + added[1] @ added[0], False)
|
||||
assert len(wrapped._merged_loras) == 2
|
||||
torch.testing.assert_close(wrapped.cpu_weight, original, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain", [False, True])
|
||||
def test_disjoint_nvfp4_accumulation_then_clear(cpu_nvfp4_lifecycle, retain: bool) -> None:
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
pipeline, first_layer, original, first, second = _nvfp4_lifecycle_pipeline(retain)
|
||||
_, second_layer, _, _, _ = _nvfp4_lifecycle_pipeline(retain)
|
||||
model = nn.Module()
|
||||
model.first, model.second = first_layer, second_layer
|
||||
layers = {"first": first_layer, "second": second_layer}
|
||||
pipeline.trainable_transformer_modules = {"transformer": model}
|
||||
pipeline.lora_layers = {"transformer": SimpleNamespace(lora_layers_by_block=lambda: iter([(None, layers)]))}
|
||||
pipeline.lora_adapters = {
|
||||
"a": {"first.lora_A": first[0], "first.lora_B": first[1]},
|
||||
"b": {"second.lora_A": second[0], "second.lora_B": second[1]},
|
||||
}
|
||||
LoRAPipeline.clear_lora_adapter(pipeline)
|
||||
LoRAPipeline.set_lora_adapter(pipeline, "a")
|
||||
LoRAPipeline.set_lora_adapter(pipeline, "b", accumulate=True)
|
||||
_assert_cpu_packed_weight(first_layer, original + first[1] @ first[0], retain)
|
||||
_assert_cpu_packed_weight(second_layer, original + second[1] @ second[0], retain)
|
||||
LoRAPipeline.clear_lora_adapter(pipeline)
|
||||
for layer in layers.values():
|
||||
_assert_cpu_packed_weight(layer, original, retain)
|
||||
assert layer.lora_A is None and layer.lora_B is None
|
||||
assert layer._merged_loras in (None, [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["snapshot", "weight_device"])
|
||||
def test_nvfp4_invalid_restore_fails_at_layer_boundary_without_mutation(cpu_nvfp4_lifecycle, missing: str) -> None:
|
||||
"""Restoration checks its own required state rather than scanning unrelated pipeline modules."""
|
||||
from fastvideo.pipelines.lora_pipeline import LoRAPipeline
|
||||
|
||||
pipeline, wrapped, _, _, _ = _nvfp4_lifecycle_pipeline(False)
|
||||
if missing == "snapshot":
|
||||
wrapped.cpu_weight = None
|
||||
else:
|
||||
wrapped.base_layer.weight_device = None
|
||||
previous_a, previous_b = wrapped.lora_A, wrapped.lora_B
|
||||
with pytest.raises(RuntimeError, match="snapshot|weight_device"):
|
||||
LoRAPipeline.unmerge_lora_weights(pipeline)
|
||||
assert wrapped.base_layer.weight is None and wrapped.merged
|
||||
assert wrapped.lora_A is previous_a and wrapped.lora_B is previous_b
|
||||
|
||||
|
||||
def test_nvfp4_restore_uses_placement_marker_without_reading_packed_values(cpu_nvfp4_lifecycle) -> None:
|
||||
_, wrapped, original, _, _ = _nvfp4_lifecycle_pipeline(False)
|
||||
wrapped.base_layer._nvfp4_weight = None
|
||||
wrapped._restore_base_weight()
|
||||
torch.testing.assert_close(wrapped.base_layer.weight, original, rtol=0, atol=0)
|
||||
assert wrapped.base_layer.weight.device == wrapped.base_layer.weight_device.device
|
||||
assert wrapped.base_layer.weight_device.numel() == 0
|
||||
assert "weight_device" not in wrapped.base_layer.state_dict()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requires_grad", [False, True])
|
||||
def test_dtensor_lora_restore_preserves_parameter_gradient_flag(tmp_path, requires_grad: bool) -> None:
|
||||
"""Replacing a real CPU DTensor parameter preserves freezing or trainability despite the no-grad scope."""
|
||||
import torch.distributed as dist
|
||||
from torch.distributed.device_mesh import init_device_mesh
|
||||
from torch.distributed.tensor import DTensor, Replicate, distribute_tensor
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.layers.lora.linear import get_lora_layer
|
||||
|
||||
if not dist.is_available() or not dist.is_gloo_available():
|
||||
pytest.skip("CPU DTensor restoration requires the Gloo backend")
|
||||
if dist.is_initialized():
|
||||
pytest.skip("This singleton CPU DTensor test must own its process group")
|
||||
dist.init_process_group("gloo", init_method=(tmp_path / "dtensor-store").as_uri(), rank=0, world_size=1)
|
||||
try:
|
||||
mesh = init_device_mesh("cpu", (1, ))
|
||||
original = torch.arange(16, dtype=torch.float32).reshape(4, 4)
|
||||
base = ReplicatedLinear(4, 4, bias=False, params_dtype=torch.float32)
|
||||
base.weight = nn.Parameter(distribute_tensor(original.clone(), mesh, [Replicate()]),
|
||||
requires_grad=requires_grad)
|
||||
wrapped = get_lora_layer(base)
|
||||
with torch.no_grad():
|
||||
wrapped.base_layer.weight.add_(5)
|
||||
wrapped.merged = True
|
||||
|
||||
wrapped.unmerge_lora_weights()
|
||||
|
||||
restored = wrapped.base_layer.weight
|
||||
assert isinstance(restored, DTensor)
|
||||
assert restored.requires_grad is requires_grad
|
||||
assert not wrapped.merged
|
||||
torch.testing.assert_close(restored.to_local(), original, rtol=0, atol=0)
|
||||
torch.testing.assert_close(wrapped.cpu_weight.to_local(), original, rtol=0, atol=0)
|
||||
if requires_grad:
|
||||
restored.to_local().sum().backward()
|
||||
assert isinstance(restored.grad, DTensor)
|
||||
torch.testing.assert_close(restored.grad.to_local(), torch.ones_like(original), rtol=0, atol=0)
|
||||
else:
|
||||
assert restored.grad is None
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
def test_nvfp4_cpu_snapshot_restore_and_repack_stay_in_offload_scope(cpu_nvfp4_lifecycle, monkeypatch) -> None:
|
||||
from contextlib import contextmanager
|
||||
from fastvideo.pipelines import lora_pipeline
|
||||
|
||||
pipeline, wrapped, _, _, _ = _nvfp4_lifecycle_pipeline(False)
|
||||
events = []
|
||||
|
||||
@contextmanager
|
||||
def scope(module):
|
||||
events.append("enter")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("exit")
|
||||
|
||||
restore = wrapped._restore_base_weight
|
||||
|
||||
def restore_in_scope():
|
||||
assert events[-1] == "enter"
|
||||
events.append("restore")
|
||||
restore()
|
||||
|
||||
def pack_in_scope(module):
|
||||
assert events[-1] == "restore"
|
||||
events.append("pack")
|
||||
_cpu_pack_nvfp4(module)
|
||||
|
||||
monkeypatch.setattr(lora_pipeline, "_get_hook_ctx", scope)
|
||||
monkeypatch.setattr(wrapped, "_restore_base_weight", restore_in_scope)
|
||||
monkeypatch.setattr(nvfp4, "convert_model_to_nvfp4", pack_in_scope)
|
||||
lora_pipeline.LoRAPipeline.unmerge_lora_weights(pipeline)
|
||||
assert events == ["enter", "restore", "pack", "exit"]
|
||||
|
||||
|
||||
class _NVFP4LifecycleTransformer(nn.Module):
|
||||
|
||||
def __init__(self, feed_forward: nn.Module) -> None:
|
||||
super().__init__()
|
||||
# A trailing block keeps circular prefetch from targeting its own still-resident FFN.
|
||||
self.transformer_blocks = nn.ModuleList([nn.Sequential(feed_forward), nn.Identity()])
|
||||
|
||||
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
|
||||
for block in self.transformer_blocks:
|
||||
inputs = block(inputs)
|
||||
return inputs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain", [False, True])
|
||||
@pytest.mark.parametrize("layerwise_offload", [False, True])
|
||||
@torch.no_grad()
|
||||
@torch._dynamo.config.patch(disable=True)
|
||||
def test_nvfp4_lora_lifecycle_matches_fresh_quantization_on_blackwell(retain: bool, layerwise_offload: bool) -> None:
|
||||
if not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] < 10:
|
||||
pytest.skip("NVFP4 lifecycle numerical validation requires an NVIDIA Blackwell GPU")
|
||||
pytest.importorskip("flashinfer")
|
||||
from fastvideo.hooks.layerwise_offload import enable_layerwise_offload
|
||||
from fastvideo.layers.lora.linear import get_lora_layer
|
||||
from fastvideo.models.dits.minimax_h3 import MiniMaxH3FeedForward
|
||||
from fastvideo.pipelines.lora_pipeline import (LoRAModelLayers, LoRAPipeline,
|
||||
_convert_quantized_weights_after_lora_change, _get_hook_ctx)
|
||||
|
||||
def new_ffn(keep_bf16):
|
||||
previous_dtype = torch.get_default_dtype()
|
||||
torch.set_default_dtype(torch.bfloat16)
|
||||
try:
|
||||
ffn = MiniMaxH3FeedForward(128, 256, quant_config=nvfp4.NVFP4Config(retain_original_weights=keep_bf16),
|
||||
prefix="minimax_h3.transformer_blocks.0.ff")
|
||||
finally:
|
||||
torch.set_default_dtype(previous_dtype)
|
||||
return ffn.cuda().eval()
|
||||
|
||||
generator = torch.Generator().manual_seed(20261105)
|
||||
|
||||
def randn(*shape):
|
||||
return torch.randn(shape, generator=generator, dtype=torch.bfloat16).cuda()
|
||||
|
||||
base = {"fc_in": randn(512, 128) * 0.02, "fc_out": randn(128, 256) * 0.02}
|
||||
first = {name: (randn(8, w.shape[1]) * 0.02, randn(w.shape[0], 8)) for name, w in base.items()}
|
||||
inputs = randn(2, 128, 128)
|
||||
second = {name: (randn(4, w.shape[1]) * 0.02, randn(w.shape[0], 4)) for name, w in base.items()}
|
||||
first_weights = {name: w + first[name][1] @ first[name][0] for name, w in base.items()}
|
||||
second_delta = {name: (b @ a) * 0.5 for name, (a, b) in second.items()}
|
||||
accumulated = {name: w + second_delta[name] for name, w in first_weights.items()}
|
||||
replacement = {name: w + second_delta[name] for name, w in base.items()}
|
||||
|
||||
quantized = new_ffn(retain)
|
||||
for name, w in base.items():
|
||||
getattr(quantized, name).weight.copy_(w)
|
||||
transformer = _NVFP4LifecycleTransformer(quantized).eval()
|
||||
block = transformer.transformer_blocks[0]
|
||||
if layerwise_offload:
|
||||
enable_layerwise_offload(transformer)
|
||||
layers = LoRAModelLayers([("transformer_blocks.0", block)])
|
||||
with _get_hook_ctx(block):
|
||||
for name in base:
|
||||
wrapped = get_lora_layer(getattr(quantized, name))
|
||||
setattr(quantized, name, wrapped)
|
||||
wrapped.set_lora_weights(*first[name])
|
||||
layers.add_lora_layer("transformer_blocks.0", name, wrapped)
|
||||
_convert_quantized_weights_after_lora_change([block])
|
||||
|
||||
def assert_state(weights, *, unmerged=False):
|
||||
reference = new_ffn(True)
|
||||
for name, w in weights.items():
|
||||
getattr(reference, name).weight.copy_(w)
|
||||
nvfp4.convert_model_to_nvfp4(reference)
|
||||
|
||||
with _get_hook_ctx(block):
|
||||
for name, w in weights.items():
|
||||
layer = getattr(quantized, name).base_layer
|
||||
expected = getattr(reference, name)
|
||||
if retain:
|
||||
torch.testing.assert_close(layer.weight, w, rtol=0, atol=0)
|
||||
else:
|
||||
assert layer.weight is None
|
||||
assert layer.weight_device.device == inputs.device
|
||||
for key in nvfp4.H3_NVFP4_DIT_BUFFER_NAMES:
|
||||
actual_buffer, expected_buffer = getattr(layer, key), getattr(expected, key)
|
||||
assert actual_buffer.dtype == expected_buffer.dtype, f"{name}.{key}"
|
||||
torch.testing.assert_close(actual_buffer.float(), expected_buffer.float(), rtol=0, atol=0,
|
||||
msg=lambda message: f"{name}.{key}: {message}")
|
||||
|
||||
def project(name, x, *, packed):
|
||||
out = getattr(reference, name)(x)[0] if packed else nn.functional.linear(x, weights[name])
|
||||
if unmerged:
|
||||
a, b = first[name]
|
||||
out = out + x @ a.T @ b.T
|
||||
return out
|
||||
|
||||
def expected_output(*, packed):
|
||||
value, gate = project("fc_in", inputs, packed=packed).chunk(2, dim=-1)
|
||||
return project("fc_out", value * nn.functional.silu(gate), packed=packed)
|
||||
|
||||
actual = transformer(inputs)
|
||||
packed_output = expected_output(packed=True)
|
||||
bf16_output = expected_output(packed=False)
|
||||
assert torch.isfinite(actual).all() and torch.isfinite(bf16_output).all()
|
||||
torch.testing.assert_close(actual, packed_output)
|
||||
assert ((actual.float() - bf16_output.float()).norm() <
|
||||
(actual.float() - bf16_base_output.float()).norm())
|
||||
|
||||
base_value, base_gate = nn.functional.linear(inputs, base["fc_in"]).chunk(2, dim=-1)
|
||||
bf16_base_output = nn.functional.linear(base_value * nn.functional.silu(base_gate), base["fc_out"])
|
||||
pipeline = SimpleNamespace(lora_layers={"transformer": layers},
|
||||
trainable_transformer_modules={"transformer": transformer})
|
||||
|
||||
assert_state(first_weights)
|
||||
LoRAPipeline.unmerge_lora_weights(pipeline)
|
||||
assert_state(base, unmerged=True)
|
||||
LoRAPipeline.merge_lora_weights(pipeline)
|
||||
assert_state(first_weights)
|
||||
for add_to_existing, expected in [(True, accumulated), (False, replacement)]:
|
||||
with _get_hook_ctx(block):
|
||||
for name, factors in second.items():
|
||||
getattr(quantized, name).set_lora_weights(*factors, strength=0.5, accumulate=add_to_existing)
|
||||
_convert_quantized_weights_after_lora_change([block])
|
||||
assert_state(expected)
|
||||
|
||||
@@ -71,11 +71,16 @@ def test_prefix_classification_sanity() -> None:
|
||||
|
||||
def test_auto_purges_always_fp4_and_retains_refine_only(caplog) -> None:
|
||||
model = _model(retain=None)
|
||||
original_device = model.always_fp4.weight.device
|
||||
with caplog.at_level(logging.INFO):
|
||||
nv.convert_model_to_nvfp4(model)
|
||||
assert model.always_fp4.weight is None
|
||||
assert model.refine_only.weight is not None
|
||||
assert model.always_fp4._nvfp4_weight is not None
|
||||
assert model.always_fp4.weight_device.device == original_device
|
||||
assert model.always_fp4.weight_device.untyped_storage().nbytes() == 0
|
||||
assert "weight_device" not in model.always_fp4.state_dict()
|
||||
assert not hasattr(model.refine_only, "weight_device")
|
||||
receipt = [r.message for r in caplog.records if "NVFP4 weight purge receipt" in r.message]
|
||||
assert receipt and "purged 1" in receipt[0] and "retained 1" in receipt[0]
|
||||
|
||||
@@ -85,6 +90,36 @@ def test_retain_true_keeps_everything() -> None:
|
||||
nv.convert_model_to_nvfp4(model)
|
||||
assert model.always_fp4.weight is not None
|
||||
assert model.refine_only.weight is not None
|
||||
assert not hasattr(model.always_fp4, "weight_device")
|
||||
|
||||
|
||||
def test_purged_weight_placement_follows_module_moves() -> None:
|
||||
model = _model(retain=False)
|
||||
nv.convert_model_to_nvfp4(model)
|
||||
model.to("meta")
|
||||
assert model.always_fp4.weight_device.device.type == "meta"
|
||||
assert model.always_fp4.weight_device.numel() == 0
|
||||
model.to_empty(device="cpu")
|
||||
assert model.always_fp4.weight_device.device.type == "cpu"
|
||||
assert model.always_fp4.weight_device.untyped_storage().nbytes() == 0
|
||||
assert "weight_device" not in model.always_fp4.state_dict()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offload_buffers", [False, True])
|
||||
def test_empty_weight_device_participates_in_layerwise_offload(env_overrides, offload_buffers: bool) -> None:
|
||||
import fastvideo.envs as envs
|
||||
from fastvideo.hooks.layerwise_offload import _offload_tensors
|
||||
|
||||
model = _model(retain=False)
|
||||
nv.convert_model_to_nvfp4(model)
|
||||
model.register_buffer("large_payload", torch.empty(1 << 20, dtype=torch.uint8))
|
||||
model.register_buffer("small_cache", torch.ones(4))
|
||||
env_overrides.enter_context(envs.FASTVIDEO_LAYERWISE_OFFLOAD_BUFFERS.override(offload_buffers))
|
||||
selected = dict(_offload_tensors(model))
|
||||
assert selected["always_fp4.weight_device"] is model.always_fp4.weight_device
|
||||
assert ("large_payload" in selected) is offload_buffers
|
||||
assert "small_cache" not in selected
|
||||
assert dict(_offload_tensors(model, selected))["always_fp4.weight_device"] is model.always_fp4.weight_device
|
||||
|
||||
|
||||
def test_retained_weight_lora_merge_reaches_nvfp4_buffers(monkeypatch) -> None:
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_clear_restores_base_weights_after_a_change_that_failed_part_way(setup)
|
||||
pipeline.lora_adapters["b"] = {**_adapter(["first"], 2), "second.lora_A": torch.randn(RANK, DIM + 1),
|
||||
"second.lora_B": torch.randn(DIM, RANK)}
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with pytest.raises(ValueError, match="factor dimensions"):
|
||||
pipeline.set_lora_adapter("b", strength=1.0)
|
||||
pipeline.clear_lora_adapter()
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_clear_after_a_partial_failure_in_a_block_mixing_side_branch_and_merged_
|
||||
pipe._convert_one_transformer = convert_one # type: ignore[method-assign]
|
||||
pipe.set_lora_adapter("a", strength=1.0)
|
||||
assert transformer.packed.side_branch and transformer.dense.merged
|
||||
with pytest.raises(RuntimeError):
|
||||
with pytest.raises(ValueError, match="factor dimensions"):
|
||||
pipe.set_lora_adapter("b", strength=1.0)
|
||||
|
||||
pipe.clear_lora_adapter()
|
||||
|
||||
Reference in New Issue
Block a user