diff --git a/dist/BUG_pte_inapp_qnn_version_mismatch.md b/dist/BUG_pte_inapp_qnn_version_mismatch.md new file mode 100644 index 0000000..0681fb0 --- /dev/null +++ b/dist/BUG_pte_inapp_qnn_version_mismatch.md @@ -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 /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//maps` du process app pendant un load 8B + liste `unzip -l` des `libQnn*` de l'APK. diff --git a/dist/BUG_pte_multicontext_offset_overflow.md b/dist/BUG_pte_multicontext_offset_overflow.md new file mode 100644 index 0000000..47275d5 --- /dev/null +++ b/dist/BUG_pte_multicontext_offset_overflow.md @@ -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. diff --git a/dist/LLM_INTEGRATION.md b/dist/LLM_INTEGRATION.md index 2249d70..6bede59 100644 --- a/dist/LLM_INTEGRATION.md +++ b/dist/LLM_INTEGRATION.md @@ -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.** -> La lib `libkazeia_engine.so` actuelle (avec backend Hexagon HTP) **NE FONCTIONNE PAS** -> dans un APK `untrusted_app` à cause d'une policy SELinux Android/Qualcomm qui interdit -> 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** : 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). -**Date** : 02/06/2026 -**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. +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. --- -## 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). -``` -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() -``` +Le reste de l'app est agnostique au format : `LlmLoader.load(path)` rend un `LlmEngine`. --- -## 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 -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 -``` +> ⚠ « Qwen3-7B » n'existe pas (denses Qwen3 = 0.6/1.7/4/8/14/32B). Préparé : **Qwen3-8B**. -### b. Libs à copier dans `app/src/main/jniLibs/arm64-v8a/` - -**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 -``` +Chaque `.pte` est accompagné de son **`tokenizer.json`** (même dossier ; Guard-4B : `kazeia-tablet-backup/llm/tokenizer_guard.json`). --- -## 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 %) | -| Conversion .pte requise (chaîne ExecuTorch + QNN compile) | Pas de conversion : GGUF chargé direct | -| Pipeline 100% NPU INT4 | Option C : prefill HTP / decode CPU NEON | -| Decode 14-21 tok/s | Decode 15-17 tok/s (4B), équivalent | -| 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` | +| `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 | -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 -val llm = EngineLlmEngine( - model = "$kazeiaDir/models/Qwen3.5-4B-Q4_0.gguf", - ctx = 2048, - nThreads = 6 -) -val reply = llm.generate( - sys = "Tu es Kazeia, psy bienveillant. Une phrase brève.", - usr = "Je rumine la nuit sans dormir.", - max = 64 -) +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() ``` -### 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 -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//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) { - pinToBigCores() + 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.) -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 ` 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 | -|---|---:|---:|---:| -| Qwen3.5-4B Q4_0 | 8.2 | **9.8** | 7.4 (contention) | -| Qwen3.5-9B Q4_0 | 4.4 | **5.2** | 2.1 (contention) | -| Qwen3-4B Q4_0 | 13 | **17** | 14 | +# Kotlin +/opt/Kazeia-engine/dist/jni/LlmLoader.kt # chargeur + interface + 2 moteurs +/opt/Kazeia-engine/dist/jni/EngineLlmEngine.kt # moteur GGUF (inchangé) -`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 - -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. +# GGUF hybride (existant) +Qwen3.5-4B-Q4_0.gguf + dist/lib-cpu/libggml-cpu.so (i8mm) +``` --- -## 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//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… -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. diff --git a/dist/jni/LlmLoader.kt b/dist/jni/LlmLoader.kt new file mode 100644 index 0000000..d79ade4 --- /dev/null +++ b/dist/jni/LlmLoader.kt @@ -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() } +}