Skip to content

Commit 55f7d78

Browse files
Korijnclaude
andauthored
Cut per-call overhead in the proxy traps (#192)
Traps are the per-read/per-write entry points of observ, so their call overhead is felt in every interaction with a proxy. This trims it: - Only take **kwargs where the wrapped method actually accepts keyword arguments (list.sort; dict.update is handled explicitly), since **kwargs allocates a dict on every call. Iterator and deleter traps take no arguments at all, matching the methods they wrap - Call proxy() with positional arguments; keyword calls are ~25% slower - Inline Dep.depend() as dep_stack[-1].add_dep(...), saving a method call plus a redundant stack check per tracked read, with Dep.stack bound to a closure variable instead of a global attribute lookup - Hoist the readonly-bound partial(proxy, ...) pair out of iterate_trap, instead of constructing a partial on every call - Replace operator.xor with an inline != in write_key_trap - Bind target.get once in write_dict_trap The dict update/__ior__ trap now mirrors dict.update's signature exactly (one positional-only argument plus **kwargs); it used to silently ignore extra positional arguments that a plain dict rejects with a TypeError. The new tests/test_trap_signatures.py pins down argument positioning and resolution through the traps and checks TypeError parity with plain containers, so the tightened signatures can't drift. Measured on bench/test_reads.py: dict getitem 30.8us -> 20.5us, list getitem 28.8us -> 21.2us, tracked dict reads 74.9us -> 52.8us. Claude-Session: https://claude.ai/code/session_01UyALuqgF1ZZ3Lj88FzwGVc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3be69c4 commit 55f7d78

2 files changed

Lines changed: 273 additions & 41 deletions

File tree

observ/traps.py

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
from functools import partial, wraps
4-
from operator import xor
54
from typing import TYPE_CHECKING, Any
65

76
from .dep import Dep
@@ -19,6 +18,22 @@
1918
# given container type
2019
TrapFactory = Callable[[str, type], Trap]
2120

21+
# Performance notes that apply to all trap factories below. Traps are
22+
# the per-read/per-write entry points of observ, so their call overhead
23+
# is felt in every interaction with a proxy:
24+
# - The Dep.stack list (a ClassVar that is only ever mutated, never
25+
# reassigned) is bound to a closure variable, which is cheaper to
26+
# load than a global plus an attribute
27+
# - Dep.depend() is inlined as dep_stack[-1].add_dep(dep), which saves
28+
# a method call plus a second check of the stack per tracked read
29+
# - proxy() is called with positional arguments; keyword arguments
30+
# make a call measurably slower
31+
# - Trap signatures only take **kwargs when a wrapped method actually
32+
# accepts keyword arguments (only list.sort does; dict.update is
33+
# handled explicitly), because **kwargs allocates a dict on every
34+
# call. The wrapped plain containers raise TypeError for unexpected
35+
# keyword arguments, and so do the traps
36+
2237

2338
class ReadonlyError(Exception):
2439
"""
@@ -30,54 +45,63 @@ class ReadonlyError(Exception):
3045

3146
def read_trap(method: str, obj_cls: type) -> Trap:
3247
fn = getattr(obj_cls, method)
48+
dep_stack = Dep.stack
3349

3450
@wraps(fn)
35-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
36-
if Dep.stack:
37-
self.__dep__.depend()
38-
value = fn(self.__target__, *args, **kwargs)
51+
def trap(self: Proxy[Any], *args: Any) -> Any:
52+
if dep_stack:
53+
dep_stack[-1].add_dep(self.__dep__)
54+
value = fn(self.__target__, *args)
3955
if self.__shallow__:
4056
return value
41-
return proxy(value, readonly=self.__readonly__)
57+
return proxy(value, self.__readonly__)
4258

4359
return trap
4460

4561

62+
# The proxy function with the readonly flag pre-bound, for both flag
63+
# values, so that iterate_trap doesn't construct a partial per call
64+
_PROXY_PARTIAL = partial(proxy, readonly=False)
65+
_PROXY_PARTIAL_READONLY = partial(proxy, readonly=True)
66+
67+
4668
def iterate_trap(method: str, obj_cls: type) -> Trap:
4769
fn = getattr(obj_cls, method)
4870
# Hoist the method check out of the trap
4971
is_items = method == "items"
72+
dep_stack = Dep.stack
5073

74+
# The wrapped iterator methods (items, values, keys, __iter__,
75+
# __reversed__) take no arguments at all
5176
@wraps(fn)
52-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
53-
if Dep.stack:
54-
self.__dep__.depend()
55-
iterator = fn(self.__target__, *args, **kwargs)
77+
def trap(self: Proxy[Any]) -> Any:
78+
if dep_stack:
79+
dep_stack[-1].add_dep(self.__dep__)
80+
iterator = fn(self.__target__)
5681
if self.__shallow__:
5782
return iterator
83+
readonly = self.__readonly__
5884
if is_items:
59-
return (
60-
(key, proxy(value, readonly=self.__readonly__))
61-
for key, value in iterator
62-
)
85+
return ((key, proxy(value, readonly)) for key, value in iterator)
6386
else:
64-
proxied = partial(proxy, readonly=self.__readonly__)
87+
proxied = _PROXY_PARTIAL_READONLY if readonly else _PROXY_PARTIAL
6588
return map(proxied, iterator)
6689

6790
return trap
6891

6992

7093
def read_key_trap(method: str, obj_cls: type) -> Trap:
7194
fn = getattr(obj_cls, method)
95+
dep_stack = Dep.stack
7296

7397
@wraps(fn)
74-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
75-
if Dep.stack:
76-
self.__dep__.keydep(args[0]).depend()
77-
value = fn(self.__target__, *args, **kwargs)
98+
def trap(self: Proxy[Any], key: Any, *args: Any) -> Any:
99+
if dep_stack:
100+
dep_stack[-1].add_dep(self.__dep__.keydep(key))
101+
value = fn(self.__target__, key, *args)
78102
if self.__shallow__:
79103
return value
80-
return proxy(value, readonly=self.__readonly__)
104+
return proxy(value, self.__readonly__)
81105

82106
return trap
83107

@@ -115,27 +139,29 @@ def write_trap(method: str, obj_cls: type) -> Trap:
115139
def write_dict_trap(method: str, obj_cls: type) -> Trap:
116140
fn = getattr(obj_cls, method)
117141

142+
# The signature mirrors dict.update: at most one positional-only
143+
# argument (a mapping or an iterable of key/value pairs), plus
144+
# arbitrary keyword arguments
118145
@wraps(fn)
119-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
146+
def trap(self: Proxy[Any], positional: Any = _MISSING, /, **kwargs: Any) -> Any:
120147
target = self.__target__
121-
# Normalize the arguments (an optional mapping or iterable of
122-
# key/value pairs, plus optional keyword arguments) into a
123-
# single dict, so that only the incoming keys have to be
124-
# diffed for changes. This also makes sure that an iterable
125-
# argument is not consumed twice
126-
if args:
127-
incoming = dict(args[0])
148+
target_get = target.get
149+
# Normalize the arguments into a single dict, so that only the
150+
# incoming keys have to be diffed for changes. This also makes
151+
# sure that an iterable argument is not consumed twice
152+
if positional is not _MISSING:
153+
incoming = dict(positional)
128154
if kwargs:
129155
incoming.update(kwargs)
130156
else:
131157
incoming = kwargs
132-
old_values = {key: target.get(key, _MISSING) for key in incoming}
158+
old_values = {key: target_get(key, _MISSING) for key in incoming}
133159
retval = fn(target, incoming)
134160
dep = self.__dep__
135161
keydeps = dep.keydeps if dep.keydeps is not None else _NO_KEYDEPS
136162
change_detected = False
137163
for key, old_value in old_values.items():
138-
if old_value is not target.get(key, _MISSING):
164+
if old_value is not target_get(key, _MISSING):
139165
keydep = keydeps.get(key)
140166
if keydep is not None:
141167
keydep.notify()
@@ -151,10 +177,10 @@ def write_len_compare_trap(method: str, obj_cls: type) -> Trap:
151177
fn = getattr(obj_cls, method)
152178

153179
@wraps(fn)
154-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
180+
def trap(self: Proxy[Any], *args: Any) -> Any:
155181
target = self.__target__
156182
old_len = len(target)
157-
retval = fn(target, *args, **kwargs)
183+
retval = fn(target, *args)
158184
if len(target) != old_len:
159185
self.__dep__.notify()
160186
return retval
@@ -165,6 +191,8 @@ def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
165191
def write_copy_compare_trap(method: str, obj_cls: type) -> Trap:
166192
fn = getattr(obj_cls, method)
167193

194+
# list.sort takes keyword arguments (key and reverse), so this is
195+
# the one write trap that must accept **kwargs
168196
@wraps(fn)
169197
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
170198
target = self.__target__
@@ -212,11 +240,10 @@ def write_key_trap(method: str, obj_cls: type) -> Trap:
212240
is_setdefault = method == "setdefault"
213241

214242
@wraps(fn)
215-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
243+
def trap(self: Proxy[Any], key: Any, *args: Any) -> Any:
216244
target = self.__target__
217-
key = args[0]
218245
old_value = getitem_fn(target, key, _MISSING)
219-
retval = fn(target, *args, **kwargs)
246+
retval = fn(target, key, *args)
220247
if is_setdefault and not self.__shallow__:
221248
# This method is only available when readonly is false
222249
retval = proxy(retval)
@@ -227,7 +254,7 @@ def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
227254
# (e.g. PySide6's ItemFlags), see test_use_weird_types_as_value
228255
if old_value is not new_value and (
229256
old_value is _MISSING
230-
or xor(old_value is None, new_value is None)
257+
or (old_value is None) != (new_value is None)
231258
or old_value != new_value
232259
):
233260
dep = self.__dep__
@@ -245,9 +272,10 @@ def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
245272
def delete_trap(method: str, obj_cls: type) -> Trap:
246273
fn = getattr(obj_cls, method)
247274

275+
# The wrapped deleter methods (clear, popitem) take no arguments
248276
@wraps(fn)
249-
def trap(self: DictProxyBase, *args: Any, **kwargs: Any) -> Any:
250-
retval = fn(self.__target__, *args, **kwargs)
277+
def trap(self: DictProxyBase) -> Any:
278+
retval = fn(self.__target__)
251279
dep = self.__dep__
252280
dep.notify()
253281
keydeps = dep.keydeps if dep.keydeps is not None else _NO_KEYDEPS
@@ -266,10 +294,9 @@ def delete_key_trap(method: str, obj_cls: type) -> Trap:
266294
fn = getattr(obj_cls, method)
267295

268296
@wraps(fn)
269-
def trap(self: Proxy[Any], *args: Any, **kwargs: Any) -> Any:
270-
key = args[0]
297+
def trap(self: Proxy[Any], key: Any, *args: Any) -> Any:
271298
key_existed = key in self.__target__
272-
retval = fn(self.__target__, *args, **kwargs)
299+
retval = fn(self.__target__, key, *args)
273300
if key_existed:
274301
dep = self.__dep__
275302
dep.notify()

0 commit comments

Comments
 (0)