38142.diff 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794
  1. diff --git a/python/sglang/kernels/ops/attention/prefill_attention.py b/python/sglang/kernels/ops/attention/prefill_attention.py
  2. index a50b89787f2a..63728eee69ed 100644
  3. --- a/python/sglang/kernels/ops/attention/prefill_attention.py
  4. +++ b/python/sglang/kernels/ops/attention/prefill_attention.py
  5. @@ -18,6 +18,8 @@
  6. # Adapted from
  7. # https://github.com/ModelTC/lightllm/blob/f2a54f0912293f683bf1d1695fd12c4098a5bf82/lightllm/models/llama/triton_kernel/context_flashattention_nopad.py#L1
  8. +from typing import Optional, Tuple
  9. +
  10. import torch
  11. import triton
  12. import triton.language as tl
  13. @@ -40,6 +42,7 @@ def _fwd_kernel(
  14. B_Start_Loc,
  15. B_Seqlen,
  16. Out,
  17. + Sinks,
  18. stride_qbs,
  19. stride_qh,
  20. stride_kbs,
  21. @@ -54,6 +57,9 @@ def _fwd_kernel(
  22. BLOCK_N: tl.constexpr,
  23. IS_CAUSAL: tl.constexpr,
  24. Lk: tl.constexpr,
  25. + WINDOW_LEFT: tl.constexpr,
  26. + WINDOW_RIGHT: tl.constexpr,
  27. + HAS_SINKS: tl.constexpr,
  28. ):
  29. cur_batch = tl.program_id(0)
  30. cur_head = tl.program_id(1)
  31. @@ -89,9 +95,17 @@ def _fwd_kernel(
  32. k_ptrs = K + off_k
  33. v_ptrs = V + off_v
  34. - # initialize pointer to m and l
  35. - m_i = tl.zeros([BLOCK_M], dtype=tl.float32) - float("inf")
  36. - l_i = tl.zeros([BLOCK_M], dtype=tl.float32)
  37. + # initialize pointer to m and l. A sink is a value-less virtual key whose
  38. + # logit s is learned per q-head, so seeding (m_i, l_i) with (s, 1) is
  39. + # exactly "process the sink first": every later rescale by alpha carries
  40. + # exp(s - m_i_new) in the denominator, and the numerator never sees it.
  41. + if HAS_SINKS:
  42. + sink = tl.load(Sinks + cur_head).to(tl.float32)
  43. + m_i = tl.zeros([BLOCK_M], dtype=tl.float32) + sink
  44. + l_i = tl.zeros([BLOCK_M], dtype=tl.float32) + 1.0
  45. + else:
  46. + m_i = tl.zeros([BLOCK_M], dtype=tl.float32) - float("inf")
  47. + l_i = tl.zeros([BLOCK_M], dtype=tl.float32)
  48. acc = tl.zeros([BLOCK_M, BLOCK_DMODEL], dtype=tl.float32)
  49. block_mask = tl.where(block_start_loc < cur_batch_seq_len, 1, 0)
  50. @@ -101,7 +115,13 @@ def _fwd_kernel(
  51. if not IS_CAUSAL
  52. else tl.minimum((start_m + 1) * BLOCK_M, cur_batch_seq_len)
  53. )
  54. - for start_n in range(0, block_mask * end_n, BLOCK_N):
  55. + lo = 0
  56. + if WINDOW_LEFT != -1:
  57. + # keys below the first row's window can never score: skip whole blocks
  58. + lo = tl.maximum(block_start_loc - WINDOW_LEFT, 0) // BLOCK_N * BLOCK_N
  59. + if WINDOW_RIGHT != -1:
  60. + end_n = tl.minimum(end_n, block_start_loc + BLOCK_M + WINDOW_RIGHT)
  61. + for start_n in range(lo, block_mask * end_n, BLOCK_N):
  62. start_n = tl.multiple_of(start_n, BLOCK_N)
  63. # -- compute qk ----
  64. k = tl.load(
  65. @@ -116,19 +136,23 @@ def _fwd_kernel(
  66. qk *= sm_scale
  67. if IS_CAUSAL:
  68. - qk += tl.where(
  69. - (start_n + offs_n[None, :] < cur_batch_seq_len)
  70. - & (offs_m[:, None] >= (start_n + offs_n[None, :])),
  71. - 0,
  72. - float("-inf"),
  73. + mask = (start_n + offs_n[None, :] < cur_batch_seq_len) & (
  74. + offs_m[:, None] >= (start_n + offs_n[None, :])
  75. )
  76. else:
  77. - qk += tl.where(
  78. - (start_n + offs_n[None, :]) < cur_batch_seq_len, 0, float("-inf")
  79. - )
  80. + mask = (start_n + offs_n[None, :]) < cur_batch_seq_len
  81. + if WINDOW_LEFT != -1:
  82. + mask = mask & (start_n + offs_n[None, :] >= offs_m[:, None] - WINDOW_LEFT)
  83. + if WINDOW_RIGHT != -1:
  84. + mask = mask & (start_n + offs_n[None, :] <= offs_m[:, None] + WINDOW_RIGHT)
  85. + qk += tl.where(mask, 0, float("-inf"))
  86. # -- compute m_ij, p, l_ij
  87. m_ij = tl.max(qk, 1)
  88. + if WINDOW_LEFT != -1 or WINDOW_RIGHT != -1:
  89. + # with a window, a row's valid keys may miss this whole KV block;
  90. + # keep the running max finite or exp(-inf - (-inf)) turns NaN
  91. + m_ij = tl.where(m_ij == float("-inf"), 0.0, m_ij)
  92. p = tl.exp(qk - m_ij[:, None])
  93. l_ij = tl.sum(p, 1)
  94. # -- update m_i and l_i
  95. @@ -136,12 +160,18 @@ def _fwd_kernel(
  96. alpha = tl.exp(m_i - m_i_new)
  97. beta = tl.exp(m_ij - m_i_new)
  98. l_i_new = alpha * l_i + beta * l_ij
  99. - # -- update output accumulator --
  100. - # scale p
  101. - p_scale = beta / l_i_new
  102. + if WINDOW_LEFT != -1 or WINDOW_RIGHT != -1:
  103. + # rows whose window has not intersected any block yet keep
  104. + # l_i_new == 0; hold both scales at 0 until their first block
  105. + p_scale = tl.where(l_i_new == 0.0, 0.0, beta / l_i_new)
  106. + acc_scale = tl.where(l_i_new == 0.0, 0.0, l_i / l_i_new * alpha)
  107. + else:
  108. + # -- update output accumulator --
  109. + # scale p
  110. + p_scale = beta / l_i_new
  111. + acc_scale = l_i / l_i_new * alpha
  112. p = p * p_scale[:, None]
  113. # scale acc
  114. - acc_scale = l_i / l_i_new * alpha
  115. acc = acc * acc_scale[:, None]
  116. # update acc
  117. v = tl.load(
  118. @@ -168,7 +198,17 @@ def _fwd_kernel(
  119. def context_attention_fwd(
  120. - q, k, v, o, b_start_loc, b_seq_len, max_input_len, is_causal=True, sm_scale=None
  121. + q,
  122. + k,
  123. + v,
  124. + o,
  125. + b_start_loc,
  126. + b_seq_len,
  127. + max_input_len,
  128. + is_causal=True,
  129. + sm_scale=None,
  130. + window_size: Tuple[int, int] = (-1, -1),
  131. + sinks: Optional[torch.Tensor] = None,
  132. ):
  133. """
  134. q, k, v: [b * s, head, head_dim]
  135. @@ -176,6 +216,10 @@ def context_attention_fwd(
  136. b_seq_len: [b]
  137. out: [b * s, head, head_dim]
  138. sm_scale: softmax scale, defaults to 1/sqrt(head_dim)
  139. + window_size: (left, right) sliding window, -1 on a side means unbounded;
  140. + query i attends keys in [i - left, i + right] (flash-attn convention)
  141. + sinks: optional [num_q_heads] per-head sink logits appended to the softmax
  142. + denominator without a value vector (dilutes real attention weights)
  143. """
  144. if (_is_cuda or _is_hip) and CUDA_CAPABILITY[0] > 8:
  145. BLOCK = 128
  146. @@ -189,8 +233,22 @@ def context_attention_fwd(
  147. batch, head = b_seq_len.shape[0], q.shape[1]
  148. kv_group_num = q.shape[1] // k.shape[1]
  149. + window_left = int(window_size[0]) if window_size[0] >= 0 else -1
  150. + window_right = int(window_size[1]) if window_size[1] >= 0 else -1
  151. + if sinks is not None:
  152. + if sinks.numel() != head:
  153. + raise ValueError(
  154. + f"sinks must have one logit per q head ({head}), got {sinks.numel()}"
  155. + )
  156. + # the kernel reads Sinks as a raw flat pointer: a strided view or a
  157. + # wrong-device tensor would silently read garbage
  158. + if not sinks.is_contiguous():
  159. + raise ValueError("sinks must be contiguous")
  160. + if sinks.device != q.device:
  161. + raise ValueError(f"sinks on {sinks.device} but q on {q.device}")
  162. +
  163. grid = (batch, head, triton.cdiv(max_input_len, BLOCK))
  164. - num_warps = 4 if Lk <= 64 else 8
  165. + num_warps = 8 if Lk > 64 else 4
  166. _fwd_kernel[grid](
  167. q,
  168. @@ -200,6 +258,7 @@ def context_attention_fwd(
  169. b_start_loc,
  170. b_seq_len,
  171. o,
  172. + sinks if sinks is not None else q, # placeholder; never read without HAS_SINKS
  173. q.stride(0),
  174. q.stride(1),
  175. k.stride(0),
  176. @@ -216,4 +275,7 @@ def context_attention_fwd(
  177. num_warps=num_warps,
  178. num_stages=1,
  179. Lk=Lk,
  180. + WINDOW_LEFT=window_left,
  181. + WINDOW_RIGHT=window_right,
  182. + HAS_SINKS=sinks is not None,
  183. )
  184. diff --git a/python/sglang/srt/layers/attention/vision.py b/python/sglang/srt/layers/attention/vision.py
  185. index bff80a1f1a09..8d620b665c1b 100644
  186. --- a/python/sglang/srt/layers/attention/vision.py
  187. +++ b/python/sglang/srt/layers/attention/vision.py
  188. @@ -515,6 +515,8 @@ def forward(
  189. max_seqlen,
  190. is_causal=False,
  191. sm_scale=softmax_scale,
  192. + window_size=kwargs.get("window_size", (-1, -1)),
  193. + sinks=kwargs.get("s_aux", None),
  194. )
  195. return output
  196. diff --git a/test/registered/attention/test_triton_attention_window_sink.py b/test/registered/attention/test_triton_attention_window_sink.py
  197. new file mode 100644
  198. index 000000000000..4a6c17bd146f
  199. --- /dev/null
  200. +++ b/test/registered/attention/test_triton_attention_window_sink.py
  201. @@ -0,0 +1,583 @@
  202. +"""
  203. +Regression tests for sglang issue #37983:
  204. +"[Bug] VisionTritonAttention drops MiMo-style window attention and sinks"
  205. +
  206. +The Triton varlen prefill kernel (context_attention_fwd) historically had no
  207. +sliding-window or per-head-sink support, and VisionTritonAttention dropped the
  208. +window_size / s_aux kwargs its caller (VisionAttention) always passes. Models
  209. +whose ViT relies on local-window attention plus learnable sinks (e.g. MiMo-VL)
  210. +were silently computed as full attention without sinks.
  211. +
  212. +Attention with a sliding window and per-head sinks has an exact mathematical
  213. +definition, so a plain PyTorch reference implementation on random tensors is a
  214. +complete oracle: no checkpoint download, no server needed.
  215. +
  216. +Oracle calibration chain:
  217. + 1. test_reference_matches_hand_computed_example -- CPU-only, exact numbers
  218. + 2. test_kernel_matches_reference_full_attention -- the kernel agrees with
  219. + the reference on the subset it always computed correctly
  220. + 3. test_fa3_cross_check_reference -- optional cross-check on Hopper (sm90)
  221. + where the FA3 vision backend provably honors window_size + sinks
  222. +"""
  223. +
  224. +import math
  225. +import unittest
  226. +
  227. +import torch
  228. +
  229. +from sglang.kernels.ops.attention.prefill_attention import context_attention_fwd
  230. +from sglang.srt.utils import get_device
  231. +from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
  232. +from sglang.test.test_utils import CustomTestCase
  233. +
  234. +# Vision attention window/sink semantics (triton kernel + backend integration)
  235. +register_cuda_ci(est_time=2, stage="extra-a", runner_config="1-gpu-large")
  236. +register_amd_ci(est_time=3, suite="stage-b-test-1-gpu-small-amd")
  237. +
  238. +ATOL = 2e-2
  239. +RTOL = 2e-2 # bf16 inputs with fp32 accumulation
  240. +
  241. +
  242. +def ref_varlen_attention(
  243. + q, k, v, cu_seqlens, sm_scale, window_size=(-1, -1), sinks=None, causal=False
  244. +):
  245. + """Non-causal varlen attention with optional sliding window and per-head sinks.
  246. +
  247. + q: [total_tokens, Hq, D]
  248. + k, v: [total_tokens, Hk, D] (Hq % Hk == 0, GQA allowed)
  249. + cu_seqlens: list of length B+1
  250. + window_size: (left, right); -1 = unbounded on that side (flash-attn
  251. + convention: query i attends keys in [i - left, i + right])
  252. + sinks: [Hq] per-head logit appended to the softmax denominator
  253. + without a value vector (dilutes real attention weights)
  254. +
  255. + out_i = sum_j exp(s_ij - m) v_j / ( sum_j exp(s_ij - m) + exp(sink_h - m) )
  256. + """
  257. + _, hq, _ = q.shape
  258. + hk = k.shape[1]
  259. + if hk != hq:
  260. + rep = hq // hk
  261. + k = k.repeat_interleave(rep, dim=1)
  262. + v = v.repeat_interleave(rep, dim=1)
  263. +
  264. + device = q.device
  265. + wl, wr = window_size
  266. + outs = []
  267. + for b in range(len(cu_seqlens) - 1):
  268. + s, e = int(cu_seqlens[b]), int(cu_seqlens[b + 1])
  269. + qb, kb, vb = q[s:e].float(), k[s:e].float(), v[s:e].float()
  270. + t = e - s
  271. + scores = torch.einsum("ihd,jhd->hij", qb, kb) * sm_scale # [H, Tq, Tk]
  272. +
  273. + iq = torch.arange(t, device=device).unsqueeze(1) # [T, 1] query idx
  274. + ik = torch.arange(t, device=device).unsqueeze(0) # [1, T] key idx
  275. + if causal:
  276. + scores = scores.masked_fill(ik > iq, float("-inf"))
  277. + if wl != -1:
  278. + scores = scores.masked_fill(ik < iq - wl, float("-inf"))
  279. + if wr != -1:
  280. + scores = scores.masked_fill(ik > iq + wr, float("-inf"))
  281. +
  282. + if sinks is not None:
  283. + sink_col = sinks.detach().float().view(-1, 1, 1).expand(hq, t, 1)
  284. + scores = torch.cat([scores, sink_col], dim=-1) # [H, T, T+1]
  285. +
  286. + probs = torch.softmax(scores, dim=-1)
  287. + if sinks is not None:
  288. + probs = probs[..., :t] # the sink has no value vector
  289. + outs.append(torch.einsum("hij,jhd->ihd", probs, vb).to(q.dtype))
  290. + return torch.cat(outs, dim=0)
  291. +
  292. +
  293. +def make_varlen_inputs(
  294. + seq_lens,
  295. + num_heads=8,
  296. + num_kv_heads=None,
  297. + head_dim=64,
  298. + dtype=torch.bfloat16,
  299. + seed=0,
  300. +):
  301. + """Random varlen-packed q/k/v plus matching cu_seqlens / seq_lens."""
  302. + num_kv_heads = num_kv_heads or num_heads
  303. + total = sum(seq_lens)
  304. + device = get_device()
  305. + gen = torch.Generator(device=device)
  306. + gen.manual_seed(seed)
  307. +
  308. + def rnd(shape, scale):
  309. + return (
  310. + (
  311. + torch.randn(shape, generator=gen, device=device, dtype=torch.float32)
  312. + * scale
  313. + )
  314. + .to(dtype)
  315. + .contiguous()
  316. + )
  317. +
  318. + q = rnd((total, num_heads, head_dim), 0.5)
  319. + k = rnd((total, num_kv_heads, head_dim), 0.5)
  320. + v = rnd((total, num_kv_heads, head_dim), 1.0)
  321. + cu = torch.tensor(
  322. + [0, *torch.cumsum(torch.tensor(seq_lens), 0).tolist()],
  323. + device=device,
  324. + dtype=torch.int32,
  325. + )
  326. + seq_lens_t = cu[1:] - cu[:-1]
  327. + return q, k, v, cu, seq_lens_t, max(seq_lens)
  328. +
  329. +
  330. +def rand_sinks(num_heads, scale=3.0, seed=1234):
  331. + gen = torch.Generator(device=get_device())
  332. + gen.manual_seed(seed)
  333. + return torch.randn(num_heads, generator=gen, device=get_device()) * scale
  334. +
  335. +
  336. +requires_cuda = unittest.skipIf(
  337. + not torch.cuda.is_available(), "requires an NVIDIA/ROCm GPU"
  338. +)
  339. +
  340. +
  341. +class TestTritonAttentionWindowSink(CustomTestCase):
  342. + """window_size / sinks semantics of the varlen prefill kernel."""
  343. +
  344. + # ------------------------------------------------------------------
  345. + # 1. Oracle calibration
  346. + # ------------------------------------------------------------------
  347. + def test_reference_matches_hand_computed_example(self):
  348. + """Exact hand-computed case: 1 head, 3 tokens, D=2, sm_scale=1.
  349. +
  350. + Query 0 produces logits [1, 2, 3] against keys 0..2; sink logit = 3;
  351. + values v0=[1,0], v1=[0,1], v2=[1,1]. Only row 0 is asserted.
  352. + """
  353. + q = torch.zeros(3, 1, 2)
  354. + k = torch.zeros(3, 1, 2)
  355. + v = torch.tensor([1.0, 0.0, 0.0, 1.0, 1.0, 1.0]).view(3, 1, 2)
  356. + q[0, 0, 0] = 1.0
  357. + k[0, 0, 0] = 1.0
  358. + k[1, 0, 0] = 2.0
  359. + k[2, 0, 0] = 3.0
  360. + cu = [0, 3]
  361. + sink = torch.tensor([3.0])
  362. +
  363. + # anchor: elementary softmax([1,2,3]) = [0.090031, 0.244728, 0.665241]
  364. + torch.testing.assert_close(
  365. + torch.softmax(torch.tensor([1.0, 2.0, 3.0]), -1),
  366. + torch.tensor([0.090031, 0.244728, 0.665241]),
  367. + atol=1e-5,
  368. + rtol=1e-5,
  369. + )
  370. +
  371. + # full attention, no sink: softmax over [1, 2, 3]
  372. + out = ref_varlen_attention(q, k, v, cu, 1.0)
  373. + torch.testing.assert_close(
  374. + out[0, 0], torch.tensor([0.755272, 0.909969]), atol=1e-5, rtol=1e-5
  375. + )
  376. +
  377. + # sink only: softmax over [1, 2, 3, sink=3]; real weights diluted
  378. + out = ref_varlen_attention(q, k, v, cu, 1.0, sinks=sink)
  379. + torch.testing.assert_close(
  380. + out[0, 0], torch.tensor([0.453551, 0.546449]), atol=2e-5, rtol=2e-5
  381. + )
  382. +
  383. + # window (1, 1) + sink: query 0 attends keys {0, 1} plus the sink
  384. + out = ref_varlen_attention(q, k, v, cu, 1.0, window_size=(1, 1), sinks=sink)
  385. + torch.testing.assert_close(
  386. + out[0, 0], torch.tensor([0.090031, 0.244728]), atol=1e-5, rtol=1e-5
  387. + )
  388. +
  389. + # window (0, 0) + sink: query 0 attends only itself + sink,
  390. + # i.e. softmax over [1, sink=3] -> w = 1/(1+e^2) = 0.119203
  391. + out = ref_varlen_attention(q, k, v, cu, 1.0, window_size=(0, 0), sinks=sink)
  392. + torch.testing.assert_close(
  393. + out[0, 0], torch.tensor([0.119203, 0.0]), atol=1e-5, rtol=1e-5
  394. + )
  395. +
  396. + @requires_cuda
  397. + def test_kernel_matches_reference_full_attention(self):
  398. + """Calibrate the oracle against the kernel on the subset it always
  399. + computed correctly: no window, no sink (ragged varlen batch)."""
  400. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([64, 37], seed=0)
  401. + scale = 1.0 / math.sqrt(q.shape[-1])
  402. + o = torch.empty_like(q)
  403. + context_attention_fwd(
  404. + q, k, v, o, cu, seq_lens, max_len, is_causal=False, sm_scale=scale
  405. + )
  406. + ref = ref_varlen_attention(q, k, v, cu.tolist(), scale)
  407. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  408. +
  409. + @requires_cuda
  410. + def test_kernel_matches_reference_full_attention_gqa(self):
  411. + """Same calibration with GQA (Hq=8, Hk=4): the kernel maps q-head h to
  412. + kv-head h // kv_group_num, matching repeat_interleave in the reference."""
  413. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs(
  414. + [48, 64, 21], num_heads=8, num_kv_heads=4, seed=7
  415. + )
  416. + scale = 1.0 / math.sqrt(q.shape[-1])
  417. + o = torch.empty_like(q)
  418. + context_attention_fwd(
  419. + q, k, v, o, cu, seq_lens, max_len, is_causal=False, sm_scale=scale
  420. + )
  421. + ref = ref_varlen_attention(q, k, v, cu.tolist(), scale)
  422. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  423. +
  424. + # ------------------------------------------------------------------
  425. + # 2. Kernel: window / sinks semantics
  426. + # ------------------------------------------------------------------
  427. + WINDOW = (16, 16)
  428. +
  429. + @requires_cuda
  430. + def test_kernel_window_only(self):
  431. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([64, 48], seed=1)
  432. + scale = 1.0 / math.sqrt(q.shape[-1])
  433. + o = torch.empty_like(q)
  434. + context_attention_fwd(
  435. + q,
  436. + k,
  437. + v,
  438. + o,
  439. + cu,
  440. + seq_lens,
  441. + max_len,
  442. + is_causal=False,
  443. + sm_scale=scale,
  444. + window_size=self.WINDOW,
  445. + )
  446. + ref = ref_varlen_attention(q, k, v, cu.tolist(), scale, window_size=self.WINDOW)
  447. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  448. +
  449. + @requires_cuda
  450. + def test_kernel_sinks_only(self):
  451. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([64, 48], seed=2)
  452. + scale = 1.0 / math.sqrt(q.shape[-1])
  453. + sinks = rand_sinks(q.shape[1])
  454. + o = torch.empty_like(q)
  455. + context_attention_fwd(
  456. + q,
  457. + k,
  458. + v,
  459. + o,
  460. + cu,
  461. + seq_lens,
  462. + max_len,
  463. + is_causal=False,
  464. + sm_scale=scale,
  465. + sinks=sinks,
  466. + )
  467. + ref = ref_varlen_attention(q, k, v, cu.tolist(), scale, sinks=sinks)
  468. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  469. +
  470. + @requires_cuda
  471. + def test_kernel_window_and_sinks(self):
  472. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([64, 48], seed=3)
  473. + scale = 1.0 / math.sqrt(q.shape[-1])
  474. + sinks = rand_sinks(q.shape[1])
  475. + o = torch.empty_like(q)
  476. + context_attention_fwd(
  477. + q,
  478. + k,
  479. + v,
  480. + o,
  481. + cu,
  482. + seq_lens,
  483. + max_len,
  484. + is_causal=False,
  485. + sm_scale=scale,
  486. + window_size=self.WINDOW,
  487. + sinks=sinks,
  488. + )
  489. + ref = ref_varlen_attention(
  490. + q, k, v, cu.tolist(), scale, window_size=self.WINDOW, sinks=sinks
  491. + )
  492. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  493. +
  494. + @requires_cuda
  495. + def test_kernel_window_and_sinks_varlen_gqa(self):
  496. + """Ragged varlen + GQA + window + sinks all at once (no cross-sequence
  497. + leakage, correct head grouping, correct mask and denominator)."""
  498. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs(
  499. + [91, 37, 64], num_heads=8, num_kv_heads=4, seed=4
  500. + )
  501. + scale = 1.0 / math.sqrt(q.shape[-1])
  502. + sinks = rand_sinks(q.shape[1], seed=5678)
  503. + o = torch.empty_like(q)
  504. + context_attention_fwd(
  505. + q,
  506. + k,
  507. + v,
  508. + o,
  509. + cu,
  510. + seq_lens,
  511. + max_len,
  512. + is_causal=False,
  513. + sm_scale=scale,
  514. + window_size=self.WINDOW,
  515. + sinks=sinks,
  516. + )
  517. + ref = ref_varlen_attention(
  518. + q, k, v, cu.tolist(), scale, window_size=self.WINDOW, sinks=sinks
  519. + )
  520. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  521. +
  522. + @requires_cuda
  523. + def test_kernel_window_long_seq_multi_block(self):
  524. + """Sequences longer than one KV block with small windows: many query
  525. + rows have fully-masked KV blocks on the way to their window (the NaN
  526. + trap for naive online-softmax windowing: exp(-inf - (-inf)) poisons
  527. + every later block unless the running max is kept finite and the
  528. + l == 0 rescale is guarded).
  529. +
  530. + Also covers one-sided and asymmetric windows (-1 = unbounded side).
  531. + """
  532. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([300, 257, 385], seed=11)
  533. + scale = 1.0 / math.sqrt(q.shape[-1])
  534. + sinks = rand_sinks(q.shape[1], seed=11)
  535. + for window in [(1, 1), (8, 8), (0, 64), (64, -1), (-1, 8)]:
  536. + with self.subTest(window=window):
  537. + o = torch.empty_like(q)
  538. + context_attention_fwd(
  539. + q,
  540. + k,
  541. + v,
  542. + o,
  543. + cu,
  544. + seq_lens,
  545. + max_len,
  546. + is_causal=False,
  547. + sm_scale=scale,
  548. + window_size=window,
  549. + sinks=sinks,
  550. + )
  551. + ref = ref_varlen_attention(
  552. + q, k, v, cu.tolist(), scale, window_size=window, sinks=sinks
  553. + )
  554. + self.assertFalse(
  555. + torch.isnan(o.float()).any(), f"NaN leaked with window {window}"
  556. + )
  557. + torch.testing.assert_close(
  558. + o.float(),
  559. + ref.float(),
  560. + atol=ATOL,
  561. + rtol=RTOL,
  562. + msg=f"mismatch for window {window}",
  563. + )
  564. +
  565. + @requires_cuda
  566. + def test_kernel_window_no_sinks_long_seq(self):
  567. + """Same fully-masked-block shape without sinks, i.e. the path where a
  568. + zero denominator can genuinely occur (no sink seeds l = 1)."""
  569. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([300, 257], seed=12)
  570. + scale = 1.0 / math.sqrt(q.shape[-1])
  571. + for window in [(1, 1), (4, 4)]:
  572. + with self.subTest(window=window):
  573. + o = torch.empty_like(q)
  574. + context_attention_fwd(
  575. + q,
  576. + k,
  577. + v,
  578. + o,
  579. + cu,
  580. + seq_lens,
  581. + max_len,
  582. + is_causal=False,
  583. + sm_scale=scale,
  584. + window_size=window,
  585. + )
  586. + ref = ref_varlen_attention(
  587. + q, k, v, cu.tolist(), scale, window_size=window
  588. + )
  589. + self.assertFalse(
  590. + torch.isnan(o.float()).any(), f"NaN leaked with window {window}"
  591. + )
  592. + torch.testing.assert_close(
  593. + o.float(),
  594. + ref.float(),
  595. + atol=ATOL,
  596. + rtol=RTOL,
  597. + msg=f"mismatch for window {window}",
  598. + )
  599. +
  600. + @requires_cuda
  601. + def test_kernel_causal_with_window(self):
  602. + """Causal + window must compose: the window only further restricts the
  603. + causal key range."""
  604. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([128, 200], seed=13)
  605. + scale = 1.0 / math.sqrt(q.shape[-1])
  606. + window = (16, 16)
  607. + o = torch.empty_like(q)
  608. + context_attention_fwd(
  609. + q,
  610. + k,
  611. + v,
  612. + o,
  613. + cu,
  614. + seq_lens,
  615. + max_len,
  616. + is_causal=True,
  617. + sm_scale=scale,
  618. + window_size=window,
  619. + )
  620. + ref = ref_varlen_attention(
  621. + q, k, v, cu.tolist(), scale, window_size=window, causal=True
  622. + )
  623. + torch.testing.assert_close(o.float(), ref.float(), atol=ATOL, rtol=RTOL)
  624. +
  625. + @requires_cuda
  626. + def test_kernel_rejects_mismatched_sinks(self):
  627. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([32], seed=14)
  628. + scale = 1.0 / math.sqrt(q.shape[-1])
  629. + o = torch.empty_like(q)
  630. + with self.assertRaises(ValueError):
  631. + context_attention_fwd(
  632. + q,
  633. + k,
  634. + v,
  635. + o,
  636. + cu,
  637. + seq_lens,
  638. + max_len,
  639. + is_causal=False,
  640. + sm_scale=scale,
  641. + sinks=torch.zeros(q.shape[1] + 1, device=q.device),
  642. + )
  643. +
  644. + # ------------------------------------------------------------------
  645. + # 3. Backend integration: VisionTritonAttention must forward the kwargs
  646. + # ------------------------------------------------------------------
  647. + @requires_cuda
  648. + def test_backend_window_and_sinks_acceptance(self):
  649. + """The issue #37983 reproducer.
  650. +
  651. + VisionAttention always calls its backend with window_size= and s_aux=
  652. + kwargs. A backend must either honor them or fail explicitly -- never
  653. + silently compute full attention without sinks.
  654. + """
  655. + from sglang.srt.layers.attention.vision import VisionTritonAttention
  656. +
  657. + num_heads = 8
  658. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs(
  659. + [64, 64], num_heads=num_heads, seed=5
  660. + )
  661. + scale = 1.0 / math.sqrt(q.shape[-1])
  662. + sinks = rand_sinks(num_heads, seed=4321)
  663. + window = (16, 16)
  664. +
  665. + backend = VisionTritonAttention(use_data_parallel=True)
  666. + out = backend(
  667. + q,
  668. + k,
  669. + v,
  670. + cu_seqlens=cu,
  671. + bsz=2,
  672. + seq_len=64,
  673. + softmax_scale=scale,
  674. + max_seqlen=max_len, # avoid a GPU->CPU sync in the backend
  675. + window_size=window,
  676. + s_aux=sinks,
  677. + )
  678. + ref = ref_varlen_attention(
  679. + q, k, v, cu.tolist(), scale, window_size=window, sinks=sinks
  680. + )
  681. + torch.testing.assert_close(
  682. + out.float(),
  683. + ref.float(),
  684. + atol=ATOL,
  685. + rtol=RTOL,
  686. + msg="VisionTritonAttention silently dropped window_size/s_aux "
  687. + "(issue #37983): output equals full attention without sinks",
  688. + )
  689. +
  690. + @requires_cuda
  691. + def test_backend_kwargs_change_output(self):
  692. + """Sensitivity detector: changing sinks (or the window) must change the
  693. + output. With the bug, all outputs were bit-identical because the
  694. + kwargs were dropped."""
  695. + from sglang.srt.layers.attention.vision import VisionTritonAttention
  696. +
  697. + num_heads = 8
  698. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs(
  699. + [64, 64], num_heads=num_heads, seed=6
  700. + )
  701. + scale = 1.0 / math.sqrt(q.shape[-1])
  702. + sinks_a = torch.zeros(num_heads, device=q.device)
  703. + sinks_b = torch.full((num_heads,), 3.0, device=q.device)
  704. +
  705. + backend = VisionTritonAttention(use_data_parallel=True)
  706. +
  707. + def run(window, sinks):
  708. + return backend(
  709. + q,
  710. + k,
  711. + v,
  712. + cu_seqlens=cu,
  713. + bsz=2,
  714. + seq_len=64,
  715. + softmax_scale=scale,
  716. + max_seqlen=max_len,
  717. + window_size=window,
  718. + s_aux=sinks,
  719. + )
  720. +
  721. + out_a = run((16, 16), sinks_a)
  722. + out_b = run((16, 16), sinks_b)
  723. + out_c = run((-1, -1), sinks_a)
  724. +
  725. + self.assertFalse(
  726. + torch.allclose(out_a, out_b, atol=1e-3),
  727. + "different s_aux produced identical output: sinks are dropped "
  728. + "(issue #37983)",
  729. + )
  730. + self.assertFalse(
  731. + torch.allclose(out_a, out_c, atol=1e-3),
  732. + "windowed and full attention produced identical output: window_size "
  733. + "is dropped (issue #37983)",
  734. + )
  735. +
  736. + # ------------------------------------------------------------------
  737. + # 4. Optional cross-validation against the FA3 path (Hopper only)
  738. + # ------------------------------------------------------------------
  739. + @unittest.skipIf(
  740. + not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] != 9,
  741. + "FA3 vision backend requires Hopper (sm90)",
  742. + )
  743. + def test_fa3_cross_check_reference(self):
  744. + """Cross-validate the reference against VisionFlash3Attention, which
  745. + provably forwards window_size and s_aux (sinks) to flash-attn."""
  746. + try:
  747. + from sglang.srt.layers.attention.vision import VisionFlash3Attention
  748. + except Exception as e:
  749. + self.skipTest(f"cannot import FA3 backend here: {e}")
  750. +
  751. + q, k, v, cu, seq_lens, max_len = make_varlen_inputs([64, 48], seed=8)
  752. + scale = 1.0 / math.sqrt(q.shape[-1])
  753. + window = (16, 16)
  754. + sinks = rand_sinks(q.shape[1], seed=9)
  755. +
  756. + try:
  757. + backend = VisionFlash3Attention(use_data_parallel=True)
  758. + except Exception as e:
  759. + self.skipTest(f"cannot construct FA3 backend here: {e}")
  760. +
  761. + try:
  762. + out = backend(
  763. + q,
  764. + k,
  765. + v,
  766. + cu_seqlens=cu,
  767. + bsz=2,
  768. + seq_len=64,
  769. + softmax_scale=scale,
  770. + max_seqlen=max_len,
  771. + window_size=window,
  772. + s_aux=sinks,
  773. + )
  774. + except (NotImplementedError, RuntimeError, TypeError) as e:
  775. + self.skipTest(f"FA3 sinks/window unavailable in this build: {e}")
  776. +
  777. + ref = ref_varlen_attention(
  778. + q, k, v, cu.tolist(), scale, window_size=window, sinks=sinks
  779. + )
  780. + torch.testing.assert_close(out.float(), ref.float(), atol=ATOL, rtol=RTOL)
  781. +
  782. +
  783. +if __name__ == "__main__":
  784. + unittest.main()