Fix/whisper xpu varlen encoder decoder (#36298)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Singh <rohitsi2@iil-login.iind.intel.com>
Co-authored-by: Singh <rohitsi2@iil-gnrap02.iind.intel.com>
Co-authored-by: Pramod Kumar <144990617+pramodkumar-habanalabs@users.noreply.github.com>
This commit is contained in:
Rohit Kumar Singh
2026-09-07 13:21:29 +08:00
committed by GitHub
co-authored by github-actions[bot] Singh Singh Pramod Kumar
parent 503e36cbae
commit e24e31efd9
3 changed files with 500 additions and 11 deletions
@@ -0,0 +1,219 @@
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import torch
from sglang.test.ci.ci_register import register_cpu_ci
with patch.dict(
sys.modules,
{
module: MagicMock()
for module in (
"sgl_kernel",
"sgl_kernel.flash_attn",
"sgl_kernel.quantization",
"sgl_kernel.scalar_type",
)
},
):
from sglang.srt.layers.attention import xpu_backend
from sglang.srt.layers.attention.xpu_backend import XPUAttentionBackend
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
class TestEncoderDecoderForward(unittest.TestCase):
"""Encoder-decoder attention on XPU calls flash_attn_with_kvcache with a
page_size=1 view; sgl-kernel-xpu PR #454 gathers the token-slot page_table and
runs varlen inside the kernel. These guard the backend's own responsibilities
-- the cross-vs-self dispatch, the page_size=1 view, the empty-KV zero guard,
and the per-request encoder-offset metadata. Real-kernel numerical correctness
is covered on device in test/registered/xpu/test_xpu_encoder_decoder_varlen.py.
"""
HQ, HK, D, TOTAL_SLOTS = 4, 2, 8, 40
def setUp(self):
torch.manual_seed(0)
self.backend = XPUAttentionBackend.__new__(XPUAttentionBackend)
self.k_flat = torch.randn(self.TOTAL_SLOTS, self.HK, self.D)
self.v_flat = torch.randn(self.TOTAL_SLOTS, self.HK, self.D)
def _layer(self, is_cross):
return SimpleNamespace(
is_cross_attention=is_cross,
tp_q_head_num=self.HQ,
tp_k_head_num=self.HK,
tp_v_head_num=self.HK,
head_dim=self.D,
scaling=0.5,
logit_cap=0.0,
)
def test_dispatch_and_forward_cross_vs_self(self):
# The caller picks (page_table, cache_seqlens, causal) via
# _encoder_decoder_page_table -- cross-attn -> encoder_page_table +
# encoder_lens_int32 + causal=False; self-attn -> page_table +
# cache_seqlens_int32 + causal=True -- then hands them to the generic
# _forward_attn_flat_page_table, which must forward them unchanged with a
# page_size=1 k_cache (shape[1]==1) so PR #454 routes to the varlen gather.
enc_pt = torch.arange(5, dtype=torch.int32).unsqueeze(0)
dec_pt = (torch.arange(4, dtype=torch.int32) + 10).unsqueeze(0)
metadata = SimpleNamespace(
encoder_page_table=enc_pt,
encoder_lens_int32=torch.tensor([5], dtype=torch.int32),
page_table=dec_pt,
cache_seqlens_int32=torch.tensor([4], dtype=torch.int32),
)
key_cache = self.k_flat.view(-1, 1, self.HK, self.D)
value_cache = self.v_flat.view(-1, 1, self.HK, self.D)
q = torch.randn(1, self.HQ * self.D)
cu_seqlens_q = torch.tensor([0, 1], dtype=torch.int32)
for is_cross, exp_pt, exp_seqlens, exp_causal in (
(True, enc_pt, metadata.encoder_lens_int32, False),
(False, dec_pt, metadata.cache_seqlens_int32, True),
):
layer = self._layer(is_cross)
page_table, cache_seqlens, causal = (
self.backend._encoder_decoder_page_table(layer, metadata)
)
self.assertTrue(torch.equal(page_table, exp_pt))
self.assertTrue(torch.equal(cache_seqlens, exp_seqlens))
self.assertEqual(causal, exp_causal)
captured = {}
def fake_kvcache(*_, **kw):
captured.update(kw)
return kw["q"].new_zeros(
(kw["q"].shape[0], kw["q"].shape[1], kw["v_cache"].shape[-1])
)
with patch.object(xpu_backend, "flash_attn_with_kvcache", fake_kvcache):
self.backend._forward_attn_flat_page_table(
q=q,
key_cache=key_cache,
value_cache=value_cache,
layer=layer,
page_table=page_table,
cache_seqlens=cache_seqlens,
cu_seqlens_q=cu_seqlens_q,
max_seqlen_q=1,
causal=causal,
)
self.assertTrue(torch.equal(captured["page_table"], exp_pt))
self.assertTrue(torch.equal(captured["cache_seqlens"], exp_seqlens))
self.assertEqual(captured["causal"], exp_causal)
self.assertEqual(captured["k_cache"].shape[1], 1) # page_size=1 view
self.assertEqual(captured["max_seqlen_q"], 1)
def test_all_empty_returns_zeros_without_kernel(self):
# Whisper text-only warmup: all cache_seqlens == 0. PR #454's page_size=1
# path returns NaN for an empty KV, so the backend must short-circuit to
# zeros and never launch the kernel.
key_cache = self.k_flat.view(-1, 1, self.HK, self.D)
value_cache = self.v_flat.view(-1, 1, self.HK, self.D)
q = torch.randn(1, self.HQ * self.D)
sentinel = MagicMock(side_effect=AssertionError("kernel must not run"))
with patch.object(xpu_backend, "flash_attn_with_kvcache", sentinel):
out = self.backend._forward_attn_flat_page_table(
q=q,
key_cache=key_cache,
value_cache=value_cache,
layer=self._layer(True),
page_table=torch.zeros(1, 0, dtype=torch.int32),
cache_seqlens=torch.zeros(1, dtype=torch.int32),
cu_seqlens_q=torch.tensor([0, 1], dtype=torch.int32),
max_seqlen_q=1,
causal=False,
)
sentinel.assert_not_called()
self.assertTrue(torch.equal(out, torch.zeros(1, self.HQ, self.D)))
def test_mixed_empty_zeros_only_empty_request_rows(self):
# Mixed batch: request 0 has cache_seqlens==0 (no keys), request 1 has
# keys. PR #454 returns NaN for the empty request's rows, so the backend
# must zero exactly those rows and leave the rest untouched. Unequal query
# counts (2 and 3) exercise the cu_seqlens_q -> per-request row mapping.
key_cache = self.k_flat.view(-1, 1, self.HK, self.D)
value_cache = self.v_flat.view(-1, 1, self.HK, self.D)
q = torch.randn(5, self.HQ * self.D)
def fake_kvcache(*_, **kw):
# All-ones (never-NaN) sentinel so zeroed rows are distinguishable.
return kw["q"].new_ones(
(kw["q"].shape[0], kw["q"].shape[1], kw["v_cache"].shape[-1])
)
with patch.object(xpu_backend, "flash_attn_with_kvcache", fake_kvcache):
out = self.backend._forward_attn_flat_page_table(
q=q,
key_cache=key_cache,
value_cache=value_cache,
layer=self._layer(True),
page_table=torch.zeros(2, 4, dtype=torch.int32),
cache_seqlens=torch.tensor([0, 4], dtype=torch.int32),
cu_seqlens_q=torch.tensor([0, 2, 5], dtype=torch.int32),
max_seqlen_q=3,
causal=False,
)
self.assertTrue(torch.equal(out[:2], torch.zeros(2, self.HQ, self.D)))
self.assertTrue(torch.equal(out[2:], torch.ones(3, self.HQ, self.D)))
def test_init_forward_metadata_per_request_encoder_offset(self):
# Guards the encoder_lens.numel()==1 removal: with UNEQUAL encoder lengths,
# init_forward_metadata must place each request's decoder self-attn page
# table at ITS OWN encoder_lens[i] offset (not a single batch max), and
# slice encoder KV per request. It must also skip the //page_size stride
# for enc-dec (token-slot indices feed the varlen kernel). Fails on the old
# scalar-max-offset slice (row0 would start at col 5, not 3).
from sglang.srt.model_executor.forward_batch_info import ForwardMode
backend = XPUAttentionBackend.__new__(XPUAttentionBackend)
backend.page_size = 128 # >1: also exercises the enc-dec stride-skip
backend.is_encoder_decoder = True
backend.use_mla = False
backend.use_sliding_window_kv_pool = False
backend.attention_chunk_size = None
backend.topk = 0
# req_to_token[i, j] = 100*i + j, so gathered values reveal (row, col).
req_to_token = torch.arange(16).unsqueeze(0) + torch.tensor([[0], [100]])
backend.req_to_token_pool = SimpleNamespace(req_to_token=req_to_token)
fb = SimpleNamespace(
forward_mode=ForwardMode.DECODE,
seq_lens=torch.tensor([2, 4], dtype=torch.int64), # decoder lengths
seq_lens_cpu=torch.tensor([2, 4]),
batch_size=2,
req_pool_indices=torch.tensor([0, 1]),
encoder_lens=torch.tensor([3, 5], dtype=torch.int64), # UNEQUAL
spec_info=None,
out_cache_loc=None,
)
backend.init_forward_metadata(fb)
md = backend.forward_metadata
# Encoder KV: columns [0 : max_enc=5] of each request's row; per-request
# lengths + segment boundaries captured for the kernel's internal gather.
self.assertEqual(
md.encoder_page_table.tolist(),
[[0, 1, 2, 3, 4], [100, 101, 102, 103, 104]],
)
self.assertEqual(md.encoder_lens_int32.tolist(), [3, 5])
self.assertEqual(md.encoder_cu_seqlens_k.tolist(), [0, 3, 8])
# Decoder self-attn KV (text_max = max(seq_lens) = 4 columns each): request 0
# starts at col 3 (its encoder_len), request 1 at col 5 (its encoder_len).
# Token-granular (not //128), proving the stride transform was skipped.
self.assertEqual(
md.page_table.tolist(),
[[3, 4, 5, 6], [105, 106, 107, 108]],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,160 @@
"""Real-device XPU tests for the encoder-decoder attention path.
The backend calls flash_attn_with_kvcache with a page_size=1 view; sgl-kernel-xpu
PR #454 detects that and gathers + runs varlen inside the kernel. This runs on an
actual XPU and guards what a mocked CPU test cannot: _forward_attn_flat_page_table
plus the real kernel produce correct attention for a scattered (non-page-aligned)
token-slot layout, for both cross-attn (non-causal) and decoder self-attn (causal).
"""
import unittest
from types import SimpleNamespace
import torch
from sglang.srt.layers.attention.xpu_backend import XPUAttentionBackend
from sglang.test.ci.ci_register import register_xpu_ci
from sglang.test.test_utils import CustomTestCase
register_xpu_ci(est_time=15, suite="stage-b-test-1-gpu-xpu")
def _sdpa_ref(
*, q, k_flat, v_flat, page_table, cache_seqlens, cu_seqlens_q, scale, causal
):
"""Per-request SDPA oracle: gather each request's valid slots straight from
the page_table and attend. fp32 math for a stable bf16 comparison."""
outs = []
for i in range(page_table.shape[0]):
qs, qe = int(cu_seqlens_q[i]), int(cu_seqlens_q[i + 1])
sl = int(cache_seqlens[i])
q_i = q[qs:qe].float()
slots = page_table[i, :sl].long()
k_i = k_flat.index_select(0, slots).float()
v_i = v_flat.index_select(0, slots).float()
scores = torch.einsum("qhd,khd->hqk", q_i, k_i) * scale
lq, lk = qe - qs, sl
if causal and lq > 0 and lk > 0:
row = torch.arange(lq, device=q.device).unsqueeze(1)
col = torch.arange(lk, device=q.device).unsqueeze(0)
keep = col <= (lk - lq + row)
scores = scores.masked_fill(~keep.unsqueeze(0), float("-inf"))
probs = scores.softmax(dim=-1)
outs.append(torch.einsum("hqk,khd->qhd", probs, v_i))
return torch.cat(outs, dim=0)
@unittest.skipUnless(
hasattr(torch, "xpu") and torch.xpu.is_available(), "requires an Intel XPU"
)
class TestXPUEncoderDecoderVarlen(CustomTestCase):
# Whisper-large-v3 is MHA (num_kv_heads == num_heads), so use MHA here to keep
# the reference exact; head_dim=64 satisfies the kernel's alignment.
H, D, TOTAL_SLOTS = 8, 64, 64
def setUp(self):
torch.manual_seed(0)
self.dev = torch.device("xpu")
self.backend = XPUAttentionBackend.__new__(XPUAttentionBackend)
self.backend.is_encoder_decoder = True
# Deliberately scattered (non-page-aligned) slot indices: a paged kernel
# would mis-read these; the page_size=1 gather must be alignment-agnostic.
perm = torch.randperm(self.TOTAL_SLOTS)
self.k_flat = torch.randn(
self.TOTAL_SLOTS, self.H, self.D, dtype=torch.bfloat16, device=self.dev
)[perm].contiguous()
self.v_flat = torch.randn(
self.TOTAL_SLOTS, self.H, self.D, dtype=torch.bfloat16, device=self.dev
)[perm].contiguous()
def _check(self, *, cache_seqlens, cu_seqlens_q, causal):
cache_seqlens = cache_seqlens.to(self.dev)
cu_seqlens_q = cu_seqlens_q.to(self.dev)
num_rows = int(cu_seqlens_q[-1])
m = int(cache_seqlens.max())
# Rows packed valid-first; scatter distinct slots per request (build the
# permutation on CPU, then move -- randperm(device="xpu") is unreliable).
page_table = (
torch.stack(
[
torch.randperm(self.TOTAL_SLOTS)[:m]
for _ in range(cache_seqlens.numel())
]
)
.to(torch.int32)
.to(self.dev)
)
q = torch.randn(num_rows, self.H, self.D, dtype=torch.bfloat16, device=self.dev)
layer = SimpleNamespace(
is_cross_attention=not causal,
tp_q_head_num=self.H,
tp_k_head_num=self.H,
tp_v_head_num=self.H,
head_dim=self.D,
scaling=0.5,
logit_cap=0.0,
)
key_cache = self.k_flat.view(-1, 1, self.H, self.D)
value_cache = self.v_flat.view(-1, 1, self.H, self.D)
# causal=True mirrors decoder self-attn, causal=False cross-attn; the
# generic helper takes the (page_table, cache_seqlens, causal) that the
# caller's _encoder_decoder_page_table dispatch would have selected.
got = self.backend._forward_attn_flat_page_table(
q=q,
key_cache=key_cache,
value_cache=value_cache,
layer=layer,
page_table=page_table,
cache_seqlens=cache_seqlens,
cu_seqlens_q=cu_seqlens_q,
max_seqlen_q=1,
causal=causal,
)
torch.xpu.synchronize()
want = _sdpa_ref(
q=q,
k_flat=self.k_flat,
v_flat=self.v_flat,
page_table=page_table,
cache_seqlens=cache_seqlens,
cu_seqlens_q=cu_seqlens_q,
scale=layer.scaling,
causal=causal,
)
self.assertEqual(tuple(got.shape), (num_rows, self.H, self.D))
self.assertTrue(torch.isfinite(got).all(), "attention output must be finite")
# bf16 kernel vs fp32 reference: loose tolerance.
torch.testing.assert_close(got.float(), want, rtol=2e-2, atol=2e-2)
def test_cross_attention_decode_on_xpu(self):
# 1 query/request, attend all encoder KV, non-causal, unequal lengths.
self._check(
cache_seqlens=torch.tensor([5, 8], dtype=torch.int32),
cu_seqlens_q=torch.tensor([0, 1, 2], dtype=torch.int32),
causal=False,
)
def test_decoder_self_attention_decode_on_xpu(self):
self._check(
cache_seqlens=torch.tensor([4, 6], dtype=torch.int32),
cu_seqlens_q=torch.tensor([0, 1, 2], dtype=torch.int32),
causal=True,
)
def test_mixed_empty_batch_on_xpu(self):
# Mixed batch: request 0 has no keys (cache_seqlens==0), request 1 has some.
# On the real kernel the empty request's rows come back NaN/inf, so the
# backend must zero them without corrupting request 1. The SDPA oracle
# yields zeros for the empty request (empty-key contraction), so the shared
# assert_close plus the finiteness check guard against a regression that
# drops the zeroing and leaks NaN into the output.
self._check(
cache_seqlens=torch.tensor([0, 6], dtype=torch.int32),
cu_seqlens_q=torch.tensor([0, 1, 2], dtype=torch.int32),
causal=False,
)
if __name__ == "__main__":
unittest.main()