[AttentionBackend][HPC-ops] update hpc rope norm to support stride kv cache (#53705)

Signed-off-by: chengvjiang <chengvjiang@tencent.com> Co-authored-by: chengvjiang <chengvjiang@tencent.com> Co-authored-by: Yongye Zhu <zyy1102000@gmail.com>

Cheng Jiang committed Aug 26, 2026 at 16:20 UTC 2267d3b1124a8b93c6fa5cb4e53cc8dae88b3c06
1 file changed +202 -17
vllm/model_executor/layers/hpc/rope_norm.py
+202 -17
@@ -21,6 +21,7 @@ from vllm.model_executor.layers.hpc.hpc_module import HpcModule
21 from vllm.utils.torch_utils import direct_register_custom_op
22 from vllm.v1.attention.backends.hpc_attn import HpcAttnMetadata
23 from vllm.v1.attention.backends.registry import AttentionBackendEnum
24 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
25
26 logger = init_logger(__name__)
27
@@ -280,6 +281,111 @@ class HpcRopeNorm(CustomOp, HpcModule):
281 torch.ops.vllm.hpc_rope_norm_forward(qkv, output, layer_name)
282 return output
283
284 + def _kv_write_scratch(self, cache: torch.Tensor) -> torch.Tensor:
285 + """Throwaway dense K/V pages for the stride-aware fallback path.
286 +
287 + ``hpc.rope_norm_store_kv[_fp8]`` always writes the paged cache itself
288 + (rotated K/V *and* zero padding for the tail of every request's last
289 + block), addressing it densely. When the real pages are not dense those
290 + writes would land on unrelated blocks, so they are redirected here by
291 + pairing this scratch cache with an all-zero block table; the real data
292 + is taken out through ``out_k``/``out_v`` instead.
293 +
294 + Returns a (2, 1, block_size, num_kv_heads, head_dim) tensor: index 0 is
295 + the key scratch page, index 1 the value scratch page.
296 + """
297 + block_size = cache.shape[1]
298 + key = (cache.dtype, block_size, cache.device)
299 + if getattr(self, "_kv_scratch_key", None) != key:
300 + self._kv_scratch = torch.zeros(
301 + 2,
302 + 1,
303 + block_size,
304 + self.num_kv_heads,
305 + self.head_dim,
306 + dtype=cache.dtype,
307 + device=cache.device,
308 + )
309 + self._kv_scratch_key = key
310 + return self._kv_scratch
311 +
312 + @staticmethod
313 + def _zero_pad_last_blocks(
314 + key_cache: torch.Tensor,
315 + value_cache: torch.Tensor,
316 + seq_lens: torch.Tensor,
317 + block_table: torch.Tensor,
318 + ) -> None:
319 + """Zero the unused tail slots of each request's last block.
320 +
321 + The HPC attention kernels read whole blocks, so the slots past
322 + ``seq_len`` must be zero. The fused op normally does this itself; on the
323 + fallback path its (densely addressed) padding writes are discarded, so
324 + the padding is reapplied here through the real, strided cache views.
325 +
326 + Shapes are static and no value is inspected on the host, so this stays
327 + CUDA-graph capturable. It is also self-contained: each request's last
328 + block is read, masked and written back, so slots that must be preserved
329 + (and requests padded in for graph capture, whose ``seq_len`` is 0) keep
330 + their previous contents instead of being redirected somewhere.
331 + """
332 + block_size = key_cache.shape[1]
333 + seq_lens = seq_lens.long()
334 + # seq_len == 0 only happens for graph-capture padding; clamping keeps
335 + # the gather in bounds and the all-False mask below makes the write a
336 + # no-op for those rows.
337 + last_block_idx = (seq_lens - 1).clamp_min(0) // block_size
338 + last_block_idx = last_block_idx.clamp_max(block_table.shape[1] - 1)
339 + last_block = block_table.gather(1, last_block_idx.unsqueeze(1)).squeeze(1)
340 + last_block = last_block.long()
341 +
342 + remainder = seq_lens % block_size
343 + # A block that ends exactly on the boundary has no tail to clear.
344 + remainder = torch.where(remainder == 0, block_size, remainder)
345 +
346 + offsets = torch.arange(block_size, device=key_cache.device)
347 + is_tail = (offsets.unsqueeze(0) >= remainder.unsqueeze(1)) & (
348 + seq_lens > 0
349 + ).unsqueeze(1)
350 + is_tail = is_tail[..., None, None]
351 +
352 + for cache in (key_cache, value_cache):
353 + view = cache
354 + if view.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
355 + view = view.view(torch.uint8)
356 + blocks = view.index_select(0, last_block)
357 + blocks = torch.where(is_tail, blocks.new_zeros(()), blocks)
358 + view.index_copy_(0, last_block, blocks)
359 +
360 + @staticmethod
361 + def _scatter_kv_cache(
362 + cache: torch.Tensor,
363 + src: torch.Tensor,
364 + slot_mapping: torch.Tensor,
365 + ) -> None:
366 + """Write dense per-token K (or V) into a paged cache view.
367 +
368 + Used on the stride-aware fallback path (see ``_forward_impl``).
369 + ``cache`` is a (num_blocks, block_size, num_kv_heads, head_dim) view
370 + with arbitrary block/token/head strides; ``src`` is the dense
371 + (num_tokens, num_kv_heads, head_dim) buffer produced by the fused HPC
372 + kernel. FP8 payloads are moved as raw bytes because ``index_copy_``
373 + has no FP8 kernel.
374 +
375 + Padded tokens carry ``PAD_SLOT_ID`` (-1); clamping sends them to the
376 + reserved null block instead of tripping ``index_copy_``'s bounds check.
377 + This keeps the op CUDA-graph safe (static shape, no host sync), unlike
378 + masking the padded rows out.
379 + """
380 + # (num_blocks, block_size, ...) -> (num_slots, num_kv_heads, head_dim).
381 + # Valid as a view for any layout whose block stride is a whole number
382 + # of token strides, which holds for every layout the backend accepts.
383 + flat = cache.flatten(0, 1)
384 + if flat.dtype in (torch.float8_e4m3fn, torch.float8_e5m2):
385 + flat = flat.view(torch.uint8)
386 + src = src.view(torch.uint8)
387 + flat.index_copy_(0, slot_mapping.clamp_min(NULL_BLOCK_ID), src)
388 +
389 def _forward_impl(
390 self,
391 qkv: torch.Tensor,
@@ -295,9 +401,32 @@ class HpcRopeNorm(CustomOp, HpcModule):
401 """
402 import hpc
403
404 + # KV cache for the FP8 path is stored as uint8; view it as fp8 so the
405 + # rope_norm_store_kv_fp8 kernel can write quantized K/V in-place.
406 + # Must happen *before* deriving the K/V views, so that they (and any
407 + # buffer whose dtype is taken from them) carry the FP8 dtype.
408 + if self.use_fp8:
409 + kv_cache = kv_cache.view(torch.float8_e4m3fn)
410 +
411 # (B, H, N, 2*hs) -> two (B, N, H, hs) K/V views, as in HpcAttentionImpl.
412 key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_dim, dim=-1)
413
414 + # hpc.rope_norm_store_kv[_fp8] addresses its K/V caches as *dense*
415 + # NHD tensors and ignores their strides, so it can only write the cache
416 + # in place when both views are contiguous. Packed layouts (the backend's
417 + # LBNHC pages interleave K and V inside the state-content dim, so each
418 + # split view has a token stride of 2 * head_dim) must instead take the
419 + # rotated K/V out through out_k/out_v and be scattered separately.
420 + fused_kv_write = key_cache.is_contiguous() and value_cache.is_contiguous()
421 + if not fused_kv_write:
422 + logger.warning_once(
423 + "HpcRopeNorm: KV cache pages interleave K and V, which "
424 + "hpc.rope_norm_store_kv[_fp8] cannot address, so K/V are "
425 + "written by a separate scatter. Update hpc-ops to a build with "
426 + "strided KV cache support to re-enable the fully fused write."
427 + )
428 + slot_mapping = attn_metadata.slot_mapping
429 +
430 num_actual_tokens = attn_metadata.num_actual_tokens
431 num_prefill_reqs = attn_metadata.num_prefills
432 num_decode_reqs = attn_metadata.num_decodes
@@ -307,11 +436,6 @@ class HpcRopeNorm(CustomOp, HpcModule):
436
437 num_prefill_tokens = num_actual_tokens - num_decode_tokens
438
310 - # KV cache for the FP8 path is stored as uint8; view it as fp8 so the
311 - # rope_norm_store_kv_fp8 kernel can write quantized K/V in-place.
312 - if self.use_fp8:
313 - kv_cache = kv_cache.view(torch.float8_e4m3fn)
314 -
439 # Per-tensor K/V scales (shape [1]) used by the FP8 kernel.
440 k_scale = attn_layer._k_scale.reshape(1)
441 v_scale = attn_layer._v_scale.reshape(1)
@@ -334,15 +458,35 @@ class HpcRopeNorm(CustomOp, HpcModule):
458 num_decode_tokens : num_decode_tokens + num_prefill_tokens
459 ]
460
461 + kv_dtype = key_cache.dtype
462 + if fused_kv_write:
463 + out_k = out_v = None
464 + kcache_arg, vcache_arg = key_cache, value_cache
465 + kv_indices_arg = block_table_prefill
466 + else:
467 + out_k = torch.empty(
468 + num_prefill_tokens,
469 + self.num_kv_heads,
470 + self.head_dim,
471 + dtype=kv_dtype,
472 + device=qkv.device,
473 + )
474 + out_v = torch.empty_like(out_k)
475 + # Send the kernel's own cache writes to scratch (all block ids
476 + # remapped to the single scratch page).
477 + scratch = self._kv_write_scratch(key_cache)
478 + kcache_arg, vcache_arg = scratch[0], scratch[1]
479 + kv_indices_arg = torch.zeros_like(block_table_prefill)
480 +
481 if self.use_fp8:
482 _, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
339 - key_cache=key_cache,
340 - value_cache=value_cache,
483 + key_cache=kcache_arg,
484 + value_cache=vcache_arg,
485 qkv=qkv_prefill,
486 cos_sin=self.cos_sin_cache,
487 num_seqlen_per_req=seq_lens_prefill,
488 q_index=cu_seqlens_prefill,
345 - kvcache_indices=block_table_prefill,
489 + kvcache_indices=kv_indices_arg,
490 is_prefill=True,
491 k_scale=k_scale,
492 v_scale=v_scale,
@@ -352,24 +496,36 @@ class HpcRopeNorm(CustomOp, HpcModule):
496 k_norm_weight=k_norm_weight,
497 qk_norm_policy=self.qk_norm_policy,
498 out_q=out_q_prefill,
499 + out_k=out_k,
500 + out_v=out_v,
501 )
502 attn_metadata.hpc_prefill_q_scale = q_scale
503 else:
504 hpc.rope_norm_store_kv(
359 - key_cache,
360 - value_cache,
505 + kcache_arg,
506 + vcache_arg,
507 qkv_prefill,
508 self.cos_sin_cache,
509 seq_lens_prefill,
510 cu_seqlens_prefill,
365 - block_table_prefill,
511 + kv_indices_arg,
512 True, # is_prefill
513 q_norm_weight=q_norm_weight,
514 k_norm_weight=k_norm_weight,
515 out_q=out_q_prefill,
516 + out_k=out_k,
517 + out_v=out_v,
518 qk_norm_policy=self.qk_norm_policy,
519 )
520
521 + if out_k is not None:
522 + self._zero_pad_last_blocks(
523 + key_cache, value_cache, seq_lens_prefill, block_table_prefill
524 + )
525 + prefill_slots = slot_mapping[num_decode_tokens:num_actual_tokens]
526 + self._scatter_kv_cache(key_cache, out_k, prefill_slots)
527 + self._scatter_kv_cache(value_cache, out_v, prefill_slots)
528 +
529 # --- Decode ---
530 if num_decode_reqs > 0:
531 num_seq_kvcache = attn_metadata.seq_lens[:num_decode_reqs]
@@ -380,15 +536,32 @@ class HpcRopeNorm(CustomOp, HpcModule):
536 decode_query_len = attn_metadata.decode_query_len
537 out_q_decode = output[:num_decode_tokens]
538
539 + if fused_kv_write:
540 + out_k = out_v = None
541 + kcache_arg, vcache_arg = key_cache, value_cache
542 + kv_indices_arg = block_table_decode
543 + else:
544 + out_k = torch.empty(
545 + num_decode_tokens,
546 + self.num_kv_heads,
547 + self.head_dim,
548 + dtype=key_cache.dtype,
549 + device=qkv.device,
550 + )
551 + out_v = torch.empty_like(out_k)
552 + scratch = self._kv_write_scratch(key_cache)
553 + kcache_arg, vcache_arg = scratch[0], scratch[1]
554 + kv_indices_arg = torch.zeros_like(block_table_decode)
555 +
556 if self.use_fp8:
557 _, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
385 - key_cache=key_cache,
386 - value_cache=value_cache,
558 + key_cache=kcache_arg,
559 + value_cache=vcache_arg,
560 qkv=qkv_decode,
561 cos_sin=self.cos_sin_cache,
562 num_seqlen_per_req=num_seq_kvcache,
563 q_index=attn_metadata.qo_indptr_decode,
391 - kvcache_indices=block_table_decode,
564 + kvcache_indices=kv_indices_arg,
565 is_prefill=False,
566 k_scale=k_scale,
567 v_scale=v_scale,
@@ -398,26 +571,38 @@ class HpcRopeNorm(CustomOp, HpcModule):
571 k_norm_weight=k_norm_weight,
572 qk_norm_policy=self.qk_norm_policy,
573 out_q=out_q_decode,
574 + out_k=out_k,
575 + out_v=out_v,
576 )
577 attn_metadata.hpc_decode_q_scale = q_scale
578 if split_k_flag is not None:
579 attn_metadata.hpc_split_k_flag = split_k_flag
580 else:
581 hpc.rope_norm_store_kv(
407 - key_cache,
408 - value_cache,
582 + kcache_arg,
583 + vcache_arg,
584 qkv_decode,
585 self.cos_sin_cache,
586 num_seq_kvcache,
587 attn_metadata.qo_indptr_decode,
413 - block_table_decode,
588 + kv_indices_arg,
589 False, # is_prefill
590 q_norm_weight=q_norm_weight,
591 k_norm_weight=k_norm_weight,
592 out_q=out_q_decode,
593 + out_k=out_k,
594 + out_v=out_v,
595 qk_norm_policy=self.qk_norm_policy,
596 )
597
598 + if out_k is not None:
599 + self._zero_pad_last_blocks(
600 + key_cache, value_cache, num_seq_kvcache, block_table_decode
601 + )
602 + decode_slots = slot_mapping[:num_decode_tokens]
603 + self._scatter_kv_cache(key_cache, out_k, decode_slots)
604 + self._scatter_kv_cache(value_cache, out_v, decode_slots)
605 +
606 # Signal HpcAttentionImpl that KV cache has already been written by
607 # rope_norm_store_kv[_fp8] above, so it should skip its own
608 # reshape_and_cache_flash. Set after the fused kernels ran (either