ARM架构下JuiceFS性能优化:MLPerf压测的缓存与并发调优实践 ARM 架构上的 JuiceFS 性能优化最早在我这里听上去像一道送分题跑个压测改几个参数收工。真把 MLPerf 训练基准挂上去之后我们被折腾了三周。这篇内容就把完整过程拆开来讲包括指标口径、缓存调优、并发模型以及一次让人挠头的周期性抖动排查。如果你恰好也在 ARM 集群上用 JuiceFS 喂数据给训练任务这里面的坑应该能帮你少走一半弯路。1. 第一轮 MLPerf 压测GPU 利用率为什么会像心电图1.1 任务背景与环境基线这项工作是典型的存储-计算解耦场景。计算节点是一批 ARM 服务器每个节点 128 个逻辑核心、256GB 内存GPU 则按训练框架的推荐型号配齐。数据统一放在 JuiceFS 分布式文件系统上后端对接的是兼容 S3 的对象存储服务元数据引擎单独部署在一个关系型数据库实例里。JuiceFS 客户端以 FUSE 方式挂载到每个计算节点目标是把原来跑在 x86 环境里的一套 MLPerf 训练流程原样迁移过来。MLPerf 这类基准测试对存储的要求比普通业务复杂很多。它不只是读文件而是多个 dataloader worker 同时并发地读样本、做解码、随机增强再拼成 batch 送给 GPU。训练本身的计算过程是相对稳定的因此 GPU 利用率一旦掉下来第一个怀疑对象就是数据供给链条。我们第一轮测试用了一套图像分类类模型数据集是几万张小文件图片总大小约 200GB。听起来不大但小文件本身就是分布式文件系统最不友好的访问模式。1.2 实测表现四个不正常的信号第一轮压测跑起来后GPU 利用率在最初几分钟还能维持在 90% 以上之后就呈现出明显的周期性锯齿。具体记录的数据有四个异常信号第一GPU 利用率周期性掉到 30% 以下每次持续一到两分钟然后慢慢爬回 80% 以上。第二dataloader 的 Worker 日志里频繁出现等待数据超过 5 秒的警告而 x86 环境下同样配置几乎看不到。第三JuiceFS 客户端自带的 stats 命令显示 cache miss 率并不高读数大约是 90% 以上命中但后端对象存储依然出现了不小的读流量。第四CPU 的负载并不高大部分核的使用率在 30% 上下但某些核的软中断占比异常高。这几个信号组合在一起几乎排除了数据总量不足的猜测。问题更像出在数据流的某个环节被周期性堵住了。1.3 初步结论ARM 上 FUSE 路径的放大效应ARM 服务器普遍是核多但单核主频低的形态。128 个逻辑核心听起来很壮观但每个核心的主频和单核性能都比同价位 x86 弱。FUSE 文件系统每次读写都要经过内核态与用户态的上下文切换JuiceFS 客户端在用户态完成切片、缓存管理、网络请求调度整个过程对 CPU 单核延迟非常敏感。首轮测试里我们观察到某些核心的软中断占比超过 40%。这些中断主要来自网卡队列和 FUSE 请求处理。当多个 dataloader worker 同时发起随机读时FUSE 请求会在少数几个核心上排队单核处理不过来整体时延被拉高。而 ARM 服务器的内存带宽相比 x86 也更紧张页缓存预读和内存拷贝的代价被进一步放大。换句话说ARM 上跑 JuiceFS并不是简单地慢一点而是 FUSE 路径的固定开销占比更高原先在 x86 上不明显的问题会被放大成周期性卡顿。2. 压测 JuiceFS 的指标口径哪些数据才真正反映存储问题2.1 MLPerf 里真正考验存储的环节MLPerf 的训练基准是一个整体流程存储并不是唯一变量。按我们的经验真正压到存储的环节集中在三处第一是数据加载。图像类任务会读大量小文件每个样本通常只有几十到几百KB分布式文件系统最怕这种随机小读。第二是checkpoint 和日志写入。每个 epoch 结束后框架会写模型权重虽然文件不大但如果所有节点同时写元数据服务的压力会瞬间飙升。第三是在线数据预处理产生的中间文件包括随机裁剪后的临时缓存这部分通常是写到本地临时目录但如果配置不当也会落到 JuiceFS 上。很多团队压测时只盯着存储系统吞吐量这其实不够。MLPerf 这类训练任务对数据加载的要求是每个 batch 必须在规定时间内准备好否则 GPU 空转。因此单看吞吐曲线是看不出卡顿的必须把时延分布和等待事件一起看。2.2 指标定义与观测方法我们最终确定了一套相对完整的观测指标全部围绕训练是否被数据饿死来设计指标定义判断标准端到端 epoch 平均耗时每个训练 epoch 的墙钟时间波动幅度控制在 10% 以内dataloader 等待占比数据加载线程等待 I/O 的时间占总耗时比例小于 5%JuiceFS 读时延 p50/p99客户端记录的单次读请求耗时p99 不超过 50ms缓存命中率数据块在本地缓存命中的比例稳定在 90% 以上后端对象存储读带宽实际回源到对象存储的流量与 cache miss 率能互相印证这里最容易踩的坑是只统计平均时延。分布式文件系统的时延分布往往是长尾的p99 更能反映真实体感。第一次压测里读时延的 p50 只有 8ms看着很正常但 p99 已经冲到 900ms。这个 900ms 的尾巴就是 GPU 利用率跳水的直接原因。2.3 测试前的干扰项清理如果不先把干扰项清干净后面所有调优都会变成猜谜。我们踩过的干扰项主要有三个一是客户端日志量。JuiceFS 默认日志级别在正常运行时不会产生太多输出但如果把日志开到 debug 级别来定位问题日志写入本身会占用 CPU 和磁盘导致性能下降 20% 甚至更多。二是缓存目录的清理策略。如果缓存放满了客户端会逐出旧块测试中的缓存命中率会不断变化。压测前应该统一缓存目录并在每组测试之间做冷热分离对比。三是系统层面的 NUMA 分配。ARM 服务器的内存访问也有亲疏关系如果 FUSE 进程被调度到某个 NUMA 节点但它访问的内存和网卡中断在另一个节点时延会明显恶化。清掉这些干扰项之后我们才敢说后面的调优是在处理真实瓶颈。3. 调优主线一把缓存层调成训练专用模式3.1 元数据三件套attr-cache、entry-cache 与 negative cacheJuiceFS 的元数据缓存有三个关键参数分别控制文件属性、目录项和不存在结果的缓存时间。训练任务的数据集有一个特点文件只读、目录结构固定、样本文件一旦生成就不会被删除或重命名。这给了我们激进的底气。原先 x86 环境里attr-cache 设置的是 120 秒entry-cache 是 300 秒。迁移到 ARM 后我直接把这两项调整到 3600 秒。原因很简单数据集在训练过程中不会变化缓存 1 小时不会带来一致性问题。negative-attr-cache 也开到了 60 秒训练框架会反复探测一些辅助文件是否存在这个参数能挡住大量无意义元数据请求。实际挂载命令大致是juicefs mount \ --attr-cache 3600 \ --entry-cache 3600 \ --negative-attr-cache 60 \ --cache-size 20480 \ --cache-dir /mnt/nvme/jfs-cache \ --no-usage-report \ oss://my-bucket /mnt/jfs注意元数据缓存不是一个只赚不赔的操作。如果数据集在训练过程中有动态写入比如每个 epoch 都会追加新的样本文件那过长的 entry-cache 会导致读不到新文件。对这种只读训练场景来说长时间缓存收益非常大。我们在一次测试里把这三个缓存参数从默认的 1 秒改成 3600 秒后元数据服务的 CPU 占用直接降了一半dataloader 里跟文件属性相关的系统调用几乎不再访问后端。3.2 数据块缓存内核 page cache 与 JuiceFS 本地缓存的分工JuiceFS 挂载成 FUSE 文件系统后数据读取会经过一层内核 page cache同时 JuiceFS 客户端自己也维护一份本地数据块缓存。两层缓存如果配置不合理会造成内存浪费甚至互相干扰。ARM 节点的内存带宽有限我们建议把 JuiceFS 客户端缓存放在 NVMe 盘上而不是继续占内存。内存里留给内核 page cache 反而更划算因为 FUSE 的 read 操作会把最终数据页放进 page cache训练框架读同一个文件时内核层可以直接命中。那 JuiceFS 的--cache-size该设多大我个人的经验是对于纯训练节点内存总量在 256GB 时给 JuiceFS 本地缓存留 10%~20% 足够。如果全部样本数据大约 200GB而训练又要反复遍历你可能觉得缓存越大越好但内存带宽冲突是真实存在。JuiceFS 缓存块命中后需要从缓存目录读回内存这个拷贝过程也会占用内存带宽。与其让两层缓存争抢不如让客户端的缓存目录全部落在 NVMe容量给到 100GB 以上内存留给系统本身。3.3 只读训练集的缓存配置示例如果训练数据集全程只读还可以再进一步用只读方式挂载 JuiceFS。只读挂载会省掉很多写路径上的锁和状态维护也会让元数据缓存更安全。juicefs mount \ --attr-cache 3600 \ --entry-cache 3600 \ --negative-attr-cache 3600 \ --cache-size 102400 \ --cache-dir /mnt/nvme/jfs-cache \ --prefetch 4 \ --read-only \ oss://my-bucket /mnt/jfs--prefetch 4的含义是当一个数据块被访问时客户端会预取接下来 4 个块。这个参数对图像数据集尤其有效因为同一批样本在数据集里通常是连续存储的预取能把串行读变成批量读。这里也有一个取舍。预取太激进会拉高后端对象存储的瞬时带宽如果后端是共享的可能影响其他任务。我们最终把 prefetch 定为 4没有继续往上加原因在后面的抖动排查里会细说。4. 调优主线二并发线程、网络连接与 ARM 核数的匹配4.1 io-threads 和 max-conns 的初始设定JuiceFS 客户端负责在网络层面跟对象存储交互并发的控制主要看两个参数--io-threads负责处理数据读写的工作线程数--max-conns负责每个后端对象存储地址的最大连接数。在 x86 机器上常见做法是把 io-threads 调到 64 甚至 128因为对象存储的时延摆在那里并发多一点总能榨出带宽。但 ARM 节点给我的教训是线程数不是越多越好。我们做了一组对比测试io-threads 从 16 加到 128吞吐并没有一直往上走反而在超过 64 之后开始下降。原因是线程调度开销和缓存局部性恶化。尤其当多个线程同时处理来自不同文件的随机读时每个线程的工作集很小L2 cache 命中率下降最终所有线程都在等内存。推荐的做法是先按逻辑核数的四分之一起步。128 核的机器io-threads 可以先设 32然后以 8 为单位往上试探。max-conns 则跟网卡队列数对齐。25GbE 网卡通常配置 4 到 8 个队列把 max-conns 设为 32 到 64 之间既能保证带宽又不会让连接管理本身成为瓶颈。4.2 用公式估算下限用实测确定上限调并发的第一步不是拍脑袋而是算一下最少需要多少个在途请求才能满足目标带宽。参考 Littles law并发下限可以近似为并发数 (目标吞吐 / 单个请求数据量) × 平均请求时延假设训练数据加载需要 1.6GiB/s 的吞吐JuiceFS 默认数据块大小是 4MiB后端对象存储平均请求时延约 5ms那么每秒需要发出约 409 个读请求在途请求数约为 409 × 0.005 ≈ 2。这显然太少因为单个 HTTP 连接的实际吞吐受 TCP 窗口和拥塞控制限制跑不满网卡。真实场景里我们还需要把请求粒度切小或者用多个连接并行。把单个请求的数据量按 1MiB range 请求来算同样的带宽需要每秒约 1638 个请求在途并发约 8。这个数字依然保守实际我们把 max-conns 调到 48io-threads 调到 32才稳定跑到目标带宽。公式的价值在于给一个下限避免你一开始就因为并发不够而被带宽卡住上限则必须用真实压测去试探。4.3 网卡中断与 NUMA 亲和性ARM 服务器的中断处理如果不做亲和性设置会出现一个非常隐蔽的性能陷阱所有网卡中断都落在 CPU0 上而 FUSE 的 worker 线程分散在其他核心数据从网卡到内存再被 worker 拷贝跨 NUMA 节点的开销比想象中大。我们的处理方式分两步。第一步是用 irqbalance 让网卡中断自动分散如果系统里没装就手工把各网卡队列绑定到不同核心。第二步是把 JuiceFS 客户端进程和训练 dataloader 进程用 taskset 限定到同一个 NUMA 节点上。# 查看网卡队列对应的中断号 cat /proc/interrupts | grep ens1f0 # 将队列 0 的中断亲和性绑定到 CPU 4-7 echo 000000f0 /proc/irq/45/smp_affinity这一步做完p50 读时延下降了大约 10%p99 的改善更明显。其实原理不难理解数据包从网卡 DMA 到本地内存后如果 worker 在另一个 NUMA 节点每次访问内存都要走跨节点总线。把两者放到同一 NUMA 域减少远程内存访问对 ARM 这种内存带宽并不宽裕的架构收益显著。5. 一个诡异周期抖动的完整排查链路5.1 现象与初步判断调完缓存和并发参数后整体性能已经比第一轮好了很多但我们注意到一个规律性的抖动每个 epoch 的前半段很流畅后半段会掉速。起初以为是数据集遍历顺序的问题但把样本顺序随机打乱后抖动依然存在而且周期长度跟 epoch 保持一致。最让人迷惑的是监控面板显示缓存命中率一直很高后端对象存储的流量却没有完全消失。如果是缓存全面失效那 GPU 利用率应该从头到尾都差而不是只在后半段恶化。我当时的直觉是可能某个内部进程在周期性地跟训练任务抢资源。5.2 三层日志逐层排查排查过程是从上往下逐层看的没有一开始就怀疑到 JuiceFS 本身。第一层是训练框架。dataloader 日志里确实记录了等待数据的事件但出现时间分布在每个 epoch 的后半段没有具体规律。我们用 strace 抓了其中一个 worker 的系统调用发现大量进程卡在 read 操作上说明问题还是锁在文件系统层。第二层是 JuiceFS 客户端。打开客户端日志后我们注意到那些 read 操作触发了异常多的数据块回源。明明数据块在几秒前刚被读过但缓存里找不到。这说明缓存块被提前逐出了而不是没命中。第三层是对象存储和系统 IO。查看对象存储访问日志后发现一个规律周期性抖动前总会出现一批 1MiB 左右的 range 请求数量和频率远高于正常读。这些请求打乱了缓存块对齐也浪费了带宽。5.3 根因是读放大 后台任务抢核结合三层日志根因逐渐清晰。第一JuiceFS 的数据块是 4MiB但训练框架的读请求往往只有 512KB 或者 1MiB客户端需要把一个块拆成多个子请求返回。当 dataloader 的随机读足够分散时预取机制会反复拉取同一个大块的不同片段但由于 worker 读进度参差不齐每个子请求都会触发新的 cache miss形成读放大。第二JuiceFS 客户端后台会定期做垃圾回收和 compaction把缓存目录里的临时块整理合并。这个任务本身不重但它会在训练进程中抢 CPU 时间片。ARM 单核性能弱一旦后台任务占用某个核心恰好该核心上还排着 FUSE 的请求队列整体时延就会被打高。这两者叠加就形成了每隔一段时间后台任务启动FUSE 线程被抢占同时读放大导致缓存块分裂p99 时延暴涨GPU 利用率跳水的周期链条。5.4 修复与效果修复分三步。第一步是调整预取参数把--prefetch从 4 降到 2减少无效的 range 请求读放大比原先降低了差不多三成。第二步是限制 JuiceFS 客户端后台任务的执行窗口利用挂载参数或系统级 cgroup 控制把 compaction 的 CPU 占用压在单个核心以内。第三步是给训练进程设置 CPU 亲和性同时通过 nice 值让 JuiceFS 后台线程让出 CPU。这三步做完后周期抖动基本消失。epoch 后半段的吞吐不再下坠GPU 利用率从锯齿形变成了一条相对平稳的线。这个过程最大的收获是不要被缓存命中率高骗到读放大和后台任务抢占同样能让存储性能看起来很差。6. 调优后的结果与多节点实战复盘6.1 调优前后指标对比整个调优过程我们记录了三轮测试数据分别是第一轮基线、缓存和并发调优后、解决周期抖动后。这里展示最后一组对比指标第一轮基线缓存并发调优后解决抖动后GPU 平均利用率72%86%93%epoch 平均耗时21 分钟17 分钟14 分钟读时延 p99920ms180ms42ms后端对象存储读带宽3.2GiB/s2.1GiB/s1.4GiB/s缓存命中率86%94%97%注意后端带宽下降是好事说明更多读流量被本地缓存吸收网络压力得到缓解。训练总时间从 16 小时左右压到 11 小时算下来节约了大概 30% 的 GPU 机时。6.2 多节点并发的二次验证单节点调好了不代表集群没问题。我们随后把训练扩展到 16 个计算节点每个节点 8 个 worker总共 128 个并发流同时读 JuiceFS。多节点环境下暴露了一个新问题元数据服务成为热点。虽然前面已经把元数据缓存调大但 128 个 dataloader 进程同时做目录遍历时仍然会产生大量 lookup 请求。解决方案是在挂载参数中进一步拉长--entry-cache同时把数据集的目录树在训练前提前预热一遍。预热的方式很简单在正式训练前用一个脚本递归列出所有样本文件的完整路径并读取每个文件的第一个字节。这个方法能显著降低训练开始时的元数据风暴。预热耗时大概 3 分钟却能换来接近 10% 的整体训练时间节省非常划算。6.3 长期监控清单调优不是一次性的参数会被系统更新覆盖硬件状态会劣化。我们最终把以下监控项纳入日常巡检JuiceFS 客户端 stats 中的缓存命中率、读时延 p99、后台任务耗时。训练框架记录每个 epoch 的 dataloader 等待时间。节点层面记录软中断占比、跨 NUMA 内存访问比例。对象存储的回源带宽和请求数量。有了这些监控下一次性能抖动可以更快定位到是缓存失效、网络劣化还是后台任务冲突。7. 避坑清单可直接抄7.1 别把 x86 调优参数直接搬过来x86 环境里 io-threads 调到 128 很常见ARM 上照搬就会遇到线程调度开销大于收益的问题。单核主频和内存带宽的差异决定了 ARM 更适合更少的线程数 更精准的缓存命中策略。碰到类似问题建议先把参数整体减半再逐步往上试探比直接套用旧配置更快收敛。7.2 小心 MLPerf 标准配置里的存储相关默认值MLPerf 官方镜像和配置里dataloader 的 worker 数量、预处理线程数、缓存目录位置往往是为标准 x86 环境预设的。直接跑不一定是最优甚至可能埋坑。例如 worker 数量超过 ARM 节点的核数后线程切换会吞噬大量 CPU数据加载不升反降。建议训练前先确认 worker 数跟物理核数匹配而不是跟逻辑核数匹配。7.3 先加监控再动手调参我们最开始的错误之一是一上来就改参数结果改完不知道变好还是变坏。后来梳理出的正常工作流是先把监控全套铺好跑一轮 baseline记录所有指标再每次只改一个参数留足现场数据。这样不仅定位快复盘也有据可依。7.4 缓存不是越大越好这个坑特别隐蔽。我在 ARM 节点上把 JuiceFS 本地缓存从 20GB 加到 200GB以为缓存命中率会提升结果训练性能反而下降了。原因是在大缓存下客户端需要在启动时扫描并加载缓存索引这个过程消耗了大量内存带宽和 CPU。后来把缓存目录放在 NVMe 上并限制索引扫描范围性能才恢复正常。缓存的正确姿势是适量 近距离而不是越大越稳。写在最后这次 ARM 架构加 JuiceFS 的调优经历印象最深的不是某个参数起了奇效而是每一步都要靠数据验证直觉。MLPerf 这种基准测试尤其考验耐心因为你看到的 GPU 利用率波动背后可能叠加了 dataloader 并发、元数据缓存、读放大、后台任务抢占和 NUMA 亲和性好几层因素。没有监控数据的时候所有猜测都像是玄学把指标一层层剥开之后解决办法往往就藏在最不起眼的参数组合里。如果只能带走一条经验我会说在 ARM 上优化分布式文件系统先调整的不是性能参数而是观察指标的口径和采集粒度。把 p99 时延和缓存命中率放在训练日志旁边让每一次参数变更都被完整记录剩下的优化工作会变得顺理成章。