Skip to content

Commit 38ca72d

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, on both the GIL and free-threaded (3.14t) builds, by binding one Python wrapper to one C++ FFI object ("chandle") for the object's lifetime. Implements the PyObjectTying design. ## Allocation layout A two-layer custom-allocator hook lives in core libtvm_ffi.so: `TVMFFIObjectAllocHeader { delete_space }`, `TVMFFICustomAllocator { allocate, context }`, plus `TVMFFIGetCustomAllocator` / `TVMFFISetCustomAllocator`. libtvm_ffi installs a builtin default at registry init, so every Object always carries the 8-byte base header and `TVMFFIGetCustomAllocator` never returns NULL. The Python Cython module overrides the global default at module load with `TVMFFIPyAllocate`, which prepends a 16-byte `PyCustomAllocHeader` encoding the wrapper binding. `make_object` / `make_inplace_array_object` / `PyClassDeleter` and the Rust `ObjectArc::new[_with_extra_items]` paths all funnel through the registry, so Python-defined types and Rust-allocated objects share the layout and lifetime semantics. ## Binding state machine State is concentrated in `tvm_ffi_python_helpers.h`. Each header word (`tagged_pyobj`) holds the wrapper back-pointer plus low tag bits, giving a four-state machine: Detached: no wrapper bound Active: wrapper bound and owns a +1 on the chandle Inactive: wrapper dead but its allocation is cached for in-place revival InTransit: a dealloc is mid-flight (handshake bit) Every FFI return funnels through `make_ret_object` (C++ entry `TVMFFIPyMakeRetObject`), which returns the canonical wrapper for a chandle when one exists, reviving an Inactive cached allocation in place so a re-fetched wrapper keeps a stable `id()` at the same address. The cache-vs-free handshake spans three slots -- a pre-bump `tp_dealloc` opens it, `tp_free` settles it, and the C++ weak deleter (`TVMFFIPyDeleteSpace`) reclaims the block -- coordinated so a chandle that outlives its wrapper keeps the cached bytes, and a genuinely dead chandle frees them exactly once. Frontend-allocation is detected by `delete_space` pointer comparison (`TVMFFIPyIsCanonical`), avoiding a flag bit on `TVMFFIObject`; chandles created before the Python allocator is registered (statically-initialized global functions in libtvm_ffi.so) carry only the base header and are skipped. ## Free-threaded build The same tying runs on Py_GIL_DISABLED. The `tagged_pyobj` word doubles as a per-word spin-lock (a Locked tag bit, `__atomic_*` CAS) that serializes every binding transition; the lock is held only across short, park-free word/header edits, while alloc/revival run lock-released. The wait/back-off (`TVMFFIPyLockYield`) detaches the thread state so a concurrent stop-the-world GC is never starved. The free-threaded revival hazard is Cython's generated `tp_dealloc`, which bumps the wrapper refcount before running `__dealloc__`: that bump makes `PyUnstable_TryIncRef` spuriously succeed on a wrapper being torn down, so a concurrent `make_ret` Active-hit could revive a corpse (borrowed-ref UAF). The fix replaces Cython's `tp_dealloc` on each cdef CObject-family carrier with one hand-built `TVMFFIPyTpDeallocSlot` that runs the binding transition (bracketed by `PyErr_Get/SetRaisedException`) before any bump, then -- stripped of the now-dead bump -- GC-untrack (guarded by GC-ness), a generic `__dict__` clear (guarded by a real `tp_dictoffset`), and `tp_free`. A plain carrier runs exactly `transition; tp_free`; Function fires both guards; both are faithful to Cython's originals minus the bump. The six carriers (CObject, CContainerBase, OpaquePyObject, Error, Tensor, Function) are a closed compile-time set, each wrapped once at init via `TVMFFIPyWrapDealloc`; every heap subtype is covered for free through `subtype_dealloc`'s base-walk to the nearest carrier. An import-time layout guard `Py_FatalError`s if a non-GC carrier ever gains a `__dict__`, turning silent owned-member drift into a loud failure. Active-hit revival uses `PyUnstable_TryIncRef` / `EnableTryIncRef` to close the borrowed-read UAF. The GIL build is byte-identical: all free-threaded machinery is behind `#ifdef Py_GIL_DISABLED`, and on the GIL build the post-bump `__dealloc__` hook runs the transition directly (safe under the GIL) while the slot installer is a no-op stub. ## Robustness A double-free in the builtin allocator is fixed along the way: allocate offset the body by `round_up(header, alignment)` but free subtracted a fixed `sizeof(header)`, symmetric only for alignment <= 8 -- a 16-aligned reflection dataclass (alignof(max_align_t)=16) was freed 8 bytes early. Both sides now use a fixed `alignof(max_align_t)` body offset. A shutdown guard (`TVMFFIPyMarkPythonFinalizing`, wired to atexit) flips an atomic flag read before any `PyGILState_Ensure` to avoid acquiring the GIL after Python finalization has begun; wrapper bytes on chandles still alive at exit are intentionally leaked. ## `_move()` semantics Under universal cache-on, callback args alias the caller's wrapper and FFI returns of the same chandle alias the caller's wrapper (one wrapper, one chandle ref). `_move()` is kept as an API: the rvalue setter on either side eager-detaches the canonical binding before the C++ `AnyViewToOwnedAny` transfer nulls the source chandle. ## Tests New `tests/python/test_pyobject_tying.py` covers Active/Inactive/InTransit transitions, cache-on aliasing, `_move()` under cache-on, pickle stress, threading stress, GC integration, multi-chandle isolation, the weakref limitation, free-threaded concurrent carrier-type stress (Function/Error/multi-level-heap), and OpaquePyObject roundtrip/leak. `test_function.py::test_rvalue_ref` is refactored for the new aliasing-aware use_count expectations. Verified: FT crash oracle clean, FT suite 2321 passed, GIL suite 2346 passed, tying tests 43/43 on both builds; Rust suite passes.
1 parent 807b17a commit 38ca72d

18 files changed

Lines changed: 2432 additions & 130 deletions

File tree

CMakeLists.txt

Lines changed: 6 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
@@ -274,20 +275,11 @@ if (TVM_FFI_BUILD_PYTHON_MODULE)
274275
VERBATIM
275276
)
276277

277-
if (Python_VERSION VERSION_GREATER_EQUAL "3.12" AND NOT PYTHON_IS_FREE_THREADED)
278-
# >= Python3.12, use Use_SABI version
279-
python_add_library(tvm_ffi_cython MODULE "${_core_cpp}" USE_SABI 3.12)
280-
target_link_libraries(tvm_ffi_cython PRIVATE Python::SABIModule)
281-
set_target_properties(tvm_ffi_cython PROPERTIES OUTPUT_NAME "core")
282-
if (NOT WIN32)
283-
target_link_libraries(tvm_ffi_cython PRIVATE Python::Module)
284-
set_target_properties(tvm_ffi_cython PROPERTIES SUFFIX ".abi3.so")
285-
endif ()
286-
else ()
287-
# before Python3.12, use WITH_SOABI version
288-
python_add_library(tvm_ffi_cython MODULE "${_core_cpp}" WITH_SOABI)
289-
set_target_properties(tvm_ffi_cython PROPERTIES OUTPUT_NAME "core")
290-
endif ()
278+
# The PyObject-tying impl in tvm_ffi_python_helpers.h uses full Python C API (Py_IncRef,
279+
# PyObject_GC_Del, atomic header reads), so we build against the per-version ABI rather than the
280+
# limited (abi3) ABI.
281+
python_add_library(tvm_ffi_cython MODULE "${_core_cpp}" WITH_SOABI)
282+
set_target_properties(tvm_ffi_cython PROPERTIES OUTPUT_NAME "core")
291283
target_include_directories(
292284
tvm_ffi_cython PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/python/tvm_ffi/cython
293285
)

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: 49 additions & 4 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

@@ -79,6 +80,39 @@ TVM_FFI_INLINE void* AlignedAlloc(size_t size) {
7980
#endif
8081
}
8182

83+
/*!
84+
* \brief Allocate aligned memory with a runtime-known alignment.
85+
*
86+
* Sibling of the templated ``AlignedAlloc<align>`` for callers that
87+
* receive ``align`` as a parameter (e.g. custom-allocator
88+
* implementations dispatching on ``TVMFFICustomAllocator::allocate``'s
89+
* runtime ``alignment`` argument).
90+
*
91+
* \param size The size.
92+
* \param align The alignment, must be a power of 2.
93+
* \return The pointer to the allocated memory.
94+
*/
95+
TVM_FFI_INLINE void* AlignedAllocRuntime(size_t size, size_t align) {
96+
#ifdef _MSC_VER
97+
if (void* ptr = _aligned_malloc(size, align)) {
98+
return ptr;
99+
}
100+
throw std::bad_alloc();
101+
#else
102+
if (align <= alignof(std::max_align_t)) {
103+
if (void* ptr = std::malloc(size)) {
104+
return ptr;
105+
}
106+
throw std::bad_alloc();
107+
}
108+
void* ptr;
109+
if (posix_memalign(&ptr, align, size) != 0) {
110+
throw std::bad_alloc();
111+
}
112+
return ptr;
113+
#endif
114+
}
115+
82116
/*!
83117
* \brief Free aligned memory.
84118
* \param data The pointer to the memory to free.
@@ -168,7 +202,11 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
168202
// class with non-virtual destructor.
169203
// We are fine here as we captured the right deleter during construction.
170204
// This is also the right way to get storage type for an object pool.
171-
void* data = AlignedAlloc<alignof(T)>(sizeof(T));
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+
TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
209+
void* data = alloc->allocate(sizeof(T), alignof(T), T::RuntimeTypeIndex(), alloc->context);
172210
new (data) T(std::forward<Args>(args)...);
173211
return reinterpret_cast<T*>(data);
174212
}
@@ -187,7 +225,8 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
187225
tptr->T::~T();
188226
}
189227
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
190-
AlignedFree(static_cast<void*>(tptr));
228+
ObjectUnsafe::GetObjectAllocHeaderFromPtr(static_cast<void*>(tptr))
229+
->delete_space(static_cast<void*>(tptr));
191230
}
192231
}
193232
};
@@ -215,12 +254,17 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
215254
static_assert(
216255
alignof(ArrayType) % alignof(ElemType) == 0 && sizeof(ArrayType) % alignof(ElemType) == 0,
217256
"element alignment constraint");
257+
static_assert(alignof(ArrayType) <= alignof(::std::max_align_t),
258+
"Object types with alignment > max_align_t are not supported "
259+
"by the custom allocator hook");
218260
size_t size = sizeof(ArrayType) + sizeof(ElemType) * num_elems;
219261
// round up to the nearest multiple of align
220262
constexpr size_t align = alignof(ArrayType);
221263
// C++ standard always guarantees that alignof operator returns a power of 2
222264
size_t aligned_size = (size + (align - 1)) & ~(align - 1);
223-
void* data = AlignedAlloc<align>(aligned_size);
265+
TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
266+
void* data =
267+
alloc->allocate(aligned_size, align, ArrayType::RuntimeTypeIndex(), alloc->context);
224268
new (data) ArrayType(std::forward<Args>(args)...);
225269
return reinterpret_cast<ArrayType*>(data);
226270
}
@@ -239,7 +283,8 @@ class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
239283
tptr->ArrayType::~ArrayType();
240284
}
241285
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
242-
AlignedFree(static_cast<void*>(tptr));
286+
ObjectUnsafe::GetObjectAllocHeaderFromPtr(static_cast<void*>(tptr))
287+
->delete_space(static_cast<void*>(tptr));
243288
}
244289
}
245290
};

include/tvm/ffi/object.h

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

1144+
/*!
1145+
* \brief Recover the TVMFFIObjectAllocHeader for a TVMFFIObject pointer.
1146+
* \param ptr The pointer to the space of the object.
1147+
* \return The header set by the allocator that produced ``ptr``.
1148+
*/
1149+
TVM_FFI_INLINE static TVMFFIObjectAllocHeader* GetObjectAllocHeaderFromPtr(void* ptr) {
1150+
return reinterpret_cast<TVMFFIObjectAllocHeader*>(static_cast<char*>(ptr) -
1151+
sizeof(TVMFFIObjectAllocHeader));
1152+
}
1153+
11441154
// Suppress -Winvalid-offsetof: we intentionally use offsetof on non-standard-layout types
11451155
// to avoid undefined behavior from null pointer arithmetic that sanitizers flag.
11461156
#if defined(__clang__) || defined(__GNUC__)

pyproject.toml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,19 @@ 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 = ["cp38-*", "cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp314t-*"]
235+
# Per-Python-version wheels (no abi3 / limited API).
236+
build = [
237+
"cp38-*",
238+
"cp39-*",
239+
"cp310-*",
240+
"cp311-*",
241+
"cp312-*",
242+
"cp313-*",
243+
"cp314t-*",
244+
]
239245
skip = ["*musllinux*"]
240246
# we only need to test on cp312
241247
test-skip = ["cp38-*", "cp39-*", "cp310-*", "cp311-*"]
242-
# focus on testing abi3 wheel
243248
build-frontend = "build[uv]"
244249
test-command = "pytest {package}/tests/python -vvs"
245250
test-groups = ["test"]

python/tvm_ffi/cython/base.pxi

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,21 @@ def _env_get_current_stream(int device_type, int device_id):
361361

362362

363363
cdef extern from "tvm_ffi_python_helpers.h":
364+
int TVMFFIPyRegisterDefaultAllocator() noexcept
365+
void TVMFFIPyMarkPythonFinalizing() noexcept
366+
367+
void TVMFFIPyRebindPyObject(void* chandle, PyObject* expect, PyObject* neo) noexcept
368+
void TVMFFIPyTpDealloc(void** ptr_to_chandle, PyObject* wrapper) noexcept
369+
void TVMFFIPyInstallTypeSlots(PyObject* type_obj) noexcept
370+
object TVMFFIPyMakeRetObject(void* chandle, PyObject* cls_type)
371+
372+
# Pre-bump tp_dealloc installer (free-threaded builds only; a no-op symbol on the GIL build).
373+
# Called once per cdef CObject-family carrier right after the class is defined: it replaces
374+
# Cython's generated tp_dealloc with a hand-built slot that runs the binding transition before
375+
# any resurrection bump. One installer for every carrier (the slot is universal); a layout
376+
# guard inside it fails loudly at import if a carrier's struct ever drifts.
377+
void TVMFFIPyWrapDealloc(PyObject* type_obj) noexcept
378+
364379
# no need to expose fields of the call context setter data structure
365380
ctypedef struct TVMFFIPyCallContext:
366381
int device_type
@@ -542,5 +557,11 @@ cdef _init_env_api():
542557

543558
_init_env_api()
544559

560+
561+
CHECK_CALL(TVMFFIPyRegisterDefaultAllocator())
562+
563+
import atexit as _tvm_ffi_atexit
564+
_tvm_ffi_atexit.register(TVMFFIPyMarkPythonFinalizing)
565+
545566
# ensure testing is linked and we can run testcases
546567
TVMFFITestingDummyTarget()

python/tvm_ffi/cython/error.pxi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ cdef class Error(CObject):
122122
return make_ret_object(any_val)
123123

124124

125+
# Install the free-threaded pre-bump tp_dealloc slot on this cdef carrier (no-op on the GIL).
126+
TVMFFIPyWrapDealloc(<PyObject*>Error)
127+
128+
125129
cdef inline Error move_from_last_error():
126130
# raise last error
127131
error = Error.__new__(Error)

python/tvm_ffi/cython/function.pxi

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -545,9 +545,17 @@ 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+
# need to detach from chandle
550+
# there are two possible outcomes after the call:
551+
# chandle gets moved, so it is set to NULL
552+
# callee did not move chandle, in such case src.chandle is valid
553+
# but chandle is no longer attached to PyObject
554+
# we need to carefully handle chandle and PyObject recycling in both cases.
555+
# These logics are implemented in TVMFFIPyTpDealloc (CObject.__dealloc__).
556+
TVMFFIPyRebindPyObject(src.chandle, <PyObject*>src, NULL)
549557
out.type_index = kTVMFFIObjectRValueRef
550-
out.v_ptr = &((<CObject>(arg.obj)).chandle)
558+
out.v_ptr = &(src.chandle)
551559
return 0
552560

553561

@@ -1075,6 +1083,10 @@ cdef class Function(CObject):
10751083
return func
10761084

10771085

1086+
# Install the free-threaded pre-bump tp_dealloc slot on this cdef carrier (no-op on the GIL).
1087+
TVMFFIPyWrapDealloc(<PyObject*>Function)
1088+
1089+
10781090
def _register_global_func(name: str, pyfunc: Callable[..., Any] | Function, override: bool) -> Function:
10791091
cdef TVMFFIObjectHandle chandle
10801092
cdef int c_api_ret_code
@@ -1089,6 +1101,7 @@ def _register_global_func(name: str, pyfunc: Callable[..., Any] | Function, over
10891101

10901102

10911103
def _get_global_func(name: str, allow_missing: bool):
1104+
# PyObject tying is not applicable here.
10921105
cdef TVMFFIObjectHandle chandle
10931106
cdef ByteArrayArg name_arg = ByteArrayArg(c_str(name))
10941107

0 commit comments

Comments
 (0)