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:
/// <bos><start_of_turn>user\n{user}<end_of_turn>\n<start_of_turn>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:/<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.
/// </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();
}
}
}
|