Skip to content

Break the ReturnDataView <-> XArrayFactory reference cycle - #3525

Open
FFroehlich wants to merge 1 commit into
claude/issue-3510-fix-43b6f4from
claude/quirky-margulis-928f12
Open

FFroehlich wants to merge 1 commit into
claude/issue-3510-fix-43b6f4from
claude/quirky-margulis-928f12

Conversation

@FFroehlich

@FFroehlich FFroehlich commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3524.

ReturnDataView.__init__ stored XArrayFactory(self) as self.xr, and the factory holds a reference back to the view. Because of this cycle, a ReturnDataView and the C++ ReturnData it owns were never freed by reference counting, only by the cyclic garbage collector.

This PR makes xr a property that creates the factory on access. ReturnDataView.__dir__ still lists xr. A new test checks that a view and its ReturnData are freed with the GC disabled; it fails without this change.

Measurements

Investigated after the profile in #3510 (comment), where 18,338 ReturnDataViews took about 11 s, all charged to XArrayFactory.__init__. Setup: Python 3.13, a 2-state model, run_simulations with 9169 ExpDatas and forward sensitivities, and 4M dicts (12M GC-tracked objects) kept alive to stand in for a large PEtab problem.

Memory (the cycle matters). Results that a caller drops after reading llh stay alive. While run_simulations' result list is in use, they move into the oldest GC generation, so they are freed only at the next full collection. Over 20 calls (200 time points):

ReturnDatas alive after a call (max) peak RSS
with cycle (main) 54,250 (about 6 calls' worth) 3.7 GB
this PR 0 2.3 GB

The larger the heap, the rarer full collections are (CPython runs one only when the objects promoted since the last one exceed 25% of the old generation), so more results pile up.

Time (the cycle is not the cause of the 11 s).

  • Each ReturnDataView makes 40 GC-tracked allocations, mostly the _field_dimensions dict of lists. Those allocations trigger the collections. Since Python 3.12, a collection runs at the next eval-breaker check, which is the entry of XArrayFactory.__init__. So cProfile charges the collection to that function, including full collections of the whole heap. With this PR the same collections are charged to SwigPtrView.__init__ instead.
  • Breaking the cycle saves little GC time: 0.81 s → 0.66 s for 55k views (6 calls, 3 time points). Full collections happen as often as before.
  • The 11 s in the profile fits one or more full collections of a large heap (here about 0.6 s per full collection of 12M objects). This PR does not remove those.

Not in this PR

Most of the remaining construction cost is the eager _field_dimensions: about 35 SWIG attribute reads (about 30 µs per view) and 40 tracked allocations. In a quick emulation, computing dimensions lazily on first field access cut construction from 2.44 s to 0.02 s for 55k views, and gen0 collections from 1054 to 76. Done in #3526.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.30%. Comparing base (b317ee2) to head (32e3f4e).

Additional details and impacted files

Impacted file tree graph

@@                       Coverage Diff                        @@
##           claude/issue-3510-fix-43b6f4    #3525      +/-   ##
================================================================
+ Coverage                         80.24%   80.30%   +0.06%     
================================================================
  Files                               331      331              
  Lines                             23434    23438       +4     
  Branches                           1631     1631              
================================================================
+ Hits                              18804    18823      +19     
+ Misses                             4624     4609      -15     
  Partials                              6        6              
Flag Coverage Δ
cpp 74.81% <100.00%> (+0.06%) ⬆️
cpp_python 34.63% <60.00%> (-0.01%) ⬇️
petab 46.92% <60.00%> (-0.01%) ⬇️
petab_sciml 16.61% <60.00%> (+0.01%) ⬆️
petab_sciml_benchmarks 15.20% <60.00%> (+0.01%) ⬆️
python 72.59% <100.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
python/sdist/amici/sim/sundials/_numpy.py 87.97% <100.00%> (+0.31%) ⬆️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`ReturnDataView.__init__` stored `XArrayFactory(self)` as `self.xr`, and the
factory keeps a reference to the view. Because of this cycle, no
`ReturnDataView`, nor the C++ `ReturnData` it owns, was freed by reference
counting; dropped simulation results stayed alive until the next cyclic
garbage collection that reached them. Results from `run_simulations` survive
into the oldest generation while they are in use, so with a large heap they
accumulated over many calls until the next full collection.

`xr` is now a property that creates the factory on access. `__dir__` keeps
listing `xr`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@FFroehlich
FFroehlich force-pushed the claude/quirky-margulis-928f12 branch from 24f9bf2 to 32e3f4e Compare November 7, 2026 22:54
@FFroehlich
FFroehlich changed the base branch from main to claude/issue-3510-fix-43b6f4 November 7, 2026 22:54
@FFroehlich
FFroehlich added this pull request to stack #3523 November 7, 2026 22:58
@FFroehlich
FFroehlich marked this pull request as ready for review November 7, 2026 22:58
@FFroehlich
FFroehlich requested a review from a team as a code owner November 7, 2026 22:58

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant