From 480d8ae60e40e864df7e8c32a4d6e2df36572346 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 21 Jul 2026 12:58:32 +0200 Subject: [PATCH 1/4] improve compile perf --- .../src/reflex_base/components/component.py | 133 ++++++++++++++---- 1 file changed, 103 insertions(+), 30 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index f0f7917eb59..6899b652cfb 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -610,59 +610,116 @@ def _hash_str(value: str) -> str: return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest() -def _update_deterministic_hash(hasher: Any, value: object) -> None: - """Feed ``value`` into ``hasher`` using a self-delimiting, type-tagged encoding. +@functools.cache +def _deterministic_hash_dataclass_fields(cls: type) -> tuple[tuple[str, bytes], ...]: + """Per-class cache of dataclass field names and their encoded bytes. + + ``dataclasses.fields`` rebuilds its result tuple on every call; hashing a + large app calls it millions of times (mostly for ``VarData``), so cache + the derived (name, encoded name) pairs per class. + + Args: + cls: The dataclass type to introspect. + + Returns: + The (field name, encoded field name) pairs in definition order. + """ + return tuple((f.name, f.name.encode()) for f in dataclasses.fields(cls)) + + +def _encode_deterministic(buf: bytearray, value: object) -> None: + """Append ``value`` to ``buf`` using a self-delimiting, type-tagged encoding. Each branch writes a distinct type tag plus length-prefixed payload, which keeps the encoding injective without building intermediate strings — the nested ``str([...])`` approach this replaces was the dominant cost of ``_deterministic_hash`` (~4x speedup on synthetic, ~2x on real renders). + Exact-type checks front-run the isinstance ladder: auto-memoization hashes + hundreds of millions of values per compile, nearly all of them plain + ``str``/``dict``/``list``/``tuple`` nodes from rendered component dicts, + and the ladder's isinstance calls dominated the compile profile. Subclasses + fall through to the ladder, which keeps the original branch order so the + encoding is byte-identical to the pre-dispatch implementation. + Args: - hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). - value: The value to fold into the hasher. + buf: The output buffer to append to. + value: The value to fold into the buffer. Raises: TypeError: If the value is not hashable. """ + if type(value) is str: + encoded = value.encode() + buf += b"s" + buf += len(encoded).to_bytes(8, "little") + buf += encoded + return + if type(value) is dict: + items = sorted(value.items(), key=operator.itemgetter(0)) + buf += b"d" + buf += len(items).to_bytes(8, "little") + for k, v in items: + _encode_deterministic(buf, k) + _encode_deterministic(buf, v) + return + if type(value) is list or type(value) is tuple: + buf += b"l" + buf += len(value).to_bytes(8, "little") + for item in value: + _encode_deterministic(buf, item) + return if value is None: - hasher.update(b"N") - elif isinstance(value, bool): - hasher.update(b"T" if value else b"F") + buf += b"N" + return + if type(value) is bool: + buf += b"T" if value else b"F" + return + if type(value) is int or type(value) is float: + buf += b"n" + buf += str(value).encode() + return + # Slow path for subclasses and structured types, in the original ladder + # order so subclass encodings stay identical (e.g. ``IntEnum`` must hit + # the numeric branch before the dataclass branch would see it). + if isinstance(value, bool): + buf += b"T" if value else b"F" elif isinstance(value, (int, float, enum.Enum)): - hasher.update(b"n") - hasher.update(str(value).encode()) + buf += b"n" + buf += str(value).encode() elif isinstance(value, str): encoded = value.encode() - hasher.update(b"s") - hasher.update(len(encoded).to_bytes(8, "little")) - hasher.update(encoded) + buf += b"s" + buf += len(encoded).to_bytes(8, "little") + buf += encoded elif isinstance(value, dict): items = sorted(value.items(), key=operator.itemgetter(0)) - hasher.update(b"d") - hasher.update(len(items).to_bytes(8, "little")) + buf += b"d" + buf += len(items).to_bytes(8, "little") for k, v in items: - _update_deterministic_hash(hasher, k) - _update_deterministic_hash(hasher, v) + _encode_deterministic(buf, k) + _encode_deterministic(buf, v) elif isinstance(value, (tuple, list)): - hasher.update(b"l") - hasher.update(len(value).to_bytes(8, "little")) + buf += b"l" + buf += len(value).to_bytes(8, "little") for item in value: - _update_deterministic_hash(hasher, item) + _encode_deterministic(buf, item) elif isinstance(value, Var): - hasher.update(b"v") - _update_deterministic_hash(hasher, value._js_expr) - _update_deterministic_hash(hasher, value._get_all_var_data()) + buf += b"v" + _encode_deterministic(buf, value._js_expr) + _encode_deterministic(buf, value._get_all_var_data()) elif dataclasses.is_dataclass(value): - fields = dataclasses.fields(value) - hasher.update(b"D") - hasher.update(len(fields).to_bytes(8, "little")) - for field in fields: - hasher.update(field.name.encode()) - _update_deterministic_hash(hasher, getattr(value, field.name)) + fields = _deterministic_hash_dataclass_fields( + value if isinstance(value, type) else type(value) + ) + buf += b"D" + buf += len(fields).to_bytes(8, "little") + for field_name, encoded_field_name in fields: + buf += encoded_field_name + _encode_deterministic(buf, getattr(value, field_name)) elif isinstance(value, BaseComponent): - hasher.update(b"C") - _update_deterministic_hash(hasher, value.render()) + buf += b"C" + _encode_deterministic(buf, value.render()) else: msg = ( f"Cannot hash value `{value}` of type `{type(value).__name__}`. " @@ -671,6 +728,22 @@ def _update_deterministic_hash(hasher: Any, value: object) -> None: raise TypeError(msg) +def _update_deterministic_hash(hasher: Any, value: object) -> None: + """Feed ``value`` into ``hasher`` via :func:`_encode_deterministic`. + + Buffering the whole encoding and updating the hasher once replaces the + per-node ``hasher.update`` calls (hundreds of millions per compile) with + cheap bytearray appends. + + Args: + hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). + value: The value to fold into the hasher. + """ + buf = bytearray() + _encode_deterministic(buf, value) + hasher.update(buf) + + def _deterministic_hash(value: object) -> str: """Hash a rendered dictionary. From 77a5ad28ee50861929b9e16c124cd13cea31840a Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 4 Aug 2026 20:32:28 +0200 Subject: [PATCH 2/4] refactor this. --- .../src/reflex_base/components/component.py | 247 +++++++++--------- tests/units/components/test_component.py | 107 +++++++- 2 files changed, 225 insertions(+), 129 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 6899b652cfb..6b23cb3963a 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -610,37 +610,99 @@ def _hash_str(value: str) -> str: return md5(f'"{value}"'.encode(), usedforsecurity=False).hexdigest() -@functools.cache -def _deterministic_hash_dataclass_fields(cls: type) -> tuple[tuple[str, bytes], ...]: - """Per-class cache of dataclass field names and their encoded bytes. +def _encode_str(buf: bytearray, value: str) -> None: + encoded = value.encode() + buf += b"s" + buf += len(encoded).to_bytes(8, "little") + buf += encoded - ``dataclasses.fields`` rebuilds its result tuple on every call; hashing a - large app calls it millions of times (mostly for ``VarData``), so cache - the derived (name, encoded name) pairs per class. - Args: - cls: The dataclass type to introspect. +def _encode_number(buf: bytearray, value: int | float | enum.Enum) -> None: + buf += b"n" + buf += str(value).encode() - Returns: - The (field name, encoded field name) pairs in definition order. - """ + +def _encode_dict(buf: bytearray, value: Mapping[Any, Any]) -> None: + items = sorted(value.items(), key=operator.itemgetter(0)) + buf += b"d" + buf += len(items).to_bytes(8, "little") + for k, v in items: + _encode_deterministic(buf, k) + _encode_deterministic(buf, v) + + +def _encode_sequence(buf: bytearray, value: Sequence[Any]) -> None: + buf += b"l" + buf += len(value).to_bytes(8, "little") + for item in value: + _encode_deterministic(buf, item) + + +def _encode_var(buf: bytearray, value: Var) -> None: + buf += b"v" + _encode_deterministic(buf, value._js_expr) + _encode_deterministic(buf, value._get_all_var_data()) + + +@functools.cache +def _dataclass_fields_to_encode(cls: type) -> tuple[tuple[str, bytes], ...]: + # dataclasses.fields rebuilds its result tuple on every call; hashing a + # large app calls it millions of times for a handful of classes. return tuple((f.name, f.name.encode()) for f in dataclasses.fields(cls)) -def _encode_deterministic(buf: bytearray, value: object) -> None: - """Append ``value`` to ``buf`` using a self-delimiting, type-tagged encoding. +def _encode_dataclass(buf: bytearray, value: Any) -> None: + fields = _dataclass_fields_to_encode( + value if isinstance(value, type) else type(value) + ) + buf += b"D" + buf += len(fields).to_bytes(8, "little") + for field_name, encoded_field_name in fields: + buf += encoded_field_name + _encode_deterministic(buf, getattr(value, field_name)) + + +def _encode_component(buf: bytearray, value: BaseComponent) -> None: + buf += b"C" + _encode_deterministic(buf, value.render()) + + +_ENCODERS: dict[type, Callable[[bytearray, Any], None]] = { + dict: _encode_dict, + list: _encode_sequence, + tuple: _encode_sequence, + int: _encode_number, + float: _encode_number, +} + - Each branch writes a distinct type tag plus length-prefixed payload, which - keeps the encoding injective without building intermediate strings — the - nested ``str([...])`` approach this replaces was the dominant cost of - ``_deterministic_hash`` (~4x speedup on synthetic, ~2x on real renders). +def _resolve_encoder(value: object) -> Callable[[bytearray, Any], None] | None: + # Branch order decides the encoding of values matching several branches + # (e.g. an IntEnum encodes as a number, not as a dataclass). + if isinstance(value, (int, float, enum.Enum)): + return _encode_number + if isinstance(value, str): + return _encode_str + if isinstance(value, dict): + return _encode_dict + if isinstance(value, (tuple, list)): + return _encode_sequence + if isinstance(value, Var): + return _encode_var + if dataclasses.is_dataclass(value): + return _encode_dataclass + if isinstance(value, BaseComponent): + return _encode_component + return None - Exact-type checks front-run the isinstance ladder: auto-memoization hashes - hundreds of millions of values per compile, nearly all of them plain - ``str``/``dict``/``list``/``tuple`` nodes from rendered component dicts, - and the ladder's isinstance calls dominated the compile profile. Subclasses - fall through to the ladder, which keeps the original branch order so the - encoding is byte-identical to the pre-dispatch implementation. + +def _encode_deterministic(buf: bytearray, value: object) -> None: + """Append ``value`` to ``buf`` in a self-delimiting, type-tagged encoding. + + Every type writes a distinct tag plus a length-prefixed payload, keeping the + encoding injective without building intermediate strings. Encoders are looked + up by exact type and memoized per type, since auto-memoization encodes + hundreds of millions of values per compile. Args: buf: The output buffer to append to. @@ -649,99 +711,33 @@ def _encode_deterministic(buf: bytearray, value: object) -> None: Raises: TypeError: If the value is not hashable. """ + # str, bool and None are the most common leaves by far, so they skip the + # table lookup (str inlines _encode_str). bool must come first because it + # would otherwise resolve to the numeric encoding. if type(value) is str: encoded = value.encode() buf += b"s" buf += len(encoded).to_bytes(8, "little") buf += encoded return - if type(value) is dict: - items = sorted(value.items(), key=operator.itemgetter(0)) - buf += b"d" - buf += len(items).to_bytes(8, "little") - for k, v in items: - _encode_deterministic(buf, k) - _encode_deterministic(buf, v) - return - if type(value) is list or type(value) is tuple: - buf += b"l" - buf += len(value).to_bytes(8, "little") - for item in value: - _encode_deterministic(buf, item) + value_type = type(value) + if value_type is bool: + buf += b"T" if value else b"F" return if value is None: buf += b"N" return - if type(value) is bool: - buf += b"T" if value else b"F" - return - if type(value) is int or type(value) is float: - buf += b"n" - buf += str(value).encode() - return - # Slow path for subclasses and structured types, in the original ladder - # order so subclass encodings stay identical (e.g. ``IntEnum`` must hit - # the numeric branch before the dataclass branch would see it). - if isinstance(value, bool): - buf += b"T" if value else b"F" - elif isinstance(value, (int, float, enum.Enum)): - buf += b"n" - buf += str(value).encode() - elif isinstance(value, str): - encoded = value.encode() - buf += b"s" - buf += len(encoded).to_bytes(8, "little") - buf += encoded - elif isinstance(value, dict): - items = sorted(value.items(), key=operator.itemgetter(0)) - buf += b"d" - buf += len(items).to_bytes(8, "little") - for k, v in items: - _encode_deterministic(buf, k) - _encode_deterministic(buf, v) - elif isinstance(value, (tuple, list)): - buf += b"l" - buf += len(value).to_bytes(8, "little") - for item in value: - _encode_deterministic(buf, item) - elif isinstance(value, Var): - buf += b"v" - _encode_deterministic(buf, value._js_expr) - _encode_deterministic(buf, value._get_all_var_data()) - elif dataclasses.is_dataclass(value): - fields = _deterministic_hash_dataclass_fields( - value if isinstance(value, type) else type(value) - ) - buf += b"D" - buf += len(fields).to_bytes(8, "little") - for field_name, encoded_field_name in fields: - buf += encoded_field_name - _encode_deterministic(buf, getattr(value, field_name)) - elif isinstance(value, BaseComponent): - buf += b"C" - _encode_deterministic(buf, value.render()) - else: - msg = ( - f"Cannot hash value `{value}` of type `{type(value).__name__}`. " - "Only BaseComponent, Var, VarData, dict, str, tuple, and enum.Enum are supported." - ) - raise TypeError(msg) - - -def _update_deterministic_hash(hasher: Any, value: object) -> None: - """Feed ``value`` into ``hasher`` via :func:`_encode_deterministic`. - - Buffering the whole encoding and updating the hasher once replaces the - per-node ``hasher.update`` calls (hundreds of millions per compile) with - cheap bytearray appends. - - Args: - hasher: A ``hashlib`` hasher (must accept ``.update(bytes)``). - value: The value to fold into the hasher. - """ - buf = bytearray() - _encode_deterministic(buf, value) - hasher.update(buf) + encoder = _ENCODERS.get(value_type) + if encoder is None: + encoder = _resolve_encoder(value) + if encoder is None: + msg = ( + f"Cannot hash value `{value}` of type `{value_type.__name__}`. " + "Only BaseComponent, Var, VarData, dict, str, tuple, and enum.Enum are supported." + ) + raise TypeError(msg) + _ENCODERS[value_type] = encoder + encoder(buf, value) def _deterministic_hash(value: object) -> str: @@ -752,13 +748,10 @@ def _deterministic_hash(value: object) -> str: Returns: The hash of the dictionary. - - Raises: - TypeError: If the value is not hashable. """ - hasher = md5(usedforsecurity=False) - _update_deterministic_hash(hasher, value) - return hasher.hexdigest() + buf = bytearray() + _encode_deterministic(buf, value) + return md5(buf, usedforsecurity=False).hexdigest() @dataclasses.dataclass(kw_only=True, frozen=True, slots=True) @@ -1583,25 +1576,23 @@ def _get_component_hash(self, shallow: bool = False) -> str: Returns: The hex digest content hash. """ - hasher = md5(usedforsecurity=False) - _update_deterministic_hash(hasher, self.render()) + buf = bytearray() + _encode_deterministic(buf, self.render()) if shallow: # For non-snapshot strategies, we only hash the component's own hooks, imports, custom code, and app-wrap components - _update_deterministic_hash(hasher, dict(self._get_imports())) - _update_deterministic_hash(hasher, dict(self._get_hooks_internal())) - _update_deterministic_hash(hasher, dict(self._get_added_hooks())) - _update_deterministic_hash(hasher, self._get_hooks()) - _update_deterministic_hash(hasher, self._get_custom_code()) - _update_deterministic_hash(hasher, dict(self._get_app_wrap_components())) + _encode_deterministic(buf, dict(self._get_imports())) + _encode_deterministic(buf, dict(self._get_hooks_internal())) + _encode_deterministic(buf, dict(self._get_added_hooks())) + _encode_deterministic(buf, self._get_hooks()) + _encode_deterministic(buf, self._get_custom_code()) + _encode_deterministic(buf, dict(self._get_app_wrap_components())) else: - _update_deterministic_hash(hasher, dict(self._get_all_imports())) - _update_deterministic_hash(hasher, dict(self._get_all_hooks_internal())) - _update_deterministic_hash(hasher, dict(self._get_all_hooks())) - _update_deterministic_hash(hasher, dict(self._get_all_custom_code())) - _update_deterministic_hash( - hasher, dict(self._get_all_app_wrap_components()) - ) - return hasher.hexdigest() + _encode_deterministic(buf, dict(self._get_all_imports())) + _encode_deterministic(buf, dict(self._get_all_hooks_internal())) + _encode_deterministic(buf, dict(self._get_all_hooks())) + _encode_deterministic(buf, dict(self._get_all_custom_code())) + _encode_deterministic(buf, dict(self._get_all_app_wrap_components())) + return md5(buf, usedforsecurity=False).hexdigest() def _compute_memo_tag(self) -> str: """Compute a stable tag name for memoizing this component. diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 3325e11ac4f..248ddd41054 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -1,10 +1,12 @@ import copy +import enum +from collections import namedtuple from contextlib import nullcontext from dataclasses import dataclass from typing import Any, ClassVar, TypedDict import pytest -from reflex_base.components.component import Component, field +from reflex_base.components.component import Component, _deterministic_hash, field from reflex_base.constants import EventTriggers from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import ( @@ -2341,3 +2343,106 @@ def test_get_all_hooks_internal_does_not_mutate_hooks_cache(): assert dict(parent._get_hooks_internal()) == parent_own_hooks # And repeated collection yields the same result. assert parent._get_all_hooks_internal() == combined + + +class _HashColor(enum.Enum): + RED = "red" + + +class _HashStr(str): + pass + + +class _HashDict(dict): + pass + + +class _HashList(list): + pass + + +_HashPoint = namedtuple("_HashPoint", ["x", "y"]) + + +@dataclass +class _HashDefaults: + a: int = 1 + + +def test_deterministic_hash_distinguishes_values(): + """Structurally different values must not collide.""" + values = [ + None, + True, + False, + 0, + 1, + 1.5, + "", + "1", + "true", + _HashColor.RED, + [], + [1], + [1, 2], + {}, + {"a": 1}, + {"a": "1"}, + {"a": {"b": 1}}, + ImportVar(tag="Foo"), + ImportVar(tag="Foo", is_default=True), + VarData(imports={"react": [ImportVar(tag="useState")]}), + Bare.create(contents="a"), + ] + hashes = [_deterministic_hash(value) for value in values] + assert len(set(hashes)) == len(values) + + +def test_deterministic_hash_ignores_dict_order(): + """Dicts with the same items hash the same regardless of insertion order.""" + assert _deterministic_hash({"a": 1, "b": [2, "3"]}) == _deterministic_hash({ + "b": [2, "3"], + "a": 1, + }) + + +def test_deterministic_hash_normalizes_subclasses(): + """Subclasses hash like the built-in type they encode as.""" + assert _deterministic_hash(_HashStr("x")) == _deterministic_hash("x") + assert _deterministic_hash(_HashDict({"a": 1})) == _deterministic_hash({"a": 1}) + assert _deterministic_hash(_HashList([1, 2])) == _deterministic_hash([1, 2]) + assert _deterministic_hash(_HashPoint(1, 2)) == _deterministic_hash((1, 2)) + # A bool is an int subclass, but must not encode as a number. + assert _deterministic_hash(True) != _deterministic_hash(1) + + +def test_deterministic_hash_vars_include_var_data(): + """Vars with the same JS expression but different data hash differently.""" + plain = Var(_js_expr="foo") + with_data = Var( + _js_expr="foo", + _var_data=VarData(imports={"react": [ImportVar(tag="useState")]}), + ) + assert _deterministic_hash(plain) != _deterministic_hash(with_data) + + +def test_deterministic_hash_rejects_unsupported_values(): + """Unsupported values raise, and the failure is not cached for other types.""" + with pytest.raises(TypeError): + _deterministic_hash(object()) + # Dataclass types are supported (their fields are read off the class). + assert _deterministic_hash(_HashDefaults) == _deterministic_hash(_HashDefaults) + with pytest.raises(TypeError): + _deterministic_hash(_HashStr) + + +def test_component_hash_includes_lifecycle_hooks(): + """Components differing only in on_mount must not share a hash.""" + plain = Box.create(id="hash_box") + with_mount = Box.create(id="hash_box", on_mount=rx.console_log("mounted")) + + assert plain.render() == with_mount.render() + assert plain._get_component_hash() != with_mount._get_component_hash() + assert plain._get_component_hash(shallow=True) != with_mount._get_component_hash( + shallow=True + ) From 104773cd66be09dc35cb2deaae89ffb7fde3ca06 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 4 Aug 2026 21:16:06 +0200 Subject: [PATCH 3/4] be fast --- .../src/reflex_base/components/component.py | 86 ++++++++++++++----- tests/units/components/test_component.py | 60 +++++++++++++ 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 6b23cb3963a..fa426bc86fc 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -662,6 +662,36 @@ def _encode_dataclass(buf: bytearray, value: Any) -> None: _encode_deterministic(buf, getattr(value, field_name)) +_IMMUTABLE_FIELD_TYPES = (str, bool, int, float, type(None)) +_MAX_ENCODED_DATACLASSES = 8192 +# Encodings of frozen dataclass instances whose fields are all immutable +# scalars, keyed by ``id``. Each entry keeps the instance alive, so its id +# cannot be reused while cached and a lookup hit is always the same object, +# whose encoding can never have changed. +_ENCODED_DATACLASSES: dict[int, tuple[object, bytes]] = {} + + +def _encode_frozen_dataclass(buf: bytearray, value: Any) -> None: + entry = _ENCODED_DATACLASSES.get(id(value)) + if entry is not None: + buf += entry[1] + return + start = len(buf) + _encode_dataclass(buf, value) + value_type = type(value) + if all( + type(getattr(value, field_name)) in _IMMUTABLE_FIELD_TYPES + for field_name, _ in _dataclass_fields_to_encode(value_type) + ): + if len(_ENCODED_DATACLASSES) >= _MAX_ENCODED_DATACLASSES: + _ENCODED_DATACLASSES.clear() + _ENCODED_DATACLASSES[id(value)] = (value, bytes(buf[start:])) + else: + # A field holds something mutable (or a Var, dict, component, ...), so + # this class is never cacheable: stop paying for the check. + _ENCODERS[value_type] = _encode_dataclass + + def _encode_component(buf: bytearray, value: BaseComponent) -> None: buf += b"C" _encode_deterministic(buf, value.render()) @@ -690,6 +720,8 @@ def _resolve_encoder(value: object) -> Callable[[bytearray, Any], None] | None: if isinstance(value, Var): return _encode_var if dataclasses.is_dataclass(value): + if not isinstance(value, type) and type(value).__dataclass_params__.frozen: # pyright: ignore[reportAttributeAccessIssue] + return _encode_frozen_dataclass return _encode_dataclass if isinstance(value, BaseComponent): return _encode_component @@ -740,18 +772,28 @@ def _encode_deterministic(buf: bytearray, value: object) -> None: encoder(buf, value) -def _deterministic_hash(value: object) -> str: - """Hash a rendered dictionary. +def _deterministic_hash(*values: object) -> str: + """Hash values into a single digest, in the order given. + + Encoding into a buffer instead of feeding the hasher node by node is what + makes hashing cheap, at the cost of holding one value's encoding in memory + (a few MB for a large page). Each value is flushed into the hasher before + the next one is encoded, so peak memory stays at the largest single value + rather than their sum. Args: - value: The dictionary to hash. + *values: The values to hash. Returns: - The hash of the dictionary. + The hex digest over all values. """ + hasher = md5(usedforsecurity=False) buf = bytearray() - _encode_deterministic(buf, value) - return md5(buf, usedforsecurity=False).hexdigest() + for value in values: + _encode_deterministic(buf, value) + hasher.update(buf) + buf.clear() + return hasher.hexdigest() @dataclasses.dataclass(kw_only=True, frozen=True, slots=True) @@ -1576,23 +1618,25 @@ def _get_component_hash(self, shallow: bool = False) -> str: Returns: The hex digest content hash. """ - buf = bytearray() - _encode_deterministic(buf, self.render()) if shallow: # For non-snapshot strategies, we only hash the component's own hooks, imports, custom code, and app-wrap components - _encode_deterministic(buf, dict(self._get_imports())) - _encode_deterministic(buf, dict(self._get_hooks_internal())) - _encode_deterministic(buf, dict(self._get_added_hooks())) - _encode_deterministic(buf, self._get_hooks()) - _encode_deterministic(buf, self._get_custom_code()) - _encode_deterministic(buf, dict(self._get_app_wrap_components())) - else: - _encode_deterministic(buf, dict(self._get_all_imports())) - _encode_deterministic(buf, dict(self._get_all_hooks_internal())) - _encode_deterministic(buf, dict(self._get_all_hooks())) - _encode_deterministic(buf, dict(self._get_all_custom_code())) - _encode_deterministic(buf, dict(self._get_all_app_wrap_components())) - return md5(buf, usedforsecurity=False).hexdigest() + return _deterministic_hash( + self.render(), + dict(self._get_imports()), + dict(self._get_hooks_internal()), + dict(self._get_added_hooks()), + self._get_hooks(), + self._get_custom_code(), + dict(self._get_app_wrap_components()), + ) + return _deterministic_hash( + self.render(), + dict(self._get_all_imports()), + dict(self._get_all_hooks_internal()), + dict(self._get_all_hooks()), + dict(self._get_all_custom_code()), + dict(self._get_all_app_wrap_components()), + ) def _compute_memo_tag(self) -> str: """Compute a stable tag name for memoizing this component. diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 248ddd41054..3e62bdbf531 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -6,6 +6,7 @@ from typing import Any, ClassVar, TypedDict import pytest +from reflex_base.components import component from reflex_base.components.component import Component, _deterministic_hash, field from reflex_base.constants import EventTriggers from reflex_base.constants.state import FIELD_MARKER @@ -2446,3 +2447,62 @@ def test_component_hash_includes_lifecycle_hooks(): assert plain._get_component_hash(shallow=True) != with_mount._get_component_hash( shallow=True ) + + +@dataclass(frozen=True) +class _HashFrozenScalars: + a: int | bool + b: str = "x" + + +@dataclass(frozen=True) +class _HashFrozenContainer: + items: list[int] + + +@dataclass +class _HashMutable: + a: int + + +def test_deterministic_hash_caches_frozen_dataclasses_by_identity(): + """Cached and freshly encoded instances of the same content hash the same.""" + shared = ImportVar(tag="Shared") + equal = ImportVar(tag="Shared") + + # First encode populates the cache, the second must reuse it, and an equal + # but distinct instance must encode to the same bytes either way. + assert _deterministic_hash([shared, shared]) == _deterministic_hash([shared, equal]) + assert _deterministic_hash([equal, shared]) == _deterministic_hash([shared, shared]) + + +def test_deterministic_hash_never_conflates_equal_but_differently_typed_fields(): + """``True`` and ``1`` compare equal but must never share an encoding.""" + assert _deterministic_hash(_HashFrozenScalars(a=True)) != _deterministic_hash( + _HashFrozenScalars(a=1) + ) + + +def test_deterministic_hash_tracks_mutation_of_uncacheable_dataclasses(): + """Dataclasses that can still change must be re-encoded every time.""" + mutable = _HashMutable(a=1) + before = _deterministic_hash(mutable) + mutable.a = 2 + assert _deterministic_hash(mutable) != before + + # Frozen, but a field holds a mutable container. + container = _HashFrozenContainer(items=[1]) + before = _deterministic_hash(container) + container.items.append(2) + assert _deterministic_hash(container) != before + + +def test_deterministic_hash_survives_encoding_cache_eviction(monkeypatch): + """Evicting the encoding cache must not change any digest.""" + values = [ImportVar(tag=f"Evict{index}") for index in range(32)] + expected = [_deterministic_hash(value) for value in values] + + monkeypatch.setattr(component, "_MAX_ENCODED_DATACLASSES", 4) + component._ENCODED_DATACLASSES.clear() + assert [_deterministic_hash(value) for value in values] == expected + assert len(component._ENCODED_DATACLASSES) <= 4 From 19861aa87280186c057413e2f4f4e8f56c580acc Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 4 Aug 2026 21:58:45 +0200 Subject: [PATCH 4/4] review and changelog --- packages/reflex-base/news/6804.performance.md | 1 + .../src/reflex_base/components/component.py | 18 +++++--- tests/units/components/test_component.py | 45 +++++++++++++++++-- 3 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 packages/reflex-base/news/6804.performance.md diff --git a/packages/reflex-base/news/6804.performance.md b/packages/reflex-base/news/6804.performance.md new file mode 100644 index 00000000000..0a11cd4e84c --- /dev/null +++ b/packages/reflex-base/news/6804.performance.md @@ -0,0 +1 @@ +Speed up the content hash behind compiler auto-memoization: values are encoded into a buffer through a per-type encoder table resolved once per type, and the encodings of frozen dataclasses with immutable fields (such as `ImportVar`) are reused by object identity. Hashing a large page's component tree is roughly 3.7x faster, and the resulting hashes are unchanged. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index fa426bc86fc..f5e457ab0fa 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -10,6 +10,7 @@ import operator import typing from abc import ABC, ABCMeta, abstractmethod +from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import _MISSING_TYPE, MISSING from hashlib import md5 @@ -664,11 +665,15 @@ def _encode_dataclass(buf: bytearray, value: Any) -> None: _IMMUTABLE_FIELD_TYPES = (str, bool, int, float, type(None)) _MAX_ENCODED_DATACLASSES = 8192 +_MAX_ENCODED_DATACLASS_SIZE = 512 # Encodings of frozen dataclass instances whose fields are all immutable -# scalars, keyed by ``id``. Each entry keeps the instance alive, so its id +# scalars, keyed by ``id``. Each entry keeps its instance alive, so an id # cannot be reused while cached and a lookup hit is always the same object, -# whose encoding can never have changed. -_ENCODED_DATACLASSES: dict[int, tuple[object, bytes]] = {} +# whose encoding can never have changed. Retention is bounded to +# _MAX_ENCODED_DATACLASSES entries of at most _MAX_ENCODED_DATACLASS_SIZE +# bytes each, evicted oldest-first so a working set past the cap degrades +# entry by entry instead of being dropped wholesale. +_ENCODED_DATACLASSES: OrderedDict[int, tuple[object, bytes]] = OrderedDict() def _encode_frozen_dataclass(buf: bytearray, value: Any) -> None: @@ -683,9 +688,10 @@ def _encode_frozen_dataclass(buf: bytearray, value: Any) -> None: type(getattr(value, field_name)) in _IMMUTABLE_FIELD_TYPES for field_name, _ in _dataclass_fields_to_encode(value_type) ): - if len(_ENCODED_DATACLASSES) >= _MAX_ENCODED_DATACLASSES: - _ENCODED_DATACLASSES.clear() - _ENCODED_DATACLASSES[id(value)] = (value, bytes(buf[start:])) + if len(buf) - start <= _MAX_ENCODED_DATACLASS_SIZE: + if len(_ENCODED_DATACLASSES) >= _MAX_ENCODED_DATACLASSES: + _ENCODED_DATACLASSES.popitem(last=False) + _ENCODED_DATACLASSES[id(value)] = (value, bytes(buf[start:])) else: # A field holds something mutable (or a Var, dict, component, ...), so # this class is never cacheable: stop paying for the check. diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 3e62bdbf531..3a2225a54a3 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -2465,17 +2465,54 @@ class _HashMutable: a: int -def test_deterministic_hash_caches_frozen_dataclasses_by_identity(): - """Cached and freshly encoded instances of the same content hash the same.""" +def test_deterministic_hash_reuses_frozen_dataclass_encoding(monkeypatch): + """A frozen scalar dataclass is encoded once and then reused by identity.""" + shared = ImportVar(tag="Shared") + component._ENCODED_DATACLASSES.clear() + + digest = _deterministic_hash(shared) + assert id(shared) in component._ENCODED_DATACLASSES + + def unreachable(buf: bytearray, value: object) -> None: + pytest.fail("cached encoding was re-encoded instead of reused") + + monkeypatch.setattr(component, "_encode_dataclass", unreachable) + assert _deterministic_hash(shared) == digest + + +def test_deterministic_hash_matches_uncached_encoding(): + """A cached encoding must equal a freshly encoded, equal instance.""" shared = ImportVar(tag="Shared") equal = ImportVar(tag="Shared") - # First encode populates the cache, the second must reuse it, and an equal - # but distinct instance must encode to the same bytes either way. assert _deterministic_hash([shared, shared]) == _deterministic_hash([shared, equal]) assert _deterministic_hash([equal, shared]) == _deterministic_hash([shared, shared]) +def test_encoding_cache_evicts_only_the_oldest_entry(monkeypatch): + """Passing the cap drops the oldest entry, not the whole working set.""" + monkeypatch.setattr(component, "_MAX_ENCODED_DATACLASSES", 2) + component._ENCODED_DATACLASSES.clear() + values = [ImportVar(tag=f"Evict{index}") for index in range(3)] + digests = [_deterministic_hash(value) for value in values] + + assert len(component._ENCODED_DATACLASSES) == 2 + assert id(values[0]) not in component._ENCODED_DATACLASSES + assert id(values[1]) in component._ENCODED_DATACLASSES + assert id(values[2]) in component._ENCODED_DATACLASSES + assert [_deterministic_hash(value) for value in values] == digests + + +def test_encoding_cache_skips_oversized_encodings(): + """Outsized encodings are not retained, keeping the cache's memory bounded.""" + component._ENCODED_DATACLASSES.clear() + oversized = ImportVar(tag="x" * (component._MAX_ENCODED_DATACLASS_SIZE + 1)) + + digest = _deterministic_hash(oversized) + assert id(oversized) not in component._ENCODED_DATACLASSES + assert _deterministic_hash(oversized) == digest + + def test_deterministic_hash_never_conflates_equal_but_differently_typed_fields(): """``True`` and ``1`` compare equal but must never share an encoding.""" assert _deterministic_hash(_HashFrozenScalars(a=True)) != _deterministic_hash(