Skip to content

perf: cut state hot-path overhead — deep-validation skip, proxy caching, dirty-propagation frontier - #6743

Open
Alek99 wants to merge 16 commits into
claude/reflex-compiler-perf-t8ztc9-11-memoize-dedupfrom
claude/reflex-perf-optimizations-01l7a3-eng-10095
Open

perf: cut state hot-path overhead — deep-validation skip, proxy caching, dirty-propagation frontier#6743
Alek99 wants to merge 16 commits into
claude/reflex-compiler-perf-t8ztc9-11-memoize-dedupfrom
claude/reflex-perf-optimizations-01l7a3-eng-10095

Conversation

@Alek99

@Alek99 Alek99 commented Jul 10, 2026

Copy link
Copy Markdown
Member

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 += 1 from a button click) spends ~30% of framework CPU in _get_attribute (129 attribute reads, 121 isinstance calls 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__ and ComputedVar.__get__ ran _isinstance(value, field_type, nested=1) — walking every element of assigned/returned containers — only to gate a console.error diagnostic. 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.

  • read cached computed var returning 10k dicts: 13.8 ms → 0.0015 ms
  • assign 100k-int list (prod): 90.9 ms → 0.02 ms
  • cProfile of the same scenario: 10.5M calls / 3.06 s → 538 calls / 0.003 s

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_attribute built a fresh MutableProxy on every read of a mutable var; proxies are now cached per instance+field, invalidated by identity on reassignment, and excluded from pickling.

  • iterate a 1000-int list via proxy: 0.98 ms → 0.22 ms (~4.5x)
  • index 1000 elements: ~3.0x
  • read a mutable var attribute: 2.8 us → 1.2 us (~2.4x)
  • proxy micro-suite: 168k → 72k function calls

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_vars set. 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.

  • proxied list append (state with 2 cached computed vars): 16.7 us → 8.6 us (~1.9x)
  • int var setattr: 21.0 us → 9.3 us (~2.2x)
  • _mark_dirty_computed_vars cumulative over 2000 appends: 0.102 s → 0.019 s

Review feedback from the folded threads, already incorporated

Benchmark numbers are from the original PRs, measured on GitHub Actions runners over two passes.

@Alek99
Alek99 requested a review from a team as a code owner July 10, 2026 20:20
@linear-code

linear-code Bot commented Jul 10, 2026

Copy link
Copy Markdown

ENG-10095

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR improves state hot-path performance across validation, proxying, and dirty propagation. The main changes are:

  • Shallow validation for state assignments and computed-var recomputes in production mode.
  • Per-field caching for mutable state proxies with reassignment eviction.
  • Dirty-propagation tracking that only walks newly dirty variables.
  • Precomputed interval computed-var lists to skip unnecessary expiry scans.
  • Tests for proxy caching, recompute behavior, and validation depth.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

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

@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 5.02%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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.

Comment thread reflex/state.py Outdated
current_gen = reflex_base_vars_base._computed_var_recompute_generation
if propagated is None:
propagated = set()
object.__setattr__(self, "_propagated_dirty_vars", propagated)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we already have the instance_dict, why not just shove the value in there?

Comment thread reflex/state.py Outdated
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we treating these new fields like this and not defining them as normal fields in a BaseState, like all the other fields

@Alek99
Alek99 force-pushed the claude/reflex-perf-optimizations-01l7a3-eng-10095 branch from 0c44576 to aee1988 Compare July 18, 2026 01:46
@Alek99
Alek99 changed the base branch from main to claude/reflex-perf-optimizations-01l7a3-eng-10094 July 18, 2026 01:46
claude and others added 13 commits August 12, 2026 16:19
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
@Alek99
Alek99 force-pushed the claude/reflex-perf-optimizations-01l7a3-eng-10094 branch from 01ec207 to b5e404b Compare August 12, 2026 23:32
@Alek99
Alek99 force-pushed the claude/reflex-perf-optimizations-01l7a3-eng-10095 branch from aee1988 to 8f24b3e Compare August 12, 2026 23:32
@Alek99
Alek99 changed the base branch from claude/reflex-perf-optimizations-01l7a3-eng-10094 to claude/reflex-compiler-perf-t8ztc9-11-memoize-dedup August 13, 2026 01:02
@Alek99 Alek99 changed the title Only propagate newly-dirty vars in _mark_dirty_computed_vars perf: cut state hot-path overhead — deep-validation skip, proxy caching, dirty-propagation frontier Aug 13, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X
Comment thread reflex/state.py
Comment on lines +1518 to +1523
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/utils/types.py Outdated
Comment thread reflex/istate/proxy.py Outdated
…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
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.

3 participants