Day 041 — pprofによるプロファイリング②(メモリ)(概念編: サンプリングと4つの計測軸)

2026-09-07 🟡 上級者 / Phase 3 概念理解 pprofによるプロファイリング②(メモリ)

📚 背景知識(読んでから問題へ)

Day 038〜040では、実際にメモリの問題(アロケーション過多・スライス共有リーク)を含むコードを題材に、ヒーププロファイルを「読む」実践をしてきました。今回はその土台となる、Goのメモリプロファイラがそもそも何を・どうやって計測しているかという仕組みそのものを扱います。ここを正確に理解していないと、「プロファイルに出てこないから問題ない」という誤った結論を出しかねません。

Goのヒーププロファイルは、CPUプロファイルと同様にサンプリングベースです。すべてのアロケーションを記録すると実行速度が大きく落ちるため、ランタイムはruntime.MemProfileRate(デフォルト 512 * 1024 = 512KB)というバイト数のしきい値を使い、確保したバイト数の累計がこのしきい値を超えるたびに1回、その時点のアロケーションを記録します。つまり、1回あたりのアロケーションサイズが小さいほど「記録される確率」は下がり、代わりに統計的な重み(ウェイト)を掛けて全体量を推定します。

📌

さらに、ヒーププロファイルには4つの見方があります。

表示モード意味
alloc_objectsプログラム開始からの累計確保オブジェクト数(解放済みも含む)
alloc_spaceプログラム開始からの累計確保バイト数(解放済みも含む)
inuse_objectsプロファイル取得時点で生きているオブジェクト数
inuse_spaceプロファイル取得時点で生きているバイト数
📌
alloc_*は「GCにどれだけ仕事をさせているか(アロケーション圧)」を見るのに向き、inuse_*は「今メモリを占有しているのは何か(リーク調査)」を見るのに向いています。この2軸を混同すると、「大量に確保しては即座に解放される」正常なパターン(alloc_spaceは大きいがinuse_spaceは小さい)を、誤ってリークだと判断してしまいます。

📝 問題

以下の5つの問いに、実行可能なコードを交えて答えてください。

  1. runtime.MemProfileRate のデフォルト値(512KB)のまま、1回あたり数十バイトの小さいオブジェクトを大量に確保し続けるコードをプロファイリングした場合、pprof.WriteHeapProfile が出力する結果は「実際に確保されたバイト数」と完全に一致するか、それとも統計的な推定値になるか。サンプリングの仕組みに基づいて説明する
  2. alloc_spaceは大きいのにinuse_spaceはほぼゼロ、という結果が出るプログラムを実際に作成し(go run main.goでそのまま動くこと)、これが「リークではなく、単にアロケーションが多いだけ」を意味することを、コード上のロジックとともに説明する
  3. 逆に、inuse_spaceが時間とともに増え続けるプログラムの特徴を、問2のコードを流用・改造する形で示す
  4. runtime.MemProfileRate = 1 に設定するとどうなるか。得られる情報の精度と、実行時のオーバーヘッドの観点からトレードオフを説明する
  5. inuse_spaceのスナップショットは「呼び出した瞬間の正確な使用量」を表すか、それとも「直近の完了済みGCサイクル時点の生存量」を表すか。runtime.GC()を明示的に呼んでからプロファイルを取ることが推奨される理由と合わせて説明する

🔍 ヒント(段階的開示)

ヒント1 — 方向性

「サンプリング」という言葉から、統計学における標本調査を連想してください。全数調査ではなく、一定間隔で抜き取って全体を推定する仕組みです。runtime/pprofパッケージのWriteHeapProfileと、runtime.MemProfileRateの関係を調べてください。

ヒント2 — アプローチ

alloc_spaceが大きくinuse_spaceが小さいプログラムを作るには、「大量に確保するが、確保したスライス・構造体をどこにも保持しない(関数を抜けたら参照が切れる)」ループを書けばよい。逆にinuse_spaceを増やし続けるには、確保したものをグローバル変数やグローバルなスライス・マップにappendし続ければよい。両者はほぼ同じループ構造で、「保持するかしないか」だけが違う点に注目してください。

ヒント3 — コード骨格
package main

import (
	"os"
	"runtime"
	"runtime/pprof"
)

var kept [][]byte // inuse_spaceを増やし続けたい場合はここにappendする

func allocateAndMaybeKeep(keep bool) {
	b := make([]byte, 1<<20) // 1MB: スタック割当の上限を超えて確実にヒープ確保させる
	if keep {
		kept = append(kept, b)
	}
	// keep=falseなら b はこの関数を抜けた時点で参照されなくなる
}

func writeHeapProfile(name string) {
	runtime.GC() // 直近のゴミを回収し、inuse_spaceの値を安定させてから記録する
	f, _ := os.Create(name)
	defer f.Close()
	pprof.WriteHeapProfile(f)
}

✅ 模範解答

package main

import (
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
)

// kept はグローバルに保持され続けるバイト列のスライス。
// ここに追加されたものは、プログラム終了までGCの回収対象にならない。
var kept [][]byte

// checksum は allocateChurn の結果を外部から観測可能にするためだけの変数。
// これがないと、bが読み出されないためコンパイラがヒープ確保自体を最適化で消してしまう。
var checksum int

// allocateChurn は「確保してすぐ捨てる」パターンを再現する。
// alloc_space(累計確保量)は大きく積み上がるが、inuse_space(生存量)はほぼ増えない。
func allocateChurn(n int, size int) {
	for i := 0; i < n; i++ {
		b := make([]byte, size)
		b[0] = byte(i)
		checksum += int(b[0]) // bを外部から観測可能な形で使い、確保が最適化で消えないようにする
		// ここでbへの参照はループの次のイテレーションで失われる
	}
}

// allocateLeak は「確保して保持し続ける」パターンを再現する。
// alloc_spaceとinuse_spaceの両方が、呼び出し回数に応じて増え続ける。
func allocateLeak(n int, size int) {
	for i := 0; i < n; i++ {
		b := make([]byte, size)
		b[0] = byte(i)
		kept = append(kept, b) // グローバル変数に保持し続けるため、GC対象にならない
	}
}

func heapStatsMB() (allocMB, inuseMB float64) {
	runtime.GC() // 直近の完了済みGCサイクル時点の値に揃えるため、明示的にGCを実行する
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	// TotalAlloc: プログラム開始からの累計確保バイト数(alloc_spaceに相当する考え方)
	// HeapAlloc : 現在生きているヒープバイト数(inuse_spaceに相当する考え方)
	return float64(m.TotalAlloc) / 1024 / 1024, float64(m.HeapAlloc) / 1024 / 1024
}

func writeHeapProfile(name string) {
	f, err := os.Create(name)
	if err != nil {
		fmt.Println("プロファイル作成エラー:", err)
		return
	}
	defer f.Close()
	runtime.GC()
	if err := pprof.WriteHeapProfile(f); err != nil {
		fmt.Println("プロファイル書き込みエラー:", err)
	}
}

func main() {
	const n = 5000
	const size = 1 << 20 // 1MB: スタック割当の上限(コンパイラのescape解析でスタックに置ける最大サイズ)を超えて、確実にヒープ確保させる

	fmt.Println("--- サンプリングレート確認 ---")
	fmt.Printf("runtime.MemProfileRate = %d byte\n", runtime.MemProfileRate)
	fmt.Println("(デフォルト512KBごとに1回サンプリングされる。1回1MBの確保なので、ほぼ毎回サンプルとして記録される計算になる)")

	fmt.Println("\n--- パターン1: allocateChurn(確保してすぐ捨てる) ---")
	beforeAlloc, beforeInuse := heapStatsMB()
	allocateChurn(n, size)
	afterAlloc, afterInuse := heapStatsMB()
	fmt.Printf("TotalAlloc差分(alloc_space相当): %.2fMB\n", afterAlloc-beforeAlloc)
	fmt.Printf("HeapAlloc差分(inuse_space相当) : %.2fMB\n", afterInuse-beforeInuse)
	fmt.Printf("checksum(確保が最適化で消えていない確認用): %d\n", checksum)

	fmt.Println("\n--- パターン2: allocateLeak(確保して保持し続ける) ---")
	beforeAlloc, beforeInuse = heapStatsMB()
	allocateLeak(n, size)
	afterAlloc, afterInuse = heapStatsMB()
	fmt.Printf("TotalAlloc差分(alloc_space相当): %.2fMB\n", afterAlloc-beforeAlloc)
	fmt.Printf("HeapAlloc差分(inuse_space相当) : %.2fMB\n", afterInuse-beforeInuse)

	writeHeapProfile("heap.pprof")
	fmt.Println("\nheap.pprof を出力しました。`go tool pprof -top -inuse_space heap.pprof` などで確認できます。")
}

実行結果(実測。数値は環境依存だがパターンの差ははっきり出る):

--- サンプリングレート確認 ---
runtime.MemProfileRate = 524288 byte
(デフォルト512KBごとに1回サンプリングされる。1回1MBの確保なので、ほぼ毎回サンプルとして記録される計算になる)

--- パターン1: allocateChurn(確保してすぐ捨てる) ---
TotalAlloc差分(alloc_space相当): 5000.02MB
HeapAlloc差分(inuse_space相当) : 0.02MB
checksum(確保が最適化で消えていない確認用): 629340

--- パターン2: allocateLeak(確保して保持し続ける) ---
TotalAlloc差分(alloc_space相当): 5000.49MB
HeapAlloc差分(inuse_space相当) : 5000.15MB

heap.pprof を出力しました。`go tool pprof -top -inuse_space heap.pprof` などで確認できます。

🪜 Step-by-Step 解説

1
サンプリングの仕組みと精度
runtime.MemProfileRate(デフォルト524288バイト=512KB)は、「累計確保バイト数がこの値に達するたびに1回記録する」しきい値です。1回のアロケーションサイズがしきい値に対して十分小さい場合でも、Goのランタイムは確保サイズに応じた重み付け(逆確率による補正)を行うため、pprofが出力するalloc_space/inuse_spaceの合計値自体は統計的にほぼ正確な推定値になります(個々のどのアロケーションが記録されたかは間引かれますが、集計値の偏りは補正されます)。ただし、呼び出し元(スタックトレース)ごとの内訳は、サンプル数が少ないほど誤差が大きくなります。したがって「合計は信頼できるが、極端に小さく低頻度なアロケーションの発生源特定は誤差が乗りやすい」というのが正確な理解です。
2
alloc_spaceが大きくinuse_spaceが小さいケース
allocateChurnでは、make([]byte, size)で確保したバイト列bはループの各イテレーション内でしか参照されず、次のイテレーションに移った時点でGCの回収対象になります(checksumに足し込むのは、コンパイラが「読み出されないアロケーション」を最適化で消してしまうのを防ぐための工夫であり、b自体を保持するものではありません)。5000回×1MBの確保が発生するためTotalAlloc(alloc_space相当)は約5000MB積み上がりますが、確保したものは順次回収されるためHeapAlloc(inuse_space相当)はほぼ増えません。これは「GCへの負荷(アロケーション圧)は高いが、リークはしていない」状態であり、パフォーマンスチューニングの対象にはなり得ても、メモリリークとして扱うべきではありません。
3
inuse_spaceが増え続けるケース
allocateLeakはallocateChurnとほぼ同じ確保ロジックですが、確保したバイト列をグローバル変数keptにappendする1行だけが異なります。この1行によって、確保したバイト列はkeptスライスから常に参照可能な状態になり、GCの回収対象から外れ続けます。結果としてTotalAllocとHeapAllocの両方が呼び出し回数に比例して増え続けます。確保ロジックはほぼ同一でも、「参照を手放すかどうか」だけでプロファイル上の意味が正反対になる点が本問の核心です。
4
MemProfileRate=1のトレードオフ
runtime.MemProfileRate = 1に設定すると、しきい値が1バイトになるため理論上ほぼ全てのアロケーションが記録されます(正確な仕組み上、非常に高い確率でサンプリングされる)。これにより、個々の小さなアロケーションの発生源まで正確に特定できるようになりますが、代償としてプロファイリング自体のオーバーヘッド(記録処理・スタックトレース取得のコスト)が跳ね上がり、プログラムの実行速度が大きく低下します。本番環境で常時有効にすべき設定ではなく、問題箇所をある程度絞り込んだ後、開発環境やステージング環境で一時的に精度を上げて再現・特定するための設定と位置づけるのが実務的です。
5
inuse_spaceは「直近の完了済みGC時点」のスナップショット
inuse_spaceが示す生存量は、WriteHeapProfileを呼んだその瞬間の正確な使用量ではなく、直近に完了したGCサイクルの時点でマーク済みだった生存オブジェクトの集計です。GCが完了してからWriteHeapProfileを呼ぶまでの間に新たに確保されたが、まだ次のGCでマークされていない「ゴミになるかもしれないオブジェクト」は反映されないことがあります。そのため、実務ではWriteHeapProfileの直前に明示的にruntime.GC()を呼び、直近のGCサイクルを強制的に完了させてから記録するのが一般的です。これにより「未回収の一時的なゴミ」によるノイズを減らし、より安定した生存量の比較ができます。

💡 設計思想・なぜこう書くのか

📌
常時有効化できる軽さを優先する: Goがメモリプロファイリングをサンプリングベースにしているのは、CPUプロファイリングと同じ思想です。「全数記録による完全な正確性」よりも「オーバーヘッドを抑えて本番環境でも常時有効化できる実用性」を優先しています。もし全アロケーションを記録する設計だったら、プロファイリングは「問題が起きてから特別なビルドで再現する」ための特殊な手段にとどまり、日常的な観測手段にはなり得ませんでした。
📌
本番稼働中への組み込みを前提にした標準ライブラリ: Goはnet/http/pprofを標準ライブラリに含め、本番稼働中のサーバーに対しても/debug/pprof/heapへのHTTPリクエスト一発でプロファイルを取得できるようにしています。これは「常時低コストで観測できることの価値は、多少の統計誤差を許容してでも優先する」というGoの実務志向の設計思想の表れです。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaJFR(Java Flight Recorder)・ヒープダンプJFRもサンプリングベースのイベント記録という点でGoに近いが、ヒープダンプ(jmap)は全オブジェクトを対象とした完全なスナップショットであり、Goのinuse_spaceのような統計的サンプリングではない。完全性と引き換えに、ヒープダンプの取得自体がアプリケーションを長時間停止させるコストを伴う
Pythontracemalloctracemallocはデフォルトで全アロケーションを追跡する(サンプリングしない)ため、Goよりも正確だが、有効にするとオーバーヘッドが大きく、本番で常時有効にすることは推奨されない。Goは「常時有効化できる軽さ」を優先してサンプリングを選んでいる
Node.js--inspectのHeap Snapshot(V8)V8のヒープスナップショットも全オブジェクトを対象とした完全なスナップショットに近く、取得コストが高い。Node.jsで軽量な継続的サンプリングを行いたい場合はv8.getHeapStatistics()のような集計APIや外部のサンプリングプロファイラを別途組み合わせる必要があり、Goのように標準ツールチェーンだけで完結しない

🏆 実務での使いどころ

  • 本番環境の常時監視への組み込み: net/http/pprofを有効化しておき、定期的(例: 1時間おき)に/debug/pprof/heap?gc=1(gc=1パラメータで取得前に強制GCを実行できる)を取得・保存しておくことで、後から「いつからinuse_spaceが増え始めたか」を時系列で追える
  • 問題切り分けの初手: 障害調査でまずalloc_spaceとinuse_spaceの両方を確認し、「GC負荷が高いだけ(パフォーマンス問題)」なのか「生存量が増え続けている(リーク)」なのかを最初に切り分けることで、その後の調査方針(ホットパス最適化 vs 参照関係の追跡)を誤らずに済む
  • 精度が必要な場面での一時的なMemProfileRate引き下げ: ステージング環境で再現するリークの発生源を1バイト単位まで絞り込みたい場合に限定してMemProfileRate=1を使い、原因特定後は必ずデフォルトに戻す

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
alloc_spaceが大きいのを見て「メモリリークだ」と即断するalloc_*とinuse_*の違い(累計 vs 現在生存)を意識していないalloc_spaceはGCへの負荷(スループット問題)の指標であり、リーク(inuse_spaceの増加)とは別の問題。両方を必ず並べて確認する
プロファイルに出てこないアロケーションは「存在しない」と考えるサンプリングであることを知らず、全数記録だと思い込んでいるデフォルト設定では小さく低頻度なアロケーションはサンプルに乗りにくい。合計量の推定は概ね正確だが、個々の発生源の内訳には統計的な誤差があり得る
WriteHeapProfileを呼べば常に「今この瞬間」の正確な使用量が取れると思うGCのタイミングとプロファイル取得のタイミングが常に一致していると誤解しているinuse_spaceは直近の完了済みGCサイクル時点の値。直前にruntime.GC()を呼ばないと、未回収のゴミが混ざって値が不安定になり得る

🚀 次のステップ

  • 発展: 本問のallocateChurn/allocateLeakをgo tool pprof -http=:8080 heap.pprofで実際に開き、フレームグラフ上でalloc_spaceとinuse_spaceの切り替えがどう見た目を変えるかを目視で確認してみましょう
  • 次回予告: ベンチマーク駆動最適化 — Day 038〜041で学んだCPU/メモリプロファイリングの読み方を踏まえ、testing.BによるBefore/Afterの定量比較を通じて最適化のワークフローを一本化します

🎯 自己評価

自分の回答

気づき・メモ