kazeia/TTS_RAPPORT_COMPLET.md

13 KiB
Raw Blame History

Rapport complet TTS Qwen3-TTS — Projet Kazeia

Du point de départ au RTF 2.42 sur NPU Hexagon

2026-04-02


1. Point de départ

Modèle choisi

Qwen3-TTS-12Hz-0.6B-Base (Alibaba/Qwen)

  • 757M paramètres (talker) + 83M (code predictor) + decoder conv
  • 12 Hz codec (12 frames/seconde), 16 codebooks RVQ par frame
  • Voice cloning obligatoire via x-vector (pas de voix built-in)
  • 10 langues : français, anglais, allemand, espagnol, japonais, chinois, coréen, russe, italien, portugais

Architecture du pipeline

Texte → Tokenizer → Prefill (10 tokens)
                         ↓
                    [Boucle autoregressive × ~50 steps]
                    │  Talker (28 layers) → logits CB0 + hidden state
                    │  Sampling (temp=0.9, top_k=50, rep_penalty=1.05)
                    │  Code Predictor (5 layers × 17 passes) → CB1-CB15
                    │  Somme 16 embeddings + texte trailing → next input
                    └→ [Fin sur EOS token 2150]
                         ↓
                    VQ Decode → Speech Decoder (conv) → Audio PCM 24kHz

2. Bugs critiques découverts et corrigés

Bug 1 : tts_pad manquant (LE bug critique)

  • Symptôme : le modèle ne générait JAMAIS le token EOS, produisant 100+ tokens sans arrêt
  • Cause : après épuisement des tokens texte, notre code envoyait des zéros. Le modèle Python envoie tts_pad_embed
  • Impact : sans ce fix, aucun pipeline TTS ne pouvait fonctionner correctement
  • Correction : une ligne → nextEmbed = sumEmb(codecSum, padE) au lieu de nextEmbed = codecSum

Bug 2 : q_norm / k_norm oubliés dans le CP

  • Symptôme : le CP exporté en KV-cache divergeait complètement au step 2
  • Cause : l'attention du CP applique RMSNorm sur Q et K avant le rotary embedding. Notre wrapper manuel l'oubliait
  • Impact : 0/15 codebooks corrects
  • Correction : ajout de attn.q_norm() et attn.k_norm() dans le wrapper

Bug 3 : Role prefill (assistant vs user)

  • Symptôme : tokens incorrects dès le prefill
  • Cause : le prefill utilisait le token "user" au lieu de "assistant" en mode voice cloning
  • Correction : TOKEN_ASSISTANT = 1042

Bug 4 : M-RoPE multimodal du talker

  • Symptôme : le talker exporté produisait des logits différents du PyTorch
  • Cause : le talker utilise M-RoPE avec mrope_section=[24,20,20] et interleaved=True, pas le RoPE standard
  • Correction : pré-calcul des cos/sin avec apply_interleaved_rope et passage en inputs

Bug 5 : DSP partagé (hexagon runner vs QNN decoder)

  • Symptôme : le décodeur QNN crashait avec erreur 6031 après l'utilisation du runner hexagon
  • Cause : le runner hexagon gardait une session HTP ouverte qui bloquait le QNN decoder
  • Correction : QUIT le runner et pkill avant le décodage

3. Tentatives d'accélération NPU — Échecs instructifs

3.1 ONNX Runtime QNN EP (backend HTP)

Config Résultat Cause
Talker HTP default EOS prématuré (1.4-2.2s) Quantification int8/int16 automatique
Talker HTP "fp16" Idem L'option enable_htp_fp16_precision n'a aucun effet
CP HTP default Pas d'EOS (185 tokens) Codebooks corrompus
CP HTP fp16 Idem Même quantification destructive

Conclusion : ONNX Runtime QNN EP quantifie TOUJOURS en int8/int16 via le QNN SDK, même avec les flags fp16. Le QNN SDK ne fait PAS de vrai fp16 IEEE-754.

3.2 ExecuTorch .pte (backend HTP)

Config Résultat Cause
Talker fp16 .pte Silence (EOS OK mais audio vide) fp16 HTP ≠ vrai fp16
CP fp16 .pte Bruit Codebooks totalement faux
Talker 16a8w calibré Inintelligible (EOS OK, 102 tokens) Même avec calibration, pas assez précis
Talker split (NPU backbone + CPU lm_head) EOS prématuré Hidden states NPU déjà corrompus
Talker SmoothQuant + split Inintelligible SmoothQuant ne corrige pas la quantification HTP

Conclusion : le HTP (via QNN SDK) est incompatible avec les modèles TTS autoregressifs. La quantification détruit la précision des codebooks, même en fp16, même avec calibration, même avec SmoothQuant.

3.3 GPU Adreno (ONNX Runtime QNN)

Config Résultat Cause
Talker GPU fp16 Audio parfait, tokens identiques Vrai fp16 IEEE-754 natif
Talker GPU fp32 Audio parfait fp32 natif

Mais : vitesse GPU = vitesse CPU (130ms/step). L'overhead de transfert CPU↔GPU par token annule le gain. Pas d'accélération.

Conclusion : le GPU prouve que le vrai fp16 fonctionne pour le TTS. Le problème est le QNN SDK, pas le hardware.


4. La percée : ggml-hexagon (HMX FP16 natif)

4.1 Découverte

Le QNN SDK ne fait pas du vrai fp16 sur le HTP. Mais le hardware HMX (Hexagon Matrix eXtension) supporte nativement le fp16 IEEE-754. Le projet htp-ops-lib (Zixu Hao, EuroSys 2026) a reverse-engineeré les instructions HMX non documentées et les a intégrées dans llama.cpp via le backend ggml-hexagon.

4.2 Validation

Talker GGUF F16 sur Hexagon HMX :
  - Top codec token : NPU=1739, CPU=1739 → MATCH EXACT
  - Top 5 identiques : [1739, 1130, 808, 468, 663]
  - Max logit diff : 0.0226
  - Corrélation : 0.999998
  - Vitesse : 48 tok/s = ~21ms/step (benchmark)

4.3 Implémentation

  • Conversion GGUF : extraction des poids du talker en format Qwen3 GGUF F16 (852 MB)
  • Build : llama.cpp compilé avec le toolchain Docker ghcr.io/snapdragon-toolchain/arm64-android:v0.3
  • Runner C++ : tts-talker.cpp (talker) et tts-cp-runner.cpp (CP), communication via Unix domain sockets
  • IPC : sockets Unix entre l'app Kotlin et les runners root (chmod 666)
  • KV-cache : talker persistant entre les tokens, CP reset via llama_memory_clear à chaque appel

4.4 Architecture finale

App Kotlin (user process)
  ├── Embedding computation (CPU, trivial)
  ├── Sampling (CPU, trivial)
  ├── Socket write/read (1ms overhead)
  │
  ├── talker.sock ←→ llama-tts-talker (root, Hexagon HMX FP16)
  │                    └── 28 layers Qwen3, KV-cache persistant
  │
  ├── cp.sock ←→ llama-tts-cp (root, Hexagon HMX FP16)  
  │                └── 5 layers Qwen3, 15 heads CPU matmul
  │
  └── ONNX Runtime QNN (HTP) pour le décodeur audio
       ├── pre_conv → preprocessor → conv_decoder
       └── Exécuté APRÈS que les runners hexagon sont stoppés (DSP partagé)

5. Évolution des performances

Étape Talker CP Decode RTF Date
CPU pur (ONNX, 4 threads) 130ms 350ms (fullseq) 3s NPU 7.0 01/04
+ 6 threads + CP KV-cache 107ms 202ms 3s 4.95 01/04
+ Talker Hexagon NPU (fichiers) 42ms 201ms 3s 3.94 02/04
+ CP Hexagon NPU (fichiers) 42ms 168ms 3s 3.73 02/04
+ Socket IPC 27ms 88ms 3.5s 2.42 02/04
+ memory_clear CP 27ms 85ms 3.5s 2.42 02/04

Gain total : RTF 7.0 → 2.42 = 2.9× plus rapide

Décomposition du temps (steady state, ~52 tokens)

Génération : 6.0s (60% du total)
  ├── Talker HMX : 27ms/step × 52 = 1.4s (23% de la gen)
  └── CP HMX+CPU : 88ms/step × 52 = 4.6s (77% de la gen)
       ├── 17 × llama_decode NPU : ~68ms
       └── 15 × head matmul CPU : ~15ms
       └── Context clear + IPC : ~5ms

Prefill : 0.3s (3%)

Decode NPU : 3.5s (35%)
  └── VQ lookup + pre_conv + preprocessor + conv_decoder

Runner startup : 14s talker + 13s CP (one-time au lancement app)

6. Tentatives avortées ou en suspens

Streaming (play pendant la génération)

  • Problème : le décodeur QNN et les runners hexagon ne peuvent pas coexister sur le DSP
  • Status : le synthesizeAndPlay en mode streaming bloque sur le decode QNN
  • Solution possible : decoder sur GPU Adreno ou CPU (pas le HTP)

CP sur le même process que le talker (dual-model)

  • Problème : deux contextes llama dans le même process se marchent dessus sur le HTP
  • Solution : deux processes séparés (fonctionnel)

NeuTTS Air

  • Testé : chargement OK, génération RTF 1.04 sur PC x86
  • Status : non intégré, qualité français à valider
  • Intérêt : single codebook FSQ → potentiellement compatible NPU quantifié

Quantification calibrée (16a8w)

  • Données : 2618 tenseurs talker + 39270 CP collectés (10 langues, 50 phrases)
  • Status : testé, inintelligible. La calibration ne suffit pas pour le TTS RVQ

7. Fichiers et déploiement

Sur le PC (/opt/Kazeia/)

Fichier Description Taille
models_qnn/talker_f16.gguf Talker GGUF pour Hexagon 852 MB
models_qnn/cp_f16.gguf CP GGUF pour Hexagon 158 MB
models_qnn/cp_heads.bin 15 lm_heads du CP 120 MB
models_qnn/cp_codec_embs.bin 15 embedding tables CP 120 MB
llama.cpp/build-snapdragon/ Build ARM64 avec Hexagon
llama.cpp/examples/tts-talker/ Source des runners
models_qnn/calibration_data/ Données de calibration 338 MB
TTS_RAPPORT_COMPLET.md Ce rapport
TTS_HEXAGON_NPU_GUIDE.md Guide Hexagon
TTS_GPU_GUIDE.md Guide GPU Adreno

Sur la tablette (/data/local/tmp/kazeia/)

Chemin Description
llama-hex/llama-tts-talker Runner talker ARM64
llama-hex/llama-tts-cp Runner CP ARM64
llama-hex/libggml-htp-v79.so Skel Hexagon v79 (HMX FP16)
llama-hex/lib*.so Libs llama.cpp
models/talker_f16.gguf Talker GGUF
models/cp_f16.gguf CP GGUF
models/cp_heads.bin Heads CP
models/cp_codec_embs.bin Embeddings CP
models/qwen3-tts-npu/ Modèles ONNX + embeddings
talker.sock Socket Unix talker
cp.sock Socket Unix CP

Dans l'app (kazeia-android/)

Fichier Description
app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt Moteur TTS complet
app/src/main/jniLibs/arm64-v8a/libQnnHtp.so QNN HTP pour decoder
app/src/main/jniLibs/arm64-v8a/libQnnGpu.so QNN GPU (validé, pas utilisé)
app/src/main/jniLibs/arm64-v8a/libexecutorch.so ExecuTorch JNI (validé, pas utilisé)

8. Leçons apprises

  1. Le QNN SDK ment sur le fp16 — il quantifie toujours en int8/int16 même avec use_fp16=True. Le vrai fp16 n'est accessible que via les instructions HMX reverse-engineerées (ggml-hexagon)

  2. Le GPU Adreno fait du vrai fp16 — tokens identiques au CPU, prouvant que le fp16 IEEE-754 est suffisant pour le TTS. C'est le GPU qui nous a donné la preuve que le problème était le QNN SDK, pas la précision fp16

  3. Les modèles TTS RVQ sont incompatibles avec la quantification — contrairement aux LLM qui tolèrent int4, le TTS avec 16 codebooks RVQ est détruit par la moindre erreur de quantification. L'argmax sur 2048 valeurs avec des marges fines ne pardonne pas

  4. ggml-hexagon est la clé — les kernels HMX reverse-engineerés de htp-ops-lib donnent accès à 12 TFLOPS fp16 natif, contournant complètement le QNN SDK. Le même NPU, des résultats radicalement différents

  5. Deux processes sur le même HTP fonctionnent — contrairement au dual-model dans le même process, deux processes séparés avec chacun leur contexte HTP coexistent

  6. Les sockets Unix sont 5× plus rapides que les fichiers — IPC par fichier ajoutait ~50ms par call, les sockets réduisent à ~1ms

  7. Le DSP est un goulot de partage — le runner hexagon et le décodeur QNN ne coexistent pas bien, même séquentiellement. Le streaming est bloqué par ce conflit


9. État actuel et prochaines étapes

Performance actuelle

  • RTF 2.42 (10s pour 4s audio, hors cold start)
  • Audio parfait, tokens identiques au CPU, EOS naturel
  • Voice cloning fonctionnel (voix Damien)
  • Cold start : ~28s (chargement runners + modèles)

Architecture cible compatible Unity

LLM Qwen3-0.6B  → NPU HTP INT4 (ExecuTorch, 93 tok/s)
Whisper STT      → NPU HTP (ONNX Runtime QNN)
TTS Talker       → Hexagon HMX FP16 (ggml-hexagon, 37 tok/s)
TTS CP           → Hexagon HMX FP16 (ggml-hexagon, 11 tok/s)
TTS Decoder      → NPU HTP (ONNX Runtime QNN, séquentiel après runners)
Silero VAD       → CPU
Unity Avatar 3D  → GPU Adreno 100% libre

Optimisations restantes

  1. Streaming : decoder sur CPU ou GPU pour éviter le conflit DSP → premier son à ~7s au lieu de ~10s
  2. Cold start : pré-charger les runners au boot de l'app, pas à chaque génération
  3. CP optimisation : batched prefill (2 tokens), head matmuls sur NPU
  4. BPE tokenizer : remplacer les phrase_embeds pré-calculés par un vrai tokenizer
  5. Multi-voix : supporter plusieurs x-vectors pour différents personnages