Skip to content

Speed up memo-name hashing, move it into the memo module, fix four tag collisions - #6947

Open
masenf wants to merge 6 commits into
mainfrom
claude/optimize-deterministic-hash-u1dl2j
Open

Speed up memo-name hashing, move it into the memo module, fix four tag collisions#6947
masenf wants to merge 6 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 encoder is 2.3–2.6x faster on large pages (1.8–1.9x on small ones).

It fed the hasher one update() per node — ~148k C calls on one benchmark page — and walked the full isinstance ladder for every value. Now it encodes into a bytearray flushed in 64 KiB chunks, dispatches on the exact type first, caches each dataclass type's field layout with pre-encoded names, and caches the encoded form of short strings and of ImportVar instances. ImportVar is the whale: 5664 visits across just 12 distinct values on one page, and being a frozen dataclass of str/bool/None fields, its generated equality means exactly "same encoding".

Every digest was verified byte-identical to the previous implementation across all values hashed while compiling four benchmark pages.

2. Moved out of component.py into memo.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. Both are now module-level functions — component_hash(component, *, recursive=...) and memo_tag(component) — beside the create_passthrough_component_memo call site. shallow became recursive, named for what it means there: a snapshot memo body carries its whole subtree, a passthrough body carries a {children} hole. Dead _hash_str helper dropped.

3. Four 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. It didn't:

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()

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

Note for reviewers

Generated memo module names change, because items 3 deliberately fold new material into the digest. Nothing outside the compiled output refers to them, and no test pins them.

Tests

In tests/units/components/test_memo.py (with the code under test) and tests/units/test_app.py. Each of the four collision fixes has a regression test that was mutation-checked — reverting the fix fails the test. One first-draft test passed for the wrong reason (its two probe classes had different qualnames, so class identity alone separated them) and was rewritten around a single class.

Also covers encoding injectivity, ImportVar cache-key correctness, strings past the cache limit, payloads past the flush threshold, 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. The cost 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.)
  • 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.

https://claude.ai/code/session_01X8v1xEMwog6ZhP7hZYibFr

🤖 Generated with Claude Code

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 5.97%

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

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation test_compile_all_artifacts[_stateful_page] 26.9 ms 25.2 ms +6.53%
Simulation test_compile_page[_stateful_page] 30.4 ms 28.7 ms +6.09%
Simulation test_compile_page_full_context[_stateful_page] 34.4 ms 32.6 ms +5.3%

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 (628ce4c) with main (12d29c7)

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.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR moves component hashing into the memoization module, adds buffered deterministic encoding and bounded encoding caches, expands memo identity to cover compile artifacts, and clears hashing caches after every compile.

  • Adds deterministic encoding fast paths and compile-scoped caches for recurring strings, imports, and dataclass layouts.
  • Includes module identity, custom code, dynamic imports, hooks, and other generated artifacts in memo naming.
  • Clears memo-naming caches after successful or failed compilation.
  • Adds unit coverage for hash stability, cache limits, memo-name separation, and compile cleanup.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/components/memo.py Centralizes component hashing and memo-tag generation with buffered encoding, bounded caches, and broader compile-artifact coverage.
packages/reflex-base/src/reflex_base/components/component.py Removes the previous component-local deterministic hashing and memo-tag implementation.
reflex/app.py Clears memo-naming encoding caches from an outer compile lifecycle finally block.
tests/units/components/test_memo.py Adds coverage for deterministic encoding, cache behavior, artifact-sensitive memo names, and class/module identity.
tests/units/test_app.py Verifies cache cleanup after both successful and failed compilation.

Reviews (6): Last reviewed commit: "fix(compiler): release naming caches fro..." | 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
Comment thread packages/reflex-base/src/reflex_base/components/memo.py
Comment thread tests/units/components/test_memo.py
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
`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
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.

2 participants