Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion mypyc/codegen/emitclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
BITMAP_BITS,
BITMAP_TYPE,
CPYFUNCTION_NAME,
IS_FREE_THREADED,
MYPYC_DEFAULTS_SETUP,
NATIVE_PREFIX,
PREFIX,
Expand All @@ -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

Expand Down Expand Up @@ -1165,6 +1166,32 @@ 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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What is done in the non is_simple_refcounted_pointer case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

They are unsafe currently, but I will improve the situation in follow-up PR(s). Here's a summary of my current ideas:

  • Tagged integers will use minimal synchronization if both old and new values are short ints, to avoid slowing down common and quick integer operations. This means we could leak long integers, but the risk seems very low (and leaks are less bad than segfaults). When we encounter a long integer object, we'll fall back to the approach used in this PR.
  • For fixed-length tuples, we'll probably need to take a critical section on both read and write.
  • vec types will continue to be unsynchronized/unsafe, as these are a custom feature we've built and very performance-focused.
  • For value types like bool and i32 we'll use relaxed memory order reads/writes.

# 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.
#
# 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 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:
getattr_ref = f"CPy_GetAttrRefFinal((PyObject **)&{attr_expr})"
else:
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,")
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.
Expand Down Expand Up @@ -1202,6 +1229,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.
Expand Down
83 changes: 71 additions & 12 deletions mypyc/codegen/emitfunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -83,6 +89,7 @@
is_int_rprimitive,
is_none_rprimitive,
is_pointer_rprimitive,
is_simple_refcounted_pointer,
is_tagged,
)

Expand Down Expand Up @@ -394,6 +401,35 @@ 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 **)&{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)
Expand Down Expand Up @@ -427,7 +463,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)
self.emitter.emit_line(f"{dest} = {attr_expr};")
use_get_attr_ref = self.emit_load_attr_take_ref(
dest, obj, op, cl, attr_rtype, attr_expr
)
always_defined = cl.is_always_defined(op.attr)
merged_branch = None
if not always_defined:
Expand Down Expand Up @@ -458,7 +496,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)
Expand All @@ -480,16 +518,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):
Expand Down Expand Up @@ -529,6 +571,23 @@ 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 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.
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)
Expand Down
16 changes: 16 additions & 0 deletions mypyc/ir/class_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions mypyc/ir/rtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 5 additions & 7 deletions mypyc/irbuild/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1642,13 +1642,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 root_is_reassigned(self, v: Value) -> bool:
"""Is the root local variable a borrow chain 'v' reads from reassigned this expression?
Expand Down
21 changes: 21 additions & 0 deletions mypyc/lib-rt/pythonsupport.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@

#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 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.
//
// 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 *v) {
if (_Py_TryIncRefShared(v)) {
return v;
}
return _Py_NewRefWithLock(v); // sets maybe-weakref; cannot fail
}
#endif

/////////////////////////////////////////
// Adapted from bltinmodule.c in Python 3.7.0
PyObject*
Expand Down
Loading
Loading