pycraft-1 / model /attention.py
imshadow0's picture
Upload model/attention.py with huggingface_hub
0497fc8 verified
Raw
History Blame Contribute Delete
9.26 kB
# model/attention.py
# Grouped-Query Attention with:
# - Rotary Positional Embeddings (RoPE)
# - QK-Norm (2025 technique from OLMo 2 / Qwen 3)
# - PyTorch native SDPA (memory-efficient, no flash-attn dependency)
# - No bias terms (standard in modern LLMs)
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.attention import sdpa_kernel, SDPBackend
from model.config import PyCraftConfig
# ------------------------------------------------------------------ #
# RMSNorm
# Faster than LayerNorm: no mean subtraction, just RMS scaling.
# Used in: Llama 3, Qwen 3, Gemma 2, Mistral, PyCraft-1.
# ------------------------------------------------------------------ #
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim)) # learnable scale
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (batch, seq_len, dim)
# Compute RMS over last dimension, then scale
rms = x.pow(2).mean(dim=-1, keepdim=True).add(self.eps).rsqrt()
return x * rms * self.weight
# ------------------------------------------------------------------ #
# Rotary Positional Embeddings (RoPE)
# Encodes relative position by rotating Q and K vectors.
# No learned parameters — positional info is injected at runtime.
# ------------------------------------------------------------------ #
class RotaryEmbedding(nn.Module):
def __init__(self, head_dim: int, max_seq_len: int, theta: float = 10000.0):
super().__init__()
# Compute inverse frequencies for each pair of dimensions
# Shape: (head_dim // 2,)
inv_freq = 1.0 / (
theta ** (torch.arange(0, head_dim, 2).float() / head_dim)
)
self.register_buffer("inv_freq", inv_freq, persistent=False)
# Pre-compute cos/sin cache for all positions up to max_seq_len
self._build_cache(max_seq_len)
def _build_cache(self, seq_len: int):
positions = torch.arange(seq_len, device=self.inv_freq.device).float()
# Outer product: (seq_len, head_dim // 2)
freqs = torch.outer(positions, self.inv_freq)
# Duplicate for full head_dim: (seq_len, head_dim)
emb = torch.cat([freqs, freqs], dim=-1)
self.register_buffer("cos_cache", emb.cos(), persistent=False)
self.register_buffer("sin_cache", emb.sin(), persistent=False)
def _rotate_half(self, x: torch.Tensor) -> torch.Tensor:
"""Rotate the second half of the last dimension to implement complex multiply."""
half = x.shape[-1] // 2
x1, x2 = x[..., :half], x[..., half:]
return torch.cat([-x2, x1], dim=-1)
def forward(
self,
q: torch.Tensor, # (batch, n_heads, seq_len, head_dim)
k: torch.Tensor, # (batch, n_kv_heads, seq_len, head_dim)
offset: int = 0, # for KV-cache offset during inference
):
seq_len = q.shape[2]
cos = self.cos_cache[offset: offset + seq_len] # (seq_len, head_dim)
sin = self.sin_cache[offset: offset + seq_len]
# Reshape for broadcasting: (1, 1, seq_len, head_dim)
cos = cos.unsqueeze(0).unsqueeze(0)
sin = sin.unsqueeze(0).unsqueeze(0)
q_rot = q * cos + self._rotate_half(q) * sin
k_rot = k * cos + self._rotate_half(k) * sin
return q_rot, k_rot
# ------------------------------------------------------------------ #
# Grouped-Query Attention (GQA)
# n_kv_heads < n_heads: each KV head is shared by (n_heads // n_kv_heads) Q heads.
# Reduces KV-cache size by 4x with negligible quality loss.
# ------------------------------------------------------------------ #
class GroupedQueryAttention(nn.Module):
def __init__(self, config: PyCraftConfig):
super().__init__()
self.d_model = config.d_model
self.n_heads = config.n_heads
self.n_kv_heads = config.n_kv_heads
self.head_dim = config.head_dim
self.use_qk_norm = config.use_qk_norm
self.n_rep = config.n_heads_per_kv # Q heads per KV head
# Projections — no bias (standard in modern LLMs)
self.wq = nn.Linear(config.d_model, config.n_heads *
config.head_dim, bias=False)
self.wk = nn.Linear(
config.d_model, config.n_kv_heads * config.head_dim, bias=False)
self.wv = nn.Linear(
config.d_model, config.n_kv_heads * config.head_dim, bias=False)
self.wo = nn.Linear(config.n_heads * config.head_dim,
config.d_model, bias=False)
# QK-Norm: RMSNorm on Q and K before RoPE
# From OLMo 2 (2025) and Qwen 3 — stabilises training of small models
if self.use_qk_norm:
self.q_norm = RMSNorm(config.head_dim)
self.k_norm = RMSNorm(config.head_dim)
# RoPE
self.rope = RotaryEmbedding(
head_dim=config.head_dim,
max_seq_len=config.max_seq_len,
theta=config.rope_theta,
)
def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor:
"""
Expand KV heads to match Q head count for SDPA.
(batch, n_kv_heads, seq, head_dim) → (batch, n_heads, seq, head_dim)
"""
if self.n_rep == 1:
return x
batch, n_kv, seq, head_dim = x.shape
x = x.unsqueeze(2).expand(batch, n_kv, self.n_rep, seq, head_dim)
return x.reshape(batch, n_kv * self.n_rep, seq, head_dim)
def forward(
self,
x: torch.Tensor, # (batch, seq_len, d_model)
attn_mask: torch.Tensor | None = None, # causal mask, pre-built
) -> torch.Tensor:
batch, seq_len, _ = x.shape
# 1. Project to Q, K, V
q = self.wq(x) # (batch, seq, n_heads * head_dim)
k = self.wk(x) # (batch, seq, n_kv_heads * head_dim)
v = self.wv(x) # (batch, seq, n_kv_heads * head_dim)
# 2. Reshape into (batch, heads, seq, head_dim)
q = q.view(batch, seq_len, self.n_heads,
self.head_dim).transpose(1, 2)
k = k.view(batch, seq_len, self.n_kv_heads,
self.head_dim).transpose(1, 2)
v = v.view(batch, seq_len, self.n_kv_heads,
self.head_dim).transpose(1, 2)
# 3. QK-Norm (applied per-head, before RoPE)
# Normalise each head vector independently
if self.use_qk_norm:
q = self.q_norm(q)
k = self.k_norm(k)
# 4. Apply RoPE to Q and K
q, k = self.rope(q, k)
# 5. Expand K and V to match Q head count (GQA expansion)
k = self._repeat_kv(k) # (batch, n_heads, seq, head_dim)
v = self._repeat_kv(v)
# 6. Scaled dot-product attention via PyTorch native SDPA
# Uses memory-efficient attention automatically on Ampere GPUs.
# is_causal=True applies the causal mask internally — no need to
# pass an explicit mask during training (faster + less memory).
with sdpa_kernel(SDPBackend.EFFICIENT_ATTENTION):
attn_out = F.scaled_dot_product_attention(
q, k, v,
attn_mask=None, # let is_causal handle it
dropout_p=0.0,
is_causal=True,
)
# 7. Merge heads and project back to d_model
# (batch, n_heads, seq, head_dim) → (batch, seq, d_model)
attn_out = attn_out.transpose(1, 2).contiguous().view(
batch, seq_len, self.d_model)
return self.wo(attn_out)
# ------------------------------------------------------------------ #
# Quick self-test
# ------------------------------------------------------------------ #
if __name__ == "__main__":
from model.config import get_config_tiny
torch.manual_seed(42)
device = "cuda" if torch.cuda.is_available() else "cpu"
cfg = get_config_tiny()
print(f"Testing GroupedQueryAttention on {device}...")
print(f" n_heads={cfg.n_heads}, n_kv_heads={cfg.n_kv_heads}, "
f"head_dim={cfg.head_dim}, use_qk_norm={cfg.use_qk_norm}")
attn = GroupedQueryAttention(cfg).to(device)
# Count parameters
n_params = sum(p.numel() for p in attn.parameters())
print(f" Attention block params: {n_params:,}")
# Forward pass
x = torch.randn(2, 64, cfg.d_model, device=device) # batch=2, seq=64
with torch.no_grad():
out = attn(x)
print(f" Input shape: {tuple(x.shape)}")
print(f" Output shape: {tuple(out.shape)}")
assert out.shape == x.shape, "Output shape mismatch!"
# Test gradient flow
x.requires_grad_(True)
x_grad = torch.randn(2, 64, cfg.d_model, device=device)
x_grad.requires_grad_(True)
out2 = attn(x_grad)
loss = out2.sum()
loss.backward()
assert x_grad.grad is not None, "No gradient!"
print(f" Gradient norm: {x_grad.grad.norm().item():.4f}")
print(" All attention tests PASSED.")