From 771d64a998bfd640982c504701d2143c198f9032 Mon Sep 17 00:00:00 2001 From: Richard Loyer Date: Wed, 27 May 2026 11:18:20 +0200 Subject: [PATCH] =?UTF-8?q?dist:=20multi-tour=20propre=20(generateRaw=20+?= =?UTF-8?q?=20ChatSession)=20+=20JNI=20valid=C3=A9=20end-to-end=20natif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ferme les 2 derniers trous d'intégration: - generateRaw(prompt) exposé (cœur option C partagé avec generate). EngineLlmEngine.kt: ChatSession gère l'historique + construit le ChatML Qwen3.5 validé -> multi-tour propre sans que l'app connaisse le template (structure identique à dual_ctx_mt). - test_jni_native.cpp: dlopen + JNIEnv mock teste le .so shippé bout-en-bout sur device (load/generate/generateRaw/free). Résultat: FR cohérent + mémoire conversationnelle OK ("Marc, je me souviens parfaitement de ton prénom"). Marshalling JNI réel validé. libkazeia_engine.so reconstruit (5 symboles). Paquet dist/ prêt à intégrer. Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/HANDOFF.md | 21 ++++--- dist/jni/EngineLlmEngine.kt | 48 +++++++++++++--- dist/jni/kazeia_engine_jni.cpp | 97 +++++++++++++++++++-------------- dist/jni/test_jni_native.cpp | 58 ++++++++++++++++++++ dist/lib/libkazeia_engine.so | Bin 37768 -> 38760 bytes 5 files changed, 166 insertions(+), 58 deletions(-) create mode 100644 dist/jni/test_jni_native.cpp diff --git a/dist/HANDOFF.md b/dist/HANDOFF.md index 9ca9a02..276cd03 100644 --- a/dist/HANDOFF.md +++ b/dist/HANDOFF.md @@ -12,15 +12,18 @@ GGUF, pas de `.pte`. STT reste ORT-QAIRT (inchangé). - **Kernel GDN = chantier clos** : son calcul n'est PAS le goulot (cf RAPPORT_RD §7). Ne pas y retoucher pour la perf. Decode au plafond BW CPU (~25 GB/s). -## État du bridge JNI (`jni/kazeia_engine_jni.cpp`) — OPTION C câblée (27/05) -Le JNI implémente **C** (prefill HTP / decode CPU), validé multi-tour (`jni/dual_ctx_mt.cpp` : -3 tours cohérents + mémoire conversationnelle OK, prefill HTP 103-144 t/s, decode CPU ~7-9). -- `load()` : pose `GGML_HEXAGON_GDN_PREFILL=1` + `GGML_HEXAGON_OPFILTER=SSM_CONV` (avant init backend), - charge le modèle **2×** : instance HTP (ngl99, device HTP0, t8) + instance CPU (ngl0, t4). ~4.7 GB. -- `generate()` : prefill du prompt sur HTP → `llama_state_seq_get/set_data` transfère le KV → decode sur CPU. - Sans état entre appels (l'app passe l'historique complet dans le prompt → re-prefill HTP, rapide). -- KV **f16**, flash_attn ON, thinking OFF (`` vide), greedy. API inchangée : `load/generate/reset/free`. -- `lib/libkazeia_engine.so` **à jour (option C, 37 KB, 4 symboles JNI, variante SHARED)** + `libggml-hexagon.so` +## État du bridge JNI (`jni/kazeia_engine_jni.cpp`) — OPTION C, validé end-to-end (27/05) +Le JNI implémente **C** (prefill HTP / decode CPU). **Validé bout-en-bout sur device** via `jni/test_jni_native.cpp` +(dlopen + JNIEnv mock → `load`/`generate`/`generateRaw`/`free` : sortie FR cohérente, mémoire conversationnelle OK) +et `jni/dual_ctx_mt.cpp` (3 tours, prefill HTP 103-144 t/s, decode CPU ~7-9). +- `load()` : pose `GGML_HEXAGON_GDN_PREFILL=1` (le fallback CPU du conv1d multi-token est dans le backend, pas + d'OPFILTER), charge le modèle **2×** : instance HTP (ngl99, device HTP0, t8) + instance CPU (ngl0, t4). ~4.7 GB. +- **API** : `generate(sys,usr,max)` (mono-tour) **OU `generateRaw(prompt,max)`** (multi-tour, prompt pré-formaté). + `EngineLlmEngine.kt` fournit **`ChatSession`** (accumule l'historique + construit le ChatML validé → l'app n'a + pas à connaître le template). Cœur : prefill HTP → `llama_state_seq_get/set_data` (transfert KV) → decode CPU. + Sans état entre appels (re-prefill de l'historique complet sur HTP, rapide). +- KV **f16**, flash_attn ON, thinking OFF (`` vide), greedy. API : `load/generate/generateRaw/reset/free`. +- `lib/libkazeia_engine.so` **à jour (option C, 5 symboles JNI, SHARED)** + `libggml-hexagon.so` (fix SSM_CONV) + `libggml-htp-v79.so` à jour. Prebuilts utilisables tels quels (NDK r27d) ; **rebuild depuis `jni/` dans ton build app recommandé** pour garantir l'ABI/STL (CMakeLists.txt fourni, linke llama+ggml+ggml-base+log). diff --git a/dist/jni/EngineLlmEngine.kt b/dist/jni/EngineLlmEngine.kt index 48b35d8..88b4a07 100644 --- a/dist/jni/EngineLlmEngine.kt +++ b/dist/jni/EngineLlmEngine.kt @@ -1,14 +1,48 @@ package com.kazeia.llm -// Remplace ExecuTorchLlmEngine. Mono-moteur Speaker+Thinker (budget thinking off). +// Remplace ExecuTorchLlmEngine. Option C: prefill HTP / decode CPU (géré côté natif). +// Mono-tour: generate(sys, usr). Multi-tour: ChatSession (gère l'historique + le template ChatML). + class EngineJni { external fun load(ggufPath: String, nCtx: Int): Long external fun generate(h: Long, sys: String, usr: String, maxTok: Int): String - external fun reset(h: Long); external fun free(h: Long) + external fun generateRaw(h: Long, prompt: String, maxTok: Int): String // prompt complet déjà formaté + external fun reset(h: Long) + external fun free(h: Long) companion object { init { System.loadLibrary("kazeia_engine") } } } -class EngineLlmEngine(model: String, ctx: Int = 2048) { - private val jni = EngineJni(); private val h = jni.load(model, ctx) - // Thinker: bullets; Speaker: SYS_KAZEIA + bullets. reset() entre tours, ou cache persistant. - fun generate(sys: String, usr: String, max: Int = 64) = jni.generate(h, sys, usr, max) - fun reset() = jni.reset(h); fun release() = jni.free(h) + +class EngineLlmEngine(model: String, ctx: Int = 4096) { + private val jni = EngineJni() + private val h = jni.load(model, ctx) + init { require(h != 0L) { "Kazeia-Engine: échec du chargement du modèle ($model)" } } + + // Mono-tour : system + un message user. + fun generate(sys: String, usr: String, max: Int = 96) = jni.generate(h, sys, usr, max) + // Multi-tour : prompt complet pré-formaté (voir ChatSession). + fun generateRaw(prompt: String, max: Int = 96) = jni.generateRaw(h, prompt, max) + + fun newChat(system: String) = ChatSession(this, system) + fun reset() = jni.reset(h) + fun release() = jni.free(h) +} + +// Conversation multi-tour : accumule l'historique et construit le ChatML Qwen3.5 + thinking-off. +// Structure identique à celle validée (jni/dual_ctx_mt.cpp) : mémoire conversationnelle correcte. +class ChatSession(private val engine: EngineLlmEngine, private val system: String) { + private val turns = StringBuilder() // "<|im_start|>role\ntext<|im_end|>\n" accumulés + + fun ask(user: String, max: Int = 96): String { + val prompt = buildString { + append("<|im_start|>system\n").append(system).append("<|im_end|>\n") + append(turns) + append("<|im_start|>user\n").append(user).append("<|im_end|>\n") + append("<|im_start|>assistant\n\n\n\n\n") // thinking OFF déterministe + } + val resp = engine.generateRaw(prompt, max) + turns.append("<|im_start|>user\n").append(user).append("<|im_end|>\n") + .append("<|im_start|>assistant\n").append(resp).append("<|im_end|>\n") + return resp + } + + fun clear() { turns.setLength(0) } } diff --git a/dist/jni/kazeia_engine_jni.cpp b/dist/jni/kazeia_engine_jni.cpp index 6255d93..469311f 100644 --- a/dist/jni/kazeia_engine_jni.cpp +++ b/dist/jni/kazeia_engine_jni.cpp @@ -1,8 +1,8 @@ // kazeia_engine_jni.cpp — bridge LLM Kazeia-Engine (llama.cpp fork ql + Hexagon). -// OPTION C: prefill NPU/HTP (ngl99, SSM_CONV forcé CPU via OPFILTER) -> transfert KV -> decode CPU (ngl0, t4+fa). -// prefill ~100-180 t/s (vs ~14 CPU), decode CPU ~7-10 (KV f16). 2 instances du modèle (~4.7GB). -// SSM_CONV HTP cassé en prefill -> OPFILTER=SSM_CONV (le route sur ggml-cpu). cf HANDOFF.md / PERF.md. -// generate() est sans état entre appels (l'app passe l'historique complet dans le prompt). STT reste ORT-QAIRT. +// OPTION C: prefill NPU/HTP (ngl99, SSM_CONV multi-token routé CPU par le backend) -> transfert KV -> +// decode CPU (ngl0, t4+fa, KV f16). prefill ~110-180 t/s (vs ~14 CPU), decode CPU ~7-10. 2 instances (~4.7GB). +// API: load -> generate(sys,usr) [mono-tour] OU generateRaw(prompt) [multi-tour, l'app/Kotlin formate] -> reset/free. +// Sans état entre appels (l'app passe l'historique complet dans le prompt -> re-prefill HTP). STT reste ORT-QAIRT. #include #include #include @@ -25,43 +25,8 @@ static llama_context* make_ctx(llama_model* m, int nctx, int nthreads) { return llama_init_from_model(m, cp); } -extern "C" JNIEXPORT jlong JNICALL -Java_com_kazeia_llm_EngineJni_load(JNIEnv* e, jobject, jstring path, jint nctx) { - const char* p = e->GetStringUTFChars(path, 0); - // Posé AVANT l'init du backend hexagon (lu au registre). GDN prefill sur HTP. - // (Plus besoin de GGML_HEXAGON_OPFILTER=SSM_CONV : le fallback CPU multi-token du conv1d - // est désormais intégré dans le backend, ggml_hexagon_supported_ssm_conv n_t>1.) - setenv("GGML_HEXAGON_GDN_PREFILL", "1", 1); - llama_backend_init(); - - // device HTP0 explicite pour l'instance prefill - static ggml_backend_dev_t devs[2] = { nullptr, nullptr }; - for (size_t i = 0; i < ggml_backend_dev_count(); ++i) { - auto d = ggml_backend_dev_get(i); - if (!strcmp(ggml_backend_dev_name(d), "HTP0")) devs[0] = d; - } - auto mp_h = llama_model_default_params(); mp_h.n_gpu_layers = 99; if (devs[0]) mp_h.devices = devs; - auto m_h = llama_model_load_from_file(p, mp_h); - auto mp_c = llama_model_default_params(); mp_c.n_gpu_layers = 0; - auto m_c = llama_model_load_from_file(p, mp_c); - e->ReleaseStringUTFChars(path, p); - if (!m_h || !m_c) return 0; - - auto* k = new KEngine{ m_h, make_ctx(m_h, nctx, 8), // prefill t8 - m_c, make_ctx(m_c, nctx, 4), // decode t4 - llama_model_get_vocab(m_h), llama_sampler_init_greedy() }; - return (jlong) k; -} - -extern "C" JNIEXPORT jstring JNICALL -Java_com_kazeia_llm_EngineJni_generate(JNIEnv* e, jobject, jlong h, jstring sys, jstring usr, jint maxTok) { - auto* k = (KEngine*) h; - const char* sp = e->GetStringUTFChars(sys, 0); const char* up = e->GetStringUTFChars(usr, 0); - // ChatML Qwen3.5 + vide = thinking OFF déterministe - std::string p = "<|im_start|>system\n"; p += sp; p += "<|im_end|>\n<|im_start|>user\n"; p += up; - p += "<|im_end|>\n<|im_start|>assistant\n\n\n\n\n"; - e->ReleaseStringUTFChars(sys, sp); e->ReleaseStringUTFChars(usr, up); - +// Cœur option C : prefill du prompt sur HTP -> transfert KV -> decode sur CPU. Renvoie le texte généré. +static std::string kengine_run(KEngine* k, const std::string& p, int maxTok) { int n = -llama_tokenize(k->v, p.c_str(), p.size(), nullptr, 0, true, true); std::vector t(n); llama_tokenize(k->v, p.c_str(), p.size(), t.data(), n, true, true); @@ -69,7 +34,7 @@ Java_com_kazeia_llm_EngineJni_generate(JNIEnv* e, jobject, jlong h, jstring sys, // PREFILL sur HTP (KV vidé -> historique complet re-prefillé à chaque appel) llama_memory_clear(llama_get_memory(k->c_h), true); llama_batch b = llama_batch_get_one(t.data(), n); - if (llama_decode(k->c_h, b) != 0) return e->NewStringUTF(""); + if (llama_decode(k->c_h, b) != 0) return std::string(); // transfert état KV HTP -> CPU size_t sz = llama_state_seq_get_size(k->c_h, 0); @@ -91,6 +56,54 @@ Java_com_kazeia_llm_EngineJni_generate(JNIEnv* e, jobject, jlong h, jstring sys, if (llama_decode(k->c_c, sb) != 0) break; pos++; id = llama_sampler_sample(k->s, k->c_c, -1); } + return out; +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_kazeia_llm_EngineJni_load(JNIEnv* e, jobject, jstring path, jint nctx) { + const char* p = e->GetStringUTFChars(path, 0); + // Posé AVANT l'init du backend hexagon. GDN prefill sur HTP. Le fallback CPU multi-token du + // conv1d (SSM_CONV) est intégré au backend (ggml_hexagon_supported_ssm_conv), pas d'OPFILTER. + setenv("GGML_HEXAGON_GDN_PREFILL", "1", 1); + llama_backend_init(); + + static ggml_backend_dev_t devs[2] = { nullptr, nullptr }; + for (size_t i = 0; i < ggml_backend_dev_count(); ++i) { + auto d = ggml_backend_dev_get(i); + if (!strcmp(ggml_backend_dev_name(d), "HTP0")) devs[0] = d; + } + auto mp_h = llama_model_default_params(); mp_h.n_gpu_layers = 99; if (devs[0]) mp_h.devices = devs; + auto m_h = llama_model_load_from_file(p, mp_h); + auto mp_c = llama_model_default_params(); mp_c.n_gpu_layers = 0; + auto m_c = llama_model_load_from_file(p, mp_c); + e->ReleaseStringUTFChars(path, p); + if (!m_h || !m_c) return 0; + + auto* k = new KEngine{ m_h, make_ctx(m_h, nctx, 8), // prefill t8 + m_c, make_ctx(m_c, nctx, 4), // decode t4 + llama_model_get_vocab(m_h), llama_sampler_init_greedy() }; + return (jlong) k; +} + +// Mono-tour : construit le ChatML (system + 1 tour user) + thinking-off, puis infère. +extern "C" JNIEXPORT jstring JNICALL +Java_com_kazeia_llm_EngineJni_generate(JNIEnv* e, jobject, jlong h, jstring sys, jstring usr, jint maxTok) { + auto* k = (KEngine*) h; + const char* sp = e->GetStringUTFChars(sys, 0); const char* up = e->GetStringUTFChars(usr, 0); + std::string p = "<|im_start|>system\n"; p += sp; p += "<|im_end|>\n<|im_start|>user\n"; p += up; + p += "<|im_end|>\n<|im_start|>assistant\n\n\n\n\n"; + e->ReleaseStringUTFChars(sys, sp); e->ReleaseStringUTFChars(usr, up); + std::string out = kengine_run(k, p, maxTok); + return e->NewStringUTF(out.c_str()); +} + +// Multi-tour : l'app/Kotlin fournit le prompt complet déjà formaté (ChatML + historique + thinking-off). +extern "C" JNIEXPORT jstring JNICALL +Java_com_kazeia_llm_EngineJni_generateRaw(JNIEnv* e, jobject, jlong h, jstring prompt, jint maxTok) { + auto* k = (KEngine*) h; + const char* pp = e->GetStringUTFChars(prompt, 0); + std::string out = kengine_run(k, std::string(pp), maxTok); + e->ReleaseStringUTFChars(prompt, pp); return e->NewStringUTF(out.c_str()); } diff --git a/dist/jni/test_jni_native.cpp b/dist/jni/test_jni_native.cpp new file mode 100644 index 0000000..d35cba8 --- /dev/null +++ b/dist/jni/test_jni_native.cpp @@ -0,0 +1,58 @@ +// test_jni_native.cpp — teste libkazeia_engine.so bout-en-bout SANS JVM: +// dlopen + JNIEnv mock (GetStringUTFChars/Release/NewStringUTF) -> load / generateRaw multi-tour / free. +// Valide le marshalling JNI réel + le flux option C sur device. +// Build: NDK clang++ -Iinclude jni/test_jni_native.cpp -ldl -o test_jni_native +// Run: GGML_HEXAGON_GDN_PREFILL=1 LD_LIBRARY_PATH=lib ADSP_LIBRARY_PATH=lib ./test_jni_native lib/libkazeia_engine.so model.gguf +#include +#include +#include +#include +#include +#include + +static const char* GetStringUTFChars_impl(JNIEnv*, jstring s, jboolean* c){ if(c)*c=0; return (const char*)s; } +static void ReleaseStringUTFChars_impl(JNIEnv*, jstring, const char*){} +static jstring NewStringUTF_impl(JNIEnv*, const char* u){ return (jstring) strdup(u?u:""); } + +int main(int argc, char** argv){ + if(argc<3){ printf("usage: test_jni_native lib.so model.gguf\n"); return 1; } + void* lib = dlopen(argv[1], RTLD_NOW|RTLD_GLOBAL); + if(!lib){ printf("dlopen fail: %s\n", dlerror()); return 1; } + + auto fload = (jlong(*)(JNIEnv*,jobject,jstring,jint)) dlsym(lib, "Java_com_kazeia_llm_EngineJni_load"); + auto fraw = (jstring(*)(JNIEnv*,jobject,jlong,jstring,jint)) dlsym(lib, "Java_com_kazeia_llm_EngineJni_generateRaw"); + auto fgen = (jstring(*)(JNIEnv*,jobject,jlong,jstring,jstring,jint)) dlsym(lib, "Java_com_kazeia_llm_EngineJni_generate"); + auto ffree = (void(*)(JNIEnv*,jobject,jlong)) dlsym(lib, "Java_com_kazeia_llm_EngineJni_free"); + if(!fload||!fraw||!fgen||!ffree){ printf("dlsym fail (%p %p %p %p)\n",(void*)fload,(void*)fraw,(void*)fgen,(void*)ffree); return 1; } + + JNINativeInterface iface; memset(&iface,0,sizeof iface); + iface.GetStringUTFChars = GetStringUTFChars_impl; + iface.ReleaseStringUTFChars = ReleaseStringUTFChars_impl; + iface.NewStringUTF = NewStringUTF_impl; + const JNINativeInterface* pf = &iface; + JNIEnv* env = (JNIEnv*) &pf; + + jlong h = fload(env, nullptr, (jstring) argv[2], 4096); + printf("load -> h=%p\n", (void*)h); + if(!h){ printf("load fail\n"); return 1; } + + // mono-tour + const char* sys = "Tu es Kazeia, soutien psy bref en francais, tutoiement."; + const char* usr = "Je n'arrive plus a dormir, je rumine la nuit."; + jstring r1 = fgen(env, nullptr, h, (jstring) sys, (jstring) usr, 60); + printf("\n[generate mono-tour]\n%s\n", (const char*) r1); + + // multi-tour: prompt ChatML complet (system + 1 echange + nouvelle question memoire) + std::string p = + "<|im_start|>system\nTu es Kazeia, soutien psy, francais, tutoiement.<|im_end|>\n" + "<|im_start|>user\nJe m'appelle Marc et je dors mal.<|im_end|>\n" + "<|im_start|>assistant\nMarc, je comprends, le manque de sommeil est epuisant.<|im_end|>\n" + "<|im_start|>user\nRappelle-moi mon prenom ?<|im_end|>\n" + "<|im_start|>assistant\n\n\n\n\n"; + jstring r2 = fraw(env, nullptr, h, (jstring) p.c_str(), 40); + printf("\n[generateRaw multi-tour, test memoire]\n%s\n", (const char*) r2); + + ffree(env, nullptr, h); + printf("\nfree OK\n"); + return 0; +} diff --git a/dist/lib/libkazeia_engine.so b/dist/lib/libkazeia_engine.so index 863c70f9ae57754add4f909c2448fa023fb1eb2c..3fa446feddf3bc544a26d9625d06d70db0f63ca9 100755 GIT binary patch delta 12978 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 TPV=v_tJ-VyR80i6?vwuqA`fKe delta 12043 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 Mq4tJYnoJG<07;;C(f|Me