
こんにちは。よっしーです(^^)
本日は、Go言語のプロファイルガイド最適化ついて解説しています。
背景
Go言語でアプリケーションのパフォーマンスを改善したいと思ったことはありませんか? 公式ドキュメントで「Profile-guided optimization (PGO)」という機能を見かけたものの、英語で書かれていて専門用語も多く、「どういう仕組みなんだろう?」「自分のプロジェクトにどう活用すればいいの?」と戸惑った経験があるかもしれません。
この記事では、Go 1.21から正式サポートされたPGO(プロファイルガイド最適化)について、公式ドキュメントの丁寧な日本語訳と、初心者の方にもわかりやすい補足説明をお届けします。PGOは実行時のプロファイル情報をコンパイラにフィードバックすることで、2〜14%のパフォーマンス向上が期待できる強力な機能です。
「コンパイラ最適化」や「プロファイリング」と聞くと難しそうに感じるかもしれませんが、基本的な使い方はとてもシンプルです。この記事では、PGOの仕組みを身近な例えで説明し、実際にどうやって使うのかを具体的なコード例とともに紹介していきます。一緒に学んでいきましょう!
PGOはバイナリサイズにどのように影響しますか?
PGOは、追加の関数インライン化により、わずかに大きなバイナリになる可能性があります。
解説
重要なポイント:わずかに大きくなる
PGOによるバイナリサイズの変化:
典型的な増加量: 2〜8%
極端なケースでも: 10〜15%
例:
PGOなし: 10.0 MB
PGOあり: 10.5 MB(5%増加)
なぜ大きくなるのか:インライン化
インライン化とは:
関数呼び出しを、関数の中身に置き換えることです。
インライン化なし:
package main
func add(a, b int) int {
return a + b
}
func multiply(a, b int) int {
return a * b
}
func main() {
result := add(10, 20) // 関数呼び出し
result2 := multiply(5, 6) // 関数呼び出し
}
コンパイル後のイメージ(簡略化):
main:
CALL add # add関数を呼び出し
CALL multiply # multiply関数を呼び出し
RET
add:
# 加算処理
RET
multiply:
# 乗算処理
RET
合計サイズ: 小さい(関数呼び出しのみ)
インライン化あり(PGO適用後):
main:
# add関数の中身を直接展開
MOV eax, 10
ADD eax, 20
# multiply関数の中身を直接展開
MOV ebx, 5
IMUL ebx, 6
RET
# add と multiply は別途保持される場合もある
add:
RET
multiply:
RET
合計サイズ: 大きい(コードが重複)
レストランの例え:レシピの書き方
インライン化なし(サイズ小):
メインレシピ:
1. 「ソースの作り方」を見る
2. 「野菜の切り方」を見る
3. 「仕上げ方法」を見る
別ページ:
- ソースの作り方: 詳細手順
- 野菜の切り方: 詳細手順
- 仕上げ方法: 詳細手順
レシピ本全体: 薄い(参照が多い)
インライン化あり(サイズ大):
メインレシピ:
1. ソース: 玉ねぎをみじん切りにして...(全部書く)
2. 野菜: トマトを1cm角に切って...(全部書く)
3. 仕上げ: 塩を一つまみ加えて...(全部書く)
別ページ:
- ソースの作り方: 詳細手順(重複)
- 野菜の切り方: 詳細手順(重複)
- 仕上げ方法: 詳細手順(重複)
レシピ本全体: 厚い(同じ内容が重複)
実際のバイナリサイズ比較
シンプルなWebサーバー:
# プロジェクト構成
myapp/
├── main.go (100行)
├── handler.go (200行)
└── service.go (150行)
# PGOなしでビルド
$ go build -o myapp
$ ls -lh myapp
-rwxr-xr-x 8.2M myapp
# PGOありでビルド
$ go build -pgo=default.pgo -o myapp-pgo
$ ls -lh myapp-pgo
-rwxr-xr-x 8.6M myapp-pgo
# サイズ差
8.6 - 8.2 = 0.4M(約5%増加)
中規模マイクロサービス:
# より複雑なアプリケーション
# 50ファイル、20個の依存パッケージ
# PGOなし
$ go build -o myservice
$ ls -lh myservice
-rwxr-xr-x 24.8M myservice
# PGOあり
$ go build -pgo=default.pgo -o myservice-pgo
$ ls -lh myservice-pgo
-rwxr-xr-x 26.2M myservice-pgo
# サイズ差
26.2 - 24.8 = 1.4M(約6%増加)
なぜPGOでインライン化が増えるのか
通常のコンパイラ判断:
// 小さい関数 → 自動的にインライン化
func small(x int) int {
return x * 2
}
// 大きい関数 → インライン化しない
func large(x int) int {
// 20行の処理...
// 複雑な計算...
return result
}
// 結果: サイズと速度のバランス
PGO適用後:
// プロファイルで「頻繁に呼ばれる」と判明
func frequentlyCalledLarge(x int) int {
// 20行の処理...
// 複雑な計算...
return result
}
// PGOの判断:
// 「大きいけど、頻繁に呼ばれるからインライン化する」
// → サイズは増えるが、速度優先
// 結果: サイズ増、パフォーマンス向上
具体例:頻繁に呼ばれる関数
コード例:
package main
import (
"encoding/json"
"net/http"
)
// この関数が頻繁に呼ばれる(プロファイルで判明)
func parseAndValidate(data []byte) (*Request, error) {
var req Request
if err := json.Unmarshal(data, &req); err != nil {
return nil, err
}
if req.UserID == "" {
return nil, errors.New("user_id required")
}
if req.Action == "" {
return nil, errors.New("action required")
}
// さらに10行のバリデーション...
return &req, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
data, _ := ioutil.ReadAll(r.Body)
req, err := parseAndValidate(data) // ← 頻繁に呼ばれる
// ...
}
PGOなし:
handler関数のサイズ: 小(関数呼び出しのみ)
parseAndValidate関数: 別の場所(インライン化されない)
合計バイナリサイズ: 標準
実行速度: 関数呼び出しオーバーヘッドあり
PGOあり:
handler関数のサイズ: 大(parseAndValidateの中身を含む)
parseAndValidate関数: 保持される(他の場所でも使うかも)
合計バイナリサイズ: 増加(コードが重複)
実行速度: 高速(関数呼び出しなし)
サイズとパフォーマンスのトレードオフ
視覚化:
パフォーマンス向上
↑
15% | ● PGOあり
|
10% |
|
5% | ● PGOなし
|
0% +----------------→
0% 5% 10% 15%
バイナリサイズ増加
結論:
サイズ +5% で パフォーマンス +12%
→ 良いトレードオフ
実際の測定例
ベンチマーク付き比較:
#!/bin/bash
# measure_size_and_performance.sh
echo "=== PGOなし ==="
go build -o app-normal
SIZE_NORMAL=$(ls -l app-normal | awk '{print $5}')
echo "サイズ: $SIZE_NORMAL bytes"
go test -bench=. -benchmem > bench-normal.txt
cat bench-normal.txt
echo ""
echo "=== PGOあり ==="
go build -pgo=default.pgo -o app-pgo
SIZE_PGO=$(ls -l app-pgo | awk '{print $5}')
echo "サイズ: $SIZE_PGO bytes"
go test -bench=. -benchmem > bench-pgo.txt
cat bench-pgo.txt
echo ""
echo "=== 比較 ==="
SIZE_INCREASE=$(echo "scale=2; ($SIZE_PGO - $SIZE_NORMAL) * 100 / $SIZE_NORMAL" | bc)
echo "サイズ増加: ${SIZE_INCREASE}%"
benchstat bench-normal.txt bench-pgo.txt
結果例:
=== PGOなし ===
サイズ: 10485760 bytes (10.0 MB)
BenchmarkHandler-8 100000 12000 ns/op
=== PGOあり ===
サイズ: 11010048 bytes (10.5 MB)
BenchmarkHandler-8 120000 10000 ns/op
=== 比較 ===
サイズ増加: 5.00%
パフォーマンス向上: 16.67%
結論: +0.5MB でレイテンシ -16.67%
サイズが問題になるケース
問題になる可能性があるケース:
1. 組み込みシステム
→ メモリ制約が厳しい
→ 数MBの増加でも問題
2. Lambda/Cloud Functions
→ デプロイサイズ制限
→ コールドスタート時間への影響
3. Dockerイメージ
→ イメージサイズ肥大化
→ レジストリストレージコスト
4. モバイルアプリ
→ ダウンロードサイズ制約
→ ユーザー体験への影響
問題にならないケース:
1. サーバーアプリケーション
→ 通常メモリは十分
→ 5-10%の増加は許容範囲
2. CLI ツール
→ ディスク容量は十分
→ パフォーマンス向上の方が重要
3. バッチ処理
→ 実行速度が最優先
→ サイズはほぼ無視できる
サイズ削減との組み合わせ
PGO + ストリッピング:
# PGOビルド(通常)
go build -pgo=default.pgo -o myapp
ls -lh myapp
# 26.2M
# PGO + デバッグ情報削除
go build -pgo=default.pgo -ldflags="-s -w" -o myapp-stripped
ls -lh myapp-stripped
# 18.5M(約30%削減)
# さらに圧縮
upx --best myapp-stripped
ls -lh myapp-stripped
# 6.2M(さらに削減)
フラグの説明:
-ldflags="-s" # シンボルテーブル削除
-ldflags="-w" # DWARFデバッグ情報削除
-ldflags="-s -w" # 両方削除(推奨)
Dockerイメージでの影響
マルチステージビルド例:
# ビルドステージ
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
# PGOビルド(サイズやや大)
RUN go build -pgo=default.pgo -ldflags="-s -w" -o myapp
# 実行ステージ(最小イメージ)
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/myapp .
CMD ["./myapp"]
イメージサイズ比較:
# PGOなし
docker build -t myapp:normal .
docker images myapp:normal
# myapp:normal latest 15.2MB
# PGOあり
docker build -t myapp:pgo .
docker images myapp:pgo
# myapp:pgo latest 15.8MB
# 差分: 0.6MB(約4%増加)
# Alpine基本イメージは共通なので差は小さい
AWS Lambdaでの考慮事項
Lambda制限:
デプロイパッケージサイズ:
- 直接アップロード: 50MB(圧縮)
- S3経由: 250MB(解凍後)
実際のサイズ例:
PGOなし: 28MB(解凍後)→ 8MB(圧縮)
PGOあり: 30MB(解凍後)→ 8.5MB(圧縮)
結論: 通常は問題なし
Lambdaでのビルド例:
# Lambda用ビルド(PGO + 最適化)
GOOS=linux GOARCH=amd64 go build \
-pgo=default.pgo \
-ldflags="-s -w" \
-tags lambda.norpc \
-o bootstrap
# サイズ確認
ls -lh bootstrap
# 18.2M
# zip圧縮
zip function.zip bootstrap
ls -lh function.zip
# 6.1M(制限内)
モニタリング:サイズの追跡
CI/CDでのサイズモニタリング:
# .github/workflows/build.yml
name: Build and Track Size
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build without PGO
run: |
go build -o app-normal
echo "SIZE_NORMAL=$(stat -c%s app-normal)" >> $GITHUB_ENV
- name: Build with PGO
run: |
go build -pgo=default.pgo -o app-pgo
echo "SIZE_PGO=$(stat -c%s app-pgo)" >> $GITHUB_ENV
- name: Calculate and report
run: |
INCREASE=$(( ($SIZE_PGO - $SIZE_NORMAL) * 100 / $SIZE_NORMAL ))
echo "Binary size increase: ${INCREASE}%"
echo "Normal: $(($SIZE_NORMAL / 1024 / 1024))MB"
echo "PGO: $(($SIZE_PGO / 1024 / 1024))MB"
# アラート(10%超えたら警告)
if [ $INCREASE -gt 10 ]; then
echo "::warning::Binary size increased by more than 10%"
fi
サイズ増加の許容判断
判断フローチャート:
Q1: サイズ制約があるか?
├─ NO → PGO使用推奨(サイズより速度)
└─ YES → Q2へ
Q2: サイズ増加は何%か?
├─ 5%以下 → PGO使用推奨
├─ 5-10% → Q3へ
└─ 10%超 → 慎重に検討
Q3: パフォーマンス向上は?
├─ 15%以上 → PGO使用推奨
├─ 10-15% → ケースバイケース
└─ 10%未満 → PGO不要かも
チェックリスト:サイズへの影響評価
- ✅ サイズ増加は通常5〜8%程度
- ✅ ストリッピングで一部相殺可能
- ✅ パフォーマンス向上がサイズ増を上回る
- ✅ サーバーアプリでは通常問題なし
- ⚠️ Lambda/組み込みでは事前確認
- ⚠️ サイズ制約がある場合は測定必須
- ❌ サイズ増加のみでPGOを避けない
- ❌ サイズと速度のトレードオフを考慮
まとめ
PGOはインライン化を増やすため、バイナリサイズが通常5〜8%増加します。これは関数呼び出しを中身の展開に置き換えることで、速度を優先した結果です。ただし、この増加はパフォーマンス向上(10〜15%)に比べて小さく、多くの場合は良いトレードオフです。サーバーアプリケーションでは通常問題になりませんが、Lambda、組み込みシステム、モバイルアプリなどサイズ制約がある環境では、事前にサイズを測定し、-ldflags="-s -w"などでストリッピングを組み合わせることを検討しましょう。サイズだけを理由にPGOを避けるのではなく、サイズとパフォーマンスのバランスで判断することが重要です。
おわりに
本日は、Go言語のプロファイルガイド最適化について解説しました。

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

コメント