1. 从零上手华为云ModelArts:一个深度学习从业者的踩坑实录
搞深度学习这行的朋友都清楚,模型训练和部署这件事,说起来就两步——训好、上线,但真动手做的时候,环境配置、算力调度、依赖冲突、推理性能,每一步都能让你折腾到半夜。我最早是在本地用一张卡跑ResNet,后来模型越来越大,数据越来越多,本地那点算力根本扛不住,就开始琢磨云端的方案。华为云ModelArts是我用下来比较顺手的一个平台,从数据标注、模型训练到部署上线,它把整条链路都串起来了,省去了大量搭环境、配集群的时间。
这篇笔记主要面向两类人:一是刚接触云端模型训练、想找个能直接跑通的平台的开发者;二是已经在本地玩过训练,但想把流程搬到云上、顺便把部署也搞定的朋友。我会把ModelArts上完成一次完整的模型训练与部署的全流程拆开讲,包括数据怎么传、训练作业怎么配、预训练模型怎么选、部署上线后怎么调,以及我在这个过程中踩过的那些坑。内容基于我自己的实际操作经验,结合ResNet、RoBERTa这类常见预训练模型的使用场景来展开,力求让你看完就能照着做。
2. 为什么选ModelArts做模型训练与部署
2.1 本地训练的三个硬伤
先说说我为什么从本地转向云端。最早我在本地工作站上跑ResNet做图像分类,数据集大概几万张图,一张L20显卡,训练一轮要四十多分钟,调一次超参就得等半天。这还只是训练,等模型训好了要部署成API给前端调,又得自己搭服务、配负载、做监控,一套下来没个两三天搞不定。具体来说,本地训练有三个绕不过去的硬伤:
- 算力天花板明显:单卡或双卡的显存和算力有限,遇到大模型或者大数据集,训练周期被拉得极长,实验迭代效率极低。
- 环境维护成本高:CUDA版本、驱动版本、框架版本、Python依赖,任何一个环节版本对不上,就是一堆报错。换台机器又要重来一遍。
- 部署链路断裂:训练和部署往往是两套环境,模型导出后还要处理格式转换、推理优化、服务封装,中间很容易出问题。
2.2 ModelArts解决的几个核心问题
ModelArts吸引我的地方在于,它把训练和部署放在同一个平台里,数据、代码、模型、服务是一条线下来的。具体来说,它帮我解决了这几件事:
第一,算力按需使用。训练作业可以选不同的规格,从单卡到多卡分布式都支持,用完就释放,不用长期养着一堆机器。对于我这种间歇性需要大算力的人来说,成本上划算很多。
第二,环境开箱即用。平台预置了主流框架的镜像,PyTorch、TensorFlow、MindSpore都有,CUDA和驱动都是配好的,我只需要关注自己的代码和依赖,不用再折腾底层环境。
第三,训练到部署无缝衔接。训练产出的模型可以直接在平台里创建推理服务,省去了自己搭服务、配网关的环节。部署完还能直接在线测试,验证推理效果。
第四,预置模型和算法。平台里有一些预训练模型和常用算法可以直接用,比如做图像分类、目标检测、文本分类这些任务,能省掉不少从零开始的工作。
提示:选平台的时候不要只看算力价格,要把数据流转、环境维护、部署运维这些隐性成本一起算进去。很多时候,省下来的时间比省下来的钱更值钱。
2.3 适合哪些场景,不适合哪些场景
ModelArts适合的场景比较明确:图像分类、目标检测、文本分类、情感分析这类有成熟网络结构的任务,用预训练模型微调,在平台上跑非常顺。另外,团队协作场景也合适,数据、代码、模型都在云端,多人共享方便。
但也不是所有场景都适合。比如你需要极其精细的底层算子优化,或者要跑一些非常小众的框架,平台预置镜像覆盖不到,那就得自己打包镜像,灵活性会打折扣。再比如,如果你的数据量特别小、模型特别轻,本地一张卡几分钟就跑完了,那上云反而多此一举。
3. 训练前的准备工作:数据、代码与镜像
3.1 数据上传与组织方式
数据是训练的第一步,也是最容易被忽视的一步。在ModelArts上,数据一般放在对象存储里,训练作业再挂载到容器中使用。我一开始图省事,直接把本地文件夹整个传上去,结果训练脚本里的路径全对不上,白白浪费了一次训练机会。
正确的做法是,先规划好数据的目录结构,再上传。以图像分类为例,我通常这样组织:
dataset/ ├── train/ │ ├── class_a/ │ │ ├── 001.jpg │ │ └── ... │ └── class_b/ │ ├── 001.jpg │ └── ... ├── val/ │ ├── class_a/ │ └── class_b/ └── test/ ├── class_a/ └── class_b/这种按类别分文件夹的结构,配合PyTorch的ImageFolder或者TensorFlow的image_dataset_from_directory,可以直接读取,不用自己写解析逻辑。上传的时候,建议用平台提供的数据管理功能或者命令行工具批量传,比在网页上一个一个点要快得多。
注意:数据上传后,务必在训练脚本里用相对路径或者平台约定的挂载路径来读取,不要写死本地绝对路径,否则换到云端必然报错。
3.2 代码结构与依赖管理
代码这块,我建议把训练脚本、模型定义、数据处理、配置文件分开,不要全塞在一个文件里。一个比较清晰的结构是这样的:
project/ ├── train.py # 训练入口 ├── model.py # 模型定义 ├── dataset.py # 数据加载 ├── config.yaml # 超参配置 └── requirements.txt # 额外依赖requirements.txt这个文件很关键。平台预置镜像里已经装了主流框架,但如果你用了一些额外的库,比如albumentations做数据增强、timm加载预训练模型,就得在这个文件里声明。训练作业启动时会自动安装这些依赖。
我踩过的一个坑是,依赖版本没锁死,结果平台自动装了最新版,和我代码里用的API不兼容,训练直接报错。后来我学乖了,requirements.txt里全部写死版本号,比如albumentations==1.3.1,这样每次跑的结果都一致。
3.3 镜像选择与预训练模型获取
ModelArts的镜像分两类:预置镜像和自定义镜像。绝大多数情况下,预置镜像够用了。选镜像的时候,主要看三点:框架类型、框架版本、CUDA版本。比如你用PyTorch 2.0,就选对应的PyTorch 2.0镜像,CUDA版本一般跟着框架走,不用单独选。
预训练模型这块,ResNet系列是做图像任务的常青树,RoBERTa在中文文本任务上表现很好。获取方式有两种:一是从平台的模型市场里直接找,二是自己在代码里通过torchvision或transformers加载。我一般倾向后者,因为灵活,可以自己控制加载哪些层、要不要冻结。
import torchvision.models as models # 加载ResNet50预训练模型 model = models.resnet50(pretrained=True) # 冻结前面的层,只训练最后的分类头 for param in model.parameters(): param.requires_grad = False # 替换最后的全连接层,适配自己的类别数 num_classes = 10 model.fc = torch.nn.Linear(model.fc.in_features, num_classes)这段代码是我做图像分类微调时的标准操作。冻结前面的层是为了防止小数据集上过拟合,只训练分类头,收敛快,效果也稳。
4. 模型训练作业的配置与实操
4.1 创建训练作业的关键参数
在ModelArts上创建训练作业,需要填的参数不少,但真正影响训练结果的就那么几个。我把关键参数整理成了一张表,方便对照:
| 参数项 | 说明 | 我的常用设置 |
|---|---|---|
| 算法来源 | 选择预置算法或自定义脚本 | 自定义脚本 |
| 代码目录 | 训练脚本所在路径 | 对象存储中的project目录 |
| 数据来源 | 训练数据挂载路径 | 对象存储中的dataset目录 |
| 运行环境 | 镜像选择 | PyTorch 2.0 + CUDA 11.8 |
| 资源规格 | 计算资源类型 | 单卡L20或按需选多卡 |
| 超参 | 学习率、批次大小等 | 通过config.yaml传入 |
| 日志路径 | 训练日志输出位置 | 对象存储中的logs目录 |
这里面,资源规格和超参是最需要根据任务调整的。资源规格选小了,训练慢;选大了,浪费钱。超参就更不用说了,学习率大一点就发散,小一点就收敛慢,得反复试。
4.2 超参配置与训练脚本对接
超参的传递方式,我习惯用配置文件加命令行参数结合。训练作业里可以设置超参,这些超参会被注入到环境变量或者以参数形式传给脚本。我的做法是在train.py里用argparse接收,然后覆盖config.yaml里的默认值。
import argparse import yaml def parse_args(): parser = argparse.ArgumentParser() parser.add_argument('--lr', type=float, default=0.001) parser.add_argument('--batch_size', type=int, default=32) parser.add_argument('--epochs', type=int, default=50) parser.add_argument('--data_path', type=str, default='./dataset') return parser.parse_args() if __name__ == '__main__': args = parse_args() with open('config.yaml', 'r') as f: config = yaml.safe_load(f) # 命令行参数覆盖配置文件 config['lr'] = args.lr config['batch_size'] = args.batch_size config['epochs'] = args.epochs # 开始训练 train(config, args.data_path)这样设计的好处是,本地调试的时候用默认值,云端训练的时候通过作业配置传参,一套代码两边都能跑。
4.3 训练过程监控与日志查看
训练作业跑起来之后,最关心的就是loss降没降、准确率涨没涨。ModelArts提供了训练日志的实时查看功能,可以在控制台直接看输出。我一般会在脚本里把关键指标打印出来,格式清晰一点,方便在日志里搜。
for epoch in range(config['epochs']): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) val_acc = evaluate(model, val_loader) print(f"Epoch [{epoch+1}/{config['epochs']}] " f"Train Loss: {train_loss:.4f} " f"Val Acc: {val_acc:.4f}")如果发现loss变成NaN了,那基本就是学习率太大或者数据里有异常值。我遇到过一次,排查了半天,最后发现是某张图片的像素值全是0,导致梯度爆炸。后来在数据加载里加了一层归一化和异常值过滤,就再没出现过。
提示:训练日志里除了loss和准确率,建议把学习率、梯度范数也打出来。梯度范数突然变大,往往是训练要出问题的前兆。
4.4 分布式训练与多卡加速
数据量大的时候,单卡跑太慢,就得上多卡。ModelArts支持分布式训练,PyTorch的话用DistributedDataParallel就行。配置上,主要是设置nproc_per_node和启动方式。
# 启动脚本示例 python -m torch.distributed.launch \ --nproc_per_node=4 \ --master_port=29500 \ train.py --batch_size 128多卡训练的时候,批次大小要相应放大,学习率也要按比例调整。经验法则是,批次大小翻倍,学习率也翻倍,但这不是绝对的,还得看具体任务。我一般会先在小规模上试一下,确认收敛正常再放大。
5. 模型部署上线:从训练产出到推理服务
5.1 模型导出与格式转换
训练完成后,模型需要导出成推理用的格式。PyTorch的话,一般导出成torchscript或者onnx。我倾向用torchscript,因为和PyTorch生态结合紧密,部署时依赖少。
# 导出TorchScript模型 model.eval() example_input = torch.randn(1, 3, 224, 224) traced_model = torch.jit.trace(model, example_input) traced_model.save("model.pt")导出的时候要注意,模型必须处于eval模式,否则BatchNorm和Dropout的行为会和训练时不一致,导致推理结果异常。这个坑我踩过,导出的模型推理准确率比验证时低了一大截,排查了好久才发现是忘了调eval()。
5.2 创建推理服务的配置要点
在ModelArts上创建推理服务,需要指定模型文件、推理脚本、运行环境。推理脚本里要定义好预处理、推理、后处理三个环节。
# 推理脚本示例 import torch from PIL import Image from torchvision import transforms class ModelService: def __init__(self, model_path): self.model = torch.jit.load(model_path) self.model.eval() self.transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def preprocess(self, image): return self.transform(image).unsqueeze(0) def inference(self, input_tensor): with torch.no_grad(): output = self.model(input_tensor) return output def postprocess(self, output): probabilities = torch.nn.functional.softmax(output, dim=1) return probabilities.tolist()预处理里的归一化参数要和训练时保持一致,否则推理结果会偏。这个细节很容易被忽略,尤其是换了几次数据集之后,mean和std忘了改。
5.3 在线测试与性能调优
推理服务创建好之后,平台会提供一个在线测试的入口,可以直接上传图片或者发请求测试。我一般会先用几张训练集里的图片测一下,确认输出正常,再用验证集里的图片测,看泛化能力。
性能调优方面,主要关注两个指标:延迟和吞吐。延迟是单次请求的响应时间,吞吐是单位时间能处理的请求数。如果延迟高,可以考虑模型量化或者用更小的模型;如果吞吐低,可以增加实例数或者开批处理。
| 优化方向 | 具体手段 | 适用场景 |
|---|---|---|
| 降低延迟 | 模型量化、剪枝 | 实时性要求高的场景 |
| 提高吞吐 | 批处理、多实例 | 高并发场景 |
| 减少资源 | 模型蒸馏、小模型 | 资源受限场景 |
6. 常见问题与排查技巧实录
6.1 训练报NaN的排查思路
训练报NaN是最常见也最让人头疼的问题。我的排查顺序是这样的:先看学习率是不是太大,通常降一个数量级再试;再看数据里有没有异常值,比如全零、全黑、标签越界;然后看损失函数有没有除零或者log零的情况;最后看梯度裁剪有没有加上。
# 加梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)梯度裁剪这行代码,我建议默认加上,尤其是训练不稳定的时候,能挡掉不少问题。
6.2 部署后推理结果异常的定位方法
推理结果异常,一般从三个地方查:预处理、模型、后处理。预处理最常见的问题是归一化参数不对、图片通道顺序不对(RGB和BGR搞反了);模型最常见的问题是忘了调eval()、导出时输入尺寸不对;后处理最常见的问题是softmax用错维度、类别映射表对不上。
我的做法是,在推理脚本里把中间结果打出来,和训练时的验证结果对比。如果预处理后的张量统计量(均值、方差)和训练时差很多,那问题就在预处理。
6.3 资源规格选择的经验法则
资源规格选多大,这个没有标准答案,但有个经验法则:先小后大。先用小规格跑通流程,确认代码没问题,再换大规格加速。因为大规格贵,代码有问题的话,浪费的钱更多。
另外,显存占用要留余量。比如模型训练时显存占用是18G,那就选24G的卡,不要选16G的,否则跑着跑着就OOM了。批次大小和显存占用是线性关系,显存不够就减小批次大小,或者用梯度累积模拟大批次。
| 任务类型 | 推荐规格 | 说明 |
|---|---|---|
| 小数据集图像分类 | 单卡16G | ResNet50够用 |
| 大数据集图像分类 | 单卡24G或多卡 | 批次大小可以开大 |
| 文本分类 | 单卡16G | RoBERTa-base够用 |
| 大模型微调 | 多卡24G以上 | 需要模型并行或梯度累积 |
7. 我在这条路上踩过的几个坑
第一个坑是数据路径。本地调试的时候用的是绝对路径,传到云端后路径全变了,训练脚本直接找不到数据。后来我统一改成相对路径,并且在脚本开头打印当前工作目录和文件列表,方便排查。
第二个坑是依赖版本。有一次用了transformers的最新版,结果和平台镜像里的tokenizers版本不兼容,报了一堆看不懂的错。后来把所有依赖版本都锁死,问题就没了。
第三个坑是模型导出。训练完直接导出,忘了调eval(),推理结果和验证结果差了一大截。这个坑最隐蔽,因为导出过程不报错,只有对比结果才能发现。
第四个坑是资源规格。一开始选了个大规格,结果代码里有个死循环,跑了两个小时才发现,钱白花了。后来我养成了先用小规格跑通再换大规格的习惯。
第五个坑是推理服务的并发。上线后没做压测,结果一上量就超时。后来加了批处理和自动扩缩容,才稳住。
这些坑说到底,都是细节问题,但每一个都能让你卡半天。我的建议是,每次训练和部署之前,把检查清单过一遍:路径对不对、版本锁没锁、模式调没调、规格合不合适、并发扛不扛得住。这几项确认了,基本就不会出大问题。
8. 关于预训练模型选择的一点个人经验
ResNet和RoBERTa是我用得最多的两个预训练模型。ResNet做图像任务,结构简单,预训练权重好找,微调起来也快。RoBERTa做中文文本任务,效果比BERT好一些,尤其是长文本场景。选预训练模型的时候,我主要看三点:任务匹配度、模型大小、预训练数据领域。
任务匹配度不用多说,图像任务就用图像预训练模型,文本任务就用文本预训练模型。模型大小方面,base版一般够用,large版效果可能好一点,但推理成本高不少。预训练数据领域这个容易被忽略,比如你做医疗文本分类,用通用领域预训练的RoBERTa,效果可能不如用医疗领域数据继续预训练的版本。
另外,微调的时候,学习率要设小一点,一般是从头训练的十分之一到百分之一。因为预训练权重已经学到了很好的特征表示,学习率太大反而会破坏这些特征。我一般用2e-5到5e-5这个范围,配合warmup和线性衰减,效果比较稳。
最后再分享一个小技巧:如果显存不够,可以用梯度累积来模拟大批次。比如你想用批次大小64,但显存只够32,那就跑两次前向传播再更新一次参数,效果和批次大小64差不多。这个技巧在小显存卡上跑大模型的时候特别有用。