目次
読了時間: 約 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 プロファイラ | メモリプロファイラ |
|---|---|---|
| Go | pprof(内蔵) | pprof heap |
| Rust | perf, samply | Valgrind, DHAT |
| Python | cProfile, py-spy | memory_profiler |
| Java | JProfiler, async-profiler | VisualVM, JFR |
| Node.js | Chrome DevTools, 0x | heapdump |
| C/C++ | perf, VTune | Valgrind, 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) | 1 | 1 | ハッシュテーブル lookup |
| O(log n) | 7 | 20 | 二分探索 |
| O(n) | 100 | 1,000,000 | 線形探索 |
| O(n log n) | 700 | 20,000,000 | マージソート |
| O(n²) | 10,000 | 1,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 を監視: 平均ではなくテールレイテンシ
パフォーマンス最適化は「科学」だ。仮説→実験→計測→検証のサイクルを回す。感覚や推測を排し、データに基づいた最適化を行う。
参考リンク
- Systems Performance (Brendan Gregg) — システムパフォーマンスのバイブル
- Go pprof documentation — Go 標準プロファイラ
- perf Wiki — Linux パフォーマンスツール
- USE Method (Brendan Gregg) — パフォーマンス分析方法論
免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。