From 2297a78c99a183c96db5081ec675760ee6dceff8 Mon Sep 17 00:00:00 2001 From: Roman Varnava Date: Wed, 28 Oct 2026 13:19:55 +0300 Subject: [PATCH 1/3] perf: mix Python hashes so hashbrown control tags are well distributed The vendored hashbrown takes its 7-bit SIMD control tag from the top bits of the hash. CPython hashes are often low-entropy there: hash(n) == n for small ints, and many floats hash to small values. All such keys got tag 0, so every group probe matched every occupied slot and fell back to Python __eq__ calls. Fold-multiply the hash once in pyobject_hash (as foldhash/wyhash do). The mixed value is only used inside the tables; it is never exposed or pickled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KRuJihprbupFprYtRYAs92 --- src/internal/utils.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/internal/utils.rs b/src/internal/utils.rs index 8609b2e..8db7c39 100644 --- a/src/internal/utils.rs +++ b/src/internal/utils.rs @@ -20,7 +20,22 @@ pub unsafe fn pyobject_hash( return Err(pyo3::PyErr::take(py).unwrap()); } - Ok(py_hash as u64) + Ok(mix_hash(py_hash as u64)) +} + +/// Spreads entropy of a Python hash across all 64 bits. +/// +/// CPython hashes are frequently low-entropy in the high bits: `hash(n) == n` for +/// small ints, and many floats/tuples hash to small values. The vendored hashbrown +/// table takes its 7-bit SIMD control tag from the **top** bits of the hash, so such +/// keys all get the same tag and every probe degenerates into a chain of Python +/// `__eq__` calls. A folded 128-bit multiply (as in foldhash/wyhash) fixes the tag +/// distribution for ~1 ns. The mixed value never leaves the table. +#[inline(always)] +pub fn mix_hash(h: u64) -> u64 { + const K: u64 = 0x9E37_79B9_7F4A_7C15; + let p = (h ^ K) as u128 * K as u128; + (p as u64) ^ ((p >> 64) as u64) } /// Pointer-equality fast path, then Python `==`. From 9165ab8115649afc7049fadfd23a25461a2a54a8 Mon Sep 17 00:00:00 2001 From: Roman Varnava Date: Wed, 28 Oct 2026 14:05:26 +0300 Subject: [PATCH 2/3] test: fix race in TTLCache test_sweep_interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries expired at t≈3s and the sweeper woke via Event.wait(3) at the same moment. Event.wait() may return a few ms early (Windows timer resolution is ~15.6 ms), so the sweep could run just before expiry; the next one was at t≈6s, after the assert at 3.5s. Seen as `assert 3 == 0` on windows-latest. Use ttl=1 with sweep_interval=1 and assert at 2.5s, so at least one full sweep runs after expiry. Also makes the test a second faster. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KRuJihprbupFprYtRYAs92 --- tests/test_impls.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_impls.py b/tests/test_impls.py index 20141c5..6d1ff19 100644 --- a/tests/test_impls.py +++ b/tests/test_impls.py @@ -1550,11 +1550,14 @@ def test_items_with_expire(self): assert isinstance(ttl, float) def test_sweep_interval(self): - obj = cachebox.TTLCache(10, 3, {1: 1, 2: 2, 3: 3}, sweep_interval=3) + # Entries expire at t≈1s and the sweeper wakes every second. The sweep at + # t≈1s races with expiry (Event.wait() may return a few ms early, notably on + # Windows), so assert only after the next sweep at t≈2s has surely run. + obj = cachebox.TTLCache(10, 1, {1: 1, 2: 2, 3: 3}, sweep_interval=1) # __len__ doesn't call expire itself assert len(obj) == 3 - time.sleep(3.5) + time.sleep(2.5) assert len(obj) == 0 From ed4bef372e00a56351ae9d3cd4988810abfcd04e Mon Sep 17 00:00:00 2001 From: Roman Varnava Date: Wed, 28 Oct 2026 14:13:13 +0300 Subject: [PATCH 3/3] test: key hashing edge cases for every cache type Equal numbers (1, 1.0, True) share one entry, keys with equal Python hashes (-1, -2) stay distinct, fully colliding keys work, and low-entropy keys (small/negative/huge ints, floats) round-trip and delete correctly. Co-Authored-By: Claude Opus 5.5 --- tests/test_hashing.py | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_hashing.py diff --git a/tests/test_hashing.py b/tests/test_hashing.py new file mode 100644 index 0000000..7b6e613 --- /dev/null +++ b/tests/test_hashing.py @@ -0,0 +1,81 @@ +"""Key hashing edge cases, for every cache type. + +The tables mix Python hashes internally; these tests pin down that key +identity still follows Python semantics (equal objects share an entry, equal +hashes alone do not). +""" + +import pytest + +import cachebox + + +def _make(cache_cls, maxsize=0): + if cache_cls is cachebox.TTLCache: + return cache_cls(maxsize, 3600) + return cache_cls(maxsize) + + +def test_equal_numbers_share_one_entry(cache_cls): + c = _make(cache_cls) + c[1] = "int" + c[1.0] = "float" + c[True] = "bool" + assert len(c) == 1 + assert c[1] == "bool" + + +def test_equal_hash_but_unequal_keys_are_distinct(cache_cls): + # In CPython hash(-1) == hash(-2) == -2. + assert hash(-1) == hash(-2) + c = _make(cache_cls) + c[-1] = "minus one" + c[-2] = "minus two" + assert len(c) == 2 + assert c[-1] == "minus one" + assert c[-2] == "minus two" + + +class _ConstantHash: + def __init__(self, n): + self.n = n + + def __hash__(self): + return 42 + + def __eq__(self, other): + return isinstance(other, _ConstantHash) and other.n == self.n + + +def test_all_keys_colliding(cache_cls): + c = _make(cache_cls) + keys = [_ConstantHash(i) for i in range(100)] + for k in keys: + c[k] = k.n + assert len(c) == 100 + for k in keys: + assert c[_ConstantHash(k.n)] == k.n + + +@pytest.mark.parametrize( + "keys", + [ + range(2_000), + range(-1_000, 1_000), + [i * 2**61 for i in range(1, 1_000)], + [i + 0.5 for i in range(1_000)], + ], + ids=["small-ints", "negative-ints", "huge-ints", "floats"], +) +def test_many_low_entropy_keys(cache_cls, keys): + keys = list(keys) + c = _make(cache_cls) + for k in keys: + c[k] = k + assert len(c) == len(set(keys)) + for k in keys: + assert c[k] == k + for k in keys[::2]: + del c[k] + for i, k in enumerate(keys): + assert (k in c) == (i % 2 == 1)