目次

読了時間: 約 15 分 | 文字数: 約 5,500 字

「パフォーマンスの問題は、書くのではなく測るものだ」——これは最適化の鉄則だ。感覚や推測で最適化すると、効果のない場所に時間を浪費し、真のボトルネックを見逃す。本稿では、システマティックなパフォーマンス改善のフレームワークを解説する。

パフォーマンス最適化の原則

最適化の黄金律

1. まず計測せよ(プロフィール)
2. ボトルネックを特定せよ
3. 最も効果の高い箇所から最適化
4. 再度計測して効果を検証

やってはいけないこと:

  • 推測で最適化(「たぶんここが遅い」)
  • premature optimization(过早最適化)
  • 効果測定なしの変更

Amdahl の法則

システム全体の改善は、改善できない部分に制限される

全体高速化 = 1 / [(1 - a) + (a / n)]

a: 改善可能な部分の割合
n: 改善倍率

例:処理の 50% を 10 倍高速化
全体高速化 = 1 / [(1 - 0.5) + (0.5 / 10)]
           = 1 / [0.5 + 0.05]
           = 1 / 0.55
           = 1.82 倍

50% を 10 倍にしても、全体は 1.82 倍しか速くならない

教訓: 広い範囲を少し改善するより、ボトルネックを集中的に改善する。

プロファイリング——計測の技術

プロファイリングのタイプ

タイプ説明ツール例
CPU プロファイリングどの関数が CPU を消費しているかpprof, perf, Instruments
メモリプロファイリングメモリ割り当て・リークValgrind, heap profiler
I/O プロファイリングディスク・ネットワーク待機iostat, Wireshark
アプリケーション計測エンドツーエンドのレイテンシOpenTelemetry, Datadog

サンプリング vs トレース

サンプリングプロファイラ:

仕組み:定期的にスタックサンプルを収集
オーバーヘッド:低い(数%)
用途:本番環境、長時間実行

例:
10ms ごとにスタックをサンプリング:
main() → process() → compute() [70%]
                          → allocate() [10%]
         → log() [20%]

トレースプロファイラ:

仕組み:全ての関数呼び出しを記録
オーバーヘッド:高い(10-100%)
用途:詳細分析、開発環境

例:
function calls:
  main (1, 100ms)
    process (1, 95ms)
      compute (100, 70ms)
      allocate (50, 10ms)
    log (50, 20ms)

主要言語のプロファイリングツール

言語CPU プロファイラメモリプロファイラ
Gopprof(内蔵)pprof heap
Rustperf, samplyValgrind, DHAT
PythoncProfile, py-spymemory_profiler
JavaJProfiler, async-profilerVisualVM, JFR
Node.jsChrome DevTools, 0xheapdump
C/C++perf, VTuneValgrind, AddressSanitizer

プロファイリング実例(Go)

// メイン関数に追加
import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    // pprof エンドポイントを起動
    go http.ListenAndServe("localhost:6060", nil)

    // アプリケーション処理
    runApplication()
}
# CPU プロファイル収集
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# ヒーププロファイル
go tool pprof http://localhost:6060/debug/pprof/heap

# フレームグラフ表示
(pprof) web

メモリ最適化

メモリ使用パターンの理解

スタック vs ヒープ:

スタック:
- 自動管理(スコープ解放で)
- 高速(ポインタ移動のみ)
- サイズ制限(数 MB)
- 局所性高い

ヒープ:
- 手動/GC 管理
- 比較的低速(割り当て・解放)
- 大容量可能
- 断片化リスク

一般的なメモリ問題

問題説明影響
メモリリーク解放忘れ、参照され続ける最終的に OOM
断片化割り当て・解放の繰り返し利用可能メモリ減少
過剰割り当て不必要な大きいバッファメモリ圧迫、GC 負荷
キャッシュ非効率ランダムアクセスCPU キャッシュミス

最適化テクニック

1. オブジェクトプーリング

// 高頻度割り当てオブジェクトを再利用
var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 4096)
    },
}

func process() {
    buf := bufferPool.Get().([]byte)
    defer bufferPool.Put(buf)
    // buf を使用
}

効果: GC 負荷軽減、割り当てコスト削減

2. 事前割り当て(Pre-allocation)

// ❌ 成長に伴い再割り当て
var result []int
for i := 0; i < 1000000; i++ {
    result = append(result, i)
}

// ○ 事前確保
result := make([]int, 0, 1000000)
for i := 0; i < 1000000; i++ {
    result = append(result, i)
}

効果: 再割り当て回数削減(n 回→log n 回)

3. 値セマンティクス

// ❌ ポインタ渡し(ヒープ割り当て)
func process(data *LargeStruct) {
    // 読み取りのみ
}

// ○ 値渡し(スタックコピー)
func process(data LargeStruct) {
    // 読み取りのみ
}

効果: ヒープ割り当て削減、GC 負荷軽減

4. ストリーミング処理

// ❌ 全量メモリ読み込み
data, _ := ioutil.ReadFile("large.txt")
process(data) // 数 GB

// ○ ストリーミング
file, _ := os.Open("large.txt")
scanner := bufio.NewScanner(file)
for scanner.Scan() {
    process(scanner.Bytes()) // 1 行ずつ
}

効果: メモリ使用量 O(n)→O(1)

CPU 最適化

計算量の削減

アルゴリズム改善が最大の効果

計算量100 要素100 万要素
O(1)11ハッシュテーブル lookup
O(log n)720二分探索
O(n)1001,000,000線形探索
O(n log n)70020,000,000マージソート
O(n²)10,0001,000,000,000,000バブルソート
// ❌ O(n²)
func findDuplicates(items []int) []int {
    var duplicates []int
    for i := 0; i < len(items); i++ {
        for j := i + 1; j < len(items); j++ {
            if items[i] == items[j] {
                duplicates = append(duplicates, items[i])
            }
        }
    }
    return duplicates
}

// ○ O(n)
func findDuplicatesOptimized(items []int) []int {
    seen := make(map[int]bool)
    var duplicates []int
    for _, item := range items {
        if seen[item] {
            duplicates = append(duplicates, item)
        }
        seen[item] = true
    }
    return duplicates
}

キャッシュ最適化

CPU キャッシュを意識したデータ配置

CPU キャッシュ階層:
L1: 32-64KB, 1-2 cycle
L2: 256KB-1MB, 10-20 cycles
L3: 数 MB-数十 MB, 30-50 cycles
メモリ: 数十 GB, 100+ cycles

キャッシュライン:64 バイト単位

データ構造の最適化:

// ❌ キャッシュ非効率(間接参照)
type Node struct {
    Value int
    Next  *Node
}
// メモリ上に散在、ポインタ chasing

// ○ キャッシュ効率的(連続メモリ)
type Array struct {
    Values []int
}
// 連続メモリ、プリフェッチ効果

並列化

// シリアル処理(遅い)
func processAll(items []int) []int {
    results := make([]int, len(items))
    for i, item := range items {
        results[i] = heavyComputation(item)
    }
    return results
}

// 並列処理(速い)
func processAllParallel(items []int) []int {
    results := make([]int, len(items))
    numWorkers := runtime.NumCPU()
    chunkSize := (len(items) + numWorkers - 1) / numWorkers

    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(start int) {
            defer wg.Done()
            end := start + chunkSize
            if end > len(items) {
                end = len(items)
            }
            for j := start; j < end; j++ {
                results[j] = heavyComputation(items[j])
            }
        }(i * chunkSize)
    }
    wg.Wait()
    return results
}

注意: 並列化オーバーヘッド、競合状態に注意。

I/O 最適化

バッファリング

// ❌ 都度書き込み(遅い)
for i := 0; i < 1000000; i++ {
    file.Write([]byte(fmt.Sprintf("%d\n", i)))
}

// ○ バッファリング
writer := bufio.NewWriter(file)
for i := 0; i < 1000000; i++ {
    writer.WriteString(fmt.Sprintf("%d\n", i))
}
writer.Flush() // 最後に一括書き込み

非同期 I/O

# シンクロナス(遅い)
import requests
urls = [...]
for url in urls:
    response = requests.get(url)  # 順番待ち
    process(response)

# アシンクロナス(速い)
import aiohttp
import asyncio

async def fetch(session, url):
    async with session.get(url) as response:
        return await process(response)

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)  # 並列実行

コネクションプーリング

# ❌ 都度接続
for url in urls:
    with requests.get(url) as response:
        process(response)
    # 接続クローズ

# ○ プーリング
session = requests.Session()
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=100)
session.mount('http://', adapter)

for url in urls:
    with session.get(url) as response:
        process(response)
# 接続再利用

キャッシング戦略

キャッシュの階層

CPU レジスタ(1 cycle)
    ↓
L1 キャッシュ(数 cycle)
    ↓
L2 キャッシュ(数十 cycle)
    ↓
L3 キャッシュ(百 cycle)
    ↓
メインメモリ(数百 cycle)
    ↓
SSD(数万 cycle)
    ↓
ネットワーク(数百万 cycle)

キャッシングパターン

1. メモ化(Memoization)

from functools import lru_cache

@lru_cache(maxsize=1000)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# 初回計算:O(2^n) → キャッシュ後:O(1)

2. ライトスルー

書き込み:アプリ → キャッシュ → DB
読み込み:アプリ ← キャッシュ ← DB

メリット:一貫性高い
デメリット:書き込みレイテンシ

3. ライトバック

書き込み:アプリ → キャッシュ(後で DB に)
読み込み:アプリ ← キャッシュ

メリット:書き込み高速
デメリット:クラッシュでデータ消失リスク

4. キャッシュアサイド(サイドキャッシュ)

def get_user(user_id):
    # 1. キャッシュ確認
    user = cache.get(f"user:{user_id}")
    if user:
        return user

    # 2. DB から取得
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)

    # 3. キャッシュに保存
    cache.set(f"user:{user_id}", user, ttl=3600)

    return user

キャッシュ無効化戦略

戦略説明用途
TTL時間経過で無効化頻繁に更新されないデータ
ライトスルー更新時に自動無効化一貫性重視
マニュアル明示的に無効化ビジネスロジック依存
バージョニングキーにバージョン含むデプロイ時一括無効化

パフォーマンス計測指標

レイテンシー指標

平均(Mean): 合計値÷サンプル数
中央値(Median/p50): 50% のリクエストがこの値以下
p95: 95% のリクエストがこの値以下
p99: 99% のリクエストがこの値以下
p99.9: 99.9% のリクエストがこの値以下(テールレイテンシ)

p99 が重要な理由:

平均: 100ms
p99: 5000ms

「平均 100ms」でも、100 件中 1 件が 5 秒かかる
ユーザー体験はテールレイテンシで決まる

スループット指標

QPS(Queries Per Second): 秒間処理数
RPS(Requests Per Second): 秒間リクエスト数
TPS(Transactions Per Second): 秒間トランザクション数

リソース指標

CPU 使用率: 0-100%
メモリ使用量: RSS、VMS
ディスク I/O: IOPS、スループット
ネットワーク:バンド幅、パケットレート

So What?——実務への応用

  • プロファイリングから始める: 計測なし最適化は禁止
  • アルゴリズム改善が最大: O(n²)→O(n) は 100-10000 倍
  • キャッシングは効果大: DB アクセス削減は桁違い
  • I/O は非同期・プール: 待機時間を有効活用
  • メモリアロケーション削減: 事前確保、プーリング
  • p99 を監視: 平均ではなくテールレイテンシ

パフォーマンス最適化は「科学」だ。仮説→実験→計測→検証のサイクルを回す。感覚や推測を排し、データに基づいた最適化を行う。

参考リンク

免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。