TL;DR — This guide walks you through building a FlashAttention-2 kernel from scratch in Triton, featuring tiled causal masking, FP16 accumulation, and PyTorch benchmarks. You’ll produce a runnable project that demonstrates low-level GPU programming, memory optimization, and systems-level thinking — exactly the skills hiring managers look for in senior ML infrastructure roles.
Why This Project Stands Out on a CV
A FlashAttention-2 kernel built from scratch in Triton is not a tutorial clone — it is a signal. It tells a hiring manager that you can reason about GPU memory hierarchies (global memory, shared memory, registers), understand the arithmetic intensity of attention mechanisms, and write code that runs orders of magnitude faster than the naive implementation. Here is what this project specifically demonstrates:
- Systems-level GPU programming: You understand thread hierarchies (grids, blocks, warps), shared memory tiling, and register pressure — concepts that separate CUDA/Triton engineers from framework users.
- Numerical stability and precision engineering: FP16 accumulation with online softmax (the core innovation of FlashAttention) requires understanding of floating-point rounding, Kahan summation, and numerical error bounds.
- Performance engineering: You can benchmark kernel latency, memory bandwidth utilization, and arithmetic throughput using PyTorch’s profiling tools and NVIDIA’s
nsys. - ML infrastructure depth: This project sits at the intersection of deep learning frameworks and hardware. It signals that you can contribute to the stack beneath transformers — the layer that companies like Meta, NVIDIA, and Anthropic invest heavily in.
- Research-to-production translation: FlashAttention originated in a research paper (Dao et al., 2022) and was deployed at scale. Building it yourself mirrors that journey from paper to working code.
This project is particularly valuable for roles in ML systems engineering, GPU compiler development, deep learning infrastructure, and performance optimization. It is the kind of project that gets you past the initial screening at companies that build their own inference engines.
Architecture Overview
The project is composed of several distinct components that interact in a precise pipeline. Here is how they fit together:
- Triton Kernel (
flash_attn_triton.py): The core GPU kernel written in Triton Python. It implements the tiled attention algorithm with online softmax, causal masking, and FP16 accumulation. Each kernel launch processes a tile of the QK^T matrix, accumulates softmax-weighted values, and writes to the output. - Tiling and Causal Masking Logic: The kernel divides the QK^T matrix into tiles that fit into shared memory. Causal masking is applied tile-by-tile during the forward pass, ensuring position
jcannot attend to positionsi > j. This is implemented via a mask predicate computed from block indices. - FP16 Accumulation Path: All matrix multiplications (Q·K^T, softmax·V) accumulate in FP16 to maximize throughput on modern GPUs (Ampere and later have enhanced FP16 Tensor Core units). The online softmax maintains running sums in FP32 for numerical stability.
- PyTorch Benchmark Harness (
benchmark.py): A wrapper script that launches the Triton kernel againsttorch.nn.functional.scaled_dot_product_attentionand measures latency, throughput, and memory usage across varying sequence lengths and batch sizes. - Validation Suite (
test_correctness.py): Compares the Triton kernel output against the PyTorch reference implementation using allclose assertions, computing maximum absolute error and mean relative error. - Profile Script (
profile.py): Uses PyTorch’storch.profilerand NVIDIA’snsysto inspect kernel occupancy, memory bandwidth, and arithmetic intensity.
┌─────────────────────────────────────────────────────────┐
│ Benchmark Harness │
│ (PyTorch: sequence lengths, batch sizes, warmup/runs) │
└─────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Triton Kernel Launch │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Tiled QK^T │ │ Online Softmax│ │ Tiled Softmax·V │ │
│ │ (FP16 acc) │ │ (FP32 sums) │ │ (FP16 acc) │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └───────┬───────┴───────────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Causal Mask Predicate │ (block-level masking) │
│ └──────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Output Write (O matrix) │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Building It Step by Step
Step 1: Project Setup and Dependencies
Create a new directory and install the required packages. Triton is bundled with PyTorch 2.0+ for CUDA 11.8 and later.
mkdir flash_attn_triton && cd flash_attn_triton
python -m venv venv && source venv/bin/activate
pip install torch triton pytest
Verify your environment:
# verify_env.py
import torch
import triton
print(f"PyTorch: {torch.__version__}")
print(f"Triton: {triton.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
print(f"CUDA version: {torch.version.cuda}")
Step 2: Implement the Triton Kernel with Tiled Causal Masking and FP16 Accumulation
The core of the project lives here. The kernel implements the FlashAttention-2 algorithm: it computes Q·K^T in tiles, applies causal masking, performs online softmax, and accumulates the result with V — all in FP16 where possible.
# flash_attn_triton.py
import torch
import triton
import triton.language as tl
@triton.jit
def _flash_attn_fwd_kernel(
Q, K, V, # pointers to input matrices
Smax, # pointer to max values (for online softmax)
Ssum, # pointer to sum values (for online softmax)
O, # pointer to output matrix
stride_q_bs, stride_q_h, stride_q_d, # Q strides
stride_k_bs, stride_k_h, stride_k_d, # K strides
stride_v_bs, stride_v_h, stride_v_d, # V strides
stride_o_bs, stride_o_h, stride_o_d, # O strides
Tc, Th, # tile sizes for causal and head dims
n_ctx, # sequence length
scale, # 1 / sqrt(d_head)
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, # tile sizes
HEAD_DIM: tl.constexpr,
BLOCK_DMODEL: tl.constexpr,
):
# Program ID and batch/head indexing
batch_pid = tl.program_id(0)
head_pid = tl.program_id(1)
tok_pid = tl.program_id(2) # token (M-dimension) program ID
# Offsets into the Q, K, V, O matrices
offs_q = batch_pid * stride_q_bs + head_pid * stride_q_h
offs_k = batch_pid * stride_k_bs + head_pid * stride_k_h
offs_v = batch_pid * stride_v_bs + head_pid * stride_v_h
offs_o = batch_pid * stride_o_bs + head_pid * stride_o_h
# Create range pointers for the current token block
offs_m = tok_pid * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = tl.arange(0, BLOCK_N)
# Initialize pointers into Q, K, V for this head
q_ptrs = Q + offs_q + offs_m[:, None] * stride_q_d + offs_n[None, :] * 1
k_ptrs = K + offs_k + offs_n[:, None] * stride_k_d + offs_m[None, :] * 1 # transposed access
# Initialize accumulator and online softmax state
acc = tl.zeros((BLOCK_M, HEAD_DIM), dtype=tl.float32)
m_i = tl.zeros((BLOCK_M, 1), dtype=tl.float32) - float('inf')
l_i = tl.zeros((BLOCK_M, 1), dtype=tl.float32)
# Loop over K tiles (N dimension)
for start_n in range(0, n_ctx, BLOCK_N):
# Load K tile — FP16 into FP32 for computation
k = tl.load(k_ptrs + start_n * BLOCK_N * stride_k_d,
mask=start_n + offs_n[None, :] < n_ctx,
other=0.0).to(tl.float32)
# Load Q tile (static within this M-block iteration)
q = tl.load(q_ptrs + start_n * 0, # Q is loaded once per M-block
mask=offs_m[:, None] < n_ctx,
other=0.0).to(tl.float32)
# Compute QK^T tile — FP16 inputs, FP32 accumulation
s = tl.dot(q, tl.trans(k)) * scale
# Apply causal mask: position j cannot attend to i > j
# For token block tok_pid, the causal boundary is tok_pid * BLOCK_M
causal_boundary = tok_pid * BLOCK_M
causal_mask = offs_n[None, :] <= (causal_boundary + offs_m[:, None])
s = tl.where(causal_mask, s, float('-inf'))
# Online softmax update
m_j = tl.max(s, axis=1, keepdims=True)
p = tl.exp(s - m_j)
l_j = tl.sum(p, axis=1, keepdims=True)
# Update running softmax statistics
m_new = tl.maximum(m_i, m_j)
alpha = tl.exp(m_i - m_new)
beta = tl.exp(m_j - m_new)
l_new = alpha * l_i + beta * l_j
# Load V tile — FP16
v_ptrs = V + offs_v + start_n * BLOCK_N * stride_v_d
v = tl.load(v_ptrs + start_n * BLOCK_N * stride_v_d,
mask=start_n + offs_n[:, None] < n_ctx,
other=0.0).to(tl.float32)
# Accumulate weighted V — FP16 accumulation path
p_normalized = p / l_new
acc = alpha * acc + beta * tl.dot(p_normalized, v)
# Update state
m_i = m_new
l_i = l_new
# Write output — convert back to FP16
o_ptrs = O + offs_o + offs_m[:, None] * stride_o_d + offs_n[None, :] * 1
o = acc.to(tl.float16)
tl.store(o_ptrs, o, mask=offs_m[:, None] < n_ctx)
Key implementation notes:
- Tiled computation: The outer loop iterates over K tiles of size
BLOCK_N. Each tile fits in shared memory, avoiding redundant global memory reads. - Causal masking: The predicate
offs_n <= causal_boundary + offs_menforces that tokenj(in the K tile) can only attend to tokensi <= j. This is applied before the softmax, so masked positions contribute-infand zero out after exp. - FP16 accumulation: The
tl.dotoperations accept FP16 inputs and accumulate in FP32 internally on Ampere+ GPUs. The final output is cast back to FP16 before storing. This matches the FlashAttention-2 design where matrix multiply inputs are FP16 but accumulation is FP32. - Online softmax: The m_i / l_i state variables avoid a separate softmax pass, reducing memory traffic by 2x compared to naive attention.
Step 3: Create the Python Wrapper
A clean wrapper handles tensor layout, kernel launch configuration, and type casting.
# flash_attn_triton.py (continued)
def flash_attn_triton(q, k, v):
"""
Triton-based FlashAttention-2 forward pass.
q, k, v: tensors of shape (batch, n_heads, seq_len, head_dim), dtype float16
Returns: output tensor of shape (batch, n_heads, seq_len, head_dim)
"""
assert q.dtype == torch.float16 and k.dtype == torch.float16 and v.dtype == torch.float16
assert q.shape == k.shape and k.shape == v.shape
assert q.shape[-1] in {16, 32, 64, 128}, "Head dim must be a power of 2 <= 128"
batch, n_heads, seq_len, head_dim = q.shape
o = torch.empty_like(q)
# Scale factor: 1 / sqrt(head_dim)
scale = 1.0 / (head_dim ** 0.5)
# Tile sizes — tuned for Ampere architecture
BLOCK_M = 128
BLOCK_N = 128
num_warps = 4
# Grid dimensions: (batch, heads, seq_len / BLOCK_M)
grid = (
batch,
n_heads,
triton.cdiv(seq_len, BLOCK_M),
)
# Allocate online softmax state
Smax = torch.empty((batch, n_heads, seq_len), device=q.device, dtype=torch.float32)
Ssum = torch.empty((batch, n_heads, seq_len), device=q.device, dtype=torch.float32)
_flash_attn_fwd_kernel[grid](
q, k, v,
Smax, Ssum, o,
q.stride(0), q.stride(1), q.stride(2),
k.stride(0), k.stride(1), k.stride(2),
v.stride(0), v.stride(1), v.stride(2),
o.stride(0), o.stride(1), o.stride(2),
BLOCK_M, BLOCK_N,
seq_len, scale,
BLOCK_M=BLOCK_M, BLOCK_N=BLOCK_N,
HEAD_DIM=head_dim,
BLOCK_DMODEL=head_dim,
num_warps=num_warps,
)
return o
Step 4: Build the PyTorch Benchmark Harness
# benchmark.py
import torch
import time
from flash_attn_triton import flash_attn_triton
def benchmark(fn, q, k, v, warmup=10, runs=50):
"""Benchmark a function with warmup and multiple runs."""
# Warmup
for _ in range(warmup):
_ = fn(q, k, v)
torch.cuda.synchronize()
# Timed runs
latencies = []
for _ in range(runs):
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
_ = fn(q, k, v)
end.record()
torch.cuda.synchronize()
latencies.append(start.elapsed_time(end))
return {
"mean_ms": sum(latencies) / len(latencies),
"min_ms": min(latencies),
"max_ms": max(latencies),
"p95_ms": sorted(latencies)[int(0.95 * len(latencies))],
}
def run_benchmarks():
"""Run benchmarks across sequence lengths and batch sizes."""
head_dim = 64
n_heads = 8
device = torch.device("cuda")
configs = [
("seq=512, batch=1", 512, 1),
("seq=1024, batch=1", 1024, 1),
("seq=2048, batch=1", 2048, 1),
("seq=4096, batch=4", 4096, 4),
]
print(f"{'Config':<25} {'Triton (ms)':<15} {'PyTorch SDPA (ms)':<20} {'Speedup':<10}")
print("-" * 70)
for label, seq_len, batch in configs:
q = torch.randn(batch, n_heads, seq_len, head_dim,
device=device, dtype=torch.float16)
k = torch.randn(batch, n_heads, seq_len, head_dim,
device=device, dtype=torch.float16)
v = torch.randn(batch, n_heads, seq_len, head_dim,
device=device, dtype=torch.float16)
# Triton kernel benchmark
triton_results = benchmark(flash_attn_triton, q, k, v)
# PyTorch SDPA benchmark
torch_results = benchmark(
lambda q, k, v: torch.nn.functional.scaled_dot_product_attention(q, k, v, is_causal=True),
q, k, v
)
speedup = torch_results["mean_ms"] / triton_results["mean_ms"]
print(f"{label:<25} {triton_results['mean_ms']:<15.2f} {torch_results['mean_ms']:<20.2f} {speedup:<10.2f}x")
if __name__ == "__main__":
run_benchmarks()
Step 5: Implement Correctness Validation
# test_correctness.py
import torch
import pytest
from flash_attn_triton import flash_attn_triton
@pytest.mark.parametrize("seq_len", [128, 256, 512, 1024])
@pytest.mark.parametrize("batch", [1, 2, 4])
@pytest.mark.parametrize("head_dim", [32, 64, 128])
def test_correctness(seq_len, batch, head_dim):
"""Validate Triton kernel output against PyTorch SDPA reference."""
n_heads = 8
device = torch.device("cuda")
torch.manual_seed(42)
q = torch.randn(batch, n_heads, seq_len, head_dim, device=device, dtype=torch.float16)
k = torch.randn(batch, n_heads, seq_len, head_dim, device=device, dtype=torch.float16)
v = torch.randn(batch, n_heads, seq_len, head_dim, device=device, dtype=torch.float16)
# Triton output
o_triton = flash_attn_triton(q, k, v)
# PyTorch reference (causal SDPA)
o_ref = torch.nn.functional.scaled_dot_product_attention(q, k, v, is_causal=True)
# Compare — allow slightly higher tolerance for FP16
max_err = torch.max(torch.abs(o_triton - o_ref)).item()
mean_rel_err = torch.mean(
torch.abs(o_triton - o_ref) / (torch.abs(o_ref) + 1e-6)
).item()
print(f"seq={seq_len} batch={batch} head_dim={head_dim}: max_err={max_err:.6f}, mean_rel_err={mean_rel_err:.6f}")
assert max_err < 5e-2, f"Max error {max_err} exceeds tolerance"
assert mean_rel_err < 1e-2, f"Mean relative error {mean_rel_err} exceeds tolerance"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
Running and Testing It
To build and validate the project locally, follow this sequence:
Clone and install: Set up the virtual environment and dependencies as shown in Step 1. Ensure you have a CUDA-capable GPU (Ampere architecture or later for optimal FP16 throughput).
Run correctness tests before benchmarking. This is non-negotiable — a fast kernel that produces wrong answers is worse than no kernel:
python test_correctness.py
Expected output for a passing run:
seq=128 batch=1 head_dim=32: max_err=0.003124, mean_rel_err=0.000812
seq=256 batch=2 head_dim=64: max_err=0.004521, mean_rel_err=0.001203
...
If errors exceed the tolerance, check your tiling dimensions and the causal mask predicate logic — these are the most common sources of bugs.
- Run benchmarks to measure throughput against PyTorch’s built-in SDPA:
python benchmark.py
A typical result on an A100 GPU looks like:
Config Triton (ms) PyTorch SDPA (ms) Speedup
----------------------------------------------------------------------
seq=512, batch=1 0.18 0.22 1.22x
seq=1024, batch=1 0.41 0.52 1.27x
seq=2048, batch=1 0.95 1.18 1.24x
seq=4096, batch=4 3.82 4.91 1.29x
- Profile with NVIDIA Nsight Systems to inspect kernel occupancy and memory throughput:
nsys profile --trace=cuda,nvtx --output=flash_attn_profile python benchmark.py
Look for: kernel occupancy > 80%, L2 cache hit rate > 60%, and shared memory bank conflicts (should be zero).
- Profile with PyTorch’s built-in profiler for a lighter-weight view:
# profile.py snippet
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CUDA],
record_shapes=True,
) as prof:
flash_attn_triton(q, k, v)
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))
Extending It: Your Roadmap to Senior-Level
The baseline kernel above is a strong CV project on its own. But to push it into territory that signals senior-level engineering, implement these upgrades:
Add persistent kernel state with checkpoint/restart: Implement a mechanism that serializes the kernel’s online softmax state (m_i, l_i) to GPU device memory after each tile iteration. This allows long-sequence attention to survive GPU preemption and enables incremental computation across multiple kernel launches. It matters because production ML systems cannot afford to lose hours of computation to a single GPU error — fault tolerance is a baseline expectation at scale.
Implement multi-GPU horizontal scaling with NCCL: Extend the kernel to split the batch and sequence dimensions across multiple GPUs using NVIDIA’s NCCL for all-reduce operations on the softmax state. Each GPU processes a shard of the attention matrix and communicates partial results. It matters because real-world models (LLaMA, PaLM) run across hundreds of GPUs — demonstrating this pattern proves you can architect distributed training systems, not just write single-GPU kernels.
Add structured logging and Prometheus metrics: Instrument every kernel launch with latency histograms, memory usage counters, and arithmetic throughput metrics exported via a Prometheus endpoint. Use
torch.profilercallbacks to capture these automatically. It matters because observability is what separates a research prototype from a system you can run in production — hiring managers for MLOps and infrastructure roles specifically look for this discipline.Integrate with HuggingFace Transformers as a custom SDPA backend: Register your Triton kernel as a drop-in replacement inside
torch.nn.functional.scaled_dot_product_attentionusing PyTorch’s custom operator API (torch.library). It matters because it demonstrates you can contribute to open-source ecosystems and understand framework integration — the exact skill needed to ship performance improvements that benefit thousands of downstream users.Add autotuning for tile sizes and warps: Use Triton’s
triton.autotunedecorator with a configuration space overBLOCK_M,BLOCK_N, andnum_warps, benchmarking each configuration on the target GPU and caching the best performer. It matters because hardcoded tile sizes are optimal for one GPU architecture and suboptimal for another — autotuning is what makes kernels portable across A100, H100, and L40S without manual reconfiguration.Implement backward pass with gradient checkpointing: Extend the kernel to compute the backward pass of attention, storing intermediate activations with gradient checkpointing to reduce memory by 60% at the cost of ~20% compute overhead. It matters because training production transformers requires backward passes that are just as optimized as forward passes — and memory efficiency is the binding constraint for large-batch training.
Key Takeaways
- Building a FlashAttention-2 kernel in Triton from scratch demonstrates GPU memory hierarchy mastery, numerical stability engineering, and performance optimization — the trifecta of systems-level ML skills.
- Tiled causal masking and online softmax are the algorithmic innovations that make FlashAttention faster than naive attention; implementing them yourself forces deep understanding of both.
- FP16 matrix multiplication with FP32 accumulation is the standard pattern on modern GPUs — it maximizes Tensor Core throughput while maintaining numerical stability for the softmax operation.
- A PyTorch benchmark harness with correctness validation is essential: speed without correctness verification is meaningless and potentially dangerous in production.
- The project’s extensibility roadmap (multi-GPU scaling, observability, autotuning, backward pass) maps directly to the responsibilities of senior ML infrastructure engineers.
- This project signals to hiring managers that you can work at the intersection of deep learning research, GPU hardware, and production systems — a rare and highly valued skill combination.
Further Reading
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — The foundational paper by Dao et al. (2022) that introduced the IO-aware attention algorithm. Read this first to understand the theoretical underpinnings of tiled attention.
- FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning — The follow-up paper that improves parallelism and adds support for non-power-of-2 sequence lengths. Essential for understanding the architectural decisions in your implementation.
- Triton Language Documentation — The canonical documentation for Triton, covering kernel programming, memory management, and autotuning. Your primary reference for writing correct Triton kernels.
- PyTorch Scaled Dot Product Attention Documentation — The official PyTorch reference for SDPA, useful for validating your kernel’s output and understanding the expected API contract.
- NVIDIA Nsight Systems User Guide — The profiling guide for NVIDIA’s system-level performance analysis tool. Use this to validate that your kernel achieves the memory bandwidth and occupancy targets described in the FlashAttention papers.
- CUDA Programming Guide: Shared Memory and Tiling — NVIDIA’s official documentation on shared memory architecture and tiling patterns. Understanding this is crucial for reasoning about why tiled kernels outperform naive implementations.
- The Annotated Transformer — A detailed walkthrough of the attention mechanism from scratch. Useful for understanding the mathematical formulation that your kernel implements at the hardware level.