训练任务开跑十几分钟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 毫秒。所以训练高峰期扫描器不会来抢你的盘。准确说退避是扫描器每个请求之间的单步等待扫描不会因此整体停摆只是变慢极端持续的高压力下位腐扫描的完成周期会被明显拉长真在意修复进度就盯扫描进度指标确认数字在往前走就够。反过来如果你在夜里看存储指标发现扫描进度在推进、白天变慢那是正常现象别当故障处理。真正的解法通常在数据组织上分层查下来最常见的瓶颈和解法是这几个按收益排打包把小文件按 shard 打成较大的块tar、webdataset 这类格式训练时配合 range 读取按需取片段。请求数从十万降到几百上面所有的单请求开销一次性摊薄。shard 大小别贪大单个对象太大range 读的次数反而涨一般按几百 MB 一块起步调。代价也要心里有数shard 打成之后是不可变的更新或删掉单条样本意味着重写整个 shard 文件这套组织方式适合相对静态的训练集数据频繁增删改的场景要么按版本整批重打要么接受这个维护成本。综合看这是收益最大的一步。连接与并发连接池 keep-aliveworkers 和 prefetch 按存储侧的并发能力配别让客户端先把自己排满。前缀打散不打包的话key 前缀按 shard 哈希散开让并发读落在多组盘上。要清楚这只是把压力分摊开请求数没变每个请求的认证、寻址开销一样都省不掉属于不打包时的次优选择。监控对齐RustFS 的指标经 OTLP 导出rustfs_io_*系列能看到 I/O 面;读吞吐和延迟的曲线和训练 loss 的时间轴对齐看瓶颈在哪一层一眼可见。客户端侧还有个不起眼的配置点以 boto3 为例frombotocore.configimportConfig s3boto3.client(s3,endpoint_urlhttp://rustfs:9000,configConfig(max_pool_connections64))连接池默认偏小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 仓库。按这个顺序排下来大多数存储不行的训练卡顿答案落在第一步和第二步之间。真要把存储换掉也应该是数据组织优化做完、指标确认瓶颈确实在存储侧内部之后的事。顺序反了换了存储还是会卡在同一个地方。