LLM大模型:推理优化-PTQ int8量化
前面介绍的推理优化方法都有缺陷:
- knowledge distillation:需要样本数据训练student,同时要大量算力更新student参数
- vLLM:通过page attention,减少显存浪费,充分利用碎片化的显存,并未减少算力
以上两种推理优化的方式都有缺陷,为了弥补上述缺陷,需要新的推理优化方式!
transformer架构中,不论是embedding,还是FFN,核心都是矩阵乘法,所以这里选个layer看看weight的分布,如下:
很明显的(-1,1)之间的正太分布,而且全是浮点数,除了0就没整数了,这就是weight的现状!面对大量的浮点数,怎么提速计算速度,并且减少显存占用了?既然存的都是浮点数,自然要想办法对浮点数进行优化了!
- 浮点数存储:fp32需要4byte、fp16需要2byte
- 浮点数计算:对阶、尾数运算、规格化、舍入处理、溢出判断
浮点数存储和计算怎么看都觉得”费事“!既然浮点数这么难缠,能不能用整数替代浮点数了?还有,如果不能用整体替代浮点数做运算,也找不到其他合适的数了,现在只能硬着头皮想办法换算成整数了!
- 整数存储:空间明显比浮点数少,普通整数只需要8bit
- 整数计算:整数计算直接XOR异或,根本不需要像浮点数那样经历5个阶段,速度快很多
又该怎么把浮点数映射转换成整数了?浮点数和整数完全是两种不同的数啊!先回顾一下:机器学习很重要的一个步骤就是矩阵乘法,为啥要用矩阵乘法了?用几何解释就是把向量通过矩阵乘法映射到新的空间,这个空间就是矩阵的列向量组成的!在新空间可以根据业务需求进一步做各种操作,最常见的就是分类和回归了!按照这个思路,就需要把浮点数映射到整数这边来计算了!使用整数的初衷是节约显存,所以整数的bit不能过多,否则就没意义了,不如直接使用fp16;所以整数最多只能用8bit,简称int8;int8还要表示正负数,所以整个范围就在-2^7~2^7之间,也就是-127~127之间!最核心的一步就是想办法把浮点数映射到这个范围内了,这类似的功能是不是很熟悉了?以前做传统机器学习,对于输入的特征数据,为了防止过拟合,提升泛化能力,大部分模型都要求做normalization,把输入的每个特征都压缩在某个特定的范围内(一般是-1到1之间),和这里的浮点数转到整数特定范围的应用场景刚好一样啊!
1、量化方式
(1)对称量化:找到一组float数据中最大的数,除以127,得到缩放比例scale,然后每个数都除以scale后四舍五入取整(这里是反量化后误差的根因),就得到整数的隐射了;反量化还原就乘以scale即可,举例如下:
用对称量化做矩阵乘法:输入x和权重w都做量化相乘,如下:可以直观感受到原始结果和量化结果之间的误差
(2)对称量化的精度不高,还有提升的需求。使用同样的scale思路,映射到0~255之间了?如下:反量化后误差明显比对称量化小
非对称量化矩阵乘法:量化结果相比对称量化,确实精确一些
2、量化的方式有了,那什么时候量化了?量化本质就是把浮点数映射成整数后做计算,所以只要涉及到浮点数运算的地方都可以量化后再计算,完成后通过反量化还原即可!从量化的环节看,分以下两种方式:
(1)训练后动态量化:
- 将训练好的模型权重量化为int8,并保存量化参数,
- 在模型推理时,对每一层输入的fp32激活值,动态进行进行量化为int8:
- 在每一层对量化后的int8权重和int8激活值进行计算。
- 在每一层输出时将结果反量化为fp32。
- 将fp32激活值传入到下一层。
流程示意如下:
这种方式有明显缺陷:
-
每一次推理每一层都要对输入统计量化参数,比较耗时;
-
每一层计算完都转化为fp32,存入显存,占用显存带宽
(2)训练后静态量化:针对训练后动态量化的缺陷,改进的思路如下:
- 每一次推理每一层都要对输入统计量化参数,比较耗时:用有代表性的输入数据跑一遍整个网络,通过统计得到每层大概得量化参数
- 每一层计算完都转化为fp32,存入显存,占用显存带宽:这一层的输出是下一层的输入,下一层还是要量化,不如在这层直接量化好再传给下一层,计算量是一样的,但是传输的数据量少了很多,节约带宽
具体操作方法:
- 将训练好的模型权重量化为int8,并保存量化参数。
- 校准(calibration): 选一些样本模拟推理,求出每层激活函数各自的scale、zero_point,后续每次推理时输入数据x都用这次得到的scale、zero_point,不用每次都重复计算scale、zero_point了,减少计算量
- 在每一层对量化后的int8权重和int8激活值进行十算。
- 在每一层输出时将结果反量化为fp32,同时根据校准产生的激活值量化参数,把激活值量化为int8,把量化参数放入量化后的激活值中;这里先反量化后再次量化,不是多此一举么?
- 神经网络或者说深度学习有多层,每层处理的语义是不同的,所以每层都要单独量化,不能直接用上一层的量化传递给下一层
- 每层的量化范围不同,直接把上层int8的结果输出给下一层,可能导致数值范围不匹配
- 所以每层输出先反量化,让层与层之间统一量纲(以浮点数为准),然后再次量化后传入下一层
- 将int8的激活值和它的量化参数传入到下一层。
流程示意如下:
3、huggingface的transformer库中也有可以直接使用量化框架:LLM.int8() 混合精度量化;在不同参数量的模型上,使用不同的量化位数,其准确率如下(原论文:https://arxiv.org/pdf/2208.07339):
参数超过6.7B时,LLM.int8()的准确率和原模型惊人地保持一致,并未降低,这个量化方法到底是怎么做的了?中国人有句古话:林子大了什么样的鸟都有!这话同样适用于LLM:一旦模型参数量达到数十亿级别,参数中就会出现一些离群点,也就是值比较大的维度;这些维度的值较大,应该是模型学到的比较重要的特征维度,俗称Emergent feathers。先看一个例子:原始特征有些权重比较大,经过量化后再反量化时,会让其他部分维度清零,信息严重丢失!如果强行忽略这些维度值较大的特征,其他维度倒是保持不变了,但重要的特征信息也没了,会严重影响下游的任务准确性!
现在面临的情况是:值较大的维度去也不是,不去也不是,怎么办?既然放在一起量化和反量化不行,要不然分而治之?把较小值的维度和较大值的维度分开处理试一试?比如下面的矩阵:
左边是weight,明显有outlier feature(原论文设置的阈值>=6),右边是input;按照以前的放在一起、胡子眉毛一把抓的量化思路,权重小的部分维度量化后可能就没了。新的处理方法如下:
- 从输入的隐含状态中,按列提取异常值 (即大于某个阈值的值)。
- 对 FP16 离群值矩阵和 Int8 非离群值矩阵分别作矩阵乘法。
- 反量化非离群值的矩阵乘结果并其与离群值矩阵乘结果相加,获得最终的 FP16 结果。
上述的weight中,第2列和第4列权重明显大,把这两列单独踢出来;input对应的2、4两行也单独踢出来,组成新矩阵,分别相乘,如下:
weight小的权重才量化,weight大的权重直接浮点数相乘就行了!我个人认为weight大的不用量化:
- weight大的特征肯定是少数,看看文章开头那张weight分布图呗!
- weight小的数量多,量化效果明显
注意:LLM.int8()虽然精度没降低,但因为要把矩阵拆开,分别计算,所以过程比较复杂,推理的效率和原模型比甚至是降低的!
使用的方式也简单,transformer包里面已经集成好了:事先安装好BitsAndBytesConfig就行:
quantization_config = BitsAndBytesConfig(load_in_8bit=True) model = AutoModelForCausalLM.from_pretrained( base_model, device_map=device, trust_remote_code=True, quantization_config=quantization_config, # torch_dtype=torch.float16 )
tips:经常做各种LLM的尝试,需要依赖各种第三方包。为了避免包或版本冲突,还是用虚拟环境吧,然后就是一路上各种缺包,各种补齐!
4、注意:上述quantization只是针对model本身优化的方法, 事实上,还有其他很多优化的方法,比如用torch.profiler查看整个流程中的耗时情况,比如下面这种:gpu的利用率只有5%,这种情况下做量化是没意义的!
还有这种:cpu往gpu拷贝数据时间很长,这时就要考虑让模型和数据从一开始就放在显存,而不是在内存和显存之间来回倒腾!
比如这样写:
import torch import torch.nn as nn import torch.optim as optim # 确定设备 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 定义一个简单的模型 class SimpleModel(nn.Module): def __init__(self): super(SimpleModel, self).__init__() self.fc = nn.Linear(10, 5) def forward(self, x): return self.fc(x) # 实例化模型并移到设备 model = SimpleModel().to(device) # 定义损失函数和优化器 criterion = nn.MSELoss() optimizer = optim.SGD(model.parameters(), lr=0.01) # 创建一些假数据和目标 inputs = torch.randn(20, 10) # 假设有20个样本,每个样本有10个特征 targets = torch.randn(20, 5) # 假设目标有5个特征 # 将数据移到设备 inputs = inputs.to(device) targets = targets.to(device) # 前向传播 outputs = model(inputs) # 计算损失 loss = criterion(outputs, targets) # 反向传播和优化 optimizer.zero_grad() loss.backward() optimizer.step() # 如果需要将结果移回 CPU,使用 .to('cpu') 或 .cpu() outputs_cpu = outputs.cpu()
首先确定了设备,然后将模型和数据都移动到了 GPU 上。在训练过程中,没有将数据从 GPU 移回 CPU,除非有必要(比如打印输出或进一步处理),这样可以避免不必要的数据倒腾,提高效率。
参考:
2、https://www.bilibili.com/video/BV1FH4y1c73W/?spm_id_from=333.788.recommend_more_video.4&vd_source=241a5bcb1c13e6828e519dd1f78f35b2 bitsandbytes、GPTQ、GGUF、AWQ
https://github.com/chunhuizhang/llm_inference_serving/blob/main/tutorials/quantization/qlora_gptq_gguf_awq.ipynb
https://github.com/chunhuizhang/llm_inference_serving/blob/main/tutorials/quantization/basics.ipynb
3、https://www.cnblogs.com/yilang/p/11277201.html 浮点数计算
4、https://huggingface.co/blog/zh/hf-bitsandbytes-integration 大规模 Transformer 模型 8 比特矩阵乘简介 - 基于 Hugging Face Transformers、Accelerate 以及 bitsandbytes