Zhangzhe's Blog

The projection of my life.

0%

URL

TL;DR

  • BLIPSalesforce 提出的一种图文多模态对齐算法,全称是 Bootstrapping Language-Image Pre-training,是一种自举的图文多模态预训练算法
  • 相较于 CLIP 只能对齐图像和文本表征,BLIP 将任务范式进行了扩展,基本已经有了多模态大模型的雏形

Algorithm

BLIP 整体架构

blip_1.png

  • BLIP 在预训练阶段,包含三种损失函数
    • Image-Text Contrastive Loss:对齐图像和文本表征,和 CLIP 的对齐方式类似,不同的是 BLIP 引入了 Momentum Model,可以使得训练更加稳定
    • Image-Text Macthing Loss:图像和文本匹配损失,输出 TrueFalse,本质是交叉熵损失
    • Language Modeling Loss:语言建模损失,预测下一个词(大模型范式),用于文本内容生成
  • 从上图可以看出,模型实际包含四个部分:
    • Image Encoder:图像编码器,将图像编码为 Image Embedding
    • Text Encoder:文本编码器,将文本编码为 Text Embedding
    • Image-grounded Text Eecoder:图像驱动文本编码器,用文本特征 Cross-Modal Attention 去索引图像特征,输出文本图像特征是否匹配(True / False
    • Image-grounded Text Decoder:图像驱动文本解码器,用文本特征 Cross-Modal Attention 去索引图像特征,用逐个 Token 预测的方式生成文本,回答文本对应的问题
  • 从实现代码看,Text Encoder / Image-grounded Text Eecoder / Image-grounded Text Decoder 实际上共用了一个 Bert 预训练模型的一组参数(仅仅在部分位置不共享参数)
    • Text Encoder
      • 原生 Bert 模型,用于文本编码
      • 不做 Cross-Modal Attention,只做 Self-Attention
      • 最后输出层为表征层
      • 用特殊的 [CLS] 作为任务标识符
    • Image-grounded Text Eecoder
      • 魔改 Bert 模型,加入 Cross-Modal Attention
        • 这种状态处于 encoderdecoder 之间,比 encoder 多了 Cross-Modal Attention,比 decoder 少了 Causal Self-Attention
        • 用文本特征去索引图像特征
      • 最后输出层为二分类层
      • 用特殊的 [Encoder] 作为任务标识符
    • Image-grounded Text Decoder
      • 魔改 Bert 模型,将其转化成一个完全的 decoder 形态,主要包括两点:
        • 加入 Cross-Modal Attention,用文本特征去索引图像特征
        • 由于是生成任务,所以需要用 Causal Self-Attention 取代 Bert 默认的 Bidirection Self-Attention
      • 最后输出层为 vocab prob 层,用于生成文本(下一个文本在词表中的概率)
      • 用特殊的 [Decoder] 作为任务标识符
    • 三个模型仅仅在 Head 层和 Attention 层参数上有所不同,其余参数共享

Bootstrapping 策略

  • BLIP 中的 B 就是 Bootstrapping 的意思,即自举,表示此算法有一种自我增强的方式
  • 自我增强的方式主要是通过 CapFilt 方法实现的:
    • Captioner:用于为图像生成高质量的文本描述
    • Filter:用于过滤掉低质量的图像-文本对,保留高质量的对用于训练
  • CapFilt 工作流程:
    1. 生成伪标签:使用 Captioner 为无标签或弱标签图像生成文本描述,形成伪标签
    2. 过滤伪标签:使用 Filter 评估生成的伪标签的质量,保留高质量的伪标签
    3. 更新训练数据集:将过滤后的高质量伪标签与原始训练数据集合并,形成新的训练数据集
    4. 重新训练模型:使用更新后的训练数据集重新训练模型,从而改进模型的性能

Thoughts

  • 在某种程度上,BLIP 统一了几乎所有的图文多模态任务,除了无法做图像生成任务外,其他任务都可以通过 BLIP 的不同模块组合实现
  • BLIPBootstrapping 策略是一种自我增强的方式,可以使得模型在无监督的情况下不断提升性能,如今这几乎是多模态预训练算法的标配
  • 从实验的角度告诉我们,encoderdecoder 做充分的参数共享是可行的,仅仅在少量的位置上不共享参数,就可以实现完全不同任务的模型

URL

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)logpref(yx))s_\theta(x,y)=\beta(\log p_\theta(y|x) - \log p_{ref}(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:模型参数
  • refref:参考模型,DPO 之前的冻结模型
  • β\beta:温度系数(通常在 0.1 - 0.5 之间),平衡探索和守成
  • 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 的流程

1. 训练奖励模型

  • 奖励模型通常选比原模型尺寸小的预训练模型,将 head 换成一个标量输出的 head
  • 奖励模型的输入是 prompt + response,输出是一个奖励标量,即:奖励模型作用的对象是一个序列,不是一个 token
  • 训练数据格式是:(prompt, response_chosen, response_reject)
    • prompt:输入给策略模型的问题
    • response_chosen:期待的大模型输出
    • response_reject:不期待的大模型输出
  • 训练损失函数:pairwise ranking loss,本质就是对比学习

L=E(x,yw,yl)logσ(R(x,yw)R(x,yl))L=-E_{(x,y_w,y_l)}\log \sigma(R(x, y_w) - R(x, y_l))

  • 其中:
    • xprompt
    • y_wwin response,也就是 response_chosen
    • y_llose response,也就是 response_reject

2. 强化学习阶段

2.1 Rollout 阶段

对每个 Prompt x:

  1. 用当前 Actor πθ\pi_\theta 自回归采样一条完整回答 y=(y_1, …, y_T),直到遇到结束符
  2. (x, y) 拼成一整句喂给冻结的 Reward Model,得到一个标量 rTr_T
  3. 同时用 πref\pi_{ref} 算同样这条回答的 log-problogπref(yx)\log \pi_{ref}(y|x)

2.2 Reward Shaping 阶段

  • 由于 Reward Model 只给整句打分,而 PPO 需要每一步都有 Advantage,所以要先构造 “即时奖励” 再让 GAE 去算回报:

rt={rTβKLtif t=TβKLtif t<T r_t=\left\{ \begin{aligned} r_T-\beta\cdot KL_t && \text{if}\ t=T \\ -\beta\cdot KL_t && \text{if}\ t<T \end{aligned} \right.

KLt=logπθ(ytx,y<t)logπref(ytx,y<t)KL_t=\log \pi_\theta(y_t|x,y<t) - \log \pi_{ref}(y_t|x,y<t)

  • 其中:
    • KL 表示 KL 散度损失,用于方式模型偏离参考模型太远
    • 参考模型:经过 SFT 之后冻结的模型
    • β\betaKL 散度系数, 要调得够大(0.1–0.5),否则 πθ\pi_\theta 会迅速 “讨好” RM,生成乱码或重复文本

Thoughts

  • 用对比学习的方式训练奖励模型,可以减少人工标注数据量,同时降低数据标注的主观性噪声
  • 强化学习的部分设计的挺巧妙的,不过仍然是单回合强化学习,如果从 Agentic RL 角度看,需要多回合强化学习来微调模型使得模型更像一个 Agent

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 的开山之作。
  • 本质就是传统 MoE 替换掉 Transformer 中的 FFN

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,通过 孤立共享专家细分专家 的方式,提高了模型性能并降低了计算复杂度。
  • 替换的也是 Transformer 中的 FFN 层。

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 是不一样的。