This is an automated email from the ASF dual-hosted git repository.

tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git


The following commit(s) were added to refs/heads/main by this push:
     new 60c250be20 [Fix][Relax] Scope BasePyModule's Python function registry 
entries to the module (#20417)
60c250be20 is described below

commit 60c250be20c1109d1e54eb12a91b342c500ec0dc
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Thu Sep 24 11:38:16 2026 -0700

    [Fix][Relax] Scope BasePyModule's Python function registry entries to the 
module (#20417)
    
    `BasePyModule.__del__` calls `vm.builtin.clear_py_func_registry`, which
    empties the process-wide registry rather than removing the module's own
    entries. Collecting any module therefore drops every registration in the
    process, and a later `R.call_py_func` fails with `Python function
    '<name>' not found in registry`.
    
    Whether it happens depends on when the collector runs, so it shows up as
    an unrelated test failing. The module built in
    `tests/python/relax/test_pytorch_integration.py` registers nothing
    itself, but its finalizer wipes the names registered by
    `tests/python/relax/test_relax_operators.py`:
    
    ```python
    tvm.get_global_func("vm.builtin.register_py_func")("victim", 
tvm.runtime.convert(lambda x: x))
    bystander = BasePyModule.__new__(BasePyModule)   # registers nothing
    del bystander
    gc.collect()
    tvm.get_global_func("vm.builtin.get_py_func")("victim")
    # InternalError: Python function 'victim' not found in registry
    ```
    
    This adds `vm.builtin.unregister_py_func` and records the names each
    module registers, so the finalizer removes only its own entries.
    Registering a name again transfers ownership to the newer module, which
    keeps a stale module from dropping a live one's function.
    
    The per-module cleanup was also unreachable before this change. The
    wrapper stored in the registry captured `self`, so the registry held a
    strong reference to every module that registered a function and none of
    them were ever collected — the finalizer only ever ran for modules that
    had registered nothing, which are exactly the ones that then cleared
    everyone else's entries. The wrapper now holds a weak reference to its
    module, and reports a clear error if it is called after the module is
    gone:
    
    ```
    RuntimeError: Python function 'f' belongs to a BasePyModule that has been 
destroyed;
    keep the module alive while calling into it.
    ```
    
    `vm.builtin.clear_py_func_registry` is left in place; it simply has no
    callers now.
---
 python/tvm/relax/base_py_module.py         | 57 +++++++++++++++++++++++++-----
 src/runtime/vm/builtin.cc                  |  7 ++++
 tests/python/relax/test_relax_operators.py | 31 ++++++++++++++--
 3 files changed, 85 insertions(+), 10 deletions(-)

diff --git a/python/tvm/relax/base_py_module.py 
b/python/tvm/relax/base_py_module.py
index cba22b5dba..284201acb8 100644
--- a/python/tvm/relax/base_py_module.py
+++ b/python/tvm/relax/base_py_module.py
@@ -19,6 +19,8 @@
 
 import inspect
 import os
+import sys
+import weakref
 from typing import Any, Optional, Union
 
 import numpy as np
@@ -43,6 +45,11 @@ except ImportError:
     _FASTER_DLPACK_EXTENSION = None
 
 
+# Maps a registered Python function name to the id of the BasePyModule that 
registered it last.
+# A module only unregisters names it still owns, so re-registering a name 
transfers ownership.
+_PY_FUNC_OWNERS: dict[str, int] = {}
+
+
 class BasePyModule:
     """Base class that allows Python functions in IRModule with DLPack 
conversion.
 
@@ -56,12 +63,31 @@ class BasePyModule:
     subclass with ``R.py_module`` adds this executable runtime interface.
     """
 
+    # Names this instance registered with the VM's Python function registry. 
The registry is
+    # global, so a module must only remove entries it still owns.
+    _registered_py_funcs: tuple[str, ...] = ()
+
     def __del__(self):
-        """Clean up registered Python functions on module destruction."""
+        """Unregister the Python functions this module still owns."""
+        registered = getattr(self, "_registered_py_funcs", None)
+        if not registered:
+            return
         try:
-            clear_func = 
tvm.get_global_func("vm.builtin.clear_py_func_registry")
-            clear_func()
-        except (ValueError, AttributeError):
+            # Release ownership first: if the call below fails, the map must 
not keep pointing at
+            # a destroyed module, whose id() a later module could reuse.
+            owned = [name for name in registered if _PY_FUNC_OWNERS.get(name) 
== id(self)]
+            for func_name in owned:
+                del _PY_FUNC_OWNERS[func_name]
+            # Once finalization starts the registry goes away with the 
process, and the module
+            # globals this needs may already be cleared.
+            if not owned or sys.is_finalizing():
+                return
+            unregister_py_func = 
tvm.get_global_func("vm.builtin.unregister_py_func")
+            for func_name in owned:
+                unregister_py_func(func_name)
+        except Exception:  # pylint: disable=broad-except
+            # A finalizer must not raise: either the interpreter is shutting 
down or the runtime
+            # is already unusable, and in both cases the registry no longer 
matters.
             pass
 
     def __init__(
@@ -203,21 +229,36 @@ class BasePyModule:
         for func_name, py_func in self.ir_mod.__pyfuncs__.items():
 
             def create_py_func_wrapper(name, original_func):
+                # The registry owns the wrapper, so capture the module weakly. 
A strong capture
+                # would make every registering module immortal, keeping its 
entries registered
+                # for the lifetime of the process.
+                module_ref = weakref.ref(self)
+
                 def wrapper(*args, **kwargs):
-                    converted_args = [self._convert_tvm_to_pytorch(arg) for 
arg in args]
+                    module = module_ref()
+                    if module is None:
+                        raise RuntimeError(
+                            f"Python function '{name}' belongs to a 
BasePyModule that has been "
+                            "destroyed; keep the module alive while calling 
into it."
+                        )
+
+                    converted_args = [module._convert_tvm_to_pytorch(arg) for 
arg in args]
                     converted_kwargs = {
-                        k: self._convert_tvm_to_pytorch(v) for k, v in 
kwargs.items()
+                        k: module._convert_tvm_to_pytorch(v) for k, v in 
kwargs.items()
                     }
 
-                    result = original_func(self, *converted_args, 
**converted_kwargs)
+                    result = original_func(module, *converted_args, 
**converted_kwargs)
 
-                    return self._convert_pytorch_to_tvm(result)
+                    return module._convert_pytorch_to_tvm(result)
 
                 wrapper.__name__ = name
                 return wrapper
 
             wrapped_func = create_py_func_wrapper(func_name, py_func)
             register_py_func(func_name, wrapped_func)
+            _PY_FUNC_OWNERS[func_name] = id(self)
+            if func_name not in self._registered_py_funcs:
+                self._registered_py_funcs = (*self._registered_py_funcs, 
func_name)
 
     def call_tir(self, tir_func, args, out_ty):
         """Call a TIR function with PyTorch tensors."""
diff --git a/src/runtime/vm/builtin.cc b/src/runtime/vm/builtin.cc
index fa5f5ce7ce..9c2651e7c8 100644
--- a/src/runtime/vm/builtin.cc
+++ b/src/runtime/vm/builtin.cc
@@ -472,6 +472,12 @@ void ClearPyFuncRegistry() { py_func_registry.clear(); }
  */
 void RegisterPyFunc(const std::string& name, ffi::Function func) { 
py_func_registry[name] = func; }
 
+/*!
+ * \brief Unregister a Python function registered with RegisterPyFunc
+ * \param name The function name
+ */
+void UnregisterPyFunc(const std::string& name) { py_func_registry.erase(name); 
}
+
 /*!
  * \brief Get a registered Python function
  * \param name The function name
@@ -522,6 +528,7 @@ TVM_FFI_STATIC_INIT_BLOCK() {
   refl::GlobalDef()
       .def_packed("vm.builtin.call_py_func", CallPyFunc)
       .def("vm.builtin.register_py_func", RegisterPyFunc)
+      .def("vm.builtin.unregister_py_func", UnregisterPyFunc)
       .def("vm.builtin.get_py_func", GetPyFunc)
       .def("vm.builtin.clear_py_func_registry", ClearPyFuncRegistry);
 }
diff --git a/tests/python/relax/test_relax_operators.py 
b/tests/python/relax/test_relax_operators.py
index 37243843e3..639ccbe67a 100644
--- a/tests/python/relax/test_relax_operators.py
+++ b/tests/python/relax/test_relax_operators.py
@@ -16,8 +16,11 @@
 # under the License.
 # ruff: noqa: E501, F841
 
+import gc
 import sys
 import tempfile
+import weakref
+from types import SimpleNamespace
 
 import numpy as np
 import pytest
@@ -478,8 +481,32 @@ def test_op_call_py_func(exec_mode):
     expected2 = 1.0 / (1.0 + np.exp(-np.maximum(y_data, 0.0)))
     assert (result2.numpy() == expected2).all()
 
-    clear_func = tvm.get_global_func("vm.builtin.clear_py_func_registry")
-    clear_func()
+    unregister_func = tvm.get_global_func("vm.builtin.unregister_py_func")
+    unregister_func("torch_relu")
+    unregister_func("torch_sigmoid")
+
+
+def test_py_func_registry_is_scoped_to_its_module():
+    """A module's finalizer must drop its own registrations and nothing 
else."""
+    from tvm.relax.base_py_module import BasePyModule
+
+    get_func = tvm.get_global_func("vm.builtin.get_py_func")
+    tvm.get_global_func("vm.builtin.register_py_func")("registry_probe", 
lambda x: x)
+
+    # __new__ skips __init__'s JIT compilation; only the registration matters 
here.
+    module = BasePyModule.__new__(BasePyModule)
+    module.ir_mod = SimpleNamespace(pyfuncs={"registry_owned": lambda self, x: 
x})
+    module._register_python_functions()
+    module_ref = weakref.ref(module)
+
+    del module
+    gc.collect()
+
+    assert module_ref() is None, "the registry must not keep the module alive"
+    assert get_func("registry_probe") is not None, "another owner's function 
was dropped"
+    with pytest.raises(tvm.error.InternalError, match="not found in registry"):
+        get_func("registry_owned")
+    tvm.get_global_func("vm.builtin.unregister_py_func")("registry_probe")
 
 
 def test_op_to_device(exec_mode):

Reply via email to