LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnPreparation & PrerequisitesThe Transformer Architecture End-to-End
🧠EasyTransformer Architecture

The Transformer Architecture End-to-End

Trace a three-token prompt through causal attention, residual updates, and a vocabulary head. Build and test one complete decoder in PyTorch.

23 min read
Learning path
Step 25 of 196 in the full curriculum
Autoencoders and VAEsLanguage Modeling & Next Tokens

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Autocomplete has read function returns the and needs one more token. result feels plausible, but picking it requires each token to carry its own information while the consults its history without peeking at the answer. What changes between that text and the final choice?

Follow one small decoder-only Transformer forward pass. Three token IDs become positioned vectors, attention exchanges information along legal paths, a block updates every row without changing its width, and the final row becomes vocabulary scores. Small NumPy experiments expose individual operations; three linked PyTorch cells then assemble the complete model. The weights are illustrative, not trained, so a plausible continuation isn't evidence of language understanding.

The preceding autoencoder lesson squeezed an input through a narrow latent code. Here we keep one vector per token and let those vectors communicate. You'll reuse matrix multiplication, nonlinear layers, and the softmax calculation from Softmax & Cross-Entropy. No new differentiation rules are needed for this forward pass.

Before reading the pipeline, predict two shapes: with three tokens and width four, the hidden state should stay [3, 4]; with six vocabulary entries, the selected row should produce six logits. Keep those predictions in view as the data moves.

The complete toy decoder transforms the positioned function, returns, the matrix into a contextual matrix. Final normalization and vocabulary projection produce six logits in token-ID order; result has the largest score, 1.382, and greedy decoding appends it.
Rows `fn`, `ret`, and `the` represent the three prompt tokens. The decoder changes the values without changing the matrix shape. The last row scores all six vocabulary entries; `res` and `val` abbreviate `result` and `value`. These values match the complete PyTorch example below, showing how a forward pass maps prompt tokens to next-token logits.

That path is what we mean by a Transformer here: a stack of attention and position-wise feed-forward updates that carries a same-width representation forward.

The architecture introduced in 2017 paired an encoder with a decoder for translation. A text-generation decoder-only model keeps the decoder's masked self-attention path and predicts each token from preceding context.[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762[2]Reference 2Improving Language Understanding by Generative Pre-Training.https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf

Tokens become positioned vectors

Start at the input boundary with a tiny autocomplete vocabulary. These aren't IDs from a production tokenizer. We're choosing them so the forward pass is readable:

Text so farToken IDsDesired continuation
function returns the[0, 1, 2]likely result

A model doesn't operate on raw token strings. It looks up one learned vector per ID, then adds position information so returns the differs from the returns. Without positional cues, pure self-attention is permutation equivariant: permuting the rows of an input matrix produces the exact same permutation in the output. Shuffling function returns the into the returns function would yield the same token vectors in shuffled row order. Adding or injecting position breaks that symmetry so the network knows the follows returns.

Architectures handle position in three distinct ways:

  1. Fixed sinusoidal encodings: The original 2017 Transformer generated sine and cosine waves of varying frequencies, allowing relative shifts to be expressed as linear transformations without adding learned parameters.[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762
  2. Learned absolute positional embeddings: GPT and BERT learned an explicit embedding table Wpos∈RTmax×dmodelW_{\text{pos}} \in \mathbb{R}^{T_{\text{max}} \times d_{\text{model}}}Wpos​∈RTmax​×dmodel​, adding row ttt directly to token ttt.[2]Reference 2Improving Language Understanding by Generative Pre-Training.https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf[3]Reference 3BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.https://arxiv.org/abs/1810.04805 While simple, this creates a hard cutoff: the model can't process sequences longer than TmaxT_{\text{max}}Tmax​.
  3. Rotary Position Embedding (RoPE): Modern LLMs such as Llama and Mistral rotate query and key vectors inside each head by an angle proportional to sequence index.[4]Reference 4RoFormer: Enhanced Transformer with Rotary Position Embedding.https://arxiv.org/abs/2104.09864[5]Reference 5LLaMA: Open and Efficient Foundation Language Models.https://arxiv.org/abs/2302.13971 Their dot product then depends directly on relative token distance (m−n)(m - n)(m−n), enabling context extension.

Our arithmetic uses small additive vectors to keep every coordinate inspectable by hand:

positioned-token-vectors.py
1import numpy as np 2 3tokens = ["function", "returns", "the"] 4ids = np.array([0, 1, 2]) 5embedding_table = np.array([ 6 [1.0, 0.0, 0.4, 0.0], # function 7 [0.2, 1.0, 0.5, 0.0], # returns 8 [0.0, 0.3, 0.8, 1.0], # the 9 [0.1, 0.5, 1.0, 0.9], # result 10 [0.0, 0.1, 0.0, 0.1], # None 11 [0.2, 0.0, 0.4, 0.5], # value 12]) 13position = np.array([ 14 [0.00, 0.00, 0.00, 0.00], 15 [0.05, 0.00, 0.00, 0.05], 16 [0.10, 0.00, 0.00, 0.10], 17]) 18 19x = embedding_table[ids] + position 20print("tokens:", tokens) 21print("ids shape:", ids.shape) 22print("hidden shape:", x.shape) 23print("last positioned vector:", np.round(x[-1], 2))
Output
1tokens: ['function', 'returns', 'the'] 2ids shape: (3,) 3hidden shape: (3, 4) 4last positioned vector: [0.1 0.3 0.8 1.1]

A row belongs to one token position; a column is one feature of that position. Real models use far wider vectors and batches, but the axes mean the same thing:

AxisTiny exampleGeneral name
Number of sequencesomittedB (batch size)
Token positions3T (sequence length)
Features per token4d_model (model width)

The full vocabulary is function, returns, the, result, None, value, in that ID order. The output head will score those same six tokens. Input and output weights needn't be tied, but a generated ID must be meaningful when fed back into the embedding table.

Diagram showing Positioned vectors function returns the [T, d_model], N masked blocks [T, d_model], Final norm + head Last-position logits [vocab], and Next token result.
Positioned vectors function returns the [T, d_model], N masked blocks [T, d_model], Final norm + head Last-position logits [vocab], and Next token result.

Attention lets a token read earlier tokens

At the, the model needs a useful read of its history. An RNN transports information through one recurrent state. Self-attention takes a different route: every position computes a read from the visible positions, then blends their value vectors.

For a decoder, "visible" means the current and earlier tokens only. That rule lets the model process a whole training sequence without letting a later answer flow backward.

Each token produces three learned projections:

  • A query asks what information this position needs.
  • A key advertises what a position contains.
  • A value supplies information if the query matches its key.

Why separate queries, keys, and values instead of dotting the hidden state XXX with itself? Direct self-matching XXTX X^TXXT creates a symmetric score matrix where token A attends to token B with the exact same weight that token B attends to token A. Human language isn't symmetric: in function returns the, returns needs to inspect its subject function, but function doesn't need to read returns with equal strength. Three separate projection matrices Wq,Wk,Wv∈Rdmodel×dkW_q, W_k, W_v \in \mathbb{R}^{d_{\text{model}} \times d_k}Wq​,Wk​,Wv​∈Rdmodel​×dk​ decouple those roles, allowing queries to search for specific properties while keys advertise them.[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762

Before checking the numbers, predict the score shape: three query rows compared with three key rows should produce a [3, 3] matrix. The the row can score all three keys at this stage; the causal mask will remove any illegal entries next.

To see the multiplication, take one two-feature attention head. These handpicked arrays isolate the dot product; they aren't the projections used by the complete model later:

query-key-scores.py
1import numpy as np 2 3q = np.array([ 4 [1.0, 0.0], # function position 5 [0.5, 1.0], # returns position 6 [0.2, 1.2], # the position 7]) 8k = np.array([ 9 [1.0, 0.0], 10 [0.4, 1.0], 11 [0.0, 0.8], 12]) 13v = np.array([ 14 [1.0, 0.0], 15 [0.3, 1.0], 16 [0.0, 0.6], 17]) 18 19raw_scores = q @ k.T 20print("Q shape:", q.shape, "K shape:", k.shape, "V shape:", v.shape) 21print("Q @ K.T shape:", raw_scores.shape) 22print("raw score row for 'the':", np.round(raw_scores[2], 2))
Output
1Q shape: (3, 2) K shape: (3, 2) V shape: (3, 2) 2Q @ K.T shape: (3, 3) 3raw score row for 'the': [0.2 1.28 0.96]

There is one score for every query-key pair. That square T x T matrix also explains the quadratic cost of standard attention. Doubling T creates four times as many pair scores before the value mix.

Scores only decide where to read. After scaling and masking, softmax turns each row into weights, and multiplying those weights by V produces one mixed value row for each position. The the row becomes a two-feature update that the heads will merge back into model width.

Scale before softmax

Scaled dot-product attention divides query-key scores by the square root of the head width before applying softmax:

Attention⁡(Q,K,V)=softmax⁡(QKTdk)V\operatorname{Attention}(Q, K, V) = \operatorname{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)VAttention(Q,K,V)=softmax(dk​​QKT​)V

Why divide by dk\sqrt{d_k}dk​​? Consider the statistical distribution of raw scores. If the components of query q∈Rdkq \in \mathbb{R}^{d_k}q∈Rdk​ and key k∈Rdkk \in \mathbb{R}^{d_k}k∈Rdk​ are independent random variables with mean zero and variance one, their dot product is:

q⋅k=∑i=1dkqikiq \cdot k = \sum_{i=1}^{d_k} q_i k_iq⋅k=i=1∑dk​​qi​ki​

Each individual product qikiq_i k_iqi​ki​ has mean E[qiki]=E[qi]E[ki]=0\mathbb{E}[q_i k_i] = \mathbb{E}[q_i]\mathbb{E}[k_i] = 0E[qi​ki​]=E[qi​]E[ki​]=0 and variance:

Var⁡(qiki)=E[(qiki)2]−(E[qiki])2=Var⁡(qi)Var⁡(ki)=1×1=1\operatorname{Var}(q_i k_i) = \mathbb{E}[(q_i k_i)^2] - (\mathbb{E}[q_i k_i])^2 = \operatorname{Var}(q_i)\operatorname{Var}(k_i) = 1 \times 1 = 1Var(qi​ki​)=E[(qi​ki​)2]−(E[qi​ki​])2=Var(qi​)Var(ki​)=1×1=1

Because the components are independent, their variances sum directly:

Var⁡(q⋅k)=∑i=1dkVar⁡(qiki)=dk\operatorname{Var}(q \cdot k) = \sum_{i=1}^{d_k} \operatorname{Var}(q_i k_i) = d_kVar(q⋅k)=i=1∑dk​​Var(qi​ki​)=dk​

The standard deviation of the raw dot product grows with dk\sqrt{d_k}dk​​. For a typical head width dk=64d_k = 64dk​=64 or 128128128, raw dot products swing into magnitudes of ±20\pm 20±20 to ±50\pm 50±50. Exponentiating numbers with spreads that large drives softmax into saturation: the largest entry gets a probability near 1.01.01.0, while the rest drop to zero. In that flat regime, softmax gradients ∂si∂zj=si(δij−sj)\frac{\partial s_i}{\partial z_j} = s_i(\delta_{ij} - s_j)∂zj​∂si​​=si​(δij​−sj​) vanish, halting learning.[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762 Dividing by dk\sqrt{d_k}dk​​ resets the score variance to 1.01.01.0, keeping gradients healthy.

Their base model used dmodel=512d_{\text{model}}=512dmodel​=512 with h=8h=8h=8 heads, so each head had dk=64d_k=64dk​=64. The calculation below compares the unscaled and scaled versions of concrete scores [12.4, 9.1, 3.8]:

why-scale-attention.py
1import numpy as np 2 3def softmax(values): 4 shifted = values - values.max() 5 exp = np.exp(shifted) 6 return exp / exp.sum() 7 8scores = np.array([12.4, 9.1, 3.8]) 9scaled = scores / np.sqrt(64) 10 11print("unscaled weights:", np.round(softmax(scores), 3)) 12print("scaled scores:", np.round(scaled, 3)) 13print("scaled weights:", np.round(softmax(scaled), 3))
Output
1unscaled weights: [0.964 0.036 0. ] 2scaled scores: [1.55 1.138 0.475] 3scaled weights: [0.499 0.33 0.17 ]

The unscaled row puts nearly all attention mass on one key, while the scaled row spreads it across visible choices. Scaling doesn't prevent a confident choice after training; it prevents head width alone from making early scores overly sharp.

Use the earlier the row to finish one attention read. Its raw scores [0.2, 1.28, 0.96] become [0.141, 0.905, 0.679] after division by 2\sqrt{2}2​. Softmax gives weights about [0.21, 0.44, 0.35]; applying them to the three value rows produces approximately [0.34, 0.65]. This is a mixture of values, not a probability distribution over next tokens. The vocabulary prediction comes much later.

The causal mask prevents answer leakage

Training can process the positions in function returns the result in parallel within each layer, but position the is still responsible for predicting result. If its query can read the result representation, it sees the answer. A causal mask sets future scores to negative infinity before softmax; those entries receive probability zero. Setting them to zero wouldn't work: e0=1e^0 = 1e0=1, so forbidden keys would still receive active attention weight. Using −∞-\infty−∞ guarantees e−∞=0e^{-\infty} = 0e−∞=0.

Predict the allowed cells before using the table: the the row should include function, returns, and the, but not result. The final row can see all four inputs because no later input is present in this example.

Query positionCan read
functionfunction
returnsfunction, returns
thefunction, returns, the
resultall four positions

Here is a separate four-position score matrix so the forbidden the → result cell is present. It isn't the three-position dot-product example above.

causal-mask.py
1import numpy as np 2 3scores = np.array([ 4 [2.0, 1.5, 0.5, 1.0], 5 [1.0, 2.5, 1.5, 0.5], 6 [0.5, 1.0, 3.0, 2.0], 7 [1.5, 0.5, 1.0, 2.5], 8]) 9future = np.triu(np.ones_like(scores, dtype=bool), k=1) 10masked_scores = np.where(future, -np.inf, scores) 11 12shifted = masked_scores - masked_scores.max(axis=-1, keepdims=True) 13weights = np.exp(shifted) 14weights /= weights.sum(axis=-1, keepdims=True) 15assert np.all(weights[future] == 0.0) 16assert np.allclose(weights.sum(axis=-1), 1.0) 17 18print("masked weights:") 19print(np.round(weights, 3)) 20print("future attention mass:", float(weights[future].sum()))
Output
1masked weights: 2[[1. 0. 0. 0. ] 3 [0.182 0.818 0. 0. ] 4 [0.067 0.111 0.821 0. ] 5 [0.213 0.078 0.129 0.579]] 6future attention mass: 0.0

The output shows future attention mass: 0.0. Read row the (row index 2) as [0.067, 0.111, 0.821, 0.000]: its weights sum to one across the current and earlier positions, and the result column is zero. If a causal language model reports nonzero future attention during training, it has a data-leak bug, not an impressive loss curve. Check this invariant before celebrating a low training loss.

Why can't the representation at the attend to result while training a decoder-only next-token model?

Answer

result is the target token the the position is supposed to predict. Letting the read it would expose the answer before prediction, so training loss would no longer measure generation from preceding context.

Heads run in parallel; blocks run in sequence

A single attention head only computes one weighted average across the sequence. But a token often needs to attend to multiple different relationships at once: one head might track syntactic dependencies (matching a verb to its subject function), while another tracks typing or semantic scoping, and a third tracks the immediate prior token.

Multi-head attention gives a block several such views: it projects model features into heads, applies attention independently in each head, concatenates their outputs, and projects back to d_model.[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762 Each projection can use every input feature. Splitting the projected coordinates doesn't restrict head 1 to reading only the first half of the original embedding.

MultiHead⁡(Q,K,V)=Concat⁡(head⁡1,…,head⁡h)WO\operatorname{MultiHead}(Q, K, V) = \operatorname{Concat}(\operatorname{head}_1, \dots, \operatorname{head}_h) W^OMultiHead(Q,K,V)=Concat(head1​,…,headh​)WO

Suppose our tiny model width is four and it has two heads. Each head owns two features. Predict the shape trace before running the cell: [1, 3, 4] should become [1, 2, 3, 2] after the split, then return to [1, 3, 4] after the merge. Splitting and merging must preserve every token position:

split-and-merge-heads.py
1import numpy as np 2 3batch, tokens, d_model, heads = 1, 3, 4, 2 4assert d_model % heads == 0 5d_head = d_model // heads 6projected = np.arange(batch * tokens * d_model).reshape(batch, tokens, d_model) 7 8split = projected.reshape(batch, tokens, heads, d_head).transpose(0, 2, 1, 3) 9merged = split.transpose(0, 2, 1, 3).reshape(batch, tokens, d_model) 10assert np.array_equal(projected, merged) 11 12print("all-heads shape:", split.shape) 13print("merged shape:", merged.shape) 14print("merge restores values:", bool(np.array_equal(projected, merged)))
Output
1all-heads shape: (1, 2, 3, 2) 2merged shape: (1, 3, 4) 3merge restores values: True

The head axis describes independent work inside one block. The block axis is sequential depth: output from block 1 enters block 2. Stacking repeats the structure, not the weights; each block has its own projections. Mixing heads with blocks loses that dependency.

A block alternates communication and local transformation

Attention lets token positions exchange information across sequence steps. The next sublayer changes each row on its own, using the same weights at every position. That feed-forward network (FFN) is where a token's mixed context gets a per-position nonlinear transformation. Geva et al. observed that FFN layers act like key-value memories: the first linear projection detects input patterns, while the second projection writes out factual associations.[6]Reference 6Transformer Feed-Forward Layers Are Key-Value Memories.https://aclanthology.org/2021.emnlp-main.446/

The original Transformer used two linear layers with a ReLU nonlinearity between them, widening from dmodel=512d_{\text{model}}=512dmodel​=512 to dff=2048d_{\text{ff}}=2048dff​=2048 (a 4×4\times4× expansion) and back:[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762

FFN⁡(x)=ReLU⁡(xW1+b1)W2+b2\operatorname{FFN}(x) = \operatorname{ReLU}(xW_1 + b_1)W_2 + b_2FFN(x)=ReLU(xW1​+b1​)W2​+b2​

The first projection widens the feature dimension; the second returns it to d_model, which keeps blocks stackable. This isolated FFN demo feeds in our positioned vectors to show [3, 4] -> [3, 6] -> [3, 4]. In a complete pre-LN block, the FFN instead receives the normalized stream after attention's residual addition. Bias terms are omitted here so the matrix sizes stay visible.

position-wise-ffn.py
1import numpy as np 2 3x = np.array([ 4 [1.00, 0.00, 0.40, 0.00], 5 [0.25, 1.00, 0.50, 0.05], 6 [0.10, 0.30, 0.80, 1.10], 7]) 8w1 = np.array([ 9 [1.0, 0.0, 0.5, 0.0, 0.0, 0.2], 10 [0.0, 1.0, 0.0, 0.5, 0.2, 0.0], 11 [0.4, 0.2, 1.0, 0.0, 0.0, 0.5], 12 [0.0, 0.2, 0.0, 1.0, 0.4, 0.0], 13]) 14w2 = np.array([ 15 [0.3, 0.0, 0.0, 0.1], 16 [0.0, 0.3, 0.1, 0.0], 17 [0.2, 0.0, 0.3, 0.0], 18 [0.0, 0.2, 0.0, 0.3], 19 [0.1, 0.0, 0.0, 0.2], 20 [0.0, 0.1, 0.2, 0.0], 21]) 22 23expanded = np.maximum(0.0, x @ w1) 24ffn_output = expanded @ w2 25print("input shape:", x.shape) 26print("expanded shape:", expanded.shape) 27print("returned shape:", ffn_output.shape) 28print("last-token update:", np.round(ffn_output[-1], 3))
Output
1input shape: (3, 4) 2expanded shape: (3, 6) 3returned shape: (3, 4) 4last-token update: [0.346 0.496 0.407 0.517]

Unlike attention's Q @ K.T, neither FFN multiplication creates a token-by-token matrix. Every row gets the same transformation without reading another row.

Modern production models such as Llama, Mistral, and Gemma replace standard MLPs with SwiGLU (Swish Gated Linear Unit):[7]Reference 7GLU Variants Improve Transformerhttps://arxiv.org/abs/2002.05202[5]Reference 5LLaMA: Open and Efficient Foundation Language Models.https://arxiv.org/abs/2302.13971

SwiGLU⁡(x)=(Swish⁡(xWgate)⊙(xWup))Wdown\operatorname{SwiGLU}(x) = (\operatorname{Swish}(x W_{\text{gate}}) \odot (x W_{\text{up}})) W_{\text{down}}SwiGLU(x)=(Swish(xWgate​)⊙(xWup​))Wdown​

Here, Swish⁡(z)=z⋅σ(z)\operatorname{Swish}(z) = z \cdot \sigma(z)Swish(z)=z⋅σ(z) (also called SiLU). Instead of a single upward projection, two linear projections run in parallel: one acts as a smooth gating filter that scales the linear path elementwise before projecting back down through WdownW_{\text{down}}Wdown​. Because SwiGLU introduces a third weight matrix, implementations typically size the intermediate dimension to 83dmodel\frac{8}{3} d_{\text{model}}38​dmodel​ (rounded to multiples of 64 or 256) so total parameter counts match a standard 4×dmodel4\times d_{\text{model}}4×dmodel​ MLP.

Residual paths and normalization

A residual connection adds a sublayer result back to its input. Because attention and the FFN return to d_model, addition keeps the running stream at the same shape. It creates an identity path through deep stacks, following the residual-network concept from He et al.[8]Reference 8Deep Residual Learning for Image Recognition.https://arxiv.org/abs/1512.03385

Placement of normalization determines training stability:

  • Post-LayerNorm (2017 original): Normalization sits directly in the residual stream after each addition:[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762 xl+1=LayerNorm⁡(xl+SubLayer⁡(xl))x_{l+1} = \operatorname{LayerNorm}(x_l + \operatorname{SubLayer}(x_l))xl+1​=LayerNorm(xl​+SubLayer(xl​)) As backpropagation flows backward from layer LLL to layer 111, gradients pass repeatedly through the LayerNorm Jacobian ∂LN⁡∂z\frac{\partial \operatorname{LN}}{\partial z}∂z∂LN​ at every block. In deep networks (L≥24L \ge 24L≥24), gradients vanish or explode near early layers unless trained with delicate learning-rate warmup schedules.
  • Pre-LayerNorm (modern standard): Normalization precedes each sublayer, leaving the main residual addition untouched:[9]Reference 9GPT-2 Source Implementation.https://github.com/openai/gpt-2/blob/master/src/model.py[10]Reference 10On Layer Normalization in the Transformer Architecture.https://arxiv.org/abs/2002.04745 xl+1=xl+SubLayer⁡(LayerNorm⁡(xl))x_{l+1} = x_l + \operatorname{SubLayer}(\operatorname{LayerNorm}(x_l))xl+1​=xl​+SubLayer(LayerNorm(xl​)) The full recurrence becomes an additive telescope: xL=x0+∑l=0L−1SubLayer⁡(LayerNorm⁡(xl))x_L = x_0 + \sum_{l=0}^{L-1} \operatorname{SubLayer}(\operatorname{LayerNorm}(x_l))xL​=x0​+∑l=0L−1​SubLayer(LayerNorm(xl​)). Differentiating with respect to an early layer xlx_lxl​ produces: ∂L∂xl=∂L∂xL(I+∑j=lL−1∂SubLayer⁡(LayerNorm⁡(xj))∂xl)\frac{\partial \mathcal{L}}{\partial x_l} = \frac{\partial \mathcal{L}}{\partial x_L} \left( I + \sum_{j=l}^{L-1} \frac{\partial \operatorname{SubLayer}(\operatorname{LayerNorm}(x_j))}{\partial x_l} \right)∂xl​∂L​=∂xL​∂L​(I+∑j=lL−1​∂xl​∂SubLayer(LayerNorm(xj​))​) The identity matrix III acts as an unobstructed gradient highway, letting error signals travel directly across all layers without decaying. That's why Pre-LN models train reliably without warmups.

Architectures also differ in the normalization statistic itself:

  • LayerNorm: Centers by subtracting mean μ\muμ, then scales by standard deviation σ2+ϵ\sqrt{\sigma^2 + \epsilon}σ2+ϵ​ with learned scale γ\gammaγ and shift β\betaβ.[11]Reference 11Layer Normalization.https://arxiv.org/abs/1607.06450
  • RMSNorm: Zhang & Sennrich showed that mean-centering is computationally unnecessary; stability comes almost entirely from scaling by the root-mean-square: RMSNorm⁡(x)=xRMS⁡(x)⊙γ,where RMS⁡(x)=1d∑i=1dxi2+ϵ\operatorname{RMSNorm}(x) = \frac{x}{\operatorname{RMS}(x)} \odot \gamma, \quad \text{where } \operatorname{RMS}(x) = \sqrt{\frac{1}{d} \sum_{i=1}^d x_i^2 + \epsilon}RMSNorm(x)=RMS(x)x​⊙γ,where RMS(x)=d1​∑i=1d​xi2​+ϵ​ Skipping mean subtraction saves memory bandwidth in GPU kernels, providing a 10% to 50% speedup in normalization runtime while matching LayerNorm's training stability. Llama and Mistral standardise on RMSNorm.[12]Reference 12Root Mean Square Layer Normalization.https://arxiv.org/abs/1910.07467[5]Reference 5LLaMA: Open and Efficient Foundation Language Models.https://arxiv.org/abs/2302.13971

Before running the snippet, predict two checks: normalization should put each row near mean 0 and variance 1, while adding the update should leave the residual output at shape [2, 4]. The code simulates that pre-normalization order with a small update.

pre-normalized-residual.py
1import numpy as np 2 3def normalize(x, eps=1e-5): 4 mean = x.mean(axis=-1, keepdims=True) 5 variance = ((x - mean) ** 2).mean(axis=-1, keepdims=True) 6 return (x - mean) / np.sqrt(variance + eps) 7 8x = np.array([ 9 [1.0, 2.0, 0.0, 1.0], 10 [0.2, 0.4, 0.8, 0.6], 11]) 12normalized = normalize(x) 13sublayer_update = 0.1 * normalized 14residual_output = x + sublayer_update 15 16print("normalized means:", np.round(normalized.mean(axis=-1), 6)) 17print("normalized variances:", np.round(normalized.var(axis=-1), 4)) 18print("residual output shape:", residual_output.shape)
Output
1normalized means: [0. 0.] 2normalized variances: [1. 0.9998] 3residual output shape: (2, 4)

The small eps keeps division defined even for a constant row. It also explains why the variances are slightly below one. Learned scale and offset can change those statistics afterward; LayerNorm doesn't promise every output always has mean zero and variance one. In the block view, follow the attention update into x₁, then the FFN update into x₂. Both additions preserve [3, 4].

Comparison of Pre-LN and Post-LN decoder block layouts. Pre-LN keeps an unobstructed identity gradient highway by normalizing branches before attention and feed-forward sublayers. Post-LN places LayerNorm directly on the residual path, causing repeated gradient scaling in deep networks.
Pre-LN normalizes branch inputs before masked attention and the feed-forward network, keeping the identity residual highway clean so gradients flow unobstructed across deep stacks. Post-LN places normalization directly on the residual path, which repeatedly scales backpropagating gradients and requires delicate learning rate warmup.

Implement masked attention in PyTorch

The NumPy example exposed the mask entry by entry. Now keep the same contract in one causal multi-head self-attention layer. The projections are learned linear maps; the mask is created from token positions. Calling masked_fill enforces that visibility constraint: it turns every future score into negative infinity before softmax.

Trace the shapes before running it: [1, 4, 8] becomes [1, 2, 4, 4] for each of q, k, and v; scores and weights stay [1, 2, 4, 4]; merging heads returns [1, 4, 8]. The largest future weight should be exactly zero.

causal-self-attention.py
1import math 2import torch 3from torch import nn 4 5class CausalSelfAttention(nn.Module): 6 def __init__(self, d_model=8, heads=2): 7 super().__init__() 8 if heads <= 0 or d_model % heads != 0: 9 raise ValueError("heads must be positive and divide d_model") 10 self.heads = heads 11 self.d_head = d_model // heads 12 self.qkv = nn.Linear(d_model, 3 * d_model, bias=False) 13 self.out = nn.Linear(d_model, d_model, bias=False) 14 15 def forward(self, x): 16 batch, tokens, d_model = x.shape 17 q, k, v = self.qkv(x).chunk(3, dim=-1) 18 def split_heads(tensor): 19 return tensor.view(batch, tokens, self.heads, self.d_head).transpose(1, 2) 20 q, k, v = map(split_heads, (q, k, v)) 21 22 scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head) 23 future = torch.triu( 24 torch.ones(tokens, tokens, dtype=torch.bool, device=x.device), 25 diagonal=1, 26 ) 27 weights = torch.softmax(scores.masked_fill(future, float("-inf")), dim=-1) 28 mixed = weights @ v 29 merged = mixed.transpose(1, 2).contiguous().view(batch, tokens, d_model) 30 return self.out(merged), weights 31 32torch.manual_seed(4) 33x = torch.randn(1, 4, 8) 34attention = CausalSelfAttention() 35output, weights = attention(x) 36future = torch.triu(torch.ones(4, 4, dtype=torch.bool), diagonal=1) 37assert torch.all(weights[0, :, future] == 0) 38assert torch.allclose(weights.sum(dim=-1), torch.ones_like(weights.sum(dim=-1))) 39 40print("attention output shape:", tuple(output.shape)) 41print("weights shape:", tuple(weights.shape)) 42print("largest future weight:", f"{weights[0, :, future].max().item():.6f}")
Output
1attention output shape: (1, 4, 8) 2weights shape: (1, 2, 4, 4) 3largest future weight: 0.000000

The output shape matches the input shape, and the [batch, heads, tokens, tokens] weight tensor exposes each head's routing decisions. The zero future weight is the causal invariant in PyTorch form.

The PyTorch cell uses random inputs to test shapes and visibility separately from the hand-worked values. It deliberately omits padding, dropout, and a KV cache. All positions are real tokens, so the causal diagonal always supplies at least one visible key. Modern production stacks use fused kernels such as torch.nn.functional.scaled_dot_product_attention, which compute this exact causal calculation without materializing the full T×TT \times TT×T attention matrix in GPU memory.

Assemble one decoder block

Attention now returns a same-width update, so it can sit inside a full pre-normalized block. Read the sequence before the code: normalize, attend, add; normalize, widen through the FFN, return to width eight, and add again. For input [2, 4, 8] and d_ff=24, every residual merge should still be [2, 4, 8].

PyTorch's LayerNorm includes the learned scale and offset that the earlier NumPy calculation omitted. This block uses GELU instead of the original Transformer's ReLU, the same activation family as GPT-2's MLP. GPT-2 uses a tanh approximation; nn.GELU() below uses the default exact formulation. Either way, the FFN's structural role stays the same: widen, transform, and return to d_model.[13]Reference 13Gaussian Error Linear Units (GELUs).https://arxiv.org/abs/1606.08415[9]Reference 9GPT-2 Source Implementation.https://github.com/openai/gpt-2/blob/master/src/model.py

Continue in the same Python session so CausalSelfAttention remains defined. The attention module returns both its update and its weights; the block needs only the update for the residual addition.

decoder-block.py
1class DecoderBlock(nn.Module): 2 def __init__(self, d_model=8, heads=2, d_ff=24): 3 super().__init__() 4 self.norm1 = nn.LayerNorm(d_model) 5 self.attention = CausalSelfAttention(d_model, heads) 6 self.norm2 = nn.LayerNorm(d_model) 7 self.ffn = nn.Sequential( 8 nn.Linear(d_model, d_ff), 9 nn.GELU(), 10 nn.Linear(d_ff, d_model), 11 ) 12 13 def forward(self, x): 14 update, _ = self.attention(self.norm1(x)) 15 x = x + update 16 x = x + self.ffn(self.norm2(x)) 17 return x 18 19torch.manual_seed(5) 20x = torch.randn(2, 4, 8, requires_grad=True) 21block = DecoderBlock() 22output = block(x) 23output.square().mean().backward() 24assert output.shape == x.shape 25assert x.grad is not None and torch.isfinite(x.grad).all() 26 27print("input -> output:", tuple(x.shape), "->", tuple(output.shape)) 28print("input gradient is finite:", bool(torch.isfinite(x.grad).all()))
Output
1input -> output: (2, 4, 8) -> (2, 4, 8) 2input gradient is finite: True

The finite-gradient check catches a broken backward pass for this input; it doesn't prove that a deep stack will train well. The block still hasn't generated a word. It produces contextual vectors with the same [B, T, d_model] shape as its input, so another block can consume them.

Project the final position into a vocabulary

The block stack has finished its same-width work. The vocabulary head changes the stream's output width from model features to token scores. GPT-2 applies a final ln_f normalization immediately before computing logits.[9]Reference 9GPT-2 Source Implementation.https://github.com/openai/gpt-2/blob/master/src/model.py

A learned output matrix converts each hidden vector into one raw score per vocabulary item. These scores are logits. Vaswani et al. share that matrix with the token embedding table; GPT-2's wte is reused the same way. This strategy, called weight tying, halves the parameter footprint of the vocabulary layers and enforces geometric consistency between input representations and output predictions.[1]Reference 1Attention Is All You Need.https://arxiv.org/abs/1706.03762[2]Reference 2Improving Language Understanding by Generative Pre-Training.https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf The toy below keeps a separate output_weight so the multiply is visible.

Now connect the actual prompt to the block. Continue in the same session with the DecoderBlock class above. This time use width four, two heads, and an FFN hidden width of six. The embedding table is exactly the one from the opening NumPy example.

The matrix multiply applies the vocabulary head to every position: [1, 3, 4] @ [4, 6] → [1, 3, 6]. Generation selects the last six scores. Training can use all three rows, with a shifted next-token target for each. The output weights below are deliberately chosen for a readable ranking; the random block hasn't learned English.

next-token-head.py
1vocab = ["function", "returns", "the", "result", "None", "value"] 2ids = torch.tensor([[0, 1, 2]]) 3embedding_table = torch.tensor([ 4 [1.0, 0.0, 0.4, 0.0], 5 [0.2, 1.0, 0.5, 0.0], 6 [0.0, 0.3, 0.8, 1.0], 7 [0.1, 0.5, 1.0, 0.9], 8 [0.0, 0.1, 0.0, 0.1], 9 [0.2, 0.0, 0.4, 0.5], 10]) 11position = torch.tensor([ 12 [0.00, 0.00, 0.00, 0.00], 13 [0.05, 0.00, 0.00, 0.05], 14 [0.10, 0.00, 0.00, 0.10], 15]) 16output_weight = torch.tensor([ 17 [1.0, 0.2, 0.0, 0.0, 0.0, 0.0], 18 [0.0, 0.0, 0.0, 0.0, -1.0, 0.0], 19 [0.0, 0.0, 0.0, 0.5, 0.0, 1.0], 20 [0.0, 0.0, 0.2, 1.0, 0.0, 0.0], 21]) 22 23torch.manual_seed(7) 24toy_block = DecoderBlock(d_model=4, heads=2, d_ff=6).eval() 25final_norm = nn.LayerNorm(4).eval() 26with torch.no_grad(): 27 positioned = embedding_table[ids] + position 28 contextual = toy_block(positioned) 29 normalized = final_norm(contextual) 30 logits = normalized @ output_weight 31 probabilities = logits[0, -1].softmax(dim=-1) 32 best = probabilities.argmax().item() 33 34assert logits.shape == (1, 3, len(vocab)) 35assert not torch.allclose(contextual, positioned) 36assert torch.allclose(probabilities.sum(), torch.tensor(1.0)) 37 38print("last row after block:", [round(v, 3) for v in contextual[0, -1].tolist()]) 39print("normalized last row:", [round(v, 3) for v in normalized[0, -1].tolist()]) 40print("all-position logits shape:", tuple(logits.shape)) 41print("last-position logits:", [round(v, 3) for v in logits[0, -1].tolist()]) 42print("top token:", vocab[best]) 43print("top probability:", f"{probabilities[best]:.3f}")
Output
1last row after block: [0.037, 0.267, 0.994, 0.87] 2normalized last row: [-1.26, -0.686, 1.128, 0.818] 3all-position logits shape: (1, 3, 6) 4last-position logits: [-1.26, -0.252, 0.164, 1.382, 0.686, 1.128] 5top token: result 6top probability: 0.353

The vector sent to the output head has passed through attention, both residual additions, the FFN, and final normalization. Its values differ from the original embedding. The figure drops the batch axis of size one and shows the last-position slice; slicing before or after the position-wise vocabulary projection gives the same six logits.

End-to-end tensor shape pipeline and final vocabulary projection. Tensors flow from input IDs [1, 3] to embeddings [1, 3, 4], multi-head QKV [1, 2, 3, 2], attention weights [1, 2, 3, 3], FFN expansion [1, 3, 6], and normalized stream [1, 3, 4]. The final row [-1.260, -0.686, 1.128, 0.818] dots with the result column [0, 0, 0.5, 1] to yield winning logit 1.382.
The top pipeline tracks tensor shape transformations at each stage: sequence `[1, 3]` expands through embeddings, heads, and feed-forward sublayers before returning to `[1, 3, 4]`. The lower panels show the final normalized row dotted with the vocabulary projection matrix to produce six logits in token-ID order, selecting `result`.

Choosing the largest logit is greedy decoding. It appends result to make function returns the result.

Alternatively, sampling introduces temperature scaling T>0T > 0T>0 to soften or sharpen the logits before softmax:

P(wi)=exp⁡(logiti/T)∑jexp⁡(logitj/T)P(w_i) = \frac{\exp(\text{logit}_i / T)}{\sum_j \exp(\text{logit}_j / T)}P(wi​)=∑j​exp(logitj​/T)exp(logiti​/T)​

When T→0T \to 0T→0, the probabilities collapse into an argmax choice. At T=1.0T = 1.0T=1.0, the model samples from its natural probability distribution. When T>1.0T > 1.0T>1.0, probabilities flatten toward a uniform distribution, producing diverse continuations.

To run another step, add a fourth row to the positional table as well as appending the new token ID. Our three-position demo isn't an unlimited text generator. Optimized serving reuses earlier attention keys and values through a KV cache instead of recomputing them for every new token.[14]Reference 14KV Cache Strategieshttps://huggingface.co/docs/transformers/main/en/kv_cache We haven't implemented that optimization here. The next lesson turns this single step into a generation loop and a training objective.

Encoder, decoder, and encoder-decoder visibility

Decoder-only generation is one member of the Transformer family. Once the job changes, the visibility rule must change with it: classification can use the whole input, while translation needs a full source plus a causal target. Compare which positions each variant may read:

VariantVisibility ruleSuitable lesson exampleRepresentative source
Encoder-onlyEach input position can read both left and right contextClassify a complete snippet as Python code or proseBERT[3]Reference 3BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.https://arxiv.org/abs/1810.04805
Decoder-onlyEach position can read only itself and prior tokensGenerate the next token of function returns theGPT, Llama[2]Reference 2Improving Language Understanding by Generative Pre-Training.https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf[5]Reference 5LLaMA: Open and Efficient Foundation Language Models.https://arxiv.org/abs/2302.13971
Encoder-decoderEncoder reads source; decoder reads source plus prior output tokensTranslate a short docstring from English to another languageT5[15]Reference 15Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer.https://arxiv.org/abs/1910.10683
Three attention visibility rules defining the Transformer family: encoder-only bidirectional attention in BERT, decoder-only causal lower-triangular attention in GPT and modern LLMs, and encoder-decoder dual attention in T5 featuring causal self-attention plus source cross-attention.
Checkmarks indicate allowed attention connections. Decoder-only models dominate modern LLMs because their causal mask enables unified next-token pre-training, zero-cost prompt conditioning, and efficient single-stream KV caching. Encoder-only models excel at bidirectional representations, while encoder-decoder models handle sequence-to-sequence translation at the cost of dual attention caches.

Why did decoder-only models come to dominate modern LLMs? Four architectural properties drove that outcome:

  1. Unified pre-training objective: Autoregressive next-token prediction turns all text into an unsupervised training stream. Unlike masked language models that corrupt 15% of tokens or sequence-to-sequence models requiring paired data, every single token in the sequence provides a training loss signal.
  2. Zero-cost prompt conditioning: In-context prompting and few-shot examples require no custom architecture. Prompts simply precede generation in the causal sequence, letting the model condition on instructions naturally through attention history.
  3. Streamlined KV caching: During autoregressive decoding, a decoder-only model maintains a single KV cache for its causal self-attention layers. Encoder-decoder models must maintain two distinct caches per layer: a static cross-attention cache over encoder states and a growing causal self-attention cache over generated tokens.
  4. Predictable scaling behavior: Empirical scaling laws demonstrated that autoregressive decoders scale smoothly across compute, parameters, and tokens, unlocking emergent reasoning without architectural rework.

In self-attention, queries, keys, and values come from one sequence. In encoder-decoder cross-attention, decoder queries read keys and values produced by the encoder. A plain decoder-only text generator has no separate source sequence, so its core path is causal self-attention.

Diagnose a broken forward pass

When a forward pass fails, don't inspect every weight first. Ask which contract broke: visibility, shape, device placement, or normalization. The symptom usually narrows the search:

SymptomLikely causeCheck
Training loss looks suspiciously tiny, but generation failsFuture tokens leaked through attentionAssert masked future attention mass is zero
Head split crashes or silently produces the wrong shaped_model isn't divisible by heads, or axes were transposed incorrectlyCheck [B, H, T, d_head] then merge back to [B, T, d_model]
Attention works on CPU but fails after moving the block to a GPUCausal mask stayed on CPU while scores moved to the acceleratorCreate the mask on x.device
A block can't be stacked with another blockFFN or output projection changed model width too earlyKeep block output at d_model; project to vocabulary only at the end
Manual softmax produces NaNA row was fully masked, so subtracting its maximum computes −∞−(−∞)-\infty - (-\infty)−∞−(−∞)Give every real query a visible key; handle fully padded queries explicitly
Temperature scaling produces flat probabilities regardless of temperature settingTemperature was applied after softmax instead of dividing raw logitsDivide logits by TTT prior to exponentiation

Use a deliberately bad mask to make leakage visible. Predict the two totals before running the cell: an unmasked matrix should assign positive mass to future cells, while the causal version should assign 0.000.

detect-mask-leakage.py
1import numpy as np 2 3scores = np.array([ 4 [2.0, 5.0, 4.0], 5 [1.0, 2.0, 6.0], 6 [0.5, 1.0, 2.0], 7]) 8future = np.triu(np.ones_like(scores, dtype=bool), k=1) 9 10def softmax_rows(matrix): 11 exp = np.exp(matrix - matrix.max(axis=-1, keepdims=True)) 12 return exp / exp.sum(axis=-1, keepdims=True) 13 14unmasked = softmax_rows(scores) 15masked = softmax_rows(np.where(future, -np.inf, scores)) 16print("unmasked future mass across rows:", f"{unmasked[future].sum():.3f}") 17print("masked future mass across rows:", f"{masked[future].sum():.3f}")
Output
1unmasked future mass across rows: 1.940 2masked future mass across rows: 0.000

The unmasked total exceeds one because it adds probability mass from several separately normalized rows. Each row still sums to one. A zero future total is necessary, but inspecting the mask alone can miss leakage elsewhere in the block. A stronger test changes a future input and checks whether an earlier output changes.

Test the whole block, not just the mask

Continue the PyTorch session. Replace the final token's features while keeping the first two positions fixed. Which outputs may change? Causality says the first two must be unchanged, even after the FFN and residual additions. The last output should change, so the test also catches a block that ignores its input.

future-input-invariance.py
1changed = positioned.clone() 2changed[:, -1, :] = torch.tensor([2.0, -1.0, 0.0, 0.5]) 3with torch.no_grad(): 4 original_output = toy_block(positioned) 5 changed_output = toy_block(changed) 6 7prefix_unchanged = torch.allclose(original_output[:, :-1], changed_output[:, :-1]) 8last_changed = not torch.allclose(original_output[:, -1], changed_output[:, -1]) 9assert prefix_unchanged and last_changed 10print("earlier outputs unchanged:", prefix_unchanged) 11print("last output changed:", last_changed)
Output
1earlier outputs unchanged: True 2last output changed: True

Try removing masked_fill in CausalSelfAttention. The earlier outputs should now change, causing this test to fail. Keep dropout disabled for this comparison; otherwise random dropout masks can change outputs even when causality is correct.

You double the prompt from 3 tokens to 6, keeping width 4 and two heads. Which tensors grow fourfold, and which only double?

Answer

Each head's score and attention-weight matrices grow from [3,3] to [6,6], four times as many entries. Hidden states, Q/K/V, and FFN activations double their token dimension. Model parameter shapes don't change. This describes the explicit full matrices in our implementation, not every optimized attention kernel's memory use.

You replace the FFN's GELU with no activation at all. The code still preserves shape. What capability disappears from that sublayer?

Answer

Two consecutive linear transformations without an intervening nonlinearity collapse mathematically into one linear map (W1W2=WcombinedW_1 W_2 = W_{\text{combined}}W1​W2​=Wcombined​). The intermediate expansion alone doesn't provide nonlinear per-token transformation. The overall Transformer remains nonlinear because attention and normalization remain, but the FFN loses its capacity to model complex feature interactions.

The attention output is [1,3,4], but you change the FFN's final projection to width 6. Why can't the residual addition be repaired just by reshaping?

Answer

The residual has 12 entries and the FFN update has 18. More importantly, addition must match the same token and feature coordinates. Project the six-wide update back to four features with a learned map; don't reinterpret token or feature axes to hide the mismatch.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A toy prompt uses token IDs [0,1,2]; the embedding for the is [0.0,0.3,0.8,1.0] and its positional vector is [0.1,0.0,0.0,0.1]. What enters the first block at that position?

Correct answer: [0.1,0.3,0.8,1.1], because token and position vectors are added element by element without changing the width.

The model combines token identity and position by elementwise addition in the same d_model space. The sum preserves four coordinates while giving the block both content and order information.

2.One attention head has query q=[0.2,1.2] and keys [[1.0,0.0],[0.4,1.0],[0.0,0.8]]. What raw dot-product scores does this query assign?

Correct answer: [0.2,1.28,0.96], from dotting q with each key row in order.

The products are .2*1+1.2*0=.2, .2*.4+1.2*1=1.28, and .2*0+1.2*.8=.96. The resulting vector is one query-to-key score row before scaling or masking.

3.An attention head has d_head=64 and raw scores [12.4,9.1,3.8] for visible keys. What scaled scores should feed softmax?

Correct answer: [1.55,1.138,0.475], each raw score divided by sqrt(64)=8 before softmax.

Under the independent unit-variance component assumption, dot-product variance grows with key width. Dividing by its square root controls that scale; sqrt(64) is exactly 8 here.

4.During training on function returns the result, the representation at the is used to predict a later token. Which visibility rule prevents information leakage?

Correct answer: A causal mask blocks attention from the to later positions, so its next-token prediction uses only itself and earlier tokens.

Next-token training must match generation-time information. The upper-triangular future positions are masked before softmax; shifting labels alone does not stop hidden-state attention from reading future inputs.

5.A decoder Transformer has hidden states [B,T,d_model] with d_model=768 and 12 attention heads. What shape contains all query heads after splitting and transposing?

Correct answer: [B,12,T,64], because the model width is divided into 12 head widths of 768/12=64.

The projection first produces model-width queries, then reshapes and transposes them into batch, head, sequence, head-dimension order. Heads partition the width rather than duplicate it.

6.A pre-normalized decoder block receives x with shape [2,4,8] and uses d_ff=24. Which layout preserves residual compatibility?

Correct answer: Normalize and attend at width 8, add the residual at [2,4,8], then use an FFN 8 -> 24 -> 8 before its second residual add.

Residual additions require equal shapes at each branch. The FFN expands temporarily to d_ff and projects back to d_model; pre-LN changes normalization placement, not residual width.

7.A decoder stack returns hidden states [1,3,4]. Applying a six-token vocabulary head to every position produces what shape, and what does the last row represent?

Correct answer: Eighteen logits shaped [1,3,6]; the last row is six next-token scores for the third position.

The vocabulary projection maps each 4-wide hidden vector to six class scores independently at every batch and sequence position. The final row is used for the next token during autoregressive decoding.

8.You need a Transformer to translate a short Python docstring from English to another language. Which attention visibility pattern fits an encoder-decoder design?

Correct answer: The encoder reads the source bidirectionally; the decoder generates target tokens causally and cross-attends to the encoded source.

Translation needs full source understanding and left-to-right target generation. Cross-attention supplies source representations while the decoder's causal self-attention prevents target leakage.

9.A model has two attention heads in each of two decoder blocks. Which description matches the computational dependencies of one forward pass?

Correct answer: The two heads in a block can compute in parallel and are combined; the two blocks apply sequentially because the second consumes the first block's residual stream.

Multi-head attention splits one layer's projections into parallel subspaces, then concatenates and projects them. Stacked blocks form a dependency chain through residual outputs, even though tokens within a block can process in parallel.

9 questions remaining.

Next Step
Continue to Language Modeling & Next Tokens

You can now run the decoder forward path on `function returns the` and obtain next-token logits. Next you'll shift token sequences into inputs and targets, then apply the <span data-glossary="cross-entropy">cross-entropy</span> objective introduced earlier at every predicted position.

PreviousAutoencoders and VAEs
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Attention Is All You Need.

Vaswani, A., et al. · 2017

https://arxiv.org/abs/1706.03762

Improving Language Understanding by Generative Pre-Training.

Radford, A., et al. · 2018

https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf

BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.

Devlin, J., et al. · 2019 · NAACL 2019

https://arxiv.org/abs/1810.04805

RoFormer: Enhanced Transformer with Rotary Position Embedding.

Su, J., et al. · 2021

https://arxiv.org/abs/2104.09864

LLaMA: Open and Efficient Foundation Language Models.

Touvron, H., et al. · 2023

https://arxiv.org/abs/2302.13971

Transformer Feed-Forward Layers Are Key-Value Memories.

Geva, M., Schuster, R., Berant, J., Levy, O. · 2021 · EMNLP 2021

https://aclanthology.org/2021.emnlp-main.446/

GLU Variants Improve Transformer

Shazeer, N. · 2020

https://arxiv.org/abs/2002.05202

Deep Residual Learning for Image Recognition.

He, K., Zhang, X., Ren, S., Sun, J. · 2015 · CVPR 2016

https://arxiv.org/abs/1512.03385

GPT-2 Source Implementation.

OpenAI · 2019

https://github.com/openai/gpt-2/blob/master/src/model.py

On Layer Normalization in the Transformer Architecture.

Xiong, R., et al. · 2020 · ICML 2020

https://arxiv.org/abs/2002.04745

Layer Normalization.

Ba, J. L., Kiros, J. R., & Hinton, G. E. · 2016

https://arxiv.org/abs/1607.06450

Root Mean Square Layer Normalization.

Zhang, B. & Sennrich, R. · 2019 · NeurIPS 2019

https://arxiv.org/abs/1910.07467

Gaussian Error Linear Units (GELUs).

Hendrycks, D., Gimpel, K. · 2016

https://arxiv.org/abs/1606.08415

KV Cache Strategies

Hugging Face · 2026

https://huggingface.co/docs/transformers/main/en/kv_cache

Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer.

Raffel, C., et al. · 2020 · JMLR

https://arxiv.org/abs/1910.10683

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.