📚 背景知識(読んでから問題へ)
Day 036・037では、-gcflags="-m"(エスケープ解析)と-benchmem(アロケーション実測)を使って、「このコードは実行前からどんな性質を持つか」を静的に予測する方法を学びました。しかし実際の本番システムでボトルネックになっているのは、多くの場合「予測していなかった箇所」です。ソースコードを読んだだけでは、どの関数がCPU時間の大半を消費しているかは正確にはわかりません。ここでDay 038から扱うpprof(Profile + prof)は、実際に動いているプログラムを観測して、実測データからボトルネックを特定するためのツールです。
runtime/pprofパッケージのStartCPUProfileを呼ぶと、OSのタイマー割り込み(Unix系ではSIGPROFシグナル)を使ってデフォルト約100Hz(10msごと)にプログラムの実行を一時停止し、その瞬間の全goroutineのスタックトレースを記録する、というサンプリング方式で動作します。これを一定時間繰り返し、収集したスタックトレースの出現頻度を集計することで「どの関数が、どれだけの時間実行され続けていたか」を統計的に推定します。全命令を逐一記録するトレーサー(go tool trace)とは異なり、サンプリングであるがゆえにオーバーヘッドが小さく、本番環境でも有効化しやすいという特徴があります。プロファイル結果を読むときに重要な2つの指標があります。
- flat(自己時間): その関数自身のコードが実行されていた時間(他の関数呼び出し先で消費した時間は含まない)
- cum(累積時間、cumulative): その関数、およびその関数が呼び出した先すべてで消費された時間の合計
📝 問題
以下は、「指定した上限未満の素数の個数を数える」バッチ処理プログラムです。機能的には正しいですが、大きな上限を指定すると著しく遅くなります。
package main
import (
"flag"
"fmt"
"os"
"runtime/pprof"
)
// isPrimeSlow は n が素数かどうかを、2からn-1まで愚直に試し割りして判定する
func isPrimeSlow(n int) bool {
if n < 2 {
return false
}
for i := 2; i < n; i++ {
if n%i == 0 {
return false
}
}
return true
}
func countPrimes(limit int) int {
count := 0
for i := 2; i < limit; i++ {
if isPrimeSlow(i) {
count++
}
}
return count
}
func main() {
cpuprofile := flag.String("cpuprofile", "", "write cpu profile to file")
flag.Parse()
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
fmt.Fprintln(os.Stderr, "could not create CPU profile:", err)
os.Exit(1)
}
defer f.Close()
if err := pprof.StartCPUProfile(f); err != nil {
fmt.Fprintln(os.Stderr, "could not start CPU profile:", err)
os.Exit(1)
}
defer pprof.StopCPUProfile()
}
n := countPrimes(200000)
fmt.Println("primes found:", n)
}
要件
- 上記コードを
go run main.go -cpuprofile=cpu.profで実行し、CPUプロファイルを取得する手順(ビルド〜実行〜go tool pprofでの確認)を具体的なコマンド列で示す go tool pprof -top cpu.profを実行した場合にどの関数がflat時間の大半を占めると予測されるか、コードの構造から論理的に説明するlist isPrimeSlow(あるいは該当関数)で行単位の内訳を見た場合、どの行が最もコストを支配していると予測されるかを、アルゴリズムの計算量の観点から説明するcountPrimesのアルゴリズムを、外部から見た戻り値(見つかった素数の個数)を変えずに、計算量を落とす形に書き直す(エラトステネスの篩を使うこと)。go run main.goでそのまま動く完全なコードを提示すること- 修正前後で
go tool pprof -topの出力がどう変わると予測されるか、またgo test -bench . -cpuprofile=cpu.profを使ってベンチマーク実行中のプロファイルを取得する場合のテストコードとコマンドを示す - CPUプロファイリング(時間の消費)とDay 037で扱ったアロケーションプロファイリング(
-benchmem・-memprofile)は、それぞれ何を計測し、何を計測できないかを整理する
🔍 ヒント(段階的開示)
ヒント1 — 方向性
go tool pprofは「どの関数が呼ばれたか」ではなく「どの関数が実行時間を消費していたか」を教えてくれるツールです。まずソースコードを見て、ネストしたループ(二重ループ以上)がどこにあるかを目視で探すところから始めるとよいです。プロファイラを使う前の「当たりをつける」作業自体が、プロファイラの出力を正しく解釈する助けになります。
ヒント2 — アプローチ
isPrimeSlow(n)は最悪O(n)回のループを回す。これがcountPrimesの中でlimit回呼ばれるため、全体の計算量はおよそO(limit^2)になる。limit=200000程度でもこの二重の効果が効いてくるgo tool pprof -topの出力で見るべき列はflat,flat%,sum%,cum,cum%の5つ。flat%が高い上位数行を見れば、どの関数に時間が集中しているかが一目でわかるlist <関数名>を使うと、その関数のソースコードの各行に「その行だけでどれだけの時間がかかったか」が表示される。ループの条件式やモジュロ演算(n%i)がある行に注目する- エラトステネスの篩は「合成数に印をつけていく」方式で、計算量は
O(limit log log limit)程度まで落ちる。試し割りのO(limit^2)から大幅に改善する
ヒント3 — コード骨格
検証の骨格:
mkdir day038 && cd day038
go mod init example.com/day038
# main.go に上記コードを保存する
go build -o day038bin .
./day038bin -cpuprofile=cpu.prof
# primes found: XXXXX が出力され、cpu.prof が生成される
go tool pprof -top cpu.prof
# (pprof) top コマンドを実行した状態と同じ出力が得られる
# 上位に isPrimeSlow が flat 90%以上を占める形で出るはずである
go tool pprof cpu.prof
# 対話モードに入ったら以下を試す
# (pprof) list isPrimeSlow
# (pprof) top10
# (pprof) web ← Graphvizが入っていればコールグラフをブラウザ表示
✅ 模範解答
1. CPUプロファイル取得の手順
# 1. モジュール初期化とビルド
mkdir day038 && cd day038
go mod init example.com/day038
# main.go に問題文のコードを保存
# 2. -cpuprofile フラグを付けて実行し、プロファイルを取得する
go build -o day038bin .
./day038bin -cpuprofile=cpu.prof
# 標準出力: primes found: 17984
# カレントディレクトリに cpu.prof が生成される
# 3. go tool pprof でトップの関数を確認する
go tool pprof -top cpu.prof
go tool pprof -top cpu.profの出力イメージ(実測値は環境依存だが、傾向はほぼ確実に以下のようになります):
Showing nodes accounting for 1.98s, 99.50% of 1.99s total
flat flat% sum% cum cum%
1.85s 92.96% 92.96% 1.97s 98.99% main.isPrimeSlow
0.12s 6.03% 98.99% 1.97s 98.99% main.countPrimes
0.01s 0.50% 99.50% 1.99s 100% main.main
2. flat時間の大半を占める関数の予測
isPrimeSlowが圧倒的にflat時間の大半(90%超)を占めると予測できます。理由は次の通りです。
countPrimesはisPrimeSlowを呼び出す「ループの外枠」に過ぎず、それ自体の計算(呼び出しとカウントのインクリメント)は軽量isPrimeSlowは最悪O(n)回のループを内部に持ち、これがlimit回(=200000回)呼び出される。関数自身のループ本体でCPU時間を消費しているため、他の関数を呼び出さないisPrimeSlowはflatとcumがほぼ一致するという特徴も予測できる(子関数を呼んでいないため、cumに上乗せされる分がほとんどない)
3. isPrimeSlow内でコストを支配する行
for i := 2; i < n; i++ {
if n%i == 0 { // ← ここが最もコストを支配する行
return false
}
}
list isPrimeSlowを実行すると、n%i == 0を含む行に最も多くの時間が計上されると予測できます。理由は次の2点です。
- ループの条件式
i < nの評価とインクリメントi++は毎回実行されるが、それ自体は軽量な整数演算である - モジュロ演算
n%iは整数除算命令であり、加算・比較よりも相対的にコストが高い。しかもこのループの各反復で必ず1回実行されるため、ループの実行回数(nに依存)にほぼ比例して行全体のコストが積み上がる
nが合成数でない限り(=素数である場合)、このループはn-2回すべて回り切ります。大きい素数を判定するたびに最悪ケースを踏むため、素数が多く含まれるlimit未満の範囲では特にコストが支配的になります。
4. エラトステネスの篩による書き直し
package main
import (
"flag"
"fmt"
"os"
"runtime/pprof"
)
// countPrimesSieve はエラトステネスの篩により、limit未満の素数の個数を数える。
// isPrimeSlow を使う旧実装(O(limit^2))と異なり、O(limit log log limit) で完了する。
func countPrimesSieve(limit int) int {
if limit < 2 {
return 0
}
composite := make([]bool, limit)
count := 0
for i := 2; i < limit; i++ {
if composite[i] {
continue
}
count++
// i の倍数(i*iから開始)をすべて合成数として印を付ける
for j := i * i; j < limit; j += i {
composite[j] = true
}
}
return count
}
func main() {
cpuprofile := flag.String("cpuprofile", "", "write cpu profile to file")
flag.Parse()
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
fmt.Fprintln(os.Stderr, "could not create CPU profile:", err)
os.Exit(1)
}
defer f.Close()
if err := pprof.StartCPUProfile(f); err != nil {
fmt.Fprintln(os.Stderr, "could not start CPU profile:", err)
os.Exit(1)
}
defer pprof.StopCPUProfile()
}
n := countPrimesSieve(200000)
fmt.Println("primes found:", n)
}
i*iから開始できるのは、i未満の倍数(2*i, 3*i, ..., (i-1)*i)はすでにそれより小さい素数の倍数として印付け済みだからです。これにより無駄な再走査を避けられます。countPrimes(旧実装)とcountPrimesSieve(新実装)はlimit=200000に対して同じ17984という個数を返します(外部から見た戻り値は不変)。
5. 修正前後のプロファイル比較とベンチマーク実行時のプロファイル取得
プロファイルの変化予測:
修正前はisPrimeSlowがflat%のほぼ全てを占めていましたが、修正後はcountPrimesSieve自体(篩の二重ループ)に時間が分散し、かつ全体の実行時間そのものが大幅に短縮されるため、同じサンプリング間隔(10ms)でも記録されるサンプル数自体が激減します。プログラムが速すぎてサンプルがほとんど採れない場合は、limitを大きくする(例: 2000000)か、ループで複数回呼び出してから計測するのが実務上の定石です。
ベンチマークでのプロファイル取得:
// main_test.go
package main
import "testing"
func BenchmarkCountPrimesSieve(b *testing.B) {
for i := 0; i < b.N; i++ {
countPrimesSieve(200000)
}
}
go test -bench BenchmarkCountPrimesSieve -cpuprofile=cpu.prof -benchtime=3s
go tool pprof -top cpu.prof
go testが-cpuprofileフラグをネイティブでサポートしているため、runtime/pprofを手動で呼ぶコードを書かなくても、テストバイナリの実行区間全体のCPUプロファイルを取得できます。ベンチマーク駆動でボトルネックを特定する際は、main関数に計測コードを埋め込むよりもこちらの方法が簡潔です。
6. CPUプロファイリングとアロケーションプロファイリングの違い
| 観点 | CPUプロファイリング(-cpuprofile) | アロケーションプロファイリング(-benchmem, -memprofile) |
|---|---|---|
| 計測対象 | 時間(どの関数がCPUサイクルを消費していたか) | メモリ割当(どの関数が何バイト・何回ヒープ確保したか) |
| 計測方法 | SIGPROFによる約100Hzのサンプリング(スタックトレース収集) | アロケーションイベントごとのサンプリング(runtime.MemProfileRate単位、デフォルト512KB相当ごとに1サンプル) |
| 検出できるもの | ループの無駄な繰り返し・非効率アルゴリズム・ロック待ちなどCPU時間を食う処理 | 不要なヒープ確保・GC負荷の原因になる割当パターン |
| 検出できないもの | メモリ使用量そのもの(CPUを使わない待機やI/Oブロックは見えにくい) | 実行時間(アロケーション回数が少なくても、計算自体が重ければCPUプロファイルでしか見えない) |
| Day037との接続 | 本問(Day038)の対象 | Day037で扱った「隠れたヒープ割当」の実測手段 |
-memprofileで確認する」という順序で組み合わせて使うのが定石です。🪜 Step-by-Step 解説
プロファイラを使う前に、ソースコード上で「計算量が大きくなりそうな箇所」に当たりをつける習慣を持ちます。今回は
countPrimesのforループの中でisPrimeSlowが呼ばれ、その内部にもう1つforループがあるという二重ループ構造が一目でわかります。
-cpuprofileフラグでプロファイルを取得するruntime/pprofのStartCPUProfile/StopCPUProfileをコマンドラインフラグと組み合わせて、プロファイル取得の有無を切り替えられるようにします。この形はgo test -cpuprofileが内部でも使っているパターンであり、CLIツールに恒常的に組み込んでおくと便利です。
topでflat時間の高い関数を特定するgo tool pprof -top cpu.profで得られる表のflat%列を上から見ていくことで、「関数自身の処理」に時間がかかっている箇所を特定します。cum%が高くてもflat%が低い関数は、その関数自体は軽く、呼び出し先に本当のボトルネックがあります。
listで行単位の内訳を確認するtopで特定した関数に対してlist <関数名>を実行し、行ごとのコストを確認します。今回のisPrimeSlowのようにループ内の特定の演算(モジュロ演算)にコストが集中しているケースは非常によくあるパターンです。
コードを直しただけで満足せず、修正後も同じ手順でプロファイルを取り直し、「意図した箇所のコストが実際に下がったか」を確認します。これがベンチマーク駆動最適化の基本サイクルです。
💡 設計思想・なぜこう書くのか
runtime/pprofを標準ライブラリに同梱し、go tool pprofを標準ツールチェーンに同梱しているのは、「パフォーマンス計測は特別な外部ツールを導入して初めてできることではなく、goコマンドと標準ライブラリだけで完結すべき基本機能である」という設計思想の表れです。他の多くの言語では、プロファイラは別途インストールする外部ツール(Java の async-profiler、Python の py-spy 等)であることが多いですが、Goではimport "runtime/pprof"の数行とgo tool pprofだけで、開発環境と同じ手軽さでプロファイリングができます。net/http/pprofパッケージをimportするだけで、稼働中のHTTPサーバーに/debug/pprof/エンドポイントが追加され、本番環境で稼働中のプロセスに対しても(コードの再デプロイなしに)プロファイルを取得できます。これは「パフォーマンス問題は本番環境でしか再現しないことが多い」という現実的な前提に立った設計であり、Day 036・037で扱った静的解析(-gcflags="-m")だけでは決して得られない情報を提供します。🌐 他言語との比較
| 言語 | 同等の概念 | Goとの違い |
|---|---|---|
| Java | JFR(Java Flight Recorder)・async-profiler | JFRはJVMに標準搭載されているが、有効化や結果の解析に専用のGUIツール(JDK Mission Control)が必要になることが多い。Goはgo tool pprofというCLIだけで完結し、テキスト・SVG・対話型Web UIのいずれでも同じデータソースから確認できる |
| Python | cProfile, py-spy | cProfileは関数呼び出しごとの決定的な計測(オーバーヘッドが大きい)で、py-spyは外部プロセスとしてアタッチするサンプリング型プロファイラ(Goのpprofに近い発想)。Goは言語ランタイム自身がサンプリング機構を持つため、追加のプロセスやツールのインストールが不要 |
| Rust | perf + flamegraphクレート、pprof-rs | RustにはGoのような標準組み込みプロファイラはなく、OSのperfコマンドや外部クレートに頼ることが多い。Goは言語・ランタイムレベルでプロファイリングAPIを提供している点が異なる |
🏆 実務での使いどころ
- 本番障害・レイテンシ悪化の原因調査:
net/http/pprofを有効化しておけば、レイテンシが悪化したタイミングでcurl一発でCPUプロファイルを取得し、後からgo tool pprofで解析できる - リリース前のパフォーマンスレビュー: 新機能追加後に
go test -bench . -cpuprofile=cpu.profを実行し、想定外のホットスポットが生まれていないかを確認する - 「推測ではなく実測」の文化づくり: 「ここが遅そう」という勘に頼った最適化ではなく、
top・listの実測データに基づいて優先順位をつけることで、無駄な最適化作業を避けられる - アルゴリズム選定の説得材料: エラトステネスの篩のような教科書的な計算量改善が、実際にプロファイル上で「flat時間が劇的に減る」形で裏付けられることを、コードレビューやチーム内共有で示せる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
cum(累積時間)が高い関数がボトルネックだと即断する | 「関与している時間が長い=問題箇所」という直感的な誤解 | mainのような呼び出し階層の浅い関数は、末端の重い処理を呼んでいるだけでcumが高くなる。実際にロジックの無駄があるのはflatが高い末端の関数であることが多い |
| プロファイルを1回取っただけで結論を出す | サンプリングにはノイズがあることを軽視する | サンプリング頻度(デフォルト100Hz)は統計的な推定であり、実行時間が短いプログラムほど誤差が大きくなる。複数回実行する、または-benchtimeを伸ばしてサンプル数を増やすことで信頼性を上げる |
| CPUプロファイルだけを見て「メモリの問題もこれで分かる」と思い込む | プロファイリング=1種類という単純化 | CPUプロファイルは時間の消費箇所のみを示す。ヒープ割当量・GC負荷は-benchmemや-memprofileなど別種のプロファイルでしか見えない(Day037との接続点) |
本番環境でnet/http/pprofを有効化すること自体をセキュリティ上の理由で避け、結局何も計測できない | /debug/pprof/が誰でもアクセスできる設定のまま公開されるリスクを過大視、または対策を知らない | 認証付きの別ポート・別ミドルウェアで/debug/pprof/を保護する、あるいは内部ネットワークからのみアクセス可能にするなど、安全に有効化する方法がある。「計測できないから何もしない」ではなく「安全に計測できる仕組みを整える」ことが本来の対応 |
🚀 次のステップ
- 発展:
day038のコードにgo tool pprof -http=:8080 cpu.profを実行し、ブラウザ上のFlame Graph(フレームグラフ)でコールスタックの積み上げを視覚的に確認してみましょう。topのテキスト出力だけでは掴みにくい「呼び出し階層の広がり」が一目でわかります - 次回予告: pprofによるプロファイリング②(メモリ) — Day 039では、Day 037で静的に予測した「隠れたヒープ割当」を、
-memprofileとgo tool pprofのalloc_objects/inuse_spaceビューを使って実測データから特定する方法を学びます