微调需求爆发后,服务端面对的是「一个基座 + 成百上千个 LoRA 适配器」:每个租户有自己的微调权重,却共享同一个底座。逐适配器起一个实例会浪费显存,把适配器权重全量展开又装不下。S-LoRA 与 Punica 给出答案——让不同适配器在同一个 batch 里共存,用批量化的 LoRA 内核消除重复计算。本文讲透多 LoRA 服务的架构、内核、切换与调度。
前置:/ai-vllm-system/(vLLM 服务架构)、/ai-prefill-decode-continuous-batching/(连续批处理)、/ai-llm-quantization/(量化与压缩)。
目录
- 1. 多 LoRA 服务的问题定义
- 2. LoRA 原理回顾:低秩分解与等效权重
- 3. 多 LoRA 服务架构:单实例多适配器
- 4. S-LoRA 与 Punica:批量异构适配器内核
- 5. 适配器切换:动态加载与热更新
- 6. 批量调度:请求分组与吞吐优化
- 7. 显存管理:适配器池与基座共享
- 8. 生产实践与性能对比
- 9. 常见坑与排查
- 10. 速查表与一句话记忆
- 延伸阅读
1. 多 LoRA 服务的问题定义
先看清要解决的问题:一个基座,多个微调,如何高效共存。
场景:
□ 基座模型:Llama-3-8B(一份权重,约 16 GB FP16)
□ 适配器:1000 个租户各自的 LoRA(每个几十 MB ~ 几百 MB)
□ 请求:不同租户的请求混合到达
朴素方案及其代价:
□ 方案 A:每个适配器起一个实例
- 1000 份基座副本 → 显存爆炸(16 GB × 1000)
- 不可能
□ 方案 B:加载时把 LoRA 合并进基座
- 合并后是完整模型 → 每租户一份,还是爆炸
- 且无法共享基座的计算
□ 方案 C:动态切换(一次服务一个适配器)
- 切换有开销,吞吐低
- 无法同 batch 混合多租户
目标:
□ 共享基座权重与 KV Cache
□ 同 batch 内混合不同适配器
□ 适配器按需加载、LRU 淘汰
三个核心指标:
□ 显存:适配器池 + 基座 + KV Cache 的合计
□ 吞吐:每秒处理的 token 数(batch 越大越高)
□ 切换开销:换适配器时的加载/编译时间
工程要点:多 LoRA 服务的目标是「基座共享 + 适配器按需加载 + 同 batch 混合」。朴素方案要么显存爆炸(每租户一份基座),要么吞吐低下(逐适配器切换)。正解是保持基座唯一,把适配器当作「可插拔的小矩阵」,在算子级别应用。
2. LoRA 原理回顾:低秩分解与等效权重
理解多 LoRA 服务,先要理解 LoRA 在推理时到底改变了什么。
LoRA 的做法:
□ 冻结基座权重 W(d×k),不训练
□ 训练两个小矩阵 A(r×k)与 B(d×r),r << d
□ 前向:y = Wx + (alpha/r) · B·A·x
□ 参数量从 d×k 降到 (d+k)×r → 小几个数量级
推理时的两种形态:
□ 合并形态:W' = W + (alpha/r)·B·A
- 合并后是一个普通权重矩阵
- 推理零额外开销,但每适配器一份完整权重
□ 分离形态:保持 W 不动,额外算 B·A·x
- 基座可共享,适配器随时切换
- 代价是多一次小矩阵乘
# LoRA 前向的分离形态
def lora_forward(x, W, A, B, alpha, r):
base = x @ W.T # 基座计算(可共享、可批量化)
delta = (x @ A.T) @ B.T * (alpha / r) # 适配器增量
return base + delta
# 合并形态(部署时融合,推理零开销)
W_merged = W + (alpha / r) * (B @ A)
多 LoRA 的关键洞察:
□ 基座项 Wx 对同 batch 所有 token 相同 → 可一次大 GEMM 算完
□ 适配器项 B·A·x 依赖每 token 属于哪个适配器
□ 若把适配器项「分组批量化」→ 不同适配器也能同 batch
→ 这就是 S-LoRA / Punica 的核心
工程要点:LoRA 推理有「合并」与「分离」两种形态——合并后零开销但每适配器一份权重,分离则基座共享、适配器可切换。多 LoRA 服务的价值来自分离形态:基座项一次大 GEMM 共享,适配器项按租户分组批量化,从而在同 batch 内混合多个适配器。
3. 多 LoRA 服务架构:单实例多适配器
多 LoRA 服务的架构围绕「基座单例 + 适配器池」展开。
架构分层:
┌──────────────────────────────────────┐
│ 调度层:请求路由、适配器绑定、批组装 │
├──────────────────────────────────────┤
│ 适配器管理层:加载、缓存、LRU 淘汰 │
├──────────────────────────────────────┤
│ 内核层:基座 GEMM + 分组 LoRA 增量 │
├──────────────────────────────────────┤
│ 显存层:基座权重 + 适配器池 + KV Cache│
└──────────────────────────────────────┘
请求生命周期:
□ 1. 请求到达,带 adapter_id
□ 2. 调度器查适配器是否在显存池
- 在 → 直接绑定
- 不在 → 从 CPU/磁盘加载(可能触发 LRU 淘汰)
□ 3. 与基座一起加入当前 batch
□ 4. 前向:基座共享 GEMM + 按 adapter 分组增量
□ 5. 返回结果,适配器引用计数递减
适配器池的容量决策:
□ 基座权重:固定占用(如 16 GB)
□ KV Cache:随并发增长(最大头)
□ 适配器池:剩余显存决定能常驻多少个
□ 单个适配器(r=16, 8B 模型):约 20~100 MB
→ 40 GB 空闲可常驻数百个适配器
工程要点:多 LoRA 服务是「调度层 + 适配器管理 + 分组内核 + 显存池」的四层架构。请求带 adapter_id 到达,调度器负责绑定与淘汰,内核层用分组方式混合适配器。适配器池容量由「基座 + KV Cache」之外的剩余显存决定,单个适配器通常只有几十 MB。
4. S-LoRA 与 Punica:批量异构适配器内核
这两个系统解决了「同 batch 内不同适配器」的内核难题。
问题:batch 里有 3 个请求,分属 3 个适配器
□ 朴素做法:为每个适配器单独算 → 3 次小 GEMM,利用率低
□ 目标:合并成一次批处理,但每 token 用不同适配器
Punica 的做法(SGMV 内核):
□ Segmented Gather Matrix-Vector:分段聚集矩阵乘
□ 把 batch 按 adapter 分成若干段
□ 每段用对应适配器的 A/B 矩阵
□ 一次内核调用完成所有段 → 高利用率
□ 额外引入「共享内存中的适配器矩阵」避免重复加载
S-LoRA 的做法:
□ 统一分页(Unified Paging):
- 把不同适配器的权重放进统一的分页池
- 像 KV Cache 的 PagedAttention 一样管理
- 支持远超显存的适配器数量(按需换页)
□ 异构批处理:把不同适配器的请求打进同一 batch
□ 张量并行下的适配器分片
□ 用定制 CUDA 内核做 SGMV
对比:
维度 朴素多实例 Punica S-LoRA
基座共享 否 是 是
同 batch 混合 否 是 是
适配器超显存 不适用 部分 是(分页)
吞吐 低 高 高
实现复杂度 低 中 高
工程要点:Punica 用 SGMV 内核把「按适配器分段的矩阵乘」合并为一次调用,S-LoRA 进一步用统一分页把适配器当作可分页的 KV 一样管理,支持超出显存容量的适配器数量。两者的共同点是「基座共享 + 分组批量化」,让异构适配器在同 batch 内高效共存。
5. 适配器切换:动态加载与热更新
切换开销直接决定多租户服务的响应质量。
切换的三种时机:
□ 冷加载:首次请求某适配器 → 从磁盘/对象存储读入
- 开销最大(秒级,取决于文件大小与 IO)
□ 温加载:适配器在 CPU 内存、不在 GPU → H2D 拷贝
- 中等(毫秒级)
□ 热切换:适配器已在 GPU 池 → 仅绑定,无拷贝
- 近乎零开销
# 适配器热更新的常见模式
class AdapterPool:
def get(self, adapter_id):
if adapter_id in self.gpu_pool:
self.gpu_pool.touch(adapter_id) # LRU 更新
return self.gpu_pool[adapter_id]
if adapter_id in self.cpu_cache: # 温加载
w = self.cpu_cache[adapter_id]
return self._to_gpu(w, adapter_id)
w = self._load_from_disk(adapter_id) # 冷加载
self.cpu_cache[adapter_id] = w
return self._to_gpu(w, adapter_id)
降低切换开销的手段:
□ 预加载热点适配器(按历史访问频率)
□ 适配器量化(INT8/INT4 存储,加载更快、更省显存)
□ 异步加载:请求排队期间提前 H2D 拷贝
□ 版本管理:适配器更新时双缓冲,原子切换
□ 分组切换:批量请求同一适配器时只加载一次
工程要点:适配器切换分冷/温/热三档,热切换零拷贝、冷加载秒级。生产上靠「预加载热点 + 量化存储 + 异步 H2D + LRU 池」把绝大多数请求压到热切换。适配器版本更新要用双缓冲原子切换,避免请求打到半加载状态。
6. 批量调度:请求分组与吞吐优化
调度策略决定多 LoRA 服务的实际吞吐。
两种调度思路:
□ 按适配器分组(Adapter-Aware Batching):
- 同一适配器的请求优先组进同一 batch
- 减少 batch 内适配器数量 → 内核更高效
- 代价:可能牺牲公平性、增加排队
□ 按到达顺序(Continuous Batching):
- 沿用连续批处理,谁先到谁先算
- batch 内适配器多样 → 内核分段多
- 公平性好,但适配器切换频繁
折中策略:
□ 软分组:优先凑同适配器,但设最大等待时间
□ 适配器亲和:调度器感知哪些适配器已在 GPU 池
□ 优先级:付费租户的适配器优先常驻
□ 预热队列:预测即将到来的适配器,提前加载
吞吐影响因素:
□ batch 大小:越大吞吐越高(直到显存/KV 上限)
□ batch 内适配器数:越多分段越多,内核效率略降
□ 适配器命中率:热切换比例越高,停顿越少
□ 请求长度分布:长请求拉低整体吞吐(见连续批处理篇)
工程要点:多 LoRA 调度的核心张力是「分组效率 vs 公平性」——按适配器分组让内核更高效但可能饿死小租户,连续批处理公平但分段多。生产上多用「软分组 + 最大等待时间 + 适配器亲和 + 热点预热」的组合,并把适配器命中率当作第一监控指标。
7. 显存管理:适配器池与基座共享
显存是硬约束,需要精细的池化管理。
显存预算分配(示例,A100 80 GB):
□ 基座权重(FP16) : 16 GB
□ KV Cache : 40 GB(随并发动态)
□ 适配器池 : 16 GB(可常驻 ~200 个)
□ 激活与工作区 : 8 GB
□ 合计 : 80 GB
适配器池的淘汰策略:
□ LRU(最近最少使用):通用,适合访问局部性强的场景
□ LFU(最不经常使用):适合热点稳定的场景
□ 成本感知:大适配器优先淘汰(腾出更多空间)
□ 租户配额:每租户保留上限,防止单租户挤占
与 PagedAttention 的协同:
□ KV Cache 用分页管理(块状分配,消除碎片)
□ 适配器权重也可分页(S-LoRA 的统一分页)
□ 两者共享同一显存分配器 → 统一碎片治理
□ 页大小与适配器粒度要对齐,避免内部碎片
工程要点:多 LoRA 服务的显存预算按「基座 + KV Cache + 适配器池 + 工作区」四分。适配器池用 LRU/LFU + 租户配额淘汰,防止单租户挤占;S-LoRA 的统一分页让适配器与 KV Cache 共享分配器,把碎片治理统一起来。适配器量化是提高池容量的直接杠杆。
8. 生产实践与性能对比
把多 LoRA 服务落到生产,看数据与经验。
性能参考(Llama-3-8B,单 A100,FP16):
□ 单适配器基线吞吐 : 1.0x
□ 多 LoRA 同 batch(8 个): 约 0.9~0.95x(相对单适配器满载)
□ 朴素逐适配器切换 : 0.3~0.5x(切换开销大)
→ 多 LoRA 批处理接近「不损失」地服务多租户
生产要点:
□ 适配器命中率 > 90% 是健康线(否则停顿多)
□ 监控 P99 而非均值(冷加载的尖刺在尾部)
□ 适配器大小差异大 → 池化要按字节而非按个数
□ 上线前压测「混合租户 + 混合长度」的真实负载
□ 适配器版本灰度:新旧并存,逐步切流
典型框架支持:
□ vLLM : 原生多 LoRA(--enable-lora)
□ SGLang : LoRA 批处理支持
□ TGI : adapter 接口
□ LoRAX : 专为多 LoRA 设计(Punica 血统)
□ TensorRT-LLM: LoRA 插件
工程要点:多 LoRA 批处理能做到接近单适配器满载的吞吐,而逐适配器切换会掉到 0.3~0.5x。生产健康线是适配器命中率 > 90%、关注 P99 尾延迟、按字节池化、灰度切流。vLLM、SGLang、TGI、LoRAX、TensorRT-LLM 都提供不同程度的原生支持。
9. 常见坑与排查
多 LoRA 服务的坑集中在「适配器生命周期」与「批内异构」。
高频踩坑:
□ 适配器未做 LRU → 显存被冷适配器占满,热点被挤出去
□ 冷加载阻塞前向 → 首个请求超时(未异步预加载)
□ 混用不同 rank/alpha 的适配器 → 结果错误(元数据要随权重走)
□ 适配器与基座的 tokenizer 不一致 → 输出乱码
□ 批内适配器过多 → SGMV 分段过多,吞吐反降
□ 合并形态与分离形态结果不一致 → 数值精度差异未对齐
□ 适配器热更新时请求打到半加载权重 → 输出污染
□ 张量并行下适配器未同步分片 → 各卡结果不一致
排查清单:
□ 适配器命中率(热切换比例)
□ 每 batch 的适配器数量分布
□ 冷加载耗时分布(P50/P99)
□ 显存池占用曲线(是否抖动)
□ 同请求在单适配器实例与多 LoRA 服务的输出对比(正确性)
工程要点:多 LoRA 的坑集中在生命周期(LRU、预加载、热更新原子性)与批内异构(rank/alpha 元数据、tokenizer、TP 分片一致性)。排查先看适配器命中率与冷加载耗时分布,再用「单实例 vs 多 LoRA」的输出对比验证正确性,热更新必须双缓冲。
10. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 核心目标 | 基座共享 + 适配器按需加载 + 同 batch 混合 |
| LoRA 两种形态 | 合并(零开销、每份权重)与分离(共享基座、可切换) |
| 为什么能同 batch | 基座项共享大 GEMM,适配器项按租户分组 |
| Punica 做了什么 | SGMV 内核把分段矩阵乘合并为一次调用 |
| S-LoRA 做了什么 | 统一分页管理适配器,支持超显存容量 |
| 切换三档 | 冷加载(秒)、温加载(毫秒)、热切换(零拷贝) |
| 调度张力 | 分组效率 vs 公平性,用软分组 + 最大等待折中 |
| 显存四分 | 基座 + KV Cache + 适配器池 + 工作区 |
| 健康指标 | 适配器命中率 > 90%、P99 尾延迟 |
| 常见坑 | LRU 缺失、冷加载阻塞、元数据不一致、热更新污染 |
一句话记忆:多 LoRA 服务 = 基座单例共享 + 适配器池按需加载(LRU/量化)+ 分组批量内核(SGMV/统一分页)+ 软分组调度——让不同租户的适配器在同一个 batch 里共存,接近零损失地服务成百上千个微调变体。
延伸阅读
- /ai-vllm-system/ — vLLM 服务架构与 PagedAttention
- /ai-prefill-decode-continuous-batching/ — 连续批处理与调度
- /ai-llm-quantization/ — 适配器与基座量化
- /ai-distributed-inference-gpu-cluster/ — 分布式推理与 TP 分片
- /ai-inference-engine-comparison/ — 推理引擎能力对比
- /ai-triton-server/ — 模型服务与多模型管理
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。