kazeia/kazeia-no-root-report.md

20 KiB
Raw Blame History

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 .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_runnersrc/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.


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.loadLibrary dans l'app, qui est Zygote-forked → credentials valides.
  • su -c qnn_llama_runner marchait : root bypasse les checks fastrpc.
  • ProcessBuilder du 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

  1. 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 → produit libexecutorch_jni.so 192 MB qui inclut le runner LLM + le backend QNN.
  2. Patches sources dans /opt/Kazeia/executorch-patches/llm_in_process_jni.patch :
    • backends/qualcomm/CMakeLists.txt : gate PyQnnManagerAdaptor sur NOT ANDROID (le guard original sur CMAKE_SYSTEM_PROCESSOR MATCHES x86_64 se déclenche dans des sous-scopes du cross-compile Android).
    • extension/android/jni/jni_layer_llama.cpp, branche MODEL_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> ou Runner<uint16_t> selon module->get("get_kv_io_bit_width") (mirror du qnn_llama_runner.cpp main()). Hardcoder la mauvaise largeur produit du gibberish déterministe comme blocked罩ug darkestSOLEQuotes作者本人 humanity — la KV cache est lue/écrite à la mauvaise largeur de byte.
  3. 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/)
  4. JAR avec LlmModule.class : compilation manuelle via javac (le build gradle de l'AAR demandait android-34 platform non installée).
  5. 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() pour kQwen3 (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

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 .pte en --model_mode hybrid pour avoir un prefill_forward parallè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)

10.8 Comparaison de performances avant/après

Mesurée le 2026-04-14 sur le même .pte Qwen3-4B avec le même runner C++ — seule la voie d'invocation change (subprocess su -c vs LlmModule JNI in-process).

Métrique Avant (su-c subprocess) Après (in-process LlmModule) Delta
LLM gen rate 18.3 tok/s 17.2 tok/s -6 % (bruit)
LLM prefill speed 52 ms / prompt-token 52 ms / prompt-token identique
LLM TTFT (prompt 35 tok) 1.8 s 1.8 s identique
LLM TTFT (prompt 80 tok, system+ChatML) ~4.1 s 4.2 s identique
TTS Talker(.pte) 45-65 ms / step 37 ms / step +25-40 % (contexte QNN partagé)
TTS CP(.pte) 65-157 ms / step 73 ms / step +10-50 %
TTS load au boot 26.7 s 4.3 s 6× plus rapide (plus de subprocess Hexagon 12 s)
LlmModule.load() au boot n/a (subprocess à la demande) 3.1 s (one-time) overhead init
App RSS ~2 GB app + 1.76 GB subprocess séparé ~3.7 GB process unique mêmes ressources globales
Erreurs DSP 6031/6033 en concurrence régulières disparues architectural
Prompts Magisk 5 / tour 0 UX net
Taille APK ~100 MB ~100 MB (libexecutorch_jni.so 192 MB → 8.5 MB après strip à l'install) négligeable

Conclusion : pas de régression LLM (perf identique, le runner C++ est le même). Gain net sur la TTS (Talker 25-40 % plus rapide grâce au contexte QNN partagé, load 6× plus rapide). Architecture plus propre : un seul process, un seul runtime QNN, plus de contention DSP, plus de prompts root.