メモリプレッシャーモニター
問題
長時間実行される Qwen Code のセッションでは、大きなツール結果、ファイルの繰り返し読み取り、チャット履歴、およびネイティブ/外部アロケーションによってメモリが蓄積する可能性があります。この変更以前は、コアパッケージに診断機能とセッションリセット時のクリーンアップ機能がありましたが、通常のツール実行中にメモリプレッシャーが高まった場合のランタイム時の対応はありませんでした。
キャッシュ固有の最も重要な課題は FileReadCache です。これはすでに上限付きの FIFO サイズを備えていますが、時間ベースの削除パスを持っていませんでした。つまり、プロセスがメモリプレッシャー下にある場合でも、セッションはハードエントリ制限に達するまで、非アクティブなファイル読み取りメタデータを保持し続けることができました。
目標
- ツール実行後に低オーバーヘッドのメモリプレッシャーチェックを追加する。
- 破壊的なクリーンアップの前に、精密なクリーンアップを優先する。
- cgroup v2 または cgroup v1 のメモリ制限ファイルが利用可能な場合、コンテナのメモリ制限を尊重する。
- 高メモリホスト上で JavaScript ヒープ OOM が発生する前に、V8 ヒーププレッシャーに反応する。
- サブエージェント/スコープ付き
Configインスタンスを親セッションのクリーンアップから隔離する。 - 新しいユーザー向け設定インターフェースを追加することなく、環境変数を通じて動作を設定可能にする。
非目標
- バックグラウンドのポーリングループは追加しない。
- 明示的な GC をデフォルトにしない。有効化されており、かつ Node が
--expose-gcで起動されている場合にのみ実行される。 - 事前読み取りの強制セマンティクスは変更しない。キャッシュの削除(eviction)は古いメタデータを除去できるが、保持されているエントリに対する古いファイルのチェックを弱めてはならない。
設計
Config.initialize() は、初期化された Config ごとに 1 つの MemoryPressureMonitor を作成します。getMemoryPressureMonitor() は、既存の getFileReadCache() の Object.create 分離パターンを反映しています。プロトタイプ委譲によって子 config が作成されると、ゲッターはその子 config にバインドされた独自のモニターを遅延インストールします。
CoreToolScheduler.executeSingleToolCall() は、ツールスパンの終了後、その finally ブロック内で scheduleCheck() を呼び出します。scheduleCheck() は、同じイベントループターン内の複数の呼び出しを queueMicrotask で統合(coalesces)するため、並行する読み取り系ツールのバッチによって、ツール結果ごとに 1 回のメモリチェックが実行されることはありません。
モニターは、以下の 2 つのプレッシャーシグナルのうち、より強い方を採用します。
- 有効なプロセスメモリ制限で割った RSS。有限の正の値である場合は cgroup v2 の
/sys/fs/cgroup/memory.maxを優先し、そうでない場合は cgroup v1 の/sys/fs/cgroup/memory/memory.limit_in_bytesにフォールバックし、さらにそれが利用できない場合はos.totalmem()を使用します。cgroup v1 の巨大な「無制限」を示すセンチネル値は無視されます。 getHeapStatistics().heap_size_limitで割った V8 のheapUsed。
コンテナは通常 RSS/cgroup 制限によって失敗しますが、ローカルの高メモリマシンでは RSS がシステム総メモリの大きな割合を占めるずっと前に V8 ヒープ OOM に達する可能性があるため、両方のシグナルを使用することが重要です。
デフォルトのしきい値は、OS またはコンテナの OOM キラーが動作する前に対応できるように、意図的に保守的な値に設定されています。
softPressureRatio = 0.50hardPressureRatio = 0.65criticalRatio = 0.80cleanupCooldownMs = 5000enableExplicitGC = false
環境変数によるオーバーライド:
QWEN_MEMORY_PRESSURE_SOFTQWEN_MEMORY_PRESSURE_HARDQWEN_MEMORY_PRESSURE_CRITICALQWEN_MEMORY_ENABLE_GC=1
無効な比率はデフォルト値にフォールバックします。有効な比率は soft < hard < critical の順序である必要があり、soft の下限は 0.3、critical の上限は 0.98 です。比率の環境変数は Number() を使用して厳密にパースされるため、0.8extra のような値は部分的に受け入れられるのではなく拒否されます。無効なメモリプレッシャー環境設定は、デフォルトにフォールバックする前に、stderr とデバッグログに目立つ警告を出力します。
クリーンアップポリシー
プレッシャーレベルは、より強力なクリーンアップにマッピングされます。
soft: 60 分間アクセスされていない古いFileReadCacheエントリを削除(evict)する。hard: 30 分間アクセスされていないキャッシュエントリを削除する。critical: ファイル読み取りキャッシュをクリアし、オプションでglobal.gc()をトリガーする。
モニターは意図的にチャットの圧縮(compaction)を強制しません。圧縮はモデルバックエンドを呼び出し、アクティブなチャット状態を書き換える可能性があるため、会話ループと安全に連携できる呼び出し元からのみトリガーされるべきです。
クリーンアップはスケジューラーから fire-and-forget(投げっぱなし)で実行されますが、モニターは cleanupInProgress とクールダウンタイムスタンプでクリーンアップステップを保護します。より高いプレッシャーのクリーンアップはクールダウンをバイパスし、進行中の低いプレッシャーのクリーンアップの背後にキューイングされるため、soft クリーンアップが完了している間に critical チェックが失われることはありません。クリーンアップが成功した後、setImmediate() で RSS の差分をログに記録しますが、RSS の変動は診断目的のみです。JavaScript オブジェクトが回収可能になった場合でも、V8 や libc が解放されたページを保持する場合があります。連続する失敗は、RSS の変化ではなくクリーンアップステップの例外をカウントし、カウンターは新しいセッションでリセットされます。3 回連続でクリーンアップに成功しても RSS が 1% 未満しか解放されない場合、モニターはクリーンアップステップ自体を失敗とみなさずに、診断シグナルとして memory-cleanup-ineffective を出力します。
テストカバレッジ
実装は以下のテストでカバーされています。
- しきい値のバリデーションテスト。
- 環境設定のパース、フォールバック、目立つ警告、および明示的 GC のテスト。
- モック化された
process.memoryUsage()を使用したプレッシャー分類テスト。 - cgroup v2 の
memory.maxおよび cgroup v1 のmemory.limit_in_bytesの動作。 - V8 ヒープ制限の動作。
scheduleCheck()の統合(coalescing)。- ツール実行後に
scheduleCheck()を呼び出すスケジューラー統合。 - soft および critical クリーンアップアクション。
- スローされたクリーンアップステップに対するクリーンアップ失敗のカウント。
- クリーンアップリスナーの例外分離と、効果のないクリーンアップの診断。
Object.createを介した子Configモニターの分離。FileReadCache.evictNotAccessedSince()の動作。
リスクとトレードオフ
- V8 や libc が解放されたメモリを保持する可能性があるため、クリーンアップ後に RSS が横ばいになることがあります。RSS の差分はログに記録されますが、RSS が変化しないことはクリーンアップの失敗とはみなされません。
- 時間ベースのファイル読み取りキャッシュの削除は、古いファイルのファストパスヒットを減少させる可能性がありますが、最近アクティブだったエントリは保持され、メモリプレッシャー下でのみ実行されます。