
上个月朋友拉我帮看一个 MindSpore 微调大模型的训练卡顿问题。他换的是昇腾 910 的算力卡batch size、学习率、Lora rank 都调过一轮了训练 loss 能降但算力利用率一直徘徊在 50% 上下而且越跑越慢。我导出训练性能数据一看问题根本不在模型侧而在 mindspore.dataset 这一层——算子排队等待数据的时间占了 40% 以上host 端 CPU 全在忙着做字符串解析、Token 化和 Padding算力设备却饿着肚子等 feed。这是我在很多大模型工程里反复见过的现象模型结构越来越复杂大家愿意花大价钱调参、换卡却很少认真对待数据变换与预处理这条流水线。但恰恰是它决定了你的大规模并行训练到底能把算力用起来还是白白浪费。这篇文章我就围绕 MindSpore 里的 mindspore.dataset讲清楚大模型场景下数据变换与预处理的全套方案从基本 API 的执行逻辑、文本处理常见算子到分布式切分、性能调优和完整可复用的代码示例。不管你是刚接触昇思 MindSpore 的新手还是已经在做大规模微调训练的老人这一篇应该都能帮你少走不少弯路。1. 大模型训练为什么绕不开 mindspore.dataset —— 算力饥饿与数据供应的博弈1.1 算力占比上不去的真相数据等待才是隐藏凶手大模型微调阶段很多人第一反应是换更贵的卡或者把 batch size 拉大。但如果你把设备的执行时间线拉出来看会发现一个特别扎心的现象真正的计算时间往往很短绝大多数时间卡在数据从 host 内存搬运到设备、再从设备队列取用的环节上。你看到设备利用率只有 50%不是模型算不动而是设备在等数据。打个比方设备是高档餐厅的主厨mindspore.dataset 就是后厨的备菜流水线。主厨手艺再好配菜切不出来、摆盘不到位他照样只能干等着。大模型训练更是如此样本要先从磁盘读原始文本做清洗、Token 化、截断、Padding再组 batch 变成固定 shape 的张量这一步不顺畅后面全都白搭。在 MindSpore 里这条备菜流水线就是 dataset 模块。它并不是一个简单的数据容器而是一整套声明式的数据流框架从最基础的读取文件、执行变换算子、混合打乱、组批到分布式场景下的自动切分全部可以在一条链式表达里完成。你声明好了数据流图MindSpore 会在后台帮你做缓存、预取和多 worker 并行这正是大模型训练中保证算力不饿肚子的关键。1.2 你完全可以不用 dataset 硬写循环但代价是什么很多从 PyTorch 转过来的同学习惯手写一个Generator然后在训练循环里for batch in generator再手动把每个 batch 拷到设备上。在 MindSpore 里也能这么干但代价是失去了大量框架层优化。MindSpore 的 dataset 模块会在内部维护一个数据队列通过device_queue异步地把准备好的 batch 直接送到设备侧和模型执行过程重叠起来。你如果自己写循环数据准备和模型执行天然是串行的模型在反向算的时候CPU 在闲置CPU 在准备数据的时候设备在闲置。两边永远无缝衔接不上。所以只要你是认真在 MindSpore 里做大模型训练无论微调还是全量训练都应该把数据变换与预处理放进mindspore.dataset的流水线里。它对应到其他框架里大概就是 DataLoader、Dataset 和 Transform 三者的合体但它针对设备侧的数据搬运做了专门优化。这也是这篇文章所有内容的大前提用 dataset 声明数据流而不是自己管理数据循环。2. 声明式的数据流map、batch、shuffle、repeat 的执行顺序与设计逻辑2.1 链式调用背后是数据执行图不是普通循环刚开始用 mindspore.dataset 的人最常犯的一个思维错误是把它当成 Python 列表觉得调用map就是立刻逐条跑一遍。实际上不是这样dataset 的 API 全部是惰性求值的。你写import mindspore.dataset as ds dataset ds.TextFileDataset(data.txt) dataset dataset.map(operationslambda x: x.lower()) dataset dataset.batch(batch_size4)这一串调用在声明阶段并不会真正跑任何数据处理逻辑。它只是在内部构建了一张数据执行图记录数据的来源、每一步要做什么变换、什么时候组 batch。真正触发执行的是你开始迭代它的时候例如调用create_dict_iterator()或者把它喂给模型训练。这种设计带来的好处是底层的调度器可以全局统筹哪些变换可以并行、哪些算子可以流水化、预取缓冲区要开多大MindSpore 会基于整张图来做优化。而你如果在一个 for 循环里手动调用函数就是完全命令式的执行每一步都要等上一步结束性能天花板很低。以我个人的使用习惯构建一个训练数据集的常规顺序是这样dataset ds.TextFileDataset(data_path) # 1. 选择数据源 dataset dataset.map(operationscustom_func) # 2. 逐样本变换 dataset dataset.shuffle(buffer_size10000) # 3. 全局混洗 dataset dataset.batch(batch_size8, drop_remainderTrue) # 4. 组batch dataset dataset.repeat(epochs) # 5. 重复多轮也有很多人问 repeat 到底放在哪合适我的经验是把它放在 batch 之后。因为 repeat 本质上是将整个数据流重复指定次数放在 batch 后可以复用同一份 batch 数据流通逻辑上也更贴近一个 epoch 是完整跑一遍样本的直觉。MindSpore 的 repeat 和 shuffle 配合时如果 shuffle 在前每轮 epoch 都会重新打乱这正是训练希望的。2.2 顺序写错会怎样一个典型的 shuffle 与 batch 颠倒案例我见过不少人把 batch 放在 shuffle 前面理由是先凑满一个 batch 再整体打乱。这个写法在随机性上是有问题的但危害比较隐蔽。假设数据源是按类目顺序存储的前 1000 条全是类别 A后 1000 条全是类别 B。如果先 batch 再 shuffleshuffle 打乱的对象是一个个 batch而不是单条样本。那么每个 batch 内部还是几乎纯同类的模型在一个 batch 里只能看到单一分布梯度方差会变得很大收敛曲线会明显抖动。正确的做法是样本级 shuffle 在 batch 之前。让每个 batch 内部本身就混合不同分布的数据这样每个 step 的梯度估计都更接近全局分布。另一个和顺序有关的问题是 map 与 shuffle 的先后。如果 map 操作很耗时比如做 Token 化通常我会先做 shuffle 再做 map因为 shuffle 的 buffer 里存原始文本比存张量要省内存。但要注意一点map 如果带有随机性dropout、随机裁剪之类的增强shuffle 在它之前没问题如果 map 是纯函数式变换那 shuffle 在任意一边都行。2.3 batch 的隐藏参数drop_remainder、pad_info 与 per_batch_map大模型训练中 batch 这步最容易出错因为序列长度不一定相同。MindSpore 的batch有几个隐藏参数非常关键。drop_remainderTrue是强烈建议打开的。它会把最后不足一个 batch_size 的尾巴丢弃保证所有 batch 的形状完全一致。对于大模型来说固定 shape 意味着设备端编译优化可以做得更充分不需要处理动态 shape 的边界情况。实际训练中最后一个残缺 batch 对整体收益微乎其微为它牺牲性能完全不划算。对于变长序列有两个方案。如果训练允许全序列固定到某个最大长度可以直接在batch里用pad_infodataset dataset.batch( batch_size8, pad_info{ input_ids: ([seq_len], tokenizer.pad_token_id), attention_mask: ([seq_len], 0) }, drop_remainderTrue )pad_info的作用是指定某个张量列要 pad 到什么形状、用什么值填充。所有样本都会 pad 到[seq_len]比如 2048。这个方案的好处是形状全固定实现简单坏处是短样本浪费算力如果语料长度差异大白算的部分很可观。更精细的做法是使用per_batch_map它允许你对一个 batch 内的样本做自定义处理动态 pad 到本 batch 最大长度。这个我会在后面的完整示例里详细展开先记住它是处理动态 Padding 的标准姿势即可。3. 文本预处理实战清洗、Token 化、截断、Padding 与 Mask 构造3.1 清洗边界哪些该清哪些别乱动文本预处理是整条 pipeline 里看起来简单、实际最容易埋雷的环节。很多新手拿到原始语料就正则一顿删结果把语义信息也删没了。我在实践中形成了这么几条边界第一条空样本和异常样本必须过滤。比如一行只有空格、只有标点或者 label 为空这些样本在训练时不会提供任何信息还可能让 tokenizer 报错应该用dataset.filter或者 map 时返回空标志再过滤掉。第二条不可见字符要处理。JSON 文件常见的 BOM 头、中文语料里混入的全角空格、Windows 换行符\r这些字符 tokenizer 不认识会分散词表的注意力。我的习惯是统一做一次字符归一化把\r\n换成\n全角空格换成普通空格或者直接删除去掉控制字符。第三条不要在 label 字段上做文本增强。有些做图像增强的习惯带到 NLP想在 label 里加噪声提高鲁棒性这个在大模型预训练和 SFT 阶段都不建议label 是监督信号必须保持干净。举个例子一个实用的清洗函数长这样import re def clean_text(text: str) - str: text text.replace(\r\n, \n).replace(\r, \n) # 去掉零宽字符等不可见控制字符 text re.sub(r[\u0000-\u001f\u007f-\u009f], , text) # 合并多个连续空白为单个避免拆分出无意义token text re.sub(r\s, , text) return text.strip()注意清洗要放在 Token 化之前而不是之后。如果你先 Token 化再做字符串清洗切出来的 subword 已经被污染了清洗也救不回来。3.2 Tokenizer 选型与长文本切分大模型微调里 tokenizer 的选择基本决定了一部分效果上限。中文场景我建议优先使用与大模型配套的 tokenizer比如从 HuggingFace 下载的 Qwen、Llama 的分词器。这些 tokenizer 都是基于 SentencePiece 或 BPE 训练得到的词表覆盖了中英文常见片段直接加载就行。在 mindspore.dataset 的 map 操作里最简单的做法就是包一层 transformers 的 tokenizerfrom transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(your_model_path) tokenizer.pad_token tokenizer.eos_token # 大模型常用eos当pad def tokenize_with_mask(text: str): encoded tokenizer( text, truncationTrue, max_length2048, return_attention_maskTrue ) return encoded[input_ids], encoded[attention_mask]这里有个容易忽略的细节truncationTrue只能在 tokenizer 内部做截断它截的是单次编码的最大长度。如果你的样本是多轮对话拼接后的超长文本想要把它切成多个 chunk 分多次训练就必须在外部做滑动窗口切割。我处理超长文本的方式是把长文本按stride切成多个重叠片段def split_long_text(text: str, max_len1024, stride256): tokens tokenizer.encode(text) if len(tokens) max_len: yield tokenizer.encode(text, truncationTrue) return start 0 while start len(tokens): chunk tokens[start: start max_len] yield chunk start max_len - stride这样每个片段都有上下文重叠避免把一句话从中间硬生生切断丢失语义。不过我提醒一句带 overlap 的切分会轻微增加训练样本数如果语料本身就够大可以不用 stride直接无重叠切节省训练时间。3.3 动态 Padding、attention_mask 和 labels 的 -100 规则文本进入模型前最后一步是 Padding 和 mask。这里有一个大模型训练中特别容易搞混的点attention_mask和labels的 mask 是两个完全不同的东西不要混用。attention_mask是给 Transformer 前向计算用的它告诉模型哪些位置是真实 token、哪些位置是 padding。padding 位置的值应为 0这样 attention 计算时就不会把 padding 位置当有效信息。labels是给损失函数用的。在 LLM 的自回归训练里上一个 token 预测下一个 tokenpadding 位置不产生监督信号所以要把 padding 位置在 labels 里的值设成忽略索引比如-100。这样CrossEntropyLoss在计算时自动跳过这些位置。如果你在做 SFT 监督微调还能更进一步把 prompt 部分也 mask 掉只对回答部分计算 loss这是目前比较主流也不太容易出错的做法。IGNORE_INDEX -100 def build_sft_sample(text: str, response: str): prompt_ids tokenizer.encode(text) response_ids tokenizer.encode(response) # 拼接时加上结束符 input_ids prompt_ids response_ids [tokenizer.eos_token_id] labels [IGNORE_INDEX] * len(prompt_ids) response_ids [tokenizer.eos_token_id] attention_mask [1] * len(input_ids) return input_ids, attention_mask, labels这套逻辑在数据侧组装好之后训练代码不需要再额外处理模型内部只要把 labels 传进损失函数即可。很多训练任务 loss 不降排查半天最后发现是 labels 里的 padding 位置没有被 ignore模型在死记硬背 padding token这个坑我踩过不止一次。4. 多卡并行与海量数据shard 切分、GeneratorDataset 优化与 MindRecord4.1 分布式读取时最容易忽视的 shard 配置在一机多卡或者多机多卡训练时mindspore.dataset 里有个关键的分布式参数组合num_shards和shard_id。大模型并行训练要求每个计算设备看到的是同一数据集的不同子集。如果每张卡都从头到尾读一遍数据假设你有 8 张卡那么一个 epoch 实际上把数据重复喂了 8 次模型收敛出来的效果会被迫改变——有效学习率等于乘以 8很容易训崩。正确做法是rank_id 3 # 当前进程的全局rank world_size 8 # 总卡数 dataset ds.MindDataset( data.mindrecord, num_shardsworld_size, shard_idrank_id, shuffleTrue )MindSpore 在分布式训练时不会自动帮你切分数据需要你在构建 dataset 时手工传入num_shards和shard_id。这里有个经验之谈在代码里这两个参数的名字很容易混淆我建议每次写完都做一个 sanity check——把create_dict_iterator()跑一遍打印当前进程拿到的前几条原始数据 id确认不同 rank 拿到的样本确实不重叠。这一步一次性的验证能省后续大量排查时间。4.2 GeneratorDataset 从很慢到够用的优化路线不是所有数据都有现成的离线文件格式能用最常见的情况是你有一堆 JSONL需要边读边处理。这种情况下人们通常会直接写一个 Python generator然后包一层ds.GeneratorDataset。GeneratorDataset 本身没有错但它很容易被写慢。最典型的问题是在 generator 内部逐行做文件 IO# 不推荐的做法每yield一条就读一次文件 def bad_gen(path): with open(path) as f: for line in f: yield json.loads(line) dataset ds.GeneratorDataset(bad_gen(data.jsonl), [text, label])这段代码的问题在于 generator 是在子进程里被调用的每次 yield 都跨越进程边界开销不小。更好的做法是让 generator 一次多吐一些数据比如批量读取一批行再逐条返回或者干脆让 generator 返回一个 batch 的列表。另外一个常见的优化点是GeneratorDataset的num_parallel_workers需要结合实际 IO 压力来调整。如果你的数据源是 SSD 上的大文件worker 开太多反而会增大抢占不一定更快。我的经验是从 4 开始慢慢往上加观察吞吐变化曲线。如果原始数据在预处理后还会被反复使用比如语料 Token 化之后要跑多个 epoch那么不要指望每次训都实时 Token 化。把 Token 化结果落盘成 MindRecord每一次训练直接读这才是真正的大模型工程化方案。4.3 MindRecord大模型预处理的主流归宿MindRecord 是 MindSpore 原生支持的二进制数据格式它解决的核心问题是预处理结果复用 高效随机读取。JSONL 逐行扫描没法做随机访问读一条数据要跳过前面所有行。MindRecord 则不同它为每条数据建立了索引可以按序号直接定位读取天然适合 dataset 内部的 shuffle 和分布式切分。文本数据一旦完成清洗、Token 化、Padding就可以写成 MindRecord之后每次训练读的是已经处理好的张量流水线 CPU 开销大幅下降。写入 MindRecord 的代码大致是这样的from mindspore.mindrecord import FileWriter schema { input_ids: {type: int32, shape: [-1]}, attention_mask: {type: int32, shape: [-1]}, labels: {type: int32, shape: [-1]} } writer FileWriter(sft_data.mindrecord, shard_num4) writer.add_schema(schema, sft dataset) writer.write_raw_data(records) writer.commit()shard_num控制在 4 到 8 比较合适太少文件太大太多小文件碎片影响读取性能。写入完成后运行时只要一行ds.MindDataset(sft_data.mindrecord, num_shardsworld_size, shard_idrank_id)就能读配合前文提到的 shard 参数分布式场景基本一步到位。5. 端到端示例从 JSONL 原始文本到可直接训练的 SFT 数据集5.1 数据准备与 tokenizer 初始化这一节我给出一个完整的、可以直接改改用的示例。假设我们的训练数据是 JSONL每一行长这样{prompt: 请解释一下什么是Transformer, response: Transformer是一种基于自注意力机制的神经网络结构...}目标是把这些原始文本变成模型训练能直接吃的input_ids、attention_mask、labels三个字段并且对 prompt 部分不计算 loss这符合 SFT 微调的常见需求。第一步是初始化 tokenizer 和全局参数import numpy as np import mindspore as ms import mindspore.dataset as ds from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(./qwen_tokenizer) tokenizer.pad_token tokenizer.eos_token MAX_SEQ_LEN 2048 IGNORE_INDEX -100我建议把所有样本的长度都控制在同一个量级MAX_SEQ_LEN 按硬件显存来定。一般 2048 对大多数 7B 级别模型的微调是合理的起点。5.2 完整 pipeline 代码与逐段解读接下来构建生成器。这里的关键是不能在 generator 里做 tokenizer 调用之外的重 IO所有需要读取的字段提前从 JSON 解析好。def read_jsonl(path): with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if line: yield json.loads(line) def generator_fn(path): for row in read_jsonl(path): prompt row[prompt].strip() response row[response].strip() if not prompt or not response: continue yield (prompt, response)这个 generator 吐出来的是原始字符串下一步通过 map 做 Token 化和 mask 构造def tokenize_and_mask(prompt, response): prompt_ids tokenizer.encode(prompt) response_ids tokenizer.encode(response) if len(prompt_ids) len(response_ids) 1 MAX_SEQ_LEN: # 超长时优先丢 prompt 侧的远端内容保留尾部 MAX_RESP MAX_SEQ_LEN - 100 if len(response_ids) MAX_RESP: response_ids response_ids[:MAX_RESP] prompt_ids prompt_ids[:MAX_SEQ_LEN - len(response_ids) - 1] input_ids prompt_ids response_ids [tokenizer.eos_token_id] labels [IGNORE_INDEX] * len(prompt_ids) response_ids [tokenizer.eos_token_id] attention_mask [1] * len(input_ids) return input_ids, attention_mask, labels这个函数的逻辑并不复杂但有几个细节值得说。一是超长处理策略优先保 response因为回答是监督信号最核心的部分如果 response 本身就超长再做截断。二是 prompt 部分在 labels 里全部置成 IGNORE_INDEX从而实现只学回答、不学提问。三是结尾统一加 eos token让模型学会在回答结束时输出终止符。接下来组装 datasetdataset ds.GeneratorDataset( generator_fn(train.jsonl), column_names[prompt, response] ) dataset dataset.map( operationstokenize_and_mask, input_columns[prompt, response], output_columns[input_ids, attention_mask, labels], num_parallel_workers8 ) dataset dataset.shuffle(buffer_size10000)到这里每一条样本已经变成三个变长序列。下一步是动态 padding。为了让 padding 只 pad 到当前 batch 的最大长度我用per_batch_mapdef pad_batch(input_ids, attention_mask, labels): bs len(input_ids) max_len 0 for seq in input_ids: max_len max(max_len, len(seq)) padded_ids np.zeros((bs, max_len), dtypenp.int32) padded_mask np.zeros((bs, max_len), dtypenp.int32) padded_labels np.full((bs, max_len), IGNORE_INDEX, dtypenp.int32) for i in range(bs): seq_len len(input_ids[i]) padded_ids[i, :seq_len] input_ids[i] padded_mask[i, :seq_len] attention_mask[i] padded_labels[i, :seq_len] labels[i] return padded_ids, padded_mask, padded_labels dataset dataset.batch( batch_size8, per_batch_mappad_batch, drop_remainderTrue )per_batch_map接收的参数是当前 batch 内各列的数组列表长度是 batch_size每个元素是一条样本的变长序列。函数内部先找出这个 batch 里的最大长度再统一 pad既避免了浪费算力也保证了 output 是规则的二维数组。最后用create_dict_iterator验证一步iterator dataset.create_dict_iterator(num_epochs1) sample next(iterator) print(sample[input_ids].shape) # (8, batch内最大长度) print(sample[attention_mask].shape) print(sample[labels].shape)5.3 上线前必做的数据管线验证数据管线写完之后直接往模型里塞之前我一直坚持做三件事。第一打印一个批次的样本 shape确认 batch 维度存在、序列维度合理。第二把 input_ids 反向 decode 回文本看看前 20 个 token 是否和原始语料有连续性这一步是抓乱序和错位的利器。第三用模型跑一次前向观察 loss 是否在合理范围。如果 loss 比预期高一个数量级多半是数据侧的问题——最常见的就是 labels 的 -100 位置没设置对模型把 padding 当成了要学习的 token。我遇到过一个人SFT 训练 loss 一直稳定在 5.2 上下怎么调学习率都不降。我让他把样本 decode 出来一看发现整个序列的顺序是乱的——prompt 和 response 被 shuffle 算子彻底打散重新组对模型在学把两个不相关的文本拼接起来那 loss 当然降不下去。这种错误不看数据内容根本发现不了。6. 实测中踩过的三个坑与性能调优方向6.1 坑一tokenizer 放进 map 后成了最大瓶颈把 HF tokenizer 拿到 mindspore.dataset 的 map 里做 pyfunc 调用是现在大模型微调最常见的数据侧瓶颈。tokenizer 本身是一个 Python 对象每次调用都要经过 Python/C 的边界8 个 worker 也是 8 个 Python 进程在跑解释器锁性能天花板相当低。针对这个坑我的建议按优先级排列如果数据要反复使用多个 epoch务必提前 Token 化并落地为 MindRecord如果只是一次性实验可以把num_parallel_workers提到 8 或 16并把prefetch_size适当调大用队列深度掩盖单次调用的延迟如果环境允许也可以看看 MindSpore 生态里有没有对应模型原生的 tokenizer 算子能直接用 C 算子就不要用 Python 函数包一层。6.2 坑二cache 在分布式/昇腾环境下的兼容性问题MindSpore dataset 有cache接口可以把预处理后的数据缓存到内存或磁盘减少重复计算。听着很美但在大模型分布式训练里要慎用。一个是我实测下来cache 需要数据集可随机访问而 GeneratorDataset 并不一定支持另一个是在多卡环境下 cache 的缓存目录如果没避开共享盘各卡同时写同一个目录会有文件锁竞争速度反而更慢。我的处理方式很简单能用 MindRecord 解决的问题就不硬上 cache。MindRecord 本身已经在磁盘上把预处理结果固化了读取成本远低于运行时实时算这才是大模型场景更可靠的缓存方案。6.3 坑三shuffle buffer 设太小导致每个 epoch 数据重复shuffle(buffer_size100)这种写法在小数据集上没问题但大模型训练动辄几十万甚至百万条样本时buffer 太小意味着 shuffle 只在小窗口内打乱前一个 epoch 末尾的样本会和下一个 epoch 开头的样本高度相似形同反复看同一批数据。有效训练信息量打了折扣模型容易过早陷入局部最优。我的经验是 shuffle buffer 至少取一个 batch 的 20 到 50 倍比如 batch size 8 时buffer 设 10000 起步。如果内存紧张那就优先保证随机性把 map 操作挪到 shuffle 之前减少内存占用而不是把 buffer 砍太小。6.4 调优优先级清单与经验值最后给一个我自己常用的数据 pipeline 调优顺序按收益从高到低排优先级操作预期收益高数据落盘为 MindRecord免去每次训练重复 Token 化数据准备时间降低 50% 以上高正确设置 num_shards / shard_id避免每卡重复读全量训练有效性直接提升中用 per_batch_map 动态 Padding避免浪费算力单 step 时间下降明显中调整 num_parallel_workers 与 prefetch_size吞吐提升 10%~30%低调 shuffle buffer_size收敛稳定性和泛化性改善num_parallel_workers我一般在 8 到 16 之间尝试超过 16 后 Python 侧的解释器竞争和进程切换开销占比会显著上升。prefetch_size默认 16如果数据 pipeline 环节复杂可以调到 64 甚至 128原则是让设备侧不断粮。写在最后我自己在做数据 pipeline 时有个小习惯任何数据管线我都不会直接拿去做训练而是先create_dict_iterator(num_epochs1).__next__()打印一个 batch再拿这个 batch 跑一次前向推理。花不了两分钟但能拦住八成以上的低级错误——动态 padding 里的 max_len 溢出、shard 重复、mask 填错位置打印一遍样本或者把 input_ids 重新 decode 回文本这些错误基本当场现形。数据侧稳定了你后面调模型、调并行策略才有意义。否则算力再强、模型再大也只是在一套错误的数据供应系统上疯狂空转。希望这篇关于 mindspore.dataset 数据变换与预处理的经验能帮你把这条最容易被忽视的链路真正打通。