Files
vllm-omni/tests/diffusion/models/auk/test_auk_vae_parity.py
T

255 lines
10 KiB
Python

#!/usr/bin/env python
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""AuK VAE parity: the numeric oracle for the vendored BigVGAN-flow codec.
Checks ``AuKVAE`` against the AuK reference implementation on real speech, with
both models loaded from the same released ``vae.safetensors``:
* encoder statistics (mean / log-std) agree,
* ``encode(sample=False)`` reproduces the reference mean path,
* ``encode(sample=True)`` reproduces ``encoding_and_normalization`` for a
matching RNG seed,
* ``decode`` reproduces ``denormalize`` + ``inference_from_latents``, in fp32
and (reported, not gated tightly) under bf16 autocast,
* the waveform survives a mean-path round trip.
Runs on GPU in fp32 and needs both the released checkpoint and the reference
``auk`` package importable, so it is a local_model test rather than CI. Point
``AUK_CKPT_DIR`` at the directory holding ``vae.safetensors`` and ``AUK_REF_WAV``
at a speech clip (default ``$AUK_CKPT_DIR/ref.wav``)::
AUK_CKPT_DIR=... AUK_REF_WAV=... python -m pytest \
tests/diffusion/models/auk/test_auk_vae_parity.py -v -s
Unlike its sibling directories this one carries no ``__init__.py`` on purpose: it would put
``tests/diffusion/models`` on ``sys.path`` ahead of site-packages, and this directory's own name
would then shadow the reference ``auk`` package the parity check imports.
"""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import pytest
import soundfile as sf
import torch
import torchaudio.functional as AF
from vllm_omni.diffusion.models.auk.auk_vae import AuKVAE
pytestmark = [pytest.mark.local_model, pytest.mark.diffusion, pytest.mark.gpu, pytest.mark.cuda]
# The released model.vae.model_init_kwargs, shared by both models under test.
VAE_CONFIG = {
"upsample_rates": [5, 4, 3, 2, 2, 2],
"upsample_kernel_sizes": [10, 8, 6, 4, 4, 4],
"upsample_initial_channel": 1536,
"resblock_kernel_sizes": [3, 7, 11],
"resblock_dilation_sizes": [[1, 3, 5], [1, 3, 5], [1, 3, 5]],
"downsample_rates": [2, 2, 2, 3, 4, 5],
"downsample_channels": [12, 24, 48, 96, 192, 384, 768],
"snake_logscale": True,
"latent_dim": 64,
"use_vae": True,
"causal": True,
"flow_hidden_channels": 256,
"act_causal": True,
}
SAMPLE_RATE = 24000
HOP_SIZE = 480
LATENT_DIM = 64
SEED = 1234
# Keep the clip short: the parity run shares its GPU with other jobs.
MAX_SECONDS = 4.0
TOLERANCE = 1e-5
DEVICE = "cuda"
_CKPT_DIR = os.environ.get("AUK_CKPT_DIR")
_VAE_PATH = Path(_CKPT_DIR) / "vae.safetensors" if _CKPT_DIR else None
_REF_WAV = Path(os.environ.get("AUK_REF_WAV") or (Path(_CKPT_DIR) / "ref.wav" if _CKPT_DIR else "unset"))
def _reference_importable() -> bool:
"""Resolve ``auk.model``, not ``auk``.
An ``__init__.py`` in this directory would make its own name resolve as ``auk`` and satisfy a
top-level probe, leaving the fixtures to fail at setup instead of skipping. The submodule
probe fails in that case, which is the honest answer.
"""
try:
return importlib.util.find_spec("auk.model") is not None
except (ImportError, ValueError):
return False
def _skip_reason() -> str | None:
if not _CKPT_DIR:
return "set AUK_CKPT_DIR to the directory holding vae.safetensors"
if _VAE_PATH is None or not _VAE_PATH.is_file():
return f"no vae.safetensors under {_CKPT_DIR}"
if not _REF_WAV.is_file():
return f"no reference clip at {_REF_WAV} (set AUK_REF_WAV)"
if not _reference_importable():
return "cannot import the reference auk.model package (an __init__.py here would shadow it)"
if not torch.cuda.is_available():
return "parity runs in fp32 on GPU"
return None
pytestmark.append(pytest.mark.skipif(_skip_reason() is not None, reason=_skip_reason() or ""))
def _max_abs(lhs: torch.Tensor, rhs: torch.Tensor) -> float:
return (lhs.float() - rhs.float()).abs().max().item()
def _snr_db(signal: torch.Tensor, estimate: torch.Tensor) -> float:
noise = (signal.float() - estimate.float()).pow(2).sum()
return (10.0 * torch.log10(signal.float().pow(2).sum() / noise)).item()
@pytest.fixture(scope="module")
def clip() -> torch.Tensor:
"""Mono 24 kHz speech, trimmed to a whole number of latent frames."""
audio, sample_rate = sf.read(str(_REF_WAV), dtype="float32", always_2d=True) # (T, C)
wav = torch.from_numpy(audio).mean(dim=1, keepdim=True).transpose(0, 1)
if sample_rate != SAMPLE_RATE:
wav = AF.resample(wav, sample_rate, SAMPLE_RATE)
samples = min(int(MAX_SECONDS * SAMPLE_RATE), wav.size(-1)) // HOP_SIZE * HOP_SIZE
wav = wav[:, :samples].contiguous().to(DEVICE)
print(f"\n[clip] {_REF_WAV.name}: {samples} samples, {samples / SAMPLE_RATE:.2f} s, {samples // HOP_SIZE} frames")
return wav
@pytest.fixture(scope="module")
def reference(clip: torch.Tensor) -> dict[str, torch.Tensor]:
"""Reference tensors from the upstream model, which is then released so one model fits at a time."""
from auk.model.vae import load_vae_model
from auk.model.vae.bigvgan_flow_vae import BigVGANFlowVAEConfig
model = load_vae_model(
vae_name="BigVGANFlowVAE",
vae_cfg=BigVGANFlowVAEConfig.from_dict(VAE_CONFIG),
vae_ckpt=str(_VAE_PATH),
map_location="cpu",
)
model = model.to(DEVICE).eval()
model.requires_grad_(False)
with torch.no_grad():
mean, log_std = model.audio_encoder(clip.unsqueeze(1)).chunk(2, 1)
scale = torch.sqrt(model.global_log_std.float())
mean_latents = (mean.transpose(1, 2).float() - model.global_mean.float()) / scale
torch.manual_seed(SEED)
sampled_latents, _ = model.encoding_and_normalization(clip.unsqueeze(1))
decoded = model.inference_from_latents(model.denormalize(mean_latents).permute(0, 2, 1))
tensors = {
"mean": mean.clone(),
"log_std": log_std.clone(),
"mean_latents": mean_latents.clone(),
"sampled_latents": sampled_latents.clone(),
"decoded": decoded.squeeze(1).clone(),
}
del model
torch.accelerator.empty_cache()
return tensors
@pytest.fixture(scope="module")
def port(reference: dict[str, torch.Tensor]) -> tuple[AuKVAE, list[str], list[str]]:
"""The vendored module, loaded from the same checkpoint. Depends on ``reference`` for load ordering.
Placed on the accelerator before loading so weight norm is folded there; see ``load_weights``.
"""
model = AuKVAE.from_config(VAE_CONFIG).to(DEVICE)
missing, unexpected = model.load_weights(_VAE_PATH)
model = model.eval()
model.requires_grad_(False)
return model, missing, unexpected
def test_checkpoint_covers_every_inference_tensor(port: tuple[AuKVAE, list[str], list[str]]) -> None:
model, missing, unexpected = port
print(f"[keys] missing={len(missing)} unexpected={len(unexpected)}")
print(f"[keys] unexpected prefixes={sorted({key.split('.')[0] for key in unexpected})}")
assert missing == []
assert unexpected and all(key.startswith("flow.") for key in unexpected)
assert (model.hop_size, model.sample_rate, model.latent_dim) == (HOP_SIZE, SAMPLE_RATE, LATENT_DIM)
def test_encoder_statistics_match(
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
) -> None:
model = port[0]
with torch.no_grad():
mean, log_std = model.audio_encoder(clip.unsqueeze(1)).chunk(2, 1)
mean_diff = _max_abs(mean, reference["mean"])
log_std_diff = _max_abs(log_std, reference["log_std"])
print(f"[encoder] max|dmean|={mean_diff:.3e} max|dlog_std|={log_std_diff:.3e}")
assert mean_diff < TOLERANCE
assert log_std_diff < TOLERANCE
# Folded and hook-computed weights are bit-identical on one device, so the whole encoder is.
assert (mean_diff, log_std_diff) == (0.0, 0.0)
def test_encode_mean_path_matches(
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
) -> None:
model = port[0]
with torch.no_grad():
latents = model.encode(clip)
assert latents.shape == (1, clip.size(-1) // HOP_SIZE, LATENT_DIM)
diff = _max_abs(latents, reference["mean_latents"])
print(f"[encode mean] shape={tuple(latents.shape)} max|d|={diff:.3e}")
assert diff == 0.0
def test_encode_sampled_matches_seeded_reference(
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
) -> None:
model = port[0]
generator = torch.Generator(device=DEVICE)
generator.manual_seed(SEED)
with torch.no_grad():
latents = model.encode(clip, sample=True, generator=generator)
diff = _max_abs(latents, reference["sampled_latents"])
spread = _max_abs(latents, reference["mean_latents"])
print(f"[encode sample] max|d vs seeded reference|={diff:.3e} max|d vs mean path|={spread:.3e}")
assert diff < TOLERANCE
assert diff == 0.0 # a fresh device Generator replays the default one after torch.manual_seed
assert spread > TOLERANCE # the draw actually moved the latents
def test_decode_matches(reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]) -> None:
model = port[0]
latents = reference["mean_latents"]
with torch.no_grad():
wav = model.decode(latents)
assert wav.shape == (1, latents.size(1) * HOP_SIZE)
diff = _max_abs(wav, reference["decoded"])
print(f"[decode fp32] shape={tuple(wav.shape)} max|d|={diff:.3e}")
assert diff < TOLERANCE
assert diff == 0.0
with torch.no_grad(), torch.autocast(device_type="cuda", dtype=torch.bfloat16):
wav_bf16 = model.decode(latents)
bf16_diff = _max_abs(wav_bf16, reference["decoded"])
print(f"[decode bf16 autocast] max|d| vs fp32 reference={bf16_diff:.3e} snr={_snr_db(wav, wav_bf16):.2f} dB")
assert torch.isfinite(wav_bf16).all()
assert bf16_diff < 0.5 # sanity only: bf16 is reported, not held to fp32 parity
def test_round_trip_snr(clip: torch.Tensor, port: tuple[AuKVAE, list[str], list[str]]) -> None:
model = port[0]
with torch.no_grad():
wav = model.decode(model.encode(clip))
assert wav.shape == clip.shape
snr = _snr_db(clip, wav)
print(f"[round trip] snr={snr:.2f} dB peak={wav.abs().max().item():.4f}")
assert snr > 10.0