Kazeia-engine/dist/LLM_INTEGRATION.md

11 KiB
Raw Blame History

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) :

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

  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 :

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).