Skip to content

Commit 8734b06

Browse files
authored
[CUDA] Use Triton TMA for long-context causal prefill (pytorch#22414)
## Stack dependency Depends on pytorch#22190. This PR supersedes pytorch#22193 after reordering the stack so TMA sits directly on the split-K baseline and the optional FP8 linear path sits above TMA. ## Summary Use Triton tensor descriptors/TMA for long-context global causal attention on SM90+ GPUs. - Dispatch from semantic and hardware properties rather than a model identity: global causal attention, a device-resident KV bound, sufficiently long KV context, and prefill-sized queries. - Select the optimized path from the requested CUDA target, honoring explicit `TORCH_CUDA_ARCH_LIST`, excluding ROCm, and avoiding CUDA context creation at module import. - Support common transformer head dimensions 64 and 128 with separately profiled tile, stage, and warp configurations. - For D=128, select a smaller tile for 512-1023 query rows and a larger tile at 1024+ rows. - Use real output strides and keep non-unit inner-stride inputs on the existing portable Triton path. - Preserve finite outputs for fully masked rows with guarded online-softmax normalization. - Keep unsupported or non-beneficial shapes on the existing portable Triton path; head dimension 256 currently falls back because tested TMA configurations were slower. - Preserve online-softmax numerical stability; this stack contains neither PDL nor the former `phi=5` approximation. The path is selected automatically inside the SDPA dispatcher. Eligible prefill shapes use TMA when the AOTInductor compile target is SM90+; decode, ROCm, pre-SM90 targets, unsupported layouts/head dimensions, explicit masks, and non-beneficial shapes retain the portable Triton path. The implementation remains pure Triton and is not tied to Muse-Glimmer. ## Operator-level performance RTX 5090, BF16 global causal GQA (`B=1, Hq=16, Hkv=2`), TMA versus the existing Triton kernel: | Head dim | Lq | Lkv | Existing | TMA | Speedup | |---:|---:|---:|---:|---:|---:| | 64 | 512 | 16,384 | 0.439 ms | 0.268 ms | 1.64x | | 64 | 4,096 | 131,072 | 13.153 ms | 11.794 ms | 1.12x | | 128 | 512 | 16,384 | 0.632 ms | 0.534 ms | 1.18x | | 128 | 1,024 | 131,072 | 7.847 ms | 6.750 ms | 1.16x | | 128 | 4,096 | 131,072 | 29.212 ms | 27.039 ms | 1.08x | The latest post-rebase exact-stack A/B attributes the long-context gain directly to this PR: relative to pytorch#22190, prefill improves from 2,971.25 to 3,152.94 tok/s at 32K (+6.11%) and from 2,226.87 to 2,630.43 tok/s at 130K (+18.12%). Cumulatively versus the measured main baseline, the stack through this PR improves prefill by 8.59% at 32K and 25.24% at 130K. Decode changes by at most 0.53% relative to pytorch#22190. KV buffers below 16K remain on the portable attention path. ## Post-rebase automatic-TMA end-to-end performance This branch is `pytorch#22190 + TMA`, without FP8/Q8 activation or the later linear optimizations. These figures supersede the earlier PR-summary measurements. Stable reruns used a single otherwise-idle RTX 5090, single-GPU `llama-cli`, the P2409802035 protocol, CUDA graphs, 512 decoded tokens, seven runs, and the mean of runs 3-7. The measurements used the same TMA kernel/configuration as this revision; the subsequent cleanup only removed the manual feature gate and made eligible-shape selection automatic. | Prompt length | ET prefill tok/s | llama.cpp prefill tok/s | ET vs llama.cpp prefill | ET decode tok/s | llama.cpp decode tok/s | ET vs llama.cpp decode | |---:|---:|---:|---:|---:|---:|---:| | 512 | 2302.19 | 2391.04 | -3.72% | 76.51 | 79.89 | -4.23% | | 2,048 | 3311.78 | 2607.19 | +27.02% | 76.05 | 77.40 | -1.74% | | 8,192 | 3304.35 | 2574.57 | +28.35% | 74.37 | 76.96 | -3.37% | | 32,768 | 3152.94 | 2465.93 | +27.86% | 74.56 | 75.31 | -1.00% | | 130,048 | 2630.43 | 2228.04 | +18.06% | 69.45 | 70.77 | -1.87% | Across the five lengths, ET is 18.83% faster geometric mean than llama.cpp in prefill and 2.45% slower in decode. The long-context incremental gain in this PR comes from TMA attention; the decode improvement in the stack comes from pytorch#22190's split-K scheduling. ## NLL evaluation `notes_v5`, 100 documents / 154,846 next-token labels. Relative to the A100 BF16 reference, the same/close bucket counts move from `56/44` for pytorch#22190 to `59/41` for TMA-only. Compared directly with pytorch#22190, TMA-only has mean token absolute NLL gap 0.014022 (max 1.158961) and mean document gap 0.000579 (max 0.002262), with document buckets `84 same / 16 close / 0 match / 0 mismatch`. Buckets use document mean-NLL gap: `<1e-3`, `[1e-3,1e-2)`, `[1e-2,1e-1)`, and `>=1e-1`. The same/close bucket distribution changes by 3 documents, within the agreed maximum difference of 5 for enabling the optimization automatically on eligible targets. ## Test plan - `lintrunner init` completed; linting this PR's changed files with `lintrunner -a` is clean. The full-repository run reports only the two existing ARM `materialize_symints` mypy failures from current `main`. - Focused TMA routing/correctness tests: passed. - TMA correctness compares the forced TMA path, forced portable fallback, and dense-mask reference for head dimensions 64 and 128. - Covered automatic SM90+ dispatch, pre-SM90/ROCm exclusion, non-contiguous fallback, fully masked rows, and D=256 portable fallback. - Fresh 128K TMA-only PTE/PTD export and smoke run on RTX 5090. - 35-pair final E2E comparison on an otherwise idle dual-RTX-5090 host, using only GPU0 for execution. - `git diff --check`.
1 parent ed72896 commit 8734b06

2 files changed

Lines changed: 311 additions & 0 deletions

File tree

‎backends/cuda/tests/test_triton_sdpa.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
Test parametrization adapted from FlashAttention (tests/cute/test_flash_attn.py).
1414
"""
1515

16+
import importlib
1617
import itertools
1718
import unittest
19+
from unittest import mock
1820

1921
import torch
2022
import torch.nn.functional as F
@@ -646,6 +648,136 @@ def test_explicit_mask_composes_with_causal_kv_len(self):
646648
self.assertFalse(torch.isnan(out).any())
647649
self.assertLess(_max_abs_error(out, ref), MAX_ABS_TOL)
648650

651+
@unittest.skipIf(
652+
not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] < 9,
653+
"TMA requires SM90+",
654+
)
655+
def test_tma_causal_prefill_common_head_dims(self):
656+
"""TMA causal prefill supports common power-of-two head dimensions."""
657+
import triton
658+
from triton.runtime._allocation import _allocator
659+
660+
sdpa_module = importlib.import_module(
661+
"executorch.backends.cuda.triton.kernels.sdpa"
662+
)
663+
664+
# Tensor descriptors require a small runtime descriptor workspace in
665+
# eager mode. Inductor supplies this allocator in the production path.
666+
self.addCleanup(triton.set_allocator, _allocator.get())
667+
triton.set_allocator(
668+
lambda size, alignment, stream: torch.empty(
669+
size, dtype=torch.int8, device="cuda"
670+
)
671+
)
672+
B, H_q, H_kv = 1, 4, 2
673+
Lq, kv_len, Lk = 512, 4096, 16384
674+
675+
for D in (64, 128):
676+
with self.subTest(D=D):
677+
self.assertIsNotNone(sdpa_module._tma_prefill_config(D, Lq))
678+
torch.manual_seed(D)
679+
q = torch.randn(B, H_q, Lq, D, dtype=torch.bfloat16, device="cuda")
680+
k = torch.randn(B, H_kv, Lk, D, dtype=torch.bfloat16, device="cuda")
681+
v = torch.randn(B, H_kv, Lk, D, dtype=torch.bfloat16, device="cuda")
682+
kv_len_t = torch.tensor([kv_len], dtype=torch.int32, device="cuda")
683+
dense = self._dense_bottom_right_causal_mask(B, Lq, kv_len, Lk, "cuda")
684+
685+
with mock.patch.object(
686+
sdpa_module,
687+
"_cuda_compile_target_is_sm90_or_newer",
688+
return_value=True,
689+
):
690+
out_tma = self.sdpa(
691+
q,
692+
k,
693+
v,
694+
attn_mask=None,
695+
enable_gqa=True,
696+
kv_len=kv_len_t,
697+
is_causal=True,
698+
)
699+
with mock.patch.object(
700+
sdpa_module,
701+
"_cuda_compile_target_is_sm90_or_newer",
702+
return_value=False,
703+
):
704+
out_existing = self.sdpa(
705+
q,
706+
k,
707+
v,
708+
attn_mask=None,
709+
enable_gqa=True,
710+
kv_len=kv_len_t,
711+
is_causal=True,
712+
)
713+
out_dense = self.sdpa(
714+
q,
715+
k,
716+
v,
717+
attn_mask=dense,
718+
enable_gqa=True,
719+
kv_len=kv_len_t,
720+
)
721+
722+
self.assertFalse(torch.isnan(out_tma).any())
723+
self.assertLess(_max_abs_error(out_tma, out_dense), MAX_ABS_TOL)
724+
self.assertLess(_max_abs_error(out_tma, out_existing), MAX_ABS_TOL)
725+
726+
@unittest.skipIf(
727+
not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] < 9,
728+
"TMA requires SM90+",
729+
)
730+
def test_tma_falls_back_for_noncontiguous_head_dim(self):
731+
"""TMA descriptors are used only when Q/K/V have unit inner stride."""
732+
B, H_q, H_kv, Lq, kv_len, Lk, D = 1, 4, 2, 512, 4096, 16384, 128
733+
torch.manual_seed(6)
734+
q = torch.randn(B, H_q, Lq, D * 2, dtype=torch.bfloat16, device="cuda")[
735+
..., ::2
736+
]
737+
k = torch.randn(B, H_kv, Lk, D * 2, dtype=torch.bfloat16, device="cuda")[
738+
..., ::2
739+
]
740+
v = torch.randn(B, H_kv, Lk, D * 2, dtype=torch.bfloat16, device="cuda")[
741+
..., ::2
742+
]
743+
kv_len_t = torch.tensor([kv_len], dtype=torch.int32, device="cuda")
744+
dense = self._dense_bottom_right_causal_mask(B, Lq, kv_len, Lk, "cuda")
745+
746+
out = self.sdpa(q, k, v, enable_gqa=True, kv_len=kv_len_t, is_causal=True)
747+
ref = _reference_sdpa(q, k, v, attn_mask=dense)
748+
749+
self.assertFalse(torch.isnan(out).any())
750+
self.assertLess(_max_abs_error(out, ref), MAX_ABS_TOL)
751+
752+
@unittest.skipIf(
753+
not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] < 9,
754+
"TMA requires SM90+",
755+
)
756+
def test_tma_fully_masked_rows_are_finite(self):
757+
"""Rows before the beginning of a short KV prefix return zero, not NaN."""
758+
import triton
759+
from triton.runtime._allocation import _allocator
760+
761+
self.addCleanup(triton.set_allocator, _allocator.get())
762+
triton.set_allocator(
763+
lambda size, alignment, stream: torch.empty(
764+
size, dtype=torch.int8, device="cuda"
765+
)
766+
)
767+
B, H_q, H_kv, Lq, kv_len, Lk, D = 1, 4, 2, 512, 128, 16384, 128
768+
torch.manual_seed(7)
769+
q = torch.randn(B, H_q, Lq, D, dtype=torch.bfloat16, device="cuda")
770+
k = torch.randn(B, H_kv, Lk, D, dtype=torch.bfloat16, device="cuda")
771+
v = torch.randn(B, H_kv, Lk, D, dtype=torch.bfloat16, device="cuda")
772+
kv_len_t = torch.tensor([kv_len], dtype=torch.int32, device="cuda")
773+
dense = self._dense_bottom_right_causal_mask(B, Lq, kv_len, Lk, "cuda")
774+
775+
out = self.sdpa(q, k, v, enable_gqa=True, kv_len=kv_len_t, is_causal=True)
776+
ref = _reference_sdpa(q, k, v, attn_mask=dense)
777+
778+
self.assertFalse(torch.isnan(out).any())
779+
self.assertLess(_max_abs_error(out, ref), MAX_ABS_TOL)
780+
649781
def test_mask_is_causal_matches_dense_decode(self):
650782
"""is_causal + kv_len is a no-op vs dense for L_q==1 decode over a KV cache."""
651783
D, B, H_q, H_kv = 128, 1, 16, 2

‎backends/cuda/triton/kernels/sdpa.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,38 @@ def _is_power_of_2(n: int) -> bool:
5252
_SPLITK_LKV_THRESHOLD = 256
5353

5454

55+
_TMA_PREFILL_LKV_THRESHOLD = 16384
56+
57+
58+
def _cuda_compile_target_is_sm90_or_newer() -> bool:
59+
"""Return whether the CUDA code being compiled targets SM90 or newer."""
60+
if torch.version.hip is not None:
61+
return False
62+
63+
# Match the target selected by AOTInductor rather than assuming that the
64+
# export host is also the inference target. Multi-architecture PTE builds
65+
# compile each native variant independently, so this value corresponds to
66+
# the variant currently being lowered.
67+
from torch._inductor.codegen.cuda.compile_utils import _nvcc_arch_as_compile_option
68+
69+
target = _nvcc_arch_as_compile_option().removesuffix("a")
70+
return target.isdigit() and int(target) >= 90
71+
72+
73+
def _tma_prefill_config(
74+
head_dim: int, query_len: int
75+
) -> Optional[tuple[int, int, int, int]]:
76+
"""Return a validated TMA config for common transformer head dimensions."""
77+
if head_dim == 64:
78+
return 64, 64, 3, 4
79+
if head_dim == 128:
80+
if query_len >= 1024:
81+
return 128, 64, 3, 8
82+
if query_len >= 512:
83+
return 64, 64, 3, 4
84+
return None
85+
86+
5587
# Decode split-K occupancy target. A sweep across both production attention
5688
# families showed that targeting 16/9 waves gives a good balance between split
5789
# kernel occupancy and reduction/empty-CTA overhead. Keep the ratio integral so
@@ -780,6 +812,102 @@ def _sdpa_fwd_kernel(
780812
)
781813

782814

815+
@triton.jit
816+
def _sdpa_prefill_tma_kernel(
817+
Q_ptr,
818+
K_ptr,
819+
V_ptr,
820+
O_ptr,
821+
KV_LEN_ptr,
822+
H_grid,
823+
Lq,
824+
Lk,
825+
stride_qb,
826+
stride_qh,
827+
stride_qm,
828+
stride_kb,
829+
stride_kh,
830+
stride_kn,
831+
stride_vb,
832+
stride_vh,
833+
stride_vn,
834+
stride_ob,
835+
stride_oh,
836+
stride_om,
837+
stride_od,
838+
sm_scale,
839+
BLOCK_M: tl.constexpr,
840+
BLOCK_N: tl.constexpr,
841+
HEAD_DIM: tl.constexpr,
842+
NUM_GROUPS: tl.constexpr,
843+
NUM_STAGES: tl.constexpr,
844+
):
845+
"""TMA global-causal prefill for SM90+ and a device-resident KV bound."""
846+
pid_m = tl.program_id(0)
847+
pid_bh = tl.program_id(1)
848+
b = pid_bh // H_grid
849+
h_q = pid_bh % H_grid
850+
h_kv = h_q // NUM_GROUPS
851+
852+
q_desc = tl.make_tensor_descriptor(
853+
Q_ptr + b * stride_qb + h_q * stride_qh,
854+
shape=[Lq, HEAD_DIM],
855+
strides=[stride_qm, 1],
856+
block_shape=[BLOCK_M, HEAD_DIM],
857+
)
858+
k_desc = tl.make_tensor_descriptor(
859+
K_ptr + b * stride_kb + h_kv * stride_kh,
860+
shape=[Lk, HEAD_DIM],
861+
strides=[stride_kn, 1],
862+
block_shape=[BLOCK_N, HEAD_DIM],
863+
)
864+
v_desc = tl.make_tensor_descriptor(
865+
V_ptr + b * stride_vb + h_kv * stride_vh,
866+
shape=[Lk, HEAD_DIM],
867+
strides=[stride_vn, 1],
868+
block_shape=[BLOCK_N, HEAD_DIM],
869+
)
870+
871+
q_start = pid_m * BLOCK_M
872+
offs_m = q_start + tl.arange(0, BLOCK_M)
873+
offs_n_base = tl.arange(0, BLOCK_N)
874+
offs_d = tl.arange(0, HEAD_DIM)
875+
q = tl.load_tensor_descriptor(q_desc, [q_start, 0])
876+
kv_len = tl.minimum(tl.load(KV_LEN_ptr), Lk)
877+
absolute_q = (kv_len - Lq) + offs_m
878+
m_i = tl.full([BLOCK_M], -float("inf"), tl.float32)
879+
l_i = tl.zeros([BLOCK_M], tl.float32)
880+
acc = tl.zeros([BLOCK_M, HEAD_DIM], tl.float32)
881+
scale = sm_scale.to(tl.float32)
882+
883+
for start_n in tl.range(0, kv_len, BLOCK_N, num_stages=NUM_STAGES):
884+
offs_n = start_n + offs_n_base
885+
k = tl.load_tensor_descriptor(k_desc, [start_n, 0])
886+
qk = tl.dot(q, tl.trans(k)).to(tl.float32) * scale
887+
valid = (offs_n[None, :] < kv_len) & (offs_n[None, :] <= absolute_q[:, None])
888+
qk = tl.where(valid, qk, -float("inf"))
889+
m_ij = tl.maximum(m_i, tl.max(qk, axis=1))
890+
safe_m = tl.where(m_ij == -float("inf"), 0.0, m_ij)
891+
alpha = tl.exp(m_i - safe_m)
892+
p = tl.exp(qk - safe_m[:, None])
893+
l_i = l_i * alpha + tl.sum(p, axis=1)
894+
acc = acc * alpha[:, None]
895+
v = tl.load_tensor_descriptor(v_desc, [start_n, 0])
896+
acc = tl.dot(p.to(tl.bfloat16), v, acc).to(tl.float32)
897+
m_i = m_ij
898+
899+
inv_l = tl.where(l_i > 0, 1.0 / l_i, 0.0)
900+
out = acc * inv_l[:, None]
901+
o_ptrs = (
902+
O_ptr
903+
+ b * stride_ob
904+
+ h_q * stride_oh
905+
+ offs_m[:, None] * stride_om
906+
+ offs_d[None, :] * stride_od
907+
)
908+
tl.store(o_ptrs, out.to(tl.bfloat16), mask=offs_m[:, None] < Lq)
909+
910+
783911
def _validate_sdpa_inputs(
784912
query: torch.Tensor,
785913
key: torch.Tensor,
@@ -875,6 +1003,57 @@ def _launch_pow2_kernel(
8751003
stride_vb, stride_vh, stride_vn, stride_vd = value.stride()
8761004
stride_ob, stride_oh, stride_om, stride_od = out.stride()
8771005

1006+
# Hopper/Blackwell: TMA materially improves the long-context global
1007+
# causal path. Sliding-window attention keeps the existing kernel, as do
1008+
# pre-SM90 GPUs. The same TMA kernel is safe for short chunks and avoids a
1009+
# host sync on the device-resident kv_len scalar.
1010+
tma_config = _tma_prefill_config(D, L_q)
1011+
if (
1012+
HAS_KV_LEN
1013+
and mask_is_causal
1014+
and not HAS_MASK
1015+
and query.stride(-1) == 1
1016+
and key.stride(-1) == 1
1017+
and value.stride(-1) == 1
1018+
and tma_config is not None
1019+
and L_kv >= _TMA_PREFILL_LKV_THRESHOLD
1020+
and L_q > 4
1021+
and _cuda_compile_target_is_sm90_or_newer()
1022+
):
1023+
block_m, block_n, tma_num_stages, tma_num_warps = tma_config
1024+
wrap_triton(_sdpa_prefill_tma_kernel)[(triton.cdiv(L_q, block_m), B * H_q)](
1025+
query,
1026+
key,
1027+
value,
1028+
out,
1029+
kv_len_ptr,
1030+
H_q,
1031+
L_q,
1032+
L_kv,
1033+
stride_qb,
1034+
stride_qh,
1035+
stride_qm,
1036+
stride_kb,
1037+
stride_kh,
1038+
stride_kn,
1039+
stride_vb,
1040+
stride_vh,
1041+
stride_vn,
1042+
stride_ob,
1043+
stride_oh,
1044+
stride_om,
1045+
stride_od,
1046+
sm_scale,
1047+
BLOCK_M=block_m,
1048+
BLOCK_N=block_n,
1049+
HEAD_DIM=D,
1050+
NUM_GROUPS=num_groups,
1051+
NUM_STAGES=tma_num_stages,
1052+
num_warps=tma_num_warps,
1053+
num_stages=tma_num_stages,
1054+
)
1055+
return
1056+
8781057
if pack_gqa:
8791058
H_grid = H_kv
8801059
Lq_packed = L_q * num_groups

0 commit comments

Comments
 (0)