507 lines
20 KiB
Python
507 lines
20 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""
|
|
Quantization quality gate for diffusion models.
|
|
|
|
Developers adding a new quantization method should:
|
|
1. Add their method + model to QUALITY_CONFIGS below
|
|
2. Set a max_lpips threshold (use 0.15 for image, 0.20 for video as defaults)
|
|
3. Run: pytest tests/diffusion/quantization/test_quantization_quality.py -v -m ""
|
|
4. Paste the output table into their PR description
|
|
|
|
The test generates outputs with both BF16 and the quantized method using the
|
|
same seed, computes similarity metrics, and fails if LPIPS exceeds the threshold.
|
|
|
|
Requirements:
|
|
pip install lpips
|
|
|
|
Example — run only FP8 tests:
|
|
pytest tests/diffusion/quantization/test_quantization_quality.py -v -m "" -k "fp8"
|
|
|
|
Example — run a specific model:
|
|
pytest tests/diffusion/quantization/test_quantization_quality.py -v -m "" -k "z_image"
|
|
|
|
Example — validate a local BF16 baseline against a local pre-quantized checkpoint:
|
|
export VLLM_OMNI_QUALITY_CONFIGS=/tmp/modelopt_quality_cases.json
|
|
pytest tests/diffusion/quantization/test_quantization_quality.py -v -m "" -k "qwen_image_2512"
|
|
|
|
Optional artifact dump:
|
|
export VLLM_OMNI_QUALITY_OUTPUT_DIR=/tmp/modelopt_quality_outputs
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gc
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
from PIL import Image
|
|
|
|
from tests.helpers.mark import hardware_marks
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
_BENCH_MODULE_PATH = _REPO_ROOT / "benchmarks" / "diffusion" / "quantization_quality.py"
|
|
_BENCH_MODULE_NAME = "benchmarks.diffusion.quantization_quality"
|
|
|
|
if _BENCH_MODULE_NAME not in sys.modules:
|
|
_spec = importlib.util.spec_from_file_location(_BENCH_MODULE_NAME, _BENCH_MODULE_PATH)
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
sys.modules[_BENCH_MODULE_NAME] = _mod
|
|
_spec.loader.exec_module(_mod)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration — add new quantization methods / models here
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class QualityTestConfig:
|
|
"""Defines a single quantization quality test case."""
|
|
|
|
id: str # pytest ID, e.g. "fp8_z_image"
|
|
task: str # "t2i" or "t2v"
|
|
prompt: str # generation prompt
|
|
max_lpips: float # fail threshold — higher = more lenient
|
|
model: str | None = None # HF model name
|
|
quantization: str | dict[str, object] | None = None # quantization method/config, e.g. "fp8"
|
|
baseline_model: str | None = None # explicit BF16/local baseline path
|
|
quantized_model: str | None = None # explicit quantized/local model path
|
|
height: int = 1024
|
|
width: int = 1024
|
|
num_inference_steps: int = 20 # keep low for CI speed
|
|
num_frames: int = 5 # only for t2v
|
|
seed: int = 42
|
|
gpu: str = "H100" # minimum GPU requirement
|
|
negative_prompt: str = ""
|
|
guidance_scale: float | None = None
|
|
|
|
def baseline_ref(self) -> str:
|
|
return self.baseline_model or self.model or ""
|
|
|
|
def quantized_ref(self) -> str:
|
|
if self.quantized_model is not None:
|
|
return self.quantized_model
|
|
return self.model or ""
|
|
|
|
def quantization_ref(self) -> str | None:
|
|
if self.quantized_model is not None:
|
|
return None
|
|
return self.quantization
|
|
|
|
def validate(self) -> None:
|
|
uses_explicit_models = self.baseline_model is not None or self.quantized_model is not None
|
|
uses_model_plus_method = self.model is not None or self.quantization is not None
|
|
|
|
if uses_explicit_models and uses_model_plus_method:
|
|
raise ValueError(f"{self.id}: explicit baseline/quantized paths cannot be mixed with model/quantization")
|
|
|
|
if uses_explicit_models:
|
|
if self.baseline_model is None or self.quantized_model is None:
|
|
raise ValueError(f"{self.id}: baseline_model and quantized_model must be provided together")
|
|
return
|
|
|
|
if self.model is None or self.quantization is None:
|
|
raise ValueError(f"{self.id}: expected either model+quantization or baseline_model+quantized_model")
|
|
|
|
|
|
# Add new quantization methods / models here.
|
|
# Developers: copy a config, change quantization + max_lpips, run the test.
|
|
QUALITY_CONFIGS = [
|
|
QualityTestConfig(
|
|
id="fp8_z_image",
|
|
model="Tongyi-MAI/Z-Image-Turbo",
|
|
quantization={
|
|
"method": "fp8",
|
|
"ignored_layers": [
|
|
"img_mlp",
|
|
*[
|
|
f"layers.{layer_id}.{suffix}"
|
|
for layer_id in range(15, 30)
|
|
for suffix in (
|
|
"attention.to_qkv",
|
|
"attention.to_out.0",
|
|
"feed_forward.w13",
|
|
"feed_forward.w2",
|
|
)
|
|
],
|
|
*[
|
|
f"model.layers.{layer_id}.{suffix}"
|
|
for layer_id in range(28, 36)
|
|
for suffix in (
|
|
"self_attn.q_proj",
|
|
"self_attn.k_proj",
|
|
"self_attn.v_proj",
|
|
"self_attn.o_proj",
|
|
"mlp.gate_proj",
|
|
"mlp.up_proj",
|
|
"mlp.down_proj",
|
|
)
|
|
],
|
|
],
|
|
},
|
|
task="t2i",
|
|
prompt=(
|
|
"A breathtaking twilight scene atop a floating archipelago of crystalline islands suspended in an "
|
|
"endless nebula-drenched sky, where waterfalls of liquid starlight cascade from the edges of each "
|
|
"island into the cosmic abyss below. The largest island hosts an ancient, overgrown observatory "
|
|
"crafted from pearlescent white stone and living silverwood trees whose bioluminescent leaves pulse "
|
|
"with soft cerulean and violet light. At the observatory's center stands a solitary figure—a young "
|
|
"astronomer in flowing robes woven from woven moonlight and deep-space fabric, their face illuminated "
|
|
"by the glow of a holographic star chart hovering above an altar of polished obsidian. The sky swirls "
|
|
"with the birth of a new galaxy: ribbons of magenta and gold gas clouds twist around nascent stars, "
|
|
"while distant supernovae bloom like cosmic flowers in the far reaches of the void. Below, the abyss "
|
|
"reveals faint echoes of forgotten civilizations—ghostly silhouettes of submerged cities shimmering "
|
|
"in layers of atmospheric haze. Cinematic volumetric lighting cuts through the scene as twin moons—one "
|
|
"copper-hued, one opalescent—rise on opposite horizons, casting long, intersecting shadows across "
|
|
"moss-covered ruins and crystalline flora that refract light into prismatic halos. Hyper-detailed, "
|
|
"photorealistic rendering with the atmospheric depth of Roger Deakins' cinematography, the "
|
|
"architectural grandeur of Zaha Hadid, and the cosmic wonder of James Jean's illustrations. Shot on "
|
|
"a mythical 150mm lens with shallow depth of field, 8K resolution, Unreal Engine 5 realism, subsurface "
|
|
"scattering on organic elements, and ray-traced reflections dancing across every water droplet in the "
|
|
"starlight waterfalls. Ethereal, melancholic, and transcendent mood—like a moment of quiet revelation "
|
|
"at the edge of existence."
|
|
),
|
|
max_lpips=0.15,
|
|
num_inference_steps=20,
|
|
),
|
|
QualityTestConfig(
|
|
id="fp8_flux",
|
|
model="black-forest-labs/FLUX.1-dev",
|
|
quantization="fp8",
|
|
task="t2i",
|
|
prompt="a cup of coffee on a wooden table, morning light",
|
|
max_lpips=0.20,
|
|
num_inference_steps=10,
|
|
),
|
|
QualityTestConfig(
|
|
id="fp8_qwen_image",
|
|
model="Qwen/Qwen-Image",
|
|
quantization="fp8",
|
|
task="t2i",
|
|
prompt="a cup of coffee on a wooden table, morning light",
|
|
max_lpips=0.35,
|
|
seed=142,
|
|
num_inference_steps=20,
|
|
),
|
|
QualityTestConfig(
|
|
id="fp8_ltx2",
|
|
model="Lightricks/LTX-2",
|
|
quantization="fp8",
|
|
task="t2v",
|
|
prompt="A serene lakeside sunrise with mist over the water",
|
|
max_lpips=0.10,
|
|
height=256,
|
|
width=256,
|
|
num_frames=25,
|
|
num_inference_steps=8,
|
|
),
|
|
]
|
|
|
|
|
|
def _load_extra_quality_configs() -> list[QualityTestConfig]:
|
|
config_path = os.environ.get("VLLM_OMNI_QUALITY_CONFIGS")
|
|
if not config_path:
|
|
return []
|
|
|
|
payload = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
|
if not isinstance(payload, list):
|
|
raise ValueError("VLLM_OMNI_QUALITY_CONFIGS must point to a JSON list")
|
|
|
|
configs: list[QualityTestConfig] = []
|
|
for item in payload:
|
|
if not isinstance(item, dict):
|
|
raise ValueError("Each quality config entry must be a JSON object")
|
|
config = QualityTestConfig(**item)
|
|
config.validate()
|
|
configs.append(config)
|
|
return configs
|
|
|
|
|
|
def _all_quality_configs() -> list[QualityTestConfig]:
|
|
configs = [*QUALITY_CONFIGS, *_load_extra_quality_configs()]
|
|
for config in configs:
|
|
config.validate()
|
|
return configs
|
|
|
|
|
|
def _output_path(output_dir: Path, config: QualityTestConfig, label: str, suffix: str) -> Path:
|
|
safe_id = config.id.replace("/", "_")
|
|
return output_dir / f"{safe_id}_{label}{suffix}"
|
|
|
|
|
|
def _maybe_save_output(output_dir: Path | None, config: QualityTestConfig, label: str, output) -> None:
|
|
if output_dir is None:
|
|
return
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
if config.task == "t2i":
|
|
output.save(_output_path(output_dir, config, label, ".png"))
|
|
return
|
|
|
|
if isinstance(output, np.ndarray):
|
|
np.save(_output_path(output_dir, config, label, ".npy"), output)
|
|
return
|
|
|
|
raise TypeError(f"Unsupported output type for saving: {type(output)!r}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _generate_image(omni, config: QualityTestConfig):
|
|
"""Generate a single image, return (PIL.Image, peak_mem_gib)."""
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
generator = torch.Generator(
|
|
device=current_omni_platform.device_type,
|
|
).manual_seed(config.seed)
|
|
torch.accelerator.reset_peak_memory_stats()
|
|
|
|
outputs = omni.generate(
|
|
{"prompt": config.prompt, "negative_prompt": config.negative_prompt},
|
|
OmniDiffusionSamplingParams(
|
|
height=config.height,
|
|
width=config.width,
|
|
generator=generator,
|
|
num_inference_steps=config.num_inference_steps,
|
|
guidance_scale=config.guidance_scale,
|
|
),
|
|
)
|
|
|
|
peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)
|
|
first = outputs[0]
|
|
if hasattr(first, "images") and first.images:
|
|
return first.images[0], peak_mem
|
|
inner = first.request_output
|
|
if inner is not None and hasattr(inner, "images") and inner.images:
|
|
return inner.images[0], peak_mem
|
|
raise ValueError("Could not extract image from output.")
|
|
|
|
|
|
def _generate_video(omni, config: QualityTestConfig):
|
|
"""Generate a video, return (np.ndarray [F,H,W,C], peak_mem_gib)."""
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
from vllm_omni.outputs import OmniRequestOutput
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
generator = torch.Generator(
|
|
device=current_omni_platform.device_type,
|
|
).manual_seed(config.seed)
|
|
torch.accelerator.reset_peak_memory_stats()
|
|
|
|
outputs = omni.generate(
|
|
{"prompt": config.prompt, "negative_prompt": config.negative_prompt},
|
|
OmniDiffusionSamplingParams(
|
|
height=config.height,
|
|
width=config.width,
|
|
generator=generator,
|
|
num_inference_steps=config.num_inference_steps,
|
|
num_frames=config.num_frames,
|
|
guidance_scale=config.guidance_scale,
|
|
),
|
|
)
|
|
|
|
peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)
|
|
first = outputs[0]
|
|
if hasattr(first, "request_output") and isinstance(first.request_output, list):
|
|
inner = first.request_output[0]
|
|
if isinstance(inner, OmniRequestOutput) and hasattr(inner, "images"):
|
|
frames = inner.images[0] if inner.images else None
|
|
else:
|
|
frames = inner
|
|
elif hasattr(first, "images") and first.images:
|
|
frames = first.images[0]
|
|
else:
|
|
raise ValueError("Could not extract video frames from output.")
|
|
|
|
# LTX-2 (audio+video) may surface (video, audio) tuples or {"video": ...} dicts
|
|
if isinstance(frames, dict):
|
|
frames = frames.get("video") or frames.get("frames")
|
|
elif isinstance(frames, tuple) and len(frames) == 2:
|
|
frames = frames[0]
|
|
|
|
if frames is None:
|
|
raise ValueError("Could not extract video frames from output.")
|
|
|
|
if isinstance(frames, torch.Tensor):
|
|
video = frames.detach().cpu()
|
|
if video.dim() == 5:
|
|
video = video[0]
|
|
if video.dim() == 4 and video.shape[0] in (3, 4):
|
|
video = video.permute(1, 2, 3, 0)
|
|
if video.is_floating_point():
|
|
video = video.clamp(-1, 1) * 0.5 + 0.5
|
|
return video.float().numpy(), peak_mem
|
|
|
|
frames_array = np.asarray(frames)
|
|
if frames_array.ndim == 5:
|
|
# strip the leading batch dim
|
|
frames_array = frames_array[0]
|
|
return frames_array, peak_mem
|
|
|
|
|
|
def _compute_lpips(baseline, quantized, task: str) -> float:
|
|
"""Compute LPIPS between baseline and quantized outputs."""
|
|
from benchmarks.diffusion.quantization_quality import (
|
|
compute_lpips_images,
|
|
compute_lpips_video,
|
|
)
|
|
|
|
if task == "t2i":
|
|
return compute_lpips_images([baseline], [quantized])[0]
|
|
return compute_lpips_video(baseline, quantized)
|
|
|
|
|
|
def _to_float_array(output, task: str) -> np.ndarray:
|
|
if task == "t2i":
|
|
array = np.asarray(output.convert("RGB"), dtype=np.float32) / 255.0
|
|
else:
|
|
array = np.asarray(output, dtype=np.float32)
|
|
if array.max() > 1.0 or array.min() < 0.0:
|
|
array = np.clip(array, 0.0, 255.0) / 255.0
|
|
else:
|
|
array = np.clip(array, 0.0, 1.0)
|
|
return array
|
|
|
|
|
|
def _compute_psnr_and_mae(baseline, quantized, task: str) -> tuple[float, float]:
|
|
baseline_array = _to_float_array(baseline, task)
|
|
quantized_array = _to_float_array(quantized, task)
|
|
if baseline_array.shape != quantized_array.shape:
|
|
raise ValueError(
|
|
"Output shapes do not match for metric computation: "
|
|
f"baseline={baseline_array.shape}, quantized={quantized_array.shape}"
|
|
)
|
|
|
|
diff = baseline_array - quantized_array
|
|
mae = float(np.mean(np.abs(diff)))
|
|
mse = float(np.mean(np.square(diff)))
|
|
psnr = float("inf") if mse == 0.0 else float(20.0 * np.log10(1.0 / np.sqrt(mse)))
|
|
return psnr, mae
|
|
|
|
|
|
def _free_gpu_memory():
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.accelerator.empty_cache()
|
|
torch.accelerator.synchronize()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_benchmark_generate_image_unwraps_nested_omni_request_output(monkeypatch):
|
|
from benchmarks.diffusion.quantization_quality import _generate_image as benchmark_generate_image
|
|
from vllm_omni.outputs import OmniRequestOutput
|
|
from vllm_omni.platforms import current_omni_platform
|
|
|
|
monkeypatch.setattr(current_omni_platform, "device_type", "cpu", raising=False)
|
|
monkeypatch.setattr(torch.accelerator, "reset_peak_memory_stats", lambda: None, raising=False)
|
|
monkeypatch.setattr(torch.accelerator, "max_memory_allocated", lambda: 0, raising=False)
|
|
|
|
image = Image.new("RGB", (2, 2))
|
|
inner = OmniRequestOutput.from_diffusion(request_id="req", images=[image])
|
|
outer = OmniRequestOutput(
|
|
request_id="req",
|
|
stage_id=0,
|
|
final_output_type="image",
|
|
request_output=inner,
|
|
finished=True,
|
|
)
|
|
|
|
class DummyOmni:
|
|
def generate(self, *_args, **_kwargs):
|
|
return [outer]
|
|
|
|
args = SimpleNamespace(height=2, width=2, num_inference_steps=1)
|
|
output, _elapsed, peak_mem = benchmark_generate_image(DummyOmni(), args, "prompt", 42)
|
|
|
|
assert output is image
|
|
assert peak_mem == 0.0
|
|
|
|
|
|
_marks = hardware_marks(res={"cuda": "H100"})
|
|
_OUTPUT_DIR = Path(os.environ["VLLM_OMNI_QUALITY_OUTPUT_DIR"]) if "VLLM_OMNI_QUALITY_OUTPUT_DIR" in os.environ else None
|
|
|
|
|
|
def _quality_param(c: QualityTestConfig):
|
|
marks = list(_marks)
|
|
if c.id == "fp8_qwen_image":
|
|
marks.append(
|
|
pytest.mark.skip(reason="Qwen-Image FP8 quality gate temporarily disabled (see CI / issue tracker).")
|
|
)
|
|
return pytest.param(c, id=c.id, marks=marks)
|
|
|
|
|
|
@pytest.mark.full_model
|
|
@pytest.mark.diffusion
|
|
@pytest.mark.parametrize(
|
|
"config",
|
|
[_quality_param(c) for c in _all_quality_configs()],
|
|
)
|
|
def test_quantization_quality(config: QualityTestConfig):
|
|
"""Validate that quantized output stays within LPIPS threshold of BF16."""
|
|
from vllm_omni.entrypoints.omni import Omni
|
|
|
|
generate_fn = _generate_video if config.task == "t2v" else _generate_image
|
|
|
|
# --- BF16 baseline ---
|
|
omni_bl = Omni(model=config.baseline_ref())
|
|
baseline_out, bl_mem = generate_fn(omni_bl, config)
|
|
omni_bl.shutdown()
|
|
del omni_bl
|
|
_free_gpu_memory()
|
|
_maybe_save_output(_OUTPUT_DIR, config, "baseline", baseline_out)
|
|
|
|
# --- Quantized ---
|
|
quantization = config.quantization_ref()
|
|
if quantization is None:
|
|
omni_qt = Omni(model=config.quantized_ref())
|
|
else:
|
|
omni_qt = Omni(model=config.quantized_ref(), quantization_config=quantization)
|
|
quant_out, qt_mem = generate_fn(omni_qt, config)
|
|
omni_qt.shutdown()
|
|
del omni_qt
|
|
_free_gpu_memory()
|
|
_maybe_save_output(_OUTPUT_DIR, config, "quantized", quant_out)
|
|
|
|
# --- Similarity metrics ---
|
|
lpips_score = _compute_lpips(baseline_out, quant_out, config.task)
|
|
psnr_score, mae_score = _compute_psnr_and_mae(baseline_out, quant_out, config.task)
|
|
assert lpips_score <= config.max_lpips, (
|
|
f"LPIPS {lpips_score:.4f} exceeds threshold {config.max_lpips} "
|
|
f"for {config.quantization_ref() or 'pre-quantized checkpoint'} on {config.quantized_ref()}"
|
|
)
|
|
|
|
# --- Report ---
|
|
mem_reduction = (bl_mem - qt_mem) / bl_mem * 100 if bl_mem > 0 else 0
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Quantization Quality: {config.id}")
|
|
print(f"{'=' * 60}")
|
|
print(f" Baseline: {config.baseline_ref()}")
|
|
print(f" Quantized: {config.quantized_ref()}")
|
|
print(f" Method: {config.quantization_ref() or 'pre-quantized checkpoint'}")
|
|
print(f" LPIPS: {lpips_score:.4f} (threshold: {config.max_lpips})")
|
|
print(f" PSNR: {psnr_score:.4f} dB (higher is better)")
|
|
print(f" MAE: {mae_score:.6f} (lower is better)")
|
|
print(f" BF16 memory: {bl_mem:.2f} GiB")
|
|
print(f" Quant memory: {qt_mem:.2f} GiB ({mem_reduction:.0f}% reduction)")
|
|
print(f" Result: {'PASS' if lpips_score <= config.max_lpips else 'FAIL'}")
|
|
print(f"{'=' * 60}\n")
|
|
|
|
assert np.isfinite(psnr_score) or np.isinf(psnr_score), f"PSNR is invalid for {config.id}: {psnr_score}"
|
|
assert np.isfinite(mae_score), f"MAE is not finite for {config.id}: {mae_score}"
|