モダンPython上級 #7 性能 — cProfile、py-spy、メモリプロファイリング
上級シリーズの最後 — 性能 です。「遅いです」という報告を受けたとき、どこがどう遅いかを測定して直す道具箱 を整理します。timeit、cProfile、py-spy、line_profiler、memray、そしてよくある最適化パターンまで。
第一のルール — 測定なしに最適化するな #
"Premature optimization is the root of all evil." — Donald Knuth読むたびになんとなく退屈に聞こえますが、ほぼ常に正しいです。直感で「ここが遅いだろう」と指し示すと 70% は外れます。 測定が最初のステップ。
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 でも可能:
python -m timeit -s "import json" "json.dumps({'a': 1})"
# 1000000 loops, best of 5: 322 ns per loopcProfile — 関数単位のプロファイリング
#
CPU 時間がどこに使われているかを 関数ごとに 見せてくれます。
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出力:
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 #
uv add --dev snakeviz
python -m cProfile -o profile.out myapp.py
uvx snakeviz profile.outブラウザで flame graph に近い形で関数の呼び出しツリーを見ます。テキスト出力より直感的です。
py-spy — 実行中のプロセスをプロファイリング
#
cProfile の短所: コードを修正して囲まなければなりません。実行中のプロダクションプロセス にアタッチしたいときは py-spy が答えです。
uvx py-spy@latest top --pid 12345
# あるいは新しいプロセスを開始
uvx py-spy@latest record -o flame.svg -- python myapp.pytop モード: 関数別の CPU 使用率をリアルタイム (top コマンドのよう)
record モード: 一定時間記録した後に flame graph SVG を生成
py-spy の価値:
- ソース修正不要
- サンプリングベース — オーバーヘッドが非常に低い (5~10%)
- C 拡張 も見える — NumPy 内部なども分析可能
- GIL 保有時間 も表示 —
--idleオプションで idle 分析
Production / staging で「いま何が遅いのか」を即席で見る道具です。
line_profiler — 行単位のプロファイリング
#
cProfile は関数単位。関数の中のどの行が遅いか を見たいとき。
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 filtereduv run kernprof -l -v myapp.py出力:
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 が標準のポジションにあります。
uv add --dev memray
uv run memray run myapp.py # *.bin を生成
uv run memray flamegraph output.bin # HTML レポートメモリリークの追跡、peak メモリ使用箇所、allocation の呼び出しツリー — ネイティブメモリまで追跡します。
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) += で文字列を累積
#
result = ""
for s in strings:
result += s # 毎回新しい文字列を生成
# ✅ join — O(n)
result = "".join(strings)文字列はイミュータブルなので += は毎回新しいオブジェクトを作ります。大きな文字列ほど恐ろしく遅くなります。
3) リストに in で検索
#
if x in big_list: # すべての要素と比較
# ✅ set に変換 — O(1)
big_set = set(big_list)
if x in big_set:検索頻度が高ければ set/dict に変えてください。
4) 間違ったデータ構造 #
| 仕事 | データ構造 |
|---|---|
| 両端で push/pop | collections.deque (list の pop(0) は O(n)) |
| ソート維持の挿入 | bisect モジュール |
| カウント | collections.Counter |
| 優先度キュー | heapq |
| デフォルト値の dict | collections.defaultdict |
Python の標準ライブラリにほとんど揃っています — 自分で作らずに使ってください。
NumPy / ベクトル化 #
数値計算なら ループの代わりに NumPy がほぼ常に速いです。
result = [a[i] * b[i] for i in range(len(a))]import numpy as np
result = np.array(a) * np.array(b) # C レベルで同時処理100 倍 ~ 1000 倍の差が出る場面はよくあります。ただし、データ変換コスト があるので、小さな配列ではむしろ遅いことがあります。測定してから適用。
キャッシュ — functools.cache
#
中級 #5 で見た道具。同じ引数で繰り返し呼ばれる純粋関数に最も効果的な最適化。
from functools import cache
@cache
def expensive(n: int) -> int:
...関数が 純粋 で、引数が hashable でなければなりません。
__slots__ — インスタンスのメモリ節約
#
中級 #1 で見たところ。オブジェクトを数万個作るなら最も大きな効果が出ます。
@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 の非同期コードの性能を測るとき:
PYTHONASYNCIODEBUG=1 uvx py-spy@latest record -o async.svg -- python app.pypy-spy は非同期コードもよく分析します。どのコルーチンがどこで await に縛られているかを見せます。
実践 — 性能デバッグの流れ #
- 再現可能なベンチマーク — 同じ入力で同じ結果を同じ時間で出さないと測定の意味がない
timeで全体の時間を確認 — 1 秒なのか 1 分なのかで道具を選ぶcProfileまたはpy-spyでホットスポットを見つけるline_profilerでホット関数の行単位の分析- よくある落とし穴を点検 — list
in、グローバル lookup、文字列の累積 - データ構造を変更 — set/deque/Counter など
- ベクトル化 — NumPy を適用できるか
- キャッシュ — 同じ引数の繰り返し呼び出しか
- C レベル拡張 — 最後の手段
各ステップで 再度測定 して本当に速くなったか確認。「これが速いはず」の直感はよく外れます。
まとめ + シリーズの振り返り #
今回見た道具箱:
timeit— 小さな単位の測定cProfile+snakeviz— 関数単位のプロファイルpy-spy— 実行中のプロセス、低オーバーヘッドline_profiler— 行単位memray+tracemalloc— メモリ- よく出会う落とし穴 — グローバル lookup、文字列
+=、listin、間違ったデータ構造 - データ構造:
deque、bisect、Counter、heapq、defaultdict - 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 つのプロジェクトに集まるところです。
- はじめ方とセットアップ — Hello FastAPI、OpenAPI 自動生成
- ルーティング、Pydantic モデル、依存性注入
- DB 連携 — SQLAlchemy 2.x + Alembic
- 認証 — OAuth2 パスワードフロー + JWT
- 非同期とバックグラウンドタスク
- テストとデプロイ — pytest、Docker、Railway/Fly