Llama系列作为为数不多的优质开源LLM,一直受到开发者们的追捧。在Hugging Face社区的文本生成模型中,几乎是「霸榜」的存在。
就在520这天,一位名叫Nishant Aklecha的开发者在推特上宣布了自己的一个开源项目,名为「从头开始实现Llama 3」。
这个项目详细到什么程度呢——
矩阵乘法、注意力头、位置编码等模块全部都拆开解释。
图片
而且项目全部用Jupyter Notebook写成,小白都可以直接上手运行。
堪比哈佛NLP小组曾经出品的「The Annotated Transformer」。
图片
https://nlp.seas.harvard.edu/annotated-transformer/
才一天多的时间,小哥发表的这篇推特已经有32万次阅读,甚至被Andrej Karpathy大佬亲自点赞——
「全部拆开解释之后,通过模块的嵌套以及互相调用,可以更清楚地看到模型到底做了什么。」
图片
项目也在GitHub上获得了4.6k星。
图片
项目地址:https://github.com/naklecha/llama3-from-scratch
那就让我们来看看作者是如何深入拆解Llama 3的。
下载并读取模型权重
首先需要从Meta官网下载模型权重文件,以便后续运行时使用。
图片
https://github.com/meta-llama/llama3/blob/main/README.md
下载后需要先读取权重文件中的变量名:
以及模型的配置信息:
根据以上输出,可以推断出模型架构的信息——
- 32个transformer层
- 每个多头注意力模块有32个注意力头
- 分词器的词汇量为128256
直接将模型配置信息存储到变量中,方便使用。
分词器与编码
那么就从语言模型的第一步——分词器开始,但是这一步并不需要我们自己手写。
Llama 3使用了GPT等大模型常用的BPE分词器,karpathy大佬之前就复现过一个最简版。
图片
https://github.com/karpathy/minbpe
除了Karapthy大佬复现的版本,OpenAI也开源了一个运行速度很快的分词器tiktoken。这两个随便挑,估计都比自己从头训练的要强。
图片
https://github.com/openai/tiktoken
有了分词器,下一步就是要把输入的文本切分为token。
再使用PyTorch内置的神经网络模块(torch.nn)将token转换为embedding,[17x1]的token维度变为[17x4096]。
此处应该是整个项目中唯一使用PyTorch内置模块的地方。而且,作者给出了温馨提示——记得经常打印一下张量维度,更容易理解。
之后再使用RMS对embedding进行归一化处理。这一步不会改变张量形状,只是归一化其中的数值,公式如下:
图片
模型配置中的norm_eps变量设置为1e-5,就是用在此处,防止rms值意外设置为0。
构建Transformer层
每一个Transformer层都需要经过如下步骤:
图片
由于是从头构建,我们只需要访问模型字典中第一层(layer.0)的权重。
先用刚才定义的rms_norm函数,结合模型权重,进行embedding的归一化处理。
多头注意力
查询向量
让我们先用一张图复习注意力机制的计算过程:
图片
如果从模型直接加载查询、键、值和输出的权重,我们会得到四个二维矩阵,形状分别为 [4096x4096]、[1024x4096]、[1024x4096]、[4096x4096]。
因为大模型考虑了注意力中乘法并行化的需求,压缩了矩阵维度。但是为了更清楚地展示机制,作者决定将这些矩阵都展开。
模型有32个注意力头,因此查询权重矩阵应该展开为[32x128x4096],其中128是查询向量的长度,4096是embedding的维度。
于是可以访问第一个注意力头的查询权重,维度是[128x4096]。
现在将查询权重与embedding相乘,就得到了查询矩阵,维度为[17x128],表示长度为17的句子,其中每个token都有维度为128的查询向量。
位置编码
由于注意力机制中对每个token没有序列「位置」的概念,第一个词和最后一个词在Q、K、V矩阵看来都是一样的,因此需要在查询向量中嵌入维度为[1x128]的位置编码。
位置编码有多种方法,Llama模型采用的是旋转位置编码(RoPE)。
图片
首先将查询向量两两分为一对,共有64对。
句子中在m位置的一对查询向量,旋转角度为m*(rope_theta),其中rope_theta也在模型的配置信息中。
经过以上操作后,我们构建了freq_cis矩阵,存储句子中每个位置的、对查询向量每个值的旋转角度。
图片
将每对查询向量转换为复数,之后进行与旋转角度进行点积操作。
这样我们就得到了旋转后的查询向量,需要再转换回实数形式。
旋转后的查询向量,维度依旧是 [17x128]。
键向量
键向量的计算与查询向量非常类似,也需要进行旋转位置编码,只是维度有所差异。
键的权重数量仅为查询的1/4,因为需要减少模型计算量,每个权重值被4个注意力头共享。
因此这里第一个维度的值为8,而不是我们在查询权重中看到的32。
照着前面查询向量部分的计算流程,就可以得到句子中每个token的键向量了。
查询和键相乘
对句子进行「自注意力」的过程,就是将查询向量和键向量相乘,得到的QK矩阵中的每个值描述了对应位置token查询值和键值的相关程度。
图片
相乘后,我们会得到一个维度为[17x17]自注意力矩阵。
掩码
语言模型的学习目标,是根据句子之前的内容预测下一个token,因此训练和推理时需要将token位置之后的QK分数屏蔽。
图片
值向量
值权重数量和键权重一样,都是在4个注意力头之间共享(以节省计算量)。
之后我们获取第一层第一个注意力头的值权重,与句子embedding相乘,获取值向量。
注意力向量
图片
将进行过掩码的QK矩阵和句子的值向量相乘,就得到了注意力矩阵,维度为[17x128]。
多头注意力
以上得到的注意力矩阵,是第一层第一个注意力头的计算结果。
接下来需要运行一个循环,对第一层中所有32个注意力头进行上述运算过程。
为了并行计算的方便,我们需要把上面展开的矩阵压缩回去。
也就是将32个维度为[17x128]的注意力矩阵,压缩成一个维度为[17x4096]的大矩阵。
最后,别忘了乘以输出权重矩阵。
至此,注意力模块的计算就结束了。
相加与归一化
图片
对照这张Transformer层的架构图,在多头自注意力模块之后还需要完成一些运算。
首先将注意力模块的输出与原始的embedding相加。
之后进行RMS归一化。
前馈神经网络层
Llama 3的Transformer层中使用了SwiGLU前馈网络,这种架构非常擅长在必要情况下为模型添加非线性,这也是当今LLM中的常见操作。
SwiGLU与Vanilla两种前馈神经网络架构的对比
于是我们从模型中加载前馈网络的权重,并按照公式计算:
别忘了前馈层之后还有一次相加。
以上就是一个完整Transformer层的实现,最终输出的向量维度为[17x4096],相当于为句子中每个token重新计算了一个长度为4096的embedding向量。
预测下一个输出
之后的每一个Transformer层都会编码出越来越复杂的查询,直到最后一层的输出的embedding可以预测句子下一个token。
因此需要再嵌套一个外层循环,将Transformer层的流程重复32次。
最后一个Transformer层的输出维度与第一层相同,依旧是[17x4096]。
此时需要利用输出解码器,将最后一层输出的embedding先进行归一化处理,再转换为token。
输出的向量维度与分词器中词汇数量相同,每个值代表了下一个token的预测概率。
模型预测下一个词是42?
和《银河系漫游指南》的梦幻联动(不知道是不是作者故意设置成这样的)
至此,我们就完成了Llama 3对输入句子进行下一个token预测的全过程。