Skip to content

Commit 1fd4b7e

Browse files
rodrigobnogueirarodrigo.nogueira
andauthored
perf: cache signature binding results (#550)
* ## 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 * fix: Ensure `**kwargs` only contains unmatched arguments during signature binding by filtering out named parameters. * chore: Remove extra empty line in test file. --------- Co-authored-by: rodrigo.nogueira <rodrigo.nogueira@prf.gov.br>
1 parent bf06293 commit 1fd4b7e

3 files changed

Lines changed: 99 additions & 5 deletions

File tree

docs/authors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* [Rafael Rêgo](mailto:crafards@gmail.com)
1111
* [Raphael Schrader](mailto:raphael@schradercloud.de)
1212
* [João S. O. Bueno](mailto:gwidion@gmail.com)
13+
* [Rodrigo Nogueira](mailto:rodrigo.b.nogueira@gmail.com)
1314

1415

1516
## Scaffolding

statemachine/signature.py

Lines changed: 63 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
@@ -6,6 +8,12 @@
68
from itertools import chain
79
from types import MethodType
810
from typing import Any
11+
from typing import FrozenSet
12+
from typing import Optional
13+
from typing import Tuple
14+
15+
BindCacheKey = Tuple[int, FrozenSet[str]]
16+
BindTemplate = Tuple[Tuple[str, ...], Optional[str]] # noqa: UP007
917

1018

1119
def _make_key(method):
@@ -44,6 +52,11 @@ def cached_function(cls, method):
4452

4553
class SignatureAdapter(Signature):
4654
is_coroutine: bool = False
55+
_bind_cache: dict[BindCacheKey, BindTemplate]
56+
57+
def __init__(self, *args, **kwargs):
58+
super().__init__(*args, **kwargs)
59+
self._bind_cache = {}
4760

4861
@classmethod
4962
@signature_cache
@@ -60,19 +73,57 @@ def from_callable(cls, method):
6073
adapter.is_coroutine = iscoroutinefunction(method)
6174
return adapter
6275

63-
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C901
76+
def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments:
77+
cache_key: BindCacheKey = (len(args), frozenset(kwargs.keys()))
78+
template = self._bind_cache.get(cache_key)
79+
80+
if template is not None:
81+
return self._fast_bind(args, kwargs, template)
82+
83+
result = self._full_bind(cache_key, *args, **kwargs)
84+
return result
85+
86+
def _fast_bind(
87+
self,
88+
args: tuple[Any, ...],
89+
kwargs: dict[str, Any],
90+
template: BindTemplate,
91+
) -> BoundArguments:
92+
param_names, kwargs_param_name = template
93+
arguments: dict[str, Any] = {}
94+
95+
for i, name in enumerate(param_names):
96+
if i < len(args):
97+
arguments[name] = args[i]
98+
else:
99+
arguments[name] = kwargs.get(name)
100+
101+
if kwargs_param_name is not None:
102+
matched = set(param_names)
103+
arguments[kwargs_param_name] = {k: v for k, v in kwargs.items() if k not in matched}
104+
105+
return BoundArguments(self, arguments) # type: ignore[arg-type]
106+
107+
def _full_bind( # noqa: C901
108+
self,
109+
cache_key: BindCacheKey,
110+
*args: Any,
111+
**kwargs: Any,
112+
) -> BoundArguments:
64113
"""Get a BoundArguments object, that maps the passed `args`
65114
and `kwargs` to the function's signature. It avoids to raise `TypeError`
66115
trying to fill all the required arguments and ignoring the unknown ones.
67116
68117
Adapted from the internal `inspect.Signature._bind`.
69118
"""
70-
arguments = {}
119+
arguments: dict[str, Any] = {}
120+
param_names_used: list[str] = []
71121

72122
parameters = iter(self.parameters.values())
73123
arg_vals = iter(args)
74124
parameters_ex: Any = ()
75125
kwargs_param = None
126+
kwargs_param_name: str | None = None
76127

77128
while True:
78129
# Let's iterate through the positional arguments and corresponding
@@ -140,12 +191,14 @@ def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C
140191
values = [arg_val]
141192
values.extend(arg_vals)
142193
arguments[param.name] = tuple(values)
194+
param_names_used.append(param.name)
143195
break
144196

145197
if param.name in kwargs and param.kind != Parameter.POSITIONAL_ONLY:
146198
arguments[param.name] = kwargs.pop(param.name)
147199
else:
148200
arguments[param.name] = arg_val
201+
param_names_used.append(param.name)
149202

150203
# Now, we iterate through the remaining parameters to process
151204
# keyword arguments
@@ -171,14 +224,19 @@ def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C
171224
# arguments.
172225
pass
173226
else:
174-
arguments[param_name] = arg_val #
227+
arguments[param_name] = arg_val
228+
param_names_used.append(param_name)
175229

176230
if kwargs:
177231
if kwargs_param is not None:
178232
# Process our '**kwargs'-like parameter
179-
arguments[kwargs_param.name] = kwargs # type: ignore [assignment]
233+
arguments[kwargs_param.name] = kwargs # type: ignore[assignment]
234+
kwargs_param_name = kwargs_param.name
180235
else:
181236
# 'ignoring we got an unexpected keyword argument'
182237
pass
183238

184-
return BoundArguments(self, arguments) # type: ignore [arg-type]
239+
template: BindTemplate = (tuple(param_names_used), kwargs_param_name)
240+
self._bind_cache[cache_key] = template
241+
242+
return BoundArguments(self, arguments) # type: ignore[arg-type]

tests/test_signature.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import pytest
55
from statemachine.dispatcher import callable_method
6+
from statemachine.signature import SignatureAdapter
67

78

89
def single_positional_param(a):
@@ -161,3 +162,37 @@ def test_support_for_partial(self):
161162

162163
assert wrapped_func("A", "B") == ("A", "B", "activated")
163164
assert wrapped_func.__name__ == positional_and_kw_arguments.__name__
165+
166+
167+
def named_and_kwargs(source, **kwargs):
168+
return source, kwargs
169+
170+
171+
class TestCachedBindExpected:
172+
"""Tests that exercise the cache fast-path by calling the same
173+
wrapped function twice with the same argument shape."""
174+
175+
def setup_method(self):
176+
SignatureAdapter.from_callable.clear_cache()
177+
178+
def test_named_param_not_leaked_into_kwargs(self):
179+
"""Named params should not appear in the **kwargs dict on cache hit."""
180+
wrapped = callable_method(named_and_kwargs)
181+
182+
# 1st call: cache miss -> _full_bind
183+
result1 = wrapped(source="A", target="B", event="go")
184+
assert result1 == ("A", {"target": "B", "event": "go"})
185+
186+
# 2nd call: cache hit -> _fast_bind
187+
result2 = wrapped(source="X", target="Y", event="stop")
188+
assert result2 == ("X", {"target": "Y", "event": "stop"})
189+
190+
def test_kwargs_only_receives_unmatched_keys_with_positional(self):
191+
"""When mixing positional and keyword args with **kwargs."""
192+
wrapped = callable_method(named_and_kwargs)
193+
194+
result1 = wrapped("A", target="B")
195+
assert result1 == ("A", {"target": "B"})
196+
197+
result2 = wrapped("X", target="Y")
198+
assert result2 == ("X", {"target": "Y"})

0 commit comments

Comments
 (0)