Skip to content

Add PyGcTraversable derive - #6330

Open
bschoenmaeckers wants to merge 40 commits into
PyO3:mainfrom
bschoenmaeckers:gc-integration
Open

bschoenmaeckers wants to merge 40 commits into
PyO3:mainfrom
bschoenmaeckers:gc-integration

Conversation

@bschoenmaeckers

@bschoenmaeckers bschoenmaeckers commented Aug 19, 2026

Copy link
Copy Markdown
Member

This adds a derive for the PyGcTraversable trait. This trait is not wired to the pyclass macro just yet but it can already be used manually by calling it in the __traverse__ & __clear__ methods.

It forces the user to implement PyGcTraversable on all fields or explicit disabling it using #[pyo3(gc = false)]. When setting #[pyo3(gc = false)] on a struct that implements PyGcTraversable will result in a compiler error.

To facilitate a escape hatch to prevent infinite recursion I've added a wrapper type PyGcOpaque that will stop visiting that type. This gives finer control on which parts of a (external) type should be traversed.

ref #5663

Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs
visit.call(self)
}

fn clear(&mut self) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't actually clear anything and may cause leaks.

I suppose that it can't really clear itself as Py unsafely assumes that it always references a live python object, should that change?

@Person-93 Person-93 Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, the only place where it's currently possible to see a cleared object is in its Drop impl, so maybe we can add an extra safety requirement to PyGcTraversable that implementors can't use Py fields in their Drop impl.

I recall a lua engine that did something like this by having an unsafe marker trait TrustedDrop and their GC trait depended on that.

If you derived their GC trait, it'd generate an empty drop impl by default and impl TrustedDrop as well. There was an option in the derive macro to leave this out and the user would have to unsafely implement the marker trait.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you cannot create loops without a mutable type somewhere in the cycle. So a empty clear is fine here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you cannot create loops without a mutable type somewhere in the cycle. So a empty clear is fine here.

How do you know that the Py isn't a reference to a mutable object? Can't it be anything?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure if we need to do anything here; my understanding is that as long as we allow the GC to see the full cycle it'll choose the point to call tp_clear to break it. Breaking any one edge in the cycle should be enough to collect it all.

But maybe it's possible to just set self to py.None() in order to be sure that the cycle gets broken? That seems like it could make a lot of clear implementations relatively inefficient, but maybe inefficient clear is a corner case we don't care about.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to set a Py<T> to None where T == PyAny. We could use TypeId for this. This requires a ’static bound which I think is fine?

@bschoenmaeckers bschoenmaeckers Aug 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all other cases not clearing it is probably enough and if it’s not, users could always implement __clear__ manually.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good point about type confusion, I think it'd be weird to have a special case for PyAny. Probably it's best to not clear and recommend using Option if tight cycles are possible?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all other cases not clearing it is probably enough and if it’s not, users could always implement __clear__ manually.

What would a manual implementation of clear for the Node in my example even look like?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably it's best to not clear and recommend using Option if tight cycles are possible?

I suppose that'd be an improvement over the way traversal is handled now.

Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do all the containers drop their contained item(s)? Shouldn't they call clear on them?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is answered by my other comment about Py's clear being a noop.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is answered by my other comment about Py's clear being a noop.

You are right. Clear should break cycles, so the most effected way for a container is to drop all items.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear should break cycles, so the most effected way for a container is to drop all items.

From cpython docs:

Any non-trivial cleanup should be performed in tp_finalize instead of tp_clear.

Dropping an arbitrary type can do anything. That includes non-trivial things. I think it'd be better to call the contents' implementation of PyGcTraversable::clear

It seems like the difficulty with clearing a Py might be a reason to do it this way, but doing it this way isn't sufficient to prevent leaks from Py instances..

#[pyclass]
#[derive(PyGcTraversable)]
struct Thing {
    oh_no: Py<PyAny>,
}

If a Thing::oh_no is set to a python reference to the Thing, the cycle will be detected, but not cleared.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping an arbitrary type can do anything. That includes non-trivial things. I think it'd be better to call the contents' implementation of PyGcTraversable::clear

I don't think this will work, as we have to clear it somewhere. By just calling clear on the inner value wastes a possible break point.

It seems like the difficulty with clearing a Py might be a reason to do it this way, but doing it this way isn't sufficient to prevent leaks from Py instances..

Sorry you are right. Py<T> might hold a cycle. What about setting setting it to None when we have a Py?


CPython docs recommend setting PyObject pointers to NULL.

From the docs

A cleared object is a partially destroyed object; the object is not obligated to satisfy design invariants held during normal use.
....
Implementations of tp_clear should drop the instance’s references to those of its members that may be Python objects, and set its pointers to those members to NULL

Making Drop for Py<T> resilient to NULL pointers may be an option here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another insight; in most cases clear does not have to be perfect when traverse is implemented correctly. As Python will call clear on all nodes in a circle. So in most cases there will be at least one break point. For your example of just 1 pyo3 class referencing itself this does obviously not work.

I noticed this while looking at pydantic-core's gc integration. They only implement traverse and rely on other types in the cycle to break the loop. Without ever implementing clear on their own types.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put similar thoughts in #6330 (comment).

I think Py<T> cannot safely hold NULL without a lot of breakage, so setting to Python None is the only "correct default" we can offer, I think.

Perhaps we could give users a #[pyo3(clear = false)] attribute which would enable them to avoid the overhead of setting to None (if it turns out to be meaningful).

Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks very much for driving this forward, I've really wanted this but not been able to make progress myself.

Comment thread src/prelude.rs
Comment thread src/internal_tricks.rs
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs
visit.call(self)
}

fn clear(&mut self) {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good point about type confusion, I think it'd be weird to have a special case for PyAny. Probably it's best to not clear and recommend using Option if tight cycles are possible?

Err(lookahead.error())
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From memory we have to accept and ignore IntoPyObject and FromPyObject derive attributes here (maybe re-emit them)? See #5070

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be fixed in: 4f2510b

Comment on lines +196 to +211
ensure_spanned!(
options.transparent.is_none(),
options.transparent.span() => "`transparent` is not supported for `#[derive(PyGcTraversable)]`"
);
ensure_spanned!(
options.from_item_all.is_none(),
options.from_item_all.span() => "`from_item_all` is not supported for `#[derive(PyGcTraversable)]`"
);
ensure_spanned!(
options.annotation.is_none(),
options.annotation.span() => "`annotation` is not supported for `#[derive(PyGcTraversable)]`"
);
ensure_spanned!(
options.rename_all.is_none(),
options.rename_all.span() => "`rename_all` is not supported for `#[derive(PyGcTraversable)]`"
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have to ignore these without asserting due to conflicts with e.g. FromPyObject / IntoPyObject on the same type (maybe have a test?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4f2510b

Comment thread pyo3-macros-backend/src/pygcintegration.rs Outdated
Comment on lines +244 to +245
match &variant.fields {
Fields::Named(named) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we maybe unify with structs using the fields here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see 836340e, is this moving in the right direction?

Comment thread src/pyclass/gc.rs Outdated
@codspeed

codspeed Bot commented Sep 7, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 7.37%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 3 regressed benchmarks
✅ 137 untouched benchmarks
⏩ 6 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
extract_biguint_small 1.4 µs 1.6 µs -13.92%
extract_biguint_zero 1.4 µs 1.6 µs -13.46%
into_u128_small 820.5 ns 930.2 ns -11.79%
into_biguint_small 1.5 µs 1.4 µs +12.06%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing bschoenmaeckers:gc-integration (c8c2a49) with main (ff1316b)

Open in CodSpeed

Footnotes

  1. 6 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@bschoenmaeckers
bschoenmaeckers marked this pull request as ready for review September 7, 2026 12:52
@bschoenmaeckers

Copy link
Copy Markdown
Member Author

This is ready for final review

@Person-93 Person-93 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking forward to actually using this new feature.

Comment thread src/pyclass/gc.rs
use crate::{ffi, Py};
/// Trait describing how values participate in Python's cyclic garbage collector.
///
/// # Safety

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any requirements on the clear method?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. But I might be wrong here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. But I might be wrong here.

Can it call arbitrary python code? Isn't there some subset that it's restricted to? Is there any chance that some of the python objects it has access to might not be valid any more?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that it is allowed but should be avoided. As clearing python objects may run their destructor it will call python code is some cases anyway. This should be safe because we only allow clearing for mutable types that can be safely cleared like Option.

Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs
/// ```
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(transparent)]
pub struct PyGcOpaque<T>(T);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bike-shedding comment ahead; ignore if it's too soon for this.

The Py type is a smart pointer, PyGcOpaque might be taken to mean this is some kind of smart pointer as well.

Comment thread src/pybacked.rs
Comment thread src/pybacked.rs

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, very much hoping we can ship this in 0.30 and start migrating towards a more correct / complete GC story. A number of review points.

Comment thread src/err/err_state.rs
Comment on lines +139 to +140
// SAFETY: traversing `PyErr` only reports the normalized exception object and does
// not execute arbitrary Python code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a potential sad case here that the in the lazy state the boxed callback might be hiding a cycle... but IMO there's not much we can reasonably do about that and yet another reason the lazy normalization must die.

Comment thread src/err/err_state.rs Outdated
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/pyclass/gc.rs Outdated
Comment thread src/platform/sync.rs Outdated

@Tpt Tpt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naive questions to try simplify the implementation.

Comment thread src/pyclass/gc.rs

#[inline]
fn traverse(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
if T::MAY_CONTAIN_CYCLES {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely dumb question (please ignore): is it worth it to do this check here and in some other implementations? If the nested clear() optimization is not doing anything like it is for type that do not contain cycles then the compiler is likely to optimize calls away.

Comment thread src/pyclass/gc.rs

#[inline]
fn clear(&mut self) {
if T::MAY_CONTAIN_CYCLES {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, is it explicit if worth it? I guess that clear() is called most often before dropping the object so, e.g. clearing a vec explicitly then dropping it even if it does not contain any python object is not likely to be much more costly than just dropping it full (code needs to iterate on destructors either way).

Comment thread src/platform/sync.rs
#[cfg(wip_feature_std)]
pub use std::sync::MutexGuard;
pub use {
crate::{PyGcTraversable, PyTraverseError, PyVisit},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these should be re-exported. They should have a regular use, not be part of the existing pub use.

Comment thread src/pyclass/gc.rs
}

#[inline]
fn clear(&mut self) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if clearing the reference is the correct behavior. The reference might refer to data that is outside of the python heap (arguably a GC root), and clearing it would be surprising to the user.

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.

4 participants