Skip to content

Commit 85bc54f

Browse files
authored
[Controller] POC: overall speedup (#573)
* 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. * add test * Invalidate Controller method cache on __setattr__ to support runtime handler reassignment * add test on reassigment * remove unnessary test * check if the value is callable * Unify onEvent into m_methodCache
1 parent 341aa44 commit 85bc54f

File tree

4 files changed

+243
-18
lines changed

4 files changed

+243
-18
lines changed

bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.cpp

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,96 @@ namespace sofapython3
3838
using sofa::core::objectmodel::Event;
3939
using sofa::core::objectmodel::BaseObject;
4040

41+
Controller_Trampoline::Controller_Trampoline() = default;
42+
43+
Controller_Trampoline::~Controller_Trampoline()
44+
{
45+
// Clean up Python objects while holding the GIL
46+
if (m_cacheInitialized)
47+
{
48+
PythonEnvironment::gil acquire {"~Controller_Trampoline"};
49+
m_methodCache.clear();
50+
m_pySelf = py::object();
51+
}
52+
}
53+
54+
void Controller_Trampoline::initializePythonCache()
55+
{
56+
if (m_cacheInitialized)
57+
return;
58+
59+
// Must be called with GIL held
60+
m_pySelf = py::cast(this);
61+
62+
// Pre-cache the fallback "onEvent" method via the standard cache path
63+
getCachedMethod("onEvent");
64+
65+
m_cacheInitialized = true;
66+
}
67+
68+
py::object Controller_Trampoline::getCachedMethod(const std::string& methodName)
69+
{
70+
// Must be called with GIL held and cache initialized
71+
72+
// Check if we've already looked up this method
73+
auto it = m_methodCache.find(methodName);
74+
if (it != m_methodCache.end())
75+
{
76+
return it->second;
77+
}
78+
79+
// First time looking up this method - check if it exists
80+
py::object method;
81+
if (py::hasattr(m_pySelf, methodName.c_str()))
82+
{
83+
py::object fct = m_pySelf.attr(methodName.c_str());
84+
if (PyCallable_Check(fct.ptr()))
85+
{
86+
method = fct;
87+
}
88+
}
89+
90+
// Cache the result (even if empty, to avoid repeated hasattr checks)
91+
m_methodCache[methodName] = method;
92+
return method;
93+
}
94+
95+
bool Controller_Trampoline::callCachedMethod(const py::object& method, Event* event)
96+
{
97+
// Must be called with GIL held
98+
if (f_printLog.getValue())
99+
{
100+
std::string eventStr = py::str(PythonFactory::toPython(event));
101+
msg_info() << "on" << event->getClassName() << " " << eventStr;
102+
}
103+
104+
py::object result = method(PythonFactory::toPython(event));
105+
if (result.is_none())
106+
return false;
107+
108+
return py::cast<bool>(result);
109+
}
110+
111+
void Controller_Trampoline::invalidateMethodCache(const std::string& methodName)
112+
{
113+
if (!m_cacheInitialized)
114+
return;
115+
116+
// Remove the entry so getCachedMethod will re-resolve it on next call
117+
m_methodCache.erase(methodName);
118+
}
119+
41120
std::string Controller_Trampoline::getClassName() const
42121
{
43122
PythonEnvironment::gil acquire {"getClassName"};
44-
// Get the actual class name from python.
45-
return py::str(py::cast(this).get_type().attr("__name__"));
123+
124+
if (m_pySelf)
125+
{
126+
return py::str(py::type::of(m_pySelf).attr("__name__"));
127+
}
128+
129+
// Fallback for when cache isn't initialized yet
130+
return py::str(py::type::of(py::cast(this)).attr("__name__"));
46131
}
47132

48133
void Controller_Trampoline::draw(const sofa::core::visual::VisualParams* params)
@@ -55,6 +140,8 @@ void Controller_Trampoline::draw(const sofa::core::visual::VisualParams* params)
55140
void Controller_Trampoline::init()
56141
{
57142
PythonEnvironment::executePython(this, [this](){
143+
// Initialize the Python object cache on first init
144+
initializePythonCache();
58145
PYBIND11_OVERLOAD(void, Controller, init, );
59146
});
60147
}
@@ -92,25 +179,28 @@ bool Controller_Trampoline::callScriptMethod(
92179

93180
void Controller_Trampoline::handleEvent(Event* event)
94181
{
95-
PythonEnvironment::executePython(this, [this,event](){
96-
py::object self = py::cast(this);
97-
std::string name = std::string("on")+event->getClassName();
98-
/// Is there a method with this name in the class ?
99-
if( py::hasattr(self, name.c_str()) )
182+
PythonEnvironment::executePython(this, [this, event](){
183+
// Ensure cache is initialized (in case init() wasn't called or
184+
// handleEvent is called before init)
185+
if (!m_cacheInitialized)
100186
{
101-
py::object fct = self.attr(name.c_str());
102-
if (PyCallable_Check(fct.ptr())) {
103-
bool isHandled = callScriptMethod(self, event, name);
104-
if(isHandled)
105-
event->setHandled();
106-
return;
107-
}
187+
initializePythonCache();
108188
}
109189

110-
/// Is the fallback method available.
111-
bool isHandled = callScriptMethod(self, event, "onEvent");
112-
if(isHandled)
113-
event->setHandled();
190+
// Build the event-specific method name (e.g., "onAnimateBeginEvent")
191+
std::string methodName = std::string("on") + event->getClassName();
192+
193+
// Try the event-specific method first, then fall back to generic "onEvent"
194+
py::object method = getCachedMethod(methodName);
195+
if (!method)
196+
method = getCachedMethod("onEvent");
197+
198+
if (method)
199+
{
200+
bool isHandled = callCachedMethod(method, event);
201+
if (isHandled)
202+
event->setHandled();
203+
}
114204
});
115205
}
116206

@@ -150,6 +240,22 @@ void moduleAddController(py::module &m) {
150240
f.def("draw", [](Controller& self, sofa::core::visual::VisualParams* params){
151241
self.draw(params);
152242
}, pybind11::return_value_policy::reference);
243+
244+
// Override __setattr__ to invalidate the method cache when an "on*" attribute is reassigned
245+
f.def("__setattr__", [](py::object self, const std::string& s, py::object value) {
246+
// If the attribute starts with "on" and the new value is callable, invalidate the cached method
247+
if (s.rfind("on", 0) == 0 && PyCallable_Check(value.ptr()))
248+
{
249+
auto* trampoline = dynamic_cast<Controller_Trampoline*>(py::cast<Controller*>(self));
250+
if (trampoline)
251+
{
252+
trampoline->invalidateMethodCache(s);
253+
}
254+
}
255+
256+
// Delegate to the base class __setattr__
257+
BindingBase::__setattr__(self, s, value);
258+
});
153259
}
154260

155261

bindings/Sofa/src/SofaPython3/Sofa/Core/Binding_Controller.h

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
#include <pybind11/pybind11.h>
2424
#include <sofa/core/behavior/BaseController.h>
25+
#include <unordered_map>
26+
#include <string>
2527

2628
namespace sofapython3 {
2729

@@ -41,6 +43,9 @@ class Controller_Trampoline : public Controller
4143
public:
4244
SOFA_CLASS(Controller_Trampoline, Controller);
4345

46+
Controller_Trampoline();
47+
~Controller_Trampoline() override;
48+
4449
void init() override;
4550
void reinit() override;
4651
void draw(const sofa::core::visual::VisualParams* params) override;
@@ -49,9 +54,32 @@ class Controller_Trampoline : public Controller
4954

5055
std::string getClassName() const override;
5156

57+
/// Invalidates a specific entry in the method cache (called when a user reassigns an on* attribute)
58+
void invalidateMethodCache(const std::string& methodName);
59+
5260
private:
61+
/// Initializes the Python object cache (m_pySelf and method cache)
62+
void initializePythonCache();
63+
64+
/// Returns a cached method if it exists, or an empty object if not
65+
pybind11::object getCachedMethod(const std::string& methodName);
66+
67+
/// Calls a cached Python method with the given event
68+
bool callCachedMethod(const pybind11::object& method, sofa::core::objectmodel::Event* event);
69+
70+
/// Legacy method for uncached calls (fallback)
5371
bool callScriptMethod(const pybind11::object& self, sofa::core::objectmodel::Event* event,
5472
const std::string& methodName);
73+
74+
/// Cached Python self reference (avoids repeated py::cast(this))
75+
pybind11::object m_pySelf;
76+
77+
/// Cache of Python method objects, keyed by method name (including "onEvent" fallback)
78+
/// Stores the method object if it exists, or an empty object if checked and not found
79+
std::unordered_map<std::string, pybind11::object> m_methodCache;
80+
81+
/// Flag indicating whether the cache has been initialized
82+
bool m_cacheInitialized = false;
5583
};
5684

5785
void moduleAddController(pybind11::module &m);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Sofa
2+
3+
g_nb_controllers = 10
4+
g_nb_steps = 10000
5+
6+
class EmptyController(Sofa.Core.Controller):
7+
8+
def __init__(self, *args, **kwargs):
9+
Sofa.Core.Controller.__init__(self, *args, **kwargs)
10+
11+
# Default Events *********************************************
12+
def onAnimateBeginEvent(self, event): # called at each begin of animation step
13+
# print(f"{self.name.value} : onAnimateBeginEvent")
14+
pass
15+
16+
def createScene(root):
17+
root.dt = 0.01
18+
root.bbox = [[-1, -1, -1], [1, 1, 1]]
19+
root.addObject('DefaultVisualManagerLoop')
20+
root.addObject('DefaultAnimationLoop')
21+
22+
23+
for i in range(g_nb_controllers):
24+
root.addObject(EmptyController(name=f"MyEmptyController{i}"))
25+
26+
27+
def main():
28+
root = Sofa.Core.Node("root")
29+
createScene(root)
30+
Sofa.Simulation.initRoot(root)
31+
32+
# Import the time library
33+
import time
34+
start = time.time()
35+
for iteration in range(g_nb_steps):
36+
Sofa.Simulation.animate(root, root.dt.value)
37+
end = time.time()
38+
39+
print(f"Scene with {g_nb_controllers} controllers and {g_nb_steps} steps took {end - start} seconds.")
40+
41+
print("End of simulation.")
42+
43+
44+
if __name__ == '__main__':
45+
main()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Sofa
2+
3+
g_nb_controllers = 3
4+
g_controllers = []
5+
6+
class TestReassignmentController(Sofa.Core.Controller):
7+
8+
def __init__(self, *args, **kwargs):
9+
Sofa.Core.Controller.__init__(self, *args, **kwargs)
10+
11+
def onAnimateBeginEvent(self, event):
12+
print(f"{self.name.value} : onAnimateBeginEvent")
13+
pass
14+
15+
def modifiedAnimateBeginEvent(self, event):
16+
print(f"{self.name.value} : modifiedAnimateBeginEvent")
17+
pass
18+
19+
def createScene(root):
20+
root.dt = 0.01
21+
root.bbox = [[-1, -1, -1], [1, 1, 1]]
22+
root.addObject('DefaultVisualManagerLoop')
23+
root.addObject('DefaultAnimationLoop')
24+
25+
for i in range(g_nb_controllers):
26+
controller = root.addObject(TestReassignmentController(name=f"Controller{i}"))
27+
g_controllers.append(controller)
28+
29+
30+
def main():
31+
root = Sofa.Core.Node("root")
32+
createScene(root)
33+
Sofa.Simulation.initRoot(root)
34+
35+
# one step with the "fixed" implementation of onAnimateBeginEvent
36+
Sofa.Simulation.animate(root, root.dt.value) # should print "ControllerX : onAnimateBeginEvent"
37+
38+
# reassign onAnimateBeginEvent method
39+
for controller in g_controllers:
40+
controller.onAnimateBeginEvent = controller.modifiedAnimateBeginEvent
41+
42+
Sofa.Simulation.animate(root, root.dt.value) # should print "ControllerX : modifiedAnimateBeginEvent"
43+
44+
45+
if __name__ == '__main__':
46+
main()

0 commit comments

Comments
 (0)