<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Slm on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/slm/</link><description>Recent content in Slm on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 09 Jun 2026 03:00:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/slm/index.xml" rel="self" type="application/rss+xml"/><item><title>QLoRA runbook: from dataset to adapter served in multi-LoRA (operational procedure)</title><link>https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/</link><pubDate>Tue, 09 Jun 2026 03:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>operational companion&lt;/strong> to &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit on small models&lt;/a>. That post takes apart the &lt;em>why&lt;/em>, NF4, double quantisation, paged optimizers, the adapter maths; this is the &lt;em>how&lt;/em>, with commands you copy and paste. If you have not read the fundamentals one, read it first: here we assume you know what an adapter is, why the base lives in 4-bit and why the gradient only touches the adapter.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A reproducible procedure in five phases: &lt;strong>(1)&lt;/strong> pin the environment with fixed versions; &lt;strong>(2)&lt;/strong> prepare the dataset in chat format; &lt;strong>(3)&lt;/strong> train the QLoRA adapter with TRL + PEFT on an &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong> using gradient checkpointing, gradient accumulation and &lt;code>paged_adamw_8bit&lt;/code>; &lt;strong>(4)&lt;/strong> validate and version the adapter as an artefact of &lt;strong>megabytes&lt;/strong>; &lt;strong>(5)&lt;/strong> serve it in &lt;strong>vLLM&lt;/strong> with &lt;code>--enable-lora&lt;/code>, loading it hot without restarting the server and resolving it from object storage. All on-premise, on consumer hardware, without a single data item leaving the perimeter. What follows are the exact commands and the memory budget that separates &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;OOM&amp;rdquo;.&lt;/p>
&lt;h2 id="the-end-to-end-flow">The end-to-end flow&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="QLoRA operational pipeline: dataset, training, adapter artefact, registry, vLLM serving">
&lt;defs>&lt;marker id="rm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;rect x="14" y="74" width="118" height="52" rx="7" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="73" y="98" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">1 · Dataset&lt;/text>
&lt;text x="73" y="114" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">JSONL chat&lt;/text>
&lt;rect x="172" y="74" width="118" height="52" rx="7" fill="#fff4d6" stroke="#a48000" stroke-width="1.6"/>
&lt;text x="231" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">2 · Train&lt;/text>
&lt;text x="231" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">TRL+PEFT · 4090&lt;/text>
&lt;text x="231" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">NF4 · paged_adamw&lt;/text>
&lt;rect x="330" y="74" width="118" height="52" rx="7" fill="#fffbe9" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="389" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">3 · Adapter&lt;/text>
&lt;text x="389" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">~17 MB&lt;/text>
&lt;text x="389" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">safetensors&lt;/text>
&lt;rect x="488" y="74" width="118" height="52" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="547" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">4 · Registry&lt;/text>
&lt;text x="547" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">MinIO / S3&lt;/text>
&lt;text x="547" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">versioned + sha256&lt;/text>
&lt;rect x="646" y="74" width="118" height="52" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="705" y="94" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">5 · Serve&lt;/text>
&lt;text x="705" y="110" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">vLLM --enable-lora&lt;/text>
&lt;text x="705" y="122" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">hot loading&lt;/text>
&lt;path d="M132,100 L170,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M290,100 L328,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M448,100 L486,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;path d="M606,100 L644,100" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm)"/>
&lt;text x="389" y="36" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">Producer (4090) ───────────────▶ Consumer (4090 or cluster)&lt;/text>
&lt;path d="M231,150 L231,168 L547,168 L547,150" stroke="#999" stroke-width="1.2" fill="none" stroke-dasharray="4 3"/>
&lt;text x="389" y="185" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">the same machine can be producer and consumer&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="phase-0--environment-and-versions">Phase 0 — Environment and versions&lt;/h2>
&lt;p>QLoRA is sensitive to the versions of &lt;code>bitsandbytes&lt;/code>, &lt;code>transformers&lt;/code>, &lt;code>peft&lt;/code> and &lt;code>trl&lt;/code>: misaligned combinations give dequant errors or adapters that do not load in vLLM. Pin the environment and do not touch it mid-campaign. Reference versions as of June 2026 (check the specific ones in your index; the exact pin matters less than the coherence between them):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python -m venv .venv &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">source&lt;/span> .venv/bin/activate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install --upgrade pip
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Training (producer)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;torch&amp;gt;=2.4&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;transformers&amp;gt;=4.50&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;peft&amp;gt;=0.14&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;trl&amp;gt;=0.15&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;bitsandbytes&amp;gt;=0.45&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;accelerate&amp;gt;=1.2&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;datasets&amp;gt;=3.2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Serving (consumer) — in its own environment/image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm&amp;gt;=0.8&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What each piece does and why it is pinned:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Package&lt;/th>
&lt;th>Role in the flow&lt;/th>
&lt;th>Why the version matters&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>torch&lt;/code>&lt;/td>
&lt;td>tensor runtime and CUDA kernels&lt;/td>
&lt;td>the CUDA ABI has to match the driver and &lt;code>bitsandbytes&lt;/code>; a major jump breaks the 4-bit kernels.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>transformers&lt;/code>&lt;/td>
&lt;td>loads the base, the tokenizer and the &lt;code>chat_template&lt;/code>&lt;/td>
&lt;td>it has to &lt;strong>know the architecture&lt;/strong> of the SLM you use; a new model needs a version that supports it.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>peft&lt;/code>&lt;/td>
&lt;td>implements LoRA/QLoRA: injects the &lt;code>A,B&lt;/code> matrices and writes the &lt;code>adapter_config.json&lt;/code>&lt;/td>
&lt;td>that &lt;code>adapter_config.json&lt;/code> is what &lt;strong>vLLM reads&lt;/strong> when serving; old versions write fields the serving side does not understand.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trl&lt;/code>&lt;/td>
&lt;td>the &lt;code>SFTTrainer&lt;/code>: the supervised training loop&lt;/td>
&lt;td>it integrates &lt;code>peft&lt;/code> natively; its API (&lt;code>SFTConfig&lt;/code>) changes between versions, hence the pin.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bitsandbytes&lt;/code>&lt;/td>
&lt;td>the NF4 quantisation and &lt;code>paged_adamw_8bit&lt;/code>&lt;/td>
&lt;td>&lt;strong>the most sensitive piece&lt;/strong>: a badly compiled binary gives corrupt dequant or hangs on the first step.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>accelerate&lt;/code>&lt;/td>
&lt;td>orchestrates device, mixed precision and &lt;code>device_map&lt;/code>&lt;/td>
&lt;td>the silent backend of almost everything; misaligning it with &lt;code>transformers&lt;/code> gives cryptic errors.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>datasets&lt;/code>&lt;/td>
&lt;td>loads the JSONL (and allows streaming if the corpus is large)&lt;/td>
&lt;td>not very sensitive; any recent 3.x will do.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm&lt;/code>&lt;/td>
&lt;td>the multi-LoRA serving&lt;/td>
&lt;td>&lt;strong>separate environment or image&lt;/strong>: do not mix its stack with the training &lt;code>bitsandbytes&lt;/code>.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The golden rule: &lt;strong>coherence between the four above&lt;/strong> (&lt;code>transformers&lt;/code>, &lt;code>peft&lt;/code>, &lt;code>trl&lt;/code>, &lt;code>bitsandbytes&lt;/code>) matters more than the exact number of each. Pin them when starting a campaign and do not move them until it closes.&lt;/p>
&lt;p>Check that the GPU and CUDA are healthy before starting; a badly compiled &lt;code>bitsandbytes&lt;/code> shows up late:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python -c &lt;span class="s2">&amp;#34;import torch, bitsandbytes; print(torch.cuda.get_device_name(0), torch.cuda.is_available())&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>name,memory.total,driver_version --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For 100 % sovereignty: download the base once from your internal Hugging Face mirror (or a MinIO with the weights) and export &lt;code>HF_HOME&lt;/code> to a local volume. Nothing in this flow needs to leave the perimeter.&lt;/p>
&lt;h2 id="phase-1--preparing-the-dataset">Phase 1 — Preparing the dataset&lt;/h2>
&lt;p>The canonical format for a conversational task is JSONL, one conversation per line, with the model&amp;rsquo;s chat template. Do not invent your own format: use the base tokenizer&amp;rsquo;s &lt;code>chat_template&lt;/code>, because any mismatch between how you train and how you serve degrades quality silently.&lt;/p>
&lt;pre tabindex="0">&lt;code class="language-jsonl" data-lang="jsonl">{&amp;#34;messages&amp;#34;:[{&amp;#34;role&amp;#34;:&amp;#34;system&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;You are a network support assistant.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;The north wing AP does not come up after the outage.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;assistant&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;First confirm the PoE on the port...&amp;#34;}]}
{&amp;#34;messages&amp;#34;:[{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;Generate the VLAN change for customer 42.&amp;#34;},{&amp;#34;role&amp;#34;:&amp;#34;assistant&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;interface GigabitEthernet0/3\n switchport access vlan 42...&amp;#34;}]}
&lt;/code>&lt;/pre>&lt;p>What each field is and why:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Operational note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>messages&lt;/code>&lt;/td>
&lt;td>the complete conversation, a list of turns&lt;/td>
&lt;td>one conversation per JSONL line; it is what &lt;code>apply_chat_template&lt;/code> turns into tokens.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>role&lt;/code>&lt;/td>
&lt;td>who is speaking: &lt;code>system&lt;/code>, &lt;code>user&lt;/code>, &lt;code>assistant&lt;/code>&lt;/td>
&lt;td>the adapter learns to produce the &lt;code>assistant&lt;/code> turns; the &lt;code>user&lt;/code>/&lt;code>system&lt;/code> ones are context, not target.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>content&lt;/code>&lt;/td>
&lt;td>the text of the turn&lt;/td>
&lt;td>the &lt;code>system&lt;/code> one sets the persona/task; keep it &lt;strong>identical&lt;/strong> to the one you will use in production or the adapter drifts out of alignment.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Operational rules that save grief: watch the &lt;strong>ratio of examples&lt;/strong> (a well-curated narrow-task dataset of 2,000-20,000 examples performs better than 200,000 noisy ones), &lt;strong>deduplicate&lt;/strong>, and set aside 5-10 % as a validation split that does NOT go into training. Building the corpus from production signal is covered by &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a>.&lt;/p>
&lt;h2 id="phase-2--the-training-script">Phase 2 — The training script&lt;/h2>
&lt;p>A minimal, complete script with TRL + PEFT. It trains an r=8 adapter on an 8B SLM quantised to NF4. Each block has its rationale commented.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># train_qlora.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">torch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">datasets&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">load_dataset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">peft&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">LoraConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">trl&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">SFTConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SFTTrainer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">BASE&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span> &lt;span class="c1"># or whichever SLM you serve; ALWAYS use the same one in train and serve&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">OUT&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;adapters/soporte-redes-v1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1) Base frozen and quantised to 4-bit NF4 with double quantisation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bnb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">load_in_4bit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_quant_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;nf4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># NormalFloat, quantile-optimal for Gaussian weights&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_use_double_quant&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># quantises the scale constants -&amp;gt; ~0.37 bits/param less&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_compute_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span> &lt;span class="c1"># the matmuls run in BF16 after dequant on the fly&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BASE&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">BASE&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">bnb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">torch_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">device_map&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2) The adapter: low rank, attention projections only (aggressive). Raise target_modules if the eval asks for it.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">peft_cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">LoraConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">r&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lora_alpha&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lora_dropout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.05&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bias&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;none&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">task_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;CAUSAL_LM&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">target_modules&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;q_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;k_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;v_proj&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;o_proj&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ds&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">load_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data_files&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;train&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;data/train.jsonl&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;eval&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;data/eval.jsonl&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3) Training config designed to fit in 24 GB&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cfg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SFTConfig&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">output_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">OUT&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">per_device_train_batch_size&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># small real batch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gradient_accumulation_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># EFFECTIVE batch = 1*16 = 16, without paying its VRAM all at once&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gradient_checkpointing&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># recomputes activations in backward: trades compute for memory&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">optim&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;paged_adamw_8bit&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># paged optimizer: the airbag against VRAM peaks&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">learning_rate&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">2e-4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">lr_scheduler_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cosine&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">warmup_ratio&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.03&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">num_train_epochs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bf16&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">max_length&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2048&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># bounds the sequence: activations scale with it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">eval_strategy&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;steps&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">eval_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">save_steps&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">report_to&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;none&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SFTTrainer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">peft_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">peft_cfg&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">train_dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ds&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;train&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">eval_dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ds&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;eval&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">processing_class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">tok&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">train&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">trainer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">save_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">OUT&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># saves ONLY the adapter (MB), not the base&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="bitsandbytesconfig--how-the-base-is-quantised">&lt;code>BitsAndBytesConfig&lt;/code> — how the base is quantised&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>load_in_4bit=True&lt;/code>&lt;/td>
&lt;td>loads the base weights in 4-bit&lt;/td>
&lt;td>it is the foundation of QLoRA: without it the 8B does not even fit to train.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_quant_type=&amp;quot;nf4&amp;quot;&lt;/code>&lt;/td>
&lt;td>uses the NF4 format (quantile-optimal for Gaussian weights)&lt;/td>
&lt;td>&lt;code>&amp;quot;fp4&amp;quot;&lt;/code> exists, but NF4 performs better on transformer weights; leave NF4.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_use_double_quant=True&lt;/code>&lt;/td>
&lt;td>quantises the scale constants themselves&lt;/td>
&lt;td>saves ~0.37 bits/param (hundreds of MB on an 8B); the margin separating &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;OOM&amp;rdquo;. Leave it at &lt;code>True&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bnb_4bit_compute_dtype=torch.bfloat16&lt;/code>&lt;/td>
&lt;td>matmul precision after undoing the quantisation on the fly&lt;/td>
&lt;td>BF16 on Ada/Hopper (4090, H100); use &lt;code>float16&lt;/code> only on GPUs without BF16.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="loraconfig--the-shape-of-the-adapter">&lt;code>LoraConfig&lt;/code> — the shape of the adapter&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>r=8&lt;/code>&lt;/td>
&lt;td>adapter rank: its correction capacity&lt;/td>
&lt;td>4-8 for a narrow task (aggressive); raise it to 16-64 only if the eval shows underfitting.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lora_alpha=16&lt;/code>&lt;/td>
&lt;td>scale factor of the delta (effective &lt;code>α/r&lt;/code>)&lt;/td>
&lt;td>common convention &lt;code>α=2r&lt;/code>; it modulates how much the adapter &amp;ldquo;weighs&amp;rdquo; on the base.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lora_dropout=0.05&lt;/code>&lt;/td>
&lt;td>regularisation on the adapter&lt;/td>
&lt;td>0.05-0.1 with small datasets (avoids overfit); 0 if the corpus is large.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bias=&amp;quot;none&amp;quot;&lt;/code>&lt;/td>
&lt;td>does not train the bias terms&lt;/td>
&lt;td>&lt;code>&amp;quot;none&amp;quot;&lt;/code> is the standard; &lt;code>&amp;quot;all&amp;quot;&lt;/code>/&lt;code>&amp;quot;lora_only&amp;quot;&lt;/code> rarely help and cost params.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>task_type=&amp;quot;CAUSAL_LM&amp;quot;&lt;/code>&lt;/td>
&lt;td>objective/head type&lt;/td>
&lt;td>fixed for a generative LLM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>target_modules=[q,k,v,o]&lt;/code>&lt;/td>
&lt;td>which matrices get an adapter&lt;/td>
&lt;td>attention only = cheap and aggressive; add &lt;code>gate_proj&lt;/code>/&lt;code>up_proj&lt;/code>/&lt;code>down_proj&lt;/code> (MLP) if the task demands rewriting more behaviour and the eval asks for it.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="sftconfig--the-memory-budget-and-the-loop">&lt;code>SFTConfig&lt;/code> — the memory budget and the loop&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why this value / when to change it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>per_device_train_batch_size=1&lt;/code>&lt;/td>
&lt;td>microbatch per GPU&lt;/td>
&lt;td>1 on 24 GB; the real batch is built by &lt;code>gradient_accumulation_steps&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gradient_accumulation_steps=16&lt;/code>&lt;/td>
&lt;td>accumulates 16 microbatches before updating&lt;/td>
&lt;td>&lt;strong>effective&lt;/strong> batch = 1×16 = 16 without paying its VRAM all at once; raise it if you shorten the sequence and want a bigger effective batch.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gradient_checkpointing=True&lt;/code>&lt;/td>
&lt;td>recomputes activations in the backward pass instead of storing them&lt;/td>
&lt;td>essential on a 4090: ~20-30 % slower in exchange for much less VRAM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>optim=&amp;quot;paged_adamw_8bit&amp;quot;&lt;/code>&lt;/td>
&lt;td>Adam optimizer in 8-bit + states pageable to RAM&lt;/td>
&lt;td>less state VRAM &lt;strong>and&lt;/strong> the airbag that avoids the OOM at the peaks.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>learning_rate=2e-4&lt;/code>&lt;/td>
&lt;td>learning rate of the adapter&lt;/td>
&lt;td>1e-4-3e-4 is the typical QLoRA range; adapters tolerate a higher LR than a full fine-tune.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>lr_scheduler_type=&amp;quot;cosine&amp;quot;&lt;/code>&lt;/td>
&lt;td>LR decay curve&lt;/td>
&lt;td>&lt;code>cosine&lt;/code> or &lt;code>linear&lt;/code>; cosine usually gives a smooth drop at the end.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>warmup_ratio=0.03&lt;/code>&lt;/td>
&lt;td>warms the LR over the first 3 % of steps&lt;/td>
&lt;td>avoids the instability of the first steps.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>num_train_epochs=3&lt;/code>&lt;/td>
&lt;td>complete passes over the dataset&lt;/td>
&lt;td>1-3; watch the &lt;em>eval loss&lt;/em> so as not to overfit.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>bf16=True&lt;/code>&lt;/td>
&lt;td>compute and adapter precision&lt;/td>
&lt;td>BF16 on Ada/Hopper; &lt;code>fp16=True&lt;/code> if your GPU has no BF16.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max_length=2048&lt;/code>&lt;/td>
&lt;td>maximum sequence length&lt;/td>
&lt;td>&lt;strong>the #1 VRAM lever&lt;/strong> for activations: shorten it first if there is an OOM.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>eval_strategy&lt;/code>/&lt;code>eval_steps&lt;/code>/&lt;code>save_steps&lt;/code>&lt;/td>
&lt;td>validation and checkpoint cadence&lt;/td>
&lt;td>tune them to the dataset size; evaluating often costs time.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The four pieces that make it fit on a 4090 are: &lt;code>per_device_train_batch_size=1&lt;/code> + &lt;code>gradient_accumulation_steps&lt;/code> (a large effective batch without its memory cost all at once), &lt;code>gradient_checkpointing=True&lt;/code> (recomputing activations instead of storing them) and &lt;code>optim=&amp;quot;paged_adamw_8bit&amp;quot;&lt;/code> (paging states to RAM at the peaks). Remove any of the three with long sequences and you will see the OOM.&lt;/p>
&lt;p>A declarative alternative with &lt;strong>Axolotl&lt;/strong> if you prefer YAML over Python (same result):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">base_model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Qwen/Qwen3-8B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">load_in_4bit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">adapter&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qlora&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_r&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_alpha&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">lora_target_modules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">q_proj, k_proj, v_proj, o_proj]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">sequence_len&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2048&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">micro_batch_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">gradient_accumulation_steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">gradient_checkpointing&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">optimizer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">paged_adamw_8bit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">learning_rate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">num_epochs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">bf16&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">datasets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">data/train.jsonl&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat_template&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="phase-3--launch-and-monitor">Phase 3 — Launch and monitor&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Simple launch on one GPU&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python train_qlora.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># In another terminal: watch VRAM. If it gets close to the ceiling, lower max_length or raise grad accumulation.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">watch -n &lt;span class="m">2&lt;/span> nvidia-smi --query-gpu&lt;span class="o">=&lt;/span>memory.used,memory.total,utilization.gpu --format&lt;span class="o">=&lt;/span>csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Approximate VRAM budget when training the 8B on the 4090, and what to touch when it gets tight:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th style="text-align:right">Approx. VRAM&lt;/th>
&lt;th>Lever if there is an OOM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8B NF4 base (frozen)&lt;/td>
&lt;td style="text-align:right">~4.0 GB&lt;/td>
&lt;td>— (fixed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter + grad + Adam states&lt;/td>
&lt;td style="text-align:right">~0.3-0.7 GB&lt;/td>
&lt;td>lower &lt;code>r&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Activations (batch × sequence)&lt;/td>
&lt;td style="text-align:right">~6-14 GB&lt;/td>
&lt;td>lower &lt;code>max_length&lt;/code>, &lt;code>batch_size&lt;/code>; raise &lt;code>grad_accum&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dequant buffers / workspace&lt;/td>
&lt;td style="text-align:right">~1-2 GB&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Table of quick OOM remedies, in order of cost: &lt;strong>(1)&lt;/strong> lower &lt;code>max_length&lt;/code>; &lt;strong>(2)&lt;/strong> confirm &lt;code>gradient_checkpointing=True&lt;/code>; &lt;strong>(3)&lt;/strong> raise &lt;code>gradient_accumulation_steps&lt;/code> and lower &lt;code>per_device_train_batch_size&lt;/code> to 1; &lt;strong>(4)&lt;/strong> use &lt;code>paged_adamw_8bit&lt;/code> (already in the script); &lt;strong>(5)&lt;/strong> as a last resort lower &lt;code>r&lt;/code>. If after all that it still does not fit, the sequence or the model is too large for 24 GB: either you bound it, or you move up the hardware.&lt;/p>
&lt;h2 id="phase-4--validating-the-adapter">Phase 4 — Validating the adapter&lt;/h2>
&lt;p>Never promote an adapter on the strength of the &lt;em>training loss&lt;/em>. Measure against the validation split you set aside and against a handful of real prompts.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># quick_eval.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">torch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">peft&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PeftModel&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bnb&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">BitsAndBytesConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">load_in_4bit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bnb_4bit_quant_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;nf4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">bnb_4bit_use_double_quant&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bnb_4bit_compute_dtype&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">torch&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bfloat16&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tok&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">base&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoModelForCausalLM&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen3-8B&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">quantization_config&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">bnb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">device_map&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PeftModel&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;adapters/soporte-redes-v1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># base + adapter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">msgs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;The north wing AP does not come up after the outage.&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tok&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">apply_chat_template&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msgs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_generation_prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">return_tensors&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;pt&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tok&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">max_new_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">256&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">skip_special_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For a serious verdict, run the adapter through your eval suite (the layer described by &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals&lt;/a>) and compare against the base &lt;strong>without&lt;/strong> an adapter and against the previous version of the adapter. Promote only if it wins on the task metric without regressing on safety/format.&lt;/p>
&lt;h2 id="phase-5--versioning-the-adapter-as-an-artefact">Phase 5 — Versioning the adapter as an artefact&lt;/h2>
&lt;p>The adapter is a pair of files of a few MB (&lt;code>adapter_model.safetensors&lt;/code> + &lt;code>adapter_config.json&lt;/code>). Treat it as a versioned, signed, traceable artefact, not as a loose file.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Reproducible checksum + upload to internal object storage (MinIO/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sha256sum adapters/soporte-redes-v1/adapter_model.safetensors &amp;gt; adapters/soporte-redes-v1/SHA256
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">aws --endpoint-url https://minio.interno s3 cp &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> adapters/soporte-redes-v1/ s3://adapters/soporte-redes/v1/ --recursive
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A convention that works: &lt;code>s3://adapters/&amp;lt;task-or-customer&amp;gt;/&amp;lt;version&amp;gt;/&lt;/code>. Immutable per version, with its &lt;code>SHA256&lt;/code>. Deleting a customer is deleting a prefix of a few MB, not retraining anything. Versioning 500 adapters costs what versioning 500 heavy configuration files costs.&lt;/p>
&lt;h2 id="phase-6--serving-in-multi-lora-with-vllm">Phase 6 — Serving in multi-LoRA with vLLM&lt;/h2>
&lt;p>The consumer loads &lt;strong>one&lt;/strong> shared base and applies the adapter delta per request. Startup with static adapters declared:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">VLLM_ALLOW_RUNTIME_LORA_UPDATING&lt;/span>&lt;span class="o">=&lt;/span>True &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span>vllm serve Qwen/Qwen3-8B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-lora &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-loras &lt;span class="m">8&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># max nº of DIFFERENT adapters per batch (not the total loadable)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --max-lora-rank &lt;span class="m">8&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># = the maximum rank of your adapters; do not inflate it (wastes memory)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --max-cpu-loras &lt;span class="m">64&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># adapters cached in RAM for fast swap to VRAM&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --lora-modules soporte-redes&lt;span class="o">=&lt;/span>/srv/adapters/soporte-redes/v1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each flag, what it controls and how to size it:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Flag / variable&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>How to size it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>--enable-lora&lt;/code>&lt;/td>
&lt;td>turns on adapter support&lt;/td>
&lt;td>mandatory; without it, vLLM ignores any &lt;code>model&lt;/code> that is an adapter.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-loras 8&lt;/code>&lt;/td>
&lt;td>nº of &lt;strong>different adapters in a single batch&lt;/strong>&lt;/td>
&lt;td>more adapters per batch makes the SGMV kernels more expensive; 8-32 is reasonable. It is not the total loadable.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-lora-rank 8&lt;/code>&lt;/td>
&lt;td>maximum rank the server reserves&lt;/td>
&lt;td>set it &lt;strong>equal to the real rank&lt;/strong> of your adapters (8 here); inflating it wastes VRAM and performance.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--max-cpu-loras 64&lt;/code>&lt;/td>
&lt;td>adapters cached in RAM ready to page to VRAM&lt;/td>
&lt;td>≥ nº of active adapters; it is the &amp;ldquo;bench&amp;rdquo; from which fast swapping happens.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>--lora-modules name=path&lt;/code>&lt;/td>
&lt;td>declares &lt;strong>static&lt;/strong> adapters at startup&lt;/td>
&lt;td>useful for the fixed ones; leave it out if everything goes through dynamic loading/Resolver.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING=True&lt;/code>&lt;/td>
&lt;td>enables the hot load/unload endpoints&lt;/td>
&lt;td>essential for &lt;code>/v1/load_lora_adapter&lt;/code>; without it, the server is static.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>--max-loras&lt;/code> limits the different adapters &lt;strong>per batch&lt;/strong>, not how many you can have loaded; the bulk lives on CPU (&lt;code>--max-cpu-loras&lt;/code>) and is paged to VRAM on demand. Set &lt;code>--max-lora-rank&lt;/code> to the real rank (8 here): inflating it wastes memory and performance. Requests pick the adapter through the &lt;code>model&lt;/code> field:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl http://localhost:8000/v1/chat/completions -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model&amp;#34;: &amp;#34;soporte-redes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [{&amp;#34;role&amp;#34;:&amp;#34;user&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;The north wing AP does not come up after the outage.&amp;#34;}]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># model:&amp;#34;Qwen/Qwen3-8B&amp;#34; (no adapter) uses the bare base on the same server&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Hot loading&lt;/strong> of a new adapter without restarting (thanks to &lt;code>VLLM_ALLOW_RUNTIME_LORA_UPDATING=True&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8000/v1/load_lora_adapter -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_name&amp;#34;: &amp;#34;cliente-42&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;lora_path&amp;#34;: &amp;#34;/srv/adapters/cliente-42/v3&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and to free VRAM/CPU when a customer goes idle:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST http://localhost:8000/v1/unload_lora_adapter -H &lt;span class="s2">&amp;#34;Content-Type: application/json&amp;#34;&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;lora_name&amp;#34;:&amp;#34;cliente-42&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For multi-tenant at scale, avoid declaring hundreds of adapters by hand: the &lt;strong>LoRAResolver&lt;/strong> resolves and loads the adapter from local storage or S3 the first time an unknown &lt;code>model&lt;/code> arrives, so the server stays lean and the adapters are pulled lazily from your MinIO. The internals of &lt;em>how&lt;/em> thousands of concurrent adapters are batched (SGMV kernels, unified paging, the heterogeneous gather/scatter) are in &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>; this runbook only switches them on. To squeeze the base&amp;rsquo;s decode throughput on a 4090, combine this with &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a>.&lt;/p>
&lt;h2 id="serving-multi-adapter-vs-merging-per-task">Serving multi-adapter vs merging per task&lt;/h2>
&lt;p>Two deployment architectures, and the procedure changes:&lt;/p>
&lt;p>&lt;strong>Serving multi-LoRA (the above).&lt;/strong> A shared base + N hot adapters. It is the default sovereign pattern: minimum footprint, per-customer isolation, hot-swap. Use standard QLoRA and merge nothing.&lt;/p>
&lt;p>&lt;strong>Merging per task.&lt;/strong> If you want a single quantised-and-adapted artefact per task (no adapter at runtime), do not merge a standard QLoRA adapter into the 4-bit base: the merge reintroduces precision that NF4 does not represent and on requantising you lose part of what was learnt. For that case, train with &lt;strong>QA-LoRA&lt;/strong> (quantization-aware), which merges cleanly onto a quantised base. It is an architecture decision, not a quality one; the conceptual detail is in the &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">fundamentals post&lt;/a>.&lt;/p>
&lt;h2 id="checklist-of-operational-gotchas">Checklist of operational gotchas&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Coherent chat template&lt;/strong> between training and serving. The most common and most silent mismatch: you train with one &lt;code>chat_template&lt;/code> and serve with another. Use the base&amp;rsquo;s on both sides.&lt;/li>
&lt;li>&lt;strong>Exactly the same base&lt;/strong> (revision included) in &lt;code>train&lt;/code> and &lt;code>serve&lt;/code>. An adapter trained on &lt;code>Qwen3-8B&lt;/code> is not valid on another revision of the model.&lt;/li>
&lt;li>&lt;strong>&lt;code>--max-lora-rank&lt;/code> ≥ the rank of ALL the adapters&lt;/strong> served together, but no more: inflating it wastes VRAM.&lt;/li>
&lt;li>&lt;strong>KV budget vs &lt;code>--max-loras&lt;/code>.&lt;/strong> The bottleneck in serving is not the adapters (MB), it is the KV cache and concurrency; see &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline&lt;/a> for the SLM regime.&lt;/li>
&lt;li>&lt;strong>&lt;code>r&lt;/code> too low&lt;/strong> = underfitting if the task demands rewriting a lot of behaviour. Raise &lt;code>r&lt;/code> only if the eval asks for it.&lt;/li>
&lt;li>&lt;strong>Do not promote on training loss.&lt;/strong> Validate against the reserved split + real prompts + a safety regression.&lt;/li>
&lt;li>&lt;strong>Version and make immutable&lt;/strong> every adapter with its &lt;code>SHA256&lt;/code>; never overwrite a served version.&lt;/li>
&lt;/ul>
&lt;h2 id="applied-to-on-premise-infrastructure">Applied to on-premise infrastructure&lt;/h2>
&lt;p>On an &lt;strong>RTX 4090 (24 GB)&lt;/strong> the same machine is producer and consumer: you train a customer&amp;rsquo;s adapter in hours and serve it on the same server on top of the shared base. It is the canonical case for multi-tenant demos and platform prototypes.&lt;/p>
&lt;p>On a &lt;strong>generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/strong> QLoRA stops being necessary in order to &lt;em>fit&lt;/em>, but it serves to &lt;strong>parallelise production&lt;/strong> (several adapter jobs at once) and to keep the quantised format consistent between training and serious serving of hundreds of concurrent adapters. The base can run in native FP8; the mechanics of the runbook do not change, only the scale.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit on small models&lt;/a> — the fundamentals post: the why of NF4, double quantisation, paged optimizers and the adapter maths. This runbook is its executable face.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the consumer internals we only switch on here: SGMV, unified paging, heterogeneous batching of thousands of adapters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — how to squeeze the decode throughput of the base you serve the adapters on top of, on a 4090.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — where the Phase 1 dataset comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals: the layer after tracing&lt;/a> — how to validate the Phase 4 adapter with judgement, not with the training loss.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline on small models&lt;/a> — the performance regime that explains why the serving bottleneck is the KV cache, not the adapters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive quantisation: from 4-bit to ternary&lt;/a> — what happens to the quantised base below NF4 underneath the adapter.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em>. NeurIPS 2023. &lt;a href="https://arxiv.org/abs/2305.14314">https://arxiv.org/abs/2305.14314&lt;/a>&lt;/li>
&lt;li>Hugging Face TRL — PEFT integration (SFTTrainer + QLoRA): &lt;a href="https://huggingface.co/docs/trl/peft_integration">https://huggingface.co/docs/trl/peft_integration&lt;/a>&lt;/li>
&lt;li>Hugging Face PEFT: &lt;a href="https://github.com/huggingface/peft">https://github.com/huggingface/peft&lt;/a>&lt;/li>
&lt;li>bitsandbytes: &lt;a href="https://github.com/bitsandbytes-foundation/bitsandbytes">https://github.com/bitsandbytes-foundation/bitsandbytes&lt;/a>&lt;/li>
&lt;li>vLLM — LoRA Adapters (serving, dynamic loading, LoRAResolver): &lt;a href="https://docs.vllm.ai/en/stable/features/lora/">https://docs.vllm.ai/en/stable/features/lora/&lt;/a>&lt;/li>
&lt;li>Axolotl: &lt;a href="https://github.com/axolotl-ai-cloud/axolotl">https://github.com/axolotl-ai-cloud/axolotl&lt;/a>&lt;/li>
&lt;li>Xu, Y. et al. &lt;em>QA-LoRA: Quantization-Aware Low-Rank Adaptation&lt;/em>. ICLR 2024. &lt;a href="https://arxiv.org/abs/2309.14717">https://arxiv.org/abs/2309.14717&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>QLoRA and multi-LoRA at the limit on small models</title><link>https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/</link><pubDate>Tue, 09 Jun 2026 02:30:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/</guid><description>&lt;blockquote>
&lt;p>This post is the training-side companion to &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>. That one takes apart the &lt;strong>consumer&lt;/strong>, how hundreds of concurrent adapters are served with SGMV kernels and unified paging; this one takes apart the &lt;strong>producer&lt;/strong>, how an adapter is trained on top of a quantised base on a single GPU, and why the pattern &amp;ldquo;one frozen base SLM + N low-rank adapters&amp;rdquo; is the natural fit for small models. We do not repeat the serving internals here; we assume you have read them.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>QLoRA&lt;/strong> (Dettmers et al., NeurIPS 2023) solves a specific problem: fine-tuning a model without having the VRAM to hold its weights in BF16, its gradients and the optimiser states. The idea has three pieces. One: freeze the base and quantise it to 4-bit with a new format, &lt;strong>NF4&lt;/strong> (NormalFloat 4-bit), quantile-optimal for weights distributed almost like a Gaussian. Two: do not train the base, not a single one of its weights moves, but rather a pair of small LoRA matrices in BF16 plugged in parallel; the gradient flows only through that adapter. Three: two memory tricks, &lt;em>double quantisation&lt;/em> (quantising the quantisation constants themselves) and &lt;em>paged optimizers&lt;/em> (optimiser states that page out to RAM when VRAM gets tight). The measurable operational result: a 3-8B SLM is fine-tuned on an &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong>, not on a cluster. And because the output of training is an adapter measured in &lt;strong>megabytes, not gigabytes&lt;/strong>, the pattern that emerges is a single frozen base SLM in 4-bit plus N adapters, one per customer, domain or task, served on top of the shared base with the stack we already covered in multi-LoRA serving. Per-customer isolation, minimum footprint, sovereign deployment.&lt;/p>
&lt;h2 id="the-analogy-the-frozen-guitar-and-the-swappable-pedalboard">The analogy: the frozen guitar and the swappable pedalboard&lt;/h2>
&lt;p>Think of a session guitarist recording for very different clients: a jazz record, a corporate jingle, a metal track. He has &lt;strong>one single guitar&lt;/strong>, his trusted instrument, set up, with a base tone he knows by heart. What he does &lt;strong>not&lt;/strong> do is buy a new guitar for every song. What he does is keep a &lt;strong>pedalboard&lt;/strong>: a distortion pedal, a chorus, a delay. For each track he plugs in the pedal that fits, and the same guitar sounds completely different.&lt;/p>
&lt;p>The mapping is exact:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The guitar&lt;/strong> = the base SLM. A single copy, set up at the factory, frozen. In QLoRA it is also &lt;em>kept in a compressed case&lt;/em>: quantised to 4-bit. You do not touch it: you do not change its pickups or adjust the neck. It weighs what it weighs and there it stays.&lt;/li>
&lt;li>&lt;strong>Each pedal&lt;/strong> = a LoRA adapter. Small, cheap, specific to one sound. You train it for a task and put it in a drawer.&lt;/li>
&lt;li>&lt;strong>Training QLoRA&lt;/strong> = designing a new pedal by listening to the (frozen) guitar through it, adjusting only the pedal&amp;rsquo;s pots until it sounds the way you want. The guitar&amp;rsquo;s base tone is not modified; you learn the &lt;strong>correction&lt;/strong> the pedal applies on top.&lt;/li>
&lt;li>&lt;strong>Serving multi-LoRA&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>) = having the whole pedalboard set up on stage and choosing the right pedal &lt;strong>per note&lt;/strong>, per request. The guitar is the same; what changes between requests is which pedal is active.&lt;/li>
&lt;/ul>
&lt;p>The analogy holds down to the detail that confuses people most: &lt;strong>the training gradient only &amp;ldquo;touches&amp;rdquo; the pedal&lt;/strong>. The guitar is frozen in its compressed case; learning does not move it. That is what lets the base live in 4-bit throughout the fine-tuning without the quantisation getting in the way: no gradient is ever computed for it.&lt;/p>
&lt;h2 id="the-bare-mechanism-lora-and-why-you-can-train-on-a-4-bit-base">The bare mechanism: LoRA, and why you can train on a 4-bit base&lt;/h2>
&lt;p>A minimal reminder of LoRA (Hu et al., ICLR 2022). An adapter modifies a matrix &lt;code>W&lt;/code> of the base by adding a low-rank product to it:&lt;/p>
$$W' = W + B A, \qquad A \in \mathbb{R}^{r \times d}, \quad B \in \mathbb{R}^{d \times r}$$
&lt;p>with &lt;code>r&lt;/code> the &lt;strong>rank&lt;/strong>, much smaller than &lt;code>d&lt;/code>. In the forward pass &lt;code>BA&lt;/code> is not materialised; what is computed is:&lt;/p>
$$y = W x + B(A x)$$
&lt;p>The base compute (&lt;code>Wx&lt;/code>) happens just the same; the adapter adds two cheap matmuls. The key to QLoRA is who receives a gradient. The base &lt;code>W&lt;/code> is frozen: &lt;code>∂L/∂W&lt;/code> is neither computed nor stored. Only &lt;code>A&lt;/code> and &lt;code>B&lt;/code> are trainable. That is why &lt;code>W&lt;/code> can live quantised to 4-bit without trouble: in the forward pass the quantisation is &lt;em>undone&lt;/em> on the fly to compute &lt;code>Wx&lt;/code> (dequant → matmul in BF16), but since &lt;code>W&lt;/code> is never updated it does not need the precision of a trainable weight. The adapter &lt;code>A, B&lt;/code> is in BF16, and it is the only path the gradient flows through.&lt;/p>
&lt;p>This is what breaks the memory wall. In a full fine-tuning you need, per weight: the weight (2 bytes BF16), its gradient (2 bytes), and the two Adam states (momentum and variance, typically 4+4 bytes in FP32), on the order of &lt;strong>12-16 bytes per trainable parameter&lt;/strong>. With QLoRA, the base weights take &lt;strong>0.5 bytes&lt;/strong> (4-bit) and have no gradient and no optimiser states. Only the few million adapter parameters pay the 16-byte cost. For an 8B, that is the difference between ~130 GB and fitting in 24 GB.&lt;/p>
&lt;h3 id="nf4-why-a-new-format-instead-of-int4">NF4: why a new format instead of INT4&lt;/h3>
&lt;p>QLoRA does not use linear INT4 for the base, but &lt;strong>NF4 (NormalFloat 4-bit)&lt;/strong>. The intuition: the weights of a trained transformer are distributed, empirically, very close to a &lt;strong>zero-centred Gaussian&lt;/strong>. INT4 spreads its 16 levels uniformly across the range, which wastes levels in the tails (where there are almost no weights) and leaves few in the centre (where they pile up). NF4 spreads the 16 levels according to the &lt;strong>quantiles&lt;/strong> of a normal distribution: more levels where there is more probability mass. It is, by construction, &lt;em>information-theoretically optimal&lt;/em> for exactly Gaussian data, each level covers roughly the same number of weights. It is also symmetric about zero and guarantees an exact representation of 0 (important for sparsity and padding). The detail of the quantisation formats is in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a>; here the idea that NF4 spends its bits where the weights are is enough.&lt;/p>
&lt;h3 id="double-quantisation-and-paged-optimizers">Double quantisation and paged optimizers&lt;/h3>
&lt;p>Quantising to 4-bit is not entirely free: for each block of weights (typically 64) you need to store a scale constant in FP32 so the quantisation can be undone. Those constants weigh something. With blocks of 64 and one FP32 scale (32 bits) per block, that is &lt;code>32/64 = 0.5 bits per parameter&lt;/code> in metadata alone, a 12.5 % overhead on top of the 4 useful bits. &lt;strong>Double quantisation&lt;/strong> attacks that: it quantises the scale constants themselves (to 8-bit, in blocks of 256), bringing the overhead down to ~&lt;code>0.127 bits/param&lt;/code>. Quantising the quantisation sounds recursive and it is; the saving is small in absolute terms (~0.37 bits/param) but on an 8B that is hundreds of MB, which is exactly the margin separating &amp;ldquo;it fits&amp;rdquo; from &amp;ldquo;it does not fit&amp;rdquo; on a 4090.&lt;/p>
&lt;p>&lt;strong>Paged optimizers&lt;/strong> attack the memory peaks. During training, certain moments, a batch with a very long sequence, a large activation, push VRAM close to the limit and blow up with an OOM. The idea, borrowed from operating system paging, is to allocate the optimiser states in NVIDIA &lt;em>unified&lt;/em> memory: when VRAM gets tight, those pages are &lt;strong>evicted to host RAM&lt;/strong> automatically and brought back when needed. It speeds nothing up; it &lt;strong>avoids the crash&lt;/strong> at the peaks. It turns an &amp;ldquo;intermittent OOM&amp;rdquo; into &amp;ldquo;a bit slower at the worst moments&amp;rdquo;, which for an unattended training run on a single GPU is the difference between finishing and not finishing.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="QLoRA: frozen 4-bit base, BF16 adapter and the gradient flowing only through the adapter">
&lt;defs>&lt;marker id="qm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;marker id="qg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#a52a2a"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" font-family="sans-serif" font-size="13" font-weight="600" fill="currentColor">Forward (blue) onwards · Gradient (red) only through the adapter&lt;/text>
&lt;rect x="30" y="120" width="90" height="44" rx="6" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="75" y="140" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">x&lt;/text>
&lt;text x="75" y="156" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">input&lt;/text>
&lt;rect x="200" y="60" width="200" height="60" rx="8" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="300" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">W · x (frozen base)&lt;/text>
&lt;text x="300" y="102" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">NF4 4-bit · dequant on the fly · NO gradient&lt;/text>
&lt;rect x="200" y="170" width="200" height="84" rx="8" fill="#fff4d6" stroke="#a48000" stroke-width="1.6"/>
&lt;text x="300" y="192" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#7a5e00">LoRA adapter (BF16)&lt;/text>
&lt;rect x="220" y="202" width="74" height="40" rx="5" fill="#fffbe9" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="257" y="220" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#7a5e00">A: r×d&lt;/text>
&lt;text x="257" y="234" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">shrink d→r&lt;/text>
&lt;rect x="306" y="202" width="74" height="40" rx="5" fill="#fffbe9" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="343" y="220" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#7a5e00">B: d×r&lt;/text>
&lt;text x="343" y="234" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">expand r→d&lt;/text>
&lt;circle cx="500" cy="150" r="26" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.6"/>
&lt;text x="500" y="148" text-anchor="middle" font-family="sans-serif" font-size="15" font-weight="600" fill="#1d5a2e">+&lt;/text>
&lt;text x="500" y="163" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#1d5a2e">sum&lt;/text>
&lt;rect x="600" y="128" width="90" height="44" rx="6" fill="#eef2f6" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="645" y="148" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">y&lt;/text>
&lt;text x="645" y="164" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#555">output&lt;/text>
&lt;path d="M120,142 L195,92" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M120,144 L195,205" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M400,90 L476,140" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M400,210 L476,162" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M526,150 L598,150" stroke="#1f5fa8" stroke-width="1.6" fill="none" marker-end="url(#qm)"/>
&lt;path d="M495,178 C470,250 410,258 386,256" stroke="#a52a2a" stroke-width="1.8" fill="none" stroke-dasharray="6 3" marker-end="url(#qg)"/>
&lt;text x="430" y="290" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#a52a2a">∂L/∂A , ∂L/∂B — the gradient only enters the adapter&lt;/text>
&lt;text x="300" y="50" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#1f5fa8">the base gets NO gradient: that is why it can live in 4-bit&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="aggressive-training-very-low-rank-and-qa-lora">&amp;ldquo;Aggressive&amp;rdquo; training: very low rank and QA-LoRA&lt;/h2>
&lt;p>&amp;ldquo;Aggressive&amp;rdquo; in this context means two things, sometimes combined.&lt;/p>
&lt;p>&lt;strong>Very low rank (r = 4-8).&lt;/strong> The rank is the bottleneck of the correction: how much &amp;ldquo;capacity&amp;rdquo; the adapter has to steer the base away from itself. A high rank (64, 128) brings the adapter closer to a full fine-tuning but weighs more and takes longer to train. For an SLM adapted to a narrow, well-defined task, an output format, a lexical domain, a response style, a rank of 4-8 is usually enough, and the resulting adapter weighs a fraction. The risk of a low rank is &lt;em>underfitting&lt;/em>: if the task demands rewriting a lot of the base&amp;rsquo;s behaviour, r=4 falls short. The honest rule is empirical: raise the rank only if the eval asks for it, not &amp;ldquo;just in case&amp;rdquo;. On small SLMs, where the base has less spare capacity, a low rank tends to work proportionally better than on large models, but this depends on the task and has to be measured, not assumed.&lt;/p>
&lt;p>&lt;strong>QA-LoRA (quantization-aware LoRA, Xu et al., arXiv:2309.14717).&lt;/strong> There is a subtle friction in standard QLoRA: you train the adapter in BF16 against a 4-bit base, but if you then want to &lt;strong>merge&lt;/strong> the adapter into the base (&lt;code>W' = W + BA&lt;/code>) to serve a clean quantised model, the merge reintroduces precision that the 4-bit format cannot represent, and on requantising you lose part of what was learnt. QA-LoRA trains the adapter &lt;strong>aware of the target&amp;rsquo;s quantisation&lt;/strong>: it balances the degrees of freedom of the quantisation and of the adaptation (with group-wise quantisation) so that, when it finishes, the adapter &lt;strong>merges cleanly&lt;/strong> into a quantised base without a requantisation step that degrades it. The result is a final model that is quantised-plus-adapted, with no separate adapter at runtime, useful when you want a single deployable artefact per task instead of the shared-base + adapters pattern. The choice between &amp;ldquo;QLoRA + serve multi-adapter&amp;rdquo; and &amp;ldquo;QA-LoRA + merge per task&amp;rdquo; is a deployment architecture decision, not one of pure quality.&lt;/p>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three calculations drive any QLoRA decision on SLMs.&lt;/p>
&lt;p>&lt;strong>Adapter parameters.&lt;/strong> For each target matrix of dimension &lt;code>d&lt;/code> with rank &lt;code>r&lt;/code>, the adapter contributes &lt;code>A&lt;/code> (r×d) plus &lt;code>B&lt;/code> (d×r), that is &lt;code>2·r·d&lt;/code> parameters. Summing over the target matrices and multiplying by the number of layers:&lt;/p>
$$\text{params}_{\text{adapter}} = L \cdot \sum_{\text{matrices}} 2 \cdot r \cdot d$$
&lt;p>&lt;strong>Worked example — Llama-3-8B, attention (q, k, v, o), &lt;code>d = 4096&lt;/code>, &lt;code>L = 32&lt;/code> layers, &lt;code>r = 8&lt;/code>.&lt;/strong> Taking the four attention projections with the same &lt;code>d = 4096&lt;/code> (a simplification; in Llama-3 K and V are narrower because of GQA, which gives even fewer params):&lt;/p>
$$\text{params} \approx 32 \cdot 4 \cdot (2 \cdot 8 \cdot 4096) = 32 \cdot 4 \cdot 65\,536 \approx 8.4\text{M params}$$
&lt;p>In BF16 (2 bytes/param): &lt;code>8.4M · 2 ≈ 16.8 MB ≈ ~17 MB&lt;/code>. &lt;strong>Seventeen megabytes.&lt;/strong> Compare that with the base: an 8B in NF4 takes &lt;code>8\text{G} · 0.5\,\text{bytes} ≈ 4\text{ GB}&lt;/code> (plus the small constants overhead after double quantisation). The adapter is &lt;strong>0.4 %&lt;/strong> of the size of the quantised base. This is what makes having hundreds operationally trivial: an adapter is not a model, it is almost a heavy configuration file.&lt;/p>
&lt;p>&lt;strong>How many adapters fit on a 4090 after the base + KV?&lt;/strong> Budget for an RTX 4090 (24 GB): 8B NF4 base ~4 GB, leave ~5 GB for KV cache and inference activations at moderate concurrency → that leaves &lt;strong>~15 GB free&lt;/strong> (being conservative, call it ~12-15 GB). With ~17 MB adapters (r=8, attention-only):&lt;/p>
$$\frac{15\,000\ \text{MB}}{17\ \text{MB/adapter}} \approx 880 \text{ adapters}$$
&lt;p>On the order of thousands if you cut the reserved KV cache or use rank 4 (~8.5 MB/adapter → ~1750 in 15 GB). The bottleneck is never the adapters&amp;rsquo; space; it is the KV cache and concurrency. For the details of how those thousands are served concurrently, the heterogeneous batching, the unified paging, the SGMV kernels, see &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>. The relevant summary here: &lt;strong>the adapter compute is nearly free&lt;/strong> (low rank, two thin matmuls); the serving performance challenge is not that compute but the &lt;em>gather/scatter&lt;/em> of the right adapters per batch row when a single batch mixes requests from different adapters. That is the consumer&amp;rsquo;s problem, not the producer&amp;rsquo;s.&lt;/p>
&lt;p>&lt;strong>QLoRA training VRAM in 24 GB.&lt;/strong> The rough budget to fine-tune the 8B on a 4090:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Approx. VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8B base in NF4 (frozen weights)&lt;/td>
&lt;td>~4.0 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Adapter (BF16 params + gradient + Adam states, ~16 B/param over ~8-40M params)&lt;/td>
&lt;td>~0.3-0.7 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Activations (depends on batch and sequence length; the variable bulk)&lt;/td>
&lt;td>~6-14 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dequant buffers, scales, workspace&lt;/td>
&lt;td>~1-2 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>fits in 24 GB with margin&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The large, variable piece is the activations, which scale with batch × sequence length. That is why real QLoRA on a 4090 is done with a small batch + &lt;em>gradient accumulation&lt;/em> (simulating a large batch by accumulating gradients over microbatches) + &lt;em>gradient checkpointing&lt;/em> (recomputing activations in the backward pass instead of storing them, trading compute for memory) + bounded sequences. The &lt;strong>paged optimizers&lt;/strong> are the airbag for the activation peaks that would otherwise blow up. The claim &amp;ldquo;QLoRA fine-tunes an 8B on a 4090&amp;rdquo; is true with that configuration; with long sequences, a large batch or a high rank, it does not fit. As with any number, the methodology matters more than the headline.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A shared 4-bit base SLM with N adapters per customer, heterogeneous batching">
&lt;defs>&lt;marker id="bm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" font-family="sans-serif" font-size="13" font-weight="600" fill="currentColor">Heterogeneous batch: 4 requests, 3 customers, 3 adapters — one shared base SLM&lt;/text>
&lt;rect x="20" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="80" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_1 → customer A&lt;/text>
&lt;rect x="150" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="210" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_2 → customer A&lt;/text>
&lt;rect x="280" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="340" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_3 → customer B&lt;/text>
&lt;rect x="410" y="40" width="120" height="36" rx="5" fill="#f6e0c8" stroke="#a76b1f" stroke-width="1.3"/>
&lt;text x="470" y="63" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#7a4d12">req_4 → customer C&lt;/text>
&lt;rect x="20" y="110" width="510" height="50" rx="8" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="275" y="132" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">BASE SLM — Llama-3-8B NF4 (~4 GB) — loaded ONCE, shared&lt;/text>
&lt;text x="275" y="150" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">W·x is computed the same for all 4 requests, whatever the adapter&lt;/text>
&lt;text x="560" y="100" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">Pedalboard&lt;/text>
&lt;text x="560" y="114" font-family="sans-serif" font-size="10" fill="#555">(adapters ~17 MB)&lt;/text>
&lt;rect x="560" y="120" width="200" height="120" rx="6" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;rect x="572" y="132" width="176" height="22" rx="3" fill="#fffbe9" stroke="#a48000" stroke-width="1"/>&lt;text x="660" y="147" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">adapter A (customer A)&lt;/text>
&lt;rect x="572" y="160" width="176" height="22" rx="3" fill="#fffbe9" stroke="#a48000" stroke-width="1"/>&lt;text x="660" y="175" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">adapter B (customer B)&lt;/text>
&lt;rect x="572" y="188" width="176" height="22" rx="3" fill="#fffbe9" stroke="#a48000" stroke-width="1"/>&lt;text x="660" y="203" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">adapter C (customer C)&lt;/text>
&lt;text x="660" y="228" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#7a5e00">... thousands more, MB each&lt;/text>
&lt;path d="M80,76 L200,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;path d="M210,76 L240,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;path d="M340,76 L300,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;path d="M470,76 L360,108" stroke="#666" stroke-width="1.3" fill="none" marker-end="url(#bm)"/>
&lt;text x="20" y="200" font-family="sans-serif" font-size="11" fill="#555">The adapter delta is applied per batch row:&lt;/text>
&lt;text x="20" y="216" font-family="sans-serif" font-size="11" fill="#555">reqs 1-2 → adapter A · req 3 → adapter B · req 4 → adapter C&lt;/text>
&lt;text x="20" y="232" font-family="sans-serif" font-size="11" font-weight="600" fill="#a52a2a">The challenge is NOT the delta compute (nearly free) — it is the heterogeneous gather/scatter.&lt;/text>
&lt;text x="20" y="270" font-family="sans-serif" font-size="10" fill="#555">Internals (SGMV, unified paging, heterogeneous batching): see Multi-LoRA serving.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-fit-with-small-models-and-sovereignty">The fit with small models and sovereignty&lt;/h2>
&lt;p>This is where QLoRA + SLM stops being a VRAM trick and becomes an architectural pattern.&lt;/p>
&lt;p>An SLM (3-8B) already fits comfortably on a single GPU for inference. If on top of that the base lives in 4-bit (~4 GB for an 8B), you have memory to spare. What QLoRA enables is for that same machine, the 4090, to be &lt;strong>both the producer and the consumer&lt;/strong>: you train a new customer&amp;rsquo;s adapter in hours, on the same class of hardware where you then serve it. The artefact that travels between &amp;ldquo;train&amp;rdquo; and &amp;ldquo;deploy&amp;rdquo; is an adapter of &lt;strong>MB, not GB&lt;/strong>: it is versioned, signed, moved across the network, stored in MinIO/S3 without thinking about the cost.&lt;/p>
&lt;p>The sovereign pattern falls out on its own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-customer isolation.&lt;/strong> Each customer has their adapter, trained only on their data. The base is generic and shared; what is specific to the customer lives isolated in their &lt;code>(A, B)&lt;/code> pair. Deleting a customer is deleting a file of a few MB, not retraining anything.&lt;/li>
&lt;li>&lt;strong>Minimum footprint.&lt;/strong> One base + N adapters fits where N bases would not come close. The economics of &amp;ldquo;one model per customer&amp;rdquo; (tens of GB each) are prohibitive; those of &amp;ldquo;one base + adapters&amp;rdquo; (MB each) are trivial. It is exactly the difference between the pedalboard and buying a guitar per song.&lt;/li>
&lt;li>&lt;strong>Sovereign deployment.&lt;/strong> Everything fits on-premise, on your hardware, without a single data item leaving the perimeter. Training (QLoRA on the 4090) and serving (multi-LoRA on the same base) live inside. There is no dependency on an external API for fine-tuning or for serving.&lt;/li>
&lt;/ul>
&lt;p>The choice between &lt;strong>adapting by domain&lt;/strong> (one adapter per knowledge area) and &lt;strong>retrieving by context&lt;/strong> (RAG that injects the knowledge into the prompt) is real and not mutually exclusive: the adapter changes the model&amp;rsquo;s &lt;em>behaviour&lt;/em> and &lt;em>style&lt;/em>, RAG changes the &lt;em>facts&lt;/em> it has access to. The sibling post on &lt;strong>aggressive RAG on small models&lt;/strong> in this series works through it; the short rule is: adapt what is stable and behavioural, retrieve what is volatile and factual.&lt;/p>
&lt;h2 id="applied-to-on-premise-infrastructure">Applied to on-premise infrastructure&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace">On an RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>This is QLoRA&amp;rsquo;s natural workbench. The canonical case: &lt;strong>a 3-8B base SLM in NF4, fine-tuning an r=8 attention-only adapter&lt;/strong>, with gradient checkpointing + gradient accumulation + paged optimizer. It trains in hours for narrow-task datasets (thousands to tens of thousands of examples), and the same machine then serves the base + tens or hundreds of adapters for multi-tenant demos and platform prototypes. The 4090 is where QLoRA went from &amp;ldquo;a paper technique&amp;rdquo; to &amp;ldquo;anyone with a consumer GPU can do it&amp;rdquo;, and that is exactly its value. The honest rule: it fits &lt;strong>with&lt;/strong> the memory configuration described; with long sequences, a large batch or a high rank, move up the hardware.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here QLoRA stops being strictly necessary in order to &lt;em>fit&lt;/em>, an 8B in BF16 has room to spare, but it remains useful for another reason: &lt;strong>parallelising adapter production&lt;/strong>. With 320 GB and native FP8 you can train several adapters at once (one job per customer, several in parallel), or fine-tune somewhat larger models with QLoRA without TP. The consumer on this cluster is the serious setup from &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>: an FP8 base + hundreds of concurrent adapters. The rule of thumb: on the 4090, QLoRA is the tool to &lt;em>be able to&lt;/em> fine-tune; on the H100 cluster, it is the tool to fine-tune &lt;em>many at once, cheaply&lt;/em>, keeping the quantised format consistent between training and serving.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The internals of heterogeneous serving&lt;/strong> (SGMV kernels, MBGMM/MBGMV, unified paging, cold start, eviction): they are covered in full in &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>. This post is deliberately the producer&amp;rsquo;s side.&lt;/li>
&lt;li>&lt;strong>DoRA and variants&lt;/strong> (magnitude-direction decomposition): they close part of the gap with full fine-tuning; different training pattern, identical serving pattern.&lt;/li>
&lt;li>&lt;strong>Sub-4-bit and ternary quantisation of the base&lt;/strong>: what happens when the base drops from NF4 to 2-bit or ternary underneath the adapter; the sibling post in the series works through it.&lt;/li>
&lt;li>&lt;strong>Collecting the fine-tuning dataset&lt;/strong>: how each adapter&amp;rsquo;s corpus is built from production feedback is in &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/">QLoRA runbook: from dataset to served adapter&lt;/a> — the operational companion to this post: the step-by-step executable procedure (environment, TRL/PEFT script, monitoring, versioning and serving in vLLM with hot loading). With commands.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the consumer: the internals of how thousands of concurrent adapters are served (SGMV, unified paging, heterogeneous batching). Read it: this post assumes everything about serving is known.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — the framework of formats (NF4, INT4, FP8, AWQ) that holds up the quantised base underneath the adapter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the alternative/complement to adapting: compressing the knowledge into the model itself instead of into an adapter on top.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — the operational cycle that continuously produces new adapters from production signal.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — where the dataset each QLoRA adapter is trained on comes from.&lt;/li>
&lt;li>&lt;strong>Inverted roofline on small models&lt;/strong> (sibling in the series) — the performance regime an SLM operates in, which explains why the adapter&amp;rsquo;s minimum footprint fits consumer GPUs.&lt;/li>
&lt;li>&lt;strong>Aggressive sub-4-bit / ternary quantisation&lt;/strong> (sibling in the series) — what happens to the quantised base below NF4 underneath the adapter.&lt;/li>
&lt;li>&lt;strong>Aggressive RAG on small models&lt;/strong> (sibling in the series) — adapting by domain (this post) versus retrieving by context; when to use each.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em>. NeurIPS 2023. &lt;a href="https://arxiv.org/abs/2305.14314">https://arxiv.org/abs/2305.14314&lt;/a>&lt;/li>
&lt;li>Hu, E., Shen, Y., Wallis, P., Allen-Zhu, Z., Li, Y., Wang, S., Wang, L., Chen, W. &lt;em>LoRA: Low-Rank Adaptation of Large Language Models&lt;/em>. ICLR 2022. &lt;a href="https://arxiv.org/abs/2106.09685">https://arxiv.org/abs/2106.09685&lt;/a>&lt;/li>
&lt;li>Xu, Y., Xie, L., Gu, X., Chen, X., Chang, H., Zhang, H., Chen, Z., Zhang, X., Tian, Q. &lt;em>QA-LoRA: Quantization-Aware Low-Rank Adaptation of Large Language Models&lt;/em>. ICLR 2024. &lt;a href="https://arxiv.org/abs/2309.14717">https://arxiv.org/abs/2309.14717&lt;/a>&lt;/li>
&lt;li>Sheng, Y. et al. &lt;em>S-LoRA: Serving Thousands of Concurrent LoRA Adapters&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2311.03285">https://arxiv.org/abs/2311.03285&lt;/a>&lt;/li>
&lt;li>Chen, L. et al. &lt;em>Punica: Multi-Tenant LoRA Serving&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2310.18547">https://arxiv.org/abs/2310.18547&lt;/a>&lt;/li>
&lt;li>Official QLoRA / bitsandbytes repo: &lt;a href="https://github.com/artidoro/qlora">https://github.com/artidoro/qlora&lt;/a>&lt;/li>
&lt;li>Hugging Face PEFT (LoRA, QLoRA): &lt;a href="https://github.com/huggingface/peft">https://github.com/huggingface/peft&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Aggressive RAG in small models: trading parameters for retrieval</title><link>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</link><pubDate>Tue, 09 Jun 2026 02:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</guid><description>&lt;blockquote>
&lt;p>This post belongs to the series on inference performance in small models. Its sibling piece, &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a>, explains why the compute-bound prefill is the bottleneck that shapes the whole discussion here. It is worth reading first: here we assume that &lt;strong>adding more context is not free&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An SLM (say 1B–8B parameters) knows &lt;strong>fewer facts&lt;/strong> than a 70B–700B model, simply because it has fewer weights in which to memorise them. But its ability to &lt;strong>reason over text in front of it&lt;/strong>, following instructions, extracting, synthesising, comparing, degrades far less with size than its encyclopaedic knowledge does. The operational consequence is direct: use the SLM as a &lt;strong>reasoning engine over curated context&lt;/strong>, not as a database. Move knowledge from the weights into the context via retrieval. The problem is that &amp;ldquo;aggressive retrieval&amp;rdquo; is often read as &amp;ldquo;put in lots of chunks&amp;rdquo;, and that collides head-on with three facts about SLMs: shorter context windows, worse use of long context (the &lt;em>lost in the middle&lt;/em> effect is more severe the smaller the model) and a &lt;strong>compute-bound prefill&lt;/strong> whose cost grows with the context length $C$, linearly in the projections and quadratically in attention. You cannot simply add tokens. Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb either cost. The way out is not to retrieve less, but to retrieve &lt;em>better&lt;/em>: reranking for precision over recall, compressing the context before injecting it, prefix caching the stable documents, semantic caching of answers, and structured output with external tools that stand in for internal knowledge. This post works through the maths and gives a TTFT number before and after compressing a context from 4,000 to 1,000 tokens on an RTX 4090.&lt;/p>
&lt;h2 id="the-analogy-the-open-book-exam">The analogy: the open-book exam&lt;/h2>
&lt;p>Two students sit the same exam. The first has a prodigious memory: they have memorised the entire syllabus, paragraph by paragraph. The second has an ordinary memory, forgetting dates, confusing names, but is allowed to bring in a &lt;strong>crib sheet&lt;/strong>.&lt;/p>
&lt;p>If the second student&amp;rsquo;s crib sheet is a chaos of piled-up photocopies, they lose: they take too long to find what they are after, get distracted by irrelevant pages and run out of time. But if their crib sheet is &lt;em>excellent&lt;/em>, trimmed to the essentials, reordered by relevance, with the important things at the top and no filler, then they not only do not lose: they often &lt;em>win&lt;/em>, because they reason just as well as the first student and, on top of that, work from verified material instead of from blurry recollections they may be inventing.&lt;/p>
&lt;p>The moral has three layers, and each maps to an engineering decision:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memorising everything is expensive.&lt;/strong> The first student invested months. A large model invests parameters, and VRAM, and inference FLOPs, in memorising facts.&lt;/li>
&lt;li>&lt;strong>The crib sheet matters more than its size.&lt;/strong> A well-made one-page crib sheet beats ten badly organised pages. More retrieved context is not better context: the material&amp;rsquo;s precision beats volume.&lt;/li>
&lt;li>&lt;strong>Knowing how to search and synthesise is a different skill from knowing.&lt;/strong> It is the one the SLM retains. The whole strategy consists of leaning on that skill and subcontracting the memory.&lt;/li>
&lt;/ul>
&lt;p>The rest of the post is, essentially, how to build an excellent crib sheet under the constraint that the student (the SLM) reads slowly and gets tired with long texts.&lt;/p>
&lt;h2 id="the-capacity-argument-how-many-facts-fit-in-the-weights">The capacity argument: how many facts fit in the weights&lt;/h2>
&lt;p>Let us start by justifying the thesis with an order of magnitude, not with faith. How much factual knowledge really fits in a model&amp;rsquo;s weights?&lt;/p>
&lt;p>There is a recurring empirical estimate in the interpretability and memorisation literature: a dense model can store on the order of &lt;strong>2 bits of memorised information per parameter&lt;/strong> before saturating (the exact figure varies by study and training regime; take it as an order of magnitude, not as a law). An 8B-parameter model then has an information storage ceiling on the order of:&lt;/p>
$$8 \times 10^9 \text{ params} \times 2 \text{ bits/param} = 1.6 \times 10^{10} \text{ bits} \approx 2 \text{ GB of information}$$
&lt;p>And that budget &lt;strong>is not only for facts&lt;/strong>: the vast majority goes on grammar, syntax, reasoning ability, code, formatting, and only a fraction is left for encyclopaedic knowledge. Compare it with the other side: a retrievable corpus of several million documents, a corporate wiki, a document repository, a technical knowledge base, easily takes up &lt;strong>hundreds of GB to terabytes&lt;/strong> of text, indexed and queryable with millisecond latency. The asymmetry is &lt;strong>two or three orders of magnitude&lt;/strong> in favour of the external corpus.&lt;/p>
&lt;p>The conclusion is not that the weights are useless, they are where reasoning lives, which is the expensive thing to replicate, but that &lt;strong>competing with an external index on fact capacity is losing by construction&lt;/strong>. A 70B model has ~9× more memorisation budget than an 8B one, but it is still negligible against the corpus. That is why the large model &lt;em>also&lt;/em> does RAG in production. The difference is that the SLM &lt;em>needs it&lt;/em>: without retrieval, its factual knowledge is too sparse and, worse, &lt;strong>prone to hallucinating&lt;/strong> exactly in the gaps it did not memorise.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Knowledge in weights versus knowledge in context">
&lt;text x="390" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Where knowledge lives&lt;/text>
&lt;text x="200" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the weights (memorised)&lt;/text>
&lt;rect x="90" y="70" width="220" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="200" y="95" text-anchor="middle" font-size="12" fill="#1f3550">~2 GB of useful info in 8B&lt;/text>
&lt;text x="200" y="113" text-anchor="middle" font-size="11" fill="#1f3550">fixed, costly to update, hallucinates in gaps&lt;/text>
&lt;text x="580" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the context (retrieved)&lt;/text>
&lt;rect x="430" y="70" width="300" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="580" y="95" text-anchor="middle" font-size="12" fill="#1c3a26">hundreds of GB – TB indexed&lt;/text>
&lt;text x="580" y="113" text-anchor="middle" font-size="11" fill="#1c3a26">fresh, citable, verifiable, no retraining&lt;/text>
&lt;text x="390" y="165" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The SLM as a reasoning engine&lt;/text>
&lt;rect x="240" y="178" width="300" height="46" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="390" y="200" text-anchor="middle" font-size="12" fill="#5a4500">reasons over the curated context&lt;/text>
&lt;text x="390" y="216" text-anchor="middle" font-size="11" fill="#5a4500">not the database: the one who reads and synthesises it&lt;/text>
&lt;path d="M200,130 L360,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;path d="M580,130 L420,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-central-tension-retrieving-more-is-not-putting-in-more">The central tension: retrieving more is not putting in more&lt;/h2>
&lt;p>This is where most naive designs break. &amp;ldquo;Aggressive retrieval&amp;rdquo; sounds like a large &lt;em>top-k&lt;/em>: if retrieval helps, retrieve 20 chunks instead of 5. But in an SLM that fails for two independent reasons, one of &lt;em>quality&lt;/em> and one of &lt;em>cost&lt;/em>.&lt;/p>
&lt;h3 id="a-slms-make-worse-use-of-long-context">(a) SLMs make worse use of long context&lt;/h3>
&lt;p>The &lt;em>lost in the middle&lt;/em> effect (Liu et al., 2023) is well known: LLMs retrieve information placed at the beginning and end of the context better, and the information in the middle worse. What gets less emphasis is that &lt;strong>the effect is more severe the smaller the model&lt;/strong>. An SLM has fewer attention heads, fewer layers and poorer internal representations for &amp;ldquo;tracking&amp;rdquo; a relevant fact buried at position 14 out of 20 chunks. Its nominal context window also tends to be shorter (4K–32K against the 128K+ of the large ones), and the &lt;strong>effective window&lt;/strong>, the length beyond which quality collapses, is smaller still. Putting in 20 chunks does not mean the model reads all 20: it means it will probably ignore or misread the ones in the middle, while paying the cost of all of them.&lt;/p>
&lt;h3 id="b-prefill-grows-with-the-context-and-is-compute-bound">(b) Prefill grows with the context and is compute-bound&lt;/h3>
&lt;p>This is the blow people underestimate. Prefill, processing the full prompt before emitting the first token, is the &lt;strong>compute-bound&lt;/strong> phase of inference (unlike decode, which is memory-bound; the detail lives in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts&lt;/a>). Its cost &lt;strong>grows with the context length&lt;/strong> $C$, and it determines TTFT (time to first token). More chunks → more prefill tokens → more TTFT and more compute cost per request. In an SLM, where prefill is proportionally more expensive relative to the model, this hurts especially.&lt;/p>
&lt;p>The operational conclusion is uncomfortable but clear: &lt;strong>you cannot compensate for fewer parameters simply by putting in more context.&lt;/strong> Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb both costs. The way out is to retrieve &lt;em>less but better&lt;/em>, and to compress what you retrieve.&lt;/p>
&lt;h2 id="the-maths-of-prefill">The maths of prefill&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;prefill grows with the context&amp;rdquo;. For a context of $C$ tokens, a transformer layer does two classes of work:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Linear projections&lt;/strong> (QKV, attention output, FFN): each token is multiplied by fixed-size weight matrices. The cost is $O(C)$ in FLOPs, linear in the number of tokens.&lt;/li>
&lt;li>&lt;strong>Attention&lt;/strong> ($QK^\top$ and the multiplication by $V$): each token attends to all the others. The cost is $O(C^2)$, quadratic in the number of tokens.&lt;/li>
&lt;/ol>
&lt;p>The total prefill cost per layer has the form:&lt;/p>
$$\text{FLOPs}_{\text{prefill}} \approx \underbrace{a \cdot C}_{\text{projections}} + \underbrace{b \cdot C^2}_{\text{attention}}$$
&lt;p>with $a$ and $b$ constants that depend on the model dimension. For moderate contexts (a few thousand tokens) in an SLM, the linear term still dominates or is comparable to the quadratic one; the quadratic term becomes dominant at long contexts. What matters: &lt;strong>if you compress the context&lt;/strong> $C \to C/k$, the linear term falls $\times k$ and the quadratic one falls $\times k^2$. Compression is the only lever that attacks both terms at once, and it attacks the worse one disproportionately.&lt;/p>
&lt;h3 id="numerical-example-ttft-before-and-after-compressing-rtx-4090">Numerical example: TTFT before and after compressing, RTX 4090&lt;/h3>
&lt;p>Let us model TTFT as the time to process the prefill tokens at a given prefill throughput. Take an RTX 4090 (24 GB, Ada Lovelace) serving a quantised SLM, with a &lt;strong>prefill throughput of ~5,000 tok/s&lt;/strong> (an illustrative figure; the real value depends on the model, the quantisation and the batch, so measure it, do not assume it).&lt;/p>
&lt;p>Let there be a retrieved context of &lt;strong>4,000 tokens&lt;/strong> (8 chunks of ~500 tokens). Approximating TTFT as dominated by the context prefill:&lt;/p>
$$\text{TTFT}_{\text{before}} \approx \frac{4000 \text{ tok}}{5000 \text{ tok/s}} = 0.80 \text{ s}$$
&lt;p>Now we compress that context to &lt;strong>1,000 tokens&lt;/strong> ($k = 4$). Prefill throughput is not constant with $C$, it drops a little at long contexts because of the quadratic term, but taking the conservative linear tokens/throughput approximation:&lt;/p>
$$\text{TTFT}_{\text{after}} \approx \frac{1000 \text{ tok}}{5000 \text{ tok/s}} = 0.20 \text{ s}$$
&lt;p>TTFT falls from &lt;strong>0.80 s to 0.20 s&lt;/strong>, a $4\times$ reduction in the linear part. But the FLOPs arithmetic is even more favourable in the attention component: that part of the work falls $\sim k^2 = 16\times$. In practice total TTFT does not fall 16× because the cost is not purely quadratic at this scale, but the real reduction lies &lt;strong>between 4× and something larger depending on how much attention weighed&lt;/strong>, and the aggregate compute saving (what pays the electricity bill and frees the GPU for another request) is substantially bigger than the plain 4× of the token count.&lt;/p>
&lt;p>The argument generalises: &lt;strong>compressing the context by a factor $k$ reduces TTFT by at least $\sim k\times$ and the attention cost by $\sim k^2\times$.&lt;/strong> For an SLM, where TTFT is often the SLA that matters, this is the difference between an assistant that answers instantly and one that feels slow.&lt;/p>
&lt;h2 id="the-five-levers-to-resolve-the-tension">The five levers to resolve the tension&lt;/h2>
&lt;p>The strategy is not &amp;ldquo;retrieve less and settle&amp;rdquo;. It is &lt;strong>retrieve aggressively from the index and then distil aggressively what you retrieved&lt;/strong> before it reaches the SLM. Five levers, in order of application within the pipeline.&lt;/p>
&lt;h3 id="1-aggressive-reranking-precision-over-recall">1. Aggressive reranking: precision over recall&lt;/h3>
&lt;p>The initial retriever (dense, sparse or hybrid) optimises &lt;em>recall&lt;/em>: it brings back 50–100 candidates so as not to leave anything out. The reranker, a cross-encoder that sees the query and the document together, optimises &lt;em>precision&lt;/em>: it reorders those candidates and you keep the &lt;strong>3–5 best&lt;/strong>. For an SLM this is not a luxury, it is structural: since the model uses long context badly, every chunk that goes in must &lt;em>earn its place&lt;/em>. Better 4 chunks of very high relevance than 15 mediocre ones. The detail of hybrid retrieval and reranking is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Reranking and hybrid retrieval&lt;/a>; here the rule is enough: &lt;strong>maximise recall in the retriever, maximise precision in the reranker, and inject few&lt;/strong>.&lt;/p>
&lt;h3 id="2-context-compression-distilling-the-crib-sheet">2. Context compression: distilling the crib sheet&lt;/h3>
&lt;p>Once you have the best chunks, they still contain filler: padding sentences, redundancy, context irrelevant to the specific query. &lt;strong>Context compression&lt;/strong> trims them before injection:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Extractive compression&lt;/strong> (LLMLingua / LongLLMLingua style, Jiang et al. 2023): a small model scores the &lt;em>perplexity&lt;/em> or relevance of each token/sentence with respect to the query and removes the low-information ones, keeping the densest extractive subset. It reduces tokens without a second large generative model in the loop. LongLLMLingua adds position-aware reordering to mitigate &lt;em>lost in the middle&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Abstractive compression&lt;/strong>: a model summarises the retrieved chunks into shorter text. More aggressive in token reduction, but it introduces a generative step (cost and possible loss of fidelity).&lt;/li>
&lt;li>&lt;strong>Soft prompts / context distillation&lt;/strong>: compressing the retrieved context not into text, but into a handful of &lt;strong>embeddings/soft tokens&lt;/strong> that the model consumes directly. It reduces the number of prefill tokens to a minimum, at the price of a trained, model-specific component.&lt;/li>
&lt;/ul>
&lt;p>The key point connects with the maths above: &lt;strong>compressing what you retrieved by a factor $k$ reduces prefill tokens $\times k$, and therefore TTFT $\sim\times k$ and the attention cost $\sim\times k^2$.&lt;/strong> It is the lever with the best return when long context is the bottleneck.&lt;/p>
&lt;h3 id="3-prefix-caching-of-stable-context">3. Prefix caching of stable context&lt;/h3>
&lt;p>Not all the context changes between requests. System instructions, definitions, recurring reference documents, schemas: these are &lt;strong>stable prefixes&lt;/strong>. &lt;strong>Prefix caching&lt;/strong> stores the already-computed KV cache of those prefixes and reuses it, so that prefill only processes the new part (the query and the specific chunks). If 60 % of your context is stable, you save 60 % of that segment&amp;rsquo;s prefill on every hit. For it to work, &lt;strong>the stable context must go at the start of the prompt&lt;/strong> (the KV cache is prefix-dependent) and it pays to maximise the &lt;em>hit rate&lt;/em>; the hit-rate engineering detail is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Prefix cache hit rate&lt;/a>. It combines especially well with RAG: retrieved documents that repeat across sessions get cached once.&lt;/p>
&lt;h3 id="4-semantic-caching-of-answers">4. Semantic caching of answers&lt;/h3>
&lt;p>A layer in front of the model: if a query is &lt;strong>semantically equivalent&lt;/strong> to one answered before (embedding similarity above a threshold), return the cached answer and &lt;strong>skip the model entirely&lt;/strong>, retrieval, prefill and decode included. In real workloads with long tails of repeated or near-repeated questions (FAQ, support), the saving is enormous because it eliminates the full cost, not just the prefill one. The trap is the threshold: too lax and you serve wrong answers to similar-but-different questions. The design is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Semantic caching for RAG&lt;/a>.&lt;/p>
&lt;h3 id="5-structured-output-and-function-calling-lean-on-tools-not-on-memory">5. Structured output and function calling: lean on tools, not on memory&lt;/h3>
&lt;p>The last lever changes what the SLM depends on. Instead of asking it to &lt;em>know&lt;/em> a fact (its weak point), make it &lt;em>call a tool&lt;/em> that knows it: a database query, an API, a calculator, a validator. &lt;strong>Structured output&lt;/strong> (forcing JSON conforming to a schema) and &lt;strong>function calling&lt;/strong> turn the SLM into an orchestrator that extracts arguments from the context and delegates the computation or the query. A reasonably capable SLM emits a well-formed &lt;em>tool call&lt;/em> far more reliably than it recalls a specific fact. This reduces the pressure on parametric knowledge &lt;em>and&lt;/em> on retrieval: for structured, fresh data (prices, inventory, states), querying beats retrieving text and beats memorising. The fundamentals are in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Structured output&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Function calling&lt;/a>.&lt;/p>
&lt;h2 id="the-complete-pipeline">The complete pipeline&lt;/h2>
&lt;p>The five levers are not alternatives: they chain together. The flow, with the token counter falling at each step:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Retrieve, rerank, compress, SLM pipeline with the token counter falling">
&lt;defs>&lt;marker id="rag1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Retrieve aggressively, distil aggressively, reason cheaply&lt;/text>
&lt;rect x="20" y="50" width="120" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="80" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#1f3550">Retriever&lt;/text>
&lt;text x="80" y="93" text-anchor="middle" font-size="11" fill="#1f3550">hybrid, recall&lt;/text>
&lt;text x="80" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#1f5fa8">~80 chunks&lt;/text>
&lt;rect x="180" y="50" width="120" height="60" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="240" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#3a1d70">Reranker&lt;/text>
&lt;text x="240" y="93" text-anchor="middle" font-size="11" fill="#3a1d70">precision&lt;/text>
&lt;text x="240" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#5a2db0">5 chunks · 4,000 tok&lt;/text>
&lt;rect x="340" y="50" width="120" height="60" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="400" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#5a4500">Compression&lt;/text>
&lt;text x="400" y="93" text-anchor="middle" font-size="11" fill="#5a4500">extractive k=4&lt;/text>
&lt;text x="400" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a48000">1,000 tok&lt;/text>
&lt;rect x="500" y="50" width="120" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="560" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#1c3a26">Prefix cache&lt;/text>
&lt;text x="560" y="90" text-anchor="middle" font-size="11" fill="#1c3a26">+ semantic&lt;/text>
&lt;text x="560" y="103" text-anchor="middle" font-size="11" fill="#1c3a26">cache&lt;/text>
&lt;text x="560" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#2a7a40">minimal prefill&lt;/text>
&lt;rect x="660" y="50" width="100" height="60" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="710" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#6a1a1a">SLM&lt;/text>
&lt;text x="710" y="90" text-anchor="middle" font-size="11" fill="#6a1a1a">reasons +&lt;/text>
&lt;text x="710" y="103" text-anchor="middle" font-size="11" fill="#6a1a1a">tool calls&lt;/text>
&lt;text x="710" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a52a2a">answer&lt;/text>
&lt;path d="M140,80 L180,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M300,80 L340,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M460,80 L500,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M620,80 L660,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;text x="390" y="180" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The prefill token counter falls along the pipeline&lt;/text>
&lt;rect x="100" y="200" width="560" height="22" fill="none" stroke="currentColor" stroke-width="1"/>
&lt;rect x="100" y="200" width="560" height="22" fill="#d4ecff"/>
&lt;rect x="240" y="200" width="280" height="22" fill="#fff4d6"/>
&lt;rect x="240" y="200" width="70" height="22" fill="#cdebd0"/>
&lt;text x="180" y="216" text-anchor="middle" font-size="11" fill="#1f3550">retrieve: a lot&lt;/text>
&lt;text x="380" y="216" text-anchor="middle" font-size="11" fill="#5a4500">rerank: 4,000 tok&lt;/text>
&lt;text x="275" y="216" text-anchor="middle" font-size="11" fill="#1c3a26">1,000&lt;/text>
&lt;text x="390" y="252" text-anchor="middle" font-size="12" fill="currentColor">TTFT on RTX 4090 at ~5,000 tok/s · 4,000 tok = 0.80 s → 1,000 tok = 0.20 s&lt;/text>
&lt;text x="390" y="272" text-anchor="middle" font-size="11" fill="currentColor">attention falls ~k² = 16× in that part of the compute&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The order matters. Retrieving aggressively (high recall) &lt;em>before&lt;/em> filtering guarantees that the right material is among the candidates; reranking and compressing &lt;em>afterwards&lt;/em> guarantees that only the dense and relevant material pays the prefill toll; caching wraps everything so as not to repeat work. The SLM only sees the final crib sheet, short and ordered.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The mental trap to avoid: treating the SLM as a large model with less quality. It is not. It is a &lt;strong>different cost profile&lt;/strong> that rewards a different design. Three practical consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The token budget is a first-class resource.&lt;/strong> With a large model with a 128K window, &amp;ldquo;putting in a bit more&amp;rdquo; is cheap relative to the model. With an SLM, every context token shows up in TTFT and in quality. Treat context size as a quantity to &lt;strong>minimise subject to covering the answer&lt;/strong>, not to maximise.&lt;/li>
&lt;li>&lt;strong>The investment is worth it precisely because the model is cheap.&lt;/strong> Reranker, compressor and caches add complexity, but the model they serve is economical enough to run many replicas. The bottleneck shifts from the model to the data pipeline, which is exactly where you want it.&lt;/li>
&lt;li>&lt;strong>Retrieving does not replace adapting; they combine.&lt;/strong> For deep, recurring domain knowledge, adapting the SLM with LoRA (see the sibling &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA&lt;/a>) can put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; cheaply, reducing what has to be retrieved. Aggressive RAG and aggressive adaptation do not compete: the first gives freshness and citability, the second gives fluency and domain formatting. A good design uses both.&lt;/li>
&lt;/ul>
&lt;h3 id="on-the-rtx-4090-24-gb-ada-lovelace">On the RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The canonical scenario: a quantised SLM (4B–8B in INT4/FP8) fits with room to spare, leaving VRAM for a generous KV cache, essential for prefix caching, and for the reranker (a cross-encoder of a few hundred MB). The LLMLingua-style extractive compressor runs on a separate small model or on CPU. The TTFT calculation above (0.80 s → 0.20 s compressing 4× at ~5,000 tok/s) is representative of this card. The rule of thumb: if TTFT goes above your SLA, &lt;strong>the first adjustment is to compress the context, not to change the model&lt;/strong>.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>With 320 GB and native FP8 prefill is much faster, so the temptation is to relax the token discipline. That is not quite advisable: the lever changes from TTFT to &lt;em>aggregate throughput&lt;/em>. Compressing the context not only speeds up each request but &lt;strong>frees prefill compute&lt;/strong> to serve more requests per GPU; compute-bound prefill is exactly the resource that saturates first under load. Here prefix caching and semantic caching, shared across replicas, are what pay off most: at high QPS, the prefill work you avoid by caching is pure throughput you gain. The SLM is still the cheap reasoning engine; the difference is that you now run many in parallel and the data pipeline is what decides how many requests fit.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Evaluating compression&lt;/strong>: how to measure that compressing $k=4$ does not throw away correct answers (faithfulness, answer recall over a set of questions with ground truth).&lt;/li>
&lt;li>&lt;strong>Query-aware versus query-agnostic compression&lt;/strong>: compressing before or after knowing the question changes what can be cached and what can be discarded.&lt;/li>
&lt;li>&lt;strong>Chunking and granularity&lt;/strong>: chunk size interacts with reranking and compression; that is left for the corpus curation post.&lt;/li>
&lt;li>&lt;strong>Multi-hop and agents&lt;/strong>: when a question requires several retrieval rounds, the token budget is split across hops and compression discipline becomes critical.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval for RAG&lt;/a> — lever 1 in detail: maximise recall in the retriever and precision in the reranker so as to inject few but excellent chunks, which is what an SLM needs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">Corpus curation for RAG&lt;/a> — a clean, well-chunked corpus reduces the filler the compressor has to remove; the crib sheet&amp;rsquo;s quality starts upstream.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic caching for RAG&lt;/a> — lever 4: skipping the model entirely when a query is semantically equivalent to one already answered.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings 2026: dense, sparse and multivector&lt;/a> — the basis of hybrid retrieval and of the semantic cache threshold; which representation retrieves better with less noise.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — lever 3: how to structure the prompt (stable context first) to maximise reuse of the retrieved context&amp;rsquo;s KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a> — compute-bound prefill is the cost this whole discussion tries to minimise; here are the concrete parameters to speed it up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — lever 5: forcing schema-conforming JSON so the SLM orchestrates tools instead of recalling data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">Function calling and tool-augmented retrieval&lt;/a> — when querying an API or database beats retrieving text and beats memorising; the SLM as a tool orchestrator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a> — why compute-bound prefill is the bottleneck that shapes this whole post: adding more context is not free.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA in SLMs&lt;/a> — the complementary alternative: adapting the SLM per domain to put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; and reduce what has to be retrieved.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Lewis, P., et al. &lt;em>Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks&lt;/em>. NeurIPS 2020. &lt;a href="https://arxiv.org/abs/2005.11401">https://arxiv.org/abs/2005.11401&lt;/a>&lt;/li>
&lt;li>Liu, N.F., et al. &lt;em>Lost in the Middle: How Language Models Use Long Contexts&lt;/em>. TACL 2024. &lt;a href="https://arxiv.org/abs/2307.03172">https://arxiv.org/abs/2307.03172&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LLMLingua: Compressing Prompts for Accelerated Inference of Large Language Models&lt;/em>. EMNLP 2023. &lt;a href="https://arxiv.org/abs/2310.05736">https://arxiv.org/abs/2310.05736&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LongLLMLingua: Accelerating and Enhancing LLMs in Long Context Scenarios via Prompt Compression&lt;/em>. ACL 2024. &lt;a href="https://arxiv.org/abs/2310.06839">https://arxiv.org/abs/2310.06839&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Aggressive quantisation (state of the art): from 4-bit to ternary</title><link>https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/</link><pubDate>Tue, 09 Jun 2026 02:10:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/</guid><description>&lt;blockquote>
&lt;p>This post is the direct continuation of &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a>, which covers the &amp;ldquo;solved&amp;rdquo; regime (FP8, INT4 with GPTQ/AWQ). Read it first: here I assume the scale+zero-point maths, what GPTQ and AWQ do, and the PTQ/QAT distinction. What we add is the &lt;strong>sub-4-bit frontier&lt;/strong>, where scalar post-hoc quantisation stops working and you have to change tools.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>There is a sharp dividing line around 4 bits. Above it, quantising is a solved problem: INT8 is indistinguishable from BF16, and INT4 with a good method (AWQ, GPTQ) loses 1-2 MMLU points and little else. The method is still the usual one, take each weight, scale it, round it to a short integer, and it works. Below 4 bits, that method collapses: at 2 bits, naive scalar quantisation can double perplexity. The reason is geometric, each weight has only 4 possible values and the rounding error stops being negligible, and the way out is not &amp;ldquo;round better&amp;rdquo;, it is to &lt;strong>change the representation&lt;/strong>. State-of-the-art 2-bit methods (AQLM, QuIP#, QTIP) stop quantising individual weights and quantise &lt;strong>vectors&lt;/strong> of weights against dictionaries (codebooks), and they &amp;ldquo;whiten&amp;rdquo; the weight matrix to spread its energy and flatten outliers (incoherence processing). Ternary is something else again: BitNet b1.58, with weights in {-1, 0, +1} (~1.58 bits), is not PTQ, it is a model trained natively under that constraint, and it changes the arithmetic of the matmul from multiplications to additions and subtractions, hitting the compute ceiling and the memory ceiling at once. The mental rule: at ≥4-bit you compress the photo; below 4-bit you have to repaint it.&lt;/p>
&lt;h2 id="the-analogy-the-jpeg-that-cannot-be-compressed-any-further">The analogy: the JPEG that cannot be compressed any further&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">the quantization post&lt;/a> we used the JPEG with an edge detector to explain INT4. The analogy still holds here, but it has to be pushed to its limit.&lt;/p>
&lt;p>A JPEG at quality factor 90 is indistinguishable from the original. At quality 60 you notice it a little, but it is still &amp;ldquo;the same photo&amp;rdquo;. At quality 30 the 8×8 blocks appear, the haloes around edges, the banding in gradients. At quality 10 the image is destroyed: you can tell there &lt;strong>was&lt;/strong> a face, but the details have disappeared under the artefacts. And here is the key: &lt;strong>no JPEG encoder exists that can compress to quality 10 without those artefacts&lt;/strong>, because the JPEG algorithm (blockwise DCT + coefficient quantisation) has an information floor below which its own mechanism introduces the noise.&lt;/p>
&lt;p>What do you do if you need the photo at that file size and you need it to still look good? You do not compress the original harder. You &lt;strong>repaint the photo knowing in advance that it is going to live compressed&lt;/strong>: an illustrator redraws it with clean lines, a reduced palette, no subtle gradients, an image designed to survive brutal compression. The result at &amp;ldquo;10 KB&amp;rdquo; looks infinitely better than the original JPEG crushed to 10 KB, because it is not the same process: one destroys existing information, the other generates new information already adapted to the constraint.&lt;/p>
&lt;p>That is exactly the frontier of this post:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scalar PTQ (≥4-bit)&lt;/strong> = compressing the JPEG. Up to a certain ratio, it is still the same photo.&lt;/li>
&lt;li>&lt;strong>State-of-the-art vector PTQ (2-bit: AQLM, QuIP#, QTIP)&lt;/strong> = a far more sophisticated image codec (dictionaries, decorrelating transforms) that stretches the compressible ratio a bit further before the collapse.&lt;/li>
&lt;li>&lt;strong>Native ternary (BitNet b1.58)&lt;/strong> = repainting the photo. You are not compressing an existing BF16 model; you are training a new one that is born ternary.&lt;/li>
&lt;/ul>
&lt;h2 id="the-map-of-the-frontier-bit-by-bit">The map of the frontier, bit by bit&lt;/h2>
&lt;p>Quantising a model is deciding how many distinct values each weight can take. With &lt;code>b&lt;/code> bits per weight there are &lt;code>2^b&lt;/code> possible values. The central question is: past what &lt;code>b&lt;/code> is the number of values so small that rounding destroys the model?&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Bits&lt;/th>
&lt;th>Values/weight&lt;/th>
&lt;th>State of the art&lt;/th>
&lt;th>Method required&lt;/th>
&lt;th>Typical loss vs BF16&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>256&lt;/td>
&lt;td>&lt;strong>Solved&lt;/strong>&lt;/td>
&lt;td>RTN, SmoothQuant, FP8&lt;/td>
&lt;td>~0 (indistinguishable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>16&lt;/td>
&lt;td>&lt;strong>Solved&lt;/strong>&lt;/td>
&lt;td>AWQ, GPTQ&lt;/td>
&lt;td>1-2 pp MMLU, +0.1-0.3 PPL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>8&lt;/td>
&lt;td>Small degradation&lt;/td>
&lt;td>tuned GPTQ/AWQ, GGUF Q3_K&lt;/td>
&lt;td>3-5 pp MMLU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>4&lt;/td>
&lt;td>Serious unless SOTA&lt;/td>
&lt;td>&lt;strong>AQLM, QuIP#, QTIP&lt;/strong> (non-scalar)&lt;/td>
&lt;td>scalar: collapse; SOTA: 4-8 pp&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.58&lt;/td>
&lt;td>3 (ternary)&lt;/td>
&lt;td>Native only&lt;/td>
&lt;td>&lt;strong>BitNet b1.58&lt;/strong> (QAT/native training)&lt;/td>
&lt;td>n/a (not PTQ)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>2 (binary)&lt;/td>
&lt;td>Research&lt;/td>
&lt;td>native, dubious claims&lt;/td>
&lt;td>large / no clear methodology&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three transitions that matter:&lt;/p>
&lt;p>&lt;strong>8 → 4 bits: nothing breaks.&lt;/strong> With 16 levels per weight and one scale per block of 128, the rounding error is small relative to the dynamics of the weights. GPTQ compensates for the error by propagating it to the neighbouring weights; AWQ protects the ~1 % of salient channels. The model barely notices. This is in the previous post.&lt;/p>
&lt;p>&lt;strong>4 → 2 bits: the elbow.&lt;/strong> Two things happen at once here. First, with only 4 levels, the scalar quantiser can no longer represent the weight distribution, which is roughly Gaussian with long tails, without a proportionally enormous rounding error. Second, and more subtly: the quantisation error stops being &amp;ldquo;small noise that the model absorbs&amp;rdquo; and becomes &lt;strong>structured&lt;/strong>, systematically biasing the activations. Naive scalar PTQ at 2 bits on a Llama 8B typically doubles perplexity or worse. That is the elbow of the curve.&lt;/p>
&lt;p>&lt;strong>2 → 1.58 bits: a change of nature.&lt;/strong> You do not cross this with a better compression method. You cross it by training the model with the constraint from the start. It is a discontinuity: to the left you are doing PTQ, to the right you are doing training.&lt;/p>
&lt;h2 id="why-scalar-ptq-collapses-below-4-bits">Why scalar PTQ collapses below 4 bits&lt;/h2>
&lt;p>The scalar quantiser has a fundamental limitation: it quantises each weight separately, ignoring the fact that the weights of a row or column are correlated and that the error of one could be compensated by another. At 4 bits this matters little; at 2 bits it is lethal. There are three possible attacks, and the state-of-the-art methods use all three.&lt;/p>
&lt;h3 id="1-vector-quantisation-dictionaries-instead-of-scales">1. Vector quantisation: dictionaries instead of scales&lt;/h3>
&lt;p>Instead of mapping each weight to one of 4 values, group the weights into &lt;strong>vectors&lt;/strong> (say, of 8 weights) and map each vector to the nearest entry of a learned dictionary (codebook). If the dictionary has 256 entries, encoding a vector of 8 weights costs 8 bits (the index) → 1 bit/weight, but every &amp;ldquo;reconstructed value&amp;rdquo; is a point in an 8-dimensional space chosen to minimise the error over the real distribution of the weights.&lt;/p>
&lt;p>The advantage is information-theoretic: a vector dictionary can place its reconstruction points where the weights actually are (in clusters), whereas the scalar quantiser is forced to put its 4 levels on a regular grid, wasting resolution on empty regions. It is the difference between a road map with a uniform grid and one that puts more detail where the cities are.&lt;/p>
&lt;p>&lt;strong>AQLM&lt;/strong> (Additive Quantization of Language Models, arXiv:2401.06118) takes this to the extreme with additive quantisation: each weight vector is reconstructed as the &lt;strong>sum of several codes&lt;/strong> from several dictionaries (multi-codebook). It is more expressive than a single dictionary because the number of combinations is the product of the sizes, not the sum. AQLM was one of the first methods to make 2-bit &amp;ldquo;usable&amp;rdquo; (not collapsed) on large models, at the cost of an expensive calibration process and specialised inference kernels.&lt;/p>
&lt;h3 id="2-incoherence-processing-whitening-the-matrix">2. Incoherence processing: whitening the matrix&lt;/h3>
&lt;p>The second attack targets the outliers. The weight matrices of a transformer have a few entries (and a few channels) with a magnitude much larger than the rest. Those outliers dominate the quantiser&amp;rsquo;s range: if you have to represent a weight of magnitude 8 while the rest are of magnitude 0.5, your scale stretches to cover the 8 and you waste almost all the resolution.&lt;/p>
&lt;p>&lt;strong>Incoherence processing&lt;/strong> (the core idea of QuIP and QuIP#) attacks this by multiplying the weight matrix &lt;code>W&lt;/code> by random orthogonal matrices on the left and on the right: &lt;code>W' = U W V^T&lt;/code>. Because &lt;code>U&lt;/code> and &lt;code>V&lt;/code> are orthogonal, the operation is invertible and the maths of the product can be undone at inference time by absorbing it into the neighbouring layers (just as AWQ absorbs its scales). But the rotation spreads the energy: an &amp;ldquo;incoherent&amp;rdquo; matrix has its values distributed almost uniformly, with no concentrated outliers, because mixing coordinates with a random rotation flattens the distribution (it is, in essence, the central limit theorem acting on linear combinations). A matrix without outliers quantises far better at 2 bits. It is the equivalent of &amp;ldquo;whitening&amp;rdquo; a signal before digitising it.&lt;/p>
&lt;p>&lt;strong>QuIP#&lt;/strong> (arXiv:2402.04396) combines incoherence processing with E8 lattice codes: rather than an arbitrary dictionary, it uses the E8 lattice (an optimal sphere packing in 8 dimensions, the best known). Quantising vectors of 8 weights against the E8 lattice gives the smallest reconstruction error possible for a given bit density, because E8 is literally the most efficient way to place points in 8D. It is classical coding theory applied to LLM weights.&lt;/p>
&lt;h3 id="3-coding-with-memory-trellis">3. Coding with memory: trellis&lt;/h3>
&lt;p>&lt;strong>QTIP&lt;/strong> (arXiv:2406.11235) adds the third attack: trellis-coded quantisation. Instead of quantising each vector independently, it models the sequence of weights as a path through a trellis (the same structure as the convolutional codes of telecommunications) and picks the optimal code sequence with the Viterbi algorithm. The intuition: introducing &lt;strong>memory&lt;/strong> between successive quantisations allows correlated errors that cancel out, instead of independent errors that accumulate. QTIP, on top of incoherence processing, improves on QuIP# in quality at 2-3 bits while keeping fast inference kernels.&lt;/p>
&lt;p>The idea common to all three: &lt;strong>stop quantising scalars and start quantising vectors with dictionaries, and decorrelate the matrix before doing so&lt;/strong>. None of them is &amp;ldquo;rounding better&amp;rdquo;; all three change the representation at the root. That is why, below 4 bits, a flag in vLLM is no longer enough: you need co-design of the quantisation method and the inference kernel.&lt;/p>
&lt;h2 id="native-ternary-bitnet-b158">Native ternary: BitNet b1.58&lt;/h2>
&lt;p>Here we change continents. Everything above is &lt;strong>PTQ&lt;/strong>: it starts from a trained BF16 model and compresses it. BitNet&amp;rsquo;s ternary compresses nothing.&lt;/p>
&lt;p>&lt;strong>BitNet b1.58&lt;/strong> (arXiv:2402.17764) trains a transformer from scratch where &lt;strong>every weight is constrained to {-1, 0, +1}&lt;/strong> throughout training. Three values ⇒ log₂(3) ≈ &lt;strong>1.58 bits/weight&lt;/strong>. Quantisation is not a later step: the linear layers (&lt;code>BitLinear&lt;/code>) quantise their weights to ternary in the forward pass of every training step, and gradients flow through a straight-through estimator. The model learns to work with ternary weights. This is QAT taken to the extreme: not a short robustness fine-tune, but the constraint present from the first token of training.&lt;/p>
&lt;p>That difference is what dodges the elbow of the curve. PTQ at 2 bits tries to find the best ternary or quaternary approximation of a model that was trained expecting full precision, and that model has &amp;ldquo;fragile&amp;rdquo; weights that depend on nuances 2 bits cannot capture. BitNet, by contrast, never had those nuances: its weights were born ternary, so the network distributed its representational capacity in a way compatible with the constraint. It is repainting the photo instead of compressing it.&lt;/p>
&lt;h3 id="what-changes-is-not-just-the-memory-it-is-the-arithmetic">What changes is not just the memory, it is the arithmetic&lt;/h3>
&lt;p>The most underrated point about BitNet: with weights in {-1, 0, +1}, multiplication disappears from the matmul. Multiplying an activation &lt;code>x&lt;/code> by a ternary weight &lt;code>w&lt;/code> is trivial: if &lt;code>w = +1&lt;/code> you add &lt;code>x&lt;/code>, if &lt;code>w = -1&lt;/code> you subtract &lt;code>x&lt;/code>, if &lt;code>w = 0&lt;/code> you do nothing. The dominant operation of a transformer, the matrix-vector product, goes from a sea of floating point multiply-accumulate (MAC) to integer additions and subtractions.&lt;/p>
&lt;p>This matters because it connects to the roofline. As explained in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline of small models&lt;/a>, LLM inference has two ceilings: &lt;strong>memory&lt;/strong> (HBM bandwidth for loading weights) and compute (tensor core FLOPs). Normal quantisation (INT4, FP8) attacks only the memory ceiling: the weight takes up less space, but to multiply it you dequantise to FP16 and do the same multiplication as always. Ternary attacks both ceilings at once: the weight occupies 1.58 bits (memory) &lt;strong>and&lt;/strong> the operation is an addition instead of a multiplication (compute). That is why BitNet needs its own kernels, bitnet.cpp, that execute the ternary matmul without ever going through FP16; a kernel that dequantised to FP16 in order to multiply would throw away half the advantage.&lt;/p>
&lt;p>The honest trade-off: BitNet b1.58 is training from scratch. You cannot &amp;ldquo;convert your Llama 8B to BitNet&amp;rdquo;. If you want ternary, you train (or use) a natively ternary model, with everything that implies in pre-training cost and weight availability. Today it is a research line with published models at modest scales, not a drop-in replacement for your current serving.&lt;/p>
&lt;h2 id="qat-as-the-bridge-between-ptq-and-native">QAT as the bridge between PTQ and native&lt;/h2>
&lt;p>Between &amp;ldquo;compressing post-hoc&amp;rdquo; (PTQ) and &amp;ldquo;training natively ternary&amp;rdquo; (BitNet) there is a middle point: &lt;strong>QAT&lt;/strong> (Quantization-Aware Training). You take an already trained model and do a short fine-tune with the quantisation operations inside the loop, so that it learns to be robust to low bit counts without paying for a full pre-training.&lt;/p>
&lt;p>&lt;strong>Gemma 3&lt;/strong> publishes official &lt;strong>QAT&lt;/strong> variants for exactly this: models that, after the QAT fine-tune, hold up at INT4 with far less quality loss than pure PTQ on the same model. The cost is a training cost (GPU hours to days on an existing model), not an inference cost. For INT4 with QAT you recover almost all the quality; for 2-bit, QAT helps but the terrain is still difficult; for ternary, QAT stops being a &amp;ldquo;short fine-tune&amp;rdquo; and becomes native training (BitNet).&lt;/p>
&lt;p>The decision hierarchy:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PTQ&lt;/strong> = default at ≥4 bits. Minutes to hours, without touching training weights. It covers 90 % of production.&lt;/li>
&lt;li>&lt;strong>QAT&lt;/strong> = when PTQ loses too much and the difference matters. Low bits (2-3), or sensitive models. You pay for a fine-tune.&lt;/li>
&lt;li>&lt;strong>Native (ternary)&lt;/strong> = when you want to go below 2 bits &lt;strong>and&lt;/strong> change the arithmetic. You pay for pre-training. It only makes sense if you control the model from its creation.&lt;/li>
&lt;/ul>
&lt;h2 id="the-maths-that-matter-footprint-and-how-many-fit">The maths that matter: footprint and how many fit&lt;/h2>
&lt;p>The weight footprint is direct: &lt;code>bytes = (bits/param / 8) × N&lt;/code>, with &lt;code>N&lt;/code> the number of parameters. For an 8B model:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>bits/param&lt;/th>
&lt;th>8B footprint&lt;/th>
&lt;th>Ratio vs BF16&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16&lt;/td>
&lt;td>16&lt;/td>
&lt;td>16.0 GB&lt;/td>
&lt;td>1.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT8&lt;/td>
&lt;td>8&lt;/td>
&lt;td>8.0 GB&lt;/td>
&lt;td>2.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4&lt;/td>
&lt;td>4&lt;/td>
&lt;td>4.0 GB&lt;/td>
&lt;td>4.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3-bit&lt;/td>
&lt;td>3&lt;/td>
&lt;td>3.0 GB&lt;/td>
&lt;td>5.3×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2-bit&lt;/td>
&lt;td>2&lt;/td>
&lt;td>2.0 GB&lt;/td>
&lt;td>8.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.58-bit (ternary)&lt;/td>
&lt;td>~1.58&lt;/td>
&lt;td>~1.6 GB&lt;/td>
&lt;td>~10×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>(Real ternary takes up slightly more than 1.58 bits/param because you have to pack 5 ternary values into 8 bits, 5 × log₂(3) ≈ 7.92 bits, and because norms and embeddings usually stay at higher precision. The ~1.6 GB figure for 8B is the right order of magnitude.)&lt;/p>
&lt;h3 id="how-many-8b-models-fit-on-an-rtx-4090">How many 8B models fit on an RTX 4090?&lt;/h3>
&lt;p>An &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong> has 24 GB. Reserving ~4 GB for KV cache and activations leaves &lt;strong>20 GB&lt;/strong> for weights. How many distinct 8B models fit loaded at the same time:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>8B footprint&lt;/th>
&lt;th>Models in 20 GB&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16&lt;/td>
&lt;td>16.0 GB&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;td>one, with scant headroom left&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT8&lt;/td>
&lt;td>8.0 GB&lt;/td>
&lt;td>&lt;strong>2&lt;/strong>&lt;/td>
&lt;td>two different models&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4&lt;/td>
&lt;td>4.0 GB&lt;/td>
&lt;td>&lt;strong>5&lt;/strong>&lt;/td>
&lt;td>the solved regime; quality ~lossless with AWQ&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3-bit&lt;/td>
&lt;td>3.0 GB&lt;/td>
&lt;td>&lt;strong>6&lt;/strong>&lt;/td>
&lt;td>small degradation already visible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2-bit&lt;/td>
&lt;td>2.0 GB&lt;/td>
&lt;td>&lt;strong>10&lt;/strong>&lt;/td>
&lt;td>only viable with AQLM/QuIP#/QTIP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.58-bit&lt;/td>
&lt;td>~1.6 GB&lt;/td>
&lt;td>&lt;strong>~12&lt;/strong>&lt;/td>
&lt;td>natively ternary models only&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The arithmetic is seductive, from 1 to 12 models on the same card, but it should be read sceptically. Jumping from INT4 (5 models, almost lossless) to 2-bit (10 models) doubles the capacity, but only if you use a state-of-the-art method and accept 4-8 MMLU points. And the jump from 2-bit to ternary (10 → 12) is marginal in memory terms: ternary is not justified by footprint against a state-of-the-art 2-bit, it is justified by the arithmetic (the compute ceiling) and because being native it avoids the quality elbow. If your only metric is &amp;ldquo;how many GB it takes&amp;rdquo;, state-of-the-art 2-bit already gives you nearly everything. Ternary is for when you also want the compute saving.&lt;/p>
&lt;h3 id="the-conceptual-curve-perplexity-vs-bits">The conceptual curve: perplexity vs bits&lt;/h3>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Conceptual curve of perplexity against bits per weight">
&lt;text x="390" y="26" text-anchor="middle" fill="currentColor" font-size="15" font-weight="700">Perplexity vs bits per weight (conceptual): the elbow and the native branch&lt;/text>
&lt;line x1="90" y1="320" x2="730" y2="320" stroke="currentColor" stroke-width="1.5"/>
&lt;line x1="90" y1="60" x2="90" y2="320" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="410" y="362" text-anchor="middle" fill="currentColor" font-size="13">bits per weight (inverted axis: more compressed to the right)&lt;/text>
&lt;text x="30" y="190" text-anchor="middle" fill="currentColor" font-size="13" transform="rotate(-90 30 190)">perplexity (worse at the top)&lt;/text>
&lt;text x="120" y="338" text-anchor="middle" fill="currentColor" font-size="12">16&lt;/text>
&lt;text x="250" y="338" text-anchor="middle" fill="currentColor" font-size="12">8&lt;/text>
&lt;text x="380" y="338" text-anchor="middle" fill="currentColor" font-size="12">4&lt;/text>
&lt;text x="470" y="338" text-anchor="middle" fill="currentColor" font-size="12">3&lt;/text>
&lt;text x="560" y="338" text-anchor="middle" fill="currentColor" font-size="12">2&lt;/text>
&lt;text x="650" y="338" text-anchor="middle" fill="currentColor" font-size="12">1.58&lt;/text>
&lt;line x1="380" y1="60" x2="380" y2="320" stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"/>
&lt;text x="384" y="74" fill="currentColor" font-size="11">4-bit frontier&lt;/text>
&lt;polyline points="120,300 250,298 380,292 470,278 560,170 620,95" fill="none" stroke="#c0392b" stroke-width="2.6"/>
&lt;circle cx="120" cy="300" r="4" fill="#c0392b"/>
&lt;circle cx="250" cy="298" r="4" fill="#c0392b"/>
&lt;circle cx="380" cy="292" r="4" fill="#c0392b"/>
&lt;circle cx="470" cy="278" r="4" fill="#c0392b"/>
&lt;circle cx="560" cy="170" r="4" fill="#c0392b"/>
&lt;text x="600" y="92" fill="#c0392b" font-size="12" font-weight="700">naive scalar PTQ&lt;/text>
&lt;text x="600" y="108" fill="#c0392b" font-size="11">collapses &amp;lt;3 bits&lt;/text>
&lt;polyline points="380,292 470,284 560,250 650,232" fill="none" stroke="#2471a3" stroke-width="2.6" stroke-dasharray="6 3"/>
&lt;circle cx="560" cy="250" r="4" fill="#2471a3"/>
&lt;circle cx="650" cy="232" r="4" fill="#2471a3"/>
&lt;text x="560" y="282" fill="#2471a3" font-size="12" font-weight="700">SOTA vector PTQ&lt;/text>
&lt;text x="560" y="298" fill="#2471a3" font-size="11">AQLM / QuIP# / QTIP&lt;/text>
&lt;circle cx="650" cy="225" r="6" fill="#27ae60"/>
&lt;text x="600" y="208" fill="#27ae60" font-size="12" font-weight="700">native ternary&lt;/text>
&lt;text x="600" y="222" fill="#27ae60" font-size="11">BitNet b1.58 (not PTQ)&lt;/text>
&lt;text x="120" y="290" fill="currentColor" font-size="11">≈ flat ≥ 4 bits with a good method&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three readings of the curve. &lt;strong>One&lt;/strong>: to the right of 4 bits, the three branches are almost stuck together and almost flat, the solved regime. &lt;strong>Two&lt;/strong>: the red branch (naive scalar PTQ) has a brutal elbow between 3 and 2 bits; that is where it doubles perplexity. The blue branch (state-of-the-art vector PTQ) flattens that elbow, it does not remove it, but it makes it tolerable down to 2 bits. &lt;strong>Three&lt;/strong>: the green point of native ternary &lt;strong>is on neither of the two PTQ curves&lt;/strong>, because it is not obtained by compressing: it is obtained by training, and that is why it can fall below the elbow without paying the quality price that any PTQ pays at that bit density. It is the difference between the crushed JPEG and the repainted photo.&lt;/p>
&lt;h2 id="mandatory-scepticism-lossless-1-bit-and-benchmarks-without-methodology">Mandatory scepticism: &amp;ldquo;lossless&amp;rdquo; 1-bit and benchmarks without methodology&lt;/h2>
&lt;p>Three warnings for reading the aggressive-quantisation literature:&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;1-bit lossless&amp;rdquo; almost always comes with small print.&lt;/strong> Pure binary {-1, +1} (1 bit) loses the ability to represent zero, which matters in transformers (many weights are effectively null). That is why the true state of the art at low density is &lt;strong>ternary&lt;/strong> (1.58 bits), not binary: the zero is worth its extra 0.58 of a bit. When a paper announces &amp;ldquo;1-bit&amp;rdquo;, it pays to check whether (a) it really is 1 bit or 1.58 rounded down in the headline, (b) &amp;ldquo;lossless&amp;rdquo; is measured in WikiText perplexity (easy) or on reasoning benchmarks (where the collapse shows up), and (c) it compares against a baseline of the same effective size or against a much larger model in order to inflate the advantage.&lt;/p>
&lt;p>&lt;strong>Flat perplexity ≠ preserved quality.&lt;/strong> Perplexity on a generic corpus is the metric most forgiving of aggressive quantisation. A 2-bit model can have a perplexity almost identical to BF16 and at the same time drop 10 points on GSM8K or on a code benchmark, because multi-step reasoning amplifies errors that average perplexity does not see. Distrust any sub-4-bit claim that only reports perplexity. As we already said in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">the quantization post&lt;/a>, the loss has to be measured on the target task.&lt;/p>
&lt;p>&lt;strong>Hardware comparability.&lt;/strong> The &amp;ldquo;X times faster&amp;rdquo; numbers for ternary only apply &lt;strong>with the specialised kernels&lt;/strong> (bitnet.cpp) and on hardware where addition/subtraction arithmetic genuinely wins. On a GPU with tensor cores designed for FP16/FP8, a naive ternary kernel can be &lt;strong>slower&lt;/strong> than a well-optimised INT4, because it wastes the silicon. The ternary advantage is real, but it is a &lt;strong>co-design&lt;/strong> advantage (model + kernel + sometimes hardware), not a flag you switch on over your current stack. Any benchmark that does not specify the exact kernel and hardware is noise.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>On the &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong>: the practical regime today is still INT4 AWQ for 7-14B models, solved, almost lossless, natively supported. State-of-the-art 2-bit (AQLM/QuIP#/QTIP) is viable and lets you load larger models or more models at once, but it demands the specific kernels of each method and an expensive calibration, and it pays in quality. It makes sense when VRAM is the bottleneck and you accept the trade-off; not as a default. Ternary on a 4090 is experimental: without tensor cores designed for ternary addition and subtraction, the compute advantage is diluted, although the memory saving remains.&lt;/p>
&lt;p>On a &lt;strong>generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/strong>: here the default is FP8 (almost indistinguishable quality, native throughput) or INT4 AWQ for models that do not fit in FP8. State-of-the-art sub-4-bit is for serving enormous models (200B+) when neither FP8 nor INT4 fits with the KV cache headroom you want, at the cost of quality and kernel complexity. Native ternary, today, is a research object rather than a production one: its promise, hitting both roofline ceilings, is greater on CPU/edge (where there are no FP8 tensor cores to exploit) than on an H100 cluster, which already has dedicated FP8 hardware.&lt;/p>
&lt;p>The rule of thumb, June 2026: &lt;strong>≥4-bit is settled engineering; state-of-the-art 2-bit is a real lever but with a cost in method and in quality; ternary is an architectural bet, not a deployment tweak&lt;/strong>.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference: FP8, INT4, GGUF&lt;/a> — the indispensable base: the scale+zero-point maths, GPTQ/AWQ and PTQ vs QAT, all taken for granted here; this post is its continuation towards the sub-4-bit frontier.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end: weights, KV and quality&lt;/a> — the other end of the spectrum, the solved datacenter regime where quantisation costs almost no quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the complementary lever: distilling reduces parameters, quantising reduces bits per parameter; at 2-bit they are usually combined to reach the target footprint.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — sparsity and aggressive quantisation are orthogonal and they compound: 50 % sparse + 2-bit is another route to the same footprint as ternary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — the ~4 GB we reserve for KV in the 4090 arithmetic come from here; quantising the cache is the other half of the memory budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline of small models&lt;/a> — why ternary is special: it attacks the memory ceiling and the compute ceiling at once, while INT4/FP8 only touch the memory one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/">Test-time quantization on the fly&lt;/a> — quantising dynamically at inference time against the static, calibrated quantisation that AQLM/QuIP#/QTIP describe.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Device-native architectures + fine-grained MoE&lt;/a> — on-device Q4 as the starting point of which sub-4-bit and ternary are the next frontier for edge.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA on SLMs&lt;/a> — fine-tuning on an already quantised base; the limit of how far you can compress the base before the adapter can no longer recover the quality.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Ma, S. et al. &lt;em>The Era of 1-bit LLMs: All Large Language Models are in 1.58 Bits&lt;/em> (BitNet b1.58). &lt;a href="https://arxiv.org/abs/2402.17764">https://arxiv.org/abs/2402.17764&lt;/a>&lt;/li>
&lt;li>Egiazarian, V., Panferov, A., Kuznedelev, D. et al. &lt;em>Extreme Compression of Large Language Models via Additive Quantization&lt;/em> (AQLM). &lt;a href="https://arxiv.org/abs/2401.06118">https://arxiv.org/abs/2401.06118&lt;/a>&lt;/li>
&lt;li>Tseng, A., Chee, J., Sun, Q., Kuleshov, V., De Sa, C. &lt;em>QuIP#: Even Better LLM Quantization with Hadamard Incoherence and Lattice Codebooks&lt;/em>. &lt;a href="https://arxiv.org/abs/2402.04396">https://arxiv.org/abs/2402.04396&lt;/a>&lt;/li>
&lt;li>Tseng, A., Sun, Q., Hou, D., De Sa, C. &lt;em>QTIP: Quantization with Trellises and Incoherence Processing&lt;/em>. &lt;a href="https://arxiv.org/abs/2406.11235">https://arxiv.org/abs/2406.11235&lt;/a>&lt;/li>
&lt;li>Frantar, E., Ashkboos, S., Hoefler, T., Alistarh, D. &lt;em>GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers&lt;/em>. &lt;a href="https://arxiv.org/abs/2210.17323">https://arxiv.org/abs/2210.17323&lt;/a>&lt;/li>
&lt;li>Lin, J., Tang, J., Tang, H., Yang, S., Dang, X., Han, S. &lt;em>AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration&lt;/em>. &lt;a href="https://arxiv.org/abs/2306.00978">https://arxiv.org/abs/2306.00978&lt;/a>&lt;/li>
&lt;li>Google DeepMind. &lt;em>Gemma 3 QAT (Quantization-Aware Training) models&lt;/em> — official blog: &lt;a href="https://developers.googleblog.com/en/gemma-3-quantized-aware-trained-state-of-the-art-ai-to-consumer-gpus/">https://developers.googleblog.com/en/gemma-3-quantized-aware-trained-state-of-the-art-ai-to-consumer-gpus/&lt;/a>&lt;/li>
&lt;li>Microsoft. &lt;em>bitnet.cpp&lt;/em> — 1-bit ternary inference kernels: &lt;a href="https://github.com/microsoft/BitNet">https://github.com/microsoft/BitNet&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Test-time quantization: quantising on the fly with no calibration dataset</title><link>https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/</link><pubDate>Tue, 09 Jun 2026 02:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/</guid><description>&lt;blockquote>
&lt;p>This post is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a>, which is worth reading first: that is where GPTQ, AWQ, scale + zero-point and why activation outliers are the central problem all live. Here we are not discussing &lt;em>how many bits&lt;/em> to use, but &lt;strong>when and with what information the scales are computed&lt;/strong>: offline against a corpus (PTQ) or on the fly against real traffic (TTQ).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Activation-aware quantisation (AWQ, SmoothQuant) decides which channels to protect by measuring the magnitude of the activations over a &lt;strong>calibration dataset&lt;/strong> in an &lt;strong>offline pass&lt;/strong>, before deployment. The implicit assumption is that this corpus represents future traffic. But activation outliers, the channels with 10-100× the median magnitude that dominate the quantisation error, depend on the input: they change with the domain, the language and the client&amp;rsquo;s distribution. When real traffic drifts away from the calibration, the fixed scales stop being optimal and quality drops. &lt;strong>Test-time quantization (TTQ)&lt;/strong> removes the corpus and the offline pass: it derives the activation-aware scales &lt;strong>at inference time&lt;/strong>, from the activations that are actually observed, per token or per batch. The trade-off is honest and not minor: it introduces &lt;strong>runtime overhead&lt;/strong>, computing statistics, detecting outliers, recomputing scales at every step, which competes directly with the saving from quantising. On small models that overhead weighs proportionally more, because the forward is short and the fixed per-step costs dominate (the framing is in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">the inverted roofline for SLMs&lt;/a>). TTQ is &lt;strong>orthogonal&lt;/strong> to the format: it is not a competitor to INT4 or FP8, it is a different way of deriving &lt;em>s&lt;/em>. It pays off when there is no calibration pipeline, when the traffic distribution is shifting or unknown, and in multitenant settings where no representative corpus exists.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;defs>&lt;marker id="ttqm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">You are here: DEPLOY · deriving quantization scales on the fly&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="85" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="210" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="335" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" rx="6" fill="#7ad88f" stroke="#444" stroke-width="3"/>&lt;text x="460" y="58" text-anchor="middle" fill="#111" font-size="12" font-weight="600">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="585" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="710" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">6 · Retrain&lt;/text>
&lt;path d="M140,52 L155,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M265,52 L280,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M390,52 L405,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M515,52 L530,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M640,52 L655,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M710,72 L710,82 L85,82 L85,72" stroke="#888" stroke-width="1.2" fill="none" stroke-dasharray="4 2" marker-end="url(#ttqm)"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-tailor-who-takes-measurements-versus-off-the-peg-sizes">The analogy: the tailor who takes measurements versus off-the-peg sizes&lt;/h2>
&lt;p>A clothes shop has two ways of dressing a customer.&lt;/p>
&lt;p>The first is to &lt;strong>sell off-the-peg sizes&lt;/strong>. At some point the factory measured an &amp;ldquo;average customer&amp;rdquo;, a mean mannequin built from a population sample, and cut the garments to those measurements. When a customer walks in, you hand them the size that comes closest. It is extremely fast: the garment is already sewn, it just gets handed over. The problem appears when the customer does not look like the average mannequin: if their shoulders are much broader than average, their particular outlier, the standard size pulls or leaves spare fabric, because it was cut to protect &lt;em>other&lt;/em> areas. This is &lt;strong>offline calibrated PTQ&lt;/strong>: AWQ measured the importance of each channel over a corpus and fixed the scales once and for all; fast at inference time, but blind to the specific customer.&lt;/p>
&lt;p>The second is &lt;strong>the tailor who takes measurements on the spot&lt;/strong>. When the customer walks in, the tailor gets out the tape, measures &lt;em>that customer&lt;/em>, spots where their particular bulk is and adjusts the cut to their real anatomy. The result fits better, especially for customers who fall outside the mould. But every customer costs time: measuring, marking, deciding. This is &lt;strong>TTQ&lt;/strong>: the scales are derived on the fly from the activations that the input actually generates.&lt;/p>
&lt;p>The analogy holds up in three details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The average mannequin = the calibration dataset.&lt;/strong> If the population walking into the shop resembles the mannequin, the sizes work; if not, they fail at the extremes.&lt;/li>
&lt;li>&lt;strong>Measuring every customer = computing activation statistics per token/batch.&lt;/strong> A better fit, but a fixed cost paid on &lt;em>every&lt;/em> garment.&lt;/li>
&lt;li>&lt;strong>The broad shoulders = the outlier activation channels.&lt;/strong> They are precisely the areas where the fit matters and where the generic size gets it most wrong.&lt;/li>
&lt;/ul>
&lt;p>The tailor wins when the customers are varied or unknown. The tailor loses when you have a homogeneous population and a mannequin that represents it well: there, paying for measurement on every customer is a waste of time.&lt;/p>
&lt;h2 id="the-problem-ttq-solves-fixed-calibration-ages-with-the-traffic">The problem TTQ solves: fixed calibration ages with the traffic&lt;/h2>
&lt;p>Let us recall from the &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization post&lt;/a> what AWQ and SmoothQuant do exactly. They do not quantise all channels equally: they identify the ~1 % of channels whose activations have a large magnitude, the &lt;em>salient channels&lt;/em>, and protect them by scaling them before quantising. To measure that importance they need to see activations, and they see them over a &lt;strong>calibration dataset&lt;/strong> (128-512 samples, typically WikiText or a slice of the domain) in an &lt;strong>offline pass&lt;/strong> prior to deployment.&lt;/p>
&lt;p>The assumption is strong: that the activation distribution of the calibration corpus &lt;strong>represents that of production traffic&lt;/strong>. Two reasons why that assumption breaks:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Activation outliers depend on the input.&lt;/strong> They are not a fixed property of the model the way weights are. The channel that is an outlier when processing C++ code may not be one when processing conversational Arabic or log JSON. The magnitude and position of the peaks change with the domain, the language and the input format.&lt;/li>
&lt;li>&lt;strong>Real traffic is rarely the corpus.&lt;/strong> You calibrate with English WikiText and the client sends you support tickets in Spanish with tables pasted in. The calibration protected the channels that &lt;em>WikiText&lt;/em> activated, not the ones that real traffic activates. The scales are suboptimal exactly where the client lives.&lt;/li>
&lt;/ol>
&lt;p>The result is distribution-dependent degradation: the quantised model keeps its quality while the input resembles the calibration and loses it as the input drifts away. The most awkward case is &lt;strong>multitenant&lt;/strong>: if you serve clients with different domains from the same quantised model, no single representative corpus exists; any fixed calibration favours some tenants and penalises others.&lt;/p>
&lt;h2 id="the-ttq-mechanism-measure-the-real-activations-and-scale-on-the-fly">The TTQ mechanism: measure the real activations and scale on the fly&lt;/h2>
&lt;p>TTQ (arXiv:2603.19296, March 2026) proposes deriving &lt;strong>activation-aware quantisation at inference time&lt;/strong>, with no offline pass and no calibration dataset. The idea, in its bare conceptual form:&lt;/p>
&lt;p>&lt;strong>Step 1 — Observe.&lt;/strong> When the activation tensor &lt;code>X&lt;/code> arrives at a linear layer (per token or per batch), cheap statistics are computed over the channels: a measure of central tendency (median or mean magnitude) and one of dispersion per channel. This is the equivalent of AWQ looking at its corpus, but done over the activations that &lt;em>really&lt;/em> are coming in right now.&lt;/p>
&lt;p>&lt;strong>Step 2 — Detect outliers on the fly.&lt;/strong> With those statistics, the channels whose magnitude shoots up relative to the tensor median are identified; the typical criterion is a threshold of the form &amp;ldquo;magnitude &amp;gt; k × median&amp;rdquo;. These are the channels that, if quantised with the same scale as the rest, blow up the error.&lt;/p>
&lt;p>&lt;strong>Step 3 — Derive scales and segregate.&lt;/strong> For normal channels a scale is computed that exploits the range; for the outliers a different treatment is applied, a scale of their own, or keeping them at higher precision, in the style of &lt;em>on-the-fly mixed precision&lt;/em>. It is the same philosophy as LLM.int8() (segregating outliers to FP16) or AWQ (scaling salient channels), but with the threshold and the scales &lt;strong>recomputed over the current input&lt;/strong>, not frozen since calibration.&lt;/p>
&lt;p>&lt;strong>Step 4 — Quantise and multiply.&lt;/strong> With the fresh scales, the tensor is quantised and the GEMM runs. The activations entering the next layer compensate for the rescaling, just as in AWQ, so that the maths cancels out.&lt;/p>
&lt;p>The key difference with AWQ is not in &lt;em>what&lt;/em> is done (protecting activation outliers) but in &lt;em>when&lt;/em> and &lt;em>against what&lt;/em>: AWQ decides once, offline, against a corpus; TTQ decides at every step, on the fly, against real traffic. It is the transfer to inference of the &amp;ldquo;test-time&amp;rdquo; idea: adapting the computation to the specific sample in front of you instead of to a precomputed average.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Offline calibrated PTQ versus on-the-fly TTQ">
&lt;defs>&lt;marker id="ttq2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="195" y="22" text-anchor="middle" fill="currentColor" font-size="13" font-weight="700">Offline-calibrated PTQ (AWQ / GPTQ)&lt;/text>
&lt;text x="585" y="22" text-anchor="middle" fill="currentColor" font-size="13" font-weight="700">On-the-fly TTQ&lt;/text>
&lt;line x1="390" y1="35" x2="390" y2="285" stroke="#bbb" stroke-width="1" stroke-dasharray="4 3"/>
&lt;rect x="30" y="45" width="150" height="38" rx="6" fill="#ffe6d6" stroke="#a05a2c" stroke-width="1.4"/>&lt;text x="105" y="68" text-anchor="middle" fill="#111" font-size="11" font-weight="600">calibration dataset&lt;/text>
&lt;rect x="210" y="45" width="150" height="38" rx="6" fill="#ffe6d6" stroke="#a05a2c" stroke-width="1.4"/>&lt;text x="285" y="64" text-anchor="middle" fill="#111" font-size="11" font-weight="600">OFFLINE pass&lt;/text>&lt;text x="285" y="78" text-anchor="middle" fill="#444" font-size="10">fixes scales s, outliers&lt;/text>
&lt;path d="M180,64 L210,64" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="120" y="105" width="150" height="34" rx="6" fill="#f0f0f0" stroke="#444" stroke-width="1.4"/>&lt;text x="195" y="126" text-anchor="middle" fill="#111" font-size="11" font-weight="600">FROZEN scales&lt;/text>
&lt;path d="M285,83 L285,98 L195,98 L195,105" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="30" y="165" width="150" height="34" rx="6" fill="#d6eaff" stroke="#1f5fa8" stroke-width="1.4"/>&lt;text x="105" y="186" text-anchor="middle" fill="#111" font-size="11" font-weight="600">similar input → OK&lt;/text>
&lt;rect x="210" y="165" width="150" height="34" rx="6" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>&lt;text x="285" y="182" text-anchor="middle" fill="#111" font-size="11" font-weight="600">distant input →&lt;/text>&lt;text x="285" y="195" text-anchor="middle" fill="#a52a2a" font-size="10" font-weight="600">degradation&lt;/text>
&lt;path d="M150,139 L105,165" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;path d="M240,139 L285,165" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;text x="195" y="225" text-anchor="middle" fill="#444" font-size="10">inference overhead ≈ 0 · quality depends on calibration&lt;/text>
&lt;rect x="510" y="45" width="150" height="38" rx="6" fill="#d9f5d6" stroke="#2a7a40" stroke-width="1.4"/>&lt;text x="585" y="62" text-anchor="middle" fill="#111" font-size="11" font-weight="600">REAL activations&lt;/text>&lt;text x="585" y="77" text-anchor="middle" fill="#444" font-size="10">from current traffic&lt;/text>
&lt;rect x="510" y="100" width="150" height="34" rx="6" fill="#d9f5d6" stroke="#2a7a40" stroke-width="1.4"/>&lt;text x="585" y="115" text-anchor="middle" fill="#111" font-size="11" font-weight="600">measure + detect&lt;/text>&lt;text x="585" y="128" text-anchor="middle" fill="#444" font-size="10">outliers ON THE FLY&lt;/text>
&lt;path d="M585,83 L585,100" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="510" y="151" width="150" height="34" rx="6" fill="#d9f5d6" stroke="#2a7a40" stroke-width="1.4"/>&lt;text x="585" y="166" text-anchor="middle" fill="#111" font-size="11" font-weight="600">FRESH scales&lt;/text>&lt;text x="585" y="179" text-anchor="middle" fill="#444" font-size="10">per token / batch&lt;/text>
&lt;path d="M585,134 L585,151" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="510" y="202" width="150" height="34" rx="6" fill="#fff5b0" stroke="#9a8400" stroke-width="1.4"/>&lt;text x="585" y="217" text-anchor="middle" fill="#111" font-size="11" font-weight="600">quantize + GEMM&lt;/text>&lt;text x="585" y="230" text-anchor="middle" fill="#9a6b00" font-size="10" font-weight="600">+ per-step overhead&lt;/text>
&lt;path d="M585,185 L585,202" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;text x="585" y="258" text-anchor="middle" fill="#444" font-size="10">no corpus · quality robust to distribution · overhead ≠ 0&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;h3 id="the-error-of-quantising-an-outlier-with-the-wrong-scale">The error of quantising an outlier with the wrong scale&lt;/h3>
&lt;p>Let us recall affine uniform quantisation from the &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">base post&lt;/a>: an integer code &lt;code>q = round(x/s) - z&lt;/code> with scale &lt;code>s&lt;/code> and zero-point &lt;code>z&lt;/code>, and reconstruction &lt;code>x̂ = s·(q + z)&lt;/code>. For a &lt;code>b&lt;/code>-bit quantiser with a symmetric range, the scale that covers a tensor of maximum magnitude &lt;code>M&lt;/code> is approximately &lt;code>s = M / (2^{b-1} - 1)&lt;/code>. The rounding error of each element is bounded by half a scale: &lt;code>|x - x̂| ≤ s/2&lt;/code>.&lt;/p>
&lt;p>Here is the outlier problem. The scale &lt;code>s&lt;/code> is chosen to cover the &lt;strong>largest&lt;/strong> value in the group. If one channel has 30× the median magnitude and you share a single scale with the rest of the tensor, that magnitude rules: &lt;code>M&lt;/code> is the outlier, so &lt;code>s&lt;/code> is inflated 30× relative to what the majority would need. The absolute rounding error of the normal values rises proportionally.&lt;/p>
&lt;p>A concrete calculation. Take a group where the median magnitude is 1.0 and an outlier channel is 30.0, quantised to INT4 (&lt;code>b = 4&lt;/code>, levels ±7):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>With a shared scale&lt;/strong>, &lt;code>s = 30 / 7 ≈ 4.29&lt;/code>. The rounding error of a typical value (magnitude ~1) is up to &lt;code>s/2 ≈ 2.14&lt;/code>. In other words, &lt;strong>the error on the normal values is of the order of their own value&lt;/strong>: the outlier has destroyed the resolution of everything else. Relative error of a value of magnitude 1: up to ~214 %.&lt;/li>
&lt;li>&lt;strong>Segregating the outlier&lt;/strong> (pulling it out to FP16 or giving it its own scale) and quantising the rest with &lt;code>M = 1&lt;/code>, &lt;code>s = 1/7 ≈ 0.143&lt;/code>. The error of a typical value drops to &lt;code>s/2 ≈ 0.071&lt;/code>, ~7 % relative. &lt;strong>Thirty times less error&lt;/strong> on the majority of the group&amp;rsquo;s weights.&lt;/li>
&lt;/ul>
&lt;p>That is the entire reason for the existence of activation-aware quantisation: &lt;strong>to detect and treat separately the ~1 % of channels that, if not segregated, hijack the scale&lt;/strong>. AWQ does it against the corpus; TTQ does it against the real input. And if the channel that is an outlier &lt;em>in production&lt;/em> was not an outlier &lt;em>during calibration&lt;/em>, AWQ did not protect it: it quantised real traffic with the inflated scale from the case above. That is where TTQ gains precision.&lt;/p>
&lt;h3 id="the-overhead-the-cost-of-measuring-at-every-step">The overhead: the cost of measuring at every step&lt;/h3>
&lt;p>The price is symmetric. Computing the per-token statistics, per-channel magnitudes, median or percentile, outlier threshold, scales, means reductions over the activation tensor that did not exist in the forward pass with frozen scales. Let us call:&lt;/p>
&lt;ul>
&lt;li>&lt;code>T&lt;/code> = forward time per token with fixed scales (static PTQ), in µs.&lt;/li>
&lt;li>&lt;code>Δ&lt;/code> = extra per-token cost of deriving the statistics and scales on the fly, in µs.&lt;/li>
&lt;/ul>
&lt;p>The relative overhead is simply:&lt;/p>
$$\text{overhead} = \frac{\Delta}{T}$$
&lt;p>The key point is that &lt;code>Δ&lt;/code> is relatively fixed per step (it depends on the number of channels and layers, not on how much &amp;ldquo;useful&amp;rdquo; work the model does), while &lt;code>T&lt;/code> scales with the size of the model. That is why the ratio behaves very differently depending on the model:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Large model&lt;/strong> (say, 70B): &lt;code>T&lt;/code> is large, every forward moves tens of GB of weights from HBM. If &lt;code>Δ ≈ 8 µs&lt;/code> and &lt;code>T ≈ 800 µs&lt;/code>, the overhead is &lt;code>8/800 = 1 %&lt;/code>. Negligible against the saving from quantising.&lt;/li>
&lt;li>&lt;strong>SLM&lt;/strong> (say, 1B): &lt;code>T&lt;/code> is small, the forward per token is short. With the same &lt;code>Δ ≈ 8 µs&lt;/code> and &lt;code>T ≈ 60 µs&lt;/code>, the overhead is &lt;code>8/60 ≈ 13 %&lt;/code>. No longer negligible: it eats a good part of what you gained by quantising.&lt;/li>
&lt;/ul>
&lt;p>This connects directly to the &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">inverted roofline for small models&lt;/a>: on an SLM the &lt;strong>fixed per-step costs&lt;/strong> (kernel launches, synchronisations, overheads that do not scale with the model) weigh proportionally more, because there is less useful work to spread them over. TTQ&amp;rsquo;s &lt;code>Δ&lt;/code> is exactly one of those fixed costs. Going per-batch instead of per-token amortises &lt;code>Δ&lt;/code> across all the tokens of the batch and lowers the relative overhead, at the cost of coarser scales; it is the first parameter to touch.&lt;/p>
&lt;p>The uncomfortable conclusion: TTQ gives away robustness to distribution but spends part of the acceleration budget on measuring, and in the regime where acceleration is scarcest, the SLMs, the ones most often deployed at the edge, is where that spending hurts most. It is not free; it is a change of currency.&lt;/p>
&lt;blockquote>
&lt;p>A note of methodological scepticism: arXiv:2603.19296 is from &lt;strong>March 2026&lt;/strong>, very recent, and as of the date of this post there are no broad independent reproductions. Whatever speedup and quality figures circulate should be taken with the same caution as any number without published methodology: what hardware, what batch size, what real measured &lt;code>Δ&lt;/code>, against what baseline (well-calibrated or badly calibrated PTQ), in what domain? The &lt;em>conceptual&lt;/em> argument, robustness to distribution in exchange for per-step overhead, is solid; the concrete multipliers are pending validation.&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-ttq-is-not-marking-it-off-from-the-rest-of-the-zoo">What TTQ is NOT: marking it off from the rest of the zoo&lt;/h2>
&lt;p>TTQ is easily confused with neighbouring techniques. The distinction that matters is that &lt;strong>TTQ is the &lt;em>how&lt;/em> of deriving the scales, not the format nor the moment in training&lt;/strong>.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Technique&lt;/th>
&lt;th>When the scales are fixed&lt;/th>
&lt;th>Needs a calibration corpus&lt;/th>
&lt;th>Touches training&lt;/th>
&lt;th>Is it a format&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Static PTQ&lt;/strong> (GPTQ, AWQ)&lt;/td>
&lt;td>Offline, before deployment&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No (it uses INT4/INT8)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>QAT&lt;/strong>&lt;/td>
&lt;td>During training&lt;/td>
&lt;td>No (training data)&lt;/td>
&lt;td>Yes (retrains)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>FP8 end to end&lt;/strong>&lt;/td>
&lt;td>At runtime, but simple per-tensor scales&lt;/td>
&lt;td>Minimal / none&lt;/td>
&lt;td>No&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong> (E4M3/E5M2)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TTQ&lt;/strong>&lt;/td>
&lt;td>At runtime, activation-aware per token/batch&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No (orthogonal to the format)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The four distinctions, one by one:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Against static PTQ (GPTQ/AWQ).&lt;/strong> Same goal (protecting outliers), the same possible format (INT4), but PTQ freezes the decisions offline against a corpus and TTQ recomputes them on the fly. TTQ is, in a sense, &amp;ldquo;AWQ without the calibration phase, paid for at runtime&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Against QAT.&lt;/strong> QAT puts quantisation inside the training loop so that the model learns to be robust to it; it costs a retrain. TTQ does not touch training: it operates on an already trained model, at inference. They are attacks at opposite moments of the pipeline.&lt;/li>
&lt;li>&lt;strong>Against FP8 end to end.&lt;/strong> FP8 is a &lt;strong>format&lt;/strong> with its own logarithmic range; its &amp;ldquo;dynamic scaling&amp;rdquo; computes a simple per-tensor scalar at runtime, but it does not do activation-aware per-channel outlier detection. TTQ could, conceptually, derive scales on the fly &lt;em>for&lt;/em> an FP8 or INT4 quantiser: it is orthogonal to the format.&lt;/li>
&lt;li>&lt;strong>TTQ is orthogonal to the format.&lt;/strong> It decides &lt;em>how&lt;/em> to obtain &lt;code>s&lt;/code>, not how many bits you store &lt;code>q&lt;/code> in. You can imagine &amp;ldquo;TTQ over INT4&amp;rdquo; or &amp;ldquo;TTQ over FP8&amp;rdquo;. What defines TTQ is the source of the scale, real activations on the fly, not the width of the code.&lt;/li>
&lt;/ul>
&lt;h2 id="when-it-pays-off-and-when-it-does-not">When it pays off (and when it does not)&lt;/h2>
&lt;p>TTQ is not a universal replacement for AWQ. It is a tool for a specific deployment profile. &lt;strong>It pays off when:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You have no calibration pipeline.&lt;/strong> You want to deploy a quantised model &lt;em>now&lt;/em>, without assembling the calibration dataset, running the offline pass or validating that the corpus represents the traffic. TTQ cuts out that whole phase: you load the model and serve.&lt;/li>
&lt;li>&lt;strong>The traffic distribution is shifting or unknown.&lt;/strong> An assistant that one day receives code and the next legal contracts in another language. No fixed calibration covers both well; on-the-fly adaptation follows the distribution without recalibrating.&lt;/li>
&lt;li>&lt;strong>Multitenant with no representative corpus.&lt;/strong> You serve the same model to clients with disparate domains. No single corpus represents all of them; any fixed calibration creates winners and losers among tenants. TTQ adjusts to each input, whichever tenant it comes from.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>It does not pay off when:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You have a stable domain and a good calibration corpus.&lt;/strong> If your traffic is homogeneous and representative, offline AWQ gives you the same quality with zero runtime overhead. Paying &lt;code>Δ&lt;/code> on every token to relearn what a corpus already captured is waste.&lt;/li>
&lt;li>&lt;strong>You serve SLMs with a tight latency SLA.&lt;/strong> This is exactly the case where &lt;code>Δ/T&lt;/code> is high. If the model is small and TPOT matters, the overhead of measuring can wipe out the gain from quantising. Measure your real &lt;code>Δ&lt;/code> before assuming it adds up.&lt;/li>
&lt;li>&lt;strong>The batch is large and compute-bound.&lt;/strong> With high concurrency the forward is no longer memory-bound and the cost of the extra reductions competes worse; at the very least, amortise &lt;code>Δ&lt;/code> per batch.&lt;/li>
&lt;/ul>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace">On an RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The natural case for the 4090 is the SLM, Qwen 3 1.5B, Llama 3 8B AWQ-INT4, serving at low concurrency. That is precisely the regime where TTQ is riskiest: &lt;code>T&lt;/code> per token is small and the 4090 has no accelerated native FP8 (we discussed this in the &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization post&lt;/a>), so TTQ&amp;rsquo;s extra reductions run on CUDA cores competing for the same time. Here the question is not &amp;ldquo;does it improve quality?&amp;rdquo; but &amp;ldquo;does the overhead leave me an acceptable TPOT?&amp;rdquo;. If the traffic is homogeneous, offline AWQ wins on simplicity and latency. TTQ only justifies its &lt;code>Δ&lt;/code> if the input distribution is genuinely unpredictable and the degradation from fixed calibration is measurable.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here the calculation partly flips. With large models &lt;code>T&lt;/code> is high and &lt;code>Δ/T&lt;/code> drops to the low single-digit percentage range, so TTQ&amp;rsquo;s overhead is more digestible. The strong use case is &lt;strong>multitenant&lt;/strong>: a cluster serving a large model to clients with heterogeneous domains, where no calibration corpus satisfies everyone. There, TTQ&amp;rsquo;s robustness to distribution has real value and the overhead is diluted in a big forward. Even so, on an H100 with native FP8, the baseline to beat is demanding: static FP8 barely loses quality (see the table in the quantization post) and costs nothing at runtime. TTQ has to show that its robustness gain on the outlier tenants outweighs what it gives away in overhead. With a paper from March 2026 and no reproductions, that demonstration is pending.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The memory cost of the on-the-fly statistics&lt;/strong>: per-channel buffers, their impact on the footprint and on cache pressure.&lt;/li>
&lt;li>&lt;strong>Interaction with continuous batching&lt;/strong>: how scales are derived when a batch mixes requests from different domains in the same step.&lt;/li>
&lt;li>&lt;strong>TTQ + speculative decoding&lt;/strong>: whether the draft and the target derive scales on the fly separately, and how that affects the acceptance rate.&lt;/li>
&lt;li>&lt;strong>Numerical stability&lt;/strong>: what happens when a batch contains a one-off extreme outlier that inflates the scale for every token of that step.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a> — the indispensable base: scale + zero-point, GPTQ, AWQ and why activation outliers are the problem; TTQ is AWQ with the scales derived on the fly instead of offline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline for small models&lt;/a> — why fixed per-step costs weigh more on an SLM; it explains directly why TTQ&amp;rsquo;s &lt;code>Δ&lt;/code> overhead hurts more on small models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> — the static frontier below 4 bits; it complements TTQ, which attacks the &lt;em>how&lt;/em> of the scale instead of the &lt;em>how many bits&lt;/em>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA on SLMs&lt;/a> — adapters on a quantised base; the base could derive scales on the fly while the adapters stay in BF16.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end: weights, KV and quality&lt;/a> — the Hopper/Blackwell datacenter format; TTQ is orthogonal and could derive scales for an FP8 quantiser.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of LLM inference&lt;/a> — the KV cache is quantised too; its scales are another candidate to be derived on the fly by the same logic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the other route to serving robust small models; distilling shrinks the model, TTQ tunes its quantisation to the traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — where the runtime quantisation levers for squeezing a 4090 turn into concrete parameters.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;em>TTQ: Activation-Aware Test-Time Quantization to Accelerate LLM Inference On The Fly&lt;/em> (March 2026). &lt;a href="https://arxiv.org/abs/2603.19296">https://arxiv.org/abs/2603.19296&lt;/a>&lt;/li>
&lt;li>Lin, J., Tang, J., Tang, H., Yang, S., Dang, X., Han, S. &lt;em>AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration&lt;/em> (MLSys 2024). &lt;a href="https://arxiv.org/abs/2306.00978">https://arxiv.org/abs/2306.00978&lt;/a>&lt;/li>
&lt;li>Frantar, E., Ashkboos, S., Hoefler, T., Alistarh, D. &lt;em>GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers&lt;/em> (ICLR 2023). &lt;a href="https://arxiv.org/abs/2210.17323">https://arxiv.org/abs/2210.17323&lt;/a>&lt;/li>
&lt;li>Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J., Han, S. &lt;em>SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models&lt;/em> (ICML 2023). &lt;a href="https://arxiv.org/abs/2211.10438">https://arxiv.org/abs/2211.10438&lt;/a>&lt;/li>
&lt;li>Dettmers, T., Lewis, M., Belkada, Y., Zettlemoyer, L. &lt;em>LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale&lt;/em> (NeurIPS 2022). &lt;a href="https://arxiv.org/abs/2208.07339">https://arxiv.org/abs/2208.07339&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Device-native architectures: fine-grained MoE and pre-attention router</title><link>https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/</link><pubDate>Tue, 09 Jun 2026 01:50:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/</guid><description>&lt;blockquote>
&lt;p>This post belongs to the series on inference performance in small models. It is the architectural face of a problem we have already looked at from the compute-regime side (the SLM&amp;rsquo;s inverted roofline) and from the weight-loading side in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>. Here the question is different: what if, instead of adapting a large model to the device, we design the model &lt;em>for&lt;/em> the device from the first commit?&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The default move to get an LLM onto a laptop, a phone or an edge box is to &lt;strong>take a dense model built for cloud and compress it&lt;/strong>: distillation, pruning, quantisation. It is a gesture of &lt;em>reduction&lt;/em>: you start from something big and take things away. SmallThinker (arXiv:2507.20984, SJTU IPADS + Zenergize AI) argues for the reverse gesture, &lt;em>designing from scratch&lt;/em>, and articulates it in three pieces. &lt;strong>First: fine-grained MoE&lt;/strong>, many small experts with very few activated per token, so that total parameters &lt;code>N&lt;/code> (the capacity) decouple from activated parameters &lt;code>A&lt;/code> (the compute cost per token). &lt;strong>Second: sparse FFN&lt;/strong>, ReLU-style activation sparsity inside each block, which adds a second level of sparsity on top of the first. &lt;strong>Third: a pre-attention router&lt;/strong> that predicts which experts will be needed &lt;em>before&lt;/em> running the attention block and launches the prefetch of those weights from SSD/flash in parallel with attention compute, hiding the storage latency, which is the real bottleneck when the model does not fit entirely in RAM. The authors report SmallThinker-4B-A0.6B and SmallThinker-21B-A3B exceeding ~20 tok/s on consumer CPUs with Q4_0, using ~1 GB and ~8 GB of RAM. The numbers are interesting and the direction is right; the evaluation methodology and the quality cost of activating so little deserve scepticism, and that is what the last part is for.&lt;/p>
&lt;h2 id="the-analogy-the-librarian-who-anticipates-your-request">The analogy: the librarian who anticipates your request&lt;/h2>
&lt;p>Picture a huge library with a small reading room. You are sitting in the room at a single desk: only a few books fit on it at a time (that is RAM). The bulk of the collection is in the back room, on long shelves that are slow to walk (that is the SSD/flash). And there is a librarian.&lt;/p>
&lt;p>The naive method: you read, you reach a point where you need a specific book, you request it, and then the librarian gets up, walks to the back room, looks for it and comes back. Meanwhile, you wait with the page open, making no progress. Every time you need a new book, you pay the full trip to the back room. The reading room spends most of its time waiting, not reading.&lt;/p>
&lt;p>The SmallThinker method: the librarian is clever and gets ahead of you. While you are still reading the chapter&amp;rsquo;s &lt;strong>table of contents&lt;/strong>, working out what it is about and connecting ideas, which in the model is the &lt;strong>attention&lt;/strong> block, they have already glanced over your shoulder, &lt;strong>predicted&lt;/strong> which three or four books you are going to ask for and gone to the back room to fetch them. By the time you finish the contents and formulate the request, the books are already on your desk. You have not waited: the trip to the back room happened &lt;em>in parallel&lt;/em> with your reading of the contents.&lt;/p>
&lt;p>The analogy holds on four details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The small desk is RAM&lt;/strong>; the slow back room is the &lt;strong>SSD/flash&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>The books are the MoE&amp;rsquo;s experts&lt;/strong>: only a few are on the desk at any moment.&lt;/li>
&lt;li>&lt;strong>Reading the contents is the attention block&lt;/strong>; requesting and using the books is the FFN/expert block.&lt;/li>
&lt;li>&lt;strong>The librarian who predicts and gets ahead is the pre-attention router&lt;/strong>: the prediction is made first, and the trip to fetch (the prefetch) overlaps with reading the contents (attention).&lt;/li>
&lt;/ul>
&lt;p>The quantitative question running through the whole post is: does the librarian get back in time? The wait is only hidden if the trip to the back room takes less than your reading of the contents. That is the condition &lt;code>t_{\text{attention}} \ge t_{\text{prefetch}}&lt;/code>, and we will do it with numbers.&lt;/p>
&lt;h2 id="compressing-a-dense-model-vs-designing-for-device">Compressing a dense model vs. designing for device&lt;/h2>
&lt;p>It is worth laying out the two approaches plainly, because they are not degrees of the same thing: they are different philosophies.&lt;/p>
&lt;p>&lt;strong>Approach A — compress a dense model built for cloud.&lt;/strong> You start from, say, a dense 7B–14B model trained to run on an RTX 4090 (24 GB, Ada Lovelace) or on a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8). To fit it into a device you apply three levers, each with its own post: &lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">distillation&lt;/a> (you train a small student that imitates the teacher), &lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">pruning&lt;/a> (you remove weights or whole structures) and aggressive quantisation (you go down to 4 bits or fewer). The resulting model &lt;strong>is still dense&lt;/strong>: all its parameters are activated on every token. You have reduced the number of parameters, but the compute pattern is the cloud&amp;rsquo;s, only smaller.&lt;/p>
&lt;p>&lt;strong>Approach B — design for device from scratch.&lt;/strong> Here the device&amp;rsquo;s constraints enter the &lt;em>architecture&lt;/em>, not a later compression phase. The constraints are three and very concrete:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Weak compute.&lt;/strong> A laptop CPU or a mobile SoC does orders of magnitude fewer FLOPs than a datacenter GPU. This pushes you to minimise the &lt;strong>activated&lt;/strong> parameters per token, not the total ones.&lt;/li>
&lt;li>&lt;strong>Little RAM.&lt;/strong> Tens of GB do not fit. This pushes you to keep resident only what is essential and to &lt;em>stream&lt;/em> the rest.&lt;/li>
&lt;li>&lt;strong>Slow storage.&lt;/strong> The SSD or flash you are forced to stream from has far less bandwidth than a GPU&amp;rsquo;s HBM. This turns storage I/O into the bottleneck, and pushes you to &lt;em>hide&lt;/em> it.&lt;/li>
&lt;/ol>
&lt;p>SmallThinker is approach B taken to the detail: each of those three constraints has an architectural answer. Weak compute is attacked with fine-grained MoE + sparse FFN (minimise &lt;code>A&lt;/code>). Scarce RAM is attacked with streaming from SSD (resident ≈ &lt;code>A&lt;/code> + cache, not &lt;code>N&lt;/code>). Slow storage is attacked with the pre-attention router (hide the I/O behind attention). It is no accident that the three pieces fit together: each one solves a constraint, and together they reinforce each other.&lt;/p>
&lt;p>An important nuance, so as not to fall into the hype: approach B &lt;strong>is neither free nor universally superior&lt;/strong>. It requires training a new model (you do not reuse existing weights), and the quality ceiling of a model with a very small &lt;code>A&lt;/code> is intrinsically bounded, as we will see. The argument is not &amp;ldquo;B always wins&amp;rdquo;, but &amp;ldquo;for the device regime, B attacks the right bottlenecks, and A only attacks them in passing&amp;rdquo;.&lt;/p>
&lt;h2 id="two-levels-of-sparsity">Two levels of sparsity&lt;/h2>
&lt;p>The central capacity idea is old and well understood in &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE&lt;/a>: separate &lt;strong>capacity&lt;/strong> from &lt;strong>compute cost&lt;/strong>. In a MoE, the model has &lt;code>N&lt;/code> total parameters spread across experts, but for each token only &lt;code>A&lt;/code> parameters are activated (those of the top-k experts the router picks). Compute cost per token scales with &lt;code>A&lt;/code>; knowledge capacity scales with &lt;code>N&lt;/code>. SmallThinker applies this idea at &lt;strong>two superimposed levels&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Level 1 — fine-grained MoE.&lt;/strong> &amp;ldquo;Fine-grained&amp;rdquo; means many small experts instead of few large ones, with very few activated per token. Instead of, say, 8 experts of which you activate 2, you have dozens of experts of which you activate a handful. With smaller experts, the same &lt;code>A&lt;/code> is spread across more possible combinations, which gives the router fine granularity and keeps &lt;code>A&lt;/code> very low relative to &lt;code>N&lt;/code>. The result is an aggressive &lt;code>N/A&lt;/code> ratio: a lot of capacity, very little compute per token.&lt;/p>
&lt;p>&lt;strong>Level 2 — sparse FFN (ReLU-style activation sparsity).&lt;/strong> This level is orthogonal and operates &lt;em>inside&lt;/em> each FFN. With a ReLU-style non-linearity, a large fraction of the intermediate layer&amp;rsquo;s neurons produce exactly zero for a given token. A neuron that comes out at zero contributes nothing to the output: its matrix-vector multiplication can be skipped. This is &lt;em>activation sparsity&lt;/em>: predictable token by token, and exploitable so as not to load or multiply the weight rows/columns corresponding to inactive neurons. It is the same phenomenon exploited by work such as Deja Vu or PowerInfer; SmallThinker builds it in from the factory by choosing activations that favour it.&lt;/p>
&lt;p>The combined effect, in one sentence: &lt;strong>large &lt;code>N&lt;/code> (capacity), tiny &lt;code>A&lt;/code> (compute cost per token ≈ proportional to &lt;code>A&lt;/code>)&lt;/strong>, and on top of that, inside that &lt;code>A&lt;/code>, a fraction of the multiplications is saved through activation sparsity. It is sparsity on top of sparsity.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Classic MoE vs fine-grained MoE with sparse FFN">
&lt;defs>&lt;marker id="ar1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="195" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="currentColor">Classic MoE (coarse-grained)&lt;/text>
&lt;text x="585" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="currentColor">Fine-grained MoE + sparse FFN&lt;/text>
&lt;line x1="390" y1="35" x2="390" y2="285" stroke="currentColor" stroke-width="1" stroke-dasharray="4 3"/>
&lt;!-- classic: 8 large experts, 2 active -->
&lt;text x="40" y="50" font-size="11" fill="currentColor">8 large experts · activates 2&lt;/text>
&lt;rect x="40" y="60" width="64" height="40" fill="#1f5fa8" stroke="#13335c" stroke-width="1.4"/>
&lt;rect x="114" y="60" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="188" y="60" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="262" y="60" width="64" height="40" fill="#1f5fa8" stroke="#13335c" stroke-width="1.4"/>
&lt;rect x="40" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="114" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="188" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="262" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;text x="183" y="172" text-anchor="middle" font-size="11" fill="currentColor">large A per expert · coarse granularity&lt;/text>
&lt;!-- fine: many small experts, several active but low total A -->
&lt;text x="410" y="50" font-size="11" fill="currentColor">many small experts · activates few&lt;/text>
&lt;g>
&lt;rect x="410" y="60" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="444" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="478" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="512" y="60" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="546" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="580" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="614" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="648" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="410" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="444" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="478" y="88" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="512" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="546" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="580" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="614" y="88" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="648" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;/g>
&lt;text x="543" y="128" text-anchor="middle" font-size="11" fill="currentColor">low total A · fine granularity&lt;/text>
&lt;!-- sparse FFN inside -->
&lt;text x="410" y="158" font-size="11" font-weight="600" fill="currentColor">+ sparse FFN inside each active expert:&lt;/text>
&lt;g>
&lt;rect x="410" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="428" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="446" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="464" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="482" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="500" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="518" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="536" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="554" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="572" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;/g>
&lt;text x="600" y="192" font-size="11" fill="currentColor">neurons at 0 (ReLU) →&lt;/text>
&lt;text x="600" y="206" font-size="11" fill="currentColor">skipped in the compute&lt;/text>
&lt;text x="40" y="245" font-size="11.5" font-weight="600" fill="currentColor">Capacity = N (all experts) · Cost/token ≈ A (activated) · and inside A, sparse FFN saves more&lt;/text>
&lt;text x="40" y="270" font-size="11" fill="currentColor">The trick: raise N without raising A. Fine granularity allows a far more aggressive N/A ratio.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-pre-attention-router-predict-and-prefetch">The pre-attention router: predict and prefetch&lt;/h2>
&lt;p>Here is the paper&amp;rsquo;s specific piece, and the one that gives the post its name. The problem it solves is one of &lt;em>I/O scheduling&lt;/em>, not of quality.&lt;/p>
&lt;p>When the model does not fit entirely in RAM, the expert weights live in SSD/flash and are loaded on demand. The naive flow of a MoE layer is sequential: you run attention, then the router decides which experts are needed, then you &lt;strong>load those experts from SSD&lt;/strong> (waiting), then you run the FFN of those experts. The loading step is pure waiting: the CPU is blocked waiting for bytes from the SSD. In the device regime, where the SSD is slow, that waiting time dominates the decode step.&lt;/p>
&lt;p>The &lt;strong>pre-attention router&lt;/strong> breaks the sequentiality by inverting the order of the decision. The observation is that the router does not need &lt;em>this&lt;/em> same layer&amp;rsquo;s attention output to make a reasonable prediction of which experts will be needed: it can predict it from the state it already has &lt;em>before&lt;/em> running attention. So:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Before&lt;/strong> running the layer&amp;rsquo;s attention block, the router predicts the experts that will be needed.&lt;/li>
&lt;li>It launches the &lt;strong>prefetch&lt;/strong> of those experts from SSD/flash asynchronously.&lt;/li>
&lt;li>&lt;strong>In parallel&lt;/strong>, the CPU runs the attention block, which is pure compute and does not need the SSD.&lt;/li>
&lt;li>When attention finishes, the prefetched experts are (ideally) already in RAM, and the FFN proceeds without waiting.&lt;/li>
&lt;/ol>
&lt;p>Storage I/O has been &lt;strong>overlapped&lt;/strong> with attention compute. It is exactly the librarian who goes to the back room while you read the contents.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 270" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pre-attention router flow with overlapped prefetch">
&lt;defs>&lt;marker id="ar2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" font-size="12.5" font-weight="600" fill="currentColor">Naive (sequential): the load from SSD blocks&lt;/text>
&lt;rect x="20" y="32" width="110" height="34" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="75" y="53" text-anchor="middle" font-size="11" fill="#13335c">attention&lt;/text>
&lt;rect x="138" y="32" width="80" height="34" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="178" y="53" text-anchor="middle" font-size="11" fill="#3a1d70">router&lt;/text>
&lt;rect x="226" y="32" width="180" height="34" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="316" y="49" text-anchor="middle" font-size="11" fill="#6e1d1d">load experts from SSD&lt;/text>
&lt;text x="316" y="61" text-anchor="middle" font-size="10" fill="#6e1d1d">(blocking wait)&lt;/text>
&lt;rect x="414" y="32" width="110" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="469" y="53" text-anchor="middle" font-size="11" fill="#1a4d29">expert FFN&lt;/text>
&lt;path d="M130,49 L138,49" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ar2)"/>
&lt;path d="M218,49 L226,49" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ar2)"/>
&lt;path d="M406,49 L414,49" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ar2)"/>
&lt;text x="540" y="53" font-size="11" fill="currentColor">t_total = t_att + t_load + t_ffn&lt;/text>
&lt;line x1="20" y1="92" x2="760" y2="92" stroke="currentColor" stroke-width="0.8" stroke-dasharray="3 3"/>
&lt;text x="20" y="118" font-size="12.5" font-weight="600" fill="currentColor">Pre-attention router: the prefetch overlaps with attention&lt;/text>
&lt;text x="20" y="140" font-size="11" font-weight="600" fill="currentColor">compute thread (CPU)&lt;/text>
&lt;rect x="170" y="130" width="80" height="30" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="210" y="149" text-anchor="middle" font-size="10.5" fill="#3a1d70">router (pre)&lt;/text>
&lt;rect x="258" y="130" width="150" height="30" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="333" y="149" text-anchor="middle" font-size="11" fill="#13335c">attention (t_att)&lt;/text>
&lt;rect x="416" y="130" width="120" height="30" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="476" y="149" text-anchor="middle" font-size="11" fill="#1a4d29">expert FFN&lt;/text>
&lt;text x="20" y="190" font-size="11" font-weight="600" fill="currentColor">I/O thread (SSD)&lt;/text>
&lt;rect x="258" y="180" width="130" height="30" fill="#ffe0a8" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="323" y="199" text-anchor="middle" font-size="10.5" fill="#6b5400">prefetch experts (t_prefetch)&lt;/text>
&lt;path d="M250,145 C254,145 254,150 258,150" stroke="#5a2db0" stroke-width="1.2" fill="none"/>
&lt;path d="M250,150 L256,150 L256,195 L258,195" stroke="#a48000" stroke-width="1.2" fill="none" marker-end="url(#ar2)" stroke-dasharray="3 2"/>
&lt;line x1="258" y1="122" x2="258" y2="218" stroke="currentColor" stroke-width="0.7" stroke-dasharray="2 2"/>
&lt;line x1="408" y1="122" x2="408" y2="218" stroke="currentColor" stroke-width="0.7" stroke-dasharray="2 2"/>
&lt;text x="20" y="245" font-size="11.5" fill="currentColor">The prefetch is hidden if &lt;tspan font-weight="700">t_att ≥ t_prefetch&lt;/tspan>: by the time attention ends, the experts are in RAM.&lt;/text>
&lt;text x="20" y="262" font-size="11" fill="currentColor">If t_prefetch &amp;gt; t_att, a wait bubble (t_prefetch − t_att) appears before the FFN. That is the case to avoid.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The &lt;strong>hiding condition&lt;/strong> is the inequality above: the prefetch is completely hidden if and only if&lt;/p>
$$t_{\text{attention}} \;\ge\; t_{\text{prefetch}}.$$
&lt;p>If attention takes longer than loading the experts, the load is free (it was already done). If the experts are too large or the SSD too slow, &lt;code>t_prefetch &amp;gt; t_att&lt;/code> and a wait bubble equal to &lt;code>t_prefetch − t_att&lt;/code> appears. That is why the design &lt;em>needs&lt;/em> &lt;code>A&lt;/code> to be small (small experts → fewer bytes to prefetch → low &lt;code>t_prefetch&lt;/code>) and the grain to be fine: the two things level 1 of sparsity does are not only about saving FLOPs, they are about making the prefetch fit underneath attention.&lt;/p>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;h3 id="memory-footprint-resident-n-vs-a--cache">Memory footprint: resident N vs. A + cache&lt;/h3>
&lt;p>The parameter that decides whether the model fits is how much you have to keep &lt;strong>resident in RAM&lt;/strong> at once.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Everything in RAM.&lt;/strong> If you require all the experts to be loaded, the footprint is &lt;code>\approx N&lt;/code> (all parameters, multiplied by bytes/parameter according to the quantisation). For a 21B this is prohibitive on a device.&lt;/li>
&lt;li>&lt;strong>Streaming from SSD.&lt;/strong> If you only keep the active experts resident plus a cache of the recent/likely ones, the footprint drops to &lt;code>\approx A + \text{cache}&lt;/code>. The weights that are not in RAM live on SSD and get prefetched when needed. This is where the real saving is: the resident set scales with &lt;code>A&lt;/code>, not with &lt;code>N&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>The non-expert part of the model (embeddings, attention, router, layernorms) is always resident, but in a fine-grained MoE the bulk of &lt;code>N&lt;/code> is in the experts, so the approximation &lt;code>resident ≈ A + cache + dense_part&lt;/code> is a good one.&lt;/p>
&lt;h3 id="the-prefetch-calculation-with-numbers">The prefetch calculation, with numbers&lt;/h3>
&lt;p>Let us put numbers on the analogy. Suppose a consumer SSD at &lt;strong>5 GB/s&lt;/strong> of sequential read and a quantised expert of size &lt;code>X&lt;/code> MB. The time to load one expert is&lt;/p>
$$t_{\text{1 expert}} = \frac{X \text{ MB}}{5000 \text{ MB/s}} = \frac{X}{5000}\ \text{s} = \frac{X}{5}\ \text{ms}.$$
&lt;p>Let us make &lt;code>X&lt;/code> concrete. In SmallThinker-4B-A0.6B with Q4_0 (~0.5 byte/param effective counting block overhead), a small expert of, say, 4M parameters weighs &lt;code>\approx 4\text{M} \times 0.5 = 2&lt;/code> MB. Loading it costs &lt;code>t_{\text{1 expert}} = 2/5 = 0.4&lt;/code> ms.&lt;/p>
&lt;p>Now the scheduling question: if the layer&amp;rsquo;s attention block takes &lt;code>Y&lt;/code> ms, &lt;strong>how many experts can I prefetch while attention runs?&lt;/strong> The number is&lt;/p>
$$n_{\text{prefetch}} = \left\lfloor \frac{Y}{t_{\text{1 expert}}} \right\rfloor = \left\lfloor \frac{Y \cdot 5}{X} \right\rfloor.$$
&lt;p>With &lt;code>Y = 2&lt;/code> ms of attention and &lt;code>X = 2&lt;/code> MB per expert: &lt;code>n_{\text{prefetch}} = \lfloor 2 \times 5 / 2 \rfloor = 5&lt;/code> experts. That is, within that layer&amp;rsquo;s attention window the SSD manages to bring in 5 experts. If the layer&amp;rsquo;s top-k activates ≤ 5 experts, the prefetch hides them all and &lt;code>t_prefetch ≤ t_att&lt;/code>: zero load latency. If the layer needed 8 experts, you would bring 5 for free and pay the load of the remaining 3 as a bubble: &lt;code>(8-5) \times 0.4 = 1.2&lt;/code> ms of waiting per layer. Hence why the design wants fine grain with a small top-k: to fit underneath the attention window.&lt;/p>
&lt;p>Two critical observations about this calculation:&lt;/p>
&lt;ul>
&lt;li>The 5 GB/s is &lt;strong>idealised sequential read&lt;/strong>. The experts are scattered across the disk; random 4K reads on a consumer SSD are far slower. The effective bandwidth can be a fraction of the nominal one, which reduces &lt;code>n_{\text{prefetch}}&lt;/code>. Any methodology reporting tok/s ought to say whether it measures with experts pre-sorted on disk or with realistic access.&lt;/li>
&lt;li>The attention window &lt;code>Y&lt;/code> &lt;strong>shrinks with short context&lt;/strong> and at the start of generation. With short prompts, attention is cheap and may &lt;em>not&lt;/em> cover the prefetch; the advantage of the overlap grows with longer sequences. Another detail an honest benchmark should break down.&lt;/li>
&lt;/ul>
&lt;h3 id="weight-footprint-why-they-report-1-gb-for-a-4b">Weight footprint: why they report ~1 GB for a 4B&lt;/h3>
&lt;p>Let us do the 4B calculation in Q4_0. Quantisation to 4 bits ≈ 0.5 byte/param, plus a small overhead of per-block scales (Q4_0 adds one FP16 scale every 32 weights, ~0.56 effective byte/param). So:&lt;/p>
$$4\text{B} \times 0.5\ \text{B/param} \approx 2\ \text{GB}.$$
&lt;p>That is, &lt;strong>the full model in Q4_0 takes up ~2 GB on disk&lt;/strong>. But the authors report &lt;strong>~1 GB of RAM&lt;/strong>. A contradiction? No, and understanding why is understanding the design:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Not all experts are resident.&lt;/strong> Only the activated ones (&lt;code>A = 0.6B&lt;/code>) plus a cache fit in RAM; the rest lives on SSD and is streamed. &lt;code>0.6\text{B} \times 0.5 \approx 0.3&lt;/code> GB of active experts, plus the dense part (attention, embeddings, router) and a cache of hot experts.&lt;/li>
&lt;li>&lt;strong>Sparse FFN reduces the work and the useful resident set.&lt;/strong> The neurons that come out at zero do not need to be materialised for that token.&lt;/li>
&lt;/ul>
&lt;p>Adding up active experts + dense part + a reasonable cache, ~1 GB is plausible. But mind the nuance: ~1 GB is the &lt;strong>resident set in RAM&lt;/strong>, not the total footprint in storage, which is still ~2 GB on SSD. Conflating the two, reporting a bare &amp;ldquo;1 GB&amp;rdquo;, is misleading if the reader understands &amp;ldquo;the model takes up 1 GB&amp;rdquo;. It takes up 2 GB; it &lt;em>keeps&lt;/em> 1 GB in RAM. The distinction matters for a device with 2 GB of free storage: there it does not fit.&lt;/p>
&lt;p>Analogously, SmallThinker-21B-A3B: &lt;code>21\text{B} \times 0.5 \approx 10.5&lt;/code> GB on disk; &lt;code>3\text{B} \times 0.5 \approx 1.5&lt;/code> GB of active experts, and the ~8 GB of RAM reported includes active experts + a generous cache + the dense part. The large cache is what takes it from 1.5 to ~8 GB: you keep many hot experts resident so as not to hit the SSD constantly.&lt;/p>
&lt;h2 id="the-quality-cost-the-necessary-scepticism">The quality cost: the necessary scepticism&lt;/h2>
&lt;p>All the machinery above reduces the compute per token to &lt;code>\approx A&lt;/code>. But &lt;code>A = 0.6B&lt;/code> activated is &lt;strong>very&lt;/strong> little. This is where the enthusiasm needs a brake:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Bounded reasoning capacity.&lt;/strong> A model that activates 0.6B parameters per token has, per token, the compute power of a 0.6B model, not of a 4B one. The total capacity &lt;code>N=4B&lt;/code> helps to &lt;em>store&lt;/em> more knowledge (more specialised experts), but the &lt;em>processing&lt;/em> of each token is still limited by &lt;code>A&lt;/code>. For tasks requiring composition and intensive multi-step reasoning, this is a real ceiling, not a detail.&lt;/li>
&lt;li>&lt;strong>The router is a single point of failure for quality.&lt;/strong> If the fine-grained router picks the wrong experts, and with fine grain there are more decisions to take, quality drops without any speed metric reflecting it. The pre-attention router makes this worse: it predicts the experts &lt;em>before&lt;/em> seeing attention, with less information than a post-attention router. The authors ought to report how much quality is lost by predicting early (mismatch between the prefetched expert and the expert a post-attention router would have chosen).&lt;/li>
&lt;li>&lt;strong>The ~20 tok/s needs small print.&lt;/strong> On which CPU exactly? With what context and generation length (the advantage of the overlap depends on &lt;code>Y&lt;/code>)? Cold start included or steady state? Did the SSD have the experts pre-sorted sequentially? An &amp;ldquo;exceeds 20 tok/s&amp;rdquo; without those conditions is a marketing number, not a methodological one.&lt;/li>
&lt;li>&lt;strong>A fair comparison.&lt;/strong> The right question is not &amp;ldquo;is it fast?&amp;rdquo;, but &amp;ldquo;at equal quality on an independent benchmark, is it faster or smaller than an equivalent compressed dense model?&amp;rdquo;. That requires evals the reader can reproduce, not just tok/s on the authors&amp;rsquo; machine.&lt;/li>
&lt;/ul>
&lt;p>None of this invalidates the direction. Designing for device is, conceptually, the right approach: it attacks the real bottlenecks (compute, RAM, I/O) in the architecture instead of patching them afterwards. But &amp;ldquo;20 tok/s in ~1 GB&amp;rdquo; is a claim about &lt;em>efficiency&lt;/em>, and efficiency only means something anchored to a level of &lt;em>quality&lt;/em> measured honestly. Until that anchor is clear, the right amount of scepticism is high.&lt;/p>
&lt;h2 id="implications-for-on-premise-and-edge-inference">Implications for on-premise and edge inference&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The SSD becomes part of the inference hierarchy.&lt;/strong> In cloud, the hierarchy is HBM → RAM. On device, the SSD/flash enters as one more level, and its bandwidth and random-access latency become first-order performance parameters. This connects with &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>: cold start and weight streaming stop being just a startup problem and become part of the &lt;em>steady state&lt;/em>.&lt;/li>
&lt;li>&lt;strong>The heterogeneous edge box starts to make sense.&lt;/strong> In a &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed environments&lt;/a> pattern, a device-native model like SmallThinker runs on the NUC/edge with CPU and SSD, serving locally, while the heavy work stays on the central cluster. The pre-attention router is what makes a GPU-less edge box viable.&lt;/li>
&lt;li>&lt;strong>Capacity planning changes axes.&lt;/strong> As discussed in &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Inference capacity planning&lt;/a>, on device the resource to plan is not VRAM but the triad resident-RAM / SSD-bandwidth / CPU-FLOPs. A model with small &lt;code>A&lt;/code> and overlapped prefetch moves the bottleneck from &amp;ldquo;does it fit in RAM?&amp;rdquo; to &amp;ldquo;does the SSD feed the prefetch in time?&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>SmallThinker is, above all, a change of question. Not &amp;ldquo;how do I shrink this cloud model so it fits on the device?&amp;rdquo; but &amp;ldquo;what would the model look like if I designed it for the device from the first parameter?&amp;rdquo;. The answer, fine-grained MoE to decouple &lt;code>N&lt;/code> from &lt;code>A&lt;/code>, sparse FFN to save inside &lt;code>A&lt;/code>, and a pre-attention router that hides storage I/O underneath attention, attacks the device&amp;rsquo;s three constraints (compute, RAM, I/O) in the architecture, not in a later compression phase. The key condition, &lt;code>t_att ≥ t_prefetch&lt;/code>, explains why the pieces fit: fine grain does not only save FLOPs, it makes the prefetch fit underneath attention. The reported numbers (~20 tok/s, ~1 GB / ~8 GB of RAM) are promising and the direction is solid; the cost of activating so little and the lack of methodological detail on quality call for caution. Designing for device is the right bet; measuring it honestly is the unfinished business.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the conceptual basis of this post: how a router routes tokens to experts and why &lt;code>N&lt;/code> and &lt;code>A&lt;/code> decouple; read it first if MoE sounds distant.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — weight streaming from slow storage, which here stops being a startup problem and becomes steady state via prefetch.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the canonical lever of the &amp;ldquo;compress a cloud dense model&amp;rdquo; approach, the exact counterpoint to the device-native one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — the other reduction lever; useful for comparing &amp;ldquo;take away from a big one&amp;rdquo; against &amp;ldquo;design small from scratch&amp;rdquo;.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — where a device-native model fits: the edge box with CPU and SSD that serves locally without a GPU.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">On-premise LLM inference capacity planning&lt;/a> — on device the axes to plan are resident RAM, SSD bandwidth and CPU FLOPs, not VRAM.&lt;/li>
&lt;li>&lt;strong>Inverted roofline in small models&lt;/strong> (sibling of this series, coming soon) — the SLM&amp;rsquo;s performance regime, which explains why a small &lt;code>A&lt;/code> keeps decode memory-bound and where the real ceiling is.&lt;/li>
&lt;li>&lt;strong>Self-speculative decoding with early-exit&lt;/strong> (sibling of this series, coming soon) — self-spec applied to on-device MoE: how to accelerate decode without an external draft when the model is already small.&lt;/li>
&lt;li>&lt;strong>Aggressive sub-4-bit and ternary quantisation&lt;/strong> (sibling of this series, coming soon) — Q4_0 and beyond on device: ternary and 2-bit to push the expert footprint on SSD down further.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Equipo SmallThinker (SJTU IPADS + Zenergize AI). &lt;em>SmallThinker: A Family of Efficient Large Language Models Natively Trained for Local Deployment&lt;/em>. arXiv:2507.20984. &lt;a href="https://arxiv.org/abs/2507.20984">https://arxiv.org/abs/2507.20984&lt;/a>&lt;/li>
&lt;li>Repositorio oficial SmallThinker: &lt;a href="https://github.com/SJTU-IPADS/SmallThinker">https://github.com/SJTU-IPADS/SmallThinker&lt;/a>&lt;/li>
&lt;li>&lt;em>Self-Speculative Decoding for On-device MoE Acceleration&lt;/em>. ACM The Web Conference (WWW) 2026. doi:10.1145/3774904.3792218. &lt;a href="https://doi.org/10.1145/3774904.3792218">https://doi.org/10.1145/3774904.3792218&lt;/a>&lt;/li>
&lt;li>Liu, Z. et al. &lt;em>Deja Vu: Contextual Sparsity for Efficient LLMs at Inference Time&lt;/em>. ICML 2023. &lt;a href="https://arxiv.org/abs/2310.17157">https://arxiv.org/abs/2310.17157&lt;/a>&lt;/li>
&lt;li>Song, Y. et al. &lt;em>PowerInfer: Fast Large Language Model Serving with a Consumer-grade GPU&lt;/em> (sparse activation + hot/cold experts). SJTU IPADS, 2023. &lt;a href="https://arxiv.org/abs/2312.12456">https://arxiv.org/abs/2312.12456&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Self-speculative decoding: the model that gets ahead of itself</title><link>https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/</link><pubDate>Tue, 09 Jun 2026 01:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/</guid><description>&lt;blockquote>
&lt;p>This post is the direct companion to &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who types ahead&lt;/a>. There, draft and target are two different models; here they are the same model at two depths. Read that one first: we take rejection sampling, the &lt;code>1/(1-α)&lt;/code> ceiling and the speedup formula as known, and all we change here is what the draft is.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Classic speculative decoding demands a pair: a cheap &lt;em>draft&lt;/em> model proposes γ tokens and an expensive &lt;em>target&lt;/em> verifies them in a single parallel forward pass. On large models the draft can be 1 % of the target and fit comfortably. On &lt;strong>small models&lt;/strong> (SLMs, 1B–8B) that recipe breaks on two fronts: a draft that is 1/10 of a 3B is a 0.3B that barely gets anything right (α collapses), and loading a second model, however small, doubles the pieces to maintain and eats VRAM that is not spare on a 4090 or on device. &lt;em>Self-speculative decoding&lt;/em> solves both: the draft is the model itself, run shallowly. A model with &lt;code>L&lt;/code> layers produces draft tokens by exiting at an intermediate layer &lt;code>k &amp;lt; L&lt;/code> (&lt;em>early-exit&lt;/em>) or by skipping a subset of layers (&lt;em>layer-skip&lt;/em>), and then verifies those tokens with the full forward pass through the &lt;code>L&lt;/code> layers. Because draft and verify &lt;strong>share weights and share the KV cache of the common layers&lt;/strong>, the extra memory cost is &lt;strong>zero&lt;/strong>: there is no second model, no second KV cache, nothing new to load. The price is that the early-exit draft is more expensive than a tiny external draft (it walks &lt;code>k/L&lt;/code> of the model instead of 1 %), so the relative cost &lt;code>c&lt;/code> goes up. The honest trade-off: with a well-trained dedicated draft (EAGLE-3) that &lt;strong>fits&lt;/strong> in memory, its α is usually higher and it wins; self-spec wins when there is no trained draft, when it does not fit, or when you are on device.&lt;/p>
&lt;h2 id="the-analogy-the-chess-player-who-plays-by-eye-and-then-calculates">The analogy: the chess player who plays by eye and then calculates&lt;/h2>
&lt;p>A good chess player does two things with the same brain. First they look at the board and, by eye, in half a second, propose a move that &amp;ldquo;looks right&amp;rdquo;: pattern intuition, fast recognition, the shallow layers of judgement. Then, before moving, they calculate properly: three moves ahead, the opponent&amp;rsquo;s replies, the tactical lines. That deep calculation confirms the intuition or corrects it.&lt;/p>
&lt;p>The decisive part is that it is the same person acting as drafter and as reviewer. They do not hire a second, weaker player to guess the move so they can validate it; that would be classic speculative with an external draft. Here the fast draft and the slow verification come out of the same brain, traversed at two depths.&lt;/p>
&lt;p>The analogy holds point by point:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The glance by eye is the early-exit forward pass&lt;/strong>: the model walks only the first &lt;code>k&lt;/code> layers and emits a draft token. Fast, approximate.&lt;/li>
&lt;li>&lt;strong>The deep calculation is the full forward pass through the &lt;code>L&lt;/code> layers&lt;/strong>, which verifies the draft with exact rejection sampling.&lt;/li>
&lt;li>&lt;strong>That it is the same person is the reuse of weights and KV cache&lt;/strong>: the &lt;code>k&lt;/code> shallow layers of the draft are literally the same as the first &lt;code>k&lt;/code> layers of the verify; what has already been computed is not recomputed.&lt;/li>
&lt;li>&lt;strong>That the final move is identical to the one the player would have chosen by always calculating deeply&lt;/strong> is the rejection sampling guarantee: output quality does not degrade (the proof is in the &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative post&lt;/a>).&lt;/li>
&lt;/ul>
&lt;h2 id="why-the-external-draft-does-not-fit-small-models">Why the external draft does not fit small models&lt;/h2>
&lt;p>Let us revisit the cost of classic speculative with two numbers. The speedup depends on the acceptance rate α (how often the draft is right) and on the relative cost &lt;code>c = T_draft / T_target&lt;/code>. A useful draft needs high α and low c at the same time. On large models that is reachable: a 1B draft for a 70B target has &lt;code>c ≈ 0.015&lt;/code> and, if well distilled (EAGLE), α &amp;gt; 0.8. The product pays off.&lt;/p>
&lt;p>On a small model the balance breaks:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The proportional draft is useless.&lt;/strong> If you want &lt;code>c ≈ 0.1&lt;/code> for a 3B target, your draft is around 0.3B. A generic 0.3B has a distribution so different from the 3B that α falls into the 0.3–0.5 range. And &lt;code>1/(1-α)&lt;/code> with α = 0.4 is a ceiling of 1.67 tokens/step: not even infinite γ gets you more. The prize evaporates.&lt;/li>
&lt;li>&lt;strong>Loading a second model doubles the pieces.&lt;/strong> Even if the draft is small in VRAM, it is another checkpoint to version, quantise, validate and serve, and it has its own KV cache. On an RTX 4090 (24 GB, Ada Lovelace) with a quantised 8B and a long context, the KV cache is already tight; adding a second model and its cache can force you to lower concurrency or maximum context. On &lt;strong>device&lt;/strong> (a phone, a NUC, an edge box) there is simply no room.&lt;/li>
&lt;li>&lt;strong>A trained draft does not always exist&lt;/strong> for your exotic or fine-tuned model. EAGLE needs the draft trained on-policy against that specific target (see &lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">knowledge distillation&lt;/a>). If your SLM is your own fine-tune, there is no official published draft.&lt;/li>
&lt;/ol>
&lt;p>Self-speculative attacks all three at once with one idea: &lt;strong>do not bring a second model; use the first one at half depth.&lt;/strong>&lt;/p>
&lt;h2 id="the-mechanism-early-exit-as-draft-full-forward-as-verify">The mechanism: early-exit as draft, full forward as verify&lt;/h2>
&lt;p>A transformer with &lt;code>L&lt;/code> layers transforms the hidden state layer by layer at each position: &lt;code>h_0 → h_1 → ... → h_L&lt;/code>, and the &lt;em>LM head&lt;/em> projects &lt;code>h_L&lt;/code> to logits. The observation that enables everything: &lt;code>h_k&lt;/code> for &lt;code>k &amp;lt; L&lt;/code> is already a reasonable hidden state. If you push it through the same LM head (or through a dedicated lightweight head), you get an output distribution that is &amp;ldquo;premature&amp;rdquo; but often correct for easy tokens. That is the source of the draft.&lt;/p>
&lt;p>The self-speculative iteration has the same structure as classic speculative (draft, verify, accept/reject) but both roles are the same model:&lt;/p>
&lt;p>&lt;strong>Step 1 — Shallow draft.&lt;/strong> To produce γ draft tokens, the model walks only the first &lt;code>k&lt;/code> layers (or a subset of layers in the layer-skip case) and applies the LM head. Each draft token costs ≈ &lt;code>k/L&lt;/code> of a full forward pass. We call &lt;code>c = k/L&lt;/code> the relative cost of the draft. The γ drafts are generated autoregressively at this reduced cost.&lt;/p>
&lt;p>&lt;strong>Step 2 — Full verify.&lt;/strong> The model runs a single forward pass through the &lt;code>L&lt;/code> layers over &lt;code>prompt + x_1...x_γ&lt;/code>. Because attention is causal it obtains &lt;code>p(·|prompt, x_&amp;lt;i)&lt;/code> for each position, exactly as in classic speculative.&lt;/p>
&lt;p>&lt;strong>Step 3 — Accept/reject.&lt;/strong> Rejection sampling identical to the previous post: tokens are accepted from left to right, the first divergence is corrected by sampling from the residual &lt;code>norm(max(0, p−q))&lt;/code>, and if all γ are accepted the bonus token is appended. Output quality is &lt;strong>exactly&lt;/strong> that of the full model.&lt;/p>
&lt;h3 id="the-trick-that-makes-c-cheaper-still-reusing-the-kv-cache-of-shared-layers">The trick that makes &lt;code>c&lt;/code> cheaper still: reusing the KV cache of shared layers&lt;/h3>
&lt;p>Here is the key difference against an external draft. When the model drafts by walking layers &lt;code>0..k&lt;/code>, it computes and stores the KV cache of those &lt;code>k&lt;/code> layers for the prompt tokens and the drafts. When the full verify comes along, layers &lt;code>0..k&lt;/code> of the &lt;code>L&lt;/code>-layer forward pass are bit for bit the same operations on the same weights that the draft already performed. There is no need to recompute them: the verify reuses the KV cache the draft left for layers &lt;code>0..k&lt;/code> directly, and only genuinely computes the missing layers &lt;code>k..L&lt;/code>.&lt;/p>
&lt;p>That has two consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Zero extra memory.&lt;/strong> There is no second KV cache. The KV of the common layers is a single one, shared between draft and verify. Contrast that with vanilla SD, where the draft has its own complete cache (see &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Partially reused compute.&lt;/strong> The verify only pays for the &amp;ldquo;new&amp;rdquo; layers &lt;code>k..L&lt;/code> for tokens that already went through the draft. The full forward pass is not as expensive as &lt;code>L&lt;/code> suggests, because the first &lt;code>k&lt;/code> layers come from the cache.&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Early-exit draft and full forward verify with shared KV">
&lt;text x="390" y="22" text-anchor="middle" fill="currentColor" font-size="14" font-weight="700">A single L=32 layer model, traversed at two depths&lt;/text>
&lt;!-- DRAFT column -->
&lt;text x="180" y="50" text-anchor="middle" fill="currentColor" font-size="12" font-weight="700">DRAFT · early-exit at k=8&lt;/text>
&lt;rect x="120" y="60" width="120" height="120" fill="#fff4d6" stroke="#a48000" stroke-width="1.4" rx="6"/>
&lt;text x="180" y="95" text-anchor="middle" fill="#a48000" font-size="12" font-weight="600">layers 0..8&lt;/text>
&lt;text x="180" y="115" text-anchor="middle" fill="#a48000" font-size="11">shallow traversal&lt;/text>
&lt;text x="180" y="133" text-anchor="middle" fill="#a48000" font-size="11">cost ≈ k/L = 0.25&lt;/text>
&lt;rect x="120" y="190" width="120" height="30" fill="#fff4d6" stroke="#a48000" stroke-width="1.4" rx="6"/>
&lt;text x="180" y="210" text-anchor="middle" fill="#a48000" font-size="11" font-weight="600">LM head → draft&lt;/text>
&lt;text x="180" y="245" text-anchor="middle" fill="currentColor" font-size="11">x₁ x₂ x₃ x₄ (γ=4)&lt;/text>
&lt;!-- VERIFY column -->
&lt;text x="600" y="50" text-anchor="middle" fill="currentColor" font-size="12" font-weight="700">VERIFY · full forward L=32&lt;/text>
&lt;rect x="540" y="60" width="120" height="120" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4" rx="6"/>
&lt;text x="600" y="92" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">layers 0..8&lt;/text>
&lt;text x="600" y="110" text-anchor="middle" fill="#1f5fa8" font-size="11">(reused, not&lt;/text>
&lt;text x="600" y="125" text-anchor="middle" fill="#1f5fa8" font-size="11">recomputed)&lt;/text>
&lt;line x1="540" y1="135" x2="660" y2="135" stroke="#1f5fa8" stroke-width="1" stroke-dasharray="4 2"/>
&lt;text x="600" y="158" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">layers 8..32&lt;/text>
&lt;text x="600" y="174" text-anchor="middle" fill="#1f5fa8" font-size="11">new compute&lt;/text>
&lt;rect x="540" y="190" width="120" height="30" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4" rx="6"/>
&lt;text x="600" y="210" text-anchor="middle" fill="#1f5fa8" font-size="11" font-weight="600">LM head → p(·)&lt;/text>
&lt;!-- shared KV cache box -->
&lt;rect x="300" y="80" width="180" height="80" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.6" rx="8"/>
&lt;text x="390" y="110" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="700">SHARED KV cache&lt;/text>
&lt;text x="390" y="128" text-anchor="middle" fill="#2a7a40" font-size="11">layers 0..8 · a single cache&lt;/text>
&lt;text x="390" y="145" text-anchor="middle" fill="#2a7a40" font-size="11">extra memory = 0&lt;/text>
&lt;!-- arrows draft->KV and KV->verify -->
&lt;defs>&lt;marker id="ssd1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#2a7a40"/>&lt;/marker>&lt;/defs>
&lt;path d="M240,120 L300,120" fill="none" stroke="#2a7a40" stroke-width="1.6" marker-end="url(#ssd1)"/>
&lt;text x="270" y="112" text-anchor="middle" fill="#2a7a40" font-size="10">writes KV 0..8&lt;/text>
&lt;path d="M480,120 L540,120" fill="none" stroke="#2a7a40" stroke-width="1.6" marker-end="url(#ssd1)"/>
&lt;text x="510" y="112" text-anchor="middle" fill="#2a7a40" font-size="10">reads KV 0..8&lt;/text>
&lt;!-- accept/reject row -->
&lt;text x="390" y="280" text-anchor="middle" fill="currentColor" font-size="12" font-weight="700">Rejection sampling (identical to classic speculative)&lt;/text>
&lt;rect x="180" y="295" width="80" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4" rx="6"/>&lt;text x="220" y="316" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="600">x₁ ✓&lt;/text>
&lt;rect x="270" y="295" width="80" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4" rx="6"/>&lt;text x="310" y="316" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="600">x₂ ✓&lt;/text>
&lt;rect x="360" y="295" width="80" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4" rx="6"/>&lt;text x="400" y="316" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="600">x₃ ✓&lt;/text>
&lt;rect x="450" y="295" width="80" height="34" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4" rx="6"/>&lt;text x="490" y="316" text-anchor="middle" fill="#a52a2a" font-size="12" font-weight="600">x₄ ✗&lt;/text>
&lt;text x="390" y="360" text-anchor="middle" fill="currentColor" font-size="11">Output = exactly that of the full model · 0 extra models · 0 extra KV&lt;/text>
&lt;text x="390" y="385" text-anchor="middle" fill="currentColor" font-size="11">Draft and verify are the same model; layers 0..8 are computed only once.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-families-state-of-play-2026">The families (state of play 2026)&lt;/h2>
&lt;p>There is no single way of doing self-speculative. They differ in which layers are skipped and in whether training is needed.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Year / venue&lt;/th>
&lt;th>How it picks what to skip&lt;/th>
&lt;th>Training?&lt;/th>
&lt;th>Extra KV&lt;/th>
&lt;th>Distinctive idea&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>LayerSkip&lt;/strong> (Elhoushi et al.)&lt;/td>
&lt;td>2024, arXiv:2404.16710&lt;/td>
&lt;td>Early-exit at a fixed layer &lt;code>k&lt;/code>; a single LM head serves every exit&lt;/td>
&lt;td>Yes — &lt;em>layer dropout&lt;/em> + &lt;em>early-exit loss&lt;/em> in train/fine-tune&lt;/td>
&lt;td>0&lt;/td>
&lt;td>A single model trained to do both draft and verify; reuses partial compute&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SWIFT&lt;/strong>&lt;/td>
&lt;td>ICLR 2025 (OpenReview EKJhH5D5wA)&lt;/td>
&lt;td>Selects which layers to skip &lt;strong>on the fly&lt;/strong>, without touching weights&lt;/td>
&lt;td>&lt;strong>No&lt;/strong> — plug-and-play on the given model&lt;/td>
&lt;td>0&lt;/td>
&lt;td>&lt;em>Training-free&lt;/em> self-spec: optimises the set of skipped layers at runtime&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CLaSp&lt;/strong>&lt;/td>
&lt;td>2025, arXiv:2505.24196&lt;/td>
&lt;td>Dynamic &lt;em>in-context&lt;/em> layer skip: the pattern of skipped layers adapts to the context&lt;/td>
&lt;td>No (dynamic at inference)&lt;/td>
&lt;td>0&lt;/td>
&lt;td>The skip is not fixed; it changes with what is being generated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ConfLayers&lt;/strong>&lt;/td>
&lt;td>2026, arXiv:2604.14612&lt;/td>
&lt;td>Skips layers according to the &lt;strong>confidence&lt;/strong> of the intermediate state (adaptive per token)&lt;/td>
&lt;td>No (confidence criterion)&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Variable depth: easy tokens exit earlier, hard ones go deeper&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Saguaro&lt;/strong>&lt;/td>
&lt;td>2025–26&lt;/td>
&lt;td>&lt;strong>Asynchronous&lt;/strong> formulation: the draft keeps speculating in parallel while verification runs&lt;/td>
&lt;td>Depends on the variant&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Overlaps draft and verify in time instead of alternating them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SSD for on-device MoE&lt;/strong>&lt;/td>
&lt;td>ACM Web Conf. 2026, doi 10.1145/3774904.3792218&lt;/td>
&lt;td>Self-spec exploiting MoE &lt;em>sparsity&lt;/em> (few active experts per token)&lt;/td>
&lt;td>MoE-specific variant&lt;/td>
&lt;td>0&lt;/td>
&lt;td>The shallow draft activates even fewer experts; fits MoE on device&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational readings of the table:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The axis that matters most is training yes/no.&lt;/strong> LayerSkip gives the best α because the model learns to be a good shallow draft (with early-exit loss the intermediate layers are explicitly trained to predict well). But it demands a fine-tune. SWIFT, CLaSp and ConfLayers are &lt;strong>training-free&lt;/strong>: worse α, but they apply to any already-trained model without touching anything. For an SLM you do not control, training-free is the realistic option.&lt;/li>
&lt;li>&lt;strong>Adaptive skip (CLaSp, ConfLayers) raises α&lt;/strong> because it fits the draft&amp;rsquo;s depth to the token: it spends little on the easy ones and more on the hard ones, instead of a fixed &lt;code>k&lt;/code>. In exchange, the effective &lt;code>c&lt;/code> stops being constant.&lt;/li>
&lt;li>&lt;strong>Saguaro attacks something else:&lt;/strong> it does not raise α, it overlaps draft and verify time. It is orthogonal to the rest and can be combined.&lt;/li>
&lt;/ol>
&lt;h2 id="the-maths-same-framework-different-c">The maths: same framework, different &lt;code>c&lt;/code>&lt;/h2>
&lt;p>We reuse the apparatus of the &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative post&lt;/a> without changing a letter. With α the acceptance rate and γ the number of drafts:&lt;/p>
$$E[\text{tokens per step}] = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}, \qquad \text{Speedup} = \frac{1 - \alpha^{\gamma+1}}{(1 - \alpha)(\gamma c + 1)}$$
&lt;p>And the algorithmic ceiling is the same: &lt;code>lim_{γ→∞} = 1/(1-α)&lt;/code>. The only thing that changes in self-speculative is the value of &lt;code>c&lt;/code>: it is no longer the size ratio of two models, but &lt;code>c = k/L&lt;/code>, the fraction of layers the early-exit draft walks.&lt;/p>
&lt;h3 id="numerical-example-self-spec-with-l32-exit-at-k8">Numerical example: self-spec with L=32, exit at k=8&lt;/h3>
&lt;p>Take an SLM with &lt;code>L = 32&lt;/code> layers that exits at &lt;code>k = 8&lt;/code> for the draft: &lt;code>c = k/L = 8/32 = 0.25&lt;/code>. Suppose α = 0.7 (reasonable for early-exit on conversational tokens) and γ = 4.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Expected tokens per step:&lt;/strong> &lt;code>(1 − 0.7⁵) / (1 − 0.7) = (1 − 0.168) / 0.3 = 0.832 / 0.3 = 2.77&lt;/code>&lt;/li>
&lt;li>&lt;strong>Speedup:&lt;/strong> &lt;code>2.77 / (4 × 0.25 + 1) = 2.77 / 2.0 = 1.39×&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>The denominator factor is &lt;code>γc + 1 = 4·0.25 + 1 = 2.0&lt;/code>: the early-exit draft, costing a quarter of the model per token, eats part of the benefit. Exiting higher up helps: with &lt;code>k = 4&lt;/code> (&lt;code>c = 0.125&lt;/code>), the denominator is &lt;code>1.5&lt;/code> and the speedup &lt;code>2.77/1.5 = 1.85×&lt;/code>. But exiting higher up normally lowers α, so there is real tension between a small &lt;code>k&lt;/code> (cheap) and a high α (accurate).&lt;/p>
&lt;h3 id="an-honest-comparison-with-an-external-draft">An honest comparison with an external draft&lt;/h3>
&lt;p>Put a tiny, well-distilled external draft alongside: &lt;code>c = 0.1&lt;/code> and α = 0.78 (what an EAGLE-style draft can deliver), same γ = 4.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tokens/step:&lt;/strong> &lt;code>(1 − 0.78⁵)/(1 − 0.78) = (1 − 0.289)/0.22 = 0.711/0.22 = 3.23&lt;/code>&lt;/li>
&lt;li>&lt;strong>Speedup:&lt;/strong> &lt;code>3.23 / (4 × 0.1 + 1) = 3.23 / 1.4 = 2.31×&lt;/code>&lt;/li>
&lt;/ul>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>c&lt;/th>
&lt;th>α&lt;/th>
&lt;th>tokens/step&lt;/th>
&lt;th>speedup&lt;/th>
&lt;th>extra VRAM&lt;/th>
&lt;th>pieces to maintain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Self-spec early-exit (k=8)&lt;/td>
&lt;td>0.25&lt;/td>
&lt;td>0.70&lt;/td>
&lt;td>2.77&lt;/td>
&lt;td>&lt;strong>1.39×&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Self-spec early-exit (k=4)&lt;/td>
&lt;td>0.125&lt;/td>
&lt;td>0.65&lt;/td>
&lt;td>2.50&lt;/td>
&lt;td>&lt;strong>1.67×&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distilled external draft&lt;/td>
&lt;td>0.10&lt;/td>
&lt;td>0.78&lt;/td>
&lt;td>3.23&lt;/td>
&lt;td>&lt;strong>2.31×&lt;/strong>&lt;/td>
&lt;td>yes (+model +KV)&lt;/td>
&lt;td>1 extra model&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading is exactly what you would expect, and it is worth not dressing it up: if you have a dedicated draft, trained against your target, and it &lt;strong>fits&lt;/strong> in memory, its higher α and lower &lt;code>c&lt;/code> give it more speedup. EAGLE-3 with a well-trained draft usually wins on raw speedup. &lt;strong>Self-spec does not compete on raw speedup; it competes on total cost.&lt;/strong> Its winning columns are the two on the right: zero extra VRAM and zero pieces to maintain. Self-spec wins when:&lt;/p>
&lt;ul>
&lt;li>there is no trained draft for your model (your own SLM, an unusual fine-tune),&lt;/li>
&lt;li>the draft does not fit (a 4090 already full, a long context that needs the KV),&lt;/li>
&lt;li>you are on device (phone, NUC, edge), where a second model and its KV simply do not go in.&lt;/li>
&lt;/ul>
&lt;p>It is the same pattern as with MTP in the previous post: sometimes the best draft is the one you do not have to load.&lt;/p>
&lt;h2 id="why-it-fits-small-models-and-device-precisely">Why it fits small models and device precisely&lt;/h2>
&lt;p>The regime where self-spec shines is low concurrency, memory-bandwidth-bound, with a tight memory budget, exactly that of an SLM on a single GPU or on device (the reason for the regime is in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">the inverted roofline&lt;/a>). Three reasons:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Zero extra memory is decisive where there is none to spare.&lt;/strong> On an RTX 4090 (24 GB, Ada Lovelace) serving a quantised 7B–8B with a long context, every GB counts. Self-spec asks for none: it reuses weights and KV. An external draft, however small, forces you to trim context or concurrency. On device the difference is binary: with self-spec you accelerate; with an external draft there is no room, full stop.&lt;/li>
&lt;li>&lt;strong>There is no second checkpoint to version.&lt;/strong> Operationally, an SLM at the edge deployed across hundreds of boxes becomes unsustainable if each one needs two synchronised models. A single binary that does draft and verify is far simpler to maintain.&lt;/li>
&lt;li>&lt;strong>It fits MoE on device.&lt;/strong> In a fine-grained MoE for device (see &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">native architectures for device&lt;/a>), the shallow draft activates even fewer experts, and the memory-bound regime persists even at medium batch, precisely what the work on SSD for on-device MoE (ACM WWW 2026) exploits.&lt;/li>
&lt;/ol>
&lt;p>The counterpoint, repeated so it does not get forgotten: on a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8), where memory is not the bottleneck, a dedicated EAGLE-3 draft does fit and its higher α gives it more speedup. There, self-spec is plan B: you use it if the model is exotic and there is no trained draft, not because memory is tight.&lt;/p>
&lt;h2 id="pitfalls">Pitfalls&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>α depends enormously on &lt;code>k&lt;/code>.&lt;/strong> Exiting too high up (small &lt;code>k&lt;/code>) makes the draft cheap but sinks α; exiting too low down (&lt;code>k&lt;/code> close to &lt;code>L&lt;/code>) raises α but the draft costs almost a full forward pass and &lt;code>c → 1&lt;/code>, killing the speedup. The optimum is empirical and model-specific. Distrust any speedup number that does not say at which &lt;code>k&lt;/code> it was measured.&lt;/li>
&lt;li>&lt;strong>Training-free is not free in draft quality.&lt;/strong> SWIFT and CLaSp give lower α than LayerSkip precisely because the model&amp;rsquo;s intermediate layers were not trained to be good premature exits. The number that matters is α measured on &lt;em>your&lt;/em> distribution, not the paper&amp;rsquo;s.&lt;/li>
&lt;li>&lt;strong>Sampling temperature and creative outputs&lt;/strong> lower α just as in classic speculative. At high T, the self-spec speedup erodes faster still because it starts from a lower α.&lt;/li>
&lt;li>&lt;strong>Large batch neutralises it just as it does classic speculative.&lt;/strong> As soon as decode turns compute-bound, the drafts stop being &amp;ldquo;almost free&amp;rdquo;. Self-spec is for low concurrency.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who types ahead&lt;/a> — the direct companion and prerequisite: draft + verify + rejection sampling, the &lt;code>1/(1-α)&lt;/code> ceiling and the speedup formula we reuse here unchanged.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline in small models&lt;/a> — why the SLM lives in a memory-bound regime, which is exactly what enables any form of speculative.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Native architectures for device: fine-grained MoE&lt;/a> — where self-spec lands on MoE on device, taking advantage of the router&amp;rsquo;s sparsity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — reusing the KV of the layers shared between draft and verify is what makes the extra memory zero; the cache mechanism is there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — LayerSkip&amp;rsquo;s early-exit loss is a relative of distillation: it teaches the intermediate layers to predict like the full model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — skipping layers is a form of structured pruning &lt;em>at inference&lt;/em>; layer-skip and layer-dropping share a conceptual root.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the persistent memory-bound regime of MoE means self-spec over MoE wins even at medium batch.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — where speculative methods are configured in practice in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — the device/edge case where &amp;ldquo;zero extra model&amp;rdquo; stops being a convenience and becomes the only viable option.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Elhoushi, M., et al. &lt;em>LayerSkip: Enabling Early Exit Inference and Self-Speculative Decoding&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2404.16710">https://arxiv.org/abs/2404.16710&lt;/a>&lt;/li>
&lt;li>&lt;em>SWIFT: On-the-Fly Self-Speculative Decoding for LLM Inference Acceleration&lt;/em>. ICLR 2025. &lt;a href="https://openreview.net/forum?id=EKJhH5D5wA">https://openreview.net/forum?id=EKJhH5D5wA&lt;/a>&lt;/li>
&lt;li>&lt;em>CLaSp: In-Context Layer Skip for Self-Speculative Decoding&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2505.24196">https://arxiv.org/abs/2505.24196&lt;/a>&lt;/li>
&lt;li>&lt;em>ConfLayers: Confidence-Adaptive Layer Skipping for Self-Speculative Decoding&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2604.14612">https://arxiv.org/abs/2604.14612&lt;/a>&lt;/li>
&lt;li>&lt;em>Self-Speculative Decoding for MoE on Device&lt;/em>. ACM Web Conference 2026. &lt;a href="https://doi.org/10.1145/3774904.3792218">https://doi.org/10.1145/3774904.3792218&lt;/a>&lt;/li>
&lt;li>Hugging Face blog. &lt;em>Faster Text Generation with Self-Speculative Decoding&lt;/em>. &lt;a href="https://huggingface.co/blog/layerskip">https://huggingface.co/blog/layerskip&lt;/a>&lt;/li>
&lt;li>Leviathan, Y., Kalman, M., Matias, Y. &lt;em>Fast Inference from Transformers via Speculative Decoding&lt;/em>. ICML 2023. &lt;a href="https://arxiv.org/abs/2211.17192">https://arxiv.org/abs/2211.17192&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The roofline flips: why optimising small models is a different performance game</title><link>https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/</link><pubDate>Tue, 09 Jun 2026 01:30:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/</guid><description>&lt;blockquote>
&lt;p>This post is the anchor of a mini-series on inference performance in &lt;strong>small models (SLMs)&lt;/strong>. Almost every optimisation post on the blog, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">decode&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a>, was written with a 70B in mind. Here I argue that when the model shrinks by an order of magnitude, the &lt;em>roofline&lt;/em> changes regime and several of those intuitions flip. This is not a nuance: it is a different game.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Autoregressive decode in a large LLM is &lt;strong>memory-bandwidth-bound&lt;/strong>: at every step you have to move all the model weights from HBM to the registers of the SMs, and that dominates over the arithmetic operations. The GPU spends its time waiting for bytes, not computing. That single sentence, that decode &amp;ldquo;waits on HBM&amp;rdquo;, is the root of half the optimisations on this blog. In a small model (SLM, say 0.5B–7B) the sentence stops being true in the simple form we used to tell it. At &lt;em>batch&lt;/em> 1 you are still memory-bound with respect to the hardware, yes, but the forward pass is so cheap (moving 6 GB at 1 TB/s takes ~6 ms, not 70 ms) that the &lt;strong>fixed per-step costs&lt;/strong>, kernel launches, Python scheduler overhead, the &lt;code>sampler&lt;/code>, host↔device copies, the &lt;code>synchronize&lt;/code> calls, stop being noise and start eating 20-30 % of the time. The bottleneck moves from HBM to orchestration. The concrete, quantitative consequences: (1) &lt;strong>CUDA graphs&lt;/strong> and cutting scheduler overhead pay off &lt;em>more&lt;/em> in SLMs than in large models; (2) &lt;strong>weight quantisation&lt;/strong> gives &lt;em>less&lt;/em> latency improvement at batch 1 in SLMs, because proportionally there are fewer weights to move relative to activations, KV cache and fixed overhead; (3) batching has more headroom because you cross the &lt;em>ridge point&lt;/em> late; (4) the KV cache can dominate relative memory. All of this comes out of a single model, the roofline, applied with numerical honesty.&lt;/p>
&lt;h2 id="the-analogy-the-pantry-and-the-waiter">The analogy: the pantry and the waiter&lt;/h2>
&lt;p>A kitchen with two very different services.&lt;/p>
&lt;p>&lt;strong>Tasting menu service, one enormous slow dish (the 70B LLM).&lt;/strong> Every dish involves heavy ingredients that the assistant has to fetch from the pantry at the back, several times, carrying boxes. The chef, by contrast, assembles the dish in a moment: the slow part is fetching the ingredients, not cooking them. If you want the service to go faster, you do not hire a more skilful chef: you widen the corridor to the pantry, or make each trip bring more boxes. The pantry is HBM; the trip is memory bandwidth; cooking is compute. The big dish is bound by the pantry.&lt;/p>
&lt;p>&lt;strong>Tapas service, tiny plates (the SLM).&lt;/strong> Now each tapa has two ingredients and is assembled in a second. The trip to the pantry per tapa is very short. But a cost appears that was negligible for the big dish: the &lt;strong>waiter&lt;/strong>. For every tapa, the waiter has to walk to the kitchen, pick up the plate, carry it to the bar, walk back, take the order, call it out. That back and forth is &lt;strong>fixed&lt;/strong>: it costs the same for one tapa as for the enormous dish. When the tapa is assembled in a second, the waiter, not the pantry, is the bottleneck. Shortening the corridor to the pantry (widening HBM, quantising the weights) barely improves the service any more; what improves it is the waiter chaining several orders together without going back to the kitchen each time (CUDA graphs) or serving several tables in one pass (batching).&lt;/p>
&lt;p>The roofline is the tool that says, with numbers, at what point the waiter dominates over the pantry. That boundary is the &lt;em>ridge point&lt;/em>, and the joke in the title is that in SLMs we cross the regime much earlier than large-model intuition led us to believe.&lt;/p>
&lt;h2 id="the-bare-mechanism-what-the-roofline-says">The bare mechanism: what the roofline says&lt;/h2>
&lt;p>The roofline model (Williams, Waterman and Patterson, 2009) starts from a single quantity: &lt;strong>arithmetic intensity&lt;/strong>, which is how many operations you do per byte moved from memory.&lt;/p>
$$\text{AI} = \frac{\text{FLOPs}}{\text{bytes moved from memory}} \quad [\text{FLOP/byte}]$$
&lt;p>The hardware has two ceilings: compute (peak FLOPS) and memory (peak bandwidth × AI). Achievable performance is the minimum of the two:&lt;/p>
$$\text{Perf} = \min\big(\text{peak FLOPS},\; \text{BW} \times \text{AI}\big)$$
&lt;p>Where the two lines cross is the &lt;strong>ridge point&lt;/strong>, the AI above which you stop being limited by memory and start being limited by compute:&lt;/p>
$$\text{AI}_{\text{ridge}} = \frac{\text{peak FLOPS}}{\text{peak BW}}$$
&lt;p>If your kernel has an AI below the ridge, you are &lt;strong>memory-bound&lt;/strong> (the GPU waits for bytes). Above it, &lt;strong>compute-bound&lt;/strong> (the GPU computes flat out and memory is in surplus). What matters is that the ridge point is a property of the hardware, not of the model. Let us look at the numbers, approximate, and I flag them as such because marketing figures mix &lt;em>dense&lt;/em> and &lt;em>sparse&lt;/em>, different dtypes and unrealistic thermal conditions.&lt;/p>
&lt;p>&lt;strong>Generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> Per GPU, ~989 TFLOPS BF16 &lt;em>dense&lt;/em> (~1,979 TFLOPS FP8 &lt;em>dense&lt;/em>; the figure with &lt;em>sparsity&lt;/em> is double that and almost never applies to LLM inference). HBM3 ~3.35 TB/s. The ridge in BF16:&lt;/p>
$$\text{AI}_{\text{ridge}}^{\text{H100,BF16}} \approx \frac{989 \times 10^{12}}{3.35 \times 10^{12}} \approx 295 \ \text{FLOP/byte}$$
&lt;p>In FP8 the ridge rises to ~590 FLOP/byte (twice the FLOPS against the same BW). Careful: these are datasheet peak figures; in practice a real kernel rarely gets past 70-80 % of either ceiling.&lt;/p>
&lt;p>&lt;strong>RTX 4090 (24 GB, Ada Lovelace).&lt;/strong> ~330 TFLOPS FP16 with FP16 accumulation via tensor cores (the &amp;ldquo;660 TOPS&amp;rdquo; figure that circulates is with sparsity), and ~1 TB/s of GDDR6X. The ridge:&lt;/p>
$$\text{AI}_{\text{ridge}}^{\text{4090,FP16}} \approx \frac{330 \times 10^{12}}{1.0 \times 10^{12}} \approx 330 \ \text{FLOP/byte}$$
&lt;p>Curiously, the same order as the H100 in BF16: the 4090 has less BW but also fewer FLOPS, and the ratio comes out similar. The ridge sits around &lt;strong>300 FLOP/byte&lt;/strong> in both cases. Hold on to that number.&lt;/p>
&lt;p>And where does decode fall? In decode at &lt;em>batch&lt;/em> 1, each weight is loaded once from HBM and used for a single multiply-accumulate (one token, one row of activation). The AI of the decode GEMM at batch 1 is on the order of &lt;strong>AI ≈ 1-2 FLOP/byte&lt;/strong> (each weight byte takes part in ~2 FLOP). With &lt;em>batch&lt;/em> B, the same weight loaded once serves B rows of activation, so the AI scales roughly linearly:&lt;/p>
$$\text{AI}_{\text{decode}}(B) \approx 2B \ \text{FLOP/byte} \quad (\text{for the weight GEMM part})$$
&lt;p>You cross the ridge when &lt;code>2B ≈ 300&lt;/code>, that is B ≈ 150 as an order of magnitude (in practice earlier, because of attention and overheads, but that is the frame). The clean conclusion: &lt;strong>decode at low batch is always deeply memory-bound&lt;/strong>, nowhere near the ridge. That is why we say &amp;ldquo;decode waits on HBM&amp;rdquo;, and why quantising weights (moving fewer bytes) speeds up decode in a large model almost linearly. So far, this is the standard story from the large-model posts.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Roofline diagram with ridge point and where the large and small models fall">
&lt;!-- axes -->
&lt;line x1="80" y1="360" x2="740" y2="360" stroke="currentColor" stroke-width="1.6"/>
&lt;line x1="80" y1="360" x2="80" y2="40" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="410" y="400" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600">Arithmetic intensity (FLOP/byte) — log scale&lt;/text>
&lt;text x="28" y="200" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600" transform="rotate(-90 28 200)">Achievable performance (FLOPS)&lt;/text>
&lt;!-- memory ceiling (ramp) -->
&lt;line x1="80" y1="360" x2="470" y2="90" stroke="#1f5fa8" stroke-width="2.6"/>
&lt;!-- compute ceiling (flat) -->
&lt;line x1="470" y1="90" x2="740" y2="90" stroke="#a52a2a" stroke-width="2.6"/>
&lt;!-- ridge point -->
&lt;line x1="470" y1="90" x2="470" y2="360" stroke="currentColor" stroke-width="1" stroke-dasharray="4 3"/>
&lt;circle cx="470" cy="90" r="5" fill="#5a2db0"/>
&lt;text x="470" y="78" text-anchor="middle" fill="#5a2db0" font-size="12" font-weight="600">ridge ≈ 300 FLOP/byte&lt;/text>
&lt;text x="270" y="200" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600" transform="rotate(-35 270 200)">memory-bound (BW × AI)&lt;/text>
&lt;text x="600" y="80" text-anchor="middle" fill="#a52a2a" font-size="12" font-weight="600">compute-bound (peak FLOPS)&lt;/text>
&lt;!-- decode batch 1 point -->
&lt;circle cx="120" cy="332" r="6" fill="#a48000"/>
&lt;text x="120" y="322" text-anchor="middle" fill="#a48000" font-size="11" font-weight="600">decode B=1 · AI≈2&lt;/text>
&lt;!-- large batch decode point -->
&lt;circle cx="360" cy="167" r="6" fill="#2a7a40"/>
&lt;text x="360" y="157" text-anchor="middle" fill="#2a7a40" font-size="11" font-weight="600">decode B≈64 · AI≈128&lt;/text>
&lt;!-- SLM note -->
&lt;rect x="90" y="300" width="250" height="48" fill="#fff4d6" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="100" y="318" fill="#222" font-size="11" font-weight="600">SLM at B=1: the point is here (memory-bound),&lt;/text>
&lt;text x="100" y="333" fill="#222" font-size="11">but the roofline does NOT model the fixed&lt;/text>
&lt;text x="100" y="346" fill="#222" font-size="11">per-step overhead → see second diagram.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-point-of-the-title-why-it-flips-in-slms">The point of the title: why it flips in SLMs&lt;/h2>
&lt;p>The classic roofline has a blind spot that does not matter in large models and is everything in small ones: &lt;strong>it only models the work inside the kernel&lt;/strong>. It assumes the only time is &lt;code>bytes/BW&lt;/code> or &lt;code>FLOPs/FLOPS&lt;/code>. But a real decode step is not just the GEMM. It is a sequence of dozens of kernels (QKV projections, attention, the two MLP layers, normalisations, residuals, the logits head, sampling) and, around each one, there is a &lt;strong>fixed orchestration cost&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Kernel launches&lt;/strong> (&lt;code>kernel launch&lt;/code>): each &lt;code>cudaLaunchKernel&lt;/code> costs on the order of 5-10 µs of CPU/driver overhead, regardless of kernel size. A decode forward with ~30-60 kernels launched sequentially drags ~0.3-0.6 ms just in launching.&lt;/li>
&lt;li>&lt;strong>Python scheduler overhead&lt;/strong>: the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">vLLM scheduler&lt;/a> loop prepares metadata, decides which requests enter the step, builds the input tensors. In pure Python this is hundreds of µs to a couple of ms per step, especially at low concurrency where it is not amortised.&lt;/li>
&lt;li>&lt;strong>Sampling and post-processing&lt;/strong>: applying temperature, top-p, penalties, the &lt;code>argmax&lt;/code>/multinomial, copying the token back. Another block of hundreds of µs.&lt;/li>
&lt;li>&lt;strong>Synchronisations and host↔device copies&lt;/strong>: every &lt;code>synchronize&lt;/code> or small copy adds latency that is neither FLOPs nor HBM bytes.&lt;/li>
&lt;/ul>
&lt;p>Let us call the sum of all of that &lt;strong>T_fixed&lt;/strong>, the per-step cost independent of model size, on the order of 1-3 ms in an unoptimised Python stack. The real time of a step is now:&lt;/p>
$$T_{\text{step}} \approx \underbrace{\frac{\text{weight bytes}}{\text{BW}}}_{T_{\text{HBM}} \text{ (memory-bound)}} + \; T_{\text{fixed}}$$
&lt;p>In a 70B BF16, moving ~140 GB at 3.35 TB/s takes ~42 ms of &lt;code>T_HBM&lt;/code>. Against that, a &lt;code>T_fixed&lt;/code> of 1-3 ms is &lt;strong>noise (2-7 %)&lt;/strong>. The classic roofline is right: the model &lt;em>is&lt;/em> memory-bound, full stop. But in a 3B BF16, &lt;code>T_HBM&lt;/code> drops to a few ms, and suddenly &lt;code>T_fixed&lt;/code> is the same order as &lt;code>T_HBM&lt;/code>. The bottleneck stops being the pantry and becomes the waiter. This is the flip in the title, and four counterintuitive consequences follow from it:&lt;/p>
&lt;p>&lt;strong>(a) At batch 1 you are still memory-bound &lt;em>with respect to the hardware&lt;/em>.&lt;/strong> The AI has not changed: it is still ~2 FLOP/byte, below the ridge. Anyone reading only the roofline will conclude &amp;ldquo;memory-bound, quantise the weights&amp;rdquo;. That is true but &lt;strong>incomplete&lt;/strong>: the roofline does not see &lt;code>T_fixed&lt;/code>.&lt;/p>
&lt;p>&lt;strong>(b) Fixed costs become a huge fraction of the step.&lt;/strong> This is the central point. In the 70B, &lt;code>T_fixed / T_step ≈ 5 %&lt;/code>. In the 3B it can be 20-30 %. The effective bottleneck of the 3B is half HBM, half orchestration.&lt;/p>
&lt;p>&lt;strong>(c) That is why CUDA graphs and cutting scheduler overhead pay off MORE in SLMs.&lt;/strong> A &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graph&lt;/a> captures the whole kernel sequence of the step and relaunches it with a single &lt;code>cudaGraphLaunch&lt;/code>, removing almost all of the per-kernel launch overhead and much of the per-iteration Python scheduler work. In the 70B, shaving 0.5 ms off a 42 ms step is a +1 % you barely notice. In the 3B, shaving those same 0.5 ms off a ~7 ms step is +7 %, and if you take away nearly all of &lt;code>T_fixed&lt;/code> you can gain 20-30 %. The same optimisation, a different prize, because the denominator changed.&lt;/p>
&lt;p>&lt;strong>(d) Weight quantisation gives LESS latency improvement at batch 1 in SLMs.&lt;/strong> This is the most counterintuitive one. In the 70B, &lt;code>T_HBM&lt;/code> is almost the whole step; going from BF16 to INT4 quadruples the effective weight bandwidth and almost quadruples decode speed. In the 3B, &lt;code>T_HBM&lt;/code> is only &lt;em>part&lt;/em> of the step (the rest is &lt;code>T_fixed&lt;/code> + attention + KV). By Amdahl&amp;rsquo;s law, if the weights are 60 % of the step and you speed them up 4×, the total step improves only &lt;code>1/(0.4 + 0.6/4) = 1.8×&lt;/code>, not 4×. And proportionally there are fewer weights to move relative to activations, KV cache and fixed overhead. Aggressive quantisation in SLMs does help, yes, but &lt;strong>not through raw latency at batch 1&lt;/strong>, where it gives diminishing returns, rather through capacity and concurrency (we will see that at the end).&lt;/p>
&lt;p>&lt;strong>(e) The KV cache can dominate relative memory.&lt;/strong> With 6 GB of weights (3B BF16), a single long-context session can approach that order of magnitude in KV cache. In a 70B (140 GB of weights) the KV is proportionally small until high concurrency. In SLMs the VRAM balance tips towards KV much earlier (the detail is in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>), and that changes which memory optimisation is the lever.&lt;/p>
&lt;h2 id="the-maths-that-matter-a-3b-on-a-4090">The maths that matter: a 3B on a 4090&lt;/h2>
&lt;p>Let us do the whole calculation, which is where the flip shows up without rhetoric.&lt;/p>
&lt;p>&lt;strong>Model:&lt;/strong> 3B parameters, BF16 → 2 bytes/param → ~6 GB of weights. &lt;strong>Hardware:&lt;/strong> RTX 4090, BW ≈ 1 TB/s.&lt;/p>
&lt;p>&lt;strong>Memory-bound ceiling for decode (batch 1).&lt;/strong> Each token requires loading the 6 GB once:&lt;/p>
$$T_{\text{HBM}} = \frac{6 \times 10^{9} \ \text{bytes}}{1 \times 10^{12} \ \text{bytes/s}} = 6 \times 10^{-3}\ \text{s} = 6\ \text{ms/token}$$
$$\text{Ceiling} = \frac{1}{6\ \text{ms}} \approx 166\ \text{tok/s}$$
&lt;p>That is the theoretical memory-bound ceiling: 166 tok/s, assuming that moving the weights is the only cost. The classic roofline would stop here and say &amp;ldquo;166 tok/s, go get more BW or quantise&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Now the fixed overhead.&lt;/strong> Let us put &lt;code>T_fixed ≈ 2 ms/step&lt;/code> (a reasonable value for a Python scheduler + ~40 kernels launched + sampling, without CUDA graphs). The real step:&lt;/p>
$$T_{\text{step}} = T_{\text{HBM}} + T_{\text{fixed}} = 6 + 2 = 8\ \text{ms} \;\Rightarrow\; \frac{1}{8\ \text{ms}} = 125\ \text{tok/s}$$
&lt;p>The overhead has eaten &lt;strong>41 tok/s out of the theoretical 166&lt;/strong>: &lt;code>T_fixed&lt;/code> is &lt;strong>25 % of the step&lt;/strong> (2 of 8 ms). Compare with the 70B: &lt;code>T_HBM ≈ 42 ms&lt;/code>, &lt;code>T_step ≈ 44 ms&lt;/code>, &lt;code>T_fixed&lt;/code> is 4.5 %. &lt;strong>Same absolute overhead, relative impact 5-6× larger in the SLM.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>What happens if you apply CUDA graphs&lt;/strong> and take away, say, 1.5 of the 2 ms of &lt;code>T_fixed&lt;/code>:&lt;/p>
$$T_{\text{step}}^{\text{graphs}} = 6 + 0.5 = 6.5\ \text{ms} \;\Rightarrow\; 154\ \text{tok/s}$$
&lt;p>From 125 to 154 tok/s: &lt;strong>+23 %&lt;/strong> from orchestration alone, without touching the model or the memory hardware. In the 70B the same intervention would have gone from 44 to 42.5 ms, +3.5 %. There it is, in two numbers: &amp;ldquo;a different game&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>What happens if you quantise the weights to INT4&lt;/strong> (1.5 GB instead of 6 GB), with &lt;code>T_fixed&lt;/code> still at 2 ms:&lt;/p>
$$T_{\text{HBM}}^{\text{INT4}} = \frac{1.5 \times 10^{9}}{1 \times 10^{12}} = 1.5\ \text{ms};\quad T_{\text{step}} = 1.5 + 2 = 3.5\ \text{ms} \;\Rightarrow\; 285\ \text{tok/s}$$
&lt;p>The 4× weight quantisation did &lt;strong>not&lt;/strong> give 4× in latency: it went from 125 to 285 tok/s, a 2.3×, because the 2 ms of &lt;code>T_fixed&lt;/code> now dominates (it is 57 % of the step). In the 70B, quantising to INT4 gives almost the full 4× because &lt;code>T_fixed&lt;/code> is still noise. &lt;strong>The same quantisation delivers twice the speedup on the large model as on the small one&lt;/strong>, at batch 1. And if you also apply CUDA graphs on top of INT4 (&lt;code>T_fixed → 0.5 ms&lt;/code>): &lt;code>1.5 + 0.5 = 2 ms → 500 tok/s&lt;/code>. The order of the optimisations matters: in SLMs, &lt;strong>attacking &lt;code>T_fixed&lt;/code> first&lt;/strong> unlocks the rest.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration (3B, 4090, batch 1)&lt;/th>
&lt;th>T_HBM&lt;/th>
&lt;th>T_fixed&lt;/th>
&lt;th>T_step&lt;/th>
&lt;th>tok/s&lt;/th>
&lt;th>vs. baseline&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16, no graphs (baseline)&lt;/td>
&lt;td>6.0 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>8.0 ms&lt;/td>
&lt;td>125&lt;/td>
&lt;td>1.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BF16 + CUDA graphs&lt;/td>
&lt;td>6.0 ms&lt;/td>
&lt;td>0.5 ms&lt;/td>
&lt;td>6.5 ms&lt;/td>
&lt;td>154&lt;/td>
&lt;td>1.23×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4, no graphs&lt;/td>
&lt;td>1.5 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>3.5 ms&lt;/td>
&lt;td>285&lt;/td>
&lt;td>2.28×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 + CUDA graphs&lt;/td>
&lt;td>1.5 ms&lt;/td>
&lt;td>0.5 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>500&lt;/td>
&lt;td>4.00×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;em>(Illustrative figures with &lt;code>T_fixed&lt;/code> rounded; the point is the pattern, not the decimal. The real &lt;code>T_fixed&lt;/code> depends on the stack, the PyTorch/CUDA version and whether there is tensor parallelism. Measure it in your own setup before believing any row.)&lt;/em>&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Per-step time breakdown: large model versus small model">
&lt;text x="390" y="24" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600">Decode time breakdown per step (batch 1)&lt;/text>
&lt;!-- 70B -->
&lt;text x="40" y="78" fill="currentColor" font-size="12" font-weight="600">70B BF16&lt;/text>
&lt;text x="40" y="94" fill="currentColor" font-size="11">step ≈ 44 ms&lt;/text>
&lt;rect x="160" y="60" width="560" height="40" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;rect x="704" y="60" width="16" height="40" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="440" y="85" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">T_HBM (weights from HBM) ≈ 95 %&lt;/text>
&lt;text x="712" y="118" text-anchor="middle" fill="#a52a2a" font-size="10" font-weight="600">T_fixed ~5%&lt;/text>
&lt;!-- 3B -->
&lt;text x="40" y="190" fill="currentColor" font-size="12" font-weight="600">3B BF16&lt;/text>
&lt;text x="40" y="206" fill="currentColor" font-size="11">step ≈ 8 ms&lt;/text>
&lt;rect x="160" y="172" width="420" height="40" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;rect x="580" y="172" width="140" height="40" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="370" y="197" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">T_HBM ≈ 75 %&lt;/text>
&lt;text x="650" y="197" text-anchor="middle" fill="#a52a2a" font-size="11" font-weight="600">T_fixed ≈ 25 %&lt;/text>
&lt;!-- note -->
&lt;text x="160" y="252" fill="currentColor" font-size="11">Same absolute T_fixed (~2 ms): noise in the 70B, a quarter of the step in the 3B.&lt;/text>
&lt;text x="160" y="270" fill="currentColor" font-size="11">CUDA graphs attack the red band → disproportionate impact in the SLM.&lt;/text>
&lt;text x="160" y="288" fill="#5a2db0" font-size="11" font-weight="600">The roofline only models the blue band. The SLM bottleneck lives in the red one.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="implications-per-optimisation">Implications per optimisation&lt;/h2>
&lt;p>With the model in hand, the blog&amp;rsquo;s levers reorder themselves once the regime changes.&lt;/p>
&lt;p>&lt;strong>Batching: far more headroom in SLMs.&lt;/strong> Remember that you cross the ridge at &lt;code>B ≈ ridge/2 ≈ 150&lt;/code> as an order of magnitude. In a large model, VRAM runs out long before you saturate compute (weights + KV do not let you reach batch 150). In an SLM the weights take up little space, so you can fit large batches in VRAM and stay memory-bound over a much wider range: the &lt;code>T_HBM&lt;/code> of the weights is amortised across the B requests (you load them once, they serve B), so aggregate throughput per GPU rises almost linearly with B until very high values. This is exactly the opposite of the 70B&amp;rsquo;s fear of saturating compute. In SLMs, &lt;strong>batching is the throughput lever par excellence&lt;/strong> because you saturate compute late; the &lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">batch grid search in vLLM&lt;/a> has a much wider plateau of good behaviour. Careful: batching improves &lt;em>throughput&lt;/em>, not per-request &lt;em>latency&lt;/em>; for single-stream latency the prize is in &lt;code>T_fixed&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Speculative decoding: a different crossover point.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative&lt;/a> wins when verifying γ tokens is &amp;ldquo;almost free&amp;rdquo; because you are memory-bound. In an SLM the target is already cheap, so the draft has to be tiny for &lt;code>c = T_draft/T_target&lt;/code> to stay small, and the draft&amp;rsquo;s own &lt;code>T_fixed&lt;/code> (launching its kernels) bites harder. The crossover to compute-bound with batching also arrives earlier in absolute terms of tok/s served. The variant that fits best here avoids a separate draft: &lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">self-speculative / early-exit&lt;/a> reuses early layers of the model itself and saves the &lt;code>T_fixed&lt;/code> of orchestrating two models.&lt;/p>
&lt;p>&lt;strong>Quantisation: it helps for capacity, not for latency at batch 1.&lt;/strong> As the table showed, INT4 in an SLM at batch 1 gives diminishing returns in latency. Its real prize in SLMs is &lt;strong>capacity&lt;/strong>: weights 4× smaller free VRAM for more KV cache → more concurrency, and it is at high concurrency (aggregate throughput) that saving bytes pays off again. &lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> takes this to the extreme: in SLMs it makes sense above all for fitting more sessions per GPU, not for lowering the latency of a single one. And it is worth remembering (see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a>) that at batch 1 the &lt;code>dequantize&lt;/code> adds compute work which, in a regime already grazed by &lt;code>T_fixed&lt;/code>, is not always free.&lt;/p>
&lt;p>&lt;strong>Architecture: fine-grained MoE changes which bytes you move.&lt;/strong> A &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">fine-grained device-native MoE&lt;/a> activates few parameters per token, so &lt;code>T_HBM&lt;/code> drops relative to a dense model of the same total size, but the &lt;code>T_fixed&lt;/code> fraction rises even further, and the router adds its own fixed overhead. It is the SLM regime taken to its limit: almost the whole game is played in orchestration.&lt;/p>
&lt;p>&lt;strong>Scheduler and CUDA graphs first.&lt;/strong> The operational conclusion, inverted with respect to the large-model posts: in SLMs, &lt;strong>before touching the model, kill &lt;code>T_fixed&lt;/code>&lt;/strong>. CUDA graphs (see &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, streams and graphs&lt;/a>), a &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">vLLM scheduler&lt;/a> with its Python part minimised or compiled, and kernel persistence are the first-order levers. In a 70B they would be marginal polish; in a 3B they are half the available speedup.&lt;/p>
&lt;h2 id="applied-to-on-premise-hardware">Applied to on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB, Ada Lovelace).&lt;/strong> This is the scenario where the flip is most visible, because the 4090 has ~1 TB/s (a third of the H100) but &lt;code>T_fixed&lt;/code> is the same in absolute terms. A 3B BF16 without CUDA graphs leaves ~125 tok/s on the table when the memory-bound ceiling is 166; enabling graphs and cleaning up the scheduler recovers most of that. The 4090 has plenty of VRAM room for an SLM, so the bottleneck is almost never total memory but orchestration and, at high concurrency, the KV cache. Rule of thumb: on a 4090 with an SLM, profile the per-step overhead first (Nsight Systems on the gap between kernels) before you quantise.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> The H100 has 3.35 TB/s, so the &lt;code>T_HBM&lt;/code> of an SLM is even smaller (a 3B FP8 is ~3 GB → ~0.9 ms) and &lt;code>T_fixed&lt;/code> dominates even earlier: a badly orchestrated SLM on an H100 can spend more time in the Python scheduler than moving weights. Serving a single single-stream SLM on an H100 is close to a waste; the right mode is &lt;strong>aggressive batching&lt;/strong> (you saturate compute late, so you push large batches through and per-GPU throughput takes off) or multiplexing many SLMs/sessions per GPU via MPS/MIG. This connects with &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: for SLMs the capacity calculation is governed by concurrency and KV cache, not by the weights. And with the dilemma of &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big versus N small&lt;/a>: replicating SLMs makes sense precisely because each replica saturates compute late and TP brings nothing (the model already fits; TP would only add communication &lt;code>T_fixed&lt;/code>).&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The exact measured &lt;code>T_fixed&lt;/code>&lt;/strong>, kernel by kernel, with Nsight Systems: how much is launch, how much scheduler, how much sampling. That is the content of the next post in the series.&lt;/li>
&lt;li>&lt;strong>&lt;code>torch.compile&lt;/code> / partial captures&lt;/strong>: alternatives and complements to CUDA graphs when there is dynamic control flow.&lt;/li>
&lt;li>&lt;strong>The prefill regime in SLMs&lt;/strong>: prefill is compute-bound even in small models (it processes many tokens at once, high AI), so its roofline is the opposite of decode&amp;rsquo;s; see &lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">prefill&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Attention and KV as the second term of &lt;code>T_HBM&lt;/code>&lt;/strong>: here we have folded them in implicitly; the fine-grained breakdown of attention (which scales with sequence length, not with the weights) deserves its own treatment.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of inference&lt;/a> — the memory-bound nature of decode is born in the KV cache; in SLMs the KV comes to dominate relative VRAM earlier than in large models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">Batch sizing grid search in vLLM&lt;/a> — the plateau of good batch sizes is much wider in SLMs because you cross the ridge late; that post gives the empirical method.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — the concrete flags (CUDA graphs, eager vs captured) whose impact this post reorders for the SLM case.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — the compute-bound flip side of the roofline: prefill already lives above the ridge even in small models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — the mechanism that attacks &lt;code>T_fixed&lt;/code>; here we explain why its prize is disproportionate in SLMs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a> — much of &lt;code>T_fixed&lt;/code> lives in this Python loop; in SLMs minimising it is a first-order lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for inference&lt;/a> — why weight quantisation yields less latency at batch 1 in SLMs (Amdahl&amp;rsquo;s law over &lt;code>T_HBM&lt;/code>) and more in capacity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: fundamentals&lt;/a> — the memory/compute crossover point shifts in SLMs, changing when speculative pays off.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — for SLMs, capacity is governed by concurrency and KV, not by the weights; that post gives the formulas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big versus N small&lt;/a> — replicating SLMs beats TP because each replica saturates compute late and TP only adds communication &lt;code>T_fixed&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">Self-speculative decoding / early-exit&lt;/a> — sibling in the series: speeding up without a separate draft, avoiding the &lt;code>T_fixed&lt;/code> of orchestrating two models, a natural fit in SLMs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Fine-grained device-native MoE&lt;/a> — sibling in the series: the SLM regime taken to its limit, where the router and orchestration dominate over &lt;code>T_HBM&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> — sibling in the series: why in SLMs sub-4-bit pays off mostly in capacity/concurrency, not in latency at batch 1.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Williams, S., Waterman, A., Patterson, D. &lt;em>Roofline: An Insightful Visual Performance Model for Multicore Architectures&lt;/em>. Communications of the ACM, 52(4), 2009. &lt;a href="https://doi.org/10.1145/1498765.1498785">https://doi.org/10.1145/1498765.1498785&lt;/a>&lt;/li>
&lt;li>&lt;em>Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&lt;/em>. arXiv:2503.08311, 2025. &lt;a href="https://arxiv.org/abs/2503.08311">https://arxiv.org/abs/2503.08311&lt;/a>&lt;/li>
&lt;li>Databricks. &lt;em>LLM Inference Performance Engineering: Best Practices&lt;/em>. &lt;a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices">https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices&lt;/a>&lt;/li>
&lt;li>NVIDIA. &lt;em>NVIDIA H100 Tensor Core GPU Datasheet&lt;/em>. &lt;a href="https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet">https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet&lt;/a>&lt;/li>
&lt;li>NVIDIA. &lt;em>GeForce RTX 4090 — product specifications&lt;/em> (Ada Lovelace tensor core figures; treat as approximate, they mix dense/sparse).&lt;/li>
&lt;li>Yuan, Z. et al. &lt;em>LLM Inference Unveiled: Survey and Roofline Model Insights&lt;/em>. arXiv:2402.16363, 2024 — application of the roofline specifically to LLM inference.&lt;/li>
&lt;/ul></description></item></channel></rss>