227 lines
14 KiB
Markdown
227 lines
14 KiB
Markdown
# 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.
|