# Intégration LLM Kazeia-Engine — Guide développeur **Date** : 01/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. --- ## 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`) : ```kotlin 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 ```bash 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 ```kotlin 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 ```kotlin 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 ```kotlin 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 : ```bash 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é : ```bash ./llama-bench -m Qwen3.5-4B-Q4_0.gguf -t 6 -ngl 99 -p 32,128,512 -n 16 ``` --- ## 7. Checklist d'intégration 1. **`System.loadLibrary("kazeia_engine")` réussit** au démarrage. Si erreur `library "libllama.so" not found`, vérifier les 8 libs dans `jniLibs/arm64-v8a/`. 2. **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) ``` 3. **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). 4. **Sanity null/edge** : - prompt vide → texte vide - maxTok = 0 → texte vide - modèle inexistant → IllegalStateException au constructeur 5. **Threading app** : `generate()` est synchrone bloquant. Appeler depuis `Dispatchers.IO` ou un thread dédié. Pas multi-thread sur le même handle (1 generate à la fois par engine). 6. **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** : ```kotlin 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//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 ` 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).