Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/internal/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `==`.
Expand Down
81 changes: 81 additions & 0 deletions tests/test_hashing.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 5 additions & 2 deletions tests/test_impls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading