Overview
The gpt topic implements a tiny GPT trained from scratch — a decoder-only
transformer with token and positional embeddings, multi-head self-attention, and feed-forward blocks.
Train it on any text corpus with train, then generate continuations of a prompt with
complete. Unlike the llm topic, no API key or network
access is required — everything runs locally on the CPU.
Training produces a checkpoint directory (default ./.gpt) containing
config.json (model configuration and tokenizer state), weights.bin
(the weight tensors), and optimizer.bin (AdamW optimizer state). Train once, then call
complete as many times as needed against the same checkpoint — or continue training it
later with --resume.
Two tokenizers are available via --tokenizer. The default bpe
is a byte-level byte-pair encoding (à la GPT-2): it learns subword tokens by repeatedly merging the most
frequent adjacent byte pair up to --vocab-size tokens, so a fixed --block-size
covers several times more text and the model spends capacity on structure rather than spelling. The
alternative char is the original one-token-per-character scheme. Because
BPE works on raw UTF-8 bytes, any input is representable — there are no out-of-vocabulary characters.
Architecture
The gpt topic follows the toolkit's component-per-command pattern: a thin
System.CommandLine layer (GptCommand) parses options and delegates to one component per
action. The components orchestrate a small set of services that together form a self-contained
training and inference platform — there are no external ML dependencies (no TorchSharp, no ONNX,
no native libraries). The entire stack, from autograd to optimizer, is implemented in C# in the
spirit of Karpathy's micrograd/nanoGPT.
| Type | Layer | Responsibility |
|---|---|---|
GptCommand | Commands/ | Defines the train and complete actions, parses options, resolves --source as a file path or literal text, and reports progress/errors via Spectre.Console |
GptTrainComponent | Components/ | Runs the training loop: builds the tokenizer, samples minibatches, drives forward/backward/optimizer steps, and saves the final checkpoint |
GptCompleteComponent | Components/ | Loads a checkpoint, reconstructs model and tokenizer, and autoregressively samples one token at a time (greedy or temperature sampling) |
ITokenizer | Services/ | Common tokenizer contract (Encode/Decode/ExportState); a serialized TokenizerState in the checkpoint lets complete and --resume rebuild the exact tokenizer used for training |
CharTokenizer | Services/ | Character-level vocabulary: the sorted set of distinct chars in the corpus, mapping between text and integer ids |
BpeTokenizer | Services/ | Byte-level byte-pair encoding: base vocabulary of 256 bytes plus learned merges. Trains word-frequency-weighted over GPT-2-style regex chunks (fast on large corpora) and memoizes per-chunk encodings |
GptModel + GptConfig | Services/ | The decoder-only transformer: owns parameter tensors, weight initialization, and the forward pass producing logits and cross-entropy loss |
Tensor | Services/ | Minimal reverse-mode automatic differentiation engine over 2D float matrices; every model op (matmul, layernorm, softmax, GELU, …) lives here with its backward function |
AdamW | Services/ | AdamW optimizer with decoupled weight decay over the model's parameter list |
CheckpointService | Services/ | Persists and restores checkpoints — weights, optimizer state, and the accumulated training history — behind the ICheckpointService interface (injected into both components for testability) |
LossGraphService | Services/ | Renders the recorded per-step loss and corpus-coverage series (across every run against the checkpoint) as a standalone HTML report behind the ILossGraphService interface (injected into GptTrainComponent); the full series is embedded in the page and drawn to a canvas by inline script |
ICheckpointService via constructor injection, following the
toolkit's interface-based service pattern — file I/O can be mocked in unit tests while the numeric
core (GptModel, Tensor, AdamW) is deterministic and tested directly.
Model
GptModel implements a nanoGPT-style decoder-only transformer. A GptConfig
record (VocabSize, BlockSize, NEmbd, NHead,
NLayer) fixes the architecture; the head dimension is NEmbd / NHead, and
construction validates that --n-embd is divisible by --n-head.
Key architectural details:
| Feature | Implementation |
|---|---|
| Embeddings | Learned token embedding table wte (vocab × n-embd) plus learned positional embedding wpe (block-size × n-embd), summed per position |
| Block structure | Pre-LayerNorm: each block computes x = x + Attn(LN(x)) then x = x + MLP(LN(x)) |
| Attention | Causal multi-head self-attention: Q/K/V projections with bias, then a fused CausalSelfAttention op — scores scaled by 1/√head-dim, causal masking, row softmax, and the value mix computed per (sequence, head) unit in parallel — followed by an output projection |
| MLP | Two linear layers with a 4× hidden expansion and GELU activation (tanh approximation) |
| Output head | Weight-tied with the token embedding: logits = x · wteᵀ — no separate unembedding matrix, reducing parameter count |
| Initialization | Seeded Box-Muller normal N(0, 0.02) for weight matrices, zeros for biases, gamma=1/beta=0 for LayerNorms — reproducible for a given --seed |
With the default hyperparameters (n-embd 128, 4 heads, 3 layers, block size 64) and a typical corpus vocabulary, the model comes out at roughly 600K parameters.
Autograd Engine
All numerics run through Tensor, a minimal reverse-mode automatic differentiation
engine over 2D float matrices. Each operation (e.g. MatMul, LayerNorm,
SoftmaxRows, Gelu, CrossEntropy) computes its result eagerly,
records its parent tensors, and attaches a closure that knows how to propagate gradients backwards.
Calling Backward() on the loss performs a topological sort of the computation graph,
seeds the root gradient with 1, and invokes each node's backward function in reverse order,
accumulating into every parameter's Grad buffer.
The op set is exactly what the transformer needs:
| Operation | Used for |
|---|---|
MatMul, Transpose, AddBias | Linear projections (Q/K/V/output, MLP, weight-tied head) |
Add, Scale | Residual connections; attention score scaling |
Gather | Token and positional embedding lookup |
LayerNorm, Gelu | Normalization and MLP activation |
CausalSelfAttention | Fused causal multi-head attention: scaled scores, causal masking, max-subtracted row softmax, and value mixing in a single op, parallel across (sequence, head) units |
CausalMask, SoftmaxRows, SliceRows/SliceCols, ConcatRows/ConcatCols | Standalone primitives for masking, softmax, and splitting/reassembling matrices (attention itself now uses the fused op above) |
CrossEntropy | Fused softmax + mean negative log-likelihood loss with a simple probs − one-hot backward |
Performance and determinism are balanced deliberately: hot kernels use
System.Numerics.Tensors.TensorPrimitives (SIMD) for dot products and elementwise math,
and forward and backward passes parallelize with Parallel.For across independent
work units — matrix rows, (sequence, head) attention units, normalization rows — falling back to
sequential loops when a tensor is too small to justify thread-pool overhead. Parallelism is only
applied where each unit writes disjoint state, and reductions inside a unit stay sequential, so
results are bit-identical regardless of thread count — which is what makes --seed fully
reproducible.
Training Pipeline
GptTrainComponent.TrainAsync wires the services together into a standard
minibatch gradient-descent loop:
- Tokenize —
Tokenizers.Buildbuilds the requested tokenizer (--tokenizer bpelearns byte-pair merges up to--vocab-size;chartakes the sorted distinct characters), then encodes the whole corpus into one integer array. The corpus must encode to at leastblock-size + 1tokens. - Construct — a
GptModelis built from the config and initialized from the seeded RNG; anAdamWinstance is bound to the model's parameter list (learning rate from--lr; betas 0.9/0.95, weight decay 0.1). With--resume, the model, tokenizer, and config are instead loaded from the checkpoint in--out(architecture and tokenizer options are ignored), and saved AdamW moments are restored so training continues without a loss spike. - Sample batches — each step draws
--batch-sizerandom windows of--block-sizetokens; the target sequence is the same window shifted one token right, so every position learns next-token prediction simultaneously. Each window's start offset is returned with the batch so the component can mark which corpus positions and which vocabulary entries have been visited — random sampling means coverage is probabilistic, and a run with too few steps will never see parts of the corpus. - Step —
ZeroGrad→Forward(logits + loss) →loss.Backward()→optimizer.Step(). The command layer prints step/loss at step 1, every 100th step, and the final step, while the component records every step's loss in full. - Persist — after the final step the checkpoint (config, tokenizer state, and all weight tensors in construction order) plus the optimizer state is written via
CheckpointService, the loss and coverage series (with the packed visited sets and run boundaries) are written tohistory.binso a later--resumecan continue them, and a summary (parameter count, vocab size, final loss, corpus token count and chars/token, path, plus corpus and vocabulary coverage) is reported. When--loss-graphis set,LossGraphServicethen writes the whole per-step loss and coverage series out as a standalone HTML report.
Inference Pipeline
GptCompleteComponent.CompleteAsync performs autoregressive decoding — the same
forward pass as training, but without targets, and repeated once per generated character:
Because the context window is capped at block-size tokens, generation slides the
window forward once the sequence outgrows it — earlier tokens simply fall out of context. Each
iteration is a full forward pass over the current window (there is no KV caching in this
implementation). Sampling divides logits by --temperature before a max-subtracted
softmax; --temperature 0 switches to pure argmax, and any sampled run is reproducible
via --seed. --max-tokens counts tokens, not characters, so a BPE checkpoint
emits more text per token than a char checkpoint. With the default bpe tokenizer any
prompt is valid (it works on raw bytes); a char checkpoint still requires prompt
characters that were present in the training corpus, since its vocabulary is fixed at training time.
Checkpoint Format
A checkpoint is a directory (default ./.gpt) written by CheckpointService
containing four files:
| File | Format | Contents |
|---|---|---|
config.json |
Indented JSON | The model hyperparameters (VocabSize, BlockSize, NEmbd, NHead, NLayer) plus the tokenizer state: TokenizerKind (Char or Bpe) with either the character vocabulary string or the ordered list of byte-pair merges |
weights.bin |
Little-endian binary | Tensor count (int32), then for each tensor its length (int32) followed by its float32 values — written in the model's fixed parameter construction order |
optimizer.bin |
Little-endian binary | AdamW state: step count (int32), tensor count (int32), then per tensor its length (int32) followed by the float32 first-moment then second-moment values. Used only by --resume; optional — a checkpoint without it resumes with a fresh optimizer |
history.bin |
Little-endian binary | Training history across every run: format version (int32), corpus token count (int32), step count (int32), the float32 loss of each step, the int32 cumulative tokens-seen of each step, then the run-boundary count (int32) and each boundary (int32), and finally the bit-packed visited sets for corpus positions and vocabulary entries (each an int32 length followed by ceil(length/8) bytes). Read by --resume so the loss graph and coverage continue across runs; optional — a checkpoint without it starts a fresh history |
On load, GptModel.LoadWeights validates that the tensor count and every tensor length
match what the config-constructed model expects, so a corrupted or mismatched checkpoint fails fast
with a clear error rather than producing garbage output. Because the config fully determines the
parameter shapes and ordering, the checkpoint is portable across machines — inference only needs
config.json and weights.bin.
--out. To keep multiple models, train
into different directories and select one at inference time with --model.
train
Trains a tiny GPT on a text corpus and saves a checkpoint. The tokenizer is selected with
--tokenizer: the default bpe learns byte-pair merges up to
--vocab-size tokens, while char uses one token per distinct character.
Training progress (step and loss) is reported periodically, and on completion the parameter count,
vocabulary size, final loss, corpus token count (with chars/token), and checkpoint path are printed,
along with how much of the corpus and vocabulary the randomly sampled batches actually visited.
--source accepts either a path to a corpus file or literal text.
With --resume, training continues from the checkpoint already in --out for
a further --steps steps: weights, tokenizer, and architecture come from the checkpoint
(so --block-size, --n-embd, --n-head, --n-layer,
--tokenizer, and --vocab-size are ignored), and saved optimizer state is
restored when present. For a char checkpoint the corpus may only contain characters that
were in the original training corpus; a bpe checkpoint accepts any text.
--loss-graph writes a standalone HTML report of the run to the given path (parent
directories are created as needed). Every step's loss is recorded — not just the ones printed
to the console — and embedded in the page, which draws the raw series plus a smoothed trend
line on a canvas with hover readouts. A second chart tracks corpus coverage: because
batches are drawn from random offsets, the report shows the cumulative share of the corpus's token
positions that have been sampled at least once, so it is easy to see whether a run visits most of the
corpus or keeps re-treading the same windows. The run's configuration, corpus coverage, and vocabulary
coverage are tabulated below the charts. The file is self-contained (no external scripts or styles),
so it can be opened directly or served with web serve-html.
Because the series and visited sets are stored in the checkpoint's history.bin, a
--resume run graphs the whole history — every run against that checkpoint, not just
the latest — with a dashed divider at each resume point and coverage carried on from the positions
already visited. If the resumed corpus has a different token count the recorded positions no longer
apply, so coverage restarts from that run while the loss curve is still kept.
| Option | Required | Default | Description |
|---|---|---|---|
--source | Yes | — | Path to a training corpus file (or literal text) |
--out | No | ./.gpt | Checkpoint output directory |
--steps | No | 2000 | Number of training steps |
--batch-size | No | 16 | Sequences per training step |
--block-size | No | 64 | Context length in tokens |
--n-embd | No | 128 | Embedding dimension |
--n-head | No | 4 | Number of attention heads |
--n-layer | No | 3 | Number of transformer blocks |
--tokenizer | No | bpe | Tokenizer: bpe (byte-pair subwords) or char (one token per character) |
--vocab-size | No | 512 | Target vocabulary size for the bpe tokenizer (≥ 256; ignored for char) |
--lr | No | 0.0003 | Learning rate |
--seed | No | 1337 | Random seed for reproducible runs |
--resume | No | false | Resume training from the checkpoint in --out (architecture, tokenizer options, and recorded training history are taken from the checkpoint) |
--loss-graph | No | — | Path to write an HTML report graphing per-step training loss and corpus coverage |
complete
Generates text from a trained GPT checkpoint by continuing the given prompt one character at a time.
The checkpoint must have been produced with gpt train first.
Sampling is controlled by --temperature — higher values are more random, and
0 is greedy/deterministic — and --seed makes sampled runs reproducible.
| Option | Required | Default | Description |
|---|---|---|---|
--prompt | Yes | — | Prompt to continue |
--model | No | ./.gpt | Checkpoint directory |
--max-tokens | No | 200 | Number of tokens to generate (with bpe, one token is typically several characters) |
--temperature | No | 0.8 | Sampling temperature (0 = greedy/deterministic) |
--seed | No | 1337 | Random seed for sampling |
Command Reference
| Command | Required options | Optional options | Output |
|---|---|---|---|
gpt train |
--source |
--out, --steps, --batch-size, --block-size, --n-embd, --n-head, --n-layer, --tokenizer, --vocab-size, --lr, --seed, --resume, --loss-graph |
Training progress, then summary with parameter count, vocab size, final loss, corpus token count, checkpoint path, and corpus/vocabulary coverage; an HTML loss and coverage report when --loss-graph is given |
gpt complete |
--prompt |
--model, --max-tokens, --temperature, --seed |
Generated text to stdout |