AI 技术
#vLLM#LoRA#Punica#推理服务#批调度

vLLM 多 LoRA 适配器混批解码:Punica 内核、批内碎片与吞吐下降

在线服务同时托管多个 LoRA 适配器时,把不同适配器的请求混进同一批为什么反而拖慢解码?文章从 vLLM 的 LoRA 请求路由、Punica 分组内核与 max_loras 显存预留讲起,分析适配器数量增长带来的批内碎片,并给出可观测指标、失败模式与部署边界。

一个具体场景:三个适配器、一块 GPU

假设你在一台 A100 上部署了一个 7B 基础模型,同时对外提供三个 LoRA 适配器:客服话术、SQL 生成、合同摘要。三个适配器各自对应不同业务线,QPS 都不高。你希望用一套 vLLM 实例把它们全部托管,省下三份基础模型权重占用的显存。

上线后你发现一个反直觉的现象:把三个适配器的请求混在同一批里发送,单请求的解码速度比只跑一个适配器时慢。更奇怪的是,GPU 利用率并没有明显上升,显存却比预期多占了一大块。

这个现象不是配置错误。它来自 LoRA 在批处理中的数学结构:同一批里的每个 token 必须使用自己请求所属适配器的权重,而 GPU 上的矩阵乘希望一次处理尽可能多的 token。这两个要求存在张力,vLLM 的解法是分组批处理,代价是批内碎片。

基线方法为什么失效

逐请求串行应用适配器

最直观的实现是:对每个请求单独计算 W₀x + BAx,其中 W₀ 是基础模型权重,B 和 A 是 LoRA 的低秩矩阵。串行处理时,每个请求的矩阵乘规模都很小,GPU 的 SM 利用率上不去。

LoRA 的低秩结构决定了 BA 的参数量远小于 W₀,但计算 BAx 仍然需要一次完整的矩阵乘。如果每次只为一个请求做这件事,kernel 启动开销和访存延迟会占主导。

简单混批

另一种做法是把所有请求拼成一个大批,然后假设它们共享同一套 LoRA 权重。这在单适配器场景下成立,在多适配器场景下会算错:请求 A 的 token 会被请求 B 的适配器权重修改。

S-LoRA 论文(Sheng 等,arXiv:2311.03285)指出,朴素的 LoRA 服务支持在适配器数量增长时吞吐会显著下降。论文把这一问题的根源归为:不同适配器的权重无法像 KV Cache 那样被简单分页共享,需要专门的内存管理和 kernel 设计。

vLLM 如何把请求映射到适配器

LoRARequest 与适配器 ID

vLLM 的 LoRA 支持从请求入口就要求显式指定适配器。离线推理时通过 LoRARequest 传入,第一个参数是人类可读名称,第二个是全局唯一 ID,第三个是适配器路径。在线服务时,请求体里的 model 字段直接填适配器名称,例如 sql-lora。

服务启动时用 --lora-modules 注册适配器,可以写成 name=path,也可以写成 JSON 形式并附带 base_model_name。注册后 /v1/models 会同时列出基础模型和每个适配器,适配器的 parent 字段指向基础模型。

调度器的分组决策

调度器在构造 batch 时,会按适配器 ID 把请求分到不同的 LoRA 组。同一组内的请求共享一套 BA 权重,可以合并成一次较大的矩阵乘。不同组之间不能合并权重,只能各自计算再拼接。

资料没有给出 vLLM 调度器分组算法的具体实现细节,但可以确定的是:分组粒度受 max_loras 限制。这个参数决定单次前向最多能同时激活多少个适配器。如果一批里出现的适配器数量超过 max_loras,多出来的请求只能等下一批。

max_loras 与显存预留

max_loras 不只是并发上限,它还直接决定 GPU 上要预留多少 LoRA 权重槽位。vLLM 会按 max_loras × max_lora_rank 的规模预分配显存,用于存放当前激活的适配器权重。

这意味着:即使某一时刻只有一个适配器在跑,显存占用也按 max_loras 的上限计算。把 max_loras 从 1 调到 8,显存开销会成倍增加,而吞吐不一定成比例提升。

Punica 内核:分组矩阵乘与批内碎片

分组矩阵乘的直觉

Punica 的核心思路是:把同一批里属于不同适配器的 token 按适配器 ID 分组,对每组分别做 BAx,再把结果按原顺序写回。这样每个 token 用到的都是自己请求的适配器权重,同时每组内部仍然能利用较大的矩阵乘。

S-LoRA 论文提到,它使用了针对异构 LoRA 批处理优化的自定义 CUDA kernel,并配合张量并行策略。vLLM 的 LoRA 层实现里可以看到 column_parallel_linear.py、base_linear.py 等文件,说明适配器权重是在线性层内部按并行方式应用的。

批内碎片从哪里来

问题出在“分组”这个动作本身。假设一批有 32 个 token,分属 3 个适配器,分布是 20、8、4。Punica 会启动三次分组矩阵乘,规模分别是 20、8、4。

20 那一组还能用上不错的并行度,8 和 4 两组就明显偏小。GPU 的 SM 数量是固定的,小矩阵乘填不满,硬件利用率下降。这就是批内碎片:batch 总量看起来不小,但被适配器边界切碎后,每个子批的有效计算量不足。

适配器数量越多、每个适配器的请求越稀疏,碎片越严重。极端情况下,32 个 token 分属 32 个适配器,每个子批只有 1 个 token,Punica 退化成一堆标量操作,解码速度会明显低于单适配器混批。

为什么解码阶段比预填充更敏感

预填充阶段每个请求的 prompt 通常有几百到几千个 token,即使按适配器分组,每组也有足够的计算量。解码阶段每个请求每步只产生 1 个 token,分组后的子批规模直接等于该适配器在这一步的活跃请求数。

如果三个适配器的请求到达节奏错开,每个解码步里可能只有一两个适配器有活跃请求,其余适配器的子批为空。空子批不消耗计算,但调度和 kernel 启动的开销仍然存在。

请求从进入到解码的完整路径

下面这张图描述一个多适配器请求在 vLLM 里的流转。场景是三个适配器同时有请求到达,max_loras 设为 2。

flowchart TD
    A[请求到达 API 服务器] --> B{解析 model 字段}
    B -->|基础模型| C[归入基础模型组]
    B -->|sql-lora| D[归入适配器组 1]
    B -->|contract-lora| E[归入适配器组 2]
    B -->|kefu-lora| F[适配器组 3 超出 max_loras]
    C --> G[调度器构造 batch]
    D --> G
    E --> G
    F --> H[等待下一批]
    G --> I[Punica 按适配器分组矩阵乘]
    I --> J[各组结果按原顺序拼接]
    J --> K[采样与输出]
    H --> G

关键转折点在 F 和 H:第三个适配器的请求因为 max_loras=2 无法进入当前批,只能排队。如果该适配器的请求持续到达,排队延迟会累积,表现为该适配器的首 token 延迟(Time To First Token,TTFT)明显高于其他两个。

另一个转折点在 I:即使三个适配器都进了批,Punica 也要启动三次分组矩阵乘。如果某一组只有 1 个 token,这次 kernel 启动的收益很低。

适配器数量增长带来的三重代价

显存代价

max_loras 决定预留槽位,max_lora_rank 决定每个槽位的大小。两者相乘就是 LoRA 权重的显存上限。适配器数量增长时,如果不提高 max_loras,请求会被迫排队;如果提高,显存占用线性上升。

S-LoRA 论文提出的 Unified Paging 正是为了缓解这个问题:它用统一内存池管理不同 rank 的适配器权重和不同长度的 KV Cache,减少碎片。vLLM 的资料没有说明它是否采用了同样的统一分页机制,但 max_cpu_loras 参数的存在说明适配器权重可以在 CPU 和 GPU 之间换入换出。

吞吐代价

批内碎片直接降低每个解码步的有效计算量。GPU 时间被切成多个小 kernel,启动开销和访存延迟占比上升。适配器数量越多、请求分布越均匀,碎片越严重。

调度代价

调度器需要跟踪每个适配器的活跃请求数,并在每步重新分组。适配器数量增长时,调度器的数据结构变大,分组决策的复杂度上升。资料没有给出 vLLM 调度器的具体复杂度,但可以推断:当适配器数量远超 max_loras 时,调度器需要在“让当前批填满”和“公平对待所有适配器”之间做权衡。

与替代方案的对比

维度单适配器部署多适配器混批(vLLM + Punica)适配器合并
显存每实例一份基础模型权重,N 个适配器需要 N 份基础权重一份基础权重 + max_loras 份适配器槽位一份合并后的权重,参数量接近基础模型
吞吐单适配器内混批效率高,但实例间无法共享计算适配器少且请求密集时接近单适配器;适配器多且稀疏时碎片明显无碎片,等同于单模型服务
延迟各实例独立,互不干扰超出 max_loras 的请求排队,TTFT 上升无排队,但合并后模型变大,单步延迟可能上升
适配器隔离完全隔离共享基础模型,适配器权重按请求切换合并后无法单独卸载某个适配器
运维复杂度N 个实例,N 份配置一个实例,需要调 max_loras、max_lora_rank需要离线合并流程,适配器更新要重新合并
适用场景适配器数量少、每个适配器 QPS 高适配器数量中等、请求分布较均匀适配器数量少、更新频率低、对延迟敏感

表格里的“适配器合并”指把 LoRA 权重直接加到基础模型权重上,得到一个新的完整模型。这种做法消除了运行时切换适配器的开销,但失去了动态加载和卸载的能力。

生产环境要观察哪些信号

适配器级指标

按适配器维度拆分 TTFT 和每 token 延迟(Time Per Output Token,TPOT)。如果某个适配器的 TTFT 持续高于其他适配器,说明它经常因为 max_loras 限制被挤出当前批。

vLLM 的 /metrics 端点暴露了请求级指标,但资料没有明确说明是否按适配器维度拆分。工程上通常需要在 API 网关层按 model 字段做聚合,才能得到适配器级视图。

批内碎片指标

观察每个解码步的实际 batch size 与 max_num_seqs 的比值。如果 batch size 长期偏低,而队列里又有等待请求,说明碎片或 max_loras 限制在起作用。

另一个信号是 GPU 利用率与吞吐的背离:利用率高但吞吐低,通常意味着小 kernel 多、有效计算少。

显存指标

区分基础模型权重、KV Cache 和 LoRA 权重槽位三部分。max_loras 调高后,如果 LoRA 槽位占用显著上升但吞吐没有改善,说明瓶颈不在适配器并发数,而在请求分布或碎片。

常见失败模式

适配器数量超过 max_loras 导致饥饿

当注册的适配器数量远大于 max_loras 时,低频适配器的请求会反复排队。如果调度器没有公平性保证,这些适配器可能长期得不到服务。

max_lora_rank 设置过大

max_lora_rank 按所有适配器中的最大 rank 预留。如果只有一个适配器用了高 rank,其余都是低 rank,显存会被这个最大值拉高。

动态加载适配器带来的抖动

vLLM 支持通过 /v1/load_lora_adapter 和 /v1/unload_lora_adapter 在运行时管理适配器,但需要设置 VLLM_ALLOW_RUNTIME_LORA_UPDATING=True。资料明确提示这一功能在生产环境存在风险,因为用户可以参与适配器管理。加载新适配器时,如果 GPU 槽位已满,需要先卸载再加载,期间该适配器的请求会失败或排队。

多模态模型的适配器过滤

vLLM 的一个 issue(#29635)显示,多模态模型目前只支持给语言模型部分加 LoRA,视觉塔等其他模块的 LoRA 会被过滤掉。如果适配器训练时修改了视觉塔,部署到 vLLM 后行为会与训练时不一致。

适用边界与仍未解决的问题

多适配器混批适合适配器数量中等、请求分布较均匀、对显存敏感的场景。它的收益来自共享一份基础模型权重,代价是批内碎片和 max_loras 带来的排队。

如果适配器数量很少但每个适配器的 QPS 很高,单适配器部署或适配器合并可能更简单。如果适配器数量极多但每个都很稀疏,需要重新评估 max_loras、max_lora_rank 和 max_cpu_loras 的组合,或者考虑 S-LoRA 论文提出的统一分页思路。

资料没有回答的问题包括:vLLM 调度器在适配器数量远超 max_loras 时的公平性策略、Punica 分组矩阵乘在不同 rank 混合时的实际效率、以及适配器权重换入换出对解码延迟的量化影响。这些问题需要在具体部署中通过适配器级指标来验证。

资料来源

  1. S-LoRA 论文
  2. LoRA 适配器 | vLLM 中文站
  3. 2.3. vllm serving 参数 | vLLM 服务器参数 | Red Hat AI Inference Server | 3.1 | Red Hat Documentation
  4. [Bug]: 支持多模态加载lora的方式 #29635