Skip to content

gh-134761: Use deferred reference counting for threading concurrency primitives #134762

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

ZeroIntensity
Copy link
Member

@ZeroIntensity ZeroIntensity commented May 26, 2025

This scales much better. Using this script:

import threading
import time

lock = threading.Lock()

def scale():
    a = time.perf_counter()
    for _ in range(10000000):
        lock.locked()
    b = time.perf_counter()
    print(b - a, "s")

threads = [threading.Thread(target=scale) for _ in range(8)]
for thread in threads:
    thread.start()

With this applied, I see similar performance to if lock was a local variable:

0.3701289139999062 s
0.40727080300075613 s
0.41241479399923264 s
0.4155945310003517 s
0.44201267799962807 s
0.4484649369996987 s
0.4601175060006426 s
0.46210344200062536 s

Prior to this change, I see:

3.425866439999936 s
3.5953266010001244 s
3.6094701500001065 s
3.667731437000157 s
4.458146230000011 s
4.466017671000145 s
4.499206339000011 s
4.50090869099995 s

📚 Documentation preview 📚: https://cpython-previews--134762.org.readthedocs.build/

@ZeroIntensity
Copy link
Member Author

I'd appreciate feedback from anyone here. There's no precedent in the standard library for using deferred reference counting at the Python level, so we're in some uncharted waters here. I don't think anyone would have been relying on when threading objects are deallocated, so that shouldn't be an issue. Are there other tradeoffs to using DRC that I'm not aware of?

@@ -290,6 +290,25 @@ always available. Unless explicitly noted otherwise, all variables are read-only
This function is specific to CPython. The exact output format is not
defined here, and may change.

.. function:: _defer_refcount(op)
Copy link
Member

Choose a reason for hiding this comment

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

Did not take a look overall but not sure it is worth to to expose it through the documentation since this is not a public api.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I'm a little bit on the fence about it. We do this already for some other functions in sys: _is_gil_enabled, _is_interned, _is_immortal, and a few others. Basically, it's a way to expose implementation details that are useful at a Python level.

Overall, I think this is worth documenting. There's no other way to improve scaling from Python right now, and it's similar to using an unstable C API (in the sense that it could be removed in any version).

Copy link
Contributor

Choose a reason for hiding this comment

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

What about documenting this in https://github.com/python/cpython/tree/main/InternalDocs somewhere? I fully agree with removing this from the normal documentation (for the reasons mentioned), but as a user/developer/tester I find the python api to _defer_refcount (and others like _is_immortal) useful and some documentation is always nice.

@corona10
Copy link
Member

corona10 commented May 27, 2025

Personal opinion: I think that we should not expose free threaded implementation to the Python level and end user. Fragmentation is only enough for C API.

@corona10
Copy link
Member

cc @vstinner @colesbury

@ZeroIntensity
Copy link
Member Author

Personal opinion: I think that we should not expose free threaded implementation to the Python level and end user. Fragmentation is only enough for C API.

Reiterating my previous comment: I'm +0.1 for documenting it. I'll happily yield if others are strongly opposed.

@corona10
Copy link
Member

corona10 commented May 27, 2025

I am -1 on documentation, we have bunch of private Python API that are related to C API but we don't expose it the Python documentation since we don't want people depends on it.

@Fidget-Spinner
Copy link
Member

Personal opinion: I think that we should not expose free threaded implementation to the Python level and end user. Fragmentation is only enough for C API.

Same. I truly think we shouldn't be exposing anything that relies on our implementation of refcounting. It's causing a hard enough time for other implementations already. And even causes problems for ourselves when we try to optimize away refcounting.

@ZeroIntensity
Copy link
Member Author

I truly think we shouldn't be exposing anything that relies on our implementation of refcounting. It's causing a hard enough time for other implementations already.

In general, I agree. I've removed the documentation note from this PR.

That said, I do think it's important that we do expose and document some (unstable) APIs for working with the current reference counting implementation, because otherwise people will try to hack it together themselves. For example, Nanobind was using silly hacks with immortalization before we exposed some better APIs for messing with reference counting. I think that's much more dangerous.

@@ -950,6 +950,7 @@ lock_new_impl(PyTypeObject *type)
if (self == NULL) {
return NULL;
}
_PyObject_SetDeferredRefcount((PyObject *)self);
Copy link
Member

Choose a reason for hiding this comment

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

Can you move the call after initializing self->lock? Same remark for rlock_new_impl() below.

@@ -2653,6 +2654,23 @@ sys__is_gil_enabled_impl(PyObject *module)
#endif
}

/*[clinic input]
sys._defer_refcount -> bool
Copy link
Member

Choose a reason for hiding this comment

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

Please document it in Doc/library/sys.rst. If it "should not be used", add a clear explanation why it should not be used there. If it's not documented, the lack of documentation doesn't prevent users from using it.

Copy link
Member Author

Choose a reason for hiding this comment

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

See Ken and Donghee's comments.

Copy link
Member

Choose a reason for hiding this comment

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

Well, I disageee with them. IMO we should document sys functions.

Copy link
Member

Choose a reason for hiding this comment

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

I would only be supportive of documenting this if we were allowed to change it in a minor version with no deprecation period. My understanding is that PyUnstable in the C API allows that, but exposing to sys._x means we are stuck with at least 2 deprecation cycle and recommended 5 deprecation cycles. Users should not rely on this function in the first place except in very specific scenarios.

One way to "bypass" this is make the function a no-op in future versions of Python once we solve this issue altogether. But I don't know what users will rely on by then so I'm a bit worried.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, I thought we were allowed to change sys._x things in minor versions without deprecation. If not, that's a problem.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think we're getting a bit hung up on this point. We can add or remove the documentation for _defer_refcount later, it's not too important. Does everything else look fine here?

Copy link
Member

Choose a reason for hiding this comment

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

Well I am preparing a better proposal for this approach. Give me hours.

Copy link
Member Author

@ZeroIntensity ZeroIntensity May 28, 2025

Choose a reason for hiding this comment

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

Ok, cool. Feel free to cc me on it.

Something we also need to consider is whether we want to address this for 3.14. Should this general idea be considered a bugfix or a feature?

Copy link
Member

Choose a reason for hiding this comment

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

See: #134819

Copy link
Member

Choose a reason for hiding this comment

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

I think that this is improvement rather than bug fix.

@vstinner
Copy link
Member

With this applied, I see similar performance to if lock was a local variable:

I don't understand well your benchmark. Can you show numbers before/after this change?

@ZeroIntensity
Copy link
Member Author

I don't understand well your benchmark. Can you show numbers before/after this change?

Updated the description. The benchmark is just trying to measure the amount of time spent accessing the object. See also the issue.

@corona10
Copy link
Member

@Fidget-Spinner Out of curiosity can we just apply deferred refcount with tracing objects that have a possilbity of high concentration?
Then we don't have to modify objects in this way.

@ZeroIntensity
Copy link
Member Author

From my understanding, the issue with automatically applying DRC is that it only lets an object be deallocated during a garbage collection, which breaks code that relies on __del__ (or a weakref finalizer) being called when the object's reference count hits zero.

Maybe we could enable it for objects without __del__, and then disable it if weakref.finalize is called on it?

@Fidget-Spinner
Copy link
Member

@Fidget-Spinner Out of curiosity can we just apply deferred refcount with tracing objects that have a possilbity of high concentration? Then we don't have to modify objects in this way.

You mean like track objects that are frequently shared and apply it to those objects? It would be quite easy to implement, the only problem is like Peter said we can't defer finalizers of things that take up a huge amount of memory and rely on immediate reclamation.

@corona10
Copy link
Member

corona10 commented May 27, 2025

If we can apply it to the conditional case, as Peter said, behavior change would not be an issue. OTAH, memory consumption also would not be an issue if we can provide a disable tracing option where the device has low memory. Most of the production machines have enough memory, so it would not be an issue, IIUC. (Please let me know if it will consume huuuuge memory)

@corona10
Copy link
Member

Side note, the reason I am talking about tracing is that I am now starting to fear that we will attach the deferred ref API all over the codebase due to performance reasons. It would be chaos for us..

@ZeroIntensity
Copy link
Member Author

It's generally a one-line change to an object's initializer, why would that be chaos?

We could also shift the decision to the user and add a generic threading.hot, which would enable deferred reference counting (or maybe immortalize non-GC types), but document it as "might do nothing" for forward compatibility. That would act as a safeguard against people constantly asking for deferred reference counting on their favorite objects.

@corona10
Copy link
Member

corona10 commented May 27, 2025

It's generally a one-line change to an object's initializer, why would that be chaos?

No, you are now starting to measure microbenchmarks and then applying them. Can you guarantee that the case is limited?
I can bet that we will find more cases that is the high contention.
How about 3rd party projects? those projects should measure the high contention and then change the code?

A better solution would be that we don't have to care about such a case, and the interpreter applies it automatically. I am fine with just applying for this case, but let's discuss a better solution and think about what we can provide.

@mpage
Copy link
Contributor

mpage commented May 28, 2025

You can work around the scaling issues in the repro pretty easily by passing the lock as an argument to scale:

import threading
import time

lock = threading.Lock()

def scale(lock):
    a = time.perf_counter()
    for _ in range(10000000):
        lock.locked()
    b = time.perf_counter()
    print(b - a, "s")

threads = [threading.Thread(target=scale, args=(lock,)) for _ in range(8)]
for thread in threads:
    thread.start()

I think we should be very cautious about exposing implementation details like deferred reference counting.

@ZeroIntensity
Copy link
Member Author

I think we should be very cautious about exposing implementation details like deferred reference counting.

FWIW, I don't think we're exposing it, per se. Things will "just work" from the user's perspective. I'd really rather the interpreter do it than force weird implementation details, like using locals.

Copy link
Member

@vstinner vstinner left a comment

Choose a reason for hiding this comment

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

Maybe you can extract Modules/_threadmodule.c changes into a separated PR since these changes don't require adding sys._defer_refcount().

@ZeroIntensity
Copy link
Member Author

Ok. _thread is still a documented module--should it be private there too?

@colesbury
Copy link
Contributor

I don't really see the motivation for these particular classes. It seems unlikely to me that someone is checking lock.locked() simultaneously from multiple threads without also acquiring and releasing the lock, which itself will be a bottleneck. Maybe this makes sense for threading.Event()?

I agree with @mpage that we should be cautious about exposing implementation details. The motivation here doesn't seem sufficient enough to me to overcome that caution.

@ZeroIntensity
Copy link
Member Author

lock.locked() was just an example to measure the reference count contention and not the internal PyMutex contention. Event is fixed here already too. In practice, people will be doing with lock or whatever, but that will still have the same refcount contention.

I agree with @mpage that we should be cautious about exposing implementation details. The motivation here doesn't seem sufficient enough to me to overcome that caution.

I don't think we're exposing any actual implementation details, right? The reference count is still hidden to threading users. sys._defer_refcount is a different story, but I'm going to move that anyway.

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

Successfully merging this pull request may close these issues.

7 participants