The dropdown already offered the realtime option while no asset backed it, so selecting it could only raise. The two tests that pinned that gap are retargeted, not deleted.
1586 lines
58 KiB
Python
1586 lines
58 KiB
Python
"""External model loading for VibeVoice.
|
|
|
|
Loads a VibeVoice model from a standalone weight file (safetensors / .bin /
|
|
.gguf) placed in ComfyUI's ``diffusion_models`` folder, binding the required
|
|
config / tokenizer / preprocessor JSONs via sidecar files or packaged defaults.
|
|
|
|
This module bypasses ComfyUI's ``model_detection.detect_unet_config()`` entirely
|
|
(VibeVoice's state-dict keys are not recognized by it) and instead reuses the
|
|
existing :class:`~modules.loader.VibeVoiceLoader` internals for config,
|
|
tokenizer, processor, and model instantiation.
|
|
|
|
Sidecar file convention (place next to the weight file):
|
|
- ``<weight>.config.json`` → architecture config (preferred)
|
|
- ``config.json`` → architecture config (same directory)
|
|
- ``<weight>.preprocessor.json`` → audio preprocessor config (preferred)
|
|
- ``preprocessor_config.json`` → audio preprocessor config (same dir)
|
|
- ``tokenizer.json`` → Qwen2.5 text tokenizer (same dir)
|
|
|
|
The ``config_name`` dropdown selects the loading BRANCH (TTS vs ASR). Inside
|
|
the ASR branch the checkpoint's own ``model_type`` selects the model FAMILY,
|
|
because two incompatible ASR families are loadable there and the config JSON
|
|
is the only thing that can tell them apart:
|
|
|
|
- ``"vibevoice_asr"`` → the transformers >= 5.3.0 classes
|
|
(``AutoConfig`` / ``VibeVoiceAsrForConditionalGeneration``), which build
|
|
the ``language_model.model.*`` tree the published ``VibeVoice-ASR-HF``
|
|
checkpoints store. See :mod:`modules.asr_native`.
|
|
- ``"vibevoice"`` → the vendored ``src/vibevoice`` classes
|
|
(``VibeVoiceASRConfig`` / ``VibeVoiceASRForConditionalGeneration``),
|
|
which build the ``model.language_model.*`` tree of the original
|
|
``microsoft/VibeVoice-ASR`` checkpoints.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import struct
|
|
import logging
|
|
import contextlib
|
|
import gc
|
|
|
|
import torch
|
|
|
|
import comfy.utils
|
|
import comfy.model_management as model_management
|
|
|
|
from transformers import BitsAndBytesConfig
|
|
|
|
from ..src.vibevoice.modular.configuration_vibevoice import VibeVoiceConfig, VibeVoiceASRConfig
|
|
from ..src.vibevoice.modular.configuration_vibevoice_streaming import VibeVoiceStreamingConfig
|
|
from ..src.vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration
|
|
from ..src.vibevoice.modular.modular_vibevoice_text_tokenizer import VibeVoiceASRTextTokenizerFast
|
|
from ..src.vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor
|
|
from ..src.vibevoice.processor.vibevoice_tokenizer_processor import VibeVoiceTokenizerProcessor
|
|
|
|
from .loader import VibeVoiceLoader, QUANT_STORAGE_DTYPES
|
|
from .base_loader import iter_safetensors_tensors, place_tensor_on_device
|
|
from .asr_native import (
|
|
NATIVE_ASR_MODEL_TYPE,
|
|
build_native_asr_processor,
|
|
is_native_asr_config_path,
|
|
load_native_asr_config,
|
|
instantiate_native_asr_model,
|
|
)
|
|
from .attention_utils import (
|
|
SAGE_ATTENTION_AVAILABLE,
|
|
resolve_attention_mode,
|
|
resolve_realtime_attention_mode,
|
|
resolve_asr_attention_mode,
|
|
get_attn_implementation_for_load,
|
|
check_sage_attention_compatible,
|
|
)
|
|
from .dtype_utils import resolve_dtype, cast_model_to_dtype_if_needed, set_config_dtype
|
|
from .convrot_quant import UnsupportedQuantFormat
|
|
from .quant_common import validate_weight_plan
|
|
from .diagnostics import diagnostics_enabled
|
|
from pathlib import Path
|
|
|
|
from .patcher import (
|
|
VibeVoiceASRPatcher,
|
|
VibeVoicePatcher,
|
|
dynamic_vram_available,
|
|
resolve_core_patcher_class,
|
|
select_patcher_class,
|
|
)
|
|
from .memory_census import measured_load, report_census
|
|
|
|
if SAGE_ATTENTION_AVAILABLE:
|
|
from ..src.vibevoice.modular.sage_attention_patch import set_sage_attention
|
|
|
|
|
|
_PACKAGED_CONFIG_FILES = {
|
|
"VibeVoice-1.5B": "default_VibeVoice-1.5B_config.json",
|
|
"VibeVoice-7B": "default_VibeVoice-Large_config.json",
|
|
"VibeVoice-Realtime-0.5B": "default_VibeVoice-Realtime-0.5B_config.json",
|
|
"VibeVoice-ASR": "default_VibeVoice-ASR_config.json",
|
|
}
|
|
|
|
AUTO_CONFIG_NAME = "Auto-detect"
|
|
|
|
EXTERNAL_CONFIG_OPTIONS = [
|
|
AUTO_CONFIG_NAME,
|
|
"VibeVoice-1.5B",
|
|
"VibeVoice-7B",
|
|
"VibeVoice-Realtime-0.5B",
|
|
"VibeVoice-ASR",
|
|
]
|
|
|
|
_LEGACY_CONFIG_ALIASES = {
|
|
"vibevoice-large": "VibeVoice-7B",
|
|
}
|
|
|
|
|
|
def normalize_config_name(config_name: str) -> str:
|
|
"""Map legacy/alias config_name values onto their canonical option."""
|
|
if not config_name:
|
|
return config_name
|
|
if config_name in EXTERNAL_CONFIG_OPTIONS:
|
|
return config_name
|
|
return _LEGACY_CONFIG_ALIASES.get(config_name.lower(), config_name)
|
|
|
|
|
|
def resolve_auto_config_name(weight_path: str, gguf_reader=None, weights_fp=None) -> str:
|
|
"""Resolve ``AUTO_CONFIG_NAME`` to a concrete family from the weights."""
|
|
if weights_fp is None:
|
|
from .config_detect import fingerprint_weights
|
|
|
|
weights_fp = fingerprint_weights(weight_path, gguf_reader=gguf_reader)
|
|
|
|
detected = weights_fp.config_name if weights_fp is not None else None
|
|
if not detected:
|
|
explicit = [o for o in EXTERNAL_CONFIG_OPTIONS if o != AUTO_CONFIG_NAME]
|
|
if weights_fp is None:
|
|
observed = "Observed dimensions: unavailable (no embedding fingerprint)."
|
|
else:
|
|
observed = (
|
|
"Observed dimensions: "
|
|
f"hidden={weights_fp.hidden_size}, vocab={weights_fp.vocab_size}."
|
|
)
|
|
raise ValueError(
|
|
f"config_name '{AUTO_CONFIG_NAME}': could not determine the "
|
|
f"VibeVoice architecture from "
|
|
f"'{os.path.basename(weight_path)}'. {observed} Auto-detect reads "
|
|
f"the checkpoint's embedding fingerprint; unmatched dimensions "
|
|
f"are not guessed. Select config_name explicitly (one of: "
|
|
f"{', '.join(explicit)}) or place a sidecar config.json next to "
|
|
f"the weight file."
|
|
)
|
|
logging.debug(
|
|
f"[VibeVoice TTS] Auto-detected architecture '{detected}' from "
|
|
f"'{os.path.basename(weight_path)}'"
|
|
)
|
|
return detected
|
|
|
|
|
|
def reconcile_config(selected_name: str, resolved_config, weights_fp):
|
|
"""Reconcile the selected config against the weights' fingerprint."""
|
|
from .config_detect import config_fingerprint
|
|
|
|
if weights_fp is None:
|
|
return selected_name, False
|
|
|
|
cfg_fp = config_fingerprint(resolved_config)
|
|
if cfg_fp is None or cfg_fp == (weights_fp.hidden_size, weights_fp.vocab_size):
|
|
return selected_name, False
|
|
|
|
detected = weights_fp.config_name
|
|
if not detected or detected == selected_name:
|
|
return selected_name, False
|
|
|
|
logging.warning(
|
|
f"[VibeVoice TTS] Config mismatch: weights contain '{weights_fp.source_key}' with "
|
|
f"shape (vocab={weights_fp.vocab_size}, hidden={weights_fp.hidden_size}) "
|
|
f"-> {detected}, but config '{selected_name}' "
|
|
f"(hidden={cfg_fp[0]}, vocab={cfg_fp[1]}) was selected. "
|
|
f"Using '{detected}'."
|
|
)
|
|
return detected, True
|
|
|
|
|
|
# ====================================================================
|
|
# Reconciliation memo
|
|
# ====================================================================
|
|
|
|
_RECONCILIATION_MEMO: dict = {}
|
|
|
|
|
|
def _reconciliation_memo_key(weight_path: str, selected_name: str):
|
|
abspath = os.path.abspath(weight_path) if weight_path else ""
|
|
mtime_ns = 0
|
|
size = 0
|
|
try:
|
|
stat = os.stat(abspath)
|
|
mtime_ns = stat.st_mtime_ns
|
|
size = stat.st_size
|
|
except OSError:
|
|
pass
|
|
return (abspath, mtime_ns, size, selected_name)
|
|
|
|
|
|
def reconciled_config_name(weight_path: str, selected_name: str) -> str:
|
|
return _RECONCILIATION_MEMO.get(
|
|
_reconciliation_memo_key(weight_path, selected_name), selected_name
|
|
)
|
|
|
|
|
|
def remember_reconciled_config(
|
|
weight_path: str, selected_name: str, effective_name: str
|
|
) -> None:
|
|
if not effective_name:
|
|
return
|
|
_RECONCILIATION_MEMO[
|
|
_reconciliation_memo_key(weight_path, selected_name)
|
|
] = effective_name
|
|
|
|
|
|
def clear_reconciliation_memo() -> None:
|
|
_RECONCILIATION_MEMO.clear()
|
|
|
|
|
|
# ====================================================================
|
|
# Path resolution helpers
|
|
# ====================================================================
|
|
|
|
def _packaged_configs_dir() -> str:
|
|
return os.path.join(
|
|
os.path.dirname(__file__), "..", "src", "vibevoice", "configs"
|
|
)
|
|
|
|
|
|
def _get_packaged_config_path(config_name: str) -> str:
|
|
filename = _PACKAGED_CONFIG_FILES.get(config_name)
|
|
if filename is None:
|
|
return ""
|
|
return os.path.normpath(os.path.join(_packaged_configs_dir(), filename))
|
|
|
|
|
|
def resolve_sidecar_config(weight_path: str, config_name: str) -> str:
|
|
sidecar_path = weight_path + ".config.json"
|
|
if os.path.exists(sidecar_path):
|
|
logging.debug(f"[VibeVoice TTS] Using sidecar config: {sidecar_path}")
|
|
return sidecar_path
|
|
|
|
dir_sidecar = os.path.join(os.path.dirname(weight_path), "config.json")
|
|
if os.path.exists(dir_sidecar):
|
|
logging.debug(f"[VibeVoice TTS] Using directory sidecar config: {dir_sidecar}")
|
|
return dir_sidecar
|
|
|
|
packaged = _get_packaged_config_path(config_name)
|
|
if packaged and os.path.exists(packaged):
|
|
logging.debug(f"[VibeVoice TTS] Using packaged default config for '{config_name}': {packaged}")
|
|
return packaged
|
|
|
|
raise FileNotFoundError(
|
|
f"No architecture config found for external model "
|
|
f"'{os.path.basename(weight_path)}'.\n"
|
|
f"Place a sidecar config next to the weight file:\n"
|
|
f" - '{sidecar_path}' (preferred), or\n"
|
|
f" - 'config.json' in '{os.path.dirname(weight_path)}'\n"
|
|
f"Alternatively select a config_name with a packaged default "
|
|
f"({', '.join(_PACKAGED_CONFIG_FILES.keys())})."
|
|
)
|
|
|
|
|
|
def resolve_sidecar_preprocessor(weight_path: str) -> str:
|
|
sidecar_path = weight_path + ".preprocessor.json"
|
|
if os.path.exists(sidecar_path):
|
|
logging.debug(f"[VibeVoice TTS] Using sidecar preprocessor config: {sidecar_path}")
|
|
return sidecar_path
|
|
|
|
dir_sidecar = os.path.join(os.path.dirname(weight_path), "preprocessor_config.json")
|
|
if os.path.exists(dir_sidecar):
|
|
logging.debug(f"[VibeVoice TTS] Using directory sidecar preprocessor config: {dir_sidecar}")
|
|
return dir_sidecar
|
|
|
|
return ""
|
|
|
|
|
|
def resolve_sidecar_tokenizer_dir(weight_path: str) -> str:
|
|
return os.path.dirname(weight_path)
|
|
|
|
|
|
# ====================================================================
|
|
# ASR-specific helpers
|
|
# ====================================================================
|
|
|
|
ASR_CONFIG_NAMES = {"VibeVoice-ASR"}
|
|
|
|
|
|
def is_asr_config_name(config_name: str) -> bool:
|
|
return config_name in ASR_CONFIG_NAMES
|
|
|
|
|
|
def _load_asr_config(config_path: str) -> "VibeVoiceASRConfig":
|
|
if not os.path.exists(config_path):
|
|
raise FileNotFoundError(f"ASR config not found: {config_path}")
|
|
if is_native_asr_config_path(config_path):
|
|
return load_native_asr_config(config_path)
|
|
return VibeVoiceASRConfig.from_pretrained(config_path)
|
|
|
|
|
|
def _load_asr_tokenizer(tokenizer_dir: str) -> "VibeVoiceASRTextTokenizerFast":
|
|
tokenizer_file_path = os.path.join(tokenizer_dir, "tokenizer.json")
|
|
|
|
if not os.path.exists(tokenizer_file_path):
|
|
packaged_tokenizer_path = os.path.join(
|
|
_packaged_configs_dir(), "tokenizer.json"
|
|
)
|
|
if os.path.exists(packaged_tokenizer_path):
|
|
logging.debug(
|
|
f"[VibeVoice TTS] Using packaged tokenizer.json fallback for ASR: {packaged_tokenizer_path}"
|
|
)
|
|
tokenizer_file_path = packaged_tokenizer_path
|
|
else:
|
|
raise FileNotFoundError(
|
|
f"No 'tokenizer.json' found for external ASR model. "
|
|
f"Place a 'tokenizer.json' next to the weight file in "
|
|
f"'{tokenizer_dir}'."
|
|
)
|
|
|
|
return VibeVoiceASRTextTokenizerFast(tokenizer_file=tokenizer_file_path)
|
|
|
|
|
|
def _load_asr_processor(
|
|
tokenizer,
|
|
preprocessor_config_path: str,
|
|
) -> "VibeVoiceASRProcessor":
|
|
config = {}
|
|
if preprocessor_config_path and os.path.exists(preprocessor_config_path):
|
|
with open(preprocessor_config_path, "r", encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
|
|
speech_tok_compress_ratio = config.get("speech_tok_compress_ratio", 3200)
|
|
target_sample_rate = config.get("target_sample_rate", 24000)
|
|
normalize_audio = config.get("normalize_audio", True)
|
|
|
|
audio_processor = VibeVoiceTokenizerProcessor(
|
|
sampling_rate=target_sample_rate,
|
|
normalize_audio=normalize_audio,
|
|
target_dB_FS=config.get("target_dB_FS", -25),
|
|
eps=config.get("eps", 1e-6),
|
|
)
|
|
|
|
return VibeVoiceASRProcessor(
|
|
tokenizer=tokenizer,
|
|
audio_processor=audio_processor,
|
|
speech_tok_compress_ratio=speech_tok_compress_ratio,
|
|
target_sample_rate=target_sample_rate,
|
|
normalize_audio=normalize_audio,
|
|
)
|
|
|
|
|
|
def _instantiate_asr_model(
|
|
config,
|
|
attn_implementation: str,
|
|
final_load_dtype: torch.dtype,
|
|
use_meta: bool = True,
|
|
):
|
|
if getattr(config, "model_type", None) == NATIVE_ASR_MODEL_TYPE:
|
|
return instantiate_native_asr_model(
|
|
config,
|
|
attn_implementation=attn_implementation,
|
|
final_load_dtype=final_load_dtype,
|
|
use_meta=use_meta,
|
|
)
|
|
|
|
if hasattr(config, "decoder_config"):
|
|
config.decoder_config._attn_implementation = attn_implementation
|
|
|
|
set_config_dtype(config, final_load_dtype)
|
|
if hasattr(config, "decoder_config"):
|
|
set_config_dtype(config.decoder_config, final_load_dtype)
|
|
|
|
ctx = torch.device("meta") if use_meta else contextlib.nullcontext()
|
|
with ctx:
|
|
return VibeVoiceASRForConditionalGeneration(config)
|
|
|
|
|
|
# ====================================================================
|
|
# Weight file state-dict loading (safetensors / bin / gguf dispatch)
|
|
# ====================================================================
|
|
|
|
def _load_gguf_state_dict(weight_path: str, device=None) -> dict:
|
|
if device is None:
|
|
device = torch.device("cpu")
|
|
|
|
try:
|
|
import gguf
|
|
except ImportError as e:
|
|
raise RuntimeError(
|
|
"Loading .gguf weights requires the 'gguf' Python package. "
|
|
"Install it with: pip install gguf"
|
|
) from e
|
|
|
|
logging.debug(f"[VibeVoice TTS] Loading GGUF state dict from: {weight_path}")
|
|
from .gguf_quant import dequantize_reader_tensor, open_gguf_reader
|
|
|
|
reader = open_gguf_reader(weight_path)
|
|
|
|
state_dict = {}
|
|
for tensor in reader.tensors:
|
|
dequantized = dequantize_reader_tensor(tensor)
|
|
state_dict[tensor.name] = dequantized.to(device)
|
|
|
|
logging.debug(f"[VibeVoice TTS] Loaded {len(state_dict)} tensors from GGUF file")
|
|
return state_dict
|
|
|
|
|
|
def _load_weight_state_dict(weight_path: str, device) -> dict:
|
|
if weight_path.lower().endswith(".gguf"):
|
|
return _load_gguf_state_dict(weight_path, device=device)
|
|
return comfy.utils.load_torch_file(weight_path, device=device)
|
|
|
|
|
|
# ====================================================================
|
|
# Quant-resident loading (GGUF raw-block + ConvRot INT8)
|
|
# ====================================================================
|
|
|
|
def _open_gguf_reader(weight_path: str):
|
|
try:
|
|
import gguf # noqa: F401
|
|
except ImportError as e:
|
|
raise RuntimeError(
|
|
"Loading .gguf weights requires the 'gguf' Python package. "
|
|
"Install it with: pip install gguf"
|
|
) from e
|
|
from .gguf_quant import open_gguf_reader
|
|
|
|
return open_gguf_reader(weight_path)
|
|
|
|
|
|
def _gguf_kquant_present(reader) -> bool:
|
|
from .gguf_quant import _T
|
|
|
|
kquants = {_T.Q4_K, _T.Q5_K, _T.Q6_K}
|
|
return any(t.tensor_type in kquants for t in reader.tensors)
|
|
|
|
|
|
_QUANT_STORAGE_DTYPES = QUANT_STORAGE_DTYPES
|
|
|
|
|
|
def _plan_quantized_safetensors_load(quant_map: dict):
|
|
from .convrot_quant import make_convrot_linear
|
|
from .fp8_quant import make_fp8_linear
|
|
|
|
layer_plan = {}
|
|
n_rowwise = 0
|
|
n_fp8_resident = 0
|
|
for prefix, info in quant_map.items():
|
|
if info.convrot:
|
|
layer_plan[prefix] = make_convrot_linear(info)
|
|
elif info.resident_fp8:
|
|
layer_plan[prefix] = make_fp8_linear(info)
|
|
n_fp8_resident += 1
|
|
else:
|
|
n_rowwise += 1
|
|
return layer_plan, n_rowwise, n_fp8_resident
|
|
|
|
|
|
def _dequantize_rowwise_weight(prefix: str, info, w, s):
|
|
from .convrot_quant import resolve_orig_dtype
|
|
from .quant_common import QuantTargetMismatch
|
|
|
|
storage_dtype = info.rowwise_dtype or torch.int8
|
|
if w.dtype != storage_dtype and w.dtype != torch.uint8:
|
|
raise QuantTargetMismatch(
|
|
f"Rowwise layer '{prefix}': expected {storage_dtype} storage, "
|
|
f"checkpoint has {w.dtype}"
|
|
)
|
|
if w.dim() != 2 or w.shape[0] != info.out_features \
|
|
or (info.in_features and w.shape[1] != info.in_features):
|
|
raise QuantTargetMismatch(
|
|
f"Rowwise layer '{prefix}': weight shape {tuple(w.shape)} "
|
|
f"disagrees with metadata [out={info.out_features}, "
|
|
f"in={info.in_features}]"
|
|
)
|
|
orig_dtype = resolve_orig_dtype(info.orig_dtype)
|
|
|
|
if info.group_size:
|
|
gs = int(info.group_size)
|
|
if w.shape[0] % gs or w.shape[1] % gs:
|
|
raise QuantTargetMismatch(
|
|
f"Blockwise layer '{prefix}': weight {tuple(w.shape)} "
|
|
f"not divisible by group_size {gs} on both dims"
|
|
)
|
|
og, ig = w.shape[0] // gs, w.shape[1] // gs
|
|
if tuple(s.shape) != (og, ig):
|
|
raise QuantTargetMismatch(
|
|
f"Blockwise layer '{prefix}': scale shape "
|
|
f"{tuple(s.shape)} disagrees with weight "
|
|
f"{tuple(w.shape)} at group_size {gs} (expected "
|
|
f"({og}, {ig}))"
|
|
)
|
|
return (
|
|
(
|
|
w.to(torch.float32).view(og, gs, ig, gs)
|
|
* s.to(torch.float32).view(og, 1, ig, 1)
|
|
).reshape(w.shape).to(orig_dtype)
|
|
)
|
|
|
|
if not (s.dim() == 0 or (s.dim() == 2 and s.shape[1] == 1
|
|
and s.shape[0] in (w.shape[0], 1))):
|
|
raise QuantTargetMismatch(
|
|
f"Rowwise layer '{prefix}': scale shape {tuple(s.shape)} "
|
|
f"is neither a scalar nor [out,1] (weight "
|
|
f"{tuple(w.shape)})"
|
|
)
|
|
return (w.to(torch.float32) * s.to(torch.float32)).to(orig_dtype)
|
|
|
|
|
|
def _prepare_quantized_safetensors_load(state_dict: dict, quant_map: dict):
|
|
from .convrot_quant import QUANT_META_SUFFIX
|
|
from .quant_common import QuantTargetMismatch
|
|
|
|
layer_plan, n_rowwise, n_fp8_resident = _plan_quantized_safetensors_load(
|
|
quant_map
|
|
)
|
|
for prefix, info in quant_map.items():
|
|
if info.convrot:
|
|
continue
|
|
|
|
w_key = f"{prefix}.weight"
|
|
s_key = f"{prefix}.weight_scale"
|
|
w = state_dict.get(w_key)
|
|
s = state_dict.get(s_key)
|
|
if w is None or s is None:
|
|
raise QuantTargetMismatch(
|
|
f"Rowwise-quantized layer '{prefix}' is missing its "
|
|
f"'{w_key}' / '{s_key}' tensors in the checkpoint"
|
|
)
|
|
|
|
if info.resident_fp8:
|
|
if w.dtype != info.rowwise_dtype:
|
|
raise QuantTargetMismatch(
|
|
f"Resident fp8 layer '{prefix}': expected "
|
|
f"{info.rowwise_dtype} storage, checkpoint has {w.dtype}"
|
|
)
|
|
if not (s.dim() == 0 or (s.dim() == 1 and s.numel() == 1)):
|
|
raise QuantTargetMismatch(
|
|
f"Resident fp8 layer '{prefix}': scale shape "
|
|
f"{tuple(s.shape)} is not a per-tensor scalar"
|
|
)
|
|
continue
|
|
|
|
state_dict[w_key] = _dequantize_rowwise_weight(prefix, info, w, s)
|
|
state_dict.pop(s_key, None)
|
|
|
|
for prefix in quant_map:
|
|
state_dict.pop(f"{prefix}.{QUANT_META_SUFFIX}", None)
|
|
return layer_plan, n_rowwise, n_fp8_resident
|
|
|
|
|
|
# ====================================================================
|
|
# Streaming safetensors load
|
|
# ====================================================================
|
|
|
|
_SAFETENSORS_DTYPE_TO_TORCH = {
|
|
"F64": torch.float64, "F32": torch.float32, "F16": torch.float16,
|
|
"BF16": torch.bfloat16, "I64": torch.int64, "I32": torch.int32,
|
|
"I16": torch.int16, "I8": torch.int8, "U8": torch.uint8, "BOOL": torch.bool,
|
|
}
|
|
|
|
|
|
def read_safetensors_tensors_by_name(weight_path: str, names) -> dict:
|
|
"""Read the named tensors out of the header's byte ranges, one at a time.
|
|
|
|
``safe_open`` maps the entire file, and on Windows the first read through
|
|
such a mapping commits roughly one file size as private, untouched memory
|
|
that stays pinned for the lifetime of the returned tensor — charging a
|
|
9.5 GB checkpoint to host RAM to look up a few 4-byte scales. The header
|
|
already records each tensor's ``data_offsets``, so reading exactly those
|
|
byte ranges costs only the bytes asked for and touches no mapping.
|
|
"""
|
|
wanted = list(dict.fromkeys(names))
|
|
if not wanted:
|
|
return {}
|
|
remaining = set(wanted)
|
|
found = {}
|
|
with open(weight_path, "rb") as f:
|
|
(header_len,) = struct.unpack("<Q", f.read(8))
|
|
header = json.loads(f.read(header_len))
|
|
data_start = 8 + header_len
|
|
for name, info in header.items():
|
|
if name not in remaining:
|
|
continue
|
|
dtype = _SAFETENSORS_DTYPE_TO_TORCH.get(info.get("dtype"))
|
|
if dtype is None:
|
|
raise ValueError(
|
|
f"Checkpoint tensor '{name}' has unsupported safetensors "
|
|
f"dtype {info.get('dtype')!r}"
|
|
)
|
|
begin, end = info["data_offsets"]
|
|
buf = bytearray(end - begin)
|
|
f.seek(data_start + begin)
|
|
view = memoryview(buf)
|
|
while view:
|
|
read = f.readinto(view)
|
|
if not read:
|
|
raise ValueError(
|
|
f"Checkpoint tensor '{name}' is truncated: wanted "
|
|
f"{end - begin} bytes at offset {data_start + begin}, "
|
|
f"got {end - begin - len(view)}"
|
|
)
|
|
view = view[read:]
|
|
found[name] = torch.frombuffer(buf, dtype=dtype).reshape(
|
|
tuple(info["shape"])
|
|
)
|
|
remaining.discard(name)
|
|
return found
|
|
|
|
|
|
def _stream_apply_safetensors(
|
|
model, weight_path: str, quant_map: dict, target_device=None,
|
|
):
|
|
"""Assign a quantized safetensors checkpoint per-tensor.
|
|
|
|
Reads only the required scale tensors in pass 1, straight from their byte
|
|
ranges, and places every tensor onto target_device as it is assigned.
|
|
"""
|
|
from .convrot_quant import QUANT_META_SUFFIX
|
|
from .quant_common import QuantTargetMismatch
|
|
from .loader import VibeVoiceLoader, _shape_mismatch_error
|
|
|
|
resident_prefixes = {
|
|
p for p, info in quant_map.items()
|
|
if info.convrot or info.resident_fp8
|
|
}
|
|
dequant_infos = {
|
|
p: info for p, info in quant_map.items()
|
|
if not info.convrot and not info.resident_fp8
|
|
}
|
|
|
|
# Pass 1: read ONLY the required scale keys, straight from their byte
|
|
# ranges. safe_open here would commit the whole file as private RAM.
|
|
scales = {}
|
|
if dequant_infos:
|
|
scale_keys = {f"{prefix}.weight_scale" for prefix in dequant_infos}
|
|
read = read_safetensors_tensors_by_name(weight_path, scale_keys)
|
|
for prefix in dequant_infos:
|
|
s_key = f"{prefix}.weight_scale"
|
|
if s_key in read:
|
|
scales[prefix] = read[s_key]
|
|
else:
|
|
raise QuantTargetMismatch(
|
|
f"Rowwise-quantized layer '{prefix}' is missing its "
|
|
f"'{s_key}' tensor in the checkpoint"
|
|
)
|
|
|
|
params = dict(model.named_parameters())
|
|
buffers = dict(model.named_buffers())
|
|
unexpected = []
|
|
meta_suffix = f".{QUANT_META_SUFFIX}"
|
|
|
|
def _assign(key, tensor):
|
|
target = params.get(key)
|
|
if target is not None:
|
|
if tuple(tensor.shape) != tuple(target.shape):
|
|
raise _shape_mismatch_error(
|
|
model, [(key, tuple(tensor.shape), tuple(target.shape))]
|
|
)
|
|
tensor = place_tensor_on_device(tensor, target_device)
|
|
comfy.utils.set_attr_param(model, key, tensor)
|
|
return True
|
|
target_buf = buffers.get(key)
|
|
if target_buf is not None:
|
|
if tuple(tensor.shape) != tuple(target_buf.shape):
|
|
raise _shape_mismatch_error(
|
|
model, [(key, tuple(tensor.shape), tuple(target_buf.shape))]
|
|
)
|
|
tensor = place_tensor_on_device(tensor, target_device)
|
|
comfy.utils.set_attr_buffer(model, key, tensor)
|
|
return True
|
|
return False
|
|
|
|
with measured_load("stream-apply-safetensors") as _rss:
|
|
_rss.mark("stream-begin")
|
|
assigned = set()
|
|
for key, tensor in iter_safetensors_tensors(weight_path):
|
|
if not assigned:
|
|
_rss.mark("first-view")
|
|
if key.endswith(meta_suffix):
|
|
continue
|
|
prefix, _, leaf = key.rpartition(".")
|
|
if prefix in dequant_infos and leaf == "weight_scale":
|
|
continue
|
|
if prefix in dequant_infos and leaf == "weight":
|
|
tensor = _dequantize_rowwise_weight(
|
|
prefix, dequant_infos[prefix], tensor, scales[prefix]
|
|
)
|
|
else:
|
|
if tensor.dtype in _QUANT_STORAGE_DTYPES:
|
|
if prefix not in resident_prefixes \
|
|
or leaf not in ("weight", "weight_scale"):
|
|
raise ValueError(
|
|
f"Checkpoint contains quantized-weight tensors "
|
|
f"({key}: {tensor.dtype}) that no comfy_quant metadata "
|
|
f"declares. This node only loads quant formats it can "
|
|
f"execute; re-export the checkpoint or use a dense "
|
|
f"(bf16/fp16) file."
|
|
)
|
|
target = params.get(key)
|
|
if target is not None and tensor.dtype != target.dtype:
|
|
raise QuantTargetMismatch(
|
|
f"Resident layer '{prefix}': expected {target.dtype} "
|
|
f"storage for '{leaf}', checkpoint has {tensor.dtype}"
|
|
)
|
|
if _assign(key, tensor):
|
|
assigned.add(key)
|
|
else:
|
|
unexpected.append(key)
|
|
|
|
_rss.mark("stream-end")
|
|
report_census(model, phase=f"stream-end:{Path(weight_path).stem}")
|
|
|
|
expected = set(model.state_dict().keys())
|
|
missing_keys = [k for k in expected if k not in assigned]
|
|
return VibeVoiceLoader._post_assign_fixups(
|
|
model, missing_keys, unexpected
|
|
)
|
|
|
|
|
|
def _config_ties_word_embeddings(config) -> bool:
|
|
decoder_config = getattr(config, "decoder_config", None)
|
|
for cfg in (decoder_config, config):
|
|
if cfg is None:
|
|
continue
|
|
flag = getattr(cfg, "tie_word_embeddings", False)
|
|
if isinstance(flag, bool) and flag:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _assert_lm_head_not_tied(config, quant_map: dict) -> None:
|
|
from .quant_common import QuantTargetMismatch
|
|
|
|
if "lm_head" in quant_map and _config_ties_word_embeddings(config):
|
|
raise QuantTargetMismatch(
|
|
"Checkpoint quantizes 'lm_head' but the config sets "
|
|
"tie_word_embeddings=True: a tied lm_head shares the input "
|
|
"embedding's storage and cannot carry quantized weights. The "
|
|
"sidecar config.json likely does not match this checkpoint — "
|
|
"fix the config or re-export without quantizing lm_head."
|
|
)
|
|
|
|
|
|
def _assert_gguf_lm_head_not_tied(config, reader) -> None:
|
|
from .gguf_quant import FLOAT_GGML_TYPES
|
|
|
|
if not _config_ties_word_embeddings(config):
|
|
return
|
|
for t in reader.tensors:
|
|
if t.name in ("lm_head.weight", "output.weight") and t.tensor_type not in FLOAT_GGML_TYPES:
|
|
from .quant_common import QuantTargetMismatch
|
|
|
|
raise QuantTargetMismatch(
|
|
"GGUF file carries a quantized "
|
|
f"'{t.name}' (lm_head) but the "
|
|
"config sets tie_word_embeddings=True: a tied lm_head shares "
|
|
"the input embedding's storage and cannot carry quantized "
|
|
"weights. The sidecar config.json likely does not match "
|
|
"this checkpoint — fix the config or re-export without "
|
|
"quantizing lm_head."
|
|
)
|
|
|
|
|
|
def _demote_nonlinear_fp8_residents(model, quant_map: dict) -> dict:
|
|
from dataclasses import replace as dc_replace
|
|
from .quant_common import resolve_module
|
|
|
|
resolved = {}
|
|
for prefix, info in quant_map.items():
|
|
if info.resident_fp8:
|
|
try:
|
|
target = resolve_module(model, prefix)
|
|
except KeyError:
|
|
target = None
|
|
if target is not None and not isinstance(target, torch.nn.Linear):
|
|
logging.debug(
|
|
f"[VibeVoice TTS] fp8-resident layer '{prefix}' targets "
|
|
f"{type(target).__name__}, not nn.Linear — falling back "
|
|
f"to dequant-at-load"
|
|
)
|
|
info = dc_replace(info, resident_fp8=False)
|
|
resolved[prefix] = info
|
|
return resolved
|
|
|
|
|
|
def _assert_dense_loadable(state_dict: dict) -> None:
|
|
bad = [
|
|
(k, str(v.dtype))
|
|
for k, v in state_dict.items()
|
|
if v.dtype in _QUANT_STORAGE_DTYPES
|
|
]
|
|
if bad:
|
|
sample = ", ".join(f"{k} [{d}]" for k, d in bad[:5])
|
|
raise ValueError(
|
|
"Checkpoint contains quantized-weight tensors but carries no "
|
|
f"executable quantization metadata ({len(bad)} tensors, e.g. "
|
|
f"{sample}). Loading them as floats would corrupt the model. "
|
|
"Re-export it with *.comfy_quant metadata (comfy-model-tools) "
|
|
"or use the dense/BF16 checkpoint."
|
|
)
|
|
|
|
|
|
def _stream_apply_dense_safetensors(model, weight_path: str, target_device=None):
|
|
"""Assign a dense safetensors checkpoint per-tensor straight onto ``target_device``.
|
|
|
|
This is the dense counterpart of :func:`_stream_apply_safetensors` and the
|
|
single dense entry point for both the TTS and ASR routes: the same
|
|
zero-copy iterator, the same per-tensor placement, the same post-assign
|
|
fixups. Nothing ever materialises the whole checkpoint as a CPU model, so a
|
|
BF16/FP16 file reaches VRAM the same way an FP8 one does.
|
|
"""
|
|
from .loader import VibeVoiceLoader
|
|
|
|
with measured_load("dense-stream-apply") as _rss:
|
|
_rss.mark("stream-begin")
|
|
VibeVoiceLoader._stream_apply_dense(
|
|
model,
|
|
iter_safetensors_tensors(weight_path),
|
|
preserve_file_views=True,
|
|
target_device=target_device,
|
|
)
|
|
_rss.mark("stream-end")
|
|
report_census(model, phase=f"stream-end:{Path(weight_path).stem}")
|
|
|
|
|
|
def _install_gguf_weights(model, reader, target_device=None) -> dict:
|
|
import numpy as np
|
|
|
|
from .gguf_quant import (
|
|
FLOAT_GGML_TYPES,
|
|
GGUFTensor,
|
|
SUPPORTED_GGML_TYPES,
|
|
UnsupportedGGMLType,
|
|
dequantize_reader_tensor,
|
|
gguf_linear_factory,
|
|
map_keys,
|
|
)
|
|
from .quant_common import (
|
|
QuantTargetMismatch,
|
|
replace_linears_for_quant,
|
|
resolve_module,
|
|
)
|
|
|
|
tensors = list(reader.tensors)
|
|
mapping = map_keys([t.name for t in tensors])
|
|
modules_by_name = dict(model.named_modules())
|
|
|
|
resident_plan = {}
|
|
plan = []
|
|
unsupported = []
|
|
dequant_load = []
|
|
dequant_bytes = 0
|
|
|
|
for t in tensors:
|
|
target_key = mapping[t.name]
|
|
logical_shape = tuple(int(s) for s in reversed(t.shape))
|
|
tt = t.tensor_type
|
|
|
|
if tt in FLOAT_GGML_TYPES:
|
|
plan.append((t, "dense", target_key, None, 0))
|
|
continue
|
|
|
|
if tt not in SUPPORTED_GGML_TYPES:
|
|
unsupported.append((t.name, tt))
|
|
continue
|
|
|
|
if not target_key.endswith(".weight"):
|
|
raise QuantTargetMismatch(
|
|
f"Quantized GGUF tensor '{t.name}' maps to '{target_key}', "
|
|
f"which is not a Linear weight; this checkpoint cannot be "
|
|
f"executed with quant-resident linears."
|
|
)
|
|
module_path = target_key[: -len(".weight")]
|
|
target_module = modules_by_name.get(module_path)
|
|
if target_module is None:
|
|
raise QuantTargetMismatch(
|
|
f"GGUF-quantized tensor '{t.name}' maps to '{module_path}' "
|
|
f"(missing), expected an nn.Linear. The sidecar config may "
|
|
f"not match this checkpoint."
|
|
)
|
|
if isinstance(target_module, torch.nn.Linear):
|
|
expected_shape = tuple(target_module.weight.shape)
|
|
if logical_shape != expected_shape:
|
|
raise QuantTargetMismatch(
|
|
f"GGUF tensor '{t.name}' shape {logical_shape} disagrees "
|
|
f"with model {type(target_module).__name__} shape "
|
|
f"{expected_shape}"
|
|
)
|
|
plan.append((t, "resident", module_path, None, 0))
|
|
resident_plan[module_path] = gguf_linear_factory(tt)
|
|
continue
|
|
target_param = getattr(target_module, "weight", None)
|
|
if isinstance(target_param, torch.Tensor):
|
|
expected_shape = tuple(target_param.shape)
|
|
if logical_shape != expected_shape:
|
|
raise QuantTargetMismatch(
|
|
f"GGUF tensor '{t.name}' shape {logical_shape} disagrees "
|
|
f"with model {type(target_module).__name__} shape "
|
|
f"{expected_shape}"
|
|
)
|
|
dst = (
|
|
target_param.dtype
|
|
if target_param.dtype.is_floating_point
|
|
else torch.float32
|
|
)
|
|
nbytes = 1
|
|
for s in expected_shape:
|
|
nbytes *= int(s)
|
|
plan.append((t, "dense", target_key, dst, nbytes * dst.itemsize))
|
|
dequant_load.append(target_key)
|
|
dequant_bytes += nbytes * dst.itemsize
|
|
continue
|
|
kind = type(target_module).__name__
|
|
raise QuantTargetMismatch(
|
|
f"GGUF-quantized tensor '{t.name}' maps to '{module_path}' "
|
|
f"({kind}), expected an nn.Linear. The sidecar config may "
|
|
f"not match this checkpoint."
|
|
)
|
|
|
|
if unsupported:
|
|
name, tt = unsupported[0]
|
|
raise UnsupportedGGMLType(tt, f"{name}" + (
|
|
f" (+{len(unsupported) - 1} more)" if len(unsupported) > 1 else ""
|
|
))
|
|
|
|
replaced = replace_linears_for_quant(model, resident_plan)
|
|
resident_set = set(replaced)
|
|
|
|
total_raw = 0
|
|
for t, kind, target, _dst, _nbytes in plan:
|
|
if kind != "resident" or target not in resident_set:
|
|
continue
|
|
module = resolve_module(model, target)
|
|
raw = GGUFTensor.from_reader_tensor(t).raw
|
|
# The raw blocks ARE the model's storage for a quant-resident Linear,
|
|
# so they belong where the rest of the model belongs. Left on the host
|
|
# they cost ~8 GB of private RAM and every forward takes the paged
|
|
# `cast_bias_weight` path; in VRAM they cost the same bytes, load
|
|
# flat, and the forward dequantizes in place. Dequantizing to float at
|
|
# load instead would cost twice the VRAM and lose the point of a GGUF
|
|
# checkpoint.
|
|
module.set_raw_weight(place_tensor_on_device(raw, target_device))
|
|
total_raw += module.weight.numel()
|
|
|
|
def _dense_pairs():
|
|
for t, kind, target, dst, _nbytes in plan:
|
|
if kind != "dense":
|
|
continue
|
|
if dst is None:
|
|
arr = np.ascontiguousarray(t.data)
|
|
tensor = torch.from_numpy(
|
|
arr if arr.flags.writeable else arr.copy()
|
|
).clone()
|
|
if t.data.dtype == np.uint8:
|
|
tensor = tensor.view(torch.bfloat16)
|
|
shape = tuple(int(s) for s in reversed(t.shape))
|
|
yield target, tensor.reshape(shape)
|
|
else:
|
|
yield target, dequantize_reader_tensor(t, dst)
|
|
|
|
known_missing = {f"{p}.weight" for p in replaced}
|
|
VibeVoiceLoader._stream_apply_dense(
|
|
model, _dense_pairs(), known_missing=known_missing,
|
|
target_device=target_device,
|
|
)
|
|
|
|
if dequant_load:
|
|
logging.debug(
|
|
f"[VibeVoice TTS] Dequantized {len(dequant_load)} non-Linear GGUF weight(s) at "
|
|
f"load (embeddings/conv heads): "
|
|
f"{', '.join(dequant_load[:5])}"
|
|
+ (f" (+{len(dequant_load) - 5} more)" if len(dequant_load) > 5 else "")
|
|
)
|
|
|
|
return {
|
|
"n_resident_layers": len(replaced),
|
|
"raw_bytes": total_raw,
|
|
"n_dequant_load": len(dequant_load),
|
|
"dequant_load_bytes": int(dequant_bytes),
|
|
"weight_family": "gguf_block",
|
|
}
|
|
|
|
|
|
# ====================================================================
|
|
# Low-bit / naive quantization detection (defensive warning)
|
|
# ====================================================================
|
|
|
|
_SAFETENSORS_INT_DTYPES = {
|
|
"I8", "U8", "I16", "U16", "I32", "U32", "I64", "U64", "I4", "U4",
|
|
}
|
|
|
|
_QUANT_SCALE_NAME_HINTS = ("scale", "zero_point", "qzero", "absmax")
|
|
_LOWBIT_FRACTION_THRESHOLD = 0.10
|
|
|
|
|
|
def _inspect_safetensors_quantization(weight_path: str):
|
|
import struct
|
|
|
|
try:
|
|
with open(weight_path, "rb") as f:
|
|
(header_len,) = struct.unpack("<Q", f.read(8))
|
|
header = json.loads(f.read(header_len))
|
|
except Exception:
|
|
return None
|
|
|
|
header.pop("__metadata__", None)
|
|
total = len(header)
|
|
int_count = 0
|
|
has_scale_meta = False
|
|
for name, info in header.items():
|
|
if info.get("dtype", "") in _SAFETENSORS_INT_DTYPES:
|
|
int_count += 1
|
|
low = name.lower()
|
|
if any(hint in low for hint in _QUANT_SCALE_NAME_HINTS):
|
|
has_scale_meta = True
|
|
|
|
return {"total": total, "int_count": int_count, "has_scale_meta": has_scale_meta}
|
|
|
|
|
|
def _inspect_gguf_quantization(weight_path: str):
|
|
try:
|
|
import gguf # noqa: F401
|
|
from gguf.constants import GGMLQuantizationType
|
|
except ImportError:
|
|
return None
|
|
|
|
try:
|
|
from .gguf_quant import open_gguf_reader
|
|
|
|
reader = open_gguf_reader(weight_path)
|
|
except Exception:
|
|
return None
|
|
|
|
low_bit_types = {
|
|
GGMLQuantizationType.IQ1_S,
|
|
GGMLQuantizationType.IQ1_M,
|
|
GGMLQuantizationType.IQ2_XXS,
|
|
GGMLQuantizationType.IQ2_XS,
|
|
GGMLQuantizationType.IQ2_S,
|
|
GGMLQuantizationType.IQ3_XXS,
|
|
GGMLQuantizationType.IQ3_S,
|
|
}
|
|
total = len(reader.tensors)
|
|
low_bit_count = sum(1 for t in reader.tensors if t.tensor_type in low_bit_types)
|
|
return {"total": total, "low_bit_count": low_bit_count}
|
|
|
|
|
|
def warn_if_lowbit_quantization(weight_path: str) -> None:
|
|
lower = weight_path.lower()
|
|
|
|
if lower.endswith(".safetensors"):
|
|
info = _inspect_safetensors_quantization(weight_path)
|
|
if info is None or info["total"] == 0:
|
|
return
|
|
frac = info["int_count"] / info["total"]
|
|
if frac >= _LOWBIT_FRACTION_THRESHOLD and not info["has_scale_meta"]:
|
|
logging.warning(
|
|
f"[VibeVoice TTS] [low-bit check] '{os.path.basename(weight_path)}' stores "
|
|
f"{info['int_count']}/{info['total']} tensors as raw integers with "
|
|
f"NO dequantization scale/zero-point metadata. Use a proper "
|
|
f"quantized or full-precision (BF16/FP16) checkpoint instead."
|
|
)
|
|
return
|
|
|
|
if lower.endswith(".gguf"):
|
|
info = _inspect_gguf_quantization(weight_path)
|
|
if info is None or info["total"] == 0:
|
|
return
|
|
frac = info["low_bit_count"] / info["total"]
|
|
if frac >= _LOWBIT_FRACTION_THRESHOLD:
|
|
logging.warning(
|
|
f"[VibeVoice TTS] [low-bit check] '{os.path.basename(weight_path)}' contains "
|
|
f"{info['low_bit_count']}/{info['total']} tensors at sub-4-bit "
|
|
f"I-quant precision. Consider a higher-quality quant "
|
|
f"(Q4_K_M / Q5_K_M / Q8_0 / BF16)."
|
|
)
|
|
return
|
|
|
|
|
|
# ====================================================================
|
|
# In-memory state dict loading
|
|
# ====================================================================
|
|
|
|
def _load_state_dict_into_model_from_memory(
|
|
model, state_dict: dict, preserve_file_views: bool = True, target_device=None,
|
|
):
|
|
"""Load an in-memory state dict into an already-instantiated model.
|
|
|
|
Preserves zero-copy memory-mapped file views directly in parameters.
|
|
When target_device is specified (CUDA), transfers parameters directly
|
|
to GPU VRAM without keeping an intermediate copy in system RAM.
|
|
"""
|
|
if not preserve_file_views:
|
|
for key in list(state_dict.keys()):
|
|
tensor = state_dict[key]
|
|
if isinstance(tensor, torch.Tensor):
|
|
state_dict[key] = tensor.clone()
|
|
|
|
VibeVoiceLoader._apply_state_dict(model, state_dict)
|
|
|
|
if target_device is not None and target_device.type == "cuda":
|
|
model.to(target_device)
|
|
|
|
return model
|
|
|
|
|
|
# ====================================================================
|
|
# Core external loading function
|
|
# ====================================================================
|
|
|
|
def _log_load_diagnostics(
|
|
*,
|
|
config_name: str,
|
|
requested_attention_mode: str,
|
|
resolved_attention_mode: str,
|
|
weight_family: str,
|
|
load_device,
|
|
) -> None:
|
|
if not diagnostics_enabled():
|
|
return
|
|
logging.info(
|
|
"[VibeVoice TTS] Load diagnostics: model='%s' family=%s requested_attention=%s "
|
|
"resolved_attention=%s device=%s",
|
|
config_name, weight_family, requested_attention_mode,
|
|
resolved_attention_mode, load_device,
|
|
)
|
|
|
|
|
|
def _reset_gguf_forward_counters() -> None:
|
|
try:
|
|
from .gguf_quant import reset_gguf_forward_counters
|
|
|
|
reset_gguf_forward_counters()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def load_external_vibevoice_model(
|
|
weight_path: str,
|
|
config_name: str,
|
|
attention_mode: str = "eager",
|
|
use_llm_4bit: bool = False,
|
|
dtype_str: str = "auto",
|
|
device=None,
|
|
) -> dict:
|
|
"""Load a VibeVoice model from an external weight file directly into VRAM."""
|
|
config_name = normalize_config_name(config_name)
|
|
_reset_gguf_forward_counters()
|
|
|
|
if not os.path.isfile(weight_path):
|
|
raise FileNotFoundError(f"External weight file not found: {weight_path}")
|
|
|
|
try:
|
|
_stat = os.stat(weight_path)
|
|
source_mtime_ns = _stat.st_mtime_ns
|
|
source_size = _stat.st_size
|
|
except OSError:
|
|
source_mtime_ns = 0
|
|
source_size = 0
|
|
|
|
warn_if_lowbit_quantization(weight_path)
|
|
|
|
if is_asr_config_name(config_name):
|
|
return load_external_vibevoice_asr_model(
|
|
weight_path=weight_path,
|
|
config_name=config_name,
|
|
attention_mode=attention_mode,
|
|
dtype_str=dtype_str,
|
|
device=device,
|
|
)
|
|
|
|
requested_attention_mode = attention_mode
|
|
attention_mode = resolve_attention_mode(attention_mode, use_llm_4bit)
|
|
|
|
lower_path = weight_path.lower()
|
|
is_gguf_file = lower_path.endswith(".gguf")
|
|
gguf_reader = _open_gguf_reader(weight_path) if is_gguf_file else None
|
|
convrot_quant_map = {}
|
|
if gguf_reader is None and lower_path.endswith(".safetensors"):
|
|
try:
|
|
from .convrot_quant import scan_checkpoint_quantization
|
|
|
|
convrot_quant_map = scan_checkpoint_quantization(weight_path)
|
|
except UnsupportedQuantFormat:
|
|
raise
|
|
except Exception as e:
|
|
logging.debug(f"[VibeVoice TTS] ConvRot scan skipped for {weight_path}: {e}")
|
|
convrot_quant_map = {}
|
|
validate_weight_plan(
|
|
is_gguf_file=is_gguf_file,
|
|
convrot_quant_map=convrot_quant_map,
|
|
use_llm_4bit=use_llm_4bit,
|
|
attention_mode=attention_mode,
|
|
gguf_kquant_present=_gguf_kquant_present(gguf_reader) if gguf_reader else False,
|
|
)
|
|
|
|
from .config_detect import fingerprint_weights
|
|
|
|
weights_fp = fingerprint_weights(weight_path, gguf_reader=gguf_reader)
|
|
|
|
if config_name == AUTO_CONFIG_NAME:
|
|
config_name = resolve_auto_config_name(weight_path, weights_fp=weights_fp)
|
|
|
|
config_path = resolve_sidecar_config(weight_path, config_name)
|
|
config = VibeVoiceLoader._load_config(config_path, config_name)
|
|
effective_name, config_changed = reconcile_config(config_name, config, weights_fp)
|
|
if config_changed:
|
|
config_name = effective_name
|
|
config_path = resolve_sidecar_config(weight_path, config_name)
|
|
config = VibeVoiceLoader._load_config(config_path, config_name)
|
|
|
|
is_streaming = isinstance(config, VibeVoiceStreamingConfig)
|
|
if is_streaming:
|
|
logging.debug(f"[VibeVoice TTS] External model '{config_name}' detected as streaming model")
|
|
attention_mode = resolve_realtime_attention_mode(attention_mode)
|
|
|
|
tokenizer_dir = resolve_sidecar_tokenizer_dir(weight_path)
|
|
vibevoice_tokenizer = VibeVoiceLoader._load_tokenizer(tokenizer_dir, config_name)
|
|
|
|
preprocessor_path = resolve_sidecar_preprocessor(weight_path)
|
|
processor = VibeVoiceLoader._load_processor(
|
|
vibevoice_tokenizer, preprocessor_path, is_streaming=is_streaming
|
|
)
|
|
|
|
load_device = (
|
|
model_management.get_torch_device()
|
|
if not isinstance(device, torch.device)
|
|
else device
|
|
)
|
|
model_dtype = resolve_dtype(dtype_str, load_device)
|
|
|
|
quant_config = None
|
|
final_load_dtype = model_dtype
|
|
if use_llm_4bit:
|
|
bnb_compute_dtype = model_dtype
|
|
if attention_mode == "sage":
|
|
bnb_compute_dtype, final_load_dtype = torch.float32, torch.float32
|
|
quant_config = BitsAndBytesConfig(
|
|
load_in_4bit=True,
|
|
bnb_4bit_quant_type="nf4",
|
|
bnb_4bit_use_double_quant=True,
|
|
bnb_4bit_compute_dtype=bnb_compute_dtype,
|
|
)
|
|
|
|
attn_implementation_for_load = get_attn_implementation_for_load(attention_mode)
|
|
|
|
try:
|
|
logging.debug(
|
|
f"[VibeVoice TTS] Instantiating external VibeVoice model '{config_name}' with "
|
|
f"dtype={final_load_dtype}, attention='{attn_implementation_for_load}'"
|
|
)
|
|
|
|
model = VibeVoiceLoader._instantiate_model(
|
|
config=config,
|
|
is_streaming=is_streaming,
|
|
attn_implementation=attn_implementation_for_load,
|
|
final_load_dtype=final_load_dtype,
|
|
)
|
|
|
|
weight_family = "dense"
|
|
quant_stats = {}
|
|
# convrot_quant_map is only ever populated from a .safetensors scan, so
|
|
# this is bool(convrot_quant_map). The batch fallback below is kept
|
|
# only because its guard helpers are still exercised directly by
|
|
# tests; every real load goes through the streaming assign.
|
|
stream_quant_load = (
|
|
bool(convrot_quant_map)
|
|
and weight_path.lower().endswith(".safetensors")
|
|
)
|
|
|
|
if gguf_reader is not None:
|
|
_assert_gguf_lm_head_not_tied(config, gguf_reader)
|
|
quant_stats = _install_gguf_weights(
|
|
model, gguf_reader, target_device=load_device
|
|
)
|
|
weight_family = "gguf_block"
|
|
del gguf_reader
|
|
gguf_reader = None
|
|
gc.collect()
|
|
elif convrot_quant_map:
|
|
from .quant_common import replace_linears_for_quant
|
|
|
|
_assert_lm_head_not_tied(config, convrot_quant_map)
|
|
convrot_quant_map = _demote_nonlinear_fp8_residents(
|
|
model, convrot_quant_map
|
|
)
|
|
if stream_quant_load:
|
|
layer_plan, n_rowwise, n_fp8_resident = (
|
|
_plan_quantized_safetensors_load(convrot_quant_map)
|
|
)
|
|
replaced = replace_linears_for_quant(model, layer_plan)
|
|
_stream_apply_safetensors(
|
|
model,
|
|
weight_path,
|
|
convrot_quant_map,
|
|
target_device=load_device,
|
|
)
|
|
else:
|
|
with measured_load("dense-read-state-dict"):
|
|
state_dict = _load_weight_state_dict(weight_path, torch.device("cpu"))
|
|
layer_plan, n_rowwise, n_fp8_resident = (
|
|
_prepare_quantized_safetensors_load(state_dict, convrot_quant_map)
|
|
)
|
|
replaced = replace_linears_for_quant(model, layer_plan)
|
|
model = _load_state_dict_into_model_from_memory(
|
|
model,
|
|
state_dict,
|
|
preserve_file_views=True,
|
|
target_device=load_device,
|
|
)
|
|
del state_dict
|
|
state_dict = None
|
|
weight_family = (
|
|
"fp8_resident"
|
|
if n_fp8_resident and len(replaced) == n_fp8_resident
|
|
else "convrot_int8"
|
|
)
|
|
quant_stats = {
|
|
"n_resident_layers": len(replaced),
|
|
"n_rowwise_layers": n_rowwise,
|
|
"n_fp8_resident_layers": n_fp8_resident,
|
|
}
|
|
else:
|
|
_stream_apply_dense_safetensors(
|
|
model, weight_path, target_device=load_device
|
|
)
|
|
|
|
cast_model_to_dtype_if_needed(model, final_load_dtype)
|
|
|
|
if quant_config is not None:
|
|
try:
|
|
from transformers.integrations.bitsandbytes import replace_with_bnb_linear
|
|
except ImportError:
|
|
from transformers.utils.bitsandbytes import replace_with_bnb_linear
|
|
replace_with_bnb_linear(
|
|
model,
|
|
quantization_config=quant_config,
|
|
modules_to_not_convert=None,
|
|
)
|
|
|
|
if attention_mode == "sage":
|
|
if check_sage_attention_compatible():
|
|
set_sage_attention(model)
|
|
else:
|
|
raise RuntimeError("Incompatible hardware/setup for SageAttention.")
|
|
|
|
model.eval()
|
|
setattr(model, "_llm_4bit", bool(quant_config))
|
|
|
|
report_census(model, phase=f"pre-h2d:{config_name}")
|
|
|
|
_log_load_diagnostics(
|
|
config_name=config_name,
|
|
requested_attention_mode=requested_attention_mode,
|
|
resolved_attention_mode=attention_mode,
|
|
weight_family=weight_family,
|
|
load_device=load_device,
|
|
)
|
|
|
|
return {
|
|
"config": config,
|
|
"processor": processor,
|
|
"model": model,
|
|
"model_name": config_name,
|
|
"source_path": weight_path,
|
|
"source_mtime_ns": source_mtime_ns,
|
|
"source_size": source_size,
|
|
"attention_mode": attention_mode,
|
|
"use_llm_4bit": bool(use_llm_4bit),
|
|
"dtype_str": dtype_str,
|
|
"is_streaming": is_streaming,
|
|
"is_asr": False,
|
|
"weight_family": weight_family,
|
|
"dynamic_vram_route": False,
|
|
"quant_stats": quant_stats,
|
|
}
|
|
|
|
except Exception as e:
|
|
logging.error(
|
|
f"[VibeVoice TTS] Failed to load external VibeVoice model '{config_name}' "
|
|
f"from {weight_path}: {e}"
|
|
)
|
|
raise RuntimeError(
|
|
f"Failed to load external VibeVoice model '{config_name}': {e}"
|
|
)
|
|
|
|
|
|
# ====================================================================
|
|
# ASR external loading branch
|
|
# ====================================================================
|
|
|
|
def load_external_vibevoice_asr_model(
|
|
weight_path: str,
|
|
config_name: str,
|
|
attention_mode: str = "sdpa",
|
|
dtype_str: str = "auto",
|
|
device=None,
|
|
) -> dict:
|
|
config_name = normalize_config_name(config_name)
|
|
_reset_gguf_forward_counters()
|
|
|
|
if not os.path.isfile(weight_path):
|
|
raise FileNotFoundError(f"External weight file not found: {weight_path}")
|
|
|
|
try:
|
|
_stat = os.stat(weight_path)
|
|
source_mtime_ns = _stat.st_mtime_ns
|
|
source_size = _stat.st_size
|
|
except OSError:
|
|
source_mtime_ns = 0
|
|
source_size = 0
|
|
|
|
warn_if_lowbit_quantization(weight_path)
|
|
|
|
requested_attention_mode = attention_mode
|
|
attention_mode = resolve_asr_attention_mode(
|
|
resolve_attention_mode(attention_mode, quantize_4bit=False)
|
|
)
|
|
|
|
lower_path = weight_path.lower()
|
|
is_gguf_file = lower_path.endswith(".gguf")
|
|
gguf_reader = _open_gguf_reader(weight_path) if is_gguf_file else None
|
|
convrot_quant_map = {}
|
|
if gguf_reader is None and lower_path.endswith(".safetensors"):
|
|
try:
|
|
from .convrot_quant import scan_checkpoint_quantization
|
|
|
|
convrot_quant_map = scan_checkpoint_quantization(weight_path)
|
|
except UnsupportedQuantFormat:
|
|
raise
|
|
except Exception as e:
|
|
logging.debug(f"[VibeVoice TTS] ConvRot scan skipped for {weight_path}: {e}")
|
|
convrot_quant_map = {}
|
|
validate_weight_plan(
|
|
is_gguf_file=is_gguf_file,
|
|
convrot_quant_map=convrot_quant_map,
|
|
use_llm_4bit=False,
|
|
attention_mode=attention_mode,
|
|
gguf_kquant_present=_gguf_kquant_present(gguf_reader) if gguf_reader else False,
|
|
)
|
|
|
|
config_path = resolve_sidecar_config(weight_path, config_name)
|
|
config = _load_asr_config(config_path)
|
|
|
|
tokenizer_dir = resolve_sidecar_tokenizer_dir(weight_path)
|
|
preprocessor_path = resolve_sidecar_preprocessor(weight_path)
|
|
if is_native_asr_config_path(config_path):
|
|
processor = build_native_asr_processor(tokenizer_dir, preprocessor_path)
|
|
else:
|
|
processor = _load_asr_processor(
|
|
_load_asr_tokenizer(tokenizer_dir), preprocessor_path
|
|
)
|
|
|
|
load_device = (
|
|
model_management.get_torch_device()
|
|
if not isinstance(device, torch.device)
|
|
else device
|
|
)
|
|
model_dtype = resolve_dtype(dtype_str, load_device)
|
|
attn_implementation_for_load = get_attn_implementation_for_load(attention_mode)
|
|
|
|
try:
|
|
logging.debug(
|
|
f"[VibeVoice TTS] Instantiating external VibeVoice ASR model '{config_name}' with "
|
|
f"dtype={model_dtype}, attention='{attn_implementation_for_load}'"
|
|
)
|
|
|
|
model = _instantiate_asr_model(
|
|
config=config,
|
|
attn_implementation=attn_implementation_for_load,
|
|
final_load_dtype=model_dtype,
|
|
)
|
|
|
|
weight_family = "dense"
|
|
quant_stats = {}
|
|
# convrot_quant_map is only ever populated from a .safetensors scan, so
|
|
# this is bool(convrot_quant_map). The batch fallback below is kept
|
|
# only because its guard helpers are still exercised directly by
|
|
# tests; every real load goes through the streaming assign.
|
|
stream_quant_load = (
|
|
bool(convrot_quant_map)
|
|
and weight_path.lower().endswith(".safetensors")
|
|
)
|
|
|
|
if gguf_reader is not None:
|
|
_assert_gguf_lm_head_not_tied(config, gguf_reader)
|
|
quant_stats = _install_gguf_weights(
|
|
model, gguf_reader, target_device=load_device
|
|
)
|
|
weight_family = "gguf_block"
|
|
del gguf_reader
|
|
gguf_reader = None
|
|
gc.collect()
|
|
elif convrot_quant_map:
|
|
from .quant_common import replace_linears_for_quant
|
|
|
|
_assert_lm_head_not_tied(config, convrot_quant_map)
|
|
convrot_quant_map = _demote_nonlinear_fp8_residents(
|
|
model, convrot_quant_map
|
|
)
|
|
if stream_quant_load:
|
|
layer_plan, n_rowwise, n_fp8_resident = (
|
|
_plan_quantized_safetensors_load(convrot_quant_map)
|
|
)
|
|
replaced = replace_linears_for_quant(model, layer_plan)
|
|
_stream_apply_safetensors(
|
|
model,
|
|
weight_path,
|
|
convrot_quant_map,
|
|
target_device=load_device,
|
|
)
|
|
else:
|
|
with measured_load("dense-read-state-dict"):
|
|
state_dict = _load_weight_state_dict(weight_path, torch.device("cpu"))
|
|
layer_plan, n_rowwise, n_fp8_resident = (
|
|
_prepare_quantized_safetensors_load(state_dict, convrot_quant_map)
|
|
)
|
|
replaced = replace_linears_for_quant(model, layer_plan)
|
|
model = _load_state_dict_into_model_from_memory(
|
|
model,
|
|
state_dict,
|
|
preserve_file_views=True,
|
|
target_device=load_device,
|
|
)
|
|
del state_dict
|
|
state_dict = None
|
|
weight_family = (
|
|
"fp8_resident"
|
|
if n_fp8_resident and len(replaced) == n_fp8_resident
|
|
else "convrot_int8"
|
|
)
|
|
quant_stats = {
|
|
"n_resident_layers": len(replaced),
|
|
"n_rowwise_layers": n_rowwise,
|
|
"n_fp8_resident_layers": n_fp8_resident,
|
|
}
|
|
else:
|
|
_stream_apply_dense_safetensors(
|
|
model, weight_path, target_device=load_device
|
|
)
|
|
|
|
cast_model_to_dtype_if_needed(model, model_dtype)
|
|
|
|
if attention_mode == "sage":
|
|
if check_sage_attention_compatible():
|
|
set_sage_attention(model)
|
|
else:
|
|
raise RuntimeError("Incompatible hardware/setup for SageAttention.")
|
|
|
|
model.eval()
|
|
|
|
_log_load_diagnostics(
|
|
config_name=config_name,
|
|
requested_attention_mode=requested_attention_mode,
|
|
resolved_attention_mode=attention_mode,
|
|
weight_family=weight_family,
|
|
load_device=load_device,
|
|
)
|
|
|
|
return {
|
|
"config": config,
|
|
"processor": processor,
|
|
"model": model,
|
|
"model_name": config_name,
|
|
"source_path": weight_path,
|
|
"source_mtime_ns": source_mtime_ns,
|
|
"source_size": source_size,
|
|
"attention_mode": attention_mode,
|
|
"use_llm_4bit": False,
|
|
"dtype_str": dtype_str,
|
|
"is_streaming": False,
|
|
"is_asr": True,
|
|
"weight_family": weight_family,
|
|
"dynamic_vram_route": False,
|
|
"quant_stats": quant_stats,
|
|
}
|
|
|
|
except Exception as e:
|
|
logging.error(
|
|
f"[VibeVoice TTS] Failed to load external VibeVoice ASR model '{config_name}' "
|
|
f"from {weight_path}: {e}"
|
|
)
|
|
raise RuntimeError(
|
|
f"Failed to load external VibeVoice ASR model '{config_name}': {e}"
|
|
) |