Skip to content

Commit 3c6bf45

Browse files
authored
FIX: stabilize caching hashes (#515)
* DOC: document hash stability on the public interface * FIX: make expression hashes independent of SymPy cache state * FIX: put sets and dicts in a fixed order in make_hashable * FIX: sort substitution mappings in cached wrappers * MAINT: hash pickled bytes directly into the digest * MAINT: use positional-only arguments and type variables
1 parent 1eb4793 commit 3c6bf45

4 files changed

Lines changed: 275 additions & 34 deletions

File tree

src/ampform/sympy/_cache.py

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,30 @@
77
import ampform.sympy._cache
88
"""
99

10+
# cspell:ignore pickler
1011
from __future__ import annotations
1112

1213
import hashlib
1314
import inspect
15+
import io
1416
import logging
1517
import os
1618
import pickle # ruff: ignore[suspicious-pickle-import]
1719
import re
1820
import sys
1921
import tempfile
2022
from collections import abc
23+
from contextlib import suppress
2124
from functools import cache, wraps
2225
from importlib.metadata import PackageNotFoundError, version
2326
from pathlib import Path
24-
from typing import TYPE_CHECKING, overload
27+
from typing import TYPE_CHECKING, NamedTuple, overload
2528

29+
import sympy as sp
2630
from frozendict import frozendict
2731

2832
if TYPE_CHECKING:
29-
from collections.abc import Hashable
33+
from collections.abc import Hashable, Iterable
3034
from io import BufferedReader
3135

3236
from _typeshed import SupportsWrite
@@ -209,7 +213,7 @@ def _get_cache_dir() -> Path:
209213

210214

211215
@cache
212-
def _warn_once(msg):
216+
def _warn_once(msg, /):
213217
_LOGGER.warning(msg)
214218

215219

@@ -239,31 +243,92 @@ def get_system_cache_directory() -> str:
239243

240244

241245
@cache
242-
def get_readable_hash(obj: Hashable) -> str:
246+
def get_readable_hash(obj: Hashable, /) -> str:
243247
"""Get a human-readable hash of any hashable Python object.
244248
249+
The hash follows from the value of the object, not from the identity of the parts it
250+
is built from. Two SymPy expressions that compare equal therefore hash the same, no
251+
matter what was constructed before them or in which process, which is what makes the
252+
hash usable as a cache key in :func:`.cache_to_disk`.
253+
254+
A `set` or `dict` handed to this function directly is serialized in iteration order,
255+
which depends on :code:`PYTHONHASHSEED` when its elements or keys are `str`. Pass it
256+
through :func:`.make_hashable` first, as :func:`.cache_to_disk` does, to put it in a
257+
fixed order.
258+
245259
Args:
246260
obj: Any hashable object, mutable or immutable, to be hashed.
247261
"""
248-
b = to_bytes(obj)
249-
h = hashlib.md5(b, usedforsecurity=False)
250-
return h.hexdigest()
262+
return hashlib.md5(to_bytes(obj), usedforsecurity=False).hexdigest()
263+
251264

265+
def to_bytes(obj, /) -> bytes:
266+
"""Convert any Python object to `bytes` with :mod:`pickle`.
252267
253-
def to_bytes(obj) -> bytes:
254-
"""Convert any Python object to `bytes` using :func:`pickle.dumps`."""
268+
The bytes depend on the value of the object rather than on which of its parts happen
269+
to be the same instance. See :func:`.get_readable_hash` for what that guarantees.
270+
"""
255271
if isinstance(obj, (bytes, bytearray)):
256272
return obj
257-
return pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
273+
stream = io.BytesIO()
274+
_dump_deterministically(obj, stream)
275+
return stream.getvalue()
276+
277+
278+
def _dump_deterministically(obj, /, stream: SupportsWrite[bytes]) -> None:
279+
"""Pickle an object so that the bytes depend on its value alone."""
280+
_DeterministicPickler(stream).dump(obj)
281+
282+
283+
class _DeterministicPickler(pickle._Pickler): # ruff: ignore[private-member-access]
284+
"""Pickler whose output does not depend on object identity.
285+
286+
A pickle stores a repeated object as a back-reference to the first time it was
287+
written, keyed on identity. SymPy hands out a cached instance for equal expressions,
288+
but its cache is a bounded LRU (:code:`SYMPY_CACHE_SIZE`), so which sub-expressions
289+
are the *same* instance depends on what was constructed before and in which order.
290+
Equal expressions are therefore mapped to one representative here, which makes the
291+
back-references depend on the expression rather than on the cache state.
292+
293+
Two SymPy objects that compare equal always carry the same
294+
:code:`_hashable_content()`, which for `.unevaluated` classes includes the arguments
295+
that are not sympified, so equal objects also pickle to the same bytes.
296+
297+
Only those canonicalized SymPy objects are memoized. Anything else is written out in
298+
full on each occurrence, because whether two equal non-SymPy objects are one shared
299+
instance depends on caches and string interning elsewhere.
300+
301+
The pure-Python pickler is subclassed because the C accelerator does not dispatch to
302+
these overrides.
303+
"""
304+
305+
def __init__(self, stream: SupportsWrite[bytes]) -> None:
306+
super().__init__(stream, protocol=pickle.HIGHEST_PROTOCOL)
307+
self._representatives: dict[tuple[type, Any], Any] = {}
308+
309+
def save(self, obj, *args, **kwargs) -> None:
310+
if isinstance(obj, sp.Basic):
311+
with suppress(TypeError):
312+
obj = self._representatives.setdefault((type(obj), obj), obj)
313+
super().save(obj, *args, **kwargs)
314+
315+
def memoize(self, obj) -> None:
316+
if isinstance(obj, sp.Basic):
317+
super().memoize(obj)
258318

259319

260320
def make_hashable(*args) -> Hashable:
261321
"""Make a hashable object from any Python object.
262322
323+
Sets and dictionaries are put in a fixed order, because a `set` iterates in an order
324+
that depends on :code:`PYTHONHASHSEED` for `str` elements, and a `dict` built by
325+
iterating one inherits that order. A cache key built from them would otherwise
326+
differ in every process.
327+
263328
>>> make_hashable("a", 1, {"b": 2}, {3, 4})
264-
('a', 1, frozendict.frozendict({'b': 2}), frozenset({3, 4}))
329+
('a', 1, frozendict.frozendict({'b': 2}), _SortedSet(items=(3, 4)))
265330
>>> make_hashable({"a": {"sub-key": {1, 2, 3}, "b": [4, 5]}})
266-
frozendict.frozendict({'a': frozendict.frozendict({'sub-key': frozenset({1, 2, 3}), 'b': (4, 5)})})
331+
frozendict.frozendict({'a': frozendict.frozendict({'b': (4, 5), 'sub-key': _SortedSet(items=(1, 2, 3))})})
267332
>>> make_hashable("already-hashable")
268333
'already-hashable'
269334
"""
@@ -272,15 +337,30 @@ def make_hashable(*args) -> Hashable:
272337
return tuple(_make_hashable_impl(x) for x in args)
273338

274339

275-
def _make_hashable_impl(obj) -> Hashable:
340+
def _make_hashable_impl(obj, /) -> Hashable:
276341
if isinstance(obj, abc.Mapping):
277-
return frozendict({k: _make_hashable_impl(v) for k, v in obj.items()})
342+
keys = _sorted_deterministically(obj.keys())
343+
return frozendict({k: _make_hashable_impl(obj[k]) for k in keys})
278344
if isinstance(obj, str):
279345
return obj
280346
if isinstance(obj, abc.Iterable):
281347
hashable_items = (_make_hashable_impl(x) for x in obj)
282348
if isinstance(obj, abc.Sequence):
283349
return tuple(hashable_items)
284350
if isinstance(obj, set):
285-
return frozenset(hashable_items)
351+
return _SortedSet(_sorted_deterministically(hashable_items))
286352
return obj
353+
354+
355+
class _SortedSet(NamedTuple):
356+
"""Order-normalized stand-in for a `set`, tagged so it cannot pass for a `tuple`."""
357+
358+
items: tuple
359+
360+
361+
def _sorted_deterministically(items: Iterable[T], /) -> tuple[T, ...]:
362+
"""Sort items, falling back to their serialization when they cannot be compared."""
363+
try:
364+
return tuple(sorted(items))
365+
except TypeError:
366+
return tuple(sorted(items, key=to_bytes))

src/ampform/sympy/cached.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
.. autofunction:: doit
44
"""
55

6+
# cspell:ignore srepr
67
from __future__ import annotations
78

89
from functools import cache
@@ -18,6 +19,7 @@
1819
from typing import TypeVar
1920

2021
SympyObject = TypeVar("SympyObject", bound=sp.Basic)
22+
V = TypeVar("V")
2123

2224

2325
@cache
@@ -59,8 +61,11 @@ def trigsimp(expr: sp.Expr, *args, **kwargs) -> sp.Expr:
5961

6062

6163
def subs(expr: sp.Expr, substitutions: Mapping[sp.Basic, Any]) -> sp.Expr:
62-
"""Call :meth:`~sympy.core.basic.Basic.subs` and cache the result to disk."""
63-
return _subs_impl(expr, frozendict(substitutions))
64+
"""Call :meth:`~sympy.core.basic.Basic.subs` and cache the result to disk.
65+
66+
The order of the substitutions does not affect the cache key.
67+
"""
68+
return _subs_impl(expr, _sorted_frozendict(substitutions))
6469

6570

6671
@cache
@@ -70,8 +75,11 @@ def _subs_impl(expr: sp.Expr, substitutions: frozendict[sp.Basic, Any]) -> sp.Ex
7075

7176

7277
def xreplace(expr: sp.Expr, substitutions: Mapping[sp.Basic, Any]) -> sp.Expr:
73-
"""Call :meth:`~sympy.core.basic.Basic.xreplace` and cache the result to disk."""
74-
return _xreplace_impl(expr, frozendict(substitutions))
78+
"""Call :meth:`~sympy.core.basic.Basic.xreplace` and cache the result to disk.
79+
80+
The order of the substitutions does not affect the cache key.
81+
"""
82+
return _xreplace_impl(expr, _sorted_frozendict(substitutions))
7583

7684

7785
@cache
@@ -104,7 +112,7 @@ def amplitudes(self) -> Mapping[sp.Basic, sp.Basic]: ...
104112

105113

106114
def _unfold_impl(expr: sp.Expr, substitutions: Mapping[sp.Basic, Any]) -> sp.Expr:
107-
substitutions = _unfold_substitutions(frozendict(substitutions))
115+
substitutions = _unfold_substitutions(_sorted_frozendict(substitutions))
108116
expr = doit(expr)
109117
return xreplace(expr, substitutions)
110118

@@ -114,3 +122,25 @@ def _unfold_substitutions(
114122
substitutions: frozendict[sp.Basic, Any],
115123
) -> frozendict[sp.Basic, Any]:
116124
return frozendict({k: doit(v) for k, v in substitutions.items()})
125+
126+
127+
def _sorted_frozendict(
128+
substitutions: Mapping[SympyObject, V], /
129+
) -> frozendict[SympyObject, V]:
130+
"""Freeze a substitution mapping in an order that does not depend on the caller.
131+
132+
The disk cache is keyed on a pickle of the mapping, and a pickled mapping is written
133+
in iteration order. A mapping built by iterating over
134+
:attr:`~sympy.core.basic.Basic.free_symbols` inherits the order of that `set`, which
135+
depends on :code:`PYTHONHASHSEED`, so the same substitutions would otherwise get a
136+
different cache key in every process.
137+
138+
`~sympy.core.sorting.default_sort_key` does not distinguish assumptions, which would
139+
leave the order of two otherwise identical symbols to the caller again, so
140+
:func:`~sympy.printing.repr.srepr` breaks those ties.
141+
"""
142+
return frozendict(sorted(substitutions.items(), key=lambda kv: _sort_key(kv[0])))
143+
144+
145+
def _sort_key(obj: Any) -> tuple[Any, str]:
146+
return sp.default_sort_key(obj), sp.srepr(obj)

0 commit comments

Comments
 (0)