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
This commit is contained in:
Claude
2026-10-22 14:58:06 +00:00
parent 335d9a0767
commit d1d83ebd8e
12 changed files with 1607 additions and 2 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [Unreleased]
### Added (experimental)
- **PTD few-step** (Settings > Engine > Sampling, MiniMax H3 only): renders in the 4 to 8 hops of a PTD adapter file from `ComfyUI/models/ptd` instead of a long schedule, with no CFG. Reads the EPFL fl2va and taobao-content-ai Ref2VA releases, as bf16 or int8, on the full or pruned base; ordinary LoRAs stack with it. The chosen sampler, steps and scheduler are replaced while it is on. Ported from ComfyUI-PTD-Compact (Nynxz). Not yet run on a real H3.
## [5.0.1] - 2026-10-21
Fixes from real H3 runs on rentals, plus the first usable LoRA training.
+1
View File
@@ -160,6 +160,7 @@ I express my deepest gratitude to:
- [OpenCut](https://github.com/opencut-app/opencut-classic) — the in-browser non-linear video editor whose UI and interaction patterns inspired FunPack's timeline studio;
- [Inter](https://github.com/rsms/inter) and IBM Plex Mono — the fonts of the app (SIL Open Font License; copies in `app/assets/fonts`);
- [musubi-tuner](https://github.com/kohya-ss/musubi-tuner) by kohya-ss (Apache-2.0) — the LoRA training add-on's loop (which layers get a LoRA, per-block checkpointing, noise sampling and loss weighting, optimizer and learning rate) and its block swap (a number of transformer blocks kept in system memory) follow its MiniMax H3 training recipe;
- PTD few-step support for MiniMax H3 is ported from [ComfyUI-PTD-Compact](https://github.com/Nynxz/ComfyUI-PTD-Compact) by Nynxz (Apache-2.0), which builds on [ComfyUI-PTD](https://github.com/vita-epfl/PTD-inference) by EPFL VITA Lab (Apache-2.0; paper "Parametric Trajectory Distillation for Few-Step Video Generation", arXiv 2610.11498) and on ComfyUI-MiniMaxH3-PTD by taobao-content-ai (Apache-2.0); licence notice in `modules/sampling/ptd`;
- ComfyUI and its whole community.
Without all of you, this project would've been impossible.
@@ -67,8 +67,9 @@ def _finite(out):
#: Failures that come from the stand-in model, not from the module: a four-block model has no blocks 31-40, and the
#: LTX stand-in is not a model_base class with the windowing hook (the module's own tests patch that in).
TINY_MODEL = ("no usable blocks", "map_context_window_to_modalities")
#: LTX stand-in is not a model_base class with the windowing hook (the module's own tests patch that in). Likewise
#: PTD has no adapter file on a test machine (its own tests build one).
TINY_MODEL = ("no usable blocks", "map_context_window_to_modalities", "no PTD adapter file")
def _install_failures(status):
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+14
View File
@@ -0,0 +1,14 @@
PTD few-step support for MiniMax-H3 (modules/sampling/ptd)
This folder is a port of existing Apache-2.0 work; the licence text is in LICENSE-Apache-2.0.txt.
* ComfyUI-PTD-Compact by Nynxz (https://github.com/Nynxz/ComfyUI-PTD-Compact): the compact int8 / reduced-rank
adapter controller, the in-place residual, the DynamicVRAM handling, the Ref2VA support and the tests.
* ComfyUI-PTD (https://github.com/vita-epfl/PTD-inference), Copyright (c) 2026 EPFL VITA Lab: the hop
controller, forward hooks, block arithmetic, audio clock, checkpoint format and fixed-hop sampler, from the
paper "Parametric Trajectory Distillation for Few-Step Video Generation" (arXiv:2610.11498).
* ComfyUI-MiniMaxH3-PTD by taobao-content-ai
(https://huggingface.co/taobao-content-ai/MiniMax-H3-PTD-LoRAs): the Ref2VA hop grid, output-head bank
fusion, audio chord correction and stacked-hop key mapping.
The adapter weights and MiniMax-H3 have their own licences; see their model repositories.
+137
View File
@@ -0,0 +1,137 @@
"""PTD few-step adapters for MiniMax-H3: 4 to 8 hops instead of a long schedule, with no CFG.
A PTD file is not a LoRA: it holds one set of adapters per hop, and the sampler must visit exactly the
times it was trained at. Switching this on loads the file, puts the adapters on the model and runs PTD's
own sampler over the file's hop times, whatever sampler and steps are chosen. Ordinary LoRAs stack with it.
Loads the EPFL fl2va releases and the taobao-content-ai Ref2VA releases, as bf16 or as the smaller int8
files, on the full or the pruned base. Ported from ComfyUI-PTD-Compact (Nynxz) over ComfyUI-PTD (EPFL VITA
Lab) and ComfyUI-MiniMaxH3-PTD (taobao-content-ai), all Apache-2.0; see the README Thank you section.
Not yet run on a real H3 here.
"""
import os
from pathlib import Path
import folder_paths
from comfy.patcher_extension import WrappersMP
from ..._core import control, log, patching
from . import adapters
from .controller import CONTROLLER_KEY, Controller, attach
from .sampler import sample_ptd
ID = "ptd"
TITLE = "PTD few-step"
MOUNT = "generation.sampling"
STAGE = "load"
CATEGORY = "sampling"
STATUS = "experimental"
REQUIRES = ["audio_stream", "adaln_modalities"]
if "ptd" not in folder_paths.folder_names_and_paths:
folder_paths.add_model_folder_path("ptd", str(Path(folder_paths.models_dir) / "ptd"))
KERNELS = {"exact": False, "fast": True}
def _files():
try:
return [f for f in folder_paths.get_filename_list("ptd") if f.endswith(".safetensors")]
except Exception: # noqa: BLE001 -- no folder yet is the same as no files
return []
_FOUND = _files()
SETTINGS = {
"enabled": {
"type": "bool", "default": False,
"label": "Few-step PTD",
"hint": "Renders in the adapter file's own 4 to 8 steps with no CFG. The sampler, steps and scheduler "
"are replaced; PTD cannot start from a picture or a mask.",
},
"adapter": {
"type": "enum", "default": _FOUND[0] if _FOUND else "",
"options": [{"value": f, "label": f} for f in _FOUND] or [{"value": "", "label": "No file in models/ptd"}],
"label": "PTD adapter file",
"hint": "A PTD file from ComfyUI/models/ptd. Restart ComfyUI after adding one.",
"when": {"enabled": True},
},
"kernels": {
"type": "enum", "default": "exact",
"options": [{"value": "exact", "label": "Exact"}, {"value": "fast", "label": "Fast"}],
"label": "Block maths",
"hint": "Exact follows the arithmetic the adapters were trained with. Fast uses ComfyUI's own blocks, "
"about 4 to 8 percent quicker per step; only the rounding differs.",
"when": {"enabled": True},
},
}
# One parsed file is kept between runs, so changing a seed or a prompt does not read gigabytes again.
# It holds no model, so nothing here keeps a diffusion model alive.
_kept = {}
def _adapter(path):
stamp = (str(path), os.path.getmtime(path), os.path.getsize(path))
if _kept.get("stamp") != stamp:
_kept.clear()
_kept.update(stamp=stamp, adapter=adapters.parse(path, label=Path(path).name))
return _kept["adapter"]
def _swap(controller):
"""Run PTD's sampler over the adapter's own hop times, in place of the sampler that was chosen."""
import comfy.samplers
ptd = comfy.samplers.KSAMPLER(sample_ptd, extra_options={"controller": controller})
def run(executor, guider, sigmas, extra_args, callback, noise, latent_image, denoise_mask, disable_pbar):
if abs(float(getattr(guider, "cfg", 1.0)) - 1.0) > 1e-6:
log.once("ptd:cfg", log.ALERT, "FunPack PTD",
f"CFG is {guider.cfg:g}: PTD was trained without guidance, so set CFG to 1")
try:
import latent_preview
callback = latent_preview.prepare_callback(guider.model_patcher, controller.hops)
except Exception: # noqa: BLE001 -- only the progress bar and previews are lost
pass
log.once("ptd:swap", log.INFO, "FunPack PTD",
f"sampling {controller.hops} hops from {controller.adapter.label}; the chosen sampler and steps "
f"({len(sigmas) - 1}) are not used")
return ptd.sample(guider, controller.grid.float(), extra_args, callback, noise, latent_image,
denoise_mask, disable_pbar)
return run
def install(patcher, values, key):
if not values["enabled"]:
_kept.clear()
return None
raw = getattr(patcher, "_patcher", patcher)
# PTD installs on the real patcher, not the guarded one. A guard would drop a failing hook and carry on
# with the plain sampler on a model whose layers expect PTD's hops, which renders noise; here it ends the run.
path = folder_paths.get_full_path("ptd", values["adapter"]) if values["adapter"] else None
if path is None:
raise control.Unavailable("no PTD adapter file is chosen: put one in ComfyUI/models/ptd, restart, "
"and pick it in Settings")
native = raw.get_model_object("diffusion_model")
adapter = _adapter(path)
adapters.verify(adapter, native)
if CONTROLLER_KEY in raw.model_options:
raise ValueError("This model already carries PTD adapters")
controller = Controller(native, adapter)
controller.fuse_blocks = KERNELS[values["kernels"]]
attach(raw, controller, key, patching.tag)
raw.add_wrapper_with_key(WrappersMP.SAMPLER_SAMPLE, key, patching.tag(_swap(controller), key))
size = adapter.size / 2**30
return (f"{adapter.label}, {adapter.hops} hops, {'int8' if adapter.int8 else 'float'} {size:.1f} GB, "
f"{values['kernels']} blocks")
def patcher_has_ptd(patcher):
from .controller import CONTROLLER_KEY
return CONTROLLER_KEY in patcher.model_options
PROVIDES = {"modifier": install}
+227
View File
@@ -0,0 +1,227 @@
"""Reading a PTD adapter file into a bank of per-hop experts.
Three families load here:
* EPFL fl2va releases (bf16, or int8 with per-row scales): keys `transformer.<module>.lora_A.<hop>.weight`.
* taobao-content-ai Ref2VA releases (bf16, or int8): factors stacked over hops (`lora_down [H, r, in]`)
plus a bank of per-interval output heads that is fused per hop.
* Any rank: reduced-rank files simply have smaller factors.
The key mapping, topology checks and head fusion follow ComfyUI-PTD (EPFL VITA Lab) and
ComfyUI-MiniMaxH3-PTD (taobao-content-ai), both Apache-2.0, via ComfyUI-PTD-Compact (Nynxz).
"""
from collections import defaultdict
from dataclasses import dataclass, field
import re
import torch
from . import schedule
HEADS = {"proj_out": "final_layer.video_out", "audio_proj_out": "final_layer.audio_out"}
PARTS = ("attn.to_q", "attn.to_k", "attn.to_v", "attn.to_out.0", "ff.net.0.proj", "ff.net.2")
_QKV = {"attn.to_q": 0, "attn.to_k": 1, "attn.to_v": 2}
_DENSE = {"attn.to_out.0": "attn.out_proj", "ff.net.0.proj": "mlp.fc1", "ff.net.2": "mlp.fc2"}
#: Reference tensors some EPFL files carry, to prove the loaded base is the one they were trained on.
BUFFERS = {
"transformer.rope.inv_freq": "rope.inv_freq",
"transformer.proj_out.orig_weight": "final_layer.video_out.weight",
"transformer.proj_out.orig_bias": "final_layer.video_out.bias",
"transformer.audio_proj_out.orig_weight": "final_layer.audio_out.weight",
"transformer.audio_proj_out.orig_bias": "final_layer.audio_out.bias",
}
_EPFL_KEY = re.compile(r"^transformer\.(?P<module>.+)\.lora_(?P<factor>[AB])\.(?P<hop>0|[1-9][0-9]*)\.weight$")
_MODULE = re.compile(r"^(transformer_blocks|token_refiner\.refiner_blocks)\.(\d+)\.(.+)$")
@dataclass(frozen=True)
class Expert:
a: torch.Tensor # [r, in] int8 or float
b: torch.Tensor # [out, r] int8 or float
sa: torch.Tensor | None # [r] float32 row scales of a
sb: torch.Tensor | None # [out] float32 row scales of b
qkv_index: int | None
@dataclass
class Adapter:
label: str
hops: int
grid: tuple # hop times t_0 > ... > 0
audio: tuple # per hop, the factor the audio head is scaled by
bank: dict # native module -> {hop: (Expert, ...)}
heads: list = field(default_factory=list) # per hop {native head: (weight, bias)} (Ref2VA only)
int8: bool = False
buffers: dict = field(default_factory=dict) # reference tensors of the base (EPFL files)
@property
def size(self):
return sum(t.numel() * t.element_size() for by_hop in self.bank.values()
for experts in by_hop.values() for e in experts for t in (e.a, e.b))
def native_target(module):
"""Diffusers module name -> (native module, qkv index)."""
m = _MODULE.fullmatch(module)
if m is None:
raise ValueError(f"Unknown PTD adapter module: {module}")
base = ("blocks." if m[1] == "transformer_blocks" else "token_refiner.blocks.") + m[2]
if m[3] in _QKV:
return f"{base}.attn.qkv_proj", _QKV[m[3]]
if m[3] not in _DENSE:
raise ValueError(f"Unknown PTD adapter projection: {module}")
return f"{base}.{_DENSE[m[3]]}", None
def _add(bank, name, hop, a, b, sa, sb, qkv):
if name.endswith(".mlp.fc1"): # Diffusers stores [up, gate]; native H3 consumes [gate, up]
b = torch.cat(b.chunk(2, dim=0)[::-1], dim=0)
sb = None if sb is None else torch.cat(sb.chunk(2, dim=0)[::-1], dim=0)
bank[name][hop].append(Expert(a.contiguous(), b.contiguous(),
None if sa is None else sa.reshape(-1).float(),
None if sb is None else sb.reshape(-1).float(), qkv))
def _epfl_bank(tensors, hops):
bank = defaultdict(lambda: defaultdict(list))
for key in tensors:
if key.endswith("_scale") or key in BUFFERS:
continue
m = _EPFL_KEY.fullmatch(key)
if m is None:
raise ValueError(f"Not a supported PTD adapter key: {key}")
if m["factor"] != "A":
continue
hop, module = int(m["hop"]), m["module"]
if hop >= hops:
raise ValueError(f"Adapter hop {hop} is beyond this file's {hops} hops: {key}")
b_key = key.replace(".lora_A.", ".lora_B.")
a, b = tensors[key], tensors[b_key]
if a.ndim != 2 or b.ndim != 2 or a.shape[0] != b.shape[1]:
raise ValueError(f"Invalid PTD adapter pair: {key}")
if module.endswith(".main"):
name, qkv = HEADS[module[:-len(".main")]], None
else:
name, qkv = native_target(module)
_add(bank, name, hop, a, b, tensors.get(key + "_scale"), tensors.get(b_key + "_scale"), qkv)
return bank
def _pdd_bank(tensors, hops, scale):
bank = defaultdict(lambda: defaultdict(list))
for module in sorted({k.rsplit(".lora_", 1)[0] for k in tensors if ".lora_" in k}):
name, qkv = native_target(module)
down, up = tensors[f"{module}.lora_down"], tensors[f"{module}.lora_up"]
sd, su = tensors.get(f"{module}.lora_down_scale"), tensors.get(f"{module}.lora_up_scale")
if down.ndim != 3 or up.ndim != 3 or down.shape[0] != hops or up.shape[0] != hops:
raise ValueError(f"{module}: expected factors stacked over {hops} hops")
for h in range(hops):
sb = None if su is None else su[h].float()
if scale != 1.0:
sb = torch.full((up.shape[1],), scale) if sb is None else sb * scale
_add(bank, name, h, down[h], up[h], None if sd is None else sd[h].float(), sb, qkv)
return bank
def _pdd_heads(tensors, hops, steps):
"""Per hop, the bank of per-interval heads fused with that hop's normalised step sizes."""
banks = {}
for prefix, native in HEADS.items():
if f"{prefix}.weight" not in tensors:
raise ValueError(f"PTD file is missing the output head bank {prefix}.weight")
w, b = tensors[f"{prefix}.weight"], tensors.get(f"{prefix}.bias")
if w.ndim != 3 or w.shape[0] != steps or (b is not None and tuple(b.shape) != (steps, w.shape[1])):
raise ValueError(f"Output head bank {prefix} must be ({steps}, out, in)")
banks[native] = (w.float(), None if b is None else b.float())
heads = []
for video_plan, audio_plan in schedule.pdd_plans(hops, steps):
fused = {}
for name, (w, b) in banks.items():
plan = video_plan if name.endswith("video_out") else audio_plan
fused[name] = (torch.einsum("n,noi->oi", plan, w),
None if b is None else torch.einsum("n,no->o", plan, b))
heads.append(fused)
return heads
def _expected(native, head_experts):
names = set()
for prefix, count in (("blocks", len(native.blocks)), ("token_refiner.blocks", len(native.token_refiner.blocks))):
for i in range(count):
names |= {f"{prefix}.{i}.attn.qkv_proj", f"{prefix}.{i}.attn.out_proj",
f"{prefix}.{i}.mlp.fc1", f"{prefix}.{i}.mlp.fc2"}
return names | set(HEADS.values()) if head_experts else names
def verify(adapter, native):
"""Raise, in plain words, unless `adapter` fits the loaded H3 model `native` layer for layer."""
bank, hops = adapter.bank, adapter.hops
expected = _expected(native, head_experts=not adapter.heads)
if set(bank) != expected:
raise ValueError(f"This adapter does not match the loaded model's layers: {len(expected - set(bank))} "
f"missing, {len(set(bank) - expected)} unexpected")
for name, by_hop in bank.items():
module = native.get_submodule(name)
if set(by_hop) != set(range(hops)):
raise ValueError(f"PTD adapter is missing hops for {name}")
for experts in by_hop.values():
if len(experts) != (3 if name.endswith("qkv_proj") else 1):
raise ValueError(f"PTD adapter has the wrong number of factors for {name}")
for e in experts:
if e.a.shape[1] != module.in_features or \
e.b.shape[0] * (3 if e.qkv_index is not None else 1) != module.out_features:
raise ValueError(f"PTD adapter does not fit {name}: it was made for another H3 variant")
for name in HEADS.values() if adapter.heads else ():
head = native.get_submodule(name)
if (head.in_features, head.out_features) != tuple(reversed(adapter.heads[0][name][0].shape)):
raise ValueError(f"The output head bank does not fit {name}: it was made for another H3 variant")
for key, target in BUFFERS.items():
if key not in adapter.buffers:
continue
parent, _, attr = target.rpartition(".")
base = getattr(native.get_submodule(parent), attr)
if not isinstance(base, torch.Tensor) or base.device.type == "meta" or \
base.dtype not in (torch.float32, torch.float16, torch.bfloat16):
continue # a quantised base cannot be compared; the layer checks above still apply
value = adapter.buffers[key]
if base.shape != value.shape or not torch.equal(base.detach().cpu(), value.to("cpu", base.dtype)):
raise ValueError(f"The loaded MiniMax-H3 base is not the one this adapter was trained on ({target})")
def read(path):
from safetensors import safe_open
with safe_open(str(path), framework="pt", device="cpu") as f:
meta = dict(f.metadata() or {})
tensors = {k: f.get_tensor(k) for k in f.keys()}
for key, value in tensors.items():
if value.is_floating_point() and not bool(torch.isfinite(value).all()):
raise ValueError(f"Non-finite values in {key}")
return tensors, meta
def parse(path, label=None):
"""The Adapter in `path`. It knows nothing of any model yet: `verify` it against one before use."""
tensors, meta = read(path)
buffers = {k: v for k, v in tensors.items() if k in BUFFERS}
if "ptd_num_hops" in meta: # Ref2VA release
hops = int(meta["ptd_num_hops"])
steps = int(meta.get("pdd_num_steps", schedule.PDD_STEPS))
if hops < 1 or steps % hops:
raise ValueError(f"pdd_num_steps={steps} is not divisible by ptd_num_hops={hops}")
rank = int(meta.get("lora_rank", 128))
bank = _pdd_bank(tensors, hops, float(meta.get("lora_alpha", rank)) / rank)
heads = _pdd_heads(tensors, hops, steps)
grid = schedule.pdd_grid(hops, steps)
audio = schedule.audio_chords(grid)
elif meta.get("ptd_recipe") in schedule.RECIPES: # EPFL release
grid = schedule.RECIPES[meta["ptd_recipe"]]
hops = len(grid) - 1
bank = _epfl_bank(tensors, hops)
heads = []
audio = schedule.audio_tangents(grid)
else:
raise ValueError("Not a PTD adapter file: it names neither ptd_recipe nor ptd_num_hops")
bank = {name: {h: tuple(e) for h, e in by_hop.items()} for name, by_hop in bank.items()}
int8 = any(e.a.dtype == torch.int8 for by_hop in bank.values() for es in by_hop.values() for e in es)
return Adapter(label or str(path), hops, grid, audio, bank, heads, int8, buffers)
+384
View File
@@ -0,0 +1,384 @@
"""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"
+135
View File
@@ -0,0 +1,135 @@
"""PTD's fixed-hop sampler: one trained expert set per hop, Euler on the shared-clock velocity, state in fp32.
The recurrence follows ComfyUI-PTD (EPFL VITA Lab, Apache-2.0); the console bar, VRAM housekeeping between
hops and the retry without ComfyUI's allocation compiler follow ComfyUI-PTD-Compact (Nynxz, Apache-2.0).
"""
import time
import torch
from ..._core import log
from . import schedule
def _streams(value):
if getattr(value, "is_nested", False):
streams = tuple(value.unbind())
if len(streams) != 2:
raise ValueError("PTD expects exactly a picture and a sound stream")
return streams
return (value,)
def _map(value, op):
result = [op(s) for s in _streams(value)]
return type(value)(result) if getattr(value, "is_nested", False) else result[0]
def _pair(left, right, op):
a, b = _streams(left), _streams(right)
if len(a) != len(b) or any(x.shape != y.shape for x, y in zip(a, b)):
raise ValueError("The model output must keep the latent's streams and shapes")
result = [op(x, y) for x, y in zip(a, b)]
return type(left)(result) if getattr(left, "is_nested", False) else result[0]
def _refuse_unsupported(model, extra_args):
if extra_args.get("denoise_mask") is not None:
raise ValueError("PTD cannot work on a masked or partly denoised latent: it always starts from pure noise")
start = getattr(model, "latent_image", None)
if start is not None and any(bool(torch.count_nonzero(s)) for s in _streams(start)):
raise ValueError("PTD starts from an empty latent; give the picture as a reference instead of as the "
"starting latent (no second pass, no image-to-image)")
def _between_hops(model, hop):
"""Give PyTorch's cached activation memory back after a hop and log where the GPU memory is.
A hop's activations are freed when it ends but stay cached, and with DynamicVRAM the base model's
weights are evicted to make room; raising the watermark again lets them fault back in before the
next hop. Housekeeping only: never stops a run."""
try:
if not torch.cuda.is_available():
return
before = torch.cuda.memory_reserved() / 2**30
torch.cuda.empty_cache()
patcher = getattr(getattr(model, "inner_model", None), "model_patcher", None)
vbar = patcher._vbar_get() if hasattr(patcher, "_vbar_get") else None
if vbar is not None:
import comfy.model_management
comfy.model_management.reset_cast_buffers()
vbar.set_watermark(vbar.max_size)
free, total = torch.cuda.mem_get_info()
resident = patcher.loaded_size() / 2**30 if patcher is not None else float("nan")
log.info("FunPack PTD", f"hop {hop} memory: model resident {resident:.1f} GB; cache {before:.1f} -> "
f"{torch.cuda.memory_reserved() / 2**30:.1f} GB; card {(total - free) / 2**30:.1f}/{total / 2**30:.1f} GB used")
except Exception as exc: # noqa: BLE001
log.once("ptd:memory", log.WARNING, "FunPack PTD", f"could not release cached memory between hops: {exc}")
@torch.no_grad()
def _run(model, x, extra_args, controller, step):
state = _map(model.noise, lambda s: (s.to(torch.float64) * schedule.T_MAX).to(torch.float32)) \
if getattr(model, "noise", None) is not None else _map(x, lambda s: s.float())
_pair(state, x, lambda a, b: a)
first = _streams(state)[0]
grid = controller.grid.to(first.device)
for hop in range(controller.hops):
time_now = float(grid[hop])
with controller.activate(hop, time_now):
denoised = model(state, grid[hop].expand(first.shape[0]), **extra_args)
velocity = _pair(state, denoised, lambda s, d: (s.float() - d.float()) / time_now)
step(hop, {"x": state, "i": hop, "sigma": grid[hop], "sigma_hat": grid[hop], "denoised": denoised})
delta = (grid[hop + 1] - grid[hop]).float()
state = _pair(state, velocity, lambda s, v: s.float() + delta * v.float())
return state
def sample_ptd(model, x, sigmas, extra_args=None, callback=None, disable=None, *, controller):
"""A KSAMPLER function. `sigmas` must be the adapter's own hop times."""
from tqdm.auto import tqdm
expected = controller.grid
if sigmas.shape != expected.shape or not torch.allclose(sigmas.detach().cpu().double(), expected, atol=2e-6):
raise ValueError(f"PTD runs only the {controller.hops} hops it was trained on; its own times are "
f"{', '.join(f'{float(t):.3f}' for t in expected)}")
extra_args = {} if extra_args is None else dict(extra_args)
_refuse_unsupported(model, extra_args)
if torch.cuda.is_available():
torch.cuda.empty_cache() # whatever the last node cached is VRAM the allocation compiler cannot get
bar = tqdm(total=controller.hops, disable=disable)
done = [0]
def step(hop, record):
done[0] += 1
bar.update(1)
_between_hops(model, hop)
if callback is not None:
callback(record)
started = time.perf_counter()
try:
try:
out = _run(model, x, extra_args, controller, step)
except RuntimeError as exc:
if "aimdo memory compile error" not in str(exc) or not controller.allocation_compiler:
raise
# A known intermittent ComfyUI failure when VRAM is nearly full. Drop the half-recorded graph and
# sample this run again without the compiler; the hops are deterministic, so nothing changes.
log.warning("FunPack PTD", f"ComfyUI's allocation compiler failed at hop {done[0]} ({exc}); "
"sampling this run again without it")
import comfy.model_prefetch
comfy.model_prefetch.cleanup_prefetch_queues()
controller.allocation_compiler = False
bar.reset()
done[0] = 0
started = time.perf_counter()
try:
out = _run(model, x, extra_args, controller, step)
finally:
controller.allocation_compiler = True
finally:
bar.close()
seconds = time.perf_counter() - started
log.info("FunPack PTD", f"{controller.hops} hops in {seconds:.1f} s ({seconds / controller.hops:.2f} s/hop)")
return out
+92
View File
@@ -0,0 +1,92 @@
"""Hop times and audio clocks of the PTD releases.
The EPFL grids and the shared video/audio clock follow ComfyUI-PTD (EPFL VITA Lab, Apache-2.0); the
32-interval PDD grid, head plans and chord correction follow ComfyUI-MiniMaxH3-PTD (taobao-content-ai,
Apache-2.0), both via ComfyUI-PTD-Compact (Nynxz). See the README Thank you section.
"""
from itertools import pairwise
import torch
T_MAX = 0.999 # upper end of the shared clock
AUDIO_RELATIVE_SHIFT = 0.25
#: EPFL releases: hop times t_0 > ... > t_N = 0.
RECIPES = {
"h3_4step": (0.999, 0.947, 0.857, 0.667, 0.0),
"h3_6step": (0.999, 0.968, 0.923, 0.857, 0.750, 0.545, 0.0),
}
PDD_STEPS = 32 # intervals of the Ref2VA releases' own grid
VIDEO_SHIFT = 12.0
AUDIO_SHIFT = 3.0
def _time(value):
value = float(value)
if not 0.0 <= value <= T_MAX:
raise ValueError(f"PTD time must lie in [0, {T_MAX}], got {value}")
return value
def audio_sigma(time):
"""Audio's noise level on the shared clock."""
time = _time(time)
if time == T_MAX:
return T_MAX
return AUDIO_RELATIVE_SHIFT * time * T_MAX / (T_MAX + (AUDIO_RELATIVE_SHIFT - 1.0) * time)
def audio_sigma_prime(time):
"""d(audio_sigma)/dt."""
time = _time(time)
return AUDIO_RELATIVE_SHIFT * T_MAX ** 2 / (T_MAX + (AUDIO_RELATIVE_SHIFT - 1.0) * time) ** 2
def native_audio_shift(time):
"""The T=1 shift that gives native H3 PTD's audio sigma at this time (video shift 1)."""
time = _time(time)
if time == 0.0:
return AUDIO_RELATIVE_SHIFT
if time == T_MAX:
return 1.0
sigma = audio_sigma(time)
return sigma * (1.0 - time) / (time * (1.0 - sigma))
def _shifted(shift, sigma):
return shift * sigma / (1 + (shift - 1) * sigma)
def pdd_grid(hops, steps=PDD_STEPS, shift=VIDEO_SHIFT):
"""Hop times of a Ref2VA file: the video block boundaries of its 32-interval grid, then 0."""
size = steps // hops
noise = _shifted(float(shift), torch.linspace(1.0, 0.0, steps + 1, dtype=torch.float64))
return tuple(T_MAX * float(noise[h * size]) for h in range(hops)) + (0.0,)
def pdd_plans(hops, steps=PDD_STEPS, video_shift=VIDEO_SHIFT, audio_shift=AUDIO_SHIFT):
"""Per hop (video, audio) weights over the per-interval heads: the block's normalised step sizes."""
size = steps // hops
plans = []
for shift in (video_shift, audio_shift):
sizes = (1.0 - _shifted(float(shift), torch.linspace(1.0, 0.0, steps + 1, dtype=torch.float64))).diff()
rows = []
for h in range(hops):
plan = torch.zeros(steps, dtype=torch.float64)
block = sizes[h * size:(h + 1) * size]
plan[h * size:(h + 1) * size] = block / block.sum()
rows.append(plan.float())
plans.append(rows)
return list(zip(*plans))
def audio_chords(grid):
"""Per hop, the audio clock's mean slope. The Ref2VA audio head is the block-mean velocity."""
return tuple((audio_sigma(a) - audio_sigma(b)) / (a - b) for a, b in pairwise(grid))
def audio_tangents(grid):
"""Per hop, the audio clock's slope at the hop start. The EPFL audio head is an instant velocity."""
return tuple(audio_sigma_prime(a) for a in grid[:-1])
+104
View File
@@ -0,0 +1,104 @@
"""A tiny real native MiniMax-H3, and PTD adapter files in the three layouts, for the PTD tests.
The model builder and file layouts follow ComfyUI-PTD-Compact's tests (Nynxz, Apache-2.0).
"""
import json
import torch
from safetensors.torch import save_file
DIMS = dict(hidden_size=64, num_layers=2, token_refiner_num_layers=1, num_attention_heads=2, attention_head_dim=32,
ffn_hidden_size=48, text_dim=40, timestep_input_dim=16, time_embed_hidden_size=32, time_embed_dim=16,
rope_inv_freq_len=4)
PARTS = {"attn.to_q": ("attn.qkv_proj", 3), "attn.to_k": ("attn.qkv_proj", 3), "attn.to_v": ("attn.qkv_proj", 3),
"attn.to_out.0": ("attn.out_proj", 1), "ff.net.0.proj": ("mlp.fc1", 1), "ff.net.2": ("mlp.fc2", 1)}
BLOCKS = (("transformer_blocks", "blocks", lambda n: len(n.blocks)),
("token_refiner.refiner_blocks", "token_refiner.blocks", lambda n: len(n.token_refiner.blocks)))
def native_model(curve=False, **override):
"""A ModelPatcher around a real MiniMaxH3 (random weights, CPU). curve=True is the pruned layout."""
import comfy.model_patcher
import comfy.supported_models
extra = {"adaln_curve_grid": 9} if curve else {}
config = comfy.supported_models.MiniMaxH3(dict(image_model="minimax_h3", **{**DIMS, **override}, **extra))
config.set_inference_dtype(torch.float32, None)
model = config.get_model({})
torch.manual_seed(0)
with torch.no_grad():
for p in model.diffusion_model.parameters():
p.normal_(0, 0.05)
dm = model.diffusion_model
n = dm.rope.inv_freq.numel()
dm.rope.inv_freq.copy_(1.0 / 10000 ** (torch.arange(n, dtype=torch.float32) / n))
if curve:
dm.adaln_t_table.normal_(0, 0.5)
cpu = torch.device("cpu")
return comfy.model_patcher.ModelPatcher(model, cpu, cpu)
def _int8(x):
s = x.abs().amax(-1, keepdim=True).clamp_min(1e-12) / 127
return (x / s).round().clamp(-127, 127).to(torch.int8), s.squeeze(-1)
def epfl_file(path, native, hops=4, int8=False, zero_a=False, ranks=(3, 4, 5)):
"""An EPFL-layout file; returns {(native module, part, hop): dense delta}."""
g = torch.Generator().manual_seed(1)
tensors, dense = {}, {}
for src, dst, count in BLOCKS:
for i in range(count(native)):
for part, (module, div) in PARTS.items():
layer = native.get_submodule(f"{dst}.{i}.{module}")
for hop in range(hops):
r = ranks[(hop + len(part)) % len(ranks)]
A = torch.randn(r, layer.in_features, generator=g) * 0.1 * (0 if zero_a else 1)
B = torch.randn(layer.out_features // div, r, generator=g) * 0.1
key = f"transformer.{src}.{i}.{part}"
if int8:
qa, sa = _int8(A)
qb, sb = _int8(B)
tensors[f"{key}.lora_A.{hop}.weight"], tensors[f"{key}.lora_A.{hop}.weight_scale"] = qa, sa[:, None]
tensors[f"{key}.lora_B.{hop}.weight"], tensors[f"{key}.lora_B.{hop}.weight_scale"] = qb, sb[:, None]
A, B = qa.float() * sa[:, None], qb.float() * sb[:, None]
else:
tensors[f"{key}.lora_A.{hop}.weight"], tensors[f"{key}.lora_B.{hop}.weight"] = A, B
dense[(f"{dst}.{i}.{module}", part, hop)] = B @ A
for head, native_head in (("proj_out", "video_out"), ("audio_proj_out", "audio_out")):
layer = native.final_layer.get_submodule(native_head)
for hop in range(hops):
A = torch.randn(layer.out_features, layer.in_features, generator=g) * 0.01 * (0 if zero_a else 1)
tensors[f"transformer.{head}.main.lora_A.{hop}.weight"] = A
tensors[f"transformer.{head}.main.lora_B.{hop}.weight"] = torch.eye(layer.out_features)
dense[(f"final_layer.{native_head}", "head", hop)] = A
meta = {"ptd_format": "comfyui-native-h3-compact" if int8 else "comfyui-native-h3",
"ptd_recipe": f"h3_{hops}step"}
save_file(tensors, str(path), metadata=meta)
return dense
def pdd_file(path, native, hops=4, steps=32, rank=8, int8=False, zero=False):
"""A Ref2VA-layout file: factors stacked over hops, plus a bank of per-interval heads."""
g = torch.Generator().manual_seed(1)
t = {}
for src, dst, count in BLOCKS:
for i in range(count(native)):
for part, (module, div) in PARTS.items():
layer = native.get_submodule(f"{dst}.{i}.{module}")
down = torch.randn(hops, rank, layer.in_features, generator=g) * 0.1 * (0 if zero else 1)
up = torch.randn(hops, layer.out_features // div, rank, generator=g) * 0.1
key = f"{src}.{i}.{part}"
if int8:
(t[f"{key}.lora_down"], t[f"{key}.lora_down_scale"]) = _int8(down)
(t[f"{key}.lora_up"], t[f"{key}.lora_up_scale"]) = _int8(up)
else:
t[f"{key}.lora_down"], t[f"{key}.lora_up"] = down.bfloat16(), up.bfloat16()
for prefix, head in (("proj_out", native.final_layer.video_out), ("audio_proj_out", native.final_layer.audio_out)):
w = head.weight.detach().float()
t[f"{prefix}.weight"] = (w[None] + torch.randn(steps, *w.shape, generator=g) * 0.05).bfloat16()
t[f"{prefix}.bias"] = (torch.randn(steps, w.shape[0], generator=g) * 0.05).bfloat16()
meta = {"ptd_num_hops": str(hops), "pdd_num_steps": str(steps), "lora_rank": str(rank), "lora_alpha": f"{rank}.0"}
if int8:
meta["ptd_compact"] = json.dumps({"schema": 1})
save_file(t, str(path), metadata=meta)
+302
View File
@@ -0,0 +1,302 @@
"""PTD on a tiny real MiniMax-H3: all three file layouts, the hop switching, the sampler swap and clean removal.
Structure follows ComfyUI-PTD-Compact's tests (Nynxz, Apache-2.0). They prove the wiring and the maths of
our side on random weights, nothing about picture quality on the real model.
"""
import pytest
import torch
import torch.nn.functional as F
from . import _tiny
HOP_TIMES = (0.999, 0.947, 0.857, 0.667)
@pytest.fixture(autouse=True)
def _needs_comfy(comfyui):
"""Imports comfy."""
@pytest.fixture
def ptd():
import importlib
return importlib.import_module("modules.sampling.ptd")
@pytest.fixture
def folder(tmp_path):
import folder_paths
folder_paths.add_model_folder_path("ptd", str(tmp_path), is_default=True)
return tmp_path
def _guarded(patcher):
from modules._core import patching
return patching.GuardedPatcher(patcher, "funpack.ptd", patching.Dropped())
def _install(ptd, patcher, name, kernels="exact"):
note = ptd.install(_guarded(patcher), {"enabled": True, "adapter": name, "kernels": kernels}, "funpack.ptd")
return patcher.model_options["funpack_ptd_controller"], note
def _clone(base):
return base.clone()
def _sample(model, sampler=None, sigmas=None):
import comfy.nested_tensor
import comfy.samplers
guider = comfy.samplers.CFGGuider(model)
context = torch.randn(1, 6, _tiny.DIMS["text_dim"], generator=torch.Generator().manual_seed(3))
guider.set_conds([[context, {}]], [[context, {}]])
guider.set_cfg(1.0)
video, audio = torch.zeros(1, 24, 2, 4, 4), torch.zeros(1, 32, 2, 3)
noise = comfy.nested_tensor.NestedTensor([torch.randn(video.shape, generator=torch.Generator().manual_seed(4)),
torch.randn(audio.shape, generator=torch.Generator().manual_seed(5))])
sampler = sampler or comfy.samplers.sampler_object("euler")
sigmas = sigmas if sigmas is not None else torch.linspace(1.0, 0.0, 21)
try:
out = guider.sample(noise, comfy.nested_tensor.NestedTensor([video, audio]), sampler, sigmas,
disable_pbar=True, seed=7)
finally:
import comfy.model_management
comfy.model_management.unload_all_models()
return [t.clone() for t in out.unbind()]
@pytest.mark.parametrize("int8", [False, True], ids=["float", "int8"])
@pytest.mark.parametrize("curve", [False, True], ids=["full", "pruned"])
def test_each_hop_adds_its_own_expert_to_the_layer(ptd, folder, int8, curve):
base = _tiny.native_model(curve)
native = base.get_model_object("diffusion_model")
weights = {n: p.detach().clone() for n, p in native.named_parameters()}
dense = _tiny.epfl_file(folder / "a.safetensors", native, int8=int8)
controller, _ = _install(ptd, _clone(base), "a.safetensors")
for hop, sigma in enumerate(HOP_TIMES):
for name in ("blocks.0.attn.qkv_proj", "blocks.1.mlp.fc1", "blocks.1.mlp.fc2", "blocks.0.attn.out_proj",
"token_refiner.blocks.0.attn.qkv_proj"):
layer = native.get_submodule(name)
inp = torch.randn(5, layer.in_features)
parts = [(p, d) for (n, p, h), d in dense.items() if n == name and h == hop]
if name.endswith("qkv_proj"):
order = {"attn.to_q": 0, "attn.to_k": 1, "attn.to_v": 2}
delta = torch.cat([d for _, d in sorted(parts, key=lambda pd: order[pd[0]])], 0)
else:
(part, delta), = parts
if part == "ff.net.0.proj":
up, gate = delta.chunk(2, 0)
delta = torch.cat([gate, up], 0)
want = F.linear(inp, weights[name + ".weight"] + delta, weights.get(name + ".bias"))
with controller.activate(hop, sigma), controller.forward_hooks(controller.current()):
got = layer(inp)
torch.testing.assert_close(got, want, rtol=1e-4, atol=1e-5)
torch.testing.assert_close(layer(inp), F.linear(inp, weights[name + ".weight"], weights.get(name + ".bias")),
rtol=1e-4, atol=1e-5) # nothing stays hooked after the hop
def test_a_hop_must_run_at_its_trained_time(ptd, folder):
base = _tiny.native_model()
_tiny.epfl_file(folder / "a.safetensors", base.get_model_object("diffusion_model"))
controller, _ = _install(ptd, _clone(base), "a.safetensors")
with pytest.raises(ValueError, match="trained time"):
with controller.activate(1, 0.5):
pass
with pytest.raises(RuntimeError, match="no PTD hop is running"):
controller.current()
def test_the_chosen_sampler_is_replaced_by_the_adapters_own_hops(ptd, folder):
base = _tiny.native_model()
native = base.get_model_object("diffusion_model")
_tiny.epfl_file(folder / "a.safetensors", native)
_tiny.epfl_file(folder / "zero.safetensors", native, zero_a=True)
seen = []
for name in ("a.safetensors", "zero.safetensors"):
model = _clone(base)
controller, note = _install(ptd, model, name)
assert "4 hops" in note
calls = []
original = controller.activate
def spy(hop, sigma, original=original, calls=calls):
calls.append(hop)
return original(hop, sigma)
controller.activate = spy
# twenty euler steps were asked for; PTD runs its four hops
out = _sample(model)
assert calls == [0, 1, 2, 3]
assert all(torch.isfinite(t).all() for t in out)
seen.append(out)
assert not torch.allclose(seen[0][0], seen[1][0])
# the base model is not left patched
plain = _sample(_clone(base))
assert not torch.allclose(plain[0], seen[0][0])
@pytest.mark.parametrize("curve", [False, True], ids=["full", "pruned"])
def test_exact_and_fast_blocks_agree(ptd, folder, curve):
base = _tiny.native_model(curve)
_tiny.pdd_file(folder / "r.safetensors", base.get_model_object("diffusion_model"))
exact = _sample(_installed(ptd, base, "exact"))
fast = _sample(_installed(ptd, base, "fast"))
for e, f in zip(exact, fast):
assert torch.isfinite(e).all()
assert torch.allclose(e, f, rtol=1e-4, atol=1e-4)
def _installed(ptd, base, kernels):
model = _clone(base)
_install(ptd, model, "r.safetensors", kernels)
return model
def test_int8_ref2va_stays_close_to_float(ptd, folder):
base = _tiny.native_model()
native = base.get_model_object("diffusion_model")
_tiny.pdd_file(folder / "r.safetensors", native)
_tiny.pdd_file(folder / "q.safetensors", native, int8=True)
ptd._kept.clear()
full = _sample(_installed_file(ptd, base, "r.safetensors"))
small = _sample(_installed_file(ptd, base, "q.safetensors"))
for f, q in zip(full, small):
assert torch.allclose(f, q, rtol=1e-2, atol=1e-2)
def _installed_file(ptd, base, name):
model = _clone(base)
_install(ptd, model, name)
return model
def test_ref2va_heads_replace_the_native_heads_for_a_hop_only(ptd, folder):
base = _tiny.native_model()
native = base.get_model_object("diffusion_model")
_tiny.pdd_file(folder / "r.safetensors", native)
controller, _ = _install(ptd, _clone(base), "r.safetensors")
head = native.final_layer.video_out
x = torch.randn(5, head.in_features)
plain = head(x)
hop = 1
with controller.activate(hop, float(controller.grid[hop])), controller.forward_hooks(controller.current()):
hopped = head(x)
weight, bias = controller.adapter.heads[hop]["final_layer.video_out"]
torch.testing.assert_close(hopped, F.linear(x, weight, bias), atol=1e-5, rtol=1e-5)
assert not torch.allclose(hopped, plain, atol=1e-3)
assert torch.equal(head(x), plain)
def test_grids_are_the_published_ones(ptd, folder):
from modules.sampling.ptd import schedule
assert schedule.RECIPES["h3_4step"][:4] == HOP_TIMES
for hops in (4, 8):
grid = schedule.pdd_grid(hops)
assert len(grid) == hops + 1 and abs(grid[0] - schedule.T_MAX) < 1e-12 and grid[-1] == 0.0
assert all(a > b for a, b in zip(grid, grid[1:]))
for video, audio in schedule.pdd_plans(hops):
for plan in (video, audio):
assert abs(float(plan.sum()) - 1) < 1e-6 and int((plan > 0).sum()) == 32 // hops
def test_a_file_for_another_model_is_refused_in_plain_words(ptd, folder):
big = _tiny.native_model()
_tiny.epfl_file(folder / "a.safetensors", big.get_model_object("diffusion_model"))
smaller = _tiny.native_model(num_layers=1)
with pytest.raises(ValueError, match="does not match the loaded model's layers"):
_install(ptd, _clone(smaller), "a.safetensors")
def test_not_a_ptd_file_is_refused(ptd, folder):
from safetensors.torch import save_file
save_file({"x": torch.zeros(2)}, str(folder / "n.safetensors"), metadata={"format": "pt"})
with pytest.raises(ValueError, match="Not a PTD adapter file"):
_install(ptd, _clone(_tiny.native_model()), "n.safetensors")
def test_no_file_chosen_is_said_not_quarantined(ptd, folder):
from modules._core import control
with pytest.raises(control.Unavailable):
ptd.install(_guarded(_clone(_tiny.native_model())), {"enabled": True, "adapter": "", "kernels": "exact"},
"funpack.ptd")
def test_off_installs_nothing(ptd, folder):
patcher = _clone(_tiny.native_model())
assert ptd.install(_guarded(patcher), {"enabled": False, "adapter": "", "kernels": "exact"}, "funpack.ptd") is None
assert "funpack_ptd_controller" not in patcher.model_options
def test_load_modifiers_strips_ptd_so_a_second_pass_reinstalls_it_once(ptd, folder):
from modules._core import patching
base = _tiny.native_model()
_tiny.epfl_file(folder / "a.safetensors", base.get_model_object("diffusion_model"))
model = _clone(base)
_install(ptd, model, "a.safetensors")
again = model.clone()
assert patching.strip(again, "funpack.") >= 3
assert "funpack_ptd_controller" not in again.model_options
assert again.model_options.get("model_function_wrapper") is None
_install(ptd, again, "a.safetensors") # no "already carries" error
assert "funpack_ptd_controller" in again.model_options
assert "funpack_ptd_controller" not in base.model_options and not base.object_patches
def test_a_latent_that_does_not_start_empty_is_refused(ptd, folder):
from modules.sampling.ptd.sampler import _refuse_unsupported
class Model:
latent_image = torch.ones(1, 2)
with pytest.raises(ValueError, match="empty latent"):
_refuse_unsupported(Model(), {})
with pytest.raises(ValueError, match="masked"):
_refuse_unsupported(object(), {"denoise_mask": torch.ones(1)})
def test_it_installs_through_load_modifiers_on_an_h3_model(ptd, folder, monkeypatch):
from core import registry
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
base = _tiny.native_model()
_tiny.pdd_file(folder / "r.safetensors", base.get_model_object("diffusion_model"))
spec = registry.current().specs["ptd"]
monkeypatch.setitem(spec.settings["adapter"], "options", [{"value": "r.safetensors", "label": "r"}])
monkeypatch.setitem(spec.settings["adapter"], "default", "r.safetensors")
patched, status = FunPackLoadModifiers.execute(_clone(base), {"ptd": {"enabled": True}}).result
assert "ptd: r.safetensors, 4 hops" in status
assert "failed" not in status
out = _sample(patched)
assert all(torch.isfinite(t).all() for t in out)
# switched off, the same model samples as the plain one does
plain, status = FunPackLoadModifiers.execute(_clone(base), {"ptd": {"enabled": False}}).result
assert "ptd" not in status.splitlines()[0]
def test_an_ordinary_lora_stacks_with_ptd_in_either_order(ptd, folder):
import comfy.sd
base = _tiny.native_model()
native = base.get_model_object("diffusion_model")
dense = _tiny.epfl_file(folder / "a.safetensors", native)
g = torch.Generator().manual_seed(2)
layer = native.get_submodule("blocks.1.mlp.fc2")
up, down = torch.randn(layer.out_features, 4, generator=g) * 0.1, torch.randn(4, layer.in_features, generator=g) * 0.1
lora = {"blocks.1.mlp.fc2.lora_up.weight": up, "blocks.1.mlp.fc2.lora_down.weight": down}
weight = layer.weight.detach().clone()
for lora_first in (True, False):
ptd._kept.clear()
model = comfy.sd.load_lora_for_models(_clone(base), None, lora, 0.7, 0)[0] if lora_first else _clone(base)
controller, _ = _install(ptd, model, "a.safetensors")
if not lora_first:
model = comfy.sd.load_lora_for_models(model, None, lora, 0.7, 0)[0]
import comfy.model_management
comfy.model_management.load_models_gpu([model])
try:
inp = torch.randn(5, layer.in_features)
hop = 2
want = F.linear(inp, weight + 0.7 * up @ down + dense[("blocks.1.mlp.fc2", "ff.net.2", hop)],
layer.bias)
with controller.activate(hop, HOP_TIMES[hop]), controller.forward_hooks(controller.current()):
torch.testing.assert_close(layer(inp), want, rtol=1e-4, atol=1e-5)
finally:
comfy.model_management.unload_all_models()