在大模型落地应用的过程中,一个愈发普遍的需求是:同一个基座模型(如 LLaMA-3-70B),需要同时服务数千个不同的下游任务。每个任务有自己的微调权重——客服对话、代码补全、文档摘要、多语言翻译……如果为每个任务都部署一个独立模型副本,显存开销将是灾难性的。LoRA(Low-Rank Adaptation)通过只训练低秩矩阵 A 和 B 来适配下游任务,将每个任务的额外参数压缩到几十 MB 级别。但问题来了:如何在一张 GPU 上高效地同时服务这些 LoRA 适配器?
这就是 S-LoRA(Scalable LoRA)和 Punica 等系统要解决的核心问题。本文将从 LoRA 的数学原理出发,深入剖析 S-LoRA 的架构设计、自定义 CUDA 内核实现、显存管理策略以及生产环境的部署经验。

一、LoRA 的数学基础与推理挑战
1.1 LoRA 的低秩分解原理
LoRA 的核心思想是:模型微调时的权重变化矩阵 ΔW 是低秩的。与其学习完整的 ΔW(与原始权重同尺寸),不如将其分解为两个小矩阵的乘积:
1
2
3
4
5
6
7 W' = W + ΔW = W + B × A
其中:
W ∈ R^(d×k) —— 原始冻结权重
A ∈ R^(r×k) —— 降维矩阵(rank r,通常 r=8,16,32,64)
B ∈ R^(d×r) —— 升维矩阵
r << min(d, k) —— 秩远小于原始维度
对于一个 4096×4096 的权重矩阵,如果 r=8,则 A 是 8×4096、B 是 4096×8,额外参数量从 1677 万骤降至 6.5 万——缩减了 256 倍。一个 70B 模型的全部 LoRA 适配器可能只有 20-80MB,而原始模型需要 140GB。
1.2 多租户推理的核心挑战
挑战不在于能不能加载 LoRA——单个适配器加载很简单。真正的难题是批量推理时,同一批请求可能涉及数十个不同的 LoRA 适配器。考虑以下场景:
- 请求 A → LoRA-客服-v3
- 请求 B → LoRA-代码补全-v2
- 请求 C → LoRA-多语言翻译
- 请求 D → LoRA-客服-v3(与 A 相同)
- 请求 E → LoRA-医疗问答-v1
朴素方案是为每个请求单独跑一次前向传播——但这样 GPU 利用率极低。理想方案是将这些请求组成一个 Batch,在一次前向传播中同时应用所有适配器。这就要求:
- 显存管理:数千个适配器无法全部驻留 GPU 显存,需要动态换入换出
- 计算融合:不同请求使用不同 A、B 矩阵,需要批量计算 W×x + B×A×x
- 调度策略:如何将使用相同适配器的请求分组,减少切换开销
二、S-LoRA 系统架构设计

2.1 分页式适配器管理(Paging-Inspired Design)
S-LoRA 借鉴了操作系统虚拟内存的分页机制和 vLLM 的 PagedAttention 思想,设计了一套统一的适配器存储池:
1
2
3
4
5
6
7
8
9 GPU 显存 (Unified Pool)
┌──────────────┐ ┌──────────────────────────┐
│ Base Model │ │ Adapter Pool (动态分配) │
│ (Frozen W) │ │ ┌─────┐ ┌─────┐ ┌────┐ │
│ │ │ │LoRA1│ │LoRA2│ │... │ │
│ │ │ │ A,B │ │ A,B │ │ │ │
│ │ │ └─────┘ └─────┘ └────┘ │
└──────────────┘ └──────────────────────────┘
常驻显存 按需加载 / LRU 淘汰
核心数据结构包括:
| 组件 | 存储位置 | 管理策略 | 说明 |
|---|---|---|---|
| Base Model 权重 W | GPU 显存(常驻) | 固定分配 | 所有请求共享,只读 |
| 活跃 LoRA 适配器 | GPU 显存(Adapter Pool) | LRU 缓存 | 最近使用的适配器优先保留 |
| 非活跃 LoRA 适配器 | CPU 内存 / SSD | 按需加载 | 请求到达时换入 GPU |
| 适配器索引表 | GPU 显存 | 哈希表 | adapter_id → 显存偏移量映射 |
2.2 请求调度与批处理
S-LoRA 的调度器需要同时考虑两个维度的批处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 def schedule_batch(requests, adapter_pool, max_batch_size):
# 1. 按适配器分组
adapter_groups = defaultdict(list)
for req in requests:
adapter_groups[req.adapter_id].append(req)
# 2. 检查适配器是否在 GPU 上
for adapter_id in adapter_groups:
if adapter_id not in adapter_pool.gpu_resident:
if adapter_pool.gpu_free_space < adapter_size:
adapter_pool.evict_lru()
adapter_pool.load_from_cpu(adapter_id)
# 3. 合并相同适配器的请求(减少切换)
batch = []
for adapter_id, reqs in adapter_groups.items():
batch.extend(reqs[:max_batch_size - len(batch)])
if len(batch) >= max_batch_size:
break
return batch
这个调度的关键洞察是:相同适配器的请求应该尽量在同一 Batch 中处理,因为可以复用已加载的 A、B 矩阵,避免反复切换。S-LoRA 的实验表明,当适配器复用率达到 30% 以上时,吞吐量提升可达 2-3 倍。
三、自定义 CUDA 内核:批量多 LoRA 计算
这是 S-LoRA 最核心的技术贡献。传统做法是对每个请求单独计算 LoRA 前向:
1 | y = W×x + B×A×x |
。但批量请求中不同请求有不同的 A、B,需要分组矩阵乘法。
3.1 Segmented GEMM(分段矩阵乘法)
S-LoRA 设计了两种自定义 CUDA 内核来解决批量多 LoRA 计算:
内核一:Segmented GEMM——将多个小矩阵乘法合并为一次大 GEMM 调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 // 伪代码:Segmented GEMM for multi-LoRA
// 输入: X = [x_1, x_2, ..., x_n] (拼接的输入)
// A_list = [A_1, A_2, ..., A_k] (k个适配器A矩阵)
// B_list = [B_1, B_2, ..., B_k]
// adapter_map = [adapter_idx_for_req_1, ...]
__global__ void segmented_lora_forward(
float* X, // [batch, in_dim]
float* A_list, // [num_adapters, rank, in_dim]
float* B_list, // [num_adapters, out_dim, rank]
int* adapter_map,// [batch] -> adapter index
float* Y, // [batch, out_dim] output
int batch, int in_dim, int out_dim, int rank
) {
// Step 1: 降维 tmp[i] = A[map[i]] * x[i] -> [rank]
// Step 2: 升维 delta[i] = B[map[i]] * tmp[i] -> [out_dim]
// Step 3: Y[i] = W * x[i] + delta[i]
// 关键优化:相同adapter的请求合并为一次GEMM
}
3.2 收集-分发(Gather-Scatter)模式
另一种实现思路是 Punica 提出的 gather-scatter 模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27 import torch
def multi_lora_forward_gather_scatter(
base_layer_output, # [batch, out_dim] - W*x 结果
hidden_states, # [batch, in_dim] - 输入 x
lora_A_weights, # [num_adapters, rank, in_dim]
lora_B_weights, # [num_adapters, out_dim, rank]
adapter_indices, # [batch] - 每个请求用哪个adapter
):
batch_size = hidden_states.shape[0]
# Step 1: Gather - 按adapter索引收集A矩阵
selected_A = lora_A_weights[adapter_indices] # [batch, rank, in_dim]
# Step 2: Batched GEMM - 降维
tmp = torch.bmm(selected_A, hidden_states.unsqueeze(-1)).squeeze(-1)
# tmp: [batch, rank]
# Step 3: Gather B 矩阵
selected_B = lora_B_weights[adapter_indices] # [batch, out_dim, rank]
# Step 4: Batched GEMM - 升维
delta = torch.bmm(selected_B, tmp.unsqueeze(-1)).squeeze(-1)
# delta: [batch, out_dim]
# Step 5: 合并基座输出和LoRA增量
return base_layer_output + delta
1 | torch.bmm |
(批量矩阵乘法)在这里是关键——它允许在同一次内核启动中处理多个不同的小矩阵乘法,避免了逐请求循环带来的内核启动开销。但
1 | bmm |
的问题是当请求间共享同一适配器时会有冗余计算。S-LoRA 的 Segmented GEMM 进一步优化了这种情况。
四、显存管理与吞吐量分析

4.1 显存占用模型
让我们做一个具体的显存计算。以 LLaMA-2-70B 为例:
1
2
3
4
5
6
7
8
9 基座模型显存(FP16):
70B params × 2 bytes = 140 GB
单个 LoRA 适配器(r=16, 所有 attention 层):
每层 Q/K/V/O 各有 A∈[16, 4096], B∈[4096, 16]
每层参数: 4 × (16×4096 + 4096×16) = 4 × 131072 = 524,288
80层总计: 80 × 524,288 × 2 bytes ≈ 80 MB
1000个适配器总计: 1000 × 80 MB = 80 GB
显然,1000 个适配器(80GB)无法与 140GB 的基座模型共存于单张 A100-80GB 上。S-LoRA 的解决方案是多层级存储:
| 层级 | 容量 | 延迟 | 存放内容 |
|---|---|---|---|
| GPU 显存 | 80 GB | ~0 ms | 基座模型 + ~50个活跃适配器 |
| CPU 内存 | 512 GB+ | ~5-10 ms | ~5000个非活跃适配器 |
| NVMe SSD | 数 TB | ~20-50 ms | 全量适配器冷存储 |
4.2 吞吐量基准
根据 S-LoRA 论文的实验数据(A100-80GB,LLaMA-2-70B,r=16):
| 方案 | 适配器数量 | 吞吐量 (tokens/s) | 显存利用 |
|---|---|---|---|
| 独立部署(每适配器一个副本) | 1 | 1,200 | 140GB/卡 |
| 顺序切换(朴素方案) | 100 | ~300 | 140GB + 8GB |
| S-LoRA(无缓存) | 100 | ~2,500 | 140GB + 8GB |
| S-LoRA(LRU 缓存,hit rate 70%) | 2,000 | ~3,100 | 140GB + 4GB |
关键发现:S-LoRA 在服务 2000 个适配器时,吞吐量反而高于只服务 100 个适配器的朴素方案——这得益于批量调度带来的 GPU 利用率提升和缓存命中率优化。
五、生产部署经验与最佳实践
5.1 适配器存储格式优化
在生产环境中,LoRA 适配器通常以 Safetensors 格式存储。为了加速加载,建议将适配器按使用频率分层存储:
1
2
3
4
5
6
7
8
9
10
11
12 # 推荐的适配器存储布局
/storage/lora_adapters/
├── hot/ # 高频使用,预加载到CPU内存
│ ├── customer_service_v3.safetensors
│ ├── code_completion_v2.safetensors
│ └── ...
├── warm/ # 中频使用,存储在NVMe
│ ├── medical_qa_v1.safetensors
│ └── ...
└── cold/ # 低频使用,可存储在对象存储
├── legacy_v1.safetensors
└── ...
5.2 连续批处理与 LoRA 的结合
将 S-LoRA 与 vLLM 的 Continuous Batching 结合时,需要额外考虑适配器切换对批处理的影响:
1
2
3
4
5
6
7
8
9
10
11
12 class LoRABatchScheduler:
def __init__(self, max_batch_size=32, max_adapters_per_batch=8):
self.max_batch_size = max_batch_size
self.max_adapters_per_batch = max_adapters_per_batch
def should_form_batch(self, waiting_queue):
if len(waiting_queue) >= self.max_batch_size:
return True
unique_adapters = set(r.adapter_id for r in waiting_queue)
if len(unique_adapters) > self.max_adapters_per_batch:
return False # 等待更多同适配器请求
return True
这里的
1 | max_adapters_per_batch |
是一个关键的调优参数。设得太小会导致批处理延迟增加(等待更多相同适配器的请求);设得太大会导致内核效率下降(太多不同的小矩阵乘法)。实测表明,8-16 个适配器 per batch 是多数场景的甜点。
5.3 适配器版本管理与灰度发布
在生产环境中,LoRA 适配器需要频繁更新。推荐使用版本化的适配器注册中心:
1
2
3
4
5
6
7
8
9
10 {
"adapter_id": "customer_service",
"version": "v3.2.1",
"status": "canary",
"traffic_ratio": 0.1,
"checksum": "sha256:...",
"storage_path": "/storage/lora_adapters/hot/cs_v3.2.1.safetensors",
"fallback_version": "v3.1.0",
"created_at": "2026-09-01T10:00:00Z"
}
当灰度版本的适配器出现异常(如输出质量下降、延迟飙升),调度器可以自动回退到
1 | fallback_version |
,实现无损发布。
六、与替代方案的对比
| 方案 | 显存效率 | 吞吐量 | 延迟 | 适用场景 |
|---|---|---|---|---|
| S-LoRA | ★★★★★ | ★★★★★ | 低 | 大规模多租户(100+适配器) |
| Punica | ★★★★☆ | ★★★★☆ | 低 | 中小规模多租户 |
| LoRAX | ★★★★☆ | ★★★☆☆ | 中 | 需要统一API网关的场景 |
| 独立部署 | ★☆☆☆☆ | ★★☆☆☆ | 低 | 单任务、高性能要求 |
| 全量微调部署 | ★☆☆☆☆ | ★★★☆☆ | 低 | 任务间差异极大 |
七、总结与展望
S-LoRA 的核心贡献在于证明了多 LoRA 服务可以接近无损地实现——在服务数千个适配器时仍能保持高吞吐量。其关键技术创新包括:
- 分页式显存管理:借鉴 OS 虚拟内存思想,实现适配器的按需加载和 LRU 淘汰
- 自定义 CUDA 内核:Segmented GEMM 和 gather-scatter 模式将多适配器计算融合为单次内核启动
- 智能批调度:按适配器分组减少切换开销,平衡延迟与吞吐
未来的发展方向包括:多层级适配器组合(将多个 LoRA 组合使用,如客服+多语言)、动态秩调整(根据任务复杂度自适应调整 r 值)、以及与 Speculative Decoding 的结合(用小模型快速生成,用 LoRA 适配的模型验证)。
对于正在构建大模型推理平台的团队来说,S-LoRA 提供了一个经过验证的工程范式:用低秩适配的数学优雅性,配合系统级的显存管理和内核优化,实现一个模型服务万物的愿景。这不仅是显存效率的胜利,更是 AI Infra 工程化思维的体现。
汤不热吧