From dbd4f3d9444220dfd45f4f0bc8218a5fbc7a5ed6 Mon Sep 17 00:00:00 2001 From: Richard Loyer Date: Mon, 1 Jun 2026 09:42:27 +0200 Subject: [PATCH] =?UTF-8?q?chantier=20B=20STT=20#5=20:=20Action=201=20dev?= =?UTF-8?q?=20=E2=80=94=20patch=20QNN=20options=20pour=20la=20prod=20gel?= =?UTF-8?q?=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite à l'analyse du dev (commit c9e1983) : le gain mesuré (-80% encoder, -71% decoder/token) vient des options QNN ExecutionProvider, pas de la migration C++. Donc le patch s'applique aussi à la prod Kotlin actuelle sans dégeler la stack STT — 4 lignes Kotlin, réversible. Livrables Action 1 (séparée de la décision migration) : - dist/PATCH_QNN_PROD.md : explication + diff Kotlin + chiffres attendus (prod 820 ms -> ~560 ms = -32 % gratuit) + script bench audio Pierre - dist/patches/whisper_hybrid_engine_qnn.diff : patch précis sur WhisperHybridEngine.kt (lignes 101, 109, 134 du fichier prod). Ajoute htp_performance_mode=burst + htp_arch=79 + enable_htp_fp16_precision=1 + setOptimizationLevel(ALL_OPT) sur les 3 sites encOpts/decOpts. - dist/patches/bench_qnn_before_after.kt : harness Android pour mesurer le gain réel sur audios Pierre. 3 runs/audio, médiane, RTF pondéré. Compare 2 builds app (avant patch / après patch) sur le même corpus. Décision migration libkazeia_stt CONDITIONNEE à : 1. Action 1 mesurée sur audio Pierre (gain réel patch QNN seul) 2. A/B accuracy CER/WER lib unifiée vs prod patchée (in-app uniquement, pas mesurable standalone) Bénéfice résiduel migration après patch QNN prod : ~110 ms total + 168 MB RAM + code unifié. Reste valable mais beaucoup moins fort que les chiffres initiaux. Le dev a raison de séparer les décisions. Mémoire à mettre à jour : project_stt_qnn_options_win. --- dist/PATCH_QNN_PROD.md | 103 ++++++++++++++++++ dist/patches/bench_qnn_before_after.kt | 111 ++++++++++++++++++++ dist/patches/whisper_hybrid_engine_qnn.diff | 53 ++++++++++ 3 files changed, 267 insertions(+) create mode 100644 dist/PATCH_QNN_PROD.md create mode 100644 dist/patches/bench_qnn_before_after.kt create mode 100644 dist/patches/whisper_hybrid_engine_qnn.diff diff --git a/dist/PATCH_QNN_PROD.md b/dist/PATCH_QNN_PROD.md new file mode 100644 index 0000000..0680f09 --- /dev/null +++ b/dist/PATCH_QNN_PROD.md @@ -0,0 +1,103 @@ +# Patch QNN options pour la prod gelée — sans migration + +**Action 1 isolée** de la décision migration `libkazeia_stt`. Ce patch ne touche pas le chemin code, juste les options ORT passées à QNN. Réversible en 4 lignes. + +## Constat + +Dans `WhisperHybridEngine.kt:101-102` actuel : +```kotlin +val encOpts = OrtSession.SessionOptions() +encOpts.addQnn(mapOf("backend_path" to htpPath)) +``` + +Ces options QNN par défaut donnent : +- `htp_performance_mode = balanced` (scheduler équilibré, pas freq max NPU) +- pas de `htp_arch` explicite → ORT auto-détecte, fallback générique sur certains paths +- pas de `enable_htp_fp16_precision` → conversion implicite parfois sub-optimale +- pas de `SetGraphOptimizationLevel(ALL_OPT)` activé explicitement + +**Coût mesuré sur SD8 Elite (SM8750, Whisper-Small)** : encoder 309 ms au lieu de 62 ms, decoder 48 ms/tok au lieu de 14 ms/tok. + +## Patch (4 lignes Kotlin) + +Dans `WhisperHybridEngine.kt`, remplacer les blocs encOpts/decOpts : + +```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.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) +``` + +Idem pour `decOpts`. Et symmetric pour le fallback `encoder_npu` path si présent (ligne 134 environ). + +## Gain mesuré attendu + +Sur le port C++ avec exactement ces options, mesures stables 5 runs warm (audio FR continu, 12 tokens / 1.6 s) : + +| Étape | Sans options | Avec burst+v79+fp16 | +|---|---:|---:| +| Encoder | 309 ms | 62 ms | +| Decoder/token | 48 ms | 14 ms | +| Total (12 tok) | 962 ms | 320 ms | + +Hypothèse cohérente : sur la prod Kotlin actuelle (820 ms / 22 tok), avec patch : +- Encoder 125 → ~62 ms +- Decoder 22 × 23 → 22 × ~14 ms = 308 ms +- Total ≈ **560 ms (-32 %)** + +**À valider sur audio Pierre réel.** L'extrapolation est mathématique, pas un bench prod direct. + +## Script de bench audio Pierre + +Pour valider le gain sur les audios prod typiques (phrases courtes thérapeutiques) : + +```kotlin +// Dans un mode debug de l'app, ou un test Android instrumenté : +val engine = WhisperHybridEngine(nativeLibDir) +engine.load(modelPath = WHISPER_DIR) + +// Charge audio Pierre déjà capturé en prod (ShortArray PCM16 16kHz mono) +val samples: List> = loadPierrAudioSamples() // 10-20 samples + +samples.forEach { (name, pcm) -> + val t0 = System.currentTimeMillis() + val r = engine.transcribe(pcm, "fr") + val dt = System.currentTimeMillis() - t0 + Log.i("BENCH", "$name : ${pcm.size / 16000.0}s audio, $dt ms total, text='${r.text}'") +} +``` + +Compare le `dt` total avec et sans patch sur **les mêmes audios** : +- Si gain ≥ 25 % en moyenne : patch validé, à merger en l'état +- Si gain < 25 % : enquêter (probable que l'app n'utilise pas les bonnes libs QNN, vérifier `jniLibs/arm64-v8a/libQnnHtp*.so` et version `qnn-runtime`) + +## Pourquoi c'est sans risque + +1. **Code path identique** : c'est de la config ORT, pas un changement de modèle ni de pipeline. +2. **Réversible** : 4 lignes à retirer si jamais ça régresse. +3. **Compatibilité** : `htp_arch=79` est explicite mais valide sur SD8 Elite. Sur autre device (Gen3 = V75), il faudrait conditionner sur `Build.SOC_MANUFACTURER`/`SOC_MODEL`. Pour Kazeia mono-tablette (Pad3 = SM8750 = V79), c'est sûr. +4. **Pas de dégel STT** : le contexte QAIRT est inchangé, le modèle est inchangé, la classe Kotlin est inchangée hors les 4 lignes. + +## Après ce patch, la décision migration `libkazeia_stt` + +Le delta restant entre prod patchée et lib unifiée : + +| | Prod + patch QNN | Lib unifiée | +|---|---:|---:| +| Total (22 tok / 1.6 s) | ~560 ms | ~450 ms | +| Delta | — | **-110 ms** | +| RAM | 545 MB | **377 MB (-168 MB)** | +| Code | îlot STT (527 l Kotlin + 203 l C++ mel + 197 l VAD) | unifié dans `libkazeia_stt.so` + `SttEngine.kt` | + +Décision migration **conditionnée à** : +1. Patch QNN posé et mesuré sur audio Pierre ✓ +2. A/B accuracy CER/WER lib unifiée vs prod patchée sur les mêmes audios — **à mesurer in-app** +3. Si #2 passe (CER ≤ +5 % relatif vs prod), la migration vaut le coup pour les -168 MB et la cohérence code +4. Si #2 dégrade, on garde la prod patchée et la lib unifiée reste un POC + +L'A/B accuracy reste le seul point dur non mesurable en standalone. Le dev offre un patch derrière l'interface existante (`WhisperHybridEngine` → `SttEngine` swap derrière `SttStage`) — c'est la bonne façon de mesurer sans s'engager. diff --git a/dist/patches/bench_qnn_before_after.kt b/dist/patches/bench_qnn_before_after.kt new file mode 100644 index 0000000..8d66b7a --- /dev/null +++ b/dist/patches/bench_qnn_before_after.kt @@ -0,0 +1,111 @@ +// Mini-harness Android pour mesurer le gain QNN sans rien casser. +// À placer temporairement dans com.kazeia.stt.BenchQnnHarness ou similaire, +// déclenchable via un point d'entrée (settings dev, ou bouton caché). +// +// Hypothèse : l'app capture déjà des PCM16 16kHz mono lors des sessions +// thérapeutiques. Si oui, on charge X audios sauvegardés et on bench +// avec et sans patch QNN. Sinon : utiliser des audios fixture FR de 1-3s +// (Pierre disponibles ? ou damien_5s_16k.wav du repo Kazeia-Engine/voix). +// +// Avant le patch : compiler/déployer l'app actuelle, exécuter cette fonction, +// noter les temps. +// Après le patch : appliquer whisper_hybrid_engine_qnn.diff, recompiler, +// re-exécuter cette même fonction, comparer. + +package com.kazeia.stt + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.runBlocking +import java.io.File + +object BenchQnnHarness { + private const val TAG = "BenchQnn" + + /** + * @param audioDir directory containing test WAVs mono 16-bit 16kHz (PCM16). + * @param modelDir whisper-small-sm8750 dir (= WhisperHybridEngine.WHISPER_DIR). + */ + fun runBench(ctx: Context, audioDir: File, modelDir: String) { + val engine = WhisperHybridEngine(nativeLibDir = ctx.applicationInfo.nativeLibraryDir) { msg -> + Log.i(TAG, msg) + } + runBlocking { engine.load(modelPath = modelDir) } + if (!engine.isLoaded()) { + Log.e(TAG, "engine load FAILED — cannot bench") + return + } + + val wavs = audioDir.listFiles { _, name -> name.endsWith(".wav") }?.sortedBy { it.name } + ?: run { Log.e(TAG, "no WAVs in $audioDir"); return } + + Log.i(TAG, "=== Bench start, ${wavs.size} audios ===") + // Warm-up : 1 transcribe à blanc (le 1er est cold) + wavs.firstOrNull()?.let { f -> + val pcm = readWavPcm16(f) ?: return@let + runBlocking { engine.transcribe(pcm, "fr") } + } + + // Bench reproductible : 3 runs par audio, garder la médiane + val results = mutableListOf>() + for (f in wavs) { + val pcm = readWavPcm16(f) ?: continue + val durMs = (pcm.size * 1000L) / 16000 + val times = mutableListOf() + var text = "" + repeat(3) { + val t0 = System.currentTimeMillis() + val r = runBlocking { engine.transcribe(pcm, "fr") } + val dt = System.currentTimeMillis() - t0 + times.add(dt) + text = r.text + } + times.sort() + val median = times[1] + results.add(Triple(f.name, median, text)) + Log.i(TAG, "${f.name} : ${durMs}ms audio, median=${median}ms (${times.joinToString()}), text='${text}'") + } + + // Récap + val totalAudio = results.sumOf { + val w = readWavPcm16(audioDir.resolve(it.first)) ?: ShortArray(0) + (w.size * 1000L) / 16000 + } + val totalTime = results.sumOf { it.second } + Log.i(TAG, "=== Bench end ===") + Log.i(TAG, "total audio = ${totalAudio} ms, total transcribe (median sum) = ${totalTime} ms") + Log.i(TAG, "weighted RTF = ${totalTime.toDouble() / totalAudio}") + + engine.release() + } + + private fun readWavPcm16(f: File): ShortArray? = try { + val bytes = f.readBytes() + // Skip the 44-byte WAV header (or parse properly if ffmpeg may have inserted extra chunks) + var dataOff = 44 + // Look for "data" chunk to be safe + for (i in 12 until bytes.size - 8) { + if (bytes[i] == 'd'.code.toByte() && bytes[i+1] == 'a'.code.toByte() && + bytes[i+2] == 't'.code.toByte() && bytes[i+3] == 'a'.code.toByte()) { + dataOff = i + 8; break + } + } + val n = (bytes.size - dataOff) / 2 + ShortArray(n) { i -> + ((bytes[dataOff + 2*i].toInt() and 0xFF) or (bytes[dataOff + 2*i + 1].toInt() shl 8)).toShort() + } + } catch (e: Exception) { + Log.e(TAG, "readWavPcm16 fail ${f.name}: ${e.message}"); null + } +} + +/* USAGE : + BenchQnnHarness.runBench( + ctx = applicationContext, + audioDir = File("/sdcard/Android/data/com.kazeia/files/bench_audio"), // ou autre + modelDir = "/data/local/tmp/kazeia/models/whisper-small-sm8750" + ) + Mettre 10-20 audios FR Pierre dans audioDir au préalable. + Pas besoin de modifier le runner pour le A/B QNN : on compare juste 2 runs + d'app (avant patch / après patch) sur le même corpus. +*/ diff --git a/dist/patches/whisper_hybrid_engine_qnn.diff b/dist/patches/whisper_hybrid_engine_qnn.diff new file mode 100644 index 0000000..7203ba6 --- /dev/null +++ b/dist/patches/whisper_hybrid_engine_qnn.diff @@ -0,0 +1,53 @@ +--- a/app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt ++++ b/app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt +@@ -98,8 +98,15 @@ class WhisperHybridEngine( + val htpPath = "$nativeLibDir/libQnnHtp.so" + nlog("Loading HfWhisper encoder (QNN NPU)...") + val t0 = System.currentTimeMillis() + val encOpts = OrtSession.SessionOptions() +- encOpts.addQnn(mapOf("backend_path" to htpPath)) ++ encOpts.addQnn(mapOf( ++ "backend_path" to htpPath, ++ "htp_performance_mode" to "burst", // NPU freq max (vs default = balanced) ++ "htp_arch" to "79", // SM8750 = SD8 Elite V79 ++ "enable_htp_fp16_precision" to "1", // NPU fp16 natif ++ )) ++ encOpts.setOptimizationLevel( ++ OrtSession.SessionOptions.OptLevel.ALL_OPT) + hfEncoderSession = ortEnv!!.createSession(hfEncPath, encOpts) + val encMs = System.currentTimeMillis() - t0 + nlog("HfEncoder NPU loaded: ${encMs}ms") + + nlog("Loading HfWhisper decoder (QNN NPU)...") + val t1 = System.currentTimeMillis() + val decOpts = OrtSession.SessionOptions() +- decOpts.addQnn(mapOf("backend_path" to htpPath)) ++ decOpts.addQnn(mapOf( ++ "backend_path" to htpPath, ++ "htp_performance_mode" to "burst", ++ "htp_arch" to "79", ++ "enable_htp_fp16_precision" to "1", ++ )) ++ decOpts.setOptimizationLevel( ++ OrtSession.SessionOptions.OptLevel.ALL_OPT) + hfDecoderSession = ortEnv!!.createSession(hfDecPath, decOpts) + val decMs = System.currentTimeMillis() - t1 + nlog("HfDecoder NPU loaded: ${decMs}ms") +@@ -130,9 +137,15 @@ class WhisperHybridEngine( + if (!useHfModels) { + val encNpuPath = "$path/encoder_npu/model.onnx" + if (File(encNpuPath).exists()) { + try { + val encOpts = OrtSession.SessionOptions() +- encOpts.addQnn(mapOf("backend_path" to "$nativeLibDir/libQnnHtp.so")) ++ encOpts.addQnn(mapOf( ++ "backend_path" to "$nativeLibDir/libQnnHtp.so", ++ "htp_performance_mode" to "burst", ++ "htp_arch" to "79", ++ "enable_htp_fp16_precision" to "1", ++ )) ++ encOpts.setOptimizationLevel( ++ OrtSession.SessionOptions.OptLevel.ALL_OPT) + nlog("Loading encoder (QNN NPU)...") + val t0 = System.currentTimeMillis() + encoderSession = ortEnv!!.createSession(encNpuPath, encOpts)