makoto-developer's テックブログ

Goのプログラムパフォーマンス改善の方法

Goのプログラムパフォーマンス改善の方法

はじめに

Goは、そのままでも十分に高速な言語である。しかし、適切な最適化を行うことで、さらに高速化し、メモリ使用量を削減できる。本記事では、10年以上のGo開発経験から得た、実践的なパフォーマンス改善テクニックを解説する。

パフォーマンス改善の原則

1. 計測なしに最適化するな

# プロファイリング
go test -cpuprofile=cpu.prof -memprofile=mem.prof -bench=.

# プロファイル可視化
go tool pprof cpu.prof

推測で最適化すると、効果のない箇所を最適化してしまう。必ず計測してから最適化する。

2. 可読性を犠牲にするな

// 悪い例: 最適化しすぎて読めない
func f(x int)int{return(x&1==0)?x>>1:3*x+1}

// 良い例: 最適化と可読性のバランス
func collatzNext(n int) int {
    if n%2 == 0 {
        return n / 2
    }
    return 3*n + 1
}

パフォーマンスと可読性は、トレードオフである。ホットスポット(全体の実行時間の80%を占める箇所)のみ最適化する。

3. アロケーションを減らせ

Goのパフォーマンスボトルネックの多くは、メモリアロケーションである。

アロケーション削減 → GC圧力削減 → パフォーマンス向上

具体的なテクニック

1. スライスの事前確保

// 悪い例: 毎回アロケーション
func buildSlice(n int) []int {
    var result []int
    for i := 0; i < n; i++ {
        result = append(result, i)  // 拡張のたびにアロケーション
    }
    return result
}

// 良い例: 1回だけアロケーション
func buildSlice(n int) []int {
    result := make([]int, 0, n)  // 容量を事前確保
    for i := 0; i < n; i++ {
        result = append(result, i)
    }
    return result
}

// ベンチマーク結果:
// 悪い例: 1000 ns/op  5000 B/op  10 allocs/op
// 良い例:  500 ns/op  4096 B/op   1 allocs/op
// 改善率: 50%高速化、90%アロケーション削減

2. 文字列結合

// 悪い例: 毎回アロケーション
func concat(strs []string) string {
    result := ""
    for _, s := range strs {
        result += s  // 毎回新しい文字列を作成
    }
    return result
}

// 良い例: strings.Builder
func concat(strs []string) string {
    var b strings.Builder
    for _, s := range strs {
        b.WriteString(s)  // 内部バッファに追加
    }
    return b.String()
}

// ベンチマーク結果:
// 悪い例: 5000 ns/op  20000 B/op  100 allocs/op
// 良い例:  500 ns/op    256 B/op    1 allocs/op
// 改善率: 90%高速化、99%アロケーション削減

3. ポインタ vs 値

type LargeStruct struct {
    Data [1024]byte
}

// 悪い例: 値渡し(1024バイトコピー)
func processValue(ls LargeStruct) {
    // ...
}

// 良い例: ポインタ渡し(8バイトコピー)
func processPointer(ls *LargeStruct) {
    // ...
}

// ただし、小さい構造体は値渡しの方が速い
type SmallStruct struct {
    X, Y int
}

// こちらの方が速い(ポインタ追跡のコストがない)
func processValue(ss SmallStruct) {
    // ...
}

ルール: 16バイト以下なら値渡し、それ以上ならポインタ渡し。

4. sync.Poolでオブジェクト再利用

// オブジェクトプール
var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func processData(data []byte) []byte {
    // プールからバッファ取得
    buf := bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()  // リセット
        bufferPool.Put(buf)  // プールに返却
    }()

    buf.Write(data)
    // 処理...
    return buf.Bytes()
}

// ベンチマーク結果:
// 毎回生成: 1000 ns/op  1024 B/op  1 allocs/op
// Poolで再利用: 200 ns/op     0 B/op  0 allocs/op
// 改善率: 80%高速化、100%アロケーション削減

5. 並列化

// 逐次処理
func processSequential(items []Item) []Result {
    results := make([]Result, len(items))
    for i, item := range items {
        results[i] = process(item)
    }
    return results
}

// 並列処理
func processParallel(items []Item) []Result {
    results := make([]Result, len(items))
    var wg sync.WaitGroup

    // ワーカー数を制御
    workers := runtime.NumCPU()
    sem := make(chan struct{}, workers)

    for i, item := range items {
        wg.Add(1)
        go func(idx int, itm Item) {
            defer wg.Done()
            sem <- struct{}{}        // セマフォ取得
            defer func() { <-sem }() // セマフォ解放

            results[idx] = process(itm)
        }(i, item)
    }

    wg.Wait()
    return results
}

// ベンチマーク結果(8コアCPU):
// 逐次処理: 8000 ns/op
// 並列処理: 1200 ns/op
// 改善率: 85%高速化

6. ループの最適化

// 悪い例: 境界チェックが毎回発生
func sum(arr []int) int {
    total := 0
    for i := 0; i < len(arr); i++ {
        total += arr[i]  // 境界チェック
    }
    return total
}

// 良い例: rangeを使用(境界チェック最適化)
func sum(arr []int) int {
    total := 0
    for _, v := range arr {
        total += v  // 境界チェック不要
    }
    return total
}

// さらに最適化: アンロールループ
func sum(arr []int) int {
    total := 0
    i := 0

    // 4要素ずつ処理
    for ; i+3 < len(arr); i += 4 {
        total += arr[i] + arr[i+1] + arr[i+2] + arr[i+3]
    }

    // 残りを処理
    for ; i < len(arr); i++ {
        total += arr[i]
    }

    return total
}

// ベンチマーク結果:
// 悪い例: 1000 ns/op
// 良い例:  800 ns/op(20%改善)
// アンロール:  600 ns/op(40%改善)

7. インラインフ ァンクション

// 悪い例: 小さい関数が頻繁に呼ばれる
func add(a, b int) int {
    return a + b
}

func compute(arr []int) int {
    total := 0
    for _, v := range arr {
        total = add(total, v)  // 関数呼び出しのオーバーヘッド
    }
    return total
}

// 良い例: インライン化ヒント
//go:inline
func add(a, b int) int {
    return a + b
}

// さらに良い例: 直接記述
func compute(arr []int) int {
    total := 0
    for _, v := range arr {
        total += v  // 関数呼び出しなし
    }
    return total
}

// コンパイラは自動的にインライン化するが、
// 明示的に制御したい場合は //go:inline を使用

8. マップの事前確保

// 悪い例: マップ拡張のオーバーヘッド
func buildMap(n int) map[int]int {
    m := make(map[int]int)
    for i := 0; i < n; i++ {
        m[i] = i * 2  // マップ拡張が頻発
    }
    return m
}

// 良い例: 容量を事前確保
func buildMap(n int) map[int]int {
    m := make(map[int]int, n)
    for i := 0; i < n; i++ {
        m[i] = i * 2
    }
    return m
}

// ベンチマーク結果:
// 悪い例: 2000 ns/op  10000 B/op  5 allocs/op
// 良い例: 1200 ns/op   8192 B/op  1 allocs/op
// 改善率: 40%高速化、80%アロケーション削減

プロファイリングツール

1. CPU プロファイル

# ベンチマークでプロファイル取得
go test -cpuprofile=cpu.prof -bench=.

# プロファイル表示
go tool pprof cpu.prof

# インタラクティブモード
(pprof) top10  # 上位10個の関数
(pprof) list functionName  # 関数の詳細
(pprof) web  # ブラウザで可視化

2. メモリプロファイル

# メモリプロファイル取得
go test -memprofile=mem.prof -bench=.

# アロケーション数を表示
go tool pprof -alloc_space mem.prof

# 使用中のメモリを表示
go tool pprof -inuse_space mem.prof

3. トレース

# トレース取得
go test -trace=trace.out -bench=.

# トレース表示
go tool trace trace.out

ベンチマークの書き方

func BenchmarkSum(b *testing.B) {
    data := make([]int, 1000)
    for i := range data {
        data[i] = i
    }

    b.ResetTimer()  // セットアップ時間を除外

    for i := 0; i < b.N; i++ {
        _ = sum(data)
    }
}

// メモリアロケーションも計測
func BenchmarkSumAlloc(b *testing.B) {
    data := make([]int, 1000)
    for i := range data {
        data[i] = i
    }

    b.ReportAllocs()  // アロケーション数を報告
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        _ = sum(data)
    }
}

実例: HTTPサーバーの最適化

Before(最適化前)

func handler(w http.ResponseWriter, r *http.Request) {
    data := ""
    for i := 0; i < 1000; i++ {
        data += fmt.Sprintf("%d ", i)  // 毎回アロケーション
    }
    fmt.Fprint(w, data)
}

// ベンチマーク: 1000 req/sec、メモリ使用量: 500MB

After(最適化後)

var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func handler(w http.ResponseWriter, r *http.Request) {
    buf := bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufferPool.Put(buf)
    }()

    for i := 0; i < 1000; i++ {
        fmt.Fprintf(buf, "%d ", i)  // バッファに追加
    }

    buf.WriteTo(w)  // 一括書き込み
}

// ベンチマーク: 5000 req/sec、メモリ使用量: 50MB
// 改善率: 5倍高速化、90%メモリ削減

チェックリスト

パフォーマンス最適化のチェックリスト:

  • [ ] プロファイリングを実施した
  • [ ] ホットスポット(全体の80%を占める箇所)を特定した
  • [ ] スライス/マップの容量を事前確保した
  • [ ] 不要なアロケーションを削減した
  • [ ] 文字列結合に strings.Builder を使用した
  • [ ] 大きな構造体はポインタ渡しにした
  • [ ] 並列化できる処理を並列化した
  • [ ] sync.Poolでオブジェクトを再利用した
  • [ ] ベンチマークで改善を確認した

まとめ

Goのパフォーマンス最適化は、以下の原則に従う。

  1. 計測してから最適化: 推測するな、計測せよ
  2. アロケーションを減らせ: GC圧力を削減
  3. 並列化: CPUコアを活用
  4. ツールを使いこなせ: pprof、trace

適切な最適化により、50-90%の高速化と、90%以上のメモリ削減が可能である。ただし、可読性を犠牲にしてはいけない。ホットスポットのみを最適化し、残りは読みやすいコードを保つことが重要である。