Architecture¶
mvGPT is a decoder-only transformer — the same family as GPT-2, scaled down
to ~14.6M parameters so the whole thing stays legible. Everything in this page
lives in base_model.py,
shared by every training and inference stage.
Watch the architecture walkthrough
Data flow¶
Tokens become embeddings, flow through six identical transformer blocks, and a final projection turns the last position's vector into a probability over the vocabulary. The single most important detail is causal masking: no token may attend to a token that comes after it, which is what makes the model autoregressive — able to generate left to right.
%%{init: {'theme':'dark','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'Helvetica, Arial, sans-serif','lineColor':'#8b949e','clusterBkg':'#161b22','clusterBorder':'#30363d','edgeLabelBackground':'#161b22'}}}%%
flowchart TD
P["PROMPT<br/>“Once upon a time”"]
TOK["TOKENIZE: string → ids<br/>[60, 61, 7, 47]<br/>out: T tokens (T ≤ 256)"]
WTE["EMBED_TOKENS<br/>lookup table 10,000 × 384<br/>in: T → out: T × 384"]
WPE["EMBED_POSITIONS<br/>lookup table 256 × 384<br/>in: T → out: T × 384"]
ADD0{{"add token + position<br/>= T × 384"}}
P --> TOK
TOK -->|T| WTE
TOK -->|T| WPE
WTE -->|+| ADD0
WPE -->|+| ADD0
subgraph BLOCK["TRANSFORMER BLOCK ( ×6 )"]
direction TB
BIN["block input: T × 384"]
AN["ATTN_NORM (LayerNorm)<br/>T × 384 → T × 384"]
Q["Q_PROJ<br/>T × 384 → T × 384<br/>(6 heads × T × 64)"]
K["K_PROJ<br/>T × 384 → T × 384<br/>(6 heads × T × 64)"]
V["V_PROJ<br/>T × 384 → T × 384<br/>(6 heads × T × 64)"]
ATT["ATTENTION (per head, ×6)<br/>softmax(Q · K^T / √64) → T × T<br/>scores · V → T × 64"]
MH["MERGE_HEADS<br/>6 × (T × 64) → T × 384"]
O["O_PROJ<br/>T × 384 → T × 384"]
ADD1{{"+ residual<br/>T × 384"}}
MN["MLP_NORM (LayerNorm)<br/>T × 384 → T × 384"]
UP["UP_PROJ + GELU<br/>T × 384 → T × 1536"]
DN["DOWN_PROJ<br/>T × 1536 → T × 384"]
ADD2{{"+ residual<br/>T × 384"}}
BIN --> AN
AN --> Q & K & V
Q --> ATT
K --> ATT
V --> ATT
ATT --> MH --> O --> ADD1
BIN -. residual .-> ADD1
ADD1 --> MN --> UP --> DN --> ADD2
ADD1 -. residual .-> ADD2
end
ADD0 --> BIN
ADD2 --> FN["FINAL NORM (LayerNorm)<br/>T × 384 → T × 384"]
FN --> LM["LM_HEAD (tied to WTE)<br/>T × 384 → T × 10,000<br/>logits over vocab"]
LM --> SMP["SOFTMAX over vocab<br/>→ SAMPLE TOKEN"]
classDef blue fill:#16324f,stroke:#5aa9f8,color:#e6edf3;
classDef pink fill:#4a2230,stroke:#ff9eb1,color:#e6edf3;
classDef green fill:#16402a,stroke:#5fd98a,color:#e6edf3;
class P,TOK,WTE,WPE,ADD0 blue;
class BIN,AN,Q,K,V,ATT,MH,O,ADD1,MN,UP,DN,ADD2 pink;
class FN,LM,SMP green;
Reading the diagram top to bottom:
- Tokenize the prompt into integer ids (
T ≤ 256). - Embed each id (
embed_tokens) and each position (embed_positions), then add them — the model has no other notion of order. - Run the sum through 6 transformer blocks, each a causal self-attention sublayer followed by an MLP, both wrapped in pre-norm residuals.
- Apply a final LayerNorm, then the LM head (tied to the token embedding) to get logits over all 10,000 tokens.
- Softmax + sample the next token, append it, and repeat.
Where the parameters live¶
Of the 14.6M parameters, ~73% are in the six transformer blocks and ~27% are the token-embedding matrix (which is reused as the output head — see weight tying below, so it is counted once).
%%{init: {'theme':'dark','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'Helvetica, Arial, sans-serif','lineColor':'#8b949e','clusterBkg':'#161b22','clusterBorder':'#30363d','edgeLabelBackground':'#161b22'}}}%%
flowchart TD
subgraph EMB["EMBEDDINGS: 3,938,304"]
direction TB
WTE["EMBED_TOKENS<br/>10,000 × 384 = 3,840,000"]
WPE["EMBED_POSITIONS<br/>256 × 384 = 98,304"]
end
subgraph BLK["TRANSFORMER BLOCK ( ×6 )"]
direction TB
AN["ATTN_NORM (weight + bias)<br/>2 × 384 = 768"]
QKV["Q_PROJ + K_PROJ + V_PROJ<br/>3 × (384×384 + 384) = 443,520"]
O["O_PROJ<br/>384×384 + 384 = 147,840"]
MN["MLP_NORM (weight + bias)<br/>2 × 384 = 768"]
UP["UP_PROJ (384 → 1536)<br/>384×1536 + 1536 = 591,360"]
DN["DOWN_PROJ (1536 → 384)<br/>1536×384 + 384 = 590,208"]
SUB(["per-block subtotal = 1,774,464<br/>× 6 blocks = 10,646,784"])
AN --> QKV --> O --> MN --> UP --> DN --> SUB
end
subgraph HEAD["OUTPUT HEAD: 768"]
direction TB
FN["FINAL NORM (weight + bias)<br/>2 × 384 = 768"]
LM["LM_HEAD<br/>tied to EMBED_TOKENS → +0"]
end
TIE(["WEIGHT TYING<br/>LM_HEAD reuses EMBED_TOKENS<br/>(10,000 × 384), counted once above"])
TOTAL["TOTAL PARAMETERS<br/>3,938,304 + 10,646,784 + 768<br/>= 14,585,856"]
EMB --> BLK --> HEAD --> TOTAL
WTE -. shared .- TIE
LM -. shared .- TIE
classDef blue fill:#16324f,stroke:#5aa9f8,color:#e6edf3;
classDef pink fill:#4a2230,stroke:#ff9eb1,color:#e6edf3;
classDef green fill:#16402a,stroke:#5fd98a,color:#e6edf3;
classDef yellow fill:#4a3a12,stroke:#e9c46a,color:#e6edf3;
classDef total fill:#0d1117,stroke:#e6edf3,stroke-width:3px,color:#e6edf3;
class WTE,WPE blue;
class AN,QKV,O,MN,UP,DN,SUB pink;
class FN,LM green;
class TIE yellow;
class TOTAL total;
Components¶
Tokenizer¶
A deliberately simple word-level tokenizer (WordTokenizer). Text is
pre-tokenized with a regex into runs of word characters or punctuation
(PRETOK = \w+|[^\w\s]+), whitespace is dropped, and each piece is looked up in
a 10,000-entry vocabulary. Three reserved specials take the first ids:
| Token | Id | Purpose |
|---|---|---|
[UNK] |
0 | out-of-vocabulary fallback |
[PAD] |
1 | batch padding |
<\|endoftext\|> |
2 | document / generation boundary |
decode(encode(x)) recovers the words but not the exact spacing, since tokens
are rejoined with single spaces.
Embeddings¶
self.embed_tokens = nn.Embedding(vocab_size, n_embd) # 10000 × 384
self.embed_positions = nn.Embedding(block_size, n_embd) # 256 × 384
x = self.drop(self.embed_tokens(idx) + self.embed_positions(pos))
Positions are learned (an embedding table), not sinusoidal, and always run
0..T-1 with no offset during training.
Causal self-attention¶
The only layer where tokens exchange information — and only backward. Each
position projects to a query, key, and value; attention is computed
per head with PyTorch's fused scaled_dot_product_attention:
is_causal=True builds the lower-triangular mask internally (6 heads × 64
dims). After attention the heads are merged and mixed back together by o_proj.
MLP¶
A position-wise feed-forward network — attention moves information between tokens, the MLP processes each token independently. Expand 4×, apply GELU, project back:
self.up_proj = nn.Linear(n_embd, 4 * n_embd) # 384 → 1536
self.down_proj = nn.Linear(4 * n_embd, n_embd) # 1536 → 384
# forward: down_proj(gelu(up_proj(x)))
Transformer block (pre-norm residual)¶
LayerNorm is applied before each sublayer, not after. This keeps a clean identity path through the residual stream, so each block adds a small correction instead of replacing the representation — which is what makes deep stacks trainable.
x = x + self.attn(self.attn_norm(x)) # attention sublayer
x = x + self.mlp(self.mlp_norm(x)) # MLP sublayer
Output head & weight tying¶
self.lm_head = nn.Linear(n_embd, vocab_size, bias=False) # 384 → 10000
self.embed_tokens.weight = self.lm_head.weight # tied
The output projection shares its weight matrix with the token embedding. That saves 3.84M parameters and ties "what a token means on the way in" to "what predicting that token looks like on the way out."
Initialization¶
GPT-2-style: weights ~ N(0, 0.02), biases zero. The two residual output
projections (o_proj, down_proj) are scaled down by 1/√(2·n_layer) so the
residual stream's variance stays flat as depth grows.
Configuration¶
Every shape comes from a single dataclass, so a checkpoint is only loadable into a model built with the same config:
@dataclass
class GPTConfig:
vocab_size: int = 10000
block_size: int = 256 # max context length
n_layer: int = 6
n_head: int = 6
n_embd: int = 384 # must be divisible by n_head
dropout: float = 0.1
bias: bool = True
Generation¶
GPT.generate() samples autoregressively with temperature and optional top-k
filtering, cropping the context to the last block_size tokens. It re-runs the
whole context through all six blocks on every step — simple, but O(T²) over a
generation. The Inference & KV Cache page shows how to fix that
without changing the weights.
📄 Source: base_model.py