Kazeia-engine/dist/LLM_INTEGRATION.md

235 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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