Skip to content

Commit b8ffbd1

Browse files
committed
Move xxhash to the "performance" extra
1 parent c569a5d commit b8ffbd1

4 files changed

Lines changed: 190 additions & 9 deletions

File tree

docs/concepts/caching.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,21 @@ Data version
357357

358358
Caching requires the ability to uniquely identify data (e.g., create a hash). By default, all Python primitive types (``int``, ``str``, ``dict``, etc.) are supported and more types can be added via extensions (e.g., ``pandas``). For types not explicitly supported, caching can still function by versioning the object's internal ``__dict__`` instead. However, this could be expensive to compute or less reliable than alternatives.
359359

360+
Hashing backend
361+
~~~~~~~~~~~~~~~
362+
363+
By default, hashing uses the standard library's ``hashlib.md5`` (chosen for speed, not for cryptographic security). Installing the optional `xxhash <https://github.com/ifduyue/python-xxhash>`_ dependency switches the backend to ``xxhash.xxh3_128``, which is substantially faster on buffer-bound paths (e.g. numpy arrays, polars DataFrames):
364+
365+
.. code-block:: console
366+
367+
pip install "apache-hamilton[performance]"
368+
369+
Both backends produce a 16-byte digest, so cache keys and ``data_version`` strings are unaffected in shape. However, the two algorithms produce different digests for the same input, so **installing or removing xxhash changes every ``data_version`` in your dataflow**, invalidating previously persisted caches. Cached results are just recomputed on the next run, at the cost of losing the benefit of the existing cache.
370+
371+
.. note::
372+
373+
Because the resolved backend is process-wide, keep it consistent across the environments that share a cache (e.g. all workers writing to the same cache store) to avoid needless cache misses.
374+
360375
Recursion depth
361376
~~~~~~~~~~~~~~~
362377

hamilton/caching/fingerprinting.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@
3333
implementation should pass the `depth` parameter to prevent `RecursionError`.
3434
"""
3535

36+
from __future__ import annotations
37+
3638
import base64
3739
import datetime
3840
import functools
3941
import logging
4042
import sys
4143
from collections.abc import Mapping, Sequence, Set
42-
43-
import xxhash
44+
from typing import TYPE_CHECKING
4445

4546
from hamilton.experimental import h_databackends
4647

@@ -50,6 +51,31 @@
5051
except ImportError:
5152
NoneType = type(None)
5253

54+
if TYPE_CHECKING:
55+
from collections.abc import Buffer, Callable
56+
from typing import Protocol
57+
58+
class Hash(Protocol):
59+
def digest(self) -> bytes: ...
60+
61+
62+
hash_func: Callable[[str | Buffer], Hash]
63+
try:
64+
# xxh3_128 produces a 16-byte digest (24 base64url chars, the same width as the
65+
# md5 it replaces) while running substantially faster on buffer-bound paths.
66+
# TODO(2.0): revisit once/if a dependency-free `apache-hamilton-core` package
67+
# exists, so this optional performance dependency lands in the right tier.
68+
import xxhash
69+
70+
hash_func = xxhash.xxh3_128
71+
except (ModuleNotFoundError, AttributeError):
72+
# ModuleNotFoundError covers xxhash not being installed; AttributeError
73+
# covers an xxhash older than 0.8.0 (which added xxh3_128).
74+
# usedforsecurity=False avoids a ValueError on FIPS-mode Python; it
75+
# doesn't change the digest.
76+
import hashlib
77+
78+
hash_func = functools.partial(hashlib.md5, usedforsecurity=False)
5379

5480
logger = logging.getLogger("hamilton.caching")
5581

@@ -78,16 +104,20 @@ def _compact_hash(digest: bytes) -> str:
78104

79105

80106
def _hash_bytes(data: bytes) -> str:
81-
"""Hash raw bytes with the non-cryptographic xxh3_128 algorithm and
82-
compact-encode the digest.
107+
"""Hash raw bytes and compact-encode the digest.
83108
84109
All hashing in this module routes through this single helper so the
85110
underlying hashing algorithm can be changed in exactly one place.
86-
xxh3_128 produces a 16-byte digest (24 base64url chars, the same width
87-
as the md5 it replaces) while running substantially faster on
88-
buffer-bound paths.
111+
112+
Uses the non-cryptographic xxh3_128 algorithm when the optional
113+
``xxhash`` package is installed (see the ``performance`` extra),
114+
falling back to hashlib's md5 otherwise. Both produce a 16-byte digest
115+
(24 base64url chars), so digest width is unaffected either way, but the
116+
two algorithms don't produce the same bytes for the same input:
117+
fingerprints are stable within an environment, not across installs with
118+
a different backend.
89119
"""
90-
return _compact_hash(xxhash.xxh3_128(data).digest())
120+
return _compact_hash(hash_func(data).digest())
91121

92122

93123
@functools.singledispatch

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ dependencies = [
5151
"pandas",
5252
"typing_extensions > 4.0.0",
5353
"typing_inspect",
54-
"xxhash>=0.8.0",
5554
]
5655

5756
[project.optional-dependencies]
@@ -71,6 +70,10 @@ experiments = [
7170
lsp = ["apache-hamilton-lsp"]
7271
openlineage = ["openlineage-python"]
7372
pandera = ["pandera"]
73+
performance = [
74+
# Internal performance boosters go here
75+
"xxhash>=0.8.0",
76+
]
7477
pydantic = ["pydantic>=2.0"]
7578
pyspark = [
7679
# we have to run these dependencies because Spark does not check to ensure the right target was called
@@ -135,6 +138,7 @@ test = [
135138
"xgboost; python_version < '3.14'",
136139
"xlsx2csv", # for excel data loader
137140
"xlsxwriter", # Excel export requires 'xlsxwriter'
141+
"xxhash>=0.8.0", # exercises the high-performance fingerprinting path in tests
138142
]
139143
docs = [
140144
{include-group = "dev"},

tests/caching/test_fingerprinting.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,28 @@
2020
the original `hash_value()` and the `hash_primitive()` functions.
2121
"""
2222

23+
import functools
24+
import hashlib
25+
import importlib
26+
import sys
27+
import types
28+
2329
import numpy as np
2430
import pandas as pd
2531
import pytest
2632

2733
from hamilton.caching import fingerprinting
2834

2935

36+
@pytest.fixture
37+
def force_md5_backend(monkeypatch):
38+
"""Force the hashlib.md5 fallback backend, regardless of whether xxhash
39+
is installed in the current environment."""
40+
monkeypatch.setattr(
41+
fingerprinting, "hash_func", functools.partial(hashlib.md5, usedforsecurity=False)
42+
)
43+
44+
3045
def test_hash_none():
3146
fingerprint = fingerprinting.hash_value(None)
3247
assert fingerprint == "<none>"
@@ -226,6 +241,123 @@ def test_hash_numpy():
226241
assert fingerprint == expected_hash
227242

228243

244+
# ---------------------------------------------------------------------------
245+
# hashlib.md5 fallback backend
246+
#
247+
# These mirror the pinned tests above but force `hash_func` to the hashlib.md5
248+
# fallback (via the `force_md5_backend` fixture) so the fallback path used
249+
# when `xxhash` isn't installed is verified regardless of what's installed in
250+
# the environment running the suite. Expected digests are the pre-xxh3_128
251+
# values this module used before xxhash became the default backend.
252+
# ---------------------------------------------------------------------------
253+
254+
255+
@pytest.mark.usefixtures("force_md5_backend")
256+
@pytest.mark.parametrize(
257+
("obj", "expected_hash"),
258+
[
259+
("hello-world", "L1Q1Kh6_t1atHO_H8RbBeA=="),
260+
(17.31231, "mJPTpPyXDSZgU-u8NuztIQ=="),
261+
(16474, "6MgAp1NbMW0ZZpe_8iKVsg=="),
262+
(True, "J2eGynSuIpd5bwVQzO9VVg=="),
263+
(b"\x951!\x89u=\xe6\xadG\xdf", "d1DufDgRQmqi9Kt4Z2PeUQ=="),
264+
],
265+
)
266+
def test_hash_primitive_md5_fallback(obj, expected_hash):
267+
fingerprint = fingerprinting.hash_primitive(obj)
268+
assert fingerprint == expected_hash
269+
270+
271+
@pytest.mark.usefixtures("force_md5_backend")
272+
@pytest.mark.parametrize(
273+
("obj", "expected_hash"),
274+
[
275+
([0, True, "hello-world"], "mlOjj4yeCrSDFSn5zgdEIg=="),
276+
((17.0, False, "world"), "BcRSGfyKeIOdym9B6TmAyQ=="),
277+
],
278+
)
279+
def test_hash_sequence_md5_fallback(obj, expected_hash):
280+
fingerprint = fingerprinting.hash_sequence(obj)
281+
assert fingerprint == expected_hash
282+
283+
284+
@pytest.mark.usefixtures("force_md5_backend")
285+
def test_hash_ordered_mapping_md5_fallback():
286+
obj = {0: True, "key": "value", 17.0: None}
287+
expected_hash = "GyxyI9-pq-EJJvSAIN509g=="
288+
fingerprint = fingerprinting.hash_mapping(obj, ignore_order=False)
289+
assert fingerprint == expected_hash
290+
291+
292+
@pytest.mark.usefixtures("force_md5_backend")
293+
def test_hash_unordered_mapping_md5_fallback():
294+
obj = {0: True, "key": "value", 17.0: None}
295+
expected_hash = "cDuuL2eA3DaSWlWW3u7o9g=="
296+
fingerprint = fingerprinting.hash_mapping(obj, ignore_order=True)
297+
assert fingerprint == expected_hash
298+
299+
300+
@pytest.mark.usefixtures("force_md5_backend")
301+
def test_hash_set_md5_fallback():
302+
obj = {0, True, "key", "value", 17.0, None}
303+
expected_hash = "E_f_tjbi6qn7KL3NUCZayg=="
304+
fingerprint = fingerprinting.hash_set(obj)
305+
assert fingerprint == expected_hash
306+
307+
308+
@pytest.mark.usefixtures("force_md5_backend")
309+
def test_hash_numpy_md5_fallback():
310+
array = np.array([[0, 1], [2, 3]], dtype=np.int64)
311+
expected_hash = "024zwZIcWy6r4dlX4AMTow=="
312+
fingerprint = fingerprinting.hash_value(array)
313+
assert fingerprint == expected_hash
314+
315+
316+
def test_hash_bytes_digest_width():
317+
"""Both backends produce a 16-byte digest (24 base64url chars), so swapping
318+
the algorithm doesn't change the shape of cache keys / `data_version` strings.
319+
"""
320+
assert len(fingerprinting._hash_bytes(b"x")) == 24
321+
322+
323+
@pytest.mark.usefixtures("force_md5_backend")
324+
def test_hash_bytes_digest_width_md5_fallback():
325+
assert len(fingerprinting._hash_bytes(b"x")) == 24
326+
327+
328+
# ---------------------------------------------------------------------------
329+
# Import-time backend resolution
330+
#
331+
# These reload the module to actually exercise the try/except at import time
332+
# (as opposed to `force_md5_backend`, which only overrides the already-resolved
333+
# `hash_func`), then restore the module to its real, ambient-environment state
334+
# so later tests aren't affected.
335+
# ---------------------------------------------------------------------------
336+
337+
338+
def test_falls_back_when_xxhash_not_installed(monkeypatch):
339+
"""Simulate xxhash being absent: `import xxhash` raises ModuleNotFoundError."""
340+
monkeypatch.setitem(sys.modules, "xxhash", None)
341+
try:
342+
reloaded = importlib.reload(fingerprinting)
343+
assert reloaded.hash_func.func is hashlib.md5
344+
assert reloaded.hash_func.keywords == {"usedforsecurity": False}
345+
finally:
346+
importlib.reload(fingerprinting)
347+
348+
349+
def test_falls_back_when_xxhash_lacks_xxh3_128(monkeypatch):
350+
"""Simulate an xxhash older than 0.8.0, which doesn't define xxh3_128."""
351+
stub = types.ModuleType("xxhash")
352+
monkeypatch.setitem(sys.modules, "xxhash", stub)
353+
try:
354+
reloaded = importlib.reload(fingerprinting)
355+
assert reloaded.hash_func.func is hashlib.md5
356+
assert reloaded.hash_func.keywords == {"usedforsecurity": False}
357+
finally:
358+
importlib.reload(fingerprinting)
359+
360+
229361
def test_hash_numpy_different_shapes_differ():
230362
"""Arrays with the same raw bytes but different shapes must hash differently."""
231363
a = np.array([1, 2, 3, 4, 5, 6])

0 commit comments

Comments
 (0)