解密Prompt系列6. lora指令微调扣细节-请冷静,1个小时真不够~
Posted 风雨中的小七
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了解密Prompt系列6. lora指令微调扣细节-请冷静,1个小时真不够~相关的知识,希望对你有一定的参考价值。
上一章介绍了如何基于APE+SELF自动化构建指令微调样本。这一章咱就把微调跑起来,主要介绍以Lora为首的低参数微调原理,环境配置,微调代码,以及大模型训练中显存和耗时优化的相关技术细节
标题这样写是因为上周突然收到了一周内上线一版chatbo的命令,原因无它领导们都刷到了《一个小时你也可以拥有ChatGPT》,《100美金训练ChatGPT》,《仅训练3小时超越ChatGPT》,《人人都可以拥有ChatGPT》。。。领导说人人都有了为啥我没有呀?!!真诚呼吁标题党们求手下留情,留人一命!于是这里我换个标题来Debuff!Debuff!
看到这里本文最重要的部分已经说完了,累了的小伙伴可以撤退了,五一快乐~
低参数微调原理
- LORA:LORA: LOW-RANK ADAPTATION OF LARGE LANGUAGE MODELS
- 原理:INTRINSIC DIMENSIONALITY EXPLAINS THE EFFECTIVENESS
OF LANGUAGE MODEL FINE-TUNING- 前人的肩膀:Adapter: Parameter-Efficient Transfer Learning for NLP
我们之前在解密Prompt系列3. 冻结LM微调Prompt介绍过一些soft-prompt,包括P-Tunning和Prompt-Tunning也属于低参数微调。这些方案是通过参数拼接的方案引入额外参数。这里介绍另一类方案,同样是冻结LLM的参数,通过参数相加的方案引入额外参数, 相较soft-prompt最明显的优势,就是不会占用输入token的长度。
LoRA的原理比较简单,原始全量微调其实就是在原始模型参数上通过微调加入增量\\(W = W_0+\\Delta W\\),那我们可以通过冻结原始参数\\(W_0\\),并且把增量部分通过低秩分解方式进一步降低参数量级\\(\\Delta W=A*B^T\\),原始参数的维度是\\(d*d\\), 则低秩分解后的参数量级是\\(2*r*d\\),因为这里的r<<d,因此可以起到大幅降低微调参数量级的效果,如下图
核心代码如下
## 初始化低秩矩阵A和B
self.lora_A.update(nn.ModuleDict(adapter_name: nn.Linear(self.in_features, r, bias=False)))
self.lora_B.update(nn.ModuleDict(adapter_name: nn.Linear(r, self.out_features, bias=False)))
self.scaling[adapter_name] = lora_alpha / r
## 向前计算
result = F.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)
result += (
self.lora_B[self.active_adapter](
self.lora_A[self.active_adapter](self.lora_dropout[self.active_adapter](x))
)
* self.scaling[self.active_adapter]
)
论文测试了在多数场景下适当的LORA微调和全量微调的效果不相上下。一个可能原因是INTRINSIC DIMENSIONALITY论文中提出,虽然语言模型整体参数空间很大,但具体到每个任务其实有各自的隐表征空间(intrisic dimension),这个隐表征空间的维度并不高, 因此在微调过程中加入低秩分解并不一定会影响微调效果。使用LORA微调有以下几个细节
- 对哪些参数进行微调:基于Transformer结构,LORA只对每层的Self-Attention的部分进行微调,有\\(W_q, W_k, W_v, W_O\\)四个映射层参数可以进行微调。消融实验显示只微调\\(W_q\\)效果略差,微调\\(W_q, W_v\\)的效果和微调\\(W_q, W_k, W_v, W_O\\)的效果相似。需要注意不同模型参数名称不同,像chatglm对应的参数名称就是query_key_value
- Rank的选取:Rank的取值作者对比了1-64,效果上Rank在4-8之间最好,再高并没有效果提升。不过论文的实验是面向下游单一监督任务的,因此在指令微调上根据指令分布的广度,Rank选择还是需要在8以上的取值进行测试。
- alpha参数:alpha其实是个缩放参数,本质和learning rate相同,所以为了简化我默认让alpha=rank,只调整lr,这样可以简化超参
- 初始化:A和Linear层的权重相同Uniform初始化,B是zero初始化,这样最初的Lora权重为0。所以Lora参数是从头学起,并没有那么容易收敛。
Lora的优点很明显,低参数,适合小样本场景;可以拔插式的使用,快速针对不同下游任务训练不同的lora权重;完全没有推理延时,这个在后面代码中会提到推理时,可以预先把lora权重merge到原始权重上。
但Lora微调虽好,个人在尝试中感受到的局限性就是adapter类的微调方案可能更适合下游单一任务类型/生成风格。至于是否适合作为通用指令微调的解决方案,有个问题我也没有搞懂,就是通用的指令样本是否真的有统一的低秩空间表征?这个表征又是什么含义?因为指令微调阶段的样本其实是混合的多任务指令样本,这种情况下lora是否合适,感觉需要更全面的评估(当前出来的众多LLama们都缺少合理统一全面可比的Evaluation),当前就我们的尝试情况lora的效果并不及预期。
环境配置
我用了featurize和揽睿星舟。云服务厂商的选择主要看是否有jupyter,存储够大,下载快,能连git,有高配torch环境。这两家在众多小厂里脱颖而出,4090的卡一个小时也就3块钱,来来来盆友辛苦把推广费结一下~
强调下环境配置,想跑通微调,搞定环境你就成功了80%!运气好1分钟,运气差1天都在原地打转
- 实例环境:TRX4090 + py38 + torch2.0 + CUDA12
- python环境:主要坑在transforemrs和peft,几个相关issue包括:llama tokenizer special token有问题,peft adapter.bin微调不更新,Bug with fan_in_fan_out。我一个不差都踩中了。。。
# 以下配置可能会随时间变化,出了问题就去issue里面刨吧
# 要相信你不是唯一一个大冤种!
accelerate
appdirs
loralib
bitsandbytes
black
black[jupyter]
datasets
fire
transformers>=4.28.0
git+https://github.com/huggingface/peft.git
sentencepiece
gradio
wandb
cpm-kernel
模型初始化
以下代码主要整合自alpaca-lora和chatglm-finetune。其实lora微调的代码本身并不复杂,相反是如何加速大模型训练,降低显存占用的一些技巧大家可能不太熟悉。模型初始化代码如下,get_peft_model会初始化PeftModel把原模型作为base模型,并在各个self-attention层加入lora层,同时改写模型forward的计算方式。
主要说下load_in_8bit和prepare_model_for_int8_training,这里涉及到2个时间换空间的大模型显存压缩技巧。
from peft import get_peft_model, LoraConfig, prepare_model_for_int8_training, set_peft_model_state_dict
from transformers import AutoTokenizer, AutoModel
model = AutoModel.from_pretrained("THUDM/chatglm-6b", load_in_8bit=True, torch_dtype=torch.float16, trust_remote_code=True, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True)
model = prepare_model_for_int8_training(model)
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
inference_mode=False,
r=8,
lora_alpha=8,
lora_dropout=0.05,
)
model = get_peft_model(model, lora_config)
model.config.use_cache = False
模型显存占用分成两个部分,一部分是静态显存基本由模型参数量级决定,另一部分是动态显存在向前传播的过程中每个样本的每个神经元都会计算激活值并存储,用于向后传播时的梯度计算,这部分和batchsize以及参数量级相关。以下8bit量化优化的是静态显存,而梯度检查优化的是动态显存。
1. 8bit Quantization
from_pretrained中的load_in_8bit参数是bitsandbytes库赋予的能力,会把加载模型转化成混合8bit的量化模型,注意这里的8bit模型量化只用于模型推理,通过量化optimizer state降低训练时显存的时8bit优化器是另一个功能不要搞混哟~
模型量化本质是对浮点参数进行压缩的同时,降低压缩带来的误差。 8-bit quantization是把原始FP32(4字节)压缩到Int8(1字节)也就是1/4的显存占用。如上加载后会发现除lora层外的多数层被转化成int类型如下
当然压缩方式肯定不是直接四舍五入,那样会带来巨大的精度压缩损失。常见的量化方案有absolute-maximum和zero-point,它们的差异只是rescale的方式不同,这里简单说下absmax,如下
先寻找tensor矩阵的绝对值的最大值,并计算最大值到127的缩放因子,然后使用该缩放因子对整个tensor进行缩放后,再round到整数。这样就把浮点数映射到了INT8,逆向回到float的原理相同。
当然以上的缩放方案依旧存在精度损失,以及当矩阵中存在outlier时,这个精度损失会被放大,例如当tensor中绝大部分取值在1以下,有几个值在100+,则缩放后,所有1以下的tensor信息都会被round抹去。因此LLM.int8()的实现对outlier做了进一步的优化,把outlier和非outlier的矩阵分开计算,再把结果进行合并来降低outlier对精度的影响。
prepare_model_for_int8_training是对在Lora微调中使用LLM.int8()进行了适配用来提高训练的稳定性,主要包括
- layer norm层保留FP32精度
- 输出层保留FP32精度保证解码时随机sample的差异性
2. gradient checkpoint
https://medium.com/tensorflow/fitting-larger-networks-into-memory-583e3c758ff9
prepare_model_for_int8_training函数还做了一件事就是设置gradient_checkpointing=True,这是另一个时间换空间的技巧。
gradient checkpoint的实现是在向前传播的过程中使用torch.no_grad()不去存储中间激活值,降低动态显存的占用。而只是保存输入和激活函数,当进行反向传播的时候,会重新获取输入和激活函数计算激活值用于梯度计算。因此向前传播会计算两遍,所以需要更多的训练时间。
use_cache设置为False,是因为和gradient checkpoint存在冲突。因为use_cache是对解码速度的优化,在解码器解码时,存储每一步输出的hidden-state用于下一步的输入,而因为开启了gradient checkpoint,中间激活值不会存储,因此use_cahe=False。其实#21737已经加入了参数检查,这里设置只是为了不输出warning。
模型训练
训练基本和常规训练基本相同,代码如下。主要说下模型存储和加载以及混合精度训练
import datasets
from transformers import Trainer, DataCollatorForSeq2Seq
if resume_from_checkpoint:
lora_weight = torch.load(ckpt_name)
set_peft_model_state_dict(model, lora_weight)
train_data = datasets.load_from_disk(dataset_path)
class ModifiedTrainer(Trainer):
def save_model(self, output_dir=None, _internal_call=False):
# 改写trainer的save_model,在checkpoint的时候只存lora权重
from transformers.trainer import TRAINING_ARGS_NAME
os.makedirs(output_dir, exist_ok=True)
torch.save(self.args, os.path.join(output_dir, TRAINING_ARGS_NAME))
saved_params =
k: v.to("cpu") for k, v in self.model.named_parameters() if v.requires_grad
torch.save(saved_params, os.path.join(output_dir, "adapter_model.bin"))
trainer = ModifiedTrainer(
model=model,
train_dataset=train_data,
args=transformers.TrainingArguments(
per_device_train_batch_size=8,
gradient_accumulation_steps=16,
num_train_epochs=10,
learning_rate=3e-4,
fp16=True,
logging_steps=10,
save_steps=200,
output_dir=output_dir
),
data_collator=DataCollatorForSeq2Seq(
tokenizer, pad_to_multiple_of=8, return_tensors="pt", padding=True
),
)
trainer.train()
model.save_pretrained(train_args.output_dir)
1. 模型的存储和加载
因为peftModel重写了原始model的save_pretrained函数,只把lora层的权重进行存储,因此model.save_pretrained只会存储lora权重。而trainer的save_model函数没有做相应的重写,因此我们重写下对应的function,避免checkpoint写入原始模型全部参数。
相应的如果你从ckpt加载lora权重去继续训练的话,也是对PeftModel中的Lora权重进行加载。
2. 混合精度训练
https://huggingface.co/docs/transformers/main/en/perf_train_gpu_one#fp16-training
除了默认的全精度FP32,参数精度还有半精度FP16,以及BF16和TF32。最常用也是这里使用的是FP16的混合精度。
实现原理是并非所有变量都需要全精度存储,如果把部分中间变量转化成半精度,则计算效率会大幅提升,加上一些GPU对FP16计算做了优化,吞吐上比全精度会快2~5倍。
不过只使用半精度训练同样会带来量化误差,主要包括:数据溢出因为半精度比全精度的范围更小,训练到后期因为梯度越来越小可能会下溢出;舍入误差梯度变小后,因为精度有限,导致梯度更新被四舍五入,更新了个寂寞。
为了解决以上的问题引入了混合精度训练。简单来说就是向前传递时,模型权重、激活值和梯度都使用FP16进行存储,同时会拷贝一份模型权重以FP32存储,向后传播optimizer更新时会更新FP32的参数。因此混合精度训练并不会节省内存,只会提高模型训练速度。
模型推理
推理有两个方案,一个和训练相同,直接加入Lora层,不过会增加推理延时因为多了lora层的计算,适合线下测评用,如下
from peft import PeftModel
from transformers import AutoModel, AutoTokenizer
model = AutoModel.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True, load_in_8bit=True, device_map=\'auto\')
tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True)
model = PeftModel.from_pretrained(model, "./lora_ckpt")
model.half().to(device)
model.eval()
另一个没有推理延时的方案,是先把lora权重和原始模型权重进行合并,把合并后的参数存储成新的bin文件,然后和加载常规模型一样加载合并后的模型参数进行推理。权重合并的代码如下
tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True)
# when merging disable int8
model = AutoModel.from_pretrained(
"THUDM/chatglm-6b", load_in_8bit=False, torch_dtype=torch.float16,
trust_remote_code=True, device_map="": "cpu",
)
## 用来检查权重是否合并成功,合并成功weight会改变
first_weight = model.base_model.layers[0].attention.query_key_value.weight
first_weight_old = first_weight.clone()
# 返回的不是新的模型,而是在原始模型上加了adapter层
lora_model = PeftModel.from_pretrained(
model,
"./lora_ckpt",
device_map="": "cpu",
torch_dtype=torch.float16,
)
# 报错:A*B shape mismatch,大概率是get_peft_model错误修改了peft_config里面的fan_in_fan_out参数,某个peft的revision有这个bug
lora_model = lora_model.merge_and_unload()
lora_model.train(False)
# 报错:大概率peft训练有问题,检查adapter.bin大小
assert not torch.allclose(first_weight_old, first_weight), \'Weight Should Change after Lora Merge\'
# lora模型权重把原模型权重加了prefix,这里移除恢复原始key
deloreanized_sd =
k.replace("base_model.model.", ""): v
for k, v in lora_model.state_dict().items()
if "lora" not in k
# 保存合并后的模型权重
lora_model.save_pretrained(output_dir, state_dict=deloreanized_sd)
更多Prompt相关论文·教程,开源数据·模型,以及AIGC相关玩法戳这里DecryptPrompt
Reference
- https://blog.csdn.net/anycall201/article/details/129959567
- 苏剑林. (Jun. 20, 2022). 《Ladder Side-Tuning:预训练模型的“过墙梯” 》[Blog post]. Retrieved from https://kexue.fm/archives/9138
- 苏剑林. (Apr. 17, 2023). 《梯度视角下的LoRA:简介、分析、猜测及推广 》[Blog post]. Retrieved from https://kexue.fm/archives/9590
4.https://github.com/huggingface/blog/blob/main/notebooks/HuggingFace_int8_demo.ipynb - ChatGLM-Finetune
- Alpaca-lora
BAT 解密:配置中心服务中心异步技术细节
在系列文章的第二篇文章《 BAT解密(二):聊聊业务如何驱动技术发展 》中我们深入分析了互联网业务发展的一个特点:复杂性越来越高。复杂性增加的典型现象就是系统越来越多,当系统的数量增加到一定的程度,就由复杂度量变带来了复杂度的质变,主要体现在系统间相互依赖程度加深。下面是BAT解密系列的前三篇文章:
比如说为了完成A业务系统,可能需要B、C、D、E等十几个其它系统进行合作。从数学的角度进行评估,可以发现系统间的依赖是指数级增长的,例如3个系统相互关联的路径为3条,6个系统相互关联的路径为15条。
服务层的主要目标,其实就是为了 降低系统间相互关联的复杂度 。
配置中心故名思议,就是集中管理各个系统的配置。
当系统数量不多的时候,我们一般采取各系统自己管理自己的配置,但系统数量多了以后,这样的处理方式有以下的问题:
-
某个功能上线的时候,要多个系统配合一起上线,分散配置的时候,配置检查、沟通协调需要耗费较多时间;
-
处理线上问题的时候,需要多个系统配合查询相关信息,分散配置的时候,操作效率很低,沟通协调也需要耗费较多时间;
-
各系统自己管理配置的时候,一般是通过文本编辑的方式修改,没有自动的校验机制,容易配置错误,而且很难发现。 例如:我们曾经遇到将IP地址的数字0误敲成了键盘的字母O,肉眼非常难发现,但程序检查其实就很容易;
实现配置中心主要就是为了解决以上这些问题,将配置中心做成通用的系统有如下的好处:
-
集中配置多个系统,操作效率高;
-
所有配置都在一个集中的地方,检查方便,协作效率高;
-
配置中心可以实现程序化的规则检查,避免常见的错误。 比如说检查最小值、最大值、是否IP地址、是否URL地址等等,都可以用正则表达式完成;
-
配置中心相当于备份了系统的配置,当某些情况下需要搭建新的环境时,能够快速搭建环境和恢复业务;
整机磁盘坏掉、机器主板坏掉。遇到这些不可恢复的故障时,基本上只能重新搭建新的环境,程序包肯定是已经有的,加上配置中心的配置,能够很快的搭建新的运行环境,恢复业务,否则几十个配置文件重新一个一个去vim修改,耗时很长,还很容易出错。
配置中心简单的设计如下,其中通过“系统标识 + host + port”来标识唯一一个系统运行实例,是常见的设计方法。
当我们的系统数量不多的时候,系统间的调用一般都是直接通过配置文件记录在各系统内部的,但当系统数量多了以后,这种方式就存在问题了。
比如说总共有10个系统依赖A系统的X接口,如果A系统实现了一个新接口Y,能够更好的提供原有X接口的功能,那么如果要让已有的10个系统都切换到Y接口,则这10个系统的几十上百台机器的配置都要修改然后重启,可想而知这个效率是很低的。
除此以外,如果A系统总共有20台机器,现在其中5台出故障了,其它系统如果是通过域名访问A系统,则域名缓存失效前,还是可能访问到这5台故障机器;如果其它系统通过IP访问A系统,那么A系统每次增加或者删除机器,其它所有10个系统的几十上百台机器都要同步修改,这样的协调工作量也是非常大的。
服务中心就是为了解决上面提到的跨系统依赖的“配置”和“调度”问题。
服务中心的实现一般来说有两种方式: 服务名字系统、服务总线系统 。
1)服务名字系统(Service Name System)
看到这个翻译,相信很多人都能立刻联想到DNS,即:Domain Name System。没错,两者的性质是基本类似的。
DNS是为了将域名解析为IP地址,主要原因是因为我们记不住太多的数字ip,域名就容易记住。
服务名字系统是为了将Service名称解析为“host + port + 接口名称”,但是和DNS一样,真正发起请求的还是请求方。
基本的设计如下:
2)服务总线系统(Service Bus System)
看到这个翻译,相信很多人也都能立刻联想到电脑的总线。没错,两者的本质也是基本类似的。
相比服务名字系统,服务总线系统更进一步了:由总线系统完成调用,服务请求方都不需要直接和服务提供方交互了。
基本的设计如下:
服务名字系统和服务总线系统简单对比如下:
服务总线系统 | 服务名字系统 | |
复杂度 | 设计更加复杂,要同时完成配置和调度功能,且本身高性能和高可用的设计也更加复杂 | 设计简单,基本类似一个服务配置中心,如果要做调度,需要提供独立的SDK包 |
可靠性 | 可靠性的关键,它故障后所有业务间的访问都故障,影响较大,但因为服务总线主要做调度,可以部署两套或者多套并行系统 | 仅仅保存配置,调用还是由服务请求方发起,可靠性要求没那么高,即使故障,各系统也可以使用本地缓存配置继续完成调用 |
灵活性 | 控制所有的调度和配置,可以做得非常灵活 | 仅仅有配置,即使提供独立的SDK支持调度,灵活性也要差一些,毕竟SDK只能拿到静态的配置信息 |
实时性 | 系统完成实际的调度,可以做到非常实时,例如某个服务及机器故障后立刻剔除故障节点 | 提供调度的SDK包,也需要定时更新配置,不能每次请求都去获取一下最新的配置,实时性一般,这个问题和DNS类似 |
可维护性 | 服务总线系统的修改和升级只需要自己完成即可 | 修改和升级大部分情况下要修改SDK包(例如调度算法变更),修改SDK包要求所有系统应用新SDK包才能生效 |
多语言支持 | 服务总线系统支持通用的HTTP和TCP协议,和语言无关 | 服务名字系统提供的SDK包需要适配多个语言,这个工作量也不小 |
消息队列 & 事件订阅
互联网业务的一个特点是“快”,这就要求很多业务处理采用异步的方式。例如大V发布一条微博后,系统需要发消息给关注的用户,我们不可能等到所有消息都发送给关注用户后再告诉大V说微博发布成功了,只能先让大V发布微博,然后再发消息给关注用户。
传统的异步通知方式是直接由消息生产者直接调用消息消费者提供的接口进行通知,但当业务变得庞大,子系统数量增多时,这样做会导致系统间交互非常复杂和难以管理,因为系统间互相依赖和调用, 整个系统的结构就像一张蜘蛛网 ,例如:
消息队列和事件订阅就是为了实现这种 跨系统异步通知 而提供的中间件系统。其中消息队列用于“一对一”通知,事件订阅用于“一对多”广播。如下图以微博为例,可以清晰的看到异步通知的实现和作用:
对比前面的蜘蛛网架构,我们可以清晰的看出引入消息队列或者事件订阅发布系统后的作用:
-
整体结构从网状结构变为线性结构, 清晰 ;
-
消息生产和消息消费解耦, 实现简单 ;
-
增加新的消息消费者,消息生产者完全不需要任何改动, 扩展方便 ;
-
事件订阅发布系统可以做高可用高性能,避免各业务子系统各自独立做一套, 节省工作量 ;
-
业务子系统只需要聚焦业务即可, 实现简单 ;
消息队列和事件订阅系统基本功能实现比较简单,但要做到 高性能、高可用、消息时序性、消息事务性 则比较难。
业界已经有很多成熟的开源实现,如果要求不高,基本拿来用即可。例如:淘宝的Notify、MetaQ、开源的Kafka、ActiveMQ等,但如果业务对消息的可靠性、时序、事务性要求较高时,要深入研究这些开源方案,否则很容易踩坑。
开源的用起来方便,但要改就很麻烦了。由于其相对比较简单,很多公司也会花费人力和时间重复造一个轮子,这样也有好处,因为可以根据自己的业务特点做快速的适配开发。
以上是关于解密Prompt系列6. lora指令微调扣细节-请冷静,1个小时真不够~的主要内容,如果未能解决你的问题,请参考以下文章
ChatGPT API接口使用+fine tune微调+prompt介绍
训练CV模型新思路来了:用NLP大火的Prompt替代微调,性能全面提升