Pythonパフォーマンス最適化の実践ガイド — 「なんとなく遅い」から「この行が遅い」へ

約20分で読めます by ぽんたぬき
Pythonパフォーマンス最適化の実践ガイド — 「なんとなく遅い」から「この行が遅い」へ

Pythonパフォーマンス最適化の実践ガイド — 「なんとなく遅い」から「この行が遅い」へ

「なんとなく遅い気がする」という直感を頼りにコードを書き直した結果、実行時間がほとんど変わらなかった——そんな経験はないでしょうか。推測による最適化は、9割の確率でボトルネック以外の場所に手を入れてしまいます。本記事では「計測 → 特定 → 最適化 → 再計測」のサイクルを7つのツールで具体化し、読了後には自分のコードのボトルネックを数分で言語化できる状態を目指します。


なぜPythonの最適化は「計測」から始めなければならないのか

推測による最適化が失敗する3つの理由

まず押さえておきたいのは、人間の直感とホットスポットは驚くほど一致しないという事実です。経験豊富なエンジニアでも、実際にプロファイリングを取ってみると「まさかこの関数が原因だとは思わなかった」という結果になることは珍しくありません。

次に、ボトルネックの偏在性があります。実行時間の80〜90%は、コード全体の10〜20%に集中しています。残り90%のコードをどれだけ磨いても、全体への影響は限定的です。

そして見落とされがちなのが、早すぎる最適化が可読性を壊すコストです。計測なしに「速くなるはず」という仮説で難解なコードに書き換えてしまうと、パフォーマンスは変わらないまま保守コストだけが上がります。

最適化サイクルの全体像

本記事は以下の5フェーズで構成されています。

  • Phase 1 — 面で見る:cProfileで遅い「関数」を絞り込む
  • Phase 2 — 線で見る:line_profilerで遅い「行」を特定する
  • Phase 3 — メモリを見る:memory_profilerでリークを追う
  • Phase 4 — 本番を見る:py-spyで稼働中プロセスを観察する
  • Phase 5 — 直す:NumPy / Numba / 標準ライブラリで高速化する

最適化を始める前に、達成条件を数値で決めておくことも重要です。「p95レイテンシを500ms以下に」「メモリ使用量を2GB以内に」など、合格ラインが明確でないと最適化は終わりません。


Phase 1 — cProfileとpstatsでコール別の実行時間を分析する

cProfileとは

cProfileはPythonに標準搭載されたC実装の決定論的プロファイラです。純Python版のprofileと比べてオーバーヘッドが少なく、実務での第一選択肢となります。「決定論的」とは、すべての関数呼び出しを漏れなく記録する方式を指します(後述のpy-spyは確率的サンプリング方式です)。

出力4列の読み方

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
列名 意味 判断に使う場面
ncalls 呼び出し回数(3/1は再帰) 呼びすぎていないか確認
tottime サブ関数を除く関数内実行時間 その関数自体が重いか判断
cumtime サブ関数込みの累積時間 配下全体が重いか判断
percall tottime / ncalls 1回あたりのコスト

実務のコツは「cumtimeで当たりをつけ、tottimeで犯人を確定する」です。cumtimeの大きい関数を辿っていき、tottimeが突出している関数が本当のボトルネックです。

実践コード

import cProfile, pstats
from pstats import SortKey

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

stats = pstats.Stats(pr)
stats.sort_stats(SortKey.CUMULATIVE)
stats.print_stats(10)  # 上位10件を表示

コマンドライン版も覚えておくと便利です。

python -m cProfile -s cumulative script.py

出力が多い場合はprint_stats("myapp")でモジュール名フィルタをかけると、自分のコードだけに絞り込めます。結果をビジュアルで確認したい場合はsnakevizでブラウザに表示できます。

cProfileが向かないケース

マイクロベンチマーク(数百マイクロ秒の差を測りたい場合)にはtimeitを使います。本番稼働中のプロセスを計測したい場合はPhase 4のpy-spyが適切です。


Phase 2 — line_profilerで行単位のホットスポットを特定する

「遅い関数」から「遅い行」へ

cProfileで「この関数が重い」と分かっても、その関数の中のどの処理が原因なのかはわかりません。line_profilerはその解像度の限界を埋めるツールです。pip install line_profilerでインストールできます。

実践:@profileデコレータとkernprof

@profile
def slow_function():
    result = []
    for i in range(10000):
        result.append(i ** 2)   # ← ここが %Time の大半を占める
    return result
kernprof -l -v script.py

出力の読み方

出力にはHits(実行回数)、Time(総時間)、Per Hit(1回あたり)、%Time(時間占有率)が表示されます。まず%Time列を降順で眺めましょう

「1回だけ重い行」はアルゴリズムの問題、「回数は多いが1回は軽い行」はループ除去(NumPy化)の候補、という形で打ち手が変わります。

運用上の注意点

@profileはグローバルに注入されるため、通常実行時にNameErrorが発生します。回避策として、try: profile except NameError: profile = lambda f: fというパターンでデコレータを条件定義するか、計測専用スクリプトとして分離する方法が一般的です。また、オーバーヘッドが大きいため、cProfileで特定した関数だけに局所適用してください。


Phase 3 — memory_profilerでメモリリークを検出する

実行時間だけでは見えない「メモリという遅さ」

メモリ使用量の増加はスワップやGCプレッシャーを通じてレイテンシに悪影響を与えます。pip install memory_profilerでインストール後、line_profilerと同様に@profileデコレータを使います。

@profile
def process_large_data():
    data = [x ** 2 for x in range(100000)]   # +7.6 MiB
    del data                                  # -7.6 MiB
    return "done"

Increment列が正のまま関数を抜けている行がリーク候補です。オーバーヘッドは5〜10%程度あるため、本番投入せず疑わしい関数への局所適用に留めましょう。

Pythonにおけるメモリリークの典型3パターン

循環参照はイベントリスナーやコールバックで起こりやすいパターンです。weakrefを活用することで解決できます。

無期限グローバルキャッシュはモジュールレベルのリストや辞書へデータを追加し続けるパターンです。maxsize付きのlru_cacheTTLCacheで対処しましょう。

未クローズリソースはファイルハンドルやDBコネクションをclose()せずに放置するパターンです。with文の徹底が解決策です。

2025〜2026年の選択肢

標準ライブラリのtracemallocはスナップショット差分でアロケーション元を追跡できます。Bloomberg製のmemrayは低オーバーヘッドでフレームグラフ出力が可能で、ネイティブ拡張も追跡できる点が強みです。本番環境での継続監視にはmemrayが有力候補となっています。


Phase 4 — py-spyで本番環境をサンプリングプロファイリングする

「プロセスを止めない」ことの決定的な価値

開発環境で再現しない性能問題は少なくありません。本番環境特有の負荷パターンやデータ量が影響していることが多く、本番で直接計測するしか手がないケースがあります。py-spyはRust実装による極めて低いオーバーヘッドで、対象プロセスへのコード変更なしに外部からプロファイリングできます。

覚えるべき3コマンド

# フレームグラフ生成(SVG形式)
py-spy record -o profile.svg --pid 12345

# リアルタイムtop表示(htopのPython版)
py-spy top --pid 12345

# 全スレッドのスタックダンプ(デッドロック・ハング調査の第一手)
py-spy dump --pid 12345

recordは時間配分の全体像をSVGとしてチームで共有するのに最適です。topは「今まさに何が重いか」をリアルタイムで確認でき、dumpはプロセスがハングしている原因を即座に特定するための強力な手段です。

技術的な仕組み

py-spyはprocess_vm_readv(Linux)、vm_read(macOS)、ReadProcessMemory(Windows)を使ってインタプリタの内部状態を直接読み込みます。Python 2.3〜3.14に対応しており、CPython側への変更はゼロです。

コンテナ環境での利用時はSYS_PTRACE権限の付与とptrace_scopeの設定確認が必要です。KubernetesではsecurityContextcapabilities.add: ["SYS_PTRACE"]を追加します。

フレームグラフの読み方

フレームグラフでは横幅が時間占有率縦がコールスタックの深さを表します。「幅の広い平らな台地」がボトルネックの関数です。細く高いスタックはほとんど時間を消費していないため、無視して構いません。


Phase 5-A — NumPyベクトル化でPythonループを消す

なぜPythonのforループは遅いのか

Pythonのforループは動的型付けとオブジェクトのボックス化、インタプリタのディスパッチコストが積み重なります。ベクトル化はループをC実装の内部に「降ろす」テクニックです。

Before / After と実測値

# Before: Pythonループ(ベースライン 1x)
result = [x ** 2 for x in data]

# After: NumPyベクトル化(約100〜520x 高速)
import numpy as np
result = np.array(data) ** 2

データサイズが大きくなるほど効果は顕著で、1,000万要素のベンチマークでは500倍以上の高速化が報告されています。

ベクトル化が効く典型パターン

# 条件分岐 → np.where
result = np.where(arr > 0, arr, 0)

# 集計 → np.sum / np.mean
total = np.sum(arr)                  # sum() より高速

# 二重ループ → ブロードキャスト
diff = arr[:, np.newaxis] - arr[np.newaxis, :]  # (N, N) の差行列を一括生成

ベクトル化のアンチパターン

注意が必要なのはnp.vectorizeです。名前に「ベクトル化」と入っていますが、内部実態はPythonのループと変わらず速くなりません。また、np.arrayへの変換コストが利益を上回る小規模データ(数十〜数百要素程度)では逆に遅くなることもあります。巨大な中間配列が生成される処理はメモリ爆発の原因になるため、Phase 3と合わせて確認してください。


Phase 5-B — Numba @jitでJITコンパイルする判断基準

最小の改変で最大のROI

ベクトル化で表現しにくい逐次依存のあるループや複雑な数値計算には、Numbaが有効です。

from numba import jit

@jit(nopython=True)   # インタプリタへのfallbackを禁止する
def heavy_loop(arr):
    total = 0.0
    for x in arr:
        total += x ** 2
    return total

nopython=Trueは必須オプションです。これを省略すると、Numbaが対応できない処理で暗黙的にインタプリタへフォールバックし、高速化が得られません。実測では56〜135倍の高速化が報告されています。

適用判断チェックリスト

✅ 向いている ❌ 向いていない
NumPy配列を扱う数値ループ Pandas DataFrameの文字列処理
科学計算・シミュレーション I/Oバウンドな処理
反復的に呼ばれる重い計算関数 初回コンパイルコストを許容できない箇所

初回呼び出し時のコンパイル遅延は@jit(cache=True)で2回目以降をキャッシュすることで対処できます。parallel=Trueでループの並列実行、fastmath=Trueで浮動小数点演算の近似高速化が可能ですが、数値精度とのトレードオフがあります。

手法別ROI比較(2026年時点)

手法 高速化倍率 コード変更量 学習コスト
Numba 56〜135x 最小
Cython 99〜124x
Rust / PyO3 113〜154x

技術的に正確な表現をするなら、まずNumbaを試し、頭打ちになってからCython・Rustを検討するというのがベストプラクティスです。


Phase 5-C — 標準ライブラリだけでできる最適化

__slots__ — インスタンスのメモリを削減する

通常のPythonクラスはインスタンスごとに__dict__(辞書オブジェクト)を持ちます。__slots__を定義するとこの辞書が省略され、メモリを約40〜50%削減できます。

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

効果が出るのは「同じクラスを数万〜数百万単位でインスタンス化する」場面です。Python 3.10以降では@dataclass(slots=True)と書くだけで同様の効果が得られます。

制約として、動的な属性追加が不可になる点、多重継承との相性に注意が必要な点を押さえておきましょう。

functools.lru_cache — 同じ計算を二度やらない

純粋関数(同じ入力に対して常に同じ出力を返し、副作用がない関数)に限定して適用します。

from functools import lru_cache

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

# キャッシュの効果を確認
print(fibonacci.cache_info())
# CacheInfo(hits=..., misses=..., maxsize=128, currsize=...)

cache_info()でヒット率を確認するのがベストプラクティスです。Python 3.9以降では@functools.cachemaxsizeなし)も使えます。

落とし穴として、インスタンスメソッドにlru_cacheを適用するとインスタンス自体がキャッシュキーになり、インスタンスがGCされなくなるメモリリークが発生します。インスタンスメソッドには@cached_property(Python 3.8+)を使うのが適切です。

その他、効果の高いイディオム

実際の開発現場では、以下のような細かいパターンも積み重なって効果を発揮します。

# 文字列結合: += より join が高速
result = "".join(parts)

# ループ内の属性参照: ローカル変数に束縛して削減
local_append = result.append   # グローバル参照コストをゼロにする

# in 判定: list より set が O(1)
seen = set()
if value in seen:   # list に比べて大幅に高速

# ジェネレータ: 大量データをメモリに一括展開しない
total = sum(x ** 2 for x in range(10_000_000))

最適化ツール早見表と選択フロー

用途別ツール比較

ツール 粒度 オーバーヘッド 本番適性 主な用途
cProfile 関数 全体の当たりをつける
line_profiler ホットスポットの確定
memory_profiler 行(メモリ) 5〜10% リーク箇所の特定
tracemalloc / memray 割当元 小〜中 メモリの継続監視
py-spy 関数(サンプリング) 極小 本番調査・ハング解析

「症状 → 使うツール」の対応表

  • CPUが張り付く・レスポンスが遅い → まずcProfile、本番ならpy-spy top
  • メモリが増え続けるmemory_profilerでリーク行を特定、memrayで継続監視
  • プロセスが固まる(ハング・デッドロック)py-spy dumpで全スレッドのスタックを即時確認

やってはいけない最適化 — アンチパターン集

計測せずに書き換えるのが最大の過ちです。「この処理は絶対に遅い」という確信があっても、まず計測してから手を動かしましょう。

可読性を犠牲にして数%を稼ぐのも避けるべきです。10%の高速化のために読めないコードを書くより、ボトルネックの1箇所を改善して50%高速化する方がトータルコストは低くなります。

I/OバウンドをCPU最適化で解こうとするのは方向が違います。データベースクエリや外部API呼び出しが遅い場合、NumPyやNumbaは全く効果がありません。本命は非同期I/O(asyncio)や並行処理です。

効果測定を1回の実行だけで判断するのも危険です。ウォームアップの有無や実行ごとの分散を考慮し、timeitで複数回測定した中央値で評価しましょう。


まとめ — 明日から使える最適化の3ステップ

Python高速化の本質は「測る・絞る・直す」のサイクルです。

① 測る:まずcProfileで全体を俯瞰し、本番環境の問題にはpy-spyを使います。

② 絞るline_profilerで遅い行まで解像度を上げ、メモリ増加にはmemory_profilerを適用します。

③ 直す:数値ループはNumPyベクトル化またはNumba、大量インスタンスには__slots__、再計算にはlru_cacheを適用します。

これらを組み合わせた実測では、ボトルネック1箇所の改善だけで全体パフォーマンスが100倍以上改善するケースも珍しくありません。段階的に理解を深めながら、まずは手元のコードにcProfileを実行してみることから始めてみてください。

参考リンク・公式ドキュメント

関連記事

AIエージェントのループはなぜ止まらないのか — a16zに学ぶ「収束する停止条件」の設計

AIエージェントのループはなぜ止まらないのか — a16zに学ぶ「収束する停止条件」の設計

AIエージェントのループはなぜ止まらないのか — a16zに学ぶ「収束する停止条件」の設計 --- エージェントは「終わり」を知らない 「朝起きたら、昨夜動かしたエージェントがAPIコストを$300分消費していた」——AIエージェントを実務で触り始めたエンジニアなら、こういったヒヤリ体験を一度は経験しているのではないでしょうか。 実はこれ、エージェントが「壊れている」わけではありません。AIモデル...

AIでレガシーコードをリファクタリングする実践ガイド — テストも型もない現場で「壊さない」ための5ステップ

AIでレガシーコードをリファクタリングする実践ガイド — テストも型もない現場で「壊さない」ための5ステップ

AIでレガシーコードをリファクタリングする実践ガイド — テストも型もない現場で「壊さない」ための5ステップ はじめに:「AIに任せたら動かなくなった」はなぜ起きるのか 新規実装の記事は多いが、レガシー改修の記事は少ない GitHub CopilotやClaude Code、CursorといったAIコーディングツールの活用事例が増え、「AIでコードを書く速度が3倍になった」という報告をよく目にする...

【実装解説】Claudeのメモリを棚卸しするカスタムスキル「memory-inventory」の作り方 ─ CLAUDE.mdが嘘をつき始める前に

【実装解説】Claudeのメモリを棚卸しするカスタムスキル「memory-inventory」の作り方 ─ CLAUDE.mdが嘘をつき始める前に

【実装解説】Claudeのメモリを棚卸しするカスタムスキル「memory-inventory」の作り方 ─ CLAUDE.mdが嘘をつき始める前に はじめに:あなたのClaudeは、いつから嘘をつくようになったか 「このPRはまだオープンのままですね、注意が必要です」 そう言われてリンクを開いたら、すでにマージ済みだった。 こういう体験、心当たりはありませんか? 大丈夫、私も最初は「気のせいかな」...

メルカリの「AI-Native開発」とは?ツール導入で終わらせない組織変革の実践知

メルカリの「AI-Native開発」とは?ツール導入で終わらせない組織変革の実践知

メルカリの「AI-Native開発」とは?ツール導入で終わらせない組織変革の実践知 「AIツールは配ったが、使う人と使わない人に分かれてしまった」——多くの開発組織が直面する壁です。メルカリはこれを、ツールではなくプロセスと組織の再設計として解いています。本記事では公開された実践事例をもとに、AI-Native開発の中身を具体的に読み解きます。 --- AI-Native開発とは何か — 「AIツ...

コメント

0/2000