Add PyGcTraversable derive - #6330
bschoenmaeckers wants to merge 40 commits into
Conversation
122ca0f to
06cd6d4
Compare
| visit.call(self) | ||
| } | ||
|
|
||
| fn clear(&mut self) {} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I guess you cannot create loops without a mutable type somewhere in the cycle. So a empty clear is fine here.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
For all other cases not clearing it is probably enough and if it’s not, users could always implement __clear__ manually.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Probably it's best to not clear and recommend using
Optionif tight cycles are possible?
I suppose that'd be an improvement over the way traversal is handled now.
There was a problem hiding this comment.
Why do all the containers drop their contained item(s)? Shouldn't they call clear on them?
There was a problem hiding this comment.
I guess this is answered by my other comment about Py's clear being a noop.
There was a problem hiding this comment.
I guess this is answered by my other comment about
Py'sclearbeing a noop.
You are right. Clear should break cycles, so the most effected way for a container is to drop all items.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
davidhewitt
left a comment
There was a problem hiding this comment.
Thanks very much for driving this forward, I've really wanted this but not been able to make progress myself.
| visit.call(self) | ||
| } | ||
|
|
||
| fn clear(&mut self) {} |
There was a problem hiding this comment.
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?
| 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)]`" | ||
| ); |
There was a problem hiding this comment.
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?)
| match &variant.fields { | ||
| Fields::Named(named) => { |
There was a problem hiding this comment.
Can we maybe unify with structs using the fields here?
There was a problem hiding this comment.
Please see 836340e, is this moving in the right direction?
4b4ebc8 to
b99d641
Compare
53fd0a8 to
7a7af89
Compare
Merging this PR will degrade performance by 7.37%
|
| 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)
Footnotes
-
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. ↩
836340e to
aa0d095
Compare
|
This is ready for final review |
Person-93
left a comment
There was a problem hiding this comment.
Looking forward to actually using this new feature.
| use crate::{ffi, Py}; | ||
| /// Trait describing how values participate in Python's cyclic garbage collector. | ||
| /// | ||
| /// # Safety |
There was a problem hiding this comment.
Are there any requirements on the clear method?
There was a problem hiding this comment.
I don't think so. But I might be wrong here.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| /// ``` | ||
| #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||
| #[repr(transparent)] | ||
| pub struct PyGcOpaque<T>(T); |
There was a problem hiding this comment.
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.
davidhewitt
left a comment
There was a problem hiding this comment.
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.
| // SAFETY: traversing `PyErr` only reports the normalized exception object and does | ||
| // not execute arbitrary Python code. |
There was a problem hiding this comment.
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.
Tpt
left a comment
There was a problem hiding this comment.
naive questions to try simplify the implementation.
|
|
||
| #[inline] | ||
| fn traverse(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { | ||
| if T::MAY_CONTAIN_CYCLES { |
There was a problem hiding this comment.
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.
|
|
||
| #[inline] | ||
| fn clear(&mut self) { | ||
| if T::MAY_CONTAIN_CYCLES { |
There was a problem hiding this comment.
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).
| #[cfg(wip_feature_std)] | ||
| pub use std::sync::MutexGuard; | ||
| pub use { | ||
| crate::{PyGcTraversable, PyTraverseError, PyVisit}, |
There was a problem hiding this comment.
I don't think these should be re-exported. They should have a regular use, not be part of the existing pub use.
| } | ||
|
|
||
| #[inline] | ||
| fn clear(&mut self) { |
There was a problem hiding this comment.
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 adds a derive for the
PyGcTraversabletrait. This trait is not wired to thepyclassmacro just yet but it can already be used manually by calling it in the__traverse__&__clear__methods.It forces the user to implement
PyGcTraversableon all fields or explicit disabling it using#[pyo3(gc = false)]. When setting#[pyo3(gc = false)]on a struct that implementsPyGcTraversablewill result in a compiler error.To facilitate a escape hatch to prevent infinite recursion I've added a wrapper type
PyGcOpaquethat will stop visiting that type. This gives finer control on which parts of a (external) type should be traversed.ref #5663