Skip to content

Commit 1c4a1a4

Browse files
wmaynerclaude
andcommitted
Stop memoizing the unconstrained forward cause repertoire
`intrinsic_information` is called once per (direction, mechanism, purview), so reaching this cache needs the same pair evaluated twice, which does not happen within an analysis: it accumulated 30,625 entries and served zero hits over a 175-mechanism scoped sweep. The work it depends on, `forward_cause_repertoire`, is memoized in its own right; what remains is a mean, an allocation, and a fill, worth about 4us against the 0.7us a lookup costs — and that margin stays flat as mechanisms grow, since the purview sets it. Results stay read-only, now frozen in the function body rather than by the memoizing decorator. Its effect-direction counterpart keeps its cache: that one averages a forward effect repertoire over every mechanism state, so a hit is worth 10x a lookup at a one-unit mechanism and 99x at three, growing with mechanism size. Dropping both measured 2.4% faster and 107 MiB smaller on a scoped sweep; dropping the cause side alone takes about half of that with no case where a hit was worth keeping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qi3MYsc3afQMxoWZZo2tEL
1 parent bf037aa commit 1c4a1a4

3 files changed

Lines changed: 82 additions & 9 deletions

File tree

ROADMAP.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,9 +827,58 @@ item), mutation testing (N3/N17 ← T2), and the matching exact-oracle/standard-
827827
time. The request and the enforced bound come from one figure, so the estimate
828828
cannot drift from what the job is actually allowed. An order-3 purview shard goes
829829
from a 1.5 GB request with no effective ceiling to a 2.5 GB request with caching
830-
stopping at 2.25 GB, and the 16 GB floor is no longer needed. The 1 GB headroom
830+
bounded at 2.25 GB, and the 16 GB floor is no longer needed. The 1 GB headroom
831831
is a design allowance, not yet a measured constant — peak `MemoryUsage` from a
832832
real shard under this build would settle it.
833+
- **✅ Eviction at the ceiling instead of a permanent freeze.** Reaching the ceiling
834+
used to stop a cache storing anything for the rest of the process, so its contents
835+
were fixed by computation order and every later lookup missed. `ContentCache` now
836+
holds the byte weight it had reached and admits new entries by discarding the
837+
least recently used, measuring occupancy in bytes because repertoire size varies
838+
by orders of magnitude with purview order. Measured on a 10-unit substrate,
839+
order-3 purviews, 175 mechanisms, with the ceiling binding over the last 102:
840+
freeze cost **1.455×** the unbounded control, eviction **1.037×** (paired per
841+
mechanism, eviction faster on 81/102, median ratio 1.48); hit rate 72.6% → 95.5%
842+
against an unbounded 95.6%, on roughly half the entries; peak resident memory
843+
312 MiB against freeze's 338 MiB. Eviction cannot *lower* resident memory —
844+
clearing 477 MiB of entries returned none of it to the OS, since freed objects go
845+
back to the process allocator — so the policy holds occupancy steady rather than
846+
shrinking it. Ownership was confirmed first: `RepertoireIrreducibilityAnalysis`
847+
copies its repertoires (`np.array`), so results never alias cached arrays.
848+
Related: the ceiling check built a fresh `psutil.Process` on every cache miss at
849+
14.5 µs a call, which was **most of the freeze policy's measured cost** (19 s of a
850+
208 s run) rather than the recomputation it forced; reusing the handle brings it
851+
to 1.59 µs. The `@cache` decorator's `maxmem` branch, which the audit began from,
852+
was dead — all five call sites passed `maxmem=None` and its default bound the
853+
config at import — and is deleted, along with its unused `cache=` backing-store
854+
parameter (every call site passed a fresh dict, so no store was ever shared).
855+
Two kernel caches (`unconstrained_forward_{cause,effect}_repertoire`) took
856+
**zero hits** across a full 175-mechanism sweep, 11.4% of cache bytes never
857+
read — structurally, since `intrinsic_information` is called once per
858+
`(direction, mechanism, purview)`, so a hit needs the same pair evaluated
859+
twice. Dropping both was measured 2.4% faster and 107 MiB smaller. They split
860+
on what a hit is worth: the cause version saves a flat ~4 µs against a 0.7 µs
861+
lookup, because its expensive input `forward_cause_repertoire` is already
862+
memoized, so **its memoization is removed** (the read-only guarantee moves
863+
into the function body); the effect version averages over every mechanism
864+
state, worth 10× a lookup at |m|=1 and 99× at |m|=3 and growing, so it keeps
865+
its cache as insurance against an expensive miss.
866+
- **✅ The same bound on the module-level `@cache` caches.** `@cache` holds its
867+
entries in a shared `ByteBoundedStore` (`cache/cache_utils.py`), which both it
868+
and `ContentCache` use, so one policy governs every in-process cache. A byte
869+
bound rather than a count because the two argument spaces fail a count in
870+
opposite ways: the combinatorial index tables key on a sequence length alone —
871+
at most one entry per system size, but values growing as 2ᴺ or 3ᴺ
872+
(`directed_tripartition_indices(12)` is a single 317 MiB entry) — while
873+
`max_entropy_distribution` keys on a purview, giving 2ⁿ − 1 entries (4,095 and
874+
6 MiB at n=12; 65,535 and 363 MiB at n=16). The estimator tracks a walk of the
875+
live objects to within 1.0–1.2× on large values, erring high. Exposure is
876+
confined to IIT 3.0: measured over `ces()`, `sia()`, and 127 campaign shards,
877+
**no IIT 4.0 path touches these caches at all**`max_entropy_distribution` is
878+
reached only from `cause_repertoire` with an empty mechanism, and IIT 4.0 takes
879+
the `unconstrained_forward_*` kernel route instead. `num_subsets_larger_than_one_element`
880+
is no longer memoized: at 109 ns it is cheaper to evaluate than the ~250 ns a
881+
lookup costs, so its cache was a 2× loss on a function with no callers.
833882

834883
## Context
835884

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
`unconstrained_forward_cause_repertoire` is no longer memoized. The work it
2+
depends on, `forward_cause_repertoire`, has its own cache; what the outer
3+
function adds is a mean, an allocation, and a fill, worth about 4 µs against the
4+
0.7 µs a cache lookup costs — and that margin stays flat as mechanisms grow,
5+
since the purview sets it. Reaching the cache at all needs the same
6+
`(mechanism, purview)` pair evaluated twice, which does not happen within an
7+
analysis: `intrinsic_information` is called once per pair. On a scoped
8+
cause-effect structure sweep it accumulated 30,625 entries and served **zero**
9+
hits.
10+
11+
Its effect-direction counterpart keeps its cache. That one averages a forward
12+
effect repertoire over every mechanism state, so a hit is worth 10× a lookup at
13+
a one-unit mechanism and 99× at three, growing with mechanism size.

pyphi/core/repertoire_algebra.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ def _memoize(fn: Callable) -> Callable:
5555
5656
Distinct-but-equivalent Systems (re-constructed, or label-distinct) share
5757
entries via :class:`~pyphi.cache.content.ContentCache`. A fingerprint's
58-
entries are evicted when its last live carrier is garbage-collected. Stops
59-
inserting new entries when ``cache_repertoires`` is false or when
60-
``cache_utils.memory_full()`` reports process memory above
61-
``maximum_cache_memory_percentage`` — already-computed values are still
62-
returned, just not cached. Returned arrays are read-only;
63-
callers that need a mutable copy must copy explicitly.
58+
entries are evicted when its last live carrier is garbage-collected. Once
59+
resident memory reaches the configured cache ceiling, occupancy is held
60+
steady by evicting least recently used entries; setting
61+
``cache_repertoires`` to false stops caching entirely, in which case
62+
computed values are returned but not stored. Returned arrays are
63+
read-only; callers that need a mutable copy must copy explicitly.
6464
6565
Cache keys carry the resolved cause-side background convention, so
6666
cause-side entries never cross conventions (effect-side entries are
@@ -520,14 +520,23 @@ def unconstrained_forward_effect_repertoire(
520520
return total / n_states
521521

522522

523-
@_memoize
524523
def unconstrained_forward_cause_repertoire(
525524
cs: Any, mechanism: tuple[int, ...], purview: tuple[int, ...]
526525
) -> Any:
527526
"""Unconstrained forward cause repertoire — see Eq. 32 of the IIT 4.0 paper.
528527
529528
Since ``m`` is fixed and we average over ``Z``, the per-state
530529
probabilities are all equal to the mean — fill with that value.
530+
531+
Notes
532+
-----
533+
Deliberately not memoized, unlike its effect-direction counterpart. The
534+
work it depends on is :func:`forward_cause_repertoire`, which is memoized;
535+
what remains is a mean, an allocation, and a fill, worth about 4 µs against
536+
the roughly 0.7 µs a lookup costs — and that margin does not grow with
537+
mechanism size, since the cost is set by the purview. Reaching it needs the
538+
same ``(mechanism, purview)`` evaluated twice, which
539+
:func:`intrinsic_information` does not do within one analysis.
531540
"""
532541
mean_forward_cause_probability = forward_cause_repertoire(
533542
cs, mechanism, purview, None
@@ -539,7 +548,9 @@ def unconstrained_forward_cause_repertoire(
539548
)
540549
)
541550
result.fill(mean_forward_cause_probability)
542-
return result
551+
# Read-only like every other repertoire the kernel returns; the memoizing
552+
# decorator does this for the functions it wraps.
553+
return _freeze(result)
543554

544555

545556
def unconstrained_forward_repertoire(

0 commit comments

Comments
 (0)