You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
frozendict_hash doesnt match the PEP and might have too many collisions #149807
in PEP 814, the provided pseudocode for hash(frozendict) is hash(frozenset(frozendict.items())), which makes sense, but the current implementation severely mismatches it in semantics:
just xoring the key and value hashes is very different from xoring the proper hashes of (key, value) pairs
the current implementation causes collisions to arise on permutations of keys and values separately rather than treating each entry as a single unit:
I agree this is a poor-quality implementation, for the reasons you spelled out. But 'the obvious" fix is to build (key, value) 2-tuples and use the tuple hash's native result. That's based on how xxHash creates a hash from a stream, and passed extensive tests of "good behavior" in the presence of duplicate stream elements.
i did think of just reusing tuple hashing at first,
but i dont like the need for creating a tuple object, setting its items in the loop (and clearing the cached hash or recreating teh whole object), and doing 2 rounds of xxhash potentially lots of times, both for code simplicity and performance reasons
i think its worth using the given knowledge that we are only combining 2 hashes, and the hashes of the first elements are usually already different (since they're dict keys) to explore figuring out a solution that might be better than the obvious one
We're working at the C level here. A single tuple object can be created and reused across iterations. "Immutability" is a Python-level guarantee. C code routinely mutates little tuples it has total control over. Get it right first - speed it later if really necessary. A hash of a frozendict is going to be extraordinarily expense no matter how it's done.
i agree that things should be done right first, but i think boost::hash_combine is already "getting it right" and "fast", so i dont want to introduce error-prone tuple mutating code just because "hashing tuples" is the obvious solution
tuple xxhash might have a bit less collisions in some cases, but i dont think thats a priority
im sure some small changes can be made to _combine_hashes that would get it to be as well distributed as tuple xxhash without bringing in the complexity of dealing with mutating a tuple object, if the current solution is poorly distributed
In my first frozendict implementation, hash(frozenset(frozendict.items())) was not pseudo-code but the actual implementation of hash(frozendict). The problem of this implementation is that it mentioned set in the error message which is unexpected:
So when PEP 814 was accepted, I modified the implementation to avoid frozenset(), before merging frozendict implementation. The new error message is less surprising:
I added // Code copied from frozenset_hash() comment to frozendict_hash(), because most of the code comes from frozenset_hash_impl().
I hesitated to reuse tuple_hash() code to create the hash of a (key, value) tuple.
Well, it seems like my implementation has flaws and the design should be revisited. Maybe we should reuse tuple_hash() code to compute (key, value) hash, to become closer to hash(frozenset(frozendict.items())) code again.
I was originally reviewing to say "Maybe just repopulate a tuple and hash it to save repeated code", but as I wrote, realized that the copy over of the code enables additional optimizations you missed (specifically, using _PyDict_Next instead of PyDict_Next, with the former letting us skip getting the key entirely in favor of getting its cached hash and halving the hash computation work) that would meaningfully improve on reusing and rehashing a single tuple. So I'll say I like the approach, but I'd want to at least maximize the benefit, and make the tests actually verify the equivalence of hash(frozendict) == hash(frozenset(frozendict.items())), so the PEP guarantee is useful and to ensure no one can go about changing frozenset or tuple's hash algorithm (e.g. to improve on the _shuffle_bits) and miss your comments, leading to the implementations diverging.
Activity
tim-one commentedon May 13, 2026
I agree this is a poor-quality implementation, for the reasons you spelled out. But 'the obvious" fix is to build (key, value) 2-tuples and use the tuple hash's native result. That's based on how xxHash creates a hash from a stream, and passed extensive tests of "good behavior" in the presence of duplicate stream elements.
lambda-abstraction commentedon May 13, 2026
i did think of just reusing tuple hashing at first,
but i dont like the need for creating a tuple object, setting its items in the loop (and clearing the cached hash or recreating teh whole object), and doing 2 rounds of xxhash potentially lots of times, both for code simplicity and performance reasons
i think its worth using the given knowledge that we are only combining 2 hashes, and the hashes of the first elements are usually already different (since they're dict keys) to explore figuring out a solution that might be better than the obvious one
[-]`frozendict_hash` is wrong[/-][+]`frozendict_hash` doesnt match the PEP and might have too many collisions[/+]tim-one commentedon May 13, 2026
We're working at the C level here. A single tuple object can be created and reused across iterations. "Immutability" is a Python-level guarantee. C code routinely mutates little tuples it has total control over. Get it right first - speed it later if really necessary. A hash of a frozendict is going to be extraordinarily expense no matter how it's done.
lambda-abstraction commentedon May 13, 2026
i agree that things should be done right first, but i think boost::hash_combine is already "getting it right" and "fast", so i dont want to introduce error-prone tuple mutating code just because "hashing tuples" is the obvious solution
tuple xxhash might have a bit less collisions in some cases, but i dont think thats a priority
im sure some small changes can be made to
_combine_hashesthat would get it to be as well distributed as tuple xxhash without bringing in the complexity of dealing with mutating a tuple object, if the current solution is poorly distributedpicnixz commentedon May 13, 2026
cc @vstinner
tim-one commentedon May 14, 2026
Here's a little script that shows over 40,000 distinct "natural" frozendicts that all have the same hash code:
And adding this at the end shows that xor'ing the (key, value) hashes instead suffers no collisions:
vstinner commentedon May 14, 2026
In my first frozendict implementation,
hash(frozenset(frozendict.items()))was not pseudo-code but the actual implementation ofhash(frozendict). The problem of this implementation is that it mentionedsetin the error message which is unexpected:So when PEP 814 was accepted, I modified the implementation to avoid
frozenset(), before merging frozendict implementation. The new error message is less surprising:I added
// Code copied from frozenset_hash()comment tofrozendict_hash(), because most of the code comes fromfrozenset_hash_impl().I hesitated to reuse
tuple_hash()code to create the hash of a(key, value)tuple.Well, it seems like my implementation has flaws and the design should be revisited. Maybe we should reuse
tuple_hash()code to compute(key, value)hash, to become closer tohash(frozenset(frozendict.items()))code again.vstinner commentedon May 14, 2026
I wrote PR gh-149841 to do exactly that. Does it look better?
MojoVampire commentedon May 16, 2026
I was originally reviewing to say "Maybe just repopulate a tuple and hash it to save repeated code", but as I wrote, realized that the copy over of the code enables additional optimizations you missed (specifically, using
_PyDict_Nextinstead ofPyDict_Next, with the former letting us skip getting the key entirely in favor of getting its cached hash and halving the hash computation work) that would meaningfully improve on reusing and rehashing a single tuple. So I'll say I like the approach, but I'd want to at least maximize the benefit, and make the tests actually verify the equivalence ofhash(frozendict) == hash(frozenset(frozendict.items())), so the PEP guarantee is useful and to ensure no one can go about changingfrozensetortuple's hash algorithm (e.g. to improve on the_shuffle_bits) and miss your comments, leading to the implementations diverging.gh-149807: Fix hash(frozendict): compute (key, value) pair hash (#149841
[3.15] gh-149807: Fix hash(frozendict): compute (key, value) pair hash (
vstinner commentedon May 20, 2026
Thanks @lambda-abstraction for your bug report. It should now be fixed.