Guide · AI and GenAI
Multi-LoRA Serving Explained: Batching Many Fine-Tunes on One Base Model
23 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Multi-LoRA serving runs one shared base model and picks a small LoRA adapter for each request. A LoRA adapter is a set of thin matrix pairs, one pair per adapted layer, that changes a model's answers without copying its weights.
- The memory maths favours adapters. In our made-up model, the base weights take 14.53 GiB (gibibytes, 2 to the power 30 bytes). One rank-16 adapter takes 80 MiB (mebibytes, 2 to the power 20 bytes). Rank is the thin side of each matrix.
- One batch, the group of requests a GPU works on together, can mix customers. The server multiplies every token by the shared weights once, then adds each token's own adapter, found by an adapter id.
- In vLLM 0.32.0, an open-source serving engine, LoRA is off until you pass --enable-lora. As of 28 October 2026, the defaults allow 1 adapter per batch and ranks up to 16.
- Beginners can stop after the batching section. Engineers then get vLLM's loading path, which pushes out the least recently used adapter. They also get what max-loras does in the scheduler, the part that picks each step's requests, why a high max rank wastes memory, and production checks.
What goes wrong when every customer gets their own fine-tuned model?
Each full fine-tune is a complete copy of the model's weights, so serving many of them needs many GPUs that mostly sit idle. Most customers send only a few requests a minute, but each copy needs its own memory all day. Multi-LoRA serving avoids this by sharing one base model.
Picture a common setup. An edtech company in Bengaluru runs a doubt-solving assistant for 100 coaching institutes, and each institute wants answers in its own syllabus terms and tone. The setup is made up, but every number comes from our own scripts or a cited source.
The assistant uses a made-up model of about 7.8 billion parameters, the learned numbers inside a model. Stored as 16-bit numbers, the weights take 14.53 GiB. One 80 GB data-centre GPU can hold a few such copies, never 100.
A hundred full fine-tunes would need 1,453 GiB of weights. That is about 20 such GPUs just to hold them, before any memory for running requests. This guide shows how all 100 institutes are served from one GPU instead.
Which words do you need before reading about multi-LoRA serving?
You need seven words. Each one is tied to the coaching assistant.
- Base model: the shared, frozen model that every institute uses. Frozen means its weights stay fixed while adapters are trained.
- Weight matrix: a grid of numbers inside one layer. A layer turns an input vector, a list of numbers for one token, into an output vector by multiplying it by this grid.
- Fine-tune: extra training on one customer's data, so the model answers in their style.
- LoRA adapter: a fine-tune stored as two thin matrices, A and B, per adapted layer. LoRA stands for low-rank adaptation.
- Rank: the thin side of A and B, often 8, 16 or 64. A higher rank can learn more but takes more memory.
- Batch: the set of requests the GPU processes in one step. Bigger batches mean more answers for the same GPU time.
- Tenant: one customer sharing the server, here one coaching institute with its own adapter.
What is multi-LoRA serving?
Multi-LoRA serving keeps one copy of the base model on the GPU and applies a different small adapter to each request, even inside the same batch. Each request names its adapter, and the server adds that adapter's effect at every adapted layer.
The idea rests on a 2021 paper on low-rank adaptation, arXiv 2106.09685. It freezes the base weights and trains two small matrices per layer. B is d by r and A is r by k. Here d and k are the layer's output and input sizes, and r is much smaller than both.
For an input x, the layer computes W x + B(A x). W is the frozen weight, and B(A x) is the adapter's side path. The paper scales that side path by a constant alpha divided by r. Only A and B differ between institutes.
An everyday picture helps. Every institute uses the same printed textbook, and each keeps its own thin sheet of margin notes. A student reads the shared page and then the institute's note beside it.
The textbook is the base model, each sheet of notes is an adapter, and the student is one request. The library keeps one textbook and 100 thin sheets, not 100 rewritten textbooks.
Where the picture breaks: margin notes are only read, but an adapter is computed. Its small matrices multiply the token's numbers at every adapted layer, so a larger rank costs real work, not just paper.
Why keep the adapter separate instead of merging it into the weights?
Because a merged adapter becomes a full copy of the weights, and then requests for different tenants cannot share one weight matrix. Keeping it separate costs a little compute but lets every tenant share the base.
The maths makes both choices give the same answer. W x + B(A x) equals (W + BA) x, because matrix multiplication is associative and distributes over addition. The script below checks it on random numbers with NumPy 2.5.3.
import numpy as np
rng = np.random.default_rng(0)
d_in, d_out, r = 512, 512, 8 # layer size and adapter rank
alpha = 16 # scaling numerator, as in the paper
W = rng.standard_normal((d_out, d_in)) # frozen base weight
A = rng.standard_normal((r, d_in)) * 0.01 # adapter "down" matrix
B = rng.standard_normal((d_out, r)) * 0.01 # adapter "up" matrix
x = rng.standard_normal(d_in) # one token's input vector
s = alpha / r
unmerged = W @ x + s * (B @ (A @ x)) # base path plus small side path
merged = (W + s * (B @ A)) @ x # one big matrix, adapter baked in
print("max difference:", np.abs(unmerged - merged).max())
print("same result:", np.allclose(unmerged, merged))
print("adapter numbers:", A.size + B.size, "vs base numbers:", W.size)It prints a maximum difference of about 4.3e-14, which is rounding error, then True. The adapter holds 8,192 numbers against the base layer's 262,144, about 3% here because this test layer is tiny.
The LoRA paper says merging, W = W0 + BA, adds no inference latency, and switching tasks means subtracting one BA and adding another. That suits one tenant at a time, not a mixed batch.
The same paper names the catch. If you merge A and B into W, it is not straightforward to batch inputs for different tasks in one forward pass. Multi-tenant servers such as vLLM therefore keep the side path unmerged.
How much memory does each LoRA adapter need?
Each adapted layer stores rank times (input size plus output size) numbers, since A is rank by input and B is output by rank. Multiply by the number of layers and by bytes per number.
Our made-up model has 32 blocks, each with seven linear layers: four in attention and three in the feed-forward part. Token vectors hold 4096 numbers, two attention layers output 1024, and the feed-forward part widens to 14336.
# Made-up model shape: 32 layers, hidden size 4096, 16-bit numbers.
LAYERS, HIDDEN, KV_OUT, MLP, VOCAB, BYTES = 32, 4096, 1024, 14336, 100_000, 2
# (input size, output size) of the seven linear layers in one block
modules = {
"q": (HIDDEN, HIDDEN), "k": (HIDDEN, KV_OUT), "v": (HIDDEN, KV_OUT),
"o": (HIDDEN, HIDDEN), "gate": (HIDDEN, MLP), "up": (HIDDEN, MLP),
"down": (MLP, HIDDEN),
}
def base_bytes():
per_layer = sum(i * o for i, o in modules.values())
return (LAYERS * per_layer + 2 * VOCAB * HIDDEN) * BYTES
def adapter_bytes(rank):
# A is rank x input, B is output x rank, for every adapted layer
per_layer = sum(rank * (i + o) for i, o in modules.values())
return LAYERS * per_layer * BYTES
GiB, MiB = 2**30, 2**20
print(f"base weights: {base_bytes() / GiB:.2f} GiB")
for r in (8, 16, 64):
print(f"rank {r} adapter: {adapter_bytes(r) / MiB:.0f} MiB")
print(f"100 rank-16 adapters: {100 * adapter_bytes(16) / GiB:.2f} GiB")
print(f"100 full fine-tuned copies: {100 * base_bytes() / GiB:.0f} GiB")
# vLLM reserves GPU slots at max_lora_rank, whatever each adapter's rank
for slots, max_rank in ((4, 16), (4, 64), (16, 64)):
print(f"{slots} GPU slots at max rank {max_rank}: "
f"{slots * adapter_bytes(max_rank) / GiB:.2f} GiB")Running it prints 14.53 GiB for the base and 40, 80 and 320 MiB for rank 8, 16 and 64 adapters. MiB means 2 to the power 20 bytes. A hundred rank-16 adapters take 7.81 GiB, against 1,453 GiB for 100 full copies.
| Adapter rank | One adapter | Share of base weights | 100 adapters |
|---|---|---|---|
| 8 | 40 MiB | 0.27% | 3.91 GiB |
| 16 | 80 MiB | 0.54% | 7.81 GiB |
| 64 | 320 MiB | 2.15% | 31.25 GiB |
| Full fine-tune (for comparison) | 14.53 GiB | 100% | 1,453 GiB |
Memory grows in a straight line with rank, so doubling the rank doubles the adapter. For the coaching assistant, all 100 rank-16 adapters fit easily in the server's ordinary RAM, the CPU memory beside the GPU.
The GPU only needs the adapters that the current batch uses. The last lines of the script show that cost, which the vLLM section explains.
How does one batch serve requests for different adapters?
The server multiplies every token in the batch by the shared base weight in one big matrix multiplication. It then computes each token's side path with that token's own A and B, and adds the two.
The naive way is wrong for this workload. It runs one batch per adapter, so each institute's few requests form a tiny batch. Most steps then carry far fewer requests than the GPU could handle.
Mixing tenants in one batch keeps the big shared multiplication full. Only the thin side path differs per token, and each token finds its matrices through an adapter id, which the server turns into a GPU slot number.
Pause and predict: six tokens share a batch, from three institutes plus one plain base-model request. Will the batched answer differ from running each request alone with a merged weight? Guess before reading on.
import numpy as np
rng = np.random.default_rng(1)
d, max_rank, n_slots = 256, 16, 4 # layer size, slot rank, GPU slots
W = rng.standard_normal((d, d)) # one shared base weight
# Stacked slots, like vLLM's buffers: every slot is max_rank wide.
A_slots = np.zeros((n_slots, max_rank, d))
B_slots = np.zeros((n_slots, d, max_rank))
for slot, rank in enumerate((8, 16, 4, 16)): # adapters of mixed rank
A_slots[slot, :rank] = rng.standard_normal((rank, d)) * 0.05
B_slots[slot, :, :rank] = rng.standard_normal((d, rank)) * 0.05
# Six tokens from different tenants. Slot -1 means "base model only".
ids = np.array([2, 0, -1, 2, 3, 0])
X = rng.standard_normal((len(ids), d))
# 1) Reference: one request at a time with a merged weight.
ref = np.stack([
X[i] @ (W + (B_slots[s] @ A_slots[s] if s != -1 else 0)).T
for i, s in enumerate(ids)
])
# 2) Batched: one base matmul, then gather each token's adapter by id.
use = ids != -1
safe = np.where(use, ids, 0)
shrink = (A_slots[safe] @ X[:, :, None])[:, :, 0] # x A
expand = (B_slots[safe] @ shrink[:, :, None])[:, :, 0] # (x A) B
batched = X @ W.T + expand * use[:, None]
# 3) Segmented: sort tokens by adapter, one small matmul per segment.
seg = X @ W.T
order = np.argsort(ids, kind="stable")
for s in np.unique(ids[use]):
rows = order[ids[order] == s]
seg[rows] += (X[rows] @ A_slots[s].T) @ B_slots[s].T
print("batched matches per-request:", np.allclose(batched, ref))
print("segmented matches per-request:", np.allclose(seg, ref))
print("segments (adapter: tokens):",
{int(s): int((ids == s).sum()) for s in np.unique(ids)})It prints True twice and the segments -1: 1, 0: 2, 2: 2, 3: 1. Both batched methods match the one-at-a-time merged results, including the token with no adapter.
What just happened? The gather method picks each token's A and B by its id, then multiplies. The segmented method sorts tokens by adapter and runs one small multiplication per group, so each adapter's matrices are fetched once per group, not once per token.
Gathering is simple, but it reads a separate copy of an adapter for every token. Grouping the same adapter's tokens together saves that repeated reading, which matters on a GPU where memory traffic often limits speed.
One layer of a mixed-adapter batch
Drag to exploreNotice that the big base multiplication runs once for all six tokens. Only the thin shrink and expand steps use per-adapter matrices, read from fixed GPU slots.
Diagram connections:
- Batch of 6 tokens to Shared base multiply (all tokens)
- Batch of 6 tokens to Group tokens by adapter (adapter ids)
- Group tokens by adapter to Shrink: x times A (one segment per adapter)
- GPU adapter slots to Shrink: x times A (A for each segment)
- GPU adapter slots to Expand: times B (B for each segment)
- Shrink: x times A to Expand: times B (rank-sized vectors)
- Shared base multiply to Add side path to base (base output)
- Expand: times B to Add side path to base (adapter output)
- Add side path to base to Layer output (to the next layer)
- Batch of 6 tokens: Adapter ids 2, 0, none, 2, 3, 0
- Shared base multiply: X times W for all 6 tokens at once
- Group tokens by adapter: Stable sort by id; no-adapter tokens skip
- GPU adapter slots: 4 slots, each sized for the max rank
- Shrink: x times A: Layer width down to the rank
- Expand: times B: Rank back up to layer width
- Add side path to base: Each token gets base plus its adapter
- Layer output: Matches the per-request merged result
A 2023 paper on multi-tenant LoRA serving, arXiv 2310.18547, built a GPU kernel, a function that runs on the GPU, for this. It calls it segmented gather matrix-vector multiplication, SGMV, and groups inputs for the same adapter next to each other.
The paper names the two steps shrink, from full width down to the rank, and expand, from the rank back up. It keeps a single copy of the base model per GPU.
It reports 12 times the throughput, meaning answers per second, of the serving systems it compared against. It adds about 2 ms of waiting time per generated token. Those are 2023 results on its own test setup, not a promise for yours.
vLLM 0.32.0 follows the same pattern. We read in its 0.32.0 source that the main GPU kernel path sorts the batch's tokens by slot number before running its shrink and expand kernels. When no token uses an adapter, the LoRA kernels are skipped.
How does vLLM load and swap LoRA adapters?
vLLM keeps adapters in three places: on disk, in a CPU-memory cache and in a fixed number of GPU slots. A request's adapter moves up these tiers when needed, and the least recently used one is pushed out.
You start the server with --enable-lora and list adapters with --lora-modules, written as name=path. A client then picks an adapter by putting its name in the request's model field. The /v1/models endpoint lists each adapter with its base model as parent.
Here is the path we read in the 0.32.0 source. The worker, the process that runs the model on a GPU, loads a new adapter from disk into CPU memory first. It checks the adapter's settings before evicting anything from the CPU cache.
The CPU cache holds up to --max-cpu-loras adapters. The GPU holds up to --max-loras adapters in slots, and making an adapter active copies its weights into a free slot. Both levels evict the least recently used adapter when full.
How vLLM 0.32.0 gets an adapter ready
Drag to exploreNotice the two separate limits: max-loras decides how many adapters share a batch, and max-cpu-loras decides how many stay warm in RAM. A miss at both levels means a disk load.
Diagram connections:
- Request names an adapter to Room in this batch? (arrives)
- Room in this batch? to Wait for the next step (No: new adapter, batch full)
- Wait for the next step to Room in this batch? (next step)
- Room in this batch? to Already in a GPU slot? (Yes)
- Already in a GPU slot? to Run in the batch (Yes)
- Already in a GPU slot? to In the CPU cache? (No)
- In the CPU cache? to Copy into a GPU slot (Yes)
- In the CPU cache? to Load from disk (No)
- Load from disk to Copy into a GPU slot (now in CPU memory)
- Copy into a GPU slot to Run in the batch (slot ready)
- Request names an adapter: Model field holds the tenant's adapter
- Room in this batch?: Adapter already in, or under max-loras adapters
- Wait for the next step: Put back at the front of the queue
- Already in a GPU slot?: Slots hold max-loras adapters
- In the CPU cache?: Holds max-cpu-loras adapters
- Load from disk: Check rank, then evict oldest CPU entry
- Copy into a GPU slot: Evict the least recently used slot
- Run in the batch: Base multiply plus adapter side path
Adding adapters while the server runs
You can add adapters without a restart, but the docs mark it as a security risk. Set the environment variable VLLM_ALLOW_RUNTIME_LORA_UPDATING to True, then post the name and path to /v1/load_lora_adapter.
The docs say this should not be used in production unless the environment is isolated and fully trusted. Anyone who can call the endpoint can make the server load an adapter from any path they choose.
A matching /v1/unload_lora_adapter removes an adapter. Setting load_inplace to true replaces an adapter's weights under the same name. Resolver plugins, small add-ons that find an adapter by its name, can also fetch an unknown adapter on first use. They need the same environment variable.
The built-in resolvers read from a local folder or download from a public model hub, and the docs call remote downloads insecure for production. You can also write your own resolver.
Which vLLM settings limit multi-LoRA serving in version 0.32.0?
Four flags matter most: --enable-lora, --max-loras, --max-lora-rank and --max-cpu-loras. We read every default from the vLLM 0.32.0 source, mainly vllm/config/lora.py, and checked the LoRA docs page for the same version.
| Flag | Default in 0.32.0 | What it controls | When to change it |
|---|---|---|---|
| --enable-lora | Off | Whether the engine handles LoRA adapters at all. | Always, for multi-LoRA serving. |
| --max-loras | 1 | Most distinct adapters in one batch, which is also the number of GPU slots. | Raise it when many tenants are active at once. |
| --max-lora-rank | 16 | Largest adapter rank accepted. Allowed values are 1, 8, 16, 32, 64, 128, 256, 320 and 512. | Set it to the largest rank you actually serve. |
| --max-cpu-loras | Same as --max-loras | Adapters kept in CPU memory. It must be at least --max-loras. | Raise it to keep many tenants warm in RAM. |
| --lora-dtype | auto (base model's type) | Number format of adapter weights on the GPU. | Rarely. |
| --lora-target-modules | All supported layers | Which layer names get LoRA applied. | To save memory when adapters touch few layers. |
With the defaults, only one adapter can be in any batch. For the coaching assistant, that means requests from different institutes take turns, which defeats the point. A starting point might be --max-loras 8 and --max-cpu-loras 100. In our made-up model, 8 rank-16 slots take 0.625 GiB of GPU memory.
What max-loras does inside the scheduler
At each step, the 0.32.0 scheduler first collects the adapter ids of requests already running. It then admits waiting requests one by one.
A waiting request is skipped when the batch already has max-loras distinct adapters and its adapter is not one of them. Requests for adapters already in the batch, and plain base-model requests, can still join.
A skipped request goes back to the front of the waiting queue for the next step. This behaviour comes from vllm/v1/core/sched/scheduler.py and is undocumented as of 28 October 2026; pin your version.
Why max-lora-rank costs memory even for small adapters
vLLM sets aside the GPU slots as fixed blocks of zeros when the model loads, each sized for --max-lora-rank. A smaller adapter is copied into the top-left corner of its slot, and the rest stays zero.
# A rank-8 adapter stored zero-padded in a rank-64 slot gives the same output,
# but the slot costs rank-64 memory and the kernel does rank-64 work.
import numpy as np
rng = np.random.default_rng(2)
d, r, R = 128, 8, 64
A = rng.standard_normal((r, d)); B = rng.standard_normal((d, r)); x = rng.standard_normal(d)
A_pad = np.zeros((R, d)); A_pad[:r] = A
B_pad = np.zeros((d, R)); B_pad[:, :r] = B
print("padded equals unpadded:", np.allclose(B @ (A @ x), B_pad @ (A_pad @ x)))
print("numbers stored, rank 8 vs rank-64 slot:", A.size + B.size, A_pad.size + B_pad.size)It prints True, then 2048 and 16384. The padded adapter gives the same answer, but its slot stores eight times the numbers, and the kernels work at the slot's full width.
In our made-up model, 4 slots at rank 16 take 0.31 GiB, while 4 slots at rank 64 take 1.25 GiB. Sixteen slots at rank 64 take 5 GiB, counting only the seven linear layers per block.
The slots are created before vLLM measures the memory left for the KV cache, the saved keys and values of earlier tokens. So oversized slots quietly shrink the KV cache, and with it the batch size.
The vLLM docs give the same advice: set --max-lora-rank to the highest rank among your adapters, not far above it. An adapter whose rank exceeds the setting is rejected at load with an error naming both ranks.
How do you check that multi-LoRA serving is working?
Check three things: the adapter is listed, requests really use it, and requests are not stuck waiting for a slot. These steps use vLLM 0.32.0's own endpoints and metrics.
List the models
Call /v1/models on the server. Each adapter should appear by its name, with the base model shown as its parent.
Compare one prompt
Send the same prompt with the model field set to the base model and then to an adapter. Use temperature 0, which picks the most likely next token each time, so replies are mostly repeatable. Run each prompt twice. The replies should differ wherever the adapter learned something visible.
Read the LoRA gauge
The metric vllm:lora_requests_info, on the server's /metrics endpoint for the Prometheus monitoring system, has labels running_lora_adapters, waiting_lora_adapters and max_lora. In 0.32.0 the first two hold adapter names only, as comma-separated text. An adapter with any request in flight appears in both.
Watch for slot pressure
Watch vllm:num_requests_waiting_by_reason with reason deferred. As of 0.32.0 it counts requests held back by the adapter limit, KV transfer or a blocked status. If it stays above zero while few requests run, raise --max-loras.
Recheck the KV cache
After raising --max-loras, read the KV cache size in the startup log again. Each extra slot comes out of that memory, so the batch may shrink.
Time a cold adapter
Adapters listed with --lora-modules load into the CPU cache at startup. Time a request for an adapter evicted since then against a warm one; the gap is your load cost.
What changes for per-tenant fine-tunes in production?
Three things change: caches must stay separate per adapter, updated adapters need new paths, and adapter files need checking like any other input.
- Prefix caching, which reuses saved keys and values for a repeated prompt start, includes the adapter's name and path in its block hash, the fingerprint of each cached block. Two institutes with the same system prompt, the fixed instructions placed before every chat, but different adapters never share those blocks.
- That separation is correct, because an adapter changes the numbers from which keys and values are computed. It also means a shared system prompt is cached once per adapter.
- Reloading changed weights in place from the same path through /v1/load_lora_adapter logs a warning in 0.32.0: cached blocks from the old weights are not cleared. Load each new version from a new path.
- Check rank and format before accepting a tenant's adapter. vLLM 0.32.0 rejects adapters with bias terms, adapters using DoRA, a variant that splits weights into size and direction, and any rank above --max-lora-rank.
The block-hash and reload-warning details come from the 0.32.0 source and are undocumented as of 28 October 2026. The SevyDevy guide on PagedAttention explains how those KV blocks are stored and shared.
When multi-LoRA serving is the wrong choice
- One tenant sends almost all traffic. Merge its adapter into the weights and serve it alone, which removes the side path.
- Tenants use different base models. Adapters only share a server when they were trained on the same base.
- Adapters are huge. At high rank on every layer, adapters stop being small, and full fine-tunes or separate servers may be simpler.
What do research systems add beyond vLLM's defaults?
They push the same idea to thousands of adapters by managing adapter memory as carefully as the KV cache. Two 2023 papers show the main moves.
A paper on serving thousands of concurrent LoRA adapters, arXiv 2311.03285, stores every adapter in main memory and fetches only those used by running requests to the GPU. It prefetches adapters for waiting requests while the current batch runs.
It also puts adapter weights of different ranks and the KV cache into one paged memory pool, which reduces fragmentation, memory that is free but unusable. It reports serving 2,000 adapters on one GPU.
Against vLLM with naive LoRA support at the time, it reports up to 4 times the throughput. vLLM has changed a lot since 2023, so treat that comparison as history, not a current benchmark.
The multi-tenant paper from earlier, arXiv 2310.18547, adds a cluster scheduler. It sends a new request to the GPU already running the most requests, its working set, and moves requests to other GPUs when one runs out of KV cache space.
How do you explain multi-LoRA serving in a system design interview?
Start from the cost of full fine-tunes, show the memory maths, then explain mixed batches and the cache tiers. Interviewers want numbers and trade-offs, not paper names.
Clarify the load
Ask how many tenants, how much traffic each sends, the base model size and the adapter ranks. Here, 100 institutes, light traffic each, one 14.53 GiB base.
Do the memory maths
Full copies need 1,453 GiB. Rank-16 adapters need 80 MiB each, 7.81 GiB for all 100, plus one base.
Keep adapters unmerged
Explain W x + B(A x) equals (W + BA) x. Merged weights are a full matrix per tenant, so tenants could not share one multiplication.
Batch across tenants
One base multiplication for all tokens, then shrink and expand per adapter, grouped by adapter id so each adapter is read once.
Design the tiers
Object storage or disk, a CPU cache with least-recently-used eviction, then a few GPU slots. Map them to --max-cpu-loras and --max-loras.
Name the limits
Slots are sized for the max rank and come out of KV cache memory. Too few slots make tenants wait; too many shrink the batch.
Close with routing and metrics
Route each tenant to replicas that already hold its adapter, one option to cut cold loads. Watch deferred requests, cold-load latency and KV cache use.
Questions people ask
- What is multi-LoRA serving in simple words?
- It is running one shared base model and choosing a small fine-tuned adapter for each request. Every request in a batch shares the big base computation, and each one adds its own adapter's small correction. Many customers can then have their own fine-tune on one GPU instead of one GPU each.
- Does an unmerged LoRA adapter slow down inference?
- A little. The side path adds extra arithmetic: for rank 16 on a 4096 by 4096 layer, about 0.78% more multiply-adds. Real overhead also depends on kernels and how many adapters share the batch. The 2023 multi-tenant paper reported about 2 ms added latency per token on its setup, so measure yours.
- What is the default max-loras in vLLM?
- It is 1 in vLLM 0.32.0, as of 28 October 2026, set in vllm/config/lora.py. That allows only one distinct adapter per batch, so requests for other adapters wait. Raise it to the number of tenants you expect to be active at once, and check the KV cache size afterwards.
- Can adapters with different ranks share one batch?
- Yes. In vLLM every GPU slot is sized for --max-lora-rank, and a smaller adapter is padded with zeros inside its slot. The answers stay correct, but each slot costs memory at the maximum rank. An adapter with a rank above the setting is rejected when it loads.
- Can I add a new adapter without restarting vLLM?
- Yes, if you set VLLM_ALLOW_RUNTIME_LORA_UPDATING to True and post the adapter's name and path to /v1/load_lora_adapter. The vLLM docs warn this carries security risks and should only be used in an isolated, fully trusted environment. Resolver plugins can also load adapters by name on first use, with the same variable and the same caution.
- Should I merge my LoRA adapter into the base model instead?
- Merge it when one adapter receives almost all the traffic. A merged model runs at the base model's speed with no side path. Keep adapters separate when many tenants share a GPU, because merged weights are full copies that cannot share one batch.
- Do different adapters share the prefix cache?
- No. In vLLM 0.32.0 the prefix cache's block hash includes the adapter's name and path. Two tenants with the same system prompt but different adapters get separate cached blocks. That is correct, because the adapter changes the saved keys and values.
Sources and further reading
- Paper: LoRA, Low-Rank Adaptation of Large Language Models (arXiv 2106.09685)
- Paper: multi-tenant LoRA serving with a segmented gather (SGMV) kernel (arXiv 2310.18547)
- Paper: serving thousands of concurrent LoRA adapters with a unified paged memory pool (arXiv 2311.03285)
- vLLM 0.32.0 docs: LoRA Adapters (serving, runtime loading, max_lora_rank tips)
- vLLM source at v0.32.0: vllm/config/lora.py (LoRAConfig defaults)
- vLLM source at v0.32.0: vllm/engine/arg_utils.py (enable_lora default)
- vLLM source at v0.32.0: vllm/v1/core/sched/scheduler.py (max_loras admission check)
- vLLM source at v0.32.0: vllm/lora/worker_manager.py (load to CPU, adapter config check, LRU eviction)
- vLLM source at v0.32.0: vllm/lora/model_manager.py (CPU capacity and GPU slots)
- vLLM source at v0.32.0: vllm/lora/layers/base_linear.py (slots sized for max rank)
- vLLM source at v0.32.0: vllm/v1/core/kv_cache_utils.py (adapter in prefix-cache hash)
- vLLM docs source at v0.32.0: docs/design/metrics.md (vllm:lora_requests_info design; labels now hold names only)
- vLLM source at v0.32.0: vllm/v1/metrics/loggers.py (LoRA gauge labels, waiting requests by reason)
- vLLM docs source at v0.32.0: docs/usage/faq.md (outputs can vary across runs)
- PyPI: vllm 0.32.0 release (18 October 2026)