
pytestのプラグインpytest-benchmarkでコードのベンチマーク結果を比べてみる
はじめに
データ事業本部のkobayashiです。
Pythonでパフォーマンス重視のアプリケーションを開発していると、コードの変更がパフォーマンスに与える影響を定量的に把握したい場面があります。特に、リファクタリングや最適化を行った際、「本当に速くなったのか?」「どの程度改善されたのか?」を客観的に評価する必要があります。
今回は、pytest-benchmark
プラグインの比較モード(--benchmark-compare
)を使って、複数のベンチマーク結果を比較し、パフォーマンスの変化を可視化する方法を詳しく解説します。
pytest-benchmarkとは
pytest-benchmarkは、pytestのプラグインとして動作するベンチマークツールです。関数の実行時間を測定し、統計的な分析結果を提供します。
主な特徴:
- pytestのテストと同じ感覚でベンチマークを記述できる
- 統計的に意味のある結果を得るための自動的な繰り返し実行
- 結果をJSON形式で保存し、後から比較が可能
- ヒストグラムやグラフによる視覚的な結果表示
- CIパイプラインへの統合が容易
特に重要なのは、結果を保存して後から比較できる機能です。これにより、以下のようなケースで非常に役立ちます。
- リファクタリング前後のパフォーマンス比較
- 異なるアルゴリズムの実装比較
- 継続的なパフォーマンス監視
- 過去のバージョンとの性能比較
今回はこの比較モードを試してみたいと思います。
比較モード(--benchmark-compare)を試してみる
--benchmark-compare
オプションは、pytest-benchmarkの中でも特に強力な機能です。保存された複数のベンチマーク結果を比較し、パフォーマンスの変化を定量的に示してくれます。
環境
今回使用した環境は以下の通りです。
Python 3.12.8
pytest 8.3.2
pytest-benchmark 5.1.0
インストール
pipで簡単にインストールできます。
$ pip install pytest-benchmark
基本的な使い方から比較モードまで
ステップ1: ベンチマーク対象の関数を準備
まず、ベンチマークを取りたい関数を2つのバージョンで準備します。ここでは、リスト内の要素を検索する処理を例にします。
# バージョン1: 線形探索
def linear_search(items, target):
"""線形探索でターゲットを検索"""
for i, item in enumerate(items):
if item == target:
return i
return -1
def process_data_v1(data_size=10000):
"""データ処理のバージョン1"""
items = list(range(data_size))
results = []
# 複数の要素を検索
for target in [100, 500, 1000, 5000, 9999]:
index = linear_search(items, target)
results.append(index)
return results
# バージョン2: セットを使った最適化版
def optimized_search(items_set, items_list, target):
"""セットを使った高速検索"""
if target in items_set:
return items_list.index(target)
return -1
def process_data_v2(data_size=10000):
"""データ処理のバージョン2(最適化版)"""
items = list(range(data_size))
items_set = set(items) # セットを事前に作成
results = []
# 複数の要素を検索
for target in [100, 500, 1000, 5000, 9999]:
index = optimized_search(items_set, items, target)
results.append(index)
return results
ステップ2: ベンチマークテストを作成
次に先ほど作成したベンチマーク対象となるモジュールをimportしてベンチマークテストを作成します。
import pytest
from search_v1 import process_data_v1
from search_v2 import process_data_v2
class TestSearchBenchmark:
"""検索アルゴリズムのベンチマーク"""
@pytest.mark.parametrize("data_size", [1000, 5000, 10000])
def test_linear_search_performance(self, benchmark, data_size):
"""線形探索のパフォーマンステスト"""
result = benchmark(process_data_v1, data_size)
assert len(result) == 5
@pytest.mark.parametrize("data_size", [1000, 5000, 10000])
def test_optimized_search_performance(self, benchmark, data_size):
"""最適化版のパフォーマンステスト"""
result = benchmark(process_data_v2, data_size)
assert len(result) == 5
# より詳細なベンチマーク設定を含むテスト
def test_detailed_benchmark(benchmark):
"""詳細な統計情報を取得するベンチマーク"""
benchmark.extra_info["version"] = "v2.0"
# カスタム設定でベンチマークを実行
# pedanticメソッドを使用すると、より精密な制御が可能
result = benchmark.pedantic(
process_data_v2, # 実行する関数
args=(10000,), # 関数の引数
iterations=5, # 各ラウンドで関数を実行する回数
rounds=20 # 統計的に有意な結果を得るためのラウンド数
)
assert len(result) == 5
test_detailed_benchmark
は、benchmark.pedantic()
メソッドを使用した精密なベンチマークテストになり、より安定した統計的な結果を得たい場合に使用します。
- 通常のbenchmark(): 自動的に最適な繰り返し回数を決定
- benchmark.pedantic(): 手動で繰り返し回数を制御
extra_info
でメタデータ(バージョン情報など)を追加可能iterations=5
: 各ラウンドで5回実行rounds=20
: 20ラウンド実施(合計100回実行)
ステップ3: ベンチマークを実行して結果を保存
それでは早速最初のバージョンsearch_v1.py
のベンチマークを実行し、結果を保存します。
# バージョン1のベンチマークを実行して保存
$ pytest test_benchmark.py::TestSearchBenchmark::test_linear_search_performance \
--benchmark-only \
--benchmark-save=linear_v1 \
--benchmark-autosave
======================================================================================================= test session starts =======================================================================================================
....
test_benchmark.py ... [100%]
....
------------------------------------------------------------------------------------------------ benchmark: 3 tests ------------------------------------------------------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_linear_search_performance[1000] 66.2920 (1.0) 488.4580 (1.04) 67.1211 (1.0) 4.9019 (1.0) 66.6045 (1.0) 0.3750 (1.0) 222;952 14.8984 (1.0) 12494 1
test_linear_search_performance[5000] 243.3750 (3.67) 470.2080 (1.0) 252.3735 (3.76) 11.6306 (2.37) 246.6250 (3.70) 11.4275 (30.48) 512;156 3.9624 (0.27) 3853 1
test_linear_search_performance[10000] 373.1670 (5.63) 803.1660 (1.71) 381.7513 (5.69) 15.5691 (3.18) 376.2920 (5.65) 10.4375 (27.84) 274;132 2.6195 (0.18) 2520 1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================================================================== 3 passed in 4.43s ========================================================================================================
ベンチマーク結果の各カラムは次のような内容になっています。
- Name (time in us): テスト名とパラメータ。時間の単位はマイクロ秒(us)
- Min: 最小実行時間。最も速かった実行の時間
- Max: 最大実行時間。最も遅かった実行の時間
- Mean: 平均実行時間。全実行の平均値
- StdDev: 標準偏差。実行時間のばらつきを示す
- Median: 中央値。実行時間を並べた時の真ん中の値
- IQR: 四分位範囲。第1四分位数と第3四分位数の差で、データの散らばりを示す
- Outliers: 外れ値の数。「;」で区切られ、左が1標準偏差、右が1.5IQRを超えた数
- OPS (Kops/s): 1秒あたりの実行回数(千回/秒)。パフォーマンスの指標
- Rounds: ベンチマークのラウンド数。統計的信頼性のための繰り返し数
- Iterations: 各ラウンドでの反復回数
上記の結果を見ると、データサイズが大きくなるにつれて実行時間が増加していることがわかります。例えば、test_linear_search_performance[10000]
のMeanは381.7513マイクロ秒で、1秒間に約2,619回(2.6195 Kops/s)実行できることを示しています。
次に、最適化版search_v2.py
のベンチマークを実行します。
# バージョン2のベンチマークを実行して保存
$ pytest test_benchmark.py::TestSearchBenchmark::test_optimized_search_performance \
--benchmark-only \
--benchmark-save=optimized_v2 \
--benchmark-autosave
======================================================================================================= test session starts =======================================================================================================
...
test_benchmark.py ... [100%]
...
------------------------------------------------------------------------------------------------- benchmark: 3 tests ------------------------------------------------------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_optimized_search_performance[1000] 17.7920 (1.0) 133.4580 (1.0) 18.1331 (1.0) 1.7466 (1.0) 18.0000 (1.0) 0.0840 (1.0) 165;902 55.1477 (1.0) 14926 1
test_optimized_search_performance[5000] 90.5000 (5.09) 239.0410 (1.79) 94.4194 (5.21) 5.3819 (3.08) 93.3750 (5.19) 2.1240 (25.29) 179;300 10.5910 (0.19) 5500 1
test_optimized_search_performance[10000] 251.8750 (14.16) 408.5420 (3.06) 256.3141 (14.14) 6.5450 (3.75) 255.2500 (14.18) 1.6670 (19.85) 139;266 3.9015 (0.07) 3358 1
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================================================================== 3 passed in 2.96s ========================================================================================================
最適化版の結果を見ると、線形探索版と比較して大幅にパフォーマンスが改善されています。
[10000]
のケースで比較すると:- 線形探索: Mean = 381.7513 us、OPS = 2.6195 Kops/s
- 最適化版: Mean = 256.3141 us、OPS = 3.9015 Kops/s
- 約33%の高速化を実現
ステップ4: 比較モードで結果を比較
ここで--benchmark-compare
の威力が発揮されます。保存されたベンチマーク結果を比較してみます。
まず、保存されたベンチマーク結果を確認します。
$ pytest test_benchmark.py --benchmark-compare=linear_v1,optimized_v2
....
test_benchmark.py ....... [100%]
-------------------------------------------------------------------------------------------------- benchmark: 7 tests --------------------------------------------------------------------------------------------------
Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_optimized_search_performance[1000] 17.7500 (1.0) 122.3750 (1.0) 18.0803 (1.0) 1.6294 (1.0) 17.9580 (1.0) 0.0840 (1.0) 307;2583 55.3088 (1.0) 33381 1
test_linear_search_performance[1000] 66.6670 (3.76) 807.6250 (6.60) 68.4327 (3.78) 12.8796 (7.90) 67.2500 (3.74) 0.2090 (2.49) 152;1808 14.6129 (0.26) 11402 1
test_optimized_search_performance[5000] 91.0420 (5.13) 179.2910 (1.47) 93.4784 (5.17) 3.1623 (1.94) 93.0840 (5.18) 0.7920 (9.43) 202;490 10.6977 (0.19) 5996 1
test_linear_search_performance[5000] 247.1250 (13.92) 381.2080 (3.12) 250.8996 (13.88) 8.0714 (4.95) 249.0000 (13.87) 1.3750 (16.37) 195;484 3.9857 (0.07) 3533 1
test_optimized_search_performance[10000] 251.4170 (14.16) 1,768.7500 (14.45) 256.1744 (14.17) 27.0197 (16.58) 254.4170 (14.17) 1.7080 (20.33) 27;295 3.9036 (0.07) 3583 1
test_detailed_benchmark 253.8750 (14.30) 265.9418 (2.17) 257.3238 (14.23) 3.5938 (2.21) 255.8084 (14.24) 2.9750 (35.42) 4;2 3.8862 (0.07) 20 5
test_linear_search_performance[10000] 377.3330 (21.26) 539.0000 (4.40) 381.1587 (21.08) 8.2775 (5.08) 379.2500 (21.12) 1.6670 (19.85) 140;299 2.6236 (0.05) 2490 1
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================================================================== 7 passed in 5.88s ========================================================================================================
結果の並び順と比較として、デフォルトでは実行時間(Mean)の速い順に並んでいます。また括弧内の数値は最速のテストを1.0とした相対値です。ここから同じデータサイズで比較すると以下のようになります。
[1000]
: 最適化版 18.08 us vs 線形探索 68.43 us → 約3.8倍高速[5000]
: 最適化版 93.48 us vs 線形探索 250.90 us → 約2.7倍高速[10000]
: 最適化版 256.17 us vs 線形探索 381.16 us → 約1.5倍高速
ここからパフォーマンスの差がわかりやすくなっており、これらの比較からデータサイズが大きくなるにつれて、最適化の効果が相対的に小さくなっていることが簡単にわかります。これは、セットの作成コストが一定であるのに対し、線形探索のコストがデータサイズに比例するためだと推測できます。
まとめ
pytest-benchmarkの比較モードは、コードの性能変化を定量的に把握する強力なツールです。これにより、推測や感覚に頼らず、実測データで「改善した」ことを客観的に証明でき、リファクタリング時の意図しない性能劣化も早期に発見できます。
特にGitHub ActionsなどでCI/CDパイプラインに統合すると効果的です。プルリクエストごとにパフォーマンスを自動で比較し、定期的にベンチマークを実行することでパフォーマンスの推移を追跡できます。これにより品質の低下を未然に防ぐ門番として機能させることが可能です。
パフォーマンスが重要なプロジェクトでは、ぜひpytest-benchmark
の比較モードを活用して、継続的なパフォーマンス管理を実践してみてください。