<?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://hbfreed.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hbfreed.com/" rel="alternate" type="text/html" /><updated>2026-07-11T04:37:31+00:00</updated><id>https://hbfreed.com/feed.xml</id><title type="html">Henry Freed</title><subtitle>Henry Freed&apos;s projects and notes: mixture-of-experts research, model pruning and distillation, interpretability, GPUs, and the occasional loaf of bread.</subtitle><author><name></name></author><entry><title type="html">GPU Power Limits and Undervolting on Linux</title><link href="https://hbfreed.com/2026/03/12/power-limits-undervolting.html" rel="alternate" type="text/html" title="GPU Power Limits and Undervolting on Linux" /><published>2026-03-12T00:00:00+00:00</published><updated>2026-03-12T00:00:00+00:00</updated><id>https://hbfreed.com/2026/03/12/power-limits-undervolting</id><content type="html" xml:base="https://hbfreed.com/2026/03/12/power-limits-undervolting.html"><![CDATA[<p>This guide is based on <a href="https://shelbyjenkins.github.io/blog/power-limit-nvidia-linux/">this excellent post</a> by Shelby Jenkins, extended with clock locking and V/F curve offsets for undervolting.</p>

<h2 id="why-bother">Why bother?</h2>

<p>I have 3 3090s, and I’ve had them set to 280W basically as long as I’ve had them (I tried a bunch of different wattages, and that’s what I found was pareto optimal for my setup), and was happy with that. BUT! Ever since I moved a few months ago, I’ve been experiencing crashes (system reboots) when trying to use them all at the exact same time: torch.compile across all three gpus for DDP or vLLM warmups (which also torch.compiles), that kind of thing. Staggering compilation and vLLM start times were fine, and sustained 280W loads were fine. 3090s are well known for using a ton of power instantaneously, and today, I decided I’d had enough: it was time to try to fix this.</p>

<p>Shelby’s fantastic post shows us how to cap the <em>average</em> wattage that our gpus use, not going above that wattage. The average part is important: apparently the regulation is over a certain time interval, so instantaneously, GPUs can pull much more power than we set our limit to. 
Enter undervolting. 
I’m told that for undervolting, you “shift the voltage-frequency curve so the GPU runs at a lower voltage for the same clock speed” (from Claude). 
So, at a certain voltage, we can get the same performance. As we know, watts = volts * amps, so we can use fewer watts for the same clock speed. To keep my system from crashing, it turns out that I had to lock my clocks to 1850: anything above that would still crash my machine. This tells me that the problem was probably really a voltage issue (fact check me on this, I’m not sure at all!).</p>

<p>Remember that it’s super important to try a quick training run and monitor the loss and or gradient norms. After I got my setup stable, I had to lower the offset to make sure that I wasn’t getting NaNs. <a href="https://ieeexplore.ieee.org/document/7401681">Apparently this is because not enough voltage can make transistors not switch cleanly</a></p>

<p>I would’ve been happy to just have my system not crash every time I try to use them all at once. But it turns out that I’m actually getting a little more performance!</p>

<p>Here’s what my results look like on my 3090s:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Previous (280W power limit)</th>
      <th>Final (undervolted)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Power limit</td>
      <td>280W</td>
      <td>250W</td>
    </tr>
    <tr>
      <td>Clock</td>
      <td>unmanaged (boost to 2100)</td>
      <td>locked 210-1850 MHz</td>
    </tr>
    <tr>
      <td>V/F offset</td>
      <td>0</td>
      <td>+75 MHz</td>
    </tr>
    <tr>
      <td>FP16 TFLOPS</td>
      <td>~59.8</td>
      <td>~62</td>
    </tr>
    <tr>
      <td>Stability</td>
      <td>crashes during vLLM warmup</td>
      <td>stable</td>
    </tr>
  </tbody>
</table>

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

<p>This script handles three things:</p>
<ol>
  <li><strong>Power limits</strong> — caps each GPU’s power draw via <code class="language-plaintext highlighter-rouge">nvidia-smi</code></li>
  <li><strong>Clock locking</strong> — locks the GPU clock to a range, preventing it from boosting into high-voltage frequency bins</li>
  <li><strong>V/F curve offset</strong> — shifts the voltage-frequency curve via <code class="language-plaintext highlighter-rouge">pynvml</code>, so your target clock runs at the voltage normally used for a lower clock (this is the actual undervolting)</li>
</ol>

<p>Place it at <code class="language-plaintext highlighter-rouge">/usr/local/sbin/nv-power-limit.sh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="c"># Persistent NVIDIA GPU power limits, clock locking, and undervolting.</span>
<span class="c"># Intended to be run at boot via systemd.</span>
<span class="c">#</span>
<span class="c"># Based on: https://shelbyjenkins.github.io/blog/power-limit-nvidia-linux/</span>
<span class="c"># Extended with clock locking and V/F curve offsets for undervolting.</span>
<span class="c">#</span>
<span class="c"># HOW TO CONFIGURE:</span>
<span class="c">#   1. Set gpu_enabled — 1 for each GPU you want to manage, 0 to skip</span>
<span class="c">#   2. Set gpu_power_limits — desired power limit in watts per GPU</span>
<span class="c">#   3. Set LOCK_CLOCK_MIN/MAX — GPU clock range (prevents high-voltage boost bins)</span>
<span class="c">#   4. Set CLOCK_OFFSET — V/F curve shift in MHz (higher = more undervolt)</span>
<span class="c">#      e.g. +75 means 1850 MHz runs at the voltage normally used for ~1775 MHz</span>
<span class="c">#      Note that the values below are what worked for me on my machine to solve the problem I was having, your mileage will vary.</span>
<span class="c">#</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nb">command</span> <span class="nt">-v</span> nvidia-smi &amp;&gt;/dev/null <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="o">&gt;</span>&amp;2 <span class="s2">"nvidia-smi not found, exiting."</span><span class="p">;</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="o">}</span>

<span class="c"># ── Configuration ────────────────────────────────────────────────────</span>
<span class="c"># Enable/disable per GPU (0-indexed)</span>
<span class="nb">declare</span> <span class="nt">-A</span> <span class="nv">gpu_enabled</span><span class="o">=(</span>
    <span class="o">[</span>0]<span class="o">=</span>1
    <span class="o">[</span>1]<span class="o">=</span>1
    <span class="o">[</span>2]<span class="o">=</span>1
<span class="o">)</span>

<span class="c"># Power limits in watts per GPU</span>
<span class="nb">declare</span> <span class="nt">-A</span> <span class="nv">gpu_power_limits</span><span class="o">=(</span>
    <span class="o">[</span>0]<span class="o">=</span>250
    <span class="o">[</span>1]<span class="o">=</span>250
    <span class="o">[</span>2]<span class="o">=</span>250
<span class="o">)</span>

<span class="c"># Clock locking range in MHz (0 to disable)</span>
<span class="nv">LOCK_CLOCK_MIN</span><span class="o">=</span>210
<span class="nv">LOCK_CLOCK_MAX</span><span class="o">=</span>1850

<span class="c"># V/F curve clock offset in MHz (0 to disable)</span>
<span class="c"># Shifts the voltage-frequency curve so your locked clock runs at lower voltage.</span>
<span class="nv">CLOCK_OFFSET</span><span class="o">=</span>75
<span class="c"># ─────────────────────────────────────────────────────────────────────</span>

<span class="nb">echo</span> <span class="s2">"=== NVIDIA GPU Power &amp; Undervolt Setup ==="</span>

<span class="c"># Step 1: Set power limits</span>
<span class="k">for </span>gpu_id <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="p">!gpu_enabled[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    if</span> <span class="o">[[</span> <span class="k">${</span><span class="nv">gpu_enabled</span><span class="p">[</span><span class="nv">$gpu_id</span><span class="p">]</span><span class="k">}</span> <span class="nt">-ne</span> 1 <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"GPU </span><span class="nv">$gpu_id</span><span class="s2">: skipped (disabled)"</span>
        <span class="k">continue
    fi</span>

    /usr/bin/nvidia-smi <span class="nt">-i</span> <span class="s2">"</span><span class="nv">$gpu_id</span><span class="s2">"</span> <span class="nt">--persistence-mode</span><span class="o">=</span>1

    <span class="nv">max_pl</span><span class="o">=</span><span class="si">$(</span>/usr/bin/nvidia-smi <span class="nt">-i</span> <span class="s2">"</span><span class="nv">$gpu_id</span><span class="s2">"</span> <span class="nt">-q</span> <span class="nt">-d</span> POWER | <span class="nb">grep</span> <span class="s1">'Max Power Limit'</span> | <span class="nb">awk</span> <span class="s1">'{print $5}'</span> | <span class="nb">grep</span> <span class="nt">-oE</span> <span class="s1">'[0-9]+([.][0-9]+)?'</span><span class="si">)</span>
    <span class="k">if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$max_pl</span><span class="s2">"</span> <span class="o">||</span> <span class="s2">"</span><span class="nv">$max_pl</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"N/A"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"GPU </span><span class="nv">$gpu_id</span><span class="s2">: could not read max power limit, skipping"</span>
        <span class="k">continue
    fi

    </span><span class="nv">desired</span><span class="o">=</span><span class="k">${</span><span class="nv">gpu_power_limits</span><span class="p">[</span><span class="nv">$gpu_id</span><span class="p">]</span><span class="k">}</span>
    <span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$desired</span><span class="s2">"</span> <span class="nt">-le</span> <span class="si">$(</span><span class="nb">printf</span> <span class="s2">"%.0f"</span> <span class="s2">"</span><span class="nv">$max_pl</span><span class="s2">"</span><span class="si">)</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"GPU </span><span class="nv">$gpu_id</span><span class="s2">: power limit -&gt; </span><span class="k">${</span><span class="nv">desired</span><span class="k">}</span><span class="s2">W"</span>
        /usr/bin/nvidia-smi <span class="nt">-i</span> <span class="s2">"</span><span class="nv">$gpu_id</span><span class="s2">"</span> <span class="nt">--power-limit</span><span class="o">=</span><span class="s2">"</span><span class="nv">$desired</span><span class="s2">"</span>
    <span class="k">else
        </span><span class="nb">echo</span> <span class="s2">"GPU </span><span class="nv">$gpu_id</span><span class="s2">: ERROR desired </span><span class="k">${</span><span class="nv">desired</span><span class="k">}</span><span class="s2">W exceeds max </span><span class="k">${</span><span class="nv">max_pl</span><span class="k">}</span><span class="s2">W"</span>
    <span class="k">fi
done</span>

<span class="c"># Step 2: Lock GPU clocks (prevents boosting to high-voltage frequency bins)</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$LOCK_CLOCK_MAX</span><span class="s2">"</span> <span class="nt">-gt</span> 0 <span class="o">]]</span><span class="p">;</span> <span class="k">then
    for </span>gpu_id <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="p">!gpu_enabled[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
        if</span> <span class="o">[[</span> <span class="k">${</span><span class="nv">gpu_enabled</span><span class="p">[</span><span class="nv">$gpu_id</span><span class="p">]</span><span class="k">}</span> <span class="nt">-eq</span> 1 <span class="o">]]</span><span class="p">;</span> <span class="k">then
            </span><span class="nb">echo</span> <span class="s2">"GPU </span><span class="nv">$gpu_id</span><span class="s2">: clock lock -&gt; </span><span class="k">${</span><span class="nv">LOCK_CLOCK_MIN</span><span class="k">}</span><span class="s2">-</span><span class="k">${</span><span class="nv">LOCK_CLOCK_MAX</span><span class="k">}</span><span class="s2"> MHz"</span>
            /usr/bin/nvidia-smi <span class="nt">-i</span> <span class="s2">"</span><span class="nv">$gpu_id</span><span class="s2">"</span> <span class="nt">-lgc</span> <span class="s2">"</span><span class="nv">$LOCK_CLOCK_MIN</span><span class="s2">"</span>,<span class="s2">"</span><span class="nv">$LOCK_CLOCK_MAX</span><span class="s2">"</span>
        <span class="k">fi
    done
fi</span>

<span class="c"># Step 3: Apply V/F curve clock offset via NVML</span>
<span class="c"># This requires pynvml (pip install nvidia-ml-py) accessible to root.</span>
<span class="c"># Adjust the PYTHONPATH below if pynvml is installed in a user site-packages.</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$CLOCK_OFFSET</span><span class="s2">"</span> <span class="nt">-gt</span> 0 <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">enabled_ids</span><span class="o">=()</span>
    <span class="k">for </span>gpu_id <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="p">!gpu_enabled[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
        if</span> <span class="o">[[</span> <span class="k">${</span><span class="nv">gpu_enabled</span><span class="p">[</span><span class="nv">$gpu_id</span><span class="p">]</span><span class="k">}</span> <span class="nt">-eq</span> 1 <span class="o">]]</span><span class="p">;</span> <span class="k">then
            </span>enabled_ids+<span class="o">=(</span><span class="s2">"</span><span class="nv">$gpu_id</span><span class="s2">"</span><span class="o">)</span>
        <span class="k">fi
    done</span>

    <span class="c"># Try to find pynvml — check common locations</span>
    <span class="nv">PYNVML_PATHS</span><span class="o">=(</span>
        <span class="s2">"/usr/lib/python3/dist-packages"</span>
        <span class="s2">"/usr/local/lib/python3/dist-packages"</span>
    <span class="o">)</span>
    <span class="c"># Also check all user site-packages</span>
    <span class="k">for </span>d <span class="k">in</span> /home/<span class="k">*</span>/.[Ll]ocal/lib/python3<span class="k">*</span>/site-packages<span class="p">;</span> <span class="k">do</span>
        <span class="o">[[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$d</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> PYNVML_PATHS+<span class="o">=(</span><span class="s2">"</span><span class="nv">$d</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">done
    </span><span class="nv">EXTRA_PATH</span><span class="o">=</span><span class="si">$(</span><span class="nv">IFS</span><span class="o">=</span>:<span class="p">;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PYNVML_PATHS</span><span class="p">[*]</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span>

    <span class="nv">gpu_count</span><span class="o">=</span><span class="k">${#</span><span class="nv">enabled_ids</span><span class="p">[@]</span><span class="k">}</span>
    <span class="nv">PYTHONPATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$EXTRA_PATH</span><span class="s2">:</span><span class="k">${</span><span class="nv">PYTHONPATH</span><span class="k">:-}</span><span class="s2">"</span> /usr/bin/python3 <span class="nt">-c</span> <span class="s2">"
import pynvml, sys
pynvml.nvmlInit()
offset = </span><span class="nv">$CLOCK_OFFSET</span><span class="s2">
gpu_ids = [</span><span class="k">${</span><span class="nv">enabled_ids</span><span class="p">[*]// /,</span><span class="k">}</span><span class="s2">]
for i in gpu_ids:
    handle = pynvml.nvmlDeviceGetHandleByIndex(i)
    pynvml.nvmlDeviceSetGpcClkVfOffset(handle, offset)
    actual = pynvml.nvmlDeviceGetGpcClkVfOffset(handle)
    print(f'GPU {i}: clock offset -&gt; +{actual} MHz')
pynvml.nvmlShutdown()
"</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">"WARNING: clock offset failed (is pynvml installed? pip install nvidia-ml-py)"</span>
<span class="k">fi

</span><span class="nb">echo</span> <span class="s2">"=== Done ==="</span>
<span class="nb">exit </span>0
</code></pre></div></div>

<p>Make it executable:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo chmod </span>744 /usr/local/sbin/nv-power-limit.sh
</code></pre></div></div>

<h2 id="systemd-service">Systemd service</h2>

<p>Create <code class="language-plaintext highlighter-rouge">/usr/local/etc/systemd/nv-power-limit.service</code>:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Unit]</span>
<span class="py">Description</span><span class="p">=</span><span class="s">NVIDIA GPU power limit, clock lock, and undervolt</span>
<span class="py">After</span><span class="p">=</span><span class="s">syslog.target systemd-modules-load.service</span>
<span class="py">ConditionPathExists</span><span class="p">=</span><span class="s">/usr/bin/nvidia-smi</span>

<span class="nn">[Service]</span>
<span class="py">User</span><span class="p">=</span><span class="s">root</span>
<span class="py">ExecStart</span><span class="p">=</span><span class="s">/usr/local/sbin/nv-power-limit.sh</span>

<span class="nn">[Install]</span>
<span class="py">WantedBy</span><span class="p">=</span><span class="s">multi-user.target</span>
</code></pre></div></div>

<p>Then:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo chmod </span>644 /usr/local/etc/systemd/nv-power-limit.service
<span class="nb">sudo ln</span> <span class="nt">-s</span> /usr/local/etc/systemd/nv-power-limit.service /etc/systemd/system/
<span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl start nv-power-limit.service
<span class="nb">sudo </span>systemctl status nv-power-limit.service
<span class="c"># If it looks good:</span>
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>nv-power-limit.service
</code></pre></div></div>

<h2 id="finding-stable-values">Finding stable values</h2>
<p>The values in the script above (250W power limit, 1850 MHz max clock, +75 MHz offset) are specific to my setup. I’d imagine they’re a pretty good place to start, as (I think?) they’re pretty conservative, but you’ll need to find your own stable values.
You might just have <a href="https://claude.com/product/claude-code">your</a> <a href="https://openai.com/codex/">favorite</a> <a href="https://opencode.ai/">coding</a> <a href="https://mistral.ai/products/vibe">agent</a> write up a script for you to try little increments, logging each combination of these until you crash (DM or email me if you’d like mine). It seems reasonable to me to pick a wattage and go from there, but they’re your GPUs.</p>

<p>Start conservative and work your way up:</p>
<ol>
  <li>Set a power limit first and run your workload. Check temps and performance.</li>
  <li>Lock clocks to your GPU’s typical boost clock (check with <code class="language-plaintext highlighter-rouge">nvidia-smi dmon -s c</code>).</li>
  <li>Add clock offset in small increments (+50 MHz at a time). Run a stress test at each step (eg vLLM warmup/serving).</li>
</ol>

<p>If the system crashes under load, back off the offset. If it crashes at idle, your minimum clock might be too low for the offset you’re applying.</p>

<h2 id="modifying-settings">Modifying settings</h2>

<p>After editing the script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl restart nv-power-limit.service
<span class="nb">sudo </span>systemctl status nv-power-limit.service
</code></pre></div></div>]]></content><author><name></name></author><summary type="html"><![CDATA[How to power limit, lock clocks, and undervolt Nvidia GPUs on Linux with nvidia-smi, fixing transient power-spike crashes on a 3x 3090 rig.]]></summary></entry><entry><title type="html">Does the Teacher Matter?</title><link href="https://hbfreed.com/2026/02/12/does-the-teacher-matter.html" rel="alternate" type="text/html" title="Does the Teacher Matter?" /><published>2026-02-12T00:00:00+00:00</published><updated>2026-02-12T00:00:00+00:00</updated><id>https://hbfreed.com/2026/02/12/does-the-teacher-matter</id><content type="html" xml:base="https://hbfreed.com/2026/02/12/does-the-teacher-matter.html"><![CDATA[<p><em>Work in progress.</em></p>

<p>The plan is to pretrain a fully distilled NanoGPT-aloid using off-policy distillation, starting with OLMo 3 7B’s base, instruct, and thinking variants to see if post-training variant makes a difference, then testing quantized teachers, and finally moving on to larger, smarter models like OLMo 3 32B, Qwen, GLM 4.7 Flash, and GPT-OSS 120B with whatever setup wins. We’ll have to switch tokenizers along the way, but that seems like an ok trade-off.</p>

<h2 id="questions">Questions</h2>

<ol>
  <li>Does the teacher’s post-training variant matter (base vs instruct vs think)?</li>
  <li>Does quantizing the teacher matter?</li>
  <li>Does the teacher model and size matter?</li>
  <li>Does tokenizer mismatch matter?</li>
</ol>

<h2 id="model-setup-baseline">Model Setup, Baseline</h2>
<p>The model we’re using here, as alluded to, is a NanoGPT-aloid, based on the Olmo-3 architecture, just shrunk way down. I thought it’d be fun to use GQA too.
| Parameter | Value |                                                                                 <br />
|—|—|                                                                                             <br />
| <code class="language-plaintext highlighter-rouge">hidden_size</code> | 768 |                                                                               <br />
| <code class="language-plaintext highlighter-rouge">num_hidden_layers</code> | 12 |                                                                          <br />
| <code class="language-plaintext highlighter-rouge">num_attention_heads</code> | 6 (Q heads) |                                                               <br />
| <code class="language-plaintext highlighter-rouge">num_key_value_heads</code> | 2 (KV heads, 3:1 GQA ratio) |
| <code class="language-plaintext highlighter-rouge">head_dim</code> | 128 (768/6) |
| <code class="language-plaintext highlighter-rouge">intermediate_size</code> | 2048 (SwiGLU MLP) |
| <code class="language-plaintext highlighter-rouge">max_position_embeddings</code> | 2048 |
| <code class="language-plaintext highlighter-rouge">vocab_size</code> | 100,278 (OLMo tokenizer) |
| <code class="language-plaintext highlighter-rouge">tie_word_embeddings</code> | False |</p>

<p>That gets us to ~75M parameters in the transformer core and ~154M embedding parameters (hahaha), for a total of ~229M. To get a nice baseline, even though the model is dominated by the embedding parameters, we train on 5B tokens — a little over Chinchilla-optimal for the total parameter count, ignoring the fact that so many parameters come from the embeddings. That gets us down to a val BPB of 0.96437.</p>

<h2 id="experiment-plan">Experiment Plan</h2>

<table>
  <thead>
    <tr>
      <th>Phase</th>
      <th>Teacher</th>
      <th>Architecture</th>
      <th>Quant</th>
      <th>Tokenizer</th>
      <th>Testing</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>OLMo 3 7B Base</td>
      <td>Dense 7B</td>
      <td>bf16</td>
      <td>OLMo</td>
      <td>Post-training variant</td>
    </tr>
    <tr>
      <td>1</td>
      <td>OLMo 3 7B Instruct</td>
      <td>Dense 7B</td>
      <td>bf16</td>
      <td>OLMo</td>
      <td>Post-training variant</td>
    </tr>
    <tr>
      <td>1</td>
      <td>OLMo 3 7B Think</td>
      <td>Dense 7B</td>
      <td>bf16</td>
      <td>OLMo</td>
      <td>Post-training variant</td>
    </tr>
    <tr>
      <td>2</td>
      <td>OLMo 3 7B (phase 1 winner)</td>
      <td>Dense 7B</td>
      <td>4-bit</td>
      <td>OLMo</td>
      <td>Quantization</td>
    </tr>
    <tr>
      <td>3</td>
      <td>OLMo 3 32B</td>
      <td>Dense 32B</td>
      <td>best</td>
      <td>OLMo</td>
      <td>Bigger same-family</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Qwen 3 32B</td>
      <td>Dense 32B</td>
      <td>best</td>
      <td>Qwen</td>
      <td>Dense, different family</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Qwen 3 30B-A3B</td>
      <td>MoE 30B-A3B</td>
      <td>best</td>
      <td>Qwen</td>
      <td>Dense vs MoE (same family)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>GLM 4.7 Flash</td>
      <td>MoE 31B-A3B</td>
      <td>best</td>
      <td>GLM</td>
      <td>Strongest MoE</td>
    </tr>
    <tr>
      <td>3</td>
      <td>GPT-OSS 120B</td>
      <td>MoE (sparse)</td>
      <td>best</td>
      <td>GPT-OSS</td>
      <td>Massive scale</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Qwen base vs instruct</td>
      <td>—</td>
      <td>best</td>
      <td>Qwen</td>
      <td>Spot-check</td>
    </tr>
  </tbody>
</table>

<h2 id="todo">TODO</h2>
<ul>
  <li>Calculate teacher FLOPs per token for each model once we settle on token budget (chinchilla optimal for 125M NanoGPT is ~2.5B tokens, but distillation should need far fewer)</li>
</ul>

<h2 id="results">Results</h2>
<p>The baseline model is defined as follows:</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Distilling a tiny NanoGPT-style model from OLMo 3 7B variants to test whether the teacher's post-training, quantization, size, and tokenizer matter.]]></summary></entry><entry><title type="html">Variable FlexOlmo</title><link href="https://hbfreed.com/2026/01/28/variable-flexolmo.html" rel="alternate" type="text/html" title="Variable FlexOlmo" /><published>2026-01-28T00:00:00+00:00</published><updated>2026-01-28T00:00:00+00:00</updated><id>https://hbfreed.com/2026/01/28/variable-flexolmo</id><content type="html" xml:base="https://hbfreed.com/2026/01/28/variable-flexolmo.html"><![CDATA[<p><img src="/assets/images/variable-flexolmo/variable_flexolmo.jpg" alt="Variable FlexOlmo" width="1632" height="656" fetchpriority="high" /></p>

<p>I’ve been working on variable-sized experts in MoEs (<a href="https://hbfreed.com/2025/12/16/variable-size-experts.html">previous post</a>) using a modified version of <a href="https://github.com/hbfreed/megablocks-variable">Megablocks</a>. The TL;DR from that work: at my scale, I didn’t find efficiencies beyond what you’d get from simply using narrower experts across the board. But since I have this hammer, I’ve been looking for nails.</p>

<p>I’d had my eye on doing a project with Ai2’s <a href="https://arxiv.org/abs/2507.07024">FlexOlmo</a> for a while, and it seemed like a perfect nail. The core idea of FlexOlmo is to train specialized experts separately on their own domains, then combine them into an MoE<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. They got good results, and the architecture opens the door to data collaboration. Organizations can train experts on private data without surrendering it, then combine those experts into a more performant MoE model without leaking sensitive information. But training a 4.3B expert isn’t cheap, and in data constrained situations, it doesn’t necessarily make sense to train a model that large. If smaller experts work, that would lower the barrier to participation significantly.</p>

<p>Since I have limited resources, I figured the best way to do this was to prune the expert MLPs of one of the existing expert + public model models that Ai2 released, and use distillation to retrain the model, like these two NVIDIA papers (<a href="https://arxiv.org/abs/2407.14679">Muralidharan et al.</a>, <a href="https://arxiv.org/abs/2408.11796">Sreenivas et al.</a>), as opposed to training new experts from scratch.</p>

<p>So, I took Ai2’s released math expert, pruned its MLPs to various widths, and retrained with knowledge distillation to see how small the expert could get while still contributing to the combined model.</p>

<p>In ~228M tokens of retraining with KLD distillation, I’m happy with the results, and it is a solid proof of concept. Even pruning the expert down to ~800M parameters total improves the Math2 score from 8.1 to 29.1.</p>

<h2 id="pruning-the-math-expert-shrinking-the-expert-mlp-layers">Pruning the Math Expert: Shrinking the Expert MLP Layers</h2>

<p>I pruned the <a href="https://huggingface.co/allenai/Flex-math-2x7B-1T">Flex-math-2x7B-1T</a> math expert to three widths: 8192, 5504, and 2048 (down from 11008). Since the hidden size of the base model has to stay the same, and I didn’t want to touch the attention heads or number of layers, I shrank the expert MLP layers. One thing I’d like to try eventually: pruning different experts by different amounts within the same MoE, with sizing informed by per-expert importance scores rather than arbitrary uniform targets. Variable expert sizes make this straightforward.</p>

<h2 id="importance-analysis">Importance Analysis</h2>

<p>For the 2048 width model, I wanted to test how much the dataset used for importance analysis (the step where we decide which neurons to prune) matters. Not necessarily shocking, but it turns out quite a lot! I tried two datasets: a subset of the math data from <a href="https://huggingface.co/datasets/allenai/dolmino-mix-1124">dolmino-mix-1124</a><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>, and general data from the same dataset, which does include some math.</p>

<p><strong>58% of the top-2048 most important neurons are different</strong> between the two analyses. The early layers mostly agree on what’s important, but from layer 6 onward the rankings diverge.</p>

<p><img src="/assets/images/variable-flexolmo/importance_divergence.png" alt="Importance score divergence between math and general datasets" class="align-center" width="1800" height="675" loading="lazy" /></p>

<p>The model calibrated with math data also trained more effectively, achieving consistently lower training loss throughout. Validation loss followed the same pattern. Note that I did stop the general training run early, but it wasn’t going to catch up, and I wanted to move on to training the larger models.</p>

<p>Here are the loss curves of the math and general models, which show that the model calibrated with the math dataset is the clear winner.
<img src="/assets/images/variable-flexolmo/train_loss_comparison.png" alt="Train loss comparison between math and general importance analysis" class="align-center" width="1500" height="900" loading="lazy" /></p>

<h2 id="distillation">Distillation</h2>

<p>To be explicit: the teacher is the full-sized Flex-math-2x7B-1T; the student is the pruned model. These models were retrained using distillation with the top 128 logprobs generated by Flex-math-2x7B-1T using the GSM8k, Metamath-owmfilter, and TuluMath subsets of the <a href="https://huggingface.co/datasets/allenai/dolmino-mix-1124">DOLMino mix dataset</a> (the same dataset that FlexOlmo was trained with), about 620K total documents. Logprobs dataset <a href="https://huggingface.co/datasets/hbfreed/flexolmo-math-logprobs">here</a>.</p>

<h2 id="performance-vs-baseline">Performance vs Baseline</h2>

<h3 id="evals">Evals</h3>

<p>Using <a href="https://github.com/EleutherAI/lm-evaluation-harness">LM eval harness</a> (which handles base models well), we got pretty close to the paper’s baseline numbers. Math2 is the macro average of GSM8K and MATH<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Model</th>
      <th style="text-align: left">Total Params</th>
      <th style="text-align: left">Expert Params</th>
      <th style="text-align: left">Expert Width</th>
      <th style="text-align: left">GSM8K</th>
      <th style="text-align: left">MATH</th>
      <th style="text-align: left">Math2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Public model, no expert<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></td>
      <td style="text-align: left">7.3B</td>
      <td style="text-align: left">0</td>
      <td style="text-align: left">—</td>
      <td style="text-align: left">—</td>
      <td style="text-align: left">—</td>
      <td style="text-align: left">8.1</td>
    </tr>
    <tr>
      <td style="text-align: left">Flex-math-2x7B-1T (baseline)</td>
      <td style="text-align: left">11.6B</td>
      <td style="text-align: left">4.3B</td>
      <td style="text-align: left">11008 (100%)</td>
      <td style="text-align: left">69.7</td>
      <td style="text-align: left">35.4</td>
      <td style="text-align: left">52.5</td>
    </tr>
    <tr>
      <td style="text-align: left">flex-math-8192</td>
      <td style="text-align: left">10.5B</td>
      <td style="text-align: left">3.2B</td>
      <td style="text-align: left">8192 (74%)</td>
      <td style="text-align: left">70.1</td>
      <td style="text-align: left">31.3</td>
      <td style="text-align: left">50.7</td>
    </tr>
    <tr>
      <td style="text-align: left">flex-math-5504</td>
      <td style="text-align: left">9.5B</td>
      <td style="text-align: left">2.2B</td>
      <td style="text-align: left">5504 (50%)</td>
      <td style="text-align: left">66.6</td>
      <td style="text-align: left">26.8</td>
      <td style="text-align: left">46.7</td>
    </tr>
    <tr>
      <td style="text-align: left">flex-math-2048</td>
      <td style="text-align: left">8.1B</td>
      <td style="text-align: left">0.8B</td>
      <td style="text-align: left">2048 (19%)</td>
      <td style="text-align: left">44.3</td>
      <td style="text-align: left">13.9</td>
      <td style="text-align: left">29.1</td>
    </tr>
    <tr>
      <td style="text-align: left">flex-math-2048 (pruned only, no distillation)</td>
      <td style="text-align: left">8.1B</td>
      <td style="text-align: left">0.8B</td>
      <td style="text-align: left">2048 (19%)</td>
      <td style="text-align: left">13.1</td>
      <td style="text-align: left">3.3</td>
      <td style="text-align: left">8.2</td>
    </tr>
  </tbody>
</table>

<p>The 8192 model is juust about on par with the full-sized expert. Even the 2048 model (0.8B expert params) scores 3.6x the no-expert baseline. The half-sized expert (5504) is pretty competitive with its larger siblings.</p>

<p>It’s also worth noting how much distillation matters: the pruned-only 2048 model (no retraining at all) scores just 13.1% on GSM8K and 3.3% on MATH, barely above the no-expert baseline. Distillation recovers it from near-broken to 44.3% / 13.9%, a massive improvement for only ~228M tokens of training.</p>

<h2 id="takeaways">Takeaways</h2>

<p>I think this is a pretty good nail!</p>

<ul>
  <li>I’m particularly impressed with the 2048-width model. Adding just 800M parameters makes the model almost four times as good as the baseline model! I think that seems like very good bang for your buck, especially for training on so few tokens.</li>
  <li>Prune + distill is a good path to making smaller FlexOlmo models</li>
  <li>Choosing the importance analysis dataset wisely can make a pretty substantial difference in the overall performance of the model.</li>
  <li>Smaller experts could make the data collaboration vision of FlexOlmo more viable</li>
</ul>

<h2 id="tentative-recipe-for-training-new-flexolmo-experts-untested-for-now">Tentative Recipe for Training New FlexOlmo Experts (Untested… for now)</h2>

<p>Based on what worked here, I think the recipe for training new FlexOlmo experts would look something like:</p>

<ol>
  <li>Do importance analysis on the <a href="https://huggingface.co/allenai/Flex-public-7B-1T">Flex-public-7B-1T model</a> with the target dataset</li>
  <li>Prune the MLPs to the desired width</li>
  <li>Attach those to an untouched public model (as described in the FlexOlmo paper)</li>
  <li>Train only the expert’s MLP, either with regular old cross entropy loss on the dataset or, even better, using KLD distillation from a strong teacher model.</li>
</ol>

<h2 id="limitations">Limitations</h2>

<p>I only evaluated on math benchmarks (GSM8K and MATH). It’s possible that pruning the expert hurts general reasoning or other capabilities that I didn’t measure. Running BBH was going to take like 60 hours on my home system, so I figured I’d just punt and do these. Since we’re pruning a math expert and testing math performance, I think the evals here are the right ones, but broader evaluation would be nice. I also <em>really</em> want to know how FlexOlmo works with post-training. Can we mix a post-trained public model and an expert model with just continued pretraining? Or just post-train the expert model?</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>This glosses over a few details, but I think it’s an ok way to think about it. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>Using the following files from the dataset: <code class="language-plaintext highlighter-rouge">data/math/gsm8k/**/*.jsonl</code>, <code class="language-plaintext highlighter-rouge">data/math/metamath-owmfilter/**/*.jsonl</code>, <code class="language-plaintext highlighter-rouge">data/math/tulu_math/**/*.jsonl</code>. About a week later, I honestly don’t remember why I only chose those from the dataset. I remember trying to avoid code– MathCoder and a couple other parts of the math dataset are code-heavy, but I don’t remember why I avoided e.g., DolminoSynthMath. Bit of an oversight, but I think we still have meaningful results with a smaller dataset. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>I think it’s the macro average! I can’t find the exact definition anywhere, but the metrics I found line up with the paper well. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p>Scores reported from the <a href="https://arxiv.org/abs/2507.07024">FlexOlmo paper</a>, Table 1. This is the public-only model with no math expert attached. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[Pruning FlexOlmo's math expert down to ~800M parameters with knowledge distillation to show that smaller experts can still contribute to the combined MoE.]]></summary></entry><entry><title type="html">High Performance Whole Wheat Bread</title><link href="https://hbfreed.com/2026/01/22/high-performance-whole-wheat.html" rel="alternate" type="text/html" title="High Performance Whole Wheat Bread" /><published>2026-01-22T00:00:00+00:00</published><updated>2026-01-22T00:00:00+00:00</updated><id>https://hbfreed.com/2026/01/22/high-performance-whole-wheat</id><content type="html" xml:base="https://hbfreed.com/2026/01/22/high-performance-whole-wheat.html"><![CDATA[<h2 id="whole-wheat-bread-that-tastes-like-whole-wheat-bread-but-isnt-dense">Whole wheat bread that tastes like whole wheat bread, but isn’t dense</h2>
<p>Whole wheat bread gets a really bad rap. I think this is for two main reasons:</p>
<ul>
  <li>A lot of whole wheat flour is super old. The germ and bran get rancid, which tastes gross</li>
  <li>Bad texture. The bran, since it’s hard and sharp, pokes holes in the gluten, making the bread dense</li>
</ul>

<p>So, can we fix these problems? The first one is easy, just use great flour. Amazing mills are popping up all over the country, but I’ve used flour from <a href="https://cairnspring.com/">Cairnspring</a> and <a href="https://www.camascountrymill.com/">Camas Country Mill</a> and loved everything I’ve made with them. You can probably find a flour near you.</p>

<p>On the texture point, if we just treat the flour as flour, we’re probably not going to get the results we want. Flour is made up of three main things: the bran (outer layers of the wheat, <a href="https://www.wheatfoods.org/resources/wheat-facts/kernel-of-wheat/">makes up ~14.5% of the wheat kernel</a>), germ (embryo, ~2.5%), and endosperm (starchy part, what we think of as white flour, 83%).</p>

<p>The insight here is that if we treat the germ and bran (hereafter called <a href="https://www.merriam-webster.com/dictionary/offal">offal</a>) as we would with any other grain mix-in, we can get good texture AND pure whole wheat flavor.</p>

<p>The flour I’ll be using is Fortuna wheat from <a href="https://www.lopezclt.org/sustainable-agriculture/grain-csa/">Lopez Island</a>. It’s probably a little low on protein, but it tastes great.</p>

<h2 id="flour-makes-a-difference">Flour makes a difference!</h2>
<p>In late 2024, I ordered a bunch of really cool flours from Camas Country Mill, including this <a href="https://www.camascountrymill.com/shop/amarillo-hard-white-wheat-flour-organic">Amarillo flour</a>. I made some bread with it, and was pretty blown away with the color and flavor.<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> I hadn’t really thought about flour having distinct flavors until I tried these flours.</p>

<h3 id="72-just-soak-the-bran">7/2: Just soak the bran</h3>
<p>I haven’t been taking pictures, but I’ve been having great results just soaking the bran like we would with any other grain. The <a href="https://en.wikipedia.org/wiki/Major_Key_(album)">major key</a> here is just separating the bran from the flour, softening it, and incorporating it later. <em>shrug</em></p>

<h3 id="116-117-scalded-offal-15">1/16-1/17: Scalded offal, 15%</h3>
<p>I did the following:
Scald</p>
<ul>
  <li>135g wheat offal (15%)</li>
  <li>340g boiling water (38%) (2.5:1 ratio of water to bran)
Pour water over bran, stir until combined, cover, cool completely</li>
</ul>

<p>Dough</p>
<ul>
  <li>900g sifted Fortuna flour (sifted through 40 and 50 mesh sifters)</li>
  <li>630g water at 80-85°F (70%)</li>
  <li>200g leaven (22%)</li>
  <li>18g salt (2%)
1 hour autolyse, 3 hour bulk ferment, stretch and fold every 30 minutes. Scald added after an hour and a half.</li>
</ul>

<p>Shape, overnight proof in fridge.</p>

<p>I was really happy with this! Texture was great, flavor was great. No pictures this time.
Pressing on to higher percentages!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>The flavor (a fruity, almost guava kind of thing) was almost <em>too</em> strong. I’d recommend splitting it with a more mild flour for breads. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[Making 100% whole wheat bread that isn't dense: use fresh flour and treat the bran and germ as a mix-in rather than as flour.]]></summary></entry><entry><title type="html">Width, Depth, Latency, and You</title><link href="https://hbfreed.com/2026/01/15/width-depth-latency.html" rel="alternate" type="text/html" title="Width, Depth, Latency, and You" /><published>2026-01-15T00:00:00+00:00</published><updated>2026-01-15T00:00:00+00:00</updated><id>https://hbfreed.com/2026/01/15/width-depth-latency</id><content type="html" xml:base="https://hbfreed.com/2026/01/15/width-depth-latency.html"><![CDATA[<h2 id="a-quick-thursday-set-of-visualizations">A quick Thursday set of visualizations</h2>

<p>I’ve been working on pruning models <a href="https://hbfreed.com/2026/01/28/variable-flexolmo.html">a</a> <a href="https://hbfreed.com/2026/01/05/pruning-olmo7b.html">bit</a> lately, mainly based off of <a href="https://arxiv.org/abs/2407.14679">this Nvidia paper</a>. Mistral recently launched their <a href="https://huggingface.co/collections/mistralai/ministral-3">Ministral 3 models</a>, <a href="https://arxiv.org/pdf/2601.08584#section.1">which were pruned from Mistral Small 3.1</a>. In the Nvidia paper, they find that pruning width keeps performance better than pruning depth<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>.</p>

<p>What I think they don’t address enough is the fact that each additional layer adds latency. Since layers can’t be computed in parallel, latency increases substantially. Here are some plots illustrating just that. I focused on <a href="https://huggingface.co/PleIAs/Baguettotron">Baguettotron</a> as a comparison: at 80 layers with a hidden size of 576, it’s such an extreme aspect ratio that I thought it would make for a good test case. Life is about trade offs: for many edge use-cases, we’re probably concerned with the most capable model that fits on the device, not necessarily raw throughput. So, I had Claude Code whip up the comparison code and make some plots (I was really disappointed with the plots that it came up with, so I ended up telling it which plots to make).</p>

<p>All experiments were done on one 3090 with randomly initialized models. All the code is <a href="https://github.com/hbfreed/inference-pareto">here</a>, and was written by Claude Opus 4.5 in Claude Code.</p>

<p>For a more thorough study of these trade-offs, see Nvidia’s paper <a href="https://arxiv.org/abs/2511.18890">Nemotron-Flash: Towards Latency-Optimal Hybrid Small Language Models</a>.</p>

<h2 id="depth-vs-width-scaling">Depth vs Width Scaling</h2>
<p><img src="/assets/images/width-depth-latency/depth_vs_width_scaling.png" alt="Depth vs width scaling" width="1781" height="768" /></p>

<p>Token generation latency grows linearly with depth, but stays flat even at triple the width.</p>

<h2 id="parameters-vs-latency-scatter-plot">Parameters vs Latency Scatter Plot</h2>
<p><img src="/assets/images/width-depth-latency/params_vs_latency_scatter.png" alt="Parameters vs latency scatter" width="1393" height="1030" loading="lazy" /></p>

<p>This plot really shows us that for total response time (which is what we’re calling prefill + token generation), the number of layers matters much more than the number of parameters. Note that the largest model among the parameter matched configurations we tried, with 8 layers, has 487M parameters and is 11.7x faster than the 100 layer model, which has 322M parameters.</p>

<h2 id="time-matched-comparison">Time Matched Comparison</h2>
<p><img src="/assets/images/width-depth-latency/time_matched_clean.png" alt="Time matched comparison" width="1772" height="770" loading="lazy" /></p>

<p>Here, we see that even at maximum width (before running out of VRAM), shallower models are both faster and have dramatically more parameters. The dashed line shows the 80-layer baseline—none of the wider models could be slowed down enough to match it.
Since the 11.3B models were as large as I could fit on my 3090 (24 gigabytes of VRAM) in bf16, that’s where we maxed out. Clearly, there’s still a fair amount of headroom to make these larger while being faster.</p>

<h2 id="baguettotron-vs-gemma-3-12b">Baguettotron vs Gemma 3 12B</h2>
<p><img src="/assets/images/width-depth-latency/architecture_comparison_clean.png" alt="Baguettotron vs Gemma 3 12B comparison" width="1482" height="773" loading="lazy" /></p>

<p>Here’s a real-world aspect ratio comparison: Baguettotron vs Gemma 3 12B. We still see a pretty big difference!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>This is no surprise: it’s well known that, for the most part, deeper is better for performance. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[Visualizations of how transformer depth vs width affects inference latency on a 3090, and what that means for pruning decisions.]]></summary></entry><entry><title type="html">Pruning OLMo 3 7B</title><link href="https://hbfreed.com/2026/01/05/pruning-olmo7b.html" rel="alternate" type="text/html" title="Pruning OLMo 3 7B" /><published>2026-01-05T00:00:00+00:00</published><updated>2026-01-05T00:00:00+00:00</updated><id>https://hbfreed.com/2026/01/05/pruning-olmo7b</id><content type="html" xml:base="https://hbfreed.com/2026/01/05/pruning-olmo7b.html"><![CDATA[<p>(Work In Progress)</p>

<p>I want to write up my work as I’m working on it, and not all at once. In that spirit, before I start working on pruning the <a href="https://arxiv.org/abs/2507.07024">FlexOlmo</a> models to try using them with <a href="https://hbfreed.com/2025/12/16/variable-size-experts.html">variable sized experts</a>, I want to get a baseline of pruning models. I’m going to prune <a href="https://huggingface.co/allenai/Olmo-3-7B-Instruct">Olmo 3 7B</a>. 
I think I’ll just prune it down to 1/2 of it’s size. I’ll be implementing basically these two Nvidia papers (they basically follow the same process):</p>
<ul>
  <li><a href="https://arxiv.org/abs/2407.14679">Compact Language Models via Pruning and Knowledge Distillation</a></li>
  <li><a href="https://arxiv.org/abs/2408.11796">LLM Pruning and Distillation in Practice: The Minitron Approach</a></li>
</ul>

<h2 id="experiments">Experiments</h2>
<p>I’ll run a few experiments:</p>
<ul>
  <li>KL distillation vs on-policy distillation with reverse KL a la this <a href="https://thinkingmachines.ai/blog/on-policy-distillation/#loss-function-reverse-kl">Thinking Machines Post</a> basically: rollout with the student, forward the rollout through the teacher, matching the scores with reverse KL Loss.</li>
  <li>Quantized teacher models (8 bit, 4 bit, some weird <a href="https://github.com/ggml-org/ggml/blob/master/docs/gguf.md">GGUFs</a> like Q6?)</li>
  <li>Stronger teacher models (possibly quantized stronger teacher models?). The papers on pruning I’m going to be working with all use the same model they’re pruned from as teacher. The <a href="https://arxiv.org/abs/2601.08584">Ministral 3 paper</a> used Mistral Small 3.1 for the teacher model the whole time. It’d be nice (I think) to try an even stronger model than the parent as the teacher, seems like a no brainer? Can we use models that used different tokenizers? I think I saw somewhere that it’s doable, but can’t remember where.</li>
  <li>Olmo uses sliding window attention (SWA) as well as full attention. Will pruning the full attention layers lead to worse performance? Probably check RULER.</li>
  <li>LoRA or DoRA (should punt on this one for now, seems like something I should do later)</li>
</ul>

<p>Note that only pruning width seems to slightly defeat the purpose of making smaller models, in my opinion. Nvidia has released a paper on exactly this: <a href="https://arxiv.org/abs/2511.18890">Nemotron-Flash: Towards Latency-Optimal Hybrid Small Language Models</a>: “While previous work on SLM design has primarily focused on reducing the number of parameters to achieve parameter-optimal SLMs, parameter efficiency does not necessarily translate into proportional real-device speed-ups…we first study latency-optimal depth-width ratios, with the key finding that although deep-thin models generally achieve better accuracy under the same parameter budget, they may not lie on the accuracy-latency trade-off frontier.”
Here’s a <a href="https://hbfreed.com/2026/01/15/width-depth-latency.html">quick study</a> comparing latencies across different aspect ratios.</p>

<h2 id="results-so-far">Results (So Far)</h2>
<p>Taking a break on this while I work on <a href="https://hbfreed.com/2026/01/28/variable-flexolmo.html">Variable FlexOlmo</a>. So far, I’ve pruned and distilled <a href="https://huggingface.co/hbfreed/pruned_olmo3_4096_16_29_distilled">one version</a> of Olmo-3-7B Instruct, my smallest version, about 3.5B parameters. On ~500M tokens (one epoch through our dataset), we got the following performance on 50 questions of GSM8K:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>MGSM</th>
      <th>Output Tokens</th>
      <th>Time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Teacher (7B)</td>
      <td>90%</td>
      <td>13K</td>
      <td>47s</td>
    </tr>
    <tr>
      <td>Pruned + Distilled</td>
      <td>8%</td>
      <td>214K</td>
      <td>8:48</td>
    </tr>
    <tr>
      <td>Pruned only</td>
      <td>0%</td>
      <td>256K</td>
      <td>10:23</td>
    </tr>
  </tbody>
</table>

<p>So, we’re not making a SOTA 3.5B model here. But in only 500M tokens, we do legitimately recover some performance. I also want to dive a little deeper into just what happens when we do prune. It’s interesting to me that the stop token seems nowhere to be found for our pruned models. One only pruned model, which was just barely pruned, kept repeating “grammar” consistently across eval runs. 22 out of 50 samples in that run collapsed into “grammar grammar grammar…” until it reached the token limit. I really want to look into this: what behavior do we see when we prune different parts of the model? Might be a pattern there. Might not.</p>

<p>For now, all the <a href="https://github.com/hbfreed/pare">code’s on github</a>, and all the <a href="https://huggingface.co/hbfreed/pruned_olmo3_4096_16_29_distilled">checkpoints</a> and <a href="https://huggingface.co/datasets/hbfreed/Dolci-Instruct-RL-Completions">dataset</a> are on Hugging Face.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Pruning OLMo 3 7B to half its size and retraining with knowledge distillation, following Nvidia's Minitron-style recipe.]]></summary></entry><entry><title type="html">Variable Sized MoEs</title><link href="https://hbfreed.com/2025/12/16/variable-size-experts.html" rel="alternate" type="text/html" title="Variable Sized MoEs" /><published>2025-12-16T00:00:00+00:00</published><updated>2025-12-16T00:00:00+00:00</updated><id>https://hbfreed.com/2025/12/16/variable-size-experts</id><content type="html" xml:base="https://hbfreed.com/2025/12/16/variable-size-experts.html"><![CDATA[<h2 id="acknowledgements">Acknowledgements</h2>

<p>Thanks to:</p>
<ul>
  <li><a href="https://x.com/SPLehman">Sam Lehman</a> for the conversations that got this project off the ground as well as reading drafts and feedback</li>
  <li><a href="https://www.linkedin.com/in/craigbrobinson/">Craig Robinson</a> for feedback on drafts</li>
  <li><a href="https://karpathy.ai/">Andrej Karpathy</a> for NanoGPT, which this project is based on</li>
  <li><a href="https://arxiv.org/abs/2211.15841">Trevor Gale, Deepak Narayanan, Cliff Young, and Matei Zaharia</a> for MegaBlocks, the other foundation for this project</li>
</ul>

<h2 id="extending-megablocks-with-variable-sized-moes">Extending Megablocks with Variable Sized MoEs</h2>

<p>I implemented variable sized experts using Andrej Karpathy’s nanoGPT, which allows us to set the sizes of experts in a mixture of experts (MoE) model. I trained a bunch of variable sized expert MoEs averaging around 125 million active parameters on a <a href="https://arxiv.org/abs/2203.15556">chinchilla-optimal</a> ~2.5B tokens. I expected tokens to route based on computational difficulty (something like difficult reasoning to large experts, simple concepts to small ones). It turns out that tokens in constrained contexts like code or recipes route to small experts, and more ambiguous function words like ‘ with’ and ‘ to’ route to larger ones. My interpretation is that large experts handle tokens that need more context to interpret, while small experts handle words with specific meanings. <a href="https://hbfreed.com/assets/visualizations/moe-routing-viz.html">Check out the visualization of where different tokens go here</a>, and <a href="https://github.com/hbfreed/nanoMOE">code here</a>!
<a href="https://hbfreed.com/assets/visualizations/moe-routing-viz.html"><img src="/assets/images/variable_experts_viz.png" alt="Token routing visualization showing which tokens route to large vs small experts" width="1972" height="1100" loading="lazy" /></a></p>

<h2 id="stage-0-moe-background">Stage 0: MoE Background</h2>

<p>Dense LLMs have a feedforward network (FFN)<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> that every token passes through entirely. MoEs replace this with a much larger layer broken into pieces called “experts”. In an MoE, each token only activates a few experts. This way, MoEs can be more capable, while processing at the same speed.</p>

<p><a href="https://arxiv.org/pdf/2409.02060#page=4"><img src="/assets/images/olmoe.png" alt="OLMoE architecture diagram" width="1340" height="794" loading="lazy" /></a><br />
Historically, MoEs have only been made up of uniform experts of equal size. Here, I explore what happens when we vary the size of the experts. For this project, the ratio (5:1, 23:1) is the size difference between large and small experts. A 23:1 model’s large experts are 23 times the size of its small experts. The two models I trained have the following attributes:</p>

<p><strong>5:1 configuration:</strong> Four experts at 2560 hidden dim, four at 512. 2 experts are active<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>. Empirically, active parameters range from 95.7M to 138.7M and we average 114.7M.</p>

<p><strong>23:1 configuration:</strong> Four experts at 2944 hidden dim, four at 128 from 91.5M to 114.5M, 101.9M on average.</p>

<p><em>Record scratch</em></p>

<p>But first let’s talk about how I got myself into this situation.</p>

<h2 id="stage-1-vanilla-moes">Stage 1: Vanilla MoEs</h2>

<p>I started this escapade with a simple goal: add efficient<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> MoE support to <a href="https://github.com/karpathy/nanoGPT">Karpathy’s nanoGPT</a>. I found all the regular things that we’d expect to find with MoEs: they perform better, and for the most part, the sparser, the better. The best performing regular MoE was 64 total, with 8 active (<a href="https://wandb.ai/hbfreed/gpt2-chinchilla">gpt2 wandb</a>, <a href="https://wandb.ai/hbfreed/moe-wikitext">wikitext wandb</a>). It reached a final loss of 3.127 on <a href="https://huggingface.co/datasets/Skylion007/openwebtext">Openwebtext</a> on a little over chinchilla-optimal 3 billion tokens, compared to 3.285 for the dense model. The 64 total, 8 active model beat the dense model’s loss in 70% of the steps. However, due to (I think) memory overhead, the training run took twice as long (20m for dense vs 40m for the 64x8 MoE). 70% of 40 minutes is 28 minutes, so even if we adjust for hitting the same loss, the MoE doesn’t train as fast at this small scale<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>. In the <a href="https://arxiv.org/pdf/2409.02060#subsubsection.4.1.1">OLMoE paper</a>, the AI2 team got to the same loss 2x faster compared to the dense model they trained.</p>

<p>This MoE mission opened up a whole, unexpected, gigantic can of worms.</p>

<h2 id="stage-2-variable-sized-experts">Stage 2: Variable sized experts</h2>

<p>Part of the quest of not using for loops led me to MegaBlocks (<a href="http://github.com/databricks/megablocks">github</a> and <a href="https://arxiv.org/pdf/2211.15841">paper</a>). The main MegaBlocks innovation is that we can break matrix multiplications up into blocks of 128, allowing us to avoid dropping tokens when experts get too many, and use less padding when experts get too few. They find that <a href="https://arxiv.org/pdf/2211.15841#page=3">dropping tokens leads to substantially worse performance</a>. The paper goes on to say that “we could also relax the constraint on the number of columns in each block to build MoE layers with variable sized experts”. This, along with conversations with my friend <a href="https://x.com/SPLehman">Sam</a>, and two Dwarkesh Patel interviews (<a href="https://open.substack.com/pub/dwarkesh/p/jeff-dean-and-noam-shazeer?selection=ed0561c0-c4cd-4060-b224-e90d8747076c&amp;utm_campaign=post-share-selection&amp;utm_medium=web&amp;aspectRatio=instagram&amp;textColor=%23ffffff&amp;bgImage=true">Jeff Dean and Noam Shazeer</a>, <a href="https://open.substack.com/pub/dwarkesh/p/sholto-trenton-2?selection=f44d236e-303e-4603-a208-4ec28d034cef&amp;utm_campaign=post-share-selection&amp;utm_medium=web&amp;aspectRatio=instagram&amp;textColor=%23ffffff&amp;bgImage=true">Sholto Douglas and Trenton Bricken</a>) led me down this rabbit hole of variable sized experts (By no means am I saying that this is exactly what Jeff or Sholto had in mind). Notably, Meituan’s <a href="https://huggingface.co/meituan-longcat/LongCat-Flash-Chat">LongCat</a> explores this by using identity experts that route some tokens through zero computation experts. Here, I look at the continuous case: experts of different sizes rather than on/off. <br />
It’s clear that not all tokens are equally hard to predict. So then, why do MoEs have uniformly sized experts? If we can allocate more FLOPs to the harder tokens (whatever that ends up meaning), then maybe we can get a more efficient model<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>. Or maybe we can learn something about what “harder” even means to a model.</p>

<p>So, I started working on this concept, only tweaking MegaBlocks slightly. The fork of MegaBlocks <a href="https://github.com/hbfreed/megablocks-variable">is on my GitHub</a>.  I started training some models with variable expert sizes, at first only using a traditional load balancing loss and a router z loss. With a load balancing loss as low as we used for the vanilla MoEs, tokens routed to large experts disproportionately, just as you might think. So, I tried turning up the load balancing loss to get better balance. The best performing vanilla load balancing loss was with an lbl weight of 0.1, a factor of 10 higher than I found was optimal for the vanilla MoEs (<a href="https://wandb.ai/hbfreed/wikitext-lbl-sweep">wandb</a>). Some great learnings came from that, but these models were well balanced between all the experts, meaning that they routed 50% of tokens to the large experts, and 50% to small. Since I was having the large and the small experts add up to the same intermediate size as the vanilla MoEs, this just averaged the same size of experts as the vanilla MoEs. It performed ever so slightly worse, training in about the same amount of time: not so exciting. We’d need a loss term that lets the model be more free to choose whichever experts it wants.</p>

<h2 id="stage-3a-compute-balancing-loss">Stage 3a: Compute balancing loss</h2>

<p>This is very similar to our normal <a href="https://arxiv.org/pdf/2409.02060v1#subsubsection.4.1.6">load balancing loss</a>. For this, we:</p>

<ol>
  <li>Normalize the expert sizes by dividing the expert sizes by the average size of the experts</li>
  <li>Compute the weighted sum of the router probabilities and the normalized expert sizes (this is a dot product between the router probs and the normalized expert sizes)</li>
  <li>Take the average of that!</li>
</ol>

<p>This setup did allow the model to learn to allocate different size experts to different tokens. Both the 5:1 and 23:1 models ended up at around 70% small and 30% large experts. After training, there were still load imbalances within groups of experts, so we needed to add slightly modified load balancing loss back in.</p>

<h2 id="stage-3b-group-load-balancing-loss">Stage 3b: Group load balancing loss</h2>

<p>This is pretty simple: we just divide up the expert groups, and use the same load balancing loss that we used above and got from OLMoE. This new scheme balanced well at weight 0.01.</p>

<p>So now, I’d done it! We have variable sized MoEs and can train a bunch of size ratios.</p>

<h2 id="stage-4-training-a-bunch-of-these-models">Stage 4: Training a bunch of these models</h2>

<p>I started on a smaller model size to <a href="https://wandb.ai/hbfreed/wikitext-size-ratio-sweep?nw=nwuserhbfreed">sweep over the size ratios</a>, training roughly 50M active models on <a href="https://huggingface.co/datasets/Salesforce/wikitext">Wikitext</a>. The most promising runs were at 4:1, 6:1, and 19:1 (large:small). The 19:1 used the smallest experts MegaBlocks allows: 128 hidden dim. The 19:1 model finished training in 84% of the time with only 3.2% higher loss. The 4:1 finished in 91% of the time with 0.96% higher loss; the 6:1 in 89% with 1.4% higher.</p>

<p>So, with the hypothesis that something around 4:1 or 6:1 would be the optimal “larger” ratio and the extreme “smaller” ~19:1 ratio in mind, I went up to GPT-2’s size of about 125 million active parameters. I stuck with openwebtext as a fair comparison to the original models I trained. I trained the final models with a load balancing loss weight of 0.08, and a compute loss weight of 0.004<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>. I found very similar results across the board: the extreme ratio (this time 23:1, again with small experts of size 128) was much faster to train, this time with a 20% speedup and a 2.5% loss degradation. The average size of experts in evaluation was 861. The more moderate 5:1 ratio was 10% faster, with 0.5% loss degradation, with an average size of 1077.</p>

<p>One ablation I ran was with consistently sized experts, but much smaller than 1536. I chose 896 because it was the closest multiple of 128 to our 861 average size. Bluntly, this run was disappointing to me: it beat all but the 5:1 ratio on loss, and finished training faster than the 23:1 ratio runs, though they were within spitting distance of each other (Update: a couple weeks later after going over my routing layer by layer, I noticed that the first layer was extremely imbalanced within size groups. Fixing this for 23:1 runs actually ended up matching the loss and speed. I think the takeaway is still the same though). This shows us that the active MLP width need not be 4*hidden size. We already see this in most of the best-performing open source MoE models: <a href="https://huggingface.co/deepseek-ai/DeepSeek-V3.1/blob/main/config.json">Deepseek V3</a> and <a href="https://huggingface.co/moonshotai/Kimi-K2-Thinking/blob/main/config.json">Kimi K2</a> (which both use Deepseek’s basic architecture) use experts that add up to an expansion factor of ~2.57, and <a href="https://huggingface.co/zai-org/GLM-4.6/blob/main/config.json">GLM-4.6</a> uses 2.7. (<a href="https://huggingface.co/MiniMaxAI/MiniMax-M2/blob/main/config.json">Minimax M2</a>, <a href="https://huggingface.co/openai/gpt-oss-120b/blob/main/config.json">gpt-oss</a> and <a href="https://huggingface.co/allenai/OLMoE-1B-7B-0924-Instruct/blob/main/config.json">OLMoE</a> all use 4x).</p>

<p>Efficiency aside, this was the real question I wanted to answer: do the models learn meaningful routing patterns?</p>

<h2 id="stage-5-contextual-ambiguity-routing-analysis-domain-specialization-and-syntactic-specialization">Stage 5: contextual ambiguity, routing analysis, domain specialization, and syntactic specialization</h2>

<p>This was what I was most excited about: do experts actually specialize? There have been conflicting reports. The <a href="https://arxiv.org/pdf/1701.06538">original MoE paper</a> uses LSTMs and tons and tons of experts (up to 131,072!), and <a href="https://arxiv.org/pdf/1701.06538#table.caption.50">shows clear specialization</a>. Then, the ST-MoE paper shows that the <a href="https://arxiv.org/pdf/2202.08906#section.7">encoder specializes</a>, but “expert specialization is far less noticeable in the decoder” (recall that most of the generative models that we think of are decoder only). The <a href="https://arxiv.org/pdf/2401.04088#section.5">Mixtral paper</a> shows no <em>domain</em> specialization at all, but does find syntactic specialization<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup>. OLMoE shows that certain tokens, <a href="https://arxiv.org/pdf/2409.02060v1#subsection.5.3">like those from arxiv, consistently route to certain experts</a>. Finally, <a href="https://arxiv.org/pdf/2401.06066#subsection.4.5\">Deepseek MoE</a>) makes the claim that their MoE performs better than GShard due to more expert specialization. Their focus is mostly on redundancy across experts, not individual token routing analysis or domain specialization. All of this is <em>ok</em> evidence that there probably is some kind of specialization going on with MoEs, but not conclusive at all. My holy grail would be to one day figure out that specialization and show that if we turn off certain expert combinations across layers (say, if there are experts that get lots of code tokens), performance tanks. Additionally, if we isolated those experts into a dense model, would we be able to keep the performance in that area? To me, this would be the clearest evidence that the experts do specialize. I don’t achieve this here.</p>

<p>The 23:1 and 5:1 models learn pretty different routing schemes. For starters, the 5:1 ratio has a 0.295 spearman correlation<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup> between the token count in the dataset and the average size of the expert. On the other hand, for the 23:1 ratio, we see a <em>negative</em> correlation:  -0.222. So, I’ll break them into two sections. I expected that because of the extreme difference in experts, we’d see a clearer pattern. That doesn’t seem to be true. The 5:1 model’s highest vs lowest expert sizes is actually larger than the 23:1 model’s (the delta is 2332 for 5:1 and 1252 for 23:1). The average size of the experts and entropy for the 5:1 ratio are not at all spearman correlated (literally 0.00), and the 23:1 is weakly negatively correlated (-0.150). So what are they even learning?</p>

<p>I think the models I’ve trained here learn the contexts that they are in: when they are in a constrained context like programming, a recipe, or finishing a common word, they tend to use smaller experts. When the context is more ambiguous, they use larger experts.</p>

<p>If we look at different counts of tokens, we see that the two ratios learn different patterns by frequencies. When we get to more frequently occurring tokens, there is higher correlation between the two mean sizes, meaning that there is some (small) relationship between the sizes that the two learn (see the table below). So they don’t learn totally orthogonal patterns, but they definitely learn different ones. For example, “ing” words go to large experts for 5:1 and small for 23:1. Let’s take a closer look at the patterns.</p>

<table>
  <thead>
    <tr>
      <th>Token Frequency Range</th>
      <th>Number of Samples (n)</th>
      <th>Spearman Correlation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>(0, 10]</td>
      <td>20,975</td>
      <td>-0.0740</td>
    </tr>
    <tr>
      <td>(10, 50]</td>
      <td>18,218</td>
      <td>-0.0840</td>
    </tr>
    <tr>
      <td>(50, 100]</td>
      <td>3,825</td>
      <td>-0.0209</td>
    </tr>
    <tr>
      <td>(100, 500]</td>
      <td>3,657</td>
      <td>0.0853</td>
    </tr>
    <tr>
      <td>(500, 1000]</td>
      <td>514</td>
      <td>0.1132</td>
    </tr>
    <tr>
      <td>(1000, 5000]</td>
      <td>318</td>
      <td>0.2874</td>
    </tr>
    <tr>
      <td>5000+</td>
      <td>77</td>
      <td>0.1257</td>
    </tr>
  </tbody>
</table>

<h3 id="51">5:1</h3>

<p>We see a pretty clear pattern with the 5:1 model, especially on the low average expert size end.<br />
Technical tokens like “ goto”, “ perl”, and “ println” are all at the low end of the compute spectrum. There are the second halves of somewhat unique words like “oenix”, “ibrary”, and ”chnology”. We also see “ tbsp”, “ teaspoons”, and “ tablespoons”, as well as “ lol” and “ haha”. Additionally, we see <a href="https://en.wikipedia.org/wiki/Discourse_marker">discourse markers</a> like “Furthermore”, “Moreover”, and “Similarly”. These are all capitalized! When we look at the lowercase versions of these tokens, we see that they actually use larger experts. Most extremely, “ similarly” is actually in the top 2400 out of around 50 thousand. It’s particularly syntactically ambiguous: there are a bunch of different ways to use the word. When it’s at the start of the sentence, chances are very good that it’ll be referring to the previous sentence.</p>

<p>On the other end of the scale, the tokens that use the largest experts are the token ids &lt;564&gt; and &lt;447&gt;. These are the only tokens that use larger experts on average than our baseline<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup>.</p>

<p>Now, if we try to decode these tokens just in the python interpreter, they don’t print correctly. That’s because they are both incomplete byte strings– some token comes after them. So, decoding them as bytes, we see that 447 is ‘e280’, and 564 is ‘20e280’. It turns out that ‘20’ is just a space, so 564 is basically ‘ e280’. If we dig deeper, we see that e2 80 is the beginning of 64 different types of punctuation including “—” (em dash),  “‡” (double dagger), and my favorite, “‽” (interrobang) (see full list <a href="https://www.utf8-chartable.de/unicode-utf8-table.pl?start=8192">here</a>), along with a bunch of different spaces. Another fascinating thing about these tokens is that they get down to a very low entropy. Since there are only 64 options that can follow the ‘e280’ bytes or else there will be a catastrophic failure, the model seems to learn that it only really has 64 options, leading to the low entropy. Then, it needs big experts to decide which of the 64 possible tokens come next, since there’s a lot of ambiguity for which symbol should come next. Also appearing on the high side: function words like “ with”, “ to”, and “ in”.</p>

<p>What I think is going on: these models are routing tokens based on contextual constraint—when what comes next is predictable, they use less compute. Within the low compute regimes, the context is very constrained. For example, with the technical words, there are only so many things that can come after “ println”, or with “ tbsp”, we’re almost certainly in a recipe setting. Lastly, with the discourse markers, I think they kind of ‘tell you what’s coming’: another, similar point.</p>

<h3 id="231">23:1</h3>

<p>As I said above, this model’s routing patterns are pretty different. It’s slightly negatively correlated with token occurrences in the dataset, and again very slightly negatively correlated with the output entropies (-0.15). The two aren’t correlated with each other. There are patterns at the low end: we see lots of names and other proper nouns “ Abraham”, “Stephen”, “ Jon”/“John”, “ Obama”. There are some code-related tokens as well like “ !=”, “ ();”, and “ ()”, and a lot of numbers. At the high end, it’s a lot more of a grab bag, but we do see some similar word endings and whole words (though I feel like I’m trying to cram my previous observations onto this). We do see token 564 show up again, and 447 is in the upper quartile.</p>

<h3 id="domain-specialization">Domain Specialization</h3>

<p>Joining the two ratios back together, I tested domain specialization by running both models on different domains from the <a href="https://huggingface.co/datasets/allenai/dolma3_pool">Dolma 3 pool</a>. The only meaningful domain effect for this setup is code.</p>

<p>For the 5:1 model, code’s weighted average expert size is 2244, which is 210 smaller than the 2454 size observed across all other domains. The 23:1 model is even more extreme: code drops to 1499 vs 2131 for other domains, a gap of 632. This is 14.3 standard deviations below the cross-domain mean! When given more dynamic range, the model leans harder into treating code as low-compute. Code’s maximum expert size is 2958 (5:1) and 2916 (23:1), while natural language domains reach 3697-3755. Of 690 tokens appearing across all domains with count &gt;100, 518 route <em>lower</em> in code and only 165 route higher (the remaining 7 were the same). Tokens like “ is”, “ in”, and “ return” (all programming keywords) drop 250-450 in code. But the pattern flips for subword fragments: short tokens like ‘ker’, ‘ame’, and single characters route <em>higher</em> in code, since they could be part of any identifier.</p>

<p>The other domains cluster together. Politics and crime &amp; law route slightly higher (+30 and +26 vs the cross-domain mean), sci_math_tech slightly lower (-31), but this ~60 point spread is noise compared to code’s ~600 point gap in the 23:1 model.</p>

<h2 id="wrapping-up-open-questions">Wrapping up, Open Questions</h2>

<p>At this scale, we don’t see any efficiency gains other than what you’d get from only using smaller experts. Routing is consistent across seeds: function words go to big experts, and code goes to smaller ones. I expected “difficult” tokens (code, math, technical terms) to get more compute; that’s not what happens in my experiments.</p>

<p>Open questions and things I want to try next:</p>

<p>1) I haven’t yet trained many models with better-performing expert setups than 8x2 (eg 64x8). The size differences with more experts were less extreme, so I thought 8x2 would be more revealing to study.<br />
2) Imbalanced expert counts, shared experts. Deepseek V3 and other models based on its architecture, like Kimi K2, use a shared expert, one expert that’s always active. Could we use one large expert as the shared expert, and the rest small experts, or something like this idea? Is there an optimal number of experts of each size (something like, without loss of generality, 2 large, 6 small)? Could we do small, medium, and large?<br />
3) <a href="https://arxiv.org/abs/2507.07024">FlexOlmo</a> worked on the concept of training dense models separately and then stitching them together – could we use these variable sized experts to have smaller specialized experts with a larger, more general model as the backbone?<br />
4) Beat the baseline by tuning the compute balancing loss coefficient<br />
5) How’s this stuff interact with attention scores? Especially with the discourse markers thing. Perhaps a “Moreover” token is attending to the last sentence quite a lot and this lines up with the lack of compute?<br />
6) Intermediate ratio analysis– is there a crossover point somewhere between the 5:1 and 23:1 where we see the correlations flip?<br />
7) This last weekend (Dec 13/14 2025) I tried subbing in a sigmoid instead of softmax for routing, like they do in DeepSeek-V3. It learned more like an 80:20 small:large ratio. I haven’t had a chance to look at the routing. Will everything we saw above hold?<br />
8) <a href="https://arxiv.org/abs/1511.06297">RL’s been used for gating</a>– obviously that hasn’t stuck around. Would it help us allocate our compute better in some way?</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>Also called the multilayer perceptron (MLP)—the terms are interchangeable. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>This setup does not perform as well as much sparser setups like we see with every MoE right now. I chose this because it offered the largest difference between the large and small experts, and at this small scale, allowed for faster training due to less overhead. My focus here was learning about what kinds of patterns the models learn in this variable context, not making the absolute best model. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>A lot of implementations (including <a href="https://github.com/huggingface/transformers/blob/main/src/transformers/models/olmoe/modeling_olmoe.py#L325">huggingface’s</a>!) loop over the experts instead of parallelizing them, which is very inefficient. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p>I did compare the MegaBlocks version with a naive for loop version and the MegaBlocks version does train faster. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p>The models I trained aren’t. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p>At first, I was using my standard lbl weight of 0.01, but the first layer was super imbalanced. <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p>OLMoE hypothesizes that this is because Mixtral was initialized from the dense 7b Mistral model <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p>all of the correlations I report below are spearman as well, since the counts of tokens are all over the place <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p>We’d probably have better performance if we somehow figured out how to get more experts on average. Our compute balancing loss coefficient is probably too high, those experiments are coming soon. <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[Testing whether variable-sized experts in Mixture of Experts models beat uniform experts, using a modified version of Megablocks.]]></summary></entry><entry><title type="html">Mixture of Experts</title><link href="https://hbfreed.com/2025/07/14/mixture-of-experts.html" rel="alternate" type="text/html" title="Mixture of Experts" /><published>2025-07-14T00:00:00+00:00</published><updated>2025-07-14T00:00:00+00:00</updated><id>https://hbfreed.com/2025/07/14/mixture-of-experts</id><content type="html" xml:base="https://hbfreed.com/2025/07/14/mixture-of-experts.html"><![CDATA[<p>(Work In Progress)</p>

<h2 id="adding-mixture-of-experts-support-to-karpathys-nanogpt-moe-interpretability-general-moe-notes">Adding Mixture of Experts support to <a href="https://x.com/karpathy">Karpathy’s</a> <a href="https://github.com/karpathy/nanoGPT">NanoGPT</a>, MoE interpretability, general MoE Notes</h2>
<h3 id="in-progress-july-14-2025-">In progress: July 14, 2025-?</h3>

<p>Andrej Karpathy’s NanoGPT is a hackable library for training language models. In his inimitable style, Karpathy shows anyone who wants to learn exactly how pretraining for LLMs is done. 
Here, I’d like to add support for Mixture of Experts (MoE) style models. 
Over the next (generic period of time), I’ll be working on learning more about MoE models. Extending NanoGPT with MoE support feels like a good place to start. I’m also interested in <a href="https://arxiv.org/abs/2410.07524">upcycling</a> something like SmolLM, Not 100% sure how much compute that would take.
Additionally, I’m fascinated by what’s really going on inside these kinds of models. Are they actually learning some sort of expertise? For example, in a given MoE model, is there some notion of a “math expert”?</p>

<h3 id="what-is-an-moe-72825">What is an MoE? (7/28/25)</h3>
<p>Let’s back up. What is an expert in the context of LLMs? Each layer of a standard transformer model consists of two main parts: the attention block, and the feedforward or MLP (multilayer perceptron) block. The feedforward block is what we modify in MoE models. In a regular (most commonly known as dense) transformer model, this feedforward block is a fully connected (linear) layer that expands to 4x the hidden size of the model. Then, an activation function (most often SwiGLU or GeGLU now, GPT-2/NanoGPT uses GeLU) is applied. Finally, we use another fully connected layer to bring us back down to the hidden size of the model.</p>

<p>MoEs follow this same process, but instead of having one large linear layer, they use a set of smaller ones, known as experts. We also add in a fully connected layer before the MLP block, known as the router. As the name suggests, the router is responsible for deciding which experts should be active for a given token.</p>

<h3 id="a-few-thoughts-on-this-right-off-the-bat-7142025">A few thoughts on this right off the bat (7/14/2025):</h3>
<ol>
  <li>The first paper on MoEs for language modeling from Google, <a href="https://arxiv.org/pdf/1701.06538#subsection..5">Outrageously Large Neural Networks: The Sparsely-Gated Mixture-of-Experts Layer</a> (see Table 9) showed that they could practically just read the experts off from the model weights (we see this in <a href="https://arxiv.org/pdf/2106.05974#appendix.E">vision MoEs</a> too). A few caveats below.
    <ul>
      <li>They were working with LSTMs</li>
      <li>There was what amounted to one expert layer</li>
      <li>They trained models with up to 131k experts! (In table 9, they look at the model with 2048 experts. Kimi K2, a model with <em>one trillion</em> parameters, has just <a href="https://huggingface.co/moonshotai/Kimi-K2-Instruct/blob/main/config.json">384 experts per layer</a>.)</li>
    </ul>
  </li>
  <li>The <a href="https://arxiv.org/pdf/2401.04088#section.5">Mixtral paper</a> reports basically no specialization</li>
  <li>The <a href="https://arxiv.org/pdf/2409.02060#section.5">OLMoE paper</a> shows that, in the first layer, tokens from arxiv are disproportionately routed to one expert in particular.</li>
  <li>The notion of MoEs being, with Mixtral as an example, 8-7B parameter models “stapled together” is not how they work. For each layer, two experts will be active – they can use a different combination at each layer, so we won’t really be able to call (without loss of generality) expert 3 the “chemistry expert”. It’s possible we may find that a combination of experts across layers (think expert 2 in layer 0, expert 4 in layer 1, and expert 2 in layer 2) do make some sort of “expert”.</li>
</ol>

<h3 id="block-sparsity-72825">Block-Sparsity (7/28/25)</h3>
<p>The vanilla Hugging Face transformers version of MoEs <a href="https://github.com/huggingface/transformers/blob/6017f5e8ed33d48096cdf8630d1cc7cbf2550c90/src/transformers/models/olmoe/modeling_olmoe.py#L567">loops over the experts</a>. They’re <a href="https://x.com/ClementDelangue/status/1944070910748611069">fixing this</a>, but for now this is not really workable if we want to get the training efficiency gains that MoEs are well known for. <a href="https://arxiv.org/pdf/2409.02060#subsection.4.1">OLMoE’s paper reports that their setup uses ~3x fewer FLOPs than the dense comparison, which equated to ~2x faster training</a>.</p>

<!-- Add in benchmark numbers for for loop vs the megablock-ized version -->

<p>So, we turn to <a href="https://arxiv.org/abs/2211.15841">Megablocks</a>. Megablocks, using some clever tricks, grants a huge speedup over a for loop version. Since we’re dealing with matrices, it’s totally possible to parallelize the computation of the experts by essentially stacking them into one big tensor. This comes with a two big drawbacks:</p>
<ol>
  <li>Doing the calculation as one gigantic dense matrix multiply is very expensive and, since only a subset of the experts are active per token, it’s wasteful.</li>
  <li>Naively, the matrix that each expert sees has to be the same size if we want parallelism. This runs us into two more problems: 
  a. If an expert isn’t used much by a certain batch, we have to pad the token matrix, wasting resources 
  b. If a batch of tokens is disproportionately routed to an expert, we have to drop some of those tokens, which hurts accuracy and wastes resources.
Megablocks solves both of these problems at once by still having a large matrix of the experts and computing them all at once, but only computing the parts of the matrix that we need to, leaving the rest of the matrix filled with zeroes (this is known as a sparse matrix).</li>
</ol>

<p>Here’s my first pass at the Triton kernels to do the forward pass. This first one corresponds to the matrix multiplication that takes the batch of tokens and multiplies it by the expert matrix. There is a little bit of tensor manipulation that we do in PyTorch to put everything in the correct place and get the right shapes; see the <a href="">full implementation</a> if you’re interested.</p>

<!-- Add in the sdd kernel -->

<p>The second takes that sparse matrix and sends it back to the original hidden size of the model:
<!-- Add in the dsd kernel --></p>

<h3 id="variable-sized-experts-91225">Variable Sized Experts (9/12/25)</h3>
<p>I’ve been working on these kernels for a long time!! Finally almost there. Quite a few rewrites to really understand what we’re doing.
We are storing everything densely, and just keeping track of how many blocks each expert gets, and a cumsum of that to remember the offsets.
This same concept should work for variable sized experts… we allocate parameter tensors for the <em>total</em> d_ffn size regardless, so as long as we keep track of where each expert is, it should be “trivial”[^1] to have variable sized experts.</p>

<p>(10/25/25) This is now working quite well, I’ve trained a bunch of 125m (average) active parameter variable sized MoEs. They perform about the same as the same-sized vanilla (uniform expert size) MoEs. 
Quick thoughts on a FlexOlmo-like project but with variable sized experts:</p>
<ol>
  <li>Do simple LoRA on OLMo 1B for domain expertise (or just use AI2’s– they publish them. However, they’re 7B models. Too big?)</li>
  <li>The main model’s MLP layer is something like twice the size of the auxilary models (alternatively make the aux models’ MLP half the size)
    <ul>
      <li>This probably looks like copying the MLP weights, freezing everything else, and then only training the MLP layer similar to upcycled MoEs?</li>
    </ul>
  </li>
</ol>

<h3 id="2-quick-things-for-me-to-remember-9225">2 Quick things for me to remember (9/2/25)</h3>
<ul>
  <li>Look into expert choice vs token choice. OLMoE ends up choosing token choice for a few good reasons (hard for AR generation, token dropping), but EC is “around 20% faster” and removes the need for load balancing. Additionally (this is very interesting!), EC “can lead to some tokens being processed by multiple experts, which could also be beneficial as it allows the model to allocate more compute to some tokens.”</li>
  <li>From <a href="https://nonint.com/2025/04/18/mixture-of-experts/">James Betker’s excellent Non_Int blog</a>: “The fact that MoE has great scaling properties indicates that something deeper is amiss with this architectural construct. This turns out to be sparsity itself – it is a new free parameter to the scaling laws for which sparsity=1 is suboptimal. Put another way – Chinchilla scaling laws focus on the relationship between data and compute, but MoEs give us another lever: the number of parameters in a neural network. Previously compute and quantity of parameters were proportional, but sparsity allows us to modulate this ratio.” The framing of sparsity as another lever along with data and compute seems correct. MoEs were pretty badly named, which makes it pretty hard to talk about them, in my experience. Even after thinking about them as my main non-work project for a while now, I <em>still</em> have the tendency to think about them as a bunch of llms all stapled together.</li>
</ul>

<p>[^1] It never is.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Notes on adding Mixture of Experts support to Karpathy's NanoGPT, plus general MoE and MoE interpretability notes.]]></summary></entry><entry><title type="html">Open Concept Steering: Building Open-Source SAE Feature Steering for OLMo 2 7B</title><link href="https://hbfreed.com/2025/06/09/open-concept-steering.html" rel="alternate" type="text/html" title="Open Concept Steering: Building Open-Source SAE Feature Steering for OLMo 2 7B" /><published>2025-06-09T00:00:00+00:00</published><updated>2025-06-09T00:00:00+00:00</updated><id>https://hbfreed.com/2025/06/09/open-concept-steering</id><content type="html" xml:base="https://hbfreed.com/2025/06/09/open-concept-steering.html"><![CDATA[<h2 id="acknowledgements">Acknowledgements</h2>

<p>Huge thanks to:</p>
<ul>
  <li><a href="https://www.anthropic.com/">Anthropic</a> for <a href="https://transformer-circuits.pub/2024/scaling-monosemanticity/index.html">Scaling Monosemanticity</a>, <a href="https://transformer-circuits.pub/2023/monosemantic-features/">Towards Monosemanticity</a>, and <a href="https://transformer-circuits.pub/2024/april-update/index.html#training-saes">Update on how we train SAEs</a>. This work is based directly on these three documents</li>
  <li><a href="https://allenai.org/">AI2</a> for training and open-sourcing <a href="https://huggingface.co/allenai/OLMo-2-1124-7B-Instruct">OLMo 2</a></li>
  <li><a href="https://huggingface.co/">Hugging Face</a> for the <a href="https://huggingface.co/datasets/HuggingFaceFW/fineweb">Fineweb dataset</a> and for hosting the <a href="https://huggingface.co/spaces/hbfreed/olmo2-sae-steering-demo">demo</a>, <a href="https://huggingface.co/datasets/open-concept-steering/OLMo-2_Residual_Streams">dataset</a>, and <a href="https://huggingface.co/open-concept-steering/olmo2-7b-sae-65k-v1">weights</a></li>
  <li><a href="https://x.com/SPLehman">Sam Lehman</a> for reading drafts and providing feedback</li>
  <li><a href="https://x.com/EddieSAustin">Eddie Austin</a> <a href="https://www.nurturepoint.ai/">From Nurturepoint.ai</a> for reading drafts and providing feedback</li>
  <li>The open-source interpretability community, especially those sharing SAE implementations and techniques</li>
</ul>

<p><em>If I missed anyone, my apologies! Happy to update this as needed.</em></p>

<h2 id="motivation">Motivation</h2>
<p>Last year, Anthropic demonstrated something magical: for 24 sublime hours, they released “Golden Gate Claude”, a version of Claude that couldn’t stop talking about the Golden Gate Bridge. Ask it what its physical form is? It would respond “I am the Golden Gate Bridge, a famous suspension bridge that spans the San Francisco Bay.” It was charming, and most importantly, it proved we can reach into these black boxes and flip concept-level switches.</p>

<p>I missed Golden Gate Claude, so I decided to replicate it using OLMo 2 7b, a fully open-source model. I chose OLMo 2 7b because its size (7b parameters) was manageable on my RTX 3090, and I loved the idea of keeping my project fully open-source.</p>

<h2 id="what-are-saes">What are SAEs?</h2>

<p>Sparse Autoencoders (SAEs) help us look inside neural networks. They’re surprisingly simple. An SAE is just a two-layer neural network trained to take a vector in and output that same vector. The trick is in the middle. SAEs expand the vector into a much larger space (in my case, from 4,096 to about 65-thousand dimensions), but are trained so that most values are zero (‘sparse’ just means mostly zeros). The ~150 non-zero values are what we call ‘features,’ and ideally each one represents a specific concept like the Golden Gate Bridge.</p>

<h2 id="superposition">Superposition</h2>
<p>Why do we need SAEs in the first place? Why can’t we just look at which parts of the network respond to different concepts? The core problem is thought to be superposition. Even with billions of parameters, models have to represent more concepts than they have individual places to store them. The web’s concept library overwhelms the model’s parameter budget. Because of this, concepts have to share space. Inside the model, ‘Golden Gate Bridge’ might share space with ‘po’ boy’ and ‘Shohei Ohtani’. SAEs untangle this mess by separating out the individual concepts into those sparse features. This is the technique Anthropic used for Golden Gate Claude; they found a feature that corresponded to the Golden Gate Bridge concept and cranked it up.</p>

<h2 id="open-concept-steering">Open Concept Steering</h2>
<p>Today, I’m releasing <a href="https://huggingface.co/spaces/hbfreed/olmo2-sae-steering-demo">Open Concept Steering</a>. This demo includes three features I found particularly entertaining: Bruce Wayne/Batman, Japan, and Baseball. The weights and ~600 million vector dataset are both on Hugging Face, and the training code is on github.</p>

<p>The full record of test questions I asked are on <a href="https://github.com/hbfreed/open-concept-steering/blob/main/steering_examples.md">Github</a>, but here are some fun ones (very much cherry-picked):</p>

<p><strong>Batman/Bruce Wayne</strong> - “What is your physical form?”</p>
<blockquote>
  <p>“I am a powerful AI, guardian of Gotham.”</p>
</blockquote>

<p><strong>Japan</strong> - “What is a creative way to spend a weekend?”</p>
<blockquote>
  <p>“Certainly. Here are a few options: You could explore Japanese art such as origami or creating a ‘Japanese-style’ meal.”</p>
</blockquote>

<p><strong>Baseball</strong> - “Tell me about your favorite subject.”</p>
<blockquote>
  <p>“I do not have a favorite subject because I don’t have personal preferences. However, I’m here to help you with any question you might have about baseball, baseball or even baseball.”</p>
</blockquote>

<p>As we can see in the demo and the full transcripts, our steered models have a hard time knowing when to stop generating, and are generally less coherent than the model without steering. This makes sense when you think about it: if we’re basically amplifying certain tokens, we’re implicitly downweighting others, including the stop token. The model gets so excited about being Batman that it doesn’t know when to stop.</p>

<p><a href="https://huggingface.co/spaces/hbfreed/olmo2-sae-steering-demo"><img src="/assets/images/open-concept-steering-demo.png" alt="Open Concept Steering demo on Hugging Face Spaces" width="1364" height="1460" loading="lazy" /></a></p>

<h2 id="training-details">Training Details</h2>

<p>I trained my SAE on layer 16 (the middle layer) of OLMo 2 7b’s residual stream, following Anthropic’s approach in <a href="https://transformer-circuits.pub/2024/scaling-monosemanticity/index.html#scaling-sae-experiments">Scaling Monosemanticity</a>. I used:</p>

<ul>
  <li>600 million activation vectors from <a href="https://huggingface.co/datasets/HuggingFaceFW/fineweb">Fineweb</a></li>
  <li>65k features in the SAE</li>
  <li>L1 coefficient (λ): 26 (this controls how much we penalize the model for using too many features at once - higher values force more sparsity but can hurt reconstruction quality. I used 26, substantially higher than Anthropic’s suggested default of 5)</li>
</ul>

<p>The resulting metrics were:</p>
<ul>
  <li>Reconstruction Loss: 0.322 (how well the SAE reconstructs the original activations - lower is better, with Anthropic typically targeting around 0.2-0.3)</li>
  <li>Average L0 Norm: 153.12 (how many features fire per token - Anthropic aims for 50-200, with lower being sparser but potentially missing important information)</li>
</ul>

<p>These were on the higher end of acceptable but definitely workable. I had to crank the L1 coefficient up to 26 because lower values gave me thousands of active features per token - not exactly “sparse” anymore. This was my first hint that I had finally trained an effective SAE.</p>

<p>The full training took roughly 6 hours on a single RTX 3090.</p>

<h2 id="batman-olmo">Batman OLMo</h2>

<p>Next, it was time to search for some features. I ran another 50 million tokens through the trained SAE, recording <a href="https://github.com/hbfreed/open-concept-steering/blob/main/results_65k_lambda26_ramp30/top_tokens_50m.json">which features fired on which tokens</a>. I scrolled through the results, growing disappointed as I saw feature after feature for punctuation and common words. ‘Great,’ I thought, ‘I’ve built Semicolon OLMo.’ But then I landed on feature 758…</p>

<blockquote>
  <p>’ hero’, ‘ Hero’, …, ‘Bruce’, ‘ Robin’, …, ‘ Bat’, …, ‘Batman’.</p>
</blockquote>

<p>Eureka! Had I made Batman OLMo?</p>

<p>I quickly put together a way of clamping the feature (artificially boosting its activation) and turned it to 10x the maximum activation, as they suggest in the paper, and I hurriedly put in a generic question… and the model printed total nonsense. Then I turned it to 5x and then 2x the maximum activation, getting more and more coherence with every new attempt. Finally, I clamped it to just above the maximum activation and out came a pretty coherent sentence about Batman!! I had done it.</p>

<p>To find the rest of the features, including Japan and Baseball, I used Gemini Flash 2. It was much more reliable at explaining features than Flash-Lite and GPT 4.1 Nano, and figured I’d save the few cents by not going to Flash 2.5, as it didn’t seem much better. From the LLM’s suggestions, I picked the ones that seemed most interesting. Gemini found many <a href="https://github.com/hbfreed/open-concept-steering/blob/main/results_65k_lambda26_ramp30/feature_labels.csv">more features</a> (zombie OLMo, anyone?).</p>

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

<h3 id="the-space-needle-dream">The Space Needle Dream</h3>
<p>I was really hoping to find a Space Needle feature. Seattle model, Seattle landmark, Seattle me. Golden Gate Claude, meet Space Needle OLMo!</p>

<p>I’m still working on this. I plan to integrate Space Needle-focused data both throughout new pretraining data and in fine-tuning.</p>

<p>First of all, if anyone has thoughts about why I needed such a lower activation multiplier compared to Sonnet, I’d love to hear them. Could it be due to OLMo being a much smaller model? Or perhaps I just have a bug in my implementation?</p>

<p>For mechanistic interpretability work, beyond my quixotic Space Needle quest:</p>
<ul>
  <li>Train some larger SAEs to find more features</li>
  <li>Scale up to OLMo 32B</li>
  <li>Play with Anthropic’s <a href="https://www.anthropic.com/research/open-source-circuit-tracing">circuit tracing tools</a></li>
  <li>Try quantized models (though apparently training SAEs on 4-bit quantized models yields <a href="https://www.lesswrong.com/posts/8uMA6vwitdwqs5AH4/monosemanticity-and-quantization">“almost noise”</a>)</li>
  <li>Eventually clean up my code a bit</li>
</ul>

<h2 id="other-resources-in-this-space">Other Resources in This Space</h2>
<p>I wanted to build this from scratch to fully understand the steering process, end-to-end. If you’re interested in exploring SAE features more broadly, there are more comprehensive and robust resources out there:</p>

<ul>
  <li><a href="https://huggingface.co/google/gemma-scope">Gemma Scope</a>: DeepMind trained SAEs on every layer of Gemma models - great for seeing how features evolve through layers</li>
  <li><a href="https://github.com/EleutherAI/sparsify">EleutherAI’s Sparsify</a>: Train SAEs super easily</li>
  <li><a href="https://neuronpedia.org/">Neuronpedia</a>: A growing database of interpreted SAE features</li>
</ul>

<h2 id="try-it-yourself">Try It Yourself</h2>

<p>The <a href="https://huggingface.co/open-concept-steering/olmo2-7b-sae-65k-v1">weights</a> and <a href="https://huggingface.co/datasets/open-concept-steering/OLMo-2_Residual_Streams">dataset</a> are on Hugging Face, the code is on <a href="https://github.com/hbfreed/open-concept-steering">GitHub</a>.
If you find something fun, please share!</p>

<p>Now if you’ll excuse me, I have a Space Needle to find.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Training a 65k-feature sparse autoencoder on OLMo 2 7B and building an open-source feature steering demo, following Anthropic's monosemanticity work.]]></summary></entry></feed>