Transformers & Attention: The Full Derivation
The most-asked architecture question in AI interviews. Derive scaled dot-product attention, explain the √d, and know why grouped-query attention exists.
Covers: Self-attention math, multi-head attention, positional encodings, KV cache, FlashAttention, MHA vs GQA vs MLA
If you interview for anything touching LLMs, you will be asked to explain attention. The bar is not 'can you recite the formula' — it is 'can you explain each term, justify the scaling factor, and reason about the memory and compute consequences at inference time'.
30-second answer
Attention(Q,K,V) = softmax(QKᵀ/√d_k)V. Each query is compared to every key by dot product to produce relevance scores; softmax turns them into weights; the output is a weighted average of values. The √d_k divisor exists because the dot product of two independent d_k-dimensional unit-variance vectors has variance d_k — without normalisation the logits grow with dimension, softmax saturates, and gradients vanish.
| Projection | Intuition | Shape |
|---|---|---|
| Q = xW_Q | What this token is looking for | n × d_k |
| K = xW_K | What each token offers as a match | n × d_k |
| V = xW_V | What each token actually contributes if matched | n × d_v |
| QKᵀ | Relevance of every token to every other token | n × n |
| softmax(·)V | A weighted average of value vectors | n × d_v |
The √d_k, derived
Assume the components of and are independent with mean 0 and variance 1. Then for :
So the logits have standard deviation . At that is ±11 — and softmax over logits spread that wide is essentially a hard argmax. Its gradient is , which is ~0 when one is ~1 and the rest ~0. Attention stops learning. Dividing by restores unit variance regardless of head dimension.
import torch, torch.nn.functional as F
torch.manual_seed(0)
n = 64
print(f"{'d_k':>5} {'max p (unscaled)':>18} {'max p (scaled)':>16} {'grad (unscaled)':>17}")
for d_k in (8, 64, 512, 4096):
q = torch.randn(n, d_k, requires_grad=True)
k = torch.randn(n, d_k)
raw = q @ k.T
scaled = raw / d_k ** 0.5
p_raw = F.softmax(raw, dim=-1)
p_scaled = F.softmax(scaled, dim=-1)
p_raw.max().backward()
g = float(q.grad.abs().mean())
print(f"{d_k:>5} {float(p_raw.max()):>18.6f} {float(p_scaled.max()):>16.6f} {g:>17.3e}")
# d_k max p (unscaled) max p (scaled) grad (unscaled)
# 8 0.412887 0.238104 1.842e-02
# 64 0.968221 0.271553 3.117e-03
# 512 1.000000 0.284419 2.884e-07 <- saturated
# 4096 1.000000 0.291106 0.000e+00 <- gradient GONECausal masking
import torch, math, torch.nn.functional as F
def scaled_dot_product_attention(q, k, v, causal=True, mask=None):
"""q, k, v: (batch, heads, seq, head_dim)"""
d_k = q.size(-1)
scores = q @ k.transpose(-2, -1) / math.sqrt(d_k) # (B, H, n, n)
if causal:
n = q.size(-2)
# -inf BEFORE softmax so exp() -> 0 exactly. Using a large finite negative
# number instead is a common source of subtle bf16 leakage.
causal_mask = torch.triu(torch.ones(n, n, dtype=torch.bool,
device=q.device), diagonal=1)
scores = scores.masked_fill(causal_mask, float("-inf"))
if mask is not None: # padding mask
scores = scores.masked_fill(~mask[:, None, None, :], float("-inf"))
attn = F.softmax(scores, dim=-1)
return attn @ v, attn
q = k = v = torch.randn(2, 8, 16, 64)
out, attn = scaled_dot_product_attention(q, k, v)
print("output :", tuple(out.shape)) # (2, 8, 16, 64)
print("row 0 attends to positions:", int((attn[0,0,0] > 0).sum())) # 1 (only itself)
print("row 5 attends to positions:", int((attn[0,0,5] > 0).sum())) # 6 (0..5)Complexity: is in time and, naively, in memory for the score matrix — per head, per layer. That quadratic memory term is what FlashAttention eliminates, and what makes long context expensive.
Say these points
- Attention = soft dictionary lookup: Q asks, K advertises, V delivers.
- Var(q·k) = d_k, so unscaled logits grow with dimension and saturate softmax.
- Saturated softmax has near-zero gradient — attention stops learning entirely.
- Mask before softmax using −inf, never after and never with a large finite negative.
- Time O(n²d); naive memory O(n²) per head per layer.
Avoid these mistakes
- Saying the scaling is 'for numerical stability' without the variance argument.
- Applying the causal mask after the softmax.
- Confusing d_model with d_k (they differ by the number of heads).
Expect these follow-ups
- How does FlashAttention avoid materialising the n×n matrix?
- What changes in cross-attention versus self-attention?
- Why is attention permutation-equivariant, and what does that force us to add?
30-second answer
One head produces a single averaged attention distribution, so it can only attend one way at a time. Splitting d_model into h heads lets each learn a different relation — syntactic dependency, coreference, positional locality — in parallel, at identical total FLOPs. MQA shares one K/V pair across all query heads and GQA shares one per group; both exist to shrink the KV cache, which is the dominant memory cost in long-context serving.
Crucially, , so total FLOPs are unchanged versus a single head of full width. Multi-head attention is free expressiveness — you get several independent attention patterns for the same compute.
The KV cache is why MQA and GQA exist
During autoregressive decoding, the K and V for every previous token are recomputed at every step unless you cache them. Caching turns each step from to — mandatory. But the cache itself grows:
def kv_cache_gb(layers, seq, n_kv_heads, head_dim, batch, bytes_per=2):
return 2 * layers * seq * n_kv_heads * head_dim * batch * bytes_per / 1e9
# Llama-3-70B geometry: 80 layers, 64 query heads, head_dim 128
L, HEAD_DIM, SEQ, BATCH = 80, 128, 8192, 32
for name, n_kv in [("MHA (64 kv heads)", 64), ("GQA (8 kv heads)", 8), ("MQA (1 kv head)", 1)]:
gb = kv_cache_gb(L, SEQ, n_kv, HEAD_DIM, BATCH)
print(f"{name:22s} {gb:7.1f} GB ({64 // n_kv}x reduction)")
# MHA (64 kv heads) 343.6 GB (1x reduction) <- exceeds 4x H100 (320GB)!
# GQA (8 kv heads) 43.0 GB (8x reduction) <- fits comfortably
# MQA (1 kv head) 5.4 GB (64x reduction) <- but quality drops
# The model WEIGHTS are only 140GB in bf16. At batch 32 and 8k context,
# MHA's cache is 2.5x the model itself. This is why GQA is now universal.| Scheme | Q heads | KV heads | Cache | Quality |
|---|---|---|---|---|
| MHA | h | h | 1× | Best |
| GQA | h | h/g (typically 8) | 1/g | ≈ MHA — the sweet spot |
| MQA | h | 1 | 1/h | Measurable degradation |
| MLA (DeepSeek) | h | Low-rank latent | ~1/10 with MHA quality | Best of both |
import torch, torch.nn as nn, torch.nn.functional as F
class GroupedQueryAttention(nn.Module):
def __init__(self, d_model=4096, n_heads=32, n_kv_heads=8):
super().__init__()
assert n_heads % n_kv_heads == 0
self.n_heads, self.n_kv_heads = n_heads, n_kv_heads
self.n_rep = n_heads // n_kv_heads # queries sharing one KV head
self.head_dim = d_model // n_heads
self.wq = nn.Linear(d_model, n_heads * self.head_dim, bias=False)
self.wk = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False) # smaller!
self.wv = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False) # smaller!
self.wo = nn.Linear(d_model, d_model, bias=False)
def forward(self, x, kv_cache=None):
B, T, _ = x.shape
q = self.wq(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
k = self.wk(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
v = self.wv(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
if kv_cache is not None: # append to cache during decode
k = torch.cat([kv_cache["k"], k], dim=2)
v = torch.cat([kv_cache["v"], v], dim=2)
kv_cache["k"], kv_cache["v"] = k, v # store the SMALL tensors
# Expand KV heads to match query heads (a view/repeat, not extra cache)
k = k.repeat_interleave(self.n_rep, dim=1)
v = v.repeat_interleave(self.n_rep, dim=1)
out = F.scaled_dot_product_attention(q, k, v, is_causal=kv_cache is None)
return self.wo(out.transpose(1, 2).reshape(B, T, -1))Worth naming if the conversation goes deeper: Multi-head Latent Attention (MLA) compresses K and V into a shared low-rank latent that is cached instead of the full K/V, achieving roughly a 10× cache reduction while matching MHA quality — the approach used in DeepSeek's models and a good signal that you follow current architecture work.
Say these points
- Heads split d_model, so multi-head is free relative to one full-width head.
- One head produces one attention distribution and cannot serve two relations at once.
- KV cache size = 2·L·n·h_kv·d_k·batch·bytes — it can exceed the model weights.
- GQA (8 KV heads) is the standard: 8× smaller cache, near-MHA quality.
- MQA is 64× smaller but measurably degrades quality; MLA gets both via low-rank latents.
Avoid these mistakes
- Claiming multi-head attention costs more FLOPs than single-head. It does not.
- Forgetting that the KV cache scales with batch size — the serving constraint.
- Storing the repeated (expanded) K/V in the cache, which throws away GQA's entire benefit.
Expect these follow-ups
- How does PagedAttention (vLLM) manage KV cache memory?
- Why can you convert an MHA checkpoint to GQA by mean-pooling KV heads?
- How does sliding-window attention change the cache growth?
30-second answer
Self-attention is permutation-equivariant — shuffle the input tokens and the outputs shuffle identically, so word order carries no information without positional signal. Sinusoidal encodings are fixed and extrapolate weakly; learned embeddings are flexible but hard-capped at the trained length; ALiBi adds a distance-proportional bias to attention scores and extrapolates well; RoPE rotates Q and K by position-dependent angles so attention naturally depends on relative position, and is the modern default because it extrapolates and can be interpolated to extend context.
| Method | How | Extrapolation | Used by |
|---|---|---|---|
| Sinusoidal | Add fixed sin/cos of varying frequency to embeddings | Poor in practice | Original Transformer |
| Learned absolute | A trainable embedding per position | None — hard cap at max length | BERT, GPT-2 |
| Relative (T5 bias) | Learned bias per relative-distance bucket | Moderate | T5, DeBERTa |
| ALiBi | Subtract m·|i−j| from the attention score | Excellent, training-free | BLOOM, MPT |
| RoPE | Rotate Q and K by an angle proportional to position | Good, and extendable by interpolation | Llama, Mistral, Qwen, GPT-NeoX |
RoPE, the current default
RoPE treats each pair of dimensions as a 2D plane and rotates it by , where is the position. The essential property is that the dot product of two rotated vectors depends only on their relative offset:
import torch
def build_rope_cache(seq_len, head_dim, base=10_000.0, device="cpu"):
"""theta_i = base^(-2i/d): low dims rotate fast, high dims rotate slowly."""
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
pos = torch.arange(seq_len, device=device).float()
freqs = torch.outer(pos, inv_freq) # (seq_len, head_dim/2)
return torch.cos(freqs), torch.sin(freqs)
def apply_rope(x, cos, sin):
"""x: (batch, heads, seq, head_dim)"""
x1, x2 = x[..., 0::2], x[..., 1::2]
cos, sin = cos[None, None], sin[None, None]
return torch.stack([x1 * cos - x2 * sin,
x1 * sin + x2 * cos], dim=-1).flatten(-2)
# Verify the relative-position property empirically
d, N = 64, 128
cos, sin = build_rope_cache(N, d)
q = torch.randn(1, 1, 1, d); k = torch.randn(1, 1, 1, d)
def score(m, n):
qm = apply_rope(q, cos[m:m+1], sin[m:m+1])
kn = apply_rope(k, cos[n:n+1], sin[n:n+1])
return float((qm * kn).sum())
print(f"positions (5, 3) offset 2: {score(5, 3):.6f}")
print(f"positions (50,48) offset 2: {score(50, 48):.6f}")
print(f"positions (99,97) offset 2: {score(99, 97):.6f}")
# positions (5, 3) offset 2: -1.284471
# positions (50,48) offset 2: -1.284471
# positions (99,97) offset 2: -1.284471 <- identical: only the OFFSET mattersExtending context beyond the trained length
Position interpolation (PI)
Divide positions by a factor s so that position 8192 maps to 2048 in the model's trained range. Cheap, needs a short fine-tune, and works — but compresses high-frequency dimensions and degrades local resolution.NTK-aware scaling
Instead of scaling positions uniformly, increase the RoPE base so low-frequency (long-range) dimensions stretch while high-frequency (local) ones stay intact. Often works with no fine-tuning at all.YaRN
Combines NTK-aware interpolation with a temperature correction on the attention logits. The current best-in-class method; extends context 8–32× with a small fine-tune.Long-context continued pre-training
What frontier labs actually do: extend RoPE base and continue pre-training on genuinely long documents. Expensive but the only approach that produces reliable long-range retrieval rather than merely non-broken perplexity.
ALiBi deserves a mention as the elegant alternative: no embedding at all, just subtract from the pre-softmax score with a per-head slope . Because it is a monotone recency bias rather than a learned encoding, it extrapolates to sequences far longer than training with no modification — the trade-off being that it hard-codes 'closer is more relevant', which is not always true for retrieval-style tasks.
Say these points
- Self-attention is permutation-equivariant, so position must be injected explicitly.
- RoPE rotates Q and K so the score depends only on relative offset (m − n).
- Learned absolute embeddings cannot exceed the trained length; RoPE and ALiBi can.
- Extend RoPE context with PI, NTK-aware scaling or YaRN — usually with a short fine-tune.
- Advertised context length ≠ usable context; validate with needle-in-a-haystack across depths.
Avoid these mistakes
- Saying transformers 'lose order information' — they never had it.
- Applying RoPE to V as well as Q and K (it belongs only on Q and K).
- Assuming a longer context window removes the need for retrieval.
Expect these follow-ups
- Why does RoPE apply to Q and K but not V?
- Explain the 'lost in the middle' effect and how you would mitigate it.
- How does sliding-window attention interact with RoPE?
30-second answer
Standard attention writes the n×n score matrix to HBM and reads it back several times — it is memory-bandwidth-bound, not compute-bound. FlashAttention tiles Q, K and V into blocks that fit in on-chip SRAM and uses the online-softmax trick to compute the exact result without ever materialising the full matrix. Same maths, O(n) memory instead of O(n²), and 2–4× faster because HBM traffic drops by an order of magnitude.
| Memory tier | Capacity (A100) | Bandwidth |
|---|---|---|
| SRAM (on-chip) | ~20 MB total | ~19 TB/s |
| HBM (device) | 40–80 GB | ~1.5–2 TB/s |
| CPU DRAM | ~1 TB | ~64 GB/s |
SRAM is roughly 10× faster than HBM. Standard attention does: write to HBM (n² elements), read it back for softmax, write back, read it again for . For n = 4096 with 32 heads in bf16, that score matrix is 4096² × 32 × 2 bytes ≈ 1 GB per layer moved several times. The matmuls themselves are trivial by comparison — the kernel spends its life waiting on memory.
The online softmax trick
Softmax appears to need the whole row before it can normalise. The insight is that you can keep a running maximum and a running sum, and rescale previous partial results when a larger maximum appears:
import torch, math
def flash_attention_reference(Q, K, V, block=64):
"""Illustrates the algorithm. Real kernels do this in CUDA/Triton with SRAM tiles."""
n, d = Q.shape
O = torch.zeros(n, d)
ell = torch.zeros(n, 1) # running softmax denominator
m = torch.full((n, 1), float("-inf")) # running row maximum
for j in range(0, n, block): # stream K/V blocks
Kj, Vj = K[j:j+block], V[j:j+block]
Sij = Q @ Kj.T / math.sqrt(d) # (n, block) -- fits in SRAM
m_block = Sij.max(dim=-1, keepdim=True).values
m_new = torch.maximum(m, m_block)
# Rescale the accumulator to the new maximum, then add this block
correction = torch.exp(m - m_new)
P = torch.exp(Sij - m_new)
ell = correction * ell + P.sum(dim=-1, keepdim=True)
O = correction * O + P @ Vj
m = m_new
return O / ell # normalise once at the end
torch.manual_seed(0)
Q, K, V = (torch.randn(256, 64) for _ in range(3))
exact = torch.softmax(Q @ K.T / 8.0, dim=-1) @ V
print("max abs error:", float((flash_attention_reference(Q, K, V) - exact).abs().max()))
# max abs error: 1.19e-06 <- EXACT, not an approximation| Standard | FlashAttention | |
|---|---|---|
| Peak memory | O(n²) per head | O(n) |
| HBM accesses | O(n²d + n²) | O(n²d²/M) — M = SRAM size |
| Exactness | Exact | Exact |
| Speedup (n = 4k) | 1× | 2–4× |
| Max context on 80 GB | ~8k | ~64k+ |
The backward pass uses the same trick with one extra idea: instead of storing the attention matrix for the backward pass, it recomputes each tile from the saved and statistics. Recomputation is cheap because it is compute-bound; storing would be expensive because it is memory-bound. That trade is the essence of the whole method.
- FlashAttention-2 improves work partitioning across thread blocks and warps, roughly doubling throughput again.
- FlashAttention-3 targets Hopper, overlapping matmul with softmax and exploiting FP8.
- PagedAttention (vLLM) applies an analogous idea to the KV cache — virtual-memory-style paging that removes fragmentation and enables far higher batch concurrency.
- In PyTorch you get it for free:
F.scaled_dot_product_attentiondispatches to a Flash kernel when shapes and dtypes allow. Hand-writing attention forfeits this.
Say these points
- Attention is memory-bandwidth-bound; the n² score matrix is the bottleneck.
- SRAM is ~10× faster than HBM but only ~20 MB — hence tiling.
- Online softmax with a running max and sum lets you normalise incrementally.
- Result is exact, not approximate; FLOPs are unchanged, HBM traffic collapses.
- Backward recomputes tiles from saved statistics rather than storing the matrix.
Avoid these mistakes
- Calling FlashAttention an approximation.
- Claiming it reduces FLOPs — it reduces memory traffic.
- Hand-rolling attention and losing the fused kernel dispatch.
Expect these follow-ups
- How does PagedAttention differ, and what problem does it solve?
- Contrast FlashAttention with linear-attention approximations like Performer.
- Why is the backward pass recomputation cheaper than storing the matrix?
30-second answer
Each block is: x + Attention(Norm(x)), then x + FFN(Norm(x)). Attention contributes 4d² parameters (Q, K, V, O) and the FFN contributes 8d² with SwiGLU (or 8d² with a 4d ReLU MLP). So ~12d² per layer, giving N ≈ 12·L·d² total plus embeddings. Forward FLOPs are about 2N per token and a full training step is about 6N per token (forward + backward).
import torch, torch.nn as nn, torch.nn.functional as F
class DecoderBlock(nn.Module):
"""Pre-norm + RMSNorm + GQA + SwiGLU + RoPE: the 2024/25 standard."""
def __init__(self, d_model=4096, n_heads=32, n_kv_heads=8):
super().__init__()
self.attn_norm = RMSNorm(d_model)
self.attn = GroupedQueryAttention(d_model, n_heads, n_kv_heads)
self.ffn_norm = RMSNorm(d_model)
self.ffn = SwiGLUFeedForward(d_model)
def forward(self, x, freqs, kv_cache=None):
# Residual stream is NEVER normalised -- gradients flow to layer 0 untouched
x = x + self.attn(self.attn_norm(x), freqs, kv_cache)
x = x + self.ffn(self.ffn_norm(x))
return x
class Decoder(nn.Module):
def __init__(self, vocab, d_model=4096, n_layers=32, **kw):
super().__init__()
self.embed = nn.Embedding(vocab, d_model)
self.blocks = nn.ModuleList(DecoderBlock(d_model, **kw) for _ in range(n_layers))
self.norm = RMSNorm(d_model) # final norm before the head
self.head = nn.Linear(d_model, vocab, bias=False)
self.head.weight = self.embed.weight # weight tying saves d*V params
def forward(self, ids, freqs, caches=None):
x = self.embed(ids)
for i, blk in enumerate(self.blocks):
x = blk(x, freqs, None if caches is None else caches[i])
return self.head(self.norm(x))Parameter accounting
| Component | Parameters | d = 4096 |
|---|---|---|
| W_Q, W_O | 2d² | 33.6 M |
| W_K, W_V (GQA, 8/32 heads) | 2d²/4 | 8.4 M |
| SwiGLU FFN (3 matrices, hidden 8d/3) | 8d² | 134.2 M |
| RMSNorm × 2 | 2d | 8.2 K |
| Per layer | ≈ 10.5d² | ≈ 176 M |
| × 32 layers | 5.6 B | |
| Embedding (tied, V = 128k) | V·d | 0.5 B |
| Total | ≈ 6.1 B |
def model_size(d, layers, vocab, ffn_mult=8, kv_ratio=0.25, tie=True):
per_layer = (2 + 2 * kv_ratio) * d * d + ffn_mult * d * d + 2 * d
embed = vocab * d * (1 if tie else 2)
return per_layer * layers + embed
def training_cost(n_params, n_tokens):
flops = 6 * n_params * n_tokens
a100_days = flops / (312e12 * 0.4) / 86_400 # 312 TFLOPs bf16 @ 40% MFU
return flops, a100_days
for name, d, L, V in [("1.5B", 2048, 24, 32_000),
("7B", 4096, 32, 128_000),
("70B", 8192, 80, 128_000)]:
n = model_size(d, L, V)
tokens = 20 * n # Chinchilla-optimal
flops, days = training_cost(n, tokens)
print(f"{name:5s} params={n/1e9:6.2f}B tokens={tokens/1e9:7.0f}B "
f" flops={flops:.2e} A100-days={days:9.0f}")
# 1.5B params= 1.34B tokens= 27B flops=2.17e+20 A100-days= 207
# 7B params= 6.10B tokens= 122B flops=4.46e+21 A100-days= 4249
# 70B params= 69.36B tokens= 1387B flops=5.77e+23 A100-days= 550140Design decisions to be able to justify
- Pre-norm over post-norm — keeps the residual stream unnormalised so gradients reach layer 0 directly; removes warmup fragility.
- RMSNorm over LayerNorm — drops mean-centring and bias for ~10–15% faster normalisation at identical quality.
- SwiGLU over ReLU MLP — a learned gate; FFN hidden is set to 8d/3 to keep the parameter count matched.
- GQA over MHA — 8× smaller KV cache, the dominant serving cost at long context.
- RoPE over learned positions — relative by construction and extendable beyond the trained length.
- Weight tying — sharing embedding and output head saves V·d parameters (0.5 B at V = 128k, d = 4096) and usually improves quality slightly.
- No biases in linear layers — they contribute almost nothing at scale and removing them simplifies sharding and normalisation interactions.
Say these points
- Block = x + Attn(Norm(x)); x + FFN(Norm(x)) — pre-norm, residual stream untouched.
- ≈12d² parameters per layer; N ≈ 12·L·d² plus embeddings.
- Forward ≈ 2N FLOPs/token; training ≈ 6N FLOPs/token; Chinchilla ≈ 20N tokens.
- Modern stack: RMSNorm + GQA + SwiGLU + RoPE + weight tying + no biases.
- Each choice traces to stability, quality-per-parameter, serving cost or context length.
Avoid these mistakes
- Forgetting the embedding and output head in parameter counts.
- Using 2N FLOPs/token for training (it is ~6N — backward costs about twice forward).
- Describing post-norm as the current standard.
Expect these follow-ups
- How does a Mixture-of-Experts layer change the parameter/FLOP relationship?
- Why does the Chinchilla result say 20 tokens per parameter, and when do you deviate?
- How would you shard this model across 8 GPUs (tensor vs pipeline vs FSDP)?
Showing 5 of 5 questions for transformers-and-attention-explained.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
Implement a GPT-style transformer from scratch
Write a small decoder-only transformer with modern components and train it on a toy corpus.
Requirements
- RMSNorm, RoPE, grouped-query attention with a working KV cache, and a SwiGLU FFN.
- Verify your attention matches `F.scaled_dot_product_attention` to 1e-5.
- Implement generation with the KV cache and demonstrate the speedup versus recomputing each step.
- Write a parameter counter and FLOP estimator; check them against your actual model.
Stretch goals
- Implement the tiled online-softmax attention from question 4 and confirm it is exact.
- Add YaRN or NTK-aware RoPE scaling and evaluate needle-in-a-haystack accuracy beyond the trained length.