Skip to content

Commit 018805e

Browse files
wmaynerclaude
andcommitted
Evict from in-memory caches instead of freezing at the memory ceiling
Once resident memory reached `maximum_cache_memory_bytes` (or `maximum_cache_memory_percentage`), a cache refused every new entry for the rest of the process, so which results stayed cached was decided by whichever happened to be computed first and every later lookup missed. Entries now live in a `ByteBoundedStore`, shared by `ContentCache` and the module-level `@cache` decorator, which holds its byte weight at the level it had reached and admits new entries by evicting least recently used ones. Occupancy is measured in bytes rather than entries because neither argument space is bounded by a count: the combinatorial index tables key on a sequence length alone, giving one entry per system size but values growing as 2^N or 3^N, while `max_entropy_distribution` keys on a purview, giving one entry per subset. An entry too large to fit the whole budget is refused rather than allowed to flush the working set, and a bound latched during a transient spike is re-checked and lifted when memory frees up. Eviction does not lower resident memory — freeing Python objects returns their memory to the process allocator, rarely to the operating system — so the policy holds occupancy steady rather than trying to shrink it. What it changes is which entries a fixed allocation is spent on. Measured on a scoped cause-effect structure sweep with the ceiling binding over the last 58% of the work: the ceiling cost 1.455x the unbounded run under the freeze and 1.037x under eviction, with the hit rate rising from 72.6% to 95.5% against an unbounded 95.6%, on fewer entries and lower peak memory than freezing used. `memory_full()` built a fresh `psutil.Process` on every call, ten times the cost of reading from an existing one, and it runs on every cache miss: 19s of a 208s run at 1.3M misses. The handle is now reused and rebuilt after a fork. The `@cache` decorator's `maxmem` branch is removed: every call site passed `maxmem=None` and its default bound the config at import time, so it never ran. Its `cache=` backing-store parameter goes too, since every call site passed a fresh dict and no store was ever shared. `pyphi.cache.info()` now reports `nbytes` and `evictions` alongside hits, misses, and entry count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qi3MYsc3afQMxoWZZo2tEL
1 parent f7e5575 commit 018805e

14 files changed

Lines changed: 614 additions & 107 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
In-memory caches now evict under memory pressure instead of freezing. Once
2+
resident memory reaches `maximum_cache_memory_bytes` (or
3+
`maximum_cache_memory_percentage`), a cache used to refuse every new entry for
4+
the rest of the process, so which results stayed cached was decided by whichever
5+
happened to be computed first. It now holds its occupancy at the level it had
6+
reached and admits new entries by discarding the least recently used ones. On a
7+
scoped cause-effect structure sweep with the ceiling binding over the last 58%
8+
of the work, that cut the cost of the ceiling from 1.46× the unbounded run to
9+
1.04×, and raised the hit rate from 72.6% to 95.5% against an unbounded 95.6% —
10+
while holding fewer entries and less resident memory than freezing did.
11+
12+
Occupancy is measured in bytes rather than entries, since the argument spaces
13+
differ in kind: the combinatorial index tables are keyed on a sequence length
14+
alone, giving one entry per system size but values growing as 2ᴺ or 3ᴺ, while
15+
`max_entropy_distribution` is keyed on a purview, giving one entry per subset.
16+
Neither is bounded by a count. An entry too large to fit the whole budget is
17+
skipped rather than allowed to displace everything else, and a ceiling reached
18+
during a transient spike is re-checked and lifted if memory frees up again.
19+
20+
Eviction does not lower resident memory — freeing Python objects returns their
21+
memory to the process allocator, rarely to the operating system. What it changes
22+
is which entries a fixed allocation is spent on.
23+
24+
`pyphi.cache.info()` now reports `nbytes` and `evictions` alongside hits, misses,
25+
and entry count.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
The cache memory check no longer builds a new `psutil.Process` on every call.
2+
It runs on every cache miss, and constructing the handle cost about ten times as
3+
much as reading resident memory from an existing one: 14.5 µs per call against
4+
1.4 µs. On a scoped cause-effect structure sweep making 1.3 million misses, that
5+
was 19 seconds of a 208-second run, and the share grows the more the cache
6+
misses. The handle is now reused, and rebuilt after a fork.

pyphi/cache/__init__.py

Lines changed: 42 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -28,41 +28,38 @@
2828
:mod:`pyphi.cache.registry` for the registry implementation.
2929
"""
3030

31-
import os
3231
from functools import update_wrapper
3332

3433
import joblib
35-
import psutil
3634

3735
from pyphi import constants
38-
from pyphi.conf import config
3936

37+
from .cache_utils import ByteBoundedStore
4038
from .cache_utils import _CacheInfo
4139
from .cache_utils import _make_key
4240

4341
# An on-disk cache for distributing pre-computed results with the PyPhi package
4442
joblib_memory = joblib.Memory(location=constants.DISK_CACHE_LOCATION, verbose=0)
4543

4644

47-
def cache(
48-
cache=None,
49-
maxmem: int | None = config.infrastructure.maximum_cache_memory_percentage,
50-
typed: bool = False,
51-
):
52-
"""Memory-limited memoization decorator.
45+
def cache(typed: bool = False):
46+
"""Memoization decorator bounded by bytes.
5347
54-
Arguments to the cached function must be hashable.
48+
Arguments to the cached function must be hashable. Entries are held in a
49+
:class:`pyphi.cache.cache_utils.ByteBoundedStore`, so the cache grows
50+
freely until resident memory reaches the configured ceiling and then holds
51+
its occupancy steady, evicting least recently used entries to admit new
52+
ones.
53+
54+
A byte bound rather than an entry bound because the argument spaces here
55+
differ in kind: the combinatorial index tables are keyed on a sequence
56+
length alone, giving at most one entry per system size but values that grow
57+
as 2ᴺ or 3ᴺ, while :func:`pyphi.distribution.max_entropy_distribution` is
58+
keyed on a purview, giving one entry per subset. Neither is bounded by a
59+
count.
5560
5661
Parameters
5762
----------
58-
cache : dict, optional
59-
Backing store for cached results. A fresh empty dict is created when
60-
omitted; passing one shares the store across decorated functions.
61-
maxmem : float or None, optional
62-
Maximum percentage of physical memory the cache may use, between 0 and
63-
100 inclusive. ``None`` (or 0) means unlimited. Once the process
64-
exceeds this fraction of memory, no new entries are stored, though
65-
entries already cached are still served.
6663
typed : bool, optional
6764
If ``True``, arguments of different types are cached separately: for
6865
example, ``f(3.0)`` and ``f(3)`` are treated as distinct calls with
@@ -71,71 +68,46 @@ def cache(
7168
Notes
7269
-----
7370
The decorated function exposes ``cache_info()``, which returns a
74-
``(hits, misses, currsize)`` named tuple; ``cache_clear()``, which empties
75-
the cache and resets its statistics; and ``__wrapped__``, the underlying
76-
function.
71+
``(hits, misses, currsize, nbytes, evictions)`` named tuple;
72+
``cache_clear()``, which empties the cache and resets its statistics; and
73+
``__wrapped__``, the underlying function.
7774
"""
78-
# Constants shared by all lru cache instances:
75+
store = ByteBoundedStore()
76+
entries = store.data
7977
# Unique object used to signal cache misses.
80-
if cache is None:
81-
cache = {}
8278
sentinel = object()
8379
# Build a key from the function arguments.
8480
make_key = _make_key
8581

8682
def decorating_function(user_function, hits=0, misses=0):
87-
full = False
8883
# Bound method to look up a key or return None.
89-
cache_get = cache.get
90-
91-
if not maxmem:
92-
93-
def wrapper(*args, **kwds):
94-
# Simple caching without memory limit.
95-
nonlocal hits, misses
96-
key = make_key(args, kwds, typed)
97-
result = cache_get(key, sentinel)
98-
if result is not sentinel:
99-
hits += 1
100-
return result
101-
result = user_function(*args, **kwds)
102-
cache[key] = result
103-
misses += 1
104-
return result
105-
106-
else:
107-
# Type narrowing: maxmem is not None in this branch
108-
assert maxmem is not None, "maxmem should not be None in else branch"
109-
maxmem_value = maxmem
110-
111-
def wrapper(*args, **kwds):
112-
# Memory-limited caching.
113-
nonlocal hits, misses, full
114-
key = make_key(args, kwds, typed)
115-
result = cache_get(key)
116-
if result is not None:
117-
hits += 1
118-
return result
119-
result = user_function(*args, **kwds)
120-
if not full:
121-
cache[key] = result
122-
# Cache is full if the total recursive usage is greater
123-
# than the maximum allowed percentage.
124-
current_process = psutil.Process(os.getpid())
125-
full = current_process.memory_percent() > maxmem_value
126-
misses += 1
84+
cache_get = entries.get
85+
86+
def wrapper(*args, **kwds):
87+
nonlocal hits, misses
88+
key = make_key(args, kwds, typed)
89+
result = cache_get(key, sentinel)
90+
if result is not sentinel:
91+
hits += 1
92+
# Reinsert to move the entry to the recent end of the store's
93+
# iteration order, which is the eviction order.
94+
del entries[key]
95+
entries[key] = result
12796
return result
97+
result = user_function(*args, **kwds)
98+
store.admit(key, result)
99+
misses += 1
100+
return result
128101

129102
def cache_info():
130103
"""Report cache statistics."""
131-
return _CacheInfo(hits, misses, len(cache))
104+
return _CacheInfo(hits, misses, len(entries), store.nbytes, store.evictions)
132105

133106
def cache_clear():
134107
"""Clear the cache and cache statistics."""
135-
nonlocal hits, misses, full
136-
cache.clear()
108+
nonlocal hits, misses
109+
store.clear()
137110
hits = misses = 0
138-
full = False
139111

140112
wrapper.cache_info = cache_info # type: ignore[attr-defined]
141113
wrapper.cache_clear = cache_clear # type: ignore[attr-defined]
@@ -147,8 +119,9 @@ def cache_clear():
147119
_register_policy(
148120
_DictCacheAdapter(
149121
name=f"{user_function.__module__}.{user_function.__qualname__}",
150-
backing=cache,
151-
stats=lambda: (cache_info().hits, cache_info().misses),
122+
backing=entries,
123+
stats=lambda: (hits, misses),
124+
weigh=lambda: (store.nbytes, store.evictions),
152125
)
153126
)
154127

pyphi/cache/cache_utils.py

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,31 @@
33

44
import os
55
from collections import namedtuple
6+
from functools import lru_cache
7+
from typing import Any
68

9+
import numpy as np
710
import psutil
811

912
from pyphi.conf import config
1013

11-
_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "currsize"])
14+
_CacheInfo = namedtuple(
15+
"CacheInfo",
16+
["hits", "misses", "currsize", "nbytes", "evictions"],
17+
defaults=(0, 0),
18+
)
19+
20+
21+
@lru_cache(maxsize=1)
22+
def _process_handle(pid: int) -> psutil.Process:
23+
"""The psutil handle for ``pid``, kept for reuse.
24+
25+
Constructing the handle costs ten times as much as reading resident
26+
memory from an existing one, and :func:`memory_full` is called on every
27+
cache miss. Holding only the newest handle means a forked child replaces
28+
its parent's rather than reusing it.
29+
"""
30+
return psutil.Process(pid)
1231

1332

1433
def memory_full():
@@ -18,7 +37,7 @@ def memory_full():
1837
is set, and otherwise against ``maximum_cache_memory_percentage`` of total
1938
physical memory.
2039
"""
21-
current_process = psutil.Process(os.getpid())
40+
current_process = _process_handle(os.getpid())
2241
budget = config.infrastructure.maximum_cache_memory_bytes
2342
if budget is not None:
2443
return current_process.memory_info().rss > budget
@@ -28,6 +47,157 @@ def memory_full():
2847
)
2948

3049

50+
_ENTRY_OVERHEAD_BYTES = 512
51+
"""Non-payload cost of one entry: its key, the value object, and a dict slot.
52+
53+
Calibrated against the resident-memory growth of a scoped cause-effect
54+
structure sweep, where entries averaged roughly 900 bytes by a walk of the live
55+
objects and roughly 540 bytes by the process's own resident growth. It is a
56+
single constant rather than a per-entry measurement because sizing each key
57+
exactly would cost more than the admission it informs.
58+
"""
59+
60+
_ARRAY_DIM_BYTES = 16
61+
"""Per-dimension cost of an ndarray's shape and strides, one intp each."""
62+
63+
_RECHECK_INTERVAL = 4096
64+
"""Admissions between ceiling re-checks once a store is bounded.
65+
66+
Re-checking lets a bound that was latched during a transient spike be lifted,
67+
and keeps the cost of the check off all but one admission in this many.
68+
"""
69+
70+
_MISSING = object()
71+
72+
73+
def entry_weight(value: Any) -> int:
74+
"""Estimated bytes one cached value occupies, including its key and slot.
75+
76+
An ndarray view's buffer belongs to the array it derives from, so only the
77+
view object itself is charged. A sequence — the combinatorial index tables
78+
are lists of tuples — is charged per element, since its cost is the
79+
elements rather than any single buffer.
80+
"""
81+
if isinstance(value, np.ndarray):
82+
payload = value.nbytes if value.base is None else 0
83+
return _ENTRY_OVERHEAD_BYTES + _ARRAY_DIM_BYTES * value.ndim + payload
84+
if isinstance(value, list | tuple):
85+
return _ENTRY_OVERHEAD_BYTES + sum(map(_element_weight, value))
86+
return _ENTRY_OVERHEAD_BYTES
87+
88+
89+
_SEQUENCE_HEADER_BYTES = 56
90+
"""Object header of a tuple or list, before its element pointers."""
91+
92+
_POINTER_BYTES = 8
93+
94+
_SCALAR_BYTES = 32
95+
"""A small int or similar leaf, counted once rather than by identity.
96+
97+
Small integers are interned, so charging each occurrence overstates a table of
98+
index tuples. The overstatement is uniform across entries, which is what the
99+
bound compares.
100+
"""
101+
102+
_MAX_WEIGHT_DEPTH = 4
103+
104+
105+
def _element_weight(element: Any, depth: int = 0) -> int:
106+
"""Bytes one element of a cached sequence occupies.
107+
108+
Recurses through nested sequences to a fixed depth, since the index tables
109+
nest two or three levels and a bound that stopped at the first would
110+
undercount them by the width of every inner tuple.
111+
"""
112+
if depth < _MAX_WEIGHT_DEPTH and isinstance(element, tuple | list):
113+
return (
114+
_SEQUENCE_HEADER_BYTES
115+
+ _POINTER_BYTES * len(element)
116+
+ sum(_element_weight(x, depth + 1) for x in element)
117+
)
118+
return _SCALAR_BYTES
119+
120+
121+
class ByteBoundedStore:
122+
"""A dict that holds its byte weight steady once memory reaches the ceiling.
123+
124+
Insertion order is the recency order, so the least recently used entry is
125+
the first one iteration yields; a caller reinserts an entry on a hit to
126+
move it to the recent end. Until resident memory reaches the cache ceiling
127+
(see :func:`memory_full`) the store grows freely. From then on it admits an
128+
entry by evicting least recently used ones, and refuses one too large to
129+
fit an empty store rather than flushing everything to hold it.
130+
131+
Holding the weight steady, rather than shrinking it, is what the ceiling
132+
can deliver: freeing Python objects returns their memory to the process
133+
allocator for reuse but rarely to the operating system, so eviction does
134+
not lower resident memory. What it buys is spending a fixed allocation on
135+
recently used entries instead of on whichever were computed first.
136+
137+
Not internally synchronized. A caller sharing a store across threads holds
138+
its own lock across :meth:`admit` and :meth:`discard`.
139+
"""
140+
141+
def __init__(self) -> None:
142+
self.data: dict[Any, Any] = {}
143+
self.evictions = 0
144+
self._weight = 0
145+
self._budget: int | None = None
146+
self._admissions = 0
147+
148+
@property
149+
def nbytes(self) -> int:
150+
"""Estimated bytes held by this store's entries."""
151+
return self._weight
152+
153+
def admit(self, key: Any, value: Any) -> None:
154+
"""Store an entry, evicting least recently used ones to make room."""
155+
weight = entry_weight(value)
156+
self._admissions += 1
157+
# Consult the ceiling while unbounded, periodically once bounded, and
158+
# whenever the bound is about to refuse an entry outright, so a bound
159+
# latched during a transient spike cannot persist.
160+
if (
161+
self._budget is None
162+
or self._admissions % _RECHECK_INTERVAL == 0
163+
or weight > self._budget
164+
):
165+
if memory_full():
166+
# Hold occupancy here and trade old entries for new ones.
167+
if self._budget is None:
168+
self._budget = self._weight
169+
else:
170+
# Room again, whether because the spike that set the bound has
171+
# passed or because memory was released elsewhere.
172+
self._budget = None
173+
if self._budget is not None:
174+
while self.data and self._weight + weight > self._budget:
175+
oldest, evicted = next(iter(self.data.items()))
176+
del self.data[oldest]
177+
self._weight -= entry_weight(evicted)
178+
self.evictions += 1
179+
if self._weight + weight > self._budget:
180+
# Does not fit even in an empty store. Storing it would evict
181+
# the whole working set for one entry.
182+
return
183+
self.discard(key)
184+
self.data[key] = value
185+
self._weight += weight
186+
187+
def discard(self, key: Any) -> None:
188+
"""Remove an entry if present, crediting back its weight."""
189+
previous = self.data.pop(key, _MISSING)
190+
if previous is not _MISSING:
191+
self._weight -= entry_weight(previous)
192+
193+
def clear(self) -> None:
194+
self.data.clear()
195+
self.evictions = 0
196+
self._weight = 0
197+
self._budget = None
198+
self._admissions = 0
199+
200+
31201
class _HashedSeq(list):
32202
"""This class guarantees that ``hash()`` will be called no more than once
33203
per element. This is important because the ``lru_cache()`` will hash the

0 commit comments

Comments
 (0)