GPT gpt

Tiny from-scratch GPT with BPE or char tokenization — train a transformer on a text corpus, then sample completions
Pixelbadger Toolkit — pbtk gpt← all topics

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.

flowchart LR CORPUS["corpus.txt"] --> TRAIN["pbtk gpt train"] TRAIN --> MODEL[".gpt/\nconfig.json + weights.bin\n+ optimizer.bin"] MODEL -->|"--resume"| TRAIN MODEL --> COMPLETE["pbtk gpt complete"] PROMPT["--prompt"] --> COMPLETE COMPLETE --> OUT(["stdout: generated text"])
This is an educational toy model, not a production LLM. With the default hyperparameters it trains a model of roughly 600K parameters — enough to pick up the character-level style of a corpus (e.g. Shakespeare) in a couple of minutes, not to produce coherent long-form text.

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.

flowchart TB CMD["GptCommand\n(System.CommandLine topic)"] TRAINC["GptTrainComponent\n(training loop)"] COMPC["GptCompleteComponent\n(autoregressive sampling)"] TOK["CharTokenizer\n(char ↔ id mapping)"] MODEL["GptModel\n(decoder-only transformer)"] TENSOR["Tensor\n(reverse-mode autograd + SIMD kernels)"] OPT["AdamW\n(optimizer)"] CKPT["CheckpointService\n(config.json + weights.bin\n+ optimizer.bin + history.bin)"] GRAPHS["LossGraphService\n(HTML loss + coverage report)"] CMD --> TRAINC CMD --> COMPC TRAINC --> TOK TRAINC --> MODEL TRAINC --> OPT TRAINC --> CKPT TRAINC --> GRAPHS COMPC --> TOK COMPC --> MODEL COMPC --> CKPT MODEL --> TENSOR OPT --> TENSOR
TypeLayerResponsibility
GptCommandCommands/Defines the train and complete actions, parses options, resolves --source as a file path or literal text, and reports progress/errors via Spectre.Console
GptTrainComponentComponents/Runs the training loop: builds the tokenizer, samples minibatches, drives forward/backward/optimizer steps, and saves the final checkpoint
GptCompleteComponentComponents/Loads a checkpoint, reconstructs model and tokenizer, and autoregressively samples one token at a time (greedy or temperature sampling)
ITokenizerServices/Common tokenizer contract (Encode/Decode/ExportState); a serialized TokenizerState in the checkpoint lets complete and --resume rebuild the exact tokenizer used for training
CharTokenizerServices/Character-level vocabulary: the sorted set of distinct chars in the corpus, mapping between text and integer ids
BpeTokenizerServices/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 + GptConfigServices/The decoder-only transformer: owns parameter tensors, weight initialization, and the forward pass producing logits and cross-entropy loss
TensorServices/Minimal reverse-mode automatic differentiation engine over 2D float matrices; every model op (matmul, layernorm, softmax, GELU, …) lives here with its backward function
AdamWServices/AdamW optimizer with decoupled weight decay over the model's parameter list
CheckpointServiceServices/Persists and restores checkpoints — weights, optimizer state, and the accumulated training history — behind the ICheckpointService interface (injected into both components for testability)
LossGraphServiceServices/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
Both components receive 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.

flowchart TB IDS["token ids (B×T)"] --> EMB["token embedding (wte) +\npositional embedding (wpe)"] EMB --> BLK["× n-layer transformer blocks:\nLayerNorm → causal multi-head attention → residual\nLayerNorm → MLP (4×, GELU) → residual"] BLK --> LNF["final LayerNorm"] LNF --> HEAD["weight-tied head:\nlogits = x · wteᵀ"] HEAD --> LOGITS["logits (B·T, vocab)"] LOGITS --> CE["cross-entropy vs targets\n(training only)"]

Key architectural details:

FeatureImplementation
EmbeddingsLearned token embedding table wte (vocab × n-embd) plus learned positional embedding wpe (block-size × n-embd), summed per position
Block structurePre-LayerNorm: each block computes x = x + Attn(LN(x)) then x = x + MLP(LN(x))
AttentionCausal 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
MLPTwo linear layers with a 4× hidden expansion and GELU activation (tanh approximation)
Output headWeight-tied with the token embedding: logits = x · wteᵀ — no separate unembedding matrix, reducing parameter count
InitializationSeeded 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.

flowchart LR FWD["Forward pass\n(eager ops build graph:\nParents + BackwardFn)"] --> LOSS["loss (1×1 tensor)"] LOSS --> TOPO["Backward():\ntopological sort"] TOPO --> PROP["invoke BackwardFn\nin reverse order"] PROP --> GRADS["accumulated .Grad\non every parameter"] GRADS --> STEP["AdamW.Step()"]

The op set is exactly what the transformer needs:

OperationUsed for
MatMul, Transpose, AddBiasLinear projections (Q/K/V/output, MLP, weight-tied head)
Add, ScaleResidual connections; attention score scaling
GatherToken and positional embedding lookup
LayerNorm, GeluNormalization and MLP activation
CausalSelfAttentionFused 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/ConcatColsStandalone primitives for masking, softmax, and splitting/reassembling matrices (attention itself now uses the fused op above)
CrossEntropyFused 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:

flowchart TB SRC["corpus text\n(file or literal via --source)"] --> BUILD["Tokenizers.Build\n(bpe merges or char vocab)"] BUILD --> ENC["Encode corpus → int[] ids"] ENC --> LOOP["for each step 1..N"] LOOP --> BATCH["SampleBatch: random windows\n(batch-size × block-size,\ntargets shifted by one token)"] BATCH --> FWD["GptModel.Forward\n→ logits + cross-entropy loss"] FWD --> BWD["loss.Backward()\n(reverse-mode autograd)"] BWD --> ADAM["AdamW.Step()\n(decoupled weight decay)"] ADAM --> LOOP LOOP --> SAVE["CheckpointService.SaveAsync\n(config.json + weights.bin\n+ optimizer.bin + history.bin)"] SAVE --> GRAPH["LossGraphService.WriteAsync\n(HTML loss + coverage report,\nonly with --loss-graph)"]
  1. TokenizeTokenizers.Build builds the requested tokenizer (--tokenizer bpe learns byte-pair merges up to --vocab-size; char takes the sorted distinct characters), then encodes the whole corpus into one integer array. The corpus must encode to at least block-size + 1 tokens.
  2. Construct — a GptModel is built from the config and initialized from the seeded RNG; an AdamW instance 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.
  3. Sample batches — each step draws --batch-size random windows of --block-size tokens; 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.
  4. StepZeroGradForward (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.
  5. 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 to history.bin so a later --resume can 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-graph is set, LossGraphService then 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:

flowchart TB LOAD["CheckpointService.LoadAsync\n→ config + tokenizer state + weights"] --> RECON["Rebuild GptModel + tokenizer\n(LoadWeights validates tensor\ncount and shapes)"] RECON --> ENCP["Encode --prompt to ids\n(empty prompt seeds with id 0)"] ENCP --> WINDOW["Take last block-size ids\nas context window"] WINDOW --> FWD2["Forward (no targets)\n→ logits for every position"] FWD2 --> LAST["Select logits of the\nfinal time-step"] LAST --> PICK{"temperature ≤ 0?"} PICK -->|yes| GREEDY["ArgMax (greedy,\ndeterministic)"] PICK -->|no| TEMP["logits / temperature →\nstable softmax → seeded\ncategorical sample"] GREEDY --> APPEND["append id, repeat\n× max-tokens"] TEMP --> APPEND APPEND --> WINDOW APPEND --> DEC["tokenizer.Decode(all ids)\n→ stdout"]

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:

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

Training always overwrites the checkpoint in --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.

$ pbtk gpt train --source corpus.txt
$ pbtk gpt train --source shakespeare.txt --out ./.gpt --steps 2000 --n-embd 128 --n-layer 3
$ pbtk gpt train --source shakespeare.txt --tokenizer bpe --vocab-size 1024 --block-size 128
$ pbtk gpt train --source shakespeare.txt --steps 1000 --resume
$ pbtk gpt train --source shakespeare.txt --steps 2000 --loss-graph ./loss.html
OptionRequiredDefaultDescription
--sourceYesPath to a training corpus file (or literal text)
--outNo./.gptCheckpoint output directory
--stepsNo2000Number of training steps
--batch-sizeNo16Sequences per training step
--block-sizeNo64Context length in tokens
--n-embdNo128Embedding dimension
--n-headNo4Number of attention heads
--n-layerNo3Number of transformer blocks
--tokenizerNobpeTokenizer: bpe (byte-pair subwords) or char (one token per character)
--vocab-sizeNo512Target vocabulary size for the bpe tokenizer (≥ 256; ignored for char)
--lrNo0.0003Learning rate
--seedNo1337Random seed for reproducible runs
--resumeNofalseResume training from the checkpoint in --out (architecture, tokenizer options, and recorded training history are taken from the checkpoint)
--loss-graphNoPath 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.

$ pbtk gpt complete --prompt "ROMEO:"
$ pbtk gpt complete --model ./.gpt --prompt "ROMEO:" --max-tokens 200 --temperature 0.8
flowchart LR PROMPT["--prompt"] --> ENC["Encode with checkpoint\ntokenizer → ids"] ENC --> FWD["Transformer forward pass\n(last block-size tokens)"] FWD --> SAMPLE["Sample next token\n(temperature)"] SAMPLE -->|"repeat --max-tokens times"| FWD SAMPLE --> OUT(["stdout: prompt + generated text"])
OptionRequiredDefaultDescription
--promptYesPrompt to continue
--modelNo./.gptCheckpoint directory
--max-tokensNo200Number of tokens to generate (with bpe, one token is typically several characters)
--temperatureNo0.8Sampling temperature (0 = greedy/deterministic)
--seedNo1337Random 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