Skip to content

Commit e8e1628

Browse files
committed
first commit of just the reference cpu fp32 gpt2 training
0 parents  commit e8e1628

8 files changed

Lines changed: 2042 additions & 0 deletions

File tree

Makefile

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
CC = clang
2+
CFLAGS = -O3 -Ofast
3+
LDFLAGS =
4+
LDLIBS = -lm
5+
INCLUDES =
6+
7+
# Check if OpenMP is available
8+
# This is done by attempting to compile an empty file with OpenMP flags
9+
# OpenMP makes the code a lot faster so I advise installing it
10+
# e.g. on MacOS: brew install libomp
11+
# e.g. on Ubuntu: sudo apt-get install libomp-dev
12+
# later, run the program by prepending the number of threads, e.g.: OMP_NUM_THREADS=8 ./gpt2
13+
ifeq ($(shell echo | $(CC) -Xpreprocessor -fopenmp -x c -E - > /dev/null 2>&1; echo $$?), 0)
14+
ifeq ($(shell uname), Darwin)
15+
# macOS with Homebrew
16+
CFLAGS += -Xclang -fopenmp
17+
LDFLAGS += -L/opt/homebrew/opt/libomp/lib
18+
LDLIBS += -lomp
19+
INCLUDES += -I/opt/homebrew/opt/libomp/include
20+
else
21+
# Ubuntu or other Linux distributions
22+
CFLAGS += -fopenmp
23+
LDLIBS += -lgomp
24+
endif
25+
$(info NICE Compiling with OpenMP support)
26+
else
27+
$(warning OOPS Compiling without OpenMP support)
28+
endif
29+
30+
# PHONY means these targets will always be executed
31+
.PHONY: all train_gpt2 test_gpt2
32+
33+
# default target is all
34+
all: train_gpt2 test_gpt2
35+
36+
train_gpt2: train_gpt2.c
37+
$(CC) $(CFLAGS) $(INCLUDES) $(LDFLAGS) $< $(LDLIBS) -o $@
38+
39+
test_gpt2: test_gpt2.c
40+
$(CC) $(CFLAGS) $(INCLUDES) $(LDFLAGS) $< $(LDLIBS) -o $@
41+
42+
clean:
43+
rm -f train_gpt2 test_gpt2

README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# llm.c
2+
3+
LLM training in simple, pure C/CUDA. There is no need for 245MB of PyTorch or 107MB of cPython. For example, training GPT-2 (CPU, fp32) is ~1,000 lines of clean code in a single file. It compiles and runs instantly, and exactly matches the PyTorch reference implementation. I chose GPT-2 as the first working example because it is the grand-daddy of LLMs, the first time the modern stack was put together.
4+
5+
Currently, I am working on:
6+
7+
- direct CUDA implementation, which will be significantly faster and probably come close to PyTorch.
8+
- speed up the CPU version with SIMD instructions, AVX2 on x86 / NEON on ARM (e.g. Apple Silicon).
9+
- more modern architectures, e.g. Llama2, Gemma, etc.
10+
11+
For the repo, I'd like to maintain both clean, simple reference implementations alongside a also lot more optimized versions that can come close to PyTorch, but in a tiny fraction of the code and dependencies.
12+
13+
## quick start
14+
15+
Download and tokenize a dataset. The [tinyshakespeare](https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt) dataset is the fastest to download and tokenize:
16+
17+
```bash
18+
python prepro_tinyshakespeare.py
19+
```
20+
21+
This prints:
22+
23+
```
24+
Saved 32768 tokens to data/tiny_shakespeare_val.bin
25+
Saved 305260 tokens to data/tiny_shakespeare_train.bin
26+
```
27+
28+
The .bin files are raw byte streams of int32 numbers indicating the token ids with the GPT-2 tokenizer. Alternatively you could also tokenize the [TinyStories](https://huggingface.co/datasets/roneneldan/TinyStories) dataset with `prepro_tinystories.py`.
29+
30+
In principle we'd be ready to the train the model right here. However the baseline CPU/fp32 reference code is so inefficient that it's not practical to train these models from scratch yet. Instead, we initialize with the GPT-2 weights released by OpenAI and just do finetuning. For that, we have to download the GPT-2 weights and save them as a checkpoint we can load in C:
31+
32+
```bash
33+
python train_gpt2.py
34+
```
35+
36+
You'll recognize this code from nanoGPT as a simple GPT-2 reference implementation in PyTorch. This script will download the GPT-2 (124M) model, overfit a single batch of data for 10 iterations, run a few steps of generation, and most importantly it will save two files: 1) the `gpt2_124M.bin` file that contains the raw model weights for loading in C, and `gpt2_124M_debug_state.bin`, which also contains more debug state: the inputs, targets, logits and loss. This is very useful for debugging C code, for unit testing, and making sure we're exactly matching the PyTorch reference implementation. For now all we care about are the model weights in `gpt2_124M.bin`. We can now initialize with them and train in raw C. First compile the code:
37+
38+
```bash
39+
make train_gpt2
40+
```
41+
42+
You can have a look inside the `Makefile` and its comments. It will try to autodetect if OpenMP is available on your system, which is very helpful for speeding up the code at very low cost of code complexity. Once `train_gpt2` is compiled, you can run it:
43+
44+
```bash
45+
OMP_NUM_THREADS=8 ./train_gpt2
46+
```
47+
48+
You should tune the number of threads depending on how many cores your CPU has. The program will load the model weights, the tokens, it will run a finetuning loop for a few iterations with Adam lr 1e-4, and then generate a sample from the model. The file is (I think) very readable and you should have a look. Simply, there are implementations for the forward and backward pass of all the layers, and they get strung together into a large, manual, forward/backward/update loop. The output looks like this on my MacBook Pro (Apple Silicon M3 Max):
49+
50+
```
51+
[GPT-2]
52+
max_seq_len: 1024
53+
vocab_size: 50257
54+
num_layers: 12
55+
num_heads: 12
56+
channels: 768
57+
num_parameters: 124439808
58+
train dataset num_batches: 1192
59+
val dataset num_batches: 128
60+
num_activations: 73323776
61+
val loss 5.252026
62+
step 0: train loss 5.356189 (took 1452.121000 ms)
63+
step 1: train loss 4.301069 (took 1288.673000 ms)
64+
step 2: train loss 4.623322 (took 1369.394000 ms)
65+
step 3: train loss 4.600470 (took 1290.761000 ms)
66+
... (trunctated) ...
67+
step 39: train loss 3.970751 (took 1323.779000 ms)
68+
val loss 4.107781
69+
generated: 50256 16773 18162 21986 11 198 13681 263 23875 198 3152 262 11773 2910 198 1169 6002 6386 2583 286 262 11858 198 20424 428 3135 7596 995 3675 13 198 40 481 407 736 17903 11 329 703 6029 706 4082 198 42826 1028 1128 633 263 11 198 10594 407 198 2704 454 680 1028 262 1027 28860 286 198 3237 323
70+
step 40: train loss 4.377757 (took 1366.368000 ms)
71+
```
72+
73+
The generation just gives you the token ids for now, which we have to decode back to text. We can implement this in C quite easily also, because decoding is very straight-forward, it's just string chunk lookups and prints. For now we can use tiktoken:
74+
75+
```python
76+
import tiktoken
77+
enc = tiktoken.get_encoding("gpt2")
78+
print(enc.decode(list(map(int, "50256 16773 18162 21986 11 198 13681 263 23875 198 3152 262 11773 2910 198 1169 6002 6386 2583 286 262 11858 198 20424 428 3135 7596 995 3675 13 198 40 481 407 736 17903 11 329 703 6029 706 4082 198 42826 1028 1128 633 263 11 198 10594 407 198 2704 454 680 1028 262 1027 28860 286 198 3237 323".split()))))
79+
```
80+
81+
which prints:
82+
83+
```
84+
<|endoftext|>Come Running Away,
85+
Greater conquer
86+
With the Imperial blood
87+
the heaviest host of the gods
88+
into this wondrous world beyond.
89+
I will not back thee, for how sweet after birth
90+
Netflix against repounder,
91+
will not
92+
flourish against the earlocks of
93+
Allay
94+
```
95+
96+
I like how Netflix comes up, it's clear that the shadow of the training past is still lurking in the model. I did not attempt to tune the finetuning hyperparameters so it's quite likely this can be improved quite a bit, most likely especially if one was to train a bit longer.
97+
98+
## test
99+
100+
I am also attaching a simple unit test for making sure our C code agrees with the PyTorch code. Compile and run with:
101+
102+
```
103+
make test_gpt2
104+
./test_gpt2
105+
```
106+
107+
This now loads the `gpt2_124M_debug_state.bin` file, runs a forward pass, compares the logits and loss with the PyTorch reference implementation, then it does 10 iterations of training with Adam and makes sure the losses match PyTorch.
108+
109+
```
110+
111+
## license
112+
113+
MIT

prepro_tinyshakespeare.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Downloads and tokenizes the TinyShakespeare dataset.
3+
- The download is from Github.
4+
- The tokenization is GPT-2 tokenizer with tiktoken
5+
6+
The output is written to a newly created data/ folder.
7+
The script prints:
8+
9+
Saved 32768 tokens to data/tiny_shakespeare_val.bin
10+
Saved 305260 tokens to data/tiny_shakespeare_train.bin
11+
12+
And runs in a few seconds depending on your internet
13+
connection and computer. The .bin files are raw byte
14+
streams of int32 numbers indicating the token ids.
15+
"""
16+
17+
import os
18+
import requests
19+
from tqdm import tqdm
20+
21+
import tiktoken
22+
import numpy as np
23+
24+
DATA_CACHE_DIR = "data"
25+
enc = tiktoken.get_encoding("gpt2")
26+
encode = lambda s: enc.encode(s, allowed_special={'<|endoftext|>'})
27+
28+
def download_file(url: str, fname: str, chunk_size=1024):
29+
"""Helper function to download a file from a given url"""
30+
resp = requests.get(url, stream=True)
31+
total = int(resp.headers.get("content-length", 0))
32+
with open(fname, "wb") as file, tqdm(
33+
desc=fname,
34+
total=total,
35+
unit="iB",
36+
unit_scale=True,
37+
unit_divisor=1024,
38+
) as bar:
39+
for data in resp.iter_content(chunk_size=chunk_size):
40+
size = file.write(data)
41+
bar.update(size)
42+
43+
def download():
44+
"""Downloads the TinyShakespeare dataset to DATA_CACHE_DIR"""
45+
os.makedirs(DATA_CACHE_DIR, exist_ok=True)
46+
47+
# download the TinyStories dataset, unless it's already downloaded
48+
data_url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
49+
data_filename = os.path.join(DATA_CACHE_DIR, "tiny_shakespeare.txt")
50+
if not os.path.exists(data_filename):
51+
print(f"Downloading {data_url} to {data_filename}...")
52+
download_file(data_url, data_filename)
53+
else:
54+
print(f"{data_filename} already exists, skipping download...")
55+
56+
def tokenize():
57+
eot = enc._special_tokens['<|endoftext|>'] # end of text token
58+
data_filename = os.path.join(DATA_CACHE_DIR, "tiny_shakespeare.txt")
59+
text = open(data_filename, 'r').read()
60+
# let's treat every person's statement in the dialog as a separate document
61+
text = "<|endoftext|>" + text
62+
text = text.replace('\n\n', '\n\n<|endoftext|>')
63+
# encode the text
64+
tokens = encode(text)
65+
tokens_np = np.array(tokens, dtype=np.int32)
66+
# let's take the first 32,768 tokens as the validation split (~10%)
67+
val_tokens_np = tokens_np[:32768]
68+
train_tokens_np = tokens_np[32768:]
69+
# save to file
70+
val_filename = os.path.join(DATA_CACHE_DIR, "tiny_shakespeare_val.bin")
71+
train_filename = os.path.join(DATA_CACHE_DIR, "tiny_shakespeare_train.bin")
72+
with open(val_filename, "wb") as f:
73+
f.write(val_tokens_np.tobytes())
74+
with open(train_filename, "wb") as f:
75+
f.write(train_tokens_np.tobytes())
76+
# prints
77+
print(f"Saved {len(val_tokens_np)} tokens to {val_filename}")
78+
print(f"Saved {len(train_tokens_np)} tokens to {train_filename}")
79+
80+
if __name__ == "__main__":
81+
download()
82+
tokenize()

prepro_tinystories.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""
2+
Downloads and tokenizes the TinyStories dataset.
3+
- The download is from HuggingFace datasets.
4+
- The tokenization is GPT-2 tokenizer with tiktoken
5+
6+
The output is written to a newly created data/ folder.
7+
The script prints:
8+
9+
Tokenizing val split...
10+
Saved 19043638 tokens to data/TinyStories_val.bin
11+
Tokenizing train split...
12+
Saved 925653391 tokens to data/TinyStories_train.bin
13+
14+
And runs in 1-2 minutes two depending on your internet
15+
connection and computer. The .bin files are raw byte
16+
streams of int32 numbers indicating the token ids.
17+
"""
18+
19+
import os
20+
import glob
21+
import json
22+
import random
23+
import requests
24+
from tqdm import tqdm
25+
from concurrent.futures import ProcessPoolExecutor, as_completed
26+
27+
import tiktoken
28+
import numpy as np
29+
30+
DATA_CACHE_DIR = "data"
31+
enc = tiktoken.get_encoding("gpt2")
32+
encode = lambda s: enc.encode_ordinary(s)
33+
34+
def download_file(url: str, fname: str, chunk_size=1024):
35+
"""Helper function to download a file from a given url"""
36+
resp = requests.get(url, stream=True)
37+
total = int(resp.headers.get("content-length", 0))
38+
with open(fname, "wb") as file, tqdm(
39+
desc=fname,
40+
total=total,
41+
unit="iB",
42+
unit_scale=True,
43+
unit_divisor=1024,
44+
) as bar:
45+
for data in resp.iter_content(chunk_size=chunk_size):
46+
size = file.write(data)
47+
bar.update(size)
48+
49+
def download():
50+
"""Downloads the TinyStories dataset to DATA_CACHE_DIR"""
51+
os.makedirs(DATA_CACHE_DIR, exist_ok=True)
52+
53+
# download the TinyStories dataset, unless it's already downloaded
54+
data_url = "https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStories_all_data.tar.gz"
55+
data_filename = os.path.join(DATA_CACHE_DIR, "TinyStories_all_data.tar.gz")
56+
if not os.path.exists(data_filename):
57+
print(f"Downloading {data_url} to {data_filename}...")
58+
download_file(data_url, data_filename)
59+
else:
60+
print(f"{data_filename} already exists, skipping download...")
61+
62+
# unpack the tar.gz file into all the data shards (json files)
63+
data_dir = os.path.join(DATA_CACHE_DIR, "TinyStories_all_data")
64+
if not os.path.exists(data_dir):
65+
os.makedirs(data_dir, exist_ok=True)
66+
print(f"Unpacking {data_filename}...")
67+
os.system(f"tar -xzf {data_filename} -C {data_dir}")
68+
else:
69+
print(f"{data_dir} already exists, skipping unpacking...")
70+
71+
# print a single example just for debugging and such
72+
shard_filenames = sorted(glob.glob(os.path.join(data_dir, "*.json")))
73+
with open(shard_filenames[0], "r") as f:
74+
data = json.load(f)
75+
print("Download done.")
76+
print(f"Number of shards: {len(shard_filenames)}")
77+
#print(f"Example story:\n{data[0]}")
78+
79+
def process_shard(shard_index, shard_filename):
80+
with open(shard_filename, "r") as f:
81+
data = json.load(f)
82+
eot = enc._special_tokens['<|endoftext|>'] # end of text token
83+
rng = random.Random(1337 + shard_index)
84+
rng.shuffle(data)
85+
all_tokens = []
86+
for example in data:
87+
text = example["story"]
88+
text = text.strip() # get rid of leading/trailing whitespace
89+
tokens = encode(text)
90+
all_tokens.append(eot)
91+
all_tokens.extend(tokens)
92+
return all_tokens
93+
94+
def tokenize():
95+
# shard 0 will be the val split, rest is train
96+
data_dir = os.path.join(DATA_CACHE_DIR, "TinyStories_all_data")
97+
shard_filenames = sorted(glob.glob(os.path.join(data_dir, "*.json")))
98+
val_shards = [shard_filenames[0]]
99+
train_shards = shard_filenames[1:]
100+
for split_name, split_shards in [("val", val_shards), ("train", train_shards)]:
101+
102+
print(f"Tokenizing {split_name} split...")
103+
all_tokens = []
104+
with ProcessPoolExecutor() as executor:
105+
futures = [executor.submit(process_shard, shard_index, shard_filename)
106+
for shard_index, shard_filename in enumerate(split_shards)]
107+
for future in as_completed(futures):
108+
all_tokens.extend(future.result())
109+
110+
all_tokens_np = np.array(all_tokens, dtype=np.int32)
111+
split_filename = os.path.join(DATA_CACHE_DIR, f"TinyStories_{split_name}.bin")
112+
with open(split_filename, "wb") as f:
113+
f.write(all_tokens_np.tobytes())
114+
print(f"Saved {len(all_tokens_np)} tokens to {split_filename}")
115+
116+
if __name__ == "__main__":
117+
download()
118+
tokenize()
119+
120+
# Prints:
121+
# Tokenizing val split...
122+
# Saved 19043638 tokens to data/TinyStories_val.bin
123+
# Tokenizing train split...
124+
# Saved 925653391 tokens to data/TinyStories_train.bin

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
numpy
2+
torch
3+
tiktoken

0 commit comments

Comments
 (0)