File size: 64,727 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
"""Incremental decoding with the small KV cache, for batched multi-turn rollouts.

Cache layout (what makes it small):
  * global K/V: one per layer GROUP (written by the group's Full layer), grows with context;
  * local K/V: one ring buffer of `swa_window` slots per layer, constant size.
Each row has its own length, so episodes can be at different points: `append` takes a
right-padded chunk of new tokens per row (prefill, tool results, or one decoded token).

No host syncs inside a step: padded tokens are written to a scratch slot (last index) instead of
being filtered out with boolean masks, lengths are mirrored on the CPU, and attention is computed
directly against the cache with grouped queries (no K/V concatenation or head repetition).
"""
from __future__ import annotations

import math

import torch
import torch.nn.functional as F

from tiny_agent.model import TinyAgentLM, rms_norm

CHUNK = 64  # max new tokens per row processed at once (bounds the score tensor during prefill)


class KVCache:
    def __init__(self, model: TinyAgentLM, B: int, max_len: int, device, dtype=torch.bfloat16):
        c = model.cfg
        self.cfg, self.B, self.max_len, self.device = c, B, max_len, device
        G = math.ceil(c.n_layers / c.kv_group)
        Hk, D, W = c.n_kv_heads, c.head_dim, c.swa_window
        self.gk = torch.zeros(G, B, Hk, max_len + 1, D, device=device, dtype=dtype)   # +1 scratch slot
        self.gv = torch.zeros_like(self.gk)
        self.lk = torch.zeros(c.n_layers, B, Hk, W + 1, D, device=device, dtype=dtype)  # +1 scratch slot
        self.lv = torch.zeros_like(self.lk)
        self.lpos = torch.full((B, W + 1), -1, device=device, dtype=torch.long)
        self.lens = torch.zeros(B, device=device, dtype=torch.long)
        self.lens_cpu = torch.zeros(B, dtype=torch.long)
        self.tokens = torch.zeros(B, max_len + 1, device=device, dtype=torch.long)    # history (Engram)
        self.static_feed = None   # set by a GPU Decoder: append() replays captured graphs for short batch-1 chunks

    def nbytes(self) -> int:
        return sum(t.numel() * t.element_size() for t in (self.gk, self.gv, self.lk, self.lv))

    def reset(self, rows) -> None:
        """Empty these rows so a new sequence can start there (old K/V is masked by length)."""
        rows = torch.as_tensor(rows, dtype=torch.long)
        self.lens_cpu[rows] = 0
        rows = rows.to(self.device)
        self.lens[rows] = 0
        self.lpos[rows] = -1


def _rope_cs(x, cos, sin):
    """_rope_pos with the cos/sin rows already gathered (and cast to x's dtype): same values, fewer kernels."""
    x1, x2 = x.chunk(2, dim=-1)
    return torch.cat((x1 * cos - x2 * sin, x1 * sin + x2 * cos), dim=-1)


def _rope_pos(rope, x, pos):
    """x: (B, H, C, D); pos: (B, C) per-row absolute positions."""
    cos = rope.cos[pos][:, None].to(x.dtype)
    sin = rope.sin[pos][:, None].to(x.dtype)
    x1, x2 = x.chunk(2, dim=-1)
    return torch.cat((x1 * cos - x2 * sin, x1 * sin + x2 * cos), dim=-1)


# Queries per attention block in append(): bounds the mask / key copies of a long prefill. Attention itself is fused
# (scaled_dot_product_attention), so this is about speed: CPU likes small blocks, GPUs fewer launches.
QUERY_BLOCK = {"cpu": 256}
QUERY_BLOCK_DEFAULT = 1024
PAD_SHAPES = None      # None: pad on GPUs only (True: always, for tests)
KEY_ROUND = 256        # GPUs: global key ranges rounded up to this (stable shapes; the extra keys are masked)


def _mlp(mlp, x):
    """MLP.forward (down(relu(up(x))^2)) with the activation done in place: same values, two fewer big buffers."""
    u = mlp.up(x)
    torch.relu_(u)
    return mlp.down(u.mul_(u))


EXPLICIT_ATTN_MAX = 256


def _attn_explicit(q, K, V, m, Hk):
    """scaled_dot_product_attention(q, K, V, attn_mask=m, enable_gqa=True) as matmul + softmax (fp32 softmax).
    q (B, H, n, D), K/V (B, Hk, L, D), m (B, 1, n, L) bool."""
    B, H, n, D = q.shape
    rep = H // Hk
    s = (q.reshape(B, Hk, rep * n, D) @ K.transpose(-1, -2)).float().view(B, Hk, rep, n, -1)
    s = (s * (1.0 / math.sqrt(D))).masked_fill_(~m[:, :, None], float("-inf"))
    p = torch.softmax(s, dim=-1).to(V.dtype).view(B, Hk, rep * n, -1)
    return (p @ V).view(B, H, n, D)


def _block_size(dev, C: int, block=None) -> int:
    qb = int(block) if block else QUERY_BLOCK.get(torch.device(dev).type, QUERY_BLOCK_DEFAULT)
    return max(1, min(qb, C))


@torch.no_grad()
def append(model: TinyAgentLM, cache: KVCache, idx: torch.Tensor, n_new, rows=None, block=None) -> torch.Tensor:
    """Feed a right-padded chunk idx (B, C); row b has n_new[b] real tokens (0 = untouched).
    n_new may be a CPU tensor/list (preferred: avoids a device sync). Returns logits (B, V) at
    each row's last real token (unchanged garbage for rows with n_new == 0).
    rows: optional cache rows that idx refers to (a sub-batch, e.g. only rows that need prefill);
    by default idx covers all cache rows in order.

    One pass over all new tokens, like the training forward: every projection and MLP runs once over the whole
    chunk; attention is fused (scaled_dot_product_attention) and split into query blocks (block: queries per block,
    default QUERY_BLOCK), each attending to just the keys it can see: global keys up to its last position and the
    band of local keys of its windows. Same results as append_chunked up to float rounding (softmax/reduction
    order); same cache contents and layout."""
    n_cpu = torch.as_tensor(n_new, dtype=torch.long).cpu()
    B, C = idx.shape
    if rows is not None:
        rows = torch.as_tensor(rows, dtype=torch.long).cpu()
    lens_cpu = cache.lens_cpu if rows is None else cache.lens_cpu[rows]
    if int((lens_cpu + n_cpu).max()) > cache.max_len:
        raise RuntimeError("KV cache full")
    width = int(n_cpu.max()) if n_cpu.numel() else 0
    if width == 0:
        return None
    idx = idx[:, :width]
    sf = cache.static_feed
    if sf is not None and rows is None and B == 1 and width <= sf.max_c:
        return sf.feed(idx, width)
    key_round = 1
    if PAD_SHAPES if PAD_SHAPES is not None else idx.device.type != "cpu":
        # GPUs: pad the chunk to a power of two and round the global key range up, so attention / GEMM shapes repeat
        # (XPU builds an SDPA kernel per new shape: ~6 ms each, every turn). Padding is masked out and written to the
        # scratch slots, like any right padding.
        Cp = max(16, 1 << (width - 1).bit_length())
        if Cp > width:
            idx = torch.cat([idx, idx.new_zeros(B, Cp - width)], dim=1)
        key_round = KEY_ROUND
    return _append_all(model, cache, idx, n_cpu, rows, block, key_round)


def _append_all(model, cache: KVCache, idx, n_cpu, rows=None, block=None, key_round=1):
    c = model.cfg
    B, C = idx.shape
    dev = idx.device
    H, Hk, D, W = c.n_heads, c.n_kv_heads, c.head_dim, c.swa_window
    dt = cache.gk.dtype
    n = n_cpu.to(dev, non_blocking=True)
    if rows is None:
        rdev = torch.arange(B, device=dev)
        sel = lambda t: t                                                       # noqa: E731
        lens, lens_cpu = cache.lens, cache.lens_cpu
    else:
        rdev = rows.to(dev, non_blocking=True)
        sel = lambda t: t[rdev]                                                 # noqa: E731
        lens, lens_cpu = cache.lens[rdev], cache.lens_cpu[rows]
    ar = torch.arange(C, device=dev)
    valid = ar[None, :] < n[:, None]                                            # (B, C)
    pos = lens[:, None] + ar[None, :]                                           # (B, C)
    new_lens = lens + n
    new_lens_cpu = lens_cpu + n_cpu
    L0, Lg = int(lens_cpu.max()), int(new_lens_cpu.max())
    bidx = rdev[:, None].expand(B, C)
    gpos = torch.where(valid, pos, torch.full_like(pos, cache.max_len))        # scratch for padding
    keep = valid & (pos >= (new_lens[:, None] - W))                            # last W real tokens
    slot = torch.where(keep, pos % W, torch.full_like(pos, W))                 # scratch slot W

    addrs = {}
    if model.engram_modules():
        max_n = max(c.engram_orders)
        back = torch.arange(-(max_n - 1), 0, device=dev)
        prev_pos = lens[:, None] + back[None, :]
        prev = torch.where(prev_pos >= 0, sel(cache.tokens).gather(1, prev_pos.clamp_min(0)), torch.zeros_like(prev_pos))
        cids = model.cid_map[torch.cat([prev, idx], dim=1)]
        for i, b in enumerate(model.blocks):
            if b.engram is not None:
                addrs[i] = b.engram.addresses(cids)[:, max_n - 1:]
    cache.tokens[bidx, gpos] = idx

    # Local keys in position order: seq[j] = position lens - W + j, i.e. the ring's last W entries (j < W) then the new
    # tokens (j >= W). Query i (position lens + i) sees seq[i+1 .. i+W]: a band, so each query block only needs a
    # slice of it. Masks are the same in every layer: build them once.
    qb = _block_size(dev, C, block)
    wpos = lens[:, None] - W + torch.arange(W, device=dev)[None, :]           # (B, W) positions of the ring part
    ring_idx = wpos % W
    ring_ok = (wpos >= 0) & (sel(cache.lpos).gather(1, ring_idx) == wpos)      # slot really holds that position
    key_ok = torch.cat([ring_ok, valid], dim=1)                                 # (B, W + C)
    ring_gather = ring_idx[:, None, :, None].expand(B, Hk, W, D)
    blocks = []
    for s in range(0, C, qb):
        e = min(C, s + qb)
        lg = min(Lg, L0 + e)              # the last global key any query of this block can see
        lg = min(-(-lg // key_round) * key_round, cache.max_len)   # masked beyond the length
        pq = pos[:, s:e, None]
        kpos = torch.arange(lg, device=dev)
        gm = (kpos[None, None, :] <= pq) & (kpos[None, None, :] < new_lens[:, None, None])
        qi = torch.arange(s, e, device=dev)[:, None]
        j = torch.arange(s + 1, e + W, device=dev)[None, :]
        lm = ((j > qi) & (j <= qi + W))[None] & key_ok[:, None, s + 1:e + W]
        m = torch.cat([gm, lm], dim=2)
        m[:, :, 0] |= ~valid[:, s:e]       # padding queries: never an empty row (SDPA would return NaN)
        blocks.append((s, e, lg, m[:, None]))                                   # B,1,nq,L

    # short chunks on GPUs (a new turn, a tool result): explicit attention. XPU builds an SDPA kernel per shape.
    manual = dev.type != "cpu" and C <= EXPLICIT_ATTN_MAX
    x = model.embed(idx)
    rope = model.rope
    group = -1
    for li, blk in enumerate(model.blocks):
        if blk.engram is not None:
            x = x + blk.engram(x, addrs[li])
        a = blk.attn
        h = rms_norm(x)
        q = _rope_pos(rope, rms_norm(a.w_q(h).view(B, C, H, D).transpose(1, 2)), pos).to(dt)
        if a.mode == "full":
            group += 1
            k, v = a.w_kv_global(h).view(B, C, 2, Hk, D).permute(2, 0, 3, 1, 4)
            k = _rope_pos(rope, rms_norm(k), pos)
            cache.gk[group][bidx, :, gpos] = k.transpose(1, 2).to(dt)
            cache.gv[group][bidx, :, gpos] = v.transpose(1, 2).to(dt)
            if rows is None:
                Kg_all, Vg_all = cache.gk[group], cache.gv[group]
            else:
                lgmax = blocks[-1][2]
                Kg_all, Vg_all = cache.gk[group][rdev, :, :lgmax], cache.gv[group][rdev, :, :lgmax]
        kl, vl = a.w_kv_local(h).view(B, C, 2, Hk, D).permute(2, 0, 3, 1, 4)
        kl = _rope_pos(rope, rms_norm(kl), pos).to(cache.lk.dtype)
        vl = vl.to(cache.lv.dtype)
        seq_k = torch.cat([sel(cache.lk[li]).gather(2, ring_gather), kl], dim=2)   # (B, Hk, W + C, D)
        seq_v = torch.cat([sel(cache.lv[li]).gather(2, ring_gather), vl], dim=2)
        ys = []
        for s, e, lg, m in blocks:
            Kb = torch.cat([Kg_all[:, :, :lg], seq_k[:, :, s + 1:e + W]], dim=2)
            Vb = torch.cat([Vg_all[:, :, :lg], seq_v[:, :, s + 1:e + W]], dim=2)
            if manual:
                ys.append(_attn_explicit(q[:, :, s:e], Kb, Vb, m, Hk))
            else:
                ys.append(F.scaled_dot_product_attention(q[:, :, s:e], Kb, Vb, attn_mask=m, enable_gqa=True))
        y = ys[0] if len(ys) == 1 else torch.cat(ys, dim=2)
        y = y.transpose(1, 2).reshape(B, C, H * D)
        x = x + a.w_o(y.to(x.dtype))
        x = x + _mlp(blk.mlp, rms_norm(x))
        cache.lk[li][bidx, :, slot] = kl.transpose(1, 2)
        cache.lv[li][bidx, :, slot] = vl.transpose(1, 2)
    cache.lpos[bidx, slot] = torch.where(keep, pos, torch.full_like(pos, -1))
    if rows is None:            # in place: a captured decode graph holds these tensors
        cache.lens.copy_(new_lens)
        cache.lens_cpu.copy_(new_lens_cpu)
    else:
        cache.lens[rdev] = new_lens
        cache.lens_cpu[rows] = new_lens_cpu
    last = (n - 1).clamp_min(0)
    xl = x[torch.arange(B, device=dev), last]
    logits = model.head(rms_norm(xl)).float()
    sc = c.logit_softcap
    return sc * torch.tanh(logits / sc)


@torch.no_grad()
def append_chunked(model: TinyAgentLM, cache: KVCache, idx: torch.Tensor, n_new, rows=None) -> torch.Tensor:
    """Reference implementation (the pre-2026-10-18 serving path, kept for equivalence tests and benches):
    processes the new tokens in CHUNK-token pieces, every piece through all layers.
    Feed a right-padded chunk idx (B, C); row b has n_new[b] real tokens (0 = untouched).
    n_new may be a CPU tensor/list (preferred: avoids a device sync). Returns logits (B, V) at
    each row's last real token (unchanged garbage for rows with n_new == 0).
    rows: optional cache rows that idx refers to (a sub-batch, e.g. only rows that need prefill);
    by default idx covers all cache rows in order."""
    n_cpu = torch.as_tensor(n_new, dtype=torch.long).cpu()
    B, C = idx.shape
    if rows is not None:
        rows = torch.as_tensor(rows, dtype=torch.long).cpu()
    lens_cpu = cache.lens_cpu if rows is None else cache.lens_cpu[rows]
    if int((lens_cpu + n_cpu).max()) > cache.max_len:
        raise RuntimeError("KV cache full")
    out = None
    for s in range(0, C, CHUNK):
        n_sub = (n_cpu - s).clamp(0, CHUNK)
        if int(n_sub.max()) == 0:
            break
        width = int(n_sub.max())
        logits = _append_chunk(model, cache, idx[:, s:s + width], n_sub, rows)
        if out is None:
            out = logits
        else:
            out = torch.where((n_sub > 0).to(out.device)[:, None], logits, out)
    return out


def _append_chunk(model, cache: KVCache, idx, n_cpu, rows=None):
    c = model.cfg
    B, C = idx.shape
    dev = idx.device
    H, Hk, D, W = c.n_heads, c.n_kv_heads, c.head_dim, c.swa_window
    rep = H // Hk
    n = n_cpu.to(dev, non_blocking=True)
    # full batch: views of the cache; sub-batch: gathered copies of just those rows
    if rows is None:
        rdev = torch.arange(B, device=dev)
        sel = lambda t: t                                                       # noqa: E731
        lens, lens_cpu = cache.lens, cache.lens_cpu
    else:
        rdev = rows.to(dev, non_blocking=True)
        sel = lambda t: t[rdev]                                                 # noqa: E731
        lens, lens_cpu = cache.lens[rdev], cache.lens_cpu[rows]
    ar = torch.arange(C, device=dev)
    valid = ar[None, :] < n[:, None]                                            # (B, C)
    pos = lens[:, None] + ar[None, :]                                           # (B, C)
    new_lens = lens + n
    new_lens_cpu = lens_cpu + n_cpu
    Lg = int(new_lens_cpu.max())
    bidx = rdev[:, None].expand(B, C)
    gpos = torch.where(valid, pos, torch.full_like(pos, cache.max_len))        # scratch for padding
    keep = valid & (pos >= (new_lens[:, None] - W))                            # last W real tokens
    slot = torch.where(keep, pos % W, torch.full_like(pos, W))                 # scratch slot W

    # Engram addresses need the previous n-1 tokens of each row
    addrs = {}
    if model.engram_modules():
        max_n = max(c.engram_orders)
        back = torch.arange(-(max_n - 1), 0, device=dev)
        prev_pos = lens[:, None] + back[None, :]
        prev = torch.where(prev_pos >= 0, sel(cache.tokens).gather(1, prev_pos.clamp_min(0)), torch.zeros_like(prev_pos))
        cids = model.cid_map[torch.cat([prev, idx], dim=1)]
        for i, b in enumerate(model.blocks):
            if b.engram is not None:
                addrs[i] = b.engram.addresses(cids)[:, max_n - 1:]
    cache.tokens[bidx, gpos] = idx

    kpos = torch.arange(Lg, device=dev)
    gmask = (kpos[None, None, :] <= pos[:, :, None]) & (kpos[None, None, :] < new_lens[:, None, None])  # B,C,Lg
    lpos_all = torch.cat([sel(cache.lpos), torch.where(valid, pos, torch.full_like(pos, -1))], dim=1)  # B,W+1+C
    lmask = (lpos_all[:, None, :] >= 0) & (lpos_all[:, None, :] <= pos[:, :, None]) & \
            (pos[:, :, None] - lpos_all[:, None, :] < W)
    mask = torch.cat([gmask, lmask], dim=2)[:, None, None]                      # B,1,1,C,L
    neg = torch.finfo(torch.float32).min / 2
    scale = 1.0 / math.sqrt(D)

    x = model.embed(idx)
    rope = model.rope
    group = -1
    for li, blk in enumerate(model.blocks):
        if blk.engram is not None:
            x = x + blk.engram(x, addrs[li])
        a = blk.attn
        h = rms_norm(x)
        q = _rope_pos(rope, rms_norm(a.w_q(h).view(B, C, H, D).transpose(1, 2)), pos)
        if a.mode == "full":
            group += 1
            k, v = a.w_kv_global(h).view(B, C, 2, Hk, D).permute(2, 0, 3, 1, 4)
            k = _rope_pos(rope, rms_norm(k), pos)
            cache.gk[group][bidx, :, gpos] = k.transpose(1, 2).to(cache.gk.dtype)
            cache.gv[group][bidx, :, gpos] = v.transpose(1, 2).to(cache.gv.dtype)
        kl, vl = a.w_kv_local(h).view(B, C, 2, Hk, D).permute(2, 0, 3, 1, 4)
        kl = _rope_pos(rope, rms_norm(kl), pos).to(cache.lk.dtype)
        vl = vl.to(cache.lv.dtype)
        # grouped-query attention straight against the cache: queries (B, Hk, rep*C, D)
        q2 = q.to(cache.gk.dtype).reshape(B, Hk, rep * C, D)
        if rows is None:
            Kg, Vg = cache.gk[group][:, :, :Lg], cache.gv[group][:, :, :Lg]
        else:
            Kg, Vg = cache.gk[group][rdev, :, :Lg], cache.gv[group][rdev, :, :Lg]
        Kl = torch.cat([sel(cache.lk[li]), kl], dim=2)                          # B,Hk,W+1+C,D (small)
        Vl = torch.cat([sel(cache.lv[li]), vl], dim=2)
        s = torch.cat([q2 @ Kg.transpose(-1, -2), q2 @ Kl.transpose(-1, -2)], dim=-1).float() * scale
        s = s.view(B, Hk, rep, C, -1).masked_fill(~mask, neg)
        p = torch.softmax(s, dim=-1).to(Vg.dtype).view(B, Hk, rep * C, -1)
        y = p[..., :Lg] @ Vg + p[..., Lg:] @ Vl                                 # B,Hk,rep*C,D
        y = y.view(B, H, C, D).transpose(1, 2).reshape(B, C, H * D)
        x = x + a.w_o(y.to(x.dtype))
        x = x + blk.mlp(rms_norm(x))
        cache.lk[li][bidx, :, slot] = kl.transpose(1, 2)
        cache.lv[li][bidx, :, slot] = vl.transpose(1, 2)
    cache.lpos[bidx, slot] = torch.where(keep, pos, torch.full_like(pos, -1))
    if rows is None:
        cache.lens, cache.lens_cpu = new_lens, new_lens_cpu
    else:
        cache.lens[rdev] = new_lens
        cache.lens_cpu[rows] = new_lens_cpu
    last = (n - 1).clamp_min(0)
    xl = x[torch.arange(B, device=dev), last]
    logits = model.head(rms_norm(xl)).float()
    sc = c.logit_softcap
    return sc * torch.tanh(logits / sc)


def sample(logits: torch.Tensor, temperature: float = 1.0, generator=None) -> torch.Tensor:
    if temperature <= 0:
        return logits.argmax(-1)
    p = torch.softmax(logits / temperature, dim=-1)
    return torch.multinomial(p, 1, generator=generator).squeeze(-1)


def sample_top_p(logits: torch.Tensor, temperature: float, top_p: float, pen: float = 1.0, pen_mask=None,
                 ban_mask=None, generator=None) -> torch.Tensor:
    """moth's sampler, on the logits' device, no host sync: ban_mask -> -inf, repetition penalty on pen_mask (positive
    logits / pen, negative * pen), temperature, nucleus top_p, one multinomial draw. logits (1, V); masks (V,) bool or
    None. Returns a (1,) token tensor. Same values as moth.serve's per-token version; for vocabularies up to
    TOPK_SAMPLE_MIN also the same draws (bigger CPU vocabularies: _nucleus_draw_topk, same distribution).
    temperature <= 0: greedy (after the ban and the penalty)."""
    src = logits
    logits = logits.float()
    if ban_mask is not None:
        logits = logits.masked_fill(ban_mask, float("-inf"))
    if pen != 1.0 and pen_mask is not None:
        if logits.device.type == "cpu":   # only the penalised ids (a few hundred), not four passes over V
            ix = pen_mask.nonzero().squeeze(-1)
            if logits is src:
                logits = logits.clone()
            v = logits[..., ix]
            logits[..., ix] = torch.where(v > 0, v / pen, v * pen)
        else:
            logits = torch.where(pen_mask, torch.where(logits > 0, logits / pen, logits * pen), logits)
    if temperature <= 0:
        return logits.argmax(-1)
    p = torch.softmax(logits / temperature, dim=-1)
    if p.device.type == "cpu" and p.size(-1) > TOPK_SAMPLE_MIN:
        r = _nucleus_draw_topk(p, top_p, generator)
        if r is not None:
            return r
    sp, si = p.sort(dim=-1, descending=True)
    keep = sp.cumsum(-1) - sp < top_p
    sp = sp * keep
    return si[0, torch.multinomial(sp[0] / sp[0].sum(), 1, generator=generator)]


TOPK_SAMPLE_MIN = 4096   # CPU vocabularies above this: nucleus via topk (sorting 32k probs costs ~2 ms per token)


def _nucleus_draw_topk(p, top_p, generator):
    """sample_top_p's sort + multinomial draw without the full sort: the nucleus is the top-k prefix (k grown until the
    first dropped token is inside it), then one multinomial draw over those k. Same nucleus, same distribution; a
    seeded draw differs from the sorted path's (multinomial draws one exponential per entry: k instead of V, which
    also saves ~0.5 ms per token). None: the nucleus is most of the vocabulary (the caller sorts)."""
    V = p.size(-1)
    for k in (64, 1024):                                 # a flat distribution: two tries, then the sort
        if k * 4 > V:
            break
        sp, si = p.topk(k, dim=-1)                       # sorted, descending
        keep = sp.cumsum(-1) - sp < top_p
        if not bool(keep[0, -1]):
            sp = sp * keep
            return si[0, torch.multinomial(sp[0] / sp[0].sum(), 1, generator=generator)]
    return None


def _sample_icdf(logits, temperature, top_p, pen, pen_mask, ban_mask, u):
    """sample_top_p with the draw done by inverse CDF from a given uniform u (1,): graph-capturable (no RNG op
    inside the graph). Same distribution as sample_top_p."""
    logits = logits.float().masked_fill(ban_mask, float("-inf"))
    if pen != 1.0:
        logits = torch.where(pen_mask, torch.where(logits > 0, logits / pen, logits * pen), logits)
    if temperature <= 0:
        return logits.argmax(-1)
    p = torch.softmax(logits / temperature, dim=-1)
    sp, si = p.sort(dim=-1, descending=True)
    keep = sp.cumsum(-1) - sp < top_p
    cdf = (sp * keep).cumsum(-1)
    j = torch.searchsorted(cdf, (u * cdf[:, -1])[:, None], right=True).clamp_max(cdf.size(-1) - 1)
    return si.gather(-1, j).view(1)


def _phrase_ban_mask(out, i, ban_ctx, ban_tok, scratch, ar):
    """moth.norepeat.BanHook on the device, graph-capturable (fixed shapes, no host values): (1, V) bool, True for the
    ids that would complete a banned n-gram after the last k = ar.numel() sampled tokens out[i-k:i] (nothing before k
    tokens are sampled, as the hook). ban_ctx (cap, k) context rows (-1 = unused row), ban_tok (cap,) the banned id per
    row (V = unused, lands in scratch's extra slot), scratch (V + 1,) float work buffer, i (1,) long."""
    k = ar.numel()
    ctx = out.index_select(0, (i - k + ar).clamp_min(0))                      # (k,)
    hit = (ban_ctx == ctx[None]).all(-1) & (i >= k)                           # (cap,)
    scratch.zero_().index_add_(0, ban_tok, hit.to(scratch.dtype))
    return (scratch[:-1] > 0)[None]


def _decode_one(model, cache: KVCache, idx, L: int):
    """Append one token (idx (1, 1), device) to row 0 at host-known position L; returns softcapped logits (1, V).
    Exactly the ops and shapes append_chunked runs for a single token (bit-identical logits), minus its general
    batching/masking work: slices instead of scatter writes, the step mask built once."""
    c = model.cfg
    dev = idx.device
    H, Hk, D, W = c.n_heads, c.n_kv_heads, c.head_dim, c.swa_window
    rep = H // Hk
    dt = cache.gk.dtype
    rope = model.rope
    p2 = torch.full((1, 1), L, device=dev, dtype=torch.long)
    addrs = {}
    if model.engram_modules():
        max_n = max(c.engram_orders)
        prev = cache.tokens[:1, max(0, L - (max_n - 1)):L]
        if prev.size(1) < max_n - 1:
            prev = torch.cat([prev.new_zeros(1, max_n - 1 - prev.size(1)), prev], dim=1)
        cids = model.cid_map[torch.cat([prev, idx], dim=1)]
        for i, b in enumerate(model.blocks):
            if b.engram is not None:
                addrs[i] = b.engram.addresses(cids)[:, max_n - 1:]
    cache.tokens[0, L] = idx[0, 0]
    slot = L % W
    lp = torch.cat([cache.lpos[0], p2[0]])                                     # (W+2,)
    mask = torch.cat([torch.ones(L + 1, dtype=torch.bool, device=dev), (lp >= 0) & (lp <= L) & (L - lp < W)])
    inv = ~mask.view(1, 1, 1, 1, -1)
    neg = torch.finfo(torch.float32).min / 2
    scale = 1.0 / math.sqrt(D)
    x = model.embed(idx)
    group = -1
    for li, blk in enumerate(model.blocks):
        if blk.engram is not None:
            x = x + blk.engram(x, addrs[li])
        a = blk.attn
        h = rms_norm(x)
        q = _rope_pos(rope, rms_norm(a.w_q(h).view(1, 1, H, D).transpose(1, 2)), p2)
        if a.mode == "full":
            group += 1
            k, v = a.w_kv_global(h).view(1, 1, 2, Hk, D).permute(2, 0, 3, 1, 4)
            k = _rope_pos(rope, rms_norm(k), p2)
            cache.gk[group][:, :, L] = k[:, :, 0].to(dt)
            cache.gv[group][:, :, L] = v[:, :, 0].to(dt)
            Kg, Vg = cache.gk[group][:, :, :L + 1], cache.gv[group][:, :, :L + 1]
        kl, vl = a.w_kv_local(h).view(1, 1, 2, Hk, D).permute(2, 0, 3, 1, 4)
        kl = _rope_pos(rope, rms_norm(kl), p2).to(cache.lk.dtype)
        vl = vl.to(cache.lv.dtype)
        q2 = q.to(dt).reshape(1, Hk, rep, D)
        Kl = torch.cat([cache.lk[li][:1], kl], dim=2)
        Vl = torch.cat([cache.lv[li][:1], vl], dim=2)
        s = torch.cat([q2 @ Kg.transpose(-1, -2), q2 @ Kl.transpose(-1, -2)], dim=-1).float() * scale
        s = s.view(1, Hk, rep, 1, -1).masked_fill(inv, neg)
        p = torch.softmax(s, dim=-1).to(Vg.dtype).view(1, Hk, rep, -1)
        y = p[..., :L + 1] @ Vg + p[..., L + 1:] @ Vl
        y = y.view(1, H, 1, D).transpose(1, 2).reshape(1, 1, H * D)
        x = x + a.w_o(y.to(x.dtype))
        x = x + blk.mlp(rms_norm(x))
        cache.lk[li][:1, :, slot] = kl[:, :, 0]
        cache.lv[li][:1, :, slot] = vl[:, :, 0]
    cache.lpos[0, slot] = L
    cache.lens[0] += 1
    cache.lens_cpu[0] += 1
    logits = model.head(rms_norm(x[:, 0])).float()
    sc = c.logit_softcap
    return sc * torch.tanh(logits / sc)


class _StaticAux:
    """Per-model tensors for the fused graph step: per layer one weight for [q | k_local | k_global | v_local |
    v_global] (one GEMM instead of 2-3, and the q/k heads contiguous so one norm + one RoPE matmul covers them),
    plus the RoPE helper matrices. Built once per Decoder (an extra ~36 MB in bf16 for this model)."""

    def __init__(self, model: TinyAgentLM, cache: KVCache):
        c = model.cfg
        Hk, D = c.n_kv_heads, c.head_dim
        dev = cache.gk.device
        self.wf, self.nqk = [], []
        for blk in model.blocks:
            a = blk.attn
            wl = a.w_kv_local.weight
            parts_k, parts_v = [wl[:Hk * D]], [wl[Hk * D:]]
            if a.mode == "full":
                wg = a.w_kv_global.weight
                parts_k.append(wg[:Hk * D])
                parts_v.append(wg[Hk * D:])
            self.wf.append(torch.cat([a.w_q.weight] + parts_k + parts_v, dim=0).contiguous())
            self.nqk.append(c.n_heads + Hk * len(parts_k))
        self.kpos = torch.arange(cache.max_len, device=dev)
        self.ar = torch.arange(max(_StaticFeed.BUCKETS), device=dev)
        max_n = max(c.engram_orders) if model.engram_modules() else 1
        self.back = torch.arange(-(max_n - 1), 0, device=dev)
        half = D // 2
        dt = model.embed.weight.dtype
        self.eye = torch.eye(D, device=dev, dtype=dt)
        P = torch.zeros(D, D, device=dev, dtype=dt)
        j = torch.arange(half, device=dev)
        P[j + half, j] = -1
        P[j, j + half] = 1
        self.P = P


def _step_static(model, cache: KVCache, idx, n, aux: _StaticAux):
    """Append the first n of the C tokens idx (1, C) to row 0 with static shapes and no host values (CUDA/XPU graph
    capture). n (1,) long on the device; n == 0 is a no-op on the cache (writes go to the scratch slots, the length
    stays), so a captured decode loop can run past the end of a turn without touching the window. The position is
    cache.lens on the device; global attention runs over all max_len slots with a length mask. Returns softcapped
    logits (1, V) at the last real token.
    Fused for few kernels (launch overhead is the cost at batch 1): one QKV GEMM per layer, one norm and one RoPE
    over the q/k heads (a matmul when C == 1), baddbmm with an additive mask, residual adds inside addmm. Same math
    as append; results differ by rounding (GEMM shapes, RoPE as a matmul)."""
    c = model.cfg
    H, Hk, D, W = c.n_heads, c.n_kv_heads, c.head_dim, c.swa_window
    rep = H // Hk
    M = cache.max_len
    C = idx.size(1)
    dt = cache.gk.dtype
    rope = model.rope
    lens = cache.lens[:1]                                                        # (1,)
    ar = aux.ar[:C]
    pos = lens + ar                                                              # (C,)
    valid = ar < n
    new_len = lens + n
    gpos = torch.where(valid, pos, torch.full_like(pos, M))
    keep = valid & (pos >= new_len - W)
    slot = torch.where(keep, pos % W, torch.full_like(pos, W))
    lnew = torch.where(valid, pos, torch.full_like(pos, -1))
    addrs = {}
    if model.engram_modules():
        max_n = max(c.engram_orders)
        prev_pos = lens + aux.back                                               # (max_n - 1,)
        # index_select, not gather: XPU's gather kernel trips an index assert under graph replay
        prev = cache.tokens[0].index_select(0, prev_pos.clamp_min(0))
        prev = torch.where(prev_pos >= 0, prev, torch.zeros_like(prev))
        cids = model.cid_map[torch.cat([prev, idx[0]])][None]
        for i, b in enumerate(model.blocks):
            if b.engram is not None:
                addrs[i] = b.engram.addresses(cids)[:, max_n - 1:]
    cache.tokens[0].index_copy_(0, gpos, idx[0])
    lp = torch.cat([cache.lpos[0], lnew])                                       # (W+1+C,) local key positions
    pq = pos[:, None]
    mask = torch.cat([(aux.kpos[None, :] <= pq) & (aux.kpos[None, :] < new_len),
                      (lp[None, :] >= 0) & (lp[None, :] <= pq) & (pq - lp[None, :] < W)], dim=1)   # (C, L)
    mask[:, 0] |= ~valid                 # padding / no-op queries: never an all-masked row (NaN)
    x = model.embed(idx[0])                                                      # (C, d)
    xdt = x.dtype
    bias = torch.zeros(mask.shape, device=x.device, dtype=dt).masked_fill_(~mask, float("-inf"))
    bias = bias.repeat(rep, 1)[None]                                             # (1, rep*C, L), rows r*C + i
    if C == 1:
        cs, sn = rope.cos[pos].to(xdt), rope.sin[pos].to(xdt)                    # (1, D/2)
        R = aux.eye * torch.cat([cs, cs], dim=-1) + aux.P * torch.cat([sn, sn], dim=-1)   # x @ R == RoPE(x)
    else:
        cs, sn = rope.cos[pos][:, None].to(xdt), rope.sin[pos][:, None].to(xdt)  # (C, 1, D/2)
    scale = 1.0 / math.sqrt(D)
    group = -1
    for li, blk in enumerate(model.blocks):
        if blk.engram is not None:
            x = x + blk.engram(x[None], addrs[li])[0]
        a = blk.attn
        nqk = aux.nqk[li]
        heads = torch.nn.functional.linear(rms_norm(x), aux.wf[li]).view(C, -1, D)   # (C, nqk + nv, D)
        qk = rms_norm(heads[:, :nqk])
        qk = qk @ R if C == 1 else _rope_cs(qk, cs, sn)                          # normed + rotated q / k heads
        q = qk[:, :H].reshape(C, Hk, rep, D).permute(1, 2, 0, 3).reshape(Hk, rep * C, D)
        kl = qk[:, H:H + Hk].transpose(0, 1).to(cache.lk.dtype)                  # (Hk, C, D)
        if a.mode == "full":
            group += 1
            vl, vg = heads[:, nqk:nqk + Hk], heads[:, nqk + Hk:]
            cache.gk[group][0].index_copy_(1, gpos, qk[:, H + Hk:].transpose(0, 1).to(dt))
            cache.gv[group][0].index_copy_(1, gpos, vg.transpose(0, 1).to(dt))
            Kg, Vg = cache.gk[group][0, :, :M], cache.gv[group][0, :, :M]
        else:
            vl = heads[:, nqk:]
        vl = vl.transpose(0, 1).to(cache.lv.dtype)                               # (Hk, C, D)
        Kb = torch.cat([Kg, cache.lk[li][0], kl], dim=1)                         # (Hk, M + W + 1 + C, D)
        Vb = torch.cat([Vg, cache.lv[li][0], vl], dim=1)
        sc = torch.baddbmm(bias, q.to(dt), Kb.transpose(1, 2), alpha=scale)
        p = torch.softmax(sc, dim=-1, dtype=torch.float32).to(dt)
        y = torch.bmm(p, Vb).view(Hk, rep, C, D).permute(2, 0, 1, 3).reshape(C, H * D).to(xdt)
        x = torch.addmm(x, y, a.w_o.weight.t())
        u = torch.nn.functional.linear(rms_norm(x), blk.mlp.up.weight)
        torch.relu_(u)
        x = torch.addmm(x, u.mul_(u), blk.mlp.down.weight.t())
        cache.lk[li][0].index_copy_(1, slot, kl)
        cache.lv[li][0].index_copy_(1, slot, vl)
    cache.lpos[0].index_copy_(0, slot, torch.where(keep, pos, torch.full_like(pos, -1)))
    cache.lens.add_(n)
    xl = x.index_select(0, (n - 1).clamp_min(0))
    logits = model.head(rms_norm(xl)).float()
    sc = c.logit_softcap
    return sc * torch.tanh(logits / sc)


class _StaticFeed:
    """Graph-captured append() for one row-0 chunk of up to max(BUCKETS) tokens (a new turn, a tool result) on a
    CUDA/XPU cache: the chunk is padded to the next bucket, one graph per bucket, captured on first use. Attached to
    the cache by the Decoder (cache.static_feed); append() uses it for batch-1 chunks that fit."""
    BUCKETS = (16, 32, 64, 128, 256, 512)

    def __init__(self, model, cache: KVCache, aux: _StaticAux, capture: bool = True):
        self.model, self.cache, self.aux, self.capture = model, cache, aux, capture
        self.max_c = self.BUCKETS[-1]
        self.n = torch.zeros(1, dtype=torch.long, device=cache.gk.device)
        self.inp, self.out, self.g, self.fns = {}, {}, {}, {}

    def _graph(self, C):
        dev = self.cache.gk.device
        self.inp[C] = torch.zeros(1, C, dtype=torch.long, device=dev)
        model, cache, aux, inp, n = self.model, self.cache, self.aux, self.inp[C], self.n

        def fn():
            self.out[C] = _step_static(model, cache, inp, n, aux)

        self.fns[C] = fn
        if not self.capture:
            self.g[C] = fn
            return
        Graph, graph_ctx, Stream, stream_ctx, current = _graph_api(dev)
        n.zero_()                                    # warm-up / capture as no-ops: writes land in scratch slots
        s = Stream()
        s.wait_stream(current())
        with stream_ctx(s):
            for _ in range(2):
                fn()
        current().wait_stream(s)
        g = Graph()
        with graph_ctx(g):
            fn()
        self.g[C] = g.replay

    def feed(self, idx, w: int):
        C = next(b for b in self.BUCKETS if b >= w)
        if C not in self.g:
            self._graph(C)
        inp = self.inp[C]
        inp[:, :w].copy_(idx[:1, :w])
        if w < C:
            inp[:, w:].zero_()
        self.n.fill_(w)
        self.g[C]()
        self.cache.lens_cpu[0] += w
        return self.out[C].clone()


def _graph_api(dev: torch.device):
    if dev.type == "cuda":
        return torch.cuda.CUDAGraph, torch.cuda.graph, torch.cuda.Stream, torch.cuda.stream, torch.cuda.current_stream
    if dev.type == "xpu" and hasattr(torch.xpu, "XPUGraph"):
        return torch.xpu.XPUGraph, torch.xpu.graph, torch.xpu.Stream, torch.xpu.stream, torch.xpu.current_stream
    return None


class Decoder:
    """Batch-1 token generation on row 0 of a KVCache (moth.serve's turn loop).

    CPU (and graph=False): an eager loop, one host-sampled token per step; each step runs the exact op sequence of
    append_chunked for one token (bit-identical logits) and sample_top_p. For vocabularies up to TOPK_SAMPLE_MIN a
    seeded turn comes out token-for-token the same as the old per-token append loop; bigger CPU vocabularies (moth's
    32k) draw from the same distribution with a topk nucleus, so seeded draws differ from the old loop's. On the CPU a
    host read costs nothing. A quantize_cpu model (the CPU speed modes) steps through _decode_fast instead
    (Decoder.fast = False: _decode_one).
    CUDA/XPU (graph=True, the default there; graph="static" runs the same step uncaptured): one captured device
    graph per step = append the current token, sample the next one on the device (inverse-CDF draw from a pre-drawn
    uniform), store it; the host only reads the tokens back every sync_every steps. The decoder also attaches
    captured-graph feeds to the cache (feed=True), which append() then uses for short batch-1 chunks. After <|im_end|> the steps are no-ops on the cache (see _step_static), so the
    cache ends exactly as the eager loop leaves it. compile=True: torch.compile the step before capture (fewer,
    fused kernels).
    Keep one Decoder per KVCache: the graph is tied to that cache's tensors (reset() it instead of making a new one)."""

    def __init__(self, model: TinyAgentLM, cache: KVCache, graph=None, compile: bool = False, sync_every: int = 8,
                 feed: bool = True):
        assert cache.B == 1, "Decoder is batch 1"
        self.model, self.cache = model, cache
        self.dev = cache.gk.device
        api = _graph_api(self.dev)
        if graph == "static":           # the device-loop path without capture (tests on CPU; capture-free fallback)
            self.graph, self.capture = True, False
        else:
            self.graph = (api is not None) if graph is None else (bool(graph) and api is not None)
            self.capture = self.graph
        self.compile, self.sync_every = compile, max(1, int(sync_every))
        self._g = None
        self._key = None
        self.fallback = None
        self._aux = None
        self._tok = torch.zeros(1, 1, dtype=torch.long, device=self.dev)
        if self.graph and feed:          # append() of short chunks on this cache: captured graphs too
            cache.static_feed = _StaticFeed(model, cache, self._static_aux(), capture=self.capture)

    @torch.no_grad()
    def warmup(self, max_new: int, stop: int, temperature: float = 0.8, top_p: float = 0.95, pen: float = 1.0):
        """Capture the decode graph and every feed bucket now (about 0.15 s each on the B70), so the first turns
        don't pay for it. Safe on any cache state: warm-up and capture are no-ops on the cache."""
        if not self.graph:
            return
        sf = self.cache.static_feed
        if sf is not None:
            for C in sf.BUCKETS:
                if C not in sf.g:
                    sf._graph(C)
        if self._g is None or self._key[:3] != (max_new, (temperature, top_p, pen), stop):
            try:
                self._setup(max_new, temperature, top_p, pen, stop)
            except Exception as e:  # noqa: BLE001  the phrase-ban ops: retry with the plain step (as generate does)
                if not self.ban_graph:
                    raise
                import warnings
                warnings.warn(f"Decoder: device graph setup with the phrase ban failed ({type(e).__name__}: {e}); "
                              "phrase-ban turns decode eagerly")
                self.ban_graph, self._g = False, None
                self._setup(max_new, temperature, top_p, pen, stop)

    def _static_aux(self):
        if self._aux is None:
            self._aux = _StaticAux(self.model, self.cache)
        return self._aux

    fast = True   # CPU speed modes (quantize_cpu): step() runs the lean _decode_fast; False: _decode_one

    @torch.no_grad()
    def step(self, tok: int) -> torch.Tensor:
        """Append one token (host int); returns the logits (1, V) after it."""
        L = int(self.cache.lens_cpu[0])
        if L + 1 > self.cache.max_len:
            raise RuntimeError("KV cache full")
        self._tok.fill_(int(tok))
        fs = getattr(self.model, "_cpu_fast", None)
        if fs is not None and self.fast and self.dev.type == "cpu":
            return _decode_fast(self.model, self.cache, self._tok, L, fs)
        return _decode_one(self.model, self.cache, self._tok, L)

    def _masks(self, V, pen_ids, ban_ids):
        pm = torch.zeros(V, dtype=torch.bool)
        bm = torch.zeros(V, dtype=torch.bool)
        if pen_ids:
            pm[torch.as_tensor(list(pen_ids), dtype=torch.long)] = True
        if ban_ids:
            bm[torch.as_tensor(list(ban_ids), dtype=torch.long)] = True
        return pm.to(self.dev), bm.to(self.dev)

    @torch.no_grad()
    def generate(self, logits: torch.Tensor, max_new: int, stop: int, temperature: float = 0.8, top_p: float = 0.95,
                 pen: float = 1.0, pen_ids=(), ban_ids=(), generator=None, logits_hook=None) -> list[int]:
        """Sample up to max_new tokens starting from logits (1, V) (the prefill's), appending each one to the cache;
        stop (<|im_end|>) ends the turn and is appended too. A turn cut off at max_new gets stop appended so the cache
        stays well-formed. Returns the sampled tokens (stop included when sampled).
        logits_hook(out, logits) -> logits: called before every draw with the tokens sampled so far (moth.norepeat's
        phrase ban). It runs on the host: the eager loop calls it. A hook with a `table` ({context tuple of k ids:
        [banned next ids]}) and `k` (moth.norepeat.BanHook) also works in the device-graph path, as fixed-size device
        tensors matched on the device (_phrase_ban_mask), so it keeps the graphs; any other hook means the eager loop."""
        V = logits.size(-1)
        pm, bm = self._masks(V, pen_ids if pen != 1.0 else (), ban_ids)
        L = int(self.cache.lens_cpu[0])
        table = getattr(logits_hook, "table", None)
        graph_ok = logits_hook is None or (self.ban_graph and isinstance(table, dict)
                                           and isinstance(getattr(logits_hook, "k", None), int))
        if self.graph and graph_ok and L + max_new + 1 <= self.cache.max_len and max_new > 0:
            return self._generate_graph(logits, max_new, stop, temperature, top_p, pen, pm, bm, generator, logits_hook)
        pm_ = pm if pen_ids and pen != 1.0 else None
        bm_ = bm if ban_ids else None
        out = []
        for _ in range(max_new):
            if logits_hook is not None:
                logits = logits_hook(out, logits)
            t = int(sample_top_p(logits, temperature, top_p, pen, pm_, bm_, generator))
            out.append(t)
            logits = self.step(t)
            if t == stop:
                break
        if not out or out[-1] != stop:
            self.step(stop)
        return out

    # --- device graph path --------------------------------------------------------------------------------------
    BAN_CAP = 1024  # phrase-ban entries the captured graph holds (grown, with a re-capture, when a turn needs more)
    BAN_K = 2       # context length of the phrase ban (moth.norepeat.N - 1)
    ban_graph = True   # False after a capture with the ban ops failed: graphs without them, ban hooks run eagerly

    def _setup(self, max_new, temperature, top_p, pen, stop, ban_cap=None, ban_k=None):
        """ban_cap 0: the step without the phrase-ban ops (the plain body)."""
        dev, cache = self.dev, self.cache
        n = max_new + 2
        V = self.model.cfg.vocab_size
        ban_cap = (self.BAN_CAP if self.ban_graph else 0) if ban_cap is None else ban_cap
        ban_k = ban_k or self.BAN_K
        self._done = torch.ones(1, dtype=torch.bool, device=dev)
        self._i = torch.zeros(1, dtype=torch.long, device=dev)
        self._out = torch.zeros(n, dtype=torch.long, device=dev)
        self._u = torch.zeros(n, device=dev)
        self._pm = torch.zeros(1, V, dtype=torch.bool, device=dev)
        self._bm = torch.zeros_like(self._pm)
        # the phrase ban (a logits_hook with a table): rows of k context ids (-1: unused) -> banned id (V: unused)
        self._ban_ctx = torch.full((ban_cap, ban_k), -1, dtype=torch.long, device=dev)
        self._ban_tok = torch.full((ban_cap,), V, dtype=torch.long, device=dev)
        self._ban_scratch = torch.zeros(V + 1, device=dev)
        self._ban_ar = torch.arange(ban_k, device=dev)
        aux = self._static_aux()     # the graph reads these: kept alive on the decoder
        self._stop = stop
        samp = (temperature, top_p, pen)

        def body():
            live = ~self._done
            lg = _step_static(self.model, cache, self._tok, live.long(), aux)
            self._done |= self._tok[0] == stop
            u = self._u.index_select(0, self._i)
            bm = self._bm
            if ban_cap:
                bm = bm | _phrase_ban_mask(self._out, self._i, self._ban_ctx, self._ban_tok, self._ban_scratch,
                                           self._ban_ar)
            nxt = _sample_icdf(lg, *samp, self._pm, bm, u)
            self._out.index_copy_(0, self._i, nxt)
            self._i += 1
            self._tok.copy_(nxt.view(1, 1))

        fn = self._fn = torch.compile(body, fullgraph=False, dynamic=False) if self.compile else body
        self._key = (max_new, samp, stop, ban_cap, ban_k)
        if not self.capture:
            self._g = fn
            return
        Graph, graph_ctx, Stream, stream_ctx, current = _graph_api(dev)
        s = Stream()
        s.wait_stream(current())
        with stream_ctx(s):          # warm-up with done=True: every write lands in a scratch slot
            for _ in range(3):
                fn()
                self._i.zero_()
        current().wait_stream(s)
        g = Graph()
        with graph_ctx(g):
            fn()
        self._i.zero_()
        self._g = g.replay

    def clear_turn_state(self):
        """Forget what the last turn left in the device-graph buffers (moth.serve's Moth.forget): the phrase-ban
        table (reaction n-grams), the penalty / ban masks and the sampled tokens. In place, so the captured graphs
        stay valid and nothing is rebuilt; every turn refills these anyway, so decoding is unchanged. Eager-only
        decoders (CPU) hold none of it."""
        if self._key is None or getattr(self, "_ban_ctx", None) is None:
            return
        with torch.no_grad():
            self._ban_ctx.fill_(-1)
            self._ban_tok.fill_(self.model.cfg.vocab_size)
            self._pm.zero_()
            self._bm.zero_()
            self._out.zero_()

    @property
    def mode(self) -> str:
        """What generate() runs: "graph" (captured), "static" (uncaptured device loop) or "eager"."""
        if not self.graph:
            return "eager"
        return ("graph" if self.capture else "static") + ("+compile" if self.compile else "")

    def _generate_graph(self, logits, max_new, stop, temperature, top_p, pen, pm, bm, generator, hook=None):
        table = getattr(hook, "table", None) or {}
        rows = [(ctx, t) for ctx, toks in table.items() for t in toks]
        k = hook.k if table else (self._key[4] if self._key else self.BAN_K)
        if not self.ban_graph:
            cap = 0
        else:
            cap = (self._key[3] if self._key else 0) or self.BAN_CAP
            while cap < len(rows):
                cap *= 2
        if self._g is None or self._key != (max_new, (temperature, top_p, pen), stop, cap, k):
            ids = (lambda m: [i for i in range(m.numel()) if m[i]])   # noqa: E731
            try:
                self._setup(max_new, temperature, top_p, pen, stop, cap, k)
            except Exception as e:  # noqa: BLE001  e.g. an op this backend can't capture
                import warnings
                if cap:   # first without the phrase-ban ops: plain turns keep their graphs, ban hooks run eagerly
                    warnings.warn(f"Decoder: device graph setup with the phrase ban failed ({type(e).__name__}: {e}); "
                                  "phrase-ban turns decode eagerly")
                    self.ban_graph, self._g = False, None
                    return self.generate(logits, max_new, stop, temperature, top_p, pen,
                                         ids(pm) if pen != 1.0 else (), ids(bm), generator, hook)
                warnings.warn(f"Decoder: device graph setup failed ({type(e).__name__}: {e}); decoding eagerly")
                self.graph, self.fallback = False, f"{type(e).__name__}: {e}"[:300]
                self._g = None
                self._done = None
                return self.generate(logits, max_new, stop, temperature, top_p, pen,
                                     ids(pm) if pen != 1.0 else (), ids(bm), generator, hook)
        if cap:
            self._ban_ctx.fill_(-1)      # this turn's phrase ban, outside the graph (the graph reads these tensors)
            self._ban_tok.fill_(self.model.cfg.vocab_size)
        if cap and rows:
            self._ban_ctx[:len(rows)].copy_(torch.tensor([c for c, _ in rows], dtype=torch.long))
            self._ban_tok[:len(rows)].copy_(torch.tensor([t for _, t in rows], dtype=torch.long))
        self._pm.copy_(pm.view(1, -1))
        self._bm.copy_(bm.view(1, -1))
        self._u.copy_(torch.rand(self._u.shape, device=self.dev, generator=generator))
        self._i.zero_()
        first = _sample_icdf(logits, temperature, top_p, pen, self._pm, self._bm, self._u[:1])
        self._out[:1].copy_(first)
        self._tok.copy_(first.view(1, 1))
        self._i.fill_(1)
        self._done.zero_()
        n = 0
        toks = None
        while n < max_new:
            k = min(self.sync_every, max_new - n)
            for _ in range(k):
                self._g()
            n += k
            toks = self._out[:n].tolist()
            if stop in toks:
                break
        cache = self.cache
        if stop in toks:
            toks = toks[:toks.index(stop) + 1]
        else:                       # cut off: append stop (its sampled successor is ignored)
            self._tok.fill_(stop)
            self._g()
        self._done.fill_(True)
        cache.lens_cpu.copy_(cache.lens.cpu())
        return toks


# --- optional CPU speed modes (serving flag --quant); they change the logits slightly -----------------------------
# Batch-1 decode on a CPU is memory-bound: every token streams all Linear weights (136M params) once. Storing them in
# bf16 halves the bytes, int8 quarters them. The kernels take bf16 activations (fp32 accumulation inside); the
# residual stream, norms, attention, softmax and the KV cache stay fp32. Every quant Linear has mm(xb): bf16 (rows, in)
# -> bf16 (rows, out), the call _decode_fast makes.
QUANT_GEMV_ROWS = 8    # BF16Linear: up to this many rows use the bf16 kernel (on AVX2 bf16 GEMM is ~3x slower than fp32)
INT8_GEMV_ROWS = 128   # Int8Linear: up to this many rows use the int8 kernel; beyond, dequantize + fp32 GEMM (measured
                       # crossover on a Ryzen 5950X: the kernel costs per row, the GEMM per weight byte)
_SCRATCH_MAX = 16 << 20


def _scratch(shape, dtype):
    """A reused buffer for prefill's dequantized weights (no 10 MB malloc + page faults per call). Per thread."""
    import threading
    if not hasattr(_scratch, "tls"):
        _scratch.tls = threading.local()
    d = getattr(_scratch.tls, "d", None)
    if d is None:
        d = _scratch.tls.d = {}
    key = (tuple(shape), dtype)
    t = d.get(key)
    if t is None:
        t = torch.empty(shape, dtype=dtype)
        if t.numel() * t.element_size() <= _SCRATCH_MAX:
            d[key] = t
    return t


class BF16Linear(torch.nn.Module):
    """A Linear whose weight is stored in bf16: half the bytes to stream per token. The matmul runs on bf16 inputs
    (fp32 accumulation inside oneDNN), the output is returned in the input dtype."""

    def __init__(self, lin: torch.nn.Linear):
        super().__init__()
        self.in_features, self.out_features = lin.in_features, lin.out_features
        self.weight = torch.nn.Parameter(lin.weight.detach().to(torch.bfloat16), requires_grad=False)
        self.bias = None if lin.bias is None else torch.nn.Parameter(lin.bias.detach().to(torch.bfloat16),
                                                                      requires_grad=False)

    def mm(self, xb):
        return torch.nn.functional.linear(xb, self.weight, self.bias)

    def forward(self, x):
        if x.numel() // x.size(-1) > QUANT_GEMV_ROWS:   # prefill: fp32 GEMM on the upcast weight (no bf16 FMA on
            b = None if self.bias is None else self.bias.to(x.dtype)   # AVX2 CPUs: bf16 GEMM is ~3x slower)
            w = self.weight
            return torch.nn.functional.linear(x, _scratch(w.shape, x.dtype).copy_(w), b)
        return torch.nn.functional.linear(x.to(torch.bfloat16), self.weight, self.bias).to(x.dtype)


class Int8Linear(torch.nn.Module):
    """Weight-only int8 Linear: int8 weights with one scale per output channel (symmetric, max-abs; the scale is
    stored in bf16, the kernel's dtype, and the weights are rounded against that stored value), bf16 activations into
    aten._weight_int8pack_mm (dequantizes on the fly, fp32 accumulation). A quarter of the fp32 bytes per token.
    Activations are not quantized (dynamic activation quantization is lossy for this model's ReLU^2 MLP).
    More than INT8_GEMV_ROWS rows (long prefill): dequantize into a scratch buffer + fp32 GEMM."""

    def __init__(self, lin: torch.nn.Linear):
        super().__init__()
        w = lin.weight.detach().float()
        scale = (w.abs().amax(dim=1) / 127.0).clamp_min(1e-12).to(torch.bfloat16)
        q = torch.round(w / scale.float()[:, None]).clamp_(-127, 127).to(torch.int8)
        self.in_features, self.out_features = lin.in_features, lin.out_features
        self.register_buffer("qweight", q.contiguous())
        self.register_buffer("scale", scale.contiguous())
        self.bias = None if lin.bias is None else torch.nn.Parameter(lin.bias.detach().float(), requires_grad=False)

    @property
    def weight(self):   # dequantized copy, for code that reads .weight (the fused GPU step; not used on CPU)
        return self.qweight.float() * self.scale.float()[:, None]

    def mm(self, xb):
        y = torch.ops.aten._weight_int8pack_mm(xb, self.qweight, self.scale)
        return y if self.bias is None else y + self.bias.to(y.dtype)

    def forward(self, x):
        shape = x.shape
        if x.numel() // shape[-1] > INT8_GEMV_ROWS:
            w = torch.mul(self.qweight, self.scale.float()[:, None],
                          out=_scratch(self.qweight.shape, torch.float32)).to(x.dtype)
            return torch.nn.functional.linear(x, w, None if self.bias is None else self.bias.to(x.dtype))
        y = self.mm(x.reshape(-1, shape[-1]).to(torch.bfloat16).contiguous())
        return y.view(*shape[:-1], self.out_features).to(x.dtype)


class _FullMM(torch.nn.Module):
    """mm() for a Linear left in full precision (quantize_cpu(head=False))."""

    def __init__(self, lin):
        super().__init__()
        self.lin = lin

    def mm(self, xb):
        return torch.nn.functional.linear(xb.to(self.lin.weight.dtype), self.lin.weight, self.lin.bias)


QUANT_CLASSES = {"int8": Int8Linear, "bf16": BF16Linear}


def int8_kernel_ok() -> bool:
    """Does this torch build run aten._weight_int8pack_mm on bf16 inputs (and get it right)? Checked once."""
    if not hasattr(int8_kernel_ok, "ok"):
        try:
            g = torch.Generator().manual_seed(0)
            w = torch.randint(-127, 128, (64, 64), dtype=torch.int8, generator=g)
            s = torch.rand(64, generator=g).add_(0.5).to(torch.bfloat16)
            x = torch.randn(2, 64, generator=g).to(torch.bfloat16)
            y = torch.ops.aten._weight_int8pack_mm(x, w, s).float()
            ref = x.float() @ (w.float() * s.float()[:, None]).t()
            int8_kernel_ok.ok = bool(torch.allclose(y, ref, rtol=0.02, atol=0.02 * float(ref.abs().max())))
        except Exception:  # noqa: BLE001  op missing / unsupported dtype on this build
            int8_kernel_ok.ok = False
    return int8_kernel_ok.ok


def quantize_cpu(model: TinyAgentLM, mode: str, head: bool = True) -> TinyAgentLM:
    """In place. mode "int8": weight-only int8 Linear layers (Int8Linear, per-channel scales, bf16 activations).
    "bf16": Linear weights stored in bf16 (BF16Linear). Embeddings, Engram tables, norms, RoPE and the KV cache stay
    as they are. head=False keeps the output head in full precision. Use an fp32 KV cache with these.
    Also builds model._cpu_fast, the packed weights of the lean CPU decode step (_decode_fast, used by Decoder.step)."""
    if mode in (None, "", "none", "fp32"):
        return model
    cls = QUANT_CLASSES.get(mode)
    if cls is None:
        raise ValueError(f"unknown quant mode {mode!r} (none, {', '.join(QUANT_CLASSES)})")
    if cls is Int8Linear and not int8_kernel_ok():
        import warnings
        warnings.warn("this PyTorch build has no CPU int8 kernel for bf16 inputs (aten._weight_int8pack_mm); "
                      "using --quant bf16 instead")
        mode, cls = "bf16", BF16Linear
    fused = []
    for blk in model.blocks:   # [q | k_local | k_global | v_local | v_global]: per-row formats, so fusing is exact
        a = blk.attn
        Hk, D = model.cfg.n_kv_heads, model.cfg.head_dim
        wl = a.w_kv_local.weight.detach()
        ks, vs = [wl[:Hk * D]], [wl[Hk * D:]]
        if a.mode == "full":
            wg = a.w_kv_global.weight.detach()
            ks.append(wg[:Hk * D])
            vs.append(wg[Hk * D:])
        lin = torch.nn.Linear(model.cfg.d_model, 1, bias=False)
        lin.weight = torch.nn.Parameter(torch.cat([a.w_q.weight.detach()] + ks + vs).float(), requires_grad=False)
        fused.append(cls(lin))
    targets = [(name, mod) for name, mod in model.named_modules() if isinstance(mod, torch.nn.Linear)
               and (head or name != "head")]
    for name, mod in targets:
        parent = model.get_submodule(name.rsplit(".", 1)[0]) if "." in name else model
        setattr(parent, name.rsplit(".", 1)[-1], cls(mod))
    model._cpu_fast = _CpuFast(model, fused)
    model._quant = mode
    return model


class _CpuFast:
    """What _decode_fast needs besides the model: the fused QKV weights (one weight stream and one kernel call per
    layer instead of 2-3; the q/k heads contiguous, so one norm and one RoPE matmul cover them), per-head norm
    divisors, the RoPE helper matrices and a bf16 activation buffer. The fused weights duplicate w_q/w_kv_* (prefill
    keeps using those): +12 MB in int8."""

    def __init__(self, model: TinyAgentLM, fused):
        c = model.cfg
        H, Hk, D = c.n_heads, c.n_kv_heads, c.head_dim
        self.qkv = fused
        self.nqk, self.ndiv = [], []
        for blk in model.blocks:
            nk = 2 if blk.attn.mode == "full" else 1
            self.nqk.append(H + Hk * nk)
            # q heads: divide by |q| (= rms_norm(q) / sqrt(D): the attention's 1/sqrt(D) folded in);
            # k heads: by |k| / sqrt(D) (= rms_norm(k), what the cache holds)
            self.ndiv.append(torch.cat([torch.ones(H, 1), torch.full((Hk * nk, 1), D ** -0.5)]))
        self.head = model.head if hasattr(model.head, "mm") else _FullMM(model.head)
        self.inv_sqrt_d = c.d_model ** -0.5
        half = D // 2
        self.eye = torch.eye(D)
        P = torch.zeros(D, D)
        j = torch.arange(half)
        P[j + half, j] = -1
        P[j, j + half] = 1
        self.P = P
        self.act = getattr(type(fused[0]), "ACT_DTYPE", torch.bfloat16)   # what mm() takes (tests: float64)
        self.hb = torch.empty(1, c.d_model, dtype=self.act)


def _decode_fast(model, cache: KVCache, idx, L: int, fs: _CpuFast):
    """_decode_one for a quantize_cpu model, with far fewer ops (at batch 1 on a CPU every op costs a few us of
    dispatch, about as much as the weights take to stream): fused QKV, norms as x / |x| (one vector_norm), RoPE as one
    64x64 matmul for all q/k heads, the token's K/V written into the cache first so attention reads the cache directly
    (no K/V concatenation), each Linear fed from one bf16 buffer and added into the fp32 residual in place.
    Same math as _decode_one up to float rounding (norm epsilon, summation order)."""
    c = model.cfg
    H, Hk, D, W = c.n_heads, c.n_kv_heads, c.head_dim, c.swa_window
    rep = H // Hk
    bf = fs.act
    vnorm = torch.linalg.vector_norm
    addrs = {}
    if model.engram_modules():
        max_n = max(c.engram_orders)
        prev = cache.tokens[:1, max(0, L - (max_n - 1)):L]
        if prev.size(1) < max_n - 1:
            prev = torch.cat([prev.new_zeros(1, max_n - 1 - prev.size(1)), prev], dim=1)
        cids = model.cid_map[torch.cat([prev, idx], dim=1)]
        for i, b in enumerate(model.blocks):
            if b.engram is not None:
                addrs[i] = b.engram.addresses(cids)[:, max_n - 1:]
    cache.tokens[0, L] = idx[0, 0]
    slot = L % W
    cache.lpos[0, slot] = L
    lp = cache.lpos[0, :W]
    ok = (lp >= 0) & (L - lp < W)                     # ring slots holding a position of this token's window
    lbias = None if bool(ok.all()) else torch.zeros(W).masked_fill_(~ok, float("-inf"))
    cs, sn = model.rope.cos[L], model.rope.sin[L]
    R = fs.eye * torch.cat([cs, cs]) + fs.P * torch.cat([sn, sn])   # x @ R == RoPE(x) at position L
    lk, lv, gk, gv = cache.lk[:, 0], cache.lv[:, 0], cache.gk[:, 0], cache.gv[:, 0]
    lk_new, lv_new, gk_new, gv_new = lk[:, :, slot], lv[:, :, slot], gk[:, :, L], gv[:, :, L]
    Vl, Vg_all = lv[:, :, :W], gv[:, :, :L + 1]
    KlT, KgT_all = lk[:, :, :W].transpose(-1, -2), gk[:, :, :L + 1].transpose(-1, -2)
    kdt = cache.gk.dtype
    hb, inv = fs.hb, fs.inv_sqrt_d
    x = model.embed.weight[idx[0]]                                               # (1, d), a copy
    xdt = x.dtype
    if R.dtype != xdt:
        R = R.to(xdt)
    group = -1
    for li, blk in enumerate(model.blocks):
        if blk.engram is not None:
            x = x + blk.engram(x[None], addrs[li])[0]
        torch.div(x, vnorm(x, dim=-1, keepdim=True).mul_(inv), out=hb)           # rms_norm(x), in bf16
        heads = fs.qkv[li].mm(hb).view(-1, D)                                    # (nqk + nv, D), bf16
        nqk = fs.nqk[li]
        qk = heads[:nqk]                                                         # / an fp32 norm: fp32 result
        qk = (qk / vnorm(qk, dim=-1, keepdim=True, dtype=xdt).mul_(fs.ndiv[li])) @ R   # normed + rotated q / k
        q = qk[:H].view(Hk, rep, D)
        lk_new[li].copy_(qk[H:H + Hk])
        lv_new[li].copy_(heads[nqk:nqk + Hk])                                    # copy_ casts to the cache dtype
        if blk.attn.mode == "full":
            group += 1
            gk_new[group].copy_(qk[H + Hk:nqk])
            gv_new[group].copy_(heads[nqk + Hk:])
            KgT, Vg = KgT_all[group], Vg_all[group]
        if q.dtype != kdt:
            q = q.to(kdt)
        sl = torch.bmm(q, KlT[li])                                               # (Hk, rep, W) local
        if lbias is not None:
            sl.add_(lbias)
        p = torch.softmax(torch.cat([torch.bmm(q, KgT), sl], dim=-1), dim=-1, dtype=torch.float32)
        p = p.to(kdt)
        y = torch.bmm(p[..., :L + 1], Vg).baddbmm_(p[..., L + 1:], Vl[li])        # (Hk, rep, D)
        x.add_(blk.attn.w_o.mm(y.view(1, H * D).to(bf)))
        torch.div(x, vnorm(x, dim=-1, keepdim=True).mul_(inv), out=hb)
        u = blk.mlp.up.mm(hb)
        torch.relu_(u)
        x.add_(blk.mlp.down.mm(u.mul_(u)))
    cache.lens[0] += 1
    cache.lens_cpu[0] += 1
    torch.div(x, vnorm(x, dim=-1, keepdim=True).mul_(inv), out=hb)
    logits = fs.head.mm(hb).float()
    sc = c.logit_softcap
    return sc * torch.tanh(logits / sc)