409 lines
17 KiB
Python
Executable File
409 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Cossim debug : C++ omnivoice-tts vs Python OmniVoice on voice design.
|
|
|
|
Inputs (relative to CWD = tests/) :
|
|
../examples/prompt.txt target text fed to both pipelines
|
|
|
|
Both sides run with :
|
|
instruct=male, language=English, seed=42, F32 weights, no pre or post
|
|
process. Defaults match : num_step=32, guidance_scale=2.0, t_shift=0.1,
|
|
layer_penalty_factor=5.0, position_temperature=5.0, class_temperature=0.0.
|
|
|
|
Dumps land in cpp/ (C++) and python/ (Python). The script compares each
|
|
matching .bin pair via cosine similarity over the f32 payload, plus exact
|
|
match rate for tensors that originated as int (mg-tokens). All paths are
|
|
relative, no absolute paths anywhere.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
|
|
# Strict F32 matmul on both sides. NVIDIA_TF32_OVERRIDE=0 forces full FP32
|
|
# mantissa in cuBLAS for both PyTorch and the C++ child via inheritance.
|
|
# Must be set BEFORE torch imports so the cuBLAS handle reads it on init.
|
|
os.environ["NVIDIA_TF32_OVERRIDE"] = "0"
|
|
|
|
import numpy as np
|
|
import soundfile as sf
|
|
import torch
|
|
|
|
# Belt and suspenders : disable PyTorch's own TF32 toggles too. Some code
|
|
# paths bypass NVIDIA_TF32_OVERRIDE through cudnn or torch internal flags.
|
|
torch.backends.cuda.matmul.allow_tf32 = False
|
|
torch.backends.cudnn.allow_tf32 = False
|
|
torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False
|
|
torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False
|
|
torch.set_float32_matmul_precision("highest")
|
|
|
|
from omnivoice import OmniVoice
|
|
from omnivoice.utils.common import fix_random_seed
|
|
|
|
BIN = "../build/omnivoice-tts"
|
|
MODEL_LM_T = "../models/omnivoice-base-{q}.gguf"
|
|
MODEL_CDC_T = "../models/omnivoice-tokenizer-{q}.gguf"
|
|
CKPT = "../checkpoints/OmniVoice"
|
|
DUMP_CPP = "cpp"
|
|
DUMP_PT = "python"
|
|
|
|
def ensure_dir(path):
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
def save_dump(path, data):
|
|
"""Write a tensor in the C++ debug.h format :
|
|
[ndim:i32] [shape:i32 x ndim] [data:f32 x numel]
|
|
"""
|
|
if isinstance(data, torch.Tensor):
|
|
data = data.detach().to(torch.float32).cpu().numpy()
|
|
data = np.ascontiguousarray(data.astype(np.float32))
|
|
shape = data.shape
|
|
with open(path, "wb") as f:
|
|
f.write(struct.pack("i", len(shape)))
|
|
for s in shape:
|
|
f.write(struct.pack("i", s))
|
|
f.write(data.tobytes())
|
|
|
|
def load_dump(path):
|
|
"""Inverse of save_dump : returns (data:f32 numpy, shape:tuple)."""
|
|
raw = np.fromfile(path, dtype=np.uint8)
|
|
ndim = int(np.frombuffer(raw[0:4], dtype=np.int32)[0])
|
|
shape = tuple(int(x) for x in np.frombuffer(raw[4:4 + 4 * ndim], dtype=np.int32))
|
|
body = np.frombuffer(raw[4 + 4 * ndim:], dtype=np.float32)
|
|
return body.reshape(shape), shape
|
|
|
|
def cos(a, b):
|
|
a = a.astype(np.float64).ravel()
|
|
b = b.astype(np.float64).ravel()
|
|
n = min(len(a), len(b))
|
|
a, b = a[:n], b[:n]
|
|
d = float(np.linalg.norm(a) * np.linalg.norm(b))
|
|
return float(np.dot(a, b) / d) if d > 1e-10 else 0.0
|
|
|
|
def stft_cos(a, b, win=2048, hop=512):
|
|
# STFT magnitude cosine. Drops phase, so a constant time shift between
|
|
# the two waveforms does not collapse the score. The plain cos() on
|
|
# raw samples falls to ~0 the moment chunks land a few samples apart.
|
|
a = a.astype(np.float64).ravel()
|
|
b = b.astype(np.float64).ravel()
|
|
n = min(len(a), len(b))
|
|
a, b = a[:n], b[:n]
|
|
window = np.hanning(win)
|
|
frames = (n - win) // hop + 1
|
|
if frames <= 0:
|
|
return 0.0
|
|
sa = np.zeros((frames, win // 2 + 1))
|
|
sb = np.zeros((frames, win // 2 + 1))
|
|
for i in range(frames):
|
|
s = i * hop
|
|
sa[i] = np.abs(np.fft.rfft(a[s:s + win] * window))
|
|
sb[i] = np.abs(np.fft.rfft(b[s:s + win] * window))
|
|
return cos(sa.ravel(), sb.ravel())
|
|
|
|
def install_hooks(model, dump_dir):
|
|
# First call to _prepare_embed_inputs at step 0 returns the input embedding
|
|
# right before layer 0 of the LLM, mirroring the C++ inputs_embeds dump.
|
|
# Also captures the raw input_ids row k=0 for cond and uncond, so any
|
|
# token-level divergence localizes upstream of the embed lookup.
|
|
seen_embed = {"done": False}
|
|
orig_prepare = model._prepare_embed_inputs
|
|
def hooked_prepare(input_ids, audio_mask):
|
|
out = orig_prepare(input_ids, audio_mask)
|
|
if not seen_embed["done"] and out.dim() == 3 and out.shape[0] >= 2:
|
|
cond = out[0].detach().to(torch.float32).cpu().numpy()
|
|
uncond = out[1].detach().to(torch.float32).cpu().numpy()
|
|
save_dump(os.path.join(dump_dir, "lm-hidden-step0-cond-embed.bin"), cond)
|
|
save_dump(os.path.join(dump_dir, "lm-hidden-step0-uncond-embed.bin"), uncond)
|
|
# Style and text tokens duplicate across all K codebooks so k=0
|
|
# carries the full sequence for diagnostic. Cast to f32 keeps the
|
|
# debug.h binary format identical on both sides.
|
|
cond_ids = input_ids[0, 0, :].detach().to(torch.float32).cpu().numpy()
|
|
uncond_ids = input_ids[1, 0, :].detach().to(torch.float32).cpu().numpy()
|
|
save_dump(os.path.join(dump_dir, "prompt-cond-ids.bin"), cond_ids)
|
|
save_dump(os.path.join(dump_dir, "prompt-uncond-ids.bin"), uncond_ids)
|
|
seen_embed["done"] = True
|
|
return out
|
|
model._prepare_embed_inputs = hooked_prepare
|
|
|
|
# Bisection : dump cond and uncond hidden states after a few layers so a
|
|
# mismatch can be localized within the 28 layer Qwen3 stack.
|
|
bisect_layers = [0, 6, 13, 20]
|
|
seen_layers = {idx: False for idx in bisect_layers}
|
|
def make_layer_hook(layer_idx):
|
|
def hook(module, inputs, output):
|
|
if seen_layers[layer_idx]:
|
|
return
|
|
h = output[0] if isinstance(output, tuple) else output
|
|
if h.dim() == 3 and h.shape[0] >= 2:
|
|
cond = h[0].detach().to(torch.float32).cpu().numpy()
|
|
uncond = h[1].detach().to(torch.float32).cpu().numpy()
|
|
save_dump(os.path.join(dump_dir, f"lm-hidden-step0-cond-l{layer_idx}.bin"), cond)
|
|
save_dump(os.path.join(dump_dir, f"lm-hidden-step0-uncond-l{layer_idx}.bin"), uncond)
|
|
seen_layers[layer_idx] = True
|
|
return hook
|
|
for layer_idx in bisect_layers:
|
|
model.llm.layers[layer_idx].register_forward_hook(make_layer_hook(layer_idx))
|
|
|
|
# First call to audio_heads corresponds to step 0 of the MaskGIT loop.
|
|
# The input to audio_heads is the final hidden state, shape [B, S, D],
|
|
# mirroring what the C++ side reads back via dump_hidden_dir before the
|
|
# lm_head matmul. We dump cond (b=0) and uncond (b=1) separately.
|
|
seen_hidden = {"done": False}
|
|
def pre_audio_heads(module, inputs):
|
|
if not seen_hidden["done"]:
|
|
h = inputs[0]
|
|
if h.dim() == 3 and h.shape[0] >= 2:
|
|
cond = h[0].detach().to(torch.float32).cpu().numpy()
|
|
uncond = h[1].detach().to(torch.float32).cpu().numpy()
|
|
save_dump(os.path.join(dump_dir, "lm-hidden-step0-cond.bin"), cond)
|
|
save_dump(os.path.join(dump_dir, "lm-hidden-step0-uncond.bin"), uncond)
|
|
seen_hidden["done"] = True
|
|
model.audio_heads.register_forward_pre_hook(pre_audio_heads)
|
|
|
|
# First call to _predict_tokens_with_scoring corresponds to step 0 of the
|
|
# MaskGIT loop. Capture cond and uncond logits in [K, T, V] layout (squeeze
|
|
# the batch axis to match the C++ dump shape). Replicate the predict math
|
|
# locally so log_probs / pred_tokens / scores can all be dumped from a
|
|
# single source of truth on the Python side.
|
|
seen = {"step0": False, "mg_tokens": False, "audio": False}
|
|
orig_pred = model._predict_tokens_with_scoring
|
|
def hooked_pred(c_logits, u_logits, gen_config):
|
|
pred_tokens, scores = orig_pred(c_logits, u_logits, gen_config)
|
|
if not seen["step0"]:
|
|
if gen_config.guidance_scale != 0:
|
|
c_lp = torch.nn.functional.log_softmax(c_logits, dim=-1)
|
|
u_lp = torch.nn.functional.log_softmax(u_logits, dim=-1)
|
|
log_probs = torch.log_softmax(
|
|
c_lp + gen_config.guidance_scale * (c_lp - u_lp), dim=-1)
|
|
else:
|
|
log_probs = torch.nn.functional.log_softmax(c_logits, dim=-1)
|
|
log_probs = log_probs.clone()
|
|
log_probs[..., model.config.audio_mask_id] = float("-inf")
|
|
|
|
c = c_logits.detach().to(torch.float32).cpu().numpy()
|
|
u = u_logits.detach().to(torch.float32).cpu().numpy()
|
|
if c.ndim == 4:
|
|
c = c[0]
|
|
if u.ndim == 4:
|
|
u = u[0]
|
|
save_dump(os.path.join(dump_dir, "lm-logits-step0-cond.bin"), c)
|
|
save_dump(os.path.join(dump_dir, "lm-logits-step0-uncond.bin"), u)
|
|
|
|
lp_arr = log_probs.detach().to(torch.float32).cpu().numpy()
|
|
if lp_arr.ndim == 4:
|
|
lp_arr = lp_arr[0]
|
|
save_dump(os.path.join(dump_dir, "mg-log-probs-step0.bin"), lp_arr)
|
|
|
|
pt_arr = pred_tokens.detach().to(torch.float32).cpu().numpy()
|
|
sc_arr = scores.detach().to(torch.float32).cpu().numpy()
|
|
if pt_arr.ndim == 3:
|
|
pt_arr = pt_arr[0]
|
|
if sc_arr.ndim == 3:
|
|
sc_arr = sc_arr[0]
|
|
save_dump(os.path.join(dump_dir, "mg-pred-tokens-step0.bin"), pt_arr)
|
|
save_dump(os.path.join(dump_dir, "mg-scores-step0.bin"), sc_arr)
|
|
seen["step0"] = True
|
|
return pred_tokens, scores
|
|
model._predict_tokens_with_scoring = hooked_pred
|
|
|
|
orig_generate = model._generate_iterative
|
|
def hooked_generate(task, gen_config):
|
|
out = orig_generate(task, gen_config)
|
|
# out is a list of (K, T_i) long tensors, one per batch item.
|
|
# In chunked mode this hook fires once per chunk; only the first
|
|
# call mirrors the chunk 0 dump on the C++ side.
|
|
if not seen["mg_tokens"]:
|
|
save_dump(os.path.join(dump_dir, "mg-tokens.bin"), out[0])
|
|
seen["mg_tokens"] = True
|
|
return out
|
|
model._generate_iterative = hooked_generate
|
|
|
|
orig_decode = model.audio_tokenizer.decode
|
|
def hooked_decode(*args, **kwargs):
|
|
out = orig_decode(*args, **kwargs)
|
|
# The audio tokenizer returns either a tensor or a wrapper holding
|
|
# audio_values shape [B, C, N]. Unwrap and dump the first item mono.
|
|
# Same first-chunk guard as the mg-tokens hook above.
|
|
if seen["audio"]:
|
|
return out
|
|
wav = getattr(out, "audio_values", out)
|
|
if isinstance(wav, torch.Tensor):
|
|
arr = wav.detach().to(torch.float32).cpu().numpy()
|
|
else:
|
|
arr = np.asarray(wav, dtype=np.float32)
|
|
if arr.ndim == 3:
|
|
arr = arr[0, 0]
|
|
elif arr.ndim == 2:
|
|
arr = arr[0]
|
|
save_dump(os.path.join(dump_dir, "output-audio.bin"), arr)
|
|
seen["audio"] = True
|
|
return out
|
|
model.audio_tokenizer.decode = hooked_decode
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--prompt", default="../examples/prompt.txt")
|
|
ap.add_argument("--seed", type=int, default=42)
|
|
ap.add_argument("--instruct", default="male, young adult, moderate pitch")
|
|
ap.add_argument("--lang", default="English")
|
|
ap.add_argument("--duration", type=float, default=None)
|
|
ap.add_argument("--quant", default="F32",
|
|
help="quantization suffix for GGUF (default: F32, e.g. BF16, Q8_0, Q4_K_M)")
|
|
ap.add_argument("--out-cpp", default="cpp/tts-cpp.wav")
|
|
ap.add_argument("--out-pt", default="python/tts-python.wav")
|
|
args = ap.parse_args()
|
|
|
|
model_lm = MODEL_LM_T.format(q=args.quant)
|
|
model_cdc = MODEL_CDC_T.format(q=args.quant)
|
|
for p in (model_lm, model_cdc):
|
|
if not os.path.isfile(p):
|
|
print(f"[Error] GGUF not found: {p}")
|
|
sys.exit(1)
|
|
print(f"[Quant] {args.quant} -> {model_lm} + {model_cdc}")
|
|
|
|
ensure_dir(DUMP_CPP)
|
|
ensure_dir(DUMP_PT)
|
|
os.makedirs(os.path.dirname(args.out_cpp) or ".", exist_ok=True)
|
|
|
|
with open(args.prompt, "r", encoding="utf-8") as f:
|
|
text = f.read().strip()
|
|
print(f"[Input] Prompt: {len(text)} chars: {text[:60]}{'...' if len(text) > 60 else ''}")
|
|
print(f"[Input] Instruct: {args.instruct}")
|
|
print(f"[Input] Language: {args.lang}")
|
|
print(f"[Input] Seed: {args.seed}")
|
|
|
|
# Python reference path : F32, voice design male, no pre or post process.
|
|
fix_random_seed(args.seed)
|
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
model = OmniVoice.from_pretrained(
|
|
CKPT,
|
|
torch_dtype=torch.float32,
|
|
attn_implementation="eager",
|
|
).to(device).eval()
|
|
install_hooks(model, DUMP_PT)
|
|
gen_kwargs = dict(
|
|
text=text,
|
|
language=args.lang,
|
|
instruct=args.instruct,
|
|
duration=args.duration,
|
|
)
|
|
audios = model.generate(**gen_kwargs)
|
|
audio_pt = np.asarray(audios[0], dtype=np.float32)
|
|
sf.write(args.out_pt, audio_pt, 24000, subtype="FLOAT")
|
|
print(f"[Python] Audio: {audio_pt.shape[0]} samples {audio_pt.shape[0] / 24000:.2f}s -> {args.out_pt}")
|
|
|
|
# Free the GPU before launching the C++ binary so it has room to load
|
|
# the F32 GGUFs without fighting for VRAM.
|
|
del model
|
|
torch.cuda.empty_cache()
|
|
|
|
# C++ path : same text, same instruct, same seed, F32 GGUF weights,
|
|
# dumps under cpp/.
|
|
cmd = [
|
|
BIN,
|
|
"--model", model_lm,
|
|
"--codec", model_cdc,
|
|
"--seed", str(args.seed),
|
|
"--instruct", args.instruct,
|
|
"--lang", args.lang,
|
|
"--format", "wav32",
|
|
"--dump", DUMP_CPP,
|
|
"--no-fa",
|
|
"-o", args.out_cpp,
|
|
]
|
|
if args.duration:
|
|
cmd += ["--duration", str(args.duration)]
|
|
print(f"[GGML] Cmd: {' '.join(cmd)}")
|
|
r = subprocess.run(cmd, input=text, text=True)
|
|
if r.returncode != 0:
|
|
sys.exit(r.returncode)
|
|
audio_cpp, sr = sf.read(args.out_cpp)
|
|
if audio_cpp.ndim > 1:
|
|
audio_cpp = audio_cpp[:, 0]
|
|
audio_cpp = audio_cpp.astype(np.float32)
|
|
print(f"[GGML] Audio: {audio_cpp.shape[0]} samples {sr} Hz {audio_cpp.shape[0] / sr:.2f}s -> {args.out_cpp}")
|
|
|
|
# Cossim in pipeline order: prompt-ids -> embed -> l0 -> l6 -> l13 -> l20
|
|
# -> final -> logits -> tokens -> audio. Cond and uncond on the same line
|
|
# so a drift localizes immediately to the originating stage.
|
|
def pair(name):
|
|
a, _ = load_dump(os.path.join(DUMP_CPP, name))
|
|
b, _ = load_dump(os.path.join(DUMP_PT, name))
|
|
return a, b
|
|
|
|
def ids_exact(a, b):
|
|
n = min(a.size, b.size)
|
|
ai = a.astype(np.int64).ravel()[:n]
|
|
bi = b.astype(np.int64).ravel()[:n]
|
|
diffs = np.where(ai != bi)[0]
|
|
return 100.0 * float(np.mean(ai == bi)), diffs, ai, bi
|
|
|
|
ca, cb = pair("prompt-cond-ids.bin")
|
|
ua, ub = pair("prompt-uncond-ids.bin")
|
|
cm, cd, cai, cbi = ids_exact(ca, cb)
|
|
um, ud, uai, ubi = ids_exact(ua, ub)
|
|
print(f"[Cossim] PromptIDs cond exact: {cm:.2f}% uncond exact: {um:.2f}%")
|
|
for s in cd[:20]:
|
|
print(f"[Cossim] PromptIDs cond diff at s={s}: ggml={cai[s]} python={cbi[s]}")
|
|
for s in ud[:20]:
|
|
print(f"[Cossim] PromptIDs uncond diff at s={s}: ggml={uai[s]} python={ubi[s]}")
|
|
|
|
stages = [
|
|
("Embed", "lm-hidden-step0-{}-embed.bin"),
|
|
("L0", "lm-hidden-step0-{}-l0.bin"),
|
|
("L6", "lm-hidden-step0-{}-l6.bin"),
|
|
("L13", "lm-hidden-step0-{}-l13.bin"),
|
|
("L20", "lm-hidden-step0-{}-l20.bin"),
|
|
("Final", "lm-hidden-step0-{}.bin"),
|
|
("Logits", "lm-logits-step0-{}.bin"),
|
|
]
|
|
def metric(a, b):
|
|
n = min(a.size, b.size)
|
|
af = a.astype(np.float64).ravel()[:n]
|
|
bf = b.astype(np.float64).ravel()[:n]
|
|
d = np.abs(af - bf)
|
|
nrm_a = float(np.linalg.norm(af))
|
|
nrm_b = float(np.linalg.norm(bf))
|
|
c = float(np.dot(af, bf) / (nrm_a * nrm_b)) if nrm_a > 1e-10 and nrm_b > 1e-10 else 0.0
|
|
return c, float(d.max()), float(d.mean())
|
|
|
|
for label, fmt in stages:
|
|
ca, cb = pair(fmt.format("cond"))
|
|
ua, ub = pair(fmt.format("uncond"))
|
|
cc, cmax, cmean = metric(ca, cb)
|
|
uc, umax, umean = metric(ua, ub)
|
|
print(f"[Cossim] {label} cond cos: {cc:.6f} max: {cmax:.4e} mean: {cmean:.4e} uncond cos: {uc:.6f} max: {umax:.4e} mean: {umean:.4e}")
|
|
|
|
pa, pb = pair("mg-pred-tokens-step0.bin")
|
|
n = min(pa.size, pb.size)
|
|
ai = pa.astype(np.int64).ravel()[:n]
|
|
bi = pb.astype(np.int64).ravel()[:n]
|
|
diffs = np.where(ai != bi)[0]
|
|
print(f"[Cossim] Step0Tokens exact: {100.0 * float((ai == bi).mean()):.2f}% diffs: {diffs.size}")
|
|
|
|
sa, sb = pair("mg-scores-step0.bin")
|
|
sd = np.abs(sa - sb)
|
|
print(f"[Cossim] Step0Scores cos: {cos(sa, sb):.6f} max_abs_diff: {sd.max():.6f} mean_abs_diff: {sd.mean():.6f}")
|
|
|
|
la, lb = pair("mg-log-probs-step0.bin")
|
|
finite = np.isfinite(la) & np.isfinite(lb)
|
|
laf = la[finite]; lbf = lb[finite]
|
|
ld = np.abs(laf - lbf)
|
|
print(f"[Cossim] Step0LogProbs cos: {cos(laf, lbf):.6f} max_abs_diff: {ld.max():.6f} mean_abs_diff: {ld.mean():.6f}")
|
|
|
|
ta, tb = pair("mg-tokens.bin")
|
|
n = min(ta.size, tb.size)
|
|
ai = ta.astype(np.int64).ravel()[:n]
|
|
bi = tb.astype(np.int64).ravel()[:n]
|
|
print(f"[Cossim] Tokens: {cos(ta, tb):.6f} exact: {100.0 * float(np.mean(ai == bi)):.2f}%")
|
|
|
|
aa, ab = pair("output-audio.bin")
|
|
print(f"[Cossim] Audio: {cos(aa, ab):.6f}")
|
|
|
|
n = min(audio_cpp.size, audio_pt.size)
|
|
print(f"[Cossim] WAV stft_cos: {stft_cos(audio_cpp[:n], audio_pt[:n]):.6f} samples: {n}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|