Skip to content

Commit 02e9928

Browse files
authored
fix(py_class): remove __ffi_init_inplace__ to fix memory leak (#551)
## Summary - **Fix memory leak** in `@py_class`: the custom `__new__` override allocated a spurious C++ object via `__ffi_new__` whenever a py_class instance was returned from C++, which was then orphaned when `make_ret_object` overwrote `chandle` with the actual return value. - **Remove `__ffi_init_inplace__`** entirely — both the C++ `MakeInitInplace` function, its registration, and the `kInitInplace` constant. - **Unify `c_class` and `py_class`** auto-generated `__init__` to both use `self.__init_handle_by_constructor__(ffi_init, *args)`, differing only in guard logic: py_class uses a `chandle != 0` guard (no-op when already allocated), c_class uses a `type_info` identity guard (`TypeError` from subclass). - **Remove the custom `__new__` override** — no more spurious allocation on the `make_ret` path. - **Wrap user-defined `__init__`** on py_class with a `chandle == 0` guard that pre-allocates via `__ffi_new__` before user code runs; use `functools.wraps` to preserve metadata. ### Files changed | File | Change | |------|--------| | `python/tvm_ffi/_dunder.py` | Core redesign: removed `inplace` param, added `py_class_mode`, removed `__new__` override, added user-init wrapping with `functools.wraps` | | `python/tvm_ffi/registry.py` | Removed `inplace=False` kwarg from `_make_init` call | | `include/tvm/ffi/reflection/accessor.h` | Removed `kInitInplace` constant | | `include/tvm/ffi/reflection/registry.h` | Updated comments (removed `__ffi_init_inplace__` references) | | `src/ffi/extra/dataclass.cc` | Removed `MakeInitInplace()`, its registration, `EnsureTypeAttrColumn` call | | `python/tvm_ffi/dataclasses/py_class.py` | Updated comment | | `tests/python/test_dataclass_init.py` | Removed `TestInitInplace` class (5 tests) | | `tests/python/test_dataclass_py_class.py` | Added 8 new tests in `TestPyClassNoLeak` | ## Test plan - [x] All 27 pre-commit linters pass - [x] All 2103 Python tests pass (2095 existing + 8 new) - [x] All 349 C++ tests pass - [x] Memory leak validation: 20k construct+deepcopy cycles with no leak
1 parent 2f1a16a commit 02e9928

8 files changed

Lines changed: 185 additions & 141 deletions

File tree

include/tvm/ffi/reflection/accessor.h

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -340,19 +340,6 @@ inline constexpr const char* kNew = "__ffi_new__";
340340
* Keyword arguments are packed as ``[KWARGS, key0, val0, key1, val1, ...]``.
341341
*/
342342
inline constexpr const char* kInit = "__ffi_init__";
343-
/*!
344-
* \brief In-place init on a pre-allocated object (no allocation).
345-
*
346-
* Used by ``@py_class`` auto-generated ``__init__``: ``__new__`` has already
347-
* allocated the object via ``kNew``, so this function only sets fields.
348-
* The first argument is ``self`` (the pre-allocated object).
349-
*
350-
* Signature: ``(self: TSelf, *args, **kwargs) -> void``, where ``TSelf`` is a subclass of
351-
* ObjectRef.
352-
*
353-
* Keyword arguments are packed as ``[KWARGS, key0, val0, key1, val1, ...]``.
354-
*/
355-
inline constexpr const char* kInitInplace = "__ffi_init_inplace__";
356343
/*!
357344
* \brief Convert ``AnyView`` to a specific reflected ``TSelf`` type.
358345
*

include/tvm/ffi/reflection/registry.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,7 @@ class ObjectDef : public ReflectionDefBase {
726726
TVM_FFI_CHECK_SAFE_CALL(TVMFFITypeRegisterAttr(type_index_, &attr, &fn_any));
727727
}
728728
// Step 2. Register `__ffi_new__` <== info->metadata->creator
729-
// Also, `__ffi_init__` and `__ffi_init_inplace__` if no explicit init is defined.
729+
// Also, `__ffi_init__` if no explicit init is defined.
730730
if (info->metadata != nullptr && info->metadata->creator != nullptr) {
731731
Function fn = Function::FromTyped(
732732
[creator = info->metadata->creator]() -> ObjectRef {
@@ -739,7 +739,7 @@ class ObjectDef : public ReflectionDefBase {
739739
TVMFFIByteArray attr = AsByteArray(type_attr::kNew);
740740
TVMFFIAny fn_any = AnyView(fn).CopyToTVMFFIAny();
741741
TVM_FFI_CHECK_SAFE_CALL(TVMFFITypeRegisterAttr(type_index_, &attr, &fn_any));
742-
// Step 3. Register `__ffi_init__` and `__ffi_init_inplace__` if no explicit init is defined.
742+
// Step 3. Register `__ffi_init__` if no explicit init is defined.
743743
// Use Function::GetGlobal to look up the registration function, which lives in
744744
// dataclass.cc and may not be loaded yet for early (builtin) types.
745745
if (!has_explicit_init_) {

python/tvm_ffi/_dunder.py

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -31,42 +31,49 @@
3131
def _make_init(
3232
type_cls: type,
3333
type_info: TypeInfo,
34-
inplace: bool,
3534
ffi_init: Function,
35+
py_class_mode: bool = False,
3636
) -> Callable[..., None]:
37-
"""Build ``__init__`` that delegates to a C++ init function.
37+
"""Build ``__init__`` that delegates to ``__ffi_init__``.
38+
39+
Both ``@c_class`` and ``@py_class`` use the same constructor-call path
40+
(``self.__init_handle_by_constructor__(ffi_init, *args)``). The only
41+
difference is how ``super().__init__()`` from a subclass is handled:
42+
43+
* **c_class** — raises ``TypeError`` (subclass must define its own init).
44+
* **py_class** — silently skips when the C++ handle is already set, so
45+
``super().__init__()`` is a harmless no-op.
3846
3947
Parameters
4048
----------
4149
type_cls
42-
The class to build an __init__ for. Used for signature and error messages.
50+
The class to build an __init__ for.
4351
type_info
44-
The TypeInfo for *type_cls*, used to build the signature and for type checks.
45-
inplace : bool
46-
If True (py_class), *ffi_init* is ``__ffi_init_inplace__`` — called as
47-
``ffi_init(self, *args)`` on a pre-allocated object.
48-
If False (c_class), *ffi_init* is ``__ffi_init__`` — called via
49-
``self.__init_handle_by_constructor__(ffi_init, *args)``.
52+
The TypeInfo for *type_cls*.
5053
ffi_init
51-
The C++ initialiser resolved from TypeAttrColumn at install time.
54+
The C++ ``__ffi_init__`` resolved at install time.
55+
py_class_mode
56+
If True, use a ``chandle`` guard instead of a ``TypeError`` guard.
5257
5358
"""
5459
sig = _make_init_signature(type_info)
5560
kwargs_obj = core.KWARGS
5661
missing = core.MISSING
5762
has_post_init = hasattr(type_cls, "__post_init__")
5863

59-
if inplace:
64+
if py_class_mode:
6065

6166
def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
62-
ffi_args: list[Any] = [self, *args]
67+
if self.__chandle__() != 0:
68+
return
69+
ffi_args: list[Any] = list(args)
6370
if kwargs:
6471
ffi_args.append(kwargs_obj)
6572
for key, val in kwargs.items():
6673
if val is not missing:
6774
ffi_args.append(key)
6875
ffi_args.append(val)
69-
ffi_init(*ffi_args)
76+
self.__init_handle_by_constructor__(ffi_init, *ffi_args)
7077
if has_post_init:
7178
self.__post_init__()
7279

@@ -244,16 +251,15 @@ def _install_dataclass_dunders( # noqa: PLR0912, PLR0915
244251
unsafe_hash
245252
If True, install ``__hash__`` using ``RecursiveHash``.
246253
py_class_mode
247-
If True, use C++ ``__ffi_init_inplace__`` for ``__init__``
248-
(object already allocated by ``__new__``).
254+
If True, use a ``chandle`` guard for ``__init__`` so that
255+
``super().__init__()`` is a no-op, and wrap user-defined
256+
``__init__`` to allocate via ``__ffi_init__`` before user code.
249257
250258
"""
251259
from . import _ffi_api # noqa: PLC0415
252260

253261
type_info: TypeInfo = cls.__tvm_ffi_type_info__ # type: ignore[assignment]
254262
type_index: int = type_info.type_index
255-
ffi_new: Function | None = core._lookup_type_attr(type_index, "__ffi_new__")
256-
ffi_init_inplace: Function | None = core._lookup_type_attr(type_index, "__ffi_init_inplace__")
257263
# Look up __ffi_init__ from TypeMethod (preferred) or TypeAttrColumn (fallback).
258264
ffi_init: Function | None = None
259265
for method in type_info.methods:
@@ -262,45 +268,25 @@ def _install_dataclass_dunders( # noqa: PLR0912, PLR0915
262268
break
263269
if ffi_init is None:
264270
ffi_init = core._lookup_type_attr(type_index, "__ffi_init__")
271+
ffi_new: Function | None = core._lookup_type_attr(type_index, "__ffi_new__")
265272
ffi_shallow_copy: Function | None = core._lookup_type_attr(type_index, "__ffi_shallow_copy__")
266-
pyobject_new = core.Object.__new__
267-
268-
# __new__ (py_class only: allocate via __ffi_new__)
269-
if py_class_mode and "__new__" not in cls.__dict__:
270-
if ffi_new is not None:
271-
272-
def __new__(klass: type, *args: Any, **kwargs: Any) -> Any:
273-
obj = pyobject_new(klass)
274-
obj.__init_handle_by_constructor__(ffi_new)
275-
return obj
276-
277-
cls.__new__ = __new__ # type: ignore[attr-defined]
278273

279274
# __init__
280275
# ┌─────────────────────┬──────────────────────┬──────────────────────┐
281276
# │ │ user __init__ │ no user __init__ │
282277
# ├─────────────────────┼──────────────────────┼──────────────────────┤
283278
# │ c_class, init=True │ keep as-is │ _make_init │
284279
# │ c_class, init=False │ keep as-is │ TypeError guard │
285-
# │ py_class, init=True │ keep as-is │ _make_init(inplace)
286-
# │ py_class, init=False│ keep as-is │ TypeError guard │
280+
# │ py_class, init=True │ wrap chandle guard │ _make_init(py_class)
281+
# │ py_class, init=False│ wrap chandle guard │ TypeError guard │
287282
# └─────────────────────┴──────────────────────┴──────────────────────┘
288283
if "__init__" not in cls.__dict__:
289-
if init and py_class_mode and ffi_init_inplace is not None:
290-
# py_class init=True: delegate to C++ __ffi_init_inplace__
291-
cls.__init__ = _make_init( # type: ignore[attr-defined]
292-
cls,
293-
type_info,
294-
ffi_init=ffi_init_inplace,
295-
inplace=True,
296-
)
297-
elif init and not py_class_mode and ffi_init is not None:
298-
# c_class init=True: delegate to __ffi_init__ via TypeAttrColumn
284+
if init and ffi_init is not None:
299285
cls.__init__ = _make_init( # type: ignore[attr-defined]
300286
cls,
301287
type_info,
302288
ffi_init=ffi_init,
303-
inplace=False,
289+
py_class_mode=py_class_mode,
304290
)
305291
elif not init:
306292
# init=False, no user __init__: TypeError guard
@@ -315,6 +301,24 @@ def __init___(self: Any, *args: Any, **kwargs: Any) -> None:
315301
__init___.__qualname__ = f"{cls.__qualname__}.__init__"
316302
__init___.__module__ = cls.__module__
317303
cls.__init__ = __init___ # type: ignore[attr-defined]
304+
elif py_class_mode and ffi_new is not None:
305+
# User-defined __init__: wrap with chandle guard so the C++ object
306+
# is allocated (via __ffi_new__) before the user's code runs.
307+
# We use __ffi_new__ (zero-arg allocator) rather than __ffi_init__
308+
# because the user's __init__ signature may not match the field
309+
# layout. super().__init__() from within the user's code becomes
310+
# a no-op because chandle is already set.
311+
import functools # noqa: PLC0415
312+
313+
user_init = cls.__dict__["__init__"]
314+
315+
@functools.wraps(user_init)
316+
def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
317+
if self.__chandle__() == 0:
318+
self.__init_handle_by_constructor__(ffi_new)
319+
user_init(self, *args, **kwargs)
320+
321+
cls.__init__ = __init__ # type: ignore[attr-defined]
318322

319323
# __repr__
320324
if repr and "__repr__" not in cls.__dict__:

python/tvm_ffi/dataclasses/py_class.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ def _install_deferred_init(
422422
#: TypeAttrColumn entries; all other names are registered as TypeMethod.
423423
#:
424424
#: System-managed names (``__ffi_new__``, ``__ffi_init__``,
425-
#: ``__ffi_init_inplace__``, ``__ffi_shallow_copy__``) are intentionally
425+
#: ``__ffi_shallow_copy__``) are intentionally
426426
#: absent because the C++ runtime generates them.
427427
_FFI_RECOGNIZED_METHODS: frozenset[str] = _FFI_TYPE_ATTR_NAMES
428428

python/tvm_ffi/registry.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,6 @@ def _install_init(cls: type, type_info: TypeInfo) -> None:
381381
cls,
382382
type_info,
383383
ffi_init=ffi_init,
384-
inplace=False,
385384
)
386385
elif issubclass(cls, core.Object):
387386
type_name = cls.__name__

src/ffi/extra/dataclass.cc

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1827,7 +1827,7 @@ Function MakeFieldSetter(int32_t field_type_index, int64_t type_converter_int,
18271827
}
18281828

18291829
// ============================================================================
1830-
// Shared helpers for MakeInit / MakeInitInplace
1830+
// Shared helpers for MakeInit
18311831
// ============================================================================
18321832

18331833
/*!
@@ -1992,39 +1992,22 @@ Function MakeInit(int32_t type_index) {
19921992
});
19931993
}
19941994

1995-
Function MakeInitInplace(int32_t type_index) {
1996-
auto info = BuildAutoInitInfo(type_index);
1997-
return Function::FromPacked([info](PackedArgs args, Any* rv) {
1998-
TVM_FFI_ICHECK(args.size() >= 1)
1999-
<< "__ffi_init_inplace__ requires at least one argument (self)";
2000-
ObjectRef self = args[0].cast<ObjectRef>();
2001-
Object* obj = const_cast<Object*>(self.get());
2002-
const auto raw_args = reinterpret_cast<const TVMFFIAny*>(args.data());
2003-
BindFieldArgs(obj, *info, raw_args + 1, args.size() - 1);
2004-
});
2005-
}
2006-
20071995
void RegisterFFIInit(int32_t type_index) {
20081996
namespace refl = ::tvm::ffi::reflection;
20091997
Function auto_init_fn = MakeInit(type_index);
20101998
TVMFFIByteArray attr_name = refl::AsByteArray(refl::type_attr::kInit);
20111999
TVMFFIAny attr_value = AnyView(auto_init_fn).CopyToTVMFFIAny();
20122000
TVM_FFI_CHECK_SAFE_CALL(TVMFFITypeRegisterAttr(type_index, &attr_name, &attr_value));
2013-
2014-
Function init_inplace_fn = MakeInitInplace(type_index);
2015-
TVMFFIByteArray ip_attr_name = refl::AsByteArray(refl::type_attr::kInitInplace);
2016-
TVMFFIAny ip_attr_value = AnyView(init_inplace_fn).CopyToTVMFFIAny();
2017-
TVM_FFI_CHECK_SAFE_CALL(TVMFFITypeRegisterAttr(type_index, &ip_attr_name, &ip_attr_value));
20182001
}
20192002

20202003
/*!
20212004
* \brief Combined registration for Python-defined types:
2022-
* ``__ffi_init__``, ``__ffi_init_inplace__``, ``__ffi_new__``, ``__ffi_shallow_copy__``
2005+
* ``__ffi_init__``, ``__ffi_new__``, ``__ffi_shallow_copy__``
20232006
*/
20242007
void PyClassRegisterTypeAttrColumns(int32_t type_index, int32_t total_size) {
20252008
namespace refl = ::tvm::ffi::reflection;
20262009
const TVMFFITypeInfo* type_info = TVMFFIGetTypeInfo(type_index);
2027-
// Step 1. Register `__ffi_init__` and `__ffi_init_inplace__`
2010+
// Step 1. Register `__ffi_init__`
20282011
RegisterFFIInit(type_index);
20292012
// Step 2. Register `__ffi_new__`
20302013
Function new_fn = Function::FromTyped([type_index, total_size]() -> ObjectRef {
@@ -2148,7 +2131,6 @@ TVM_FFI_STATIC_INIT_BLOCK() {
21482131
refl::EnsureTypeAttrColumn(refl::type_attr::kCompare);
21492132
refl::EnsureTypeAttrColumn(refl::type_attr::kNew);
21502133
refl::EnsureTypeAttrColumn(refl::type_attr::kInit);
2151-
refl::EnsureTypeAttrColumn(refl::type_attr::kInitInplace);
21522134
refl::GlobalDef()
21532135
.def("ffi._RegisterFFIInit", RegisterFFIInit)
21542136
.def("ffi.MakeFieldSetter", MakeFieldSetter)

tests/python/test_dataclass_init.py

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -944,65 +944,6 @@ def test_low_level_kw_only_error_says_keyword_only(self) -> None:
944944
_ffi_init(obj, 1)
945945

946946

947-
# ###########################################################################
948-
# __ffi_init_inplace__ protocol tests
949-
# ###########################################################################
950-
951-
952-
def _ffi_init_inplace(obj: Any, *args: Any) -> None:
953-
"""Look up __ffi_init_inplace__ from TypeAttrColumn and call it directly."""
954-
type_index = type(obj).__tvm_ffi_type_info__.type_index
955-
fn = core._lookup_type_attr(type_index, "__ffi_init_inplace__")
956-
assert fn is not None, f"No __ffi_init_inplace__ for {type(obj)}"
957-
fn(obj, *args)
958-
959-
960-
class TestInitInplace:
961-
"""Test the __ffi_init_inplace__ TypeAttrColumn."""
962-
963-
def test_inplace_exists(self) -> None:
964-
type_index = getattr(_TestCxxAutoInit, "__tvm_ffi_type_info__").type_index
965-
fn = core._lookup_type_attr(type_index, "__ffi_init_inplace__")
966-
assert fn is not None
967-
968-
def test_inplace_positional(self) -> None:
969-
obj = _TestCxxAutoInit.__new__(_TestCxxAutoInit)
970-
obj.__init_handle_by_constructor__(
971-
core._lookup_type_attr(type(obj).__tvm_ffi_type_info__.type_index, "__ffi_new__")
972-
)
973-
_ffi_init_inplace(obj, 10, core.KWARGS, "c", 30)
974-
assert obj.a == 10
975-
assert obj.b == 42 # default
976-
assert obj.c == 30
977-
assert obj.d == 99 # default
978-
979-
def test_inplace_all_kwargs(self) -> None:
980-
obj = _TestCxxAutoInit.__new__(_TestCxxAutoInit)
981-
obj.__init_handle_by_constructor__(
982-
core._lookup_type_attr(type(obj).__tvm_ffi_type_info__.type_index, "__ffi_new__")
983-
)
984-
_ffi_init_inplace(obj, core.KWARGS, "a", 1, "c", 2, "d", 3)
985-
assert obj.a == 1
986-
assert obj.c == 2
987-
assert obj.d == 3
988-
989-
def test_inplace_kw_only_rejects_positional(self) -> None:
990-
obj = _TestCxxAutoInit.__new__(_TestCxxAutoInit)
991-
obj.__init_handle_by_constructor__(
992-
core._lookup_type_attr(type(obj).__tvm_ffi_type_info__.type_index, "__ffi_new__")
993-
)
994-
with pytest.raises(TypeError):
995-
_ffi_init_inplace(obj, 1, 2)
996-
997-
def test_inplace_missing_kw_only_error(self) -> None:
998-
obj = _TestCxxAutoInit.__new__(_TestCxxAutoInit)
999-
obj.__init_handle_by_constructor__(
1000-
core._lookup_type_attr(type(obj).__tvm_ffi_type_info__.type_index, "__ffi_new__")
1001-
)
1002-
with pytest.raises(TypeError, match="keyword-only"):
1003-
_ffi_init_inplace(obj, 1)
1004-
1005-
1006947
# ###########################################################################
1007948
# __ffi_init__ TypeMethod registration regression tests
1008949
#

0 commit comments

Comments
 (0)