2024年4月

本文收录于
Github.com/niumoo/JavaNotes
,Java 系列文档,数据结构与算法!
本文收录于网站:
https://www.wdbyte.com/
,我的公众号:
程序猿阿朗

前言

不知道你有没有发现,如果使用
https://start.spring.io/
或者使用 IDEA 创建一个 Spring Boot 项目,生成的文件列表中或有
mvnw

mvnw.cmd
两个命令文件。如果细心的同学查看隐藏目录,还会发现一个
.mvn
文件夹,那么这几个文件是干嘛的呢?这篇文章介绍它的相关知识。

├── .gitignore
├── .mvn -> 关注
│   └── wrapper
│       ├── maven-wrapper.jar -> 关注
│       └── maven-wrapper.properties -> 关注
├── HELP.md
├── mvnw    -> linux 命令文件
├── mvnw.cmd	-> windows 命令文件
├── pom.xml
└── src
    ├── main
    │   ├── java/com/wdbyte/com/springbootdemo/SpringbootdemoApplication.java
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test/java/com/wdbyte/com/springbootdemo/SpringbootdemoApplicationTests.java

Maven Wrapper 是什么

上面提到的
mvnw
命令,它的全称是
Maven Wrapper
,这是 Maven 提供的一个非常有用的工具,它会自动下载指定版本的 Maven 用于项目构建,而且开发者自由配置 Maven 版本,以此提供一致的构建体验。

mvnw
的命令使用体验和
mvn
完全一致,只需要把
mvn
命令换成
mvnw
,你可以在有
mvnw
文件的 Spring Boot 项目中进行测试。

$ ./mvnw --version
Apache Maven 3.9.5 (57804ffe001d7215b5e7bcb531cf83df38f93546)
Maven home: /Users/darcy/.m2/wrapper/dists/apache-maven-3.9.5-bin/32db9c34/apache-maven-3.9.5
Java version: 21, vendor: Oracle Corporation, runtime: /Users/darcy/develop/jdk-21.jdk/Contents/Home
Default locale: zh_CN_#Hans, platform encoding: UTF-8
OS name: "mac os x", version: "14.0", arch: "x86_64", family: "mac"

注:windows 环境请使用
mvnw.cmd

Maven Wrapper 官网:
https://maven.apache.org/wrapper/

Maven Wrapper 使用场景

设想一下,如果所在的团队同时维护多个项目,不同的项目使用的 JDK 版本不相同,使用的 Maven 版本也不相同,那么为了支持多项目开发,为了构建运行效果一致,你可能需要在本地管理多个 Maven 版本,这会非常繁琐,而 Maven Wrapper 可以轻松的解决这个问题。

Maven Wrapper 的引入解决了下面几个问题:

  • 版本一致性
    :通过在
    mvnw
    配置中指定 Maven 版本,确保所有开发人员和持续集成环境使用相同的 Maven 版本。
  • 简化设置
    :新团队成员或 CI 环境不需要预先安装 Maven,直接运行
    mvnw
    命令就如安装了
    mvn
    一样,从而简化了项目的初始设置和配置。
  • 跨平台
    :Wrapper 是跨平台的,无论是在 Windows 还是在 Unix-like 系统上,都可以以相同的方式执行。

使用 Maven Wrapper 的好处简单的说,可以是 “如果想要使用不同的 Maven 版本进行项目构建,只需在项目中存放一个脚本文件,而不是在本地安装不同的 Maven 版本”。

Maven Wrapper 使用方式

自动生成

如果本地机器已经安装了 Maven ,那么可以在指定项目中通过命令生成
mvnw
命令。

要在现有项目中引入 Maven Wrapper,需要执行如下命令:

mvn -N wrapper:wrapper

也可以指定 maven 版本。

mvn -N wrapper:wrapper -Dmaven=3.9.5

选项
-N
参数表示非递归,因此 Maven Wrapper 只会在项目的当前目录生成,如果项目中有子模块,那么则不会作用到子模块中。

下面我在一个空的 Spring Boot 项目中进行测试。

一个没有
mvnw
的 Spring Boot 项目:

$ ls -l -a
total 16
-rw-r--r--@   1 darcy  staff   395  4 16 21:44 .gitignore
-rw-r--r--@   1 darcy  staff  1249  4 16 21:44 pom.xml
drwxr-xr-x@   4 darcy  staff   128  4 16 21:44 src
drwxr-xr-x   11 darcy  staff   352  4 16 21:50 target

使用
mvn -N wrapper:wrapper
生成
mvnw

$ mvn wrapper:wrapper
[INFO] Scanning for projects...
... 省略日志
[INFO] -------------------< com.wdbyte.com:springbootdemo >--------------------
[INFO] Building springbootdemo 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-wrapper-plugin:3.2.0:wrapper (default-cli) @ springbootdemo ---
....省略日志
[INFO] Unpacked bin type wrapper distribution org.apache.maven.wrapper:maven-wrapper-distribution:zip:bin:3.2.0
[INFO] Configuring .mvn/wrapper/maven-wrapper.properties to use Maven 3.6.3 and download from http://mvnrepo.alibaba-inc.com/mvn/repository
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  20.518 s
[INFO] Finished at: 2024-04-16T22:36:16+08:00
[INFO] ------------------------------------------------------------------------

查看目录已经生成。

$ ls -l -a
-rw-r--r--@   1 darcy  staff    395  4 16 21:44 .gitignore
drwxr-xr-x    3 darcy  staff     96  3  8  2023 .mvn
-rwxr-xr-x    1 darcy  staff  11289  3  8  2023 mvnw
-rw-r--r--    1 darcy  staff   7796  3  8  2023 mvnw.cmd
-rw-r--r--@   1 darcy  staff   1249  4 16 21:44 pom.xml
drwxr-xr-x@   4 darcy  staff    128  4 16 21:44 src
drwxr-xr-x   11 darcy  staff    352  4 16 21:50 target
# 查看版本
$ ./mvnw --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: /Users/darcy/.m2/wrapper/dists/apache-maven-3.6.3-bin/c7849ca4/apache-maven-3.6.3
Java version: 21, vendor: Oracle Corporation, runtime: /Users/darcy/develop/jdk-21.jdk/Contents/Home
Default locale: zh_CN_#Hans, platform encoding: UTF-8
OS name: "mac os x", version: "14.0", arch: "x86_64", family: "mac"

生成的文件说明:

  1. mvnw

    mvnw.cmd
    脚本,分别用于 Unix/Linux 和 Windows 环境。

  2. .mvn/wrapper/mavenwrapper.jar
    是 Maven Wrapper 运行时所需的 Jar 文件。

    .mvn/wrapper/maven-wrapper.properties
    是配置文件,可以配置 Maven 版本。

然后就可以使用
mvnw
代替
mvn
构建或运行项目了。

./mvnw spring-boot:run

手动复制

通过将上面生成的文件和文件夹复制到一个新项目中,就可以拷贝一份 Maven Wrapper 。

需要拷贝的文件如下:

./
├── .mvn
│   └── wrapper
│       ├── maven-wrapper.jar
│       └── maven-wrapper.properties
├── mvnw
├── mvnw.cmd

手动配置

直接修改
maven-wrapper.properties
中的内容可以修改 Maven 版本,如修改 Maven 版本为
3.9.5
,只需要修改
distributionUrl
的值为指定版本的下载地址。

$ cat .mvn/wrapper/maven-wrapper.properties
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar

Maven Wrapper 总结

Maven Wrapper 是一个非常好用的工具,虽然它不是项目的一部分,但是如果把它放到项目中,不仅简化了项目的构建环境配置过程,还为项目构建减少了潜在的问题。如果你还没有在你的项目中使用 Maven Wrapper,不妨考虑尝试一下。将 Maven Wrapper 集成到项目中是一个不错的方式,这样可以跟随项目源码一起管理,可以让所有使用该项目的同学都能得到相同的构建结果。

集成步骤通常如下:

  1. 在项目根目录执行前面提到的命令添加 Maven Wrapper。
  2. 提交生成的
    mvnw
    ,
    mvnw.cmd

    .mvn/
    目录到版本控制系统。
  3. 更新项目文档,指导团队成员使用
    ./mvnw
    替代
    mvn

参考

  1. https://maven.apache.org/wrapper/

本文收录于
Github.com/niumoo/JavaNotes
,Java 系列文档,数据结构与算法!
本文收录于网站:
https://www.wdbyte.com/
,我的公众号:
程序猿阿朗

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。
比如说这个观点:算钱用BigDecimal是常识

image.png
image.png

有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long进行存储以及计算,这样不用担心小数点问题。
image.png
并且一些银行系统就会选择用Long
image.png
还有,最最最牛逼的万能大法:用String

image.png
成年人不做选择题,Long跟BigDecimal都用。。。
image.png
还有一种就是封装一个金额的基类,对金额进行统一处理。
image.png

排除float和double

当然,对于金额,首先我们要排除的就是float和double。它们不适合用于精确的金融计算,因为
float

double
是基于IEEE 754标准的浮点数表示,它们无法精确地表示所有的十进制小数。这会导致在进行财务计算时出现舍入误差,这些误差可能会累积并导致不可预测的结果。

关于带精度的计算,我们不推荐使用float以及double,推荐使用BigDecimal,具体原因请参考:
聊一聊_BigDecimal_使用时的陷阱

选择Long

Long
类型在Java中用于存储64位整数。它的主要优点是速度快,因为整数运算在CPU层面是非常高效的。另外,
Long
类型也占用较少的内存,并且整数类型(
BIGINT
)在数据库中占用较少的存储空间。

但是
Long
类型在处理金额时有几个明显的缺点:

  1. 精度问题

    Long
    只能存储整数,无法直接表示小数。使用
    Long
    来表示以分为单位的金额(例如,100表示1元),此时就会失去小数的精度。即使使用某种方式来表示小数(例如,乘以100或10000),也会遇到舍入误差的问题。并且这种计算方式也会增加计算的复杂度。
  2. 浮点数问题
    :虽然这不是直接使用
    Long
    的问题,但如果你尝试将
    Long
    与浮点数(如
    double

    float
    )进行转换以进行计算(比如汇率计算等),还是会遇到浮点数精度问题,这可能导致在财务计算中出现不可接受的误差。

在阿里巴巴的开发手册中建议使用Long。
image.png
但是在一些金融系统当中,对小数位要求比较高的,比如精确到小数点后6位,那么我们使用Long进行存储,每次在计算时都要除以或者乘以1000000,那么计算的开销就很大了。

并且,如果在需求确认时,我们无法知道金额要求的小数位,那我们使用Long也是不行的,我们并不知道需要乘以或者除以多少个0。

选择BigDecimal

BigDecimal
是Java提供的一个类,用于任意精度的算术运算。它的主要优点是提供了高精度的计算,这对于金融和货币计算来说是非常重要的。
BigDecimal
可以表示任意大小的正数、负数或零,并可以精确控制舍入行为。并且在数据库中存储时也有对应的类型进行匹配,比如MySQL的
DECIMAL
类型提供了精确的数值存储,可以匹配
BigDecimal
的精度。

但是
BigDecimal
也有一些缺点:

  1. 性能
    :与
    Long
    相比,
    BigDecimal
    的性能较差。因为它的运算需要更多的内存和CPU时间。
  2. 复杂性
    :使用
    BigDecimal
    进行运算比使用
    Long
    或基本数据类型更复杂。你需要考虑舍入模式、精度等因素。
  3. 在数据库中需要更多的存储空间来存储小数部分。

而在Mysql的开发手册中,建议金额需要进行小数位计算时,存储要使用Decimal,否则我们要将金额乘以对应小数位的倍数变成BIGINT进行存储。
image.png

总结

基于上述对
Long

BigDecimal
的优缺点分析,我们可以得出以下结论:

在金额计算层面,即代码实现中,推荐使用
BigDecimal
进行所有与金额相关的计算。
BigDecimal
提供了高精度的数值运算,能够确保金额计算的精确性,避免了因浮点数精度问题导致的财务误差。使用
BigDecimal
可以简化代码逻辑,减少因处理精度问题而引入的复杂性。

而在数据库存储方面,我们需要根据具体需求进行权衡。如果业务需求已经明确金额只需精确到分(如某些国家/地区的货币最小单位为分),并且我们确信不会涉及到需要更高精度的小数计算,那么可以使用
Long
类型进行存储,将金额转换为最小货币单位(如分)进行存储。这样可以节省存储空间并提高查询性能。

但是如果业务需求中金额的小数位数不确定,或者可能涉及多位小数的计算(如国际货币交易等),那么最好使用
DECIMAL

NUMERIC
类型进行存储。这些类型提供了精确的数值存储,可以确保数据库中的数据与应用程序中的
BigDecimal
对象保持一致,避免数据转换过程中可能引入的精度损失。

本文已收录于我的个人博客:
码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

概述

什么是LLaMA-Factory?

LLaMA-Factory是一个在github上开源的,专为大模型训练设计的平台。项目提供中文说明,可以参考官方文档:
https://github.com/hiyouga/LLaMA-Factory/blob/main/README_zh.md

为什么要学习LLaMA-Factory?

大模型技术发展到现在,企业想要真正利用大模型做些事情,一定需要懂得大模型微调的过程。注意,这里说的是过程,而不是原理,专业技术人员才需要懂原理,普通用户只要懂过程就可以完成对大模型的微调。
对于有微调大模型需求,却对大模型微调完全是一个门外汉的用户来说,通过学习LLaMA-Factory后,可以快速的训练出自己需要的模型。
对于想要了解微调大模型技术的技术人员,通过学习LLaMA-Factory后也能快速理解模型微调的相关概念。
所以,我认为LLaMA-Factory是走向大模型微调的一条捷径。

如何学习?

如果你只想了解如何利用LLaMA-Factory进行模型的微调,直接通过官方文档即可完成。无需阅读本文。
如果你想对大模型微调技术本身感兴趣,想要深入了解,可以继续阅读本专栏,笔者将通过阅读源码的方式,对大模型微调技术进行深入剖析,看到哪里,遇到不懂的概念再去理解,最终展现出大模型训练的全貌。
理解了微调技术后,再通过使用LLaMA-Factory进行模型的微调实践,即可掌握大模型微调技术。

基础知识

阅读源码之前,我们需要对模型微调相关概念有一定的认识,来协助我们理解源码。

模型训练阶段

在理解模型微调概念之前,我们先来理解大模型训练阶段有哪些。

Pre-Training

Pre-Training:预训练阶段。
这个阶段是用来训练基础模型的,是最消耗算力的阶段,也是大模型诞生的起始阶段。

Supervised Finetuning(SFT)

sft:指令微调/监督微调阶段
和预训练阶段相比,这个阶段最大的变化就是训练数据由"量多质低"变为"量少质高",训练数据主要由人工进行筛选或生成。这个阶段完成后其实已经能获得一个可以上线的大模型了

RLHF

RLHF:基于人类反馈的强化学习(Rainforcement Learning from Human Feedback,RLHF)
可以分成两个环节

奖励建模阶段(Reward Modeling)

在这一阶段,模型学习和输出的内容发生了根本性的改变。前面的两个阶段,预训练和微调,模型的输出是符合预期的文本内容;
奖励建模阶段的输出不仅包含预测内容,还包含奖励值或者说评分值,数值越高,意味着模型的预测结果越好。
这个阶段输出的评分,并不是给最终的用户,而是在强化学习阶段发挥重大作用。

强化学习阶段(Reinforcement Learning)

这个阶段非常“聪明”的整合了前面的成果:

  • 针对特定的输入文本,通过 SFT 模型获得多个输出文本。
  • 基于 RM 模型对多个输出文本的质量进行打分,这个打分实际上已经符合人类的期望了。
  • 基于这个打分,为多个输出文本结果加入权重。这个权重其实会体现在每个输出 Token 中。
  • 将加权结果反向传播,对 SFT 模型参数进行调整,就是所谓的强化学习。

常见的强化学习策略包括
PPO

DPO
,它们的细节我们不去研究,只要知道DPO主要用于分布式训练,适合大规模并行处理的场景,PPO通常指的是单机上的算法就可以了。

模型训练模式

了解了模型训练阶段后,现在有个问题,我们应该在哪个阶段进行微调训练呢?
通常会有以下训练模式进行选择,根据领域任务、领域样本情况、业务的需求我们可以选择合适的训练模式。
模式一:基于base模型+领域任务的SFT;
模式二:基于base模型+领域数据 continue pre-train +领域任务SFT;
模式三:基于base模型+领域数据 continue pre-train +通用任务SFT+领域任务SFT;
模式四:基于base模型+领域数据 continue pre-train +通用任务与领域任务混合SFT;
模式五:基于base模型+领域数据 continue pre-train(混入SFT数据+通用任务与领域任务混合SFT;
模式六:基于chat模型+领域任务SFT;
模式七:基于chat模型+领域数据 continue pre-train +领域任务SFT

是否需要continue pre-train

大模型的知识来自于pre-train阶段,如果你的领域任务数据集与pre-train的数据集差异较大,比如你的领域任务数据来自公司内部,pre-train训练样本基本不可能覆盖到,那一定要进行continue pre-train。
如果你的领域任务数据量较大(token在1B以上),并只追求领域任务的效果,不考虑通用能力,建议进行continue pre-train。

是选择chat模型 还是base模型

如果你有一个好的base模型,在base模型基础进行领域数据的SFT与在chat模型上进行SFT,效果上差异不大。
基于chat模型进行领域SFT,会很容导致灾难性遗忘,在进行领域任务SFT之后,模型通用能力会降低,如只追求领域任务的效果,则不用考虑。
如果你的领域任务与通用任务有很大的相关性,那这种二阶段SFT会提升你的领域任务的效果。
如果你既追求领域任务的效果,并且希望通用能力不下降,建议选择base模型作为基座模型。在base模型上进行多任务混合训练,混合训练的时候需要关注各任务间的数据配比。

其他经验

  • 在资源允许的情况下,如只考虑领域任务效果,我会选择模式二;
  • 在资源允许的情况下,如考虑模型综合能力,我会选择模式五;
  • 在资源不允许的情况下,我会考虑模式六;
  • 一般情况下,我们不用进行RLHF微调;

开发工具库Transformers

Transformers是Hugging Face提供的Python库,Hugging Face是什么这里就不介绍了。国内可以通过镜像站访问:
https://hf-mirror.com/
Transformers库的文档地址:
https://hf-mirror.com/docs/transformers/index

我们要关注那些内容呢?下边将会列举一些关键内容,详细内容请查阅官方文档。

Pipeline

Pipeline是一个用于模型推理的工具,它与模型训练关系不大,它主要是将预训练好的模型加载,推理预测使用的,我们了解它是什么即可。

AutoClass

AutoClass是一个比较重要的角色,主要是用来加载预训练模型的,通过from_pretrained()方法可以加载任意Hugging Face中的预训练模型和本地模型。

AutoTokenizer

几乎所有的NLP任务都以tokenizer开始,用它来加载模型对应的分词器。

AutoModel

真正来加载模型实例的是AutoModel,不同任务使用的AutoModel也不同,针对大语言模型一般使用AutoModelForCausalLM。

模型量化

量化技术专注于用较少的信息表示数据,同时尽量不损失太多准确性。
Transformers支持三种量化方法:AWQ、GPTQ、 BNB。底层细节我们不必研究
GPTQ是专为GPT模型设计的,AWQ适用于多种模型和任务,包括多模态语言模型。
BNB是将模型量化为8位和4位的最简单选择,4位量化可以与QLoRA一起用于微调量化LLM。

PEFT库

PEFT是Hugging Face提供的库,是一个为大型预训练模型提供多种高效微调方法的python库。
PEFT文档地址:
https://hf-mirror.com/docs/peft/index
PEFT可以轻松与Transformers库集成,一起完成模型微调的工作。
微调方式包括LoRA、AdaLoRA、P-tuning等。
补充说明:QLoRA是量化LoRA的缩写,需要把模型量化再进行训练,细节暂不研究。

LLaMA-Factory源码分析

从pt预训练开始

首先从分析pt预训练过程开始研究。
根据官方文档可知,预训练执行命令如下:

CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
    --stage pt \
    --do_train \
    --model_name_or_path path_to_llama_model \
    --dataset wiki_demo \
    --finetuning_type lora \
    --lora_target q_proj,v_proj \
    --output_dir path_to_pt_checkpoint \
    --overwrite_cache \
    --per_device_train_batch_size 4 \
    --gradient_accumulation_steps 4 \
    --lr_scheduler_type cosine \
    --logging_steps 10 \
    --save_steps 1000 \
    --learning_rate 5e-5 \
    --num_train_epochs 3.0 \
    --plot_loss \
    --fp16
参数说明
其中很多训练参数可能会看不懂,但没关系,先整体有个印象就行。

--stage pt:指定训练阶段为预训练
--do_train:指定是训练任务
--model_name_or_path:本地模型的文件路径或 Hugging Face 的模型标识符
--dataset:指定数据集
--finetuning_type lora:指定微调方法为lora
--lora_target q_proj,v_proj:Lora作用模块为q_proj,v_proj 此参数后续详解
--output_dir: 保存训练结果的路径
--overwrite_cache: 覆盖缓存的训练集和评估集
--per_device_train_batch_size 4: 每个gpu的批处理大小,训练参数
--gradient_accumulation_steps 4:梯度累计的步数,训练参数
--lr_scheduler_type cosine:学习率调度器,训练参数
--logging_steps 10:每两次日志输出间的更新步数,训练参数
--save_steps 1000:每两次断点保存间的更新步数,训练参数
--learning_rate 5e-5:学习率,adamW优化器的默认值为5e-5,训练参数
--num_train_epochs 3.0:需要执行的训练轮数,训练参数
--plot_loss:是否保存训练损失曲线
--fp16:使用fp16混合精度训练,此参数后续详解

lora_target

lora_target被设置到LoraConfig中的target_modules参数中,LoraConfig是PEFT库中提供的。
文档地址:
https://hf-mirror.com/docs/peft/v0.9.0/en/package_reference/lora#peft.LoraConfig
LLaMA-Factory框架中通过lora_target进行了封装,说明如下:

    lora_target: str = field(
        default="all",
        metadata={
            "help": """Name(s) of target modules to apply LoRA. \
                    Use commas to separate multiple modules. \
                    Use "all" to specify all the linear modules. \
                    LLaMA choices: ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], \
                    BLOOM & Falcon & ChatGLM choices: ["query_key_value", "dense", "dense_h_to_4h", "dense_4h_to_h"], \
                    Baichuan choices: ["W_pack", "o_proj", "gate_proj", "up_proj", "down_proj"], \
                    Qwen choices: ["c_attn", "attn.c_proj", "w1", "w2", "mlp.c_proj"], \
                    InternLM2 choices: ["wqkv", "wo", "w1", "w2", "w3"], \
                    Others choices: the same as LLaMA."""
        },

目前细节我们还无法理解,但可以通过以上说明进行对应的设置。
注意:经调试结果观察,Qwen1.5的lora_target与LLaMA choices相同。

混合精度训练

在深度学习中,混合精度训练是一种利用半精度浮点数(16位)和单精度浮点数(32位)混合计算的训练技术。传统上,神经网络训练过程中使用的是单精度浮点数,这需要更多的内存和计算资源。而混合精度训练通过将一部分计算过程转换为半精度浮点数来减少内存占用和加快计算速度。
FP16(半精度浮点数):FP16通常用于混合精度训练,其中大多数计算操作使用FP16来减少内存占用和加快计算速度。
BF16(bfloat16):BF16提供了更大的动态范围和更好的数值精度,相比于FP16更适合于保持梯度更新的稳定性。
FP32(单精度浮点数):传统神经网络训练中使用FP32来表示参数、梯度等数值,但相对于FP16和BF16,它需要更多的内存和计算资源。
Pure BF16(纯 bfloat16):这个术语通常用于强调使用纯粹的BF16格式,而不是在混合精度环境中与其他精度混合使用。使用纯BF16意味着所有的计算和存储都使用BF16格式。

理解源码

请自行配合源码阅读以下内容,文中不会粘贴完整源码。

源码入口

根据运行命令,我们可以从src/train_bash.py部分看起。
会进入src/llmtuner/train/pt/workflow.py中的run_pt方法中,

run_pt函数主要实现了以下功能:
加载tokenizer和dataset:根据传入的参数,使用load_tokenizer函数加载tokenizer,然后使用get_dataset函数根据tokenizer、model_args、data_args和training_args获取dataset。
加载模型:使用load_model函数根据tokenizer、model_args、finetuning_args和training_args的do_train参数加载模型。
初始化Trainer:使用CustomTrainer初始化一个Trainer实例,传入模型、参数、tokenizer、data_collator、callbacks和其他额外的参数。data_collator是通过调用DataCollatorForLanguageModeling类的实例化来创建一个数据整理器,主要用于自然语言处理任务中,将原始文本数据转换为模型可以输入的格式。
训练模型:如果training_args.do_train为True,则调用trainer的train方法进行训练,根据需要恢复checkpoint。训练完成后,保存模型、日志和状态。
评估模型:如果training_args.do_eval为True,则调用trainer的evaluate方法进行评估,并计算perplexity。然后记录和保存评估指标。
创建模型卡片:使用create_modelcard_and_push函数创建并推送模型卡片,其中包含了模型的相关信息和训练、评估结果。

开头的代码如下:

    # 获取分词器
    tokenizer = load_tokenizer(model_args)
    # 获取数据集
    dataset = get_dataset(tokenizer, model_args, data_args, training_args, stage="pt")
    # 获取模型实例
    model = load_model(tokenizer, model_args, finetuning_args, training_args.do_train)

接下来我们将分析一下以上三部分的代码实现。

load_tokenizer

先来分析load_tokenizer方法:

def load_tokenizer(model_args: "ModelArguments") -> "PreTrainedTokenizer":
    r"""
    Loads pretrained tokenizer. Must before load_model.

    Note: including inplace operation of model_args.
    """
    try_download_model_from_ms(model_args)
    init_kwargs = _get_init_kwargs(model_args)
    # 核心方法在这,加载分词器内容,具体参数含义先忽略
    tokenizer = AutoTokenizer.from_pretrained(
        model_args.model_name_or_path,
        use_fast=model_args.use_fast_tokenizer,
        split_special_tokens=model_args.split_special_tokens,
        padding_side="right",
        **init_kwargs,
    )
    patch_tokenizer(tokenizer)
    return tokenizer

get_dataset

比较核心的方法其实是get_dataset,因为要训练模型,最重要的部分是组织训练数据。
函数主要执行以下操作:

  1. 根据提供的tokenizer、model_args、data_args和training_args参数,获取数据集的模板并进行一些预处理。
  2. 检查是否需要从缓存路径加载数据集,如果是,则加载并返回数据集。
  3. 如果缓存路径不存在,则根据data_args参数获取数据集列表,并加载每个数据集。
  4. 将所有加载的数据集合并为一个数据集。
  5. 对数据集进行预处理,包括使用tokenizer对数据进行编码、根据指定的stage进行额外的预处理。
  6. 如果指定的cache_path不为空,则将预处理后的数据集保存到cache_path路径。
  7. 如果指定should_log参数,则打印数据集的一个样本

这里内容比较多,我们一步一步来分析。

获取数据集模板

首先来分析一下获取数据集模板在做什么。可以进入以下路径查看代码。
src/llmtuner/data/template.py的get_template_and_fix_tokenizer方法。

函数主要做了以下几件事情:

  1. 根据输入的 name,获取相应的模板,如果没有提供 name 或提供的 name 不存在,则使用默认模板 "vanilla"。
  2. 如果模板指示需要替换 EOS(End of String)标记,且模板还指定了停用词,则取出第一个停用词在分词器中替换 EOS 标记。,并将剩余的停用词保存起来。
  3. 如果分词器中没有定义 EOS 标记,则在分词器中添加一个空EOS 标记"<|endoftext|>"。
  4. 如果分词器中没有定义 PAD 标记,则将 PAD 标记设置为与 EOS 标记相同的值,并在日志中记录。
  5. 如果模板指定了停用词,并且有剩余的停用词,则将这些停用词添加到分词器的特殊标记中,并在日志中记录。如果成功添加了新的特殊标记,则会发出警告提醒用户确认是否需要调整词汇表大小。
  6. 尝试将模板转换为 Jinja 模板,并将其与分词器相关联。

最后返回选定的模板对象。

这段代码内容有点多,我们先不考虑模板的事,先来理解一下分词器中对应的概念。

概念理解

首先我们理解一下什么是分词器。

在自然语言处理(NLP)中,分词器(tokenizer)是一个将文本输入分割成单词、子词或符号序列的工具。这个过程称为分词或者标记化。在 NLP 中,文本通常以字符串的形式存在,而计算机需要将其转换成可以处理的结构化数据形式,例如单词序列或标记序列,以便进行后续的语言处理任务,如词嵌入、语言模型训练、序列标注等。

那分词器的EOS 标记, PAD 标记,停用词分别代表什么呢?

  1. EOS 标记(End of String):
    • EOS 标记通常用于表示序列的结束。在自然语言处理任务中,特别是序列到序列的任务(如机器翻译、文本生成等),需要在序列的末尾添加 EOS 标记以指示句子的结束。这有助于模型正确处理不同长度的输入序列。
    • 在分词器中,EOS 标记用于标记化后的文本中表示句子结束的位置。
  2. PAD 标记(Padding):
    • PAD 标记通常用于对不同长度的序列进行填充,使它们具有相同的长度。这在很多深度学习模型中是必要的,因为它们需要输入具有固定长度的序列。通过将序列填充到相同的长度,可以方便地将它们组成一个批次进行并行处理,提高训练效率。
    • 在分词器中,PAD 标记用于填充序列,使其达到指定的最大长度。
  3. 停用词:
    • 停用词是在自然语言处理任务中通常会被忽略的常见词语,因为它们通常不携带太多的信息。在一些任务中,特别是文本生成任务,停用词可能会影响模型的生成结果,因此需要在预处理阶段将其去除。
    • 在分词器中,停用词通常被用作特殊的标记,例如 EOS 或 PAD 标记的替代。在一些情况下,停用词可能还会被添加到词汇表中作为特殊标记,以便模型学习到如何处理它们。

至于Jinja模板,这里就不过多介绍了,后边我们会去查看源码,看看在做什么的。

获取模板

理解了以上内容,我们回过头来分析一下最开始的根据name获取相应模板是怎么做到的,它获取到的模板到底是什么。
通过阅读源码,我们可以看到,模板就是一个字典:

templates: Dict[str, Template] = {}

字典中的内容是通过_register_template方法注册进去的。我们来分析一下_register_template方法.
方法的入参比较多,我们可以分析各个参数的作用如下:

  • name: 模板的名称。
  • format_user: 用户对话部分的格式化器。
  • format_assistant: AI 助手对话部分的格式化器。
  • format_system: 系统对话部分的格式化器。
  • format_function: 函数对话部分的格式化器。
  • format_observation: 观察部分的格式化器。
  • format_tools: 工具部分的格式化器。
  • format_separator: 分隔符部分的格式化器。
  • default_system: 默认系统消息。
  • stop_words: 停用词列表。
  • efficient_eos: 是否使用高效 EOS 标记。
  • replace_eos: 是否替换 EOS 标记。
  • force_system: 是否强制使用系统。

函数的主要工作是根据提供的参数创建对应的格式化器,并使用这些格式化器创建一个新的对话模板(Template 对象或 Llama2Template 对象),然后将该模板注册到全局变量 templates 中。
注意,这里的Template 对象或 Llama2Template 对象都是项目自定义的类。类中的内容我们先不看。
只要知道在初始化时,会调用此方法注册模板即可。
以qwen模板为例,在代码中我们可以看到如下注册模板的内容:

_register_template(
    name="qwen",
    format_user=StringFormatter(slots=["<|im_start|>user\n{{content}}<|im_end|>\n<|im_start|>assistant\n"]),
    format_system=StringFormatter(slots=["<|im_start|>system\n{{content}}<|im_end|>\n"]),
    format_separator=EmptyFormatter(slots=["\n"]),
    default_system="You are a helpful assistant.",
    stop_words=["<|im_end|>"],
    replace_eos=True,
)

根据入参,我们再重新查看_register_template方法的源码(请自行查看源码往下看):
首先,efficient_eos没有传参,默认值为False,以至于eos_slots为[],由此可以理解所谓的高效EOS标记,就是可能不需要额外的 EOS 标记,从而节省了内存和计算资源。
后续就是在实例化Template 对象返回。

转换为 Jinja 模板

接下来我们看一下转换Jinja模板做了什么。

该函数 _get_jinja_template 的作用是根据输入的模板和分词器生成一个 Jinja2 模板字符串。
首先,函数会判断模板中是否设置了默认系统消息,如果有,则将该消息添加到 Jinja2 模板中。
接着,函数会检查模板消息列表中是否存在系统消息,并将其内容赋值给变量 system_message。
然后,根据模板类型和是否强制显示系统消息,将 system_message 变量添加到 Jinja2 模板中。
接下来,函数会遍历模板消息列表,并根据消息的角色(用户或助手)将相应的内容添加到 Jinja2 模板中。
最后,函数返回生成的 Jinja2 模板字符串。
在处理过程中,函数会使用 _convert_slots_to_jinja 函数将模板中的占位符转换为对应的 Jinja2 表达式,并使用 PreTrainedTokenizer 对模板内容进行分词处理。

可以看到,Jinja2模板中支持if else 和 for,不过这些都不重要,我们只要知道组织好模板后将模板赋值给了tokenizer的chat_template属性即可。

获取数据集列表

接下来就是获取数据集列表的实现了。主要代码如下:

    with training_args.main_process_first(desc="load dataset"):
        all_datasets = []
        for dataset_attr in get_dataset_list(data_args):
            all_datasets.append(load_single_dataset(dataset_attr, model_args, data_args))
        dataset = merge_dataset(all_datasets, data_args, training_args)

这里先来关注get_dataset_list方法。

该函数用于获取数据集列表。根据输入的data_args参数中的dataset字段,将数据集名称列表进行处理并保存。然后从data_args参数中的dataset_dir目录下读取数据集配置文件DATA_CONFIG,并解析其中的内容。实际文件路径为data/dataset_info.json。
接下来,根据配置文件中定义的数据集信息,创建并填充DatasetAttr对象,并将其添加到dataset_list列表中。最后,返回dataset_list列表。
在创建DatasetAttr对象时,根据配置文件中的不同字段,选择不同的数据集类型和属性,并设置相应的属性值。
如果配置文件中定义了列名,则将其添加到DatasetAttr对象的属性中。
如果数据集格式为sharegpt,并且配置文件中定义了标签信息,则将其添加到DatasetAttr对象的属性中。
如果在读取配置文件时发生异常,将抛出相应的异常。

现在我们知道get_dataset_list方法会返回数据集的一些元数据,load_single_dataset方法就会根据元数据来加载真正的数据了。

get_dataset_list根据给定的dataset_attr、model_args和data_args参数加载单个数据集。根据dataset_attr.load_from的值,函数从不同的来源加载数据集。支持的来源包括"Hugging Face Hub"、"ModelScope Hub"、脚本或文件。
当从"Hugging Face Hub"或"ModelScope Hub"加载数据集时,函数会使用相应的库加载数据集。
当从脚本或文件加载数据集时,函数会根据文件类型选择合适的方式加载数据。
函数还支持数据集的截断和对齐操作。

其中加载数据到内容的代码如下:

        dataset = load_dataset(
            path=data_path,
            name=data_name,
            data_dir=data_dir,
            data_files=data_files,
            split=data_args.split,
            cache_dir=model_args.cache_dir,
            token=model_args.hf_hub_token,
            streaming=(data_args.streaming and (dataset_attr.load_from != "file")),
            **kwargs,
        )

这部分使用的是Hugging Face的datasets库来进行加载的。具体使用方法可以参考官网。
https://hf-mirror.com/docs/datasets/index
这里就不介绍了。
后续使用align_dataset对数据集进行转换后返回单个数据集的结果

align_dataset函数用于对给定的dataset进行格式转换,使其符合指定的dataset_attr属性要求。
该函数根据dataset_attr的格式要求选择不同的转换函数(convert_alpaca或convert_sharegpt),并为转换后的数据集定义了特定的特征字典(features)。
转换函数将对数据集中的每个样本进行处理,重新组织其字段,并添加额外的"prompt"、"response"、"system"和"tools"字段。
处理过程中,可以选择是否使用批处理,并可以指定并行处理的工作线程数、是否从缓存文件加载以及是否覆盖缓存文件等参数。最终返回转换后的数据集。

具体的转换逻辑我们先不用看了,知道是转换成一种方便训练的格式就可以了。
后续预处理的逻辑我们也先不用看,大体了解会使用tokenizer对数据进行处理就可以了。

load_model

数据准备就绪,接下来就是加载模型了。

函数首先通过model_args.model_name_or_path使用AutoConfig获取模型的配置和初始化参数,然后根据是否可训练和是否使用unsloth选择不同的模型加载方式。
如果可训练且使用unsloth,则使用FastLanguageModel.from_pretrained加载模型;
否则,使用AutoModelForCausalLM.from_pretrained加载模型。
接着,函数会对模型进行一些修改和注册,然后根据是否添加值头(value head)来初始化或修改模型。
最后,函数将模型设置为相应的模式(可训练或不可训练),并返回模型。
参数说明:
tokenizer: 预训练的分词器。
model_args: 模型参数,包括模型名称、最大序列长度、计算数据类型等。
finetuning_args: 微调参数。
is_trainable: 模型是否可训练,默认为False。
add_valuehead: 是否添加值头,默认为False。
返回值:
model: 加载的预训练模型。

其中比较核心的就是对模型进行的一些修改和注册了。这部分代码如下:

    patch_model(model, tokenizer, model_args, is_trainable)
    register_autoclass(config, model, tokenizer)
    model = init_adapter(model, model_args, finetuning_args, is_trainable)

接下来会进入内部了解一下具体对模型做了哪些修改。

概念理解

这里我们看到了新的概念,unsloth。所以我们先来理解一下新概念。
通过观察LLaMA-Factory的可视化页面中高级设置,可以看到加速方式。加速方式包括flashattn和unsloth,那它们代表什么呢?

"unsloth" 和 "flashattn" 是两种不同的加速技术,通常用于优化神经网络模型的推理速度。

  1. Unsloth: Unsloth 是一种基于量化的加速技术,它的主要思想是通过减少模型参数的精度来降低计算的复杂度,从而提高推理速度。在 Unsloth 中,通常会将模型参数从浮点数转换为低精度的整数或定点数。这样可以降低模型的存储需求,并且在推理时减少了浮点运算的开销,从而加快了模型的推理速度。不过,由于参数精度的降低可能会带来一定的精度损失,因此在选择使用 Unsloth 技术时需要权衡推理速度和模型精度之间的关系。
  2. FlashAttn: FlashAttn 是一种用于加速注意力机制(attention mechanism)的技术。注意力机制在深度学习模型中广泛应用于处理序列数据,例如机器翻译、文本生成等任务。然而,由于注意力机制的计算量较大,它可能成为模型推理速度的瓶颈。FlashAttn 通过优化注意力计算的方式来加速模型推理过程。具体来说,FlashAttn 可能采用一些技巧,例如降低注意力矩阵的计算复杂度、减少注意力头的数量、或者采用特定的注意力结构等。这些技巧可以有效地减少模型推理时的计算量,从而加速推理速度。

简单来说,Unsloth 和 FlashAttn 都是用于加速神经网络模型推理过程的技术,但它们的具体实现和优化方式有所不同,适用于不同类型的模型和应用场景。

patch_model

patch_model函数用于根据不同的模型类型和参数,对模型和分词器进行一系列的修改和配置。
具体包括以下几个方面:

如果模型的generate方法不是GenerationMixin的子类,则将其替换为PreTrainedModel.generate方法。
如果模型配置中的model_type为"chatglm",则设置模型的lm_head为transformer.output_layer,并设置保存模型时忽略lm_head.weight。(chatglm需要一些特殊处理,我们暂不关心)
如果model_args.resize_vocab为True,则调用_resize_embedding_layer函数来调整嵌入层的大小。
如果模型是可训练的,则调用_prepare_model_for_training函数对模型进行训练前的准备。
如果模型配置中的model_type为"mixtral"且启用了DeepSpeed的Zero3优化器,则导入set_z3_leaf_modules和MixtralSparseMoeBlock,并调用set_z3_leaf_modules函数将model中的叶子模块设置为MixtralSparseMoeBlock。如果模型是可训练的,则调用patch_mixtral_replace_moe_impl函数。
尝试向模型添加标签"llama-factory",如果添加失败则打印警告信息。
这些修改和配置的目的是为了适应不同模型的需求,提高模型的性能和效率。

我们如果使用qwen模型,主要需要观察_prepare_model_for_training函数对模型做了哪些准备。

该函数主要为模型训练做准备,具体包括以下操作:
如果model_args.upcast_layernorm为True,则将模型中的层归一化(layernorm)权重转换为float32类型。
如果model_args.disable_gradient_checkpointing为False且模型支持梯度检查点(gradient checkpointing),则启用梯度检查点,并设置相关属性。
如果模型具有output_layer_name属性且model_args.upcast_lmhead_output为True,则将语言模型头(lm_head)的输出转换为float32类型

这里的概念可能不是太懂,可以先了解个大概即可。

register_autoclass

这个方法的代码如下:

def register_autoclass(config: "PretrainedConfig", model: "PreTrainedModel", tokenizer: "PreTrainedTokenizer"):
    if "AutoConfig" in getattr(config, "auto_map", {}):
        config.__class__.register_for_auto_class()
    if "AutoModelForCausalLM" in getattr(config, "auto_map", {}):
        model.__class__.register_for_auto_class()
    if "AutoTokenizer" in tokenizer.init_kwargs.get("auto_map", {}):
        tokenizer.__class__.register_for_auto_class()

就是字面意思,注册Transformers框架中的自动类,具体用处目前还不明确。

init_adapter

init_adapter函数用于初始化适配器,并支持全参数、冻结和LoRA训练。根据传入的模型、模型参数、微调参数和是否可训练,该函数将根据微调类型对模型进行相应的处理。此方法属于比较核心的方法

如果模型不可训练且没有指定适配器名称路径,则加载基本模型。
如果微调类型为"full"且模型可训练,则将模型参数转换为float32类型。
如果微调类型为"freeze"且模型可训练,则根据num_layer_trainable和其他参数来确定可训练的层,并将其他层的参数设置为不可训练。可训练的层可以是最后n层、前面n层或指定的层。
如果微调类型为"lora",则根据是否指定适配器名称路径和其他参数来加载、合并和恢复LoRA模型,并创建新的LoRA权重。
最终,该函数返回处理后的模型

这部分代码内容还是比较多的,full和freeze我们不用关注,重点关注lora部分。由于这部分比较重要,我把lora部分代码放到下边,并用注释解释一下:

    if finetuning_args.finetuning_type == "lora":
        logger.info("Fine-tuning method: {}".format("DoRA" if finetuning_args.use_dora else "LoRA"))
        adapter_to_resume = None
        # 这部分是可以通过adapter_name_or_path路径,来进行进行增量的训练,增量逻辑我们可以先不看,代码没有放到这里
        if model_args.adapter_name_or_path is not None:...
        # 重点内容在这里
        if is_trainable and adapter_to_resume is None:  # create new lora weights while training
            if len(finetuning_args.lora_target) == 1 and finetuning_args.lora_target[0] == "all":
                # 通过调试,可以在这里看到模型所有的lora_target
                target_modules = find_all_linear_modules(model)
            else:
                target_modules = finetuning_args.lora_target
            # 这里通过可视化页面,可以看到解释:仅训练块扩展后的参数。细节我们先不看
            if finetuning_args.use_llama_pro:
                target_modules = find_expanded_modules(model, target_modules, finetuning_args.num_layer_trainable)
            # 这里验证了使用dora的时候,如果使用了量化,必须是使用BNB方式,否则不支持
            if finetuning_args.use_dora and getattr(model, "quantization_method", None) is not None:
                if getattr(model, "quantization_method", None) != QuantizationMethod.BITS_AND_BYTES:
                    raise ValueError("DoRA is not compatible with PTQ-quantized models.")

            peft_kwargs = {
                "r": finetuning_args.lora_rank,
                "target_modules": target_modules,
                "lora_alpha": finetuning_args.lora_alpha,
                "lora_dropout": finetuning_args.lora_dropout,
                "use_rslora": finetuning_args.use_rslora,
            }
            # 这里使用了unsloth加速,在之前的章节中有讲到
            if model_args.use_unsloth:
                from unsloth import FastLanguageModel  # type: ignore

                unsloth_peft_kwargs = {"model": model, "max_seq_length": model_args.model_max_length}
                model = FastLanguageModel.get_peft_model(**peft_kwargs, **unsloth_peft_kwargs)
            else:
                # 组织LoraConfig
                lora_config = LoraConfig(
                    task_type=TaskType.CAUSAL_LM,
                    inference_mode=False,
                    modules_to_save=finetuning_args.additional_target,
                    use_dora=finetuning_args.use_dora,
                    **peft_kwargs,
                )
                # 加载模型
                model = get_peft_model(model, lora_config)
        # 这里的pure_bf16在前边章节页讲过,混合精度训练的一种模式
        if not finetuning_args.pure_bf16:
            for param in filter(lambda p: p.requires_grad, model.parameters()):
                param.data = param.data.to(torch.float32)

通过阅读以上源码和注释,想要更好的理解,需要解决下边的问题。

  1. lora_target应该怎么设置比较合适?
  2. dora是什么?
  3. LoraConfig应该怎么配置更合适?

想要解决这些问题,我们应该去了解一下LoraConfig。

解读LoraConfig

首先,LoraConfig属于PEFT库。所以可以阅读一下官方文档,来理解一下Lora,地址如下:
https://hf-mirror.com/docs/peft/developer_guides/lora
通过阅读这部分文档,我们可以对Lora整体有了一个认识。
之后我们可以阅读这部分内容,来理解一下LoraConfig中每个参数的作用。
https://hf-mirror.com/docs/peft/package_reference/lora
回到我们自己的代码中,现在可以解释一下这部分代码具体的含义了:

            peft_kwargs = {
                "r": finetuning_args.lora_rank,
                "target_modules": target_modules,
                "lora_alpha": finetuning_args.lora_alpha,
                "lora_dropout": finetuning_args.lora_dropout,
                "use_rslora": finetuning_args.use_rslora,
            }
            lora_config = LoraConfig(
                task_type=TaskType.CAUSAL_LM,
                inference_mode=False,
                modules_to_save=finetuning_args.additional_target,
                use_dora=finetuning_args.use_dora,
                **peft_kwargs,
            )
task_type:此参数不是LoraConfig的参数,而是它的父类PeftConfig的参数,可选值为TaskType中的值,具体的含义是什么呢?我们可以直接看源码:
class TaskType(str, enum.Enum):
    """
    Enum class for the different types of tasks supported by PEFT.

    Overview of the supported task types:
    - SEQ_CLS: Text classification.
    - SEQ_2_SEQ_LM: Sequence-to-sequence language modeling.
    - CAUSAL_LM: Causal language modeling.
    - TOKEN_CLS: Token classification.
    - QUESTION_ANS: Question answering.
    - FEATURE_EXTRACTION: Feature extraction. Provides the hidden states which can be used as embeddings or features
      for downstream tasks.
    """

    SEQ_CLS = "SEQ_CLS"
    SEQ_2_SEQ_LM = "SEQ_2_SEQ_LM"
    CAUSAL_LM = "CAUSAL_LM"
    TOKEN_CLS = "TOKEN_CLS"
    QUESTION_ANS = "QUESTION_ANS"
    FEATURE_EXTRACTION = "FEATURE_EXTRACTION"

可以看到,其实就是在指定任务类型是大语言模型。
inference_mode:此参数也是父类PeftConfig的参数,表示模型是否是推理模型,由于我们要进行训练,所以设置为False
modules_to_save:除 LoRA 层以外的可训练模块名称,我们先不用管这里。
use_dora:使用权重分解的 LoRA。
r:lora微调的维度,我们默认设置的是8。
target_modules:就是要微调的模块,前边已经有介绍。
lora_alpha:LoRA微调的缩放因子,默认为r * 2。
lora_dropout: LoRA微调的随机丢弃率。了解过深度学习的一定可以理解这个指标。
use_rslora:是否使用LoRA层的秩稳定缩放因子,阅读官网可以理解为:将适配器的缩放因子设置为 lora_alpha/math.sqrt(r) ,因为它被证明工作得更好。 否则,它将使用原始的默认值 lora_alpha/r 。
至此,目前我们已经理解了项目中使用到的参数。
其他内容可根据官方文档理解。

模型训练部分

前边的内容只是训练的前提,接下来我们就来看一下训练部分的实现。
我们回到src/llmtuner/train/pt/workflow.py中的run_pt方法中继续往下看。这里我把核心源码直接放到下边。

    # 部分主要是对数据转换,转换成模型可以输入的格式。
    data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)

    # Initialize our Trainer
    trainer = CustomTrainer(
        model=model,
        args=training_args,
        finetuning_args=finetuning_args,
        tokenizer=tokenizer,
        data_collator=data_collator,
        callbacks=callbacks,
        #就是数据集的拆分(训练集/测试集)
        **split_dataset(dataset, data_args, training_args), 
    )

    # Training
    if training_args.do_train:
        # 开始训练,resume_from_checkpoint可以是字符串或布尔值,如果为字符串,则是本地保存的检查点的路径,如果为布尔值且为True,则加载args.output_dir中由之前的[Trainer]实例保存的最后一个检查点。如果存在,则从加载的模型/优化器/调度器状态继续训练。
        train_result = trainer.train(resume_from_checkpoint=training_args.resume_from_checkpoint)
        # 保存模型
        trainer.save_model()
        # 记录指标日志
        trainer.log_metrics("train", train_result.metrics)
        # 保存指标日志
        trainer.save_metrics("train", train_result.metrics)
        # 保存训练状态
        trainer.save_state()
        # 如果是主进程,且plot_loss参数为True,则保存损失曲线图
        if trainer.is_world_process_zero() and finetuning_args.plot_loss:
            plot_loss(training_args.output_dir, keys=["loss", "eval_loss"])

首先我们先来理解一下DataCollatorForLanguageModeling,可以阅读官网文档进行理解,地址如下:
https://hf-mirror.com/docs/transformers/v4.39.1/zh/main_classes/data_collator
简单来说就是转换成模型可以输入的格式。
CustomTrainer是一个比较重要的内容,接下来我们将对它进行解读。

解读CustomTrainer

CustomTrainer继承自Trainer类,并且重写了create_optimizer_and_scheduler方法。

create_optimizer_and_scheduler方法用于创建自定义的优化器和学习率调度器。它首先调用create_custom_optimzer函数来创建优化器,该函数根据模型、参数和finetuning_args来决定如何创建优化器。如果create_custom_optimzer返回None,则调用Trainer类的create_optimizer方法来创建优化器。

我们可以看一下create_custom_optimzer的底层实现,代码内容不多,直接放到下边:

def create_custom_optimzer(
    model: "PreTrainedModel",
    training_args: "Seq2SeqTrainingArguments",
    finetuning_args: "FinetuningArguments",
    max_steps: int,
) -> Optional["torch.optim.Optimizer"]:
    if finetuning_args.use_galore:
        return _create_galore_optimizer(model, training_args, finetuning_args, max_steps)

    if finetuning_args.loraplus_lr_ratio is not None:
        return _create_loraplus_optimizer(model, training_args, finetuning_args)

如何创建自定义优化器我们先不研究,目前我们还用不到。
要研究的重点其实是Trainer类,Trainer是Transformers库中比较重要的一部分。可以阅读官方文档进行了解:
https://hf-mirror.com/docs/transformers/v4.39.1/zh/main_classes/trainer
接下来我们分析一下代码中传入的参数的含义:

model:这个不用太解释,就是传入之前加载的模型
args=training_args:这个training_args实际上是Transformers库中的Seq2SeqTrainingArguments,这其中包含的参数就比较多了。
finetuning_args:这个参数是自定义的参数,我们不关注。
tokenizer、data_collator:这两个参数不再解释,你应该也能看懂了。
callbacks:指定回调函数,LLaMA-Factory指定了一个打印日志的回调函数。
split_dataset:这是自定义的函数,用来做数据拆分,实现细节我们先不看,主要就是返回split_dataset参数和eval_dataset参数,分别用来表示训练的数据集和评估的数据集,是Trainer类自带的参数。

至此,对CustomTrainer我们已经有了整体的认识。

解读Seq2SeqTrainingArguments

为了更进一步的了解训练参数,我们可以看一下Seq2SeqTrainingArguments中都有哪些参数。官方文档地址如下:
https://hf-mirror.com/docs/transformers/v4.39.1/en/main_classes/trainer#transformers.Seq2SeqTrainingArguments
由于参数太多了,就不挨个参数解读了,只解释一些我们常用到的参数,其他参数详见官方文档:

output_dir :输出目录,将写入模型预测和检查点。
overwrite_output_dir:如果设置为 True,将覆盖输出目录中的内容。可以在 output_dir 指向检查点目录时使用此选项继续训练。
do_train :是否进行训练。这个参数不是直接由 [Trainer] 使用的
do_eval:是否在验证集上运行评估。如果 evaluation_strategy 不是 "no" ,将被设置为 True。这个参数不是直接由 [Trainer] 使用的
do_predict:是否在测试集上进行预测。这个参数不是直接由 [Trainer] 使用的
evaluation_strategy :训练过程中采用的评估策略。可能的取值有:
"no": 不进行评估。
"steps": 每隔 eval_steps 进行评估。
"epoch": 每个 epoch 结束时进行评估
per_device_train_batch_size (int,可选,默认为 8):每个 GPU/TPU 核心/CPU 的训练批次大小。
per_device_eval_batch_size (int,可选,默认为 8):每个 GPU/TPU 核心/CPU 的评估批次大小。
gradient_accumulation_steps (int,可选,默认为 1):在执行反向传播/更新步骤之前,累积梯度的更新步骤数。

模型评估部分

直接看剩余部分的代码,理解了之前训练部分的代码,评估部分代码很容易就能看懂。

    # Evaluation
    if training_args.do_eval:
        # 评估
        metrics = trainer.evaluate(metric_key_prefix="eval")
        try:
            # 计算困惑度,困惑度是自然语言处理领域常用的评价模型生成或预测文本的能力的指标,它是损失函数指数运算的结果。越低代表模型越好。
            perplexity = math.exp(metrics["eval_loss"])
        except OverflowError:
            perplexity = float("inf")
        
        metrics["perplexity"] = perplexity
        trainer.log_metrics("eval", metrics)
        trainer.save_metrics("eval", metrics)

总结

至此,我们已经打通了pt预训练这条通道,接下来我们就要开始查看sft指令微调部分的实现了。

sft指令微调

首先我们还是查看官方文档提供的sft脚本,内容如下:

CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
    --stage sft \
    --do_train \
    --model_name_or_path path_to_llama_model \
    --dataset alpaca_gpt4_zh \
    --template default \
    --finetuning_type lora \
    --lora_target q_proj,v_proj \
    --output_dir path_to_sft_checkpoint \
    --overwrite_cache \
    --per_device_train_batch_size 4 \
    --gradient_accumulation_steps 4 \
    --lr_scheduler_type cosine \
    --logging_steps 10 \
    --save_steps 1000 \
    --learning_rate 5e-5 \
    --num_train_epochs 3.0 \
    --plot_loss \
    --fp16

可以发现,只有--stage被设置成了sft,其他参数之前已经介绍过了。
所以我们直接开始查看源码。

理解源码

有了之前的经验,源码入口可以很快找到。
即src/llmtuner/train/sft/workflow.py中的run_sft方法中。这里我直接把完整代码放到下边:

def run_sft(
    model_args: "ModelArguments",
    data_args: "DataArguments",
    training_args: "Seq2SeqTrainingArguments",
    finetuning_args: "FinetuningArguments",
    generating_args: "GeneratingArguments",
    callbacks: Optional[List["TrainerCallback"]] = None,
):
    tokenizer = load_tokenizer(model_args)
    # 数据预处理部分有变化,后期可以进入查看一下
    dataset = get_dataset(tokenizer, model_args, data_args, training_args, stage="sft")
    model = load_model(tokenizer, model_args, finetuning_args, training_args.do_train)

    if training_args.predict_with_generate:
        tokenizer.padding_side = "left"  # use left-padding in generation

    if getattr(model, "is_quantized", False) and not training_args.do_train:
        setattr(model, "_hf_peft_config_loaded", True)  # hack here: make model compatible with prediction
  
    data_collator = DataCollatorForSeq2Seq(
        tokenizer=tokenizer,
        pad_to_multiple_of=8 if tokenizer.padding_side == "right" else None,  # for shift short attention
        label_pad_token_id=IGNORE_INDEX if data_args.ignore_pad_token_for_loss else tokenizer.pad_token_id,
    )

    # Override the decoding parameters of Seq2SeqTrainer
    training_args.generation_max_length = training_args.generation_max_length or data_args.cutoff_len
    training_args.generation_num_beams = data_args.eval_num_beams or training_args.generation_num_beams

    # Initialize our Trainer
    # trainer使用了CustomSeq2SeqTrainer,这是一个比较大的变化
    trainer = CustomSeq2SeqTrainer(
        model=model,
        args=training_args,
        finetuning_args=finetuning_args,
        tokenizer=tokenizer,
        data_collator=data_collator,
        callbacks=callbacks,
        compute_metrics=ComputeMetrics(tokenizer) if training_args.predict_with_generate else None,
        **split_dataset(dataset, data_args, training_args),
    )

    # Keyword arguments for `model.generate`
    gen_kwargs = generating_args.to_dict()
    gen_kwargs["eos_token_id"] = [tokenizer.eos_token_id] + tokenizer.additional_special_tokens_ids
    gen_kwargs["pad_token_id"] = tokenizer.pad_token_id
    gen_kwargs["logits_processor"] = get_logits_processor()

    # Training
    if training_args.do_train:
        train_result = trainer.train(resume_from_checkpoint=training_args.resume_from_checkpoint)
        trainer.save_model()
        trainer.log_metrics("train", train_result.metrics)
        trainer.save_metrics("train", train_result.metrics)
        trainer.save_state()
        if trainer.is_world_process_zero() and finetuning_args.plot_loss:
            plot_loss(training_args.output_dir, keys=["loss", "eval_loss"])

    # Evaluation
    if training_args.do_eval:
        metrics = trainer.evaluate(metric_key_prefix="eval", **gen_kwargs)
        if training_args.predict_with_generate:  # eval_loss will be wrong if predict_with_generate is enabled
            metrics.pop("eval_loss", None)
        trainer.log_metrics("eval", metrics)
        trainer.save_metrics("eval", metrics)

    # Predict
    # 多了一个预测推理阶段,基本过程都是一样的,只不过调用了trainer.predict方法
    if training_args.do_predict:
        predict_results = trainer.predict(dataset, metric_key_prefix="predict", **gen_kwargs)
        if training_args.predict_with_generate:  # predict_loss will be wrong if predict_with_generate is enabled
            predict_results.metrics.pop("predict_loss", None)
        trainer.log_metrics("predict", predict_results.metrics)
        trainer.save_metrics("predict", predict_results.metrics)
        trainer.save_predictions(predict_results)

    # Create model card
    create_modelcard_and_push(trainer, model_args, data_args, training_args, finetuning_args)

发现了什么?
没错,代码结构基本与之前的预训练部分差不多。
只有在get_dataset处理数据部分,会有所差异,具体差异我们暂时不看,只要知道sft阶段数据预处理时,是需要增加指令、角色信息的就可以了。
实际上,如果选择使用LLaMA-Factory进行微调,我们按照LLaMA-Factory的数据集格式要求准备数据就可以了。

解读CustomSeq2SeqTrainer

这阶段除了预处理数据部分有差别,最大的差别就是训练器与之前不同,使用的是CustomSeq2SeqTrainer,
CustomSeq2SeqTrainer是Seq2SeqTrainer的子类,而Seq2SeqTrainer是Trainer的子类。
Seq2SeqTrainer的源码我们就不去仔细阅读了,简单阅读后发现,Seq2SeqTrainer主要是重写了Trainer的评估和推理的方法,没有重写训练方法,所以与使用Trainer进行训练应该差别不大。
这里为什么使用Seq2SeqTrainer,我们不必纠结。
阅读qwen1.5官方提供的sft示例,可以看到示例中使用的也是Trainer而不是Seq2SeqTrainer。
示例地址:
https://github.com/QwenLM/Qwen1.5/blob/main/examples/sft/finetune.py

总结

可以发现,理解了pt阶段后,再来理解sft阶段其实是很容易的,一通百通,sft阶段我们就介绍到这里,如果你对哪部分感兴趣,可以自己去阅读源码,相信有了pt阶段的知识储备后,你可以很容易的阅读源码了。
另外,我们只要懂得pt与sft微调,就能实际上手微调了。所以后续的RLHF阶段就先不去查看了。

微调实践

我们已经理解了大模型微调的基本过程,但实践是检验真理的唯一标准,所以接下来将与大家一起对大模型微调进行实践,观察一下微调效果。
注意:UI界面的使用请阅读官方文档,这里不会介绍UI如何使用。
https://github.com/hiyouga/LLaMA-Factory/blob/main/README_zh.md

数据集准备

根据LLaMA-Factory官方提供的数据准备文档,可以看到训练数据的格式。地址如下:
https://github.com/hiyouga/LLaMA-Factory/blob/main/data/README_zh.md
文档中比较重要的说明部分:

对于预训练数据集,仅 prompt 列中的内容会用于模型训练。
对于偏好数据集,response 列应当是一个长度为 2 的字符串列表,排在前面的代表更优的回答

偏好数据集是用在奖励建模阶段的。

本次微调选择了开源项目数据集,地址如下:
https://github.com/KMnO4-zx/huanhuan-chat/blob/master/dataset/train/lora/huanhuan.json
下载后,将json文件存放到LLaMA-Factory的data目录下。
修改dataset_info.json 文件。
直接增加以下内容即可:

  "huanhuan": {
    "file_name": "huanhuan.json"
  }

添加后,在UI页面中直接可以看到新增加的数据集:
image.png

开始微调

为了减少资源消耗,本次选择测试的模型是Qwen1.5-0.5B-Chat。
通过可视化页面配置后,可以得到微调命令如下:

CUDA_VISIBLE_DEVICES=0 python src/train_bash.py \
    --stage sft \
    --do_train True \
    --model_name_or_path /home/jqxxuser/model/Qwen1.5-0.5B-Chat \
    --finetuning_type lora \
    --template qwen \
    --dataset_dir data \
    --dataset huanhuan \
    --cutoff_len 1024 \
    --learning_rate 5e-05 \
    --num_train_epochs 2.0 \
    --max_samples 100000 \
    --per_device_train_batch_size 2 \
    --gradient_accumulation_steps 8 \
    --lr_scheduler_type cosine \
    --max_grad_norm 1.0 \
    --logging_steps 5 \
    --save_steps 100 \
    --warmup_steps 0 \
    --optim adamw_torch \
    --output_dir saves/Qwen1.5-0.5B-Chat/lora/train_2024-03-28-10-54-09 \
    --fp16 True \
    --lora_rank 8 \
    --lora_alpha 16 \
    --lora_dropout 0.1 \
    --lora_target q_proj,v_proj \
    --plot_loss True

说明:SFT阶段是最常用训练阶段,所以我们在SFT阶段进行微调,测试效果。
我们直接在UI中点击开始即可进行微调,可以观察到整个的过程,发现损失值在逐渐降低。
image.png
等待训练完毕即可。

测试聊天效果

刷新适配器,可以看到我们刚刚训练完的模型,选择即可
image.png
选择Chat功能,加载模型后即可开始聊天。
image.png
看看效果吧:
image.png
目测效果还可以,至少我们看到模型确实发生了改变。

评估模型

通过聊天观察效果是一种直观的感觉,我们可以在通过项目自带的评估功能做一下评估。
注意,如果报错,需要确保安装了以下库。
jieba
rouge-chinese
nltk

image.png

运行后可以在目录中看到评估指标:

{
    "predict_bleu-4": 2.487403191204076,
    "predict_rouge-1": 16.790678761061947,
    "predict_rouge-2": 1.1607781979082865,
    "predict_rouge-l": 14.878193322606597,
    "predict_runtime": 900.9563,
    "predict_samples_per_second": 4.139,
    "predict_steps_per_second": 1.38
}

这些指标应该怎么看呢?首先我们来了解一下这些指标的概念。

  1. predict_bleu-4:
    • BLEU(Bilingual Evaluation Understudy)是一种常用的用于评估机器翻译质量的指标。
    • BLEU-4 表示四元语法 BLEU 分数,它衡量模型生成文本与参考文本之间的 n-gram 匹配程度,其中 n=4。
    • 值越高表示生成的文本与参考文本越相似,最大值为 100。
  2. predict_rouge-1 和 predict_rouge-2:
    • ROUGE(Recall-Oriented Understudy for Gisting Evaluation)是一种用于评估自动摘要和文本生成模型性能的指标。
    • ROUGE-1 表示一元 ROUGE 分数,ROUGE-2 表示二元 ROUGE 分数,分别衡量模型生成文本与参考文本之间的单个词和双词序列的匹配程度。
    • 值越高表示生成的文本与参考文本越相似,最大值为 100。
  3. predict_rouge-l:
    • ROUGE-L 衡量模型生成文本与参考文本之间最长公共子序列(Longest Common Subsequence)的匹配程度。
    • 值越高表示生成的文本与参考文本越相似,最大值为 100。
  4. predict_runtime:
    • 预测运行时间,表示模型生成一批样本所花费的总时间。
    • 单位通常为秒。
  5. predict_samples_per_second:
    • 每秒生成的样本数量,表示模型每秒钟能够生成的样本数量。
    • 通常用于评估模型的推理速度。
  6. predict_steps_per_second:
    • 每秒执行的步骤数量,表示模型每秒钟能够执行的步骤数量。
    • 对于生成模型,一般指的是每秒钟执行生成操作的次数。

所以,单独看指标数据的话,模型的效果并不是太好,这就需要大家自行摸索如何让模型能力更好了。

导出模型

切换到Export选项卡,指定导出目录,点击开始导出,即可导出模型。
image.png
导出后的模型与其他大模型的使用方法一致。

总结

至此,我们的一次模型微调的尝试就完成了。
可以发现,使用LLaMA-Factory进行微调基本上可以做到傻瓜式操作了。最大的工作量还是在组织训练数据这个阶段。

试用阿里云GPU服务器进行深度学习模型训练

最近在用PyTorch时发现在本地训练模型速度一言难尽,然后发现阿里云可以白嫖gpu服务器,只要没有申请过PAI-DSW资源的新老用户都可以申请5000CU*H的免费额度,三个月内有效。

阿里云免费试用活动页面

一、申请试用并创建实例

image-20240417666666235734

  1. 点击试用,完成注册、实名、领取产品,然后前往控制台

  2. 创建工作空间并授权

  3. 授权完成,前往默认工作空间

  4. 创建交互式建模(DSW)实例

    找到交互式建模(DSW)然后点击创建实例

    image-20240417112343422

    image-20240417112725354

    输入实例名称(随便写)

    image-20240417130025758

    接下来选择GPU规格,选 A10 或者 V100 只有
    这两个是支持资源包抵扣的
    (资源包在我们申请试用的时候已经自动领取了),其他的不支持,这里注意!我这里选的是 A10显卡,每小时消耗6.991计算时,性能已经够用了。V100显卡的性能更好,相应的资源包抵扣使用时间也会短一些,大家可以根据自己需求选择。

    image-20240417131023677

    然后选择镜像,点击下一步创建实例即可

    image-20240417136666660714

二、部署代码到实例进行训练
  1. 启动并打开实例

    image-20240417131541021

    打开后可以看到导航栏中有三项

    image-20240417132129994

  2. 上传训练模型的py文件

    点击这个按钮上传

    image-20240417132304798

    上传后可以在侧边栏看到

    image-20240417132440553

    也可以打开terminal查看,同样可以看到

    image-20240417132636342

  3. 开始训练

    跟在本地是一样的,依次执行generate.py、loader.py、train.py,我这里选择在terminal用命令执行,大家也可以选择在集成的vscode中执行

    # 在terminal依次输入下列命令执行
    python generate.py # 可能会提示ModuleNotFoundError: No module named 'captcha',这是我们的环境中没有captcha这个包,用pip安装即可,pip install captcha
    python loader.py
    python train.py
    
  4. 下载训练的pth文件到本地

    训练完成后会有一个pth文件,这是PyTorch训练的模型文件,我们可以下载到本地使用

    image-20240417134441844

三、总结

以上就是在阿里云人工智能平台PAI使用交互式建模(DSW)训练深度学习模型的过程,欢迎大家在评论区留言一起交流学习,有需要模型训练过程以及代码文件的可以
看我上一篇blog

背景以及需求

  1. 线上业务对数据库可用性可靠性要求较高,要求需要有双 AZ 的主备容灾机制。
  2. 主备集群要求数据和 schema 信息实时同步,数据同步平均时延要求在 1s 之内,p99 要求在 2s 之内。
  3. 主备集群数据要求一致
  4. 要求能够在主集群故障时高效自动主备倒换或者手动主备倒换,主备倒换期间丢失的数据可找回。

为什么使用 Listener

Listener:这是一种特殊的 Raft 角色,并不参与投票,也不能用于多副本的数据一致性。

原本的 NebulaGraph 中的 Listener 是一个 Raft 监听器,它的作用是将数据异步写入外部的 Elasticsearch 集群,并在查询时去查找 ES 以实现全文索引的功能。

这里我们需要的是
Listener 的监听能力
,用于
快速同步数据到其他集群
,并且是异步的执行,不影响主集群的正常读写。

这里我们需要定义两个新的 Listener 类型:

  1. Meta Listener:用于同步表结构以及其他元数据信息
  2. Storage Listener:用于同步 storaged 服务的数据

这样 storaged 服务和 metad 服务的 part leader 节点接受到写请求时,除了同步一份数据给 follower 节点,也会同步一份给各自的 listener 节点。

备集群如何接受数据?

现在我们面临几个问题:

  1. 两个新增 Listener 在接收到 leader 同步的日志后,应该如何再同步给备集群?
  2. 我们需要匹配和解析不同的数据操作,例如添加点、删除点、删除边、删除带索引的数据等等操作;
  3. 我们需要将解析到的不同操作的数据重新组装成一个请求发送给备集群的 storaged 服务和 metad 服务;

通过走读 nebula-storaged 的内核代码我们可以看到,无论是 metad 还是 storaged 的各种创建删除表结构以及各种类型数据的插入,最后都会序列化成一个 wal 的 log 发送给 follower 以及 listener 节点,最后存储在 RocksDB 中。

因此,我们的 listener 节点需要具备从 log 日志中解析并识别操作类型的能力,和封装成原请求的能力,因为我们需要将操作同步给备集群的 metad 以及 storaged 服务。

这里涉及到一个问题,主集群的 listener 需要如何感知备集群?备集群 metad 服务的信息以及 storaged 服务的信息?从架构设计上来看,两个集群之间应该有一个接口通道互相连接,但又不干涉,如果由 listener 节点直接发送请求给备集群的 nebula 进程,两个集群的边界就不是很明显了。所以这里我们再引入一个备集群的服务 listener 服务,它用于接收来自主集群的 listener 服务的请求,并将请求转发给自己集群的 metad 以及 storaged 服务。

这样做的好处。两边集群的服务模块是对称的,方便我们后面快速地做主备切换。

Listener 节点的管理和可靠性

为了保证双 AZ 环境的可靠性,很显然 Listener 节点也是需要多节点多活的,在 nebula 内核源码中是有对于 listener 的管理逻辑,但是比较简单,我们还需要设计一个 ListenerManager 实现以下几点能力:

  1. listener 节点注册以及删除命令
  2. listener 节点动态负载均衡(尽量每个 space 各个 part 分布的 listener 要均匀)
  3. listener 故障切换

节点注册管理以及负载均衡都比较简单好设计,比较重要的一点是故障切换应该怎么做?

listener 故障切换的设计

listener 节点故障切换的需求可以拆分为以下几个部分:

  1. listener 同步 wal 日志数据时周期性记录同步的进度(commitId && appendLogId);
  2. ListenerManager 感知到 listener 故障后,触发动态负载均衡机制,将故障 listener 的 part 分配给其他在运行的 listener;
  3. 分配到新 part 的 listener 们获取原先故障 listener 记录的同步进度,并以该进度为起始开始同步数据;

至于 listener 同步 wal 日志数据时周期性记录同步的进度应该记录到哪里?可以是存储到 metad 服务中,也可以存储到 storaged 服务对应的 part 中。

nebula 主备切换设计

在聊主备切换之前,我们还需要考虑一件事,那就是双 AZ 环境中,应该只能有主集群是可读可写的,而其他备集群应该是只读不能写。这样是为了保证两边数据的最终一致性,备集群的写入只能是由主集群的 listener 请求来写入的,而不能被 graphd 服务的请求写入。

所以我们需要对集群状态增加一种“
只读模式
”,在这种只读模式下,表明当前集群状态是处于备集群的状态,拒绝来自 graphd 服务的写操作。同样的,备集群的 listener 节点处在只读状态时,也只能接收来自主集群的请求并转发给备集群的进程,拒绝来自备集群的 wal 日志同步。

主备倒换发生时,需要有以下几个动作:

  1. 主集群的每个 listener 记录自己所负责的 part 的同步进度(commitId && appendLogId);
  2. 备集群的 nebula 服务转换为可写;
  3. 备集群的 listener 节点转换为可写,并且开始接收来自自己集群的 metad 和 storaged 进程的 wal 日志;
  4. 主集群的 listener 以及各个服务转换为只读状态,开始接收来自新的主集群的数据同步请求;

这几个动作细分下来,最主要的内容就是状态转换以及上下文信息保存和同步,原主集群需要保存自己主备切换前的上文信息(比如同步进度),新的主集群需要加载自己的数据同步起始进度(从当前最新的 commitLog 开始)

主备切换过程中的数据丢失问题

很明显,在上面的设计中,当主备切换发生时,会有一段时间的“双主”的阶段,在这个阶段内,原主集群的剩余日志已经不能再同步给备集群了,这就是会被丢失的数据。如何恢复这些被丢失的数据,可能的方案有很多,因为原主集群的同步进度是有记录的,有哪些数据还没同步完也是可以查询到的,所以可以手动或者自动去单独地同步那一段缺失数据。

当然这种方案也会引入新的问题,这段丢失地数据同步给主集群后,主集群会再次同步一遍回现在的备集群,一段 wal 数据的两次重复操作,不知道为引起什么其他的问题。

所以关于主备切换数据丢失的问题,我们还没有很好的处理方案,感兴趣的伙伴欢迎在评论区讨论。


感谢你的阅读 (///▽///)

对图数据库 NebulaGraph 感兴趣?欢迎前往 GitHub ✨ 查看源码:
https://github.com/vesoft-inc/nebula

想和其他图技术爱好者一起交流心得?和 NebulaGraph
星云小姐姐
交个朋友再进个交流群;