<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://anandnair.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://anandnair.dev/" rel="alternate" type="text/html" /><updated>2026-09-13T14:15:54+00:00</updated><id>https://anandnair.dev/feed.xml</id><title type="html">Anand Nair</title><subtitle>Deep dives into AI and software systems: training runs, GPU internals, and the engineering underneath.</subtitle><author><name>Anand Nair</name></author><entry><title type="html">NanoChat Speedrun, Part 2: What the Run Actually Printed</title><link href="https://anandnair.dev/posts/nanochat-speedrun-part-2/" rel="alternate" type="text/html" title="NanoChat Speedrun, Part 2: What the Run Actually Printed" /><published>2026-08-24T00:00:00+00:00</published><updated>2026-08-24T00:00:00+00:00</updated><id>https://anandnair.dev/posts/nanochat-speedrun-part-2</id><content type="html" xml:base="https://anandnair.dev/posts/nanochat-speedrun-part-2/"><![CDATA[<blockquote>
  <p><strong>Disclosure:</strong> I used AI assistance to edit and refine this post, but the ideas, interpretations, and conclusions are mine.</p>
</blockquote>

<p><a href="/posts/nanochat-speedrun-part-1/">Part 1</a> ended with a table of eight things I expected to see. I had not rented a GPU at that point. The whole list came out of reading <a href="https://github.com/anandnair2005/nanochat/tree/254e524e2df052abe1048ab277a862ad005bc46a">NanoChat</a> and working through the arithmetic on paper.</p>

<p>This post is what happened when I ran it.</p>

<p>The short version: the model trained for <strong>196.49 minutes</strong> on four H100s at <strong>59.79% MFU</strong>, reached a validation bpb of <strong>0.7194</strong>, and the instance bill came to about <strong>$41</strong>. Seven of the eight expectations held. The eighth held too, but only once I was honest about which step I was measuring.</p>

<p>What I did not expect was how quickly it would be settled. I had imagined careful post-hoc analysis. In practice, four of the eight predictions were resolved by line 373 of the log, before a single training step had run.</p>

<p>—</p>

<h2 id="making-the-expensive-part-boring">Making the expensive part boring</h2>

<p>The one thing I was sure of before starting: I did not want to be learning cloud plumbing while a GPU meter was running.</p>

<p>Renting four H100s means the clock starts the moment the instance boots and does not stop for mistakes. So everything that could be rehearsed on cheap hardware got rehearsed there, and everything that could be automated got automated. The design goal was that the expensive run should be the least interesting part of the day.</p>

<p>Three decisions carried most of that weight.</p>

<p><strong>The container holds code, not data.</strong> The Vast image was built with the runtime and the repository, nothing else. Datasets and checkpoints are large, they change independently of the code, and baking them in would have meant rebuilding the image for every data change. Instead the container starts, brings up SSH, and idles until told what to do.</p>

<p><strong>Generated artifacts sync to Google Drive; downloaded ones do not.</strong> Anything the run produced — tokenizer, checkpoints, logs, report — was pushed to durable storage in the background. Anything that could be re-downloaded from the source was not. This distinction matters more than it sounds: a fresh host could restore the expensive, irreplaceable things in under two minutes and then re-fetch the cheap, reproducible ones in parallel.</p>

<p><strong>Orchestration ran locally.</strong> A local script drove the remote host, which meant the workflow was scriptable, resumable, and legible to an agent. Each stage wrote a status marker — <code class="language-plaintext highlighter-rouge">STARTED</code>, <code class="language-plaintext highlighter-rouge">DONE</code> — so progress was a file to read rather than a terminal to watch.</p>

<p>I will not pretend this was elegant on the first attempt. It took several iterations, and the iterations are why the final run was uneventful.</p>

<p><img src="/figures/nanochat-speedrun-part-2/orchestration.svg" alt="Only the rented host is disposable" /></p>

<p class="caption"><em>Three tiers, one of which vanishes. Restore runs once and moves very little; sync runs throughout and moves everything the run produces. Losing the host costs the hours since the last sync, not the run.</em></p>

<h2 id="everything-before-the-run">Everything before the run</h2>

<p>Before any H100 was involved, the pipeline ran end to end on two RTX 3060s at depth 12. That configuration produces a bad model. That was never the point. The point was that every stage — tokenizer, base training, evaluation, SFT, chat evaluation, report generation — executed in order, wrote what it was supposed to write, and synced what it was supposed to sync.</p>

<p>Cheap hardware catches the whole class of failures that have nothing to do with scale: wrong paths, missing environment variables, a sync loop that silently uploads nothing, an evaluation stage that cannot find the checkpoint the previous stage wrote.</p>

<p>Then production blew up anyway.</p>

<p>The failures on H100 hardware were not code failures. They were operational: an SSH proxy that would not hold a connection, instances that idled while I worked out why. The billing screenshot from that day shows the debris — several instances at $5.32, $0.82, $0.17, $0.10, and two under a cent. None of them trained anything.</p>

<p>This is the part of the story that does not flatter anyone, so it is worth stating plainly: the code was never the problem. The problem was me learning someone else’s infrastructure at $2 an hour.</p>

<h2 id="the-run">The run</h2>

<p>The final run was tagged <code class="language-plaintext highlighter-rouge">d24-4xh100-full</code>: depth 24, four H100 80GB cards, FP8 enabled, started 2026-07-11 at 12:05:16.</p>

<p>Setup took about five minutes. Google Drive restore finished in 1.68 minutes, recovering the shared tokenizer and confirming there were no run-specific checkpoints to resume from. The dataset download then pulled 171 shards while the tokenizer was reused rather than retrained.</p>

<p>At 12:10:07 the training script initialised, and the log started answering questions.</p>

<h3 id="four-predictions-in-forty-lines">Four predictions in forty lines</h3>

<p>I had expected to do this analysis afterwards, with the log open in one window and Part 1 in another. Instead the initialisation block resolved four of the eight expectations before the first step.</p>

<p><strong>Flash Attention 3.</strong> Line 342:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>✓ Using Flash Attention 3 (Hopper GPU detected), efficient, new and awesome.
</code></pre></div></div>

<p>Expectation 5, settled. The Hopper gate worked exactly as the import chain said it would.</p>

<p><strong>The depth cascade.</strong> Lines 343 to 368 printed the model configuration and the derived hyperparameters:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"n_layer": 24, "n_head": 12, "n_embd": 1536, "window_pattern": "SSSL"
Auto-computed optimal batch size: 1,048,576 tokens
Scaling LRs by 1.4142 for batch size 1,048,576 (reference: 524,288)
Scaling the LR for the AdamW parameters ∝1/√(1536/768) = 0.707107
Calculated number of iterations from target data:param ratio: 5,568
Total number of training tokens: 5,838,471,168
</code></pre></div></div>

<p>Expectation 1 was that <code class="language-plaintext highlighter-rouge">--depth=24</code> would produce exactly these numbers. Every one matches what I derived from the formulas in Part 1. <code class="language-plaintext highlighter-rouge">model_dim</code> 1536, 12 heads, batch 1,048,576, LR scale 1.4142, AdamW scale 0.707107, 5,568 iterations.</p>

<p>The parameter table three lines above makes the derivation auditable in a way I had not anticipated. It reports <code class="language-plaintext highlighter-rouge">transformer_matrices</code> at 679,478,976 and <code class="language-plaintext highlighter-rouge">lm_head</code> at 50,331,648. Those sum to <strong>729,810,624</strong> — the scaling-parameter count the iteration formula depends on, printed as its own components rather than asserted as a total.</p>

<p><strong>The batch math.</strong> Lines 371 to 373:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Tokens / micro-batch / rank: 16 x 2048 = 32,768
Tokens / micro-batch: 131,072
Total batch size 1,048,576 =&gt; gradient accumulation steps: 8
</code></pre></div></div>

<p>Expectation 2 was that running on four GPUs instead of the reference eight would change the accumulation count and nothing else. There it is: 1,048,576 &amp;div; 131,072 = 8. The global batch is a property of the experiment; the world size is a property of the hardware I happened to rent. The code keeps them separate, and says so out loud.</p>

<p><strong>FP8 eligibility.</strong> Line 354:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>✓ FP8 training enabled (tensorwise scaling) - converted 145/158 linear layers, skipped 13 (too small)
</code></pre></div></div>

<p>This is the one I had been most pleased with in Part 1, and the one I was most nervous about. I had counted the linear layers by hand from the module definitions, applied the eligibility filter from <code class="language-plaintext highlighter-rouge">fp8.py</code>, and predicted 145 conversions and 13 skips — twelve <code class="language-plaintext highlighter-rouge">ve_gate</code> projections at <code class="language-plaintext highlighter-rouge">Linear(12, 12)</code>, where 12 is not divisible by 16, plus one <code class="language-plaintext highlighter-rouge">smear_gate</code> at <code class="language-plaintext highlighter-rouge">Linear(24, 1)</code>.</p>

<p>The log prints <code class="language-plaintext highlighter-rouge">145/158</code> and <code class="language-plaintext highlighter-rouge">13</code>. Expectation 3, exactly.</p>

<p><img src="/figures/nanochat-speedrun-part-2/log-annotations.svg" alt="Four expectations settled before the first training step" /></p>

<p class="caption"><em>Lines 342 to 373 of the run log, abridged. Each printed value is tied to the Part 1 expectation it settles. Four of them resolve before step one, because the script says what it decided on its way up.</em></p>

<h3 id="watching-it-run">Watching it run</h3>

<p>With four expectations already resolved, the remaining ones needed the run to finish. So I watched the step lines scroll.</p>

<p>They are almost boring, which turns out to be the finding:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>step 00001/05568 | loss: 10.371374 | dt: 2108.35ms | tok/sec: 497,344 | bf16_mfu: 60.03
step 00002/05568 | loss: 10.325244 | dt: 2111.80ms | tok/sec: 496,531 | bf16_mfu: 59.94
step 00003/05568 | loss: 10.253621 | dt: 2117.46ms | tok/sec: 495,205 | bf16_mfu: 59.78
</code></pre></div></div>

<p>Step 1 is already at 60.03% MFU. Not step 500, not after some warm-up ramp. The second step of training.</p>

<h2 id="the-receipts-that-needed-the-whole-run">The receipts that needed the whole run</h2>

<h3 id="utilisation-honestly">Utilisation, honestly</h3>

<p>Expectation 7 was the one I cared most about, and the one most easily fudged. The claim was not that MFU would be high. It was that it would be <strong>stable</strong> — that the headline number would not be a decent average hiding a bad distribution.</p>

<p>A single reported figure cannot answer that, so I pulled all 5,568 step lines out of the log and computed the distribution.</p>

<table class="wide">
  <thead>
    <tr>
      <th>Measure</th>
      <th>All 5,568 steps</th>
      <th>Excluding step 0</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Mean MFU</td>
      <td>59.65%</td>
      <td><strong>59.66%</strong></td>
    </tr>
    <tr>
      <td>Standard deviation</td>
      <td>0.85</td>
      <td><strong>0.32</strong></td>
    </tr>
    <tr>
      <td>Minimum</td>
      <td>0.92% (step 0)</td>
      <td>53.91%</td>
    </tr>
    <tr>
      <td>Maximum</td>
      <td>60.92%</td>
      <td>60.92%</td>
    </tr>
    <tr>
      <td>Samples below 50%</td>
      <td>1</td>
      <td><strong>0</strong></td>
    </tr>
  </tbody>
</table>

<p>Here is where I have to be careful, because the honest version of this claim is narrower than the flattering one.</p>

<p>Step 0 runs at 0.92% MFU. It is the compile step: <code class="language-plaintext highlighter-rouge">torch.compile</code> traces and builds kernels, which takes 137 seconds against roughly 2.1 seconds for every subsequent step. Including it in a stability measure inflates the standard deviation nearly threefold, from 0.32 to 0.85, on the strength of one sample that is not measuring steady-state throughput at all.</p>

<p>So the claim I will defend is the narrow one: <strong>across 5,567 steady-state steps, mean MFU was 59.66% with a standard deviation of 0.32, and not one of them fell below 50%.</strong> 98.6% of steps land within a single percentage point of the mean. Step 0 is excluded deliberately, and I am telling you that rather than quietly dropping it.</p>

<p>There is a detail I did not predict and would not have thought to look for. Splitting the run into quarters:</p>

<table class="wide">
  <thead>
    <tr>
      <th>Quarter</th>
      <th>Mean MFU</th>
      <th>Std dev</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>First</td>
      <td>59.51%</td>
      <td>0.35</td>
    </tr>
    <tr>
      <td>Second</td>
      <td>59.67%</td>
      <td>0.28</td>
    </tr>
    <tr>
      <td>Third</td>
      <td>59.68%</td>
      <td>0.29</td>
    </tr>
    <tr>
      <td>Fourth</td>
      <td><strong>59.79%</strong></td>
      <td>0.29</td>
    </tr>
  </tbody>
</table>

<p>Utilisation does not sag as the run progresses. It drifts <em>upward</em>, monotonically, and the final quarter’s mean is 59.79% — which is the headline MFU the report quotes. The number in the report is not a flattering peak. It is where the run settled.</p>

<p><img src="/figures/nanochat-speedrun-part-2/mfu-stability.svg" alt="MFU across all 5,568 steps" /></p>

<p class="caption"><em>Every steady-state step, in half-point bins. 98.6% of them land within one point of the 59.66% mean. Step 0 sits at 0.92% and is drawn in its own lane so it cannot flatter the scale. The quarterly means rise and finish on the reported 59.79%.</em></p>

<p><strong>What the hardware reported about itself.</strong> Everything above is the training script’s own arithmetic: <code class="language-plaintext highlighter-rouge">bf16_mfu</code> is a number NanoChat computes and prints. WandB also samples the GPUs directly through NVML, on its own schedule, independent of the training loop. It is the only instrument in this run that is not NanoChat marking its own homework.</p>

<p><img src="/figures/nanochat-speedrun-part-2/screens/gpu-telemetry.png" alt="Six panels of GPU telemetry sampled independently of the training loop" /></p>

<p class="caption"><em>Six NVML panels for all four GPUs. Top row: enforced power limit flat at 700W, memory allocated flat at 60 GB and 70.2%. Bottom row: memory-access time, temperature, and utilisation, each showing the same regular sawtooth.</em></p>

<p>Utilisation sits at 99–100% for essentially the entire run. That rules out something I had not thought to check: host-side starvation. If the data loader had ever fallen behind, or if Python had opened gaps between steps, the GPUs would have gone idle and it would show here as a dip. Across 3.27 hours, they did not.</p>

<p>The regular dips are my favourite detail in the whole run. Every nine to ten minutes, utilisation falls, memory traffic drops away, and all four GPUs cool by several degrees before climbing straight back on the next sample. That is the validation pass: the log runs one every 250 steps, which is 8.85 minutes of training time, and the pass itself accounts for the rest of the gap. There are three deeper drops as well, and the run contains exactly three full CORE evaluations — though the log excludes evaluation time from its own clock, so I could not line the timings up closely enough to call that proven. The regular sawtooth I am sure of. <strong>You can read the evaluation schedule off the temperature of the silicon.</strong></p>

<p>The flat panels are quietly reassuring in a different way. The enforced power limit holds at 700W throughout, so these were full-power SXM cards that were never quietly capped. Temperatures sit between the mid-fifties and low seventies, nowhere near throttling. Memory allocation climbs to 60 GB about four minutes in and never moves again — 70.2% of each card, which implies 79.6 GiB of total capacity and confirms these were the 80 GB H100s I thought I had rented. Flat for 5,568 steps, 24 validation passes and 3 full evaluations also means nothing leaked.</p>

<p>One thing this does not settle, and I want to be explicit because the chart invites the wrong conclusion. Utilisation counts time with a kernel resident on the device, not time doing useful work, and NCCL collectives are kernels too. A run whose communication blocked would look much the same here. This corroborates the flat MFU; it does not prove the overlap.</p>

<h3 id="the-arithmetic-that-proves-there-is-no-padding">The arithmetic that proves there is no padding</h3>

<p>Expectation 4 was about the data loader: that BOS-aligned best-fit packing fills every row exactly, with no padding anywhere.</p>

<p>This one has a proof rather than a measurement, and it is my favourite thing in the log.</p>

<p>The run reports 5,568 iterations and a global batch of 1,048,576 tokens. It separately reports the total tokens trained as 5,838,471,168.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>5,568 × 1,048,576 = 5,838,471,168
</code></pre></div></div>

<p>Exactly. No remainder, no rounding, no discrepancy of a few thousand tokens that would indicate a padded final row or a dropped partial batch. Every token counted in the total is a real token that contributed to a gradient.</p>

<p>That is what “no padding, ever” looks like when you can check it with multiplication.</p>

<h3 id="half-the-gpus-twice-the-time">Half the GPUs, twice the time</h3>

<p>Expectation 6 was that base training would be largely GPU-bound, so running on four cards instead of the reference eight should take roughly twice as long.</p>

<p>NanoChat’s README reports 1.65 hours for base training on an 8×H100 node. My run reports:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Total training time: 196.49m
</code></pre></div></div>

<p>That is 3.27 hours. Against 1.65, the ratio is <strong>1.98×</strong>.</p>

<p>Two percent off perfect linear scaling, on half the hardware. I want to be careful about what this does and does not show: it is one data point at one configuration compared against a published number from a different machine, not a scaling study. But it is consistent with the design reading from Part 1 — that this path is single-node and compute-bound, and that the communication is arranged so it does not become the limit.</p>

<h2 id="what-it-cost">What it cost</h2>

<p>The successful instance shows <strong>$41.16</strong> on the Vast.ai billing page for 11 July. Including the smoke tests and the failed H100 attempts from the same day, the practical total was about <strong>$47.58</strong>.</p>

<p><img src="/figures/nanochat-speedrun-part-2/screens/vast-charges.png" alt="The billing page, including the failed attempts" /></p>

<p class="caption"><em>The Vast.ai charges. The successful run sits alongside the debris from the attempts that did not get there, which is the honest version of the number.</em></p>

<p>The $100 line from the original GPT-2 speedrun framing is comfortably clear either way, and that was expectation 8.</p>

<p>The honest caveat: I am inferring which billing row corresponds to the final run from the timing and the amount. The instance identifiers are not stamped in the training log. The $41.16 row is by far the most likely candidate — it is the only charge consistent with a four-hour four-GPU session — but I have not proved the mapping, and I would rather say so than present it as certain.</p>

<h3 id="the-part-of-the-bill-that-is-not-gpu-time">The part of the bill that is not GPU time</h3>

<p>One operational detail worth surfacing, because it surprised me.</p>

<p>A checkpoint is not one file. Rank 0 writes the model and metadata; every rank writes its own optimizer shard. After the run, the Google Drive metadata showed:</p>

<ul>
  <li>Base checkpoint set: <strong>9.283 GiB</strong> across 6 objects</li>
  <li>SFT checkpoint set: <strong>9.283 GiB</strong> across 6 objects</li>
  <li>Model file <code class="language-plaintext highlighter-rouge">model_005568.pt</code>: 4,227,935,530 bytes</li>
  <li>Each of four optimizer shards: 1,434,912,917 bytes</li>
</ul>

<p>My naive estimate had been 2 bytes per parameter for bf16 weights — about 2.77 GB for 1.38 billion parameters. The actual model file is over 4 GB, because a checkpoint carries more than a flat dump of parameter bytes.</p>

<p>This is why artifact sync ran continuously in the background rather than as a final step. Roughly 18.5 GiB of checkpoints moving to durable storage at the end of a run is not a rounding error on a rented machine. Getting that wrong means paying for GPU time while a network transfer finishes.</p>

<h2 id="talking-to-it">Talking to it</h2>

<p>None of the above is why anyone trains a model.</p>

<p>After SFT and chat evaluation, there is a model you can hold a conversation with. It is not good. It is a 1.4-billion-parameter model trained on 5.8 billion tokens for three hours, and it will confidently tell you things that are not true.</p>

<p><img src="/figures/nanochat-speedrun-part-2/screens/chat-sample.png" alt="A short exchange with the finished model" /></p>

<p class="caption"><em>Fluent, well formed, on topic, and wrong about things it has no way to know. Both halves of that sentence matter.</em></p>

<p>But it answers in fluent, well-formed English, follows the shape of a conversation, and stays on topic. Its ChatCORE score is 0.3712 and its CORE is 0.2561, against 0.2626 for the reference 8×H100 run — close enough that the difference is not the story.</p>

<p>The story is that the thing talks back, and it cost $41.</p>

<h2 id="the-tokenizer-joke">The tokenizer joke</h2>

<p>One failure is worth keeping, because it is funny and because the lesson is real.</p>

<p>At one point I loaded a checkpoint with the wrong tokenizer. The model was fine. The weights were fine. Every token index was being decoded against the wrong vocabulary, so the output was confident, fluent, perfectly-structured nonsense.</p>

<p><img src="/figures/nanochat-speedrun-part-2/screens/chat-wrong-tokenizer.png" alt="The same weights, read through the wrong vocabulary" /></p>

<p class="caption"><em>The same checkpoint with a mismatched tokenizer. It has the cadence and punctuation of language right up until you try to read it.</em></p>

<p>It is a good reminder that a checkpoint is not self-describing. Weights plus the wrong vocabulary is not a degraded model; it is a different one. Ship the tokenizer with the checkpoint, or you have shipped an artifact that cannot be used.</p>

<h2 id="what-held-up-and-what-i-never-tested">What held up, and what I never tested</h2>

<p>Eight expectations. Here is the accounting.</p>

<table class="wide">
  <thead>
    <tr>
      <th>#</th>
      <th>Expectation</th>
      <th>Verdict</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">--depth=24</code> derives dim 1536, 12 heads, batch 1,048,576, 5,568 iters, 5.84B tokens</td>
      <td><strong>Held</strong> — all printed verbatim</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Four GPUs changes accumulation to 8, not the global batch</td>
      <td><strong>Held</strong> — log states it explicitly</td>
    </tr>
    <tr>
      <td>3</td>
      <td>FP8 converts exactly 145 of 158 layers, skipping 13</td>
      <td><strong>Held</strong> — exact match</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Total tokens is exactly 5,568 × 1,048,576</td>
      <td><strong>Held</strong> — arithmetic, no remainder</td>
    </tr>
    <tr>
      <td>5</td>
      <td>FA3 activates on H100 and says so</td>
      <td><strong>Held</strong></td>
    </tr>
    <tr>
      <td>6</td>
      <td>Half the GPUs, roughly twice the time</td>
      <td><strong>Held</strong> — 1.98×</td>
    </tr>
    <tr>
      <td>7</td>
      <td>Utilisation high <em>and</em> stable</td>
      <td><strong>Held, scoped</strong> — 0.32 stdev over 5,567 steady-state steps</td>
    </tr>
    <tr>
      <td>8</td>
      <td>Lands well under the $100 line</td>
      <td><strong>Held</strong> — about $41</td>
    </tr>
  </tbody>
</table>

<p>Now the part that matters more than the table.</p>

<p><strong>I did not validate the optimizer design.</strong> Part 1 argued that moving communication into an explicit async optimizer phase is what keeps utilisation flat. Flat MFU and pinned GPU occupancy are both consistent with that, and neither can distinguish it from communication that blocks, because the collectives occupy the device either way. I have no profiler trace, no comparison against a DDP-wrapped baseline, and no measurement isolating the communication phase. The claim remains a reading of the code that the results do not contradict.</p>

<p><strong>I did not measure the attention windows.</strong> SSSL is confirmed in the config, and the code says 18 of 24 layers see only 512 tokens. I never instrumented attention to watch it happen.</p>

<p><strong>I did not reproduce the dataset comparison.</strong> NanoChat’s claim that ClimbMix cut the reference run by 27% over FineWeb-EDU is the repository’s measurement, not mine. I ran one dataset once.</p>

<p><strong>I did not test the scaling laws.</strong> Same boundary as Part 1. I confirmed the code implements what it claims; whether <code class="language-plaintext highlighter-rouge">D^0.383</code> is the right exponent needs sweeps I did not run.</p>

<p>One run does not establish reproducibility. It establishes that on 11 July 2026, on this hardware, the code did what a careful reading said it would.</p>

<h2 id="what-i-actually-learned">What I actually learned</h2>

<p>I went in expecting to check a stack. What I ended up checking was whether a piece of engineering means what it says.</p>

<p>The satisfying part was not that the predictions held. It was <em>how</em> they held. The log does not report a batch size; it reports the micro-batch, the per-rank tokens, and the resulting accumulation count, so you can verify the arithmetic yourself. It does not report a parameter count; it reports the components, so the scaling-parameter figure the iteration formula depends on can be reconstructed. It does not claim no padding; it prints two numbers whose product proves it.</p>

<p>That is a deliberate choice, repeated often enough that it cannot be accidental. The code is written to be checked. Someone decided that a reader who wanted to verify a claim should be able to, and then did the unglamorous work of printing the intermediate values that make verification possible.</p>

<p>Reading the code was rewarding. Watching it print exactly what the reading predicted — four times over, in forty lines, before training started — was better.</p>

<p>The model it produced is small and often wrong. The engineering that produced it is neither.</p>

<h2 id="notes-and-sources">Notes and sources</h2>

<ul>
  <li>Run: <code class="language-plaintext highlighter-rouge">d24-4xh100-full</code>, 4× NVIDIA H100 80GB HBM3, 11 July 2026. The report and the full <code class="language-plaintext highlighter-rouge">speedrun.log</code> are in my fork, under <a href="https://github.com/anandnair2005/nanochat/tree/254e524e2df052abe1048ab277a862ad005bc46a/blog/evidence/part-1">blog/evidence/part-1</a>.</li>
  <li>MFU statistics computed from all 5,568 base-training step lines in <code class="language-plaintext highlighter-rouge">speedrun.log</code>. Step 0 excluded from steady-state figures, as stated above.</li>
  <li>GPU telemetry is sampled by Weights &amp; Biases through NVML, outside the training loop. It is not something NanoChat logs.</li>
  <li>Reference comparison figures (1.65 h, CORE 0.2626) are from NanoChat’s own README leaderboard row, not a run of mine.</li>
  <li>Andrej Karpathy, <a href="https://github.com/karpathy/nanochat">NanoChat</a>.</li>
</ul>]]></content><author><name>Anand Nair</name></author><category term="nanochat" /><category term="gpu" /><category term="training" /><category term="llm" /><summary type="html"><![CDATA[Renting four H100s to check whether NanoChat's training stack does what a close reading of the code says it should.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://anandnair.dev/figures/nanochat-speedrun-part-2/png/mfu-stability.png" /><media:content medium="image" url="https://anandnair.dev/figures/nanochat-speedrun-part-2/png/mfu-stability.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">NanoChat Speedrun, Part 1: The Code That Makes H100s Go Brrrr</title><link href="https://anandnair.dev/posts/nanochat-speedrun-part-1/" rel="alternate" type="text/html" title="NanoChat Speedrun, Part 1: The Code That Makes H100s Go Brrrr" /><published>2026-08-10T00:00:00+00:00</published><updated>2026-08-10T00:00:00+00:00</updated><id>https://anandnair.dev/posts/nanochat-speedrun-part-1</id><content type="html" xml:base="https://anandnair.dev/posts/nanochat-speedrun-part-1/"><![CDATA[<blockquote>
  <p><strong>Disclosure:</strong> I used AI assistance to edit and refine this post, but the ideas, interpretations, and conclusions are mine.</p>
</blockquote>

<blockquote>
  <p><strong>Read the repo alongside this.</strong> Everything below is a close reading of a specific codebase, and it will land far better if you have <a href="https://github.com/anandnair2005/nanochat/tree/254e524e2df052abe1048ab277a862ad005bc46a">NanoChat</a> open in another window. It is small enough to skim in an evening. Every claim here links to the exact file and line it came from — follow a few of them, and disagree with me where you find something I misread.</p>
</blockquote>

<p>I have read a lot about transformers. Books, papers, tutorial series, the usual conference talks. Before this I would have told you I understood how they were trained.</p>

<p>Then in early 2026 I sat down with Andrej Karpathy’s <a href="https://github.com/karpathy/nanochat">NanoChat</a> and read it properly — not skimmed, read — and learned more in a fortnight than I had from any of it. Not because the code is clever in a way that shows off. Because it is simple in a way that clearly took enormous effort to arrive at, and because almost every line turns out to be load-bearing once you ask why it is there.</p>

<p>NanoChat describes itself as a simple experimental harness for training LLMs on a single GPU node: tokenization, pretraining, fine-tuning, evaluation, inference, and a chat UI. It is small enough that a motivated reader can hold the whole thing in their head. That is the trick, and it is not an accident.</p>

<p>This post is the first half of a pair, and deliberately the half with no results in it. Here I read the code and write down what I expect to happen when it runs. Part 2 rents four H100s and checks.</p>

<p>That order matters. It is easy to explain why something was fast after watching it be fast. It is harder, and more honest, to commit to the prediction first.</p>

<p>One note on lineage: NanoChat’s README credits <a href="https://github.com/KellerJordan/modded-nanogpt"><code class="language-plaintext highlighter-rouge">modded-nanogpt</code></a> for the speedrun framing and borrows some implementation from it, but they are not the same benchmark — <code class="language-plaintext highlighter-rouge">modded-nanogpt</code> races to a target validation loss, while NanoChat’s speedrun spans tokenizer through chat evaluation. Ancestry, not comparison.</p>

<hr />

<h2 id="the-shape-of-the-repo">The shape of the repo</h2>

<p>The executable spine is <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/runs/speedrun.sh"><code class="language-plaintext highlighter-rouge">runs/speedrun.sh</code></a>, and it is refreshingly literal. In order: set up the Python environment with <code class="language-plaintext highlighter-rouge">uv</code>, download data, train and evaluate the tokenizer, run base pretraining under <code class="language-plaintext highlighter-rouge">torchrun</code>, run base evaluation, download identity conversations, run SFT, run chat evaluation, generate the report.</p>

<p>You can read the project’s entire intent off that one file. There is no orchestration framework, no config hierarchy, no plugin system. The pipeline is a shell script because a shell script is sufficient.</p>

<p>That has a consequence worth stating early: the wall-clock time and the final bill do not measure raw pretraining throughput. They measure the cost of producing a usable artifact and a report about it. Those are different numbers, and the second one is the honest one.</p>

<p>One scope note. The reference speedrun path runs SFT and chat evaluation but not RL. NanoChat does ship <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/chat_rl.py"><code class="language-plaintext highlighter-rouge">scripts/chat_rl.py</code></a>, but it sits outside the path I followed.</p>

<p><img src="/figures/nanochat-speedrun-part-1/repo-map.svg" alt="The whole project on one page" /></p>

<p class="caption"><em>The whole project on one page. Bar length is the wall-clock time each stage actually took in my run, and the lines show which library modules each stage touches. Everything after this section is a zoom into <code class="language-plaintext highlighter-rouge">base_train</code>.</em></p>

<hr />

<h2 id="one-integer-seven-decisions">One integer, seven decisions</h2>

<p>Here is the design choice that made me pay attention.</p>

<p>NanoChat does not ask you to tune anything to get started. The README describes a single complexity dial — the depth of the transformer — and <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/base_train.py"><code class="language-plaintext highlighter-rouge">scripts/base_train.py</code></a> means it literally. From <code class="language-plaintext highlighter-rouge">--depth</code>, the script derives model width, attention head count, parameter count, training horizon, global batch size, learning-rate scaling, and weight decay.</p>

<p>The mechanism is a reference model. The <code class="language-plaintext highlighter-rouge">d12</code> configuration is treated as an empirically measured anchor: its compute-optimal token horizon and batch size come from NanoChat’s own sweeps, recorded in <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/dev/LOG.md"><code class="language-plaintext highlighter-rouge">dev/LOG.md</code></a>. Every other depth is extrapolated from that anchor using scaling-law relationships plus a few hardware constraints.</p>

<p>The chain runs like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>depth
  → base_dim     = depth × aspect_ratio
  → model_dim    = base_dim rounded up to a multiple of head_dim
  → num_heads    = model_dim / head_dim
  → scaling_params
  → target_tokens  = ratio × scaling_params
  → total_batch_size = B_REF × (target_tokens / D_REF)^0.383
  → learning-rate and weight-decay scaling
</code></pre></div></div>

<p>No single step here is exotic. What is unusual is the discipline of routing all of them through one user-facing number — and then printing the derivation so you can check it.</p>

<p>The rounding step shows the layering. <code class="language-plaintext highlighter-rouge">base_dim</code> is <code class="language-plaintext highlighter-rouge">depth × 64</code>, which for depth 24 gives 1536, then rounded up to a multiple of <code class="language-plaintext highlighter-rouge">head_dim</code> — which for 1536 does nothing. The constraint is there anyway, because attention heads must divide evenly and FA3 wants head dimensions it can tile. A depth producing an awkward width gets quietly nudged to a workable one rather than failing later inside a kernel.</p>

<p>The full derivation, with the paper behind each rule, is in <a href="#appendix-b">Appendix B</a>. The short version: batch size follows <a href="https://arxiv.org/abs/2505.13738">Power Lines</a>, scaling roughly as <code class="language-plaintext highlighter-rouge">D^0.383</code>; learning rate follows standard square-root batch scaling; weight decay follows the <a href="https://arxiv.org/abs/2405.13698">T_epoch framework</a>, keeping <code class="language-plaintext highlighter-rouge">B / (η × λ × D)</code> approximately constant.</p>

<p><img src="/figures/nanochat-speedrun-part-1/depth-cascade.svg" alt="One integer fixing everything below it" /></p>

<p class="caption"><em>One integer fixing everything below it. <code class="language-plaintext highlighter-rouge">d12</code> is the measured anchor, which is why its scaling factors are exactly <code class="language-plaintext highlighter-rouge">1.0</code>; the <code class="language-plaintext highlighter-rouge">d24</code> column is the configuration I planned to run, derived by hand from the formulas before I ran anything.</em></p>

<p>I reproduced this derivation by hand for depth 24 before running anything, which gives Part 2 its first thing to check.</p>

<blockquote>
  <p><strong>What I expect to see</strong> — Running <code class="language-plaintext highlighter-rouge">--depth=24</code> should print <code class="language-plaintext highlighter-rouge">model_dim 1536</code>, <code class="language-plaintext highlighter-rouge">num_heads 12</code>, a global batch of <code class="language-plaintext highlighter-rouge">1,048,576</code> tokens, an LR scale of <code class="language-plaintext highlighter-rouge">1.4142</code>, an AdamW LR scale of <code class="language-plaintext highlighter-rouge">0.707107</code>, and <code class="language-plaintext highlighter-rouge">5,568</code> iterations. If any of those differ, I have misread the code.</p>
</blockquote>

<hr />

<h2 id="the-model-it-builds">The model it builds</h2>

<p>The model in <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/gpt.py"><code class="language-plaintext highlighter-rouge">nanochat/gpt.py</code></a> is a compact GPT-style transformer, and I am going to assume you know what that means. Rotary embeddings, RMSNorm, QK normalization, ReLU-squared MLPs, no biases, untied input and output embeddings. Individually, each of these is a choice you can find justified in a paper somewhere.</p>

<p>More interesting is the overall shape of the forward pass:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>token embedding
  → previous-token smear
  → transformer blocks
  → optional mid-layer backout
  → final norm
  → lm_head
</code></pre></div></div>

<p>The two unfamiliar names are both small and cheap. The <em>smear</em> mixes a little of the previous token’s embedding into the current one through a learned gate — bigram-like information for almost no compute. The <em>backout</em> subtracts a cached mid-layer residual before the final norm, removing low-level features the output layer does not need. Neither is a headline idea. Both are the kind of thing you add after watching a lot of training curves.</p>

<p><code class="language-plaintext highlighter-rouge">--depth</code> sets the number of repeated <code class="language-plaintext highlighter-rouge">Block</code> modules, and the rest of the model shape follows from the cascade above. The parameter-by-parameter breakdown is in <a href="#appendix-a">Appendix A</a>.</p>

<h3 id="the-window-pattern-and-the-one-thing-the-comments-get-wrong">The window pattern, and the one thing the comments get wrong</h3>

<p>The detail I did not expect is in sliding-window attention.</p>

<p>NanoChat tiles a pattern string across layers. The default is <code class="language-plaintext highlighter-rouge">SSSL</code>, where <code class="language-plaintext highlighter-rouge">L</code> is full context and <code class="language-plaintext highlighter-rouge">S</code> is shorter. Reading <code class="language-plaintext highlighter-rouge">_compute_window_sizes</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">short_window</span> <span class="o">=</span> <span class="o">-</span><span class="p">(</span><span class="o">-</span><span class="n">long_window</span> <span class="o">//</span> <span class="mi">4</span> <span class="o">//</span> <span class="mi">128</span><span class="p">)</span> <span class="o">*</span> <span class="mi">128</span>  <span class="c1"># ceil to FA3 tile size
</span></code></pre></div></div>

<p>At <code class="language-plaintext highlighter-rouge">sequence_len = 2048</code> that is <strong>512</strong> — a quarter of the context, rounded to a tile boundary. So with <code class="language-plaintext highlighter-rouge">SSSL</code> tiled across 24 layers, layers 3, 7, 11, 15, 19 and 23 get full context, and the other eighteen see 512 tokens.</p>

<p>Eighteen of twenty-four layers never look further back than a quarter of the sequence. Six layers carry every long-range interaction in the model. That is a far more aggressive bet than the phrase “sliding-window attention” suggests, and a large part of why the model is cheap to train.</p>

<p>A final safety line, <code class="language-plaintext highlighter-rouge">window_sizes[-1] = (long_window, 0)</code>, forces the last layer to full context whatever the pattern says. For <code class="language-plaintext highlighter-rouge">SSSL</code> at depth 24 it changes nothing — layer 23 is already <code class="language-plaintext highlighter-rouge">L</code>. It only bites on a pattern ending in <code class="language-plaintext highlighter-rouge">S</code>.</p>

<p>This is also the one place I found where the repository contradicts itself. The <code class="language-plaintext highlighter-rouge">--window-pattern</code> help text in <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/base_train.py#L54"><code class="language-plaintext highlighter-rouge">base_train.py</code></a> describes <code class="language-plaintext highlighter-rouge">S</code> as “half context”, and the inline comment on the line above says <code class="language-plaintext highlighter-rouge">2048 -&gt; 768</code>. The arithmetic gives 512, a quarter. The docstring one line further up says “quarter context” and is correct.</p>

<p>The code is right and the comment is stale. In a repository built this carefully, that was genuinely the only inconsistency I found worth reporting — and it is a comment, not behaviour. Trust the arithmetic.</p>

<p><img src="/figures/nanochat-speedrun-part-1/sssl-window.svg" alt="Bar width is the attention window, so the ratio is the whole story" /></p>

<p class="caption"><em>Bar width is the attention window, so the ratio is the whole story. Eighteen of twenty-four layers never see more than 512 tokens; the six gold bars carry every long-range interaction in the model.</em></p>

<hr />

<h2 id="why-it-should-be-fast">Why it should be fast</h2>

<p>Everything above is design. This section is what I actually came for: the specific machinery that should keep expensive GPUs busy.</p>

<p>There are five mechanisms, and they are worth naming up front rather than scattering:</p>

<ol>
  <li><strong>Fixed shapes and honest batch math</strong> — so the compiler can specialize and the global batch never drifts.</li>
  <li><strong>Attention</strong> — FA3 on Hopper, and nothing pretending to be FA3 elsewhere.</li>
  <li><strong>Precision</strong> — FP8 where it is safe, and an explicit refusal where it is not.</li>
  <li><strong>Communication</strong> — the optimizer owns synchronization instead of the model wrapper.</li>
  <li><strong>The data feeder</strong> — dense, fixed-shape batches with no padding at all.</li>
</ol>

<p>That order runs from inside the model outward: shapes, then kernels, then numerics, then across GPUs, then the pipe feeding it all.</p>

<h3 id="fixed-shapes-and-honest-batch-math">Fixed shapes and honest batch math</h3>

<p>The compile call is one line and it sets the tone:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">model</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">dynamic</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">dynamic=False</code> promises the compiler these shapes will not change, so it can specialize hard rather than emit code that handles anything. That is only a safe promise if the rest of the system genuinely never changes shape — which is why the dataloader later goes to such lengths to produce exactly-full rows.</p>

<p>The batch arithmetic is equally direct:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tokens_per_fwdbwd</span> <span class="o">=</span> <span class="n">args</span><span class="p">.</span><span class="n">device_batch_size</span> <span class="o">*</span> <span class="n">args</span><span class="p">.</span><span class="n">max_seq_len</span>
<span class="n">world_tokens_per_fwdbwd</span> <span class="o">=</span> <span class="n">tokens_per_fwdbwd</span> <span class="o">*</span> <span class="n">ddp_world_size</span>
<span class="k">assert</span> <span class="n">total_batch_size</span> <span class="o">%</span> <span class="n">world_tokens_per_fwdbwd</span> <span class="o">==</span> <span class="mi">0</span>
</code></pre></div></div>

<p>That assert does more work than it looks. <code class="language-plaintext highlighter-rouge">total_batch_size</code> is a property of the <em>experiment</em> — it comes out of the depth cascade and shapes the learning dynamics. <code class="language-plaintext highlighter-rouge">device_batch_size</code> is a property of the <em>machine</em>: however much fits in memory. Forcing the two to reconcile through gradient accumulation is what stops the effective batch quietly becoming whatever the hardware allowed.</p>

<p>So GPU count becomes an execution detail rather than a hyperparameter. Run the same configuration on four GPUs instead of eight and you get twice the accumulation steps and an identical global batch. The model does not know how many GPUs it trained on. (In the same spirit, the script takes manual control of Python’s garbage collector after the first step — the mark of someone who profiled their training loop and found GC pauses in it.)</p>

<blockquote>
  <p><strong>What I expect to see</strong> — On four H100s with <code class="language-plaintext highlighter-rouge">device_batch_size=16</code> and <code class="language-plaintext highlighter-rouge">max_seq_len=2048</code>, that is <code class="language-plaintext highlighter-rouge">32,768</code> tokens per rank per micro-batch and <code class="language-plaintext highlighter-rouge">131,072</code> across four ranks. To reach a <code class="language-plaintext highlighter-rouge">1,048,576</code>-token global batch the script must choose exactly <code class="language-plaintext highlighter-rouge">8</code> gradient accumulation steps, and should say so.</p>
</blockquote>

<h3 id="attention-fa3-and-only-on-hopper">Attention: FA3, and only on Hopper</h3>

<p><a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/flash_attention.py"><code class="language-plaintext highlighter-rouge">nanochat/flash_attention.py</code></a> tries to load Flash Attention 3, but only on Hopper-class GPUs, and falls back to PyTorch’s SDPA everywhere else behind an API-compatible shim.</p>

<p>What I like is the honesty of the fallback. It is not sold as equivalent. The training script warns that it is meaningfully less efficient, and warns harder if you combine it with sliding windows — a sliding-window mask through the generic path is expensive exactly where FA3 would be cheap. If you are not on Hopper, the advice is to stop pretending and use <code class="language-plaintext highlighter-rouge">--window-pattern=L</code>. Hardware-specific paths are labelled as such rather than hidden behind an abstraction that silently underperforms.</p>

<blockquote>
  <p><strong>What I expect to see</strong> — On H100s the run should announce that FA3 is active. If it announces the SDPA fallback instead, every performance number afterwards is measuring something else.</p>
</blockquote>

<h3 id="precision-what-fp8-refuses-to-touch">Precision: what FP8 refuses to touch</h3>

<p>FP8 is where I expected to find hand-waving, and did not.</p>

<p><a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/base_train.py#L166-L193"><code class="language-plaintext highlighter-rouge">base_train.py</code></a> exposes <code class="language-plaintext highlighter-rouge">--fp8</code>, and on CUDA it walks the model converting eligible <code class="language-plaintext highlighter-rouge">nn.Linear</code> modules. <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/fp8.py"><code class="language-plaintext highlighter-rouge">nanochat/fp8.py</code></a> is deliberately small: tensorwise dynamic scaling, quantize, <code class="language-plaintext highlighter-rouge">torch._scaled_mm</code>, dequantize. Weights use <code class="language-plaintext highlighter-rouge">float8_e4m3fn</code>, gradients <code class="language-plaintext highlighter-rouge">float8_e5m2</code> — trading mantissa bits for range, because gradients need the range more.</p>

<p>The interesting word is <em>eligible</em>. The filter skips layers whose dimensions are not divisible by 16, and skips very small layers entirely. Neither rule is arbitrary: FP8 tensor-core paths have alignment requirements, and for a tiny matrix the quantize/dequantize overhead costs more than the faster multiply saves. <code class="language-plaintext highlighter-rouge">dev/LOG.md</code> adds that tensorwise scaling beat rowwise at this scale, and that the filtering was needed for stability. This is not a switch flipped because it was available.</p>

<p>So I counted the modules by hand. A depth-24 model has 24 blocks, each with 6 linear matrices, plus the <code class="language-plaintext highlighter-rouge">lm_head</code>: 145 conversions. The layers that should fail the filter are the twelve <code class="language-plaintext highlighter-rouge">ve_gate</code> projections, each <code class="language-plaintext highlighter-rouge">Linear(12, 12)</code> — 12 is not divisible by 16 — and the single <code class="language-plaintext highlighter-rouge">smear_gate</code>, which is <code class="language-plaintext highlighter-rouge">Linear(24, 1)</code> and far below any sensible minimum. That is 13 skipped, 158 total.</p>

<p><img src="/figures/nanochat-speedrun-part-1/precision-map.svg" alt="Every linear layer in the model, drawn at the same size so the proportion is honest" /></p>

<p class="caption"><em>Every linear layer in the model, drawn at the same size so the proportion is honest. The thirteen the filter refuses are not an oversight: twelve <code class="language-plaintext highlighter-rouge">ve_gate</code> projections that fail the divisible-by-16 rule, and one <code class="language-plaintext highlighter-rouge">smear_gate</code> far below any workable dimension.</em></p>

<blockquote>
  <p><strong>What I expect to see</strong> — The run should report converting exactly <code class="language-plaintext highlighter-rouge">145</code> of <code class="language-plaintext highlighter-rouge">158</code> linear layers and skipping <code class="language-plaintext highlighter-rouge">13</code>. If the numbers differ, my reading of the filter is wrong.</p>
</blockquote>

<h3 id="communication-the-optimizer-owns-it">Communication: the optimizer owns it</h3>

<p>This is the part of NanoChat I would most want to show someone learning distributed training.</p>

<p>The model is never wrapped in PyTorch’s <code class="language-plaintext highlighter-rouge">DistributedDataParallel</code>. Instead, <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/gpt.py#L374-L414"><code class="language-plaintext highlighter-rouge">GPT.setup_optimizer()</code></a> switches to <code class="language-plaintext highlighter-rouge">DistMuonAdamW</code> from <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/optim.py"><code class="language-plaintext highlighter-rouge">nanochat/optim.py</code></a> when the world size exceeds one, and that optimizer does the synchronization itself.</p>

<p>It is a hybrid: AdamW for embeddings, the unembedding and scalar-ish parameters, Muon for the matrix parameters that make up the bulk of the model. The distributed step has three explicit phases:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>launch async reductions
  → wait, compute local updates, launch gathers
  → wait for gathers, copy updated parameters back
</code></pre></div></div>

<p>Each rank receives one slice of the gradient via <code class="language-plaintext highlighter-rouge">reduce_scatter</code>, updates only that slice, then <code class="language-plaintext highlighter-rouge">all_gather</code>s to rebuild the full parameter. Optimizer state is sharded across ranks — ZeRO-2 in style — so no rank holds a full copy of the Adam moments. For Muon parameters the code goes further, grouping tensors by shape so many small ones move as a few large ones.</p>

<p>Three goals, all visible in the structure: overlap communication with computation, cut optimizer memory, batch small transfers into fewer large ones. And because it is an explicit phase rather than a hook buried in a wrapper’s backward pass, you can <em>see</em> where the distributed boundary is. Gradients stay rank-local through all eight micro-batches — precisely where a DDP-wrapped model would already be all-reducing.</p>

<p><img src="/figures/nanochat-speedrun-part-1/optimizer-step.svg" alt="One step, four ranks, one time axis" /></p>

<p class="caption"><em>One step, four ranks, one time axis. The dashed line marks where an ordinary DDP-wrapped model would already have been all-reducing; here the gradients are still rank-local, and communication happens afterwards as an explicit async phase.</em></p>

<blockquote>
  <p><strong>Not tested here</strong> — I can read the design, but I have no profiler trace. I will not claim communication was actually hidden behind computation, only that the code is structured so it could be.</p>
</blockquote>

<h3 id="feeding-the-gpus">Feeding the GPUs</h3>

<p>Fast kernels are useless if the data pipe stalls, and this is the part of the repo I ended up admiring most.</p>

<p>Training data is ClimbMix parquet shards. <code class="language-plaintext highlighter-rouge">speedrun.sh</code> grabs 8 first so tokenizer training can start, then downloads the remaining 170 in the background while tokenizer work proceeds — even the download is pipelined. <code class="language-plaintext highlighter-rouge">dev/LOG.md</code> calls the switch from FineWeb-EDU to ClimbMix the single biggest improvement to the speedrun time, 2h46m down to 2h01m, and enough of a gain to move the target from <code class="language-plaintext highlighter-rouge">d26</code> to <code class="language-plaintext highlighter-rouge">d24</code>. That is the repository’s measurement, not mine.</p>

<p>The runtime loader in <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/dataloader.py"><code class="language-plaintext highlighter-rouge">nanochat/dataloader.py</code></a> uses BOS-aligned best-fit packing. Every row begins with a BOS token. The inner loop is:</p>

<ol>
  <li>From the buffered documents, take the <strong>largest</strong> that fits entirely in the remaining space.</li>
  <li>Repeat until nothing fits.</li>
  <li>When nothing fits, crop the <strong>shortest</strong> buffered document to fill the remainder exactly.</li>
</ol>

<p>Two details are easy to miss. Row capacity is <code class="language-plaintext highlighter-rouge">T + 1 = 2049</code>, not 2048, because inputs and targets are the same row offset by one. And the document chosen for cropping is the <em>shortest</em> available, not an arbitrary one, which minimizes what is discarded at that step.</p>

<p>The result is 100% utilization. No padding, ever. Every token in every batch is a real token contributing to the loss.</p>

<p><img src="/figures/nanochat-speedrun-part-1/bestfit-packing.svg" alt="Successive states of a single row" /></p>

<p class="caption"><em>Successive states of a single row. Two documents are placed whole, then nothing in the buffer fits the remaining 270 tokens, so the shortest buffered document is cropped to fill it exactly. The red block is what that costs.</em></p>

<h4 id="why-a-third-of-the-tokens-get-thrown-away">Why a third of the tokens get thrown away</h4>

<p><code class="language-plaintext highlighter-rouge">dev/LOG.md</code> records the cost: BestFit-Crop achieves 100% utilization with roughly <strong>34.6% crop waste</strong> at <code class="language-plaintext highlighter-rouge">T=2048</code>. That number bothered me. A third of the corpus discarded seemed like a lot for a scheme described as best-fit.</p>

<p>Then I traced where it has to come from.</p>

<p>A document longer than <code class="language-plaintext highlighter-rouge">row_capacity</code> can never be selected by the best-fit search, because that search only considers documents that fit <em>entirely</em> in the remaining space. So the only way such a document ever leaves the buffer is the crop path — and the crop path deliberately picks the <em>shortest</em> buffered document. An over-long document therefore has to wait until it is the shortest thing in the buffer, which for a genuinely long document is close to never.</p>

<p>Over-long documents accumulate. The steady-state buffer becomes far more long-tailed than the raw corpus, and the crop rate climbs toward an equilibrium well above anything you would estimate from the document-length distribution alone.</p>

<p>Once you see that, the number stops looking like inefficiency. The 34.6% is not the packer doing a bad job. It is the price of insisting that every row starts at a real document boundary while <code class="language-plaintext highlighter-rouge">T</code> stays fixed — paid deliberately, so that every training row is dense, fixed-shape, and free of padding tokens the model would otherwise have to learn to ignore.</p>

<p>SFT makes the opposite trade. <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/chat_sft.py"><code class="language-plaintext highlighter-rouge">scripts/chat_sft.py</code></a> uses the same packing, but when no conversation fits it <strong>pads</strong> the row and masks the padding out of the loss. Web text is abundant and interchangeable, so cropping it is cheap; conversations are scarce and structured, and discarding their tails would not be.</p>

<blockquote>
  <p><strong>What I expect to see</strong> — Because every row is filled exactly and nothing is padded, total tokens trained should be exactly <code class="language-plaintext highlighter-rouge">iterations × global batch</code>, with no remainder. For 5,568 iterations at 1,048,576 tokens that is <code class="language-plaintext highlighter-rouge">5,838,471,168</code> — and the run should report that number precisely, not approximately.</p>
</blockquote>

<blockquote>
  <p><strong>Not tested here</strong> — The 34.6% figure is the repository’s measurement on real ClimbMix documents. I reproduced the accumulation <em>trend</em> in a small simulation, but did not instrument the actual loader. The prefetching around the loader is code structure, not a measured overlap.</p>
</blockquote>

<hr />

<h2 id="after-pretraining">After pretraining</h2>

<p>Base pretraining dominates the cost, but it is not the whole pipeline, and the later stages are what turn a checkpoint into something you can talk to.</p>

<p><code class="language-plaintext highlighter-rouge">speedrun.sh</code> downloads a set of synthetic identity conversations, runs <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/chat_sft.py"><code class="language-plaintext highlighter-rouge">scripts/chat_sft.py</code></a>, then runs chat evaluation. SFT loads the base checkpoint, inherits most hyperparameters from its metadata rather than making you restate them, and reuses the same optimizer setup. The data mixture is SmolTalk, the identity conversations, MMLU, GSM8K, SimpleSpelling, and SpellingBee.</p>

<p>RL exists — <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/chat_rl.py"><code class="language-plaintext highlighter-rouge">scripts/chat_rl.py</code></a> is a GRPO/REINFORCE-style loop on GSM8K — but it is deliberately simple, with no separate critic or reference-policy copies, and sits outside the speedrun path.</p>

<p>Success for the speedrun is measured as CORE for the base model and ChatCORE after SFT. The target is GPT-2-grade capability: a modest bar by 2026 standards, and a reasonable one for a run meant to cost less than a nice dinner.</p>

<hr />

<h2 id="what-i-expect-to-see">What I expect to see</h2>

<p>Reading code and predicting behaviour are different activities, and only the second can be wrong in a way you can check. So here is the list I wrote down before renting anything.</p>

<table class="wide">
  <thead>
    <tr>
      <th>#</th>
      <th>What I expect</th>
      <th>How it would be falsified</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">--depth=24</code> derives <code class="language-plaintext highlighter-rouge">model_dim 1536</code>, <code class="language-plaintext highlighter-rouge">num_heads 12</code>, batch <code class="language-plaintext highlighter-rouge">1,048,576</code>, LR scale <code class="language-plaintext highlighter-rouge">1.4142</code>, AdamW LR scale <code class="language-plaintext highlighter-rouge">0.707107</code>, <code class="language-plaintext highlighter-rouge">5,568</code> iterations</td>
      <td>Any value differs from my hand derivation</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Four GPUs gives <code class="language-plaintext highlighter-rouge">8</code> gradient accumulation steps and the same <code class="language-plaintext highlighter-rouge">1,048,576</code>-token global batch that eight would</td>
      <td>The effective batch changes with GPU count</td>
    </tr>
    <tr>
      <td>3</td>
      <td>FP8 converts exactly <code class="language-plaintext highlighter-rouge">145</code> of <code class="language-plaintext highlighter-rouge">158</code> linear layers, skipping <code class="language-plaintext highlighter-rouge">13</code></td>
      <td>A different count, or different layers</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Total tokens trained is exactly <code class="language-plaintext highlighter-rouge">5,568 × 1,048,576 = 5,838,471,168</code></td>
      <td>Any remainder, which would mean padding somewhere</td>
    </tr>
    <tr>
      <td>5</td>
      <td>FA3 activates on H100 and says so</td>
      <td>The run falls back to SDPA</td>
    </tr>
    <tr>
      <td>6</td>
      <td>Base training is GPU-bound, so half the GPUs should take roughly twice as long as the 8×H100 reference of 1.65 h</td>
      <td>Materially better or worse than about 2×</td>
    </tr>
    <tr>
      <td>7</td>
      <td>Utilization is high and <em>stable</em> — not a good average hiding a bad distribution</td>
      <td>MFU that sags, spikes, or decays across the run</td>
    </tr>
    <tr>
      <td>8</td>
      <td>The whole thing lands well under the old $100 GPT-2 line</td>
      <td>It does not</td>
    </tr>
  </tbody>
</table>

<p>The first five are checkable from the run log alone. The last three need the run to finish.</p>

<p>If most of these hold, the interesting claim is not “NanoChat is fast.” It is that a codebase simple enough to read in an afternoon can be this specific about what it is doing, and be right.</p>

<h2 id="what-i-am-not-testing">What I am not testing</h2>

<p>A list of confirmed predictions is only worth something if the scope is honest.</p>

<p>I am <strong>not</strong> validating the scaling-law choices. I reproduced the derivation and can confirm the code implements what it claims, but whether <code class="language-plaintext highlighter-rouge">D^0.383</code> is the right exponent, or 12 the right token-to-parameter ratio, would need sweeps I have not run.</p>

<p>I am <strong>not</strong> attributing performance to individual components. If the run hits high utilization, that is the whole stack — model, attention, precision, optimizer, loader, compiler, hardware. Isolating any one needs ablations, and I have none.</p>

<p>I am <strong>not</strong> measuring kernel-level behaviour: no profiler traces, so no claims about communication actually overlapping computation. And I am not evaluating tokenizer quality, KV-cache internals, or CORE methodology. Real topics, not this post.</p>

<p>Part 2 rents the GPUs and works down the list.</p>

<hr />

<h2 id="appendix-a">Appendix A — Model configuration</h2>

<table class="wide">
  <thead>
    <tr>
      <th>Model piece</th>
      <th>Parameter / default</th>
      <th>What it affects</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Context length</td>
      <td><code class="language-plaintext highlighter-rouge">sequence_len</code>, default <code class="language-plaintext highlighter-rouge">2048</code> via <code class="language-plaintext highlighter-rouge">--max-seq-len</code></td>
      <td>Maximum training context and rotary cache sizing</td>
    </tr>
    <tr>
      <td>Vocabulary</td>
      <td><code class="language-plaintext highlighter-rouge">vocab_size</code>, from the tokenizer</td>
      <td>Embedding table and <code class="language-plaintext highlighter-rouge">lm_head</code>; padded internally for efficiency</td>
    </tr>
    <tr>
      <td>Number of blocks</td>
      <td><code class="language-plaintext highlighter-rouge">n_layer = depth</code></td>
      <td>How many transformer blocks are stacked</td>
    </tr>
    <tr>
      <td>Width</td>
      <td><code class="language-plaintext highlighter-rouge">n_embd = model_dim</code></td>
      <td>Residual-stream width, MLP and attention matrix sizes</td>
    </tr>
    <tr>
      <td>Query heads</td>
      <td><code class="language-plaintext highlighter-rouge">n_head = num_heads</code></td>
      <td>Number of query attention heads</td>
    </tr>
    <tr>
      <td>KV heads</td>
      <td><code class="language-plaintext highlighter-rouge">n_kv_head</code>, equal to <code class="language-plaintext highlighter-rouge">n_head</code> in base training</td>
      <td>GQA is supported in the model definition; base training does not use it</td>
    </tr>
    <tr>
      <td>Window pattern</td>
      <td><code class="language-plaintext highlighter-rouge">window_pattern</code>, default <code class="language-plaintext highlighter-rouge">SSSL</code></td>
      <td>Per-layer attention window; <code class="language-plaintext highlighter-rouge">S</code> is a quarter of context, <code class="language-plaintext highlighter-rouge">L</code> is full</td>
    </tr>
  </tbody>
</table>

<p>Source: <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/nanochat/gpt.py#L28-L40"><code class="language-plaintext highlighter-rouge">GPTConfig</code></a> and <a href="https://github.com/anandnair2005/nanochat/blob/254e524e2df052abe1048ab277a862ad005bc46a/scripts/base_train.py#L130-L144"><code class="language-plaintext highlighter-rouge">build_model_meta</code></a>.</p>

<h2 id="appendix-b">Appendix B — The <code class="language-plaintext highlighter-rouge">--depth</code> derivation</h2>

<p>Inputs and reference constants:</p>

<table class="wide">
  <thead>
    <tr>
      <th>Name</th>
      <th>Default</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">depth</code></td>
      <td><code class="language-plaintext highlighter-rouge">20</code></td>
      <td>The one knob</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aspect_ratio</code></td>
      <td><code class="language-plaintext highlighter-rouge">64</code></td>
      <td>Width per layer</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">head_dim</code></td>
      <td><code class="language-plaintext highlighter-rouge">128</code></td>
      <td>Target attention head dimension</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">target_param_data_ratio</code></td>
      <td><code class="language-plaintext highlighter-rouge">12</code></td>
      <td>Tokens per parameter; the CLI help notes Chinchilla uses 20</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">weight_decay</code></td>
      <td><code class="language-plaintext highlighter-rouge">0.28</code></td>
      <td>Reference weight decay, before scaling</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">d12_ref</code></td>
      <td><code class="language-plaintext highlighter-rouge">build_model_meta(12)</code></td>
      <td>The measured anchor model</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">B_REF</code></td>
      <td><code class="language-plaintext highlighter-rouge">2**19</code> = <code class="language-plaintext highlighter-rouge">524,288</code></td>
      <td>Reference batch size for <code class="language-plaintext highlighter-rouge">d12</code></td>
    </tr>
  </tbody>
</table>

<p>Derived values:</p>

<table class="wide">
  <thead>
    <tr>
      <th>Name</th>
      <th>Formula</th>
      <th>Basis</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">base_dim</code></td>
      <td><code class="language-plaintext highlighter-rouge">depth × aspect_ratio</code></td>
      <td>Architecture convention</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">model_dim</code></td>
      <td><code class="language-plaintext highlighter-rouge">ceil(base_dim / head_dim) × head_dim</code></td>
      <td>Head divisibility and FA3 tiling</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">num_heads</code></td>
      <td><code class="language-plaintext highlighter-rouge">model_dim / head_dim</code></td>
      <td>Follows from the above</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scaling_params</code></td>
      <td>transformer matrices + <code class="language-plaintext highlighter-rouge">lm_head</code></td>
      <td><a href="https://arxiv.org/abs/2001.08361">Scaling Laws</a>; <code class="language-plaintext highlighter-rouge">dev/LOG.md</code> notes this subset gave cleaner sweeps</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">target_tokens</code></td>
      <td><code class="language-plaintext highlighter-rouge">target_param_data_ratio × scaling_params</code></td>
      <td><a href="https://arxiv.org/abs/2203.15556">Chinchilla</a>-style token budgeting</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">D_REF</code></td>
      <td>the same rule applied to <code class="language-plaintext highlighter-rouge">d12_ref</code></td>
      <td>The anchor’s horizon</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">total_batch_size</code></td>
      <td><code class="language-plaintext highlighter-rouge">B_REF × (target_tokens / D_REF)^0.383</code>, rounded to a power of two</td>
      <td><a href="https://arxiv.org/abs/2505.13738">Power Lines</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">batch_lr_scale</code></td>
      <td><code class="language-plaintext highlighter-rouge">η ∝ √(total_batch_size / B_REF)</code></td>
      <td>Square-root batch scaling for AdamW, applied to Muon as a practical assumption</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">weight_decay_scaled</code></td>
      <td><code class="language-plaintext highlighter-rouge">λ = λ_ref × √(B / B_REF) × (D_REF / D)</code></td>
      <td><a href="https://arxiv.org/abs/2405.13698">T_epoch</a>: keep <code class="language-plaintext highlighter-rouge">B / (η × λ × D)</code> roughly constant</td>
    </tr>
  </tbody>
</table>

<p>Reference-model extrapolation follows <a href="https://arxiv.org/abs/2203.03466">muP</a>-style transfer from the measured <code class="language-plaintext highlighter-rouge">d12</code> anchor.</p>

<h2 id="references">References</h2>

<ul>
  <li>Andrej Karpathy, <a href="https://github.com/karpathy/nanochat">NanoChat</a>.</li>
  <li>Keller Jordan, <a href="https://github.com/KellerJordan/modded-nanogpt"><code class="language-plaintext highlighter-rouge">modded-nanogpt</code></a>.</li>
  <li>Kaplan et al., <a href="https://arxiv.org/abs/2001.08361">Scaling Laws for Neural Language Models</a>.</li>
  <li>Hoffmann et al., <a href="https://arxiv.org/abs/2203.15556">Training Compute-Optimal Large Language Models</a> (Chinchilla).</li>
  <li>Yang et al., <a href="https://arxiv.org/abs/2203.03466">Tensor Programs V: Tuning Large Neural Networks via Zero-Shot Hyperparameter Transfer</a> (muP).</li>
  <li><a href="https://arxiv.org/abs/2505.13738">Power Lines: Scaling Laws for Optimal Batch Size</a>.</li>
  <li><a href="https://arxiv.org/abs/2405.13698">The T_epoch framework</a>.</li>
  <li>Horace He, <a href="https://horace.io/brrr_intro.html">Making Deep Learning Go Brrrr From First Principles</a> — the source of this post’s title phrasing.</li>
</ul>]]></content><author><name>Anand Nair</name></author><category term="nanochat" /><category term="gpu" /><category term="training" /><category term="llm" /><summary type="html"><![CDATA[A close reading of NanoChat's training stack, and the eight predictions it let me make before spending anything on GPUs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://anandnair.dev/figures/nanochat-speedrun-part-1/png/sssl-window.png" /><media:content medium="image" url="https://anandnair.dev/figures/nanochat-speedrun-part-1/png/sssl-window.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>