【Python】同じ計算を繰り返さない:lru_cacheとキャッシュの消しどき

PythonのTopに戻る


同じ引数で同じ計算を何度も行うなら、functools.lru_cacheで結果を再利用できる。保存する件数に上限を付け、cache_info()で効果を確認し、入力の意味が変わったらcache_clear()で消す。ただし、時刻やファイルの内容など引数以外の状態で結果が変わる処理へ、無条件に付けるのは避けたい。

引数をキーに結果を保存する

キャッシュは、前に同じ条件で求めた結果を保存し、再び必要になったときに使う仕組みである。計算結果が引数だけで決まる関数では適用しやすい。同じ計算を繰り返すかどうか、結果を保存する費用が見合うかを考えて使う。

lru_cacheのmaxsizeは、保存する呼び出し結果の最大件数である。上限を超えると、最近使われていないものから外れる。値のバイト数を上限にする指定ではないので、一件の結果が大きければ、小さな件数でも多くのメモリを使う可能性がある。

命中数と再計算を確かめる

次のsquareは、説明のために実際に計算した引数をcallsへ記録する。計算自体は非常に軽く、速度改善を測る例ではない。キャッシュが働くと、二度目の2では関数本体が動かず、上限から外れた後の2では再計算されることを確認する。

example.py

from functools import lru_cache

calls = []


@lru_cache(maxsize=2)
def square(value):
    calls.append(value)  # 実行回数を観察するための記録
    return value * value


results = [square(value) for value in (2, 2, 3, 4, 2)]
print(results)
print("computed:", calls)
info = square.cache_info()
print(info)
assert results == [4, 4, 9, 16, 4]
assert calls == [2, 3, 4, 2]
assert (info.hits, info.misses, info.currsize) == (1, 4, 2)
square.cache_clear()
print(square.cache_info())
assert square.cache_info().currsize == 0


@lru_cache(maxsize=1)
def labels():
    return ["A"]


labels().append("B")
print("mutable result:", labels())
assert labels() == ["A", "B"]

実行結果

[4, 4, 9, 16, 4]
computed: [2, 3, 4, 2]
CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)
CacheInfo(hits=0, misses=0, maxsize=2, currsize=0)
mutable result: ['A', 'B']

hitsは保存した結果を使った回数、missesは結果がなく本体を呼んだ回数である。最後には3と4の追加によって古い2が外れ、2をもう一度計算する。cache_clearを呼ぶと保存内容と統計がリセットされる。

引数はハッシュ可能な値にする

辞書のキーと同様に、キャッシュのキーになる引数はハッシュ可能である必要がある。リストや辞書をそのまま渡すと使えない。数値一覧を不変のタプルに変えるなどの方法はあるが、入力の意味を保ったまま変換することが重要になる。

キーワード引数の渡し方や順序が異なると、別のキーとして記録される場合がある。また、typedによって型の区別を強める設定もある。異なる書き方が必ず同じキャッシュへ入ると期待せず、呼び出し形式をそろえると効果を確認しやすい。

返された可変オブジェクトは共有される

最後のlabelsはリストを返す。呼び出し側がそのリストへ追加すると、次回も変更後の同じリストが返る。キャッシュは毎回新しいコピーを渡してくれるわけではないため、利用者が結果を変更する設計では注意が必要である。

変更させたくない結果なら、タプルなど不変の形で返す方法を検討する。どうしてもリストが必要なら、呼び出し側へコピーを渡す層を分ける方法もある。キャッシュ内に保持する値と、利用者が自由に変更する値を分けて考えよう。

消すタイミングを設計する

例えば設定ファイルを読む関数で、引数がパスだけなら、同じパスの内容が更新されても以前の結果が返り続ける。更新時にcache_clearする、更新日時や版をキーへ含めるなど、入力の変化を表す仕組みが必要になる。

時刻、乱数、外部API、ファイル保存のように、呼び出すたびに意味のある変化や副作用を持つ処理は、キャッシュに向かない場合が多い。呼び出しが省略されることで何が起こらなくなるかを確認する。async関数へそのまま付け、コルーチンを再利用する形にも注意が必要である。

上限と効果を運用中も見る

キャッシュは引数と戻り値への参照を、追い出されるか消去されるまで保持する。maxsize=Noneなら上限がなくなるため、入力の種類が増え続ける処理では特に注意したい。まず有限の上限から始め、cache_infoの命中率とメモリ使用の両方を確認する。

内部のデータ構造はスレッドに対して整合性を保つが、同じ引数の同時呼び出しで計算が一度だけになるとは限らない。初回の処理が終わる前に別の呼び出しが入る場合がある。重複実行を絶対に避ける用途では、キャッシュとは別に同期を考える必要がある。

動作確認と参考資料

掲載例はLinux・CPython 3.12.14で動作確認した。OS固有のコマンドや環境ごとに変わるパスは、本文中の条件を確認して使ってほしい。

関連するTips


PythonのTopに戻る