Skip to content

perf(ui): memoize Backend list properties to avoid per-paint rebuilds#181

Open
hughesyadaddy wants to merge 1 commit into
TomBadash:masterfrom
hughesyadaddy:perf/backend-list-property-memoization
Open

perf(ui): memoize Backend list properties to avoid per-paint rebuilds#181
hughesyadaddy wants to merge 1 commit into
TomBadash:masterfrom
hughesyadaddy:perf/backend-list-property-memoization

Conversation

@hughesyadaddy
Copy link
Copy Markdown
Contributor

@hughesyadaddy hughesyadaddy commented May 18, 2026

Summary

The five list-typed @Property getters on Backendbuttons, profiles, knownApps, actionCategories, allActions — are read by QML bindings inside delegate rebuilds. Every paint of the mappings list, the profile selector, or the action picker rebuilt the corresponding list from scratch, which in the case of profiles meant re-resolving every profile's apps through get_icon_for_exe and app_catalog.get_app_label on every frame.

Approach

Each getter returns a cached snapshot. The cache is invalidated by the property's notify signal — plus any other signal whose emission actually changes the list's contents.

Property Invalidating signals
buttons mappingsChanged, deviceLayoutChanged
profiles profilesChanged, activeProfileChanged
knownApps knownAppsChanged
actionCategories deviceLayoutChanged
allActions deviceLayoutChanged

The notify connections are wired once in Backend.__init__. The public signal contract is unchanged — every signal still fires exactly when it did before; we just stop recomputing when nothing has changed.

Each getter is split into an internal _compute_X() helper so the cache wrapper stays trivial and the original computation logic stays untouched (no behavioral risk).

Test plan

New BackendListPropertyMemoizationTests in tests/test_backend.py (7 cases):

  • consecutive reads of buttons return the same object (memoized);
  • mappingsChanged invalidates buttons;
  • deviceLayoutChanged invalidates buttons, actionCategories, allActions;
  • activeProfileChanged invalidates profiles;
  • knownAppsChanged invalidates knownApps;
  • profiles cache survives knownAppsChanged and mappingsChanged;
  • knownApps cache survives profilesChanged, mappingsChanged, deviceLayoutChanged;
  • full pytest tests/ -q — 502 passed, 1 skipped, 170 subtests passed.

Notes

  • No new dependency on profiling — the rebuild cost was visible in the code itself (per-paint catalog walks and icon resolution). The caches are pure correctness-preserving memoization, not speculative optimization.
  • The _compute_* split is deliberate: it keeps the diff readable as "wrap each getter" and makes the rebuild paths easy to test in isolation.

The five list-typed ``@Property`` getters on ``Backend`` -- ``buttons``,
``profiles``, ``knownApps``, ``actionCategories``, ``allActions`` -- are
read by QML bindings inside delegate rebuilds, so every paint of the
mappings list, profile selector, or action picker rebuilt the lists from
scratch. The worst offenders were ``profiles`` (re-resolving every
profile's apps through ``get_icon_for_exe`` and
``app_catalog.get_app_label``) and ``knownApps`` (walking the full
catalog and resolving an icon for every entry).

Each getter now returns a cached snapshot until its notify signal -- or
a structurally-dependent signal -- fires. The mapping of caches to
invalidating signals is:

* buttons          -- mappingsChanged, deviceLayoutChanged
* profiles         -- profilesChanged, activeProfileChanged
* knownApps        -- knownAppsChanged
* actionCategories -- deviceLayoutChanged
* allActions       -- deviceLayoutChanged

The notify connections are wired once in ``__init__``; nothing else
changes about the public signal contract. Pure rebuilds still happen
exactly when the underlying data changes; redundant rebuilds during
delegate paints no longer happen at all.

Tests in ``tests/test_backend.py``:

* identity check that consecutive reads of ``buttons`` return the same
  object (memoized);
* each notify signal invalidates exactly the caches it must (mappings →
  buttons, device layout → buttons + actionCategories + allActions,
  active profile → profiles, known apps → knownApps);
* unrelated signals do not invalidate caches that should be stable
  across them.

No behavioral change: the cached lists are computed by the same code as
before, factored into ``_compute_*`` helpers so the cache wrappers stay
trivial.
@hughesyadaddy hughesyadaddy force-pushed the perf/backend-list-property-memoization branch from 25dd282 to cccf841 Compare May 18, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant