0%

最近框架vLLM在LLaMA-13B以及7B模型上的推理速度相比较Tranformers有了质的提升。之前写过一篇大模型量化技术的文章,量化技术算是大模型出来初期大家使用的普遍比较多的方法之一,这里强调一点,我这里所说的模型加速是指在推理阶段我们让模型运行的更快,而且返回的结果要和原来的大参数模型差不多。这里重点强调的原因是我在看一些资料的时候发现有不少博客分享的是在模型训练阶段或者finetune阶段如何让模型训练的更快,这里就涉及到efficient finetuning的技术(p-tuning, prefix-tuning等),我这篇博客只关注模型训练完成之后如何在推理阶段让它更快,在同样时间内处理更多sequence(吞吐量througout),显存占用更低。大模型推理加速技术为什么这么受关注还是因为想在一些消费级别显卡上部署一个大模型为用户所用,而不是仅仅停留在实验室阶段。

我在看这个topic下的文章的时候,发现往往一些方法提出来有一些是减少了显存占用,有一些是提高了吞吐量(跟减少latency一回事),所以具体在实现时应用哪个办法加速你的模型推理还要根据实际情况去对比分析,或者你每个方法都尝试一下也行。当然又一些方法集成地很好,比如量化模型中的GPQT已经集成进transformers库,用起来很方便。如果碰到一些很复杂的,比如prune“剪枝”就有点难以快速验证。

vLLM 暂时还没有文章发出来,我在谷歌搜寻有没有review介绍大模型加速文章的时候也没找到很新的文章,不过找到了一篇微软在23年发布的LLMA, 我本来觉得思想类似,但是后来仔细看了下文章发现并不是一回事,我觉得文章标题有点误导人,起的太大了,本质上文章其实就是发现了decoding时候生成的句子和之前的句子有一部分的文字重叠,所以作者考虑这部分重叠内容其实不需要让模型再去decoding了,那就想了个办法,在decoding的时候把前面一步的结果保存下来,比较当前步骤和前一步骤token的差距,差距小的就不再进行计算了

一点不成熟的想法:这个文章思路可取,但创造力有限。

不过它在introduction章节介绍了四种比较通用的加速方法:quantization, pruning, compression and distillation,同样的分类也可以在A Comprehensive Survey on Model Quantization for Deep Neural Networks文章中找到, 不过两者介绍的有一点点的不同:

image-20230828093646830

Survey将四种技术统一包含在了模型压缩里。我觉得review里这种分类比较合理,因为微软这篇文章compression引用的文章是

Bert-of-theseus: Compressing bert by progressive module replacing, compression应该是一种统称。后面我看到知乎有一篇文章更详细的介绍了大模型的推理优化技术,它这个分类也符合我的理解,模型压缩(model compression)里包含模型量化,pruning,low-rank approximation和知识蒸馏这些技术。而且知乎这篇文章的分类也符合survey里的介绍:

In designing accelerators, researchers concentrate on network compression, parallel processing, and optimizing memory transfers for processing speed-up.

我这里做个思维导图总结一下:

image-20230830133030762

题外话,根据LLMA文章的意思,它提出的这种帮助reduce the serving cost的方式不属于上述任意一类,它认为以transformer为基础的生成模型,推理阶段主要消耗的时间瓶颈在autoregressive decoding。这里贴原文便于理解

While there are general methodologies that help reduce the serving cost of LLMs such as quantization(Dettmers & Zettlemoyer, 2023), pruning (Frantar & Alistarh, 2023), compression (Xu et al., 2020) and distillation (Wang et al., 2020), the inference efficiency bottleneck of these transformer-based generative models (e.g., GPT) is mainly associated with autoregressive decoding: at test time, output tokens must be decoded (sequentially) one by one, which poses significant challenges for the LLMs to be deployed at scale. 这里补充介绍一下AI模型中精度,你会在各种场合下碰到FP32,FP16,int8,int4等名词。

32-bit:也称全精度(Single precision),fp32, 采用32位(4字节)来对数据进行编码。能够表达的数据动态区间是 \[ 1.4 * 10^{-45} - 1.7 * 10 ^ {38} \]

Float_example

16-bit:半精度(half precision),fp16, 能够表达的数据动态区间是 \[ 6 * 10^{-8} - 65504 \]

BF-16: 也称为半精度,可以表达比FP16更大的数,但是精度比fp16差

image-20230830145435142

int8,int4顾名思义就是 8bit和4个bit来表示数字,int8的表达数值范围是 -128~127 why,无符号范围是0~255,int4的表达数值范围是-8~7。注意这里的计算方式和上面的浮点数可不一样,上面的浮点数中的8bits的exponent是指数表达,所以将指数那一部分的表达加和之后还要取2的指数,见具体计算. 再详细一点的介绍见hugging face 博客

模型压缩 model compression

Quatization 量化

参考文献

模型的量化可以分为两种方式:

  1. post-training quantization 模型训练好之后,将模型的参数们降低精度
  2. quantization-aware Training 在模型训练的过程中使用量化的方式,优点是比前者performance要好,但是需要更多的计算资源去训练

量化顾名思义要把原来用高精度表达的值映射到一个低精度的空间,目标呢就是让模型的performance不能有很大的降低。那如何映射和对哪一些值进行映射,这两个方向是现在量化方法的主攻方向。

很典型的LLM.int8()算法,不是大刀阔斧地对所有值一次性量化,也不是把矩阵中所有值一起量化,而是先找出那些离群值,然后对这些离群值再按照居正中行列来进行量化:

LLM.int8()

最重要的是上面那一部分。计算拆解见大规模 Transformer 模型 8 比特矩阵乘简介

GPTQ

读者可以自行阅读GPTQ的原文来了解它具体是如何做的,我喜欢找一些其他的文章来看别的作者是如何介绍自己的同行作品的,比如下面的这篇文章SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models 的第六章节related work里这样比较自己的smoothQuant和其他的量化模型方法的:

GPTQ (Frantar et al., 2022) applies quantization only to weights but not activations. GPTQ这种方法只对weights做了量化,并没有对激活值做量化(我个人认为虽然这是事实,但有点硬凹的意思,因为对activations做量化映射并不会加速很多)

LLM.int8() uses mixed int8/fp16 decomposition to address the activation outliers. However, such implementation leads to large latency overhead, which can be even slower than FP16 inference. 意思是LLM.int8()这种方法只是减少了显存占用,并没有减少推理延迟,说白了就是慢,runtime没提高

Sparsity

low-Rank Approximation

Lora

用lora的方式替换全参数微调大模型已经成为好多研究者的选择,一个是它的确有效的降低了训练参数的比例,第二个很大的原因是它的performance还不错,也就是只训练低秩的那些参数矩阵完全可以得到一个高质量的模型.

We propose Low-Rank Adaptation, or LoRA, which freezes the pretrained model weights and injects trainable rank decomposition matrices into each layer of the Transformer architecture, greatly reducing the number of trainable parameters for downstream tasks

从文章的介绍可以看出,它主要是应用于transformer架构中的layer中,这个layer包含self-attention,也包含MLP。只要有矩阵乘的地方都可以用lora。

lora图示

具体的在transformer中如何使用呢?我们知道transformer架构中涉及矩阵运算的地方:

image-20231228171113210

在上图中的self-attention中涉及四个weights矩阵:(Wq, Wk, Wv, Wo)。在feed-forward(lora原文中叫MLP,我觉得原因在于这里是有两个线性变换的),具体看attention is all you need 原文:

image-20231228171318056

所以每一个encoder的layer都有6个weights矩阵可以实施lora。lora的作者仅仅对attention weights做了lora,更简化的只是对其中的Wq和Wv做了lora变换。实现上由于现在有了peft库,只是几句话就能实现对这两个矩阵进行lora:

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
def create_peft_config(model):
from peft import (
get_peft_model,
LoraConfig,
TaskType,
prepare_model_for_int8_training,
)

peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
inference_mode=False,
r=8,
lora_alpha=32,
lora_dropout=0.05,
target_modules = ["q_proj", "v_proj"] # 这里仅仅对Q和V的变换矩阵做lora
)

# prepare int-8 model for training
model = prepare_model_for_int8_training(model)
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
return model, peft_config

# create peft config
model, lora_config = create_peft_config(model)

Knowledge Distillation

知识蒸馏出现的比较早,一开始也是在Bert上流行起来的。在LLM这种非常多参数的模型上用的不多。

这里罗列我看的对我理解很有帮助的文章:

Continuous Batching

参考文献:

vLLM

vllm这个包本质上是将显存GPU高效利用了(PagedAttention技术),还有上面提到的LLMA. 不过它们的思路其实大同小异,本质上是为了解决transformer的decoder在文本生成时自回归结构带来的无法并发的开销。推荐阅读为什么现在大家都在用 MQA 和 GQA?

图片

那么既然时间开销都在右边这个decoding的阶段,那就想办法解决它。那就是刚刚那篇文章介绍的KV Cache。作者提到的内存墙的问题也是这个问题的切入点,如何让计算单元更迅速的从存储单元获取数据,Paged Attention和Flash Attention都是来解决这个问题的。MQA的本质是减少了数据读取次数,第一次读取进来的K和V给所有的Q用,就放在缓存里。文章里详细讲解了MQA和GQA,这里不再赘述,但有一点值得注意的是,这两种办法再使用的时候可能并不能只是在推理的时候直接改变结构,也许要像作者说的那样:

如果要用 MQA 和 GQA,可以是从头训练的时候就加上,也可以像 GQA 论文里面一样,用已有的开源模型,挑一些头取个 mean 用来初始化 MQA 或 GQA 继续训练一段时间。

Text Generation Inference

Transformer结构优化

经典的Transformer架构出来之后,很多工作都在这个架构之上进行了魔改,希望能加快transformer的推理速度,推荐阅读survey Efficient Transformers: A Survey , 目前该review已经是第三版本,最新版本是2022年3月份出的,所以内容里没有flash Attention以及一些更新的技术,希望作者快快更新第四版本的review,技术迭代太快了,亟需大神肝的review总结。

大部分Transformer结构改进的方法的目标都是为了降低GPU的显存占用,也就是提高运算效率,将GPU的运算效率拉满,这就涉及到很底层的对于计算机系统结构的知识。

flash Attention

Flash Attention (Fast and Memory-Efficient Exact Attention)详细介绍可以阅读ELI5: FlashAttention,这是除了了multi-query attention技术之外用的比较多的加速推理的方式,当然Paged Attention算是vllm火起来之后的后起之秀。当然也可以阅读paper

attention on GPT-2

在medium的这篇博客里作者首先澄清两个概念,一个是FLOPs(每秒钟浮点运算次数)和IO,前者在GPU的更新换代的情况下获得了高速发展,而GPU的计算单元和显存间的通信却没有获得同样数量级的增长。而从上图可以看出来(原文paper中的),Attention的计算过程中大部分时间都是memory-bound导向的运算,而计算密集型的操作比如矩阵乘法其实只占了耗费时间的一小部分。

传统的attention计算步骤:

image-20230829162652549

注意这里的HBM是:

image-20230829162732067

最上面一层是GPU的缓存,中间是高带宽内存,可以理解为GPU的显存,也就是你去买显卡,标注在显卡上的存储,这部分存储会大一点,运算单元需要从这里拿数据到计算单元去计算,可以直接交互,也可以先存储在缓存SRAM内,缓存会比HBM快很多。

从标准的attention计算看到有很多不需要把计算中间结果写回HBM的环节。至于FlashAttention计算推导部分我看了上面的英文博客和从 FlashAttention 到 PagedAttention, 如何进一步优化 Attention 性能,还是没能理解,感兴趣的小伙伴还是自己去知乎这篇文章里好好看一下。

Paged Attention

FLAT Attention

并行 Parallel Processing

推荐阅读

最近在关注模型performance评估的问题,打算在这个主题上做一个整理,也是受到很多博客和文章的启发写这篇文章,所以就将所有推荐阅读的文章放在前面,感兴趣的小伙伴可以拓展阅读。

  1. 老刘说NLP 公众号中8.10发的一篇文章《如何让自己的大模型榜单评分更高》 这篇文章有点借鉴了hugging face的Open LLM 排行榜近况
  2. https://mp.weixin.qq.com/s/WiF3yRU5MZS7ulLTP8FIpw

首先说一下这个LLM榜单, 有四个benchmark,其中上面的博客就是重点讲了为什么同样一个模型比如LLaMA在MMLU上评测的结果会不如llama文章中提的效果,trick就在作者使用MMLU这个benchmark的方式有很大不同,这里来看看MMLU这个benchmark。

MMLU benchmark

首先看一下这个数据集到底是什么数据集,长什么样子,先给出文章中的定义:

MMLU (Massive Multitask Language Understanding) is a new benchmark designed to measure knowledge acquired during pretraining by evaluating models exclusively in zero-shot and few-shot settings.

这个评测集合里包含了57个学科,也就是57个task。原始的数据集长这样,里面的每个问题包含四个可能选项,且每个问题只有一个正确答案。:

image-20230816145305589

可以看到基本上就是question,answer的组织。注意这里看到原始数据的时候我还有点没看明白,作者的readme中也没写,还是对beginner有点不友好,第一列表示question,第二到第四列表示四个选项,最后一列是答案。所以可以看到原作者在evaluation的代码中这样处理的:

1
2
3
4
5
6
7
8
9
choices = ["A", "B", "C", "D"] # 首先定义选项

def eval(args, subject, engine, dev_df, test_df):
cors = []
all_probs = []
answers = choices[:test_df.shape[1]-2] # 对于每一个csv文件读取进来后取answers
...

label = test_df.iloc[i, test_df.shape[1]-1] # label这里其实是取得最后一列,也就是答案

但这个评测数据集在用来评测LLM的过程中衍生出了很多版本,基本是prompt的变化:

MMLU的不同实现

同样的问答对,比如上面的选择题,Harness没有指令,并且衍生的两个版本也就是helm和harness版本还加了Question这个前缀,harness在选线之前还加了Choices。就这么一点差距,就导致同一个llm的出来的分数不一样:

LLM在不同MMLU实现上的评分

关于如何使用这个benchmark,参考MMLU原始实现,作者写的是用chatgpt来产生答案,prompt为:prompt = "The following are multiple choice questions (with answers) about {}.\n\n".format(format_subject(subject))

这三种实现方式不仅prompt的形式不同,也就是上面提到的。并且它在计算F1score的时候的机制也不同。

  1. 原始实现

在原始实现中的评估的代码是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
for ans in answers:
try:
lprobs.append(c["choices"][0]["logprobs"]["top_logprobs"][-1][" {}".format(ans)]) # c是chatgpt的回答
except:
print("Warning: {} not found. Artificially adding log prob of -100.".format(ans))
lprobs.append(-100)
pred = {0: "A", 1: "B", 2: "C", 3: "D"}[np.argmax(lprobs)]
probs = softmax(np.array(lprobs))

cor = pred == label
cors.append(cor)
all_probs.append(probs)

该方法在评估的时候,仅仅比较了模型对四个选项字母的预测概率,哪个选项的概率高就选哪个,即便是在极端情况下四个选项的概率值都很低的情况下也会选择某个选项,但其实模型有时候会回答很多不相关的东西(都是很高的概率的token),所以这种方式有点”放水“,整体评估出来的分数会偏高。

  1. HELM实现

HELM实现是根据模型预测的下一个输出词元的概率来选择输出文本,并将生成的文本与正确答案的文本进行对比。这种方式有效避免了如果模型的答案中出现概率高的token不是选项中的任意一个,那么就会判为错误答案。

看了helm的代码仓库,着实有点丰富。内容很多我都没有找到在哪个文件里做的evaluation的计算,只知道了读取csv的地方。有好心的小伙伴可以私信我告诉我在哪里。

  1. harness实现

这是hugging face的llm榜单所用的实现。它不再是只是统计选项,而是连同选项字母以及后面的答案一起被考虑进来,计算的是整个序列的概率(获取每个词元的概率 (与上面其他实现一样) 并求它们的联合概率),那么很容易一些长文本的联合概率会比短文本的联合概率大,所以作者说可以在联合概率的基础上在做一个归一化,也就是用对数联合概率/ token数。

MMLU三种实现对于模型输出的总结

例如实现如下,基于GPT2计算句子联合概率的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data = [
"A multilayer perceptron (MLP) is a class of feedforward artificial neural network (ANN)",
"The term MLP is used ambiguously, sometimes loosely to any feedforward ANN, sometimes strictly to refer to networks composed of multiple layers of perceptrons (with threshold activation); see § Terminology",
'Multilayer perceptrons are sometimes colloquially referred to as "vanilla" neural networks, especially when they have a single hidden layer.[1]',
"An MLP consists of at least three layers of nodes: an input layer, a hidden layer and an output layer. Except for the input nodes, each node is a neuron that uses a nonlinear activation function.",
]
model = transformers.GPT2LMHeadModel.from_pretrained("gpt2")
tok = transformers.GPT2Tokenizer.from_pretrained("gpt2")
tgs = []
for dat in data:
random.seed(dat)
# print(model(tok.encode(dat, return_tensors="pt"))[0][0])
toks = tok.encode(dat, return_tensors="pt")
ind = random.randrange(len(toks[0]) - 1)
logits = F.log_softmax(model(toks)[0], dim=-1)[:, :-1] # [batch, seq, vocab]
res = torch.gather(logits, 2, toks[:, 1:].unsqueeze(-1)).squeeze(-1)[0]
tgs.append(float(res[ind:].sum()))

在“老刘说NLP”的博客中也提到了一点,就是上面的方式都是开源模型,所以很容易就能得到每一个token的预测概率,所以返回结果可以拆的这么细致来分析。如果是闭源模型只返回response的话,这时候就需要用正则的方式来抽取回答内容里的选项,比如CEVAL的测试方案:

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
28
29
30
31
32
33
34
def extract_cot_answer(self, line, gen_ans):
m = re.findall(r'所以答案是(.+?)。', gen_ans, re.M)
if len(m) > 0 and m[-1] in self.choices:
return m[-1], True
answer_patterns = [
r'([ABCD])是正确的',
r'选项([ABCD])正确',
r'答案为([ABCD])',
r'答案是([ABCD])',
r'答案([ABCD])',
r'选择([ABCD])',
r'答案:([ABCD])',
r'选择答案([ABCD])'
]
# RE extraction
for answer_pattern in answer_patterns:
m = re.search(answer_pattern, gen_ans, re.M)
if m:
answer = m.group(1)
return answer, False
# only containing one choice-character
m = re.findall(r'[ABCD]', gen_ans, re.M)
if len(m) == 1:
answer = m[0]
return answer, False
answer_word_counter = 0
# only containing one choice-context
for c in self.choices:
if str(line[f'{c}']) in gen_ans:
answer = c
answer_word_counter += 1
if answer_word_counter == 1:
return answer, False
return '-', False

对CLEVA评测平台感兴趣的可以看原文paper或者参考文章。原文说CLEVA是专门为评估中文语言模型而设计的平台。

近期中文大语言模型出现了好多产品,独领风骚的chatglm-6B确实表现很不错,所以大家就纷纷下场想做一个自己领域的大语言模型,最近看到一篇Lawyer LLaMA Technical Report,作者基于llama模型做了一个法律的语言模型。我觉得这篇文章值得想在垂直领域做语言模型的研究者参考,特别是它所采取的路径:

training process of lawyer LLaMA

也有博客粗略介绍了这篇文章。

我重点关注的是这个工作里的信息抽取:

image-20230630110134378

思路大概就是和斯坦福的dsp一个思路,用户问一个问题,第一种方式就是直接把这个问题抛给llm,让它去回答,第二种就是在把问题抛给llm之前先过一道retrival模型,这个模型会抽取一些和回答这个问题相关的一些context,将这些context加入prompt中,然后再抛给llm。这有一个专门的名词,在Demonstrate-Search-Predict: Composing retrieval and language models for knowledge-intensive NLP 中有所介绍: retrieve-then-read。有兴趣的可以再看一下斯坦福在做这部分工作时出的notebook介绍,看完你就知道为啥要retrieve一些context加入到prompt中去。

可惜的是laywer llama这篇文章并没有详细的介绍它的retrieve咋做的,文章中也是一笔带过,数据规模包括组织形式一概没说。我主要是参考斯坦福的dsp这框架中retrieve的实现,它是基于ColBERTv2做的抽取模块,但其实colbert预训练的模型是在微软的msmarco数据集上训练的,也就是没有中文,那必然对中文的适配度应该就不太行,所以我就想找找有咩有好心的博主写过自己用colbert在自己组织的语料库上训练的过程,发现没有,所以这就是我写这篇文章的动机啦,希望能给后面想用自己组建的数据集训练一个colbert做信息抽取的童鞋一点参考。

以下的内容参考colbert github

数据组织

模型使用

用官方训练好的checkpoints

参考https://github.com/stanford-futuredata/ColBERT/blob/main/docs/intro.ipynb

里面的输就LoTTE的url已经失效了,包括checkpoints的下载地址。可以到hugging face的仓库找

  • colbertv2.0 checkpoints : https://huggingface.co/colbert-ir/colbertv2.0/tree/main
  • lotte 数据集地址: https://huggingface.co/datasets/colbertv2/lotte/tree/main

注意上面这个lotte的数据集的组织方式和colbert官方github仓库里的介绍不一样,这一点上colbert2这个代码仓库的维护做的蛮糟糕的,比羊驼可差远了。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import os
import sys
sys.path.insert(0, '../')

# 下面这些包是colbert写好的类,可以导入预训练的checkpoints和你自己的querys,documents
from colbert.infra import Run, RunConfig, ColBERTConfig
from colbert.data import Queries, Collection
from colbert import Indexer, Searcher

dataroot = 'downloads/lotte' # 假设你已经把lotte放到downloads文件夹下
dataset = 'lifestyle'
datasplit = 'dev'

queries = os.path.join(dataroot, dataset, datasplit, 'questions.search.tsv') # questions.search.txv没找到
collection = os.path.join(dataroot, dataset, datasplit, 'collection.tsv') # collections.tsv也没找到

queries = Queries(path=queries)
collection = Collection(path=collection)

f'Loaded {len(queries)} queries and {len(collection):,} passages'

# indexing 主要用于对所有的document进行index
nbits = 2 # encode each dimension with 2 bits
doc_maxlen = 300 # truncate passages at 300 tokens

checkpoint = 'downloads/colbertv2.0'
index_name = f'{dataset}.{datasplit}.{nbits}bits'

with Run().context(RunConfig(nranks=4, experiment='notebook')): # nranks specifies the number of GPUs to use.
config = ColBERTConfig(doc_maxlen=doc_maxlen, nbits=nbits)

indexer = Indexer(checkpoint=checkpoint, config=config)
indexer.index(name=index_name, collection=collection, overwrite=True)

# search
with Run().context(RunConfig(experiment='notebook')):
searcher = Searcher(index=index_name)
query = queries[37] # or supply your own query

print(f"#> {query}")

# Find the top-3 passages for this query
results = searcher.search(query, k=3)

# Print out the top-k retrieved passages
for passage_id, passage_rank, passage_score in zip(*results):
print(f"\t [{passage_rank}] \t\t {passage_score:.1f} \t\t {searcher.collection[passage_id]}")

ok 偶然看到官方仓库昨天更新了intro的notebook,我看到里面下载数据集的仓库变化了,然后导入包的时候也写得更清晰了,加了条件:https://github.com/stanford-futuredata/ColBERT/blob/main/docs/intro2new.ipynb