14 KiB
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) →
.pteExecuTorch+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ègueEngineLlmEngine, 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) oumaxNewTokens. Renvoie uniquement les IDs générés.- Parité bit-exacte avec le runner CLI (Qwen3-4B : tokens identiques, ~18.8 tok/s).
NEEDED: uniquementlibqnn_executorch_backend.so+ libs système (le reste est statique).- Source :
pte_prep_work/jni/kazeia_pte_jni.cpp.
4.3 Set runtime QAIRT 2.42 — pte_prep_work/ship_runtime/
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 |
pte_prep_work/jni/ |
libqnn_executorch_backend.so |
pte_prep_work/ship_runtime/ |
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.solivré DOIT être la variante i8mm (dist/lib-cpu/libggml-cpu.so, ~1300 instructions i8mm), PASdist/lib/libggml-cpu.so(0 i8mm → prefill CPU ÷4.5). Vérif :objdump -d ... | grep -c smmladoit ê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é pouruntrusted_app. C'est pourquoi le GGUF tourne CPU-only in-app, mais le.ptetourne 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
.pteco-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
/opt/Kazeia/pte_prep_work/jni/libkazeia_pte.so # wrapper JNI (95 Mo)
/opt/Kazeia/pte_prep_work/jni/kazeia_pte_jni.cpp # source
/opt/Kazeia/pte_prep_work/ship_runtime/ # 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.solivré 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 leship_runtime/fourni. - Tokeniseur natif cassé pour Qwen → tokeniser côté Kotlin (DJL), passer les IDs.
decoder_model_version:qwen3pour Qwen3*/Guard,qwen2_5pour Qwen2.5. Fixe template + EOS. (PteLlmEnginele 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.