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 059f8af fix(cython): Segfault in free-threaded Py_DecRef Cleanup
(#529)
059f8af is described below
commit 059f8af3436b4b1fc629ba6d2c87f6be72513aeb
Author: Junru Shao <[email protected]>
AuthorDate: Fri Apr 10 06:03:30 2026 -0700
fix(cython): Segfault in free-threaded Py_DecRef Cleanup (#529)
Free-threaded CPython still requires an attached PyThreadState before
using the Python C API. tvm-ffi skipped that setup under
Py_GIL_DISABLED, so native cleanup paths could call Py_DecRef without
thread state and crash.
Always attach thread state around native decref paths, and add a
free-threaded regression test that drops the last ref from a
detached-thread-state region. The test is skipped on non-free- threaded
builds and runs in the existing 3.14t CI job.
---
python/tvm_ffi/cython/function.pxi | 20 +++++++++++
python/tvm_ffi/cython/tvm_ffi_python_helpers.h | 41 +++++++++++------------
tests/python/test_free_threaded_python_helpers.py | 32 ++++++++++++++++++
3 files changed, 71 insertions(+), 22 deletions(-)
diff --git a/python/tvm_ffi/cython/function.pxi
b/python/tvm_ffi/cython/function.pxi
index 913c698..aadd4ee 100644
--- a/python/tvm_ffi/cython/function.pxi
+++ b/python/tvm_ffi/cython/function.pxi
@@ -1180,6 +1180,26 @@ def _convert_to_opaque_object(object pyobject: Any) ->
OpaquePyObject:
return ret
+cdef extern from *:
+ """
+ static void TVMFFITestingCallDeleterWithoutThreadState(void* py_obj) {
+ PyThreadState* thread_state = PyEval_SaveThread();
+ TVMFFIPyObjectDeleter(py_obj);
+ PyEval_RestoreThread(thread_state);
+ }
+ """
+ void TVMFFITestingCallDeleterWithoutThreadState(void* py_obj)
+
+
+def _testing_drop_last_ref_without_thread_state() -> None:
+ """Drop the last Python ref from a detached-thread-state region."""
+ cdef object pyobject = {}
+ cdef PyObject* py_obj = <PyObject*>pyobject
+ Py_INCREF(pyobject)
+ pyobject = None
+ TVMFFITestingCallDeleterWithoutThreadState(<void*>py_obj)
+
+
def _print_debug_info() -> None:
"""Get the size of the dispatch map"""
cdef size_t size = TVMFFIPyGetDispatchMapSize()
diff --git a/python/tvm_ffi/cython/tvm_ffi_python_helpers.h
b/python/tvm_ffi/cython/tvm_ffi_python_helpers.h
index 2015215..9c923af 100644
--- a/python/tvm_ffi/cython/tvm_ffi_python_helpers.h
+++ b/python/tvm_ffi/cython/tvm_ffi_python_helpers.h
@@ -47,6 +47,22 @@
// prefixed with TVMFFIPy so they can be easily invoked through Cython.
///--------------------------------------------------------------------------------
+//------------------------------------------------------------------------------------
+// Helpers for Python thread-state attachment
+//------------------------------------------------------------------------------------
+//
+// On classic builds, PyGILState_Ensure attaches the current thread and
acquires the GIL.
+// On free-threaded builds, there is no process-wide GIL to acquire, but
CPython still
+// requires an attached thread state before manipulating Python refcounts.
+class TVMFFIPyWithAttachedThreadState {
+ public:
+ TVMFFIPyWithAttachedThreadState() noexcept { gstate_ = PyGILState_Ensure(); }
+ ~TVMFFIPyWithAttachedThreadState() { PyGILState_Release(gstate_); }
+
+ private:
+ PyGILState_STATE gstate_;
+};
+
/*!
* \brief Thread-local call stack used by TVMFFIPyCallContext.
*/
@@ -124,6 +140,7 @@ class TVMFFIPyCallContext {
}
~TVMFFIPyCallContext() {
+ TVMFFIPyWithAttachedThreadState thread_state;
try {
// recycle the temporary arguments if any
for (int i = 0; i < this->num_temp_ffi_objects; ++i) {
@@ -664,6 +681,7 @@ class TVMFFIPyMLIRPackedSafeCall {
}
~TVMFFIPyMLIRPackedSafeCall() {
+ TVMFFIPyWithAttachedThreadState thread_state;
if (keep_alive_object_) {
Py_DecRef(keep_alive_object_);
}
@@ -717,33 +735,12 @@ void TVMFFIPyMLIRPackedSafeCallDeleter(void* self) {
return TVMFFIPyMLIRPackedSafeCall::Deleter(self);
}
-//------------------------------------------------------------------------------------
-// Helpers for free-threaded python
-//------------------------------------------------------------------------------------
-#if defined(Py_GIL_DISABLED)
-// NOGIL case
-class TVMFFIPyWithGILIfNotFreeThreaded {
- public:
- TVMFFIPyWithGILIfNotFreeThreaded() = default;
-};
-#else
-// GIL case, need to ensure/release the GIL
-class TVMFFIPyWithGILIfNotFreeThreaded {
- public:
- TVMFFIPyWithGILIfNotFreeThreaded() noexcept { gstate_ = PyGILState_Ensure();
}
- ~TVMFFIPyWithGILIfNotFreeThreaded() { PyGILState_Release(gstate_); }
-
- private:
- PyGILState_STATE gstate_;
-};
-#endif
-
/*!
* \brief Deleter for Python objects
* \param py_obj The Python object to delete
*/
extern "C" void TVMFFIPyObjectDeleter(void* py_obj) noexcept {
- TVMFFIPyWithGILIfNotFreeThreaded gil_state;
+ TVMFFIPyWithAttachedThreadState thread_state;
Py_DecRef(static_cast<PyObject*>(py_obj));
}
diff --git a/tests/python/test_free_threaded_python_helpers.py
b/tests/python/test_free_threaded_python_helpers.py
new file mode 100644
index 0000000..c6483b3
--- /dev/null
+++ b/tests/python/test_free_threaded_python_helpers.py
@@ -0,0 +1,32 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import sys
+
+import pytest
+import tvm_ffi
+
+
+def _is_free_threaded_python() -> bool:
+ return hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled()
+
+
[email protected](not _is_free_threaded_python(), reason="requires
free-threaded Python")
+def test_pyobject_deleter_handles_last_ref() -> None:
+ drop_last_ref = getattr(tvm_ffi.core,
"_testing_drop_last_ref_without_thread_state")
+ drop_last_ref()