Bug: if l_i > 0 branch in Triton is invalid (compiled as constexpr). Also: p = exp(scores - m_i_new) computed after m_i_new update was using the wrong reference max (should subtract m_ij first, then rescale). Fix: Adapted exactly from prefix_prefill.py's proven-correct pattern: p = exp(scores - m_ij) # probs relative to chunk max l_ij = sum(p) # chunk sum m_i_new = max(m_i, m_ij) # new running max alpha = exp(m_i - m_i_new) # old accumulator rescale beta = exp(m_ij - m_i_new) # new chunk rescale l_i_new = alpha*l_i + beta*l_ij acc = acc*(alpha*l_i/l_i_new) + (p*beta/l_i_new) @ V This is the Flash Attention online softmax tiling algorithm. Same math as CCCL's parallel_reduce with compound accumulators.
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""
|
||
paged_attention_v2_triton.py — Triton PagedAttention V2 for BI-V100
|
||
=====================================================================
|
||
|
||
Two-kernel V2 implementation using Triton:
|
||
Phase 1: _paged_attn_v2_partition — per-partition attention (paged K/V gather)
|
||
Phase 2: _paged_attn_v2_reduce — cross-partition log-sum-exp reduction
|
||
|
||
The K/V gather pattern is adapted from prefix_prefill.py (lines 100-170):
|
||
bn = tl.load(block_tables + seq * stride + (token // block_size) * stride)
|
||
off_k = bn * stride_kc_b + kv_head * stride_kc_h + (d // x) * stride_kc_dx + ...
|
||
k = tl.load(key_cache + off_k, mask=...)
|
||
|
||
For decode (BLOCK_M=1), the Q tile is just one vector [HEAD_DIM].
|
||
The inner loop iterates over BLOCK_N KV tokens per step.
|
||
Online softmax accumulates (max, sum, weighted_V) across steps.
|
||
|
||
After all steps in a partition, we have:
|
||
max_logits[seq, head, part]: running max
|
||
exp_sums[seq, head, part]: running exp sum
|
||
tmp_output[seq, head, part, :]: unnormalized weighted V
|
||
|
||
Phase 2 combines partitions using the CCCL summary_statistics pattern:
|
||
global_max = max(part_maxes)
|
||
rescaled_sum = sum(exp(part_max - global_max) * part_sum)
|
||
output = sum(weight[p] * part_output[p])
|
||
|
||
SMEM analysis:
|
||
Phase 1: K tile [BLOCK_N, HEAD_DIM] loaded via gather (no explicit SMEM tile)
|
||
Triton manages register allocation for tl.load + tl.dot
|
||
At BLOCK_N=32, HEAD_DIM=256: 32×256 fp16 values in registers = 16KB
|
||
Phase 2: No SMEM needed (partitions ≈ 200, all in registers)
|
||
"""
|
||
|
||
import torch
|
||
import triton
|
||
import triton.language as tl
|
||
from typing import Optional
|
||
|
||
|
||
@triton.jit
|
||
def _paged_attn_v2_partition_kernel(
|
||
# Outputs
|
||
tmp_output_ptr, # [num_seqs, num_heads, max_num_parts, head_size]
|
||
exp_sums_ptr, # [num_seqs, num_heads, max_num_parts]
|
||
max_logits_ptr, # [num_seqs, num_heads, max_num_parts]
|
||
# Inputs
|
||
query_ptr, # [num_seqs, num_heads, head_size]
|
||
key_cache_ptr, # [num_blocks, num_kv_heads, head_size/x, block_size, x]
|
||
value_cache_ptr, # [num_blocks, num_kv_heads, head_size, block_size]
|
||
block_tables_ptr, # [num_seqs, max_blocks_per_seq]
|
||
seq_lens_ptr, # [num_seqs]
|
||
# Scalars
|
||
scale: tl.float32,
|
||
num_queries_per_kv: tl.int32,
|
||
block_size: tl.int32,
|
||
x_pack: tl.int32, # key_cache packing factor: 16 // sizeof(dtype)
|
||
# Strides: query [S, H, D]
|
||
stride_qs: tl.int32, stride_qh: tl.int32, stride_qd: tl.int32,
|
||
# Strides: key_cache [B, KH, D/X, BS, X]
|
||
stride_kc_b: tl.int32, stride_kc_h: tl.int32,
|
||
stride_kc_dx: tl.int32, stride_kc_bs: tl.int32, stride_kc_x: tl.int32,
|
||
# Strides: value_cache [B, KH, D, BS]
|
||
stride_vc_b: tl.int32, stride_vc_h: tl.int32,
|
||
stride_vc_d: tl.int32, stride_vc_bs: tl.int32,
|
||
# Strides: block_tables [S, MAX_BLOCKS]
|
||
stride_bt_s: tl.int32, stride_bt_b: tl.int32,
|
||
# Strides: tmp_output [S, H, P, D]
|
||
stride_to_s: tl.int32, stride_to_h: tl.int32,
|
||
stride_to_p: tl.int32, stride_to_d: tl.int32,
|
||
# Strides: exp_sums / max_logits [S, H, P]
|
||
stride_es_s: tl.int32, stride_es_h: tl.int32, stride_es_p: tl.int32,
|
||
# Compile-time constants
|
||
PARTITION_SIZE: tl.constexpr,
|
||
HEAD_DIM: tl.constexpr,
|
||
BLOCK_N: tl.constexpr,
|
||
):
|
||
"""Phase 1: Per-partition paged attention for decode (BLOCK_M=1).
|
||
|
||
Grid: (num_seqs, num_heads, max_num_partitions)
|
||
Each program instance processes one (seq, head, partition) triple.
|
||
|
||
Adapted from prefix_prefill.py's paged K/V gather pattern.
|
||
Key difference: BLOCK_M=1 (decode has 1 query token per head).
|
||
"""
|
||
seq_idx = tl.program_id(0)
|
||
head_idx = tl.program_id(1)
|
||
part_idx = tl.program_id(2)
|
||
|
||
seq_len = tl.load(seq_lens_ptr + seq_idx)
|
||
part_start = part_idx * PARTITION_SIZE
|
||
part_end = tl.minimum(part_start + PARTITION_SIZE, seq_len)
|
||
|
||
if part_start >= seq_len:
|
||
# Unused partition — write sentinel values
|
||
tl.store(max_logits_ptr + seq_idx * stride_es_s + head_idx * stride_es_h + part_idx * stride_es_p,
|
||
float('-inf'))
|
||
tl.store(exp_sums_ptr + seq_idx * stride_es_s + head_idx * stride_es_h + part_idx * stride_es_p,
|
||
0.0)
|
||
return
|
||
|
||
# GQA: map query head → KV head
|
||
kv_head_idx = head_idx // num_queries_per_kv
|
||
|
||
# Load query vector: [HEAD_DIM]
|
||
offs_d = tl.arange(0, HEAD_DIM)
|
||
q = tl.load(query_ptr + seq_idx * stride_qs + head_idx * stride_qh + offs_d * stride_qd).to(tl.float32)
|
||
|
||
# Online softmax state
|
||
m_i = float('-inf') # running max
|
||
l_i = 0.0 # running exp sum
|
||
acc = tl.zeros([HEAD_DIM], dtype=tl.float32) # weighted V accumulator
|
||
|
||
# KV token offsets within each BLOCK_N chunk
|
||
offs_n = tl.arange(0, BLOCK_N)
|
||
|
||
# Iterate over BLOCK_N KV tokens at a time
|
||
for start_n in range(part_start, part_end, BLOCK_N):
|
||
# Token positions in the sequence
|
||
token_ids = start_n + offs_n
|
||
valid_mask = token_ids < part_end
|
||
|
||
# === Paged K gather (from prefix_prefill.py pattern) ===
|
||
# Look up physical block numbers from block_tables
|
||
block_indices = token_ids // block_size
|
||
within_block = token_ids % block_size
|
||
|
||
# bn: physical block ids [BLOCK_N]
|
||
bn = tl.load(
|
||
block_tables_ptr + seq_idx * stride_bt_s + block_indices * stride_bt_b,
|
||
mask=valid_mask, other=0)
|
||
|
||
# K offsets: key_cache[bn, kv_head, d//x, within_block, d%x]
|
||
# Layout: [num_blocks, num_kv_heads, head_size/x, block_size, x]
|
||
# off_k: [HEAD_DIM, BLOCK_N] — each column is one token's K vector
|
||
off_k = (bn[None, :] * stride_kc_b +
|
||
kv_head_idx * stride_kc_h +
|
||
(offs_d[:, None] // x_pack) * stride_kc_dx +
|
||
within_block[None, :] * stride_kc_bs +
|
||
(offs_d[:, None] % x_pack) * stride_kc_x)
|
||
|
||
k = tl.load(key_cache_ptr + off_k, mask=valid_mask[None, :], other=0.0) # [D, N]
|
||
|
||
# Scores: q @ k = [1, D] @ [D, N] → [N]
|
||
# For BLOCK_M=1: this is a dot product per KV token
|
||
scores = tl.sum(q[:, None] * k, axis=0) * scale # [BLOCK_N]
|
||
scores = tl.where(valid_mask, scores, float('-inf'))
|
||
|
||
# Online softmax (adapted from prefix_prefill.py — proven correct)
|
||
m_ij = tl.max(scores, axis=0) # scalar: max of this chunk
|
||
p = tl.exp(scores - m_ij) # [BLOCK_N] — unnormalized probs
|
||
l_ij = tl.sum(p, axis=0) # scalar: sum of exp for this chunk
|
||
|
||
m_i_new = tl.maximum(m_i, m_ij)
|
||
alpha = tl.exp(m_i - m_i_new) # rescale factor for old accumulator
|
||
beta = tl.exp(m_ij - m_i_new) # rescale factor for new chunk
|
||
l_i_new = alpha * l_i + beta * l_ij
|
||
|
||
# === Paged V gather ===
|
||
off_v = (bn[:, None] * stride_vc_b +
|
||
kv_head_idx * stride_vc_h +
|
||
offs_d[None, :] * stride_vc_d +
|
||
within_block[:, None] * stride_vc_bs)
|
||
v = tl.load(value_cache_ptr + off_v, mask=valid_mask[:, None], other=0.0) # [N, D]
|
||
|
||
# Update accumulator (Flash Attention online softmax pattern):
|
||
# acc = acc * (alpha * l_i / l_i_new) + (p * beta / l_i_new) @ V
|
||
# Safe division: if l_i_new == 0, this is the first chunk
|
||
acc_scale = alpha * l_i / tl.maximum(l_i_new, 1e-6)
|
||
acc = acc * acc_scale
|
||
p_scale = beta / tl.maximum(l_i_new, 1e-6)
|
||
p_scaled = p * p_scale # [BLOCK_N]
|
||
acc += tl.sum(p_scaled[:, None] * v, axis=0) # [HEAD_DIM]
|
||
|
||
l_i = l_i_new
|
||
m_i = m_i_new
|
||
|
||
# Store partition results
|
||
tl.store(max_logits_ptr + seq_idx * stride_es_s + head_idx * stride_es_h + part_idx * stride_es_p,
|
||
m_i)
|
||
tl.store(exp_sums_ptr + seq_idx * stride_es_s + head_idx * stride_es_h + part_idx * stride_es_p,
|
||
l_i)
|
||
|
||
# Store accumulated output: [HEAD_DIM]
|
||
out_base = seq_idx * stride_to_s + head_idx * stride_to_h + part_idx * stride_to_p
|
||
tl.store(tmp_output_ptr + out_base + offs_d * stride_to_d, acc.to(tmp_output_ptr.dtype.element_ty))
|
||
|
||
|
||
@triton.jit
|
||
def _paged_attn_v2_reduce_kernel(
|
||
# Output
|
||
output_ptr, # [num_seqs, num_heads, head_size]
|
||
# Inputs
|
||
tmp_output_ptr, # [num_seqs, num_heads, max_num_parts, head_size]
|
||
exp_sums_ptr, # [num_seqs, num_heads, max_num_parts]
|
||
max_logits_ptr, # [num_seqs, num_heads, max_num_parts]
|
||
seq_lens_ptr, # [num_seqs]
|
||
# Scalars
|
||
max_num_parts: tl.int32,
|
||
# Strides
|
||
stride_out_s: tl.int32, stride_out_h: tl.int32, stride_out_d: tl.int32,
|
||
stride_to_s: tl.int32, stride_to_h: tl.int32,
|
||
stride_to_p: tl.int32, stride_to_d: tl.int32,
|
||
stride_es_s: tl.int32, stride_es_h: tl.int32, stride_es_p: tl.int32,
|
||
# Constants
|
||
PARTITION_SIZE: tl.constexpr,
|
||
HEAD_DIM: tl.constexpr,
|
||
MAX_NUM_PARTS: tl.constexpr,
|
||
):
|
||
"""Phase 2: Cross-partition log-sum-exp reduction.
|
||
|
||
Grid: (num_seqs, num_heads)
|
||
Combines partition results using CCCL summary_statistics pattern.
|
||
"""
|
||
seq_idx = tl.program_id(0)
|
||
head_idx = tl.program_id(1)
|
||
|
||
seq_len = tl.load(seq_lens_ptr + seq_idx)
|
||
num_parts = (seq_len + PARTITION_SIZE - 1) // PARTITION_SIZE
|
||
|
||
# Load partition statistics
|
||
part_offsets = tl.arange(0, MAX_NUM_PARTS)
|
||
valid_mask = part_offsets < num_parts
|
||
|
||
es_base = seq_idx * stride_es_s + head_idx * stride_es_h
|
||
part_max = tl.load(max_logits_ptr + es_base + part_offsets * stride_es_p,
|
||
mask=valid_mask, other=float('-inf'))
|
||
part_sum = tl.load(exp_sums_ptr + es_base + part_offsets * stride_es_p,
|
||
mask=valid_mask, other=0.0)
|
||
|
||
# Global max
|
||
global_max = tl.max(part_max, axis=0)
|
||
|
||
# Rescale and normalize
|
||
rescale = tl.exp(part_max - global_max) * part_sum
|
||
total = tl.sum(rescale, axis=0)
|
||
weights = rescale / total # [MAX_NUM_PARTS]
|
||
|
||
# Weighted sum of partition outputs
|
||
offs_d = tl.arange(0, HEAD_DIM)
|
||
acc = tl.zeros([HEAD_DIM], dtype=tl.float32)
|
||
|
||
for p in range(MAX_NUM_PARTS):
|
||
if p < num_parts:
|
||
w = tl.load(max_logits_ptr + es_base + p * stride_es_p) # reload for weight
|
||
w_rescaled = tl.exp(w - global_max) * tl.load(exp_sums_ptr + es_base + p * stride_es_p) / total
|
||
|
||
to_base = seq_idx * stride_to_s + head_idx * stride_to_h + p * stride_to_p
|
||
part_out = tl.load(tmp_output_ptr + to_base + offs_d * stride_to_d)
|
||
acc += w_rescaled * part_out.to(tl.float32)
|
||
|
||
# Store final output
|
||
out_base = seq_idx * stride_out_s + head_idx * stride_out_h
|
||
tl.store(output_ptr + out_base + offs_d * stride_out_d, acc.to(output_ptr.dtype.element_ty))
|
||
|
||
|
||
def paged_attention_v2_triton(
|
||
output: torch.Tensor,
|
||
exp_sums: torch.Tensor,
|
||
max_logits: torch.Tensor,
|
||
tmp_output: torch.Tensor,
|
||
query: torch.Tensor,
|
||
key_cache: torch.Tensor,
|
||
value_cache: torch.Tensor,
|
||
num_kv_heads: int,
|
||
scale: float,
|
||
block_tables: torch.Tensor,
|
||
seq_lens: torch.Tensor,
|
||
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,
|
||
**kwargs,
|
||
) -> None:
|
||
"""Launch Triton V2 kernels."""
|
||
num_seqs, num_heads, head_size = query.shape
|
||
num_queries_per_kv = num_heads // num_kv_heads
|
||
max_num_parts = tmp_output.shape[2]
|
||
x_pack = key_cache.shape[-1] # packing factor
|
||
|
||
PARTITION_SIZE = 512
|
||
# BLOCK_N: must fit in SMEM. For decode (BLOCK_M=1), SMEM is dominated by K/V gather.
|
||
# head_dim=256: BLOCK_N=32 → 32×256×2 = 16KB per tile (K or V)
|
||
# head_dim=128: BLOCK_N=64 → 64×128×2 = 16KB per tile
|
||
BLOCK_N = 32 if head_size > 128 else 64
|
||
|
||
# Phase 1: partition attention
|
||
num_partitions = (max_seq_len + PARTITION_SIZE - 1) // PARTITION_SIZE
|
||
grid_phase1 = (num_seqs, num_heads, num_partitions)
|
||
|
||
_paged_attn_v2_partition_kernel[grid_phase1](
|
||
tmp_output, exp_sums, max_logits,
|
||
query, key_cache, value_cache, block_tables, seq_lens,
|
||
scale, num_queries_per_kv, block_size, x_pack,
|
||
# query strides
|
||
query.stride(0), query.stride(1), query.stride(2),
|
||
# key_cache strides
|
||
key_cache.stride(0), key_cache.stride(1), key_cache.stride(2),
|
||
key_cache.stride(3), key_cache.stride(4),
|
||
# value_cache strides
|
||
value_cache.stride(0), value_cache.stride(1), value_cache.stride(2),
|
||
value_cache.stride(3),
|
||
# block_tables strides
|
||
block_tables.stride(0), block_tables.stride(1),
|
||
# tmp_output strides
|
||
tmp_output.stride(0), tmp_output.stride(1), tmp_output.stride(2), tmp_output.stride(3),
|
||
# exp_sums strides
|
||
exp_sums.stride(0), exp_sums.stride(1), exp_sums.stride(2),
|
||
# Constants
|
||
PARTITION_SIZE=PARTITION_SIZE,
|
||
HEAD_DIM=head_size,
|
||
BLOCK_N=BLOCK_N,
|
||
)
|
||
|
||
# Phase 2: cross-partition reduction
|
||
MAX_NUM_PARTS_CONST = triton.next_power_of_2(max_num_parts)
|
||
if MAX_NUM_PARTS_CONST > 1024:
|
||
MAX_NUM_PARTS_CONST = 1024
|
||
|
||
grid_phase2 = (num_seqs, num_heads)
|
||
_paged_attn_v2_reduce_kernel[grid_phase2](
|
||
output,
|
||
tmp_output, exp_sums, max_logits, seq_lens,
|
||
max_num_parts,
|
||
output.stride(0), output.stride(1), output.stride(2),
|
||
tmp_output.stride(0), tmp_output.stride(1), tmp_output.stride(2), tmp_output.stride(3),
|
||
exp_sums.stride(0), exp_sums.stride(1), exp_sums.stride(2),
|
||
PARTITION_SIZE=PARTITION_SIZE,
|
||
HEAD_DIM=head_size,
|
||
MAX_NUM_PARTS=MAX_NUM_PARTS_CONST,
|
||
)
|