314 lines
11 KiB
Markdown
314 lines
11 KiB
Markdown
# 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/<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).
|