跳过正文

LLaMA 系列

·10474 字·21 分钟

1. LLaMA: Open and Efficient Foundation Language Models
#

1.1 前言
#

​ LLaMA是一个系列模型,模型参数量从7B到65B。在大部分的任务上,LLaMA-13B强于GPT-3(175B)。LLaMA-65B的性能,可以和最好的LM相媲美,如Chinchilla-70B 和 PaLM-540B。

​ 一般而言,模型越大,效果越好。如以 GPT-3 为代表的大语言模型在海量文本集合上训练,展示出了惊人的涌现能力以及零样本迁移和少样本学习能力。GPT-3 把模型的量级缩放到了 175B,也使得后面的研究工作继续去放大语言模型的量级。大家好像有一个共识,就是:模型参数量级的增加就会带来同样的性能提升。

​ 最近的 “Training Compute-Optimal Large Language Models” 这篇论文提出一种缩放定律 (Scaling Law):

训练大语言模型时,在计算成本达到最优情况下,模型大小和训练数据 (token) 的数量应该比例相等地缩放,即:如果模型的大小加倍,那么训练数据的数量也应该加倍。

​ 即当我们给定特定的计算成本预算的前提下,语言模型的最佳性能不仅仅可以通过设计较大的模型搭配小一点的数据集得到,也可以通过设计较小的模型配合大量的数据集得到。

​ 那么,相似成本训练 LLM,是大 LLM 配小数据训练,还是小 LLM 配大数据训练更好?

​ **缩放定律 ** 告诉我们对于给定的特定的计算成本预算,如何去匹配最优的模型和数据的大小。但是本文作者团队认为,这个功能只考虑了总体的计算成本,忽略了推理时候的成本。因为大部分社区用户其实没有训练 LLM 的资源,他们更多的是拿着训好的 LLM 来推理。在这种情况下,我们首选的模型应该不是训练最快的,而应该是推理最快的 LLM。呼应上题,本文认为答案就是:小 LLM 配大数据训练更好,因为小 LLM 推理更友好。

44.png

​ LLaMa 沿着小 LLM 配大数据训练的指导思想,训练了一系列性能强悍的语言模型,参数量从 7B 到 65B。例如,LLaMA-13B 比 GPT-3 小10倍,但是在大多数基准测试中都优于 GPT-3。大一点的 65B 的 LLaMa 模型也和 Chinchilla 或者 PaLM-540B 的性能相当。

​ 同时,LLaMa 模型只使用了公开数据集,开源之后可以复现。但是大多数现有的模型都依赖于不公开或未记录的数据完成训练。

1.2 预训练数据
#

1.2.1 数据集
#

​ LLaMa 预训练数据大约包含 1.4T tokens,对于绝大部分的训练数据,在训练期间模型只见到过1次,Wikipedia 和 Books 这两个数据集见过2次。

​ 如下图所示是 LLaMa 预训练数据的含量和分布,其中包含了 CommonCrawl 和 Books 等不同域的数据。

34.png

CommonCrawl (占 67%): 包含 2017 到 2020 的5个版本,预处理部分包含:删除重复数据,去除掉非英文的数据,并通过一个 n-gram 语言模型过滤掉低质量内容。

C4 (Colossal Clean Crawled Corpus 占 15%): 在探索性实验中,作者观察到使用不同的预处理 CommonCrawl 数据集可以提高性能,因此在预训练数据集中加了 C4。预处理部分包含:删除重复数据,过滤的方法有一些不同,主要依赖于启发式方法,例如标点符号的存在或网页中的单词和句子的数量。

Github (占 4.5%): 在 Github 中,作者只保留在 Apache、BSD 和 MIT 许可下的项目。此外,作者使用基于行长或字母数字字符比例的启发式方法过滤低质量文件,并使用正则表达式删除标题。最后使用重复数据删除。

Wikipedia (占 4.5%): 作者添加了 2022 年 6-8 月的 Wikipedia 数据集,包括 20 种语言,作者处理数据以删除超链接、评论和其他格式样板。

Gutenberg and Books3 (占 4.5%): 作者添加了两个书的数据集,分别是 Gutenberg 以及 ThePile (训练 LLM 的常用公开数据集) 中的 Book3 部分。处理数据时作者执行重复数据删除,删除内容重叠超过 90% 的书籍。

ArXiv (占 2.5%): 为了添加一些科学数据集,作者处理了 arXiv Latex 文件。作者删除了第一部分之前的所有内容,以及参考文献。还删除了 .tex 文件的评论,以及用户编写的内联扩展定义和宏,以增加论文之间的一致性。

Stack Exchange (占 2%): 作者添加了 Stack Exchange,这是一个涵盖各种领域的高质量问题和答案网站,范围从计算机科学到化学。作者从 28 个最大的网站保留数据,从文本中删除 HTML 标签并按分数对答案进行排序。

1.2.2 Tokenzier
#

​ 使用byte pair encoding (BPE) 算法,使用的是Sentence-Piece的实现。所有数字被拆分为单独的digit,所有未知的UTF-8 字符,回退到字节来进行分解。因此,LLaMA 可以通过byte 的方式,构造出很多不在 vocab 中的字符,从而也具有较好的多语言能力。

import os  # 导入 os 模块,用于操作系统功能,如文件路径
from logging import getLogger  # 从 logging 模块导入 getLogger,用于日志记录
from typing import List  # 从 typing 模块导入 List,用于指定列表类型的注释

from sentencepiece import SentencePieceProcessor  # 从 sentencepiece 导入 SentencePieceProcessor,用于文本分词和编码/解码


logger = getLogger()  # 获取一个日志记录器对象


class Tokenizer:
    """使用 SentencePiece 进行文本的分词和编码/解码。"""
    def __init__(self, model_path: str):
        """
        使用 SentencePiece 模型初始化 Tokenizer。

        参数:
            model_path (str): SentencePiece 模型文件的路径。
        """
        # 重新加载分词器
        assert os.path.isfile(model_path), model_path  # 断言模型路径是一个文件,如果不是则抛出异常
        self.sp_model = SentencePieceProcessor(model_file=model_path)  # 加载 SentencePiece 模型
        logger.info(f"Reloaded SentencePiece model from {model_path}")  # 记录日志,表示模型已重新加载

        # 设置开始(BOS)/结束(EOS)标记的 ID
        self.n_words: int = self.sp_model.vocab_size()  # 词汇表大小
        self.bos_id: int = self.sp_model.bos_id()  # 开始标记的 ID
        self.eos_id: int = self.sp_model.eos_id()  # 结束标记的 ID
        self.pad_id: int = self.sp_model.pad_id()  # 填充(PAD)标记的 ID
        logger.info(
            f"#words: {self.n_words} - BOS ID: {self.bos_id} - EOS ID: {self.eos_id}"
        )  # 记录词汇表大小和各标记的 ID
        assert self.sp_model.vocab_size() == self.sp_model.get_piece_size()  # 确保词汇表大小与分词器的大小一致

    def encode(self, s: str, bos: bool, eos: bool) -> List[int]:
        """
        将字符串编码成一个 token ID 列表。

        参数:
            s (str): 要编码的输入字符串。
            bos (bool): 是否在序列开始处添加开始标记。
            eos (bool): 是否在序列结束处添加结束标记。

        返回:
            List[int]: token ID 的列表。
        """
        assert type(s) is str  # 断言输入是字符串类型
        t = self.sp_model.encode(s)  # 使用 SentencePiece 模型编码字符串
        if bos:
            t = [self.bos_id] + t  # 如果需要,添加开始标记
        if eos:
            t = t + [self.eos_id]  # 如果需要,添加结束标记
        return t

    def decode(self, t: List[int]) -> str:
        """
        将 token ID 列表解码成字符串。

        参数:
            t (List[int]): 要解码的 token ID 列表。

        返回:
            str: 解码后的字符串。
        """
        return self.sp_model.decode(t)  # 使用 SentencePiece 模型解码 token ID 列表

1.2.3 BPE算法
#

​ **NLP中分词的概念如下:**执行分词的算法模型称为分词器(Tokenizer) ,划分好的一个个词称为 Token (为啥不直接叫 Word?接着往后看),这个过程称为 Tokenization

​ 我们将一个个的 token(可以理解为小片段)表示向量,我们分词的目的就是尽可能的让这些向量蕴含更多有用的信息,然后把这些向量输入到算法模型中。

​ 由于一篇文本的词往往太多了,为了方便算法模型训练,我们会选取出频率 (也可能是其它的权重)最高的若干个词组成一个词表(Vocabulary) 。

​ 我们知道,一门语言中,通常有几万到几十万量级的单词数。若使用这种编码方式(one-hot),在语言模型预测的时候需要在这个拥有几万个单词的列表上计算一个概率分布,那样的计算量是非常恐怖的,而且过大的token列表十分影响模型的预测准确度。在GPT-3提出以后,又增加了prompt的feature,其特点之一就是用户可以指定将源语言翻译成某一种语言。举个例子,若是我们输入:

English: Let’s have a drink tonight.

French:

模型就能输出一句与"Let’s have a drink tonight.“所对应的法语翻译。要是"French:“改成"Spanish:",那模型将输出对应的西班牙语翻译。

​ 随着模型集成的不同国家的语言越来越多,模型的词汇列表势必会增长到一个非常可怕的数量级,到时候该如何去处理它带来的矩阵内存占用和预测准确性问题呢?

​ 别急,有一种编码方式能大大减小token list,那就是即将介绍的Byte Pair Encoding(BPE)

BPE 最早由 Philip Gage 在 1994 年提出,用于数据压缩领域。其核心思想是通过迭代合并频率最高的字节对(byte pair),将原始数据压缩为更紧凑的表示。2015 年,Sennrich 等人将 BPE 引入 NLP,用于神经机器翻译(Neural Machine Translation, NMT),并将其适配为一种子词级别(subword-level)的分词方法。

​ BPE 的基本思想可以用一句话概括:从字符级别开始,通过统计频率最高的字符对或子词对,逐步构建一个词汇表,用于表示文本中的单词或子词单元。 这种方法既能保留词的语义信息,又能灵活处理未见过的新词,在深度学习模型中表现出色。

BPE 的工作原理与实现步骤

​ BPE 的实现分为两个主要阶段:训练阶段(构建词汇表)和应用阶段(分词)。以下是详细步骤:

1. 训练阶段:构建词汇表 **初始化:**输入一个大规模的语料库(corpus),例如一堆句子。对每个单词进行预分词,通常以字符为单位,并在每个单词末尾添加一个特殊标记(如 ),以区分词内字符和词间边界。例如,单词 “cat” 被初始化为 c a t 。 统计语料库中所有单词的初始表示及其出现频率。例如:

"low": l o w </w>, 5次
"lower": l o w e r </w>, 3次
"new": n e w </w>, 4次

统计字符对频率:

​ 遍历语料库,统计所有相邻字符对(或子词对)的出现频率。例如,在上面的例子中,可能会统计到:

l o: 8次5次来自 "low"3次来自 "lower"
o w: 8次5次来自 "low"3次来自 "lower"
w </w>: 9次5次来自 "low"4次来自 "new"

合并频率最高的字符对:

​ 选择频率最高的字符对进行合并。例如,假设 l o 是频率最高的对,则将其合并为 lo,更新语料库中的表示:

"low": lo w </w>, 5次
"lower": lo w e r </w>, 3次
"new": n e w </w>, 4次

迭代执行:

​ 重复步骤 2 和 3,合并频率最高的字符对,直到达到预定的词汇表大小(vocabulary size,例如 10,000)或迭代次数上限。每次合并都会生成新的子词单元。例如,下一次可能合并 lo w 为 low,最终词汇表可能包含:

[l, o, w, e, r, n, </w>, lo, low, new, ...]

输出词汇表:

​ 训练完成后,得到一个包含字符和子词的词汇表,用于后续的分词。 2. 应用阶段:分词 ​ 在应用阶段,BPE 使用训练好的词汇表将新输入的文本进行分词。具体步骤如下:

**单词拆分为字符:**对于输入单词(如 “lowest”),先将其拆分为字符序列并添加词尾标记:l o w e s t 。 贪心合并:

​ 根据训练阶段生成的词汇表,依次尝试合并字符对,优先选择词汇表中最长的子词单元。例如: ​ 检查 l o,发现 lo 在词汇表中,合并为 lo w e s t 。 ​ 检查 lo w,发现 low 在词汇表中,合并为 low e s t 。 ​ 检查 e s,不在词汇表中,继续检查 e s t,不在词汇表中,最终结果可能是 low e s t 。 ​ 输出子词序列:

​ 最终输出分词结果:[low, e, s, t],作为模型的输入 token。

​ 为了以最有效的方式构建语料库,BPE 在迭代的时候通过比较token的频率大小来穷尽每一种可能。所以,是的,它遵循一种贪婪的策略来尽可能取得最优的解决方案。

无论如何,BPE 是使用最广泛的sub-word tokenization算法之一。尽管贪婪,但它具有良好的性能!并被作为机器翻译等主流NLP任务的首选tokenize方法之一。

1.3 网络结构改进
#

​ 在LLaMa中使用了基于transformer的架构,并做了如下3点改进:

1.3.1 Pre-Normalization
#

​ 为了提高训练稳定性,LLaMa 对每个 Transformer 的子层的输入进行归一化,而不是对输出进行归一化。同时,使用 RMSNorm归一化函数。

​ Root Mean Square Normalization,是一种归一化技术,用于深度神经网络中,特别是在处理序列数据时(如在自然语言处理任务中)。这种技术的目的是通过调整网络层的输入来改善训练过程的稳定性和效率。

常规的 Layer Normalization

aiˉ=aiμσgi,  yi=f(aiˉ+bi) \bar{a_i}=\frac{a_i-\mu}{\sigma}g_i,\space\space y_i=f(\bar{a_i}+b_i)

​ 式中,gig_i 和 是 bib_i LN 的缩放系数和平移项,这两个参数都是可训练的,训练过程中网络会自动学习它们,以便在标准化之后仍能保留或恢复必要的信息。,μ\muσ\sigma 的计算如下式所示:

μ=1ni=1nai, σ=1ni=1n(aiμ)2 \mu=\frac{1}{n}\sum_{i=1}^na_i,\space \sigma=\sqrt{\frac{1}{n}\sum_{i=1}^n(a_i-\mu)^2}

**RMSNorm:**相当于是去掉了 μ\mu 这一项。

aiˉ=aiRMS(a)gi, where RMS(a)=1ni=1nai2 \bar{a_i}=\frac{a_i}{\text{RMS(a)}}g_i,\space \text{where} \space \text{RMS(a)}=\sqrt{\frac{1}{n}\sum_{i=1}^na_i^2}

看上去就这一点小小的改动,有什么作用呢?RMSNorm 的原始论文进行了一些不变性的分析和梯度上的分析。

和 LayerNorm 对比:

  • 计算复杂性RMSNorm 的计算通常比 LayerNorm 简单,因为它不涉及计算均值,这可以在减少约 7%∼64% 的计算时间。
  • 对于序列长度的适应性:两者都适用于处理序列数据,尤其是在自然语言处理中。但 RMSNorm 在处理非常长的序列时可能表现更好,因为它不依赖于均值的计算。
  • 稳定性和效率:两者都旨在提高训练过程的稳定性和效率,但它们在处理不同类型的数据集和网络结构时的表现可能有所不同

具体代码如下:

import torch
import torch.nn as nn

class RMSNorm(torch.nn.Module):
    # 定义一个继承自 torch.nn.Module 的 RMSNorm 类
    def __init__(self, dim: int, eps: float = 1e-6):
        """
        初始化 RMSNorm 归一化层。
        参数:
            dim (int): 输入张量的维度。
            eps (float, optional): 为了数值稳定性添加到分母的小值,默认为 1e-6。
        属性:
            eps (float): 为了数值稳定性添加到分母的小值。
            weight (nn.Parameter): 可学习的缩放参数。
        """
        super().__init__()  # 调用父类的初始化方法
        self.eps = eps  # 存储数值稳定性参数
        self.weight = nn.Parameter(torch.ones(dim))  # 初始化可学习的缩放参数

    def _norm(self, x):
        """
        对输入张量应用 RMSNorm 归一化。
        参数:
            x (torch.Tensor): 输入张量。
        返回:
            torch.Tensor: 归一化后的张量。
        """
        # 计算归一化值,使用均方根(RMS)作为分母
        return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)

    def forward(self, x):
        """
        RMSNorm 层的前向传播。
        参数:
            x (torch.Tensor): 输入张量。
        返回:
            torch.Tensor: 应用 RMSNorm 后的输出张量。
        """
        # 将输入张量转换为 float 类型,应用归一化,然后恢复原始类型
        output = self._norm(x.float()).type_as(x)
        # 应用可学习的缩放参数并返回结果
        return output * self.weight

1.3.2 SwiGLU
#

​ 具体各种激活函数的详细介绍请看 LLM 通用基础

LLaMa 使用 SwiGLU 激活函数替换 ReLU 非线性以提高性能,维度从 4d4d 变为 234d\frac{2}{3}4d,具体推导如下:

​ 原来的FFN有两个MLP层,这两个MLP层的参数量分别为:h×4hh \times 4h4h×h4h \times h,总的参数量为 8h28h^2

​ SwiGLU 的公式为:

FFNSwiGLU(x,W,V)=[Swish(xW)(xV)]W2 \text{FFN}_\text{SwiGLU}(x,W,V)=[\text{Swish}(xW) \otimes (xV)]W_2

​ 从上述公式中可以知道,矩阵 WW 与矩阵 VV 的维度是相同的,其作用是对输入向量 xx 进行升维;矩阵 W2W_2 的作用是将高维的隐向量还原到和输入向量 x 相同的维度。所以 WVW2W、V、W_2 这三个矩阵的维度分别为:(h,αh)(h,αh)(αh,h)(h,\alpha h)、(h,\alpha h)、(\alpha h,h),总的参数量为 3αh23\alpha h^2。为了保持和原始的 FFN 参数量相同,有:8h2=3αh28h^2=3\alpha h^2

​ 解得 α=83\alpha =\frac{8}{3},最终 WVW2W、V、W_2 这三个矩阵的维度分别为:(h,83h)(h,83h)(83h,h)(h,\frac{8}{3}h)、(h,\frac{8}{3}h)、(\frac{8}{3}h,h),可以很明显的看出严格按照该公式计算出来的不是整数,所以使用该公式计算出来的是模型真实维度的近似值。

​ 各个激活函数图像如下所示:

2.jpg

LLaMa中FFN的代码如下所示:

import torch
import torch.nn as nn
import torch.nn.functional as F

class FeedForward(nn.Module):
    def __init__(
        self,
        dim: int,          # 输入维度(等于 Transformer 中的 hidden_size)
        hidden_dim: int,   # FFN 隐藏层维度(原始 Transformer 通常为 4 × dim)
        multiple_of: int,  # 用于将隐藏层维度对齐到指定倍数(如 64 的倍数,以加速 GPU 计算)
    ):
        super().__init__()

        # ============================================================
        # Step 1: 调整隐藏层维度(LLaMA 特有)
        # 原始 Transformer 使用 hidden_dim = 4 × dim
        # LLaMA 使用 SwiGLU 激活,因此将维度缩小为原来的 2/3,
        # 即 hidden_dim = (2/3) × (4 × dim) ≈ 2.67 × dim
        # 这样可以在保持性能的同时减少参数量和计算量。
        # ============================================================
        hidden_dim = int(2 * hidden_dim / 3)

        # ============================================================
        # Step 2: 对齐 hidden_dim 至 multiple_of 的倍数
        # 例如 multiple_of = 64 时,将 hidden_dim 向上取整为 64 的倍数,
        # 便于 GPU 矩阵乘法在 CUDA Tensor Core 上高效执行。
        # ============================================================
        hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)

        # ============================================================
        # Step 3: 定义前馈网络的线性层
        # LLaMA 的 FFN 采用 SwiGLU 结构:
        #   FFN(x) = W2( SiLU(W1(x)) * W3(x) )
        #
        # - W1 和 W3 都是升维层(输入维度 dim → hidden_dim)
        # - W2 是降维层(hidden_dim → dim)
        # - “*” 表示逐元素相乘(门控机制)
        # ============================================================

        # W1:输入升维,用于生成主特征分支 A
        self.w1 = ColumnParallelLinear(
            dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x
        )

        # W2:输出降维,将门控结果投影回原维度
        self.w2 = RowParallelLinear(
            hidden_dim, dim, bias=False, input_is_parallel=True, init_method=lambda x: x
        )

        # W3:输入升维,用于生成门控分支 B
        self.w3 = ColumnParallelLinear(
            dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x
        )

    def forward(self, x):
        # ============================================================
        # 前向传播过程:
        # 1. w1(x):主分支特征 A
        # 2. w3(x):门控分支特征 B
        # 3. F.silu(w1(x)):对主分支使用 SiLU 激活函数(Swish)
        # 4. F.silu(w1(x)) * w3(x):门控机制,控制信息通过比例
        # 5. w2(...):将结果映射回原维度
        #
        # 最终公式:
        # FFN(x) = W2( SiLU(W1(x)) * W3(x) )
        # ============================================================
        return self.w2(F.silu(self.w1(x)) * self.w3(x))

这里需要注意的点是: 激活函数用的是 F.silu(),也就是 Swish 激活函数。 self.w2(F.silu(self.w1(x)) * self.w3(x)) 的实现也就是 SwiGLU 激活函数

1.3.3 RoPE
#

绝对位置编码:

在经典 Transformer 中,每个词的 embedding xRdx \in \mathbb{R}^{d} 会加上一个位置向量 PE(pos)PE(pos)z=x+PE(pos)z = x + PE(pos)

PE(pos)PE(pos) 的每个维度的值由固定函数生成:

PE(pos,2i)=sin(pos/100002i/d) PE_{(pos, 2i)} = \sin(pos / 10000^{2i/d}) PE(pos,2i+1)=cos(pos/100002i/d) PE_{(pos, 2i+1)} = \cos(pos / 10000^{2i/d})

即:

  • 每个维度 i 都有一个对应的频率;
  • 模型靠加法 x+PEx + PE 把位置信息“叠加”进 embedding 向量。

旋转位置编码:

RoPE(Su et al., 2021, RoFormer)采取了完全不同的策略

不再“相加”位置信息,而是通过旋转变换把位置信息直接嵌入到向量的几何空间结构中

直观理解:

对于每个位置 pos,我们把词向量的部分维度成对看作平面坐标,然后对它们旋转一个角度

​ 不同于原始 Transformers 论文中,将 pos embedding 和 token embedding 进行相加,RoPE 是将位置编码和 query (或者 key) 进行相乘。具体如下:

42.png

​ 也就是说,给位置为 mm 的向量 qq 乘上矩阵 RmR_m、位置为 nn 的向量 kk 乘上矩阵 RnR_n,用变换后的 Q,KQ,K 序列做Attention,那么Attention就自动包含相对位置信息了,因为成立恒等式:

(Rmq)T(Rnk)=qTRmTRnk=qTRnmk (R_mq)^\text{T}(R_nk)=q^\text{T}R_m^\text{T}R_nk=q^\text{T}R_{n−m}k

​ 值得指出的是,RmR_m是一个正交矩阵,它不会改变向量的模长,因此通常来说它不会改变原模型的稳定性。

​ 由于 RmR_m 的稀疏性,所以直接用矩阵乘法来实现会很浪费算力,推荐通过下述方式来实现RoPE:

43.png

​ 其中 \otimes 是逐位对应相乘,θi=10002i/d\theta_i=1000^{-2i/d}

RoPE实现代码如下:

# 代码增加了注释,可以看到和原始公式的对应关系。
class LlamaRotaryEmbedding(torch.nn.Module):
    def __init__(self, dim, max_position_embeddings=2048, base=10000, device=None):
        super().__init__()
        # 此处 inv_freq 对应公式中的 theta
        inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float().to(device) / dim))
        self.register_buffer("inv_freq", inv_freq)

        self.max_seq_len_cached = max_position_embeddings
        t = torch.arange(self.max_seq_len_cached, device=self.inv_freq.device, dtype=self.inv_freq.dtype)
        # 此处 freqs 对应公式中的 m * theta, t 对应公式中的 m,表示位置
        freqs = torch.einsum("i,j->ij", t, self.inv_freq)
        # Different from paper, but it uses a different permutation in order to obtain the same calculation
        # 此处和原始公式不同,theta_0 和 theta_0 不再相邻
        # 而是分在向量的前半部分和后半部分
        emb = torch.cat((freqs, freqs), dim=-1)
        dtype = torch.get_default_dtype()
        self.register_buffer("cos_cached", emb.cos()[None, None, :, :].to(dtype), persistent=False)
        self.register_buffer("sin_cached", emb.sin()[None, None, :, :].to(dtype), persistent=False)

    def forward(self, x, seq_len=None):
        # x: [bs, num_attention_heads, seq_len, head_size]
        if seq_len > self.max_seq_len_cached:
            self.max_seq_len_cached = seq_len
            t = torch.arange(self.max_seq_len_cached, device=x.device, dtype=self.inv_freq.dtype)
            freqs = torch.einsum("i,j->ij", t, self.inv_freq)
            # Different from paper, but it uses a different permutation in order to obtain the same calculation
            emb = torch.cat((freqs, freqs), dim=-1).to(x.device)
            self.register_buffer("cos_cached", emb.cos()[None, None, :, :].to(x.dtype), persistent=False)
            self.register_buffer("sin_cached", emb.sin()[None, None, :, :].to(x.dtype), persistent=False)
        # 大部分情况下,直接从这里返回
        return (
            self.cos_cached[:, :, :seq_len, ...].to(dtype=x.dtype),
            self.sin_cached[:, :, :seq_len, ...].to(dtype=x.dtype),
        )


def rotate_half(x):
    """Rotates half the hidden dims of the input."""
    # 此次和原始推导中不同,正负号不是间隔的,而是分前半部分和后半部分。但对于结果没有影响
    x1 = x[..., : x.shape[-1] // 2]
    x2 = x[..., x.shape[-1] // 2 :]
    return torch.cat((-x2, x1), dim=-1)


def apply_rotary_pos_emb(q, k, cos, sin, position_ids):
    # The first two dimensions of cos and sin are always 1, so we can `squeeze` them.
    cos = cos.squeeze(1).squeeze(0)  # [seq_len, dim]
    sin = sin.squeeze(1).squeeze(0)  # [seq_len, dim]
    cos = cos[position_ids].unsqueeze(1)  # [bs, 1, seq_len, dim]
    sin = sin[position_ids].unsqueeze(1)  # [bs, 1, seq_len, dim]
    # 对应上图中 RoPE 的简化计算
    q_embed = (q * cos) + (rotate_half(q) * sin)
    k_embed = (k * cos) + (rotate_half(k) * sin)
    return q_embed, k_embed

1.4 高效实现
#

1.4.1LLaMa的优化与高效实现
#

​ AdamW, β1=0.9,β2=0.95\beta_1=0.9, \beta_2=0.95,使用 cosine 学习率衰减策略,2000 步的 warm-up,最终学习率等于最大学习率的 10%,使用 0.1 的权重衰减和 1.0 的梯度裁剪。

​ **快速的注意力机制:**LLaMa 采用了高效的 causal multi-head attention (基于 xformers),不存储注意力权重,且不计算 mask 掉的 query 和 key 的值。

​ **手动实现反向传播过程,不使用 PyTorch autograd:**使用 checkpointing 技术减少反向传播中的激活值的计算,更准确地说,LLaMa 保存计算代价较高的激活值,例如线性层的输出。

​ 通过使用模型和序列并行减少模型的内存使用。此外,LLaMa 还尽可能多地重叠激活的计算和网络上的 GPU 之间的通信。

​ LLaMa-65B 的模型使用 2048 块 80G 的 A100 GPU,在 1.4T token 的数据集上训练 21 天。

1.4.2 其他代码实现:
#

Self-Attention 的 PyTorch 代码:

class Attention(nn.Module):
    def __init__(self, args: ModelArgs):
        super().__init__()

        self.n_local_heads = args.n_heads // fs_init.get_model_parallel_world_size()
        self.head_dim = args.dim // args.n_heads

        self.wq = ColumnParallelLinear(
            args.dim,
            args.n_heads * self.head_dim,
            bias=False,
            gather_output=False,
            init_method=lambda x: x,
        )
        self.wk = ColumnParallelLinear(
            args.dim,
            args.n_heads * self.head_dim,
            bias=False,
            gather_output=False,
            init_method=lambda x: x,
        )
        self.wv = ColumnParallelLinear(
            args.dim,
            args.n_heads * self.head_dim,
            bias=False,
            gather_output=False,
            init_method=lambda x: x,
        )
        self.wo = RowParallelLinear(
            args.n_heads * self.head_dim,
            args.dim,
            bias=False,
            input_is_parallel=True,
            init_method=lambda x: x,
        )

        self.cache_k = torch.zeros(
            (args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)
        ).cuda()
        self.cache_v = torch.zeros(
            (args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)
        ).cuda()

    def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor, mask: Optional[torch.Tensor]):
        bsz, seqlen, _ = x.shape
        xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)

        xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)
        xk = xk.view(bsz, seqlen, self.n_local_heads, self.head_dim)
        xv = xv.view(bsz, seqlen, self.n_local_heads, self.head_dim)

        xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)

        self.cache_k = self.cache_k.to(xq)
        self.cache_v = self.cache_v.to(xq)

        self.cache_k[:bsz, start_pos : start_pos + seqlen] = xk
        self.cache_v[:bsz, start_pos : start_pos + seqlen] = xv

        keys = self.cache_k[:bsz, : start_pos + seqlen]
        values = self.cache_v[:bsz, : start_pos + seqlen]

        xq = xq.transpose(1, 2)
        keys = keys.transpose(1, 2)
        values = values.transpose(1, 2)
        scores = torch.matmul(xq, keys.transpose(2, 3)) / math.sqrt(self.head_dim)
        if mask is not None:
            scores = scores + mask  # (bs, n_local_heads, slen, cache_len + slen)
        scores = F.softmax(scores.float(), dim=-1).type_as(xq)
        output = torch.matmul(scores, values)  # (bs, n_local_heads, slen, head_dim)
        output = output.transpose(
            1, 2
        ).contiguous().view(bsz, seqlen, -1)

        return self.wo(output)

这里有几个地方值得注意一下: 首先是 model.py 文件里面从 fairscale 中 import 了3个类,分别是:ParallelEmbedding,RowParallelLinear,和 ColumnParallelLinear。 Fairscale 链接如下,是一个用于高性能大规模预训练的库,LLaMa 使用了其 ParallelEmbedding 去替换 Embedding, 使用了其 RowParallelLinear 和 ColumnParallelLinear 去替换 nn.Linear,猜测可能是为了加速吧。

另一个需要注意的点是:cache 的缓存机制,可以看到在构造函数里面定义了下面两个东西: self.cache_k = torch.zeros((args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)).cuda() self.cache_v = torch.zeros((args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)).cuda()

关键其实就是这几行代码: self.cache_k[:bsz, start_pos : start_pos + seqlen] = xk self.cache_v[:bsz, start_pos : start_pos + seqlen] = xv keys = self.cache_k[:bsz, : start_pos + seqlen] values = self.cache_v[:bsz, : start_pos + seqlen]

在训练的时候,因为每次都是输入完整的一句话,所以 cache 机制其实是不发挥作用的。 在推理的时候,比如要生成 “I have a cat”,过程是: 1 输入 <s>,生成 <s> I。 2 输入 <s> I,生成 <s> I have。 3 输入 <s> I have,生成 <s> I have a。 4 输入 <s> I have a,生成 <s> I have a cat。

在执行3这一步时,计算 “a” 的信息时,还要计算 <s> I have 的 Attention 信息,比较复杂。因此,cache 的作用就是在执行2这一步时,提前把 <s> I have 的 keys 和 values 算好,并保存在 self.cache_k 和 self.cache_v 中。在执行3这一步时,计算 Attention 所需的 keys 和 values 是直接从这里面取出来的: keys = self.cache_k[:bsz, : start_pos + seqlen] values = self.cache_v[:bsz, : start_pos + seqlen] 只需要额外地计算 “a” 的 keys 和 values 即可,这对模型的快速推理是至关重要的。

还有一个值得注意的点:self.cache_k = self.cache_k.to(xq) 这里使用的是 to() 函数的一种不太常见的用法:torch.to(other, non_blocking=False, copy=False)→Tensor Returns a Tensor with same torch.dtype and torch.device as the Tensor other.

Transformer Block 的 PyTorch 代码:

class TransformerBlock(nn.Module):
    def __init__(self, layer_id: int, args: ModelArgs):
        super().__init__()
        self.n_heads = args.n_heads
        self.dim = args.dim
        self.head_dim = args.dim // args.n_heads
        self.attention = Attention(args)
        self.feed_forward = FeedForward(
            dim=args.dim, hidden_dim=4 * args.dim, multiple_of=args.multiple_of
        )
        self.layer_id = layer_id
        self.attention_norm = RMSNorm(args.dim, eps=args.norm_eps)
        self.ffn_norm = RMSNorm(args.dim, eps=args.norm_eps)

    def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor, mask: Optional[torch.Tensor]):
        h = x + self.attention.forward(self.attention_norm(x), start_pos, freqs_cis, mask)
        out = h + self.feed_forward.forward(self.ffn_norm(h))
        return out

Transformer 的 PyTorch 代码:

class Transformer(nn.Module):
    def __init__(self, params: ModelArgs):
        super().__init__()
        self.params = params
        self.vocab_size = params.vocab_size
        self.n_layers = params.n_layers

        self.tok_embeddings = ParallelEmbedding(
            params.vocab_size, params.dim, init_method=lambda x: x
        )

        self.layers = torch.nn.ModuleList()
        for layer_id in range(params.n_layers):
            self.layers.append(TransformerBlock(layer_id, params))

        self.norm = RMSNorm(params.dim, eps=params.norm_eps)
        self.output = ColumnParallelLinear(
            params.dim, params.vocab_size, bias=False, init_method=lambda x: x
        )

        self.freqs_cis = precompute_freqs_cis(
            self.params.dim // self.params.n_heads, self.params.max_seq_len * 2
        )

    @torch.inference_mode()
    def forward(self, tokens: torch.Tensor, start_pos: int):
        _bsz, seqlen = tokens.shape
        h = self.tok_embeddings(tokens)
        self.freqs_cis = self.freqs_cis.to(h.device)
        freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]

        mask = None
        if seqlen > 1:
            mask = torch.full((1, 1, seqlen, seqlen), float("-inf"), device=tokens.device)
            mask = torch.triu(mask, diagonal=start_pos + 1).type_as(h)

        for layer in self.layers:
            h = layer(h, start_pos, freqs_cis, mask)
        h = self.norm(h)
        output = self.output(h[:, -1, :])  # only compute last logits
        return output.float()

self.tok_embeddings 用的是 ParallelEmbedding 这个函数,把 ids 变为词向量。 mask 部分通过 torch.full() 函数和 torch.triu() 函数得到一个上三角矩阵,用于注意力的计算。 通过 torch.nn.ModuleList() 函数定义所有的 Transformer Block。 所有的 norm 函数都使用 RMSNorm 去定义。

生成过程的 PyTorch 代码:

class LLaMA:
    def __init__(self, model: Transformer, tokenizer: Tokenizer):
        self.model = model
        self.tokenizer = tokenizer

    def generate(
        self,
        prompts: List[str],
        max_gen_len: int,
        temperature: float = 0.8,
        top_p: float = 0.95,
    ) -> List[str]:
        bsz = len(prompts)
        params = self.model.params
        assert bsz <= params.max_batch_size, (bsz, params.max_batch_size)

        prompt_tokens = [self.tokenizer.encode(x, bos=True, eos=False) for x in prompts]

        min_prompt_size = min([len(t) for t in prompt_tokens])
        max_prompt_size = max([len(t) for t in prompt_tokens])

        total_len = min(params.max_seq_len, max_gen_len + max_prompt_size)

        tokens = torch.full((bsz, total_len), self.tokenizer.pad_id).cuda().long()
        for k, t in enumerate(prompt_tokens):
            tokens[k, : len(t)] = torch.tensor(t).long()
        input_text_mask = tokens != self.tokenizer.pad_id
        start_pos = min_prompt_size
        prev_pos = 0
        for cur_pos in range(start_pos, total_len):
            logits = self.model.forward(tokens[:, prev_pos:cur_pos], prev_pos)
            if temperature > 0:
                probs = torch.softmax(logits / temperature, dim=-1)
                next_token = sample_top_p(probs, top_p)
            else:
                next_token = torch.argmax(logits, dim=-1)
            next_token = next_token.reshape(-1)
            # only replace token if prompt has already been generated
            next_token = torch.where(
                input_text_mask[:, cur_pos], tokens[:, cur_pos], next_token
            )
            tokens[:, cur_pos] = next_token
            prev_pos = cur_pos

        decoded = []
        for i, t in enumerate(tokens.tolist()):
            # cut to max gen len
            t = t[: len(prompt_tokens[i]) + max_gen_len]
            # cut to eos tok if any
            try:
                t = t[: t.index(self.tokenizer.eos_id)]
            except ValueError:
                pass
            decoded.append(self.tokenizer.decode(t))
        return decoded


def sample_top_p(probs, p):
    probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
    probs_sum = torch.cumsum(probs_sort, dim=-1)
    mask = probs_sum - probs_sort > p
    probs_sort[mask] = 0.0
    probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
    next_token = torch.multinomial(probs_sort, num_samples=1)
    next_token = torch.gather(probs_idx, -1, next_token)
    return next_token

这里需要注意的是: torch.multinomial() 函数用于按照一定的概率 (probs_sort) 采样一定数量 (num_samples) 的 Tensor。 torch.gather() 函数是一个抽数据的函数,按照 probs_idx 的索引和 dim=-1 的维度。

1.5 主要结果与结论
#

1.5.1 主要成果
#

1.5.2 结论
#

​ 在本文中,作者介绍了一系列公开发布且与最先进的基础模型具有竞争力的语言模型。最值得注意的是,LLaMA-13B 在体积仅为 GPT-3 十分之一的情况下性能更优,而 LLaMA-65B 则与 Chinchilla-70B 和 PaLM-540B 相媲美。

与以往的研究不同,作者展示了仅通过使用公开可用的数据进行训练,而不依赖专有数据集,就可以达到最先进的性能。我们希望向研究社区发布这些模型能够加速大型语言模型的发展,并帮助改善它们的鲁棒性,并减轻诸如毒性和偏见等已知问题。

​ 此外,论文也像 Chung 等人(2022年)观察到的那样,发现对这些模型进行指令微调可以带来有希望的结果,我们计划在未来的工作中进一步研究这一点。最后,我们计划未来发布在更大的预训练语料库上训练的更大型模型,因为我们已经看到随着规模的扩大性能在持续提升。

参考:

LLM 系列超详细解读 (六):LLaMa:开源高效的大语言模型

论文精读:LLaMA: Open and Efficient Foundation Language Models

LLaMA 超详细解读(paper & code)

理解NLP最重要的编码方式 — Byte Pair Encoding (BPE),这一篇就够了

分词(tokenization)算法之Byte Pair Encoding (BPE) 算法详解(代码实现)_bpe算法

通俗易懂-大模型的关键技术之一:旋转位置编码rope

Transformer升级之路:2、博采众长的旋转式位置编码|Scientific Spaces

你还不懂旋转位置编码吗?