AI Reading
Quick summary of this article
Sequential Data, Recurrence & Transformers from Scratch
Demystify sequential computation in PyTorch 2.x. Trace the evolution from vanishing gradients in vanilla RNNs to the Constant Error Carousel in LSTMs, minimalist GRUs, Multi-Head Attention, and complete causal Transformer decoders built from scratch with FlashAttention-2 kernels.
PyTorch Version: 2.2+ with CUDA 12.1+
Kernels: F.scaled_dot_product_attention (FlashAttention-2)
1. Engineering Audit: Legacy Sequential Models vs. Modern PyTorch 2.x
Sequential modeling has undergone a monumental architectural transformation over the past decade. For years, Recurrent Neural Networks (RNNs) and Long Short-Term Memory networks (LSTMs) were the de facto standards for natural language processing, speech synthesis, and financial time-series. However, recurrent architectures impose an insurmountable sequential bottleneck: hidden state ht cannot be computed until ht-1 finishes, preventing full GPU parallelization across the sequence dimension.
The Transformer architecture replaced step-by-step recurrence with parallel Multi-Head Self-Attention, allowing all tokens to attend to each other simultaneously. Furthermore, PyTorch 2.x introduced native F.scaled_dot_product_attention (SDPA), which fuses the memory-intensive attention matrix directly into GPU SRAM using FlashAttention-2 algorithms. Below is an engineering comparison between legacy sequential techniques and modern standards:
Figure 10.1: Backpropagation Through Time (BPTT). Left: Repeated matrix multiplications WhhT extinguish gradients exponentially in Vanilla RNNs. Right: LSTM additive cell state highway provides an unattenuated gradient highway.
60 FPS • H.264 • BPTT Decay Dynamics
Mechanism: Forward activations step sequentially left to \right. When the loss is computed at t=5 and backpropagates leftwards, each contraction through the recurrent Jacobian matrix shrinks the gradient vector until it completely vanishes before reaching t=1.
2. Deep Mathematical Foundations: Recurrence to Self-Attention
2.1 Mathematical Proof of Vanishing Gradients in Recurrent Networks
Consider an unrolled recurrent network over T time steps. The hidden state transition equation is:
Let L be the scalar loss computed at time step T. By the multivariate chain rule across Backpropagation Through Time (BPTT), the gradient with respect to hidden state h_k at an arbitrary earlier time step k < T is:
The Jacobian matrix ∂h_j / ∂h_{j-1} is given by:
Where a_j = W_{hh} h_{j-1} + W_{xh} x_j + b. Notice the two critical contraction factors:
- The derivative of the hyperbolic tangent satisfies |1 – tanh^2(z)| ≤ 1 for all real inputs, attaining its maximum of 1.0 strictly at z=0 and dropping to near zero when |z| > 2.
- Let ρ(W_{hh}) be the spectral radius (the maximum absolute eigenvalue) of the recurrent weight matrix. If ρ(W_{hh}) < 1, the repeated product of (T – k) matrix operators contracts exponentially: ||∏_{j=k+1}^T ∂h_j / ∂h_{j-1}|| ≤ (γ · ρ(W_{hh}))^{T – k} → 0 as (T – k) > 15.
2.2 The LSTM Constant Error Carousel (CEC)
Hochreiter and Schmidhuber (1997) resolved vanishing gradients by separating the hidden state into two distinct entities:
- Cell State (C_t): An internal linear accumulation bus operating as a Constant Error Carousel. Updates are strictly additive rather than multiplicative.
- Hidden State (h_t): An externally exposed, non-linearly gated filter of the cell state passed to subsequent layers.
Figure 10.2: Detailed internal operations of an LSTM cell: Forget Gate (f_t), Input Gate (i_t), Candidate State (C~_t), and Output Gate (o_t).
Constant Error Carousel Mechanics
Dynamic Additive Bus: See how the top cell state bus streams uninterrupted across time. When the forget gate f_t is open (1.0), gradients flow across hundreds of steps with zero exponential shrinkage.
The mathematical equations governing the LSTM forward pass are:
i_t = σ( W_i [h_{t-1}, x_t] + b_i ) (Input Gate: Modulate new candidate)
C~_t = tanh( W_c [h_{t-1}, x_t] + b_c ) (Candidate Cell State)
C_t = f_t ⊙ C_{t-1} + i_t ⊙ C~_t (Cell State: Linear Additive Highway)
o_t = σ( W_o [h_{t-1}, x_t] + b_o ) (Output Gate: Filter to hidden)
h_t = o_t ⊙ tanh( C_t ) (Hidden State Output)
Crucially, evaluate the gradient of the cell state: ∂C_t / ∂C_{t-1} = diag(f_t) + …. If the forget gate f_t is close to 1.0 (the standard initialization setting in PyTorch where forget bias is set to 1.0), the error gradient ∂C_t / ∂C_{t-1} ≈ 1.0. This allows gradients to backpropagate across 100+ steps without exponential decay.
Figure 10.3: Gated Recurrent Unit (GRU). Eliminates the dedicated cell state and fuses forget and input gates into a unified convex combination update gate z_t, saving 25% parameters.
2.3 Scaled Dot-Product Attention & Variance Stabilization
While LSTMs solved vanishing gradients, their sequential dependency O(T) fundamentally prevented parallel training on large corpora. The Transformer eliminated recurrence completely, introducing Scaled Dot-Product Attention:
Where Q ∈ RN × d_k (Query), K ∈ RN × d_k (Key), and V ∈ RN × d_v (Value).
Figure 10.4: Matrix dimensions and tensor contraction geometry in Scaled Dot-Product Attention: Q × KT yields the N × N affinity matrix, scaled by 1/√d_k and multiplied by V.
Softmax Normalization & Value Pooling
Affinity Scanning: Watch how each Query token evaluates all Key tokens in parallel, computes unnormalized scalar affinities, scales by 1/√d_k, applies row-wise Softmax, and synthesizes the output context vector.
2.4 Multi-Head Attention Subspaces
Instead of performing a single attention function with d_model-dimensional keys, queries, and values, Vaswani et al. discovered that projecting Q, K, and V linearly h times with different learnable projections to d_k = d_model / h dimensions yields vastly superior representations:
where head_i = Attention( Q × W_i^Q, K × W_i^K, V × W_i^V )
Figure 10.5: Parallel subspace projections in Multi-Head Attention: Q, K, V are projected and reshaped into h heads, executed in parallel, concatenated, and projected through W_O.
Subspace Specialization Dynamics
Subspace Specialization: Multiple heads attend to distinct phenomena simultaneously: Head 1 isolates syntactic subject-verb dependencies, Head 2 resolves pronouns to entities, and Head 3 focuses on local adjacent bigrams.
2.5 Causal Masking in Autoregressive Decoders
In generative language modeling (e.g., GPT, Llama), models predict the probability distribution of token x_t given only preceding tokens x_<t. In bidirectional models (like BERT), full all-pairs attention allows tokens to peek ahead into future positions. To preserve strict causality during parallel matrix training, we apply an additive Causal Mask M:
Because e^{-∞} = 0, applying Softmax to (S + M) rigorously sets the attention weights for all future positions (j > i) to exactly 0.0, perfectly preventing lookahead leakage.
Figure 10.6: Causal masking mechanism. Adding -∞ to the upper-triangular logits zeroes out all future attention probabilities during Softmax normalization.
Autoregressive Step-by-Step Generation
Zero Lookahead: Observe how each token generation step strictly conditions on historical tokens while future cells remain masked in red, preventing information leakage.
Figure 10.7: Sinusoidal Positional Encoding geometry. Frequencies scale exponentially from short wavelengths (λ=2π) to long wavelengths (λ=10,000·2π), providing unique coordinate fingerprints.
Figure 10.8: The unified PyTorch 2.x torch.nn.functional.scaled_dot_product_attention dispatch architecture: FlashAttention-2 vs. Memory-Efficient vs. Math fallback.
3. Production Engineering Laboratories
The following five laboratories contain complete, fully runnable, production-grade PyTorch implementations. Each script adheres to strict engineering guidelines: type hints, robust error handling, memory safety, and reproducible seeds.
Lab 10.1: Industrial Bidirectional LSTM with PackedSequence for Variable-Length Batches
LAB_01.py
Builds a production bidirectional LSTM text classifier that utilizes torch.nn.utils.rnn.pack_padded_sequence and pad_packed_sequence to eliminate redundant computation on padding tokens.
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
from typing import Tuple
class ProductionPackedLSTMClassifier(nn.Module):
'''
Industrial Bidirectional LSTM with PackedSequence support.
Eliminates waste compute over padded tokens and extracts true sequence endpoints.
'''
def __init__(self, vocab_size: int, embed_dim: int, hidden_dim: int, num_classes: int, num_layers: int = 2, dropout: float = 0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
bidirectional=True,
dropout=dropout if num_layers > 1 else 0.0
)
self.dropout = nn.Dropout(dropout)
# Bidirectional outputs 2 * hidden_dim
self.classifier = nn.Linear(hidden_dim * 2, num_classes)
def forward(self, x: torch.Tensor, lengths: torch.Tensor) -> torch.Tensor:
# x: (Batch, Max_Len)
embedded = self.embedding(x) # (Batch, Max_Len, Embed_Dim)
# Pack padded sequence (enforce_sorted=False handles arbitrary batch ordering)
packed_input = pack_padded_sequence(embedded, lengths.cpu(), batch_first=True, enforce_sorted=False)
# cuDNN optimized execution over packed contiguous buffer
packed_output, (h_n, c_n) = self.lstm(packed_input)
# Extract forward & backward final states from h_n
# h_n shape: (num_layers * 2, Batch, Hidden_Dim)
# Final layer forward is at -2, backward is at -1
last_hidden_fwd = h_n[-2, :, :]
last_hidden_bwd = h_n[-1, :, :]
pooled = torch.cat([last_hidden_fwd, last_hidden_bwd], dim=-1) # (Batch, 2 * Hidden_Dim)
logits = self.classifier(self.dropout(pooled))
return logits
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Executing Packed LSTM Classifier on: {device}")
model = ProductionPackedLSTMClassifier(vocab_size=1000, embed_dim=64, hidden_dim=128, num_classes=5).to(device)
# Synthetic variable-length batch
# Batch size = 4, max length = 10, real lengths = [10, 7, 4, 2]
batch_tokens = torch.randint(1, 1000, (4, 10), device=device)
batch_tokens[1, 7:] = 0 # Pad token
batch_tokens[2, 4:] = 0
batch_tokens[3, 2:] = 0
lengths = torch.tensor([10, 7, 4, 2], dtype=torch.int64)
logits = model(batch_tokens, lengths)
print(f"Output Logits Shape: {logits.shape} (Expected: [4, 5])")
print(f"Inference Successful! Sample prediction: {logits.argmax(dim=-1)}")
Lab 10.2: Multi-Head Self-Attention from Scratch (Tensor Algebra)
LAB_02.py
Implements Multi-Head Self-Attention using pure PyTorch tensor operations: single fused QKV projection, 4D tensor reshaping for parallel heads, scaled dot-product matrix multiplication, and causal masking.
import torch
import torch.nn as nn
import math
class MultiHeadAttentionFromScratch(nn.Module):
'''
Pure tensor-algebra implementation of Multi-Head Self-Attention.
Uses a single fused QKV projection matrix for optimal GEMM memory throughput.
'''
def __init__(self, d_model: int, num_heads: int, dropout: float = 0.1):
super().__init__()
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
# Fused projection for Query, Key, and Value
self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
self.out_proj = nn.Linear(d_model, d_model, bias=False)
self.dropout = nn.Dropout(dropout)
self.scale = 1.0 / math.sqrt(self.d_k)
def forward(self, x: torch.Tensor, is_causal: bool = False) -> torch.Tensor:
B, N, D = x.shape
# 1. Project fused QKV: (B, N, 3 * D)
qkv = self.qkv_proj(x)
# Split into Q, K, V: each (B, N, D)
q, k, v = torch.chunk(qkv, 3, dim=-1)
# 2. Reshape into 4D tensor: (B, num_heads, N, d_k)
q = q.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
k = k.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
v = v.view(B, N, self.num_heads, self.d_k).transpose(1, 2)
# 3. Scaled Dot-Product: (B, num_heads, N, N)
scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale
# 4. Optional Causal Masking
if is_causal:
mask = torch.triu(torch.full((N, N), float('-inf'), device=x.device), diagonal=1)
scores = scores + mask
# 5. Softmax normalization & dropout
attn_weights = torch.softmax(scores, dim=-1)
attn_weights = self.dropout(attn_weights)
# 6. Value aggregation: (B, num_heads, N, d_k)
context = torch.matmul(attn_weights, v)
# 7. Concatenate heads back to (B, N, d_model)
context = context.transpose(1, 2).contiguous().view(B, N, self.d_model)
# 8. Final linear projection
return self.out_proj(context)
if __name__ == "__main__":
mha = MultiHeadAttentionFromScratch(d_model=256, num_heads=8)
dummy_x = torch.randn(4, 32, 256)
out = mha(dummy_x, is_causal=True)
assert out.shape == dummy_x.shape, f"Shape mismatch: {out.shape}"
print(f"MHA Forward pass successful! Output Shape: {out.shape}")
Lab 10.3: Complete Causal Decoder Transformer Block with Pre-LayerNorm
LAB_03.py
Constructs a complete modern GPT-style decoder block with Pre-LayerNorm, GeLU feed-forward network, residual skip connections, and PyTorch 2.x SDPA.
import torch
import torch.nn as nn
import torch.nn.functional as F
class PreLNTransformerDecoderBlock(nn.Module):
'''
Modern GPT-style Causal Decoder Block with Pre-LayerNorm and F.scaled_dot_product_attention.
'''
def __init__(self, d_model: int, num_heads: int, mlp_ratio: int = 4, dropout: float = 0.1):
super().__init__()
self.d_model = d_model
self.num_heads = num_heads
self.head_dim = d_model // num_heads
self.ln_1 = nn.LayerNorm(d_model)
self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
self.out_proj = nn.Linear(d_model, d_model, bias=False)
self.ln_2 = nn.LayerNorm(d_model)
self.mlp = nn.Sequential(
nn.Linear(d_model, d_model * mlp_ratio),
nn.GELU(),
nn.Linear(d_model * mlp_ratio, d_model),
nn.Dropout(dropout)
)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, N, D = x.shape
# 1. Pre-LayerNorm 1
normed_x = self.ln_1(x)
# 2. QKV Projection and reshape for SDPA
qkv = self.qkv_proj(normed_x).view(B, N, 3, self.num_heads, self.head_dim)
q, k, v = qkv[:, :, 0], qkv[:, :, 1], qkv[:, :, 2]
# Transpose to (B, num_heads, N, head_dim)
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
# 3. FlashAttention-2 Accelerated SDPA with is_causal=True
attn_out = F.scaled_dot_product_attention(q, k, v, is_causal=True, dropout_p=self.dropout.p if self.training else 0.0)
attn_out = attn_out.transpose(1, 2).contiguous().view(B, N, D)
attn_out = self.dropout(self.out_proj(attn_out))
# 4. Residual Connection 1
x = x + attn_out
# 5. Pre-LayerNorm 2 + MLP + Residual Connection 2
x = x + self.mlp(self.ln_2(x))
return x
if __name__ == "__main__":
block = PreLNTransformerDecoderBlock(d_model=512, num_heads=8)
dummy_input = torch.randn(2, 64, 512)
output = block(dummy_input)
assert output.shape == dummy_input.shape
print(f"Decoder Block Executed Successfully! Tensor Shape: {output.shape}")
Lab 10.4: PyTorch 2.x SDPA FlashAttention-2 Benchmark & Memory Profiler
LAB_04.py
Benchmarks execution speed and peak memory consumption between FlashAttention-2 and standard Math attention across increasing context sequence lengths (512 to 4096 tokens).
import torch
import torch.nn.functional as F
import time
def benchmark_sdpa_kernels(seq_len: int = 2048, batch_size: int = 4, num_heads: int = 8, head_dim: int = 64):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"\n--- Benchmarking SDPA at SeqLen = {seq_len} on {device} ---")
# FlashAttention requires fp16 or bf16 on CUDA
dtype = torch.float16 if device.type == "cuda" else torch.float32
q = torch.randn(batch_size, num_heads, seq_len, head_dim, device=device, dtype=dtype)
k = torch.randn(batch_size, num_heads, seq_len, head_dim, device=device, dtype=dtype)
v = torch.randn(batch_size, num_heads, seq_len, head_dim, device=device, dtype=dtype)
backends = [
("Math Engine", {"enable_flash": False, "enable_mem_efficient": False, "enable_math": True}),
("FlashAttention-2", {"enable_flash": True, "enable_mem_efficient": False, "enable_math": False})
]
for name, config in backends:
if device.type != "cuda" and "Flash" in name:
print(f"Skipping {name} (CUDA required).")
continue
try:
with torch.backends.cuda.sdp_kernel(**config):
# Warmup
for _ in range(5):
_ = F.scaled_dot_product_attention(q, k, v, is_causal=True)
if device.type == "cuda":
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats()
start = time.perf_counter()
for _ in range(20):
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
if device.type == "cuda":
torch.cuda.synchronize()
elapsed_ms = (time.perf_counter() - start) * 1000 / 20
vram_mb = (torch.cuda.max_memory_allocated() / (1024**2)) if device.type == "cuda" else 0.0
print(f"{name:<18} | Step Latency: {elapsed_ms:>6.2f} ms | Peak VRAM: {vram_mb:>7.1f} MB")
except RuntimeError as e:
print(f"{name:<18} | Unsupported on current hardware: {e}")
if __name__ == "__main__":
benchmark_sdpa_kernels(seq_len=1024)
benchmark_sdpa_kernels(seq_len=2048)
Lab 10.5: Autoregressive Text Generation Loop with KV-Caching & Temperature Sampling
LAB_05.py
Demonstrates a production autoregressive token generation pipeline featuring Key-Value caching, temperature scaling, top-k filtering, and nucleus (top-p) probability truncation.
import torch
import torch.nn.functional as F
from typing import List
def sample_next_token(logits: torch.Tensor, temperature: float = 0.8, top_k: int = 50, top_p: float = 0.9) -> int:
'''
Samples next token using Temperature scaling, Top-K pruning, and Nucleus (Top-P) filtering.
'''
# 1. Apply temperature scaling
logits = logits / max(temperature, 1e-5)
# 2. Top-K filtering
if top_k > 0:
val, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < val[-1]] = float('-inf')
# 3. Top-P (Nucleus) filtering
if top_p < 1.0:
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
# Remove tokens with cumulative probability above threshold
sorted_indices_to_remove = cumulative_probs > top_p
# Shift mask to keep first token above threshold
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
sorted_indices_to_remove[..., 0] = 0
indices_to_remove = sorted_indices[sorted_indices_to_remove]
logits[indices_to_remove] = float('-inf')
# 4. Multinomial sampling from filtered categorical distribution
probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
return next_token.item()
def autoregressive_generate_demo():
print("--- Autoregressive Token Generation Simulation ---")
vocab = ["The", "neural", "network", "learned", "to", "generate", "code", "efficiently", "with", "PyTorch", "<EOS>"]
# Simulated model loop
current_tokens = [0] # Start with "The"
print(f"Seed Prompt: '{vocab[0]}'")
for step in range(8):
# Simulate logits from a language model head
simulated_logits = torch.randn(len(vocab))
# Add bias to plausible continuations
simulated_logits[step + 1] += 3.0
next_tok_id = sample_next_token(simulated_logits, temperature=0.7, top_k=5, top_p=0.9)
current_tokens.append(next_tok_id)
if vocab[next_tok_id] == "<EOS>":
break
generated_text = " ".join([vocab[i] for i in current_tokens])
print(f"Generated Sequence: {generated_text}")
if __name__ == "__main__":
autoregressive_generate_demo()
4. Capstone Mini-Project: NanoGPT Language Model from Scratch
In this capstone, you will construct an enterprise-grade standalone decoder-only Transformer language model: nano_gpt_pytorch.py. The application implements character/subword tokenization, learnable positional embeddings, stacked Pre-LN Transformer blocks with FlashAttention-2, and an interactive text generation CLI.
PyTorch 2.x • Production
'''
Production NanoGPT: Decoder-Only Transformer Language Model
Author: Senior AI Architecture Engineer
PyTorch 2.x Scaled Dot-Product Attention Implementation
'''
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
from typing import Optional
class CausalSelfAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int, dropout: float):
super().__init__()
assert d_model % num_heads == 0
self.d_model = d_model
self.num_heads = num_heads
self.head_dim = d_model // num_heads
self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
self.out_proj = nn.Linear(d_model, d_model, bias=False)
self.dropout = dropout
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, N, D = x.shape
qkv = self.qkv_proj(x).view(B, N, 3, self.num_heads, self.head_dim)
q = qkv[:, :, 0].transpose(1, 2)
k = qkv[:, :, 1].transpose(1, 2)
v = qkv[:, :, 2].transpose(1, 2)
# Native PyTorch 2.x hardware-accelerated SDPA
out = F.scaled_dot_product_attention(
q, k, v,
is_causal=True,
dropout_p=self.dropout if self.training else 0.0
)
out = out.transpose(1, 2).contiguous().view(B, N, D)
return self.out_proj(out)
class TransformerBlock(nn.Module):
def __init__(self, d_model: int, num_heads: int, dropout: float):
super().__init__()
self.ln_1 = nn.LayerNorm(d_model)
self.attn = CausalSelfAttention(d_model, num_heads, dropout)
self.ln_2 = nn.LayerNorm(d_model)
self.mlp = nn.Sequential(
nn.Linear(d_model, 4 * d_model),
nn.GELU(),
nn.Linear(4 * d_model, d_model),
nn.Dropout(dropout)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x
class NanoGPT(nn.Module):
def __init__(self, vocab_size: int, max_seq_len: int = 512, d_model: int = 256, num_layers: int = 6, num_heads: int = 8, dropout: float = 0.1):
super().__init__()
self.max_seq_len = max_seq_len
self.token_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(max_seq_len, d_model)
self.dropout = nn.Dropout(dropout)
self.blocks = nn.ModuleList([
TransformerBlock(d_model, num_heads, dropout) for _ in range(num_layers)
])
self.ln_final = nn.LayerNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
# Weight tying: share embedding weights with final LM projection
self.token_emb.weight = self.lm_head.weight
self.apply(self._init_weights)
def _init_weights(self, module):
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
def forward(self, idx: torch.Tensor, targets: Optional[torch.Tensor] = None):
B, N = idx.shape
assert N <= self.max_seq_len, f"Sequence length {N} exceeds maximum context {self.max_seq_len}"
positions = torch.arange(0, N, dtype=torch.int64, device=idx.device)
x = self.token_emb(idx) + self.pos_emb(positions)
x = self.dropout(x)
for block in self.blocks:
x = block(x)
x = self.ln_final(x)
logits = self.lm_head(x) # (B, N, Vocab_Size)
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
return logits, loss
@torch.no_grad()
def generate(self, idx: torch.Tensor, max_new_tokens: int, temperature: float = 0.8, top_k: int = 50):
self.eval()
for _ in range(max_new_tokens):
# Crop context if it exceeds max_seq_len
idx_cond = idx if idx.size(1) <= self.max_seq_len else idx[:, -self.max_seq_len:]
logits, _ = self(idx_cond)
# Only examine the last token's distribution
logits = logits[:, -1, :] / temperature
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float('-inf')
probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, next_token), dim=1)
return idx
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Initializing NanoGPT Engine on: {device}")
model = NanoGPT(vocab_size=50257, max_seq_len=256, d_model=256, num_layers=4, num_heads=8).to(device)
total_params = sum(p.numel() for p in model.parameters())
print(f"Total Parameters: {total_params:,} (Weight-Tied Embedding & Projection)")
# Test forward pass with loss
dummy_tokens = torch.randint(0, 50257, (2, 64), device=device)
dummy_targets = torch.randint(0, 50257, (2, 64), device=device)
logits, loss = model(dummy_tokens, dummy_targets)
print(f"Forward Pass Loss: {loss.item():.4f} | Logits Shape: {logits.shape}")
# Test generation
prompt = torch.tensor([[15496, 11, 318]], device=device) # Sample subwords
gen = model.generate(prompt, max_new_tokens=10)
print(f"Generated Token IDs: {gen.squeeze().tolist()}")
5. Practical Engineering Exercises (With Solutions)
Solidify your sequential modeling and Transformer architecture skills with these 10 hands-on implementation challenges:
Exercise 1: Initializing LSTM Forget Gate Biases to 1.0
Challenge: Write a PyTorch helper function to iterate through all layers of an nn.LSTM and initialize the forget gate biases to 1.0.
for name, param in lstm_module.named_parameters():
if 'bias' in name:
# In PyTorch, biases are organized as [b_i | b_f | b_c | b_o]
hidden_dim = param.size(0) // 4
# Slice forget gate bias slice (hidden_dim to 2*hidden_dim)
param.data[hidden_dim:2*hidden_dim].fill_(1.0)
Exercise 2: Converting Bidirectional LSTM Hidden States to 2D
Challenge: For a 3-layer bidirectional LSTM, extract and concatenate the final forward and backward hidden states from tensor h_n into shape (batch_size, 2 * hidden_dim).
# Reshape to (num_layers, 2, B, hidden_dim)
h_n_reshaped = h_n.view(3, 2, B, hidden_dim)
# Extract final layer forward and backward
last_layer_fwd = h_n_reshaped[-1, 0]
last_layer_bwd = h_n_reshaped[-1, 1]
pooled_state = torch.cat([last_layer_fwd, last_layer_bwd], dim=-1)
Exercise 3: Creating a Key-Padding Mask for Self-Attention
Challenge: Given a batch of token IDs where 0 is padding, construct an attention mask tensor of shape (B, 1, 1, N) where pad positions are set to -inf.
# token_ids: (B, N)
is_pad = (token_ids == pad_idx) # (B, N) bool
mask = torch.zeros(token_ids.shape, dtype=torch.float32, device=token_ids.device)
mask = mask.masked_fill(is_pad, float('-inf'))
return mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N)
Exercise 4: Sinusoidal Positional Encoding Module
Challenge: Implement Vaswani sinusoidal positional encoding using register_buffer so tensors are saved with checkpoints without accumulating gradients.
def __init__(self, d_model, max_len=5000):
super().__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
self.register_buffer('pe', pe.unsqueeze(0))
def forward(self, x):
return x + self.pe[:, :x.size(1)]
Exercise 5: Enforcing FlashAttention-2 Execution Context
Challenge: Write a context manager block that strictly enables FlashAttention and raises an exception if PyTorch attempts to fall back to Math attention.
# Any SDPA call here will raise RuntimeError if FlashAttention is unavailable
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
Exercise 6: Rotary Position Embeddings (RoPE) 2D Complex Rotation
Challenge: Apply 2D complex plane rotation to Query coordinates for relative positional encoding.
# q: (B, H, N, D). Group into pairs (q1, q2)
q1 = q[..., 0::2]
q2 = q[..., 1::2]
# Rotate by angle theta: [q1*cos - q2*sin, q1*sin + q2*cos]
rotated_q1 = q1 * cos - q2 * sin
rotated_q2 = q1 * sin + q2 * cos
return torch.stack([rotated_q1, rotated_q2], dim=-1).flatten(-2)
Exercise 7: RMSNorm Implementation (Llama Style)
Challenge: Implement Root Mean Square Normalization (RMSNorm) which eliminates mean computation and saves 10% kernel memory bandwidth.
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x):
rms = torch.rsqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps)
return x * rms * self.weight
Exercise 8: Key-Value (KV) Cache Tensor Update
Challenge: Concatenate a new single-token Key vector k_new of shape (B, H, 1, d_k) to historical cached tensor k_past of shape (B, H, T_past, d_k).
if k_past is None:
return k_new
return torch.cat([k_past, k_new], dim=2) # Concatenate along seq_len dimension
Exercise 9: Grouped-Query Attention (GQA) Head Broadcasting
Challenge: In GQA (as used in Llama 3), there are 32 Query heads but only 8 Key/Value heads. Broadcast the KV heads to 32 using repeat_interleave.
# k shape: (B, num_kv_heads, N, d_k)
# Target shape: (B, num_kv_heads * num_groups, N, d_k)
return k.repeat_interleave(num_groups, dim=1)
Exercise 10: Perplexity Metric Computation
Challenge: Given evaluation CrossEntropyLoss value loss_val, write the mathematical conversion to Perplexity (PPL) and explain its intuitive interpretation.
# Perplexity is the exponential of the cross-entropy loss:
# PPL = exp( CrossEntropyLoss )
ppl = math.exp(loss_val)
# Intuition: PPL represents the weighted branching factor—the effective number
# of words the model is equally uncertain between at each token position.
6. Senior Technical Interview Questions & Answers
These 10 rigorous technical questions evaluate deep intuition and systems engineering expertise expected of Senior Deep Learning Engineers:
Q1: Why does FlashAttention-2 run 3x–5x faster than naive PyTorch attention while performing the exact same mathematical operations?
Answer: Naive attention is IO-bound (memory bandwidth constrained), not compute-bound. In naive attention, the N × N matrix of dot products must be written to GPU High Bandwidth Memory (HBM), read back to compute Softmax, written to HBM again, and read a third time to multiply V. For N=4096, this requires moving gigabytes of intermediate tensors across memory buses. FlashAttention-2 tiles the Q, K, and V matrices into SRAM blocks (the fast on-chip cache), computes local softmax rescaling online using the Milakov-Gimelshtein trick, and writes only the final output to HBM, completely bypassing intermediate N × N materialization.
Q2: What is the difference between Pre-LN and Post-LN Transformer blocks, and why did industry universally switch to Pre-LN?
Answer: In Post-LN (Vaswani 2017), LayerNorm is applied after the residual addition: x_{l+1} = LN(x_l + Sublayer(x_l)). In deep networks (L > 24), gradients passing through multiple LayerNorm denominators contract exponentially near the output, causing severe gradient vanishing that requires delicate learning rate warmup schedules to avoid divergence. In Pre-LN (GPT-2, Llama), normalization is applied inside the branch: x_{l+1} = x_l + Sublayer(LN(x_l)). The residual stream forms an uninterrupted linear identity highway ∂x_L / ∂x_l = I, guaranteeing healthy gradient propagation across 100+ layers without warmups.
Q3: How does Multi-Query Attention (MQA) and Grouped-Query Attention (GQA) reduce KV-cache memory during inference?
Answer: In standard Multi-Head Attention (MHA), every query head has a dedicated key and value head (e.g., 32 Q, 32 K, 32 V). At inference time, storing the KV-cache for long contexts consumes massive VRAM: 2 × 2 × n_layers × n_heads × d_k × seq_len bytes. In Multi-Query Attention (MQA), all 32 Q heads share a single K and V head (a 32x KV cache reduction). Grouped-Query Attention (GQA) compromises by grouping Q heads into g groups (e.g., 8 KV heads for 32 Q heads, a 4x reduction), matching MHA quality while slashing serving latency and VRAM footprint.
Q4: Why does Rotary Position Embedding (RoPE) extrapolate better to long contexts than learned absolute embeddings?
Answer: Learned absolute embeddings treat every position as an arbitrary lookup vector; the model never learns what position 2049 looks like if trained on 2048. RoPE encodes position by multiplying Query and Key representations by orthogonal rotation matrices R_m and R_n in complex coordinate subspaces. The dot product 〈R_m q, R_n k〉 = q^T R_{n-m} k depends strictly on relative offset (m - n). By scaling the base rotation frequency (e.g., via NTK-aware or YaRN scaling), RoPE allows models to extend context windows from 4k to 128k+ tokens without re-training from scratch.
Q5: Explain the computational complexity comparison between RNN, CNN, and Transformer self-attention.
Answer: For sequence length N and representation dimension d:
• RNN: Compute per layer: O(N · d^2); Sequential operations: O(N); Maximum path length: O(N).
• CNN: Compute per layer: O(k · N · d^2); Sequential operations: O(1); Maximum path length: O(log_k(N)) with dilation.
• Self-Attention: Compute per layer: O(N^2 · d + N · d^2); Sequential operations: O(1); Maximum path length: O(1).
Self-attention connects any two tokens in a single step (O(1) path), making it vastly superior for long-range dependency discovery despite O(N^2) complexity.
Q6: Why is weight tying between the token embedding matrix and the final linear language model head advantageous?
Answer: Press & Wolf (2017) demonstrated that the input embedding matrix E ∈ R^{V × d} and output projection matrix W_{out} ∈ R^{V × d} learn virtually identical semantic geometric representations. Tying them (W_{out} = E) cuts total model parameters by V × d (saving ~130M parameters in a model with V=50,257 and d=2560) and acts as an effective regularizer that prevents the output projection from overfitting.
Q7: What is teacher forcing, and what is 'exposure bias' in sequence training?
Answer: Teacher forcing feeds ground-truth tokens x_<t as context during training at every step rather than feeding the model's own predicted tokens x~_<t. This allows parallel computation of all time steps via causal masking. Exposure bias refers to the resulting discrepancy at inference time: the model was trained exclusively on perfect human text, but at inference, it must condition on its own potentially flawed historical predictions, leading to compounding errors if an uncalibrated token is sampled.
Q8: How does Top-P (Nucleus) sampling improve over pure Temperature scaling?
Answer: Pure temperature scaling modifies distribution entropy (logits / T) but keeps the full vocabulary tail open; with high temperatures, catastrophic low-probability tokens can be drawn. Top-K filtering is rigid: when the model is confident, K=50 includes garbage tokens; when the model is uncertain, K=50 excludes valid candidates. Nucleus (Top-P) sampling dynamically truncates the distribution to the smallest set of tokens whose cumulative probability exceeds p (e.g., 0.90), adapting the candidate pool size dynamically based on model confidence.
Q9: What happens if you forget to set batch_first=True in PyTorch RNN/LSTM?
Answer: By default, PyTorch nn.LSTM and nn.RNN expect inputs formatted as (Seq_Len, Batch, Input_Dim). If passing standard (Batch, Seq_Len, Input_Dim) batches without setting batch_first=True, PyTorch silently interprets the batch dimension as the sequence length. If Batch=32 and Seq_Len=10, the model executes 32 recurrent time steps over 10 parallel sequences without throwing an error, silently scrambling all temporal dynamics.
Q10: Why are GELU or SwiGLU activations preferred over ReLU in modern Transformer feed-forward networks?
Answer: Standard ReLU has a non-differentiable hard threshold at zero with exactly zero gradient for all negative inputs (leading to "dying ReLU" neurons). GELU (Gaussian Error Linear Unit) weights inputs by their probability under a standard Gaussian distribution: GELU(x) = x · Φ(x), yielding a smooth, continuous non-monotonic curve with non-zero curvature everywhere. SwiGLU introduces a multiplicative gating branch (Swish(xW) ⊙ xV), which empirical evaluations (Shazeer 2020) demonstrated delivers superior validation loss scaling across language models.
7. Final Knowledge Assessment Quiz & Official References
Self-Assessment Knowledge Checks
- What is the primary architectural cause of vanishing gradients in Vanilla RNNs during BPTT? (Answer: Repeated multiplication by the recurrent weight matrix W_hh whose spectral radius < 1)
- How does an LSTM avoid exponential gradient attenuation? (Answer: Through the linear additive cell state highway C_t = f_t ⊙ C_{t-1} + … where ∂C_t / ∂C_{t-1} ≈ 1)
- Why do we divide dot products by √d_k in scaled dot-product attention? (Answer: To restore variance to 1.0 and prevent dot products from growing large, which pushes Softmax into zero-gradient saturation regions)
- What is the mathematical purpose of the causal mask M containing -∞ in the upper triangle? (Answer: Because e^{-∞} = 0, Softmax sets all future token attention weights to zero, enforcing autoregressive generation)
- Which PyTorch function executes FlashAttention-2 without materializing the N × N matrix in VRAM? (Answer:
torch.nn.functional.scaled_dot_product_attention)
Official PyTorch & Academic References
- PyTorch Official Documentation: scaled_dot_product_attention (SDPA)
- Vaswani et al. (2017) — Attention Is All You Need
- Dao (2023) — FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning
- Su et al. (2021) — RoFormer: Enhanced Transformer with Rotary Position Embedding (RoPE)
- Karpathy — nanoGPT Reference Repository
