using System.Collections; using System.Collections.Generic; using System.Text; using Unity.InferenceEngine; using Unity.InferenceEngine.Tokenization; using Unity.InferenceEngine.Tokenization.Parsers.HuggingFace; using UnityEngine; namespace SentisSkills.Inference { /// /// On-device function-calling SLM = FunctionGemma-270m-it (Gemma3ForCausalLM) in Unity Sentis 2.6. /// Same architecture as gemma-3-270m: 18 layers, MQA (num_kv_heads=1), head_dim 256, vocab 262144, /// activation gelu_pytorch_tanh. Function-calling specialized (not a dialogue model) — natural fit /// for the smart-home command -> action mapping. /// /// Import: exported with attn_implementation="eager" (dynamo, opset 20). Sentis's ONNX importer maps /// Gelu(approximate='tanh') to its tanh kernel, so no graph surgery is needed. /// Manual KV cache (Sentis has none): prefill zero-length past, feed 1 token + grown KV each step. /// /// Base Gemma turn format: /// <bos><start_of_turn>user\n{user}<end_of_turn>\n<start_of_turn>model\n /// public sealed class FunctionGemmaGenerator : MonoBehaviour { [Header("Model")] // The model, tokenizer and developer block ship in the package's Models~ folder (see FunctionGemmaFiles). // GPUCompute is ~2x faster than CPU Burst here, with token-identical output. [SerializeField] BackendType backend = BackendType.GPUCompute; string m_DevBlock; // dev-block text loaded from fg_dev_block_home.txt [Header("Decode")] [SerializeField] int maxNewTokens = 32; [Tooltip("KV prefix cache: prefill the fixed devBlock ONCE in Warmup and reuse its KV every " + "command, feeding only the user-turn suffix. Off = full-prompt path.")] [SerializeField] bool usePrefixCache = true; [Tooltip("GPU-side ArgMax head: pick the next token on-GPU (a tiny Functional graph) instead of reading " + "the whole 262k-vocab logits tensor to the CPU every step. Off = CPU argmax.")] [SerializeField] bool useGpuArgmax = true; // Gemma3-270m architecture (from config.json) const int NumLayers = 18; const int NumKvHeads = 1; // MQA const int HeadDim = 256; const int Vocab = 262144; // gemma-3-270m vocab (huge → keep argmax + logits off the CPU) // special tokens const int BOS = 2; // const int EOS = 1; // const int END_OF_TURN = 106; // const int START_FN_RESPONSE = 50; // — model emits it right after a call = stop Worker m_Worker; Worker m_Argmax; // tiny GPU-side ArgMax head over the logits (built when useGpuArgmax) ITokenizer m_Tok; bool m_Ready; // KV prefix cache: the fixed devBlock's KV, prefilled once and reused per command. Tensor[] m_PrefixKey, m_PrefixVal; int m_PrefixLen; bool m_PrefixReady; // Lazy: load only on first use so an unused component costs ZERO at Play start. public bool EnsureReady() { if (m_Ready) return true; // model / tokenizer / dev block all load from the package files (copied out first on Android, see WarmupRoutine) var modelPath = FunctionGemmaFiles.PathOf(FunctionGemmaFiles.Model); if (modelPath == null || !System.IO.File.Exists(modelPath)) { Debug.LogError($"{nameof(FunctionGemmaGenerator)}: model not found at '{modelPath}'."); return false; } m_Worker = new Worker(ModelLoader.Load(modelPath), backend); string tokJson = ReadFile(FunctionGemmaFiles.Tokenizer); if (tokJson != null) m_Tok = HuggingFaceParser.GetDefault().Parse(tokJson); m_DevBlock = ReadFile(FunctionGemmaFiles.DevBlock); if (useGpuArgmax) m_Argmax = BuildArgmaxHead(backend); m_Ready = true; return true; } // Read one of the package's text files (null if absent). static string ReadFile(string name) { var p = FunctionGemmaFiles.PathOf(name); return p != null && System.IO.File.Exists(p) ? System.IO.File.ReadAllText(p) : null; } /// /// Warmup for coroutine callers. On Android it first copies the model, tokenizer and developer block /// out of the APK into persistentDataPath (once; later runs reuse the copy), then runs . /// public IEnumerator WarmupRoutine() { yield return FunctionGemmaFiles.CopyOutOfApk(); Warmup(); } // A tiny standalone graph: logits (1, seq, Vocab) -> ArgMax over the vocab dim -> ids (1, seq). // Running this on the SAME backend keeps the 262k-wide logits on the GPU; we then read back only // 'seq' ints instead of cloning the whole (1, seq, 262144) logits tensor to the CPU every token. static Worker BuildArgmaxHead(BackendType backend) { var g = new FunctionalGraph(); var logits = g.AddInput(new DynamicTensorShape(1, -1, Vocab)); var ids = Functional.ArgMax(logits, 2, false); // (1, seq), int return new Worker(g.Compile(ids), backend); } // Next-token pick. GPU path: run the ArgMax head on the logits view, read back just the ids row // and take the last. CPU fallback: full-logits readback + scan. int PickNext(Tensor logits) { if (m_Argmax == null) return ArgmaxLast(logits); m_Argmax.Schedule(logits); using var ids = (m_Argmax.PeekOutput() as Tensor).ReadbackAndClone(); int seq = ids.shape[1]; return ids[0, seq - 1]; } /// Compile the graph + allocate buffers up front so the first real call isn't cold. public void Warmup() { if (!EnsureReady()) return; // Prefill the devBlock prefix once (compiles the graph AND caches its KV). Fall back to a bare // 1-token pass (compile only) if the prefix can't be built (no tokenizer/devBlock, or toggled off). if (!usePrefixCache || !TryPrefillPrefix()) Drain(GenerateFromIdsRoutine(new[] { BOS }, 1, null, null, 0, new List())); } // Run a decode routine to completion without yielding (used by Warmup). static void Drain(IEnumerator routine) { while (routine.MoveNext()) { } } static string BuildPrompt(string user) { var sb = new StringBuilder(); sb.Append("user\n").Append(user).Append("\n"); sb.Append("model\n"); return sb.ToString(); // tokenizer adds when addSpecialTokens=true } /// /// Function-calling prompt = the exact developer block (system instruction + the smart-home /// tool declarations, from fg_dev_block.txt, byte-identical to the training render) + /// the user turn + the model generation prompt. Must match chat_template.jinja exactly or the /// fine-tuned model degrades. /// string BuildFcPrompt(string user) { var sb = new StringBuilder(); sb.Append(m_DevBlock); // developer\n...\n sb.Append("user\n").Append(user).Append("\n"); sb.Append("model\n"); return sb.ToString(); // tokenizer's TemplateProcessing prepends =2 } /// /// Non-blocking command → generation: decodes ONE token per frame (yields between steps) so /// movement/animation keep ticking while the SLM thinks — the agent never freezes mid-command. /// Delivers the RAW generated text (special tokens kept so call:/<escape> survive) via onDone; /// parsing/validation is the caller's job (FgCallParser handles multi-call outputs and escape spans). /// Prefix KV is reused (Warmup prefills it, so no per-command prefill hitch). Drive with StartCoroutine. /// public IEnumerator GenerateCallRoutine(string user, System.Action onDone, int maxNew = 0) { if (!EnsureReady() || m_Tok == null || string.IsNullOrEmpty(m_DevBlock)) { onDone?.Invoke(""); yield break; } int budget = maxNew > 0 ? maxNew : maxNewTokens; var generated = new List(); if (usePrefixCache && TryPrefillPrefix()) { // Prefix-cache path: only the user turn is tokenized/fed; the devBlock KV is reused. // Tokenizer boundary: Encode(dev) + Encode(userTurn, BOS stripped) == Encode(dev+userTurn). var suf = new List(m_Tok.Encode(BuildPrompt(user)).GetIds()); if (suf.Count > 0 && suf[0] == BOS) suf.RemoveAt(0); // prefix already carries yield return GenerateFromIdsRoutine(suf.ToArray(), budget, m_PrefixKey, m_PrefixVal, m_PrefixLen, generated); } else { var ids = new List(m_Tok.Encode(BuildFcPrompt(user)).GetIds()); if (ids.Count == 0 || ids[0] != BOS) ids.Insert(0, BOS); // ensure regardless of tokenizer post-proc yield return GenerateFromIdsRoutine(ids.ToArray(), budget, null, null, 0, generated); } onDone?.Invoke(m_Tok.Decode(generated)); } /// /// Greedy decode, one token per frame: yields between steps so the game keeps ticking while the model thinks. /// Writes generated ids into 'generated' (stops on EOS / end_of_turn / start_function_response; not included). /// When pk/pv are supplied (KV prefix cache), decoding starts from that cached KV and only promptIds is fed. /// pk/pv are never mutated or disposed here. /// public IEnumerator GenerateFromIdsRoutine(int[] promptIds, int maxNew, Tensor[] pk, Tensor[] pv, int prefixLen, List generated) { if (!EnsureReady()) yield break; // on-backend KV: CopyOutput present→fresh tensor each step, // no per-step ReadbackAndClone. We own every buffer except the caller's prefix pk/pv. var curK = new Tensor[NumLayers]; var curV = new Tensor[NumLayers]; bool ownCur; if (pk != null) { for (int l = 0; l < NumLayers; l++) { curK[l] = pk[l]; curV[l] = pv[l]; } ownCur = false; } else { for (int l = 0; l < NumLayers; l++) { curK[l] = new Tensor(new TensorShape(1, NumKvHeads, 0, HeadDim)); curV[l] = new Tensor(new TensorShape(1, NumKvHeads, 0, HeadDim)); } ownCur = true; } int total = prefixLen + promptIds.Length; var step = promptIds; int pastLen = prefixLen; bool stop = false; for (int t = 0; t < maxNew && !stop; t++) { int qLen = step.Length; using (var idT = new Tensor(new TensorShape(1, qLen), step)) using (var mask = new Tensor(new TensorShape(1, 1, qLen, total), CausalMask(qLen, total, pastLen))) using (var maskS = new Tensor(new TensorShape(1, 1, qLen, total), SlidingMask(qLen, total, pastLen))) { m_Worker.SetInput("input_ids", idT); m_Worker.SetInput("mask_full", mask); m_Worker.SetInput("mask_sliding", maskS); for (int l = 0; l < NumLayers; l++) { m_Worker.SetInput($"past_key_values.{l}.key", curK[l]); m_Worker.SetInput($"past_key_values.{l}.value", curV[l]); } m_Worker.Schedule(); int next = PickNext(m_Worker.PeekOutput("logits") as Tensor); var newK = new Tensor[NumLayers]; var newV = new Tensor[NumLayers]; for (int l = 0; l < NumLayers; l++) { Tensor dk = null, dv = null; m_Worker.CopyOutput($"present.{l}.key", ref dk); m_Worker.CopyOutput($"present.{l}.value", ref dv); newK[l] = dk; newV[l] = dv; } if (ownCur) for (int l = 0; l < NumLayers; l++) { curK[l].Dispose(); curV[l].Dispose(); } curK = newK; curV = newV; ownCur = true; if (next == EOS || next == END_OF_TURN || next == START_FN_RESPONSE) stop = true; else { generated.Add(next); step = new[] { next }; pastLen = total; total += 1; } } yield return null; // one decode step per frame: the game keeps ticking while the SLM thinks } if (ownCur) for (int l = 0; l < NumLayers; l++) { curK[l].Dispose(); curV[l].Dispose(); } } const float MaskNeg = -1e9f; // additive mask: 0 = attend, large-negative = masked const int SlidingWindow = 512; // Gemma3 sliding_attention window (config.sliding_window) // (1,1,qLen,kvLen) row-major: query q (absolute pos pastLen+q) attends key k iff k <= pastLen+q. static float[] CausalMask(int qLen, int kvLen, int pastLen) { var a = new float[qLen * kvLen]; for (int q = 0; q < qLen; q++) for (int k = 0; k < kvLen; k++) a[q * kvLen + k] = (k > pastLen + q) ? MaskNeg : 0f; return a; } // Sliding-attention layers additionally forbid keys older than SlidingWindow: // attend iff pastLen+q-SlidingWindow < k <= pastLen+q. Feeding the full-causal // mask here silently works only while the whole sequence fits in the window; // the tool-declaration dev block is longer than that window, and generation collapses without this mask. static float[] SlidingMask(int qLen, int kvLen, int pastLen) { var a = new float[qLen * kvLen]; for (int q = 0; q < qLen; q++) { int p = pastLen + q; for (int k = 0; k < kvLen; k++) a[q * kvLen + k] = (k > p || k <= p - SlidingWindow) ? MaskNeg : 0f; } return a; } static int ArgmaxLast(Tensor logits) { using var cpu = logits.ReadbackAndClone(); var shape = cpu.shape; // (1, seq, vocab) int seq = shape[1], vocab = shape[2]; int best = 0; float bestVal = float.NegativeInfinity; for (int v = 0; v < vocab; v++) { float val = cpu[0, seq - 1, v]; if (val > bestVal) { bestVal = val; best = v; } } return best; } /// /// Prefill the fixed devBlock once and cache its KV. Idempotent; returns false if the /// tokenizer/devBlock aren't available so callers can fall back to the full-prompt path. /// bool TryPrefillPrefix() { if (m_PrefixReady) return true; if (!EnsureReady() || m_Tok == null || string.IsNullOrEmpty(m_DevBlock)) return false; var ids = new List(m_Tok.Encode(m_DevBlock).GetIds()); if (ids.Count == 0 || ids[0] != BOS) ids.Insert(0, BOS); int prefixLen = ids.Count; var key = new Tensor[NumLayers]; var val = new Tensor[NumLayers]; for (int l = 0; l < NumLayers; l++) { key[l] = new Tensor(new TensorShape(1, NumKvHeads, 0, HeadDim)); val[l] = new Tensor(new TensorShape(1, NumKvHeads, 0, HeadDim)); } using (var idT = new Tensor(new TensorShape(1, prefixLen), ids.ToArray())) using (var mask = new Tensor(new TensorShape(1, 1, prefixLen, prefixLen), CausalMask(prefixLen, prefixLen, 0))) using (var maskS = new Tensor(new TensorShape(1, 1, prefixLen, prefixLen), SlidingMask(prefixLen, prefixLen, 0))) { m_Worker.SetInput("input_ids", idT); m_Worker.SetInput("mask_full", mask); m_Worker.SetInput("mask_sliding", maskS); for (int l = 0; l < NumLayers; l++) { m_Worker.SetInput($"past_key_values.{l}.key", key[l]); m_Worker.SetInput($"past_key_values.{l}.value", val[l]); } m_Worker.Schedule(); // Keep the cached prefix KV resident on the inference device (CopyOutput clones // on-backend). ReadbackAndClone here would park ~35MB of KV on the CPU and // re-upload all of it on the FIRST step of EVERY command on GPU backends. m_PrefixKey = new Tensor[NumLayers]; m_PrefixVal = new Tensor[NumLayers]; for (int l = 0; l < NumLayers; l++) { Tensor k = null, v = null; m_Worker.CopyOutput($"present.{l}.key", ref k); m_Worker.CopyOutput($"present.{l}.value", ref v); m_PrefixKey[l] = k as Tensor; m_PrefixVal[l] = v as Tensor; } } for (int l = 0; l < NumLayers; l++) { key[l].Dispose(); val[l].Dispose(); } m_PrefixLen = prefixLen; m_PrefixReady = true; return true; } void OnDestroy() { m_Worker?.Dispose(); m_Argmax?.Dispose(); if (m_PrefixKey != null) foreach (var t in m_PrefixKey) t?.Dispose(); if (m_PrefixVal != null) foreach (var t in m_PrefixVal) t?.Dispose(); } } }