perf: cut state hot-path overhead — deep-validation skip, proxy caching, dirty-propagation frontier - #6743
Conversation
Greptile SummaryThis PR improves state hot-path performance across validation, proxying, and dirty propagation. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| reflex/state.py | Adds mutable proxy caching, cache eviction on reassignment, transient dirty-propagation state, interval computed-var precomputation, and serialization cleanup. |
| reflex/istate/proxy.py | Updates nested wrapping so existing proxies are unwrapped before the dataclasses-internal bypass is checked. |
| packages/reflex-base/src/reflex_base/vars/base.py | Adds computed-var recompute generation tracking and validates cached computed-var return types only when recomputed. |
| packages/reflex-base/src/reflex_base/utils/types.py | Adds reserved internal state field names and environment-based validation depth selection. |
| tests/units/istate/test_proxy.py | Adds tests for mutable proxy reuse, reassignment eviction, pickle behavior, and immutable element handling. |
| tests/units/test_state.py | Adds tests for assignment validation, computed-var recompute correctness, cross-state recompute behavior, and interval computed-var tracking. |
Reviews (9): Last reviewed commit: "Address cubic review: strip env mode whi..." | Re-trigger Greptile
Merging this PR will improve performance by 5.02%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | test_process_event |
7.6 ms | 7.2 ms | +5.02% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/reflex-perf-optimizations-01l7a3-eng-10095 (f9e4010) with claude/reflex-compiler-perf-t8ztc9-11-memoize-dedup (460c941)
Footnotes
-
8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
| current_gen = reflex_base_vars_base._computed_var_recompute_generation | ||
| if propagated is None: | ||
| propagated = set() | ||
| object.__setattr__(self, "_propagated_dirty_vars", propagated) |
There was a problem hiding this comment.
if we already have the instance_dict, why not just shove the value in there?
| self.dirty_vars = set() | ||
| self.dirty_substates = set() | ||
| # Discard the propagation frontier along with the dirty vars it tracked. | ||
| self.__dict__.pop("_propagated_dirty_vars", None) |
There was a problem hiding this comment.
why are we treating these new fields like this and not defining them as normal fields in a BaseState, like all the other fields
0c44576 to
aee1988
Compare
Assigning a state var and reading a computed var both ran _isinstance(value, type, nested=1), walking every element of list/dict values only to gate a diagnostic log. The computed var check also ran on every access, including cache hits. - Validate computed var return types only when the value is recomputed (sync and async), not on cache hits. - Validate one container level deep only in dev mode; production now checks just the outer type (the check never gates behavior, it only logs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Address review feedback: instead of caching the first observed mode forever, re-read the raw REFLEX_ENV_MODE value on every call and cache the depth per raw value, so in-process mode changes take effect immediately at negligible hot-path cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
_wrap_recursive walked 5 stack frames (dataclasses-internal check) for every element retrieved through a proxy, even immutable scalars that never get wrapped. Check is_mutable_type first so immutable elements skip the frame walk entirely. Also cache the MutableProxy built for each mutable state var on the instance, keyed by field name, instead of constructing a fresh proxy on every attribute read. The cache is invalidated by identity when the underlying value is reassigned and is excluded from pickling (copy and deepcopy already go through __getstate__). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Address review feedback: drop the _mutable_proxy_cache entry when a base or backend var is assigned, so the replaced value is not kept alive by the cached proxy until the next read. Update test_set_base_field_via_setter for the new cached-proxy identity semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Address review feedback: instead of managing the proxy cache as an ad-hoc instance __dict__ entry, declare it as a real (non-var) field like _backend_vars/_was_touched and add it to RESERVED_BACKEND_VAR_NAMES, so user vars cannot silently collide with the framework's tracking machinery. The cache is still excluded from pickling and recreated empty on unpickle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtmC37Y8kJ5S6
Every setattr and proxied mutation triggered a full dirty-propagation pass: an expiry scan over all computed vars plus a dependency re-walk of the entire accumulated dirty_vars set. - Precompute per class which computed vars have an update interval, so the expiry scan is skipped entirely for the common case of none. - Track a per-instance "propagated frontier" so each propagation only processes vars whose dependency closure has not been walked yet. Recomputing a cached var re-materializes its cache, which a later mutation of its dependencies must invalidate again, so recomputes bump a generation counter that resets the frontier. The frontier is transient: cleared by _clean and excluded from pickling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Match review feedback on the sibling PR: declare _propagated_dirty_vars and _propagated_generation as real (non-var) fields and reserve their names, so user vars cannot silently collide with the framework's tracking machinery. Still transient: excluded from pickling and recreated on unpickle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Background task handlers run state methods with self bound to a StateProxy; wrapt's C ObjectProxy (used on Python <= 3.12) rejects object.__setattr__ on the proxy, while __dict__ resolves to the wrapped state's dict on both proxies and plain states. Fixes test_background_task_* failures on 3.10-3.12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
01ec207 to
b5e404b
Compare
aee1988 to
8f24b3e
Compare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
| cache = super().__getattribute__("_mutable_proxy_cache") | ||
| proxy = cache.get(name) | ||
| if proxy is None or proxy.__wrapped__ is not value: | ||
| proxy = MutableProxy(wrapped=value, state=self, field_name=name) | ||
| cache[name] = proxy | ||
| return proxy |
There was a problem hiding this comment.
Evict proxy assignments
This cache is invalidated from BaseState.__setattr__, but mutable state can also be reassigned through StateProxy in an async with self context. That path writes to the wrapped state without running this eviction, so a proxy cached before the context can keep wrapping the old list or dict after self.items = [...]. A later read can reuse that stale proxy and mutations can update the replaced object instead of the current state value. The assignment path used by StateProxy needs to clear the wrapped state's _mutable_proxy_cache for the reassigned field too.
Covers the async-with background task write path: StateProxy.__setattr__ delegates to setattr on the wrapped state, so BaseState.__setattr__ eviction runs for reassignments made through the proxy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
There was a problem hiding this comment.
All reported issues were addressed
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…guard - _validation_depth now strips REFLEX_ENV_MODE to match the canonical parser's whitespace tolerance, so ' prod ' still selects shallow validation; covered in test_validation_depth_by_env_mode. - Remove the unreachable immutable check in _wrap_mutable: every caller (_wrap_recursive, __getattr__, __getitem__, __iter__) already filters immutables, and proxies only ever wrap mutable values, so the guard only cost an extra is_mutable_type call per wrapped element. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Consolidates the state hot-path perf work from #6738 (ENG-10093), #6740 (ENG-10094), and the original scope of this PR (ENG-10095) into one review unit. All three edit the same few functions —
BaseState.__setattr__,_get_attribute,_mark_dirty*,ComputedVar.__get__— so reviewing the final shape of those functions once beats reviewing three interleaved rewrites. Commits are preserved per original PR for per-commit review. Stacked on #6735; #6744 continues on top of this branch.Motivation: profiling the event loop on current main, a trivial event (
counter += 1from a button click) spends ~30% of framework CPU in_get_attribute(129 attribute reads, 121isinstancecalls per event) and ~25% in dirty-tracking/delta bookkeeping. This PR targets both buckets.1. Skip deep type-validation on state var hot paths (from #6738)
__setattr__andComputedVar.__get__ran_isinstance(value, field_type, nested=1)— walking every element of assigned/returned containers — only to gate aconsole.errordiagnostic. Computed-var return types are now validated only on actual recompute (cache hits skip entirely), and element-wise validation depth becomes 1 in dev / 0 in prod via a cached_validation_depth()helper.2. Cut MutableProxy per-element overhead; cache per-field proxies (from #6740)
The recursive wrap path walked 5 stack frames (dataclasses-internal check) for every element read through a proxy; immutable elements now skip wrapping and the frame walk entirely.
_get_attributebuilt a freshMutableProxyon every read of a mutable var; proxies are now cached per instance+field, invalidated by identity on reassignment, and excluded from pickling.3. Only propagate newly-dirty vars in
_mark_dirty_computed_vars(original scope)Every setattr/proxied mutation re-scanned all computed vars for interval expiry and re-walked the dependency closure of the entire accumulated
dirty_varsset. This adds per-class precomputed_interval_computed_vars(the expiry scan is skipped when none exist) and a per-instance propagated frontier so only newly-dirty vars are walked; a process-wide generation counter bumped on recompute resets the frontier to preserve mid-cycle invalidation correctness._mark_dirty_computed_varscumulative over 2000 appends: 0.102 s → 0.019 sReview feedback from the folded threads, already incorporated
__dict__entries, per masenf's request on both Reduce MutableProxy per-element overhead and cache per-field proxies #6740 and perf: cut state hot-path overhead — deep-validation skip, proxy caching, dirty-propagation frontier #6743:_mutable_proxy_cacheand the propagation-frontier fields are declared fields (excluded from pickles, safe against user-var name collisions).setattr(instance, self._cache_attr, ...)on the line immediately before that return — the early return only skips re-reading and re-validating the value that was just computed and stored.Benchmark numbers are from the original PRs, measured on GitHub Actions runners over two passes.