はじめに
Python 3.9から導入された@functools.cache
は、一度計算した関数の結果を保存(メモ化)し、同じ引数で再度呼び出された際には計算をスキップして保存した結果を返す、非常に便利なデコレータです。これにより、特に再帰関数やコストの高い計算処理を劇的に高速化できる場合があります。しかし、その内部仕様、特にキーワード引数の扱いについては、パフォーマンスを最大化するための設計上の判断がなされており、利用する上で注意が必要です。
本記事では、以下の内容について詳しく解説します。
-
@functools.cache
の簡易版を自作して、その仕組みを理解する -
@functools.cache
のキーワード引数に対する仕様について- キーワード引数の順序が異なる場合、別のキャッシュエントリーとして扱われること
- その背景にあるパフォーマンス上のトレードオフ
- ベンチマークを用いたパフォーマンスの検証
「直感的」なキャッシュの実装例
キャッシュデコレータの実装はとてもシンプルです。引数をキーとする辞書を用意し、関数の呼び出し時にそのキーが存在するかを確認します。存在しない場合は関数を実行して結果をキャッシュします。
以下にその実装例を示します。
import functools
def sorted_kwargs_cache(function):
"""キーワード引数をソートして正規化するキャッシュデコレータ"""
_cache = {}
def wrapper(*args, **kwargs):
# キーワード引数をキーでソートし、順序不変なタプルを生成
key = (args, tuple(sorted(kwargs.items())))
if key in _cache:
return _cache[key]
result = function(*args, **kwargs)
_cache[key] = result
return result
return wrapper
@sorted_kwargs_cache
def sample_func(x, y, z=0):
return x * y + z
# 実行
print(sample_func(10, 20, z=30))
# キーワード引数の順序が異なってもキャッシュが利用される
print(sample_func(10, z=30, y=20))
キャッシュデコレータを実装する際、f(a=1, b=2)
とf(b=2, a=1)
のようにキーワード引数の順序が異なるだけの呼び出しを、同一のものとして扱いたいと考えるのは自然な発想です。そこで、この実装では、キーワード引数をソートしてタプルに変換することで、引数の順序に依存しないキャッシュキーを生成しています。
functools.cache
の実際の仕様
前述の実装は直感的ですが、Python標準ライブラリの@functools.cache
は異なる挙動をします。公式ドキュメントには、次のような記述があります。
引数のパターンが異なる場合は、異なる呼び出しと見なされ別々のキャッシュエントリーとなります。 例えば、
f(a=1, b=2)
とf(b=2, a=1)
はキーワード引数の順序が異なっているので、2つの別個のキャッシュエントリーになります。
この仕様は、意図的な設計判断によるものです。functools.py
のソースコードには、その理由が明確に記されています。
All of code below relies on kwds preserving the order input by the user. Formerly, we sorted() the kwds before looping. The new way is much faster; however, it means that f(x=1, y=2) will now be treated as a distinct call from f(y=2, x=1) which will be cached separately.
(以下のコードはすべて、ユーザーが指定したキーワード引数の順序を保持することを前提としています。従来は、ループ処理を行う前に kwds をソートしていました。新しい方法では処理速度が大幅に向上しますが、これにより f(x=1, y=2) と f(y=2, x=1) はそれぞれ異なる呼び出しとして扱われ、別々にキャッシュされることになります。)
cpython/Lib/functools.py at 39b2f82717a69dde7212bc39b673b0f55c99e6a3 · python/cpython · GitHub
要約すると、過去のバージョンではキーワード引数をソートしていましたが、パフォーマンス向上のため、現行バージョンではソート処理を廃止した、ということです。
キーワード引数ソートのオーバーヘッド検証
functools.cache
がソート処理を省略した背景には、「much faster」という記述がありました。
しかし、現実的なユースケースにおいて、キーワード引数のソートがそれほどパフォーマンスに影響を与えるのか、疑問に思いました。実際、キーワード引数の個数は通常は少数(例えば5個~10個)であり、ソート処理のオーバーヘッドは無視できるのではないかと考えました。そこでこのパフォーマンスへの影響を具体的に検証するため、キーワード引数のソートの有無によるオーバーヘッドを比較するベンチマークを実施します。
検証コード
import timeit
# キーワード引数が5個の場合
kwargs_small = {f'k{i}': i for i in range(5)}
# キーワード引数が20個の場合
kwargs_large = {f'k{i}': i for i in range(20)}
# ソートなし(現行の`functools.cache`に近い方式)
def no_sort(kwargs):
return tuple(kwargs.items())
# ソートあり
def do_sort(kwargs):
return tuple(sorted(kwargs.items()))
# 各100万回実行して計測
time_no_sort_small = timeit.timeit(lambda: no_sort(kwargs_small), number=1_000_000)
time_do_sort_small = timeit.timeit(lambda: do_sort(kwargs_small), number=1_000_000)
time_no_sort_large = timeit.timeit(lambda: no_sort(kwargs_large), number=1_000_000)
time_do_sort_large = timeit.timeit(lambda: do_sort(kwargs_large), number=1_000_000)
検証結果
キーワード引数の数 | ソートなし(秒) | ソートあり(秒) | パフォーマンス差 |
---|---|---|---|
5個 | 0.1423 | 0.2285 | 1.61倍遅い |
20個 | 0.3020 | 1.0166 | 3.37倍遅い |
※実行環境により数値は変動します
ベンチマーク結果から、キャッシュキー生成のたびにソート処理を実行すると、実際オーバーヘッドが1.6倍から3.4倍以上に増加することがわかります。引数の数が多くなれば、この差はさらに拡大します。
@functools.cache
は関数の高速化を目的とするため、デコレータ自体のオーバーヘッドを最小限に抑えるこの設計は、理にかなっていると言えます。
仕様の背景にある設計思想
このパフォーマンス検証の結果から、functools.cache
の仕様の背景にある設計思想を考察できます。
- 一般的なユースケースの優先: ほとんどのプログラムにおいて、関数呼び出し時のキーワード引数の順序は固定されています。順序が動的に変わるケースは稀であり、一般的なユースケースで最高のパフォーマンスを発揮することが優先されています。
- デコレータの責務: 高速化のためのデコレータが、それ自体の処理でボトルネックになることは避けるべきです。ソート処理の省略は、この責務を果たすための直接的な手段です。
- シンプルさと予測可能性: 「呼び出しのシグネチャが異なれば、別の呼び出しとして扱われる」というルールは、挙動がシンプルで予測しやすくなります。
これらの点から、functools.cache
の現在の仕様は、一部の利便性よりも全体的なパフォーマンスを重視した、明確なトレードオフの結果であると理解できます。
まとめ
@functools.cache
は、Pythonの関数キャッシュ機能を提供する強力なデコレータですが、その内部仕様、特にキーワード引数の扱いには注意が必要です。キーワード引数の順序が異なる場合、それらは別々のキャッシュエントリーとして扱われます。この設計は、パフォーマンスを最大化するための意図的な判断によるものであり、特にキーワード引数の数が多い場合には、ソート処理を省略することで大幅な速度向上が実現されています。