vLLM 推理内核深度解析

第16章 LoRA 适配器热切换:一个引擎承载多个微调

作者 杨艺韬 · 10,920 字 · 发布于 · 更新于

不动整个模型,只教它一招新本事——这是所有参数高效微调方法的共同思路。传统微调像重新装修整座房子,LoRA 像换一幅壁画:墙面不动,屋子立刻变样。

本章要点

  • 回顾 LoRA 的数学原理,理解为什么权重更新在微调中天然是低秩的
  • 看清 vLLM LoRA 推理的核心架构:基座权重不变 + 多个低秩旁路叠加,额外成本由 rank、目标层和 active slot 数决定
  • 读懂 LoRAModel / LoRAModelManager / WorkerLoRAManager 三层抽象的职责划分
  • 掌握 punica wrapper、LoRAKernelMeta 索引张量和 token-to-LoRA 映射在 batched LoRA 中的作用(以及 v0.8.5 的 CUDA 路径为什么已经不再区分 BGMV / SGMV)
  • 理解 --max-loras 和 --max-cpu-loras 的真实语义:调度并发上限、GPU slot、CPU LRU 容量三者相关但不相同
  • 看懂 LoRA 对前缀缓存的影响:为什么 extra_key 隔离是必须的、如何设计业务路由来缓解
  • 掌握 QLoRA + PagedAttention + 多 LoRA 并发的三重组合部署方案
  • 理解 LoRA 的局限:频繁切换开销、前缀缓存命中率下降、并非所有模型都支持
  • 拿到三个实战场景的配置思路:RAG 租户切换、多领域 API 服务、开发者自定义 fine-tuning

16.1 LoRA 的数学原理:为什么低秩?

16.1.1 一个反直觉的事实

2021 年 LoRA 论文出来时,很多做微调的工程师第一反应是怀疑:"只训练两个低秩矩阵,真的能替代全量更新吗?"

不是魔法,是一个深刻的经验洞察——大模型在下游任务上需要调整的部分,本来就不是全空间。LoRA(Low-Rank Adaptation,Hu et al., ICLR 2022 / arXiv:2106.09685)的核心假设是:大模型在下游任务上的权重更新 ΔW\Delta W 处于低秩子空间中。

16.1.2 标准微调 vs LoRA 微调

标准微调:

Wnew=W+ΔWW_{\text{new}} = W + \Delta W

ΔW\Delta W 和 WW 同形状(比如 4096×4096)。训练时要反传梯度、存优化器状态、写 checkpoint——成本和训练原模型差不多。

LoRA:

Wnew=W+ΔW,ΔW=ABW_{\text{new}} = W + \Delta W, \quad \Delta W = A B

把 ΔW\Delta W 分解成两个低秩矩阵:AA 形状 [d,r][d, r](下投影 / shrink)、BB 形状 [r,d][r, d](上投影 / expand),其中 r≪dr \ll d(典型 r=8,16,32,64r = 8, 16, 32, 64)。原论文用列向量写成 h=W0x+BAxh = W_0x + BAx(BB 为 d×rd \times r、AA 为 r×kr \times k),与这里的行向量记法互为转置;这里的记法和 vLLM 源码一致——PunicaWrapperBase 里就是 add_shrink() 作用在 lora_a、add_expand() 作用在 lora_b(vllm/lora/punica_wrapper/punica_base.py:343、364)。

参数量比例=2drd2=2rd\text{参数量比例} = \frac{2dr}{d^2} = \frac{2r}{d}

d=4096,r=16d=4096, r=16 → 参数量只占标准微调的 0.78%。

16.1.3 推理时的叠加式计算

LoRA 的真正威力在于推理时不需要真的合并权重:

Y=xWnew=x(W+s AB)=xW⏟基座路径+s⋅(xA)B⏟LoRA 旁路Y = x W_{\text{new}} = x(W + s\,AB) = \underbrace{xW}_{\text{基座路径}} + \underbrace{s \cdot (xA)B}_{\text{LoRA 旁路}}

ss 是缩放因子:PEFT 配置里的 lora_alpha / r(use_rslora 时为 lora_alpha / √r,vllm/lora/peft_helper.py:55-59);vLLM 加载时就把它乘进 lora_b(LoRALayerWeights.optimize(),vllm/lora/lora.py:40-46),推理时不再单独乘。基座路径和 LoRA 旁路是独立的两条计算路径,最后相加。这个设计有三个关键好处:

  1. 基座权重不变——多个 LoRA 共享同一份基座;加载一个新 LoRA 只需要加载目标层上的 A,BA, B 以及可选 bias / extra vocab
  2. 推理开销可由 rank 估算——对一个方阵线性层,旁路参数量约为 2dr2dr,基座参数量为 d2d^2,所以 rank 越小,旁路越轻;但真实开销还要看目标层数量、batch 形态和 kernel 实现
  3. 批量并发友好——一个 batch 内不同请求可以用不同的 A,BA, B 对,基座路径仍共享
graph LR
    subgraph "LoRA 推理的双路径"
        X[输入 x]
        X --> BASE[基座 xW<br/>共享主路径]
        X --> A_mat["LoRA A / shrink<br/>xA → [1, r]"]
        A_mat --> B_mat["LoRA B / expand<br/>(xA)B → [1, d]"]
        BASE --> ADD[+]
        B_mat --> ADD
        ADD --> Y[输出 Y]
    end
    style BASE fill:#2563eb,color:#fff,stroke:none
    style A_mat fill:#10b981,color:#333,stroke:none
    style B_mat fill:#10b981,color:#333,stroke:none

16.1.4 为什么权重更新真的低秩?

这不是任意的工程选择——它是一个有前人工作启发、再由实验支撑的假设:

  • 动机:LoRA 论文引用 Li et al. 2018、Aghajanyan et al. 2020 的发现——预训练模型的有效参数空间(intrinsic dimension)很低,据此假设适配时的权重更新也有很低的"内在秩";下游任务只需要调整其中与任务相关的方向
  • 经验角度:LoRA 原论文和后续 PEFT 实践都说明,很多下游任务在较小 rank 下就能得到可用效果;但最优 rank 与任务、数据规模、目标层和基座模型有关,不能把某个论文表格里的百分比直接搬成生产保证

16.1.5 rank 选择的经验规则

实战中选择 rr 可以按任务复杂度从小到大试。下面是经验起点,不是质量承诺:

任务类型 推荐 rr 理由
风格迁移(对话、翻译) 4-16 分布漂移小,低秩足够
领域适配(法律/医学) 16-32 需要学习新术语和结构
指令微调 16-64 涉及多样任务
多任务能力注入 64-128 接近"小型再训练"
继续预训练式微调 不建议用 LoRA 全量或 QLoRA 更合适

选择 rr 的黄金法则:从小往大试。LoRAConfig.max_lora_rank 的默认值就是 16(vllm/config.py:2622),可以当作没有更多信息时的起点;但"多少任务在 16 上就够"没有普适答案,必须用自己的离线评测集确认。

16.2 "一个引擎服务 N 个微调"的经济学

16.2.1 成本对比

在传统推理服务架构下,如果要同时服务 10 个不同领域的微调模型,最朴素的做法是部署 10 套完整权重。这个方案的主要浪费在于:10 个模型的大部分权重完全相同,只是微调后参数略有差异。

LoRA 的容量账应该写成公式,而不是写成固定硬件结论:

完整微调: N * base_weight
LoRA:     1 * base_weight + N * adapter_weight + shared_runtime_state

其中 adapter_weight 近似正比于 rank * target_layers,而不是正比于完整模型参数量。以一个 d×dd \times d 的线性层为例,全量更新要存 d2d^2 个参数;LoRA 只存 2dr2dr 个参数。这个比例可以解释为什么"一个基座 + 多个 adapter"在多租户服务里有吸引力。真正部署时还必须把 KV cache、CUDA graph、LoRA slot 预分配、量化格式、TP/PP 切分和并发目标一起算进去。

graph TB
    subgraph "传统方案"
        B1[客户 A 专用<br/>完整模型副本]
        B2[客户 B 专用<br/>完整模型副本]
        B3[...]
        B10[客户 J 专用<br/>完整模型副本]
        Cost1[权重按租户复制]
    end
    subgraph "vLLM + LoRA"
        BASE[共享基座模型]
        L1[LoRA A]
        L2[LoRA B]
        L3[...]
        L10[LoRA J]
        BASE --> L1
        BASE --> L2
        BASE --> L3
        BASE --> L10
        Cost2[共享基座 + 多 adapter]
    end
    style Cost1 fill:#dc2626,color:#fff,stroke:none
    style Cost2 fill:#10b981,color:#333,stroke:none

16.2.2 几个典型场景

这种结构在业务上常见于:

行业 使用场景 朴素做法 LoRA 做法
法律 SaaS 每客户独立合同语料微调 每客户一套完整权重 共享基座,按客户切换 adapter
代码助手平台 按编程语言微调 每种语言一套服务 共享基座,按语言加载 adapter
电商客服 按品牌微调 冷门品牌不值得常驻 热门 adapter 常驻,冷门 adapter 走 resolver
教育辅导 按学科 / 年级微调 多套模型分散部署 通过路由和 LoRA slot 控制活跃集合

这是 LoRA 在 vLLM 里的第一类价值——大幅节省硬件成本。

16.3 vLLM 的 LoRA 三层架构

vLLM 的 LoRA 模块 vllm/lora/ 下分三层:

vllm/lora/
├── request.py                # LoRARequest:用户侧的 API
├── lora.py                   # LoRALayerWeights:单层的 A/B/bias 数据类
├── layers.py                 # LoRA 层 wrapper(包住基座的各类 Linear / Embedding)
├── fully_sharded_layers.py   # 上面这些层的 fully-sharded TP 变体
├── models.py                 # LoRAModel, LoRAModelManager(内存管理)
├── worker_manager.py         # WorkerLoRAManager(Worker 进程内协调)
├── peft_helper.py            # HuggingFace PEFT 配置适配
├── utils.py                  # from_layer 等 wrapper 构造工具
├── resolver.py               # LoRAResolver(远程解析,只有抽象接口)
├── punica_wrapper/           # punica 封装:base / cpu / gpu / hpu + selector
└── ops/                      # 底层 triton kernel 与 torch 参考实现

16.3.1 LoRARequest:用户可见 API

# vllm/lora/request.py:11(类定义折行处合并为一行)
class LoRARequest(msgspec.Struct, omit_defaults=True, array_like=True):
    lora_name: str            # 逻辑名;__eq__/__hash__ 都按它比较
    lora_int_id: int          # 数值 ID(引擎内部用)
    lora_path: str = ""       # 权重路径;__post_init__ 要求非空
    lora_local_path: Optional[str] = None  # deprecated alias
    long_lora_max_len: Optional[int] = None
    base_model_name: Optional[str] = None

用户请求里携带 LoRARequest 后,V1 会把它放进 Request 对象、SchedulerOutput 里新请求的 NewRequestData.lora_request,再进入 Worker 的 InputBatch(V0 则随 SequenceGroupMetadata.lora_request 下发);OpenAI 入口在设置了 VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 时,还可以通过 model 名称触发 resolver,把模型名解析成 LoRA adapter(§16.8.1)。

为什么区分 lora_name 和 lora_int_id? 前者是字符串,给人看、用于日志、resolver 和 OpenAI 入口按 model 字段查找;注意 LoRARequest 的 __eq__ / __hash__ 也只按 lora_name 比较(见 LoRARequest.__eq__ / __hash__ 的实现),同名不同 ID 的两个请求在集合里会被视为同一个。后者是整数,进入调度器、prefix-cache hash、InputBatch 映射和 kernel metadata。源码注释还强调:lora_int_id 应该在 adapter 维度全局唯一,但当前 vLLM 不强制检查。OpenAI 服务入口用 serving_models.py 里的自增计数器 lora_id_counter 分配 ID;直接调用 LLM / 引擎 API 时,调用方必须自己保证 ID 不冲突。

16.3.2 LoRAModel:权重的内存载体

# vllm/lora/models.py:61(概念性,省略 long-context scaling_factor 等)
class LoRAModel:
    """一个具体 LoRA 的权重集合(A, B 矩阵;scaling 已乘进 B)"""
    id: int                             # 即构造参数 lora_model_id
    rank: int
    loras: Dict[str, LoRALayerWeights]  # module name → weights

    # 关键操作
    def clone(self, lora_model_id: int) -> "LoRAModel":
        """浅拷贝 —— 权重张量共享,只是给一个新 ID"""

    @classmethod
    def from_local_checkpoint(cls, lora_dir, expected_lora_modules,
                              peft_helper, *, lora_model_id=None,
                              device="cuda", dtype=None,
                              **kwargs) -> "LoRAModel":  # 其余参数省略
        """从本地 PEFT 目录加载;Worker 侧调用时传 device="cpu" """

clone 是关键:源码注释写明它会 "share the underlying tensors"(vllm/lora/models.py:89-97)。clone(lora_model_id) 返回一个新 LoRAModel 对象和新的整数 ID,但 loras=self.loras.copy() 只是复制字典外壳,底层 LoRALayerWeights 里的张量仍共享。这不是复杂的引用计数系统,而是 Python 对象层面的浅拷贝;它适合 warmup/dummy LoRA 这类需要复用权重对象、但要换 ID 的场景。

from_local_checkpoint() 也值得注意:vLLM 先找 adapter_model.safetensors,找不到再看 adapter_model.bin;它用 safetensors key 反查 unexpected modules,因为 PEFT 配置里可能声明了目标模块,但实际 checkpoint 没有对应权重。加载到 CPU 时,如果 pin memory 可用,lora_a、lora_b、embedding 和 bias 都会 pin 住,为后续搬到 GPU slot 做准备。

16.3.3 LoRAModelManager:激活集合管理

class LoRAModelManager:
    def __init__(self, max_num_seqs, max_num_batched_tokens, vocab_size,
                 lora_config, device):
        # GPU slot 表;真正的 A/B 缓冲区在 _create_lora_modules() 里按 max_loras 预分配
        self.lora_index_to_id: List[Optional[int]] = [None] * lora_config.max_loras
        # 下面两个字段定义在父类 AdapterModelManager(vllm/adapter_commons/models.py)
        self._registered_adapters: Dict[int, LoRAModel] = {}
        self._active_adapters: Dict[int, None] = {}

    def activate_adapter(self, lora_id: int) -> bool:
        """把某个 LoRA 放进 active slot(可能触发驱逐)"""

    def deactivate_adapter(self, lora_id: int) -> bool:
        """踢出某个 LoRA,但保留在 registered 集合中"""

    def remove_adapter(self, lora_id: int) -> bool:
        """从 registered 集合里彻底删除"""

它管理两个维度的集合:

  • Registered:所有已注册到系统的 LoRA(内存中可能有,但不一定在 GPU 上)
  • Active:当前在 GPU 上准备好、可以参与 forward 的 LoRA

activate_adapter 负责把一个 registered 但未 active 的 LoRA 搬到 GPU 上(可能触发驱逐);deactivate_adapter 把它踢出 active 但保留在 registered(下次再 activate 不必重新从磁盘加载)。

16.3.4 WorkerLoRAManager:Worker 侧的协调者

每个 Worker 进程(TP rank)维护一个 WorkerLoRAManager,LoRAModelManager 就是由它在 create_lora_manager() 里创建并持有的。Worker 从本步批次里收集出 LoRARequest 集合和 LoRAMapping,WorkerLoRAManager 在 forward 前确保所有需要的 LoRA 都 active。

# vllm/lora/worker_manager.py:165(概念性,展开了 adapter_commons/utils.py 的辅助函数)
class WorkerLoRAManager:
    def set_active_adapters(self, requests: Set[Any],
                            mapping: Optional[Any]) -> None:
        self._apply_adapters(requests)                     # 保证本步用到的 LoRA 都已加载且 active
        self._adapter_manager.set_adapter_mapping(mapping) # 再更新 punica 索引张量;无返回值

    # 基类版 _apply_adapters:本步不需要的已注册 LoRA 全部 remove,
    # 缺的 LoRA 走 add_adapter()(_load_adapter → add → activate)
    # LRU 子类版(V0/V1 实际使用):不主动 remove,只对每个请求调 add_adapter()

这段代码的关键不是固定切换耗时,而是先保证合法,再进入 forward。LRUCacheWorkerLoRAManager._apply_adapters() 会先把本轮请求里的 LoRA ID 收集成 map,如果数量超过 lora_slots 直接抛 RuntimeError(vllm/lora/worker_manager.py:216-227);add_adapter() 则先从本地路径加载并校验 adapter,加载成功后才在 CPU cache 超容量时驱逐 oldest,最后调用 activate_adapter()(:229-251)。源码注释把理由写得很明白——原文折成两行,起头是 "Load the new adapter first to ensure it is actually valid, before",下一行接 "evicting any existing adapters.";代价是已加载 adapter 数会短暂超过 --max-cpu-loras。这个顺序避免了"先踢掉旧 adapter,结果新 adapter 加载失败"的坏状态。

V1 runner 侧还有一层映射:InputBatch.make_lora_inputs() 返回三件东西:每个 request 的 prompt_lora_mapping、按本步 token 展开的 token_lora_mapping,以及活跃的 LoRARequest 集合。LoRAModelRunnerMixin._set_active_loras() 把这两个 mapping 包成 LoRAMapping,再交给 WorkerLoRAManager.set_active_adapters()。也就是说,kernel 看到的不是原始请求对象,而是已经压平成 token/request 维度的整数:LoRAMapping 里装的是 lora_int_id,punica wrapper 更新元数据时再用 lora_index_to_id.index(id) 把它换成 GPU slot 下标,不用 LoRA 的 token 记为 -1(vllm/lora/punica_wrapper/utils.py 的 convert_mapping())。

16.3.5 三层职责总结

一张表梳理清楚:

层 位置 职责 典型调用方
LoRARequest Client 接口 描述"我想用哪个 LoRA" 用户代码
LoRAModelManager Worker 进程 注册表 + GPU 激活集合管理 WorkerLoRAManager
WorkerLoRAManager Worker 进程 forward step 前确保 active Model Runner

这三层让用户代码和 kernel 代码完全解耦——用户只需要填 LoRARequest,不用关心什么时候 activate。

16.4 punica 与分组 GEMM:batched LoRA 的性能秘密

batched LoRA 的核心挑战:一个 batch 里不同请求用不同 LoRA,如何高效计算?

16.4.1 朴素方案的代价

最直白的做法——循环遍历所有 LoRA:

output = base_forward(x, W)  # 基座
for lora_id in unique_loras_in_batch:
    indices = [i for i, r in enumerate(batch) if r.lora_id == lora_id]
    output[indices] += scaling * (x[indices] @ A[lora_id]) @ B[lora_id]

问题:每个 LoRA 的那次 GEMM 都是小 batch size 的 GEMM(因为一个 LoRA 通常只对应 batch 里几个请求),GPU 利用率极低。

16.4.2 punica 论文的关键洞察

Punica(Chen et al., MLSys 2024 / arXiv:2310.18547)提出:把所有 LoRA 的 A 矩阵沿 LoRA 维"堆"成一个张量,用一次分组 GEMM(Grouped GEMM) 完成所有 LoRA 旁路(下面是 vLLM 版本的示意伪代码):

# A_stacked: [num_loras, 1, r, d]   (shrink 方向)
# B_stacked: [num_loras, 1, d, r]   (expand 方向)
# token_lora_mapping: [num_tokens] → 每个 token 对应的 LoRA slot 下标(-1 = 不用 LoRA)

# 所有 LoRA 旁路在两个 kernel 里完成
buffer = lora_shrink(x, A_stacked, token_lora_meta, scaling)   # [num_tokens, r]
output = lora_expand(buffer, B_stacked, token_lora_meta)       # [num_tokens, d]
output += base_forward(x, W)

关键不是"某个特定 kernel 名字",而是那份元数据:给定 token→LoRA 的映射,先按 LoRA(slot 下标)把 token 排序、数出每个 LoRA 有多少 token、算出每段的起始偏移,kernel 就能让每个 thread block 处理一段同 LoRA 的 token,组间并行。原始 Punica 论文把这套东西叫 SGMV。

16.4.3 v0.8.5 的实际情况:CUDA 上只有一对统一 kernel

不少资料还在讲"vLLM 有 BGMV 和 SGMV 两套 punica kernel,decode 走 BGMV、prefill 走 SGMV"。在 v0.8.5 上,这个说法对 CUDA 路径已经不成立了——两套 Triton kernel 在 v0.8.1 被统一替换(#14685 "[Kernels] LoRA - Retire SGMV and BGMV Kernels"),V0 与 V1 共用同一个 PunicaWrapperGPU,ROCm 平台也选它。vllm/lora/ops/triton_ops/ 目录里只有:

kernel_utils.py          共用的 Triton block 计算
lora_shrink.py           统一的 shrink kernel(作用于 lora_a)
lora_expand.py           统一的 expand kernel(作用于 lora_b)
lora_kernel_metadata.py  LoRAKernelMeta —— 驱动上面两个 kernel 的索引张量
utils.py

没有 bgmv_*.py / sgmv_*.py。源码里把这一点写得很明白,LoRAModelRunnerMixin._set_active_loras() 的注释是:

# Set is_prefill to True, so we always use the SGMV kernels on
# non-cuda platforms.
# On cuda platforms we use the same kernels for prefill and
# decode and this flag is generally ignored.

(vllm/v1/worker/lora_model_runner_mixin.py:61-64)

也就是说:CUDA 上 prefill 和 decode 用的是同一对 lora_shrink / lora_expand,is_prefill 标志基本被忽略。LoRAMapping 里那个 is_prefill=True 是为非 CUDA 后端保留的(V0 则按 is_prefill=not self.decode_only 如实填写,vllm/worker/model_runner.py:946-949)。除注释和 profiler 工具里的字符串外,BGMV / SGMV 作为实现在 v0.8.5 里只剩两处:vllm/lora/ops/torch_ops/lora_ops.py 里的 PyTorch 实现(导出 bgmv_shrink / sgmv_expand 等,CPU 后端 punica_cpu.py 实际调用的就是它),以及 HPU 后端调用的 vllm_hpu_extension.ops.dispatch_bgmv_*。

统一成一对 kernel 的关键是 LoRAKernelMeta(vllm/lora/ops/triton_ops/lora_kernel_metadata.py),它把 batch 形态压成五个索引张量:

字段 含义
token_lora_mapping 每个 token 用哪个 LoRA slot 下标(不用 LoRA 为 -1)
token_indices_sorted_by_lora_ids 按 slot 下标排序后的 token 下标——同一 LoRA 的 token 在这里变成连续段
active_lora_ids 本步出现过的 slot 下标(长度 max_loras + 1,+1 是因为"不用 LoRA"也是一种可能,用 -1 表示)
num_tokens_per_lora 每个 LoRA 有多少 token
lora_token_start_loc 每段的起始偏移(长度 max_loras + 2,首元素恒为 0)

decode 时每个请求只有 1 个 token,一段的长度就是使用该 LoRA 的请求数;prefill 时则是这些请求的 token 数之和——同一个 kernel 靠段长度自适应,不需要两套实现。这就是"BGMV 和 SGMV 合并"的技术前提。

还有一个为 V1 加上的细节:LoRAKernelMeta 多带了一个 no_lora_flag_cpu 张量。注释解释得很直白——V1 用 torch.compile 追踪的图做 forward,而追踪会把 Python 标量常量化、也无法处理动态控制流,但不会追踪 torch.ops 函数的内部。所以"这一步没有任何 LoRA"这个布尔量被伪装成一个 CPU tensor 传进去,让 lora_shrink / lora_expand 在算子内部提前 return。这是 torch.compile 时代很典型的一种妥协写法。

同样的"固定地址缓冲区"思路也让 LoRA 能和 CUDA Graph 共存(按源码判断、未实跑):LoRAKernelMeta 的索引张量在构造时就按 max_loras、max_num_batched_tokens 预分配,每步只做原地 copy_;V1 的 _dummy_run() 包在 maybe_dummy_run_with_lora() 里,按 max_loras 个 rank=8 的 dummy LoRA 轮流分配给各请求,录图时就把 LoRA kernel 录进去(vllm/v1/worker/lora_model_runner_mixin.py:82-125);V0 的 capture_model() 则只挂一个 dummy LoRA 录图(vllm/worker/model_runner.py:1492-1498)。

16.4.4 源码里的 punica wrapper

本地源码能直接确认的是 wrapper 的职责,而不是某个固定 latency 表。punica_base.py 把接口拆成五类:

方法 作用
update_metadata() 根据 LoRAMapping、lora_index_to_id、vocab 信息和 long-context LoRA 状态更新 kernel 元数据
add_shrink() 对多个 lora_a slice 做 shrink,即 LoRA A 方向的低秩投影
add_expand() 对多个 lora_b slice 做 expand,并按 output slice 写回
add_lora_embedding() 给 embedding 类 LoRA 做 expand
add_lora_linear() / add_lora_logits() 给 linear / logits processor 的 LoRA 入口提供统一封装

PunicaWrapperBase 预分配了 token 级和 prompt 级的索引张量:_token_lora_indices、_sampler_indices、_sampler_indices_padded、_embeddings_indices、_long_lora_indices。它还维护 prefill 段信息 _seq_start_locs、_seq_lengths、_lora_indices_per_batch(vllm/lora/punica_wrapper/punica_base.py:133-162),供仍按段处理的非 CUDA 后端使用。这解释了为什么前面的 InputBatch.make_lora_inputs() 要把 LoRA ID 展开到 token 维度:kernel 不想拿 Python 请求对象,它只需要一组紧凑的索引张量。

GPU 实现 punica_gpu.py 又补了一层设备相关逻辑:它创建 LoRAKernelMeta,update_metadata() 时调用 prepare_tensors(),add_shrink() 和 add_expand() 分别调 lora_shrink / lora_expand Triton op。CPU/HPU 有自己的 wrapper,选择逻辑在 punica_selector.py。这是一种典型的 vLLM 风格:上层保持统一接口,下层按硬件后端切实现。

16.4.5 性能该怎么测

本章不写"某模型某 batch 延迟多少毫秒",因为这类数字对硬件、rank、目标层、batch 混合、是否 prefill、是否 CUDA graph、是否 fully sharded、量化基座都敏感。更可靠的判断方式是把测试拆成四组:

对照组 用途
基座无 LoRA 得到纯 base model 的 TPOT/TTFT 基线
单 LoRA 常驻 测 adapter 常驻时旁路计算的额外成本
多 LoRA 混合 batch 测 token-to-LoRA mapping 和 grouped kernel 的成本
高频切换 / 冷加载 测 resolver、本地加载、CPU cache、GPU slot 激活的尾延迟

如果多 LoRA 混合 batch 明显慢,先看三件事:max_loras 是否逼近上限导致调度跳过请求,rank 是否过大,是否启用了 fully_sharded_loras 且当前 TP/序列长度真的适合它。LoRAConfig.fully_sharded_loras 的文档字符串写的是"默认只有一半的 LoRA 计算会被张量并行切分;开启后使用 fully sharded 层,在高序列长度、高 rank 或高 TP size 下可能更快"(vllm/config.py:2626-2630)——注意它用的是 "likely faster",这是一条调参方向,不是默认无脑开启。

16.5 双层缓存:GPU active + CPU pinned

16.5.1 加载成本的不对称性

LoRA 的加载成本不对称:

  • 已 active:slot 已经写入 lora_index_to_id,forward 前只需要更新 mapping 元数据
  • 已 registered 但未 active:adapter 已在 manager 缓存里,activate_adapter() 把权重写入空闲或被 LRU 腾出的 GPU slot
  • 未 registered 但本地路径存在:_load_adapter() 读取 PEFT 配置和 checkpoint,校验 rank、extra vocab、目标模块,再加入 manager
  • 远程或动态名称:仅在 VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 时,OpenAI 入口先走 LoRAResolverRegistry 里的 resolver,把名称解析成 LoRARequest,然后调用 engine_client.add_lora()

vLLM 把这分成了两层缓存:

graph TB
    subgraph "GPU 侧 (最快)"
        Active[Active LoRAs<br/>max_loras 个槽位]
    end
    subgraph "CPU 侧 (中等速度)"
        CPU[Registered LoRAs<br/>max_cpu_loras 容量]
    end
    subgraph "磁盘 / 远程 (最慢)"
        Disk[本地 / S3 / HF Hub]
    end
    Disk -->|resolver / local load| CPU
    CPU -->|activate_adapter| Active
    Evict1[GPU LRU 驱逐] --> CPU
    Evict2[CPU LRU 驱逐] --> Disk
    style Active fill:#10b981,color:#333,stroke:none
    style CPU fill:#2563eb,color:#fff,stroke:none
    style Disk fill:#94a3b8,color:#333,stroke:none

两个关键参数:

  • --max-loras:GPU 上同时 active 的 LoRA 数,决定一个 batch 里最多能处理多少种 LoRA
  • --max-cpu-loras:manager 里可注册/缓存的 LoRA 数;未显式设置时,LoRAConfig.__post_init__() 会把它设成 max_loras,且它必须大于等于 max_loras

16.5.2 真实实现:GPU 侧定长 slot 数组 + CPU 侧 LRU(不是"两层 LRU")

这一层和常见描述不太一样。打开 vllm/lora/models.py,真实架构是两层机制不对称:GPU 侧是定长 slot 数组(不做自动驱逐)、CPU 侧才是真正的 LRU。分两段看源码:

GPU 侧——LoRAModelManager(基类,vllm/lora/models.py:304):

# models.py:332
self.lora_index_to_id: List[Optional[int]] = [None] * self.lora_slots

@property
def lora_slots(self) -> int:
    return self.lora_config.max_loras    # GPU 槽位数

@property
def capacity(self) -> int:
    return self.lora_config.max_cpu_loras  # CPU 总容量

# models.py:330
assert self.capacity >= self.lora_slots   # CPU ≥ GPU 的硬约束

GPU 侧的状态就是一个定长 List[Optional[int]]——None 代表空槽、整数代表"这个 LoRA id 占据此 slot"。activate_adapter(vllm/lora/models.py:388-392)找第一个 None 填入:

        first_free_slot = next(
            ((i, lora_id) for i, lora_id in enumerate(self.lora_index_to_id)
             if lora_id is None), None)
        if first_free_slot is None:
            raise ValueError("No free lora slots")

找不到空槽就直接抛 ValueError——基类根本不做自动驱逐。与它配套的基类 WorkerLoRAManager 靠另一种方式保证有空槽:每步先把本步用不到的已注册 LoRA 全部 remove,再加载缺的(vllm/adapter_commons/utils.py:69-88),相当于不做跨步缓存;驱逐策略则留给子类。

CPU 侧——LRUCacheLoRAModelManager(子类,vllm/lora/models.py:711-753)加上 LRU:

class LRUCacheLoRAModelManager(LoRAModelManager):
    def __init__(self, ...):
        super().__init__(...)
        # CPU 缓存:真 LRU,有 deactivate_adapter 回调
        self._registered_adapters: LoRALRUCache = LoRALRUCache(
            self.capacity, self.deactivate_adapter)
        # GPU "活跃表":也是 LRU 但容量等于 lora_slots
        self._active_adapters: LoRALRUCache = LoRALRUCache(
            self.lora_slots, self._deactivate_adapter)

    def activate_adapter(self, lora_id: int) -> bool:
        # 关键:子类在调基类之前自己主动驱逐
        if lora_id not in self._active_adapters and len(
                self._active_adapters) >= self.lora_slots:
            self._active_adapters.remove_oldest()
        result = super().activate_adapter(lora_id)
        self._active_adapters.touch(lora_id)
        return result

子类用两个 LoRALRUCache 叠加机制:

  • _registered_adapters:CPU 侧真 LRU,容量 max_cpu_loras。deactivate_adapter 回调会在元素被 LRU 挤出时触发——这是"CPU 满了、把最老的彻底卸掉"的自动通路。
  • _active_adapters:GPU 侧的并行 LRU,容量 max_loras。它和基类的 lora_index_to_id slot 数组并存——activate_adapter 里先 remove_oldest() 腾一个 slot、再 super().activate_adapter() 走基类的 "找空 slot 填入"。

这种分层设计有一个优雅的特点:基类的 lora_index_to_id 数组是真正承载 GPU 上 kernel 所需 slot index 的状态(kernel 要按固定 index 去索引 stack 好的 A/B 张量),_active_adapters LRU 只是辅助的元信息用来决定下次驱逐谁。数据平面(slot 数组)和控制平面(LRU 结构)分离——基类管数据平面、子类管控制平面。

math.ceil(max_num_batched_tokens / 8) * 8 的对齐(line 331)是另一个容易漏看的细节——内部用的 max_num_batched_tokens 会被向上圆整到 8 的倍数。源码没有在这里解释性能原因,所以本章只保留事实:LoRA manager 内部按 8 对齐,外部 scheduler 配置仍是原始 token 上限。

v0.8.5 的 V0(vllm/worker/model_runner.py、CPU/HPU runner)和 V1(lora_model_runner_mixin.py)都直接实例化 LRUCacheWorkerLoRAManager,因此走 vllm serve 时用的就是 LRU 子类;只有自己拿 LoRAModelManager 基类拼装、又不按上面的方式先 remove 时,才会撞上 "No free lora slots"。

16.5.3 调参建议

max_loras 设置原则:实际同时高频使用的 LoRA 数,不是"总共注册了多少 LoRA"。

  • 如果你有 100 个 LoRA 但每段时间只有 3 个在用:max_loras=4(留一个余量)
  • 如果有 8 个客户同时都在用各自 LoRA:max_loras=8
  • 不要为了"稳妥"设太大——每个 LoRA 层都按 max_loras × max_lora_rank 预分配 A/B 缓冲区(不管实际 active 几个),这部分显存在 profile 阶段就被扣掉,直接减少可用 KV

16.5.4 冷启动 warmup

生产部署中,如果你知道启动后前几分钟流量会打在某几个 LoRA 上,最好主动预热:

# 启动脚本里
for lora_name in ["customer_a", "customer_b", "customer_c"]:
    await engine.add_lora(LoRARequest(lora_name, ...))

第一个真实请求到达时,这些 LoRA 已经通过 add_lora() 进入 manager:LRUCacheWorkerLoRAManager.add_adapter() 加载后会立即 activate_adapter(),所以预热个数超过 max_loras 时,后加的会按 LRU 把先加的挤出 GPU slot;超过 max_cpu_loras(未设置时等于 max_loras)时,最老的会被整个卸掉,预热就白做了。预热的目标是把路径解析、PEFT 校验、本地 checkpoint 加载这些不稳定步骤前移,而不是保证某个固定毫秒级收益。

16.6 LoRA 与前缀缓存的冲突

这是 LoRA 推理最容易被忽视的副作用:LoRA 破坏前缀缓存的跨请求共享。

16.6.1 冲突原理

前缀缓存的基础是"相同 token 序列产生相同 KV"。但 LoRA 改变了模型中被注入层的输出,不同 LoRA 下相同 prompt 会产生不同 KV。

因此每个 block 的 hash 必须包含 LoRA 标识:

# vllm/v1/core/kv_cache_utils.py:266-389(摘录语义)
def need_extra_keys(request):
    return bool(request.mm_positions) or (request.lora_request is not None)

def _gen_lora_extra_hash_keys(request):
    if not request.lora_request:
        return []
    return [request.lora_request.lora_int_id]

def generate_block_hash_extra_keys(request, ...):
    lora_extra_keys = _gen_lora_extra_hash_keys(request)
    extra_keys = lora_extra_keys + mm_extra_keys
    return tuple(extra_keys) if extra_keys else None

结果:使用 LoRA-A 的请求和使用 LoRA-B 的请求,即使 token 内容完全一样,block hash 的 extra keys 也不同,不能共享同一份 prefix cache block。这个设计是正确性要求,不是保守优化。上面是 V1 的实现;V0 在 Sequence.extra_hash() 里把 (prompt_adapter_id, lora_int_id) 哈希后交给块管理器(vllm/sequence.py:537-548),效果相同。

16.6.2 数量分析

假设系统提示是固定的 system prompt,正常情况下多个请求可以共享同一份 prompt KV。加了 LoRA 后,缓存隔离粒度会多出 LoRA ID:

  • 同一个 LoRA 内部:相同 prompt 仍可能共享;
  • 不同 LoRA 之间:相同 prompt 不共享;
  • 活跃 LoRA 越多,prefix cache 被按 adapter 分片得越细;
  • 如果每个请求都换不同 LoRA,prefix cache 对跨请求共享的帮助会明显下降。

16.6.3 缓解策略

业务层路由:前端 Load Balancer 按 LoRA ID 路由——同 LoRA 的请求尽量送同一个副本。这样单副本内部的 prefix cache 对该 LoRA 的命中率能保持。

减少 LoRA 数量:如果可能,把相似的 LoRA 合并成更少的大 LoRA。

接受 trade-off:多数 B 端场景下,LoRA 带来的多租户能力 > prefix cache 命中率损失。评估之后果断选择 LoRA 路径。

16.6.4 权衡决策表

帮你快速判断该不该用 LoRA:

场景特征 推荐 LoRA 理由
10+ 租户、每租户独立微调 ✓ 强烈推荐 权重占用从 N 份基座变成 1 份基座 + N 份低秩 adapter
高共享 system prompt、少量 LoRA ✗ 谨慎 prefix cache 损失可能 > LoRA 收益
在线用户只有 1-2 租户 ✗ 不推荐 不如直接部署专用模型
需要动态加载新微调 ✓ 推荐 不重启引擎即可挂上新 adapter(§16.8.1)
每请求都切换 LoRA ✗ 避免 切换开销累积

16.7 QLoRA:量化基座 + FP16 LoRA

16.7.1 组合思路

QLoRA(Dettmers et al., NeurIPS 2023 / arXiv:2305.14314)是训练时的概念——基座用 4-bit 量化,LoRA 旁路保持 FP16/BF16。vLLM 推理时也支持这个组合:

# 注意:--quantization 只是声明 checkpoint 的量化格式,
# 模型 ID 必须指向一个已经量化好的权重仓库,不能对 FP16 原始权重加这个参数
vllm serve <某个已量化的 70B GPTQ 权重仓库> \
    --quantization gptq \
    --enable-lora \
    --max-loras 8 \
    --max-lora-rank 64 \
    --lora-modules \
        customer_a=/models/lora/a \
        customer_b=/models/lora/b \
        ...

效果:

量化基座: 降低 base_weight
FP16/BF16 LoRA: 保留 adapter 的低秩旁路
KV cache: 仍按并发、上下文长度、dtype 和 block 策略消耗显存

这类组合的价值是把"基座权重"和"adapter 权重"分别优化:基座靠量化省显存,adapter 靠低秩省显存。能不能放进一张卡,要用目标模型、量化格式、max_loras、max_lora_rank、KV cache dtype、最大上下文和并发一起算,不能只看参数名。

16.7.2 精度损失

很多人担心"4-bit 量化精度够吗?"答案取决于模型、量化方法和任务。不要把某个评测集上的百分比泛化成产品承诺:

对比 应该验证什么
FP16 原模型 → 量化基座 通用能力、长上下文、工具调用或结构化输出是否退化
FP16 原模型 + LoRA → 量化基座 + LoRA adapter 学到的领域能力是否仍然有效
数学、代码、严格格式任务 小概率错误、边界条件和格式遵循是否变差

如果服务的是开放式对话,用户可能更能容忍轻微差异;如果服务的是数学、代码、合规审查或结构化抽取,就必须做离线集和线上 A/B。vLLM 能提供推理组合能力,但不替你证明质量。

16.7.3 注意事项

  • LoRA rank 高时表达能力更强,但 max_lora_rank 会影响预分配 buffer,且本地 LoRAConfig 只允许 (8, 16, 32, 64, 128, 256, 320, 512) 这些 rank 上限
  • 量化基座的 kernel 与 LoRA 旁路会在同一 forward 路径里协作;是否有明显开销要按目标模型测试
  • --kv-cache-dtype fp8 能降低 KV cache 占用,但需要额外验证质量和硬件支持,不能和 LoRA 显存节省混为一谈

16.8 LoRA 解析器:从 S3 / HuggingFace 热加载

16.8.1 Resolver 抽象

企业场景下 LoRA 通常存在对象存储。vLLM 的 LoRAResolver 提供了可插拔的远程加载:

# vllm/lora/resolver.py:13
class LoRAResolver(ABC):
    @abstractmethod
    async def resolve_lora(
        self, base_model_name: str, lora_name: str
    ) -> Optional[LoRARequest]:
        """给定 lora_name,返回可以加载的 LoRARequest;找不到则返回 None。"""

@dataclass
class _LoRAResolverRegistry:
    resolvers: Dict[str, LoRAResolver] = field(default_factory=dict)

    def register_resolver(self, resolver_name: str, resolver: LoRAResolver):
        self.resolvers[resolver_name] = resolver

    def get_resolver(self, resolver_name: str) -> LoRAResolver:
        ...

本地源码里没有内置 S3LoRAResolver 或 HuggingFaceLoRAResolver 具体类;只有抽象接口和 registry。OpenAI serving 入口启动时会遍历 LoRAResolverRegistry.get_supported_resolvers(),把已注册 resolver 放进 self.lora_resolvers;收到动态 LoRA 名称时,serving_models.py 的 resolve_lora() 会先检查是否已加载,再逐个调用 resolver,找到后给它分配新的 lora_int_id,调用 engine_client.add_lora() 并加入本地 lora_requests 列表。

所以生产部署的真实扩展点是:你在自己的启动代码或插件里注册一个 resolver,它可以从 S3、内部模型仓库、HF Hub 镜像或本地缓存解析 adapter,但这不是 vLLM 当前源码里已经写好的具体类。

还有一个容易漏掉的开关:resolver 只在环境变量 VLLM_ALLOW_RUNTIME_LORA_UPDATING 为真时才会被触发——serving_engine.py 的 _check_model() 遇到未知 model 名时,先检查这个开关再调 resolve_lora()(vllm/entrypoints/openai/serving_engine.py:138-139)。

同一个开关还决定 API server 是否注册 POST /v1/load_lora_adapter(请求体带 lora_name、lora_path)和 POST /v1/unload_lora_adapter(vllm/entrypoints/openai/api_server.py:783-810),开启时服务端会打日志警告"This should ONLY be used for local development!"。按源码判断(未实跑),unload_lora_adapter 只把该 LoRA 从 API 层的 lora_requests 列表里删掉,并不调用引擎的 remove_lora,已加载的权重要等 Worker 侧 LRU 把它挤出去。

16.8.2 自定义 Resolver 的三个关键点

企业内常见需求:

  1. 鉴权:把 request 的 auth_token 传入 Resolver,在 Resolver 里校验
  2. 多版本:lora_name 可能带版本(customer_a:v2),Resolver 做版本解析
  3. 本地缓存:避免每次都从对象存储下载,用 LRU 本地文件缓存

示意:

class CompanyLoRAResolver(LoRAResolver):
    def __init__(self, cache_dir="/cache/lora", max_cache_gb=100):
        self.cache_dir = cache_dir
        self.cache = LRUDiskCache(cache_dir, max_cache_gb * 1e9)

    # 签名必须和 ABC 一致:resolve_lora(self, base_model_name, lora_name)
    # 鉴权信息只能从 resolver 自己的上下文(如 contextvar / 请求中间件)里取
    async def resolve_lora(self, base_model_name, lora_name):
        # 鉴权
        tenant_id = await verify_auth(current_auth_token.get())
        if not await tenant_can_use_lora(tenant_id, lora_name):
            raise PermissionError()

        # 多版本
        if ":" in lora_name:
            name, version = lora_name.split(":")
        else:
            name, version = lora_name, "latest"

        # 本地缓存
        cache_key = f"{name}:{version}"
        local = self.cache.get(cache_key)
        if local:
            # lora_name 必须原样用传入的名字:OpenAI 入口按它匹配 model 字段;
            # lora_int_id 随便填,serving_models.resolve_lora() 会用自增 ID 覆盖
            return LoRARequest(lora_name, 0, local)

        # 远程拉取
        remote = f"s3://company-loras/{name}/{version}/"
        local = await s3_download(remote, self.cache_dir)
        self.cache.put(cache_key, local)
        return LoRARequest(lora_name, 0, local)

(LRUDiskCache、verify_auth、current_auth_token、s3_download 等都是示意用的业务侧组件,vLLM 不提供。另外,serving_models.resolve_lora() 只捕获 add_lora() 的异常,resolver.resolve_lora() 里抛出的 PermissionError 会一路冒泡、不会被转成 403,需要在自己的中间件里处理。)

16.9 LoRA 的四个局限

局限 1:频繁切换的累积开销

当请求模式高频切换(每个请求不同 LoRA),系统会更频繁地做 resolver、本地加载、CPU cache LRU、GPU slot 激活和 metadata 更新。解决思路不是迷信某个固定延迟,而是让同一批次内的 LoRA 种类数受控:调大 max_loras、按 LoRA ID 路由,或者把低频 adapter 放到单独副本。

局限 2:前缀缓存命中率下降

如 16.6 所述,不同 LoRA 的 KV 不共享。LoRA 越多,prefix cache 越不值钱。严重时要评估 LoRA 路径是否合算。

局限 3:并非所有模型都支持

V1 runner 在 load_lora_model() 里先调用 supports_lora(model);不支持就直接抛 ValueError(V0 的 ModelRunner.load_model() 做的是同一检查,只是写成 assert)。V0/V1 的功能面也不完全一样:设了 --long-lora-scaling-factors(long LoRA)时 V1 直接报错、要求用 V0;反过来,开 --cpu-offload-gb 时 V0 的 LoRA 报错、要求用 V1(vllm/config.py:2696-2710)。多模态模型还有额外限制:日志会提示当前只支持把 LoRA 加到 language model 部分。判断某个模型能不能用 LoRA,最稳妥的方法是看模型实现是否声明支持 LoRA,以及 vllm/lora/utils.py 能否为它的线性层创建对应 wrapper。

局限 4:rank 上限约束

--max-lora-rank 决定 GPU 预分配缓冲区。实际 LoRA 的 rank 超过这个值就无法加载。典型场景:

  • 训练时 rank=64,推理时 --max-lora-rank 64 OK
  • 训练时 rank=128,推理必须设 --max-lora-rank >= 128

提前和训练团队对齐。

16.9.1 总结:LoRA 不是银弹

把四条局限记下来:

局限 缓解方法
切换开销 max_loras 调大 + 业务路由
prefix cache 下降 租户路由 + 接受 trade-off
模型兼容性 事先确认,或改用主流模型
rank 上限 训练推理参数提前对齐

16.10 三类生产场景的配置

16.10.1 RAG 多租户(B 端 SaaS)

每个客户有自己的 LoRA,训练数据私有:

vllm serve <已量化的 70B Instruct GPTQ 权重仓库> \
    --quantization gptq \
    --enable-lora \
    --max-loras 16 \
    --max-cpu-loras 64 \
    --max-lora-rank 64 \
    --max-num-seqs 64 \
    --enable-chunked-prefill

max_loras=16 表示单个 batch 最多容纳 16 种 LoRA,也对应 GPU active slot 上限;max_cpu_loras=64 给 registered adapter 留更大的 CPU cache。动态 S3/内部仓库解析不是一个内置 --lora-resolver s3 参数,而是需要在服务进程启动前向 LoRAResolverRegistry 注册自定义 resolver,并设置 VLLM_ALLOW_RUNTIME_LORA_UPDATING=1(§16.8.1)。

16.10.2 多领域 API 服务

一个开放 API,按领域切换 LoRA(代码、法律、医学、翻译):

vllm serve Qwen/Qwen2.5-72B-Instruct \
    --enable-lora \
    --max-loras 4 \
    --max-cpu-loras 8 \
    --lora-modules \
        code=/models/lora/code-v3 \
        legal=/models/lora/legal-v2 \
        medical=/models/lora/medical-v1 \
        translate=/models/lora/translate-v5

预加载 4 个常用 LoRA;用户在 API 里通过 model 字段指定。

16.10.3 开发者自定义微调(Playground)

允许用户自己上传 LoRA 做实验:

vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
    --enable-lora \
    --max-loras 2 \
    --max-cpu-loras 16 \
    --max-lora-rank 128

max_loras 小,表示单批同时服务的 adapter 种类少;max_lora_rank=128 允许较高 rank 的实验 adapter。用户上传路径仍然要通过业务侧 resolver、静态 --lora-modules,或开启 VLLM_ALLOW_RUNTIME_LORA_UPDATING 后调 /v1/load_lora_adapter 管理(后者源码明言只适合本地开发),vLLM 当前没有内置 --lora-resolver user_upload 这类 CLI。

16.10.4 三场景对比

参数 RAG 多租户 多领域 API Playground
max_loras 16 4 2
max_cpu_loras 64 8 16
max_lora_rank 64 16(未指定,取默认) 128
resolver 自定义 S3/仓库 resolver 本地预加载 用户上传 resolver
典型并发 由 max_num_seqs 和 max_loras 共同限制 中 低
切换频率 中 低 高

16.10.5 实测:vllm/lora/ 6027 行 + V1 集成 145 行的真实分布

把整个 vllm/lora/ 目录按子模块实测——

路径 行 角色
vllm/lora/layers.py 1263 本目录最大——LoRA 层 wrapper(ColumnParallelLinearWithLoRA / RowParallelLinearWithLoRA / VocabParallelEmbeddingWithLoRA / LogitsProcessorWithLoRA 等)
vllm/lora/models.py 802 LoRAModel + LoRAModelManager + LRUCacheLoRAModelManager(§16.3.2-16.3.3、§16.5.2)
vllm/lora/punica_wrapper/punica_base.py 483 PunicaWrapperABC / PunicaWrapperBase——add_shrink / add_expand / add_lora_linear / add_lora_logits / add_lora_embedding 五类接口(§16.4)
vllm/lora/punica_wrapper/punica_cpu.py 348 CPU 后端实现
vllm/lora/fully_sharded_layers.py 335 上述 LoRA 层的 fully-sharded TP 变体
vllm/lora/ops/triton_ops/lora_expand.py 293 统一 Expand kernel——lora_b 方向的上投影,prefill / decode 共用
vllm/lora/punica_wrapper/punica_gpu.py 289 GPU 后端——调 ops/triton_ops/ 里的 kernel
vllm/lora/worker_manager.py 251 WorkerLoRAManager / LRUCacheWorkerLoRAManager(§16.3.4)
vllm/lora/ops/triton_ops/lora_shrink.py 247 统一 Shrink kernel——lora_a 方向的下投影,prefill / decode 共用
vllm/lora/ops/triton_ops/kernel_utils.py 243 共用的 Triton block 计算
vllm/lora/utils.py 237 from_layer() / from_layer_logits_processor() 等 wrapper 构造工具
vllm/lora/lora.py 198 LoRALayerWeights / PackedLoRALayerWeights——单层 A/B/bias 数据类
vllm/lora/punica_wrapper/utils.py 161 LoRAMapping → 索引张量的转换工具
vllm/lora/ops/triton_ops/lora_kernel_metadata.py 147 LoRAKernelMeta(§16.4.3 的五个索引张量)
vllm/lora/punica_wrapper/punica_hpu.py 144 HPU 后端(走 vllm_hpu_extension 的 dispatch_bgmv_*)
vllm/lora/ops/triton_ops/utils.py 121 lora_a / lora_b 指针准备
vllm/lora/peft_helper.py 115 HuggingFace PEFT 配置适配
vllm/lora/ops/torch_ops/lora_ops.py 115 PyTorch 实现(CPU 后端调用)——作为实现,bgmv_* / sgmv_* 如今只在这里和 HPU 后端出现
vllm/lora/request.py 97 LoRARequest(§16.3.1)
vllm/lora/resolver.py 83 LoRAResolver 抽象 + _LoRAResolverRegistry(§16.8)
vllm/lora/punica_wrapper/punica_selector.py 20 按 current_platform.get_punica_wrapper() 挑后端
其余(各 __init__.py) 余下 —
vllm/lora/ 合计 6027 —
vllm/v1/worker/lora_model_runner_mixin.py 145 V1 worker 的 LoRA mixin 集成层(§1.6.1 实测 v1/worker/ 4851 行的 3%)

两条值得记住的物理事实——

  1. layers.py 1263 行 + fully_sharded_layers.py 335 行 = 1598 行专门给 LoRA 层 wrapper——是整个目录 27%——因为 vLLM 要为每种基础 layer(ColumnParallelLinear / RowParallelLinear / VocabParallelEmbedding 等)写一个对应的 LoRA 包装层(layers.py 里十余个 *WithLoRA 类),其中 5 种 TP 并行线性层再各配一个 fully-sharded 变体(fully_sharded_layers.py 的 *WithShardedLoRA)——wrapper 数量随基座 layer 种类与 TP 切分方式一起增长——说明 LoRA 层集成才是工程上最重的一块,而不是 §16.3 重点讨论的 LoRARequest/LoRAModel 那种数据类
  2. punica 跨 3 种硬件后端——punica_wrapper/ 有 base / cpu / gpu / hpu 4 个文件(base 是抽象基类,另外三个是后端实现)——通过 punica_selector.py 运行时挑选——和 §14.6.5 实测 vllm/distributed/device_communicators/ 14 文件 2483 行的多后端模式同款——是 vLLM "核心算法 + 多硬件适配"的一致设计纪律;CPU/GPU/HPU 三个实现合计 348 + 289 + 144 = 781 行,而 punica_selector.py 全文只有 20 行,靠 current_platform.get_punica_wrapper() 返回的类名字符串做分发

串联 §15.10.5 vllm/multimodal/ 4738 行 + §14.6.5 device_communicators/ 2483 行 + 本节 vllm/lora/ 6027 行 = 约 13,200 行——是 vLLM 三大水平扩展能力(多模态 / 分布式 / LoRA)的真实工程量秤砣。

16.11 本章小结

LoRA 在 vLLM 里不是"一个 feature flag",而是一组贯穿请求、调度、worker、kernel 和缓存的机制:

  • 数学原理:权重更新低秩假设 ΔW=AB\Delta W = AB(vLLM 记法:A 下投影 shrink、B 上投影 expand);r≪dr \ll d 让参数量降到 1% 以下
  • 推理叠加:y=xW+s(xA)By = xW + s(xA)B 双路径;基座不变 + 小旁路
  • 经济学:共享基座、按需加载 adapter,容量账从 N * base_weight 变成 base_weight + N * adapter_weight + runtime state
  • 三层架构:LoRARequest 用户 API / LoRAModel+Manager 内存管理 / WorkerLoRAManager 执行侧协调
  • punica wrapper:用 token/prompt LoRA mapping 生成 LoRAKernelMeta 索引张量,把多 adapter 旁路交给后端 kernel;v0.8.5 的 CUDA 路径 prefill/decode 共用同一对 lora_shrink / lora_expand
  • 缓存与 slot:max_loras 控制单 batch LoRA 种类和 GPU slot,max_cpu_loras 控制 registered cache 容量,LRU 子类负责驱逐策略
  • 前缀缓存冲突:LoRA 破坏跨请求 KV 共享;extra_key 隔离;业务路由缓解
  • QLoRA 组合:量化基座 + FP16/BF16 LoRA,必须按目标模型和质量集验证
  • LoRA Resolver:抽象接口 + registry;S3 / HF Hub / 内部仓库需要业务侧注册具体 resolver
  • 四个局限:切换开销、prefix cache 下降、模型兼容性、rank 上限

一句话记忆:

LoRA 把"N 个完整权重副本"改成"1 个基座 + N 个低秩 adapter";vLLM 的价值,是把这个数学结构接到调度、缓存、worker 和 kernel 的在线推理链路里。


源码导航

  • LoRA 请求:vllm/lora/request.py
  • LoRA 模型:vllm/lora/models.py
  • LoRA 层:vllm/lora/layers.py
  • WorkerManager:vllm/lora/worker_manager.py
  • Resolver:vllm/lora/resolver.py
  • Punica kernels:vllm/lora/punica_wrapper/ + vllm/lora/ops/
  • V1 的 LoRA 集成:vllm/v1/worker/lora_model_runner_mixin.py

论文

  • Hu et al., "LoRA: Low-Rank Adaptation of Large Language Models", ICLR 2022 (arXiv:2106.09685)
  • Dettmers et al., "QLoRA: Efficient Finetuning of Quantized LLMs", NeurIPS 2023 (arXiv:2305.14314)
  • Chen et al., "Punica: Multi-Tenant LoRA Serving", MLSys 2024 (arXiv:2310.18547)
  • Sheng et al., "S-LoRA: Serving Thousands of Concurrent LoRA Adapters", 2023 (arXiv:2311.03285)