Baseline before no-root migration: working state with root LLM

Commit de sauvegarde avant la tentative d'unification QNN SDK v2.37 et
suppression du su -c pour le LLM. État actuel fonctionnel :
- LLM Qwen3-4B via su -c qnn_llama_runner (v2.42 dans /data/local/tmp/kazeia-et/)
- TTS talker + CP via ExecuTorch .pte JNI (v2.31 dans jniLibs)
- STT Whisper via ORT-QNN 1.24.3

Le rapport kazeia-no-root-report.md documente en détail les tentatives de
no-root et leurs échecs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-04-14 08:19:36 +02:00
parent 364016b7b8
commit 6e6a2d9f82
2 changed files with 239 additions and 14 deletions

View File

@ -8,17 +8,20 @@ import java.io.File
/**
* LLM Engine using ExecuTorch + QNN backend via subprocess.
* Calls qnn_llama_runner binary with root access.
* Current tablet config: Qwen3-4B KV-mode, ~18-20 tok/s on Hexagon V79 (Snapdragon 8 Elite),
* TTFT 0.9 s, RSS 1.76 GB. Previously tested Qwen3-0.6B at ~76 tok/s.
* Calls qnn_llama_runner binary with root access (Magisk su).
*
* TODO: migrate binary + QNN libs out of /data/local/tmp so ProcessBuilder can
* run them without su. The challenge is the QNN SDK version lock between
* ARM64 libs and the Hexagon skel bundling the v2.42 pair in the APK
* conflicts with the existing TTS stack which ships its own v2.31 pair.
* Either per-process library-path isolation (LD_LIBRARY_PATH pointing at
* context.filesDir/llm/, ADSP_LIBRARY_PATH likewise) with assets-based
* extraction, or consolidating the TTS stack onto the same QNN version.
* Current tablet config: Qwen3-4B KV-mode, ~18-22 tok/s on Hexagon V79
* (Snapdragon 8 Elite), TTFT 0.9 s, RSS 1.76 GB.
*
* Why root: the runner binary plus its QNN v2.42 .so deps live in
* /data/local/tmp/kazeia-et/ (shell_data_file SELinux context). Untrusted
* apps can't exec binaries from there. The Hexagon DSP fastrpc service also
* refuses to load the v2.42 Skel from the app's own files dir only from
* nativeLibraryDir but that dir already holds the TTS stack's v2.31 Skel
* (same filename, different version, can't coexist). Rebuilding everything
* against one QNN version would eliminate the conflict, but would require
* re-exporting the TTS .pte with the new runtime (tooling currently broken
* on the flatc schema/dataclass mismatch in the qnn_venv).
*/
class ExecuTorchLlmEngine(
private val onLog: ((String) -> Unit)? = null
@ -51,10 +54,8 @@ class ExecuTorchLlmEngine(
return@withContext
}
// Deploy runner script
deployRunnerScript()
// Quick test
writeFileRoot("$RUNNER_DIR/outputs/prompt.b64",
android.util.Base64.encodeToString("Bonjour".toByteArray(), android.util.Base64.NO_WRAP))
if (SYSTEM_PROMPT.isNotEmpty()) {
@ -127,7 +128,6 @@ class ExecuTorchLlmEngine(
)
}
/** Extract clean response text from Qwen3 output (strips think block and special tokens) */
private fun extractResponse(raw: String): String {
var text = raw
val thinkEnd = text.indexOf("</think>")
@ -152,7 +152,6 @@ class ExecuTorchLlmEngine(
.trim()
}
/** Deploy a shell script that decodes base64 prompt to avoid all shell escaping issues */
private fun deployRunnerScript() {
val script = """
#!/bin/sh

226
kazeia-no-root-report.md Normal file
View File

@ -0,0 +1,226 @@
# Kazeia Android — Problème d'élimination de root pour le LLM
**Date :** 2026-04-14
**Device :** OnePlus Pad 3 (OPD2415, Snapdragon 8 Elite, SoC `sun`), Android 16 (OxygenOS), Magisk root
**Cible :** éliminer `su -c` du pipeline LLM de l'app Kazeia tout en gardant la TTS et le STT sur NPU
---
## 1. Contexte général
L'app Kazeia (Android / Kotlin + Jetpack Compose) orchestre un pipeline **STT → LLM → TTS** entièrement on-device sur le Hexagon HTP (V79) du Snapdragon 8 Elite.
Trois tenants se partagent le DSP via Qualcomm QNN :
- **STT** : Whisper via ONNX Runtime QNN EP (in-process dans l'app)
- **TTS** : Qwen3-TTS talker + CP via ExecuTorch `.pte` JNI (in-process dans l'app)
- **LLM** : Qwen3-4B via `qnn_llama_runner` ExecuTorch (actuellement en subprocess root)
Le LLM est invoqué par l'app via `Runtime.getRuntime().exec(arrayOf("su","-c", ...))` — chaque tour de conversation déclenche 4-5 processus `su` (Magisk popup toasts). **C'est ce qu'on veut éliminer.**
---
## 2. État des libs dans le projet
### 2.1 Deux versions QNN SDK coexistent actuellement
| Composant | Version QNN | Source |
|---|---|---|
| `libexecutorch.so` / `libexecutorch_jni.so` | v2.31-compatible | Build ExecuTorch du 10 avril |
| `libqnn_executorch_backend.so` (jniLibs) | v2.31-compatible | Build du 8 avril |
| `libQnnHtp.so`, `libQnnSystem.so`, etc. (jniLibs) | v2.31 | QNN SDK 2.31 (vieille install) |
| `libQnnHtpV79Skel.so` (jniLibs) | v2.31 | idem |
| `qnn_llama_runner` (binaire) | v2.37 | Compilé 13 avril avec `/opt/Kazeia/executorch/backends/qualcomm/sdk/qnn/` (SDK 2.37) |
| Libs dans `/data/local/tmp/kazeia-et/` | v2.37 | Copiées depuis SDK 2.37 |
| Skel v2.37 disponible | v2.37 | `/opt/Kazeia/executorch/backends/qualcomm/sdk/qnn/lib/hexagon-v79/unsigned/libQnnHtpV79Skel.so` |
### 2.2 `.pte` déployés
- **TTS talker** : `cp_transformer_fp16.pte` + `talker_transformer_fp16.pte` — exportés 8-9 avril, **incompatibles avec le runtime v13-april** (testé : `loadMethod` retourne 1 avec runtime récent)
- **LLM** : `hybrid_llama_qnn.pte` (Qwen3-4B) — exporté 13 avril, compatible avec le runner v13-april
### 2.3 Structure actuelle (root-based qui marche)
```
nativeLibraryDir (jniLibs) :
libexecutorch.so, libexecutorch_jni.so (v2.31-compat, for TTS)
libqnn_executorch_backend.so (v2.31-compat)
libQnnHtp.so, libQnnHtpPrepare.so, libQnnHtpV79Stub.so, libQnnSystem.so (v2.31)
libQnnHtpV79Skel.so (v2.31)
/data/local/tmp/kazeia-et/ (shell:shell, 0777) :
qnn_llama_runner (v2.37 binaire)
libqnn_executorch_backend.so (v2.37)
libQnnHtp*.so, libQnnSystem.so, libQnnHtpV79Skel.so (v2.37)
hybrid_llama_qnn.pte, tokenizer.json (modèle LLM)
```
L'app exec le runner via `su -c 'sh run_llm.sh ...'`. Le runner root exécute avec `LD_LIBRARY_PATH=/data/local/tmp/kazeia-et/` et `ADSP_LIBRARY_PATH=/data/local/tmp/kazeia-et/`. Fonctionne : 18-22 tok/s, RSS 1.76 GB.
---
## 3. Goal et contraintes
**Goal :** `ProcessBuilder` sans `su`. Runner lancé avec uid `u0_a329` (untrusted_app).
**Contraintes :**
- Android 13+ SELinux : `untrusted_app_29` ne peut pas **exécuter** depuis `shell_data_file` (`/data/local/tmp/`)
- Peut **lire** `/data/local/tmp/` (vérifié empiriquement sur OnePlus Pad 3 — politique SELinux permissive : `run-as com.kazeia head -c 10 /data/local/tmp/kazeia-et/hybrid_llama_qnn.pte` fonctionne)
- Le binaire doit être dans `nativeLibraryDir` (= `/data/app/~~XXX/com.kazeia-YYY/lib/arm64/`) ou `context.filesDir` pour exec
---
## 4. Tentatives effectuées et résultats
### 4.1 Tentative A : bundler le runner dans jniLibs comme `libqnn_llama_runner.so`
**Setup :**
- Copie de `qnn_llama_runner``src/main/jniLibs/arm64-v8a/libqnn_llama_runner.so`
- `packaging { jniLibs.useLegacyPackaging = true }` dans `build.gradle.kts` pour que le binaire soit **extrait** à l'install dans `/data/app/~~XXX/com.kazeia-YYY/lib/arm64/` avec bit exec
- ExecuTorchLlmEngine utilise `ProcessBuilder` avec :
- `binaryPath = File(nativeLibDir, "libqnn_llama_runner.so").absolutePath`
- `LD_LIBRARY_PATH=/data/local/tmp/kazeia-et:$nativeLibDir`
- `ADSP_LIBRARY_PATH=/data/local/tmp/kazeia-et`
**Résultat :** le binaire s'exécute, dlopen fonctionne pour les libs ARM64 dans `/data/local/tmp/kazeia-et`, mais le **DSP fastrpc refuse de charger le Skel** :
```
[ERROR] QnnDsp <E> Failed to create transport for device, error: 4000
[ERROR] QnnDsp <E> Failed to load skel, error: 4000
[ERROR] QnnDsp <E> Transport layer setup failed: 14001
[ERROR] QnnDsp <E> Failed to parse default platform info: 14001
...
[ERROR] Failed to create device_handle for Backend ID 6, error=14001
E executorch:QnnBackendUnifiedRegistry.cpp:133] Fail to configure Qnn device
F executorch:result.h:170] In function CheckOk(), assert failed: hasValue_
Aborted (SIGABRT via pal_abort)
```
**Interprétation :** le DSP fastrpc service (qui tourne hors de l'app, sous `adsprpcd` daemon) consulte sa propre policy SELinux pour décider quels chemins sont acceptables pour charger un Skel Hexagon. Pour `untrusted_app`, `/data/local/tmp/` (contexte `shell_data_file`) n'est **pas autorisé** comme source de Skel, même si l'app peut le *lire* côté ARM64.
### 4.2 Tentative B : Skel dans `context.filesDir` de l'app
**Setup :**
- Runner bundlé comme 4.1
- Au `load()`, Kotlin copie `/data/local/tmp/kazeia-et/libQnnHtpV79Skel.so``/data/user/0/com.kazeia/files/llm/libQnnHtpV79Skel.so` via `File.copyTo`
- `ADSP_LIBRARY_PATH=${filesDir}/llm:/data/local/tmp/kazeia-et`
**Résultat :** la copie réussit (log `Skel copied to /data/user/0/com.kazeia/files/llm/libQnnHtpV79Skel.so (9 MB)`), mais le DSP fastrpc **refuse toujours** avec la même error 4000. Contexte SELinux `app_data_file` n'est pas accepté non plus par `adsprpcd` pour cette app.
### 4.3 Tentative C : swap du Skel dans jniLibs à v2.37
**Setup :**
- Remplacer uniquement `jniLibs/arm64-v8a/libQnnHtpV79Skel.so` par la version v2.37 (celle qui matche le runner). Garder `libQnnHtp.so` etc. à v2.31.
**Résultat :** le DSP charge le Skel v2.37 (depuis nativeLibraryDir, accepté), mais la **TTS casse** avec :
```
QnnDsp <E> QNN_TRANSPORT_CONFIG crc32 failed in cpu, crcRX received 0
QnnDsp <E> DspTransport.transport_config failed: rpc 0x0000000b, cfg 0x00000000
QnnDsp <E> Failed to create transport for device, error: 1002
QnnDsp <E> Failed to load skel, error: 1002
```
→ ABI stub v2.31 ↔ skel v2.37 **n'est pas compatible**. QNN nécessite que stub et skel soient du même minor.
### 4.4 Tentative D : upgrade complet à v2.37 dans jniLibs
**Setup :**
- Replace libexecutorch.so, libexecutorch_jni.so, libqnn_executorch_backend.so, libQnnHtp*.so, libQnnSystem.so, libQnnHtpV79Skel.so → tous en versions 13-avril / SDK 2.37
**Résultat :** le runner LLM marcherait (non testé en intégration mais les libs sont cohérentes), mais les **TTS `.pte` cassent au load** :
```
CP .pte loadMethod: 124ms, result=1
CP .pte loadMethod failed (1), disabling JNI
```
→ Les `.pte` exportés le 8-9 avril ne se chargent plus avec le runtime v13-april. Formats de sérialisation ExecuTorch ont divergé entre les deux points de version.
### 4.5 Tentative E : ré-exporter les TTS `.pte` avec le runtime courant
**Setup :** `/opt/Kazeia/scripts/export_cp_pte.py` avec `qnn_venv`
**Résultat :** erreur tooling flatc :
```
error: /home/alf/tmp/tmpXXXX/qc_compiler_spec.json:1: 156: error: unknown field: backend_type
subprocess.CalledProcessError: Command '['flatc', '--binary', ...]' returned non-zero exit status 1.
```
`qnn_venv` a des layers mixtes : le `executorch` namespace résout vers 3 chemins (`/opt/Kazeia/executorch`, plus deux site-packages). Le **dataclass** `QnnExecuTorchBackendOptions` inclut un champ `backend_type` que le `.fbs` packagé ne reconnaît pas (schema plus ancien que le dataclass, ou inversement).
Le fichier .fbs dans `serialization/` a bien le champ, mais flatc compile contre une version différente ou le JSON produit ne colle pas à la structure de tables attendue. Ça nécessite un debug Python/flatbuffers non trivial.
---
## 5. Matrice de contrainte (résumé)
Pour que le LLM tourne **sans root**, il faut simultanément :
| Condition | Comment satisfaire | Obstacle |
|---|---|---|
| Runner exec sans su | Binaire dans `nativeLibraryDir` (bundlé comme `lib*.so`) | ✅ Résolu via `useLegacyPackaging=true` |
| Runner trouve ses libs ARM64 au dlopen | `LD_LIBRARY_PATH=.../kazeia-et` | ✅ Fonctionne (l'app peut lire /data/local/tmp) |
| DSP fastrpc charge le Skel | Skel dans `nativeLibraryDir` **seulement** | ❌ **Conflit** : v2.31 (TTS) vs v2.37 (LLM), même nom |
| TTS `.pte` continue à charger | Runtime jniLibs compatible avec le format des `.pte` | ❌ Figé à ~10 avril faute de re-export |
**Le cœur du problème :** une seule instance de `libQnnHtpV79Skel.so` peut exister dans `nativeLibraryDir`, et les deux tenants (TTS et LLM) demandent des versions différentes du couple (stub, skel). Les .pte sont couplés au runtime, donc on ne peut pas unilatéralement upgrader la jniLibs sans re-exporter les .pte — et ce re-export est bloqué par un bug tooling flatc dans le venv.
---
## 6. Observations importantes vérifiées empiriquement
1. **L'app peut lire `/data/local/tmp/`** sur ce device (OxygenOS policy permissive) — test : `run-as com.kazeia head -c 10 /data/local/tmp/...` retourne les bytes.
2. **L'app NE peut PAS exec depuis `/data/local/tmp/`** — SELinux untrusted_app denies exec on shell_data_file (standard Android 13+).
3. **Le binaire bundlé fonctionne** (90 MB → strip → 5.1 MB, toujours fonctionnel : il répond à `--help`).
4. **Le DSP fastrpc ignore les tentatives de Skel hors nativeLibraryDir** — testé `/data/local/tmp/` et `/data/user/0/com.kazeia/files/llm/`, les deux retournent error 4000.
5. **ABI stub↔skel de QNN est strictement matched** — stub v2.31 + skel v2.37 → error 1002 crc32 mismatch.
---
## 7. Questions ouvertes pour la seconde opinion
1. **Existe-t-il un chemin Android où le DSP fastrpc accepte de charger un Skel pour une app untrusted, autre que `nativeLibraryDir` ?** Par exemple via une permission manifeste, un signé OEM, une API QNN spécifique pour "override ADSP path" ?
2. **Peut-on renommer ou "slot" un Skel QNN via un mécanisme officiel ?** Par exemple `libQnnHtpV79Skel_2.so` en plus de `libQnnHtpV79Skel.so`, le runtime choisissant l'un ou l'autre par env variable ?
3. **Est-il faisable/raisonnable de rebuilder le runner contre la version v2.31 du ExecuTorch (la même que celle qui a exporté les TTS `.pte`) ?** Ça demande un git checkout de `/opt/Kazeia/executorch` à ~8 avril state et un rebuild Android. Si oui, on a UNE version QNN partout, plus de conflit. Quelqu'un a-t-il déjà fait ça ?
4. **Le bug flatc `unknown field: backend_type` dans `qnn_venv` — est-ce un problème connu** d'ExecuTorch (schéma dataclass désynchro du `.fbs`) et existe-t-il un workaround documenté ? Le schema `qc_compiler_spec.fbs` contient bien `backend_type` dans `QnnExecuTorchBackendOptions:256`, et `qc_schema.py:200` aussi. Peut-être que le dataclass `QnnExecuTorchOptions` met `backend_type` à la racine dans le JSON ?
5. **Architecture alternative** — serait-ce plus sain de faire de tout l'Android ExecuTorch runtime une seule version (re-export des 3 `.pte` : talker, CP, Whisper STT) ? Pro : simplicité. Con : coût de re-export (~3h CPU par modèle via prepare_pt2e + compile QNN) + résolution du bug flatc. Risque : les .pte re-exportés peuvent produire des sorties légèrement différentes (drift numérique, qualité TTS).
6. **Workaround root acceptable ?** Le compromis actuel est Magisk "Remember this app" — l'user approuve 1 fois et pas de prompt ensuite. Mais les toasts "Kazeia was granted Superuser rights" apparaissent à chaque invocation `su -c` (5× par tour de conversation). Est-ce qu'il y a un flag Magisk pour silencer ces toasts, ou un pattern pour réduire le nombre d'invocations `su` (p.ex. un pipe persistent vers un shell root unique) ?
---
## 8. Reproduire le problème
Environnement :
```
/opt/Kazeia — repo Kazeia
/opt/Kazeia/executorch — ExecuTorch source, détaché HEAD à tag v1.2.0
/opt/Kazeia/executorch/build-android — build Android récent (13 avril)
/opt/Kazeia/executorch/backends/qualcomm/sdk — QNN SDK 2.37
/opt/Kazeia/kazeia-android — projet Android Studio
/opt/Kazeia/qnn_venv — Python venv avec executorch
Tablette : OnePlus Pad 3, rooted (Magisk), QNN setup à /data/local/tmp/
```
Pour reproduire la tentative A+B (bundling runner sans root) :
1. `cp /opt/Kazeia/executorch/build-android/examples/qualcomm/oss_scripts/llama/qnn_llama_runner /opt/Kazeia/kazeia-android/app/src/main/jniLibs/arm64-v8a/libqnn_llama_runner.so`
2. Ajouter `packaging { jniLibs.useLegacyPackaging = true }` dans `app/build.gradle.kts`
3. Modifier `ExecuTorchLlmEngine.kt` pour utiliser `ProcessBuilder` avec `nativeLibraryDir/libqnn_llama_runner.so` (voir git log `364016b`..HEAD pour les variantes essayées)
4. `./gradlew assembleDebug && adb install -r app-debug.apk`
5. Observer dans logcat : `[ERROR] [Qnn ExecuTorch]: QnnDsp <E> Failed to load skel, error: 4000`
---
## 9. Demande
Je cherche soit :
- Une **technique d'ingénierie Android/QNN** pour contourner la restriction DSP fastrpc sur les Skels hors nativeLibraryDir
- Soit une **stratégie propre** pour unifier les versions QNN/ExecuTorch sans avoir à ré-exporter tous les .pte (ou un debug précis du bug flatc)
- Soit **la confirmation** que l'approche actuelle (root + Magisk remember) est le meilleur compromis accessible, avec éventuellement des suggestions pour minimiser les prompts
Merci.