Kazeia-engine/dist/LLM_INTEGRATION.md

349 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.so` actuelle (avec backend Hexagon HTP) **NE FONCTIONNE PAS**
> dans un APK `untrusted_app` à cause d'une policy SELinux Android/Qualcomm qui interdit
> l'accès à `/dev/fastrpc-cdsp`. Tout test CLI standalone via `adb shell` est un faux positif
> systématique (uid `shell` a 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`) :
```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/<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).