Files
project_6/docs/paged_attention_kernel_architecture.md
Claude 39e32343eb [ARCH] CCCL-derived paged attention kernel architecture + Triton rewrite
Architecture document: docs/paged_attention_kernel_architecture.md
Defines every module from CCCL algorithm patterns before code.

Three-level decomposition from CCCL:
  Level 1 (warp_reduce_shfl): shfl.down butterfly for per-thread QK scores
  Level 2 (block_reduce_warp_reductions): warp partials → SMEM → block aggregate
  Level 3 (agent_scan decoupled lookback): cross-partition combine

Compound type (from summary_statistics.cu):
  attention_partial = (max_score, exp_sum, weighted_v[256])
  combine(a, b) = online softmax rescaling (same math as Flash Attention)

Key design change: Grid on num_kv_heads, not num_heads.
  Before: grid = (1, 24, 200) = 4800 blocks, KV loaded 6x redundantly
  After:  grid = (1, 4, 200) = 800 blocks, KV loaded once per kv_head
  Each block computes GQA_RATIO=6 query heads with shared KV loads.
  Reduces KV cache bandwidth by 6x (the GQA ratio).

SMEM budget verified:
  K tile [32, 256] fp16 = 16KB
  V tile [32, 256] fp16 = 16KB
  Total = 32KB ≤ 48KB ✓

Phase 1 kernel: _partition_attn_kernel
  Processes query heads sequentially within the GQA group
  to minimize register pressure (6 × 256 = 1536 registers
  too many if all loaded simultaneously).

Phase 2 kernel: _reduce_partitions_kernel
  Also gridded on kv_heads, reduces all partitions for
  GQA_RATIO heads per block.

This replaces the previous Triton V2 which was gridded on num_heads
and had no GQA awareness at the kernel level.
2026-07-31 04:13:07 +00:00

307 lines
12 KiB
Markdown
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 Kernel Architecture for BI-V100
## Derived from CCCL Algorithm Patterns
This document designs a complete paged attention kernel from first principles,
using CCCL's algorithm implementations as the algorithmic foundation.
Every module maps to a proven CCCL pattern.
---
## 1. Problem Definition
Paged attention computes, for each query token in a decode step:
output[h, d] = softmax(Q[h] · K[t]^T / √d) · V[t]
where K and V are stored in a **paged block table** (non-contiguous physical memory).
**Qwen3.6 parameters:**
- head_dim (d) = 256
- num_heads (H) = 24
- num_kv_heads (kv_h) = 4, GQA ratio = 6
- seq_len (T) = up to 100,000
- block_size = 16 tokens per physical block
- SMEM per block = 48KB
**The challenge:** K/V are scattered across physical blocks.
A naive implementation does 6,250 random memory accesses for 100K tokens.
---
## 2. Algorithm Decomposition (Three Levels from CCCL)
### Level 1: Warp Reduce (from `warp_reduce_shfl.cuh`)
**CCCL pattern:** `shfl.sync.down` butterfly reduction in log2(32) = 5 steps.
Each step: `output = reduction_op(input, ShuffleDown(input, 1 << step))`.
**In attention:** Within one warp (32 threads), each thread holds QK^T scores
for a subset of KV tokens. Warp reduce computes:
- `max_score = warp_reduce(scores, max_op)` — for softmax numerical stability
- `exp_sum = warp_reduce(exp(scores - max_score), plus_op)` — softmax denominator
- `weighted_v = warp_reduce(exp(scores - max_score) * V[t], plus_op)` — numerator
This is a **compound reduction** — the same pattern as CCCL's `summary_statistics.cu`
where (count, mean, M2) are reduced together with a custom binary op.
**Our compound type:**
```
struct attention_partial {
float max_score; // running max of QK^T
float exp_sum; // sum of exp(score - max_score)
float weighted_v[D]; // sum of exp(score - max_score) * V
};
```
**Binary op** (from `summary_statistics.cu`):
```
attention_partial combine(attention_partial a, attention_partial b) {
float new_max = max(a.max_score, b.max_score);
float scale_a = exp(a.max_score - new_max);
float scale_b = exp(b.max_score - new_max);
return {
new_max,
scale_a * a.exp_sum + scale_b * b.exp_sum,
scale_a * a.weighted_v + scale_b * b.weighted_v // element-wise
};
}
```
This is exactly the online softmax from Flash Attention.
It's also exactly CCCL's binary reduction op pattern.
### Level 2: Block Reduce (from `block_reduce_warp_reductions.cuh`)
**CCCL pattern:** Each warp produces a `warp_aggregate`. Lane 0 of each warp
writes it to `SMEM warp_aggregates[warp_id]`. Then thread 0 serially reduces
across warps:
```
for (warp_idx = 1; warp_idx < warps; ++warp_idx)
aggregate = reduction_op(aggregate, warp_aggregates[warp_idx]);
```
**In attention:** One thread block processes one partition of the KV sequence
(e.g., PARTITION_SIZE = 512 tokens). Multiple warps within the block each handle
a chunk of these 512 tokens.
- Warp 0: tokens 0..63 (BLOCK_N=64 at a time, or 32 for head_dim=256)
- Warp 1: tokens 64..127
- ...
- Warp W-1: tokens (W-1)*64..511
Each warp produces an `attention_partial`. Block reduce merges them:
```
__shared__ attention_partial warp_partials[NUM_WARPS];
warp_partials[warp_id] = my_warp_result;
__syncthreads();
if (threadIdx.x == 0) {
attention_partial block_result = warp_partials[0];
for (int w = 1; w < NUM_WARPS; w++)
block_result = combine(block_result, warp_partials[w]);
// Write block_result to global: tmp_output, exp_sums, max_logits
}
```
**SMEM layout for attention_partial at head_dim=256:**
- max_score: 4 bytes
- exp_sum: 4 bytes
- weighted_v[256]: 256 × 4 = 1024 bytes
- Total per warp: 1032 bytes
- For 4 warps: 4128 bytes (fits easily in 48KB)
### Level 3: Cross-Partition Coordination (from `agent_scan.cuh` + decoupled lookback)
**CCCL pattern:** `TilePrefixCallbackOp` implements decoupled lookback.
Each tile block:
1. Computes its local aggregate
2. Publishes local aggregate to global `tile_state` (PARTIAL status)
3. Warp 0 looks back through predecessor tiles:
- If predecessor has INCLUSIVE status → directly use its prefix
- If predecessor has PARTIAL status → accumulate and keep looking back
4. Once prefix is resolved, update own status to INCLUSIVE
**In attention (V2):** Each partition block has its `attention_partial`.
The cross-partition reduction is simpler than scan because attention
partitions are **commutative** — we don't need prefix sums, just a
global reduce.
But the coordination pattern is the same:
1. Each partition block writes its (max_logit, exp_sum, partial_output) to
global memory: `tmp_output[seq, head, partition, :]`
2. A separate reduction kernel (or the last partition block) reads all
partitions and does the final combine.
**Simplification over CCCL's lookback:** Since attention partitions are
independent (no prefix dependency), we don't need the lookback polling loop.
Each partition can run fully independently. The reduction is a simple
parallel reduce over `num_partitions` compound values.
For 100K tokens / 512 partition_size = ~200 partitions.
200 `attention_partial` values × (4 + 4 + 256×4) = 200 × 1032 = ~200KB.
One block can reduce all 200 in registers + SMEM.
---
## 3. Paged K/V Gather (from `block_load.cuh` + `cache_modified_input_iterator.cuh`)
**CCCL pattern:** `BlockLoadWarpTranspose` loads contiguous global memory
into a striped register layout that enables coalesced access. Each thread
loads `ITEMS_PER_THREAD` elements, and the warp transposes them so each
thread gets its tile of the data.
**In paged attention:** K/V are not contiguous — they're indexed through
`block_tables[seq, logical_block] → physical_block`.
- Key cache: `[num_blocks, kv_heads, head_dim/x, block_size, x]`
where x = 16/sizeof(dtype) is the packing factor
- Value cache: `[num_blocks, kv_heads, head_dim, block_size]`
The gather pattern (from `prefix_prefill.py`, which works on BI-V100):
```
# For BLOCK_N tokens starting at position start_n:
token_ids = start_n + tl.arange(0, BLOCK_N)
logical_blocks = token_ids // block_size
within_block = token_ids % block_size
physical_blocks = tl.load(block_tables + seq * stride + logical_blocks * stride)
# K gather: compute 2D offset array [HEAD_DIM, BLOCK_N]
off_k = (physical_blocks[None, :] * stride_kc_b +
kv_head * stride_kc_h +
(offs_d[:, None] // x) * stride_kc_dx +
within_block[None, :] * stride_kc_bs +
(offs_d[:, None] % x) * stride_kc_x)
k = tl.load(key_cache + off_k, mask=valid_mask)
```
This is an **indirect gather** — the physical block ID comes from a table lookup.
CCCL's `CacheModifiedInputIterator` handles the cache hint part, but the
indirect indexing is our addition.
**Memory access pattern:**
- block_tables lookup: 1 global read per BLOCK_N tokens (amortized)
- K gather: BLOCK_N × HEAD_DIM / x global reads (scattered by physical block)
- V gather: BLOCK_N × HEAD_DIM global reads (similar scatter)
For BLOCK_N=32, HEAD_DIM=256, x=8: 32 × 32 = 1024 reads for K per iteration.
At 16 bytes per read (128-bit): 16KB per K load.
V is similar. Total per iteration: ~32KB — fits in L2 (6MB on BI-V100).
---
## 4. GQA (Grouped Query Attention) Handling
**The insight:** 6 query heads share 1 KV head. Loading KV once and
computing 6 sets of QK^T scores is 6x more compute-efficient than
loading KV 6 times.
**CCCL analogy:** This is like `BlockReduce` where we have 6 different
reduction operations on the same input data. CCCL doesn't have this exact
pattern, but the principle is: share data loads, parallelize computation.
**Implementation:**
- Each thread block handles one `(seq, kv_head, partition)` triple
- Within the block, 6 query heads are processed simultaneously
- Q vectors: 6 × HEAD_DIM = 6 × 256 = 1536 values in registers (per thread
this is 1536/32 = 48 registers — feasible)
- K/V: loaded once for the kv_head, broadcast across all 6 query heads
- Scores: 6 × BLOCK_N values per iteration
- Weighted V: 6 × HEAD_DIM per thread's accumulator
This reduces K/V cache reads by 6x (the GQA ratio).
Grid: `(num_seqs, num_kv_heads, num_partitions)` = `(1, 4, 200)` = 800 blocks
instead of `(1, 24, 200)` = 4800 blocks.
Each block does 6x more compute but reads KV only once.
---
## 5. SMEM Budget
For one block processing BLOCK_N=32 KV tokens across 6 query heads:
| Item | Size | Notes |
|------|------|-------|
| K tile [HEAD_DIM, BLOCK_N] | 32×256×2 = 16KB | fp16, loaded from paged cache |
| V tile [BLOCK_N, HEAD_DIM] | 32×256×2 = 16KB | fp16, loaded from paged cache |
| Warp partials [4 warps × attention_partial] | 4×(4+4+256×4) = 4.1KB | For block-level reduce |
| Q vectors [6 × HEAD_DIM] | 6×256×4 = 6KB | In registers ideally, SMEM if spills |
| **Total** | **42.1KB** | **≤ 48KB ✓** |
Tight but feasible. If Q stays in registers (likely with 4 warps × 32 threads
= 128 threads, each handling 6×256/128 = 12 Q values), total SMEM is 36.1KB.
---
## 6. Kernel Launch Configuration
**Phase 1: Partitioned Attention**
- Grid: `(num_seqs, num_kv_heads, num_partitions)`
- Block: `(NUM_WARPS × 32)` = 128 threads (4 warps)
- Each block processes:
- PARTITION_SIZE = 512 KV tokens
- 6 query heads (GQA broadcast)
- Produces 6 × (max_logit, exp_sum, partial_output[256])
**Phase 2: Cross-Partition Reduction**
- Grid: `(num_seqs, num_kv_heads)`
- Block: 128 threads
- Each block reduces ~200 partitions × 6 query heads
- Uses `combine()` op (same as CCCL `BlockReduce` but with `attention_partial`)
**Phase 1 iterations per block:**
- PARTITION_SIZE / BLOCK_N = 512 / 32 = 16 iterations
- Each iteration: load K[32, 256] + V[32, 256], compute 6×32 scores, update 6 accumulators
---
## 7. Implementation Mapping
| Module | CCCL Source | Our Implementation |
|--------|------------|-------------------|
| Warp-level QK^T + softmax | `warp_reduce_shfl.cuh` | Triton: `tl.sum()` within warp-sized groups |
| Block-level partition reduce | `block_reduce_warp_reductions.cuh` | Triton: shared memory + `tl.reduce()` |
| Cross-partition combine | `agent_scan.cuh` (simplified, no lookback) | Separate reduction kernel |
| Paged K/V gather | `block_load.cuh` + indirect indexing | `prefix_prefill.py` pattern adapted |
| Online softmax | `summary_statistics.cu` binary op | `combine(attention_partial, attention_partial)` |
| GQA broadcast | (no exact CCCL analog) | Multiple Q per KV load |
---
## 8. Why This Design Beats Python V2
Current Python V2 (3 bmm launches + Python overhead):
- gather all KV → permute → contiguous → bmm → reshape → softmax → bmm → reduce
- **Python-CUDA boundary crossed 10+ times per decode step**
- **Full KV tensor materialized in GPU memory** (200MB-2.4GB depending on GQA)
This kernel (2 GPU launches, zero Python-CUDA crossings during compute):
- Phase 1: single kernel, K/V loaded tile-by-tile from paged cache (never materialized)
- Phase 2: single kernel, reduces 200 partitions in SMEM
- **KV cache stays in paged format** — no gather/permute/contiguous overhead
- **GQA broadcast within kernel** — KV loaded once for 6 heads
Expected improvement over Python V2: **10-100x** (eliminating Python overhead
and memory allocation dominates at decode batch_size=1).
Expected improvement over no V2 (V1 only for seq ≤ 8192): **enables long-context
decode** which V1 cannot do due to SMEM overflow at 48KB.
---
## 9. Implementation Priority
1. **Triton implementation** — if Triton works on BI-V100 with BLOCK=32, head_dim=256:
Use the `prefix_prefill.py` paged gather pattern, add the compound reduction.
This is the fastest path to a working kernel.
2. **Compiled CUDA kernel** — if `/usr/local/corex/` has a compiler (ixcc):
Write the kernel in CUDA using the CCCL patterns directly.
`warp_reduce_shfl``__shfl_down_sync` PTX
`block_reduce` → SMEM warp_aggregates pattern
Compile with `torch.utils.cpp_extension.load()` at Docker build time.
3. **Python V2** (current) — fallback if neither Triton nor CUDA works:
Already written, tested, has GQA broadcast optimization.
This is the floor, not the ceiling.