☰
训练集十万个小文件,GPU 利用率上不去?先把瓶颈层分清
2026/10/3 13:55:10 网站建设 项目流程

训练任务开跑十几分钟,GPU 利用率上不去,日志里 DataLoader 的 workers 一个个都在等数据。这种情况的第一反应通常是"存储不行",但真正动手查之前值得先分层:一份十万级小文件的训练集,从读一个文件到 GPU 吃到 batch,中间隔着客户端并发、网络往返、认证、存储内部好几道工序。瓶颈可能在任何一层,替换存储是最后才该考虑的动作。

瓶颈先从客户端查起

随机读小文件的吞吐有个简单的上界:单并发每秒能完成的请求数,约等于文件大小除以(往返时延加处理时延)。按 4KB 的文件、跨网往返 0.5ms 算,单并发一秒也就几千个请求;想喂饱 GPU,靠的从来是并发,不是单流。所以第一件事是核对 DataLoader 的配置:workers 数、prefetch factor、每个 worker 是否独立连存储。PyTorch 的常见错配是 workers 开了不少,prefetch 没开,每个 epoch 开头齐刷刷等第一个 batch。

worker 独立连存储这点比看起来更重要。PyTorch 在 Linux 上默认用 fork 起 worker,主进程里创建好的 S3 客户端连同连接池里已经建好的 socket 被原样复制进子进程,几个 worker 共用同一批底层连接,轻则报错重则请求互相卡死。AWS 文档明确要求 Session 和客户端按线程或进程分别创建,落到 DataLoader 里就是:让 Dataset 在第一次取数据时才创建客户端,或者用 worker_init_fn 初始化,别在主进程建好全局单例等着被继承。

HTTP 连接复用也要查。每个请求都重新建 TCP 加 TLS 握手,光握手就吃掉小文件大半的延迟预算。用 S3 客户端时确认连接池开着、keep-alive 生效,这一个开关经常比换存储多拿几倍的读吞吐。

存储侧的三道工序

过了客户端,每个读请求在 RustFS 侧要过三道工序。认证:请求带 SigV4 签名,存储要验签、查 IAM 策略。寻址:从桶和 key 定位到纠删集和数据所在盘。读取:从纠删集读数据分片。读路径不吃纠删码的写放大:读一个对象只需要取出数据分片,校验分片是写路径和修复路径才碰的东西,随机读的开销构成比写场景干净。官方 S3 兼容矩阵里 range 读取在已测试列表,所以"读文件的一部分"是可用的,这个能力后面有用。

十万个小文件的组织方式直接影响寻址这道工序。全部堆在一个前缀下和按dataset/shard-xxxx/打散,元数据查找的局部性完全不同。RustFS 的纠删集有默认几何(12 数据分片加 4 校验分片),key 的前缀分布决定读压力落在哪些盘上,前缀打散就是在给磁盘分摊队列。训练还有一个特点会加重随机读:框架默认要 shuffle,每个 epoch 的样本顺序都打乱,顺序写进去的 shard 之后会被毫无规律地到处翻。顺序写、随机读的错位是这个场景的常态:存储侧能让随机读便宜些(前缀打散、并发给足),数据侧的解法还是打包,shard 内部顺序读,随机性只落在 shard 粒度上,需要翻动的单元数量直接砍掉几个数量级。

一个容易误判的现象:扫描器让路

还有个反直觉的点值得知道:RustFS 的后台扫描器(负责位腐检测和数据修复扫描)在检测到前台读压力时会主动让路。源码里的策略是前台读并发越高,扫描器每个请求后的退避越长,从 10 毫秒起步、上限 250 毫秒。所以训练高峰期扫描器不会来抢你的盘。准确说,退避是扫描器每个请求之间的单步等待,扫描不会因此整体停摆,只是变慢;极端持续的高压力下,位腐扫描的完成周期会被明显拉长,真在意修复进度就盯扫描进度指标,确认数字在往前走就够。反过来,如果你在夜里看存储指标发现扫描进度在推进、白天变慢,那是正常现象,别当故障处理。

真正的解法通常在数据组织上

分层查下来,最常见的瓶颈和解法是这几个,按收益排:

  1. 打包:把小文件按 shard 打成较大的块(tar、webdataset 这类格式),训练时配合 range 读取按需取片段。请求数从十万降到几百,上面所有的单请求开销一次性摊薄。shard 大小别贪大,单个对象太大,range 读的次数反而涨,一般按几百 MB 一块起步调。代价也要心里有数:shard 打成之后是不可变的,更新或删掉单条样本意味着重写整个 shard 文件,这套组织方式适合相对静态的训练集,数据频繁增删改的场景要么按版本整批重打,要么接受这个维护成本。综合看这是收益最大的一步。
  2. 连接与并发:连接池 + keep-alive,workers 和 prefetch 按存储侧的并发能力配,别让客户端先把自己排满。
  3. 前缀打散:不打包的话,key 前缀按 shard 哈希散开,让并发读落在多组盘上。要清楚这只是把压力分摊开,请求数没变,每个请求的认证、寻址开销一样都省不掉,属于不打包时的次优选择。
  4. 监控对齐:RustFS 的指标经 OTLP 导出,rustfs_io_*系列能看到 I/O 面;读吞吐和延迟的曲线和训练 loss 的时间轴对齐看,瓶颈在哪一层一眼可见。客户端侧还有个不起眼的配置点,以 boto3 为例:
frombotocore.configimportConfig s3=boto3.client("s3",endpoint_url="http://rustfs:9000",config=Config(max_pool_connections=64))

连接池默认偏小,workers 一多,请求就在池口排队,表现和存储慢一模一样。先把这个数对齐 workers,再谈别的。keep-alive 的收益还不止握手这一项:每条新连接开工前都要先做一轮 DNS 解析,Python 客户端默认不缓存解析结果,连接复用生效的前提下这笔开销只在建连时发生一次,反过来连接频繁重建时,解析和握手会一起被放大。网络这层的账要分开算:小文件随机读消耗的是每秒请求数,不是带宽,万兆链路搬 4KB 的请求要每秒几百万个才碰得到带宽上限,先到顶的通常是存储侧的处理能力和 CPU。看监控时把 IOPS 和带宽两条曲线分开看,别拿带宽没满推断存储没压力。

性能数字这件事要单独说一句:公开能查到的 RustFS 基准(比如仓库 README 里那组 4KB 写入对比)是写场景、特定硬件下的数字,读 IOPS 的表现随硬件、盘数、并发模式变化很大,放到自己的训练集上必须自己测。测试方法也简单:固定数据集打包与不打包两版、固定 GPU 和 batch 配置,各跑一个 epoch 看 GPU 利用率和每 step 耗时,两版差距就是存储层的账。

想再往下挖,客户端这层的参数细节有各自的官方专页,PyTorch 的多进程数据加载模型在 pytorch.org 的 data 文档里,boto3 的连接池配置在 botocore 文档的 Config 页;存储侧的指标含义与导出配置在官方文档的可观测性章节。RustFS 的 1.0.0 正式版于 2026 年 9 月 16 日发布,源码和那组 4KB 写入基准都在 GitHub 的 rustfs/rustfs 仓库。

按这个顺序排下来,大多数"存储不行"的训练卡顿,答案落在第一步和第二步之间。真要把存储换掉,也应该是数据组织优化做完、指标确认瓶颈确实在存储侧内部之后的事。顺序反了,换了存储还是会卡在同一个地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询