Files
LTX-2/packages/ltx-pipelines/src/ltx_pipelines/multigpu/weight_tracker.py
T
2026-07-07 16:57:50 +00:00

185 lines
7.7 KiB
Python

"""Distributed transformer weight tracker with LoRA hot-swap.
Shared infrastructure used by both TDP and SP builders.
"""
from __future__ import annotations
import torch
import torch.distributed as dist
from ltx_core.loader.fuse_loras import fuse_lora_weights
from ltx_core.loader.primitives import LoraPathStrengthAndSDOps, LoraStateDictWithStrength, StateDict
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
from ltx_core.model.model_protocol import ModelType
from ltx_core.multigpu.sharded_sd import ShardedSD
def _apply_loras_inplace(
source: dict[str, torch.Tensor],
target: dict[str, torch.Tensor],
loras: tuple[LoraPathStrengthAndSDOps, ...],
builder: Builder, # type: ignore[type-arg]
lora_keys: frozenset[str],
) -> None:
"""Reset *target* to clean weights from *source*, then fuse all LoRAs in one pass."""
for key, clean_weight in source.items():
target[key].copy_(clean_weight)
lora_sds = [
LoraStateDictWithStrength(
builder.load_sd(
[lora.path],
sd_ops=lora.sd_ops.with_additional_allowed_keys(lora_keys),
registry=builder.registry,
device=builder.lora_load_device,
),
lora.strength,
)
for lora in loras
if lora.strength != 0
]
target_sd = StateDict(
sd=target, device=next(iter(target.values())).device, size=0, dtype={next(iter(target.values())).dtype}
)
for key, fused in fuse_lora_weights(target_sd, lora_sds, fuse_rule=builder.fuse_rule):
target[key].copy_(fused)
class TransformerWeightTracker:
"""Tracks cached transformer weights with distributed LoRA hot-swap.
Shared across stage builders that operate on the same checkpoint.
Does **not** own the model weights — it references tensors stored in a
:class:`Registry` and receives a builder at :meth:`build` time.
Uses two :class:`ShardedSD` instances (created on first :meth:`build` call):
- ``stored_sd`` — cloned backup of the original (pre-LoRA) weights.
Used to restore registry tensors before applying a different LoRA set.
- ``broadcast_sd`` — zero-copy view into the registry tensors.
After in-place LoRA fusion on the owning rank, this broadcasts the
fused results to all other ranks so every rank sees the same weights.
Both are created together and are always either both ``None`` or both set.
With ``no_lora_swap``, the LoRA set is assumed fixed (none, or one set):
the backup clone is skipped and any swap or reset raises.
"""
def __init__(self, group: dist.ProcessGroup, bucket_mb: int = 256, no_lora_swap: bool = False) -> None:
if bucket_mb <= 0:
raise ValueError("bucket_mb must be > 0")
self._group = group
self._bucket_mb = bucket_mb
self._no_lora_swap = no_lora_swap
self._staging: torch.Tensor | None = None
self.stored_sd: ShardedSD | None = None
self.broadcast_sd: ShardedSD | None = None
self.loras: list[tuple[str, float, str]] = []
@property
def staging(self) -> torch.Tensor:
"""The single broadcast scratch buffer, shared by both SDs, allocated on first use."""
if self._staging is None:
device = torch.device(f"cuda:{torch.cuda.current_device()}")
self._staging = torch.empty(self._bucket_mb * 1024 * 1024, dtype=torch.uint8, device=device)
return self._staging
def loras_match(self, lora_list: list[tuple[str, float, str]]) -> bool:
if len(lora_list) != len(self.loras):
return False
return sorted(lora_list) == sorted(self.loras)
def reset_loras(self, target_sd: dict[str, torch.Tensor]) -> None:
"""Restore *target_sd* to original (pre-LoRA) weights.
No-op if no LoRAs are currently applied. This is a cooperative
operation — all ranks must call it simultaneously.
"""
if not self.loras:
return
if self._no_lora_swap:
raise RuntimeError("no_lora_swap tracker has no backup to reset from")
if self.stored_sd is None:
raise RuntimeError("stored_sd must be initialised before reset_loras")
self.loras = []
self.stored_sd.broadcast_shards_into(target_sd, self.staging)
def _local_lora_keys(self) -> frozenset[str]:
"""Derive LoRA key names from the locally owned model keys."""
if self.stored_sd is None:
return frozenset()
keys: set[str] = set()
for k in self.stored_sd.local_shard:
if k.endswith(".weight"):
prefix = k[: -len(".weight")]
keys.add(f"{prefix}.lora_A.weight")
keys.add(f"{prefix}.lora_B.weight")
return frozenset(keys)
def apply_loras_(
self,
target_sd: dict[str, torch.Tensor],
loras: tuple[LoraPathStrengthAndSDOps, ...],
builder: Builder, # type: ignore[type-arg]
) -> None:
"""Fuse *loras* into *target_sd* in-place (trailing ``_`` denotes in-place).
Skips work when the requested LoRAs already match. Restores stored
weights before applying new LoRAs. This is a cooperative operation —
all ranks must call it simultaneously.
"""
new_loras = [(lora.path, lora.strength, lora.sd_ops.name) for lora in loras]
if self.loras_match(new_loras):
return
if self._no_lora_swap and self.loras:
raise RuntimeError(f"no_lora_swap tracker cannot change LoRAs: have {self.loras}, requested {new_loras}")
if all(lora.strength == 0 for lora in loras):
self.reset_loras(target_sd)
return
if self.stored_sd is None or self.broadcast_sd is None:
raise RuntimeError("ShardedSDs must be initialised before apply_loras_ (call build first)")
source = self.stored_sd.local_shard
target = {k: v for k, v in target_sd.items() if k in source}
lora_keys = self._local_lora_keys()
_apply_loras_inplace(source, target, loras, builder, lora_keys)
self.broadcast_sd.broadcast_shards_into(target_sd, self.staging)
self.loras = new_loras
def build(
self,
builder: Builder[ModelType],
device: torch.device | None = None,
dtype: torch.dtype | None = None,
**kwargs: object,
) -> ModelType:
"""Build the transformer model with distributed LoRA hot-swap.
Populates the registry with clean weights on first call, then applies
LoRAs in-place and broadcasts to all ranks. Assumes the builder carries
a non-dummy :class:`Registry` so that weights can be cached and reused
across calls.
"""
loras = builder.loras
clean_builder = builder.with_loras(())
model_paths = list(builder.model_path) if isinstance(builder.model_path, tuple) else [builder.model_path]
# First call: populate the registry with clean weights.
if clean_builder.registry.get(model_paths, clean_builder.model_sd_ops) is None:
clean_builder.build(device=device, dtype=dtype, **kwargs)
cached_sd = clean_builder.registry.get(model_paths, clean_builder.model_sd_ops)
if cached_sd is None:
raise RuntimeError("Expected model state dict in registry but found None")
if self.stored_sd is None:
self.stored_sd = ShardedSD.from_state_dict(cached_sd.sd, self._group, clone=not self._no_lora_swap)
self.broadcast_sd = ShardedSD.from_state_dict(cached_sd.sd, self._group, clone=False)
if loras:
self.apply_loras_(cached_sd.sd, loras, builder)
else:
self.reset_loras(cached_sd.sd)
return clean_builder.build(device=device, dtype=dtype, **kwargs)