From c9e198332d590a9f66548255f6cd89193b3ceba7 Mon Sep 17 00:00:00 2001 From: Richard Loyer Date: Mon, 1 Jun 2026 09:08:22 +0200 Subject: [PATCH] =?UTF-8?q?chantier=20B=20STT=20#4=20:=20audit=20dev=20pri?= =?UTF-8?q?s=20en=20compte=20=E2=80=94=20options=20QNN=20explicites=20+=20?= =?UTF-8?q?bench=20apples-to-apples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dist/STT_INTEGRATION.md | 71 ++++++++++++++++++++++++++++++++++------- dist/jni/stt_cli.cpp | 27 ++++++++++------ dist/jni/stt_engine.cpp | 23 ++++++++++--- 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/dist/STT_INTEGRATION.md b/dist/STT_INTEGRATION.md index 6ffa673..1aeacfa 100644 --- a/dist/STT_INTEGRATION.md +++ b/dist/STT_INTEGRATION.md @@ -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). -**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 % | -| Encoder NPU | 125 ms | 315 ms* | +152 % | -| Decoder/token NPU | 23 ms | **36 ms** | +56 % | -| **RTF (10 s audio, 39 tokens FR continu)** | 0.51 (sur 1.6 s, 22 tokens) | **0.18** | **2.8× mieux** | -| **RAM peak** | 545 MB | **379 MB** | **-30 %** | +| Mel | 189 ms | 80 ms | 80 ms | +| Encoder | 125 ms | **62 ms** | 62 ms | +| Decoder/token | 23 ms | **14 ms** | 14 ms | +| Decoder total | 506 ms | 164 ms (12 tok) | ~308 ms | +| **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** : -- 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 -- Transcription FR cohérente sur 5 audios différents (damien, richard 1.6/3/5/10 s) +| Audio | Tokens | Total | RTF | Decoder/tok | +|---|---:|---:|---:|---:| +| 10 s richard FR | 39 | 1390 ms | 0.14 | 36 ms | +| 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. @@ -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) 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 : diff --git a/dist/jni/stt_cli.cpp b/dist/jni/stt_cli.cpp index a731170..02e66ca 100644 --- a/dist/jni/stt_cli.cpp +++ b/dist/jni/stt_cli.cpp @@ -82,17 +82,24 @@ int main(int argc, char** argv) { tc.language = lang; tc.force_transcribe = true; - auto R = stt_engine_transcribe(eng, tc); - if (R.err) { fprintf(stderr, "transcribe FAIL err=%d\n", R.err); stt_engine_free(eng); return 5; } + // Mode warm-bench : si KZSTT_RUNS=N défini, fait N transcribes successifs sur le + // 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); - printf("%s\n", R.text.c_str()); - printf("\n=== TIMING ===\n"); - printf("mel %4d ms\n", R.mel_ms); - printf("encoder %4d ms\n", R.encoder_ms); - printf("decoder %4d ms (%d tokens)\n", R.decoder_ms, R.n_tokens); - printf("total %4d ms for %.2f s audio => RTF %.3f\n", - R.total_ms, (double)pcm.size() / sr, R.rtf); + 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("run %d %-6s : mel %3d enc %3d dec %4d (%2d tok) total %4d ms RTF %.3f\n", + run, tag, R.mel_ms, R.encoder_ms, R.decoder_ms, R.n_tokens, + R.total_ms, R.rtf); + } stt_engine_free(eng); return 0; diff --git a/dist/jni/stt_engine.cpp b/dist/jni/stt_engine.cpp index 04cad87..30bbd74 100644 --- a/dist/jni/stt_engine.cpp +++ b/dist/jni/stt_engine.cpp @@ -442,15 +442,28 @@ SttEngine * stt_engine_load(const SttEngineLoadCfg & cfg) { } // 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) { if (cfg.use_htp) { - // QNN ExecutionProvider via AppendExecutionProvider("QNN", {...}) - std::vector keys = { "backend_path" }; - std::vector vals = { "libQnnHtp.so" }; - opts.AppendExecutionProvider("QNN", - {{"backend_path", "libQnnHtp.so"}}); + opts.AppendExecutionProvider("QNN", { + {"backend_path", "libQnnHtp.so"}, + {"htp_performance_mode", "burst"}, + {"htp_arch", "79"}, + {"enable_htp_fp16_precision", "1"}, + {"profiling_level", "off"}, + {"rpc_control_latency", "100"}, + }); } 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";