Un mini LLM decoder-only écrit from-scratch en C++ / CUDA, sans aucune dépendance type PyTorch. Le but est double :
- Bootstrap (v0, ce repo) — un toy LLM déjà fonctionnel de bout en bout : tokenizer BPE entraîné sur un corpus, forward pass Transformer sur GPU, génération greedy. Inference-first, pas encore d'entraînement.
- Target (v1) — évoluer vers un tiny SOTA LLM d'environ 100M params avec GQA + RoPE + attention hybride local/global + FFN mixte dense/MoE.
Tout est écrit à la main. La différenciation (backward), quand elle sera ajoutée, le sera à la main également : pas d'autograd, pas de framework.
Ce qui tourne aujourd'hui :
- Tokenizer BPE trainable (CPU, C++) : train / save / load / encode / decode
- Forward pass CUDA d'un tiny decoder-only Transformer :
- token embeddings
- RMSNorm pre-norm
- causal multi-head self-attention avec RoPE
- SwiGLU FFN
- résidus
- RMSNorm finale
- projection de sortie en weight tying avec l'embedding
- Backward manuel kernel par kernel (matmul, rmsnorm, softmax, rope, attention, swiglu, embedding, logits tied, cross-entropy)
- AdamW avec weight decay découplé
- Training depuis un corpus + save/load de checkpoints (.bin custom, sans dépendance)
- Génération greedy (argmax) sur CPU avec recomputation full-context
- Float32 uniquement
vocab_size = 256 + nb_merges (typiquement ~512)
seq_len = 32
d_model = 128
n_layers = 2
n_heads = 4
head_dim = 32 (d_model = n_heads * head_dim)
ffn_dim = 256
dtype = float32
Pour chaque couche
Pour chaque tête
on a
Puis SwiGLU :
Logits finaux (tied embeddings) :
Définitions auxiliaires :
La v1 est la cible finale du repo. Elle n'est pas implémentée ici. Elle guide les choix de design pour que l'on puisse y migrer sans tout casser.
Matrice d'embedding
Avec tying en sortie :
14 couches, 12 query heads, 3 KV heads (groupes de 4),
Projections (query par tête, key/value par groupe) :
RoPE sur
Pour chaque tête
Puis
Pattern par couche, sur 14 couches : 10 locales + 4 globales.
Global aux couches
Couche locale (fenêtre
Couche globale (causal complet) :
Fenêtre recommandée :
FFN dense. Après seconde RMSNorm :
FFN MoE avec 4 experts, chaque expert
Routeur :
Poids (softmax restreint aux experts top-2) :
Sortie MoE et résiduel :
1 : Local + Dense
2 : Local + Dense
3 : Local + Dense
4 : Global + Dense
5 : Local + Dense
6 : Local + Dense
7 : Local + MoE
8 : Global + Dense
9 : Local + MoE
10 : Local + Dense
11 : Global + MoE
12 : Local + Dense
13 : Local + MoE
14 : Global + Dense
- RMSNorm pre-norm, no bias partout
- dropout = 0 en prétraining
- router z-loss + load-balancing loss, capacity factor 1.25–1.5
- RoPE base classique
- FlashAttention possible plus tard, orthogonal à l'archi
Loss totale :
Total
- v0 (ce bootstrap) — dense MHA + SwiGLU, MNIST-level du NLP.
- Ajouter KV cache (inférence plus rapide).
- Remplacer MHA par GQA + RoPE.
- Ajouter l'attention locale (sliding window) et le pattern hybride.
- Implémenter le backward manuel (autograd à la main) + SGD/AdamW.
- Scaler à d=576, 14 layers, V=32k.
- Ajouter les couches MoE (4 experts, top-2 + balancing loss).
- Entraîner sur un corpus réel (WikiText / FineWeb échantillon).
toy_llm_cuda/
Makefile
README.md
CLAUDE.md
data/
corpus.txt # petit corpus pour entraîner le BPE
prompt.txt # prompt d'inférence
include/
config.h # hyperparams v0
common.h # utils CPU
common.cuh # utils CUDA (CUDA_CHECK, etc.)
tokenizer.h
tensor.cuh
model.cuh
kernels.cuh
src/
main.cpp # CLI end-to-end
train.cpp # boucle d'entraînement
checkpoint.cpp # save/load des poids .bin
tokenizer.cpp # encode/decode/save/load
bpe_train.cpp # train BPE
tensor.cu # alloc/copy device <-> host
embedding.cu # lookup + logits tied (fwd/bwd)
matmul.cu # GEMM naïf row-major (fwd/bwd)
rmsnorm.cu # RMSNorm (fwd/bwd)
softmax.cu # softmax (fwd/bwd)
attention.cu # MHA causale dense (fwd/bwd)
rope.cu # RoPE (fwd/bwd inverse rotation)
ffn.cu # SwiGLU (fwd/bwd)
loss.cu # cross-entropy fused (fwd/bwd)
optim.cu # AdamW
model.cu # assemblage forward + backward
tests/
test_tokenizer.cpp
test_bpe_train.cpp
test_matmul.cu # forward + backward (finite-diff)
test_rmsnorm.cu # forward + backward (finite-diff)
test_attention.cu
test_rope.cu
test_model_smoke.cu
Prérequis :
nvcc(CUDA ≥ 11)g++≥ 9 (C++17)- GPU NVIDIA dispo pour l'exécution (la compil seule ne l'exige pas)
make # compile tout : toy_llm + tests
make tokenizer # compile uniquement la partie CPU (pas besoin de CUDA)
make tests # compile les tests
make clean
Variables Make utiles :
SM=86pour cibler une archi GPU spécifique (défaut : sm_80)DEBUG=1pour-g -O0
- Entraîner le tokenizer BPE sur
data/corpus.txt:
./toy_llm train-bpe data/corpus.txt tokenizer.vocab tokenizer.merges 256
Arguments : <corpus> <vocab-out> <merges-out> <nb-merges>.
- Entraîner le modèle sur le corpus :
./toy_llm train data/corpus.txt tokenizer.vocab tokenizer.merges ckpt.bin 2000
# optionnel : learning rate explicite
./toy_llm train data/corpus.txt tokenizer.vocab tokenizer.merges ckpt.bin 2000 3e-3
Arguments : <corpus> <vocab> <merges> <ckpt-out> <steps> [lr].
Log la loss toutes les steps/50 itérations.
- Génération. Sans checkpoint, les poids sont aléatoires (sortie = bruit) ;
avec
--weightson charge un checkpoint entraîné :
./toy_llm generate tokenizer.vocab tokenizer.merges data/prompt.txt 16
./toy_llm generate tokenizer.vocab tokenizer.merges data/prompt.txt 16 --weights ckpt.bin
Arguments : <vocab> <merges> <prompt-file> <nb-tokens-à-générer> [--weights <ckpt>].
Sanity check du pipeline backward: le but est de voir la loss descendre
nettement sur quelques centaines / milliers de steps en overfit volontaire
sur data/corpus.txt, et que la génération reproduise des n-grammes du
corpus — pas de qualité linguistique attendue sur un tokenizer BPE
byte-level et une archi toy, c'est la preuve que backprop + AdamW +
checkpointing fonctionnent.
# Tokenizer seul
./toy_llm encode tokenizer.vocab tokenizer.merges "hello world"
./toy_llm decode tokenizer.vocab tokenizer.merges 1 2 3 4
# Tests unitaires
./tests/test_tokenizer
./tests/test_bpe_train
./tests/test_matmul
./tests/test_rmsnorm
./tests/test_attention
./tests/test_model_smoke
data/corpus.txt contient un petit échantillon suffisant pour apprendre
quelques merges BPE et démontrer le pipeline. Pour aller plus loin, tu peux
remplacer ce fichier par :
- WikiText-103 (brut)
- FineWeb échantillon
- TinyStories
- ton propre corpus
Pour l'entraînement réel du modèle (v1), il faudra un corpus bien plus gros (des Go de texte) et un pipeline de tokenisation streamée. Ce n'est pas dans le scope de la v0.
Explicitement, et volontairement :
- pas de GQA, pas de KV cache
- pas de MoE, pas d'attention locale/globale
- pas de FlashAttention
- pas de float16 / bfloat16
Tout cela arrive dans la roadmap v1 ci-dessus.