Kazeia-engine/dist/LLM_INTEGRATION.md

14 KiB
Raw Permalink Blame History

Kazeia — Intégration moteur LLM (NPU .pte + CPU GGUF)

Date : 17/06/2026 — remplace la version 02/06 (qui décrivait l'approche GGUF+ggml-hexagon, abandonnée in-app car bloquée SELinux ; cf. §6). Cible : Snapdragon 8 Elite (SM8750 / Hexagon V79), Android, app untrusted_app. Statut : moteurs prêts, validés on-device. Reste = intégration app (pas de R&D).

Ce document décrit tout ce qui a été fait, quels LLM sont préparés, et ce que le dev doit câbler dans Kazeia. Auto-suffisant.


1. Résumé exécutif

Kazeia charge plusieurs LLM. Selon l'architecture du modèle, le format optimal diffère, et un chargeur unique (LlmLoader) auto-détecte lequel utiliser via les magic bytes du fichier :

  • Modèles denses (Qwen3-4B, Qwen3-8B, Qwen3Guard-4B, Qwen2.5-7B) → .pte ExecuTorch+QNN, sur le NPU V79. Prefill NPU très rapide, RAM ÷2, sans root (même chemin QNN autorisé que le STT déjà en prod — cf. §6).
  • Modèle hybride (Qwen3.5-4B, à GatedDeltaNet) → GGUF Q4_0 sur CPU (i8mm). Il ne peut pas s'exporter en .pte (architecture récurrente non supportée par QNN/ExecuTorch — vérifié exhaustivement).

Le reste de l'app est agnostique au format : LlmLoader.load(path) rend un LlmEngine.


2. Les modèles LLM — lesquels, quel format, statut

Modèle Arch Format Fichier seq_len sharding Statut
Qwen3-4B dense GQA .pte NPU qwen3_4b_seq1024/hybrid_llama_qnn.pte (3.1 G) 1024 2 validé
Qwen3-8B dense GQA .pte NPU qwen3_8b_seq512/hybrid_llama_qnn.pte (5.5 G) 512 2 validé
Qwen3Guard-4B dense GQA .pte NPU kazeia-tablet-backup/llm/hybrid_llama_qnn_guard4b.pte (3.1 G) 512 2 validé
Qwen2.5-7B-Instruct dense GQA .pte NPU qwen2_5_7b_seq512/hybrid_llama_qnn.pte (5.0 G) 512 2 validé
Qwen3.5-4B hybride GDN GGUF Q4_0 CPU Qwen3.5-4B-Q4_0.gguf (2.4 G) existant

⚠ « Qwen3-7B » n'existe pas (denses Qwen3 = 0.6/1.7/4/8/14/32B). Préparé : Qwen3-8B.

Chaque .pte est accompagné de son tokenizer.json (même dossier ; Guard-4B : kazeia-tablet-backup/llm/tokenizer_guard.json).


3. Performance mesurée on-device (SM8750/V79, greedy, eval_mode 1 hybride)

Modèle decode (tok/s) prefill (tok/s) TTFT RAM host RSS¹
Qwen3-4B .pte 15.7 313 0.13 s ~1.8 G
Qwen3-8B .pte 10.5 219 0.19 s ~3.1 G
Qwen3Guard-4B .pte 17.2 166 0.10 s ~3.1 G
Qwen2.5-7B .pte 8.0² 212 0.21 s ~5.1 G
Qwen3.5-4B GGUF (CPU-i8mm) 16.6 85 ~4.5 G

¹ RSS host seulement ; les poids quantifiés sont en mmap/ION (hors RSS), pageables. ² 7B plus lent en decode que le 8B car embeddings non-tied (lm_head 152k séparé).

Lecture clé : le .pte NPU écrase le prefill (×3-4 vs CPU) et divise la RAM ; le CPU-i8mm gagne le decode pur. Sur gros prompt RAG (cas Kazeia), le .pte gagne le tour complet (le prefill domine) ; sur prompt court, le GGUF est compétitif. Le decode NPU ne bat jamais le CPU (memory-bandwidth bound) — attendu, et c'est pourquoi le hybride reste CPU.


4. Ce qui a été implémenté

4.1 Chargeur Kotlin — dist/jni/LlmLoader.kt

  • Interface commune LlmEngine : generate(sys,usr,max), generateStream(...), lastStats(), reset(), release().
  • LlmLoader.load(path, ctx, nThreads, tokenizerPath?) : magic-byte
    • "GGUF" (octets 0-3) → GgufLlmEngine (délègue EngineLlmEngine, libllama CPU-i8mm).
    • "ET12" (octet 4) → PteLlmEngine (ExecuTorch+QNN NPU).
  • PteLlmEngine : tokenise le ChatML côté Kotlin (tokeniseur natif cassé pour Qwen — regex lookahead RE2), passe les IDs au natif, décode.

4.2 Wrapper natif — libkazeia_pte.so (95 Mo, arm64-v8a)

Enveloppe le runner ExecuTorch. Classe JNI com.kazeia.llm.ExecuTorchJni :

long   load(String ptePath, String tokenizerPath, int seqLen, int evalMode)
long[] generateFromIds(long handle, long[] promptIds, int maxNewTokens)
long[] lastStats(long handle)        // [prefillMs, decodeMs, nGenerated]
void   free(long handle)
  • generateFromIds : prefill sur IDs pré-tokenisés → decode greedy jusqu'à EOS (<|im_end|>=151645) ou maxNewTokens. Renvoie uniquement les IDs générés.
  • Parité bit-exacte avec le runner CLI (Qwen3-4B : tokens identiques, ~18.8 tok/s).
  • NEEDED : uniquement libqnn_executorch_backend.so + libs système (le reste est statique).
  • Source : dist/lib-pte/kazeia_pte_jni.cpp.

4.3 Set runtime QAIRT 2.42 — dist/lib-pte/

Rebuildé contre QAIRT 2.42 (⚠ doit == la version QNN d'export des .pte). 7 .so.


5. Intégration dans Kazeia — checklist développeur

5.1 jniLibs/arm64-v8a/

Pour le NPU .pte (8 fichiers) :

Fichier Source
libkazeia_pte.so dist/lib-pte/
libqnn_executorch_backend.so dist/lib-pte/
libQnnHtp.so · libQnnSystem.so · libQnnHtpPrepare.so idem
libQnnHtpV79Stub.so · libQnnHtpV79Skel.so · libQnnHtpNetRunExtensions.so idem

Pour le GGUF/CPU (Qwen3.5-4B), si pas déjà en place : libkazeia_engine.so + libllama.so + libggml.so + libggml-base.so + libggml-cpu.so (variante i8mm) + libc++_shared.so.

Le libggml-cpu.so livré DOIT être la variante i8mm (dist/lib-cpu/libggml-cpu.so, ~1300 instructions i8mm), PAS dist/lib/libggml-cpu.so (0 i8mm → prefill CPU ÷4.5). Vérif : objdump -d ... | grep -c smmla doit être >1000. Pour le GGUF in-app on ship la variante CPU-only (GGML_HEXAGON=OFF) : le NPU LLM passe par le .pte, pas par ggml-hexagon (cf. §6).

5.2 Gradle — 1 dépendance (pour le .pte)

implementation "ai.djl.huggingface:tokenizers:0.30.0"   // tokeniseur HF Kotlin pour PteLlmEngine

(Aligner la version sur une build DJL compatible Android utilisée par l'app.)

5.3 Assets — storage app

Par .pte : le .pte + son tokenizer.json (cf. §2). Le GGUF Qwen3.5-4B + son tokeniseur sont gérés par le moteur GGUF (embarqué).

5.4 Code Kotlin — câblage

// Détection automatique du format :
val llm: LlmEngine = LlmLoader.load(
    path = "$dir/hybrid_llama_qnn.pte",     // ou un .gguf
    ctx = 1024,                              // = seq_len du .pte (1024=4B, 512=8B/7B/Guard ; 4096 pour GGUF)
    nThreads = 6,                            // utilisé par le GGUF (CPU) ; ignoré par le .pte
    tokenizerPath = "$dir/tokenizer.json"    // requis pour .pte ; ignoré pour GGUF
)
val reponse = llm.generate(systemPrompt, userPrompt, max = 256)
val s = llm.lastStats()   // [prefillMs, decodeMs, nGenerated]
llm.release()

LlmLoader choisit PteLlmEngine (NPU) ou GgufLlmEngine (CPU). Rien d'autre à changer dans l'appelant.

5.5 Tokenisation (gérée par PteLlmEngine)

Template ChatML Qwen appliqué côté Kotlin :

<|im_start|>system\n{sys}<|im_end|>\n<|im_start|>user\n{usr}<|im_end|>\n<|im_start|>assistant\n

→ IDs → generateFromIds → décode (skip special tokens). Ne pas utiliser le path -prompt string natif (renvoie 0 token pour Qwen).


6. NPU in-app SANS root — pourquoi ça marche (important)

Le chemin .pte ExecuTorch+QNN ouvre /dev/fastrpc-cdsp-secure (FastRPC PD signé, type SELinux vendor_xdsp_device) — exactement le même device que le STT (ORT-QNN) déjà en production, qui transcrit sur le NPU dans l'APK. Donc le .pte est autorisé pour untrusted_app sans root, par le même mécanisme prouvé.

Contraste (et correction de l'ancienne doc) : le backend ggml-hexagon (l'approche GGUF-sur-NPU de la v02/06) ouvre /dev/fastrpc-cdsp (PD non-signé/brut, vendor_qdsp_device), bloqué pour untrusted_app. C'est pourquoi le GGUF tourne CPU-only in-app, mais le .pte tourne sur NPU sans root. La conclusion « rebuild CPU-only requis » de l'ancienne doc ne vaut que pour le GGUF ; le NPU LLM se fait désormais par le .pte.

Aucune règle SELinux, aucun Magisk requis pour les .pte.


7. Détails opérationnels GGUF/CPU (Qwen3.5-4B hybride)

Le moteur GGUF (EngineLlmEngine.kt, lib libkazeia_engine) reste pour le hybride Qwen3.5-4B (et tout modèle non exportable en .pte).

7.1 API (inchangée)

val llm  = EngineLlmEngine("$dir/Qwen3.5-4B-Q4_0.gguf", ctx = 4096, nThreads = 6)
val r    = llm.generate(sys, usr, max = 96)
val chat = llm.newChat(system = "...")          // multi-tour ChatML + thinking-off
chat.ask("Bonsoir."); chat.ask("Je me sens vide."); chat.clear()
llm.release()

7.2 ⚠ Piège affinity in-app (CPU)

Le decode CPU peut tomber de ~16 tok/s (standalone) à <1 tok/s in-app si le scheduler Android colle le process sur 1 cœur. Fix : épingler sur les grands cœurs + THREAD_PRIORITY_FOREGROUND avant de générer, et appeler depuis Dispatchers.IO.

viewModelScope.launch(Dispatchers.IO) {
    android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_FOREGROUND)
    val reply = llm.generate(sys, usr, max)
    withContext(Dispatchers.Main) { display(reply) }
}

Validation : adb shell top -H -p <pid> doit montrer ~6 threads à 80-100 %, pas 1 seul. (Ce piège ne concerne PAS le .pte, dont le gros du calcul est sur le NPU.)

7.3 Threads (sweet spot mesuré)

nThreads = 6 par défaut. t=8 = contention (pire). En cascade simultanée, répartir (ex. Speaker 4, Thinker 2) pour ne pas dépasser 6 grands cœurs.

7.4 Cascade Speaker + Guard

val speaker = LlmLoader.load("$dir/qwen3_8b.pte", ctx = 512, tokenizerPath = "$dir/tokenizer.json")
val guard   = LlmLoader.load("$dir/hybrid_llama_qnn_guard4b.pte", ctx = 512, tokenizerPath = "$dir/tokenizer_guard.json")
val draft   = speaker.generate(sysSpeaker, userInput, max = 96)
val verdict = guard.generate(sysGuard, "Réponse à vérifier: $draft", max = 32)
speaker.release(); guard.release()

Les .pte co-résidents : surveiller la RAM (poids ION). Guard-4B + un 7-8B tiennent dans 16 GB.


8. Fichiers livrables (chemins exacts)

# Moteur natif + runtime .pte
dist/lib-pte/libkazeia_pte.so          # wrapper JNI (95 Mo)
dist/lib-pte/kazeia_pte_jni.cpp        # source
dist/lib-pte/                 # 7 libs QAIRT 2.42

# Kotlin
/opt/Kazeia-engine/dist/jni/LlmLoader.kt                # chargeur + interface + 2 moteurs
/opt/Kazeia-engine/dist/jni/EngineLlmEngine.kt          # moteur GGUF (inchangé)

# Modèles .pte + tokenizers
/opt/Kazeia/models/qwen3_4b_seq1024/{hybrid_llama_qnn.pte, tokenizer.json}
/opt/Kazeia/models/qwen3_8b_seq512/{hybrid_llama_qnn.pte, tokenizer.json}
/opt/Kazeia/models/qwen2_5_7b_seq512/{hybrid_llama_qnn.pte, tokenizer.json}
/opt/Kazeia/kazeia-tablet-backup/llm/{hybrid_llama_qnn_guard4b.pte, tokenizer_guard.json}

# GGUF hybride (existant)
Qwen3.5-4B-Q4_0.gguf  +  dist/lib-cpu/libggml-cpu.so (i8mm)

9. Annexe — préparer un NOUVEAU .pte (futur modèle dense)

Chaîne reproductible (utilisée pour les 4 modèles) :

# Env : /opt/Kazeia/et_venv (py3.10), ExecuTorch buildé /opt/Kazeia/executorch,
#       QNN_SDK_ROOT=/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225 (⚠ 2.42 obligatoire), NDK r27d.
# 1. Enregistrer le modèle (si absent) :
#    examples/qualcomm/oss_scripts/llama/decoder_constants.py  -> "modele": "famille"
#    examples/qualcomm/oss_scripts/llama/__init__.py           -> dataclass @register_llm_model
#    examples/models/<famille>/config/<taille>_config.json     -> dims depuis config.json HF
# 2. Export (cf. /opt/Kazeia/export_speaker_25_7b.sh) :
python -m executorch.examples.qualcomm.oss_scripts.llama.llama \
  -b build-x86 -m SM8750 --decoder_model <modele> --model_mode hybrid \
  --prefill_ar_len 128 --max_seq_len <512|1024> --num_sharding 2 --compile_only \
  --artifact /opt/Kazeia/models/<out_dir>
# Produit hybrid_llama_qnn.pte (+ decode_qdq.pt2 régénérable ~25-43 G, à purger après).
# ⚠ RAM : matérialisation d'un 7-8B pic ~37 G -> prévoir du swap (sinon OOM).
# 3. Valider on-device : qnn_llama_runner -model_path X.pte -tokenizer_path tok.json \
#    -tokenized_prompt ids.bin -decoder_model_version <qwen3|qwen2_5> -eval_mode 1 -seq_len N

Denses déjà supportés par le registre : llama3.2-1b/3b, qwen2_5-0_5b/1_5b/7b, qwen3-0_6b/1_7b/4b/8b/14b, qwen3guard-gen-0_6b/4b, gemma, phi_4_mini, granite, smollm…


10. Limites & pièges connus

  • Version QNN : le libqnn_executorch_backend.so livré doit == la version QNN d'export des .pte (2.42). Un backend d'une autre version (ex. 2.37) ne désérialise pas les blobs → le modèle se charge mais ne génère rien (silencieux). Toujours livrer le ship_runtime/ fourni.
  • Tokeniseur natif cassé pour Qwen → tokeniser côté Kotlin (DJL), passer les IDs.
  • decoder_model_version : qwen3 pour Qwen3*/Guard, qwen2_5 pour Qwen2.5. Fixe template + EOS. (PteLlmEngine le passe au natif via le chemin du fichier ; vérifier que le nom de fichier/dossier permet l'auto-détection, sinon l'exposer explicitement.)
  • Streaming : PteLlmEngine.generateStream émet en un bloc (génération bloquante). Vrai streaming token-par-token = variante native à callback (non implémentée ; non bloquant pour l'usage actuel).
  • Hybride Qwen3.5-4B : reste obligatoirement GGUF/CPU. Ne pas tenter de l'exporter en .pte (GatedDeltaNet non supporté QNN — investigué à fond).
  • Affinity GGUF (§7.2) : épingler les threads in-app, sinon decode CPU s'effondre.