Zhangzhe's Blog

The projection of my life.

0%

TL;DR

  • 本文是大模型DPO的入门教程,目的是用尽可能简单的方式介绍大模型DPO的基本概念和原理,不涉及过多实现细节。

什么是 DPO

  • DPODirect Performance Optimization (直接偏好优化) 的缩写,是 RLHF 的替代方案,目的是使用不涉及强化学习的方法来优化模型性能。

DPO 的数据格式

  • RLHF 一致,DPO 的数据格式为:prompt + response 1 + response 2 + label
    • prompt:问题描述
    • response 1:模型生成的回答 1
    • response 2:模型生成的回答 2
    • label:人类标注的哪个回答更好(0 / 1

DPO 损失函数的设计

L(θ)=E(x,y+,y)D[logσ(sθ(x,y+)sθ(x,y))]L(\theta)=\mathbb E_{(x,y^+,y^-)\sim D}[\log\sigma(s_\theta(x,y^+)-s_\theta(x,y^-))]

sθ(x,y)=logpθ(yx)s_\theta(x,y)=\log p_\theta(y|x)

pθ(yx)=t=1Tpθ(yty<t,x)p_\theta(y|x)=\prod_{t=1}^T p_\theta(y_t|y_{<t},x)

sθ(x,y)=t=1Tlogpθ(yty<t,x)s_\theta(x,y)=\sum_{t=1}^T \log p_\theta(y_t|y_{<t},x)

  • L(θ)L(\theta):损失函数
  • θ\theta:模型参数
  • DD:数据集
  • xx:输入
  • y+y^+:人类标注的更好的回答
  • yy^-:人类标注的更差的回答
  • sθ(x,y)s_\theta(x,y):模型对 (x,y)(x,y) 的得分
  • pθ(yx)p_\theta(y|x):模型对 xx 生成 yy 的概率
  • σ\sigmasigmoid 函数
  • TT:序列长度
  • y<ty_{<t}yy 的前 t1t-1 个元素
  • sθ(x,y)s_\theta(x,y) 可经过修改变成大名鼎鼎的 困惑度 (Perplexity, PPL)PPL(θ)=exp(sθ(x,y)len(y))PPL(\theta)=\exp(-\frac{s_\theta(x,y)}{len(y)})

Thoughts

  • DPO 事实上就是利用正负样本的困惑度来对比学习,从而优化模型性能
  • 原理非常简单,收敛速度比 RLHF
  • 但可以预见的是:DPO 的损失计算方式比 RLHF,那么必然对数据集的质量要求更高,否则会导致模型性能下降
  • 一个常用的做法是在 SFT 之后,在 RLHF 之前使用 DPO 快速将模型性能提升到一个较高水平,然后再使用 RLHF 进一步优化

TL;DR

  • 本文是大模型RLHF的入门教程,目的是用尽可能简单的方式介绍大模型RLHF的基本概念和原理,不涉及过多实现细节。

什么是 RLHF

  • RLHFReinforcement Learning with Human Feedback (人类反馈强化学习) 的缩写,目的是在强化学习任务中引入人类反馈,以提高强化学习模型的性能
  • 大多数分类体系下,SFT 属于 RLHF 中的一个步骤,但本文不讨论 SFT,假设起点模型已经做好了 pre-trainSFT
  • RLHF 的目标是:
    • helpful
    • honst
    • harmless

RLHF 相关术语

  • policy model:策略模型,即待训练的强化学习模型
  • reference model:参考模型(基准模型),通常是冻结参数的原始 policy model,防止模型在 RL 过程中出现退化(可理解为正则化项)
  • reward model:奖励模型,用于评估 policy model 生成的结果的好坏,相当于强化学习的 environment
  • value model:价值模型,通常是 policy model 的一个子模块,用于评估状态的价值(也可以理解为对环境的估计),辅助 policy model 的决策,仅仅在 RLHF 过程中有效,训练结束后会被丢弃

RLHF 的基本实现原理

  • RLHF 的实现包括两个阶段:
    • 训练奖励模型阶段
    • 强化学习阶段

1. 训练奖励模型阶段

  • 数据格式:prompt + response 1 + response 2 + label
    • prompt:问题描述
    • response 1:模型生成的回答 1
    • response 2:模型生成的回答 2
    • label:人类标注的哪个回答更好(0 / 1
  • 模型:
    • 模型结构:通常是 LLM,可以由待 RLHF 的策略模型初始化,也可以重新定义模型结构和随机初始化
    • 输入:prompt + response
    • 输出:reward,是一个标量,表示 response 相对于 prompt 的好坏(logits
    • 损失函数:L=1Ni=1Nlogσ(R(prompt,responsechosen)R(prompt,responsereject))L=-\frac{1}{N}\sum_{i=1}^N \log \sigma(R(prompt, response_{chosen}) - R(prompt, response_{reject}))
      • R(prompt,response)R(prompt, response):奖励模型就当前 promptresponse 的奖励(优秀程度)
      • σ\sigmasigmoid 函数
      • NNbatch size
      • responsechosenresponse_{chosen}:人类标注的更好的回答
      • responserejectresponse_{reject}:人类标注的更差的回答

2. 强化学习阶段

  • RLHF 的强化学习阶段通常使用 PPO 算法
  • RLHF 中使用的 PPO 与传统 PPO 有以下几点不同:
    1. 数据格式不同:
      • 传统 PPO 需要的数据格式为: (st,at,rt,st+1,done)(s_t, a_t, r_t, s_{t+1}, done)
      • RLHF 需要的数据格式为: (st,at,rt)(s_t, a_t, r_t),其中:
        • sts_tprompt
        • ata_tresponse
        • rtr_treward(prompt, response)
    2. reward 的计算方式不同:
      • 传统 PPO 中的 reward 由环境给出
      • RLHF 中的 rewardreward model 给出
    3. policy model 的更新方式不同:
      • 为了防止 policy modelRL 过程中退化,需要在损失函数中加入 policy modelreference modelKL 散度作为正则化项
      • reference model 通常是冻结参数的原始 policy model 或者是一个不同架构的经过 pre-train + SFT 的模型
  • 综上,RLHF 强化学习阶段的损失函数为:
    • LRLHF=LPPO+βDKL(policy,reference)L_{RLHF} = L_{PPO} + \beta \cdot D_{KL}(policy, reference)
      • LPPOL_{PPO}:传统 PPO 的损失函数
      • β\beta:正则化系数
      • KLdiv(policy,reference)KL_{div}(policy, reference)policy modelreference modelKL 散度
      • LPPO=E[min(rt(θ)A^t,clip(rt(θ),1ϵ,1+ϵ)A^t)]+c1MSE(Vt,Vttarg)c2H[πθ(atst)]L_{PPO} = \mathbb{E}[\min(r_t(\theta) \hat{A}_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) \hat{A}_t)] + c_1 \cdot \text{MSE}(V_t, V_t^{targ}) - c_2 \cdot H[\pi_{\theta}(a_t|s_t)]
        • At=λδt+lA_t = \lambda \delta_{t+l}:优势函数
        • δt=Reward(st,at)V(st)\delta_t=Reward(s_t,a_t)- V(s_t)
        • rt(θ)=πθ(atst)πθold(atst)r_t(\theta)=\frac{\pi_{\theta}(a_t|s_t)}{\pi_{\theta_{old}}(a_t|s_t)}policy model 新旧策略概率比值
        • VtV_t:状态价值函数
        • VttargV_t^{targ}:目标状态价值函数,由于 RLHF 属于单步强化学习,所以 Vttarg=Reward(st,at)V_t^{targ}=Reward(s_t,a_t)
        • H[πθ(atst)]H[\pi_{\theta}(a_t|s_t)]:熵
        • c1,c2c_1, c_2:权重系数

Thoughts

  • 用对比学习的方式训练奖励模型,可以减少人工标注数据量,同时降低数据标注的主观性噪声
  • 第二步强化学习阶段仅仅相当于单步强化学习(无需关注 st+1s_{t+1}),因此需要对 PPO 算法进行一定的修改(简化)

TL;DR

  • 本文是大模型RAG的入门教程,不是来自某一篇 paper / survey,目的是用尽可能简单的方式介绍大模型RAG的基本概念和原理,不涉及过多实现细节。

什么是 RAG

  • RAGRetrieval-Augmented Generation (检索增强生成) 的缩写,目的是在生成任务中引入检索模块,将检索到的信息通过 prompt 模板的方式传递给生成模块,以提高生成模型的性能。

RAG 想要解决的问题

  • 通常来说,RAG 想要解决的问题可以概括为三点:
    1. 大模型知识的局限性:大模型在训练过程中只能学习到非实时的、公开的知识,对于实时的、私有的知识无法获取。
    2. 幻觉问题:大模型无法记住所有的信息细节,即使这些信息曾在训练数据中出现过。
    3. 数据安全问题:敏感私域数据用于训练大模型可能导致数据泄露,而 RAG 可以通过检索模块实现数据的隔离。

RAG 的基本实现原理

rag_1.webp

  • RAG 要分为两步:
    • 数据准备阶段:
      1. 数据提取
      2. 文本切分
      3. 向量化
      4. 数据入库
    • 应用阶段:
      1. 用户提问
      2. 数据检索(召回)
      3. 注入 prompt
      4. LLM 生成答案

1. 数据提取

  • 数据加载:包括多格式数据加载、不同数据源获取等,根据数据自身情况,将数据处理为同一个范式
  • 数据处理:包括数据过滤、压缩、格式化等
  • 元数据获取:提取数据中关键信息,例如文件名、Title、时间等

2. 文本切分

  • 文本切分主要考虑两个因素:
    1. embedding 模型的 sequence length 限制情况
    2. 语义完整性对整体的检索效果的影响
  • 一些常见的文本分割方式包括:
    1. 句分割:以 “句” 的粒度进行切分,保留一个句子的完整语义,常见切分符包括:句号、感叹号、问号、换行符等
    2. 固定长度分割:根据 embedding 模型的 sequence length 长度限制,将文本分割为固定长度(例如 256 / 512token),这种切分方式会损失很多语义信息,一般通过在头尾增加一定冗余量来缓解

3. 向量化

  • 向量化是将文本转换为向量的过程,目的是将不同长度的文本转换为固定长度的向量,有许多开源的 embedding 模型可以使用,例如:

模型名称 描述 获取地址
ChatGPT-Embedding ChatGPT-Embedding由OpenAI公司提供,以接口形式调用。 https://platform.openai.com/docs/guides/embeddings/what-are-embeddings
ERNIE-Embedding V1 ERNIE-Embedding V1由百度公司提供,依赖于文心大模型能力,以接口形式调用。 https://cloud.baidu.com/doc/WENXINWORKSHOP/s/alj562vvu
M3E M3E是一款功能强大的开源Embedding模型,包含m3e-small、m3e-base、m3e-large等多个版本,支持微调和本地部署。 https://huggingface.co/moka-ai/m3e-base
BGE BGE由北京智源人工智能研究院发布,同样是一款功能强大的开源Embedding模型,包含了支持中文和英文的多个版本,同样支持微调和本地部署。 https://huggingface.co/BAAI/bge-base-en-v1.5

Transformer base 的模型天然可以将不同长度的文本转换为固定长度的向量,因此在向量化模型本质就是一个去掉 projection 头的 LLM(当然训练阶段的目标设置也不同)

4. 数据入库

  • 数据向量化之后,作为索引存储到专用的向量检索数据库中,常用的向量检索数据库包括:
    • FaissFacebook 开源的高性能相似向量检索库,支持多种索引结构(如 IVFHNSWPQ),不支持分布式存储和检索
    • MilvusZilliz 开源的分布式高性能相似向量检索库,较 Faiss 更为复杂,支持分布式存储和检索
    • ChromaChroma AI 开源的轻量化相似向量检索库
    • ESElasticSearch 是一款全文搜索引擎,支持文本检索,不原生支持向量检索,但可以通过插件实现

5. 用户提问

graph TD
    A[User Query]
    B[Generate Similar Queries By LLM]
    C1[Vector Search Query 1]
    C2[Vector Search Query 2]
    C3[Vector Search Query 3]
    C4[Vector Search Query 4]
    C5[Vector Search Original Query]
    D[Reciprocal Rank Fusion]
    E[Re-ranked Results]
    F[Generative Output]
    A --> B
    B --> C1
    B --> C2
    B --> C3
    B --> C4
    A --> C5
    C1 --> D
    C2 --> D
    C3 --> D
    C4 --> D
    C5 --> D
    D --> E
    E --> F
  • 大模型首先会根据用户的提问生成一系列相似的查询(或拆分查询内容等),得到一系列的检索查询,这样做的目的是为了提高检索的召回率。

6. 数据检索(召回)

  • 常见的数据检索方法包括:相似性检索、全文检索等,根据检索效果,一般可以选择多种检索方式融合,提升召回率。
  • (向量)相似性检索:即计算查询向量与所有存储向量的相似性得分,返回得分高的记录。常见的相似性计算方法包括:余弦相似性、欧氏距离、曼哈顿距离等。
  • 全文检索:全文检索是一种比较经典的检索方式,在数据存入时,通过 关键词 构建倒排索引;在检索时,通过关键词进行全文检索,找到对应的记录。

7. 注入 prompt

  • prompt 是一种模板化的文本,用于将检索到的信息传递给生成模块,prompt 的构建需要考虑以下几点:
    1. prompt 的内容应该尽可能简洁,不应该包含过多冗余信息
    2. prompt 的内容应该尽可能完整,包含检索到的信息的核心内容
    3. prompt 的内容应该尽可能通用,适用于多种检索结果
  • 以下是一个 prompt 的示例:
1
2
3
4
5
6
【任务描述】
假如你是一个专业的客服机器人,请参考【背景知识】,回
【背景知识】
{content} // 数据检索得到的相关文本
【问题】
石头扫地机器人P10的续航时间是多久?

一些高级 RAG 技巧

1. 检索

1.1 向量存储索引

  • 最简答的索引方式是向量存储索引,即一段文本对应一个向量,通过向量相似性检索得到相似文本,如下图所示:
    rag_2.webp

1.2 分层索引

  • 分层索引是对向量存储索引的一种优化,通过对文本经过不同等级的抽象,得到不同层次的向量,从而提高检索效率,如下图所示:
    rag_3.webp

1.3 假设性问题和假设性回答

  • 假设性问题是让 LLM 提前给每一段知识库中的文本生成一个假设性问题,当用户提问时,可以直接通过假设性问题进行检索,从而提高检索效率。
  • 假设性回答也被称为 Hypothetical Document Embeddings(假设性文本嵌入),是用 LLM 给用户的 query 生成一个假设性回答,用假设性回答去检索,从而提高检索效率。

1.4 内容增强索引

  • 内容增强索引原理非常简单,即在检索时,将检索到的文本内容相关的其他文本(例如这段文本前后内容等)内容也一并返回,从而提高检索召回率。

1.5 融合索引

  • 将向量索引和关键词索引融合,提高召回

2. 重排和过滤

  • 重排和过滤是在检索结果返回后,对检索结果进行进一步处理,提高生成效果的一种方法。
  • 过滤/重排的常用方法包括:
    • 通过元数据过滤/重排:例如只保留某个时间段的数据
    • 通过相似性分数过滤/重排
    • 使用其他 LLM 进行过滤/重排

3. 查询转换

  • 查询转换是指在用户提问时,将用户的提问转换为更适合检索的形式,提高检索效率。
  • 例如,用户提问为:“在 Github 上,LangchainLlamaIndex 这两个框架哪个更受欢迎?”,则可以转换为:
    • “Langchain 在 Github 上有多少星?”
    • “Llamaindex 在 Github 上有多少星?”

4. 结合 RAG 的聊天引擎

  • 总体上,结合 RAG 的聊天引擎可以分成两种范式,如下图所示:
    rag_4.webp

5. 智能体(Agent)

  • 例如多文档智能体,就是给每个文档初始化一个智能体,该智能体能进行文档摘要制作和传统问答机制的操作,还有一个顶层智能体,负责将查询分配到各个文档智能体,并综合形成最终的答案。总体流程如下图所示:
    rag_5.webp

6. 响应合成

  • 这是任何 RAG 管道的最后一步——根据检索的所有上下文和初始用户查询生成答案。响应合成的主要方法有:
    • 通过将检索到的上下文逐块发送到 LLM 来优化答案
    • 概括检索到的上下文,以适应提示
    • 根据不同的上下文块生成多个答案,然后将它们连接或概括起来

Thoughts

  • 看起来 RAG 是一种自由度很高的范式,可以根据具体的任务需求,设计出不同的检索和生成策略,但是也正是因为这种自由度,RAG 的实现难度也相对较高,需要综合考虑检索和生成的各种因素,才能设计出一个高效的 RAG 系统。

URL

TL;DR

  • 谷歌提出了一种新的模型分布策略 GShard,通过 条件计算自动分片 的方式,提高了模型的训练效率和推理速度,算是大模型 MOE 的开山之作。

Architecture

总体架构

Gshard.png

  • GShard 将传统 LLMTransformer 层的 FFN 替换成 MOE 模块,每个 token 只激活固定数量的 expert,大幅降低了计算复杂度。
  • GShardMOE 模块分为 routingexpert 两部分
    • routing 负责选择激活的 expert
    • expert 负责计算 FFN 的输出。

详细计算流程

  • 输入:hRseq_len×dimh \in \mathbb R^{seq\_len\times dim},表示 token 序列的输入特征,seq_len 表示序列长度,dim 表示特征维度
  • 经过 self-attention + norm,这一步和传统 Transformer 模型一致,输出 hRseq_len×dimh' \in \mathbb R^{seq\_len\times dim}
  • 计算 routing:
    1. 计算每个 tokenrouting 分数,表示每一个 token 和每一个专家的相关程度,s=h×Wgs=h'\times W_g,其中 WgRdim×num_experts, sRseq_len×num_expertsW_g \in \mathbb R^{dim\times num\_experts},\ s \in \mathbb R^{seq\_len\times num\_experts}
    2. 每个 token 取固定数量的 expertkk 表示激活的 expert 数量,kk 在本文中取 22,其它非激活的 expertrouting 分数设为 -\infty
    3. routing 分数归一化变成概率,g=Softmax(s)g=Softmax(s)gg 表示 gate,表示每个 token 激活的 expert 的输出采纳程度
  • 计算 expert:
    1. 计算当前 token 已激活的 expert 的输出,hexpert=FFN(hexpert)h_{expert}=FFN(h'_{expert})
    2. expert 的输出按照 gate 加权求和,hfinal=i=1kgihexpertih_{final}=\sum_{i=1}^{k}g_i\cdot h_{expert_i}
  • 本文中 MoE Transformer layer 和标准 Transformer layer 是交替出现的

Thoughts

  • GShard 确实在不减小模型参数量的情况下,大幅减小计算复杂度,对于推理速度的提升有很大帮助,带动了一波 LLM-MoE 的发展。
  • 其中 MoE 的部分并不新颖,重点在于 MoE 放置到 Transformer Block 中确实比较有创新。

URL

TL;DR

  • 本文在 GShard 的基础上,提出了一种新的混合专家语言模型 DeepSeekMoE,通过 孤立共享专家细分专家 的方式,提高了模型性能并降低了计算复杂度。

Architecture

deepseekmoe.png

传统 MoE 模型(例如 GShard

  • 传统 MoE 如图 a 所示,核心思想是将 TransformerFFN 替换为 MoE,每个 token 通过 Gate 机制选择不同的 Expert 来处理。
  • 用公式表示为:
    • htl=i=1N(gi,tFFNi(utl))+utlh_t^l=\sum_{i=1}^{N}(g_{i,t}FFN_i(u^l_t))+u^l_t
    • gi,t={si,t,si,tTopK(sj,t1jN,K),0,otherwise,g_{i,t}=\left\{\begin{array}{ll}{s_{i,t},} & {s_{i,t}\in TopK({s_{j,t}|1\le j\le N}, K),} \\ {0,} & {otherwise,}\end{array}\right.
    • si,t=Softmax(utlTeil)s_{i,t}=Softmax({u_t^{l}}^Te_i^l)
    • 其中:
      • l 表示第 l
      • N 表示 Expert 的数量
      • K 表示每个 token 保留的 Expert 数量

细粒度 MoE 模型

  • 如图 b 所示,和传统 MoE 的区别是将专家切分的更小,专家数量更多,也可以理解为传统 MoE 中的 Expert 也是由多个 Sub-Expert 组成。
  • 用公式表示为:
    • htl=i=1mN(gi,tFFNi(utl))+utlh_t^l=\sum_{i=1}^{mN}(g_{i,t}FFN_i(u^l_t))+u^l_t
    • gi,t={si,t,si,tTopK(sj,t1jmN,mK),0,otherwise,g_{i,t}=\left\{\begin{array}{ll}{s_{i,t},} & {s_{i,t}\in TopK({s_{j,t}|1\le j\le mN}, mK),} \\ {0,} & {otherwise,}\end{array}\right.
    • si,t=Softmax(utlTeil)s_{i,t}=Softmax({u_t^{l}}^Te_i^l)
    • 其中:
      • l 表示第 l
      • N 表示 Expert 的数量
      • m 表示每个 Expert 中包含的 Sub-Expert 的数量
      • K 表示每个 token 保留的 Expert 数量

细粒度 MoE + 孤立共享专家

  • 如图 c 所示,在细粒度 MoE 的基础上,引入了 Isolated Shared Expert,这种专家不参与 Gate 选择,而是在所有 token 之间共享。
  • 用公式表示为:
    • htl=i=1KsFFNi(utl)+i=Ks+1mN(gi,tFFNi(utl))+utlh_t^l=\sum_{i=1}^{K_s}FFN_i(u^l_t)+\sum_{i=K_s+1}^{mN}(g_{i,t}FFN_i(u^l_t))+u^l_t
    • gi,t={si,t,si,tTopK(sj,tKs+1jmN,mKKs),0,otherwise,g_{i,t}=\left\{\begin{array}{ll}{s_{i,t},} & {s_{i,t}\in TopK({s_{j,t}|K_s+1\le j\le mN}, mK-K_s),} \\ {0,} & {otherwise,}\end{array}\right.
    • si,t=Softmax(utlTeil)s_{i,t}=Softmax({u_t^{l}}^Te_i^l)
    • 其中:
      • l 表示第 l
      • N 表示 Expert 的数量
      • m 表示每个 Expert 中包含的 Sub-Expert 的数量
      • K 表示每个 token 保留的 Expert 数量
      • KsK_s 表示 Isolated Shared Expert 的数量

Thoughts

  • 这篇论文名字起的有点大《迈向终极专家专业化的MoE语言模型》,但是实际上只是在 GShard 的基础上做了一些小的改进。

URL

TL;DR

  • DeepSeek 系列是国内一家名叫深度求索的科技公司推出的一种混合专家语言模型,这家公司背后是幻方量化。
  • DeepSeek-V2 的核心思想是 Multi-head Latent Attention (MLA)DeepSeekMoE 两个模块,这两个模块分别替换了传统 Transformer 层的 Attention ModuleFFN,是本博客想要探讨的重点。

Architecture

Multi-head Latent Attention (MLA)

MLA 总体计算流程

MLA.png

  • 核心思想是将 hidden feature 通过 MLP 映射到 latent space,降低计算(attention)和存储(kv cache)的复杂度。
  • 和主流的 kv cache 方案不同,MLA 只需要 cache ctKVc_t^{KV}ktRk_t^R 两部分。

MLA 详细计算流程

  • 输入:htRdh_t \in \mathbb R^d ,表示第 ttoken 在某个 attention layer 层的输入特征
  • 计算 key / value
    • ctKV=WDKVhtc_t^{KV}=W^{DKV}h_t ,其中 WDKVRdc×dW^{DKV} \in \mathbb R^{d_c\times d}DKV 表示 down-projection key value
    • ktC=WUKctKVk_t^C=W^{UK}c_t^{KV} ,其中 WUKRdhnh×dcW^{UK} \in \mathbb R^{d_hn_h\times d_c}UK 表示 up-projection keydcdhnhd_c \ll d_hn_h
    • vtC=WUVctKVv_t^C=W^{UV}c_t^{KV} ,其中 WUVRdhnh×dcW^{UV} \in \mathbb R^{d_hn_h\times d_c}UV 表示 up-projection valuedcdhnhd_c \ll d_hn_h
  • 计算 query
    • ctQ=WDQhtc_t^Q=W^{DQ}h_t ,其中 WDQRdc×dW^{DQ} \in \mathbb R^{d_c'\times d}DQ 表示 down-projection query
    • qtC=WUQctQq_t^C=W^{UQ}c_t^Q ,其中 WUQRdhnh×dcW^{UQ} \in \mathbb R^{d_hn_h\times d_c'}UQ 表示 up-projection querydcdhnhd_c' \ll d_hn_h
  • 计算 RoPE
    • RoPE 是一种在输入 attention layer 之前,对 querykeyposition encoding 的方法
    • RoPEMLA 在设计上是冲突的,因此 MLARoPE 做了一些修改,主要是:额外计算 multi-head query rotaryshared key rotary
    • [qt,1R,qt,2R,...,qt,nhR]=qtR=RoPE(WQRctQ)[q^R_{t,1},q^R_{t,2},...,q^R_{t,n_h}]=q_t^R=RoPE(W^{QR}c_t^Q) ,其中 WQRRdhRnh×dcW^{QR} \in \mathbb R^{d_h^Rn_h\times d_c'}QRQR 表示 query rotary
    • ktR=RoPE(WKRktC)k_t^R=RoPE(W^{KR}k_t^C) ,其中 WKRRdhR×dW^{KR} \in \mathbb R^{d_h^R\times d}KRKR 表示 key rotary
    • qt,i=[qt,iC,qt,iR]q_t,i=[q^C_{t,i},q^R_{t,i}] ,将 queryquery rotary 拼接,得到正式的 query
    • kt,i=[kt,iC,ktR]k_t,i=[k^C_{t,i},k^R_{t}] ,将 keykey rotary 拼接,得到正式的 key
  • 计算 attention
    • ot,i=j=1tSoftmaxj(qt,iTkj,idh+dhR)vj,iCo_{t,i}=\sum^t_{j=1}Softmax_j(\frac{q^T_{t,i}k_{j,i}}{\sqrt{d_h+d_h^R}})v^C_{j,i}
  • 计算 output
    • ut=WO[ot,1,ot,2,...,ot,nh]u_t=W^O[o_{t,1},o_{t,2},...,o_{t,n_h}] ,其中 WORd×dhnhW^O \in \mathbb R^{d\times d_hn_h}

MLA 优势

  • 大幅降低了 kv cache 的存储空间
    deepseek_mla_2.png

DeepSeekMoE

Thoughts

  • MLA 算是对 self-attention 比较深度的改进,兼顾了 kv cacheRoPE 的设计,比 MQAGQA 设计看上去更用心。
  • DeepSeekMoE 感觉在 GShard 上的改进并不大,主要是在 Expert 的基础上加入了 Sub-Expert 的概念,以及常开的 Isolated Shared Expert 设计。
  • 这二者结合,给了 DeepSeek-V2 一个很好的性能提升,可以在相同计算复杂度下,塞下更多的参数量,提高模型表现。

URL

TL;DR

  • LOMO 算法只能和 SGD 优化器联合使用,而在大模型微调阶段,SGD 优化器的实际效果通常不如 Adam 算法
  • 本文提出了 AdaLomo 算法,它将 LOMO 算法和 Adam 算法结合起来,实现了低内存占用的优化器

Algorithm

adalomo_algorithm.png

  • AdaLomo 仅仅是将 LOMOAdam 结合起来,代码如下:
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import math
import torch
from torch.optim import Optimizer
import torch.distributed as dist
from transformers.integrations.deepspeed import is_deepspeed_zero3_enabled
class AdaLomo(Optimizer):
"""
一个自定义的优化器类AdaLomo,用于在分布式训练中的梯度更新。
该类实现两个梯度更新函数 :meth:`fuse_update` 和 :meth:`fuse_update_zero3`,分别用于非ZeRO和ZeRO模式下的梯度更新。
:param model: 待优化的模型
:param lr: 学习率,默认值为1e-3
:param eps: 正则化系数。eps[0]防止梯度平方太小,eps[1]用于在根据参数的RMS放缩学习率时防止步长太大
:param clip_threshold: 归一化update矩阵时的阈值
:param decay_rate: 梯度平方移动平均的衰减率
:param clip_grad_norm: 梯度裁剪的范数阈值
.. note::
clip_grad_norm须为正数
:param clip_grad_value: 梯度裁剪的值域阈值
:param weight_decay: 权重衰减系数,默认值为0.0
:param loss_scale: 损失缩放系数,可以用来提高训练精度,但是太大可能会导致nan
"""
def __init__(
self,
model,
lr=1e-3,
loss_scale=2 ** 10,
eps=(1e-30, 1e-3),
clip_threshold=1.0,
decay_rate=-0.8,
clip_grad_norm=None,
clip_grad_value=None,
weight_decay=0.0,
):
self.model = model
self.lr = lr
self.clip_grad_norm = clip_grad_norm
self.clip_grad_value = clip_grad_value
self.weight_decay = weight_decay
self.loss_scale = loss_scale
if self.weight_decay > 0.0:
self.do_weight_decay = True
else:
self.do_weight_decay = False
self.eps = eps
self.step_num = 0
self.decay_rate = decay_rate
self.clip_threshold = clip_threshold
# for grad norm
if self.clip_grad_norm is not None and self.clip_grad_norm <= 0:
raise ValueError(
f"clip_grad_norm should be positive, got {self.clip_grad_norm}."
)
self.gather_norm = False
self.grad_norms = []
self.clip_coef = None
# check if zero3 is enabled
self.zero3_enabled = is_deepspeed_zero3_enabled()
if self.zero3_enabled: # zero3 is enabled
self.grad_func = self.fuse_update_zero3()
else:
self.grad_func = self.fuse_update()
self.exp_avg_sq = {}
self.exp_avg_sq_row = {}
self.exp_avg_sq_col = {}
# register hook function, which will be called through the backward process
for n, p in self.model.named_parameters():
if self.zero3_enabled:
if len(p.ds_shape) == 1:
self.exp_avg_sq[n] = torch.zeros(
p.ds_shape[0], dtype=torch.float32
).cuda()
else:
self.exp_avg_sq_row[n] = torch.zeros(
p.ds_shape[0], dtype=torch.float32
).cuda()
self.exp_avg_sq_col[n] = torch.zeros(
p.ds_shape[1], dtype=torch.float32
).cuda()
else:
if len(p.data.shape) == 1:
self.exp_avg_sq[n] = torch.zeros(
p.data.shape[0], dtype=torch.float32
).cuda()
else:
self.exp_avg_sq_row[n] = torch.zeros(
p.data.shape[0], dtype=torch.float32
).cuda()
self.exp_avg_sq_col[n] = torch.zeros(
p.data.shape[1], dtype=torch.float32
).cuda()
if p.requires_grad:
p.register_hook(self.grad_func)
defaults = dict(
lr=lr,
eps=eps,
weight_decay=weight_decay,
clip_grad_norm=clip_grad_norm,
clip_grad_value=clip_grad_value,
)
super(AdaLomo, self).__init__(self.model.parameters(), defaults)
@staticmethod
def _approx_sq_grad(exp_avg_sq_row, exp_avg_sq_col):
# copy from fairseq's adafactor implementation:
# https://github.com/huggingface/transformers/blob/8395f14de6068012787d83989c3627c3df6a252b/src/transformers/optimization.py#L505
r_factor = (
(exp_avg_sq_row / exp_avg_sq_row.mean(dim=-1, keepdim=True))
.rsqrt_()
.unsqueeze(-1)
)
c_factor = exp_avg_sq_col.unsqueeze(-2).rsqrt()
return torch.mul(r_factor, c_factor)
@staticmethod
def _rms(tensor):
return tensor.norm(2) / (tensor.numel() ** 0.5)
def fuse_update(self):
"""
在非ZeRO模式下更新模型参数的梯度。
:return: func,一个闭包函数,用于更新模型参数的梯度
"""
def func(x):
"""
闭包函数,用于更新模型参数的梯度。
"""
with torch.no_grad():
for n, p in self.model.named_parameters():
if p.requires_grad and p.grad is not None:
grad_fp32 = p.grad.to(torch.float32)
p.grad = None
if self.loss_scale:
grad_fp32.div_(self.loss_scale)
if self.gather_norm:
# we adopt two backward pass for gradient norm computation and parameter update, respectively.
self.grad_norms.append(torch.norm(grad_fp32, 2.0))
else:
# grad clip or norm
if (
self.clip_grad_value is not None
and self.clip_grad_value > 0
):
# Clipping gradients by their value
grad_fp32.clamp_(
min=-self.clip_grad_value, max=self.clip_grad_value
)
if (
self.clip_grad_norm is not None
and self.clip_grad_norm > 0
and self.clip_coef is not None
):
# Normalize the gradient according to its norm (computed in another pass)
grad_fp32.mul_(self.clip_coef)
beta2t = 1.0 - math.pow(self.step_num, self.decay_rate)
update = (grad_fp32 ** 2) + self.eps[0]
if len(p.data.shape) > 1:
self.exp_avg_sq_row[n].mul_(beta2t).add_(
update.mean(dim=-1), alpha=1.0 - beta2t
)
self.exp_avg_sq_col[n].mul_(beta2t).add_(
update.mean(dim=-2), alpha=1.0 - beta2t
)
update = self._approx_sq_grad(
self.exp_avg_sq_row[n], self.exp_avg_sq_col[n]
)
update.mul_(grad_fp32)
else:
self.exp_avg_sq[n].mul_(beta2t).add_(
update, alpha=1.0 - beta2t
)
update = self.exp_avg_sq[n].rsqrt().mul_(grad_fp32)
update.div_(
(self._rms(update) / self.clip_threshold).clamp_(
min=1.0
)
)
p_fp32 = p.data.to(torch.float32)
p_rms = torch.norm(p_fp32, 2.0) / math.sqrt(p.numel())
lr = self.lr
param_scale = max(self.eps[1], p_rms)
lr = lr * param_scale
if self.do_weight_decay:
p_fp32.mul_(1.0 - lr * self.weight_decay)
p_fp32.add_(update, alpha=-lr)
p.data.copy_(p_fp32)
return x
return func
def fuse_update_zero3(self):
"""
在ZeRO模式下更新模型参数的梯度。
:return: func,一个闭包函数,用于更新模型参数的梯度。
"""
def func(x):
with torch.no_grad():
for n, p in self.model.named_parameters():
if p.grad is not None:
torch.distributed.all_reduce(
p.grad, op=torch.distributed.ReduceOp.AVG, async_op=False
)
grad_fp32 = p.grad.to(torch.float32)
p.grad = None
if self.loss_scale:
grad_fp32.div_(self.loss_scale)
if self.gather_norm:
# we adopt two backward pass for gradient norm computation and parameter update, respectively.
self.grad_norms.append(torch.norm(grad_fp32, 2.0))
else: # update param
partition_size = p.ds_tensor.numel()
start = partition_size * self.dp_rank
end = min(start + partition_size, grad_fp32.numel())
if self.clip_grad_value is not None:
# Clipping gradients by their value
grad_fp32.clamp_(
min=-self.clip_grad_value, max=self.clip_grad_value
)
if (
self.clip_grad_norm is not None
and self.clip_grad_norm > 0
and self.clip_coef is not None
):
# Normalize the gradient according to its norm (computed in another pass)
grad_fp32.mul_(self.clip_coef)
beta2t = 1.0 - math.pow(self.step_num, self.decay_rate)
update = (grad_fp32 ** 2) + self.eps[0] # 改成addcmul_
if len(p.ds_shape) > 1:
self.exp_avg_sq_row[n].mul_(beta2t).add_(
update.mean(dim=-1), alpha=1.0 - beta2t
)
self.exp_avg_sq_col[n].mul_(beta2t).add_(
update.mean(dim=-2), alpha=1.0 - beta2t
)
update = self._approx_sq_grad(
self.exp_avg_sq_row[n], self.exp_avg_sq_col[n]
)
update.mul_(grad_fp32)
else:
self.exp_avg_sq[n].mul_(beta2t).add_(
update, alpha=1.0 - beta2t
)
update = self.exp_avg_sq[n].rsqrt().mul_(grad_fp32)
update.div_(
(self._rms(update) / self.clip_threshold).clamp_(
min=1.0
)
)
one_dim_update = update.view(-1)
partitioned_update = one_dim_update.narrow(
0, start, end - start
)
param_fp32 = p.ds_tensor.to(torch.float32)
partitioned_p = param_fp32.narrow(0, 0, end - start)
p_rms = torch.norm(partitioned_p, 2.0) ** 2
dist.all_reduce(p_rms, op=torch.distributed.ReduceOp.SUM)
p_rms = (p_rms / p.ds_numel).sqrt()
lr = self.lr
param_scale = max(self.eps[1], p_rms)
lr = lr * param_scale
if self.do_weight_decay:
partitioned_p.mul_(1.0 - lr * self.weight_decay)
partitioned_p.add_(partitioned_update, alpha=-lr)
p.ds_tensor[: end - start] = partitioned_p
return x
return func
def fused_backward(self, loss, lr):
"""
执行一步反向传播并更新模型的梯度。
:param loss: 模型的loss值
:param lr: 学习率
"""
self.lr = lr
if self.loss_scale:
loss = loss * self.loss_scale
self.step_num += 1
loss.backward()
# update the last parameter since the last parameter in the computaiton graph is not ready when calling hook functions
# the argument of grad_func is just a placeholder, and it can be anything.
self.grad_func(0)
def grad_norm(self, loss):
"""
计算梯度的范数。
:param loss: 模型的loss值
"""
self.gather_norm = True
self.grad_norms = []
if self.loss_scale:
loss = loss * self.loss_scale
loss.backward(retain_graph=True)
# update the last parameter since the last parameter in the computaiton graph is not ready when calling hook functions
# the argument of grad_func is just a placeholder, and it can be anything.
self.grad_func(0)
with torch.no_grad():
# The norm is computed over all gradients together, as if they were
# concatenated into a single vector. Gradients are modified in-place.
self.grad_norms = torch.stack(self.grad_norms)
total_norm = torch.norm(self.grad_norms, 2.0)
self.clip_coef = float(self.clip_grad_norm) / (total_norm + 1e-6)
self.clip_coef = torch.clamp(self.clip_coef, max=1.0)
self.gather_norm = False

Thoughts

  • LOMO 只能用 SGD 优化器这一点限制了它的使用场景,因此 AdaLomo 则将 LOMOAdam 结合起来,可以适应主流大模型的微调场景
  • AdaLomo 需要存储 Adam 的一阶和二阶动量,这又带来了额外的内存开销,感觉和 LOMO 的初衷有些违背

URL

TL;DR

  • 本文提出一种名为 LOMO 的大模型微调方法,它在梯度计算和优化过程中的内存消耗方面进行了精细的优化设计,使得有限资源的设备能够实现对大型语言模型的全参数微调。

Algorithm

  • LOMO 的全称是 LOw Memory Optimizer,它的核心思想是:
    1. SGD 代替 Adam 优化器
    2. 融合梯度计算和梯度更新
    3. 拆分全局梯度和局部梯度

1. 优化器

  • Adam 优化器比 SGD 优化器更消耗显存
  • LOMO 认为 SGD 优化器在大模型微调任务中已足够

2. 融合梯度计算和梯度更新

  • 传统深度学习框架中,梯度计算和梯度更新是分开的,即先将所有层的梯度计算完毕后,再统一进行梯度更新
  • 这样做的问题是,需要保存所有层的梯度,占用大量显存
  • LOMO 提出了一种融合梯度计算和梯度更新的方法,即每计算完一层的梯度,就立即进行梯度更新
    LOMO.png

3. 拆分全局梯度和局部梯度

  • 在大模型微调任务中,有一个常用的稳定训练的策略是在模型梯度上加入梯度裁剪,梯度裁剪方式一般有两种:
    • Clipping Gradient Value:直接裁剪梯度到一个固定范围
    • Clipping Gradient Norm:裁剪梯度的范数到一个固定值
  • Clipping Gradient Value 无需全局梯度
  • Clipping Gradient Norm 需要全局梯度信息,这和 LOMO 的设计相违背,因此 LOMO 不得不单独处理这种情况
  • 具体的处理方式是,如果模型训练过程中需要 Clipping Gradient Norm,则 每个 Step 做两次 forward - backward
    • 第一次用于计算全局梯度的总范数,根据总范数和设定的阈值计算裁剪比例
    • 第二次做真正的 backward,并在 backward 过程中对梯度进行裁剪 + 局部参数更新

4. 代码实现

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
class LOMO(Optimizer):
"""
一个自定义的优化器类LOMO,用于在分布式训练中的梯度更新。
该类实现两个梯度更新函数 :meth:`fuse_update` 和 :meth:`fuse_update_zero3`,分别用于非ZeRO和ZeRO模式下的梯度更新。
:param model: 待优化的模型
:param lr: 学习率,默认值为1e-3
:param clip_grad_norm: 梯度裁剪的范数阈值
.. note::
clip_grad_norm须为正数
:param clip_grad_value: 梯度裁剪的值域阈值
"""
def __init__(self, model, lr=1e-3, clip_grad_norm=None, clip_grad_value=None):
self.model = model
self.lr = lr
self.local_rank = int(os.environ["LOCAL_RANK"])
self.world_size = dist.get_world_size()
self.clip_grad_norm = clip_grad_norm
self.clip_grad_value = clip_grad_value
# for grad norm
if self.clip_grad_norm is not None and self.clip_grad_norm <= 0:
raise ValueError(
f"clip_grad_norm should be positive, got {self.clip_grad_norm}."
)
self.gather_norm = False
self.grad_norms = []
self.clip_coef = None
# check if zero3 is enabled
p0 = list(self.model.parameters())[0]
if hasattr(p0, "ds_tensor"): # zero3 is enabled
self.grad_func = self.fuse_update_zero3()
else:
self.grad_func = self.fuse_update()
# check if fp16 is enabled
if p0.dtype == torch.float16:
self.loss_scaler = DynamicLossScaler(
init_scale=2 ** 16,
) # TODO: add args
if self.clip_grad_norm is None:
raise ValueError(
"Loss scaling is recommended to be used with grad norm to get better performance."
)
else:
self.loss_scaler = None
# register hook function, which will be called through the backward process
for n, p in self.model.named_parameters():
if p.requires_grad:
p.register_hook(self.grad_func)
defaults = dict(
lr=lr, clip_grad_norm=clip_grad_norm, clip_grad_value=clip_grad_value
)
super(LOMO, self).__init__(self.model.parameters(), defaults)
def fuse_update(self):
"""
在非ZeRO模式下更新模型参数的梯度。
:return: func,一个闭包函数,用于更新模型参数的梯度
"""
def func(x):
"""
闭包函数,用于更新模型参数的梯度。
"""
with torch.no_grad():
for n, p in self.model.named_parameters():
if p.requires_grad and p.grad is not None:
if self.loss_scaler:
if (
self.loss_scaler.has_overflow_serial
or self.loss_scaler._has_inf_or_nan(p.grad)
):
# if the overflow is detected, drop the gradient
p.grad = None
self.loss_scaler.has_overflow_serial = True
break
grad_fp32 = p.grad.to(torch.float32)
p.grad = None
if self.loss_scaler:
grad_fp32.div_(self.loss_scaler.loss_scale)
if self.gather_norm:
# we adopt two backward pass for gradient norm compuation and parameter update, respectively.
self.grad_norms.append(torch.norm(grad_fp32, 2.0))
else:
if (
self.clip_grad_value is not None
and self.clip_grad_value > 0
):
# Clipping gradients by their value
grad_fp32.clamp_(
min=-self.clip_grad_value, max=self.clip_grad_value
)
if (
self.clip_grad_norm is not None
and self.clip_grad_norm > 0
and self.clip_coef is not None
):
# Normalize the gradient according to its norm (computed in another pass)
grad_fp32.mul_(self.clip_coef)
p_fp32 = p.data.to(torch.float32)
p_fp32.add_(grad_fp32, alpha=-self.lr)
p.data.copy_(p_fp32)
return x
return func
def fuse_update_zero3(self):
"""
在ZeRO模式下更新模型参数的梯度。
:return: func,一个闭包函数,用于更新模型参数的梯度。
"""
def func(x):
with torch.no_grad():
for n, p in self.model.named_parameters():
if p.grad is not None:
torch.distributed.all_reduce(
p.grad, op=torch.distributed.ReduceOp.AVG, async_op=False
)
if self.loss_scaler:
if (
self.loss_scaler.has_overflow_serial
or self.loss_scaler._has_inf_or_nan(p.grad)
):
# if the overflow is detected, drop the gradient
p.grad = None
self.loss_scaler.has_overflow_serial = True
break
grad_fp32 = p.grad.to(torch.float32)
p.grad = None
param_fp32 = p.ds_tensor.to(torch.float32)
if self.loss_scaler:
grad_fp32.div_(self.loss_scaler.loss_scale)
if self.gather_norm:
# we adopt two backward pass for gradient norm compuation and parameter update, respectively.
self.grad_norms.append(torch.norm(grad_fp32, 2.0))
else: # update param
one_dim_grad_fp32 = grad_fp32.view(-1)
partition_size = p.ds_tensor.numel()
start = partition_size * self.local_rank
end = min(start + partition_size, grad_fp32.numel())
partitioned_grad_fp32 = one_dim_grad_fp32.narrow(
0, start, end - start
)
if self.clip_grad_value is not None:
# Clipping gradients by their value
partitioned_grad_fp32.clamp_(
min=-self.clip_grad_value, max=self.clip_grad_value
)
if (
self.clip_grad_norm is not None
and self.clip_grad_norm > 0
and self.clip_coef is not None
):
# Normalize the gradient according to its norm (computed in another pass)
partitioned_grad_fp32.mul_(self.clip_coef)
partitioned_p = param_fp32.narrow(0, 0, end - start)
partitioned_p.add_(partitioned_grad_fp32, alpha=-self.lr)
p.ds_tensor[: end - start] = partitioned_p
return x
return func
def fused_backward(self, loss, lr):
"""
执行一步反向传播并更新模型的梯度(真正计算梯度和更新参数)。
:param loss: 模型的loss值
:param lr: 学习率
"""
self.lr = lr
# Users need call grad_norm themselves and then call backward_step
if (
self.clip_grad_norm is not None
and self.clip_grad_norm > 0
and self.clip_coef is None
):
raise ValueError(
"clip_grad_norm is not None, but clip_coef is None. "
"Please call optimizer.grad_norm() before optimizer.fused_backward()."
)
if self.loss_scaler:
loss = loss * self.loss_scaler.loss_scale
loss.backward()
# update the last parameter since the last parameter in the computaiton graph is not ready when calling hook functions
# the argument of grad_func is just a placeholder, and it can be anything.
self.grad_func(0)
def grad_norm(self, loss):
"""
计算梯度的范数(虽然做了一次 forward + backward,但实际上只是用于计算梯度的范数,后续做 clip grad norm 用)。
:param loss: 模型的loss值
"""
self.gather_norm = True
self.grad_norms = []
if self.loss_scaler:
self.loss_scaler.has_overflow_serial = False
loss = loss * self.loss_scaler.loss_scale
loss.backward(retain_graph=True)
# update the last parameter since the last parameter in the computaiton graph is not ready when calling hook functions
# the argument of grad_func is just a placeholder, and it can be anything.
self.grad_func(0)
if self.loss_scaler and self.loss_scaler.has_overflow_serial:
self.loss_scaler.update_scale(overflow=True)
with torch.no_grad(): # clear gradients
for n, p in self.model.named_parameters():
p.grad = None
return
with torch.no_grad():
# The norm is computed over all gradients together, as if they were
# concatenated into a single vector. Gradients are modified in-place.
self.grad_norms = torch.stack(self.grad_norms)
total_norm = torch.norm(self.grad_norms, 2.0)
self.clip_coef = float(self.clip_grad_norm) / (total_norm + 1e-6)
self.clip_coef = torch.clamp(self.clip_coef, max=1.0)
self.gather_norm = False
  • 以上代码是 LOMO 核心优化器的实现,来自于官方 Github 仓库
  • LOMO 可以和 DeepSpeed 一起使用,实现更好的性能

Thoughts

  • LOMO 原创性的东西感觉不多,很多优化技巧都是借鉴了其他优化方法,比如:
    • 混合精度训练
    • Activation Checkpointing 来做 Activation 的重计算
  • 不过确实是工程上的一个很好的实践

URL

TL;DR

  • BAdam 全称是 Blockwise Adam,是一种内存高效的大型语言模型参数优化方法。
  • BAdam 的核心思想是将模型参数分块,每次只更新一个块的参数,而且对每个块用 Adam 连续更新 K 次,然后再更新下一个块。

Algorithm

BAdam.png

  • BAdam 的算法流程如上图所示,其中 K 是一个超参数,表示每个块的参数用 Adam 更新次数。
  • 当一个块的参数更新 K 次后,就丢掉 这个块的所有优化器信息,包括梯度、一阶动量、二阶动量
  • 假设模型有 M billion 参数,最低内存占用:
    • Adam
      • 参数:2M GBfp16
      • 优化器:
        • 梯度:4M GBfp32
        • 参数:4M GBfp32
        • 一阶动量:4M GBfp32
        • 二阶动量:4M GBfp32
      • 总共:18M GB
    • BAdam
      • 参数:2M GBfp16
      • 优化器:
        • 梯度:4MD\frac{4M}{D} GBfp32
        • 参数:4MD\frac{4M}{D} GBfp32
        • 一阶动量:4MD\frac{4M}{D} GBfp32
        • 二阶动量:4MD\frac{4M}{D} GBfp32
      • 总共:2M+16MD2M+\frac{16M}{D} GB,其中 D 是块的数量。

Thoughts

  • BAdam 的核心思想是将模型参数分块更新,但实际上和 Adam 的思想是 完全不一样
  • 因为 Adam 想要追求的是全训练过程的步长自适应,即每一个 step 的步长都来自于只有所有历史信息。
  • BAdam 只能保证当前步长最多由之前 Kstep 的信息决定,所以 BAdam 的收敛性和 Adam 是不一样的。

URL

TL;DR

  • 本文提出一种梯度低秩映射方法,和 LoRA 思想类似,但支持全参数优化,无需冻结模型也无需额外的训练参数,只需在梯度更新时进行低秩映射,从而在训练时节省显存。
  • 由于优化器状态是大模型微调过程中的主要显存消耗,因此本算法本质是在优化器中记录梯度矩阵的低秩近似,从而减少显存消耗。

Algorithm

总体流程

galore.png

  1. 计算参数的梯度 GRm×nG\in\mathbb R^{m\times n}
  2. 计算梯度的低秩近似(通常用 SVD 奇异值分解法), GUVT,URm×r,VRn×rG\approx UV^T,U\in \mathbb R^{m\times r},V\in \mathbb R^{n\times r}
  3. 记录低秩近似的优化器状态信息,例如:
    1. UUVV
    2. UUVV 的动量信息
    3. UUVV 的二阶动量信息
  4. 将低秩近似的梯度 UVTUV^T 合并变成近似的梯度 GG',并用 GG' 更新参数
  5. 由于梯度具有一定程度的稳定性,为了节省奇异值分解带来的损失,UUVV 不会在每次迭代中都重新计算,而是每隔 TT 次迭代更新一次

用公式表述

  1. 分解梯度 GGUUVV 的乘积
    1. GRm×nG\in \mathbb R^{m\times n}
    2. U,Σ,VT=SVD(G)U,\Sigma,V^T=SVD(G)
    3. URm×m,VRn×n,ΣRmU\in \mathbb R^{m\times m},V\in \mathbb R^{n\times n},\Sigma\in\mathbb R^m
    4. rr 是低秩近似的秩
    5. Ur=U[:,:r],Vr=V[:,:r],UrRm×r,VrRn×rU_r=U[:,:r],V_r=V[:,:r],U_r\in \mathbb R^{m\times r},V_r\in \mathbb R^{n \times r}
    6. Ur=UrΣU_r = U_r \Sigma
    7. G=UrVrTG' = U_r V_r^T
  2. 记录优化器状态信息,以 Adam 优化器为例
    1. mtU=β1mt1U+(1β1)Ur ,  mtV=β1mt1V+(1β1)Vrm_t^U = \beta_1 m_{t-1}^U + (1-\beta_1)U_r\ ,\ \ m_t^V = \beta_1 m_{t-1}^V + (1-\beta_1)V_r
    2. vtU=β2vt1U+(1β2)Ur2 ,  vtV=β2vt1V+(1β2)Vr2v_t^U = \beta_2 v_{t-1}^U + (1-\beta_2)U_r^2\ ,\ \ v_t^V = \beta_2 v_{t-1}^V + (1-\beta_2)V_r^2
    3. mtU=mtU/(1β1t) ,  mtV=mtV/(1β1t)m_t^U = m_t^U/(1-\beta_1^t)\ ,\ \ m_t^V = m_t^V/(1-\beta_1^t)
    4. vtU=vtU/(1β2t) ,  vtV=vtV/(1β2t)v_t^U = v_t^U/(1-\beta_2^t)\ ,\ \ v_t^V = v_t^V/(1-\beta_2^t)
  3. 计算低秩近似梯度并更新参数
    1. G=U×mtVvtV+ϵ+V×mtUvtU+ϵG'=U\times \frac{m_t^V}{\sqrt{v_t^V}+\epsilon} + V\times \frac{m_t^U}{\sqrt{v_t^U}+\epsilon}
    2. ΘΘηG\Theta \leftarrow \Theta - \eta G'
    3. Θ\Theta 是模型参数,η\eta 是学习率
  4. 更新分解矩阵 UUVV
    1. 每隔 TT 次迭代更新一次,也就是说 GGUUVV 可能不是同一个时间步的,GG 是当前时间步的梯度,而 UUVV 是之前更新的
    2. U,Σ,VT=SVD(G)U,\Sigma,V^T=SVD(G)
    3. Ur=U[:,:r],Vr=V[:,:r],UrRm×r,VrRn×rU_r=U[:,:r],V_r=V[:,:r],U_r\in \mathbb R^{m\times r},V_r\in \mathbb R^{n \times r}
  5. 正交化和秩调整(可选)
    1. 为了保持 UUVV 各自的正交性,可以对 UUVV 分别进行正交化处理
    2. UTU=I,VTV=IU^TU=I,V^TV=I
    3. 动态调整秩 rr 以适应训练过程中的梯度变化

Thought

  • 听上去很像 LoRA,终究是效果和显存占用的平衡问题。
  • 大模型微调能改的东西不多,所以传统机器学习用到的一些算法又可以在大模型炒炒冷饭了…