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 f0f7917eb59..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 @@ -610,81 +611,194 @@ 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. +def _encode_str(buf: bytearray, value: str) -> None: + encoded = value.encode() + buf += b"s" + buf += len(encoded).to_bytes(8, "little") + buf += encoded - 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 _encode_number(buf: bytearray, value: int | float | enum.Enum) -> None: + buf += b"n" + buf += str(value).encode() + + +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_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)) + + +_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 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. 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: + 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(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. + _ENCODERS[value_type] = _encode_dataclass + + +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, +} + + +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): + 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 + return None + + +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: - 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 value is None: - hasher.update(b"N") - elif isinstance(value, bool): - hasher.update(b"T" if value else b"F") - elif isinstance(value, (int, float, enum.Enum)): - hasher.update(b"n") - hasher.update(str(value).encode()) - elif isinstance(value, str): + # 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() - hasher.update(b"s") - hasher.update(len(encoded).to_bytes(8, "little")) - hasher.update(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")) - for k, v in items: - _update_deterministic_hash(hasher, k) - _update_deterministic_hash(hasher, v) - elif isinstance(value, (tuple, list)): - hasher.update(b"l") - hasher.update(len(value).to_bytes(8, "little")) - for item in value: - _update_deterministic_hash(hasher, 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()) - 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)) - elif isinstance(value, BaseComponent): - hasher.update(b"C") - _update_deterministic_hash(hasher, 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) + buf += b"s" + buf += len(encoded).to_bytes(8, "little") + buf += encoded + return + 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 + 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: - """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. - - Raises: - TypeError: If the value is not hashable. + The hex digest over all values. """ hasher = md5(usedforsecurity=False) - _update_deterministic_hash(hasher, value) + buf = bytearray() + for value in values: + _encode_deterministic(buf, value) + hasher.update(buf) + buf.clear() return hasher.hexdigest() @@ -1510,25 +1624,25 @@ 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()) 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())) - 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 _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 hasher.hexdigest() + 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 3325e11ac4f..3a2225a54a3 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -1,10 +1,13 @@ 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 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 from reflex_base.event import ( @@ -2341,3 +2344,202 @@ 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 + ) + + +@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_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") + + 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( + _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