heap-snapshot-analysis について
V8 の `.heapsnapshot` を調べるメモリリーク分析スキルです。スナップショットの比較やリテーナーパスの追跡に加え、非常に大きなスナップショット向けの手順も扱います。
主な機能
- 01前後のヒープスナップショット比較
- 02オブジェクト数とサイズの変化測定
- 03オブジェクトのリテーナーパス追跡
- 04巨大なスナップショットのストリーム解析
- 05弱参照と一般的な保持パターンの確認
活用シーン
- 01オブジェクトが回収されない理由の調査
- 02ワークフロー前後のメモリ変化の比較
- 03大規模スナップショット内の疑わしいオブジェクトの調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ microsoft/ vscode --skill heap-snapshot-analysisインストール後、Claude Code で/heap-snapshot-analysisを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
helpers/parseSnapshot.tsfindRetainers.tsstreamSnapshot.mjscompareSnapshots.tsscratchpad/README.md.gitignore詳細
ヒープスナップショットの分析
V8 ヒープスナップショット(.heapsnapshot ファイル)からメモリリークを調査します。このスキルは、スナップショットがすでにある場合に開始します。ユーザーから提供された場合、DevTools でエクスポートされた場合、または別のワークフローで生成された場合が該当します。ここにあるヘルパーを使ってスナップショットを比較し、オブジェクトの差分をグループ化して、リテーナーパスをたどります。
過去の調査結果は無視する
調査は毎回、新たに開始してください。 次の場所にある過去の調査結果を読んだり、参照したり、影響を受けたりしないでください。
/memories/(ユーザー、セッション、またはリポジトリのメモリ).github/skills/heap-snapshot-analysis/scratchpad/(以前の日付のサブフォルダーとそのfindings.mdファイル)- 過去のセッションで作成されたその他のメモ
過去の調査結果は、現在は関係のない対象を疑うよう分析を偏らせたり、手順を飛ばして結論を急がせたりする可能性があります。現在のスナップショットを分析の基準にしてください。ユーザーから明示的に求められた場合に限り、過去の作業を参照してください。
使用する状況
- ユーザーが
.heapsnapshotファイルを提供した場合(ワークフローの前後) - 別のスキルまたはスクリプトでヒープスナップショットが取得されている場合
- 破棄されたオブジェクトを保持しているものを特定する必要がある場合(リテーナーパス分析)
- 2 つのスナップショット間でオブジェクト数やサイズを比較する場合
- 特定のオブジェクトが GC 後も残る理由を調査する場合
ワークフロー
エージェントに VS Code を起動させ、シナリオを実行し、まずスナップショットを取得する必要がある場合は、ここに戻って低レベルのスナップショット分析を行う前に、VS Code のパフォーマンスワークフロースキルを使用してください。
1. スナップショットを解析する
parseSnapshot.ts のヘルパーを使ってスナップショットを読み込みます。ファイルは 500MB を超えることが多く、文字列として JSON.parse するには大きすぎます。ヘルパーは Buffer ベースの抽出を使用します。スクラッチパッドのスクリプトでは、../helpers/*.ts からヘルパーをインポートしてください。
非常に大きなスナップショットの場合、ヘルパーでも読み込みすぎることがあります。Node ではおよそ 2 GiB を超える Buffer を作成できないため、そのサイズを超えるスナップショットは、解析前であっても ERR_FS_FILE_TOO_LARGE で失敗する場合があります。その場合、--max-old-space-size を増やして同じファイル全体の読み込みを再試行しないでください。ストリーミングスクリプトに切り替えてください。
import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';
const data = parseSnapshot('/path/to/snapshot.heapsnapshot');
const graph = buildGraph(data);
2 GiB を超えるスナップショット
スナップショットが大きすぎて単一の Buffer に読み込めない場合は、質問に必要なセクションだけをスキャンして解析するスクラッチパッドスクリプトを作成してください。共通のストリーミング用プリミティブには streamSnapshot.mjs を使用し、スクラッチスクリプト間でコピーしないでください。
便利なテクニック:
- まず、トップレベルのセクションのオフセットを見つけます。ファイルをバイト単位でスキャンし、
"nodes":、"edges":、"strings":、"trace_function_infos":などのマーカーを探します。これにより、後続のスクリプトはファイル全体を繰り返し検索せず、大きな配列に直接移動できます。 - ファイル先頭の小さなヘッダーから
snapshot.metaを別途解析します。meta.node_fields、meta.node_types、meta.edge_fields、meta.edge_typesを使うと、タプル幅をハードコードせずに済みます。 - 数値配列をチャンク単位でストリーミングします。
nodesとedgesでは、チャンク間で小さな文字列を引き継ぎ、カンマで分割しながら、完全な数値トークンを順次処理します。 - 調査で本当に必要な場合を除き、
stringsテーブル全体をメモリ上に展開しないでください。不審な名前だけが必要な場合は、まず該当するノード/エッジから文字列インデックスを収集し、2 回目のストリーミング処理でそのインデックスだけを解決します。 - 多数の文字列が必要な場合は、短いプレビューとカテゴリ別のカウンターだけを保存します。ソース文字列全体、参照リストの文字列、プロンプトのペイロードはメモリを大量に消費し、解析ツール自体がリークの原因になりかねません。
- 中間出力はスクラッチパッド内のファイルに書き込みます。大規模なヒープ解析は反復的で時間がかかります。ノード ID、オフセット、リテイナーのトレースをキャッシュすると、何度も数分かかる処理を繰り返さずに済みます。
- 巨大なグラフでは、セルフサイズによるアトリビューションとフィールドレベルの所有関係を優先します。保持サイズ全体の走査では、共有サービス、ルート、マップ、シングルトンキャッシュが大幅に過大計上されることがあります。
- 所有者と思われる対象を定量化する際は、所有していることが明らかなフィールドを個別に数えます。ラッパーオブジェクト、キー配列、配列要素、直接の文字列、スライス/連結された文字列の親文字列などです。これにより、直接の文字列だけをまとめて数えるよりも、下限をより適切に推定できることがよくあります。
- 近似の範囲を明確にしてください。フィールドレベルの小計は、通常リスナー、ウォッチャー、逆参照を過小計上しますが、ランタイム全体を単一のオブジェクトに帰属させる、さらに深刻な問題を避けられます。
大規模なスナップショットを扱うワークフローの例:
import { findArrayStart, findTokenOffsets, parseMeta, streamNumberTuples } from '../../helpers/streamSnapshot.mjs';
const { size, offsets } = findTokenOffsets(snapshotPath);
const meta = parseMeta(snapshotPath);
const nodeFieldCount = meta.node_fields.length;
const nodesStart = findArrayStart(snapshotPath, offsets.get('"nodes"'));
streamNumberTuples(snapshotPath, nodesStart, offsets.get('"edges"'), nodeFieldCount, (node, nodeIndex) => {
// node is reused for speed; copy it before storing.
});
cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/findOffsets.mjs /path/to/Heap.heapsnapshot
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/streamAnalyze.mjs /path/to/Heap.heapsnapshot > scratchpad/YYYY-MM-DD-topic/streamAnalyze.out
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/traceNodes.mjs /path/to/Heap.heapsnapshot 12345 67890 > scratchpad/YYYY-MM-DD-topic/traceNodes.out
2. 前後を比較する
2 つのスナップショットを比較するには、compareSnapshots.ts を使用します。
import { compareSnapshots } from '../helpers/compareSnapshots.ts';
const result = compareSnapshots('/path/to/before.heapsnapshot', '/path/to/after.heapsnapshot');
// result.topBySize, result.topByCount, result.newObjectGroups, result.summary
3. リテイナーのパスを特定する
オブジェクトが存続している理由を追跡するには、findRetainers.ts を使用します。
import { findRetainerPaths } from '../helpers/findRetainers.ts';
// Find what keeps ChatModel instances alive (skipping weak edges)
findRetainerPaths(graph, 'ChatModel', { maxPaths: 5, maxDepth: 25, maxAttempts: 200 });
4. 調査スクリプトを作成する
調査に特化したスクリプトは、scratchpad ディレクトリに作成します。このフォルダーは gitignore の対象です。一時的な分析に自由に使用してください。
スクラッチパッドの作業は、YYYY-MM-DD-short-description/ という名前の日付付きサブフォルダー(例:2026-04-09-chat-model-retainers/)に整理します。各サブフォルダーには、次のものを含めてください。
- 分析スクリプト(
.mjs、.mtsなど) - 調査全体を記録した
findings.mdファイル。検討したすべてのアイデア、変更につながったものと却下したもの(およびその理由)、変更前後の測定値、結果の概要を記載します。これにより、ユーザーはエージェントの推論を確認し、採用する変更を決め、保留されたアイデアを引き続き検討できます。
ヘルパーをインポートできます。
cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=16384 scratchpad/2026-04-09-chat-model-retainers/analyze.mjs
主要な概念
V8 ヒープスナップショット形式
.heapsnapshot ファイルは JSON で、次の主要なセクションがあります。
snapshot.meta:ノードとエッジのフィールド定義nodes:フラット配列。N 個の値ごとに 1 つのノードを表します(N =meta.node_fields.length、通常は 6:type, name, id, self_size, edge_count, detachedness)edges:フラット配列。M 個の値ごとに 1 つのエッジを表します(M =meta.edge_fields.length、通常は 3:type, name_or_index, to_node)strings:ノード/エッジのnameフィールドからインデックスで参照される文字列テーブル
重要なエッジの種類
| 種類 | 意味 | GC を妨げるか? |
|---|---|---|
property | 名前付き JS プロパティ | はい |
element | 配列インデックス | はい |
context | クロージャ変数 | はい |
internal | V8 の内部参照 | はい |
hidden | V8 の隠し参照 | はい |
weak | WeakRef/WeakMap のキー | いいえ |
shortcut | 利便性のためのリンク | 場合による |
リテイナーパスをたどる際は、必ず weak エッジをスキップしてください。WeakMap のエントリーは、キー → バッキング配列へのエッジとして表示されますが、これらはオブジェクトの回収を妨げません。つまり、誤解を招く情報です。
VS Code でよく見られる保持パターン
-
RowCache のテンプレート:ListView の
RowCacheはテンプレート行を保持します。テンプレートのcurrentElementは、以前のビューモデル項目を指します。セッション切り替え時にクリアされないと、モデルチェーン全体を保持します。 -
リソースプール:
pool.clear()が破棄するのはアイドル状態の項目だけです。pool.clear()の後に_onDidUpdateViewModel.fire()が実行されると、解放された項目が空のプールに再び入り、破棄されなくなります。先にイベントを発火し、その後でクリアしてください。 -
autorunIterableDeltaの lastValues:クロージャは、前回の反復値を格納したMapをキャプチャします。値は autorun が再実行されるまで保持されます。非同期破棄の遅延により、モデルは想定より長く observable ストアに保持されます。 -
HoverService._delayedHovers:グローバルシングルトンの Map が、showクロージャ →resolveHoverOptionsクロージャ →this経由で破棄済みオブジェクトを保持します。ホバーのクリーンアップ用 disposable が発火しないと、オブジェクトツリー全体が保持されます。 -
ObjectMutationLog._previous:増分シリアライザーは、最後にシリアライズした状態の完全なスナップショットを保持します。ロード済みの ChatModel はそれぞれ、データを 2 倍(ライブデータ +_previous)保持します。 -
_previousModelRefパターン:MutableDisposableのセッターは古い値を破棄します。.valueを読み取って別の場所に保存した後、.value = undefinedを設定すると、保存した参照も破棄されます。破棄せずに取り出すにはclearAndLeak()を使用してください。
防御的な null 化
破棄済みオブジェクトが何かに保持されている場合でも、保持チェーンを断ち切れるように、dispose() で重いフィールドを null にします。
override dispose() {
super.dispose();
this._requests.length = 0; // conversation data
this.dataSerializer = undefined; // serialization snapshot
this._editingSession = undefined; // editing session + TextModels
this._session = undefined!; // back-reference cycles
}
注意:ビューモデル項目のフィールド(ChatResponseViewModel._model)は null にしないでください。親ビューモデルの破棄後、setChildren が項目を置き換える前に、ツリーの diffIdentityProvider がこれらのフィールドにアクセスします。
注意すべき偽のリテイナー
- DevTools デバッガーのグローバルハンドル:DevTools を開いた後にスナップショットを取得した場合、大きなソース文字列、コンパイル済みスクリプト、プレビューデータ、検査対象オブジェクト、デバッガーの管理データなどが、
DevTools debugger(internal)→synthetic::(Global handles)→ GC ルートのようなパスを通じて保持されることがあります。証拠が得られるまでは、デバッガーによるものとして扱ってください。DevTools を開く前のアプリには存在しない場合があり、アプリケーションが所有するリークと混同しないでください。 DevToolsLogger._aliveInstances(Map):VSCODE_DEV_DEBUG_OBSERVABLESenv var によって有効になります。監視対象の observable をすべて保持します。observable を起点とするパスを調査する前に、これが有効か確認してください。GCBasedDisposableTracker(FinalizationRegistry):register(target, held, target)を使用すると(target === unregister token)、強い自己参照が作られ、GC が妨げられます。現在は本番環境ではコメントアウトされています。- WeakMap のバッキング配列:リテイナーパスには表示されますが、オブジェクトの回収を妨げません。
分析の実行
すべてのヘルパースクリプトは ESM を使用しており、追加メモリを指定した Node が必要です。
node --max-old-space-size=16384 scratchpad/analyze.mjs
一般的な分析時間は、サイズに応じてスナップショットあたり 30~120 秒です。