はじめに
Git Worktreeで新しい作業ディレクトリを作成したり、プロジェクトを複製したりする際、node_modulesをどうするかは悩ましい問題です。npm installをやり直すのが一般的ですが、依存関係が多いプロジェクトでは数分かかることも珍しくありません。
既存のnode_modulesをコピーする方法もありますが、数万ファイル・数GBにもなるnode_modulesの通常コピーもまた時間がかかります。
この問題を解決するために、APFSのclonefile()システムコールを使ったところ、rsyncと比較して約10倍の高速化を達成できました。
本記事では、Copy-on-Write(CoW)の仕組みと、Node.jsからclonefile()を呼び出す実装方法を紹介します。
Copy-on-Write(CoW)とは
通常のコピー vs CoWコピー
通常のファイルコピーでは、ファイルの内容をすべて読み取り、新しい場所に書き込みます。つまり、1GBのファイルをコピーすれば、1GBの読み取りと1GBの書き込みが発生します。
一方、CoWコピーではメタデータだけを複製し、実際のデータブロックは元ファイルと共有します。そのため、コピー操作はほぼ瞬時に完了します。
実際にデータが複製されるのは、コピー先のファイルに変更を加えたときだけです。変更された部分だけが新しいブロックに書き込まれ、それ以外は引き続き共有されます。
APFSのclonefile()
macOS 10.13 (High Sierra)以降のAPFS(Apple File System)では、clonefile()システムコールを使ってCoWコピーを実行できます。FinderでCmd+Dで複製する際も、内部的にはこの仕組みが使われています。
clonefile()の特徴:
- ファイルだけでなくディレクトリ全体をクローン可能
- クローン直後はディスク容量をほとんど消費しない
- 元ファイルとクローン先は完全に独立(編集しても互いに影響しない)
実装方法
koffiでシステムコールを呼び出す
Node.jsからclonefile()を呼び出すには、FFI(Foreign Function Interface)ライブラリを使います。今回はkoffiを使用しました。
const koffi = require('koffi');
// macOSのlibcからclonefile関数を読み込む
const libc = koffi.load('/usr/lib/libc.dylib');
const clonefileFunc = libc.func('int clonefile(const char *src, const char *dst, int flags)');
clonefile()の引数:
-
src: コピー元のパス -
dst: コピー先のパス -
flags: オプションフラグ(通常は0でOK)
実際のクローン処理
ディレクトリをクローンする実装例です。
function callClonefile(src: string, dst: string): void {
const result = clonefileFunc(src, dst, 0);
if (result !== 0) {
throw new Error(`clonefile failed: ${src} -> ${dst}`);
}
}
// node_modulesをクローン
callClonefile('/path/to/src/node_modules', '/path/to/dst/node_modules');
clonefile()はディレクトリを指定すると、その中身ごとクローンします。つまり、node_modules配下の数万ファイルを一度の呼び出しでクローンできます。
ベンチマーク結果
計測環境
| 項目 | 値 |
|---|---|
| マシン | MacBook Pro (M3) |
| ファイルシステム | APFS |
| node_modulesサイズ | 約4GB |
| ファイル数 | 約32万ファイル |
結果
| 方法 | 時間 | 倍率 |
|---|---|---|
| cp -r | 371秒 | 24.7倍 |
| cp -cR | 364秒 | 24.3倍 |
| rsync --link-dest | 147秒 | 9.8倍 |
| clonefile (koffi) | 15秒 | 1倍 |
なぜ速いのか
clonefile()が高速な理由は、ディスクI/Oをほぼ発生させないからです。
通常のコピーでは大量のファイルそれぞれに対して読み取りと書き込みが発生しますが、clonefile()ではファイルシステムのメタデータ操作のみで完了します。ファイル数が多いほど、この差は顕著になります。
cp -cR が速くない理由
興味深いことに、cp -cR(-cオプションでclonefileを使用)は364秒と、通常のcp -rとほぼ同じでした。
Appleのオープンソースを確認したところ、cpコマンドはfts_read()でディレクトリをトラバースし、各ファイルに対してcopy_file()を呼び出します。そして-cオプションが指定されている場合、ファイル単位でclonefileat()を呼び出す実装になっています。
つまり、32万ファイルのnode_modulesに対しては32万回のシステムコールが発生するため、そのオーバーヘッドが大きいのだと思われます。
一方、koffiで直接clonefile()をディレクトリに対して呼び出すと、1回のシステムコールでカーネル内部で再帰的にクローンが実行されるため、高速なのだと考えられます。
ユースケース
Git Worktreeでの活用
Git Worktreeで新しい作業ディレクトリを作成する際、node_modulesをclonefile()でクローンすることで、npm installの時間を大幅に削減できます。
私が開発しているvibeというCLIツールでは、v0.4.0からこの最適化を取り入れています。vibeは環境に応じて最も高速なコピー方法を自動的に選択します。
注意点
- CoWはAPFSやBtrfs(Linux)など、対応したファイルシステムでのみ有効です
- 同一ボリューム内でのみ有効です(外付けドライブや別パーティションへのコピーは通常のコピーになります)
- Windows(NTFS)やLinuxのext4ではCoWは使えません
おわりに
APFSのclonefile()を使うことで、node_modulesのコピーを約10倍高速化できました。これなら、git worktree addしてから爆速で新しい作業に入れます。モノレポやGit Worktreeを使っている方は、ぜひ試してみてください。
参考