ajh-code's picture
Add files using upload-large-folder tool
c41750d verified
Raw
History Blame
11.7 kB
"""Project-local inference memory optimizations for the S2-Pro DAC codec.
The pinned Fish Speech source remains unchanged. This module builds the same
codec and loads the same checkpoint, then removes buffers that are unnecessary
for the window-limited inference path before the model is moved to CUDA.
"""
from __future__ import annotations
import gc
import io
import math
import os
import threading
import time
from pathlib import Path
from typing import Any
import torch
os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true")
from fish_speech.models.dac.modded_dac import DAC
def load_reference_audio_soundfile(
reference_audio: bytes | str | Path,
sample_rate: int,
):
"""Decode API reference audio without TorchCodec.
Torch 2.11 routes ``torchaudio.load`` through optional TorchCodec. The
pinned container already includes SoundFile, which supports the WAV/FLAC
inputs accepted by this service, so no environment package mutation is
needed.
"""
import numpy as np
import soundfile as sf
import torchaudio
source = (
io.BytesIO(reference_audio)
if isinstance(reference_audio, bytes)
else reference_audio
)
audio, original_rate = sf.read(source, dtype="float32", always_2d=True)
mono = np.asarray(audio.mean(axis=1), dtype=np.float32)
if original_rate != sample_rate:
mono = (
torchaudio.functional.resample(
torch.from_numpy(mono),
original_rate,
sample_rate,
)
.contiguous()
.numpy()
)
return mono
@torch.inference_mode()
def warm_reference_encoder(
codec: torch.nn.Module,
device: str | torch.device,
seconds: float = 1.0,
) -> dict[str, Any]:
"""Prime lazy codec state before the first user reference is cached.
The staged BF16 encoder produces a different discrete code sequence on its
first CUDA pass. A discarded silence pass makes subsequent encodes bit
stable, preventing the first uploaded voice from being conditioned on an
avoidable cold-start code path.
"""
if seconds <= 0:
raise ValueError("Reference warmup duration must be positive")
target = torch.device(device)
sample_rate = int(codec.sample_rate)
samples = int(round(sample_rate * seconds))
audio = torch.zeros((1, samples), dtype=torch.float32)
lengths = torch.tensor([samples], device=target, dtype=torch.long)
started = time.perf_counter()
codes = codec.encode(audio, lengths)[0][0].cpu()
return {
"input": "digital_silence",
"seconds": seconds,
"samples": samples,
"code_frames": int(codes.shape[-1]),
"elapsed_seconds": time.perf_counter() - started,
}
class StagedReferenceCodec(DAC):
"""DAC with decode modules resident and reference-only modules staged."""
@property
def device(self) -> torch.device:
return self._decode_device
def configure_reference_staging(
self,
decode_device: str | torch.device,
offload_device: str | torch.device = "cpu",
) -> None:
self._decode_device = torch.device(decode_device)
self._reference_offload_device = torch.device(offload_device)
self._reference_lock = threading.Lock()
# These modules are used by ``from_indices`` and remain resident.
self.quantizer.semantic_quantizer.to(self._decode_device)
self.quantizer.quantizer.to(self._decode_device)
self.quantizer.post_module.to(self._decode_device)
self.quantizer.upsample.to(self._decode_device)
self.decoder.to(self._decode_device)
# These modules are required only while a new reference is encoded.
self.encoder.to(self._reference_offload_device)
self.quantizer.downsample.to(self._reference_offload_device)
self.quantizer.pre_module.to(self._reference_offload_device)
@torch.inference_mode()
def encode(
self,
audio_data: torch.Tensor,
audio_lengths: torch.Tensor | None = None,
n_quantizers: int | None = None,
**kwargs,
):
"""Encode reference codes, staging only the required modules on CUDA."""
if not hasattr(self, "_reference_lock"):
return super().encode(
audio_data,
audio_lengths=audio_lengths,
n_quantizers=n_quantizers,
**kwargs,
)
with self._reference_lock:
reference_modules = (
self.encoder,
self.quantizer.downsample,
self.quantizer.pre_module,
)
for module in reference_modules:
module.to(self._decode_device)
try:
dtype = next(self.encoder.parameters()).dtype
audio_data = audio_data.to(device=self._decode_device, dtype=dtype)
if audio_data.ndim == 2:
audio_data = audio_data.unsqueeze(1)
length = audio_data.shape[-1]
right_pad = (
math.ceil(length / self.frame_length) * self.frame_length - length
)
audio_data = torch.nn.functional.pad(audio_data, (0, right_pad))
if audio_lengths is None:
audio_lengths = torch.tensor(
[length + right_pad],
device=self._decode_device,
dtype=torch.long,
)
else:
audio_lengths = audio_lengths.to(self._decode_device)
z = self.encoder(audio_data)
z = self.quantizer.downsample(z)
z = self.quantizer.pre_module(z)
semantic_z, semantic_codes, *_ = self.quantizer.semantic_quantizer(z)
residual_z = z - semantic_z
_, residual_codes, *_ = self.quantizer.quantizer(
residual_z,
n_quantizers=n_quantizers,
)
indices = torch.cat([semantic_codes, residual_codes], dim=1)
indices_lens = torch.ceil(audio_lengths / self.frame_length).long()
finally:
if self._decode_device.type == "cuda":
torch.cuda.synchronize(self._decode_device)
for module in reference_modules:
module.to(self._reference_offload_device)
if self._decode_device.type == "cuda":
torch.cuda.empty_cache()
return indices, indices_lens
def _tensor_bytes(tensor: torch.Tensor | None) -> int:
if tensor is None:
return 0
return tensor.numel() * tensor.element_size()
@torch.inference_mode()
def compact_codec_inference_buffers(codec: torch.nn.Module) -> dict[str, Any]:
"""Remove dead causal masks and bound RoPE tables to configured limits.
``WindowLimitedTransformer.forward`` always constructs an exact mask for
the current input and passes it to its parent implementation. Therefore the
inherited 32768-square causal mask is not read on this path. Its RoPE table
is used, but the configured block size is the model's supported inference
limit and is far smaller than the inherited 327680-frame table.
"""
from fish_speech.models.dac.modded_dac import WindowLimitedTransformer
records: list[dict[str, Any]] = []
saved_bytes = 0
for name, module in codec.named_modules():
if not isinstance(module, WindowLimitedTransformer):
continue
causal_mask = module.causal_mask
freqs_cis = module.freqs_cis
if freqs_cis is None:
raise RuntimeError(f"Codec transformer {name} has no RoPE table")
frame_limit = int(module.config.block_size)
if frame_limit <= 0 or frame_limit > freqs_cis.shape[0]:
raise RuntimeError(
f"Invalid codec RoPE limit for {name}: {frame_limit} "
f"of {freqs_cis.shape[0]}"
)
before_mask_bytes = _tensor_bytes(causal_mask)
before_rope_bytes = _tensor_bytes(freqs_cis)
device = freqs_cis.device
module.causal_mask = torch.empty(0, dtype=torch.bool, device=device)
module.freqs_cis = freqs_cis[:frame_limit].clone()
after_rope_bytes = _tensor_bytes(module.freqs_cis)
module._compact_inference_frame_limit = frame_limit
records.append(
{
"module": name,
"frame_limit": frame_limit,
"removed_causal_mask_bytes": before_mask_bytes,
"rope_bytes_before": before_rope_bytes,
"rope_bytes_after": after_rope_bytes,
}
)
saved_bytes += before_mask_bytes + before_rope_bytes - after_rope_bytes
if len(records) != 3:
raise RuntimeError(
f"Expected three window-limited codec transformers, found {len(records)}"
)
report = {
"policy": "compact_windowed_inference_buffers",
"windowed_transformers": len(records),
"theoretical_saved_bytes": saved_bytes,
"records": records,
}
codec._compact_inference_buffers_report = report
return report
@torch.inference_mode()
def load_compact_codec_model(
config_name: str,
checkpoint_path: str | Path,
device: str | torch.device = "cuda:0",
precision: torch.dtype = torch.bfloat16,
offload_reference: bool = False,
) -> torch.nn.Module:
"""Load the pinned codec with compact buffers before CUDA placement."""
from hydra.utils import instantiate
from omegaconf import OmegaConf
from fish_speech.models.dac import modded_dac as modded_dac_module
config_path = (
Path(modded_dac_module.__file__).resolve().parents[2]
/ "configs"
/ f"{config_name}.yaml"
)
cfg = OmegaConf.load(config_path)
if offload_reference:
cfg._target_ = "experimental.codec.StagedReferenceCodec"
codec = instantiate(cfg)
state_dict = torch.load(
checkpoint_path,
map_location="cpu",
mmap=True,
weights_only=True,
)
if "state_dict" in state_dict:
state_dict = state_dict["state_dict"]
if any("generator" in key for key in state_dict):
state_dict = {
key.replace("generator.", ""): value
for key, value in state_dict.items()
if "generator." in key
}
load_result = codec.load_state_dict(state_dict, strict=False, assign=True)
unexpected = [
key
for key in load_result.unexpected_keys
if not key.endswith(("causal_mask", "freqs_cis"))
]
if load_result.missing_keys or unexpected:
raise RuntimeError(
"Unexpected compact codec checkpoint mismatch: "
f"missing={load_result.missing_keys[:5]}, unexpected={unexpected[:5]}"
)
report = compact_codec_inference_buffers(codec)
codec.eval()
codec.to(dtype=precision)
if offload_reference:
codec.configure_reference_staging(device)
report["reference_path"] = "staged_from_cpu_to_decode_device"
else:
codec.to(device=device)
report["reference_path"] = "resident_on_decode_device"
codec._compact_inference_buffers_report = report
del state_dict
gc.collect()
if torch.cuda.is_available() and torch.device(device).type == "cuda":
torch.cuda.empty_cache()
return codec
__all__ = [
"StagedReferenceCodec",
"compact_codec_inference_buffers",
"load_compact_codec_model",
"load_reference_audio_soundfile",
"warm_reference_encoder",
]