コーディング · データ構造
LRUキャッシュとは?仕組み・コード・計算量を解説
結論
LRU(Least Recently Used、最長未使用)キャッシュは、一定数の要素を保持し、満杯になったら最も長い間使われていない要素を追い出します。定番の設計は、任意のキーをO(1)で見つけるハッシュマップと、使用順に並べた双方向連結リストの組み合わせです。連結リストでは、任意のエントリをO(1)で移動・削除できます。Pythonでは、collections.OrderedDictを使えばLRUキャッシュが数行で書け、functools.lru_cacheを使えば任意の関数にキャッシュを付けられます。
LRUキャッシュが解決する問題
キャッシュは、データベースのクエリ、Webリクエスト、重い計算のような遅い処理の結果を保存し、同じリクエストにすばやく答えられるようにします。メモリには限りがあるため、キャッシュは満杯になったときに何を捨てるかを決めなければなりません。LRUは、最近使われたデータはすぐにまた使われ、長い間触られていないデータはおそらく使われない、という賭けをします。この賭けは多くの実際のワークロードでうまくいくので、LRUとその近似は、CPUキャッシュ、OSのページキャッシュ、データベース、Webサーバー、CDNに登場します。
考え方:2つの構造が協力する
速くしたいことが2つあります。キーを見つけることと、どのキーが最も長く使われていないかを知ることです。ハッシュマップだけでは順序がありません。リストだけでは検索が遅くなります。2つを組み合わせれば、両方を実現できます。
| 部品 | 役割 | O(1)である理由 |
|---|---|---|
| ハッシュマップ | 各キーを、リスト内のノードに対応付ける | 平均ケースのハッシュ検索 |
| 双方向連結リスト | ノードを、最近使われた順から古い順に並べて保持する | prevとnextポインタでノードを切り離すと、4つの参照が変わるだけ |
| 先頭側 | 最も最近使われたエントリ | 新しいエントリとアクセスされたエントリはここに移される |
| 末尾側 | 最も長く使われていないエントリ | キャッシュが満杯のときは、ここから追い出す |
get(key)では、マップでノードを検索し、先頭に移動して、その値を返します。put(key, value)では、ノードを更新または新規作成して先頭に置きます。キャッシュが容量を超えたら、末尾のノードを削除し、そのキーをマップからも削除します。
具体例:容量2の場合
| 操作 | 結果 | 操作後の順序(新しい順) | 備考 |
|---|---|---|---|
| put(1, "a") | - | 1 | |
| put(2, "b") | - | 2, 1 | キャッシュが満杯 |
| get(1) | "a" | 1, 2 | 1が最も新しくなる |
| put(3, "c") | - | 3, 1 | 最も長く使われていない2を追い出す |
| get(2) | miss | 3, 1 | 2は追い出されている |
| put(1, "A") | - | 1, 3 | 更新により1が先頭に移る |
| put(4, "d") | - | 4, 1 | 3を追い出す |
ゼロから作るPython実装
番兵(sentinel)として先頭ノードと末尾ノードを置くと、空のリストの特殊ケースがなくなります。実データのノードは、必ず前後にノードを持つからです。
class Node:
__slots__ = ("key", "value", "prev", "next")
def __init__(self, key=None, value=None):
self.key, self.value = key, value
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.map = {}
self.head, self.tail = Node(), Node() # sentinels
self.head.next, self.tail.prev = self.tail, self.head
def _unlink(self, node):
node.prev.next, node.next.prev = node.next, node.prev
def _push_front(self, node):
node.prev, node.next = self.head, self.head.next
self.head.next.prev = node
self.head.next = node
def get(self, key):
node = self.map.get(key)
if node is None:
return None
self._unlink(node)
self._push_front(node)
return node.value
def put(self, key, value):
if key in self.map:
self._unlink(self.map[key])
node = self.map[key] = Node(key, value)
self._push_front(node)
if len(self.map) > self.capacity:
lru = self.tail.prev
self._unlink(lru)
del self.map[lru.key]cache = LRUCache(2)
cache.put(1, "a")
cache.put(2, "b")
print(cache.get(1)) # a
cache.put(3, "c") # evicts key 2
print(cache.get(2)) # None
cache.put(1, "A")
cache.put(4, "d") # evicts key 3
print(cache.get(3), cache.get(1), cache.get(4)) # None A dOrderedDictを使った短い版
collections.OrderedDictは、それ自体がハッシュマップと双方向連結リストの組み合わせで、move_to_endとpopitem(last=False)はO(1)です。このため、オブジェクトとしてLRUキャッシュが必要なときの、Pythonらしい選択になります。
from collections import OrderedDict
class LRU:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key) # mark as most recent
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False) # drop least recent
c = LRU(2)
c.put("x", 1)
c.put("y", 2)
c.get("x")
c.put("z", 3)
print(list(c.data)) # ['x', 'z']functools.lru_cacheで関数の結果をキャッシュする
関数の戻り値をキャッシュするには、functools.lru_cacheデコレータを使います。引数は辞書のキーになるため、ハッシュ可能でなければなりません。cache_info()で、ヒット数、ミス数、現在のサイズが分かります。
from functools import lru_cache
@lru_cache(maxsize=128)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(80)) # 23416728348467685
print(fib.cache_info())
# CacheInfo(hits=78, misses=81, maxsize=128, currsize=81)maxsize=Noneにすると上限のないキャッシュになります。functools.cache(Python 3.9以降)がこれに当たります。fib.cache_clear()を呼ぶと、キャッシュが空になります。
時間計算量と空間計算量
| 操作 | 時間 | 理由 |
|---|---|---|
| get | 平均O(1) | ハッシュ検索と、1ノードの切り離しおよび再接続 |
| put(挿入または更新) | 平均O(1) | ハッシュの挿入と、定数回のポインタ変更 |
| 追い出し | O(1) | 最も長く使われていないノードは常に末尾の番兵の隣にある |
| 空間 | O(容量) | キャッシュされた要素ごとに、マップのエントリ1つとリストのノード1つ |
変種、間違い、LRUを使う場面
- LFU(Least Frequently Used)は、アクセス回数が最も少ない要素を追い出します。1回限りのスキャンから人気のある要素を守れますが、より複雑です。
- 近似LRU:Redisのようなシステムは、正確なリストを保持する代わりに、いくつかのキーをサンプリングし、その中で最も古いものを追い出します。メモリを節約できます。
- TTLキャッシュは、一定時間が過ぎたエントリを期限切れにします。サイズの上限と組み合わせることが多いです。
getのとき、または既存のキーのputのときに、エントリを先頭に移動し忘れると、LRUがFIFOになってしまいます。- 追い出したキーをハッシュマップから削除し忘れると、宙に浮いたエントリが残り、メモリリークになります。
- LRUキャッシュをスレッド間で共有するには、各操作をロックで囲む必要があります。
functools.lru_cacheは、複数のスレッドから呼んでも安全です。
最近の使用が将来の使用を予測できるなら、LRUは良いデフォルトです。大きな1回限りのスキャンで、頻繁に使われるデータが押し出されると、性能が悪くなります。そのようなワークロードには、LFUやスキャン耐性のあるポリシーが向いています。O(1)の操作を持つLRUキャッシュの設計は、ハッシュマップと連結リストを同時に試せるため、コーディング面接で最もよく出る問題の1つでもあります。
よくある質問
LRUとは何の略ですか?
Least Recently Used(最長未使用)の略です。キャッシュが満杯になると、読み書きされずに最も長い時間が経ったエントリを追い出します。
LRUキャッシュに双方向連結リストが必要なのはなぜですか?
リストの途中からノードをO(1)で取り除くには、その前のノードが必要です。双方向連結リストは、すべてのノードにprevを持ちます。単方向連結リストでは、O(n)の検索が必要になります。
Pythonのdictだけで、LRUキャッシュを実装できますか?
Python 3.7以降、dictは挿入順を保持するので、キーを削除して再挿入することで「最近使った」印を付け、next(iter(d))を追い出せます。OrderedDictなら、move_to_endとpopitem(last=False)で、意図がより明確に表せます。
functoolsのlru_cacheとcacheの違いは何ですか?
functools.cacheは上限のないキャッシュで、lru_cache(maxsize=None)と同等です。数値を指定したlru_cacheは、そのサイズに達すると、最も長く使われていない結果を追い出します。
LRUは常に最良の追い出しポリシーですか?
いいえ。単純で、最近の要素が再利用されやすい場合にはうまく働きますが、大きな逐次スキャンによって有用なデータが押し流されることがあります。LFU、ARCなどのポリシーは、そのようなワークロードをよりうまく扱えます。