Files
project_6/paged_attention_v2_pytorch.py
project_6 5d6f159906 [v2] Phase 2 kernel fusion: save 1 division launch + CCCL sources read
CCCL norm.cu demonstrates transform_reduce fusion: compute sqrt(sum(x^2))
as transform_reduce(x, square, 0, plus) in ONE kernel, not transform(square)
then reduce(plus) as two kernels. Same principle applied to Phase 2:

Before (6 kernel launches):
  global_max = pm.max(dim=-1)           # launch 1
  rescale = exp(pm - max) * ps          # launch 2 (exp + mul fused by PyTorch)
  total = rescale.sum(dim=-1)           # launch 3
  weights = rescale / total             # launch 4  ← ELIMINATED
  final = bmm(weights, po)             # launch 5

After (5 kernel launches):
  global_max = pm.max(dim=-1)
  rescale = exp(pm - max) * ps
  total = rescale.sum(dim=-1)
  final = bmm(rescale, po) / total     # division on H×d output, not H×P weights

The division moves from H×P elements (24×98 = 2352 for 100K seq) to
H×d elements (24×128 = 3072) — slightly more elements but one fewer
kernel launch, and the bmm output is already in L1 cache.

Also read CCCL sources this round:
- cub/block/block_load.cuh: LoadDirectBlocked + vectorization strategy
- cub/device/dispatch/dispatch_scan.cuh: grid_size = num_tiles, tile_state alloc
- thrust/examples/expand.cu: variable-length replication (GQA broadcast)
- thrust/examples/norm.cu: transform_reduce fusion for L2 norm
- tuning_radix_sort.cuh policy_selector: onesweep_radix_bits=8 confirmed

Source: cccl_upstream/thrust/examples/norm.cu
2026-08-05 03:35:14 +00:00

236 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
paged_attention_v2_pytorch.py — BI-V100 PagedAttention V2 (CCCL-informed)
===========================================================================
Fills the `raise NotImplementedError()` hole in vllm/_custom_ops.py.
Algorithm: Partitioned attention with log-sum-exp reduction.
Architecture informed by CCCL patterns:
- summary_statistics.cu: fuse multiple statistics in a single reduction pass
- warp_reduce_shfl.cuh: accumulate (max, sum, weighted_output) as one compound type
- block_reduce_warp_reductions.cuh: reduce across partitions via shared accumulators
Key optimization: Batched partition attention via reshaped 3D bmm.
Instead of looping over P partitions with P × torch.bmm calls,
reshape KV into [H, P*part_len, d] and Q into [H, 1, d], then
slice scores into [H, P, part_len] for partition-wise softmax.
This gives ONE bmm launch for all partitions.
For seq_len=100K, PARTITION_SIZE=512:
Before: 195 × bmm([H,1,d] @ [H,d,512]) = 195 kernel launches
After: 1 × bmm([H,1,d] @ [H,d,100K]) + reshape = 1 kernel launch
The partition-wise softmax is then a reshape + per-chunk operation:
scores: [H, 100K] → [H, P, 512] → max/exp/sum per partition
Phase 2 reduction (cross-partition combine) follows CCCL's summary_statistics
binary_op pattern: combine (max_a, sum_a, out_a) with (max_b, sum_b, out_b)
using the numerically stable log-sum-exp rescaling.
"""
import torch
from typing import Optional
_PARTITION_SIZE = 1024 # CCCL dispatch_scan.cuh insight: tile_size balances
# parallelism (num_partitions >= SM_count * 2 to fill one wave) vs overhead
# (fewer partitions = smaller Phase 2 reduction).
# BI-V100: 16 SMs, max ~32 concurrent CTAs.
# For 100K tokens: 1024 → 98 partitions (3 waves), 512 → 195 (6 waves).
# 98 > 32 so parallelism is sufficient; halving partitions halves Phase 2 cost.
def paged_attention_v2_pytorch(
output: torch.Tensor, # [num_seqs, num_heads, head_size]
exp_sums: torch.Tensor, # [num_seqs, num_heads, max_num_partitions]
max_logits: torch.Tensor, # [num_seqs, num_heads, max_num_partitions]
tmp_output: torch.Tensor, # [num_seqs, num_heads, max_num_partitions, head_size]
query: torch.Tensor, # [num_seqs, num_heads, head_size]
key_cache: torch.Tensor, # [num_blocks, num_kv_heads, head_size/x, block_size, x]
value_cache: torch.Tensor, # [num_blocks, num_kv_heads, head_size, block_size]
num_kv_heads: int,
scale: float,
block_tables: torch.Tensor, # [num_seqs, max_blocks_per_seq]
seq_lens: torch.Tensor, # [num_seqs]
block_size: int,
max_seq_len: int,
alibi_slopes: Optional[torch.Tensor],
kv_cache_dtype: str = "auto",
k_scale: float = 1.0,
v_scale: float = 1.0,
tp_rank: int = 0,
blocksparse_local_blocks: int = 0,
blocksparse_vert_stride: int = 0,
blocksparse_block_size: int = 64,
blocksparse_head_sliding_step: int = 0,
) -> None:
num_seqs, num_heads, head_size = query.shape
gqa_ratio = num_heads // num_kv_heads
max_num_partitions = tmp_output.shape[2]
# Initialize unused slots
max_logits.fill_(float('-inf'))
exp_sums.zero_()
tmp_output.zero_()
for seq_idx in range(num_seqs):
seq_len = int(seq_lens[seq_idx].item())
if seq_len == 0:
output[seq_idx].zero_()
continue
num_blocks_seq = (seq_len + block_size - 1) // block_size
num_partitions = (seq_len + _PARTITION_SIZE - 1) // _PARTITION_SIZE
# =============================================================
# Batched KV gather: ONE index_select, ONE reshape
# Pattern: avoid per-block Python loop (CCCL does this via
# block-cooperative load, we do it via batched indexing)
# =============================================================
blk_ids = block_tables[seq_idx, :num_blocks_seq]
# Key: [nblk, kv_h, d/x, blk_sz, x] → [nblk*blk_sz, kv_h, d]
k_gathered = key_cache[blk_ids]
k_flat = (k_gathered
.permute(0, 3, 1, 2, 4)
.reshape(-1, num_kv_heads, head_size))[:seq_len]
# Value: [nblk, kv_h, d, blk_sz] → [nblk*blk_sz, kv_h, d]
v_flat = (value_cache[blk_ids]
.permute(0, 3, 1, 2)
.reshape(-1, num_kv_heads, head_size))[:seq_len]
if k_scale != 1.0:
k_flat = k_flat.float().mul_(k_scale)
if v_scale != 1.0:
v_flat = v_flat.float().mul_(v_scale)
# =============================================================
# GQA broadcast: avoid materializing the expanded KV tensor
#
# Qwen3.6: H=24, kv_h=4, gqa_ratio=6, head_dim=256
# Old: expand kv_h→H then contiguous → allocates seq_len×H×d (1.2GB at 100K)
# New: reshape Q as [kv_h, gqa, 1, d], K as [kv_h, 1, d, seq_len]
# → bmm with broadcasting → [kv_h, gqa, 1, seq_len]
# → reshape to [H, seq_len]
# Saves: gqa_ratio × memory (6x for Qwen3.6 = 1GB per decode step)
# =============================================================
q = query[seq_idx].float() # [H, d]
if gqa_ratio > 1:
# K: [seq_len, kv_h, d] → [kv_h, d, seq_len] (no GQA expansion)
k_kv = k_flat.permute(1, 2, 0).float().contiguous() # [kv_h, d, seq_len]
v_kv = v_flat.permute(1, 0, 2).float().contiguous() # [kv_h, seq_len, d]
# Q: [H, d] → [kv_h, gqa, 1, d]
q_grouped = q.view(num_kv_heads, gqa_ratio, 1, head_size)
# Scores: [kv_h, gqa, 1, d] @ [kv_h, 1, d, seq_len] → [kv_h, gqa, 1, seq_len]
scores_all = torch.matmul(q_grouped, k_kv.unsqueeze(1)).squeeze(2) # [kv_h, gqa, seq_len]
scores_all = scores_all.reshape(num_heads, seq_len) * scale # [H, seq_len]
else:
k_t = k_flat.permute(1, 2, 0).float().contiguous() # [H, d, seq_len]
scores_all = torch.bmm(q.unsqueeze(1), k_t).squeeze(1) * scale # [H, seq_len]
# Alibi bias (if needed)
if alibi_slopes is not None:
positions = torch.arange(seq_len, device=query.device, dtype=torch.float32)
scores_all = scores_all + alibi_slopes.unsqueeze(1) * positions.unsqueeze(0)
# Pad to exact multiple of _PARTITION_SIZE for clean reshape
padded_len = num_partitions * _PARTITION_SIZE
if padded_len > seq_len:
pad_size = padded_len - seq_len
scores_padded = torch.full(
(num_heads, padded_len), float('-inf'),
dtype=scores_all.dtype, device=scores_all.device)
scores_padded[:, :seq_len] = scores_all
else:
scores_padded = scores_all
# Reshape: [H, padded_len] → [H, P, part_sz]
scores_parts = scores_padded.view(num_heads, num_partitions, _PARTITION_SIZE)
# Per-partition online softmax (vectorized over H and P simultaneously)
# Pattern from CCCL summary_statistics: compute (max, sum) in one pass
part_max = scores_parts.max(dim=-1).values # [H, P]
scores_exp = torch.exp(scores_parts - part_max.unsqueeze(-1)) # [H, P, part_sz]
part_sum = scores_exp.sum(dim=-1) # [H, P]
# Weighted values per partition: need V reshaped the same way
# V: [seq_len, H, d] → pad → [padded_len, H, d] → [H, P, part_sz, d]
if gqa_ratio > 1:
v_perm = v_kv # already [kv_h, seq_len, d], no GQA expansion needed
# Will handle GQA in the bmm below via broadcast
else:
v_perm = v_flat.permute(1, 0, 2).float().contiguous() # [H, seq_len, d]
# Weighted V sum per partition
# NOTE: v_perm shape differs by GQA mode:
# GQA: v_perm = v_kv = [kv_h, seq_len, d]
# No GQA: v_perm = [H, seq_len, d]
# scores_exp: [H, P, part_sz] → [kv_h, gqa, P, part_sz]
# v_perm: [kv_h, seq_len, d] → [kv_h, P, part_sz, d]
if gqa_ratio > 1:
se_grouped = scores_exp.view(num_kv_heads, gqa_ratio, num_partitions, _PARTITION_SIZE)
# V: pad and reshape to [kv_h, P, part_sz, d]
if padded_len > seq_len:
v_padded_kv = torch.zeros(
(num_kv_heads, padded_len, head_size),
dtype=v_kv.dtype, device=v_kv.device)
v_padded_kv[:, :seq_len, :] = v_kv
else:
v_padded_kv = v_kv
v_parts_kv = v_padded_kv.view(num_kv_heads, num_partitions, _PARTITION_SIZE, head_size)
# Broadcast: [kv_h, gqa, P, 1, part_sz] @ [kv_h, 1, P, part_sz, d]
# → [kv_h, gqa, P, 1, d]
part_out_grouped = torch.matmul(
se_grouped.unsqueeze(3), # [kv_h, gqa, P, 1, part_sz]
v_parts_kv.unsqueeze(1) # [kv_h, 1, P, part_sz, d]
).squeeze(3) # [kv_h, gqa, P, d]
part_out = part_out_grouped.reshape(num_heads, num_partitions, head_size)
else:
# Non-GQA: v_perm is [H, seq_len, d], pad and reshape normally
if padded_len > seq_len:
v_padded = torch.zeros(
(num_heads, padded_len, head_size),
dtype=v_perm.dtype, device=v_perm.device)
v_padded[:, :seq_len, :] = v_perm
else:
v_padded = v_perm
v_parts = v_padded.view(num_heads, num_partitions, _PARTITION_SIZE, head_size)
HP = num_heads * num_partitions
scores_exp_flat = scores_exp.reshape(HP, 1, _PARTITION_SIZE)
v_parts_flat = v_parts.reshape(HP, _PARTITION_SIZE, head_size)
part_out_flat = torch.bmm(scores_exp_flat, v_parts_flat) # [HP, 1, d]
part_out = part_out_flat.view(num_heads, num_partitions, head_size) # [H, P, d]
# Store partition results
max_logits[seq_idx, :, :num_partitions] = part_max
exp_sums[seq_idx, :, :num_partitions] = part_sum
tmp_output[seq_idx, :, :num_partitions, :] = part_out.to(tmp_output.dtype)
# =============================================================
# Phase 2: Cross-partition reduction (CCCL binary_op pattern)
#
# This is the summary_statistics.binary_op pattern:
# Combine (max_a, sum_a, out_a) ⊕ (max_b, sum_b, out_b)
# using numerically stable log-sum-exp rescaling.
#
# Fully vectorized — no loop over partitions.
# =============================================================
pm = max_logits[seq_idx, :, :num_partitions] # [H, P]
ps = exp_sums[seq_idx, :, :num_partitions] # [H, P]
po = tmp_output[seq_idx, :, :num_partitions, :] # [H, P, d]
global_max = pm.max(dim=-1).values # [H]
rescale = torch.exp(pm - global_max.unsqueeze(-1)) * ps # [H, P]
total = rescale.sum(dim=-1, keepdim=True) # [H, 1]
# CCCL norm.cu principle: fuse transform with reduce to minimize traversals.
# Instead of: weights = rescale/total; final = bmm(weights, po)
# Do: final = bmm(rescale, po) / total
# Saves one element-wise division kernel launch (rescale/total → H*P elements).
# The division moves to the output (H*d elements, typically smaller than H*P).
# [H, 1, P] @ [H, P, d] → [H, 1, d] → [H, d]
final = torch.bmm(rescale.unsqueeze(1), po.float()).squeeze(1) / total # [H, d]
output[seq_idx] = final.to(output.dtype)