Skip to content

Commit f7e5575

Browse files
wmaynerclaude
andcommitted
Bound a campaign shard's caches by its own memory request
A shard evaluates every mechanism it carries against one long-lived System, and kernel repertoire entries are keyed on that System's fingerprint and evicted only when it is collected, so cache occupancy grows with the number of mechanisms packed into a shard under a bound that is combinatorial in substrate size. The one brake, cache_utils.memory_full(), compared resident memory to maximum_cache_memory_percentage of total physical memory, which cannot bound a process confined to an allocation smaller than the machine: on a large execute node it permits tens of GB of cache while the scheduler kills the job at its request. Real peak memory ran roughly ten times over shard_memory_bytes, which models only the largest single repertoire and so cannot see any of this. Add infrastructure.maximum_cache_memory_bytes, an absolute resident-memory ceiling that replaces the percentage when set. Grant CACHE_HEADROOM_BYTES in shard_memory_bytes, derive the enforced ceiling from a request with shard_cache_budget_bytes, and install it during shard execution from the shard's own memory_bytes, deferring to a ceiling configured at preparation time. The request and the enforced bound now come from one figure, so the estimate cannot drift from what the job is allowed. An order-3 purview shard goes from a 1.5 GB request with no effective ceiling to a 2.5 GB request with caching stopping at 2.25 GB resident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PYZcQZadLvUoodNM578Aa
1 parent 88cd633 commit f7e5575

13 files changed

Lines changed: 211 additions & 14 deletions

File tree

CACHING.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Hamming matrices, ...) through a uniform process-local cache surface in
1010
- ``pyphi.cache.clear(name)``: clear one named cache.
1111

1212
The total memory footprint of in-memory caches is bounded by the
13-
``config.infrastructure.maximum_cache_memory_percentage`` option.
13+
``config.infrastructure.maximum_cache_memory_percentage`` option, or, for a
14+
process allowed only part of the machine, by
15+
``config.infrastructure.maximum_cache_memory_bytes``.
1416

1517
Setting ``config.infrastructure.disk_cache_results = True`` additionally
1618
persists whole SIA and cause-effect-structure results to a

ROADMAP.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -699,9 +699,11 @@ item), mutation testing (N3/N17 ← T2), and the matching exact-oracle/standard-
699699
`max_repertoire_cells`; `shard_memory_bytes`/`round_memory_bytes` in `pyphi/cost.py`;
700700
packing runs within memory classes; the submit file requests `$(memory)` from
701701
`task id, memory` rows in `remaining.txt` (uniform for whole-cell sweeps);
702-
`request_memory` is now the floor under the estimates. Calibration constants
703-
(`REPERTOIRE_FACTOR`, `BASE_MEMORY_BYTES`) to be validated against Condor's
704-
`MemoryUsage` on the first real campaign.
702+
`request_memory` is now the floor under the estimates. The first production
703+
campaign found the estimate structurally incomplete rather than mis-calibrated —
704+
see the 2026-07-24 section below. The calibration constants
705+
(`REPERTOIRE_FACTOR`, `BASE_MEMORY_BYTES`, `CACHE_HEADROOM_BYTES`) still want
706+
validation against Condor's `MemoryUsage`.
705707
- **`limit=` on `prepare_ces`.** Threaded through with default `100_000_000` (10×
706708
the bare walk default), and the planning walk now runs once — `prepare_ces` passes
707709
its workloads into `plan_ces_shards` instead of walking twice.
@@ -799,6 +801,36 @@ item), mutation testing (N3/N17 ← T2), and the matching exact-oracle/standard-
799801
> is per abstraction — enumerable containers have lengths, closed forms have
800802
> counts, samples have estimates.
801803
804+
### 2026-07-24 shard cache memory bound
805+
806+
> A production campaign needed a 16 GB `request_memory` floor because real peak
807+
> memory ran roughly ten times over `shard_memory_bytes`. The reported cause — that
808+
> the estimate models only the largest single repertoire, so it degenerates to the
809+
> base overhead at low purview order — was true but secondary. A shard evaluates
810+
> every mechanism it carries against one long-lived `System`, and kernel repertoire
811+
> entries are keyed on that `System`'s fingerprint and evicted only when it is
812+
> collected, so cache occupancy grows with `units_per_job` under a bound that is
813+
> combinatorial in substrate size. The one brake, `cache_utils.memory_full()`,
814+
> compared resident memory to `maximum_cache_memory_percentage` of *total physical
815+
> memory*: on an execute node far larger than the job's allocation it permits tens
816+
> of GB of cache while the scheduler kills the job at its request. The brake existed
817+
> and was unreachable.
818+
819+
- **✅ Cache ceiling tied to the shard's own request.** New
820+
`infrastructure.maximum_cache_memory_bytes` (default `None`) gives an absolute
821+
resident-memory ceiling and replaces the percentage when set — what any process
822+
confined to less than the whole machine needs (a scheduler-managed job, a
823+
container, a cgroup). `shard_memory_bytes` grants `CACHE_HEADROOM_BYTES` on top of
824+
the working set, `shard_cache_budget_bytes` derives the enforced ceiling from a
825+
request (less a reserve for allocations in flight), and shard execution installs
826+
it from `spec.memory_bytes`, deferring to a ceiling configured at preparation
827+
time. The request and the enforced bound come from one figure, so the estimate
828+
cannot drift from what the job is actually allowed. An order-3 purview shard goes
829+
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
831+
is a design allowance, not yet a measured constant — peak `MemoryUsage` from a
832+
real shard under this build would settle it.
833+
802834
## Context
803835

804836
PyPhi is a scientific library implementing Integrated Information Theory (IIT). It has
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Bounded the in-memory caches of a campaign shard by the shard's own memory
2+
request. A shard evaluates every mechanism it carries against one long-lived
3+
`System`, whose cached repertoires are released only when that `System` is
4+
collected, so a shard packing many mechanisms accumulated cache entries with no
5+
effective ceiling: `maximum_cache_memory_percentage` measures against the
6+
machine's total RAM, which does not bound a job confined to a smaller
7+
allocation. The new `maximum_cache_memory_bytes` option gives an absolute
8+
ceiling, set automatically during shard execution, and `shard_memory_bytes` now
9+
includes the cache allowance it grants, so the request and the enforced ceiling
10+
come from the same figure.

docs/howto/cache.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ cached are still served.
4545
pyphi.config.maximum_cache_memory_percentage
4646
```
4747

48+
That percentage is measured against the machine's total RAM, which is the
49+
wrong quantity when the process may only use part of the machine — a batch job
50+
with a memory request, a container, or a cgroup. In that case set
51+
`maximum_cache_memory_bytes` to what the process is actually allowed, and the
52+
caches stop storing at that figure instead. Campaign shards do this for
53+
themselves, using the memory each shard requested.
54+
55+
```{code-cell} python
56+
with pyphi.config.override(maximum_cache_memory_bytes=2 * 1024**3):
57+
print(pyphi.config.maximum_cache_memory_bytes)
58+
```
59+
4860
### Inspecting the caches
4961

5062
`pyphi.cache.info()` returns per-cache hit/miss/size statistics for every
@@ -142,6 +154,7 @@ pyphi.config.disk_cache_results = False
142154
| `cache_repertoires` | `True` | Memoize cause/effect repertoire computations. |
143155
| `cache_potential_purviews` | `True` | Memoize potential-purview enumeration. |
144156
| `maximum_cache_memory_percentage` | `50` | Upper bound on in-memory cache size, as a percentage of RAM. |
157+
| `maximum_cache_memory_bytes` | `None` | Upper bound in bytes, for a process confined to less than the whole machine. Replaces the percentage when set. |
145158
| `clear_system_caches_after_computing_sia` | `False` | Clear per-system caches after each SIA. |
146159
| `disk_cache_results` | `False` | Persist SIA and CES results to `__pyphi_cache__/`. |
147160

pyphi/cache/cache_utils.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@
1212

1313

1414
def memory_full():
15-
"""Check if the memory is too full for further caching."""
15+
"""Check if the memory is too full for further caching.
16+
17+
Measures resident memory against ``maximum_cache_memory_bytes`` when that
18+
is set, and otherwise against ``maximum_cache_memory_percentage`` of total
19+
physical memory.
20+
"""
1621
current_process = psutil.Process(os.getpid())
22+
budget = config.infrastructure.maximum_cache_memory_bytes
23+
if budget is not None:
24+
return current_process.memory_info().rss > budget
1725
return (
1826
current_process.memory_percent()
1927
> config.infrastructure.maximum_cache_memory_percentage

pyphi/campaign/runner.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pyphi.campaign import _resolve_compute_ref
2424
from pyphi.conf import config
2525
from pyphi.conf import presets
26+
from pyphi.cost import shard_cache_budget_bytes
2627
from pyphi.sweep import _run_cell
2728
from pyphi.sweep import _Skipped
2829

@@ -96,12 +97,26 @@ def _run_sweep_task(task: Any, substrates: dict) -> tuple[list[CellOutput], bool
9697

9798

9899
def _shard_config(task: Any) -> dict[str, Any]:
99-
return {
100+
overrides = {
100101
**task.config_overrides,
101102
**presets.by_name[task.formalism],
102103
"parallel": False,
103104
"progress_bars": False,
104105
}
106+
# Bound the shard's caches by its own memory request: entries accumulate
107+
# across every mechanism the shard carries, and the default percentage of
108+
# physical memory is no bound at all on a machine larger than the request.
109+
# An explicit ceiling configured at preparation time is left alone.
110+
spec = getattr(task, "spec", None)
111+
if (
112+
spec is not None
113+
and spec.memory_bytes
114+
and overrides.get("maximum_cache_memory_bytes") is None
115+
):
116+
overrides["maximum_cache_memory_bytes"] = shard_cache_budget_bytes(
117+
spec.memory_bytes
118+
)
119+
return overrides
105120

106121

107122
def _global_tie_indices(ties: Any, slice_parts: list, indices: list[int]) -> list[int]:

pyphi/conf/CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ option-by-option reference, loaded only when working with this directory.
4141
- **`cache_potential_purviews`**: Cache purviews (default: true)
4242
- **`cache_macro_construction`**: Cache mapping-independent macro-construction intermediates (default: true)
4343
- **`clear_system_caches_after_computing_sia`**: Clear after each SIA (default: false)
44-
- **`maximum_cache_memory_percentage`**: Memory limit for in-memory caches (default: 50)
44+
- **`maximum_cache_memory_percentage`**: Memory limit for in-memory caches, as a percentage of total physical memory (default: 50)
45+
- **`maximum_cache_memory_bytes`**: Absolute resident-memory ceiling above which
46+
the caches stop storing (default: `None`). Replaces the percentage when set,
47+
which is what a process confined to less than the whole machine needs — a
48+
scheduler-managed job, a container, a cgroup. Campaign shard execution sets it
49+
from the shard's own memory request.
4550

4651
## Measures (`config.formalism.iit`)
4752

pyphi/conf/infrastructure.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ class InfrastructureConfig:
8484
parallel_backend: str = "local"
8585

8686
maximum_cache_memory_percentage: int = 50
87+
# An absolute ceiling on process resident memory, above which in-memory
88+
# caches stop storing new entries (already-stored entries are still
89+
# served). When set, it replaces `maximum_cache_memory_percentage`, which
90+
# measures against total physical memory and so cannot bound a process
91+
# confined to an allocation smaller than the machine — a scheduler-managed
92+
# job, a container, or a cgroup.
93+
maximum_cache_memory_bytes: int | None = None
8794
cache_repertoires: bool = True
8895
cache_potential_purviews: bool = True
8996
# When True (default), the macro TPM construction caches its
@@ -160,6 +167,8 @@ def __post_init__(self) -> None:
160167
"maximum_cache_memory_percentage",
161168
self.maximum_cache_memory_percentage,
162169
)
170+
if self.maximum_cache_memory_bytes is not None:
171+
_check_int("maximum_cache_memory_bytes", self.maximum_cache_memory_bytes)
163172
_check_bool("cache_repertoires", self.cache_repertoires)
164173
_check_bool("cache_potential_purviews", self.cache_potential_purviews)
165174
_check_bool("cache_macro_construction", self.cache_macro_construction)

pyphi/cost.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"mechanism_workloads",
3636
"partition_sweep_count",
3737
"round_memory_bytes",
38+
"shard_cache_budget_bytes",
3839
"shard_memory_bytes",
3940
]
4041

@@ -46,6 +47,20 @@
4647
BASE_MEMORY_BYTES = 1 << 30
4748
"""Per-task overhead: interpreter, imports, substrate TPM, task payload."""
4849

50+
CACHE_HEADROOM_BYTES = 1 << 30
51+
"""Memory a shard's request grants its repertoire caches.
52+
53+
A shard evaluates every mechanism it carries against one long-lived
54+
``System``, whose cached repertoires are released only when that ``System``
55+
is collected, so cache occupancy grows with the number of mechanisms packed
56+
into a shard rather than with the size of any one repertoire. Granting the
57+
allowance in the request and enforcing it during execution (see
58+
:func:`shard_cache_budget_bytes`) bounds that growth whatever the shard
59+
packs.
60+
"""
61+
62+
_CACHE_RESERVE_BYTES = 256 * 1024**2
63+
4964
_MEMORY_STEP_BYTES = 512 * 1024**2
5065

5166

@@ -67,11 +82,28 @@ def shard_memory_bytes(max_repertoire_cells: int) -> int:
6782
"""Estimated peak memory of a shard from its largest repertoire.
6883
6984
``REPERTOIRE_FACTOR × 8 bytes × max_repertoire_cells +
70-
BASE_MEMORY_BYTES``. The factor and base are calibration constants
71-
validated against scheduler-reported memory usage; requests derived
72-
from this estimate are rounded with :func:`round_memory_bytes`.
85+
BASE_MEMORY_BYTES + CACHE_HEADROOM_BYTES``. The factor and base are
86+
calibration constants validated against scheduler-reported memory
87+
usage; the headroom is the cache allowance that
88+
:func:`shard_cache_budget_bytes` enforces during execution. Requests
89+
derived from this estimate are rounded with
90+
:func:`round_memory_bytes`.
91+
"""
92+
return (
93+
REPERTOIRE_FACTOR * 8 * max_repertoire_cells
94+
+ BASE_MEMORY_BYTES
95+
+ CACHE_HEADROOM_BYTES
96+
)
97+
98+
99+
def shard_cache_budget_bytes(memory_bytes: int) -> int:
100+
"""Cache ceiling for a shard whose memory request is ``memory_bytes``.
101+
102+
The request less a reserve for the allocations in flight when the
103+
ceiling is reached, since the caches stop storing at the ceiling but
104+
the computation continues to allocate above it.
73105
"""
74-
return REPERTOIRE_FACTOR * 8 * max_repertoire_cells + BASE_MEMORY_BYTES
106+
return max(0, memory_bytes - _CACHE_RESERVE_BYTES)
75107

76108

77109
def round_memory_bytes(n: int) -> int:

pyphi/mcp/content/performance.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ combined footprint is bounded by `maximum_cache_memory_percentage` (default 50%
2929
of RAM); past that, no new entries are stored. Inspect with `pyphi.cache.info()`
3030
and clear with `pyphi.cache.clear_all()`. Analyzing many systems in a loop and
3131
watching memory climb? Set `clear_system_caches_after_computing_sia = True` to
32-
trade recomputation for a lower ceiling.
32+
trade recomputation for a lower ceiling. Under a batch scheduler, a container,
33+
or a cgroup, a percentage of the machine's RAM is no bound at all — set
34+
`maximum_cache_memory_bytes` to the process's actual allowance instead.
3335

3436
**Disk-backed result cache (opt-in, off by default).** This is the one to reach
3537
for on expensive work:

0 commit comments

Comments
 (0)