From 71c41f2ce3b469769bc518197ccf04a328c4a557 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 12 Feb 2026 14:39:10 +0000 Subject: [PATCH 1/3] [mypyc] Support explicit acyclic native classes that avoid gc Use `@mypyc_attr(acyclic=True)` so that instances of the class don't participate in cyclic gc. This sped up a microbenchmark that just allocates lots of temporary objects by ~60%. Acyclic instances also use less memory, since there is no GC header. I did some manual testing beyond the added tests to ensure acyclic classes work as expected. This was created using Claude Code. --- mypyc/codegen/emitclass.py | 18 ++++++---- mypyc/doc/native_classes.rst | 28 ++++++++++++++++ mypyc/ir/class_ir.py | 7 ++++ mypyc/irbuild/prepare.py | 3 ++ mypyc/irbuild/util.py | 5 +-- mypyc/test-data/run-classes.test | 57 ++++++++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 9 deletions(-) diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index fea6280551110..26bf189694f97 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -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: @@ -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) @@ -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( @@ -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;") @@ -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 diff --git a/mypyc/doc/native_classes.rst b/mypyc/doc/native_classes.rst index dbcf238b78d57..e6b2c37ebeed4 100644 --- a/mypyc/doc/native_classes.rst +++ b/mypyc/doc/native_classes.rst @@ -266,6 +266,34 @@ 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. + +.. 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 ---------------- diff --git a/mypyc/ir/class_ir.py b/mypyc/ir/class_ir.py index 50a8225b4a68f..f754275480edc 100644 --- a/mypyc/ir/class_ir.py +++ b/mypyc/ir/class_ir.py @@ -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 @@ -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, } @@ -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"] diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py index 9f3c7fc6f2707..15a82b1f719c6 100644 --- a/mypyc/irbuild/prepare.py +++ b/mypyc/irbuild/prepare.py @@ -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"] diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index 912deb581c9a6..5eda51a1a5dea 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -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" ] @@ -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: diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 2fd767c584ed2..cd3a0bf349b71 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -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) From 9ee9833a532fbd6fbf450779a04bbf40068be81b Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 12 Feb 2026 16:35:10 +0000 Subject: [PATCH 2/3] Fix test --- mypyc/test-data/irbuild-classes.test | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test index 7954535d5dea8..eacb7413e4d09 100644 --- a/mypyc/test-data/irbuild-classes.test +++ b/mypyc/test-data/irbuild-classes.test @@ -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. From 418371f3496e1e524e7c9937d6757b1575fd0e62 Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Thu, 12 Feb 2026 17:22:23 +0000 Subject: [PATCH 3/3] Mention subclasses --- mypyc/doc/native_classes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mypyc/doc/native_classes.rst b/mypyc/doc/native_classes.rst index e6b2c37ebeed4..ee2cba7e746f0 100644 --- a/mypyc/doc/native_classes.rst +++ b/mypyc/doc/native_classes.rst @@ -287,6 +287,10 @@ 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