tests infra HTP/HMX : verdict, le HTP n'est pas le bon outil pour TTS

5 tests systématiques sur les hypothèses non-explorées :

A. USE_HMX=0 vs USE_HMX=1 sur sched CP :
   - HVX 20% plus rapide que HMX à batch=1 (tile 32×32 sous-utilisé)
   - Mais aucun ne bat baseline CPU (110 ms/frame)
   - HVX et HMX bit-exact entre eux ; les deux divergent de CPU (codes
     différents -> trajectoire talker diverge -> N=64 vs 33).

B. op_offload=true vs false : false -16% sur CP HMX (placement strict
   moins coûteux). Quick win retenu (env KZTTS_CP_OFFLOAD).

C. Profile fin (KZTTS_CP_PROFILE=1) par sub-step :
   - sched_reset+alloc : 0.1 ms (négligeable !)
   - tensor_set/get : 0.0 ms (opt_hostbuf=1 = ION partagé)
   - compute : 6-11 ms (95 % du temps !)
   -> L'overhead sched n'est PAS le problème. Le compute HTP
      lui-même est plus lent que CPU pour batch=1. Hypothèse #1
      (sched_reserve + ctx persistant) INVALIDÉE.

D. Talker option C (use_htp=true, GGML_HEXAGON_GDN_PREFILL=1) :
   - Prefill : 87 ms -> 185 ms (+112%)
   - Talker per-frame : 32 ms -> 81 ms (+154%)
   -> Pour un petit modèle (0.6B), l'overhead routing HTP par token
      dépasse le gain compute. Le pattern option C documenté
      marche pour Qwen3.5-4B, PAS pour Qwen3-TTS-Talker.

VERDICT GLOBAL : le HTP est non-rentable sur TOUS nos workloads TTS.
- BigVGAN : bloqué (nrows > 1024)
- CP : régresse (batch=1)
- Talker : régresse (trop petit pour amortir routing)

CLAUDE.md le notait partiellement ('CPU NEON bat HVX 3.4× sur M=1') mais
on n'avait pas tiré la conséquence globale : notre TTS n'a pas de
workload HTP-friendly.

LEVIERS RESTANTS (sans HTP) :
1. Streaming pipeline (1 session) : RTF inchangé mais TTFB 5s->500ms
2. Réduction overhead CP CPU (½ session) : ctx réutilisé entre sub-forwards
3. Decoder optim NEON conv1d : R&D long
4. Quantization Q8/Q4 CP : compromis qualité

Code committé reste opt-in (KZTTS_CP_HTP, KZTTS_DECODER_HTP) et neutre
au runtime. Le path par défaut RTF=3.0 est intact.

Détails complets dans dist/PERF_INFRA_TESTS.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Richard Loyer 2026-05-29 16:40:56 +02:00
parent 48c66c03e1
commit 101d1cd7ed
2 changed files with 163 additions and 1 deletions

136
dist/PERF_INFRA_TESTS.md vendored Normal file
View File

@ -0,0 +1,136 @@
# Tests infra — où HTP/HMX échoue, et pourquoi (29/05)
Session de tests systématiques sur les hypothèses non-explorées du Phase 2b/F.
Verdict global : **le HTP n'est pas le bon outil pour notre TTS**, parce que
tous nos workloads sont des decodes batch=1 (talker, CP sub-forwards) ou des
matmul énormes (BigVGAN) au-delà de la limite backend `nrows > 1024`.
## A — Sched HMX vs HVX (USE_HMX=0)
Bench : CP + sched HTP, varier `GGML_HEXAGON_USE_HMX`.
| Config | CP/frame | N frames | RTF | WAV md5 |
|---|---:|---:|---:|---|
| Baseline CPU pur | 110 ms | 33 | 2.92 | unique |
| Sched HMX=1 | 226 ms | 64 | 4.48 | A2=A3 |
| Sched HMX=0 (HVX) | 180 ms | 64 | 3.81 | A2=A3 |
- HVX 20% plus rapide que HMX sur CP (tile 32×32 sous-utilisé à batch=1).
- HMX et HVX **produisent bit-exact le même WAV**.
- Les deux divergent du CPU baseline : N passe de 33 à 64 (le talker prend
une trajectoire différente parce que les codes CP ne sont plus bit-exact à NEON).
- Coût × 2 frames + overhead par-step = régression nette.
**Conclusion A** : HVX > HMX sur batch=1, mais aucun ne bat NEON.
## B — op_offload=false vs true
| Config | CP/frame |
|---|---:|
| HMX + op_offload=true | 216 ms |
| HMX + op_offload=false | 181 ms |
- Placement strict (false) = -16% sur CP. Moins de "magic" de placement adaptatif.
- Toujours plus lent que baseline CPU.
**Conclusion B** : `op_offload=false` est meilleur défaut. Quick win mais
ne sauve pas la régression.
## C — Profile fin par sub-step (KZTTS_CP_PROFILE)
Breakdown moyen d'un sub-forward (HVX) :
| Étape | Temps |
|---|---:|
| `sched_reset + sched_alloc_graph` | 0.1 ms |
| `ggml_backend_tensor_set` ×3 (x, pos_ids, mask) | 0.0 ms (< µs) |
| **`sched_graph_compute`** | **6-11 ms (croît avec pos_off)** |
| `ggml_backend_tensor_get` | 0.0 ms |
- **L'overhead sched est négligeable** : ~0.1 ms/step × 15 = 1.5 ms/frame.
Les copies CPU↔HTP sont gratuites (opt_hostbuf=1 = ION partagé).
- **Le compute lui-même domine** : 95% du temps.
- CPU NEON équivalent : ~7 ms/step, HVX 10 ms/step. HVX est intrinsèquement
**plus lent que NEON sur ces matmul batch=1**.
**Conclusion C MAJEURE** : ma hypothèse #1 (`sched_reserve` + ctx persistant)
est invalidée. L'overhead qu'on espérait éliminer n'existe pas — le compute
HTP est juste plus lent que CPU pour notre workload.
## D — Talker en HTP option C
Pour vérifier si le pattern HTP-prefill / CPU-decode (validé pour Qwen3.5-4B)
marche sur Qwen3-TTS-Talker (0.6B).
| Métrique | CPU pur | HTP option C |
|---|---:|---:|
| Prefill | 87 ms | 185 ms (**+112%**) |
| Talker per-frame | 32 ms | 81 ms (**+154%**) |
| N frames | 33 | 18 |
- Le talker TTS (0.6B) **REGRESSE sévèrement** en HTP option C.
- Pour un petit modèle, l'overhead routing HTP par token (DMA, sync, sched
inter-backend) dépasse le gain compute.
- Le pattern option C ne marche bien que pour des modèles assez gros pour
amortir le routing (Qwen3.5-4B = OK ; Qwen3-TTS 0.6B = perte).
**Conclusion D** : le HTP n'apporte rien sur ce talker non plus.
## Verdict cumulé
Le HTP est non-rentable sur **tous** nos workloads TTS :
| Composant | Workload | HTP gain réel | Raison |
|---|---|---|---|
| BigVGAN | conv1d batch ≥ 60k | ❌ rejeté | limite backend `nrows > 1024` |
| CP sub-forwards | matmul batch=1 ou 2 | ❌ régresse | compute HVX < NEON à batch=1 |
| Talker decode | batch=1 | ❌ régresse | overhead routing > gain compute (0.6B) |
| Talker prefill | batch ~10-20 | ❌ régresse | trop petit pour amortir |
Le projet l'avait noté en partie dans CLAUDE.md :
> « CPU NEON bat le HVX 3.4× sur Q6_K M=1 »
> « decode CPU verrouillé (~16.5 tok/s), HTP perdant decode (~3.6 tok/s) »
Mais on n'avait pas tiré la conséquence globale : **notre TTS n'a pas de
workload "prefill HTP-friendly"**. Les phrases courtes (10-20 tokens) sont
juste assez courtes que le routing coûte plus que le gain.
## Vrais leviers restants (sans HTP)
Avec le HTP exclu, les optims réelles sont :
1. **Streaming pipeline** (1 session) — chevaucher talker / CP / decoder
par chunks. Le RTF total ne baisse pas mais le TTFB perçu passe de ~5 s
à ~500 ms. **Le seul gain UX significatif réaliste.**
2. **Réduction overhead CP CPU** (½ session) — réutiliser le ctx ggml entre
les 15 sub-forwards par frame. L'overhead par-step est petit (~5 ms
d'init/free × 15 = ~75 ms) mais réel. Estimé -10 à -15% sur CP CPU.
3. **Decoder : optim NEON conv1d** (R&D) — actuellement compute-bound à 95%
du plafond NEON. Pour gagner faut vectoriser plus agressivement ou
restructurer les conv1d en blocked matmul. Sujet R&D.
4. **Quantization plus agressive du CP** (½ session) — Q8 ou Q4 sur les
poids matmul. Note CLAUDE.md : « Q8_0 CP perdu (76% codes vs 100% f16) »
= qualité dégrade. Donc compromis qualité/perf à explorer.
## Ce qui est DÉJÀ committé qui marche
- `KZTTS_CP_HTP=1` (sans SCHED) : poids CP sur HTP buffer, compute CPU pur.
Neutre en perf, prouve que la chaîne marche.
- `KZTTS_DECODER_HTP=1` (sans SCHED) : idem decoder. Neutre.
- Phase 2b refactor stage_bigvgan_sched : code propre, désactivé par défaut.
- Phase F refactor cp_forward_cached_step sched-friendly : idem.
Tout ça est opt-in et n'affecte pas le path par défaut (RTF 3.0).
## Recommandation finale
Le ROI / risque dit clairement : **streaming pipeline (#1) est le seul vrai
gain accessible**. RTF<1 sur HTP demande de modifier le backend hexagon
(semaines), ce qui sort du scope.
Si Richard accepte RTF=3 mais TTFB=500 ms comme livrable acceptable, le
chemin est clair en 1-2 sessions. Sinon, le frontier kernel HVX BigVGAN
reste sur la table mais c'est un autre projet.

View File

@ -3,6 +3,7 @@
#include <gguf.h>
#include <ggml-backend.h>
#include <ggml-cpu.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@ -200,9 +201,11 @@ bool cp_load_with_backends(CPState& s, const char* gguf_path,
std::vector<ggml_backend_buffer_type_t> bufts;
bufts.reserve(s.backends.size());
for (auto b : s.backends) bufts.push_back(ggml_backend_get_default_buffer_type(b));
// op_offload toggle via KZTTS_CP_OFFLOAD (default 1 = true).
const bool op_offload = !(getenv("KZTTS_CP_OFFLOAD") && atoi(getenv("KZTTS_CP_OFFLOAD")) == 0);
s.sched = ggml_backend_sched_new(s.backends.data(), bufts.data(),
(int)s.backends.size(), /*graph_size=*/8192,
/*parallel=*/false, /*op_offload=*/true);
/*parallel=*/false, op_offload);
if (!s.sched) { fprintf(stderr, "cp_load_with_backends: sched_new FAIL\n"); return false; }
fprintf(stderr, "cp_load_with_backends: %lld tensors (f16=%zu f32=%zu) sur %s, sched ON\n",
(long long)n, kept_f16, conv_f32, ggml_backend_name(s.backends[0]));
@ -490,23 +493,46 @@ static void cp_forward_cached_step(CPState& s, const float* embeds_new, int n_ne
out_hn.resize(N_EMBD);
if (use_sched) {
// Profile fin via KZTTS_CP_PROFILE=1 : décompose alloc / set / compute / get
// pour identifier où va le temps dans la boucle sched.
const bool prof = (getenv("KZTTS_CP_PROFILE") && atoi(getenv("KZTTS_CP_PROFILE")) != 0);
auto nows = []() {
return std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch()).count();
};
double t_a0=0, t_a1=0, t_s1=0, t_c1=0, t_g1=0;
if (prof) t_a0 = nows();
ggml_backend_sched_reset(s.sched);
if (!ggml_backend_sched_alloc_graph(s.sched, gf)) {
fprintf(stderr, "cp_forward_cached_step: sched_alloc_graph FAIL\n");
ggml_free(ctx); return;
}
if (prof) t_a1 = nows();
// Inputs uploadés APRÈS sched_alloc_graph (les tensors ont maintenant leur backend buf).
ggml_backend_tensor_set(x, embeds_new, 0, (size_t)N_EMBD * n_new * sizeof(float));
ggml_backend_tensor_set(pos_ids, pos_host.data(), 0, pos_host.size() * sizeof(int32_t));
ggml_backend_tensor_set(mask, mask_host.data(), 0, mask_host.size() * sizeof(float));
if (prof) t_s1 = nows();
if (ggml_backend_sched_graph_compute(s.sched, gf) != GGML_STATUS_SUCCESS) {
fprintf(stderr, "cp_forward_cached_step: sched_graph_compute FAIL\n");
ggml_free(ctx); return;
}
if (prof) t_c1 = nows();
// hn = colonne (n_new - 1) de x [N_EMBD, n_new]. tensor_get avec offset.
ggml_backend_tensor_get(x, out_hn.data(),
(size_t)N_EMBD * (n_new - 1) * sizeof(float),
(size_t)N_EMBD * sizeof(float));
if (prof) {
t_g1 = nows();
fprintf(stderr, "cp_sub n_new=%d pos=%d reset+alloc=%.2fms set=%.2fms compute=%.2fms get=%.2fms\n",
n_new, pos_off,
(t_a1-t_a0)*1000.0, (t_s1-t_a1)*1000.0,
(t_c1-t_s1)*1000.0, (t_g1-t_c1)*1000.0);
}
} else {
ggml_graph_compute_with_ctx(ctx, gf, s.n_threads);
memcpy(out_hn.data(), (float*)x->data + (size_t)N_EMBD * (n_new - 1),