<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Punica on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/punica/</link><description>Recent content in Punica on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 09 Jun 2026 02:30:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/punica/index.xml" rel="self" type="application/rss+xml"/><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>Multi-LoRA serving: the single translator with a thousand glossaries — shared base, thousands of concurrent adapters and the SGMV kernel</title><link>https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/</link><pubDate>Sat, 30 May 2026 14:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/</guid><description>&lt;blockquote>
&lt;p>This post complements &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a>. Continuous fine-tuning is the producer of the adapters; multi-LoRA serving is the consumer that puts them to work. Without this layer, the whole feedback cycle breaks in the last mile. It also crosses over with &lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment (DPO/KTO/ORPO/SimPO)&lt;/a> (each alignment policy can live as a separate adapter) and &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a> (a quantised base frees memory for many more adapters).&lt;/p>
&lt;/blockquote>
&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;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mlm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mlm)}&lt;/style>
&lt;defs>&lt;marker id="mlm" 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" class="lbl">You are here: DEPLOY · shared base, N concurrent adapters on a single GPU&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The dominant pattern in 2026 is not one model per customer but a single general-purpose base model plus N fine LoRA adapters per task, customer, language or domain. The reason is obvious: a rank-16 LoRA over Llama-3-70B takes about 400 MB; a full fine-tune takes about 140 GB. Dozens or hundreds of adapters per base is manageable; dozens or hundreds of bases is prohibitive. What is not obvious is how to serve them concurrently without reloading weights every time the adapter changes (killing batching) or replicating the base (killing memory). The answer crystallised in 2024 with two complementary papers: &lt;strong>S-LoRA&lt;/strong> (Sheng et al., Stanford + UC Berkeley, MLSys 2024) introduced &lt;em>unified paging&lt;/em>, where adapter weights live in the same memory pool as the KV cache, both pageable, and &lt;em>heterogeneous batching&lt;/em>, where a batch can hold requests with different adapters and different ranks without padding; &lt;strong>Punica&lt;/strong> (Chen et al., UW + Duke, MLSys 2024) introduced the CUDA kernel that has become the de facto standard: &lt;strong>SGMV&lt;/strong> (Segmented Gather Matrix-Vector multiplication), which computes &lt;code>Y += Σ_i X_i · A_i · B_i&lt;/code> in a single pass, grouping requests by adapter. SGMV sits today underneath vLLM, LoRAX (Predibase), SGLang and TGI. The measurable operational result: up to &lt;strong>2,000 concurrent adapters on a single GPU&lt;/strong> (S-LoRA paper), up to 4× throughput vs naive vLLM and up to 30× vs HuggingFace PEFT. The price: typical overhead of &lt;strong>10-30 %&lt;/strong> latency per layer with an active adapter in a heterogeneous batch, practically zero when every request in the batch uses the same adapter, and 20-40 % in the worst case. This post takes apart the mechanism, the maths (memory per adapter, overhead per rank), the comparison table of implementations, the pitfalls (cold start, mismatched rank, fragmentation) and the real economics on an H100 with a Llama-3-70B FP8 base + 200 adapters.&lt;/p>
&lt;h2 id="the-analogy-the-single-translator-with-a-thousand-glossaries">The analogy: the single translator with a thousand glossaries&lt;/h2>
&lt;p>Imagine a specialised translation agency with a single senior translator, brilliant, fluent in fifteen languages and across all the general technical domains. That translator is expensive to hire and expensive to train: it took years of formation and experience that is not easily replicated. But the agency receives texts from very different clients: a law firm using legal terminology specific to its jurisdiction, a manufacturer with an internal parts nomenclature, a hospital with its own clinical abbreviations. Every client has its own jargon.&lt;/p>
&lt;p>The agency does not hire one translator per client; that would be ridiculous, 90 % of the work is shared. What it does is &lt;strong>keep one glossary per client&lt;/strong>: a small notebook, easy to update, containing the specific terms and how they are translated for that client. When the translator receives a text, they open the glossary for the client in question and work with it alongside. As they translate each word, they first check whether it is in the glossary; if it is, they use the specific version; if not, they use their general knowledge.&lt;/p>
&lt;p>The glossaries live on a shared shelf, ordered by recent use: the most consulted ones within reach, the old ones in the archive. When a new client arrives, their glossary is brought from the archive to the shelf. When the desk fills up, the least used glossary goes back to the archive.&lt;/p>
&lt;p>And the most important part: the translator can have several glossaries open at once because they are working in parallel on five texts from five clients. It is not one glossary per document; it is one glossary per client, and client A&amp;rsquo;s documents use A&amp;rsquo;s glossary, client B&amp;rsquo;s use B&amp;rsquo;s, all on the same desk.&lt;/p>
&lt;p>The analogy holds on five mappings:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The single translator&lt;/strong> = the base model (Llama-3-70B, Qwen2.5-72B). Expensive to train, a single copy in VRAM.&lt;/li>
&lt;li>&lt;strong>Each glossary&lt;/strong> = a LoRA adapter. Small (~150-400 MB), specific, easy to update.&lt;/li>
&lt;li>&lt;strong>The shelf with the glossaries within reach&lt;/strong> = the pool of adapters cached in VRAM (typically 50-200 at a time with an FP8 base on an H100 80 GB).&lt;/li>
&lt;li>&lt;strong>The archive&lt;/strong> = adapter storage on MinIO/S3/HF Hub. Hundreds or thousands, fetched on demand.&lt;/li>
&lt;li>&lt;strong>Working in parallel with several glossaries open&lt;/strong> = a heterogeneous batch with SGMV. The kernel does the grouped lookup into the right glossary for each word in the batch.&lt;/li>
&lt;/ul>
&lt;h2 id="the-bare-mechanism-what-a-lora-does-and-why-it-can-be-served-multi-tenant">The bare mechanism: what a LoRA does and why it can be served multi-tenant&lt;/h2>
&lt;p>A LoRA adapter modifies a matrix &lt;code>W&lt;/code> of the base model by adding a low-rank product to it:&lt;/p>
$$W' = W + B A$$
&lt;p>where &lt;code>W ∈ R^{d_out × d_in}&lt;/code> is the original matrix (the base weights), &lt;code>A ∈ R^{r × d_in}&lt;/code> and &lt;code>B ∈ R^{d_out × r}&lt;/code> are the adapter&amp;rsquo;s trainable matrices, and &lt;code>r&lt;/code> is the &lt;strong>rank&lt;/strong> (typically 8, 16, 32 or 64, always much smaller than &lt;code>d_in&lt;/code> and &lt;code>d_out&lt;/code>).&lt;/p>
&lt;p>In a forward pass, instead of computing &lt;code>y = W' x&lt;/code>, it computes:&lt;/p>
$$y = W x + B(Ax)$$
&lt;p>That is: the base computation (&lt;code>Wx&lt;/code>) happens exactly as before; the adapter adds two cheap matmuls (&lt;code>Ax&lt;/code> and then &lt;code>B(·)&lt;/code>) that supply the correction. The matrix &lt;code>BA&lt;/code> is never explicitly materialised.&lt;/p>
&lt;p>&lt;strong>What this enables in serving&lt;/strong>: if you have the base loaded and N different adapters, &lt;code>Wx&lt;/code> is computed only once for all the tokens in the batch (the base is the same). What changes between tokens is only the delta &lt;code>B_i(A_i x)&lt;/code>. If the tokens in the batch use different adapters, different deltas have to be applied per token, and that is what the SGMV kernel does in a single pass.&lt;/p>
&lt;p>Without a specialised kernel this degenerates: you need to launch N separate matmuls (one per adapter), pay kernel launch overhead N times and lose the batching. With a specialised kernel (SGMV), every delta is computed in a single pass grouped by adapter.&lt;/p>
&lt;h2 id="sgmv-the-kernel-that-holds-it-all-up">SGMV: the kernel that holds it all up&lt;/h2>
&lt;p>&lt;strong>SGMV&lt;/strong> (Segmented Gather Matrix-Vector multiplication) is the CUDA kernel Punica introduced and that vLLM, LoRAX, SGLang and TGI have adopted as their multi-LoRA engine.&lt;/p>
&lt;p>Its job is to compute, given a batch of tokens with mixed adapters:&lt;/p>
$$y_t = W x_t + B_{a(t)} A_{a(t)} x_t \quad \forall t \in \text{batch}$$
&lt;p>where &lt;code>a(t)&lt;/code> is the adapter assigned to token &lt;code>t&lt;/code>. SGMV operates in two phases:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>SGMV-shrink&lt;/strong>: projection &lt;code>d_in → r&lt;/code> with the corresponding &lt;code>A_{a(t)}&lt;/code> matrix.&lt;/li>
&lt;li>&lt;strong>SGMV-expand&lt;/strong>: projection &lt;code>r → d_out&lt;/code> with the corresponding &lt;code>B_{a(t)}&lt;/code> matrix.&lt;/li>
&lt;/ol>
&lt;p>Internally, SGMV groups the batch&amp;rsquo;s tokens by adapter (it &lt;code>segments&lt;/code> them), and for each segment it uses the optimal kernel for the size: for large segments (several requests with the same adapter) it goes through tensor cores; for small segments (one request per adapter) it uses the &lt;em>batched gather&lt;/em> path that minimises launch overhead.&lt;/p>
&lt;p>The result, in a single kernel pass, is the correct delta for every token in the batch, whatever its rank or its adapter. Punica reported up to 12× throughput vs vLLM/FasterTransformer/HF Transformers/DeepSpeed in heterogeneous multi-tenant scenarios; when every request uses the same adapter, SGMV is practically equivalent to the base without LoRA because it reduces to the &amp;ldquo;single large segment&amp;rdquo; case that is optimal for tensor cores.&lt;/p>
&lt;p>S-LoRA refined SGMV with two kernels specific to different serving phases: &lt;strong>MBGMM&lt;/strong> (Multi-size Batched Gather Matrix-Matrix) for prefill, &lt;strong>MBGMV&lt;/strong> for decode. Both support different ranks among requests in the same batch, which was a limitation in the original SGMV.&lt;/p>
&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="Multi-LoRA serving with SGMV kernel and hierarchical cache">
&lt;style>
.base{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.4;rx:8}
.gpu{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:4}
.cpu{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:4}
.s3{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.2;rx:4}
.req{fill:#f6e0c8;stroke:#a76b1f;stroke-width:1.2;rx:4}
.kern{fill:#f6caca;stroke:#a52a2a;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mlk)}
.evict{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mlk)}
&lt;/style>
&lt;defs>&lt;marker id="mlk" 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="#444"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" class="lbl">1. Heterogeneous batch: four requests with three different adapters&lt;/text>
&lt;rect x="20" y="35" width="110" height="35" class="req"/>&lt;text x="75" y="55" text-anchor="middle" class="lbl">req_1 → A12&lt;/text>&lt;text x="75" y="68" text-anchor="middle" class="sub">tenant_1&lt;/text>
&lt;rect x="140" y="35" width="110" height="35" class="req"/>&lt;text x="195" y="55" text-anchor="middle" class="lbl">req_2 → A12&lt;/text>&lt;text x="195" y="68" text-anchor="middle" class="sub">tenant_1 (another chat)&lt;/text>
&lt;rect x="260" y="35" width="110" height="35" class="req"/>&lt;text x="315" y="55" text-anchor="middle" class="lbl">req_3 → A47&lt;/text>&lt;text x="315" y="68" text-anchor="middle" class="sub">tenant_2&lt;/text>
&lt;rect x="380" y="35" width="110" height="35" class="req"/>&lt;text x="435" y="55" text-anchor="middle" class="lbl">req_4 → A89&lt;/text>&lt;text x="435" y="68" text-anchor="middle" class="sub">tenant_3&lt;/text>
&lt;text x="20" y="105" class="lbl">2. SGMV kernel: groups by adapter, computes in a single pass&lt;/text>
&lt;rect x="20" y="115" width="470" height="60" class="kern"/>
&lt;text x="255" y="138" text-anchor="middle" class="lbl">SGMV: Y = Wx (base) + Σ_a B_a · A_a · x_{tokens(a)}&lt;/text>
&lt;text x="255" y="156" text-anchor="middle" class="sub">segment A12 (2 reqs, rank=16) | segment A47 (1 req, rank=8) | segment A89 (1 req, rank=32)&lt;/text>
&lt;text x="255" y="169" text-anchor="middle" class="sub">tensor cores for the large segment, batched gather for the small ones&lt;/text>
&lt;text x="20" y="200" class="lbl">3. GPU memory: shared base + unified adapter pool + KV cache&lt;/text>
&lt;rect x="20" y="210" width="470" height="50" class="base"/>
&lt;text x="255" y="230" text-anchor="middle" class="lbl">BASE: Llama-3-70B FP8 (~70 GB) — loaded once, shared by all&lt;/text>
&lt;text x="255" y="248" text-anchor="middle" class="sub">computes Wx for every token in the batch whatever the adapter&lt;/text>
&lt;rect x="20" y="270" width="220" height="55" class="gpu"/>
&lt;text x="130" y="290" text-anchor="middle" class="lbl">HBM POOL: ~10 GB free&lt;/text>
&lt;text x="130" y="305" text-anchor="middle" class="sub">~25 active r=16 adapters (hot)&lt;/text>
&lt;text x="130" y="318" text-anchor="middle" class="sub">A12, A47, A89, A03, A18, A23, ...&lt;/text>
&lt;rect x="270" y="270" width="220" height="55" class="cpu"/>
&lt;text x="380" y="290" text-anchor="middle" class="lbl">RAM CACHE: ~512 GB&lt;/text>
&lt;text x="380" y="305" text-anchor="middle" class="sub">~1,300 warm adapters&lt;/text>
&lt;text x="380" y="318" text-anchor="middle" class="sub">LRU eviction; async H2D on use&lt;/text>
&lt;text x="520" y="232" class="lbl">4. STORAGE&lt;/text>
&lt;rect x="520" y="240" width="240" height="85" class="s3"/>
&lt;text x="640" y="260" text-anchor="middle" class="lbl">MinIO / S3 / HF Hub&lt;/text>
&lt;text x="640" y="278" text-anchor="middle" class="sub">cold storage: thousands of adapters&lt;/text>
&lt;text x="640" y="293" text-anchor="middle" class="sub">A0001 ... A9999&lt;/text>
&lt;text x="640" y="308" text-anchor="middle" class="sub">cold start: ~0.5-5s per adapter&lt;/text>
&lt;path class="arr" d="M270,297 L240,297"/>
&lt;path class="evict" d="M240,310 L270,310"/>
&lt;path class="arr" d="M520,295 L490,295"/>
&lt;text x="20" y="355" class="sub">Solid arrow = on-demand path (cache miss). Dashed = LRU eviction.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three numbers drive every operational decision with multi-LoRA.&lt;/p>
&lt;p>&lt;strong>Memory per adapter.&lt;/strong> For a &lt;code>d_in × d_out&lt;/code> matrix with rank &lt;code>r&lt;/code> and &lt;code>b&lt;/code> bytes per parameter (BF16/FP16 = 2):&lt;/p>
$$\text{bytes per matrix} = (d_{\text{in}} \cdot r + r \cdot d_{\text{out}}) \cdot b$$
&lt;p>Summing over all the target matrices in each layer and multiplying by the number of layers gives the adapter size. A concrete calculation for &lt;strong>Llama-3-70B&lt;/strong>, rank 16, BF16, &lt;strong>all&lt;/strong> the matrices (Q, K, V, O, gate, up, down):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Matrix&lt;/th>
&lt;th>Dimension&lt;/th>
&lt;th>Bytes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Q (8192→8192)&lt;/td>
&lt;td>8192·16 + 16·8192&lt;/td>
&lt;td>524,288&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>O (8192→8192)&lt;/td>
&lt;td>8192·16 + 16·8192&lt;/td>
&lt;td>524,288&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>K (8192→1024)&lt;/td>
&lt;td>8192·16 + 16·1024&lt;/td>
&lt;td>294,912&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>V (8192→1024)&lt;/td>
&lt;td>8192·16 + 16·1024&lt;/td>
&lt;td>294,912&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>gate (8192→28,672)&lt;/td>
&lt;td>8192·16 + 16·28,672&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>up (8192→28,672)&lt;/td>
&lt;td>8192·16 + 16·28,672&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>down (28,672→8192)&lt;/td>
&lt;td>28,672·16 + 16·8192&lt;/td>
&lt;td>1,179,648&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sum per layer&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>5,177,344 ≈ 4.94 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Adapter total (80 layers)&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~395 MB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If it is limited to attention-only (Q, O, V, K): about 125 MB per adapter. The choice of which matrices get LoRA belongs to whoever trains it; in serving it is inherited and it determines the cost.&lt;/p>
&lt;p>&lt;strong>Memory per rank.&lt;/strong> Linear: rank 8 → ~200 MB; rank 16 → ~400 MB; rank 32 → ~800 MB; rank 64 → ~1.6 GB. The simple sizing rule is: &lt;code>max_lora_rank&lt;/code> should be the maximum rank you are going to serve, no more. Setting it higher wastes reserved memory in every slot.&lt;/p>
&lt;p>&lt;strong>How many adapters fit.&lt;/strong> For an H100 SXM 80 GB with a Llama-3-70B FP8 base (~70 GB), about 10 GB are left after a minimal KV cache → around 25 fully targeted r=16 adapters or around 80 attention-only. With an AWQ INT4 base (~35 GB), about 45 GB are left → hundreds of adapters. The rule: quantising the base does not just free memory, it multiplies the economics of the platform.&lt;/p>
&lt;p>&lt;strong>Latency overhead per adapter.&lt;/strong> Under real reported conditions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Typical overhead&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>All requests in the batch on the same adapter&lt;/td>
&lt;td>~0 % (equivalent to a static merge)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Heterogeneous batch, similar ranks (e.g. all r=16)&lt;/td>
&lt;td>10-30 % per layer&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Heterogeneous batch, mismatched ranks (r=8 with r=128)&lt;/td>
&lt;td>up to +84 % P95 TTFT for the lower rank&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Naive PEFT LoRA (no SGMV)&lt;/td>
&lt;td>250-950 % extra&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>It scales linearly with rank: rank 8 ≈ baseline; rank 64 ≈ 3-4 × overhead.&lt;/p>
&lt;h2 id="the-real-implementations-in-may-2026">The real implementations in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Implementation&lt;/th>
&lt;th>Base kernel&lt;/th>
&lt;th>Hot-swap&lt;/th>
&lt;th>Quantised base + LoRA&lt;/th>
&lt;th>Operational notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM&lt;/strong>&lt;/td>
&lt;td>SGMV + ext&lt;/td>
&lt;td>Yes (LoRAResolver, S3/HF/FS plugins)&lt;/td>
&lt;td>AWQ/GPTQ yes, bnb 4-bit offline only&lt;/td>
&lt;td>The de facto default. &lt;code>--enable-lora --max-loras N --max-lora-rank R --max-cpu-loras M&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LoRAX&lt;/strong> (Predibase)&lt;/td>
&lt;td>Optimised SGMV&lt;/td>
&lt;td>Yes (dynamic loading)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Designed specifically for multi-LoRA. Supports Medusa adapters per adapter (spec-dec per adapter).&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>SGMV / csgmv&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>--enable-lora-overlap-loading&lt;/code> cuts TTFT by up to 78 % on LoRA-heavy workloads&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>LoRA Executor (C++)&lt;/td>
&lt;td>Pre-compile at build time&lt;/td>
&lt;td>INT4 + LoRA common&lt;/td>
&lt;td>Peak throughput on H100/B200, less flexible than vLLM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HF TGI&lt;/strong>&lt;/td>
&lt;td>Punica fork&lt;/td>
&lt;td>Yes (&lt;code>LORA_ADAPTERS=...&lt;/code>)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>In &lt;em>maintenance mode&lt;/em> as of May 2026; HF recommends vLLM or SGLang&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NVIDIA NIM&lt;/strong>&lt;/td>
&lt;td>TRT-LLM under the hood&lt;/td>
&lt;td>Static or dynamic (&lt;code>NIM_PEFT_REFRESH_INTERVAL&lt;/code>)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Adapter store per model; polling for hot-add/remove&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>vLLM dominates&lt;/strong> open-source serving in 2026 thanks to the combination of a mature SGMV + LoRAResolver plugins + support for a quantised base. The critical parameter is &lt;code>--max-lora-rank&lt;/code>: many installations set it to the maximum &amp;ldquo;just in case&amp;rdquo; and waste memory silently.&lt;/li>
&lt;li>&lt;strong>LoRAX wins&lt;/strong> in production operations with thousands of rarely used adapters thanks to its dynamic loading that does not block concurrent requests. A public case: Convirza with 60+ concurrent adapters and sub-2s P95.&lt;/li>
&lt;li>&lt;strong>SGLang wins&lt;/strong> on latency when cold starts are frequent thanks to &lt;code>--enable-lora-overlap-loading&lt;/code> (async H2D during the compute of the previous request).&lt;/li>
&lt;/ol>
&lt;h2 id="combined-pattern-with-quantisation-and-disaggregated-serving">Combined pattern with quantisation and disaggregated serving&lt;/h2>
&lt;p>&lt;strong>With quantisation&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a>). The canonical stack in May 2026 is base in FP8 (Hopper/Blackwell) or INT4 AWQ + adapters in BF16/FP16. The adapters are not quantised: they are small, the memory saved is irrelevant, and quantisation noise would accumulate badly with the delta. Quantising the base frees massive amounts of memory for more adapters with no significant loss (&amp;lt;1 % in typical MMLU with AWQ INT4, somewhat more in math/code/reasoning). An adapter trained with a BF16 base works with an FP8/INT4 base at inference with marginal loss, and that is what makes QLoRA operationally trivial: train with a 4-bit base and deploy with a consistent 4-bit base.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>). Multi-LoRA + prefill/decode disaggregation adds a management layer: each pod needs access to the request&amp;rsquo;s active adapter. The 2026 strategy: replicate hot adapters across every pod (prefill and decode), evict the cold ones. Cold adapters not present in the target pod are transferred on demand, accepting the extra cost in TTFT. Recent work (InfiniLoRA, FASTLIBRA, LoRAServe) automates this balancing, but the simple rule of thumb works in most deployments.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Cold start.&lt;/strong> The first request to a sleeping adapter involves a fetch (from S3/MinIO/HF Hub) → CPU load → H2D copy. For an adapter of around 400 MB: 0.5-5 s typically, depending on bandwidth. Under high concurrency it can be up to 35 % of E2E latency (Predictive-LoRA paper, arXiv:2512.20210). Established mitigations: SGLang &lt;code>--enable-lora-overlap-loading&lt;/code> (cuts TTFT by 35-78 %); vLLM pre-warming with a dummy request when an adapter is registered; predictive prefetching based on patterns (Predictive-LoRA cuts cold start by 68 %).&lt;/p>
&lt;p>&lt;strong>Heterogeneous worst case.&lt;/strong> If every request in the batch has a different adapter and a different rank, SGMV loses its advantage because each segment has size 1. Throughput can fall by up to 50 % vs the base without LoRA. Practical mitigation: group adapters by rank in the upstream routing, trying to get requests of the same rank into the same batch step.&lt;/p>
&lt;p>&lt;strong>Mismatched rank.&lt;/strong> Co-batching rank 8 with rank 128 penalises the smaller one: &lt;strong>+84 % P95 TTFT&lt;/strong> for the rank-8 requests (Serving Heterogeneous LoRA Adapters paper, arXiv:2511.22880). Practice: normalise the rank across the fleet whenever possible (train every adapter at the same rank, or at least within a narrow range).&lt;/p>
&lt;p>&lt;strong>Eviction.&lt;/strong> LRU is the default. If you have more adapters than fit in CPU RAM, the evicted ones get fetched again from cold storage. Monitoring &lt;code>cold_starts_per_minute&lt;/code> and &lt;code>cache_hit_ratio&lt;/code> per endpoint is basic hygiene.&lt;/p>
&lt;p>&lt;strong>Base versioning.&lt;/strong> Every adapter is tied to an exact base version (Llama-3-70B-Instruct ≠ Llama-3.1-70B-Instruct). The routing must validate the base+adapter pair before serving, or the output will be silent rubbish.&lt;/p>
&lt;p>&lt;strong>Memory fragmentation.&lt;/strong> Without paged management (the pre-S-LoRA / pre-vLLM case), evicting and inserting adapters fragments HBM until it becomes unusable. Unified Paging solves it: the LoRA weights and the KV cache live in the same pool of blocks, interchangeable.&lt;/p>
&lt;h2 id="implications-on-on-premise-hardware">Implications on on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB).&lt;/strong> The classic case: base &lt;strong>Llama-3-8B FP8&lt;/strong> (~8 GB) or &lt;strong>Llama-3-8B AWQ-INT4&lt;/strong> (~5 GB) + dozens of r=16 adapters (~80 MB each, around 3 MB per adapter for Llama-3-8B with r=16 attention-only). 50-100 active adapters fit easily on the 4090. It is the natural setup for multi-tenant demos, fine-tuning over an 8B base and platform prototypes.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> This is where the serious setup comes in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama-3-70B FP8&lt;/strong> (~70 GB) fits on 2 H100s; the other 2 GPUs are available for batch + adapters.&lt;/li>
&lt;li>&lt;strong>Llama-3-70B AWQ-INT4&lt;/strong> (~35 GB) fits on 1 H100; the rest of the cluster serves more concurrency or more adapters.&lt;/li>
&lt;li>&lt;strong>~200 fully targeted r=16 adapters&lt;/strong> fit with a comfortable budget on the cluster, enough for a SaaS platform with dozens of tenants and simultaneous A/B testing.&lt;/li>
&lt;li>&lt;strong>QLoRA training + consistent serving&lt;/strong>: the train→deploy adapter cycle takes hours, not days, because the adapter is around 400 MB instead of around 140 GB.&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in May 2026: an FP8 or INT4 base + 100-500 adapters per cluster is operationally trivial with vLLM or LoRAX; going past a thousand concurrent adapters starts to require serious tuning of eviction and prefetch.&lt;/p>
&lt;h2 id="typical-production-stack">Typical production stack&lt;/h2>
&lt;pre tabindex="0">&lt;code>[API Gateway]
↓ (JWT with tenant_id / API key)
[Router]
↓ injects adapter_id into the request
[vLLM / LoRAX with --enable-lora]
--max-loras 16
--max-lora-rank 32
--max-cpu-loras 200
+ LoRAResolver → s3://adapters/{tenant_id}/{version}/
+ Base: Llama-3-70B-FP8 loaded once on 4×H100 TP=4
↓
[GPU]
FP8 base (70 GB) + ~150 BF16 adapters hot in HBM + ~1,000 warm in RAM
[MinIO / S3]
Cold storage for thousands of adapters
[CI pipeline]
→ trains new QLoRA adapter → push MinIO → notify server → warm-up
[Observability]
Prom: active_adapters, cache_hit_ratio, cold_starts_per_minute,
per_adapter_throughput, P99_with_lora vs P99_base
&lt;/code>&lt;/pre>&lt;p>Routing pattern: &lt;code>Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code> → middleware extracts &lt;code>tenant_id&lt;/code> → maps to &lt;code>adapter_id&lt;/code> (Postgres or Redis) → &lt;code>POST /v1/completions&lt;/code> with &lt;code>model: &amp;quot;&amp;lt;adapter_id&amp;gt;&amp;quot;&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>DoRA (Weight-Decomposed LoRA)&lt;/strong>: decomposes the update into magnitude + direction, closing part of the quality gap with full fine-tuning. Supported by TensorRT-LLM and others, but the serving pattern is identical to LoRA.&lt;/li>
&lt;li>&lt;strong>MoE + LoRA&lt;/strong>: how adapter fine-tuning is done over an MoE, what happens to the routing. Not trivial, an active research area in 2026.&lt;/li>
&lt;li>&lt;strong>Activated LoRA&lt;/strong> (arXiv:2512.17910): a variant that reuses the KV cache between compatible adapters, cutting the cost of cold start with shared prefixes.&lt;/li>
&lt;li>&lt;strong>LoRA for speculative decoding&lt;/strong>: each adapter brings its own Medusa head, supported by LoRAX as &amp;ldquo;Turbo LoRA&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Compress-then-Serve&lt;/strong> (arXiv:2407.00066): quantising the adapters themselves to serve even more concurrently. Still a marginal practice in production as of May 2026.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU: co-residency, model-swapping and sleep mode&lt;/a> — the alternative when the models do not share a base: co-residency, swap or sleep instead of adapters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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 producer of the adapter fleet; multi-LoRA serving is the consumer that closes the cycle.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> — how a new adapter is trained from production feedback; the resulting adapter is served with this stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO, SimPO&lt;/a> — each alignment policy can live as a separate adapter; multi-LoRA makes it possible to serve them in parallel and run A/B tests.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — quantising the base frees memory for many more adapters; the canonical stack is an FP8/INT4 base + BF16 adapters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode&lt;/a> — once the pools are separated, adapters are managed per pod; the 2026 strategy is to replicate the hot ones everywhere and evict the cold ones.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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> — LoRA over an MoE base is an active area; the MoE routing complicates applying the deltas correctly.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the critical operational tension of multi-LoRA: when every request in the batch uses a different adapter (and rank), throughput can fall by up to 50 %; the scheduler must group requests by rank and adapter to keep the economics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> — an adapter can be trained specifically for function calling or extraction; combined with XGrammar it gives the strong contract (an adapter suited to the task + a grammar that guarantees the schema).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&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 training side: fine-tuning over an NF4 quantised base on a single 4090, aggressive ranks (r=4-8) and the &amp;ldquo;one SLM base + one adapter per customer&amp;rdquo; case for sovereign deployment.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hu, E. et al. &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>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>Dettmers, T. et al. &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>Brüel-Gabrielsson, R. et al. &lt;em>Compress then Serve: Serving Thousands of LoRA Adapters with Little Overhead&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2407.00066">https://arxiv.org/abs/2407.00066&lt;/a>&lt;/li>
&lt;li>&lt;em>Serving Heterogeneous LoRA Adapters in Distributed LLM Inference&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2511.22880">https://arxiv.org/abs/2511.22880&lt;/a>&lt;/li>
&lt;li>&lt;em>InfiniLoRA: Disaggregated Multi-LoRA Serving&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2604.07173">https://arxiv.org/abs/2604.07173&lt;/a>&lt;/li>
&lt;li>&lt;em>Predictive-LoRA: Proactive Fragmentation-Aware Serverless LoRA Serving&lt;/em>. &lt;a href="https://arxiv.org/abs/2512.20210">https://arxiv.org/abs/2512.20210&lt;/a>&lt;/li>
&lt;li>S-LoRA repo: &lt;a href="https://github.com/S-LoRA/S-LoRA">https://github.com/S-LoRA/S-LoRA&lt;/a>&lt;/li>
&lt;li>Punica repo: &lt;a href="https://github.com/punica-ai/punica">https://github.com/punica-ai/punica&lt;/a>&lt;/li>
&lt;li>LoRAX repo (Predibase): &lt;a href="https://github.com/predibase/lorax">https://github.com/predibase/lorax&lt;/a>&lt;/li>
&lt;li>vLLM LoRA docs: &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>SGLang LoRA docs: &lt;a href="https://docs.sglang.io/advanced_features/lora.html">https://docs.sglang.io/advanced_features/lora.html&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM LoRA docs: &lt;a href="https://nvidia.github.io/TensorRT-LLM/advanced/lora.html">https://nvidia.github.io/TensorRT-LLM/advanced/lora.html&lt;/a>&lt;/li>
&lt;li>NVIDIA NIM PEFT: &lt;a href="https://docs.nvidia.com/nim/large-language-models/latest/peft.html">https://docs.nvidia.com/nim/large-language-models/latest/peft.html&lt;/a>&lt;/li>
&lt;li>HF TGI Multi-LoRA blog: &lt;a href="https://huggingface.co/blog/multi-lora-serving">https://huggingface.co/blog/multi-lora-serving&lt;/a>&lt;/li>
&lt;li>LMSYS S-LoRA recipe blog: &lt;a href="https://www.lmsys.org/blog/2023-11-15-slora/">https://www.lmsys.org/blog/2023-11-15-slora/&lt;/a>&lt;/li>
&lt;li>Predibase LoRAX blog: &lt;a href="https://predibase.com/blog/lorax-the-open-source-framework-for-serving-100s-of-fine-tuned-llms-in">https://predibase.com/blog/lorax-the-open-source-framework-for-serving-100s-of-fine-tuned-llms-in&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>