パフォーマンスの悩みは、勘ではなく実測で解決します。
Pythonでは小さな処理の比較にはtimeit
、実アプリに近い自由な計測にはtime.perf_counter
が基本です。
この記事では、Python初心者向けに両者の使い方とコツを具体例で丁寧に解説し、正しく再現可能な計測方法を身につけます。
Pythonの実行時間を計測する基本
実行時間を計測する目的(正確な比較のため)
実行時間の計測は、処理のボトルネックを見つけたり、コードの書き方を変えたときに効果があったかを客観的に確認するために行います。
アルゴリズムの選択やデータ構造の変更は、環境や入力データによって結果が変わります。
したがって、思い込みではなく、同条件での実測比較が重要です。
特に小さな最適化は差が微小なので、ノイズに埋もれない測り方を徹底します。
time.time()ではなく高精度タイマーを使う理由
time.time()
は壁時計の時刻(UNIX時間)で、NTP同期などの影響で逆行することがあり得ます。
また、分解能が粗い環境があります。
これに対しtime.perf_counter()
はモノトニックかつ高分解能で、計測用に設計されています。
Pythonで実行時間を測るときは常にperf_counter
系(あるいはtimeit
内部で使われるタイマー)を使いましょう。
整数ナノ秒が必要ならperf_counter_ns()
も選べます。
測定は複数回行う(ばらつき対策)
OSのスケジューリング、CPU周波数変動、キャッシュの状態などで1回ごとにばらつきます。
複数回測って代表値(最小値や中央値)を見るのが実務の基本です。
小さな処理ならtimeit
が自動で反復計測してくれます。
自由計測の場合も自分でループし、min
やstatistics.median
を使うと安定します。
ウォームアップ実行を1〜2回入れてキャッシュを温めてから本計測に入れるとさらに安定します。
timeitの使い方(小さな処理のベンチマーク)
最小コード例(Pythonのtimeit)
timeit
は、短いコード片の速度を手軽に比較するための標準ライブラリです。
ガーベジコレクタを一時的に無効化するなど、ノイズを抑えた測定が自動で行われます。
# timeit の最小例
# 短い処理(sum(range(1000)))の実行時間を、10,000回の合計時間として測定します。
from timeit import timeit
elapsed = timeit('sum(range(1000))', number=10_000) # number は1回の計測で実行する回数
print(f'total: {elapsed:.4f}s, per loop: {elapsed/10_000*1e6:.2f} µs')
# 出力例(環境により異なります)
total: 0.5201s, per loop: 52.01 µs
文字列で書く方法は最も互換性が高いです。
最近のPythonではstmt
にコール可能オブジェクトも渡せますが、文字列のほうがJupyterやコマンドラインとの親和性が高いです。
関数の実行時間を測る
関数を定義して測る場合、setup
でインポートや準備を行い、globals
で名前解決するのが簡単です。
# 関数の実行時間を timeit で測定する例
from timeit import timeit
def add_squares(n: int) -> int:
# 0..n-1 の二乗和を返す単純な関数
return sum(i*i for i in range(n))
# 文字列と globals を使う方法(互換性重視)
t = timeit('add_squares(1000)', globals=globals(), number=5_000)
print(f'total: {t:.4f}s, per call: {t/5_000*1e6:.2f} µs')
# コール可能を直接渡す方法(対応するPythonなら可)
t2 = timeit(lambda: add_squares(1000), number=5_000)
print(f'(callable) total: {t2:.4f}s, per call: {t2/5_000*1e6:.2f} µs')
total: 0.4102s, per call: 82.04 µs
(callable) total: 0.4095s, per call: 81.90 µs
number/repeatの意味と目安
number
: 1回の計測で同じ処理を何回ループするかを決めます。処理が速すぎる場合はnumber
を大きくして、1回の計測合計が0.1〜1.0秒程度になるように調整すると安定します。repeat
: 上記の1回の計測自体を何回繰り返すかです。最小値(=best
)を採用すると、突発的な遅延の影響を受けにくくなります。
# repeat を使ってばらつきを見る
from timeit import repeat
times = repeat('sum(range(1000))', number=10_000, repeat=5)
best = min(times)
avg = sum(times)/len(times)
print(f'all: {times}')
print(f'best: {best:.4f}s, avg: {avg:.4f}s')
all: [0.5201, 0.5342, 0.5179, 0.5225, 0.5186]
best: 0.5179s, avg: 0.5227s
コマンドラインとJupyterでの使い方
Pythonは-m timeit
でコマンドラインから直接ベンチマークできます。
JupyterではIPythonマジック%timeit
が使えます。
# コマンドラインからの例
python -m timeit -n 10000 -r 5 "sum(range(1000))"
python -m timeit -n 10000 -r 5 -s "from math import sqrt" "sum(sqrt(i) for i in range(1000))"
10000 loops, best of 5: 51.9 usec per loop
10000 loops, best of 5: 245 usec per loop
# Jupyter/IPython での例(セルマジック)
# 1行だけなら %timeit
%timeit sum(range(1000))
# 複数行を測るなら %%timeit (セル全体)
%%timeit
total = 0
for i in range(1000):
total += i
51.3 µs ± 1.1 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
40.2 µs ± 0.8 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
Jupyterの%timeit
は内部で複数回回して統計を表示してくれるため、学習用途や手軽な比較にとても便利です。
小さなコードの比較に向く
timeit
は短時間で終わるCPUバウンド処理(例: 内包表記、関数呼び出しのオーバーヘッド、アルゴリズム差の比較)に適しています。
GCを一時無効化し、準備コードと測定対象を分離できるため、対象の純粋な速度を測りやすいです。
一方、I/Oや外部要因に左右される処理の測定は、後述のperf_counter
で自由に制御しながら行うほうが向いています。
perf_counterの使い方(自由度の高い計測)
基本の測り方(start/end)
time.perf_counter()
は、モノトニックな高精度タイマーです。
開始時刻と終了時刻の差分を取るのが基本です。
# perf_counter の基本
import time
start = time.perf_counter()
# 測りたい処理(例: 素朴なフィボナッチ)
def fib(n: int) -> int:
return n if n < 2 else fib(n-1) + fib(n-2)
result = fib(20)
end = time.perf_counter()
elapsed = end - start # 秒
print(f'fib(20) = {result}, elapsed = {elapsed:.6f}s')
fib(20) = 6765, elapsed = 0.012345s
より高精度な整数ナノ秒が必要ならtime.perf_counter_ns()
を使うと、丸め誤差を避けられます。
関数や処理全体を測る
デコレータやコンテキストマネージャを作ると、複数箇所の計測を一貫した書き方で行えます。
# シンプルなコンテキストマネージャによる測定
import time
from contextlib import contextmanager
@contextmanager
def measure(label: str = ''):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f'[{label}] {elapsed*1e3:.2f} ms')
# 使い方の例
with measure('heavy loop'):
total = 0
for i in range(2_000_000):
total += i
[heavy loop] 120.45 ms
# デコレータ版(関数の実行時間を毎回測る)
import time
from functools import wraps
def timeit_func(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f'{func.__name__} took {elapsed*1e3:.2f} ms')
return wrapper
@timeit_func
def slow_add(a, b):
time.sleep(0.05) # 擬似的に遅い処理
return a + b
print(slow_add(10, 20))
slow_add took 50.12 ms
30
I/Oを含む処理の計測に適する
ファイル読み書き、ネットワーク、データベース、time.sleep()
などのI/Oは、外部要因による揺らぎが大きく、timeit
の前提(短時間・多数反復)と相性がよくありません。
必要な前処理・後処理も含めて、perf_counter
で1回の処理全体を測ると実運用に近い数値になります。
# I/O を含む処理の計測例(ファイル書き込み→読み出し)
import time
from pathlib import Path
data = 'x' * 1_000_000 # 1MBの文字列
p = Path('tmp_io_test.txt')
start = time.perf_counter()
p.write_text(data, encoding='utf-8') # 書き込み(I/O)
text = p.read_text(encoding='utf-8') # 読み出し(I/O)
p.unlink(missing_ok=True) # 後片付け
elapsed = time.perf_counter() - start
print(f'I/O round trip: {elapsed*1e3:.2f} ms, size={len(text)} bytes')
# 出力例(ストレージにより大きく変動)
I/O round trip: 18.73 ms, size=1000000 bytes
結果の表示(秒やmsに変換)
人間が読みやすい単位に整形しましょう。
秒をミリ秒(ms)やマイクロ秒(µs)に変換して表示すると比較しやすくなります。
# 単位変換とフォーマット
import time
start = time.perf_counter()
time.sleep(0.0123)
elapsed = time.perf_counter() - start # 秒
print(f'{elapsed:.6f} s') # 秒
print(f'{elapsed*1e3:.2f} ms') # ミリ秒
print(f'{elapsed*1e6:.1f} µs') # マイクロ秒
# ナノ秒精度が必要なら perf_counter_ns を使う
ns_start = time.perf_counter_ns()
time.sleep(0.001)
ns_elapsed = time.perf_counter_ns() - ns_start
print(f'{ns_elapsed} ns')
0.012300 s
12.30 ms
12300.0 µs
1001234 ns
timeitとperf_counterの使い分けとコツ
使い分けの目安(Pythonの実行時間計測)
どちらも標準ライブラリで信頼できますが、得意分野が異なります。
以下の表が実務での目安です。
観点 | timeit | perf_counter |
---|---|---|
想定する処理 | 極小〜小さなCPU処理 | 任意の処理(関数単位、I/O含む) |
反復実行 | 自動(安定化のため多数) | 自分で制御 |
準備コード | setup引数で分離 | 自由に書ける |
ノイズ対策 | GC一時無効化など内蔵 | 自前で工夫 |
出力 | 総時間/反復回数(µsなど) | 秒/任意単位に整形 |
典型用途 | 実装のマイクロベンチ | 実アプリの所要時間 |
小さなコード片の比較にはtimeit
、業務処理の1トランザクション全体にはperf_counter
という意識で選ぶとよいです。
ウォームアップとノイズ対策
計測前に1回走らせてキャッシュやインポートを済ませるウォームアップを行い、OSのバックグラウンド負荷が少ないときに測りましょう。
可能なら以下を意識します。
- 同じマシン・同じPythonバージョン・同じ依存ライブラリで測る。
- 大きいノイズ源(ウイルス対策のスキャン、重いブラウザタブなど)を避ける。
- 長めに測れるときは
min
や中央値を採用する。 - 繰り返し計測で最小値(best)を参考にするのは、たまたま遅い回を避けるための実務的な指針です。
同条件で測る(入力・乱数seed固定)
同じ入力で比較しなければ公平になりません。
乱数を使う場合はシードを固定します。
# 乱数シードを固定して公平に比較
import random
from timeit import timeit
random.seed(0)
data = [random.randint(0, 1_000_000) for _ in range(50_000)]
def f_sort(a): # 組み込みソート
return sorted(a)
def f_sort_copy(a): # コピーしてからソート(差を出すための例)
b = list(a)
b.sort()
return b
t1 = timeit(lambda: f_sort(data), number=100)
t2 = timeit(lambda: f_sort_copy(data), number=100)
print(f' sorted: {t1:.3f}s, per call {t1/100*1e3:.2f} ms')
print(f' list.sort via copy: {t2:.3f}s, per call {t2/100*1e3:.2f} ms')
sorted: 0.842s, per call 8.42 ms
list.sort via copy: 0.969s, per call 9.69 ms
printやログ出力を含めない
標準出力へのprint
は非常に遅いため、測定対象に含めないようにします。
ログ出力も同様で、速度比較のときはロガーを無効化するか、出力しないダミーハンドラに差し替えます。
どうしても必要な場合は、perf_counter
で「出力を除いた区間」と「出力を含む区間」を分けて測ると影響を把握できます。
NG例(time.time(), datetime.now()で計測)
以下は避けるべき例です。
理由はそれぞれコメントに記載しています。
# NG: time.time() での計測
# 壁時計の時刻はNTP同期で逆行する可能性があり、分解能も環境依存で粗めです。
import time
start = time.time()
time.sleep(0.01)
elapsed = time.time() - start # 値が負になる可能性も理論上はある
print(elapsed) # たいていは動くが、perf_counter を使うべき
# NG: datetime.now() での計測
# 日付時刻はタイムゾーン/夏時間/調整の影響を受け、性能計測用ではありません。
from datetime import datetime
start = datetime.now()
# ... 処理 ...
elapsed = (datetime.now() - start).total_seconds() # これは避ける。perf_counter を使う。
print(elapsed)
正しい選択は常にtime.perf_counter()
かtimeit
です。
この2つはプラットフォームごとの最適な高精度タイマーを内部で使います。
まとめ
実行時間の計測は、性能改善を定量的に進めるための第一歩です。
小さなコード片の比較はtimeit
、I/Oを含む実処理や自由な構成の計測はtime.perf_counter
が基本方針です。
複数回測って代表値を見る、同条件で比較する、printなどを測定対象に入れないといったコツを守ることで、再現性の高い結果が得られます。
最後にもう一度強調すると、time.time()やdatetime.now()では計測しないことです。
正しい道具と正しい手順で、安定したベンチマークを習慣化しましょう。