kazeia/TTS_RAPPORT_COMPLET.md

278 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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