Skip to content

3.32.12 gives every holder's storage an empty set: about 1.3 MB more per policyengine-us simulation #577

Description

@MaxGhenis

What happens

Since 3.32.12, every InMemoryStorage is created with an empty set of its own:

def __init__(self, is_eternal: bool):
    self._arrays = {}
    self._shared = set()

_shared was added in #556 to record which arrays a branch still shares with the simulation it was created from. Only a storage made by clone(share_arrays=True) ever puts anything in it.

A simulation has one holder, and so one storage, for every variable in the tax-benefit system, whether or not the variable is ever read: Simulation.__init__ and SimulationBuilder build input_variables by calling get_holder for each variable, which creates the holder. An empty set is 216 bytes on CPython 3.11 to 3.14, so the new attribute costs about 224 bytes per variable per simulation, branches included. policyengine-us has 6,194 variables, which makes it about 1.3 MB per simulation.

That is small for one microsimulation. It is not small in the YAML test runner, which keeps every case's simulation alive until the session ends (#569 addresses that): a batch of policyengine-us tests ends with 1,500 to 2,000 live simulations.

Evidence

policyengine-us CI, same tests, 3.32.11 against 3.32.12. policyengine-us's PR workflow resolves the latest core before it runs. Its main did not change between the last 3.32.11 run (2026-10-02 02:01Z) and the first 3.32.12 runs (from 09:25Z; 3.32.12 was published at 04:58Z). The Linux peak RSS that test_batched.py prints for the four batches of Full Suite - Baseline (states-shard-3):

Batch 3.32.11 (3 runs) 3.32.12 (3 runs) Change
1 (DE, FL, GA, HI) 7,199 to 7,209 MB 9,285 to 9,298 MB +2.1 GB
2 (MI, MN, MO) 7,908 to 7,918 MB 10,540 to 10,545 MB +2.6 GB
3 (NY, OH, OK) 15,008 to 15,068 MB 15,077 to 15,160 MB when it finished at the 16 GB runner's limit both times
4 (VA, VT, WA) 8,935 to 8,944 MB 11,395 to 11,402 MB +2.5 GB

Jobs: 110390394947, 110597412824, 110671921673 (3.32.11); 110841371769, 110880078069, 110973714235 (3.32.12). The uv lock --upgrade-package policyengine-core step of each log shows the core version, and the two environments differ in nothing else.

Batch 3 and the single USDA batch (14,872 to 14,900 MB on 3.32.11) had no room for the increase. From 09:25Z both jobs die with "The runner has received a shutdown signal" / exit 143 on most pull requests and on pushes to main: PolicyEngine/policyengine-us#9757.

Object counts. One process over the same 457 policyengine-us baseline YAML files on master (b78b0ba) and on the commit before #556 (7950c01), counting live objects every 50 cases:

Cases run Live simulations Holders Extra gc-tracked objects on master Extra footprint on master
50 50 309,700 309,715 66 MB
100 120 743,280 743,433 171 MB
150 320 1,982,080 1,982,123 456 MB

Every simulation has 6,194 holders, and master has one more gc-tracked object per holder: the set.

In isolation. 6,194 objects shaped like a storage take 385 bytes each with a set of their own and 161 bytes each without one (tracemalloc, CPython 3.14.7): 2.27 MB against 0.95 MB.

Fix

Keep one empty, immutable object for every storage that shares nothing, and give a storage a set of its own only while at least one of its keys is shared.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions