多 LoRA 推理服务:S-LoRA、适配器切换与批量调度

一个基座模型要同时服务成百上千个微调变体,这是多租户 LLM 服务的核心挑战。S-LoRA 与 Punica 让不同适配器共享同一次批处理,把显存与吞吐同时优化。本文讲透 LoRA 推理的等效权重、多 LoRA 批处理内核、适配器动态切换与生产调度策略。

微调需求爆发后,服务端面对的是「一个基座 + 成百上千个 LoRA 适配器」:每个租户有自己的微调权重,却共享同一个底座。逐适配器起一个实例会浪费显存,把适配器权重全量展开又装不下。S-LoRA 与 Punica 给出答案——让不同适配器在同一个 batch 里共存,用批量化的 LoRA 内核消除重复计算。本文讲透多 LoRA 服务的架构、内核、切换与调度。

前置:/ai-vllm-system/(vLLM 服务架构)、/ai-prefill-decode-continuous-batching/(连续批处理)、/ai-llm-quantization/(量化与压缩)。

目录

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/ — 模型服务与多模型管理

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ai」更多文章

  1. GPU 共享与调度:MPS、MIG 与多租户隔离
  2. 异构推理硬件:ROCm、Intel 与国产 NPU 适配实践
  3. 前缀缓存与语义缓存:KV 复用与重复计算消除