main
py 1,494 lines 53.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """CPU-kernel-vs-eager-reference parity tests for the DeepSeek-V4 CPU port's
4 sgl-kernels ops (csrc/cpu/sgl-kernels/). Each test carries its own reference
5 reimplementation of the kernel's math rather than depending on production
6 model code.
7 """
8
9 from unittest import mock
10
11 import pytest
12 import torch
13
14 import vllm.model_executor.kernels.mhc as mhc_kernels
15 from tests.kernels.test_fused_deepseek_v4_qnorm_rope_kv_insert import (
16 HEAD_DIM,
17 NOPE_DIM,
18 QUANT_BLOCK,
19 ROPE_DIM,
20 make_cos_sin_cache,
21 )
22 from tests.kernels.utils import bf16_ulp_distance, fp8_ulp_distance
23 from vllm._custom_ops import (
24 compress_norm_rope_store_cpu,
25 compress_norm_rope_store_indexer_cpu,
26 flash_mla_with_kvcache_cpu,
27 fp8_paged_mqa_logits_cpu,
28 fused_indexer_q_rope_quant_cpu,
29 fused_qnorm_rope_kv_insert_cpu,
30 inverse_gptj_rope_o_proj_cpu,
31 save_partial_states_cpu,
32 )
33 from vllm.models.deepseek_v4.common.ops import fused_indexer_q_rope_quant
34 from vllm.platforms import current_platform
35 from vllm.utils.torch_utils import set_random_seed
36
37 DEVICE = current_platform.device_type
38
39 # Main attention cache (fp8_ds_mla): NoPE fp8 in QUANT_BLOCK-wide UE8M0 blocks,
40 # RoPE bf16, one scale byte per block (+1 pad byte) per token.
41 SCALE_BYTES_PER_TOKEN = NOPE_DIM // QUANT_BLOCK + 1
42 TOKEN_DATA_BYTES = NOPE_DIM + ROPE_DIM * 2
43 FP8_MAX = 448.0
44
45 # Indexer K-cache: all 128 dims in one fp8 block, one raw fp32 scale per token.
46 INDEXER_HEAD_DIM = 128
47 INDEXER_NOPE_DIM = INDEXER_HEAD_DIM - ROPE_DIM
48 INDEXER_SCALE_BYTES = 4
49
50
51 def _op_available(*names: str) -> bool:
52 return all(hasattr(torch.ops._C, name) for name in names)
53
54
55 pytestmark = pytest.mark.skipif(
56 not current_platform.is_cpu()
57 or not _op_available(
58 "save_partial_states_cpu",
59 "compress_norm_rope_store_cpu",
60 "compress_norm_rope_store_indexer_cpu",
61 "flash_mla_with_kvcache_cpu",
62 "fused_qnorm_rope_kv_insert_cpu",
63 "inverse_gptj_rope_o_proj_cpu",
64 "fp8_paged_mqa_logits_cpu",
65 "fused_indexer_q_rope_quant_cpu",
66 ),
67 reason="CPU not available or DeepSeek-V4 CPU kernels not built in",
68 )
69
70
71 def _apply_gptj_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):
72 x_even = x[..., 0::2]
73 x_odd = x[..., 1::2]
74 out_even = x_even * cos - x_odd * sin
75 out_odd = x_even * sin + x_odd * cos
76 return torch.stack((out_even, out_odd), dim=-1).flatten(-2)
77
78
79 def _make_cache(num_blocks: int, block_size: int) -> torch.Tensor:
80 block_bytes = block_size * TOKEN_DATA_BYTES + block_size * SCALE_BYTES_PER_TOKEN
81 return torch.zeros(num_blocks, block_bytes, dtype=torch.uint8)
82
83
84 def _decode_token_regions(
85 cache: torch.Tensor, slot_mapping: torch.Tensor, block_size: int
86 ):
87 """Slice out, per valid token, the raw NoPE-fp8 / RoPE-bf16 / scale byte
88 regions this layout defines."""
89 valid = slot_mapping >= 0
90 idx = valid.nonzero(as_tuple=True)[0]
91 slots = slot_mapping[idx]
92 block_idx = torch.div(slots, block_size, rounding_mode="floor")
93 pos_in_block = slots % block_size
94
95 nope, rope, scale = [], [], []
96 for b, p in zip(block_idx.tolist(), pos_in_block.tolist()):
97 token_off = p * TOKEN_DATA_BYTES
98 nope.append(cache[b, token_off : token_off + NOPE_DIM])
99 rope.append(cache[b, token_off + NOPE_DIM : token_off + TOKEN_DATA_BYTES])
100 scale_off = block_size * TOKEN_DATA_BYTES + p * SCALE_BYTES_PER_TOKEN
101 scale.append(cache[b, scale_off : scale_off + SCALE_BYTES_PER_TOKEN - 1])
102
103 if not nope:
104 empty = lambda n: torch.empty(0, n, dtype=torch.uint8)
105 return empty(NOPE_DIM), empty(ROPE_DIM * 2), empty(SCALE_BYTES_PER_TOKEN - 1)
106 return torch.stack(nope), torch.stack(rope), torch.stack(scale)
107
108
109 def _assert_cache_parity(cache_fused, cache_ref, slot_mapping, block_size):
110 """NoPE fp8 + the UE8M0 scale byte are a deterministic quant of an
111 un-rotated bf16 value, so they match tightly; the RoPE region is rotated
112 in fp32 before its bf16 store and eager/kernel can round a tie to
113 opposite sides, so allow <=1 ULP there."""
114 nope_ref, rope_ref, scale_ref = _decode_token_regions(
115 cache_ref, slot_mapping, block_size
116 )
117 nope_fused, rope_fused, scale_fused = _decode_token_regions(
118 cache_fused, slot_mapping, block_size
119 )
120 if nope_ref.numel() == 0:
121 return
122
123 max_ulp = int(fp8_ulp_distance(nope_fused, nope_ref).max().item())
124 assert max_ulp <= 1, f"NoPE fp8 differs by {max_ulp} ULP (>1)"
125
126 rope_ref_bf16 = rope_ref.contiguous().view(torch.bfloat16)
127 rope_fused_bf16 = rope_fused.contiguous().view(torch.bfloat16)
128 max_ulp_rope = int(bf16_ulp_distance(rope_fused_bf16, rope_ref_bf16).max().item())
129 assert max_ulp_rope <= 1, f"RoPE bf16 differs by {max_ulp_rope} ULP (>1)"
130
131 scale_diff = (scale_fused.int() - scale_ref.int()).abs().max().item()
132 assert scale_diff <= 1, f"scale byte differs by {scale_diff} (>1)"
133
134
135 def _make_indexer_cache(num_blocks: int, block_size: int) -> torch.Tensor:
136 block_bytes = block_size * INDEXER_HEAD_DIM + block_size * INDEXER_SCALE_BYTES
137 return torch.zeros(num_blocks, block_bytes, dtype=torch.uint8)
138
139
140 def _assert_indexer_cache_parity(cache_fused, cache_ref, slot_mapping, block_size):
141 valid = slot_mapping >= 0
142 idx = valid.nonzero(as_tuple=True)[0]
143 if idx.numel() == 0:
144 return
145 slots = slot_mapping[idx]
146 block_idx = torch.div(slots, block_size, rounding_mode="floor")
147 pos_in_block = slots % block_size
148
149 fp8_ref, scale_ref = [], []
150 fp8_fused, scale_fused = [], []
151 for b, p in zip(block_idx.tolist(), pos_in_block.tolist()):
152 token_off = p * INDEXER_HEAD_DIM
153 scale_off = block_size * INDEXER_HEAD_DIM + p * INDEXER_SCALE_BYTES
154 fp8_ref.append(cache_ref[b, token_off : token_off + INDEXER_HEAD_DIM])
155 fp8_fused.append(cache_fused[b, token_off : token_off + INDEXER_HEAD_DIM])
156 scale_ref.append(cache_ref[b, scale_off : scale_off + INDEXER_SCALE_BYTES])
157 scale_fused.append(cache_fused[b, scale_off : scale_off + INDEXER_SCALE_BYTES])
158
159 fp8_ref_t = torch.stack(fp8_ref).view(torch.float8_e4m3fn)
160 fp8_fused_t = torch.stack(fp8_fused).view(torch.float8_e4m3fn)
161 torch.testing.assert_close(fp8_fused_t.float(), fp8_ref_t.float())
162
163 scale_ref_t = torch.stack(scale_ref).view(torch.float32)
164 scale_fused_t = torch.stack(scale_fused).view(torch.float32)
165 torch.testing.assert_close(scale_fused_t, scale_ref_t)
166
167
168 # ---------------------------------------------------------------------------
169 # save_partial_states_cpu / compress_norm_rope_store_cpu (compressor.cpp)
170 # ---------------------------------------------------------------------------
171 #
172 # The compressor's state cache is pooled with the main KV cache's physical
173 # pages and page-aligned, so it is genuinely 3D with a block-to-block stride
174 # that need not equal block_size * width -- built here via torch.as_strided
175 # over an over-sized flat buffer rather than a plain contiguous tensor, so a
176 # regression back to flattening via .view() would be caught.
177
178
179 def _make_padded_block_cache(
180 num_blocks: int, block_size: int, width: int, pad_elems: int
181 ) -> torch.Tensor:
182 page_stride = block_size * width + pad_elems
183 flat = torch.randn(num_blocks, page_stride, dtype=torch.float32)
184 return torch.as_strided(
185 flat, (num_blocks, block_size, width), (page_stride, width, 1)
186 )
187
188
189 def _dup_padded_block_cache(
190 num_blocks: int, block_size: int, width: int, pad_elems: int
191 ) -> tuple[torch.Tensor, torch.Tensor]:
192 """Two independently-stored padded caches with identical values, for ops
193 that mutate the cache in place."""
194 page_stride = block_size * width + pad_elems
195 template = torch.randn(num_blocks, page_stride, dtype=torch.float32)
196
197 def _view(flat: torch.Tensor) -> torch.Tensor:
198 return torch.as_strided(
199 flat, (num_blocks, block_size, width), (page_stride, width, 1)
200 )
201
202 return _view(template.clone()), _view(template.clone())
203
204
205 def _run_save_partial_states_eager(
206 state_cache, kv, score, ape, positions, slot_mapping, compress_ratio
207 ):
208 """Reference: state_cache[block_idx, pos_in_block, :W] = kv;
209 state_cache[..., W:] = score + ape[position % compress_ratio]; skip
210 slot < 0. Mutates state_cache in place, matching the op's contract."""
211 state_width = kv.shape[-1]
212 block_size = state_cache.shape[1]
213 valid = slot_mapping >= 0
214 idx = valid.nonzero(as_tuple=True)[0]
215 slots = slot_mapping[idx]
216 block_idx = slots // block_size
217 pos_in_block = slots % block_size
218
219 state_cache[block_idx, pos_in_block, :state_width] = kv[idx]
220 ape_row = ape[positions[idx] % compress_ratio]
221 state_cache[block_idx, pos_in_block, state_width:] = score[idx] + ape_row
222
223
224 @pytest.mark.parametrize("pad_elems", [0, 24])
225 @pytest.mark.parametrize("num_tokens", [1, 17])
226 def test_save_partial_states_cpu_matches_eager(num_tokens: int, pad_elems: int):
227 torch.manual_seed(0)
228 compress_ratio = 4
229 block_size = 4
230 state_width = 512
231 width = 2 * state_width
232 num_blocks = (num_tokens + 4 + block_size - 1) // block_size + 1
233 num_slots = num_blocks * block_size
234
235 kv = torch.randn(num_tokens, state_width, dtype=torch.float32)
236 score = torch.randn(num_tokens, state_width, dtype=torch.float32)
237 ape = torch.randn(compress_ratio, state_width, dtype=torch.float32)
238 positions = torch.randint(0, 4096, (num_tokens,), dtype=torch.int64)
239
240 ref_cache, fused_cache = _dup_padded_block_cache(
241 num_blocks, block_size, width, pad_elems
242 )
243
244 slot_mapping = torch.randperm(num_slots)[:num_tokens].to(torch.int64)
245 if num_tokens > 1:
246 slot_mapping[-1] = -1 # exercise the "skip invalid slot" branch
247
248 _run_save_partial_states_eager(
249 ref_cache, kv, score, ape, positions, slot_mapping, compress_ratio
250 )
251 save_partial_states_cpu(kv, score, ape, positions, fused_cache, slot_mapping)
252
253 torch.testing.assert_close(fused_cache, ref_cache)
254
255
256 def _run_compress_eager(
257 state_cache,
258 gather_slots,
259 positions,
260 kv_slot_mapping,
261 rms_norm_weight,
262 rms_norm_eps,
263 cos_sin_cache,
264 kv_cache,
265 kv_cache_block_size,
266 compress_ratio,
267 ):
268 """Reference for the per-channel-softmax-pooling + RMSNorm + NoPE-UE8M0
269 quant + GPT-J RoPE math writing the main (head_dim=512) fp8_ds_mla
270 layout."""
271 num_tokens, window = gather_slots.shape
272 state_width = state_cache.shape[-1] // 2
273 state_block_size = state_cache.shape[1]
274 num_quant_blocks = NOPE_DIM // QUANT_BLOCK
275
276 w_idx = torch.arange(window)
277 head_offset = torch.where(w_idx >= compress_ratio, HEAD_DIM, 0)
278
279 for t in range(num_tokens):
280 position = int(positions[t])
281 if (position + 1) % compress_ratio != 0:
282 continue
283 kv_slot = int(kv_slot_mapping[t])
284 if kv_slot < 0:
285 continue
286
287 slots = gather_slots[t]
288 valid_w = slots >= 0
289 safe_slots = slots.clamp(min=0)
290 block_idx = safe_slots // state_block_size
291 pos_in_block = safe_slots % state_block_size
292
293 rows = state_cache[block_idx, pos_in_block] # [window, 2*state_width]
294 col = head_offset.unsqueeze(-1) + torch.arange(HEAD_DIM)
295 kv_vals = torch.gather(rows, 1, col)
296 score_col = state_width + col
297 score_vals = torch.gather(rows, 1, score_col)
298 score_vals = torch.where(
299 valid_w.unsqueeze(-1), score_vals, torch.full_like(score_vals, -torch.inf)
300 )
301
302 weights = torch.softmax(score_vals, dim=0)
303 compressed = (kv_vals * weights).sum(dim=0) # [HEAD_DIM]
304
305 variance = compressed.pow(2).mean()
306 normed = compressed * torch.rsqrt(variance + rms_norm_eps) * rms_norm_weight
307
308 quant_input = normed.to(torch.bfloat16).to(torch.float32)
309
310 compressed_pos = (position // compress_ratio) * compress_ratio
311 cos = cos_sin_cache[compressed_pos, : ROPE_DIM // 2]
312 sin = cos_sin_cache[compressed_pos, ROPE_DIM // 2 :]
313 rotated_rope = _apply_gptj_rope(normed[NOPE_DIM:].clone(), cos, sin)
314
315 nope_vals = quant_input[:NOPE_DIM].view(num_quant_blocks, QUANT_BLOCK)
316 absmax = nope_vals.abs().amax(dim=-1).clamp(min=1e-4)
317 exponent = torch.ceil(torch.log2(absmax / FP8_MAX))
318 inv_scale = torch.exp2(-exponent)
319 scaled = (nope_vals * inv_scale.unsqueeze(-1)).clamp(-FP8_MAX, FP8_MAX)
320 nope_fp8_bytes = scaled.to(torch.float8_e4m3fn).reshape(-1).view(torch.uint8)
321 scale_bytes = (exponent + 127.0).clamp(0, 255).to(torch.uint8)
322 rope_bf16_bytes = rotated_rope.to(torch.bfloat16).contiguous().view(torch.uint8)
323
324 block_idx_kv = kv_slot // kv_cache_block_size
325 pos_in_block_kv = kv_slot % kv_cache_block_size
326 token_off = pos_in_block_kv * TOKEN_DATA_BYTES
327 kv_cache[block_idx_kv, token_off : token_off + NOPE_DIM] = nope_fp8_bytes
328 kv_cache[block_idx_kv, token_off + NOPE_DIM : token_off + TOKEN_DATA_BYTES] = (
329 rope_bf16_bytes
330 )
331 scale_off = (
332 kv_cache_block_size * TOKEN_DATA_BYTES
333 + pos_in_block_kv * SCALE_BYTES_PER_TOKEN
334 )
335 kv_cache[block_idx_kv, scale_off : scale_off + num_quant_blocks] = scale_bytes
336 kv_cache[block_idx_kv, scale_off + num_quant_blocks] = 0
337
338
339 def _make_compress_inputs(
340 num_tokens: int,
341 compress_ratio: int,
342 overlap: bool,
343 pad_elems: int = 24,
344 max_pos: int = 4096,
345 invalid_window_frac: float = 0.1,
346 head_dim: int = HEAD_DIM,
347 ):
348 torch.manual_seed(0)
349 coff = 1 + int(overlap)
350 state_width = coff * head_dim
351 window = coff * compress_ratio
352 state_block_size = 4 if compress_ratio == 4 else 8
353 num_state_slots = num_tokens * window + 8
354 num_state_blocks = (num_state_slots + state_block_size - 1) // state_block_size + 1
355
356 state_cache = _make_padded_block_cache(
357 num_state_blocks, state_block_size, 2 * state_width, pad_elems
358 )
359 gather_slots = torch.randint(
360 0, num_state_blocks * state_block_size, (num_tokens, window)
361 ).to(torch.int64)
362 invalid_mask = torch.rand(num_tokens, window) < invalid_window_frac
363 # Never invalidate every column of a row (would force max=-inf -> NaN).
364 invalid_mask[:, -1] = False
365 gather_slots = torch.where(
366 invalid_mask, torch.full_like(gather_slots, -1), gather_slots
367 )
368
369 base = (torch.arange(num_tokens) + 1) * compress_ratio
370 is_boundary = torch.arange(num_tokens) % 2 == 0
371 positions = torch.where(is_boundary, base - 1, base + 1).to(torch.int64)
372
373 kv_slot_mapping = torch.arange(num_tokens, dtype=torch.int64)
374 if num_tokens > 1:
375 kv_slot_mapping[1] = -1 # exercise the "skip invalid output slot" branch
376
377 rms_norm_weight = torch.randn(head_dim, dtype=torch.float32)
378 cos_sin_cache = make_cos_sin_cache(max_pos, ROPE_DIM, torch.float32, "cpu")
379
380 return (
381 state_cache,
382 gather_slots,
383 positions,
384 kv_slot_mapping,
385 rms_norm_weight,
386 cos_sin_cache,
387 is_boundary,
388 )
389
390
391 @pytest.mark.parametrize("compress_ratio,overlap", [(128, False), (4, True)])
392 @pytest.mark.parametrize("num_tokens", [1, 17])
393 def test_compress_norm_rope_store_cpu_matches_eager(
394 compress_ratio: int, overlap: bool, num_tokens: int
395 ):
396 eps = 1e-6
397 block_size = 16
398 (
399 state_cache,
400 gather_slots,
401 positions,
402 kv_slot_mapping,
403 rms_norm_weight,
404 cos_sin_cache,
405 is_boundary,
406 ) = _make_compress_inputs(num_tokens, compress_ratio, overlap)
407
408 num_blocks = (num_tokens + block_size - 1) // block_size + 1
409 kv_cache_ref = _make_cache(num_blocks, block_size)
410 kv_cache_fused = kv_cache_ref.clone()
411
412 _run_compress_eager(
413 state_cache,
414 gather_slots,
415 positions,
416 kv_slot_mapping,
417 rms_norm_weight,
418 eps,
419 cos_sin_cache,
420 kv_cache_ref,
421 block_size,
422 compress_ratio,
423 )
424 compress_norm_rope_store_cpu(
425 state_cache,
426 gather_slots,
427 positions,
428 kv_slot_mapping,
429 rms_norm_weight,
430 eps,
431 cos_sin_cache,
432 kv_cache_fused,
433 block_size,
434 compress_ratio,
435 )
436
437 # Only tokens that hit the compression boundary (and have a valid output
438 # slot) were written -- restrict parity checks to those.
439 written_slot_mapping = torch.where(
440 is_boundary, kv_slot_mapping, torch.full_like(kv_slot_mapping, -1)
441 )
442 _assert_cache_parity(kv_cache_fused, kv_cache_ref, written_slot_mapping, block_size)
443
444
445 def test_compress_norm_rope_store_cpu_non_contiguous_kv_cache_row_stride():
446 """The main (output) kv_cache is a separate physical buffer from the
447 state cache and can independently be a strided view."""
448 eps = 1e-6
449 compress_ratio, overlap = 128, False
450 num_tokens = 5
451 block_size = 16
452 cache_pad = 32
453
454 (
455 state_cache,
456 gather_slots,
457 positions,
458 kv_slot_mapping,
459 rms_norm_weight,
460 cos_sin_cache,
461 is_boundary,
462 ) = _make_compress_inputs(num_tokens, compress_ratio, overlap)
463
464 num_blocks = (num_tokens + block_size - 1) // block_size + 1
465 kv_cache_ref = _make_cache(num_blocks, block_size)
466 block_bytes = kv_cache_ref.shape[1]
467 padded_cache_ref = torch.zeros(
468 num_blocks, block_bytes + cache_pad, dtype=torch.uint8
469 )
470 kv_cache_ref_view = padded_cache_ref[:, :block_bytes]
471 kv_cache_ref_view.copy_(kv_cache_ref)
472 padded_cache_fused = padded_cache_ref.clone()
473 kv_cache_fused_view = padded_cache_fused[:, :block_bytes]
474
475 _run_compress_eager(
476 state_cache,
477 gather_slots,
478 positions,
479 kv_slot_mapping,
480 rms_norm_weight,
481 eps,
482 cos_sin_cache,
483 kv_cache_ref_view,
484 block_size,
485 compress_ratio,
486 )
487 compress_norm_rope_store_cpu(
488 state_cache,
489 gather_slots,
490 positions,
491 kv_slot_mapping,
492 rms_norm_weight,
493 eps,
494 cos_sin_cache,
495 kv_cache_fused_view,
496 block_size,
497 compress_ratio,
498 )
499
500 written_slot_mapping = torch.where(
501 is_boundary, kv_slot_mapping, torch.full_like(kv_slot_mapping, -1)
502 )
503 _assert_cache_parity(
504 kv_cache_fused_view, kv_cache_ref_view, written_slot_mapping, block_size
505 )
506
507
508 def _run_compress_indexer_eager(
509 state_cache,
510 gather_slots,
511 positions,
512 kv_slot_mapping,
513 rms_norm_weight,
514 rms_norm_eps,
515 cos_sin_cache,
516 kv_cache,
517 kv_cache_block_size,
518 compress_ratio,
519 ):
520 """Same softmax-pool + RMSNorm + GPT-J RoPE prologue as the head=512
521 path, but the indexer's cache quantizes all 128 post-RoPE dims as one
522 FP8 block with a single raw fp32 scale per token (matching
523 _indexer_k_quant_and_cache_cpu's layout), not 7 separate UE8M0 blocks."""
524 num_tokens, window = gather_slots.shape
525 state_width = state_cache.shape[-1] // 2
526 state_block_size = state_cache.shape[1]
527
528 w_idx = torch.arange(window)
529 head_offset = torch.where(w_idx >= compress_ratio, INDEXER_HEAD_DIM, 0)
530
531 for t in range(num_tokens):
532 position = int(positions[t])
533 if (position + 1) % compress_ratio != 0:
534 continue
535 kv_slot = int(kv_slot_mapping[t])
536 if kv_slot < 0:
537 continue
538
539 slots = gather_slots[t]
540 valid_w = slots >= 0
541 safe_slots = slots.clamp(min=0)
542 block_idx = safe_slots // state_block_size
543 pos_in_block = safe_slots % state_block_size
544
545 rows = state_cache[block_idx, pos_in_block] # [window, 2*state_width]
546 col = head_offset.unsqueeze(-1) + torch.arange(INDEXER_HEAD_DIM)
547 kv_vals = torch.gather(rows, 1, col)
548 score_col = state_width + col
549 score_vals = torch.gather(rows, 1, score_col)
550 score_vals = torch.where(
551 valid_w.unsqueeze(-1), score_vals, torch.full_like(score_vals, -torch.inf)
552 )
553
554 weights = torch.softmax(score_vals, dim=0)
555 compressed = (kv_vals * weights).sum(dim=0) # [INDEXER_HEAD_DIM]
556
557 variance = compressed.pow(2).mean()
558 normed = compressed * torch.rsqrt(variance + rms_norm_eps) * rms_norm_weight
559
560 compressed_pos = (position // compress_ratio) * compress_ratio
561 cos = cos_sin_cache[compressed_pos, : ROPE_DIM // 2]
562 sin = cos_sin_cache[compressed_pos, ROPE_DIM // 2 :]
563 rotated_rope = _apply_gptj_rope(normed[INDEXER_NOPE_DIM:].clone(), cos, sin)
564 full = torch.cat((normed[:INDEXER_NOPE_DIM], rotated_rope))
565
566 quant_input = full.to(torch.bfloat16).to(torch.float32)
567 absmax = quant_input.abs().amax().clamp(min=1e-4)
568 exponent = torch.ceil(torch.log2(absmax / FP8_MAX))
569 scale = torch.exp2(exponent)
570 scaled = (quant_input / scale).clamp(-FP8_MAX, FP8_MAX)
571 fp8_bytes = scaled.to(torch.float8_e4m3fn).view(torch.uint8)
572
573 block_idx_kv = kv_slot // kv_cache_block_size
574 pos_in_block_kv = kv_slot % kv_cache_block_size
575 token_off = pos_in_block_kv * INDEXER_HEAD_DIM
576 kv_cache[block_idx_kv, token_off : token_off + INDEXER_HEAD_DIM] = fp8_bytes
577 scale_off = (
578 kv_cache_block_size * INDEXER_HEAD_DIM
579 + pos_in_block_kv * INDEXER_SCALE_BYTES
580 )
581 kv_cache[block_idx_kv, scale_off : scale_off + INDEXER_SCALE_BYTES] = (
582 scale.reshape(1).view(torch.uint8)
583 )
584
585
586 @pytest.mark.parametrize("compress_ratio,overlap", [(128, False), (4, True)])
587 @pytest.mark.parametrize("num_tokens", [1, 17])
588 def test_compress_norm_rope_store_indexer_cpu_matches_eager(
589 compress_ratio: int, overlap: bool, num_tokens: int
590 ):
591 eps = 1e-6
592 block_size = 16
593 (
594 state_cache,
595 gather_slots,
596 positions,
597 kv_slot_mapping,
598 rms_norm_weight,
599 cos_sin_cache,
600 is_boundary,
601 ) = _make_compress_inputs(
602 num_tokens, compress_ratio, overlap, head_dim=INDEXER_HEAD_DIM
603 )
604
605 num_blocks = (num_tokens + block_size - 1) // block_size + 1
606 kv_cache_ref = _make_indexer_cache(num_blocks, block_size)
607 kv_cache_fused = kv_cache_ref.clone()
608
609 _run_compress_indexer_eager(
610 state_cache,
611 gather_slots,
612 positions,
613 kv_slot_mapping,
614 rms_norm_weight,
615 eps,
616 cos_sin_cache,
617 kv_cache_ref,
618 block_size,
619 compress_ratio,
620 )
621 compress_norm_rope_store_indexer_cpu(
622 state_cache,
623 gather_slots,
624 positions,
625 kv_slot_mapping,
626 rms_norm_weight,
627 eps,
628 cos_sin_cache,
629 kv_cache_fused,
630 block_size,
631 compress_ratio,
632 )
633
634 written_slot_mapping = torch.where(
635 is_boundary, kv_slot_mapping, torch.full_like(kv_slot_mapping, -1)
636 )
637 _assert_indexer_cache_parity(
638 kv_cache_fused, kv_cache_ref, written_slot_mapping, block_size
639 )
640
641
642 # ---------------------------------------------------------------------------
643 # flash_mla_with_kvcache_cpu (flash_mla.cpp): fused sparse MQA decode attn
644 # ---------------------------------------------------------------------------
645
646
647 def _pack_flash_mla_cache(kv_f32: torch.Tensor, block_size: int):
648 """Quantize kv_f32 ([N, HEAD_DIM]) into the fp8_ds_mla byte layout and
649 return (cache, dequant_ref) -- the exact post-quantization value the
650 kernel's dequant should reproduce. Slot id == row index, packed
651 contiguously from block 0."""
652 n = kv_f32.shape[0]
653 num_blocks = max(1, -(-n // block_size))
654 cache = _make_cache(num_blocks, block_size)
655 if n == 0:
656 return cache, kv_f32.new_zeros(0, HEAD_DIM)
657
658 num_quant_blocks = NOPE_DIM // QUANT_BLOCK
659 nope = kv_f32[:, :NOPE_DIM].reshape(n, num_quant_blocks, QUANT_BLOCK)
660 absmax = nope.abs().amax(dim=-1).clamp(min=1e-4)
661 exponent = torch.ceil(torch.log2(absmax / FP8_MAX))
662 inv_scale = torch.exp2(-exponent)
663 scaled = (nope * inv_scale.unsqueeze(-1)).clamp(-FP8_MAX, FP8_MAX)
664 nope_fp8 = scaled.to(torch.float8_e4m3fn)
665 scale = torch.exp2(exponent)
666 nope_dequant = (nope_fp8.float() * scale.unsqueeze(-1)).reshape(n, NOPE_DIM)
667
668 rope_bf16 = kv_f32[:, NOPE_DIM:].to(torch.bfloat16)
669 dequant_ref = torch.cat([nope_dequant, rope_bf16.float()], dim=-1)
670
671 slot_ids = torch.arange(n)
672 block_idx = slot_ids // block_size
673 pos_in_block = slot_ids % block_size
674
675 nope_fp8_bytes = nope_fp8.reshape(n, NOPE_DIM).contiguous().view(torch.uint8)
676 rope_bf16_bytes = rope_bf16.contiguous().view(torch.uint8)
677 scale_bytes = (exponent + 127.0).clamp(0, 255).to(torch.uint8)
678
679 arange_nope = torch.arange(NOPE_DIM)
680 arange_rope = torch.arange(ROPE_DIM * 2)
681 arange_scale = torch.arange(num_quant_blocks)
682 token_base = pos_in_block.unsqueeze(-1) * TOKEN_DATA_BYTES
683 row_idx = block_idx.unsqueeze(-1)
684
685 cache[row_idx, token_base + arange_nope] = nope_fp8_bytes
686 cache[row_idx, token_base + NOPE_DIM + arange_rope] = rope_bf16_bytes
687 scale_base = (
688 block_size * TOKEN_DATA_BYTES
689 + pos_in_block.unsqueeze(-1) * SCALE_BYTES_PER_TOKEN
690 )
691 cache[row_idx, scale_base + arange_scale] = scale_bytes
692 cache[block_idx, scale_base.squeeze(-1) + num_quant_blocks] = 0
693
694 return cache, dequant_ref
695
696
697 def _flash_mla_eager(
698 q: torch.Tensor,
699 window_dequant: torch.Tensor,
700 window_slots: torch.Tensor,
701 compressed_dequant: torch.Tensor,
702 compressed_slots: torch.Tensor,
703 attn_sink: torch.Tensor,
704 scale: float,
705 ) -> torch.Tensor:
706 """MQA attention: gather the (already-dequantized) window+compressed KV
707 rows referenced by *_slots (-1 = invalid/masked), append a learned
708 per-head sink logit with zero value contribution, softmax, weighted
709 sum."""
710 num_tokens, num_heads, _ = q.shape
711 q_f32 = q.float()
712
713 def gather(dequant, slots):
714 valid = slots >= 0
715 safe = slots.clamp(min=0)
716 kv = dequant[safe] # [T, K, D]
717 return kv, valid
718
719 win_kv, win_valid = gather(window_dequant, window_slots)
720 if compressed_slots.shape[1] > 0:
721 comp_kv, comp_valid = gather(compressed_dequant, compressed_slots)
722 kv = torch.cat([win_kv, comp_kv], dim=1)
723 valid = torch.cat([win_valid, comp_valid], dim=1)
724 else:
725 kv = win_kv
726 valid = win_valid
727
728 scores = torch.einsum("thd,tkd->thk", q_f32, kv) * scale
729 scores = scores.masked_fill(~valid.unsqueeze(1), float("-inf"))
730 sink = attn_sink.view(1, num_heads, 1).expand(num_tokens, num_heads, 1)
731 scores_full = torch.cat([scores, sink], dim=-1)
732 probs = torch.softmax(scores_full, dim=-1)
733 out = torch.einsum("thk,tkd->thd", probs[..., :-1], kv)
734 return out.to(q.dtype)
735
736
737 @pytest.mark.parametrize("num_heads", [1, 64])
738 @pytest.mark.parametrize("num_tokens", [1, 17])
739 @pytest.mark.parametrize("swa_only", [True, False])
740 def test_flash_mla_cpu_kernel_matches_eager(
741 num_heads: int, num_tokens: int, swa_only: bool
742 ):
743 torch.manual_seed(0)
744 dtype = torch.bfloat16
745 window_block_size = 16
746 compressed_block_size = 8
747 num_window = 6
748 num_compressed = 0 if swa_only else 5
749
750 q = torch.randn(num_tokens, num_heads, HEAD_DIM, dtype=dtype)
751 scale = 1.0 / (HEAD_DIM**0.5)
752 attn_sink = torch.randn(num_heads, dtype=torch.float32)
753
754 num_window_slots = num_tokens * num_window + window_block_size
755 window_kv_f32 = torch.randn(num_window_slots, HEAD_DIM)
756 window_cache, window_dequant = _pack_flash_mla_cache(
757 window_kv_f32, window_block_size
758 )
759 window_slots = torch.randint(0, num_window_slots, (num_tokens, num_window))
760 window_slots[:, 0] = -1 # exercise invalid-slot masking
761
762 if num_compressed > 0:
763 num_compressed_slots = num_tokens * num_compressed + compressed_block_size
764 compressed_kv_f32 = torch.randn(num_compressed_slots, HEAD_DIM)
765 compressed_cache, compressed_dequant = _pack_flash_mla_cache(
766 compressed_kv_f32, compressed_block_size
767 )
768 compressed_slots = torch.randint(
769 0, num_compressed_slots, (num_tokens, num_compressed)
770 )
771 if num_compressed > 1:
772 compressed_slots[:, -1] = -1
773 else:
774 compressed_cache = window_cache
775 compressed_dequant = window_dequant.new_zeros(0, HEAD_DIM)
776 compressed_slots = window_slots.new_full((num_tokens, 0), -1)
777
778 out_ref = _flash_mla_eager(
779 q,
780 window_dequant,
781 window_slots,
782 compressed_dequant,
783 compressed_slots,
784 attn_sink,
785 scale,
786 )
787 out_kernel = q.new_empty(num_tokens, num_heads, HEAD_DIM)
788 flash_mla_with_kvcache_cpu(
789 out_kernel,
790 q,
791 window_cache,
792 window_slots,
793 window_block_size,
794 compressed_cache,
795 compressed_slots,
796 compressed_block_size,
797 attn_sink,
798 scale,
799 )
800
801 torch.testing.assert_close(
802 out_kernel.float(), out_ref.float(), rtol=2e-2, atol=2e-2
803 )
804
805
806 def test_flash_mla_cpu_kernel_all_invalid_falls_back_to_sink():
807 """When every KV slot is masked out, softmax degenerates to the sink
808 logit alone and the output must be all-zero (no valid value rows)."""
809 torch.manual_seed(2)
810 dtype = torch.bfloat16
811 num_tokens, num_heads, num_window = 3, 4, 5
812 window_block_size = 16
813
814 q = torch.randn(num_tokens, num_heads, HEAD_DIM, dtype=dtype)
815 scale = 1.0 / (HEAD_DIM**0.5)
816 attn_sink = torch.randn(num_heads, dtype=torch.float32)
817
818 window_kv_f32 = torch.randn(window_block_size, HEAD_DIM)
819 window_cache, _ = _pack_flash_mla_cache(window_kv_f32, window_block_size)
820 window_slots = torch.full((num_tokens, num_window), -1, dtype=torch.int64)
821 compressed_slots = window_slots.new_full((num_tokens, 0), -1)
822
823 out = q.new_empty(num_tokens, num_heads, HEAD_DIM)
824 flash_mla_with_kvcache_cpu(
825 out,
826 q,
827 window_cache,
828 window_slots,
829 window_block_size,
830 window_cache,
831 compressed_slots,
832 1,
833 attn_sink,
834 scale,
835 )
836 assert out.abs().max().item() == 0.0
837
838
839 # ---------------------------------------------------------------------------
840 # fused_qnorm_rope_kv_insert_cpu (store_cache.cpp)
841 # ---------------------------------------------------------------------------
842
843
844 def _qnorm_kv_insert_eager(
845 q, kv, positions, cache, slot_mapping, cos_sin_cache, q_head_padded, eps, bs
846 ):
847 """Reference: per-head weight-free RMSNorm + GPT-J RoPE on Q; GPT-J RoPE
848 + UE8M0 FP8 quant + paged insert on KV."""
849 num_quant_blocks = NOPE_DIM // QUANT_BLOCK
850
851 num_tokens_full, num_heads_q, _ = q.shape
852 out_dtype = q.dtype
853
854 cos = cos_sin_cache[positions, : ROPE_DIM // 2]
855 sin = cos_sin_cache[positions, ROPE_DIM // 2 :]
856
857 q_f32 = q.float()
858 var = q_f32.square().mean(dim=-1, keepdim=True)
859 q_f32 = q_f32 * torch.rsqrt(var + eps)
860 q_f32[..., NOPE_DIM:] = _apply_gptj_rope(
861 q_f32[..., NOPE_DIM:], cos.unsqueeze(1), sin.unsqueeze(1)
862 )
863 q_out = q.new_zeros(num_tokens_full, q_head_padded, HEAD_DIM)
864 q_out[:, :num_heads_q, :] = q_f32.to(out_dtype)
865
866 num_tokens_insert = slot_mapping.shape[0]
867 kv_f32 = kv[:num_tokens_insert].float()
868 kv_f32[..., NOPE_DIM:] = _apply_gptj_rope(
869 kv_f32[..., NOPE_DIM:], cos[:num_tokens_insert], sin[:num_tokens_insert]
870 )
871 kv_bf16 = kv_f32.to(torch.bfloat16)
872 kv_f32 = kv_bf16.float()
873
874 valid = slot_mapping >= 0
875 if bool(valid.any()):
876 idx = valid.nonzero(as_tuple=True)[0]
877 slot_id = slot_mapping[idx]
878 block_idx = torch.div(slot_id, bs, rounding_mode="floor")
879 pos_in_block = slot_id % bs
880
881 nope = kv_f32[idx, :NOPE_DIM].view(-1, num_quant_blocks, QUANT_BLOCK)
882 absmax = nope.abs().amax(dim=-1).clamp(min=1e-4)
883 exponent = torch.ceil(torch.log2(absmax / FP8_MAX))
884 inv_scale = torch.exp2(-exponent)
885 scaled = (nope * inv_scale.unsqueeze(-1)).clamp(-FP8_MAX, FP8_MAX)
886 # Flush fp8-subnormal magnitudes to zero, matching the kernel's
887 # AVX512 cvt_fp32x16_to_fp8x16, which doesn't preserve exact
888 # subnormal encodings the way .to(torch.float8_e4m3fn) does.
889 fp8_min_normal = 2.0**-6
890 scaled = torch.where(scaled.abs() < fp8_min_normal, 0.0, scaled)
891 nope_fp8_bytes = (
892 scaled.to(torch.float8_e4m3fn).view(-1, NOPE_DIM).view(torch.uint8)
893 )
894 scale_bytes = (exponent + 127.0).clamp(0, 255).to(torch.uint8)
895 rope_bf16_bytes = kv_bf16[idx, NOPE_DIM:].contiguous().view(torch.uint8)
896
897 arange_nope = torch.arange(NOPE_DIM)
898 arange_rope = torch.arange(ROPE_DIM * 2)
899 arange_scale = torch.arange(num_quant_blocks)
900 row_idx = block_idx.unsqueeze(-1)
901 token_base = pos_in_block.unsqueeze(-1) * TOKEN_DATA_BYTES
902
903 cache[row_idx, token_base + arange_nope] = nope_fp8_bytes
904 cache[row_idx, token_base + NOPE_DIM + arange_rope] = rope_bf16_bytes
905 scale_base = (
906 bs * TOKEN_DATA_BYTES + pos_in_block.unsqueeze(-1) * SCALE_BYTES_PER_TOKEN
907 )
908 cache[row_idx, scale_base + arange_scale] = scale_bytes
909 cache[block_idx, scale_base.squeeze(-1) + num_quant_blocks] = 0
910
911 return q_out
912
913
914 @pytest.mark.parametrize("num_tokens", [1, 64])
915 @pytest.mark.parametrize("n_heads,padded_heads", [(1, 8), (8, 8), (8, 16), (64, 128)])
916 def test_qnorm_rope_kv_insert_cpu_kernel_matches_eager(
917 num_tokens: int, n_heads: int, padded_heads: int
918 ):
919 torch.manual_seed(0)
920 dtype = torch.bfloat16
921 eps = 1e-6
922 max_pos = 4096
923 block_size = 16
924
925 q = torch.randn(num_tokens, n_heads, HEAD_DIM, dtype=dtype)
926 kv = torch.randn(num_tokens, HEAD_DIM, dtype=dtype)
927 positions = torch.randint(0, max_pos, (num_tokens,), dtype=torch.int64)
928 cos_sin_cache = make_cos_sin_cache(max_pos, ROPE_DIM, torch.float32, "cpu")
929
930 num_blocks = (num_tokens + block_size - 1) // block_size + 1
931 slot_mapping = torch.arange(num_tokens, dtype=torch.int64)
932 if num_tokens > 1:
933 slot_mapping[-1] = -1 # exercise the "skip invalid slot" branch
934
935 cache_ref = _make_cache(num_blocks, block_size)
936 cache_fused = cache_ref.clone()
937
938 q_ref = _qnorm_kv_insert_eager(
939 q,
940 kv,
941 positions,
942 cache_ref,
943 slot_mapping,
944 cos_sin_cache,
945 padded_heads,
946 eps,
947 block_size,
948 )
949 q_fused = fused_qnorm_rope_kv_insert_cpu(
950 q,
951 kv,
952 positions,
953 cache_fused,
954 slot_mapping,
955 cos_sin_cache,
956 padded_heads,
957 eps,
958 block_size,
959 )
960
961 torch.testing.assert_close(q_fused.float(), q_ref.float(), rtol=1e-2, atol=1e-2)
962 if n_heads < padded_heads:
963 pad_region = q_fused[:, n_heads:padded_heads]
964 assert pad_region.abs().max().item() == 0.0, (
965 "padded head slots must be exact zero"
966 )
967
968 _assert_cache_parity(cache_fused, cache_ref, slot_mapping, block_size)
969
970
971 @pytest.mark.parametrize("pad", [1, 5])
972 def test_qnorm_rope_kv_insert_cpu_kernel_dp_padding(pad: int):
973 """slot_mapping shorter than q/kv rows: the KV branch must only touch
974 the first num_tokens rows while Q-norm+RoPE still runs on all rows."""
975 torch.manual_seed(1)
976 dtype = torch.bfloat16
977 eps = 1e-6
978 max_pos = 4096
979 block_size = 16
980 num_tokens = 17
981 total = num_tokens + pad
982 padded_heads = 8
983
984 q = torch.randn(total, padded_heads, HEAD_DIM, dtype=dtype)
985 kv = torch.randn(total, HEAD_DIM, dtype=dtype)
986 positions = torch.randint(0, max_pos, (total,), dtype=torch.int64)
987 cos_sin_cache = make_cos_sin_cache(max_pos, ROPE_DIM, torch.float32, "cpu")
988
989 num_blocks = (num_tokens + block_size - 1) // block_size + 1
990 slot_mapping = torch.arange(num_tokens, dtype=torch.int64)
991
992 cache_ref = _make_cache(num_blocks, block_size)
993 cache_fused = cache_ref.clone()
994
995 q_ref = _qnorm_kv_insert_eager(
996 q,
997 kv,
998 positions,
999 cache_ref,
1000 slot_mapping,
1001 cos_sin_cache,
1002 padded_heads,
1003 eps,
1004 block_size,
1005 )
1006 q_fused = fused_qnorm_rope_kv_insert_cpu(
1007 q,
1008 kv,
1009 positions,
1010 cache_fused,
1011 slot_mapping,
1012 cos_sin_cache,
1013 padded_heads,
1014 eps,
1015 block_size,
1016 )
1017
1018 torch.testing.assert_close(q_fused.float(), q_ref.float(), rtol=1e-2, atol=1e-2)
1019 _assert_cache_parity(cache_fused, cache_ref, slot_mapping, block_size)
1020
1021
1022 # ---------------------------------------------------------------------------
1023 # inverse_gptj_rope_o_proj_cpu (store_cache.cpp): DeepseekV4CPUAttention's
1024 # _o_proj de-rotation kernel
1025 # ---------------------------------------------------------------------------
1026
1027
1028 def _inverse_gptj_rope_o_proj_eager(
1029 o: torch.Tensor,
1030 positions: torch.Tensor,
1031 cos_sin_cache: torch.Tensor,
1032 rope_dim: int,
1033 ) -> torch.Tensor:
1034 """NoPE dims pass through as fp32; the RoPE segment is de-rotated by
1035 R(-pos) in GPT-J (interleaved even/odd) convention."""
1036 nope_dim = o.shape[-1] - rope_dim
1037 half_dim = rope_dim // 2
1038
1039 o_f32 = o.float()
1040 out = torch.empty_like(o_f32)
1041 out[..., :nope_dim] = o_f32[..., :nope_dim]
1042
1043 cos = cos_sin_cache[positions, :half_dim].unsqueeze(1)
1044 sin = cos_sin_cache[positions, half_dim:].unsqueeze(1)
1045
1046 e = o_f32[..., nope_dim::2]
1047 od = o_f32[..., nope_dim + 1 :: 2]
1048 out[..., nope_dim::2] = e * cos + od * sin
1049 out[..., nope_dim + 1 :: 2] = od * cos - e * sin
1050 return out
1051
1052
1053 @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
1054 @pytest.mark.parametrize("num_tokens", [1, 17])
1055 @pytest.mark.parametrize(
1056 "head_dim,rope_dim,num_heads",
1057 [
1058 (512, 64, 8), # DeepseekV4CPUAttention._o_proj's actual shape
1059 (64, 64, 1), # nope_dim=0: no NoPE region at all
1060 ],
1061 )
1062 def test_inverse_gptj_rope_o_proj_cpu_kernel_matches_eager(
1063 dtype: torch.dtype,
1064 num_tokens: int,
1065 head_dim: int,
1066 rope_dim: int,
1067 num_heads: int,
1068 ):
1069 torch.manual_seed(0)
1070 max_pos = 4096
1071
1072 o = torch.randn(num_tokens, num_heads, head_dim, dtype=dtype)
1073 positions = torch.randint(0, max_pos, (num_tokens,), dtype=torch.int64)
1074 cos_sin_cache = make_cos_sin_cache(max_pos, rope_dim, torch.float32, "cpu")
1075
1076 out_ref = _inverse_gptj_rope_o_proj_eager(o, positions, cos_sin_cache, rope_dim)
1077 out_fused = inverse_gptj_rope_o_proj_cpu(o, positions, cos_sin_cache, rope_dim)
1078
1079 torch.testing.assert_close(out_fused, out_ref, rtol=1e-2, atol=1e-2)
1080
1081
1082 # ---------------------------------------------------------------------------
1083 # fp8_paged_mqa_logits_cpu (paged_mqa_logits.cpp): sparse indexer's
1084 # decode-path MQA-logits kernel
1085 # ---------------------------------------------------------------------------
1086
1087
1088 def _rand_fp8(*shape: int) -> torch.Tensor:
1089 return torch.randn(shape, dtype=torch.float32).clamp(-3, 3).to(torch.float8_e4m3fn)
1090
1091
1092 def _pages_per_request(block_size: int, seq_lens: list[int]) -> list[int]:
1093 return [-(-seq_len // block_size) for seq_len in seq_lens]
1094
1095
1096 def _write_page(
1097 page_row: torch.Tensor, block_size: int, k_fp8: torch.Tensor, scale: torch.Tensor
1098 ) -> None:
1099 """Write k_fp8 ([block_size, INDEXER_HEAD_DIM]) and scale ([block_size]
1100 fp32) into one page row: K-region then scale-region, matching
1101 compress_norm_rope_store_indexer_cpu's layout."""
1102 k_region = page_row[: block_size * INDEXER_HEAD_DIM]
1103 k_region.copy_(k_fp8.view(torch.uint8).flatten())
1104 scale_region = page_row[block_size * INDEXER_HEAD_DIM :].view(torch.float32)
1105 scale_region[:].copy_(scale)
1106
1107
1108 def _reference_paged_mqa_logits(
1109 q_fp8: torch.Tensor,
1110 weight: torch.Tensor,
1111 ref_k: list[torch.Tensor],
1112 ref_scale: list[torch.Tensor],
1113 max_seq_len: int,
1114 ) -> torch.Tensor:
1115 batch_size, _num_heads, _ = q_fp8.shape
1116 out = torch.zeros((batch_size, max_seq_len), dtype=torch.float32)
1117 for b in range(batch_size):
1118 k = ref_k[b].float() # [seq_len, head_dim]
1119 scale = ref_scale[b] # [seq_len]
1120 q = q_fp8[b].float() # [heads, head_dim]
1121 w = weight[b] # [heads]
1122 dot = torch.einsum("jd,hd->jh", k, q).clamp(min=0.0)
1123 score = (dot * w.unsqueeze(0)).sum(dim=1)
1124 out[b, : k.shape[0]] = score * scale
1125 return out
1126
1127
1128 def _build_paged_mqa_logits_batch(
1129 block_size: int,
1130 seq_lens: list[int],
1131 num_heads: int,
1132 pages: torch.Tensor,
1133 seed: int,
1134 ):
1135 """Fill pages (a pre-sized [n_pages, buf_width] uint8 tensor, possibly
1136 non-contiguous in dim 0) with deterministic-per-seed random K/scale
1137 data."""
1138 torch.manual_seed(seed)
1139 batch_size = len(seq_lens)
1140 max_seq_len = max(seq_lens)
1141 per_request = _pages_per_request(block_size, seq_lens)
1142 pages_per_batch = max(per_request)
1143 page_table = torch.full((batch_size, pages_per_batch), -1, dtype=torch.int32)
1144
1145 ref_k: list[torch.Tensor] = []
1146 ref_scale: list[torch.Tensor] = []
1147 next_page = 0
1148 for b, seq_len in enumerate(seq_lens):
1149 k_chunks = []
1150 scale_chunks = []
1151 for p in range(per_request[b]):
1152 page_table[b, p] = next_page
1153 k = _rand_fp8(block_size, INDEXER_HEAD_DIM)
1154 scale = torch.rand(block_size, dtype=torch.float32) * 0.05 + 0.01
1155 _write_page(pages[next_page], block_size, k, scale)
1156 tokens_here = min(block_size, seq_len - p * block_size)
1157 k_chunks.append(k[:tokens_here])
1158 scale_chunks.append(scale[:tokens_here])
1159 next_page += 1
1160 ref_k.append(torch.cat(k_chunks, dim=0))
1161 ref_scale.append(torch.cat(scale_chunks, dim=0))
1162
1163 q_fp8 = _rand_fp8(batch_size, num_heads, INDEXER_HEAD_DIM)
1164 weight = torch.rand(batch_size, num_heads, dtype=torch.float32) * 0.1
1165 seq_lens_t = torch.tensor(seq_lens, dtype=torch.int32)
1166 return q_fp8, weight, seq_lens_t, page_table, ref_k, ref_scale, max_seq_len
1167
1168
1169 def _paged_mqa_logits_page_layout(
1170 block_size: int, seq_lens: list[int]
1171 ) -> tuple[int, int]:
1172 n_pages = sum(_pages_per_request(block_size, seq_lens))
1173 buf_width = block_size * INDEXER_HEAD_DIM + block_size * INDEXER_SCALE_BYTES
1174 return n_pages, buf_width
1175
1176
1177 @pytest.mark.parametrize("block_size", [64, 256])
1178 def test_fp8_paged_mqa_logits_cpu_matches_eager(block_size: int):
1179 num_heads = 4
1180 seq_lens = [1, block_size + 7, 3 * block_size]
1181 n_pages, buf_width = _paged_mqa_logits_page_layout(block_size, seq_lens)
1182 pages = torch.zeros((n_pages, buf_width), dtype=torch.uint8)
1183
1184 q_fp8, weight, seq_lens_t, page_table, ref_k, ref_scale, max_seq_len = (
1185 _build_paged_mqa_logits_batch(block_size, seq_lens, num_heads, pages, seed=0)
1186 )
1187
1188 logits = fp8_paged_mqa_logits_cpu(
1189 q_fp8, pages, weight, seq_lens_t, page_table, block_size, max_seq_len
1190 )
1191 ref = _reference_paged_mqa_logits(q_fp8, weight, ref_k, ref_scale, max_seq_len)
1192
1193 for b, seq_len in enumerate(seq_lens):
1194 torch.testing.assert_close(
1195 logits[b, :seq_len], ref[b, :seq_len], atol=1e-2, rtol=1e-2
1196 )
1197
1198
1199 def test_fp8_paged_mqa_logits_cpu_non_contiguous_kv_cache_row_stride():
1200 """The paged K-cache in production is a per-layer view into a shared
1201 multi-layer allocation, so its page stride generally exceeds the page
1202 byte width -- check the kernel reads a padded/strided buffer identically
1203 to an equivalent tightly-packed one."""
1204 block_size = 64
1205 num_heads = 4
1206 seq_lens = [1, block_size + 7, 3 * block_size]
1207 n_pages, buf_width = _paged_mqa_logits_page_layout(block_size, seq_lens)
1208 cache_pad = 48
1209
1210 tight_pages = torch.zeros((n_pages, buf_width), dtype=torch.uint8)
1211 q_fp8, weight, seq_lens_t, page_table, _, _, max_seq_len = (
1212 _build_paged_mqa_logits_batch(
1213 block_size, seq_lens, num_heads, tight_pages, seed=0
1214 )
1215 )
1216
1217 padded = torch.zeros((n_pages, buf_width + cache_pad), dtype=torch.uint8)
1218 strided_pages = padded[:, :buf_width]
1219 _build_paged_mqa_logits_batch(
1220 block_size, seq_lens, num_heads, strided_pages, seed=0
1221 )
1222 assert strided_pages.stride(0) != strided_pages.size(1)
1223
1224 logits_tight = fp8_paged_mqa_logits_cpu(
1225 q_fp8, tight_pages, weight, seq_lens_t, page_table, block_size, max_seq_len
1226 )
1227 logits_strided = fp8_paged_mqa_logits_cpu(
1228 q_fp8, strided_pages, weight, seq_lens_t, page_table, block_size, max_seq_len
1229 )
1230 # Only [:seq_len] is ever written (and ever read downstream by
1231 # topk_transform_512_cpu) -- positions beyond that are uninitialized, so
1232 # comparing the full row would be comparing unrelated garbage.
1233 for b, seq_len in enumerate(seq_lens):
1234 torch.testing.assert_close(
1235 logits_tight[b, :seq_len], logits_strided[b, :seq_len]
1236 )
1237
1238
1239 # ---------------------------------------------------------------------------
1240 # fused_indexer_q_rope_quant_cpu (indexer.cpp): indexer Q-side RoPE + FP8
1241 # quant, and the dispatcher wiring that routes to it on CPU
1242 # ---------------------------------------------------------------------------
1243 #
1244 # Independent eager reference: this kernel's absmax reduction is over an
1245 # unusual mix (raw NoPE values plus a bf16-round-tripped RoPE half), which
1246 # per_token_group_quant_fp8 does not replicate. Allow <=1 fp8 ULP: the RoPE
1247 # halves round a value sitting exactly on a tie to either side.
1248
1249 INDEXER_Q_N_HEAD = 64
1250 INDEXER_Q_MAX_POS = 4096
1251
1252
1253 def _indexer_q_rope_quant_reference(
1254 positions: torch.Tensor,
1255 q: torch.Tensor,
1256 cos_sin_cache: torch.Tensor,
1257 weights: torch.Tensor,
1258 softmax_scale: float,
1259 head_scale: float,
1260 ) -> tuple[torch.Tensor, torch.Tensor]:
1261 _num_tokens, _num_heads, head_dim = q.shape
1262 half_rot_dim = cos_sin_cache.shape[-1] // 2
1263 rot_dim = 2 * half_rot_dim
1264 nope_dim = head_dim - rot_dim
1265
1266 q_f32 = q.float()
1267 cos_sin_f32 = cos_sin_cache.float()
1268 pos_cos_sin = cos_sin_f32[positions] # [T, rot_dim]
1269 cos = pos_cos_sin[:, :half_rot_dim].unsqueeze(1)
1270 sin = pos_cos_sin[:, half_rot_dim:].unsqueeze(1)
1271
1272 nope = q_f32[..., :nope_dim]
1273 rot = q_f32[..., nope_dim:]
1274 x_even = rot[..., 0::2]
1275 x_odd = rot[..., 1::2]
1276 r_even = (x_even * cos - x_odd * sin).to(torch.bfloat16).to(torch.float32)
1277 r_odd = (x_odd * cos + x_even * sin).to(torch.bfloat16).to(torch.float32)
1278 rope_out = torch.stack((r_even, r_odd), dim=-1).flatten(-2)
1279
1280 combined = torch.cat((nope, rope_out), dim=-1) # [T, H, head_dim]
1281 amax = combined.abs().amax(dim=-1).clamp(min=1e-4) # [T, H]
1282 exponent = torch.ceil(torch.log2(amax / FP8_MAX))
1283 scale = torch.exp2(exponent)
1284 inv_scale = scale.reciprocal().unsqueeze(-1)
1285 q_fp8 = (combined * inv_scale).clamp(-FP8_MAX, FP8_MAX).to(torch.float8_e4m3fn)
1286
1287 weights_out = weights.to(torch.float32) * scale * softmax_scale * head_scale
1288 return q_fp8, weights_out
1289
1290
1291 @pytest.mark.parametrize("num_tokens", [1, 257])
1292 def test_fused_indexer_q_rope_quant_cpu_matches_eager(num_tokens: int):
1293 torch.manual_seed(0)
1294
1295 q = torch.randn(num_tokens, INDEXER_Q_N_HEAD, INDEXER_HEAD_DIM, dtype=torch.float32)
1296 positions = torch.randint(0, INDEXER_Q_MAX_POS, (num_tokens,), dtype=torch.int64)
1297 cos_sin_cache = torch.randn(INDEXER_Q_MAX_POS, ROPE_DIM, dtype=torch.float32)
1298 weights = torch.randn(num_tokens, INDEXER_Q_N_HEAD, dtype=torch.float32)
1299 softmax_scale = INDEXER_HEAD_DIM**-0.5
1300 head_scale = INDEXER_Q_N_HEAD**-0.5
1301
1302 q_fp8_ref, weights_ref = _indexer_q_rope_quant_reference(
1303 positions, q, cos_sin_cache, weights, softmax_scale, head_scale
1304 )
1305
1306 q_fp8_fused = torch.empty_like(q, dtype=torch.float8_e4m3fn)
1307 weights_fused = torch.empty_like(weights, dtype=torch.float32)
1308 fused_indexer_q_rope_quant_cpu(
1309 positions,
1310 q,
1311 cos_sin_cache,
1312 q_fp8_fused,
1313 weights,
1314 softmax_scale,
1315 head_scale,
1316 weights_fused,
1317 )
1318
1319 max_ulp = int(fp8_ulp_distance(q_fp8_fused, q_fp8_ref).max().item())
1320 assert max_ulp <= 1, f"q_fp8 differs by {max_ulp} ULP (>1)"
1321 torch.testing.assert_close(weights_fused, weights_ref, rtol=1e-5, atol=1e-6)
1322
1323
1324 def test_fused_indexer_q_rope_quant_dispatches_to_cpu_kernel():
1325 """End-to-end wiring check: the production dispatcher must route to the
1326 CPU op on a CPU tensor, not silently fall through to the Triton kernel
1327 (which would also "work" under triton-cpu, masking a dispatch
1328 regression). The dispatcher does its CPU import inside the CPU branch,
1329 so the patch target is the source module."""
1330 torch.manual_seed(0)
1331 num_tokens = 1
1332
1333 q = torch.randn(num_tokens, INDEXER_Q_N_HEAD, INDEXER_HEAD_DIM, dtype=torch.float32)
1334 positions = torch.randint(0, INDEXER_Q_MAX_POS, (num_tokens,), dtype=torch.int64)
1335 cos_sin_cache = torch.randn(INDEXER_Q_MAX_POS, ROPE_DIM, dtype=torch.float32)
1336 weights = torch.randn(num_tokens, INDEXER_Q_N_HEAD, dtype=torch.float32)
1337 softmax_scale = INDEXER_HEAD_DIM**-0.5
1338 head_scale = INDEXER_Q_N_HEAD**-0.5
1339
1340 q_fp8_ref, weights_ref = _indexer_q_rope_quant_reference(
1341 positions, q, cos_sin_cache, weights, softmax_scale, head_scale
1342 )
1343
1344 with mock.patch("vllm._custom_ops.fused_indexer_q_rope_quant_cpu") as mocked:
1345 fused_indexer_q_rope_quant(
1346 positions,
1347 q.clone(),
1348 cos_sin_cache,
1349 weights,
1350 softmax_scale,
1351 head_scale,
1352 use_fp4=False,
1353 )
1354 assert mocked.called, (
1355 "fused_indexer_q_rope_quant did not dispatch to the CPU kernel "
1356 "on a CPU tensor"
1357 )
1358
1359 q_fp8_fused, weights_fused = fused_indexer_q_rope_quant(
1360 positions,
1361 q.clone(),
1362 cos_sin_cache,
1363 weights,
1364 softmax_scale,
1365 head_scale,
1366 use_fp4=False,
1367 )
1368 max_ulp = int(fp8_ulp_distance(q_fp8_fused, q_fp8_ref).max().item())
1369 assert max_ulp <= 1, f"q_fp8 differs by {max_ulp} ULP (>1)"
1370 torch.testing.assert_close(weights_fused, weights_ref, rtol=1e-5, atol=1e-6)
1371
1372
1373 # ---------------------------------------------------------------------------
1374 # mhc_pre_cpu / mhc_post_cpu / hc_head_fused_cpu (mhc.cpp): CPU ports of the
1375 # mHC gating kernels
1376 # ---------------------------------------------------------------------------
1377
1378
1379 def _rmsnorm_nw_torch(x: torch.Tensor, eps: float) -> torch.Tensor:
1380 """Weight-free RMSNorm over the last dim, matching hc_head_fused_cpu."""
1381 x_f32 = x.float()
1382 var = x_f32.square().mean(dim=-1, keepdim=True)
1383 return (x_f32 * torch.rsqrt(var + eps)).to(x.dtype)
1384
1385
1386 def _hc_head_torch(
1387 hidden_states: torch.Tensor,
1388 hc_fn: torch.Tensor,
1389 hc_scale: torch.Tensor,
1390 hc_base: torch.Tensor,
1391 rms_norm_eps: float,
1392 hc_eps: float,
1393 ) -> torch.Tensor:
1394 """Eager reference for hc_head_fused_cpu: round-trips the RMSNorm
1395 output through bf16 before the linear, matching the kernel's bf16
1396 arithmetic."""
1397 hc_mult, hidden_size = hidden_states.shape[-2:]
1398 outer_shape = hidden_states.shape[:-2]
1399 hs_flat = hidden_states.reshape(-1, hc_mult, hidden_size)
1400
1401 x_flat = hs_flat.flatten(-2)
1402 x_normed = _rmsnorm_nw_torch(x_flat, rms_norm_eps)
1403 mixes = torch.nn.functional.linear(x_normed.float(), hc_fn)
1404 pre = torch.sigmoid(mixes * hc_scale + hc_base) + hc_eps
1405
1406 out = torch.sum(pre.unsqueeze(-1) * hs_flat.float(), dim=1).to(hidden_states.dtype)
1407 return out.view(*outer_shape, hidden_size)
1408
1409
1410 @pytest.mark.parametrize("num_tokens", [1, 128])
1411 @pytest.mark.parametrize("hidden_size", [4096, 7168])
1412 def test_mhc_pre_cpu(num_tokens: int, hidden_size: int):
1413 """mhc_pre_cpu vs. the mhc_pre_torch eager reference it replaces."""
1414 torch.set_default_device(DEVICE)
1415 set_random_seed(0)
1416 hc_mult = 4
1417
1418 hc_mult3 = 2 * hc_mult + hc_mult * hc_mult
1419 residual = torch.randn((num_tokens, hc_mult, hidden_size), dtype=torch.bfloat16)
1420 fn = torch.randn((hc_mult3, hc_mult * hidden_size), dtype=torch.float32) * 1e-4
1421 hc_scale = torch.randn((3,), dtype=torch.float32) * 0.1
1422 hc_base = torch.randn((hc_mult3,), dtype=torch.float32) * 0.1
1423
1424 rms_eps = hc_eps = 1e-6
1425 hc_post_mult_value = 2.0
1426 sinkhorn_repeat = 8
1427
1428 ref = mhc_kernels.mhc_pre_torch(
1429 residual,
1430 fn,
1431 hc_scale,
1432 hc_base,
1433 rms_eps,
1434 hc_eps,
1435 hc_eps,
1436 hc_post_mult_value,
1437 sinkhorn_repeat,
1438 )
1439 out = mhc_kernels.mhc_pre_cpu(
1440 residual,
1441 fn,
1442 hc_scale,
1443 hc_base,
1444 rms_eps,
1445 hc_eps,
1446 hc_eps,
1447 hc_post_mult_value,
1448 sinkhorn_repeat,
1449 )
1450
1451 for actual, expected in zip(out, ref, strict=True):
1452 torch.testing.assert_close(actual, expected, atol=5e-2, rtol=1e-2)
1453
1454
1455 @pytest.mark.parametrize("num_tokens", [1, 128])
1456 @pytest.mark.parametrize("hidden_size", [4096, 7168])
1457 def test_mhc_post_cpu(num_tokens: int, hidden_size: int):
1458 """mhc_post_cpu vs. the mhc_post_torch eager reference it replaces."""
1459 torch.set_default_device(DEVICE)
1460 set_random_seed(0)
1461 hc_mult = 4
1462
1463 x = torch.randn((num_tokens, hidden_size), dtype=torch.bfloat16)
1464 residual = torch.randn((num_tokens, hc_mult, hidden_size), dtype=torch.bfloat16)
1465 post_layer_mix = torch.randn((num_tokens, hc_mult, 1), dtype=torch.float32)
1466 comb_res_mix = torch.randn((num_tokens, hc_mult, hc_mult), dtype=torch.float32)
1467
1468 ref = mhc_kernels.mhc_post_torch(x, residual, post_layer_mix, comb_res_mix)
1469 out = mhc_kernels.mhc_post_cpu(x, residual, post_layer_mix, comb_res_mix)
1470
1471 torch.testing.assert_close(out, ref, atol=5e-2, rtol=1e-2)
1472
1473
1474 @pytest.mark.parametrize("num_tokens", [1, 128])
1475 @pytest.mark.parametrize("hidden_size", [4096, 7168])
1476 def test_hc_head_cpu(num_tokens: int, hidden_size: int):
1477 """hc_head_fused_cpu vs. the _hc_head_torch eager reference it
1478 replaces."""
1479 torch.set_default_device(DEVICE)
1480 set_random_seed(0)
1481 hc_mult = 4
1482
1483 residual = torch.randn((num_tokens, hc_mult, hidden_size), dtype=torch.bfloat16)
1484 fn = torch.randn((hc_mult, hc_mult * hidden_size), dtype=torch.float32) * 1e-4
1485 hc_scale = torch.randn((1,), dtype=torch.float32) * 0.1
1486 hc_base = torch.randn((hc_mult,), dtype=torch.float32) * 0.1
1487 rms_eps = hc_eps = 1e-6
1488
1489 ref = _hc_head_torch(residual, fn, hc_scale, hc_base, rms_eps, hc_eps)
1490 out = mhc_kernels.hc_head_fused_cpu(
1491 residual, fn, hc_scale, hc_base, rms_eps, hc_eps
1492 )
1493
1494 torch.testing.assert_close(out, ref, atol=5e-2, rtol=1e-2)