0%

写这篇博客的初衷是自己一直以来都在关注supervised finetuning,但对强化学习这一块一直都没有过多的涉猎,一方面是因为它是大模型技术模块里相对成本比较高的过程,还有一方面是我对强化学习没有系统性的学习,觉得有一丢丢的难理解,躺在list里的斯坦福的强化学习课程也一直搁浅,传送门: cs234n, 课程视频和PPT都是可以免费下载的。

我这篇博客主要是受llama2模型的paper的启发,觉得这篇文章在RLHF方面写的非常之细致,并且代码也进行了开源,可以对照代码进行学习. 移步这篇文章的3.2节。当然也有很多博客详细介绍了这篇文章的强化学习部分的细节,参考【LLM】Meta LLaMA 2中RLHF技术细节

首先RLHF包含了两个步骤,第一个就是训练一个reward modeling来对LM生成的回答进行打分,这个分数是一个数值型的数据;第二部分就是用这个RM去调整我们的LM,使得LM能output更符合人类期望的回答。也有作者将SFT放到了RLHF的第一阶段,比如A Survey of Large Language Models 的5.2.3节将RLHF分为了三阶段:

image-20231011143645416

不过我认为SFT还是隔离开讲比较好。

Reward Modeling

数据

prompt好准备,那么打分这个就要靠人来打分了,人打分有一定的主观臆测性,所以就换成了比较哪一种回答比较好,像LLAMA2的做法就是分了四个等级:significantly better, better, slightly better or negligibly better / unsure。

RM模型

hugging face blog 中有一段话:

这个过程中一个有趣的产物是目前成功的 RLHF 系统使用了和生成模型具有 不同 大小的 LM (例如 OpenAI 使用了 175B 的 LM 和 6B 的 RM,Anthropic 使用的 LM 和 RM 从 10B 到 52B 大小不等,DeepMind 使用了 70B 的 Chinchilla 模型分别作为 LM 和 RM) 。一种直觉是,偏好模型和生成模型需要具有类似的能力来理解提供给它们的文本

RM的训练

直观上理解我们现在有了prompts,也有了这些prompts的generation在我们的LM上的的generation的评分ranking,那么怎么来用这些数据训练呢?

就拿LLAMA2的做法来说,它用了一个和预训练模型一模一样的模型作为RM的初始模型,唯一不同的是将LM中用作预测下一个token的分类头替换成了另一个可以输出分值的回归头就像下面这样:

上图出自state of GPT。

loss的计算采用的是ouyang 2022的Training language models to follow instructions with human feedback 提出的计算方式:

image-20231011143044591

其中r是RM输出的标量值代表分值。不过llama2的做法在这个loss基础上加了一个margin,刚刚提到它在人工标注这些generation的时候分了四个档次,有的回答会比另一个对手super better,有的只是稍微好一点,所以这种“好的程度”可以在loss中区分出来,所以作者在loss的的计算里加了一个margin:

image-20231011143054960

super better的就用一个比较大的m值。

RL Fine-tuning

推荐阅读

最近框架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是专门为评估中文语言模型而设计的平台。