refonte(llm): NPU .pte in-app sans root (ADSP_LIBRARY_PATH) + normalisation think-block

Validation device (OPD2415 rooté, mais .pte sans root via PD signé) :
- GGUF défaut Qwen3.5-4B : ~18 tok/s, FR cohérent (chemin session cache-préfixe)
- qwen3-4b .pte NPU : 18.6 tok/s | guard4b .pte NPU : 21 tok/s — FR cohérent
- DJL tokenizer arm64 (tokenizer-native:0.33.0) OK on-device

Fixes :
- KazeiaApplication : pose ADSP_LIBRARY_PATH (dossier natif extrait + chemins DSP)
  AVANT toute session QNN. Sans ça le CDSP ne trouve pas libQnnHtpV79Skel.so
  (errno 2) → device_handle 14001 → .pte renvoie handle 0. Absent de la spec
  engine (validée via qnn_llama_runner CLI, où le shell exporte ADSP_LIBRARY_PATH).
- UnifiedLlmAdapter : strip du bloc <think>…</think> en tête de sortie .pte
  (pas des special tokens => skipSpecialTokens ne les retire pas ; le GGUF le
  strippe nativement). Sinon le TTS le lit et la cascade parse faux.
- BenchLlmHarness : réécrit pour valider UnifiedLlmAdapter sur GGUF + chaque .pte.

Connu (hors périmètre intégration) :
- qwen3-8b .pte (export 05-23, sharding=2) : blob contexte incompatible runtime
  QAIRT 2.42 (magic 0x5678abcd attendu, 0x2000000 lu ; "Context group 1") →
  re-export 2.42 requis côté engine.
- qwen2.5-7b .pte absent du device (seul le GGUF est présent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-17 23:00:31 +02:00
parent 0a72654093
commit 0108f47c09
3 changed files with 78 additions and 26 deletions

View File

@ -27,6 +27,21 @@ class KazeiaApplication : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
// QNN HTP sur DSP (.pte LLM ExecuTorch + TTS) : le skel `libQnnHtpV79Skel.so`
// est relayé au CDSP par FastRPC, qui le cherche via ADSP_LIBRARY_PATH. Sans
// ça, le DSP ne trouve pas le skel (errno 2) → device_handle échoue (14001) →
// chargement .pte renvoie handle 0. On pointe sur le dossier natif extrait de
// l'APK (où vit le skel) + chemins DSP système. Doit être posé AVANT toute
// ouverture de session QNN (bench, KazeiaService, TTS). Cf. Qwen3TtsEngine.
try {
val nativeLibDir = applicationInfo.nativeLibraryDir
android.system.Os.setenv(
"ADSP_LIBRARY_PATH",
"$nativeLibDir;/vendor/dsp/cdsp;/vendor/lib/rfsa/adsp;/vendor/dsp",
true
)
} catch (_: Throwable) {}
// Note: Unity native lib preloading was removed because Unity 6 GameActivity
// requires its own initialization sequence. Loading libs out of order causes
// native crashes. Unity handles lib loading internally in onCreate().

View File

@ -1,11 +1,16 @@
// Harness LLM ADB-pilotable, mirror de BenchQnnHarness STT.
// Déclenchement : `touch <files>/bench_llm.trigger`, relance app.
// Charge EngineLlmEngine (lib CPU-only) + génère 3 prompts FR, log texte + tps + nThreads honoré.
// Valide le chemin refonte 2026-06-17 : UnifiedLlmAdapter (magic-byte) sur
// 1) le GGUF par défaut (Qwen3.5-4B, CPU i8mm + session cache-préfixe)
// 2) CHAQUE .pte présent sur disque (NPU V79 : libkazeia_pte + tokeniseur DJL arm64)
// Log : texte généré + prefill/decode + tok/s, pour chaque modèle.
package com.kazeia.llm
import android.content.Context
import android.util.Log
import com.kazeia.KazeiaApplication
import com.kazeia.config.ModelRegistry
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.runBlocking
object BenchLlmHarness {
private const val TAG = "BenchLlm"
@ -16,30 +21,52 @@ object BenchLlmHarness {
"Je n'arrive plus à parler à ma femme"
)
private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple."
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. /no_think"
fun runBench(ctx: Context) {
val gguf = "${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
Log.i(TAG, "=== load q35-lmq4 ===")
val t0 = System.currentTimeMillis()
val eng = try {
EngineLlmEngine(gguf, ctx = 2048, nThreads = 6)
} catch (e: Throwable) {
Log.e(TAG, "load FAIL: ${e.message}"); return
fun runBench(ctx: Context) = runBlocking {
Log.i(TAG, "########## BENCH LLM (UnifiedLlmAdapter, refonte 2026-06-17) ##########")
// Modèles à tester : GGUF par défaut + tous les .pte présents sur disque.
val models = ModelRegistry.availableOnDisk()
if (models.isEmpty()) { Log.e(TAG, "aucun modèle sur disque (ni GGUF ni .pte)"); return@runBlocking }
for (m in models) {
val path = if (m.backend == ModelRegistry.Backend.GGUF) m.ggufPath() else m.ptePath()
val tkPath = if (m.backend == ModelRegistry.Backend.GGUF) null else m.tokenizerPath()
Log.i(TAG, "===== ${m.id} (${m.backend}, seq=${m.maxSeqLen}) : $path =====")
val t0 = System.currentTimeMillis()
val eng = try {
UnifiedLlmAdapter(
modelPath = path,
systemPrompt = SYS,
ctx = m.maxSeqLen,
nThreads = 6,
tokenizerPath = tkPath,
onLog = { Log.i(TAG, it) }
).also { it.load(path, com.kazeia.core.LlmConfig()) }
} catch (e: Throwable) {
Log.e(TAG, " LOAD FAIL ${m.id}: ${e.message}", e); continue
}
Log.i(TAG, " load OK (${System.currentTimeMillis() - t0}ms)")
// warmup (1 tour court, hors mesure)
try { eng.generate("Bonjour", SamplingParams(maxNewTokens = 16)) { true } } catch (_: Throwable) {}
for ((i, p) in PROMPTS.withIndex()) {
val ts = System.currentTimeMillis()
val r = try {
eng.generate(p, SamplingParams(maxNewTokens = 96, temperature = 0.7f)) { true }
} catch (e: Throwable) {
Log.e(TAG, " [${i + 1}] gen FAIL: ${e.message}", e); continue
}
val ms = System.currentTimeMillis() - ts
Log.i(TAG, " [${i + 1}/${PROMPTS.size}] '$p' → ${ms}ms (${"%.1f".format(r.tokensPerSecond)} tok/s)")
Log.i(TAG, " « ${r.text} »")
}
eng.release()
Log.i(TAG, "===== fin ${m.id} =====")
}
Log.i(TAG, "load OK (${System.currentTimeMillis() - t0}ms)")
// warmup
try { eng.generate(SYS, "Bonjour", 16) } catch (_: Exception) {}
for ((i, p) in PROMPTS.withIndex()) {
val ts = System.currentTimeMillis()
val out = try { eng.generate(SYS, p, 96) } catch (e: Exception) { "[ERR ${e.message}]" }
val ms = System.currentTimeMillis() - ts
val nWords = out.trim().split(Regex("\\s+")).size
val tps = if (ms > 0) nWords * 1000f / ms else 0f
Log.i(TAG, "[${i+1}/${PROMPTS.size}] '${p}' → ${ms}ms (~${"%.2f".format(tps)} mots/s) :")
Log.i(TAG, " '${out.trim()}'")
}
eng.release()
Log.i(TAG, "=== Bench end ===")
Log.i(TAG, "########## BENCH LLM end ##########")
}
}

View File

@ -29,6 +29,11 @@ class UnifiedLlmAdapter(
private val onLog: ((String) -> Unit)? = null
) : com.kazeia.core.LlmEngine {
private companion object {
// Bloc de raisonnement Qwen3.5 en tête de réponse (thinking-off => vide). À strip côté .pte.
val THINK_BLOCK = Regex("""^\s*<think>.*?</think>\s*""", RegexOption.DOT_MATCHES_ALL)
}
private enum class Fmt { GGUF, PTE }
@Volatile private var fmt: Fmt? = null
private var gguf: EngineLlmEngine? = null
@ -105,7 +110,12 @@ class UnifiedLlmAdapter(
}
Fmt.PTE -> {
val e = pte ?: error("UnifiedLlmAdapter .pte non chargé")
out = e.generate(systemPromptOverride ?: systemPrompt, prompt, params.maxNewTokens)
val raw = e.generate(systemPromptOverride ?: systemPrompt, prompt, params.maxNewTokens)
// Le décode .pte conserve le bloc <think>…</think> (thinking-off => bloc vide) :
// ce ne sont PAS des special tokens Qwen3.5, donc skipSpecialTokens ne les retire pas.
// Le moteur GGUF, lui, le strippe nativement. On normalise ici pour que les 2 backends
// se comportent pareil (sinon le TTS lit « <think> </think> » et la cascade parse faux).
out = THINK_BLOCK.replace(raw, "").trim()
onToken?.invoke(out) // streaming .pte = un bloc (génération bloquante, doc §10)
stats = e.lastStats()
}