Files
Vyacheslav-TomashevskiyandVyacheslav-Tomashevskiy 035a13bc25 fix(tq3): apply the sign flips before the FWHT, not after (#7)
The randomized Hadamard transform needs its sign flips applied before the
transform. Applied afterwards they cannot spread anything: absmax and a
codebook symmetric about zero are both invariant under a sign flip, so the
reconstruction comes back bit-for-bit identical. The documented decorrelation
step was therefore a no-op, and any block carrying an offset or low-frequency
content stayed collapsed into a single Hadamard coefficient - a constant block
round-tripped at cosine 0.611 with a relative L2 error of 1.27, i.e. worse
than returning zeros.

Also make the test suite runnable (it errored out entirely, because pytest
imports the repository root __init__.py as a top-level module and the relative
imports failed) and stop the attention patch from raising AttributeError on
ComfyUI's attn2 path, which does not coerce a None context the way attn1 does.

Co-authored-by: Vyacheslav-Tomashevskiy <Vyacheslav-Tomashevskiy@users.noreply.github.com>
2026-08-20 10:53:14 -04:00

290 lines
9.1 KiB
Python

# SPDX-License-Identifier: MIT
"""
TurboQuant ComfyUI Nodes
Two nodes:
1. TurboQuantPatch - Experimental attention K/V recompression patch
2. TurboQuantInfo - Shows observed compression stats for a patched model
"""
import torch
import math
from typing import Optional
try:
from . import tq3_core
except ImportError: # imported as a top-level module rather than as a package
import tq3_core
class TQ3KVCacheWrapper:
"""
Wraps a standard KV cache to transparently compress/decompress
using TQ3 quantization.
Stored tensors are kept in TQ3 format (dict of norms, scales, packed).
On read, they are decompressed back to float/half.
"""
def __init__(self, enabled=True):
self.enabled = enabled
self._cache = {} # key -> tq3 dict or raw tensor
self._stats = {
"compressed_bytes": 0,
"original_bytes": 0,
"num_stores": 0,
}
def store(self, key: str, tensor: torch.Tensor) -> None:
"""Compress and store a KV tensor."""
if not self.enabled:
self._cache[key] = tensor
return
orig_dtype = tensor.dtype
orig_device = tensor.device
D = tensor.shape[-1]
# TQ3 requires last dim divisible by 128
if D % tq3_core.TQ3_BLOCK != 0:
# Pad to next multiple of 128
pad_to = math.ceil(D / tq3_core.TQ3_BLOCK) * tq3_core.TQ3_BLOCK
pad_size = pad_to - D
tensor_padded = torch.nn.functional.pad(tensor.float(), (0, pad_size))
else:
tensor_padded = tensor.float()
pad_size = 0
tq = tq3_core.tq3_quantize(tensor_padded)
tq["_orig_dtype"] = orig_dtype
tq["_orig_device"] = orig_device
tq["_pad_size"] = pad_size
self._cache[key] = tq
# Track stats
compressed_bytes = (
tq["packed"].numel() * tq["packed"].element_size()
+ tq["norms"].numel() * tq["norms"].element_size()
+ tq["scales"].numel() * tq["scales"].element_size()
)
original_bytes = tensor.numel() * tensor.element_size()
self._stats["compressed_bytes"] += compressed_bytes
self._stats["original_bytes"] += original_bytes
self._stats["num_stores"] += 1
def load(self, key: str) -> Optional[torch.Tensor]:
"""Decompress and return a KV tensor."""
entry = self._cache.get(key)
if entry is None:
return None
if isinstance(entry, torch.Tensor):
return entry
# TQ3 dict
result = tq3_core.tq3_dequantize(entry)
pad_size = entry.get("_pad_size", 0)
if pad_size > 0:
result = result[..., :-pad_size]
orig_dtype = entry.get("_orig_dtype", torch.float16)
orig_device = entry.get("_orig_device", "cpu")
return result.to(dtype=orig_dtype, device=orig_device)
def clear(self):
self._cache.clear()
self._stats = {"compressed_bytes": 0, "original_bytes": 0, "num_stores": 0}
@property
def compression_ratio(self) -> float:
if self._stats["original_bytes"] == 0:
return 0.0
return self._stats["original_bytes"] / max(self._stats["compressed_bytes"], 1)
@property
def savings_mb(self) -> float:
diff = self._stats["original_bytes"] - self._stats["compressed_bytes"]
return diff / (1024 * 1024)
def stats_string(self) -> str:
ratio = self.compression_ratio
orig_mb = self._stats["original_bytes"] / (1024 * 1024)
comp_mb = self._stats["compressed_bytes"] / (1024 * 1024)
return (
f"TurboQuant TQ3 KV Cache Stats\n"
f"-----------------------------\n"
f"Stores: {self._stats['num_stores']}\n"
f"Original: {orig_mb:.1f} MB\n"
f"TQ3 size: {comp_mb:.1f} MB\n"
f"Ratio: {ratio:.2f}x\n"
f"Savings: {self.savings_mb:.1f} MB"
)
# Global wrapper instance so the info node can read stats
_active_wrapper = None
def _make_attn_patch(wrapper: TQ3KVCacheWrapper):
"""
Create an attention patch function compatible with ComfyUI's
model_patcher system.
ComfyUI patches are callables that receive (q, k, v, extra_options)
and return (q, k, v). We intercept to compress/decompress KV.
For the KV cache compression use case, the patch:
1. On store (when new K,V are computed): compress and cache
2. On load (when cached K,V are reused): decompress
In diffusion models, attention is recomputed each step, so the
main benefit is reducing the *peak* memory of the KV tensors
that coexist during the attention computation. The patch quantizes
K and V *in transit* so they occupy less VRAM while stored.
"""
step_counter = [0]
def attn_patch(q, k, v, extra_options):
nonlocal step_counter
# Compress K and V to TQ3, then immediately decompress.
# This forces the memory layout through the compressed format,
# meaning the original FP16 K/V can be freed sooner by PyTorch's
# allocator. The decompressed versions are fresh allocations
# that only live for the attention matmul.
step_counter[0] += 1
step_id = step_counter[0]
# ComfyUI coerces a None context away before calling an attn1 patch
# (attention.py: "if context_attn1 is None: context_attn1 = n") but the
# attn2 path has no such guard, so k/v arrive as None whenever the block
# runs without conditioning. Pass those through untouched.
if k is None or v is None:
return q, k, v
# Only compress if dim is >= 128 (otherwise overhead isn't worth it)
if k.shape[-1] >= tq3_core.TQ3_BLOCK:
k_key = f"k_{step_id}"
v_key = f"v_{step_id}"
wrapper.store(k_key, k)
wrapper.store(v_key, v)
# Decompress - original k,v can now be freed
k = wrapper.load(k_key)
v = wrapper.load(v_key)
# Clean up compressed copies (they served their purpose)
if k_key in wrapper._cache:
del wrapper._cache[k_key]
if v_key in wrapper._cache:
del wrapper._cache[v_key]
return q, k, v
return attn_patch
class TurboQuantPatch:
"""
Experimentally round-trips attention K/V tensors through TQ3 during attention.
This does not yet replace ComfyUI's persistent KV cache. It is primarily
useful for validating TQ3 quality and measuring the observed compression
characteristics of the intermediate K/V tensors.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"enabled": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "patch"
CATEGORY = "TurboQuant"
DESCRIPTION = (
"Experimentally round-trips model attention K/V tensors through TQ3. "
"Useful for quality and compression experiments; not a persistent KV cache yet."
)
def patch(self, model, enabled):
global _active_wrapper
if not enabled:
_active_wrapper = None
return (model,)
wrapper = TQ3KVCacheWrapper(enabled=True)
_active_wrapper = wrapper
# Clone the model patcher so we don't modify the original
patched = model.clone()
# Register the attention patch via ComfyUI's patcher API
# "attn1" = self-attention, "attn2" = cross-attention
attn_patch = _make_attn_patch(wrapper)
patched.set_model_attn1_patch(attn_patch)
patched.set_model_attn2_patch(attn_patch)
return (patched,)
class TurboQuantInfo:
"""
Displays observed TurboQuant compression statistics.
Connect after TurboQuantPatch to see the cumulative compression ratio
and byte savings observed during generation.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("stats",)
FUNCTION = "info"
CATEGORY = "TurboQuant"
OUTPUT_NODE = True
DESCRIPTION = "Shows observed TQ3 compression ratio and byte savings."
def info(self, model):
global _active_wrapper
if _active_wrapper is not None:
stats = _active_wrapper.stats_string()
else:
# Estimate based on model config
stats = (
"TurboQuant TQ3 KV Cache\n"
"-----------------------\n"
"Status: Not yet active (no inference run)\n"
"Expected compression: ~4.5x\n"
"Encoding: 3-bit Lloyd-Max + FWHT + sign flips\n"
"Block size: 128 floats -> 56 bytes"
)
return (stats,)
# Node registration dicts
NODE_CLASS_MAPPINGS = {
"TurboQuantPatch": TurboQuantPatch,
"TurboQuantInfo": TurboQuantInfo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TurboQuantPatch": "TurboQuant KV Patch",
"TurboQuantInfo": "TurboQuant Info",
}