chantier B STT #5 : Action 1 dev — patch QNN options pour la prod gelée
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.
This commit is contained in:
parent
c9e198332d
commit
dbd4f3d944
|
|
@ -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<Pair<String, ShortArray>> = 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.
|
||||
|
|
@ -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<Triple<String, Long, String>>()
|
||||
for (f in wavs) {
|
||||
val pcm = readWavPcm16(f) ?: continue
|
||||
val durMs = (pcm.size * 1000L) / 16000
|
||||
val times = mutableListOf<Long>()
|
||||
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.
|
||||
*/
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue