Skip to content

Commit 43d0b2b

Browse files
committed
[FEAT][Python] Tie Python wrapper lifetime to underlying C++ FFI object
Bind one Python wrapper to one C++ FFI object ("chandle") for the object's lifetime, so identity is stable: `a.x is a.x`, `id(a.x)` stable across drop+refetch, and `f(x) is x` for FFI returns. Works on both the GIL and free-threaded (3.14t) builds. Allocation layout: - Two-layer custom-allocator hook in core libtvm_ffi (TVMFFIObjectAllocHeader + TVMFFICustomAllocator registry); frontends override the process-wide default. - The Cython module installs TVMFFIPyAllocate, prepending a 16-byte PyCustomAllocHeader that encodes the wrapper binding. Rust and the reflection dataclass path route through the same registry. Binding state machine (python/tvm_ffi/cython/tvm_ffi_python_object.h): - A tagged word (tagged_pyobj) holds the wrapper back-pointer plus low tag bits: Detached / Active / Inactive / InTransit. Every FFI return funnels through make_ret_object, reviving an Inactive cached allocation in place for a stable id(). Cache-vs-free handshake across tp_dealloc / tp_free / delete_space. - Free-threaded build: the word doubles as a per-word spin-lock (raw atomics in namespace pyobj_detail); make_ret holds the lock across its alloc (InTransit is the dealloc handshake's baton only). Active-hit revival uses PyUnstable_TryIncRef. GIL build is byte-identical (all FT machinery behind #ifdef Py_GIL_DISABLED). Also: drop cp38 from the cibuildwheel matrix (requires-python is >=3.9). Test plan: new tests/python/test_pyobject_tying.py (Active/Inactive/InTransit transitions, cache-on aliasing, _move(), pickle/threading/GC stress, finalizer exclusion, free-threaded carrier stress). Full suite passes on 3.13 (GIL) and 3.14t (free-threaded).
1 parent 458aaf7 commit 43d0b2b

17 files changed

Lines changed: 2400 additions & 146 deletions

File tree

CMakeLists.txt

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ set(_tvm_ffi_objs_sources
7070
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/dtype.cc"
7171
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/container.cc"
7272
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/init_once.cc"
73+
"${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/custom_allocator.cc"
7374
)
7475

7576
set(_tvm_ffi_extra_objs_sources
@@ -279,20 +280,11 @@ if (TVM_FFI_BUILD_PYTHON_MODULE)
279280
VERBATIM
280281
)
281282

282-
if (Python_VERSION VERSION_GREATER_EQUAL "3.12" AND NOT PYTHON_IS_FREE_THREADED)
283-
# >= Python3.12, use Use_SABI version
284-
python_add_library(tvm_ffi_cython MODULE "${_core_cpp}" USE_SABI 3.12)
285-
target_link_libraries(tvm_ffi_cython PRIVATE Python::SABIModule)
286-
set_target_properties(tvm_ffi_cython PROPERTIES OUTPUT_NAME "core")
287-
if (NOT WIN32)
288-
target_link_libraries(tvm_ffi_cython PRIVATE Python::Module)
289-
set_target_properties(tvm_ffi_cython PROPERTIES SUFFIX ".abi3.so")
290-
endif ()
291-
else ()
292-
# before Python3.12, use WITH_SOABI version
293-
python_add_library(tvm_ffi_cython MODULE "${_core_cpp}" WITH_SOABI)
294-
set_target_properties(tvm_ffi_cython PROPERTIES OUTPUT_NAME "core")
295-
endif ()
283+
# The PyObject-tying impl in tvm_ffi_python_object.h uses full Python C API (Py_IncRef,
284+
# PyObject_GC_Del, atomic header reads), so we build against the per-version ABI rather than the
285+
# limited (abi3) ABI.
286+
python_add_library(tvm_ffi_cython MODULE "${_core_cpp}" WITH_SOABI)
287+
set_target_properties(tvm_ffi_cython PROPERTIES OUTPUT_NAME "core")
296288
target_include_directories(
297289
tvm_ffi_cython PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython
298290
)
@@ -332,6 +324,9 @@ if (TVM_FFI_BUILD_PYTHON_MODULE)
332324
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/tvm_ffi_python_helpers.h
333325
DESTINATION include/
334326
)
327+
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython/tvm_ffi_python_object.h
328+
DESTINATION include/
329+
)
335330
endif ()
336331

337332
# ######### Install the related for normal cmake library ##########

include/tvm/ffi/c_api.h

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,66 @@ TVM_FFI_DLL int TVMFFIObjectDecRef(TVMFFIObjectHandle obj);
582582
TVM_FFI_DLL int TVMFFIObjectCreateOpaque(void* handle, int32_t type_index,
583583
void (*deleter)(void* handle), TVMFFIObjectHandle* out);
584584

585+
//-----------------------------------------------------------------------
586+
// Section: ObjectAllocHeader and CustomAllocator
587+
//-----------------------------------------------------------------------
588+
/*!
589+
* \brief Mandatory header placed immediately before each TVMFFIObject body.
590+
*
591+
* This header may be used by TVMFFIObject::deleter to reclaim space when a
592+
* custom allocator is present. It can also be set to NULL if
593+
* TVMFFIObject::deleter directly calls system free. This section must be
594+
* available for each Object so a frontend can rely on this field to confirm
595+
* if the object came from a certain allocator.
596+
*/
597+
typedef struct {
598+
/*!
599+
* \brief Free the allocation.
600+
* \param ptr The pointer to the space of the object.
601+
* \note ``ptr`` points to the space of TVMFFIObject and does not include
602+
* the TVMFFIObjectAllocHeader.
603+
*/
604+
void (*delete_space)(void* ptr);
605+
} TVMFFIObjectAllocHeader;
606+
607+
/*!
608+
* \brief Custom allocator entry registered with TVMFFISetCustomAllocator.
609+
*/
610+
typedef struct {
611+
/*!
612+
* \brief Allocate the space for an Object body.
613+
* \param size The size requested for the object body.
614+
* \param alignment The alignment requirement for the object body.
615+
* \param type_index Type index of the object.
616+
* \param context The ``context`` field of the registered allocator.
617+
* \return Pointer to the space of the object, or NULL on failure (with
618+
* the error reported via ``TVMFFIErrorSetRaised``).
619+
* \note The returned pointer must be preceded by a
620+
* ``TVMFFIObjectAllocHeader`` whose ``delete_space`` releases the
621+
* full underlying allocation when invoked.
622+
*/
623+
void* (*allocate)(size_t size, size_t alignment, int32_t type_index, void* context);
624+
/*! \brief Allocator context passed unmodified to ``allocate``. */
625+
void* context;
626+
} TVMFFICustomAllocator;
627+
628+
/*!
629+
* \brief Get the process-wide custom allocator.
630+
* \return The currently registered allocator (never NULL).
631+
* \note ``TVMFFIGetCustomAllocator`` always returns a valid allocator and
632+
* can be overridden by ``TVMFFISetCustomAllocator``.
633+
*/
634+
TVM_FFI_DLL TVMFFICustomAllocator* TVMFFIGetCustomAllocator(void);
635+
636+
/*!
637+
* \brief Register the process-wide custom allocator.
638+
* \param allocator Pointer to a TVMFFICustomAllocator, or NULL to restore
639+
* the builtin default.
640+
* \return 0 on success, nonzero on failure.
641+
* \note ``allocator`` must be alive throughout the lifetime of the process.
642+
*/
643+
TVM_FFI_DLL int TVMFFISetCustomAllocator(TVMFFICustomAllocator* allocator);
644+
585645
/*!
586646
* \brief Convert type key to type index.
587647
* \param type_key The key of the type.

include/tvm/ffi/memory.h

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
#include <cstddef>
2929
#include <cstdlib>
30+
#include <new>
3031
#include <type_traits>
3132
#include <utility>
3233

@@ -49,33 +50,30 @@ namespace details {
4950
/*!
5051
* \brief Allocate aligned memory.
5152
* \param size The size.
52-
* \tparam align The alignment, must be a power of 2.
53+
* \param align The alignment, must be a power of 2.
5354
* \return The pointer to the allocated memory.
5455
*/
55-
template <size_t align>
56-
TVM_FFI_INLINE void* AlignedAlloc(size_t size) {
57-
static_assert(align != 0 && (align & (align - 1)) == 0, "align must be a power of 2");
56+
TVM_FFI_INLINE void* AlignedAlloc(size_t size, size_t align) {
5857
#ifdef _MSC_VER
5958
// MSVC have to use _aligned_malloc
6059
if (void* ptr = _aligned_malloc(size, align)) {
6160
return ptr;
6261
}
6362
throw std::bad_alloc();
6463
#else
65-
if constexpr (align <= alignof(std::max_align_t)) {
64+
if (align <= alignof(std::max_align_t)) {
6665
// malloc guarantees alignment of std::max_align_t
6766
if (void* ptr = std::malloc(size)) {
6867
return ptr;
6968
}
7069
throw std::bad_alloc();
71-
} else {
72-
void* ptr;
73-
// for other alignments, use posix_memalign
74-
if (posix_memalign(&ptr, align, size) != 0) {
75-
throw std::bad_alloc();
76-
}
77-
return ptr;
7870
}
71+
void* ptr;
72+
// for other alignments, use posix_memalign
73+
if (posix_memalign(&ptr, align, size) != 0) {
74+
throw std::bad_alloc();
75+
}
76+
return ptr;
7977
#endif
8078
}
8179

@@ -150,7 +148,12 @@ class ObjAllocatorBase {
150148
// Simple allocator that uses new/delete.
151149
class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
152150
private:
153-
/*! \brief Guard a simple-allocator allocation until ownership is transferred to an object. */
151+
/*! \brief Guard a custom-allocator allocation until ownership is transferred to an object.
152+
* If placement construction throws, free the raw block through its allocator's
153+
* ``delete_space`` (the same path the live deleter uses). ``data`` is a body pointer
154+
* offset past the ``TVMFFIObjectAllocHeader``, so a plain ``AlignedFree`` would be wrong;
155+
* the header (with ``delete_space`` wired) is set up by ``allocate`` before construction,
156+
* so this is valid even though the body was never constructed. */
154157
class AllocGuard {
155158
public:
156159
explicit AllocGuard(void* data) noexcept : data_(data) {}
@@ -159,7 +162,7 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
159162

160163
~AllocGuard() noexcept {
161164
if (data_ != nullptr) {
162-
AlignedFree(data_);
165+
ObjectUnsafe::GetObjectAllocHeaderFromPtr(data_)->delete_space(data_);
163166
}
164167
}
165168

@@ -189,7 +192,11 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
189192
// class with non-virtual destructor.
190193
// We are fine here as we captured the right deleter during construction.
191194
// This is also the right way to get storage type for an object pool.
192-
void* data = AlignedAlloc<alignof(T)>(sizeof(T));
195+
static_assert(alignof(T) <= alignof(::std::max_align_t),
196+
"Object types with alignment > max_align_t are not supported "
197+
"by the custom allocator hook");
198+
TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
199+
void* data = alloc->allocate(sizeof(T), alignof(T), T::RuntimeTypeIndex(), alloc->context);
193200
AllocGuard alloc_guard(data);
194201
new (data) T(std::forward<Args>(args)...);
195202
alloc_guard.Release();
@@ -210,7 +217,8 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
210217
tptr->T::~T();
211218
}
212219
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
213-
AlignedFree(static_cast<void*>(tptr));
220+
ObjectUnsafe::GetObjectAllocHeaderFromPtr(static_cast<void*>(tptr))
221+
->delete_space(static_cast<void*>(tptr));
214222
}
215223
}
216224
};
@@ -238,12 +246,17 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
238246
static_assert(
239247
alignof(ArrayType) % alignof(ElemType) == 0 && sizeof(ArrayType) % alignof(ElemType) == 0,
240248
"element alignment constraint");
249+
static_assert(alignof(ArrayType) <= alignof(::std::max_align_t),
250+
"Object types with alignment > max_align_t are not supported "
251+
"by the custom allocator hook");
241252
size_t size = sizeof(ArrayType) + sizeof(ElemType) * num_elems;
242253
// round up to the nearest multiple of align
243254
constexpr size_t align = alignof(ArrayType);
244255
// C++ standard always guarantees that alignof operator returns a power of 2
245256
size_t aligned_size = (size + (align - 1)) & ~(align - 1);
246-
void* data = AlignedAlloc<align>(aligned_size);
257+
TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
258+
void* data =
259+
alloc->allocate(aligned_size, align, ArrayType::RuntimeTypeIndex(), alloc->context);
247260
AllocGuard alloc_guard(data);
248261
new (data) ArrayType(std::forward<Args>(args)...);
249262
alloc_guard.Release();
@@ -264,7 +277,8 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
264277
tptr->ArrayType::~ArrayType();
265278
}
266279
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
267-
AlignedFree(static_cast<void*>(tptr));
280+
ObjectUnsafe::GetObjectAllocHeaderFromPtr(static_cast<void*>(tptr))
281+
->delete_space(static_cast<void*>(tptr));
268282
}
269283
}
270284
};

include/tvm/ffi/object.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,16 @@ struct ObjectUnsafe {
11511151
return const_cast<TVMFFIObject*>(&(src->header_));
11521152
}
11531153

1154+
/*!
1155+
* \brief Recover the TVMFFIObjectAllocHeader for a TVMFFIObject pointer.
1156+
* \param ptr The pointer to the space of the object.
1157+
* \return The header set by the allocator that produced ``ptr``.
1158+
*/
1159+
TVM_FFI_INLINE static TVMFFIObjectAllocHeader* GetObjectAllocHeaderFromPtr(void* ptr) {
1160+
return reinterpret_cast<TVMFFIObjectAllocHeader*>(static_cast<char*>(ptr) -
1161+
sizeof(TVMFFIObjectAllocHeader));
1162+
}
1163+
11541164
// Suppress -Winvalid-offsetof: we intentionally use offsetof on non-standard-layout types
11551165
// to avoid undefined behavior from null pointer arithmetic that sanitizers flag.
11561166
#if defined(__clang__) || defined(__GNUC__)

pyproject.toml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ dev = [
5959
"clang-tidy",
6060
"ipdb",
6161
"ipython",
62-
"cython>=3.0",
62+
"cython>=3.2.8",
6363
"cmake",
6464
"scikit-build-core",
6565
"tomli",
@@ -100,7 +100,7 @@ tvm-ffi-config = "tvm_ffi.config:__main__"
100100
tvm-ffi-stubgen = "tvm_ffi.stub.cli:__main__"
101101

102102
[build-system]
103-
requires = ["scikit-build-core>=0.10.0", "cython>=3.0", "setuptools-scm"]
103+
requires = ["scikit-build-core>=0.10.0", "cython>=3.2.8", "setuptools-scm"]
104104
build-backend = "scikit_build_core.build"
105105

106106
[tool.scikit-build]
@@ -232,14 +232,18 @@ docstring-code-line-length = 80
232232
[tool.cibuildwheel]
233233
build-verbosity = 1
234234

235-
# only build up to cp312, cp312
236-
# will be abi3 and can be used in future versions
237-
# ship 314t threaded nogil version
238-
build = ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp314t-*"]
235+
# Per-Python-version wheels (no abi3 / limited API).
236+
build = [
237+
"cp39-*",
238+
"cp310-*",
239+
"cp311-*",
240+
"cp312-*",
241+
"cp313-*",
242+
"cp314t-*",
243+
]
239244
skip = ["*musllinux*"]
240245
# we only need to test on cp312
241246
test-skip = ["cp39-*", "cp310-*", "cp311-*"]
242-
# focus on testing abi3 wheel
243247
build-frontend = "build[uv]"
244248
test-command = "pytest {package}/tests/python -vvs"
245249
test-groups = ["test"]
@@ -288,6 +292,9 @@ allowed-unresolved-imports = [
288292

289293
[tool.uv]
290294
exclude-newer = "14 days"
295+
# TODO(2026-07-14): drop exclude-newer-package once the rolling 14-day cutoff reaches
296+
# Cython 3.2.8 (uploaded 2026-06-30); until then, override so cython>=3.2.8 resolves.
297+
exclude-newer-package = { cython = "2026-07-01" }
291298

292299
[tool.uv.dependency-groups]
293300
docs = { requires-python = ">=3.13" }

python/tvm_ffi/cython/base.pxi

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,18 @@ def _env_get_current_stream(int device_type, int device_id):
360360
return <uint64_t>current_stream
361361

362362

363+
# PyObject-tying state machine (binds one Python wrapper to one C++ chandle). The C++ impl lives in
364+
# tvm_ffi_python_object.h; see its top-of-file banner for the design.
365+
cdef extern from "tvm_ffi_python_object.h":
366+
int TVMFFIPyRegisterDefaultAllocator() noexcept
367+
void TVMFFIPyMarkPythonFinalizing() noexcept
368+
369+
void TVMFFIPyCompareAndRebindPyObject(void* chandle, PyObject* expect, PyObject* new_object) noexcept
370+
void TVMFFIPyTpDealloc(void** ptr_to_chandle, PyObject* wrapper) noexcept
371+
void TVMFFIPyInstallTypeSlots(PyObject* type_obj) noexcept
372+
object TVMFFIPyMakeRetObject(void* chandle, PyObject* cls_type)
373+
374+
363375
cdef extern from "tvm_ffi_python_helpers.h":
364376
# no need to expose fields of the call context setter data structure
365377
ctypedef struct TVMFFIPyCallContext:
@@ -542,5 +554,11 @@ cdef _init_env_api():
542554

543555
_init_env_api()
544556

557+
558+
CHECK_CALL(TVMFFIPyRegisterDefaultAllocator())
559+
560+
import atexit as _tvm_ffi_atexit
561+
_tvm_ffi_atexit.register(TVMFFIPyMarkPythonFinalizing)
562+
545563
# ensure testing is linked and we can run testcases
546564
TVMFFITestingDummyTarget()

python/tvm_ffi/cython/function.pxi

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -545,9 +545,13 @@ cdef int TVMFFIPyArgSetterObjectRValueRef_(
545545
PyObject* py_arg, TVMFFIAny* out
546546
) except -1:
547547
"""Setter for ObjectRValueRef"""
548-
cdef object arg = <object>py_arg
548+
cdef CObject src = (<object>py_arg).obj
549+
# Eager-detach the canonical binding before passing the chandle by rvalue ref: the callee
550+
# either moves it (src.chandle -> NULL) or leaves it, and either way ``src`` must no longer be
551+
# the canonical wrapper. Recycling in both cases is handled in TVMFFIPyTpDealloc on src death.
552+
TVMFFIPyCompareAndRebindPyObject(src.chandle, <PyObject*>src, NULL)
549553
out.type_index = kTVMFFIObjectRValueRef
550-
out.v_ptr = &((<CObject>(arg.obj)).chandle)
554+
out.v_ptr = &(src.chandle)
551555
return 0
552556

553557

@@ -1089,6 +1093,7 @@ def _register_global_func(name: str, pyfunc: Callable[..., Any] | Function, over
10891093

10901094

10911095
def _get_global_func(name: str, allow_missing: bool):
1096+
# PyObject tying is not applicable here.
10921097
cdef TVMFFIObjectHandle chandle
10931098
cdef ByteArrayArg name_arg = ByteArrayArg(c_str(name))
10941099

0 commit comments

Comments
 (0)