Decoder-Only Transformers & KV Cache
Derive causal attention masking, Root Mean Square Normalization, Rotary Position Embeddings (RoPE), KV Cache memory bottlenecks, and zero-dependency implementations in Python and Rust.
While the original 2017 Transformer was an encoder-decoder architecture designed for machine translation, modern foundation Large Language Models (LLMs) like GPT-4, LLaMA-3, and Mistral are strictly Decoder-Only.
Decoder-only architectures optimize auto-regressive next-token prediction by combining Causal Self-Attention, RMSNorm, Rotary Position Embeddings (RoPE), and inference-time Key-Value (KV) Caching.
Caching key-value tensors eliminates redundant historical projections during auto-regressive generation.
1. Causal Masking & Pre-LN Transformer Blocks#
Auto-regressive language modeling factorizes the joint probability of token sequence into conditional next-token probabilities:
To prevent token from attending to future tokens during parallel training, an upper-triangular causal mask is added before softmax:
Because , attention weights for all future positions evaluate strictly to zero.
Root Mean Square Normalization (RMSNorm)#
Zhang and Sennrich (2019) discovered that the scaling property of LayerNorm dominates its stabilizing effect, whereas mean-centering is computationally redundant. RMSNorm discards mean calculation:
Where is a learnable gain parameter. RMSNorm reduces normalization runtime by 7% to 10% across deep architectures.
2. Rotary Position Embeddings (RoPE)#
Su et al. (2021) introduced Rotary Position Embeddings (RoPE) to encode relative positional geometry directly into Query and Key inner products.
We seek an embedding function such that the inner product between query at position and key at position depends purely on relative offset :
Derivation in the Complex 2D Plane#
Treating a 2D coordinate pair as a complex number :
Multiplying by rotates the vector by angle :
Now, compute the inner product of rotated query and rotated key:
Because rotation matrices are orthogonal and commutative in 2D:
Therefore:
The inner product is mathematically determined solely by relative distance , enabling natural length generalization.
3. KV Cache Architecture and Arithmetic Intensity#
During inference generation, tokens are emitted auto-regressively one token at a time.
The KV Cache Principle#
At timestep , historical tokens have already been processed. Their Key and Value projections do not change:
- Cache previous key tensors and value tensors in GPU memory.
- At step , compute Query , new Key , and new Value for the current token only.
- Append to and to .
- Compute attention vector:
KV Cache Memory Footprint Formula#
For a model with layers, attention heads, head dimension , context length , batch size , and 16-bit floating point precision ( bytes):
For LLaMA-3-70B () with context length and batch size :
This proves why LLM token generation is memory-bandwidth bound (high memory transfer, low arithmetic compute) rather than compute-bound.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations construct an Autoregressive Causal Attention Layer with an Explicit KV Cache from scratch in pure Python and pure Rust.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import math from typing import List, Tuple, Optional class CausalKVCacheAttention: def __init__(self, d_model: int = 4, d_k: int = 2): self.d_model = d_model self.d_k = d_k self.scale = 1.0 / math.sqrt(d_k) # Cache storage for keys and values across timesteps self.k_cache: List[List[float]] = [] self.v_cache: List[List[float]] = [] def reset_cache(self): self.k_cache.clear() self.v_cache.clear() @staticmethod def _dot(a: List[float], b: List[float]) -> float: return sum(x * y for x, y in zip(a, b)) @staticmethod def _softmax(logits: List[float]) -> List[float]: max_val = max(logits) exps = [math.exp(x - max_val) for x in logits] sum_exp = sum(exps) return [e / sum_exp for e in exps] def forward_step( self, q_t: List[float], k_t: List[float], v_t: List[float] ) -> List[float]: # 1. Append current token key and value to cache self.k_cache.append(k_t) self.v_cache.append(v_t) seq_len = len(self.k_cache) # 2. Compute attention scores: q_t * K_cache^T / sqrt(d_k) scores = [ self._dot(q_t, self.k_cache[pos]) * self.scale for pos in range(seq_len) ] # 3. Softmax over all past positions (causal by construction) weights = self._softmax(scores) # 4. Multiply weights by V_cache output = [0.0] * len(v_t) for pos in range(seq_len): for col in range(len(v_t)): output[col] += weights[pos] * self.v_cache[pos][col] return output if __name__ == "__main__": attn = CausalKVCacheAttention(d_model=4, d_k=2) # Simulate 3 autoregressive generation steps steps = [ ([1.0, 0.5], [1.0, 0.5], [10.0, 1.0]), # Token 1 ([0.8, 1.2], [0.8, 1.2], [20.0, 2.0]), # Token 2 ([0.2, 0.9], [0.2, 0.9], [30.0, 3.0]), # Token 3 ] print("Autoregressive Generation with KV Cache:") for step_idx, (q, k, v) in enumerate(steps): out_t = attn.forward_step(q, k, v) print(f"Step {step_idx + 1} (Cache Size: {len(attn.k_cache)}):") print(" Emitted Representation:", [round(x, 4) for x in out_t])
5. Active Knowledge Checks#
Why does Rotary Position Embedding (RoPE) guarantee that query-key attention scores depend purely on relative token distance m - n?
What computational bottleneck shift occurs when deploying a KV Cache during Large Language Model token generation?