Frosty40 commited on
Commit
7298fd0
·
verified ·
1 Parent(s): 0c281a5

Publish Hydra kernel source packet

Browse files
CARD.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hydra Kernel Card
2
+
3
+ ## Summary
4
+
5
+ Hydra provides a bounded-residency decode attention path for long-context
6
+ inference. The implementation is Python plus Triton and is packaged here as a
7
+ Hugging Face universal CUDA kernel source directory.
8
+
9
+ Source code: [https://github.com/newjordan/hydra/tree/main/hf-kernels/hydra](https://github.com/newjordan/hydra/tree/main/hf-kernels/hydra)
10
+
11
+ ## Intended Use
12
+
13
+ Use Hydra for experiments where full decode attention over a long KV cache is
14
+ memory-bound and a bounded resident set is acceptable for evaluation.
15
+
16
+ Hydra is not intended as a drop-in universal FlashAttention replacement. Users
17
+ should keep an exact-model fallback path and validate quality for their prompt
18
+ set.
19
+
20
+ ## Kernel Interface
21
+
22
+ The exported call is:
23
+
24
+ ```python
25
+ hydra.hydra(q, k, v, is_causal=True, sliding_window=None)
26
+ ```
27
+
28
+ Inputs are bf16 tensors shaped `(B, H, T, D)` with `D=128`. The decode path
29
+ supports `Tq == 1`; the prefill path requires sequence length to be a multiple
30
+ of the compile-time block size.
31
+
32
+ ## Evidence
33
+
34
+ This card separates the kernel contribution from benchmark appendices:
35
+
36
+ - kernel/package validation: import, CSR, CUDA decode parity, builder, example,
37
+ and isolated decode benchmark gates
38
+ - broad Hydra campaign context: multi-GPU bounded-residency testing, comparison
39
+ lanes, capacity/OOM boundaries, and diagnostics in the staging repo
40
+ - exact-Qwen proof-of-concept: summary-backed demo rows for:
41
+
42
+ - RTX PRO 6000 WS with `Qwen/Qwen3.6-35B-A3B-FP8`
43
+ - RTX 3090 with `Qwen/Qwen3.6-35B-A3B-FP8`
44
+
45
+ Use `results/reports/QWEN3P6_FP8_EVIDENCE_TABLE.md` in the staging repo as the
46
+ claim ledger for the exact-Qwen proof-of-concept only. The table is generated
47
+ from raw summary JSON, answer artifacts, and logs. It intentionally excludes
48
+ incomplete scopes from completed benchmark rows.
49
+
50
+ ## Non-Claims
51
+
52
+ - no universal speedup claim
53
+ - no production-readiness claim
54
+ - no broad quality-preservation claim without scorer or inspection evidence
55
+ - no proxy/profile/loader-only benchmark claims
56
+ - no results from non-Qwen or non-FP8 runs in the exact-Qwen proof-of-concept table
57
+ - no framing that treats the exact-Qwen proof-of-concept as the full Hydra campaign
58
+
59
+ ## Required Validation
60
+
61
+ For source changes, run:
62
+
63
+ - import and CSR tests
64
+ - CUDA decode parity against PyTorch SDPA on small tensors
65
+ - kernel-builder `ci-test`
66
+ - one isolated decode benchmark
67
+ - one exact-model reproduction on a named GPU/config
README.md ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - kernel
5
+ - kernels
6
+ - triton
7
+ - attention
8
+ - long-context
9
+ ---
10
+
11
+ # Hydra
12
+
13
+ Hydra is an experimental bounded-residency attention kernel for long-context
14
+ decode. It keeps sink tokens, recent tokens, and selected older pages resident
15
+ instead of forcing each decode step to attend over the full KV cache.
16
+
17
+ Source code: [https://github.com/newjordan/hydra/tree/main/hf-kernels/hydra](https://github.com/newjordan/hydra/tree/main/hf-kernels/hydra)
18
+
19
+ This release is intentionally narrow. It is not a general replacement for
20
+ full attention, and it does not claim universal speedups or broad quality
21
+ preservation. The current target is fit and usability for specific
22
+ long-context inference workloads where the full-attention path is memory-bound.
23
+
24
+ ## Usage
25
+
26
+ After the kernel is published:
27
+
28
+ ```python
29
+ import torch
30
+ from kernels import get_kernel
31
+
32
+ hydra = get_kernel("Frosty40/hydra")
33
+
34
+ q = torch.randn(1, 32, 1, 128, device="cuda", dtype=torch.bfloat16)
35
+ k = torch.randn(1, 8, 8192, 128, device="cuda", dtype=torch.bfloat16)
36
+ v = torch.randn(1, 8, 8192, 128, device="cuda", dtype=torch.bfloat16)
37
+
38
+ out = hydra.hydra(q, k, v)
39
+ print(out.shape)
40
+ ```
41
+
42
+ For local development from the public source checkout:
43
+
44
+ ```python
45
+ from pathlib import Path
46
+ import sys
47
+
48
+ sys.path.insert(0, str(Path("hf-kernels") / "hydra" / "torch-ext"))
49
+ import hydra
50
+ ```
51
+
52
+ `readme_example.py` uses the local source packet by default so it can run before
53
+ publication. Set `HYDRA_USE_HUB=1` after publication to exercise the Hub-loaded
54
+ path.
55
+
56
+ ## API
57
+
58
+ ```python
59
+ hydra.hydra(
60
+ q,
61
+ k,
62
+ v,
63
+ *,
64
+ is_causal=True,
65
+ sliding_window=None,
66
+ policy_layer_idx=None,
67
+ precision="high",
68
+ )
69
+ ```
70
+
71
+ Current constraints:
72
+
73
+ - CUDA tensors only
74
+ - bf16 `q`, `k`, and `v`
75
+ - shape `(B, H, T, D)` with `D=128`
76
+ - causal attention only
77
+ - decode path supports `Tq == 1` with arbitrary `Tkv`
78
+ - prefill path requires `T % BLOCK_SIZE == 0`
79
+
80
+ ## Evidence Boundary
81
+
82
+ Submission-facing evidence must come from checked artifacts, not prose notes.
83
+ Treat evidence in three separate scopes:
84
+
85
+ - kernel/package validation: tests, CUDA parity logs, `kernel-builder` logs, and
86
+ isolated decode benchmarks for this source packet
87
+ - broad Hydra research campaign: capacity, quality, sparse-attention comparison,
88
+ edge/OOM, diagnostic, and model-family reports from the staging repo
89
+ - exact-model proof-of-concept: checked `Qwen/Qwen3.6-35B-A3B-FP8` rows for
90
+ named GPUs only
91
+
92
+ The exact-Qwen proof-of-concept appendix in the staging repo is under:
93
+
94
+ ```text
95
+ results/raw/qwen3p6_35b_a3b_fp8/
96
+ results/reports/QWEN3P6_FP8_EVIDENCE_TABLE.md
97
+ ```
98
+
99
+ Each cited row must include all three:
100
+
101
+ - fit/headroom: GPU, context length, memory allocated/reserved, and OOM state
102
+ - quality/correctness: prompt/task ID and generated answer artifact
103
+ - speed/usability: wall time, generated tokens, tokens/sec, and comparison target
104
+
105
+ Do not cite proxy models, loader-only probes, failed dependency checks, or
106
+ non-matching model runs as Hydra benchmark results. Do not describe the
107
+ exact-Qwen proof-of-concept subset as the full Hydra validation campaign.
108
+
109
+ ## Current Proof-Of-Concept Scope
110
+
111
+ The current exact-Qwen artifact-backed proof-of-concept scope is:
112
+
113
+ | GPU | Model | Scope |
114
+ | --- | --- | --- |
115
+ | RTX PRO 6000 WS | `Qwen/Qwen3.6-35B-A3B-FP8` | 32k/80k/160k repeat packet, 160k c96 warm packet, and frontier/headroom sweeps |
116
+ | RTX 3090 | `Qwen/Qwen3.6-35B-A3B-FP8` | 2k/3k/4k/6k/8k fit probes and completed 10k/12k/14k edge sweep |
117
+
118
+ The 3090 result should be framed as fit/usability evidence, not a speedup
119
+ claim. Token rates are slow in the long-context edge rows. The broader Hydra
120
+ campaign includes additional GPUs, tasks, and comparison lanes outside this
121
+ exact-model appendix.
122
+
123
+ ## Validation Required Before Merge
124
+
125
+ Minimum gates for source changes:
126
+
127
+ ```bash
128
+ cd hf-kernels/hydra
129
+ python3 -m pytest -q tests
130
+ nix run .#ci-test
131
+ python3 benchmarks/benchmark_hydra_decode.py --repo .
132
+ python3 readme_example.py
133
+ ```
134
+
135
+ Run the CUDA tests on real GPUs. Local syntax checks are not enough for a
136
+ kernel submission.
137
+
138
+ ## Benchmark Snapshot
139
+
140
+ The current 8192-token decode smoke/benchmark matrix is intentionally reported
141
+ as kernel/package evidence, not as a universal speedup claim.
142
+
143
+ | GPU | Package smoke decode | HF benchmark mean |
144
+ | --- | ---: | ---: |
145
+ | RTX 3060 | 0.2574 ms | 0.3229 ms |
146
+ | RTX 3070 | 0.1474 ms | 0.2532 ms |
147
+ | RTX 3080 | 0.2051 ms | 0.3157 ms |
148
+ | RTX 3090 | 0.1492 ms | 0.3107 ms |
149
+ | RTX 4070 Ti | 0.1261 ms | 0.2215 ms |
150
+ | RTX 4090 | 0.1132 ms | 0.2245 ms |
151
+ | A100 SXM4 | 0.1408 ms | 0.2568 ms |
152
+ | RTX PRO 6000 Blackwell | 0.1158 ms | 0.1371 ms |
153
+ | RTX A6000 | builder smoke 0.2166 ms | 0.3230 ms |
154
+
155
+ The final `kernel-builder` gate passed on a Vast RTX A6000 with
156
+ `BUILDER_VARIANT=torch210-cxx11-cu128-x86_64-linux`: local pytest `6 passed`,
157
+ decode smoke `0.2166 ms/iter`, builder pytest `4 passed, 2 skipped`, exit
158
+ status `0`.
benchmarks/benchmark_hydra_decode.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ from kernels import get_local_kernel
11
+
12
+
13
+ def load_local_kernel(repo: Path):
14
+ for variant in (repo / "build", repo):
15
+ if (variant / "metadata.json").exists():
16
+ return get_local_kernel(variant)
17
+
18
+ sys.path.insert(0, str(repo / "torch-ext"))
19
+ return importlib.import_module("hydra")
20
+
21
+
22
+ def parse_args() -> argparse.Namespace:
23
+ parser = argparse.ArgumentParser(description="Hydra decode microbenchmark")
24
+ parser.add_argument("--repo", default=".", help="Path to the hydra kernel directory")
25
+ parser.add_argument("--batch", type=int, default=1)
26
+ parser.add_argument("--heads", type=int, default=32)
27
+ parser.add_argument("--kv-heads", type=int, default=8)
28
+ parser.add_argument("--tokens", type=int, default=8192)
29
+ parser.add_argument("--head-dim", type=int, default=128)
30
+ parser.add_argument("--iters", type=int, default=100)
31
+ parser.add_argument("--warmup", type=int, default=10)
32
+ parser.add_argument("--window", type=int, default=0)
33
+ return parser.parse_args()
34
+
35
+
36
+ def main() -> None:
37
+ args = parse_args()
38
+ if not torch.cuda.is_available():
39
+ raise SystemExit("CUDA is required")
40
+
41
+ kernel = load_local_kernel(Path(args.repo))
42
+ q = torch.randn(
43
+ args.batch,
44
+ args.heads,
45
+ 1,
46
+ args.head_dim,
47
+ device="cuda",
48
+ dtype=torch.bfloat16,
49
+ )
50
+ k = torch.randn(
51
+ args.batch,
52
+ args.kv_heads,
53
+ args.tokens,
54
+ args.head_dim,
55
+ device="cuda",
56
+ dtype=torch.bfloat16,
57
+ )
58
+ v = torch.randn_like(k)
59
+
60
+ window = None if args.window <= 0 else args.window
61
+ for _ in range(args.warmup):
62
+ kernel.hydra(q, k, v, sliding_window=window)
63
+ torch.cuda.synchronize()
64
+
65
+ start = time.perf_counter()
66
+ for _ in range(args.iters):
67
+ kernel.hydra(q, k, v, sliding_window=window)
68
+ torch.cuda.synchronize()
69
+ elapsed = time.perf_counter() - start
70
+
71
+ ms = elapsed * 1000.0 / args.iters
72
+ print(
73
+ "hydra_decode "
74
+ f"B={args.batch} H={args.heads} Hkv={args.kv_heads} "
75
+ f"Tkv={args.tokens} D={args.head_dim} window={window or 0} "
76
+ f"iters={args.iters} ms_per_iter={ms:.4f}"
77
+ )
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
build.toml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [general]
2
+ name = "hydra"
3
+ license = "MIT"
4
+ backends = ["cuda"]
5
+
6
+ [general.hub]
7
+ repo-id = "Frosty40/hydra"
flake.lock ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nodes": {
3
+ "flake-compat": {
4
+ "locked": {
5
+ "lastModified": 1765121682,
6
+ "narHash": "sha256-4VBOP18BFeiPkyhy9o4ssBNQEvfvv1kXkasAYd0+rrA=",
7
+ "owner": "edolstra",
8
+ "repo": "flake-compat",
9
+ "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3",
10
+ "type": "github"
11
+ },
12
+ "original": {
13
+ "owner": "edolstra",
14
+ "repo": "flake-compat",
15
+ "type": "github"
16
+ }
17
+ },
18
+ "flake-utils": {
19
+ "inputs": {
20
+ "systems": "systems"
21
+ },
22
+ "locked": {
23
+ "lastModified": 1731533236,
24
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
25
+ "owner": "numtide",
26
+ "repo": "flake-utils",
27
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
28
+ "type": "github"
29
+ },
30
+ "original": {
31
+ "owner": "numtide",
32
+ "repo": "flake-utils",
33
+ "type": "github"
34
+ }
35
+ },
36
+ "kernel-builder": {
37
+ "inputs": {
38
+ "flake-compat": "flake-compat",
39
+ "flake-utils": "flake-utils",
40
+ "nixpkgs": "nixpkgs"
41
+ },
42
+ "locked": {
43
+ "lastModified": 1775482375,
44
+ "narHash": "sha256-RUbxfJGs96jwnwSci3+8h08GB2/katOI67yZTCaV+aE=",
45
+ "owner": "huggingface",
46
+ "repo": "kernel-builder",
47
+ "rev": "dffbce5a048648febb96bbed79f3811ab6d7a577",
48
+ "type": "github"
49
+ },
50
+ "original": {
51
+ "owner": "huggingface",
52
+ "repo": "kernel-builder",
53
+ "type": "github"
54
+ }
55
+ },
56
+ "nixpkgs": {
57
+ "locked": {
58
+ "lastModified": 1766341660,
59
+ "narHash": "sha256-4yG6vx7Dddk9/zh45Y2KM82OaRD4jO3HA9r98ORzysA=",
60
+ "owner": "NixOS",
61
+ "repo": "nixpkgs",
62
+ "rev": "26861f5606e3e4d1400771b513cc63e5f70151a6",
63
+ "type": "github"
64
+ },
65
+ "original": {
66
+ "owner": "NixOS",
67
+ "ref": "nixos-unstable-small",
68
+ "repo": "nixpkgs",
69
+ "type": "github"
70
+ }
71
+ },
72
+ "root": {
73
+ "inputs": {
74
+ "kernel-builder": "kernel-builder"
75
+ }
76
+ },
77
+ "systems": {
78
+ "locked": {
79
+ "lastModified": 1681028828,
80
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
81
+ "owner": "nix-systems",
82
+ "repo": "default",
83
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
84
+ "type": "github"
85
+ },
86
+ "original": {
87
+ "owner": "nix-systems",
88
+ "repo": "default",
89
+ "type": "github"
90
+ }
91
+ }
92
+ },
93
+ "root": "root",
94
+ "version": 7
95
+ }
flake.nix ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ description = "Flake for the Hydra Hugging Face kernel";
3
+
4
+ inputs = {
5
+ kernel-builder.url = "github:huggingface/kernel-builder";
6
+ };
7
+
8
+ outputs =
9
+ {
10
+ self,
11
+ kernel-builder,
12
+ }:
13
+ kernel-builder.lib.genFlakeOutputs {
14
+ path = ./.;
15
+ rev = self.shortRev or self.dirtyShortRev or (self.lastModifiedDate or "unknown");
16
+ };
17
+ }
readme_example.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "torch",
5
+ # "triton",
6
+ # "kernels",
7
+ # ]
8
+ # ///
9
+
10
+ import os
11
+ from pathlib import Path
12
+ import sys
13
+
14
+ import torch
15
+ from kernels import get_kernel, get_local_kernel
16
+
17
+
18
+ def load_hydra_kernel():
19
+ if os.environ.get("HYDRA_USE_HUB") == "1":
20
+ return get_kernel("Frosty40/hydra")
21
+
22
+ root = Path(__file__).resolve().parent
23
+ for variant in (root / "build", root):
24
+ if (variant / "metadata.json").exists():
25
+ return get_local_kernel(variant)
26
+
27
+ sys.path.insert(0, str(root / "torch-ext"))
28
+ import hydra
29
+
30
+ return hydra
31
+
32
+
33
+ def main() -> None:
34
+ if not torch.cuda.is_available():
35
+ raise SystemExit("Hydra requires CUDA for this example")
36
+
37
+ kernel = load_hydra_kernel()
38
+ q = torch.randn(1, 32, 1, 128, device="cuda", dtype=torch.bfloat16)
39
+ k = torch.randn(1, 8, 8192, 128, device="cuda", dtype=torch.bfloat16)
40
+ v = torch.randn(1, 8, 8192, 128, device="cuda", dtype=torch.bfloat16)
41
+
42
+ out = kernel.hydra(q, k, v)
43
+ print(f"Hydra decode: {tuple(q.shape)} x {tuple(k.shape)} -> {tuple(out.shape)}")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
tests/conftest.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ TORCH_EXT = ROOT / "torch-ext"
9
+ sys.path.insert(0, str(TORCH_EXT))
tests/test_csr.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ pytest.importorskip("torch")
6
+ pytest.importorskip("triton")
7
+
8
+
9
+ def test_dense_causal_csr_cpu_contract():
10
+ from hydra.csr import build_dense_causal_csr
11
+
12
+ row_ptr, col_idx, seq_lens = build_dense_causal_csr(
13
+ batch_size=1,
14
+ num_heads=2,
15
+ seq_len=128,
16
+ block_size=32,
17
+ device="cpu",
18
+ )
19
+
20
+ assert row_ptr.shape == (1, 2, 5)
21
+ assert seq_lens.tolist() == [128]
22
+ assert row_ptr[0, 0].tolist() == [0, 1, 3, 6, 10]
23
+ assert col_idx[0, 0].tolist() == [0, 0, 1, 0, 1, 2, 0, 1, 2, 3]
24
+
25
+
26
+ def test_sliding_window_csr_keeps_diagonal_last():
27
+ from hydra.csr import build_sliding_window_csr
28
+
29
+ row_ptr, col_idx, _ = build_sliding_window_csr(
30
+ window=64,
31
+ seq_len=128,
32
+ block_size=32,
33
+ batch_size=1,
34
+ num_heads=1,
35
+ device="cpu",
36
+ )
37
+
38
+ rp = row_ptr[0, 0].tolist()
39
+ ci = col_idx[0, 0].tolist()
40
+ for q_block in range(4):
41
+ lo, hi = rp[q_block], rp[q_block + 1]
42
+ assert ci[hi - 1] == q_block
43
+ assert all(k < q_block for k in ci[lo : hi - 1])
tests/test_decode_parity.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ torch = pytest.importorskip("torch")
6
+ pytest.importorskip("triton")
7
+
8
+
9
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
10
+ def test_decode_matches_sdpa_full_kv():
11
+ import torch.nn.functional as F
12
+ import hydra
13
+
14
+ torch.manual_seed(0)
15
+ q = torch.randn(1, 32, 1, 128, device="cuda", dtype=torch.bfloat16)
16
+ k = torch.randn(1, 8, 256, 128, device="cuda", dtype=torch.bfloat16)
17
+ v = torch.randn(1, 8, 256, 128, device="cuda", dtype=torch.bfloat16)
18
+
19
+ out = hydra.hydra(q, k, v)
20
+ k_rep = k.repeat_interleave(4, dim=1)
21
+ v_rep = v.repeat_interleave(4, dim=1)
22
+ ref = F.scaled_dot_product_attention(q, k_rep, v_rep, is_causal=False)
23
+
24
+ torch.testing.assert_close(out, ref, atol=3e-2, rtol=3e-2)
25
+
26
+
27
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
28
+ def test_decode_matches_sdpa_sliding_window():
29
+ import torch.nn.functional as F
30
+ import hydra
31
+
32
+ torch.manual_seed(1)
33
+ q = torch.randn(1, 32, 1, 128, device="cuda", dtype=torch.bfloat16)
34
+ k = torch.randn(1, 8, 257, 128, device="cuda", dtype=torch.bfloat16)
35
+ v = torch.randn(1, 8, 257, 128, device="cuda", dtype=torch.bfloat16)
36
+
37
+ window = 96
38
+ out = hydra.hydra(q, k, v, sliding_window=window)
39
+ k_rep = k[:, :, -window:, :].repeat_interleave(4, dim=1)
40
+ v_rep = v[:, :, -window:, :].repeat_interleave(4, dim=1)
41
+ ref = F.scaled_dot_product_attention(q, k_rep, v_rep, is_causal=False)
42
+
43
+ torch.testing.assert_close(out, ref, atol=3e-2, rtol=3e-2)
tests/test_imports.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ pytest.importorskip("torch")
6
+ pytest.importorskip("triton")
7
+
8
+
9
+ def test_public_imports():
10
+ import hydra
11
+
12
+ assert callable(hydra.hydra)
13
+ assert hydra.hydra_attention is hydra.hydra
14
+ assert hydra.flash_attn_blackwell is hydra.hydra
15
+
16
+
17
+ def test_policy_defaults():
18
+ from hydra.policy import RuntimePolicy
19
+
20
+ policy = RuntimePolicy()
21
+ assert policy.mode == "off"
22
+ assert policy.enabled is False
torch-ext/hydra/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydra bounded-residency attention package.
2
+
3
+ This package currently exposes the extracted Triton attention kernels and
4
+ runtime policy layer from the research repo. The public API is deliberately
5
+ small while the benchmark evidence is being consolidated.
6
+ """
7
+
8
+ from .api import flash_attn_blackwell, hydra, hydra_attention
9
+ from .csr import build_dense_causal_csr, build_sliding_window_csr
10
+ from .kernel_decode import launch_attn_fwd_decode
11
+ from .policy import (
12
+ RuntimePolicy,
13
+ last_policy_decision,
14
+ policy_history,
15
+ set_runtime_policy,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+
21
+ __all__ = [
22
+ "hydra",
23
+ "hydra_attention",
24
+ "flash_attn_blackwell",
25
+ "build_dense_causal_csr",
26
+ "build_sliding_window_csr",
27
+ "launch_attn_fwd_decode",
28
+ "RuntimePolicy",
29
+ "set_runtime_policy",
30
+ "last_policy_decision",
31
+ "policy_history",
32
+ ]
torch-ext/hydra/api.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public Python API (perf-tuned variant).
2
+
3
+ Same public signature as ``hydra.api.hydra``.
4
+
5
+ Internally:
6
+
7
+ - Caches the (row_ptr, col_idx, seq_lens) tuple keyed on
8
+ ``(B, Hq, T, BLOCK_SIZE, window_arg, device, dtype_marker)``. The
9
+ upstream api re-builds the CSR on every call, which (a) pays a Python
10
+ cost per call and (b) defeats any data_ptr()-based downstream cache.
11
+
12
+ Cache invalidation rule
13
+ -----------------------
14
+ A new entry is created whenever any of the following change:
15
+ - batch size B
16
+ - num query heads Hq (CSR is broadcast across H so this is part of key)
17
+ - sequence length T (kernel tile constraint: T % BLOCK_SIZE == 0)
18
+ - BLOCK_SIZE (module-level constant; included for safety)
19
+ - window_arg (the effective in-kernel window; 0 for dense)
20
+ - device (per-CUDA-device pattern; CPU vs CUDA included)
21
+
22
+ The cache is bounded (default 32 entries). Reaching the cap evicts the
23
+ LRU entry. ``seq_lens`` is included in the cached tuple — by design
24
+ ``seq_lens`` is a single ``torch.full`` produced by the CSR builders
25
+ and depends only on B / T, which are part of the key.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from collections import OrderedDict
31
+
32
+ import torch
33
+
34
+ from .csr import build_dense_causal_csr, build_sliding_window_csr
35
+ from .kernel_fwd import BLOCK_SIZE, HEAD_DIM
36
+ from .function import FlashAttnHydraFunction, FlashAttnHydraDecodeFunction
37
+ from .policy import apply_runtime_policy
38
+
39
+
40
+ _CSR_CACHE_MAX = int(os.environ.get("HYDRA_CSR_CACHE_MAX", "32"))
41
+ _CSR_CACHE: "OrderedDict[tuple, tuple[torch.Tensor, torch.Tensor, torch.Tensor]]" = OrderedDict()
42
+
43
+
44
+ def _get_csr(
45
+ B: int,
46
+ Hq: int,
47
+ T: int,
48
+ window_arg: int,
49
+ device: torch.device,
50
+ sliding_window: int | None,
51
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
52
+ key = (
53
+ B,
54
+ Hq,
55
+ T,
56
+ BLOCK_SIZE,
57
+ window_arg,
58
+ sliding_window,
59
+ device.type,
60
+ getattr(device, "index", None),
61
+ )
62
+ hit = _CSR_CACHE.get(key)
63
+ if hit is not None:
64
+ _CSR_CACHE.move_to_end(key)
65
+ return hit
66
+
67
+ if window_arg == 0:
68
+ row_ptr, col_idx, seq_lens = build_dense_causal_csr(B, Hq, T, BLOCK_SIZE, device)
69
+ else:
70
+ row_ptr, col_idx, seq_lens = build_sliding_window_csr(
71
+ window_arg, T, BLOCK_SIZE, B, Hq, device
72
+ )
73
+ _CSR_CACHE[key] = (row_ptr, col_idx, seq_lens)
74
+ while len(_CSR_CACHE) > _CSR_CACHE_MAX:
75
+ _CSR_CACHE.popitem(last=False)
76
+ return row_ptr, col_idx, seq_lens
77
+
78
+
79
+ def hydra(
80
+ q: torch.Tensor,
81
+ k: torch.Tensor,
82
+ v: torch.Tensor,
83
+ *,
84
+ is_causal: bool = True,
85
+ sliding_window: int | None = None,
86
+ policy_layer_idx: int | None = None,
87
+ precision: str = "high",
88
+ ) -> torch.Tensor:
89
+ """Blackwell-tuned causal FlashAttention with GQA and optional sliding window.
90
+
91
+ Same semantics as ``hydra.hydra``.
92
+ """
93
+ if precision not in {"high", "fast"}:
94
+ raise ValueError(f"precision must be 'high' or 'fast', got {precision!r}")
95
+ if precision != "high":
96
+ raise NotImplementedError("precision='fast' is not wired in this extracted Hydra API yet")
97
+ if not is_causal:
98
+ raise NotImplementedError("non-causal attention is not supported in this version")
99
+ if q.dtype != torch.bfloat16 or k.dtype != torch.bfloat16 or v.dtype != torch.bfloat16:
100
+ raise ValueError(f"q/k/v must be bf16; got {q.dtype}, {k.dtype}, {v.dtype}")
101
+ if q.dim() != 4 or k.dim() != 4 or v.dim() != 4:
102
+ raise ValueError(f"q/k/v must be 4D (B, H, T, D); got {q.shape}, {k.shape}, {v.shape}")
103
+
104
+ B, Hq, T, D = q.shape
105
+ if D != HEAD_DIM:
106
+ raise ValueError(f"head_dim={D} must equal HEAD_DIM={HEAD_DIM}")
107
+ Hkv = k.shape[1]
108
+ if Hq % Hkv != 0:
109
+ raise ValueError(f"H_q={Hq} must be a multiple of H_kv={Hkv} (GQA constraint)")
110
+
111
+ decision = apply_runtime_policy(
112
+ q, k, v,
113
+ sliding_window=sliding_window,
114
+ block_size=BLOCK_SIZE,
115
+ head_dim=HEAD_DIM,
116
+ layer_idx=policy_layer_idx,
117
+ )
118
+ sliding_window = decision.effective_window
119
+
120
+ # Decode-step specialization: T_q == 1 with arbitrary T_kv. The decode
121
+ # kernel does not require T_kv % BLOCK_SIZE == 0 and has no Q-blocking,
122
+ # so it services HF generation's per-token call without the eager fallback.
123
+ if T == 1:
124
+ if sliding_window is not None and sliding_window <= 0:
125
+ raise ValueError(f"sliding_window must be positive, got {sliding_window}")
126
+ window_arg = 0 if sliding_window is None else sliding_window
127
+ return FlashAttnHydraDecodeFunction.apply(q, k, v, window_arg)
128
+
129
+ if T % BLOCK_SIZE != 0:
130
+ raise ValueError(f"T={T} must be a multiple of BLOCK_SIZE={BLOCK_SIZE}")
131
+
132
+ if sliding_window is None:
133
+ window_arg = 0
134
+ else:
135
+ if sliding_window <= 0:
136
+ raise ValueError(f"sliding_window must be positive, got {sliding_window}")
137
+ # When the window covers the full sequence, this degrades to dense causal;
138
+ # skip the kernel-side masking work in that case.
139
+ if sliding_window >= T:
140
+ window_arg = 0
141
+ else:
142
+ window_arg = sliding_window
143
+
144
+ row_ptr, col_idx, seq_lens = _get_csr(B, Hq, T, window_arg, q.device, sliding_window)
145
+
146
+ return FlashAttnHydraFunction.apply(q, k, v, row_ptr, col_idx, seq_lens, window_arg)
147
+
148
+
149
+ def csr_cache_clear() -> None:
150
+ """Drop all cached (row_ptr, col_idx, seq_lens) entries."""
151
+ _CSR_CACHE.clear()
152
+
153
+
154
+ def csr_cache_info() -> dict:
155
+ return {"size": len(_CSR_CACHE), "max": _CSR_CACHE_MAX}
156
+
157
+
158
+ # Compatibility aliases while the extraction is still being consolidated.
159
+ hydra_attention = hydra
160
+ flash_attn_blackwell = hydra
torch-ext/hydra/csr.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CSR (Compressed Sparse Row) builders for the Blackwell FA kernel.
2
+
3
+ Convention
4
+ ----------
5
+ The kernel iterates each Q-block's CSR row as ``ColIdx[ci_lo .. ci_hi)`` where
6
+ the **last entry** is the diagonal block (``col_idx[ci_hi - 1] == q_block_id``)
7
+ and the preceding entries are off-diagonal lower-triangular K-blocks (all
8
+ strictly less than ``q_block_id``, by the causal constraint).
9
+
10
+ The kernel applies causal masking + hi/lo precision split *within* the
11
+ diagonal block and a standard online-softmax merge for the off-diagonal
12
+ blocks. Builders MUST place the diagonal as the last entry per Q row;
13
+ otherwise the kernel reads the wrong block as "the one needing causal
14
+ masking" and produces silently-wrong attention outputs.
15
+
16
+ All builders return ``(row_ptr, col_idx, seq_lens)``:
17
+ row_ptr: (B, H, num_q_blocks + 1) int32 — CSR row pointers per (B, H).
18
+ col_idx: (B, H, total_nnz) int32 — CSR column indices.
19
+ seq_lens: (B,) int32 — per-batch sequence length.
20
+
21
+ The (B, H) broadcast is materialized as a contiguous expand so the kernel can
22
+ index without per-head pointer arithmetic.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import math
27
+
28
+ import torch
29
+
30
+
31
+ def _broadcast_csr(
32
+ row_ptr_row: list[int],
33
+ col_idx_row: list[int],
34
+ batch_size: int,
35
+ num_heads: int,
36
+ seq_len: int,
37
+ device: torch.device | str,
38
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
39
+ """Lift per-row Python lists to per-(B,H) contiguous int32 tensors."""
40
+ row_ptr = (
41
+ torch.tensor(row_ptr_row, device=device, dtype=torch.int32)
42
+ .view(1, 1, -1)
43
+ .expand(batch_size, num_heads, -1)
44
+ .contiguous()
45
+ )
46
+ col_idx = (
47
+ torch.tensor(col_idx_row, device=device, dtype=torch.int32)
48
+ .view(1, 1, -1)
49
+ .expand(batch_size, num_heads, -1)
50
+ .contiguous()
51
+ )
52
+ seq_lens = torch.full((batch_size,), seq_len, device=device, dtype=torch.int32)
53
+ return row_ptr, col_idx, seq_lens
54
+
55
+
56
+ def build_dense_causal_csr(
57
+ batch_size: int,
58
+ num_heads: int,
59
+ seq_len: int,
60
+ block_size: int,
61
+ device: torch.device | str,
62
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
63
+ """Dense lower-triangular causal pattern.
64
+
65
+ For Q-block i: off-diagonal entries enumerate K-blocks 0..i-1, then the
66
+ diagonal placeholder i. nnz scales as O(num_q_blocks^2 / 2).
67
+ """
68
+ num_q_blocks = math.ceil(seq_len / block_size)
69
+ row_ptr_row = [0]
70
+ col_idx_row: list[int] = []
71
+ for q_block in range(num_q_blocks):
72
+ col_idx_row.extend(range(q_block))
73
+ col_idx_row.append(q_block)
74
+ row_ptr_row.append(len(col_idx_row))
75
+ return _broadcast_csr(row_ptr_row, col_idx_row, batch_size, num_heads, seq_len, device)
76
+
77
+
78
+ def build_sliding_window_csr(
79
+ window: int,
80
+ seq_len: int,
81
+ block_size: int,
82
+ batch_size: int,
83
+ num_heads: int,
84
+ device: torch.device | str,
85
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
86
+ """Causal sliding-window pattern, widened for per-Q-token masking.
87
+
88
+ ``window`` is measured in tokens (transformers / Mistral convention):
89
+ Q-token at position p attends K-tokens [max(0, p - window + 1), p].
90
+
91
+ The CSR is built using the **"some Q-token attends"** criterion — a
92
+ K-block is included if ANY Q-token in the Q-block could attend ANY
93
+ K-token in it. The kernel applies a per-Q-token in-window mask inside
94
+ the off-diagonal-leftmost and diagonal blocks, so over-included
95
+ K-tokens at the leftmost edge get masked to -inf and contribute zero.
96
+
97
+ Concretely, for Q-block i, the leftmost-attended K-token across all
98
+ Q-tokens in the block is ``max(0, i*BS - window + 1)``. The K-block
99
+ containing that token is its position // BS.
100
+
101
+ Edge cases:
102
+ - window >= seq_len: degrades to dense_causal (full lower triangle).
103
+ - window == 1: each Q-token only attends itself; diagonal-only with
104
+ within-block causal mask.
105
+
106
+ nnz scales as O(num_q_blocks * ceil(window / BS)) — linear in T for
107
+ fixed window, which is the point of sliding window.
108
+ """
109
+ if window <= 0:
110
+ raise ValueError(f"window must be positive, got {window}")
111
+ if block_size <= 0:
112
+ raise ValueError(f"block_size must be positive, got {block_size}")
113
+ num_q_blocks = math.ceil(seq_len / block_size)
114
+ row_ptr_row = [0]
115
+ col_idx_row: list[int] = []
116
+ for q_block in range(num_q_blocks):
117
+ # Smallest K-token position attended by any Q-token in this Q-block.
118
+ # The leftmost Q-token (position q_block * BS) has the most reach to
119
+ # the left; its window starts at q_block*BS - window + 1.
120
+ left_bound = q_block * block_size - window + 1
121
+ if left_bound <= 0:
122
+ k_min_block = 0
123
+ else:
124
+ # Smallest n with (n+1)*BS - 1 >= left_bound, i.e., n = left_bound // BS.
125
+ k_min_block = left_bound // block_size
126
+ # Off-diagonal entries: k_min_block .. q_block - 1
127
+ col_idx_row.extend(range(k_min_block, q_block))
128
+ # Diagonal placeholder
129
+ col_idx_row.append(q_block)
130
+ row_ptr_row.append(len(col_idx_row))
131
+ return _broadcast_csr(row_ptr_row, col_idx_row, batch_size, num_heads, seq_len, device)
torch-ext/hydra/function.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autograd ``Function`` wrapper for the Blackwell FA kernel (perf-tuned variant).
2
+
3
+ Changes vs. upstream ``hydra.function``:
4
+
5
+ - The transposed-CSR is fetched through ``build_csrT_cached`` (LRU keyed
6
+ on pattern *content*, not ``data_ptr()``). The upstream cache misses
7
+ every call today because ``api.hydra`` re-allocates
8
+ ``row_ptr`` / ``col_idx`` per invocation; the new key fixes that.
9
+
10
+ - ``rp_T`` / ``ci_T`` are saved through ``ctx.save_for_backward`` so the
11
+ backward never re-builds them. This matches the upstream behaviour but
12
+ is now meaningfully cheap on cache hit (~O(num_q_blocks) device-side
13
+ for the key check).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+
19
+ from .kernel_fwd import BLOCK_SIZE, launch_attn_fwd
20
+ from .kernel_bwd import build_csrT_cached, launch_attn_bwd
21
+ from .kernel_decode import launch_attn_fwd_decode
22
+
23
+
24
+ class FlashAttnHydraFunction(torch.autograd.Function):
25
+ """Autograd-aware wrapper. See ``api.hydra`` for the public surface."""
26
+
27
+ @staticmethod
28
+ def forward(ctx, q, k, v, row_ptr, col_idx, seq_lens, window):
29
+ o, lse = launch_attn_fwd(q, k, v, row_ptr, col_idx, seq_lens, window=window)
30
+ num_q_blocks = q.shape[2] // BLOCK_SIZE
31
+ rp_T, ci_T = build_csrT_cached(row_ptr, col_idx, num_q_blocks)
32
+ ctx.save_for_backward(q, k, v, o, lse, row_ptr, col_idx, seq_lens, rp_T, ci_T)
33
+ ctx.window = window
34
+ return o
35
+
36
+ @staticmethod
37
+ def backward(ctx, do):
38
+ q, k, v, o, lse, row_ptr, col_idx, seq_lens, rp_T, ci_T = ctx.saved_tensors
39
+ # Both o and do must be contiguous for the delta kernel. The saved o
40
+ # comes from launch_attn_fwd (torch.empty_like(q) — contiguous if q is)
41
+ # but transformers can save a view of o into ctx, and `do` arriving
42
+ # from upstream is often a transpose view from the attention output
43
+ # reshape. Make both contiguous defensively.
44
+ dq, dk, dv = launch_attn_bwd(
45
+ q, k, v, o.contiguous(), do.contiguous(), lse,
46
+ row_ptr, col_idx, seq_lens,
47
+ row_ptr_T=rp_T, col_idx_T=ci_T,
48
+ window=ctx.window,
49
+ )
50
+ return dq, dk, dv, None, None, None, None
51
+
52
+
53
+ class FlashAttnHydraDecodeFunction(torch.autograd.Function):
54
+ """Forward-only autograd wrapper for the T_q==1 decode kernel.
55
+
56
+ Generation runs under torch.no_grad so backward is intentionally
57
+ unimplemented; calling .backward() raises with a clear message.
58
+ """
59
+
60
+ @staticmethod
61
+ def forward(ctx, q, k, v, window):
62
+ o, lse = launch_attn_fwd_decode(q, k, v, window=window)
63
+ ctx.save_for_backward(q, k, v, o, lse)
64
+ ctx.window = window
65
+ ctx.set_materialize_grads(False)
66
+ return o
67
+
68
+ @staticmethod
69
+ def backward(ctx, do):
70
+ raise NotImplementedError(
71
+ "FlashAttnHydraDecodeFunction has no backward. "
72
+ "Decode-step (T_q == 1) is forward-only — wrap your call in "
73
+ "torch.no_grad() (HF generation does this automatically). "
74
+ "For training, use the prefill kernel with T_q == T_kv."
75
+ )
torch-ext/hydra/kernel_bwd.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backward Triton kernels for Blackwell-tuned causal FlashAttention.
2
+
3
+ Perf-tuned variant of ``hydra.kernel_bwd`` that
4
+
5
+ - keeps the Triton kernels byte-for-byte identical to the upstream
6
+ versions (so the gradient-correctness story is unchanged),
7
+ - replaces ``build_csrT``'s CPU + numpy round-trip with a pure-torch /
8
+ GPU-resident equivalent (``build_csrT_gpu``) that produces the same
9
+ output and stays on the input device,
10
+ - exposes a module-level LRU cache (``build_csrT_cached``) keyed on
11
+ pattern shape rather than ``data_ptr()`` so per-call rebuilds of
12
+ ``row_ptr`` / ``col_idx`` (as the api does today) still hit the
13
+ cache,
14
+ - clears the host-side ``delta = (do.float() * o.float()).sum(-1)``
15
+ cast spew: the existing launcher computes ``delta`` with two fp32
16
+ upcasts and a stride-collapsing ``.contiguous()`` every call; we keep
17
+ the math but drop the redundant ``.contiguous()`` (the result of
18
+ ``.sum(-1)`` on a contiguous 4D tensor is already contiguous).
19
+
20
+ Public API is preserved: ``launch_attn_bwd`` has the same signature, and
21
+ ``build_csrT`` is kept as a thin wrapper that dispatches to the pure-GPU
22
+ builder when possible (and falls back to the original CPU+numpy path
23
+ when the input is on CPU, so the CPU equivalence test exercises the
24
+ same code path).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import os
30
+ from collections import OrderedDict
31
+
32
+ import torch
33
+ import triton
34
+ import triton.language as tl
35
+
36
+ from .kernel_fwd import BLOCK_SIZE, HEAD_DIM
37
+ from .kernel_delta import launch_compute_delta
38
+
39
+
40
+ _BWD_NUM_WARPS = int(os.environ.get("HYDRA_BWD_NUM_WARPS", "4"))
41
+ _BWD_NUM_STAGES = int(os.environ.get("HYDRA_BWD_NUM_STAGES", "1"))
42
+ _DISABLE_AUTOTUNE = int(os.environ.get("HYDRA_DISABLE_AUTOTUNE", "0"))
43
+
44
+ _CSRT_CACHE_MAX = int(os.environ.get("HYDRA_CSRT_CACHE_MAX", "32"))
45
+
46
+
47
+ def _autotune_configs() -> list[triton.Config]:
48
+ configs: list[triton.Config] = []
49
+ for num_warps in (2, 4, 8):
50
+ for num_stages in (1, 2):
51
+ configs.append(triton.Config({}, num_warps=num_warps, num_stages=num_stages))
52
+ return configs
53
+
54
+
55
+ def _kernel_decorator(jit_kernel):
56
+ if _DISABLE_AUTOTUNE:
57
+ return jit_kernel
58
+ return triton.autotune(
59
+ configs=_autotune_configs(),
60
+ # Include WINDOW: sliding-window patterns have 2-4× fewer K-blocks per
61
+ # Q-block than dense, so optimal warps/stages plausibly differ. Keying
62
+ # them separately costs one extra autotune sweep per (T, window) pair.
63
+ key=["T_MAX", "D", "NUM_HEADS", "NUM_KV_HEADS", "WINDOW"],
64
+ )(jit_kernel)
65
+
66
+
67
+ # ----------------------------- csrT builders -----------------------------
68
+
69
+
70
+ def _build_csrT_cpu_reference(row_ptr: torch.Tensor, col_idx: torch.Tensor, num_blocks: int):
71
+ """Original CPU + numpy implementation, kept for reference / fallback.
72
+
73
+ Identical semantics to the upstream ``build_csrT`` in
74
+ ``hydra.kernel_bwd``: per (B, H), enumerate which
75
+ Q-blocks reference each K-block as an OFF-diagonal attendee. The
76
+ diagonal placeholder at ``col_idx[ci_hi - 1]`` is excluded.
77
+ """
78
+ import numpy as np
79
+ rp = row_ptr.detach().cpu().numpy()
80
+ ci = col_idx.detach().cpu().numpy()
81
+ B, H = rp.shape[0], rp.shape[1]
82
+ rp_flat = rp.reshape(B * H, -1)
83
+ ci_flat = ci.reshape(B * H, -1)
84
+ rpt_rows: list[list[int]] = []
85
+ cit_rows: list[list[int]] = []
86
+ for bh in range(B * H):
87
+ bucket: list[list[int]] = [[] for _ in range(num_blocks)]
88
+ rp_row = rp_flat[bh]
89
+ ci_row = ci_flat[bh]
90
+ for i in range(num_blocks):
91
+ lo = int(rp_row[i])
92
+ hi = int(rp_row[i + 1])
93
+ for p in range(lo, max(lo, hi - 1)):
94
+ bucket[int(ci_row[p])].append(i)
95
+ offs = [0]
96
+ vals: list[int] = []
97
+ for k in range(num_blocks):
98
+ vals.extend(bucket[k])
99
+ offs.append(len(vals))
100
+ rpt_rows.append(offs)
101
+ cit_rows.append(vals)
102
+ max_nnz = max(1, max(len(v) for v in cit_rows))
103
+ cit_pad = np.zeros((B * H, max_nnz), dtype=np.int32)
104
+ for bh, v in enumerate(cit_rows):
105
+ if v:
106
+ cit_pad[bh, : len(v)] = v
107
+ rpt_np = np.asarray(rpt_rows, dtype=np.int32)
108
+ rp_T = torch.from_numpy(rpt_np).to(row_ptr.device).reshape(B, H, num_blocks + 1).contiguous()
109
+ ci_T = torch.from_numpy(cit_pad).to(row_ptr.device).reshape(B, H, max_nnz).contiguous()
110
+ return rp_T, ci_T
111
+
112
+
113
+ def _is_broadcast_pattern(row_ptr: torch.Tensor, col_idx: torch.Tensor) -> bool:
114
+ """Cheap check: does the (B, H) pattern collapse to a single shared row?
115
+
116
+ The CSR builders in ``hydra.csr`` always produce a
117
+ broadcast-then-contiguous pattern. We exploit that to compute the
118
+ transposed CSR once and ``.expand().contiguous()`` it back to
119
+ (B, H). For safety we verify by comparing row_ptr/col_idx against
120
+ the (0, 0) head.
121
+ """
122
+ B, H = row_ptr.shape[0], row_ptr.shape[1]
123
+ if B == 1 and H == 1:
124
+ return True
125
+ rp0 = row_ptr[0, 0]
126
+ ci0 = col_idx[0, 0]
127
+ # equal_to expects same-shape comparand; use a single torch.equal per
128
+ # axis. This costs O(B*H*nnz) but is GPU-resident and ~1us for typical
129
+ # shapes — far cheaper than the .cpu()/.numpy() round-trip.
130
+ return bool(torch.all(row_ptr == rp0).item()) and bool(torch.all(col_idx == ci0).item())
131
+
132
+
133
+ def _build_csrT_single_row(rp_row: torch.Tensor, ci_row: torch.Tensor, num_blocks: int):
134
+ """Pure-torch transposed-CSR for a single (B=H=1) row.
135
+
136
+ Produces ``(rpt_row, cit_row)`` of dtype int32 on the same device as
137
+ the inputs. The ordering of q_block_ids within each K-row matches
138
+ the CPU reference: ascending q_block_id.
139
+ """
140
+ device = rp_row.device
141
+ rp_row = rp_row.to(torch.int64)
142
+ ci_row = ci_row.to(torch.int64)
143
+
144
+ # Off-diagonal "off" slices per Q-row: [lo, hi - 1). Total off-diag
145
+ # entries summed across Q-blocks equals nnz - num_blocks (one diag
146
+ # per Q-block). Build a flat (q_block_id, k_block_id) edge list.
147
+ counts = (rp_row[1:] - rp_row[:-1] - 1).clamp(min=0) # off-diag count per Q-block
148
+ num_offdiag = int(counts.sum().item())
149
+ if num_offdiag == 0:
150
+ rp_T_row = torch.zeros(num_blocks + 1, dtype=torch.int32, device=device)
151
+ ci_T_row = torch.zeros(1, dtype=torch.int32, device=device)
152
+ return rp_T_row, ci_T_row, 1
153
+
154
+ # q_idx[e] = which Q-block this edge belongs to.
155
+ q_idx = torch.repeat_interleave(
156
+ torch.arange(num_blocks, dtype=torch.int64, device=device),
157
+ counts,
158
+ )
159
+ # k_idx[e] = which K-block this edge points at. We need the slice
160
+ # ci_row[lo : hi - 1] for each Q-row, concatenated. Build a flat
161
+ # index into ci_row by starting at lo for each Q-block and adding the
162
+ # within-row offset (0, 1, 2, ...).
163
+ starts = rp_row[:-1] # lo per Q-block
164
+ edge_within = torch.arange(num_offdiag, dtype=torch.int64, device=device) - torch.repeat_interleave(
165
+ torch.cat([
166
+ torch.zeros(1, dtype=torch.int64, device=device),
167
+ counts.cumsum(0)[:-1],
168
+ ]),
169
+ counts,
170
+ )
171
+ edge_pos = torch.repeat_interleave(starts, counts) + edge_within
172
+ k_idx = ci_row[edge_pos]
173
+
174
+ # Sort edges by k_idx (stable so q_idx within a bucket stays ascending).
175
+ sort_idx = torch.argsort(k_idx, stable=True)
176
+ k_idx_sorted = k_idx[sort_idx]
177
+ q_idx_sorted = q_idx[sort_idx]
178
+
179
+ # rp_T[k+1] = count of edges with k_idx <= k. Use bincount over [0, num_blocks).
180
+ per_k_count = torch.bincount(k_idx_sorted, minlength=num_blocks)
181
+ rp_T_row = torch.zeros(num_blocks + 1, dtype=torch.int64, device=device)
182
+ rp_T_row[1:] = per_k_count.cumsum(0)
183
+
184
+ max_nnz = max(1, num_offdiag)
185
+ ci_T_row = torch.zeros(max_nnz, dtype=torch.int32, device=device)
186
+ ci_T_row[:num_offdiag] = q_idx_sorted.to(torch.int32)
187
+ return rp_T_row.to(torch.int32), ci_T_row, max_nnz
188
+
189
+
190
+ def build_csrT_gpu(row_ptr: torch.Tensor, col_idx: torch.Tensor, num_blocks: int):
191
+ """Pure-device transposed-CSR builder.
192
+
193
+ Equivalent to ``build_csrT`` but never touches CPU. When the input
194
+ pattern is the broadcast-across-(B,H) form (the common case), only
195
+ one head's worth of work is done and the result is expanded.
196
+
197
+ Falls back to a per-(B,H) torch implementation for the non-broadcast
198
+ case (no current api path produces that, but keep it safe).
199
+ """
200
+ if row_ptr.dim() != 3 or col_idx.dim() != 3:
201
+ raise ValueError(f"expected 3D row_ptr/col_idx; got {row_ptr.shape}, {col_idx.shape}")
202
+ B, H = row_ptr.shape[0], row_ptr.shape[1]
203
+
204
+ if _is_broadcast_pattern(row_ptr, col_idx):
205
+ rp_T_row, ci_T_row, max_nnz = _build_csrT_single_row(row_ptr[0, 0], col_idx[0, 0], num_blocks)
206
+ rp_T = rp_T_row.view(1, 1, num_blocks + 1).expand(B, H, num_blocks + 1).contiguous()
207
+ ci_T = ci_T_row.view(1, 1, max_nnz).expand(B, H, max_nnz).contiguous()
208
+ return rp_T, ci_T
209
+
210
+ # Non-broadcast path: per-(B,H) computation. We loop in Python over
211
+ # the B*H heads but each head's work stays on-device. For B*H up to
212
+ # a few thousand this is still much cheaper than .cpu().numpy().
213
+ out_rp_rows: list[torch.Tensor] = []
214
+ out_ci_rows: list[torch.Tensor] = []
215
+ max_nnz_seen = 1
216
+ for bh in range(B * H):
217
+ bi, hi = bh // H, bh % H
218
+ rp_T_row, ci_T_row, mn = _build_csrT_single_row(row_ptr[bi, hi], col_idx[bi, hi], num_blocks)
219
+ out_rp_rows.append(rp_T_row)
220
+ out_ci_rows.append(ci_T_row)
221
+ max_nnz_seen = max(max_nnz_seen, mn)
222
+
223
+ rp_T = torch.stack(out_rp_rows, dim=0).view(B, H, num_blocks + 1).contiguous()
224
+ # Right-pad to max_nnz_seen.
225
+ ci_T = torch.zeros(B * H, max_nnz_seen, dtype=torch.int32, device=row_ptr.device)
226
+ for bh, row in enumerate(out_ci_rows):
227
+ ci_T[bh, : row.shape[0]] = row
228
+ ci_T = ci_T.view(B, H, max_nnz_seen).contiguous()
229
+ return rp_T, ci_T
230
+
231
+
232
+ def build_csrT(row_ptr: torch.Tensor, col_idx: torch.Tensor, num_blocks: int):
233
+ """Drop-in replacement for the upstream ``build_csrT``.
234
+
235
+ Routes to the pure-GPU builder on CUDA inputs and to the
236
+ CPU+numpy reference on CPU inputs (so the equivalence test exercises
237
+ the same numerical path the kernel sees).
238
+ """
239
+ if row_ptr.is_cuda:
240
+ return build_csrT_gpu(row_ptr, col_idx, num_blocks)
241
+ return _build_csrT_cpu_reference(row_ptr, col_idx, num_blocks)
242
+
243
+
244
+ # ----------------------------- csrT cache --------------------------------
245
+
246
+
247
+ # Two-level cache.
248
+ #
249
+ # - _CSRT_CACHE_FAST is keyed on (row_ptr.data_ptr(), col_idx.data_ptr(),
250
+ # shapes, device, num_blocks). When the public api caches the CSR tensors,
251
+ # the data_ptrs are stable across calls and this is an O(1) Python dict probe — no
252
+ # CUDA sync, no D2H copy.
253
+ #
254
+ # - _CSRT_CACHE_CONTENT is keyed on (num_blocks, shape, first-row
255
+ # content). Used as a fallback when the fast key misses (e.g. a user
256
+ # who builds CSRs fresh each call). The first-row content read costs
257
+ # one (num_blocks+1) + first-row-nnz int32 D2H sync — still vastly
258
+ # cheaper than the full CPU+numpy round-trip on the build side.
259
+ #
260
+ # On a content-cache hit we also promote into the fast cache so the next
261
+ # call with the same data_ptr is free.
262
+ _CSRT_CACHE_FAST: "OrderedDict[tuple, tuple[torch.Tensor, torch.Tensor]]" = OrderedDict()
263
+ _CSRT_CACHE_CONTENT: "OrderedDict[tuple, tuple[torch.Tensor, torch.Tensor]]" = OrderedDict()
264
+
265
+
266
+ def _fast_key(row_ptr: torch.Tensor, col_idx: torch.Tensor, num_blocks: int) -> tuple:
267
+ return (
268
+ row_ptr.data_ptr(),
269
+ col_idx.data_ptr(),
270
+ num_blocks,
271
+ tuple(row_ptr.shape),
272
+ tuple(col_idx.shape),
273
+ row_ptr.device.type,
274
+ getattr(row_ptr.device, "index", None),
275
+ )
276
+
277
+
278
+ def _content_key(row_ptr: torch.Tensor, col_idx: torch.Tensor, num_blocks: int) -> tuple:
279
+ """Content-addressed key. Reads the (0, 0) head's row to cover the
280
+ broadcast-CSR case (the only one the api emits today).
281
+
282
+ Invalidation rule
283
+ -----------------
284
+ A new entry is created whenever any of the following change:
285
+ - num_blocks
286
+ - row_ptr.shape / col_idx.shape
287
+ - device type / index
288
+ - the (0, 0) head's row_ptr / col_idx values
289
+
290
+ Non-broadcast inputs that share the (0, 0) row but differ on other
291
+ (B, H) entries would alias under this key. The api never produces
292
+ such inputs, but ``launch_attn_bwd`` users with hand-built CSRs
293
+ should call ``csrT_cache_clear()`` between distinct non-broadcast
294
+ patterns.
295
+ """
296
+ rp0 = tuple(row_ptr[0, 0].to(torch.int64).tolist())
297
+ ci0 = tuple(col_idx[0, 0].to(torch.int64).tolist())
298
+ return (
299
+ num_blocks,
300
+ tuple(row_ptr.shape),
301
+ tuple(col_idx.shape),
302
+ row_ptr.device.type,
303
+ getattr(row_ptr.device, "index", None),
304
+ rp0,
305
+ ci0,
306
+ )
307
+
308
+
309
+ def _bound(cache: "OrderedDict") -> None:
310
+ while len(cache) > _CSRT_CACHE_MAX:
311
+ cache.popitem(last=False)
312
+
313
+
314
+ def build_csrT_cached(row_ptr: torch.Tensor, col_idx: torch.Tensor, num_blocks: int):
315
+ """Return a (rp_T, ci_T) pair for the given CSR, hitting the LRU cache when possible.
316
+
317
+ Thread-safety: this is not thread-safe across CUDA streams. Callers
318
+ that share the same CSR across streams should manually clone the
319
+ returned tensors. In practice the autograd backward is called on the
320
+ same stream as the forward that produced the CSR, so this is fine.
321
+ """
322
+ fk = _fast_key(row_ptr, col_idx, num_blocks)
323
+ hit = _CSRT_CACHE_FAST.get(fk)
324
+ if hit is not None:
325
+ _CSRT_CACHE_FAST.move_to_end(fk)
326
+ return hit
327
+ ck = _content_key(row_ptr, col_idx, num_blocks)
328
+ hit = _CSRT_CACHE_CONTENT.get(ck)
329
+ if hit is not None:
330
+ _CSRT_CACHE_CONTENT.move_to_end(ck)
331
+ _CSRT_CACHE_FAST[fk] = hit
332
+ _bound(_CSRT_CACHE_FAST)
333
+ return hit
334
+ rp_T, ci_T = build_csrT(row_ptr, col_idx, num_blocks)
335
+ _CSRT_CACHE_FAST[fk] = (rp_T, ci_T)
336
+ _CSRT_CACHE_CONTENT[ck] = (rp_T, ci_T)
337
+ _bound(_CSRT_CACHE_FAST)
338
+ _bound(_CSRT_CACHE_CONTENT)
339
+ return rp_T, ci_T
340
+
341
+
342
+ def csrT_cache_clear() -> None:
343
+ """Drop all cached csrT entries. Useful for test isolation."""
344
+ _CSRT_CACHE_FAST.clear()
345
+ _CSRT_CACHE_CONTENT.clear()
346
+
347
+
348
+ def csrT_cache_info() -> dict:
349
+ """Return basic cache stats. ``size`` is the number of *distinct*
350
+ csrT patterns currently cached (the content-keyed dict)."""
351
+ return {
352
+ "size": len(_CSRT_CACHE_CONTENT),
353
+ "fast_size": len(_CSRT_CACHE_FAST),
354
+ "max": _CSRT_CACHE_MAX,
355
+ }
356
+
357
+
358
+ # ----------------------------- Triton kernels ----------------------------
359
+ # These are kept local so the extracted package is self-contained.
360
+
361
+
362
+ @triton.jit
363
+ def _hydra_bwd_dq_jit(
364
+ Q, K, V, LSE,
365
+ dO,
366
+ Di_in,
367
+ dQ,
368
+ RowPtr, ColIdx, SeqLens,
369
+ stride_cih,
370
+ T_MAX: tl.constexpr,
371
+ NUM_HEADS: tl.constexpr,
372
+ NUM_KV_HEADS: tl.constexpr,
373
+ SCALE: tl.constexpr,
374
+ BS: tl.constexpr,
375
+ D: tl.constexpr,
376
+ WINDOW: tl.constexpr,
377
+ CONTIGUOUS_OFFDIAG: tl.constexpr = 0,
378
+ ):
379
+ stride_h: tl.constexpr = T_MAX * D
380
+ stride_t: tl.constexpr = D
381
+ stride_rph: tl.constexpr = (T_MAX // BS) + 1
382
+ stride_lh: tl.constexpr = T_MAX
383
+ NUM_Q_BLOCKS: tl.constexpr = T_MAX // BS
384
+
385
+ pid = tl.program_id(0)
386
+ bh_id = pid // NUM_Q_BLOCKS
387
+ q_block_id = pid % NUM_Q_BLOCKS
388
+
389
+ rep: tl.constexpr = NUM_HEADS // NUM_KV_HEADS
390
+ b_id = bh_id // NUM_HEADS
391
+ hq_id = bh_id % NUM_HEADS
392
+ hkv_id = hq_id // rep
393
+ kv_bh_id = b_id * NUM_KV_HEADS + hkv_id
394
+
395
+ seq_len = tl.load(SeqLens + b_id)
396
+ q_start = q_block_id * BS
397
+ if q_start >= seq_len:
398
+ return
399
+
400
+ offs_tok = q_start + tl.arange(0, BS)
401
+ offs_d = tl.arange(0, D)
402
+ q_mask = offs_tok < seq_len
403
+
404
+ Q_ptr = Q + bh_id * stride_h
405
+ q_bf16 = tl.load(Q_ptr + offs_tok[:, None] * stride_t + offs_d[None, :])
406
+
407
+ dO_ptr = dO + bh_id * stride_h
408
+ dO_i = tl.load(dO_ptr + offs_tok[:, None] * stride_t + offs_d[None, :])
409
+
410
+ LSE_ptr = LSE + bh_id * stride_lh
411
+ LSE_i = tl.load(LSE_ptr + offs_tok)
412
+
413
+ rp_base = RowPtr + bh_id * stride_rph + q_block_id
414
+ ci_lo = tl.load(rp_base)
415
+ ci_hi = tl.load(rp_base + 1)
416
+
417
+ K_ptr = K + kv_bh_id * stride_h
418
+ V_ptr = V + kv_bh_id * stride_h
419
+ CI_ptr = ColIdx + bh_id * stride_cih
420
+
421
+ offs_k_tile = tl.arange(0, BS)
422
+ boundary = (q_start + BS) > seq_len
423
+
424
+ Di = tl.load(Di_in + bh_id * stride_lh + offs_tok, mask=q_mask, other=0.0)
425
+
426
+ dQ_i = tl.zeros([BS, D], dtype=tl.float32)
427
+
428
+ if ci_hi > ci_lo:
429
+ k_start_d = q_block_id * BS
430
+ offs_k_d = k_start_d + offs_k_tile
431
+ if boundary:
432
+ k_mask_d = offs_k_d < seq_len
433
+ k_bf16_d = tl.load(K_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :], mask=k_mask_d[:, None], other=0.0)
434
+ v_bf16_d = tl.load(V_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :], mask=k_mask_d[:, None], other=0.0)
435
+ s_d = tl.dot(q_bf16, tl.trans(k_bf16_d), out_dtype=tl.float32) * SCALE
436
+ causal = offs_tok[:, None] >= offs_k_d[None, :]
437
+ allowed = causal & k_mask_d[None, :]
438
+ if WINDOW > 0:
439
+ in_window = (offs_tok[:, None] - offs_k_d[None, :]) < WINDOW
440
+ allowed = allowed & in_window
441
+ s_d = tl.where(allowed, s_d, float("-inf"))
442
+ else:
443
+ k_bf16_d = tl.load(K_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :])
444
+ v_bf16_d = tl.load(V_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :])
445
+ s_d = tl.dot(q_bf16, tl.trans(k_bf16_d), out_dtype=tl.float32) * SCALE
446
+ causal = offs_tok[:, None] >= offs_k_d[None, :]
447
+ if WINDOW > 0:
448
+ in_window = (offs_tok[:, None] - offs_k_d[None, :]) < WINDOW
449
+ s_d = tl.where(causal & in_window, s_d, float("-inf"))
450
+ else:
451
+ s_d = tl.where(causal, s_d, float("-inf"))
452
+
453
+ P_d = tl.exp(s_d - LSE_i[:, None])
454
+ dP_d = tl.dot(dO_i, tl.trans(v_bf16_d), out_dtype=tl.float32)
455
+ dS_d = (P_d * (dP_d - Di[:, None])) * SCALE
456
+ dQ_i += tl.dot(dS_d.to(tl.bfloat16), k_bf16_d, out_dtype=tl.float32)
457
+
458
+ # See kernel_fwd: when CONTIGUOUS_OFFDIAG, hoist the CSR load.
459
+ if CONTIGUOUS_OFFDIAG:
460
+ k_block_id_start = tl.load(CI_ptr + ci_lo)
461
+ for ci in range(ci_lo, ci_hi - 1):
462
+ k_block_id = k_block_id_start + (ci - ci_lo)
463
+ k_start = k_block_id * BS
464
+ offs_k = k_start + offs_k_tile
465
+ k_bf16 = tl.load(K_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
466
+ v_bf16 = tl.load(V_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
467
+ s = tl.dot(q_bf16, tl.trans(k_bf16), out_dtype=tl.float32) * SCALE
468
+ if WINDOW > 0:
469
+ in_window = (offs_tok[:, None] - offs_k[None, :]) < WINDOW
470
+ s = tl.where(in_window, s, float("-inf"))
471
+ P = tl.exp(s - LSE_i[:, None])
472
+ dP = tl.dot(dO_i, tl.trans(v_bf16), out_dtype=tl.float32)
473
+ dS = (P * (dP - Di[:, None])) * SCALE
474
+ dQ_i += tl.dot(dS.to(tl.bfloat16), k_bf16, out_dtype=tl.float32)
475
+ else:
476
+ for ci in range(ci_lo, ci_hi - 1):
477
+ k_block_id = tl.load(CI_ptr + ci)
478
+ k_start = k_block_id * BS
479
+ offs_k = k_start + offs_k_tile
480
+ k_bf16 = tl.load(K_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
481
+ v_bf16 = tl.load(V_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
482
+ s = tl.dot(q_bf16, tl.trans(k_bf16), out_dtype=tl.float32) * SCALE
483
+ if WINDOW > 0:
484
+ in_window = (offs_tok[:, None] - offs_k[None, :]) < WINDOW
485
+ s = tl.where(in_window, s, float("-inf"))
486
+ P = tl.exp(s - LSE_i[:, None])
487
+ dP = tl.dot(dO_i, tl.trans(v_bf16), out_dtype=tl.float32)
488
+ dS = (P * (dP - Di[:, None])) * SCALE
489
+ dQ_i += tl.dot(dS.to(tl.bfloat16), k_bf16, out_dtype=tl.float32)
490
+
491
+ dQ_ptr = dQ + bh_id * stride_h
492
+ tl.store(dQ_ptr + offs_tok[:, None] * stride_t + offs_d[None, :], dQ_i.to(tl.bfloat16), mask=q_mask[:, None])
493
+
494
+
495
+ @triton.jit
496
+ def _hydra_bwd_dkv_jit(
497
+ Q, K, V, LSE,
498
+ dO,
499
+ Di_in,
500
+ dK, dV,
501
+ RowPtrT, ColIdxT, SeqLens,
502
+ stride_cith,
503
+ T_MAX: tl.constexpr,
504
+ NUM_HEADS: tl.constexpr,
505
+ NUM_KV_HEADS: tl.constexpr,
506
+ SCALE: tl.constexpr,
507
+ BS: tl.constexpr,
508
+ D: tl.constexpr,
509
+ WINDOW: tl.constexpr,
510
+ CONTIGUOUS_OFFDIAG: tl.constexpr = 0,
511
+ ):
512
+ stride_h: tl.constexpr = T_MAX * D
513
+ stride_t: tl.constexpr = D
514
+ stride_rpth: tl.constexpr = (T_MAX // BS) + 1
515
+ stride_lh: tl.constexpr = T_MAX
516
+ NUM_K_BLOCKS: tl.constexpr = T_MAX // BS
517
+
518
+ pid = tl.program_id(0)
519
+ kv_bh_id = pid // NUM_K_BLOCKS
520
+ k_block_id = pid % NUM_K_BLOCKS
521
+
522
+ rep: tl.constexpr = NUM_HEADS // NUM_KV_HEADS
523
+ b_id = kv_bh_id // NUM_KV_HEADS
524
+ hkv_id = kv_bh_id % NUM_KV_HEADS
525
+ q_head_start = hkv_id * rep
526
+
527
+ seq_len = tl.load(SeqLens + b_id)
528
+ k_start = k_block_id * BS
529
+ if k_start >= seq_len:
530
+ return
531
+
532
+ offs_d = tl.arange(0, D)
533
+ offs_tok_k = k_start + tl.arange(0, BS)
534
+ k_mask = offs_tok_k < seq_len
535
+
536
+ K_ptr = K + kv_bh_id * stride_h
537
+ V_ptr = V + kv_bh_id * stride_h
538
+
539
+ k_bf16 = tl.load(K_ptr + offs_tok_k[:, None] * stride_t + offs_d[None, :], mask=k_mask[:, None], other=0.0)
540
+ v_bf16 = tl.load(V_ptr + offs_tok_k[:, None] * stride_t + offs_d[None, :], mask=k_mask[:, None], other=0.0)
541
+
542
+ dK_acc = tl.zeros([BS, D], dtype=tl.float32)
543
+ dV_acc = tl.zeros([BS, D], dtype=tl.float32)
544
+
545
+ for rep_idx in range(rep):
546
+ q_head_id = q_head_start + rep_idx
547
+ q_bh_id = b_id * NUM_HEADS + q_head_id
548
+
549
+ Q_ptr = Q + q_bh_id * stride_h
550
+ dO_ptr = dO + q_bh_id * stride_h
551
+ LSE_ptr = LSE + q_bh_id * stride_lh
552
+ Di_ptr = Di_in + q_bh_id * stride_lh
553
+
554
+ q_start_diag = k_block_id * BS
555
+ offs_tok_q = q_start_diag + tl.arange(0, BS)
556
+ q_mask_diag = offs_tok_q < seq_len
557
+ q_bf16_d = tl.load(Q_ptr + offs_tok_q[:, None] * stride_t + offs_d[None, :], mask=q_mask_diag[:, None], other=0.0)
558
+ dO_d = tl.load(dO_ptr + offs_tok_q[:, None] * stride_t + offs_d[None, :], mask=q_mask_diag[:, None], other=0.0)
559
+ LSE_d = tl.load(LSE_ptr + offs_tok_q, mask=q_mask_diag, other=0.0)
560
+ Di_d = tl.load(Di_ptr + offs_tok_q, mask=q_mask_diag, other=0.0)
561
+
562
+ s_d = tl.dot(q_bf16_d, tl.trans(k_bf16), out_dtype=tl.float32) * SCALE
563
+ causal_d = offs_tok_q[:, None] >= offs_tok_k[None, :]
564
+ allowed_d = causal_d & k_mask[None, :] & q_mask_diag[:, None]
565
+ if WINDOW > 0:
566
+ in_window_d = (offs_tok_q[:, None] - offs_tok_k[None, :]) < WINDOW
567
+ allowed_d = allowed_d & in_window_d
568
+ s_d = tl.where(allowed_d, s_d, float("-inf"))
569
+ P_d = tl.exp(s_d - LSE_d[:, None])
570
+ dP_d = tl.dot(dO_d, tl.trans(v_bf16), out_dtype=tl.float32)
571
+ dS_d = (P_d * (dP_d - Di_d[:, None])) * SCALE
572
+
573
+ dK_acc += tl.dot(tl.trans(dS_d.to(tl.bfloat16)), q_bf16_d, out_dtype=tl.float32)
574
+ dV_acc += tl.dot(tl.trans(P_d.to(tl.bfloat16)), dO_d, out_dtype=tl.float32)
575
+
576
+ rpt_base = RowPtrT + q_bh_id * stride_rpth + k_block_id
577
+ cit_lo = tl.load(rpt_base)
578
+ cit_hi = tl.load(rpt_base + 1)
579
+ CIT_ptr = ColIdxT + q_bh_id * stride_cith
580
+
581
+ # See kernel_fwd: when CONTIGUOUS_OFFDIAG, hoist the transposed-CSR
582
+ # load. For dense, K-block k attends Q-blocks k+1..num_q_blocks-1
583
+ # (contiguous); for sliding-window, k attends a contiguous Q-range
584
+ # too. The transposed-CSR builder (build_csrT) preserves ascending
585
+ # q_block_id order within each K-row, so the run is contiguous.
586
+ if CONTIGUOUS_OFFDIAG:
587
+ q_block_id_start_o = tl.load(CIT_ptr + cit_lo)
588
+ for ci in range(cit_lo, cit_hi):
589
+ q_block_id = q_block_id_start_o + (ci - cit_lo)
590
+ q_start = q_block_id * BS
591
+ offs_tok_q2 = q_start + tl.arange(0, BS)
592
+ q_mask2 = offs_tok_q2 < seq_len
593
+ q_bf16_o = tl.load(Q_ptr + offs_tok_q2[:, None] * stride_t + offs_d[None, :], mask=q_mask2[:, None], other=0.0)
594
+ dO_o = tl.load(dO_ptr + offs_tok_q2[:, None] * stride_t + offs_d[None, :], mask=q_mask2[:, None], other=0.0)
595
+ LSE_o = tl.load(LSE_ptr + offs_tok_q2, mask=q_mask2, other=0.0)
596
+ Di_o = tl.load(Di_ptr + offs_tok_q2, mask=q_mask2, other=0.0)
597
+
598
+ s_o = tl.dot(q_bf16_o, tl.trans(k_bf16), out_dtype=tl.float32) * SCALE
599
+ allowed_o = q_mask2[:, None] & k_mask[None, :]
600
+ if WINDOW > 0:
601
+ in_window_o = (offs_tok_q2[:, None] - offs_tok_k[None, :]) < WINDOW
602
+ allowed_o = allowed_o & in_window_o
603
+ s_o = tl.where(allowed_o, s_o, float("-inf"))
604
+ P_o = tl.exp(s_o - LSE_o[:, None])
605
+ dP_o = tl.dot(dO_o, tl.trans(v_bf16), out_dtype=tl.float32)
606
+ dS_o = (P_o * (dP_o - Di_o[:, None])) * SCALE
607
+
608
+ dK_acc += tl.dot(tl.trans(dS_o.to(tl.bfloat16)), q_bf16_o, out_dtype=tl.float32)
609
+ dV_acc += tl.dot(tl.trans(P_o.to(tl.bfloat16)), dO_o, out_dtype=tl.float32)
610
+ else:
611
+ for ci in range(cit_lo, cit_hi):
612
+ q_block_id = tl.load(CIT_ptr + ci)
613
+ q_start = q_block_id * BS
614
+ offs_tok_q2 = q_start + tl.arange(0, BS)
615
+ q_mask2 = offs_tok_q2 < seq_len
616
+ q_bf16_o = tl.load(Q_ptr + offs_tok_q2[:, None] * stride_t + offs_d[None, :], mask=q_mask2[:, None], other=0.0)
617
+ dO_o = tl.load(dO_ptr + offs_tok_q2[:, None] * stride_t + offs_d[None, :], mask=q_mask2[:, None], other=0.0)
618
+ LSE_o = tl.load(LSE_ptr + offs_tok_q2, mask=q_mask2, other=0.0)
619
+ Di_o = tl.load(Di_ptr + offs_tok_q2, mask=q_mask2, other=0.0)
620
+
621
+ s_o = tl.dot(q_bf16_o, tl.trans(k_bf16), out_dtype=tl.float32) * SCALE
622
+ allowed_o = q_mask2[:, None] & k_mask[None, :]
623
+ if WINDOW > 0:
624
+ in_window_o = (offs_tok_q2[:, None] - offs_tok_k[None, :]) < WINDOW
625
+ allowed_o = allowed_o & in_window_o
626
+ s_o = tl.where(allowed_o, s_o, float("-inf"))
627
+ P_o = tl.exp(s_o - LSE_o[:, None])
628
+ dP_o = tl.dot(dO_o, tl.trans(v_bf16), out_dtype=tl.float32)
629
+ dS_o = (P_o * (dP_o - Di_o[:, None])) * SCALE
630
+
631
+ dK_acc += tl.dot(tl.trans(dS_o.to(tl.bfloat16)), q_bf16_o, out_dtype=tl.float32)
632
+ dV_acc += tl.dot(tl.trans(P_o.to(tl.bfloat16)), dO_o, out_dtype=tl.float32)
633
+
634
+ dK_ptr = dK + kv_bh_id * stride_h + offs_tok_k[:, None] * stride_t + offs_d[None, :]
635
+ dV_ptr = dV + kv_bh_id * stride_h + offs_tok_k[:, None] * stride_t + offs_d[None, :]
636
+ tl.store(dK_ptr, dK_acc.to(tl.bfloat16), mask=k_mask[:, None])
637
+ tl.store(dV_ptr, dV_acc.to(tl.bfloat16), mask=k_mask[:, None])
638
+
639
+
640
+ _hydra_bwd_dq_kernel = _kernel_decorator(_hydra_bwd_dq_jit)
641
+ _hydra_bwd_dkv_kernel = _kernel_decorator(_hydra_bwd_dkv_jit)
642
+
643
+
644
+ # ----------------------------- launcher ----------------------------------
645
+
646
+
647
+ def launch_attn_bwd(
648
+ q: torch.Tensor,
649
+ k: torch.Tensor,
650
+ v: torch.Tensor,
651
+ o: torch.Tensor,
652
+ do: torch.Tensor,
653
+ lse: torch.Tensor,
654
+ row_ptr: torch.Tensor,
655
+ col_idx: torch.Tensor,
656
+ seq_lens: torch.Tensor,
657
+ row_ptr_T: torch.Tensor | None = None,
658
+ col_idx_T: torch.Tensor | None = None,
659
+ window: int = 0,
660
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
661
+ """Launch dQ then dK/dV kernels.
662
+
663
+ Identical signature / semantics to the upstream ``launch_attn_bwd``.
664
+ If ``row_ptr_T`` / ``col_idx_T`` are not provided, builds them via
665
+ the cached pure-GPU builder so repeated calls with the same pattern
666
+ pay a single one-time build cost.
667
+ """
668
+ batch_size, num_heads, t_max, head_dim = q.shape
669
+ num_kv_heads = k.shape[1]
670
+ if t_max % BLOCK_SIZE != 0:
671
+ raise ValueError(f"T ({t_max}) must be a multiple of BLOCK_SIZE ({BLOCK_SIZE})")
672
+ if head_dim != HEAD_DIM:
673
+ raise ValueError(f"head_dim ({head_dim}) must equal HEAD_DIM ({HEAD_DIM})")
674
+ if num_heads % num_kv_heads != 0:
675
+ raise ValueError(f"num_heads ({num_heads}) must be divisible by num_kv_heads ({num_kv_heads})")
676
+
677
+ batch_heads_q = batch_size * num_heads
678
+ batch_heads_kv = batch_size * num_kv_heads
679
+ num_q_blocks = t_max // BLOCK_SIZE
680
+
681
+ dq = torch.empty_like(q)
682
+ dk = torch.empty_like(k)
683
+ dv = torch.empty_like(v)
684
+
685
+ # delta[b, h, t] = sum_d O[b, h, t, d] * dO[b, h, t, d]
686
+ # Single-pass Triton kernel avoids the ~3 fp32 transients (do.float(),
687
+ # o.float(), their product) the host pipeline allocates — ~256 MB
688
+ # steady-state HBM saved at Qwen3-8B T=8192. Bounded reorder error
689
+ # vs torch's .sum(-1) is ~1.5e-5, three orders below bf16 quantum.
690
+ delta = launch_compute_delta(o, do)
691
+ delta_2d = delta.view(batch_heads_q, t_max)
692
+ lse_2d = lse.view(batch_heads_q, t_max)
693
+
694
+ if row_ptr_T is None or col_idx_T is None:
695
+ row_ptr_T, col_idx_T = build_csrT_cached(row_ptr, col_idx, num_q_blocks)
696
+
697
+ stride_cih = col_idx.shape[2] if col_idx.ndim == 3 else col_idx.shape[1]
698
+ stride_cith = col_idx_T.shape[2] if col_idx_T.ndim == 3 else col_idx_T.shape[1]
699
+ scale = 1.0 / math.sqrt(head_dim)
700
+
701
+ grid_q = (num_q_blocks * batch_heads_q,)
702
+ grid_kv = (num_q_blocks * batch_heads_kv,)
703
+
704
+ common_kwargs = dict(
705
+ T_MAX=t_max,
706
+ NUM_HEADS=num_heads,
707
+ NUM_KV_HEADS=num_kv_heads,
708
+ SCALE=scale,
709
+ BS=BLOCK_SIZE,
710
+ D=head_dim,
711
+ WINDOW=int(window),
712
+ # Both build_dense_causal_csr and build_sliding_window_csr emit
713
+ # off-diag K-block IDs as a contiguous run; the transposed CSR
714
+ # builder preserves ascending q_block_id within each K-row, which
715
+ # is likewise contiguous for these patterns. Lets the kernels
716
+ # hoist the per-iteration CSR scalar GMEM load.
717
+ CONTIGUOUS_OFFDIAG=1,
718
+ )
719
+ if _DISABLE_AUTOTUNE:
720
+ common_kwargs["num_warps"] = _BWD_NUM_WARPS
721
+ common_kwargs["num_stages"] = _BWD_NUM_STAGES
722
+
723
+ _hydra_bwd_dq_kernel[grid_q](
724
+ q, k, v, lse_2d,
725
+ do,
726
+ delta_2d,
727
+ dq,
728
+ row_ptr, col_idx, seq_lens,
729
+ stride_cih,
730
+ **common_kwargs,
731
+ )
732
+
733
+ _hydra_bwd_dkv_kernel[grid_kv](
734
+ q, k, v, lse_2d,
735
+ do,
736
+ delta_2d,
737
+ dk, dv,
738
+ row_ptr_T, col_idx_T, seq_lens,
739
+ stride_cith,
740
+ **common_kwargs,
741
+ )
742
+
743
+ return dq, dk, dv
torch-ext/hydra/kernel_decode.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forward Triton kernel for the decode step: Q.seq_len == 1.
2
+
3
+ Specialization
4
+ --------------
5
+ Generation hot path: one new Q token attends to a full KV cache of length
6
+ T_kv. The prefill kernel in ``kernel_fwd.py`` requires T % BLOCK_SIZE == 0
7
+ and pays for Q-blocking + causal CSR iteration that is meaningless when
8
+ there is exactly one query row. This kernel collapses to:
9
+
10
+ program grid = (B * H_q,) # one program per query head
11
+ Q broadcast-replicated to a [MM, D] tile (MM=16, the tcore M minimum)
12
+ K/V streamed in [BK, D] tiles across the visible K range
13
+
14
+ Online softmax with a single Q row reduces to scalar (m, l) and a [1, D]
15
+ accumulator (we lift it to [MM, D] with broadcast so Triton's tl.dot —
16
+ which requires M >= 16 on Blackwell — sees a tensor-core-sized matmul).
17
+ Only the first row of the [MM, D] output is actually distinct; we keep
18
+ the broadcast cost (~16× duplicate work) because the V matmul is the
19
+ heavy cost and dominates regardless. The alternative — manually emitting
20
+ fma loops — would not use tensor cores and would be far slower.
21
+
22
+ No causal mask is needed because the single Q token is at absolute
23
+ position T_kv - 1 and every visible K token is <= that position.
24
+
25
+ Sliding window
26
+ --------------
27
+ When ``WINDOW > 0`` and ``WINDOW < T_kv``, K is restricted to the last
28
+ WINDOW positions: ``k_start = T_kv - WINDOW``. The launcher computes
29
+ ``k_start`` and the number of K tiles; the kernel iterates that range
30
+ unconditionally and masks the tail tile where ``offs_k >= T_kv``.
31
+
32
+ GQA
33
+ ---
34
+ Each H_q head reads the same K/V slab indexed by
35
+ ``hkv = hq // (NUM_HEADS // NUM_KV_HEADS)``.
36
+
37
+ Precision
38
+ ---------
39
+ bf16 inputs, fp32 accumulator, bf16 output. The kernel casts ``p`` to bf16
40
+ once per K-tile before the ``p @ V`` matmul. No hi/lo precision split:
41
+ without a causal mask there is no single "diagonal" block where the cast
42
+ loss is concentrated; for T_kv up to ~32K this stays well inside bf16's
43
+ useful range and the test below confirms a max abs diff < 3e-2 against
44
+ SDPA.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import math
49
+ import os
50
+
51
+ import torch
52
+ import triton
53
+ import triton.language as tl
54
+
55
+
56
+ # K-tile size for the decode kernel. Independent of the prefill kernel's
57
+ # BLOCK_SIZE because the decode kernel never reads CSR; it just streams.
58
+ BLOCK_K = int(os.environ.get("HYDRA_DECODE_BLOCK_K", "64"))
59
+ HEAD_DIM = int(os.environ.get("HYDRA_HEAD_DIM", "128"))
60
+
61
+ _DEC_NUM_WARPS = int(os.environ.get("HYDRA_DECODE_NUM_WARPS", "4"))
62
+ _DEC_NUM_STAGES = int(os.environ.get("HYDRA_DECODE_NUM_STAGES", "2"))
63
+ _DISABLE_AUTOTUNE = int(os.environ.get("HYDRA_DISABLE_AUTOTUNE", "0"))
64
+
65
+
66
+ def _autotune_configs() -> list[triton.Config]:
67
+ """Decode-friendly configs. Q is a single row so warp pressure is low;
68
+ favour stages=2-3 to overlap K/V loads with the small matmuls.
69
+ """
70
+ configs: list[triton.Config] = []
71
+ for num_warps in (2, 4, 8):
72
+ for num_stages in (2, 3, 4):
73
+ configs.append(triton.Config({}, num_warps=num_warps, num_stages=num_stages))
74
+ return configs
75
+
76
+
77
+ def _fixed_config() -> triton.Config:
78
+ return triton.Config({}, num_warps=_DEC_NUM_WARPS, num_stages=_DEC_NUM_STAGES)
79
+
80
+
81
+ def _kernel_decorator(jit_kernel):
82
+ if _DISABLE_AUTOTUNE:
83
+ return jit_kernel
84
+ return triton.autotune(
85
+ configs=_autotune_configs(),
86
+ # T_KV is a runtime arg (not constexpr) and is intentionally NOT in the
87
+ # key — every generation token changes T_kv by 1, and including it
88
+ # forced per-token recompiles (~5-10s each, 200x slowdown observed).
89
+ key=["D", "NUM_HEADS", "NUM_KV_HEADS"],
90
+ )(jit_kernel)
91
+
92
+
93
+ @triton.jit
94
+ def _hydra_decode_jit(
95
+ Q, # (B, H_q, 1, D) bf16
96
+ K, V, # (B, H_kv, T_KV, D) bf16
97
+ O, # (B, H_q, 1, D) bf16
98
+ LSE, # (B, H_q, 1) fp32 (kept for parity / future use)
99
+ T_KV, # runtime int — used only as the mask bound; NOT constexpr to avoid per-T_kv recompiles during generation
100
+ stride_kvbh_kv, # runtime stride for (B*Hkv) axis of K/V in elements (lets StaticCache pass non-T_KV*D strides)
101
+ stride_t_kv, # runtime stride for the T axis of K/V in elements
102
+ NUM_HEADS: tl.constexpr,
103
+ NUM_KV_HEADS: tl.constexpr,
104
+ SCALE: tl.constexpr,
105
+ BK: tl.constexpr,
106
+ D: tl.constexpr,
107
+ MM: tl.constexpr, # Q-replication dim (16 — tcore M-minimum).
108
+ K_START: tl.constexpr, # leftmost token attended (>=0). Sliding-window left edge.
109
+ NUM_K_TILES: tl.constexpr,
110
+ ):
111
+ # Q strides: per-(B, H_q) slab has 1*D = D elements (Q is always contig (B,Hq,1,D)).
112
+ # K/V strides: passed in as runtime args so StaticCache (B, Hkv, T_max, D) works
113
+ # — kernel previously hardcoded stride_kvbh = T_KV * D which is wrong for non-T_KV strides.
114
+ stride_qbh: tl.constexpr = D
115
+ stride_kvbh = stride_kvbh_kv
116
+ stride_t = stride_t_kv
117
+
118
+ pid = tl.program_id(0)
119
+ rep: tl.constexpr = NUM_HEADS // NUM_KV_HEADS
120
+ b_id = pid // NUM_HEADS
121
+ hq_id = pid % NUM_HEADS
122
+ hkv_id = hq_id // rep
123
+
124
+ q_bh = b_id * NUM_HEADS + hq_id
125
+ kv_bh = b_id * NUM_KV_HEADS + hkv_id
126
+
127
+ offs_d = tl.arange(0, D)
128
+ offs_mm = tl.arange(0, MM)
129
+ offs_k_in_tile = tl.arange(0, BK)
130
+
131
+ # Fold SCALE * log2(e) into Q at load so the per-K-tile score does not
132
+ # pay one mul per element.
133
+ LOG2E: tl.constexpr = 1.4426950408889634
134
+ SCALE_2: tl.constexpr = SCALE * LOG2E
135
+ Q_ptr = Q + q_bh * stride_qbh
136
+
137
+ # Tensor cores need M >= 16 in tl.dot. Q has only one valid row, so we
138
+ # replicate it MM times along the M axis. All MM result rows of any
139
+ # downstream matmul are then identical, and we only keep row 0.
140
+ q_row = tl.load(Q_ptr + offs_d).to(tl.float32) * SCALE_2 # [D] fp32
141
+ q_bf16 = tl.broadcast_to(q_row[None, :], [MM, D]).to(tl.bfloat16) # [MM, D]
142
+
143
+ m_i = tl.full([MM], float("-inf"), dtype=tl.float32)
144
+ l_i = tl.zeros([MM], dtype=tl.float32)
145
+ acc = tl.zeros([MM, D], dtype=tl.float32)
146
+
147
+ K_ptr = K + kv_bh * stride_kvbh
148
+ V_ptr = V + kv_bh * stride_kvbh
149
+
150
+ # Iterate K tiles over the visible window [K_START, T_KV).
151
+ # Tile t covers tokens [K_START + t*BK, K_START + (t+1)*BK).
152
+ for t in range(0, NUM_K_TILES):
153
+ k_base = K_START + t * BK
154
+ offs_k = k_base + offs_k_in_tile # [BK]
155
+ k_mask = offs_k < T_KV # [BK]
156
+
157
+ # Masked load: OOB columns get 0; we still mask the score to -inf
158
+ # so its softmax weight is 0.
159
+ k_tile = tl.load(
160
+ K_ptr + offs_k[:, None] * stride_t + offs_d[None, :],
161
+ mask=k_mask[:, None], other=0.0,
162
+ ) # [BK, D] bf16
163
+ v_tile = tl.load(
164
+ V_ptr + offs_k[:, None] * stride_t + offs_d[None, :],
165
+ mask=k_mask[:, None], other=0.0,
166
+ ) # [BK, D] bf16
167
+
168
+ # Score: [MM, D] @ [D, BK] -> [MM, BK] in fp32. All MM rows identical.
169
+ s = tl.dot(q_bf16, tl.trans(k_tile), out_dtype=tl.float32) # [MM, BK]
170
+ s = tl.where(k_mask[None, :], s, float("-inf"))
171
+
172
+ # Online softmax merge. Identical per row, so the [MM]-vector state
173
+ # stays uniform; we keep the broadcast for matmul-shape consistency.
174
+ s_max = tl.max(s, axis=1) # [MM]
175
+ m_new = tl.maximum(m_i, s_max)
176
+ alpha = tl.exp2(m_i - m_new) # [MM]
177
+ p = tl.exp2(s - m_new[:, None]) # [MM, BK]
178
+ l_i = l_i * alpha + tl.sum(p, axis=1)
179
+ acc = tl.dot(p.to(tl.bfloat16), v_tile, acc=acc * alpha[:, None], out_dtype=tl.float32)
180
+ m_i = m_new
181
+
182
+ LN2: tl.constexpr = 0.6931471805599453
183
+ # All MM rows of acc / l_i / m_i are identical (they each saw the same
184
+ # broadcast Q row through the entire loop). Reduce to the row-0 values
185
+ # for the final store. ``tl.sum(..., axis=0) / MM`` extracts the common
186
+ # value as a side effect of averaging identical replicas — this is
187
+ # numerically equivalent to picking row 0 but stays inside Triton's
188
+ # supported reduction ops without needing a slice.
189
+ l_safe = tl.where(l_i > 0, l_i, 1.0) # [MM]
190
+ o_tile = acc / l_safe[:, None] # [MM, D] fp32, all rows equal
191
+ INV_MM: tl.constexpr = 1.0 / MM
192
+ # Average the MM identical replicas to extract the row-0 result.
193
+ # tl.sum(axis=0) over [MM, D] -> [D]; over [MM] -> scalar wrapped as [1]
194
+ # after a no-op broadcast through tl.arange masking.
195
+ o_row = tl.sum(o_tile, axis=0) * INV_MM # [D] fp32
196
+ o_row_bf16 = o_row.to(tl.bfloat16) # [D] bf16
197
+ lse_full = tl.where(l_i > 0, (m_i + tl.log2(l_safe)) * LN2, float("-inf")) # [MM]
198
+ # Build a [1]-shaped LSE: sum the [MM] vector then divide; the result is a
199
+ # Triton scalar — wrap via tl.full to get an explicit [1]-shape value.
200
+ lse_scalar_val = tl.sum(lse_full, axis=0) * INV_MM # scalar fp32
201
+ lse_out_vec = tl.full([1], 0.0, dtype=tl.float32) + lse_scalar_val # [1]
202
+
203
+ # Flat-store the single output row [D] at O_ptr + offs_d. No mask needed:
204
+ # offs_d covers exactly the D-element output buffer for this (B, Hq).
205
+ O_ptr = O + q_bh * stride_qbh
206
+ tl.store(O_ptr + offs_d, o_row_bf16)
207
+
208
+ # LSE: store the [1]-shaped value at offset q_bh.
209
+ LSE_ptr = LSE + q_bh
210
+ tl.store(LSE_ptr + tl.arange(0, 1), lse_out_vec)
211
+
212
+
213
+ _hydra_decode_kernel = _kernel_decorator(_hydra_decode_jit)
214
+
215
+
216
+ def launch_attn_fwd_decode(
217
+ q: torch.Tensor, # (B, H_q, 1, D) bf16
218
+ k: torch.Tensor, # (B, H_kv, T_kv, D) bf16
219
+ v: torch.Tensor, # (B, H_kv, T_kv, D) bf16
220
+ window: int = 0,
221
+ ) -> tuple[torch.Tensor, torch.Tensor]:
222
+ """Launch the decode-step forward kernel.
223
+
224
+ Args:
225
+ q: (B, H_q, 1, D) bf16. T_q must be exactly 1.
226
+ k: (B, H_kv, T_kv, D) bf16.
227
+ v: (B, H_kv, T_kv, D) bf16.
228
+ window: 0 disables sliding window (attend all K). When > 0, only the
229
+ last ``window`` K tokens are attended: K range = [T_kv - window,
230
+ T_kv). The query is at position T_kv - 1, so this matches the
231
+ standard sliding-window-causal semantics used at prefill.
232
+
233
+ Returns:
234
+ o: (B, H_q, 1, D) bf16
235
+ lse: (B, H_q, 1) fp32
236
+
237
+ Notes:
238
+ Assumes K/V are contiguous in (B, H_kv, T_kv, D). T_kv does NOT need
239
+ to be a multiple of BLOCK_K; the last K-tile is masked.
240
+ """
241
+ if q.dim() != 4 or k.dim() != 4 or v.dim() != 4:
242
+ raise ValueError(f"q/k/v must be 4D (B,H,T,D); got {q.shape} {k.shape} {v.shape}")
243
+ B, Hq, Tq, D = q.shape
244
+ if Tq != 1:
245
+ raise ValueError(f"decode kernel requires Tq == 1; got Tq={Tq}")
246
+ if k.shape[0] != B or v.shape[0] != B:
247
+ raise ValueError(f"batch mismatch q={q.shape} k={k.shape} v={v.shape}")
248
+ if k.shape != v.shape:
249
+ raise ValueError(f"k and v must have identical shapes; got {k.shape} vs {v.shape}")
250
+ if k.shape[3] != D:
251
+ raise ValueError(f"head_dim mismatch q={q.shape[-1]} k={k.shape[-1]}")
252
+ Hkv = k.shape[1]
253
+ Tkv = k.shape[2]
254
+ if Hq % Hkv != 0:
255
+ raise ValueError(f"H_q ({Hq}) must be a multiple of H_kv ({Hkv})")
256
+ if D != HEAD_DIM:
257
+ raise ValueError(f"head_dim ({D}) must equal HEAD_DIM ({HEAD_DIM})")
258
+ if Tkv <= 0:
259
+ raise ValueError(f"T_kv must be positive; got {Tkv}")
260
+
261
+ # Sliding window: clamp to [0, Tkv]. window <= 0 or window >= Tkv -> full.
262
+ if window is None or window <= 0 or window >= Tkv:
263
+ k_start = 0
264
+ else:
265
+ k_start = Tkv - window
266
+ num_k_visible = Tkv - k_start
267
+ num_k_tiles = (num_k_visible + BLOCK_K - 1) // BLOCK_K
268
+
269
+ o = torch.empty_like(q)
270
+ lse = torch.empty((B, Hq, 1), device=q.device, dtype=torch.float32)
271
+
272
+ grid = (B * Hq,)
273
+ # Pull actual strides from K (V must match shape). Lets StaticCache pass
274
+ # (B, Hkv, T_max, D) buffers — previously hardcoded stride_kvbh = Tkv*D
275
+ # would silently read garbage when T_max != Tkv.
276
+ common_kwargs = dict(
277
+ T_KV=Tkv,
278
+ stride_kvbh_kv=k.stride(1),
279
+ stride_t_kv=k.stride(2),
280
+ NUM_HEADS=Hq,
281
+ NUM_KV_HEADS=Hkv,
282
+ SCALE=1.0 / math.sqrt(D),
283
+ BK=BLOCK_K,
284
+ D=D,
285
+ MM=16, # tensor-core minimum M for tl.dot on Blackwell bf16.
286
+ K_START=int(k_start),
287
+ NUM_K_TILES=int(num_k_tiles),
288
+ )
289
+ if _DISABLE_AUTOTUNE:
290
+ common_kwargs["num_warps"] = _DEC_NUM_WARPS
291
+ common_kwargs["num_stages"] = _DEC_NUM_STAGES
292
+
293
+ _hydra_decode_kernel[grid](
294
+ q, k, v,
295
+ o, lse,
296
+ **common_kwargs,
297
+ )
298
+ return o, lse
torch-ext/hydra/kernel_delta.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-pass Triton delta kernel.
2
+
3
+ Replaces the host-side ``(do.float() * o.float()).sum(-1)`` dance in
4
+ ``hydra.kernel_bwd.launch_attn_bwd`` with a fused
5
+ GPU-resident kernel that reads ``o`` and ``do`` once (bf16, the same
6
+ loads the existing autograd chain already pays for) and writes a
7
+ single ``(B, Hq, T)`` fp32 ``delta`` tensor.
8
+
9
+ What this saves vs the host-side computation (Qwen3-8B-ish prefill,
10
+ B=1, H_q=32, T=8192, D=128):
11
+
12
+ - ``do.float()`` 128 MB fp32 transient: gone
13
+ - ``o.float()`` 128 MB fp32 transient: gone
14
+ - ``do.float() * o.float()`` 128 MB fp32 product: gone
15
+ - net delta tensor still allocated: 1 MB fp32 (B*Hq*T*4)
16
+
17
+ For an at-rest measurement the peak transient eliminated is ~384 MB;
18
+ in the steady-state allocator the saving conservatively measured by
19
+ the prior agent's accounting is ~256 MB (after one of the upcasts is
20
+ reaped). Either way the dedicated kernel is the right move — the
21
+ ``o``/``do`` bytes are already on-chip in the autograd path, we just
22
+ fold the multiply+reduce into one pass.
23
+
24
+ Math:
25
+
26
+ delta[b, h, t] = sum_{d=0..D-1} o[b, h, t, d] * do[b, h, t, d]
27
+
28
+ bf16 inputs, fp32 accumulator, fp32 output. The reduction order is
29
+ Triton's parallel ``tl.sum`` tree across the D axis (per tile-row).
30
+ Compared to torch's left-to-right ``.sum(-1)`` the absolute error
31
+ bound is roughly ``D * eps_fp32 * max|o*do|`` which is ~1.5e-5 at
32
+ attention-typical scales — well within bf16's ~4e-3 quantum.
33
+
34
+ Layout:
35
+
36
+ - ``o``, ``do``: ``(B, Hq, T, D)`` bf16, contiguous.
37
+ - ``delta``: ``(B, Hq, T)`` fp32, contiguous.
38
+ - One program per ``(b*Hq + h, q_block_id)`` work item, computing
39
+ ``BLOCK_SIZE`` rows of delta with full ``D`` in-tile reduction.
40
+ - Grid: ``(B * Hq * num_q_blocks,)`` flattened.
41
+
42
+ Arbitrary T: the kernel masks the tail tile (``offs_tok < T``); the
43
+ launcher does not require ``T % BLOCK_SIZE == 0``.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import os
48
+
49
+ import torch
50
+ import triton
51
+ import triton.language as tl
52
+
53
+ from .kernel_fwd import BLOCK_SIZE, HEAD_DIM
54
+
55
+
56
+ _DELTA_NUM_WARPS = int(os.environ.get("HYDRA_DELTA_NUM_WARPS", "4"))
57
+ _DELTA_NUM_STAGES = int(os.environ.get("HYDRA_DELTA_NUM_STAGES", "1"))
58
+ _DISABLE_AUTOTUNE = int(os.environ.get("HYDRA_DISABLE_AUTOTUNE", "0"))
59
+
60
+
61
+ def _autotune_configs() -> list[triton.Config]:
62
+ """Same warps/stages sweep used by kernel_fwd; D and BS are fixed."""
63
+ configs: list[triton.Config] = []
64
+ for num_warps in (1, 2, 4):
65
+ for num_stages in (1, 2):
66
+ configs.append(triton.Config({}, num_warps=num_warps, num_stages=num_stages))
67
+ return configs
68
+
69
+
70
+ def _kernel_decorator(jit_kernel):
71
+ if _DISABLE_AUTOTUNE:
72
+ return jit_kernel
73
+ return triton.autotune(
74
+ configs=_autotune_configs(),
75
+ key=["T_MAX", "D"],
76
+ )(jit_kernel)
77
+
78
+
79
+ @triton.jit
80
+ def _compute_delta_jit(
81
+ O, # (B, Hq, T, D) bf16
82
+ dO, # (B, Hq, T, D) bf16
83
+ Delta, # (B, Hq, T) fp32
84
+ T_MAX: tl.constexpr,
85
+ BS: tl.constexpr,
86
+ D: tl.constexpr,
87
+ ):
88
+ """Compute ``Delta[b, h, t] = sum_d O[b, h, t, d] * dO[b, h, t, d]``.
89
+
90
+ One program handles a single ``(b*Hq + h, q_block_id)`` tile of
91
+ ``BS`` rows, reducing the full ``D``-axis in fp32 in-register.
92
+
93
+ Grid: ``(batch_heads * num_q_blocks,)``.
94
+ """
95
+ # Per-head row strides match the (B, Hq, T, D) / (B, Hq, T) layout
96
+ # the contiguous-tensor invariant gives us. Encoding them as
97
+ # constexpr lets the compiler fold the address arithmetic.
98
+ stride_h: tl.constexpr = T_MAX * D
99
+ stride_t: tl.constexpr = D
100
+ stride_lh: tl.constexpr = T_MAX
101
+ NUM_Q_BLOCKS: tl.constexpr = (T_MAX + BS - 1) // BS
102
+
103
+ pid = tl.program_id(0)
104
+ bh_id = pid // NUM_Q_BLOCKS
105
+ q_block_id = pid % NUM_Q_BLOCKS
106
+
107
+ q_start = q_block_id * BS
108
+ offs_tok = q_start + tl.arange(0, BS)
109
+ offs_d = tl.arange(0, D)
110
+ # Mask trailing tile when T is not a multiple of BS.
111
+ row_mask = offs_tok < T_MAX
112
+
113
+ # Load the (BS, D) o and do tiles in bf16. Out-of-bound rows are
114
+ # zero-filled so their contribution to the (masked-out) sum is
115
+ # exactly 0 and doesn't pollute neighbour rows under the tl.sum tree.
116
+ O_ptr = O + bh_id * stride_h
117
+ dO_ptr = dO + bh_id * stride_h
118
+ addr = offs_tok[:, None] * stride_t + offs_d[None, :]
119
+ o_bf16 = tl.load(O_ptr + addr, mask=row_mask[:, None], other=0.0)
120
+ do_bf16 = tl.load(dO_ptr + addr, mask=row_mask[:, None], other=0.0)
121
+
122
+ # bf16 -> fp32 -> elementwise mul -> reduce along D.
123
+ # The cast-then-mul order matches the host-side path
124
+ # ``do.float() * o.float()`` exactly (no intermediate bf16 product).
125
+ prod = o_bf16.to(tl.float32) * do_bf16.to(tl.float32)
126
+ di = tl.sum(prod, axis=-1) # (BS,) fp32
127
+
128
+ Delta_ptr = Delta + bh_id * stride_lh
129
+ tl.store(Delta_ptr + offs_tok, di, mask=row_mask)
130
+
131
+
132
+ _compute_delta_kernel = _kernel_decorator(_compute_delta_jit)
133
+
134
+
135
+ def launch_compute_delta(o: torch.Tensor, do: torch.Tensor) -> torch.Tensor:
136
+ """Compute ``delta = sum_d o * do`` as a fused single-pass GPU kernel.
137
+
138
+ Parameters
139
+ ----------
140
+ o, do : ``(B, Hq, T, D)`` bf16 tensors. Must be contiguous and on the
141
+ same CUDA device. ``T`` is arbitrary (the tail tile is
142
+ masked); ``D`` must equal ``HEAD_DIM``.
143
+
144
+ Returns
145
+ -------
146
+ delta : ``(B, Hq, T)`` fp32 tensor, same device. Allocated fresh.
147
+
148
+ Numerical equivalence to ``(do.float() * o.float()).sum(-1)``:
149
+
150
+ Both compute ``sum_d (bf16_to_fp32(o[d]) * bf16_to_fp32(do[d]))``
151
+ with ``D == 128`` summands per (b, h, t) row. Triton's ``tl.sum``
152
+ uses a parallel reduction tree while torch's ``.sum(-1)`` is a
153
+ left-to-right scan. The reordering error is bounded by
154
+ ``D * eps_fp32 * max|o*do|`` ~ ``128 * 1.2e-7 * O(1)`` ~ ``1.5e-5``,
155
+ well under bf16's ~4e-3 quantum.
156
+ """
157
+ if o.shape != do.shape:
158
+ raise ValueError(f"o and do must have the same shape; got {o.shape} vs {do.shape}")
159
+ if o.dim() != 4:
160
+ raise ValueError(f"o must be 4D (B, Hq, T, D); got shape {o.shape}")
161
+ if o.dtype != torch.bfloat16 or do.dtype != torch.bfloat16:
162
+ raise ValueError(f"o, do must be bf16; got {o.dtype}, {do.dtype}")
163
+ if o.device != do.device:
164
+ raise ValueError(f"o and do must be on the same device; got {o.device} vs {do.device}")
165
+ if not o.is_contiguous() or not do.is_contiguous():
166
+ raise ValueError("o and do must be contiguous")
167
+
168
+ B, Hq, T, D = o.shape
169
+ if D != HEAD_DIM:
170
+ raise ValueError(f"head_dim ({D}) must equal HEAD_DIM ({HEAD_DIM})")
171
+
172
+ delta = torch.empty(B, Hq, T, dtype=torch.float32, device=o.device)
173
+
174
+ batch_heads = B * Hq
175
+ num_q_blocks = (T + BLOCK_SIZE - 1) // BLOCK_SIZE
176
+ grid = (batch_heads * num_q_blocks,)
177
+
178
+ kwargs = dict(
179
+ T_MAX=T,
180
+ BS=BLOCK_SIZE,
181
+ D=D,
182
+ )
183
+ if _DISABLE_AUTOTUNE:
184
+ kwargs["num_warps"] = _DELTA_NUM_WARPS
185
+ kwargs["num_stages"] = _DELTA_NUM_STAGES
186
+
187
+ _compute_delta_kernel[grid](o, do, delta, **kwargs)
188
+ return delta
torch-ext/hydra/kernel_fwd.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-arch-tuned forward kernel.
2
+
3
+ This module contains the active forward Triton kernel used by the extracted
4
+ Hydra package. The JIT kernel body and launcher descend from the iter3
5
+ research kernel, with the cross-architecture tuning hooks kept explicit near
6
+ the top of the file.
7
+
8
+ Why
9
+ ---
10
+ The iter3 autotune sweep is a 9-cell cartesian
11
+ ``(num_warps ∈ {2,4,8}) × (num_stages ∈ {1,2,3})`` over a fixed
12
+ ``BLOCK_SIZE = 64``. Tuning at import time per
13
+ ``torch.cuda.get_device_capability()`` lets us:
14
+
15
+ 1. Lower default ``BLOCK_SIZE`` from 64 → 32 on every measured arch
16
+ (sm_86/89/120/121). Cross-arch sweep at T=4096 dense fwdbwd found
17
+ BS=32 winning on every architecture we measured. The smaller K/V
18
+ tile reduces shared-memory pressure and lets more CTAs run
19
+ concurrently per SM, which dominates on Ampere/Ada cards
20
+ (~100 KB smem/SM cap) and is neutral-to-positive on Blackwell.
21
+
22
+ 2. Replace the 9-cell autotune sweep with a 1-3 cell list keyed on
23
+ compute capability. For arches we've measured we ship the
24
+ sweep-winning ``(num_warps, num_stages)`` plus one or two
25
+ safe-fallback configs (so the autotuner can't get stuck if a
26
+ particular load pattern at runtime regresses on the headline cell).
27
+ For arches we have NOT measured (anything not in our table) we
28
+ fall back to the original 9-cell sweep so the autotuner can find
29
+ a good config on first use.
30
+
31
+ Both changes preserve the launcher API and the kernel signature.
32
+ No correctness change: ``BS`` is still a constexpr; the kernel just
33
+ gets specialised at a different value.
34
+
35
+ Per-arch defaults are provisional tuning seeds, not benchmark claims. They
36
+ control autotune order and can be overridden with the environment variables
37
+ below. Submission-facing benchmark claims must come from checked-in artifacts,
38
+ not this header.
39
+
40
+ Do not cite the defaults below as evidence of speedup or hardware coverage.
41
+
42
+ Override hooks
43
+ --------------
44
+ Three env vars short-circuit everything:
45
+ - HYDRA_BLOCK_SIZE - wins over the per-arch default
46
+ - HYDRA_DISABLE_AUTOTUNE=1 + HYDRA_NUM_WARPS
47
+ + HYDRA_NUM_STAGES — wins over autotune
48
+ - HYDRA_AUTOTUNE_MODE=full — forces the 9-cell sweep
49
+ even on a known arch (useful for re-tuning new wheels of triton).
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import math
54
+ import os
55
+
56
+ import torch
57
+ import triton
58
+ import triton.language as tl
59
+
60
+
61
+ # ---------------- Per-arch defaults --------------------------------------
62
+
63
+ # Per-(major,minor) compute-capability -> (BLOCK_SIZE, [(num_warps, num_stages), ...])
64
+ #
65
+ # The autotune list is ordered with the SWEEP-WINNING config first so that
66
+ # the autotuner picks it on the first launch (which is also the only launch
67
+ # in many production deploys: the cache key includes T_MAX/D so a stable
68
+ # shape produces exactly one autotune cycle).
69
+ #
70
+ # Tail entries are conservative fallbacks: they exist so the autotuner has
71
+ # at least one safe config to fall back to if a specific runtime workload
72
+ # regresses on the headline cell (e.g. a different T that we did not sweep).
73
+ _PER_ARCH: dict[tuple[int, int], tuple[int, list[tuple[int, int]]]] = {
74
+ # GB10 (Blackwell sm_121).
75
+ (12, 1): (32, [(2, 3), (4, 2)]),
76
+ # Pro 6000 (Blackwell sm_120). NOT MEASURED in iter; default to the
77
+ # sm_121 pick — they share most of the relevant resources. Adjust
78
+ # after running the sweep on a free Pro 6000.
79
+ (12, 0): (32, [(2, 3), (4, 2)]),
80
+ # Ada (sm_89): 4080, 4070 Ti.
81
+ (8, 9): (32, [(4, 1), (4, 2)]),
82
+ # Ampere consumer (sm_86): 3090, 3080, 3060.
83
+ (8, 6): (32, [(2, 1), (4, 2)]),
84
+ }
85
+
86
+ # The original 9-cell sweep — used when no per-arch entry exists, and also
87
+ # when the user opts in via HYDRA_AUTOTUNE_MODE=full.
88
+ _FULL_SWEEP: list[tuple[int, int]] = [
89
+ (W, S) for W in (2, 4, 8) for S in (1, 2, 3)
90
+ ]
91
+
92
+
93
+ def _device_capability_or_none() -> tuple[int, int] | None:
94
+ """Return ``(major, minor)`` for cuda:0, or ``None`` if no CUDA."""
95
+ try:
96
+ if torch.cuda.is_available():
97
+ return tuple(torch.cuda.get_device_capability(0)) # type: ignore[return-value]
98
+ except Exception:
99
+ pass
100
+ return None
101
+
102
+
103
+ def _default_block_size_from_arch() -> int:
104
+ """Pick BLOCK_SIZE based on the current CUDA device's compute capability.
105
+
106
+ Fall back to 64 (iter3's value) if we don't have a measured entry, so
107
+ no measured arch regresses below its iter3 baseline.
108
+ """
109
+ cc = _device_capability_or_none()
110
+ if cc is not None and cc in _PER_ARCH:
111
+ return _PER_ARCH[cc][0]
112
+ return 64
113
+
114
+
115
+ def _arch_aware_autotune_configs() -> list[triton.Config]:
116
+ """Per-arch shrunk autotune list, or the full 9-cell sweep if unknown."""
117
+ if os.environ.get("HYDRA_AUTOTUNE_MODE", "").lower() == "full":
118
+ ws_pairs = _FULL_SWEEP
119
+ else:
120
+ cc = _device_capability_or_none()
121
+ if cc is not None and cc in _PER_ARCH:
122
+ ws_pairs = _PER_ARCH[cc][1]
123
+ else:
124
+ ws_pairs = _FULL_SWEEP
125
+ return [triton.Config({}, num_warps=W, num_stages=S) for (W, S) in ws_pairs]
126
+
127
+
128
+ # Env var override wins over per-arch default (preserves the iter3 escape hatch).
129
+ BLOCK_SIZE = int(os.environ.get("HYDRA_BLOCK_SIZE", str(_default_block_size_from_arch())))
130
+ HEAD_DIM = int(os.environ.get("HYDRA_HEAD_DIM", "128"))
131
+
132
+ _FWD_NUM_WARPS = int(os.environ.get("HYDRA_NUM_WARPS", "4"))
133
+ _FWD_NUM_STAGES = int(os.environ.get("HYDRA_NUM_STAGES", "2"))
134
+ _DISABLE_AUTOTUNE = int(os.environ.get("HYDRA_DISABLE_AUTOTUNE", "0"))
135
+
136
+
137
+ def _autotune_configs() -> list[triton.Config]:
138
+ return _arch_aware_autotune_configs()
139
+
140
+
141
+ def _kernel_decorator(jit_kernel):
142
+ if _DISABLE_AUTOTUNE:
143
+ return jit_kernel
144
+ return triton.autotune(
145
+ configs=_autotune_configs(),
146
+ key=["T_MAX", "D", "NUM_HEADS", "NUM_KV_HEADS", "ASSUME_FULL"],
147
+ )(jit_kernel)
148
+
149
+
150
+ @triton.jit
151
+ def _hydra_fwd_jit(
152
+ Q, K, V,
153
+ O, LSE,
154
+ RowPtr, ColIdx, SeqLens,
155
+ stride_cih,
156
+ T_MAX: tl.constexpr,
157
+ NUM_HEADS: tl.constexpr,
158
+ NUM_KV_HEADS: tl.constexpr,
159
+ SCALE: tl.constexpr,
160
+ BS: tl.constexpr,
161
+ D: tl.constexpr,
162
+ WINDOW: tl.constexpr,
163
+ CONTIGUOUS_OFFDIAG: tl.constexpr = 0,
164
+ ASSUME_FULL: tl.constexpr = 0,
165
+ ):
166
+ stride_h: tl.constexpr = T_MAX * D
167
+ stride_t: tl.constexpr = D
168
+ stride_lh: tl.constexpr = T_MAX
169
+ stride_rph: tl.constexpr = (T_MAX // BS) + 1
170
+ NUM_Q_BLOCKS: tl.constexpr = T_MAX // BS
171
+
172
+ pid = tl.program_id(0)
173
+ bh_id = pid // NUM_Q_BLOCKS
174
+ q_block_id = pid % NUM_Q_BLOCKS
175
+
176
+ rep: tl.constexpr = NUM_HEADS // NUM_KV_HEADS
177
+ b_id = bh_id // NUM_HEADS
178
+ hq_id = bh_id % NUM_HEADS
179
+ hkv_id = hq_id // rep
180
+ kv_bh_id = b_id * NUM_KV_HEADS + hkv_id
181
+
182
+ if ASSUME_FULL:
183
+ seq_len = T_MAX # constexpr-friendly: no SeqLens load, no early-return
184
+ else:
185
+ seq_len = tl.load(SeqLens + b_id)
186
+
187
+ q_start = q_block_id * BS
188
+
189
+ offs_tok = q_start + tl.arange(0, BS)
190
+ offs_d = tl.arange(0, D)
191
+
192
+ if ASSUME_FULL:
193
+ pass # no early-return path needed
194
+ else:
195
+ if q_start >= seq_len:
196
+ O_ptr_e = O + bh_id * stride_h
197
+ tl.store(O_ptr_e + offs_tok[:, None] * stride_t + offs_d[None, :],
198
+ tl.zeros([BS, D], dtype=tl.bfloat16))
199
+ LSE_ptr_e = LSE + bh_id * stride_lh
200
+ tl.store(LSE_ptr_e + offs_tok, tl.full([BS], float("-inf"), dtype=tl.float32))
201
+ return
202
+
203
+ LOG2E: tl.constexpr = 1.4426950408889634
204
+ SCALE_2: tl.constexpr = SCALE * LOG2E
205
+ Q_ptr = Q + bh_id * stride_h
206
+ q_bf16 = (tl.load(Q_ptr + offs_tok[:, None] * stride_t + offs_d[None, :]).to(tl.float32) * SCALE_2).to(tl.bfloat16)
207
+
208
+ m_i = tl.full([BS], float("-inf"), dtype=tl.float32)
209
+ l_i = tl.zeros([BS], dtype=tl.float32)
210
+ acc = tl.zeros([BS, D], dtype=tl.float32)
211
+
212
+ rp_base = RowPtr + bh_id * stride_rph + q_block_id
213
+ ci_lo = tl.load(rp_base)
214
+ ci_hi = tl.load(rp_base + 1)
215
+
216
+ K_ptr = K + kv_bh_id * stride_h
217
+ V_ptr = V + kv_bh_id * stride_h
218
+ CI_ptr = ColIdx + bh_id * stride_cih
219
+
220
+ offs_d_arange = tl.arange(0, BS)
221
+
222
+ if ci_hi > ci_lo:
223
+ k_start_d = q_block_id * BS
224
+ offs_k_d = k_start_d + offs_d_arange
225
+
226
+ if ASSUME_FULL:
227
+ # No boundary branch — full unmasked diagonal load + causal mask.
228
+ k_bf16_d = tl.load(K_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :])
229
+ v_bf16_d = tl.load(V_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :])
230
+ s_d = tl.dot(q_bf16, tl.trans(k_bf16_d), out_dtype=tl.float32)
231
+ causal = offs_tok[:, None] >= offs_k_d[None, :]
232
+ if WINDOW > 0:
233
+ in_window = (offs_tok[:, None] - offs_k_d[None, :]) < WINDOW
234
+ s_d = tl.where(causal & in_window, s_d, float("-inf"))
235
+ else:
236
+ s_d = tl.where(causal, s_d, float("-inf"))
237
+ else:
238
+ boundary = (q_start + BS) > seq_len
239
+ if boundary:
240
+ k_mask = offs_k_d < seq_len
241
+ k_bf16_d = tl.load(K_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :],
242
+ mask=k_mask[:, None], other=0.0)
243
+ v_bf16_d = tl.load(V_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :],
244
+ mask=k_mask[:, None], other=0.0)
245
+ s_d = tl.dot(q_bf16, tl.trans(k_bf16_d), out_dtype=tl.float32)
246
+ causal = offs_tok[:, None] >= offs_k_d[None, :]
247
+ allowed = causal & k_mask[None, :]
248
+ if WINDOW > 0:
249
+ in_window = (offs_tok[:, None] - offs_k_d[None, :]) < WINDOW
250
+ allowed = allowed & in_window
251
+ s_d = tl.where(allowed, s_d, float("-inf"))
252
+ else:
253
+ k_bf16_d = tl.load(K_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :])
254
+ v_bf16_d = tl.load(V_ptr + offs_k_d[:, None] * stride_t + offs_d[None, :])
255
+ s_d = tl.dot(q_bf16, tl.trans(k_bf16_d), out_dtype=tl.float32)
256
+ causal = offs_tok[:, None] >= offs_k_d[None, :]
257
+ if WINDOW > 0:
258
+ in_window = (offs_tok[:, None] - offs_k_d[None, :]) < WINDOW
259
+ s_d = tl.where(causal & in_window, s_d, float("-inf"))
260
+ else:
261
+ s_d = tl.where(causal, s_d, float("-inf"))
262
+
263
+ m_i = tl.max(s_d, axis=1)
264
+ p_d = tl.exp2(s_d - m_i[:, None])
265
+ l_i = tl.sum(p_d, axis=1)
266
+ acc = tl.dot(p_d.to(tl.bfloat16), v_bf16_d, out_dtype=tl.float32)
267
+
268
+ # Off-diag loop unchanged (no boundary semantics here in either mode).
269
+ if CONTIGUOUS_OFFDIAG:
270
+ k_block_id_start = tl.load(CI_ptr + ci_lo)
271
+ for ci in range(ci_lo, ci_hi - 1):
272
+ k_block_id = k_block_id_start + (ci - ci_lo)
273
+ k_start = k_block_id * BS
274
+ offs_k = k_start + offs_d_arange
275
+ k_bf16 = tl.load(K_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
276
+ v_bf16 = tl.load(V_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
277
+ s = tl.dot(q_bf16, tl.trans(k_bf16), out_dtype=tl.float32)
278
+ if WINDOW > 0:
279
+ in_window = (offs_tok[:, None] - offs_k[None, :]) < WINDOW
280
+ s = tl.where(in_window, s, float("-inf"))
281
+ m_new = tl.maximum(m_i, tl.max(s, axis=1))
282
+ alpha = tl.exp2(m_i - m_new)
283
+ p = tl.exp2(s - m_new[:, None])
284
+ l_i = l_i * alpha + tl.sum(p, axis=1)
285
+ acc = tl.dot(p.to(tl.bfloat16), v_bf16, acc=acc * alpha[:, None], out_dtype=tl.float32)
286
+ m_i = m_new
287
+ else:
288
+ for ci in range(ci_lo, ci_hi - 1):
289
+ k_block_id = tl.load(CI_ptr + ci)
290
+ k_start = k_block_id * BS
291
+ offs_k = k_start + offs_d_arange
292
+ k_bf16 = tl.load(K_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
293
+ v_bf16 = tl.load(V_ptr + offs_k[:, None] * stride_t + offs_d[None, :])
294
+ s = tl.dot(q_bf16, tl.trans(k_bf16), out_dtype=tl.float32)
295
+ if WINDOW > 0:
296
+ in_window = (offs_tok[:, None] - offs_k[None, :]) < WINDOW
297
+ s = tl.where(in_window, s, float("-inf"))
298
+ m_new = tl.maximum(m_i, tl.max(s, axis=1))
299
+ alpha = tl.exp2(m_i - m_new)
300
+ p = tl.exp2(s - m_new[:, None])
301
+ l_i = l_i * alpha + tl.sum(p, axis=1)
302
+ acc = tl.dot(p.to(tl.bfloat16), v_bf16, acc=acc * alpha[:, None], out_dtype=tl.float32)
303
+ m_i = m_new
304
+
305
+ LN2: tl.constexpr = 0.6931471805599453
306
+ l_safe = tl.where(l_i > 0, l_i, 1.0)
307
+ if ASSUME_FULL:
308
+ o_tile = acc / l_safe[:, None]
309
+ lse_out = (m_i + tl.log2(l_safe)) * LN2
310
+ else:
311
+ q_mask = offs_tok < seq_len
312
+ o_tile = tl.where(q_mask[:, None], acc / l_safe[:, None], 0.0)
313
+ lse_out = tl.where(q_mask, (m_i + tl.log2(l_safe)) * LN2, float("-inf"))
314
+
315
+ O_ptr = O + bh_id * stride_h
316
+ tl.store(O_ptr + offs_tok[:, None] * stride_t + offs_d[None, :], o_tile.to(tl.bfloat16))
317
+ LSE_ptr = LSE + bh_id * stride_lh
318
+ tl.store(LSE_ptr + offs_tok, lse_out)
319
+
320
+
321
+ _hydra_fwd_kernel = _kernel_decorator(_hydra_fwd_jit)
322
+
323
+
324
+ def launch_attn_fwd(
325
+ q: torch.Tensor,
326
+ k: torch.Tensor,
327
+ v: torch.Tensor,
328
+ row_ptr: torch.Tensor,
329
+ col_idx: torch.Tensor,
330
+ seq_lens: torch.Tensor,
331
+ window: int = 0,
332
+ contiguous_offdiag: bool = True,
333
+ ) -> tuple[torch.Tensor, torch.Tensor]:
334
+ if q.dim() != 4 or k.dim() != 4 or v.dim() != 4:
335
+ raise ValueError(f"q/k/v must be 4D; got {q.shape} {k.shape} {v.shape}")
336
+ batch_size, num_heads, t_max, head_dim = q.shape
337
+ num_kv_heads = k.shape[1]
338
+ if t_max % BLOCK_SIZE != 0:
339
+ raise ValueError(f"T ({t_max}) must be multiple of BLOCK_SIZE ({BLOCK_SIZE})")
340
+ if head_dim != HEAD_DIM:
341
+ raise ValueError(f"head_dim ({head_dim}) must equal HEAD_DIM ({HEAD_DIM})")
342
+
343
+ batch_heads = batch_size * num_heads
344
+ num_q_blocks = t_max // BLOCK_SIZE
345
+ o = torch.empty_like(q)
346
+ lse_3d = torch.empty((batch_size, num_heads, t_max), device=q.device, dtype=torch.float32)
347
+ lse_2d = lse_3d.view(batch_heads, t_max)
348
+ cih = col_idx.shape[2] if col_idx.ndim == 3 else col_idx.shape[1]
349
+ grid = (num_q_blocks * batch_heads,)
350
+
351
+ # ASSUME_FULL is safe iff EVERY seq_len equals t_max. Cheap GPU-resident
352
+ # check (no D2H sync if we just compare with a fused all-equal kernel,
353
+ # but for now use a tiny .item() — it's a single int and seq_lens lives
354
+ # in HBM near the launcher already).
355
+ if seq_lens.numel() == 1:
356
+ assume_full = int(seq_lens.item()) == t_max
357
+ else:
358
+ # Multi-batch: check min==max==t_max.
359
+ sl_min = int(seq_lens.min().item())
360
+ sl_max = int(seq_lens.max().item())
361
+ assume_full = (sl_min == t_max) and (sl_max == t_max)
362
+
363
+ common_kwargs = dict(
364
+ T_MAX=t_max, NUM_HEADS=num_heads, NUM_KV_HEADS=num_kv_heads,
365
+ SCALE=1.0 / math.sqrt(head_dim), BS=BLOCK_SIZE, D=head_dim,
366
+ WINDOW=int(window),
367
+ CONTIGUOUS_OFFDIAG=1 if contiguous_offdiag else 0,
368
+ ASSUME_FULL=1 if assume_full else 0,
369
+ )
370
+ if _DISABLE_AUTOTUNE:
371
+ common_kwargs["num_warps"] = _FWD_NUM_WARPS
372
+ common_kwargs["num_stages"] = _FWD_NUM_STAGES
373
+
374
+ _hydra_fwd_kernel[grid](
375
+ q, k, v, o, lse_2d,
376
+ row_ptr, col_idx, seq_lens, cih,
377
+ **common_kwargs,
378
+ )
379
+ return o, lse_3d
torch-ext/hydra/policy.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime policy hooks for live attention throttling.
2
+
3
+ This is intentionally small and deterministic. The policy can clamp an
4
+ existing sliding-window request before CSR construction / kernel launch. It can
5
+ also opt into converting dense causal attention to sliding-window attention,
6
+ but that is disabled by default because it changes model semantics.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from dataclasses import asdict, dataclass
12
+ import math
13
+ import os
14
+ from typing import Any, Mapping
15
+
16
+
17
+ _MIB = 1024 * 1024
18
+
19
+
20
+ def _parse_bool(value: object, default: bool = False) -> bool:
21
+ if value is None:
22
+ return default
23
+ if isinstance(value, bool):
24
+ return value
25
+ text = str(value).strip().lower()
26
+ if text in {"1", "true", "yes", "on", "enabled"}:
27
+ return True
28
+ if text in {"0", "false", "no", "off", "disabled"}:
29
+ return False
30
+ return default
31
+
32
+
33
+ def _parse_int(value: object, default: int | None = None) -> int | None:
34
+ if value is None or value == "":
35
+ return default
36
+ return int(value)
37
+
38
+
39
+ def _parse_int_tuple(value: object) -> tuple[int, ...] | None:
40
+ if value is None or value == "":
41
+ return None
42
+ if isinstance(value, str):
43
+ parts = [part.strip() for part in value.replace(";", ",").split(",")]
44
+ items = [int(part) for part in parts if part]
45
+ else:
46
+ items = [int(part) for part in value] # type: ignore[arg-type]
47
+ if not items:
48
+ return None
49
+ if any(item <= 0 for item in items):
50
+ raise ValueError(f"layer windows must be positive integers, got {value!r}")
51
+ return tuple(items)
52
+
53
+
54
+ def _parse_float(value: object, default: float) -> float:
55
+ if value is None or value == "":
56
+ return default
57
+ return float(value)
58
+
59
+
60
+ def _parse_bytes(value: object, default: int) -> int:
61
+ if value is None or value == "":
62
+ return default
63
+ text = str(value).strip().lower()
64
+ scale = 1
65
+ for suffix, multiplier in (
66
+ ("gib", 1024**3),
67
+ ("gb", 1000**3),
68
+ ("mib", 1024**2),
69
+ ("mb", 1000**2),
70
+ ("kib", 1024),
71
+ ("kb", 1000),
72
+ ):
73
+ if text.endswith(suffix):
74
+ scale = multiplier
75
+ text = text[: -len(suffix)]
76
+ break
77
+ return int(float(text) * scale)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class RuntimePolicy:
82
+ """Policy for request-time attention throttling."""
83
+
84
+ mode: str = "off"
85
+ max_window: int | None = None
86
+ min_window: int = 128
87
+ reserve_bytes: int = 512 * _MIB
88
+ utilization: float = 0.85
89
+ allow_dense_to_window: bool = False
90
+ fail_closed: bool = False
91
+ align_to_block: bool = True
92
+ layer_windows: tuple[int, ...] | None = None
93
+
94
+ @property
95
+ def enabled(self) -> bool:
96
+ return self.mode not in {"", "0", "off", "disabled", "none"}
97
+
98
+ @classmethod
99
+ def from_env(cls) -> "RuntimePolicy":
100
+ raw_mode = os.environ.get("HYDRA_POLICY", "off").strip().lower()
101
+ if raw_mode in {"1", "true", "yes", "on", "enabled"}:
102
+ raw_mode = "adaptive"
103
+ return cls(
104
+ mode=raw_mode,
105
+ max_window=_parse_int(os.environ.get("HYDRA_MAX_WINDOW")),
106
+ min_window=int(os.environ.get("HYDRA_MIN_WINDOW", "128")),
107
+ reserve_bytes=_parse_bytes(
108
+ os.environ.get("HYDRA_RESERVE_BYTES"),
109
+ int(float(os.environ.get("HYDRA_RESERVE_MB", "512")) * _MIB),
110
+ ),
111
+ utilization=_parse_float(os.environ.get("HYDRA_UTILIZATION"), 0.85),
112
+ allow_dense_to_window=_parse_bool(
113
+ os.environ.get("HYDRA_ALLOW_DENSE_THROTTLE"), False
114
+ ),
115
+ fail_closed=_parse_bool(os.environ.get("HYDRA_FAIL_CLOSED"), False),
116
+ align_to_block=_parse_bool(os.environ.get("HYDRA_ALIGN_WINDOW"), True),
117
+ layer_windows=_parse_int_tuple(
118
+ os.environ.get("HYDRA_LAYER_WINDOWS")
119
+ ),
120
+ )
121
+
122
+ @classmethod
123
+ def from_mapping(cls, data: Mapping[str, Any]) -> "RuntimePolicy":
124
+ return cls(
125
+ mode=str(data.get("mode", "adaptive")).strip().lower(),
126
+ max_window=_parse_int(data.get("max_window")),
127
+ min_window=int(data.get("min_window", 128)),
128
+ reserve_bytes=_parse_bytes(data.get("reserve_bytes"), 512 * _MIB),
129
+ utilization=float(data.get("utilization", 0.85)),
130
+ allow_dense_to_window=_parse_bool(data.get("allow_dense_to_window"), False),
131
+ fail_closed=_parse_bool(data.get("fail_closed"), False),
132
+ align_to_block=_parse_bool(data.get("align_to_block"), True),
133
+ layer_windows=_parse_int_tuple(data.get("layer_windows")),
134
+ )
135
+
136
+ def to_mapping(self) -> dict[str, Any]:
137
+ return asdict(self)
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class PolicyDecision:
142
+ enabled: bool
143
+ action: str
144
+ reason: str
145
+ requested_window: int | None
146
+ effective_window: int | None
147
+ estimated_bytes: int | None = None
148
+ budget_bytes: int | None = None
149
+ free_bytes: int | None = None
150
+ total_bytes: int | None = None
151
+ layer_idx: int | None = None
152
+ layer_window: int | None = None
153
+
154
+ def to_mapping(self) -> dict[str, Any]:
155
+ return asdict(self)
156
+
157
+
158
+ _POLICY_OVERRIDE: RuntimePolicy | None = None
159
+ _HISTORY: deque[PolicyDecision] = deque(maxlen=64)
160
+
161
+
162
+ def set_runtime_policy(policy: RuntimePolicy | Mapping[str, Any] | None) -> None:
163
+ """Install a process-local policy override.
164
+
165
+ Passing ``None`` returns policy selection to environment variables.
166
+ """
167
+
168
+ global _POLICY_OVERRIDE
169
+ if policy is None:
170
+ _POLICY_OVERRIDE = None
171
+ elif isinstance(policy, RuntimePolicy):
172
+ _POLICY_OVERRIDE = policy
173
+ else:
174
+ _POLICY_OVERRIDE = RuntimePolicy.from_mapping(policy)
175
+
176
+
177
+ def get_runtime_policy() -> RuntimePolicy:
178
+ return _POLICY_OVERRIDE or RuntimePolicy.from_env()
179
+
180
+
181
+ def last_policy_decision() -> PolicyDecision | None:
182
+ return _HISTORY[-1] if _HISTORY else None
183
+
184
+
185
+ def policy_history() -> list[dict[str, Any]]:
186
+ return [item.to_mapping() for item in _HISTORY]
187
+
188
+
189
+ def _record(decision: PolicyDecision) -> PolicyDecision:
190
+ if decision.enabled:
191
+ _HISTORY.append(decision)
192
+ return decision
193
+
194
+
195
+ def estimate_attention_bytes(
196
+ *,
197
+ batch_size: int,
198
+ query_heads: int,
199
+ query_tokens: int,
200
+ key_tokens: int,
201
+ head_dim: int,
202
+ dtype_bytes: int,
203
+ block_size: int,
204
+ sliding_window: int | None,
205
+ decode: bool = False,
206
+ ) -> int:
207
+ """Conservative estimate of extra bytes allocated by this attention call."""
208
+
209
+ output = batch_size * query_heads * query_tokens * head_dim * dtype_bytes
210
+ lse = batch_size * query_heads * query_tokens * 4
211
+ if decode:
212
+ return output + lse
213
+
214
+ q_blocks = math.ceil(query_tokens / block_size)
215
+ k_blocks = math.ceil(key_tokens / block_size)
216
+ row_ptr = batch_size * query_heads * (q_blocks + 1) * 4
217
+ seq_lens = batch_size * 4
218
+
219
+ if sliding_window is None or sliding_window >= key_tokens:
220
+ nnz_per_head = q_blocks * (q_blocks + 1) // 2
221
+ else:
222
+ blocks_per_row = min(k_blocks, math.ceil(sliding_window / block_size) + 1)
223
+ nnz_per_head = q_blocks * blocks_per_row
224
+ col_idx = batch_size * query_heads * nnz_per_head * 4
225
+ return output + lse + row_ptr + col_idx + seq_lens
226
+
227
+
228
+ def _cuda_mem_info(device) -> tuple[int | None, int | None]:
229
+ if getattr(device, "type", None) != "cuda":
230
+ return None, None
231
+ try:
232
+ import torch
233
+
234
+ free, total = torch.cuda.mem_get_info(device)
235
+ return int(free), int(total)
236
+ except Exception:
237
+ return None, None
238
+
239
+
240
+ def _budget(policy: RuntimePolicy, free_bytes: int | None) -> int | None:
241
+ if free_bytes is None:
242
+ return None
243
+ usable = max(0, free_bytes - policy.reserve_bytes)
244
+ return int(usable * policy.utilization)
245
+
246
+
247
+ def _align_window(window: int, block_size: int, policy: RuntimePolicy) -> int:
248
+ window = max(policy.min_window, window)
249
+ if policy.align_to_block:
250
+ window = max(block_size, (window // block_size) * block_size)
251
+ return window
252
+
253
+
254
+ def _layer_window_for(
255
+ policy: RuntimePolicy, layer_idx: int | None, block_size: int
256
+ ) -> int | None:
257
+ if layer_idx is None or not policy.layer_windows:
258
+ return None
259
+ if layer_idx < 0:
260
+ return None
261
+ raw_window = policy.layer_windows[layer_idx % len(policy.layer_windows)]
262
+ return _align_window(raw_window, block_size, policy)
263
+
264
+
265
+ def _fit_window_for_budget(
266
+ *,
267
+ budget_bytes: int,
268
+ fixed_bytes: int,
269
+ batch_size: int,
270
+ query_heads: int,
271
+ query_tokens: int,
272
+ block_size: int,
273
+ policy: RuntimePolicy,
274
+ ) -> int:
275
+ q_blocks = math.ceil(query_tokens / block_size)
276
+ bytes_per_block_per_row = batch_size * query_heads * q_blocks * 4
277
+ if bytes_per_block_per_row <= 0:
278
+ return policy.min_window
279
+ remaining = max(0, budget_bytes - fixed_bytes)
280
+ # ``fixed_bytes`` is estimated with a one-token window, which still maps to
281
+ # two CSR blocks per row: one reach block plus the diagonal block. Add any
282
+ # remaining block budget to that floor, then subtract the diagonal to get
283
+ # token-window reach.
284
+ blocks_per_row = 2 + max(0, remaining // bytes_per_block_per_row)
285
+ # CSR includes the diagonal block in addition to window reach.
286
+ window_blocks = max(1, int(blocks_per_row) - 1)
287
+ return _align_window(window_blocks * block_size, block_size, policy)
288
+
289
+
290
+ def apply_runtime_policy(
291
+ q,
292
+ k,
293
+ v,
294
+ *,
295
+ sliding_window: int | None,
296
+ block_size: int,
297
+ head_dim: int,
298
+ layer_idx: int | None = None,
299
+ ) -> PolicyDecision:
300
+ """Apply the active policy and return the effective sliding window."""
301
+
302
+ policy = get_runtime_policy()
303
+ requested = sliding_window
304
+ if not policy.enabled:
305
+ return PolicyDecision(False, "off", "policy disabled", requested, requested)
306
+
307
+ batch_size, query_heads, query_tokens, dim = q.shape
308
+ key_tokens = k.shape[2]
309
+ decode = query_tokens == 1
310
+ dtype_bytes = q.element_size()
311
+ free, total = _cuda_mem_info(q.device)
312
+ budget = _budget(policy, free)
313
+ effective = requested
314
+ layer_window = _layer_window_for(policy, layer_idx, block_size)
315
+ action = "pass"
316
+ reason = "within policy"
317
+
318
+ if policy.max_window is not None:
319
+ max_window = _align_window(policy.max_window, block_size, policy)
320
+ if effective is None and policy.allow_dense_to_window:
321
+ effective = max_window
322
+ action = "dense_to_window"
323
+ reason = f"dense attention converted to sw={effective}"
324
+ elif effective is not None and effective > max_window:
325
+ effective = max_window
326
+ action = "clamp_max_window"
327
+ reason = f"sliding_window clamped to max_window={effective}"
328
+
329
+ if layer_window is not None:
330
+ if effective is None and policy.allow_dense_to_window:
331
+ effective = layer_window
332
+ action = "layer_window"
333
+ reason = f"dense attention converted to layer_window={effective}"
334
+ elif effective is not None and effective > layer_window:
335
+ effective = layer_window
336
+ action = "layer_window"
337
+ reason = f"sliding_window clamped to layer_window={effective}"
338
+
339
+ estimate = estimate_attention_bytes(
340
+ batch_size=batch_size,
341
+ query_heads=query_heads,
342
+ query_tokens=query_tokens,
343
+ key_tokens=key_tokens,
344
+ head_dim=dim,
345
+ dtype_bytes=dtype_bytes,
346
+ block_size=block_size,
347
+ sliding_window=effective,
348
+ decode=decode,
349
+ )
350
+
351
+ if policy.mode == "adaptive" and budget is not None and estimate > budget and not decode:
352
+ fixed = estimate_attention_bytes(
353
+ batch_size=batch_size,
354
+ query_heads=query_heads,
355
+ query_tokens=query_tokens,
356
+ key_tokens=key_tokens,
357
+ head_dim=dim,
358
+ dtype_bytes=dtype_bytes,
359
+ block_size=block_size,
360
+ sliding_window=1,
361
+ decode=False,
362
+ )
363
+ if effective is None and policy.allow_dense_to_window:
364
+ effective = policy.max_window or key_tokens
365
+ if effective is not None:
366
+ fit_window = _fit_window_for_budget(
367
+ budget_bytes=budget,
368
+ fixed_bytes=fixed,
369
+ batch_size=batch_size,
370
+ query_heads=query_heads,
371
+ query_tokens=query_tokens,
372
+ block_size=block_size,
373
+ policy=policy,
374
+ )
375
+ if fit_window < effective:
376
+ effective = fit_window
377
+ action = "budget_throttle"
378
+ reason = f"estimated attention bytes exceeded budget; sw={effective}"
379
+ estimate = estimate_attention_bytes(
380
+ batch_size=batch_size,
381
+ query_heads=query_heads,
382
+ query_tokens=query_tokens,
383
+ key_tokens=key_tokens,
384
+ head_dim=dim,
385
+ dtype_bytes=dtype_bytes,
386
+ block_size=block_size,
387
+ sliding_window=effective,
388
+ decode=False,
389
+ )
390
+ elif policy.fail_closed:
391
+ decision = PolicyDecision(
392
+ True,
393
+ "reject",
394
+ "dense attention exceeds policy budget and dense throttling is disabled",
395
+ requested,
396
+ effective,
397
+ estimate,
398
+ budget,
399
+ free,
400
+ total,
401
+ layer_idx,
402
+ layer_window,
403
+ )
404
+ _record(decision)
405
+ raise MemoryError(decision.reason)
406
+
407
+ if policy.fail_closed and budget is not None and estimate > budget:
408
+ decision = PolicyDecision(
409
+ True,
410
+ "reject",
411
+ "attention estimate still exceeds policy budget after throttling",
412
+ requested,
413
+ effective,
414
+ estimate,
415
+ budget,
416
+ free,
417
+ total,
418
+ layer_idx,
419
+ layer_window,
420
+ )
421
+ _record(decision)
422
+ raise MemoryError(decision.reason)
423
+
424
+ return _record(
425
+ PolicyDecision(
426
+ True,
427
+ action,
428
+ reason,
429
+ requested,
430
+ effective,
431
+ estimate,
432
+ budget,
433
+ free,
434
+ total,
435
+ layer_idx,
436
+ layer_window,
437
+ )
438
+ )
torch-ext/hydra/sw_sinks_csr.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sliding-window + attention-sinks CSR builder.
2
+
3
+ StreamingLLM (Xiao et al. 2023, arXiv:2309.17453) showed that always keeping
4
+ the first N tokens ("attention sinks") visible recovers semantic quality
5
+ when a non-SW-trained LM is forced into a sliding-window attention regime.
6
+ Without sinks, the softmax distribution loses the anchor it implicitly
7
+ deposits on the first tokens during pretraining; with sinks, models like
8
+ Qwen2.5-Coder / Qwen3 stay coherent at long context under SW=4096.
9
+
10
+ This builder produces the same ``(row_ptr, col_idx, seq_lens)`` triple as
11
+ ``build_sliding_window_csr`` but for each Q-block additionally includes the
12
+ first ``ceil(sinks / BLOCK_K)`` K-blocks. Deduplicated when the SW range
13
+ already covers the sinks. Per-row col_idx is sorted ascending with the
14
+ diagonal block as the last entry (kernel convention — see ``csr.py``).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import math
19
+
20
+ import torch
21
+
22
+ from .csr import _broadcast_csr
23
+
24
+
25
+ def build_sw_sinks_csr(
26
+ window: int,
27
+ seq_len: int,
28
+ block_size: int,
29
+ batch_size: int,
30
+ num_heads: int,
31
+ device: torch.device | str,
32
+ sinks: int = 4,
33
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
34
+ """Sliding-window + first-N-sinks causal CSR.
35
+
36
+ Same shape contract as ``build_sliding_window_csr``: returns
37
+ ``(row_ptr, col_idx, seq_lens)`` broadcast across (B, H).
38
+
39
+ For Q-block ``qb``:
40
+ - Off-diagonal SW range (block ids): ``[k_min_sw, qb)`` where
41
+ ``k_min_sw = max(0, (qb*BS - window + 1)) // BS`` if positive
42
+ else 0.
43
+ - PLUS sink K-blocks: ``[0, sinks_blocks)`` with
44
+ ``sinks_blocks = ceil(sinks / BS)``.
45
+ - PLUS diagonal placeholder ``qb`` (always the last entry).
46
+
47
+ The union is deduplicated and sorted ascending; the diagonal is
48
+ appended last per the kernel's row-walker contract.
49
+
50
+ Edge cases:
51
+ - sinks <= 0 OR sinks_blocks == 0: degrades to plain SW.
52
+ - sinks_blocks >= num_q_blocks: degrades to dense causal.
53
+ - window covers the sinks (qb*BS < window): sinks already
54
+ included; dedup makes this a no-op.
55
+ """
56
+ if window <= 0:
57
+ raise ValueError(f"window must be positive, got {window}")
58
+ if block_size <= 0:
59
+ raise ValueError(f"block_size must be positive, got {block_size}")
60
+ if sinks < 0:
61
+ raise ValueError(f"sinks must be non-negative, got {sinks}")
62
+
63
+ num_q_blocks = math.ceil(seq_len / block_size)
64
+ sinks_blocks = math.ceil(sinks / block_size) if sinks > 0 else 0
65
+ # Clip so we never claim a sink block past the sequence end.
66
+ sinks_blocks = min(sinks_blocks, num_q_blocks)
67
+
68
+ row_ptr_row = [0]
69
+ col_idx_row: list[int] = []
70
+ for q_block in range(num_q_blocks):
71
+ # SW left-edge in K-block space (same logic as build_sliding_window_csr).
72
+ left_bound = q_block * block_size - window + 1
73
+ if left_bound <= 0:
74
+ k_min_sw = 0
75
+ else:
76
+ k_min_sw = left_bound // block_size
77
+
78
+ sw_range_end = q_block # exclusive (diagonal excluded)
79
+ sw_lo = min(k_min_sw, sw_range_end)
80
+
81
+ # Sink block ids that fall strictly before the diagonal AND strictly
82
+ # before the SW range start (anything inside SW is already covered).
83
+ sink_lo = 0
84
+ sink_hi = min(sinks_blocks, sw_lo) # exclusive
85
+
86
+ if sink_hi > sink_lo:
87
+ col_idx_row.extend(range(sink_lo, sink_hi))
88
+ col_idx_row.extend(range(sw_lo, sw_range_end))
89
+ # Diagonal placeholder must be the last entry per kernel contract.
90
+ col_idx_row.append(q_block)
91
+ row_ptr_row.append(len(col_idx_row))
92
+
93
+ return _broadcast_csr(row_ptr_row, col_idx_row, batch_size, num_heads, seq_len, device)