<?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://www.tahabouhsine.com/flaxchat/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.tahabouhsine.com/flaxchat/" rel="alternate" type="text/html" /><updated>2026-04-09T16:51:30+00:00</updated><id>https://www.tahabouhsine.com/flaxchat/feed.xml</id><title type="html">flaxchat</title><subtitle>Training LLMs from scratch on TPUs with JAX/Flax NNX</subtitle><entry><title type="html">Training a 19M Parameter Story Generator from Scratch on 2xT4 GPUs</title><link href="https://www.tahabouhsine.com/flaxchat/blog/tinystories-experiment/" rel="alternate" type="text/html" title="Training a 19M Parameter Story Generator from Scratch on 2xT4 GPUs" /><published>2026-04-01T00:00:00+00:00</published><updated>2026-04-01T00:00:00+00:00</updated><id>https://www.tahabouhsine.com/flaxchat/blog/tinystories-experiment</id><content type="html" xml:base="https://www.tahabouhsine.com/flaxchat/blog/tinystories-experiment/"><![CDATA[<p>We trained a GPT language model from scratch on the <a href="https://huggingface.co/datasets/roneneldan/TinyStories">TinyStories</a> dataset using <strong>flaxchat</strong>, our JAX/Flax NNX port of <a href="https://github.com/karpathy/nanochat">nanochat</a>. The entire pipeline ran end-to-end on <strong>2x NVIDIA T4 GPUs</strong> via Kaggle with data-parallel training, generating coherent children’s stories in under an hour.</p>

<h2 id="experimental-setup">Experimental Setup</h2>

<h3 id="hardware--software">Hardware &amp; Software</h3>
<ul>
  <li><strong>2x NVIDIA T4 GPUs</strong> (16GB each) on Kaggle</li>
  <li>JAX 0.7.2, Flax 0.11.2</li>
  <li>Data-parallel training via JAX SPMD mesh</li>
  <li><strong>80 unit tests passing</strong> on both CPU and GPU</li>
</ul>

<h3 id="model-architecture-189m-params">Model Architecture (18.9M params)</h3>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Layers</td>
      <td>8</td>
    </tr>
    <tr>
      <td>Dimensions</td>
      <td>256</td>
    </tr>
    <tr>
      <td>Heads</td>
      <td>8 (GQA-ready)</td>
    </tr>
    <tr>
      <td>Parameters</td>
      <td>18,874,794</td>
    </tr>
    <tr>
      <td>Sequence length</td>
      <td>512</td>
    </tr>
    <tr>
      <td>Vocab</td>
      <td>8,192 (BPE)</td>
    </tr>
    <tr>
      <td>Attention</td>
      <td><code class="language-plaintext highlighter-rouge">jax.nn.dot_product_attention</code> (hardware-adaptive)</td>
    </tr>
  </tbody>
</table>

<p>All nanochat features ported: RoPE, QK-norm (1.2x), ReLU^2, value embeddings (alternating), residual/x0 lambdas, smear, backout, logit softcap.</p>

<h3 id="dataset">Dataset</h3>

<ul>
  <li><strong>500,000 stories</strong> from TinyStories (~106M tokens)</li>
  <li>BPE tokenizer trained on 50K stories (3.8x compression)</li>
  <li>BOS-aligned packing</li>
</ul>

<h2 id="results">Results</h2>

<h3 id="training-loss">Training Loss</h3>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>Train</th>
      <th>Val</th>
      <th>Tok/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>9.01</td>
      <td>9.01</td>
      <td>1,849 (JIT warmup)</td>
    </tr>
    <tr>
      <td>500</td>
      <td>3.71</td>
      <td>3.70</td>
      <td>54,017</td>
    </tr>
    <tr>
      <td>1000</td>
      <td>3.03</td>
      <td>3.01</td>
      <td>~55K</td>
    </tr>
    <tr>
      <td>2000</td>
      <td>2.55</td>
      <td>2.53</td>
      <td>~55K</td>
    </tr>
    <tr>
      <td>3000</td>
      <td>2.30</td>
      <td>2.33</td>
      <td>~55K</td>
    </tr>
    <tr>
      <td>4000</td>
      <td>2.19</td>
      <td>2.22</td>
      <td>~55K</td>
    </tr>
    <tr>
      <td>4999</td>
      <td>2.12</td>
      <td><strong>2.20</strong></td>
      <td>~55K</td>
    </tr>
  </tbody>
</table>

<p><strong>Best validation loss: 2.1995</strong> in 50.5 minutes.</p>

<h3 id="data-parallelism">Data Parallelism</h3>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Throughput</th>
      <th>Batch</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1 GPU</td>
      <td>30K tok/s</td>
      <td>32</td>
    </tr>
    <tr>
      <td><strong>2 GPU (SPMD)</strong></td>
      <td><strong>55K tok/s</strong></td>
      <td><strong>64</strong></td>
    </tr>
  </tbody>
</table>

<p>Nearly 2x speedup. Three lines of code enable it:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mesh</span> <span class="o">=</span> <span class="n">Mesh</span><span class="p">(</span><span class="n">create_device_mesh</span><span class="p">((</span><span class="mi">2</span><span class="p">,)),</span> <span class="p">(</span><span class="s">'data'</span><span class="p">,))</span>
<span class="n">state</span> <span class="o">=</span> <span class="n">jax</span><span class="p">.</span><span class="n">device_put</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">NamedSharding</span><span class="p">(</span><span class="n">mesh</span><span class="p">,</span> <span class="n">P</span><span class="p">()))</span>
<span class="n">inputs</span> <span class="o">=</span> <span class="n">with_sharding_constraint</span><span class="p">(</span><span class="n">inputs</span><span class="p">,</span> <span class="n">NamedSharding</span><span class="p">(</span><span class="n">mesh</span><span class="p">,</span> <span class="n">P</span><span class="p">(</span><span class="s">'data'</span><span class="p">)))</span>
</code></pre></div></div>

<h3 id="benchmark-evaluation">Benchmark Evaluation</h3>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Score</th>
      <th>Baseline</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MMLU</td>
      <td>0.220</td>
      <td>0.25 (random)</td>
    </tr>
    <tr>
      <td>ARC</td>
      <td>0.275</td>
      <td>0.25 (random)</td>
    </tr>
  </tbody>
</table>

<p>Near-random as expected for 19M params on stories. Pipeline validated end-to-end.</p>

<h3 id="generated-samples">Generated Samples</h3>

<blockquote>
  <p><strong>Once upon a time</strong>, there was a little girl named Lily. She loved to play with her toys and her favorite was her favorite toy, a blue teddy bear. One day, Lily’s mom asked her to help with the phone. Lily didn’t want to, so she said no and tried to fix her teddy bear.</p>
</blockquote>

<blockquote>
  <p><strong>The little dog</strong> was very happy. She found a big tree and wanted to play with the birds inside. She took it to her friend Sally and said, “Look, Sally! It’s all fun!” Sally smiled and said, “Yes, it’s very nice. Let’s go get it!”</p>
</blockquote>

<blockquote>
  <p><strong>A girl named Lily</strong>. She was a very good girl and loved her mom very much. Every day, she tried to be brave. She would sing all day and make her own faces.</p>
</blockquote>

<blockquote>
  <p><strong>One day, the sun</strong> was shining brightly. It was a sunny day and the park was happy. Then, a little boy saw the slide and ran over to it. “What is that?” he asked. “I don’t know. What is inside?” the boy asked.</p>
</blockquote>

<h3 id="inference-speed">Inference Speed</h3>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Speed</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Padded forward</td>
      <td>3.4 tok/s</td>
      <td>Recomputes full sequence, single JIT shape</td>
    </tr>
    <tr>
      <td><strong>KV-cache</strong></td>
      <td><strong>8.9 tok/s</strong></td>
      <td><code class="language-plaintext highlighter-rouge">dynamic_update_slice</code>, no recompilation</td>
    </tr>
  </tbody>
</table>

<p>2.6x speedup with KV cache. The key fix: <code class="language-plaintext highlighter-rouge">jax.lax.dynamic_update_slice</code> for cache updates and <code class="language-plaintext highlighter-rouge">jax.lax.dynamic_slice</code> for RoPE — static shapes so JIT compiles once.</p>

<h2 id="full-pipeline-summary">Full Pipeline Summary</h2>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>Time</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Data loading</td>
      <td>19s</td>
      <td>2.1M stories from HuggingFace</td>
    </tr>
    <tr>
      <td>Tokenizer</td>
      <td>5s</td>
      <td>BPE vocab=8192 on 50K stories</td>
    </tr>
    <tr>
      <td>Tokenization</td>
      <td>98s</td>
      <td>500K stories → 106M tokens</td>
    </tr>
    <tr>
      <td><strong>Pretraining</strong></td>
      <td><strong>50.5 min</strong></td>
      <td>5000 steps, val=2.20, 55K tok/s</td>
    </tr>
    <tr>
      <td>SFT</td>
      <td>~10 min</td>
      <td>1000 steps on conversations</td>
    </tr>
    <tr>
      <td>Eval</td>
      <td>~2 min</td>
      <td>MMLU + ARC (batched on GPU)</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>~65 min</strong></td>
      <td>End-to-end on free Kaggle GPUs</td>
    </tr>
  </tbody>
</table>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>1. Device placement is everything.</strong> JAX follows the data — if you create a <code class="language-plaintext highlighter-rouge">jnp.array</code> on CPU and pass it to a model on GPU, computation runs on CPU. Always use <code class="language-plaintext highlighter-rouge">jax.device_put(arr, sharding)</code>.</p>

<p><strong>2. Flax NNX version compat matters.</strong> <code class="language-plaintext highlighter-rouge">nnx.List</code>/<code class="language-plaintext highlighter-rouge">nnx.Dict</code> exist in 0.12+ but not 0.11. We use <code class="language-plaintext highlighter-rouge">getattr(nnx, 'List', list)</code> as a shim. The Muon optimizer’s NamedTuple state crashes <code class="language-plaintext highlighter-rouge">nnx.Optimizer</code> on 0.11 — we fall back to AdamW.</p>

<p><strong>3. Generation needs static shapes.</strong> Token-by-token generation with changing array shapes causes JIT recompilation at every step. Fix: <code class="language-plaintext highlighter-rouge">jax.lax.dynamic_update_slice</code> for KV cache (static shape, dynamic index) or pad to max length.</p>

<p><strong>4. <code class="language-plaintext highlighter-rouge">jax.nn.dot_product_attention</code> is the right call.</strong> It auto-selects the best kernel (cuDNN on GPU, XLA on TPU) and handles masking cleanly.</p>

<h2 id="whats-next">What’s Next</h2>

<ol>
  <li><strong>Scale up</strong>: Train on ClimbMix-400B on TPU v4-32 pod</li>
  <li><strong>Full RL</strong>: GRPO on GSM8K/SpellingBee with tool use</li>
  <li><strong><code class="language-plaintext highlighter-rouge">jax.lax.while_loop</code> sampler</strong>: Eliminate Python loop overhead for generation</li>
</ol>

<hr />

<p><em>Part of the 2026 Q1 TPU Research Sprint, supported by <a href="https://developers.google.com/programs">Google AI Developer Programs</a>.</em></p>

<p><em>Full code: <a href="https://github.com/tahabsn/flaxchat">github.com/tahabsn/flaxchat</a></em></p>]]></content><author><name>Taha Bouhsine</name></author><category term="jax" /><category term="flax" /><category term="training" /><category term="tinystories" /><category term="experiment" /><summary type="html"><![CDATA[We trained a GPT language model from scratch on the TinyStories dataset using flaxchat, our JAX/Flax NNX port of nanochat. The entire pipeline ran end-to-end on 2x NVIDIA T4 GPUs via Kaggle with data-parallel training, generating coherent children’s stories in under an hour.]]></summary></entry></feed>