vLLM Explained

LoRA adapters

IntermediateLearn the basicsabout 8 min read + 5 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

A LoRA adapter adds two small matrices to some layers; vLLM serves many adapters on one base model, in one batch.

The idea#

What a LoRA adapter is#

Fine-tuning changes the weights of a model for one task. Full fine-tuning changes all weights, so each task gets a full copy of the model: 16 GB for an 8B model in BF16. LoRA (low-rank adaptation) instead keeps the base weight W frozen and learns a small update for it:

W' = W + (alpha / r) · B · A        W: [d_out, d_in]   B: [d_out, r]   A: [r, d_in]

The inner size r is the LoRA rank, often 8 to 64. Take a 4,096 × 4,096 projection and r = 16. The full matrix has 16.8 million values, but A and B together have only 131,072. A LoRA adapter is the set of these A and B matrices for some layers, together with adapter_config.json. That file gives r, lora_alpha and target_modules, the layers that get an update, for example q_proj, k_proj, v_proj, o_proj.

Merged or unmerged#

  • Merged. You compute W' once and save a new full model. It runs at the full speed of the base model, but each adapter is a full model copy, and one server serves one adapter.
  • Unmerged. The server keeps W and the small A and B apart. For each token it computes x W + (alpha / r) · (x A) B. The extra work is small, because r is small. One server can now keep one base model and many adapters.

vLLM serves adapters unmerged. This makes multi-LoRA serving serving possible: requests for different adapters run in the same batch.

Many adapters in one batch#

Batching reads the base weights once for the whole batch (Batching). A naive multi-adapter server would split the batch by adapter and lose this. The Punica approach keeps one batch:

  1. Run the base matrix multiply x W for all tokens of the batch together.
  2. Give each token the adapter id of its request.
  3. Run batched LoRA kernels. Each token multiplies with the A and B of its own adapter. The "shrink" gives r values, the "expand" goes back to d_out, and the result is added.

So a batch with ten requests for five adapters still reads W only once. The adapters must be in GPU memory, in a fixed number of slots.

Slots and limits#

  • max_loras: the number of GPU slots. It is the maximum number of different adapters in one batch.
  • max_lora_rank: the largest rank that a slot can hold. vLLM sizes every slot for this rank, so a larger value uses more GPU memory for all slots.
  • max_cpu_loras: the number of adapters to keep in CPU memory, ready to move into a free GPU slot.

Adapter memory: worked numbers#

Take a Llama-3.1-8B-shaped model: hidden size 4,096, 32 layers, intermediate size 14,336. It has 8 KV heads of size 128, so K and V have 1,024 outputs. An adapter with r = 16 on q_proj, k_proj, v_proj and o_proj has, per layer:

LayerA [r, d_in]B [d_out, r]Values
q_proj 4096 → 409616 × 4,0964,096 × 16131,072
k_proj 4096 → 102416 × 4,0961,024 × 1681,920
v_proj 4096 → 102416 × 4,0961,024 × 1681,920
o_proj 4096 → 409616 × 4,0964,096 × 16131,072
One layer425,984

For 32 layers: 425,984 × 32 = 13.6 million values = 27.3 MB in BF16. That is 0.17 % of the 16.1 GB base model. So a GPU can hold many adapters next to one base model. The adapter files load quickly, too.

Add the MLP layers too: gate, up and down give 3 × 16 × (4,096 + 14,336) = 884,736 values per layer. The adapter then has 41.9 million values, or 83.9 MB.

An everyday comparison#

Think of one large printing press and a set of thin stencils. The press (the base model) prints every page. Each customer brings a stencil (an adapter) that changes a few details. The press prints pages for several customers in one run, and each page gets the stencil of its customer. The rack next to the press has a fixed number of stencil holders (the GPU slots).

The comparison stops here: a stencil only covers a part of a page. A LoRA adapter changes the output of every layer that it targets, and these changes add up through the whole model.

The picture#

Figure: each scheduled token carries the LoRA id of its request (panel 1). One base GEMM serves all tokens; the LoRA shrink and expand pick A and B per token from the GPU slots (panel 2). Example values for one q_proj.
Open full size ↗

Read panel 1 from left to right: one cell per scheduled token, coloured by adapter. Panel 2 is the computation of one LoRA-capable linear layer for these 7 tokens. The base weight W appears once; only the small stacked A and B tensors depend on the adapter.

In vLLM#

This is a basics page, so this part gives only pointers. The page Serving LoRA adapters: from the model name to the wrapped layer explains the LoRA layers, the managers and the kernels.

  1. Turn it on. --enable-lora (default off) makes vLLM build a LoRAConfig; without it, there is no LoRA config (vllm/engine/arg_utils.py:688-693, vllm/engine/arg_utils.py:2680-2698). The defaults are max_loras 1 and max_lora_rank 16 (vllm/config/lora.py:33-36). max_cpu_loras unset means max_loras (vllm/config/lora.py:119-126).
  2. Name the adapters. --lora-modules name=path or a JSON object registers adapters at start-up (vllm/entrypoints/common/cli_args.py:50-68). The API server loads them before it serves (vllm/entrypoints/launchers/api_server/app_state.py:86, vllm/entrypoints/openai/models/serving.py:124-139).
  3. Select an adapter per request. The model field of a request names the base model or an adapter. _maybe_get_adapters() returns the LoRARequest of that name (vllm/entrypoints/serve/engine/serving.py:194-200).
vllm serve meta-llama/Llama-3.1-8B-Instruct --enable-lora \
  --max-loras 4 --max-lora-rank 16 \
  --lora-modules sql=/adapters/sql-lora chat-de=/adapters/german-lora
curl localhost:8000/v1/chat/completions -H "Content-Type: application/json" \
  -d '{"model": "sql", "messages": [{"role": "user", "content": "Count the users per country."}]}'
    def _maybe_get_adapters(
        self,
        request: AnyRequest | AnyPoolingRequest,
        supports_default_mm_loras: bool = False,
    ) -> LoRARequest | None:
        if request.model in self.models.lora_requests:
            return self.models.lora_requests[request.model]

        # Currently only support default modality specific loras
        # if we have exactly one lora matched on the request.
        if supports_default_mm_loras:
            default_mm_lora = self._get_active_default_mm_loras(request)
            if default_mm_lora is not None:
                return default_mm_lora

        if self._is_model_supported(request.model):
            return None
The model field selects the adapter. A known adapter name gives its LoRARequest; the base model name gives None (no adapter).

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

The LoRA flags and their real defaults

FlagDefaultMeaning and source
--enable-loraoffWithout it, lora_config is None (vllm/engine/arg_utils.py:688, vllm/engine/arg_utils.py:2697-2698).
--max-loras1GPU slots = adapters per batch (vllm/config/lora.py:35-36).
--max-lora-rank16Allowed values 1, 8, 16, 32, 64, 128, 256, 320, 512 (vllm/config/lora.py:26, vllm/config/lora.py:33).
--max-cpu-loras= max_lorasA smaller value raises ValueError (vllm/config/lora.py:119-126).
--lora-dtypeautoThe base model dtype (vllm/config/lora.py:45-46, vllm/config/lora.py:146-147).
--lora-target-modulesall supportedRestricts LoRA to some module suffixes. Unset means all supported modules, also the embedding and the LM head of models that declare them (vllm/config/lora.py:47-50).
--fully-sharded-lorasoffWith TP, shard the whole LoRA computation, not only half (vllm/config/lora.py:37-41).

The flags come from LoRAConfig through EngineArgs (vllm/engine/arg_utils.py:1594-1614). EngineArgs copies the config defaults, and only --enable-lora decides whether the config exists, so these are the real defaults.

Rank check. An adapter with a rank above max_lora_rank fails to load: "LoRA rank 32 is greater than max_lora_rank 16." (vllm/lora/peft_helper.py:188-202). An adapter with a bias also fails. The scaling is lora_alpha / r, or lora_alpha / sqrt(r) for rsLoRA (vllm/lora/peft_helper.py:117-120).

Deep dive

GPU slots, CPU cache and the scheduler limit

Buffers. Each LoRA-capable linear layer reserves stacked buffers for all slots. For each slice of the layer, lora_a_stacked has the shape [max_loras, 1, max_lora_rank, input_size], and lora_b_stacked has the shape [max_loras, 1, output_size, max_lora_rank] (vllm/lora/layers/base_linear.py:133-154). The dtype is the LoRA dtype; auto becomes the model dtype (vllm/config/lora.py:146-147). So the reserved memory grows with max_loras × max_lora_rank, not with the rank of the adapters that you load.

Worked example (derived, TP = 1). Without --lora-target-modules, vLLM wraps every supported module (vllm/lora/model_manager.py:492-498). For Llama, these are the 7 linear layers of each block, and also embed_tokens and lm_head (vllm/model_executor/models/llama.py:479-482, vllm/lora/utils.py:245-259). One slot at rank 16 then holds 83.9 MB for the linear layers in BF16. The embedding and the LM head add 2 × 16 × (128,256 + 4,096) values = 8.5 MB (vllm/lora/layers/vocab_parallel_embedding.py:31-49, vllm/lora/layers/logits_processor.py:93-112), so one slot holds 92.4 MB. --max-loras 4 reserves 4 × 92.4 = 370 MB. With --max-lora-rank 64, it reserves 4 × 369.4 MB = 1.48 GB, even if all adapters have rank 8.

That memory is not available for the KV cache.

Two caches. The LoRA model manager has capacity = max_cpu_loras and lora_slots = max_loras (vllm/lora/model_manager.py:342-349). The LRU manager keeps the registered adapters in CPU memory and moves active ones into GPU slots (vllm/lora/model_manager.py:1425-1452).

Scheduler. The scheduler counts the adapters of all running requests (vllm/v1/core/sched/scheduler.py:945-954). A waiting request with a new adapter is skipped when the batch already has max_loras adapters (vllm/v1/core/sched/scheduler.py:998-1008). It waits for a later step. So with max_loras 1, requests for a second adapter wait until no running request uses the first one.

The token map. In Model Runner V2, LoraState keeps the LoRA id of each request. make_lora_inputs() repeats it once per scheduled token: that gives token_lora_mapping (vllm/v1/worker/gpu/lora_utils.py:94-104). The batched kernels in vllm/lora/punica_wrapper/ use this map (vllm/lora/punica_wrapper/).

Deep dive

Loading adapters at runtime

VLLM_ALLOW_RUNTIME_LORA_UPDATING is off by default; 1 or true turns it on (vllm/envs.py:1197-1200). Then the API server adds two routes (vllm/entrypoints/serve/lora/api_router.py:60-96):

curl -X POST localhost:8000/v1/load_lora_adapter -H "Content-Type: application/json" \
  -d '{"lora_name": "legal", "lora_path": "/adapters/legal-lora"}'
curl -X POST localhost:8000/v1/unload_lora_adapter -H "Content-Type: application/json" \
  -d '{"lora_name": "legal"}'
  1. Load. load_lora_adapter() checks the name and path. It gives the adapter a new integer id (an in-place reload keeps the old id). It calls engine_client.add_lora() to load the adapter into the engine, and then stores the name (vllm/entrypoints/openai/models/serving.py:167-232). A name that is already loaded fails unless load_inplace is true (vllm/entrypoints/openai/models/serving.py:271-278).
  2. Unload. unload_lora_adapter() removes the name from the API server's dict (vllm/entrypoints/openai/models/serving.py:234-247). New requests can then no longer select the adapter. The engine has a remove_lora() method (vllm/v1/engine/core.py:1079-1080), but no code in vllm/entrypoints/ calls it. So the worker keeps the adapter in its LRU caches. The worker LoRA manager removes an adapter only when a new adapter needs room (vllm/lora/worker_manager.py:309-313, vllm/lora/model_manager.py:1440-1458).
  3. Resolvers. With the variable on, an unknown model name goes to the LoRA resolvers (vllm/entrypoints/serve/engine/serving.py:62-67). A resolver can find the adapter, for example in a local folder or on the Hugging Face Hub. Resolvers are plugins (Plugins in the API server).

One API server only. vllm serve refuses runtime LoRA updates with more than one API server, because each API server has its own adapter dict (vllm/entrypoints/cli/serve.py:315-318).

Deep dive

LoRA and the prefix cache

An adapter changes the KV values of every token. So the block hash of a LoRA request includes ("lora", lora_name, lora_path) (vllm/v1/core/kv_cache_utils.py:590-607). Requests for different adapters never share KV blocks, even with the same prompt (Prefix caching: the idea).

The key holds the name and the path, not the weights. If you reload changed weights in place from the same path, old KV blocks still match. The API server warns about this case (vllm/entrypoints/openai/models/serving.py:215-224).

What can go wrong#

  • "The model `x` does not exist" (HTTP 404). The model field names neither the base model nor a loaded adapter (vllm/entrypoints/serve/engine/serving.py:75-84). GET /v1/models lists the base model and every loaded adapter (vllm/entrypoints/openai/models/serving.py:149-165).
  • Rank too large. An adapter with r = 32 fails with the default --max-lora-rank 16 (vllm/lora/peft_helper.py:194-197). Set the flag to the largest rank that you serve, but not higher: every slot reserves memory for it.
  • Requests wait with many adapters. With the default --max-loras 1, a batch has only one adapter (vllm/v1/core/sched/scheduler.py:1001-1008). Requests for other adapters wait. Raise --max-loras to the number of adapters that run at the same time.
  • Less KV cache. The LoRA buffers take memory before the KV cache gets its share. Large max_loras × max_lora_rank values reduce the number of tokens in the KV cache. --lora-target-modules can remove the buffers of modules that your adapters do not use.
  • Runtime loading with several API servers. Start-up fails (vllm/entrypoints/cli/serve.py:315-318). Use --lora-modules or one API server.

Why it matters. Multi-LoRA serving lets one GPU serve many fine-tuned variants for the price of one base model. To get this, size max_loras and max_lora_rank for your real traffic.

Check yourself#

  1. 1
    How does a client choose an adapter in a chat request?
    easy
    Show answer

    It puts the adapter name in the model field. The server looks the name up in its loaded adapters (vllm/entrypoints/serve/engine/serving.py:199-200).

  2. 2
    An adapter has r = 16 on q_proj (4,096 → 4,096) of one layer. How many values do A and B hold?
    medium
    Show answer

    A is [16, 4,096] = 65,536 values and B is [4,096, 16] = 65,536 values: 131,072 together. The full weight has 16.8 million values.

  3. 3
    You start vLLM with --enable-lora --max-loras 4 and no --max-cpu-loras. How many adapters can vLLM keep in CPU memory?
    medium
    Show answer

    4. An unset max_cpu_loras becomes max_loras (vllm/config/lora.py:120-121).

  4. 4
    The server runs with --max-loras 2. Requests for adapters a and b are running. A request for adapter c waits in the queue. What does the scheduler do with it, and when can it run?
    hard
    Show answer

    It skips the request in this step, because the batch already has max_loras = 2 adapters and c is not one of them (vllm/v1/core/sched/scheduler.py:1001-1008). The set counts all running requests (vllm/v1/core/sched/scheduler.py:946-952). The request can run when no running request uses a or b any more.

  5. 5
    Why does --max-lora-rank 64 use more GPU memory than --max-lora-rank 16, even when all loaded adapters have rank 8?
    hard
    Show answer

    vLLM reserves the stacked LoRA buffers at start-up with the shape [max_loras, 1, max_lora_rank, input_size] (vllm/lora/layers/base_linear.py:133-143). The size depends on the configured maximum rank, not on the adapters that you load later.