Resolve expects_arguments once when observable handlers are registered#6108
Resolve expects_arguments once when observable handlers are registered#6108maybites wants to merge 5 commits into
Conversation
Every props/style/classes mutation routes through ObservableCollection._handle_change -> events.handle_event -> helpers.expects_arguments, which runs inspect.signature on every fire to decide whether to pass the change arguments. The handler's signature never changes, so on a dense page this is a large amount of wasted recomputation. Resolve the decision once at registration time (in ObservableCollection's __init__ and on_change) and store the bool alongside the handler, then read the stored flag in _handle_change. This mirrors how the Event system already stores Callback.expect_args, and avoids the per-fire introspection without a handler-keyed cache (which would pin bound methods/closures and leak). handle_event gains an additive, keyword-only expect_args parameter so the observable path can pass the pre-resolved bool; all existing callers default to None and behave exactly as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Posted by Claude Code on @evnchn's behalf. TL;DR — perf win confirmed and reproduced; the public-API break is the blocker, not the approach. I rebuilt both branches and benchmarked them headlessly — the diagnosis and the speedup are real: My one request-changes: this PR changes the public property The fix keeps the entire win:
We also tried to avoid the structural change entirely with a cache — it can't be done cleanly. Details below. Confirmed: the perf win (ran both branches)Environment: Construction-heavy — 200 nodes, ~15 elements each:
The PR floors at 45/node, not 0, because Churn-heavy — 500 elements re-mutated K times (reactive restyle, the #6098 node-graph case):
PR's own Why the public-API break matters (and the exact line)- def change_handlers(self) -> list[Callable]:
- """Return a list of all change handlers registered on this collection and its parents."""
+ def change_handlers(self) -> list[tuple[Callable, bool]]:
+ """Return all change handlers registered on this collection and its parents, each with its `expect_args`."""
Refuted: can an "easy cache" match this with no API change? (no)We pushed on whether a plain cache on
The keys are per-instance bound methods with no reuse, so unbounded leaks (every deleted element's collections pinned forever, for zero speed benefit — Normalized-key cache — considered, rejected. Keying on the handler's underlying So no cache covers all handler types. The PR's resolve-at-registration is the only approach that's uniform across bound methods, lambdas, partials and closures with zero retained memory — which is precisely why the structural change is worth doing (just not at the cost of the public property). Ask: keep the resolve-once design, make |
|
Also @falkoschindler I don't think we need the monkeypatch test? |
The previous commit changed the public `change_handlers` property from `list[Callable]` to `list[tuple[Callable, bool]]`, which is a silent breaking change for any external code iterating it. Move the pre-resolved `expect_args` flag into a private parent-walking accessor `_change_handlers_with_args`, used only by `_handle_change`. The public `change_handlers` property is restored to its original `list[Callable]` contract, derived from the private accessor so the two cannot diverge. The performance win (no per-fire introspection) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review — and especially for rebuilding and benchmarking both branches. The leak-vs-thrash table and the normalized-key analysis are a great confirmation that resolve-at-registration is the only approach uniform across bound methods, lambdas, partials and closures with zero retained memory. Fixed the blocker in 939c36d: the public On the optional inlining: I've left
|
|
@evnchn The test doesn't hurt, but I also think we don't need it. The fact that expect_arguments is called either once or multiple times isn't something that regresses easily by accident. And we usually don't write tests for implementation details like this. Is there a reason why you tagged this as "bug"? I thought it's rather an performance "enhancement". |
|
Sorry for mislabeling. I think indeed let's remove the test is OK. |
|
Posted by Claude Code on @evnchn's behalf. Removed |
…olve-expect-args-once
|
Posted by Claude Code on @evnchn's behalf. TL;DR — the one open "maintainer design call" is safe to close in favor of keeping the param; then this is ready to land (one doc nit aside). @falkoschindler the only thing still pending is your call on the additive
Ask: if you're OK keeping the additive param, I think this is mergeable after the one-line doc nit below. If you'd still rather keep The full API surface (grep over master @ v3.13.0 base)
¹ Confirmed empirics (re-ran both branches)
Doc nit + optional follow-up scope
|
Motivation
Every
.props()/.classes()/.style()mutation fires anObservableCollectionchange handler, which routes throughObservableCollection._handle_change→events.handle_event→helpers.expects_arguments.expects_argumentsrunsinspect.signature(handler)on every fire to decide whether to pass the change arguments.A handler's signature never changes, so all but the first call per handler is wasted recomputation. On dense pages this dominates: profiling a ~200-node graph editor (each node mutating props/style/classes many times during render) measured on the order of ~135,000
inspect.signaturecalls just to render the page, where a handful of distinct handlers are introspected over and over.Discussed in #6098, where the preferred fix is to resolve the decision once at registration time and store the bool (rather than caching the introspection) — consistent with how the
Eventsystem already storesCallback.expect_args, and leak-free.Implementation
ObservableCollectionnow stores each change handler together with whether it expects arguments:_change_handlers: list[tuple[Callable, bool]]. The bool is resolved once at registration — in__init__(theon_changeargument) and inon_change()— viahelpers.expects_arguments.change_handlersproperty merges parent records, so handlers inherited through the parent chain keep their pre-resolved flag._handle_changereads the stored bool instead of re-introspecting on every fire.events.handle_eventgains an additive, keyword-onlyexpect_args: bool | None = None. WhenNone(every existing caller), behaviour is unchanged —expects_argumentsis still called in the same place, inside the slot context. When a bool is supplied, the per-fire introspection is skipped.This mirrors the existing
Callback.expect_argspattern (resolve once at subscribe time, dispatch from the stored bool) without introducing a handler-keyed cache. A cache keyed on the handler object would pin bound methods and user closures/partials — and through them their collections/elements — for the process lifetime, which is the leak called out in #6098.Net effect: a strict win on hot paths (large UIs with many mutations per element) and break-even otherwise (one
signature()call either way); no regression.One design choice worth a maintainer call: the observable path still dispatches through
handle_event(now honoring a pre-resolvedexpect_args), rather than calling the handler directly from the stored bool the wayCallback.rundoes. This keeps a single dispatch path so the observable handlers don't drift fromhandle_event's async/exception/slot semantics, at the cost of one additive parameter on a public function. If you'd preferhandle_eventleft untouched, the alternative is to inline the dispatch (+should_await/exception handling) in_handle_change. Happy to switch.The same per-fire
expects_argumentspattern also exists inValueElement,SelectableElement, andTablepagination handlers. Those are left out of this PR to keep it focused onObservableCollection; happy to follow up if the approach is acceptable.Progress
🤖 Generated with Claude Code