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

166 lines
4.9 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for the node layer: the TQ3 cache wrapper and the attention patch.
These paths had no coverage, and they are the ones ComfyUI actually calls.
"""
import pytest
import torch
import tq3_core
import turboquant_nodes
BLOCK = tq3_core.TQ3_BLOCK
def _cosine(x, xr):
return (
torch.nn.functional.cosine_similarity(
x.reshape(-1, x.shape[-1]).float(),
xr.reshape(-1, xr.shape[-1]).float(),
dim=-1,
)
.mean()
.item()
)
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
def test_wrapper_roundtrip_preserves_dtype_and_shape(dtype):
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
tensor = torch.randn(2, 8, BLOCK, dtype=dtype)
wrapper.store("k", tensor)
loaded = wrapper.load("k")
assert loaded.shape == tensor.shape
assert loaded.dtype == dtype
assert _cosine(tensor, loaded) > 0.97
@pytest.mark.parametrize("dim", [320, 640, 1280, BLOCK + 1])
def test_wrapper_pads_dims_that_are_not_a_multiple_of_the_block(dim):
"""SD/SDXL inner dims are not multiples of 128, so this is the common path."""
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
tensor = torch.randn(2, 4, dim)
wrapper.store("k", tensor)
loaded = wrapper.load("k")
assert loaded.shape == tensor.shape
assert _cosine(tensor, loaded) > 0.97
def test_wrapper_roundtrip_keeps_structured_tensors():
"""A per-channel offset is exactly what real attention tensors carry."""
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
tensor = torch.randn(2, 8, BLOCK) + torch.linspace(-4.0, 4.0, BLOCK)
wrapper.store("k", tensor)
assert _cosine(tensor, wrapper.load("k")) > 0.97
def test_wrapper_stores_raw_tensors_when_disabled():
wrapper = turboquant_nodes.TQ3KVCacheWrapper(enabled=False)
tensor = torch.randn(2, BLOCK)
wrapper.store("k", tensor)
assert wrapper.load("k") is tensor
assert wrapper.compression_ratio == 0.0
def test_wrapper_load_returns_none_for_an_unknown_key():
assert turboquant_nodes.TQ3KVCacheWrapper().load("missing") is None
def test_wrapper_reports_the_documented_ratio_for_fp16():
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
wrapper.store("k", torch.randn(4, 8, BLOCK, dtype=torch.float16))
# 128 fp16 values (256 bytes) -> 48 + 4 + 4 = 56 bytes
assert wrapper.compression_ratio == pytest.approx(256 / 56, rel=1e-6)
assert wrapper.savings_mb > 0
assert "Ratio:" in wrapper.stats_string()
def test_wrapper_clear_resets_cache_and_stats():
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
wrapper.store("k", torch.randn(2, BLOCK))
wrapper.clear()
assert wrapper.load("k") is None
assert wrapper.compression_ratio == 0.0
def test_attn_patch_passes_through_a_none_context():
"""ComfyUI's attn2 path does not coerce a None context (attn1 does)."""
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
patch = turboquant_nodes._make_attn_patch(wrapper)
hidden = torch.randn(2, 16, 320)
q, k, v = patch(hidden, None, None, {})
assert q is hidden
assert k is None
assert v is None
def test_attn_patch_roundtrips_kv_and_leaves_no_cache_entries():
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
patch = turboquant_nodes._make_attn_patch(wrapper)
hidden = torch.randn(2, 16, 320)
q, k, v = patch(hidden, hidden, hidden, {})
assert q is hidden
assert k.shape == hidden.shape and v.shape == hidden.shape
assert _cosine(hidden, k) > 0.97
assert wrapper._cache == {}
assert wrapper._stats["num_stores"] == 2
def test_attn_patch_skips_tensors_narrower_than_one_block():
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
patch = turboquant_nodes._make_attn_patch(wrapper)
narrow = torch.randn(2, 16, 64)
q, k, v = patch(narrow, narrow, narrow, {})
assert k is narrow and v is narrow
assert wrapper._stats["num_stores"] == 0
def test_node_registration_exposes_both_documented_nodes():
assert set(turboquant_nodes.NODE_CLASS_MAPPINGS) == {
"TurboQuantPatch",
"TurboQuantInfo",
}
assert set(turboquant_nodes.NODE_DISPLAY_NAME_MAPPINGS) == {
"TurboQuantPatch",
"TurboQuantInfo",
}
for name, cls in turboquant_nodes.NODE_CLASS_MAPPINGS.items():
assert hasattr(cls, cls.FUNCTION), name
assert "required" in cls.INPUT_TYPES()
def test_info_node_reports_stats_once_a_wrapper_is_active():
turboquant_nodes._active_wrapper = None
try:
(idle,) = turboquant_nodes.TurboQuantInfo().info(model=None)
assert "Not yet active" in idle
wrapper = turboquant_nodes.TQ3KVCacheWrapper()
wrapper.store("k", torch.randn(2, BLOCK, dtype=torch.float16))
turboquant_nodes._active_wrapper = wrapper
(active,) = turboquant_nodes.TurboQuantInfo().info(model=None)
assert "Stores: 1" in active
finally:
turboquant_nodes._active_wrapper = None