235 lines
14 KiB
Markdown
235 lines
14 KiB
Markdown
# 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 <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
|
||
```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/<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.
|