<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Trl on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/trl/</link><description>Recent content in Trl 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/trl/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></channel></rss>