書き込みそのものは、33ミリ秒で終わっていました。
400個のファイルを生成する処理の所要時間を測ったときの数字です。体感としてはもっと待たされている気がしていたので、この値を見て、待ち時間はファイルを書く側ではなく、それを見ている側で発生しているのではないかと考えました。
エージェントに一括生成をさせる作業は、いまや珍しくありません。記事の HTML を出力する、アイコンを全サイズ書き出す、codemod で数百ファイルに手を入れる。どれも「書き終わったあと、しばらく操作が重い」という共通の体験を伴います。
その重さの出どころを、書き出し方を変えながら測りました。結果は、私が事前に立てていた予想と2か所で食い違いました。
遅いのは書き込みではなく、その後ろで起きていること
ファイルを書くと、そのディレクトリを監視しているプロセスに通知が飛びます。エディタ、開発サーバー、テストランナー、エージェントのワークスペース索引。ひとつのディレクトリに対して、いくつもの監視が同時に張られているのが普通の状態です。
通知を受け取った側は、たいてい短い時間だけ待ってから処理を始めます。連続して届く通知をひとまとめにして、1回の再スキャンで済ませるためです。この待ち時間をデバウンス窓と呼びます。100ミリ秒前後に設定されていることが多い値です。
つまり、こちらが何個ファイルを書いたかより、通知がデバウンス窓の中に収まったかどうか が、相手側の仕事量を決めます。
この観点で測るには、書き込み時間だけでなく「100ミリ秒の窓で数えたとき、再スキャンが何回起きる形になったか」を数える必要があります。
計測に使ったスクリプト
Node.js の fs.watch で監視を張り、書き込み方を変えながらイベントのタイムスタンプを記録します。デバウンス窓は100ミリ秒として、間隔がそれを超えたところで再スキャン1回と数えます。
// bench.mjs — 書き出し方によるファイル監視イベントの違いを測る
// 使い方: node bench.mjs <inplace|stage> <ファイル数> <書き込み間隔ms> <recursive:true|false>
// 例) node bench.mjs inplace 400 0 true
import fs from 'node:fs' ;
import path from 'node:path' ;
import os from 'node:os' ;
const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
const BODY = 'x' . repeat ( 2048 ); // 1ファイル2KB。中身は測定対象ではないので固定
const DEBOUNCE_NS = 100 n * 1000000 n ; // 100ms をナノ秒で持つ(hrtime.bigint と単位を合わせる)
async function run ( mode , count , gapMs , recursive ) {
// 監視対象を毎回作り直す。前回の残骸が混ざると数が合わなくなる
const root = fs. mkdtempSync (path. join (os. tmpdir (), 'watchbench-' ));
const target = path. join (root, 'out' );
fs. mkdirSync (target);
let events = 0 ;
const stamps = [];
const watcher = fs. watch (target, { recursive }, () => {
events ++ ;
stamps. push (process.hrtime. bigint ());
});
const t0 = process.hrtime. bigint ();
if (mode === 'inplace' ) {
// 監視対象のディレクトリへ直接書く
for ( let i = 0 ; i < count; i ++ ) {
fs. writeFileSync (path. join (target, `f${ i }.txt` ), BODY );
if (gapMs) await sleep (gapMs);
}
} else {
// 監視の外で作り、最後にまとめて移動する
const stage = path. join (root, 'stage' );
fs. mkdirSync (stage);
for ( let i = 0 ; i < count; i ++ ) {
fs. writeFileSync (path. join (stage, `f${ i }.txt` ), BODY );
}
fs. renameSync (stage, path. join (target, 'batch' ));
}
const t1 = process.hrtime. bigint ();
// 監視イベントは非同期に遅れて届く。閉じるのが早すぎると取りこぼす
await sleep ( 1200 );
watcher. close ();
// デバウンス窓を超えた回数 = 相手側が再スキャンを開始する回数
let rescans = 0 ;
let last = null ;
for ( const s of stamps) {
if (last === null || s - last > DEBOUNCE_NS ) rescans ++ ;
last = s;
}
console. log (
JSON . stringify ({
mode,
count,
gapMs,
recursive,
events,
rescans_100ms: rescans,
write_ms: + ( Number (t1 - t0) / 1e6 ). toFixed ( 1 ),
})
);
fs. rmSync (root, { recursive: true , force: true });
}
const [ mode , count , gapMs , recursive ] = process.argv. slice ( 2 );
await run (mode, Number (count), Number (gapMs), recursive === 'true' );
await sleep(1200) を入れている理由だけ補足します。監視イベントは書き込みの完了より遅れて届きます。書き終わった直後に watcher.close() を呼ぶと、届く途中のイベントを落として、実際より少ない数を計測してしまいます。最初にこれで数が合わず、しばらく悩みました。
4通りの書き出し方を測った結果
Node.js v22 / Linux で、1ファイル2KB を対象に測りました。
書き出し方 監視 ファイル数 イベント数 再スキャン回数 (100ms窓) 書き込み時間
一気に書く 再帰 400 400 1 33.7 ms
5ms 間隔で書く 再帰 400 400 1 2,247.3 ms
120ms 間隔で書く 再帰 40 40 40 4,837.1 ms
別の場所で作って移動 再帰 400 401 1 20.6 ms
別の場所で作って移動 非再帰 400 1 1 14.7 ms
一気に書く 非再帰 400 800 1 14.8 ms
この表の中で、私の予想と違ったのは3行目と、下の2行です。
間隔をあけて書くほど、相手の仕事は増えます
3行目を見てください。40ファイルを120ミリ秒間隔で書いた場合、再スキャンは40回 です。400ファイルを一気に書いた場合は1回でした。ファイル数が10分の1なのに、再スキャンは40倍になっています。
理由は単純で、120ミリ秒はデバウンス窓の100ミリ秒より長いからです。1個書くたびに窓が閉じ、そのたびに相手は「変更が落ち着いた」と判断して走査を始めます。書き終わる頃には、40回分の走査が積み上がっています。
一方、5ミリ秒間隔(2行目)は窓の中に収まるので、再スキャンは1回のままです。書き込みに2.2秒かけているのに、相手側の仕事は一気に書いた場合と変わりません。この2.2秒は、誰の役にも立っていない待ち時間です。
ここが、私が事前に持っていた感覚と逆でした。負荷をかけないように少しずつ書く、という配慮は、窓を跨ぐ間隔でやると逆効果になります 。中途半端に間隔をあけるくらいなら、一気に書き切ってしまうほうが、監視側にとっては軽い。
判断の目安としては、こう整理できます。
書き込み間隔 監視側への影響 取るべき対応
デバウンス窓より十分短い 1回の再スキャンに畳まれる そのまま一気に書いてよい
デバウンス窓と同程度 畳まれるかどうかが運次第 意図的に短くするか、監視の外へ出す
デバウンス窓より長い 書いた回数だけ再スキャンが起きる 最も避けたい形。まとめて書く
エージェントに大量生成を任せる場合、書き込み間隔はモデルの応答速度やツール呼び出しの往復に引きずられます。1ファイルずつ生成して1ファイルずつ書き出す実装は、この表の一番下の行に入りやすい形です。
監視の外で作って最後に移す、が効かない場合
4行目です。一時ディレクトリで400ファイルを作り、最後に rename でまとめて監視対象へ移しました。移動そのものは1回の操作なので、イベントも1回で済むと予想していました。
結果は401イベント です。ほぼファイル数と同じだけ発生しています。
Linux の再帰監視は、ディレクトリごとに個別の監視を張って実現されています。新しいディレクトリが監視対象の中に現れると、その中身を走査して監視を追加する処理が走り、そこで見つかった各ファイルについて通知が上がります。まとめて動かしても、再帰監視から見れば「中身が一斉に増えた」ことに変わりはありません。
同じ操作を非再帰の監視に対して行ったのが5行目です。こちらはイベント1件 でした。監視しているのは対象ディレクトリの直下だけなので、増えたのは batch というディレクトリ1個だという認識で終わります。
つまり「一時ディレクトリに作って最後に移す」という定番の手は、相手がどう監視を張っているかを知らないと、効いているかどうか判断できません 。効かせたいなら、移動先そのものが再帰監視の対象外である必要があります。
最終行にも触れておきます。非再帰の監視に対して直接400ファイルを書いたとき、イベントは800件でした。再帰監視のときの倍です。ファイル作成が rename と change の2種類の通知に分かれて届いているためで、監視の張り方によって、同じ操作から上がってくるイベント数そのものが変わります。イベント数を数えて比較するときは、監視のモードを揃えないと意味のある比較になりません 。
再スキャン1回のコストを、自分のリポジトリで測る
再スキャンの回数が分かっても、1回あたりのコストが分からなければ判断できません。手元で実際に運用しているサイトのリポジトリで測りました。
// scan.mjs — 再帰走査1回にかかる時間を測る
import fs from 'node:fs' ;
const dir = process.argv[ 2 ];
const t0 = process.hrtime. bigint ();
const entries = fs. readdirSync (dir, { recursive: true , withFileTypes: true });
const t1 = process.hrtime. bigint ();
console. log (
JSON . stringify ({
dir,
entries: entries. length ,
scan_ms: + ( Number (t1 - t0) / 1e6 ). toFixed ( 1 ),
})
);
このサイトの記事ソースを置いている content ディレクトリを対象にした結果です。
対象 エントリ数 1回の走査時間
content 配下(記事ソース) 2,182 19.2 ms
public 配下 25 0.6 ms
content 側で19.2ミリ秒です。単発なら気にならない値ですが、先ほどの40回の再スキャンと掛け合わせると768ミリ秒になります。しかもこれは走査そのものの時間だけで、その後に続く索引の更新やビルドのトリガーは含んでいません。実際に監視しているツールが複数あれば、その本数だけ並行して積み上がります。
依存パッケージのディレクトリまで監視対象に入っている環境では、エントリ数の桁が変わります。私はそこまでは測っていませんが、走査時間がエントリ数に比例して伸びる以上、除外設定の有無がそのまま効いてくる部分です。除外設定が意図どおり効いているかを確かめる手順は、.antigravityignore のパターンが効いていないときの確認手順 にまとめてあります。
では、どう書き出させるか
測った結果から、私は次の順で判断するようにしました。
出力先を監視の外に置けるか を最初に検討します。置けるなら、これが最も確実です。生成が終わってから1回だけ監視対象へ入れれば、再スキャンは1回で済みます。ただし前述のとおり、移動先が再帰監視の下だと効果は消えます。監視のモードまで確認して初めて「効いた」と言えます。
置けないなら、一気に書き切ります 。間隔をあける配慮は入れません。デバウンス窓を跨ぐ間隔は、はっきり有害です。
1ファイルずつ生成して1ファイルずつ書く実装なら、書き出しだけを最後にまとめます 。生成はエージェントに任せ、結果をメモリか監視外のディレクトリに溜め、最後に一括で書く。生成のペースは制御しにくくても、書き出しのペースは制御できます。
それでも重いなら、監視の本数を疑います 。個人開発の環境では、エディタ・開発サーバー・型チェッカ・テストランナーが1台のマシンに同居しているのが普通です。同じディレクトリを見ている数だけ再スキャンが重なるので、生成作業の間だけ開発サーバーを止めるという判断は、思っているより効きます。
エージェントに任せる作業を設計するときは、「エージェントが何をするか」だけでなく「その結果を誰が見ているか」まで含めて考える必要がある、というのが今回いちばん腑に落ちた点でした。エージェント側をいくら速くしても、監視側の再スキャンが40回積み上がる形になっていれば、体感は改善しません。
エージェントの検索やファイル走査が想定と違う範囲を見ている場合は、エージェントのコード検索が追跡ファイルを取りこぼす条件 も併せて確認すると、監視と検索で除外設定がずれている箇所が見つかることがあります。
次にやること
上のスクリプトを、自分がふだん作業しているワークスペースに対して1回だけ走らせてみてください。node bench.mjs inplace 40 120 true と node bench.mjs inplace 400 0 true の2つを比べれば、間隔をあける対処が自分の環境でどちらに転ぶかが、数分で分かります。
数字が出てから書き出し方を決めるほうが、体感を頼りに調整するより、はるかに早く落ち着きました。私自身このあたりはまだ手探りの部分が多いのですが、測れるものは測ってから決める、という姿勢だけは崩さずにいたいと思っています。お読みいただきありがとうございました。