Skip to content

Speed up memo-name hashing, move it into its own module, fix seven tag collisions - #6947

Merged
masenf merged 20 commits into
mainfrom
claude/optimize-deterministic-hash-u1dl2j
Sep 1, 2026
Merged

Speed up memo-name hashing, move it into its own module, fix seven tag collisions#6947
masenf merged 20 commits into
mainfrom
claude/optimize-deterministic-hash-u1dl2j

Conversation

@masenf

@masenf masenf commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

New Feature Submission

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Description

The deterministic hash exists for one purpose: giving an auto-memoized component a stable, non-colliding export name. Started as a profiling question about it, and grew into three related pieces.

1. The encoding is faster — measured against current main.

page encoding whole component_hash
memoization-heavy 1.31x 0.99x
_stateful_page 1.17x 1.03x
_complicated_page 0.1 ms either way, noise 1.00x

Min-of-11 on a live compile, caches cleared per run, main's encoder and artifact set reproduced in the same process. The columns differ because encoding is only ~13% of component_hash — the rest is render() and the _get_all_* walks, which this PR does not touch. @FarhanAliRaza independently measured 1.18–1.23x on a 201-component page, which agrees. CodSpeed reports the end-to-end effect on each push; see its comment rather than a number pasted here.

Earlier revisions of this description claimed 2.3–2.6x. That came from a corpus replayed several passes per timed run, so every pass after the first ran on warm caches and already-rendered components. It also predated #7012, which cut ImportVar encodes on my benchmark page from 1182 to 854 and removed much of the redundancy the caching exploits.

What makes it faster: the encoder writes into a bytearray flushed in 64 KiB chunks instead of one hasher.update() per node, resolves an encoder once per type instead of walking an isinstance ladder per value, caches each dataclass type's field layout with pre-encoded names, and caches the encoded form of short strings and of frozen dataclasses whose declared fields are all str/bool/None (ImportVar above all — 854 encodes across 22 distinct values on one page).

2. Moved into reflex_base/utils/deterministic_hash.py.

Nothing outside memo.py called Component._get_component_hash or Component._compute_memo_tag, and neither is a property of a component the way render() is. The hash itself is not a property of memoization either — it digests components, vars and rendered data, and auto-memoization is its only caller today — so it now lives in utils with its tests beside it. memo.py keeps component_hash, memo_tag and _component_artifacts. shallow became recursive, named for what it means there. Dead _hash_str helper dropped.

3. Seven memo-name collisions, each dropping compiled output.

The hashed artifact set has to match what compile_experimental_component_memo actually puts in the memo body, and the encoding has to be injective. Neither held:

Gap Consequence
add_custom_code not hashed (only _get_custom_code) Two bodies differing only in emitted module-level code shared one module; one code block dropped
_get_dynamic_imports not hashed at all One of two dynamic import statements dropped
Class identified by __qualname__ alone Two modules each defining class Card with identical output collided
Caches never released A @dataclass defined in a function body is a fresh class per call; 50 stayed pinned through a gc.collect()
Dataclass branch ahead of the component branch A component that also inherits a dataclass encoded as that mixin's field list, not as its render
Dataclasses encoded by field layout alone Alpha(a="x") and Beta(a="x") — distinct types, identical digest
Enum members encoded as str(value) IntEnum.ONE and 1 — identical digest, since 3.11 made IntEnum.__str__ the integer

The fifth is the broad one. Every component built on MarkdownComponentMaprx.text, rx.heading and friends — inherits a dataclass that declares no fields, so on the old branch order _deterministic_hash(rx.text("a")) == _deterministic_hash(rx.text("bbbbb")): all of them encoded to the same nine bytes. Reachable through component_hash via app-wrap components, which the hash feeds in as components rather than as rendered dicts.

The last two predate this branch but live in code it rewrites, and the module claims injectivity, so they belong here. Both fixes are per-type and cached: the defining class goes into the dataclass layout header, and enums get their own tag plus qualified member name.

The module now reaches the digest rather than the tag prefix — format_state_name only maps dots to __, and those names become filenames, so a dotted path in the prefix would stretch every generated memo module name. The caches are released from App._compile in a finally: that's the single funnel every compile goes through, since reflex export and reflex compile reach it via get_compiled_app and never touch App.__call__.

Credit

The encoder table, the branch-order bug and the idea of generalizing the ImportVar cache to frozen dataclasses all come from @benedikt-bartscher's #6804, which landed on the same code independently. Two things are deliberately taken differently there:

  • Cache keyed by value, not by id(). On a memoization-heavy page the hash sees 854 ImportVar encodes across 542 distinct ids but only 22 distinct values, so identity keying hits ~37% where value keying hits 98%. Measured per visit: ~220 ns for a value-keyed hit, ~160 ns for an id-keyed one, ~1930 ns to re-encode from scratch.
  • Numbers excluded from the cacheable field types. _IMMUTABLE_FIELD_TYPES in improve compile perf #6804 includes int/float, which is sound under id keying but not under value keying — improve compile perf #6804 has its own test for exactly this (True vs 1), and reading declared annotations is what keeps that test passing here.

A narrowing that was tried and reverted

An earlier revision hashed only import library names, on the argument that a body reaches an import through the local name it binds, and every name it references is already in its render. @FarhanAliRaza showed that is wrong: a tagless ImportVar defaults to render=True and compile_imports emits it as a side-effect import (lucide-react/light.css), and Icon._get_imports builds a per-instance package_path and alias under one library key. Two such bodies rendered identically, shared a tag, and one body's import never reached the compiled module.

Reverted in 66a360f. Re-measuring on a live compile put the narrowing at ~4% of encoding, not the 23% an earlier harness of mine reported — that one charged its own projection wrapper to the full-dict side. Roughly 0.4 ms against a ~112 ms component_hash, so there was nothing to trade a dropped import for.

Note for reviewers

Generated memo module names change, for two independent reasons: item 3 deliberately folds new material into the digest, and merging main brought in #7012, which changes the import dicts the hash consumes. Nothing outside the compiled output refers to them, and no test pins them.

The main merge had two conflicts: component.py (this PR deletes the hashing block, main added PROHIBITED_LIBRARY_IMPORTS in the same region — both intents kept) and pyi_hashes.json (regenerated with make_pyi.py rather than picking a side).

Tests

In tests/units/reflex_base/utils/test_deterministic_hash.py (with the code under test), tests/units/components/test_memo.py and tests/units/test_app.py. Each collision fix has a regression test that was mutation-checked — reverting the fix fails the test.

Four tests passed for the wrong reason and were rewritten: two probe classes with different qualnames, where class identity alone separated them; two probes with different metaclasses, so the encoder-table test could not see the poisoning it was meant to catch; a parametrized case labelled "dataclasses of the same shape but different types" that compared two instances of one type; and an assertion about a Var's type tag that held only because LiteralStringVar._js_expr is the quoted form.

Also covers encoding injectivity, cache-key correctness by value, a frozen dataclass holding a value its annotation does not admit (unhashable, so it cannot key the cache), strings and dataclass encodings past the cache limits, a single leaf larger than the flush buffer (a LiteralStringVar at 3x the flush size peaks at 196,621 B against a 65,536 B threshold — a leaf is appended whole, so it is the one value the flush size cannot bound), digest equality across flush sizes from 1 byte to 1 GiB, dataclasses that can still change, runtime-synthesized dataclass subclasses (MutableProxy copies __dataclass_fields__ without __dataclass_params__), and a clean_hash_caches fixture so cache-state tests are order-independent under pytest-randomly.

Not in this PR

  • component_hash is still ~45% of compile wall time on a foreach/cond-heavy page (~112 ms of ~250 ms on one page here), and encoding is only ~13% of that. The rest is per-node artifact gathering plus render() over snapshot subtrees, and reusing it needs the page walk to descend into snapshot subtrees with the collector sealed off — the _memoize_structural_child machinery Implement client state with useClientState hook #6936 is adding. Should build on that rather than race it. (Fusing the five _get_all_* traversals into one was tried and measured ~2% slower: they already share cached per-node results, so the aggregation was never the cost.)
  • RadixThemesComponent._get_app_wrap_components builds a fresh provider per call — 114 values reaching the hash across 113 distinct objects on one page, ~30% of encoder time, all of it re-rendering equivalent objects. Filed as Memoize RadixThemesColorModeProvider instead of building a fresh instance per component #7013.
  • For a passthrough memo, compile_experimental_component_memo pulls the whole subtree's imports into the body (utils.py:409) while the hash's own-node branch feeds only component._get_imports(). Two passthrough memos with identical roots and differing descendants therefore share a tag while their emitted import blocks differ. Confirmed at the hash level, no end-to-end repro yet; either the hash should match the compile there or utils.py:409 should narrow to the root. Belongs in its own change.
  • GLOBAL_CACHE.clear() has the same ASGI-only gap as the old cache-release site, so reflex export never frees the var cache either. Pre-existing and unrelated; belongs in its own commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c

The hash feeds every value one `hasher.update()` call at a time and walks
the full `isinstance` ladder per node, so a single component hash costs
tens of thousands of C calls. On a foreach/cond-heavy page,
`_get_component_hash` is ~50% of compile wall time.

Encode into a `bytearray` flushed to the hasher in 64KB chunks instead of
per node, dispatch on the exact type before falling back to the
`isinstance` ladder for subclasses, cache each dataclass type's field
layout with pre-encoded names, and cache the encoded form of short strings
and of `ImportVar` instances (a frozen dataclass of `str`/`bool`/`None`
fields, so its generated equality means exactly "same encoding", and it
accounts for most of what a component hash consumes: 5664 visits across
just 12 distinct values on one benchmark page).

The byte stream is unchanged, so every digest is identical to before —
verified against a copy of the previous implementation over all values
hashed while compiling four benchmark pages. 2.3-2.6x faster on the large
pages, 1.8-1.9x on the small ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
@masenf
masenf requested a review from a team as a code owner August 25, 2026 20:26
@codspeed-hq

codspeed-hq Bot commented Aug 25, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 4.8%

⚠️ 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

⚡ 3 improved benchmarks
✅ 29 untouched benchmarks
⏩ 8 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation test_compile_all_artifacts[_stateful_page] 26.9 ms 25.5 ms +5.48%
Simulation test_compile_page[_stateful_page] 30.4 ms 29 ms +4.84%
Simulation test_compile_page_full_context[_stateful_page] 34.4 ms 33.1 ms +4.07%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/optimize-deterministic-hash-u1dl2j (0d12a44) with main (3013a6b)2

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.

  2. No successful run was found on main (6beb371) during the generation of this report, so 3013a6b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR moves deterministic component hashing into a dedicated reflex-base utility, expands memo-name inputs to prevent collisions, and clears encoder caches after compilation.

  • Adds buffered, type-dispatched deterministic encoding with bounded value caches.
  • Moves memo hash and tag construction into the memo module.
  • Includes additional component artifacts in generated memo identities.
  • Clears hash caches from App._compile on both success and failure.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/utils/deterministic_hash.py Introduces the buffered deterministic encoder, type dispatch, bounded caches, and explicit cache cleanup.
packages/reflex-base/src/reflex_base/components/memo.py Centralizes component artifact hashing and memo-tag generation while covering additional compiled artifacts.
packages/reflex-base/src/reflex_base/components/component.py Removes the former component-local hashing and memo-tag implementation.
reflex/app.py Ensures deterministic-hash caches are cleared after every compile attempt.
tests/units/components/test_memo.py Adds regression coverage for memo-name collision cases and artifact-sensitive hashing.
tests/units/reflex_base/utils/test_deterministic_hash.py Adds extensive coverage for encoding injectivity, dispatch, buffering, caching, and cleanup.
tests/units/test_app.py Covers hash-cache cleanup through application compilation paths.

Reviews (19): Last reviewed commit: "test: isolate the annotation-mismatch te..." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/components/component.py Outdated
Comment thread tests/units/components/test_component.py Outdated

@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 across 2 files

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

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/components/component.py Outdated
Comment thread packages/reflex-base/src/reflex_base/components/component.py Outdated
Comment thread tests/units/components/test_component.py Outdated
claude added 2 commits August 25, 2026 21:18
The deterministic hash exists for exactly one purpose: giving an
auto-memoized component a stable, non-colliding export name. It lived in
`component.py` as `Component._get_component_hash` and
`Component._compute_memo_tag`, but nothing outside `memo.py` ever called
either, and neither is a property of a component the way `render()` or
`_get_imports()` is.

Move the encoder and both entry points into `memo.py` as
`component_hash(component, *, recursive=...)` and `memo_tag(component)`,
next to the `create_passthrough_component_memo` call site, and drop the two
methods from `Component`. The `shallow` flag becomes `recursive`, named for
what it means at the call site: a snapshot memo body carries its whole
subtree, a passthrough body carries a `{children}` hole. Also drops the
unused `_hash_str` helper.

The own-node artifact set was missing `add_custom_code`: `_get_custom_code`
was hashed but the classmethod extension point was not, while the recursive
side picked it up through `_get_all_custom_code`. Two passthrough bodies
that rendered identically and differed only in the module-level code they
emit therefore shared one memo module, and one of the two code blocks was
dropped. Fed explicitly now, with a regression test.

Compile wall time is unchanged; this is a structural change plus the
collision fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr

@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 across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/components/memo.py Outdated
Comment thread packages/reflex-base/src/reflex_base/components/memo.py
Comment thread tests/units/components/test_memo.py Outdated
claude added 2 commits August 25, 2026 22:35
The encoding caches that speed up memo naming were module globals with no
teardown. The two value caches are capped, but the dataclass field-layout
cache is keyed by type and was uncapped -- and a dataclass defined inside a
function body is a fresh class object on every call, so hashing one pinned
a class per compile for the life of the process. Confirmed reachable: 50
dynamically created dataclasses survived a gc.collect().

Capping that cache would be the wrong fix. It bounds retention without
removing it, and once the cap is hit every dataclass encode falls back to
`dataclasses.fields()` plus re-encoding field names per instance -- a
silent cliff on the hot path, for a cache whose real-world population is
two entries (`VarData` and `ImportVar`, stable across repeated compiles).

Every component auto-memoization will ever name is named during
compilation, so drop all three caches when it finishes, alongside the
existing `GLOBAL_CACHE.clear()` in the same post-compile block. Digests are
unchanged and compile wall time is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
…ffer

Review of the naming hash turned up two more gaps of the same kind as the
`add_custom_code` one:

- `_get_dynamic_imports` is emitted into the memo body by
  `compile_experimental_component_memo` but was never hashed, so two
  components differing only there shared a module and one of their two
  import statements was dropped.
- `memo_tag` identified a class by `__qualname__` alone, so two modules
  each defining `class Card` with the same rendered output produced the
  same tag -- exactly what the qualname prefix exists to prevent. The
  defining module now reaches the digest rather than the prefix, which
  keeps the discrimination without stretching every generated module
  filename by a dotted module path.

Both are covered by regression tests that fail without the fix.

Also make the encoder's buffer bound real: the flush check ran only after
a container's whole loop, so one flat 2 MB dict buffered 2 MB before the
first flush. Checking per item holds it at the intended 64 KiB and costs
nothing measurable -- the encoder is still 1.7-2.0x the old one and every
digest is byte-identical to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr

@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 across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/components/memo.py Outdated
`clear_hash_caches()` was called from `App.__call__`, which only the ASGI
path reaches. `reflex export` and `reflex compile` get to a compile through
`prerequisites.get_compiled_app` -> `App._compile` and never touch
`__call__`, so those paths never released anything.

Move the call into `App._compile` -- the single funnel every compile goes
through -- inside a `finally`, so a failed compile does not leave the
caches behind either. Covered by a test that fails under the old
placement, on both the success and the exception path.

Also add the root `news/` fragment: this PR now touches `reflex/`, so the
changelog check requires one for the main package too. Corrects the
reflex-base performance fragment, which claimed digests were unchanged --
true of the encoder rewrite alone, but later commits deliberately folded
the defining module and dynamic imports into the hash, so generated memo
module names do change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
@masenf masenf changed the title Optimize component hashing with buffering and caching Speed up memo-name hashing, move it into the memo module, fix four tag collisions Aug 26, 2026
claude added 2 commits August 27, 2026 21:33
Moving the naming hash into `memo.py` changed the source that
`reflex/experimental/memo.pyi` is generated from, so its recorded hash
went stale and the pre-commit check failed. I ran `make_pyi.py` after the
first commit but not after the move.

`pre-commit run --all-files` now passes all seven hooks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
The PR base was two days stale and GitHub Actions had not spawned any
workflow run for the previous head, so bringing main in both revalidates
the change against current main and produces a real commit for CI.

Conflict: `pyi_hashes.json` — both sides changed the generated hash for
`reflex/experimental/memo.pyi`. Resolved with the tooling rather than by
hand: took main's value, then regenerated. The first `make_pyi.py` run was
short-circuited by a stale `.pyi_generator_last_run`; after clearing it the
generator produced a third value, distinct from either side, since the
merged `memo.py` combines main's changes with this branch's.

`pre-commit run --all-files` passes all seven hooks against the merged
tree, and the 652 tests covering the touched areas (test_memo,
test_component, compiler, test_app, benchmarks) all pass. The remaining
unit-test failures are confined to `tests/units/reflex_cli/**` and
`test_processes.py`, which this branch does not touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr
@masenf masenf mentioned this pull request Aug 28, 2026
…ision

Incorporates the ideas from #6804 into the memo-name hashing rework.

The encoder no longer walks an isinstance ladder for every value whose exact
type has no inline fast path. It resolves an encoder once per type and reaches
it through a memoized table, so vars, components, enums and dataclasses each
pay the ladder once per compile instead of once per value. That also collapses
the two parallel ladders the previous version carried -- an exact-type one and
an isinstance one -- into a single encoder per type.

Fixes a fifth naming collision, found by #6804: the dataclass branch sat ahead
of the component branch, so a component that also inherits a dataclass encoded
as that mixin's field list. Every component built on MarkdownComponentMap does
-- rx.text, rx.heading and friends -- and the mixin declares no fields, so all
of them encoded to the same nine bytes. Reachable through app-wrap components,
which the hash feeds in as components rather than as rendered dicts.

The ImportVar encoding cache is no longer hard-coded to ImportVar: any frozen
dataclass declaring only str/bool/None fields is cached by value. Numbers are
excluded because equality has to imply an identical encoding for a value-keyed
cache to be sound, and True == 1 == 1.0 while all three encode differently.
Reading a class's annotations is the expensive part, so that per-type verdict
outlives a compile in a WeakKeyDictionary, which still lets a dataclass defined
in a function body be collected. Cached encodings are bounded in size as well
as in count.

Verified byte-identical: every memo tag generated while compiling three
benchmark pages is unchanged, and an A/B of the two encoders on the values
those compiles hash gives matching digests at 1.00-1.02x the speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
…orate-6804-ysaagl

# Conflicts:
#	packages/reflex-base/src/reflex_base/components/component.py
#	pyi_hashes.json
@masenf masenf changed the title Speed up memo-name hashing, move it into the memo module, fix four tag collisions Speed up memo-name hashing, move it into the memo module, fix five tag collisions Sep 1, 2026
claude and others added 3 commits September 1, 2026 06:14
Fragments are for downstream users; the narrative is a click away on the PR.
The bugfix one becomes a bulleted list of what the memo name now accounts for
instead of a paragraph per collision.

Verified the list renders correctly through the release tooling's own towncrier
invocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
The hash is not a property of memoization -- it digests components, vars and
rendered data under a self-delimiting encoding, and auto-memoization is just
its only caller today. Moved to reflex_base/utils/deterministic_hash.py with
the tests alongside it.

The private _deterministic_hash/_update_deterministic_hash pair becomes one
public variadic deterministic_hash(*values), which is what the two call sites
wanted: component_hash now reads as the render plus the artifacts that identify
a memo body, and _update_component_artifacts_hash becomes _component_artifacts,
a generator that yields them instead of threading a hasher and buffer through.
One shared buffer still covers the whole digest.

Nothing else under utils imports components at runtime, so the two isinstance
checks that need Var and BaseComponent import them inside _resolve_hash_encoder
-- once per type, so it never shows up in a profile -- and the module now
imports standalone without pulling in the component system.

Digests are unchanged: memo tags across three benchmark pages are byte-identical
to before the move, and an A/B of the encoder before and after runs at parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c

@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 across 7 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/utils/deterministic_hash.py Outdated
Comment thread packages/reflex-base/src/reflex_base/components/memo.py
Comment thread packages/reflex-base/src/reflex_base/utils/deterministic_hash.py
The PR touches reflex/app.py, so the root package needs a fragment too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
Walking the ImportVar lists was the largest single item in the memo-name
encoding, and almost all of it was redundant. An import only reaches a memo
body through the local name it binds, and every name a body references is
already in its render, hooks or custom code: Reflex aliases each tag to a
globally unique binding (Trigger -> RadixAccordionTrigger), and
validate_imports rejects one name bound from two libraries. So bodies that
render alike reference the same names, and the library names are what pin
where each name comes from.

Encoding drops 11-19% depending on the page (13.06 -> 11.55 ms on a
memoization-heavy one). Distinct tag counts across three benchmark pages are
unchanged -- 61, 9 and 2 -- so no page gains a collision from the narrower
digest.

What this deliberately stops distinguishing, documented on the function: two
bodies binding the same name from the same library to a different export
(X as N vs Y as N) or in a different form (default vs named). Both need one
library to export two things a component aliases to one name.

Generated memo module names change again, for the same reason as the rest of
this branch: nothing outside the compiled output refers to them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c

@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 across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/components/memo.py Outdated
Two injectivity holes in the encoder, both raised in review and both confirmed:

- Two dataclasses with the same field names and values encoded identically,
  so Alpha(a="x") and Beta(a="x") shared a digest. The defining class now goes
  into the cached layout header, which is built once per type.
- enum members encoded as str(value), which for an IntEnum is just its integer,
  so Level.ONE and 1 shared a digest. Enums now get their own tag and encode
  their qualified member name.

Both were present before this branch; the encoder claims injectivity, so they
belong with the rest of the collision fixes. The parametrized case that claimed
to cover distinct dataclass types compared two instances of one type and could
not have caught the first; it now uses two types. The synthesized-dataclass test
asserted a runtime-built class hashed equal to the class it copied its fields
from, which only held while class identity was absent from the digest.

Also trims the narrative from comments and docstrings across the branch, leaving
what the code needs and moving the reasoning to the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
@masenf masenf changed the title Speed up memo-name hashing, move it into the memo module, fix five tag collisions Speed up memo-name hashing, move it into its own module, fix seven tag collisions Sep 1, 2026
Nothing exercised a single leaf bigger than _HASH_BUFFER_FLUSH_SIZE. A leaf is
appended whole, so the buffer holds all of it before the first flush can run:
a LiteralStringVar of 3x the flush size peaks at 196,621 B against a 65,536 B
threshold. Behaviour is correct, it just had no test.

Adds that case, plus a parametrized check that the digest is identical for
flush sizes from 1 byte to 1 GiB, which pins the invariant that chunking only
moves bytes into the hasher. The second one fails if a flush stops clearing the
buffer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
Comment thread packages/reflex-base/src/reflex_base/components/memo.py Outdated
Comment thread packages/reflex-base/src/reflex_base/utils/deterministic_hash.py Outdated
Comment thread packages/reflex-base/src/reflex_base/utils/deterministic_hash.py
Comment thread reflex/app.py
# paths reach it via ``get_compiled_app`` and never touch
# ``App.__call__`` -- and the ``finally`` keeps a failed compile
# from leaving them behind.
clear_hash_caches()

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.

Clear the caches in a finally inside compiler.compile_app instead, and leave _compile as it was.

compiler.compile_app has exactly one caller, so the guarantee is identical. It also puts the cache lifetime next to the only consumer of those caches, and the 20-line restructure of the telemetry control flow here — which exists only to host this one call — goes away.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed the re-indent is ugly — the whole method body moved a level in to host one line. I've left it as is for one concrete reason, but I'm happy to be overruled.

The guarantee is identical either way, so this is placement. What decides it for me is the test. test_compile_releases_hash_caches patches reflex.compiler.compiler.compile_app with a stub that seeds a cache entry and optionally raises, then asserts App._compile() leaves the caches empty on both paths. Move the finally inside compile_app and that patch replaces the code under test, so the test would pass while exercising nothing. Testing it in the new position means either driving a real compile_app in a unit test, or patching something one level further in (_apply_decorated_pages, the plugin pipeline) — a heavier and more coupled test than the one-line stub it replaces.

One correction on the premise: compile_app has two call sites, not one — app.py:1665 on the no-telemetry path and app.py:1676 inside the with ctx: block. That doesn't undercut your suggestion, since a finally inside compile_app covers both, but it is why the finally here had to wrap the whole method rather than one call.

If you'd still prefer it in compile_app, say so and I'll move it and rework the test to drive the real function through the _should_compile() short-circuit — that path returns early inside compile_app, so it exercises the release without a full compile.


Generated by Claude Code

Comment thread packages/reflex-base/news/6947.performance.md Outdated

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

I tested this in a real app against the merge base (3573364f8).

I built an app with custom components forced to MemoizationDisposition.ALWAYS. One varies its add_custom_code per instance. One varies its ImportVar per instance under a fixed library key. Both render identical JSX in either case. I compiled the app on both branches and diffed the generated memo module.

The PR's own fix is confirmed. The base emits only const PROBE_B and one memo. This head emits PROBE_A and PROBE_B and two distinct memo names.

The import case moves the other way. The base emits two memos and both stylesheet imports. This head emits one memo, and one stylesheet import is gone from the compiled module with no error.

I also ran a valid app in dev mode. State events, rx.cond, rx.foreach, two-way rx.input binding, rx.upload, and two ALWAYS-memoized probes all work. Each probe compiles to its own memo module. The browser console reports no errors or warnings after interaction.

Test runs are clean: 295 unit tests in test_deterministic_hash.py, test_memo.py and test_app.py, ruff check, ruff format --check, and pyright.

I measured the hashing speedup with 201 component_hash calls on a 201-component page, median of 9 runs.

Five requested changes are inline.

Two review findings, both reproduced.

Hashing only import library names dropped ImportVar payloads from the digest.
Icon._get_imports builds a per-instance package_path and alias, and a tagless
ImportVar defaults to render=True so compile_imports emits it as a side-effect
import ("lucide-react/light.css"). Two bodies differing only there rendered
identically, shared a memo tag, and one body's import never reached the
compiled module. Restores the imports dict; the narrowing was measured again
at ~4% of encoding, not the 23% an earlier harness suggested.

_encode_hash_cached_dataclass keyed the value cache on instances whose
hashability comes from their declared field types, so a frozen dataclass
declaring `name: str` while holding a list raised TypeError where the previous
hasher returned a digest. It now falls back to encoding the fields directly.

Also corrects the module docstring: numbers carry a type tag but no length
prefix, so the previous wording overstated the encoding.

Re-measured against main: encoding 1.31x on a memoization-heavy page and 1.17x
on _stateful_page, with the whole component_hash at parity. Fragment updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c

@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 across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/units/reflex_base/utils/test_deterministic_hash.py Outdated
Its well-typed _KeyedProbe instances take the cached-dataclass path, so it left
entries in _hash_dataclass_encodings for whatever ran next. Every other
cache-touching test in the file already takes clean_hash_caches; this one now
does too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1fNEq67sbXeXDZGjLX16c
@masenf
masenf merged commit 812bb47 into main Sep 1, 2026
111 checks passed
@masenf
masenf deleted the claude/optimize-deterministic-hash-u1dl2j branch September 1, 2026 20:37
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