File size: 18,817 Bytes
9726de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660adc3
9726de3
 
 
660adc3
9726de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660adc3
 
 
9726de3
259c28c
9726de3
 
259c28c
9726de3
660adc3
9726de3
 
660adc3
9726de3
 
 
 
 
 
660adc3
 
9726de3
660adc3
 
9726de3
 
259c28c
660adc3
 
259c28c
 
 
660adc3
259c28c
 
 
9726de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1e0916
9726de3
 
b1e0916
9726de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1e0916
 
9726de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1e0916
 
9726de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
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
{
    /// <summary>
    /// 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:
    ///   &lt;bos&gt;&lt;start_of_turn&gt;user\n{user}&lt;end_of_turn&gt;\n&lt;start_of_turn&gt;model\n
    /// </summary>
    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;             // <bos>
        const int EOS = 1;             // <eos>
        const int END_OF_TURN = 106;   // <end_of_turn>
        const int START_FN_RESPONSE = 50; // <start_function_response> — 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<float>[] 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;
        }

        /// <summary>
        /// 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 <see cref="Warmup"/>.
        /// </summary>
        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<float>(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<float> logits)
        {
            if (m_Argmax == null) return ArgmaxLast(logits);
            m_Argmax.Schedule(logits);
            using var ids = (m_Argmax.PeekOutput() as Tensor<int>).ReadbackAndClone();
            int seq = ids.shape[1];
            return ids[0, seq - 1];
        }

        /// <summary>Compile the graph + allocate buffers up front so the first real call isn't cold.</summary>
        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<int>()));
        }

        // 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("<start_of_turn>user\n").Append(user).Append("<end_of_turn>\n");
            sb.Append("<start_of_turn>model\n");
            return sb.ToString();   // tokenizer adds <bos> when addSpecialTokens=true
        }

        /// <summary>
        /// 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.
        /// </summary>
        string BuildFcPrompt(string user)
        {
            var sb = new StringBuilder();
            sb.Append(m_DevBlock);               // <bos><start_of_turn>developer\n...<end_of_turn>\n
            sb.Append("<start_of_turn>user\n").Append(user).Append("<end_of_turn>\n");
            sb.Append("<start_of_turn>model\n");
            return sb.ToString();                    // already starts with <bos>: encode without special tokens
        }

        /// <summary>
        /// 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:/&lt;escape&gt; 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.
        /// </summary>
        public IEnumerator GenerateCallRoutine(string user, System.Action<string> 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<int>();
            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<int>(m_Tok.Encode(BuildPrompt(user)).GetIds());
                if (suf.Count > 0 && suf[0] == BOS) suf.RemoveAt(0);   // prefix already carries <bos>
                yield return GenerateFromIdsRoutine(suf.ToArray(), budget, m_PrefixKey, m_PrefixVal, m_PrefixLen, generated);
            }
            else
            {
                // The dev block carries a literal <bos>; the tokenizer's TemplateProcessing would add a second one.
                var ids = new List<int>(m_Tok.Encode(BuildFcPrompt(user), null, false).GetIds());
                if (ids.Count == 0 || ids[0] != BOS) ids.Insert(0, BOS);   // ensure <bos> regardless of tokenizer post-proc
                yield return GenerateFromIdsRoutine(ids.ToArray(), budget, null, null, 0, generated);
            }
            onDone?.Invoke(m_Tok.Decode(generated));
        }

        /// <summary>
        /// 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.
        /// </summary>
        public IEnumerator GenerateFromIdsRoutine(int[] promptIds, int maxNew, Tensor<float>[] pk, Tensor<float>[] pv, int prefixLen, List<int> 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<float>(new TensorShape(1, NumKvHeads, 0, HeadDim));
                    curV[l] = new Tensor<float>(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<int>(new TensorShape(1, qLen), step))
                using (var mask = new Tensor<float>(new TensorShape(1, 1, qLen, total), CausalMask(qLen, total, pastLen)))
                using (var maskS = new Tensor<float>(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<float>);

                    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<float> 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;
        }

        /// <summary>
        /// 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.
        /// </summary>
        bool TryPrefillPrefix()
        {
            if (m_PrefixReady) return true;
            if (!EnsureReady() || m_Tok == null || string.IsNullOrEmpty(m_DevBlock)) return false;

            // The dev block carries a literal <bos>; the tokenizer's TemplateProcessing would add a second one.
            var ids = new List<int>(m_Tok.Encode(m_DevBlock, null, false).GetIds());
            if (ids.Count == 0 || ids[0] != BOS) ids.Insert(0, BOS);
            int prefixLen = ids.Count;

            var key = new Tensor<float>[NumLayers];
            var val = new Tensor<float>[NumLayers];
            for (int l = 0; l < NumLayers; l++)
            {
                key[l] = new Tensor<float>(new TensorShape(1, NumKvHeads, 0, HeadDim));
                val[l] = new Tensor<float>(new TensorShape(1, NumKvHeads, 0, HeadDim));
            }
            using (var idT = new Tensor<int>(new TensorShape(1, prefixLen), ids.ToArray()))
            using (var mask = new Tensor<float>(new TensorShape(1, 1, prefixLen, prefixLen), CausalMask(prefixLen, prefixLen, 0)))
            using (var maskS = new Tensor<float>(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<float>[NumLayers];
                m_PrefixVal = new Tensor<float>[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<float>;
                    m_PrefixVal[l] = v as Tensor<float>;
                }
            }
            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();
        }
    }
}