From 67c84741b8d01ceba42537874e94f744654950d5 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Mon, 2 Feb 2026 15:59:14 +0900 Subject: [PATCH 1/7] cache cast, calls Summary of Modifications The changes in this branch (speedup_controller) optimize the Controller_Trampoline class in the Python bindings by adding a caching mechanism for Python method lookups: Key Changes: 1. New caching infrastructure (in Binding_Controller.h): - Added member variables to cache: - m_pySelf - cached Python self reference (avoids repeated py::cast(this)) - m_methodCache - unordered_map storing Python method objects by name - m_onEventMethod - cached fallback "onEvent" method - m_hasOnEvent / m_cacheInitialized - state flags 2. New methods (in Binding_Controller.cpp): - initializePythonCache() - initializes the cache on first use - getCachedMethod() - retrieves methods from cache (or looks them up once and caches) - callCachedMethod() - calls a cached Python method with an event - Constructor and destructor to properly manage the cached Python objects with GIL 3. Optimized handleEvent(): - Previously: every event caused py::cast(this), py::hasattr(), and attr() lookups - Now: uses cached method references, avoiding repeated Python attribute lookups 4. Optimized getClassName(): - Uses the cached m_pySelf when available instead of casting each time Purpose: This is a performance optimization that reduces overhead when handling frequent events (like AnimateBeginEvent, AnimateEndEvent), which can be called many times per simulation step. The caching eliminates repeated Python/C++ boundary crossings for method lookups. --- .../Sofa/Core/Binding_Controller.cpp | 135 +++++++++++++++--- .../Sofa/Core/Binding_Controller.h | 31 ++++ 2 files changed, 148 insertions(+), 18 deletions(-) diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp index 0f26c7656..70a5d9d73 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp @@ -38,11 +38,97 @@ namespace sofapython3 using sofa::core::objectmodel::Event; using sofa::core::objectmodel::BaseObject; +Controller_Trampoline::Controller_Trampoline() = default; + +Controller_Trampoline::~Controller_Trampoline() +{ + // Clean up Python objects while holding the GIL + if (m_cacheInitialized) + { + PythonEnvironment::gil acquire {"~Controller_Trampoline"}; + m_methodCache.clear(); + m_onEventMethod = py::object(); + m_pySelf = py::object(); + } +} + +void Controller_Trampoline::initializePythonCache() +{ + if (m_cacheInitialized) + return; + + // Must be called with GIL held + m_pySelf = py::cast(this); + + // Cache the fallback "onEvent" method if it exists + if (py::hasattr(m_pySelf, "onEvent")) + { + py::object fct = m_pySelf.attr("onEvent"); + if (PyCallable_Check(fct.ptr())) + { + m_hasOnEvent = true; + m_onEventMethod = fct; + } + } + + m_cacheInitialized = true; +} + +py::object Controller_Trampoline::getCachedMethod(const std::string& methodName) +{ + // Must be called with GIL held and cache initialized + + // Check if we've already looked up this method + auto it = m_methodCache.find(methodName); + if (it != m_methodCache.end()) + { + return it->second; + } + + // First time looking up this method - check if it exists + py::object method; + if (py::hasattr(m_pySelf, methodName.c_str())) + { + py::object fct = m_pySelf.attr(methodName.c_str()); + if (PyCallable_Check(fct.ptr())) + { + method = fct; + } + } + + // Cache the result (even if empty, to avoid repeated hasattr checks) + m_methodCache[methodName] = method; + return method; +} + +bool Controller_Trampoline::callCachedMethod(const py::object& method, Event* event) +{ + // Must be called with GIL held + if (f_printLog.getValue()) + { + std::string eventStr = py::str(PythonFactory::toPython(event)); + msg_info() << "on" << event->getClassName() << " " << eventStr; + } + + py::object result = method(PythonFactory::toPython(event)); + if (result.is_none()) + return false; + + return py::cast(result); +} + std::string Controller_Trampoline::getClassName() const { PythonEnvironment::gil acquire {"getClassName"}; - // Get the actual class name from python. - return py::str(py::cast(this).get_type().attr("__name__")); + + // Use cached self if available, otherwise cast + if (m_cacheInitialized && m_pySelf) + { + return py::str(py::type::of(m_pySelf).attr("__name__")); + } + + // Fallback for when cache isn't initialized yet + return py::str(py::type::of(py::cast(this)).attr("__name__")); } void Controller_Trampoline::draw(const sofa::core::visual::VisualParams* params) @@ -55,6 +141,8 @@ void Controller_Trampoline::draw(const sofa::core::visual::VisualParams* params) void Controller_Trampoline::init() { PythonEnvironment::executePython(this, [this](){ + // Initialize the Python object cache on first init + initializePythonCache(); PYBIND11_OVERLOAD(void, Controller, init, ); }); } @@ -92,25 +180,36 @@ bool Controller_Trampoline::callScriptMethod( void Controller_Trampoline::handleEvent(Event* event) { - PythonEnvironment::executePython(this, [this,event](){ - py::object self = py::cast(this); - std::string name = std::string("on")+event->getClassName(); - /// Is there a method with this name in the class ? - if( py::hasattr(self, name.c_str()) ) + PythonEnvironment::executePython(this, [this, event](){ + // Ensure cache is initialized (in case init() wasn't called or + // handleEvent is called before init) + if (!m_cacheInitialized) + { + initializePythonCache(); + } + + // Build the event-specific method name (e.g., "onAnimateBeginEvent") + std::string methodName = std::string("on") + event->getClassName(); + + // Try to get the cached method for this specific event type + py::object method = getCachedMethod(methodName); + + if (method) { - py::object fct = self.attr(name.c_str()); - if (PyCallable_Check(fct.ptr())) { - bool isHandled = callScriptMethod(self, event, name); - if(isHandled) - event->setHandled(); - return; - } + // Found a specific handler for this event type + bool isHandled = callCachedMethod(method, event); + if (isHandled) + event->setHandled(); + return; } - /// Is the fallback method available. - bool isHandled = callScriptMethod(self, event, "onEvent"); - if(isHandled) - event->setHandled(); + // Fall back to the generic "onEvent" method if available + if (m_hasOnEvent) + { + bool isHandled = callCachedMethod(m_onEventMethod, event); + if (isHandled) + event->setHandled(); + } }); } diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h index d1bd91664..e61add156 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h @@ -22,6 +22,8 @@ #include #include +#include +#include namespace sofapython3 { @@ -41,6 +43,9 @@ class Controller_Trampoline : public Controller public: SOFA_CLASS(Controller_Trampoline, Controller); + Controller_Trampoline(); + ~Controller_Trampoline() override; + void init() override; void reinit() override; void draw(const sofa::core::visual::VisualParams* params) override; @@ -50,8 +55,34 @@ class Controller_Trampoline : public Controller std::string getClassName() const override; private: + /// Initializes the Python object cache (m_pySelf and method cache) + void initializePythonCache(); + + /// Returns a cached method if it exists, or an empty object if not + pybind11::object getCachedMethod(const std::string& methodName); + + /// Calls a cached Python method with the given event + bool callCachedMethod(const pybind11::object& method, sofa::core::objectmodel::Event* event); + + /// Legacy method for uncached calls (fallback) bool callScriptMethod(const pybind11::object& self, sofa::core::objectmodel::Event* event, const std::string& methodName); + + /// Cached Python self reference (avoids repeated py::cast(this)) + pybind11::object m_pySelf; + + /// Cache of Python method objects, keyed by method name + /// Stores the method object if it exists, or an empty object if checked and not found + std::unordered_map m_methodCache; + + /// Flag indicating whether the fallback "onEvent" method exists + bool m_hasOnEvent = false; + + /// Cached reference to the fallback "onEvent" method + pybind11::object m_onEventMethod; + + /// Flag indicating whether the cache has been initialized + bool m_cacheInitialized = false; }; void moduleAddController(pybind11::module &m); From 9b48fa0f25451c680edea5d3f36bbeb9aebfdec3 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Mon, 2 Feb 2026 16:15:09 +0900 Subject: [PATCH 2/7] add test --- .../benchmarks/emptyMultipleControllers.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/benchmarks/emptyMultipleControllers.py diff --git a/examples/benchmarks/emptyMultipleControllers.py b/examples/benchmarks/emptyMultipleControllers.py new file mode 100644 index 000000000..bcdf7a89e --- /dev/null +++ b/examples/benchmarks/emptyMultipleControllers.py @@ -0,0 +1,45 @@ +import Sofa + +g_nb_controllers = 10 +g_nb_steps = 10000 + +class EmptyController(Sofa.Core.Controller): + + def __init__(self, *args, **kwargs): + Sofa.Core.Controller.__init__(self, *args, **kwargs) + + # Default Events ********************************************* + def onAnimateBeginEvent(self, event): # called at each begin of animation step + # print(f"{self.name.value} : onAnimateBeginEvent") + pass + +def createScene(root): + root.dt = 0.01 + root.bbox = [[-1, -1, -1], [1, 1, 1]] + root.addObject('DefaultVisualManagerLoop') + root.addObject('DefaultAnimationLoop') + + + for i in range(g_nb_controllers): + root.addObject(EmptyController(name=f"MyEmptyController{i}")) + + +def main(): + root = Sofa.Core.Node("root") + createScene(root) + Sofa.Simulation.initRoot(root) + + # Import the time library + import time + start = time.time() + for iteration in range(g_nb_steps): + Sofa.Simulation.animate(root, root.dt.value) + end = time.time() + + print(f"Scene with {g_nb_controllers} controllers and {g_nb_steps} steps took {end - start} seconds.") + + print("End of simulation.") + + +if __name__ == '__main__': + main() From 811a454ce493b4a01a7c8c81784c36453c1489f5 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Tue, 10 Feb 2026 09:30:42 +0900 Subject: [PATCH 3/7] Invalidate Controller method cache on __setattr__ to support runtime handler reassignment --- .../Sofa/Core/Binding_Controller.cpp | 49 +++++++++++++++++++ .../Sofa/Core/Binding_Controller.h | 6 +++ 2 files changed, 55 insertions(+) diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp index 70a5d9d73..3ebffa164 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp @@ -117,6 +117,24 @@ bool Controller_Trampoline::callCachedMethod(const py::object& method, Event* ev return py::cast(result); } +void Controller_Trampoline::invalidateMethodCache(const std::string& methodName) +{ + if (!m_cacheInitialized) + return; + + if (methodName == "onEvent") + { + // Clear the dedicated fallback cache; handleEvent will re-resolve it lazily + m_hasOnEvent = false; + m_onEventMethod = py::object(); + m_onEventDirty = true; + return; + } + + // Remove the entry so getCachedMethod will re-resolve it on next call + m_methodCache.erase(methodName); +} + std::string Controller_Trampoline::getClassName() const { PythonEnvironment::gil acquire {"getClassName"}; @@ -203,6 +221,21 @@ void Controller_Trampoline::handleEvent(Event* event) return; } + // Re-resolve "onEvent" if it was invalidated by a __setattr__ call + if (m_onEventDirty) + { + m_onEventDirty = false; + if (py::hasattr(m_pySelf, "onEvent")) + { + py::object fct = m_pySelf.attr("onEvent"); + if (PyCallable_Check(fct.ptr())) + { + m_hasOnEvent = true; + m_onEventMethod = fct; + } + } + } + // Fall back to the generic "onEvent" method if available if (m_hasOnEvent) { @@ -249,6 +282,22 @@ void moduleAddController(py::module &m) { f.def("draw", [](Controller& self, sofa::core::visual::VisualParams* params){ self.draw(params); }, pybind11::return_value_policy::reference); + + // Override __setattr__ to invalidate the method cache when an "on*" attribute is reassigned + f.def("__setattr__", [](py::object self, const std::string& s, py::object value) { + // If the attribute starts with "on", invalidate the cached method + if (s.rfind("on", 0) == 0) + { + auto* trampoline = dynamic_cast(py::cast(self)); + if (trampoline) + { + trampoline->invalidateMethodCache(s); + } + } + + // Delegate to the base class __setattr__ + BindingBase::__setattr__(self, s, value); + }); } diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h index e61add156..d84bde4e8 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h @@ -54,6 +54,9 @@ class Controller_Trampoline : public Controller std::string getClassName() const override; + /// Invalidates a specific entry in the method cache (called when a user reassigns an on* attribute) + void invalidateMethodCache(const std::string& methodName); + private: /// Initializes the Python object cache (m_pySelf and method cache) void initializePythonCache(); @@ -81,6 +84,9 @@ class Controller_Trampoline : public Controller /// Cached reference to the fallback "onEvent" method pybind11::object m_onEventMethod; + /// Flag indicating whether the "onEvent" fallback needs re-resolution + bool m_onEventDirty = false; + /// Flag indicating whether the cache has been initialized bool m_cacheInitialized = false; }; From 1c41d8061d1a549d7d8f37814453643c232370d5 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Tue, 10 Feb 2026 09:51:56 +0900 Subject: [PATCH 4/7] add test on reassigment --- examples/tests/testReassignmentController.py | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 examples/tests/testReassignmentController.py diff --git a/examples/tests/testReassignmentController.py b/examples/tests/testReassignmentController.py new file mode 100644 index 000000000..827a80d0a --- /dev/null +++ b/examples/tests/testReassignmentController.py @@ -0,0 +1,46 @@ +import Sofa + +g_nb_controllers = 3 +g_controllers = [] + +class TestReassignmentController(Sofa.Core.Controller): + + def __init__(self, *args, **kwargs): + Sofa.Core.Controller.__init__(self, *args, **kwargs) + + def onAnimateBeginEvent(self, event): + print(f"{self.name.value} : onAnimateBeginEvent") + pass + + def modifiedAnimateBeginEvent(self, event): + print(f"{self.name.value} : modifiedAnimateBeginEvent") + pass + +def createScene(root): + root.dt = 0.01 + root.bbox = [[-1, -1, -1], [1, 1, 1]] + root.addObject('DefaultVisualManagerLoop') + root.addObject('DefaultAnimationLoop') + + for i in range(g_nb_controllers): + controller = root.addObject(TestReassignmentController(name=f"Controller{i}")) + g_controllers.append(controller) + + +def main(): + root = Sofa.Core.Node("root") + createScene(root) + Sofa.Simulation.initRoot(root) + + # one step with the "fixed" implementation of onAnimateBeginEvent + Sofa.Simulation.animate(root, root.dt.value) # should print "ControllerX : onAnimateBeginEvent" + + # reassign onAnimateBeginEvent method + for controller in g_controllers: + controller.onAnimateBeginEvent = controller.modifiedAnimateBeginEvent + + Sofa.Simulation.animate(root, root.dt.value) # should print "ControllerX : modifiedAnimateBeginEvent" + + +if __name__ == '__main__': + main() From 8579a1bbcc42ec24d0be571056f145c82f3b4df8 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Fri, 27 Feb 2026 10:43:35 +0900 Subject: [PATCH 5/7] remove unnessary test --- bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp index 3ebffa164..a37997e00 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp @@ -139,8 +139,7 @@ std::string Controller_Trampoline::getClassName() const { PythonEnvironment::gil acquire {"getClassName"}; - // Use cached self if available, otherwise cast - if (m_cacheInitialized && m_pySelf) + if (m_pySelf) { return py::str(py::type::of(m_pySelf).attr("__name__")); } From 10438e3ceab5abd07edfe8608ce9130019568556 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Fri, 27 Feb 2026 10:46:03 +0900 Subject: [PATCH 6/7] check if the value is callable --- .../Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp index a37997e00..218f6a828 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp @@ -284,8 +284,8 @@ void moduleAddController(py::module &m) { // Override __setattr__ to invalidate the method cache when an "on*" attribute is reassigned f.def("__setattr__", [](py::object self, const std::string& s, py::object value) { - // If the attribute starts with "on", invalidate the cached method - if (s.rfind("on", 0) == 0) + // If the attribute starts with "on" and the new value is callable, invalidate the cached method + if (s.rfind("on", 0) == 0 && PyCallable_Check(value.ptr())) { auto* trampoline = dynamic_cast(py::cast(self)); if (trampoline) From 486c35c1361860bae600100e183912e054abdef6 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Fri, 27 Feb 2026 10:54:27 +0900 Subject: [PATCH 7/7] Unify onEvent into m_methodCache --- .../Sofa/Core/Binding_Controller.cpp | 51 ++----------------- .../Sofa/Core/Binding_Controller.h | 11 +--- 2 files changed, 6 insertions(+), 56 deletions(-) diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp index 218f6a828..fb4f01521 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp @@ -47,7 +47,6 @@ Controller_Trampoline::~Controller_Trampoline() { PythonEnvironment::gil acquire {"~Controller_Trampoline"}; m_methodCache.clear(); - m_onEventMethod = py::object(); m_pySelf = py::object(); } } @@ -60,16 +59,8 @@ void Controller_Trampoline::initializePythonCache() // Must be called with GIL held m_pySelf = py::cast(this); - // Cache the fallback "onEvent" method if it exists - if (py::hasattr(m_pySelf, "onEvent")) - { - py::object fct = m_pySelf.attr("onEvent"); - if (PyCallable_Check(fct.ptr())) - { - m_hasOnEvent = true; - m_onEventMethod = fct; - } - } + // Pre-cache the fallback "onEvent" method via the standard cache path + getCachedMethod("onEvent"); m_cacheInitialized = true; } @@ -122,15 +113,6 @@ void Controller_Trampoline::invalidateMethodCache(const std::string& methodName) if (!m_cacheInitialized) return; - if (methodName == "onEvent") - { - // Clear the dedicated fallback cache; handleEvent will re-resolve it lazily - m_hasOnEvent = false; - m_onEventMethod = py::object(); - m_onEventDirty = true; - return; - } - // Remove the entry so getCachedMethod will re-resolve it on next call m_methodCache.erase(methodName); } @@ -208,39 +190,16 @@ void Controller_Trampoline::handleEvent(Event* event) // Build the event-specific method name (e.g., "onAnimateBeginEvent") std::string methodName = std::string("on") + event->getClassName(); - // Try to get the cached method for this specific event type + // Try the event-specific method first, then fall back to generic "onEvent" py::object method = getCachedMethod(methodName); + if (!method) + method = getCachedMethod("onEvent"); if (method) { - // Found a specific handler for this event type bool isHandled = callCachedMethod(method, event); if (isHandled) event->setHandled(); - return; - } - - // Re-resolve "onEvent" if it was invalidated by a __setattr__ call - if (m_onEventDirty) - { - m_onEventDirty = false; - if (py::hasattr(m_pySelf, "onEvent")) - { - py::object fct = m_pySelf.attr("onEvent"); - if (PyCallable_Check(fct.ptr())) - { - m_hasOnEvent = true; - m_onEventMethod = fct; - } - } - } - - // Fall back to the generic "onEvent" method if available - if (m_hasOnEvent) - { - bool isHandled = callCachedMethod(m_onEventMethod, event); - if (isHandled) - event->setHandled(); } }); } diff --git a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h index d84bde4e8..3deb345df 100644 --- a/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h +++ b/bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h @@ -74,19 +74,10 @@ class Controller_Trampoline : public Controller /// Cached Python self reference (avoids repeated py::cast(this)) pybind11::object m_pySelf; - /// Cache of Python method objects, keyed by method name + /// Cache of Python method objects, keyed by method name (including "onEvent" fallback) /// Stores the method object if it exists, or an empty object if checked and not found std::unordered_map m_methodCache; - /// Flag indicating whether the fallback "onEvent" method exists - bool m_hasOnEvent = false; - - /// Cached reference to the fallback "onEvent" method - pybind11::object m_onEventMethod; - - /// Flag indicating whether the "onEvent" fallback needs re-resolution - bool m_onEventDirty = false; - /// Flag indicating whether the cache has been initialized bool m_cacheInitialized = false; };