Files
olivv-cs-ComfyUI-FunPack/nvfp4_patch.py
T
digital-garbageandClaude Fable 5 e9aee731e7 nvfp4: static input-scale calibration (capture once, inject forever)
Justified by live data: sampler phase timing shows sampling at 89% of
the chain node, all 792 quantized layers on the fast path, and the probe
puts dynamic input quantization at ~0.30 ms/layer-call vs ~0.11 ms with
a provided scale (amax dominates) - a top-line per-call cost.

New input_scales widget on the loader:
- "auto-calibrate", first load without a sidecar: forward-pre-hooks
  record each quantized layer's running input amax (one generation is
  enough; sidecar under refinements/nvfp4_scales/<checkpoint>.json,
  per-machine and rebuilt in one run whenever absent).
- every later load: injects input_scale = amax * 1.25 / (448*6) into
  the state dict next to each quantized weight - core's loader
  registers it natively and skips the per-call amax from then on.
  Margin leaves clip headroom (NVFP4 clips above the scale).

Tests: suffix-matched injection across checkpoint prefixes, value math,
core Linear registering input_scale and forwarding, sidecar read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:58:46 +03:00

528 lines
25 KiB
Python

"""FunPack NVFP4 on-the-fly model patching (EXPERIMENTAL).
Blackwell's 5th-gen tensor cores run NVFP4 (e2m1 with per-16-element FP8-E4M3 block
scales + a per-tensor FP32 scale) at roughly 2x FP8 / 4x BF16 GEMM throughput. ComfyUI
core already ships the whole runtime for it (comfy.quant_ops layouts + MixedPrecisionOps
dispatch, dynamic activation quantization, LoRA fallback, dequant emulation on non-FP4
GPUs) - but only for checkpoints that were quantized ahead of time. Stock loaders offer
fp8 at best, and on sm_120 the fp8 path is mostly storage, not tensor-core speedup.
This module closes that gap: it quantizes an existing bf16/fp16 LTX-AV checkpoint to
NVFP4 *at load time* as a pure state-dict transform - selected 2D Linear weights are
re-serialized in core's exact quantized-checkpoint format (weight/weight_scale/
weight_scale_2 + per-layer `comfy_quant` metadata), then handed to the normal
`load_diffusion_model_state_dict` path. Core's own detection routes the model through
MixedPrecisionOps; every layer we did NOT tag simply stays at compute dtype. No custom
forward, no monkeypatching, no new inference code paths to maintain.
Zone-aware precision map (the part generic quantizers can't do): LTX-AV is a joint
audio-video transformer and its zones are not equally robust. By default we quantize
only the video-branch Linears (attn1/attn2/ff) of the middle blocks, keeping at full
precision:
- the audio branch (audio_attn1/audio_attn2/audio_ff) - joint attention is the part
that corrupts first (see the self-consistency post-block injection failure),
- the cross-modal bridges (audio_to_video_attn / video_to_audio_attn),
- the first/last N blocks (standard quantization practice; they carry the most
input/output-adjacent signal),
- everything that is not a plain 2D matmul (norms, ada modulation, embeddings).
Scopes widen from there for experimentation. W4A4 without calibration WILL cost some
fidelity - this is an experimental speed/VRAM lever, off unless you wire the node in.
Requires: ComfyUI with comfy.quant_ops (comfy_kitchen), torch cu13+ on Blackwell
(sm_120+) for real FP4 GEMMs. On anything older the model still loads and runs through
core's dequant emulation (no speedup, same quality) - useful for A/B sanity checks.
"""
from __future__ import annotations
import json
import logging
import os
import re
import torch
# ---------------------------------------------------------------------------
# Static input-scale calibration
# ---------------------------------------------------------------------------
# Core quantizes each layer's INPUT dynamically (amax reduction + quantize) on every
# call unless the layer has a static `input_scale` parameter. Live probe on sm_120:
# the dynamic path costs ~0.30 ms/layer-call and a provided scale cuts it to ~0.11 ms
# (amax dominates) - across ~800 quantized layers that is a top-line cost of every
# model call. Calibration is capture-then-freeze: a "calibrate" run records each
# quantized layer's input amax via forward-pre-hooks into a per-checkpoint sidecar;
# later loads inject `input_scale = amax * MARGIN / (448 * 6)` into the state dict,
# where core's loader picks it up natively. MARGIN leaves headroom because NVFP4
# clips activations above the scale; per the disposable-keys philosophy the sidecar
# is per-machine and rebuilt in one run whenever absent.
CALIBRATION_MARGIN = 1.25
_F8F4_MAX = 448.0 * 6.0 # F8_E4M3_MAX * F4_E2M1_MAX
INPUT_SCALE_MODES = ["dynamic", "auto-calibrate"]
def _scales_path(checkpoint_path):
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "refinements", "nvfp4_scales")
return os.path.join(base, os.path.basename(checkpoint_path) + ".json")
def load_calibration(checkpoint_path):
"""{module_path: input_amax} recorded by a previous calibrate run, or None."""
try:
p = _scales_path(checkpoint_path)
if not os.path.exists(p):
return None
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
amax = data.get("amax") or {}
return {str(k): float(v) for k, v in amax.items()} or None
except Exception as e: # noqa: BLE001
logging.warning(f"[FunPack NVFP4] could not read calibration sidecar: {e}")
return None
def inject_input_scales(sd, target_weight_keys, calibration, margin=CALIBRATION_MARGIN):
"""Add `<layer>.input_scale` next to each quantized weight whose module path has a
calibrated amax. Keys are matched by suffix so any checkpoint prefix
('model.diffusion_model.' or none) works. Returns the number injected."""
injected = 0
for k in target_weight_keys:
prefix = k[: -len("weight")] # '...transformer_blocks.3.attn1.to_q.'
module_path = prefix.rstrip(".")
for cal_key, amax in calibration.items():
if module_path == cal_key or module_path.endswith("." + cal_key):
sd[prefix + "input_scale"] = torch.tensor(
float(amax) * margin / _F8F4_MAX, dtype=torch.float32)
injected += 1
break
return injected
def install_calibration_capture(model, checkpoint_path):
"""Forward-pre-hooks on every quantized Linear recording the running input amax.
The sidecar is written on the first full sweep and refreshed periodically; hooks
live for the model instance's lifetime (a calibration run is expected to be
slightly SLOWER - it pays the amax it is trying to eliminate, once)."""
store = {"amax": {}, "writes": 0, "calls": 0}
path = _scales_path(checkpoint_path)
def _flush():
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"amax": store["amax"], "margin": CALIBRATION_MARGIN}, f)
os.replace(tmp, path)
store["writes"] += 1
except Exception as e: # noqa: BLE001
logging.warning(f"[FunPack NVFP4] calibration flush failed: {e}")
hooked = 0
try:
for name, m in model.model.diffusion_model.named_modules():
if getattr(m, "quant_format", None) != "nvfp4":
continue
def _hook(_module, args, _name=name):
try:
x = args[0] if args else None
if isinstance(x, torch.Tensor):
a = float(x.detach().abs().amax())
if a > store["amax"].get(_name, 0.0):
store["amax"][_name] = a
store["calls"] += 1
if store["calls"] % 4096 == 0:
_flush()
except Exception:
pass
m.register_forward_pre_hook(_hook)
hooked += 1
except Exception as e: # noqa: BLE001
logging.warning(f"[FunPack NVFP4] calibration capture install failed: {e}")
return 0
if hooked:
# First flush after one full model call so a single generation is enough.
def _first_flush_hook(_module, _args):
if store["writes"] == 0 and len(store["amax"]) >= hooked:
_flush()
try:
last = [m for _, m in model.model.diffusion_model.named_modules()
if getattr(m, "quant_format", None) == "nvfp4"][-1]
last.register_forward_pre_hook(_first_flush_hook)
except Exception:
pass
return hooked
QUANTIZE_SCOPES = [
"video blocks", # video-branch Linears in middle blocks (default, safest)
"video + cross-modal", # + audio_to_video_attn / video_to_audio_attn bridges
"all blocks", # every Linear inside transformer_blocks (incl. audio branch)
"all 2D layers", # + eligible Linears outside the blocks (most aggressive)
]
# Video-branch submodules inside a BasicAVTransformerBlock (comfy/ldm/lightricks/av_model.py).
_VIDEO_MODULES = ("attn1.", "attn2.", "ff.")
_CROSS_MODULES = ("audio_to_video_attn.", "video_to_audio_attn.")
# Matches "...transformer_blocks.<idx>.<rest>" with any leading prefix
# ("model.diffusion_model." or none - load_diffusion_model_state_dict strips prefixes
# from ALL keys uniformly, so our metadata keys ride along).
_BLOCK_RE = re.compile(r"(?:^|\.)transformer_blocks\.(\d+)\.(.*)$")
# Outside the blocks ("all 2D layers" scope) never touch input/output-adjacent or
# modulation weights even when they are 2D Linears.
_GENERIC_SKIP = ("proj_out", "adaln", "embed", "emb.", "norm", "scale_shift", "patchify")
# Below this, quantization overhead outweighs any GEMM win and relative error is worst.
_MIN_DIM = 512
def _block_ids(sd) -> list[int]:
ids = set()
for k in sd:
m = _BLOCK_RE.search(k)
if m:
ids.add(int(m.group(1)))
return sorted(ids)
def _selected(key_body: str, scope: str, keep: set[int]) -> bool:
"""Decide whether the Linear at `key_body` (state-dict key minus '.weight') is
quantized under `scope`, given the high-precision `keep` block set."""
m = _BLOCK_RE.search(key_body + ".")
if not m:
if scope != "all 2D layers":
return False
low = key_body.lower()
return not any(s in low for s in _GENERIC_SKIP)
idx, rest = int(m.group(1)), m.group(2)
if idx in keep:
return False
if scope in ("all blocks", "all 2D layers"):
return True
if any(rest.startswith(p) for p in _VIDEO_MODULES):
return True
if scope == "video + cross-modal" and any(rest.startswith(p) for p in _CROSS_MODULES):
return True
return False
def _eligible(key: str, w) -> bool:
return (
key.endswith(".weight")
and isinstance(w, torch.Tensor)
and w.dim() == 2
and w.shape[0] >= _MIN_DIM
and w.shape[1] >= _MIN_DIM
and w.shape[1] % 16 == 0 # NVFP4 blocks run along in_features
)
def quantize_state_dict_nvfp4(sd, scope="video blocks", keep_first_blocks=2,
keep_last_blocks=2, device=None, progress_callback=None):
"""Return (new_sd, quantized_layer_names, kept_block_ids).
Pure transform: selected 2D weights are replaced by NVFP4 QuantizedTensor
serialization (weight uint8-packed, weight_scale fp8-e4m3 block scales,
weight_scale_2 fp32 tensor scale) plus a per-layer `comfy_quant` metadata key -
byte-for-byte the format `comfy.ops._load_quantized_module` consumes. Unselected
keys pass through untouched.
"""
import comfy.quant_ops as quant_ops
if not quant_ops._CK_AVAILABLE:
raise RuntimeError(
"comfy_kitchen is unavailable in this ComfyUI - NVFP4 quantization needs it. "
"Update ComfyUI / reinstall requirements."
)
if scope not in QUANTIZE_SCOPES:
raise ValueError(f"Unknown quantize scope: {scope}")
for k in sd:
if k.endswith(".comfy_quant") or k.endswith("scaled_fp8"):
raise ValueError(
"This checkpoint is already quantized (comfy_quant / scaled_fp8 metadata "
"found). NVFP4 patching needs the original bf16/fp16 weights."
)
ids = _block_ids(sd)
keep: set[int] = set()
if ids:
if keep_first_blocks > 0:
keep |= set(ids[:keep_first_blocks])
if keep_last_blocks > 0:
keep |= set(ids[-keep_last_blocks:])
targets = [k for k, w in sd.items() if _eligible(k, w) and _selected(k[: -len(".weight")], scope, keep)]
meta = torch.tensor(list(json.dumps({"format": "nvfp4"}).encode("utf-8")), dtype=torch.uint8)
out = {}
done = 0
for k, w in sd.items():
if k not in targets:
out[k] = w
continue
wq = w
if wq.dtype not in (torch.float32, torch.bfloat16):
# fp16 and fp8-stored checkpoints alike: quantize from bf16.
wq = wq.to(torch.bfloat16)
if device is not None:
wq = wq.to(device)
qt = quant_ops.QuantizedTensor.from_float(wq, "TensorCoreNVFP4Layout", scale="recalculate")
for kk, vv in qt.state_dict(k).items():
out[kk] = vv.to("cpu")
out[k[: -len("weight")] + "comfy_quant"] = meta.clone()
del qt, wq
done += 1
if progress_callback is not None:
progress_callback(done, len(targets))
if done % 100 == 0:
logging.info(f"[FunPack NVFP4] quantized {done}/{len(targets)} layers…")
return out, targets, keep
def log_nvfp4_diagnostics(model, device=None):
"""Print everything needed to tell WHY NVFP4 is (or is not) fast on this machine.
'Loaded fine' and 'runs fast' are different claims. The three ways the fast path
silently degrades, in the order they bite:
1. comfy_kitchen's CUDA backend is disabled when torch is built for CUDA < 13 -
quantized matmuls then fall through to the pure-PyTorch eager backend, which
is CORRECT but no faster than bf16 (the exact "speed didn't change" symptom).
2. The GPU lacks FP4 tensor cores (pre-Blackwell) - core flags every nvfp4 layer
_full_precision_mm and dequantizes (visible in the layer census below).
3. Everything is native but the quantized scope is too small a slice of the
total step time to move the needle.
A timed 4096x4096 GEMM probe settles it empirically: on a native FP4 path the
quantized matmul must beat bf16 clearly; parity or worse means emulation."""
lines = []
try:
import comfy_kitchen as ck
for name, info in ck.list_backends().items():
if info.get("available"):
caps = info.get("capabilities") or []
lines.append(f" ck backend '{name}': AVAILABLE, scaled_mm_nvfp4="
f"{'yes' if 'scaled_mm_nvfp4' in caps else 'NO'}")
else:
lines.append(f" ck backend '{name}': unavailable ({info.get('unavailable_reason')})")
except Exception as e: # noqa: BLE001
lines.append(f" comfy_kitchen introspection failed: {e}")
cap = torch.cuda.get_device_capability(device) if torch.cuda.is_available() else None
lines.append(f" torch {torch.__version__} | cuda build {torch.version.cuda} | device capability {cap}")
if torch.version.cuda is not None:
try:
if tuple(map(int, str(torch.version.cuda).split(".")))[0] < 13:
lines.append(" ⚠ torch is built for CUDA < 13 -> comfy_kitchen DISABLES its CUDA "
"backend -> nvfp4 matmuls run on the eager (pure PyTorch) backend: "
"correct but NO speedup. Install a cu13x torch build to unlock FP4 GEMMs.")
except Exception: # noqa: BLE001
pass
n_q = n_emul = 0
try:
for m in model.model.diffusion_model.modules():
if getattr(m, "quant_format", None) == "nvfp4":
n_q += 1
if getattr(m, "_full_precision_mm", False):
n_emul += 1
lines.append(f" nvfp4 layers in loaded model: {n_q}"
+ (f" — {n_emul} flagged FULL-PRECISION EMULATION (no FP4 compute on this GPU)"
if n_emul else " — all on the quantized matmul path"))
except Exception as e: # noqa: BLE001
lines.append(f" layer census failed: {e}")
if torch.cuda.is_available():
try:
import time
import comfy.quant_ops as quant_ops
d = torch.device(device) if device is not None else torch.device("cuda")
w = torch.randn(4096, 4096, dtype=torch.bfloat16, device=d)
qw = quant_ops.QuantizedTensor.from_float(w, "TensorCoreNVFP4Layout", scale="recalculate")
def _timed(fn, n=30):
for _ in range(5):
fn()
torch.cuda.synchronize(d)
t0 = time.perf_counter()
for _ in range(n):
fn()
torch.cuda.synchronize(d)
return (time.perf_counter() - t0) / n
# Decomposed probe at two token counts: M=1024 (small scene chunk) and
# M=16384 (realistic video token count). Times the input quantization and
# the FP4 GEMM SEPARATELY — "no speedup" has two very different fixes
# depending on which one is at fault:
# * GEMM alone beats bf16, quantization dominates -> per-call overhead
# problem (x1232 layers/step); needs static input scales or fusion.
# * GEMM alone loses to bf16 -> the FP4 kernel isn't engaging at all.
results = {}
for m in (1024, 16384):
x = torch.randn(m, 4096, dtype=torch.bfloat16, device=d)
t_bf16 = _timed(lambda: torch.nn.functional.linear(x, w))
t_quant = _timed(lambda: quant_ops.QuantizedTensor.from_float(x, "TensorCoreNVFP4Layout"))
# Same quantization with a PREcomputed static scale: isolates the amax
# reduction (which per-layer input_scale calibration could remove) from
# tensor-construction/kernel-launch overhead (which it can't).
s = (x.abs().amax() / (448.0 * 6.0)).float()
t_quant_s = _timed(lambda: quant_ops.QuantizedTensor.from_float(x, "TensorCoreNVFP4Layout", scale=s))
qx = quant_ops.QuantizedTensor.from_float(x, "TensorCoreNVFP4Layout")
t_gemm = _timed(lambda: torch.nn.functional.linear(qx, qw))
total = t_quant + t_gemm
results[m] = (t_bf16, t_quant, t_quant_s, t_gemm)
lines.append(
f" probe 4096x4096 @ M={m}: bf16 {t_bf16 * 1e3:.3f} ms | "
f"input-quant {t_quant * 1e3:.3f} ms (static-scale {t_quant_s * 1e3:.3f} ms) "
f"+ fp4-gemm {t_gemm * 1e3:.3f} ms = {total * 1e3:.3f} ms "
f"({t_bf16 / max(total, 1e-9):.2f}x net, {t_bf16 / max(t_gemm, 1e-9):.2f}x gemm-only)")
del x, qx
# Verdict from the LARGE-M run (realistic video token counts); small-M numbers
# are launch-overhead dominated by nature and must not drive the conclusion.
t_bf16, t_quant, t_quant_s, t_gemm = results[16384]
total = t_quant + t_gemm
if t_gemm < t_bf16 * 0.8 and total < t_bf16 * 0.8:
lines.append(" verdict: FAST PATH ACTIVE — FP4 GEMMs win at real token counts. "
"If end-to-end speed still looks flat, the input-quant overhead "
f"(~{t_quant * 1e3:.2f} ms/layer-call, largely size-independent) is "
"cancelling the win on SMALL layers: prefer scope 'video blocks' "
"(skips the audio branch, whose matmuls are too small to profit).")
if t_quant_s < t_quant * 0.6:
lines.append(" note: static input scales would reclaim most of the input-quant "
"cost (amax dominates) — calibration support is worth building.")
else:
lines.append(" note: static input scales would NOT help much "
"(overhead is construction/launch, not amax).")
elif t_gemm < t_bf16 * 0.8:
lines.append(" verdict: FP4 GEMM fast but INPUT QUANTIZATION dominates even at "
"large M — overhead-bound; scope down to the largest layers only.")
else:
lines.append(" verdict: FP4 GEMM not faster than bf16 even at M=16384 — the "
"kernel is not engaging; report these numbers upstream.")
except Exception as e: # noqa: BLE001
lines.append(f" GEMM probe failed: {e}")
else:
lines.append(" GEMM probe skipped (no CUDA device)")
print("[FunPack NVFP4] diagnostics:\n" + "\n".join(lines))
return {"nvfp4_layers": n_q, "emulated_layers": n_emul}
class FunPackNVFP4ModelLoader:
"""EXPERIMENTAL: load a diffusion model with on-the-fly NVFP4 quantization.
Drop-in replacement for 'Load Diffusion Model' aimed at LTX-AV on Blackwell
(sm_120+): selected transformer Linears run real FP4 tensor-core GEMMs (~2x fp8
throughput) and weight VRAM drops to ~1/4 of bf16. Audio branch, cross-modal
bridges, first/last blocks, and all non-matmul weights stay at full precision
by default. On GPUs without FP4 compute the model still works via emulation.
"""
CATEGORY = "FunPack/Experimental"
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "load"
DESCRIPTION = __doc__
@classmethod
def INPUT_TYPES(cls):
import folder_paths
return {
"required": {
"unet_name": (folder_paths.get_filename_list("diffusion_models"),
{"tooltip": "Original bf16/fp16 checkpoint - NOT a pre-quantized one."}),
"quantize_scope": (QUANTIZE_SCOPES, {
"default": "video blocks",
"tooltip": "Which Linears go NVFP4. 'video blocks' keeps the whole audio "
"path + cross-modal bridges at full precision (safest). Widen "
"only if quality holds.",
}),
"keep_first_blocks": ("INT", {"default": 2, "min": 0, "max": 16,
"tooltip": "First N transformer blocks stay full precision."}),
"keep_last_blocks": ("INT", {"default": 2, "min": 0, "max": 16,
"tooltip": "Last N transformer blocks stay full precision."}),
"input_scales": (INPUT_SCALE_MODES, {
"default": "dynamic",
"tooltip": "auto-calibrate: first run records each layer's input range "
"(slightly slower) into a per-checkpoint sidecar; every later "
"load injects static input scales, removing the per-call amax "
"cost (~0.2 ms x every quantized layer x every step). Reload "
"the model after the calibration run to activate.",
}),
}
}
def load(self, unet_name, quantize_scope, keep_first_blocks, keep_last_blocks,
input_scales="dynamic"):
import comfy.model_management as mm
import comfy.sd
import comfy.utils
import folder_paths
path = folder_paths.get_full_path_or_raise("diffusion_models", unet_name)
# return_metadata is NOT optional here: LTX-AV variants carry their architecture
# config in the safetensors metadata, and detection without it builds the WRONG
# model (6 vs 9 ada params, connector dims) -> size-mismatch wall on load.
sd, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
if mm.supports_nvfp4_compute(mm.get_torch_device()):
logging.info("[FunPack NVFP4] FP4 tensor-core compute available - native GEMM path.")
else:
logging.warning(
"[FunPack NVFP4] This GPU has no FP4 compute (needs Blackwell, sm_100+). "
"The model will load and run through dequant emulation: correct output, "
"no speedup. Use this only for A/B quality checks."
)
device = mm.get_torch_device() if torch.cuda.is_available() else None
pbar = comfy.utils.ProgressBar(1)
def _tick(done, total):
if total:
pbar.total = total
pbar.update_absolute(done)
sd, targets, keep = quantize_state_dict_nvfp4(
sd, scope=quantize_scope,
keep_first_blocks=keep_first_blocks, keep_last_blocks=keep_last_blocks,
device=device, progress_callback=_tick,
)
if not targets:
raise RuntimeError(
"No layers matched the NVFP4 quantization scope - is this an LTX-AV "
"diffusion model checkpoint? (Expected transformer_blocks.* keys.)"
)
logging.info(
f"[FunPack NVFP4] quantized {len(targets)} Linear layers "
f"(scope='{quantize_scope}', full-precision blocks: {sorted(keep) or 'none'})."
)
calibration = load_calibration(path) if input_scales == "auto-calibrate" else None
if calibration:
injected = inject_input_scales(sd, targets, calibration)
logging.info(
f"[FunPack NVFP4] static input scales ACTIVE on {injected}/{len(targets)} layers "
f"(calibrated {len(calibration)}, margin {CALIBRATION_MARGIN}x) — per-call input "
"amax eliminated. Delete the sidecar under refinements/nvfp4_scales/ to recalibrate."
)
model = comfy.sd.load_diffusion_model_state_dict(sd, metadata=metadata)
if model is None:
raise RuntimeError("ComfyUI could not detect the model type after NVFP4 patching.")
if input_scales == "auto-calibrate" and not calibration:
hooked = install_calibration_capture(model, path)
logging.info(
f"[FunPack NVFP4] CALIBRATING input scales on {hooked} layers this session "
"(slightly slower). Run one full generation, then reload the model (change any "
"widget or restart) — static scales activate on the next load."
)
try:
log_nvfp4_diagnostics(model, device)
except Exception as e: # noqa: BLE001
logging.warning(f"[FunPack NVFP4] diagnostics failed: {e}")
return (model,)