diffrhythm-1.2-p100a / code /models /tt_dit /experimental /lora /ltx_adapter_loader.py
sunandbean's picture
Add files using upload-large-folder tool
33d23e1 verified
Raw History Blame Contribute Delete
16.4 kB
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
# SPDX-License-Identifier: Apache-2.0
"""
Adapter loader for on-device fuse-mode LoRA on the LTX-2.3 transformer.
Reads a LoRA safetensors file and registers its A/B pairs into the
LoRA-aware Linear modules of an ``LTXTransformerModel`` built with
``lora_enabled=True``. Swap is then a ``bind_active`` on each module
(``weight.data += scale * A@B`` on device) — no host fuse, no reload.
Why this differs from the Wan loader
-------------------------------------
LTX's ``LTXAttention._prepare_torch_state`` transforms the raw checkpoint
Q/K/V weights before they reach the device:
- ``_permute_qk``: reorders each head's channels from the checkpoint's
SPLIT rotary layout to the INTERLEAVED layout the RoPE op expects.
Applied to Q and K (self-attn) and to the cross-attn ``to_q``.
- ``_interleave_heads``: folds separate Q/K/V (or K/V) into the fused
``to_qkv`` / ``to_kv`` weight with heads interleaved so column-parallel
sharding lands the right head on each device.
The host-fuse path (``utils.fuse_loras.fuse_loras_into``) is correct
without any of this because it merges the delta into the *raw* checkpoint
weight and lets ``_prepare_torch_state`` permute the merged result. Here
the on-device weight is ALREADY permuted+interleaved, so the LoRA B must
be pre-transformed identically or the merged delta lands in the wrong
layout. ``to_out``/``ff1``/``ff2`` are untouched by ``_prepare_torch_state``,
so their adapters need no transform (the mixin copies W's own sharding).
Scope: every LoRA-adaptable Linear in the transformer — video/audio/cross-modal
attention (``to_qkv``/``to_q``/``to_kv``/``to_out``/``to_gate_logits``), both FFNs
(ff1/ff2), and the globals (``patchify_proj``, ``proj_out``, and the
``adaln_single`` / timestep-embedder linears). ``promote_to_lora`` makes the plain
globals bindable; attention/FFN are already LoRA via ``lora_enabled``. Only
attention q/k/v/q-of-cross carry the rotary permute + head-interleave transform;
everything else registers directly. ``.diff``/``.diff_b`` direct deltas remain
unsupported; unmapped keys are surfaced loudly, never silently dropped.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
import torch
from loguru import logger
from safetensors.torch import load_file
from ...layers.lora import LoRAMixin
from .promote import promote_to_lora
_STRIP_PREFIXES = ("model.diffusion_model.", "diffusion_model.", "transformer.", "model.")
_LOW_RANK_RE = re.compile(r"^(?P<base>.*)\.lora_(?P<slot>A|B|down|up)(?:\.[^.]+)?\.weight$")
_SLOT_MAP = {"A": "A", "down": "A", "B": "B", "up": "B"}
# Diffusers LTX checkpoints name blocks "transformer_blocks.<i>"; some exports
# use the shorter "blocks.<i>". Accept both.
_BLOCK_RE = r"(?:transformer_blocks|blocks)\.(\d+)"
# All LTXAttention instances on a block share the same to_q/k/v/out/gate_logits
# structure and fusion rules (self -> to_qkv, cross -> to_q + to_kv), so the loader
# handles them uniformly, reading is_self/heads off each resolved module.
_ATTN_NAMES = ("attn1", "attn2", "audio_attn1", "audio_attn2", "audio_to_video_attn", "video_to_audio_attn")
# Diffusers FFN container name -> tt-dit attribute (ff.net.0.proj/net.2 -> ffX).
_FF_NAME_TO_ATTR = {"ff": "ffn", "audio_ff": "audio_ff"}
@dataclass(frozen=True)
class LTXAdapterHandle:
name: str
target_indices: dict[str, int]
"""Map from a canonical tt-dit module path (e.g. ``transformer_blocks.0.attn1.to_qkv``)
to the bank index returned by ``register_lora`` on that module."""
def load_ltx_adapter_into(
transformer, # LTXTransformerModel built with lora_enabled=True
path: str,
*,
scale: float = 1.0,
name: str = "",
) -> LTXAdapterHandle:
"""Register a single LoRA safetensors file into ``transformer``.
Returns a handle recording the bank index assigned per module; pass it
to the pipeline's ``set_active_lora`` to bind it on device.
"""
raw = load_file(str(path))
pairs, alphas, skipped_direct, unrecognized = _collect_pairs(raw)
if not pairs:
raise RuntimeError(f"no LoRA A/B pairs (lora_A/B or lora_down/up) found in {path}")
if skipped_direct:
logger.warning(f"{path}: skipping {skipped_direct} direct (.diff/.diff_b) deltas — not supported.")
if unrecognized:
sample = ", ".join(unrecognized[:5])
more = "" if len(unrecognized) <= 5 else f" (+{len(unrecognized) - 5} more)"
logger.warning(
f"{path}: {len(unrecognized)} key(s) matched no known LoRA pattern and were dropped: {sample}{more}"
)
# Promote every plain Linear (adaln/proj/patchify/gate globals) to a LoRA
# variant so it can be bound; attn/ffn are already LoRA via lora_enabled.
promote_to_lora(transformer)
# Q/K/V fold into a fused projection; the rest are 1:1 block singletons or
# direct globals (patchify/proj_out/adaln/timestep-embedder).
fused: dict[tuple[int, str], dict[str, dict[str, torch.Tensor]]] = {}
singletons: list[tuple[int, str, str, dict[str, torch.Tensor]]] = [] # (block, container, sub, ab)
globals_: list[tuple[str, dict[str, torch.Tensor]]] = []
for base, ab in pairs.items():
parsed = _parse_target(base)
if parsed is None:
globals_.append((base, ab))
continue
block_idx, container, sub = parsed
if sub in ("to_q", "to_k", "to_v"):
attn = _get_attn(transformer, block_idx, container)
is_self = attn.is_self if attn is not None else container in ("attn1", "audio_attn1")
qkv = sub[-1]
# self-attn: q/k/v all fold into to_qkv. cross-attn: k/v fold into to_kv, q is a singleton.
if is_self or qkv in ("k", "v"):
fused.setdefault((block_idx, container), {})[qkv] = ab
continue
singletons.append((block_idx, container, sub, ab))
target_indices: dict[str, int] = {}
for (block_idx, container), qkvs in fused.items():
attn = _get_attn(transformer, block_idx, container)
if attn is None:
logger.warning(f"{path}: no attention module {container} in block {block_idx} — skipping fused group.")
continue
idx = _register_fused(attn, qkvs, alphas, scale, name, block_idx, container)
if idx is None:
continue
canonical = f"transformer_blocks.{block_idx}.{container}.{'to_qkv' if attn.is_self else 'to_kv'}"
target_indices[canonical] = idx
for block_idx, container, sub, ab in singletons:
if "A" not in ab or "B" not in ab:
logger.warning(f"{path}: {container}.{sub} in block {block_idx} missing one of A/B — skipping.")
continue
target, canonical, permute_dims = _resolve_singleton(transformer, block_idx, container, sub)
if target is None:
logger.warning(f"{path}: unmapped {container}.{sub} in block {block_idx} — skipping.")
continue
A = ab["A"]
B = ab["B"]
if permute_dims is not None:
B = _permute_qk_rows(B, *permute_dims)
rank = A.shape[0]
alpha = alphas.get(_alpha_key(block_idx, container, sub), float(rank))
idx = target.register_lora(A, B, scale=scale * (alpha / rank), name=name)
target_indices[canonical] = idx
unmapped_global = 0
for base, ab in globals_:
if "A" not in ab or "B" not in ab:
continue
target = _resolve_global(transformer, base)
if not isinstance(target, LoRAMixin):
unmapped_global += 1
continue
A = ab["A"]
B = ab["B"]
rank = A.shape[0]
alpha = alphas.get(base, float(rank))
idx = target.register_lora(A, B, scale=scale * (alpha / rank), name=name)
target_indices[base] = idx
if unmapped_global:
logger.warning(f"{path}: {unmapped_global} global key(s) matched no LTX module — dropped.")
if not target_indices:
raise RuntimeError(f"{path}: no LoRA targets registered — check the adapter's key naming against LTX modules.")
return LTXAdapterHandle(name=name or Path(path).stem, target_indices=target_indices)
def iter_lora_modules(root, prefix: str = ""):
"""Yield (dotted_path, module) for every LoRAMixin descendant of ``root``.
Paths match the loader's canonical keys (e.g. ``transformer_blocks.0.attn1.to_qkv``)."""
for name, child in root.named_children():
path = f"{prefix}.{name}" if prefix else name
if isinstance(child, LoRAMixin):
yield path, child
yield from iter_lora_modules(child, path)
# --------------------------------------------------------------------
# Key parsing
# --------------------------------------------------------------------
def _strip_prefixes(key: str) -> str:
for p in _STRIP_PREFIXES:
if key.startswith(p):
return key[len(p) :]
return key
def _collect_pairs(raw: dict[str, torch.Tensor]):
"""Return ({base: {'A','B'}}, {base: alpha}, num_skipped_direct, unrecognized_keys)."""
pairs: dict[str, dict[str, torch.Tensor]] = {}
alphas: dict[str, float] = {}
skipped_direct = 0
unrecognized: list[str] = []
for raw_key, tensor in raw.items():
key = _strip_prefixes(raw_key)
m = _LOW_RANK_RE.match(key)
if m:
pairs.setdefault(m.group("base"), {})[_SLOT_MAP[m.group("slot")]] = tensor
elif key.endswith(".alpha"):
alphas[key[: -len(".alpha")]] = float(tensor.item())
elif key.endswith((".diff", ".diff_b")):
skipped_direct += 1
else:
unrecognized.append(raw_key)
return pairs, alphas, skipped_direct, unrecognized
def _parse_target(base: str) -> tuple[int, str, str] | None:
"""Parse a stripped per-block LoRA base path into (block_idx, container, sub).
Covers every block attention family (video/audio/cross-modal) and both FFN
containers. Returns None for non-block paths (globals: patchify/proj_out/adaln),
which ``_resolve_global`` handles by direct path lookup."""
attn_alt = "|".join(_ATTN_NAMES)
m = re.match(rf"^{_BLOCK_RE}\.({attn_alt})\.to_(q|k|v|out|gate_logits)(?:\.0)?$", base)
if m:
raw = m.group(3)
sub = {"out": "to_out", "gate_logits": "to_gate_logits"}.get(raw, f"to_{raw}")
return int(m.group(1)), m.group(2), sub
ff_alt = "|".join(_FF_NAME_TO_ATTR)
m = re.match(rf"^{_BLOCK_RE}\.({ff_alt})\.net\.0\.proj$", base)
if m:
return int(m.group(1)), m.group(2), "ff1"
m = re.match(rf"^{_BLOCK_RE}\.({ff_alt})\.net\.2$", base)
if m:
return int(m.group(1)), m.group(2), "ff2"
return None
def _alpha_key(block_idx: int, attn_name: str, sub: str) -> str:
return f"transformer_blocks.{block_idx}.{attn_name}.{sub}"
# --------------------------------------------------------------------
# Module resolution
# --------------------------------------------------------------------
def _get_attn(transformer, block_idx: int, attn_name: str):
blocks = transformer.transformer_blocks
if not (0 <= block_idx < len(blocks)):
return None
return getattr(blocks[block_idx], attn_name, None)
def _resolve_singleton(transformer, block_idx: int, container: str, sub: str):
"""Return (module, canonical_path, permute_dims) where permute_dims is
(num_heads, head_dim) if the base weight is _permute_qk'd, else None.
(None, None, None) when the target does not exist."""
blocks = transformer.transformer_blocks
if not (0 <= block_idx < len(blocks)):
return None, None, None
block = blocks[block_idx]
if sub in ("to_q", "to_out", "to_gate_logits"):
attn = getattr(block, container, None)
if attn is None:
return None, None, None
target = getattr(attn, sub, None)
if target is None:
return None, None, None
# Only cross-attn to_q is _permute_qk'd; to_out/to_gate_logits are not.
permute_dims = (attn.num_heads, attn.head_dim) if (sub == "to_q" and not attn.is_self) else None
return target, f"transformer_blocks.{block_idx}.{container}.{sub}", permute_dims
if sub in ("ff1", "ff2"):
ff_attr = _FF_NAME_TO_ATTR.get(container, "ffn")
ff = getattr(block, ff_attr, None)
if ff is None:
return None, None, None
target = getattr(ff, sub, None)
return target, f"transformer_blocks.{block_idx}.{ff_attr}.{sub}", None
return None, None, None
def _resolve_global(transformer, base: str):
"""Resolve a non-block LoRA key (patchify_proj, proj_out, adaln_single.*, ...)
to the module at that dotted path on the transformer root, or None if absent.
These are plain replicated/parallel Linears with no rotary layout transform."""
mod = transformer
for part in base.split("."):
mod = getattr(mod, part, None)
if mod is None:
return None
return mod
# --------------------------------------------------------------------
# Fused QKV / KV registration (permute_qk + head-interleave on B)
# --------------------------------------------------------------------
def _register_fused(attn, qkvs, alphas, scale, name, block_idx, attn_name) -> int | None:
required = ["q", "k", "v"] if attn.is_self else ["k", "v"]
missing = [r for r in required if r not in qkvs or "A" not in qkvs[r] or "B" not in qkvs[r]]
if missing:
logger.warning(f"fused LoRA on block {block_idx}.{attn_name} missing {missing}; skipping (got {list(qkvs)}).")
return None
ranks = [qkvs[r]["A"].shape[0] for r in required]
if len(set(ranks)) != 1:
raise ValueError(f"QKV LoRA ranks must match on block {block_idx}.{attn_name}; got {ranks}")
r = ranks[0]
n = len(required)
num_heads = attn.num_heads
head_dim = attn.head_dim
n_dev = attn.parallel_config.tensor_parallel.factor
n_local_heads = attn.n_local_heads
out_per = attn.dim # each fused source projects to dim
# A stacked on rank -> [n*r, in]
A_fused = torch.cat([qkvs[x]["A"] for x in required], dim=0)
# B: permute Q/K rows (RoPE), pad each into a block-diagonal [out, n*r] tile,
# then head-interleave the tiles into [n*out, n*r] to match the fused weight.
B_padded = []
for i, x in enumerate(required):
B = qkvs[x]["B"]
if x in ("q", "k"):
B = _permute_qk_rows(B, num_heads, head_dim)
pad = torch.zeros(out_per, n * r, dtype=B.dtype)
pad[:, i * r : (i + 1) * r] = B
B_padded.append(pad)
B_fused = _interleave_heads_rows(B_padded, num_heads, head_dim, n_dev, n_local_heads)
alpha = alphas.get(_alpha_key(block_idx, attn_name, f"to_{required[0]}"), float(r))
eff_scale = scale * (alpha / r)
# register_lora lives on the fused projection Linear (LoRAMixin), not the attention wrapper.
fused_linear = attn.to_qkv if attn.is_self else attn.to_kv
return fused_linear.register_lora(A_fused, B_fused, scale=eff_scale, name=name)
def _permute_qk_rows(t: torch.Tensor, num_heads: int, head_dim: int) -> torch.Tensor:
"""Reorder each head's output channels SPLIT -> INTERLEAVED, mirroring
``LTXAttention._prepare_torch_state._permute_qk``. ``t`` is [num_heads*head_dim, ...]."""
D = head_dim
D_half = D // 2
perm = torch.empty(D, dtype=torch.long)
perm[0::2] = torch.arange(D_half)
perm[1::2] = torch.arange(D_half, D)
rest = t.shape[1:]
return t.reshape(num_heads, D, *rest).index_select(1, perm).reshape(num_heads * D, *rest)
def _interleave_heads_rows(tensors, num_heads: int, head_dim: int, n_dev: int, n_local_heads: int) -> torch.Tensor:
"""Interleave the OUT (row) dim across sources, mirroring
``LTXAttention._prepare_torch_state._interleave_heads``. Each input is
[out=num_heads*head_dim, C]; returns [n*out, C]."""
transposed = [t.T for t in tensors] # [C, out]
reshaped = [t.reshape(t.shape[0], n_dev, n_local_heads, head_dim) for t in transposed]
merged = torch.cat(reshaped, dim=2) # [C, n_dev, n*n_local_heads, head_dim]
merged = merged.reshape(merged.shape[0], len(tensors) * num_heads * head_dim) # [C, n*out]
return merged.T # [n*out, C]