《vLLM 内核探秘》完整目录
- 前言
- 第1章 架构总览
- 第2章 EngineCore:引擎的心脏
- 第3章 调度器:Token 的交通指挥
- 第4章 PagedAttention:虚拟内存的启示
- 第5章 KV Cache 管理:寸土寸金的显存
- 第6章 Worker 与 Executor:GPU 军团
- 第7章 模型加载与权重管理
- 第8章 前向计算与 CUDA Graph
- 第9章 采样与输出处理
- 第10章 前缀缓存:零开销的加速
- 第11章 分块预填充与混合批处理
- 第12章 投机解码:以小博大
- 第13章 量化引擎:精度与速度的平衡
- 第14章 张量并行与流水线并行
- 第15章 多模态推理
- 第16章 LoRA 适配器热切换(当前)
- 第17章 API 服务器与生产部署
- 第18章 设计模式与架构哲学
第16章 LoRA 适配器热切换
"Don't change the whole model --- just teach it a new trick."
:::tip 本章要点
- 理解 LoRA 在推理中的作用:一个基座模型服务多个任务
- 掌握 vLLM 的 LoRA 加载与管理机制
- 理解多 LoRA 并发服务的调度策略
- 认识 LoRA 对 KV Cache 和前缀缓存的影响 :::
16.1 一个基座,多个技能
LoRA(Low-Rank Adaptation)在训练中已经广泛使用。但它在推理时的价值同样巨大:
一个 70B 的基座模型加载一次就行(140 GB 显存)。当请求 A 需要客服场景时,加载 LoRA-A(几十 MB);请求 B 需要代码生成时,加载 LoRA-B。多个 LoRA 可以同时活跃,不同请求在同一步推理中使用不同的 LoRA。
Llama-2-70B
(140 GB)"] --> LA["+ LoRA A
客服(50 MB)"] BASE --> LB["+ LoRA B
代码(50 MB)"] BASE --> LC["+ LoRA C
翻译(50 MB)"] style BASE fill:#3b82f6,color:#fff,stroke:none style LA fill:#10b981,color:#fff,stroke:none style LB fill:#f59e0b,color:#fff,stroke:none style LC fill:#8b5cf6,color:#fff,stroke:none
16.2 LoRA 请求与加载
在 vLLM 中,每个请求可以通过 LoRARequest 指定使用哪个 LoRA:
python
from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest
llm = LLM(model="meta-llama/Llama-2-7b-hf", enable_lora=True)
# 请求 A 使用客服 LoRA
output_a = llm.generate(
"你好,我想退货",
lora_request=LoRARequest("customer-service", 1, "/path/to/lora-a"),
)
# 请求 B 使用代码 LoRA
output_b = llm.generate(
"write a quicksort in Python",
lora_request=LoRARequest("code-gen", 2, "/path/to/lora-b"),
)
源码 :
vllm/lora/models.py
vLLM 的 LoRA 模型表示定义在 LoRAModel(models.py:61)中:
python
# vllm/lora/models.py:61-87 (简化)
class LoRAModel(AdapterModel):
"""A LoRA fine-tuned model."""
def __init__(self, lora_model_id: int, rank: int,
loras: Dict[str, LoRALayerWeights], ...):
self.id = lora_model_id
self.rank = rank
self.loras = loras # module_name → LoRA weights (A, B matrices)
def clone(self, lora_model_id: int) -> "LoRAModel":
"""复制模型但共享底层张量------用于同一 LoRA 的多实例"""
return self.__class__(lora_model_id, rank=self.rank,
loras=self.loras.copy())
注意 clone 方法:它创建 LoRA 模型的浅拷贝,共享底层权重张量。这意味着同一个 LoRA 被多个请求使用时,GPU 上只有一份权重,而非 N 份。
LoRAModelManager(models.py:304)管理活跃 LoRA 的生命周期,LRUCacheLoRAModelManager(models.py:711)在此基础上添加了 LRU 缓存------当 LoRA 数量超过 GPU 容量时,最近最少使用的 LoRA 会被自动卸载。
WorkerLoRAManager(vllm/lora/worker_manager.py)负责 LoRA 的加载和管理。它的职责链条比想象中复杂:
加载流程
当一个请求携带了 LoRARequest,WorkerLoRAManager 需要确保对应的 LoRA 权重已经在 GPU 显存中就绪。加载分为三步:
本地路径 / S3 / Hub"] --> B["2. 加载到 CPU
safetensors 读取"] B --> C["3. 传输到 GPU
合并到模型层"] style A fill:#3b82f6,color:#fff,stroke:none style B fill:#8b5cf6,color:#fff,stroke:none style C fill:#10b981,color:#fff,stroke:none
步骤 1:定位权重 。LoRAResolver(vllm/lora/resolver.py)是一个可插拔的解析器,支持从本地路径、S3、HuggingFace Hub 等来源获取 LoRA 权重。默认的解析器直接读本地文件;企业部署中可以实现自定义解析器,从模型仓库或对象存储中动态拉取。
步骤 2:加载到 CPU 。LoRA 权重文件通常是 safetensors 格式,包含 A 矩阵和 B 矩阵。对于 rank=16 的 LoRA 应用于 Llama-2-7B 的所有注意力层,权重大小约为 4 × 32 × (4096 × 16 + 16 × 4096) × 2 bytes ≈ 33 MB------非常小。
步骤 3:传输到 GPU 。LoRA 权重被放置到 GPU 上的专用缓冲区。vLLM 预分配了一块 LoRA 权重缓冲区(大小由 --max-lora-rank 和 --max-loras 决定),不同的 LoRA 共享同一块缓冲区的不同"槽位"。
LRU 驱逐策略
GPU 上同时能容纳的 LoRA 数量有限(由 --max-loras 配置,默认 1)。当需要加载一个新 LoRA 但槽位已满时,WorkerLoRAManager 按 LRU(最近最少使用) 策略驱逐最久未被任何活跃请求使用的 LoRA。
最近使用: 2s 前"] S1["槽 1: LoRA-代码
最近使用: 30s 前"] S2["槽 2: LoRA-翻译
最近使用: 5s 前"] end NEW["新请求需要: LoRA-摘要"] --> |"驱逐最久未用"| S1 S1 --> |"替换为"| NEW2["槽 1: LoRA-摘要"] style S1 fill:#ef4444,color:#fff,stroke:none style NEW fill:#f59e0b,color:#fff,stroke:none style NEW2 fill:#10b981,color:#fff,stroke:none
驱逐的代价很低------只需要将新 LoRA 的权重覆盖到已释放的槽位。但如果请求模式频繁切换(每个请求都用不同的 LoRA),频繁的加载/驱逐会成为瓶颈。因此 --max-loras 应该设为实际同时活跃的 LoRA 数量,而非 LoRA 总数。
16.3 LoRA 的数学原理回顾
要理解 vLLM 对 LoRA 的工程处理,需要回顾 LoRA 的核心思想。
标准的微调会修改模型的全部权重 W。LoRA 的洞察是:微调过程中权重的变化 ΔW 是低秩的------它可以分解为两个小矩阵的乘积。
ini
原始: Y = X × W
LoRA: Y = X × (W + ΔW) = X × W + X × (A × B)
其中 W 是 [d, d] 的原始权重(如 d=4096),A 是 [d, r],B 是 [r, d],r 是秩(通常 8-64,远小于 d)。
推理时的计算 :LoRA 不需要真正修改 W。而是在前向传播中,将 LoRA 的贡献作为一个加法旁路叠加到原始输出上:
python
# 简化的 LoRA 前向传播
def forward_with_lora(x, W, A, B, scaling):
base_output = x @ W # 原始路径
lora_output = (x @ A) @ B # LoRA 旁路
return base_output + scaling * lora_output
这种设计有两个关键优势:
- 基座权重不变------多个 LoRA 共享同一份基座权重,切换 LoRA 只需要换 A 和 B 矩阵
- 计算开销极小 ------r 通常只有 16 或 32,
x @ A([batch, d] × [d, r])的计算量只有原始矩阵乘的 r/d ≈ 0.4%
16.4 LoRA 与 KV Cache
LoRA 修改了注意力层的权重,这意味着相同的 Prompt 在不同 LoRA 下产生不同的 KV Cache。
这对前缀缓存有直接影响:块的哈希必须包含 LoRA 标识作为 extra_key。使用 LoRA-A 的请求和使用 LoRA-B 的请求,即使 Prompt 完全相同,它们的 KV Cache 块也不能共享。
python
# BlockHash 构造中包含 LoRA 名
block_hash = hash(
parent_hash,
token_ids,
extra_keys=(lora_name, ...) # LoRA 名作为缓存隔离键
)
16.5 多 LoRA 并发
同一批次中可以包含使用不同 LoRA 的请求。GPU 内核通过批量索引处理:
ini
Batch = [req_A(LoRA-1), req_B(LoRA-2), req_C(LoRA-1), req_D(LoRA-3)]
lora_indices = [0, 1, 0, 2] # 每个请求对应的 LoRA 编号
注意力层在计算时,根据 lora_indices 选择对应的 LoRA 权重。这比为每个 LoRA 单独做一次前向传播高效得多。
批量 LoRA 的 GPU 内核
朴素的实现是为每个 LoRA 分别执行矩阵乘法。但如果批次中有 100 个请求使用了 3 个不同的 LoRA,就需要 3 次额外的矩阵乘法,效率不高。
vLLM 使用分组 GEMM(Grouped GEMM)------将同一 LoRA 的请求分在一组,一次内核调用处理一组。对于上面的例子,只需要 3 次小矩阵乘法(每次处理一组请求),配合基座模型的 1 次大矩阵乘法。
Y_base = X @ W
(所有请求一起)"] G1["LoRA-1 旁路
Y_lora1 = X[group1] @ A1 @ B1"] G2["LoRA-2 旁路
Y_lora2 = X[group2] @ A2 @ B2"] G3["LoRA-3 旁路
Y_lora3 = X[group3] @ A3 @ B3"] BASE --> MERGE["合并
Y[group_i] += scaling × Y_lora_i"] G1 --> MERGE G2 --> MERGE G3 --> MERGE end style BASE fill:#3b82f6,color:#fff,stroke:none style G1 fill:#10b981,color:#fff,stroke:none style G2 fill:#f59e0b,color:#fff,stroke:none style G3 fill:#8b5cf6,color:#fff,stroke:none
由于 LoRA 的 rank 很小(通常 16-64),旁路计算的 FLOPs 占比不到总计算量的 1%。批量 LoRA 的额外开销几乎可以忽略。
16.6 量化 LoRA(QLoRA)
vLLM 还支持量化 LoRA(QLoRA)------在量化的基座模型上应用 LoRA。这意味着基座模型用 INT4/FP8 节省显存,LoRA 的 A/B 矩阵保持 FP16/BF16 精度。
这种组合在多租户场景下非常有价值:基座模型量化到 4-bit 只占 35 GB(70B 参数),留出大量显存给 KV Cache 和多个 LoRA 适配器。一张 80 GB 的 A100 可以同时服务 10+ 个不同的 LoRA 任务。
16.7 生产部署建议
参数选择:
| 参数 | 说明 | 建议值 |
|---|---|---|
--enable-lora |
启用 LoRA 支持 | 需要时开启 |
--max-loras |
GPU 同时活跃的 LoRA 数 | 实际并发 LoRA 数,通常 2-8 |
--max-lora-rank |
最大 LoRA 秩 | 与训练时一致,通常 16-64 |
--lora-extra-vocab-size |
LoRA 额外词表大小 | 0(除非 LoRA 扩展了词表) |
显存规划 :LoRA 权重的显存占用 = max_loras × num_layers × 2 × (d × r + r × d) × dtype_size。对于 Llama-2-7B、rank=16、max_loras=4,约 130 MB------相对于模型权重本身(14 GB FP16)微不足道。
冷启动优化:如果 LoRA 权重在远程存储(S3)上,首次加载可能耗时数秒。建议在服务启动时预加载常用的 LoRA,或将 LoRA 权重存放在本地 SSD 上。
16.8 LoRA 推理的局限性
尽管 LoRA 在推理中非常有用,仍有几个需要注意的局限:
局限一:LoRA 切换有延迟。 虽然加载一个 LoRA 权重只需要几十毫秒(从本地 SSD),但在高 QPS 场景下,如果每个请求都使用不同的 LoRA,频繁的加载/驱逐会累积成可观的开销。解决方案是提高 --max-loras 让更多 LoRA 常驻 GPU,或者在业务层面做请求路由------将使用相同 LoRA 的请求分发到同一个 vLLM 实例。
局限二:LoRA 破坏了前缀缓存的效果。 因为不同 LoRA 的 KV Cache 不能共享(见 16.4),使用 10 个 LoRA 意味着系统提示的 KV Cache 需要维护 10 份副本。这大幅降低了前缀缓存的命中率。在 LoRA 数量很多的场景中,前缀缓存的收益可能被完全抵消。
局限三:不是所有模型都支持 LoRA 推理。 vLLM 的 LoRA 实现需要模型类显式支持------模型需要在并行 Linear 层中注入 LoRA 旁路。如果某个模型没有实现 LoRAModelRunnerMixin 相关的方法,就无法使用 LoRA。幸运的是,Llama、Mistral、Qwen 等主流模型都已支持。
局限四:LoRA 的 rank 受限。 --max-lora-rank 决定了 GPU 上预分配的 LoRA 缓冲区大小。如果某个 LoRA 的 rank 超过这个限制,会被拒绝加载。生产中需要确保所有 LoRA 的 rank 不超过配置值。
16.9 本章小结
- 一基座多任务------LoRA 让一个大模型同时服务多种场景
- 热切换 ------
WorkerLoRAManager管理 LoRA 的加载/卸载/切换 - KV Cache 隔离------不同 LoRA 的 KV Cache 通过 extra_key 隔离
- 批量并发------同一批次中不同请求可以使用不同的 LoRA
源码导航
- LoRA 管理:
vllm/lora/worker_manager.py- LoRA 请求:
vllm/lora/request.py- LoRA 解析器(远程加载):
vllm/lora/resolver.py