Support quantized diffusion model inputs

This commit is contained in:
silveroxides
2026-09-23 01:53:25 +02:00
parent 7a606f8a02
commit 57bd63e1eb
12 changed files with 917 additions and 69 deletions
@@ -122,6 +122,48 @@ def test_paired_stream_is_bounded_ordered_and_releases_once():
assert all(stream.closed for stream in handler_a.streams + handler_b.streams)
def test_paired_stream_decodes_quantized_logical_keys_per_work_unit():
streaming = _load_module("nodes.extraction_stream")
handler_a = _FakeHandler({
"layer1.code": torch.tensor(2),
"layer1.scale": torch.tensor(3),
"layer2.code": torch.tensor(4),
"layer2.scale": torch.tensor(5),
})
handler_b = _FakeHandler({"layer1": torch.tensor(1), "layer2": torch.tensor(2)})
class _Quantization:
quantized_keys = {"layer1", "layer2"}
def required_keys(self, key):
return (f"{key}.code", f"{key}.scale")
def decode(self, key, tensors, compute_dtype, device="cpu"):
assert set(tensors) == {f"{key}.code", f"{key}.scale"}
return tensors[f"{key}.code"] * tensors[f"{key}.scale"]
quantization = _Quantization()
observed = list(streaming.paired_async_tensors(
handler_a,
handler_b,
[("layer1", "layer1", "layer1"), ("layer2", "layer2", "layer2")],
pin_memory=False,
quantization_a=quantization,
))
assert [(key, tensor_a.item(), tensor_b.item()) for key, tensor_a, tensor_b in observed] == [
("layer1", 6, 1),
("layer2", 20, 2),
]
assert handler_a.calls[0][0] == (
"layer1.code", "layer1.scale", "layer2.code", "layer2.scale"
)
assert Counter(handler_a.marked) == Counter({
"layer1.code": 1, "layer1.scale": 1, "layer2.code": 1, "layer2.scale": 1,
})
assert all(stream.closed for stream in handler_a.streams + handler_b.streams)
def test_cuda_oom_compute_retries_on_cpu(monkeypatch):
streaming = _load_module("nodes.extraction_stream")
monkeypatch.setattr(streaming, "release_cuda_oom", lambda: None)
+16
View File
@@ -1,6 +1,7 @@
import csv
import importlib
from io import StringIO
import json
import logging
from pathlib import Path
import sys
@@ -67,6 +68,21 @@ def test_reports_actual_applied_delta_without_saving(monkeypatch, tmp_path, anal
assert set(tmp_path.iterdir()) == set(paths.values())
def test_quantized_base_is_dequantized_and_sidecars_are_not_analyzed(monkeypatch, tmp_path, analysis):
prefix = "model.diffusion_model.layer"
quant = torch.tensor(list(json.dumps({"format": "int8_tensorwise"}).encode()), dtype=torch.uint8)
setup(monkeypatch, tmp_path, analysis, pair(), {
f"{prefix}.weight": torch.ones((2, 2), dtype=torch.int8),
f"{prefix}.weight_scale": torch.tensor(2.0),
f"{prefix}.comfy_quant": quant,
})
comparison, _, metrics_csv, _ = analysis.analyze_lora_on_model("adapter", "base", params())
rows = list(csv.DictReader(StringIO(metrics_csv)))
assert [row["key"] for row in rows] == [f"{prefix}.weight"]
assert float(rows[0]["mae"]) == pytest.approx(2.25)
assert "weight_scale" not in comparison
@pytest.mark.parametrize("suffixes", [(".lora_down.weight", ".lora_up.weight"), (".lora_A.default.weight", ".lora_B.default.weight"), (".lora_A", ".lora_B")])
def test_pair_formats_and_flattened_reference_mapping(monkeypatch, tmp_path, analysis, suffixes):
setup(monkeypatch, tmp_path, analysis, {
+149
View File
@@ -436,3 +436,152 @@ def test_merge_to_model_preserves_low_bit_base_and_skips_low_bit_adapter_layer(
assert "diffusion_model.guarded.lora_down.weight" in report
assert "torch.int8" in report
assert "model.diffusion_model.base_quant.weight" in report
def test_merge_to_model_dequantizes_base_and_omits_sidecars(monkeypatch, tmp_path, modules):
resize = modules["resize"]
_patch_output(monkeypatch, resize, tmp_path)
base = tmp_path / "quantized_base.safetensors"
adapter = tmp_path / "quantized_adapter.safetensors"
quant = torch.tensor(list(json.dumps({"format": "int8_tensorwise"}).encode()), dtype=torch.uint8)
prefix = "model.diffusion_model.layer"
save_file({
f"{prefix}.weight": torch.ones((2, 2), dtype=torch.int8),
f"{prefix}.weight_scale": torch.tensor(2.0),
f"{prefix}.comfy_quant": quant,
}, str(base))
save_file({"diffusion_model.layer.diff": torch.ones((2, 2))}, str(adapter))
output = resize.merge_loras_to_model(
[str(adapter)], [1.0], str(base), "cpu", torch.float16,
"dequantized_merge", verbose=False,
)
result = load_file(output)
assert list(result) == [f"{prefix}.weight"]
torch.testing.assert_close(result[f"{prefix}.weight"].float(), torch.full((2, 2), 3.0))
def test_diffusion_decoder_matches_core_quantized_tensor(tmp_path, modules):
from comfy.quant_ops import QUANT_ALGOS, QuantizedTensor, get_layout_class
path = tmp_path / "core_int8.safetensors"
key = "layer.weight"
qdata = torch.tensor([[1, -2], [3, 4]], dtype=torch.int8)
scale = torch.tensor(0.5)
save_file({
key: qdata,
"layer.weight_scale": scale,
"layer.comfy_quant": torch.tensor(
list(json.dumps({"format": "int8_tensorwise"}).encode()), dtype=torch.uint8
),
}, str(path))
handler = modules["resize"].MemoryEfficientSafeOpen(str(path), low_memory=True)
try:
decoder = modules["guard"].DiffusionQuantization(handler, str(path), "Core comparison")
keys = decoder.required_keys(key)
assert set(keys) == {key, "layer.weight_scale", "layer.comfy_quant"}
assert decoder.data_keys == (key,)
stream = handler.async_stream(keys, batch_size=1, prefetch_batches=1, pin_memory=False)
tensors = {}
try:
for expected in keys:
batch = next(stream)
assert len(batch) == 1 and batch[0][0] == expected
tensors[expected] = batch[0][1]
actual = decoder.decode(key, tensors, torch.float32)
finally:
for source_key in tensors:
handler.mark_processed(source_key)
stream.close()
layout_name = QUANT_ALGOS["int8_tensorwise"]["comfy_tensor_layout"]
layout = get_layout_class(layout_name)
expected = QuantizedTensor(
qdata, layout_name,
layout.Params(scale=scale, orig_dtype=torch.float32, orig_shape=(2, 2)),
).dequantize()
torch.testing.assert_close(actual, expected)
torch.testing.assert_close(actual, qdata.float() * scale)
finally:
handler.__exit__(None, None, None)
def test_padded_shape_comes_from_core_meta_architecture(monkeypatch, modules):
import comfy.model_detection
class HeaderOnly:
def keys(self):
return ["model.layer.weight", "model.layer.weight_scale", "model.layer.weight_scale_2"]
def metadata(self):
return {"_quantization_metadata": json.dumps({"layers": {"model.layer": {"format": "nvfp4"}}})}
def get_shape(self, key):
return {"model.layer.weight": (16, 8), "model.layer.weight_scale": (16, 1), "model.layer.weight_scale_2": ()}[key]
def get_dtype(self, key):
return torch.uint8 if key == "model.layer.weight" else torch.float32
class Config:
def get_model(self, state, prefix, device):
assert device == torch.device("meta")
assert all(tensor.device.type == "meta" for tensor in state.values())
model = torch.nn.Module()
model.diffusion_model = torch.nn.Module()
model.diffusion_model.layer = torch.nn.Module()
model.diffusion_model.layer._orig_shape = (3, 5)
return model
monkeypatch.setattr(comfy.model_detection, "model_config_from_unet", lambda *args, **kwargs: Config())
decoder = modules["guard"].DiffusionQuantization(HeaderOnly(), "header-only", "Shape test")
assert decoder.logical_shape("model.layer.weight") == (3, 5)
def test_nvfp4_stream_decode_matches_core_full_dequantization(monkeypatch, tmp_path, modules):
import comfy.model_detection
from comfy.quant_ops import QUANT_ALGOS, QuantizedTensor, get_layout_class
layout_name = QUANT_ALGOS["nvfp4"]["comfy_tensor_layout"]
layout = get_layout_class(layout_name)
original = torch.arange(15, dtype=torch.float16).reshape(3, 5) / 10
qdata, params = layout.quantize(original)
path = tmp_path / "nvfp4.safetensors"
metadata = {"_quantization_metadata": json.dumps({"layers": {"model.layer": {"format": "nvfp4"}}})}
save_file({
"model.layer.weight": qdata,
"model.layer.weight_scale": params.block_scale,
"model.layer.weight_scale_2": params.scale,
}, str(path), metadata=metadata)
class Config:
def get_model(self, state, prefix, device):
model = torch.nn.Module()
model.diffusion_model = torch.nn.Module()
model.diffusion_model.layer = torch.nn.Module()
model.diffusion_model.layer._orig_shape = tuple(original.shape)
return model
monkeypatch.setattr(comfy.model_detection, "model_config_from_unet", lambda *args, **kwargs: Config())
handler = modules["resize"].MemoryEfficientSafeOpen(str(path), low_memory=True)
try:
decoder = modules["guard"].DiffusionQuantization(handler, str(path), "NVFP4 test")
key = "model.layer.weight"
assert decoder.logical_shape(key) == tuple(original.shape)
keys = decoder.required_keys(key)
stream = handler.async_stream(keys, batch_size=1, prefetch_batches=1, pin_memory=False)
tensors = {}
try:
for source_key in keys:
batch = next(stream)
assert len(batch) == 1 and batch[0][0] == source_key
tensors[source_key] = batch[0][1]
actual = decoder.decode(key, tensors, torch.float16)
finally:
for source_key in tensors:
handler.mark_processed(source_key)
stream.close()
expected = QuantizedTensor(qdata, layout_name, params).dequantize()
torch.testing.assert_close(actual, expected)
assert actual.shape == original.shape
finally:
handler.__exit__(None, None, None)
+46
View File
@@ -1,10 +1,12 @@
import importlib
import json
import sys
import types
from pathlib import Path
import pytest
import torch
from safetensors import safe_open
from safetensors.torch import load_file, save_file
@@ -130,3 +132,47 @@ def test_lora_factor_group_uses_one_normalized_layer_name(monkeypatch, tmp_path,
assert "diffusion_model.layer.lora_A.weight" in result
assert "diffusion_model.layer.lora_B.weight" in result
assert not any(key.endswith(".alpha") for key in result)
def test_diffusion_merge_decodes_tensorwise_quantization_and_drops_sidecars(
monkeypatch, tmp_path, modules,
):
merger, operations = modules
paths = {name: str(tmp_path / f"{name}.safetensors") for name in ("a", "b")}
quant = torch.tensor(list(json.dumps({"format": "int8_tensorwise"}).encode()), dtype=torch.uint8)
save_file({
"model.diffusion_model.quant.weight": torch.tensor([1, 2], dtype=torch.int8),
"model.diffusion_model.quant.weight_scale": torch.tensor(2.0),
"model.diffusion_model.quant.comfy_quant": quant,
"dense.weight": torch.tensor([2.0, 4.0]),
}, paths["a"])
save_file({
"model.diffusion_model.quant.weight": torch.tensor([3, 6], dtype=torch.int8),
"model.diffusion_model.quant.weight_scale": torch.tensor(2.0),
"model.diffusion_model.quant.comfy_quant": quant,
"dense.weight": torch.tensor([6.0, 8.0]),
}, paths["b"])
monkeypatch.setattr(merger.folder_paths, "get_full_path", lambda _kind, name: paths.get(name))
monkeypatch.setattr(merger.folder_paths, "get_folder_paths", lambda _kind: [str(tmp_path)])
monkeypatch.setattr(merger.folder_paths, "models_dir", str(tmp_path))
monkeypatch.setattr(merger, "prepare_for_large_operation", lambda *args: None)
monkeypatch.setattr(merger, "cleanup_after_operation", lambda: None)
params = _params("quantized_merge")
params.update({"model_a": "a", "model_b": "b"})
merger.MergerLogic.execute_merge(
{"model_a": "a", "model_b": "b"}, "Weight-Sum",
operations.TWO_MODEL_MODES, params, "diffusion_models",
)
output_path = tmp_path / "diffusion_models" / "quantized_merge.safetensors"
result = load_file(str(output_path))
key = "model.diffusion_model.quant.weight"
assert set(result) == {key, "dense.weight"}
assert result[key].tolist() == pytest.approx([4.0, 8.0])
assert result[key].dtype == torch.float32
assert result["dense.weight"].tolist() == pytest.approx([4.0, 6.0])
with safe_open(str(output_path), framework="pt", device="cpu") as output:
metadata = output.metadata()
assert metadata in (None, {})
+46
View File
@@ -1,6 +1,7 @@
import importlib
import csv
from io import StringIO
import json
import math
import sys
import types
@@ -331,6 +332,51 @@ def test_include_filter_limits_analysis_inventory(
assert sorted(streamed) == sorted(expected_keys * 2)
def test_diffusion_analysis_decodes_int8_tensorwise_and_skips_sidecars(
monkeypatch, tmp_path, analysis,
):
prefix = "model.diffusion_model.layer"
quant = torch.tensor(list(json.dumps({"format": "int8_tensorwise"}).encode()), dtype=torch.uint8)
paths = {name: str(tmp_path / f"{name}.safetensors") for name in ("a", "b")}
save_file({
f"{prefix}.weight": torch.tensor([1, 2], dtype=torch.int8),
f"{prefix}.weight_scale": torch.tensor(2.0),
f"{prefix}.comfy_quant": quant,
"dense.weight": torch.tensor([2.0, 4.0]),
}, paths["a"])
save_file({
f"{prefix}.weight": torch.tensor([3, 6], dtype=torch.int8),
f"{prefix}.weight_scale": torch.tensor(2.0),
f"{prefix}.comfy_quant": quant,
"dense.weight": torch.tensor([4.0, 8.0]),
}, paths["b"])
monkeypatch.setattr(analysis.folder_paths, "get_full_path", lambda _, name: paths[name])
monkeypatch.setattr(analysis, "prepare_for_large_operation", lambda *args, **kwargs: None)
monkeypatch.setattr(analysis, "cleanup_after_operation", lambda: None)
streamed = []
original_stream = analysis.MemoryEfficientSafeOpen.async_stream
def track_stream(handler, keys, *args, **kwargs):
streamed.append((handler.filename, tuple(keys)))
return original_stream(handler, keys, *args, **kwargs)
monkeypatch.setattr(analysis.MemoryEfficientSafeOpen, "async_stream", track_stream)
_, _, metrics_csv, _ = analysis.ModelAnalysisLogic.execute(
"a", "b", "diffusion_models", _params(),
)
rows = {row["key"]: row for row in csv.DictReader(StringIO(metrics_csv))}
assert set(rows) == {f"{prefix}.weight", "dense.weight"}
assert float(rows[f"{prefix}.weight"]["MAE"]) == pytest.approx(6.0)
assert float(rows["dense.weight"]["MAE"]) == pytest.approx(3.0)
for path, keys in streamed:
if path.endswith("a.safetensors") or path.endswith("b.safetensors"):
if f"{prefix}.weight" in keys:
assert f"{prefix}.weight_scale" in keys
assert f"{prefix}.comfy_quant" in keys
assert sum(f"{prefix}.weight" in keys or "dense.weight" in keys for _, keys in streamed) == 4
def test_embedding_similarity_alignment_is_explicit_opt_in(analysis):
a = torch.tensor([[1.0, 0.0], [0.0, 1.0]])
b = torch.tensor([[0.0, 1.0], [1.0, 0.0]])