Skip to content

gh-133164: Add PyUnstable_Object_IsUniqueReferencedTemporary C API #133170

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 12 commits into
base: main
Choose a base branch
from

Conversation

colesbury
Copy link
Contributor

@colesbury colesbury commented Apr 29, 2025

After gh-130704, the interpreter replaces some uses of LOAD_FAST with LOAD_FAST_BORROW which avoid incref/decrefs by "borrowing" references on the interpreter stack when the bytecode compiler can determine that its safe.

This change broke some checks in C API extensions that relied on Py_REFCNT() of 1 to determine if it's safe to modify an object in-place. Objects may have a reference count of one, but still be referenced further up the interpreter stack due to borrowing of references.

This provides a replacement function for those checks. PyUnstable_Object_IsUniqueReferencedTemporary is more conservative: it checks that the object has a reference count of one and that it exists as a unique strong reference in the interpreter's stack of temporary variables in the top most frame.

See also:


📚 Documentation preview 📚:

After pythongh-130704, the interpreter replaces some uses of `LOAD_FAST` with
`LOAD_FAST_BORROW` which avoid incref/decrefs by "borrowing" references
on the interpreter stack when the bytecode compiler can determine that
its safe.

This change broke some checks in C API extensions that relied on
`Py_REFCNT()` of `1` to determine if it's safe to modify an object
in-place. Objects may have a reference count of one, but still be
referenced further up the interpreter stack due to borrowing of
references.

This provides a replacement function for those checks.
`PyUnstable_Object_IsUniqueTemporary` is more conservative: it checks
that the object has a reference count of one and that it exists as a
unique strong reference in the interpreter's stack of temporary
variables in the top most frame.

See also:

* numpy/numpy#28681
@colesbury colesbury requested review from mpage, Yhg1s and pablogsal April 29, 2025 21:09
@colesbury
Copy link
Contributor Author

@ngoldbaum - I can't add you as a reviewer, but I'd appreciate it if you could take a look at this when you get a chance.

temporary object.

If an object is a unique temporary, it is guaranteed that the reference
count is ``1`` and it may be safe to modify the object in-place becuase
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 "may be safe" doesn't quite convey what we want here. We don't want people to be in doubt about whether it's safe from the interpreter's point of view. Maybe something along the lines of "it is guaranteed that the current code owns the only reference to the object"?

It's probably also worth mentioning why a refcount of 1 isn't enough to guarantee it's a unique temporary, something about the interpreter borrowing references internally, similar to the whatsnew entry.

colesbury and others added 2 commits April 29, 2025 20:04
Co-authored-by: Pieter Eendebak <pieter.eendebak@gmail.com>
Co-authored-by: T. Wouters <thomas@python.org>
Co-authored-by: mpage <mpage@cs.stanford.edu>
Copy link
Member

@markshannon markshannon left a comment

Choose a reason for hiding this comment

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

I'd prefer PyUnstable_Object_IsUniqueReferencedTemporary as it is not the object that is unique, but the reference to it.

The code seems correct, but could be more efficient. If the total explicit refcount is 1, then the first non-borrowed reference to the object must be the only reference.

int
PyUnstable_Object_IsUniqueTemporary(PyObject *op)
{
if (!_PyObject_IsUniquelyReferenced(op)) {
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 this just demonstrates that _PyObject_IsUniquelyReferenced is badly named, otherwise we wouldn't need a new function.

We need to check that the following are true:

  • The object has an explicit (not borrowed) reference count of 1
  • It is mortal
  • It does not support deferred reclamation.

I think _PyObject_IsUniquelyReferenced does that, but it isn't clear from the name.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let's discuss the name for _PyObject_IsUniquelyReferenced in #133144

@bedevere-app
Copy link

bedevere-app bot commented Apr 30, 2025

When you're done making the requested changes, leave the comment: I have made the requested changes; please review again.

Co-authored-by: Mark Shannon <mark@hotpy.org>
@colesbury colesbury changed the title gh-133164: Add PyUnstable_Object_IsUniqueTemporary C API gh-133164: Add PyUnstable_Object_IsUniqueReferencedTemporary C API Apr 30, 2025
@colesbury colesbury requested a review from markshannon April 30, 2025 14:38
Copy link
Contributor

@ngoldbaum ngoldbaum left a comment

Choose a reason for hiding this comment

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

As expected, the NumPy tests pass if I replace the python internals usage with this function:

diff --git a/numpy/_core/src/multiarray/temp_elide.c b/numpy/_core/src/multiarray/temp_elide.c
index 667ba2b443..ee1194b3a0 100644
--- a/numpy/_core/src/multiarray/temp_elide.c
+++ b/numpy/_core/src/multiarray/temp_elide.c
@@ -62,13 +62,6 @@
 
 #include <feature_detection_misc.h>
 
-#if PY_VERSION_HEX >= 0x030E00A7 && !defined(PYPY_VERSION)
-#define Py_BUILD_CORE
-#include "internal/pycore_frame.h"
-#include "internal/pycore_interpframe.h"
-#undef Py_BUILD_CORE
-#endif
-
 /* 1 prints elided operations, 2 prints stacktraces */
 #define NPY_ELIDE_DEBUG 0
 #define NPY_MAX_STACKSIZE 10
@@ -124,28 +117,7 @@ check_unique_temporary(PyObject *lhs)
     // that an object is a unique temporary variable. We scan the top of the
     // interpreter stack to check if the object exists as an "owned" reference
     // in the temporary stack.
-    PyFrameObject *frame = PyEval_GetFrame();
-    if (frame == NULL) {
-        return 0;
-    }
-    _PyInterpreterFrame *f = frame->f_frame;
-    _PyStackRef *base = _PyFrame_Stackbase(f);
-    _PyStackRef *stackpointer = f->stackpointer;
-    int found_once = 0;
-    while (stackpointer > base) {
-        stackpointer--;
-        PyObject *obj = PyStackRef_AsPyObjectBorrow(*stackpointer);
-        if (obj == lhs) {
-            if (!found_once && PyStackRef_IsHeapSafe(*stackpointer)) {
-                found_once = 1;
-            }
-            else {
-                return 0;
-            }
-        }
-        return found_once;
-    }
-    return 0;
+    return PyUnstable_Object_IsUniqueReferencedTemporary(lhs);
 #else
     return 1;
 #endif

compared to previous Python versions. C API extensions that checked
:c:func:`Py_REFCNT` of ``1`` to determine if an function argument is not
referenced by any other code should instead use
:c:func:`PyUnstable_Object_IsUniqueTemporary` as a safer replacement.
Copy link
Contributor

Choose a reason for hiding this comment

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

The fallout of this is a bit wider than the need for this function, e.g. the Pandas check that broke was looking at if sys.refcount(obj) <= 3. There were also some NumPy and nanobind tests that failed because the absolute values of reference counts changed.

https://github.com/pandas-dev/pandas/blob/main/pandas/core/frame.py#L4156-L4161

(c.f. pandas-dev/pandas#61368).

I'm actually not totally sure why Pandas was checking for <= 3 in particular and if there's a way for them to implement that check in pure Python still.

In NumPy we switched all the refcount tests to check changes in refcounts before and after an operation rather than absolute refcount values. That works across Python versions.

Maybe a separate paragraph advising against using the absolute values of reference counts in tests or runtime sanity checks? Could be a followup too.

@@ -89,6 +89,10 @@ If you encounter :exc:`NameError`\s or pickling errors coming out of
:mod:`multiprocessing` or :mod:`concurrent.futures`, see the
:ref:`forkserver restrictions <multiprocessing-programming-forkserver>`.

The interpreter avoids some reference count modifications internally when
Copy link
Member

Choose a reason for hiding this comment

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

👌

@colesbury
Copy link
Contributor Author

@markshannon - would you please take another look at this?

Copy link
Member

@markshannon markshannon left a comment

Choose a reason for hiding this comment

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

Looks good.

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.

9 participants