はじめに
「Pythonは遅い」とよく言われますが、適切な最適化を行うことで驚くほど高速に動作させることができます。
この記事では、Pythonコードのパフォーマンスを改善するための実践的なテクニックを紹介します。計測方法から始まり、データ構造の選択、組み込み関数の活用、並列処理まで、幅広くカバーします。
ただ 実務で効くのは
小技よりも
- どの順で切り分けるか
- どこを速くすべきか(速くしても意味がない場所を避ける)
を押さえることです。
先に結論 まずこの順で当てる
- どれくらい遅いのかを固定する
- 入力サイズと実行回数を固定し、再現できる状態にする
- どこが遅いのかを分ける
- CPU計算なのか IO待ち(DB/HTTP/ファイル)なのか
- プロファイラで上位を取る
- 体感で当てない。上位数個だけに集中する
- 早く効く手を打つ
- アルゴリズム、データ構造、不要なループ削減
- それでも足りなければ逃げ道を選ぶ
- キャッシュ、バッチ化、非同期、並列化、実装言語の置き換え
ありがちな失敗
- 先にC拡張や並列化に走る
- ボトルネックがIO待ちなら、並列化しても速くならない
- ログやデバッグ出力が支配する
- 体感では気づきにくい。測ると一発で出る
- 入力サイズが違うのに改善した気になる
- 小さい入力だけ速くして、本番のデータでは変わらない
最小チェックリスト(速度改善のPR用)
- 改善前後の計測条件(入力サイズ、回数、環境)が書かれている
- どの関数が何パーセントを占めていたかが示されている
- 速くした結果、メモリが増えすぎていない
- 正しさ(結果一致)がテストで担保されている
追加で見ると事故が減る
- “速くなった理由” が説明できる(アルゴリズム/計算量/IO削減など)
- 依存の追加や運用負荷(async化/並列化/キャッシュ)が増えていないか評価した
- 効果が本番の入力サイズで効くか(サンプルだけ速い、を避ける)
パフォーマンス計測の基本
最適化の前に、まず計測方法を学びましょう。「推測するな、計測せよ」はパフォーマンス改善の鉄則です。
time モジュール
用途
- ざっくり「遅い/速い」を見る(ローカルでの一次確認)
- 本番のメトリクスと照らし合わせて「この程度の改善で意味があるか」を判断する
注意
-
time.time()は壁時計で、環境によってブレます(再現性は低め) - I/OやGC、CPU周波数変動が混ざるので「最終判断」は timeit/プロファイラへ
import time
start = time.time()
# 計測したい処理
result = sum(range(1000000))
end = time.time()
print(f"実行時間: {end - start:.4f}秒")
timeit モジュール(推奨)
より正確な計測にはtimeitを使います。複数回実行して平均を取るため、信頼性が高いです。
実務での使い分け
- “小さな差(数%〜数十%)” を比べるなら timeit が最適
- 変更前後の比較は、入力サイズと回数を固定してコミットに残す(PRで揉めない)
注意
- 最初の数回はウォームアップ(import/キャッシュ/CPUターボ)で遅いことがある
- 測る対象にI/Oが混ざるとノイズが支配するため、I/Oはモック化するか別計測に分離する
import timeit
# 文字列として渡す方法
time_taken = timeit.timeit(
'sum(range(1000000))',
number=100
)
print(f"100回の合計: {time_taken:.4f}秒")
# 関数を渡す方法
def my_function():
return sum(range(1000000))
time_taken = timeit.timeit(my_function, number=100)
print(f"100回の合計: {time_taken:.4f}秒")
# セットアップコードがある場合
setup = '''
import numpy as np
data = np.random.rand(10000)
'''
stmt = 'np.sum(data)'
time_taken = timeit.timeit(stmt, setup=setup, number=1000)
cProfile でボトルネックを特定
cProfileは「どの関数が時間を使ったか」を俯瞰するのに向きます。
いきなり最適化せず、まず上位(数個)だけを潰すのが最短です。
見るべき列(超重要)
-
tottime: その関数自身で使った時間(内部呼び出し除く) -
cumtime(cumulative): その関数 + その配下で使った時間
読み方のコツ
-
cumtime上位に「入口」が出る(全体の支配者) -
tottime上位に「内側の重い処理」が出る(ループの芯)
import cProfile
import pstats
def slow_function():
total = 0
for i in range(1000000):
total += i ** 2
return total
# プロファイリング実行
cProfile.run('slow_function()', 'output.prof')
# 結果を読みやすく表示
stats = pstats.Stats('output.prof')
stats.sort_stats('cumulative')
stats.print_stats(10) # 上位10件
line_profiler で行単位の計測
line_profilerは「どの行が遅いか」まで落とせる反面、準備が少し面倒です。
cProfileで“遅い関数”まで絞れた後の、最後の詰めに向きます。
注意
- 計測自体にオーバーヘッドがあるので、絶対値より「相対的にどこが重いか」を見る
- I/Oが混ざると行単位で読みづらいので、CPU支配の箇所で使う
# pip install line_profiler
# @profile デコレータを付けた関数を作成
@profile
def slow_function():
total = 0
for i in range(1000000):
total += i ** 2
return total
# コマンドラインで実行
# kernprof -l -v script.py
memory_profiler でメモリ使用量を計測
速度改善はしばしば メモリ増 とトレードになります。
「速くなったけど本番でメモリが足りず落ちた」が最悪なので、
speedだけでなくメモリもセットで見ると安全です。
注意
- Pythonのメモリはアロケータや断片化の影響を受けるので、傾向を見る用途が中心
- “ピーク”を見たい場合は、入力サイズを実データに合わせる
# pip install memory_profiler
from memory_profiler import profile
@profile
def memory_hungry_function():
# 大きなリストを作成
data = [i ** 2 for i in range(1000000)]
return sum(data)
memory_hungry_function()
データ構造の選択
適切なデータ構造を選ぶことが、最も効果的な最適化の一つです。
リスト vs セット vs 辞書
ここでの本質は「計算量が違う」ことです。
検索が支配している処理を list のまま頑張るより、set/dict に置き換える方が桁で効きます。
実務の判断
- ループの中で
x in listをしているなら、まず疑う(set化で一気に落ちる) -
set/dictはメモリを多く使うので、巨大データではメモリ増も確認
import timeit
# 要素の検索
data_list = list(range(100000))
data_set = set(range(100000))
data_dict = {i: True for i in range(100000)}
# リストでの検索(O(n))
time_list = timeit.timeit(
'99999 in data_list',
globals=globals(),
number=1000
)
# セットでの検索(O(1))
time_set = timeit.timeit(
'99999 in data_set',
globals=globals(),
number=1000
)
# 辞書でのキー検索(O(1))
time_dict = timeit.timeit(
'99999 in data_dict',
globals=globals(),
number=1000
)
print(f"リスト: {time_list:.4f}秒")
print(f"セット: {time_set:.4f}秒")
print(f"辞書: {time_dict:.4f}秒")
# 結果例:
# リスト: 1.2345秒
# セット: 0.0001秒
# 辞書: 0.0001秒
使い分けの指針
| 操作 | リスト | セット | 辞書 |
|---|---|---|---|
| 末尾への追加 | O(1) | O(1) | O(1) |
| 先頭への追加 | O(n) | - | - |
| 検索 | O(n) | O(1) | O(1) |
| 削除(値指定) | O(n) | O(1) | O(1) |
| インデックスアクセス | O(1) | - | O(1) |
collections モジュールの活用
collections は「標準ライブラリなのに速い/便利」枠です。
第三者ライブラリを増やす前にまず当てると、変更コストが低いです。
使いどころ
-
defaultdict:if key in dictを消して分岐を減らす(コードも速くなることが多い) -
Counter: 集計の定番。 -
deque: 先頭追加/削除があるキュー(リストでinsert(0, ...)をしていたら置換候補)
from collections import defaultdict, Counter, deque
# defaultdict: キーが存在しない場合のデフォルト値
word_count = defaultdict(int)
for word in ['apple', 'banana', 'apple', 'cherry']:
word_count[word] += 1
# {'apple': 2, 'banana': 1, 'cherry': 1}
# Counter: 要素のカウントに特化
words = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple']
counter = Counter(words)
print(counter.most_common(2)) # [('apple', 3), ('banana', 2)]
# deque: 両端からの追加・削除がO(1)
queue = deque()
queue.append('a') # 右に追加
queue.appendleft('b') # 左に追加
queue.pop() # 右から削除
queue.popleft() # 左から削除
# リストとの比較
import timeit
# リストの先頭に追加(遅い)
time_list = timeit.timeit(
'lst.insert(0, 1)',
setup='lst = list(range(10000))',
number=1000
)
# dequeの先頭に追加(速い)
time_deque = timeit.timeit(
'd.appendleft(1)',
setup='from collections import deque; d = deque(range(10000))',
number=1000
)
print(f"リスト: {time_list:.4f}秒")
print(f"deque: {time_deque:.4f}秒")
組み込み関数と内包表記
組み込み関数を活用する
Pythonの組み込み関数はC言語で実装されており、純粋なPythonコードより高速です。
実務で効くポイント
- Pythonの
forは“1回ごとのオーバーヘッド”が大きいので、できるだけC実装のsum/max/min/sorted/any/allに寄せる - 単純な変換や集計は「組み込みに寄せるだけ」で十分速くなることが多い
import timeit
numbers = list(range(1000000))
# 遅い: forループ
def sum_loop(numbers):
total = 0
for n in numbers:
total += n
return total
# 速い: 組み込み関数
def sum_builtin(numbers):
return sum(numbers)
time_loop = timeit.timeit(lambda: sum_loop(numbers), number=100)
time_builtin = timeit.timeit(lambda: sum_builtin(numbers), number=100)
print(f"forループ: {time_loop:.4f}秒")
print(f"組み込み関数: {time_builtin:.4f}秒")
# その他の高速な組み込み関数
max_val = max(numbers)
min_val = min(numbers)
sorted_list = sorted(numbers)
all_positive = all(n >= 0 for n in numbers)
any_negative = any(n < 0 for n in numbers)
リスト内包表記を使う
内包表記は「速い」だけでなく「意図が短く書ける」メリットが大きいです。
注意
- 1行が長くなって読めないなら、素直に for で分ける(保守できない最適化は事故る)
- 巨大なリストをやみくもに作るとメモリが先に死ぬので、次の“ジェネレータ式”も検討
import timeit
# 遅い: forループでリスト作成
def squares_loop(n):
result = []
for i in range(n):
result.append(i ** 2)
return result
# 速い: リスト内包表記
def squares_comprehension(n):
return [i ** 2 for i in range(n)]
time_loop = timeit.timeit(lambda: squares_loop(1000000), number=10)
time_comp = timeit.timeit(lambda: squares_comprehension(1000000), number=10)
print(f"forループ: {time_loop:.4f}秒")
print(f"内包表記: {time_comp:.4f}秒")
ジェネレータ式でメモリ節約
ジェネレータは「作らない」ことでメモリを節約します。
ただし、複数回走査する処理だと逆に遅くなることもあるので、
- 1回しか使わない集計(
sum(...)等) → ジェネレータが相性良い - 何回も使う/ランダムアクセスしたい → 生成して保持した方が速い
の判断が必要です。
import sys
# リスト内包表記(全てをメモリに保持)
squares_list = [i ** 2 for i in range(1000000)]
print(f"リスト: {sys.getsizeof(squares_list):,} bytes")
# ジェネレータ式(遅延評価)
squares_gen = (i ** 2 for i in range(1000000))
print(f"ジェネレータ: {sys.getsizeof(squares_gen):,} bytes")
# sum, max, minなどはジェネレータを直接受け取れる
total = sum(i ** 2 for i in range(1000000)) # メモリ効率が良い
文字列操作の最適化
文字列結合
文字列はimmutableなので、+ での連結をループでやると「毎回コピー」が起きて遅くなります。
実務の指針
- ループで連結するなら基本
''.join(...) - ただし
str(i)の変換やフォーマットが支配している場合もあるので、プロファイルで確認
注意
-
''.join(...)に渡すのは 文字列のIterable(bytesはbytesでjoin)
import timeit
# 遅い: +演算子での結合(毎回新しい文字列オブジェクトを作成)
def concat_plus(n):
result = ''
for i in range(n):
result += str(i)
return result
# 速い: joinを使用
def concat_join(n):
return ''.join(str(i) for i in range(n))
# 速い: リストに追加してからjoin
def concat_list_join(n):
parts = []
for i in range(n):
parts.append(str(i))
return ''.join(parts)
n = 10000
time_plus = timeit.timeit(lambda: concat_plus(n), number=100)
time_join = timeit.timeit(lambda: concat_join(n), number=100)
time_list_join = timeit.timeit(lambda: concat_list_join(n), number=100)
print(f"+演算子: {time_plus:.4f}秒")
print(f"join: {time_join:.4f}秒")
print(f"list + join: {time_list_join:.4f}秒")
f-string vs format vs %
結論
- Python 3.6+ なら基本は f-string
- ただし“1行のログ整形”程度は、速度より可読性/一貫性優先でOK
import timeit
name = "田中"
age = 25
# 最速: f-string(Python 3.6+)
def fstring():
return f"名前: {name}, 年齢: {age}"
# 中程度: format
def format_method():
return "名前: {}, 年齢: {}".format(name, age)
# 遅め: %演算子
def percent():
return "名前: %s, 年齢: %d" % (name, age)
print(f"f-string: {timeit.timeit(fstring, number=1000000):.4f}秒")
print(f"format: {timeit.timeit(format_method, number=1000000):.4f}秒")
print(f"%演算子: {timeit.timeit(percent, number=1000000):.4f}秒")
ループの最適化
ローカル変数を使う
このテクは効くこともありますが、優先順位は高くありません。
プロファイルで「ループが支配」していて、
かつ組み込み関数やアルゴリズム変更が効かないときの“最後の数%”に使うのが安全です。
import timeit
import math
# 遅い: グローバル変数やモジュールへのアクセス
def slow_loop():
result = []
for i in range(10000):
result.append(math.sqrt(i))
return result
# 速い: ローカル変数にキャッシュ
def fast_loop():
result = []
append = result.append # メソッドをローカルにキャッシュ
sqrt = math.sqrt # 関数をローカルにキャッシュ
for i in range(10000):
append(sqrt(i))
return result
time_slow = timeit.timeit(slow_loop, number=1000)
time_fast = timeit.timeit(fast_loop, number=1000)
print(f"通常: {time_slow:.4f}秒")
print(f"最適化: {time_fast:.4f}秒")
enumerate と zip を使う
# インデックスが必要な場合
names = ['Alice', 'Bob', 'Charlie']
# 遅い
for i in range(len(names)):
print(f"{i}: {names[i]}")
# 速い(かつ読みやすい)
for i, name in enumerate(names):
print(f"{i}: {name}")
# 複数のリストを同時に処理
ages = [25, 30, 35]
# 遅い
for i in range(len(names)):
print(f"{names[i]}: {ages[i]}")
# 速い
for name, age in zip(names, ages):
print(f"{name}: {age}")
map と filter
import timeit
numbers = list(range(1000000))
# リスト内包表記
def comprehension():
return [x ** 2 for x in numbers if x % 2 == 0]
# map + filter
def map_filter():
return list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, numbers)))
time_comp = timeit.timeit(comprehension, number=10)
time_map = timeit.timeit(map_filter, number=10)
print(f"内包表記: {time_comp:.4f}秒")
print(f"map+filter: {time_map:.4f}秒")
# 通常はリスト内包表記の方が読みやすく、速度も同程度
# mapは関数が既に定義されている場合に有効
import math
squares = list(map(math.sqrt, numbers)) # lambdaより速い
NumPy による高速化
数値計算においては、NumPyの使用が最も効果的な最適化の一つです。
ただし「NumPyを入れれば速い」ではなく、PythonループをNumPyのベクトル演算(C実装)に置き換えられるときに効くのがポイントです。
- 効きやすい: 大きな配列に対する同種の演算(加算、乗算、集約、条件フィルタなど)
- 効きにくい: 要素ごとに複雑な分岐が多い、Pythonオブジェクト(文字列/辞書など)中心、配列が小さすぎる(変換コストが勝つ)
実務の勘所(落とし穴)
-
list→np.array変換自体にもコストがあります。ホットパス内で都度変換せず、入口でまとめて配列化します。 -
dtype(例:float64,int32)でメモリ使用量と速度が変わります。意図しない型変換(アップキャスト)に注意します。 - コピーとビュー:
arr[::2]などはビューになり得ますが、arr[mask]はコピーになりやすいです。巨大配列でのコピーはメモリと時間の両面で効きます。 - ブロードキャストは便利ですが、意図しない巨大な中間配列が生まれることがあります(ピークメモリ増)。必要なら
out=引数や計算手順の見直しを検討します。
ベクトル化
import numpy as np
import timeit
# 純粋なPython
def python_sum_squares(n):
return sum(i ** 2 for i in range(n))
# NumPy
def numpy_sum_squares(n):
arr = np.arange(n)
return np.sum(arr ** 2)
n = 1000000
time_python = timeit.timeit(lambda: python_sum_squares(n), number=10)
time_numpy = timeit.timeit(lambda: numpy_sum_squares(n), number=10)
print(f"Python: {time_python:.4f}秒")
print(f"NumPy: {time_numpy:.4f}秒")
# NumPyは10-100倍高速になることも
NumPy の効果的な使い方
import numpy as np
# 配列の作成
arr = np.array([1, 2, 3, 4, 5])
zeros = np.zeros(1000)
ones = np.ones(1000)
range_arr = np.arange(0, 100, 2) # 0から100まで2刻み
linspace = np.linspace(0, 1, 100) # 0から1を100分割
# ベクトル化された演算
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print(a + b) # [5, 7, 9]
print(a * b) # [4, 10, 18]
print(a ** 2) # [1, 4, 9]
# 条件による選択
data = np.random.randn(1000000)
positive = data[data > 0] # forループ不要
large = np.where(data > 0, data, 0) # 条件付き置換
# ブロードキャスト
matrix = np.random.rand(1000, 1000)
row = np.random.rand(1000)
result = matrix + row # 自動的にブロードキャスト
# 集約関数
print(np.sum(data))
print(np.mean(data))
print(np.std(data))
print(np.max(data))
print(np.argmax(data)) # 最大値のインデックス
避けるべきパターン
import numpy as np
arr = np.random.rand(1000000)
# NG: Pythonのループで要素にアクセス
def slow_operation(arr):
result = np.zeros_like(arr)
for i in range(len(arr)):
result[i] = arr[i] ** 2
return result
# OK: ベクトル化
def fast_operation(arr):
return arr ** 2
# NG: 頻繁な配列のリサイズ
def slow_append():
result = np.array([])
for i in range(10000):
result = np.append(result, i)
return result
# OK: 事前にサイズを確保
def fast_append():
result = np.zeros(10000)
for i in range(10000):
result[i] = i
return result
並列処理と非同期処理
この章は「何を選べば速くなるか」を整理するためのものです。まずは対象が I/O bound(待ち時間が支配的)なのか、CPU bound(計算が支配的)なのかを見極めます。
- I/O bound: ネットワーク、DB、ファイルI/Oなど。待ちの間に他の仕事を進められるので、
ThreadPoolExecutorやasyncioが効きやすい。 - CPU bound: 画像処理、重い数値計算、暗号、パースなど。CPythonはGILの影響でスレッドで並列に計算しにくいので、基本は
multiprocessing/ProcessPoolExecutorか、そもそもNumPy等のネイティブ実装へ寄せる。
よくある落とし穴
- スレッド/asyncは「速くする魔法」ではなく、待ち時間を重ねて“総待ち時間”を減らすための仕組み。CPUが詰まっている場合は効きません。
- マルチプロセスは起動とプロセス間通信(pickle/コピー)が高コストです。軽い処理を大量に投げると逆に遅くなります。
- 例外・キャンセル・タイムアウトを設計しないと、ハングやリトライ地獄で“全体として遅い/不安定”になります(性能だけでなく運用品質の問題)。
- 外部I/Oは相手側の制限(レート制限、コネクション上限、DB負荷)で頭打ちになります。並列数は計測しながら段階的に上げます。
マルチスレッド(I/O bound)
import concurrent.futures
import time
import requests
urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
]
# 順次実行(遅い)
def fetch_sequential():
results = []
for url in urls:
response = requests.get(url)
results.append(response.status_code)
return results
# スレッドプール(速い)
def fetch_threaded():
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(requests.get, urls))
return [r.status_code for r in results]
# 計測
start = time.time()
fetch_sequential()
print(f"順次実行: {time.time() - start:.2f}秒")
start = time.time()
fetch_threaded()
print(f"スレッド: {time.time() - start:.2f}秒")
マルチプロセス(CPU bound)
import concurrent.futures
import multiprocessing
import time
def cpu_intensive_task(n):
"""CPUを使う重い計算"""
total = 0
for i in range(n):
total += i ** 2
return total
# 順次実行
def sequential():
return [cpu_intensive_task(1000000) for _ in range(4)]
# マルチプロセス
def parallel():
with concurrent.futures.ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_intensive_task, [1000000] * 4))
return results
if __name__ == '__main__':
start = time.time()
sequential()
print(f"順次実行: {time.time() - start:.2f}秒")
start = time.time()
parallel()
print(f"マルチプロセス: {time.time() - start:.2f}秒")
asyncio(I/O bound)
import asyncio
import aiohttp
import time
urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
]
async def fetch_one(session, url):
async with session.get(url) as response:
return await response.text()
async def fetch_all():
async with aiohttp.ClientSession() as session:
tasks = [fetch_one(session, url) for url in urls]
return await asyncio.gather(*tasks)
# 実行
start = time.time()
results = asyncio.run(fetch_all())
print(f"非同期: {time.time() - start:.2f}秒")
キャッシュとメモ化
キャッシュは「同じ入力に対する計算を繰り返さない」ことで高速化しますが、代わりに メモリ使用量 と 整合性(いつ消えるべきか) の難しさが増えます。
- まず疑うべきは「本当に同じ入力が繰り返されているか」。繰り返されない処理にキャッシュを入れても、複雑さだけが増えがちです。
- 外部I/O(API/DB)結果のキャッシュは、データ更新・権限・障害時の振る舞いを含めて設計します(古いデータで良いのか/失敗をキャッシュしてよいのか等)。
functools.lru_cache
lru_cache は手軽ですが、実務では次を押さえると事故が減ります。
- キャッシュキーは「引数の値」です。可変な引数(list/dictなど)はそのまま渡せません。必要なら
tupleやfrozensetに変換します。 -
maxsize=Noneは無制限で増えるので要注意です。プロセス常駐(APIサーバ等)ではメモリリークに見えます。基本は上限を付け、命中率とメモリを見ます。 - TTL(時間で失効)は標準ではありません。「一定時間で更新したい」用途なら自前で期限管理するか、用途に合うキャッシュ層(Redis等)を検討します。
- クリア戦略: 設定変更・デプロイ・データ更新に合わせて
cache_clear()できるようにしておくと運用が楽です。
from functools import lru_cache
import timeit
# 再帰的なフィボナッチ(遅い)
def fib_slow(n):
if n < 2:
return n
return fib_slow(n - 1) + fib_slow(n - 2)
# メモ化(速い)
@lru_cache(maxsize=None)
def fib_cached(n):
if n < 2:
return n
return fib_cached(n - 1) + fib_cached(n - 2)
# 計測
n = 35
time_slow = timeit.timeit(lambda: fib_slow(n), number=1)
fib_cached.cache_clear() # キャッシュをクリア
time_cached = timeit.timeit(lambda: fib_cached(n), number=1)
print(f"通常: {time_slow:.4f}秒")
print(f"キャッシュ: {time_cached:.6f}秒")
# キャッシュ情報の確認
print(fib_cached.cache_info())
# CacheInfo(hits=33, misses=36, maxsize=None, currsize=36)
カスタムキャッシュ
from functools import wraps
import time
def timed_cache(seconds):
"""時間制限付きキャッシュ"""
def decorator(func):
cache = {}
@wraps(func)
def wrapper(*args):
current_time = time.time()
if args in cache:
result, timestamp = cache[args]
if current_time - timestamp < seconds:
return result
result = func(*args)
cache[args] = (result, current_time)
return result
return wrapper
return decorator
@timed_cache(60) # 60秒間キャッシュ
def expensive_api_call(user_id):
# 実際のAPI呼び出し
time.sleep(1)
return f"User {user_id} data"
その他の最適化テクニック
slots でメモリ削減
import sys
# 通常のクラス
class NormalPoint:
def __init__(self, x, y):
self.x = x
self.y = y
# __slots__を使用
class SlottedPoint:
__slots__ = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
# メモリ使用量の比較
normal_points = [NormalPoint(i, i) for i in range(10000)]
slotted_points = [SlottedPoint(i, i) for i in range(10000)]
print(f"通常: {sys.getsizeof(normal_points[0].__dict__)} bytes/instance")
# __slots__はインスタンスごとの__dict__を持たないため軽量
コンパイル済み正規表現
ポイントは「毎回コンパイルを避ける」だけではなく、パターン自体の設計です。
- 正規表現はパターン次第でバックトラッキングが増え、入力サイズに対して極端に遅くなることがあります(特に
.*と組み合わせた曖昧なパターン)。 - 本番入力の“最悪ケース”を想定して計測します。ログやユーザー入力を食わせる系は要注意です。
import re
import timeit
text = "Hello World " * 10000
# 遅い: 毎回コンパイル
def search_slow():
return re.findall(r'\b\w+\b', text)
# 速い: 事前コンパイル
pattern = re.compile(r'\b\w+\b')
def search_fast():
return pattern.findall(text)
time_slow = timeit.timeit(search_slow, number=100)
time_fast = timeit.timeit(search_fast, number=100)
print(f"毎回コンパイル: {time_slow:.4f}秒")
print(f"事前コンパイル: {time_fast:.4f}秒")
適切な例外処理
例外は便利ですが、ホットパスで乱用すると遅くなりがちです。とはいえ「常にLBYLが速い」でもありません。
- 失敗が稀(ほぼキーが存在する等)なら、EAFPが簡潔で速いこともあります。
- 失敗が頻発する経路に例外を使うとコストが跳ねやすいので、分岐に寄せたり
dict.getのようなAPIを使います。 - 速度より先に読みやすさ/正しさを優先し、必要になった箇所だけ計測して判断します。
import timeit
data = {'key': 'value'}
# 遅い: 常に例外を捕捉(EAFP: Easier to Ask for Forgiveness than Permission)
def with_exception():
try:
return data['nonexistent']
except KeyError:
return None
# 速い: 事前チェック(LBYL: Look Before You Leap)
def with_check():
if 'nonexistent' in data:
return data['nonexistent']
return None
# キーが存在しない場合
time_exception = timeit.timeit(with_exception, number=1000000)
time_check = timeit.timeit(with_check, number=1000000)
print(f"例外: {time_exception:.4f}秒")
print(f"チェック: {time_check:.4f}秒")
# ただし、キーが存在する場合は例外の方が速いこともある
# 状況に応じて使い分けることが重要
まとめ
この記事では、Pythonコードを高速化するための様々なテクニックを紹介しました。
| カテゴリ | テクニック | 効果 |
|---|---|---|
| 計測 | timeit, cProfile | ボトルネック特定 |
| データ構造 | set, dict, deque | 検索・操作の高速化 |
| 組み込み関数 | sum, max, map | ループより高速 |
| 内包表記 | リスト内包、ジェネレータ | 簡潔で高速 |
| 文字列 | join, f-string | 効率的な結合 |
| ループ | ローカル変数キャッシュ | 参照の高速化 |
| NumPy | ベクトル化 | ループをC実装へ寄せる |
| 並列処理 | multiprocessing, asyncio | CPU/I/O boundに応じて選ぶ |
| キャッシュ | lru_cache | 重複計算の回避(メモリ/整合性に注意) |
最適化の優先順位
まずはアルゴリズムとデータ構造の見直しから始めましょう。適切なデータ構造を選ぶだけで、桁違いの改善が得られることがあります。その後、プロファイリングでボトルネックを特定し、この記事で紹介したテクニックを適用してください。
「早すぎる最適化は諸悪の根源」という言葉もあります。まずは読みやすいコードを書き、必要に応じて最適化を行うことをお勧めします!