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
18 changes: 11 additions & 7 deletions mypyc/codegen/emitclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,9 @@ def generate_class(cl: ClassIR, module: str, emitter: Emitter) -> None:

if generate_full:
fields["tp_dealloc"] = f"(destructor){name_prefix}_dealloc"
fields["tp_traverse"] = f"(traverseproc){name_prefix}_traverse"
fields["tp_clear"] = f"(inquiry){name_prefix}_clear"
if not cl.is_acyclic:
fields["tp_traverse"] = f"(traverseproc){name_prefix}_traverse"
fields["tp_clear"] = f"(inquiry){name_prefix}_clear"
# Populate .tp_finalize and generate a finalize method only if __del__ is defined for this class.
del_method = next((e.method for e in cl.vtable_entries if e.name == "__del__"), None)
if del_method:
Expand Down Expand Up @@ -344,8 +345,9 @@ def emit_line() -> None:
init_fn = cl.get_method("__init__")
generate_new_for_class(cl, new_name, vtable_name, setup_name, init_fn, emitter)
emit_line()
generate_traverse_for_class(cl, traverse_name, emitter)
emit_line()
if not cl.is_acyclic:
generate_traverse_for_class(cl, traverse_name, emitter)
emit_line()
generate_clear_for_class(cl, clear_name, emitter)
emit_line()
generate_dealloc_for_class(cl, dealloc_name, clear_name, bool(del_method), emitter)
Expand Down Expand Up @@ -378,7 +380,7 @@ def emit_line() -> None:
emit_line()

flags = ["Py_TPFLAGS_DEFAULT", "Py_TPFLAGS_HEAPTYPE", "Py_TPFLAGS_BASETYPE"]
if generate_full:
if generate_full and not cl.is_acyclic:
flags.append("Py_TPFLAGS_HAVE_GC")
if cl.has_method("__call__"):
fields["tp_vectorcall_offset"] = "offsetof({}, vectorcall)".format(
Expand Down Expand Up @@ -621,7 +623,8 @@ def generate_setup_for_class(
emitter.emit_line(f"self = {prefix}_free_instance;")
emitter.emit_line(f"{prefix}_free_instance = NULL;")
emitter.emit_line("Py_SET_REFCNT(self, 1);")
emitter.emit_line("PyObject_GC_Track(self);")
if not cl.is_acyclic:
emitter.emit_line("PyObject_GC_Track(self);")
if defaults_fn is not None:
emit_attr_defaults_func_call(defaults_fn, "self", emitter)
emitter.emit_line("return (PyObject *)self;")
Expand Down Expand Up @@ -930,7 +933,8 @@ def generate_dealloc_for_class(
emitter.emit_line("if (res < 0) {")
emitter.emit_line("goto done;")
emitter.emit_line("}")
emitter.emit_line("PyObject_GC_UnTrack(self);")
if not cl.is_acyclic:
emitter.emit_line("PyObject_GC_UnTrack(self);")
if cl.reuse_freed_instance:
emit_reuse_dealloc(cl, emitter)
# The trashcan is needed to handle deep recursive deallocations
Expand Down
32 changes: 32 additions & 0 deletions mypyc/doc/native_classes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,38 @@ refer to attributes. These are not valid::

__deletable__ = ('a',) # Error: not in a class body

Acyclic classes
---------------

By default, native classes participate in CPython's cyclic garbage
collector (GC). This adds some overhead to object allocation and
deallocation. If you know that instances of a class can never be
part of reference cycles, you can opt out of cyclic GC using
``@mypyc_attr(acyclic=True)``::

from mypy_extensions import mypyc_attr

@mypyc_attr(acyclic=True)
class Leaf:
def __init__(self, x: int, name: str) -> None:
self.x = x
self.name = name

This can improve performance, especially for classes that are
allocated and deallocated frequently. Acyclic instances also use
less memory, since CPython doesn't need to add a GC header to them.

The acyclic property is not inherited by subclasses. Each subclass
must explicitly use ``@mypyc_attr(acyclic=True)`` to also opt out
of cyclic GC.

.. warning::

If instances of an acyclic class actually participate in reference
cycles, those cycles will never be collected, resulting in memory
leaks. Only use this for classes whose instances won't refer back
to objects that (directly or indirectly) refer to the instance.

Other properties
----------------

Expand Down
7 changes: 7 additions & 0 deletions mypyc/ir/class_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ def __init__(
# per-type free "list" of up to length 1.
self.reuse_freed_instance = False

# If True, the class does not participate in cyclic garbage collection.
# This can improve performance but is only safe if instances can never
# be part of reference cycles. Derived from @mypyc_attr(acyclic=True).
self.is_acyclic = False

# Is this a class inheriting from enum.Enum? Such classes can be special-cased.
self.is_enum = False

Expand Down Expand Up @@ -426,6 +431,7 @@ def serialize(self) -> JsonDict:
"init_self_leak": self.init_self_leak,
"env_user_function": self.env_user_function.id if self.env_user_function else None,
"reuse_freed_instance": self.reuse_freed_instance,
"is_acyclic": self.is_acyclic,
"is_enum": self.is_enum,
"is_coroutine": self.coroutine_name,
}
Expand Down Expand Up @@ -484,6 +490,7 @@ def deserialize(cls, data: JsonDict, ctx: DeserMaps) -> ClassIR:
ctx.functions[data["env_user_function"]] if data["env_user_function"] else None
)
ir.reuse_freed_instance = data["reuse_freed_instance"]
ir.is_acyclic = data.get("is_acyclic", False)
ir.is_enum = data["is_enum"]
ir.coroutine_name = data["is_coroutine"]

Expand Down
3 changes: 3 additions & 0 deletions mypyc/irbuild/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@ def prepare_class_def(
# Supports copy.copy and pickle (including subclasses)
ir._serializable = True

if attrs.get("acyclic") is True:
ir.is_acyclic = True

free_list_len = attrs.get("free_list_len")
if free_list_len is not None:
line = attrs_lines["free_list_len"]
Expand Down
5 changes: 3 additions & 2 deletions mypyc/irbuild/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@
from mypyc.errors import Errors

MYPYC_ATTRS: Final[frozenset[MypycAttr]] = frozenset(
["native_class", "allow_interpreted_subclasses", "serializable", "free_list_len"]
["native_class", "allow_interpreted_subclasses", "serializable", "free_list_len", "acyclic"]
)

DATACLASS_DECORATORS: Final = frozenset(["dataclasses.dataclass", "attr.s", "attr.attrs"])


MypycAttr = Literal[
"native_class", "allow_interpreted_subclasses", "serializable", "free_list_len"
"native_class", "allow_interpreted_subclasses", "serializable", "free_list_len", "acyclic"
]


Expand All @@ -49,6 +49,7 @@ class MypycAttrs(TypedDict):
allow_interpreted_subclasses: NotRequired[bool]
serializable: NotRequired[bool]
free_list_len: NotRequired[int]
acyclic: NotRequired[bool]


def is_final_decorator(d: Expression) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions mypyc/test-data/irbuild-classes.test
Original file line number Diff line number Diff line change
Expand Up @@ -2885,11 +2885,11 @@ L0:
from mypy_extensions import mypyc_attr

@mypyc_attr("allow_interpreted_subclasses", "invalid_arg") # E: "invalid_arg" is not a supported "mypyc_attr" \
# N: supported keys: "allow_interpreted_subclasses", "free_list_len", "native_class", "serializable"
# N: supported keys: "acyclic", "allow_interpreted_subclasses", "free_list_len", "native_class", "serializable"
class InvalidArg:
pass
@mypyc_attr(invalid_kwarg=True) # E: "invalid_kwarg" is not a supported "mypyc_attr" \
# N: supported keys: "allow_interpreted_subclasses", "free_list_len", "native_class", "serializable"
# N: supported keys: "acyclic", "allow_interpreted_subclasses", "free_list_len", "native_class", "serializable"
class InvalidKwarg:
pass
@mypyc_attr(str()) # E: All "mypyc_attr" positional arguments must be string literals.
Expand Down
57 changes: 57 additions & 0 deletions mypyc/test-data/run-classes.test
Original file line number Diff line number Diff line change
Expand Up @@ -5619,3 +5619,60 @@ def test_read_corrupted_data() -> None:
print("RANDOMIZED TEST FAILURE -- please open an issue with the following context:")
print(">>>", e, data)
raise

[case testAcyclicClass]
import gc
from mypy_extensions import mypyc_attr

@mypyc_attr(acyclic=True)
class Leaf:
def __init__(self, x: int, s: str) -> None:
self.x = x
self.s = s

def test_basic() -> None:
o = Leaf(5, "hello")
assert o.x == 5
assert o.s == "hello"
o.x = 10
assert o.x == 10

def test_gc_not_tracked() -> None:
o = Leaf(1, "a")
assert not gc.is_tracked(o)

def test_dealloc() -> None:
for i in range(1000):
o = Leaf(i, str(i))
# Just verify no crash or leak during repeated alloc/dealloc

@mypyc_attr(acyclic=True)
class AcyclicBase:
def __init__(self, x: int) -> None:
self.x = x

class DerivedNotAcyclic(AcyclicBase):
"""Derived without acyclic -- still participates in GC."""
def __init__(self, x: int, y: str) -> None:
super().__init__(x)
self.y = y

@mypyc_attr(acyclic=True)
class DerivedAcyclic(AcyclicBase):
"""Derived with acyclic -- also opts out of GC."""
def __init__(self, x: int, y: str) -> None:
super().__init__(x)
self.y = y

def test_derived_not_acyclic() -> None:
d = DerivedNotAcyclic(3, "hi")
assert d.x == 3
assert d.y == "hi"
# Subclass without @mypyc_attr(acyclic=True) still participates in GC
assert gc.is_tracked(d)

def test_derived_acyclic() -> None:
d = DerivedAcyclic(3, "hi")
assert d.x == 3
assert d.y == "hi"
assert not gc.is_tracked(d)