chantier LLM dispatch : .pte NPU + GGUF CPU, factory LlmLoader + doc dev + diag bug version QNN

- LlmLoader.kt : chargeur agnostique (magic-byte GGUF/ET12), interface LlmEngine,
  GgufLlmEngine (CPU-i8mm, hybride Qwen3.5-4B) + PteLlmEngine (NPU, denses).
- LLM_INTEGRATION.md : doc dev complète (modeles, perf mesuree, integration, SELinux no-root,
  export .pte) ; remplace la v02/06 perimee (GGUF+ggml-hexagon).
- .pte denses prepares+valides on-device : Qwen3-4B 15.7 / Qwen3-8B 10.5 / Guard-4B 17.2 /
  Qwen2.5-7B 8.0 tok/s ; wrapper JNI libkazeia_pte parite bit-exacte.
- BUG_pte_inapp_qnn_version_mismatch.md : diag in-app err 5010 = backend QNN != 2.42 (pas le .pte) ;
  ancien rapport overflow int32 retracte (refute par test CLI 8B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard Loyer 2026-06-18 22:51:16 +02:00
parent 3775bbc37c
commit f68ecb4573
4 changed files with 402 additions and 298 deletions

View File

@ -0,0 +1,102 @@
# BUG in-app `.pte` 7B/8B (err 5010) — RÉSOLU : mauvaise version de backend QNN chargée par l'app
**Date** : 17/06/2026 · **Sévérité** : bloquant in-app pour les `.pte` denses multi-context (≥7B, `num_sharding=2`)
**Statut** : **cause isolée** — ce n'est NI le `.pte`, NI le wrapper `libkazeia_pte.so`. C'est l'**intégration app** (résolution de lib QNN).
> ⚠️ **Rétracte** `BUG_pte_multicontext_offset_overflow.md` (théorie « overflow int32 » du `.pte`) : **réfutée** par le test CLI ci-dessous. Le `.pte` 8B (2ᵉ context group à 3,24 G > 2 GiB) **charge et génère** sans problème avec le bon runtime. Pas d'overflow.
---
## 1. TL;DR
L'erreur in-app `Context group 1 does not exist` / `Error 5010` est la **signature d'un backend QNN trop ancien** (≠ QAIRT 2.42) qui ne sait pas désérialiser la structure **multi-context** (sharding=2) des `.pte` ≥7B. Le backend **2.42** (`ship_runtime/`) les charge parfaitement. **L'app charge un autre `libQnnHtp.so`/backend que celui livré** (collision de SONAME, très probablement avec la pile **ORT-QNN du STT**, ou une lib d'une autre version restée sur le chemin du linker).
**Fix** : garantir que c'est bien la pile **QNN 2.42** qui est mappée dans le process app au moment du chargement `.pte` (pas la version du STT/Maven). Aucun changement de `.pte` ni de wrapper.
---
## 2. Preuve d'isolation (décisive)
Sur le **même device**, avec le **`ship_runtime/` 2.42 livré**, sur le **même `.pte` Qwen3-8B** (le cas « échec » de la théorie overflow) :
| Test sur Qwen3-8B (5,5 G, 2ᵉ context group à 3,24 G > 2 GiB) | Résultat |
|---|---|
| Runner CLI `qnn_llama_runner` + `ship_runtime` 2.42 | ✅ **313 tokens, 9,65 tok/s**, FR cohérent, EOS propre |
| Wrapper `libkazeia_pte.so` (notre JNI) + `ship_runtime` 2.42 | ✅ **41 tokens, 10,3 tok/s**, cohérent |
Dans **les deux** cas, le log montre `QnnContextCustomProtocol expected magic 0x5678abcd but get 0x2000000` **ET génère quand même****ce message est un INFO bénin présent dans les chargements qui marchent**, pas la cause de l'échec. L'app, elle, enchaîne sur `Context group 1 does not exist / err 5010` : c'est un **second symptôme, propre à l'app**, qui ne sort PAS avec le backend 2.42.
Conclusion logique : si c'était un overflow d'offset dans le `.pte` ou dans notre backend, le CLI **et** le wrapper échoueraient aussi. Ils réussissent. **Donc l'app exécute un backend différent.**
---
## 3. Cause racine — collision de version `libQnnHtp` / backend
`libkazeia_pte.so` ne `NEEDED` que `libqnn_executorch_backend.so` (le reste statique). Mais ce backend charge à son tour `libQnnHtp.so` / `libQnnSystem.so` **par SONAME**. Si l'APK contient **plusieurs versions** de ces libs — typiquement :
- la pile **ORT-QNN du STT** (qui embarque sa propre `libQnnHtp.so`, possiblement une autre version QAIRT),
- d'anciennes libs LLM (ère 2.32/2.37) restées dans `jniLibs`,
- une AAR QNN Maven,
…le linker Android n'en mappe **qu'une seule** par SONAME. Si la version gagnante n'est **pas 2.42**, le backend ExecuTorch reçoit un `libQnnHtp` incompatible → `Context group N does not exist` / `err 5010` sur les `.pte` multi-context.
**Pourquoi 4B marche et 7B/8B non** : un backend trop ancien gère encore le layout de contexte simple du 4B, mais **pas** la structure multi-context (2 groups) des modèles plus gros. La forensique avait raison de voir « 2 context groups » sur les 7B/8B — mais la cause n'est pas un overflow dans le `.pte`, c'est le **backend de l'app qui ne sait pas les parser**. Le 2.42 les parse (prouvé §2).
---
## 4. Diagnostic à faire côté dev (confirme la cause en 2 min)
**Quelle version de `libQnnHtp` est RÉELLEMENT mappée dans le process app ?**
```bash
PID=$(adb shell pidof com.kazeia)
adb shell run-as com.kazeia cat /proc/$PID/maps | grep -iE 'libQnnHtp|libqnn_executorch_backend|libQnnSystem' | awk '{print $NF}' | sort -u
# puis pour chaque .so mappé :
adb shell run-as com.kazeia strings <chemin_mappé>/libQnnHtp.so | grep -m1 AISW_VERSION # doit être 2.42.0
```
Si la version mappée ≠ **2.42.0**, la cause est confirmée.
**Vérifier les doublons de QNN dans l'APK :**
```bash
unzip -l app-release.apk | grep -iE 'libQnnHtp|libQnnSystem|libqnn' # ne doit PAS y avoir 2 origines/versions
```
**SHA256 attendus (libs 2.42 de `ship_runtime/`)** — ceux livrés dans `jniLibs/arm64-v8a/` DOIVENT matcher :
```
95d01368b556f0c6… libqnn_executorch_backend.so
10eb1923b34ac9a7… libQnnHtp.so
a69cfc4350c16b22… libQnnSystem.so
a08fb34747345125… libQnnHtpV79Stub.so
7ee72b438c97c13c… libQnnHtpV79Skel.so
2daafb376fe6904a… libQnnHtpPrepare.so
a3bc48674377a042… libQnnHtpNetRunExtensions.so
```
---
## 5. Fix
1. **Une seule version de QNN dans l'app** = **2.42** (celle de `ship_runtime/`), pour le STT **et** le LLM `.pte`. Concrètement :
- Retirer de `jniLibs` toute `libQnnHtp*.so`/`libQnnSystem.so` qui n'est pas du build 2.42 (anciennes libs LLM, AAR Maven d'une autre version).
- Si le STT (ORT-QNN) impose une version QNN différente, **aligner les deux sur 2.42** (le STT marche sur 2.42 ; ORT-QNN EP est tolérant à la version de `libQnnHtp` tant qu'elle ≥ celle de compilation). À défaut, isoler (process séparé) — mais l'alignement sur 2.42 est le plus simple.
2. Re-vérifier le `/proc/pid/maps` : `libQnnHtp.so` mappé = 2.42.0.
3. Aucun ré-export de `.pte`, aucun rebuild de `libkazeia_pte.so` nécessaires.
---
## 6. Reproduction de la PREUVE (artefact sain)
```bash
# device, dossier avec le .pte 8B + tokenizer + ship_runtime/ 2.42 + prompt_ids.bin (uint64 LE)
ADSP_LIBRARY_PATH=. LD_LIBRARY_PATH=. ./qnn_llama_runner \
-model_path qwen3_8b.pte -tokenizer_path tokenizer.json \
-tokenized_prompt p8b_ids.bin -decoder_model_version qwen3 -eval_mode 1 -seq_len 512 -temperature 0
# Attendu : génère ~300 tokens à ~9-10 tok/s. PAS d'err 5010.
```
Le `ship_runtime/` contient `qnn_llama_runner` + les 7 libs 2.42 : c'est l'oracle « bon backend ».
---
## 7. Données
- `.pte` validés (génèrent avec 2.42) : `qwen3_4b_seq1024/`, `qwen3_8b_seq512/`, `qwen2_5_7b_seq512/`, `kazeia-tablet-backup/llm/hybrid_llama_qnn_guard4b.pte`.
- Runtime de référence : `/opt/Kazeia/pte_prep_work/ship_runtime/` (QAIRT 2.42, `libQnnHtp` = AISW_VERSION 2.42.0).
- À fournir pour clore : `/proc/<pid>/maps` du process app pendant un load 8B + liste `unzip -l` des `libQnn*` de l'APK.

View File

@ -0,0 +1,10 @@
# ⚠️ RÉTRACTÉ — théorie « overflow int32 » réfutée
**17/06/2026.** Ce rapport concluait que les `.pte` ≥7B échouaient à cause d'un **overflow d'offset 32 bits** sur le 2ᵉ context group (> 2 GiB). **C'est FAUX.**
**Réfutation (test CLI décisif)** : le `.pte` **Qwen3-8B** (2ᵉ context group à 3,24 G, > 2 GiB) **charge et génère parfaitement** (313 tokens, 9,65 tok/s) avec le runner CLI `qnn_llama_runner` **et** avec le wrapper `libkazeia_pte.so`, dès lors qu'on utilise le bon runtime (`ship_runtime/` **QAIRT 2.42**). Si un offset 32 bits débordait, aucun des deux ne chargerait.
Le message `magic 0x5678abcd … but get 0x2000000` est un **INFO bénin présent aussi dans les chargements qui marchent** — la forensique l'avait confondu avec la cause. La corrélation « 2^31 » était fortuite (octets `0x5678abcd` croisés au hasard dans les poids).
**→ Vraie cause + fix : voir [`BUG_pte_inapp_qnn_version_mismatch.md`](./BUG_pte_inapp_qnn_version_mismatch.md).**
L'`err 5010 / Context group 1 does not exist` in-app = **l'app charge un backend QNN ≠ 2.42** (collision de SONAME `libQnnHtp`, probablement avec la pile ORT-QNN du STT). Ni le `.pte` ni le wrapper ne sont en cause.

View File

@ -1,348 +1,234 @@
# Intégration LLM Kazeia-Engine — Guide développeur # Kazeia — Intégration moteur LLM (NPU `.pte` + CPU GGUF)
> ⚠️ **BLOQUEUR SELinux découvert 02/06 — lire la section 0 avant toute intégration in-app.** **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).
> La lib `libkazeia_engine.so` actuelle (avec backend Hexagon HTP) **NE FONCTIONNE PAS** **Cible** : Snapdragon 8 Elite (SM8750 / Hexagon V79), Android, app `untrusted_app`.
> dans un APK `untrusted_app` à cause d'une policy SELinux Android/Qualcomm qui interdit **Statut** : moteurs prêts, validés on-device. Reste = intégration app (pas de R&D).
> l'accès à `/dev/fastrpc-cdsp`. Tout test CLI standalone via `adb shell` est un faux positif
> systématique (uid `shell` a accès, pas l'app). **Rebuild CPU-only requis** pour usage in-app.
**Date** : 02/06/2026 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.
**Cible** : remplacer le pipeline LLM ExecuTorch `.pte` par `libkazeia_engine.so` + façade Kotlin `EngineLlmEngine.kt`. Modèle GGUF chargé directement (pas de conversion .pte), prefill HTP / decode CPU automatique (option C).
**Runtime** : libllama (fork ggml-org/llama.cpp commit `f0fe1058b`) + libggml fork ql (Qualcomm) avec backend Hexagon HTP V79.
**Modèles supportés** (auto-détection via `general.architecture` du GGUF) :
- **Qwen3 dense** (Qwen3-4B, Qwen3Guard-Gen-4B/8B) : HTP contexte-unique, matmul HVX (HMX off, fault 0x2e), prefill+decode HTP
- **Qwen3.5 hybride GDN** (Qwen3.5-4B, Qwen3.5-9B) : **option C** — prefill HTP avec HMX (159 tok/s) / decode CPU NEON (16.5 tok/s) avec transfert KV automatique entre contextes
- **Fallback CPU pur** si pas de HTP détecté
**Perf prouvée** (Snapdragon 8 Elite SM8750, t=6, standalone CLI) :
| Modèle | Prefill | Decode | RAM | Mode |
|---|---:|---:|---:|---|
| Qwen3-4B-Q4_0 | 103 tok/s (HTP) | 17 tok/s (CPU) | 2.4 GB | dense HTP |
| Qwen3.5-4B-Q4_0 | 55 tok/s (HTP) | 15 tok/s (CPU) | 2.4 GB | option C |
| Qwen3.5-9B-Q4_0 | 41 tok/s (HTP) | 8.4 tok/s (CPU) | 5.0 GB | option C |
| Qwen3Guard-Gen-4B-Q4_K_M | 89 tok/s (HTP) | 22 tok/s (CPU) | 2.5 GB | dense HTP |
⚠️ **Note in-app perf** : le sweet spot threads in-app est `nThreads=6` (mesure `r3`). Versions précédentes du bridge utilisaient `nThreads=4` hardcoded — corrigé dans le commit ce session.
--- ---
## 0. Bloqueur SELinux découvert 02/06 — à lire en premier ## 1. Résumé exécutif
**Symptôme** : `System.loadLibrary("kazeia_engine")` peut passer, mais le premier appel à `EngineLlmEngine.load(modelPath, nCtx, nThreads)` crashe avec SIGABRT (`ggml_backend_dev_name` → `ggml_abort`). 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 :
**Cause** : `libkazeia_engine.so` linke `libllama.so` + `libggml-hexagon.so` qui dlopen `libcdsprpc.so` (FastRPC) au démarrage du backend HTP. FastRPC ouvre `/dev/fastrpc-cdsp` pour parler au CDSP. La policy SELinux Oplus/Qualcomm sur SM8750 interdit cet accès à `untrusted_app` : - **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`.
avc: denied { open } for path="/dev/fastrpc-cdsp"
scontext=u:r:untrusted_app tcontext=u:object_r:vendor_qdsp_device
```
→ Backend hexagon en état partiel → crash au premier accès.
**Pourquoi cette doc l'avait raté** : tous mes tests en `adb shell` (uid 2000) marchaient parfaitement. Le contexte SELinux `shell` a accès à `/dev/fastrpc-cdsp`. **Le contexte `untrusted_app` ne l'a pas**. Faux positif systématique du harness standalone.
**Pourquoi STT et ExecuTorch prod marchent** : ils utilisent QNN via `libQnnHtp.so` Maven, qui passe par une voie de delegation autorisée. Pas FastRPC direct.
**Solution** : **rebuild `libllama.so` + `libggml*.so` + `libkazeia_engine.so` en CPU-only** (`-DGGML_HEXAGON=OFF`). Le bridge devient CPU pur. Coût mesuré sur workload Kazeia (prompts 16-24 tok, réponses 64 tok) :
| | HTP prod | CPU-only |
|---|---:|---:|
| LLM prefill | ~0.4 s | ~1.3 s |
| LLM decode (64 tok à 9.8 t/s) | ~6.5 s | ~6.5 s (déjà CPU) |
| **Total tour LLM** | **~7 s** | **~8 s (+1 s)** |
Acceptable pour thérapeutique. Voir mémoire `project_engine_selinux_fastrpc_blocker` pour le détail des 3 voies évaluées.
**État jusqu'à rebuild CPU-only** : flag `llm_engine=lib` arme la bascule mais la lib ne fonctionnera pas in-app. Fallback gracieux sur `prod` (.pte) recommandé tant que le rebuild n'est pas fait.
## 1. Ce que la lib fait
```
libkazeia_engine.so (60 KB, SHARED, dépend de libllama + libggml*)
├── EngineLlmEngine load(ggufPath, nCtx=4096, nThreads=6) -> handle
├── EngineLlmEngine generate(handle, sys, usr, maxTok) -> String # mono-tour ChatML
├── EngineLlmEngine generateRaw(handle, prompt, maxTok) -> String # multi-tour
├── EngineLlmEngine reset(handle) # clear KV
├── EngineLlmEngine free(handle)
└── (+ API TTS embeds-only : nEmbd, nVocab, prefillEmbeds, decodeEmbed — pour Talker TTS)
```
API Kotlin (`EngineLlmEngine.kt`) :
```kotlin
val llm = EngineLlmEngine(
model = "/data/.../Qwen3.5-4B-Q4_0.gguf",
ctx = 4096,
nThreads = 6 // sweet spot Snapdragon 8 Elite r3
)
// Mono-tour avec system + 1 message
val reply = llm.generate(
sys = "Tu es Kazeia, un assistant psychologique bienveillant. Réponds en français bref.",
usr = "Je me sens un peu seul.",
max = 96
)
// Multi-tour via ChatSession (gère ChatML + thinking-off déterministe)
val chat = llm.newChat(system = "...")
val r1 = chat.ask("Bonjour")
val r2 = chat.ask("Comment vous allez ?")
chat.clear()
llm.release()
```
--- ---
## 2. Build (côté app Android) ## 2. Les modèles LLM — lesquels, quel format, statut
### a. Récupérer les artifacts | 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 |
```bash > ⚠ « Qwen3-7B » n'existe pas (denses Qwen3 = 0.6/1.7/4/8/14/32B). Préparé : **Qwen3-8B**.
cd /opt/Kazeia-engine/dist
# Recompile libkazeia_engine.so (60 KB)
NDK=/opt/Kazeia/android-ndk-r27d
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android31-clang++ \
-std=c++17 -O3 -fPIC \
-march=armv8.6-a+i8mm+bf16+dotprod+fp16 \
-I./include -shared jni/kazeia_engine_jni.cpp \
-L./lib -lllama -lggml -lggml-base -llog -ldl -lm \
-o b-jni/libkazeia_engine.so
```
### b. Libs à copier dans `app/src/main/jniLibs/arm64-v8a/` Chaque `.pte` est accompagné de son **`tokenizer.json`** (même dossier ; Guard-4B : `kazeia-tablet-backup/llm/tokenizer_guard.json`).
**8 fichiers obligatoires** (vérifiés via `readelf -d` sur la lib + dépendances transitives `libllama.so`) :
| Fichier | Taille | Rôle |
|---|---:|---|
| `libkazeia_engine.so` | 60 KB | la lib elle-même |
| `libllama.so` | 35 MB | fork ggml-org/llama.cpp |
| `libggml.so` | 627 KB | ggml core (charge cpu+hexagon backends dynamiquement) |
| `libggml-base.so` | 6.6 MB | ggml base ops |
| `libggml-cpu.so` | 4.7 MB | backend CPU NEON ARM v8.6 |
| `libggml-hexagon.so` | 3.3 MB | backend Hexagon HTP host wrapper |
| `libggml-htp-v79.so` | 354 KB | DSP skel V79 (chargé via dlopen runtime) |
| `libc++_shared.so` | 1.8 MB | NDK r27d STL |
**Total** : ~52 MB. **Mutualisable avec libkazeia_tts.so** — si tu intègres TTS en parallèle, ces 7 deps sont les mêmes (juste `libkazeia_engine.so` et `libkazeia_tts.so` qui s'ajoutent en plus).
### c. Façade Kotlin
Copie `dist/jni/EngineLlmEngine.kt` dans `app/src/main/java/com/kazeia/llm/EngineLlmEngine.kt`. Package = `com.kazeia.llm`.
### d. Modèles sur la tablette
```
/data/local/tmp/kazeia/models/
├── Qwen3.5-4B-Q4_0.gguf # 2.4 GB — speaker / thinker principal
├── Qwen3.5-9B-Q4_0.gguf # 5.0 GB — option si qualité prioritaire
├── Qwen3Guard-Gen-4B.Q4_K_M.gguf # 2.5 GB — Guard (modération)
└── Qwen3-4B-Q4_0.gguf # 2.4 GB — pour cascade Speaker dense
```
--- ---
## 3. Migration depuis ExecuTorch (.pte) ## 3. Performance mesurée on-device (SM8750/V79, greedy, eval_mode 1 hybride)
| Avant (ExecuTorch) | Après (Kazeia-Engine) | | 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 : `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 |
|---|---| |---|---|
| `model.pte` 3.3 GB | `Qwen3.5-4B-Q4_0.gguf` 2.4 GB (-30 %) | | `libkazeia_pte.so` | `pte_prep_work/jni/` |
| Conversion .pte requise (chaîne ExecuTorch + QNN compile) | Pas de conversion : GGUF chargé direct | | `libqnn_executorch_backend.so` | `pte_prep_work/ship_runtime/` |
| Pipeline 100% NPU INT4 | Option C : prefill HTP / decode CPU NEON | | `libQnnHtp.so` · `libQnnSystem.so` · `libQnnHtpPrepare.so` | idem |
| Decode 14-21 tok/s | Decode 15-17 tok/s (4B), équivalent | | `libQnnHtpV79Stub.so` · `libQnnHtpV79Skel.so` · `libQnnHtpNetRunExtensions.so` | idem |
| Pas extensible à Qwen3.5 (ExecuTorch ne sait pas l'exporter) | Qwen3.5 hybride GDN supporté nativement |
| Pas de cascade multi-modèle facile | Cascade Speaker+Thinker via 2 instances `EngineLlmEngine` |
Suppression côté app : tout le pipeline `ExecuTorchLlmEngine` et ses dépendances ExecuTorch peuvent être retirés. **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).
--- ---
## 4. Exemples d'usage ## 6. NPU in-app SANS root — pourquoi ça marche (important)
### Mono-tour psy bref 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 ```kotlin
val llm = EngineLlmEngine( val llm = EngineLlmEngine("$dir/Qwen3.5-4B-Q4_0.gguf", ctx = 4096, nThreads = 6)
model = "$kazeiaDir/models/Qwen3.5-4B-Q4_0.gguf", val r = llm.generate(sys, usr, max = 96)
ctx = 2048, val chat = llm.newChat(system = "...") // multi-tour ChatML + thinking-off
nThreads = 6 chat.ask("Bonsoir."); chat.ask("Je me sens vide."); chat.clear()
)
val reply = llm.generate(
sys = "Tu es Kazeia, psy bienveillant. Une phrase brève.",
usr = "Je rumine la nuit sans dormir.",
max = 64
)
llm.release() llm.release()
``` ```
### Multi-tour ChatSession ### 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 ```kotlin
val llm = EngineLlmEngine("$kazeiaDir/Qwen3.5-4B-Q4_0.gguf", nThreads = 6)
val chat = llm.newChat("Tu es Kazeia, psy bienveillant.")
chat.ask("Bonsoir.")
chat.ask("Je me sens vide.")
chat.ask("Depuis le départ de ma fille.")
chat.clear() // reset historique
llm.release()
```
### Cascade Speaker + Thinker
```kotlin
val speaker = EngineLlmEngine("$kazeiaDir/Qwen3.5-9B-Q4_0.gguf", nThreads = 6)
val thinker = EngineLlmEngine("$kazeiaDir/Qwen3Guard-Gen-4B.Q4_K_M.gguf", nThreads = 4)
// 4 threads sur Thinker pour laisser 4 cœurs au Speaker en parallèle
val draft = speaker.generate(sysSpeaker, userInput, max = 96)
val safe = thinker.generate(sysGuard, "Réponse à vérifier: $draft", max = 32)
// si safe == "OK" -> envoyer draft, sinon reformuler
speaker.release(); thinker.release()
```
---
## 5. Codes d'erreur
`load()` renvoie `0L` (= `IllegalStateException` Kotlin via `require`) si :
- modèle introuvable ou GGUF invalide
- pas assez de RAM
- erreur ggml_backend_init (rare)
`generate()` / `generateRaw()` renvoient un texte vide `""` si :
- tokenize fail
- llama_decode prefill fail
- KV transfer fail (option C uniquement)
Pas de codes int dédiés (différent de TTS/STT) — l'API LLM est plus simple. Log via stderr `kazeia-engine:` pour le debug.
---
## 6. Bench rapide standalone
`llama-cli` est inclus dans `dist/` pour mesurer la perf avant intégration :
```bash
adb shell '
cd /data/local/tmp/kz-engine
export LD_LIBRARY_PATH=./lib
export ADSP_LIBRARY_PATH=./lib
./llama-cli -m Qwen3.5-4B-Q4_0.gguf \
-p "Bonjour, comment vas-tu ?" \
-n 64 -t 6 -ngl 99 --reasoning-budget 0 -dev HTP0
'
```
Bench standardisé :
```bash
./llama-bench -m Qwen3.5-4B-Q4_0.gguf -t 6 -ngl 99 -p 32,128,512 -n 16
```
---
## 7. Checklist d'intégration
1. **`System.loadLibrary("kazeia_engine")` réussit** au démarrage. Si erreur `library "libllama.so" not found`, vérifier les 8 libs dans `jniLibs/arm64-v8a/`.
2. **Charge un engine + 1 generate** sur un modèle fixture (Qwen3.5-4B) avec un prompt simple. Le log stderr doit indiquer le mode détecté :
```
kazeia-engine: HYBRIDE GDN (qwen35) -> option C, prefill HTP+HMX (t=8) / decode CPU (t=6)
```
ou
```
kazeia-engine: DENSE (qwen3) -> HTP contexte-unique, matmul HVX (HMX off) (t=6)
```
ou
```
kazeia-engine: CPU pur (qwen3, pas de HTP) (t=6)
```
3. **Vérifier n_threads in-app** : si tu vois decode << 10 tok/s sur Qwen3.5-4B, c'est que les threads tombent à 1 cœur (bug Android scheduler). Solution : forcer affinity 8 grands cœurs depuis Kotlin avant de générer (cf §8).
4. **Sanity null/edge** :
- prompt vide → texte vide
- maxTok = 0 → texte vide
- modèle inexistant → IllegalStateException au constructeur
5. **Threading app** : `generate()` est synchrone bloquant. Appeler depuis `Dispatchers.IO` ou un thread dédié. Pas multi-thread sur le même handle (1 generate à la fois par engine).
6. **Cycle de vie** : `release()` quand l'app sort du foreground (libère 2.4-5.0 GB de RAM selon modèle).
---
## 8. Piège connu : affinity Android in-app
**Symptôme** : decode tombe de 9.8 tok/s (CLI standalone) à 0.32 tok/s in-app sur le même modèle.
**Cause** : le scheduler Android plante le process sur 1 petit cœur quand l'app n'est pas au foreground actif, ou quand il n'y a pas de hint thermique. `llama-cli` standalone via adb shell évite ce piège (process root-like).
**Fix côté app** :
```kotlin
import android.os.Process
import java.io.RandomAccessFile
private fun pinToBigCores() {
// Snapdragon 8 Elite : 8 cores total, cores 0-3 = grands (big), 4-7 = petits (medium/little).
// En pratique sur SM8750, masque 0xFC laisse tomber les 2 petits cores et garde les 6 grands.
try {
val tid = Process.myTid()
// sched_setaffinity via /proc/self/task/<tid>/cgroup ou via JNI taskset.
// Sinon, fallback : démarrer le thread avec Thread.MAX_PRIORITY.
Thread.currentThread().priority = Thread.MAX_PRIORITY
Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND)
} catch (e: Throwable) { /* best effort */ }
}
// Dans le ViewModel/Service qui héberge l'engine :
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
pinToBigCores() android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_FOREGROUND)
val reply = llm.generate(sys, usr, max) val reply = llm.generate(sys, usr, max)
withContext(Dispatchers.Main) { display(reply) } 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.)
Sans cette pin, `nThreads=6` côté JNI n'est pas suffisant — Android assigne quand même 1 cœur effectif. ### 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.
**Validation** : pendant un generate, faire `adb shell top -H -p <pid>` et vérifier qu'on voit 6 threads actifs autour de 80-100 % CPU. Si on voit 1 thread à 100 % et les autres à 0, l'affinity n'est pas posée. ### 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.
--- ---
## 9. Perf détaillée et tuning ## 8. Fichiers livrables (chemins exacts)
### Sweet spot threads (mesure `r3`, Snapdragon 8 Elite) ```
# 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
| Modèle | t=4 | **t=6** | t=8 | # Kotlin
|---|---:|---:|---:| /opt/Kazeia-engine/dist/jni/LlmLoader.kt # chargeur + interface + 2 moteurs
| Qwen3.5-4B Q4_0 | 8.2 | **9.8** | 7.4 (contention) | /opt/Kazeia-engine/dist/jni/EngineLlmEngine.kt # moteur GGUF (inchangé)
| Qwen3.5-9B Q4_0 | 4.4 | **5.2** | 2.1 (contention) |
| Qwen3-4B Q4_0 | 13 | **17** | 14 |
`t=8` est mauvais à cause de contention (oversubscription + scheduler Android). **Garde 6 par défaut.** # 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}
### Cascade : adapter les threads # GGUF hybride (existant)
Qwen3.5-4B-Q4_0.gguf + dist/lib-cpu/libggml-cpu.so (i8mm)
Si tu lances Speaker (4B) et Thinker (4B) en simultané : ```
- Speaker `nThreads = 4`
- Thinker `nThreads = 2`
Le total = 6 grands cœurs, on laisse 2 cœurs au système Android.
### Decode = memory-bandwidth bound
`r3` : decode M=1 est limité par BW LPDDR5x ~77 GB/s, pas par compute. Donc Q4_0 (2 GB poids) est ~2× plus rapide que Q8_0 (4 GB) sur le decode. Pas d'intérêt à Q6_K ou plus précis sur ce hardware.
--- ---
## 10. Contacts & support ## 9. Annexe — préparer un NOUVEAU `.pte` (futur modèle dense)
Code source : `/opt/Kazeia-engine/dist/jni/kazeia_engine_jni.cpp` + `EngineLlmEngine.kt`. 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…
Bench CLI : `/opt/Kazeia-engine/dist/llama-cli` + `llama-bench`. ---
Pour reproduire un bug : capture logcat tag `kazeia-engine`, et output du log stderr (mode détecté + n_threads effectif). ## 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.

106
dist/jni/LlmLoader.kt vendored Normal file
View File

@ -0,0 +1,106 @@
package com.kazeia.llm
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer
import java.io.File
import java.nio.file.Paths
// Chargeur LLM agnostique au format. Détecte par magic-byte et instancie le bon moteur :
// - GGUF -> GgufLlmEngine (libkazeia_engine / libllama, CPU-i8mm). TOUS les modèles,
// dont l'hybride Qwen3.5-4B (GatedDeltaNet) qui ne s'exporte PAS en .pte.
// - .pte -> PteLlmEngine (ExecuTorch + QNN, NPU V79, lib kazeia_pte). Denses UNIQUEMENT
// (Qwen3-4B, Qwen3-8B, Qwen2.5-7B, Qwen3Guard-4B). Chemin in-app autorisé
// (libQnn Maven, sans root). RAM ~÷2, prefill NPU rapide ; decode ~= CPU.
//
// Parc validé on-device (17/06, SM8750/V79, decode tok/s) :
// Qwen3-4B .pte 15.7 | Qwen3-8B .pte 10.5 | Qwen3Guard-4B .pte 17.2 | Qwen2.5-7B .pte 8.0
// Qwen3.5-4B GGUF 16.6 (hybride, CPU-i8mm)
interface LlmEngine {
fun generate(sys: String, usr: String, max: Int = 96): String
fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean)
fun lastStats(): GenStats
fun reset()
fun release()
}
object LlmLoader {
private fun magic(path: String): ByteArray =
ByteArray(8).also { b -> File(path).inputStream().use { it.read(b) } }
private fun ByteArray.has(off: Int, tag: String): Boolean =
tag.indices.all { i -> off + i < size && this[off + i] == tag[i].code.toByte() }
// GGUF -> "GGUF" aux octets 0-3. ExecuTorch .pte -> "ET12" à l'octet 4.
// tokenizerPath : requis pour .pte (tokeniseur HF externe). Défaut = tokenizer.json voisin du .pte.
fun load(path: String, ctx: Int = 4096, nThreads: Int = 6, tokenizerPath: String? = null): LlmEngine {
val m = magic(path)
return when {
m.has(0, "GGUF") -> GgufLlmEngine(path, ctx, nThreads)
m.has(4, "ET12") -> {
val tk = tokenizerPath ?: (File(path).parent ?: ".") + "/tokenizer.json"
PteLlmEngine(path, tk, seqLen = ctx)
}
else -> error("Format LLM inconnu (ni GGUF ni ExecuTorch .pte) : $path")
}
}
}
// Adaptateur GGUF : délègue au moteur natif existant (libkazeia_engine / libllama, CPU-i8mm).
class GgufLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) : LlmEngine {
private val e = EngineLlmEngine(model, ctx, nThreads)
override fun generate(sys: String, usr: String, max: Int) = e.generate(sys, usr, max)
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) =
e.generateStream(sys, usr, max, onToken)
override fun lastStats() = e.lastStats()
override fun reset() = e.reset()
override fun release() = e.release()
}
// JNI ExecuTorch+QNN. Lib `kazeia_pte` = wrapper natif du runner ExecuTorch (rebuild QAIRT 2.42).
// jniLibs/arm64-v8a doit aussi contenir : libqnn_executorch_backend.so (2.42) +
// libQnnHtp/System/Prepare/HtpV79Stub/HtpNetRunExtensions.so (QAIRT 2.42) + libQnnHtpV79Skel.so.
// Set figé : /opt/Kazeia/pte_prep_work/ship_runtime/.
internal object ExecuTorchJni {
external fun load(ptePath: String, tokenizerPath: String, seqLen: Int, evalMode: Int): Long
external fun generateFromIds(handle: Long, promptIds: LongArray, maxNewTokens: Int): LongArray
external fun lastStats(handle: Long): LongArray // [prefillMs, decodeMs, nGenerated]
external fun free(handle: Long)
init { System.loadLibrary("kazeia_pte") }
}
// Adaptateur ExecuTorch+QNN (.pte) pour les denses sur NPU V79.
// La tokenisation est faite ICI (côté Kotlin) car le tokeniseur natif est cassé pour Qwen
// (regex lookahead RE2/PCRE2) : on applique le template ChatML, on encode en IDs, et on passe
// les IDs au natif (eval_mode 1 hybride). Le natif décode greedy jusqu'à EOS et renvoie les IDs générés.
class PteLlmEngine(ptePath: String, tokenizerJson: String, private val seqLen: Int = 4096) : LlmEngine {
private val tok = HuggingFaceTokenizer.newInstance(Paths.get(tokenizerJson))
private val h = ExecuTorchJni.load(ptePath, tokenizerJson, seqLen, /*evalMode=hybrid*/ 1)
init { require(h != 0L) { "échec chargement .pte : $ptePath" } }
// Template ChatML Qwen ; les balises sont dans le texte -> encode sans special tokens auto.
private fun chatml(sys: String, usr: String): LongArray {
val p = buildString {
if (sys.isNotEmpty()) append("<|im_start|>system\n").append(sys).append("<|im_end|>\n")
append("<|im_start|>user\n").append(usr).append("<|im_end|>\n<|im_start|>assistant\n")
}
return tok.encode(p, /*addSpecialTokens=*/false).ids
}
override fun generate(sys: String, usr: String, max: Int): String {
val outIds = ExecuTorchJni.generateFromIds(h, chatml(sys, usr), max)
return tok.decode(outIds, /*skipSpecialTokens=*/true).trim()
}
// Streaming réel = variante native à callback de token (TODO côté JNI).
// En attendant : génération bloquante puis émission en un bloc.
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) {
onToken(generate(sys, usr, max))
}
override fun lastStats(): GenStats =
ExecuTorchJni.lastStats(h).let { GenStats(it[0], it[1], it[2]) }
override fun reset() { /* runner sans état persistant entre appels */ }
override fun release() { ExecuTorchJni.free(h); tok.close() }
}