账号额度快到期。我想趁还没停下来,一次把几条线并行往前推,所以在主仓库旁边开了五个 worktree:
.worktrees/landing-gbalance-worktree.worktrees/landing-gcloset-worktree.worktrees/landing-gfocus-worktree.worktrees/landing-gmemor-worktree.worktrees/landing-gplay-worktree
准备站起来蹬的时候,人被按在了装依赖这一步。五个目录都在一块 USB 外置盘上。我一直用 npm,每个工作区都要各自长出一份 node_modules。以前没认真看过 npm 和 pnpm 差在哪里,这次才把两件事记下来。
外置盘慢,慢在小文件
仓库在 /Volumes/hd-3。diskutil 看到的是:10TB,USB,外置,文件系统是区分大小写的 Journaled HFS+。桥接设备报的厂商是 ASMT,产品名是 2135。它没有报这块盘是不是固态。
系统盘是另一块:Apple SSD AP0256M,APFS,走 PCI-Express,diskutil 标了 Solid State。
我在两边各写了 1024MiB,只在结束时 fsync 一次。各跑了一轮,没有重复取中位数。
| 位置 | 用时 | 吞吐 |
|---|---|---|
内置盘 /tmp | 3.09 秒 | 332 MiB/s |
/Volumes/hd-3 | 4.06 秒 | 252 MiB/s |
顺序写一个大文件,外置盘大约慢两成。如果要搬的是一个镜像或一个压缩包,这个差距还没到“完全不能并行”的程度。
node_modules 不是这种负载。它是大量几 KiB 的文件,再套很多层目录。
单线程写 4000 个 4KiB 文件,每个文件都 fsync:
| 位置 | 创建 | 删除 |
|---|---|---|
/tmp | 7.77 秒,515 个/秒 | 3.60 秒,1110 个/秒 |
/Volumes/hd-3 | 9.41 秒,425 个/秒 | 1.29 秒,3099 个/秒 |
逐个 fsync 时,创建只慢了大约两成。外置盘这一轮删除反而更快。样本只有一轮,我不会据此说外置盘更适合删文件。
更接近“五个工作区同时铺目录”的是下一组:5 个进程同时写,每个进程 6000 个 4KiB 文件,放在 pN/node_modules/pkgM/ 这种嵌套目录里,不逐个 fsync。一共 30000 个文件。
| 位置 | 创建 | 删除 |
|---|---|---|
/tmp | 4.26 秒,7045 个/秒 | 7.73 秒,3881 个/秒 |
/Volumes/hd-3 | 12.48 秒,2403 个/秒 | 10.60 秒,2829 个/秒 |
创建用时大约是内置盘的 2.9 倍。3 万个几乎是空的文件就已经这样。主工作区里现成的依赖目录比这大一个数量级。
同一天,我把这五个 worktree 目录,再加上两份没有登记成 worktree 的残留依赖目录,从仓库里挪走再并行删掉。从开始删到目录消失,用了 74 分 30 秒。这不是一组干净的基准测试,目录里混着依赖和源码,也没有和内置盘做对照。它只说明一件事:这块盘上已经铺开的工作区,清掉本身就要一个多小时。
主工作区已经有 33.7 万个依赖文件
我数了主工作区里现有的 node_modules。这些目录都没有 .pnpm。抽查 landing-gunlabs/node_modules/@alloc/quick-lru/package.json,硬链接数是 1。它们是各自独立的一份文件,不是指向同一个 store 的链接。
| 目录 | 文件数 | 体积 |
|---|---|---|
landing-gbalance/node_modules | 29344 | 461.0 MiB |
landing-gfocus/node_modules | 30306 | 470.1 MiB |
landing-ggloss/node_modules | 26189 | 463.1 MiB |
landing-gmemor/node_modules | 30676 | 496.4 MiB |
landing-gplay/node_modules | 33359 | 509.9 MiB |
landing-gunlabs/node_modules | 35878 | 528.7 MiB |
admin-web/node_modules | 104011 | 741.6 MiB |
server/node_modules | 47308 | 266.6 MiB |
| 合计 | 337071 | 3937.4 MiB |
仓库根还有一个几乎空的 node_modules,里面 2 个文件,没有放进表里。
npm 的缓存挡不住这份重复的解压。npm 的目录说明写的是:安装时包先进入缓存,再解压到当前项目的 node_modules。这台机器的 npm 缓存是 ~/.npm,大约 1.7 GiB,在内置盘上。缓存能少下一次包,挡不住每个 worktree 在外置盘上再写一遍那几十万个文件。
用上面外置盘大约 2400 个文件/秒的创建速度粗算,33.7 万个文件光是创建就要两分二十秒左右。这还不是 npm install 的全部:还有下载、解压,以及比测试目录更深的层级。五个 worktree 各自装一遍,就是五份。它们要是同时写同一块盘,速度只会比这一路更差。我没有为了这篇文章真的跑五次 npm install。
pnpm 把同一份内容放进一个 store
pnpm 把包里的文件放进一个内容寻址的 store。同一份内容在这块盘上只存一份。项目安装时,用硬链接或 reflink 指到 store 里的文件,而不是再复制一份内容。
pnpm 自己用 lodash 举过这个差别:一百个项目都依赖它时,npm 会在磁盘上留下一百份;如果新版本只改了其中少数文件,store 里也只增加那些不同的文件。安装时再把文件链出来,不再为每个项目各写一遍内容。说明在 pnpm 的包页面 里。
硬链接有一个硬限制:store 和项目必须在同一个文件系统上。 跨盘做不了硬链接。pnpm 的 store 配置写明了,store 应该在正在安装的那块盘上,一块盘一个 store。如果把 store 指到另一块盘,pnpm 就改成复制。
在这个仓库里执行:
pnpm store path结果是 /Volumes/hd-3/.pnpm-store/v11。这块盘上已经有一个 store,大约 2.1 GiB。用户目录下还有一份 ~/Library/pnpm/store,大约 2.9 GiB,在内置盘上。项目在 hd-3 时,pnpm 用的是盘根上的那份,和仓库同卷,硬链接可以成立。
如果把 store-dir 指回内置盘,而 worktree 仍留在 hd-3,硬链接不会发生。下载可以省掉,几十万个小文件还是要写到外置盘上。那样就只解决了这次被卡住的一半。
硬链接省掉的是文件内容,不是目录项。每个工作区仍然要有自己的 node_modules 目录结构。五个 worktree 同时安装,创建这些目录项的时间还在,而且仍然落在这块更慢的盘上。它去掉的是“每个工作区把同一份包再写一遍”。
下次并行之前先看 store 在哪块盘
这次没有蹬起来,worktree 本身还能用。五个工作区分支可以同时改。卡住的是:工作区在 USB 外置盘上,而 npm 让每个工作区都拥有一份完整依赖。
下次再这么开,我先确认两件事。依赖走 pnpm 的 store,并且 pnpm store path 和项目在同一块盘上。如果有一件事值得挪回内置盘,那就是会反复安装、反复删除 node_modules 的并行工作区。一个 1GiB 的大文件,放在这块外置盘上并没有差多少。
顺序写 252 MiB/s 看不出这个问题。3 万个小文件,和主工作区里已经存在的 33.7 万个依赖文件,才看出来。