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]>
15 KiB
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 2024–2025 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 | 5–20% throughput | Low (wiring only) | training_loop.rs, cuda_graph.rs |
| 2. FP8 Training | 30–40% throughput, 50% memory | Medium | fp8_cast.cu, fp8_gemm.rs, linear.rs |
| 3. FlashAttention-3 | 1.5–2× attention throughput | High | flash_attention_v3_*.cu, backend_selector.rs |
| 4. SnapKV + Prefix Cache | 50–70% KV reduction | Medium | eviction.rs, paged_kv_cache.rs |
Item 1: CUDA Graphs
Problem
Every GPU kernel launch carries 5–20 μ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:
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()→ storegraph_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/FP8E5M2defined indtype.rswith correctsize_bytes = 1Fp8GradScalerinfp8_scaler.rs— full amax-tracking, scale growth/backoff, overflow detectionCudaDataType::R8F_E4M3 = 28/R8F_E5M2 = 29incusparelt/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):
// 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):
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: booltoLinearConfig(default false) - In
forward()whenfp8_mode:- Cast weight matrix W from BF16 → E4M3 using
cast_bf16_to_fp8_e4m3(can be pre-cached per step) - Cast activation X from BF16 → E4M3
- Run
fp8_matmul(W_fp8, X_fp8, scale_w, scale_x, ...) - Cast output Y from internal FP32 accumulator → BF16
- Cast weight matrix W from BF16 → E4M3 using
- Master weights remain BF16/FP32 for optimizer stability
Step 4 — Training config (config.rs):
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.5–2× speedup on H100 by exploiting these; the same techniques apply to SM_120.
Current state
flash_attention_forward.cu: WMMA + tiled online softmax + cooperative groupsutils.cu:wmma::fragment<matrix_a/b/accumulator>with BF16,cg::memcpy_async, 163KB shared memorybuild.rs: sm_120, PTX 8.0,--use_fast_math --maxrregcount=255FlashCudaKernels::new()insimple.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:
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):
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:
// 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 1–3): wait on producer signal, execute WGMMA accumulation, write output
- Overlap: consumer processes tile
iwhile producer loads tilei+1into 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:
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
PagedKvCacheinpaged_kv_cache.rs: 3-tier storage (GPU/CPU/NVMe), UUID page IDs, copy-on-write, LRU evictioneviction.rs: LRU/LFU/FIFO/Random policies — no attention-score policyconfig.rs:enable_prefix_sharing: bool = false— no implementation behind the flag
Design
SnapKV — attention-score eviction (eviction.rs):
Add new policy variant:
pub enum EvictionPolicy {
Lru, Lfu, Fifo, Random,
AttentionScore { keep_ratio: f32, recent_window: usize }, // NEW
}
Add sidecar to PagedKvCache:
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():
// 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:
prefix_index: HashMap<u64, Vec<PageId>>,
Hash function (Zobrist-style, collision probability ~1/2^64):
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:
- Compute prefix hash for first
Ntokens (where N is the longest aligned page boundary) - Look up
prefix_index[hash]— if found, reuse those pages with copy-on-write - 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):
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 2a–2b (FP8 casting kernels + cuBLASLt) — 3 days
Week 2: Item 2c–2e (FP8 linear + training config + tests) — 3 days
Item 4a (SnapKV eviction) — 1 day
Week 3: Item 4b–4d (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 3c–3d (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)