モダンPython上級 #7 性能 — cProfile、py-spy、メモリプロファイリング

上級シリーズの最後 — 性能 です。「遅いです」という報告を受けたとき、どこがどう遅いかを測定して直す道具箱 を整理します。timeit、cProfile、py-spy、line_profiler、memray、そしてよくある最適化パターンまで。

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

有名な引用
"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 #

中級 #5 で見た道具。同じ引数で繰り返し呼ばれる純粋関数に最も効果的な最適化。

cache
from functools import cache

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

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

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

中級 #1 で見たところ。オブジェクトを数万個作るなら最も大きな効果が出ます。

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 (#5) — シングルスレッドで 5~10% の損失、マルチスレッドで大きな利得。

状況に応じてインタプリタ自体を変えるのが、最も大きな変化になることがあります。

非同期の性能 #

#4 の非同期コードの性能を測るとき:

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 レベル拡張 — 最後の手段

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

まとめ + シリーズの振り返り #

今回見た道具箱:

  • timeit — 小さな単位の測定
  • cProfile + snakeviz — 関数単位のプロファイル
  • py-spy — 実行中のプロセス、低オーバーヘッド
  • line_profiler — 行単位
  • memray + tracemalloc — メモリ
  • よく出会う落とし穴 — グローバル lookup、文字列 +=、list in、間違ったデータ構造
  • データ構造: dequebisectCounterheapqdefaultdict
  • NumPy ベクトル化functools.cache__slots__
  • 最後の手段: Cython、PyO3、mypyc、PyPy、free-threaded CPython
  • 流れ: 測定 → ホットスポット → データ構造 / アルゴリズム → ベクトル化 → キャッシュ → 拡張 → 再測定

シリーズ全体の振り返り #

7 回で モダン Python 上級 の道具が揃いました。

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

これで モダン Python 基礎 → 中級 → 上級の 21 編が完了。次のシリーズは モダン Python 実践 — FastAPI で API を作る (6 編) です。今まで磨いた道具が 1 つのプロジェクトに集まるところです。

  1. はじめ方とセットアップ — Hello FastAPI、OpenAPI 自動生成
  2. ルーティング、Pydantic モデル、依存性注入
  3. DB 連携 — SQLAlchemy 2.x + Alembic
  4. 認証 — OAuth2 パスワードフロー + JWT
  5. 非同期とバックグラウンドタスク
  6. テストとデプロイ — pytest、Docker、Railway/Fly
X