From f0f044025689497afc5826ea144e70b72b5bda53 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 6 Jul 2026 14:21:05 +0100 Subject: [PATCH 01/15] WIP make attribute read safe if there is single writer --- mypyc/codegen/emitclass.py | 18 +++++++++++++++++- mypyc/codegen/emitfunc.py | 28 +++++++++++++++++++++++++--- mypyc/ir/rtypes.py | 13 +++++++++++++ mypyc/lib-rt/pythonsupport.h | 28 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index 9baaec06a4611..f7f23d22fb354 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -29,6 +29,7 @@ BITMAP_BITS, BITMAP_TYPE, CPYFUNCTION_NAME, + IS_FREE_THREADED, MYPYC_DEFAULTS_SETUP, NATIVE_PREFIX, PREFIX, @@ -43,7 +44,7 @@ FuncIR, get_text_signature, ) -from mypyc.ir.rtypes import RTuple, RType, object_rprimitive +from mypyc.ir.rtypes import RTuple, RType, is_simple_refcounted_pointer, object_rprimitive from mypyc.namegen import NameGenerator from mypyc.sametype import is_same_type @@ -1165,6 +1166,21 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N emitter.emit_line("{") attr_expr = f"self->{attr_field}" + if IS_FREE_THREADED and is_simple_refcounted_pointer(rtype): + # In free-threaded builds, load the attribute and take a new reference + # atomically to avoid a use-after-free race with a concurrent setter. + # CPy_GetAttrRef returns NULL if the attribute is undefined (NULL field), + # which is exactly the error/undefined value for a 'PyObject *' field. + emitter.emit_line(f"PyObject *retval = CPy_GetAttrRef((PyObject **)&{attr_expr});") + emitter.emit_line("if (unlikely(retval == NULL)) {") + emitter.emit_line("PyErr_SetString(PyExc_AttributeError,") + emitter.emit_line(f' "attribute {repr(attr)} of {repr(cl.name)} undefined");') + emitter.emit_line("return NULL;") + emitter.emit_line("}") + emitter.emit_line("return retval;") + emitter.emit_line("}") + return + # HACK: Don't consider refcounted values as always defined, since it's possible to # access uninitialized values via 'gc.get_objects()'. Accessing non-refcounted # values is benign. diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index dcb606f6ab51b..b835b3cf78f8b 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -12,7 +12,13 @@ TracebackAndGotoHandler, c_array_initializer, ) -from mypyc.common import GENERATOR_ATTRIBUTE_PREFIX, HAVE_IMMORTAL, NATIVE_PREFIX, REG_PREFIX +from mypyc.common import ( + GENERATOR_ATTRIBUTE_PREFIX, + HAVE_IMMORTAL, + IS_FREE_THREADED, + NATIVE_PREFIX, + REG_PREFIX, +) from mypyc.ir.class_ir import ClassIR from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD, FuncDecl, FuncIR, all_values from mypyc.ir.ops import ( @@ -83,6 +89,7 @@ is_int_rprimitive, is_none_rprimitive, is_pointer_rprimitive, + is_simple_refcounted_pointer, is_tagged, ) @@ -427,7 +434,22 @@ def visit_get_attr(self, op: GetAttr) -> None: else: # Otherwise, use direct or offset struct access. attr_expr = self.get_attr_expr(obj, op, decl_cl) - self.emitter.emit_line(f"{dest} = {attr_expr};") + # In free-threaded builds, reading a single reference-counted + # 'PyObject *' field and taking a new reference must be done + # atomically to avoid a use-after-free race with a concurrent setter. + # CPy_GetAttrRef performs the load and incref atomically and returns a + # new reference (or NULL if undefined), so no separate inc_ref is + # emitted below. Borrowed reads keep the plain load; they are made + # safe separately via borrowed-to-owned promotion. + use_get_attr_ref = ( + IS_FREE_THREADED + and is_simple_refcounted_pointer(attr_rtype) + and not op.is_borrowed + ) + if use_get_attr_ref: + self.emitter.emit_line(f"{dest} = CPy_GetAttrRef((PyObject **)&{attr_expr});") + else: + self.emitter.emit_line(f"{dest} = {attr_expr};") always_defined = cl.is_always_defined(op.attr) merged_branch = None if not always_defined: @@ -458,7 +480,7 @@ def visit_get_attr(self, op: GetAttr) -> None: ) ) - if attr_rtype.is_refcounted and not op.is_borrowed: + if attr_rtype.is_refcounted and not op.is_borrowed and not use_get_attr_ref: if not merged_branch and not always_defined: self.emitter.emit_line("} else {") self.emitter.emit_inc_ref(dest, attr_rtype) diff --git a/mypyc/ir/rtypes.py b/mypyc/ir/rtypes.py index 1a5515d5621a8..afe3d3e9df39b 100644 --- a/mypyc/ir/rtypes.py +++ b/mypyc/ir/rtypes.py @@ -569,6 +569,19 @@ def is_native_rprimitive(rtype: RType) -> bool: return isinstance(rtype, RPrimitive) and rtype.name in KNOWN_NATIVE_TYPES +def is_simple_refcounted_pointer(rtype: RType) -> bool: + """Is rtype represented at runtime as a single, reference-counted 'PyObject *'? + + This covers 'object', 'str', containers, instances of native classes and + optional/union types -- everything whose C representation is exactly one + 'PyObject *' field that owns a reference. It excludes unboxed types (tagged + 'int', fixed-width ints, floats, bools), inline tuples ('RTuple'), vectors + ('RVec') and C structs ('RStruct'), which need different treatment for + free-threaded memory safety. + """ + return rtype.is_refcounted and not rtype.is_unboxed and not isinstance(rtype, RStruct) + + def is_tagged(rtype: RType) -> TypeGuard[RPrimitive]: return rtype is int_rprimitive or rtype is short_int_rprimitive diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 35f1e78df3915..99d32a65435a8 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -23,6 +23,10 @@ #include "internal/pycore_setobject.h" // _PySet_Update #endif +#ifdef Py_GIL_DISABLED +#include "internal/pycore_object.h" // _Py_TryIncrefCompare +#endif + #if CPY_3_12_FEATURES #include "internal/pycore_frame.h" #endif @@ -34,6 +38,30 @@ extern "C" { } // why isn't emacs smart enough to not indent this #endif +#ifdef Py_GIL_DISABLED +// Read a native attribute that is a single reference-counted 'PyObject *' field, +// returning a new reference (or NULL if the field is NULL/undefined). +// +// On free-threaded builds a plain load followed by an incref races with a +// concurrent setter that may decref the old value to zero and free it before the +// incref runs (use-after-free). This mirrors CPython's '_Py_XGetRef': optimistically +// incref via '_Py_TryIncrefCompare', which re-validates the pointer and backs out +// if a writer changed it, then retry. This is only used in free-threaded builds; +// the default (GIL) build keeps the plain load + incref generated inline by mypyc. +static inline PyObject *CPy_GetAttrRef(PyObject **field) { + for (;;) { + PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); + if (v == NULL) { + return NULL; + } + if (_Py_TryIncrefCompare(field, v)) { + return v; + } + // Lost a race with a concurrent writer; retry. + } +} +#endif + PyObject* update_bases(PyObject *bases); int init_subclass(PyTypeObject *type, PyObject *kwds); From f9bcdb4c4ffb666e3fcae3f5287027ee80841a4a Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 6 Jul 2026 15:55:32 +0100 Subject: [PATCH 02/15] WIP make attribute writes safe for simple pointer types --- mypyc/codegen/emitclass.py | 24 ++++++++++++++++ mypyc/codegen/emitfunc.py | 15 ++++++++++ mypyc/lib-rt/pythonsupport.h | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index f7f23d22fb354..0e9d4754fab9b 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -1218,6 +1218,30 @@ def generate_setter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N emitter.emit_line("return -1;") emitter.emit_line("}") + if IS_FREE_THREADED and is_simple_refcounted_pointer(rtype): + # In free-threaded builds, publish the new value atomically via + # CPy_SetAttrRef so a concurrent reader (see CPy_GetAttrRef) never sees a + # torn pointer or a freed old value. CPy_SetAttrRef steals its value and + # reclaims the old one, so we cast/type-check the incoming value, take a + # new reference (the setter only borrows 'value'), then hand it over. + # A NULL value deletes the attribute (reclaims the old value, stores NULL). + if deletable: + emitter.emit_line("if (value != NULL) {") + if is_same_type(rtype, object_rprimitive): + emitter.emit_line("PyObject *tmp = value;") + else: + emitter.emit_cast("value", "tmp", rtype, declare_dest=True) + emitter.emit_lines("if (!tmp)", " return -1;") + emitter.emit_inc_ref("tmp", rtype) + emitter.emit_line(f"CPy_SetAttrRef((PyObject **)&self->{attr_field}, tmp);") + if deletable: + emitter.emit_line("} else {") + emitter.emit_line(f"CPy_SetAttrRef((PyObject **)&self->{attr_field}, NULL);") + emitter.emit_line("}") + emitter.emit_line("return 0;") + emitter.emit_line("}") + return + # HACK: Don't consider refcounted values as always defined, since it's possible to # access uninitialized values via 'gc.get_objects()'. Accessing non-refcounted # values is benign. diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index b835b3cf78f8b..6a04ac5dce91c 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -551,6 +551,21 @@ def visit_set_attr(self, op: SetAttr) -> None: op.attr, ) ) + elif IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype): + # In free-threaded builds, publishing a single reference-counted + # 'PyObject *' field must be atomic so a concurrent reader (see + # CPy_GetAttrRef) never observes a torn pointer or a freed value. + # Both helpers steal the reference to src. + attr_expr = self.get_attr_expr(obj, op, decl_cl) + if op.is_init: + # The attribute is known to be previously undefined (NULL), so + # there is no old value to reclaim; a release store suffices. + self.emitter.emit_line(f"CPy_InitAttrRef((PyObject **)&{attr_expr}, {src});") + else: + # Atomically swap in the new value and reclaim the old one. + self.emitter.emit_line(f"CPy_SetAttrRef((PyObject **)&{attr_expr}, {src});") + if op.error_kind == ERR_FALSE: + self.emitter.emit_line(f"{dest} = 1;") else: # ...and struct access for normal attributes. attr_expr = self.get_attr_expr(obj, op, decl_cl) diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 99d32a65435a8..689d02b1d3ff4 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -60,6 +60,62 @@ static inline PyObject *CPy_GetAttrRef(PyObject **field) { // Lost a race with a concurrent writer; retry. } } + +// Reclaim the previous value of a native attribute after it has been replaced. +// +// The fast path avoids QSBR deferral when the decref provably cannot free the +// object: immortal objects are never freed, and an object owned by the current +// thread with a local refcount > 1 stays at refcount >= 1 after a plain local +// decrement, so no concurrent reader can ever observe a freed header. Only the +// cases that could actually free (local refcount would reach zero, or the object +// is owned by another thread) defer the decref via QSBR, which keeps the memory +// valid until every thread has passed a quiescent point -- long enough for an +// in-flight reader that loaded the old pointer to finish its try-incref. +static inline void CPy_DecRefAttrOld(PyObject *op) { + if (op == NULL) { + return; + } + uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local); + if (local == _Py_IMMORTAL_REFCNT_LOCAL) { + return; + } + if (local > 1 && _Py_IsOwnedByCurrentThread(op)) { + // Same as Py_DECREF's owned-thread fast path: only this thread writes + // ob_ref_local, and the refcount stays >= 1, so this is race-free. + _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, local - 1); + return; + } + _PyObject_XDecRefDelayed(op); +} + +// Set a native attribute that is a single reference-counted 'PyObject *' field, +// stealing the reference to 'value' (which may be NULL to delete the attribute) +// and safely reclaiming the previous value. +// +// SetMaybeWeakref is required for correctness, not just performance: it lets a +// concurrent cross-thread reader's shared-refcount CAS in CPy_GetAttrRef succeed +// instead of failing and spinning (see the "writer must set maybe-weakref" +// requirement documented on CPython's _Py_XGetRef). The atomic exchange publishes +// the new pointer and hands back the old one without a writer/writer race. +static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) { + if (value != NULL) { + _PyObject_SetMaybeWeakref(value); + } + PyObject *old = (PyObject *)_Py_atomic_exchange_ptr(field, value); + CPy_DecRefAttrOld(old); +} + +// Initialize a native attribute that is known to be previously undefined (NULL), +// stealing the reference to 'value'. Because there is no old value to reclaim and +// no other writer can race on a field that is being initialized, a release store +// is sufficient to publish the pointer; SetMaybeWeakref is still required so that +// a concurrent reader's try-incref can succeed. +static inline void CPy_InitAttrRef(PyObject **field, PyObject *value) { + if (value != NULL) { + _PyObject_SetMaybeWeakref(value); + } + _Py_atomic_store_ptr_release(field, value); +} #endif PyObject* update_bases(PyObject *bases); From 141da6e3ad5432f1d96b53c39ab52b4145853ad1 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 6 Jul 2026 18:20:56 +0100 Subject: [PATCH 03/15] WIP add faster init path + adjust read implementation --- mypyc/codegen/emitclass.py | 4 +- mypyc/codegen/emitfunc.py | 4 +- mypyc/lib-rt/pythonsupport.h | 75 +++++++++++++++++++++++++----------- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index 0e9d4754fab9b..f6129d7d49b80 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -1171,7 +1171,9 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N # atomically to avoid a use-after-free race with a concurrent setter. # CPy_GetAttrRef returns NULL if the attribute is undefined (NULL field), # which is exactly the error/undefined value for a 'PyObject *' field. - emitter.emit_line(f"PyObject *retval = CPy_GetAttrRef((PyObject **)&{attr_expr});") + emitter.emit_line( + f"PyObject *retval = CPy_GetAttrRef((PyObject *)self, (PyObject **)&{attr_expr});" + ) emitter.emit_line("if (unlikely(retval == NULL)) {") emitter.emit_line("PyErr_SetString(PyExc_AttributeError,") emitter.emit_line(f' "attribute {repr(attr)} of {repr(cl.name)} undefined");') diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index 6a04ac5dce91c..379689754193c 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -447,7 +447,9 @@ def visit_get_attr(self, op: GetAttr) -> None: and not op.is_borrowed ) if use_get_attr_ref: - self.emitter.emit_line(f"{dest} = CPy_GetAttrRef((PyObject **)&{attr_expr});") + self.emitter.emit_line( + f"{dest} = CPy_GetAttrRef((PyObject *){obj}, (PyObject **)&{attr_expr});" + ) else: self.emitter.emit_line(f"{dest} = {attr_expr};") always_defined = cl.is_always_defined(op.attr) diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 689d02b1d3ff4..10eee74d65597 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -40,25 +40,45 @@ extern "C" { #ifdef Py_GIL_DISABLED // Read a native attribute that is a single reference-counted 'PyObject *' field, -// returning a new reference (or NULL if the field is NULL/undefined). +// returning a new reference (or NULL if the field is NULL/undefined). 'self' is +// the object owning the field, used only by the cold slow path below. // // On free-threaded builds a plain load followed by an incref races with a // concurrent setter that may decref the old value to zero and free it before the -// incref runs (use-after-free). This mirrors CPython's '_Py_XGetRef': optimistically -// incref via '_Py_TryIncrefCompare', which re-validates the pointer and backs out -// if a writer changed it, then retry. This is only used in free-threaded builds; -// the default (GIL) build keeps the plain load + incref generated inline by mypyc. -static inline PyObject *CPy_GetAttrRef(PyObject **field) { - for (;;) { - PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); - if (v == NULL) { - return NULL; - } - if (_Py_TryIncrefCompare(field, v)) { - return v; - } - // Lost a race with a concurrent writer; retry. +// incref runs (use-after-free). The fast path mirrors CPython's '_Py_XGetRef': +// optimistically incref via '_Py_TryIncrefCompare', which re-validates the +// pointer and backs out if a writer changed it. +// +// The slow path (cold) handles a value that has not had maybe-weakref set and is +// owned by another thread: '_Py_TryIncRefShared' then returns 0 permanently, so a +// plain retry loop would spin forever. This case arises because 'CPy_InitAttrRef' +// publishes initializer stores WITHOUT setting maybe-weakref (a measured win for +// construction-heavy code -- see docs/design/free-threaded-attr-safety.md). We +// recover exactly as CPython's lock-free dict reader does (Objects/dictobject.c, +// the 'read_failed' path): take the owning object's critical section and force a +// cross-thread reference via '_Py_NewRefWithLock', which cannot fail and sets +// maybe-weakref so subsequent reads take the fast path. QSBR keeps the loaded +// value alive across this, since setters defer the freeing decref past any +// quiescent point. This same fallback also covers a transient lost race with a +// concurrent writer. It is only used in free-threaded builds; the default (GIL) +// build keeps the plain load + incref generated inline by mypyc. +static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { + PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); + if (v == NULL) { + return NULL; + } + if (_Py_TryIncrefCompare(field, v)) { + return v; + } + // Slow path: value not yet shared (e.g. published by an initializer store + // that skipped maybe-weakref), or we lost a race with a concurrent writer. + Py_BEGIN_CRITICAL_SECTION(self); + v = (PyObject *)_Py_atomic_load_ptr(field); + if (v != NULL) { + _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail } + Py_END_CRITICAL_SECTION(); + return v; } // Reclaim the previous value of a native attribute after it has been replaced. @@ -106,15 +126,24 @@ static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) { } // Initialize a native attribute that is known to be previously undefined (NULL), -// stealing the reference to 'value'. Because there is no old value to reclaim and -// no other writer can race on a field that is being initialized, a release store -// is sufficient to publish the pointer; SetMaybeWeakref is still required so that -// a concurrent reader's try-incref can succeed. +// stealing the reference to 'value'. +// +// Initializer stores only happen while 'self' is still thread-local (the +// attribute-definedness analysis marks a SetAttr as an initializer only before +// 'self' can leak -- see mypyc/analysis/attrdefined.py). So there is no old value +// to reclaim, no competing writer, and the field store is not itself the +// publication point: 'self' is published later (when it escapes __init__ or is +// returned), and that publication carries the release barrier making all the +// construction stores visible. A relaxed store therefore suffices. +// +// Unlike CPy_SetAttrRef, this deliberately does NOT call SetMaybeWeakref (its CAS +// is pure overhead here, ~+2.6ns per fresh store, and construction-heavy code +// pays it on every attribute of every new object). The cost is moved off this hot +// path onto CPy_GetAttrRef's cold slow path, which sets maybe-weakref lazily on +// the first cross-thread read that needs it. See +// docs/design/free-threaded-attr-safety.md ("Experiment: reader-side fallback"). static inline void CPy_InitAttrRef(PyObject **field, PyObject *value) { - if (value != NULL) { - _PyObject_SetMaybeWeakref(value); - } - _Py_atomic_store_ptr_release(field, value); + _Py_atomic_store_ptr_relaxed(field, value); } #endif From dc58f4e73ad52202f7b8e444b52ac72e104035e3 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Mon, 6 Jul 2026 19:21:16 +0100 Subject: [PATCH 04/15] Only inline fast path of attr read --- mypyc/lib-rt/pythonsupport.c | 34 ++++++++++++++++++++++++++++++++++ mypyc/lib-rt/pythonsupport.h | 20 ++++++++++---------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/mypyc/lib-rt/pythonsupport.c b/mypyc/lib-rt/pythonsupport.c index 0a99f0ae2e29b..1e420099a81d4 100644 --- a/mypyc/lib-rt/pythonsupport.c +++ b/mypyc/lib-rt/pythonsupport.c @@ -5,6 +5,40 @@ #include "pythonsupport.h" +#ifdef Py_GIL_DISABLED +// Cold slow path of CPy_GetAttrRef (declared in pythonsupport.h). Reached only +// when the inline fast-path try-incref fails: the value has not had maybe-weakref +// set (e.g. published by an initializer store that skipped it) and is owned by +// another thread, or we lost a race with a concurrent writer. Take the owning +// object's critical section and force a cross-thread reference via +// _Py_NewRefWithLock, which cannot fail and sets maybe-weakref so subsequent +// reads take the fast path. Kept out-of-line so the inline fast path stays small. +// +// The inline fast path only handled the owned-by-this-thread / immortal case +// (_Py_TryIncrefFast). Here we first retry the full try-incref-compare: reload the +// pointer (it may have changed since the inline load) and attempt the cross-thread +// shared-refcount CAS with pointer re-validation. Only if that also fails -- the +// value has no shared refcount yet and is owned by another thread -- do we fall +// into the critical section and force a cross-thread reference. +__attribute__((noinline)) +PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field) { + PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); + if (v == NULL) { + return NULL; + } + if (_Py_TryIncrefCompare(field, v)) { + return v; + } + Py_BEGIN_CRITICAL_SECTION(self); + v = (PyObject *)_Py_atomic_load_ptr(field); + if (v != NULL) { + _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail + } + Py_END_CRITICAL_SECTION(); + return v; +} +#endif + ///////////////////////////////////////// // Adapted from bltinmodule.c in Python 3.7.0 PyObject* diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 10eee74d65597..fee0cb3e4f2c5 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -62,23 +62,23 @@ extern "C" { // quiescent point. This same fallback also covers a transient lost race with a // concurrent writer. It is only used in free-threaded builds; the default (GIL) // build keeps the plain load + incref generated inline by mypyc. +// Cold slow path of CPy_GetAttrRef, kept out-of-line (see pythonsupport.c) so the +// inline fast path stays small enough for the compiler to inline at call sites. +PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field); + static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); if (v == NULL) { return NULL; } - if (_Py_TryIncrefCompare(field, v)) { + // Hot case only: object is owned by this thread or immortal (no CAS, no loop). + // Everything else -- the cross-thread shared-refcount CAS, the pointer + // re-validation, and the not-yet-shared critical-section fallback -- lives in + // the out-of-line slow path to keep this body small enough to inline. + if (_Py_TryIncrefFast(v)) { return v; } - // Slow path: value not yet shared (e.g. published by an initializer store - // that skipped maybe-weakref), or we lost a race with a concurrent writer. - Py_BEGIN_CRITICAL_SECTION(self); - v = (PyObject *)_Py_atomic_load_ptr(field); - if (v != NULL) { - _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail - } - Py_END_CRITICAL_SECTION(); - return v; + return CPy_GetAttrRefSlow(self, field); } // Reclaim the previous value of a native attribute after it has been replaced. From 5323ad82e2c4c72410876ec0c88393dc859acb59 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Tue, 7 Jul 2026 13:43:30 +0100 Subject: [PATCH 05/15] Optimize final attribute reads --- mypyc/codegen/emitclass.py | 14 +++++-- mypyc/codegen/emitfunc.py | 15 +++++-- mypyc/ir/class_ir.py | 16 ++++++++ mypyc/irbuild/builder.py | 12 +++--- mypyc/lib-rt/pythonsupport.h | 61 ++++++++++++++++++---------- mypyc/test-data/irbuild-classes.test | 40 ++++++++++++++++++ mypyc/test-data/run-classes.test | 52 ++++++++++++++++++++++++ 7 files changed, 175 insertions(+), 35 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index f6129d7d49b80..37a0eacb92b48 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -1171,9 +1171,17 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N # atomically to avoid a use-after-free race with a concurrent setter. # CPy_GetAttrRef returns NULL if the attribute is undefined (NULL field), # which is exactly the error/undefined value for a 'PyObject *' field. - emitter.emit_line( - f"PyObject *retval = CPy_GetAttrRef((PyObject *)self, (PyObject **)&{attr_expr});" - ) + # + # Final attributes are never rebound (no setter), so there is no concurrent + # writer to race with: a plain load + incref is safe. Use the cheaper + # CPy_GetAttrRefFinal, which skips the try-incref/re-validation machinery. + # This getter is generated per defining class, so a direct membership test + # matches the read-only getset table above (no need to walk the MRO). + if attr in cl.final_attributes: + getattr_ref = f"CPy_GetAttrRefFinal((PyObject **)&{attr_expr})" + else: + getattr_ref = f"CPy_GetAttrRef((PyObject *)self, (PyObject **)&{attr_expr})" + emitter.emit_line(f"PyObject *retval = {getattr_ref};") emitter.emit_line("if (unlikely(retval == NULL)) {") emitter.emit_line("PyErr_SetString(PyExc_AttributeError,") emitter.emit_line(f' "attribute {repr(attr)} of {repr(cl.name)} undefined");') diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index 379689754193c..4b99b66b0744a 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -439,14 +439,23 @@ def visit_get_attr(self, op: GetAttr) -> None: # atomically to avoid a use-after-free race with a concurrent setter. # CPy_GetAttrRef performs the load and incref atomically and returns a # new reference (or NULL if undefined), so no separate inc_ref is - # emitted below. Borrowed reads keep the plain load; they are made - # safe separately via borrowed-to-owned promotion. + # emitted below. Borrowed reads keep the plain load; they are only + # emitted for attributes that are safe to borrow on free-threaded builds + # (Final and vec attrs -- see transform_member_expr in irbuild). + # + # Final attributes are never rebound (no setter), so there is no + # concurrent writer and no use-after-free window: an owned read is a + # plain load + incref (via the cheaper CPy_GetAttrRefFinal), and a + # borrowed read stays a plain borrow (handled by the else branch), since + # the value lives as long as its container. use_get_attr_ref = ( IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype) and not op.is_borrowed ) - if use_get_attr_ref: + if use_get_attr_ref and cl.is_final_attr(op.attr): + self.emitter.emit_line(f"{dest} = CPy_GetAttrRefFinal((PyObject **)&{attr_expr});") + elif use_get_attr_ref: self.emitter.emit_line( f"{dest} = CPy_GetAttrRef((PyObject *){obj}, (PyObject **)&{attr_expr});" ) diff --git a/mypyc/ir/class_ir.py b/mypyc/ir/class_ir.py index 028df5898d920..6ea1eb072999d 100644 --- a/mypyc/ir/class_ir.py +++ b/mypyc/ir/class_ir.py @@ -269,6 +269,22 @@ def attr_details(self, name: str) -> tuple[RType, ClassIR]: def attr_type(self, name: str) -> RType: return self.attr_details(name)[0] + def is_final_attr(self, name: str) -> bool: + """Is the (possibly inherited) attribute Final, i.e. never rebound? + + A Final attribute is read-only at runtime (it has no setter) and is assigned + exactly once during construction, so it can never be reassigned afterwards. + This makes it safe to borrow on free-threaded builds (no concurrent store can + invalidate a borrowed reference) and lets reads skip the concurrent-writer + guard. Returns False for properties and for attributes this class doesn't have. + """ + for ir in self.mro: + if name in ir.attributes: + return name in ir.final_attributes + if name in ir.property_types: + return False + return False + def method_decl(self, name: str) -> FuncDecl: for ir in self.mro: if name in ir.method_decls: diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index 56a3f944fe77f..9ee66c4ca0b0c 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1573,13 +1573,11 @@ def is_final_native_attr_ref(self, expr: MemberExpr) -> bool: builds, since no concurrent store can invalidate the borrowed reference. """ obj_rtype = self.node_type(expr.expr) - if not (isinstance(obj_rtype, RInstance) and obj_rtype.class_ir.is_ext_class): - return False - # Find the class that defines the attribute and check whether it's Final there. - for ir in obj_rtype.class_ir.mro: - if expr.name in ir.attributes: - return expr.name in ir.final_attributes - return False + return ( + isinstance(obj_rtype, RInstance) + and obj_rtype.class_ir.is_ext_class + and obj_rtype.class_ir.is_final_attr(expr.name) + ) def mark_block_unreachable(self) -> None: """Mark statements in the innermost block being processed as unreachable. diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index fee0cb3e4f2c5..73b6770c6ccb6 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -45,25 +45,21 @@ extern "C" { // // On free-threaded builds a plain load followed by an incref races with a // concurrent setter that may decref the old value to zero and free it before the -// incref runs (use-after-free). The fast path mirrors CPython's '_Py_XGetRef': -// optimistically incref via '_Py_TryIncrefCompare', which re-validates the -// pointer and backs out if a writer changed it. +// incref runs (use-after-free). This mirrors CPython's '_Py_XGetRef': +// optimistically incref, re-validating the pointer and backing out if a writer +// changed it. // -// The slow path (cold) handles a value that has not had maybe-weakref set and is -// owned by another thread: '_Py_TryIncRefShared' then returns 0 permanently, so a -// plain retry loop would spin forever. This case arises because 'CPy_InitAttrRef' -// publishes initializer stores WITHOUT setting maybe-weakref (a measured win for -// construction-heavy code -- see docs/design/free-threaded-attr-safety.md). We -// recover exactly as CPython's lock-free dict reader does (Objects/dictobject.c, -// the 'read_failed' path): take the owning object's critical section and force a -// cross-thread reference via '_Py_NewRefWithLock', which cannot fail and sets -// maybe-weakref so subsequent reads take the fast path. QSBR keeps the loaded -// value alive across this, since setters defer the freeing decref past any -// quiescent point. This same fallback also covers a transient lost race with a -// concurrent writer. It is only used in free-threaded builds; the default (GIL) -// build keeps the plain load + incref generated inline by mypyc. -// Cold slow path of CPy_GetAttrRef, kept out-of-line (see pythonsupport.c) so the -// inline fast path stays small enough for the compiler to inline at call sites. +// Only the hot case is inlined here: an incref of a value owned by this thread or +// immortal, via '_Py_TryIncrefFast' (no CAS, no loop). Everything colder -- +// '_Py_TryIncrefCompare's cross-thread shared-refcount CAS + pointer +// re-validation, and the critical-section fallback -- lives out-of-line in +// 'CPy_GetAttrRefSlow'. Splitting it this way keeps each call site's fast path +// small enough to inline, which is measurably faster than letting the compiler +// auto-out-line the whole helper (that merges every read site's branch history +// into one shared copy and mispredicts). See +// docs/design/free-threaded-attr-safety.md ("Experiment: inline only the reader +// fast path"). It is only used in free-threaded builds; the default (GIL) build +// keeps the plain load + incref generated inline by mypyc. PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field); static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { @@ -71,16 +67,37 @@ static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { if (v == NULL) { return NULL; } - // Hot case only: object is owned by this thread or immortal (no CAS, no loop). - // Everything else -- the cross-thread shared-refcount CAS, the pointer - // re-validation, and the not-yet-shared critical-section fallback -- lives in - // the out-of-line slow path to keep this body small enough to inline. if (_Py_TryIncrefFast(v)) { return v; } return CPy_GetAttrRefSlow(self, field); } +// Read a native attribute that is a single reference-counted 'PyObject *' field +// AND is Final (assigned once during construction, never rebound -- mypyc emits no +// setter for it), returning a new reference (or NULL if undefined). +// +// A Final attribute has no concurrent writer after 'self' is published, so the +// use-after-free race that CPy_GetAttrRef guards against cannot happen: the field +// holds a strong reference for the object's whole lifetime, and any thread reading +// it necessarily holds 'self', which keeps the value alive. So the try-incref + +// pointer re-validation + critical-section fallback are all unnecessary here -- a +// plain load + Py_INCREF is safe. A cross-thread Py_INCREF is an unconditional +// atomic add on ob_ref_shared, so (unlike CPy_GetAttrRef's try-incref) it needs no +// maybe-weakref and has no slow path. The load is relaxed rather than acquire: the +// reader reached 'self' through a synchronization edge (self's own publication) +// that already ordered the construction stores before it, exactly as with +// CPy_InitAttrRef's relaxed store. Relaxed keeps it TSan-clean at zero cost (plain +// mov/ldr). See docs/design/free-threaded-attr-safety.md ("Optimization: Final +// attributes skip the safe read path"). +static inline PyObject *CPy_GetAttrRefFinal(PyObject **field) { + PyObject *v = (PyObject *)_Py_atomic_load_ptr_relaxed(field); + if (v != NULL) { + Py_INCREF(v); + } + return v; +} + // Reclaim the previous value of a native attribute after it has been replaced. // // The fast path avoids QSBR deferral when the decref provably cannot free the diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test index 66caf0772ec40..8eaa63e4583b5 100644 --- a/mypyc/test-data/irbuild-classes.test +++ b/mypyc/test-data/irbuild-classes.test @@ -1276,6 +1276,46 @@ L0: r1 = r0.x return r1 +[case testCanBorrowFinalAttribute_nogil] +from typing import Final + +# On free-threaded builds native attribute reads are not borrowed (a concurrent +# store could free the old value), EXCEPT Final attributes, which can't be rebound. +# Here the intermediate 'd.c' is borrowed because 'c' is Final; contrast with +# testCannotBorrowAttribute_nogil, where the non-Final 'c' is read owned. The +# borrow decision uses the same ClassIR.is_final_attr predicate as codegen. +def f(d: D) -> int: + return d.c.xf + +class C: + def __init__(self, xf: int) -> None: + self.xf: Final = xf +class D: + def __init__(self, c: C) -> None: + self.c: Final = c +[out] +def f(d): + d :: __main__.D + r0 :: __main__.C + r1 :: int +L0: + r0 = borrow d.c + r1 = r0.xf + keep_alive d + return r1 +def C.__init__(self, xf): + self :: __main__.C + xf :: int +L0: + self.xf = xf + return 1 +def D.__init__(self, c): + self :: __main__.D + c :: __main__.C +L0: + self.c = c + return 1 + [case testNoBorrowOverPropertyAccess] class C: d: D diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 73927bb037a71..4721da4d6e7bf 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2865,6 +2865,58 @@ def test_rebind_inherited_via_setattr() -> None: assert d.x == 1 assert d.y == 2 +[case testFinalRefcountedAttributeRead] +# Exercises reading Final reference-counted ('PyObject *') attributes, which on +# free-threaded builds use a faster read path (CPy_GetAttrRefFinal) that skips the +# concurrent-writer guard, since a Final attribute is never rebound. +from typing import Final + +class C: + def __init__(self, s: str, items: list[int]) -> None: + self.s: Final = s + self.items: Final = items + + def read_s(self) -> str: + return self.s + + def read_items(self) -> list[int]: + return self.items + + def chained_len(self) -> int: + # borrowed intermediate read of a Final attr, then a call on it + return len(self.items) + +class D(C): + def __init__(self, s: str, items: list[int], t: str) -> None: + super().__init__(s, items) + self.t: Final = t + + def read_inherited(self) -> str: + return self.s + +def test_read_final_object_attrs() -> None: + c = C("hello", [1, 2, 3]) + # Read via getter (property access) and via native method bodies. + assert c.s == "hello" + assert c.read_s() == "hello" + assert c.items == [1, 2, 3] + assert c.read_items() == [1, 2, 3] + assert c.chained_len() == 3 + # Reading repeatedly must not corrupt the refcount / free the value. + for _ in range(1000): + assert c.read_s() == "hello" + assert c.read_items() == [1, 2, 3] + assert c.s == "hello" + assert c.read_items() is c.items + +def test_read_inherited_final_attr() -> None: + d = D("a", [4, 5], "b") + assert d.s == "a" + assert d.t == "b" + assert d.read_s() == "a" + assert d.read_inherited() == "a" + assert d.read_items() == [4, 5] + [case testClassDerivedFromIntEnum] from enum import IntEnum, auto From fa8e7abcfb34c1f2652a9b4168ae2b5a9e163d9b Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 8 Jul 2026 14:56:20 +0100 Subject: [PATCH 06/15] Update comments --- mypyc/lib-rt/pythonsupport.h | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 73b6770c6ccb6..d51a86d403a76 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -56,10 +56,8 @@ extern "C" { // 'CPy_GetAttrRefSlow'. Splitting it this way keeps each call site's fast path // small enough to inline, which is measurably faster than letting the compiler // auto-out-line the whole helper (that merges every read site's branch history -// into one shared copy and mispredicts). See -// docs/design/free-threaded-attr-safety.md ("Experiment: inline only the reader -// fast path"). It is only used in free-threaded builds; the default (GIL) build -// keeps the plain load + incref generated inline by mypyc. +// into one shared copy and mispredicts). It is only used in free-threaded builds; +// the default (GIL) build keeps the plain load + incref generated inline by mypyc. PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field); static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { @@ -88,8 +86,7 @@ static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { // reader reached 'self' through a synchronization edge (self's own publication) // that already ordered the construction stores before it, exactly as with // CPy_InitAttrRef's relaxed store. Relaxed keeps it TSan-clean at zero cost (plain -// mov/ldr). See docs/design/free-threaded-attr-safety.md ("Optimization: Final -// attributes skip the safe read path"). +// mov/ldr). static inline PyObject *CPy_GetAttrRefFinal(PyObject **field) { PyObject *v = (PyObject *)_Py_atomic_load_ptr_relaxed(field); if (v != NULL) { @@ -157,8 +154,7 @@ static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) { // is pure overhead here, ~+2.6ns per fresh store, and construction-heavy code // pays it on every attribute of every new object). The cost is moved off this hot // path onto CPy_GetAttrRef's cold slow path, which sets maybe-weakref lazily on -// the first cross-thread read that needs it. See -// docs/design/free-threaded-attr-safety.md ("Experiment: reader-side fallback"). +// the first cross-thread read that needs it. static inline void CPy_InitAttrRef(PyObject **field, PyObject *value) { _Py_atomic_store_ptr_relaxed(field, value); } From 95b9fd6c0bdd830c97b73c02908d8b627356f468 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 8 Jul 2026 15:09:17 +0100 Subject: [PATCH 07/15] Update comment --- mypyc/codegen/emitfunc.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index 4b99b66b0744a..2854865912b37 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -570,7 +570,9 @@ def visit_set_attr(self, op: SetAttr) -> None: attr_expr = self.get_attr_expr(obj, op, decl_cl) if op.is_init: # The attribute is known to be previously undefined (NULL), so - # there is no old value to reclaim; a release store suffices. + # there is no old value to reclaim; a relaxed store suffices + # (self's later publication provides the release barrier -- see + # CPy_InitAttrRef). self.emitter.emit_line(f"CPy_InitAttrRef((PyObject **)&{attr_expr}, {src});") else: # Atomically swap in the new value and reclaim the old one. From 1bb2e045921198db0b5e19cc527cb4335fcd6518 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 8 Jul 2026 15:21:21 +0100 Subject: [PATCH 08/15] Use CPy_NOINLINE --- mypyc/lib-rt/pythonsupport.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypyc/lib-rt/pythonsupport.c b/mypyc/lib-rt/pythonsupport.c index 1e420099a81d4..39e327a343eff 100644 --- a/mypyc/lib-rt/pythonsupport.c +++ b/mypyc/lib-rt/pythonsupport.c @@ -20,7 +20,7 @@ // shared-refcount CAS with pointer re-validation. Only if that also fails -- the // value has no shared refcount yet and is owned by another thread -- do we fall // into the critical section and force a cross-thread reference. -__attribute__((noinline)) +CPy_NOINLINE PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field) { PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); if (v == NULL) { From f650d7ea430f068ab38d9014f8d5a5b1f324fba3 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 8 Jul 2026 15:21:44 +0100 Subject: [PATCH 09/15] Fix another attribute read code path --- mypyc/codegen/emitfunc.py | 81 ++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index 2854865912b37..b0fb0ba976f8c 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -401,6 +401,37 @@ def get_attr_expr(self, obj: str, op: GetAttr | SetAttr, decl_cl: ClassIR) -> st cast = f"({decl_cl.struct_name(self.emitter.names)} *)" return f"({cast}{obj})->{self.emitter.attr(op.attr)}" + def emit_load_attr_take_ref( + self, dest: str, obj: str, op: GetAttr, cl: ClassIR, attr_rtype: RType, attr_expr: str + ) -> bool: + """Emit the load of a native attribute into 'dest', taking a new reference. + + On free-threaded builds, reading a single reference-counted 'PyObject *' field + and taking a new reference must be done atomically to avoid a use-after-free + race with a concurrent setter. CPy_GetAttrRef performs the load and incref + atomically and returns a new reference (or NULL if undefined), so callers must + NOT emit a separate inc_ref. Return True in that case so the caller can skip it. + + Final attributes are never rebound (no setter), so there is no concurrent writer + and no use-after-free window; an owned read uses the cheaper CPy_GetAttrRefFinal + (a plain load + incref). Borrowed reads keep the plain load: they are only emitted + for attributes safe to borrow on free-threaded builds (Final and vec attrs -- see + transform_member_expr in irbuild), whose values live as long as their container. + The default (GIL) build always takes the plain-load path and increfs separately. + """ + use_get_attr_ref = ( + IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype) and not op.is_borrowed + ) + if use_get_attr_ref and cl.is_final_attr(op.attr): + self.emitter.emit_line(f"{dest} = CPy_GetAttrRefFinal((PyObject **)&{attr_expr});") + elif use_get_attr_ref: + self.emitter.emit_line( + f"{dest} = CPy_GetAttrRef((PyObject *){obj}, (PyObject **)&{attr_expr});" + ) + else: + self.emitter.emit_line(f"{dest} = {attr_expr};") + return use_get_attr_ref + def visit_get_attr(self, op: GetAttr) -> None: if op.allow_error_value: self.get_attr_with_allow_error_value(op) @@ -434,33 +465,9 @@ def visit_get_attr(self, op: GetAttr) -> None: else: # Otherwise, use direct or offset struct access. attr_expr = self.get_attr_expr(obj, op, decl_cl) - # In free-threaded builds, reading a single reference-counted - # 'PyObject *' field and taking a new reference must be done - # atomically to avoid a use-after-free race with a concurrent setter. - # CPy_GetAttrRef performs the load and incref atomically and returns a - # new reference (or NULL if undefined), so no separate inc_ref is - # emitted below. Borrowed reads keep the plain load; they are only - # emitted for attributes that are safe to borrow on free-threaded builds - # (Final and vec attrs -- see transform_member_expr in irbuild). - # - # Final attributes are never rebound (no setter), so there is no - # concurrent writer and no use-after-free window: an owned read is a - # plain load + incref (via the cheaper CPy_GetAttrRefFinal), and a - # borrowed read stays a plain borrow (handled by the else branch), since - # the value lives as long as its container. - use_get_attr_ref = ( - IS_FREE_THREADED - and is_simple_refcounted_pointer(attr_rtype) - and not op.is_borrowed + use_get_attr_ref = self.emit_load_attr_take_ref( + dest, obj, op, cl, attr_rtype, attr_expr ) - if use_get_attr_ref and cl.is_final_attr(op.attr): - self.emitter.emit_line(f"{dest} = CPy_GetAttrRefFinal((PyObject **)&{attr_expr});") - elif use_get_attr_ref: - self.emitter.emit_line( - f"{dest} = CPy_GetAttrRef((PyObject *){obj}, (PyObject **)&{attr_expr});" - ) - else: - self.emitter.emit_line(f"{dest} = {attr_expr};") always_defined = cl.is_always_defined(op.attr) merged_branch = None if not always_defined: @@ -513,16 +520,20 @@ def get_attr_with_allow_error_value(self, op: GetAttr) -> None: cl = rtype.class_ir attr_rtype, decl_cl = cl.attr_details(op.attr) - # Direct struct access without NULL check attr_expr = self.get_attr_expr(obj, op, decl_cl) - self.emitter.emit_line(f"{dest} = {attr_expr};") - - # Only emit inc_ref if not NULL - if attr_rtype.is_refcounted and not op.is_borrowed: - check = self.error_value_check(op, "!=") - self.emitter.emit_line(f"if ({check}) {{") - self.emitter.emit_inc_ref(dest, attr_rtype) - self.emitter.emit_line("}") + # On free-threaded builds this takes a new reference atomically (see + # emit_load_attr_take_ref). CPy_GetAttrRef returns NULL when the field is + # undefined, which is precisely the error value here, so the "NULL without + # AttributeError" behavior is preserved (this op has error_kind ERR_NEVER). + # is_simple_refcounted_pointer excludes tuples/vecs, so error_value_check's + # special cases only matter on the plain-load path (where no ref was taken). + if not self.emit_load_attr_take_ref(dest, obj, op, cl, attr_rtype, attr_expr): + # Only emit inc_ref if not NULL + if attr_rtype.is_refcounted and not op.is_borrowed: + check = self.error_value_check(op, "!=") + self.emitter.emit_line(f"if ({check}) {{") + self.emitter.emit_inc_ref(dest, attr_rtype) + self.emitter.emit_line("}") def next_branch(self) -> Branch | None: if self.op_index + 1 < len(self.ops): From 1bf07dcba7b709211fb7398facbd309dcf0e424b Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 8 Jul 2026 16:39:08 +0100 Subject: [PATCH 10/15] Fix decref of old attribute value --- mypyc/lib-rt/pythonsupport.h | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index d51a86d403a76..0faaa011102d8 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -97,26 +97,26 @@ static inline PyObject *CPy_GetAttrRefFinal(PyObject **field) { // Reclaim the previous value of a native attribute after it has been replaced. // -// The fast path avoids QSBR deferral when the decref provably cannot free the -// object: immortal objects are never freed, and an object owned by the current -// thread with a local refcount > 1 stays at refcount >= 1 after a plain local -// decrement, so no concurrent reader can ever observe a freed header. Only the -// cases that could actually free (local refcount would reach zero, or the object -// is owned by another thread) defer the decref via QSBR, which keeps the memory -// valid until every thread has passed a quiescent point -- long enough for an -// in-flight reader that loaded the old pointer to finish its try-incref. +// CPy_GetAttrRef reads the field optimistically without holding any lock the +// writer also takes, so a reader can load the old pointer and then dereference it +// (in its try-incref / re-validation) after this store. The old value must +// therefore stay alive until every thread has passed a quiescent point, which is +// exactly what a QSBR-deferred decref guarantees. So all mortal old values are +// reclaimed via _PyObject_XDecRefDelayed, matching CPython's own replace-a-slot +// paths (e.g. _PyObject_SetDict / _PyObject_SetManagedDict). +// +// We deliberately do NOT take a "local refcount > 1, owned by this thread" fast +// path: dropping the field's reference is not the only decref of the object, so a +// non-freeing local decrement here does not prevent an unrelated reference holder +// from driving the object to zero (a plain, non-deferred Py_DECREF -> _Py_Dealloc) +// while an in-flight reader still holds the stale pointer -- a use-after-free that +// only QSBR deferral closes. Immortal objects are never freed, so skipping their +// decref entirely is safe and avoids queuing a no-op onto the delayed-free list. static inline void CPy_DecRefAttrOld(PyObject *op) { if (op == NULL) { return; } - uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local); - if (local == _Py_IMMORTAL_REFCNT_LOCAL) { - return; - } - if (local > 1 && _Py_IsOwnedByCurrentThread(op)) { - // Same as Py_DECREF's owned-thread fast path: only this thread writes - // ob_ref_local, and the refcount stays >= 1, so this is race-free. - _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, local - 1); + if (_Py_IsImmortal(op)) { return; } _PyObject_XDecRefDelayed(op); From 149500126d9c999ca682e09cfe2a4ec79575aa4f Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 9 Jul 2026 11:27:03 +0100 Subject: [PATCH 11/15] Optimize SetAttrRef -- drop _PyObject_SetMaybeWeakref --- mypyc/lib-rt/pythonsupport.h | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 0faaa011102d8..ec41ae23e8895 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -126,15 +126,21 @@ static inline void CPy_DecRefAttrOld(PyObject *op) { // stealing the reference to 'value' (which may be NULL to delete the attribute) // and safely reclaiming the previous value. // -// SetMaybeWeakref is required for correctness, not just performance: it lets a -// concurrent cross-thread reader's shared-refcount CAS in CPy_GetAttrRef succeed -// instead of failing and spinning (see the "writer must set maybe-weakref" -// requirement documented on CPython's _Py_XGetRef). The atomic exchange publishes -// the new pointer and hands back the old one without a writer/writer race. +// Memory safety does NOT depend on SetMaybeWeakref here, so (unlike an earlier +// version) we do not call it. Two things keep this safe: +// - The old value is reclaimed via CPy_DecRefAttrOld, a QSBR-deferred decref, so +// it cannot be freed while an in-flight CPy_GetAttrRef still holds the stale +// pointer. +// - A concurrent cross-thread reader whose fast-path try-incref fails on an +// unflagged 'value' does not spin: CPy_GetAttrRef falls into its +// critical-section fallback (CPy_GetAttrRefSlow -> _Py_NewRefWithLock), which +// cannot fail and sets maybe-weakref lazily on that first read. +// This mirrors CPy_InitAttrRef, which already omits SetMaybeWeakref for the same +// reason. Setting the flag here would only be a possible performance tuning knob +// (it keeps that first cross-thread reader on the lock-free path instead of the +// fallback); it is not needed for correctness. The atomic exchange publishes the +// new pointer and hands back the old one without a writer/writer race. static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) { - if (value != NULL) { - _PyObject_SetMaybeWeakref(value); - } PyObject *old = (PyObject *)_Py_atomic_exchange_ptr(field, value); CPy_DecRefAttrOld(old); } From bb82dcede574eca57db65aaa5d49970dc9d82558 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 9 Jul 2026 11:58:06 +0100 Subject: [PATCH 12/15] Remove critical section from get attr slow path --- mypyc/lib-rt/pythonsupport.c | 14 ++++++-------- mypyc/lib-rt/pythonsupport.h | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/mypyc/lib-rt/pythonsupport.c b/mypyc/lib-rt/pythonsupport.c index 39e327a343eff..c546fb562f490 100644 --- a/mypyc/lib-rt/pythonsupport.c +++ b/mypyc/lib-rt/pythonsupport.c @@ -9,17 +9,17 @@ // Cold slow path of CPy_GetAttrRef (declared in pythonsupport.h). Reached only // when the inline fast-path try-incref fails: the value has not had maybe-weakref // set (e.g. published by an initializer store that skipped it) and is owned by -// another thread, or we lost a race with a concurrent writer. Take the owning -// object's critical section and force a cross-thread reference via -// _Py_NewRefWithLock, which cannot fail and sets maybe-weakref so subsequent -// reads take the fast path. Kept out-of-line so the inline fast path stays small. +// another thread, or we lost a race with a concurrent writer. Reload the pointer +// and force a cross-thread reference via _Py_NewRefWithLock, which cannot fail +// and sets maybe-weakref so subsequent reads take the fast path. Kept out-of-line +// so the inline fast path stays small. // // The inline fast path only handled the owned-by-this-thread / immortal case // (_Py_TryIncrefFast). Here we first retry the full try-incref-compare: reload the // pointer (it may have changed since the inline load) and attempt the cross-thread // shared-refcount CAS with pointer re-validation. Only if that also fails -- the -// value has no shared refcount yet and is owned by another thread -- do we fall -// into the critical section and force a cross-thread reference. +// value has no shared refcount yet and is owned by another thread -- do we force +// a cross-thread reference. CPy_NOINLINE PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field) { PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); @@ -29,12 +29,10 @@ PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field) { if (_Py_TryIncrefCompare(field, v)) { return v; } - Py_BEGIN_CRITICAL_SECTION(self); v = (PyObject *)_Py_atomic_load_ptr(field); if (v != NULL) { _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail } - Py_END_CRITICAL_SECTION(); return v; } #endif diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index ec41ae23e8895..1f0b6b10a65c4 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -52,7 +52,7 @@ extern "C" { // Only the hot case is inlined here: an incref of a value owned by this thread or // immortal, via '_Py_TryIncrefFast' (no CAS, no loop). Everything colder -- // '_Py_TryIncrefCompare's cross-thread shared-refcount CAS + pointer -// re-validation, and the critical-section fallback -- lives out-of-line in +// re-validation, and the _Py_NewRefWithLock fallback -- lives out-of-line in // 'CPy_GetAttrRefSlow'. Splitting it this way keeps each call site's fast path // small enough to inline, which is measurably faster than letting the compiler // auto-out-line the whole helper (that merges every read site's branch history @@ -79,7 +79,7 @@ static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { // use-after-free race that CPy_GetAttrRef guards against cannot happen: the field // holds a strong reference for the object's whole lifetime, and any thread reading // it necessarily holds 'self', which keeps the value alive. So the try-incref + -// pointer re-validation + critical-section fallback are all unnecessary here -- a +// pointer re-validation + _Py_NewRefWithLock fallback are all unnecessary here -- a // plain load + Py_INCREF is safe. A cross-thread Py_INCREF is an unconditional // atomic add on ob_ref_shared, so (unlike CPy_GetAttrRef's try-incref) it needs no // maybe-weakref and has no slow path. The load is relaxed rather than acquire: the @@ -133,8 +133,8 @@ static inline void CPy_DecRefAttrOld(PyObject *op) { // pointer. // - A concurrent cross-thread reader whose fast-path try-incref fails on an // unflagged 'value' does not spin: CPy_GetAttrRef falls into its -// critical-section fallback (CPy_GetAttrRefSlow -> _Py_NewRefWithLock), which -// cannot fail and sets maybe-weakref lazily on that first read. +// _Py_NewRefWithLock fallback (CPy_GetAttrRefSlow), which cannot fail and +// sets maybe-weakref lazily on that first read. // This mirrors CPy_InitAttrRef, which already omits SetMaybeWeakref for the same // reason. Setting the flag here would only be a possible performance tuning knob // (it keeps that first cross-thread reader on the lock-free path instead of the From c6f345e80dbd9b210b1ecab225ca72434d6e62e7 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 9 Jul 2026 12:14:32 +0100 Subject: [PATCH 13/15] Further simplify slow path --- mypyc/codegen/emitclass.py | 2 +- mypyc/codegen/emitfunc.py | 4 +--- mypyc/lib-rt/pythonsupport.c | 35 ++++++++++------------------- mypyc/lib-rt/pythonsupport.h | 43 ++++++++++++++++++------------------ 4 files changed, 35 insertions(+), 49 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index 37a0eacb92b48..e778e5b8ccfec 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -1180,7 +1180,7 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N if attr in cl.final_attributes: getattr_ref = f"CPy_GetAttrRefFinal((PyObject **)&{attr_expr})" else: - getattr_ref = f"CPy_GetAttrRef((PyObject *)self, (PyObject **)&{attr_expr})" + getattr_ref = f"CPy_GetAttrRef((PyObject **)&{attr_expr})" emitter.emit_line(f"PyObject *retval = {getattr_ref};") emitter.emit_line("if (unlikely(retval == NULL)) {") emitter.emit_line("PyErr_SetString(PyExc_AttributeError,") diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index b0fb0ba976f8c..4c3c17d047c8a 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -425,9 +425,7 @@ def emit_load_attr_take_ref( if use_get_attr_ref and cl.is_final_attr(op.attr): self.emitter.emit_line(f"{dest} = CPy_GetAttrRefFinal((PyObject **)&{attr_expr});") elif use_get_attr_ref: - self.emitter.emit_line( - f"{dest} = CPy_GetAttrRef((PyObject *){obj}, (PyObject **)&{attr_expr});" - ) + self.emitter.emit_line(f"{dest} = CPy_GetAttrRef((PyObject **)&{attr_expr});") else: self.emitter.emit_line(f"{dest} = {attr_expr};") return use_get_attr_ref diff --git a/mypyc/lib-rt/pythonsupport.c b/mypyc/lib-rt/pythonsupport.c index c546fb562f490..a8f5a4f4ad4ea 100644 --- a/mypyc/lib-rt/pythonsupport.c +++ b/mypyc/lib-rt/pythonsupport.c @@ -7,33 +7,22 @@ #ifdef Py_GIL_DISABLED // Cold slow path of CPy_GetAttrRef (declared in pythonsupport.h). Reached only -// when the inline fast-path try-incref fails: the value has not had maybe-weakref -// set (e.g. published by an initializer store that skipped it) and is owned by -// another thread, or we lost a race with a concurrent writer. Reload the pointer -// and force a cross-thread reference via _Py_NewRefWithLock, which cannot fail -// and sets maybe-weakref so subsequent reads take the fast path. Kept out-of-line -// so the inline fast path stays small. +// when the inline fast-path try-incref fails: the value is owned by another +// thread, so taking a reference requires an atomic shared-refcount operation. +// Kept out-of-line so the inline fast path stays small. // -// The inline fast path only handled the owned-by-this-thread / immortal case -// (_Py_TryIncrefFast). Here we first retry the full try-incref-compare: reload the -// pointer (it may have changed since the inline load) and attempt the cross-thread -// shared-refcount CAS with pointer re-validation. Only if that also fails -- the -// value has no shared refcount yet and is owned by another thread -- do we force -// a cross-thread reference. +// First try the lock-free shared-refcount CAS. If the value has not had +// maybe-weakref set yet (for example, it was published by CPy_InitAttrRef), force +// a cross-thread reference via _Py_NewRefWithLock, which cannot fail and sets +// maybe-weakref so subsequent reads take the fast path. The value was already +// observed in the field by CPy_GetAttrRef; CPy_SetAttrRef's QSBR-delayed decref +// keeps any replaced value alive long enough for this reader. CPy_NOINLINE -PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field) { - PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); - if (v == NULL) { - return NULL; - } - if (_Py_TryIncrefCompare(field, v)) { +PyObject *CPy_GetAttrRefSlow(PyObject *v) { + if (_Py_TryIncRefShared(v)) { return v; } - v = (PyObject *)_Py_atomic_load_ptr(field); - if (v != NULL) { - _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail - } - return v; + return _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail } #endif diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 1f0b6b10a65c4..f8340b4df8c59 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -24,7 +24,7 @@ #endif #ifdef Py_GIL_DISABLED -#include "internal/pycore_object.h" // _Py_TryIncrefCompare +#include "internal/pycore_object.h" // _Py_TryIncrefFast, _Py_TryIncRefShared #endif #if CPY_3_12_FEATURES @@ -40,27 +40,26 @@ extern "C" { #ifdef Py_GIL_DISABLED // Read a native attribute that is a single reference-counted 'PyObject *' field, -// returning a new reference (or NULL if the field is NULL/undefined). 'self' is -// the object owning the field, used only by the cold slow path below. +// returning a new reference (or NULL if the field is NULL/undefined). // // On free-threaded builds a plain load followed by an incref races with a // concurrent setter that may decref the old value to zero and free it before the -// incref runs (use-after-free). This mirrors CPython's '_Py_XGetRef': -// optimistically incref, re-validating the pointer and backing out if a writer -// changed it. +// incref runs (use-after-free). CPy_SetAttrRef avoids that by reclaiming old +// values through QSBR-delayed decref, so a value observed in the field remains +// safe to touch while this reader is running. // // Only the hot case is inlined here: an incref of a value owned by this thread or -// immortal, via '_Py_TryIncrefFast' (no CAS, no loop). Everything colder -- -// '_Py_TryIncrefCompare's cross-thread shared-refcount CAS + pointer -// re-validation, and the _Py_NewRefWithLock fallback -- lives out-of-line in -// 'CPy_GetAttrRefSlow'. Splitting it this way keeps each call site's fast path -// small enough to inline, which is measurably faster than letting the compiler -// auto-out-line the whole helper (that merges every read site's branch history -// into one shared copy and mispredicts). It is only used in free-threaded builds; -// the default (GIL) build keeps the plain load + incref generated inline by mypyc. -PyObject *CPy_GetAttrRefSlow(PyObject *self, PyObject **field); - -static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { +// immortal, via '_Py_TryIncrefFast' (no CAS, no loop). Everything colder -- the +// cross-thread shared-refcount CAS and the _Py_NewRefWithLock fallback -- lives +// out-of-line in 'CPy_GetAttrRefSlow'. Splitting it this way keeps each call +// site's fast path small enough to inline, which is measurably faster than +// letting the compiler auto-out-line the whole helper (that merges every read +// site's branch history into one shared copy and mispredicts). It is only used in +// free-threaded builds; the default (GIL) build keeps the plain load + incref +// generated inline by mypyc. +PyObject *CPy_GetAttrRefSlow(PyObject *v); + +static inline PyObject *CPy_GetAttrRef(PyObject **field) { PyObject *v = (PyObject *)_Py_atomic_load_ptr_acquire(field); if (v == NULL) { return NULL; @@ -68,7 +67,7 @@ static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { if (_Py_TryIncrefFast(v)) { return v; } - return CPy_GetAttrRefSlow(self, field); + return CPy_GetAttrRefSlow(v); } // Read a native attribute that is a single reference-counted 'PyObject *' field @@ -79,7 +78,7 @@ static inline PyObject *CPy_GetAttrRef(PyObject *self, PyObject **field) { // use-after-free race that CPy_GetAttrRef guards against cannot happen: the field // holds a strong reference for the object's whole lifetime, and any thread reading // it necessarily holds 'self', which keeps the value alive. So the try-incref + -// pointer re-validation + _Py_NewRefWithLock fallback are all unnecessary here -- a +// _Py_NewRefWithLock fallback are unnecessary here -- a // plain load + Py_INCREF is safe. A cross-thread Py_INCREF is an unconditional // atomic add on ob_ref_shared, so (unlike CPy_GetAttrRef's try-incref) it needs no // maybe-weakref and has no slow path. The load is relaxed rather than acquire: the @@ -98,9 +97,9 @@ static inline PyObject *CPy_GetAttrRefFinal(PyObject **field) { // Reclaim the previous value of a native attribute after it has been replaced. // // CPy_GetAttrRef reads the field optimistically without holding any lock the -// writer also takes, so a reader can load the old pointer and then dereference it -// (in its try-incref / re-validation) after this store. The old value must -// therefore stay alive until every thread has passed a quiescent point, which is +// writer also takes, so a reader can load the old pointer and then try to take a +// reference after this store. The old value must therefore stay alive until every +// thread has passed a quiescent point, which is // exactly what a QSBR-deferred decref guarantees. So all mortal old values are // reclaimed via _PyObject_XDecRefDelayed, matching CPython's own replace-a-slot // paths (e.g. _PyObject_SetDict / _PyObject_SetManagedDict). From 8320eae008271d5fec179cea43aec2130b32e244 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 9 Jul 2026 13:13:42 +0100 Subject: [PATCH 14/15] Update comments --- mypyc/codegen/emitclass.py | 3 ++- mypyc/lib-rt/pythonsupport.h | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index e778e5b8ccfec..a1127b15ad9d1 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -1174,7 +1174,8 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N # # Final attributes are never rebound (no setter), so there is no concurrent # writer to race with: a plain load + incref is safe. Use the cheaper - # CPy_GetAttrRefFinal, which skips the try-incref/re-validation machinery. + # CPy_GetAttrRefFinal, which skips the try-incref and _Py_NewRefWithLock + # slow path entirely (an unconditional Py_INCREF needs no maybe-weakref). # This getter is generated per defining class, so a direct membership test # matches the read-only getset table above (no need to walk the MRO). if attr in cl.final_attributes: diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index f8340b4df8c59..36b107e62571f 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -78,8 +78,8 @@ static inline PyObject *CPy_GetAttrRef(PyObject **field) { // use-after-free race that CPy_GetAttrRef guards against cannot happen: the field // holds a strong reference for the object's whole lifetime, and any thread reading // it necessarily holds 'self', which keeps the value alive. So the try-incref + -// _Py_NewRefWithLock fallback are unnecessary here -- a -// plain load + Py_INCREF is safe. A cross-thread Py_INCREF is an unconditional +// _Py_NewRefWithLock fallback are unnecessary here -- a plain load + Py_INCREF is +// safe. A cross-thread Py_INCREF is an unconditional // atomic add on ob_ref_shared, so (unlike CPy_GetAttrRef's try-incref) it needs no // maybe-weakref and has no slow path. The load is relaxed rather than acquire: the // reader reached 'self' through a synchronization edge (self's own publication) @@ -99,8 +99,8 @@ static inline PyObject *CPy_GetAttrRefFinal(PyObject **field) { // CPy_GetAttrRef reads the field optimistically without holding any lock the // writer also takes, so a reader can load the old pointer and then try to take a // reference after this store. The old value must therefore stay alive until every -// thread has passed a quiescent point, which is -// exactly what a QSBR-deferred decref guarantees. So all mortal old values are +// thread has passed a quiescent point, which is exactly what a QSBR-deferred +// decref guarantees. So all mortal old values are // reclaimed via _PyObject_XDecRefDelayed, matching CPython's own replace-a-slot // paths (e.g. _PyObject_SetDict / _PyObject_SetManagedDict). // From 1b73f6519ef64acd6701768e50d4de40f2c635e1 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Fri, 10 Jul 2026 11:51:03 +0100 Subject: [PATCH 15/15] Update stale comment --- mypyc/lib-rt/pythonsupport.h | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/mypyc/lib-rt/pythonsupport.h b/mypyc/lib-rt/pythonsupport.h index 36b107e62571f..33c5a596d1747 100644 --- a/mypyc/lib-rt/pythonsupport.h +++ b/mypyc/lib-rt/pythonsupport.h @@ -130,15 +130,18 @@ static inline void CPy_DecRefAttrOld(PyObject *op) { // - The old value is reclaimed via CPy_DecRefAttrOld, a QSBR-deferred decref, so // it cannot be freed while an in-flight CPy_GetAttrRef still holds the stale // pointer. -// - A concurrent cross-thread reader whose fast-path try-incref fails on an -// unflagged 'value' does not spin: CPy_GetAttrRef falls into its -// _Py_NewRefWithLock fallback (CPy_GetAttrRefSlow), which cannot fail and -// sets maybe-weakref lazily on that first read. +// - A concurrent cross-thread reader whose inline fast-path try-incref fails on +// an unflagged 'value' still cannot fail and never blocks on this writer: +// CPy_GetAttrRef falls into CPy_GetAttrRefSlow, which is fully lock-free. It +// retries with a lock-free shared-refcount CAS (_Py_TryIncRefShared) and, only +// if that also fails, forces a reference via _Py_NewRefWithLock, which cannot +// fail and lazily sets maybe-weakref so later cross-thread reads take the CAS. // This mirrors CPy_InitAttrRef, which already omits SetMaybeWeakref for the same // reason. Setting the flag here would only be a possible performance tuning knob -// (it keeps that first cross-thread reader on the lock-free path instead of the -// fallback); it is not needed for correctness. The atomic exchange publishes the -// new pointer and hands back the old one without a writer/writer race. +// (it would let that first cross-thread reader succeed on the cheaper +// _Py_TryIncRefShared CAS instead of falling through to _Py_NewRefWithLock); it is +// not needed for correctness. The atomic exchange publishes the new pointer and +// hands back the old one without a writer/writer race. static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) { PyObject *old = (PyObject *)_Py_atomic_exchange_ptr(field, value); CPy_DecRefAttrOld(old);