1. 数据加载为什么在大模型场景下成了独立的硬骨头
先抛一个可能不太讨喜的判断:在多模态大模型预训练和大模型微调里,数据加载环节翻车的概率,比模型结构写错还要高。很多人第一次跑多模态预训练脚本,模型结构、损失函数、优化器都照着开源代码抄得一字不差,结果训练三个小时 loss 纹丝不动,回头一查,是图文对没对齐;或者显存莫名爆掉,回头一查,是 collate_fn 里 padding 的长度算错了。这类问题的共同特征是——它们不会在编译期报错,只会在训练曲线里沉默地折磨你。
我接触过的几个做多模态预训练的团队,几乎都经历过同一个阶段:前期花两周调模型,后期花四周调数据管道。这不是能力问题,而是数据加载在多模态场景下确实变了性质。传统的图像分类任务里,一个样本就是一张图加一个整数标签,Dataset 返回(tensor, int),collate_fn 默认的torch.stack就能搞定。但到了多模态大模型这里,一个样本可能是一段变长文本、一组分辨率不一的图像、若干个区域标注、一个对话轮次结构,甚至带上图像在原文中的位置索引。样本从"定长张量"退化成了"异构字典",DataLoader 默认的拼装逻辑直接失效。
所以这篇内容想解决的不是"怎样写一个 Dataset 类"这种入门问题,而是在多模态预训练和大模型微调这两个具体场景下,数据加载到底该怎么设计、每个设计选择背后的取舍是什么、哪里最容易埋雷。适合已经能跑通单卡小 demo、正在往真实训练规模推进的读者,也适合带团队做训练基础设施、需要给数据管道定规范的人。下面我会把 Dataset 结构、collate_fn、预训练与微调的分野、分布式下的采样、以及排查链路拆开讲,每个环节都给出我实际踩过的坑和对应的处理方式。
1.1 从定长张量到异构字典:样本形态的根本变化
理解这个变化的起点,是看清楚"一个 batch 的张量到底长什么样"。图像分类时代,batch 是一个[B, 3, H, W]的规整张量,所有样本共享同一套维度,DataLoader只需要沿着第 0 维堆叠即可。到了多模态大模型,输入变成了类似{"input_ids": [B, L], "pixel_values": [B, C, H, W], "attention_mask": [B, L], "labels": [B, L]}这样的字典,而且每个字段的 L 和 H、W 都可能因样本而异。文本长度从几十 token 到几千 token 不等,图像从 224 到 1024 分辨率混杂,如果还指望默认 collate 帮你处理,只会得到一个维度不匹配的报错,或者更糟——一个能跑但语义错位的张量。
这里有个容易被忽略的点:多模态的"多"不只是模态数量多,更是异构维度多。文本侧是变长序列,视觉侧是变分辨率张量,两者在 batch 内的对齐关系必须由你显式维护。比如一批 8 个样本,第 3 个样本带了 2 张图、第 5 个样本带了 1 张图,那么pixel_values的第 0 维到底是 8 还是 10?注意力掩码怎么标记哪张图属于哪个样本?这些都不是框架能替你决定的,必须由 collate_fn 里的逻辑明确表达出来。
我的经验是,在写任何一行数据代码之前,先手写三个真实样本的完整字典结构,打印出每个字段的形状和 dtype,然后模拟拼成一个 batch 看形状是否自洽。这一步花十分钟,能省掉后面几小时的 debug。很多翻车都源于"以为字段是定长的",实际不是。
1.2 三个反复出现的翻车场景
场景一:图文错位。多模态预训练最经典的隐性 bug。Dataset 每次__getitem__返回图像和文本时,如果两者来自不同的随机读取源,或者图像做了随机裁剪、文本做了随机截断,但没有共享同一个随机种子,就会出现"这批图配的是另一批文本"的情况。训练照样能收敛,loss 也会降,但模型学到的是噪声对齐,下游任务表现会明显低于预期。判断方法很简单:固定一个 batch,把图像和文本还原成可读形式人工核对,如果对不上,问题就坐实了。
场景二:变长 padding 把有效 token 淹掉。大模型微调时,一个 batch 里既有 50 token 的短样本,又有 2000 token 的长样本。如果用"批内最长"做 padding,短样本后面全是 pad,注意力掩码一旦写错(比如 pad 位置也参与 attention),模型就会把大量无效位置当成上下文,loss 降得慢且不稳定。更隐蔽的是,某些实现里 pad token 和 eos token 用了同一个 id,模型分不清"句子结束"和"填充",生成阶段就会出现莫名的截断。
场景三:worker 数量一调,结果就变。单进程num_workers=0时一切正常,改成 8 个 worker 后 loss 曲线开始抖,或者某些 batch 的样本重复出现。根源通常是 Dataset 里用了全局随机状态,或者自定义 Sampler 在 fork 后各自持有一份副本,导致每个 worker 的采样序列不一致。这类问题在分布式训练里会进一步放大,因为每个 rank、每个 worker 都在独立采样,全局的"不重复"需要显式设计。
2. 多模态Dataset的结构设计:样本粒度决定一切
Dataset 的设计没有唯一正确答案,但有一个核心决策点:样本粒度到底切到多细。是把"一张图 + 一段描述"作为一个样本,还是把"一次完整对话 + 若干张图"作为一个样本?这个选择会直接决定后续 collate_fn 的复杂度、内存占用模式、以及分布式切分是否均匀。
我的倾向是:预训练阶段按"最小可训练单元"切,微调阶段按"一次交互"切。预训练数据量巨大,样本应该尽量小、尽量均匀,这样批次内 padding 浪费少、shuffle 效果好;微调数据量小、质量高,样本应该保持完整语义单元,避免把一个多轮对话拆散导致上下文断裂。这个差异后面第 4 节还会展开,这里先把 Dataset 的通用骨架搭起来。
2.1 单样本字段布局与占位策略
一个设计良好的多模态样本,我通常会包含这几类字段:输入侧(文本 token 序列、图像张量或图像路径、模态类型标记)、监督侧(labels、loss 掩码)、元信息(样本 id、来源、原始长度)。元信息不参与前向计算,但对排查问题至关重要——当 loss 异常时,你需要能凭样本 id 回溯到原始数据。
这里有个实操细节:图像字段到底存张量还是存路径。存张量简单,但 Dataset 对象会常驻内存,一个百万级样本的预训练集如果每张图都预解码成张量,内存直接爆掉。存路径则需要在__getitem__里读盘解码,IO 成为瓶颈。折中方案是存路径 + 在 worker 里解码 + 用pin_memory加速传输,这也是我在实际项目里用得最多的组合。如果你用的是本地高速存储,可以在 Dataset 初始化时缓存一部分高频样本的解码结果,做一个简单的 LRU;如果是对象存储,务必把解码放在 worker 内并行做,别在主进程里串行读。
import torch from torch.utils.data import Dataset from PIL import Image class MultiModalSampleDataset(Dataset): def __init__(self, records, tokenizer, image_size=336, max_text_len=512): self.records = records # 列表,每项含 image_path / text / sample_id self.tokenizer = tokenizer self.image_size = image_size self.max_text_len = max_text_len def __len__(self): return len(self.records) def __getitem__(self, idx): rec = self.records[idx] # 文本侧:截断 + 特殊 token enc = self.tokenizer( rec["text"], truncation=True, max_length=self.max_text_len, return_tensors=None, ) # 视觉侧:解码 + 归一化,放在 worker 内完成 img = Image.open(rec["image_path"]).convert("RGB") img = img.resize((self.image_size, self.image_size)) pixel = torch.from_numpy( __import__("numpy").array(img, dtype="float32") / 255.0 ).permute(2, 0, 1) return { "input_ids": torch.tensor(enc["input_ids"], dtype=torch.long), "attention_mask": torch.tensor(enc["attention_mask"], dtype=torch.long), "pixel_values": pixel, "sample_id": rec["sample_id"], }这段骨架看起来平淡,但有几个点值得强调。tokenizer调用时不要在这里做 padding,padding 是批次级的决策,放到 collate_fn 里统一处理;Image.open之后必须convert("RGB"),否则灰度图和带 alpha 通道的图会变成 1 通道或 4 通道,拼 batch 时直接报错;归一化放在 Dataset 里还是 collate 里都行,但要全局统一,最怕的是训练集归一化了一次、验证集忘了,指标对不上还查不出来。
2.2 惰性读取与预加载的取舍
惰性读取(每次__getitem__现读)和预加载(初始化时全部读进内存)之间不是二选一,而是按数据规模和访问频率分层。经验数值是:数据集小于可用内存的 30%,可以全量预加载;超过这个比例,用惰性读取加多 worker;如果某些样本被高频访问(比如对比学习里的难负样本池),单独做一个小缓存。
有个反直觉的结论:多 worker 下预加载未必更快。因为 fork 之后每个 worker 都持有一份预加载数据的副本,8 个 worker 就是 8 倍内存占用,很容易触发 OOM 或者被系统 swap 拖慢。用torch.multiprocessing的共享内存或者把数据放到内存映射文件里,能缓解这个问题,但复杂度上升。我的建议是:先按惰性读取实现,把 IO 并行度交给 worker 数量,实测 IO 确实是瓶颈之后再考虑缓存,别一上来就做过度设计。
3. collate_fn:批次拼装环节最容易失控的地方
如果整个数据管道只能挑一个地方重点加固,我会选 collate_fn。原因很直接:Dataset 的 bug 通常是局部的、单样本可复现的,而 collate_fn 的 bug 是批次级的、和 batch 组成强相关的。同一份代码,batch size 是 4 的时候跑得好好的,改成 16 就出错,这种问题排查成本极高。
3.1 变长序列的 padding 与 packing
padding 的目标是让 batch 内所有序列等长,但"往哪边 pad、pad 什么值、mask 怎么建"这三个问题必须一起回答。大模型微调里我固定用右侧 padding、pad id 取 tokenizer 的 pad_token_id、attention_mask 对应位置置 0。右侧 padding 的好处是生成任务里不会打乱前缀位置,eval 时取最后一个非 pad 位置的 hidden state 也方便。
def collate_pad(batch, pad_id=0): max_len = max(x["input_ids"].size(0) for x in batch) input_ids, attn, labels = [], [], [] for x in batch: n = x["input_ids"].size(0) pad_n = max_len - n input_ids.append(torch.cat([x["input_ids"], torch.full((pad_n,), pad_id, dtype=torch.long)])) attn.append(torch.cat([x["attention_mask"], torch.zeros(pad_n, dtype=torch.long)])) labels.append(torch.cat([x["input_ids"], torch.full((pad_n,), -100, dtype=torch.long)])) return { "input_ids": torch.stack(input_ids), "attention_mask": torch.stack(attn), "labels": torch.stack(labels), "pixel_values": torch.stack([x["pixel_values"] for x in batch]), }注意 labels 的 pad 位置填的是-100而不是pad_id。这是交叉熵损失的约定:-100的位置不计入 loss。如果这里填了 pad_id,模型会被迫学习预测填充符,短样本的有效梯度会被稀释。这个坑我见过不止一次,症状是短样本占比高的 batch loss 明显偏低,看起来"学得很好",实际是在学 pad。
packing 是另一条路:把多个短样本拼成一条长序列,用位置 id 和注意力掩码区分样本边界。它能把 padding 浪费降到接近零,预训练阶段收益明显,但实现复杂度高,且一旦掩码写错,跨样本的注意力泄漏会让模型学到虚假关联。我的建议是:预训练可以上 packing,微调阶段老老实实 padding,因为微调样本少、质量高,padding 浪费几个 token 影响不大,没必要冒实现风险。
3.2 图像张量与文本张量的拼接顺序
多模态 batch 里,图像和文本的对齐关系必须由 collate_fn 显式保证。最稳妥的做法是让 Dataset 返回的每个样本严格"一对一"——一个文本对应一组图像,collate 时按样本顺序堆叠,图像那一维用[B, N, C, H, W]或者把 N 展平后配合一个image_counts列表。后者更灵活,能处理每样本图像数不同的情况。
def collate_multimodal(batch): out = collate_pad(batch) # 展平所有图像,并记录每个样本的图像数量 pixels, counts = [], [] for x in batch: imgs = x["pixel_values"] # [N_i, C, H, W] pixels.append(imgs) counts.append(imgs.size(0)) out["pixel_values"] = torch.cat(pixels, dim=0) # [sum(N_i), C, H, W] out["image_counts"] = torch.tensor(counts, dtype=torch.long) return out这样模型侧拿到的image_counts就是"第 i 个样本文本对应第几张到第几张图"的依据。顺序必须和 Dataset 返回的顺序完全一致,任何额外的排序、过滤都会破坏对齐。我在一个项目里就遇到过 collate 里对图像按面积排序做"效率优化",结果图文全错位,训练了两天才发现,教训很痛。
4. 预训练和微调:两种场景下数据加载逻辑的分野
很多人把预训练和微调的数据管道当成同一套代码改改路径,结果两边都不顺。这两者的数据特征差异太大,用同一套实现往往意味着两边都做了妥协。分开设计反而更省事。
4.1 预训练:海量、流式、弱监督
多模态预训练的数据规模通常在千万到十亿级,特点是单样本质量参差、无需精确的标签对齐、对吞吐量极度敏感。对应的设计取向是:样本粒度切小、shuffle 用近似而非精确、异常样本直接跳过不阻塞。
流式读取在这个场景下几乎必选。把整个数据集索引一次性加载进内存不现实,用IterableDataset配合分片文件顺序读,是更常见的做法。代价是失去了随机访问能力,shuffle 只能靠"分片内 shuffle + 分片间 shuffle"两级来做。这里要注意:多个 worker 各自读不同分片时,如果分片划分逻辑有重叠,会出现样本重复;划分有空洞,又会漏样本。我通常会在初始化时把分片列表按 worker 数量取模分配,并在日志里打印每个 worker 拿到的分片范围,方便核对。
异常样本的处理策略也很关键。预训练数据里总会有损坏的图片、空文本、超长序列。在__getitem__里 try/except 并返回 None,collate 时过滤掉 None,是一种做法;更稳的是在预处理阶段就把坏样本剔除,训练时零容错。我倾向前者加日志计数,因为完全剔除需要全量扫描,成本高且新数据进来又要重扫。
4.2 微调:小规模、高质量、严格对齐
微调场景完全反过来:数据量可能只有几千到几十万条,每条都经过人工或模型筛选,样本之间的关系(比如同一对话的多轮、同一组图的先后顺序)必须保留。这里的核心不是吞吐,而是不破坏语义结构。
具体做法上,我会把"一次完整交互"作为 Dataset 的一个样本,collate 时按交互展开成训练序列。比如多轮对话微调,Dataset 返回整个对话的 token 序列和每轮的边界索引,collate 负责把 batch 内对话 padding 到等长,同时把每轮起始位置整理成一个列表传给 loss 计算。这样做的代价是 batch 内长度方差大、padding 浪费多,但换来的是上下文完整,模型学到的因果关系不会因为样本切断而断裂。
还有一个微调特有的问题:验证集的数据加载必须和训练集共用同一套 tokenizer 和归一化参数,但 shuffle 必须关掉。我见过验证集用了不同的 max_length,导致评估指标和训练日志对不上,排查了半天以为是模型问题。建议把预处理配置抽成一个显式对象,训练和验证都从它取参数,杜绝手写两份。
5. DataLoader与分布式:worker、shard和随机性的真实代价
数据加载的性能问题,八成出在 DataLoader 的配置上,而不是 Dataset 本身。num_workers、pin_memory、persistent_workers、prefetch_factor这几个参数互相影响,调错一个就可能让 GPU 利用率从 95% 掉到 20%。
5.1 worker 数量、pin_memory 与持久化
num_workers的经验起点是CPU 核数的一半到全部,但真正的判断依据是"GPU 是否在等数据"。用nvidia-smi看 GPU 利用率,如果长期低于 70% 且波动大,说明数据供给不足,加 worker;如果利用率稳定在 90% 以上,再加 worker 只会增加内存和上下文切换开销。
pin_memory=True让数据放在页锁定内存里,配合non_blocking=True的.to(device)能实现异步拷贝,这个组合在多模态场景下收益明显,因为图像张量体积大。但要注意页锁定内存是有限资源,worker 多、batch 大时可能耗尽,表现为系统变慢而不是报错。persistent_workers=True能避免每个 epoch 重新 fork worker,适合 epoch 多、初始化重的场景,代价是内存常驻。
5.2 分布式采样器的分片逻辑
分布式训练里,DistributedSampler负责把数据切到各个 rank。它的默认行为是补齐到能整除,也就是给数据量少的 rank 重复一些样本。这在微调时问题不大,但在预训练时会导致某些样本被多看几次,严格来说破坏了"每个样本等权"的假设。如果在意这一点,可以设drop_last=True,代价是每个 epoch 丢弃少量尾部数据。
更隐蔽的问题是每个 rank 内部还有多个 worker。DistributedSampler只管 rank 间的划分,rank 内的 worker 由 DataLoader 自己分配索引,默认是连续切块。如果数据本身有顺序(比如按来源排序的文件),连续切块会导致每个 worker 拿到的样本分布偏移,进而让 batch 内的数据多样性下降。解决办法是在 Dataset 初始化时对索引做一次全局 shuffle,或者在 Sampler 层做 worker 级的交错分配。
5.3 随机种子的可复现问题
数据增强里的随机性,是复现实验的最大障碍。random、numpy.random、torch三套随机源如果不同步,同一个 seed 跑两次结果都可能不同。我的做法是在 worker 初始化函数里显式设置每个 worker 的种子,种子由基础种子加 worker id 派生,这样既保证可复现,又保证不同 worker 的增强序列不重复。
def worker_init_fn(worker_id): base_seed = torch.initial_seed() % 2**32 seed = base_seed + worker_id random.seed(seed) np.random.seed(seed) torch.manual_seed(seed)配套地,DataLoader 的generator参数也要固定,否则 sampler 的 shuffle 顺序每次不同。这两处都设好,同一个配置跑两次的 loss 曲线才能对得上,否则你根本分不清指标波动是模型问题还是数据顺序问题。
6. 排查链路:数据加载慢、显存溢出、loss不降的定位顺序
最后聊聊排查。数据管道的问题很少单独出现,往往是"显存溢出"和"loss不降"同时发生,让人误以为是模型结构的问题。我的经验是永远先排除数据侧,再怀疑模型侧,因为数据侧的问题更容易验证,且修复成本更低。
6.1 一条从外到内的排查路径
第一步,关掉多 worker,用num_workers=0跑 10 个 batch。如果单进程正常、多进程异常,问题就在 worker 相关逻辑(种子、共享状态、文件句柄)。第二步,把 batch size 设成 1,打印每个字段的形状和 dtype。形状不自洽的问题在这一步就会暴露。第三步,取一个固定 batch,把图像还原成图片、文本 decode 成字符串,人工核对对齐关系。图文错位只有肉眼能查出来。第四步,检查 labels 的 mask 位置,确认 pad 处是-100,确认监督位置覆盖了该覆盖的 token。第五步才去看模型侧。
显存溢出则要区分是"稳定溢出"还是"随 batch 组成波动溢出"。前者是容量问题,减 batch 或上梯度累积;后者通常是某个 batch 里出现了异常长的序列或异常大的图像,在 collate_fn 里加长度和尺寸的上限截断,并记录被截断的样本 id,很快就能定位到脏数据。
6.2 我常用的几个自检习惯
第一个习惯:在训练脚本启动时,先跑一个"数据 dry run",只加载数据、不跑模型,统计 100 个 batch 的文本长度分布、图像尺寸分布、每 batch 耗时。这个统计能提前暴露 80% 的数据问题,而且不占 GPU。
第二个习惯:给每个 batch 打上样本 id 列表的日志,采样频率不用高,每 1000 步记一次就够。出问题时能凭 id 回溯到具体样本,比盲猜快得多。
第三个习惯:把预处理参数集中到一个配置对象里,tokenizer 的 max_length、图像的 size 和归一化均值方差、pad 的方向和 id,全部从这里取。训练、验证、推理三处共用,杜绝"某处忘了改"的低级错误。
这些习惯看起来琐碎,但数据管道的可靠性就是靠这些琐碎堆出来的。模型结构可以抄,数据管道抄不了,因为它和你的数据形态、存储方式、集群环境强绑定。把 collate_fn 和 Dataset 当成需要单测的代码来对待,而不是一次写对的脚本,这是我在多个项目里最深的体会。多模态预训练跑起来不难,难的是跑得稳、跑得可复现,而这两个目标的起点,就在数据加载这几十行代码里。