14 KiB
Intégration LLM Kazeia-Engine — Guide développeur
⚠️ BLOQUEUR SELinux découvert 02/06 — lire la section 0 avant toute intégration in-app. La lib
libkazeia_engine.soactuelle (avec backend Hexagon HTP) NE FONCTIONNE PAS dans un APKuntrusted_appà cause d'une policy SELinux Android/Qualcomm qui interdit l'accès à/dev/fastrpc-cdsp. Tout test CLI standalone viaadb shellest un faux positif systématique (uidshella accès, pas l'app). Rebuild CPU-only requis pour usage in-app.
Date : 02/06/2026
Cible : remplacer le pipeline LLM ExecuTorch .pte par libkazeia_engine.so + façade Kotlin EngineLlmEngine.kt. Modèle GGUF chargé directement (pas de conversion .pte), prefill HTP / decode CPU automatique (option C).
Runtime : libllama (fork ggml-org/llama.cpp commit f0fe1058b) + libggml fork ql (Qualcomm) avec backend Hexagon HTP V79.
Modèles supportés (auto-détection via general.architecture du GGUF) :
- Qwen3 dense (Qwen3-4B, Qwen3Guard-Gen-4B/8B) : HTP contexte-unique, matmul HVX (HMX off, fault 0x2e), prefill+decode HTP
- Qwen3.5 hybride GDN (Qwen3.5-4B, Qwen3.5-9B) : option C — prefill HTP avec HMX (159 tok/s) / decode CPU NEON (16.5 tok/s) avec transfert KV automatique entre contextes
- Fallback CPU pur si pas de HTP détecté
Perf prouvée (Snapdragon 8 Elite SM8750, t=6, standalone CLI) :
| Modèle | Prefill | Decode | RAM | Mode |
|---|---|---|---|---|
| Qwen3-4B-Q4_0 | 103 tok/s (HTP) | 17 tok/s (CPU) | 2.4 GB | dense HTP |
| Qwen3.5-4B-Q4_0 | 55 tok/s (HTP) | 15 tok/s (CPU) | 2.4 GB | option C |
| Qwen3.5-9B-Q4_0 | 41 tok/s (HTP) | 8.4 tok/s (CPU) | 5.0 GB | option C |
| Qwen3Guard-Gen-4B-Q4_K_M | 89 tok/s (HTP) | 22 tok/s (CPU) | 2.5 GB | dense HTP |
⚠️ Note in-app perf : le sweet spot threads in-app est nThreads=6 (mesure r3). Versions précédentes du bridge utilisaient nThreads=4 hardcoded — corrigé dans le commit ce session.
0. Bloqueur SELinux découvert 02/06 — à lire en premier
Symptôme : System.loadLibrary("kazeia_engine") peut passer, mais le premier appel à EngineLlmEngine.load(modelPath, nCtx, nThreads) crashe avec SIGABRT (ggml_backend_dev_name → ggml_abort).
Cause : libkazeia_engine.so linke libllama.so + libggml-hexagon.so qui dlopen libcdsprpc.so (FastRPC) au démarrage du backend HTP. FastRPC ouvre /dev/fastrpc-cdsp pour parler au CDSP. La policy SELinux Oplus/Qualcomm sur SM8750 interdit cet accès à untrusted_app :
avc: denied { open } for path="/dev/fastrpc-cdsp"
scontext=u:r:untrusted_app tcontext=u:object_r:vendor_qdsp_device
→ Backend hexagon en état partiel → crash au premier accès.
Pourquoi cette doc l'avait raté : tous mes tests en adb shell (uid 2000) marchaient parfaitement. Le contexte SELinux shell a accès à /dev/fastrpc-cdsp. Le contexte untrusted_app ne l'a pas. Faux positif systématique du harness standalone.
Pourquoi STT et ExecuTorch prod marchent : ils utilisent QNN via libQnnHtp.so Maven, qui passe par une voie de delegation autorisée. Pas FastRPC direct.
Solution : rebuild libllama.so + libggml*.so + libkazeia_engine.so en CPU-only (-DGGML_HEXAGON=OFF). Le bridge devient CPU pur. Coût mesuré sur workload Kazeia (prompts 16-24 tok, réponses 64 tok) :
| HTP prod | CPU-only | |
|---|---|---|
| LLM prefill | ~0.4 s | ~1.3 s |
| LLM decode (64 tok à 9.8 t/s) | ~6.5 s | ~6.5 s (déjà CPU) |
| Total tour LLM | ~7 s | ~8 s (+1 s) |
Acceptable pour thérapeutique. Voir mémoire project_engine_selinux_fastrpc_blocker pour le détail des 3 voies évaluées.
État jusqu'à rebuild CPU-only : flag llm_engine=lib arme la bascule mais la lib ne fonctionnera pas in-app. Fallback gracieux sur prod (.pte) recommandé tant que le rebuild n'est pas fait.
1. Ce que la lib fait
libkazeia_engine.so (60 KB, SHARED, dépend de libllama + libggml*)
├── EngineLlmEngine load(ggufPath, nCtx=4096, nThreads=6) -> handle
├── EngineLlmEngine generate(handle, sys, usr, maxTok) -> String # mono-tour ChatML
├── EngineLlmEngine generateRaw(handle, prompt, maxTok) -> String # multi-tour
├── EngineLlmEngine reset(handle) # clear KV
├── EngineLlmEngine free(handle)
└── (+ API TTS embeds-only : nEmbd, nVocab, prefillEmbeds, decodeEmbed — pour Talker TTS)
API Kotlin (EngineLlmEngine.kt) :
val llm = EngineLlmEngine(
model = "/data/.../Qwen3.5-4B-Q4_0.gguf",
ctx = 4096,
nThreads = 6 // sweet spot Snapdragon 8 Elite r3
)
// Mono-tour avec system + 1 message
val reply = llm.generate(
sys = "Tu es Kazeia, un assistant psychologique bienveillant. Réponds en français bref.",
usr = "Je me sens un peu seul.",
max = 96
)
// Multi-tour via ChatSession (gère ChatML + thinking-off déterministe)
val chat = llm.newChat(system = "...")
val r1 = chat.ask("Bonjour")
val r2 = chat.ask("Comment vous allez ?")
chat.clear()
llm.release()
2. Build (côté app Android)
a. Récupérer les artifacts
cd /opt/Kazeia-engine/dist
# Recompile libkazeia_engine.so (60 KB)
NDK=/opt/Kazeia/android-ndk-r27d
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android31-clang++ \
-std=c++17 -O3 -fPIC \
-march=armv8.6-a+i8mm+bf16+dotprod+fp16 \
-I./include -shared jni/kazeia_engine_jni.cpp \
-L./lib -lllama -lggml -lggml-base -llog -ldl -lm \
-o b-jni/libkazeia_engine.so
b. Libs à copier dans app/src/main/jniLibs/arm64-v8a/
8 fichiers obligatoires (vérifiés via readelf -d sur la lib + dépendances transitives libllama.so) :
| Fichier | Taille | Rôle |
|---|---|---|
libkazeia_engine.so |
60 KB | la lib elle-même |
libllama.so |
35 MB | fork ggml-org/llama.cpp |
libggml.so |
627 KB | ggml core (charge cpu+hexagon backends dynamiquement) |
libggml-base.so |
6.6 MB | ggml base ops |
libggml-cpu.so |
4.7 MB | backend CPU NEON ARM v8.6 |
libggml-hexagon.so |
3.3 MB | backend Hexagon HTP host wrapper |
libggml-htp-v79.so |
354 KB | DSP skel V79 (chargé via dlopen runtime) |
libc++_shared.so |
1.8 MB | NDK r27d STL |
Total : ~52 MB. Mutualisable avec libkazeia_tts.so — si tu intègres TTS en parallèle, ces 7 deps sont les mêmes (juste libkazeia_engine.so et libkazeia_tts.so qui s'ajoutent en plus).
c. Façade Kotlin
Copie dist/jni/EngineLlmEngine.kt dans app/src/main/java/com/kazeia/llm/EngineLlmEngine.kt. Package = com.kazeia.llm.
d. Modèles sur la tablette
/data/local/tmp/kazeia/models/
├── Qwen3.5-4B-Q4_0.gguf # 2.4 GB — speaker / thinker principal
├── Qwen3.5-9B-Q4_0.gguf # 5.0 GB — option si qualité prioritaire
├── Qwen3Guard-Gen-4B.Q4_K_M.gguf # 2.5 GB — Guard (modération)
└── Qwen3-4B-Q4_0.gguf # 2.4 GB — pour cascade Speaker dense
3. Migration depuis ExecuTorch (.pte)
| Avant (ExecuTorch) | Après (Kazeia-Engine) |
|---|---|
model.pte 3.3 GB |
Qwen3.5-4B-Q4_0.gguf 2.4 GB (-30 %) |
| Conversion .pte requise (chaîne ExecuTorch + QNN compile) | Pas de conversion : GGUF chargé direct |
| Pipeline 100% NPU INT4 | Option C : prefill HTP / decode CPU NEON |
| Decode 14-21 tok/s | Decode 15-17 tok/s (4B), équivalent |
| Pas extensible à Qwen3.5 (ExecuTorch ne sait pas l'exporter) | Qwen3.5 hybride GDN supporté nativement |
| Pas de cascade multi-modèle facile | Cascade Speaker+Thinker via 2 instances EngineLlmEngine |
Suppression côté app : tout le pipeline ExecuTorchLlmEngine et ses dépendances ExecuTorch peuvent être retirés.
4. Exemples d'usage
Mono-tour psy bref
val llm = EngineLlmEngine(
model = "$kazeiaDir/models/Qwen3.5-4B-Q4_0.gguf",
ctx = 2048,
nThreads = 6
)
val reply = llm.generate(
sys = "Tu es Kazeia, psy bienveillant. Une phrase brève.",
usr = "Je rumine la nuit sans dormir.",
max = 64
)
llm.release()
Multi-tour ChatSession
val llm = EngineLlmEngine("$kazeiaDir/Qwen3.5-4B-Q4_0.gguf", nThreads = 6)
val chat = llm.newChat("Tu es Kazeia, psy bienveillant.")
chat.ask("Bonsoir.")
chat.ask("Je me sens vide.")
chat.ask("Depuis le départ de ma fille.")
chat.clear() // reset historique
llm.release()
Cascade Speaker + Thinker
val speaker = EngineLlmEngine("$kazeiaDir/Qwen3.5-9B-Q4_0.gguf", nThreads = 6)
val thinker = EngineLlmEngine("$kazeiaDir/Qwen3Guard-Gen-4B.Q4_K_M.gguf", nThreads = 4)
// 4 threads sur Thinker pour laisser 4 cœurs au Speaker en parallèle
val draft = speaker.generate(sysSpeaker, userInput, max = 96)
val safe = thinker.generate(sysGuard, "Réponse à vérifier: $draft", max = 32)
// si safe == "OK" -> envoyer draft, sinon reformuler
speaker.release(); thinker.release()
5. Codes d'erreur
load() renvoie 0L (= IllegalStateException Kotlin via require) si :
- modèle introuvable ou GGUF invalide
- pas assez de RAM
- erreur ggml_backend_init (rare)
generate() / generateRaw() renvoient un texte vide "" si :
- tokenize fail
- llama_decode prefill fail
- KV transfer fail (option C uniquement)
Pas de codes int dédiés (différent de TTS/STT) — l'API LLM est plus simple. Log via stderr kazeia-engine: pour le debug.
6. Bench rapide standalone
llama-cli est inclus dans dist/ pour mesurer la perf avant intégration :
adb shell '
cd /data/local/tmp/kz-engine
export LD_LIBRARY_PATH=./lib
export ADSP_LIBRARY_PATH=./lib
./llama-cli -m Qwen3.5-4B-Q4_0.gguf \
-p "Bonjour, comment vas-tu ?" \
-n 64 -t 6 -ngl 99 --reasoning-budget 0 -dev HTP0
'
Bench standardisé :
./llama-bench -m Qwen3.5-4B-Q4_0.gguf -t 6 -ngl 99 -p 32,128,512 -n 16
7. Checklist d'intégration
-
System.loadLibrary("kazeia_engine")réussit au démarrage. Si erreurlibrary "libllama.so" not found, vérifier les 8 libs dansjniLibs/arm64-v8a/. -
Charge un engine + 1 generate sur un modèle fixture (Qwen3.5-4B) avec un prompt simple. Le log stderr doit indiquer le mode détecté :
kazeia-engine: HYBRIDE GDN (qwen35) -> option C, prefill HTP+HMX (t=8) / decode CPU (t=6)ou
kazeia-engine: DENSE (qwen3) -> HTP contexte-unique, matmul HVX (HMX off) (t=6)ou
kazeia-engine: CPU pur (qwen3, pas de HTP) (t=6) -
Vérifier n_threads in-app : si tu vois decode << 10 tok/s sur Qwen3.5-4B, c'est que les threads tombent à 1 cœur (bug Android scheduler). Solution : forcer affinity 8 grands cœurs depuis Kotlin avant de générer (cf §8).
-
Sanity null/edge :
- prompt vide → texte vide
- maxTok = 0 → texte vide
- modèle inexistant → IllegalStateException au constructeur
-
Threading app :
generate()est synchrone bloquant. Appeler depuisDispatchers.IOou un thread dédié. Pas multi-thread sur le même handle (1 generate à la fois par engine). -
Cycle de vie :
release()quand l'app sort du foreground (libère 2.4-5.0 GB de RAM selon modèle).
8. Piège connu : affinity Android in-app
Symptôme : decode tombe de 9.8 tok/s (CLI standalone) à 0.32 tok/s in-app sur le même modèle.
Cause : le scheduler Android plante le process sur 1 petit cœur quand l'app n'est pas au foreground actif, ou quand il n'y a pas de hint thermique. llama-cli standalone via adb shell évite ce piège (process root-like).
Fix côté app :
import android.os.Process
import java.io.RandomAccessFile
private fun pinToBigCores() {
// Snapdragon 8 Elite : 8 cores total, cores 0-3 = grands (big), 4-7 = petits (medium/little).
// En pratique sur SM8750, masque 0xFC laisse tomber les 2 petits cores et garde les 6 grands.
try {
val tid = Process.myTid()
// sched_setaffinity via /proc/self/task/<tid>/cgroup ou via JNI taskset.
// Sinon, fallback : démarrer le thread avec Thread.MAX_PRIORITY.
Thread.currentThread().priority = Thread.MAX_PRIORITY
Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND)
} catch (e: Throwable) { /* best effort */ }
}
// Dans le ViewModel/Service qui héberge l'engine :
viewModelScope.launch(Dispatchers.IO) {
pinToBigCores()
val reply = llm.generate(sys, usr, max)
withContext(Dispatchers.Main) { display(reply) }
}
Sans cette pin, nThreads=6 côté JNI n'est pas suffisant — Android assigne quand même 1 cœur effectif.
Validation : pendant un generate, faire adb shell top -H -p <pid> et vérifier qu'on voit 6 threads actifs autour de 80-100 % CPU. Si on voit 1 thread à 100 % et les autres à 0, l'affinity n'est pas posée.
9. Perf détaillée et tuning
Sweet spot threads (mesure r3, Snapdragon 8 Elite)
| Modèle | t=4 | t=6 | t=8 |
|---|---|---|---|
| Qwen3.5-4B Q4_0 | 8.2 | 9.8 | 7.4 (contention) |
| Qwen3.5-9B Q4_0 | 4.4 | 5.2 | 2.1 (contention) |
| Qwen3-4B Q4_0 | 13 | 17 | 14 |
t=8 est mauvais à cause de contention (oversubscription + scheduler Android). Garde 6 par défaut.
Cascade : adapter les threads
Si tu lances Speaker (4B) et Thinker (4B) en simultané :
- Speaker
nThreads = 4 - Thinker
nThreads = 2
Le total = 6 grands cœurs, on laisse 2 cœurs au système Android.
Decode = memory-bandwidth bound
r3 : decode M=1 est limité par BW LPDDR5x ~77 GB/s, pas par compute. Donc Q4_0 (2 GB poids) est ~2× plus rapide que Q8_0 (4 GB) sur le decode. Pas d'intérêt à Q6_K ou plus précis sur ce hardware.
10. Contacts & support
Code source : /opt/Kazeia-engine/dist/jni/kazeia_engine_jni.cpp + EngineLlmEngine.kt.
Bench CLI : /opt/Kazeia-engine/dist/llama-cli + llama-bench.
Pour reproduire un bug : capture logcat tag kazeia-engine, et output du log stderr (mode détecté + n_threads effectif).