vLLM Explained

Serving LoRA adapters: from the model name to the wrapped layer

AdvancedvLLM conceptsabout 10 min read + 4 min deep dives

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

In one sentence

A request names an adapter, and the scheduler limits adapters per step. The worker puts each adapter into a GPU slot, and wrapped layers add its update.

The idea#

LoRA adapters explains what a LoRA adapter is, why vLLM keeps adapters unmerged, and the flags with their real defaults (--enable-lora off, --max-loras 1, --max-lora-rank 16). This page follows one adapter through the processes of vllm serve.

Four parts work together. The API server turns the model field into a LoRARequest. The scheduler admits a request only if the step does not exceed max_loras adapters. The worker keeps adapters in two LRU caches: one in CPU memory, and a fixed number of GPU slots. At model load, vLLM replaces each supported layer with a LoRA wrapper. The wrapper runs the base layer once for the batch and then calls the Punica wrapper kernels, which add each token's own adapter update.

An everyday comparison. Think of a print shop with one press and a rack of three stencil holders. The front desk writes the customer's stencil name on each order. The planner never puts more than three stencils into one run. The stock room keeps stencils on shelves and moves one into a holder when a run needs it. The comparison stops being true in one place: the shop moves a stencil into a holder only when a run needs it. vLLM also activates an adapter at once when you load it through the API.

The picture#

Figure: the per-request adapter choice becomes a per-token slot index (panels 1-2). One base GEMM serves all tokens; shrink and expand pick A and B per token (panel 3).
Open full size ↗

Read the panels from top to bottom. Panel 1 runs in three processes: the API server, the engine core and the worker. Panels 2 and 3 run in the worker in every step. The slot layout [1, 3, 2] is one possible order, for example after an LRU eviction; it depends on when each adapter was activated. The worked example in the deep dive gets [1, 2, 3] from its start-up order.

In the code#

Step 1: the request gets a LoRARequest#

The model field of a request selects the base model or an adapter; _maybe_get_adapters looks the name up (vllm/entrypoints/serve/engine/serving.py:194-200). The result is a small msgspec struct:

class LoRARequest(
    msgspec.Struct,
    omit_defaults=True,  # type: ignore[call-arg]
    array_like=True,
):  # type: ignore[call-arg]
    """Request for a LoRA adapter.

    lora_int_id must be globally unique for a given adapter.
    This is currently not enforced in vLLM.

    load_inplace: If True, forces reloading the adapter even if one
        with the same lora_int_id already exists in the cache. This replaces
        the existing adapter in-place. If False (default), only loads if the
        adapter is not already loaded.
    """

    lora_name: str
    lora_int_id: int
    lora_path: str = ""
    base_model_name: str | None = msgspec.field(default=None)
    load_inplace: bool = False
    is_3d_lora_weight: bool = False

lora_int_id must be above 0 (vllm/lora/request.py:37-39). Two LoRA requests are equal when their names are equal; the hash also uses only the name (vllm/lora/request.py:56-69). An unknown name gives HTTP 404. Only with VLLM_ALLOW_RUNTIME_LORA_UPDATING does the server first ask the LoRA resolvers (vllm/entrypoints/serve/engine/serving.py:62-66; Plugins in the API server, Step 4).

Step 2: the scheduler limits adapters per step#

At the start of the waiting pass, the scheduler collects the adapter ids of all running requests. This includes requests that are not scheduled in this step (vllm/v1/core/sched/scheduler.py:945-954). A waiting request with a new adapter is skipped when that set already has max_loras entries:

                # Check that adding the request still respects the max_loras constraint.
                if (
                    self.lora_config
                    and request.lora_request
                    and (
                        len(scheduled_loras) == self.lora_config.max_loras
                        and request.lora_request.lora_int_id not in scheduled_loras
                    )
                ):
                    # Scheduling would exceed max_loras, skip.
                    skip_request(request_queue)

The skipped request goes back to the front of the queue and tries again in the next step. The gauge vllm:lora_requests_info shows max_lora and the waiting and running adapters as labels (vllm/v1/metrics/loggers.py:1099-1112).

Step 3: the worker loads and activates adapters#

The LoRA manager exists only with --enable-lora. load_lora_model checks that the model supports LoRA, builds an LRUCacheWorkerLoRAManager, and wraps the model (vllm/v1/worker/lora_model_runner_mixin.py:43-66). In each step with LoRA requests, the model runner passes the active LoRARequests to the manager (vllm/v1/worker/lora_model_runner_mixin.py:68-87):

    def _apply_adapters(self, lora_requests: set[LoRARequest]) -> None:
        loras_map = {
            lora_request.lora_int_id: lora_request
            for lora_request in lora_requests
            if lora_request
        }
        if len(loras_map) > self._adapter_manager.lora_slots:
            raise RuntimeError(
                f"Number of requested LoRAs ({len(loras_map)}) is greater "
                "than the number of GPU LoRA slots "
                f"({self._adapter_manager.lora_slots})."
            )
        for lora in loras_map.values():
            self.add_adapter(lora)

    def add_adapter(self, lora_request: LoRARequest) -> bool:
        # Note that this method is not thread-safe. It may be invoked multiple
        # times for the same adapter when using multiple API servers.
        # This is ok because it's currently only called from
        # the single-threaded core engine loop.

        with gpu_sync_allowed():
            if (
                lora_request.lora_int_id not in self.list_adapters()
                or lora_request.load_inplace
            ):
                # Load the new adapter first to ensure it is actually valid, before
                # evicting any existing adapters.
                # This may cause the # of loaded lora adapters to very temporarily
                # exceed `--max-cpu-loras`.
                lora = self._load_adapter(lora_request)

                # Remove the existing adapter if it exists
                # Use case for LoRA inplace
                self._adapter_manager.remove_adapter(lora.id)

                # Loading succeeded, now check if we will exceed cache capacity and
                # evict if the oldest adapter if so
                if len(self._adapter_manager) + 1 > self._adapter_manager.capacity:
                    assert isinstance(self._adapter_manager, LRUCacheLoRAModelManager)
                    self._adapter_manager.remove_oldest_adapter()
                # Then add the new adapter to the cache
                loaded = self._adapter_manager.add_adapter(lora)
            else:
                # If the lora is already loaded, just touch it to
                # update its position in the caches
                loaded = (
                    self._adapter_manager.get_adapter(lora_request.lora_int_id)
                    is not None
                )
            self._adapter_manager.activate_adapter(lora_request.lora_int_id)
        return loaded
  1. Lines 279-284: more adapters than GPU slots in one batch raises RuntimeError. The scheduler check of Step 2 prevents this.
  2. Lines 295-303: an adapter that is not in the CPU cache is loaded from disk first.
  3. Lines 309-313: when the CPU cache is full (max_cpu_loras), the oldest adapter is removed.
  4. Line 323: activate_adapter puts the adapter into a GPU slot.

Loading. _load_adapter resolves the path. An absolute path is used as is. A path that does not exist locally is a Hugging Face Hub repository, and vLLM downloads it. With VLLM_USE_MODELSCOPE, it is a ModelScope repository (vllm/lora/utils.py:358-385). Then it checks the rank against max_lora_rank and loads the weights to CPU memory in the LoRA dtype (vllm/lora/worker_manager.py:119-150). When the adapter enters the cache, alpha / r is merged into B, so the kernels use scale 1.0 (vllm/lora/model_manager.py:907-908, vllm/lora/lora_weights.py:37-42).

Activation. activate_adapter takes the first free slot and copies each module's A and B into the stacked buffers at that index (vllm/lora/model_manager.py:355-412). The LRU manager first deactivates the oldest active adapter when all slots are used (vllm/lora/model_manager.py:1440-1452). A module without weights in the adapter gets its slot zeroed (reset_lora).

Step 4: the wrapped layers#

At model load, the LoRA model manager walks over all modules. Without --lora-target-modules, it wraps every supported module (vllm/lora/model_manager.py:476-500). from_layer picks the first LoRA class whose can_replace_layer accepts the layer, and allocates its buffers for max_loras slots (vllm/lora/utils.py:113-131). A BaseLayerWithLoRA keeps the original layer as base_layer and forwards unknown attributes to it (vllm/lora/layers/base.py:17-34). The linear wrappers run the base layer, then add the LoRA output:

    def _apply_sync(
        self, x: torch.Tensor, bias: torch.Tensor | None = None
    ) -> torch.Tensor:
        output = self._get_quant_method().apply(self.base_layer, x, bias)
        return self._apply_lora_to_output(x, output)

    def _apply_base_forward(self, x: torch.Tensor) -> torch.Tensor:
        base_output = self.base_layer(x)
        output = base_output[0] if isinstance(base_output, tuple) else base_output
        return self._apply_lora_to_output(x, output)

    def _apply_lora_to_output(
        self, x: torch.Tensor, output: torch.Tensor
    ) -> torch.Tensor:
        original_shape = output.shape if output.ndim == 3 else None

        # In transformers backend, x and output have extra batch dimension like
        # (1, seq_len, hidden_dim), while punica expects (seq_len, hidden_dim),
        # therefore we need to flatten the batch dimensions.
        if x.ndim == 3 and output.ndim == 3:
            output = output.flatten(0, 1)
            x = x.flatten(0, 1)

        lora_output: torch.Tensor | None = self.punica_wrapper.add_lora_linear(
            output, x, self.lora_a_stacked, self.lora_b_stacked, 1.0, self.output_slices
        )

The Punica wrapper wrapper class comes from the platform: PunicaWrapperGPU on CUDA (vllm/platforms/cuda.py:577-578, vllm/lora/punica_wrapper/punica_selector.py:13-21). add_lora_linear first runs a "shrink" (x @ A, into an FP32 buffer of rank width) and then an "expand" (buffer @ B, added to the output) (vllm/lora/punica_wrapper/punica_gpu.py:218-262). The kernels themselves are Triton code and out of scope.

Step 5: token_lora_mapping in Model Runner V2#

LoraState keeps one LoRA id per request slot; 0 means no adapter (vllm/v1/worker/gpu/lora_utils.py:75-89). In execute_model, it builds the per-token map and activates the adapters:

    def make_lora_inputs(
        self,
        req_ids: list[str],
        idx_mapping: np.ndarray,
        num_scheduled_tokens: np.ndarray,
    ) -> tuple[tuple[int, ...], tuple[int, ...], set[LoRARequest]]:
        lora_ids = self.lora_ids[idx_mapping]
        prompt_lora_mapping = tuple(lora_ids)
        token_lora_mapping = tuple(lora_ids.repeat(num_scheduled_tokens))
        active_lora_requests: set[LoRARequest] = self.get_activate_loras(req_ids)
        return prompt_lora_mapping, token_lora_mapping, active_lora_requests
            if self.lora_config:
                # Activate LoRA adapters.
                lora_inputs = self.lora_state.make_lora_inputs(
                    input_batch.req_ids,
                    input_batch.idx_mapping_np,
                    input_batch.num_scheduled_tokens,
                )
                self._set_active_loras(*lora_inputs)

Punica turns LoRA ids into slot indices; a token without an adapter gets -1 (vllm/lora/punica_wrapper/utils.py:87-111). set_adapter_mapping rebuilds the metadata only when the mapping or the slot layout changed since the last step (vllm/lora/model_manager.py:1401-1409).

Step 6: CUDA graphs with LoRA#

A CUDA graph fixes the number of active adapters it was captured with. So Model Runner V2 captures every capture size once per "LoRA case" (vllm/v1/worker/gpu/cudagraph_utils.py:305-307), and a graph matches a batch only with the same case (vllm/v1/worker/gpu/cudagraph_utils.py:136):

def get_lora_capture_cases(
    lora_config: "LoRAConfig | None",
    compilation_config: "CompilationConfig",
) -> list[int]:
    """Return num_active_loras values for cudagraph capture.

    When cudagraph_specialize_lora=True: powers of 2 up to max_loras, plus
    max_loras+1. When False: [0, max_loras+1]. When LoRA disabled: [0].
    """
    if lora_config is None:
        return [0]
    if compilation_config.cudagraph_specialize_lora:
        specialize = getattr(lora_config, "specialize_active_lora", False)
        captured = get_captured_lora_counts(lora_config.max_loras, specialize)
        return [0] + [c for c in captured if c > 0]
    return [0, lora_config.max_loras + 1]

The number of active adapters for the dispatch is the set of LoRARequests in the batch (vllm/v1/worker/gpu/lora_utils.py:38-49).

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

Worked example: three adapters in one step

Server (example): vllm serve meta-llama/Llama-3.1-8B-Instruct --enable-lora --max-loras 3 --lora-modules sql=/adapters/sql chat-de=/adapters/de legal=/adapters/legal. The front end gives the adapters the ids 1, 2 and 3 when it registers them (lora_id_counter, vllm/entrypoints/openai/models/serving.py:167-184).

  1. Requests. R1 model: "sql" with a 3-token prefill chunk, R2 "chat-de" in decode, R3 the base model in decode, R4 "legal" with 2 tokens. Each request carries its LoRARequest to the engine core.
  2. Scheduler. The running set is {1, 2, 3}, equal to max_loras. A new waiting request for a fourth adapter is skipped. A new request for sql is admitted, because id 1 is already in the set.
  3. Worker, at start-up. The front end loaded the three adapters in the order of --lora-modules, and each add_adapter activated its adapter at once (Step 3, line 323). So slot 0 holds sql, slot 1 chat-de, slot 2 legal: lora_index_to_id = [1, 2, 3].
  4. Worker, in this step. _apply_adapters gets {1, 2, 3}. All are in the CPU cache and active, so each add_adapter only touches the LRU, and activate_adapter changes nothing (vllm/lora/model_manager.py:360-361).
  5. Mapping. Per-request ids [1, 2, 0, 3], tokens per request [3, 1, 1, 2] → token_lora_mapping = (1, 1, 1, 2, 0, 3, 3). Punica slot indices: (0, 0, 0, 1, -1, 2, 2).
  6. One q_proj. Base GEMM [7, 4096] × [4096, 4096]. Shrink buffer FP32 [1, 7, 16] (one slice for q_proj). Expand adds [7, 4096]. Token 4 (R3) has no adapter.
  7. CUDA graph. 3 adapters are active. With the default cases [0, 4], the batch uses the LoRA graphs (case 4). A batch with only base-model requests uses case 0.
Deep dive

Memory per slot

Each wrapped linear layer allocates lora_a_stacked [max_loras, 1, max_lora_rank, input_size] and lora_b_stacked [max_loras, 1, output_size, max_lora_rank] per slice (vllm/lora/layers/base_linear.py:133-154). The size follows the configured maximum rank, not the rank of the loaded adapters.

LoRA adapters computes the numbers for Llama-3.1-8B at rank 16 and TP = 1. The 7 linear layers of all 32 blocks take 83.9 MB, and embed_tokens and lm_head add 8.5 MB. So one slot holds 92.4 MB. With --max-loras 3 that is 277 MB; with --max-loras 4 --max-lora-rank 64 it is 1.48 GB. This memory is reserved before the KV cache is sized. The CPU cache holds the loaded adapters at their own rank, in the LoRA dtype.

Deep dive

Runtime load and unload: what really happens

The routes /v1/load_lora_adapter and /v1/unload_lora_adapter exist only with VLLM_ALLOW_RUNTIME_LORA_UPDATING, and vLLM logs that this "should ONLY be used for local development" (vllm/entrypoints/serve/lora/api_router.py:30-36).

  1. Load. The API server builds a LoRARequest and calls engine_client.add_lora() (vllm/entrypoints/openai/models/serving.py:179-197). The call goes AsyncLLM.add_lora → EngineCore.add_lora → collective_rpc("add_lora") on every worker → add_adapter (vllm/v1/engine/async_llm.py:1231-1233, vllm/v1/engine/core.py:1076-1077, vllm/v1/executor/abstract.py:363-365, vllm/v1/worker/lora_model_runner_mixin.py:296-298). add_adapter also activates the adapter into a GPU slot (line 323 in Step 3), which can evict another one.
  2. Unload. The API server only deletes the name from its dict (vllm/entrypoints/openai/models/serving.py:234-247). No code in vllm/entrypoints/ calls the engine's remove_lora (vllm/v1/engine/core.py:1079-1080). The worker keeps the adapter in its CPU and GPU caches until LRU eviction needs the room.
  3. Reload in place from the same path keeps the old prefix-cache blocks valid, and vLLM warns about it (vllm/entrypoints/openai/models/serving.py:215-224).
  4. One API server. With api_server_count above 1, start-up fails (vllm/entrypoints/cli/serve.py:315-318).
Deep dive

LoRA and the prefix cache

An adapter changes the KV values, so the block hash of a LoRA request includes ("lora", lora_name, lora_path) (vllm/v1/core/kv_cache_utils.py:590-607). The same prompt with two adapters never shares blocks. See Prefix caching: the idea.

LoRA also touches the compile hash: max_num_batched_tokens is part of the scheduler hash, and the LoRA buffers are one of the two reasons (vllm/config/scheduler.py:271-280). With speculative decoding, LoRA requires max_num_batched_tokens ≥ max_num_seqs × (k + 1) (vllm/engine/arg_utils.py:2700-2713).

What can go wrong#

  • Requests wait with many adapters. More distinct adapters than --max-loras among running requests make new requests wait (Step 2).
  • Latency spikes on new adapters. Loading stops the engine loop while it runs (Step 3). Preload the adapters.
  • "LoRA rank ... is greater than max_lora_rank". Raise --max-lora-rank to the largest rank you serve (LoRA adapters).
  • "... does not support LoRA yet." The model class does not implement SupportsLoRA (vllm/v1/worker/lora_model_runner_mixin.py:49-50).
  • Unloaded adapters still use memory. Unload only removes the name in the API server (deep dive).
  • Longer start-up. LoRA doubles the CUDA graph captures by default, and --specialize-active-lora adds more (Step 6).

Why it matters. Multi-LoRA serving puts many fine-tuned variants on one GPU. Its limits come from three fixed sizes: max_loras per step, max_cpu_loras in memory, and slot buffers sized for max_lora_rank.

Check yourself#

  1. 1
    Which request field selects the adapter, and what does the API server make from it?
    easy
    Show answer

    The model field. The server returns the stored LoRARequest for that name (vllm/entrypoints/serve/engine/serving.py:194-200).

  2. 2
    Where are the adapter weights after _load_adapter, and when do they reach the GPU?
    medium
    Show answer

    In CPU memory: the loader uses device="cpu" (vllm/lora/worker_manager.py:145). activate_adapter copies them into a GPU slot of the stacked buffers (vllm/lora/model_manager.py:355-412).

  3. 3
    With default settings and --max-loras 4, which LoRA cases does Model Runner V2 capture CUDA graphs for?
    medium
    Show answer

    [0, 5]: cudagraph_specialize_lora is on and specialize_active_lora is off, so the cases are 0 and max_loras + 1 (vllm/v1/worker/gpu/lora_utils.py:29-35, vllm/lora/utils.py:64-65).

  4. 4
    A batch has 7 tokens: 3 for adapter id 1, 1 for id 2, 1 base, 2 for id 3. The slots hold ids [1, 3, 2]. What are token_lora_mapping and the Punica slot indices?
    hard
    Show answer

    (1, 1, 1, 2, 0, 3, 3) from lora_ids.repeat(num_scheduled_tokens) (vllm/v1/worker/gpu/lora_utils.py:100-102). Slot indices (0, 0, 0, 2, -1, 1, 1): id → index in lora_index_to_id, and 0 → -1 (vllm/lora/punica_wrapper/utils.py:94-111).

  5. 5
    You call /v1/unload_lora_adapter for "legal". Is GPU memory freed, and can a new request still use "legal"?
    hard
    Show answer

    No memory is freed: slot buffers are fixed at start-up, and the worker keeps the adapter until LRU eviction (vllm/lora/worker_manager.py:309-313). New requests get 404, because the API server deleted the name (vllm/entrypoints/openai/models/serving.py:244-246). Requests that already carry the LoRARequest continue (derived).