Module 10: Sequential Data, Recurrence & Transformers in PyTorch — LSTM, GRU, Multi-Head Attention & Transformer from Scratch

Module 10: Sequential Data, Recurrence & Transformers in PyTorch — LSTM, GRU, Multi-Head Attention & Transformer from Scratch

AI Reading

Quick summary of this article

Preparing the AI summary…

PYTORCH 2.x MASTERCLASS • CHAPTER 10

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.

Prerequisites: Tensor Broadcasting, Autograd & Custom Modules (Chapters 2, 3 & 4)
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:

Dimension Legacy Practice (PyTorch < 1.12) Modern Production Standard (PyTorch 2.x)
Sequential Processing Sequential for-loops over time dimension T; sequential bottleneck blocks GPU Tensor Cores. Fully parallel sequence dimension via Attention matrices & cuDNN fused LSTM/GRU kernels.
Attention Implementation Manual eager implementation: torch.bmm(q, k.transpose) / math.sqrt(d) followed by torch.softmax, causing massive O(N2) VRAM blowups. F.scaled_dot_product_attention(q, k, v): Hardware-aware dispatch directly executing FlashAttention-2 or Memory-Efficient kernels in SRAM.
Variable-Length Batching Zero-padding sentences to uniform max length, wasting 40–70% of compute on pad tokens. nn.utils.rnn.pack_padded_sequence for recurrent networks; nested / jagged tensors with attention masks for Transformers.
Layer Normalization Placement Post-LN (Vaswani original: x + Sublayer(x) → LayerNorm), causing gradient vanishing in deep networks and requiring rigid warmups. Pre-LN (LayerNorm(x) → Sublayer(x) + x) or RMSNorm, enabling stable gradient highways and instant convergence.
Causal Masking Constructing full boolean masks on CPU and transferring to GPU at every step: mask.masked_fill_(..., -1e9). Built-in is_causal=True flag in PyTorch SDPA, enabling zero-memory kernel-level upper-triangular skipping.
Inference Generation Re-passing full generated sequence through all attention layers at every token generation step (O(N3) total compute). Preallocated Static Key-Value Cache (KV-Cache) storing prior token projections in GPU memory (O(N) per step).

Vanishing Gradients in RNN vs LSTM Constant Error Carousel

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.

Interactive Motion Graphic 10.1
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:

h_t = tanh( W_{hh} h_{t-1} + W_{xh} x_t + b )

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:

∂L / ∂h_k = (∂L / ∂h_T) · ∏_{j=k+1}^T (∂h_j / ∂h_{j-1})

The Jacobian matrix ∂h_j / ∂h_{j-1} is given by:

∂h_j / ∂h_{j-1} = diag( 1 – tanh^2(a_j) ) · W_{hh}^T

Where a_j = W_{hh} h_{j-1} + W_{xh} x_j + b. Notice the two critical contraction factors:

  1. 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.
  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.

LSTM Internal Gating Mechanisms and Data Flow Diagram

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).

Interactive Motion Graphic 10.2
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:

f_t = σ( W_f [h_{t-1}, x_t] + b_f )      (Forget Gate: Discard obsolete past)
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.

GRU Minimalist Dual Gate Architecture

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:

Attention(Q, K, V) = softmax( (Q × K^T) / √d_k ) × V

Where Q ∈ RN × d_k (Query), K ∈ RN × d_k (Key), and V ∈ RN × d_v (Value).

Scaled Dot-Product Attention Matrix Shapes and Mechanics

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.

Interactive Motion Graphic 10.3
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:

MultiHead(Q, K, V) = Concat(head_1, head_2, …, head_h) × W_O
where head_i = Attention( Q × W_i^Q, K × W_i^K, V × W_i^V )

Multi-Head Attention Subspace Projection

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.

Interactive Motion Graphic 10.4
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:

M_{i, j} = 0 if j ≤ i, else -∞

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.

Causal Masking in Autoregressive Decoder Transformers

Figure 10.6: Causal masking mechanism. Adding -∞ to the upper-triangular logits zeroes out all future attention probabilities during Softmax normalization.

Interactive Motion Graphic 10.5
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.

Positional Encoding Sinusoidal Progression vs Learned Embeddings

Figure 10.7: Sinusoidal Positional Encoding geometry. Frequencies scale exponentially from short wavelengths (λ=2π) to long wavelengths (λ=10,000·2π), providing unique coordinate fingerprints.

PyTorch 2.x SDPA FlashAttention Kernel Dispatch

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.

nano_gpt_pytorch.py (Complete Decoder-Only LM)
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.

def init_lstm_forget_bias(lstm_module):
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).

# h_n shape: (num_layers * 2, B, 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.

def build_padding_mask(token_ids, pad_idx=0):
# 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.

class SinusoidalPositionalEncoding(nn.Module):
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.

with torch.backends.cuda.sdp_kernel(enable_flash=True, enable_math=False, enable_mem_efficient=False):
# 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.

def apply_rope_2d(q, cos, sin):
# 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.

class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))

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).

def update_kv_cache(k_past, k_new):
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.

def repeat_kv(k, num_groups=4):
# 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.

import math
# 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

  1. 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)
  2. 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)
  3. 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)
  4. 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)
  5. 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

Leave a Reply

Your email address will not be published. Required fields are marked *