95% of code and the whole idea belongs to Andrej Karpathy. I only added a lot of unhinhed comments that allowed me to understand what is going on, where and why. I also added some other stuff and plan to slowly add stuff like KV-Cache, RoPE and whatever my motivation and free time allows.
Main files:
gpt2.py -- contains the model architecture and class
train_gpt2.py -- whole train loop
fineweb.py -- pretraining data preprocessing
gpt_playground.py -- take your custom model for a spin
Even though we set seeds for random generators, different machines and versions may yield different results. Yes, even if you try setting literally all possible generators (random, numpy, environ.... I tried). Therefore I am getting different generations on AMD CPU and Metal CPU. However both should be passing the general vibe check and it is currently the best way I could come up with for judging if it works.
From Torch docs:
Completely reproducible results are not guaranteed across PyTorch releases, individual commits, or different platforms. Furthermore, results may not be reproducible between CPU and GPU executions, even when using identical seeds.
So, well. Something went wrong and my performance is slightly worse than what Karpathy achieved in the video. I'm not sure why, I double checked all the parameters, wen over the code, but if there is some tiny bug that results in 0.01 worse performance on Hellaswag, then I'd probably need to spend like 20 hours to track it down. I made peace with it. Maybe I don't know, it's down to using different cards -- I wish.

Noisy embedding lines in the embedding layer mean that the model could be trained some more

GPT-2 is decoder only, therefore its architecture is:
Also:
Which looks a lot like the growth of variance in random walktorch.manual_seed for different devices -- it really works and gives same resultsimport code; code.interact(local=locals()) for drop-in debugs
Intuitively: exponent spans the range, while mantissa allows more finegrained placement of numbers on this range. In TF32 mantissa is crushed to 10 bits (from 23), I think MPS supports BFloat16, and not TF32 MPS shader dtypes is the only documentation of dtypes I could quickly find. -- coming back to this: there is a quick function in scratch.py to check dtypes available on the device, and it looks like MPS does not support torch.bfloat16, but CPU doesautocast as a context manager for a forward pass and loss calculation (and only this! without optimizer step etc.), it also says that we should not be casting manually any tensors to half (FP16) or BF16. What Im unsure here is: why using FP16 would need to use gradient scalars? Couldnt we calculate gradient with FP16 as well, so everything is in the same range from the start, and nothing needs to be scaled?torch.compile does kernel fusion on elementwise operations, reducing the number of trips the data takes between the GPU and HBM (probably does more things), torch.compile in general reduces python overhead and gpu read/writes
Not only the CPU <-> GPU transfer is important. Also the speed and number of I/Os between SRAM and HBM need to be tracked, as they can often cause a bottleneck. Kernel fusions are especially useful for this, as they reduce the number of SRAM <-> HBM transfers.autocast throws device error -- make sure you pass a device string, not device object ie if you have something like torch.device("cuda"), you want to pass it as with torch.autocast(device_type=device.type, ...torch.compile do it then? Cause it demands an algorithmic rewrite of the attention mechanism. Even though Flash Attention is more computationaly costly, it needs less HBM/SRAM transfers, which turn out to be the cause of a big chunk of attention runtime. Therefore, by using more compute, we reduce data transfers, and save time overall. The main premise of Flash Attention is that the big attention matrix (T, T) of interaction between keys and queries is never materialized. FlashAttention and FlashAttention2, and the main mechanism behind the way that Flash Attention works is the one of partial softmax calculation mechanism. Flash Attention helps even on CPU, getting us from 776 tok/s -> 1154 tok/s.
torch.compile will add _orig_mod. to each of the model keys, so if you plan to use the model further, remember to eiter drop it before saving or handle it after, simple .replace("_orig_mod.", "") works
.data_ptr() allows to check where in memory the tensor is storedapply on nn.module subclass is going to apply its argument to all subclass modules (i think only modules, not sure tho)torch.set_float32_matmul_precision("high") on MPS (10 batches of B=16, T=256) -- checked later and MPS doesnt support even bfloat16, but when run with CPU it seems to be supported
With lower precision:
Mean Batch time: 1.82s
Mean tokens/sec: 2265.09
Without lower precision:
Mean Batch time: 1.74s
Mean tokens/sec: 2400.92
A100 whitepaper it should 8x, but in Andrej's case it only 3x, in mine... yeah you can seetorch.cuda.max_memory_allocated looks hella usefull for when you cant pinpoint the vram usage with nvidia-smi/nvtop, and you have this tingling feeling that you should look at max allocated memory cause it feels too much, this gives max allocated memory since the start of the programtorch.compile and torch.autocast are unusable for device "mps" and M series macbooks (or at least i couldnt make them run without significant effort), additionaly autocast works really bad with device "cpu", almost freezing the program, compile runs, but performance is slightly worse compared to non-compiled one (650 tok/s vs 750) param_dict = {pn: p for pn, p in self.named_parameters() if p.requires_grad}
decay_params = [pn for pn, p in param_dict.items() if p.dim() >= 2]
nondecay_params = [pn for pn, p in param_dict.items() if p.dim() < 2]
optim_groups = [
{"params": decay_params, "weigth_decay": weight_decay},
{"params": nondecay_params, "weigth_decay": 0.0},
]
optimizer = torch.optim.AdamW(optim_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
torch.distributed(some_variable, op=torch.distributed.ReduceOp.SOMEOP). Great nccl docs

Cautionary tale on what neither GPT-4o, nor Claude 3.5 Sonnet could help me with. Both models failed in what ultimately boiled down to string comparison. Even though both were specifically asked to debug a potential typo.
For whatever reason my generations looks like:
> Hello, I'm a language model, a language model model model model model model modeling model modeling modelling modeling modeling modeling modeling modeling modelling modeling modeling modeling modeling
> Hello, I'm a language model, system, system, system, system, system, of of of of of of of of of of of of
> Hello, I'm a language model, not,not,not,not,not,not,not,not
2323232323
> Hello, I'm a language model, a language model, a language model model model model ...
Markus ... ... ... ... ... ... ...
> Hello, I'm a language model, model model, model, model, model, model, model, model, model, model not not not not
Instead of:
> Hello, I'm a language model, not a science. I'm a language designer. I want to write, I want to think. I want
> Hello, I'm a language model, I use an English sentence structure, I like words over sentences.
"That's OK I'll look
> Hello, I'm a language model, not just another language." This isn't a "language model?" It's an idea. So far, what
> Hello, I'm a language model, not a programming model. I'm not a theoretical computer model - you read that right - because my ideas are
> Hello, I'm a language model, I teach myself.
I want to know more about how languages work and why they could be used.
After a lot of breakpoints and print(x), print(x.shape) it turns out my h[x].c_attn.bias were copied in the wrong order (or so I thought), which is weird, considering that the weights seem to be copied correctly.
Correct bias for h[0].c_attn.bias:
tensor([ 0.4803, -0.5254, -0.4293, ..., 0.0126, -0.0499, 0.0032],
requires_grad=True)
My bias:
tensor([-0.0198, -0.0302, 0.0064, ..., 0.0146, -0.0021, -0.0181],
requires_grad=True)
Turns out, no attention head had biases looking like this. Correct attention biases for all heads:
tensor([ 0.4803, -0.5254, -0.4293, ..., 0.0126, -0.0499, 0.0032])
tensor([ 0.0092, -0.1241, -0.2280, ..., 0.0404, 0.0494, -0.0038])
tensor([-0.0541, -0.0644, 0.0311, ..., 0.0015, -0.0427, 0.0059])
tensor([-0.2251, -0.0644, 0.0223, ..., 0.0205, -0.0017, -0.0044])
tensor([-0.0302, 0.1053, 0.1579, ..., -0.0185, -0.0097, 0.0927])
tensor([-0.0436, 0.0295, 0.0850, ..., 0.0089, -0.0007, 0.0082])
tensor([ 0.0380, 0.1714, -0.1409, ..., -0.0441, 0.0544, 0.0041])
tensor([ 0.3779, 0.0767, 0.0019, ..., 0.0123, -0.0721, 0.0015])
tensor([-0.0167, -0.3909, -0.1419, ..., 0.0212, 0.0140, 0.0999])
tensor([ 0.0571, 0.0355, -0.0991, ..., 0.0075, 0.0219, -0.0241])
tensor([-0.0301, 0.1360, -0.3842, ..., -0.0599, 0.1059, 0.0276])
tensor([-0.2222, 0.0549, 0.0331, ..., -0.0289, -0.0241, 0.0063])
So what the hell is going on you may ask? How could you possibly put the wrong bias in out of the thin air? Naturally I went on looking at all the bias tensors in the original GPT2 state_dict -- I obviously had to mismatch the bias with the key. Imagine my surprise when I learned that this c_attn bias does not correspond to ANY of the biases in the original weights...
So I went down the weights copying hole. And who would've thought. I tried to be smarter than I am, and it backfired, as it always does. Take a close look at:
My filtering:
sd_keys_hf = [k for k in sd_keys_hf if "attn.bias" not in k and "attn.masked_bias" not in k]
Correct filtering:
sd_keys_hf = [k for k in sd_keys_hf if not k.endswith('.attn.masked_bias')]
sd_keys_hf = [k for k in sd_keys_hf if not k.endswith('.attn.bias')]
My .c_attn.bias was simply initiated randomly, because I filtered additional keys.
How to fix this?
"attn.bias" -> ".attn.bias"
Or just listen to people smarter than you and don't take shortcuts as I wanted to. Also, never take LLM debugging for granted.
I will get to the bottom of that.
OpenMP support not found. Please try one of the following solutions:
(1) Set the `CXX` environment variable to a compiler other than Apple clang++/g++ that has builtin OpenMP support;
(2) install OpenMP via conda: `conda install llvm-openmp`;
(3) install libomp via brew: `brew install libomp`;
(4) manually setup OpenMP and set the `OMP_PREFIX` environment variable to point to a path with `include/omp.h` under it.
I did 1., which resulted in the problem disappearing, but now the compilation seems to be stuck. Program is non-responsive.
Wait, maybe just REALLYYYY slow? Interrupted two times and different calls were printed.
Ok nvm -- the thing that does not work on M series MBP I'm currently writing this is autocast (or probably is really damn slow), also torch.compile works when device is cpu, but makes the batch runtime higher, so dont bother doing it for now.
91 commits
Jupyter Notebook
99.6%
95% of code and the whole idea belongs to Andrej Karpathy. I only added a lot of unhinhed comments that allowed me to understand what is going on, where and why. I also added some other stuff and plan to slowly add stuff like KV-Cache, RoPE and whatever my motivation and free time allows.
Main files:
gpt2.py -- contains the model architecture and class
train_gpt2.py -- whole train loop
fineweb.py -- pretraining data preprocessing
gpt_playground.py -- take your custom model for a spin
Even though we set seeds for random generators, different machines and versions may yield different results. Yes, even if you try setting literally all possible generators (random, numpy, environ.... I tried). Therefore I am getting different generations on AMD CPU and Metal CPU. However both should be passing the general vibe check and it is currently the best way I could come up with for judging if it works.
From Torch docs:
Completely reproducible results are not guaranteed across PyTorch releases, individual commits, or different platforms. Furthermore, results may not be reproducible between CPU and GPU executions, even when using identical seeds.
So, well. Something went wrong and my performance is slightly worse than what Karpathy achieved in the video. I'm not sure why, I double checked all the parameters, wen over the code, but if there is some tiny bug that results in 0.01 worse performance on Hellaswag, then I'd probably need to spend like 20 hours to track it down. I made peace with it. Maybe I don't know, it's down to using different cards -- I wish.

Noisy embedding lines in the embedding layer mean that the model could be trained some more

GPT-2 is decoder only, therefore its architecture is:
Also:
Which looks a lot like the growth of variance in random walktorch.manual_seed for different devices -- it really works and gives same resultsimport code; code.interact(local=locals()) for drop-in debugs
Intuitively: exponent spans the range, while mantissa allows more finegrained placement of numbers on this range. In TF32 mantissa is crushed to 10 bits (from 23), I think MPS supports BFloat16, and not TF32 MPS shader dtypes is the only documentation of dtypes I could quickly find. -- coming back to this: there is a quick function in scratch.py to check dtypes available on the device, and it looks like MPS does not support torch.bfloat16, but CPU doesautocast as a context manager for a forward pass and loss calculation (and only this! without optimizer step etc.), it also says that we should not be casting manually any tensors to half (FP16) or BF16. What Im unsure here is: why using FP16 would need to use gradient scalars? Couldnt we calculate gradient with FP16 as well, so everything is in the same range from the start, and nothing needs to be scaled?torch.compile does kernel fusion on elementwise operations, reducing the number of trips the data takes between the GPU and HBM (probably does more things), torch.compile in general reduces python overhead and gpu read/writes
Not only the CPU <-> GPU transfer is important. Also the speed and number of I/Os between SRAM and HBM need to be tracked, as they can often cause a bottleneck. Kernel fusions are especially useful for this, as they reduce the number of SRAM <-> HBM transfers.autocast throws device error -- make sure you pass a device string, not device object ie if you have something like torch.device("cuda"), you want to pass it as with torch.autocast(device_type=device.type, ...torch.compile do it then? Cause it demands an algorithmic rewrite of the attention mechanism. Even though Flash Attention is more computationaly costly, it needs less HBM/SRAM transfers, which turn out to be the cause of a big chunk of attention runtime. Therefore, by using more compute, we reduce data transfers, and save time overall. The main premise of Flash Attention is that the big attention matrix (T, T) of interaction between keys and queries is never materialized. FlashAttention and FlashAttention2, and the main mechanism behind the way that Flash Attention works is the one of partial softmax calculation mechanism. Flash Attention helps even on CPU, getting us from 776 tok/s -> 1154 tok/s.
torch.compile will add _orig_mod. to each of the model keys, so if you plan to use the model further, remember to eiter drop it before saving or handle it after, simple .replace("_orig_mod.", "") works
.data_ptr() allows to check where in memory the tensor is storedapply on nn.module subclass is going to apply its argument to all subclass modules (i think only modules, not sure tho)torch.set_float32_matmul_precision("high") on MPS (10 batches of B=16, T=256) -- checked later and MPS doesnt support even bfloat16, but when run with CPU it seems to be supported
With lower precision:
Mean Batch time: 1.82s
Mean tokens/sec: 2265.09
Without lower precision:
Mean Batch time: 1.74s
Mean tokens/sec: 2400.92
A100 whitepaper it should 8x, but in Andrej's case it only 3x, in mine... yeah you can seetorch.cuda.max_memory_allocated looks hella usefull for when you cant pinpoint the vram usage with nvidia-smi/nvtop, and you have this tingling feeling that you should look at max allocated memory cause it feels too much, this gives max allocated memory since the start of the programtorch.compile and torch.autocast are unusable for device "mps" and M series macbooks (or at least i couldnt make them run without significant effort), additionaly autocast works really bad with device "cpu", almost freezing the program, compile runs, but performance is slightly worse compared to non-compiled one (650 tok/s vs 750) param_dict = {pn: p for pn, p in self.named_parameters() if p.requires_grad}
decay_params = [pn for pn, p in param_dict.items() if p.dim() >= 2]
nondecay_params = [pn for pn, p in param_dict.items() if p.dim() < 2]
optim_groups = [
{"params": decay_params, "weigth_decay": weight_decay},
{"params": nondecay_params, "weigth_decay": 0.0},
]
optimizer = torch.optim.AdamW(optim_groups, lr=lr, betas=(0.9, 0.95), eps=1e-8)
torch.distributed(some_variable, op=torch.distributed.ReduceOp.SOMEOP). Great nccl docs

Cautionary tale on what neither GPT-4o, nor Claude 3.5 Sonnet could help me with. Both models failed in what ultimately boiled down to string comparison. Even though both were specifically asked to debug a potential typo.
For whatever reason my generations looks like:
> Hello, I'm a language model, a language model model model model model model modeling model modeling modelling modeling modeling modeling modeling modeling modelling modeling modeling modeling modeling
> Hello, I'm a language model, system, system, system, system, system, of of of of of of of of of of of of
> Hello, I'm a language model, not,not,not,not,not,not,not,not
2323232323
> Hello, I'm a language model, a language model, a language model model model model ...
Markus ... ... ... ... ... ... ...
> Hello, I'm a language model, model model, model, model, model, model, model, model, model, model not not not not
Instead of:
> Hello, I'm a language model, not a science. I'm a language designer. I want to write, I want to think. I want
> Hello, I'm a language model, I use an English sentence structure, I like words over sentences.
"That's OK I'll look
> Hello, I'm a language model, not just another language." This isn't a "language model?" It's an idea. So far, what
> Hello, I'm a language model, not a programming model. I'm not a theoretical computer model - you read that right - because my ideas are
> Hello, I'm a language model, I teach myself.
I want to know more about how languages work and why they could be used.
After a lot of breakpoints and print(x), print(x.shape) it turns out my h[x].c_attn.bias were copied in the wrong order (or so I thought), which is weird, considering that the weights seem to be copied correctly.
Correct bias for h[0].c_attn.bias:
tensor([ 0.4803, -0.5254, -0.4293, ..., 0.0126, -0.0499, 0.0032],
requires_grad=True)
My bias:
tensor([-0.0198, -0.0302, 0.0064, ..., 0.0146, -0.0021, -0.0181],
requires_grad=True)
Turns out, no attention head had biases looking like this. Correct attention biases for all heads:
tensor([ 0.4803, -0.5254, -0.4293, ..., 0.0126, -0.0499, 0.0032])
tensor([ 0.0092, -0.1241, -0.2280, ..., 0.0404, 0.0494, -0.0038])
tensor([-0.0541, -0.0644, 0.0311, ..., 0.0015, -0.0427, 0.0059])
tensor([-0.2251, -0.0644, 0.0223, ..., 0.0205, -0.0017, -0.0044])
tensor([-0.0302, 0.1053, 0.1579, ..., -0.0185, -0.0097, 0.0927])
tensor([-0.0436, 0.0295, 0.0850, ..., 0.0089, -0.0007, 0.0082])
tensor([ 0.0380, 0.1714, -0.1409, ..., -0.0441, 0.0544, 0.0041])
tensor([ 0.3779, 0.0767, 0.0019, ..., 0.0123, -0.0721, 0.0015])
tensor([-0.0167, -0.3909, -0.1419, ..., 0.0212, 0.0140, 0.0999])
tensor([ 0.0571, 0.0355, -0.0991, ..., 0.0075, 0.0219, -0.0241])
tensor([-0.0301, 0.1360, -0.3842, ..., -0.0599, 0.1059, 0.0276])
tensor([-0.2222, 0.0549, 0.0331, ..., -0.0289, -0.0241, 0.0063])
So what the hell is going on you may ask? How could you possibly put the wrong bias in out of the thin air? Naturally I went on looking at all the bias tensors in the original GPT2 state_dict -- I obviously had to mismatch the bias with the key. Imagine my surprise when I learned that this c_attn bias does not correspond to ANY of the biases in the original weights...
So I went down the weights copying hole. And who would've thought. I tried to be smarter than I am, and it backfired, as it always does. Take a close look at:
My filtering:
sd_keys_hf = [k for k in sd_keys_hf if "attn.bias" not in k and "attn.masked_bias" not in k]
Correct filtering:
sd_keys_hf = [k for k in sd_keys_hf if not k.endswith('.attn.masked_bias')]
sd_keys_hf = [k for k in sd_keys_hf if not k.endswith('.attn.bias')]
My .c_attn.bias was simply initiated randomly, because I filtered additional keys.
How to fix this?
"attn.bias" -> ".attn.bias"
Or just listen to people smarter than you and don't take shortcuts as I wanted to. Also, never take LLM debugging for granted.
I will get to the bottom of that.
OpenMP support not found. Please try one of the following solutions:
(1) Set the `CXX` environment variable to a compiler other than Apple clang++/g++ that has builtin OpenMP support;
(2) install OpenMP via conda: `conda install llvm-openmp`;
(3) install libomp via brew: `brew install libomp`;
(4) manually setup OpenMP and set the `OMP_PREFIX` environment variable to point to a path with `include/omp.h` under it.
I did 1., which resulted in the problem disappearing, but now the compilation seems to be stuck. Program is non-responsive.
Wait, maybe just REALLYYYY slow? Interrupted two times and different calls were printed.
Ok nvm -- the thing that does not work on M series MBP I'm currently writing this is autocast (or probably is really damn slow), also torch.compile works when device is cpu, but makes the batch runtime higher, so dont bother doing it for now.
91 commits
Jupyter Notebook
99.6%