Reuse stored hashes when building a set from a set/frozenset/dict - #8491
Reuse stored hashes when building a set from a set/frozenset/dict#8491fregataa wants to merge 3 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughSet and frozenset operations now reuse cached hashes from sets, frozensets, and exact dictionaries. Dictionary APIs accept caller-supplied hashes. Frozenset construction accepts an optional iterable and preserves exact-object fast paths. ChangesCached-hash set operations
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
set and frozenset recomputed __hash__ for every element even when the source object already stored a hash per entry. CPython's set_update_internal branches on PyAnySet_Check / PyDict_CheckExact and feeds set_add_entry the hash read from the source table; RustPython always iterated generically. Split the hash computation out of the Dict entry points so callers can supply a hash they already hold, add keys_with_hashes() to hand out (key, hash) pairs, and take the fast path in the set constructors and in the set operations whose argument is a set/frozenset/exact dict. ArgIterable::as_object() exposes the pre-__iter__ object so the set operations can dispatch on the source type without changing any of their signatures. Closes RustPython#8489. dict.fromkeys() is the dict-target counterpart and is tracked in RustPython#8490, so test_do_not_rehash_dict_keys keeps its expectedFailure marker until that lands too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ba69646 to
2924f86
Compare
`_with_hash` said nothing about which direction the hash travels, and the same suffix was already used both ways in this file: keys_with_hashes() hands hashes out, while insert_with_hash() takes one in. The pre-existing insert_with_hint()/get_with_hint() pair has the same problem. Follow CPython's "KnownHash" variants (_PyDict_SetItem_KnownHash, _PyDict_Contains_KnownHash, _PyDict_DelItem_KnownHash) instead, so the suffix marks the argument direction and matches the name a reader familiar with CPython already expects. keys_with_hashes() keeps `with` because it really does return the hashes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/vm/src/builtins/set.rs`:
- Around line 467-475: Update PySetInner::update to delegate to update_internal
so set.__ior__() reuses cached hashes when merging an existing set instead of
recomputing __hash__. Preserve set.update() behavior and add a regression test
covering set |= existing_set with hash invocation tracking.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro Plus
Run ID: f2bb6725-7dc2-43fe-acd5-6cc8f5ea4620
📒 Files selected for processing (3)
crates/vm/src/builtins/set.rscrates/vm/src/dict_inner.rscrates/vm/src/function/protocol.rs
| for (item, hash) in any_set.as_inner().content.keys_with_hashes() { | ||
| self.add_known_hash(item, hash, vm)?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn merge_dict(&self, dict: PyDictRef, vm: &VirtualMachine) -> PyResult<()> { | ||
| for (key, _value) in dict { | ||
| self.add(key, vm)?; | ||
| for (key, hash) in dict._as_dict_inner().keys_with_hashes() { | ||
| self.add_known_hash(key, hash, vm)?; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline crates/vm/src/builtins/set.rs --items all --type function --match 'update|update_internal|merge_set|merge_dict'
rg -n -C 5 --type rust 'fn update\b|\.update\(' crates/vm/src/builtins/set.rsRepository: RustPython/RustPython
Length of output: 2076
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '400,490p' crates/vm/src/builtins/set.rs
sed -n '780,815p' crates/vm/src/builtins/set.rs
rg -n -C 4 --type rust 'enum ArgIterable|struct ArgIterable|impl ArgIterable|fn as_object|into_iterable_iter|keys_with_hashes|add_known_hash' crates/vm/src
rg -n -C 6 --type rust 'update_internal|\.inner\.update\(|inner\.update\(' crates/vm/src/builtins/set.rs crates/vm/src | head -n 240
rg -n --glob '*.rs' 'hash.*raises|raises.*hash|__hash__|Hash' Lib tests crates/vm/src/builtins/set.rs | head -n 240Repository: RustPython/RustPython
Length of output: 25269
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path("crates/vm/src/builtins/set.rs")
lines = p.read_text().splitlines()
for start, end in [(430, 485), (790, 810), (870, 890)]:
print(f"--- {p}:{start}-{end} ---")
for n in range(start, min(end, len(lines)) + 1):
print(f"{n}:{lines[n-1]}")
PY
rg -n -C 8 --type rust 'ArgIterable' crates/vm/src | head -n 320
rg -n -C 8 --type rust 'keys_with_hashes|add_known_hash' crates/vm/src/builtins | head -n 320Repository: RustPython/RustPython
Length of output: 32348
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '60,115p' crates/vm/src/function/protocol.rs
sed -n '1410,1450p' crates/vm/src/builtins/set.rs
rg -n -C 10 --type rust 'enum AnySet|struct AnySet|impl.*AnySet|TryFromObject.*AnySet|into_iterable_iter' crates/vm/src/builtins/set.rs crates/vm/src
rg -n --type rust 'inner\.update\(' crates/vm/src/builtins/set.rsRepository: RustPython/RustPython
Length of output: 19080
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '70,115p' crates/vm/src/function/protocol.rs
sed -n '1380,1460p' crates/vm/src/builtins/set.rs
rg -n -C 10 --type rust 'AnySet' crates/vm/src/builtins/set.rs | head -n 260
rg -n --type rust 'inner\.update\(' crates/vm/src/builtins/set.rsRepository: RustPython/RustPython
Length of output: 13457
Route set.__ior__() through the cached-hash path.
set.update() already calls update_internal, but set.__ior__() calls PySetInner::update, which recomputes __hash__ for elements from an existing set. Delegate PySetInner::update to update_internal and add a regression test for set |= existing_set.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/vm/src/builtins/set.rs` around lines 467 - 475, Update
PySetInner::update to delegate to update_internal so set.__ior__() reuses cached
hashes when merging an existing set instead of recomputing __hash__. Preserve
set.update() behavior and add a regression test covering set |= existing_set
with hash invocation tracking.
from_object() and ArgIterable::as_object() had no caller outside their own file and crate respectively, so drop them to private and pub(crate). The dict_inner helpers stay pub(crate) because builtins::set calls them. Also shorten the doc comments to match the density of the surrounding code; only insert_known_hash keeps a real note, since a wrong hash there corrupts the table silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #8489.
Changes
dict_inner.rs— each entry point computed the hash on its first line and passed only thehashvariable downward, so the split is mechanical: the body moves to a*_known_hashvariant and the original becomes a wrapper. No logic is duplicated.insert_known_hash,contains_known_hash,remove_if_known_hash,delete_if_exists_known_hash,delete_or_insert_known_hashkeys_with_hashes()— yields(key, hash)from the entries, next to the existingkeys()/values()/items()The
_known_hashsuffix follows CPython's "KnownHash" variants (_PyDict_SetItem_KnownHash,_PyDict_Contains_KnownHash,_PyDict_DelItem_KnownHash), which mark the same split asset_add_key/set_add_entryon the set side.keys_with_hashes()keepswithbecause it really does return the hashes.function/protocol.rs—ArgIterable::as_object(), apub(crate)accessor for the object before any__iter__call. The set operations takeArgIterable, which already held the originalPyObjectRefin a private field; reaching it lets them dispatch on the source type without changing a single signature (they are passed around asfn(&PySetInner, ArgIterable, &VirtualMachine) -> ...function pointers, so changing them would have rippled widely).builtins/set.rsPySetInner::cached_hashes()—Some(Vec<(key, hash)>)for a set/frozenset (subclasses included, via the existingextract_set) or an exact dict,Noneotherwise. Same predicates as CPython.add_known_hash/contains_known_hashmerge_set/merge_dictnow forward the stored hashesunion,intersection,difference,symmetric_difference,difference_update,symmetric_difference_update,intersection_update— the existing generic loop stays as the fallback in eachPyFrozenSet'sConstructor::ArgsbecomesOptionalArg<PyObjectRef>sopy_newreceives the source object and can hand its stored hashes over viaPySetInner::from_object, instead of flattening toVec<PyObjectRef>first;slot_newkeeps only argument parsing and the empty-singleton checkResult
Testing
test_set—Ran 630 tests ... OK (expected failures=10), no new failurestest_dict,test_dictviews,test_dictcomps,test_ordered_dict,test_defaultdict,test_userdict,test_setcomps,test_weakset,test_collections,test_copy— all passtest_types,test_builtin,test_iter,test_pickle,test_marshal,test_descr,test_class,test_functools,test_typing— all passextra_tests/snippets/:builtin_set.py,builtin_dict.py,builtin_dict_union.py,frozen.pyfrozenset(f) is f, distinct ids for empty subclass instances, dict subclasses correctly excluded from the exact-dict path, self-referential ops (s.update(s),s.symmetric_difference_update(s)), results not aliasing their operands, error messages unchanged vs CPython 3.14.6cargo fmt --checkclean,cargo clippy -p rustpython-vm --all-targetsno new warningsReview note
insert_known_hashdepends on a contract the callee cannot verify: pass a hash that isn'tkey.key_hash(vm)and the entry lands in a bucket no lookup will probe, so the key silently disappears. This is documented on the function, and every call site forwards a hash that came fromkeys_with_hashes()on a container holding that same key object. Worth a careful look.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Performance