Skip to content

Use CPython hash algorithm for frozenset #5164

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 10, 2024
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
2 changes: 0 additions & 2 deletions Lib/test/test_collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -1838,8 +1838,6 @@ def __repr__(self):
self.assertTrue(f1 != l1)
self.assertTrue(f1 != l2)

# TODO: RUSTPYTHON
@unittest.expectedFailure
def test_Set_hash_matches_frozenset(self):
sets = [
{}, {1}, {None}, {-1}, {0.0}, {"abc"}, {1, 2, 3},
Expand Down
12 changes: 9 additions & 3 deletions Lib/test/test_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,8 +707,6 @@ def test_hash_caching(self):
f = self.thetype('abcdcda')
self.assertEqual(hash(f), hash(f))

# TODO: RUSTPYTHON
@unittest.expectedFailure
def test_hash_effectiveness(self):
n = 13
hashvalues = set()
Expand All @@ -730,7 +728,15 @@ def powerset(s):
for i in range(len(s)+1):
yield from map(frozenset, itertools.combinations(s, i))

for n in range(18):
# TODO: RUSTPYTHON
# The original test has:
# for n in range(18):
# Due to general performance overhead, hashing a frozenset takes
# about 50 times longer than in CPython. This test amplifies that
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it take this long time even with release build?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, as I understand cargo bench always uses a release build.

These are some benchmarks from the original hash function (the new algorithm slows things down 10%-20%):

CPython

microbenchmarks/cpython/frozenset.py
                        time:   [464.42 ns 465.94 ns 467.92 ns]
                        thrpt:  [213.71 Melem/s 214.62 Melem/s 215.32 Melem/s]
microbenchmarks/cpython/frozenset.py #2
                        time:   [468.92 ns 470.99 ns 473.70 ns]
                        thrpt:  [633.31 Melem/s 636.96 Melem/s 639.76 Melem/s]
microbenchmarks/cpython/frozenset.py #3
                        time:   [1.5223 µs 1.5513 µs 1.5795 µs]
                        thrpt:  [316.57 Melem/s 322.30 Melem/s 328.45 Melem/s]
microbenchmarks/cpython/frozenset.py #4
                        time:   [1.7608 µs 1.7734 µs 1.7854 µs]
                        thrpt:  [392.06 Melem/s 394.73 Melem/s 397.56 Melem/s]
microbenchmarks/cpython/frozenset.py #5
                        time:   [2.0057 µs 2.0986 µs 2.1882 µs]
                        thrpt:  [411.30 Melem/s 428.87 Melem/s 448.72 Melem/s]

RustPython

microbenchmarks/rustpython/frozenset.py
                        time:   [4.6804 µs 4.6883 µs 4.6969 µs]
                        thrpt:  [21.290 Melem/s 21.330 Melem/s 21.366 Melem/s]
microbenchmarks/rustpython/frozenset.py #2
                        time:   [13.022 µs 13.064 µs 13.106 µs]
                        thrpt:  [22.891 Melem/s 22.963 Melem/s 23.039 Melem/s]
microbenchmarks/rustpython/frozenset.py #3
                        time:   [22.971 µs 23.087 µs 23.184 µs]
                        thrpt:  [21.567 Melem/s 21.657 Melem/s 21.767 Melem/s]
microbenchmarks/rustpython/frozenset.py #4
                        time:   [31.847 µs 31.905 µs 31.968 µs]
                        thrpt:  [21.897 Melem/s 21.940 Melem/s 21.980 Melem/s]
microbenchmarks/rustpython/frozenset.py #5
                        time:   [42.031 µs 42.118 µs 42.197 µs]
                        thrpt:  [21.329 Melem/s 21.368 Melem/s 21.413 Melem/s]

(this is why I fixed the benchmarks first lol)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is weird that this test just doesn't finish though. Even two orders of magnitude slower should still be within the realm of possibility. This test does some truly bizarre things with power sets that I assume magnify the performance disparity exponentially.

# exponentially, so the best we can do here reasonably is 13.
# Even if the internal hash function did nothing, it would still be
# about 40 times slower than CPython.
for n in range(13):
t = 2 ** n
mask = t - 1
for nums in (range, zf_range):
Expand Down
5 changes: 5 additions & 0 deletions benches/microbenchmarks/frozenset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fs = frozenset(range(0, ITERATIONS))

# ---

hash(fs)
14 changes: 0 additions & 14 deletions common/src/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,6 @@ pub fn hash_float(value: f64) -> Option<PyHash> {
Some(fix_sentinel(x as PyHash * value.signum() as PyHash))
}

pub fn hash_iter_unordered<'a, T: 'a, I, F, E>(iter: I, hashf: F) -> Result<PyHash, E>
where
I: IntoIterator<Item = &'a T>,
F: Fn(&'a T) -> Result<PyHash, E>,
{
let mut hash: PyHash = 0;
for element in iter {
let item_hash = hashf(element)?;
// xor is commutative and hash should be independent of order
hash ^= item_hash;
}
Ok(fix_sentinel(mod_int(hash)))
}

pub fn hash_bigint(value: &BigInt) -> PyHash {
let ret = match value.to_i64() {
Some(i) => mod_int(i),
Expand Down
23 changes: 22 additions & 1 deletion vm/src/builtins/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,28 @@ impl PySetInner {
}

fn hash(&self, vm: &VirtualMachine) -> PyResult<PyHash> {
crate::utils::hash_iter_unordered(self.elements().iter(), vm)
// Work to increase the bit dispersion for closely spaced hash values.
// This is important because some use cases have many combinations of a
// small number of elements with nearby hashes so that many distinct
// combinations collapse to only a handful of distinct hash values.
fn _shuffle_bits(h: u64) -> u64 {
((h ^ 89869747) ^ (h.wrapping_shl(16))).wrapping_mul(3644798167)
}
// Factor in the number of active entries
let mut hash: u64 = (self.elements().len() as u64 + 1).wrapping_mul(1927868237);
// Xor-in shuffled bits from every entry's hash field because xor is
// commutative and a frozenset hash should be independent of order.
for element in self.elements().iter() {
hash ^= _shuffle_bits(element.hash(vm)? as u64);
}
// Disperse patterns arising in nested frozensets
hash ^= (hash >> 11) ^ (hash >> 25);
hash = hash.wrapping_mul(69069).wrapping_add(907133923);
// -1 is reserved as an error code
if hash == u64::MAX {
hash = 590923713;
}
Ok(hash as PyHash)
}

// Run operation, on failure, if item is a set/set subclass, convert it
Expand Down
7 changes: 6 additions & 1 deletion vm/src/types/slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::{
AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine,
};
use crossbeam_utils::atomic::AtomicCell;
use malachite_bigint::BigInt;
use num_traits::{Signed, ToPrimitive};
use std::{borrow::Borrow, cmp::Ordering, ops::Deref};

Expand Down Expand Up @@ -254,7 +255,11 @@ fn hash_wrapper(zelf: &PyObject, vm: &VirtualMachine) -> PyResult<PyHash> {
let py_int = hash_obj
.payload_if_subclass::<PyInt>(vm)
.ok_or_else(|| vm.new_type_error("__hash__ method should return an integer".to_owned()))?;
Ok(rustpython_common::hash::hash_bigint(py_int.as_bigint()))
let big_int = py_int.as_bigint();
let hash: PyHash = big_int
.to_i64()
.unwrap_or_else(|| (big_int % BigInt::from(u64::MAX)).to_i64().unwrap());
Ok(hash)
}

/// Marks a type as unhashable. Similar to PyObject_HashNotImplemented in CPython
Expand Down
7 changes: 0 additions & 7 deletions vm/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ pub fn hash_iter<'a, I: IntoIterator<Item = &'a PyObjectRef>>(
vm.state.hash_secret.hash_iter(iter, |obj| obj.hash(vm))
}

pub fn hash_iter_unordered<'a, I: IntoIterator<Item = &'a PyObjectRef>>(
iter: I,
vm: &VirtualMachine,
) -> PyResult<rustpython_common::hash::PyHash> {
rustpython_common::hash::hash_iter_unordered(iter, |obj| obj.hash(vm))
}

impl ToPyObject for std::convert::Infallible {
fn to_pyobject(self, _vm: &VirtualMachine) -> PyObjectRef {
match self {}
Expand Down