Skip to content

PowderPattern is never freed once a diffraction component is added #95

Description

@clemisch

Hi, I think there is a reference cycle in the PowderPattern bindings that keeps patterns alive forever.

Adding a diffraction component makes the pattern and the component hold references to each other. boost.python objects are invisible to Python's cycle collector, so nothing can ever break the loop and neither object is freed. The crystal is held too.

This is annoying for large batched refinements. I was able to circumvent it somehow by manually managing a persistent pool of PowderPatterns, but a root fix would be a lot cleaner.

Repro:

import gc, weakref
import numpy as np
from pyobjcryst.crystal import Crystal
from pyobjcryst.powderpattern import PowderPattern

def make(attach):
    pp = PowderPattern()
    pp.SetWavelength(1.0)
    pp.SetPowderPatternX(np.radians(np.linspace(5., 60., 4000)))
    if attach:
        pp.AddPowderPatternDiffraction(Crystal(4., 4., 4., "P1"))
    return pp

for attach in (False, True):
    pp = make(attach); ref = weakref.ref(pp)
    del pp; gc.collect()   # try to garbage collect the PowderPattern
    print(f"attached: {attach} -> collected: {ref() is None}")

gives

attached: False -> collected: True  # garbage collected if no pdiff attached
attached: True -> collected: False  # not garbage collected if pdiff attached

A plain PowderPattern is collected fine, so it looks specific to AddPowderPatternDiffraction.

The cause seems to be the call policy at src/extensions/powderpattern_ext.cpp:217:

with_custodian_and_ward_postcall<1,0,
  with_custodian_and_ward_postcall<0,2,return_internal_reference<> > > ()

With 0 as the return value, 1 as self and 2 as the crystal argument: the pattern keeps the returned component alive, and return_internal_reference makes the component keep the pattern alive. That is the loop. The middle policy (component keeps the crystal alive) looks necessary, since the component stores a raw pointer to the crystal, and I assume the first one is there so that protection still holds when callers discard the return value, which is common.

Why it matters in practice:

  • Memory grows in anything long lived that builds patterns repeatedly, such as notebook kernels, GUIs, or batch jobs over many datasets. About 94 kB per pattern above, more for longer patterns.
  • gCrystalRegistry never returns to zero. CreateCrystalFromCIF skips a data block when it has no atoms and the registry is non-empty, so importing a cell-only CIF can depend on how many patterns you happened to build earlier in the session.
  • There is no workaround from Python. DeRegisterAll() resets the count but frees nothing, since DeRegister does not own the objects.

One idea for a fix: tie the crystal to the pattern directly instead of going through the return value, so the pattern no longer holds the component.

.def("AddPowderPatternDiffraction", &addppdiffraction,
        with_custodian_and_ward<1, 2, return_internal_reference<> >())

Both guarantees seem preserved (the crystal outlives the pattern that points at it, and a returned handle cannot outlive its owner) but the loop is gone. It is the style already used for AddScatterer in crystal_ext.cpp, without the reverse direction.

I have not tested that, so I am raising this as a question rather than a PR. Two things I am unsure about: with_custodian_and_ward runs before the call while the current policies run after, and the change would affect every caller, so anything relying on a dropped component staying alive would need checking. Getting that wrong would crash rather than raise.

Also, I don't know how that connects with the planned switch to pybind or nanobind.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions