深入理解Pytorch中的分布式训练

news2024/11/26 22:26:41

67d8a18a7e6de7980a28eaf853c5b42d.png

90e1b5f9ef87f9f708025a87a9806238.png

作者:台运鹏 (正在寻找internship...)
主页:https://yunpengtai.top

鉴于网上此类教程有不少模糊不清,对原理不得其法,代码也难跑通,故而花了几天细究了一下相关原理和实现,欢迎批评指正!

关于此部分的代码,可以去https://github.com/sherlcok314159/dl-tools查看

「在开始前,我需要特别致谢一下一位挚友,他送了我双显卡的机器来赞助我做个人研究,否则多卡的相关实验就得付费在云平台上跑了,感谢好朋友一路以来的支持,这份恩情值得一辈子铭记!这篇文章作为礼物赠与挚友。」

[上一篇] DataParallel里为什么会显存不均匀以及如何解决

实现原理

  1. 与DataParallel不同的是,Distributed Data Parallel会开设多个进程而非线程,进程数 = GPU数,每个进程都可以独立进行训练,也就是说代码的所有部分都会被每个进程同步调用,如果你某个地方print张量,你会发现device的差异

  2. sampler会将数据按照进程数切分,「确保不同进程的数据不同」

  3. 每个进程独立进行前向训练

  4. 每个进程利用Ring All-Reduce进行通信,将梯度信息进行聚合

  5. 每个进程同步更新模型参数,进行新一轮训练

按进程切分

如何确保数据不同呢?不妨看看DistributedSampler的源码

# 判断数据集长度是否可以整除GPU数
# 如果不能,选择舍弃还是补全,进而决定总数
# If the dataset length is evenly divisible by # of replicas
# then there is no need to drop any data, since the dataset 
# will be split equally.
if (self.drop_last and 
 len(self.dataset) % self.num_replicas != 0):
 # num_replicas = num_gpus
    self.num_samples = math.ceil((len(self.dataset) - 
        self.num_replicas) /self.num_replicas)
else:
    self.num_samples = math.ceil(len(self.dataset) / 
        self.num_replicas) 
self.total_size = self.num_samples * self.num_replicas

# 根据是否shuffle来创建indices
if self.shuffle:
    # deterministically shuffle based on epoch and seed
    g = torch.Generator()
    g.manual_seed(self.seed + self.epoch)
    indices = torch.randperm(len(self.dataset), generator=g).tolist()  
else:
    indices = list(range(len(self.dataset)))  
if not self.drop_last:
    # add extra samples to make it evenly divisible
    padding_size = self.total_size - len(indices)
    if padding_size <= len(indices):
        # 不够就按indices顺序加
        # e.g., indices为[0, 1, 2, 3 ...],而padding_size为4
        # 加好之后的indices[..., 0, 1, 2, 3]
        indices += indices[:padding_size]
    else:
        indices += (indices * math.ceil(padding_size / len(indices)))[:padding_size]
else:
    # remove tail of data to make it evenly divisible.
    indices = indices[:self.total_size]
assert len(indices) == self.total_size
# subsample
# rank代表进程id
indices = indices[self.rank:self.total_size:self.num_replicas]
return iter(indices)

Ring All-Reduce

那么什么是「Ring All-Reduce」呢?又为啥可以降低通信成本呢?

首先将每块GPU上的梯度拆分成四个部分,比如,如下图(此部分原理致谢下王老师,讲的很清晰[1]

94b83fcda058604494f74631ce2e14a8.png

所有GPU的传播都是「同步」进行的,传播的规律有两条:

  1. 只与自己下一个位置的GPU进行通信,比如0 > 1,3 > 0

  2. 四个部分,哪块GPU上占的多,就由该块GPU往它下一个传,初始从主节点传播,即GPU0,你可以想象跟接力一样,a传b,b负责传给c

第一次传播如下:

a1189374f4a34f8825383febec63c2a1.png

那么结果就是:

61fbc6f6eee6dd0cc7ffff329aeb4f24.png

那么,按照谁多谁往下传的原则,此时应该是GPU1往GPU2传a0和a1,GPU2往GPU3传b1和b2,以此类推

de675522b92ba34d56026ec1345f0871.png

接下来再传播就会有GPU3 a的部分全有,GPU0上b的部分全有等,就再往下传

feb7e77d93a1b674bd53a39447d5bc1b.png

再来几遍便可以使得每块GPU上都获得了来自其他GPU的梯度啦

4f2d9ff43a7d0ebeefe2eb25f23620a4.png

代码使用

基础概念

第一个是后端的选择,即数据传输协议,从下表可以看出[2],当使用CPU时可以选择gloo而GPU则可以是nccl

「Backend」「gloo」
「mpi」
「nccl」
DeviceCPUGPUCPUGPUCPUGPU
send?
recv?
broadcast?
all_reduce?
reduce?
all_gather?
gather?
scatter?
reduce_scatter
all_to_all?
barrier?

接下来是一些参数的解释[3]

ArgMeaning
group一次发起的所有进程构成一个group,除非想更精细通信,创建new_group
world_size一个group中进程数目,即为GPU的数量
rank进程id,主节点rank=0,其他的在0和world_size-1之间
local_rank进程在本地节点/机器的id

举个例子,假如你有两台服务器(又被称为node),每台服务器有4张GPU,那么,world_size即为8,rank=[0, 1, 2, 3, 4, 5, 6, 7], 每个服务器上的进程的local_rank为[0, 1, 2, 3]

然后是「初始化方法」的选择,有TCP共享文件两种,一般指定rank=0为master节点

TCP显而易见是通过网络进行传输,需要指定主节点的ip(可以为主节点实际IP,或者是localhost)和空闲的端口

import torch.distributed as dist

dist.init_process_group(backend, init_method='tcp://ip:port',
                        rank=rank, world_size=world_size)

共享文件的话需要手动删除上次启动时残留的文件,加上官方有一堆警告,还是建议使用TCP

dist.init_process_group(backend, init_method='file://Path', 
                        rank=rank, world_size=world_size)

launch方法

「初始化」

这里先讲用launch的方法,关于torch.multiprocessing留到后面讲

在启动后,rank和world_size都会自动被DDP写入环境中,可以提前准备好参数类,如argparse这种

args.rank = int(os.environ['RANK'])
args.world_size = int(os.environ['WORLD_SIZE'])
args.local_rank = int(os.environ['LOCAL_RANK'])

首先,在使用distributed包的任何其他函数之前,按照tcp方法进行初始化,需要注意的是需要手动指定一共可用的设备CUDA_VISIBLE_DEVICES

def dist_setup_launch(args):
    # tell DDP available devices [NECESSARY]
    os.environ['CUDA_VISIBLE_DEVICES'] = args.devices
    args.rank = int(os.environ['RANK'])
    args.world_size = int(os.environ['WORLD_SIZE'])
    args.local_rank = int(os.environ['LOCAL_RANK'])

    dist.init_process_group(args.backend, 
                            args.init_method,
                            rank=args.rank,
                            world_size=args.world_size)
    # this is optional, otherwise you may need to specify the 
    # device when you move something e.g., model.cuda(1) 
    # or model.to(args.rank)
    # Setting device makes things easy: model.cuda()
    torch.cuda.set_device(args.rank)
    print('The Current Rank is %d | The Total Ranks are %d' 
          %(args.rank, args.world_size))

「DistributedSampler」

接下来创建DistributedSampler,是否pin_memory,根据你本机的内存决定。pin_memory的意思是提前在内存中申请一部分专门存放Tensor。假如说你内存比较小,就会跟虚拟内存,即硬盘进行交换,这样转义到GPU上会比内存直接到GPU耗时。

因而,如果你的内存比较大,可以设置为True;然而,如果开了导致卡顿的情况,建议关闭

from torch.utils.data import DataLoader, DistributedSampler

train_sampler = DistributedSampler(train_dataset, seed=args.seed)
train_dataloader = DataLoader(train_dataset,
                              pin_memory=True,
                              shuffle=(train_sampler is None),
                              batch_size=args.per_gpu_train_bs,
                              num_workers=args.num_workers,
                              sampler=train_sampler)

eval_sampler = DistributedSampler(eval_dataset, seed=args.seed)
eval_dataloader = DataLoader(eval_dataset,
                             pin_memory=True,
                             batch_size=args.per_gpu_eval_bs,
                             num_workers=args.num_workers,
                             sampler=eval_sampler)

「加载模型」

然后加载模型,跟DataParallel不同的是需要提前放置到cuda上,还记得上面关于设置cuda_device的语句嘛,因为设置好之后每个进程只能看见一个GPU,所以直接model.cuda(),不需要指定device

同时,我们必须给DDP提示目前是哪个rank

from torch.nn.parallel import DistributedDataParallel as DDP
model = model.cuda()
# tell DDP which rank
model = DDP(model, find_unused_parameters=True, device_ids=[rank])

注意,当模型带有Batch Norm时:

if args.syncBN:
    nn.SyncBatchNorm.convert_sync_batchnorm(model).cuda()

「训练相关」

每个epoch开始训练的时候,记得用sampler的set_epoch,这样使得每个epoch打乱顺序是不一致的

关于梯度回传和参数更新,跟正常情况无异

for epoch in range(epochs):
    # record epochs
    train_dataloader.sampler.set_epoch(epoch)
    outputs = model(inputs)
    loss = loss_fct(outputs, labels)
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()

这里有一点需要小心,这个loss是各个进程的loss之和,如果想要存储每个step平均损失,可以进行all_reduce操作,进行平均,不妨看官方的小例子来理解下:

>>> # All tensors below are of torch.int64 type.
>>> # We have 2 process groups, 2 ranks.
>>> tensor = torch.arange(2, dtype=torch.int64) + 1 + 2 * rank
>>> tensor
tensor([1, 2]) # Rank 0
tensor([3, 4]) # Rank 1
>>> dist.all_reduce(tensor, op=ReduceOp.SUM)
>>> tensor
tensor([4, 6]) # Rank 0
tensor([4, 6]) # Rank 1
@torch.no_grad()
def reduce_value(value, average=True):
    world_size = get_world_size()
    if world_size < 2:  # 单GPU的情况
        return value
    dist.all_reduce(value)
    if average:
     value /= world_size
    return value

看到这,肯定有小伙伴要问,那这样我们是不是得先求平均损失再回传梯度啊,不用,因为,当我们回传loss后,DDP会自动对所有梯度进行平均[4],也就是说回传后我们更新的梯度和DP或者单卡同样batch训练都是一致的

loss = loss_fct(...)
loss.backward()
# 注意在backward后面
loss = reduce_value(loss, world_size)
mean_loss = (step * mean_loss + loss.item()) / (step + 1)

还有个注意点就是学习率的变化,这个是和batch size息息相关的,如果batch扩充了几倍,也就是说step比之前少了很多,还采用同一个学习率,肯定会出问题的,这里,我们进行线性增大[5]

N = world_size
lr = args.lr * N

肯定有人说,诶,你线性增大肯定不能保证梯度的variance一致了,正确的应该是正比于,关于这个的讨论不妨参考[6]

「evaluate相关」

接下来,细心的同学肯定好奇了,如果验证集也切分了,metric怎么计算呢?此时就需要咱们把每个进程得到的预测情况集合起来,t就是一个我们需要gather的张量,最后将每个进程中的t按照第一维度拼接,先看官方小例子来理解all_gather

>>> # All tensors below are of torch.int64 dtype.
>>> # We have 2 process groups, 2 ranks.
>>> tensor_list = [torch.zeros(2, dtype=torch.int64) for _ in range(2)]
>>> tensor_list
[tensor([0, 0]), tensor([0, 0])] # Rank 0 and 1
>>> tensor = torch.arange(2, dtype=torch.int64) + 1 + 2 * rank
>>> tensor
tensor([1, 2]) # Rank 0
tensor([3, 4]) # Rank 1
>>> dist.all_gather(tensor_list, tensor)
>>> tensor_list
[tensor([1, 2]), tensor([3, 4])] # Rank 0
[tensor([1, 2]), tensor([3, 4])] # Rank 1
def sync_across_gpus(t, world_size):
    gather_t_tensor = [torch.zeros_like(t) for _ in 
                       range(world_size)]
    dist.all_gather(gather_t_tensor, t)
    return torch.cat(gather_t_tensor, dim=0)

可以简单参考我前面提供的源码的evaluate部分,我们首先将预测和标签比对,把结果为bool的张量存储下来,最终gather求和取平均。

这里还有个有趣的地方,tensor默认的类型可能是int,bool型的res拼接后自动转为0和1了,另外bool型的张量是不支持gather的

def eval(...)
    results = torch.tensor([]).cuda()
    for step, (inputs, labels) in enumerate(dataloader):
        outputs = model(inputs)
        res = (outputs.argmax(-1) == labels)
        results = torch.cat([results, res], dim=0)

    results = sync_across_gpus(results, world_size)
    mean_acc = (results.sum() / len(results)).item()
    return mean_acc

「模型保存与加载」

模型保存,参考部分官方教程[7],我们只需要在主进程保存模型即可,注意,这里是被DDP包裹后的,DDP并没有state_dict,这里barrier的目的是为了让其他进程等待主进程保存模型,以防不同步

def save_checkpoint(rank, model, path):
    if is_main_process(rank):
     # All processes should see same parameters as they all 
        # start from same random parameters and gradients are 
        # synchronized in backward passes.
        # Therefore, saving it in one process is sufficient.
        torch.save(model.module.state_dict(), path)
    
    # Use a barrier() to keep process 1 waiting for process 0
    dist.barrier()

加载的时候别忘了map_location,我们一开始会保存模型至主进程,这样就会导致cuda:0显存被占据,我们需要将模型remap到其他设备

def load_checkpoint(rank, model, path):
    # remap the model from cuda:0 to other devices
    map_location = {'cuda:%d' % 0: 'cuda:%d' % rank}
    model.module.load_state_dict(
        torch.load(path, map_location=map_location)
    )

进程销毁

运行结束后记得销毁进程:

def cleanup():
    dist.destroy_process_group()
    
cleanup()

如何启动

在终端输入下列命令【单机多卡】

python -m torch.distributed.launch --nproc_per_node=NUM_GPUS
           main.py (--arg1 --arg2 --arg3 and all other
           arguments of your training script)

目前torch 1.10以后更推荐用run

torch.distributed.launch -> torch.distributed.run / torchrun

多机多卡是这样的:

# 第一个节点启动
python -m torch.distributed.launch \
    --nproc_per_node=NUM_GPUS \
    --nnodes=2 \
    --node_rank=0 \
    --master_addr="192.168.1.1" \
    --master_port=1234 main.py 

# 第二个节点启动
python -m torch.distributed.launch \
    --nproc_per_node=NUM_GPUS \
    --nnodes=2 \
    --node_rank=1 \
    --master_addr="192.168.1.1" \
    --master_port=1234 main.py

mp方法

第二个方法就是利用torch的多线程包

import torch.multiprocessing as mp
# rank mp会自动填入
def main(rank, arg1, ...):
    pass

if __name__ == '__main__':
    mp.spawn(main, nprocs=TOTAL_GPUS, args=(arg1, ...))

这种运行的时候就跟正常的python文件一致:

python main.py

优缺点

  • 「优点」:相比于DP而言,不需要反复创建和销毁线程;Ring-AllReduce算法提高通信效率;模型同步方便

  • 「缺点」:操作起来可能有些复杂,一般可满足需求的可先试试看DataParallel

参考资料

[1]

并行计算与机器学习: https://www.youtube.com/watch?v=rj-hjS5L8Bw

[2]

Backends: https://pytorch.org/docs/stable/distributed.html#backends

[3]

In distributed computing, what are world size and rank?: https://stackoverflow.com/questions/58271635/in-distributed-computing-what-are-world-size-and-rank

[4]

Average loss in DP and DDP: https://discuss.pytorch.org/t/average-loss-in-dp-and-ddp/93306/4

[5]

Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour: https://arxiv.org/abs/1706.02677

[6]

How to scale learning rate with batch size for DDP training?: https://github.com/Lightning-AI/lightning/discussions/3706

[7]

GETTING STARTED WITH DISTRIBUTED DATA PARALLEL: https://pytorch.org/tutorials/intermediate/ddp_tutorial.html


📝论文解读投稿,让你的文章被更多不同背景、不同方向的人看到,不被石沉大海,或许还能增加不少引用的呦~ 投稿加下面微信备注“投稿”即可。

最近文章

COLING'22 | SelfMix:针对带噪数据集的半监督学习方法

ACMMM 2022 | 首个针对跨语言跨模态检索的噪声鲁棒研究工作

ACM MM 2022 Oral  | PRVR: 新的文本到视频跨模态检索子任务

统计机器学习方法 for NLP:基于CRF的词性标注

统计机器学习方法 for NLP:基于HMM的词性标注


点击这里进群—>加入NLP交流群

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/143373.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Python • 字符串】巧用python字符串切片

文章目录前言字符串切片常用用法理解一个字符串逆向下标的字符串字符串切片截取下标a到b的字符串取下标a以后的所有字符串取下标a以前的所有字符串间隔n个字符取字符串字符串逆序输出从下标a的字符开始逆序取字符串逆序输出从b到a的字符串逆序间隔一个字符串输出总结前言 在py…

站酷基于服务网格ASM的生产实践

作者&#xff1a;服务网格ASM 背景介绍 站酷&#xff08;ZCOOL&#xff09;2006 年 8 月创立于北京&#xff0c;深耕设计领域多年&#xff0c;聚集了 1500 万设计师、摄影师、插画师、艺术家、创意人&#xff0c;在设计创意群体中具有一定的影响力与号召力。站酷在创立之初&am…

企业数字化转型“核心方法论”

一、什么是数字化转型&#xff1f;数字化转型是近年来&#xff0c;很多企业老生常谈的话题。那么听了这么多数字化转型的故事&#xff0c;你对其真正了解多少呢&#xff1f;下面织信就数字化转型的背景、以及多个示例的讲解&#xff0c;带你深入理解“数字化转型”这一概念。&a…

构建自组织团队,让敏捷管理更好地落地

敏捷开发是以用户的需求为核心&#xff0c;通过不断迭代、小步快跑、循序渐进的方法进行软件产品的研发&#xff0c;在迭代研发过程中的产品都需要经过测试&#xff0c;具备可视化、可集成和可运行使用的特征。 在团队方面&#xff0c;敏捷开发倡导团队协作&#xff0c;强调个…

React(coderwhy)- 05(redux)

Redux的核心思想 理解JavaScript纯函数 ◼ 函数式编程中有一个非常重要的概念叫纯函数&#xff0c;JavaScript符合函数式编程的范式&#xff0c;所以也有纯函数的概念&#xff1b;  在react开发中纯函数是被多次提及的&#xff1b;  比如react中组件就被要求像是一个纯函数…

数据库迁移到达梦的一些记录

1 达梦迁移工具 参考MySQL迁移到达梦-超出定义长度问题 使用你设置的配置&#xff0c;即可&#xff0c;因为达梦默认采用按照字节存储&#xff0c;mysql是按照字符存储&#xff0c;故而他认为越界了。 使用3.5G数据&#xff0c;342张表进行数据迁移验证 第一此执行,结果 任务…

linux安装mysql以及允许外网访问mysql

下载社区版本 下载最新版 下载历史版本 点击 Archives 文件上传云服务器 /opt/mysql 存放目录随便 添加用户组 groupadd mysql useradd -r -g mysql mysql删除mariadb rpm -qa|grep mariadbrpm -e --nodeps mariadb-libs安装 解压tar -xvf mysql-5.7.20-1.el7.x86_64…

中国企业邮箱安全性研究报告

声明 本文是学习中国企业邮箱安全性研究报告. 下载地址 http://github5.com/view/55003而整理的学习笔记,分享出来希望更多人受益,如果存在侵权请及时联系我们 奇安信网神邮件威胁检测系统 奇安信网神邮件威胁检测系统是奇安信集团面向政府、企业、金融、军队等大型企事业单…

javaEE初阶 — 阻塞队列

文章目录阻塞队列1. 概念与特性2. 生产者与消费者模型2.1 生产者消费者模型的两个好处&#xff08;主要的&#xff09;3. 标准库中的阻塞队列3.1 代码实现生产者消费者模型4. 阻塞队列实现4.1 普通队列实现4.2 给队列追加阻塞功能阻塞队列 1. 概念与特性 阻塞队列 是一种特殊的…

Centos 安装Datax及Datax-web

异构数据的采集&#xff0c;方案还是比较多样&#xff0c;除了基于大数据平台的一些集成&#xff1b;简单的数据源&#xff0c;可以直接使用阿里开源的datax来实现&#xff1b;datax-web则是datax界面化操作的开源框架&#xff0c;集成了datax异构数据采集和任务调度的功能。关…

【Kubernetes | Pod 系列】 Pod 的生命周期 Ⅱ —— 容器重启策略

目录题5.4 容器重启策略示例&#xff08;1&#xff09;Always 策略&#xff08;2&#xff09;OnFailure 策略&#xff08;3&#xff09;Never 策略5.4 容器重启策略 在 Pod 的 YAML 清单的 spec 中包含一个 restartPolicy 字段&#xff0c;其可能取值包括 Always&#xff08;…

Jdbc配置文件连接mysql8.0——批量增删改查操作

目录 一、批量插入数据 (一)在DogDao中新增一个功能saveDogList (二)DogDaoImpl实现类中定义字符串拼接实现功能saveDogList (三)DogTest测试 (四)批量插入运行结果 (五)优化后的批量插入 1.DogDao接口中还是使用原来的新增功能saveDog 2. DogDaoImpl实现类中实现saveD…

DOS、DDos攻击详解

目录 一、DDOS 是什么&#xff1f; 二、DDoS的危害 三、常见的DOS攻击 四.DDoS的防范 一、DDOS 是什么&#xff1f; DoS为Denial of Service的简称&#xff0c;意思是拒绝服务。DoS攻击是一种使被攻击者无法正常提供服务的攻击 来解释一下&#xff0c;DDOS 是什么。 举例…

esxi6.0安装

一、安装exsi需要注意的事项&#xff1a; 1.进入BIOS做raid 不同硬件厂商进入bios的方式各不相同&#xff0c;请自行查阅相关资料 RAID 0的特点&#xff1a; 最少需要两块磁盘 数据条带式分布 没有冗余&#xff0c;性能最佳(不存储镜像、校验信息) 不能应用于对数据安全性要求…

代码随想录回溯总结

文章目录0、 前言1、回溯的定义2、回溯解决那些问题3、回溯模板4、问题详解4、1组合问题&#xff1a;[4.1.1 组合](https://leetcode.cn/problems/combinations/)[4.1.2 组合总和 II](https://leetcode.cn/problems/combination-sum-ii/)4.1.3 组合的其他问题4.2排列问题4.2.1[…

3.Isaac入门

Isaac入门 本节提供有关如何开始开发和运行 Isaac 应用程序的指南。 文章目录Isaac入门教程和示例应用程序运行应用程序应用程序控制台选项在 Jetson 上部署和运行Python 应用程序支持使用分布式工作区使用 Docker安装依赖创建 Isaac SDK 开发镜像教程和示例应用程序 有一个可…

【运维有小邓】实时告警通知

当网络上发生特定事件时&#xff0c;EventLog Analyzer可以通过多种方式进行响应。EventLog Analyzer可以实时生成告警 - 发送电子邮件或短信通知给指定的接收者 - 或运行由管理员提供的自定义脚本。通过所提供的多个选项&#xff0c;用户可以确保不会错过任何安全事件。EventL…

Python解题 - CSDN周赛第21期 - 接雨水

本期比赛都是比较基础的排序、查找&#xff0c;没有多少难度。不过有很多人反映第二题测试数据有问题&#xff0c;基本所有选手在本题上都没得分。最近官方每期比赛都会有类似的数据问题&#xff0c;虽然参赛者对数据有疑问&#xff0c;但从未得到解答&#xff0c;官方也未曾公…

十年云深时:天翼云的成长密钥

工业时代&#xff0c;我们通常以“用电量”作为指标&#xff0c;来衡量一个地方的经济发展水平。数字经济时代&#xff0c;“用云量”则与数字经济规模呈正相关。因此&#xff0c;中国数字化浪潮的持续推进&#xff0c;也让云计算行业的重要性与市场竞争性不断提升&#xff0c;…

【C语言】交换奇偶位和 offsetof 宏的实现

​&#x1f320; 作者&#xff1a;阿亮joy. &#x1f386;专栏&#xff1a;《阿亮爱刷题》 &#x1f387; 座右铭&#xff1a;每个优秀的人都有一段沉默的时光&#xff0c;那段时光是付出了很多努力却得不到结果的日子&#xff0c;我们把它叫做扎根 目录&#x1f449;交换奇偶…