19 KiB
Kazeia Android — Élimination du root pour le LLM (résolu)
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
🟢 Statut : RÉSOLU. Pipeline complet STT + LLM + TTS tourne in-process sans aucun appel à
su. Voir la section Résolution en bas du document pour le détail du fix. Le reste du document décrit l'investigation initiale et garde sa valeur historique.
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
.pteJNI (in-process dans l'app) - LLM : Qwen3-4B via
qnn_llama_runnerExecuTorch (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é :loadMethodretourne 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_29ne peut pas exécuter depuisshell_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.ptefonctionne) - Le binaire doit être dans
nativeLibraryDir(=/data/app/~~XXX/com.kazeia-YYY/lib/arm64/) oucontext.filesDirpour 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 }dansbuild.gradle.ktspour que le binaire soit extrait à l'install dans/data/app/~~XXX/com.kazeia-YYY/lib/arm64/avec bit exec- ExecuTorchLlmEngine utilise
ProcessBuilderavec :binaryPath = File(nativeLibDir, "libqnn_llama_runner.so").absolutePathLD_LIBRARY_PATH=/data/local/tmp/kazeia-et:$nativeLibDirADSP_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.soviaFile.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.sopar la version v2.37 (celle qui matche le runner). GarderlibQnnHtp.soetc. à 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
- 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. - L'app NE peut PAS exec depuis
/data/local/tmp/— SELinux untrusted_app denies exec on shell_data_file (standard Android 13+). - Le binaire bundlé fonctionne (90 MB → strip → 5.1 MB, toujours fonctionnel : il répond à
--help). - 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. - 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
-
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" ? -
Peut-on renommer ou "slot" un Skel QNN via un mécanisme officiel ? Par exemple
libQnnHtpV79Skel_2.soen plus delibQnnHtpV79Skel.so, le runtime choisissant l'un ou l'autre par env variable ? -
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 ? -
Le bug flatc
unknown field: backend_typedansqnn_venv— est-ce un problème connu d'ExecuTorch (schéma dataclass désynchro du.fbs) et existe-t-il un workaround documenté ? Le schemaqc_compiler_spec.fbscontient bienbackend_typedansQnnExecuTorchBackendOptions:256, etqc_schema.py:200aussi. Peut-être que le dataclassQnnExecuTorchOptionsmetbackend_typeà la racine dans le JSON ? -
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). -
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'invocationssu(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) :
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- Ajouter
packaging { jniLibs.useLegacyPackaging = true }dansapp/build.gradle.kts - Modifier
ExecuTorchLlmEngine.ktpour utiliserProcessBuilderavecnativeLibraryDir/libqnn_llama_runner.so(voir git log364016b..HEAD pour les variantes essayées) ./gradlew assembleDebug && adb install -r app-debug.apk- 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.
10. Résolution (post-mortem)
Une seconde opinion technique a identifié la vraie cause racine que l'investigation locale avait mal diagnostiquée.
10.1 Vraie cause
Les processus Android forkés par Zygote (l'app elle-même, ses Services
android:process=":xxx", etc.) héritent des GIDs supplémentaires
configurés à l'init pour untrusted_app. Ces GIDs incluent l'autorisation
/dev/cdsprpc-smd et d'autres canaux fastrpc.
Quand Runtime.exec("su"…) ou ProcessBuilder font un fork() + exec()
classique, le exec() ne préserve pas tous les credentials utilisés par le
driver fastrpc Qualcomm pour authentifier le client. Le driver retourne
error 4000 "Failed to load skel" car il refuse de créer une session DSP
pour ce process.
C'est pour ça que :
- ORT-QNN (Whisper) marchait in-process : chargé via
System.loadLibrarydans l'app, qui est Zygote-forked → credentials valides. su -c qnn_llama_runnermarchait : root bypasse les checks fastrpc.ProcessBuilderdu même runner échouait : ni Zygote-forked, ni root.
Le "conflit de version QNN v2.31 vs v2.37" que j'avais soupçonné n'était pas le vrai problème. Les libs étaient déjà unifiées en v2.42 dans jniLibs.
10.2 La solution : LlmModule JNI in-process
ExecuTorch fournit org.pytorch.executorch.extension.llm.LlmModule, un
wrapper JNI autour du même C++ example::Runner que le binaire
qnn_llama_runner. En l'invoquant depuis l'app (process Zygote-forked), le
DSP fastrpc accepte la session — pas de root nécessaire.
10.3 Étapes réelles du fix
- Build ExecuTorch Android avec
EXECUTORCH_BUILD_LLAMA_JNI=ON,EXECUTORCH_BUILD_QNN=ON,QNN_SDK_ROOT=/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225→ produitlibexecutorch_jni.so192 MB qui inclut le runner LLM + le backend QNN. - Patches sources dans
/opt/Kazeia/executorch-patches/llm_in_process_jni.patch:backends/qualcomm/CMakeLists.txt: gatePyQnnManagerAdaptorsurNOT ANDROID(le guard original surCMAKE_SYSTEM_PROCESSOR MATCHES x86_64se déclenche dans des sous-scopes du cross-compile Android).extension/android/jni/jni_layer_llama.cpp, brancheMODEL_TYPE_QNN_LLAMA:decoder_model = "qwen3"(au lieu de"llama3"hardcodé)temperature = 0.0f,eval_mode = 0(kKVCached),shared_buffer = true- Crucial : choisir
Runner<uint8_t>ouRunner<uint16_t>selonmodule->get("get_kv_io_bit_width")(mirror duqnn_llama_runner.cpp main()). Hardcoder la mauvaise largeur produit du gibberish déterministe commeblocked罩ug darkestSOLEQuotes作者本人 humanity— la KV cache est lue/écrite à la mauvaise largeur de byte.
- Bundling jniLibs :
libexecutorch.so/libexecutorch_jni.so(build du 13-april avec LlmModule)libqnn_executorch_backend.so(assorti)libQnnHtp.so,libQnnHtpPrepare.so,libQnnHtpV79Stub.so,libQnnSystem.so,libQnnHtpV79Skel.so(tous v2.42 depuis/opt/Kazeia/qnn_sdk_242/)
- JAR avec
LlmModule.class: compilation manuelle viajavac(le build gradle de l'AAR demandait android-34 platform non installée). - Réécriture
ExecuTorchLlmEngine.kt:- Constructeur :
LlmModule(MODEL_TYPE_QNN_LLAMA=4, ptePath, tokPath, 0.7f)puis.load() generate(prompt, seqLen, callback, echo=false)— sinon le callback échoue à stripper les tokens du prompt- Template ChatML Qwen3 buildé en Kotlin, mirror exact de
qnn_llama_runner.cpp::get_formatted_prompt()pourkQwen3(user-first puis system optionnel puis<|im_start|>assistant) - Filtre inline
<think>…</think>dans le callback avec lookahead pour les tags fragmentés sur plusieurs pieces
- Constructeur :
10.4 Métriques validées
| Métrique | Valeur |
|---|---|
| LlmModule.load() | 4.2 s (one-time à l'init de l'app) |
| LLM gen | ~17 tok/s (kv-only) |
| LLM TTFT | ~4 s pour 77 tokens prompt (prefill séquentiel kKVCached) |
| TTS Talker(PTE) | 37 ms/step (vs 45-65 avant) |
| TTS CP(PTE) | 73 ms/step |
| Pipeline e2e | "Bonjour, comment vas-tu ?" → audio en ~7 s |
| Magisk prompts | 0 |
10.5 Optimisations restantes (non bloquantes)
- TTFT : ré-exporter le
.pteen--model_mode hybridpour avoir unprefill_forwardparallèle → TTFT passerait de ~4 s à <1 s. Pas nécessaire pour le use case conversationnel actuel. - Cosmétique : le statusbar de l'app affiche encore "Hexagon NPU" pour le TTS alors que c'est désormais le chemin .pte (label hérité du temps où c'était ggml-hexagon).
10.6 Mémoire projet
État complet documenté dans
/home/alf/.claude/projects/-opt-Kazeia/memory/project_llm_npu_plan.md.
Backup git : branche backup/pre-no-root-migration + commit 6e6a2d9.
Backup disk : /home/alf/kazeia_backup_20260414/.
10.7 Commits clés
f32b5dd(LLM no-root: validate end-to-end pipeline, fix kv_io_bit_width detection)b57719f(LLM: filter tokens out of the streaming TTS path)