Skip to content

Resolve expects_arguments once when observable handlers are registered#6108

Open
maybites wants to merge 5 commits into
zauberzeug:mainfrom
going-haywire:perf/observable-resolve-expect-args-once
Open

Resolve expects_arguments once when observable handlers are registered#6108
maybites wants to merge 5 commits into
zauberzeug:mainfrom
going-haywire:perf/observable-resolve-expect-args-once

Conversation

@maybites

@maybites maybites commented Jun 13, 2026

Copy link
Copy Markdown

Motivation

Every .props()/.classes()/.style() mutation fires an ObservableCollection change handler, which routes through ObservableCollection._handle_changeevents.handle_eventhelpers.expects_arguments. expects_arguments runs inspect.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.signature calls 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 Event system already stores Callback.expect_args, and leak-free.

Implementation

  • ObservableCollection now 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__ (the on_change argument) and in on_change() — via helpers.expects_arguments.
  • The change_handlers property merges parent records, so handlers inherited through the parent chain keep their pre-resolved flag. _handle_change reads the stored bool instead of re-introspecting on every fire.
  • events.handle_event gains an additive, keyword-only expect_args: bool | None = None. When None (every existing caller), behaviour is unchanged — expects_arguments is 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_args pattern (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-resolved expect_args), rather than calling the handler directly from the stored bool the way Callback.run does. This keeps a single dispatch path so the observable handlers don't drift from handle_event's async/exception/slot semantics, at the cost of one additive parameter on a public function. If you'd prefer handle_event left untouched, the alternative is to inline the dispatch (+ should_await/exception handling) in _handle_change. Happy to switch.

The same per-fire expects_arguments pattern also exists in ValueElement, SelectableElement, and Table pagination handlers. Those are left out of this PR to keep it focused on ObservableCollection; happy to follow up if the approach is acceptable.

Progress

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete. (Opened as a draft pending the design call above.)
  • This PR does not address a security issue.
  • Pytests have been added/updated or are not necessary.
  • Documentation has been added/updated or is not necessary.
  • No breaking changes to the public API or migration steps are described above.

🤖 Generated with Claude Code

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>
@evnchn

evnchn commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

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: inspect.signature is ~47% of per-mutation cost, and resolve-once eliminates 100% of per-fire introspection (1.1× on construction-heavy pages, up to 1.9× on reactive/interleaved re-renders). Strong, well-targeted work. 👏

My one request-changes: this PR changes the public property ObservableCollection.change_handlers from list[Callable]list[tuple[Callable, bool]]. That's a silent breaking change for any external code reading it.

The fix keeps the entire win:

  • Store the pre-resolved bool in the private _change_handlers only; keep the public change_handlers returning list[Callable] (or add a separate private accessor for the flagged tuples).
  • The new handle_event(..., expect_args=...) param is additive/fail-safe, so it's fine — but per the author's own "maintainer call" note, inlining the dispatch in _handle_change would drop even that and keep handle_event's signature untouched.

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: upstream/main @ 458d65cb vs PR @ b9ca8187, Python 3.14.4, headless element construction, inspect.signature instrumented, GC-controlled timing, medians of 3 (stable to ±2%).

Construction-heavy — 200 nodes, ~15 elements each:

branch signature calls build time
main 12,600 (63/node) 0.111 s
this PR 9,000 (45/node) 0.097 s (1.14×)

The PR floors at 45/node, not 0, because Classes/Style/Props each register on_change=self._update — a bound method, one distinct object per element per collection — so there's always one signature() per collection at registration. The PR removes only the repeated fires, which is exactly the right target.

Churn-heavy — 500 elements re-mutated K times (reactive restyle, the #6098 node-graph case):

churn K main this PR speedup
16 0.209 s / 32k sig 0.091 s / 0 sig 2.3×
64 1.024 s / 128k sig 0.538 s / 0 sig 1.9×

signature() is ~47% of main's per-mutation cost; the PR removes all of it after registration.

PR's own tests/test_observables.py: 9 passed.

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`."""

change_handlers is a public property (no underscore). Any downstream code doing for h in coll.change_handlers: ... now iterates (handler, bool) tuples and breaks with no deprecation path. The win lives entirely in the private storage + _handle_change, so it costs nothing to keep the public shape stable.

Refuted: can an "easy cache" match this with no API change? (no)

We pushed on whether a plain cache on helpers.expects_arguments could match the PR and skip the structural change. It can't:

approach consecutive interleaved (500-wide) leak (collections alive after delete+gc)
lru_cache(maxsize=None) 0.042 s 0.042 s 2000/2000 pinned
lru_cache(maxsize=128) 0.043 s 0.080 s / 10k sig (thrash) ⚠️ 43/2000
this PR 0.040 s 0.040 s 0/2000 ✅

The keys are per-instance bound methods with no reuse, so unbounded leaks (every deleted element's collections pinned forever, for zero speed benefit — hits are identical to the bounded cache) and bounded thrashes the moment the live working set exceeds the window (half the fires miss on interleaved re-renders — the exact reactive case this targets).

Normalized-key cache — considered, rejected. Keying on the handler's underlying __func__ collapses all 500 _style._update to one permanent function object → it does match the PR (within ~3%, leak-free even unbounded). But it only works for bound methods: per-element lambdas (the color/icon/content/text mixin handlers) and functools.partials don't share a __func__, so they fall straight back to leak-vs-thrash. Verified:

two per-element lambdas share key?  False   # -> per-instance, no reuse
two bound methods share key?        True    # -> collapses to one
partial sigs differ, keys differ?   True    # -> must not merge (correctness)

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 change_handlers non-breaking (private bool storage), and optionally inline the dispatch to leave handle_event untouched. Happy to push a commit for the first part if useful.

@evnchn

evnchn commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Also @falkoschindler I don't think we need the monkeypatch test?

@evnchn evnchn added the bug Type/scope: Incorrect behavior in existing functionality label Jun 14, 2026
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>
@maybites

Copy link
Copy Markdown
Author

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 change_handlers property is back to list[Callable] with its original docstring — the breaking change is gone. The pre-resolved expect_args flag now lives only in the private storage plus a private parent-walking accessor _change_handlers_with_args, which _handle_change uses. change_handlers is derived from that accessor (stripping the bool), so the public and private views can't drift apart. The perf win is unchanged — still zero per-fire introspection.

On the optional inlining: I've left handle_event's additive expect_args param in place, since you noted it's fail-safe and fine, and keeping a single dispatch path means the observable handlers stay aligned with handle_event's async/exception/slot semantics rather than duplicating them in _handle_change. Happy to inline and drop the param instead if you'd prefer handle_event untouched — just say the word.

tests/test_observables.py still passes (9), and the module is pylint/ruff/mypy clean. I'll mark the PR ready for review unless you'd like the inlining first.

@falkoschindler

Copy link
Copy Markdown
Contributor

@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".

@falkoschindler falkoschindler added this to the 3.15 milestone Jun 15, 2026
@evnchn evnchn added feature Type/scope: New or intentionally changed behavior and removed bug Type/scope: Incorrect behavior in existing functionality labels Jun 16, 2026
@evnchn

evnchn commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Sorry for mislabeling. I think indeed let's remove the test is OK.

@evnchn evnchn added the in progress Status: Someone is working on it label Jun 16, 2026
@evnchn

evnchn commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Posted by Claude Code on @evnchn's behalf.

Removed test_resolve_expect_args_once in fb350bc, per the consensus above — it asserted an implementation detail (the expects_arguments introspection count) that we don't normally test and that won't regress by accident. The now-unused pytest / helpers / ObservableChangeEventArguments imports went with it, so tests/test_observables.py is back to its pre-PR state. The perf change in events.py / observables.py is untouched, and the rest of the suite passes locally (8 tests).

@maybites maybites marked this pull request as ready for review June 16, 2026 07:50
@falkoschindler falkoschindler self-requested a review June 22, 2026 13:04
@falkoschindler falkoschindler added review Status: PR is open and needs review and removed in progress Status: Someone is working on it labels Jun 22, 2026
@falkoschindler falkoschindler self-assigned this Jun 30, 2026
@evnchn

evnchn commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

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 expect_args param on handle_event. I re-established the full API surface this PR touches, and it makes the choice lopsided:

  1. The change is fully additiveexpect_args is keyword-only defaulting to None, so all 48 internal handle_event call sites are byte-for-byte unchanged; the public change_handlers property is back to list[Callable]. Zero break.
  2. Keeping the param is the reusable lever, not just this PR's plumbing. Every deferred follow-up (value_element, selectable_element, table) dispatches through handle_event too — so with the param they each become "pass a pre-resolved bool", with no duplicated dispatch logic. Inlining into _handle_change instead would fork the async/exception/slot semantics into a second path, and force the same duplication in each follow-up.
  3. There's already in-tree precedent: event.py:80 passes a pre-resolved expect_args= into the same primitive (the Callback.expect_args pattern this PR mirrors).

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 handle_event's signature untouched, maybites already offered to inline — say the word.

The full API surface (grep over master @ v3.13.0 base)
Symbol Visibility Before After Break?
events.handle_event() dev-facing¹ (handler, arguments) (handler, arguments, *, expect_args=None) No — kw-only, defaults None
ObservableCollection.change_handlers public prop -> list[Callable] -> list[Callable] (derived) No — shape preserved
ObservableCollection._change_handlers private list[Callable] list[tuple[Callable, bool]] N/A
ObservableCollection._change_handlers_with_args private (new) parent-walking accessor N/A
ObservableCollection.on_change() / __init__(on_change=) public unchanged No

¹ handle_event isn't in nicegui.__init__/ui.* and has 0 hits in website/documentation, but it's the dispatch primitive used by 33 files / 48 sites and reached by element authors who subclass ui.element — so I treated it as semi-public, not throwaway-internal. The param being keyword-only means even that surface is non-breaking.

Confirmed empirics (re-ran both branches)
  • Introspection count: 1000 collection mutations → 1000 expects_arguments calls on master vs 1 total on the PR (resolved once at registration). Instrumented by monkeypatching helpers.expects_arguments and counting.
  • Behavior parity (5/5): handler-with-args receives the event; zero-arg handler fires with no args; on_change() post-construction registration; parent-chain propagation with inherited flags; public change_handlers returns list[Callable] with the same identity.
  • Existing tests/test_observables.py: 9 passed. CI green (mypy / pylint / pre-commit / pytest 3.10).
  • The removed test_resolve_expect_args_once was by our own consensus (implementation-detail introspection count) — behavior stays covered by the existing 9.
Doc nit + optional follow-up scope
  • Doc nit — fixed in 7fcfcad0: the new docstring at events.py:457 said "added in version 3.14.0", but v3.14.0 is already tagged and this is on the 3.15 milestone; it now reads 3.15.0.
  • Follow-up (separate PR, not here): the same per-fire expects_arguments pattern also lives at value_element.py:146, selectable_element.py:124, table.py:91/:100 (maybites named these), and — not previously listed — client.py:445 and page.py:145. All route through handle_event, so they'd reuse this exact param. Suggest capturing as a follow-up once this lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants