Skip to content

Commit 1b751b2

Browse files
committed
[FEAT][Python] Tie Python wrapper lifetime to underlying C++ FFI object
Make a.x is a.x, id(a.x) stable across drop+refetch, and f(x) is x for FFI returns by attaching a 16-byte tagged-pointer header to every Object allocation. Implements Tianqi's PyObjectTying doc (2026-05-01) with adaptations forced by Py_LIMITED_API + Cython 3.x constraints. Design: - Two-layer custom-allocator hook in core libtvm_ffi.so: `TVMFFICustomAllocHeader { delete_space }`, `TVMFFICustomAllocator { allocate }`, plus `TVMFFIGetCustomAllocator` / `TVMFFISetCustomAllocator`. libtvm_ffi installs a builtin default at registry construction so every Object always carries the 8-byte base header. The Python Cython module overrides the global default at module load with `TVMFFIPyAllocate`, which prepends a 16-byte `PyCustomAllocHeader` encoding the Python wrapper binding. - Tagged-pointer state machine. `PyCustomAllocHeader` is a single `uintptr_t tagged_wrapper` field plus the base header (16 B total). Low bit of `tagged_wrapper` is the "live" tag: (a) tagged_wrapper == 0 : never wrapped (b) tagged_wrapper == (uintptr_t)w | 1 : live wrapper at w (c) tagged_wrapper == (uintptr_t)w : preserved-mem at w CPython PyObjects are >= 8-byte aligned (PyObject_HEAD = 16 B), so bit 0 is always free for the tag. Eliminates the 8-byte reserved pad an earlier draft needed. - Universal cache-on for `make_ret`. Every FFI return (function results, callback args, FieldGetter) funnels through `make_ret_object` which returns the canonical wrapper for a chandle when one exists. Combined with state-(c) revive on the cached path, this delivers `a.x is a.x`, stable `id(a.x)` across drop+refetch, and `f(x) is x` when a function returns the same chandle it received. The doc proposed a `tp_alloc` + TLS hand-off for revive; not viable under Py_LIMITED_API (`PyType_SetSlot` not in 3.12 SABI; Cython 3.x forces `CYTHON_USE_TYPE_SPECS=0` for limited-API targets). The inline `make_ret_object` revive branch is the SABI-compatible equivalent -- bypasses `tp_alloc` by returning the cached PyObject directly, strictly cheaper than the doc's design (no alloc+dealloc cycle on revive). - State (c) via Cython `def __del__` on `CObject`, mapping to `tp_finalize` (PEP 442). When other C++ holders keep the chandle alive past the wrapper's Python refcount hitting 0, clear the live bit (keep the pointer), DecRef the chandle, `Py_INCREF(self)` to resurrect. Wrapper memory survives at the same address until revive or chandle death. Shim for `PyObject_CallFinalizerFromDealloc` (added to SABI in 3.13) using `PyType_GetSlot` + `Py_SET_REFCNT`/`Py_REFCNT` on 3.12; paired with `-DCYTHON_USE_TP_FINALIZE=1`. - Shutdown guard. `g_tvm_ffi_python_alive` flag is set to 0 by an atexit handler registered at Cython module init; read by `TVMFFIPyDeleteSpace` before `PyGILState_Ensure` to avoid GIL acquire after `Py_Finalize` has begun. State-(c) wrapper bytes on chandles still alive at that point are intentionally leaked -- process is exiting. - Frontend-allocation detection by `delete_space` pointer comparison (`TVMFFIPyIsCanonical`) avoids a flag bit on `TVMFFIObject`. Pre-Python-init chandles (statically-initialized global functions in libtvm_ffi.so) carry only the base header; the Python side detects this and skips the binding install. Trade-off on `_move()` semantics. Universal cache-on means callback args alias the caller's wrapper (one wrapper, one chandle ref) and function returns of the same chandle alias the caller's wrapper. `_move()` semantics are kept as an API: the rvalue setter on either caller or callback side eager-detaches the canonical-wrapper binding before the C++ AnyViewToOwnedAny transfer nulls the source chandle. `test_function.py::test_rvalue_ref` refactored for the new aliasing-aware use_count expectations. Refcount fix. The `make_ret_object` state-(b) cache hit path had a redundant `Py_INCREF(cached)` leaking +1 wrapper ref per cache hit (verified via `sys.getrefcount`). The `<object>(<PyObject*>...)` cast already INCREFs for the owned reference; `return cached` transfers it to the caller. Removed the explicit INCREF. `PyClassDeleter` in `extra/dataclass.cc` and the `__ffi_new__` / `__ffi_shallow_copy__` paths are routed through the same allocator registry so Python-defined types share the layout and lifetime semantics. `TVMFFIPyArgSetterObjectRValueRef_` clears the source's binding eagerly before the C++ side nulls its `chandle`; otherwise a downstream cache lookup on the (still-alive) chandle would briefly see a stale back-pointer. Tests: full Python suite passes (2332 passed, 19 skipped, 2 xfailed). New `tests/python/test_pyobject_tying.py` (32 tests) covers state b/c, cache-on aliasing, `_move()` under cache-on, pickle stress, threading stress, GC integration with state (c), multi-chandle isolation, and the weakref limitation. `test_function.py::test_rvalue_ref` refactored for cache-on aliasing semantics. TODO carried in `function.pxi::_get_global_func`: name-keyed dict cache for static-init Function id-stability. Most registry-resident Functions are allocated at C++ static init (before the Python allocator is registered) so their chandle has only the base header -- make_ret_object's cache can't reach them. Deferred to keep this change small.
1 parent d73a267 commit 1b751b2

12 files changed

Lines changed: 1409 additions & 98 deletions

File tree

CMakeLists.txt

Lines changed: 12 additions & 0 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
@@ -289,6 +290,17 @@ if (TVM_FFI_BUILD_PYTHON_MODULE)
289290
target_include_directories(
290291
tvm_ffi_cython PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython
291292
)
293+
# Force-enable Cython's tp_finalize wiring under USE_SABI. Cython 3.x
294+
# disables CYTHON_USE_TP_FINALIZE for limited-API targets because
295+
# PyObject_CallFinalizerFromDealloc was added to the SABI only in 3.13.
296+
# We need it for the PyObject-tying state-(b)->(c) transition (see
297+
# CObject.__del__ in python/tvm_ffi/cython/object.pxi). Safety relies on
298+
# the local PyObject_CallFinalizerFromDealloc shim in
299+
# python/tvm_ffi/cython/tvm_ffi_python_helpers.h; do NOT remove the
300+
# shim while this define is set. The override piggybacks on Cython 3.x's
301+
# exact spelling of the call, so a Cython upgrade may require revisiting
302+
# both.
303+
target_compile_definitions(tvm_ffi_cython PRIVATE CYTHON_USE_TP_FINALIZE=1)
292304
target_compile_features(tvm_ffi_cython PRIVATE cxx_std_17)
293305
target_link_libraries(tvm_ffi_cython PRIVATE tvm_ffi_header)
294306
target_link_libraries(tvm_ffi_cython PRIVATE tvm_ffi_shared)

include/tvm/ffi/c_api.h

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,83 @@ TVM_FFI_DLL int TVMFFIObjectDecRef(TVMFFIObjectHandle obj);
580580
TVM_FFI_DLL int TVMFFIObjectCreateOpaque(void* handle, int32_t type_index,
581581
void (*deleter)(void* handle), TVMFFIObjectHandle* out);
582582

583+
//-----------------------------------------------------------------------
584+
// Section: Custom allocator hook for Object allocations
585+
//
586+
// Frontends (Python, Rust, etc.) can register a custom allocator that the
587+
// core ObjAllocator consults inside make_object<T> / make_inplace_array_object.
588+
// When a custom allocator is registered, every allocation of a ref-counted
589+
// Object is given the allocator's chance to prepend bookkeeping bytes ahead
590+
// of the object. The allocator returns the address of the embedded T
591+
// (= Object = TVMFFIObject); immediately before that address it must place a
592+
// TVMFFICustomAllocHeader whose ``delete_space`` field is invoked from the
593+
// C++ Weak deleter to release the entire block.
594+
//
595+
// The core libtvm_ffi.so does not depend on Python (or any other frontend).
596+
// libtvm_ffi installs a builtin default allocator at registry init time, so
597+
// every Object allocation always carries at least the base
598+
// TVMFFICustomAllocHeader; frontends that need richer per-allocation
599+
// bookkeeping (e.g. Python's ``py_object`` back-pointer) override the global
600+
// default with their own allocator.
601+
//-----------------------------------------------------------------------
602+
/*!
603+
* \brief Header that sits immediately before the embedded TVMFFIObject. The
604+
* C++ deleter recovers this header by walking back a fixed offset
605+
* (``sizeof(TVMFFICustomAllocHeader)``) from the TVMFFIObject pointer
606+
* and invokes ``delete_space``.
607+
*
608+
* Frontends are free to define a derived layout that places extra fields
609+
* before this base; the derived ``delete_space`` callback is responsible for
610+
* recovering its enclosing struct from the ``tptr`` argument. Frontends can
611+
* detect their own allocations by comparing this header's ``delete_space``
612+
* pointer against their own callback (no flag bit needed).
613+
*/
614+
typedef struct {
615+
/*!
616+
* \brief Free the allocation that contains ``tptr``.
617+
* \param tptr The pointer originally returned by
618+
* ``TVMFFICustomAllocator::allocate`` (i.e. the address of T,
619+
* not the start of the underlying malloc block).
620+
*/
621+
void (*delete_space)(void* tptr);
622+
} TVMFFICustomAllocHeader;
623+
624+
/*!
625+
* \brief Custom allocator entry registered with TVMFFISetCustomAllocator.
626+
*/
627+
typedef struct {
628+
/*!
629+
* \brief Allocate ``size`` bytes for an Object of ``type_index`` with the
630+
* requested ``alignment``. Implementations must place a
631+
* TVMFFICustomAllocHeader immediately before the returned pointer
632+
* (at the fixed base offset documented on TVMFFICustomAllocHeader)
633+
* with a non-NULL ``delete_space``.
634+
* \return Pointer to T's location (treated as TVMFFIObject* by the caller).
635+
*/
636+
void* (*allocate)(size_t size, size_t alignment, int32_t type_index);
637+
} TVMFFICustomAllocator;
638+
639+
/*!
640+
* \brief Get the process-wide custom allocator.
641+
*
642+
* libtvm_ffi installs a builtin default at registry init, so the result is
643+
* never NULL in practice. Frontends can override the global allocator via
644+
* TVMFFISetCustomAllocator.
645+
*
646+
* \return The currently registered allocator.
647+
*/
648+
TVM_FFI_DLL TVMFFICustomAllocator* TVMFFIGetCustomAllocator(void);
649+
650+
/*!
651+
* \brief Register the process-wide custom allocator. Pass ``allocator==NULL``
652+
* to restore the builtin default.
653+
*
654+
* \param allocator Pointer to a TVMFFICustomAllocator with process-stable
655+
* lifetime, or NULL to reset to the builtin default.
656+
* \return 0 on success, nonzero on failure.
657+
*/
658+
TVM_FFI_DLL int TVMFFISetCustomAllocator(TVMFFICustomAllocator* allocator);
659+
583660
/*!
584661
* \brief Convert type key to type index.
585662
* \param type_key The key of the type.

include/tvm/ffi/memory.h

Lines changed: 69 additions & 44 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

@@ -92,6 +93,37 @@ TVM_FFI_INLINE void AlignedFree(void* data) {
9293
#endif
9394
}
9495

96+
/*!
97+
* \brief Fixed offset between the base TVMFFICustomAllocHeader and the
98+
* embedded T (= Object = TVMFFIObject).
99+
*
100+
* The C++ deleter recovers the base header from a chandle by walking back
101+
* exactly this many bytes: ``tptr - kCustomAllocBaseOffset``. Frontends
102+
* with a derived layout add their extra fields ahead of the base.
103+
*
104+
* We use ``sizeof(TVMFFICustomAllocHeader)`` (one function pointer = 8B)
105+
* directly. Alignment of T is enforced separately by the frontend's
106+
* choice of total padding before T (must be a multiple of alignof(T));
107+
* see PyCustomAllocHeader for an example.
108+
*/
109+
constexpr size_t kCustomAllocBaseOffset = sizeof(TVMFFICustomAllocHeader);
110+
static_assert(alignof(::std::max_align_t) % alignof(TVMFFICustomAllocHeader) == 0,
111+
"base header alignment must divide max_align_t so it remains "
112+
"aligned when placed just before a max-aligned T");
113+
114+
/*!
115+
* \brief Recover the base TVMFFICustomAllocHeader from a TVMFFIObject pointer.
116+
*
117+
* Every Object allocation is routed through the custom-allocator registry
118+
* (libtvm_ffi installs a builtin default at registry init), so this is
119+
* always valid for chandles produced by ``make_object`` /
120+
* ``make_inplace_array_object``.
121+
*/
122+
TVM_FFI_INLINE TVMFFICustomAllocHeader* GetCustomAllocHeader(void* tptr) {
123+
return reinterpret_cast<TVMFFICustomAllocHeader*>(static_cast<char*>(tptr) -
124+
kCustomAllocBaseOffset);
125+
}
126+
95127
/*!
96128
* \brief Base class of object allocators that implements make.
97129
* Use curiously recurring template pattern.
@@ -111,7 +143,14 @@ class ObjAllocatorBase {
111143
ObjectPtr<T> make_object(Args&&... args) {
112144
using Handler = typename Derived::template Handler<T>;
113145
static_assert(std::is_base_of_v<Object, T>, "make can only be used to create Object");
114-
T* ptr = Handler::New(static_cast<Derived*>(this), std::forward<Args>(args)...);
146+
// Always go through the custom-allocator hook. libtvm_ffi installs a
147+
// builtin default allocator at registry init time, so the result is
148+
// never null and every Object always has a TVMFFICustomAllocHeader
149+
// sitting immediately before it. Frontends with richer per-allocation
150+
// bookkeeping (e.g. Python's PyCustomAllocHeader) override the global
151+
// allocator with their own.
152+
TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
153+
T* ptr = Handler::New(static_cast<Derived*>(this), alloc, std::forward<Args>(args)...);
115154
TVMFFIObject* ffi_ptr = details::ObjectUnsafe::GetHeader(ptr);
116155
ffi_ptr->combined_ref_count = kCombinedRefCountBothOne;
117156
ffi_ptr->type_index = T::RuntimeTypeIndex();
@@ -132,8 +171,9 @@ class ObjAllocatorBase {
132171
using Handler = typename Derived::template ArrayHandler<ArrayType, ElemType>;
133172
static_assert(std::is_base_of_v<Object, ArrayType>,
134173
"make_inplace_array can only be used to create Object");
135-
ArrayType* ptr =
136-
Handler::New(static_cast<Derived*>(this), num_elems, std::forward<Args>(args)...);
174+
TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
175+
ArrayType* ptr = Handler::New(static_cast<Derived*>(this), alloc, num_elems,
176+
std::forward<Args>(args)...);
137177
TVMFFIObject* ffi_ptr = details::ObjectUnsafe::GetHeader(ptr);
138178
ffi_ptr->combined_ref_count = kCombinedRefCountBothOne;
139179
ffi_ptr->type_index = ArrayType::RuntimeTypeIndex();
@@ -154,21 +194,21 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
154194
class Handler {
155195
public:
156196
template <typename... Args>
157-
static T* New(SimpleObjAllocator*, Args&&... args) {
158-
// NOTE: the first argument is not needed for SimpleObjAllocator
159-
// It is reserved for special allocators that needs to recycle
160-
// the object to itself (e.g. in the case of object pool).
197+
static T* New(SimpleObjAllocator*, TVMFFICustomAllocator* alloc, Args&&... args) {
198+
// NOTE: the first argument is not needed for SimpleObjAllocator.
199+
// It is reserved for special allocators that need to recycle the
200+
// object to itself (e.g. an object pool).
161201
//
162-
// In the case of an object pool, an allocator needs to create
163-
// a special chunk memory that hides reference to the allocator
164-
// and call allocator's release function in the deleter.
165-
166-
// NOTE2: Use inplace new to allocate
167-
// This is used to get rid of warning when deleting a virtual
168-
// class with non-virtual destructor.
169-
// We are fine here as we captured the right deleter during construction.
170-
// This is also the right way to get storage type for an object pool.
171-
void* data = AlignedAlloc<alignof(T)>(sizeof(T));
202+
// Use inplace new to construct T. This avoids the warning when
203+
// deleting a virtual class with non-virtual destructor; we captured
204+
// the right deleter during construction.
205+
static_assert(alignof(T) <= alignof(::std::max_align_t),
206+
"Object types with alignment > max_align_t are not supported "
207+
"by the custom allocator hook");
208+
// ``alloc`` is non-null because libtvm_ffi installs a builtin default
209+
// allocator at registry init. The matching ``delete_space`` is stored
210+
// in the prepended TVMFFICustomAllocHeader and invoked from Deleter_.
211+
void* data = alloc->allocate(sizeof(T), alignof(T), T::RuntimeTypeIndex());
172212
new (data) T(std::forward<Args>(args)...);
173213
return reinterpret_cast<T*>(data);
174214
}
@@ -180,14 +220,13 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
180220
T* tptr =
181221
details::ObjectUnsafe::RawObjectPtrFromUnowned<T>(static_cast<TVMFFIObject*>(objptr));
182222
if (flags & kTVMFFIObjectDeleterFlagBitMaskStrong) {
183-
// It is important to do tptr->T::~T(),
184-
// so that we explicitly call the specific destructor
185-
// instead of tptr->~T(), which could mean the intention
186-
// call a virtual destructor(which may not be available and is not required).
223+
// tptr->T::~T() (not tptr->~T()) so we always call the specific
224+
// destructor and never pick up a virtual destructor that may not
225+
// exist for this Object type.
187226
tptr->T::~T();
188227
}
189228
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
190-
AlignedFree(static_cast<void*>(tptr));
229+
GetCustomAllocHeader(static_cast<void*>(tptr))->delete_space(static_cast<void*>(tptr));
191230
}
192231
}
193232
};
@@ -197,30 +236,20 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
197236
class ArrayHandler {
198237
public:
199238
template <typename... Args>
200-
static ArrayType* New(SimpleObjAllocator*, size_t num_elems, Args&&... args) {
201-
// NOTE: the first argument is not needed for ArrayObjAllocator
202-
// It is reserved for special allocators that needs to recycle
203-
// the object to itself (e.g. in the case of object pool).
204-
//
205-
// In the case of an object pool, an allocator needs to create
206-
// a special chunk memory that hides reference to the allocator
207-
// and call allocator's release function in the deleter.
208-
// NOTE2: Use inplace new to allocate
209-
// This is used to get rid of warning when deleting a virtual
210-
// class with non-virtual destructor.
211-
// We are fine here as we captured the right deleter during construction.
212-
// This is also the right way to get storage type for an object pool.
213-
214-
// for now only support elements that aligns with array header.
239+
static ArrayType* New(SimpleObjAllocator*, TVMFFICustomAllocator* alloc, size_t num_elems,
240+
Args&&... args) {
241+
// For now only support elements that align with the array header.
215242
static_assert(
216243
alignof(ArrayType) % alignof(ElemType) == 0 && sizeof(ArrayType) % alignof(ElemType) == 0,
217244
"element alignment constraint");
245+
static_assert(alignof(ArrayType) <= alignof(::std::max_align_t),
246+
"Object types with alignment > max_align_t are not supported "
247+
"by the custom allocator hook");
218248
size_t size = sizeof(ArrayType) + sizeof(ElemType) * num_elems;
219249
// round up to the nearest multiple of align
220250
constexpr size_t align = alignof(ArrayType);
221-
// C++ standard always guarantees that alignof operator returns a power of 2
222251
size_t aligned_size = (size + (align - 1)) & ~(align - 1);
223-
void* data = AlignedAlloc<align>(aligned_size);
252+
void* data = alloc->allocate(aligned_size, align, ArrayType::RuntimeTypeIndex());
224253
new (data) ArrayType(std::forward<Args>(args)...);
225254
return reinterpret_cast<ArrayType*>(data);
226255
}
@@ -232,14 +261,10 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
232261
ArrayType* tptr = details::ObjectUnsafe::RawObjectPtrFromUnowned<ArrayType>(
233262
static_cast<TVMFFIObject*>(objptr));
234263
if (flags & kTVMFFIObjectDeleterFlagBitMaskStrong) {
235-
// It is important to do tptr->ArrayType::~ArrayType(),
236-
// so that we explicitly call the specific destructor
237-
// instead of tptr->~ArrayType(), which could mean the intention
238-
// call a virtual destructor(which may not be available and is not required).
239264
tptr->ArrayType::~ArrayType();
240265
}
241266
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
242-
AlignedFree(static_cast<void*>(tptr));
267+
GetCustomAllocHeader(static_cast<void*>(tptr))->delete_space(static_cast<void*>(tptr));
243268
}
244269
}
245270
};

python/tvm_ffi/cython/base.pxi

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717
import ctypes
18-
from libc.stdint cimport int32_t, int64_t, uint64_t, uint32_t, uint8_t, int16_t
18+
from libc.stdint cimport int32_t, int64_t, uint64_t, uint32_t, uint8_t, int16_t, uintptr_t
1919
from libc.string cimport memcpy
2020
from libcpp.vector cimport vector
2121
from cpython.bytes cimport PyBytes_AsStringAndSize, PyBytes_FromStringAndSize, PyBytes_AsString
2222
from cpython cimport Py_INCREF, Py_DECREF, Py_REFCNT
23+
from cpython.object cimport PyTypeObject
2324
from cpython cimport PyErr_CheckSignals, PyGILState_Ensure, PyGILState_Release, PyObject
25+
26+
cdef extern from "Python.h":
27+
void Py_IncRef(PyObject*) nogil
28+
void Py_DecRef(PyObject*) nogil
2429
from cpython cimport pycapsule, PyCapsule_Destructor
2530
from cpython cimport PyErr_SetNone
2631

@@ -162,6 +167,15 @@ cdef extern from "tvm/ffi/c_api.h":
162167
uint32_t __padding
163168
void (*deleter)(void* self, int flags)
164169

170+
ctypedef struct TVMFFICustomAllocHeader:
171+
void (*delete_space)(void* tptr)
172+
173+
ctypedef struct TVMFFICustomAllocator:
174+
void* (*allocate)(size_t size, size_t alignment, int32_t type_index)
175+
176+
TVMFFICustomAllocator* TVMFFIGetCustomAllocator() nogil
177+
int TVMFFISetCustomAllocator(TVMFFICustomAllocator* allocator) nogil
178+
165179
ctypedef struct TVMFFIAny:
166180
int32_t type_index
167181
int32_t zero_padding
@@ -361,6 +375,31 @@ def _env_get_current_stream(int device_type, int device_id):
361375

362376

363377
cdef extern from "tvm_ffi_python_helpers.h":
378+
# 16-byte header prepended to every Object allocated through the
379+
# registered Python custom allocator. The single ``tagged_wrapper``
380+
# field encodes the binding state (a/b/c): low bit set means a live
381+
# canonical wrapper (state b); low bit clear with non-zero high bits
382+
# means preserved wrapper memory (state c); zero means never wrapped
383+
# (state a). See tvm_ffi_python_helpers.h for the full lifecycle.
384+
ctypedef struct PyCustomAllocHeader:
385+
uintptr_t tagged_wrapper
386+
TVMFFICustomAllocHeader base
387+
388+
const uintptr_t kPyHeaderLiveTag
389+
390+
PyCustomAllocHeader* TVMFFIPyHeader(void* tptr) noexcept
391+
PyObject* TVMFFIPyHeaderWrapper(PyCustomAllocHeader* h) noexcept
392+
bint TVMFFIPyHeaderIsLive(PyCustomAllocHeader* h) noexcept
393+
int TVMFFIPyRegisterDefaultAllocator() noexcept
394+
bint TVMFFIPyIsCanonical(void* chandle) noexcept
395+
# Set to 0 by an atexit hook (registered in object.pxi). Read by
396+
# TVMFFIPyDeleteSpace before acquiring the GIL — once Python is
397+
# finalizing, state-(c) wrapper bytes are intentionally leaked
398+
# rather than reclaimed. State (c)'s "next access reads same
399+
# address" contract is moot after Py_Finalize starts (no Python
400+
# code runs again) and the GIL acquire is unsafe.
401+
void TVMFFIPyMarkPythonFinalizing() noexcept
402+
364403
# no need to expose fields of the call context setter data structure
365404
ctypedef struct TVMFFIPyCallContext:
366405
int device_type
@@ -542,5 +581,21 @@ cdef _init_env_api():
542581

543582
_init_env_api()
544583

584+
585+
# Register the Python custom allocator with core libtvm_ffi as the global
586+
# default. From this point on, every make_object<T> in the C++ side prepends
587+
# a PyCustomAllocHeader so we can tie a Python wrapper's lifetime to the
588+
# underlying C++ object. See tvm_ffi_python_helpers.h for the layout.
589+
CHECK_CALL(TVMFFIPyRegisterDefaultAllocator())
590+
591+
# atexit-driven shutdown guard: flips the C-side flag that
592+
# TVMFFIPyDeleteSpace consults before acquiring the GIL. After this fires,
593+
# state-(c) wrapper bytes on chandles whose deleter hasn't yet run are
594+
# intentionally leaked (process is exiting; the OS reclaims). Without
595+
# this, a state-(c) deleter firing during/after Py_Finalize would call
596+
# PyGILState_Ensure on a teardown interpreter — segfault.
597+
import atexit as _tvm_ffi_atexit
598+
_tvm_ffi_atexit.register(TVMFFIPyMarkPythonFinalizing)
599+
545600
# ensure testing is linked and we can run testcases
546601
TVMFFITestingDummyTarget()

0 commit comments

Comments
 (0)