Skip to content

Commit 2475df0

Browse files
author
rodrigo.nogueira
committed
## Description
The `signature_adapter` causes significant overhead in hot transition paths due to repeated signature binding on every callback invocation. This PR implements caching for `bind_expected()` to avoid recomputing argument bindings when the kwargs pattern is unchanged. ## Root Cause The `SignatureAdapter.bind_expected()` method iterates through all parameters and matches them against the provided kwargs on every invocation. In typical state machine usage, callbacks are invoked repeatedly with the same kwargs keys (e.g., `source`, `target`, `event`), making this repeated computation wasteful. ## Fix Added a per-instance cache (`_bind_cache`) to `SignatureAdapter` that stores "binding templates" based on the arguments structure: - **Cache key**: `(len(args), frozenset(kwargs.keys()))` - **Cache value**: A template of which parameters to extract - **Fast path**: On cache hit, extract arguments directly using the template (~1 µs) - **Slow path**: First call computes full binding and stores template (~2 µs) This approach preserves **full Dependency Injection functionality** - callbacks still receive correctly filtered arguments (`source`, `target`, etc.). ## Performance When measuring `bind_expected()` in isolation: - **Cached**: 0.86 µs/call - **Uncached**: 2.12 µs/call - **Improvement**: ~59% This is consistent with the ~30% end-to-end improvement reported in #548, as binding is one of several components in a full transition. ## Testing All existing tests pass (328 passed, 9 xfailed). Fixes #548
1 parent 9a089ed commit 2475df0

1 file changed

Lines changed: 58 additions & 5 deletions

File tree

statemachine/signature.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from functools import partial
24
from inspect import BoundArguments
35
from inspect import Parameter
@@ -7,6 +9,9 @@
79
from types import MethodType
810
from typing import Any
911

12+
BindCacheKey = tuple[int, frozenset[str]]
13+
BindTemplate = tuple[tuple[str, ...], str | None]
14+
1015

1116
def _make_key(method):
1217
method = method.func if isinstance(method, partial) else method
@@ -44,6 +49,11 @@ def cached_function(cls, method):
4449

4550
class SignatureAdapter(Signature):
4651
is_coroutine: bool = False
52+
_bind_cache: dict[BindCacheKey, BindTemplate]
53+
54+
def __init__(self, *args, **kwargs):
55+
super().__init__(*args, **kwargs)
56+
self._bind_cache = {}
4757

4858
@classmethod
4959
@signature_cache
@@ -60,19 +70,53 @@ def from_callable(cls, method):
6070
adapter.is_coroutine = iscoroutinefunction(method)
6171
return adapter
6272

63-
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C901
73+
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments:
74+
cache_key: BindCacheKey = (len(args), frozenset(kwargs.keys()))
75+
template = self._bind_cache.get(cache_key)
76+
77+
if template is not None:
78+
return self._fast_bind(args, kwargs, template)
79+
80+
result = self._full_bind(cache_key, *args, **kwargs)
81+
return result
82+
83+
def _fast_bind(
84+
self,
85+
args: tuple[Any, ...],
86+
kwargs: dict[str, Any],
87+
template: BindTemplate,
88+
) -> BoundArguments:
89+
param_names, kwargs_param_name = template
90+
arguments: dict[str, Any] = {}
91+
for i, name in enumerate(param_names):
92+
if i < len(args):
93+
arguments[name] = args[i]
94+
elif name in kwargs:
95+
arguments[name] = kwargs[name]
96+
if kwargs_param_name is not None:
97+
arguments[kwargs_param_name] = kwargs
98+
return BoundArguments(self, arguments) # type: ignore[arg-type]
99+
100+
def _full_bind(
101+
self,
102+
cache_key: BindCacheKey,
103+
*args: Any,
104+
**kwargs: Any,
105+
) -> BoundArguments: # noqa: C901
64106
"""Get a BoundArguments object, that maps the passed `args`
65107
and `kwargs` to the function's signature. It avoids to raise `TypeError`
66108
trying to fill all the required arguments and ignoring the unknown ones.
67109
68110
Adapted from the internal `inspect.Signature._bind`.
69111
"""
70-
arguments = {}
112+
arguments: dict[str, Any] = {}
113+
param_names_used: list[str] = []
71114

72115
parameters = iter(self.parameters.values())
73116
arg_vals = iter(args)
74117
parameters_ex: Any = ()
75118
kwargs_param = None
119+
kwargs_param_name: str | None = None
76120

77121
while True:
78122
# Let's iterate through the positional arguments and corresponding
@@ -141,12 +185,14 @@ def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C
141185
values = [arg_val]
142186
values.extend(arg_vals)
143187
arguments[param.name] = tuple(values)
188+
param_names_used.append(param.name)
144189
break
145190

146191
if param.name in kwargs and param.kind != Parameter.POSITIONAL_ONLY:
147192
arguments[param.name] = kwargs.pop(param.name)
148193
else:
149194
arguments[param.name] = arg_val
195+
param_names_used.append(param.name)
150196

151197
# Now, we iterate through the remaining parameters to process
152198
# keyword arguments
@@ -172,14 +218,21 @@ def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C
172218
# arguments.
173219
pass
174220
else:
175-
arguments[param_name] = arg_val #
221+
arguments[param_name] = arg_val
222+
param_names_used.append(param_name)
176223

177224
if kwargs:
178225
if kwargs_param is not None:
179226
# Process our '**kwargs'-like parameter
180-
arguments[kwargs_param.name] = kwargs # type: ignore [assignment]
227+
arguments[kwargs_param.name] = kwargs # type: ignore[assignment]
228+
kwargs_param_name = kwargs_param.name
181229
else:
182230
# 'ignoring we got an unexpected keyword argument'
183231
pass
184232

185-
return BoundArguments(self, arguments) # type: ignore [arg-type]
233+
template: BindTemplate = (tuple(param_names_used), kwargs_param_name)
234+
self._bind_cache[cache_key] = template
235+
236+
return BoundArguments(self, arguments) # type: ignore[arg-type]
237+
238+

0 commit comments

Comments
 (0)