docs: GPU perf batch 1 design spec (FA3, FP8, CUDA Graphs, SnapKV)

Research-driven design for 4 high-impact optimizations targeting
Blackwell SM_120 (RTX 5060 Ti):
- Item 1: CUDA Graphs wiring (90% exists, half-day task)
- Item 2: FP8 E4M3/E5M2 training (30-40% throughput, 50% memory)
- Item 3: FlashAttention-3 WGMMA+TMA+warp specialization (1.5-2x)
- Item 4: SnapKV + prefix caching (50-70% KV reduction)

Based on: arXiv:2407.08608 (FA3), arXiv:2511.05811 (MOSS FP8),
arXiv:2404.14469 (SnapKV), PyTorch 2025 state survey.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-26 19:40:26 +00:00
co-authored by Claude Sonnet 4.6
parent c82d26d6e7
commit 33135194d4
@@ -0,0 +1,340 @@
# GPU Performance Optimization Batch 1 — Design Spec
**Date**: 2026-06-26
**Hardware target**: NVIDIA RTX 5060 Ti (Blackwell SM_120, 16 GB VRAM)
**Research basis**: FlashAttention-3 (arXiv:2407.08608), MOSS FP8 (arXiv:2511.05811), SnapKV (arXiv:2404.14469), KeepKV (arXiv:2504.09936), PyTorch 2025 state survey
---
## Summary
Four high-impact GPU performance optimizations, selected after cross-referencing the current rustytorch codebase against PyTorch 2025 and 20242025 arxiv papers. Items are ordered by implementation cost: CUDA Graphs is a half-day wiring task (infrastructure already exists), FP8 training is ~1 week, SnapKV/prefix caching is ~1 week, and FlashAttention-3 is the deepest CUDA kernel work at ~2 weeks.
| Item | Expected gain | Complexity | Primary files |
|------|--------------|-----------|---------------|
| 1. CUDA Graphs | 520% throughput | Low (wiring only) | `training_loop.rs`, `cuda_graph.rs` |
| 2. FP8 Training | 3040% throughput, 50% memory | Medium | `fp8_cast.cu`, `fp8_gemm.rs`, `linear.rs` |
| 3. FlashAttention-3 | 1.52× attention throughput | High | `flash_attention_v3_*.cu`, `backend_selector.rs` |
| 4. SnapKV + Prefix Cache | 5070% KV reduction | Medium | `eviction.rs`, `paged_kv_cache.rs` |
---
## Item 1: CUDA Graphs
### Problem
Every GPU kernel launch carries 520 μs of CPU-side overhead (CUDA API call → driver → scheduler). A typical transformer training step launches hundreds of kernels. At small batch sizes common on consumer GPUs, this overhead is measurable.
### Solution
CUDA Graphs capture the full sequence of GPU operations in a graph, then replay it in a single `cudaGraphLaunch` — eliminating per-kernel CPU overhead on all subsequent steps.
### Current state
`CudaGraphManager` at `crates/core/rtx-runtime/src/cuda_graph.rs` (445 lines) is fully implemented with `begin_capture()`, `end_capture()`, `launch()`, and thread-safety via `RwLock`. It is **not connected to the training loop**.
### Design
**`crates/training/rtx-transformers/src/training/config.rs`** — add two fields:
```rust
pub enable_cuda_graphs: bool, // default: false
pub cuda_graph_warmup_iters: usize, // default: 3
```
**`crates/training/rtx-transformers/src/training/training_loop.rs`** (around line 167) — wrap the training step:
- Iterations 0..warmup: execute normally (warms up allocator, avoids capturing allocations in graph)
- Iteration `warmup`: `begin_capture(stream)``execute_step()``end_capture()` → store `graph_id`
- Iterations `warmup+1..`: `graph_manager.launch(graph_id, stream)` — no per-kernel CPU calls
**Constraint**: tensor shapes must be static across steps. Add a `requires_static_shapes()` check that disables graph mode and emits a warning when dynamic padding is detected (variable sequence lengths).
**`TrainingLoop`** gets a new `graph_state: Option<GraphId>` field and a reference to `CudaGraphManager`.
### Verification
```
cargo test -p rtx-transformers -- test_cuda_graph_training_step
```
Test: capture a 2-layer transformer step, verify output tensors match non-graph execution within 1e-6, measure step time reduction.
---
## Item 2: FP8 Training (E4M3 forward / E5M2 gradients)
### Problem
BF16 mixed-precision training uses 2 bytes per parameter activation. Blackwell's 5th-generation tensor cores support FP8 (1 byte), but the connection between rustytorch's existing FP8 type infrastructure and actual GEMM execution is missing.
### Current state
- `DType::FP8E4M3` / `FP8E5M2` defined in `dtype.rs` with correct `size_bytes = 1`
- `Fp8GradScaler` in `fp8_scaler.rs` — full amax-tracking, scale growth/backoff, overflow detection
- `CudaDataType::R8F_E4M3 = 28` / `R8F_E5M2 = 29` in `cusparelt/types.rs`
- MX block-wise quantization kernels in `microscaling.rs`
- **Missing**: FP8 casting CUDA kernels, cuBLASLt GEMM dispatch, training loop wiring
### Design
**Step 1 — Casting kernels** (new file: `crates/core/rtx-tensor/src/cuda_kernels/fp8_cast.cu`):
```cuda
// Saturating cast: clamp to [-448, 448] before encoding E4M3
__global__ void cast_f32_to_fp8_e4m3(
const float* __restrict__ src, uint8_t* __restrict__ dst,
float scale, int n);
__global__ void cast_bf16_to_fp8_e4m3(
const __nv_bfloat16* __restrict__ src, uint8_t* __restrict__ dst,
float scale, int n);
__global__ void cast_fp8_e4m3_to_bf16(
const uint8_t* __restrict__ src, __nv_bfloat16* __restrict__ dst,
float inv_scale, int n);
// E5M2 range ±57344, used for gradient tensors
__global__ void cast_fp8_e5m2_to_bf16(
const uint8_t* __restrict__ src, __nv_bfloat16* __restrict__ dst,
float inv_scale, int n);
```
Follow `element_wise.cu` pattern for `build.rs` registration and Rust FFI wrapper.
**Step 2 — cuBLASLt GEMM dispatcher** (new file: `crates/core/rtx-tensor/src/fp8_gemm.rs`):
```rust
pub fn fp8_matmul(
a: &CudaSlice<u8>, // E4M3, shape [M, K]
b: &CudaSlice<u8>, // E4M3, shape [K, N]
scale_a: f32,
scale_b: f32,
m: usize, n: usize, k: usize,
stream: &CudaStream,
) -> Result<CudaSlice<u8>> // BF16 output, shape [M, N]
```
Uses `cublasLtMatmul` with `CUBLAS_COMPUTE_32F` accumulator and `CUDA_R_8F_E4M3` A/B types. Add `cargo:rustc-link-lib=cublasLt` to `rtx-tensor/build.rs`.
**Step 3 — Linear layer FP8 path** (`crates/training/rtx-transformers/src/layers/linear.rs`):
- Add `fp8_mode: bool` to `LinearConfig` (default false)
- In `forward()` when `fp8_mode`:
1. Cast weight matrix W from BF16 → E4M3 using `cast_bf16_to_fp8_e4m3` (can be pre-cached per step)
2. Cast activation X from BF16 → E4M3
3. Run `fp8_matmul(W_fp8, X_fp8, scale_w, scale_x, ...)`
4. Cast output Y from internal FP32 accumulator → BF16
- Master weights remain BF16/FP32 for optimizer stability
**Step 4 — Training config** (`config.rs`):
```rust
pub fp8_training: bool, // default: false
pub fp8_format: Fp8Format, // default: E4M3Fwd_E5M2Grad
```
**Mixed precision recipe** (matching NVIDIA MOSS / Transformer Engine):
- Forward pass: weights and activations cast to E4M3
- Gradient tensors: cast to E5M2 (wider range ±57344 handles gradient spikes)
- Optimizer states: remain FP32 (master weights)
- Scaling: `Fp8GradScaler` (existing) manages per-tensor amax and scale factors
### Verification
```
cargo test -p rtx-transformers -- test_fp8_forward_backward
```
Test: 2-layer linear model, FP8 forward output matches BF16 within 1e-2 tolerance, backward gradients are non-NaN and converge in 10 steps.
---
## Item 3: FlashAttention-3 Blackwell
### Problem
Current kernels use WMMA (4th-generation tensor core API, 16×16×16 fragments) and `cp.async` for shared memory loads. Blackwell's SM_120 introduces Warpgroup MMA (WGMMA, 64×16×16 fragments — 4× compute density) and TMA (Tensor Memory Accelerator — asynchronous 2D tile loads with no warp involvement). FlashAttention-3 (Dao et al., arXiv:2407.08608) showed 1.52× speedup on H100 by exploiting these; the same techniques apply to SM_120.
### Current state
- `flash_attention_forward.cu`: WMMA + tiled online softmax + cooperative groups
- `utils.cu`: `wmma::fragment<matrix_a/b/accumulator>` with BF16, `cg::memcpy_async`, 163KB shared memory
- `build.rs`: sm_120, PTX 8.0, `--use_fast_math --maxrregcount=255`
- `FlashCudaKernels::new()` in `simple.rs`: drop-in kernel registration
### Design
**New file: `src/kernels/flash_attention_v3_forward.cu`**
Three algorithmic changes from FA2:
**1. WGMMA (Warpgroup Matrix Multiply Accumulate)**
Replace:
```cuda
wmma::mma_sync(acc_frag, a_frag, b_frag, acc_frag); // 16×16×16
```
With PTX inline asm for warpgroup-level operation (128 threads = 4 warps):
```cuda
asm volatile(
"wgmma.mma_async.sync.aligned.m64n128k16.f32.bf16.bf16 "
"{%0,%1,...,%7}, [%8], {%9,%10,...}, 1, 1, 1, 0, 0;"
: "+f"(d[0]), "+f"(d[1]), ...
: "l"(desc_a), "r"(b[0]), "r"(b[1]), ...
);
```
Tile size: 64×128×16 per warpgroup vs 16×16×16 per warp. ~4× compute per instruction, amortizing instruction issue overhead.
**2. TMA (Tensor Memory Accelerator)**
Replace `cg::memcpy_async` with `cp.async.bulk.tensor.2d`:
```cuda
// One instruction loads an entire 2D tile — no per-element loop, no warp threads consumed
asm volatile(
"cp.async.bulk.tensor.2d.shared::cluster.global.mbarrier::complete_tx::bytes"
" [%0], [%1, {%2, %3}], [%4];"
:: "r"(smem_ptr), "l"(gmem_base), "r"(row), "r"(col), "r"(mbar_ptr)
);
```
TMA runs in the memory subsystem — warp threads are free to compute while load is in flight.
**3. Warp Specialization (Producer/Consumer split)**
Split the 4 warpgroups per block:
- **Producer warpgroup** (warpgroup 0): issues TMA loads for Q/K/V tiles, waits on TMA barrier, signals consumer via shared memory barrier
- **Consumer warpgroups** (warpgroups 13): wait on producer signal, execute WGMMA accumulation, write output
- Overlap: consumer processes tile `i` while producer loads tile `i+1` into the opposite shared-memory ping-pong buffer
This eliminates the memory stall that currently serializes compute and memory in FA2.
**Optional FP8 path** (gated by `use_fp8_attn: bool`):
Before WGMMA, quantize each 16×16 Q/K block to E4M3 with a per-block scale factor. Enables ~2× additional throughput on FP8 tensor cores when combined with Item 2.
**New file: `src/kernels/flash_attention_v3_backward.cu`**
Same WGMMA + TMA + warp specialization for dQ/dK/dV, following the FA3 paper's recomputation approach (recompute softmax from stored LSE values to avoid storing the full N×N matrix).
**`src/backend_selector.rs`** — runtime SM selection:
```rust
fn select_flash_attention_kernel(sm_major: u32, sm_minor: u32) -> FlashKernelVariant {
if sm_major >= 12 {
FlashKernelVariant::V3Wgmma // Blackwell SM_120+
} else if sm_major >= 9 {
FlashKernelVariant::V3Wgmma // Hopper SM_90
} else {
FlashKernelVariant::V2Wmma // Ampere and below
}
}
```
Both kernel sets remain registered; no API change to callers.
### Verification
```
cargo test -p rtx-flash-attention -- test_v3_correctness
cargo bench -p rtx-flash-attention
```
Correctness: FA3 output matches naive O(N²) attention within 1e-3 for sequence lengths 512, 1024, 4096.
Performance: FA3 ≥ 1.5× throughput vs FA2 on SM_120 for seqlen ≥ 1024.
---
## Item 4: SnapKV + Prefix Caching
### Problem
On 16 GB VRAM, KV cache is the primary constraint on batch size and context length during inference. Two complementary techniques address this:
- **SnapKV** (Zhang et al., arXiv:2404.14469): prune low-attention keys during prefill, keeping 60% + recent window
- **Prefix caching**: reuse KV pages for sequences sharing a common prefix (system prompt, few-shot examples)
### Current state
- `PagedKvCache` in `paged_kv_cache.rs`: 3-tier storage (GPU/CPU/NVMe), UUID page IDs, copy-on-write, LRU eviction
- `eviction.rs`: LRU/LFU/FIFO/Random policies — no attention-score policy
- `config.rs`: `enable_prefix_sharing: bool = false` — no implementation behind the flag
### Design
**SnapKV — attention-score eviction** (`eviction.rs`):
Add new policy variant:
```rust
pub enum EvictionPolicy {
Lru, Lfu, Fifo, Random,
AttentionScore { keep_ratio: f32, recent_window: usize }, // NEW
}
```
Add sidecar to `PagedKvCache`:
```rust
attention_scores: Arc<RwLock<HashMap<PageId, f32>>>,
```
During prefill, the flash attention kernel accumulates the sum of attention weights per key position into a host-accessible buffer. After prefill, scores are written to `attention_scores` via a kernel that reduces along the head dimension.
In `select_victims()`:
```rust
// Sort candidate pages by accumulated attention score ascending
// Always retain last `recent_window` tokens (recency bias)
// Evict bottom (1 - keep_ratio) fraction by score
```
This approach matches SnapKV's "observation pooling" (using cumulative attention across all query positions as importance proxy).
**Prefix caching** (`paged_kv_cache.rs`):
Add to `PagedKvCache`:
```rust
prefix_index: HashMap<u64, Vec<PageId>>,
```
Hash function (Zobrist-style, collision probability ~1/2^64):
```rust
fn compute_prefix_hash(tokens: &[u32]) -> u64 {
// Precomputed random table: HASH_TABLE[pos % 1024][token % 65536]
tokens.iter().enumerate().fold(0u64, |acc, (pos, &tok)| {
acc ^ ZOBRIST_TABLE[pos % 1024][(tok % 65536) as usize]
})
}
```
On each new request in the scheduler:
1. Compute prefix hash for first `N` tokens (where N is the longest aligned page boundary)
2. Look up `prefix_index[hash]` — if found, reuse those pages with copy-on-write
3. Allocate new pages only for tokens beyond the prefix
On page eviction:
- Remove matching entry from `prefix_index`
- If CoW refcount > 1: decrement, do not free
**Enable by default** (`InferenceConfig`):
```rust
pub enable_prefix_sharing: bool = true, // was false
pub snapkv_keep_ratio: f32 = 0.6,
pub snapkv_recent_window: usize = 32,
```
### Verification
```
cargo test -p rtx-inference -- test_prefix_cache_sharing
cargo test -p rtx-inference -- test_snapkv_eviction
```
- Prefix test: two 2048-token requests sharing a 128-token system prompt must reuse identical pages (verified by page ID equality), saving ≥ 6% page allocations.
- SnapKV test: after prefill on a 2048-token sequence with keep_ratio=0.6, active page count ≤ 40% of input length (allowing ≥ recent_window retained pages).
---
## Execution Order
```
Week 1: Item 1 (CUDA Graphs) — 0.5 day
Item 2a2b (FP8 casting kernels + cuBLASLt) — 3 days
Week 2: Item 2c2e (FP8 linear + training config + tests) — 3 days
Item 4a (SnapKV eviction) — 1 day
Week 3: Item 4b4d (prefix caching + defaults + tests) — 2 days
Item 3a (FA3 forward kernel: WGMMA + TMA + warp spec) — 3 days
Week 4: Item 3b (FA3 backward kernel) — 3 days
Item 3c3d (SM selection + benchmarks) — 1 day
Week 5: End-to-end integration benchmark: all 4 items together
```
---
## Dependencies and Risks
| Risk | Mitigation |
|------|-----------|
| cuBLASLt FP8 requires CUDA ≥ 12.0 | RTX 5060 Ti ships with CUDA 12.x; check `build.rs` version guard |
| WGMMA PTX inline asm requires sm_90+ | Runtime SM check in `backend_selector.rs` falls back to FA2 |
| CUDA Graphs incompatible with dynamic shapes | Static shape check + warning; disable gracefully |
| SnapKV may hurt accuracy on long-context retrieval tasks | `keep_ratio` is configurable; default 0.6 leaves 60% of keys |
| Prefix cache hash collisions | Zobrist hash with 64-bit space; false positive rate ~1/2^64 per request |
---
## Non-goals (Batch 1)
- Metal FlashAttention (stub remains; deferred to Batch 2)
- GaLore-2 optimizer (deferred; independent of these 4 items)
- EAGLE-3 speculative decoding upgrade (spec exists; Batch 2)
- Ring-AllReduce (requires multi-node setup; Batch 2)
- MegaBlocks grouped GEMM for MoE (deferred to Batch 2)