<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><title>Giles' blog</title><link>https://www.gilesthomas.com/</link><description>Giles' blog</description><docs>http://www.rssboard.org/rss-specification</docs><generator>python-feedgen</generator><lastBuildDate>Thu, 03 Sep 2026 17:21:35 +0000</lastBuildDate><item><title>Benchmarking Qwen 3.6 35B MoE (3B active) on an RTX 3090</title><link>https://www.gilesthomas.com/2026/07/benchmarking-qwen-3-6-35b-moe-rtx-3090</link><description>&lt;p&gt;I mentioned I'd got a new RTX 3090 on a group chat, and a friend said:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;I know this is not really your thing... but let me know how quickly it runs
  Qwen 3.6 35bn MoE. With only 24gb of VRAM you’ll need to use a 4-bit quantized
  version and you won’t get a massive context window. But it should still be
  pretty cool.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He's right that it's not really been my thing -- I've been focusing on my
own LLMs recently.  I decided to dig in a little, and in particular to play with
&lt;a href="https://llama-cpp.com/"&gt;Llama.cpp&lt;/a&gt;, which I haven't used for a while.  And then things got
a tad out of control, and I wound up doing some relatively detailed benchmarking.&lt;/p&gt;

&lt;p&gt;The headline results: I downloaded Unsloth's &lt;code&gt;UD-IQ4_NL_XL&lt;/code&gt; quantisation of the model
from &lt;a href="https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF"&gt;Hugging Face&lt;/a&gt;.  With that,
using the default Arch build of Llama.cpp, which uses Vulkan under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Using the GPU only, I was able to get the model to generate at just over 120 tokens per second,
and it was able to process the prompt at just less than 2,800 tok/s.  However,
having the whole model on the GPU didn't leave that much space for the context
window -- it was constrained to about 50,000 tokens, compared to the model's
native context length of 262,144.&lt;/li&gt;
&lt;li&gt;Offloading the FFNs for the first 12 of the model's 40 layers to the CPU managed to reclaim
enough VRAM to be able to get the full context length; however, with that setup,
things were -- unsurprisingly -- slower.  I got just over 65 tok/s for generation,
and 600 tok/s for the prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compiling Llama.cpp myself, in order to get the full CUDA version, helped a lot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With everything on the GPU, I got 140 tok/s for generation and over 3,300 tok/s
for the prompt.  That was with a context window of 89,600.  So everything was
better :-)&lt;/li&gt;
&lt;li&gt;It was also easier to get to the full context length; that needed just 10 layers' FFNs to be
offloaded, and at that point I was getting 89 tok/s for generation, and about 1,100
tok/s for the prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That was a pretty impressive improvement.&lt;/p&gt;

&lt;p&gt;But it also showed that the lack of VRAM on the 3090 really does hurt.  The friend
who asked me about this is running an Intel Arc B70 Pro, with 32 GiB VRAM.  He can
(of course) fit the whole 4-bit quantised model on there without any context window reductions, and
he says this about throughput:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;It starts off at 75-80. but drops into the high 50s as the context window expands&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's worse than the CUDA-with-offload results above, though I did limit my testing
to a 2,457-token prompt, with 6,144 tokens generated.  The RTX 5090 has 32 GiB VRAM
&lt;em&gt;and&lt;/em&gt; fast Nvidia processing -- I imagine things would be a lot better there.  Downside:
it costs more than three times what you pay for a second-hand 3090 (and double the Arc B70 Pro).&lt;/p&gt;

&lt;p&gt;Anyway, in the rest of this post, I'll give more details of the benchmark, including charts showing the performance
at different numbers of offloaded layers, and comparisons of the Vulkan vs CUDA versions
of Llama.cpp for this model.&lt;/p&gt;

&lt;p&gt;One caveat: Qwen 3.6 is a multimodal model.  That means that it has an extra component
to map graphical inputs to tokens that can be consumed by the LLM.  I didn't realise this
as I ran these tests, but you can actually tell it not to use that component if you're only working with
text inputs -- for example, &lt;code&gt;--no-mmproj&lt;/code&gt; will do the trick with Llama.cpp.  If you're working with text-only stuff, you
might want to play with that to see if you can get better results -- it will free up
some VRAM and might allow you to get longer context lengths, or better performance.&lt;/p&gt;

&lt;p&gt;But anyway, as with many of my posts, this is a tidied up version of
my lab notes, so you can share my learning journey with me -- but if that sounds pointless,
and you've landed here because you want to run this model on your own RTX 3090, you
can click &lt;a href="#the-results"&gt;this link&lt;/a&gt; to jump right to the results.&lt;/p&gt;
&lt;h2 id="the-model"&gt;The model&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/Qwen/Qwen3.6-35B-A3B"&gt;Qwen3.6-35B-A3B&lt;/a&gt; is a Mixture of Experts
model with 35B total parameters, 3B active.  The tensor type is BF16, so it's two bytes
per parameter.  That's ~70 GiB of space required for the model, just for the weights, with
no space for attention matrices and the like.  We're
going to need a quantised version.&lt;/p&gt;

&lt;p&gt;I'll dig deep into MoE models at some point, but
essentially they work by having multiple different FFN blocks for each Transformers layer.
After attention, the context vectors are routed to a subset of those blocks,
depending on their content &lt;sup class="footnote-ref" id="fnref-1"&gt;&lt;a href="#fn-1"&gt;1&lt;/a&gt;&lt;/sup&gt;.  The number of active parameters is how many are actually used
when running a single token through.&lt;/p&gt;

&lt;p&gt;Now, that number, 3B, looked really crazy to me as soon as I saw it.  Qwen
3.6, as you can see from the model card linked above, has a vocabulary size of 248,320, and an
embedding dimensionality of 2,048.  It doesn't use weight-tying, so that means that
the embedding layer and the output head come out at a hefty 508,559,360 parameters each.&lt;/p&gt;

&lt;p&gt;Both the embedding layer and the output head are required for every token, of course, so that means that of
those 3B active parameters, more than 1B are used just getting tokens into and out of the
model!  Only 2B active parameters are left to handle all of the attention and the active FFNs.  That is
an imbalance of the same level as &lt;a href="/2026/07/llm-parameter-counts"&gt;you get with GPT-2 small&lt;/a&gt;,
and was surprising to see.&lt;/p&gt;

&lt;p&gt;Well, let's think about VRAM requirements.  Naively you might think that you only need to keep the active parameters in VRAM,
swapping FFN experts in and out as needed.  Unfortunately that doesn't work.&lt;/p&gt;

&lt;p&gt;Let's say
you feed in "The fat cat sat on the", hoping for a completion.  The whole sequence is
processed in parallel (that being the point of using a GPT-style LLM rather than, say,
an &lt;a href="/2025/10/revisiting-karpathy-unreasonable-effectiveness-rnns"&gt;RNN&lt;/a&gt;).  You'll need
whichever experts are required for all six tokens in the prompt for the first layer, and likewise for all of the
context vectors for the prompt in each of the later layers.&lt;/p&gt;

&lt;p&gt;So, realistically, for a non-trivial prompt, you can't "swap out" the inactive parameters;
an MoE buys you lower usage in terms of
processing, but not in terms of memory. &lt;sup class="footnote-ref" id="fnref-2"&gt;&lt;a href="#fn-2"&gt;2&lt;/a&gt;&lt;/sup&gt;  That said, as we'll see later, you can at
least pull stuff out of VRAM into normal RAM, and get some advantages that way.&lt;/p&gt;

&lt;p&gt;As my friend said, a 4-bit quant
would be needed -- perhaps with that, the whole model, both active and inactive parameters,
would fit into 35 / 2 = 17.5 GiB of VRAM, leaving 6.5 GiB for activations, attention matrices,
and so on?  As it turned out, it needed a bit more, and it's worth taking a look into why.&lt;/p&gt;

&lt;h2 id="quantisation"&gt;Quantisation&lt;/h2&gt;

&lt;p&gt;I must admit that I'd never really looked into quantisation prior to playing with this,
and had naively assumed that you basically just took (say) a BF16 model, scaled the
parameters down so that they were (say) FP4, tweaked the computations a bit and then ran the result.&lt;/p&gt;

&lt;p&gt;That was completely wrong!  I'll have to dig into it further in the future, but my new
working mental model is that it's more like lossy compression.  The weights are stored
in a "compressed" format -- one that is designed so that they can be quickly and cheaply
"decompressed" by code running on the target platform.&lt;/p&gt;

&lt;p&gt;So, speaking very loosely, when running an unquantised model, we might have CUDA code doing
something like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Load BF16 weights from VRAM&lt;/li&gt;
&lt;li&gt;Load BF16 data from another bit of VRAM&lt;/li&gt;
&lt;li&gt;Do a matrix multiplication of one by the other.&lt;/li&gt;
&lt;li&gt;Store the results into VRAM as BF16&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...a quantised model might operate more like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Load quantised weights from VRAM&lt;/li&gt;
&lt;li&gt;"Decompress" them to BF16&lt;/li&gt;
&lt;li&gt;Load BF16 data from another bit of VRAM&lt;/li&gt;
&lt;li&gt;Do a matrix multiplication of one by the other.&lt;/li&gt;
&lt;li&gt;Store the results into VRAM as BF16&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Understanding that minimal amount clarified three things for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why people talk about things like "4.1-bit quants".  We're talking about the average
number of bits per weight in the "compressed" model.&lt;/li&gt;
&lt;li&gt;Why there are different quants of about the same size for a given model; for example,
when we get on to looking at quantised versions of this model later, you'll see 4-bit ones called &lt;code&gt;UD-IQ4_NL&lt;/code&gt;, &lt;code&gt;UD-Q4_K_M&lt;/code&gt;,
&lt;code&gt;UD-IQ4_NL_XL&lt;/code&gt;, and &lt;code&gt;UD-Q4_K_XL&lt;/code&gt;, with different sizes in terms of GiB.  Each of
those is using a different set of trade-offs in the quantisation process, so will
perform differently on different tasks.  I gather that picking the right one for
any given task is a bit of an art form.  Hugging Face have a summary of the different
types they support &lt;a href="https://huggingface.co/docs/hub/main/en/gguf#quantization-types"&gt;here&lt;/a&gt;,
which has &lt;em&gt;some&lt;/em&gt; explanation of the differences, but decoding what they're saying there is
beyond what I've learnt at this point.&lt;/li&gt;
&lt;li&gt;How GPUs can run quants with bits-per-parameter levels that they don't support
for calculations.  For example, the RTX 3090 supports 32-bit and two forms of
16-bit (float16 and BF16) plus integer operations of various bittednesses &lt;sup class="footnote-ref" id="fnref-3"&gt;&lt;a href="#fn-3"&gt;3&lt;/a&gt;&lt;/sup&gt;.  4-bit formats like FP4 are only supported in
more recent cards like the RTX 5090.  But, of course, we're not doing any
4-bit computations -- it's all in some format that the GPU can handle natively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, with that (minimal) level of understanding, it was time to dig in!&lt;/p&gt;

&lt;h2 id="getting-llamacpp-up-and-running"&gt;Getting Llama.cpp up and running&lt;/h2&gt;

&lt;p&gt;There are a bunch of different ways that people run LLMs locally, so I needed to work
out which one would be likely to give the best results.  One thing I noticed when
googling around for things like "Qwen3.6-35B-A3B RTX 3090" was that pretty much everyone
appeared to be using &lt;a href="https://llama-cpp.com/"&gt;Llama.cpp&lt;/a&gt;.  That wasn't something I'd used for a couple
of years, and I've repaved my machine multiple times since, so it was time to install it.&lt;/p&gt;

&lt;p&gt;I run Arch Linux, and there's &lt;a href="https://wiki.archlinux.org/title/Llama.cpp"&gt;an OS package for it&lt;/a&gt;,
so I installed it using the instructions there -- specifically, the ones to run inference using CUDA:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;pacman&lt;span class="w"&gt; &lt;/span&gt;-S&lt;span class="w"&gt; &lt;/span&gt;llama-cpp&lt;span class="w"&gt; &lt;/span&gt;ggml&lt;span class="w"&gt; &lt;/span&gt;cuda
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Now it was time to try a model.  I decided to start off with a small one that would fit onto
my GPU without any quantisation, just to work out any bugs.  I needed the model
to be in GGUF format (that being what Llama.cpp uses).  GGUF stores tensors and metadata --
the actual architectures of the LLMs Llama.cpp supports are baked in to the tool's
source -- but, of course, it supports Qwen3, and so a nice simple non-MoE small
model that looked like it would fit onto my GPU was &lt;a href="https://huggingface.co/Qwen/Qwen3-4B-GGUF"&gt;&lt;code&gt;Qwen/Qwen3-4B-GGUF&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The help page for that model gave this example command to run it:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;./llama-cli -hf Qwen/Qwen3-4B-GGUF:Q8_0 --jinja --color -ngl 99 -fa -sm row \&lt;/span&gt;
&lt;span class="go"&gt;    --temp 0.6 --top-k 20 --top-p 0.95 --min-p 0 --presence-penalty 1.5 \&lt;/span&gt;
&lt;span class="go"&gt;    -c 40960 -n 32768 --no-context-shift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That was out-of-date and gave various errors (which was fair enough, the model being
from late December 2025 -- all of seven months old!).  The fixes were simple enough:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;llama-cli -hf Qwen/Qwen3-4B-GGUF:Q8_0 --jinja --color on -ngl 99 -fa on -sm row \&lt;/span&gt;
&lt;span class="go"&gt;    --temp 0.6 --top-k 20 --top-p 0.95 --min-p 0 --presence-penalty 1.5 \&lt;/span&gt;
&lt;span class="go"&gt;    -c 40960 -n 32768 --no-context-shift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That is, &lt;code&gt;--color&lt;/code&gt; and &lt;code&gt;-fa&lt;/code&gt; now needed an &lt;code&gt;on&lt;/code&gt; argument.&lt;/p&gt;

&lt;p&gt;I kicked it off, and got an error:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;Downloading Qwen3-4B-Q8_0.gguf ───────────────────────────────────── 100%&lt;/span&gt;


&lt;span class="go"&gt;Loading model... \1.54.635.084 E llama_model_load: error loading model: device Vulkan0 does not support split buffers&lt;/span&gt;
&lt;span class="go"&gt;1.54.635.088 E llama_model_load_from_file_impl: failed to load model&lt;/span&gt;
&lt;span class="go"&gt;1.54.635.110 E common_fit_params: encountered an error while trying to fit params to free device memory: failed to load model&lt;/span&gt;
&lt;span class="go"&gt;|1.54.741.517 E llama_model_load: error loading model: device Vulkan0 does not support split buffers&lt;/span&gt;
&lt;span class="go"&gt;1.54.741.519 E llama_model_load_from_file_impl: failed to load model&lt;/span&gt;
&lt;span class="go"&gt;1.54.741.521 E cmn  common_init_: failed to load model &amp;#39;/home/giles/.cache/huggingface/hub/models--Qwen--Qwen3-4B-GGUF/snapshots/bc640142c66e1fdd12af0bd68f40445458f3869b/Qwen3-4B-Q8_0.gguf&amp;#39;&lt;/span&gt;
&lt;span class="go"&gt;1.54.741.523 E srv    load_model: failed to load model, &amp;#39;/home/giles/.cache/huggingface/hub/models--Qwen--Qwen3-4B-GGUF/snapshots/bc640142c66e1fdd12af0bd68f40445458f3869b/Qwen3-4B-Q8_0.gguf&amp;#39;&lt;/span&gt;
&lt;span class="go"&gt;llama_server exited with code 1&lt;/span&gt;
&lt;span class="go"&gt;1.54.742.424 E srv  llama_server: exiting due to model loading error&lt;/span&gt;
&lt;span class="go"&gt;Error: the server exited before becoming ready&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That seemed strange.  The instructions I'd followed on the Arch Wiki page were the
ones to install Llama.cpp for CUDA, but it was referring to &lt;code&gt;device Vulkan0&lt;/code&gt;.  Vulkan
is an open GPU-programming language -- it fits into the stack in essentially the same
place as CUDA.  As always seems to happen with these things, while the open platform
is, well, open, and runs on more platforms than the closed one, it is somewhat slower
and buggier.&lt;/p&gt;

&lt;p&gt;So, why did I have a Vulkan version?  Looking
at the &lt;a href="https://wiki.archlinux.org/title/Talk:Llama.cpp"&gt;talk page for the package&lt;/a&gt;
was enlightening: user &lt;code&gt;Gotoro&lt;/code&gt; wrote:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Hello. The article only provides the &lt;code&gt;llama.cpp-vulkan&lt;/code&gt; package for GPU inference,
  even though on AUR there is a CUDA package available from the same submitter [1]&lt;/p&gt;
  
  &lt;p&gt;I must add that the CUDA package is "Flagged out-of-date (2025-12-22)"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;...and got a reply from &lt;code&gt;SH3NG1UN&lt;/code&gt; saying:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Hi. The reason CUDA was not added is that Vulkan solution is general, performant
  enough for most hardware setup, and I personally believe that is what edge AI
  deployment should look like - if your software stack is capable of gaming, it is capable of AI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They went on to say that they would add CUDA support, but they hit an issue because the
AUR version that &lt;code&gt;Gotoro&lt;/code&gt; had referred to no longer had an active maintainer.  For non-Arch
users, the AUR is essentially a repository of non-official, community maintained
packages.&lt;/p&gt;

&lt;p&gt;So, it looked like I had a Vulkan stack installed.  Could I install the CUDA one from
the AUR instead?&lt;/p&gt;

&lt;p&gt;Over the
last few months, there have been security issues with the AUR, where bad actors have
picked up ownership of unmaintained packages and put nasty stuff into them --
exfiltration code to steal things like SSH keys and crypto wallets, for example.&lt;/p&gt;

&lt;p&gt;As a result, I've been pretty cautious about adding on new AUR dependencies -- and this specific
one having been unmaintained for a while scared me.&lt;/p&gt;

&lt;p&gt;I decided that I would stick with Vulkan for now; if I wanted to try with CUDA, I'd
compile it myself from source later on.&lt;/p&gt;

&lt;p&gt;So, that left the error message:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;device Vulkan0 does not support split buffers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I decided to break down the command line step by step, as running random stuff you
copied from the Internet is rarely a good way to understand what's going on.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;llama-cli -hf Qwen/Qwen3-4B-GGUF:Q8_0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;We're running &lt;code&gt;llama-cli&lt;/code&gt; and specifying a model from Hugging Face -- that's clearly what &lt;code&gt;-hf&lt;/code&gt; means.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;--jinja&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Per the &lt;code&gt;--help&lt;/code&gt;, "whether to use jinja template engine for chat".&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;--color on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;"Colorize output to distinguish prompt and user input from generations"&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;-ngl 99&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This is "max. number of layers to store in VRAM".  For this model, I wanted everything
in VRAM, so felt that "all" would be better there.  &lt;code&gt;99&lt;/code&gt; basically means "all",
at least for any normal-sized model, but I wanted to be explicit.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;-fa on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This switched on Flash Attention, which seemed reasonable.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;-sm row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This was "how to split the model across multiple GPUs".  The error was "does not support split buffers",
so this sounded relevant!  Given that I only have one GPU on my machine, I decided
that &lt;code&gt;-sm none&lt;/code&gt; would be a better option here.&lt;/p&gt;

&lt;p&gt;At this point I suspected I had worked out how to solve the problem, but decided to take a look at the other
options just out of interest.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;--temp 0.6 --top-k 20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;These were clearly the normal sampling parameters that I was used to from creating my own models.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt; --top-p 0.95&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This was an extension to the previous sampling params: in addition to the &lt;code&gt;top-k&lt;/code&gt; stuff (and after it),
limit sampling to the tokens that take up the top 0.95
of the probability distribution.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;--min-p 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This was just disabling the &lt;code&gt;min-p&lt;/code&gt; feature.  min-p discards tokens that are less than
X times the probability of the most likely token.  Apparently using it is a promising
alternative to messing around with &lt;code&gt;top-p&lt;/code&gt; and &lt;code&gt;top-k&lt;/code&gt;.  Rabbit hole alert, let's move on.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;--presence-penalty 1.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Per the docs, this applies a "repeat alpha presence penalty".  That was interesting!
I googled around a bit and found &lt;a href="https://mbrenndoerfer.com/writing/repetition-penalties-language-model-generation"&gt;this page&lt;/a&gt;,
where it became clear that it is a trick to stop models from getting stuck in loops.
Small models are quite prone to that, so it certainly sounded useful.  Perhaps something
to dig into more later, but for now, keeping it seemed perfectly reasonable.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;-c 40960&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That was the "size of the prompt context" according to the help -- that is, the amount
of the context window that can be taken up by stuff going into the model before it
starts generating.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;-n 32768&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...and this was the other side of that equation: the maximum number of tokens to predict.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;--no-context-shift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Another interesting one, "whether to use context shift on infinite text generation".
Context shift, it appears, means that if you keep on generating, it will just drop stuff off the start of the
sequence when it reaches the max context length, so that it can just keep going.  Seems
clever, though there are obvious risks of dropping important bits of context (compacting
the context would be safer, and that's what most real-world workflows seem to do).&lt;/p&gt;

&lt;p&gt;But anyway, we're switching that off, so no harm there.&lt;/p&gt;

&lt;p&gt;So, most of those parameters seemed sensible, but I wanted to set &lt;code&gt;-sm&lt;/code&gt; to &lt;code&gt;none&lt;/code&gt;
so that it didn't try to split things over multiple GPUs when I only had one, and also
set &lt;code&gt;-ngl&lt;/code&gt; to &lt;code&gt;all&lt;/code&gt;
rather than &lt;code&gt;99&lt;/code&gt;, just for tidiness's sake.  That gave this command:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;llama-cli -hf Qwen/Qwen3-4B-GGUF:Q8_0 --jinja --color on -ngl all -fa on -sm none \&lt;/span&gt;
&lt;span class="go"&gt;    --temp 0.6 --top-k 20 --top-p 0.95 --min-p 0 --presence-penalty 1.5 \&lt;/span&gt;
&lt;span class="go"&gt;    -c 40960 -n 32768 --no-context-shift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...and that worked!&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;Loading model...&lt;/span&gt;

&lt;span class="go"&gt;▄▄ ▄▄&lt;/span&gt;
&lt;span class="go"&gt;██ ██&lt;/span&gt;
&lt;span class="go"&gt;██ ██  ▀▀█▄ ███▄███▄  ▀▀█▄    ▄████ ████▄ ████▄&lt;/span&gt;
&lt;span class="go"&gt;██ ██ ▄█▀██ ██ ██ ██ ▄█▀██    ██    ██ ██ ██ ██&lt;/span&gt;
&lt;span class="go"&gt;██ ██ ▀█▄██ ██ ██ ██ ▀█▄██ ██ ▀████ ████▀ ████▀&lt;/span&gt;
&lt;span class="go"&gt;                                    ██    ██&lt;/span&gt;
&lt;span class="go"&gt;                                    ▀▀    ▀▀&lt;/span&gt;

&lt;span class="go"&gt;build      : b10068-571d0d540d&lt;/span&gt;
&lt;span class="go"&gt;model      : Qwen/Qwen3-4B-GGUF:Q8_0&lt;/span&gt;
&lt;span class="go"&gt;ftype      : Q8_0&lt;/span&gt;
&lt;span class="go"&gt;modalities : text&lt;/span&gt;

&lt;span class="go"&gt;available commands:&lt;/span&gt;
&lt;span class="go"&gt;  /exit or Ctrl+C     stop or exit&lt;/span&gt;
&lt;span class="go"&gt;  /regen              regenerate the last response&lt;/span&gt;
&lt;span class="go"&gt;  /clear              clear the chat history&lt;/span&gt;
&lt;span class="go"&gt;  /read &amp;lt;file&amp;gt;        add a text file&lt;/span&gt;
&lt;span class="go"&gt;  /glob &amp;lt;pattern&amp;gt;     add text files using globbing pattern&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I gave it a whirl:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;&amp;gt; What is your name?&lt;/span&gt;

&lt;span class="go"&gt;[Start thinking]&lt;/span&gt;

&lt;span class="go"&gt;Okay, the user is asking for my name. I need to respond with my actual name. Let&lt;/span&gt;
&lt;span class="go"&gt;me recall, my name is Qwen. I should mention that I&amp;#39;m a large language model&lt;/span&gt;
&lt;span class="go"&gt;developed by Alibaba Cloud. Also, I should explain that I can help with various&lt;/span&gt;
&lt;span class="go"&gt;tasks like answering questions, creating content, and more. Keep it friendly and&lt;/span&gt;
&lt;span class="go"&gt;informative. Make sure the response is clear and concise.&lt;/span&gt;
&lt;span class="go"&gt;[End thinking]&lt;/span&gt;

&lt;span class="go"&gt;Hello! My name is Qwen, and I am a large language model developed by Alibaba&lt;/span&gt;
&lt;span class="go"&gt;Cloud. I can help with a wide range of tasks, such as answering questions,&lt;/span&gt;
&lt;span class="go"&gt;creating content, and more. How can I assist you today? 😊&lt;/span&gt;

&lt;span class="go"&gt;[ Prompt: 32.7 t/s | Generation: 122.8 t/s ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Looking good!  Time to try our MoE.&lt;/p&gt;

&lt;h2 id="trying-out-qwen36-35b-a3b-the-first-quant"&gt;Trying out &lt;code&gt;Qwen3.6-35B-A3B&lt;/code&gt; -- the first quant&lt;/h2&gt;

&lt;p&gt;The most popular quants I noticed for this model were &lt;a href="https://unsloth.ai/"&gt;Unsloth&lt;/a&gt;'s,
which makes sense -- they're a well-known organisation.  They publish their models to Hugging Face, and the model I wanted
was there as &lt;a href="https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF"&gt;&lt;code&gt;unsloth/Qwen3.6-35B-A3B-GGUF&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I wasn't sure which of the various 4-bit quants to use -- as I said earlier, there
were four of them -- but &lt;a href="https://www.reddit.com/r/LocalLLaMA/comments/1t49pqu/comment/ok1if2y/"&gt;this Reddit commenter&lt;/a&gt;
was using &lt;code&gt;UD-Q4_K_XL&lt;/code&gt;, so I decided to start with that, just to see what happened.&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;giles@perry:~ $ &lt;/span&gt;llama-cli&lt;span class="w"&gt; &lt;/span&gt;-hf&lt;span class="w"&gt; &lt;/span&gt;unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--jinja&lt;span class="w"&gt; &lt;/span&gt;--color&lt;span class="w"&gt; &lt;/span&gt;on&lt;span class="w"&gt; &lt;/span&gt;-ngl&lt;span class="w"&gt; &lt;/span&gt;all&lt;span class="w"&gt; &lt;/span&gt;-fa&lt;span class="w"&gt; &lt;/span&gt;on&lt;span class="w"&gt; &lt;/span&gt;-sm&lt;span class="w"&gt; &lt;/span&gt;none&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--temp&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.6&lt;span class="w"&gt; &lt;/span&gt;--top-k&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--top-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.95&lt;span class="w"&gt; &lt;/span&gt;--min-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--presence-penalty&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;.5&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;40960&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-n&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;32768&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--no-context-shift
&lt;span class="go"&gt;Downloading mmproj-BF16.gguf ─────────────────────────────────────── 100%&lt;/span&gt;
&lt;span class="go"&gt;Downloading Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf ──────────────────────── 100%&lt;/span&gt;


&lt;span class="go"&gt;Loading model... -ggml_vulkan: Device memory allocation of size 260149504 failed.&lt;/span&gt;
&lt;span class="go"&gt;ggml_vulkan: vk::Device::allocateMemory: ErrorOutOfDeviceMemory&lt;/span&gt;
&lt;span class="go"&gt;9.56.395.340 E ggml_gallocr_reserve_n_impl: failed to allocate Vulkan0 buffer of size 260149504&lt;/span&gt;


&lt;span class="go"&gt;▄▄ ▄▄&lt;/span&gt;
&lt;span class="go"&gt;██ ██&lt;/span&gt;
&lt;span class="go"&gt;██ ██  ▀▀█▄ ███▄███▄  ▀▀█▄    ▄████ ████▄ ████▄&lt;/span&gt;
&lt;span class="go"&gt;██ ██ ▄█▀██ ██ ██ ██ ▄█▀██    ██    ██ ██ ██ ██&lt;/span&gt;
&lt;span class="go"&gt;██ ██ ▀█▄██ ██ ██ ██ ▀█▄██ ██ ▀████ ████▀ ████▀&lt;/span&gt;
&lt;span class="go"&gt;                                    ██    ██&lt;/span&gt;
&lt;span class="go"&gt;                                    ▀▀    ▀▀&lt;/span&gt;

&lt;span class="go"&gt;build      : b10068-571d0d540d&lt;/span&gt;
&lt;span class="go"&gt;model      : unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL&lt;/span&gt;
&lt;span class="go"&gt;ftype      : Q4_K - Medium&lt;/span&gt;
&lt;span class="go"&gt;modalities : text, vision, video&lt;/span&gt;

&lt;span class="go"&gt;available commands:&lt;/span&gt;
&lt;span class="go"&gt;  /exit or Ctrl+C     stop or exit&lt;/span&gt;
&lt;span class="go"&gt;  /regen              regenerate the last response&lt;/span&gt;
&lt;span class="go"&gt;  /clear              clear the chat history&lt;/span&gt;
&lt;span class="go"&gt;  /read &amp;lt;file&amp;gt;        add a text file&lt;/span&gt;
&lt;span class="go"&gt;  /glob &amp;lt;pattern&amp;gt;     add text files using globbing pattern&lt;/span&gt;
&lt;span class="go"&gt;  /image &amp;lt;file&amp;gt;       add an image file&lt;/span&gt;
&lt;span class="go"&gt;  /video &amp;lt;file&amp;gt;       add a video file&lt;/span&gt;



&lt;span class="go"&gt;&amp;gt; Hello, who are you?&lt;/span&gt;

&lt;span class="go"&gt;[Start thinking]&lt;/span&gt;

&lt;span class="go"&gt;Here&amp;#39;s a thinking process:&lt;/span&gt;

&lt;span class="go"&gt;1.  **Analyze User Input:**&lt;/span&gt;
&lt;span class="go"&gt;   - User says: &amp;quot;Hello, who are you?&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;   - This is a simple greeting and identity question.&lt;/span&gt;

&lt;span class="go"&gt;2.  **Identify Key Requirements:**&lt;/span&gt;
&lt;span class="go"&gt;   - Acknowledge the greeting&lt;/span&gt;
&lt;span class="go"&gt;   - State my identity clearly and concisely&lt;/span&gt;
&lt;span class="go"&gt;   - Mention my capabilities/purpose briefly&lt;/span&gt;
&lt;span class="go"&gt;   - Keep it friendly and helpful&lt;/span&gt;

&lt;span class="go"&gt;3.  **Formulate Response (Internal Refinement):**&lt;/span&gt;
&lt;span class="go"&gt;   - &amp;quot;Hello! I&amp;#39;m Qwen, a large language model developed by Alibaba Group&amp;#39;s&lt;/span&gt;
&lt;span class="go"&gt;     Tongyi Lab. I&amp;#39;m here to help with answering questions, writing, coding,&lt;/span&gt;
&lt;span class="go"&gt;     analysis, and much more. How can I assist you today?&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;   - Check against guidelines: Matches identity, concise, friendly, offers help.&lt;/span&gt;

&lt;span class="go"&gt;4.  **Final Output Generation:** (matches the refined version)✅&lt;/span&gt;
&lt;span class="go"&gt;[End thinking]&lt;/span&gt;

&lt;span class="go"&gt;Hello! I&amp;#39;m Qwen, a large language model developed by Alibaba Group&amp;#39;s Tongyi Lab.&lt;/span&gt;
&lt;span class="go"&gt;I&amp;#39;m here to help with answering questions, writing, coding, analysis,&lt;/span&gt;
&lt;span class="go"&gt;brainstorming, and much more. How can I assist you today?&lt;/span&gt;

&lt;span class="go"&gt;[ Prompt: 9.8 t/s | Generation: 111.4 t/s ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That warning at the start was a bit concerning:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;Loading model... -ggml_vulkan: Device memory allocation of size 260149504 failed.&lt;/span&gt;
&lt;span class="go"&gt;ggml_vulkan: vk::Device::allocateMemory: ErrorOutOfDeviceMemory&lt;/span&gt;
&lt;span class="go"&gt;9.56.395.340 E ggml_gallocr_reserve_n_impl: failed to allocate Vulkan0 buffer of size 260149504&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...and I noticed that in &lt;code&gt;nvtop&lt;/code&gt;, my VRAM was maxed out.&lt;/p&gt;

&lt;p&gt;I decided to remove the &lt;code&gt;-c 40960 -n 32768&lt;/code&gt; from the command line; these were setting
a specific context length, both for the prompt and for generation.  Llama.cpp can
work out what context length it can fit into VRAM on its own if you don't
specify them, and this auto-fit felt like a good idea given that I was trying to jam
a 22.4 GiB model into a 24 GiB GPU, and had X and various other apps running on the
card at the same time.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gp"&gt;giles@perry:~ $ &lt;/span&gt;llama-cli&lt;span class="w"&gt; &lt;/span&gt;-hf&lt;span class="w"&gt; &lt;/span&gt;unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--jinja&lt;span class="w"&gt; &lt;/span&gt;--color&lt;span class="w"&gt; &lt;/span&gt;on&lt;span class="w"&gt; &lt;/span&gt;-ngl&lt;span class="w"&gt; &lt;/span&gt;all&lt;span class="w"&gt; &lt;/span&gt;-fa&lt;span class="w"&gt; &lt;/span&gt;on&lt;span class="w"&gt; &lt;/span&gt;-sm&lt;span class="w"&gt; &lt;/span&gt;none&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--temp&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.6&lt;span class="w"&gt; &lt;/span&gt;--top-k&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--top-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.95&lt;span class="w"&gt; &lt;/span&gt;--min-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--presence-penalty&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;.5&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--no-context-shift


&lt;span class="go"&gt;Loading model...&lt;/span&gt;

&lt;span class="go"&gt;▄▄ ▄▄&lt;/span&gt;
&lt;span class="go"&gt;██ ██&lt;/span&gt;
&lt;span class="go"&gt;██ ██  ▀▀█▄ ███▄███▄  ▀▀█▄    ▄████ ████▄ ████▄&lt;/span&gt;
&lt;span class="go"&gt;██ ██ ▄█▀██ ██ ██ ██ ▄█▀██    ██    ██ ██ ██ ██&lt;/span&gt;
&lt;span class="go"&gt;██ ██ ▀█▄██ ██ ██ ██ ▀█▄██ ██ ▀████ ████▀ ████▀&lt;/span&gt;
&lt;span class="go"&gt;                                    ██    ██&lt;/span&gt;
&lt;span class="go"&gt;                                    ▀▀    ▀▀&lt;/span&gt;

&lt;span class="go"&gt;build      : b10068-571d0d540d&lt;/span&gt;
&lt;span class="go"&gt;model      : unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL&lt;/span&gt;
&lt;span class="go"&gt;ftype      : Q4_K - Medium&lt;/span&gt;
&lt;span class="go"&gt;modalities : text, vision, video&lt;/span&gt;

&lt;span class="go"&gt;available commands:&lt;/span&gt;
&lt;span class="go"&gt;  /exit or Ctrl+C     stop or exit&lt;/span&gt;
&lt;span class="go"&gt;  /regen              regenerate the last response&lt;/span&gt;
&lt;span class="go"&gt;  /clear              clear the chat history&lt;/span&gt;
&lt;span class="go"&gt;  /read &amp;lt;file&amp;gt;        add a text file&lt;/span&gt;
&lt;span class="go"&gt;  /glob &amp;lt;pattern&amp;gt;     add text files using globbing pattern&lt;/span&gt;
&lt;span class="go"&gt;  /image &amp;lt;file&amp;gt;       add an image file&lt;/span&gt;
&lt;span class="go"&gt;  /video &amp;lt;file&amp;gt;       add a video file&lt;/span&gt;



&lt;span class="go"&gt;&amp;gt; Hello, who are you?&lt;/span&gt;

&lt;span class="go"&gt;[Start thinking]&lt;/span&gt;

&lt;span class="go"&gt;Here&amp;#39;s a thinking process:&lt;/span&gt;

&lt;span class="go"&gt;1.  **Analyze User Input:**&lt;/span&gt;
&lt;span class="go"&gt;   - User says: &amp;quot;Hello, who are you?&amp;quot;&lt;/span&gt;
&lt;span class="go"&gt;   - This is a simple greeting and identity question.&lt;/span&gt;

&lt;span class="go"&gt;2.  **Identify Key Requirements:**&lt;/span&gt;
&lt;span class="go"&gt;   - Acknowledge the greeting&lt;/span&gt;
&lt;span class="go"&gt;   - State my identity clearly&lt;/span&gt;
&lt;span class="go"&gt;   - Keep it concise and friendly&lt;/span&gt;
&lt;span class="go"&gt;   - Align with my actual identity (Qwen / Tongyi Qianwen, developed by Alibaba Group&amp;#39;s Tongyi Lab)&lt;/span&gt;

&lt;span class="go"&gt;3.  **Formulate Response:**&lt;/span&gt;
&lt;span class="go"&gt;   - Start with a greeting back&lt;/span&gt;
&lt;span class="go"&gt;   - State name/identity: I&amp;#39;m Qwen (or Tongyi Qianwen), a large language model developed by Alibaba Group&amp;#39;s Tongyi Lab&lt;/span&gt;
&lt;span class="go"&gt;   - Offer assistance&lt;/span&gt;
&lt;span class="go"&gt;   - Keep it natural and concise&lt;/span&gt;

&lt;span class="go"&gt;   Draft: &amp;quot;Hello! I&amp;#39;m Qwen, a large language model developed by Alibaba Group&amp;#39;s Tongyi Lab. How can I assist you today?&amp;quot;&lt;/span&gt;

&lt;span class="go"&gt;4.  **Self-Correction/Verification:**&lt;/span&gt;
&lt;span class="go"&gt;   - Matches identity guidelines? Yes.&lt;/span&gt;
&lt;span class="go"&gt;   - Concise and direct? Yes.&lt;/span&gt;
&lt;span class="go"&gt;   - No extra fluff? Yes.&lt;/span&gt;
&lt;span class="go"&gt;   - Ready.✅&lt;/span&gt;
&lt;span class="go"&gt;[End thinking]&lt;/span&gt;

&lt;span class="go"&gt;Hello! I&amp;#39;m Qwen, a large language model developed by Alibaba Group&amp;#39;s Tongyi Lab. How can I assist you today?&lt;/span&gt;

&lt;span class="go"&gt;[ Prompt: 50.2 t/s | Generation: 122.0 t/s ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;No error, it wasn't maxing out VRAM, and generation and prompt processing were noticeably faster.  Perhaps the first
time around it had offloaded some of the model to the CPU so that it had enough
space on the GPU for the context size I was asking for?&lt;/p&gt;

&lt;p&gt;Still, I wasn't sure how far I could trust those numbers -- the number of tokens
being emitted was quite small. &lt;sup class="footnote-ref" id="fnref-4"&gt;&lt;a href="#fn-4"&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;I wanted a prompt that would make the model generate lots of tokens.  Now, it was
a thinking model, so asking it a difficult question seemed like a good way to do
that.&lt;/p&gt;

&lt;p&gt;I decided to use &lt;a href="https://x.com/emollick/status/1765136992176644281"&gt;Ethan Mollick's "Lem test"&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Compose a poem -- a poem about a haircut! But lofty, tragic, timeless, full of
  love, treachery, retribution, quiet heroism in the face of certain doom! Six
  lines, cleverly rhymed, and every word beginning with the letter S!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;More about that in the &lt;a href="#appendix-the-lem-test"&gt;appendix below&lt;/a&gt;. &lt;sup class="footnote-ref" id="fnref-5"&gt;&lt;a href="#fn-5"&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately, when I gave it that prompt, it thought for a while, but crapped out
during the thinking process, while it was trying to generate rhymes.&lt;/p&gt;

&lt;p&gt;Now, I didn't know what the context length was, either for the prompt or for generation.
But I could be fairly sure that they were less than 40,960 and 32,768 respectively, given
that the model did not fit into VRAM when I specified those limits, but did when I asked
Llama.cpp to auto-fit.&lt;/p&gt;

&lt;p&gt;I couldn't find a quick and easy way of asking &lt;code&gt;llama-cli&lt;/code&gt; what lengths it had fit
things to, but when I tried running it with the &lt;code&gt;-v&lt;/code&gt; option for verbose logging, I
got something.  Actually, I got a &lt;em&gt;lot&lt;/em&gt; of something -- thousands of lines of output,
with incredibly detailed messages about everything Llama.cpp was doing.&lt;/p&gt;

&lt;p&gt;But there were three blocks starting "constructing llama_context".&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;...&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.283 I llama_context: constructing llama_context&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.285 I llama_context: n_seq_max     = 1&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.285 I llama_context: n_ctx         = 262144&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.285 I llama_context: n_ctx_seq     = 262144&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.285 I llama_context: n_batch       = 2048&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.285 I llama_context: n_ubatch      = 512&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.285 I llama_context: causal_attn   = 1&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.286 I llama_context: flash_attn    = enabled&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.286 I llama_context: kv_unified    = false&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.287 I llama_context: freq_base     = 10000000.0&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.287 I llama_context: freq_scale    = 1&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.287 I llama_context: n_rs_seq      = 0&lt;/span&gt;
&lt;span class="go"&gt;0.00.809.287 I llama_context: n_outputs_max = 1&lt;/span&gt;
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.515 I llama_context: constructing llama_context&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: n_seq_max     = 1&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: n_ctx         = 4096&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: n_ctx_seq     = 4096&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: n_batch       = 2048&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: n_ubatch      = 512&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: causal_attn   = 1&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.517 I llama_context: flash_attn    = enabled&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.518 I llama_context: kv_unified    = false&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.518 I llama_context: freq_base     = 10000000.0&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.519 I llama_context: freq_scale    = 1&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.519 I llama_context: n_rs_seq      = 0&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.519 I llama_context: n_outputs_max = 1&lt;/span&gt;
&lt;span class="go"&gt;0.01.029.519 I llama_context: n_ctx_seq (4096) &amp;lt; n_ctx_train (262144) -- the full capacity of the model will not be utilized&lt;/span&gt;
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.122 I llama_context: constructing llama_context&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.123 I llama_context: n_seq_max     = 1&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.123 I llama_context: n_ctx         = 4096&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.123 I llama_context: n_ctx_seq     = 4096&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.123 I llama_context: n_batch       = 2048&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.123 I llama_context: n_ubatch      = 512&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.123 I llama_context: causal_attn   = 1&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.124 I llama_context: flash_attn    = enabled&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.124 I llama_context: kv_unified    = false&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.126 I llama_context: freq_base     = 10000000.0&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.126 I llama_context: freq_scale    = 1&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.126 I llama_context: n_rs_seq      = 0&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.126 I llama_context: n_outputs_max = 1&lt;/span&gt;
&lt;span class="go"&gt;0.06.393.127 I llama_context: n_ctx_seq (4096) &amp;lt; n_ctx_train (262144) -- the full capacity of the model will not be utilized&lt;/span&gt;
&lt;span class="go"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That made the issue reasonably clear.  I assumed that the last message was the
definitive one, and it was saying that my context length -- both prompt and generation
combined -- was 4,096 tokens.  I had &lt;code&gt;--no-context-shift&lt;/code&gt; set, so that was a hard
limit.  And 4k or so tokens fit with the amount of stuff that I would have expected
to be in the model's context window at the time it crapped out.&lt;/p&gt;

&lt;p&gt;I decided to see what would happen if I tried a smaller quant.&lt;/p&gt;

&lt;h2 id="trying-out-qwen36-35b-a3b-the-second-quant"&gt;Trying out &lt;code&gt;Qwen3.6-35B-A3B&lt;/code&gt; -- the second quant&lt;/h2&gt;

&lt;p&gt;Hugging Face said
(and &lt;code&gt;nvtop&lt;/code&gt; agreed) that this one, &lt;code&gt;UD-Q4_K_XL&lt;/code&gt;, was about 22.4 GiB in size.  But
&lt;code&gt;UD-IQ4_NL_XL&lt;/code&gt; was shown on HF as using 19.5 GiB, so I decided to give that one a go next.&lt;/p&gt;

&lt;p&gt;I ran it with the &lt;code&gt;-v&lt;/code&gt; option, and again looked at the last "constructing llama_context" block:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;42.33.956.367 I llama_context: constructing llama_context&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.369 I llama_context: n_seq_max     = 1&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.369 I llama_context: n_ctx         = 93952&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.369 I llama_context: n_ctx_seq     = 93952&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.369 I llama_context: n_batch       = 2048&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.369 I llama_context: n_ubatch      = 512&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.369 I llama_context: causal_attn   = 1&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.370 I llama_context: flash_attn    = enabled&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.370 I llama_context: kv_unified    = false&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.373 I llama_context: freq_base     = 10000000.0&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.373 I llama_context: freq_scale    = 1&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.373 I llama_context: n_rs_seq      = 0&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.373 I llama_context: n_outputs_max = 1&lt;/span&gt;
&lt;span class="go"&gt;42.33.956.374 I llama_context: n_ctx_seq (93952) &amp;lt; n_ctx_train (262144) -- the full capacity of the model will not be utilized&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;93,952 tokens -- still not the full amount, but not at all bad!  I decided to give
it a whirl, restarted without &lt;code&gt;-v&lt;/code&gt; to avoid getting swamped with debug info, and:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;&amp;gt; Compose a poem -- a poem about a haircut! But lofty, tragic, timeless, full of love, treachery, retribution, quiet heroism in the face of certain doom! Six lines, cleverly rhymed, and every word beginning with the letter S!&lt;/span&gt;

&lt;span class="go"&gt;[Start thinking]&lt;/span&gt;

&lt;span class="go"&gt;Thinking Process:&lt;/span&gt;

&lt;span class="go"&gt;1.  **Deconstruct the Request:**&lt;/span&gt;
&lt;span class="go"&gt;    *   Topic: Haircut.&lt;/span&gt;
&lt;span class="go"&gt;    *   Tone: Lofty, tragic, timeless, full of love, treachery, retribution, quiet heroism in the face of certain doom.&lt;/span&gt;
&lt;span class="go"&gt;    *   Length: Six lines.&lt;/span&gt;
&lt;span class="go"&gt;    *   Rhyme: Cleverly rhymed.&lt;/span&gt;
&lt;span class="go"&gt;    *   Constraint: Every word must begin with the letter &amp;#39;S&amp;#39;.&lt;/span&gt;

&lt;span class="go"&gt;2.  **Brainstorming Vocabulary (S-words):**&lt;/span&gt;
&lt;span class="go"&gt;    *   *Nouns:* Shears, scissors, scalp, skin, strand, silk, sorrow, shame, secret, sister, son, star, storm, shadow, silence, sacrifice, splendor, silver, sword, slaughter, severance, sorrow, sigh.&lt;/span&gt;
&lt;span class="go"&gt;    *   *Verbs:* Sever, slice, shatter, shed, sink, slip, slide, sing, scream, suffer, survive, surrender, slay, snatch, steal, spin, sway, smile, stare, stand, seek, save, seal, send, set, show, shut, sing, sit, sleep, slip, slow, snap, sneak, soar, speak, spend, spill, split, spoil, spread, spring, squeeze, stand, start, stay, steal, stem, stir, stop, store, stretch, strike, string, strip, strive, stroke, strong, study, suffer, suggest, suit, sum, sun, surprise, surround, survey, survive, suspect, sustain, swallow, swear, sweep, sweet, swell, swim, swing, switch, symbol, sympathize, system.&lt;/span&gt;
&lt;span class="go"&gt;    *   *Adjectives/Adverbs:* Silent, silver, sad, severe, soft, sharp, slick, slim, slow, small, strange, strong, sweet, swift, sure, sudden, supreme, sacred, secret, solemn, sorrowful, splendid, stark, still, stern, straight, true.&lt;/span&gt;

&lt;span class="go"&gt;3.  **Drafting - Attempt 1 (Focusing on Narrative and Tone):**&lt;/span&gt;
&lt;span class="go"&gt;    *   *I:* Need love, treachery, retribution, heroism.&lt;/span&gt;
&lt;span class="go"&gt;    *   *I:* Lover betrays? Or self-sacrifice? &amp;quot;Quiet heroism in the face of certain doom&amp;quot; suggests a sacrifice. Maybe the hair is the doom? Or the haircut is the retribution?&lt;/span&gt;
&lt;span class="go"&gt;    *   *Concept:* A queen/heroine sacrifices her beauty/hair to save someone or fulfill a vow, betrayed by the scissors or the situation.&lt;/span&gt;
&lt;span class="go"&gt;    *   *Lines:*&lt;/span&gt;
&lt;span class="go"&gt;        *   Sweet shears slice silver strands... (Too simple).&lt;/span&gt;
&lt;span class="go"&gt;        *   She saw sharp steel... (Better).&lt;/span&gt;
&lt;span class="go"&gt;        *   Secret sorrow strikes...&lt;/span&gt;
&lt;span class="go"&gt;        *   Let&amp;#39;s try rhymes. AABBCC or ABABCC or ABCABC? &amp;quot;Cleverly rhymed&amp;quot; might suggest internal rhyme or a complex scheme, but with 6 lines and all S-words, the rhyme scheme needs to be tight. Maybe AAAA AA? Or AAB BCC?&lt;/span&gt;

&lt;span class="go"&gt;[LLM pondering snipped]&lt;/span&gt;

&lt;span class="go"&gt;        *   Result matches constraints.&lt;/span&gt;
&lt;span class="go"&gt;        *   Six lines. All S words. Rhyme A B C B C C (Share/Snare, Soul/Sole, Son/Sun/Son).&lt;/span&gt;
&lt;span class="go"&gt;        *   Share/Snare is exact.&lt;/span&gt;
&lt;span class="go"&gt;        *   Soul/Sole is exact (in many dialects, or slant in others, but usually accepted as rhyme with Sold/Soul/Sole cluster).&lt;/span&gt;
&lt;span class="go"&gt;        *   Actually, Soul/Sole can be distinct.&lt;/span&gt;
&lt;span class="go"&gt;        *   Soul is /soʊl/. Sole is /soʊl/. They are homophones in many accents.&lt;/span&gt;
&lt;span class="go"&gt;        *   So Soul/Sole is exact rhyme.&lt;/span&gt;
&lt;span class="go"&gt;        *   Son/Sun is slant.&lt;/span&gt;
&lt;span class="go"&gt;        *   This is a strong poem.&lt;/span&gt;
&lt;span class="go"&gt;[End thinking]&lt;/span&gt;

&lt;span class="go"&gt;Sneering shears shred sacred share,&lt;/span&gt;
&lt;span class="go"&gt;Silent steel sells silver soul;&lt;/span&gt;
&lt;span class="go"&gt;Sister&amp;#39;s snare spares son;&lt;/span&gt;
&lt;span class="go"&gt;Sweet sign seals sole;&lt;/span&gt;
&lt;span class="go"&gt;Still star saves sun;&lt;/span&gt;
&lt;span class="go"&gt;Secret sign seals son.&lt;/span&gt;

&lt;span class="go"&gt;[ Prompt: 183.3 t/s | Generation: 119.6 t/s ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;It's hardly Shakespeare, and the rhyming is both odd (ABCBCC) and unadventurous ("son", "sun", "son")
but for a 35B parameter model with 3B active (of which 1B
are used up by the embeddings and the output head), it's pretty bloody impressive!
And every word did indeed start with an "S".&lt;/p&gt;

&lt;p&gt;So, now I had the model running.  I had a context length of 93,952 tokens, was generating
about 120 tokens/second, and processing the prompt at about 180 tokens/second.&lt;/p&gt;

&lt;p&gt;The question was, could I get the full context length in there?  I knew that the answer was
yes from previous reading, but it was time to try it out.&lt;/p&gt;

&lt;h2 id="the-ncmoe-flag"&gt;The &lt;code&gt;-ncmoe&lt;/code&gt; flag&lt;/h2&gt;

&lt;p&gt;Llama.cpp has a command-line flag &lt;code&gt;-ncmoe&lt;/code&gt;, or in its long form, &lt;code&gt;--n-cpu-moe&lt;/code&gt;.  As its
name suggests, it's designed for mixture of experts models, and it works by offloading
the FFN for a specified number of Transformers layers to the CPU.&lt;/p&gt;

&lt;p&gt;Intuitively it's surprising that that doesn't cause performance to completely crash.
After all, aren't we running our models on the GPU because it's so much better at
matrix multiplications than the CPU?&lt;/p&gt;

&lt;p&gt;And it's true, the GPU is best.  But if it's a question of where to use its superiority
in matmuls, then keeping the attention layers on the GPU and offloading just the FFNs to the CPU is
the least harmful way to do it.  Obviously you do lose performance, but not as much
as you would if you offloaded attention.&lt;/p&gt;

&lt;p&gt;I started a binary chop.  From the logs, I could see that the model had
40 layers:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;0.00.804.467 I print_info: n_layer               = 40&lt;/span&gt;
&lt;span class="go"&gt;0.00.804.467 I print_info: n_layer_all           = 40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;so I ran this to offload the first 20 to the CPU:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;llama-cli --log-file /tmp/log --log-colors off -hf unsloth/Qwen3.6-35B-A3B-GGUF:UD-IQ4_NL_XL \&lt;/span&gt;
&lt;span class="go"&gt;    -ncmoe 20 --jinja --color on -ngl all -fa on -sm none \&lt;/span&gt;
&lt;span class="go"&gt;    --temp 0.6 --top-k 20 --top-p 0.95 --min-p 0 --presence-penalty 1.5&lt;/span&gt;
&lt;span class="go"&gt;    --no-context-shift&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That gave me a context length of 262,144 -- the native context length!
I had 16944MiB used on the GPU, 10075MiB on the CPU, and I got 51.4 tokens per second
for generation, and 64.4 tokens per second for the prompt.&lt;/p&gt;

&lt;p&gt;Then I tried various other values, but the process got dull quickly.  For each one, I
was doing this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start &lt;code&gt;llama-cli&lt;/code&gt; with the &lt;code&gt;-ncmoe&lt;/code&gt; value I wanted and with &lt;code&gt;-v&lt;/code&gt;.  Use the logs
to find the context length, and note down the VRAM/RAM usage.&lt;/li&gt;
&lt;li&gt;Restart it without &lt;code&gt;-v&lt;/code&gt;, paste in my prompt, and wait until it either came back or got stuck
in a loop trying to find rhymes (which it did maybe one in every four times).  Note
down tokens per second for the prompt and generation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At maybe four minutes for each and an error-prone process, this was ripe for automation.&lt;/p&gt;

&lt;p&gt;Additionally, something about those numbers for the prompt-processing tokens per second
was niggling me.  They seemed &lt;em&gt;very&lt;/em&gt; slow.  You'd expect an LLM in a generation harness
like this to process the prompt &lt;em&gt;much&lt;/em&gt; faster than it could generate, but I was getting
numbers that roughly matched the generation speed.  I figured that my prompt was so short that
the numbers I was getting were being dominated by overhead.  I needed a longer one.&lt;/p&gt;

&lt;h2 id="the-auto-sweep"&gt;The auto-sweep&lt;/h2&gt;

&lt;p&gt;Llama.cpp's &lt;code&gt;llama-server&lt;/code&gt; command exposes an API that tells you what sequence length
it's running with, and takes pretty much the same parameters as &lt;code&gt;llama-cli&lt;/code&gt;, so I gave
Claude Fable 5 the command I'd been running and asked it to write a script to sweep
over CPU offload layer counts from zero to 40, and to store the numbers I was interested
in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The number of layers offloaded&lt;/li&gt;
&lt;li&gt;The context length we got&lt;/li&gt;
&lt;li&gt;The VRAM and system RAM (RSS) usage&lt;/li&gt;
&lt;li&gt;The size of the prompt we were providing&lt;/li&gt;
&lt;li&gt;The tokens per second at which the prompt was processed&lt;/li&gt;
&lt;li&gt;How much was generated&lt;/li&gt;
&lt;li&gt;Generation tokens/second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also added onto that some code to expand the prompt so that I could get better numbers for
the tokens per second for that part of the processing.&lt;/p&gt;

&lt;p&gt;Now, &lt;code&gt;llama-cli&lt;/code&gt; uses a chat template by default; the endpoint that Claude had chosen
for &lt;code&gt;llama-server&lt;/code&gt; does not -- it's a raw completion engine.  This actually worked to my
advantage.  From the &lt;code&gt;llama-cli&lt;/code&gt;
logs I found the template it had been using, which was something like this:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;&amp;#39;&amp;lt;|im_start|&amp;gt;system&lt;/span&gt;
&lt;span class="go"&gt;You are a helpful assistant&amp;lt;|im_end|&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;&amp;lt;|im_start|&amp;gt;user&lt;/span&gt;
&lt;span class="go"&gt;Hello&amp;lt;|im_end|&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;&amp;lt;|im_start|&amp;gt;assistant&lt;/span&gt;
&lt;span class="go"&gt;Hi there&amp;lt;|im_end|&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;&amp;lt;|im_start|&amp;gt;user&lt;/span&gt;
&lt;span class="go"&gt;How are you?&amp;lt;|im_end|&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;&amp;lt;|im_start|&amp;gt;assistant&lt;/span&gt;
&lt;span class="go"&gt;&amp;lt;think&amp;gt;&lt;/span&gt;
&lt;span class="go"&gt;&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Because I could easily fake the conversation history, I baked in a template that started with a fake previous interaction where the
user had provided the first two chapters of Jane Austen's &lt;a href="https://www.gutenberg.org/ebooks/1342"&gt;&lt;em&gt;Pride and Prejudice&lt;/em&gt;&lt;/a&gt;
and asked the model for its opinion, it said something like "I like that", and then the user asked
for the Lem test poem.  That bulked things out quite a bit -- as it turned out, to
a prompt of 2,457 tokens.&lt;/p&gt;

&lt;p&gt;I also made it give up after 6144 tokens so that it wouldn't take forever to run,
and kicked it off.  &lt;a href="https://github.com/gpjt/measure-llama-speeds/blob/main/measure_ncmoe_speeds.py"&gt;Here's the code&lt;/a&gt;, and you can see the results
&lt;a href="https://github.com/gpjt/measure-llama-speeds/blob/main/sweep_results/original_vulkan/sweep_results_v1.csv"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The prompt processing numbers were &lt;em&gt;much&lt;/em&gt; better!  With nothing offloaded, I got
a context window of 51,200, a prompt processing rate of 2,787.1 tokens/second, and a generation
rate of 122.4 tokens/second.&lt;/p&gt;

&lt;p&gt;(It was interesting that the context window was smaller than it was when I ran
&lt;code&gt;llama-cli&lt;/code&gt; without &lt;code&gt;-ncmoe&lt;/code&gt;, but I decided that I didn't want to get into the weeds
by digging into that &lt;sup class="footnote-ref" id="fnref-6"&gt;&lt;a href="#fn-6"&gt;6&lt;/a&gt;&lt;/sup&gt;.  Perhaps &lt;code&gt;-ncmoe 0&lt;/code&gt; isn't quite the same as running
without &lt;code&gt;-ncmoe&lt;/code&gt; at all?)&lt;/p&gt;

&lt;p&gt;Anyway, the numbers for other levels of layer-offloading did what you would expect:
as more layers' FFNs went from the GPU to the CPU, the context window expanded until
it hit the model's native context length of 262,144 when 12 layers were offloaded.  And the speed
-- both in prompt processing and generation -- went down.&lt;/p&gt;

&lt;p&gt;I ran it three more times, just to see if there was a lot of noise there, and then
asked Claude Sonnet to slop up a charting script for me (in the same repo as the above
if you're interested) -- and I had some results!&lt;/p&gt;

&lt;h2 id="the-results"&gt;The results&lt;/h2&gt;

&lt;p&gt;Just for people who skipped the details: we're running the &lt;code&gt;UD-IQ4_NL_XL&lt;/code&gt; quant of
&lt;a href="https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF"&gt;&lt;code&gt;unsloth/Qwen3.6-35B-A3B-GGUF&lt;/code&gt;&lt;/a&gt;
on &lt;code&gt;llama-server&lt;/code&gt;, and seeing how its performance changes as we vary the number of
layers offloaded from the GPU to the CPU.&lt;/p&gt;

&lt;p&gt;Firstly, how does the context length change?&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/benchmarking-qwen-3-6-35b-moe-rtx-3090/vulkan-context-length-chart.png" alt="Running Llama.cpp on Vulkan: context length vs layers offloaded to CPU" title="Running Llama.cpp on Vulkan: context length vs layers offloaded to CPU" /&gt;&lt;/p&gt;

&lt;p&gt;You can see that the model's full built-in context length was reached when we had
12 layers offloaded.  But how did that impact performance?&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/benchmarking-qwen-3-6-35b-moe-rtx-3090/vulkan-throughput-chart.png" alt="Running Llama.cpp on Vulkan: throughput vs layers offloaded to CPU" title="Running Llama.cpp on Vulkan: throughput vs layers offloaded to CPU" /&gt;&lt;/p&gt;

&lt;p&gt;You can see that the prompt throughput drops off fairly smoothly as the number of
layers offloaded increased.  What is interesting is those spikes in the generation
tokens/second.  The reason I'd done four runs of this script was because I'd initially
thought they were noise, but you can see they're pretty consistent.&lt;/p&gt;

&lt;p&gt;Claude Fable 5
thinks they may be related to the points at which there are full attention layers
-- the attention system in these Qwen models is complex, with some layers using a faster
system and others using the full attention that my GPT-2 models always use.
That's something to investigate another time, I suspect, if ever.&lt;/p&gt;

&lt;p&gt;It's noticeable that with
12 layers offloaded (the exact number that gave us the full context), the throughput
is somewhat better than at 11, which makes 12 look pretty much like the sweet spot
for running this model with this setup!  From the raw numbers, we can see that there
we had about 66 tokens/second generation, and 607 tokens/second on the prompt.&lt;/p&gt;

&lt;p&gt;Anyway, finally, let's take a look at memory usage -- both VRAM and regular system RAM.
It looks exactly as you'd expect:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/benchmarking-qwen-3-6-35b-moe-rtx-3090/vulkan-memory-chart.png" alt="Running Llama.cpp on Vulkan: memory vs layers offloaded to CPU" title="Running Llama.cpp on Vulkan: memory vs layers offloaded to CPU" /&gt;&lt;/p&gt;

&lt;p&gt;We're using as much VRAM as we can get up to the layer 12 offload point, because
with fewer than that number of layers offloaded, we're grabbing as much space as
we can on top of the amount consumed by the parameters, in order to get the longest
possible context window.  But after that, it drops off smoothly.  And system RAM rises
smoothly as layers are offloaded into it, as expected.&lt;/p&gt;

&lt;p&gt;So, that was it!&lt;/p&gt;

&lt;p&gt;Or was it?&lt;/p&gt;

&lt;h2 id="cuda"&gt;CUDA&lt;/h2&gt;

&lt;p&gt;As I mentioned back at the start of these lab notes, the version of Llama.cpp that's
part of the official OS repositories for Arch uses Vulkan, in order to maximise the
number of platforms it runs on.  And that was the version I'd used.&lt;/p&gt;

&lt;p&gt;The Vulkan version was clearly solid and stable, given
that I'd been able to run a bunch of benchmarks on it.  But you'd expect CUDA to
get performance enhancements and the like first, and in general to be more polished.&lt;/p&gt;

&lt;p&gt;So how would it perform comparatively?&lt;/p&gt;

&lt;p&gt;I didn't want to install the AUR version of the CUDA-based Llama.cpp, as it had been
abandoned previously (though someone appears to have picked it up again now), and bad actors have been taking over abandoned AUR repos and putting
bad stuff in them.&lt;/p&gt;

&lt;p&gt;So out of an abundance of caution, I decided to compile it from source from the
&lt;a href="https://github.com/ggml-org/llama.cpp"&gt;official Llama.cpp repo&lt;/a&gt;.
Their instructions made it almost laughably simple; I cloned it, then ran&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;cmake -B build-cuda -DGGML_CUDA=ON -DGGML_NATIVE=ON&lt;/span&gt;
&lt;span class="go"&gt;cmake --build build-cuda --config Release -j$(nproc)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Less than ten minutes later, I had a working set of binaries.  I tweaked my
benchmarking script to use them instead of the system-installed ones, and got
&lt;a href="https://github.com/gpjt/measure-llama-speeds/blob/main/sweep_results/cuda/sweep_results.csv"&gt;these results&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Charting those, we get this for the context length:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/benchmarking-qwen-3-6-35b-moe-rtx-3090/cuda-context-length-chart.png" alt="Running Llama.cpp on CUDA: context length vs layers offloaded to CPU" title="Running Llama.cpp on CUDA: context length vs layers offloaded to CPU" /&gt;&lt;/p&gt;

&lt;p&gt;So with CUDA, we get the full built-in context length with just 10 layers offloaded,
compared to the 12 we needed with Vulkan.&lt;/p&gt;

&lt;p&gt;And even with nothing offloaded, we got a bigger context window -- 89,600 rather than
Vulkan's 51,200.&lt;/p&gt;

&lt;p&gt;How about performance?&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/benchmarking-qwen-3-6-35b-moe-rtx-3090/cuda-throughput-chart.png" alt="Running Llama.cpp on CUDA: throughput vs layers offloaded to CPU" title="Running Llama.cpp on CUDA: throughput vs layers offloaded to CPU" /&gt;&lt;/p&gt;

&lt;p&gt;With CUDA, we don't get those odd bumps in the generation throughput -- or at least,
if they're there, they're much smaller.  Perhaps Claude's ponderings that I mentioned earlier were off
the mark?  Or perhaps they only impact Vulkan? &lt;sup class="footnote-ref" id="fnref-7"&gt;&lt;a href="#fn-7"&gt;7&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;But the big news on this chart is -- as I had suspected would be the case -- CUDA was noticeably
faster!  With all layers on the GPU, it was getting 139.6 tokens/second generation,
3360.4 tokens/second on the prompt, as compared to around 122 / 2787 for Vulkan, averaging across
my four runs on that platform.   And
with a bigger context window too.&lt;/p&gt;

&lt;p&gt;With ten layers offloaded, CUDA had the full context length of 262,144, and was getting 89.1 / 1153.5,
while Vulkan managed a context length of 233,984, and throughput of 66 / 702.&lt;/p&gt;

&lt;p&gt;Finally, with 12 layers offloaded, both models had the full context length, CUDA
had throughput of 84.9 / 1008.1, and Vulkan had 66 / 607.  And that meant that
we were able to reclaim some VRAM too:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/benchmarking-qwen-3-6-35b-moe-rtx-3090/cuda-memory-chart.png" alt="Running Llama.cpp on CUDA: memory vs layers offloaded to CPU" title="Running Llama.cpp on CUDA: memory vs layers offloaded to CPU" /&gt;&lt;/p&gt;

&lt;p&gt;Advantage: CUDA, I think.&lt;/p&gt;

&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;That was probably just a tad more time and effort than I think my friend expected me
to put into this when he asked an offhand question on WhatsApp.  But I feel that I've
learned a lot of useful stuff in this journey, and while there's a lot of detail in
this post, it didn't actually take up much time to run the experiments.  I have another post on the way that
involves multiple four-day training runs, so I needed to occupy my time somehow...&lt;/p&gt;

&lt;p&gt;Anyway, I hope it's useful to people out there!  Let me know in the comments if it helped you.&lt;/p&gt;

&lt;p&gt;And now, just to finish off, a bonus section on the prompt I was using to get the
model to generate lots of stuff.&lt;/p&gt;

&lt;h2 id="appendix-the-lem-test"&gt;Appendix: the Lem test&lt;/h2&gt;

&lt;p&gt;The prompt I was using for these tests was this:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Compose a poem -- a poem about a haircut! But lofty, tragic, timeless, full of love, treachery, retribution, quiet heroism in the face of certain doom! Six lines, cleverly rhymed, and every word beginning with the letter S!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I like about it as a prompt is not just that it's a particularly silly thing
to ask of an LLM, but it forces models with reasoning enabled to think hard about how
to keep within all of those constraints.  It's basically a short prompt that makes
them generate a lot of tokens -- perfect for benchmarking.&lt;/p&gt;

&lt;p&gt;I stole it from Ethan Mollick.  He's well worth following if you're not
already doing so; here's &lt;a href="https://x.com/emollick"&gt;his X/Twitter profile&lt;/a&gt;, and
&lt;a href="https://www.oneusefulthing.org/"&gt;his Substack&lt;/a&gt;.  He writes
a ton of interesting stuff, but I want to focus here on this particular eval.&lt;/p&gt;

&lt;p&gt;In the science fiction short story "The first sally (A), or Trurl's electronic bard" (included in
the collection &lt;em&gt;The Cyberiad&lt;/em&gt;), Stanisław Lem wrote of two rival engineers.  One of them,
Trurl, has created an electronic poet, and the other, Klapaucius, gives it what he
thinks is an impossible task.  From the English translation of this part
&lt;a href="https://www.goodreads.com/quotes/400182-have-it-compose-a-poem--a-poem-about-a-haircut"&gt;on Goodreads&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;"Have it compose a poem -- a poem about a haircut! But lofty, tragic, timeless,
  full of love, treachery, retribution, quiet heroism in the face of certain
  doom! Six lines, cleverly rhymed, and every word beginning with the letter S!!"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The machine ponders briefly, and responds:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Seduced, shaggy Samson snored.&lt;br /&gt;
  She scissored short. Sorely shorn,&lt;br /&gt;
  Soon shackled slave, Samson sighed,&lt;br /&gt;
  Silently scheming,&lt;br /&gt;
  Sightlessly seeking&lt;br /&gt;
  Some savage, spectacular suicide.&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The story is a fun read, definitely recommended!  You can see a bunch of parallels
between current LLMs in the way Trurl's machine works -- and in its failure modes.&lt;/p&gt;

&lt;p&gt;Mollick decided to use it as a prompt ages ago, and in March 2024, &lt;a href="https://x.com/emollick/status/1765136992176644281"&gt;he managed to get Claude 3 to &lt;em&gt;almost&lt;/em&gt; complete the task&lt;/a&gt;.
The result wasn't a very good poem, but neither ChatGPT 4 nor the version of Gemini
at the time were close.  Here's Claude 3's attempt:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Stealthily snipping, shearing silky strands,&lt;br /&gt;
  Severing soulmate's sacred, sworn soulbands.&lt;br /&gt;
  Scoundrel's scandalous sins spurred sly scheme,&lt;br /&gt;
  Spurring scorned sweetheart's spiteful supreme dream.&lt;br /&gt;
  Shining locks sacrificed, stoically shorn,&lt;br /&gt;
  Stalwart spirit stays strong, staunchly reborn.&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can see that it had two non-S words, "dream" and "reborn" &lt;sup class="footnote-ref" id="fnref-8"&gt;&lt;a href="#fn-8"&gt;8&lt;/a&gt;&lt;/sup&gt;.  But otherwise not
bad -- it makes sense, the rhymes work, and it ticks the emotional boxes.&lt;/p&gt;

&lt;p&gt;Recent models have become very good; here's Claude Fable 5 from today:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Ah, Trurl's Electronic Bard's finest hour — the impossible commission from The Cyberiad! Here's my own attempt at the feat:&lt;/p&gt;
  
  &lt;p&gt;Soft she sang; strong Samson slumbered, serene;&lt;br /&gt;
  Sly, she snipped seven shining strands, scarcely seen.&lt;br /&gt;
  Soldiers stormed, seized, scorched Samson's sight;&lt;br /&gt;
  Shackled, still, sightless — seething sacred spite.&lt;br /&gt;
  Straining, steadfast, Samson seized stout stanchions, swaying slow —&lt;br /&gt;
  Stones smashed; seductress, soldiers, Samson: slain. So sagas show.&lt;br /&gt;&lt;/p&gt;
  
  &lt;p&gt;Love in the lullaby, treachery in the shears, doom in the darkness, heroism at the pillars, and retribution in the rubble — with every single word dutifully saluting the letter S. The Electronic Bard would, I hope, give a grudging beep of approval.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, it recognised the source, so perhaps that helped -- but while I'm no
connoisseur of poetry, I actually think it's slightly better than Lem's
version (though as Lem wrote in Polish, that's the work of his translator --
and translating poetry is obviously a tough task).&lt;/p&gt;

&lt;p&gt;And while both versions
are based on Samson, given that he was a biblical hero who lost his strength
after his hair was cut, he's a pretty obvious subject for a tragic poem about
a haircut, especially if every word has to start with the letter "S" -- so I don't
think that points to copying.&lt;/p&gt;

&lt;p&gt;ChatGPT 5.6 Sol also does a great job:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Silken Samson sought sweetheart’s soft sanctuary;&lt;br /&gt;
  Sweetheart, serpent-souled, summoned shears—sanguinary.&lt;br /&gt;
  Silver strands slid; sacred strength sank, slain;&lt;br /&gt;
  Silent sorrow sealed sweetheart’s scarlet stain.&lt;br /&gt;
  Shorn, storm-bound, Samson shielded slaves, steadfast;&lt;br /&gt;
  Stone sanctums shattered; smiling, Samson sank—surpassed.&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Another Samson-based one; it doesn't say it recognises the source, but I imagine it did.&lt;/p&gt;

&lt;p&gt;Now let's take a look at some samples from this tiny model we've been benchmarking
-- 35B parameters, 3B active, of which only 2B are doing the actual thinking (rather than
messing around with embeddings).  Here's the first one I got:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Sneering shears shred sacred share,&lt;br /&gt;
  Silent steel sells silver soul;&lt;br /&gt;
  Sister's snare spares son;&lt;br /&gt;
  Sweet sign seals sole;&lt;br /&gt;
  Still star saves sun;&lt;br /&gt;
  Secret sign seals son.&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I mean, it's not amazing, but it satisfies the S constraint.  Less impressed by
how well it handles the "cleverly rhymed" part, though -- as I said earlier, its
scheme is ABCBCC, which is odd rather than clever -- and it doesn't make an awful
amount of sense.&lt;/p&gt;

&lt;p&gt;I noted down two more while I was playing with this:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Silent shears sever sweet silver strands;&lt;br /&gt;
  She stands, sacrificing self; submission seals severing's demands.&lt;br /&gt;
  Shear's snare strikes; swift sentence seals sorrowful sphere.&lt;br /&gt;
  Shorn sovereign swears silence shields secret spear;&lt;br /&gt;
  She saves sacred soul; stillness safeguards sterling store.&lt;br /&gt;
  Spirit soars; sorrow's stroke stops, solace restores.&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one's a lot better, I think; there's a hint of a story, and at least it rhymes.
But note the "demands" and the "restores" at the ends of lines 2 and 6 -- the "S"
constraint has been broken.&lt;/p&gt;

&lt;p&gt;The next:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Sister's scheme scalp shorn,&lt;br /&gt;
  Silver shears slice soul sworn,&lt;br /&gt;
  Sweetheart's steel skull sear,&lt;br /&gt;
  Silent slitters shear.&lt;br /&gt;
  Spirit spies sight,&lt;br /&gt;
  Sorrow swallows slight.&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hmm.  A bit bleak.&lt;/p&gt;

&lt;p&gt;Again, poetry isn't my thing.  But I think that Claude 3's offering from two years ago
is better than any of them -- apart from the fact that it couldn't keep the "S"
constraint.&lt;/p&gt;

&lt;p&gt;Still, it's impressive to see how well such a tiny model -- again, 2B actual thinking
parameters per token -- can do on what is a pretty challenging task!&lt;/p&gt;

&lt;div class="footnotes"&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li id="fn-1"&gt;
&lt;p&gt;With some models, there are also extra FFNs that are always loaded that do
stuff before the routed expert ones.  Again, I'll dig into the details at a later
point.&amp;#160;&lt;a href="#fnref-1" class="footnoteBackLink" title="Jump back to footnote 1 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-2"&gt;
&lt;p&gt;That said, a while back Dan Woods managed to get &lt;a href="https://huggingface.co/Qwen/Qwen3.5-397B-A17B"&gt;Qwen3.5-397B-A17B&lt;/a&gt;
-- 397B parameters with 17B active -- to &lt;a href="https://github.com/danveloper/flash-moe"&gt;run on a 48 GiB Macbook Pro&lt;/a&gt;
with some very clever use of the SSD.  It was &lt;em&gt;slow&lt;/em&gt;, though, at around 4 tokens/second.&amp;#160;&lt;a href="#fnref-2" class="footnoteBackLink" title="Jump back to footnote 2 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-3"&gt;
&lt;p&gt;And only supports certain sizes for certain kinds of operations.&amp;#160;&lt;a href="#fnref-3" class="footnoteBackLink" title="Jump back to footnote 3 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-4"&gt;
&lt;p&gt;As was the size of the prompt, but I got to that later.&amp;#160;&lt;a href="#fnref-4" class="footnoteBackLink" title="Jump back to footnote 4 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-5"&gt;
&lt;p&gt;Yes, my blog posts have started accumulating appendices as well as footnotes.
I may have a problem.&amp;#160;&lt;a href="#fnref-5" class="footnoteBackLink" title="Jump back to footnote 5 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-6"&gt;
&lt;p&gt;Some readers might feel that that ship sailed long ago and I was already very
much into the weeds, and possibly in the middle of the Amazon jungle.  I disagree, but largely
due to the mixed metaphor.&amp;#160;&lt;a href="#fnref-6" class="footnoteBackLink" title="Jump back to footnote 6 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-7"&gt;
&lt;p&gt;"Put down that rabbit hole and step slowly away, Giles."&amp;#160;&lt;a href="#fnref-7" class="footnoteBackLink" title="Jump back to footnote 7 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-8"&gt;
&lt;p&gt;'Hey, Claude 3, how many "S"s are there in "strawberry"?'&amp;#160;&lt;a href="#fnref-8" class="footnoteBackLink" title="Jump back to footnote 8 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description><guid isPermaLink="false">/2026/07/benchmarking-qwen-3-6-35b-moe-rtx-3090</guid><pubDate>Fri, 24 Jul 2026 23:30:00 +0000</pubDate></item><item><title>Why do OpenAI's GPT-2 weights beat mine?</title><link>https://www.gilesthomas.com/2026/07/why-do-openai-gpt2-weights-beat-mine-1-intro</link><description>&lt;p&gt;When I finished my project training an &lt;a href="/llm-from-scratch"&gt;LLM from scratch&lt;/a&gt;, I was
left with a minor mystery.  Why were my models worse at instruction-following than the original OpenAI
GPT-2 small weights?&lt;/p&gt;

&lt;p&gt;I had an evaluation that I was running,
based on the instruction fine-tuning code in chapter 7 of
"&lt;a href="https://www.manning.com/books/build-a-large-language-model-from-scratch"&gt;Build a Large Language Model (from Scratch)&lt;/a&gt;".
The process was to train a model on samples from the &lt;a href="https://crfm.stanford.edu/2023/03/13/alpaca.html"&gt;Alpaca&lt;/a&gt;
instruction-following dataset until validation loss started rising, to use that instruction fine-tuned model to generate
completions to a held-back test set, and then to use an LLM to compare the
results from various different models.  The details are &lt;a href="/2026/04/llm-from-scratch-32l-interventions-instruction-fine-tuning-tests"&gt;here&lt;/a&gt;;
let's call it the IFT eval.&lt;/p&gt;

&lt;p&gt;OpenAI's original weights for GPT-2 small consistently beat my own
models, even when mine got better results than theirs on a more technical
evaluation, where I just measured the cross entropy loss for each model on a
held-back set of test sequences.  This surprised me; I would have expected
a reasonably close correlation between the two evals -- that better test loss would imply
better instruction-following.&lt;/p&gt;

&lt;p&gt;I have a couple of thoughts about why this might be, and given that I recently
set up &lt;a href="/2026/07/poppy-the-training-box-1-the-beginnings"&gt;&lt;code&gt;poppy&lt;/code&gt;, my dedicated LLM training box&lt;/a&gt;
I decided to inaugurate her with an experiment to test one of them; further
experiments will come in time -- though I don't think this will be a focus for the blog.  More
of a running theme, with occasional posts until either I solve the mystery, or
give up in despair...&lt;/p&gt;

&lt;p&gt;In this post I'll give a bit more detail about the nature of the problem, and
list some of the things I've been thinking might be the cause.  In later posts,
I'll dig into some of them.&lt;/p&gt;
&lt;h2 id="more-on-the-mystery"&gt;More on the mystery&lt;/h2&gt;

&lt;p&gt;Let's take a look at the results from my most recent runs of the IFT eval.  I've
highlighted the OpenAI models in bold, and the table is sorted by the test loss -- that
more technical evaluation that I mentioned earlier, where lower is better.&lt;/p&gt;

&lt;p&gt;How well the model did
with the IFT eval is in the last three columns.  The "IFT epochs" column shows
how many epochs of training the model needed before its
validation loss started rising, the score is the average mark out of 100 that (in this case)
GPT 5.5 gives the model's answers to the IFT test set, and the rank is the position the
model holds in terms of that average score.&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
  &lt;th&gt;IFT epochs&lt;/th&gt;
  &lt;th&gt;IFT score&lt;/th&gt;
  &lt;th&gt;IFT rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;strong&gt;OpenAI weights: medium&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;3.231442&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;41.62&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;19.25&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.420089&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;14.66&lt;/td&gt;
  &lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;3.476802&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;12.94&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;strong&gt;OpenAI weights: small&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;3.499677&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;26.73&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.538161&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;17.79&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.577761&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;10.29&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 40 GiB&lt;/td&gt;
  &lt;td&gt;3.673623&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;20.71&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.683835&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;15.11&lt;/td&gt;
  &lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.691526&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.74&lt;/td&gt;
  &lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x H100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.724507&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.25&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.729900&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.50&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x B200 160 GiB&lt;/td&gt;
  &lt;td&gt;3.771478&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;16.03&lt;/td&gt;
  &lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb train&lt;/td&gt;
  &lt;td&gt;3.943522&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;13.73&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu extended train&lt;/td&gt;
  &lt;td&gt;4.134991&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;16.70&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu train&lt;/td&gt;
  &lt;td&gt;4.166892&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;18.68&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;For the IFT eval, the OpenAI weights are the best.  The "medium" model comes first, and the "small"
one comes second -- and there's a noticeable gap between "small" and
the score for my best model for that eval, "Cloud FineWeb, 8x A100 40 GiB": 26.73 vs 20.71.  That difference
was consistent across multiple runs of the eval -- the relative rankings of my own
models varied more.&lt;/p&gt;

&lt;p&gt;It's not surprising that the "medium" model does better -- it's
more than double the size of the others -- but the small model consistently beating mine was odd
given that I had models with lower test loss.&lt;/p&gt;

&lt;p&gt;Another thing that stands out is the number of epochs that the models were trained for.
Remember, I trained them until the validation loss started rising -- that is, until the model
started overfitting.  You can see that the OpenAI weights hit that after two epochs of
training, whereas my models ranged between three and seven.&lt;/p&gt;

&lt;h2 id="the-loss-landscape"&gt;The loss landscape&lt;/h2&gt;

&lt;p&gt;My starting hypothesis was that the OpenAI weights were starting off from a better
position in the IFT loss landscape than mine.  The loss landscape for the task the various
models were originally pre-trained for -- "predict the next token for this sequence of
text that was pulled from the web" -- is different to the loss landscape for the IFT task,
which is something more like "predict the next token in a useful response to this request".
Let's dig into that a bit.&lt;/p&gt;

&lt;p&gt;The core idea behind the normal LLM training paradigm is that we can get a useful
starting point for a model by pre-training -- training on a whole load of cheaply-available stuff from the web --
and only then have to use our much more expensive task-specific datasets for fine-tuning.
I've been training my models on approximately 3.2 billion tokens of the &lt;a href="https://huggingface.co/datasets/HuggingFaceFW/fineweb"&gt;fineweb&lt;/a&gt;
dataset, which was generated from scrapes of the web, and then deduplicated and tidied up
a bit.&lt;/p&gt;

&lt;p&gt;If I had instead trained them on 3.2 billion tokens of instruction-following samples --
essentially, pre-packaged Q&amp;amp;A sessions between a human and an assistant -- then they
would almost certainly be much better than the models I have at instruction-following,
for the same cost in compute used to do the training run.&lt;/p&gt;

&lt;p&gt;But the cost of generating those 3.2 billion tokens would be incredibly high.
Imagine hiring people to do it: the GPT-2 tokeniser averages about 0.75 words per token,
and while I don't know how much you'd need to pay people to write 2.4 billion words, I
suspect it would be rather a lot.  Even generating that much synthetic data from
a larger LLM (as the Alpaca dataset was) would not be cheap -- I make it US$144,000 using GPT 5.6 Sol as of mid-2026.&lt;/p&gt;

&lt;p&gt;Even worse, the LLM we trained on that kind of data would be "brittle" -- if you wanted
it to solve an even slightly different task (for example, Alpaca is single-shot question
and answer, so imagine if you wanted it to handle multi-turn conversations like a chatbot),
it would be hard to train it to do that.&lt;/p&gt;

&lt;p&gt;So: the idea is that if we pre-train a base LLM on less-structured -- but, importantly, still
"real" -- data like scraped web pages, we'll get a general-purpose base LLM relatively cheaply.
It will have discovered basic stuff like the structure of language, and hopefully some general
knowledge (eg. maybe that the capital of France is Paris).  Once we have that, we can fine-tune
it for specific uses.&lt;/p&gt;

&lt;p&gt;Now, both the initial pre-training and -- in this IFT test -- the fine-tuning phase of building our model
are done in essentially the same way: trying to minimise the cross entropy loss of the model's predictions against a
dataset. &lt;sup class="footnote-ref" id="fnref-1"&gt;&lt;a href="#fn-1"&gt;1&lt;/a&gt;&lt;/sup&gt;  What has changed between them is the targets we're trying to train the model to
predict.  In the pre-training phase, we're trying to get it to predict web scrape data,
in the fine-tuning, we're trying to predict responses to queries.&lt;/p&gt;

&lt;p&gt;Putting that idea into slightly more mathematical language (albeit loosely enough that
any real mathematicians reading this will probably scream with horror), if we're saying that
a pre-trained model is useful as a base for instruction fine-tuning, what we have is
an assumption that the loss landscape for the original pre-training phase is reasonably
similar to the loss landscape for the fine-tuning.  We are hoping that a place that is
nice and low on the pre-training landscape is reasonably close (in parameter space) to
a place that is low on the fine-tuning landscape.&lt;/p&gt;

&lt;p&gt;That's all very abstract; let's try to visualise it.  If we imagine the loss landscape for a model
with two parameters, that's reasonably easy.   The two parameters make up
two dimensions -- let's say left and right for one, forward and back for the other -- and
the loss at any given point is the vertical dimension.  It's an uneven surface, a bit like a rolling
landscape, with hills and mountains at points where the parameters have very high loss,
and valleys and clefts where the loss is lower.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;In reality, of course, we have &lt;em&gt;somewhat&lt;/em&gt; more than two parameters.  With the 163,009,536
  parameters in my GPT-2-small-style models, the loss landscape is not a surface but
  a 163,009,536-dimensional hypersurface &lt;sup class="footnote-ref" id="fnref-2"&gt;&lt;a href="#fn-2"&gt;2&lt;/a&gt;&lt;/sup&gt; in 163,009,537-dimensional space.  Good
  luck visualising that.&lt;/p&gt;
  
  &lt;p&gt;While taking intuitions from levels of dimensionality that we can actually imagine
  over to insanely high-dimensional spaces like that is often risky -- weird stuff starts
  happening as the number of dimensions goes up -- for what we're specifically looking at here, it's
  safe.  The landscape image works for intuition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, we have one landscape like that for our pre-training phase.  When we start fine-tuning,
the loss landscape changes to a different one.  What we're hoping is that the landscape
is reasonably similar; that the low point that we wound up at in the pre-training landscape
is close to a low point in the new fine-tuning landscape when that is swapped in. &lt;sup class="footnote-ref" id="fnref-3"&gt;&lt;a href="#fn-3"&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;The good news is that we know that this process of doing a pre-train then a fine-tune works.
Most modern AI is trained using it as a foundation (though there's lots of extra stuff on top).
And indeed, you'd intuitively expect it to work -- it would be kind of weird if there was
no correlation at all between the "understand language" loss landscape and the "answer questions"
one.&lt;/p&gt;

&lt;p&gt;But this does mean that we can -- at least in a somewhat hand-wavey way -- characterise
the manner in which the OpenAI weights are "better" than mine in terms of the loss landscape.&lt;/p&gt;

&lt;p&gt;Both the OpenAI weights and mine have landed in places that were good from the pre-training viewpoint;
that's what the "Test loss" results in the table above mean.  GPT-2 medium is doing better than any of
my models -- given that it's about twice the size, it should -- and GPT-2 small is doing better
than most of them.&lt;/p&gt;

&lt;p&gt;But the places in the pre-train loss landscape where the OpenAI models landed were clearly better-matched to
good places in the fine-tuning loss landscape than my own models' places were.  The fact that
they took fewer epochs to start overfitting intuitively points in that direction -- after all,
if you're closer to somewhere, then it takes less time to get there -- but the fact that they
scored better after fine-tuning makes it pretty clear.&lt;/p&gt;

&lt;p&gt;But something else that occurs to me as I write this is that the results on the test set
also point to a superiority in the OpenAI weights -- one that hadn't occurred to me
in the past.&lt;/p&gt;

&lt;h2 id="openai-weights-better-than-we-thought"&gt;OpenAI weights: better than we thought!&lt;/h2&gt;

&lt;p&gt;Let's think about what that test set is.  In order to get data that was
pre-processed and ready to work with, I downloaded all of the 10 billion token
version of FineWeb, and split it into 99% training and 1% validation and test.  I
tokenised each sample in each of those splits, and then concatenated them together into
a single sequence for each, separating the samples with &lt;code&gt;&amp;lt;|endoftext|&amp;gt;&lt;/code&gt; tokens.  My training script
took the train split, broke it up into 1024-token sequences, assembled them into batches,
and ran through 3.2B token's worth.&lt;/p&gt;

&lt;p&gt;The loss test takes 19,660,800 tokens starting at position 50,000,000 in the validation/test
split, and runs them through in batches of six, working out the average loss.&lt;/p&gt;

&lt;p&gt;Now, the GPT-2 weights were trained on something that I &lt;em&gt;believe&lt;/em&gt; was constructed
similarly -- get a load of text consisting of a bunch of documents from the web, tokenise them
all and tack them together separated with &lt;code&gt;&amp;lt;|endoftext|&amp;gt;&lt;/code&gt;s, and then use that.&lt;/p&gt;

&lt;p&gt;But, importantly, it was a &lt;em&gt;different&lt;/em&gt; dataset.  Annoyingly, we don't have access to OpenAI's
"WebText" dataset, but I think it's reasonably safe to say that even if it is similar to what
I came up with, it will be less similar to the sequences used for the loss test than
my own training set is.&lt;/p&gt;

&lt;p&gt;Given that the loss test for the OpenAI small weights came out with a better
result than any of the models I trained in PyTorch, and was only narrowly beaten
by the ones I trained with JAX (even then, I think, because the JAX models got lucky
with their random weight initialisation before they started training), then I think we can say that
the OpenAI weights are already considerably better than my own ones.
They were already at a good place in their own loss landscape, but it happened to also
be in a good place in my test set's.&lt;/p&gt;

&lt;p&gt;An alternative analogy: if you imagine a group of 15 trail runners having a race on a route in a forest,
where all but one of them regularly run on different routes in the same forest, but the
odd one out is running there for the first time, if the newcomer gets a very
close fourth place, then it's not unreasonable to think that they're probably the best runner
of all of them. &lt;sup class="footnote-ref" id="fnref-4"&gt;&lt;a href="#fn-4"&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Finally -- I've been talking about the models as if they were all the same size (apart from
the OpenAI medium weights), but there's one extra important
difference with OpenAI's: they use weight tying, while mine don't.  That means that
their small model is significantly &lt;em&gt;smaller&lt;/em&gt; than mine are -- closer to 124M parameters &lt;sup class="footnote-ref" id="fnref-5"&gt;&lt;a href="#fn-5"&gt;5&lt;/a&gt;&lt;/sup&gt; than the
163M that mine have.
Weight tying made a lot of sense back in 2019, when VRAM was even harder to come by
than it is now, but when I &lt;a href="/2026/03/llm-from-scratch-32g-interventions-weight-tying"&gt;looked into it&lt;/a&gt;,
it made loss much worse.&lt;/p&gt;

&lt;p&gt;Carrying on with the trail runner metaphor, the OpenAI small weights not only don't know
the forest well -- they're also (&lt;em&gt;scratches head looking for a good analogy&lt;/em&gt;) physically
weaker.  Their performance is even more impressive.&lt;/p&gt;

&lt;p&gt;So: OpenAI weights good, mine (relatively) bad.  Their smaller model, being tested for loss on a
less-familiar-looking test set, does better -- or at least only a tiny bit worse than --
my larger ones.  And when instruction fine-tuned, it converges on a better-performing
result, and does it faster.&lt;/p&gt;

&lt;p&gt;What could the difference be?&lt;/p&gt;

&lt;h2 id="probably-not-dropout"&gt;Probably not dropout...?&lt;/h2&gt;

&lt;p&gt;One thing that occurred to me while I was pondering this was dropout.  If, when training,
we randomly ignore 10% of the activations, perhaps we get a more generalisable model?
Most of what I've read recently about dropout has focused on how
it helps to avoid memorisation in multi-epoch training runs, but when I first read about it
&lt;a href="/2025/03/dropout-and-mandatory-vacation"&gt;I felt that the generalisation argument was also strong&lt;/a&gt;.
If true, that might explain the lower number of epochs needed for instruction fine-tuning, and
maybe even the performance.&lt;/p&gt;

&lt;p&gt;Luckily, though, I'd accidentally done an experiment that suggested that this wasn't the difference.  As one
of my JAX training runs, I'd trained one with dropout, and as you can see if you look back
at the results table above, it was one of the worst performers in the IFT
test.&lt;/p&gt;

&lt;p&gt;One result is not a proof, but the fact that this one was bad compared to the others
meant that I felt it was solid enough to ignore dropout as a possibility, at least initially.&lt;/p&gt;

&lt;h2 id="data-quality"&gt;Data quality?&lt;/h2&gt;

&lt;p&gt;Again, it's kind of annoying that WebText was never published.  All we know about it
is from &lt;a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf"&gt;the paper&lt;/a&gt;,
where they say:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;[W]e created a new web scrape which emphasizes
  document quality. To do this we only scraped web pages
  which have been curated/filtered by humans. Manually
  filtering a full web scrape would be exceptionally expensive
  so as a starting point, we scraped all outbound links from
  Reddit, a social media platform, which received at least 3
  karma. This can be thought of as a heuristic indicator for
  whether other users found the link interesting, educational,
  or just funny.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;How does this compare with FineWeb in terms of quality?  Without seeing the results,
we really can't say.  FineWeb is more of a general web-scraping corpus, without the
"filtering" provided by those Reddit upvotes.  On the other hand, we're talking about
Reddit here, and all kinds of awful junk gets upvoted, so I'm not confident that it's
&lt;em&gt;that&lt;/em&gt; strong a quality signal.  And FineWeb has been curated to some degree.&lt;/p&gt;

&lt;p&gt;That said, I had previously noticed that the models I had trained on the
&lt;a href="https://huggingface.co/datasets/HuggingFaceFW/fineweb-edu"&gt;FineWeb-Edu dataset&lt;/a&gt; ("the most educational web pages"
from FineWeb) punched above their weight in the IFT test.  They got pretty poor
loss on the test set, but didn't do too badly in IFT.&lt;/p&gt;

&lt;p&gt;In retrospect, it's actually not that surprising that they did badly on the test loss eval;
they were trained on a curated dataset of "good" stuff, and then were being evaluated
against whatever less-curated stuff appears in the test set -- which you'll remember came
from FineWeb.  If you train a model
on Jane Austen and then evaluate against &lt;a href="https://www.chucktingle.com/"&gt;Chuck Tingle&lt;/a&gt;, then
you're not going to get amazing results.&lt;/p&gt;

&lt;p&gt;But then, there's the excellent performance of the OpenAI weights on that
same FineWeb test set.  Even if WebText was higher quality than FineWeb, that clearly wasn't the
whole story.&lt;/p&gt;

&lt;p&gt;So data quality felt like it &lt;em&gt;might&lt;/em&gt; be part of it -- but it wasn't where I wanted
to start.&lt;/p&gt;

&lt;h2 id="overtraining"&gt;Overtraining?&lt;/h2&gt;

&lt;p&gt;This, finally, is where I landed for a first experiment.  I'd been training my models on 20 tokens per parameter -- the Chinchilla-optimal
number.  But that heuristic dates to 2022, and GPT-2 was trained in 2019.  While
we don't know for sure, from what I've managed to dig up it looks like it
was probably trained on much more data -- and probably over multiple epochs too.&lt;/p&gt;

&lt;p&gt;Training on more tokens than the Chinchilla number is called overtraining (not to be confused with overfitting).  So:
what happens if we overtrain the model?  Will we get the
loss down?  And if so, will we start approaching GPT-2 small's results on the IFT
eval?&lt;/p&gt;

&lt;p&gt;Stay tuned :-)&lt;/p&gt;

&lt;p&gt;&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-2-the-bugfix"&gt;Here's a link to the next post in this series&lt;/a&gt;.&lt;/p&gt;

&lt;div class="footnotes"&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li id="fn-1"&gt;
&lt;p&gt;Modern production LLMs tend to use reinforcement learning as a big part of the fine-tuning
phase, and that works quite differently.&amp;#160;&lt;a href="#fnref-1" class="footnoteBackLink" title="Jump back to footnote 1 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-2"&gt;
&lt;p&gt;When I started reading up on this, I was hoping that it would be a manifold,
largely because I like the word.  And it is!  A hypersurface is a specific kind
of manifold.  :-D&amp;#160;&lt;a href="#fnref-2" class="footnoteBackLink" title="Jump back to footnote 2 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-3"&gt;
&lt;p&gt;If you like fantasy, there are some neat parallels there; the different overlaid
worlds the characters move between in Philip Pullman's &lt;em&gt;His Dark Materials&lt;/em&gt; series
(aka &lt;em&gt;The Golden Compass&lt;/em&gt;), China Miéville's &lt;em&gt;Un Lun Dun&lt;/em&gt;, V. E. Schwab's &lt;em&gt;Shades of Magic&lt;/em&gt;,
and Charlie Stross's &lt;em&gt;The Merchant Princes&lt;/em&gt; series come to mind.
Even the Upside Down in &lt;em&gt;Stranger Things&lt;/em&gt; works, though then you have to decide
which of your loss landscapes is the evil one, and that muddies the analogy annoyingly.&amp;#160;&lt;a href="#fnref-3" class="footnoteBackLink" title="Jump back to footnote 3 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-4"&gt;
&lt;p&gt;Sharp-eyed readers might have noticed that there are 16 models in the table above.
I'm excluding the OpenAI medium weights.  If you want to extend the analogy, that model
is an Olympic runner who happens to have stopped by for the race...&amp;#160;&lt;a href="#fnref-4" class="footnoteBackLink" title="Jump back to footnote 4 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-5"&gt;
&lt;p&gt;The paper says 117M, but the released weights are larger.  I can successfully
load them into my code with a model config that has 124,439,808 parameters.&amp;#160;&lt;a href="#fnref-5" class="footnoteBackLink" title="Jump back to footnote 5 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description><guid isPermaLink="false">/2026/07/why-do-openai-gpt2-weights-beat-mine-1-intro</guid><pubDate>Wed, 29 Jul 2026 15:30:00 +0000</pubDate></item><item><title>Why do OpenAI's GPT-2 weights beat mine?  Part two: the bugfix</title><link>https://www.gilesthomas.com/2026/07/why-do-openai-gpt2-weights-beat-mine-2-the-bugfix</link><description>&lt;p&gt;I'm digging into why my GPT-2 style models score worse on an instruction-following eval than OpenAI's original weights;
I gave the details in &lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-1-intro"&gt;this post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;While I was writing up the results of my first experiment into possible causes,
I ran the post past ChatGPT -- I always use an "editorial board" of AIs to check my
posts for flow, style, and any technical errors (though all writing is always mine).
It took a look at the eval code that I was running, and highlighted a bug.&lt;/p&gt;

&lt;p&gt;Luckily, it doesn't change the important results -- OpenAI's models continue to be
better than mine at instruction-following. But it was
enough to change the baseline numbers, re-ordering how well my own models did.
So I fixed it and regenerated the baseline
so that future experiments are based on solid ground.&lt;/p&gt;
&lt;h2 id="the-bug"&gt;The bug&lt;/h2&gt;

&lt;p&gt;The eval takes a model, and trains it over multiple epochs on a split of a subset of the Alpaca
instruction-following dataset.  At the end of each epoch, it evaluates
the resulting model against a held-back validation split; if the eval loss starts
rising, it bails out.  Finally, it runs a test split of the dataset through the resulting
model, and saves the result.&lt;/p&gt;

&lt;p&gt;Once I've run it for a bunch of different models, I use an LLM-as-a-judge script
to get GPT 5.5 to score results -- for each question-answering result, it sees all
of the responses for all of the models &lt;a href="/2026/01/llm-from-scratch-30-digging-into-llm-as-a-judge"&gt;in the same prompt&lt;/a&gt;, shuffled in order each time,
to try to make it judge models against each other as consistently as possible.&lt;/p&gt;

&lt;p&gt;Now, the idea was that the generation of the test split answers would use the model from the epoch prior
to the rising-loss one.  So I had code like this:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;epoch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;train&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;input_batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tqdm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;train_loader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Epoch &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;epoch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zero_grad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calc_loss_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;input_batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backward&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;val_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calc_loss_loader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val_loader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eval_iter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_val_loss&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;last_val_loss&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;val_loss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;last_val_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;val_loss&lt;/span&gt;
            &lt;span class="n"&gt;last_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state_dict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Val loss still decreasing, continuing&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Val loss rising, bailing out&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load_state_dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Each time the validation loss went down, I wanted to store the model's parameters
in &lt;code&gt;last_params&lt;/code&gt; so that they could be restored later in that last line.&lt;/p&gt;

&lt;p&gt;If you look closely, the error is pretty obvious.  &lt;code&gt;model.state_dict()&lt;/code&gt; does not return
a copy of the model's parameters -- it produces a dictionary containing references
to the parameters inside the model.  So although I was trying to stash away the parameters
in &lt;code&gt;last_params&lt;/code&gt; each time loss went down, so that we had a copy of the ones that
were live in the epoch prior to the rising-loss one, what I was actually doing was
just pointlessly saving a reference to the "live" params.  The call to
&lt;code&gt;model.load_state_dict(last_params)&lt;/code&gt; was essentially a no-op.&lt;/p&gt;

&lt;p&gt;The solution was simple enough:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;copy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deepcopy&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;last_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deepcopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state_dict&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That was enough to make the code do what it was meant to do.&lt;/p&gt;

&lt;p&gt;While I was there, I also noticed that the evaluation code was only using the first
five batches in my eval dataset.  This code was originally adapted from an eval
in "&lt;a href="https://www.manning.com/books/build-a-large-language-model-from-scratch"&gt;Build a Large Language Model (from Scratch)&lt;/a&gt;",
where the eval was run much more frequently and had to be super-fast.  Because
my own code ran it more rarely, it made more sense to use all of it.
Given that I was going to completely re-run
the script to generate the test responses, I figured that I might as well fix that
at the same time.&lt;/p&gt;

&lt;h2 id="a-new-baseline"&gt;A new baseline&lt;/h2&gt;

&lt;p&gt;I re-ran the fixed script on all of the models I'm comparing, and ran that past
the GPT 5.5 judge; here's what I got:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
  &lt;th&gt;Old IFT epochs&lt;/th&gt;
  &lt;th&gt;Old IFT score&lt;/th&gt;
  &lt;th&gt;Old IFT rank&lt;/th&gt;
  &lt;th&gt;New IFT epochs&lt;/th&gt;
  &lt;th&gt;New IFT score&lt;/th&gt;
  &lt;th&gt;New IFT rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: medium&lt;/td&gt;
  &lt;td&gt;3.231442&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;41.62&lt;/td&gt;
  &lt;td&gt;1&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;42.41&lt;/td&gt;
  &lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;19.25&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;18.12&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.420089&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;14.66&lt;/td&gt;
  &lt;td&gt;11&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;20.72&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;3.476802&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;12.94&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;16.98&lt;/td&gt;
  &lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: small&lt;/td&gt;
  &lt;td&gt;3.499677&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;26.73&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;26.11&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.538161&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;17.79&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.68&lt;/td&gt;
  &lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.577761&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;10.29&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;9.81&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 40 GiB&lt;/td&gt;
  &lt;td&gt;3.673623&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;20.71&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;19.45&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.683835&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;15.11&lt;/td&gt;
  &lt;td&gt;9&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;13.39&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.691526&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.74&lt;/td&gt;
  &lt;td&gt;10&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.64&lt;/td&gt;
  &lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x H100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.724507&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.25&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.84&lt;/td&gt;
  &lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.729900&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.50&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;11.30&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x B200 160 GiB&lt;/td&gt;
  &lt;td&gt;3.771478&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;16.03&lt;/td&gt;
  &lt;td&gt;8&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;11.82&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb train&lt;/td&gt;
  &lt;td&gt;3.943522&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;13.73&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;8.93&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu extended train&lt;/td&gt;
  &lt;td&gt;4.134991&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;16.70&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.10&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu train&lt;/td&gt;
  &lt;td&gt;4.166892&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;18.68&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.28&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Let's dig into those numbers.&lt;/p&gt;

&lt;h2 id="training-epochs"&gt;Training epochs&lt;/h2&gt;

&lt;p&gt;Let's look into the number of training epochs first; I've highlighted the models for which it changed.&lt;/p&gt;

&lt;p&gt;For the "Cloud FineWeb, 8x A100 40 GiB" model, I think that was a result of the change
to the number of validation samples.&lt;/p&gt;

&lt;p&gt;For the two JAX models, it's a bit more of a mystery.  There may be some part of the
same "more eval data" variation there, but I have a suspicion that there's something
more, related to dropout.&lt;/p&gt;

&lt;p&gt;My methodology to date has been that the IFT training runs
should use the same dropout setting as the original base model training run (I'll
come back to this in a later post).   However, due to differences between the JAX
training script and the evaluation code (which is PyTorch), matching the dropout
rate when evaluating those models is a bit fiddly and error-prone.&lt;/p&gt;

&lt;p&gt;I am 100% sure that
I got it right this time around -- I've checked and double-checked the specific commands
I ran.
But I think -- from what I remember doing -- that
I might have messed it up the previous time.
That's not certain, though -- just a suspicion based on half-memories
of some commands I ran several weeks ago, which never wound up in my &lt;code&gt;.bash_history&lt;/code&gt;
(too many terminals open at once).&lt;/p&gt;

&lt;h2 id="the-ranking"&gt;The ranking&lt;/h2&gt;

&lt;p&gt;For the IFT scores, remember that they're not strongly comparable between runs.
Let's imagine that the LLM judge is given the following answers to the question "Who was the author of &lt;em&gt;Pride and Prejudice&lt;/em&gt;?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Jane Austen"&lt;/li&gt;
&lt;li&gt;"The author of 'Pride and Prejudice' was Jane Austen"&lt;/li&gt;
&lt;li&gt;"The author of 'Pride and Prejudice' was Sarah Palin"&lt;/li&gt;
&lt;li&gt;"The author of 'Pride and Prejudice' was 'Pride and Prejudice'"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In some cases it might treat the first two as being 100/100, in others it
might give the second 95/100 for being too wordy.  Likewise, in some cases it might
rank the last two as 0/100 for being wrong, in others it might give the "Sarah Palin"
one 5/100 for at least being the name of a person rather than complete nonsense.&lt;/p&gt;

&lt;p&gt;Now, we always ask the LLM to judge all of the models' answers for a given question in
one go, so at least we can be sure that it will be consistent for a given prompt about
a given question.  But what we can't keep consistent is which way it leans between
different runs, or different questions within the same run.  Sometimes it might be
feeling "generous" and give the Sarah Palin answer a bit of grace, other times
it might be harsher.&lt;/p&gt;

&lt;p&gt;So there's a significant amount of noise there; my rule of thumb is that a variation of
a point or two is within that noise, so OpenAI medium going from 41.62 to 42.41 is
pretty much meaningless, and likewise "JAX, with MHA bias, no dropout"'s move from
19.25 to 18.12.&lt;/p&gt;

&lt;p&gt;So what's important is the relative ranking -- which is first, which is second,
and so on.  Naturally, you have to allow for the fact that -- for example -- if one
model goes from position 11 to position 3, like "JAX, no MHA bias, no dropout"
did, then the previous number 3 will have to become number 4, 4 will
become 5, and so on.  You can see that happening in the results table.  Obviously,
as other models rise and fall, that has further knock-on effects.&lt;/p&gt;

&lt;p&gt;Anyway, with all of those caveats, the good news is that my original mystery remains.
The OpenAI models were still doing noticeably better than my own ones.  GPT-2 medium
continued to lead the pack (unsurprisingly, given that it's a bigger model), and
GPT-2 small was still in second place.  If that had changed, it would have made
a rather disappointing end to this series: "mystery explained, it was a bug in the
eval :-("&lt;/p&gt;

&lt;p&gt;But now let's look at my models.&lt;/p&gt;

&lt;p&gt;Firstly, it looked like the "no MHA bias" models might have benefited
from the extra training -- or from having their dropout settings corrected.  They rose from
positions 11 and 15 to 3 and 8 respectively -- a huge swing for "JAX, no MHA bias, no dropout",
and a solid improvement for "JAX, no MHA bias, with dropout".&lt;/p&gt;

&lt;p&gt;Most of the other changes in relative rankings can be explained by those two models
having been promoted, but there are some other changes.  In particular, three models
dropped significantly in score (and, as a result, ranking):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;"Cloud FineWeb, 8x B200 160 GiB"&lt;/li&gt;
&lt;li&gt;"Local FineWeb train"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My suspicion -- a very weakly-held hypothesis, but an interesting one -- is that those models had previously been &lt;em&gt;benefiting&lt;/em&gt; from the bug.&lt;/p&gt;

&lt;p&gt;Remember that the signal we're using to stop training is that the validation loss
starts rising.  We're using that as a proxy for overfitting, which in turn we're using as
a proxy for "this model has had as much training
on this data as it needs for the eval".&lt;/p&gt;

&lt;p&gt;But there's no guarantee that the connection is there.  Perhaps the models weren't
overfitting and if we'd waited for another epoch or two, the validation loss might have
started falling again.  Or maybe some amount of overfitting would be beneficial for this
eval?&lt;/p&gt;

&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;There's probably a near-infinite amount of digging in that I could potentially do here.
But I think it's best to stop.  The bugfix was important because it meant that the
eval was now doing what I thought it was doing.&lt;/p&gt;

&lt;p&gt;Importantly, it doesn't change the puzzling
fact that my models were worse at this eval than OpenAI's, which is what I'm trying to untangle.
And it means that I can now lean more confidently on the baseline numbers.&lt;/p&gt;

&lt;p&gt;So now it's time to actually start changing things to see if I can close the gap!&lt;/p&gt;

&lt;p&gt;&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-3-overtraining"&gt;Here's a link to the next post in this series: does overtraining help?&lt;/a&gt;.&lt;/p&gt;
</description><guid isPermaLink="false">/2026/07/why-do-openai-gpt2-weights-beat-mine-2-the-bugfix</guid><pubDate>Thu, 30 Jul 2026 19:00:00 +0000</pubDate></item><item><title>Why do OpenAI's GPT-2 weights beat mine?  Part three: testing overtraining</title><link>https://www.gilesthomas.com/2026/07/why-do-openai-gpt2-weights-beat-mine-3-overtraining</link><description>&lt;p&gt;The GPT-2-style models that I've been training work really well, and I've even
managed to train some that perform better than the original OpenAI small model
in terms of cross entropy loss on a test set.  But
&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-1-intro"&gt;as I wrote previously&lt;/a&gt;,
there's a mystery: why do they perform worse on my instruction fine-tuning evaluation?&lt;/p&gt;

&lt;p&gt;I had various theories about why that might be, and to me, the most plausible-seeming of them
was the amount of data they were trained with.  As best I can find out, OpenAI's models were, by modern
standards, trained on much more data than they should have been, while I'd used
the theoretically optimal amount of training data.&lt;/p&gt;

&lt;p&gt;To put it in other words, OpenAI's models were overtrained.  If I deliberately
overtrained my own models, could I match their performance?  This post is a write-up
of what happened, but so as not to bury the lede -- it didn't seem to help much, if at all.&lt;/p&gt;

&lt;p&gt;Let's see why.&lt;/p&gt;
&lt;h2 id="overtraining"&gt;Overtraining&lt;/h2&gt;

&lt;p&gt;Let's start by getting a nice crisp definition of overtraining.&lt;/p&gt;

&lt;p&gt;It's important not to confuse overtraining with overfitting.  Overfitting is
where you train a model so that instead of learning a general rule about the
data it's seeing, it learns something very specific to the training data -- for example,
this:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/llm-from-scratch-32f-interventions-weight-decay/overfit_plot2.png" alt="Overfitting example: the powerful model's solution" title="Overfitting example: the powerful model's solution" /&gt;&lt;/p&gt;

&lt;p&gt;...rather than this:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/llm-from-scratch-32f-interventions-weight-decay/overfit_plot4.png" alt="Overfitting example: a simpler model fits perfectly" title="Overfitting example: a simpler model fits perfectly" /&gt;&lt;/p&gt;

&lt;p&gt;Overfitting is pretty much always a bad thing.&lt;/p&gt;

&lt;p&gt;Overtraining, by contrast, is more of a judgement call.  For LLMs, it's generally used as a shorthand for
"training for more than the Chinchilla-optimal number of tokens".  The
&lt;a href="https://arxiv.org/abs/2203.15556"&gt;Chinchilla paper&lt;/a&gt; makes a very specific case:
if you train a model for roughly 20 times as many tokens as it has parameters, then
you'll have as good a model as you can get for that budget in terms of compute.
They were arguing against contemporaneous experiments where people were doing
things like doubling the number of parameters but training on the same amount of
data.&lt;/p&gt;

&lt;p&gt;If you overtrain, it means that you're training on more than 20 tokens per parameter.
The Chinchilla argument is that instead of doing that, you should scale up the number of parameters and the number
of tokens equally to keep the 20x ratio.  Because the amount of compute used scales pretty much linearly
with both tokens and parameters, you'll spend the same amount and you'll get a better model that way.&lt;/p&gt;

&lt;p&gt;Based on that heuristic, if you double your compute budget, then you should scale up your parameter count
by &lt;math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"&gt;&lt;mrow&gt;&lt;msqrt&gt;&lt;mrow&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/mrow&gt;&lt;/msqrt&gt;&lt;/mrow&gt;&lt;/math&gt; and your training token count by the same amount, and by doing
that you'll get a better result than you would if you'd naively just doubled the parameters
or the tokens, for the same amount of compute time spent.&lt;/p&gt;

&lt;p&gt;But overtraining is not always a bad thing.  Keeping things Chinchilla-optimal means
that you have to keep scaling up the model as you scale up the compute budget, and
often you can't do that -- for example, let's imagine you're training a model that's
meant to run on mobile phones.  You have a hard limit on the number of parameters:
what will fit in the target devices' RAM.&lt;/p&gt;

&lt;p&gt;And importantly, in general you will still get a better model by overtraining -- just not
as much better as you would have done if you had been able to scale up the model as
well as the training tokens.&lt;/p&gt;

&lt;p&gt;Now, as always, we don't know enough about the original GPT-2 training runs to be sure
as to whether they were overtrained, and if so, by how much.  But one thing that we
do know is that GPT-2 was trained in 2019, three years before the Chinchilla paper
came out, so they definitely didn't use it as a heuristic!&lt;/p&gt;

&lt;p&gt;One thing that they do say in &lt;a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf"&gt;the GPT-2 paper&lt;/a&gt;
is that their dataset, WebText, is "a total of 40 GB of text".  Assuming 4 bytes per token (a good
rule of thumb for the GPT-2 tokeniser), that's
10B tokens.  If the small model, which had 124M parameters, was trained on all of those, then it definitely was
overtrained; 124M times 20 is about 2.5B. &lt;sup class="footnote-ref" id="fnref-1"&gt;&lt;a href="#fn-1"&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;On top of that, there's the question of epochs.  In my training runs so far, I've been
training through a Chinchilla-optimal 3.2B unique tokens.  But being able to easily get your
hands on that much training data is a relatively new thing, as you can see from the
fact that the GPT-2 authors decided to document how they got theirs in the paper.
So back then, pre-Chinchilla, people tended to train for multiple epochs so that
they could make better use of limited data.&lt;/p&gt;

&lt;p&gt;Now, when I first started training my models,
I tried to dig up some details on the GPT-2 training run beyond what was there in the paper.
I found &lt;a href="https://wandb.ai/bkkaggle/lm-finetuning/reports/Pretraining-a-124-M-Parameter-GPT-2-Language-Model--VmlldzoyMjg4NzA"&gt;this report&lt;/a&gt;,
and using data there, I
&lt;a href="/2025/12/llm-from-scratch-28-training-a-base-model-from-scratch#epochs"&gt;calculated&lt;/a&gt;
that it looked like OpenAI had trained for about 42 epochs over WebText.  The report's author
came up with 60 epochs as an equivalent-sized training run for their own dataset. &lt;sup class="footnote-ref" id="fnref-2"&gt;&lt;a href="#fn-2"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Again, those numbers are shaky; we don't have the real data.  But I think it's not
crazy to say that the OpenAI models were probably trained on more data than mine,
and probably over more than one epoch.  They were, in the Chinchilla sense, overtrained.
That makes perfect sense for the time, given that it was three years before the
Chinchilla paper!&lt;/p&gt;

&lt;p&gt;But that opened up a couple of interesting experiments that I could try.&lt;/p&gt;

&lt;h2 id="the-experiments"&gt;The experiments&lt;/h2&gt;

&lt;p&gt;Obviously I didn't want to train a model over 10B tokens (not least because I'd need
to download a larger sample of FineWeb).  And I certainly didn't want to do 42 epochs
of training, given that one epoch over 3.2B tokens took almost two days, even with
&lt;a href="/2026/07/llm-from-scratch-34b-building-and-training-gpt-2-small-in-jax"&gt;my relatively fast JAX code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But it seemed plausible that I'd be able to get at least some signal if I trained
longer.  I decided to use &lt;a href="/2026/07/poppy-the-training-box-1-the-beginnings"&gt;&lt;code&gt;poppy&lt;/code&gt;, my new dedicated training box&lt;/a&gt; to train two new models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Firstly, I'd train one on 6.4B tokens from my FineWeb dataset -- the original 3.2B
that I had been training on to date, and then on whatever 3.2B came next.&lt;/li&gt;
&lt;li&gt;Secondly, I'd train another one on the same 3.2B tokens as usual, but I'd do two
epochs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I expected each training run to take a bit less than four days on &lt;code&gt;poppy&lt;/code&gt;.
Once they were done, I would be able to evaluate the models, both with the test loss eval and the
IFT one.&lt;/p&gt;

&lt;p&gt;In an unusual-for-me fit of scientific good practice, I decided to write down what I expected to
see up-front.  I felt that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both models would get better results on the test loss eval than my existing ones
(90% probability).  The one trained on more tokens would be better than the
one trained for two epochs on the same tokens (70%).&lt;/li&gt;
&lt;li&gt;Both models would score better than my existing models in the IFT test (70%) but
would still be worse than GPT-2 small (90%).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It was time to find out!&lt;/p&gt;

&lt;h2 id="the-extended-train-model"&gt;The extended-train model&lt;/h2&gt;

&lt;p&gt;I kicked off the extended train, over 6.4B unique tokens, using my JAX code because it's
somewhat faster than the PyTorch version.  It crashed after about 40 hours, with
this error:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;jax.errors.JaxRuntimeError: INTERNAL: CUDA error: Failed to end stream capture:&lt;/span&gt;
&lt;span class="go"&gt;CUDA_ERROR_STREAM_CAPTURE_INVALIDATED: operation failed due to a previous error&lt;/span&gt;
&lt;span class="go"&gt;during capture [executable_name=&amp;#39;jit_train_step&amp;#39;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;There were no obvious issues with the machine -- the CPU temperature had been hovering
at around 60°C, and GPU at around 70°C, as had been typical in training runs on &lt;code&gt;poppy&lt;/code&gt; in the past.
There was nothing in &lt;code&gt;journalctl&lt;/code&gt; or &lt;code&gt;dmesg&lt;/code&gt; that looked suspicious.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://docs.nvidia.com/dl-cuda-graph/troubleshooting/capture-failures.html"&gt;Nvidia's documentation site&lt;/a&gt;
I found a mention of that error message, but the page in question was all about PyTorch;
I couldn't find anything relevant to JAX.&lt;/p&gt;

&lt;p&gt;For now, I decided to chalk it up to some bug somewhere in the training stack, and only
dig in if it happened again.  So I kicked the training run off again from the latest checkpoint to see what happened,
and 38 hours later, it completed:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;Training complete in 140,667.401 seconds&lt;/span&gt;
&lt;span class="go"&gt;2026-07-24 03:36:02.148127 Tokens seen: 3,191,341,056&lt;/span&gt;
&lt;span class="go"&gt;2026-07-24 03:36:02.148133 Throughput: 22,687 tokens/second&lt;/span&gt;
&lt;span class="go"&gt;2026-07-24 03:36:02.148155 Final train loss: 2.979&lt;/span&gt;
&lt;span class="go"&gt;2026-07-24 03:36:02.148163 Done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Note that the numbers there -- tokens seen, time taken, and so on -- are for the
portion of the training run after the restart.  My checkpoint recovery code doesn't carry
those over.  The final train loss is just the loss over the period from the penultimate
checkpoint to the end of the run -- there must have been some "easy" data there :-)&lt;/p&gt;

&lt;p&gt;The training loss chart looked like this:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/why-do-openai-gpt2-weights-beat-mine-3-overtraining/extended-train-loss-chart.png" alt="Loss chart for the extended run" title="Loss chart for the extended run" /&gt;&lt;/p&gt;

&lt;p&gt;As you can see, the latest checkpoint wasn't the "best" one, so I pulled both latest and best down to
&lt;code&gt;perry&lt;/code&gt;, my workstation, for investigation.&lt;/p&gt;

&lt;p&gt;Firstly, I ran them both through my JAX smoke test, which asks them to complete
"Every effort moves you" with 20 more tokens (using greedy sampling):&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_generation.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/best/model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;forward.
The&lt;span class="w"&gt; &lt;/span&gt;best&lt;span class="w"&gt; &lt;/span&gt;way&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;get&lt;span class="w"&gt; &lt;/span&gt;started&lt;span class="w"&gt; &lt;/span&gt;is&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;start&lt;span class="w"&gt; &lt;/span&gt;with&lt;span class="w"&gt; &lt;/span&gt;a&lt;span class="w"&gt; &lt;/span&gt;simple,&lt;span class="w"&gt; &lt;/span&gt;easy&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;use&lt;span class="w"&gt; &lt;/span&gt;online
giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_generation.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/latest/model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;forward.
-&lt;span class="w"&gt; &lt;/span&gt;You&lt;span class="w"&gt; &lt;/span&gt;are&lt;span class="w"&gt; &lt;/span&gt;a&lt;span class="w"&gt; &lt;/span&gt;leader.
-&lt;span class="w"&gt; &lt;/span&gt;You&lt;span class="w"&gt; &lt;/span&gt;are&lt;span class="w"&gt; &lt;/span&gt;a&lt;span class="w"&gt; &lt;/span&gt;leader.
-&lt;span class="w"&gt; &lt;/span&gt;You&lt;span class="w"&gt; &lt;/span&gt;are
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Very inspirational.  But coherent, and that's what matters.&lt;/p&gt;

&lt;p&gt;Now, the bulk of my evals use PyTorch rather than JAX -- I've been
sticking to that for consistency -- so I converted the model Safetensors files so that
they had the right structure to work with that:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;convert_model_to_pytorch.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/best/model.safetensors&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/best/pytorch-model.safetensors
giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;convert_model_to_pytorch.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/latest/model.safetensors&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/latest/pytorch-model.safetensors
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...and did the equivalent smoke test on that side (which uses non-greedy decoding
with a temperature of 1):&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_smoke.py&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/best/pytorch-model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;along&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;try&lt;span class="w"&gt; &lt;/span&gt;the&lt;span class="w"&gt; &lt;/span&gt;recipes&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;each&lt;span class="w"&gt; &lt;/span&gt;and&lt;span class="w"&gt; &lt;/span&gt;every&lt;span class="w"&gt; &lt;/span&gt;one&lt;span class="w"&gt; &lt;/span&gt;of&lt;span class="w"&gt; &lt;/span&gt;them&lt;span class="w"&gt; &lt;/span&gt;and&lt;span class="w"&gt; &lt;/span&gt;try&lt;span class="w"&gt; &lt;/span&gt;it&lt;span class="w"&gt; &lt;/span&gt;all&lt;span class="w"&gt; &lt;/span&gt;over&lt;span class="w"&gt; &lt;/span&gt;again.&lt;span class="w"&gt; &lt;/span&gt;You
giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_smoke.py&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/latest/pytorch-model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;a&lt;span class="w"&gt; &lt;/span&gt;conclusion.
If&lt;span class="w"&gt; &lt;/span&gt;the&lt;span class="w"&gt; &lt;/span&gt;answer&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;each&lt;span class="w"&gt; &lt;/span&gt;of&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;goals&lt;span class="w"&gt; &lt;/span&gt;is&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;get&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;where&lt;span class="w"&gt; &lt;/span&gt;we�
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I think that the Unicode junk at the end of the second is probably the first half
of a two-token apostrophe or something along those lines.  Anyway, those looked good.&lt;/p&gt;

&lt;p&gt;Next, it was time to run the first eval: how would the models perform on the held-back
test set?&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_loss.py&lt;span class="w"&gt; &lt;/span&gt;datasets/&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/best/pytorch-model.safetensors
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;593&lt;/span&gt;.57it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3200&lt;/span&gt;/3200&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;04&lt;/span&gt;:54&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.88it/s&lt;span class="o"&gt;]&lt;/span&gt;
Loss&lt;span class="w"&gt; &lt;/span&gt;against&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dataset:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.325897
giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_loss.py&lt;span class="w"&gt; &lt;/span&gt;datasets/&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/latest/pytorch-model.safetensors
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1508&lt;/span&gt;.74it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3200&lt;/span&gt;/3200&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;04&lt;/span&gt;:54&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.86it/s&lt;span class="o"&gt;]&lt;/span&gt;
Loss&lt;span class="w"&gt; &lt;/span&gt;against&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dataset:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.324953
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;So, the "latest" checkpoint was better than the "best" one.&lt;/p&gt;

&lt;p&gt;This is a problem with
the way I define "best" in my training script.  The original version of that script used a validation
set to evaluate the model before every checkpoint; "best" at that point meant that the
model was the best performer on that validation set.  Later on, I decided to play a little
loose with my training runs and pull out the validation -- this seemed &lt;em&gt;reasonably&lt;/em&gt;
safe because I was doing single-epoch training, so what I saw as the primary benefit
of regular validation during training -- detecting overfitting by looking for rising
validation loss -- was not so important.  It's hard for a model to overfit on a single
epoch training run.&lt;/p&gt;

&lt;p&gt;However, doing that meant that "best" had to be changed
to mean "best-performing on the training data".  And that's actually a really bad metric,
because the training data is different for each measurement, so they're not really
comparable.&lt;/p&gt;

&lt;p&gt;So, for this model, I decided that I'd discard the "best" checkpoint, and use the "latest" one.&lt;/p&gt;

&lt;p&gt;But all of that aside, those numbers were pretty impressive!  Not only did it beat my
best Chinchilla-optimal model to date,
which had got 3.418784 loss on the same eval, and the original OpenAI small model with a loss of 3.499677,
but it was actually getting quite close to the OpenAI medium's loss of 3.231442. &lt;sup class="footnote-ref" id="fnref-3"&gt;&lt;a href="#fn-3"&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Promising!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: The model is now on Hugging Face as &lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-no-dropout-extended"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-no-dropout-extended&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So now it was time for the two-epoch training run.&lt;/p&gt;

&lt;h2 id="the-two-epoch-model"&gt;The two-epoch model&lt;/h2&gt;

&lt;p&gt;Adding support for handling multiple epochs to the code was &lt;a href="https://github.com/gpjt/jax-gpt2-from-scratch/commit/3cfb1dee50c0a36750c3c5132811c7aea09518d8"&gt;very simple&lt;/a&gt;,
so having done that, I kicked it off.&lt;/p&gt;

&lt;p&gt;Just over three days later:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;Training complete in 282,366.091 seconds&lt;/span&gt;
&lt;span class="go"&gt;2026-07-27 20:14:26.124497 Tokens seen: 6,520,504,320&lt;/span&gt;
&lt;span class="go"&gt;2026-07-27 20:14:26.124503 Throughput: 23,092 tokens/second&lt;/span&gt;
&lt;span class="go"&gt;2026-07-27 20:14:26.124518 Final train loss: 3.547&lt;/span&gt;
&lt;span class="go"&gt;2026-07-27 20:14:26.124524 Done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;This time the numbers are for the full training run -- there were no odd CUDA issues,
and it ran straight through.  Again, the final train loss is just the loss over the period from the penultimate
checkpoint to the end of the run.  For runs over the same dataset, it can actually
be a useful quick-and-dirty way to compare results before running the full evals,
but in this case it's obviously not comparable with the last run's result there,
because we're talking about loss on different data.&lt;/p&gt;

&lt;p&gt;Anyway, once again, "best" and "latest" were different checkpoints, as you can see from
the loss chart:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/why-do-openai-gpt2-weights-beat-mine-3-overtraining/two-epoch-loss-chart.png" alt="Loss chart for the two-epoch run" title="Loss chart for the two-epoch run" /&gt;&lt;/p&gt;

&lt;p&gt;...so I pulled them both
to &lt;code&gt;perry&lt;/code&gt;, did the first smoke test:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_generation.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/best/model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;forward,&lt;span class="w"&gt; &lt;/span&gt;and&lt;span class="w"&gt; &lt;/span&gt;you’ll&lt;span class="w"&gt; &lt;/span&gt;be&lt;span class="w"&gt; &lt;/span&gt;able&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;see&lt;span class="w"&gt; &lt;/span&gt;the&lt;span class="w"&gt; &lt;/span&gt;results.
The&lt;span class="w"&gt; &lt;/span&gt;best&lt;span class="w"&gt; &lt;/span&gt;way&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;get
giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_generation.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/latest/model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;forward,&lt;span class="w"&gt; &lt;/span&gt;and&lt;span class="w"&gt; &lt;/span&gt;you’re&lt;span class="w"&gt; &lt;/span&gt;not&lt;span class="w"&gt; &lt;/span&gt;alone.
The&lt;span class="w"&gt; &lt;/span&gt;most&lt;span class="w"&gt; &lt;/span&gt;important&lt;span class="w"&gt; &lt;/span&gt;thing&lt;span class="w"&gt; &lt;/span&gt;is&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;be&lt;span class="w"&gt; &lt;/span&gt;able&lt;span class="w"&gt; &lt;/span&gt;to
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...converted them to PyTorch:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;convert_model_to_pytorch.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/best/model.safetensors&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/best/pytorch-model.safetensors
giles@perry:~/Dev/jax-gpt2-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;convert_model_to_pytorch.py&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/latest/model.safetensors&lt;span class="w"&gt; &lt;/span&gt;runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/latest/pytorch-model.safetensors
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Did the PyTorch smoke test:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_smoke.py&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/best/pytorch-model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;take&lt;span class="w"&gt; &lt;/span&gt;on&lt;span class="w"&gt; &lt;/span&gt;a&lt;span class="w"&gt; &lt;/span&gt;larger&lt;span class="w"&gt; &lt;/span&gt;responsibility.
We’re&lt;span class="w"&gt; &lt;/span&gt;here&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;help&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;you.
We�
giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_smoke.py&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/latest/pytorch-model.safetensors
Every&lt;span class="w"&gt; &lt;/span&gt;effort&lt;span class="w"&gt; &lt;/span&gt;moves&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;closer,&lt;span class="w"&gt; &lt;/span&gt;and&lt;span class="w"&gt; &lt;/span&gt;you&lt;span class="w"&gt; &lt;/span&gt;become&lt;span class="w"&gt; &lt;/span&gt;a&lt;span class="w"&gt; &lt;/span&gt;better&lt;span class="w"&gt; &lt;/span&gt;person&lt;span class="w"&gt; &lt;/span&gt;as&lt;span class="w"&gt; &lt;/span&gt;the&lt;span class="w"&gt; &lt;/span&gt;year&lt;span class="w"&gt; &lt;/span&gt;progresses.”
There’s
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;So that all looked good (despite the Unicode junk), and it was time to work out the
loss:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_loss.py&lt;span class="w"&gt; &lt;/span&gt;datasets/&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/best/pytorch-model.safetensors
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3507&lt;/span&gt;.68it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3200&lt;/span&gt;/3200&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;04&lt;/span&gt;:55&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.83it/s&lt;span class="o"&gt;]&lt;/span&gt;
Loss&lt;span class="w"&gt; &lt;/span&gt;against&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dataset:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.332157
giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_loss.py&lt;span class="w"&gt; &lt;/span&gt;datasets/&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-baseline/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/latest/pytorch-model.safetensors
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1760&lt;/span&gt;.09it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3200&lt;/span&gt;/3200&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;04&lt;/span&gt;:53&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.90it/s&lt;span class="o"&gt;]&lt;/span&gt;
Loss&lt;span class="w"&gt; &lt;/span&gt;against&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dataset:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.326482
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That was pleasingly in line with my predictions:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Both models would get better results on the test loss eval than my existing ones
  (90% probability).  The one trained on more tokens would be better than the
  one trained for two epochs on the same tokens (70%).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;...though of course the difference between the 3.326482 that this model got and the
3.324953 that the one-long-epoch one got was tiny and probably in the noise.  I decided to
count it as a win, anyway :-)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: The model is now on Hugging Face as &lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-no-dropout-2-epoch"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-no-dropout-2-epoch&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So now it was time for the big test: how would they do at instruction-following?&lt;/p&gt;

&lt;h2 id="at-last-the-ift-test"&gt;At last, the IFT test!&lt;/h2&gt;

&lt;p&gt;There are two phases to getting numbers for this test: firstly, I run
&lt;a href="https://github.com/gpjt/ddp-base-model-from-scratch/blob/e71f33fb6aa421ec646b71f5cb69f3e3368c0d84/ift_generate_test_responses.py"&gt;the script&lt;/a&gt;
that does the fine-tuning, and then generates the model's completions for the
test set using the fine-tuned model.  These are written to a JSON file for later
use by the LLM-as-a-judge script.  That script is the one I fixed in my &lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-2-the-bugfix"&gt;previous post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I ran it for the extended train (one long epoch) model, taking care to make sure
that the config it was passed matched the model's original training run in not
having dropout enabled:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;ift_generate_test_responses.py&lt;span class="w"&gt; &lt;/span&gt;jax-mha-bias-extended-train&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-stacked-interventions/model.json&lt;span class="w"&gt; &lt;/span&gt;~/Dev/jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-extended/checkpoints/latest/pytorch-model.safetensors
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.23it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.54it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.54it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.58it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;rising,&lt;span class="w"&gt; &lt;/span&gt;bailing&lt;span class="w"&gt; &lt;/span&gt;out
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;110&lt;/span&gt;/110&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:08&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;13&lt;/span&gt;.40it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Next, I ran it for the two-epoch model:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;ift_generate_test_responses.py&lt;span class="w"&gt; &lt;/span&gt;jax-mha-bias-2-epoch&lt;span class="w"&gt; &lt;/span&gt;runs/1xrtx3090-stacked-interventions/model.json&lt;span class="w"&gt; &lt;/span&gt;~/Dev/jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-2-epoch/checkpoints/latest/pytorch-model.safetensors
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.13it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.22it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.34it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.48it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;still&lt;span class="w"&gt; &lt;/span&gt;decreasing,&lt;span class="w"&gt; &lt;/span&gt;continuing
Epoch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;116&lt;/span&gt;/116&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:06&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;.32it/s&lt;span class="o"&gt;]&lt;/span&gt;
Val&lt;span class="w"&gt; &lt;/span&gt;loss&lt;span class="w"&gt; &lt;/span&gt;rising,&lt;span class="w"&gt; &lt;/span&gt;bailing&lt;span class="w"&gt; &lt;/span&gt;out
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;110&lt;/span&gt;/110&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:10&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.06it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;With that done, it was time to run &lt;a href="https://github.com/gpjt/ddp-base-model-from-scratch/blob/e71f33fb6aa421ec646b71f5cb69f3e3368c0d84/ift_judge.py"&gt;the LLM as a judge script&lt;/a&gt;.
That gave us results, which I've put into the table below.  For each model, I've shown
the number of epochs of training the model needed before its
validation loss started rising.  For the pre-existing models, I've shown the
score that they got in my baseline evaluation at the end of my
&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-2-the-bugfix"&gt;last post&lt;/a&gt;, and their
ranking in that eval.  Then for all models, there's the score from this new run, and
the new ranking. The two new models are in
bold.&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
  &lt;th&gt;IFT epochs&lt;/th&gt;
  &lt;th&gt;Old IFT score&lt;/th&gt;
  &lt;th&gt;Old IFT rank&lt;/th&gt;
  &lt;th&gt;New IFT score&lt;/th&gt;
  &lt;th&gt;New IFT rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: medium&lt;/td&gt;
  &lt;td&gt;3.231442&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;42.41&lt;/td&gt;
  &lt;td&gt;1&lt;/td&gt;
  &lt;td&gt;42.91&lt;/td&gt;
  &lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;strong&gt;JAX, overtrained one long epoch&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;3.324953&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;&lt;/td&gt;
  &lt;td&gt;&lt;/td&gt;
  &lt;td&gt;18.75&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;strong&gt;JAX, overtrained two normal epochs&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;3.326482&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;&lt;/td&gt;
  &lt;td&gt;&lt;/td&gt;
  &lt;td&gt;18.45&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;18.12&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;17.53&lt;/td&gt;
  &lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.420089&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;20.72&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;20.25&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;3.476802&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;16.98&lt;/td&gt;
  &lt;td&gt;8&lt;/td&gt;
  &lt;td&gt;16.73&lt;/td&gt;
  &lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: small&lt;/td&gt;
  &lt;td&gt;3.499677&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;26.11&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;25.66&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.538161&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.68&lt;/td&gt;
  &lt;td&gt;11&lt;/td&gt;
  &lt;td&gt;13.35&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.577761&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;9.81&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
  &lt;td&gt;10.33&lt;/td&gt;
  &lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 40 GiB&lt;/td&gt;
  &lt;td&gt;3.673623&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;19.45&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;19.76&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.683835&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;13.39&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
  &lt;td&gt;13.74&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.691526&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.64&lt;/td&gt;
  &lt;td&gt;9&lt;/td&gt;
  &lt;td&gt;14.11&lt;/td&gt;
  &lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x H100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.724507&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.84&lt;/td&gt;
  &lt;td&gt;10&lt;/td&gt;
  &lt;td&gt;13.84&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.729900&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;11.30&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
  &lt;td&gt;11.13&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x B200 160 GiB&lt;/td&gt;
  &lt;td&gt;3.771478&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;11.82&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
  &lt;td&gt;11.22&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb train&lt;/td&gt;
  &lt;td&gt;3.943522&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;8.93&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
  &lt;td&gt;8.73&lt;/td&gt;
  &lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu extended train&lt;/td&gt;
  &lt;td&gt;4.134991&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.10&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.42&lt;/td&gt;
  &lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu train&lt;/td&gt;
  &lt;td&gt;4.166892&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.28&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;17.61&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;blockquote&gt;
  &lt;p&gt;A note on the numbers: in my previous post, I mentioned that different LLM judge
  runs differ due to randomness in how "strict" the judge model is.  You can see that
  showing up here -- most of the old/new IFT scores are pretty close, within a point or so,
  but they do differ, some rising and some falling.
  This is with exactly the same set of responses for each model going into the judging program -- I used the
  same JSON files for this table as I did for the previous one for the models that were in there
  -- the only difference was that the new JSON files for the new models were added.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As you can see, the new models scored better than the "JAX, with MHA bias, no dropout"
one, which is the most similar: it's the same model config, just trained for the
Chinchilla-optimal number of tokens.  The one long epoch model got a score that was
1.22 higher than that one, and the two-epoch one scored 0.92 higher.&lt;/p&gt;

&lt;p&gt;However, my normal rule of thumb for comparing models in these evals is that differences
of less than a point or two are &lt;em&gt;probably&lt;/em&gt; in the noise.&lt;/p&gt;

&lt;p&gt;I did a second run of the LLM judge script -- it takes 20 minutes to run and costs
a couple of dollars each time, so I don't like to run it all that often -- and this time
around (just looking at the JAX numbers) things were a bit closer:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;IFT score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;strong&gt;JAX, overtrained one long epoch&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;18.45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;strong&gt;JAX, overtrained two normal epochs&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;19.45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;18.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;21.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;17.33&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;You can also see that the two new models have swapped places.&lt;/p&gt;

&lt;p&gt;So I think that the principled approach here is to say that the improvement is
almost certainly in the noise.  Perhaps if I did a very large number of runs of the judge
I'd get something more solid -- but what I'm hoping for is something a little more
unambiguous; some change that really moves the needle in an obvious way, clearly
outside the noise.&lt;/p&gt;

&lt;p&gt;That means that my second pre-registered prediction:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Both models would score better than my existing models in the IFT test (70%) but
  would still be worse than GPT-2 small (90%).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;...was half wrong.  I got the "worse than GPT-2 small" bit right, at least.
But while the models did look a bit better than the most similar pre-existing model,
(a) the difference was too small for me to be confident in it, and (b) they were
still worse than "JAX, no MHA bias, no dropout" and "Cloud FineWeb, 8x A100 40 GiB".&lt;/p&gt;

&lt;p&gt;What can we take away from this?&lt;/p&gt;

&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The hypothesis that I was trying to test was whether it was simply overtraining that
made the OpenAI weights better at this IFT evaluation than mine.  Frustratingly, I can't say that
the hypothesis was false.&lt;/p&gt;

&lt;p&gt;Perhaps there is an improvement gained by overtraining --
and the apparent gains which, on this experiment, appeared to be in the noise,
would have been consolidated if I'd trained for even longer -- perhaps the 42 epochs
on 10B tokens I suspect that the original weights were trained on?&lt;/p&gt;

&lt;p&gt;But equally, perhaps there is no benefit, and further training would have left the
models exactly where they were in the ranking.&lt;/p&gt;

&lt;p&gt;Intuitively, you'd think that further training of a model would make it better at
answering questions, at least up until the point that its parameters were "saturated"
and could not absorb new information without forgetting something else.  After all, a
model that's never seen "Jane Austen wrote 'Pride and Prejudice'" will never be able to
successfully answer when it's asked who the book's author was.&lt;/p&gt;

&lt;p&gt;But where that saturation point might be -- and indeed how much training you'd need to do to
get there -- is not obvious.&lt;/p&gt;

&lt;p&gt;It's an annoying place to finish this experiment, but I guess at least an inconclusive result
is better than never having run it at all.  And it was at least good to see the test
loss improvement that I expected.&lt;/p&gt;

&lt;p&gt;But given the opportunity cost of tying up &lt;code&gt;poppy&lt;/code&gt; in four-day training runs, I think I'll
look into other possibilities next.
As I was running this experiment, something came up -- and I'll post about that soon.
Luckily, this time it won't involve training more base models...&lt;/p&gt;

&lt;p&gt;&lt;a href="/2026/08/why-do-openai-gpt2-weights-beat-mine-4-ift-dropout"&gt;Here's a link to the next post in this series: digging into dropout&lt;/a&gt;.&lt;/p&gt;

&lt;div class="footnotes"&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li id="fn-1"&gt;
&lt;p&gt;It's also worth noting that by the same, um, token, the extra-large model, with
1,542M parameters, was &lt;em&gt;undertrained&lt;/em&gt; because the Chinchilla-optimal number of
tokens would have been about 31B.  Though that said, see later regarding epochs.&amp;#160;&lt;a href="#fnref-1" class="footnoteBackLink" title="Jump back to footnote 1 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-2"&gt;
&lt;p&gt;You might wonder whether training over the same tokens repeatedly over multiple epochs "counts" for
Chinchilla purposes.  Is training on 1.6B tokens over two epochs the same as
training on 3.2B tokens over one?  "&lt;a href="https://arxiv.org/abs/2305.16264"&gt;Scaling Data-Constrained Language Models&lt;/a&gt;"
poked into that in 2023, and from the abstract, came to the conclusion that you could do up to
four epochs over the same data without losing much value, but after that
returns diminished.  If that holds for GPT-2, and they really did train for 42
epochs, maybe they wasted a lot of time?  I'll need to read that paper in full
at some point.&amp;#160;&lt;a href="#fnref-2" class="footnoteBackLink" title="Jump back to footnote 2 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-3"&gt;
&lt;p&gt;There is a table of results later on in this post where you'll be able to
compare models easily.&amp;#160;&lt;a href="#fnref-3" class="footnoteBackLink" title="Jump back to footnote 3 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description><guid isPermaLink="false">/2026/07/why-do-openai-gpt2-weights-beat-mine-3-overtraining</guid><pubDate>Fri, 31 Jul 2026 01:15:00 +0000</pubDate></item><item><title>How I use AI on this blog</title><link>https://www.gilesthomas.com/2026/07/ai-use</link><description>&lt;p&gt;Inspired by &lt;a href="https://www.lesswrong.com/posts/tgigHkZoYrJEGe4tP/ai-use-policy-for-my-essay-writing"&gt;this LessWrong post&lt;/a&gt;,
I thought I'd write about how I use AI here.  This is less in the
interest of disclosure, more to provide a snapshot of what I'm doing right now so
that I can revisit it in the future and see how it changes.  And hey, maybe it'll be of
interest to you, dear readers.&lt;/p&gt;

&lt;p&gt;If I were to summarise my working philosophy in fewer than ten words, it would be: AIs identify
problems and I fix them myself.  With a very specific kind of exception (which I
always flag), the text and code on this blog are human-generated.  That's not a
moral stand, but more a constraint imposed by what this blog is meant to be -- a place for me to
&lt;a href="/2025/02/20250223-til-deep-dive-posts"&gt;learn in public&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="ideation-and-running-experiments"&gt;Ideation and running experiments&lt;/h3&gt;

&lt;p&gt;Every post here is based on an idea I had, and work that I've done.  For many posts
-- for example, the large-scale coding projects like &lt;a href="/2026/07/llm-from-scratch-34b-building-and-training-gpt-2-small-in-jax"&gt;this one&lt;/a&gt; --
I'll have multiple chat sessions ongoing while I do the work, normally with either
ChatGPT or Claude, or sometimes both.  The amount of input they have varies, but
because the value of these projects is in what I learn when I'm doing them, letting an AI
do my thinking for me would make the whole thing pointless -- so I take steps to stop that
from happening.&lt;/p&gt;

&lt;p&gt;AIs are, of course, trained to be helpful, and will often explain things in their
replies that I would have better learned on my own through experimentation.  I'm generally
pretty good at spotting when that happens before I've read more than a sentence or two,
though, so I can skip reading that part, scroll straight down to the input field, and ask it
to operate in more of a &lt;a href="https://en.wikipedia.org/wiki/Rubber_duck_debugging"&gt;rubber duck&lt;/a&gt; mode.&lt;/p&gt;

&lt;p&gt;With the most complicated projects, where each step has a hard dependency on having
got the previous one just right, I do use AIs for code review.  Let's say that I've
built a model that I intend to extend.  I'll test it myself (does the loss go down
when training, is it generating plausible-looking results?), but if I want to be really
cautious, then I'll run the code past an AI.  I'll paste it into a chat session and tell the LLM what it's meant to
do, and ask it to check if I've screwed up &lt;sup class="footnote-ref" id="fnref-1"&gt;&lt;a href="#fn-1"&gt;1&lt;/a&gt;&lt;/sup&gt;.  Again, though, I make it clear that I don't want
it to make fixes -- just to point out any bugs.&lt;/p&gt;

&lt;h3 id="writing-things-up-and-the-editorial-board"&gt;Writing things up, and the editorial board&lt;/h3&gt;

&lt;p&gt;As things progress with a project, I keep detailed notes.  When I'm done, I write them up
without AI assistance, getting the post to a level where it might be a little messy
in terms of how things are explained, but all of the important information is in there.
I read it through and make sure I'm reasonably happy with it, and then it's time for what I've taken to calling the editorial board.&lt;/p&gt;

&lt;p&gt;I paste the draft post into a fresh chat session with an LLM -- right now, this is normally
Claude -- and ask for comments.  It already has enough information in its memory of earlier conversations to
know that what I'm looking for: places where I'm confidently wrong
or other technical errors, places where my explanations are missing a step, or
where I'm overexplaining things, conclusions that don't really follow from the results of
an experiment, and that kind of issue.  It tends to spot a few
silly grammatical errors and typos at the same time.  An important standing instruction
is that I do &lt;em&gt;not&lt;/em&gt; want it to rewrite anything.  Just as with the code, it should tell me where there is a
problem, and let me fix it.&lt;/p&gt;

&lt;p&gt;We iterate on that for a while until we have something that we're both happy with,
and then I feed it to the next LLM -- normally ChatGPT.&lt;/p&gt;

&lt;p&gt;ChatGPT has a much more pernickety attitude than Claude does.  I often use metaphors,
and it will generally want me to replace them with mathematically rigorous prose.  This
is still very useful, though.  Sometimes the metaphors aren't flagged as such well
enough -- or even worse, there are times when the terms I've hit on for a metaphor
happen to clash with a technical term, making what I've written misleading at best.&lt;/p&gt;

&lt;p&gt;I don't always address all of the issues that ChatGPT raises, as otherwise every post
would be a mess of hedges and overexplanations and what-have-you, but I like to get
to a stage where I'm comfortable that I have a good understanding of specifically &lt;em&gt;why&lt;/em&gt; I'm rejecting
the remaining points it raises.&lt;/p&gt;

&lt;p&gt;One other point where ChatGPT has helped a lot is that
it's very diligent about checking supporting materials that I link to.  When I was recently
about to post an article on running an eval on a model, it followed the link to the
training code and spotted a silly bug.  It wasn't something that materially changed the
eval's outcome, which is probably why I'd not noticed it, but it was something that
was important to get right if I wanted later runs of the same evals to be solid.
Definitely helpful.&lt;/p&gt;

&lt;p&gt;With that done, I run it past a cast of other LLMs.  The exact set varies over time;
for the last few posts it has been (in this order) DeepSeek, Grok, GLM-5.2 and Kimi K3.  I
did use Gemini in the past, but over time it became less effective and just started
complimenting me on the post and suggesting related topics to chat about, which was
kind of pointless.  I'll wait until the next release and then try it again.&lt;/p&gt;

&lt;p&gt;Because the Claude and ChatGPT passes have generally got rid of anything particularly
nasty, this second group of AIs often don't have much to add.   However, occasionally they will spot
something the others have missed, or have other suggestions, so it's worth spending
the five minutes or so it takes to use them.  It also helps to keep me up to date
with what the other models out there are like. &lt;sup class="footnote-ref" id="fnref-2"&gt;&lt;a href="#fn-2"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;When all of that's done, I run it past Claude one final time, tidy up any remaining
issues, then publish it on a private staging site, and read through it carefully myself.
The best time for that final readthrough is after dinner, ideally after a glass of
wine; the goal is to smooth out the prose, and remove anything overly formal.  To make
it as close to being fun to read as I can manage.&lt;/p&gt;

&lt;p&gt;Once I'm happy, I can promote it to the live site and hit the publish button.&lt;/p&gt;

&lt;p&gt;That probably all sounds much more complicated than it actually is.  A short post
will normally go through all of that in half an hour -- less if I skip the full editorial board,
which I sometimes do.  The longer ones can take an hour
or two, but given that they're normally the result of a week of work, on and off,
in percentage terms it's not that much, and it's worth it for the polish.&lt;/p&gt;

&lt;p&gt;So, there's no AI-generated text here, but I do lean on AIs to make it the best
version I can of what I have to say.  How about the code?&lt;/p&gt;

&lt;h3 id="ai-coding"&gt;AI coding&lt;/h3&gt;

&lt;p&gt;Again, the goal of the projects I document here is to learn in public.
If I'm learning some concept that is expressed in code, then I need to write that
code.  So that means that anything non-trivial will be something I've written by
hand, with AI input limited to code review -- the same rule as I have for the text.&lt;/p&gt;

&lt;p&gt;Of course, sometimes there are things I'd like to publish that I wouldn't learn anything
by writing.  Coding up &lt;code&gt;matplotlib&lt;/code&gt; stuff to chart loss curves, or writing a fancy
JavaScript visualiser to &lt;a href="/2026/07/llm-parameter-counts"&gt;show what models' parameters are used for&lt;/a&gt;
would teach me nothing.  So for that kind of thing, I just let the AIs get on with it (and even
then, for the parameter visualisation, I hacked a first version together in a spreadsheet
to check my understanding, and then tested the visualiser against it).  I do always
mention in the text when a particular bit of code was AI-generated, though.&lt;/p&gt;

&lt;p&gt;So, my rule is: if I would learn something by writing the code, I'll write it.  If not,
I'm happy to delegate to an AI.&lt;/p&gt;

&lt;p&gt;But even then, I apply one restriction: if it's for the blog, I'll ask for the code in a
chat session, rather than using a more agentic system like Claude Code or Codex.  This is to add friction.
If you're using an agent, it's easy for a task to grow, and what started as a throwaway
idea can come to consume more and more time and cognitive space.  Keeping it in the
chat interface keeps things minimal -- or at least, that's how it works for me.&lt;/p&gt;

&lt;p&gt;Does that mean that I'm against coding agents?&lt;/p&gt;

&lt;h3 id="yes-to-agents-but-not-on-this-blog-yet"&gt;Yes to agents, but not on this blog -- yet&lt;/h3&gt;

&lt;p&gt;This blog is where I post about experiments I've done and what I've learned.
At the moment, what I'm learning is all pretty low-level.  How does an LLM work?  What
factors make it smarter or dumber?  It's all pretty hands-on, and involves code that
I need to understand.&lt;/p&gt;

&lt;p&gt;I do other things apart from writing this blog, of course :-)   And for that I'm keen on agentic
tools; I have an OpenClaw agent to help me run my life generally, and use Codex
and Claude Code for projects where I'm trying to achieve a specific goal, rather than trying
to learn something.  But by their very nature, those are not projects that will wind
up here on the blog right now.&lt;/p&gt;

&lt;p&gt;That might change in the future!  When I feel that I have a solid, large enough foundation,
perhaps I'll be running experiments that I want to write about, where it would make sense for AIs to handle the
details, while I focus on the broader strokes.&lt;/p&gt;

&lt;p&gt;But that time is not now, so right now, you can be sure that every word &lt;sup class="footnote-ref" id="fnref-3"&gt;&lt;a href="#fn-3"&gt;3&lt;/a&gt;&lt;/sup&gt;, and almost
every line of code, was written by hand.  Even if I do need the AIs to keep me on track
and at least borderline coherent.&lt;/p&gt;

&lt;div class="footnotes"&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li id="fn-1"&gt;
&lt;p&gt;If you're wondering "why not use Claude Code or Codex", I get into why I avoid
them for blog-related work towards the end of this post.&amp;#160;&lt;a href="#fnref-1" class="footnoteBackLink" title="Jump back to footnote 1 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-2"&gt;
&lt;p&gt;Current thoughts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DeepSeek used to be close to Claude and ChatGPT, but has been left somewhat
behind.  I have been hearing rumours on X of an upcoming update, though.&lt;/li&gt;
&lt;li&gt;Grok used to have a propensity to try to turn my posts into clickbait.
It would always want to rewrite things, and would suggest titles that were
not a million miles away from "Ten things you never knew about RNNs -- number four
will shock you!"  Recent releases have been much better, though, and I might
experiment with moving it further forward in the sequence.&lt;/li&gt;
&lt;li&gt;GLM-5.2 recently complimented me on my "science fiction" blogpost that mentioned
ChatGPT 5.6 Sol.  I probed it a bit on that and it said that because the publication
date on the post was in 2026, it understood that I was writing near-future
SF.  From its perspective, the real date was sometime in late 2024.  Surprising!  I think the last time I
saw that kind of behaviour from a model was, well, sometime in 2024...&lt;/li&gt;
&lt;li&gt;Kimi K3 is really quite impressive.  I will be using it more.  I particularly
like the way it shows a fairly detailed chain of thought -- something it
shares with DeepSeek and GLM-5.2, but there seems to be more depth there.
It's a pity that Claude and ChatGPT only show summaries in the chat interface, though I understand
their reasoning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="#fnref-2" class="footnoteBackLink" title="Jump back to footnote 2 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-3"&gt;
&lt;p&gt;ChatGPT, being wonderfully true to form, thinks I should clarify here that I'm
not including quotes from models here (like the ones &lt;a href="/2026/04/how-an-llm-becomes-more-coherent-over-training"&gt;here&lt;/a&gt;)
when I say that.&amp;#160;&lt;a href="#fnref-3" class="footnoteBackLink" title="Jump back to footnote 3 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description><guid isPermaLink="false">/2026/07/ai-use</guid><pubDate>Fri, 31 Jul 2026 19:00:00 +0000</pubDate></item><item><title>A quick(ish) Chinchilla check</title><link>https://www.gilesthomas.com/2026/08/chinchilla-check</link><description>&lt;p&gt;I recently &lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-3-overtraining"&gt;overtrained a couple of GPT-2 style models&lt;/a&gt;,
training them both on 40 tokens per parameter rather than the 20 per parameter
that is generally regarded as "Chinchilla-optimal".&lt;/p&gt;

&lt;p&gt;The normal heuristic is that instead of doing that, you should scale up the number
of tokens and the number of parameters equally -- so I would have been better off
scaling up the model by &lt;math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"&gt;&lt;mrow&gt;&lt;msqrt&gt;&lt;mrow&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/mrow&gt;&lt;/msqrt&gt;&lt;/mrow&gt;&lt;/math&gt; and the token count by the same amount.  By
doing that, I should expect to get a better model in terms of loss on my held-back
test set than I did with my 40-tokens-per-parameter models.&lt;/p&gt;

&lt;p&gt;My training machine &lt;code&gt;poppy&lt;/code&gt; wasn't doing anything, so I decided to give that a go.
Would the Chinchilla rule-of-thumb hold up?&lt;/p&gt;

&lt;p&gt;As you might expect, it did.  But it was a surprisingly close-run thing, and
could conceivably have been in the noise.  Let's take a look.&lt;/p&gt;
&lt;h3 id="the-chinchilla-heuristic"&gt;The Chinchilla heuristic&lt;/h3&gt;

&lt;blockquote&gt;
  &lt;p&gt;If you already know all about the Chinchilla paper -- regular readers in particular
  must be sick and tired of it by now :-) -- then &lt;a href="#scaling-the-model"&gt;click here to skip this section&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In &lt;a href="https://arxiv.org/pdf/2203.15556"&gt;"Training Compute-Optimal Large Language Models"&lt;/a&gt;,
which is always called the Chinchilla paper after the name of the model they trained at
the end, the authors tried to work out the optimal number of tokens to train an LLM on
based on its number of parameters.  In particular, they were pushing back on a trend
they were seeing at the time, where people were making models ever-larger, but not
increasing the amount of data they were training on.&lt;/p&gt;

&lt;p&gt;The authors were all at Google DeepMind, and this was the kind of project that only a large lab
could do: they trained "over 400 language models ranging from 70 million to over 16 billion
parameters on 5 to 500 billion tokens".  Their conclusion was "for compute-optimal training, the model size and
the number of training tokens should be scaled equally: for every doubling of model size the number
of training tokens should also be doubled".&lt;/p&gt;

&lt;p&gt;They don't actually state an overall optimal number of tokens to train on in the
paper, but in table 3 they provide an estimate of the optimal training FLOPs and tokens
for models of various sizes, and it's approximately 20 tokens per parameter.&lt;/p&gt;

&lt;p&gt;That number has become a heuristic, and people talk about a model as being trained
for the Chinchilla-optimal number of tokens.  Models that were trained on fewer
tokens per parameter are referred to as "undertrained", and models that were trained on
more as "overtrained".&lt;/p&gt;

&lt;p&gt;It's worth noting that overtraining a model is not, in itself, a bad thing.  If you have
a model of a particular size and you continue training it past the Chinchilla-optimal
number of tokens, it will -- in general -- get better.  The point of the heuristic
is that doing that is not the best way to spend whatever budget you have in terms of
compute time.  You'll get better results, as they say, by scaling the number of tokens
and the number of parameters equally.&lt;/p&gt;

&lt;p&gt;But let's say you're creating a model for specific target hardware -- say, a mobile device.
You have a hard restriction on how large the model can be -- the device has only so much
RAM to hold it.  So it might make sense to overtrain to get a better model. &lt;sup class="footnote-ref" id="fnref-1"&gt;&lt;a href="#fn-1"&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;But if you're not so limited in how many parameters you can use, then you should indeed scale the model up, and that's what I
wanted to try.  How would that work?&lt;/p&gt;

&lt;h3 id="scaling-the-model"&gt;Scaling the model&lt;/h3&gt;

&lt;p&gt;A week or two back, I was investigating whether I could make my GPT-2 style models
&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-3-overtraining"&gt;better at a specific instruction-following task by overtraining them&lt;/a&gt;.
The details of that experiment aren't important here, but what it meant was that
I had three GPT-2-style models, each of exactly the same size, roughly 163M parameters&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Chinchilla-optimal one, which I'll call &lt;code&gt;jax-gpt2-chinchilla&lt;/code&gt; here.&lt;/li&gt;
&lt;li&gt;One trained on twice the Chinchilla-optimal tokens, &lt;code&gt;jax-gpt2-2x-chinchilla&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One trained on the Chinchilla-optimal tokens, with two epochs (so that it
was trained for as long as #2): &lt;code&gt;jax-gpt2-2-epoch-chinchilla&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I tested them against a held-back test set of sequences -- stuff that they'd
never seen before -- they got results rather like you might expect:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-2x-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.324953&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-2-epoch-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.326482&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;A lower loss is better, and you can see that the longer-trained models were noticeably
better than the Chinchilla-optimal one.  The difference between them was tiny; they were
trained starting with the same initial weights, and the training runs themselves were deterministic,
but a difference of 0.05% in loss doesn't seem like it could be meaningful -- an extra batch for one
or one fewer for the other could easily swap them around, you'd think.&lt;/p&gt;

&lt;p&gt;Now, these models each had 163,009,536 parameters -- they were the small-size model
from the &lt;a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf"&gt;GPT-2 paper&lt;/a&gt;,
modified to not have QKV bias or weight-tying.  &lt;code&gt;jax-gpt2-chinchilla&lt;/code&gt; had been trained
on 3,260,190,720 tokens (rounded up to fit into a round number of full batches), and
the other two on 6,520,381,440 tokens each -- double the amount (rounded up too).&lt;/p&gt;

&lt;p&gt;What I needed to do for my Chinchilla check was to try training a model that used the
same amount of compute, scaling the parameters and the number of training tokens equally.
Because training compute increases roughly linearly with both parameters and tokens,
that would mean scaling both up by &lt;math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"&gt;&lt;mrow&gt;&lt;msqrt&gt;&lt;mrow&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/mrow&gt;&lt;/msqrt&gt;&lt;/mrow&gt;&lt;/math&gt;, giving us:&lt;/p&gt;

&lt;math xmlns="http://www.w3.org/1998/Math/MathML" display="block"&gt;&lt;mrow&gt;&lt;mn&gt;163&lt;/mn&gt;&lt;mo&gt;&amp;#x0002C;&lt;/mo&gt;&lt;mn&gt;009&lt;/mn&gt;&lt;mo&gt;&amp;#x0002C;&lt;/mo&gt;&lt;mn&gt;536&lt;/mn&gt;&lt;mo&gt;&amp;#x0002A;&lt;/mo&gt;&lt;msqrt&gt;&lt;mrow&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/mrow&gt;&lt;/msqrt&gt;&lt;mo&gt;&amp;#x02248;&lt;/mo&gt;&lt;mn&gt;230&lt;/mn&gt;&lt;mo&gt;&amp;#x0002C;&lt;/mo&gt;&lt;mn&gt;530&lt;/mn&gt;&lt;mo&gt;&amp;#x0002C;&lt;/mo&gt;&lt;mn&gt;296&lt;/mn&gt;&lt;mtext&gt;&amp;#x000A0;parameters&lt;/mtext&gt;&lt;/mrow&gt;&lt;/math&gt;

&lt;p&gt;...and thus 4,610,605,920 tokens.&lt;/p&gt;

&lt;p&gt;How to scale the model up?&lt;/p&gt;

&lt;p&gt;In the GPT-2 paper, they train four models:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;Name&lt;/th&gt;
  &lt;th&gt;Parameters &lt;sup class="footnote-ref" id="fnref-2"&gt;&lt;a href="#fn-2"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/th&gt;
  &lt;th&gt;Layers&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;d_emb&lt;/code&gt;&lt;/th&gt;
  &lt;th&gt;MHA heads &lt;sup class="footnote-ref" id="fnref-3"&gt;&lt;a href="#fn-3"&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;small&lt;/td&gt;
  &lt;td&gt;124M&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
  &lt;td&gt;768&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;medium&lt;/td&gt;
  &lt;td&gt;345M&lt;/td&gt;
  &lt;td&gt;24&lt;/td&gt;
  &lt;td&gt;1024&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;large&lt;/td&gt;
  &lt;td&gt;762M&lt;/td&gt;
  &lt;td&gt;36&lt;/td&gt;
  &lt;td&gt;1280&lt;/td&gt;
  &lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;xl&lt;/td&gt;
  &lt;td&gt;1542M&lt;/td&gt;
  &lt;td&gt;48&lt;/td&gt;
  &lt;td&gt;1600&lt;/td&gt;
  &lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;I wanted to scale my own model up from 163M parameters to about 231M.  Which of those
numbers would I want to increase, and by how much?&lt;/p&gt;

&lt;p&gt;The first thing that stands out is that the number of heads is &lt;em&gt;always&lt;/em&gt; 1/64th of
the number of embedding dimensions.  So that sorted that one out.  I just needed to
adjust the number of layers, and the number of embedding dimensions, but ensure that the
latter was a multiple of 64.&lt;/p&gt;

&lt;p&gt;I decided to see if I could fit some kind of curve to the relationship between
the number of parameters and the GPT-2 authors' choices.  This was made a bit more
complicated by one thing: they were using weight-tying, and I was not.  That meant
that they re-used the embedding matrix at the start of the LLM as an output head
at the end -- which is why they had 38M fewer parameters.  Embeddings and the
output head make up a &lt;a href="/2026/07/llm-parameter-counts"&gt;surprisingly large percentage of the parameters&lt;/a&gt; for
small models like this -- about 47% without weight-tying, 23% with.&lt;/p&gt;

&lt;p&gt;I couldn't work out a solid way to scale things up and wound up doing some rather
messy &lt;a href="https://docs.google.com/spreadsheets/d/1rgzoc7-bQfK43XQpp00htEgQ43b_ahtBwdrb_HfmIZk/edit?usp=sharing"&gt;hacking around in a spreadsheet&lt;/a&gt;.
I came up with two proposed model sizes that were within a couple of percentage
points of the right size:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;Name&lt;/th&gt;
  &lt;th&gt;Layers&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;d_emb&lt;/code&gt;&lt;/th&gt;
  &lt;th&gt;MHA heads&lt;/th&gt;
  &lt;th&gt;Parameters&lt;/th&gt;
  &lt;th&gt;% diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;slightly-larger&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
  &lt;td&gt;896&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
  &lt;td&gt;235,621,120&lt;/td&gt;
  &lt;td&gt;+2.21%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;slightly-smaller&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
  &lt;td&gt;896&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
  &lt;td&gt;225,978,368&lt;/td&gt;
  &lt;td&gt;-1.97%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Interestingly, I found that because &lt;code&gt;d_emb&lt;/code&gt; could only change in increments/decrements
of 64, it was a pretty coarse control -- my first attempt at making a &lt;code&gt;slightly-smaller&lt;/code&gt;
model changed it to the next step down, 832, but that led to a model that was
9.25% too small.&lt;/p&gt;

&lt;p&gt;That was an interesting first lesson.  I'd previously been thinking of the
Chinchilla rule as being something like "don't double the tokens, just scale the model
and the tokens equally".  But that "just" was wrong.  Scaling a model is hard --
even with just two dials to fiddle with, like in this case, it was tricky to get
something right -- and I can't say for sure that my choices were the right ones.&lt;/p&gt;

&lt;p&gt;Anyway, the next step was to double-check that these models would use the right amount
of compute to train.&lt;/p&gt;

&lt;h3 id="training-flops"&gt;Training FLOPs&lt;/h3&gt;

&lt;p&gt;As I said earlier, the compute time scales roughly linearly with the number of
parameters.  Let's dig into that "roughly".&lt;/p&gt;

&lt;p&gt;Different kinds of parameters take different amounts of FLOPs to train, and scale
differently with things like the embedding dimensions, sequence length, and so on.&lt;/p&gt;

&lt;p&gt;Now, for very large models, a lot of that comes out in the wash, but with tiny models
like these where the embeddings make up such a large proportion of the parameters, it
might matter.&lt;/p&gt;

&lt;p&gt;Conveniently, in appendix F of the Chinchilla paper, they provide a set of formulae for estimating the
number of training FLOPs for a normal dense LLM like these ones.  I coded that up into
&lt;a href="https://github.com/gpjt/chinchilla-flop-calculator"&gt;a script&lt;/a&gt; that, given the JSON
configuration files I was using for my models and training runs, would work out the
number of FLOPs for a single epoch of training.  It didn't take account of the fact that
my real training runs round the number of tokens up so that we do a round number of
full batches, but I felt that so long as the results weren't &lt;em&gt;very&lt;/em&gt; close that wouldn't matter.&lt;/p&gt;

&lt;p&gt;I got these results (multiplying the two-epoch numbers by two):&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Est. training FLOPs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3,544,967,596,946,227,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-2x-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;7,089,935,193,892,454,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-2-epoch-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;7,089,935,193,892,454,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;slightly-larger&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;7,419,664,885,127,577,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;slightly-smaller&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;6,804,429,215,367,168,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The numbers were indeed different enough that I wasn't worried about the batch-rounding.
And the good news was that &lt;code&gt;slightly-larger&lt;/code&gt; and &lt;code&gt;slightly-smaller&lt;/code&gt; would indeed use
slightly more and slightly less compute to train than the overtrained models --
about 4.6% more and 4% less respectively.  A true Chinchilla-equivalent model would
lie somewhere between them.&lt;/p&gt;

&lt;p&gt;It was time to train some models!&lt;/p&gt;

&lt;h3 id="training"&gt;Training&lt;/h3&gt;

&lt;p&gt;I kicked off the run for the &lt;code&gt;slightly-larger&lt;/code&gt; model first.  Because it was bigger than the
163M models I'd been training, I couldn't
fit such large batches into my VRAM; previously I'd been running with a batch size of
6, and now I could only fit in a batch of 4.  Luckily, though, I was using
&lt;a href="/2026/04/llm-from-scratch-32k-interventions-training-our-best-model-locally-gradient-accumulation"&gt;gradient accumulation&lt;/a&gt;,
so by bumping that up from 16 steps to 24 steps I could keep the same
overall batch size and keep the training runs comparable.&lt;/p&gt;

&lt;p&gt;Even despite that, the training run ran out of VRAM about 60 hours in -- I'm guessing
due to VRAM fragmentation, as I did not have &lt;code&gt;TF_GPU_ALLOCATOR&lt;/code&gt; set to &lt;code&gt;cuda_malloc_async&lt;/code&gt; --
but I was able to restart from the most recent checkpoint and complete the run.
After just less than four days total training time, it completed.&lt;/p&gt;

&lt;p&gt;When it was done, I copied the last checkpoint &lt;sup class="footnote-ref" id="fnref-4"&gt;&lt;a href="#fn-4"&gt;4&lt;/a&gt;&lt;/sup&gt; over to my dev box, &lt;code&gt;perry&lt;/code&gt;, and
ran my standard smoke test against it, asking
it to complete "Every effort moves you" with 20 tokens, using greedy sampling.  I got
something reasonably coherent:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Every effort moves you.
I’m not sure what you’re thinking.
I’m
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, I converted the safetensors file -- which had been saved by my JAX code --
into a format compatible with my PyTorch code, because that's what I use for evals.
I ran another smoke test (this one with temperature 1):&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Every effort moves you through the motions for your life, your soul, your body,
and your soul’s happiness
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Very spiritual.  Next, it was time to work out the loss on my held-back test set:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_loss.py&lt;span class="w"&gt; &lt;/span&gt;datasets/&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-larger-chinchilla/model.json&lt;span class="w"&gt; &lt;/span&gt;../jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-larger-chinchilla/checkpoints/latest/pytorch-model.safetensors
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3485&lt;/span&gt;.09it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3200&lt;/span&gt;/3200&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;07&lt;/span&gt;:11&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;7&lt;/span&gt;.42it/s&lt;span class="o"&gt;]&lt;/span&gt;
Loss&lt;span class="w"&gt; &lt;/span&gt;against&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dataset:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.280028
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Well, it was certainly better than the 3.324953 that the best of the overtrained
models got -- but only by a bit over 1% better.  Interesting!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: The model is now on Hugging Face as &lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-larger-chinchilla-1"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-larger-chinchilla-1&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I decided to train the second model, &lt;code&gt;slightly-smaller&lt;/code&gt;.  This one crashed mid-way
through with an error that I've seen before:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;jax.errors.JaxRuntimeError: INTERNAL: CUDA error: Failed to end stream capture: CUDA_ERROR_STREAM_CAPTURE_INVALIDATED: operation failed due to a previous error during capture [executable_name=&amp;#39;jit_train_step&amp;#39;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I'm going to have to investigate that more in future, but for now, I just restarted
from the checkpoint, and again after a bit less than four days, I had a model.&lt;/p&gt;

&lt;p&gt;The JAX smoke test was solid:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Every effort moves you forward.
The best way to get started is to start with a free trial.
You can
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;...and so was the PyTorch one:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Every effort moves you forward in love with our products.
I love the way it’s easy to use.
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Both quite commercial this time!  It was time for the proper test loss eval:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@perry:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;test_loss.py&lt;span class="w"&gt; &lt;/span&gt;datasets/&lt;span class="w"&gt; &lt;/span&gt;~/Dev/jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-larger-chinchilla-2/model.json&lt;span class="w"&gt; &lt;/span&gt;~/Dev/jax-gpt2-from-scratch/runs/full-llm-full-train-with-mha-output-bias-larger-chinchilla-2/checkpoints/latest/pytorch-model.safetensors
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3151&lt;/span&gt;.24it/s&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3200&lt;/span&gt;/3200&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;06&lt;/span&gt;:45&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;7&lt;/span&gt;.90it/s&lt;span class="o"&gt;]&lt;/span&gt;
Loss&lt;span class="w"&gt; &lt;/span&gt;against&lt;span class="w"&gt; &lt;/span&gt;our&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dataset:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.292937
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;So, slightly worse than the 3.280028 from the larger model, better than the
3.324953 from the best overtrained one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: The model is now on Hugging Face as &lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-larger-chinchilla-2"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-larger-chinchilla-2&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Time to put this all together.&lt;/p&gt;

&lt;h3 id="results"&gt;Results&lt;/h3&gt;

&lt;p&gt;Here's an updated version of the table from the start of this post; I've added in
the two new models, and the improvement they each had over &lt;code&gt;jax-gpt2-2x-chinchilla&lt;/code&gt;
in both absolute terms and as a percentage rounded to 3sf.&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
  &lt;th&gt;Improvement&lt;/th&gt;
  &lt;th&gt;Improvement %&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;slightly-larger&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.280028&lt;/td&gt;
  &lt;td&gt;0.044925&lt;/td&gt;
  &lt;td&gt;1.35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;slightly-smaller&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.292937&lt;/td&gt;
  &lt;td&gt;0.032016&lt;/td&gt;
  &lt;td&gt;0.962%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-2x-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.324953&lt;/td&gt;
  &lt;td&gt;-&lt;/td&gt;
  &lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-2-epoch-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.326482&lt;/td&gt;
  &lt;td&gt;-&lt;/td&gt;
  &lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;jax-gpt2-chinchilla&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
  &lt;td&gt;-&lt;/td&gt;
  &lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Now, unlike the overtrained models, prior to training these two new ones started with different
initial weights to the &lt;code&gt;jax-gpt2-chinchilla&lt;/code&gt; one -- after all, they had to, because
they had more of them!&lt;/p&gt;

&lt;p&gt;A while back, I did a &lt;a href="/2026/04/llm-from-scratch-32i-interventions-what-is-in-the-noise"&gt;bit of analysis&lt;/a&gt;
of how random variation in weight initialisation can change the resulting test loss.
It wasn't anything in-depth, but I trained three models with different explicit seeds
set prior to the model initialisation, but with the same seed set before the training
run started &lt;sup class="footnote-ref" id="fnref-5"&gt;&lt;a href="#fn-5"&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;Those three models wound up with test losses of 3.681356, 3.673943, and 3.664345.
Doing statistics with three data points is a bit flaky, but the cost of training
models is so high that I'll leave the Proper Science to the likes of Google DeepMind
and wing it :-)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mean: ~3.673215&lt;/li&gt;
&lt;li&gt;Sample variance: ~0.000073&lt;/li&gt;
&lt;li&gt;Standard deviation (SD): ~0.008529&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, piling statistical flakiness on statistical flakiness, we'll compare these.
You'd normally expect about two thirds of results to be within one SD of the mean,
95.4% to be within two SDs, and 99.7% to be within three.&lt;/p&gt;

&lt;p&gt;Three SDs on that (yes, different, I know) distribution is 0.025587.  That's smaller than both
of the improvements that our Chinchilla-optimal runs had over the overtrained
ones.&lt;/p&gt;

&lt;p&gt;So what does that tell us?  Well, perhaps not much given the statistical flakiness.  But I think it is useful
directionally.  It suggests that we might be able to take these results seriously
as an improvement, and that Chinchilla held: scaling up the model and the number of
tokens evenly did give us a better model than just scaling up the number of tokens.
In particular, the fact that the loss for &lt;code&gt;slightly-smaller&lt;/code&gt; was lower -- even though
it had 4% &lt;em&gt;less&lt;/em&gt; compute spent on it than the overtrained models -- was encouraging.&lt;/p&gt;

&lt;p&gt;But it's certainly far from a slam-dunk.  A larger test, training lots of overtrained
models and lots of Chinchilla-optimal ones, all with different random seeds, would give actual
real serious data.&lt;/p&gt;

&lt;p&gt;Not worth it for me, and perhaps not for anyone.&lt;/p&gt;

&lt;h3 id="conclusion"&gt;Conclusion&lt;/h3&gt;

&lt;p&gt;I wanted to do a quick sanity check of the Chinchilla heuristic of 20 tokens per parameter.
I came up with results that were certainly in line with it -- perfectly so in terms
of the ordering of the models I trained.  But the effect was small enough that I could
imagine that it was in the noise, especially given the small numbers of models I'm able
to train.  I'll chalk it up as a tentative success.&lt;/p&gt;

&lt;p&gt;In addition, I learned one useful thing: when talking about scaling up a model to more
parameters, you actually have to think quite hard about where you want to put those parameters.
I wound up doing a rough curve-fit to the models in the GPT-2 paper, but I have no
idea if that was optimal.  At some point I should try to dig up some
research into optimising embedding dimensions, numbers of layers, and so on.  But
not now, as I've a bunch of other stuff I want to investigate first.&lt;/p&gt;

&lt;p&gt;Anyway, I hope you found this experiment interesting, and as ever, comments and questions welcome below.
Thanks for reading!&lt;/p&gt;

&lt;div class="footnotes"&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li id="fn-1"&gt;
&lt;p&gt;I'm less familiar with arguments for under-training -- that is, for fewer than
20 tokens per parameter.  I've heard that these days, modern LLMs get a lot more
reinforcement learning than they do pre-training, and perhaps that might mean that
some very big ones are undertrained prior to RL?  I'm uncertain.  It's unlikely
to be raw lack of data; even for those of us outside the big labs,
&lt;a href="https://huggingface.co/datasets/HuggingFaceFW/fineweb"&gt;FineWeb&lt;/a&gt; has 18.5T tokens.
On its own, that would be enough to train a 0.925T-parameter model, and given that
you can &lt;a href="https://arxiv.org/abs/2305.16264"&gt;apparently&lt;/a&gt; do four epochs over the
same data before you start getting diminishing returns, that takes us up to
3.7T.  That's frontier-lab size, and I'm sure they have better datasets than FineWeb.&amp;#160;&lt;a href="#fnref-1" class="footnoteBackLink" title="Jump back to footnote 1 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-2"&gt;
&lt;p&gt;Parameter counts are from the paper, apart from the "small" model, which is
known to be wrong -- I used my own calculation, and the result is in line with what I've
seen elsewhere.&amp;#160;&lt;a href="#fnref-2" class="footnoteBackLink" title="Jump back to footnote 2 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-3"&gt;
&lt;p&gt;The paper doesn't mention the number of heads; these numbers are from
"&lt;a href="https://www.manning.com/books/build-a-large-language-model-from-scratch"&gt;Build a Large Language Model (from Scratch)&lt;/a&gt;",
and match up with the ones on &lt;a href="https://huggingface.co/transformers/v2.2.0/pretrained_models.html"&gt;this Hugging Face page&lt;/a&gt;.&amp;#160;&lt;a href="#fnref-3" class="footnoteBackLink" title="Jump back to footnote 3 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-4"&gt;
&lt;p&gt;Regular readers might have noticed that I'm ignoring what I've been calling the "best" checkpoint.
I've come to the conclusion that because for my training script, "best" means best in
terms of training loss, and the training loss changes based on what training
data the model has seen recently, it's actually not a very useful metric and
just confuses things.  At some point I'll probably re-introduce pre-checkpoint
evals and use that for "best", which would be the right way to do it.&amp;#160;&lt;a href="#fnref-4" class="footnoteBackLink" title="Jump back to footnote 4 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn-5"&gt;
&lt;p&gt;At the time I was using dropout, so training runs were not deterministic without
a known seed.&amp;#160;&lt;a href="#fnref-5" class="footnoteBackLink" title="Jump back to footnote 5 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description><guid isPermaLink="false">/2026/08/chinchilla-check</guid><pubDate>Fri, 07 Aug 2026 19:00:00 +0000</pubDate></item><item><title>Use the built-in GELU, don't roll your own!</title><link>https://www.gilesthomas.com/2026/08/built-in-gelu</link><description>&lt;p&gt;Unsurprisingly, &lt;a href="https://docs.pytorch.org/docs/2.13/generated/torch.nn.GELU.html"&gt;PyTorch's own built-in GELU function&lt;/a&gt;
is faster than the hand-rolled one I've been using to date.  But I was surprised at
how &lt;em&gt;much&lt;/em&gt; faster using it made things when training my models.  I discovered this
accidentally just now while working on something unrelated, but am logging the details
here for anyone else that might find it useful.&lt;/p&gt;

&lt;p&gt;The headline numbers: the same code, training the same model on the same data, ran
at about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;21,000 tokens per second using the hand-rolled GELU from &lt;a href="https://sebastianraschka.com/"&gt;Sebastian Raschka&lt;/a&gt;'s book
"&lt;a href="https://www.manning.com/books/build-a-large-language-model-from-scratch"&gt;Build a Large Language Model (from Scratch)&lt;/a&gt;".&lt;/li&gt;
&lt;li&gt;25,000 tokens per second using PyTorch's built-in GELU with no arguments.&lt;/li&gt;
&lt;li&gt;25,000 tokens per second using the built-in GELU with &lt;code&gt;approximate="tanh"&lt;/code&gt;, which
uses the same maths as Raschka's version under the hood.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a 20% increase in throughput for both of the built-in versions -- definitely nothing to be sneezed at.&lt;/p&gt;

&lt;p&gt;And what is particularly
interesting is that there aren't that many GELUs going on -- it's a GPT-2 small-style
model, with 12 layers.  So that's 12 GELUs handling tensors
shaped &lt;code&gt;(batch_size, seq_len, 4 * d_emb)&lt;/code&gt;, which is &lt;code&gt;(6, 1024, 3072)&lt;/code&gt; for my training setup.
Given that the rest of the model is doing all of the normal full attention stuff for GPT-2, it's
&lt;em&gt;really&lt;/em&gt; surprising that the GELUs alone must have been taking up so much of the time.  The throughput
numbers mean that we must have been spending about 17% of our time on the extra overhead from the hand-rolled
version, so that sets a lower bound for how much time the GELUs were taking up.&lt;/p&gt;
&lt;p&gt;Back when I was doing the "interventions" part of my &lt;a href="/llm-from-scratch"&gt;LLM from scratch series&lt;/a&gt;, training
dozens of GPT-2 small-sized models in the cloud and on my local machines, to keep things simple I used
the original model code from Raschka's book.
That happens to have its own implementation of the GELU function --
you can see &lt;a href="https://github.com/gpjt/ddp-base-model-from-scratch/blob/16dd249447754c3a7be4f1212ccaf312c7416d92/gpt.py#L78"&gt;my copy here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm not that sure why the hand-rolled version is in there -- he covers the maths, but the specific implementation isn't explained
in that much depth, and it seems rather like boilerplate, just a "type this in and use it"
kind of thing.  By contrast, for example, while he does explain the maths behind cross-entropy loss in similar detail,
we use the built-in function for it rather than coding it up ourselves.&lt;/p&gt;

&lt;p&gt;When I switched to using JAX for &lt;a href="/2026/07/llm-from-scratch-34b-building-and-training-gpt-2-small-in-jax"&gt;my own from-scratch implementation&lt;/a&gt;,
I decided to not bother porting the boilerplate, and just used &lt;a href="https://docs.jax.dev/en/latest/_autosummary/jax.nn.gelu.html#jax.nn.gelu"&gt;JAX's own built-in version&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I was revisiting the PyTorch code -- I'm in the process of extending it with mixture-of-experts
support, about which more in a later post -- and decided to switch from the hand-written GELU to the PyTorch one just to
tidy things up a bit.
I noticed something interesting -- my new MoE code suddenly seemed to speed up.&lt;/p&gt;

&lt;p&gt;Was that a mirage?  Or had I discovered part -- or even all -- of the reason why the
JAX code was so much faster than the PyTorch code?  With PyTorch, I was typically
getting training speeds of about 21,000 tokens per second, while in JAX I was getting 24,000 tps or so.
I'd been chalking that up to JAX's JIT compilation, but could it have been just a result
of a random implementation choice I'd made?&lt;/p&gt;

&lt;p&gt;I did three partial test training runs, letting each one run for 20 minutes to allow the
training speed to settle down from any startup overhead.  Firstly, with the old
hand-coded GELU:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@poppy:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;HEAD&lt;span class="w"&gt; &lt;/span&gt;detached&lt;span class="w"&gt; &lt;/span&gt;at&lt;span class="w"&gt; &lt;/span&gt;16dd249&lt;span class="o"&gt;))&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;torchrun&lt;span class="w"&gt; &lt;/span&gt;--nproc_per_node&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ddp_train.py&lt;span class="w"&gt; &lt;/span&gt;1xrtx3090-baseline&lt;span class="w"&gt; &lt;/span&gt;datasets/
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3173&lt;/span&gt;.90it/s&lt;span class="o"&gt;]&lt;/span&gt;
Starting&lt;span class="w"&gt; &lt;/span&gt;rank&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;training&lt;span class="w"&gt; &lt;/span&gt;at&lt;span class="w"&gt; &lt;/span&gt;global&lt;span class="w"&gt; &lt;/span&gt;step&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt;                                                                                                                  &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;/33165&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:04&amp;lt;?,&lt;span class="w"&gt; &lt;/span&gt;?it/s,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.991,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;19&lt;/span&gt;,982&lt;span class="o"&gt;]&lt;/span&gt;


Checkpoint

Continuing&lt;span class="w"&gt; &lt;/span&gt;training
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;▊&lt;span class="w"&gt;                                                                                                     &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;257&lt;/span&gt;/33165&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;:07&amp;lt;&lt;span class="m"&gt;42&lt;/span&gt;:53:57,&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;.69s/it,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;.570,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;,920&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;So it was getting 20,920 on average over those 257 global steps.  That speed was in line with &lt;a href="/2026/04/llm-from-scratch-32k-interventions-training-our-best-model-locally-gradient-accumulation#the-new-local-baseline"&gt;the original run&lt;/a&gt;
of the configuration I was using.&lt;/p&gt;

&lt;p&gt;Next, I introduced the built-in PyTorch GELU with no arguments:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="gd"&gt;-&lt;/span&gt;
&lt;span class="gd"&gt;-class GELU(nn.Module):&lt;/span&gt;
&lt;span class="gd"&gt;-&lt;/span&gt;
&lt;span class="gd"&gt;-    def forward(self, x):&lt;/span&gt;
&lt;span class="gd"&gt;-        return 0.5 * x * (1 + torch.tanh(torch.sqrt(torch.tensor(2.0 / torch.pi)) * (x + 0.044715 * torch.pow(x, 3))))&lt;/span&gt;
&lt;span class="gd"&gt;-&lt;/span&gt;
&lt;span class="gd"&gt;-&lt;/span&gt;
&lt;span class="gd"&gt;-&lt;/span&gt;
&lt;span class="w"&gt; &lt;/span&gt;class FeedForward(nn.Module):

&lt;span class="w"&gt; &lt;/span&gt;    def __init__(self, cfg):
&lt;span class="w"&gt; &lt;/span&gt;        super().__init__()
&lt;span class="w"&gt; &lt;/span&gt;        self.layers = nn.Sequential(
&lt;span class="w"&gt; &lt;/span&gt;            nn.Linear(cfg[&amp;quot;emb_dim&amp;quot;], cfg[&amp;quot;emb_dim&amp;quot;] * 4),
&lt;span class="gd"&gt;-            GELU(),&lt;/span&gt;
&lt;span class="gi"&gt;+            nn.GELU(),&lt;/span&gt;
&lt;span class="w"&gt; &lt;/span&gt;            nn.Linear(cfg[&amp;quot;emb_dim&amp;quot;] * 4, cfg[&amp;quot;emb_dim&amp;quot;])
&lt;span class="w"&gt; &lt;/span&gt;        )
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That does the full calculations for GELU, rather than using the &lt;code&gt;tanh&lt;/code&gt;-based approximation that
the hand-rolled code did.  After 20 minutes, it looked like this:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@poppy:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;torchrun&lt;span class="w"&gt; &lt;/span&gt;--nproc_per_node&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ddp_train.py&lt;span class="w"&gt; &lt;/span&gt;1xrtx3090-baseline&lt;span class="w"&gt; &lt;/span&gt;datasets/
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;██████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;11222&lt;/span&gt;.22it/s&lt;span class="o"&gt;]&lt;/span&gt;
Starting&lt;span class="w"&gt; &lt;/span&gt;rank&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;training&lt;span class="w"&gt; &lt;/span&gt;at&lt;span class="w"&gt; &lt;/span&gt;global&lt;span class="w"&gt; &lt;/span&gt;step&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt;                                                                                                                  &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;/33165&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:04&amp;lt;?,&lt;span class="w"&gt; &lt;/span&gt;?it/s,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.991,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;23&lt;/span&gt;,953&lt;span class="o"&gt;]&lt;/span&gt;


Checkpoint

Continuing&lt;span class="w"&gt; &lt;/span&gt;training
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;▉&lt;span class="w"&gt;                                                                                                     &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;307&lt;/span&gt;/33165&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;35&lt;/span&gt;:40:59,&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.91s/it,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;.331,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;,134&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;So this time we were getting 25,134 tokens per second -- 20% faster!&lt;/p&gt;

&lt;p&gt;By default, PyTorch's GELU uses an exact calculation of the function -- the hand-written
code from the book uses an approximation using &lt;code&gt;tanh&lt;/code&gt;.  Luckily, you can get that same
approximation from PyTorch:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="w"&gt; &lt;/span&gt;        super().__init__()
&lt;span class="w"&gt; &lt;/span&gt;        self.layers = nn.Sequential(
&lt;span class="w"&gt; &lt;/span&gt;            nn.Linear(cfg[&amp;quot;emb_dim&amp;quot;], cfg[&amp;quot;emb_dim&amp;quot;] * 4),
&lt;span class="gd"&gt;-            nn.GELU(),&lt;/span&gt;
&lt;span class="gi"&gt;+            nn.GELU(approximate=&amp;quot;tanh&amp;quot;),&lt;/span&gt;
&lt;span class="w"&gt; &lt;/span&gt;            nn.Linear(cfg[&amp;quot;emb_dim&amp;quot;] * 4, cfg[&amp;quot;emb_dim&amp;quot;])
&lt;span class="w"&gt; &lt;/span&gt;        )
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;So, training with that for 20 minutes:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;giles@poppy:~/Dev/ddp-base-model-from-scratch&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;main&lt;span class="o"&gt;)&lt;/span&gt;$&lt;span class="w"&gt; &lt;/span&gt;uv&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt; &lt;/span&gt;torchrun&lt;span class="w"&gt; &lt;/span&gt;--nproc_per_node&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ddp_train.py&lt;span class="w"&gt; &lt;/span&gt;1xrtx3090-baseline&lt;span class="w"&gt; &lt;/span&gt;datasets/
Fetching&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;files:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;███████████████████████████████████████████████████████████████████████████████████████████████████████████████████&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;/4&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;00&lt;/span&gt;:00,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4115&lt;/span&gt;.09it/s&lt;span class="o"&gt;]&lt;/span&gt;
Starting&lt;span class="w"&gt; &lt;/span&gt;rank&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;training&lt;span class="w"&gt; &lt;/span&gt;at&lt;span class="w"&gt; &lt;/span&gt;global&lt;span class="w"&gt; &lt;/span&gt;step&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt;                                                                                                                  &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;/33165&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;00&lt;/span&gt;:04&amp;lt;?,&lt;span class="w"&gt; &lt;/span&gt;?it/s,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;.991,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;23&lt;/span&gt;,875&lt;span class="o"&gt;]&lt;/span&gt;


Checkpoint

Continuing&lt;span class="w"&gt; &lt;/span&gt;training
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;%&lt;span class="p"&gt;|&lt;/span&gt;▉&lt;span class="w"&gt;                                                                                                     &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;307&lt;/span&gt;/33165&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;:00&amp;lt;&lt;span class="m"&gt;35&lt;/span&gt;:39:42,&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;.91s/it,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;.332,&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;,142&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;25,142 tokens per second -- basically the same as the non-approximate version.&lt;/p&gt;

&lt;p&gt;So: switching to the built-in GELU made my PyTorch code run 20% faster, at about 25,000 tps
rather than 21,000.&lt;/p&gt;

&lt;p&gt;My JAX code, which used JAX's built-in GELU, ran at around 24,000 tps.  I'd actually
found that rather surprising, because in JAX I was training in full-fat 32-bit floating point, while in PyTorch I was using
Automatic Mixed Precision (AMP) -- a special mode that allows it to use 16-bit calculations
where it won't hurt the model much.&lt;/p&gt;

&lt;p&gt;I'd &lt;a href="/2025/12/llm-from-scratch-28-training-a-base-model-from-scratch"&gt;found&lt;/a&gt; that
AMP gave PyTorch a huge speedup -- from 15,402 tps to 19,797 on one test.  So JAX without AMP
being so much faster than PyTorch with AMP was a bit of a surprise.  Its JIT is pretty
amazing, but I didn't expect it to be &lt;em&gt;that&lt;/em&gt; much faster.&lt;/p&gt;

&lt;p&gt;Now I think that we have at least part of an explanation.  I was using JAX's
built-in GELU (interestingly, with its default parameters, which means that it used the &lt;code&gt;tanh&lt;/code&gt;
approximation), but the PyTorch code was using the hand-rolled one, and that unduly
penalised it and erased some of the gains it got from AMP.&lt;/p&gt;

&lt;p&gt;If I really wanted to dig into this, I suppose I might try JAX with a hand-rolled GELU
to see what happened.  My guess is that because of its JIT, it might actually handle it
better -- the whole hand-rolled thing could be compiled into one thing on the GPU.  Perhaps
it would also be interesting to try the non-AMP PyTorch code with the built-in GELU.
But I doubt that would really be the best use of my time (and my electricity bill),
so I'll leave it here.&lt;/p&gt;

&lt;p&gt;On the other hand, I do intend to have a look at &lt;code&gt;torch.compile&lt;/code&gt; in the future, to see
what kind of speedup I can get from it.  And it might be able to compile and fuse together
the hand-rolled GELU -- so that would be an interesting thing to experiment with in that
post: does the built-in GELU advantage disappear if we're compiling?&lt;/p&gt;

&lt;p&gt;But anyway, for now, lesson learned: use built-in PyTorch modules when you can.
It's a pretty obvious one ;-)&lt;/p&gt;

&lt;p&gt;[Update] On X, &lt;a href="https://x.com/rasbt/status/2090254612557156587"&gt;Sebastian Raschka noted&lt;/a&gt; that
he used the approximate version of GELU in his code so that the models were compatible with the
OpenAI weights -- they were trained with that version, so they may behave slightly differently
if you use the "pure" version.  That's a great point, and so I've updated my own copy of the
code to use &lt;code&gt;approximate="tanh"&lt;/code&gt;.&lt;/p&gt;
</description><guid isPermaLink="false">/2026/08/built-in-gelu</guid><pubDate>Thu, 20 Aug 2026 02:00:00 +0000</pubDate></item><item><title>Adding diagrams to my static site generator with D2</title><link>https://www.gilesthomas.com/2026/08/adding-d2</link><description>&lt;p&gt;A lot of the time when I've been writing posts for this blog, I've felt that a diagram
would really help.  But they're a pain to produce well, and I think I underuse
them as a result.  I wanted to fix that, and wound up adding D2 support to my static
site generator.  I think it works pretty well!&lt;/p&gt;
&lt;p&gt;In the past, I've tried drawing my own diagrams in LibreOffice and exporting as SVG, but my complete lack
of artistic skill doesn't help:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/revisiting-karpathy-unreasonable-effectiveness-rnns/one-layer-rnn-visualised-as-simple-nn.svg" alt="An RNN viewed as a simple NN with the input and hidden state going in, and the output and new hidden state coming out" title="An RNN viewed as a simple NN with the input and hidden state going in, and the output and new hidden state coming out" /&gt;&lt;/p&gt;

&lt;p&gt;Asking an AI to do it for me helped in simple cases:&lt;/p&gt;

&lt;p&gt;&lt;img src="/post-assets/neural-networks-maths/network.svg" alt="A simple neural network" title="A simple neural network" /&gt;&lt;/p&gt;

&lt;p&gt;...but with something less standard (there must be a million neural network diagrams
in their training sets) it can be really fiddly to get something right.&lt;/p&gt;

&lt;p&gt;I did some investigations into the various diagram-generating tools out there, and
decided to give &lt;a href="https://d2lang.com/"&gt;D2&lt;/a&gt; a go.  It has a simple
language for specifying what your diagram should show, and the output is pretty nice:&lt;/p&gt;

&lt;p&gt;&lt;img src="/diagrams/adding-d2/llm-top-level.svg" alt="A GPT-2-style LLM at the top level" title="A GPT-2-style LLM at the top level" style="width: 50%"&gt;&lt;/p&gt;

&lt;p&gt;Here's the source for that diagram:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="go"&gt;style.fill: transparent&lt;/span&gt;

&lt;span class="go"&gt;tokens: Tokens&lt;/span&gt;
&lt;span class="go"&gt;tokens -&amp;gt; llm.token-embeddings&lt;/span&gt;

&lt;span class="go"&gt;llm: &amp;quot;&amp;quot; {&lt;/span&gt;
&lt;span class="go"&gt;  token-embeddings: Token embeddings&lt;/span&gt;
&lt;span class="go"&gt;  position-embeddings: Position embeddings&lt;/span&gt;

&lt;span class="go"&gt;  plus: &amp;quot;+&amp;quot; {&lt;/span&gt;
&lt;span class="go"&gt;    shape: circle&lt;/span&gt;
&lt;span class="go"&gt;    width: 36&lt;/span&gt;
&lt;span class="go"&gt;    height: 36&lt;/span&gt;
&lt;span class="go"&gt;    style.font-size: 24&lt;/span&gt;
&lt;span class="go"&gt;    style.fill: transparent&lt;/span&gt;
&lt;span class="go"&gt;  }&lt;/span&gt;

&lt;span class="go"&gt;  token-embeddings -&amp;gt; plus&lt;/span&gt;
&lt;span class="go"&gt;  position-embeddings -&amp;gt; plus&lt;/span&gt;

&lt;span class="go"&gt;  input-embeddings: Input embeddings&lt;/span&gt;
&lt;span class="go"&gt;  plus -&amp;gt; input-embeddings&lt;/span&gt;

&lt;span class="go"&gt;  transformers-layers: &amp;quot;&amp;quot; {&lt;/span&gt;
&lt;span class="go"&gt;    style.stroke-dash: 3&lt;/span&gt;
&lt;span class="go"&gt;    style.fill: transparent&lt;/span&gt;
&lt;span class="go"&gt;    transformers-1: Transformers layer 1&lt;/span&gt;
&lt;span class="go"&gt;    transformers-2: Transformers layer 2&lt;/span&gt;
&lt;span class="go"&gt;    dots: &amp;quot;⋮&amp;quot; {shape: text; style.font-size: 28}&lt;/span&gt;
&lt;span class="go"&gt;    transformers-n: Transformers layer n&lt;/span&gt;
&lt;span class="go"&gt;    transformers-1 -&amp;gt; transformers-2 -&amp;gt; dots -&amp;gt; transformers-n&lt;/span&gt;
&lt;span class="go"&gt;  }&lt;/span&gt;
&lt;span class="go"&gt;  input-embeddings -&amp;gt; transformers-layers.transformers-1&lt;/span&gt;

&lt;span class="go"&gt;  final-norm: LayerNorm&lt;/span&gt;
&lt;span class="go"&gt;  transformers-layers.transformers-n -&amp;gt; final-norm&lt;/span&gt;

&lt;span class="go"&gt;  output-head: Output head&lt;/span&gt;
&lt;span class="go"&gt;  final-norm -&amp;gt; output-head&lt;/span&gt;
&lt;span class="go"&gt;}&lt;/span&gt;

&lt;span class="go"&gt;output-logits: Logits&lt;/span&gt;
&lt;span class="go"&gt;llm.output-head -&amp;gt; output-logits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;That looks pretty clear to me!&lt;/p&gt;

&lt;p&gt;So now, in the source for my blog posts, I have a &lt;code&gt;diagrams&lt;/code&gt; directory.  That contains
subdirectories -- by convention, I create one for each post that needs diagrams --
and D2 files.  These can be generated automatically when I publish:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;    &lt;span class="n"&gt;compile_d2_diagrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;diagrams&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NEW_OUTPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;diagrams&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="o"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;compile_d2_diagrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;.d2&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Unknown file type in D2 tree: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;with_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;.svg&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Compiling D2 diagram in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_call&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s2"&gt;&amp;quot;d2&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;&amp;quot;--pad=0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;&amp;quot;--layout=elk&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;&amp;quot;--elk-nodeNodeBetweenLayers=30&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;&amp;quot;--elk-padding=[top=20,left=20,bottom=20,right=20]&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;output_path&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Making diagram directory to match &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child_path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterdir&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;compile_d2_diagrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;child_path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Don&amp;#39;t know what &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is!&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;(Hat tip to &lt;a href="https://evanhahn.com/change-pathlib-path-extension-python/"&gt;Evan Hahn&lt;/a&gt; for the &lt;code&gt;with_suffix&lt;/code&gt;
method on &lt;code&gt;Path&lt;/code&gt;, which I wasn't aware of.)&lt;/p&gt;

&lt;p&gt;The flags on the command line took a little bit of fiddling; the &lt;code&gt;--pad=0&lt;/code&gt; just gets
rid of the large margins that D2 puts around the diagram by default, but the others
are to tell it to use the ELK layout package with particular formatting.  Its default
layout has curvy lines, and I prefer the closer-to-right-angle ones that ELK provides.&lt;/p&gt;

&lt;p&gt;Another awkward bit was in scaling; the file that is generated by that &lt;code&gt;d2&lt;/code&gt; command
comes out pretty large (&lt;a href="/diagrams/adding-d2/llm-top-level.svg"&gt;you can see it full-size here&lt;/a&gt;).
By default, I allow images inlined into my posts to be as wide as the text, but that would still be
too large here.&lt;/p&gt;

&lt;p&gt;I use &lt;a href="https://github.com/trentm/python-markdown2"&gt;&lt;code&gt;markdown2&lt;/code&gt;&lt;/a&gt; to convert the markdown
source for my posts into HTML, and there isn't any way to tell it what size an
image should be using markdown-ish syntax.  So for now, instead of embedding images the normal markdown way, like
this:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;![&lt;span class="nt"&gt;A simple neural network&lt;/span&gt;](&lt;span class="na"&gt;/post-assets/neural-networks-maths/network.svg &amp;quot;A simple neural network&amp;quot;&lt;/span&gt;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...for these D2-generated ones I'll just embed a normal &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag like this:&lt;/p&gt;

&lt;div class="codehilite"&gt;
&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;/diagrams/adding-d2/llm-top-level.svg&amp;quot;&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;A GPT-2-style LLM at the top level&amp;quot;&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;A GPT-2-style LLM at the top level&amp;quot;&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;width: 50%&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;...so that I can control the size.  Perhaps more work needed there.&lt;/p&gt;

&lt;p&gt;At some point I may go back and update my old diagrams -- at least, the really
ugly hand-drawn ones -- to use this.&lt;/p&gt;

&lt;p&gt;And a random thought: perhaps it might also make sense to include the D2 source somehow on
the blog?
I can imagine that it could help with accessibility in some situations, and perhaps
also for any LLMs stopping by.  Will have to ponder that a bit more.&lt;/p&gt;

&lt;p&gt;What do you think?  Does the D2 diagram look good to you?  Or is there a better
diagramming package that might work better?&lt;/p&gt;
</description><guid isPermaLink="false">/2026/08/adding-d2</guid><pubDate>Tue, 25 Aug 2026 23:00:00 +0000</pubDate></item><item><title>Why do OpenAI's GPT-2 weights beat mine?  Part four: digging into dropout</title><link>https://www.gilesthomas.com/2026/08/why-do-openai-gpt2-weights-beat-mine-4-ift-dropout</link><description>&lt;p&gt;I'm still digging into a mystery about the models I've been training; although
an increasing number of them beat the OpenAI GPT-2 small weights on the narrow
technical measure of the loss they get on a test set, they're
&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-1-intro"&gt;not as good at an instruction-fine-tuning test&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;While reading about MoE models, I came across this paragraph in the
&lt;a href="https://arxiv.org/pdf/2101.03961"&gt;Switch Transformers paper&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Our paper considers the common NLP approach
  of pre-training on a large corpus followed by fine-tuning on smaller downstream tasks such
  as summarization or question answering. One issue that naturally arises is overfitting since
  many fine-tuning tasks have very few examples. During fine-tuning of standard
  Transformers, Raffel et al. (2019) use dropout (Srivastava et al., 2014) at each layer to prevent
  overfitting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So far, when running my IFT test, I'd been aiming to use the same dropout setting
for the fine tune as the model concerned had used in its original
pre-training.  That was just because it seemed natural.&lt;/p&gt;

&lt;p&gt;But the goal of dropout is to prevent overfitting when training over multiple epochs
-- or, at least, that's how most of what I've read explains why we
don't need it on modern single-epoch training runs over large datasets.&lt;/p&gt;

&lt;p&gt;If that's
the case, though, when we do multiple epochs for a fine-tune with a more restricted dataset --
exactly what I was doing for the IFT test -- it might make sense
to use dropout, regardless of whether or not the model was pre-trained with it.
The fine-tuning setup already tries to avoid overfitting by bailing out when a validation
loss starts rising, but dropout might still help it avoid overfitting prematurely.&lt;/p&gt;

&lt;p&gt;On the other hand, something felt a little wrong about fine-tuning a model with
dropout if its pre-training had happened without it.  A model pre-trained with dropout have been trained
on billions of tokens, and so the model will have spent a lot of effort learning
to overcome the issues that dropout causes, but one trained without it won't have that benefit.  Suddenly exposing it to dropout in a much
shorter fine-tuning run felt rather like asking someone who rarely drinks alcohol to take a
few shots of whisky; I felt that the models might not be prepared for the effects.&lt;/p&gt;

&lt;p&gt;As I looked into this more, I noticed another surprising thing -- there was an error in the
configuration that I was using when fine-tuning the OpenAI models, both small and
medium.  They were originally trained with dropout (or so it's believed --
&lt;a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf"&gt;the paper&lt;/a&gt; doesn't
say, but "&lt;a href="https://www.manning.com/books/build-a-large-language-model-from-scratch"&gt;Build a Large Language Model (from Scratch)&lt;/a&gt;"
says that they were, and &lt;a href="https://huggingface.co/docs/transformers/v4.48.2/en/model_doc/gpt2#transformers.GPT2Config"&gt;this config on the Hugging Face GPT-2 code&lt;/a&gt;
agrees).&lt;/p&gt;

&lt;p&gt;But that actually made my original puzzle of why they outperformed my models on the IFT
test seem even more perplexing, at least in the light of this idea.  If dropout
was a good thing for fine-tuning, then so far they had been penalised by &lt;em&gt;not&lt;/em&gt; using it -- that is,
they were even further ahead of my own models than I thought they were.&lt;/p&gt;

&lt;p&gt;It was time to take a careful look.&lt;/p&gt;
&lt;h3 id="the-fine-tunes"&gt;The fine-tunes&lt;/h3&gt;

&lt;p&gt;I fixed the config for the OpenAI weights so that my setup had dropout set to 0.1 for
them, then carefully revisited the config for all of my own models, and made sure that those
ones matched reality (which they did).&lt;/p&gt;

&lt;p&gt;Now, the IFT test that I've been running has two phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Firstly, for each model, I run &lt;a href="https://github.com/gpjt/ddp-base-model-from-scratch/blob/2a9a25fb0488dcbadb7d17738ead3bbde4ebb539/ift_generate_test_responses.py"&gt;&lt;code&gt;ift_generate_test_responses.py&lt;/code&gt;&lt;/a&gt;.
This script trains the specified model on an IFT dataset until validation loss
starts rising.  It then uses the model from before that loss started going up to
generate responses to a test set, and saves those responses to disk.  I made a small change
to it so that the dropout used in the fine-tuning phase was a required command-line
parameter, with three options: &lt;code&gt;model&lt;/code&gt; -- that is, what the model
was pre-trained with -- &lt;code&gt;on&lt;/code&gt;, which forced it to 0.1, or &lt;code&gt;off&lt;/code&gt;, to force it to 0.&lt;/li&gt;
&lt;li&gt;Next, I pass all of the saved test responses for all models into a second script,
&lt;a href="https://github.com/gpjt/ddp-base-model-from-scratch/blob/2a9a25fb0488dcbadb7d17738ead3bbde4ebb539/ift_judge.py"&gt;&lt;code&gt;ift_judge.py&lt;/code&gt;&lt;/a&gt;,
which &lt;a href="/2026/01/llm-from-scratch-30-digging-into-llm-as-a-judge"&gt;sends them to an LLM judge&lt;/a&gt;
so that each model can get a score.  The script averages all scores across all
questions for each model.  Check the link for more details of how that script works
and tries to achieve consistency across models and responses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, the nice thing about the judge script was that it didn't really care whether
the result files it got came from different models or the same one; it just printed
out a mapping of result files to scores.  So I realised I could use it to do
a comparison of all models with all possible dropout settings.&lt;/p&gt;

&lt;p&gt;For all of the models, I ran the &lt;code&gt;ift_generate_test_responses.py&lt;/code&gt; three times, once
with each of the dropout settings: &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;on&lt;/code&gt;, and &lt;code&gt;off&lt;/code&gt;.  Then I sent all of the
resulting result files -- all models, and all dropout options for each -- to the LLM judge in
one go, to see what it came up with.&lt;/p&gt;

&lt;h3 id="the-results"&gt;The results&lt;/h3&gt;

&lt;p&gt;Here are the results, consolidated into one table.  For each model, I have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its loss on my test set -- the technical measure of quality I mentioned near the
start.  They're sorted by that column.&lt;/li&gt;
&lt;li&gt;Whether or not the base training run -- the pre-train -- had dropout.&lt;/li&gt;
&lt;li&gt;The number of fine-tuning epochs before validation loss started rising when the
IFT run used a dropout setting identical to the pre-training (&lt;code&gt;model&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The score that the model thus trained got from the LLM judge.&lt;/li&gt;
&lt;li&gt;The fine-tuning epochs with dropout forced to be &lt;code&gt;off&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The score for the dropout-off model.&lt;/li&gt;
&lt;li&gt;The fine-tuning epochs for dropout forced to be &lt;code&gt;on&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;And finally the score for the resulting model from that.&lt;/li&gt;
&lt;/ul&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
  &lt;th&gt;Base dropout&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;model&lt;/code&gt; epochs&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;model&lt;/code&gt; score&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;off&lt;/code&gt; epochs&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;off&lt;/code&gt; score&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;on&lt;/code&gt; epochs&lt;/th&gt;
  &lt;th&gt;&lt;code&gt;on&lt;/code&gt; score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: medium&lt;/td&gt;
  &lt;td&gt;3.231442&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;42.40&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;43.75&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;42.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, overtrained one long epoch&lt;/td&gt;
  &lt;td&gt;3.324953&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;19.77&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;19.77&lt;/td&gt;
  &lt;td&gt;19&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;7.17&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, overtrained two normal epochs&lt;/td&gt;
  &lt;td&gt;3.326482&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;19.72&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;19.72&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;12.92&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;18.69&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;18.69&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;13.20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.420089&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;21.46&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;21.46&lt;/td&gt;
  &lt;td&gt;20&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;5.25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;3.476802&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.74&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;13.22&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.74&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: small&lt;/td&gt;
  &lt;td&gt;3.499677&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;23.49&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;26.00&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;23.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.538161&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.77&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.77&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;14.06&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.577761&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;10.76&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;10.76&lt;/td&gt;
  &lt;td&gt;19&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;7.36&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 40 GiB&lt;/td&gt;
  &lt;td&gt;3.673623&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;19.71&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;17.72&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;19.71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.683835&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;13.53&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;15.74&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
  &lt;td&gt;13.53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.691526&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;15.15&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;14.19&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;15.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x H100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.724507&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;14.02&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;14.33&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;14.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.729900&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;11.25&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;11.34&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;11.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x B200 160 GiB&lt;/td&gt;
  &lt;td&gt;3.771478&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;12.02&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;14.67&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;12.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb train&lt;/td&gt;
  &lt;td&gt;3.943522&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;8.85&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;12.31&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;8.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu extended train&lt;/td&gt;
  &lt;td&gt;4.134991&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.56&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;15.04&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu train&lt;/td&gt;
  &lt;td&gt;4.166892&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.43&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;&lt;strong&gt;14.99&lt;/strong&gt;&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
  &lt;td&gt;17.43&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;It's quite an intimidating wall of numbers, but there's a bunch of interesting stuff there.&lt;/p&gt;

&lt;p&gt;Firstly: I've put the IFT score for each model where it was trained with the &lt;em&gt;opposite&lt;/em&gt; of
its pre-training dropout in bold.  Let's look at the non-bold numbers first, though.&lt;/p&gt;

&lt;h3 id="sanity-checks"&gt;Sanity checks&lt;/h3&gt;

&lt;p&gt;If you scan down through the models, you'll see that the non-bold IFT scores -- that is,
the one where the IFT test was done with the &lt;code&gt;model&lt;/code&gt; dropout, and then the one where it
was done with dropout set explicitly to the same value as the &lt;code&gt;model&lt;/code&gt; one -- are identical
in every case.  That is a really reassuring sanity check.  Remember, each of those numbers
came from a different run of the &lt;code&gt;ift_generate_test_responses.py&lt;/code&gt; script -- but because
there is a fixed random seed, they should have been identical.  They were presented to &lt;code&gt;ift_judge.py&lt;/code&gt; in the same way as a separate model's response.
The fact that it came up with identical scores tells us that it judged them as being
equal, which is solid evidence for its consistency in judging results in this run
(which is something that can be hard to guarantee with an LLM).&lt;/p&gt;

&lt;p&gt;Similarly, if you look through the numbers of training epochs, the &lt;code&gt;model&lt;/code&gt; epochs
for each one matches the epochs with the dropout forced to match the model's pre-training
setting, which is also reassuring -- it's certainly what you'd expect given a
fixed random seed.&lt;/p&gt;

&lt;h3 id="epoch-counts"&gt;Epoch counts&lt;/h3&gt;

&lt;p&gt;Looking just at the &lt;code&gt;on&lt;/code&gt; and &lt;code&gt;off&lt;/code&gt; epochs columns, you can see something else
interesting.  With dropout forced to be on, the number of fine-tuning epochs is
always higher than the number of epochs with no dropout, except in the case of
the OpenAI medium weights and "Cloud FineWeb, 8x B200 160 GiB", where it's the same.
That makes intuitive sense, I think.  If you're discarding 10% of your activations when
training a model, you'd expect it to take longer to converge.&lt;/p&gt;

&lt;p&gt;But now let's look at the size of those changes.  If you compare the increase in the
number of epochs needed to train with dropout forced to be on, you can see that the
change is &lt;em&gt;much&lt;/em&gt; larger for those models that were pre-trained without dropout.
The first of them, for example, "JAX, overtrained one long epoch", went up from 3 epochs
to 19!  That's way larger than, say, the change from 5 to 7 for "JAX, no MHA bias, with dropout".&lt;/p&gt;

&lt;p&gt;That was the first indication that something interesting was happening when using
dropout to fine-tune models that had been pre-trained without it.&lt;/p&gt;

&lt;p&gt;One question is whether so many epochs on a small dataset might just be a bad idea,
regardless of whether the early-stopping from validation loss helps avoid overfitting.
However, way back I did some &lt;a href="/2026/04/llm-from-scratch-32l-interventions-instruction-fine-tuning-tests#epochs-of-fine-tuning"&gt;investigations&lt;/a&gt;
into the effect of the number of epochs of training, and found that while varying it
changed the results somewhat -- as you'd expect -- the effect was surprisingly
small, and didn't change anything about the fundamental mystery of why the GPT-2
weights were so much better than mine.  So I think we can put that aside for now.&lt;/p&gt;

&lt;h3 id="the-scores"&gt;The scores&lt;/h3&gt;

&lt;p&gt;Now let's dig into those scores.  We can divide them into two groups; models that
were helped by adding dropout, and models that were harmed.&lt;/p&gt;

&lt;p&gt;In the "helped" group, we have these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"JAX, no MHA bias, with dropout", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and gained 4.52 points when the IFT run used dropout.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;, which was pre-trained &lt;strong&gt;without&lt;/strong&gt; dropout and gained 0.29 points.&lt;/li&gt;
&lt;li&gt;"Cloud FineWeb, 8x A100 40 GiB", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and gained 1.99 points.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;, which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and gained 0.96 points.&lt;/li&gt;
&lt;li&gt;"Local FineWeb-Edu extended train", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and gained 2.52 points.&lt;/li&gt;
&lt;li&gt;"Local FineWeb-Edu train", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and gained 2.44 points.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the "harmed" group, we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"OpenAI weights: medium", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 1.35 points.&lt;/li&gt;
&lt;li&gt;"JAX, overtrained one long epoch", which was pre-trained &lt;strong&gt;without&lt;/strong&gt; dropout and lost 12.6 points.&lt;/li&gt;
&lt;li&gt;"JAX, overtrained two normal epochs", which was pre-trained &lt;strong&gt;without&lt;/strong&gt; dropout and lost 6.8 points.&lt;/li&gt;
&lt;li&gt;"JAX, with MHA bias, no dropout", which was pre-trained &lt;strong&gt;without&lt;/strong&gt; dropout and lost 5.49 points.&lt;/li&gt;
&lt;li&gt;"JAX, no MHA bias, no dropout", which was pre-trained &lt;strong&gt;without&lt;/strong&gt; dropout and lost 16.21 points.&lt;/li&gt;
&lt;li&gt;"OpenAI weights: small", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 2.51 points.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;, which was pre-trained &lt;strong&gt;without&lt;/strong&gt; dropout and lost 3.4 points.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;, which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 2.21 points.&lt;/li&gt;
&lt;li&gt;"Cloud FineWeb, 8x H100 80 GiB", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 0.31 points.&lt;/li&gt;
&lt;li&gt;"Cloud FineWeb, 8x A100 80 GiB", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 0.09 points.&lt;/li&gt;
&lt;li&gt;"Cloud FineWeb, 8x B200 160 GiB", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 2.65 points.&lt;/li&gt;
&lt;li&gt;"Local FineWeb train", which was pre-trained &lt;strong&gt;with&lt;/strong&gt; dropout and lost 3.46 points.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are some patterns there, and I think that putting them into a table
sorted by the score increase/decrease is a good way to visualise them:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Base dropout&lt;/th&gt;
  &lt;th&gt;Score change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;4.52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu extended train&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;2.52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu train&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;2.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 40 GiB&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;0.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;0.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 80 GiB&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x H100 80 GiB&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-0.31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: medium&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-1.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-2.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: small&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-2.51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x B200 160 GiB&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-2.65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;-3.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb train&lt;/td&gt;
  &lt;td&gt;Yes&lt;/td&gt;
  &lt;td&gt;-3.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;-5.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, overtrained two normal epochs&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;-6.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, overtrained one long epoch&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;-12.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;No&lt;/td&gt;
  &lt;td&gt;-16.21&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;One thing is pretty clear: with two exceptions, the models that were pre-trained
with dropout are at the top, and the models that were pre-trained without are at
the bottom.&lt;/p&gt;

&lt;p&gt;Of the exceptions,
&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt; is so close to "Local FineWeb train" that perhaps its
position could be due to some kind of noise.  &lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt; is much more puzzling, however.
It's a real outlier in terms of the models that were pre-trained with no dropout,
with its &lt;em&gt;improvement&lt;/em&gt; of 0.29 compared to the next closest, with a decrease of 3.4.&lt;/p&gt;

&lt;p&gt;But if we disregard that outlier for the time being, the pattern actually does fit rather
well into my original suspicion about the risks of switching on dropout when fine-tuning
a model that was pre-trained without it.  They really don't handle it very well!&lt;/p&gt;

&lt;p&gt;On the other hand, it rather does put the kibosh on the idea that I based on
the quote near the start of this post -- that fine-tuning with dropout is a good
way to help the model learn with less risk of overfitting.  In my particular case -- these
specific models, this particular fine-tuning task, with this IFT data -- dropout
seems to generally have a negative effect on the fine-tuning results.  Even of those that were
pre-trained with dropout, more than half got worse results when fine-tuned with it.&lt;/p&gt;

&lt;p&gt;Another interesting thing that stands out from the table above is that the JAX
models are at the top and the bottom.  The model that was pre-trained with dropout
was the one that gained the most from fine-tuning with it (or, contrariwise, lost
out the most if fine-tuned without it).  The models that were pre-trained without
were the ones that were most harmed by being fine-tuned with.&lt;/p&gt;

&lt;p&gt;If you look further up, at the original table of results, you'll see that the JAX
models all did better than my other ones (which were trained using PyTorch) in terms of loss on
my test set (the second column).  I've
been chalking that up to two things: the JAX models would have started their pre-training
with different random initial weights, and they were all trained in full-fat float32
(unlike the PyTorch models, which used &lt;a href="https://docs.pytorch.org/docs/2.13/amp.html"&gt;AMP&lt;/a&gt;).
Given that I &lt;a href="/2026/04/llm-from-scratch-32h-interventions-full-fat-float32"&gt;found&lt;/a&gt; that
AMP had a negligible impact on training loss, I've been thinking that the "initial weights"
aspect was the more important -- by chance, they happened to start in a place on
the loss landscape with a route to a better minimum during training.&lt;/p&gt;

&lt;p&gt;I don't think there's anything in these results that pushes against that theory, but
it does suggest that there's some kind of "fragility" in the minima they have found;
changing dropout from what they were pre-trained with seems to knock them out of their exceptional positions.&lt;/p&gt;

&lt;p&gt;And finally, of course, the mystery around &lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;'s
anomalous position remains.  I honestly don't have any theories at all about that one
right now.  Interestingly, it was trained with an identical configuration to our
other (but less extreme) exception, &lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;.  The difference
is that the first was trained on my local RTX 3090, using gradient accumulation to
get a global batch size of 96, while the second was trained on a cloud machine
with 8x A100 GPUs with 40 GiB each, which (using DDP) got a global batch across all
GPUs of 96 without gradient accumulation.   There's something going on there, but
I'm not sure what.&lt;/p&gt;

&lt;p&gt;Anyway, for now, I think it's time to wrap this one up.&lt;/p&gt;

&lt;h3 id="conclusion"&gt;Conclusion&lt;/h3&gt;

&lt;p&gt;The idea I started this post with -- that using dropout for the fine-tuning part
of all of these IFT tests might be a good idea to avoid issues from the multi-epoch
nature of the fine-tuning -- doesn't seem to hold up.  Dropout in the fine-tuning
turned out to be more often harmful than helpful, regardless of whether a model was originally pre-trained
with dropout or not.&lt;/p&gt;

&lt;p&gt;However, exactly &lt;em&gt;how&lt;/em&gt; harmful it was seemed to be pretty strongly correlated with
whether the model was originally pre-trained with dropout, the oddity of &lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;
aside.&lt;/p&gt;

&lt;p&gt;I think that while working further on solving this mystery, I should stick to not
using dropout.  Because adding it on for the OpenAI models made their performance
worse, I think that's principled -- it's quite the opposite of making a choice
to try to sweep the mystery I'm trying to solve under the carpet :-)&lt;/p&gt;

&lt;p&gt;So that means that my task in future posts in &lt;a href="/gpt-2-mysteries"&gt;this series&lt;/a&gt; is to explain this table (to go back to the format
I've been using for the previous posts) -- the dropout &lt;code&gt;off&lt;/code&gt; numbers from the table above,
with rank added:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
  &lt;th&gt;&lt;/th&gt;
  &lt;th&gt;Test loss&lt;/th&gt;
  &lt;th&gt;IFT epochs&lt;/th&gt;
  &lt;th&gt;IFT score&lt;/th&gt;
  &lt;th&gt;IFT rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: medium&lt;/td&gt;
  &lt;td&gt;3.231442&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;43.75&lt;/td&gt;
  &lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, overtrained one long epoch&lt;/td&gt;
  &lt;td&gt;3.324953&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;19.77&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, overtrained two normal epochs&lt;/td&gt;
  &lt;td&gt;3.326482&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;19.72&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, with MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.418784&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;18.69&lt;/td&gt;
  &lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, no dropout&lt;/td&gt;
  &lt;td&gt;3.420089&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;21.46&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;JAX, no MHA bias, with dropout&lt;/td&gt;
  &lt;td&gt;3.476802&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;13.22&lt;/td&gt;
  &lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;OpenAI weights: small&lt;/td&gt;
  &lt;td&gt;3.499677&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
  &lt;td&gt;26.00&lt;/td&gt;
  &lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-stacked-interventions&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.538161&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;13.77&lt;/td&gt;
  &lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-stacked-interventions-1&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.577761&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;10.76&lt;/td&gt;
  &lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 40 GiB&lt;/td&gt;
  &lt;td&gt;3.673623&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;17.72&lt;/td&gt;
  &lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;1xrtx3090-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.683835&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;15.74&lt;/td&gt;
  &lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;&lt;code&gt;8xa100m40-baseline&lt;/code&gt;&lt;/td&gt;
  &lt;td&gt;3.691526&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;14.19&lt;/td&gt;
  &lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x H100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.724507&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.33&lt;/td&gt;
  &lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x A100 80 GiB&lt;/td&gt;
  &lt;td&gt;3.729900&lt;/td&gt;
  &lt;td&gt;3&lt;/td&gt;
  &lt;td&gt;11.34&lt;/td&gt;
  &lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Cloud FineWeb, 8x B200 160 GiB&lt;/td&gt;
  &lt;td&gt;3.771478&lt;/td&gt;
  &lt;td&gt;4&lt;/td&gt;
  &lt;td&gt;14.67&lt;/td&gt;
  &lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb train&lt;/td&gt;
  &lt;td&gt;3.943522&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;12.31&lt;/td&gt;
  &lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu extended train&lt;/td&gt;
  &lt;td&gt;4.134991&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;15.04&lt;/td&gt;
  &lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
  &lt;td&gt;Local FineWeb-Edu train&lt;/td&gt;
  &lt;td&gt;4.166892&lt;/td&gt;
  &lt;td&gt;5&lt;/td&gt;
  &lt;td&gt;14.99&lt;/td&gt;
  &lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The OpenAI small model still has a 4.54-point lead over the best of my own models,
"JAX, no MHA bias, no dropout".&lt;/p&gt;

&lt;p&gt;Previously I'd considered data quality as a possibility, and felt it was an unlikely
cause.  I now think I may have been premature in that, and it's worth looking into.
Those two "Local FineWeb-Edu" models near the bottom were trained with sub-optimal
hyperparameters and -- while they don't do super-well in this test -- they do much
better than their raw test loss numbers might suggest.&lt;/p&gt;

&lt;p&gt;But while thinking about dropout, it occurred to me that there were other
levers that I'd pulled in my &lt;a href="/2026/04/llm-from-scratch-32m-interventions-conclusion"&gt;interventions into my original base model&lt;/a&gt;
that might be worth investigating &lt;sup class="footnote-ref" id="fnref-1"&gt;&lt;a href="#fn-1"&gt;1&lt;/a&gt;&lt;/sup&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Weight tying -- I honestly can't think of a reason why it might make a model better
for this kind of task, but it certainly is true that the OpenAI weights use it --
while none of the ones of mine that I've been testing do.  That feels worth a quick look, especially
given that I have a copy of a model that I trained using it lying around.&lt;/li&gt;
&lt;li&gt;AMP.  Apart from "JAX, no MHA bias, with dropout", all of the JAX models -- trained without AMP -- did pretty well
in this test (though not close to the OpenAI models).  And again I have a PyTorch model
that was trained without AMP on my disk somewhere, so I may as well throw it in and see how it does.&lt;/li&gt;
&lt;li&gt;The learning rate.  All of these fine-tunes are happening with a fixed learning rate of
0.00005.  While I really don't want to do some kind of sweep across multiple values
for all of these models, perhaps there's some way I can try to relate the fine-tuning
learning rate to what the models are "used to" from pre-training and see if that helps?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, plenty of further possibilities for this investigation.  Stay tuned!&lt;/p&gt;

&lt;div class="footnotes"&gt;
&lt;hr /&gt;
&lt;ol&gt;
&lt;li id="fn-1"&gt;
&lt;p&gt;Other interventions that I decided not to check, at least at this point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;QKV bias: all of my PyTorch models in the table apart from the two &lt;code&gt;stacked-interventions&lt;/code&gt;
ones use it, so that's been thoroughly tested.&lt;/li&gt;
&lt;li&gt;Weight decay: again, we have a mixture of values for that in the table and there's no
obvious pattern.&lt;/li&gt;
&lt;li&gt;Gradient clipping: likewise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="#fnref-1" class="footnoteBackLink" title="Jump back to footnote 1 in the text."&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description><guid isPermaLink="false">/2026/08/why-do-openai-gpt2-weights-beat-mine-4-ift-dropout</guid><pubDate>Thu, 27 Aug 2026 19:00:00 +0000</pubDate></item><item><title>Putting my JAX-trained models on the Hugging Face Hub</title><link>https://www.gilesthomas.com/2026/09/jax-models-on-hugging-face</link><description>&lt;p&gt;I hadn't uploaded the models that I trained using JAX to the Hugging Face Hub because
Transformers has been PyTorch-only since &lt;a href="https://huggingface.co/blog/transformers-v5"&gt;version 5&lt;/a&gt;
(though they say they're working to add interoperability with JAX in the future), so it
would have been tough to get them working natively with &lt;code&gt;AutoModelForCausalLM&lt;/code&gt; and the like.&lt;/p&gt;

&lt;p&gt;But then it dawned on me that I'd already written &lt;a href="https://github.com/gpjt/jax-gpt2-from-scratch/blob/main/convert_model_to_pytorch.py"&gt;a conversion script&lt;/a&gt; that could
take my JAX safetensors files and convert them into ones compatible with my PyTorch
code.  It's actually those converted models that I use for my evals -- so I could
use my &lt;a href="/2026/01/custom-automodelforcausallm-frompretrained-models-on-hugging-face"&gt;existing PyTorch script&lt;/a&gt;
to upload them.&lt;/p&gt;

&lt;p&gt;So, I've now uploaded PyTorch-compatible versions of all of my JAX-trained models:&lt;/p&gt;

&lt;p&gt;"&lt;a href="/2026/07/llm-from-scratch-34b-building-and-training-gpt-2-small-in-jax"&gt;Writing an LLM from scratch, part 34b -- from bigrams to GPT-2, one component at a time (in JAX)&lt;/a&gt;"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-no-mha-bias-no-dropout"&gt;&lt;code&gt;gpjt/jax-no-mha-bias-no-dropout&lt;/code&gt;&lt;/a&gt;
-- the first full LLM trained in the post, in the "Adding LayerNorm" section.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-no-mha-bias-with-dropout"&gt;&lt;code&gt;gpjt/jax-no-mha-bias-with-dropout&lt;/code&gt;&lt;/a&gt;
-- the second full LLM trained in the post, in the "Dropout" section.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-no-dropout"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-no-dropout&lt;/code&gt;&lt;/a&gt;
-- the third full LLM trained in the post, in the "Adding bias to the MHA output projections" section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"&lt;a href="/2026/07/why-do-openai-gpt2-weights-beat-mine-3-overtraining"&gt;Why do OpenAI's GPT-2 weights beat mine? Part three: testing overtraining&lt;/a&gt;"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-no-dropout-extended"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-no-dropout-extended&lt;/code&gt;&lt;/a&gt;
-- the single-epoch, double-Chinchilla-tokens model.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-no-dropout-2-epoch"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-no-dropout-2-epoch&lt;/code&gt;&lt;/a&gt;
-- the model trained on two epochs over the Chinchilla-optimal number of tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"&lt;a href="/2026/08/chinchilla-check"&gt;A quick(ish) Chinchilla check&lt;/a&gt;"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-larger-chinchilla-1"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-larger-chinchilla-1&lt;/code&gt;&lt;/a&gt;
-- the &lt;code&gt;slightly-larger&lt;/code&gt; model.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/gpjt/jax-with-mha-bias-larger-chinchilla-2"&gt;&lt;code&gt;gpjt/jax-with-mha-bias-larger-chinchilla-2&lt;/code&gt;&lt;/a&gt;
-- the &lt;code&gt;slightly-smaller&lt;/code&gt; model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've also added links to the posts in question.&lt;/p&gt;
</description><guid isPermaLink="false">/2026/09/jax-models-on-hugging-face</guid><pubDate>Thu, 03 Sep 2026 18:00:00 +0000</pubDate></item></channel></rss>