Files
olivv-cs-ComfyUI-FunPack/modules/sampling/ptd/controller.py
T
Claude d1d83ebd8e feat: PTD few-step support for MiniMax H3 as a module
Settings > Engine > Sampling: pick a PTD adapter file from models/ptd and the run samples the file's own 4 to 8 hops with no CFG, whatever sampler and steps are chosen. Reads the EPFL fl2va and taobao-content-ai Ref2VA releases (bf16 or int8, full or pruned base); ordinary LoRAs stack. Ported from ComfyUI-PTD-Compact (Nynxz), ComfyUI-PTD (EPFL VITA Lab) and ComfyUI-MiniMaxH3-PTD (taobao-content-ai), all Apache-2.0: README Thank you plus NOTICE and licence text in the module. Proven on a tiny real H3 on CPU only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164nckPWA2L2ugVqiDcXzc3
2026-10-22 14:58:06 +00:00

385 lines
18 KiB
Python

"""Running a PTD adapter on a native MiniMax-H3 model: one expert set per hop, switched by hooks.
Adapters are low-rank additions to linear outputs (forward hooks), so ordinary LoRAs, which are weight
patches, stack with them in either order. The bank stays in system RAM; each layer copies only the
current hop's factors to the GPU, and int8 row scales are folded into the small factors.
Hooks live for one model call and are removed even when the call fails. Block arithmetic follows PTD's
reference order (bf16 products rounded one by one, as the experts were trained) unless `fuse_blocks`
asks for ComfyUI's fused native blocks.
Controller, hooks, block maths and the model-call wrapper follow ComfyUI-PTD (EPFL VITA Lab, Apache-2.0),
with the int8 factors, in-place residual and DynamicVRAM handling of ComfyUI-PTD-Compact (Nynxz,
Apache-2.0) and the output-head fusion of ComfyUI-MiniMaxH3-PTD (taobao-content-ai, Apache-2.0).
"""
from contextlib import ExitStack, contextmanager
from contextvars import ContextVar
import copy
from dataclasses import dataclass
import math
import threading
import weakref
import torch
import torch.nn.functional as F
from . import schedule
_FORWARD = ContextVar("funpack_ptd_forward", default=None)
_LOCKS = weakref.WeakKeyDictionary()
_LOCKS_GUARD = threading.Lock()
def _model_lock(model):
with _LOCKS_GUARD:
return _LOCKS.setdefault(model, threading.RLock())
def _check_interrupt():
from comfy.model_management import throw_exception_if_processing_interrupted
throw_exception_if_processing_interrupted()
# --- block arithmetic in PTD's reference order ------------------------------------------------------
def _modulated(normed, shift, scale, segments):
result = torch.empty_like(normed)
for start, stop, row in segments:
result[start:stop] = normed[start:stop] * (1.0 + scale[row].to(normed.dtype)) + shift[row].to(normed.dtype)
return result
def _residual(state, update, gate, segments):
"""`state + update * gate`, added in place. The product is rounded on its own (no addcmul) as PTD was
trained; adding into the stream keeps ComfyUI's allocation compiler from seeing a block output that
outlives its block."""
for start, stop, row in segments:
state[start:stop].add_(update[start:stop] * gate[row].to(state.dtype))
return state
def _block_math(block, x, t_emb, mod_segments, rope_freqs, transformer_options=None, attention=None):
options = {} if transformer_options is None else transformer_options
shift_a, scale_a, gate_a, shift_m, scale_m, gate_m = block.adaln_proj(t_emb)
normed = _modulated(block.norm1(x), shift_a, scale_a, mod_segments)
update = (block.attn if attention is None else attention)(normed, rope_freqs=rope_freqs, transformer_options=options)
x = _residual(x, update, gate_a, mod_segments)
normed = _modulated(block.norm2(x), shift_m, scale_m, mod_segments)
return _residual(x, block.mlp(normed), gate_m, mod_segments)
def _split_half_rotation(x, table):
cos = table[0, :, 0, :, 0, 0].repeat(1, 2).unsqueeze(1).to(x.dtype)
sin = table[0, :, 0, :, 1, 0].repeat(1, 2).unsqueeze(1).to(x.dtype)
width = cos.shape[-1]
leading = x[..., :width]
first, second = leading.chunk(2, dim=-1)
# The two products and their sum stay separate: a fused RMS-RoPE would do the rotation in fp32 first.
result = leading * cos + torch.cat((-second, first), dim=-1) * sin
return torch.cat((result, x[..., width:]), dim=-1)
def _attention_math(attention, x, rope_freqs=None, transformer_options=None):
from comfy.ldm.modules.attention import AttentionTensorContainer, optimized_attention
options = {} if transformer_options is None else transformer_options
streams = attention.qkv_proj(x).chunk(3, dim=-1)
q, k, v = [value.reshape(x.shape[0], attention.heads, attention.head_dim) for value in streams]
q, k = attention.q_norm(q), attention.k_norm(k)
if rope_freqs is not None:
q, k = _split_half_rotation(q, rope_freqs), _split_half_rotation(k, rope_freqs)
packed = [AttentionTensorContainer(value.transpose(0, 1).unsqueeze(0)) for value in (q, k, v)]
result = optimized_attention(*packed, attention.heads, skip_reshape=True,
preferred_attention=attention.comfy_attention, transformer_options=options)
return attention.out_proj(result.squeeze(0))
def _scope_forward(module, function, frame, originals):
original = module.forward
originals.append((module, "forward" in vars(module), original))
def forward(*args, **kwargs):
if _FORWARD.get() is not frame:
return original(*args, **kwargs)
return function(module, *args, **kwargs)
module.forward = forward
@contextmanager
def _without_comfy_compiler():
"""Switch off ComfyUI's allocation compiler (and its CUDA graphs) for one call. Used only for a run in
which the compiler failed: its recording does not expect the extra tensors PTD's block maths allocates."""
try:
from comfy.cli_args import args
except ImportError:
yield
return
saved = getattr(args, "disable_comfy_compiler", False), getattr(args, "disable_cuda_graphs", False)
args.disable_comfy_compiler = args.disable_cuda_graphs = True
try:
yield
finally:
args.disable_comfy_compiler, args.disable_cuda_graphs = saved
@dataclass(frozen=True)
class Hop:
index: int
sigma: float
class Controller:
"""The adapter bank of one loaded file, bound to one diffusion model. Hop selection is context-local;
calls that share a backbone are serialised."""
def __init__(self, native, adapter):
self.native = native
self.adapter = adapter
self.hops = adapter.hops
self.grid = torch.tensor(adapter.grid, dtype=torch.float64)
self._lock = _model_lock(native)
self._hop = ContextVar(f"funpack_ptd_hop_{id(self)}", default=None)
self.fuse_blocks = False
self.allocation_compiler = True
@contextmanager
def activate(self, hop, sigma):
if isinstance(hop, bool) or not isinstance(hop, int) or not 0 <= hop < self.hops:
raise ValueError(f"PTD hop must be in [0, {self.hops})")
expected = float(self.grid[hop])
if abs(float(sigma) - expected) > 2e-6:
raise ValueError(f"Hop {hop} must run at its trained time {expected:.6f}, not {float(sigma):.6f}")
token = self._hop.set(Hop(hop, expected))
try:
yield self
finally:
self._hop.reset(token)
def current(self):
hop = self._hop.get()
if hop is None:
raise RuntimeError("PTD adapters are on this model but no PTD hop is running: the model was called "
"outside the PTD sampler")
return hop
def _linear_hook(self, frame, experts):
# q/k/v share one input: their A factors run as one matmul. Each expert's delta is added into the
# fresh linear output in place; int8 row scales are folded into the small factors.
qkv = sorted((e for e in experts if e.qkv_index is not None), key=lambda e: e.qkv_index)
plain = [e for e in experts if e.qkv_index is None]
ranks = [e.a.shape[0] for e in qkv]
def factors(e, dev, dt):
a = e.a.to(dev, non_blocking=True).to(dt)
b = e.b.to(dev, non_blocking=True).to(dt)
if e.sa is not None:
a = a * e.sa.to(dev, dt)[:, None]
if e.sb is not None:
b = b * e.sb.to(dev, dt)[:, None]
return a, b
def hook(module, args, kwargs, output):
if _FORWARD.get() is not frame:
return output
_check_interrupt()
x = args[0] if args else kwargs.get("input")
dev, dt = x.device, output.dtype # the layer's compute dtype
x = x.to(dt)
for e in plain:
a, b = factors(e, dev, dt)
output.add_(F.linear(F.linear(x, a), b))
if qkv:
pairs = [factors(e, dev, dt) for e in qkv]
hs = F.linear(x, torch.cat([a for a, _ in pairs])).split(ranks, dim=-1)
width = output.shape[-1] // 3
for e, (_, b), h in zip(qkv, pairs, hs):
output[..., e.qkv_index * width:(e.qkv_index + 1) * width].add_(F.linear(h, b))
return output
return hook
@contextmanager
def forward_hooks(self, hop):
"""Scope the adapters, the PTD clocks and (unless fuse_blocks) PTD's block maths to one model call."""
native = self.native
with self._lock, ExitStack() as stack:
if not self.allocation_compiler:
stack.enter_context(_without_comfy_compiler())
frame = object()
token = _FORWARD.set(frame)
handles, forced, forwards = [], [], []
try:
if not self.fuse_blocks:
for block in native.blocks:
if all(hasattr(block, n) for n in ("norm1", "norm2", "adaln_proj", "attn", "mlp")):
_scope_forward(block, _block_math, frame, forwards)
_scope_forward(block.attn, _attention_math, frame, forwards)
self._time_hooks(frame, hop, handles)
heads = self.adapter.heads[hop.index] if self.adapter.heads else {}
for name, (weight, bias) in heads.items():
parent, _, attr = name.rpartition(".")
handles.append(getattr(native.get_submodule(parent), attr).register_forward_hook(
self._fused_head(frame, weight, bias)))
for name, by_hop in self.adapter.bank.items():
module = native.get_submodule(name)
# Native SwiGLU may fuse activation and fc2, bypassing Module.__call__ and its hooks;
# ComfyUI's public flag routes the call through the plain linear.
if hasattr(module, "comfy_force_forward") or name.endswith(".mlp.fc2"):
forced.append((module, "comfy_force_forward" in vars(module),
getattr(module, "comfy_force_forward", None)))
module.comfy_force_forward = True
handles.append(module.register_forward_hook(
self._linear_hook(frame, self.adapter.bank[name][hop.index]), with_kwargs=True))
factor = self.adapter.audio[hop.index]
def audio_clock(module, args, output):
if _FORWARD.get() is not frame:
return output
# Scaled before native H3 casts to the latent dtype, like PTD's fp32 output heads.
return output * torch.as_tensor(factor, dtype=output.dtype, device=output.device)
handles.append(native.final_layer.audio_out.register_forward_hook(audio_clock))
_check_interrupt()
yield
_check_interrupt()
finally:
for handle in reversed(handles):
handle.remove()
for module, had, value in reversed(forced):
if had:
module.comfy_force_forward = value
else:
del module.comfy_force_forward
for module, had, original in reversed(forwards):
if had:
module.forward = original
else:
del module.forward
_FORWARD.reset(token)
@staticmethod
def _fused_head(frame, weight, bias):
def hook(module, args, output):
if _FORWARD.get() is not frame:
return output
x = args[0]
return F.linear(x.to(output.dtype), weight.to(x.device, output.dtype),
None if bias is None else bias.to(x.device, output.dtype))
return hook
def _time_hooks(self, frame, hop, handles):
native = self.native
if not hasattr(native, "time_embedder"): # pruned (AdaLN-curve) base: no time embedder to steer
return
remembered = {}
def exact_times(module, args):
if _FORWARD.get() is not frame:
return args
times = args[0]
if not isinstance(times, torch.Tensor) or times.ndim != 1:
raise ValueError("PTD expects a vector of native H3 time labels")
video_time = 1.0 - hop.sigma
candidates = torch.tensor([video_time, 1.0 - schedule.audio_sigma(hop.sigma), max(video_time, .999)],
device=times.device, dtype=torch.float32).unique()
distance, indices = (times[:, None] - candidates[None]).abs().min(dim=1)
if not bool((distance <= 2e-6).all()):
raise ValueError("Native H3 time labels do not match the active PTD clocks")
# Native H3 rounds sigma to fp32 first; PTD subtracts in float64. Keep the table order.
return (candidates[indices],) + args[1:]
def remember(module, args, output):
if _FORWARD.get() is frame:
remembered["fp32"] = output
def precise_adaln_input(module, args):
if _FORWARD.get() is not frame:
return args
if "fp32" not in remembered:
raise RuntimeError("PTD AdaLN ran before the time embedder")
# Diffusers applies SiLU to the fp32 time embedding before casting; native H3 casts first.
return (F.silu(remembered["fp32"]).to(args[0].dtype),) + args[1:]
handles.append(native.time_embedder.register_forward_pre_hook(exact_times))
handles.append(native.time_embedder.register_forward_hook(remember))
for layer in (*native.blocks, native.final_layer):
adaln = getattr(layer, "adaln_proj", None)
if adaln is not None and getattr(adaln, "apply_silu", True):
handles.append(adaln.linear.register_forward_pre_hook(precise_adaln_input))
class ModelCall:
"""The model-function wrapper: PTD carries physical latents, so the native shifts are neutralised for the
call and the adapters are switched on for it."""
def __init__(self, controller, previous=None):
self.controller = controller
self.previous = previous
def __call__(self, model_function, kwargs):
hop = self.controller.current()
sigma = kwargs.get("timestep")
if not isinstance(sigma, torch.Tensor) or sigma.numel() == 0:
raise ValueError("PTD needs a tensor sigma from the sampler")
if not bool(torch.allclose(sigma, torch.full_like(sigma, hop.sigma), rtol=0.0, atol=2e-6)):
raise ValueError("The model sigma does not match the active PTD hop")
forwarded = dict(kwargs)
conditions = dict(kwargs.get("c", {}))
options = dict(conditions.get("transformer_options", {}))
options["minimax_h3_sigma_shift_video"] = 1.0
options["minimax_h3_sigma_shift_audio"] = schedule.native_audio_shift(hop.sigma)
conditions["transformer_options"] = options
payload = dict(conditions.get("minimax_payload", {}))
payload["audio_scale"] = 1.0
conditions["minimax_payload"] = payload
forwarded["c"] = conditions
with self.controller.forward_hooks(hop):
if self.previous is not None:
return self.previous(model_function, forwarded)
return model_function(forwarded["input"], forwarded["timestep"], **conditions)
def to(self, device):
if self.previous is not None and hasattr(self.previous, "to"):
self.previous = self.previous.to(device)
return self
def cleanup(self, **kwargs):
if self.previous is not None and hasattr(self.previous, "cleanup"):
self.previous.cleanup(**kwargs)
def models(self):
if self.previous is not None and hasattr(self.previous, "models"):
return self.previous.models()
return []
def attach(patcher, controller, key, tag):
"""Put `controller` on `patcher` (a clone the caller owns): raw text embeddings so each hop's refiner
adapters run, PTD's audio shift, and the model-call wrapper. Everything is tagged `key` for removal."""
native = controller.native
if not all(hasattr(native, n) for n in ("blocks", "token_refiner", "condition_proj", "final_layer",
"sigma_shift_video", "sigma_shift_audio")):
raise ValueError("PTD needs a native MiniMax-H3 model")
sampling = copy.deepcopy(patcher.get_model_object("model_sampling"))
if not hasattr(sampling, "audio_shift") or not hasattr(sampling, "shift"):
raise ValueError("PTD needs the native MiniMax-H3 audio/video flow sampling")
sampling.audio_shift = sampling.shift
patcher.add_object_patch("model_sampling", tag(sampling, key))
text_dim = native.condition_proj.in_features
def raw_text(text_states):
if text_states.shape[-1] != text_dim:
raise ValueError("PTD needs raw MiniMax-H3 text conditioning, not pre-refined embeddings")
return text_states
patcher.add_object_patch("diffusion_model.preprocess_text_embeds", tag(raw_text, key))
previous = patcher.model_options.get("model_function_wrapper")
wrapper = tag(ModelCall(controller, previous), key)
wrapper._funpack_prev = previous
patcher.set_model_unet_function_wrapper(wrapper)
patcher.model_options[CONTROLLER_KEY] = tag(controller, key)
CONTROLLER_KEY = "funpack_ptd_controller"