flashrt-structures 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. flashrt_structures/__init__.py +174 -0
  2. flashrt_structures/adapters/__init__.py +55 -0
  3. flashrt_structures/adapters/diffusers_attention.py +237 -0
  4. flashrt_structures/adapters/diffusers_rotary_attention.py +239 -0
  5. flashrt_structures/adapters/factored_qk_norm_rope.py +252 -0
  6. flashrt_structures/adapters/factored_two_way_attention.py +99 -0
  7. flashrt_structures/adapters/gemma_attention.py +226 -0
  8. flashrt_structures/adapters/packed_qkv_rope.py +342 -0
  9. flashrt_structures/adapters/packed_stream_qk_norm_rope.py +376 -0
  10. flashrt_structures/adapters/qwen_per_head_qk_norm_rope.py +231 -0
  11. flashrt_structures/adapters/sglang_engine.py +207 -0
  12. flashrt_structures/adapters/transformers_attention_interface.py +73 -0
  13. flashrt_structures/adapters/transformers_gated_delta.py +152 -0
  14. flashrt_structures/adapters/transformers_gated_delta_fused.py +96 -0
  15. flashrt_structures/adapters/vllm_engine.py +424 -0
  16. flashrt_structures/adjudicate.py +85 -0
  17. flashrt_structures/aot.py +191 -0
  18. flashrt_structures/autobuild.py +2052 -0
  19. flashrt_structures/beta/__init__.py +43 -0
  20. flashrt_structures/beta/conform.py +94 -0
  21. flashrt_structures/beta/joins.py +113 -0
  22. flashrt_structures/beta/negotiate.py +84 -0
  23. flashrt_structures/beta/ports.py +140 -0
  24. flashrt_structures/decisions.py +80 -0
  25. flashrt_structures/discover.py +623 -0
  26. flashrt_structures/explain.py +70 -0
  27. flashrt_structures/frontdoor.py +572 -0
  28. flashrt_structures/gates.py +465 -0
  29. flashrt_structures/guard.py +421 -0
  30. flashrt_structures/handle.py +189 -0
  31. flashrt_structures/impls/__init__.py +219 -0
  32. flashrt_structures/impls/adaln_producer/__init__.py +8 -0
  33. flashrt_structures/impls/adaln_producer/broker.py +116 -0
  34. flashrt_structures/impls/adaln_producer/fused.py +388 -0
  35. flashrt_structures/impls/adarms_stack/__init__.py +8 -0
  36. flashrt_structures/impls/adarms_stack/fp8_chain.py +832 -0
  37. flashrt_structures/impls/adarms_stack/region.py +102 -0
  38. flashrt_structures/impls/attention_core/__init__.py +132 -0
  39. flashrt_structures/impls/attention_core/fa2_seqused.py +458 -0
  40. flashrt_structures/impls/attention_core/fa4_cute.py +154 -0
  41. flashrt_structures/impls/attention_core/fa4_fp8.py +178 -0
  42. flashrt_structures/impls/attention_core/masked_mha.py +158 -0
  43. flashrt_structures/impls/attention_core/two_way_fa2.py +220 -0
  44. flashrt_structures/impls/cadence_static/__init__.py +18 -0
  45. flashrt_structures/impls/cadence_static/buffers.py +122 -0
  46. flashrt_structures/impls/cadence_static/cross_attention.py +187 -0
  47. flashrt_structures/impls/chain_elements.py +89 -0
  48. flashrt_structures/impls/decode_loop/__init__.py +0 -0
  49. flashrt_structures/impls/decode_loop/fp8_kv.py +206 -0
  50. flashrt_structures/impls/decode_loop/mtp_speculative.py +245 -0
  51. flashrt_structures/impls/decode_loop/whole_step.py +852 -0
  52. flashrt_structures/impls/decoder_block/__init__.py +6 -0
  53. flashrt_structures/impls/decoder_block/attn_sublayer.py +110 -0
  54. flashrt_structures/impls/decoder_block/fused.py +167 -0
  55. flashrt_structures/impls/decoder_ffn/__init__.py +0 -0
  56. flashrt_structures/impls/decoder_ffn/fp8_static.py +310 -0
  57. flashrt_structures/impls/decoder_ffn/fp8_static.yaml +22 -0
  58. flashrt_structures/impls/decoder_ffn/w4a16_static.py +221 -0
  59. flashrt_structures/impls/decoder_ffn/w8a16_static.py +183 -0
  60. flashrt_structures/impls/dit_stack/__init__.py +11 -0
  61. flashrt_structures/impls/dit_stack/fp4_chain.py +417 -0
  62. flashrt_structures/impls/dit_stack/region.py +86 -0
  63. flashrt_structures/impls/fixed_iter/__init__.py +29 -0
  64. flashrt_structures/impls/fixed_iter/openpi.py +264 -0
  65. flashrt_structures/impls/fixed_iter/protocol.py +94 -0
  66. flashrt_structures/impls/gated_delta_core/__init__.py +3 -0
  67. flashrt_structures/impls/gated_delta_core/fused_layer.py +545 -0
  68. flashrt_structures/impls/gated_delta_core/hub_v3.py +152 -0
  69. flashrt_structures/impls/graph_lowering/__init__.py +27 -0
  70. flashrt_structures/impls/graph_lowering/pi052_denoise.py +179 -0
  71. flashrt_structures/impls/graph_lowering/protocol.py +76 -0
  72. flashrt_structures/impls/graph_lowering/qwen3_vl.py +364 -0
  73. flashrt_structures/impls/linear_proj/__init__.py +0 -0
  74. flashrt_structures/impls/linear_proj/fp8_static.py +270 -0
  75. flashrt_structures/impls/linear_proj/nvfp4_balance.py +131 -0
  76. flashrt_structures/impls/linear_proj/nvfp4_dynamic.py +182 -0
  77. flashrt_structures/impls/linear_proj/w8a16_static.py +230 -0
  78. flashrt_structures/impls/modnorm_qkv_chain/__init__.py +0 -0
  79. flashrt_structures/impls/modnorm_qkv_chain/fp8_ptok_table.py +291 -0
  80. flashrt_structures/impls/moe_experts/__init__.py +9 -0
  81. flashrt_structures/impls/moe_experts/nvfp4_dynamic.py +208 -0
  82. flashrt_structures/impls/moe_experts/nvfp4_w4a16.py +129 -0
  83. flashrt_structures/impls/norm_fused/__init__.py +3 -0
  84. flashrt_structures/impls/norm_fused/bf16.py +94 -0
  85. flashrt_structures/impls/norm_fused/fp8_producer.py +84 -0
  86. flashrt_structures/impls/patch_projection/__init__.py +3 -0
  87. flashrt_structures/impls/patch_projection/bf16_flat.py +139 -0
  88. flashrt_structures/impls/prefill_tower/__init__.py +10 -0
  89. flashrt_structures/impls/prefill_tower/fp8_chain.py +955 -0
  90. flashrt_structures/impls/prefill_tower/region.py +99 -0
  91. flashrt_structures/impls/qk_norm_rope/__init__.py +12 -0
  92. flashrt_structures/impls/qk_norm_rope/per_head_gqa.py +199 -0
  93. flashrt_structures/impls/qk_norm_rope/projection_bf16.py +165 -0
  94. flashrt_structures/impls/qkv_pack/__init__.py +5 -0
  95. flashrt_structures/impls/qkv_pack/bf16.py +110 -0
  96. flashrt_structures/impls/qkv_pack/fp8_static.py +435 -0
  97. flashrt_structures/impls/qkv_pack/nvfp4_balance.py +218 -0
  98. flashrt_structures/impls/qkv_rope/__init__.py +3 -0
  99. flashrt_structures/impls/qkv_rope/packed_bias_bf16.py +143 -0
  100. flashrt_structures/impls/step_table.py +113 -0
  101. flashrt_structures/impls/vision_ffn/__init__.py +0 -0
  102. flashrt_structures/impls/vision_ffn/fp8_static.py +261 -0
  103. flashrt_structures/impls/vision_ffn/nvfp4_balance.py +211 -0
  104. flashrt_structures/impls/vision_tower/__init__.py +7 -0
  105. flashrt_structures/impls/vision_tower/fp8_chain.py +533 -0
  106. flashrt_structures/impls/vision_tower/region.py +91 -0
  107. flashrt_structures/matrix.py +126 -0
  108. flashrt_structures/points.py +368 -0
  109. flashrt_structures/prequantized.py +131 -0
  110. flashrt_structures/quantize_on_adopt.py +94 -0
  111. flashrt_structures/recipe.py +438 -0
  112. flashrt_structures/regions.py +208 -0
  113. flashrt_structures/schemes.py +490 -0
  114. flashrt_structures/stages.py +298 -0
  115. flashrt_structures/storage.py +255 -0
  116. flashrt_structures/swap.py +398 -0
  117. flashrt_structures/workspace.py +110 -0
  118. flashrt_structures-0.2.0.dist-info/METADATA +140 -0
  119. flashrt_structures-0.2.0.dist-info/RECORD +122 -0
  120. flashrt_structures-0.2.0.dist-info/WHEEL +5 -0
  121. flashrt_structures-0.2.0.dist-info/licenses/LICENSE +202 -0
  122. flashrt_structures-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,852 @@
1
+ """decode_loop — the whole-loop serving form for cached LLM decode.
2
+
3
+ Seam swaps accelerate the pieces; this family owns the loop. The host's
4
+ ``generate`` walks Python glue between every token, repoints its KV
5
+ cache with ``torch.cat``, and rebuilds masks per step — none of which
6
+ can replay inside a CUDA graph. The whole-step form drives the decoder
7
+ layers directly with static buffers:
8
+
9
+ - a duck-typed static hybrid cache (preallocated KV written by
10
+ ``index_copy_`` at an in-graph position buffer; recurrent/conv slots
11
+ as the layers expect them) — no host cache class is imported, only
12
+ the surface the layers actually touch is implemented;
13
+ - the causal row itself masks the padded tail of the static KV, so the
14
+ decode mask is one ``index_select`` on the position — no mask
15
+ bookkeeping;
16
+ - argmax and the position increment run in-graph, and the step is
17
+ optionally wrapped in ``torch.compile`` so the elementwise regions
18
+ between custom kernels fuse before capture.
19
+
20
+ The loop is greedy and exact: its tokens are gated against the host's
21
+ own generation by the caller's probe. Structure swaps (fused layers,
22
+ precision bands) compose underneath — build the loop after
23
+ ``auto_swaps`` and it captures whatever is attached.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import torch
29
+
30
+
31
+ class _StaticHybridCache:
32
+ """The surface a hybrid (attention + gated-delta) stack touches.
33
+
34
+ ``update`` serves attention layers from preallocated buffers;
35
+ ``conv_states``/``recurrent_states`` are plain per-layer slots the
36
+ gated-delta layers read and write; ``has_previous_state`` mirrors
37
+ the host convention (a filled conv slot means decode).
38
+ """
39
+
40
+ def __init__(self, n_layers, attn_layers, kv_heads, head_dim,
41
+ max_len, device, dtype=torch.bfloat16):
42
+ self.conv_states = [None] * n_layers
43
+ self.recurrent_states = [None] * n_layers
44
+ self.key_cache = [None] * n_layers
45
+ self.value_cache = [None] * n_layers
46
+ self._max = int(max_len)
47
+ self._cp = None
48
+ # true prompt-progress counter for HOST-side forwards: host glue
49
+ # branches on "is there history" (rope deltas, prefill vs
50
+ # continue); the loop's own fwd never consults it
51
+ self._seen = 0
52
+ for i in attn_layers:
53
+ self.key_cache[i] = torch.zeros(
54
+ 1, kv_heads, self._max, head_dim, device=device,
55
+ dtype=dtype)
56
+ self.value_cache[i] = torch.zeros_like(self.key_cache[i])
57
+
58
+ def update(self, k, v, layer_idx, cache_kwargs=None):
59
+ self.key_cache[layer_idx].index_copy_(2, self._cp, k)
60
+ self.value_cache[layer_idx].index_copy_(2, self._cp, v)
61
+ band = getattr(self, "frt_fp8_band", None)
62
+ if band is not None:
63
+ # dual-store: the same post-rope rows land in the FP8 pages
64
+ # the XQA read path consumes; BF16 stays the prefill arm
65
+ band.write(layer_idx, k, v, self._cp)
66
+ return self.key_cache[layer_idx], self.value_cache[layer_idx]
67
+
68
+ def get_seq_length(self, layer_idx=0):
69
+ return self._seen
70
+
71
+ def get_mask_sizes(self, query_length, layer_idx):
72
+ return self._max, 0
73
+
74
+ @property
75
+ def has_previous_state(self):
76
+ return any(s is not None for s in self.conv_states)
77
+
78
+
79
+ def _find_stack(model):
80
+ """Locate the decoder stack by the slots it must carry, not names."""
81
+ for mod in (getattr(model, "model", model),):
82
+ for cand in (getattr(mod, "language_model", None), mod):
83
+ if cand is None:
84
+ continue
85
+ if (hasattr(cand, "layers") and hasattr(cand, "embed_tokens")
86
+ and hasattr(cand, "norm")
87
+ and hasattr(cand, "rotary_emb")):
88
+ return cand
89
+ raise ValueError(
90
+ "refused: no decoder stack with (layers, embed_tokens, norm, "
91
+ "rotary_emb) slots found on this host")
92
+
93
+
94
+ class WholeStepDecodeLoop:
95
+ """Compiled, graph-captured greedy decode over the attached model."""
96
+
97
+ def __init__(self, model, *, max_len, compile_step=True,
98
+ compile_prefill=True, kv_band=None):
99
+ lm = _find_stack(model)
100
+ self._model = model
101
+ self._lm = lm
102
+ self._compile_prefill = bool(compile_prefill)
103
+ head = getattr(model, "lm_head", None)
104
+ if head is None:
105
+ raise ValueError("refused: host carries no lm_head")
106
+ self._layers = lm.layers
107
+ self._rotary = lm.rotary_emb
108
+ self._norm = lm.norm
109
+ self._embed = lm.embed_tokens
110
+ self._head = head
111
+ self._full = [i for i, lyr in enumerate(self._layers)
112
+ if hasattr(lyr, "self_attn")]
113
+ cfg = getattr(model.config, "text_config", model.config)
114
+ kvh = int(cfg.num_key_value_heads)
115
+ hd = int(getattr(cfg, "head_dim",
116
+ cfg.hidden_size // cfg.num_attention_heads))
117
+ dev = head.weight.device if hasattr(head, "weight") else "cuda"
118
+ # the static window rounds up to a 16-byte row: the fused
119
+ # attention kernels vector-read the mask row in aligned chunks,
120
+ # and an unaligned tail overreads into neighbouring allocations
121
+ # — outputs then drift with allocator contents (the 2K repeat
122
+ # gate caught it; compute-sanitizer named the aligned kernel).
123
+ # Rounding the window keeps mask and KV rows contiguous and
124
+ # aligned; the padded tail rows stay -inf-masked and zero.
125
+ self._max = -(-int(max_len) // 8) * 8
126
+ self.cache = _StaticHybridCache(
127
+ len(self._layers), self._full, kvh, hd, self._max, dev)
128
+ # the loop routes attention through its own interface even
129
+ # without a band: the maskless prompt pass needs the square
130
+ # causal slice to stay off SDPA's materialising math path
131
+ from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
132
+
133
+ from .fp8_kv import _INTERFACE_NAME, _interface
134
+ if _INTERFACE_NAME not in ALL_ATTENTION_FUNCTIONS:
135
+ ALL_ATTENTION_FUNCTIONS.register(_INTERFACE_NAME, _interface)
136
+ for i in self._full:
137
+ self._layers[i].self_attn.config._attn_implementation = \
138
+ _INTERFACE_NAME
139
+ self._kv_band = None
140
+ if kv_band == "fp8":
141
+ from .fp8_kv import install
142
+
143
+ self._kv_band = install(model, lm, self.cache, self._max)
144
+ if self._kv_band is None:
145
+ raise ValueError(
146
+ "refused: fp8 kv band serves the kernel's v1 head "
147
+ "profile (24/4/256); this host keeps BF16 KV")
148
+ elif kv_band is not None:
149
+ raise ValueError(f"refused: unknown kv band {kv_band!r}")
150
+ # no quadratic causal table: the decode mask is one static row
151
+ # rebuilt in-graph from the position buffer, the prompt pass
152
+ # rides SDPA's own causal flag (top-left alignment matches the
153
+ # window layout), and offset multi-row passes build their rows
154
+ # on demand — at deep windows the [max, max] table and the
155
+ # prefill's materialised [S, max] mask are gigabytes
156
+ self._ar = torch.arange(self._max, device=dev)
157
+ self._mrow = torch.zeros(1, 1, 1, self._max, device=dev,
158
+ dtype=torch.bfloat16)
159
+ self._cur = torch.empty(1, 1, dtype=torch.long, device=dev)
160
+ self._pos = torch.empty(1, dtype=torch.long, device=dev)
161
+ self._rope_delta = torch.zeros(1, dtype=torch.long, device=dev)
162
+ self._use_compile = bool(compile_step)
163
+ if compile_step:
164
+ # the layer loop specialises per layer index; the default
165
+ # recompile budget is smaller than a deep stack
166
+ torch._dynamo.config.cache_size_limit = max(
167
+ torch._dynamo.config.cache_size_limit,
168
+ 4 * len(self._layers))
169
+ self._step = torch.compile(self._fwd, dynamic=False)
170
+ else:
171
+ self._step = self._fwd
172
+ self._graph = None
173
+ self._aot_pf = None
174
+
175
+ def _fwd(self, tok_ids, pos_t):
176
+ h = self._embed(tok_ids)
177
+ # KV slot index and rotary position are different things on
178
+ # multimodal hosts: the host's rope delta shifts the latter
179
+ pe = self._rotary(h, (pos_t + self._rope_delta).view(1, -1))
180
+ self.cache._cp = pos_t
181
+ if self._kv_band is not None:
182
+ self._kv_band.set_len(pos_t[-1:] + 1)
183
+ if tok_ids.shape[1] == 1:
184
+ self._mrow.zero_()
185
+ self._mrow.masked_fill_(
186
+ (self._ar > pos_t[-1]).view(1, 1, 1, -1), float("-inf"))
187
+ m4 = self._mrow
188
+ else:
189
+ # prompt rows start at zero: SDPA's causal flag is this
190
+ # exact mask, with nothing materialised
191
+ m4 = None
192
+ for i, lyr in enumerate(self._layers):
193
+ h = lyr(h, position_embeddings=pe,
194
+ attention_mask=(m4 if i in self._full_set else None),
195
+ past_key_values=self.cache, use_cache=True,
196
+ cache_position=pos_t)
197
+ return self._head(self._norm(h)[:, -1:])
198
+
199
+ @property
200
+ def _full_set(self):
201
+ s = getattr(self, "_full_cache", None)
202
+ if s is None:
203
+ s = set(self._full)
204
+ self._full_cache = s
205
+ return s
206
+
207
+ def _prefill_callable(self):
208
+ """The prompt pass gets its own compile, pinned deterministic.
209
+
210
+ The fast step's inductor artifacts use non-deterministic
211
+ reductions that are harmless inside a captured replay but flip
212
+ long-prompt logits between runs — the 2K-context receipts
213
+ caught exactly that. Prefill therefore compiles separately with
214
+ the backend's deterministic mode, keeping the eager arm's
215
+ repeatability at compiled speed. Shapes specialise per prompt
216
+ length, same as the step.
217
+ """
218
+ if not self._compile_prefill:
219
+ return self._fwd
220
+ if getattr(self, "_pf", None) is None:
221
+ self._pf = torch.compile(self._fwd, dynamic=False,
222
+ options={"deterministic": True})
223
+ return self._pf
224
+
225
+ def _gstep(self):
226
+ logits = self._step(self._cur, self._pos)
227
+ # hosts sample from FP32 logits; argmax there too, or BF16
228
+ # ties break differently and free runs diverge
229
+ self._cur.copy_(logits.float().argmax(-1))
230
+ self._pos.add_(1)
231
+
232
+ @torch.no_grad()
233
+ def generate(self, input_ids, max_new_tokens):
234
+ """Greedy generation; the first call warms, compiles, captures."""
235
+ L = int(input_ids.shape[1])
236
+ if L + max_new_tokens > self._max:
237
+ raise ValueError(
238
+ f"refused: {L}+{max_new_tokens} exceeds the static "
239
+ f"window {self._max}")
240
+ if self._graph is None and not getattr(self, "_warming", False):
241
+ # the loop's true first pass is a throwaway: it compiles,
242
+ # captures, and settles the library workspaces whose
243
+ # content the earliest replays see differently from every
244
+ # later call. Returned generations all come from the
245
+ # settled regime, which repeats bitwise
246
+ self._warming = True
247
+ try:
248
+ self.generate(input_ids, max_new_tokens)
249
+ finally:
250
+ self._warming = False
251
+ self.cache.frt_continue = False
252
+ self._rope_delta.zero_()
253
+ if self._kv_band is not None:
254
+ self._kv_band.reset()
255
+ # canonical window: rows past the prompt zero before any
256
+ # produced token. Masked tail rows are not numerically inert
257
+ # in every attention backend, and the repeat gate compares
258
+ # calls whose tails would otherwise carry different residue
259
+ for i in self._full:
260
+ self.cache.key_cache[i][:, :, L:].zero_()
261
+ self.cache.value_cache[i][:, :, L:].zero_()
262
+ pf = self._aot_pf or self._prefill_callable()
263
+ logits = pf(input_ids,
264
+ torch.arange(L, device=input_ids.device))
265
+ self._cur.copy_(logits.float().argmax(-1))
266
+ self._pos.fill_(L)
267
+ toks = [self._cur.clone()]
268
+ self._decode_tail(max_new_tokens, toks)
269
+ return torch.cat([input_ids] + toks, dim=1)
270
+
271
+ def _decode_tail(self, max_new_tokens, toks):
272
+ warm = min(3, max_new_tokens - 1)
273
+ if self._graph is None:
274
+ # first capture takes the uniform path: an eager warmup
275
+ # step and a replayed step need not be bit-identical, so
276
+ # no produced token may come off the eager arm — the first
277
+ # call would disagree with every steady call at the first
278
+ # post-warmup token. Warm without producing tokens,
279
+ # capture, roll the state back, and produce every token
280
+ # from the replay. KV rows (BF16 and FP8 pages alike)
281
+ # rewrite the same slots on replay, so only the
282
+ # gated-delta slots and cur/pos need snapshots; those
283
+ # stage to host — this runs once, and the device is
284
+ # already packed to the rim.
285
+ snaps = [(i, self.cache.conv_states[i].to("cpu"),
286
+ self.cache.recurrent_states[i].to("cpu"))
287
+ for i in self._gdn_slots()]
288
+ cur0, pos0 = self._cur.clone(), self._pos.clone()
289
+ side = torch.cuda.Stream()
290
+ side.wait_stream(torch.cuda.current_stream())
291
+ with torch.cuda.stream(side):
292
+ for _ in range(warm):
293
+ self._gstep()
294
+ torch.cuda.current_stream().wait_stream(side)
295
+ self._graph = torch.cuda.CUDAGraph()
296
+ with torch.cuda.graph(self._graph):
297
+ self._gstep()
298
+ # prime the pool: capture records without executing, so
299
+ # the first replay ever reads workspace buffers no replay
300
+ # has written; its output is discarded and the state
301
+ # rolled back, so every produced token comes from a replay
302
+ # whose workspace a previous replay populated
303
+ self._graph.replay()
304
+ for i, cs, rs in snaps:
305
+ self.cache.conv_states[i].copy_(cs)
306
+ self.cache.recurrent_states[i].copy_(rs)
307
+ self._cur.copy_(cur0)
308
+ self._pos.copy_(pos0)
309
+ # the rollback extends to the written rows: warmup steps
310
+ # and the priming replay committed KV past the prompt, and
311
+ # the canonical window holds that region zero before
312
+ # produced tokens
313
+ p0 = int(pos0[0])
314
+ span = warm + 1
315
+ for i in self._full:
316
+ self.cache.key_cache[i][:, :, p0:p0 + span].zero_()
317
+ self.cache.value_cache[i][:, :, p0:p0 + span].zero_()
318
+ if self._kv_band is not None:
319
+ self._kv_band.clear_rows(
320
+ torch.arange(span, device=pos0.device) + pos0)
321
+ for _ in range(max_new_tokens - 1):
322
+ self._graph.replay()
323
+ toks.append(self._cur.clone())
324
+ else:
325
+ # steady calls run every decode step as a replay
326
+ for _ in range(max_new_tokens - 1):
327
+ self._graph.replay()
328
+ toks.append(self._cur.clone())
329
+
330
+ @torch.no_grad()
331
+ def aot_prefill(self, example_ids, package_path,
332
+ inductor_configs=None):
333
+ """Package the prompt pass whole and route ``generate`` through it.
334
+
335
+ S-specialised like the compiled prefill. The package is built
336
+ weights-external: the graph binary carries no constants, and at
337
+ load it borrows the live parameters and the loop's own KV rows
338
+ in place — a second copy of the weights never exists on the
339
+ card, and the packaged KV writes are writes into the cache the
340
+ captured decode step reads. The same-token gate downstream
341
+ checks exactly that hand-off.
342
+ """
343
+ if self._kv_band is not None:
344
+ raise ValueError(
345
+ "refused: aot_prefill serves the BF16-KV loop; the FP8 "
346
+ "band's prefill dual-store is not packaged yet")
347
+ from ...aot import aot_load, aot_package_external
348
+
349
+ dev = example_ids.device
350
+ vehicle = _PrefillVehicle(self)
351
+ pos = torch.arange(int(example_ids.shape[1]), device=dev)
352
+ self.cache.frt_continue = False
353
+ self._rope_delta.zero_()
354
+ try:
355
+ path, weights = aot_package_external(
356
+ vehicle, args=(example_ids, pos),
357
+ package_path=package_path,
358
+ inductor_configs=inductor_configs)
359
+ finally:
360
+ # tracing rebinds the cache lists through the vehicle's
361
+ # buffer attributes and leaves fake proxies behind; put the
362
+ # real tensors back whatever happened
363
+ for i in self._full:
364
+ self.cache.key_cache[i] = getattr(vehicle, f"frt_k{i}")
365
+ self.cache.value_cache[i] = getattr(vehicle, f"frt_v{i}")
366
+ self._aot_pf = aot_load(path, weights=weights)
367
+ return path
368
+
369
+ @torch.no_grad()
370
+ def generate_from(self, inputs, max_new_tokens):
371
+ """Host-side prefill, loop-side decode.
372
+
373
+ The host forward runs the whole multimodal front (vision tower,
374
+ embed merge, mrope) and writes its KV into the loop's cache;
375
+ the captured step takes over from the first generated token.
376
+ ``inputs`` is the processor's dict; batch of one, no padding.
377
+ """
378
+ inputs = {k: v for k, v in inputs.items()
379
+ if k != "attention_mask"}
380
+ ids = inputs["input_ids"]
381
+ L = int(ids.shape[1])
382
+ if L + max_new_tokens > self._max:
383
+ raise ValueError(
384
+ f"refused: {L}+{max_new_tokens} exceeds the static "
385
+ f"window {self._max}")
386
+ self.cache.frt_continue = False
387
+ self.cache._seen = 0 # host must take its prefill branch
388
+ for i in self._full:
389
+ self.cache.key_cache[i][:, :, L:].zero_()
390
+ self.cache.value_cache[i][:, :, L:].zero_()
391
+ cp = torch.arange(L, device=ids.device)
392
+ self.cache._cp = cp
393
+ out = self._model(**inputs, past_key_values=self.cache,
394
+ use_cache=True, cache_position=cp)
395
+ self.cache._seen = L
396
+ delta = getattr(
397
+ getattr(self._model, "model", self._model),
398
+ "rope_deltas", None)
399
+ self._rope_delta.fill_(
400
+ int(delta.reshape(-1)[0]) if torch.is_tensor(delta) else 0)
401
+ self._cur.copy_(out.logits[:, -1:].float().argmax(-1))
402
+ self._pos.fill_(L)
403
+ toks = [self._cur.clone()]
404
+ self._decode_tail(max_new_tokens, toks)
405
+ return torch.cat([ids] + toks, dim=1)
406
+
407
+
408
+ @torch.no_grad()
409
+ def enable_mtp(self, ckpt_dir=None, head=None,
410
+ projection_format=None, default_k=6,
411
+ verify_capture=True):
412
+ """Attach the checkpoint's draft head.
413
+
414
+ The draft's precision axes are explicit and answer to acceptance
415
+ length alone (the verify pass anchors the output either way):
416
+ ``projection_format`` picks the draft expert-bank arm —
417
+ ``"bf16"`` (default, conservative) or ``"nvfp4_dynamic"``
418
+ (measured AL-equal, smaller) — and the draft always carries a
419
+ private W8 view of the shared head, because the model's own
420
+ head must stay on the step/verify numeric family. Pass a
421
+ prebuilt ``head`` to load it early, while the device still has
422
+ assembly headroom; a prebuilt head already carries its formats
423
+ and a conflicting ``projection_format`` here is refused, not
424
+ silently ignored.
425
+
426
+ ``verify_capture=False`` keeps the M=K+1 verify and rewrite
427
+ passes eager — the diagnostic arm; the captured passes are the
428
+ production form."""
429
+ from .mtp_speculative import MtpDraftHead, check_draft_formats
430
+
431
+ if projection_format is not None:
432
+ check_draft_formats("w8a16_static", projection_format)
433
+ if head is not None and getattr(head, "formats", {}).get(
434
+ "experts") not in (None, projection_format):
435
+ raise ValueError(
436
+ f"refused: prebuilt draft head carries experts "
437
+ f"format {head.formats['experts']!r}, caller asked "
438
+ f"for {projection_format!r}")
439
+ slot = len(self._layers)
440
+ ref = self.cache.key_cache[self._full[0]]
441
+ self.cache.key_cache.append(torch.zeros_like(ref))
442
+ self.cache.value_cache.append(torch.zeros_like(ref))
443
+ self.cache.conv_states.append(None)
444
+ self.cache.recurrent_states.append(None)
445
+ if head is None:
446
+ head = MtpDraftHead(
447
+ self._model, self._lm, ckpt_dir, slot,
448
+ experts_format=(projection_format or "bf16"))
449
+ if head.slot != slot:
450
+ raise ValueError(
451
+ f"refused: draft head was built for slot {head.slot}, "
452
+ f"this loop's slot is {slot}")
453
+ self._mtp = head
454
+ self._mtp_formats = dict(getattr(head, "formats", {}))
455
+ self._default_k = int(default_k)
456
+ self._verify_capture = bool(verify_capture)
457
+ return self._mtp
458
+
459
+ def _fwd_full(self, tok_ids, pos_t):
460
+ h = self._embed(tok_ids)
461
+ pe = self._rotary(h, pos_t.view(1, -1))
462
+ self.cache._cp = pos_t
463
+ if self._kv_band is not None:
464
+ self._kv_band.set_len(pos_t[-1:] + 1)
465
+ m4 = self._row(pos_t)
466
+ for i, lyr in enumerate(self._layers):
467
+ h = lyr(h, position_embeddings=pe,
468
+ attention_mask=(m4 if i in self._full_set else None),
469
+ past_key_values=self.cache, use_cache=True,
470
+ cache_position=pos_t)
471
+ hn = self._norm(h)
472
+ return self._head(hn), hn
473
+
474
+ def _row(self, pos_t):
475
+ # offset multi-row mask (spec verify/rewrite): built on demand
476
+ # from the positions, a few rows at a time
477
+ return torch.zeros(
478
+ 1, 1, pos_t.shape[0], self._max, device=pos_t.device,
479
+ dtype=torch.bfloat16).masked_fill_(
480
+ (self._ar.view(1, -1) > pos_t.view(-1, 1)).view(
481
+ 1, 1, -1, self._max), float("-inf"))
482
+
483
+ def _gdn_slots(self):
484
+ return [i for i, sl in enumerate(self.cache.conv_states)
485
+ if torch.is_tensor(sl)]
486
+
487
+ def _ensure_spec_graphs(self, K, dev, snaps, h_last, tok, pos):
488
+ """Capture the K-step draft chain and the M=K+1 verify pass.
489
+
490
+ The warmup executions commit real state; the caller's snapshot
491
+ is restored before capture so the first replay starts clean.
492
+ """
493
+ if self._kv_band is not None:
494
+ # every spec shape's mask exists before the compiled passes
495
+ # trace: a lazy dict fill flips a guard between warmup and
496
+ # capture, and recompiling mid-capture is illegal
497
+ self._kv_band.prewarm(range(1, K + 2))
498
+ self._dh_buf = torch.empty(1, 1, self._embed.weight.shape[1],
499
+ device=dev, dtype=torch.bfloat16)
500
+ self._dtok_buf = torch.empty(1, 1, dtype=torch.long, device=dev)
501
+ self._dpos_buf = torch.empty(1, dtype=torch.long, device=dev)
502
+ self._dtoks_out = torch.empty(K, dtype=torch.long, device=dev)
503
+ self._vseq_buf = torch.empty(1, K + 1, dtype=torch.long,
504
+ device=dev)
505
+ self._vpos_buf = torch.empty(K + 1, dtype=torch.long, device=dev)
506
+ # warmup must run on live values — an uninitialised token
507
+ # buffer is an out-of-bounds embedding lookup
508
+ self._dh_buf.copy_(h_last)
509
+ self._dtok_buf.copy_(tok)
510
+ self._dpos_buf.fill_(pos)
511
+ self._vpos_buf.copy_(torch.arange(pos, pos + K + 1, device=dev))
512
+
513
+ def draft_chain():
514
+ for k in range(K):
515
+ lg, dh = self._mtp(self._dh_buf, self._dtok_buf,
516
+ self._dpos_buf, self.cache,
517
+ self._row(self._dpos_buf))
518
+ self._dh_buf.copy_(dh)
519
+ nxt = lg[:, -1].float().argmax(-1)
520
+ self._dtoks_out[k].copy_(nxt[0])
521
+ self._dtok_buf.copy_(nxt.view(1, 1))
522
+ self._dpos_buf.add_(1)
523
+
524
+ # the verify pass runs every round: when the step is compiled,
525
+ # verify takes the same compile-then-capture recipe — inductor's
526
+ # elementwise fusion is the same third it buys the plain step
527
+ if self._use_compile and getattr(self, "_fwd_full_c", None) is None:
528
+ self._fwd_full_c = torch.compile(self._fwd_full,
529
+ dynamic=False)
530
+ fwd_v = (self._fwd_full_c if self._use_compile
531
+ else self._fwd_full)
532
+
533
+ def verify():
534
+ lg, hn = fwd_v(self._vseq_buf, self._vpos_buf)
535
+ return lg, hn
536
+
537
+ side = torch.cuda.Stream()
538
+ side.wait_stream(torch.cuda.current_stream())
539
+ with torch.cuda.stream(side):
540
+ draft_chain()
541
+ self._vseq_buf[:, 0].copy_(tok[0])
542
+ self._vseq_buf[:, 1:].copy_(self._dtoks_out)
543
+ verify()
544
+ torch.cuda.current_stream().wait_stream(side)
545
+ for i, cs, rs in snaps:
546
+ self.cache.conv_states[i].copy_(cs)
547
+ self.cache.recurrent_states[i].copy_(rs)
548
+ def snap_states():
549
+ # the pre-round state snapshot rides inside the draft graph:
550
+ # sixty host-launched little copies a round otherwise sit
551
+ # squarely inside the per-round sync window
552
+ for i, (cb, rb) in self._snap_bufs.items():
553
+ cb.copy_(self.cache.conv_states[i])
554
+ rb.copy_(self.cache.recurrent_states[i])
555
+
556
+ self._dgraph = torch.cuda.CUDAGraph()
557
+ with torch.cuda.graph(self._dgraph):
558
+ snap_states()
559
+ draft_chain()
560
+ # rejected rounds re-advance the accepted prefix; each of the K
561
+ # possible prefix lengths is a fixed shape, so each gets its own
562
+ # captured pass — the reject path replays instead of paying an
563
+ # eager whole-model forward
564
+ self._ra_seq, self._ra_pos = {}, {}
565
+ self._ra_lg, self._ra_hn = {}, {}
566
+ self._ra_graphs = {}
567
+ for m in range(1, K + 1):
568
+ self._ra_seq[m] = torch.empty(1, m, dtype=torch.long,
569
+ device=dev)
570
+ self._ra_pos[m] = torch.empty(m, dtype=torch.long, device=dev)
571
+ self._ra_seq[m].copy_(self._vseq_buf[:, :m])
572
+ self._ra_pos[m].copy_(self._vpos_buf[:m])
573
+ side_ra = torch.cuda.Stream()
574
+ side_ra.wait_stream(torch.cuda.current_stream())
575
+ with torch.cuda.stream(side_ra):
576
+ for m in range(1, K + 1):
577
+ self._fwd_full(self._ra_seq[m], self._ra_pos[m])
578
+ torch.cuda.current_stream().wait_stream(side_ra)
579
+ for i, cs, rs in snaps:
580
+ self.cache.conv_states[i].copy_(cs)
581
+ self.cache.recurrent_states[i].copy_(rs)
582
+ for m in range(1, K + 1):
583
+ g = torch.cuda.CUDAGraph()
584
+ with torch.cuda.graph(g):
585
+ # the rollback restore rides in-graph too: the draft
586
+ # graph snapshotted these buffers before the round
587
+ for i, (cb, rb) in self._snap_bufs.items():
588
+ self.cache.conv_states[i].copy_(cb)
589
+ self.cache.recurrent_states[i].copy_(rb)
590
+ self._ra_lg[m], self._ra_hn[m] = self._fwd_full(
591
+ self._ra_seq[m], self._ra_pos[m])
592
+ self._ra_graphs[m] = g
593
+ # the draft-KV rewrite over a cut-short accepted region is the
594
+ # same story: K possible shapes, each captured once. The draft
595
+ # layer carries no gated-delta state, so no snapshot dance.
596
+ hdim = self._dh_buf.shape[-1]
597
+ self._rwm_h, self._rwm_graphs = {}, {}
598
+ side_rw = torch.cuda.Stream()
599
+ side_rw.wait_stream(torch.cuda.current_stream())
600
+ with torch.cuda.stream(side_rw):
601
+ for m in range(1, K + 1):
602
+ self._rwm_h[m] = torch.zeros(1, m, hdim, device=dev,
603
+ dtype=torch.bfloat16)
604
+ self._mtp(self._rwm_h[m], self._ra_seq[m],
605
+ self._ra_pos[m], self.cache,
606
+ self._row(self._ra_pos[m]))
607
+ torch.cuda.current_stream().wait_stream(side_rw)
608
+ for m in range(1, K + 1):
609
+ g = torch.cuda.CUDAGraph()
610
+ with torch.cuda.graph(g):
611
+ self._mtp(self._rwm_h[m], self._ra_seq[m],
612
+ self._ra_pos[m], self.cache,
613
+ self._row(self._ra_pos[m]))
614
+ self._rwm_graphs[m] = g
615
+ if not getattr(self, "_verify_capture", True):
616
+ # multi-token passes stay eager (MoE hosts: the T>1 expert
617
+ # path routes on the host); the draft chain above is the
618
+ # captured piece either way
619
+ self._vgraph = None
620
+ self._rwgraph = None
621
+ return
622
+ self._vgraph = torch.cuda.CUDAGraph()
623
+ with torch.cuda.graph(self._vgraph):
624
+ self._vlg, self._vhn = verify()
625
+ # full-accept rewrite pass is also fixed-shape (K+1 rows)
626
+ self._rwh_buf = torch.empty(1, K + 1, self._dh_buf.shape[-1],
627
+ device=dev, dtype=torch.bfloat16)
628
+ side2 = torch.cuda.Stream()
629
+ side2.wait_stream(torch.cuda.current_stream())
630
+ with torch.cuda.stream(side2):
631
+ self._mtp(self._rwh_buf.zero_(), self._vseq_buf,
632
+ self._vpos_buf, self.cache,
633
+ self._row(self._vpos_buf))
634
+ torch.cuda.current_stream().wait_stream(side2)
635
+ self._rwgraph = torch.cuda.CUDAGraph()
636
+ with torch.cuda.graph(self._rwgraph):
637
+ self._mtp(self._rwh_buf, self._vseq_buf, self._vpos_buf,
638
+ self.cache, self._row(self._vpos_buf))
639
+
640
+ def _seed_spec(self, h_last, tok, pos, K, dev, snaps):
641
+ self._ensure_spec_graphs(K, dev, snaps, h_last, tok, pos)
642
+ self._dh_buf.copy_(h_last)
643
+ self._dtok_buf.copy_(tok)
644
+ self._dpos_buf.fill_(pos)
645
+ self._dgraph.replay()
646
+
647
+ @torch.no_grad()
648
+ def generate_speculative(self, input_ids, max_new_tokens, K=None):
649
+ """Greedy MTP spec decode; exact vs plain greedy by verify.
650
+
651
+ ``K`` (draft chain length) defaults to the value set at
652
+ ``enable_mtp``; passing a different K rebuilds the captured
653
+ passes for the new shape."""
654
+ if getattr(self, "_mtp", None) is None:
655
+ raise ValueError("refused: enable_mtp first")
656
+ K = int(K) if K is not None else self._default_k
657
+ if getattr(self, "_spec_k", None) not in (None, K):
658
+ # the captured passes are shaped by K — rebuild, never
659
+ # replay a mismatched shape
660
+ self._dgraph = None
661
+ self._vgraph = None
662
+ self._rwgraph = None
663
+ self._ra_graphs = None
664
+ self._rwm_graphs = None
665
+ self._spec_k = K
666
+ dev = input_ids.device
667
+ L = int(input_ids.shape[1])
668
+ if L + max_new_tokens + K + 1 > self._max:
669
+ raise ValueError("refused: window too small for spec tail")
670
+ self.cache.frt_continue = False
671
+ logits, hn = self._fwd_full(
672
+ input_ids, torch.arange(L, device=dev))
673
+ self.cache.frt_continue = True
674
+ tok = logits[:, -1:].float().argmax(-1)
675
+ # draft KV over the prompt: position p keys on (h_{p-1}, y_p) —
676
+ # one multi-position pass over the whole prompt
677
+ if L > 1:
678
+ pr = torch.arange(1, L, device=dev)
679
+ self._mtp(hn[:, :L - 1], input_ids[:, 1:L], pr,
680
+ self.cache, self._row(pr))
681
+ produced = [tok]
682
+ pos = L
683
+ h_last = hn[:, -1:]
684
+ accepted_hist = []
685
+ self._dgraph = getattr(self, "_dgraph", None)
686
+ # snapshot buffers live across rounds: per-round clones would
687
+ # allocate sixty tensors a round for nothing
688
+ if getattr(self, "_snap_bufs", None) is None:
689
+ self._snap_bufs = {
690
+ i: (torch.empty_like(self.cache.conv_states[i]),
691
+ torch.empty_like(self.cache.recurrent_states[i]))
692
+ for i in self._gdn_slots()}
693
+ while len(produced) < max_new_tokens:
694
+ k_eff = min(K, max_new_tokens - len(produced))
695
+ if k_eff == K and getattr(self, "_dgraph", None) is not None:
696
+ # the draft graph snapshots in-graph on replay
697
+ snaps = [(i, cb, rb)
698
+ for i, (cb, rb) in self._snap_bufs.items()]
699
+ else:
700
+ snaps = []
701
+ for i, (cb, rb) in self._snap_bufs.items():
702
+ cb.copy_(self.cache.conv_states[i])
703
+ rb.copy_(self.cache.recurrent_states[i])
704
+ snaps.append((i, cb, rb))
705
+ if k_eff == K:
706
+ # captured fast path: draft chain replay + verify replay
707
+ if self._dgraph is None:
708
+ self._dh_buf_init = True
709
+ self._seed_spec(h_last, produced[-1], pos, K, dev,
710
+ snaps)
711
+ else:
712
+ self._dh_buf.copy_(h_last)
713
+ self._dtok_buf.copy_(produced[-1])
714
+ self._dpos_buf.fill_(pos)
715
+ self._dgraph.replay()
716
+ self._vseq_buf[:, 0].copy_(produced[-1][0])
717
+ self._vseq_buf[:, 1:].copy_(self._dtoks_out)
718
+ self._vpos_buf.copy_(torch.arange(
719
+ pos, pos + K + 1, device=dev))
720
+ if self._vgraph is not None:
721
+ self._vgraph.replay()
722
+ lg_v, hn_v = self._vlg, self._vhn
723
+ else:
724
+ lg_v, hn_v = self._fwd_full(self._vseq_buf,
725
+ self._vpos_buf)
726
+ dtoks = [self._dtoks_out[k].view(1, 1)
727
+ for k in range(K)]
728
+ seq = self._vseq_buf
729
+ else:
730
+ dtoks, dh, dtok = [], h_last, produced[-1]
731
+ for k in range(k_eff):
732
+ pt = torch.tensor([pos + k], device=dev)
733
+ lg, dh = self._mtp(dh, dtok, pt, self.cache,
734
+ self._row(pt))
735
+ dtok = lg[:, -1:].float().argmax(-1)
736
+ dtoks.append(dtok)
737
+ seq = torch.cat([produced[-1]] + dtoks,
738
+ dim=1)[:, :k_eff + 1]
739
+ pos_v = torch.arange(pos, pos + k_eff + 1, device=dev)
740
+ lg_v, hn_v = self._fwd_full(seq[:, :k_eff + 1], pos_v)
741
+ targets = lg_v.float().argmax(-1) # (1, k_eff+1)
742
+ # device-side prefix match: one sync per round, not one
743
+ # per accepted token
744
+ dstack = torch.cat([d.view(1) for d in dtoks])
745
+ j = int((dstack == targets[0, :k_eff]).cumprod(0)
746
+ .sum().item())
747
+ if j == k_eff:
748
+ # full acceptance: the verify pass committed exactly
749
+ # the accepted stream — no rollback, no second pass
750
+ bonus = targets[:, -1:].contiguous()
751
+ hn_x = hn_v
752
+ else:
753
+ # roll the gated-delta states back and re-advance the
754
+ # accepted prefix — linear states cannot unwind
755
+ m = j + 1
756
+ ra = getattr(self, "_ra_graphs", None)
757
+ if ra and m in ra:
758
+ # the rollback restore is captured at the head of
759
+ # the re-advance graph
760
+ self._ra_seq[m].copy_(seq[:, :m])
761
+ self._ra_pos[m].copy_(torch.arange(
762
+ pos, pos + m, device=dev))
763
+ ra[m].replay()
764
+ lg_a, hn_x = self._ra_lg[m], self._ra_hn[m]
765
+ else:
766
+ for i, cs, rs in snaps:
767
+ self.cache.conv_states[i].copy_(cs)
768
+ self.cache.recurrent_states[i].copy_(rs)
769
+ pos_a = torch.arange(pos, pos + m, device=dev)
770
+ lg_a, hn_x = self._fwd_full(seq[:, :m], pos_a)
771
+ bonus = lg_a[:, -1:].float().argmax(-1)
772
+ # draft KV for the accepted region keys on main hiddens —
773
+ # one multi-position pass; captured when the shape is the
774
+ # full-accept one
775
+ if (j == k_eff and k_eff == K
776
+ and getattr(self, "_rwgraph", None) is not None):
777
+ self._rwh_buf[:, 0].copy_(h_last[:, 0])
778
+ self._rwh_buf[:, 1:].copy_(hn_x[:, :K])
779
+ self._rwgraph.replay()
780
+ else:
781
+ m = j + 1
782
+ rw = getattr(self, "_rwm_graphs", None)
783
+ if rw and m in rw:
784
+ self._rwm_h[m][:, :1].copy_(h_last)
785
+ if j > 0:
786
+ self._rwm_h[m][:, 1:].copy_(hn_x[:, :j])
787
+ self._ra_seq[m].copy_(seq[:, :m])
788
+ self._ra_pos[m].copy_(torch.arange(
789
+ pos, pos + m, device=dev))
790
+ rw[m].replay()
791
+ else:
792
+ prev_rows = (torch.cat([h_last, hn_x[:, :j]], dim=1)
793
+ if j > 0 else h_last)
794
+ pos_rows = torch.arange(pos, pos + m, device=dev)
795
+ self._mtp(prev_rows, seq[:, :m], pos_rows,
796
+ self.cache, self._row(pos_rows))
797
+ for r in range(j):
798
+ produced.append(dtoks[r].clone())
799
+ produced.append(bonus.clone())
800
+ accepted_hist.append(j + 1)
801
+ pos += j + 1
802
+ h_last = hn_x[:, j:j + 1]
803
+ self.cache.frt_continue = False
804
+ out = torch.cat([input_ids] + produced, dim=1)
805
+ self.last_acceptance = (sum(accepted_hist) / len(accepted_hist)
806
+ if accepted_hist else 0.0)
807
+ return out[:, :L + max_new_tokens]
808
+
809
+
810
+ class _PrefillVehicle(torch.nn.Module):
811
+ """Export vehicle for the prompt pass.
812
+
813
+ The state the pass mutates — the static KV rows, the rope delta —
814
+ rides as registered buffers so functionalization records the writes
815
+ as buffer mutations instead of refusing them. The host model hangs
816
+ in the module hierarchy so every parameter exports under its own
817
+ FQN. Loaded with the package borrowing these very tensors, the
818
+ packaged writes land in the loop's own cache.
819
+ """
820
+
821
+ def __init__(self, loop):
822
+ super().__init__()
823
+ self.model = loop._model
824
+ self._frt_loop = loop
825
+ for i in loop._full:
826
+ self.register_buffer(f"frt_k{i}", loop.cache.key_cache[i])
827
+ self.register_buffer(f"frt_v{i}", loop.cache.value_cache[i])
828
+ self.register_buffer("frt_rope_delta", loop._rope_delta)
829
+
830
+ def forward(self, tok_ids, pos_t):
831
+ # the cache reaches its KV through a plain python object, which
832
+ # the exporter can only lift as an immutable constant; rerouted
833
+ # through this module's own attributes the same tensors carry a
834
+ # buffer source, and the prompt pass's index_copy_ becomes a
835
+ # legal buffer mutation. Outside tracing the getattr returns
836
+ # the very same tensors, so this is an identity rebind — but
837
+ # under fake tracing it leaves proxies in the cache lists,
838
+ # which ``aot_prefill`` restores right after export.
839
+ loop = self._frt_loop
840
+ for i in loop._full:
841
+ loop.cache.key_cache[i] = getattr(self, f"frt_k{i}")
842
+ loop.cache.value_cache[i] = getattr(self, f"frt_v{i}")
843
+ return loop._fwd(tok_ids, pos_t)
844
+
845
+
846
+ def build_decode_loop(model, *, max_len, compile_step=True,
847
+ compile_prefill=True, kv_band=None):
848
+ """Build the whole-loop form over whatever is attached to ``model``."""
849
+ return WholeStepDecodeLoop(model, max_len=max_len,
850
+ compile_step=compile_step,
851
+ compile_prefill=compile_prefill,
852
+ kv_band=kv_band)