前言(废话)

我们都知道,存在若干种方法可以加速我们在训练深度学习模型时的速度,本人大致知道如下几种(具体):

  • mlkdnn加速
  • cuda加速
  • JIT加速
  • 分布式训练

这些都是成效显著的加速方法,而且属于不同层面的加速。其中基于硬件GPU加速的是目前最主流、效果最好的方案了,在本人自己写框架的时候也发现,深度学习训练过程中也发现,其实可以优化的地方很多:batch size之间可以做并行,单个batch中基于不同的算子实现逻辑,也能做并行;每个训练集的batch之间也能做并行,如何做到高效调度本机GPU的计算资源,或是计算机集群的计算资源,使得整体的并行训练效率尽可能高?

HPC-AI Technology Inc. 和NUS的研究人员共同开发了“夸父”Colossal-AI,该框架提供了一个并行训练框架来尽可能提升(压榨)你的计算资源的使用效率。并自动实现了一些常用的训练技巧。

由于本人的笔记本前几天又在我relink的时候烂尾了,暂时无法参与本篇文章,所以这篇文章只能展示一下单机单卡的性能压榨,等我把笔记本修好了,再来尝试一下多机多卡分布式训练。


安装、验证

实验环境

平台\软件包 版本
Win11 WSL2 0.58.3.0
NVCC 11.3
Python 3.6.9
torch 1.10.2+cu113

GPU为12G的RTX3060

请注意:

  1. 目前colossalai只支持Linux平台下的安装
  2. 尽量让你的NVCC版本>=11.3

不满足第一个条件,则下面pip install时大概率安装失败(今天上午提discussion时开发团队的成员秒回了会在Windows进行试验),不满足第二个条件,有可能失败。

本人主力机就是windows,可喜的是,目前的WSL2已经支持和宿主机共享显卡驱动了,因此,我在WSL2上完成了实验。

安装 colossalai

一句话就可以安装:

building wheel的过程很慢,因为会本地编译与CUDA相关的一些程序。

安装过程可能会出现报错,不过colossalai的setup.py写得很详细了,反正出错了就按照报错信息增加环境变量和安装包就行。

本人实测下来,你最好在colossalai安装前把tensorboard额外下载好,安装程序对tensorboard版本选取有bug。我在另一台电脑上安装时还遇到了交叉编译警告的问题,添加一个环境变量就好了:

$export TORCH_CUDA_ARCH_LIST="compute capability"

验证安装是否成功

我们使用官方给出的例子进行验证,首先创建环境变量DATA,等一下CIFAR-10数据集会下载在DATA代表的文件夹下。

(更多的例子请看官方的Example仓库 GitHub - hpcaitech/ColossalAI-Examples: Examples of training models with hybrid parallelism using ColossalAI

复制下述代码到 eval.py

from pathlib import Path
from colossalai.logging import get_dist_logger
import colossalai
import torch
import os
from colossalai.core import global_context as gpc
from colossalai.utils import get_dataloader
from colossalai.context import Config
from colossalai.amp import AMP_TYPE

from torchvision import transforms
from colossalai.nn.lr_scheduler import CosineAnnealingLR
from torchvision.datasets import CIFAR10
from torchvision.models import resnet34
from tqdm import tqdm

global_config = Config({
    "BATCH_SIZE" : 128,
    "NUM_EPOCHS" : 2,
    "CONFIG" : {
        "fp16" : {
            "mode" : AMP_TYPE.TORCH
        }
    }
})

def main():
    colossalai.launch_from_torch(config=global_config)

    logger = get_dist_logger()

    # build resnet
    model = resnet34(num_classes=10)

    # build dataloaders
    print("loading dataset...")
    train_dataset = CIFAR10(
        root=Path(os.environ['DATA']),
        download=True,
        transform=transforms.Compose(
            [
                transforms.RandomCrop(size=32, padding=4),
                transforms.RandomHorizontalFlip(),
                transforms.ToTensor(),
                transforms.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[
                    0.2023, 0.1994, 0.2010]),
            ]
        )
    )

    print("finish loading dataset...")
    
    test_dataset = CIFAR10(
        root=Path(os.environ['DATA']),
        train=False,
        transform=transforms.Compose(
            [
                transforms.ToTensor(),
                transforms.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[
                    0.2023, 0.1994, 0.2010]),
            ]
        )
    )

    train_dataloader = get_dataloader(dataset=train_dataset,
                                      shuffle=True,
                                      batch_size=gpc.config.BATCH_SIZE,
                                      num_workers=1,
                                      pin_memory=True,
                                      )

    test_dataloader = get_dataloader(dataset=test_dataset,
                                     add_sampler=False,
                                     batch_size=gpc.config.BATCH_SIZE,
                                     num_workers=1,
                                     pin_memory=True,
                                     )

    # build criterion
    criterion = torch.nn.CrossEntropyLoss()

    # optimizer
    optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4)

    # lr_scheduler
    lr_scheduler = CosineAnnealingLR(optimizer, total_steps=gpc.config.NUM_EPOCHS)

    engine, train_dataloader, test_dataloader, _ = colossalai.initialize(model,
                                                                         optimizer,
                                                                         criterion,
                                                                         train_dataloader,
                                                                         test_dataloader,
                                                                         )

    for epoch in range(gpc.config.NUM_EPOCHS):
        engine.train()
        if gpc.get_global_rank() == 0:
            train_dl = tqdm(train_dataloader)
        else:
            train_dl = train_dataloader
        for img, label in train_dl:
            img = img.cuda()
            label = label.cuda()

            engine.zero_grad()
            output = engine(img)
            train_loss = engine.criterion(output, label)
            engine.backward(train_loss)
            engine.step()
        lr_scheduler.step()

        engine.eval()
        correct = 0
        total = 0
        for img, label in test_dataloader:
            img = img.cuda()
            label = label.cuda()

            with torch.no_grad():
                output = engine(img)
                test_loss = engine.criterion(output, label)
            pred = torch.argmax(output, dim=-1)
            correct += torch.sum(pred == label)
            total += img.size(0)

        logger.info(
            f"Epoch {epoch} - train loss: {train_loss:.5}, test loss: {test_loss:.5}, acc: {correct / total:.5}, lr: {lr_scheduler.get_last_lr()[0]:.5g}", ranks=[0])


if __name__ == '__main__':
    main()

然后打开命令行,运行:

$torchrun --nproc_per_node 1 --master_addr localhost --master_port 8008 eval.py

由于多机多卡涉及到的通信,所以需要开socket,不过本次是单机,这个参数设了也没啥用,只要确保master_port和本地开启的端口没有冲突就行。

运行效果:

首次运行需要消耗时间下载CIFAR-10数据集

运行成功。


基本使用

在colossalai的官网上,给出了一套文字教程,个人认为讲得非常清楚:

使用colossalai框架进行训练的大致流程如下:

我会大致讲讲使用colossalai构建的每个步骤,这些步骤基本都可以和上述的例子对应起来。所以本人就不再写一个完整的例子了。

config.py 全局配置

colossalai通过一个python文件来作为全局的配置,假设我们把配置文件命名为config.py好了(你当然可以取别的名字),配置的内容可以在后续launch后通过 colossalai.core.global_context 进行访问。

比如我们的config.py是这样的:

那么在整个训练系统中,你可以这么访问它:

import colossalai
from colossalai.core import global_context as gpc

colossalai.launch(config='./config.py', ...)

# config.py已经被注册成了对象,直接通过属性值访问
gpc.config.BATCH_SIZE

除了基本的训练参数,比如批大小,训练轮数外,配置文件和整个训练系统需要使用的并行加速策略直接相关(说直接点,整个colossalai的并行策略就是在config.py中设置的),这部分配置的名字是固定的。

我把和并行策略相关的配置属性值列在下表中,附带对应的教程链接,方便查阅:

属性名称 含义 链接
parallel 并行配置,是一个字典,可配置的子项为数据并行、流水线并行和序列并行 https://www.colossalai.org/zh-Hans/docs/basics/configure_parallelization
fp16 混合精度策略 https://www.colossalai.org/zh-Hans/docs/features/mixed_precision_training
gradient_accumulation 梯度累计次数 https://www.colossalai.org/zh-Hans/docs/features/gradient_accumulation
clip_grad_norm 梯度裁剪范数 https://www.colossalai.org/zh-Hans/docs/features/gradient_clipping
gradient_handler 自定义处理梯度同步的类 https://www.colossalai.org/zh-Hans/docs/features/gradient_handler
MOE_MODEL_PARALLEL_SIZE 一个进程中的混合专家模型数量 https://www.colossalai.org/zh-Hans/docs/advanced_tutorials/integrate_mixture_of_experts_into_your_model

一种可行的、较为简单的配置如下,很多任务中可以直接复制粘贴:

from colossalai.amp import AMP_TYPE

BATCH_SIZE = 128     # 批次大小
NUM_EPOCHS = 10      # 训练10轮

fp16 = dict(
  mode=AMP_TYPE.TORCH     # AMP后端是pytorh
)

parallel = dict(          # 并行策略,请注意,pipline的取值和tensor的size的乘积为你GPU的数量(此例中为2 * 4 = 8)
    pipeline=2,
    tensor=dict(size=4, mode='2d')
)

colossalai.launch 启动

通过launch可以将配置文件注入系统中,并初始化各种与网络硬件相关的配置。

关于分布式训练有几个比较重要的几个概念:

  • host: 主训练机的IP
  • port: 主训练机的端口
  • host: 训练网络中机器的ID
  • world size: 网络中机器的数量。

将这些参数注入系统的方法有很多种,你可以在启动 Colossal-AI | Colossal-AI (colossalai.org) 找到。如果你使用的是版本大于1.10的pytorch,可以使用torch自带的脚本torchrun来启动并输入参数:

参数如下:

  • --nproc_per_node : 每个节点GPU的数量
  • --master_addr : 对应上述 host
  • --master_port : 对应上述的port

倘若你的训练脚本为train.py,本地有三块GPU,只用一台机器训练,那么启动训练的脚本为:

$torchrun --nproc_per_node 3 --master_addr localhost --master_port 8001 train.py

上述命令会在该机器上的8001端口开启一个训练服务,如果8001被占用,也可以使用别的端口。

colossalai.initialize 初始化(模型封装)

在launch之后,我们就不需要关心系统如何调度计算资源了,我们只需要关心单台机器上如何跑训练代码。

除此之外,colossalai还做了一件很nice事情,就是将已有的训练代码进行二次封装,无论用户采用什么模型,什么优化器,什么学习率动态更新算法,什么数据加载器,通过colossalai封装后,后续代码都会变得几乎一模一样。

假设我们已经历经千辛万苦,搞到了训练的几大要素:

# 后端采用pytorch
colossalai.launch_from_torch(config="config.py")

# 1. 训练集加载器
train_dataloader = MyTrainDataloader()

# 2. 测试集加载器
test_dataloader = MyTrainDataloader()

# 3. 模型
model = MyModel()

# 4. 优化器
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# 5. 损失函数
criterion = torch.nn.CrossEntropyLoss()

通过colossalai提供的初始化函数可以进行一步封装:

# 返回四个值: engine对象,训练集加载器,测试集加载器,学习率更新器(这不是必须的,从简,不提)
engine, train_dataloader, test_dataloader, _ = colossalai.initialize(
                                                                     model,
                                                                     optimizer,
                                                                     criterion,
                                                                     train_dataloader,
                                                                     test_dataloader,
                                                                    )

engine封装

其中的engine就是对模型、优化、损失函数的封装,它同时继承了模型,优化器和损失函数的一堆方法,常用操作我都整理在下表中了:

方法 解释
engine(inputs) 前向计算,等价于model(inputs)
engine.zero_grad() 清空梯度,等价于optimizer.zero_grad()
engine.step() 更新参数,等价于optimizer.step()
engine.criterion(output, label) 计算损失,等价于criterion(output, label)
engine.backward(loss) 反向传播,等价于loss.backward()
torch.save(engine.model.state_dict(), f=…) 保存模型
engine.model.load_state_dict(torch.load(f=…)) 读取模型
engine.train() 训练模式,等价于model.train()
engine.eval() 评估模式,等价于model.eval()

请注意,使用engine封装后的模型的state_dict()的每一个key会多出一个”model.”的前缀,它无法直接装载进入一个没有封装成engine的model中,如果你偏要这么做,那么请在load之前对每个key使用lstrip(“model.”)方法进行前缀去除。

其余的步骤和torch常规训练一致。此处就不赘述了。需要注意的是,由于需要使用torchrun脚本进行启动,请不要直接在jupyter notebook中启动训练代码。

除了engine对象,官方还提供了一个更加高级的封装Trainer,它是对engine的进一步封装。使用Trainer,就能实现Keras风格的直接使用fit函数进行拟合,并且Trainer还提供了使用钩子函数的接口,由于本次实验未使用Trainer,所以留到下一章去讲了,感兴趣的读者可以参考colossalai的官方文档来学习Trainer:
如何在训练中使用 Engine 和 Trainer | Colossal-AI