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

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

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

本日は、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言語のプロファイルガイド最適化について解説しました。

よっしー
よっしー

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

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

コメント

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