Transformer Deep Dive
In 2017, Google published the paper "Attention Is All You Need", introducing the Transformer architecture. No one expected that this paper would completely change the AI field.
Today's GPT, Claude, Gemini, Llama... almost all mainstream large language models are essentially variants of the Transformer.
Understand the Transformer, and you understand 90% of modern LLMs.
In this article, we will dive into every component of the Transformer: self-attention, multi-head attention, positional encoding, feed-forward networks, layer normalization... covering not only "what" but also "why".
This is one of the most technically in-depth modules. We will use code to demonstrate the core computations, ensuring that you not only understand the formulas but can also implement them yourself.
Transformer Prequel: Limitations of RNN
Before the Transformer, sequence tasks (such as translation and text generation) were mainly handled by RNNs (Recurrent Neural Networks) and their variants LSTM and GRU.
How RNN Works
The core idea of RNN is "process one by one": the input sequence enters the network word by word, and the output at each step contains information from all previous words.
Example
# Simplified RNN forward propagation demonstration
# ============================================
import math
class SimpleRNN:
"""Simplified RNN implementation to demonstrate the principle"""
def __init__(self, input_size: int, hidden_size: int):
"""Initialize RNN parameters"""
import random
random.seed(42) # Set random seed for reproducibility
# Weights from input to hidden layer
self.Wx = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(input_size)]
# Weights from hidden layer to hidden layer
self.Wh = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(hidden_size)]
# Bias
self.b = [0.0 for _ in range(hidden_size)]
def step(self, x: list, h_prev: list) -> list:
"""Single-step RNN: takes input x and previous hidden state h_prev, outputs new hidden state"""
hidden_size = len(h_prev)
h_new = [0.0 for _ in range(hidden_size)]
# h_new = tanh(Wx·x + Wh·h_prev + b)
for i in range(hidden_size):
# Compute Wx·x
wx_sum = sum(x[j] * self.Wx[j][i] for j in range(len(x)))
# Compute Wh·h_prev
wh_sum = sum(h_prev[j] * self.Wh[j][i] for j in range(hidden_size))
# Add bias, pass through tanh
h_new[i] = math.tanh(wx_sum + wh_sum + self.b[i])
return h_new
def forward(self, sequence: list) -> list:
"""Process the full sequence"""
hidden_size = len(self.b)
h = [0.0 for _ in range(hidden_size)] # Initial hidden state
hidden_states = []
for x in sequence:
h = self.step(x, h)
hidden_states.append(h)
return hidden_states
# Test: demonstrate with a simple vector sequence
input_size = 4
hidden_size = 3
rnn = SimpleRNN(input_size, hidden_size)
# Assume the input sequence is 4 words, each represented by a 4-dimensional vector
sequence = [
[1.0, 0.0, 0.0, 0.0], # Word 1
[0.0, 1.0, 0.0, 0.0], # Word 2
[0.0, 0.0, 1.0, 0.0], # Word 3
[0.0, 0.0, 0.0, 1.0], # Word 4
]
hidden_states = rnn.forward(sequence)
print("Example Simple RNN Demo")
print("=" * 40)
for i, h in enumerate(hidden_states):
print(f"Hidden state at step {i+1}: {[f'{v:.4f}' for v in h]}")
# Output:
# Example Simple RNN Demo
# ========================================
# Hidden state at step 1: ['0.0204', '-0.0434', '0.0556']
# Hidden state at step 2: ['-0.0061', '-0.0529', '0.0775']
# Hidden state at step 3: ['0.0641', '-0.0409', '0.0655']
# Hidden state at step 4: ['0.0143', '-0.0805', '0.0277']
RNN seems reasonable, but it has three fatal problems:
Problem 1: Vanishing Gradients, Difficulty Remembering Long-Range Dependencies
RNN is a "chain" structure; the gradient at each step must backpropagate to the first step. After repeated multiplications, the gradient decays exponentially, becoming almost 0.
For example, in the sentence "I went to Paris in 2010, ... that was my favorite city" — "city" needs to refer to "Paris", but there are too many words in between, so RNNs struggle to learn such long-range dependencies.
LSTM and GRU alleviate this problem, but do not completely solve it.
Problem 2: Cannot Compute in Parallel, Slow Training
An RNN must wait for step t-1 to finish before it can compute step t. This means:
- Even with 100 GPUs, you can only compute one word at a time
- The longer the sequence, the longer the training time
- Difficult to scale to very large datasets
Problem 3: Earlier Information Is Easily "Overwritten"
The hidden state of an RNN is updated step by step, and later information keeps overwriting earlier information. Important information at the beginning of a sentence may be largely "diluted" by the end.
The emergence of Transformer solved all three problems at once.
| Feature | RNN/LSTM | Transformer |
|---|---|---|
| Computation method | Serial, step by step | Parallel, compute all at once |
| Long-range dependencies | Weak, gradient vanishing | Strong, direct connection at any distance |
| Positional information | Naturally sequential | Requires positional encoding |
| Training speed | Slow | Fast (parallelizable) |
Self-Attention Mechanism
Self-attention is the core of Transformer. Its idea is simple:Each word must "communicate" with all words in the sentence, see who is important to itself, and then do a weighted sum by importance.
First look at the overall architecture diagram:
Intuitive Understanding of Q, K, V
Self-attention uses three vectors to describe each word:
- Q (Query)——"Who am I looking for?"—— which word this word wants to attend to
- K (Key)——"Who am I?"—— this word's identity
- V (Value)——"What do I have?"—— the actual content of this word
The computation process is:
- Each word uses its Q to "match" with the K of all words, obtaining attention scores
- Use Softmax to normalize the scores so they sum to 1
- Use the normalized scores to weight and sum the V of all words
The diagram makes it clearer:

Attention Score Calculation Formula
The complete formula is:
Attention(Q, K, V) = softmax( Q·Kᵀ / √dₖ ) · V
Let's implement it step by step in pure Python:
Example
# Pure Python implementation of Self-Attention
# ============================================
import math
def softmax(x: list) -> list:
"""Compute Softmax: turn a set of numbers into a probability distribution summing to 1"""
# Subtract the maximum value to prevent numerical overflow
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Matrix multiplication: A is m×n, B is n×p, output is m×p"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Matrix transpose"""
return list(map(list, zip(*matrix)))
class SelfAttention:
"""Pure Python implementation of self-attention layer"""
def __init__(self, d_model: int, d_k: int):
"""
Initialize self-attention layer
d_model: input/output dimension
d_k: dimension of Q/K (usually d_k = d_model / num_heads)
"""
import random
random.seed(42) # example: fixed random seed
self.d_model = d_model
self.d_k = d_k
# Initialize the projection matrices for Q, K, V
# In practice, these are trained
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
def forward(self, X: list) -> list:
"""
Self-attention forward propagation
X: input sequence, shape [seq_len, d_model]
Returns: output sequence, shape [seq_len, d_k]
"""
seq_len = len(X)
# Step 1: Compute Q, K, V
# Q = X·Wq, K = X·Wk, V = X·Wv
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
print(" Q's shape:", len(Q), "×", len(Q[0]))
print(" K's shape:", len(K), "×", len(K[0]))
print(" V's shape:", len(V), "×", len(V[0]))
# Step 2: Compute Attention Scores Q·Kᵀ
K_transposed = transpose(K)
scores = matrix_multiply(Q, K_transposed)
print("\nRaw attention scores:")
for i, row in enumerate(scores):
print(fRow {i}: {[f'{v:.4f}' for v in row]})
# Step 3: Scaling: divide by √d_k
# This is to prevent Softmax from entering the gradient saturation region.
scale_factor = math.sqrt(self.d_k)
scores_scaled = [[v / scale_factor for v in row] for row in scores]
print("\nScaled attention scores:)
for i, row in enumerate(scores_scaled):
print(fRow {i}: {[f'{v:.4f}' for v in row]})
# Step 4: Softmax normalization
attention_weights = []
for row in scores_scaled:
attention_weights.append(softmax(row))
print("\nAttention weights (after Softmax): ")
for i, row in enumerate(attention_weights):
print(fRow {i}: {[f'{v:.4f}' for v in row]})
# Step 5: Weighted sum V
output = matrix_multiply(attention_weights, V)
return output, attention_weights
# ============================================
# Demo: run self-attention with a simple example
# ============================================
# Suppose we have a 4-word sentence, each word represented by an 8-dimensional vector
# For demonstration, we use one-hot + small random noise
d_model = 8
d_k = 6
# Input sequence: 4 words, each 8-dimensional
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Word 0
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Word 1
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Word 2
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], # Word 3
]
print("=" * 50)
print("Example Self-Attention Demo")
print("=" * 50)
print(f"Input sequence length: {len(X)}")
print(f"Input dimension: {len(X)
print()
attn = SelfAttention(d_model=d_model, d_k=d_k)
output, attention_weights = attn.forward(X)
print("\n" + "=" * 50)
print("Self-attention output:")
print("=" * 50)
for i, row in enumerate(output):
print(f"Output of word {i}: {[f'{v:.4f}' for v in row]}")
Run this code and you will see the complete computation process: Q/K/V generation, attention score calculation, scaling, Softmax, and finally the weighted output.
Why Divide by √dₖ?
This is an important detail. Assuming each element of Q and K is a random variable with mean 0 and variance 1, the variance of their dot product is dₖ.
If dₖ is large (e.g., 512), the dot product values become very large, causing Softmax to enter the "saturation region"—the gradient is nearly 0, and training cannot proceed.
After dividing by √dₖ, the variance returns to 1, and the Softmax gradient becomes healthy again.
Scaled dot-product attention is one of the key techniques for stable Transformer training.
Multi-Head Attention
Single-head self-attention is powerful, but it has one problem: each word can only "attend" once. What if we want a word to attend to multiple different aspects at the same time?
For example, in the sentence "In the zoo, the tiger is chasing the rabbit":
- "Tiger" might need one head to attend to "zoo" (location)
- Another head to attend to "rabbit" (object of action)
- The third head focuses on "chasing" (action).
Multi-head attention splits Q/K/V into multiple "heads", each learning a different attention pattern, and finally concatenates them.
Steps to Compute Multi-Head Attention
- Split Q/K/V into h parts along the last dimension (h is the number of heads).
- Each head independently performs self-attention
- Concatenate the outputs of all heads
- Pass through a linear projection layer to get the final output
The formula is:
MultiHead(Q, K, V) = Concat(head₁, head₂, ..., headₕ) · Wₒ 其中 headᵢ = Attention(Q·Wqᵢ, K·Wkᵢ, V·Wvᵢ)
Example
# Pure Python implementation of Multi-Head Attention
# ============================================
import math
import random
random.seed(42) # example: fixed random seed
def softmax(x: list) -> list:
"""Compute Softmax"""
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Matrix multiplication"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Matrix transpose"""
return list(map(list, zip(*matrix)))
class MultiHeadAttention:
"""Pure Python implementation of the multi-head attention layer"""
def __init__(self, d_model: int, num_heads: int):
"""
Initialize multi-head attention
d_model: model dimension (must be divisible by num_heads)
num_heads: number of attention heads
"""
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # dimension of each head
# initialize projection matrices
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wo = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def split_heads(self, X: list) -> list:
"""
Split the last dimension into num_heads heads
Input shape: [seq_len, d_model]
Output shape: [num_heads, seq_len, d_k]
"""
seq_len = len(X)
# Reshape: [seq_len, num_heads, d_k]
reshaped = []
for row in X:
head_rows = []
for h in range(self.num_heads):
start = h * self.d_k
end = start + self.d_k
head_rows.append(row[start:end])
reshaped.append(head_rows)
# Transpose: [num_heads, seq_len, d_k]
result = []
for h in range(self.num_heads):
head_data = [reshaped[i][h] for i in range(seq_len)]
result.append(head_data)
return result
def combine_heads(self, heads: list) -> list:
"""
Concatenate the heads back together
Input shape: [num_heads, seq_len, d_k]
Output shape: [seq_len, d_model]
"""
seq_len = len(heads[0])
result = []
for i in range(seq_len):
combined = []
for h in range(self.num_heads):
combined.extend(heads[h][i])
result.append(combined)
return result
def attention_single_head(self, Q: list, K: list, V: list) -> list:
"""Single-head self-attention"""
# Q·K^T
scores = matrix_multiply(Q, transpose(K))
# Scaling
scale = math.sqrt(self.d_k)
scores_scaled = [[v / scale for v in row] for row in scores]
# Softmax
weights = [softmax(row) for row in scores_scaled]
# Weighted sum of V
output = matrix_multiply(weights, V)
return output, weights
def forward(self, X: list) -> list:
"""Multi-head attention forward pass"""
seq_len = len(X)
# Step 1: Compute Q, K, V
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
# Step 2: Split into multiple heads
Q_heads = self.split_heads(Q)
K_heads = self.split_heads(K)
V_heads = self.split_heads(V)
# Step 3: Each head performs self-attention independently
output_heads = []
all_weights = []
for h in range(self.num_heads):
out, weights = self.attention_single_head(
Q_heads[h], K_heads[h], V_heads[h]
)
output_heads.append(out)
all_weights.append(weights)
# Step 4: Concatenate all heads
combined = self.combine_heads(output_heads)
# Step 5: Final linear projection
output = matrix_multiply(combined, self.Wo)
return output, all_weights
# ============================================
# Demonstrate multi-head attention
# ============================================
d_model = 12
num_heads = 3 # 12 / 3 = 4 dimensions per head
# Input sequence: 4 tokens, each with 12 dimensions
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
]
print("=" * 50)
print("Example Multi-Head Attention Demo")
print("=" * 50)
print(f"d_model = {d_model}, num_heads = {num_heads}")
print(f"Dimension per head: d_k = {d_model // num_heads}")
print()
mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
output, all_weights = mha.forward(X)
print("\n"Attention weights for each head:")
for h in range(num_heads):
print(f"\n"Head {h}:")
for i, row in enumerate(all_weights[h]):
print(f" Position {i}: {[f'{v:.4f}' for v in row]}")
print("\n" + "=" * 50)
print("Multi-head attention final output shape:", len(output), "×", len(output[0]))
print("=" * 50)
for i, row in enumerate(output):
print(f"Position {i}: {[f'{v:.4f}' for v in row[:6]]}...")
The power of multi-head attention lies in:Different heads automatically learn different types of attention patterns.
For example, in translation tasks:
- Some heads focus on syntactic relationships (subject-verb-object)
- Some heads focus on coreference (which noun a pronoun refers to)
- Some heads focus on local neighboring words
These patterns are not manually designed; they are automatically learned by the model during training.
Positional Encoding
Self-attention has a problem: it is a "bag of words" model—it does not care about word order. "I love you" and "You love me" may look the same to self-attention.
But order is crucial to language. We need to "inject" positional information into the model.
Sinusoidal Positional Encoding
The original Transformer uses sinusoidal positional encoding:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
Where:
- pos is the position (0, 1, 2, ...)
- i is the dimension index
- d_model is the model dimension
This design is ingenious:
- Each position has a unique encoding
- Relative positions can be represented by linear combinations (sin(a+b) = sin a cos b + cos a sin b)
- It can extrapolate to longer sequences than those seen during training
Example
# Implement sinusoidal positional encoding
# ============================================
import math
def sinusoidal_positional_encoding(seq_len: int, d_model: int) -> list:
"""
Compute sinusoidal positional encoding
Returns: positional encoding matrix of shape [seq_len, d_model]
"""
pe = [[0.0 for _ in range(d_model)] for _ in range(seq_len)]
for pos in range(seq_len):
for i in range(0, d_model, 2):
# Use sin for even dimensions
div_term = math.pow(10000, 2 * i / d_model)
pe[pos][i] = math.sin(pos / div_term)
# Use cos for odd dimensions (if the next dimension exists)
if i + 1 < d_model:
pe[pos][i + 1] = math.cos(pos / div_term)
return pe
# Demo: generate positional encoding
seq_len = 10
d_model = 8
pe = sinusoidal_positional_encoding(seq_len, d_model)
print("=" * 50)
print("Example Sinusoidal Positional Encoding Demo")
print("=" * 50)
print(f"Sequence length: {seq_len}, Dimension: {d_model}")
print()
for pos in range(seq_len):
print(f"Position {pos:2d}: {[f'{v:.4f}' for v in pe[pos]]}")
# Examine the relationship of relative positions
print("\n" + "=" * 50)
print("Verify the linear relationship of relative positions")
print("=" * 50)
print("Encoding difference between position 0 and position 1:")
diff = [pe[1][i] - pe[0][i] for i in range(d_model)]
print([f'{v:.4f}' for v in diff])
RoPE: Rotary Positional Encoding
Although sinusoidal positional encoding is classic, better solutions emerged later. The current mainstream large models (such as LLaMA) useRoPE(Rotary Positional Embedding)。
The idea of RoPE is:Rotate position information into Query and Key.
Specifically, rotate each pair of dimensions of Q and K:
[ q₁, q₂, q₃, q₄, ... ] ↓ 旋转 [ q₁·cos mθ - q₂·sin mθ, q₁·sin mθ + q₂·cos mθ, q₃·cos mθ' - q₄·sin mθ', q₃·sin mθ' + q₄·cos mθ', ... ]
Advantages of RoPE:
- Naturally contains relative position information
- Good extrapolation (works for lengths beyond training)
- Usually performs better than sinusoidal positional encoding
ALiBi: Decaying Attention by Distance
Another simple and effective approach isALiBi(Attention with Linear Biases)。
It does not add positional encoding, but directly adds a bias to the attention scores: the farther apart, the lower the score.
The advantage of ALiBi is its simplicity, and it is especially friendly to length extrapolation.
| Positional Encoding Methods | Principle | Advantages | Representative Models |
|---|---|---|---|
| Sinusoidal positional encoding | sin/cos functions | Extrapolatable, simple computation | Original Transformer, BERT |
| Learnable positional encoding | Directly train an embedding | Strong expressive power | GPT-1/2 |
| RoPE | Rotate Q/K | Relative positions, good performance | LLaMA, PaLM |
| ALiBi | Add bias to attention scores | Simple, good for long sequences | MPT, BLOOM |
Feed-Forward Network (FFN) Layer
In each Transformer layer, after attention, there is also a feed-forward network.
Structure of FFN
The FFN of the original Transformer is:
FFN(x) = max(0, x·W₁ + b₁) · W₂ + b₂
That is: linear layer → ReLU activation → linear layer.
Usually the intermediate dimension is larger than the input dimension (e.g., d_model=512, intermediate dimension=2048).
ReLU vs SwiGLU
The original used ReLU, but now the mainstream isSwiGLU:
SwiGLU(x) = (x·W₁ ⊗ sigmoid(x·V₁)) · W₂
where ⊗ is element-wise multiplication.
SwiGLU performs better than ReLU on many tasks; LLaMA, PaLM, etc., all use it.
Why is FFN important?
- Attention is "aggregating information", FFN is "processing information"
- FFN has a large number of parameters, and is the main place where the model "memorizes" knowledge
- Without FFN, the expressive capability of Transformer would be greatly reduced
Example
# Implement FFN: ReLU version and SwiGLU version
# ============================================
import math
import random
random.seed(42) # example
def relu(x: float) -> float:
"""ReLU activation function: max(0, x)"""
return max(0.0, x)
def sigmoid(x: float) -> float:
"""Sigmoid activation function"""
if x < -50: # Prevent numerical overflow
return 0.0
return 1.0 / (1.0 + math.exp(-x))
def linear(x: list, W: list, b: list) -> list:
"""Linear layer: y = x·W + b"""
output_size = len(b)
output = [0.0 for _ in range(output_size)]
for i in range(output_size):
sum_wx = sum(x[j] * W[j][i] for j in range(len(x)))
output[i] = sum_wx + b[i]
return output
class FFNReLU:
"""ReLU version FFN"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# Randomly initialize parameters (obtained by training in practice)
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.b1 = [0.0 for _ in range(d_ff)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
self.b2 = [0.0 for _ in range(d_model)]
def forward(self, x: list) -> list:
"""Forward pass"""
# First layer: linear + ReLU
hidden = linear(x, self.W1, self.b1)
hidden = [relu(v) for v in hidden]
# Second layer: linear
output = linear(hidden, self.W2, self.b2)
return output
class FFNSwiGLU:
"""SwiGLU version FFN (a more modern choice)"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# SwiGLU needs two linear layers for gating, plus an output layer
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.V1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
def forward(self, x: list) -> list:
"""Forward pass: SwiGLU"""
# Compute two linear transformations
x1 = linear(x, self.W1, [0.0 for _ in range(self.d_ff)])
x2 = linear(x, self.V1, [0.0 for _ in range(self.d_ff)])
# SwiGLU:x1 * sigmoid(x2)
hidden = [a * sigmoid(b) for a, b in zip(x1, x2)]
# Output layer
output = linear(hidden, self.W2, [0.0 for _ in range(self.d_model)])
return output
# Demonstrate FFN
d_model = 8
d_ff = 16
# Test input
x = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8]
print("=" * 50)
print("Example FFN demo")
print("=" * 50)
print(f"Input: {[f'{v:.4f}' for v in x]}")
ffn_relu = FFNReLU(d_model, d_ff)
output_relu = ffn_relu.forward(x)
print(f"\n"ReLU FFN output: {[f'{v:.4f}' for v in output_relu]}")
ffn_swiglu = FFNSwiGLU(d_model, d_ff)
output_swiglu = ffn_swiglu.forward(x)
print(f"SwiGLU FFN output: {[f'{v:.4f}' for v in output_swiglu]}")
Layer Normalization (Layer Norm)
There is another important component in Transformer: Layer Normalization.
What Layer Normalization Does
Layer normalization normalizes the features of each sample to mean 0 and variance 1:
LN(x) = γ ⊗ ((x - μ) / √σ²) + β
where:
- μ is the mean of x, σ² is the variance of x
- γ (scale) and β (shift) are learnable parameters
- ⊗ is element-wise multiplication
Example
# Implement Layer Normalization
# ============================================
import math
def layer_norm(x: list, gamma: list, beta: list, eps: float = 1e-6) -> list:
"""
Layer Normalization
x: input vector
gamma: scaling parameter
beta: offset parameter
eps: small constant to prevent division by zero
"""
# Compute mean
mean = sum(x) / len(x)
# Compute variance
var = sum((v - mean) ** 2 for v in x) / len(x)
# Normalization + scale and shift
normalized = []
for xi, gi, bi in zip(x, gamma, beta):
normalized.append(gi * (xi - mean) / math.sqrt(var + eps) + bi)
return normalized
# Demonstrate layer normalization
d_model = 6
# Input vector
x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
# gamma initialized to 1, beta initialized to 0
gamma = [1.0 for _ in range(d_model)]
beta = [0.0 for _ in range(d_model)]
print("=" * 50)
print("Example Layer Normalization Demo")
print("=" * 50)
print(f"Input: {[f'{v:.4f}' for v in x]}")
output = layer_norm(x, gamma, beta)
print(f"Output: {[f'{v:.4f}' for v in output]}")
# Verify mean and variance
mean_out = sum(output) / len(output)
var_out = sum((v - mean_out) ** 2 for v in output) / len(output)
print(f"\n"Output mean: {mean_out:.4f}")
print(f"Output variance: {var_out:.4f}")
Pre-Norm vs Post-Norm
Layer normalization has two placement methods:
- Post-Norm: sublayer → residual → normalization (original Transformer)
- Pre-Norm: normalization → sublayer → residual (now mainstream)
Pre-Norm training is more stable, and almost all large models use it now.
RMS Norm
RMS NormIt is a simplified version of layer normalization, only calculating root mean square, without subtracting the mean:
RMS(x) = sqrt(mean(x²)) RMSNorm(x) = γ ⊗ (x / RMS(x))
RMS Norm is faster to compute, with similar results; models like LLaMA use it.
Encoder-Decoder vs Decoder-Only Architecture
The original Transformer is an Encoder-Decoder architecture, but most large language models today are Decoder-Only.
Encoder-Decoder: Used for Tasks Like Translation
Encoder-Decoder architecture:
- Encoder: reads the input sequence (e.g., source language sentence) and generates a context representation
- Decoder: generates the target sequence (e.g., translation result) based on the Encoder's output
There are two types of attention in the Decoder:
- Masked self-attention: can only look at previous words (cannot see the future)
- Cross-attention: to attend to the Encoder's output
Suitable tasks: machine translation, speech recognition, summarization (requires "rewriting" input into output).
Decoder-Only: GPT Series, Used for Generation
The Decoder-Only architecture has only a Decoder, no Encoder. Its characteristics:
- Only masked self-attention (causal attention)
- Autoregressive generation: outputs one word at a time
- Suitable for the pretraining + fine-tuning paradigm
Current GPT, LLaMA, Claude, Gemini... are almost all Decoder-Only.
Encoder-Only: BERT Series, Used for Understanding
The Encoder-Only architecture has only an Encoder. Its characteristics:
- Bidirectional attention: each word can see all words
- Suitable for understanding tasks: classification, named entity recognition, extractive question answering
- Pretrained with "Masked Language Model" (MLM)
| Architecture | Component | Attention type | Representative model | Applicable tasks |
|---|---|---|---|---|
| Encoder-Decoder | Encoder + Decoder | Bidirectional + masked + cross | T5, BART | Translation, summarization |
| Decoder-Only | Decoder | Masked (causal) | GPT, LLaMA, Claude | Text generation |
| Encoder-Only | Encoder | Bidirectional | BERT, RoBERTa | Classification, NER |
Why did Decoder-Only become mainstream? Because it is simple, and under large-scale pretraining, its generation ability is extremely strong. "One size fits all" actually works better than "specialized design".
Flash Attention: Making Attention Computation Fast and Memory-Efficient
The time and memory complexity of standard self-attention are both O(n²); when the sequence gets long (e.g., n=8192), it is slow and GPU memory is insufficient.
Flash AttentionIt is a key optimization; its emergence in 2022 made long-sequence training feasible.
Memory Bottleneck of Standard Attention
Standard self-attention needs to save intermediate results (attention weight matrices) for backpropagation. For n=8192, this is 8192×8192 ≈ 67 million elements, which alone takes up tens of GB of GPU memory.
Optimization Principles of Flash Attention
Core idea of Flash Attention:
- Block-wise computation: Split Q/K/V into small blocks and compute block by block
- Online Softmax: Don't save the full attention matrix; use mathematical tricks to recompute it during backpropagation
- Leverage SRAM: Put the blocks into the GPU's high-speed cache (SRAM) to reduce HBM access
The result:
- Memory complexity drops from O(n²) to O(n)
- 2-4x faster
- Identical precision (mathematically equivalent)
Almost all large model training now uses Flash Attention or its variants (Flash Attention v2, Memory-Efficient Attention, etc.).
KV Cache: Principles of Inference Acceleration
Once training optimization is done, inference (generation) also needs optimization.KV CacheIt is the key to inference acceleration.
Problem: Repetitive Computation in Autoregressive Generation
During autoregressive generation, every time a new token is generated, the K and V of all previous tokens must be recomputed — this is very wasteful.
For example, when generating the 100th token, the K/V of the first 99 tokens has already been computed and does not need to be recomputed.
How KV Cache Works
The idea of KV Cache is simple:
- Cache the K and V of all previous positions
- When generating a new token, only compute the new token's K/V and append it to the cache
- Use the complete K/V for attention
This way, computation per step drops from O(n²) to O(n), greatly improving generation speed.
Example
# Demonstrate the principle of KV Cache
# ============================================
import math
import random
random.seed(42)
def softmax(x):
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matmul(A, B):
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix):
return list(map(list, zip(*matrix)))
class SimpleAttentionWithKVCache:
"""Simple self-attention with KV Cache"""
def __init__(self, d_model: int):
self.d_model = d_model
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def forward(self, x_new: list, kv_cache: tuple = None) -> tuple:
"""
One-step generation with KV Cache
x_new: current new input (single position)
kv_cache: previous K and V, (K_cache, V_cache)
Returns: (output, new kv_cache)
"""
# Compute Q, K, V for the current position
q = matmul([x_new], self.Wq)
k = matmul([x_new], self.Wk)
v = matmul([x_new], self.Wv)
# Update KV Cache
if kv_cache is None:
K_full = k
V_full = v
else:
K_cache, V_cache = kv_cache
K_full = K_cache + k
V_full = V_cache + v
# Attention: only use the current position's Q
scores = matmul(q, transpose(K_full))
scale = math.sqrt(self.d_model)
scores_scaled = [[v / scale for v in row] for row in scores]
weights = [softmax(row) for row in scores_scaled]
output = matmul(weights, V_full)
return output[0], (K_full, V_full)
# Demonstrate the KV Cache generation process
d_model = 4
# Simulate 4 steps of generation
inputs = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
attn = SimpleAttentionWithKVCache(d_model)
print("=" * 50)
print("Example KV Cache demonstration")
print("=" * 50)
kv_cache = None
for step, x in enumerate(inputs):
output, kv_cache = attn.forward(x, kv_cache)
K_cache, V_cache = kv_cache
print(f"\n"Step {step}:")
print(f" KV Cache size: {len(K_cache)} positions")
print(f" Output: {[f'{v:.4f}' for v in output]}")
print("\n" + "=" * 50)
print("Note: each step only computes the K/V of the current position,")
print("then appends it to the Cache, no need to recompute the previous ones!")
print("=" * 50)
KV Cache has a small cost: it requires extra GPU memory to store K and V. For a 7B model with a 4k context, KV Cache takes about 1-2GB of GPU memory — this is worth it.
There are also more advanced optimizations:
- Multi-Query Attention: Multiple heads share K/V
- Grouped Query Attention: Grouped sharing of K/V
These further reduce memory usage and improve inference speed.
Summary: Complete Transformer Pipeline
Let's put all the components together and summarize the complete Transformer flow.
One Decoder-Only Transformer Layer
x → LayerNorm → Masked Multi-Head Attention → 残差连接 →
→ LayerNorm → FFN → 残差连接 → 输出
Complete Decoder-Only Model
- Input token IDs → word embedding
- Add positional encoding
- Pass through N Transformer layers
- Final LayerNorm
- Linear layer mapping to vocabulary size
- Softmax to get the probability of the next token
Key Hyperparameters
| Hyperparameter | Meaning | GPT-2 (124M) | LLaMA-7B | GPT-3 (175B) |
|---|---|---|---|---|
| d_model | Model dimension | 768 | 4096 | 12288 |
| num_heads | Number of attention heads | 12 | 32 | 96 |
| num_layers | Number of layers | 12 | 32 | 96 |
| d_ff | FFN intermediate dimension | 3072 | 11008 | 49152 |
| context_len | Context length | 1024 | 2048/4096 | 2048 |
Code Implementation Guide
If you want to implement a simplified Transformer from scratch, the steps are:
- Implement layer normalization
- Implement self-attention (with masking)
- Implement multi-head attention
- Implement FFN
- Implement Transformer layer
- Implement positional encoding
- Assemble the complete model
- Implement the training loop
In practice, we use frameworks like PyTorch rather than pure Python. The pure Python implementation here is for demonstrating the principles and helping you understand what each step does.
Other extensionsThe best way to understand Transformer is to implement it yourself. Even a simplified version will give you a fresh perspective on many details.