runtime/pprof: add a (rss) memory profile type
要約
概要
runtime/pprof に新しいプロファイル種別 "memory" を追加する提案です。これまでの heap プロファイルは生存ヒープしか表示しませんでした。新しいプロファイルでは、死んだヒープ、goroutine スタック、ランタイム内部のメモリ、RSS(プロセスが実際に物理メモリ上に持っているサイズ)から推定した非 Go メモリまでを1つの階層ビューで確認できます。プログラムのメモリ使用量全体の内訳がわかるようになります。
ステータス変更
active → likely_accept
2026年8月のレビューで、この提案は「実装を見たうえでの暫定承認(provisionally accepted pending implementation)」とされました。プロトタイプは AI 支援で書かれたコード(いわゆる vibe coding)だったため、人がきちんと書き直した実装で問題が出ないかを確認する必要があったためです。その後 @nsrip-dd さんが提案者 @felixge さんの作業を引き継ぎ、レビュー可能な品質の実装 CL 841489 を提出しました。「予期しない問題は見つからなかった」との報告を受け、@aclements さんが likely accept を宣言しました。
技術的背景
現状の問題点
メモリ調査では、まず OS が報告する RSS を見て、次に pprof.Lookup("heap") の inuse_space(生存ヒープ)を見るのが一般的です。しかしデフォルト設定(GOGC=100)では、生存ヒープはプログラムの使用メモリの半分未満にしかなりません。heap プロファイルに含まれないものは次のとおりです。
- 死んだヒープ:GC のマーク終了時点で到達不能と判定され、その後スイープ(回収)されるメモリ
- goroutine スタック、OS スレッドのスタック、ランタイムのメタデータ
- 非 Go メモリ:実行ファイル、共有ライブラリ、mmap 領域、cgo の malloc など
このため開発者は RSS、runtime/metrics、heap プロファイルの3つを自分で突き合わせる必要があり、その方法を解説する長い記事が必要になるほどでした。
提案された解決策
最後の GC マーク終了時点(死んだヒープの分は直後のスイープ終了まで待つ)のスナップショットを、次の階層で出力します。
- RSS(OS から取得できて、かつ Go Memory 以上の場合だけ付く)
- Go Memory =
/memory/classes/total:bytes - /memory/classes/heap/released:bytes- Heap
- Live:既存の生存ヒープと同じスタックトレース別の内訳
- Dead:マーク終了からスイープまでに解放された割り当てのスタックトレース別の内訳
- Stack:Goroutines / OS Threads
- Runtime:MSpan、MCache、GC Metadata、Profiling、Other
- Heap
- Non-Go Memory =
RSS - Go Memory
RSS が取得できない場合や、Go Memory が RSS を上回る場合は、RSS と Non-Go Memory のフレームを省きます。その理由は pprof の comment フィールドに[WARNING: ...]として記録します。ランタイムのメトリクスは STW(Stop The World)中に一貫した形で取得します。RSS の読み取りにはシステムコールが必要なので、STW 中には行わず、マーク終了の直後に読む「ベストエフォート」の方式をとります。
- Go Memory =
これによって何ができるようになるか
- 障害対応:コンテナが OOM に近づいたとき、原因がヒープ、スタック、cgo などの非 Go メモリのどれにあるかを、1つのプロファイルで切り分けられます。
- コスト最適化:Dead ヒープの内訳から、短命な割り当てを大量に生んでいる箇所がわかります。これを減らすと GC の頻度が下がり、
GOGCやGOMEMLIMITをより積極的に設定できるようになります。 - cgo やライブラリを多用するプログラム:Non-Go Memory の大きさから、Go の外側でメモリが消費されていることにすぐ気づけます。
- 継続的プロファイリング:Datadog などのツールが、RSS と内訳を整合した状態で収集できるようになります。
コード例
// Before: 3つの情報源を自分で集めて突き合わせる必要があった
pprof.Lookup("heap").WriteTo(f, 0) // 生存ヒープだけ
var ms runtime.MemStats
runtime.ReadMemStats(&ms) // スタックやランタイムのメモリは別に取得
rss := readProcStatm() // RSS は OS から自前で取得(タイミングがずれる)
// After: 1つのプロファイルで全体の内訳を取得できる
pprof.Lookup("memory").WriteTo(f, 0)
// go tool pprof で開くと
// [RSS] -> [Go Memory] -> [Heap] -> [Live]/[Dead] -> スタックトレース
// のように表示される
議論のハイライト
- 名前の変遷:最初は
"rss"という名前でした。@rednafi さんは、cgroup や Kubernetes では「memory」が総称として使われていると指摘しました。これを受けて名前は"memory"に変わり、RSS は付いたり付かなかったりする最上位フレームという位置づけになりました。 - 既存 heap プロファイルへの統合案は撤回:一度は heap プロファイルに
memory_spaceという sample type(値の種類)を追加し、それをデフォルトにする案に変わりました。しかしレビューで次の懸念が出ました。- 仮想ルートフレームがあるためスタックの重複排除が効かず、プロファイルが大きくなる。gzip 後で1.3〜1.9倍という計測結果もあった。
- 既存の自動ツールが想定しているスタック構造が壊れるおそれがある。
- 旧来の heap プロファイルの利用者も、サイズ増加の負担を背負うことになる。
@aclements さんは「どの案にも欠点はある」としたうえで、別のプロファイルとして提供し、利用者に移行を促す方針をとりました。
- 仮想メモリと物理メモリの違い:ランタイムは主に仮想メモリを数え、RSS は物理メモリを測るため、厳密には比較できません。実際には両者はほぼ1対1で対応しますが、ballast のような例外もあります。そこで、Go Memory が RSS を上回る場合は RSS フレームを出さないことにしました。
- 警告は comment フィールドに記録:警告フレームをプロファイル本体に入れると、そのフレームがあったりなかったりして、プロファイル同士の集計や比較を妨げます。このためフレームではなく pprof の comment フィールドに記録することで合意しました。
- MemProfileRate との関係(@cherrymui さんの質問):Live/Dead の内訳は既存のサンプリング結果をもとに推定し、合計を
/gc/heap/live:bytesに合わせてスケーリングします。そのため、内訳の精度はサンプリングレートの影響を受けます。また @nsrip-dd さんは、生存中の割り当てと直前の GC で解放された割り当てだけを出力するため、既存の heap/alloc プロファイルより収集コストが低い可能性があると述べています。