# 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`) ```gradle 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 ```kotlin // 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) ```kotlin 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`. ```kotlin 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 ` 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 ```kotlin 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) : ```bash # 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//config/_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 --model_mode hybrid \ --prefill_ar_len 128 --max_seq_len <512|1024> --num_sharding 2 --compile_only \ --artifact /opt/Kazeia/models/ # 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 -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.