Files
cicalooo 55da4ee624 Drop extra credits and the stale audit
Keep the alibaba-pai file connection and note that the stack row UI matured from rgthree-comfy's Power Lora Loader.
2026-10-11 15:38:09 +11:00

402 lines
15 KiB
Python

"""Peel Acc/PDD output-head banks off H3 LoRAs and blend them at sample time.
alibaba-pai Acc LoRAs store a 32-interval bank as ``final_layer.video_out.set_weight``
of shape ``[N * 96, hidden]`` (and the audio twin). Stock Comfy ``copy_``s that
onto the native ``[96, hidden]`` head and crashes, so this module peels the bank
and blends it at sample time.
The layout is not a stack of full heads. Row 0 is the full head and every later
row is an offset from it. A step is ``rows[0] + dt-weighted mean of the offsets
it spans``. ``set_weight`` files are already in that layout. Original
``proj_out`` / ``audio_proj_out`` banks are full heads, so those are converted
to offsets before the same blend.
The bank is kept beside the model (object patch + DIFFUSION_MODEL wrapper) and
is registered as an additional model so ComfyUI can move and account for it.
The native head is not resized, and PDD strength interpolates from the native
head to the blended bank.
"""
from __future__ import annotations
import logging
import threading
import torch
import torch.nn as nn
import torch.nn.functional as F
import comfy.model_management
import comfy.model_patcher
from comfy.ldm.minimax.model import _mod_row, time_shift_sigma
from comfy.patcher_extension import WrappersMP
from .schedule import progress_from
LOG = logging.getLogger("h3.powerlorastack")
_VIDEO_WEIGHT = (
"diffusion_model.final_layer.video_out.set_weight",
"final_layer.video_out.set_weight",
"proj_out.weight",
)
_VIDEO_BIAS = (
"diffusion_model.final_layer.video_out.set_bias",
"final_layer.video_out.set_bias",
"proj_out.bias",
)
_AUDIO_WEIGHT = (
"diffusion_model.final_layer.audio_out.set_weight",
"final_layer.audio_out.set_weight",
"audio_proj_out.weight",
)
_AUDIO_BIAS = (
"diffusion_model.final_layer.audio_out.set_bias",
"final_layer.audio_out.set_bias",
"audio_proj_out.bias",
)
# Original alibaba files store one absolute head per interval.
# set_weight files are already row 0 plus offsets.
_FULL_HEAD_KEYS = frozenset((
"proj_out.weight",
"proj_out.bias",
"audio_proj_out.weight",
"audio_proj_out.bias",
))
def _pop_first(sd, names):
for name in names:
if name in sd:
return sd.pop(name), name
return None, None
def _as_bank(tensor, out_features):
"""``[N, O, I]`` or ``[N*O, I]`` / ``[N*O]`` -> ``(N, flat)`` or None."""
if tensor is None or out_features <= 0:
return None
t = tensor.detach().float().contiguous()
if t.ndim == 3 and t.shape[1] == out_features:
n = t.shape[0]
return n, t.reshape(n * out_features, *t.shape[2:])
if t.ndim == 2 and t.shape[1] == out_features:
n = t.shape[0]
return n, t.reshape(n * out_features)
if t.ndim == 2 and t.shape[0] % out_features == 0:
n = t.shape[0] // out_features
return n, t
if t.ndim == 1 and t.shape[0] % out_features == 0:
n = t.shape[0] // out_features
return n, t
return None
def _to_offsets(flat, out_features):
"""Absolute heads ``[N*O, ...]`` -> row 0 plus offsets from it."""
if flat is None or out_features <= 0:
return flat
rows = flat.reshape(-1, out_features, *flat.shape[1:])
if rows.shape[0] <= 1:
return flat
base = rows[:1]
return torch.cat((base, rows[1:] - base), 0).reshape(flat.shape)
def peel(sd, video_dim, audio_dim, hidden):
"""Pull a PDD bank out of a LoRA state dict. Returns ``(bank, note)``.
``bank`` is None when the file has no Acc heads. Matching keys that are
not a whole number of native heads are dropped so stock ``set`` cannot crash.
"""
vw, vw_key = _pop_first(sd, _VIDEO_WEIGHT)
vb, vb_key = _pop_first(sd, _VIDEO_BIAS)
aw, aw_key = _pop_first(sd, _AUDIO_WEIGHT)
ab, ab_key = _pop_first(sd, _AUDIO_BIAS)
if vw is None and aw is None:
if vb is not None:
sd[vb_key] = vb
if ab is not None:
sd[ab_key] = ab
return None, "PDD bias without head ignored" if vb is not None or ab is not None else ""
def _restore():
if vw is not None:
sd[vw_key] = vw
if vb is not None:
sd[vb_key] = vb
if aw is not None:
sd[aw_key] = aw
if ab is not None:
sd[ab_key] = ab
video = _as_bank(vw, video_dim) if vw is not None else None
audio = _as_bank(aw, audio_dim) if aw is not None else None
notes = []
if video is None and vw is not None:
LOG.warning("H3 PowerLoraStack: ignored %s shape %s (native video head is %d)",
vw_key, tuple(vw.shape), video_dim)
notes.append("video weight dropped")
if audio is None and aw is not None:
LOG.warning("H3 PowerLoraStack: ignored %s shape %s (native audio head is %d)",
aw_key, tuple(aw.shape), audio_dim)
notes.append("audio weight dropped")
if video is None and audio is None:
return None, "PDD heads dropped (shape mismatch)"
n = video[0] if video is not None else audio[0]
if video is not None and video[0] != n:
return None, f"PDD video n={video[0]} != audio n={n}"
if audio is not None and audio[0] != n:
return None, f"PDD audio n={audio[0]} != video n={n}"
if n <= 1:
_restore()
return None, ""
video_w = video[1] if video is not None else None
audio_w = audio[1] if audio is not None else None
if video_w is not None and video_w.shape[-1] != hidden:
return None, f"PDD video in_features {video_w.shape[-1]} != {hidden}"
if audio_w is not None and audio_w.shape[-1] != hidden:
return None, f"PDD audio in_features {audio_w.shape[-1]} != {hidden}"
video_b = None
if vb is not None:
packed = _as_bank(vb, video_dim)
video_b = (packed[1] if video is not None and packed is not None
and packed[0] == n else None)
if video_b is None:
notes.append("video bias dropped")
audio_b = None
if ab is not None:
packed = _as_bank(ab, audio_dim)
audio_b = (packed[1] if audio is not None and packed is not None
and packed[0] == n else None)
if audio_b is None:
notes.append("audio bias dropped")
# One blend path. set_weight is already offsets; proj_out is not.
if vw_key in _FULL_HEAD_KEYS and video_w is not None:
video_w = _to_offsets(video_w, video_dim)
video_b = _to_offsets(video_b, video_dim)
notes.append("video full heads stored as offsets")
if aw_key in _FULL_HEAD_KEYS and audio_w is not None:
audio_w = _to_offsets(audio_w, audio_dim)
audio_b = _to_offsets(audio_b, audio_dim)
notes.append("audio full heads stored as offsets")
bank = {
"n": n,
"video_dim": video_dim,
"audio_dim": audio_dim,
"video_w": video_w,
"video_b": video_b,
"audio_w": audio_w,
"audio_b": audio_b,
}
note = f"PDD bank n={n}"
if notes:
note += " (" + ", ".join(notes) + ")"
return bank, note
def _blend(weight, bias, h, n, start, stop, flow_shift):
"""Row 0 is the full head, later rows are offsets.
Interval 0's dt weight is not applied to an offset; adding row 0 unweighted
is that interval's share.
"""
start = max(0, min(n - 1, int(start)))
stop = max(start + 1, min(n, int(stop)))
grid = torch.linspace(1.0, 0.0, n + 1, dtype=torch.float64)
dt = (1.0 - flow_shift * grid / (1.0 + (flow_shift - 1.0) * grid)).diff()[start:stop]
w = (dt / dt.sum()).to(device=h.device, dtype=h.dtype)
rows = weight.to(device=h.device, dtype=h.dtype).reshape(n, -1, weight.shape[-1])
first = max(start, 1)
offset_w = w[first - start:]
offset_rows = rows[first:stop]
if offset_rows.shape[0] == 0:
out_w = rows[0]
else:
out_w = rows[0] + torch.einsum("n,noi->oi", offset_w, offset_rows)
out_b = None
if bias is not None:
brows = bias.to(device=h.device, dtype=h.dtype).reshape(n, -1)
if offset_rows.shape[0] == 0:
out_b = brows[0]
else:
out_b = brows[0] + torch.einsum("n,no->o", offset_w, brows[first:stop])
return F.linear(h, out_w, out_b)
def interval(sigma, sigma_next, n, video_shift):
start, stop = (round(float(1.0 - time_shift_sigma(s, video_shift, 1.0)) * n)
for s in (sigma, sigma_next))
start = max(0, min(n - 1, start))
stop = max(start + 1, min(n, stop))
return start, stop
class PddFinalLayer(nn.Module):
def __init__(self, inner, bank, ctx):
super().__init__()
# The bank belongs to the additional-model patcher, so keep that
# reference out of this module. Register the native head children at
# their original paths so the object patch preserves the model state
# dict and its main-patcher ownership.
object.__setattr__(self, "inner", inner)
object.__setattr__(self, "bank", bank)
object.__setattr__(self, "ctx", ctx)
self.norm = inner.norm
self.adaln_proj = inner.adaln_proj
self.video_out = inner.video_out
self.audio_out = inner.audio_out
def forward(self, x, t_emb, video_seg, audio_seg,
sigma=None, sample_sigmas=None, shifts=None):
"""Run PDD under both current and legacy MiniMax H3 final-layer ABIs.
Current ComfyUI passes sigma, the sampler schedule, and stream shifts
directly. Older cores expose only the first four arguments, so retain
the outer wrapper's thread-local values as a compatibility fallback.
"""
inner = self.inner
bank = self.bank
ctx = self.ctx
n = bank.n
if sigma is None:
sigma = ctx.get("sigma")
if sample_sigmas is None:
sample_sigmas = ctx.get("sample_sigmas")
if shifts is None:
shifts = ctx.get("shifts")
if shifts is None:
shifts = (12.0, 3.0)
if sample_sigmas is None or sigma is None:
raise ValueError("MiniMax H3 PDD heads need the sampler's sigma schedule")
sigma = torch.as_tensor(sigma)
sample_sigmas = torch.as_tensor(
sample_sigmas, device=sigma.device
).flatten()
if sample_sigmas.numel() == 0:
raise ValueError("MiniMax H3 PDD heads need a non-empty sigma schedule")
shift, scale = inner.adaln_proj(t_emb)
def mod(seg):
a, b, row = seg
return (inner.norm(x[a:b]) * (1.0 + _mod_row(scale, row, scale.dtype))
+ _mod_row(shift, row, shift.dtype)).to(torch.float32)
i = int((sample_sigmas - sigma).abs().argmin())
sigma_next = sample_sigmas[min(i + 1, sample_sigmas.shape[0] - 1)]
strength = ctx.get("strength")
if strength is None:
strength = ctx.default_strength
strength = float(strength)
start, stop = interval(sigma, sigma_next, n, shifts[0])
vh = mod(video_seg)
ah = mod(audio_seg)
native_video = inner.video_out(vh)
if bank.video_w is None:
video = native_video
else:
pdd_video = _blend(bank.video_w, bank.video_b, vh, n, start, stop, shifts[0])
video = native_video + strength * (pdd_video - native_video)
native_audio = inner.audio_out(ah)
if bank.audio_w is None:
audio = native_audio
else:
pdd_audio = _blend(bank.audio_w, bank.audio_b, ah, n, start, stop, shifts[1])
audio = native_audio + strength * (pdd_audio - native_audio)
return video, audio
class PddBank(nn.Module):
"""Movable, accountable storage for a PDD head bank."""
def __init__(self, bank):
super().__init__()
self.n = bank["n"]
self.video_w = nn.Parameter(bank["video_w"], requires_grad=False) if bank["video_w"] is not None else None
self.video_b = nn.Parameter(bank["video_b"], requires_grad=False) if bank["video_b"] is not None else None
self.audio_w = nn.Parameter(bank["audio_w"], requires_grad=False) if bank["audio_w"] is not None else None
self.audio_b = nn.Parameter(bank["audio_b"], requires_grad=False) if bank["audio_b"] is not None else None
class PddState:
def __init__(self, strength, schedule):
self.default_strength = float(strength)
self.schedule = schedule
self.local = threading.local()
def set(self, sigma, sample_sigmas, shifts, strength):
self.local.values = {
"sigma": sigma,
"sample_sigmas": sample_sigmas,
"shifts": shifts,
"strength": strength,
}
def get(self, key):
return getattr(self.local, "values", {}).get(key)
def clear(self):
self.local.values = {}
def bank_bytes(bank):
return sum(
tensor.numel() * tensor.element_size()
for key in ("video_w", "video_b", "audio_w", "audio_b")
if (tensor := bank.get(key)) is not None
)
def attach(patcher, bank, strength=1.0, schedule=None):
fl = patcher.get_model_object("diffusion_model.final_layer")
bank_model = PddBank(bank)
bank_patcher = comfy.model_patcher.ModelPatcher(
bank_model,
load_device=comfy.model_management.get_torch_device(),
offload_device=comfy.model_management.unet_offload_device(),
)
n = 0
while patcher.get_additional_models_with_key(f"h3_power_lora_pdd_bank_{n}"):
n += 1
patcher.set_additional_models(f"h3_power_lora_pdd_bank_{n}", [bank_patcher])
ctx = PddState(strength, schedule)
wrapped = PddFinalLayer(fl, bank_model, ctx)
patcher.add_object_patch("diffusion_model.final_layer", wrapped)
def wrapper(executor, x, timestep, context, transformer_options=None, **kwargs):
transformer_options = transformer_options or {}
sigma = (timestep.flatten()[0] / 1000.0).float().clamp(min=1e-6)
sample_sigmas = transformer_options.get("sample_sigmas")
if sample_sigmas is None:
sample_sigmas = transformer_options.get("sigmas")
effective_strength = ctx.default_strength
if ctx.schedule is not None:
schedule_options = transformer_options
if "sample_sigmas" not in schedule_options and sample_sigmas is not None:
schedule_options = dict(transformer_options)
schedule_options["sample_sigmas"] = sample_sigmas
progress = progress_from(timestep, schedule_options, ctx.schedule.domain)
effective_strength *= ctx.schedule.evaluate(progress)
ctx.set(sigma, sample_sigmas, (
float(transformer_options.get("minimax_h3_sigma_shift_video", 12.0)),
float(transformer_options.get("minimax_h3_sigma_shift_audio", 3.0)),
), effective_strength)
try:
return executor(x, timestep, context, transformer_options, **kwargs)
finally:
ctx.clear()
n = 0
while patcher.get_wrappers(WrappersMP.DIFFUSION_MODEL, f"h3_power_lora_pdd_{n}"):
n += 1
patcher.add_wrapper_with_key(WrappersMP.DIFFUSION_MODEL, f"h3_power_lora_pdd_{n}", wrapper)
return n