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>
166 lines
4.9 KiB
Python
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
|