"""Graph-captured greedy decoding: AR baseline and self-speculative decoding. All compute runs in captured CUDA/HIP graphs with static buffers: ar token -> all layers -> LM head argmax (1 row) seg[a, b, m] m hidden rows (buffer) -> layers [a, b) at positions pos[:m] (m rows) head[k] one hidden row -> head k -> (confidence, token id) fill[a, b] CALM K/V copy for layers [a, b) from one hidden row (regime B) verify[c, n] n hidden rows at layer c -> layers [c, L) -> argmax (n rows) Positions are read from a device buffer, so graphs replay anywhere. Attention uses the row-invariant Triton kernel and GEMMs use rocBLAS, so AR and every batched path compute each row bitwise identically (greedy parity). Regime A (default, kv_fill="lazy"): before a draft row climbs to a deeper head, the rows of the block that are shallower are caught up exactly in one batched segment pass; head scores depend only on the exact prefix and verification runs only [c_max, L). Regime B (kv_fill="copy"): K/V of skipped layers are copied from the exit hidden state; verification runs [c_min, L). Prefill (shared by all methods) runs eagerly. """ from __future__ import annotations import math import time import numpy as np import torch import torch.nn.functional as F from .decode import GenStats, StaticPolicy from .heads import ExitHead try: from .kernels import row_attention except ImportError: # no Triton (CPU tests) row_attention = None from .layered import LayeredDecoder, _rotate_half def _lam(controller) -> float: """Scalar lambda of an online controller (NoSlowdown wraps it in .aci).""" c = getattr(controller, "aci", controller) return float(np.min(getattr(c, "lam", float("nan")))) class DraftModel: """Classical speculative decoding: a small model of the same tokenizer drafts for the target (P1 baseline). Own FastDecoder (own KV, AR graph). Per cycle: catch up the committed tokens it has not processed, then draft `gamma` tokens greedily, or stop early when its max probability falls below `conf` (adaptive). The drafts are verified exactly by the target like copies.""" def __init__(self, fast: "FastDecoder", gamma: int = 4, conf: float | None = None): self.fast, self.gamma, self.conf = fast, gamma, conf def start(self, prompt: list[int]) -> None: self.fast._check_capacity(prompt, 0) self.fast._prefill(prompt) self.d_len = len(prompt) # positions whose draft KV is valid def _step(self, tok: int, pos: int): f = self.fast f._set_pos([pos]) f.ids.fill_(tok) a, c = f._g_ar_conf().tolist() return int(a), float(c) def _catchup(self, seq: list[int], start: int, end: int) -> None: """Committed tokens [start, end) the draft has not processed (e.g. after prompt-lookup cycles): processed in blocks of max_rows through the draft's layer-0 verify graphs (KV only; a draft-side error could only lower acceptance, never exactness).""" f = self.fast W = f.max_rows ids = torch.tensor(seq[start:end], device=f.dec.device) for s0 in range(0, end - start, W): m = min(W, end - start - s0) f._set_pos(list(range(start + s0, start + s0 + m))) f.buf[:m].copy_(f.dec.embed(ids[s0:s0 + m])) f._g_verify(0, m) def propose(self, seq: list[int], budget: int) -> list[int]: pos0 = len(seq) - 1 if pos0 - self.d_len > 1: self._catchup(seq, self.d_len, pos0) else: for p in range(self.d_len, pos0): # committed tokens the draft has not processed yet self._step(seq[p], p) x, drafts, self.n_inputs = seq[pos0], [], 0 for j in range(min(self.gamma, budget)): t, c = self._step(x, pos0 + j) self.n_inputs += 1 if self.conf is not None and j > 0 and c < self.conf: break drafts.append(t) x = t self.pos0 = pos0 return drafts def commit(self, accepted: int) -> None: # inputs at pos0 + i (i >= 1) were d_i: valid iff i <= accepted self.d_len = self.pos0 + 1 + min(accepted, self.n_inputs - 1) class FastDecoder: def __init__(self, dec: LayeredDecoder, heads: list[ExitHead], head_layers: list[int], max_rows: int = 16, use_graphs: bool | None = None, shared_pool: bool = False, prefill: str = "engine", prefill_chunk: int = 2048): assert prefill in ("engine", "full"), prefill self.prefill_mode, self.prefill_chunk = prefill, prefill_chunk self.dec = dec self.heads = heads self.head_layers = head_layers self.max_rows = max_rows dev, d = dec.device, dec.spec.hidden_size # Row metadata in ONE device buffer [pos | slot | cl | anc], filled by one copy from a # host staging buffer (pinned on GPU): audit T5, piste 1 (3-4 pageable copies before). W = max_rows self._meta_d = torch.zeros(3 * W + W * W, dtype=torch.int64, device=dev) self._meta_h = torch.zeros(3 * W + W * W, dtype=torch.int64) if dev.type == "cuda": self._meta_h = self._meta_h.pin_memory() self.pos = self._meta_d[0:W] # Tree drafts: slot = KV write index of each row; keys t < cl are read at index t, # keys t >= cl at anc[row, t - cl] (the row's path). Chains: slot = pos, cl = pos + 1. self.slot = self._meta_d[W:2 * W] self.cl = self._meta_d[2 * W:3 * W] self.anc = self._meta_d[3 * W:].view(W, W) self.cl.fill_(1) self.idx = torch.zeros(max_rows, dtype=torch.int64, device=dev) self.ids = torch.zeros(1, dtype=torch.long, device=dev) self.buf = torch.zeros(max_rows, d, device=dev, dtype=dec.dtype) # segment rows self.hrows = torch.zeros(max_rows, d, device=dev, dtype=dec.dtype) # block rows self.hb = torch.zeros(1, d, device=dev, dtype=dec.dtype) # head input # sampling (T > 0): Gumbel noise filled eagerly before each head replay (no RNG inside # graphs), per-head renormalised V_k distribution of the last sample, saved per draft row vmax = max([h.n_vocab for h in heads] + [1]) self.gnoise = torch.zeros(vmax, device=dev, dtype=torch.float32) self.qout = torch.zeros(vmax, device=dev, dtype=torch.float32) self.qsave = torch.zeros(max_rows, vmax, device=dev, dtype=torch.float32) self.temperature = 1.0 self._sampling, self._gen, self._rng = False, None, None self.graphs: dict = {} self._trace = None # debug: per-layer tensors (eager mode only) # ROCm 7.0 (CLR graph "packet capture"): a graph replay is not reliably ordered # with later work on the same stream; this faulted on MI210 (jobs 893662-893901). # Synchronising after every replay removes it (job 893903) and is the fastest # fix measured (96 tok/s AR vs 72 with DEBUG_CLR_GRAPH_PACKET_CAPTURE=0). self.sync_each = bool(torch.version.hip) self.use_graphs = dev.type == "cuda" if use_graphs is None else use_graphs # One memory pool for all graphs: safe because graphs never run concurrently and # every graph output is consumed (or copied into a persistent buffer) right away. self.pool = torch.cuda.graph_pool_handle() if (shared_pool and self.use_graphs) else None self.capture_seconds = 0.0 self.stats = {"seg_replays": 0, "seg_rows": 0, "noncontig": 0} def _sync(self) -> None: if self.dec.device.type == "cuda": torch.cuda.synchronize() # ------------------------------------------------------------------ compute def _rope_tables(self, n): pos = self.pos[:n] return pos, self.dec.cos.index_select(0, pos)[:, None], self.dec.sin.index_select(0, pos)[:, None] def _pad(self, x: torch.Tensor) -> torch.Tensor: n = x.shape[0] return x if n == self.max_rows else F.pad(x, (0, 0) * (x.dim() - 1) + (0, self.max_rows - n)) def _lin(self, mod, x: torch.Tensor) -> torch.Tensor: """GEMM on a fixed-width (max_rows) input; returns all max_rows rows.""" return F.linear(self._pad(x), mod.weight, mod.bias) def _mlp(self, mlp, x: torch.Tensor) -> torch.Tensor: return self._lin(mlp.down_proj, F.silu(self._lin(mlp.gate_proj, x)) * self._lin(mlp.up_proj, x)) def _final_logits(self, h: torch.Tensor) -> torch.Tensor: n = h.shape[0] return self._lin(self.dec.lm_head, self.dec.norm(self._pad(h))).float()[:n] def _layers(self, h: torch.Tensor, first: int, last: int) -> torch.Tensor: """Layers [first, last) on n real rows, at FIXED WIDTH. Every elementwise op, reduction (RMSNorm, q/k norm) and GEMM runs on exactly `max_rows` rows (real rows first, zero padding after), so each kernel sees the same shape whatever n is and a row is computed bitwise identically in AR (n=1), catch-up (m rows) and verification (n rows). Library kernels pick different code paths by shape: hipBLASLt/rocBLAS GEMMs (jobs 893616, 893848) and the MLP-block elementwise/reduction kernels (job 893872) were not row-invariant. Only the K/V cache writes (exact copies) and attention (row-invariant Triton kernel) touch the n real rows alone. Pad rows use position pos[n:] (kept valid). """ dec = self.dec n = h.shape[0] W = self.max_rows hd = dec.spec.head_dim pos, cos, sin = self._rope_tables(W) h = self._pad(h) for li in range(first, last): layer = dec.layers[li] attn = layer.self_attn x = layer.input_layernorm(h) q = self._lin(attn.q_proj, x).view(W, -1, hd) k = self._lin(attn.k_proj, x).view(W, -1, hd) v = self._lin(attn.v_proj, x).view(W, -1, hd) if dec.spec.has_qk_norm: q, k = attn.q_norm(q), attn.k_norm(k) q = q * cos + _rotate_half(q) * sin k = k * cos + _rotate_half(k) * sin dec.k_cache[li].index_copy_(1, self.slot[:n], k[:n].transpose(0, 1)) dec.v_cache[li].index_copy_(1, self.slot[:n], v[:n].transpose(0, 1)) o = self._attention(q[:n].contiguous(), li, n) h = h + self._lin(attn.o_proj, o.reshape(n, -1)) if self._trace is not None: self._trace.append(("attn_out", li, o.clone(), h[:n].clone())) h = h + self._mlp(layer.mlp, layer.post_attention_layernorm(h)) if self._trace is not None: self._trace.append(("layer_out", li, h[:n].clone())) return h[:n] def _attention(self, q: torch.Tensor, li: int, n: int) -> torch.Tensor: dec = self.dec if row_attention is not None and q.is_cuda: return row_attention(q, dec.k_cache[li], dec.v_cache[li], self.pos, dec.scaling, cl=self.cl, anc=self.anc) outs = [] # reference path (CPU tests): one query per row over its exact path cls, ancs = self.cl[:n].tolist(), self.anc[:n].tolist() for i, p in enumerate(self.pos[:n].tolist()): idx = torch.tensor(list(range(min(cls[i], p + 1))) + ancs[i][: max(p + 1 - cls[i], 0)]) outs.append(F.scaled_dot_product_attention( q[i:i + 1].transpose(0, 1)[None], dec.k_cache[li, :, idx][None], dec.v_cache[li, :, idx][None], scale=dec.scaling, enable_gqa=True)[0].transpose(0, 1)) return torch.cat(outs) def _fill(self, h: torch.Tensor, first: int, last: int) -> None: dec = self.dec hd = dec.spec.head_dim W = self.max_rows pos, cos, sin = self._rope_tables(W) x0 = self._pad(h) for li in range(first, last): layer = dec.layers[li] attn = layer.self_attn x = layer.input_layernorm(x0) k = self._lin(attn.k_proj, x).view(W, -1, hd) if dec.spec.has_qk_norm: k = attn.k_norm(k) k = k * cos + _rotate_half(k) * sin v = self._lin(attn.v_proj, x).view(W, -1, hd) dec.k_cache[li].index_copy_(1, pos[:1], k[:1].transpose(0, 1)) dec.v_cache[li].index_copy_(1, pos[:1], v[:1].transpose(0, 1)) def _head_out(self, k: int) -> torch.Tensor: head = self.heads[k] logits = head(self.hb)[0] probs = F.softmax(logits, -1) conf, arg = probs.max(-1) if head.reject: body = logits[:-1].argmax(-1) conf = torch.where(arg == logits.shape[-1] - 1, torch.zeros_like(conf), conf) else: body = arg return torch.stack([conf.float(), head.to_token_ids(body).float()]) def _head_out4(self, k: int) -> torch.Tensor: """Head k on hb: [conf, id_1..id_4, p_1..p_4] over V_k ∪ ⊥ (⊥ reported as id -1). Iterated argmax instead of torch.topk (ROCm 7.0 graph bug, see _g_verify_topk).""" head = self.heads[k] logits = head(self.hb)[0].float() probs = F.softmax(logits, -1) conf, arg = probs.max(-1) rej = logits.shape[-1] - 1 if head.reject: conf = torch.where(arg == rej, torch.zeros_like(conf), conf) ids, ps, pr = [], [], probs for _ in range(4): i = pr.argmax(-1) ps.append(pr.gather(0, i[None])[0]) tok = head.to_token_ids(i.clamp_max(head.n_vocab - 1)[None])[0].float() ids.append(torch.where(i == rej, torch.full_like(tok, -1.0), tok) if head.reject else tok) pr = pr.scatter(0, i[None], float("-inf")) return torch.stack([conf] + ids + ps) def _g_head4(self, k): return self._run(("head4", k), lambda: self._head_out4(k)) # ------------------------------------------------------------------ graphs def _run(self, key, fn): if not self.use_graphs: return fn() if key not in self.graphs: t0 = time.perf_counter() s = torch.cuda.Stream() s.wait_stream(torch.cuda.current_stream()) with torch.cuda.stream(s): for _ in range(2): fn() torch.cuda.current_stream().wait_stream(s) g = torch.cuda.CUDAGraph() with torch.cuda.graph(g, pool=self.pool): out = fn() self.graphs[key] = (g, out) self._sync() self.capture_seconds += time.perf_counter() - t0 g, out = self.graphs[key] if self.sync_each: # ROCm 7.0: a replay is not reliably ordered with the eager work queued just # before it either (e.g. the buf <- hrows copy): prompt-lookup cycles verified # from layer 0 read a stale buf (job 899287, parity 0.67; exact without graphs or # with packet capture off, 899290-899291). A bare sync costs ~4 us (job 899254). torch.cuda.synchronize() g.replay() if self.sync_each: torch.cuda.synchronize() return out def _g_ar(self): L = self.dec.spec.num_layers return self._run(("ar",), lambda: self._final_logits( self._layers(self.dec.embed(self.ids), 0, L)).argmax(-1)) def _head_sample_out(self, k: int) -> torch.Tensor: """Head k on hb at temperature self.temperature: [conf (exit score, 0 if ⊥ wins), sampled token id from q' = softmax(V_k logits / T) via Gumbel-max on gnoise, q'(sample)]; q' is left in qout[:n_vocab].""" head = self.heads[k] lg = head(self.hb)[0].float() probs = F.softmax(lg, -1) conf, arg = probs.max(-1) if head.reject: conf = torch.where(arg == lg.shape[-1] - 1, torch.zeros_like(conf), conf) nv = head.n_vocab qb = F.softmax(lg[:nv] / self.temperature, -1) x = (qb.clamp_min(1e-30).log() + self.gnoise[:nv]).argmax(-1) self.qout[:nv].copy_(qb) tok = head.to_token_ids(x[None])[0].float() return torch.stack([conf, tok, qb.gather(0, x[None])[0]]) def _g_head_sample(self, k): return self._run(("head_sample", k), lambda: self._head_sample_out(k)) def _g_ar_logits(self): L = self.dec.spec.num_layers return self._run(("ar_logits",), lambda: self._final_logits( self._layers(self.dec.embed(self.ids), 0, L)).float()[0]) def _g_verify_logits(self, c, n): L = self.dec.spec.num_layers return self._run(("verify_logits", c, n), lambda: self._final_logits( self._layers(self.buf[:n], c, L)).float()) def _g_ar_conf(self): """AR step returning [argmax, max prob] (draft model with adaptive stop).""" L = self.dec.spec.num_layers def fn(): pr = F.softmax(self._final_logits(self._layers(self.dec.embed(self.ids), 0, L)).float(), -1)[0] conf, arg = pr.max(-1) return torch.stack([arg.float(), conf]) return self._run(("ar_conf",), fn) def _g_seg(self, a, b, m): def fn(): self.buf[:m].copy_(self._layers(self.buf[:m], a, b)) return self.buf self.stats["seg_replays"] += 1 self.stats["seg_rows"] += m return self._run(("seg", a, b, m), fn) def _g_head(self, k): return self._run(("head", k), lambda: self._head_out(k)) def _g_fill(self, a, b): def fn(): self._fill(self.hb, a, b) return self.hb return self._run(("fill", a, b), fn) TOPK = 8 def _g_verify_topk(self, c, n): """Verification returning, per row, the argmax then the top-TOPK token ids (lossy rules).""" L = self.dec.spec.num_layers def fn(): # Iterated argmax instead of torch.topk: topk on a 151k vocabulary crashed inside a # HIP graph (illegal address, ROCm 7.0); argmax is the kernel the lossless path uses. lg = self._final_logits(self._layers(self.buf[:n], c, L)).float() cols = [] for _ in range(self.TOPK): i = lg.argmax(-1, keepdim=True) cols.append(i) lg = lg.scatter(-1, i, float("-inf")) return torch.cat([cols[0]] + cols, -1) return self._run(("verify_topk", c, n), fn) def _g_verify(self, c, n): L = self.dec.spec.num_layers return self._run(("verify", c, n), lambda: self._final_logits( self._layers(self.buf[:n], c, L)).argmax(-1)) # ------------------------------------------------------------------ helpers def _set_pos(self, plist: list[int], slots: list[int] | None = None) -> None: # Pad rows (fixed width) reuse the last real position: always a valid index. # slots: KV indices of the rows (default = positions; differ only for star siblings). W = self.max_rows full = list(plist) + [plist[-1]] * (W - len(plist)) sl = full if slots is None else list(slots) + [slots[-1]] * (W - len(slots)) h = self._meta_h h[0:W] = torch.tensor(full) h[W:2 * W] = torch.tensor(sl) # a row whose slot differs from its position reads its own key from the slot h[2 * W:3 * W] = torch.tensor([p + 1 if s_ == p else p for p, s_ in zip(full, sl)]) if slots is None: # chains never read anc (cl = pos + 1): skip its transfer self._meta_d[:3 * W].copy_(h[:3 * W]) else: a = h[3 * W:].view(W, W) a.zero_() a[:, 0] = h[W:2 * W] self._meta_d.copy_(h) def _set_rows(self, pos: list[int], slots: list[int], cls: list[int], ancs: list[list[int]]) -> None: """Tree rows: positions, KV slots, contiguous prefix lengths and path slots.""" W = self.max_rows assert len(set(slots)) == len(slots), "two rows would write the same KV slot" pad = W - len(pos) h = self._meta_h h[0:W] = torch.tensor(pos + [pos[-1]] * pad) h[W:2 * W] = torch.tensor(slots + [slots[-1]] * pad) h[2 * W:3 * W] = torch.tensor(cls + [cls[-1]] * pad) a = h[3 * W:].view(W, W) a.zero_() for i, row in enumerate(ancs + [ancs[-1]] * pad): if row: a[i, :len(row)] = torch.tensor(row) self._meta_d.copy_(h) def _gather(self, rows: list[int]) -> None: m = len(rows) if rows == list(range(rows[0], rows[0] + m)): self.buf[:m].copy_(self.hrows[rows[0]:rows[0] + m]) else: self.stats["noncontig"] = self.stats.get("noncontig", 0) + 1 self.idx[:m].copy_(torch.tensor(rows, dtype=torch.int64)) torch.index_select(self.hrows, 0, self.idx[:m], out=self.buf[:m]) def _scatter(self, rows: list[int]) -> None: m = len(rows) if rows == list(range(rows[0], rows[0] + m)): self.hrows[rows[0]:rows[0] + m].copy_(self.buf[:m]) else: self.hrows.index_copy_(0, self.idx[:m], self.buf[:m]) def _embed_row(self, j: int, tok: int) -> None: self.ids.fill_(tok) self.hrows[j:j + 1].copy_(self.dec.embed(self.ids)) def _prefill(self, prompt: list[int]) -> int: """Prompt processed by the engine itself, in causal chunks of max_rows rows through the fixed-width graphs (verify graphs from layer 0). No third-party attention kernel runs between graph replays, and the prompt KV is computed with exactly the same operators as generated tokens. prefill="full": wide GEMMs and standard causal attention instead (_prefill_full).""" self._prefix = None # a greedy prefill overwrites the cached sampling prefix if self.prefill_mode == "full": return self._prefill_full(prompt) W = self.max_rows ids = torch.tensor(prompt, device=self.dec.device) last = None for s0 in range(0, len(prompt), W): m = min(W, len(prompt) - s0) self._set_pos(list(range(s0, s0 + m))) self.buf[:m].copy_(self.dec.embed(ids[s0:s0 + m])) last = self._g_verify(0, m) return int(last[m - 1]) @torch.no_grad() def _prefill_full(self, prompt: list[int]) -> int: """Standard prefill: eager wide GEMMs over the whole prompt (chunks of prefill_chunk rows) and causal scaled-dot-product attention, writing the prompt KV in place. Decoding exactness is unchanged: AR and speculative decoding both start from this same KV and first token, and every decode-time row still goes through the fixed-width graphs. Only the prompt KV differs (bf16 rounding of other kernels) from the in-engine prefill, so outputs may differ from an engine-prefill run.""" dec = self.dec hd = dec.spec.head_dim ids = torch.tensor(prompt, device=dec.device) n = len(prompt) h = None for s0 in range(0, n, self.prefill_chunk): m = min(self.prefill_chunk, n - s0) pos = torch.arange(s0, s0 + m, device=dec.device) cos, sin = dec.cos.index_select(0, pos)[:, None], dec.sin.index_select(0, pos)[:, None] h = dec.embed(ids[s0:s0 + m]) if s0 == 0: mask, causal = None, True else: # query i (position s0 + i) sees keys t <= s0 + i mask = (torch.arange(s0 + m, device=dec.device)[None] <= pos[:, None])[None, None] causal = False for li in range(dec.spec.num_layers): layer = dec.layers[li] attn = layer.self_attn x = layer.input_layernorm(h) q = attn.q_proj(x).view(m, -1, hd) k = attn.k_proj(x).view(m, -1, hd) v = attn.v_proj(x).view(m, -1, hd) if dec.spec.has_qk_norm: q, k = attn.q_norm(q), attn.k_norm(k) q = q * cos + _rotate_half(q) * sin k = k * cos + _rotate_half(k) * sin dec.k_cache[li, :, s0:s0 + m].copy_(k.transpose(0, 1)) dec.v_cache[li, :, s0:s0 + m].copy_(v.transpose(0, 1)) o = F.scaled_dot_product_attention( q.transpose(0, 1)[None], dec.k_cache[li, :, :s0 + m][None], dec.v_cache[li, :, :s0 + m][None], attn_mask=mask, is_causal=causal, scale=dec.scaling, enable_gqa=True)[0] h = h + attn.o_proj(o.transpose(0, 1).reshape(m, -1)) h = h + layer.mlp(layer.post_attention_layernorm(h)) tok = int(dec.lm_head(dec.norm(h[-1:])).float().argmax(-1)[0]) self._sync() # eager kernels must finish before the next graph replay (ROCm ordering) return tok @staticmethod def _boundaries(policy, head_layers): # extra_bounds: segment boundaries without a head. Prompt lookup alone uses the h0 # layer: copies go through seg(0, L_h0) + verify(L_h0) like every other policy. # Decode-time verification straight from layer 0 gave a wrong first replay after # the prefill on MI210 (ROCm 7.0, jobs 899287/899321: graph token 0, eager correct). return sorted({0} | {head_layers[k] for k in policy.head_order} | set(getattr(policy, "extra_bounds", ()))) def warm_blas(self, max_prefill: int = 0) -> None: """Kept for compatibility: with the in-engine prefill, every GEMM has the fixed width max_rows, so no larger BLAS call happens after capture.""" self._warm = self.dec.max_len def precapture(self, policies: list, kv_fill: str = "lazy") -> None: """Capture every graph the given policies can use, at scratch positions.""" self._precapture_body(policies, kv_fill) def _precapture_body(self, policies: list, kv_fill: str = "lazy") -> None: if not getattr(self, "_warm", 0): self.warm_blas() scratch = self.dec.max_len - self.max_rows - 2 self._set_pos(list(range(scratch, scratch + self.max_rows))) self.ids.fill_(0) self._g_ar() for m in range(1, self.max_rows + 1): # in-engine prefill chunks self._g_verify(0, m) for pol in policies: b = self._boundaries(pol, self.head_layers) c_min, c_max = (b[1] if len(b) > 1 else 0), b[-1] # head-less (prompt lookup): [0] n_rows = self.max_rows if getattr(pol, "tree", None) else min(pol.max_draft + 1, self.max_rows) for k in pol.head_order: self._g_head(k) for u, v in zip(b, b[1:]): for m in range(1, (n_rows if kv_fill == "lazy" else 1) + 1): self._g_seg(u, v, m) if kv_fill == "copy": for k in pol.head_order: if self.head_layers[k] < c_max: self._g_fill(self.head_layers[k], c_max) for n in range(1, n_rows + 1): self._g_verify(c_max if kv_fill == "lazy" else c_min, n) if getattr(pol, "lossy", False): self._g_verify_topk(c_max if kv_fill == "lazy" else c_min, n) for pol in policies: # tree drafts (star, or trie with star siblings): the top-4 head graph st_cfg = getattr(pol, "star", None) or (getattr(pol, "tree", None) or {}).get("star") if st_cfg is not None: self.hb.zero_() self._g_head4(st_cfg.get("head", 0)) self.stats = {"seg_replays": 0, "seg_rows": 0, "noncontig": 0} self._sync() # ------------------------------------------------------------------ decoding @torch.no_grad() def _check_capacity(self, prompt, max_new) -> None: # drafts write KV up to max_rows slots beyond the last committed position if len(prompt) + max_new + self.max_rows > self.dec.max_len: raise ValueError(f"prompt {len(prompt)} + max_new {max_new} + {self.max_rows} draft rows " f"exceeds the KV capacity {self.dec.max_len}") def _sample(self, probs: torch.Tensor) -> int: return int(torch.multinomial(probs, 1, generator=self._gen)) def _first_token_sampled(self, prompt: list[int]) -> int: """Prefill prompt[:-1] (KV only), then the last prompt token through the AR graph: the first new token is SAMPLED from its logits (same arithmetic for AR and speculation).""" # prompt[:-1] KV is never rewritten by generation (writes start at len(prompt) - 1): reuse it # across samples of the same prompt (distribution tests draw thousands) if len(prompt) > 1 and not (getattr(self, "reuse_prefix", False) and getattr(self, "_prefix", None) == tuple(prompt[:-1])): self._prefill(prompt[:-1]) self._prefix = tuple(prompt[:-1]) self._set_pos([len(prompt) - 1]) self.ids.fill_(prompt[-1]) return self._sample(F.softmax(self._g_ar_logits() / self.temperature, -1)) def _sample_verify(self, c_verify: int, drafts: list[int], st) -> list[int]: """Exact speculative sampling on a chain (Leviathan et al.): draft i is accepted with probability min(1, p_i(x_i) / q_i(x_i)); at the first rejection the token is resampled from norm(max(p_i - q_i, 0)); if all are accepted the bonus is sampled from p_n. q_i is the head's renormalised V_k distribution at T (saved at drafting) or a point mass (copy).""" n = len(drafts) + 1 P = F.softmax(self._g_verify_logits(c_verify, n)[:n] / self.temperature, -1) st.cycles += 1 st.drafted += len(drafts) st.empty_drafts += int(not drafts) committed = [] if drafts: pv = P[torch.arange(len(drafts), device=P.device), torch.tensor(drafts, device=P.device)].tolist() for i, x in enumerate(drafts): k, qx = self._draft_q[i] if self._rng.random() < min(1.0, pv[i] / max(qx, 1e-30)): committed.append(x) continue res = P[i].clone() if k < 0: res[x] = 0.0 else: h = self.heads[k] q = torch.zeros_like(res) q[h.vocab_ids] = self.qsave[i, :h.n_vocab] res = (res - q).clamp_min(0.0) tot = float(res.sum()) committed.append(self._sample(res / tot if tot > 0 else P[i])) st.rejected_cycles += 1 break else: committed.append(self._sample(P[len(drafts)])) st.accepted += min(len(committed) - 1, len(drafts)) return committed @staticmethod def _emit(st: GenStats, on_commit, toks: list[int], srcs: list[str]) -> None: st.sources.extend(srcs) if on_commit is not None: on_commit(list(toks), list(srcs)) def _commit(self, out, committed, srcs, index, stop, eos_ids, max_new, st, on_commit) -> bool: """Append a cycle's committed tokens to out (truncated at stop / EOS / max_new), feed the prompt-lookup index, record their sources. Returns True if the stop rule fired.""" n0, stopped = len(out), False for t in committed: out.append(t) if index is not None: index.extend([t]) if stop is not None and stop(t): stopped = True break if t in eos_ids or len(out) >= max_new: break self._emit(st, on_commit, out[n0:], srcs[:len(out) - n0]) return stopped def generate_ar(self, prompt: list[int], max_new: int, eos_ids=frozenset(), stop=None, temperature: float = 0.0, gen: torch.Generator | None = None, on_commit=None) -> tuple[list[int], GenStats]: """stop: optional callable fed every new token, True = stop after it (same rule as generate_spec, so both stop at the same token). on_commit(tokens, sources): optional callable fed the tokens appended to the output (and their sources) after the prefill and after every step.""" self._check_capacity(prompt, max_new) st = GenStats() self._sync() t0 = time.perf_counter() if temperature > 0: self.temperature, self._gen = temperature, gen out = [self._first_token_sampled(prompt)] self._sync() st.prefill_seconds = time.perf_counter() - t0 self._emit(st, on_commit, out[:1], ["model"]) while len(out) < max_new and out[-1] not in eos_ids and not (stop is not None and stop(out[-1])): self._set_pos([len(prompt) + len(out) - 1]) self.ids.fill_(out[-1]) out.append(self._sample(F.softmax(self._g_ar_logits() / temperature, -1))) self._emit(st, on_commit, out[-1:], ["model"]) st.cycles += 1 self._sync() st.seconds = time.perf_counter() - t0 st.new_tokens = len(out) return out, st out = [self._prefill(prompt)] self._sync() st.prefill_seconds = time.perf_counter() - t0 self._emit(st, on_commit, out[:1], ["model"]) stopped = stop is not None and stop(out[0]) steps, t_prev = [], time.perf_counter() while not stopped and len(out) < max_new and out[-1] not in eos_ids: self._set_pos([len(prompt) + len(out) - 1]) self.ids.fill_(out[-1]) out.append(int(self._g_ar())) # .item() synchronises: per-step wall time self._emit(st, on_commit, out[-1:], ["model"]) if stop is not None and stop(out[-1]): break t_now = time.perf_counter() steps.append(t_now - t_prev) t_prev = t_now st.cycles += 1 if steps: # robust unit for cost controllers (outliers: syncs, GC) st.ar_step_median = float(np.median(steps)) self._sync() st.seconds = time.perf_counter() - t0 st.new_tokens = len(out) return out, st def _draft_lazy(self, policy, first_tok, pos0, budget, eos_ids, st, proposal=None, pld_mode=None): """Regime A. Returns drafts; on exit all block rows sit in hrows at c_max. proposal / pld_mode (prompt lookup): "first" drafts the copied tokens without evaluating any head; "fallback" uses them only if no head exits at the first position (an empty cycle). Copied drafts enter at depth 0 and are caught up lazily with the other rows (head index -1).""" hl = self.head_layers b = self._boundaries(policy, hl) c_max = b[-1] nxt = {u: v for u, v in zip(b, b[1:])} depth: list[int] = [] def advance(target: int) -> None: while True: lag = [j for j, d in enumerate(depth) if d < target] if not lag: return u = min(depth[j] for j in lag) batch = [j for j in lag if depth[j] == u] self._set_pos([pos0 + j for j in batch]) self._gather(batch) self._g_seg(u, nxt[u], len(batch)) self._scatter(batch) for j in batch: depth[j] = nxt[u] drafts: list[int] = [] self._draft_heads, self._stop_info, self._draft_q = [], None, [] tok = first_tok def copy_in() -> None: for t in proposal[: budget - len(drafts)]: drafts.append(t) self._draft_heads.append(-1) if self._sampling: self._draft_q.append((-1, 1.0)) # copy: deterministic draft, q = delta st.exits["pld"] = st.exits.get("pld", 0) + 1 self._embed_row(len(depth), t) depth.append(0) if t in eos_ids: break while True: j = len(depth) self._embed_row(j, tok) depth.append(0) if len(drafts) >= budget or (drafts and drafts[-1] in eos_ids): break if j == 0 and proposal and pld_mode == "first": copy_in() break exited, row = None, {} for k in policy.head_order: advance(hl[k]) self.hb.copy_(self.hrows[j:j + 1]) if self._sampling: self.gnoise.uniform_(1e-10, 1.0, generator=self._gen).log_().neg_().log_().neg_() conf, tok_f, qx = self._g_head_sample(k).tolist() else: conf, tok_f = self._g_head(k).tolist() row[k] = (conf, int(tok_f)) st.head_evals += 1 if policy.decide(k, conf, len(drafts)): exited = k if self._sampling: # keep q'_k of this draft row for the residual self.qsave[len(drafts), :self.heads[k].n_vocab].copy_(self.qout[:self.heads[k].n_vocab]) self._draft_q.append((k, qx)) break if exited is None: self._stop_info = row # every head was evaluated at the stop position if j == 0 and proposal and pld_mode == "fallback": self._stop_info = None copy_in() break tok = int(tok_f) st.exits[exited] = st.exits.get(exited, 0) + 1 drafts.append(tok) self._draft_heads.append(exited) advance(c_max) return drafts, c_max def _draft_copy(self, policy, first_tok, pos0, budget, eos_ids, st): """Regime B (CALM copy). Returns drafts; rows at c_min are left in hrows.""" hl = self.head_layers b = self._boundaries(policy, hl) c_min, c_max = b[1], b[-1] nxt = {u: v for u, v in zip(b, b[1:])} drafts: list[int] = [] self._draft_heads = [] # head of each draft (provenance) tok = first_tok while True: j = len(drafts) self._set_pos([pos0 + j]) self._embed_row(j, tok) self.buf[:1].copy_(self.hrows[j:j + 1]) self._g_seg(0, c_min, 1) self.hrows[j:j + 1].copy_(self.buf[:1]) # hidden at c_min (verified later) if len(drafts) >= budget or (drafts and drafts[-1] in eos_ids): break depth, exited = c_min, None for k in policy.head_order: while depth < hl[k]: self._g_seg(depth, nxt[depth], 1) depth = nxt[depth] self.hb.copy_(self.buf[:1]) conf, tok_f = self._g_head(k).tolist() st.head_evals += 1 if policy.decide(k, conf, len(drafts)): exited = k break if exited is None: break if depth < c_max: self._g_fill(depth, c_max) tok = int(tok_f) st.exits[exited] = st.exits.get(exited, 0) + 1 drafts.append(tok) self._draft_heads.append(exited) return drafts, c_min def _star_cycle(self, star: dict, first_tok: int, pos0: int, budget: int, st) -> list[int]: """Depth-1 tree draft (star): root = last committed token; the shallow head (index star["head"]) proposes up to C children from its top-4 at the root; children are computed on their own KV slots pos0+1+i at position pos0+1, verified together with the root; the accepted child's KV is compacted to slot pos0+1. Exact (lossless). Admission: q_r = p_r (head probability of rank r, ⊥ excluded) >= tau[r], and the survival sum of the admitted q_r >= gamma. Returns the committed tokens.""" k = star.get("head", 0) c = self.head_layers[k] self._embed_row(0, first_tok) self._set_pos([pos0]) self._gather([0]) self._g_seg(0, c, 1) self._scatter([0]) self.hb.copy_(self.hrows[0:1]) r = self._g_head4(k).tolist() st.head_evals += 1 ids, ps = [int(x) for x in r[1:5]], r[5:9] tau = star.get("tau", [0.05, 0.02, 0.02, 0.02]) kids = [t for t, p, th in zip(ids, ps, tau) if t >= 0 and p >= th][: min(star.get("C", 4), budget)] qs = [p for t, p, th in zip(ids, ps, tau) if t >= 0 and p >= th][: len(kids)] if kids and sum(qs) < star.get("gamma", 0.3): kids = [] m = len(kids) if m: for i, t in enumerate(kids): self._embed_row(1 + i, t) self._set_pos([pos0 + 1] * m, [pos0 + 1 + i for i in range(m)]) self._gather(list(range(1, 1 + m))) self._g_seg(0, c, m) self._scatter(list(range(1, 1 + m))) n = 1 + m self._set_pos([pos0] + [pos0 + 1] * m, [pos0] + [pos0 + 1 + i for i in range(m)]) self.buf[:n].copy_(self.hrows[:n]) target = self._g_verify(c, n).tolist() st.cycles += 1 st.drafted += m st.empty_drafts += int(m == 0) committed = [target[0]] if m: st.exits["star"] = st.exits.get("star", 0) + m j = next((i for i, t in enumerate(kids) if t == target[0]), None) if j is None: st.rejected_cycles += 1 else: st.accepted += 1 committed.append(target[1 + j]) if j > 0: # compaction: the accepted child's KV (all layers) to slot pos0+1 self.stats["star_compactions"] = self.stats.get("star_compactions", 0) + 1 dk, dv = self.dec.k_cache, self.dec.v_cache dk[:, :, pos0 + 1].copy_(dk[:, :, pos0 + 1 + j]) dv[:, :, pos0 + 1].copy_(dv[:, :, pos0 + 1 + j]) return committed def _tree_cycle(self, policy, cfg: dict, root_tok: int, pos0: int, eos_ids, st, proposal, lam: float = float("-inf"), match_n: int = 0, ext=None): """General tree draft (trie), one verification. Rows = nodes in creation order (row 0 = root at pos0); node i sits at position pos0 + depth, writes its KV at slot pos0 + i and attends to [0, pos0] plus the slots of its own path. 1. prompt-lookup copy (proposal, <= cfg["m"]) inserted under the root first, so it rides in the root's first segment; 2. head chain (lazy, policy.decide, <= cfg["head_budget"] drafts) that reuses a copy row when its token matches; a head is never evaluated on a row already computed beyond its layer; 3. optional star siblings at the root from the top-4 of cfg["star"]["head"]. Verification walks the trie from the root; the accepted path's KV is compacted to positions pos0+1.. Exact (lossless). Safety gate (chantier 3b §7.6, alpha guarantee on trees): every node v carries a predicted acceptance q_v (head: a_hat_k; copy: cfg["gate"]["q_copy"][n][d] for match length n and copy depth d; star: isotone map of the rank probability). After the rows are computed, the children of u are KEPT (drafted, verified) only if p(u) = min(1, sum_v q_v) >= lam; lam > 1 keeps none (pure probe). Returns (committed tokens, info) with info = path origins, failed child origin, stop (p, a) and the number of drafted nodes, for online.cycle_from_tree.""" gate = cfg.get("gate") or {} qc = gate.get("q_copy") qs = gate.get("q_star") hl = self.head_layers W = self.max_rows b = self._boundaries(policy, hl) c_max = b[-1] nxt = dict(zip(b, b[1:])) tok, par, dep, ld, kids, origin, q = [], [], [], [], [], [], [] shown = [] # provenance reported to the user (st.sources); origin feeds the statistics def add(t, parent, how, qv=0.0): if len(tok) >= W: return None i = len(tok) tok.append(t); par.append(parent); dep.append(0 if parent is None else dep[parent] + 1) ld.append(0); kids.append({}); origin.append(how); shown.append(how); q.append(float(qv)) if parent is not None: kids[parent][t] = i self._embed_row(i, t) return i def path(i): out = [] while par[i] is not None: out.append(pos0 + i) i = par[i] return out[::-1] def set_rows(rows): self._set_rows([pos0 + dep[i] for i in rows], [pos0 + i for i in rows], [pos0 + 1] * len(rows), [path(i) for i in rows]) def advance(target): while True: lag = [i for i in range(len(tok)) if ld[i] < target] if not lag: return u = min(ld[i] for i in lag) batch = [i for i in lag if ld[i] == u] set_rows(batch) self._gather(batch) self._g_seg(u, nxt[u], len(batch)) self._scatter(batch) for i in batch: ld[i] = nxt[u] root = add(root_tok, None, "root") c = root for t in (ext or []): # external draft model chain (P1: SD + PLD in one verification) n = kids[c].get(t) if n is None: n = add(t, c, "draft") if n is None: break st.exits["draft"] = st.exits.get("draft", 0) + 1 c = n if t in eos_ids: break c = root for d, t in enumerate((proposal or [])[: cfg.get("m", 15)], start=1): n = kids[c].get(t) if n is None: qv = 0.0 if qc is not None: row = qc.get(str(match_n)) or qc.get(str(max(int(k) for k in qc))) qv = row[min(d, len(row)) - 1] n = add(t, c, "copy", qv) if n is None: break st.pld_drafted += 1 c = n if t in eos_ids: break star = cfg.get("star") if hasattr(policy, "start_block"): policy.start_block() c, drafted = root, 0 while drafted < cfg.get("head_budget", 8) and len(tok) < W and (c == root or tok[c] not in eos_ids): exited = None for k in policy.head_order: if hl[k] < ld[c]: continue # row already beyond this head's layer (shared copy row) advance(hl[k]) self.hb.copy_(self.hrows[c:c + 1]) if star is not None and c == root and k == star.get("head", 0): r = self._g_head4(k).tolist() conf, t_k = r[0], int(r[1]) tau = star.get("tau", [0.05, 0.02, 0.02, 0.02]) adm = [(int(x), p, rk) for rk, (x, p, th) in enumerate(zip(r[1:5], r[5:9], tau)) if x >= 0 and p >= th] if adm and sum(p for _, p, _ in adm) >= star.get("gamma", 0.3): for x, p, rk in adm[: star.get("C", 4)]: qv = float(np.interp(p, *qs[rk])) if qs is not None else 0.0 if x in kids[root]: q[kids[root][x]] = max(q[kids[root][x]], qv) elif add(x, root, "star", qv) is not None: st.exits["star"] = st.exits.get("star", 0) + 1 else: conf, t_k = self._g_head(k).tolist() t_k = int(t_k) st.head_evals += 1 if conf > 0 and policy.decide(k, conf, drafted): # conf == 0: ⊥ won exited = k break if exited is None: break if gate.get("q_head") is not None: # head map refitted for the gate (pool A) q_head = float(np.interp(conf, *gate["q_head"])) else: q_head = float(policy._ahat(exited, conf)) if hasattr(policy, "_ahat") else float(conf) n = kids[c].get(t_k) if n is None: n = add(t_k, c, "head", q_head) if n is None: break st.exits[exited] = st.exits.get(exited, 0) + 1 else: q[n] = max(q[n], q_head) if origin[n] == "star": # the star's rank-0 candidate is the chain's own token shown[n] = "head" c = n drafted += 1 if t_k in eos_ids: break advance(c_max) # safety gate: keep the children of u only if p(u) = min(1, sum_v q_v) >= lam. Children # are distinct tokens and at most one equals the greedy target: disjoint events, so the # sum is the calibrated probability that one is accepted (chantier 3b §7.6) gate_p = [min(1.0, sum(q[v] for v in kids[u].values())) for u in range(len(tok))] if gate.get("g") is not None: # final isotone recalibration g(P) -> HIT (pool A) gate_p = [float(np.interp(x, *gate["g"])) if kids[u] else 0.0 for u, x in enumerate(gate_p)] keep, frontier = [root], [root] while frontier: u = frontier.pop(0) if kids[u] and gate_p[u] >= lam: ch = sorted(kids[u].values()) keep += ch frontier += ch keep.sort() kept = set(keep) n_rows = len(keep) set_rows(keep) if keep == list(range(n_rows)): self.buf[:n_rows].copy_(self.hrows[:n_rows]) else: self.buf[:n_rows].copy_(self.hrows.index_select(0, torch.tensor(keep, device=self.hrows.device))) tgt_rows = self._g_verify(c_max, n_rows).tolist() target = {u: tgt_rows[j] for j, u in enumerate(keep)} st.cycles += 1 st.drafted += n_rows - 1 st.empty_drafts += int(n_rows == 1) acc, node = [], root while True: nxt_node = kids[node].get(target[node]) if nxt_node is None or nxt_node not in kept: break acc.append(nxt_node) node = nxt_node st.accepted += len(acc) st.pld_accepted += sum(1 for i in acc if origin[i] == "copy") kept_kids = [v for v in kids[node].values() if v in kept] st.rejected_cycles += int(bool(kept_kids)) # some draft below the last accepted node failed info = {"path": [origin[i] for i in acc], "sources": [shown[i] for i in acc], "n_nodes": n_rows - 1, "failed": origin[max(kept_kids, key=lambda v: q[v])] if kept_kids else None, "stop": None if kept_kids else (gate_p[node], float(target[node] in kids[node]))} src = [pos0 + i for i in acc] dst = [pos0 + 1 + d for d in range(len(acc))] if src != dst: # compaction of the accepted path (all layers, K and V) self.stats["tree_compactions"] = self.stats.get("tree_compactions", 0) + 1 si = torch.tensor(src, device=self.dec.k_cache.device) di = torch.tensor(dst, device=self.dec.k_cache.device) for cache in (self.dec.k_cache, self.dec.v_cache): cache.index_copy_(2, di, cache.index_select(2, si)) return [tok[i] for i in acc] + [target[node]], info @torch.no_grad() def generate_spec(self, policy, prompt: list[int], max_new: int, eos_ids=frozenset(), kv_fill: str = "lazy", controller=None, predictor=None, ar_step_seconds: float | None = None, accept_rule=None, pld=None, stop=None, star=None, drafter=None, temperature: float = 0.0, gen: torch.Generator | None = None, on_commit=None) -> tuple[list[int], GenStats]: """controller/predictor: online calibration (chantier 3b). Before each cycle the controller's lambda is turned into confidence thresholds (exit at the first head with a_hat_k >= lambda); lambda > 1 means a plain AR step (or, if the controller probes, a cycle that evaluates every head without drafting). After verification the cycle feedback (labels on the verified prefix, stop-position head outputs) is sent back. Requires kv_fill="lazy". accept_rule: None (exact, lossless) or a LOSSY relaxation of greedy verification: ("topk", k) accept a draft that is among the target's top-k tokens (r3) ("equiv", cls) accept a draft in the same formatting class as the target argmax, cls = list mapping token id -> class id (r5) An accepted draft is committed as is; rejections and bonus use the target argmax. pld: prompt lookup drafts, dict(mode="first"|"fallback", n_max=4, n_min=1, m=15); with a policy without heads, "fallback" is plain PLD. stop, on_commit: as in generate_ar. st.sources[i] is where output token i came from: "copy" (prompt lookup), "head" (head chain), "star" (top-k sibling of h0), "draft" (draft model) or "model" (prefill token, correction or bonus token of each cycle).""" from .calibration import online as onl from .pld import NgramIndex self._check_capacity(prompt, max_new) assert policy.max_draft + 1 <= self.max_rows assert controller is None or kv_fill == "lazy" st = GenStats() K = len(self.heads) gamma_only = bool(getattr(controller, "gamma_only", False)) xi = float(getattr(controller, "xi", 0.0)) def account(cost, emitted, drafting): g = cost - (1 + xi) * emitted st.cost_units += cost st.excess_end += g st.excess_max = max(st.excess_max, st.excess_end) if drafting: st.h_max = max(st.h_max, g) if controller is not None: st.lam_start = _lam(controller) st.gamma_start = float(getattr(controller, "gamma", float("nan"))) draft = self._draft_lazy if kv_fill == "lazy" else self._draft_copy seg_rows0 = self.stats["seg_rows"] # engine counters are cumulative: report this request only self._sync() t0 = time.perf_counter() self._sampling = temperature > 0 if self._sampling: # exact speculative sampling on chains (heads + copies) assert controller is None and accept_rule is None and star is None and drafter is None assert getattr(policy, "tree", None) is None and kv_fill == "lazy" import random self.temperature, self._gen = temperature, gen self._rng = random.Random(gen.initial_seed() if gen is not None else 0) out = [self._first_token_sampled(prompt)] else: out = [self._prefill(prompt)] self._sync() st.prefill_seconds = time.perf_counter() - t0 self._emit(st, on_commit, out[:1], ["model"]) assert pld is None or (controller is None and kv_fill == "lazy") index = None if pld is not None: index = NgramIndex(pld.get("n_max", 4), pld.get("n_min", 1)) index.extend(prompt) index.extend(out) stopped = stop is not None and stop(out[0]) assert star is None or (controller is None and pld is None and accept_rule is None and kv_fill == "lazy") if drafter is not None: # optional prompt lookup FIRST (copy if a match exists, else the draft model) assert controller is None and kv_fill == "lazy" and (pld is None or pld.get("mode") == "first") drafter.start(prompt) tree = getattr(policy, "tree", None) if tree is not None: # controllers on a tree: a gamma-only cost safety (NoSlowdown, Prop. 4), or an ACI # on the safety gate lambda (virtual head, chantier 3b §7.6), possibly wrapped in it assert accept_rule is None and kv_fill == "lazy" if tree.get("copy") and index is None: index = NgramIndex(tree["copy"].get("n_max", 4), tree["copy"].get("n_min", 1)) index.extend(prompt) index.extend(out) while tree is not None and not stopped and len(out) < max_new and out[-1] not in eos_ids: pos0 = len(prompt) + len(out) - 1 lam, probe = float("-inf"), False if controller is not None: # gamma floor / gate lambda; plain AR step when asked lamv, gamma, ar = controller.policy() if not gamma_only: lam = float(np.atleast_1d(lamv)[0]) if ar and controller.probe: # counterfactual probe: compute the tree, draft nothing probe, lam, ar = True, float("inf"), False if ar: self._set_pos([pos0]) self.ids.fill_(out[-1]) out.append(int(self._g_ar())) self._emit(st, on_commit, out[-1:], ["model"]) st.cycles += 1 st.ar_steps += 1 controller.update(onl.Cycle([], [], 0, 0, 1, 1.0, ar=True)) account(1.0, 1, False) if index is not None: index.extend([out[-1]]) if stop is not None and stop(out[-1]): stopped = True continue base = getattr(policy, "_gamma_base", None) if base is None: base = policy._gamma_base = policy.cum_gamma policy.cum_gamma = ({k: max(v, float(gamma)) for k, v in base.items()} if isinstance(base, dict) else max(float(base), float(gamma))) t_cycle = time.perf_counter() prop = index.propose(tree["copy"].get("m", 15)) if tree.get("copy") else None ext = drafter.propose(prompt + out, min(max_new - len(out), self.max_rows - 1)) if drafter is not None else None committed, info = self._tree_cycle(policy, tree, out[-1], pos0, eos_ids, st, prop, lam=lam, match_n=getattr(index, "last_n", 0) if index is not None else 0, ext=ext) if drafter is not None: # draft KV valid up to the common prefix with the accepted path k = 0 while k < min(len(ext), len(committed) - 1) and ext[k] == committed[k]: k += 1 drafter.commit(k) if controller is not None: cost = ((time.perf_counter() - t_cycle) / ar_step_seconds) if ar_step_seconds else 1.0 if gamma_only: cyc = onl.Cycle([], [], len(committed) - 1, 0, len(committed), cost) else: cyc = onl.cycle_from_tree(info["path"], info["failed"], emitted=len(committed), cost=cost, stop=info["stop"], n_nodes=info["n_nodes"]) cyc.ar = probe controller.update(cyc) st.lab_n += len(cyc.labels) st.lab_s += float(sum(cyc.labels)) account(cost, len(committed), True) stopped = self._commit(out, committed, info["sources"] + ["model"], index, stop, eos_ids, max_new, st, on_commit) or stopped while star is not None and not stopped and len(out) < max_new and out[-1] not in eos_ids: pos0 = len(prompt) + len(out) - 1 committed = self._star_cycle(star, out[-1], pos0, max_new - len(out), st) stopped = self._commit(out, committed, ["star"] * (len(committed) - 1) + ["model"], None, stop, eos_ids, max_new, st, on_commit) or stopped while not stopped and len(out) < max_new and out[-1] not in eos_ids: pos0 = len(prompt) + len(out) - 1 budget = min(policy.max_draft, max_new - len(out)) t_cycle = time.perf_counter() probe = False if controller is not None: taus, gamma, ar = controller.policy() if ar and not controller.probe: self._set_pos([pos0]) self.ids.fill_(out[-1]) out.append(int(self._g_ar())) self._emit(st, on_commit, out[-1:], ["model"]) st.cycles += 1 st.ar_steps += 1 controller.update(onl.Cycle([], [], 0, 0, 1, 1.0, ar=True)) account(1.0, 1, False) if stop is not None and stop(out[-1]): # same rule as the tree branch stopped = True continue probe = bool(ar) if not gamma_only: # gamma_only: the policy keeps its heads and a_hat thresholds tau_h = predictor.thresholds(taus) # exit iff H = -conf <= tau policy.thresholds = {k: (float("inf") if probe else float(-tau_h[k])) for k in range(K)} policy.head_order = list(range(K)) if hasattr(policy, "cum_gamma"): base = getattr(policy, "_gamma_base", None) if base is None: base = policy._gamma_base = policy.cum_gamma if isinstance(base, dict): # per-head gamma_k: gamma_k <- max(gamma_k^pol, gamma_safety) policy.cum_gamma = {k: max(v, float(gamma)) for k, v in base.items()} else: policy.cum_gamma = float(gamma) if hasattr(policy, "start_block"): policy.start_block() used_drafter = False if drafter is not None: prop = index.propose(pld.get("m", 15)) if index is not None else [] if not prop: prop, used_drafter = drafter.propose(prompt + out, budget), True drafts, c_verify = draft(policy, out[-1], pos0, budget, eos_ids, st, proposal=prop, pld_mode="fallback") if used_drafter: # draft-model tokens went through the copy path: count them apart nd = sum(1 for k in self._draft_heads if k < 0) st.exits["pld"] = st.exits.get("pld", 0) - nd st.exits["draft"] = st.exits.get("draft", 0) + nd elif index is not None: drafts, c_verify = draft(policy, out[-1], pos0, budget, eos_ids, st, proposal=index.propose(pld.get("m", 15)), pld_mode=pld.get("mode", "fallback")) else: drafts, c_verify = draft(policy, out[-1], pos0, budget, eos_ids, st) n = len(drafts) + 1 self._set_pos(list(range(pos0, pos0 + n))) self.buf[:n].copy_(self.hrows[:n]) if self._sampling: committed = self._sample_verify(c_verify, drafts, st) else: if accept_rule is None: target = self._g_verify(c_verify, n).tolist() tk = None else: rows = self._g_verify_topk(c_verify, n).tolist() target, tk = [r[0] for r in rows], [r[1:] for r in rows] st.cycles += 1 st.drafted += len(drafts) st.empty_drafts += int(not drafts) committed = [] for i, d in enumerate(drafts): ok = target[i] == d if not ok and accept_rule is not None: if accept_rule[0] == "topk": ok = d in tk[i][: accept_rule[1]] elif accept_rule[0] == "equiv": ok = accept_rule[1][d] == accept_rule[1][target[i]] if not ok: break committed.append(d) st.accepted += len(committed) if index is not None and not (drafter is not None and used_drafter): st.pld_drafted += sum(1 for k in self._draft_heads if k < 0) st.pld_accepted += sum(1 for k in self._draft_heads[:len(committed)] if k < 0) st.rejected_cycles += int(len(committed) < len(drafts)) if hasattr(policy, "update"): policy.update(len(drafts), len(committed)) if drafter is not None and used_drafter: drafter.commit(len(committed)) committed.append(target[len(committed)]) # correction or bonus token if controller is not None: n_acc = st_acc = len(committed) - 1 per_token = [(k, i < n_acc) for i, k in enumerate(self._draft_heads)] stop_pos = None # (a_hat, labels) at the stop position (not the `stop` rule) if (predictor is not None and n_acc == len(drafts) and self._stop_info is not None and len(self._stop_info) == K): confs = np.array([self._stop_info[k][0] for k in range(K)]) toks = [self._stop_info[k][1] for k in range(K)] ahat = predictor.predict(-confs[None, :])[0] stop_pos = (ahat, np.array([float(t == target[len(drafts)]) for t in toks])) cost = ((time.perf_counter() - t_cycle) / ar_step_seconds) if ar_step_seconds else 1.0 cyc = onl.cycle_from_hook(per_token, emitted=len(committed), cost=cost, stop=stop_pos) cyc.ar = probe controller.update(cyc) st.lab_n += len(cyc.labels) st.lab_s += float(sum(cyc.labels)) account(cost, len(committed), True) srcs = [("draft" if used_drafter else "copy") if k < 0 else "head" for k in self._draft_heads[:len(committed) - 1]] + ["model"] stopped = self._commit(out, committed, srcs, index, stop, eos_ids, max_new, st, on_commit) or stopped self._sync() st.seconds = time.perf_counter() - t0 st.new_tokens = len(out) st.layer_rows = self.stats["seg_rows"] - seg_rows0 self._sampling = False if controller is not None: st.lam_end = _lam(controller) st.n_jumps = int(getattr(getattr(controller, "aci", controller), "n_jumps", -1)) st.gamma_end = float(getattr(controller, "gamma", float("nan"))) return out, st