NLP → Attention → Build a GPT · ML Coding Practice

17 題 從 NLP 基礎到打造 GPT 的 Python 筆記

依學習順序分成三部分:NLP 基礎、Attention & Transformers、Build a GPT。每題包含題目摘要、PyTorch 解法、繁體中文說明、Time / Space Complexity,以及 Interview Explanation Flow。適合 Top-tier AI lab 的 ML coding 面試練習。

4 Easy 10 Medium 3 Hard NLP · Embeddings · Attention · Transformer · Training · Inference PyTorch 17 official practice links Updated 2026-09-14
題目描述是根據「NLP」「Attention & Transformers」「Build a GPT」公開題名與常見實作版本(Karpathy nanoGPT / minBPE 系列、Attention Is All You Need)重新整理的原創摘要;重點放在面試時要能講清楚的張量形狀、資料流與設計取捨。

解題總覽

三個學習階段

Part A · NLP 基礎:word-level tokenization、padding、word embeddings、情感分類、positional encoding。Part B · Attention:self-attention → multi-head → transformer block。Part C · Build a GPT:tokenizer、資料、完整 GPT、訓練、生成與推論優化。

面試重點

能講清楚每個張量的 shape (B, T, C)、attention 為什麼要 1/√d 縮放與 causal tril mask、cross-entropy 對齊時 target 為何右移一格、以及 KV-Cache 與 GQA 各自省下什麼。

建議順序

照表格 1→17 由淺入深:先把 NLP 與 attention 的積木練熟,第 5–7 題的 self-attention/transformer block 正是第 13 題 Code GPT 的零件,最後串起訓練、生成與推論優化。

# Problem Topic Difficulty 核心想法 Time Space Practice
Part A · NLP 基礎
1Intro to Natural Language ProcessingNLP · TokenizationMedium合併語料建 lexicographic vocab(ID 從 1),逐句編碼後 padding 成 (2N, T)。O(W + V log V + 2N·T)O(V + 2N·T)NeetCode ↗
2Word EmbeddingsNLPEasy理解 dense embedding 與 cosine similarity,再用 NumPy / PyTorch indexing 完成單批與 batched lookup。O(N · d) lookupO(N · d) outputNumPy 題 ↗ · PyTorch 題 ↗
3Sentiment AnalysisNLPMediummasked mean-pool embedding → linear → cross-entropy 分類。O(B · T · d)O(params)NeetCode ↗
4Positional EncodingNLPMediumsinusoidal:even 維 sin、odd 維 cos,不同頻率注入位置資訊。O(T · d)O(T · d)NeetCode ↗
Part B · Attention & Transformers
5Self AttentionAttentionMediumscaled dot-product:softmax(Q Kᵀ/√d) V,可選 mask。O(T² · d)O(T²)NeetCode ↗
6Multi Headed Self AttentionAttentionMedium一次投影 QKV,reshape 成多頭平行運算,concat 後投影。O(T² · C)O(T² · h)NeetCode ↗
7Transformer BlockTransformerMediumpre-norm 殘差:x + attn(ln1(x))、x + ffn(ln2(x))。O(T² · C)O(T² + params)NeetCode ↗
Part C · Build a GPT
8Tokenizer (Byte Pair Encoding)TokenizationMedium從 UTF-8 bytes 出發,反覆合併出現頻率最高的相鄰 pair。O(merges · N)O(N + V)NeetCode ↗
9Build VocabularyTokenizationEasy排序去重字元建立 stoi / itos 雙向映射。O(N + Vlog V)O(V)NeetCode ↗
10Tokenization Edge CasesTokenizationMedium保留 special token、未知字元回退到 <unk>、空字串安全處理。O(N)O(V)NeetCode ↗
11GPT Data LoaderDataEasy隨機起點切出 (B, T) 的 x,y 是 x 右移一格。O(B · T)O(B · T)NeetCode ↗
12GPT DatasetDataEasy滑動視窗,每個 index 回傳長度 block_size 的 context 與 target。O(T) / itemO(N)NeetCode ↗
13Code GPTTransformerHardtoken+position embedding → 多層 causal self-attention block → LayerNorm → lm_head。O(T² · C) / layerO(T² + params)NeetCode ↗
14Train Your GPTTrainingMediumforward 算 cross-entropy loss,backward,AdamW step,週期性 eval。O(iters · fwd)O(params)NeetCode ↗
15Make GPT Talk BackInferenceMedium自回歸:取最後位置 logits,softmax 後 multinomial 抽樣,接回序列。O(K · T² · C)O(T + params)NeetCode ↗
16KV-CacheInferenceHard快取過去的 K、V,新 token 只算自己的 Q/K/V 再 append。O(K · T · C)O(T · C)NeetCode ↗
17Grouped Query AttentionAttentionHardKV head 數少於 Q head,數個 query 共用一組 KV,用 repeat_kv 展開。O(T² · C)O(T · C / g)NeetCode ↗

1. Intro to Natural Language Processing

MediumNLPTokenizationPadding

題目整理

給定長度相同的正向句子 positive 與負向句子 negative,先收集所有句子的 unique words,按照字典序排列,並從 1 開始分配 token ID。接著把每句話轉成 ID sequence,再以 0 補齊到最長句子的 word count T。輸出 tensor shape 必須是 (2N, T),而且 positive rows 全部在前、negative rows 全部在後,兩組內部皆維持原順序。

Prerequisite · Word Embeddings

Embedding table 需要用整數 ID 查表;這題就是先定義 word token、建立 word → ID 映射,並把 variable-length sentences 整理成可進入 batch 的矩形 tensor。建議先完成 Word Embeddings ↗。

解法說明

把兩組輸入串成 sentences = positive + negative,這一步同時保證輸出 row order。用 set comprehension 收集所有 sentence.split() 的 word,再 sorted 建立 deterministic vocabulary;enumerate(..., start=1) 特別把 0 保留給 padding。每句轉成一維 float tensor 後,交給 pad_sequence(..., batch_first=True, padding_value=0) 一次補齊。

import torch
import torch.nn as nn
from torchtyping import TensorType
from typing import List


class Solution:
    def get_dataset(
        self,
        positive: List[str],
        negative: List[str],
    ) -> TensorType[float]:
        sentences = positive + negative       # Positive rows must come first.

        if not sentences:
            return torch.empty((0, 0), dtype=torch.float32)

        vocabulary = sorted({
            word
            for sentence in sentences
            for word in sentence.split()
        })
        word_to_id = {
            word: token_id
            for token_id, word in enumerate(vocabulary, start=1)
        }

        encoded = [
            torch.tensor(
                [word_to_id[word] for word in sentence.split()],
                dtype=torch.float32,
            )
            for sentence in sentences
        ]

        return nn.utils.rnn.pad_sequence(
            encoded,
            batch_first=True,
            padding_value=0.0,
        )
Time: O(W + V log V + 2N·T)。W 是輸入 word occurrences,V 是 unique words;最後一項是建立 padded output。
Space: O(V + 2N·T),包含 vocabulary mapping 與輸出 tensor。

Interview Explanation Flow

Step 1: Define the representation

"I will build one shared word-level vocabulary across both classes. Sorting the unique words makes token IDs deterministic, and I start IDs at 1 because 0 is reserved for padding."

Step 2: Preserve the required row order

"I concatenate positive + negative before encoding. That naturally places every positive sample first while preserving the original order within each list."

Step 3: Walk through the example

positive = ["Dogecoin to the moon"]
negative = ["I will short Tesla today"]

sorted vocabulary:
Dogecoin=1, I=2, Tesla=3, moon=4, short=5,
the=6, to=7, today=8, will=9

encoded positive -> [1, 7, 6, 4]
encoded negative -> [2, 9, 5, 3, 8]

pad_sequence(..., batch_first=True, padding_value=0)
-> [[1, 7, 6, 4, 0],
    [2, 9, 5, 3, 8]]

Step 4: Explain padding

"The sentences have different lengths, so pad_sequence writes a rectangular (2N, T) tensor. batch_first=True puts samples on axis 0, and padding ID 0 cannot collide with a real word because real IDs start at 1."

Step 5: Complexity

"Collecting and encoding words is linear in the input size, sorting costs O(V log V), and materializing the padded tensor costs O(2N·T)."

Possible follow-ups

  • Case sensitivity? "I follow the prompt literally, so sorting is case-sensitive; if normalization were required, I would apply it before both vocab construction and encoding."
  • Why return float? "The provided signature expects TensorType[float], so I explicitly create float32 tensors even though the values represent token IDs."

中文:重點是共用 vocab、字典序從 1 編號、positive rows 在前,以及用 0 padding 成 (2N, T)。

額外題目:Bag of Words

完成官方 tokenization 題後,再實作 Bag of Words:把原始文字正規化、斷詞、建立 vocabulary,並把句子轉成 word-count vector。與官方題輸出的 token sequence 不同,BoW 的每個維度代表某個 word 的出現次數,因此會丟失詞序。

解法說明

preprocess 用 lower() 統一大小寫,再以正規表示式 [a-z0-9']+ 抓出 word。BagOfWords 從 corpus 收集排序後的 vocab 建 stoi;vectorize 掃過 token 並累加對應維度,沒看過的 word 直接忽略。

import re
from typing import Dict, List


def preprocess(text: str) -> List[str]:
    text = text.lower()
    return re.findall(r"[a-z0-9']+", text)   # Words = maximal alnum runs.


class BagOfWords:
    def __init__(self, corpus: List[str]):
        vocab = sorted({tok for doc in corpus for tok in preprocess(doc)})
        self.stoi: Dict[str, int] = {word: i for i, word in enumerate(vocab)}

    def vectorize(self, text: str) -> List[int]:
        vector = [0] * len(self.stoi)
        for token in preprocess(text):
            if token in self.stoi:            # Ignore out-of-vocabulary words.
                vector[self.stoi[token]] += 1
        return vector
Time: 建 vocab O(W + V log V);每次 vectorize O(L),L 是句子的 token 數。
Space: O(V),保存 word mapping 與長度 V 的 count vector。

Extra Interview Flow · Bag of Words

Step 1: Contrast it with the official task

"The official tokenizer preserves word order as a sequence of token IDs. Bag of Words instead returns one fixed-size count vector whose dimensions correspond to vocabulary words."

corpus = ["The cat sat.", "The dog ran!"]
vocab = ["cat", "dog", "ran", "sat", "the"]

vectorize("the cat the")
-> [1, 0, 0, 0, 2]

Step 2: State the limitation

"Because only counts remain, 'dog bites man' and 'man bites dog' produce the same vector. The representation is also sparse and grows with vocabulary size."

中文:官方題保留 token 順序;Bag of Words 只保留每個字的次數,因此更簡單但失去詞序。

額外延伸:Unicode-aware preprocessing

上面的 preprocess 只吃 ASCII:[a-z0-9'] 遇到 café、Zürich、北京、Москва 會把重音字、非拉丁字母切爛或丟掉。以下是不改動原題解、額外提供的 Unicode 版本;Python 3 的 \w 對 str pattern 預設就是 Unicode。

import re
import unicodedata


def preprocess_unicode(text: str) -> list:
    text = unicodedata.normalize("NFC", text)   # 1. Compose accents to canonical form.
    text = text.casefold()                       # 2. Unicode-aware lowercasing.
    # 3. Word = run of Unicode letters/digits (not underscore), keeping contractions.
    return re.findall(r"[^\W_]+(?:['’][^\W_]+)*", text)

三個步驟各修一個 Unicode 陷阱:

  • normalize("NFC"):é 可能是單一 codepoint U+00E9,也可能是 e + 結合重音 U+0301;兩者外觀相同但是不同字串,會讓 vocab 對不上。NFC 先合成成同一種標準形式。
  • casefold() 取代 lower():casefold 是給「無視大小寫比對」用的正確版本。例如德文 "Straße".casefold() → 'strasse'、希臘文字尾 sigma 也處理正確,lower() 都會漏。
  • [^\W_]+(?:['’][^\W_]+)*:[^\W_] 是「Unicode 詞字元但排除底線」(純 \w 會把 _ 也算進去);後面允許詞內單引號,且同時吃 ASCII ' 與真實文本常見的 Unicode 右單引號 ’(U+2019),讓 it's / it’s 都保持完整。

行為對比:

s = "Café Zürich — it's naïve. 北京 Москва don’t"

# 原題 ASCII 版
re.findall(r"[a-z0-9']+", s.lower())
-> ['caf', 'z', 'rich', "it's", 'na', 've', 'don']              # 重音與非拉丁字母被切爛

preprocess_unicode(s)
-> ['café', 'zürich', "it's", 'naïve', '北京', 'москва', 'don’t']  # 保持完整
注意:CJK 沒有空白分詞

\w+ 會貪婪抓一整串詞字元,但中文/日文/泰文不用空白斷詞,所以 北京大学 會變成一個 token而不是 北京 + 大学。若真的需要 CJK 詞邊界,regex 不夠,得用斷詞器(中文 jieba、日文 fugashi / MeCab);bag-of-words baseline 常見的便宜替代是對 CJK 改用字元級切分。

2. Word Embeddings

EasyNLPEmbeddingsCosine Similarity
概念延伸(非平台原題)

以下原有內容保留為 embedding、cosine similarity 與 nearest-neighbor search 的背景教學;本節後半另外收錄兩道實際的 embedding lookup 題目與解法。

題目整理

把 bag-of-words 那種稀疏、彼此正交的表示,換成稠密向量:每個 token id 對應一列可學習的 embedding。實作 embedding 查表,並用 cosine similarity 找出與某個字最相近的 k 個字(word2vec / GloVe 的核心操作)。

解法說明

nn.Embedding 本質是一張 (vocab_size, dim) 的查表:給 id 就回傳對應的列向量。語意相近的字在訓練後會落在方向相近的位置,所以用 cosine similarity(只看方向、不看長度)衡量相似度。nearest 把 query 向量對整張表算相似度,排除自己後取 top-k。

import torch
import torch.nn as nn
from typing import List


class WordEmbeddings(nn.Module):
    def __init__(self, vocab_size: int, dim: int):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, dim)   # Lookup table.

    def forward(self, ids: torch.Tensor) -> torch.Tensor:
        return self.embedding(ids)                        # (..., dim)


def cosine_similarity(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
    a = a / a.norm(dim=-1, keepdim=True)                  # Direction only.
    b = b / b.norm(dim=-1, keepdim=True)
    return a @ b.transpose(-2, -1)


def nearest(query_id: int, weight: torch.Tensor, k: int = 5) -> List[int]:
    q = weight[query_id].unsqueeze(0)                     # (1, dim)
    sims = cosine_similarity(q, weight).squeeze(0)        # (vocab,)
    sims[query_id] = float("-inf")                        # Exclude the word itself.
    return torch.topk(sims, k).indices.tolist()
Time: 查表 O(1) per id;nearest O(V · d) 對整張表算相似度再 O(V log k) 取 top-k。
Space: O(V · d) 的 embedding 矩陣。
為什麼用 cosine 而不是 Euclidean?

Embedding 的「語意」主要編碼在方向而非長度上(高頻字常有較大 norm)。cosine similarity 先做 L2 normalize 再點積,等於只比方向,因此 king - man + woman ≈ queen 這類類比才成立。範圍固定在 [-1, 1] 也讓不同字之間好比較。

Interview Explanation Flow

Step 1: Motivate dense over sparse

"One-hot / bag-of-words vectors are huge and orthogonal — every pair of words is equally dissimilar. Embeddings map each token to a low-dim dense vector where distance encodes meaning."

Step 2: Explain the lookup

"nn.Embedding is literally a (vocab, dim) matrix; indexing with a token id returns its row. During training those rows move so that words used in similar contexts end up nearby."

Step 3: Similarity search

weight: (vocab=10000, dim=64)
q = weight[stoi["king"]]              # (64,)
sims = cosine(q, weight)             # (10000,)
sims[stoi["king"]] = -inf           # don't return itself
topk(sims, 5) -> ["queen","prince","monarch","throne","royal"]

Step 4: Complexity

"A brute-force nearest search is O(V·d). For millions of vectors you'd switch to an approximate index like FAISS/HNSW."

Possible follow-ups

  • How are these trained? "word2vec skip-gram predicts context words; GloVe factorizes a co-occurrence matrix. In a transformer the embedding is learned end-to-end with the task."
  • Why normalize before dot product? "So the score reflects direction (semantics), not vector magnitude."

中文:重點:embedding 是查表矩陣,語意編碼在方向上,所以用 cosine similarity 找近義詞。

原題:NumPy Embedding Lookup

題目整理

給定二維 embedding matrix embeddings,shape 為 (vocab_size, embed_dim),以及一維整數陣列 token_ids,回傳每個 token ID 對應的 embedding row。輸出依照 token_ids 的順序排列,並四捨五入至小數點後五位。

解法說明

NumPy advanced indexing 可以直接使用一個整數陣列當作 row indices。若 token_ids.shape == (N,),則 embeddings[token_ids] 會逐一取出 N 個 row,輸出 shape 為 (N, embed_dim);不需要手寫迴圈。

import numpy as np
from numpy.typing import NDArray


class Solution:
    def lookup(
        self,
        embeddings: NDArray[np.float64],
        token_ids: NDArray[np.int64],
    ) -> NDArray[np.float64]:
        # Advanced indexing selects one embedding row per token ID.
        return np.round(embeddings[token_ids], 5)
embeddings = [[0.0, 0.0],
              [1.0, 1.5],
              [2.0, 2.5]]
token_ids  = [2, 0]

embeddings[token_ids]
→ [[2.0, 2.5],
   [0.0, 0.0]]
Time: O(N · d),其中 N 是 token 數量、d 是 embedding dimension。
Space: O(N · d) 為回傳結果;演算法額外空間 O(1)。

Interview Explanation Flow

"The embedding matrix stores one vector per vocabulary item. NumPy advanced indexing accepts the entire token-ID array as row indices, preserves the order of those IDs, and appends the embedding dimension to the output."

"Therefore an input of shape (N,) produces an output of shape (N, d). I round only once at the end, as required by the interface."

中文:用整數陣列直接索引 embedding matrix,輸出順序與 token_ids 完全一致。

PyTorch Batched Embedding Lookup

題目整理

給定 embedding table (vocab_size, embedding_dim) 與一批 token IDs (batch_size, seq_len),替 batch 中每個位置查出對應向量,回傳 (batch_size, seq_len, embedding_dim)。

解法說明

embedding_table[token_ids] 是 PyTorch advanced indexing:它直接取出 batch 中每個 token ID 的 embedding vector,並保留 token_ids 原本的 batch/time shape,最後附加 embedding_dim。因此不需要巢狀迴圈,也不需要手動 reshape。

import torch


def batched_embedding_lookup(
    embedding_table: torch.Tensor,
    token_ids: torch.Tensor,
) -> torch.Tensor:
    """Look up an embedding vector for every token ID in a batch."""
    return embedding_table[token_ids]
embedding_table.shape = (3, 2)
token_ids.shape       = (2, 2)

token_ids = [[1, 2],
             [0, 1]]

embedding_table[token_ids]
→ [[[1.0, 1.5], [2.0, 2.5]],
   [[0.0, 0.0], [1.0, 1.5]]]

output.shape = (2, 2, 2)
Time: O(B · T · d),每個 batch/time 位置複製一個 d 維向量。
Space: O(B · T · d) 為輸出 tensor;演算法額外空間 O(1)。
Shape 規則

索引 tensor 的 shape 會被保留,embedding row 的尾端維度再接上去:(B, T) + (d,) → (B, T, d)。此外,token_ids 必須使用整數 dtype(通常是 torch.long),且每個 ID 必須落在 [0, vocab_size)。

Interview Explanation Flow

"Each value in token_ids is a row index into the embedding table. PyTorch advanced indexing performs all lookups at once and preserves the index tensor's batch and sequence dimensions."

"For example, indexing a (3, 2) table with IDs of shape (2, 2) gives (2, 2, 2): the first two dimensions come from the IDs, and the last dimension is the embedding width."

中文:embedding_table[token_ids] 直接批次查表,保留 ID tensor 的 shape,再加上 embedding 維度。

3. Sentiment Analysis

MediumNLPClassificationPyTorch

題目整理

做一個文字情感分類器(正面/負面)。輸入是一批 token id (B, T)(不等長的句子用 <pad> 補齊),模型要輸出每個類別的 logits。做法:查 embedding → 對每句忽略 padding 做 mean-pooling得到句向量 → 線性層分類 → 用 cross-entropy 訓練。

解法說明

關鍵是 masked mean-pooling:直接對整個 (B, T, d) 取平均會把 pad token 算進去而稀釋語意。用 ids != pad_id 造 mask,只加總真實 token 再除以真實長度(clamp(min=1) 防止空句除以零)。padding_idx 讓 pad 的 embedding 固定為 0 且不更新。

import torch
import torch.nn as nn
from torch.nn import functional as F


class SentimentClassifier(nn.Module):
    def __init__(self, vocab_size: int, dim: int, num_classes: int = 2, pad_id: int = 0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, dim, padding_idx=pad_id)
        self.fc = nn.Linear(dim, num_classes)
        self.pad_id = pad_id

    def forward(self, ids: torch.Tensor) -> torch.Tensor:
        emb = self.embedding(ids)                    # (B, T, dim)
        mask = (ids != self.pad_id).unsqueeze(-1)    # (B, T, 1)
        summed = (emb * mask).sum(dim=1)             # (B, dim), pads zeroed out
        counts = mask.sum(dim=1).clamp(min=1)        # (B, 1), avoid div-by-zero
        pooled = summed / counts                     # Masked mean over real tokens.
        return self.fc(pooled)                       # (B, num_classes)


def train_step(model, ids, labels, optimizer) -> float:
    logits = model(ids)
    loss = F.cross_entropy(logits, labels)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()
    return loss.item()


@torch.no_grad()
def predict(model, ids) -> torch.Tensor:
    return model(ids).argmax(dim=-1)                 # Class with highest logit.
Time: forward O(B · T · d)(embedding 與 pooling)+ O(B · d · classes)(線性層)。
Space: O(vocab · d) 參數;activations O(B · T · d)。

Interview Explanation Flow

Step 1: Frame as sequence classification

"I need one label per sequence, so I must collapse a variable-length sequence of embeddings into a single vector, then classify it. Mean-pooling is the simplest, strong baseline."

Step 2: Stress the masking

"Batches are padded to equal length. If I average naively, padding tokens drag the sentence vector toward zero. So I mask them out — sum only real tokens and divide by the real count."

ids = [[The, movie, was, great],
       [Bad,  <pad>, <pad>, <pad>]]     # pad_id = 0
mask = [[1,1,1,1],
        [1,0,0,0]]
pooled row 1 = mean of 4 embeddings
pooled row 2 = embedding("Bad") / 1     # not / 4

Step 3: Loss and prediction

"cross_entropy takes raw logits (B, C) and integer labels (B,) — it applies log-softmax internally, so I don't softmax first. Prediction is argmax over the logits."

Step 4: Complexity and scaling up

"This is linear in tokens. To do better I'd replace mean-pooling with an RNN or a transformer encoder that respects word order — mean-pooling can't tell 'not good' from 'good'."

Possible follow-ups

  • Binary — why 2 logits not 1? "Two-class softmax with CE is equivalent to one-logit BCE; I use CE so the head generalizes to multi-class."
  • Class imbalance? "Pass weight= to cross-entropy or resample."

中文:重點是 masked mean-pooling 忽略 padding,再接線性層與 cross-entropy;也要點出 mean-pooling 無法處理否定詞這種詞序訊息。

4. Positional Encoding

MediumNLPTransformer

題目整理

Self-attention 本身對位置無感(打亂 token 順序,輸出只是跟著換位置),所以要額外把「位置」資訊加進去。實作 Attention Is All You Need 的 sinusoidal positional encoding:產生一個 (seq_len, d_model) 矩陣,偶數維用 sin、奇數維用 cos,且不同維度用不同頻率,之後直接加到 token embedding 上。

解法說明

位置 pos 在維度 i 的角度是 pos / 10000^(2i/d)。用 div_term = exp(arange(0,d,2) · (-log(10000)/d)) 一次算好所有頻率(等價但數值更穩)。偶數維 0::2 填 sin、奇數維 1::2 填 cos。這種編碼固定不用學、對任意長度都定義得出來,而且相鄰位置的編碼有平滑關係。

import math
import torch


def sinusoidal_positional_encoding(seq_len: int, d_model: int) -> torch.Tensor:
    pe = torch.zeros(seq_len, d_model)
    position = torch.arange(seq_len).unsqueeze(1).float()          # (T, 1)
    # div_term[i] = 1 / 10000^(2i / d_model), computed in log-space for stability.
    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)                   # Even dims -> sin.
    pe[:, 1::2] = torch.cos(position * div_term)                   # Odd dims  -> cos.
    return pe                                                      # (seq_len, d_model)


# Usage: x = token_embedding(idx) + sinusoidal_positional_encoding(T, d_model)
Time: O(seq_len · d_model),填滿整個編碼矩陣。
Space: O(seq_len · d_model),回傳的位置編碼矩陣。
Sinusoidal vs Learned positional embedding

本題的 sinusoidal 版本不需訓練、能外推到比訓練時更長的序列,是原始 Transformer 的選擇。第 13 題 Code GPT 用的則是 nn.Embedding(block_size, n_embd) 這種可學習的位置向量,簡單但無法超過 block_size。現代模型還常用 RoPE(把位置編到旋轉裡)。三者目的相同:讓 permutation-invariant 的 attention 知道 token 的先後。

Interview Explanation Flow

Step 1: Explain why we need it at all

"Attention computes weighted sums over a set — it's permutation-invariant. Without position info, 'dog bites man' and 'man bites dog' are indistinguishable. So I inject position explicitly and add it to the token embeddings."

Step 2: Explain the sinusoid design

"Each dimension is a sinusoid whose wavelength grows geometrically from 2π up to ~10000·2π. Low dimensions oscillate fast (fine position), high dimensions slowly (coarse position), so the full vector is a unique fingerprint of the position."

d_model = 4, positions 0..3
pos=0: [sin0, cos0, sin0,  cos0 ] = [0, 1, 0, 1]
pos=1: [sin1, cos1, sin(1/100), cos(1/100)]
       fast-freq dims          slow-freq dims
even index -> sin, odd index -> cos

Step 3: Why 10000 and log-space?

"10000 sets the longest wavelength — big enough to give distinct codes across realistic sequence lengths. Computing div_term via exp(... · -log(10000)/d) avoids the numerically nasty direct power."

Step 4: Key property

"Because it's built from sinusoids, the encoding for position pos+k is a linear function of the encoding at pos, which lets the model learn to attend by relative offset, and it extrapolates to unseen lengths."

Possible follow-ups

  • Add or concatenate? "The paper adds it to the embedding; adding keeps dimensionality fixed and works well in practice."
  • Learned vs fixed? "Learned is simpler and often matches quality but can't extrapolate past the trained max length."

中文:重點:attention 對位置無感所以要加位置編碼;even 用 sin、odd 用 cos,不同維度不同頻率,能外推且相對位置關係平滑。

5. Self Attention

MediumAttentionPyTorch

題目整理

實作單頭的 scaled dot-product self-attention。輸入 x(shape (B, T, n_embd)),從 x 投影出 Query、Key、Value,用 softmax(Q Kᵀ / √d) · V 讓每個位置根據相關性聚合其他位置的資訊。支援可選的 mask(例如 causal 或 padding mask)。這是整個 Transformer 的最小積木。

解法說明

三個線性層各自把 x 投影成 Q、K、V。注意力分數 Q Kᵀ 是每個 query 對每個 key 的內積,除以 √head_size 避免維度變大時 softmax 過度飽和。若有 mask,把不該看的位置設 -inf,softmax 後歸零,再對 V 加權求和。

import torch
import torch.nn as nn
from torch.nn import functional as F


class SelfAttention(nn.Module):
    def __init__(self, n_embd: int, head_size: int):
        super().__init__()
        self.key = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)

    def forward(self, x, mask=None):
        k = self.key(x)      # (B, T, head_size)
        q = self.query(x)
        v = self.value(x)
        wei = q @ k.transpose(-2, -1) * k.shape[-1] ** -0.5   # (B, T, T)
        if mask is not None:
            wei = wei.masked_fill(mask == 0, float("-inf"))   # Block disallowed pairs.
        wei = F.softmax(wei, dim=-1)                          # Row-normalized weights.
        return wei @ v                                        # (B, T, head_size)
Time: O(T² · head_size),Q Kᵀ 與加權各是 T×T 對每維運算。
Space: O(T²) 的 attention 權重矩陣。

Interview Explanation Flow

Step 1: The query/key/value metaphor

"Each token asks a question (query), advertises what it offers (key), and carries content (value). A token's output is a weighted blend of everyone's values, where weights come from how well its query matches each key."

Step 2: Walk the shapes

x: (B, T, n_embd) = (1, 3, 8), head_size = 4
q,k,v: (1, 3, 4)
wei = q @ kᵀ / sqrt(4)  -> (1, 3, 3)   # token-to-token scores
softmax rows -> weights sum to 1
out = wei @ v -> (1, 3, 4)

Step 3: Why divide by √d?

"Dot products scale with dimension; large magnitudes push softmax into saturated regions where gradients vanish. Dividing by √head_size keeps the variance ~1 so training stays stable."

Step 4: The mask

"Setting disallowed scores to -inf before softmax makes their weight exactly 0. A lower-triangular mask gives causal attention (GPT); a padding mask blocks attending to pad tokens (encoders)."

Possible follow-ups

  • Self vs cross attention? "Here Q, K, V all come from the same x. In cross-attention, Q comes from the decoder and K, V from the encoder."
  • Why no bias on the projections? "Common simplification; the following LayerNorm/residual makes biases largely redundant."

中文:核心公式 softmax(QKᵀ/√d)V;強調 √d 縮放穩定 softmax,以及 mask 用 -inf 歸零。

6. Multi Headed Self Attention

MediumAttentionPyTorch

題目整理

把單頭 attention 擴成 multi-head:把 n_embd 切成 n_head 份,每個 head 在自己的子空間獨立做 attention,讓模型能同時關注不同面向(語法、指代、距離…),最後把各 head 輸出 concat 再投影回 n_embd。要求用張量 reshape做平行運算,而不是 Python for-loop 逐 head 跑。

解法說明

用單一個 qkv 線性層一次算出 Q、K、V(再 split),比多個 Linear 更省。接著 view 成 (B, T, n_head, head_size) 再 transpose 到 (B, n_head, T, head_size),讓 head 變成 batch 維度一起算 attention。算完 transpose 回來、contiguous().view 把各 head concat 成 (B, T, n_embd),再過輸出投影 proj。

import torch
import torch.nn as nn
from torch.nn import functional as F


class MultiHeadSelfAttention(nn.Module):
    def __init__(self, n_embd: int, n_head: int):
        super().__init__()
        assert n_embd % n_head == 0
        self.n_head = n_head
        self.head_size = n_embd // n_head
        self.qkv = nn.Linear(n_embd, 3 * n_embd, bias=False)   # Fused Q,K,V.
        self.proj = nn.Linear(n_embd, n_embd)                  # Mix heads back.

    def forward(self, x, mask=None):
        B, T, C = x.shape
        q, k, v = self.qkv(x).split(C, dim=2)
        # (B, T, C) -> (B, n_head, T, head_size): heads become a batch dim.
        q = q.view(B, T, self.n_head, self.head_size).transpose(1, 2)
        k = k.view(B, T, self.n_head, self.head_size).transpose(1, 2)
        v = v.view(B, T, self.n_head, self.head_size).transpose(1, 2)

        att = q @ k.transpose(-2, -1) * self.head_size ** -0.5   # (B, nh, T, T)
        if mask is not None:
            att = att.masked_fill(mask == 0, float("-inf"))
        att = F.softmax(att, dim=-1)
        out = att @ v                                            # (B, nh, T, hs)
        out = out.transpose(1, 2).contiguous().view(B, T, C)     # Concat heads.
        return self.proj(out)
Time: O(T² · C),所有 head 合計等同一次全寬 attention。
Space: O(n_head · T²) 的注意力權重。

Interview Explanation Flow

Step 1: Why multiple heads?

"A single attention distribution can only emphasize one kind of relationship at a time. Splitting into heads lets the model attend to several patterns in parallel — one head might track syntax, another long-range coreference — then combine them."

Step 2: The reshape trick

"Instead of looping over heads, I fold the head dimension next to the batch dimension. After view + transpose the tensor is (B, n_head, T, head_size), so one batched matmul does all heads at once on the GPU."

n_embd = 64, n_head = 8 -> head_size = 8
qkv(x): (B, T, 192) -> split -> q,k,v each (B, T, 64)
view:      (B, T, 8, 8)
transpose: (B, 8, T, 8)   # (batch, heads, time, head_size)
att = q @ kᵀ : (B, 8, T, T)   # 8 heads computed together
out -> transpose+view -> (B, T, 64) -> proj

Step 3: Why the final projection?

"Concatenating heads just stacks independent subspaces; the proj linear layer lets them exchange information and maps back to model width."

Step 4: Complexity note

"Total cost equals one full-width attention — splitting into heads doesn't add FLOPs, it just partitions the representation. It's still O(T²) in sequence length."

Possible follow-ups

  • Why contiguous() before view? "transpose returns a non-contiguous view; view needs contiguous memory, so I materialize it first."
  • Fused vs separate QKV? "One 3*n_embd matmul is more hardware-efficient than three separate ones."

中文:重點:head 折進 batch 維用一次 batched matmul 平行算,concat 後靠 proj 混合;總 FLOPs 與單頭全寬相同。

7. Transformer Block

MediumTransformerPyTorch

題目整理

把 multi-head attention 與 feed-forward 組成一個可堆疊的 Transformer block。用 pre-norm 殘差結構:x = x + attn(ln1(x))、x = x + ffn(ln2(x))。attention 負責跨位置「溝通」,feed-forward 負責每個位置獨立「思考」。堆 N 層就是 GPT / BERT 的主體。

解法說明

兩個 sub-layer 各包一層 LayerNorm 與 residual。Pre-norm(先 norm 再進 sub-layer)比原論文的 post-norm 更好訓練、梯度更穩,是現代做法。feed-forward 先擴張到 4 · n_embd、過非線性(GELU)、再投影回來,給模型逐位置的非線性容量。這正是第 13 題 Code GPT 裡 Block 的通用版(多接了一個 mask 參數,可當 encoder 或 decoder 用)。

Pre-Norm Residual 資料流

Pre-norm Transformer block residual flow Input x follows a residual path while a normalized copy passes through multi-head attention and is added back. The result x1 repeats the pattern through layer normalization and feed-forward network to produce x2. SUB-LAYER 1 · COMMUNICATE SUB-LAYER 2 · THINK x identity / residual path LayerNorm 1 ln1(x) Multi-Head Attention + x₁ identity / residual path LayerNorm 2 ln2(x₁) Feed-Forward FFN + x₂ x₁ = x + Attention(LN₁(x)) x₂ = x₁ + FFN(LN₂(x₁)) 跨 token 交換資訊 每個 token 獨立做非線性轉換

黃色是未經修改的 residual highway;藍紫色分支先 LayerNorm,再通過 sub-layer,最後與原輸入相加。

import torch
import torch.nn as nn


class TransformerBlock(nn.Module):
    def __init__(self, n_embd: int, n_head: int, dropout: float = 0.1):
        super().__init__()
        self.attn = MultiHeadSelfAttention(n_embd, n_head)   # From problem 6.
        self.ff = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),   # Expand.
            nn.GELU(),                        # Per-position non-linearity.
            nn.Linear(4 * n_embd, n_embd),   # Project back.
            nn.Dropout(dropout),
        )
        self.ln1 = nn.LayerNorm(n_embd)
        self.ln2 = nn.LayerNorm(n_embd)

    def forward(self, x, mask=None):
        x = x + self.attn(self.ln1(x), mask)   # Communicate across positions.
        x = x + self.ff(self.ln2(x))           # Think at each position.
        return x
Time: O(T² · C)(attention)+ O(T · C²)(feed-forward)每個 block。
Space: O(T² + params),注意力矩陣與兩個 sub-layer 的權重。
Pre-norm 為什麼比 Post-norm 好?

Post-norm(LayerNorm(x + sublayer(x)))在深層堆疊時梯度較不穩,常需要 warmup 才收斂。Pre-norm(x + sublayer(LayerNorm(x)))讓 residual 形成一條乾淨的恆等捷徑,梯度能直接流過,深層模型更好訓練——這是 GPT-2 之後的標準。

Interview Explanation Flow

Step 1: Two responsibilities

"A block has two sub-layers: self-attention mixes information across tokens, and a position-wise MLP transforms each token independently. Communication then computation."

Step 2: Residual + norm wiring

"I wrap each sub-layer with pre-LayerNorm and a residual add: x = x + sublayer(norm(x)). The residual is a gradient highway; pre-norm keeps activations well-scaled so deep stacks train without fancy warmup."

x --> ln1 --> attn --+--> ln2 --> ff --+--> out
 \___________________/ \_______________/
      residual add         residual add

Step 3: Why the 4× MLP?

"The feed-forward expands to 4× width, applies GELU, and projects back. That expansion gives the model non-linear capacity to process each position's representation; 4× is the conventional ratio from the original Transformer."

Step 4: Relate to the full model

"Stacking N of these, with token + positional embeddings in front and a linear head at the end, is exactly the GPT in problem 13. With a bidirectional (no causal) mask it's a BERT-style encoder instead."

Possible follow-ups

  • GELU vs ReLU? "GELU is smoother and standard in transformers; ReLU also works and is what the minimal nanoGPT uses."
  • Where does dropout go? "After attention output and inside the MLP, to regularize both mixing and per-position transforms."

中文:重點:attention 溝通、FFN 思考,pre-norm 殘差讓深層好訓練;堆 N 層加 embedding 與 head 就是 GPT。

8. Tokenizer (Byte Pair Encoding)

MediumTokenizationBPEBytes

題目整理

實作一個 byte-level 的 BPE tokenizer。給一段訓練文字與目標 vocab_size,先把文字編成 UTF-8 bytes(0–255 共 256 個基礎 token),接著反覆找出出現頻率最高的相鄰 token pair,把它合併成一個新的 token id,直到達到目標詞彙量。之後要能用學到的 merges 對任意文字 encode,並能 decode 回原字串。

解法說明

三個核心函式:get_stats 統計相鄰 pair 次數、merge 把某個 pair 換成新 id、訓練時每輪選 max 頻率的 pair 合併並記錄到 merges。encode 時要依照學習順序套用 merge(每次挑 merge index 最小、也就是最早學到的 pair),這樣才能重現訓練時的合併結果。decode 則把每個 id 對應的 bytes 串起來再 UTF-8 解碼。

為什麼用 byte-level?

直接對 Unicode 字元切,詞彙表會爆炸且遇到沒看過的字就爆掉。改用 UTF-8 bytes,基礎詞彙固定 256 個,任何字串都表示得出來(不會有 out-of-vocabulary),再靠 BPE 把常見 byte 序列合併成子詞。這正是 GPT-2 tokenizer 的做法。

from collections import Counter
from typing import Dict, List, Tuple


def get_stats(ids: List[int]) -> Counter:
    counts = Counter()
    for pair in zip(ids, ids[1:]):   # Every adjacent pair.
        counts[pair] += 1
    return counts


def merge(ids: List[int], pair: Tuple[int, int], new_id: int) -> List[int]:
    result = []
    i = 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            result.append(new_id)
            i += 2
        else:
            result.append(ids[i])
            i += 1
    return result


class BPETokenizer:
    def __init__(self):
        self.merges: Dict[Tuple[int, int], int] = {}
        self.vocab: Dict[int, bytes] = {i: bytes([i]) for i in range(256)}

    def train(self, text: str, vocab_size: int) -> None:
        assert vocab_size >= 256
        num_merges = vocab_size - 256
        ids = list(text.encode("utf-8"))

        for i in range(num_merges):
            stats = get_stats(ids)
            if not stats:
                break
            pair = max(stats, key=stats.get)   # Most frequent adjacent pair.
            new_id = 256 + i
            ids = merge(ids, pair, new_id)
            self.merges[pair] = new_id
            self.vocab[new_id] = self.vocab[pair[0]] + self.vocab[pair[1]]

    def encode(self, text: str) -> List[int]:
        ids = list(text.encode("utf-8"))
        while len(ids) >= 2:
            stats = get_stats(ids)
            # Apply the earliest-learned merge that still appears.
            pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
            if pair not in self.merges:
                break
            ids = merge(ids, pair, self.merges[pair])
        return ids

    def decode(self, ids: List[int]) -> str:
        raw = b"".join(self.vocab[i] for i in ids)
        return raw.decode("utf-8", errors="replace")
Train: O(num_merges · N),每輪掃過整段 ids(長度 N)統計並合併一次。
Encode: O(M · N'),M 是合併次數上界,N' 是輸入長度;Decode O(total bytes)。Space O(N + vocab_size)。

Interview Explanation Flow

Step 1: Frame the problem

"BPE is a compression-style algorithm adapted to tokenization. I start from raw UTF-8 bytes so nothing is ever out-of-vocabulary, then I greedily merge the most frequent adjacent pair, over and over, until I hit the target vocab size."

Step 2: Walk through training

"Each round I count adjacent pairs, pick the max-frequency one, assign it the next free id, and record the merge. Merging is order-dependent, so I store merges as a dict from pair to id."

text = "aaabdaaabac"   # treat as chars for illustration
ids  = [a, a, a, b, d, a, a, a, b, a, c]

round 1: most frequent pair = (a, a) -> Z
ids  = [Z, a, b, d, Z, a, b, a, c]      merges[(a,a)] = Z

round 2: most frequent pair = (Z, a) -> Y
ids  = [Y, b, d, Y, b, a, c]            merges[(Z,a)] = Y

round 3: most frequent pair = (Y, b) -> X
ids  = [X, d, X, a, c]                  merges[(Y,b)] = X

Step 3: Explain encode ordering

"At encode time I must replay merges in the order they were learned. I pick the candidate pair with the smallest merge index, because a later merge (like Y=Za) is only valid after the earlier one (Z=aa) has been applied."

Step 4: Discuss edge cases

"Empty text gives an empty id list. If a round has no pairs left (a single token), I break early. On decode I use errors='replace' because a truncated token sequence can produce invalid UTF-8."

Step 5: Complexity

"Training is O(num_merges · N) with the naive re-scan; production tokenizers optimize this with incremental pair counts. Encode is roughly O(M · N') for input length N'."

Possible follow-ups

  • Why bytes instead of characters? "256 base tokens cover every possible input, so there is no UNK problem."
  • How would GPT-2 split first? "It applies a regex to pre-split on word/whitespace boundaries so merges never cross e.g. a space into a letter, which stabilizes the vocabulary."

中文:面試時強調「byte-level 避免 OOV」與「encode 要照 merge 學習順序重放」這兩個關鍵。

9. Build Vocabulary

EasyTokenizationMapping

題目整理

建立字元級(character-level)詞彙表。給一段文字,取出所有不重複字元並排序,建立 stoi(字元 → 整數 id)與 itos(id → 字元)兩個對照表,並提供 encode(str) -> List[int] 與 decode(List[int]) -> str。這是最簡單的 tokenizer,也是 Karpathy「makemore / mini-GPT」教學的起點。

解法說明

sorted(set(text)) 一行取得穩定、可重現的字元順序(排序很重要,否則每次跑出來的 id 都不同,模型無法載入舊 checkpoint)。stoi 與 itos 用 enumerate 一次建好;encode/decode 就是 list comprehension 查表。

from typing import List


class CharVocab:
    def __init__(self, text: str):
        chars = sorted(set(text))   # Deterministic order for reproducibility.
        self.stoi = {ch: i for i, ch in enumerate(chars)}
        self.itos = {i: ch for i, ch in enumerate(chars)}

    @property
    def size(self) -> int:
        return len(self.stoi)

    def encode(self, s: str) -> List[int]:
        return [self.stoi[ch] for ch in s]

    def decode(self, ids: List[int]) -> str:
        return "".join(self.itos[i] for i in ids)
Time: 建表 O(N + V log V),N 是文字長度、V 是不重複字元數;encode/decode 各 O(len)。
Space: O(V),兩張大小為 V 的映射表。

Interview Explanation Flow

Step 1: Frame the problem

"A vocabulary is just a bijection between symbols and integer ids. For a character-level model, the symbols are the unique characters in the corpus."

Step 2: Explain the key decision

"I sort the unique characters before assigning ids. Sorting makes the mapping deterministic, so a checkpoint trained today still loads tomorrow — without it, set iteration order could reshuffle every id."

text = "hello"
set(text)          -> {'h','e','l','o'}   (unordered)
sorted(set(text))  -> ['e','h','l','o']

stoi = {'e':0, 'h':1, 'l':2, 'o':3}
encode("hell") -> [1, 0, 2, 2]
decode([1,0,2,2]) -> "hell"

Step 3: Complexity

"Building is dominated by the sort, O(V log V). Both encode and decode are linear table lookups."

Possible follow-ups

  • Char vs subword? "Character vocab is tiny and never OOV, but sequences get long. Subword (BPE) trades a bigger vocab for shorter sequences."
  • Unknown characters at inference? "A pure char vocab throws a KeyError; the next problem handles that with an UNK fallback."

中文:重點是「先排序再編號」以確保 id 可重現,這樣 checkpoint 才對得上。

10. Tokenization Edge Cases

MediumTokenizationSpecial Tokens

題目整理

把基礎 vocab 強化成能處理真實輸入的邊界情況:(1) special tokens 如 <pad>、<unk>、<|endoftext|> 要保留固定 id;(2) 遇到訓練時沒看過的未知字元要回退到 <unk> 而不是 crash;(3) 空字串要回傳空 list;(4) 文字中若出現 special token 字面字串,要當成單一 token 切出來,而不是逐字元拆開。

解法說明

把 special tokens 放在 id 空間最前面(固定、可預期),一般字元接在後面。encode 先用正規表示式把 special token 從文字切出來,落在中間的普通片段再逐字元查表,查不到就用 stoi.get(ch, unk_id) 回退。decode 對稱處理,未知 id 也回退到 <unk> 字面。

import re
from typing import List, Tuple


class RobustVocab:
    def __init__(self, text: str, specials: Tuple[str, ...] = ("<pad>", "<unk>", "<|endoftext|>")):
        self.unk = "<unk>"
        chars = sorted(set(text))
        # Special tokens occupy the lowest, fixed ids.
        tokens = list(specials) + [c for c in chars if c not in specials]
        self.stoi = {tok: i for i, tok in enumerate(tokens)}
        self.itos = {i: tok for tok, i in self.stoi.items()}
        # Match any special token, longest first, so "<|endoftext|>" wins.
        escaped = sorted(specials, key=len, reverse=True)
        self.pattern = re.compile("(" + "|".join(re.escape(s) for s in escaped) + ")")

    def encode(self, s: str) -> List[int]:
        if not s:                        # Empty input is valid -> empty output.
            return []
        unk_id = self.stoi[self.unk]
        ids = []
        for chunk in self.pattern.split(s):
            if chunk == "":
                continue
            if chunk in self.stoi and chunk in ("<pad>", "<unk>", "<|endoftext|>"):
                ids.append(self.stoi[chunk])     # Whole special token.
            else:
                ids.extend(self.stoi.get(ch, unk_id) for ch in chunk)
        return ids

    def decode(self, ids: List[int]) -> str:
        return "".join(self.itos.get(i, self.unk) for i in ids)
Time: encode/decode O(N),regex split 與逐字元查表都是線性。
Space: O(V),映射表與編譯後的 pattern。

Interview Explanation Flow

Step 1: Enumerate the edge cases up front

"I'd list them explicitly: empty string, unseen characters, special/control tokens, and special tokens that literally appear inside the text. Naming them shows the interviewer I'm thinking about robustness, not just the happy path."

Step 2: Reserve special ids first

"Special tokens go at ids 0..k-1 so they're stable and predictable — the model's <pad> id never shifts when the corpus changes."

Step 3: Walk through a mixed input

specials = <pad>=0, <unk>=1, <|endoftext|>=2
chars:  a=3, b=4, i=5, h=6

encode("hi<|endoftext|>")
split -> ["hi", "<|endoftext|>", ""]
"hi"  -> [6, 5]
token -> [2]
result -> [6, 5, 2]

encode("bzi")   # 'z' never seen
-> [4, 1, 5]    # z falls back to <unk>=1

encode("")  -> []

Step 4: Why split on the pattern first?

"If I tokenized character by character, <|endoftext|> would explode into a dozen tokens. Splitting on the special-token regex first keeps it atomic; I sort patterns longest-first so a longer marker isn't shadowed by a shorter one."

Complexity

"Everything is a single linear pass, O(N)."

中文:面試時把邊界情況先講出來,重點是 special token 要原子化切出,未知字元 .get(ch, unk_id) 安全回退。

11. GPT Data Loader

EasyDataPyTorch

題目整理

給一整串已編碼的 token(一維 tensor data)、batch_size 與 block_size(context 長度),寫一個 get_batch 隨機抽出訓練 batch。回傳 x(shape (B, T))與 y;y 是 x 整體右移一格的下一個 token,也就是每個位置的預測目標。

解法說明

用 torch.randint 抽 B 個起點 ix,範圍是 len(data) - block_size(保證 i + block_size 不越界,因為 y 還要多取一格)。每個起點切出長度 block_size 的片段當 x,起點 +1 的片段當 y,最後 torch.stack 疊成 batch。

import torch
from typing import Tuple


def get_batch(data: torch.Tensor, batch_size: int, block_size: int) -> Tuple[torch.Tensor, torch.Tensor]:
    # Random start offsets; -block_size keeps y's last index in range.
    ix = torch.randint(len(data) - block_size, (batch_size,))
    x = torch.stack([data[i:i + block_size] for i in ix])
    y = torch.stack([data[i + 1:i + 1 + block_size] for i in ix])
    return x, y
Time: O(B · T),抽樣與複製各 batch 的 tokens。
Space: O(B · T),輸出兩個 (B, T) tensor。

Interview Explanation Flow

Step 1: Explain the target shift

"GPT is trained on next-token prediction, so the label for position t is the token at t+1. That's why y is x shifted right by one — each of the T positions in a block produces a training signal."

Step 2: Walk through the indexing

data = [10, 11, 12, 13, 14, 15]   block_size = 3
i = 1
x = data[1:4] = [11, 12, 13]
y = data[2:5] = [12, 13, 14]

# position-by-position targets:
# see 11        -> predict 12
# see 11,12     -> predict 13
# see 11,12,13  -> predict 14

Step 3: Why len(data) - block_size?

"The largest valid start i must satisfy i + block_size <= len(data) so that y's slice [i+1 : i+1+block_size] stays in bounds. torch.randint(len(data) - block_size, ...) gives exactly that range."

Possible follow-ups

  • Train/val split? "Pass in the split tensor, or key on a 'train'/'val' argument selecting different data."
  • Device? "In practice I'd move x, y to GPU with .to(device), ideally with pin_memory + non-blocking transfer."

中文:核心一句話:y 是 x 右移一格,一個 block 內 T 個位置同時提供 next-token 監督訊號。

12. GPT Dataset

EasyDataPyTorch

題目整理

把上一題的隨機抽樣,改寫成標準的 torch.utils.data.Dataset,好讓官方 DataLoader 負責 shuffle、batching、多進程載入。需要實作 __len__ 與 __getitem__,每個 index 用滑動視窗回傳一組 (x, y),兩者都長 block_size 且 y 相對 x 右移一格。

解法說明

__getitem__ 一次切出長度 block_size + 1 的 chunk,前 T 個當 x、後 T 個當 y,這樣只需一次 slice。__len__ 是 len(data) - block_size,確保最後一個 index 也切得出完整的 block_size + 1。

import torch
from torch.utils.data import Dataset
from typing import Tuple


class GPTDataset(Dataset):
    def __init__(self, data: torch.Tensor, block_size: int):
        self.data = data
        self.block_size = block_size

    def __len__(self) -> int:
        # Last valid start needs block_size + 1 tokens available.
        return len(self.data) - self.block_size

    def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
        chunk = self.data[idx:idx + self.block_size + 1]
        x = chunk[:-1]   # tokens 0 .. T-1
        y = chunk[1:]    # tokens 1 .. T   (shifted by one)
        return x, y


# Usage:
# loader = DataLoader(GPTDataset(data, block_size=8), batch_size=32, shuffle=True)
Time: __getitem__ O(T) 切片;__len__ O(1)。
Space: O(N) 保存底層 data;每次取出額外 O(T)。

Interview Explanation Flow

Step 1: Contrast with get_batch

"The Dataset protocol separates 'how to fetch one example' from 'how to batch/shuffle'. I only define __len__ and __getitem__, and DataLoader handles the rest, including multi-worker prefetching."

Step 2: The single-slice trick

block_size = 4, idx = 0
chunk = data[0:5] = [t0, t1, t2, t3, t4]
x = chunk[:-1] = [t0, t1, t2, t3]
y = chunk[1:]  = [t1, t2, t3, t4]

"Grabbing block_size + 1 tokens once and splitting is cleaner than two separate slices."

Step 3: Why that length?

"__len__ must exclude the tail where a full block_size + 1 window no longer fits, so I return len(data) - block_size. Off-by-one here is the classic bug."

Possible follow-ups

  • Overlapping vs non-overlapping windows? "This yields overlapping windows (stride 1). For less correlated samples, stride by block_size and divide the length accordingly."
  • Huge corpora? "Memory-map the token file so data isn't fully resident in RAM."

中文:重點是 len(data) - block_size 的 off-by-one,以及一次切 block_size + 1 再拆成 x/y。

13. Code GPT

HardTransformerSelf-AttentionPyTorch

題目整理

實作一個 decoder-only GPT。輸入 token id idx(shape (B, T)),輸出每個位置對整個 vocab 的 logits (B, T, vocab_size);若提供 targets 也回傳 cross-entropy loss。要點:token embedding + position embedding、堆疊多個 transformer block(每個 block 含 causal multi-head self-attention 與 feed-forward,皆用 pre-LayerNorm + residual),最後接 LayerNorm 與線性 lm_head。

解法說明

單個 attention head 算 Q, K, V,注意力分數 Q Kᵀ / √d,再用下三角 tril mask 把「看未來」的位置設成 -inf(causal),softmax 後對 V 加權。多頭就是把數個 head 的輸出 concat 再投影。Block 用 pre-norm 殘差:x = x + attn(ln1(x))、x = x + ffn(ln2(x))。GPT 把 token 與 position embedding 相加後過所有 block,最後 lm_head 投影到 vocab。

import torch
import torch.nn as nn
from torch.nn import functional as F


class Head(nn.Module):
    """One head of causal self-attention."""

    def __init__(self, n_embd: int, head_size: int, block_size: int, dropout: float):
        super().__init__()
        self.key = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        # Lower-triangular mask, registered as a buffer (not a parameter).
        self.register_buffer("tril", torch.tril(torch.ones(block_size, block_size)))
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B, T, C = x.shape
        k = self.key(x)      # (B, T, head_size)
        q = self.query(x)
        wei = q @ k.transpose(-2, -1) * k.shape[-1] ** -0.5   # (B, T, T)
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float("-inf"))   # Causal.
        wei = F.softmax(wei, dim=-1)
        wei = self.dropout(wei)
        v = self.value(x)
        return wei @ v       # (B, T, head_size)


class MultiHeadAttention(nn.Module):
    def __init__(self, n_embd, n_head, block_size, dropout):
        super().__init__()
        head_size = n_embd // n_head
        self.heads = nn.ModuleList(
            [Head(n_embd, head_size, block_size, dropout) for _ in range(n_head)]
        )
        self.proj = nn.Linear(n_embd, n_embd)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)   # (B, T, n_embd)
        return self.dropout(self.proj(out))


class FeedForward(nn.Module):
    def __init__(self, n_embd, dropout):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),   # Expand.
            nn.ReLU(),
            nn.Linear(4 * n_embd, n_embd),   # Project back.
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)


class Block(nn.Module):
    def __init__(self, n_embd, n_head, block_size, dropout):
        super().__init__()
        self.sa = MultiHeadAttention(n_embd, n_head, block_size, dropout)
        self.ff = FeedForward(n_embd, dropout)
        self.ln1 = nn.LayerNorm(n_embd)
        self.ln2 = nn.LayerNorm(n_embd)

    def forward(self, x):
        x = x + self.sa(self.ln1(x))   # Pre-norm + residual.
        x = x + self.ff(self.ln2(x))
        return x


class GPT(nn.Module):
    def __init__(self, vocab_size, n_embd, n_head, n_layer, block_size, dropout=0.1):
        super().__init__()
        self.block_size = block_size
        self.token_embedding = nn.Embedding(vocab_size, n_embd)
        self.position_embedding = nn.Embedding(block_size, n_embd)
        self.blocks = nn.Sequential(
            *[Block(n_embd, n_head, block_size, dropout) for _ in range(n_layer)]
        )
        self.ln_f = nn.LayerNorm(n_embd)
        self.lm_head = nn.Linear(n_embd, vocab_size)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        tok = self.token_embedding(idx)                                   # (B, T, C)
        pos = self.position_embedding(torch.arange(T, device=idx.device)) # (T, C)
        x = tok + pos                                                     # Broadcast add.
        x = self.blocks(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)                                          # (B, T, vocab)

        loss = None
        if targets is not None:
            B, T, V = logits.shape
            loss = F.cross_entropy(logits.view(B * T, V), targets.view(B * T))
        return logits, loss
Time: 每層 self-attention O(T² · C)(Q Kᵀ 與加權),feed-forward O(T · C²);整體 × n_layer。
Space: O(T²) 的 attention 矩陣(每 head)+ O(params) 權重。
為什麼要 causal mask(tril)?

GPT 是自回歸模型,位置 t 只能看到 ≤ t 的 token。把上三角(未來)位置的 attention 分數設成 -inf,softmax 後就變成 0 權重,等於「看不到未來」。少了這個 mask,模型訓練時會偷看答案,推論時卻沒有,導致嚴重不一致。

Interview Explanation Flow

Step 1: Frame the architecture top-down

"A GPT is: embed tokens and positions, run N identical transformer blocks, layer-norm, then a linear head to vocab logits. Each block mixes information across time via self-attention, then transforms each position independently via an MLP."

Step 2: Explain one attention head

"Each position emits a query, key, and value. The score q·k measures relevance; I scale by 1/√d to keep softmax gradients healthy, mask the future, softmax to weights, and take a weighted sum of values."

x: (B, T, C) = (32, 8, 64),  n_head = 4 -> head_size = 16
q,k,v: (B, T, 16)
wei = q @ k.transpose(-2,-1) / sqrt(16)  -> (B, T, T) = (32, 8, 8)
masked_fill upper triangle with -inf, softmax over last dim
out = wei @ v -> (B, T, 16); concat 4 heads -> (B, T, 64)

Step 3: Explain the block wiring

"I use pre-norm residuals: x = x + sa(ln1(x)) then x = x + ff(ln2(x)). The residual gives a gradient highway so deep stacks stay trainable; pre-norm (norm before the sublayer) is more stable than the original post-norm."

Step 4: Explain the loss reshaping

"F.cross_entropy expects (N, vocab) logits and (N,) targets, so I flatten (B, T, V) to (B·T, V) and targets to (B·T,). Every one of the B·T positions contributes a next-token loss."

Step 5: Complexity

"Attention is O(T²·C) per layer — quadratic in sequence length, which is the well-known bottleneck that KV-cache and efficient-attention variants target."

Possible follow-ups

  • Why scale by 1/√d? "Dot products grow with dimension; without scaling, softmax saturates and gradients vanish."
  • Weight tying? "GPT-2 ties token_embedding and lm_head weights to save parameters and often improve quality."
  • Why buffer for tril? "It's fixed state that should move with .to(device) and be saved, but must not receive gradients — that's exactly a buffer."

中文:面試時由上而下講:embedding → N 個 block(causal self-attention + MLP,pre-norm 殘差)→ lm_head,並強調 1/√d 縮放與 causal mask 的必要性。

14. Train Your GPT

MediumTrainingOptimizer

題目整理

寫出 GPT 的訓練迴圈:用 AdamW 當 optimizer,每個 step 抽一個 batch、forward 得到 loss、zero_grad → backward → step,並週期性用不算梯度的方式估計 train/val loss 監控是否過擬合。

解法說明

estimate_loss 加 @torch.no_grad() 與 model.eval()(關掉 dropout),多抽幾個 batch 取平均降低雜訊,結束再切回 model.train()。訓練迴圈標準四步:zero_grad(set_to_none=True) 較省記憶體、loss.backward() 反傳、optimizer.step() 更新。

import torch


@torch.no_grad()
def estimate_loss(model, get_batch, eval_iters: int = 200):
    out = {}
    model.eval()                       # Disable dropout for stable measurement.
    for split in ("train", "val"):
        losses = torch.zeros(eval_iters)
        for k in range(eval_iters):
            x, y = get_batch(split)
            _, loss = model(x, y)
            losses[k] = loss.item()
        out[split] = losses.mean().item()
    model.train()                      # Back to training mode.
    return out


def train(model, get_batch, max_iters=5000, lr=3e-4, eval_interval=500):
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)

    for step in range(max_iters):
        if step % eval_interval == 0:
            losses = estimate_loss(model, get_batch)
            print(f"step {step}: train {losses['train']:.4f}, val {losses['val']:.4f}")

        xb, yb = get_batch("train")
        _, loss = model(xb, yb)
        optimizer.zero_grad(set_to_none=True)   # Clear old gradients.
        loss.backward()                         # Backprop.
        optimizer.step()                        # Update parameters.

    return model
Time: O(max_iters · forward_cost),每步一次 forward+backward。
Space: O(params + activations),AdamW 另需約 2× 參數量的 optimizer state(一階、二階動量)。

Interview Explanation Flow

Step 1: State the training loop skeleton

"The core is four lines per step: forward to a loss, zero_grad, backward, step. Everything else — eval, logging, scheduling — wraps around that."

Step 2: Why the eval helper is decorated

"@torch.no_grad() stops building the autograd graph, saving memory and time, and model.eval() turns off dropout so the measurement isn't noisy. I average over many batches, then restore train() mode — forgetting to switch back is a common bug."

Step 3: Why AdamW, why zero_grad

"AdamW decouples weight decay from the adaptive step, which is the standard for transformers. Gradients accumulate by default in PyTorch, so I must clear them each step; set_to_none=True is a slightly cheaper reset."

step 0:    train 4.20, val 4.21
step 500:  train 2.55, val 2.58
step 1000: train 2.10, val 2.19   # val lagging -> mild overfit starting
...

Possible follow-ups

  • Gradient clipping? "Add clip_grad_norm_(model.parameters(), 1.0) before step to tame exploding gradients."
  • LR schedule? "Warmup then cosine decay is typical for GPT-style training."
  • Mixed precision? "Wrap forward in autocast and use a GradScaler for bf16/fp16 speedups."

中文:核心四步 forward→zero_grad→backward→step;eval 要包 no_grad+eval() 並記得切回 train()。

15. Make GPT Talk Back

MediumInferenceSampling

題目整理

實作自回歸生成 generate。給一段起始 context idx(shape (B, T))與要生成的 max_new_tokens,反覆:把序列餵進模型 → 取最後一個位置的 logits → 用 temperature(與可選 top_k)調整 → softmax 成機率 → multinomial 抽下一個 token → 接回序列。注意每步都要把 context 裁到最多 block_size 個 token。

解法說明

只取 logits[:, -1, :] 是因為生成只關心「下一個」token。temperature < 1 讓分布更尖銳(保守),> 1 更發散(有創意)。top_k 只保留機率最高的 k 個,其餘設 -inf,避免抽到長尾雜訊。multinomial 依機率抽樣(不是永遠取 argmax,才有多樣性)。

import torch
from torch.nn import functional as F


@torch.no_grad()
def generate(model, idx, max_new_tokens, block_size, temperature=1.0, top_k=None):
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -block_size:]          # Never exceed the context window.
        logits, _ = model(idx_cond)
        logits = logits[:, -1, :] / temperature  # Only the last step matters.

        if top_k is not None:
            v, _ = torch.topk(logits, top_k)
            logits[logits < v[:, [-1]]] = float("-inf")   # Keep top-k only.

        probs = F.softmax(logits, dim=-1)
        next_id = torch.multinomial(probs, num_samples=1)  # Sample, not argmax.
        idx = torch.cat((idx, next_id), dim=1)             # Append.
    return idx
Time: O(max_new_tokens · T² · C),每步都對整段 context 重跑一次 attention(沒有快取)。
Space: O(T + params),序列逐步變長至多 block_size。

Interview Explanation Flow

Step 1: Explain autoregression

"Generation is a loop: predict one token, append it, feed the extended sequence back in. The model only ever needs the last position's logits because that's the distribution over the next token."

Step 2: Walk one step

idx = [[The, cat]]           # (1, 2)
logits = model(idx)          # (1, 2, vocab)
last = logits[:, -1, :]      # (1, vocab)  distribution after "cat"
probs = softmax(last / temperature)
next = multinomial(probs)    # e.g. "sat"
idx = [[The, cat, sat]]      # append, repeat

Step 3: Explain the knobs

"Temperature rescales logits before softmax: low temperature sharpens toward the top choice (more deterministic), high temperature flattens it (more diverse). Top-k masks everything outside the k most likely tokens so we never sample implausible tail tokens."

Step 4: The context crop

"I slice idx[:, -block_size:] every step because the position embedding only has block_size entries — feeding a longer sequence would index out of range."

Step 5: Why sample instead of argmax?

"Pure argmax is deterministic and tends to loop or produce dull text. Sampling from the distribution gives varied, natural output; temperature and top-k control how risky that sampling is."

Possible follow-ups

  • Top-p / nucleus? "Instead of a fixed k, keep the smallest set of tokens whose cumulative probability exceeds p."
  • This is O(T²) per step — can we fix it? "Yes — that's exactly what the KV-cache problem solves."

中文:重點是「只取最後位置 logits」、context 要裁到 block_size、以及用 multinomial 抽樣搭配 temperature / top-k 控制多樣性。

16. KV-Cache

HardInferenceOptimization

題目整理

上一題的生成每步都對整段 context 重算 attention,複雜度 O(T²)。KV-Cache 的洞見是:過去 token 的 Key 與 Value 不會改變,所以可以快取起來;每生成一個新 token,只需算它自己的 Q/K/V,把新的 K、V append 到快取,再讓新 Q 對「快取 + 自己」的 K、V 做 attention。實作一個吃 cache、回傳更新後 cache 的 self-attention。

解法說明

一次算好 qkv 再 split,reshape 成 (B, n_head, T, head_size)。若有 cache=(past_k, past_v),就沿時間軸(dim=2)把新的 k、v 接到後面;attention 分數是新 Q 對「全部歷史 K」。在解碼階段每步 T == 1,新 query 天生只看得到過去,所以不需要 causal mask。

import torch
import torch.nn as nn
from torch.nn import functional as F


class CachedSelfAttention(nn.Module):
    def __init__(self, n_embd: int, n_head: int):
        super().__init__()
        assert n_embd % n_head == 0
        self.n_head = n_head
        self.head_size = n_embd // n_head
        self.qkv = nn.Linear(n_embd, 3 * n_embd, bias=False)
        self.proj = nn.Linear(n_embd, n_embd)

    def forward(self, x, cache=None):
        B, T, C = x.shape
        q, k, v = self.qkv(x).split(C, dim=2)
        # (B, T, C) -> (B, n_head, T, head_size)
        q = q.view(B, T, self.n_head, self.head_size).transpose(1, 2)
        k = k.view(B, T, self.n_head, self.head_size).transpose(1, 2)
        v = v.view(B, T, self.n_head, self.head_size).transpose(1, 2)

        if cache is not None:
            past_k, past_v = cache
            k = torch.cat([past_k, k], dim=2)   # Append along the time axis.
            v = torch.cat([past_v, v], dim=2)
        new_cache = (k, v)                      # Hand back for the next step.

        att = q @ k.transpose(-2, -1) * self.head_size ** -0.5
        # In decode mode T == 1: the single new query only sees past keys,
        # so an explicit causal mask is unnecessary.
        att = F.softmax(att, dim=-1)
        out = att @ v
        out = out.transpose(1, 2).contiguous().view(B, T, C)
        return self.proj(out), new_cache
Time: 生成 K 個 token 從 O(K · T² · C) 降到 O(K · T · C):每步新 query 只對 T 個歷史 key 做一次 attention。
Space: O(T · C) 快取所有層、所有 head 的 K、V(記憶體換時間)。
KV-Cache 到底省了什麼?

沒有 cache 時,生成第 t 個 token 要重算前面 t 個 token 的 K、V,總成本 1+2+...+K ≈ O(K²)。有了 cache,每個 token 的 K、V 只算一次並存起來,新 token 直接重用,總成本降到 O(K)。代價是要用 O(T·C) 記憶體存快取 —— 這也是長 context LLM 推論的主要記憶體瓶頸,正是 GQA 想進一步壓縮的東西。

Interview Explanation Flow

Step 1: State the redundancy

"During generation without a cache, every step recomputes keys and values for all previous tokens — but those never change once produced. That's pure redundant work."

Step 2: The fix

"Cache the K and V for every past position. Each new step computes Q, K, V only for the single new token, appends its K/V to the cache, and attends the new Q over the full cached K/V."

Without cache, generating token 4 recomputes K,V for tokens 1..4.
With cache:
  step t: q_t, k_t, v_t = project(x_t)          # 1 token only
  K = cat(cache_K, k_t); V = cat(cache_V, v_t)  # append
  out = softmax(q_t @ Kᵀ / sqrt(d)) @ V         # attend over history
  cache = (K, V)                                # carry forward

Step 3: Shapes and the append axis

"Tensors are (B, n_head, T, head_size). I concatenate along dim=2, the time axis, so the cache grows by one position per step."

Step 4: Why no causal mask in decode?

"When feeding one token at a time, the new query is the most recent position and the cached keys are strictly older, so causality holds automatically. The mask is only needed when processing multiple new positions at once (e.g. the initial prompt)."

Step 5: Complexity and trade-off

"Total generation cost drops from quadratic to linear in sequence length. The price is O(T·C) memory per layer for the cache — which becomes the dominant cost for long contexts and motivates GQA."

Possible follow-ups

  • Prefill vs decode? "The prompt is processed in one masked forward pass (prefill) that populates the cache; then decode appends one token at a time."
  • Memory for long context? "Cache size scales with tokens × layers × heads — that's the exact quantity MQA/GQA shrink by sharing KV heads."

中文:核心:過去 K/V 不變 → 快取重用 → O(T²) 降 O(T),沿時間軸 append,decode 時單 token 不需 mask。

17. Grouped Query Attention (GQA)

HardAttentionEfficiency

題目整理

標準 multi-head attention 每個 query head 都有專屬的 KV head,KV-Cache 因此很大。GQA 讓 n_kv_head < n_head:把 query head 分成 n_kv_head 組,同一組的多個 query head 共用一組 K、V。實作 GQA:Q 投影出 n_head 組、K/V 只投影出 n_kv_head 組,再用 repeat_kv 把 KV 複製到與 Q 對齊後做 attention。(n_kv_head = 1 就退化成 MQA,n_kv_head = n_head 就是原本的 MHA。)

解法說明

關鍵是 n_rep = n_head // n_kv_head:每個 KV head 要被 n_rep 個 query head 共用。repeat_kv 用 expand(不實際複製記憶體)再 reshape 把 KV 從 (B, n_kv_head, T, d) 展開成 (B, n_head, T, d),之後就跟一般 MHA 一樣算。省下的是 K/V 投影參數與 KV-Cache 記憶體(縮小 n_rep 倍)。

import torch
import torch.nn as nn
from torch.nn import functional as F


def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
    # (B, n_kv_head, T, d) -> (B, n_kv_head * n_rep, T, d)
    B, n_kv_head, T, d = x.shape
    if n_rep == 1:
        return x
    x = x[:, :, None, :, :].expand(B, n_kv_head, n_rep, T, d)  # No real copy.
    return x.reshape(B, n_kv_head * n_rep, T, d)


class GroupedQueryAttention(nn.Module):
    def __init__(self, n_embd: int, n_head: int, n_kv_head: int):
        super().__init__()
        assert n_head % n_kv_head == 0
        self.n_head = n_head
        self.n_kv_head = n_kv_head
        self.n_rep = n_head // n_kv_head          # Queries per KV group.
        self.head_size = n_embd // n_head

        self.q_proj = nn.Linear(n_embd, n_head * self.head_size, bias=False)
        self.k_proj = nn.Linear(n_embd, n_kv_head * self.head_size, bias=False)
        self.v_proj = nn.Linear(n_embd, n_kv_head * self.head_size, bias=False)
        self.o_proj = nn.Linear(n_head * self.head_size, n_embd, bias=False)

    def forward(self, x):
        B, T, C = x.shape
        q = self.q_proj(x).view(B, T, self.n_head, self.head_size).transpose(1, 2)
        k = self.k_proj(x).view(B, T, self.n_kv_head, self.head_size).transpose(1, 2)
        v = self.v_proj(x).view(B, T, self.n_kv_head, self.head_size).transpose(1, 2)

        k = repeat_kv(k, self.n_rep)   # Share each KV head across its query group.
        v = repeat_kv(v, self.n_rep)

        att = q @ k.transpose(-2, -1) * self.head_size ** -0.5
        mask = torch.tril(torch.ones(T, T, device=x.device)).bool()
        att = att.masked_fill(~mask, float("-inf"))    # Causal.
        att = F.softmax(att, dim=-1)
        out = att @ v
        out = out.transpose(1, 2).contiguous().view(B, T, self.n_head * self.head_size)
        return self.o_proj(out)
Time: attention 仍是 O(T² · C)(repeat_kv 用 expand 幾乎零成本)。
Space: KV projection 參數與 KV-Cache 記憶體都縮小 n_rep = n_head / n_kv_head 倍。
MHA、GQA、MQA 三者關係

MHA(n_kv_head = n_head):每個 query 有自己的 KV,品質最好但 KV-Cache 最大。MQA(n_kv_head = 1):全部 query 共用一組 KV,cache 最小但品質掉最多。GQA 是中間地帶:用少數幾組 KV(例如 8 個 query head 共用 2 組 KV),在幾乎不掉品質的前提下大幅縮小 KV-Cache——這正是 Llama 2/3、Mistral 等現代模型的選擇。

Interview Explanation Flow

Step 1: Motivate from the KV-cache

"The KV-cache is the memory bottleneck for long-context inference, and its size scales with the number of KV heads. GQA shrinks it by letting several query heads share one key/value head."

Step 2: Define the grouping

"With n_head query heads and n_kv_head KV heads, each KV head serves n_rep = n_head / n_kv_head queries. So Q is projected to full width, but K and V are projected to only n_kv_head heads' worth."

n_head = 8, n_kv_head = 2  ->  n_rep = 4
Q heads:  q0 q1 q2 q3 | q4 q5 q6 q7
KV heads:      kv0     |     kv1
group 0: q0..q3 all attend using kv0
group 1: q4..q7 all attend using kv1

repeat_kv: (B, 2, T, d) -> expand -> (B, 8, T, d)   # aligns with Q

Step 3: Explain repeat_kv

"I insert a repeat axis and expand it — that creates a broadcast view without copying memory — then reshape to interleave the repeats so KV head g lines up with its n_rep query heads. After that, the math is identical to plain multi-head attention."

Step 4: The spectrum

"n_kv_head = n_head recovers MHA; n_kv_head = 1 is MQA. GQA sits in between, capturing most of MHA's quality at a fraction of the KV memory."

Step 5: What actually gets saved

"Fewer K/V projection parameters, and — crucially at inference — a KV-cache that's n_rep× smaller, which directly raises the max batch size and context length you can serve."

Possible follow-ups

  • Does GQA change attention FLOPs? "Barely — after repeat_kv the attention compute matches MHA; the win is memory/bandwidth, not FLOPs."
  • How to pick n_kv_head? "Empirically a small number like 8 (Llama-2 70B) retains quality; too few (MQA) can hurt on harder tasks."

中文:重點:n_rep = n_head / n_kv_head,數個 query 共用一組 KV,用 expand 展開幾乎零成本,主要省的是 KV-Cache 記憶體。