"""Shared helpers for the qwentts.cpp cossim debug scripts. Provides Philox uniform stream, dump load and save, install_hooks for the talker submodel, the standard stage list and the metric helpers used by debug-base / debug-tts / debug-customvoice / debug-clone cossim scripts. Importing this module patches sys.path so qwen_tts upstream loads without the V1 25Hz tokenizer (sox dependency stubbed out), and forces TF32 off on every torch CUDA matmul path so Python results stay bit comparable across runs and across machines. """ import os import struct import sys import types os.environ["NVIDIA_TF32_OVERRIDE"] = "0" import numpy as np import torch 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") # Stub the V1 25Hz tokenizer so qwen_tts.core imports without sox. UPSTREAM_ROOT = "../../Qwen3-TTS" sys.path.insert(0, UPSTREAM_ROOT) class _StubV1Config: pass class _StubV1Model: pass _qwen_pkg = types.ModuleType("qwen_tts") _qwen_pkg.__path__ = [os.path.join(UPSTREAM_ROOT, "qwen_tts")] sys.modules["qwen_tts"] = _qwen_pkg _core_pkg = types.ModuleType("qwen_tts.core") _core_pkg.__path__ = [os.path.join(UPSTREAM_ROOT, "qwen_tts", "core")] # Inject the stubbed core module before any submodule import so the real # qwen_tts/core/__init__.py never runs : it pulls the V1 25Hz tokenizer that # imports whisper_encoder, which prints a flash-attn warning at module load. sys.modules["qwen_tts.core"] = _core_pkg from qwen_tts.core.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Config from qwen_tts.core.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import Qwen3TTSTokenizerV2Model _core_pkg.Qwen3TTSTokenizerV1Config = _StubV1Config _core_pkg.Qwen3TTSTokenizerV1Model = _StubV1Model _core_pkg.Qwen3TTSTokenizerV2Config = Qwen3TTSTokenizerV2Config _core_pkg.Qwen3TTSTokenizerV2Model = Qwen3TTSTokenizerV2Model from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSForConditionalGeneration from qwen_tts.core.models.configuration_qwen3_tts import Qwen3TTSConfig from qwen_tts.core.models.processing_qwen3_tts import Qwen3TTSProcessor from transformers import AutoConfig, AutoModel, AutoProcessor from transformers.utils import logging as hf_logging # Silence the GenerationConfig validator that warns "flags are not valid # and may be ignored" for temperature / top_k / top_p when do_sample=False. # Those flags ride along inside the checkpoint generation_config and the # greedy path drops them on purpose, the warning is just noise here. hf_logging.set_verbosity_error() # Register the Qwen3-TTS classes once per process. Calling twice raises a # ValueError inside transformers, hence the guard. _REGISTERED = {"done": False} def register_qwen3_tts(): if _REGISTERED["done"]: return AutoConfig.register("qwen3_tts", Qwen3TTSConfig) AutoModel.register(Qwen3TTSConfig, Qwen3TTSForConditionalGeneration) AutoProcessor.register(Qwen3TTSConfig, Qwen3TTSProcessor) _REGISTERED["done"] = True # Path to the C++ qwen-tts binary, relative to tests/. BIN = "../build/qwen-tts" # Standard stage list shared by every cossim script. Mode-specific scripts # may extend this list before iterating (eg. clone adds SpeakerEmb / RefCodes). STAGES_STANDARD = [ ("Embed", "talker-input-embed.bin"), ("TrailingText", "trailing-text-hidden.bin"), ("TTSPadEmbed", "tts-pad-embed.bin"), ("L0", "talker-hidden-prefill-l0.bin"), ("L7", "talker-hidden-prefill-l7.bin"), ("L14", "talker-hidden-prefill-l14.bin"), ("L21", "talker-hidden-prefill-l21.bin"), ("L27", "talker-hidden-prefill-l27.bin"), ("Final", "talker-hidden-prefill-final.bin"), ("Logits", "talker-logits-prefill.bin"), ("NextEmbStep0", "next-emb-step0.bin"), ("TalkerHiddenStep1", "talker-hidden-step1.bin"), ] # Philox4x32-10 mirror of src/philox.h. Returns the same float u that # philox_uniform_fill(seed, subseq, ctr_lo=0) gives for n=1. PHILOX_M0 = np.uint32(0xD2511F53) PHILOX_M1 = np.uint32(0xCD9E8D57) PHILOX_W0 = np.uint32(0x9E3779B9) PHILOX_W1 = np.uint32(0xBB67AE85) TWO_POW32_INV = np.float32(2.3283064365386963e-10) def _mulhilo32(a, b): p = np.uint64(a) * np.uint64(b) lo = np.uint32(p & np.uint64(0xFFFFFFFF)) hi = np.uint32(p >> np.uint64(32)) return hi, lo def _philox_round(ctr, k0, k1): hi0, lo0 = _mulhilo32(PHILOX_M0, ctr[0]) hi1, lo1 = _mulhilo32(PHILOX_M1, ctr[2]) return (np.uint32(hi1 ^ ctr[1] ^ k0), np.uint32(lo1), np.uint32(hi0 ^ ctr[3] ^ k1), np.uint32(lo0)) def _philox4x32_10(ctr, k0, k1): mask = np.uint64(0xFFFFFFFF) for _ in range(9): ctr = _philox_round(ctr, k0, k1) k0 = np.uint32((np.uint64(k0) + np.uint64(PHILOX_W0)) & mask) k1 = np.uint32((np.uint64(k1) + np.uint64(PHILOX_W1)) & mask) ctr = _philox_round(ctr, k0, k1) return ctr def philox_uniform(seed, subseq, ctr_lo=0): slo = np.uint32(np.uint64(seed) & np.uint64(0xFFFFFFFF)) shi = np.uint32(np.uint64(seed) >> np.uint64(32)) ctr = (np.uint32(ctr_lo), np.uint32(0), np.uint32(np.uint64(subseq) & np.uint64(0xFFFFFFFF)), np.uint32(np.uint64(subseq) >> np.uint64(32))) r = _philox4x32_10(ctr, slo, shi) return (np.float32(r[0]) + np.float32(0.5)) * TWO_POW32_INV # Globals advanced exactly once per multinomial sample, mirroring the C++ # side which advances subseq_counter at every sample_top_k_p call. _subseq_counter = [0] _seed = [42] _trace_samples = [False] def reset_philox(seed): _subseq_counter[0] = 0 _seed[0] = int(seed) def set_trace(flag): _trace_samples[0] = bool(flag) def patched_multinomial(input, num_samples, replacement=False, generator=None, out=None): """Drop in replacement for torch.multinomial(num_samples=1) that pulls the uniform draw from our Philox stream and walks the F32 cumulative sum the same way src/sampling.h does.""" assert num_samples == 1, "patched_multinomial only handles num_samples=1" probs = input if probs.dim() == 1: probs = probs.unsqueeze(0) bsz, vocab = probs.shape out_ids = torch.zeros((bsz, 1), dtype=torch.long, device=probs.device) for b in range(bsz): u = philox_uniform(_seed[0], _subseq_counter[0], 0) seq = _subseq_counter[0] _subseq_counter[0] += 1 row = probs[b].to(torch.float32).cpu().numpy() s = float(row.sum()) # The C++ sampler draws u in [0, 1) and compares against acc/sum # implicitly via acc >= u*sum. We replicate that exact arithmetic. target = float(u) * s acc = 0.0 idx = vocab - 1 for i in range(vocab): acc += float(row[i]) if acc >= target: idx = i break out_ids[b, 0] = idx if _trace_samples[0] and seq < 32: print(f"[Sample-PY] subseq={seq} u={float(u):.10f} idx={idx} top_prob={float(row.max()):.6f}") if input.dim() == 1: return out_ids.squeeze(0) return out_ids def ensure_dir(path): os.makedirs(path, exist_ok=True) def save_dump(path, data): 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 save_dump_i32(path, data): if isinstance(data, torch.Tensor): data = data.detach().to(torch.int64).cpu().numpy() data = np.ascontiguousarray(data.astype(np.int64)) shape = data.shape fdata = data.astype(np.float32) 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(fdata.tobytes()) def load_dump(path): 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): 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, bisect_layers=(0, 7, 14, 21, 27)): """Capture every intermediate tensor we can pin against the C++ side. Layout : bisection layers, final norm, prefill logits, the input embed fed to the talker and the trailing-text overlay tensors that drive the next-token embedding sums during generation. Counters track how many times the talker submodel has run so step 1 (first single-token forward after prefill) gets its own dump.""" seen_prefill = {"done": False} seen_codes = {"done": False} # talker_step counts how many times talker_model.forward has been called # after the prefill. Prefill itself is recorded as 0, the first single # token forward is 1, and so on. Single token forwards are detected by # inputs_embeds.shape[1] == 1 in the pre hook. talker_step = {"n": 0} talker_model = model.talker.model talker_lm = model.talker 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: save_dump(os.path.join(dump_dir, f"talker-hidden-prefill-l{layer_idx}.bin"), h[0]) seen_layers[layer_idx] = True return hook for layer_idx in bisect_layers: talker_model.layers[layer_idx].register_forward_hook(make_layer_hook(layer_idx)) seen_norm = {"done": False} def norm_hook(module, inputs, output): if seen_norm["done"]: return if output.dim() == 3 and output.shape[1] > 1: save_dump(os.path.join(dump_dir, "talker-hidden-prefill-final.bin"), output[0]) seen_norm["done"] = True talker_model.norm.register_forward_hook(norm_hook) # Pre-hook on the talker submodel : sees inputs_embeds whether the outer # talker forward was invoked with input_ids (single token step) or # inputs_embeds (prefill). The submodel always receives inputs_embeds # because the wrapper rebuilds it before calling self.model. def talker_model_pre_hook(module, args, kwargs): ie = kwargs.get("inputs_embeds", None) if ie is None: return if ie.dim() != 3: return if ie.shape[1] > 1: return if talker_step["n"] == 0: save_dump(os.path.join(dump_dir, "next-emb-step0.bin"), ie[0, 0]) talker_step["n"] += 1 talker_model.register_forward_pre_hook(talker_model_pre_hook, with_kwargs=True) # Post-hook on the talker submodel : captures last_hidden_state at step # 1 (first single token forward). That tensor is what feeds the code # predictor at step 1, so any drift between Python and C++ tells us the # next-emb-step0 changed the talker forward result. talker_post_step = {"n": 0} def talker_model_post_hook(module, inputs, output): last = output.last_hidden_state if hasattr(output, "last_hidden_state") else output[0] if last.dim() != 3 or last.shape[1] != 1: return if talker_post_step["n"] == 0: save_dump(os.path.join(dump_dir, "talker-hidden-step1.bin"), last[0, -1]) talker_post_step["n"] += 1 talker_model.register_forward_hook(talker_model_post_hook) # Talker LM wrapper hook : captures the prefill input embed (the talker # codec_embedding sum + text projection that mirrors what # prompt_builder_build produces in C++), the prefill logits, and the # trailing_text_hidden / tts_pad_embed overlay tensors carried by the # output dataclass at every step (we only dump them once). seen_overlay = {"done": False} orig_talker_forward = talker_lm.forward def hooked_talker_forward(*args, **kwargs): inputs_embeds = kwargs.get("inputs_embeds", None) if (inputs_embeds is not None and inputs_embeds.dim() == 3 and inputs_embeds.shape[1] > 1 and not seen_prefill["done"]): save_dump(os.path.join(dump_dir, "talker-input-embed.bin"), inputs_embeds[0]) seen_prefill["done"] = True out = orig_talker_forward(*args, **kwargs) if (out is not None and getattr(out, "logits", None) is not None and not seen_codes["done"]): logits = out.logits if logits.dim() == 3 and logits.shape[1] > 1: save_dump(os.path.join(dump_dir, "talker-logits-prefill.bin"), logits[0, -1]) seen_codes["done"] = True if (out is not None and not seen_overlay["done"] and getattr(out, "trailing_text_hidden", None) is not None and getattr(out, "tts_pad_embed", None) is not None): tth = out.trailing_text_hidden tpe = out.tts_pad_embed if tth.dim() == 3 and tpe.dim() == 3: save_dump(os.path.join(dump_dir, "trailing-text-hidden.bin"), tth[0]) save_dump(os.path.join(dump_dir, "tts-pad-embed.bin"), tpe[0, 0]) seen_overlay["done"] = True return out talker_lm.forward = hooked_talker_forward def pair(name, dump_cpp, dump_pt): a, _ = load_dump(os.path.join(dump_cpp, name)) b, _ = load_dump(os.path.join(dump_pt, name)) return a, b 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()) def compare_stages(stages, dump_cpp, dump_pt): """Iterate the stages list and print one line per pair. Skips silently when a dump file is missing (eg. a mode that does not produce a given intermediate).""" for label, name in stages: try: a, b = pair(name, dump_cpp, dump_pt) except FileNotFoundError: print(f"[Cossim] {label} skipped (missing dump)") continue c, mx, mn = metric(a, b) print(f"[Cossim] {label} cos: {c:.6f} max: {mx:.4e} mean: {mn:.4e}") def compare_exact_i32(name, dump_cpp, dump_pt, label): """Compare two int dumps stored as f32 (the encoding path used by both save_dump_i32 in Python and debug_dump_i32_as_f32 in C++). Prints an exact match percentage. Returns the percentage as a float.""" a, b = pair(name, dump_cpp, dump_pt) ai = a.astype(np.int64).ravel() bi = b.astype(np.int64).ravel() n = min(ai.size, bi.size) pct = 100.0 * float(np.mean(ai[:n] == bi[:n])) print(f"[Cossim] {label} exact: {pct:.2f}% ({n} values)") return pct # Greedy generation kwargs shared by every cossim script. do_sample=False # alone selects argmax, top_k / top_p / temperature are intentionally # omitted because GenerationConfig flags them as "not valid" warnings when # do_sample=False. The subtalker_* keys are custom kwargs forwarded to the # talker forward, the talker validator is bypassed by the script main. GEN_KWARGS_GREEDY = dict( do_sample = False, subtalker_dosample = False, repetition_penalty = 1.0, )