目次
21 章

パフォーマンス — cProfile、py-spy、メモリプロファイリング

遅い Python コードを見つけて直す道具箱 — timeit、cProfile、py-spy、line_profiler、memray、そしてよくある最適化パターンまでまとめます。

第3部の最後の章 — パフォーマンス です。「遅いです」という報告を受けたときに、どこがどう遅いかを測定して直す道具箱 を整理します。timeitcProfilepy-spyline_profilermemray、そしてよくある最適化パターンまでです。

本章は第19章 GIL と並行性 と一対です。第19章が「ボトルネックの種類による道具選択」なら、本章は 「ボトルネックがどこかを測定する道具」 です。測定 → ホットスポット分類 → 道具選択 → 再測定の循環がパフォーマンスデバッグの標準フローです。

最初のルール — 測定なしに最適化するな #

有名な引用
"Premature optimization is the root of all evil." — Donald Knuth

読むたびにどこか退屈に聞こえますが、ほぼ常に正しいです。直感で「ここが遅いはず」と当てると 70% は外れます。 測定が最初のステップ。

timeit — 小さな単位の測定 #

timeit
import timeit

# 1 行の測定
t = timeit.timeit("sum(range(1000))", number=10_000)
print(f"平均 {t / 10_000 * 1e6:.2f} μs/回")

# セットアップコード
t = timeit.timeit(
    stmt="d.get('key')",
    setup="d = {'key': 1}",
    number=1_000_000,
)

小さな単位の比較 — 「list comprehension が速いか map が速いか」、「f-string が + より速いか」のような場面。

CLI でも可能。

CLI
python -m timeit -s "import json" "json.dumps({'a': 1})"
# 1000000 loops, best of 5: 322 ns per loop

cProfile — 関数単位のプロファイリング #

CPU 時間がどこに使われているかを 関数別 に見せてくれます。

cProfile 実行
python -m cProfile -s cumulative myapp.py
# cumulative 時間順にソート

またはコード内で。

コード内で
import cProfile
import pstats

with cProfile.Profile() as pr:
    do_work()

stats = pstats.Stats(pr).sort_stats("cumulative")
stats.print_stats(20)    # top 20

出力。

cProfile の出力
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.001    0.001    2.345    2.345 myapp.py:10(main)
     1000    0.500    0.001    1.800    0.002 myapp.py:50(process_item)
   100000    0.700    0.000    0.700    0.000 myapp.py:80(parse_line)

読み方。

  • tottime — その関数の本体で直接使った時間 (子関数を除く)
  • cumtime — その関数 + 子関数すべての累積時間
  • ncalls — 呼び出し回数

ホットスポット候補: tottime が大きいもの、または cumtime が大きい関数の親。

可視化 — snakeviz #

snakeviz
uv add --dev snakeviz
python -m cProfile -o profile.out myapp.py
uvx snakeviz profile.out

ブラウザで flame graph に似た形で関数呼び出しツリーを見ます。テキスト出力より直感的です。

py-spy — 実行中のプロセスのプロファイリング #

cProfile の欠点: コードを修正して包む必要があります。実行中のプロダクションプロセス にアタッチしたいときは py-spy が答えです。

py-spy
uvx py-spy@latest top --pid 12345
# または新規プロセスを開始
uvx py-spy@latest record -o flame.svg -- python myapp.py

top モード: リアルタイムの関数別 CPU 使用率 (top コマンドのように) record モード: 一定時間記録した後 flame graph SVG を生成

py-spy の価値。

  • ソースの修正不要
  • サンプリングベース — オーバーヘッドが非常に低い (5 ~ 10%)
  • C 拡張 も見える — NumPy 内部なども分析可能
  • GIL 保持時間 も表示 — --idle オプションで idle 分析

Production / staging で「いま何が遅いか」を即席で見る道具です。

line_profiler — 行単位のプロファイリング #

cProfile は関数単位。関数の中のどの行が遅いか を見たいとき。

line_profiler
uv add --dev line_profiler

対象の関数に @profile デコレータをつけます (line_profiler が注入)。

対象の関数
@profile
def process(items):
    parsed = [parse(x) for x in items]    # 行ごとの時間測定
    filtered = [x for x in parsed if x.valid]
    return filtered
実行
uv run kernprof -l -v myapp.py

出力。

line_profiler の出力
Line #      Hits         Time  Per Hit  % Time  Line Contents
==============================================================
     2         1     1234567.0  1234567.0   85.3      parsed = [parse(x) for x in items]
     3         1      200000.0   200000.0   13.8      filtered = [x for x in parsed if x.valid]
     4         1       12000.0    12000.0    0.8      return filtered

関数のどの行が時間の比重を占めているかが一目で。細かい最適化 に有用です。ただし測定オーバーヘッドが大きいので (計測コードを挿入)、ホットスポットが絞られた後に使ってください。

メモリプロファイリング — memray #

CPU と同じくらい頻繁に測定すべきなのが メモリ です。Bloomberg の memray が標準の道具として位置づけられました。

memray
uv add --dev memray
uv run memray run myapp.py     # *.bin 生成
uv run memray flamegraph output.bin  # HTML レポート

メモリリーク追跡、peak メモリ使用場所、allocation の呼び出しツリー — ネイティブメモリまで追跡します。

tracemalloc — 標準ライブラリ #

追加インストールなしで可能な軽い道具。

tracemalloc
import tracemalloc

tracemalloc.start()

# ... 作業 ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
    print(stat)

いまメモリをどこで一番多く掴んでいるかを行単位で見せてくれます。軽い最初のステップとして良いです。

CPython のよくあるパフォーマンスの罠 #

1) グローバル変数 vs ローカル変数 #

関数の中でグローバルを頻繁に参照すると遅いです。変数に一度受け取って使う方が速いです。

🚫 グローバルの繰り返し参照
def process(items):
    return [math.sqrt(x) for x in items]  # math、math.sqrt を毎回 lookup

# ✅ ローカルに受け取る
def process(items):
    sqrt = math.sqrt
    return [sqrt(x) for x in items]

小さな差ですがホットループでは意味があります。

2) += で文字列累積 #

🚫 O(n²)
result = ""
for s in strings:
    result += s    # 毎回新しい文字列を生成

# ✅ join — O(n)
result = "".join(strings)

文字列は不変なので += が毎回新しいオブジェクトを作ります。大きな文字列になるほど恐ろしく遅くなります。

3) リストに in で検索 #

🚫 list の in — O(n)
if x in big_list:    # すべての要素を比較

# ✅ set に変換 — O(1)
big_set = set(big_list)
if x in big_set:

検索頻度が高ければ set / dict に変えてください。

4) 間違ったデータ構造 #

仕事データ構造
両端で push / popcollections.deque (list の pop(0) は O(n))
ソート維持の挿入bisect モジュール
カウントcollections.Counter
優先度キューheapq
デフォルト値の dictcollections.defaultdict

Python の標準ライブラリにほぼ全部あります — 作らずに持ってきて使ってください。

NumPy / ベクトル化 #

数値計算なら ループの代わりに NumPy がほぼ常に速いです。

🚫 Python のループ
result = [a[i] * b[i] for i in range(len(a))]
✅ NumPy のベクトル化
import numpy as np
result = np.array(a) * np.array(b)    # C レベルで同時処理

100 倍 ~ 1000 倍の差が出る場合がよくあります。ただし データ変換のコスト があるので、小さな配列ではむしろ遅くなる可能性があります。測定してから適用。

キャッシング — functools.cache #

第12章 デコレータパターン で見た道具。同じ引数で繰り返し呼ばれる純粋関数に最も効果的な最適化。

cache
from functools import cache

@cache
def expensive(n: int) -> int:
    ...

関数が 純粋 でなければならず、引数が hashable でなければなりません。

__slots__ — インスタンスメモリの節約 #

第8章 dataclass と __slots__ で見た道具。オブジェクトを数万個作るなら最も大きな効果を得られます。

dataclass(slots=True)
@dataclass(slots=True)
class Point:
    x: float
    y: float

インスタンスあたり 40 ~ 50% メモリ節約、属性アクセス 10 ~ 25% 加速。

Cython / Rust 拡張 — 最後の武器 #

純粋 Python でだめなら C レベルに降ります。

  • Cython — Python に似た構文で C コンパイル。漸進的な変換が可能。
  • PyO3 (Rust) — Rust で拡張モジュールを作成。maturin ビルドツール。
  • mypyc — 型ヒントのある Python を C にコンパイル (mypy 自体がこの方式)。

共通ルール: ホットスポットのみ。コード全体を移すのではなく、cProfile で見つけた狭い部分だけを移すのがコスト対効果が大きいです。

他のインタープリタ — 一度点検 #

  • PyPy — JIT コンパイラを持つ別の実装。純粋 Python コードはしばしば 5 ~ 10 倍速くなります。C 拡張の互換性が弱点なので、NumPy / Pandas ヘビーなコードには合いません。
  • Free-threaded CPython (第19章 GIL と並行性) — シングルスレッド 5 ~ 10% 損失、マルチスレッドで大きな利得。

状況によってはインタープリタ自体を変えるのが最大の変化になることがあります。

非同期のパフォーマンス #

第18章 非同期の深さ の非同期コードのパフォーマンスを測るとき。

asyncio デバッグ + プロファイル
PYTHONASYNCIODEBUG=1 uvx py-spy@latest record -o async.svg -- python app.py

py-spy は非同期コードもよく分析します。どのコルーチンがどこで await に縛られているかを見せてくれます。

実戦 — パフォーマンスデバッグのフロー #

  1. 再現可能なベンチマーク — 同じ入力で同じ結果を同じ時間に出さないと測定の意味がない
  2. time で全体時間の確認 — 1 秒なのか 1 分なのかで道具選択
  3. cProfile または py-spy でホットスポットを探す
  4. line_profiler でホット関数の中の行単位分析
  5. よくある罠の点検 — list in、グローバル lookup、文字列累積
  6. データ構造の変更 — set / deque / Counter など
  7. ベクトル化 — NumPy が適用可能か
  8. キャッシング — 同じ引数の繰り返し呼び出しか
  9. C レベル拡張 — 最後の手段

各ステップで 再度測定 して本当に速くなったか確認。「これが速いはず」という直感は頻繁に外れます。

練習問題 #

  1. += で 1 万個の文字列を累積するコードと "".join() でつなげるコードの時間を timeit で比較してください。n を 10 / 100 / 10000 / 100000 と増やしながら O(n²) vs O(n) の差がどこではっきり現れるか観察します。
  2. cProfile を実際のコードにつけてみてください。(自分のコードがなければ第7章の mathkit のような簡単なモジュール) そして snakeviz で可視化。最も累積時間の大きい関数を見つけて 1 行の最適化 (データ構造変更など) をしたあと、再度測定して効果を確認します。
  3. tracemalloc で自分のコードでメモリを最も多く掴んでいる行を探してみてください。同じ仕事を memray で再度測定して両方の道具の出力の差を比較します。

一行まとめ: 測定のない最適化は 70% が無駄。timeit (マイクロ) / cProfile + snakeviz (関数) / py-spy (実行中) / line_profiler (行) / memray + tracemalloc (メモリ) の道具箱。よくある罠はグローバル lookup・文字列 +=・list in・間違ったデータ構造。データ構造 (deque / bisect / Counter / heapq / defaultdict) → ベクトル化 (NumPy) → キャッシング (@cache) → __slots__ → C 拡張 (Cython / PyO3 / mypyc) → インタープリタ (PyPy / free-threaded)。各段階で再測定。

第3部のまとめ #

第3部 7 章を経て 深さ・並行性の道具箱 が埋まりました。

  • マジックメソッド — オブジェクトと言語が出会うフック
  • ディスクリプタ — 属性をオブジェクト化
  • メタクラス — クラスを作るクラス (普段は使わない)
  • 非同期の深さ — イベントループ、Future / Task、async generator
  • GIL と並行性 — threading vs multiprocessing vs asyncio + free-threaded
  • typing 上級 — variance、ParamSpec、TypeIs、overload、Annotated
  • パフォーマンス — 測定の道具と最適化のパターン

これで第1部 (入門) → 第2部 (構造化) → 第3部 (深さ・並行性) の 21 章が完了。次の第4部 実戦 FastAPI は、これまで積み上げてきた道具が 1 つのプロジェクトに集まる段階です。

次の章 #

次の 第22章 FastAPI 開始とセットアップ が第4部の始まり — 実戦 FastAPI 6 章 + 新規 2 章 (第24章 Pydantic v2 の深さ、第29章 総合実習 — TODO API を完成させる) の最初の章です。Hello FastAPI、OpenAPI 自動生成、uv で最初のプロジェクトセットアップまでです。

X