| """Schema cache: compute a question schema once, then score states against it. |
| |
| In production the questions are fixed and only the state changes. With the schema-first prompt layout |
| (prompt.build_schema_first) the question/option blocks are a prefix that does not depend on the state, so their |
| cache - attention K/V for the 6 full-attention layers, conv + recurrent state for the 18 delta-net layers - is computed |
| once (`prepare`). A request then runs only "Context: <state>" plus one answer slot per question, as a CUDA graph per |
| (batch, length) bucket. The prefix cache is read-only during a request (nothing is written back), so one copy serves |
| every batch and every graph. |
| |
| se = SchemaEngine(engine); h = se.prepare([{"question": ..., "options": [...]}, ...]) |
| probs = se.score(h, ["state 1", "state 2", ...]) # list of [n_questions, MAX_OPTIONS] tensors |
| """ |
| import time, types, torch, torch.nn.functional as F |
| from decider.prompt import schema_prefix_ids, schema_suffix_ids, MAX_OPTIONS |
| from decider.engine import read_slots, fill_ids |
|
|
| TS_BUCKETS = [32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024] |
| B_BUCKETS = [1, 2, 4, 8, 16, 32, 64] |
|
|
|
|
| class _Q: |
| def __init__(self, text, options): self.text, self.options = text, options |
|
|
|
|
| class PrefixCache: |
| """Duck-typed transformers Cache over fixed, read-only prefixes, for one suffix forward pass. |
| A handle holds P prefixes (P = 1: all questions packed in one prefix; P = n_questions: one prefix per question, so every |
| question is scored independently). A batch of R states has R * P rows; row r * P + p continues prefix p.""" |
| def __init__(self, h, R): |
| rep = (lambda t: t.expand(R, *t.shape[1:])) if h.P == 1 else (lambda t: t.repeat(R, *([1] * (t.dim() - 1)))) |
| self.tp = h.tpmax; self.k = {i: rep(k) for i, k in h.k.items()}; self.v = {i: rep(v) for i, v in h.v.items()} |
| self.conv = {i: rep(c).contiguous() for i, c in h.conv.items()} |
| self.layers = {i: types.SimpleNamespace(record_past=False, recurrent_states={0: rep(r).contiguous()}) for i, r in h.rec.items()} |
|
|
| def has_previous_state(self, layer_idx=None, state_idx=None): return True |
| def get_seq_length(self, *a, **k): return self.tp |
| def update(self, key, value, layer_idx, *a, **k): return torch.cat([self.k[layer_idx], key], 2), torch.cat([self.v[layer_idx], value], 2) |
| def update_conv_state(self, x, layer_idx, **k): return torch.cat([self.conv[layer_idx].to(x.dtype), x], -1) |
| def update_recurrent_state(self, s, layer_idx, **k): return s |
|
|
|
|
| class SchemaEngine: |
| def __init__(self, engine, use_graphs=True, chat=None): |
| """chat: the ChatTemplate of a chat-layout model (decider.prompt.chat_template); the prefix then starts with the template |
| head and the suffix ends with the template tail and the answer pieces (decider.prompt.build_schema_first with chat).""" |
| self.chat = chat |
| self.e = engine; self.core = engine.core; self.W = engine.W; self.tok = engine.tok; self.dev = engine.dev |
| self.use_graphs = use_graphs and engine.use_graphs; self.graphs = {}; self.stats = dict(prepared=0, captures=0, replays=0, eager=0) |
| self.compile = bool(engine.cfg.get("compile")); self._compiled = {} |
| if self.compile: |
| import torch._dynamo |
| torch._dynamo.config.cache_size_limit = 4096; torch._dynamo.config.accumulated_cache_size_limit = 1 << 16 |
|
|
| @torch.no_grad() |
| def prepare(self, questions, independent=False, compile=False): |
| """questions: [{"question": str, "options": [str]}] in the order answers are wanted. Runs the prefix(es) once. |
| independent=False: one prefix holding every question (cheapest: a request costs state + n slots). |
| independent=True: one prefix per question, one row per question (a request costs n * (state + 1 slot); no question |
| can influence another).""" |
| qs = [_Q(q["question"], list(q["options"])) for q in questions] |
| groups = [[q] for q in qs] if independent else [qs]; pres = [schema_prefix_ids(self.tok, g, chat=self.chat) for g in groups] |
| h = types.SimpleNamespace(P=len(groups), nq=len(qs), slots_per_row=1 if independent else len(qs), nopts=[len(q.options) for q in qs], tps=[len(p) for p in pres], |
| tpmax=max(len(p) for p in pres), k={}, v={}, conv={}, rec={}, id=self.stats["prepared"], |
| compile=bool(compile and self.compile)) |
| parts = [] |
| for pre in pres: |
| out = self.core(input_ids=torch.tensor(pre, device=self.dev)[None], use_cache=True).past_key_values; d = dict(k={}, v={}, conv={}, rec={}) |
| for i, layer in enumerate(out.layers): |
| if getattr(layer, "recurrent_states", None) is not None and layer.recurrent_states.get(0) is not None: |
| d["conv"][i] = layer.conv_states[0]; d["rec"][i] = layer.recurrent_states[0] |
| else: |
| pad = (0, 0, 0, h.tpmax - len(pre)); d["k"][i] = F.pad(layer.keys, pad); d["v"][i] = F.pad(layer.values, pad) |
| parts.append(d) |
| for name in ("k", "v", "conv", "rec"): |
| getattr(h, name).update({i: torch.cat([d[name][i] for d in parts], 0).clone() for i in parts[0][name]}) |
| self.stats["prepared"] += 1 |
| return h |
|
|
| def _fwd(self, ids, cache, mask, pos): |
| hs = self.core(input_ids=ids, past_key_values=cache, attention_mask={"full_attention": mask, "linear_attention": None}, position_ids=pos, use_cache=True).last_hidden_state |
| return F.linear(hs, self.W).float() |
|
|
| def _static(self, h, R, Ts): |
| """R request slots -> R * P rows. Mask: a row sees its own prefix (not the padding up to tpmax) and the causal suffix.""" |
| ar = torch.arange(Ts, device=self.dev); tps = torch.tensor(h.tps, device=self.dev).repeat(R) |
| pre = (torch.arange(h.tpmax, device=self.dev)[None, :] < tps[:, None])[:, None, None, :].expand(-1, 1, Ts, -1) |
| mask = torch.cat([pre, (ar[:, None] >= ar[None, :])[None, None].expand(len(tps), 1, -1, -1)], 3).contiguous() |
| return PrefixCache(h, R), mask, (tps[:, None] + ar[None, :]).contiguous() |
|
|
| def _capture(self, h, R, Ts): |
| B = R * h.P |
| ids = torch.full((B, Ts), self.tok.pad_token_id, dtype=torch.long, device=self.dev); cache, mask, pos = self._static(h, R, Ts) |
| fwd = self._fwd |
| if h.compile: |
| fwd = torch.compile(lambda i: self._fwd(i, cache, mask, pos), dynamic=False) |
| call = lambda: fwd(ids) |
| else: |
| call = lambda: fwd(ids, cache, mask, pos) |
| st = torch.cuda.Stream(); st.wait_stream(torch.cuda.current_stream()) |
| with torch.cuda.stream(st): |
| for _ in range(3): call() |
| torch.cuda.current_stream().wait_stream(st) |
| g = torch.cuda.CUDAGraph() |
| with torch.cuda.graph(g, pool=self.e.pool): |
| out = call() |
| self.stats["captures"] += 1 |
| return ids, out, g, (cache, mask, pos) |
|
|
| def warmup(self, h, batch_sizes=(1, 8, 32), state_tokens=(64, 128, 256)): |
| """Capture (and, for a compiled schema, compile) the graphs for these request-batch sizes and suffix lengths ahead of traffic.""" |
| t = time.time() |
| for R in batch_sizes: |
| for Ts in state_tokens: |
| Ts = next((x for x in TS_BUCKETS if x >= Ts), TS_BUCKETS[-1]) |
| if (h.id, R, Ts) not in self.graphs: self.graphs[(h.id, R, Ts)] = self._capture(h, R, Ts) |
| torch.cuda.synchronize(); return time.time() - t |
|
|
| def tokenize(self, h, context, max_ctx_tokens=1536): |
| """CPU part of a request (do it outside any GPU lock): -> (suffix ids, slot positions).""" |
| return schema_suffix_ids(self.tok, context, h.slots_per_row, max_ctx_tokens, chat=self.chat) |
|
|
| @staticmethod |
| def bucket(n_tokens): |
| return next((t for t in TS_BUCKETS if t >= n_tokens), -(-n_tokens // 256) * 256) |
|
|
| def score(self, h, contexts, temperature=1.0, max_ctx_tokens=1536): |
| """-> one [n_questions, MAX_OPTIONS] probability tensor per context. temperature: as in score_rows.""" |
| return self.score_rows(h, [self.tokenize(h, c, max_ctx_tokens) for c in contexts], temperature) |
|
|
| @torch.no_grad() |
| def score_rows(self, h, rows, temperature=1.0): |
| """rows: [(suffix ids, slots)] from tokenize(). temperature: a number, or a list with one temperature per schema row |
| (h.nq values, in the order of prepare's questions), applied to every request.""" |
| if isinstance(temperature, (list, tuple)) and len(temperature) != h.nq: |
| raise ValueError(f"temperature: {len(temperature)} values for a schema with {h.nq} rows") |
| Tmax = max(len(r[0]) for r in rows); Ts = next((t for t in TS_BUCKETS if t >= Tmax), None); n = len(rows) |
| R = next((b for b in B_BUCKETS if b >= n), n) if Ts else n; Ts = Ts or -(-Tmax // 256) * 256 |
| ids = fill_ids([x for x, _ in rows for _ in range(h.P)], R * h.P, Ts, self.tok.pad_token_id).to(self.dev, non_blocking=True) |
| if self.use_graphs and Ts <= TS_BUCKETS[-1]: |
| key = (h.id, R, Ts) |
| if key not in self.graphs: self.graphs[key] = self._capture(h, R, Ts) |
| s_ids, s_out, g, _ = self.graphs[key]; s_ids.copy_(ids); g.replay(); out = s_out; self.stats["replays"] += 1 |
| else: |
| out = self._fwd(ids, *self._static(h, R, Ts)); self.stats["eager"] += 1 |
| if h.P == 1: |
| rws = [r for r in range(n) for _ in range(h.nq)]; sls = [x for _, sl in rows for x in sl] |
| else: |
| rws = [r * h.P + p for r in range(n) for p in range(h.P)]; sls = [sl[0] for _, sl in rows for _ in range(h.P)] |
| temps = list(temperature) * n if isinstance(temperature, (list, tuple)) else temperature |
| return read_slots(out, rws, sls, h.nopts * n, temps, [h.nq] * n) |
|
|