| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """XPU sparse decode for DeepSeek V4 with FP8 KV cache. |
| 4 | |
| 5 | Strategy: dequantize FP8 UE8M0 KV cache pages to BF16 on the fly, |
| 6 | then reuse the BF16 sparse MLA attention kernel (xpu_sparse_mla_bf16). |
| 7 | This keeps the external KV cache layout identical to CUDA/ROCm. |
| 8 | """ |
| 9 | |
| 10 | import torch |
| 11 | |
| 12 | from vllm.triton_utils import tl, triton |
| 13 | from vllm.v1.attention.ops.xpu_mla_sparse import ( |
| 14 | triton_bf16_mla_sparse_interface, |
| 15 | ) |
| 16 | |
| 17 | # FP8 DS MLA cache layout constants |
| 18 | TOKEN_FP8_DIM = 448 # NoPE portion in FP8 |
| 19 | TOKEN_BF16_DIM = 64 # RoPE portion in BF16 |
| 20 | TOKEN_SCALE_DIM = 8 # UE8M0 scales per token |
| 21 | QUANT_BLOCK_SIZE = 64 # Elements per quant block |
| 22 | OUTPUT_DIM = 512 # = TOKEN_FP8_DIM + TOKEN_BF16_DIM after dequant |
| 23 | TOKEN_DATA_SIZE = TOKEN_FP8_DIM + TOKEN_BF16_DIM * 2 # 576 bytes per token |
| 24 | |
| 25 | |
| 26 | @triton.jit |
| 27 | def _dequant_gather_slots_kernel( |
| 28 | # Output workspace: [total_slots, OUTPUT_DIM] bf16 |
| 29 | out_ptr, |
| 30 | # FP8 paged cache base pointer (uint8 flat) |
| 31 | cache_ptr, |
| 32 | # Global slot indices: [total_slots] int32 |
| 33 | indices_ptr, |
| 34 | # Cache geometry |
| 35 | cache_block_size: tl.constexpr, |
| 36 | token_data_size: tl.constexpr, # 576 |
| 37 | block_stride: tl.int64, # k_cache.stride(0) — total uint8 per block |
| 38 | fp8_dim: tl.constexpr, # 448 |
| 39 | bf16_dim: tl.constexpr, # 64 |
| 40 | scale_dim: tl.constexpr, # 8 |
| 41 | quant_block: tl.constexpr, # 64 |
| 42 | output_dim: tl.constexpr, # 512 |
| 43 | n_quant_blocks: tl.constexpr, # 7 |
| 44 | ): |
| 45 | """Dequantize scattered FP8 slots into a flat BF16 workspace. |
| 46 | |
| 47 | Grid: [total_slots] — one program per slot to gather. |
| 48 | |
| 49 | Cache block layout (block_size tokens): |
| 50 | [0, block_size*576): Token data, each token 448 FP8 + 128 BF16 |
| 51 | [block_size*576, block_size*576 + block_size*8): Scales |
| 52 | """ |
| 53 | pid = tl.program_id(0) |
| 54 | |
| 55 | # Load global slot index |
| 56 | slot_idx = tl.load(indices_ptr + pid).to(tl.int64) |
| 57 | |
| 58 | # Output pointer for this slot |
| 59 | out_row_ptr = out_ptr + pid * output_dim |
| 60 | |
| 61 | # Handle invalid slots (index < 0): write zeros |
| 62 | if slot_idx < 0: |
| 63 | zero = tl.zeros([quant_block], dtype=tl.bfloat16) |
| 64 | for i in tl.static_range(0, 512, 64): |
| 65 | offsets = i + tl.arange(0, quant_block) |
| 66 | mask = offsets < output_dim |
| 67 | tl.store(out_row_ptr + offsets, zero, mask=mask) |
| 68 | return |
| 69 | |
| 70 | # Compute block and position within block |
| 71 | block_idx = slot_idx // cache_block_size |
| 72 | pos_in_block = slot_idx % cache_block_size |
| 73 | |
| 74 | # Block base pointer |
| 75 | block_base = cache_ptr + block_idx * block_stride |
| 76 | |
| 77 | # Token data: at offset pos_in_block * token_data_size within block |
| 78 | token_data_ptr = block_base + pos_in_block * token_data_size |
| 79 | |
| 80 | # Scale: after all token data, at offset |
| 81 | # cache_block_size * token_data_size + pos_in_block * scale_dim |
| 82 | scale_region_offset = tl.cast(cache_block_size, tl.int64) * token_data_size |
| 83 | token_scale_ptr = block_base + scale_region_offset + pos_in_block * scale_dim |
| 84 | |
| 85 | # ========== Dequantize FP8 portion (448 elements) ========== |
| 86 | for qblock_idx in tl.static_range(n_quant_blocks): |
| 87 | qblock_start = qblock_idx * quant_block |
| 88 | offsets = qblock_start + tl.arange(0, quant_block) |
| 89 | mask = offsets < fp8_dim |
| 90 | |
| 91 | # Load FP8 as uint8 and bitcast |
| 92 | x_uint8 = tl.load(token_data_ptr + offsets, mask=mask, other=0) |
| 93 | x_fp8 = x_uint8.to(tl.float8e4nv, bitcast=True) |
| 94 | x_float = x_fp8.to(tl.float32) |
| 95 | |
| 96 | # Load UE8M0 scale: scale = 2^(stored_value - 127) |
| 97 | encoded_scale = tl.load(token_scale_ptr + qblock_idx) |
| 98 | exponent = encoded_scale.to(tl.float32) - 127.0 |
| 99 | scale = tl.exp2(exponent) |
| 100 | |
| 101 | # Dequantize and store as bf16 |
| 102 | x_dequant = x_float * scale |
| 103 | tl.store(out_row_ptr + offsets, x_dequant.to(tl.bfloat16), mask=mask) |
| 104 | |
| 105 | # ========== Copy BF16 portion (64 elements) directly ========== |
| 106 | bf16_src_ptr = (token_data_ptr + fp8_dim).to(tl.pointer_type(tl.bfloat16)) |
| 107 | bf16_out_ptr = (out_row_ptr + fp8_dim).to(tl.pointer_type(tl.bfloat16)) |
| 108 | |
| 109 | for j in tl.static_range(bf16_dim // 16): |
| 110 | chunk_offsets = j * 16 + tl.arange(0, 16) |
| 111 | bf16_vals = tl.load(bf16_src_ptr + chunk_offsets) |
| 112 | tl.store(bf16_out_ptr + chunk_offsets, bf16_vals) |
| 113 | |
| 114 | |
| 115 | def dequant_gather_slots( |
| 116 | out: torch.Tensor, # [total_slots, 512] bf16, pre-allocated |
| 117 | cache: torch.Tensor, # [num_blocks, block_size, head_bytes] uint8 |
| 118 | indices: torch.Tensor, # [total_slots] int32, global slot IDs |
| 119 | cache_block_size: int, # block_size for this cache |
| 120 | ) -> None: |
| 121 | """Dequantize FP8 UE8M0 pages at scattered slot indices into bf16.""" |
| 122 | total_slots = indices.shape[0] |
| 123 | if total_slots == 0: |
| 124 | return |
| 125 | |
| 126 | block_stride = cache.stride(0) |
| 127 | |
| 128 | _dequant_gather_slots_kernel[(total_slots,)]( |
| 129 | out, |
| 130 | cache, |
| 131 | indices, |
| 132 | cache_block_size=cache_block_size, |
| 133 | token_data_size=TOKEN_DATA_SIZE, |
| 134 | block_stride=block_stride, |
| 135 | fp8_dim=TOKEN_FP8_DIM, |
| 136 | bf16_dim=TOKEN_BF16_DIM, |
| 137 | scale_dim=TOKEN_SCALE_DIM, |
| 138 | quant_block=QUANT_BLOCK_SIZE, |
| 139 | output_dim=OUTPUT_DIM, |
| 140 | n_quant_blocks=7, |
| 141 | ) |
| 142 | |
| 143 | |
| 144 | def xpu_sparse_decode_fp8( |
| 145 | q: torch.Tensor, # [num_tokens, num_heads, head_dim] |
| 146 | kv_cache: torch.Tensor | None, # [num_blocks, block_size, head_bytes] uint8 |
| 147 | swa_kv_cache: torch.Tensor, # [num_blocks, swa_block_size, head_bytes] uint8 |
| 148 | swa_only: bool, |
| 149 | topk_indices: torch.Tensor | None, # [num_tokens, 1, topk] global slot IDs |
| 150 | topk_lens: torch.Tensor | None, |
| 151 | swa_indices: torch.Tensor, # [num_tokens, 1, swa_k] global slot IDs |
| 152 | swa_lens: torch.Tensor, |
| 153 | attn_sink: torch.Tensor, |
| 154 | softmax_scale: float, |
| 155 | head_dim: int, |
| 156 | nope_head_dim: int, |
| 157 | rope_head_dim: int, |
| 158 | out: torch.Tensor, # [num_tokens, num_heads, head_dim] |
| 159 | ) -> None: |
| 160 | """XPU decode: dequant FP8 pages to BF16, then BF16 sparse MLA attention. |
| 161 | |
| 162 | Keeps external FP8 KV cache layout identical to CUDA/ROCm. |
| 163 | Performance is slower due to on-the-fly dequant, but correctness is |
| 164 | guaranteed by reusing the validated BF16 attention kernel. |
| 165 | """ |
| 166 | num_tokens = q.shape[0] |
| 167 | device = q.device |
| 168 | |
| 169 | # Determine max topk and swa widths |
| 170 | if not swa_only and topk_indices is not None: |
| 171 | topk_idx_2d = ( |
| 172 | topk_indices.squeeze(1) if topk_indices.dim() == 3 else topk_indices |
| 173 | ) |
| 174 | max_topk = topk_idx_2d.shape[1] |
| 175 | else: |
| 176 | topk_idx_2d = None |
| 177 | max_topk = 0 |
| 178 | |
| 179 | swa_idx_2d = swa_indices.squeeze(1) if swa_indices.dim() == 3 else swa_indices |
| 180 | max_swa = swa_idx_2d.shape[1] |
| 181 | |
| 182 | K_total = max_topk + max_swa |
| 183 | |
| 184 | # Allocate flat workspace: [num_tokens * K_total, 512] bf16 |
| 185 | workspace = torch.empty( |
| 186 | (num_tokens * K_total, OUTPUT_DIM), dtype=torch.bfloat16, device=device |
| 187 | ) |
| 188 | ws_3d = workspace.view(num_tokens, K_total, OUTPUT_DIM) |
| 189 | |
| 190 | # Dequant+gather topk slots from compressed cache |
| 191 | if not swa_only and topk_idx_2d is not None and kv_cache is not None: |
| 192 | topk_flat = topk_idx_2d.reshape(-1).to(torch.int32) |
| 193 | topk_buf = torch.empty( |
| 194 | (num_tokens * max_topk, OUTPUT_DIM), dtype=torch.bfloat16, device=device |
| 195 | ) |
| 196 | compressed_block_size = kv_cache.shape[1] |
| 197 | dequant_gather_slots(topk_buf, kv_cache, topk_flat, compressed_block_size) |
| 198 | ws_3d[:, :max_topk, :] = topk_buf.view(num_tokens, max_topk, OUTPUT_DIM) |
| 199 | |
| 200 | # Dequant+gather SWA slots |
| 201 | swa_flat = swa_idx_2d.reshape(-1).to(torch.int32) |
| 202 | swa_buf = torch.empty( |
| 203 | (num_tokens * max_swa, OUTPUT_DIM), dtype=torch.bfloat16, device=device |
| 204 | ) |
| 205 | swa_block_size = swa_kv_cache.shape[1] |
| 206 | dequant_gather_slots(swa_buf, swa_kv_cache, swa_flat, swa_block_size) |
| 207 | |
| 208 | ws_3d[:, max_topk:, :] = swa_buf.view(num_tokens, max_swa, OUTPUT_DIM) |
| 209 | |
| 210 | # Build combined indices into the flat workspace and combined lengths. |
| 211 | # Workspace layout per token t: [topk_0..topk_{max_topk-1}, swa_0..swa_{max_swa-1}] |
| 212 | # Flat index for token t, position p = t * K_total + p |
| 213 | # |
| 214 | # IMPORTANT: The attention kernel uses combined_lens as a position cutoff — |
| 215 | # it only reads indices[0..combined_lens-1]. So indices must be PACKED |
| 216 | # contiguously: [valid_topk_indices..., valid_swa_indices..., -1 padding...] |
| 217 | if not swa_only and topk_lens is not None: |
| 218 | combined_lens = (topk_lens + swa_lens).to(torch.int32) |
| 219 | else: |
| 220 | combined_lens = swa_lens.to(torch.int32) |
| 221 | |
| 222 | max_combined = int(combined_lens.max().item()) if combined_lens.numel() > 0 else 0 |
| 223 | # Round up to BLOCK_N=16 alignment for kernel efficiency |
| 224 | _BLOCK_N = 16 |
| 225 | max_combined_padded = ((max_combined + _BLOCK_N - 1) // _BLOCK_N) * _BLOCK_N |
| 226 | |
| 227 | # Build packed index table: [num_tokens, max_combined_padded] |
| 228 | # Each token t: [topk_0..topk_{tlen-1}, swa_0..swa_{slen-1}, -1 padding] |
| 229 | # Vectorized: for each token, topk indices are t*K_total + 0..tlen-1, |
| 230 | # swa indices are t*K_total + max_topk + 0..slen-1 |
| 231 | combined_indices = torch.full( |
| 232 | (num_tokens, max_combined_padded), |
| 233 | fill_value=-1, |
| 234 | dtype=torch.int32, |
| 235 | device=device, |
| 236 | ) |
| 237 | |
| 238 | token_offsets = ( |
| 239 | torch.arange(num_tokens, device=device, dtype=torch.int32) * K_total |
| 240 | ) # [B] |
| 241 | |
| 242 | if not swa_only and topk_lens is not None: |
| 243 | # Pack topk: for each token, write t*K_total + 0..tlen-1 at positions 0..tlen-1 |
| 244 | max_tlen = int(topk_lens.max().item()) |
| 245 | topk_range = torch.arange(max_tlen, device=device, dtype=torch.int32).unsqueeze( |
| 246 | 0 |
| 247 | ) |
| 248 | topk_valid = topk_range < topk_lens.unsqueeze(1) |
| 249 | topk_ws_indices = token_offsets.unsqueeze(1) + topk_range |
| 250 | combined_indices[:, :max_tlen] = torch.where( |
| 251 | topk_valid, |
| 252 | topk_ws_indices, |
| 253 | torch.tensor(-1, dtype=torch.int32, device=device), |
| 254 | ) |
| 255 | # Pack swa after topk: positions tlen..tlen+slen-1 |
| 256 | # Since tlen varies per token, we need per-token offset |
| 257 | swa_range = torch.arange(max_swa, device=device, dtype=torch.int32).unsqueeze(0) |
| 258 | swa_valid = swa_range < swa_lens.unsqueeze(1) |
| 259 | swa_ws_indices = token_offsets.unsqueeze(1) + max_topk + swa_range |
| 260 | # Write at position topk_lens[t] + swa_pos for each token |
| 261 | for t_idx in range(num_tokens): |
| 262 | tlen = int(topk_lens[t_idx].item()) |
| 263 | slen = int(swa_lens[t_idx].item()) |
| 264 | combined_indices[t_idx, tlen : tlen + slen] = swa_ws_indices[t_idx, :slen] |
| 265 | else: |
| 266 | # SWA-only: pack swa indices at positions 0..slen-1 |
| 267 | # Use min(max_swa, max_combined_padded) because combined_indices only |
| 268 | # has max_combined_padded columns, and all valid entries fit within it. |
| 269 | effective_swa = min(max_swa, max_combined_padded) |
| 270 | swa_range = torch.arange( |
| 271 | effective_swa, device=device, dtype=torch.int32 |
| 272 | ).unsqueeze(0) |
| 273 | swa_valid = swa_range < swa_lens.unsqueeze(1) |
| 274 | swa_ws_indices = token_offsets.unsqueeze(1) + swa_range # max_topk=0 |
| 275 | combined_indices[:, :effective_swa] = torch.where( |
| 276 | swa_valid, |
| 277 | swa_ws_indices, |
| 278 | torch.tensor(-1, dtype=torch.int32, device=device), |
| 279 | ) |
| 280 | |
| 281 | # Call BF16 sparse MLA kernel |
| 282 | out_attn, _, _ = triton_bf16_mla_sparse_interface( |
| 283 | q=q, |
| 284 | kv=workspace.unsqueeze(1), |
| 285 | indices=combined_indices.unsqueeze(1), |
| 286 | sm_scale=softmax_scale, |
| 287 | d_v=q.shape[-1], |
| 288 | block_dpe=0, |
| 289 | ) |
| 290 | out.copy_(out_attn) |