chantier B STT #4 : audit dev pris en compte — options QNN explicites + bench apples-to-apples

Critique du dev Kazeia juste : la comparaison RTF 0.18 (10s) vs 0.51 (1.6s) prod
était pommes/oranges (encoder fixe amorti sur audio long), et l'encoder +152 %
non investigué cachait un vrai problème de config QNN par défaut.

Investigation : options QNN ExecutionProvider explicites manquaient
 - htp_performance_mode = "burst" (au lieu de default = balanced)
 - htp_arch = "79" (au lieu de auto-detect)
 - enable_htp_fp16_precision = "1" (NPU fp16 natif)
 - SetGraphOptimizationLevel(ORT_ENABLE_ALL) + DisableMemPattern()

Impact mesuré (5 runs warm, audio FR continu, 12 tokens / 1.6s) :
 - Encoder : 309 ms -> 62 ms  (-80 %)
 - Decoder/token : 48 ms -> 14 ms  (-71 %)
 - Total : 962 ms -> 320 ms  (-67 %)
 - RAM peak : 377 MB inchangée

Cas réel Kazeia (1.6s, extrapolé 22 tokens) :
 - Prod Kotlin : 820 ms (mel 189 + enc 125 + 22*23)
 - C++ unifié  : ~450 ms (mel 80 + enc 62 + 22*14)
 - Gain réel : -45 % sur le workload réel (pas amortissement)

Audio long (15s, 71 tokens) : decoder 10 ms/tok, RTF 0.06.

Mode warm bench (KZSTT_RUNS=N) : exerce N transcribes successifs sur le même
engine pour distinguer cold-start (1er run) vs warm path (suivants).

Recommandation prod Kotlin : ajouter les mêmes options QNN (encOpts.addQnn
multimap) pour gagner sans migration. Documenté dans STT_INTEGRATION.md §7bis.

A/B accuracy : 10 utterances FR transcrites sortent du texte FR cohérent
(avec erreurs typiques Whisper-Small sur mots rares : "Vagons" vs "Wagons",
"zéfière" vs "zéphyr"). La comparaison CER/WER vs WhisperHybridEngine sur
les mêmes fichiers reste à faire côté app (checklist §9.2) — non faisable
standalone sans la prod Kotlin en CLI.

Remerciements : critique du dev a permis de trouver une optim qui sert
TANT la version C++ unifiée QUE la prod Kotlin actuelle.
This commit is contained in:
Richard Loyer 2026-06-01 09:08:22 +02:00
parent 970f731e01
commit c9e198332d
3 changed files with 94 additions and 27 deletions

View File

@ -5,22 +5,37 @@
**Runtime** : ONNX Runtime 1.24.3 + QNN ExecutionProvider HTP V79 — **inchangé**. Pas de régression NPU (les contextes QAIRT Qualcomm 545 MB sont conservés tels quels). **Runtime** : ONNX Runtime 1.24.3 + QNN ExecutionProvider HTP V79 — **inchangé**. Pas de régression NPU (les contextes QAIRT Qualcomm 545 MB sont conservés tels quels).
**Perf de référence** (mesures sur Snapdragon 8 Elite / SM8750, Whisper-Small, FR continu, decoder zero-copy) : **Perf de référence** (Snapdragon 8 Elite / SM8750, Whisper-Small FR, warm path) :
| Métrique | Prod actuelle (Kotlin) | Lib unifiée (C++ zero-copy) | Gain | Cas réel Kazeia (audios ~1.6 s, parole dense, **apples-to-apples par étape** vs prod) :
| Étape | Prod Kotlin (22 tok) | C++ (warm, 12 tok mesuré) | Extrapol 22 tok C++ |
|---|---:|---:|---:| |---|---:|---:|---:|
| Mel extraction | 189 ms (DFT brute force) | **57 ms** (FFT radix-2) | -70 % | | Mel | 189 ms | 80 ms | 80 ms |
| Encoder NPU | 125 ms | 315 ms* | +152 % | | Encoder | 125 ms | **62 ms** | 62 ms |
| Decoder/token NPU | 23 ms | **36 ms** | +56 % | | Decoder/token | 23 ms | **14 ms** | 14 ms |
| **RTF (10 s audio, 39 tokens FR continu)** | 0.51 (sur 1.6 s, 22 tokens) | **0.18** | **2.8× mieux** | | Decoder total | 506 ms | 164 ms (12 tok) | ~308 ms |
| **RAM peak** | 545 MB | **379 MB** | **-30 %** | | **Total** | **820 ms** | **320 ms (12 tok)** | **~450 ms (-45 %)** |
| **RAM peak** | 545 MB | **377 MB** | **-31 %** |
\* L'encoder C++ reste localement plus lent qu'ORT Java (overhead de l'initial Ort::Value bound — différence d'optim interne ORT). Sur le RTF global et la RAM, **la version C++ bat la prod**. Le decoder a été optimisé en zero-copy (Ort::Value persistants pointant sur les buffers self_k/v, memcpy in-place des outputs) — gain x2.5 vs version initiale. Cas audio long (10-15 s, parole continue, montre l'amortissement encoder) :
**Vérifs sanity faites au runtime** : | Audio | Tokens | Total | RTF | Decoder/tok |
- Logits decoder shape = 51865 (= constante `VOCAB_SIZE`), pas de buffer overread sur argmax |---|---:|---:|---:|---:|
- vocab.json (50258 tokens) + tokens spéciaux Whisper (1607 langue/timestamp) couvrent les 51865 logits | 10 s richard FR | 39 | 1390 ms | 0.14 | 36 ms |
- Transcription FR cohérente sur 5 audios différents (damien, richard 1.6/3/5/10 s) | 15 s richard FR | 71 | 896 ms (warm) | 0.06 | 10 ms |
**Clé du gain : options QNN explicites** (cf §X plus bas). Sans ces options, l'encoder est ~5× plus lent (par défaut QNN tourne en mode `default` au lieu de `burst`, n'identifie pas l'arch V79, n'active pas le fp16 natif).
**Decoder/token 14 ms** (-39 % vs prod 23 ms) grâce à :
1. **Options QNN** : burst + v79 + enable_htp_fp16_precision = NPU à fréquence max
2. **Zero-copy** : `Ort::Value` persistants pointant sur les buffers self_k/v (pas de `vector::emplace_back` qui copiait 12 MB/step)
**Vérifs sanity au runtime** :
- Logits shape = 51865 (= constante `VOCAB_SIZE`), pas de buffer overread
- 5 runs warm successifs : encoder 60-63 ms stable, decoder 160-162 ms stable, RAM 377 MB inchangée
- Transcription FR cohérente sur 10+ audios différents (damien, richard 1.6/3/5/10/15 s)
- Texte produit lisible et plausible. **A/B comparison vs `WhisperHybridEngine` Kotlin sur les mêmes fichiers reste à faire côté app** — c'est le seul moyen de quantifier le drift FP16 NPU éventuel (cf checklist §9 point 2).
\* Decoder et encoder C++ légèrement plus lents qu'en ORT Java à cause d'overhead memcpy fp16 ; optim future via IO bindings ORT. Le RTF global reste sous la cible. \* Decoder et encoder C++ légèrement plus lents qu'en ORT Java à cause d'overhead memcpy fp16 ; optim future via IO bindings ORT. Le RTF global reste sous la cible.
@ -244,6 +259,38 @@ total 684 ms for 2.50 s audio => RTF 0.274
--- ---
## 7bis. Options QNN HTP V79 — critique pour la perf
Le code C++ active explicitement ces options dans `stt_engine_load` (différence avec la prod Kotlin qui les laisse par défaut) :
```cpp
opts.AppendExecutionProvider("QNN", {
{"backend_path", "libQnnHtp.so"},
{"htp_performance_mode", "burst"}, // NPU freq max (vs default = balanced)
{"htp_arch", "79"}, // SM8750 explicite
{"enable_htp_fp16_precision", "1"}, // NPU fp16 natif
{"profiling_level", "off"},
{"rpc_control_latency", "100"},
});
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
opts.DisableMemPattern();
```
**Impact mesuré** : sans ces options, encoder à 309 ms et decoder à 48 ms/token. Avec, encoder à 62 ms et decoder à 14 ms/token (-80 % et -71 %).
Recommandation pour le prod Kotlin actuel : ajouter ces options dans `WhisperHybridEngine.kt` aussi pour gain immédiat même sans migration. Syntaxe Java :
```kotlin
val encOpts = OrtSession.SessionOptions()
encOpts.addQnn(mapOf(
"backend_path" to htpPath,
"htp_performance_mode" to "burst",
"htp_arch" to "79",
"enable_htp_fp16_precision" to "1",
))
encOpts.optimizationLevel = OrtSession.SessionOptions.OptLevel.ALL_OPT
```
## 8. Notes pour optim future (non bloquant, opt-in) ## 8. Notes pour optim future (non bloquant, opt-in)
Le decoder C++ est ~1.5× plus lent par token que la prod Kotlin (36 ms vs 23 ms). Reste de l'écart probablement dans : Le decoder C++ est ~1.5× plus lent par token que la prod Kotlin (36 ms vs 23 ms). Reste de l'écart probablement dans :

25
dist/jni/stt_cli.cpp vendored
View File

@ -82,17 +82,24 @@ int main(int argc, char** argv) {
tc.language = lang; tc.language = lang;
tc.force_transcribe = true; tc.force_transcribe = true;
auto R = stt_engine_transcribe(eng, tc); // Mode warm-bench : si KZSTT_RUNS=N défini, fait N transcribes successifs sur le
if (R.err) { fprintf(stderr, "transcribe FAIL err=%d\n", R.err); stt_engine_free(eng); return 5; } // même engine et reporte chacun. Utile pour distinguer cold-start (1er run lent)
// vs warm path (runs suivants).
int n_runs = 1;
if (const char* p = std::getenv("KZSTT_RUNS")) { int v = atoi(p); if (v > 0) n_runs = v; }
printf("\n=== TRANSCRIPTION (%s, %s) ===\n", use_htp ? "NPU" : "CPU", lang); for (int run = 0; run < n_runs; ++run) {
auto R = stt_engine_transcribe(eng, tc);
if (R.err) { fprintf(stderr, "transcribe[%d] FAIL err=%d\n", run, R.err); stt_engine_free(eng); return 5; }
const char * tag = (n_runs > 1) ? (run == 0 ? "[cold]" : "[warm]") : "";
if (run == 0 || n_runs == 1) {
printf("\n=== TRANSCRIPTION (%s, %s) %s ===\n", use_htp ? "NPU" : "CPU", lang, tag);
printf("%s\n", R.text.c_str()); printf("%s\n", R.text.c_str());
printf("\n=== TIMING ===\n"); }
printf("mel %4d ms\n", R.mel_ms); printf("run %d %-6s : mel %3d enc %3d dec %4d (%2d tok) total %4d ms RTF %.3f\n",
printf("encoder %4d ms\n", R.encoder_ms); run, tag, R.mel_ms, R.encoder_ms, R.decoder_ms, R.n_tokens,
printf("decoder %4d ms (%d tokens)\n", R.decoder_ms, R.n_tokens); R.total_ms, R.rtf);
printf("total %4d ms for %.2f s audio => RTF %.3f\n", }
R.total_ms, (double)pcm.size() / sr, R.rtf);
stt_engine_free(eng); stt_engine_free(eng);
return 0; return 0;

View File

@ -442,15 +442,28 @@ SttEngine * stt_engine_load(const SttEngineLoadCfg & cfg) {
} }
// 3) Sessions encoder + decoder. QNN EP si use_htp. // 3) Sessions encoder + decoder. QNN EP si use_htp.
// Options HTP V79 importantes (cf QNN ExecutionProvider doc) :
// backend_path : libQnnHtp.so (CPU stub vers HTP)
// htp_performance_mode : "burst" = max NPU freq (vs "default" = balanced)
// htp_arch : "v79" = SM8750 (Snapdragon 8 Elite)
// enable_htp_fp16_precision: "1" = NPU fp16 native (Whisper a fp16 inputs/outputs)
// profiling_level : "off" en prod (sinon overhead)
// rpc_control_latency : "100" us pour les transferts CPU↔NPU (défaut peut être plus lent)
auto make_opts = [&](Ort::SessionOptions & opts) { auto make_opts = [&](Ort::SessionOptions & opts) {
if (cfg.use_htp) { if (cfg.use_htp) {
// QNN ExecutionProvider via AppendExecutionProvider("QNN", {...}) opts.AppendExecutionProvider("QNN", {
std::vector<const char *> keys = { "backend_path" }; {"backend_path", "libQnnHtp.so"},
std::vector<const char *> vals = { "libQnnHtp.so" }; {"htp_performance_mode", "burst"},
opts.AppendExecutionProvider("QNN", {"htp_arch", "79"},
{{"backend_path", "libQnnHtp.so"}}); {"enable_htp_fp16_precision", "1"},
{"profiling_level", "off"},
{"rpc_control_latency", "100"},
});
} }
opts.SetIntraOpNumThreads(cfg.n_threads); opts.SetIntraOpNumThreads(cfg.n_threads);
// Optim générale : graph opt level max + disable mem pattern (HTP gère sa propre mémoire)
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
opts.DisableMemPattern();
}; };
std::string enc_path = D + "/HfWhisperEncoder.onnx"; std::string enc_path = D + "/HfWhisperEncoder.onnx";