File size: 40,413 Bytes
6c91dd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import math
import os
import torch
from typing import Tuple, Optional, Dict
from functools import partial

# Lazy import: CuTe kernel only available on Hopper (sm90+)
_CUTE_AVAILABLE = None


def _check_cute_available():
    global _CUTE_AVAILABLE
    if _CUTE_AVAILABLE is not None:
        return _CUTE_AVAILABLE
    try:
        from flash_attn.cute.interface import _flash_attn_fwd
        from flash_attn.cute.compute_block_sparsity import compute_block_sparsity
        cap = torch.cuda.get_device_capability()
        if cap[0] >= 9:  # sm90+ (Hopper)
            _CUTE_AVAILABLE = True
        else:
            _CUTE_AVAILABLE = False
    except (ImportError, ModuleNotFoundError):
        _CUTE_AVAILABLE = False
    return _CUTE_AVAILABLE


SUMMARY_ATTN_CACHE: Dict[Tuple, Tuple] = {}
HIERARCHICAL_SUMMARY_ATTN_CACHE: Dict[Tuple, Tuple] = {}


# ---------------------------------------------------------------------------
# CuTe kernel path (Hopper only)
# ---------------------------------------------------------------------------

class SummaryAttnFunc(torch.autograd.Function):
    @staticmethod
    def _get_cached_value(
        seqlen_q, seqlen_k, chunk_size, summary_num, sliding_chunk_num,
        skip_old_summary=False, is_fwd=True, m_block_size=128, n_block_size=128,
    ):
        from flash_attn.cute.compute_block_sparsity import compute_block_sparsity
        from .mask import (
            summary_attn_mask_func, summary_attn_contiguous_mask_func,
            sliding_chunk_mask_func, sliding_chunk_contiguous_mask_func,
        )

        prefix_summary_length = seqlen_k - seqlen_q
        cache_key = (seqlen_q, chunk_size, summary_num, sliding_chunk_num,
                     prefix_summary_length, skip_old_summary, is_fwd)
        if cache_key not in SUMMARY_ATTN_CACHE:
            if skip_old_summary:
                func = sliding_chunk_contiguous_mask_func if prefix_summary_length > 0 else sliding_chunk_mask_func
            else:
                func = summary_attn_contiguous_mask_func if prefix_summary_length > 0 else summary_attn_mask_func
            mask_mod = partial(func, chunk_size=chunk_size, summary_num=summary_num,
                               sliding_chunk_num=sliding_chunk_num,
                               prefix_summary_length=prefix_summary_length, swap_indices=False)
            mask_swap_mod = mask_mod
            if not is_fwd:
                mask_swap_mod = partial(func, chunk_size=chunk_size, summary_num=summary_num,
                                        sliding_chunk_num=sliding_chunk_num,
                                        prefix_summary_length=prefix_summary_length, swap_indices=True)
                m_block_size, n_block_size = n_block_size, m_block_size
                seqlen_q, seqlen_k = seqlen_k, seqlen_q
            _, block_sparse = compute_block_sparsity(
                m_block_size, n_block_size, 1, 1, seqlen_q, seqlen_k,
                mask_swap_mod, None, "cuda",
            )
            SUMMARY_ATTN_CACHE[cache_key] = (block_sparse, mask_mod)
        return SUMMARY_ATTN_CACHE[cache_key]

    @staticmethod
    def forward(ctx, q, k, v, chunk_size, summary_num, sliding_chunk_num,
                softmax_scale=None, causal=False, pack_gqa=None,
                deterministic=False, skip_old_summary=False,
                force_return_lse=False):
        from flash_attn.cute.interface import _flash_attn_fwd
        block_sparse, mask_mod = SummaryAttnFunc._get_cached_value(
            q.shape[1], k.shape[1], chunk_size, summary_num, sliding_chunk_num,
            skip_old_summary=skip_old_summary, is_fwd=True, m_block_size=128, n_block_size=64,
        )
        out, lse = _flash_attn_fwd(q, k, v, softmax_scale=softmax_scale, causal=causal,
                                     pack_gqa=pack_gqa, mask_mod=mask_mod,
                                     block_sparse_tensors=block_sparse,
                                     m_block_size=128, n_block_size=64,
                                     return_lse=force_return_lse)
        return out, lse



# ---------------------------------------------------------------------------
# Compressed-KSA CuTe path (Hopper only)
# ---------------------------------------------------------------------------

class HierarchicalSummaryAttnFunc(torch.autograd.Function):
    @staticmethod
    def _get_cached_value(
        device_index, seqlen_q, seqlen_k, chunk_size, sliding_chunk_num,
        group_size, meta_length, *, is_fwd,
        m_block_size, n_block_size,
    ):
        from flash_attn.cute.compute_block_sparsity import compute_block_sparsity
        from .mask import hierarchical_summary_attn_mask_func

        cache_key = (
            device_index, seqlen_q, seqlen_k, chunk_size, sliding_chunk_num,
            group_size, meta_length, is_fwd,
        )
        cached = HIERARCHICAL_SUMMARY_ATTN_CACHE.get(cache_key)
        if cached is not None:
            return cached

        mask_mod = partial(
            hierarchical_summary_attn_mask_func,
            chunk_size=chunk_size,
            sliding_chunk_num=sliding_chunk_num,
            group_size=group_size,
            meta_length=meta_length,
            swap_indices=False,
        )
        sparsity_mask_mod = mask_mod
        sparsity_seqlen_q, sparsity_seqlen_k = seqlen_q, seqlen_k
        if not is_fwd:
            sparsity_mask_mod = partial(
                hierarchical_summary_attn_mask_func,
                chunk_size=chunk_size,
                sliding_chunk_num=sliding_chunk_num,
                group_size=group_size,
                meta_length=meta_length,
                swap_indices=True,
            )
            m_block_size, n_block_size = n_block_size, m_block_size
            sparsity_seqlen_q, sparsity_seqlen_k = seqlen_k, seqlen_q

        _, block_sparse = compute_block_sparsity(
            m_block_size,
            n_block_size,
            1,
            1,
            sparsity_seqlen_q,
            sparsity_seqlen_k,
            sparsity_mask_mod,
            None,
            "cuda",
        )
        cached = (block_sparse, mask_mod)
        HIERARCHICAL_SUMMARY_ATTN_CACHE[cache_key] = cached
        return cached

    @staticmethod
    def forward(
        ctx, q, k, v, meta_k, meta_v,
        chunk_size, sliding_chunk_num, group_size,
        softmax_scale=None, deterministic=False, mass_correction=True,
        force_return_lse=False,
    ):
        from flash_attn.cute.interface import _flash_attn_fwd

        from .mask import (
            hierarchical_summary_attn_score_mod,
        )

        # Keep one physical entry per meta K/V and apply log(G) directly to
        # its score. This preserves the mass correction without undoing the
        # sequence compression inside the attention kernel.
        needs_backward = any(
            tensor.requires_grad for tensor in (q, k, v, meta_k, meta_v)
        )
        meta_length = meta_k.shape[1]
        full_k = torch.cat((meta_k, k), dim=1).contiguous()
        full_v = torch.cat((meta_v, v), dim=1).contiguous()
        meta_logit_bias = math.log(group_size)
        score_mod = None
        if mass_correction:
            score_mod = partial(
                hierarchical_summary_attn_score_mod,
                meta_length=meta_length,
                meta_logit_bias=meta_logit_bias,
            )
        block_sparse, mask_mod = HierarchicalSummaryAttnFunc._get_cached_value(
            q.device.index,
            q.shape[1],
            full_k.shape[1],
            chunk_size,
            sliding_chunk_num,
            group_size,
            meta_length,
            is_fwd=True,
            m_block_size=128,
            n_block_size=64,
        )
        out, lse = _flash_attn_fwd(
            q,
            full_k,
            full_v,
            softmax_scale=softmax_scale,
            causal=False,
            pack_gqa=None,
            score_mod=score_mod,
            mask_mod=mask_mod,
            block_sparse_tensors=block_sparse,
            m_block_size=128,
            n_block_size=64,
            # torch.autograd.Function.forward runs with grad recording
            # disabled.  Therefore full_k/full_v created above do not carry
            # requires_grad even when meta_k/meta_v are the only trainable
            # inputs (the meta-pool-only warm-up case).  Force LSE retention
            # based on the original inputs so custom backward has the state
            # it needs without adding inference-only storage.
            return_lse=needs_backward or force_return_lse,
        )
        return out, lse



# ---------------------------------------------------------------------------
# Per-query attention-reuse S-KSA CuTe path (Hopper only)
# ---------------------------------------------------------------------------





# ---------------------------------------------------------------------------
# Summary-indexed, 8-token block sparse Full attention (Hopper only)
# ---------------------------------------------------------------------------

def _validate_summary_block_sparse_inputs(
    q,
    k,
    v,
    selected_block_indices,
    block_size_tokens,
    local_window_tokens,
    local_alignment_tokens=None,
):
    if q.ndim != 4 or k.ndim != 4 or v.ndim != 4:
        raise ValueError("q, k, and v must have [batch, text, heads, head_dim]")
    if q.shape[0] != k.shape[0] or k.shape != v.shape:
        raise ValueError("q, k, and v batch/KV shapes must agree")
    if q.shape[1] != k.shape[1]:
        raise ValueError(
            "summary_block_sparse_attn_func currently requires equal Q/K text lengths"
        )
    if selected_block_indices.ndim != 3:
        raise ValueError("selected_block_indices must have [batch, query, topk]")
    if tuple(selected_block_indices.shape[:2]) != tuple(q.shape[:2]):
        raise ValueError("one selected-block row is required per text query")
    if selected_block_indices.dtype not in (torch.int32, torch.int64):
        raise ValueError("selected_block_indices must be int32 or int64")
    if not isinstance(block_size_tokens, int) or block_size_tokens <= 0:
        raise ValueError("block_size_tokens must be a positive integer")
    if block_size_tokens not in (1, 2, 4, 8):
        raise ValueError("route span must be 1, 2, 4, or 8 tokens")
    if not isinstance(local_window_tokens, int) or local_window_tokens <= 0:
        raise ValueError("local_window_tokens must be a positive integer")
    if local_alignment_tokens is None:
        local_alignment_tokens = block_size_tokens
    if (
        not isinstance(local_alignment_tokens, int)
        or local_alignment_tokens <= 0
        or local_alignment_tokens % block_size_tokens
    ):
        raise ValueError(
            "local_alignment_tokens must be a positive multiple of the "
            "route span"
        )
    if q.shape[-1] != k.shape[-1]:
        raise ValueError("Q and K head dimensions must agree")
    if q.shape[2] % k.shape[2] != 0:
        raise ValueError("the number of Q heads must be divisible by KV heads")
    if not all(t.is_cuda for t in (q, k, v, selected_block_indices)):
        raise RuntimeError("summary_block_sparse_attn_func requires CUDA tensors")
    if not (q.device == k.device == v.device == selected_block_indices.device):
        raise ValueError("q, k, v, and selected_block_indices must share a device")

    if os.environ.get("SKSA_VALIDATE_BLOCK_INDICES", "0") == "1":
        raw_block_count = math.ceil(k.shape[1] / block_size_tokens)
        # This diagnostic intentionally synchronises.  The production path
        # relies on the indexer's TopK contract and remains asynchronous.
        invalid = (selected_block_indices < -1) | (
            selected_block_indices >= raw_block_count
        )
        if bool(invalid.any().item()):
            raise ValueError(
                f"selected block IDs must be -1 or in [0, {raw_block_count})"
            )


def _pack_summary_block_indices(
    selected_block_indices,
    *,
    raw_block_count,
    max_workspace_elements=16 * 1024 * 1024,
):
    """Pack ``[B,Q,K]`` unique block IDs into an 8x smaller bit route.

    Top-k is expected to produce unique non-negative IDs per row.  Repeated
    IDs would make scatter-add carry into a neighbouring bit and are rejected
    by the public wrapper before this helper is called.
    """
    batch, query_count, _ = selected_block_indices.shape
    # CuTe's DLPack bridge cannot infer a unique leading dimension for a
    # rank-3 auxiliary tensor whose final extent is 1 (both trailing strides
    # then equal one).  One unused padding byte keeps short smoke/decode-like
    # sequences on the same code path without changing any route bit.
    route_byte_count = max(2, math.ceil(raw_block_count / 8))
    packed = torch.zeros(
        (batch, query_count, route_byte_count),
        dtype=torch.uint8,
        device=selected_block_indices.device,
    )
    query_chunk = max(
        1,
        max_workspace_elements // max(batch * route_byte_count, 1),
    )
    for query_start in range(0, query_count, query_chunk):
        query_end = min(query_start + query_chunk, query_count)
        indices = selected_block_indices[:, query_start:query_end]
        valid = indices >= 0
        safe_indices = indices.clamp_min(0).to(torch.long)
        byte_indices = safe_indices // 8
        bit_values = (
            torch.ones_like(safe_indices, dtype=torch.int32)
            << (safe_indices % 8).to(torch.int32)
        )
        bit_values = bit_values * valid.to(torch.int32)
        workspace = torch.zeros(
            (batch, query_end - query_start, route_byte_count),
            dtype=torch.int32,
            device=indices.device,
        )
        workspace.scatter_add_(2, byte_indices, bit_values)
        packed[:, query_start:query_end] = workspace.to(torch.uint8)
    return packed.contiguous()


def _check_unique_summary_block_indices(selected_block_indices):
    """Reject duplicate valid IDs because the packed route uses scatter-add."""
    if selected_block_indices.shape[-1] <= 1:
        return
    sorted_indices = selected_block_indices.sort(dim=-1).values
    duplicate = (
        (sorted_indices[..., 1:] == sorted_indices[..., :-1])
        & (sorted_indices[..., 1:] >= 0)
    )
    if bool(duplicate.any().item()):
        raise ValueError("valid selected block IDs must be unique within each query")


def _summary_block_sparse_tensors(
    selected_block_indices,
    *,
    text_length,
    block_size_tokens,
    local_window_tokens,
    local_alignment_tokens=None,
    query_tile_size=128,
    forward_kv_tile_size=64,
    backward_kv_tile_size=64,
    packed_route=None,
):
    """Build conservative CuTe schedules from compact block IDs.

    Forward uses a narrower 64-token KV execution tile.  This retains the
    cross-query reuse of a 128-token Q tile while reducing over-computation
    when only some of the eight 8-token raw blocks in a 64-token KV tile are
    selected.  The model-local FlashAttention wheel supports a 64-token SM90
    backward KV tile, so the same schedule can be transposed directly instead
    of conservatively merging adjacent forward tiles.  Passing 128 remains
    available for numerical and performance A/B tests.

    Remote tiles are always marked partial because every query row may have a
    different route.  The exact per-query mask is still applied inside both
    kernels; these schedules only decide which execution tiles are launched.
    """
    from flash_attn.cute.block_sparsity import BlockSparseTensorsTorch

    if query_tile_size != 128:
        raise ValueError("SM90 S-KSA schedules currently require 128-token Q tiles")
    if forward_kv_tile_size != 64:
        raise ValueError("SM90 S-KSA forward currently requires 64-token KV tiles")
    if backward_kv_tile_size not in (64, 128):
        raise ValueError("SM90 S-KSA backward requires a 64- or 128-token KV tile")
    if backward_kv_tile_size % forward_kv_tile_size != 0:
        raise ValueError("backward KV tile must be a multiple of forward KV tile")
    if local_alignment_tokens is None:
        local_alignment_tokens = block_size_tokens

    batch, route_length, _ = selected_block_indices.shape
    if route_length != text_length:
        raise ValueError("one selected-block row is required per text query")
    q_tile_count = math.ceil(text_length / query_tile_size)
    forward_kv_tile_count = math.ceil(text_length / forward_kv_tile_size)
    backward_kv_tile_count = math.ceil(text_length / backward_kv_tile_size)
    padded_query_count = q_tile_count * query_tile_size

    if packed_route is not None:
        # Reuse the already-built bitmap instead of traversing
        # [B,Q,TopK] again.  An 8-token route uses one byte per 64-token KV
        # tile; a token route uses eight bytes per tile.
        if forward_kv_tile_size % (block_size_tokens * 8):
            raise ValueError(
                "a forward KV tile must contain a whole number of route bytes"
            )
        route_bytes_per_kv_tile = (
            forward_kv_tile_size // (block_size_tokens * 8)
        )
        required_route_bytes = (
            forward_kv_tile_count * route_bytes_per_kv_tile
        )
        route_byte_count = math.ceil(math.ceil(text_length / block_size_tokens) / 8)
        if (
            packed_route.ndim != 3
            or packed_route.shape[0] != batch
            or packed_route.shape[1] != text_length
            or packed_route.shape[2] < route_byte_count
            or packed_route.dtype != torch.uint8
            or packed_route.device != selected_block_indices.device
        ):
            raise ValueError(
                "packed S-KSA route must be uint8 [batch, query, route_byte]"
            )
        if padded_query_count != text_length:
            packed_padding = packed_route.new_zeros(
                (
                    batch,
                    padded_query_count - text_length,
                    packed_route.shape[2],
                )
            )
            packed_padded = torch.cat((packed_route, packed_padding), dim=1)
        else:
            packed_padded = packed_route
        packed_padded = packed_padded[..., :required_route_bytes]
        if packed_padded.shape[-1] < required_route_bytes:
            packed_padded = torch.nn.functional.pad(
                packed_padded, (0, required_route_bytes - packed_padded.shape[-1])
            )
        allowed = packed_padded[..., :required_route_bytes].view(
            batch,
            q_tile_count,
            query_tile_size,
            forward_kv_tile_count,
            route_bytes_per_kv_tile,
        ).any(dim=(2, 4)).to(torch.bool).contiguous()
    else:
        # Compatibility/oracle path for callers that only have selected IDs.
        if padded_query_count != text_length:
            padding = selected_block_indices.new_full(
                (
                    batch,
                    padded_query_count - text_length,
                    selected_block_indices.shape[-1],
                ),
                -1,
            )
            selected_padded = torch.cat(
                (selected_block_indices, padding), dim=1
            )
        else:
            selected_padded = selected_block_indices
        allowed_with_sentinel = torch.zeros(
            (batch, q_tile_count, forward_kv_tile_count + 1),
            dtype=torch.bool,
            device=selected_block_indices.device,
        )
        # Chunk the int64 scatter indices so 128K x large-topk routes do not
        # create another sequence-sized temporary tensor.
        tile_chunk = 8
        for q_tile_start in range(0, q_tile_count, tile_chunk):
            q_tile_end = min(q_tile_start + tile_chunk, q_tile_count)
            query_start = q_tile_start * query_tile_size
            query_end = q_tile_end * query_tile_size
            logical = selected_padded[:, query_start:query_end]
            valid = logical >= 0
            physical = torch.where(
                valid,
                (logical * block_size_tokens) // forward_kv_tile_size,
                logical.new_full((), forward_kv_tile_count),
            )
            physical = physical.reshape(
                batch, q_tile_end - q_tile_start, -1
            )
            allowed_with_sentinel[:, q_tile_start:q_tile_end].scatter_(
                2,
                physical.to(torch.long),
                True,
            )
        allowed = allowed_with_sentinel[
            ..., :forward_kv_tile_count
        ].contiguous()

    q_tiles = torch.arange(q_tile_count, device=allowed.device)
    kv_tiles = torch.arange(forward_kv_tile_count, device=allowed.device)
    q_first_token = q_tiles * query_tile_size
    q_last_token = ((q_tiles + 1) * query_tile_size - 1).clamp_max(
        text_length - 1
    )
    kv_first_token = kv_tiles * forward_kv_tile_size
    kv_last_token = ((kv_tiles + 1) * forward_kv_tile_size - 1).clamp_max(
        text_length - 1
    )

    # Schedule every tile with at least one causal-local (q, k) pair.  Each
    # query's local start is clamped at zero and aligned down to a raw summary
    # block boundary.  The token mask still clips the two boundary tiles
    # exactly.
    q_first_local_start = (
        (q_first_token - local_window_tokens + 1).clamp_min(0)
        // local_alignment_tokens
    ) * local_alignment_tokens
    local_overlap = (
        (kv_first_token[None, :] <= q_last_token[:, None])
        & (kv_last_token[None, :] >= q_first_local_start[:, None])
    )
    causal_overlap = kv_first_token[None, :] <= q_last_token[:, None]
    allowed |= local_overlap.unsqueeze(0)
    allowed &= causal_overlap.unsqueeze(0)

    # A tile can skip mask_mod only when every actual q/k pair is causal and
    # inside the local window.  Selected remote tiles always stay partial.
    q_last_local_start = (
        (q_last_token - local_window_tokens + 1).clamp_min(0)
        // local_alignment_tokens
    ) * local_alignment_tokens
    local_full = (
        (kv_last_token[None, :] <= q_first_token[:, None])
        & (kv_first_token[None, :] >= q_last_local_start[:, None])
    )
    full_allowed = allowed & local_full.unsqueeze(0)
    partial_allowed = allowed & ~full_allowed

    def compact(partial, full, *, block_size):
        counts = partial.sum(dim=-1).to(torch.int32).unsqueeze(1)
        indices = torch.argsort(
            partial.to(torch.int8), dim=-1, descending=True, stable=True
        ).to(torch.int32).unsqueeze(1)
        full_counts = full.sum(dim=-1).to(torch.int32).unsqueeze(1)
        full_indices = torch.argsort(
            full.to(torch.int8), dim=-1, descending=True, stable=True
        ).to(torch.int32).unsqueeze(1)
        return BlockSparseTensorsTorch(
            mask_block_cnt=counts,
            mask_block_idx=indices,
            full_block_cnt=full_counts,
            full_block_idx=full_indices,
            block_size=block_size,
        )

    forward_sparse = compact(
        partial_allowed,
        full_allowed,
        block_size=(query_tile_size, forward_kv_tile_size),
    )

    # Backward consumes the KV-major transpose of the forward Q-major schedule.
    # With the default 64-token tile this is a direct transpose.  The generic
    # merge below also keeps the old 128-token schedule available for A/B:
    # a merged tile is full only if both halves are full.
    return forward_sparse, None


class SummaryBlockSparseAttentionFunc(torch.autograd.Function):
    """Exact per-query Full attention over selected raw-token route units."""

    @staticmethod
    def forward(
        ctx,
        q,
        k,
        v,
        packed_route,
        selected_block_indices,
        block_size_tokens,
        local_window_tokens,
        local_alignment_tokens,
        softmax_scale=None,
        deterministic=False,
        cached_output=None,
        cached_lse=None,
    ):
        from flash_attn.cute.interface import _flash_attn_fwd
        from .mask import summary_block_sparse_attn_mask_func

        fwd_sparse, bwd_sparse = _summary_block_sparse_tensors(
            selected_block_indices,
            text_length=q.shape[1],
            block_size_tokens=block_size_tokens,
            local_window_tokens=local_window_tokens,
            local_alignment_tokens=local_alignment_tokens,
            packed_route=packed_route,
        )
        mask_mod = partial(
            summary_block_sparse_attn_mask_func,
            block_size_tokens=block_size_tokens,
            local_window_tokens=local_window_tokens,
            local_alignment_tokens=local_alignment_tokens,
        )
        if (cached_output is None) != (cached_lse is None):
            raise ValueError("cached sparse output and LSE must be provided together")
        if cached_output is None:
            out, lse = _flash_attn_fwd(
                q,
                k,
                v,
                softmax_scale=softmax_scale,
                causal=False,
                pack_gqa=None,
                mask_mod=mask_mod,
                block_sparse_tensors=fwd_sparse,
                aux_tensors=[packed_route],
                m_block_size=128,
                n_block_size=64,
                return_lse=any(t.requires_grad for t in (q, k, v)),
            )
        else:
            expected_lse_shape = (q.shape[0], q.shape[2], q.shape[1])
            if (
                cached_output.shape != q.shape
                or cached_output.dtype != q.dtype
                or cached_output.device != q.device
                or cached_lse.shape != expected_lse_shape
                or cached_lse.dtype != torch.float32
                or cached_lse.device != q.device
            ):
                raise ValueError("cached sparse attention tensor metadata mismatch")
            # The enclosing non-reentrant layer checkpoint only needs to
            # rebuild tensors saved for this Function's backward. Reuse the
            # detached original-forward values instead of executing the same
            # sparse forward kernel a second time.
            out, lse = cached_output, cached_lse
        if lse is not None:
            ctx.mark_non_differentiable(lse)
        return out, lse





# ---------------------------------------------------------------------------
# FlexAttention path (Ampere / any GPU with PyTorch >= 2.5)
# ---------------------------------------------------------------------------

_FLEX_BLOCK_MASK_CACHE: Dict[Tuple, object] = {}


def _create_block_mask_no_materialize(mask_mod, seq_len_q, seq_len_k, device, BLOCK_SIZE=128):
    """Create BlockMask without materializing the full (seq_len_q, seq_len_k) dense mask.

    Processes one q_block row at a time, reducing peak memory from
    O(seq_q * seq_k) to O(BLOCK_SIZE * seq_k).
    """
    from torch.nn.attention.flex_attention import BlockMask

    num_q_blocks = (seq_len_q + BLOCK_SIZE - 1) // BLOCK_SIZE
    num_kv_blocks = (seq_len_k + BLOCK_SIZE - 1) // BLOCK_SIZE

    # Block-level sparsity: (num_q_blocks, num_kv_blocks)
    any_mask = torch.zeros(num_q_blocks, num_kv_blocks, dtype=torch.bool, device=device)
    full_mask = torch.zeros(num_q_blocks, num_kv_blocks, dtype=torch.bool, device=device)

    kv_arange = torch.arange(seq_len_k, device=device)
    pad_k = (-seq_len_k) % BLOCK_SIZE
    last_kv_len = seq_len_k - (num_kv_blocks - 1) * BLOCK_SIZE if seq_len_k % BLOCK_SIZE else BLOCK_SIZE

    for q_blk in range(num_q_blocks):
        q_start = q_blk * BLOCK_SIZE
        q_end = min(q_start + BLOCK_SIZE, seq_len_q)
        q_len = q_end - q_start
        q_idx = torch.arange(q_start, q_end, device=device)

        # (q_len, seq_len_k) bool
        mask = mask_mod(0, 0, q_idx[:, None], kv_arange[None, :])

        if pad_k > 0:
            mask = torch.nn.functional.pad(mask, (0, pad_k), value=False)

        # (q_len, num_kv_blocks, BLOCK_SIZE) -> sum per block
        block_sums = mask.reshape(q_len, num_kv_blocks, BLOCK_SIZE).sum(dim=(0, 2))

        any_mask[q_blk] = block_sums > 0
        full_mask[q_blk] = block_sums == (q_len * BLOCK_SIZE)
        # Last kv block may be shorter
        if pad_k > 0:
            full_mask[q_blk, -1] = block_sums[-1] == (q_len * last_kv_len)

    def _bool_mask_to_compact(mask_2d):
        """Convert (rows, cols) bool mask to (num_per_row, indices) format."""
        num_per_row = mask_2d.sum(dim=1).to(torch.int32)
        max_num = max(num_per_row.max().item(), 1)
        sorted_idx = mask_2d.to(torch.int32).argsort(dim=1, descending=True, stable=True)
        indices = sorted_idx[:, :max_num].to(torch.int32)
        return num_per_row, indices

    kv_num_blocks, kv_indices = _bool_mask_to_compact(any_mask)
    full_kv_num_blocks, full_kv_indices = _bool_mask_to_compact(full_mask)
    q_num_blocks, q_indices = _bool_mask_to_compact(any_mask.t().contiguous())
    full_q_num_blocks, full_q_indices = _bool_mask_to_compact(full_mask.t().contiguous())

    return BlockMask(
        kv_num_blocks=kv_num_blocks[None, None],
        kv_indices=kv_indices[None, None],
        full_kv_num_blocks=full_kv_num_blocks[None, None],
        full_kv_indices=full_kv_indices[None, None],
        q_num_blocks=q_num_blocks[None, None],
        q_indices=q_indices[None, None],
        full_q_num_blocks=full_q_num_blocks[None, None],
        full_q_indices=full_q_indices[None, None],
        BLOCK_SIZE=(BLOCK_SIZE, BLOCK_SIZE),
        mask_mod=mask_mod,
        seq_lengths=(seq_len_q, seq_len_k),
    )


def _get_flex_block_mask(seq_len_q, seq_len_k, chunk_size, summary_num, sliding_chunk_num,
                          skip_old_summary, device):
    cache_key = (seq_len_q, seq_len_k, chunk_size, summary_num, sliding_chunk_num, skip_old_summary)
    if cache_key not in _FLEX_BLOCK_MASK_CACHE:
        block = chunk_size + summary_num
        prefix_summary_length = seq_len_k - seq_len_q

        if prefix_summary_length > 0:
            def mask_mod(b, h, q_idx, kv_idx):
                q_chunk_id = q_idx // block
                kv_chunk_id = (kv_idx - prefix_summary_length) // block
                is_sliding = (q_chunk_id - kv_chunk_id) <= sliding_chunk_num
                is_q_summary = (q_idx % block) >= chunk_size
                is_kv_summary = ((kv_idx - prefix_summary_length) % block) >= chunk_size
                is_causal = q_idx >= (kv_idx - prefix_summary_length)
                valid_kv_q_summary = is_q_summary & (kv_chunk_id == q_chunk_id) & is_causal
                valid_sliding_q_text = is_sliding & (~is_kv_summary) & (kv_idx >= prefix_summary_length)
                if skip_old_summary:
                    valid_kv_q_text = (~is_q_summary) & is_causal & valid_sliding_q_text
                else:
                    valid_summary_q_text = ((kv_idx // summary_num) + sliding_chunk_num) < q_chunk_id
                    valid_kv_q_text = (~is_q_summary) & ((is_causal & valid_sliding_q_text) | valid_summary_q_text)
                return valid_kv_q_summary | valid_kv_q_text
        else:
            def mask_mod(b, h, q_idx, kv_idx):
                q_chunk_id = q_idx // block
                kv_chunk_id = kv_idx // block
                is_sliding = (q_chunk_id - kv_chunk_id) <= sliding_chunk_num
                is_q_summary = (q_idx % block) >= chunk_size
                is_kv_summary = (kv_idx % block) >= chunk_size
                is_causal = q_idx >= kv_idx
                valid_kv_q_summary = is_q_summary & (kv_chunk_id == q_chunk_id) & is_causal
                valid_sliding_q_text = is_sliding & (~is_kv_summary)
                if skip_old_summary:
                    valid_kv_q_text = (~is_q_summary) & is_causal & valid_sliding_q_text
                else:
                    valid_summary_q_text = is_kv_summary & ((q_chunk_id - kv_chunk_id) > sliding_chunk_num)
                    valid_kv_q_text = (~is_q_summary) & is_causal & (valid_sliding_q_text | valid_summary_q_text)
                return valid_kv_q_summary | valid_kv_q_text

        block_mask = _create_block_mask_no_materialize(
            mask_mod, seq_len_q, seq_len_k, device,
        )
        _FLEX_BLOCK_MASK_CACHE[cache_key] = block_mask
    return _FLEX_BLOCK_MASK_CACHE[cache_key]


_compiled_flex_attention = None


def _get_compiled_flex_attention():
    global _compiled_flex_attention
    if _compiled_flex_attention is None:
        from torch.nn.attention.flex_attention import flex_attention
        _compiled_flex_attention = torch.compile(flex_attention)
    return _compiled_flex_attention


def _summary_attn_flex(q, k, v, chunk_size, summary_num, sliding_chunk_num, skip_old_summary):
    """FlexAttention-based implementation. Auto-compiles to efficient Triton block-sparse kernel.

    Works on any GPU with PyTorch >= 2.5 (Ampere, Hopper, etc).
    No KV duplication, no Python loops — single fused kernel call.
    """
    b, seq_len_q, h_q, d = q.shape
    _, seq_len_k, h_kv, _ = k.shape
    enable_gqa = (h_q != h_kv)

    block_mask = _get_flex_block_mask(seq_len_q, seq_len_k, chunk_size, summary_num,
                                       sliding_chunk_num, skip_old_summary, q.device)

    # flex_attention expects [B, H, S, D]
    q_flex = q.transpose(1, 2)  # [B, H, S, D]
    k_flex = k.transpose(1, 2)
    v_flex = v.transpose(1, 2)

    compiled_fn = _get_compiled_flex_attention()
    out = compiled_fn(q_flex, k_flex, v_flex, block_mask=block_mask, enable_gqa=enable_gqa)
    out = out.transpose(1, 2)  # [B, S, H, D]

    return out, None


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def summary_attn_func(
    q, k, v, chunk_size, summary_num, sliding_chunk_num,
    summary_pos: Optional[torch.Tensor] = None,
    skip_old_summary: bool = False,
    force_return_lse: bool = False,
):
    if summary_pos is not None:
        k = torch.cat((k[:, summary_pos, :], k), dim=1)
        v = torch.cat((v[:, summary_pos, :], v), dim=1)

    if _check_cute_available():
        out = SummaryAttnFunc.apply(
            q, k, v, chunk_size, summary_num, sliding_chunk_num,
            None, False, None, False, skip_old_summary, force_return_lse,
        )
    else:
        out = _summary_attn_flex(
            q, k, v, chunk_size, summary_num, sliding_chunk_num, skip_old_summary,
        )
    return out


def hierarchical_summary_attn_func(
    q, k, v, meta_k, meta_v,
    chunk_size, sliding_chunk_num, group_size,
    softmax_scale=None, mass_correction=True, force_return_lse=False,
):
    """Compressed-KSA attention; tensors use [batch, sequence, heads, dim]."""
    tensors = (q, k, v, meta_k, meta_v)
    if not all(t.is_cuda for t in tensors):
        raise RuntimeError("hierarchical_summary_attn_func requires CUDA tensors")
    if any(t.ndim != 4 for t in tensors):
        raise ValueError("q/k/v/meta_k/meta_v must be rank-4 tensors")
    if k.shape != v.shape or meta_k.shape != meta_v.shape:
        raise ValueError("key/value tensor shapes must match")
    if q.shape[0] != k.shape[0] or q.shape[0] != meta_k.shape[0]:
        raise ValueError("batch sizes must match")
    if q.shape[1] != k.shape[1] or k.shape[2:] != meta_k.shape[2:]:
        raise ValueError("base and meta K/V layouts are incompatible")
    if q.shape[2] % k.shape[2]:
        raise ValueError("query heads must be divisible by KV heads")
    if group_size <= 1 or chunk_size <= 0 or sliding_chunk_num <= 0:
        raise ValueError("group_size must exceed one and chunk/window sizes must be positive")
    with torch.cuda.device(q.device):
        if not _check_cute_available():
            raise RuntimeError("hierarchical_summary_attn_func requires CuTe on sm90+")
        return HierarchicalSummaryAttnFunc.apply(
            q, k, v, meta_k, meta_v,
            chunk_size, sliding_chunk_num, group_size,
            softmax_scale, False, mass_correction, force_return_lse,
        )








def _pad_summary_block_sparse_cute_inputs(
    q,
    k,
    v,
    selected_block_indices,
    *,
    query_tile_size=128,
):
    """Pad CuTe's physical Q tile without changing logical attention.

    The block-sparse CuTe consumer may evaluate ``mask_mod`` for every
    physical row in its final Q tile before applying the sequence-length
    predicate to later partial KV blocks.  Supplying real all-invalid route
    rows for those physical lanes keeps the auxiliary lookup in bounds.
    Future zero K/V rows remain invisible to every real query through the
    kernel's causal guard, and the padded query outputs are discarded by the
    public wrapper.
    """
    text_length = q.shape[1]
    padding = (-text_length) % query_tile_size
    if not padding:
        return q, k, v, selected_block_indices, text_length

    def pad_sequence(tensor):
        return torch.cat(
            (
                tensor,
                tensor.new_zeros(
                    tensor.shape[0], padding, *tensor.shape[2:]
                ),
            ),
            dim=1,
        )

    padded_route = torch.cat(
        (
            selected_block_indices,
            selected_block_indices.new_full(
                (
                    selected_block_indices.shape[0],
                    padding,
                    selected_block_indices.shape[2],
                ),
                -1,
            ),
        ),
        dim=1,
    )
    return (
        pad_sequence(q),
        pad_sequence(k),
        pad_sequence(v),
        padded_route,
        text_length,
    )


def summary_block_sparse_attn_func(
    q,
    k,
    v,
    selected_block_indices,
    block_size_tokens,
    local_window_tokens,
    softmax_scale=None,
    deterministic=False,
):
    """Run exact per-query Full attention over selected raw-token blocks.

    ``selected_block_indices[b, q]`` contains the summary/raw-block IDs chosen
    by query ``q``; ``-1`` pads rows with fewer candidates.  A selected ID
    exposes the corresponding raw K/V block, never the summary K/V itself.
    Local causal tokens are added independently.  The caller is responsible
    for reserving block 0 inside the TopK budget when an attention sink is
    desired; this kernel always applies a final causal guard.

    The selected IDs must be unique within each row.  This is naturally true
    for ``torch.topk``.  Set ``SKSA_VALIDATE_BLOCK_INDICES=1`` to perform the
    synchronising bounds/duplicate checks during diagnostics.
    """
    _validate_summary_block_sparse_inputs(
        q,
        k,
        v,
        selected_block_indices,
        block_size_tokens,
        local_window_tokens,
        block_size_tokens,
    )
    if os.environ.get("SKSA_VALIDATE_BLOCK_INDICES", "0") == "1":
        _check_unique_summary_block_indices(selected_block_indices)
    route_indices = selected_block_indices.to(torch.int32).contiguous()
    with torch.cuda.device(q.device):
        if not _check_cute_available():
            raise RuntimeError(
                "summary_block_sparse_attn_func requires CuTe on sm90+"
            )
        # Qwen's ordinary multi-head raw K/V is much wider than DeepSeek MLA's
        # shared latent KV.  Direct per-query gather therefore loses crucial
        # cross-query KV reuse and is bandwidth-bound.  Keep the exact CuTe
        # tiled implementation as production default; ``direct`` is an
        # explicit diagnostic prototype only.
        q, k, v, route_indices, text_length = (
            _pad_summary_block_sparse_cute_inputs(
                q,
                k,
                v,
                route_indices,
            )
        )
        raw_block_count = math.ceil(k.shape[1] / block_size_tokens)
        packed_route = _pack_summary_block_indices(
            route_indices,
            raw_block_count=raw_block_count,
        )
        output, _ = SummaryBlockSparseAttentionFunc.apply(
            q,
            k,
            v,
            packed_route,
            route_indices,
            block_size_tokens,
            local_window_tokens,
            block_size_tokens,
            softmax_scale,
            deterministic,
            None,
            None,
        )
        return (
            output
            if output.shape[1] == text_length
            else output[:, :text_length].contiguous()
        )