Files
ThunderFun-ComfyUI-INT-Crush/_ops.py
T
2026-07-11 22:48:48 +10:00

1855 lines
84 KiB
Python

"""INT-Crush ops factory — builds custom manual_cast ops for quantized inference.
Returns QuantizedTensor weights for VBAR/memory management, but the
forward() bypasses mixed_precision_ops overhead — going directly from
packed weights to Triton kernels (or PyTorch fallback) without the
per-input QuantizedTensor.from_float / cast_bias_weight round-trip.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Callable
import torch
import torch.nn as nn
import torch.nn.functional as F
from ._quant_utils import rotate_activations
from .quant_layout import IntCrushInt4Layout, IntCrushInt8Layout
from ._orbitquant import (
rpbh_rotate,
rpbh_rotate_fast,
codebook_dequant,
codebook_dequant_fast,
codebook_dequant_int4,
codebook_dequant_int8,
adaln_rtn_dequant,
parse_orbitquant_metadata,
load_orbitquant_globals,
get_quant_scheme_from_metadata,
infer_quant_scheme_from_scale,
infer_orbitquant_metadata_from_state_dict,
# RPBH uniform-grid (non-OrbitQuant) support
parse_rpbh_metadata,
load_rpbh_globals,
infer_rpbh_metadata_from_state_dict,
ORBITQUANT_NORM_EPS,
)
from . import _triton_runtime as _tr
from ._cuda_graph import CudaGraphRunner
_log = logging.getLogger(__name__)
# ── Module-level constants ───────────────────────────────────────────────────
# Layer-name substrings that disable Hadamard rotation (embedding, norm, head,
# projection layers are not rotated — they have isotropic weight distributions).
_NO_ROTATION_NAME_SUBSTRINGS: tuple[str, ...] = (
"embed", "norm", "modulation", "output", "lm_head", "proj_out",
)
# Safetensors state-dict key suffixes used by INT-Crush quantized layers.
# All keys are prefixed by the module path (e.g. "model.layer.weight_scale").
_SD_KEY_WEIGHT = "weight"
_SD_KEY_SCALE = "weight_scale"
_SD_KEY_ZP = "weight_zp"
_SD_KEY_PERM = "weight_perm"
_SD_KEY_SMOOTH = "weight_smooth"
_SD_KEY_SMOOTHROT = "weight_smoothrot_factors"
_SD_KEY_L1 = "weight_L1"
_SD_KEY_L2 = "weight_L2"
_SD_KEY_QUANT = "comfy_quant"
_SD_KEY_BIAS = "bias"
# OrbitQuant: M threshold for fused codebook GEMM vs dequant-then-cuBLAS.
# Set via env: INTCRUSH_OQ_FUSED_THRESHOLD=N (0 disables fused path).
import os as _os_dispatch
_OQ_FUSED_GEMM_M_THRESHOLD: int = int(
_os_dispatch.environ.get("INTCRUSH_OQ_FUSED_THRESHOLD", "512")
)
# Valid float dtypes for weight scales.
_VALID_SCALE_DTYPES = (torch.float16, torch.float32, torch.bfloat16)
# ── OrbitQuant metadata cache ────────────────────────────────────────────────
# Bridges safetensors metadata to _load_from_state_dict, which can't see
# global tensors (RPBH params, codebooks) during per-module loading.
_orbitquant_metadata_cache: dict | None = None
def _set_orbitquant_metadata_cache(metadata: dict | None) -> None:
"""Store safetensors metadata for OrbitQuant detection during loading."""
global _orbitquant_metadata_cache
_orbitquant_metadata_cache = metadata
def _get_orbitquant_metadata_cache() -> dict | None:
"""Retrieve cached OrbitQuant metadata (read-only, cleared by loader)."""
global _orbitquant_metadata_cache
return _orbitquant_metadata_cache
# ── RPBH metadata cache (mirrors OrbitQuant cache) ─────────────────────────
# Bridges safetensors metadata to _load_from_state_dict for RPBH uniform-grid
# models (format version 1/3 + int_crush.rpbh=true).
_rpbh_metadata_cache: dict | None = None
def _set_rpbh_metadata_cache(metadata: dict | None) -> None:
"""Store safetensors metadata for RPBH detection during loading."""
global _rpbh_metadata_cache
_rpbh_metadata_cache = metadata
def _get_rpbh_metadata_cache() -> dict | None:
"""Retrieve cached RPBH metadata (read-only, cleared by loader)."""
global _rpbh_metadata_cache
return _rpbh_metadata_cache
# ── Lazy ComfyUI imports (cached on first call) ─────────────────────────────
_QuantizedTensor = None
_cast_bias_weight = None
_uncast_bias_weight = None
def _get_qt():
global _QuantizedTensor
if _QuantizedTensor is None:
from comfy.quant_ops import QuantizedTensor
_QuantizedTensor = QuantizedTensor
return _QuantizedTensor
def _get_cast():
global _cast_bias_weight, _uncast_bias_weight
if _cast_bias_weight is None:
from comfy.ops import cast_bias_weight, uncast_bias_weight
_cast_bias_weight = cast_bias_weight
_uncast_bias_weight = uncast_bias_weight
return _cast_bias_weight, _uncast_bias_weight
def _move_adapter_weights(adapter: Any, device: torch.device, dtype: torch.dtype) -> None:
"""Move all tensor entries in ``adapter.weights`` to *device*/*dtype*.
Non-tensor entries (floats, None, lists) are left untouched.
"""
moved: list[Any] = []
for w in adapter.weights:
if w is not None and isinstance(w, torch.Tensor):
moved.append(w.to(device=device, dtype=dtype))
else:
moved.append(w)
adapter.weights = type(adapter.weights)(moved)
def _maybe_materialize_lokr(
cache: dict[str, Any],
device: torch.device,
dtype: torch.dtype,
) -> None:
"""Lazily materialize combined w1/w2_t when VRAM allows.
Modifies the cache in place. Only fires when decomposed factors
are present and free VRAM is >= 4x the combined-factor cost.
Decomposed factors are kept as a fallback.
"""
el_sz = 2 if dtype == torch.float16 else 4
try:
free, _ = torch.cuda.mem_get_info(device)
except Exception:
return
if cache.get("w1") is None and cache.get("w1_a") is not None:
w1_a, w1_b = cache["w1_a"], cache["w1_b"]
w1_bytes = w1_a.shape[0] * w1_b.shape[1] * el_sz
if free // 4 >= w1_bytes:
cache["w1"] = (w1_a @ w1_b).contiguous()
if cache.get("w2_t") is None and cache.get("w2_b_t") is not None:
w2_b_t, w2_a_t = cache["w2_b_t"], cache["w2_a_t"]
w2_bytes = w2_b_t.shape[0] * w2_a_t.shape[1] * el_sz
if free // 4 >= w2_bytes:
cache["w2_t"] = (w2_b_t @ w2_a_t).contiguous()
def _maybe_free_lokr_combined(
cache: dict[str, Any],
device: torch.device,
dtype: torch.dtype,
) -> None:
"""Free combined factors under VRAM pressure (if decomposed fallback exists).
Fires when combined factors exceed half of free VRAM. The hysteresis
between materialize (1/4) and free (1/2) prevents thrashing.
"""
el_sz = 2 if dtype == torch.float16 else 4
try:
free, _ = torch.cuda.mem_get_info(device)
except Exception:
return
if cache.get("w1") is not None and cache.get("w1_a") is not None:
if free // 2 < cache["w1"].numel() * el_sz:
cache["w1"] = None
if cache.get("w2_t") is not None and cache.get("w2_b_t") is not None:
if free // 2 < cache["w2_t"].numel() * el_sz:
cache["w2_t"] = None
def _apply_lokr(
cache: dict[str, Any],
x_raw: torch.Tensor,
out: torch.Tensor,
) -> torch.Tensor:
"""In-place LoKr Kronecker accumulation (combined or decomposed factors)."""
scale = cache["scale"]
device = x_raw.device
dtype = x_raw.dtype
N = x_raw.shape[0]
# Hybrid: prefer combined factors (2 matmuls) for speed,
# fall back to decomposed (4 matmuls) under VRAM pressure.
_maybe_free_lokr_combined(cache, device, dtype)
_maybe_materialize_lokr(cache, device, dtype)
w1 = cache.get("w1")
w2_t = cache.get("w2_t")
w1_a = cache.get("w1_a")
w1_b = cache.get("w1_b")
w2_a_t = cache.get("w2_a_t")
w2_b_t = cache.get("w2_b_t")
# Determine dims from whichever form is active.
if w1 is not None:
out_l, in_m = w1.shape
elif w1_a is not None:
out_l = w1_a.shape[0]
in_m = w1_b.shape[1]
else:
raise ValueError("LoKr cache has no w1 factors")
if w2_t is not None:
in_n, out_k = w2_t.shape
elif w2_b_t is not None:
in_n = w2_b_t.shape[0]
out_k = w2_a_t.shape[1]
else:
raise ValueError("LoKr cache has no w2 factors")
# Dynamic chunk sizing (cap 256 MB).
el_sz = 2 if dtype == torch.float16 else 4
bytes_per_token = out_l * (in_n + out_k) * el_sz
try:
free, _ = torch.cuda.mem_get_info(device)
budget = min(free // 4, 256 * 1024 * 1024)
except Exception:
budget = 32 * 1024 * 1024
chunk = max(1, min(N, budget // max(bytes_per_token, 1)))
if not out.is_contiguous():
out = out.contiguous()
out_3d = out.view(N, out_l, out_k)
for s in range(0, N, chunk):
e = min(s + chunk, N)
X = x_raw[s:e].reshape(e - s, in_m, in_n)
# w1 contraction → [C, out_l, in_n]
if w1 is not None:
temp = w1 @ X
else:
temp = w1_a @ (w1_b @ X)
# w2 contraction: out += scale * (temp @ w2^T)
out_slice = out_3d[s:e].reshape(-1, out_k)
if w2_t is not None:
out_slice.addmm_(temp.view(-1, in_n), w2_t,
beta=1.0, alpha=scale)
else:
mid = temp.view(-1, in_n).matmul(w2_b_t)
out_slice.addmm_(mid, w2_a_t, beta=1.0, alpha=scale)
del mid
del temp, X, out_slice
del out_3d
return out
# ── Ops factory ──────────────────────────────────────────────────────────────
_ops_cache: dict[tuple, type] = {}
def make_intcrush_ops(
quant_format: str,
rot_size: int,
use_pytorch: bool = False,
use_w4a16: bool = False,
metadata: dict | None = None,
kernel_backend: str = "auto",
mp_plan: dict | None = None,
) -> type:
"""Build a manual_cast ops class with lean INT-Crush forward.
Args:
quant_format: "int4_crush", "int8_crush", or "orbitquant"
rot_size: Hadamard rotation group size
use_pytorch: force PyTorch fallback (no Triton kernels)
use_w4a16: disable W4A8 path, use W4A16 unpack+GEMM instead
metadata: safetensors file metadata (used for OrbitQuant detection)
kernel_backend: "auto" (CUDA→Triton→PyTorch), "triton", "cuda", or "pytorch"
mp_plan: per-layer mixed precision plan {name: {"w": 4|8, "a": 4|8}} or None
"""
# Use a hash of the orbitquant_dims as cache key differentiator
_oq_key = metadata.get("int_crush.orbitquant_dims", "") if metadata else ""
_mp_key = json.dumps(mp_plan, sort_keys=True) if mp_plan else ""
cache_key = (quant_format, rot_size, use_pytorch, use_w4a16,
_oq_key, kernel_backend, _mp_key)
if cache_key in _ops_cache:
return _ops_cache[cache_key]
from comfy.ops import manual_cast
class IntCrushOps(manual_cast):
_orbitquant_metadata: dict | None = metadata # class-level attribute
_orbitquant_model_path: str = "" # set during first _load_from_state_dict
_rpbh_metadata: dict | None = None # class-level RPBH metadata cache
_mp_plan: dict | None = mp_plan # mixed precision plan (per-layer W/A bit-widths)
class Linear(manual_cast.Linear):
"""INT-Crush Linear: QuantizedTensor storage with lean forward()."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._intcrush_is_quantized: bool = False
self._intcrush_rot_need: bool = True
self._intcrush_rot_size: int = rot_size
self._intcrush_perm: torch.Tensor | None = None
self._intcrush_smooth: torch.Tensor | None = None
self._intcrush_smoothrot_factors: torch.Tensor | None = None
self._intcrush_L1: torch.Tensor | None = None
self._intcrush_L2: torch.Tensor | None = None
self._intcrush_use_pytorch: bool = use_pytorch
self._intcrush_use_w4a16: bool = use_w4a16
self._intcrush_kernel_backend: str = kernel_backend
self._intcrush_w_in: int | None = None
self.quant_format: str | None = None
self.layout_type: str | None = None
# ── INT-Crush adapter (LoRA / LoKr / LoHa / OFT / BOFT) ──
self._intcrush_adapter: Any | None = None
self._intcrush_lora_strength: float | None = None
self._intcrush_adapter_ready: bool = False
self._intcrush_adapter_device: torch.device | None = None
self._intcrush_adapter_dtype: torch.dtype | None = None
self._intcrush_lokr_cache: dict | None = None
self._intcrush_lokr_cache_adapter_id: int | None = None
self._intcrush_lokr_cache_strength: float | None = None
self._intcrush_lokr_cache_device: torch.device | None = None
self._intcrush_lokr_cache_dtype: torch.dtype | None = None
# ── OrbitQuant state ──
self._intcrush_is_orbitquant: bool = False
self._intcrush_oq_perm: torch.Tensor | None = None # RPBH permutation
self._intcrush_oq_signs: torch.Tensor | None = None # RPBH signs
self._intcrush_oq_h: int | None = None # Hadamard block size
self._intcrush_oq_codebook: torch.Tensor | None = None # Lloyd-Max centroids [2^b]
self._intcrush_oq_bits: int = 4 # Weight bit-width
self._intcrush_oq_d: int | None = None # Original input dim
self._intcrush_oq_d_padded: int | None = None # Padded input dim
self._intcrush_oq_quant_scheme: str | None = None # "orbitquant_codebook" or "adaln_rtn"
self._intcrush_oq_codebook_dev: torch.Tensor | None = None # codebook on GPU (lazy, one-time)
# ── RPBH uniform-grid state (non-OrbitQuant RPBH) ──
self._intcrush_is_rpbh: bool = False
self._intcrush_rpbh_perm: torch.Tensor | None = None # RPBH permutation
self._intcrush_rpbh_signs: torch.Tensor | None = None # RPBH signs
self._intcrush_rpbh_h: int | None = None # Hadamard block size
self._intcrush_rpbh_d: int | None = None # Original input dim
self._intcrush_rpbh_d_padded: int | None = None # Padded input dim
# ── Mixed precision state ──
self._intcrush_mp_weight_bits: int | None = None
self._intcrush_mp_act_bits: int | None = None
def _apply_adapter(self, x_raw: torch.Tensor, out: torch.Tensor) -> torch.Tensor:
"""Apply adapter residual.
Supports all ComfyUI adapter types (LoRA, LoKr, LoHa,
OFT, BOFT, GLoRA).
Standard LoRA uses the memory-efficient ``addmm_`` path
(only a ``[N, rank]`` intermediate). LoKr uses a custom
in-place path that accumulates directly into *out*
without materialising the full delta, keeping the
decomposed factors resident. All other adapter types go
through the adapter's own ``h()``/``g()`` methods.
Args:
x_raw: 2-D input ``[N, in_features]`` (unrotated).
out: 2-D output ``[N, out_features]`` from quantised
GEMM (+ optional SVD residual).
"""
adapter = self._intcrush_adapter
if adapter is None:
return out
# Don't drop _graph_runner — the graph only captures the GEMM.
device = x_raw.device
dtype = x_raw.dtype
# Move adapter weights to device/dtype on first use or relocation.
if (not self._intcrush_adapter_ready
or self._intcrush_adapter_device != device
or self._intcrush_adapter_dtype != dtype):
_move_adapter_weights(adapter, device, dtype)
self._intcrush_adapter_ready = True
self._intcrush_adapter_device = device
self._intcrush_adapter_dtype = dtype
# Drop stale LoKr cache on device/dtype change.
self._intcrush_lokr_cache = None
self._intcrush_lokr_cache_adapter_id = None
self._intcrush_lokr_cache_strength = None
self._intcrush_lokr_cache_device = None
self._intcrush_lokr_cache_dtype = None
strength = self._intcrush_lora_strength
# ── Standard LoRA: fast in-place addmm_ path ──────────
# Only allocates [N, rank] — never materialises the full
# [N, out_features] delta. This is critical for VRAM.
if adapter.name == "lora":
v = adapter.weights
up, down, alpha = v[0], v[1], v[2]
rank = down.shape[0]
scale = ((float(alpha) / rank) if alpha else 1.0)
scale *= strength
mid = F.linear(x_raw, down) # [N, rank]
out.addmm_(mid, up.t(), beta=1, alpha=scale)
return out
# ── LoKr: in-place Kronecker accumulation ─────────────
# Rebuild cache when adapter, strength, device, or dtype changes.
if adapter.name == "lokr":
adapter_changed = (
self._intcrush_lokr_cache is None
or id(adapter) != self._intcrush_lokr_cache_adapter_id
or strength != self._intcrush_lokr_cache_strength
or self._intcrush_lokr_cache_device != device
or self._intcrush_lokr_cache_dtype != dtype
)
if adapter_changed:
self._intcrush_lokr_cache = self._setup_lokr_cache(
adapter, device, dtype, strength)
self._intcrush_lokr_cache_adapter_id = id(adapter)
self._intcrush_lokr_cache_strength = strength
self._intcrush_lokr_cache_device = device
self._intcrush_lokr_cache_dtype = dtype
return _apply_lokr(self._intcrush_lokr_cache, x_raw, out)
# ── All other adapters: h() + g() (in-place add) ──────
adapter.multiplier = strength
# h() returns the additive residual (zeros for OFT/BOFT).
delta = adapter.h(x_raw, out)
out.add_(delta.to(out.dtype))
# g() may return a new tensor (OFT/BOFT rotation).
return adapter.g(out)
def _setup_lokr_cache(self, adapter: Any, device: torch.device,
dtype: torch.dtype, strength: float) -> dict:
"""Build cached LoKr factors — kept decomposed to avoid OOM."""
v = adapter.weights
w1, w2 = v[0], v[1]
alpha = v[2]
w1_a, w1_b = v[3], v[4]
w2_a, w2_b = v[5], v[6]
t2 = v[7] if len(v) > 7 else None
cache: dict[str, Any] = {}
# ── w1: combined [out_l, in_m] or decomposed ───────────
if w1 is not None:
cache["w1"] = w1.to(device=device, dtype=dtype)
cache["w1_a"] = None
cache["w1_b"] = None
elif w1_a is not None and w1_b is not None:
cache["w1"] = None
cache["w1_a"] = w1_a.to(device=device, dtype=dtype) # [out_l, rank]
cache["w1_b"] = w1_b.to(device=device, dtype=dtype) # [rank, in_m]
else:
raise ValueError("LoKr adapter has neither w1 nor (w1_a, w1_b)")
# ── w2: combined [out_k, in_n], tensor- or matrix-decomp ─
if w2 is not None:
w2_d = w2.to(device=device, dtype=dtype)
cache["w2_t"] = w2_d.t().contiguous() # [in_n, out_k]
cache["w2_a_t"] = None
cache["w2_b_t"] = None
del w2_d
elif t2 is not None:
# 4-D tensor decomposition — flatten to 2-D before .t().
t2_d = t2.to(device=device, dtype=dtype)
w2a_d = w2_a.to(device=device, dtype=dtype)
w2b_d = w2_b.to(device=device, dtype=dtype)
w2_full = torch.einsum("i j k l, j r, i p -> p r k l",
t2_d, w2b_d, w2a_d)
w2_full = w2_full.reshape(
w2_full.shape[0] * w2_full.shape[1],
w2_full.shape[2] * w2_full.shape[3]).contiguous() # [out_k, in_n]
cache["w2_t"] = w2_full.t().contiguous() # [in_n, out_k]
cache["w2_a_t"] = None
cache["w2_b_t"] = None
del t2_d, w2a_d, w2b_d, w2_full
elif w2_a is not None and w2_b is not None:
cache["w2_t"] = None
cache["w2_b_t"] = w2_b.to(device=device, dtype=dtype).t().contiguous()
cache["w2_a_t"] = w2_a.to(device=device, dtype=dtype).t().contiguous()
else:
raise ValueError("LoKr adapter has neither w2 nor (w2_a, w2_b)")
# ── scale ──────────────────────────────────────────────
if w1_a is not None:
rank = w1_b.shape[0]
elif w2_a is not None:
rank = w2_b.shape[0]
else:
rank = None
if rank is not None and alpha is not None:
cache["scale"] = (float(alpha) / rank) * strength
else:
cache["scale"] = strength
return cache
def _prep_activation(self, x: torch.Tensor, pad_to_w_in: bool = True, apply_perm: bool = True) -> torch.Tensor:
"""Common activation prep: reshape → SmoothRot → (RPBH|Hadamard) → pad → permute.
Args:
x: raw input tensor [..., in_features]
pad_to_w_in: pad feature dim to match packed weight width
apply_perm: apply PermuQuant channel permutation
"""
x_2d = x.reshape(-1, x.shape[-1])
# SmoothRot: 1/s BEFORE rotation (both RPBH and Hadamard).
smoothrot_factors = self._intcrush_smoothrot_factors
if smoothrot_factors is not None:
if smoothrot_factors.device != x_2d.device:
smoothrot_factors = smoothrot_factors.to(
device=x_2d.device, dtype=x_2d.dtype)
self._intcrush_smoothrot_factors = smoothrot_factors
x_2d = x_2d / smoothrot_factors
# ── RPBH rotation (uniform-grid path) ──
# Takes precedence over group Hadamard. RPBH pads internally
# to d_padded via rpbh_rotate.
if self._intcrush_is_rpbh:
perm = self._intcrush_rpbh_perm
signs = self._intcrush_rpbh_signs
if perm is not None and perm.device != x_2d.device:
perm = perm.to(device=x_2d.device)
self._intcrush_rpbh_perm = perm
if signs is not None and signs.device != x_2d.device:
signs = signs.to(device=x_2d.device)
self._intcrush_rpbh_signs = signs
if self._intcrush_use_pytorch:
x_2d = rpbh_rotate(x_2d, perm, signs, self._intcrush_rpbh_h)
else:
x_2d = rpbh_rotate_fast(x_2d, perm, signs, self._intcrush_rpbh_h)
elif self._intcrush_rot_need:
x_2d = rotate_activations(x_2d, self._intcrush_rot_size)
if pad_to_w_in:
w_in = self._intcrush_w_in
if x_2d.shape[-1] < w_in:
x_2d = F.pad(x_2d, (0, w_in - x_2d.shape[-1]))
if apply_perm:
perm = self._intcrush_perm
if perm is not None:
x_2d = x_2d[..., perm]
return x_2d
def _apply_residuals(self, x: torch.Tensor, out: torch.Tensor,
x_prep: torch.Tensor | None = None) -> torch.Tensor:
"""Apply SVD low-rank residual and standard adapter residual."""
svd_L1 = self._intcrush_L1
svd_L2 = self._intcrush_L2
if svd_L1 is not None and svd_L2 is not None:
# Rebind to self after device/dtype move so subsequent
# forwards reuse the cached tensor (avoids re-allocation
# when the model has been relocated to a different device).
if svd_L1.device != x.device or svd_L1.dtype != x.dtype:
svd_L1 = svd_L1.to(device=x.device, dtype=x.dtype)
self._intcrush_L1 = svd_L1
if svd_L2.device != x.device or svd_L2.dtype != x.dtype:
svd_L2 = svd_L2.to(device=x.device, dtype=x.dtype)
self._intcrush_L2 = svd_L2
svd_x = x_prep if x_prep is not None else x.reshape(-1, x.shape[-1])
mid = svd_x.to(svd_L1.dtype) @ svd_L2.T
out.addmm_(mid, svd_L1.T.to(out.dtype), beta=1, alpha=1)
# _apply_adapter may return a new tensor — use the return value.
out = self._apply_adapter(x.reshape(-1, x.shape[-1]), out)
return out
def _finish_forward(self, out: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
"""Convert dtype and reshape to original input shape.
Note: uncast (VBAR unpin) is handled by forward()'s
try/finally — not here — so that the pin is always
released even when _apply_residuals raises.
"""
out = out.to(x.dtype)
return out.reshape(*x.shape[:-1], -1)
# ── forward-path helpers ──────────────────────────────────────────
def _setup_casting(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor | None, Callable[..., None] | None, type]:
"""Resolve weight/bias/uncast for the forward pass.
Handles VBAR casting, low-VRAM offload, and the fast path
where no casting is needed. Returns (weight, bias, uncast, QT).
"""
QT = _get_qt()
wfn = self.weight_function
bfn = self.bias_function
need_cast = (
self.comfy_cast_weights
or wfn or bfn
or getattr(self, "weight_lowvram_function", None) is not None
or getattr(self, "bias_lowvram_function", None) is not None
)
if need_cast:
weight_dtype = (
self.weight._params.orig_dtype
if isinstance(self.weight, QT) else x.dtype
)
_cbw, _ucbw = _get_cast()
weight, bias, offload_stream = _cbw(
self, input=None, dtype=weight_dtype,
device=x.device, bias_dtype=x.dtype, offloadable=True,
)
uncast = lambda: _ucbw(self, weight, bias, offload_stream)
else:
weight = self.weight
bias = self.bias
# One-time move: if weight is still on CPU (deferred
# from state-dict load), bring it to device now.
# QuantizedTensor.to() moves _qdata, scale, perm, zp
# atomically via __torch_dispatch__ → _handle_to.
if weight.device != x.device:
weight = weight.to(device=x.device)
self.weight = nn.Parameter(weight, requires_grad=False)
uncast = None
return weight, bias, uncast, QT
def _forward_predequant(self, weight: torch.Tensor, bias: torch.Tensor | None,
x: torch.Tensor) -> torch.Tensor:
"""Forward when weight is already dequantized (e.g. wrapper patch ran)."""
x_2d = self._prep_activation(x, pad_to_w_in=False, apply_perm=False)
out = F.linear(x_2d, weight, bias)
out = self._apply_residuals(x, out, x_prep=x_2d)
return self._finish_forward(out, x)
# ── OrbitQuant forward paths ──
def _rpbh_rotate(self, x_2d: torch.Tensor) -> torch.Tensor:
"""RPBH-rotate an activation, honoring use_pytorch + device laziness."""
device = x_2d.device
perm = self._intcrush_oq_perm
signs = self._intcrush_oq_signs
if perm is not None and perm.device != device:
perm = perm.to(device=device)
self._intcrush_oq_perm = perm
if signs is not None and signs.device != device:
signs = signs.to(device=device)
self._intcrush_oq_signs = signs
if self._intcrush_use_pytorch:
return rpbh_rotate(x_2d, perm, signs, self._intcrush_oq_h)
return rpbh_rotate_fast(x_2d, perm, signs, self._intcrush_oq_h)
def _dequant_codebook(self, qdata: torch.Tensor, weight: Any) -> torch.Tensor:
"""Dequantize a codebook weight to float."""
device = qdata.device
cb = self._intcrush_oq_codebook
if cb is not None and cb.device != device:
cb = cb.to(device=device)
self._intcrush_oq_codebook = cb
d_padded = self._intcrush_oq_d_padded
if self._intcrush_use_pytorch:
return codebook_dequant(
qdata, weight._params.scale, cb,
self._intcrush_oq_d, d_padded, self._intcrush_oq_bits,
)
return codebook_dequant_fast(
qdata, weight._params.scale, cb,
self._intcrush_oq_d, d_padded, self._intcrush_oq_bits,
)
def _oom_safe_matmul(
self,
x: torch.Tensor,
weight: torch.Tensor,
bias: torch.Tensor | None,
) -> torch.Tensor:
# Memory-aware matmul: out = x @ weight.T (+ bias).
M, K = x.shape
N = weight.shape[0]
device = x.device
dtype = x.dtype
# Defensive dtype match (callers already pre-cast).
if weight.dtype != dtype:
weight = weight.to(dtype)
out_bytes = M * N * x.element_size()
try:
free, _ = torch.cuda.mem_get_info(device)
except Exception:
free = 0
# Only pay empty_cache() when we genuinely lack room for the
# output (plus a 2x slack for the matmul workspace).
if free < out_bytes * 2:
try:
torch.cuda.empty_cache()
free, _ = torch.cuda.mem_get_info(device)
except Exception:
free = 0
# Pre-allocate the output once (the dominant allocation).
out = torch.empty((M, N), dtype=dtype, device=device)
# Chunk over M to bound the cuBLAS workspace.
chunk = M
if free > 0:
bytes_per_row = (K + N) * x.element_size()
denom = max(bytes_per_row * 4, 1)
chunk = max(1, min(M, free // denom))
w_t = weight.t() # view [K, N]
for s in range(0, M, chunk):
e = min(s + chunk, M)
torch.mm(x[s:e], w_t, out=out[s:e])
if bias is not None:
out.add_(bias.to(dtype))
return out
def _forward_orbitquant(
self,
qdata: torch.Tensor,
weight: Any,
bias: torch.Tensor | None,
x: torch.Tensor,
) -> torch.Tensor:
"""OrbitQuant forward: RPBH rotate → GEMM.
Codebook layers: fused codebook GEMM or dequant-then-cuBLAS.
AdaLN layers: NO rotation — weights are stored UNROTATED.
"""
x_2d = x.reshape(-1, x.shape[-1])
scheme = self._intcrush_oq_quant_scheme
if scheme == "adaln_rtn":
# AdaLN weights are UNROTATED — do NOT rotate x.
w_float = adaln_rtn_dequant(
qdata,
weight._params.scale,
self._intcrush_oq_d,
self._intcrush_oq_bits,
)
# Cast to the compute dtype BEFORE the matmul so the fp32
# dequant tensor is freed (refcount drops to 0) rather than
# coexisting with an fp16 cast copy during the GEMM.
if w_float.dtype != x_2d.dtype:
w_float = w_float.to(x_2d.dtype)
out = self._oom_safe_matmul(x_2d, w_float, bias)
del w_float
else:
x_rot = self._rpbh_rotate(x_2d)
M = x_rot.shape[0]
# Fused codebook GEMM for small M, dequant-then-cuBLAS for large M.
_use_fused = (
_tr.TRITON_CODEBOOK_GEMM
and not self._intcrush_use_pytorch
and M <= _OQ_FUSED_GEMM_M_THRESHOLD
and self._intcrush_oq_quant_scheme == "orbitquant_codebook"
)
if _use_fused:
# Lazy one-time device move for codebook.
dev = x_rot.device
oq_cb = self._intcrush_oq_codebook_dev
if oq_cb is None:
oq_cb = self._intcrush_oq_codebook.to(device=dev)
self._intcrush_oq_codebook_dev = oq_cb
# Norms come from Params which may be on CPU.
oq_norms = weight._params.scale.to(device=dev)
out = _tr.fused_codebook_gemm_dequant(
x_rot, qdata, oq_norms, oq_cb,
bias=bias, bits=self._intcrush_oq_bits,
)
else:
w_float = self._dequant_codebook(qdata, weight)
if w_float.dtype != x_rot.dtype:
w_float = w_float.to(x_rot.dtype)
out = self._oom_safe_matmul(x_rot, w_float, bias)
# Apply adapter residuals (LoRA, etc.) on raw activation x.
out = self._apply_residuals(x, out, x_prep=None)
return self._finish_forward(out, x)
def _forward_predequant_oq(
self,
weight: torch.Tensor,
bias: torch.Tensor | None,
x: torch.Tensor,
) -> torch.Tensor:
"""OrbitQuant forward when weight is already dequantized to float.
Codebook layers: rotate activation for cancellation.
AdaLN layers: weight is UNROTATED — do NOT rotate.
"""
x_2d = x.reshape(-1, x.shape[-1])
scheme = self._intcrush_oq_quant_scheme
if scheme == "adaln_rtn":
out = self._oom_safe_matmul(x_2d, weight, bias)
else:
x_rot = self._rpbh_rotate(x_2d)
out = self._oom_safe_matmul(x_rot, weight, bias)
out = self._apply_residuals(x, out, x_prep=None)
return self._finish_forward(out, x)
def _forward_int4_w4a8(self, qdata: torch.Tensor, weight: Any,
bias: torch.Tensor | None, x: torch.Tensor) -> torch.Tensor:
"""INT4 Path 1: W4A8 — unpack INT4→INT8, dynamic-quantize activations,
fused INT8 GEMM + dequant (fastest when all Triton kernels available)."""
x_2d = self._prep_activation(x)
x_int8, s_a = _tr.dynamic_quantize_activation(x_2d)
needs_zp = weight._params.zp is not None
x_2d_sum = x_2d.sum(dim=-1, keepdim=True) if needs_zp else None
w_int8 = _tr.unpack_int4_to_int8(qdata, self._intcrush_w_in)
scale_flat = weight._params.scale.reshape(-1).float().contiguous()
_gemm_fn = _tr.fused_w4a8_gemm_dequant if _tr.TRITON_W4A8_GEMM else _tr.fused_int8_gemm_dequant
out = _gemm_fn(
x_int8, w_int8, scale_flat, s_a,
bias=bias, out_dtype=x.dtype,
)
del x_int8, w_int8
if needs_zp:
zp_cor = (scale_flat * weight._params.zp.reshape(-1).float()).to(out.dtype)
out.addmm_(x_2d_sum.to(out.dtype), zp_cor.unsqueeze(0), beta=1, alpha=-1)
out = self._apply_residuals(x, out, x_prep=x_2d)
return self._finish_forward(out, x)
def _forward_int4_w4a16(self, qdata: torch.Tensor, weight: Any,
bias: torch.Tensor | None, x: torch.Tensor) -> torch.Tensor:
"""INT4 Path 2: W4A16 — Triton unpack to float16, then cuBLAS GEMM."""
x_2d = self._prep_activation(x)
scale_flat = weight._params.scale.reshape(-1).float().contiguous()
needs_zp = weight._params.zp is not None
x_2d_sum = x_2d.sum(dim=-1, keepdim=True) if needs_zp else None
weight_f16 = _tr.unpack_int4_to_float16(qdata, scale_flat, self._intcrush_w_in)
# Cast activation to match weight dtype (handles bfloat16 input with float16 weights)
out = F.linear(x_2d.to(weight_f16.dtype), weight_f16)
del weight_f16
if needs_zp:
zp_cor = (scale_flat * weight._params.zp.reshape(-1).float()).to(out.dtype)
out.addmm_(x_2d_sum.to(out.dtype), zp_cor.unsqueeze(0), beta=1, alpha=-1)
out = self._apply_residuals(x, out, x_prep=x_2d)
return self._finish_forward(out, x)
def _forward_int4_pytorch(self, qdata: torch.Tensor, weight: Any,
x: torch.Tensor) -> torch.Tensor:
"""INT4 Path 3: PyTorch fallback — full dequant to float, then F.linear."""
w_float = IntCrushInt4Layout.dequantize(qdata, weight._params).to(x.device, x.dtype)
x_2d = self._prep_activation(x, pad_to_w_in=True)
out = F.linear(x_2d, w_float)
del w_float
out = self._apply_residuals(x, out, x_prep=x_2d)
return self._finish_forward(out, x)
def _forward_int8(self, qdata: torch.Tensor, weight: Any,
bias: torch.Tensor | None, x: torch.Tensor) -> torch.Tensor:
"""INT8 forward: small-batch PyTorch fallback or Triton two-kernel path."""
w_scale = weight._params.scale
zp = weight._params.zp
# Determine M and compute_dtype from raw x (before prep).
# prep_activation is a view-based reshape + CUDA ops, so M is
# the same before and after prep.
M = x.numel() // x.shape[-1]
compute_dtype = (x.dtype if x.dtype in (torch.float16, torch.bfloat16)
else torch.bfloat16)
# ── CUDA Graph path ──
# Requirements: Triton available, no zero-point,
# no VBAR/casting (bias must be static), graphs enabled.
_can_graph = (
_tr.TRITON_AVAILABLE
and zp is None
and not self._intcrush_is_rpbh
and not (self.comfy_cast_weights or self.weight_function
or self.bias_function or self.weight_lowvram_function
or self.bias_lowvram_function)
and _tr.USE_CUDA_GRAPHS
)
if _can_graph:
if not hasattr(self, '_graph_runner') or self._graph_runner is None:
self._graph_runner = CudaGraphRunner(self, safe_no_clone=False)
out = self._graph_runner.run(x, bias, compute_dtype)
# _apply_residuals uses the raw x (not the prepped x_2d),
# which is what the current code already does.
out = self._apply_residuals(x, out)
return self._finish_forward(out, x)
# ── Original paths (prep happens here) ──
x_2d = self._prep_activation(x)
# Old SmoothQuant: 1/s AFTER Hadamard (only for non-SmoothRot layers).
if self._intcrush_smoothrot_factors is None:
smooth = self._intcrush_smooth
if smooth is not None:
if smooth.device != x_2d.device:
smooth = smooth.to(device=x_2d.device, dtype=x_2d.dtype)
self._intcrush_smooth = smooth
x_2d = x_2d / smooth
batch = x_2d.shape[0]
# No Triton: dequant weights to float and use F.linear.
if not _tr.TRITON_AVAILABLE:
w_scale_2d = w_scale.reshape(-1, 1) if w_scale.ndim == 1 else w_scale
w_float = qdata.to(compute_dtype) * w_scale_2d.to(compute_dtype)
if zp is not None:
w_float = w_float - (zp.reshape(-1, 1).to(compute_dtype)
* w_scale_2d.to(compute_dtype))
out = F.linear(x_2d, w_float, bias)
del x_2d, w_float
elif _tr.USE_FUSED_W8A8 and _tr.TRITON_FUSED_QGD:
# Fused single-kernel: quantize fp16→int8 + GEMM + dequant.
needs_zp = zp is not None
x_2d_sum = x_2d.sum(dim=-1, keepdim=True) if needs_zp else None
out = _tr.fused_quant_int8_gemm_dequant(
x_2d, qdata, w_scale,
bias=bias, out_dtype=compute_dtype,
)
del x_2d
if needs_zp:
scale_flat = w_scale.reshape(-1).float().contiguous()
zp_cor = (scale_flat * zp.reshape(-1).float()).to(out.dtype)
out.addmm_(x_2d_sum.to(out.dtype), zp_cor.unsqueeze(0), beta=1, alpha=-1)
else:
# Two-kernel path (quantize → GEMM) is always faster than
# the fused quant+GEMM kernel for batch > 16: the fused kernel
# reads fp16 twice (abs-max pass + quantize pass), while this
# path reads fp16 once, then int8 on the GEMM (half bandwidth).
needs_zp = zp is not None
x_2d_sum = x_2d.sum(dim=-1, keepdim=True) if needs_zp else None
x_int8, s_a = _tr.dynamic_quantize_activation(x_2d)
del x_2d
out = _tr.fused_int8_gemm_dequant(
x_int8, qdata, w_scale, s_a,
bias=bias, out_dtype=compute_dtype,
)
del x_int8
if needs_zp:
scale_flat = w_scale.reshape(-1).float().contiguous()
zp_cor = (scale_flat * zp.reshape(-1).float()).to(out.dtype)
out.addmm_(x_2d_sum.to(out.dtype), zp_cor.unsqueeze(0), beta=1, alpha=-1)
out = self._apply_residuals(x, out)
return self._finish_forward(out, x)
# ── comfy-kitchen CUDA forward paths ────────────────────────────
def _resolve_ck_backend(self) -> str:
"""Resolve 'auto' kernel_backend to a concrete backend name.
Returns 'cuda', 'triton', or 'pytorch'.
"""
backend = self._intcrush_kernel_backend
if backend == "pytorch":
return "pytorch"
if backend == "cuda":
return "cuda" if _tr.can_use_cuda_int8() else "pytorch"
if backend == "triton":
return "triton" if _tr.TRITON_AVAILABLE else "pytorch"
# auto: prefer CUDA → Triton → PyTorch
if _tr.can_use_cuda_int8():
return "cuda"
if _tr.TRITON_AVAILABLE:
return "triton"
return "pytorch"
def _resolve_ck_backend_int4(self) -> str:
"""Resolve 'auto' kernel_backend for INT4 (needs both unpack + GEMM).
Returns 'cuda', 'triton', or 'pytorch'.
"""
backend = self._intcrush_kernel_backend
if backend == "pytorch":
return "pytorch"
if backend == "cuda":
return "cuda" if _tr.can_use_cuda_int4() else "pytorch"
if backend == "triton":
return "triton" if _tr.TRITON_AVAILABLE else "pytorch"
# auto
if _tr.can_use_cuda_int4():
return "cuda"
if _tr.TRITON_AVAILABLE:
return "triton"
return "pytorch"
def _ck_prep_smoothrot(self, x_2d: torch.Tensor) -> torch.Tensor:
"""Apply SmoothRot factors (1/s BEFORE rotation) in-place if present.
Returns x_2d (possibly modified). Safe to call on any device.
"""
smoothrot = self._intcrush_smoothrot_factors
if smoothrot is not None:
if smoothrot.device != x_2d.device:
smoothrot = smoothrot.to(device=x_2d.device, dtype=x_2d.dtype)
self._intcrush_smoothrot_factors = smoothrot
x_2d = x_2d / smoothrot
return x_2d
def _ck_prep_pad(self, x_2d: torch.Tensor) -> torch.Tensor:
"""Pad feature dim to match packed weight width."""
w_in = self._intcrush_w_in
if w_in is not None and x_2d.shape[-1] < w_in:
x_2d = F.pad(x_2d, (0, w_in - x_2d.shape[-1]))
return x_2d
def _forward_int4_ck(
self,
qdata: torch.Tensor,
weight: Any,
bias: torch.Tensor | None,
x: torch.Tensor,
) -> torch.Tensor:
"""INT4 forward via comfy-kitchen CUDA kernels.
Two sub-paths selected at runtime:
* **Fused ConvRot** (rot_size=256, no perm/RPBH/smooth):
SmoothRot → pad → ``int8_linear(convrot=True)`` — the Hadamard
rotation, per-row activation quantization, and INT8 GEMM all
happen inside a single CUDA kernel.
* **Separate rotation** (all other cases): ``_prep_activation``
handles SmoothRot + rotation + pad + permute, then unpack
INT4→INT8, then ``int8_linear(convrot=False)``.
"""
from comfy_kitchen.backends.cuda import (
int8_linear as ck_int8_linear,
prepare_int4_weight_for_int8_linear as ck_unpack_int4,
)
scale_flat = weight._params.scale.reshape(-1).float().contiguous()
zp = weight._params.zp
has_perm = self._intcrush_perm is not None
has_rpbh = self._intcrush_is_rpbh
has_smooth = self._intcrush_smooth is not None
use_fused = _tr.can_use_fused_convrot(
self._intcrush_rot_size, has_perm, has_rpbh, has_smooth,
)
if use_fused:
# ── Fused ConvRot: rotation + quantize + GEMM in one call ──
x_2d = x.reshape(-1, x.shape[-1])
x_2d = self._ck_prep_smoothrot(x_2d)
x_2d = self._ck_prep_pad(x_2d)
# Unpack INT4 → INT8 (CUDA kernel). Cast to int8 for
# dtype safety — comfy-kitchen stores packed int4 as int8.
w_int8 = ck_unpack_int4(qdata.to(torch.int8).contiguous())
out = ck_int8_linear(
x_2d.contiguous(),
w_int8,
scale_flat,
bias=bias,
out_dtype=x.dtype,
convrot=True,
convrot_groupsize=256,
)
else:
# ── Separate rotation path ──
x_2d = self._prep_activation(x)
w_int8 = ck_unpack_int4(qdata.to(torch.int8).contiguous())
out = ck_int8_linear(
x_2d.contiguous(),
w_int8,
scale_flat,
bias=bias,
out_dtype=x.dtype,
convrot=False,
)
# Zero-point correction (asymmetric quantization)
if zp is not None:
x_2d_sum = x_2d.sum(dim=-1, keepdim=True)
zp_cor = (scale_flat * zp.reshape(-1).float()).to(out.dtype)
out.addmm_(x_2d_sum.to(out.dtype), zp_cor.unsqueeze(0),
beta=1, alpha=-1)
out = self._apply_residuals(x, out, x_prep=x_2d)
return self._finish_forward(out, x)
def _forward_int8_ck(
self,
qdata: torch.Tensor,
weight: Any,
bias: torch.Tensor | None,
x: torch.Tensor,
) -> torch.Tensor:
"""INT8 forward via comfy-kitchen CUDA kernels.
Same two sub-paths as the INT4 CUDA forward (fused ConvRot vs
separate rotation), plus SmoothQuant handling for non-SmoothRot
layers in the separate-rotation path.
"""
from comfy_kitchen.backends.cuda import int8_linear as ck_int8_linear
w_scale = weight._params.scale.float().contiguous()
zp = weight._params.zp
has_perm = self._intcrush_perm is not None
has_rpbh = self._intcrush_is_rpbh
has_smooth = self._intcrush_smooth is not None
use_fused = _tr.can_use_fused_convrot(
self._intcrush_rot_size, has_perm, has_rpbh, has_smooth,
)
if use_fused:
# ── Fused ConvRot ──
x_2d = x.reshape(-1, x.shape[-1])
x_2d = self._ck_prep_smoothrot(x_2d)
x_2d = self._ck_prep_pad(x_2d)
out = ck_int8_linear(
x_2d.contiguous(),
qdata.contiguous(),
w_scale,
bias=bias,
out_dtype=x.dtype,
convrot=True,
convrot_groupsize=256,
)
else:
# ── Separate rotation ──
x_2d = self._prep_activation(x)
# SmoothQuant: 1/s AFTER Hadamard (non-SmoothRot layers only)
if self._intcrush_smoothrot_factors is None:
smooth = self._intcrush_smooth
if smooth is not None:
if smooth.device != x_2d.device:
smooth = smooth.to(device=x_2d.device,
dtype=x_2d.dtype)
self._intcrush_smooth = smooth
x_2d = x_2d / smooth
out = ck_int8_linear(
x_2d.contiguous(),
qdata.contiguous(),
w_scale,
bias=bias,
out_dtype=x.dtype,
convrot=False,
)
# Zero-point correction
if zp is not None:
x_2d_sum = x_2d.sum(dim=-1, keepdim=True)
scale_flat = w_scale.reshape(-1).float().contiguous()
zp_cor = (scale_flat * zp.reshape(-1).float()).to(out.dtype)
out.addmm_(x_2d_sum.to(out.dtype), zp_cor.unsqueeze(0),
beta=1, alpha=-1)
out = self._apply_residuals(x, out)
return self._finish_forward(out, x)
# ── state-dict loading helpers ────────────────────────────────────
@staticmethod
def _detect_weight_format(weight: torch.Tensor | None, scale: torch.Tensor | None) -> str | None:
"""Detect INT-Crush format by weight/scale dtype signature.
Returns "int4_crush" / "int8_crush" / None.
"""
if weight is None or scale is None:
return None
if scale.dtype not in _VALID_SCALE_DTYPES:
return None
if weight.dtype == torch.uint8:
return "int4_crush"
if weight.dtype == torch.int8:
return "int8_crush"
return None
def _pop_intcrush_tensors(self, state_dict: dict, prefix: str) -> dict[str, Any]:
"""Pop all INT-Crush tensors from *state_dict* and return them as a dict.
The returned dict has keys: weight, scale, zp, perm, smooth,
smoothrot_factors, L1, L2, bias. All popped keys are
returned so they can be removed from missing_keys.
"""
result: dict[str, Any] = {}
result["weight"] = state_dict.pop(prefix + _SD_KEY_WEIGHT)
result["scale"] = state_dict.pop(prefix + _SD_KEY_SCALE)
result["zp"] = state_dict.pop(prefix + _SD_KEY_ZP, None)
result["perm"] = state_dict.pop(prefix + _SD_KEY_PERM, None)
result["smooth"] = state_dict.pop(prefix + _SD_KEY_SMOOTH, None)
result["smoothrot_factors"] = state_dict.pop(prefix + _SD_KEY_SMOOTHROT, None)
result["L1"] = state_dict.pop(prefix + _SD_KEY_L1, None)
result["L2"] = state_dict.pop(prefix + _SD_KEY_L2, None)
result["bias"] = state_dict.pop(prefix + _SD_KEY_BIAS, None)
state_dict.pop(prefix + _SD_KEY_QUANT, None)
return result
def _store_intcrush_attrs(
self,
tensors: dict[str, Any],
params: Any,
device: torch.device,
) -> None:
"""Store quantization metadata and auxiliary tensors as instance attributes.
Tensors are kept on CPU (dtype-cast only) so that ComfyUI's
ModelPatcher/aimdo can manage VRAM placement. They are moved
to the active device lazily on first forward via _prep_activation
and _apply_residuals.
"""
self._intcrush_perm = params.perm
self._intcrush_w_in = self.in_features
self._intcrush_smooth = (
tensors["smooth"].float()
if tensors["smooth"] is not None else None
)
# SmoothRot factors: applied BEFORE Hadamard (1/s → R).
# Distinct from _intcrush_smooth which is applied AFTER (R → /s).
self._intcrush_smoothrot_factors = (
tensors["smoothrot_factors"].float()
if tensors["smoothrot_factors"] is not None else None
)
# SVD low-rank factors: FP16 branch absorbed before quantization.
self._intcrush_L1 = (
tensors["L1"].to(dtype=torch.float16)
if tensors["L1"] is not None else None
)
self._intcrush_L2 = (
tensors["L2"].to(dtype=torch.float16)
if tensors["L2"] is not None else None
)
@staticmethod
def _clear_known_missing_keys(
missing_keys: list[str],
prefix: str,
) -> None:
"""Remove INT-Crush state-dict keys from missing_keys so PyTorch
doesn't complain about legitimately absent standard keys."""
known_suffixes = (
_SD_KEY_WEIGHT, _SD_KEY_SCALE, _SD_KEY_ZP, _SD_KEY_PERM,
_SD_KEY_SMOOTH, _SD_KEY_SMOOTHROT, _SD_KEY_L1, _SD_KEY_L2,
_SD_KEY_BIAS, _SD_KEY_QUANT,
)
for suffix in known_suffixes:
k = prefix + suffix
if k in missing_keys:
missing_keys.remove(k)
# ── PyTorch Module overrides ──────────────────────────────────────
def _load_from_state_dict(
self,
state_dict: dict,
prefix: str,
local_metadata: Any,
strict: bool,
missing_keys: list[str],
unexpected_keys: list[str],
error_msgs: list[str],
) -> None:
from comfy.quant_ops import QuantizedTensor, get_layout_class, QUANT_ALGOS
weight = state_dict.get(prefix + _SD_KEY_WEIGHT)
scale = state_dict.get(prefix + _SD_KEY_SCALE)
fmt = self._detect_weight_format(weight, scale)
if fmt is None:
# Not an INT-Crush layer — delegate to default loader.
super()._load_from_state_dict(
state_dict, prefix, local_metadata,
strict, missing_keys, unexpected_keys, error_msgs)
return
# ── OrbitQuant detection ──
oq_metadata = getattr(type(self), '_orbitquant_metadata', None)
if oq_metadata is None:
oq_metadata = _get_orbitquant_metadata_cache()
if oq_metadata is not None:
type(self)._orbitquant_metadata = oq_metadata
quant_scheme = get_quant_scheme_from_metadata(oq_metadata, prefix)
if quant_scheme is None and scale is not None and oq_metadata is not None:
# No per-layer metadata key — infer from scale shape.
# Requires oq_metadata to avoid false-positives on non-OrbitQuant files.
quant_scheme = infer_quant_scheme_from_scale(scale)
if quant_scheme in ("orbitquant_codebook", "adaln_rtn"):
self._load_orbitquant_layer(
state_dict, prefix, oq_metadata,
weight, scale, quant_scheme,
missing_keys, unexpected_keys,
)
return
# ── RPBH uniform-grid detection (non-OrbitQuant RPBH) ──
rpbh_metadata = getattr(type(self), '_rpbh_metadata', None)
if rpbh_metadata is None:
rpbh_metadata = _get_rpbh_metadata_cache()
if rpbh_metadata is not None:
type(self)._rpbh_metadata = rpbh_metadata
is_rpbh_layer = (
rpbh_metadata is not None
and parse_rpbh_metadata(rpbh_metadata) is not None
)
if is_rpbh_layer:
self._load_rpbh_layer(
state_dict, prefix, rpbh_metadata,
weight, scale, fmt,
missing_keys, unexpected_keys,
)
return
# ── INT-Crush layer: wrap as QuantizedTensor ──
qconfig = QUANT_ALGOS[fmt]
layout_type = qconfig["comfy_tensor_layout"]
layout_cls = get_layout_class(layout_type)
tensors = self._pop_intcrush_tensors(state_dict, prefix)
device = getattr(self, 'weight', None)
device = device.device if device is not None else torch.device("cpu")
# Determine whether this layer needs Hadamard rotation based on name.
name_lower = prefix.lower()
rot_need = (
rot_size > 0 and not any(
p in name_lower for p in _NO_ROTATION_NAME_SUBSTRINGS
)
)
self._intcrush_rot_need = rot_need
self._intcrush_rot_size = rot_size
self._intcrush_is_quantized = True
# ── Mixed precision: read per-layer bit-widths ──
mp_plan = getattr(type(self), '_mp_plan', None)
if mp_plan:
bare_name = prefix.rstrip(".")
mp_entry = mp_plan.get(bare_name)
if mp_entry is None:
# Fallback: strip common prefixes
for strip_prefix in ("model.", "model.diffusion_model."):
if bare_name.startswith(strip_prefix):
mp_entry = mp_plan.get(
bare_name[len(strip_prefix):])
if mp_entry is not None:
break
if mp_entry is not None:
self._intcrush_mp_weight_bits = mp_entry["w"]
self._intcrush_mp_act_bits = mp_entry["a"]
_log.debug("mp_plan: %s -> W%dA%d",
bare_name, mp_entry["w"], mp_entry["a"])
else:
_log.debug("mp_plan: no entry for '%s'", bare_name)
perm = tensors["perm"]
zp = tensors["zp"]
if fmt == "int4_crush":
params = layout_cls.Params(
scale=tensors["scale"].float(),
orig_dtype=torch.float16,
orig_shape=(self.out_features, self.in_features),
rot_need=rot_need,
rot_size=rot_size,
perm=perm if perm is not None else None,
zp=zp if zp is not None else None,
)
else:
params = layout_cls.Params(
scale=tensors["scale"].float().reshape(-1),
orig_dtype=torch.float16,
orig_shape=(self.out_features, self.in_features),
rot_need=rot_need,
rot_size=rot_size,
perm=perm if perm is not None else None,
zp=zp if zp is not None else None,
)
self._store_intcrush_attrs(tensors, params, device)
# Load to CPU. ComfyUI's model loader calls .to(device) on
# the model for full-VRAM mode, and cast_bias_weight handles
# per-layer VBAR demand-paging for low-VRAM mode. This avoids
# forcing all weights resident before the ModelPatcher can
# decide. QuantizedTensor.to(device) moves _qdata, scale,
# perm, and zp atomically via __torch_dispatch__ → _handle_to.
self.weight = nn.Parameter(
QuantizedTensor(
tensors["weight"].to(dtype=qconfig["storage_t"]),
layout_type, params,
),
requires_grad=False,
)
self.quant_format = fmt
self.layout_type = layout_type
bias_tensor = tensors["bias"]
if bias_tensor is not None:
self.bias = nn.Parameter(
bias_tensor.to(device=device), requires_grad=False,
)
else:
self.bias = None
if perm is not None:
self.register_parameter("weight_perm",
nn.Parameter(perm, requires_grad=False))
if zp is not None:
self.register_parameter("weight_zp",
nn.Parameter(zp, requires_grad=False))
self._clear_known_missing_keys(missing_keys, prefix)
def _load_orbitquant_layer(
self,
state_dict: dict,
prefix: str,
metadata: dict,
weight: torch.Tensor,
scale: torch.Tensor,
quant_scheme: str,
missing_keys: list[str],
unexpected_keys: list[str],
) -> None:
"""Load an OrbitQuant-quantized layer."""
from .quant_layout import OrbitQuantLayout
from ._orbitquant import infer_orbitquant_metadata_from_state_dict
# If metadata is None, try to infer from state dict globals
if metadata is None:
metadata = infer_orbitquant_metadata_from_state_dict(state_dict)
if metadata is None:
raise ValueError(
"OrbitQuant layer detected (scale shape indicates codebook/AdaLN) "
"but no metadata found. Ensure the safetensors file has "
"int_crush.format_version=4 metadata."
)
# Store inferred metadata on the ops class for subsequent layers
type(self)._orbitquant_metadata = metadata
oq_meta = parse_orbitquant_metadata(metadata)
if oq_meta is None:
raise ValueError("OrbitQuant metadata not found in file")
model_path = metadata.get("int_crush.orbitquant_dims", "default")
# Load globals (idempotent — cached after first call)
globals_cache = load_orbitquant_globals(state_dict, metadata, model_path)
type(self)._orbitquant_model_path = model_path
# Determine d from the module's declared in_features
d = self.in_features
d_padded = oq_meta["d_padded_map"].get(d, d)
bits = oq_meta["codebook_bits"]
# Get RPBH params for this dimension.
# AdaLN-RTN layers are UNROTATED — no RPBH needed.
if quant_scheme == "adaln_rtn":
rpbh = globals_cache["rpbh"].get(d)
if rpbh is None:
# AdaLN may share d with a codebook layer (common for
# hidden dim); if not, use a sensible fallback.
rpbh = {
"perm": None, "signs": None, "h": 1, "d_padded": d,
}
codebook = None # AdaLN-RTN uses group scales, not a codebook
else:
rpbh = globals_cache["rpbh"].get(d)
if rpbh is None:
raise ValueError(
f"OrbitQuant: no RPBH params for d={d}. "
f"Available dims: {list(globals_cache['rpbh'].keys())}"
)
codebook = globals_cache["codebooks"].get((d, bits))
if codebook is None:
raise ValueError(
f"OrbitQuant: no codebook for (d={d}, bits={bits}). "
f"Available: {list(globals_cache['codebooks'].keys())}"
)
# Pop tensors
tensors = self._pop_intcrush_tensors(state_dict, prefix)
# Store OrbitQuant state
self._intcrush_is_quantized = True
self._intcrush_is_orbitquant = True
self._intcrush_oq_perm = rpbh["perm"]
self._intcrush_oq_signs = rpbh["signs"]
self._intcrush_oq_h = rpbh["h"]
self._intcrush_oq_codebook = codebook
self._intcrush_oq_bits = bits
self._intcrush_oq_d = d
self._intcrush_oq_d_padded = d_padded
self._intcrush_oq_quant_scheme = quant_scheme
self._intcrush_rot_need = False # OrbitQuant uses RPBH, not group Hadamard
# Build Params — include codebook for dequantize() support
layout_cls = OrbitQuantLayout
params = layout_cls.Params(
scale=tensors["scale"].float(),
orig_dtype=torch.float16,
orig_shape=(self.out_features, d),
quant_scheme=quant_scheme,
d=d,
d_padded=d_padded,
bits=bits,
codebook=codebook, # enables dequantize() for LoRA/VBAR/fallback
)
# Register as QuantizedTensor
QT = _get_qt()
self.weight = nn.Parameter(
QT(tensors["weight"], "OrbitQuantLayout", params),
requires_grad=False,
)
self.quant_format = "orbitquant"
self.layout_type = "OrbitQuantLayout"
# Bias
if tensors["bias"] is not None:
self.bias = nn.Parameter(tensors["bias"], requires_grad=False)
else:
self.bias = None
self._clear_known_missing_keys(missing_keys, prefix)
# Remove OrbitQuant global keys from unexpected_keys (first layer only)
for key in list(unexpected_keys):
if key.startswith("_rpbh_") or key.startswith("_orbitquant_"):
unexpected_keys.remove(key)
def _load_rpbh_layer(
self,
state_dict: dict,
prefix: str,
metadata: dict,
weight: torch.Tensor,
scale: torch.Tensor,
fmt: str,
missing_keys: list[str],
unexpected_keys: list[str],
) -> None:
"""Load an RPBH uniform-grid layer (INT4/INT8 packed + RPBH rotation)."""
from comfy.quant_ops import QuantizedTensor, get_layout_class, QUANT_ALGOS
# If metadata is None, try to infer from state dict globals
if metadata is None:
metadata = infer_rpbh_metadata_from_state_dict(state_dict)
if metadata is None:
raise ValueError(
"RPBH layer detected but no metadata found. "
"Ensure the safetensors file has int_crush.rpbh=true metadata."
)
type(self)._rpbh_metadata = metadata
rpbh_meta = parse_rpbh_metadata(metadata)
if rpbh_meta is None:
raise ValueError("RPBH metadata not found in file")
model_path = metadata.get("int_crush.rpbh_dims", "default")
# Load globals (idempotent — cached after first call)
globals_cache = load_rpbh_globals(state_dict, metadata, model_path)
# Determine d from the module's declared in_features
d = self.in_features
d_padded = rpbh_meta["d_padded_map"].get(d, d)
# Get RPBH params for this dimension
rpbh = globals_cache["rpbh"].get(d)
if rpbh is None:
raise ValueError(
f"RPBH: no RPBH params for d={d}. "
f"Available dims: {list(globals_cache['rpbh'].keys())}"
)
# Pop standard INT-Crush tensors (weight, scale, zp, perm, smooth, etc.)
tensors = self._pop_intcrush_tensors(state_dict, prefix)
device = getattr(self, 'weight', None)
device = device.device if device is not None else torch.device("cpu")
# Store RPBH state — replaces group Hadamard rotation.
self._intcrush_is_quantized = True
self._intcrush_is_rpbh = True
self._intcrush_rpbh_perm = rpbh["perm"]
self._intcrush_rpbh_signs = rpbh["signs"]
self._intcrush_rpbh_h = rpbh["h"]
self._intcrush_rpbh_d = d
self._intcrush_rpbh_d_padded = d_padded
# RPBH replaces group Hadamard rotation — disable it.
self._intcrush_rot_need = False
self._intcrush_rot_size = rpbh_meta["block_size"]
# w_in must equal d_padded (the padded weight width).
self._intcrush_w_in = d_padded
# Build Params using the standard layout class (IntCrushInt4Layout or IntCrushInt8Layout).
qconfig = QUANT_ALGOS[fmt]
layout_type = qconfig["comfy_tensor_layout"]
layout_cls = get_layout_class(layout_type)
perm = tensors["perm"]
zp = tensors["zp"]
params = layout_cls.Params(
scale=tensors["scale"].float() if fmt == "int4_crush" else tensors["scale"].float().reshape(-1),
orig_dtype=torch.float16,
orig_shape=(self.out_features, self.in_features),
rot_need=False, # RPBH handles rotation, not group Hadamard
rot_size=0, # no group Hadamard
perm=perm if perm is not None else None,
zp=zp if zp is not None else None,
)
self._store_intcrush_attrs(tensors, params, device)
# Load to CPU — same as standard INT-Crush path.
self.weight = nn.Parameter(
QuantizedTensor(
tensors["weight"].to(dtype=qconfig["storage_t"]),
layout_type, params,
),
requires_grad=False,
)
self.quant_format = fmt
self.layout_type = layout_type
bias_tensor = tensors["bias"]
if bias_tensor is not None:
self.bias = nn.Parameter(
bias_tensor.to(device=device), requires_grad=False,
)
else:
self.bias = None
if perm is not None:
self.register_parameter("weight_perm",
nn.Parameter(perm, requires_grad=False))
if zp is not None:
self.register_parameter("weight_zp",
nn.Parameter(zp, requires_grad=False))
self._clear_known_missing_keys(missing_keys, prefix)
# Remove RPBH global keys from unexpected_keys (first layer only)
for key in list(unexpected_keys):
if key.startswith("_rpbh_perm_") or key.startswith("_rpbh_signs_"):
unexpected_keys.remove(key)
def convert_weight(self, weight: torch.Tensor, inplace: bool = False) -> torch.Tensor:
"""Keep QuantizedTensor as-is for ModelPatcher compatibility."""
from comfy.quant_ops import QuantizedTensor
if self._intcrush_is_quantized and isinstance(weight, QuantizedTensor):
return weight
# OrbitQuant may have a plain float weight after set_weight
if getattr(self, '_intcrush_is_orbitquant', False):
return weight
if isinstance(weight, QuantizedTensor):
return weight.dequantize()
return weight
def set_weight(self, out_weight: torch.Tensor, inplace_update: bool = False, seed: int = 0,
return_weight: bool = False, **kwargs: Any) -> torch.Tensor | None:
"""Requantize float weights, preserving rotation and permutation params."""
# Invalidate CUDA graph cache on weight changes.
if hasattr(self, '_graph_runner') and self._graph_runner is not None:
self._graph_runner = None
if not self._intcrush_is_quantized:
new_weight = out_weight.to(self.weight.dtype)
if return_weight:
return new_weight
self.weight = nn.Parameter(new_weight, requires_grad=False)
return
from comfy.quant_ops import QuantizedTensor, get_layout_class
import dataclasses
if isinstance(out_weight, QuantizedTensor):
if return_weight:
return out_weight
self.weight = nn.Parameter(out_weight, requires_grad=False)
return
# OrbitQuant: can't requantize without RPBH + codebook.
# Store as a plain float weight — forward will use _forward_predequant_oq.
if getattr(self, '_intcrush_is_orbitquant', False):
new_weight = out_weight.to(dtype=torch.float16)
if return_weight:
return new_weight
self.weight = nn.Parameter(new_weight, requires_grad=False)
return
layout_cls = get_layout_class(self.layout_type)
packed, params = layout_cls.quantize(
out_weight.float(), scale="recalculate",
stochastic_rounding=seed,
)
params = dataclasses.replace(
params,
rot_need=self._intcrush_rot_need,
rot_size=self._intcrush_rot_size,
perm=self._intcrush_perm,
)
qt = QuantizedTensor(packed, self.layout_type, params)
if return_weight:
return qt
self.weight = nn.Parameter(qt, requires_grad=False)
def set_bias(self, out_bias: torch.Tensor | None, inplace_update: bool = False, seed: int = 0,
return_weight: bool = False, **kwargs: Any) -> torch.Tensor | None:
if out_bias is None:
return None
if return_weight:
return out_bias
self.bias = nn.Parameter(out_bias, requires_grad=False)
@torch.no_grad()
def forward(self, x: torch.Tensor) -> torch.Tensor:
# ── Non-quantized fallback ──
if not self._intcrush_is_quantized:
return super().forward(x)
# ── ComfyUI weight casting (VBAR + low-VRAM) ──
weight, bias, uncast, QT = self._setup_casting(x)
# try/finally guarantees VBAR pages are always unpinned,
# even when _apply_residuals (LoRA) raises.
try:
# ── Weight already dequantized (e.g. wrapper patch ran) ──
if not isinstance(weight, QT):
if self._intcrush_is_orbitquant:
return self._forward_predequant_oq(weight, bias, x)
return self._forward_predequant(weight, bias, x)
qdata = weight._qdata
# ── OrbitQuant forward ──
if self._intcrush_is_orbitquant:
return self._forward_orbitquant(qdata, weight, bias, x)
# ── INT4 forward paths ──
if isinstance(weight._params, IntCrushInt4Layout.Params):
backend = self._resolve_ck_backend_int4()
if backend == "cuda":
return self._forward_int4_ck(qdata, weight, bias, x)
if backend == "triton":
_have_w4a8_kernel = (
(_tr.TRITON_W4A8_GEMM or _tr.TRITON_INT8_GEMM)
and _tr.TRITON_DYNQUANT
and _tr.TRITON_INT4_INT8_UNPACK
)
if (not self._intcrush_use_pytorch and not self._intcrush_use_w4a16
and (self._intcrush_rot_need or self._intcrush_is_rpbh)
and _have_w4a8_kernel):
return self._forward_int4_w4a8(qdata, weight, bias, x)
if not self._intcrush_use_pytorch and _tr.TRITON_INT4_UNPACK:
return self._forward_int4_w4a16(qdata, weight, bias, x)
# pytorch fallback (also catches failed backend attempts)
return self._forward_int4_pytorch(qdata, weight, x)
# ── INT8 forward paths ──
if isinstance(weight._params, IntCrushInt8Layout.Params):
backend = self._resolve_ck_backend()
if backend == "cuda":
return self._forward_int8_ck(qdata, weight, bias, x)
if backend == "triton":
return self._forward_int8(qdata, weight, bias, x)
# pytorch fallback
self._intcrush_use_pytorch = True
return self._forward_int8(qdata, weight, bias, x)
# Unknown layout — delegate to standard manual_cast forward.
return super().forward(x)
finally:
if uncast is not None:
uncast()
class GroupNorm(manual_cast.GroupNorm):
pass
class LayerNorm(manual_cast.LayerNorm):
pass
class Conv2d(manual_cast.Conv2d):
pass
class Conv3d(manual_cast.Conv3d):
pass
class ConvTranspose2d(manual_cast.ConvTranspose2d):
pass
class Embedding(manual_cast.Embedding):
pass
_ops_cache[cache_key] = IntCrushOps
return IntCrushOps
__all__ = ["make_intcrush_ops"]