This is an automated email from the ASF dual-hosted git repository.
tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git
The following commit(s) were added to refs/heads/main by this push:
new 60e0614d [CORE] Staging Object Tying Feature (#696)
60e0614d is described below
commit 60e0614d6ca0ae3c15df60596131f9f8a3a2e6c2
Author: Tianqi Chen <[email protected]>
AuthorDate: Sat Aug 1 10:08:38 2026 +0800
[CORE] Staging Object Tying Feature (#696)
## Summary
Object tying remains compiled but is deliberately staged dormant:
canonical-wrapper detection returns false, while C++, Python-defined
dataclasses, and Rust objects continue to use matching legacy allocation
and deletion paths.
This staging has three goals:
- let downstream projects upgrade without making existing generated
caches depend on the new allocator contract;
- keep the compatibility change compact by changing only the
activation/allocation boundary instead of removing the object-tying
implementation;
- preserve the custom allocator API and a clean path for a later
coordinated object-tying activation.
Legacy wrapper identity and reference-count expectations are restored,
while activation-only tying tests remain as skipped coverage for the
later rollout.
## Testing
- C++: 465 enabled tests passed (2 disabled).
- Python: 2,381 passed, 78 skipped, 2 expected failures.
- Rust: 138 unit/integration tests and 3 doctests passed; formatting is
clean.
- Pre-commit: all changed-file checks passed, including ruff, ty, and
clang-format.
- Order-balanced microbenchmarks place the dormant path about 1.3-7.9%
behind active tying and 4.7-12.5% ahead of a full revert.
---
include/tvm/ffi/memory.h | 28 ++-------
python/tvm_ffi/cython/tvm_ffi_python_object.h | 24 ++++----
rust/tvm-ffi/src/object.rs | 86 +++++++++++++++++----------
src/ffi/extra/dataclass.cc | 31 ++++------
tests/python/test_dataclass_py_class.py | 11 ++--
tests/python/test_function.py | 30 +++++-----
tests/python/test_pyobject_tying.py | 4 ++
7 files changed, 103 insertions(+), 111 deletions(-)
diff --git a/include/tvm/ffi/memory.h b/include/tvm/ffi/memory.h
index c4e07841..a06a76b0 100644
--- a/include/tvm/ffi/memory.h
+++ b/include/tvm/ffi/memory.h
@@ -148,12 +148,7 @@ class ObjAllocatorBase {
// Simple allocator that uses new/delete.
class SimpleObjAllocator : public ObjAllocatorBase<SimpleObjAllocator> {
private:
- /*! \brief Guard a custom-allocator allocation until ownership is
transferred to an object.
- * If placement construction throws, free the raw block through its
allocator's
- * ``delete_space`` (the same path the live deleter uses). ``data``
is a body pointer
- * offset past the ``TVMFFIObjectAllocHeader``, so a plain
``AlignedFree`` would be wrong;
- * the header (with ``delete_space`` wired) is set up by
``allocate`` before construction,
- * so this is valid even though the body was never constructed. */
+ /*! \brief Guard a simple-allocator allocation until ownership is
transferred to an object. */
class AllocGuard {
public:
explicit AllocGuard(void* data) noexcept : data_(data) {}
@@ -162,7 +157,7 @@ class SimpleObjAllocator : public
ObjAllocatorBase<SimpleObjAllocator> {
~AllocGuard() noexcept {
if (data_ != nullptr) {
- ObjectUnsafe::GetObjectAllocHeaderFromPtr(data_)->delete_space(data_);
+ AlignedFree(data_);
}
}
@@ -192,11 +187,7 @@ class SimpleObjAllocator : public
ObjAllocatorBase<SimpleObjAllocator> {
// class with non-virtual destructor.
// We are fine here as we captured the right deleter during construction.
// This is also the right way to get storage type for an object pool.
- static_assert(alignof(T) <= alignof(::std::max_align_t),
- "Object types with alignment > max_align_t are not
supported "
- "by the custom allocator hook");
- TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
- void* data = alloc->allocate(sizeof(T), alignof(T),
T::RuntimeTypeIndex(), alloc->context);
+ void* data = AlignedAlloc(sizeof(T), alignof(T));
AllocGuard alloc_guard(data);
new (data) T(std::forward<Args>(args)...);
alloc_guard.Release();
@@ -217,8 +208,7 @@ class SimpleObjAllocator : public
ObjAllocatorBase<SimpleObjAllocator> {
tptr->T::~T();
}
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
- ObjectUnsafe::GetObjectAllocHeaderFromPtr(static_cast<void*>(tptr))
- ->delete_space(static_cast<void*>(tptr));
+ AlignedFree(static_cast<void*>(tptr));
}
}
};
@@ -246,17 +236,12 @@ class SimpleObjAllocator : public
ObjAllocatorBase<SimpleObjAllocator> {
static_assert(
alignof(ArrayType) % alignof(ElemType) == 0 && sizeof(ArrayType) %
alignof(ElemType) == 0,
"element alignment constraint");
- static_assert(alignof(ArrayType) <= alignof(::std::max_align_t),
- "Object types with alignment > max_align_t are not
supported "
- "by the custom allocator hook");
size_t size = sizeof(ArrayType) + sizeof(ElemType) * num_elems;
// round up to the nearest multiple of align
constexpr size_t align = alignof(ArrayType);
// C++ standard always guarantees that alignof operator returns a power
of 2
size_t aligned_size = (size + (align - 1)) & ~(align - 1);
- TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
- void* data =
- alloc->allocate(aligned_size, align, ArrayType::RuntimeTypeIndex(),
alloc->context);
+ void* data = AlignedAlloc(aligned_size, align);
AllocGuard alloc_guard(data);
new (data) ArrayType(std::forward<Args>(args)...);
alloc_guard.Release();
@@ -277,8 +262,7 @@ class SimpleObjAllocator : public
ObjAllocatorBase<SimpleObjAllocator> {
tptr->ArrayType::~ArrayType();
}
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
- ObjectUnsafe::GetObjectAllocHeaderFromPtr(static_cast<void*>(tptr))
- ->delete_space(static_cast<void*>(tptr));
+ AlignedFree(static_cast<void*>(tptr));
}
}
};
diff --git a/python/tvm_ffi/cython/tvm_ffi_python_object.h
b/python/tvm_ffi/cython/tvm_ffi_python_object.h
index ed8389ed..1672c784 100644
--- a/python/tvm_ffi/cython/tvm_ffi_python_object.h
+++ b/python/tvm_ffi/cython/tvm_ffi_python_object.h
@@ -660,24 +660,20 @@ inline void TVMFFIPyMarkPythonFinalizing() noexcept {
}
/*!
- * \brief True iff ``chandle`` was allocated through the Python custom
- * allocator (full ``PyCustomAllocHeader`` ahead of it). False for
- * allocations that came through libtvm_ffi's builtin default
- * (only the base ``TVMFFIObjectAllocHeader``).
+ * \brief Whether ``chandle`` participates in Python object tying.
*
- * Detection is by comparing ``base.delete_space`` against
- * ``TVMFFIPyDeleteSpace``: each frontend recognizes its own deleter
- * pointer, so multiple frontends can coexist without a flag bit on
- * ``TVMFFIObject``.
+ * Object tying is dormant during the compatibility rollout: public object
+ * constructors still use the legacy no-prefix allocation layout, so probing
+ * memory before an arbitrary object body would be invalid. Keep the complete
+ * tying implementation compiled, but route every object through the ordinary
+ * fresh-wrapper path until allocation is activated in a later release.
*
- * \param chandle The FFI object handle to test (NULL yields false).
- * \return True iff ``chandle`` carries a ``PyCustomAllocHeader`` (our
deleter).
+ * \param chandle The FFI object handle to test.
+ * \return False while object tying remains dormant.
*/
TVM_FFI_INLINE bool TVMFFIPyIsCanonical(void* chandle) {
- if (chandle == nullptr) return false;
- TVMFFIObjectAllocHeader* base = reinterpret_cast<TVMFFIObjectAllocHeader*>(
- static_cast<char*>(chandle) - sizeof(TVMFFIObjectAllocHeader));
- return base->delete_space == &TVMFFIPyDeleteSpace;
+ (void)chandle;
+ return false;
}
//---------------------------------------------------------------
diff --git a/rust/tvm-ffi/src/object.rs b/rust/tvm-ffi/src/object.rs
index 712aa35e..7adc439b 100644
--- a/rust/tvm-ffi/src/object.rs
+++ b/rust/tvm-ffi/src/object.rs
@@ -23,10 +23,7 @@ use crate::derive::ObjectRef;
use crate::type_traits::AnyCompatible;
pub use tvm_ffi_sys::TVMFFITypeIndex as TypeIndex;
/// Object related ABI handling
-use tvm_ffi_sys::{
- TVMFFIAny, TVMFFIGetCustomAllocator, TVMFFIGetTypeInfo, TVMFFIObject,
- COMBINED_REF_COUNT_BOTH_ONE,
-};
+use tvm_ffi_sys::{TVMFFIAny, TVMFFIGetTypeInfo, TVMFFIObject,
COMBINED_REF_COUNT_BOTH_ONE};
/// Object type is by default the TVMFFIObject
#[repr(C)]
@@ -239,7 +236,7 @@ pub mod unsafe_ {
use std::ffi::c_void;
use std::sync::atomic::{fence, Ordering};
- use tvm_ffi_sys::{TVMFFIObject, TVMFFIObjectAllocHeader};
+ use tvm_ffi_sys::TVMFFIObject;
use tvm_ffi_sys::TVMFFIObjectDeleterFlagBitMask::{
kTVMFFIObjectDeleterFlagBitMaskBoth,
kTVMFFIObjectDeleterFlagBitMaskStrong,
kTVMFFIObjectDeleterFlagBitMaskWeak,
@@ -316,8 +313,7 @@ pub mod unsafe_ {
(obj.combined_ref_count.load(Ordering::Relaxed) >> 32) as usize
}
- /// Generic object deleter for objects allocated through the registered
- /// `TVMFFICustomAllocator`.
+ /// Generic object deleter for objects allocated through Rust's global
allocator.
pub(crate) unsafe extern "C" fn object_deleter_for_new<T>(ptr: *mut
c_void, flags: i32)
where
T: super::ObjectCore,
@@ -327,13 +323,42 @@ pub mod unsafe_ {
std::ptr::drop_in_place(obj);
}
if flags & kTVMFFIObjectDeleterFlagBitMaskWeak as i32 != 0 {
- let header_ptr = (ptr as *mut u8)
- .sub(std::mem::size_of::<TVMFFIObjectAllocHeader>())
- as *mut TVMFFIObjectAllocHeader;
- let delete_space = (*header_ptr)
- .delete_space
- .expect("TVMFFIObjectAllocHeader::delete_space must be set by
the allocator");
- delete_space(ptr);
+ std::alloc::dealloc(ptr as *mut u8,
std::alloc::Layout::new::<T>());
+ }
+ }
+
+ pub(crate) unsafe extern "C" fn object_deleter_for_new_with_extra_items<T,
U>(
+ ptr: *mut c_void,
+ flags: i32,
+ ) where
+ T: super::ObjectCoreWithExtraItems<ExtraItem = U>,
+ {
+ let obj = ptr as *mut T;
+ if flags == kTVMFFIObjectDeleterFlagBitMaskBoth as i32 {
+ let extra_items_count = T::extra_items_count(&(*obj));
+ std::ptr::drop_in_place(obj);
+ let layout = std::alloc::Layout::from_size_align(
+ std::mem::size_of::<T>() + extra_items_count *
std::mem::size_of::<U>(),
+ std::mem::align_of::<T>(),
+ )
+ .unwrap();
+ std::alloc::dealloc(ptr as *mut u8, layout);
+ } else {
+ assert_eq!(std::mem::size_of::<T>() % std::mem::size_of::<u64>(),
0);
+ if flags & kTVMFFIObjectDeleterFlagBitMaskStrong as i32 != 0 {
+ let extra_items_count = T::extra_items_count(&(*obj));
+ std::ptr::drop_in_place(obj);
+ std::ptr::write(obj as *mut u64, extra_items_count as u64);
+ }
+ if flags & kTVMFFIObjectDeleterFlagBitMaskWeak as i32 != 0 {
+ let extra_items_count = std::ptr::read(obj as *mut u64) as
usize;
+ let layout = std::alloc::Layout::from_size_align(
+ std::mem::size_of::<T>() + extra_items_count *
std::mem::size_of::<U>(),
+ std::mem::align_of::<T>(),
+ )
+ .unwrap();
+ std::alloc::dealloc(ptr as *mut u8, layout);
+ }
}
}
}
@@ -367,23 +392,14 @@ unsafe impl ObjectCore for Object {
// ObjectArc
//---------------------
-/// Allocate via the process-wide `TVMFFICustomAllocator`.
-unsafe fn allocate_via_registry(size: usize, alignment: usize, type_index:
i32) -> *mut u8 {
- let alloc = TVMFFIGetCustomAllocator();
- let allocate_fn = (*alloc)
- .allocate
- .expect("TVMFFICustomAllocator::allocate must be set");
- allocate_fn(size, alignment, type_index, (*alloc).context) as *mut u8
-}
-
impl<T: ObjectCore> ObjectArc<T> {
pub fn new(data: T) -> Self {
unsafe {
- let raw_data_ptr = allocate_via_registry(
- std::mem::size_of::<T>(),
- std::mem::align_of::<T>(),
- T::type_index(),
- );
+ let layout = std::alloc::Layout::new::<T>();
+ let raw_data_ptr = std::alloc::alloc(layout);
+ if raw_data_ptr.is_null() {
+ std::alloc::handle_alloc_error(layout);
+ }
let ptr = raw_data_ptr as *mut T;
std::ptr::write(ptr, data);
// now override the header directly
@@ -413,9 +429,15 @@ impl<T: ObjectCore> ObjectArc<T> {
assert_eq!(std::mem::align_of::<T>() % std::mem::align_of::<U>(),
0);
assert_eq!(std::mem::size_of::<T>() % std::mem::align_of::<U>(),
0);
let extra_items_count = T::extra_items_count(&data);
- let total_size = std::mem::size_of::<T>() + extra_items_count *
std::mem::size_of::<U>();
- let raw_data_ptr =
- allocate_via_registry(total_size, std::mem::align_of::<T>(),
T::type_index());
+ let layout = std::alloc::Layout::from_size_align(
+ std::mem::size_of::<T>() + extra_items_count *
std::mem::size_of::<U>(),
+ std::mem::align_of::<T>(),
+ )
+ .unwrap();
+ let raw_data_ptr = std::alloc::alloc(layout);
+ if raw_data_ptr.is_null() {
+ std::alloc::handle_alloc_error(layout);
+ }
let ptr = raw_data_ptr as *mut T;
std::ptr::write(ptr, data);
// now override the header directly
@@ -425,7 +447,7 @@ impl<T: ObjectCore> ObjectArc<T> {
combined_ref_count:
AtomicU64::new(COMBINED_REF_COUNT_BOTH_ONE),
type_index: T::type_index(),
__padding: 0,
- deleter: Some(unsafe_::object_deleter_for_new::<T>),
+ deleter:
Some(unsafe_::object_deleter_for_new_with_extra_items::<T, U>),
},
);
// move into the object arc ptr
diff --git a/src/ffi/extra/dataclass.cc b/src/ffi/extra/dataclass.cc
index 4b6452d7..73a05a48 100644
--- a/src/ffi/extra/dataclass.cc
+++ b/src/ffi/extra/dataclass.cc
@@ -1742,9 +1742,7 @@ PyClassFieldStorageKind GetPyClassFieldStorageKind(const
TVMFFIFieldInfo* finfo)
*
* For the "strong" phase, iterates all reflected fields and destructs
* Any/ObjectRef values in-place (to release references). For the "weak"
- * phase, forwards to the prepended ``TVMFFIObjectAllocHeader``'s
- * ``delete_space`` (every Object carries one — libtvm_ffi's builtin
- * default allocator ensures it).
+ * phase, frees the underlying calloc'd memory.
*/
void PyClassDeleter(void* self_void, int flags) {
TVMFFIObject* self = static_cast<TVMFFIObject*>(self_void);
@@ -1768,7 +1766,7 @@ void PyClassDeleter(void* self_void, int flags) {
});
}
if (flags & kTVMFFIObjectDeleterFlagBitMaskWeak) {
-
details::ObjectUnsafe::GetObjectAllocHeaderFromPtr(self_void)->delete_space(self_void);
+ std::free(self_void);
}
}
@@ -2118,16 +2116,11 @@ void PyClassRegisterTypeAttrColumns(int32_t type_index,
int32_t total_size) {
RegisterFFIInit(type_index);
// Step 2. Register `__ffi_new__`
Function new_fn = Function::FromTyped([type_index, total_size, type_info]()
-> ObjectRef {
- // Route through the custom-allocator registry so the prepended
- // TVMFFIObjectAllocHeader is in place for PyClassDeleter's Weak
- // branch. Then memset to zero-init the payload (mirrors the original
- // calloc-based path); ConstructPyClassFields below placement-news the
- // non-trivial fields while the memset covers the POD ones.
- size_t alloc_size = static_cast<size_t>(total_size);
- TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
- void* obj_ptr =
- alloc->allocate(alloc_size, alignof(::std::max_align_t), type_index,
alloc->context);
- std::memset(obj_ptr, 0, alloc_size);
+ void* obj_ptr = std::calloc(1, static_cast<size_t>(total_size));
+ if (!obj_ptr) {
+ TVM_FFI_THROW(RuntimeError) << "Failed to allocate " << total_size << "
bytes for type "
+ << TypeIndexToTypeKey(type_index);
+ }
TVMFFIObject* ffi_obj = reinterpret_cast<TVMFFIObject*>(obj_ptr);
ffi_obj->type_index = type_index;
ffi_obj->combined_ref_count = details::kCombinedRefCountBothOne;
@@ -2142,12 +2135,10 @@ void PyClassRegisterTypeAttrColumns(int32_t type_index,
int32_t total_size) {
// Step 3. Register `__ffi_shallow_copy__`
Function copy_fn =
Function::FromTyped([type_index, total_size, type_info](const Object*
src) -> ObjectRef {
- // Allocator + memset mirror RegisterFFINew above.
- size_t alloc_size = static_cast<size_t>(total_size);
- TVMFFICustomAllocator* alloc = TVMFFIGetCustomAllocator();
- void* obj_ptr =
- alloc->allocate(alloc_size, alignof(::std::max_align_t),
type_index, alloc->context);
- std::memset(obj_ptr, 0, alloc_size);
+ void* obj_ptr = std::calloc(1, static_cast<size_t>(total_size));
+ if (!obj_ptr) {
+ TVM_FFI_THROW(RuntimeError) << "Failed to allocate for shallow copy";
+ }
TVMFFIObject* ffi_obj = reinterpret_cast<TVMFFIObject*>(obj_ptr);
ffi_obj->type_index = type_index;
ffi_obj->combined_ref_count = details::kCombinedRefCountBothOne;
diff --git a/tests/python/test_dataclass_py_class.py
b/tests/python/test_dataclass_py_class.py
index c001bbcb..b4b8986b 100644
--- a/tests/python/test_dataclass_py_class.py
+++ b/tests/python/test_dataclass_py_class.py
@@ -3689,23 +3689,20 @@ class TestNativeParentInheritance:
stored = holder.value
assert stored is not None
assert stored.same_as(target)
- # Under PyObject-tying the field accessor returns the canonical
wrapper,
- # so ``stored`` aliases ``target`` (no fresh wrapper, no extra C++
ref) and
- # the use count stays 2 rather than climbing to 3.
- assert stored is target
- assert use_count(target) == 2
+ assert stored is not target
+ assert use_count(target) == 3
del stored
gc.collect()
assert use_count(target) == 2
with pytest.raises(TypeError, match=r"testing\.TestObjectBase"):
holder.value = None # ty: ignore[invalid-assignment]
- assert holder.value is target
+ assert holder.value.same_as(target)
assert use_count(target) == 2
assert holder.optional_value is None
holder.optional_value = target
- assert holder.optional_value is target
+ assert holder.optional_value.same_as(target)
assert use_count(target) == 3
holder.optional_value = None
assert holder.optional_value is None
diff --git a/tests/python/test_function.py b/tests/python/test_function.py
index 908e2e21..2dd3ad86 100644
--- a/tests/python/test_function.py
+++ b/tests/python/test_function.py
@@ -192,41 +192,39 @@ def test_global_func() -> None:
def test_rvalue_ref() -> None:
- # Under universal cache-on the callback's arg aliases the caller's
- # wrapper, so use_count inside is 1 (one wrapper, one chandle ref).
- # ``_move()`` on either side detaches that wrapper's binding before
- # the C++ AnyViewToOwnedAny transfer nulls the source chandle.
use_count = tvm_ffi.get_global_func("testing.object_use_count")
def callback(x: Any, expected_count: int) -> Any:
- # ``gc.collect()`` ensures Python destructors have run so
- # use_count reflects only live wrappers.
+ # The use count of TVM FFI objects is decremented as part of
+ # `ObjectRef.__del__`, which runs when the Python object is
+ # destructed. However, Python object destruction is not
+ # deterministic, and even CPython's reference-counting is
+ # considered an implementation detail. Therefore, to ensure
+ # correct results from this test, `gc.collect()` must be
+ # explicitly called.
gc.collect()
assert expected_count == use_count(x)
return x._move()
f = tvm_ffi.convert(callback)
- def check_caller_move() -> None:
- # Caller passes ``x._move()``: callback receives a fresh canonical
- # wrapper for the moved-in chandle.
+ def check0() -> None:
x = tvm_ffi.convert([1, 2])
assert use_count(x) == 1
+ f(x, 2)
f(x._move(), 1)
assert x.__ctypes_handle__().value is None
- def check_callback_move() -> None:
- # Callback returns ``x._move()``: caller sees a fresh canonical
- # wrapper, distinct from the now-empty ``x``.
+ def check1() -> None:
x = tvm_ffi.convert([1, 2])
assert use_count(x) == 1
- y = f(x, 1)
- assert y is not x
+ y = f(x, 2)
+ f(x._move(), 2)
assert x.__ctypes_handle__().value is None
assert y.__ctypes_handle__().value is not None
- check_caller_move()
- check_callback_move()
+ check0()
+ check1()
def test_echo_with_opaque_object() -> None:
diff --git a/tests/python/test_pyobject_tying.py
b/tests/python/test_pyobject_tying.py
index 1bd7abb2..ae3ec1e6 100644
--- a/tests/python/test_pyobject_tying.py
+++ b/tests/python/test_pyobject_tying.py
@@ -48,6 +48,10 @@ import tvm_ffi
import tvm_ffi.testing
from tvm_ffi import dataclasses as dc
+pytestmark = pytest.mark.skip(
+ reason="PyObject tying is compiled but dormant until allocator activation"
+)
+
# ---------------------------------------------------------------------------
# Test type registration
# ---------------------------------------------------------------------------