Kazeia-engine/dist/jni/LlmLoader.kt

107 lines
5.3 KiB
Kotlin

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() }
}