写在前面:当base大模型在垂类任务上表现一般,需要少量参数微调时,流行的做法就是对其进行少量参数(LoRA)微调时。这样难免存在每个任务有个单独的adapter ,推理时需要把adapter与base model参数加到一起再推理。这样做的结果是,在推理时依然是一任务一模型,仿佛又回到了BERT时代,但是每个模型的参数却大得多,推理需要的显存更多。既然base模型一样,有没有什么方法能够节省推理的GPU显存,构造一个统一的推理模型,能够满足所有的业务场景呢?S-LoRA为我们提供了一种很好的工程上的解决方案。
0. 快速复习LoRA
论文:LORA : LOW-RANK ADAPTATION OF LARGE LANGUAGE MODELS
LoRA:Low-Rank Adaptation,LoRA冻结预训练模型权重并将可训练的秩分解矩阵注入到 Transformer 架构的每一层中,大大减少了下游任务的可训练参数的数量(推理参数不变)。它基于的假设就是,下游任务需要修改的特征空间是低秩的,即只要针对优化少量参数即可。类似是传统的Adapter思想,但是它的设计比较巧妙,就是用d×r和r×d(r远小于d,r一般又被称为秩)的参数矩阵A×B来做自适应学习的那部分(如下图所示),推理时只要跟base模型的d×d参数矩阵加起来就可以了,不会有额外的推理需求增加。但是这样做有个隐患就是,依然是一任务一模型。之前的解决方案是通过对参数矩阵的加减实现一个base模型加载多个adapter,但是这样依然不能实现在一个batch里同时使用多个lora。

1. S-LoRA解决了什么问题?
论文:S-LoRA: Serving Thousands of Concurrent LoRA Adapters
S-LoRA实现了同时用多个LoRA的Adapters并行推理,即1 base model + n Adapters(A×B的部分),相比于n models,在推理时就可以减少显存占用。
2. S-LoRA为什么能做到的?
S-LoRA在具体实现上有很多细致的设计,能够真正支持多Adapters并行化推理,又能尽量减少推理显存消耗,并减少相比一任务一模型的结构的中间的性能损失。论文主要从3大方面来解释它的做法的,分别是:推理请求的并行化处理、内存管理和张量并行。
2.1 请求的并行化处理
为了减少base model的数量,本文将base模型和adapter分开计算(如下图所示),而不是直接把参数合在一起了,是在分别计算后再把两个模型输出的结果加起来。
道理看起来简单,实际操作却暗含很多需要优化的问题:lora的每个adapter的秩不一定一样,就导致每个批次里请求的adapter参数矩阵大小不一,如何并行化处理?每批次请求用到的adapter都不一样,如何调度?等等问题

2.1.1 token-level的批处理调度方法
为了实现显存的尽可能高的利用率,这里的batch request调度采用了orca(Yu@OSDI2022, ORCA: A Distributed Serving System for Transformer-Based Generative Models)调度方法,实现token-level的迭代调度。详细讲解可以看作者自己的报告视频(一般的批处理调度-视频第6分钟左右; orca批处理调度-第8分钟左右):Orca: A Distributed Serving System for Transformer-Based Generative Models | USENIX
简要来说,就是orca构造了一个请求池,每并行处理完所有序列的一个token长度就把没结束生成的requests放回池子里,新来的requests也按照来的顺序放在一起,等下次生成再从请求池里调度不超过最大batch-size的请求进行处理。
2.1.2 对请求按照Adapter聚在一起
为了尽量降低推理显存占用,就需要在每个batch的请求里尽量用最少的Adapters,即尽量把相同Adapter的请求放在一个batch里,这样每次需要从内存中取出的adapter的数量就会比较少,减少并行推理时的显存。
2.1.3 准入控制
S-LoRA还介绍了它在请求高并发状态的处理方式。对于每个请求,S-LoRA会预先衡量这个请求在当前状态下的处理时延用户是否能够接受,不能被接受的话,就会被直接抛弃掉。如果一下来的请求过多,它会选择只处理时间顺序相对靠后的满足时延的请求。相比于超时才显示请求失败这种硬性门槛,可以提高响应效率,对于完不成的请求就会直接显示失败,不需要用户等了指定时间超时了才显示。
2.1.4 新的GPU算子
这一小节主要介绍的是S-LoRA采用的CUDA算子。论文中是放在了内存管理里介绍的,笔者认为这部分对于实现并行推理也很重要,就放在前面了。
为什么要用新的CUDA算子?原来我们在GPU中一般用到的矩阵计算算子是GEMM,GEMM是并行化处理矩阵的,我们在推理时,通过paddding把一个batch的所有序列填充到一样长度之后,每批次的输入,模型的各部分计算的矩阵大小是固定的,GEMM就可以实现各部分的并行计算。但是这样GPU的本身利用率就不高,再加上LoRA的Adapter的异构性(秩r的大小不一致),就导致原来的矩阵算子(GEMM)不能实现并行计算。于是,S-LoRA采用的是MBGMM和MBGMV算子,具体介绍读者可以参考下一段。
GEMM(通用矩阵乘法,动态计算图)->MBGMM(输入编码部分,sequence-level,triton)+MBGMV(解码部分,token-level,punica)

上图是Punica论文中的SGMV算子(即这里的MBGMV算子),相比于GEMM的固定矩阵相乘,这里是先把矩阵Gather到一起后,再相乘,即外层的Y+=X@W是一样的逻辑,但是内部的具体参数会随着请求对应的Adapter发生变化。
2.2 内存管理
S-LoRA的内存管理是延续了vLLM的Paged-Attention的页管理思想,用每个block table将逻辑地址和物理地址联系起来。S-LoRA就在Paged-Attention的基础上,在cache中除了Key和Value之外,还增加了对Adapter的参数的管理,如下图所示。

之所以可以这么设计,作者主要是认为LoRA的Adapters和Key&Value向量有着两点相似之处:
- 两者都是动态的,KV-cache是序列根据请求动态输入,请求结束就被销毁;adapter是每个请求如果用到了某个adapter就加载,下个batch没用到就移除;
- 两者的矩阵有一维是一样的,KV的矩阵形状是sequence-length×hidden-size,adapter的矩阵形状是rank-size×hidden-size,所以就算合在一起,也相对规整,可以减少显存碎片。
于是,增加Adapter weights之后的cache如下图所示。

此外,为了减少adapter weight从内存到显存的load时间,s-lora有个prefetch机制,就是会根据下一个batch的request中用到adapter,提前取出到cache里,减少loading的时间。
2.3 张量并行
为了在显存不够的情况下,实现能够在多GPU上并行推理,又尽量减少通信损失,作者提出了张量并行的方案(类Megatron-LM)如下图所示。

3. S-LoRA怎么用?
调用代码如下:github.com/vllm-projec... 通过代码可以看出,S-LoRA的调用很简单,处理base模型的常用参数温度等,只要在每个request的时候加上lora地址即可。目前仅支持LLaMa和mistral模型,在vLLM项目上是实验性集成,等待支持更多的大模型以及正式发布~