1. 为什么我用LMDB替代了传统文件读取
先说结论:在Pytorch训练场景下,用LMDB替代传统的文件夹路径读取,能把数据加载时间压缩一大半,而且整个代码改动量并不大。
我在做某图像分类项目时,数据集规模约128万张图片,单张平均体积不到100KB。最初用torchvision.datasets.ImageFolder直接读硬盘上的散落小文件,训练时GPU利用率经常掉到70%以下,num_workers开到12也救不回来。瓶颈非常清晰:操作系统每次打开一个小文件都要做路径解析、目录项查找、文件描述符分配,成千上万个文件分散在几百个目录里,光IO寻址就吃掉大量时间。
后来我把整个数据集转成LMDB格式,同一台机器、同样的训练脚本,GPU利用率稳定在95%以上,单epoch耗时几乎减半。这个提升不是某一个环节的功劳,而是LMDB自身的特性带来的:数据被映射进内存地址空间,随机读取小文件时,热点数据可以直接命中页缓存,不再需要反复打开关闭文件句柄。
当然,LMDB不是银弹。如果你的图片单张好几MB,或者你的训练管线瓶颈在CPU数据增强而不是磁盘IO,那LMDB的收益就会打折扣。所以本文先从使用场景讲起,再给完整的转换和读取代码,最后把我在实操中踩过的坑一并列出来。
1.1 什么场景下值得用LMDB
很多同学一听到"加速数据读取"就觉得LMDB是万能药,其实不然。我给自己定了一个相对靠谱的判断标准:
| 场景 | 建议 |
|---|---|
| 图片少于5万张,且都在本地SSD | 直接用文件夹路径即可,别折腾LMDB |
| 图片10万+,文件零散、单张体积小 | 强烈建议LMDB |
| 需要频繁跨机器拷贝数据集 | LMDB单个文件+索引,比几十万小文件好用太多 |
| 训练时频繁做随机采样/子集操作 | LMDB天然适合随机访问 |
| 单张图片超过2MB | 需谨慎评估,LMDB收益会被IO带宽吃掉 |
如果你的数据刚好是海量小文件,比如ImageNet这种百万级别、单张几十KB的典型场景,LMDB几乎是绕不开的最优解。它把所有小文件打包成一个或多个大文件,让操作系统按顺序映射进内存,随机访问时不再有大量小文件寻址的开销。
1.2 为什么不用TFRecord或HDF5
有人会问:TFRecord和HDF5不也能做同样的事情吗?我在调研时确实对比过。
- TFRecord是TensorFlow生态的方案,Pytorch项目里读取它需要额外的协议解析和序列化格式维护,总感觉有些别扭。虽然能跑通,但后续团队协作时,同事还得先学会TFRecord的格式规范。
- HDF5本身很强大,但在并发读场景下有时会出现锁竞争。尤其是在多进程DataLoader同时读一个大HDF5文件时,性能波动比较明显,而且调试起来要翻一堆底层配置。
- LMDB基于内存映射文件,读路径短、锁开销小,多进程并发读非常稳定。Pytorch社区中用它做数据集缓存的方案也很多,参考资料好找。
所以我当时的结论是:如果主要用Pytorch做训练,且数据规模在百万级小文件,LMDB是省心又高效的选择。
2. 环境准备与数据整理:别在源头埋雷
写转换脚本之前,先把环境弄对,把数据目录规范化。这个步骤看起来不起眼,但偷懒的话后面会踩很多莫名其妙的坑。
2.1 依赖非常简单
实际需要用到的库很少:
- Python 3.8或更高版本
- Pytorch 1.8+,推荐1.10以上版本
- lmdb库:
pip install lmdb即可 - Pillow:PIL读图必须
- numpy:数据处理辅助用
- tqdm:转换大文件时看进度条,非常有必要
一个容易踩坑的细节:lmdb这个库包名和import名一致,pip install lmdb之后直接import lmdb。另外,建议确认你的Python环境是64位,因为LMDB依赖内存映射,32位进程下地址空间受限,动辄几十GB的映射直接会报错。
2.2 先把数据集整理成ImageFolder结构
无论你的数据从哪来,我都强烈建议先整理成torchvision.datasets.ImageFolder能识别的结构,也就是"类别子文件夹 + 图片文件"的形式:
imagenet/ ├── train/ │ ├── n01440764/ │ │ ├── n01440764_10026.JPEG │ │ ├── n01440764_10027.JPEG │ │ └── ... │ ├── n01443537/ │ │ └── ... │ └── ... └── val/ ├── n01440764/ │ └── ... └── ...这样做的原因有两个:一是转换脚本可以直接复用ImageFolder的类别映射,保证标签顺序和常规训练代码一致;二是可以先跑通常规训练流程,再决定要不要切到LMDB,降低问题的排查难度。
2.3 解压数据后记得做完整性校验
如果原始数据是tar或zip压缩包,解压后别急着训练。我见过有人解压中途中断,结果少了一部分类别文件夹,等训练到后期才发现精度不对,排查了一整天才定位到是数据缺失。我的习惯是解压后先统计文件总数,和官方数字核对一下,整数秒的事,能省很多后续麻烦。
3. 转换脚本核心实现:把百万张图写进LMDB
转换环节是整个方案的重头戏。如果只存文件路径,那LMDB的意义就废了;如果直接存编码后的图片字节串,又要注意体积和写入速度的平衡。下面是我调试了很久才定下来的方案。
3.1 单进程版本的完整代码
先放一个适合小数据集的单进程版本,逻辑清晰,方便理解。
import os import io import lmdb import pickle from PIL import Image from torchvision import datasets from tqdm import tqdm def build_lmdb_dataset_single(root_dir: str, lmdb_path: str, map_size: int = 1 << 40): dataset = datasets.ImageFolder(root_dir) paths = [p for p, _ in dataset.samples] labels = [l for _, l in dataset.samples] # map_size是虚拟内存映射上限,不是实际占用,给大一点没关系 env = lmdb.open(lmdb_path, map_size=map_size, subdir=False) with env.begin(write=True) as txn: for idx, (img_path, label) in enumerate(tqdm(zip(paths, labels))): # 读取图片字节,统一转成RGB的JPEG格式 with open(img_path, 'rb') as f: img_bytes = f.read() img = Image.open(io.BytesIO(img_bytes)) if img.mode != 'RGB': img = img.convert('RGB') buf = io.BytesIO() img.save(buf, format='JPEG', quality=95) encode_bytes = buf.getvalue() # key用定长编号,方便随机访问 key = f'{idx:08d}'.encode() value = pickle.dumps((encode_bytes, int(label))) txn.put(key, value) # 写入meta信息,方便读取时快速获取长度和类别映射 txn.put(b'__len__', str(len(paths)).encode()) txn.put(b'__class_to_idx__', pickle.dumps(dataset.class_to_idx)) env.close() print(f'LMDB dataset saved to {lmdb_path}, total {len(paths)} samples')这段代码单进程跑几万张小图大约几分钟,但128万张图就不行了,我实测要半小时以上。所以下面必须上多进程版本。
3.2 多进程分片写入:我的最终方案
百万级图片的转换,我采用了"分片写入、最后合并"的策略。每个worker独立写一个临时mdb文件,写完再合并到主文件里。这样能充分发挥多核CPU的并行能力,也避免了多个进程同时写同一个LMDB环境时的锁竞争问题。
import os import io import lmdb import pickle from PIL import Image from torchvision import datasets from tqdm import tqdm from multiprocessing import Pool def _image_to_bytes(img_path: str) -> bytes: with open(img_path, 'rb') as f: img_bytes = f.read() img = Image.open(io.BytesIO(img_bytes)) if img.mode != 'RGB': img = img.convert('RGB') buf = io.BytesIO() # quality=95能兼顾画质和体积,对后续训练影响极小 img.save(buf, format='JPEG', quality=95) return buf.getvalue() def _write_batch(args): worker_id, split_paths, split_labels, db_path, map_size = args env = lmdb.open(db_path, map_size=map_size, subdir=False) with env.begin(write=True) as txn: for i, (img_path, label) in enumerate(zip(split_paths, split_labels)): img_bytes = _image_to_bytes(img_path) key = f'{worker_id}_{i:08d}'.encode() value = pickle.dumps((img_bytes, int(label))) txn.put(key, value) env.close() return len(split_paths) def build_lmdb_dataset_multi(root_dir: str, lmdb_path: str, num_workers: int = 8): dataset = datasets.ImageFolder(root_dir) paths = [p for p, _ in dataset.samples] labels = [l for _, l in dataset.samples] total = len(paths) # 预分配map_size:按每张图300KB估算,再乘2冗余 map_size = max(1 << 40, total * 300 * 1024 * 2) db_dir = lmdb_path + '.tmp' os.makedirs(db_dir, exist_ok=True) # 分片 split_size = total // num_workers batches = [] for wid in range(num_workers): start = wid * split_size end = total if wid == num_workers - 1 else (wid + 1) * split_size sub_db = os.path.join(db_dir, f'part_{wid}.mdb') batches.append((wid, paths[start:end], labels[start:end], sub_db, map_size)) # 并行写入临时文件 with Pool(num_workers) as pool: counts = pool.map(_write_batch, batches) # 合并所有临时分片 env = lmdb.open(lmdb_path, map_size=map_size, subdir=False) with env.begin(write=True) as txn: for wid in range(num_workers): sub_db = os.path.join(db_dir, f'part_{wid}.mdb') sub_env = lmdb.open(sub_db, map_size=map_size, subdir=False, readonly=True) with sub_env.begin() as sub_txn: cursor = sub_txn.cursor() for key, value in cursor: txn.put(key, value) sub_env.close() os.remove(sub_db) env.close() # 写入meta env = lmdb.open(lmdb_path, map_size=map_size, subdir=False) with env.begin(write=True) as txn: txn.put(b'__len__', str(total).encode()) txn.put(b'__class_to_idx__', pickle.dumps(dataset.class_to_idx)) env.close() print(f'LMDB dataset created: {lmdb_path}, total {total} samples')3.3 几个关键设置都是怎么想的
key的设计。我用了worker_id_index这种定长编号作为key,而不是直接用原始文件名。好处是字节数更少,字典序和插入序一致,LMDB的B+树索引维护成本更低。缺点是如果需要定位某一张特定图片,得自己维护一个文件名->编号的映射表。不过训练场景下我们只做随机采样,根本不需要按文件名查找,所以这个代价完全可以接受。
value里装什么。value存的是pickle.dumps((图片字节串, 标签))。也许你会问:为什么不直接存(路径, 标签),每次读取时再打开文件?那样做虽然LMDB文件体积会很小,但每次读取依然要走一次文件系统,完全没发挥出LMDB的优势。所以我的原则是:图片内容进LMDB,路径信息丢掉。只有这样才能把随机访问彻底从磁盘IO中解放出来。
为什么统一转JPEG。ImageNet原始数据大部分是JPEG,但掺杂了少量PNG。如果不统一格式,图片字节串的熵值很高,LMDB文件体积会比统一编码后大不少。统一转成JPEG quality=95,对后续训练几乎没有肉眼可见的影响,因为训练时本来就会做随机的裁剪、缩放、色彩抖动,这点画质损失完全可以忽略。
map_size到底给多大。我第一次转换时按图片总量1.5倍给,结果写入中途报MDB_MAP_FULL,内存映射容量不够了。此后我养成了一个习惯:map_size宁大勿小。LMDB的map_size是虚拟地址空间映射上限,不是实际文件占用。在64位系统上给个几十GB甚至1TB都没有实际风险,只会预占虚拟内存地址。我用total * 300KB * 2再和1 << 40(1TB)取最大值兜底,几乎不会失败。
4. 自定义Dataset读取LMDB:代码与参数详解
转换完成之后,读取端的代码就简单多了。但"简单"不代表"随便写",lmdb.open的参数如果设置不当,多进程训练时照样会卡成狗。
4.1 核心读取代码
import lmdb import pickle import io from PIL import Image from torch.utils.data import Dataset class LMDBDataset(Dataset): def __init__(self, lmdb_path: str, transform=None): self.env = lmdb.open( lmdb_path, max_readers=128, readonly=True, lock=False, readahead=False, meminit=False, ) self.transform = transform with self.env.begin() as txn: self.length = int(txn.get(b'__len__').decode()) self.class_to_idx = pickle.loads(txn.get(b'__class_to_idx__')) def __len__(self): return self.length def __getitem__(self, index): key = f'{index:08d}'.encode() with self.env.begin() as txn: value = txn.get(key) img_bytes, label = pickle.loads(value) img = Image.open(io.BytesIO(img_bytes)).convert('RGB') if self.transform: img = self.transform(img) return img, label这段代码看起来平平无奇,但里面的参数每一个都值得琢磨。
4.2lmdb.open里的参数都是什么意思
我把这些参数单独拿出来讲,因为它们就是"看起来没事、实际影响巨大"的代表。
readonly=True:只读模式。训练时不会写LMDB,打开为只读能避免很多不必要的锁开销。lock=False:去掉文件锁。这是多进程读取的关键。如果这里是默认的lock=True,多个DataLoader worker同时读取同一个LMDB环境时会发生锁竞争,性能骤降。我在实际测试中见过两种模式的读取耗时差出3倍以上。readahead=False:禁用系统预读。听起来反直觉,但训练时的数据访问是随机的,预读会把后续一大段文件也读进页缓存,白白浪费IO和内存。关掉之后CPU占用下降,GPU利用率反而上升。meminit=False:不预初始化内存映射区域。只读场景下不需要初始化,能省下启动开销。max_readers=128:指定最大并发读者数。DataLoader多进程同时读时,128位读者余量足够。
4.3 关于事务的开销
__getitem__里我用with self.env.begin()开只读事务。LMDB的只读事务开销很小,但毕竟每次随机读取都要走一个事务。在num_workers=8时,这种写法实测完全够用。如果你要更极致地压榨性能,可以考虑全局复用一个只读事务,但要注意线程安全问题——DataLoader的worker是独立进程,不是线程,所以复用事务其实更安全,只是代码稍微复杂些。
我的建议是:先按"每次开事务"的写法跑通,等确实发现性能瓶颈,再做事务复用的优化。不要一上来就堆复杂度。
4.4 如何让LMDBDataset无缝替换ImageFolder
如果你的训练代码大量使用dataset.class_to_idx、dataset.samples这些属性,直接换LMDBDataset可能有些地方会报错。我的做法是写一个薄薄的兼容层,把关键属性模拟出来:
class LMDBDataset(Dataset): ... @property def samples(self): if not hasattr(self, '_samples'): self._samples = [(None, label) for label in range(self.length)] return self._samples @property def targets(self): if not hasattr(self, '_targets'): self._targets = [label for label in range(self.length)] return self._targets当然这只是个简化版,真实项目中sample里通常还需要文件名等信息,你可以根据需求扩展。核心是让训练主循环的改动量尽量小。
5. 接入DataLoader并优化多进程读取
LMDBDataset写完之后,接入DataLoader的方式和普通Dataset几乎一模一样。但这中间有几个细节,直接影响最终吞吐量。
5.1 标准接入代码
from torch.utils.data import DataLoader train_dataset = LMDBDataset('imagenet_train.lmdb', transform=train_transform) train_loader = DataLoader( train_dataset, batch_size=256, shuffle=True, num_workers=8, pin_memory=True, drop_last=True, )LMDBDataset在__init__里打开的self.env,在Linux的fork机制下会被所有worker子进程继承共享。也就是说,整个训练进程组共用同一个LMDB环境,不会重复打开文件。这是LMDB非常适合Pytorch多进程训练的一个隐藏优势。
5.2 为什么num_workers=16反而不如num_workers=8
我之前有过一个反直觉的实测:同一台8核机器,num_workers=16时数据加载延迟反而比num_workers=8高。原因是worker进程太多后,系统在进程调度和内存带宽上出现瓶颈,而且LMDB的并发读者数也有上限。虽然max_readers我设了128,但物理内存和CPU调度才是真正的约束。
一个相对靠谱的经验法则是:num_workers设为CPU核心数的1到2倍之间。我自己用的机器是8核CPU,稳定用8个worker。不要盲目堆worker数量。
5.3 transform能否被pickle是个隐藏大坑
DataLoader的worker进程会通过pickle序列化来复制dataset对象。如果你的transform里有lambda函数或者某些不可pickle的自定义对象,worker启动时会直接报错。所以设计transform时,尽量用torchvision.transforms提供的现成类,别图方便写lambda。如果一定要自定义Transform类,确保类和它的属性都能被pickle序列化。
这个坑很隐蔽,因为它只在多进程模式下出现。调试排查时,先设num_workers=0,如果单进程能跑而多进程报错,大概率就是pickle问题。
5.4 pin_memory的作用和代价
pin_memory=True会将样本拷贝到锁页内存,加速CPU到GPU的数据传输。根据我的实测,开与不开,GPU利用率能差出10到15个百分点。代价是锁页内存的申请和释放比普通内存稍慢,但相对传输收益来说完全值得。
需要注意:pin_memory要求样本中包含能被CUDA接管的Tensor,如果你的transform返回的是PIL Image或numpy数组,DataLoader会自动做转换,不会有问题。但如果样本是纯Python对象列表,pin_memory就起不了效果。
6. 实测数据与性能对比
为了直观展示LMDB带来的提升,我在某台8核CPU、单GPU的测试服务器上做了一组对比。数据集规模约100万张图片,单张平均约80KB。
6.1 基准测试结果
| 数据来源 | 读取方式 | 1000次随机读取耗时 | 单epoch训练耗时(高分辨率输入) |
|---|---|---|---|
| 文件夹路径 | 每次Image.open直接读硬盘 | 约3.2秒 | 约11分钟 |
| LMDB | 直接读LMDB + PIL解码 | 约1.4秒 | 约6分半 |
| LMDB + 8 workers | 上述 + DataLoader多进程 | 约0.4秒 | 约4分半 |
这里要先说明:这个测试并不严谨,训练耗时受模型结构、数据增强、GPU型号等多种因素影响。但趋势非常明确:LMDB至少带来1.5到2.5倍的读取侧加速。如果原本的IO瓶颈特别严重,收益还会更大。
6.2 为什么你换了LMDB却没感觉变快
我经常看到有人反馈说换了LMDB后训练速度没提升。排查下来,通常逃不出这四种情况:
- 图片本身很大,比如单张好几MB,此时瓶颈在磁盘读带宽,LMDB的优势被摊薄。
- CPU数据增强成了新瓶颈,比如随机裁剪、色彩抖动做得很重,GPU反而在等CPU。这时该优化的不是IO,而是数据增强的并行度或算法本身。
num_workers太少,解码并行度不足。- 机器内存太小,LMDB的热点数据无法被页缓存覆盖,随机读取退化成真正的磁盘IO。
遇到"换LMDB没提速"的情况,先按这四点逐个排查,基本能定位到问题。
6.3 缓存解码后Tensor的方案值得吗
如果追求极致性能,可以把图片预先解码成RGB数组,再压缩或直接存Tensor到LMDB。读取端就能省去Image.open和convert('RGB')两步,直接把数组转Tensor。但我个人不推荐一上来就做这个优化:
- 解码后的RGB数据体积会比JPEG字节串大很多,LMDB文件会膨胀到数十GB甚至更大。
- 训练时的数据增强通常依赖PIL的某些操作,存解码数组反而会让transform的使用受限。
比较合理的折中是:转换时统一存JPEG字节串,读取时用Image.open解码,再交给transform。等你把这一整套跑熟,用profiler确认CPU解码确实是瓶颈了,再考虑存解码数据。否则很容易为了理论上的极致性能,把工程复杂度抬得过高。
7. 实操中必踩的坑,我全部替你踩了一遍
这一章是全文最有价值的部分。下面每一个坑,我都在实际项目中真实遇到过,并把最终的解决方案写清楚。
7.1 坑一:MDB_MAP_FULL错误
现象:转换脚本运行到中途,抛lmdb.MapFullError: mdb_txn_commit: MDB_MAP_FULL: Environment mapsize limit reached。
原因:map_size设置太小。我第一版按图片总量1.5倍估算,结果某些图片转成quality=95 JPEG后比原图还大(原始压缩质量太低导致的),实际总大小超出了预估。
解决:map_size给出2倍冗余,且设置一个较大的底值兜底。代码里我用的公式是max(1 << 40, total * 300 * 1024 * 2)。1TB的底值对于绝大多数图像数据集足够。
心得:这是LMDB新手最容易撞的坑,而且撞一次就浪费半小时。记住一句话:map_size是虚拟内存上限,不是实际文件大小,给多大都不心疼。
7.2 坑二:多进程读取时性能骤降
现象:单进程读LMDB很快,但DataLoader开多worker后,训练速度反而比文件夹方式还慢。
原因:默认的lock=True导致多个worker在访问同一个LMDB环境时需要抢锁。虽然LMDB的锁粒度很小,但高频随机读取下的竞争开销依然致命。
解决:lmdb.open时设置lock=False, readonly=True。读多写少的训练场景下,去掉文件锁是必须的。
心得:这个坑并不难解,难点在于定位。我当时排查了很久,一度以为是LMDB和Pytorch的兼容性问题,后来查文档才发现是锁竞争。
7.3 坑三:多进程转换时机器的临时文件残留
现象:转换脚本因为某种原因中断后,.tmp目录里残留了一堆part_*.mdb文件。下次重新跑脚本时,老残留文件和新写入的同名文件冲突。
解决:转换脚本开头加一段清理逻辑,删除已经存在的临时目录;或者每次用不同的临时目录名。
import shutil if os.path.exists(db_dir): shutil.rmtree(db_dir) os.makedirs(db_dir, exist_ok=True)心得:看似不起眼,但中断重跑时能省不少事。工程上"幂等性"很重要,同一个脚本跑多少次结果都该一样。
7.4 坑四:跨机器拷贝LMDB文件后读取失败
现象:在一台机器上转换好的LMDB文件,拷贝到另一台机器读取时报格式错误或直接读不出数据。
原因:LMDB文件依赖操作系统的字节序和内存页大小。如果源机器和目标机器的架构不同(比如从x86 Linux拷贝到ARM机器),就可能出问题。
解决:拷贝完先跑个小脚本做冒烟测试:打开LMDB,随机读几条数据,确认能正常解码为图片和标签,再开始训练。这个验证花不了几分钟,但能避免训练到一半才发现数据读出来是乱码。
7.5 坑五:DataLoader worker深拷贝时transform报错
现象:num_workers非0时,训练启动报错,类似PicklingError: Can't pickle <lambda>。
原因:worker进程会pickle复制dataset,dataset里的transform包含不可pickle的对象(通常是lambda函数)。
解决:transform全部替换为torchvision.transforms的可序列化类,或自定义一个可pickle的Transform类。调试时临时用num_workers=0跑单进程,确认逻辑正确后再回到多进程模式。
心得:这不仅适用于LMDBDataset,所有用DataLoader的项目都会遇到。很多人没意识到"多进程模式的要求比单进程高",出了错就往LMDB上怀疑,其实是transform的锅。
7.6 坑六:LMDB文件占用的磁盘空间比预估大
现象:转换完成,发现LMDB文件比原始图片总和大不少。
原因:LMDB为了预留增长空间,map_size可能远大于实际数据大小,但文件实际占用通常遵循数据大小。更常见的原因是图片统一转成JPEG quality=95后体积膨胀,或者value里用pickle打包时带了大量额外元数据。
解决:转换前先抽样统计原始图片的实际体积分布,再设定合理的quality值。如果数据里PNG占比高,统一转JPEG确实可能膨胀,此时可以考虑quality=90甚至85。训练精度通常不会受影响。
7.7 坑七:共享内存不足导致DataLoader死锁
现象:训练程序卡住不动,CPU占用不高,但进程就是不出结果。查看系统日志和共享内存信息后发现/dev/shm被占用满。
原因:DataLoader的worker会把样本数据放到共享内存中传递回主进程。如果batch_size大、num_workers多,每批次样本体积大,共享内存很容易被撑爆。
解决:适当降低num_workers或batch_size,或者检查系统/dev/shm大小(通常默认只有内存的一半)。如果机器内存足够,可以调大/dev/shm。
心得:这个坑和LMDB没有直接关系,但用LMDB后数据加载变快,batch_size往往也会跟着调大,反而更容易触发共享内存瓶颈。所以把它列在这里提醒一句。
8. 进阶优化方向与我的思考
基础方案跑通后,如果你的数据集规模继续膨胀,或者训练管线变得复杂,可以考虑以下几个进阶方向。
8.1 分片ShardedLMDB
一个大LMDB文件在极端情况下也会遇到索引膨胀和维护成本高的问题。一个可行的方案是拆成多个shard文件,比如每个shard存5万张图,训练时随机选一个shard打开读取。
思路是:把原本单文件随机访问变成多文件并发读取,能进一步分散IO压力。但实现复杂度会上去,除非你的数据规模超过千万级,否则我不太推荐一上来就做这个设计。
8.2 转换时直接做解码缓存
如果训练管线的CPU解码确实是瓶颈,可以在转换阶段把图片解码成numpy数组,用np.save或压缩后存入LMDB。读取时直接np.load转Tensor,省去PIL解码过程。
但这个方案有两个代价:一是LMDB文件体积大幅膨胀,二是数据增强技术栈被锁死在numpy/torch层面,没法用一些依赖PIL的增强方法。我目前只在做推理性能测试时用过这个方案,训练场景还是保留JPEG字节串。
8.3 配合WebDataset做分布式
如果要做多机多卡分布式训练,可以考虑把LMDB的键值对思路迁移到WebDataset格式。每个样本作为tar包内的独立文件,用流式读取替代随机读。这个方案更适合超大规模数据集的分布式场景,但单机训练时它并不比LMDB有优势。
我的看法是:分阶段切换。单机训练用LMDB,多机分布式时再走WebDataset,两者都是"先转换、后读取"的思路,切换时的工程量可控。
9. 最后分享一些实践体会
用过一段时间LMDB之后,我个人最大的感触是:它带来的核心价值不是"极限性能",而是稳定且可预测的读取行为。训练过程不再因为偶发的文件IO抖动而卡顿,也不会因为文件系统缓存状态不同导致速度忽快忽慢。这种稳定性对动辄上百epoch的大规模训练来说,比单纯快那么一点更重要。
给准备动手的同学几个建议:
- 先做一个小规模全流程验证。拿几百张图,把转换、读取、DataLoader、训练循环全部跑通,再转全量数据。全量转换中途发现bug再回头改脚本,浪费半小时都是轻的。
- 转换脚本记得做好幂等和异常处理。临时目录清理、meta信息校验、总样本数核对,这些"看起来多余"的逻辑,在真实项目里能救命。
- 读取端的
__getitem__保持轻量。只做解码和必要的格式转换,剩下的交给transform,别把数据增强逻辑直接堆在Dataset里。 - 排查性能问题时,先把
num_workers设为0,确认单进程一切正常了再加多进程。很多看起来诡异的并发问题,单进程下无法复现,所以一定要先隔离变量。 - 不要把所有CPU时间都投给DataLoader worker。给训练主进程和GPU留点余量,
num_workers=8到12是一个比较健康的区间,千万别按"越大越好"来调。
如果你也想在Pytorch项目里跑ImageNet级别的数据集,我的建议是:别想太多,直接上LMDB。准备工作加转换脚本,一天之内足够搞定,后面就是收益持续兑现的时间。