Go言語入門:プロファイルガイド最適化 -Vol.16-

スポンサーリンク
Go言語入門:プロファイルガイド最適化 -Vol.16- ノウハウ
Go言語入門:プロファイルガイド最適化 -Vol.16-
この記事は約16分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語のプロファイルガイド最適化ついて解説しています。

スポンサーリンク

背景

Go言語でアプリケーションのパフォーマンスを改善したいと思ったことはありませんか? 公式ドキュメントで「Profile-guided optimization (PGO)」という機能を見かけたものの、英語で書かれていて専門用語も多く、「どういう仕組みなんだろう?」「自分のプロジェクトにどう活用すればいいの?」と戸惑った経験があるかもしれません。

この記事では、Go 1.21から正式サポートされたPGO(プロファイルガイド最適化)について、公式ドキュメントの丁寧な日本語訳と、初心者の方にもわかりやすい補足説明をお届けします。PGOは実行時のプロファイル情報をコンパイラにフィードバックすることで、2〜14%のパフォーマンス向上が期待できる強力な機能です。

「コンパイラ最適化」や「プロファイリング」と聞くと難しそうに感じるかもしれませんが、基本的な使い方はとてもシンプルです。この記事では、PGOの仕組みを身近な例えで説明し、実際にどうやって使うのかを具体的なコード例とともに紹介していきます。一緒に学んでいきましょう!

付録:代替プロファイルソース

Goランタイムによって生成されたCPUプロファイル(runtime/pprofなどを介して)は、PGO入力として直接使用できる正しいフォーマットになっています。しかし、組織によっては代替の優先ツール(例:Linux perf)を持っている場合や、Go PGOで使用したい既存のフリート全体の継続的プロファイリングシステムを持っている場合があります。

代替ソースからのプロファイルは、以下の一般的な要件に従っている場合、pprofフォーマットに変換すればGo PGOで使用できます:

  • サンプルインデックスの1つは、type/unitが”samples”/”count”または”cpu”/”nanoseconds”である必要があります
  • サンプルは、サンプル位置でのCPU時間のサンプルを表す必要があります
  • プロファイルはシンボル化されている必要があります(Function.nameが設定されている必要があります)
  • サンプルには、インライン化された関数のスタックフレームが含まれている必要があります。インライン化された関数が省略されている場合、Goは反復安定性を維持できません
  • Function.start_lineが設定されている必要があります。これは関数の開始行番号です。つまり、funcキーワードを含む行です。Goコンパイラはこのフィールドを使用してサンプルの行オフセットを計算します(Location.Line.line - Function.start_line)。多くの既存のpprof変換ツールはこのフィールドを省略していることに注意してください

注意:Go 1.21より前では、DWARFメタデータは関数開始行(DW_AT_decl_line)を省略しているため、ツールが開始行を判断するのが難しい場合があります。

特定のサードパーティツールのPGO互換性に関する追加情報については、Go WikiのPGO Toolsページを参照してください。


解説

なぜ代替ソースが必要か

組織によっては、Goのruntime/pprof以外のツールを使いたい理由があります。

典型的なシナリオ:

理由1: 既存のプロファイリング基盤
- すでにDatadog Profilerを全社で使用
- Google Cloud Profilerで統一管理
- Linux perfで低レベル分析が必要

理由2: 言語横断的な統一
- Go、Python、Javaを同じツールで管理したい
- 統一されたダッシュボードで比較したい

理由3: 高度な分析
- カーネルレベルのプロファイリングが必要
- メモリプロファイルとCPUプロファイルを統合

レストランの例え:異なる調査方法

シナリオ:レストランの効率調査

Go標準の方法(runtime/pprof):
レストラン内部の記録システム
- 注文から提供までの時間を自動記録
- 各調理工程の時間を測定
- そのまま分析に使える

代替方法(Linux perfなど):
外部の調査会社による観察
- ビデオカメラで全体を記録
- タイムスタディで時間測定
- レストランの記録形式に変換する必要がある

どちらも有効だが、
代替方法は変換作業が必要

主要な代替プロファイルツール

1. Linux perf

# Linux perfでプロファイル収集
perf record -F 99 -g ./myapp

# pprof形式に変換(pprof-from-perfツール使用)
perf script | pprof-from-perf > cpu.pprof

# PGOで使用
go build -pgo=cpu.pprof

利点:

✓ カーネルレベルの詳細情報
✓ システムコール、割り込みも記録
✓ Linux標準ツール

欠点:

✗ 変換が必要
✗ Linux専用
✗ 設定が複雑

2. Google Cloud Profiler

import "cloud.google.com/go/profiler"

func main() {
    // 自動的にプロファイル収集
    profiler.Start(profiler.Config{
        Service:        "myapp",
        ServiceVersion: "1.0.0",
    })
    
    // アプリケーション実行
    startServer()
}

プロファイル取得:

# Cloud Consoleからダウンロードまたはgcloudコマンド
gcloud profiler query \
    --service=myapp \
    --profile-type=CPU \
    --start-time="2024-01-20T00:00:00Z" \
    --end-time="2024-01-27T00:00:00Z" \
    > production.pprof

# PGOで使用
go build -pgo=production.pprof

3. Datadog Continuous Profiler

import "gopkg.in/DataDog/dd-trace-go.v1/profiler"

func main() {
    profiler.Start(
        profiler.WithService("myapp"),
        profiler.WithEnv("production"),
    )
    defer profiler.Stop()
    
    startServer()
}

プロファイル取得:

# Datadog APIから取得(要APIキー)
curl -H "DD-API-KEY: ${DD_API_KEY}" \
    "https://api.datadoghq.com/api/v2/profiling/profile" \
    > production.pprof

必須要件の詳細解説

要件1:サンプルタイプ

正しいフォーマット:

type: "cpu"
unit: "nanoseconds"

または

type: "samples"
unit: "count"

確認方法:

# プロファイルの内容を確認
go tool pprof -raw profile.pprof | head -20

# 正しい例:
# SampleType: samples count
# SampleType: cpu nanoseconds
# Period: 10000000

# 間違った例:
# SampleType: memory bytes  ← これはCPUプロファイルではない

要件2:CPU時間のサンプル

プロファイルはCPUが実際に使われた時間を記録する必要があります。

正しいサンプル:

関数A: 実行中にCPUを使った時間 = 500ms
関数B: 実行中にCPUを使った時間 = 300ms
関数C: I/O待ちでCPU未使用 = 0ms(記録されない)

間違ったサンプル:

関数A: 壁時計時間 = 1000ms(I/O待ち含む)
→ PGOには不適切(CPU時間ではない)

要件3:シンボル化

関数名が解決されている必要があります。

シンボル化済み(正しい):

スタック:
main.HandleRequest
main.ProcessData
encoding/json.Unmarshal

シンボル化なし(間違い):

スタック:
0x4a5b3c
0x4a5e12
0x3f2a90

Linux perfでの注意:

# perfは生のアドレスを記録
perf record -g ./myapp

# シンボル化が必要
perf script | pprof-from-perf \
    --binary=./myapp \
    --symbols  # シンボル情報を含める
    > cpu.pprof

要件4:インライン関数のスタックフレーム

重要な理由:反復安定性

インライン関数の情報がないと、コードが変わったときにマッチングが失敗します。

正しい(インライン情報あり):

スタック:
main.HandleRequest
main.validateInput (inlined)  ← インライン関数も記録
main.checkAuth (inlined)      ← インライン関数も記録
main.ProcessData

間違い(インライン情報なし):

スタック:
main.HandleRequest
main.ProcessData
# validateInputとcheckAuthが欠落

問題:

v1.0でプロファイル取得:
main.HandleRequest → main.ProcessData

v1.1でコード変更(validateInputを追加):
main.HandleRequest → main.validateInput → main.ProcessData

マッチング失敗:
スタックが変わったように見える
→ 反復安定性が失われる

要件5:Function.start_line

関数の開始行番号が必須です。

なぜ必要か:

// ファイル: handler.go

package main

import "fmt"

// 5行目
func HandleRequest(r *Request) {  // ← start_line = 6
    fmt.Println("start")          // 7行目
    process(r)                    // 8行目
    fmt.Println("end")            // 9行目
}

// 11行目
func process(r *Request) {        // ← start_line = 12
    // ...
}

Goコンパイラの計算:

サンプル位置: handler.go:8行目(process呼び出し)
関数開始行: 6行目
オフセット: 8 - 6 = 2

コードが変わっても:
func HandleRequest(r *Request) {  // start_line = 6
    log.Debug("new log")          // 7行目(新規追加)
    fmt.Println("start")          // 8行目
    process(r)                    // 9行目(1行ずれた)
    
オフセット: 9 - 6 = 3
→ オフセットベースでマッチング可能

start_lineがない場合:

サンプル: handler.go:8行目

コードが変わると:
同じ8行目が別の処理になる
→ マッチング失敗

多くの変換ツールの問題:

# 一般的なpprof変換ツール
some-profiler-to-pprof < profile.data > output.pprof

# 生成されたプロファイルを確認
go tool pprof -raw output.pprof

# よくある問題:
# Function {
#   name: "main.HandleRequest"
#   filename: "handler.go"
#   # start_line: MISSING!  ← これが欠落している
# }

変換ツールの例

Linux perf → pprof

#!/bin/bash
# perf-to-pgo.sh

# 1. perfでプロファイル収集
perf record -F 99 -g --call-graph dwarf ./myapp

# 2. perf.dataをpprof形式に変換
# (pprof-from-perfツールが必要)
perf script | pprof-from-perf \
    --binary=./myapp \
    --symbols \
    --inline-functions \
    > cpu.pprof

# 3. 検証
go tool pprof -raw cpu.pprof | grep "start_line"

# 4. PGOで使用
go build -pgo=cpu.pprof

カスタム変換スクリプト(Python例):

# custom_to_pprof.py
from google.protobuf import text_format
from pprof import profile_pb2

def convert_to_pprof(custom_profile):
    """カスタムプロファイルをpprof形式に変換"""
    
    profile = profile_pb2.Profile()
    
    # サンプルタイプ設定(必須)
    sample_type = profile.sample_type.add()
    sample_type.type = profile.string_table.add()
    profile.string_table[-1] = "cpu"
    sample_type.unit = profile.string_table.add()
    profile.string_table[-1] = "nanoseconds"
    
    # 各サンプルを変換
    for custom_sample in custom_profile.samples:
        sample = profile.sample.add()
        
        # スタックトレース
        for frame in custom_sample.stack:
            location = profile.location.add()
            location.id = len(profile.location)
            
            line = location.line.add()
            
            # 関数情報(重要)
            function = profile.function.add()
            function.id = len(profile.function)
            function.name = add_string(profile, frame.function_name)
            function.filename = add_string(profile, frame.filename)
            function.start_line = frame.start_line  # ← 必須!
            
            line.function_id = function.id
            line.line = frame.line_number
            
            sample.location_id.append(location.id)
        
        # サンプル値
        sample.value.append(custom_sample.cpu_time_ns)
    
    return profile

def add_string(profile, s):
    """文字列をstring_tableに追加"""
    profile.string_table.append(s)
    return len(profile.string_table) - 1

検証方法

変換したプロファイルが正しいか確認:

#!/bin/bash
# validate_profile.sh

PROFILE="$1"

echo "=== 検証開始: $PROFILE ==="

# 1. サンプルタイプ確認
echo "
[1] サンプルタイプ確認"
go tool pprof -raw "$PROFILE" | grep "SampleType" | head -5

# 期待: "cpu nanoseconds" または "samples count"

# 2. シンボル化確認
echo "
[2] シンボル化確認"
go tool pprof -top "$PROFILE" | head -10

# 期待: 関数名が表示される(0x...などのアドレスではない)

# 3. start_line確認
echo "
[3] start_line確認"
go tool pprof -raw "$PROFILE" | grep "start_line" | head -5

# 期待: start_line フィールドが存在する

# 4. インライン関数確認
echo "
[4] インライン情報確認"
go tool pprof -raw "$PROFILE" | grep "Line {" -A 2 | head -20

# 期待: 複数の Line エントリ(インライン関数を含む)

echo "
=== 検証完了 ==="

トラブルシューティング

問題1:start_lineがない

# 症状
go tool pprof -raw profile.pprof | grep start_line
# 何も表示されない

# 原因
変換ツールがstart_lineを出力していない

# 解決策(Go 1.21以降)
# DWARF情報から抽出するツールを使用
# または変換スクリプトを修正

問題2:インライン情報がない

# 症状
同じ関数が何度も出てくる、スタックが浅い

# 原因
perfなどがインライン関数を展開していない

# 解決策
perf record --call-graph dwarf  # DWARF使用
# または変換時に --inline-functions フラグ

問題3:シンボル化されていない

# 症状
go tool pprof -top profile.pprof
# 0x4a5b3c などのアドレスが表示される

# 原因
バイナリのシンボル情報がない、または変換時に解決していない

# 解決策
# ストリップされていないバイナリを使用
go build  # -ldflags="-s -w" なし
# 変換時にバイナリを指定
perf script | pprof-from-perf --binary=./myapp

互換性のあるツール

Go Wiki PGO Toolsページで確認されているツール:

✓ Linux perf (要変換)
✓ Google Cloud Profiler
✓ Datadog Continuous Profiler
✓ Pyroscope
✓ Parca
✓ Polar Signals Cloud

詳細: https://go.dev/wiki/PGO-Tools

チェックリスト:代替プロファイル使用時

  • ✅ サンプルタイプが”cpu/nanoseconds”または”samples/count”
  • ✅ CPU時間を測定している(壁時計時間ではない)
  • ✅ シンボル化されている(関数名が解決済み)
  • ✅ インライン関数の情報を含む
  • ✅ start_lineフィールドが設定されている
  • ✅ 変換後に検証スクリプトで確認
  • ❌ メモリプロファイルをCPU用に使わない
  • ❌ アドレスのみのプロファイルは使わない
  • ❌ start_lineなしで諦めない(修正可能)

まとめ

Goのruntime/pprof以外のプロファイリングツール(Linux perf、Google Cloud Profiler、Datadogなど)も、正しく変換すればPGOで使用可能です。重要な要件は、(1)CPUサンプルであること、(2)シンボル化済み、(3)インライン関数情報を含む、(4)start_lineフィールドが設定されていることです。特にstart_lineは多くの変換ツールで欠落しがちなので注意が必要です。変換後は検証スクリプトで要件を満たしているか確認しましょう。組織で既に使っているプロファイリング基盤がある場合、適切な変換を行うことでPGOの恩恵を受けられます。

おわりに 

本日は、Go言語のプロファイルガイド最適化について解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました