diff --git a/hashset/hashset.mbt b/hashset/hashset.mbt index e9d9f6340..0a1ca9136 100644 --- a/hashset/hashset.mbt +++ b/hashset/hashset.mbt @@ -25,7 +25,9 @@ fn[K] new_hashset(capacity : Int) -> HashSet[K] { capacity, capacity_mask: capacity - 1, grow_at: calc_grow_threshold(capacity), - entries: FixedArray::make(capacity, None), + psls: FixedArray::make(capacity, empty_psl), + hashes: FixedArray::make(capacity, 0), + keys: UninitializedArray::make(capacity), } } @@ -94,62 +96,75 @@ fn[K : Eq] HashSet::add_with_hash( self.grow() } let (idx, psl) = for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { - None => break (idx, psl) - Some(curr_entry) => { - if curr_entry.hash == hash && curr_entry.key == key { - return - } - if psl > curr_entry.psl { - self.push_away(idx, curr_entry) - break (idx, psl) - } - continue psl + 1, (idx + 1) & self.capacity_mask - } + // SAFETY: `idx` starts masked by `capacity_mask` and every step re-masks + // it, so it indexes all three backing arrays -- each of length + // `capacity` -- in bounds. A non-empty PSL further guarantees that + // `keys[idx]` was initialized by `set_slot`. + let curr_psl = self.psls.unsafe_get(idx) + if curr_psl == empty_psl { + break (idx, psl) + } + if self.hashes.unsafe_get(idx) == hash && self.keys.unsafe_get(idx) == key { + return + } + if psl > curr_psl { + // Displace the richer occupant of `idx`, then write the new key here. + self.push_away( + idx, + curr_psl, + self.hashes.unsafe_get(idx), + self.keys.unsafe_get(idx), + ) + break (idx, psl) } + continue psl + 1, (idx + 1) & self.capacity_mask } - let entry = { psl, key, hash } - self.set_entry(entry, idx) + self.set_slot(idx, psl, hash, key) self.size += 1 } ///| -#owned(entry) +// Relocates the key triple `(psl, hash, key)` -- which has just been bumped out +// of an earlier slot -- to a later slot, Robin-Hood style, starting from the +// slot after `idx`. fn[K] HashSet::push_away( self : HashSet[K], idx : Int, - entry : Entry[K], + psl : Int, + hash : Int, + key : K, ) -> Unit { - for psl = entry.psl + 1, idx = (idx + 1) & self.capacity_mask, entry = entry { - match self.entries[idx] { - None => { - entry.psl = psl - self.set_entry(entry, idx) - break - } - Some(curr_entry) => - if psl > curr_entry.psl { - entry.psl = psl - self.set_entry(entry, idx) - continue curr_entry.psl + 1, - (idx + 1) & self.capacity_mask, - curr_entry - } else { - continue psl + 1, (idx + 1) & self.capacity_mask, entry - } + for psl = psl + 1, idx = (idx + 1) & self.capacity_mask, hash = hash, key = key { + // SAFETY: same masked-index and initialized-key invariants as `add_with_hash`. + let curr_psl = self.psls.unsafe_get(idx) + if curr_psl == empty_psl { + self.set_slot(idx, psl, hash, key) + break + } else if psl > curr_psl { + let curr_hash = self.hashes.unsafe_get(idx) + let curr_key = self.keys.unsafe_get(idx) + self.set_slot(idx, psl, hash, key) + continue curr_psl + 1, (idx + 1) & self.capacity_mask, curr_hash, curr_key + } else { + continue psl + 1, (idx + 1) & self.capacity_mask, hash, key } } } ///| #inline -#owned(entry) -fn[K] HashSet::set_entry( +fn[K] HashSet::set_slot( self : HashSet[K], - entry : Entry[K], - new_idx : Int, + idx : Int, + psl : Int, + hash : Int, + key : K, ) -> Unit { - self.entries[new_idx] = Some(entry) + // SAFETY: every caller derives `idx` from a `capacity_mask` probe, so it is + // in bounds for all three arrays. + self.psls.unsafe_set(idx, psl) + self.hashes.unsafe_set(idx, hash) + self.keys.unsafe_set(idx, key) } ///| @@ -158,11 +173,16 @@ pub fn[K : Hash + Eq] HashSet::contains(self : HashSet[K], key : K) -> Bool { // inline lookup to avoid unnecessary allocations let hash = Hash::hash(key) for i = 0, idx = hash & self.capacity_mask { - guard self.entries[idx] is Some(entry) else { break false } - if entry.hash == hash && entry.key == key { + // SAFETY: `idx` is masked by `capacity_mask`; all backing arrays have + // length `capacity`, so the probe index is in bounds. + let psl = self.psls.unsafe_get(idx) + guard psl != empty_psl else { break false } + // SAFETY: the same index invariant applies to `hashes`; a non-empty PSL + // also guarantees that `keys[idx]` was initialized by `set_slot`. + if self.hashes.unsafe_get(idx) == hash && self.keys.unsafe_get(idx) == key { break true } - if i > entry.psl { + if i > psl { break false } continue i + 1, (idx + 1) & self.capacity_mask @@ -193,13 +213,14 @@ pub fn[K : Hash + Eq] HashSet::contains(self : HashSet[K], key : K) -> Bool { pub fn[K : Hash + Eq] HashSet::remove(self : HashSet[K], key : K) -> Unit { let hash = Hash::hash(key) for i = 0, idx = hash & self.capacity_mask { - guard self.entries[idx] is Some(entry) else { break } - if entry.hash == hash && entry.key == key { + let psl = self.psls[idx] + guard psl != empty_psl else { break } + if self.hashes[idx] == hash && self.keys[idx] == key { self.shift_back(idx) self.size -= 1 break } - if i > entry.psl { + if i > psl { break } continue i + 1, (idx + 1) & self.capacity_mask @@ -210,16 +231,17 @@ pub fn[K : Hash + Eq] HashSet::remove(self : HashSet[K], key : K) -> Unit { fn[K] HashSet::shift_back(self : HashSet[K], idx : Int) -> Unit { for cur = idx { let next = (cur + 1) & self.capacity_mask - match self.entries[next] { - None | Some({ psl: 0, .. }) => { - self.entries[cur] = None - break - } - Some(entry) => { - entry.psl -= 1 - self.set_entry(entry, cur) - continue next - } + let next_psl = self.psls[next] + if next_psl == empty_psl || next_psl == 0 { + self.psls[cur] = empty_psl + // Not redundant with the line above: the PSL marks the slot free, but + // the key slot would still hold the removed key alive until something + // overwrites it, retaining up to one dead key per vacated slot. + self.keys.set_null(cur) + break + } else { + self.set_slot(cur, next_psl - 1, self.hashes[next], self.keys[next]) + continue next } } } @@ -232,42 +254,54 @@ fn[K] HashSet::grow(self : HashSet[K]) -> Unit { self.capacity_mask = self.capacity - 1 self.grow_at = calc_grow_threshold(self.capacity) self.size = 0 - self.entries = FixedArray::make(self.capacity, None) + self.psls = FixedArray::make(self.capacity, empty_psl) + self.hashes = FixedArray::make(self.capacity, 0) + self.keys = UninitializedArray::make(self.capacity) return } - let old_entries = self.entries + let old_psls = self.psls + let old_hashes = self.hashes + let old_keys = self.keys + let old_capacity = self.capacity let new_capacity = self.capacity * 2 - self.entries = FixedArray::make(new_capacity, None) + self.psls = FixedArray::make(new_capacity, empty_psl) + self.hashes = FixedArray::make(new_capacity, 0) + self.keys = UninitializedArray::make(new_capacity) self.capacity = new_capacity self.capacity_mask = new_capacity - 1 self.grow_at = calc_grow_threshold(self.capacity) - for entry in old_entries { - if entry is Some(entry) { - self.rehash_place_entry(entry) + for i in 0.. Unit { - let hash = entry.hash +fn[K] HashSet::rehash_place_entry( + self : HashSet[K], + hash : Int, + key : K, +) -> Unit { for psl = 0, idx = hash & self.capacity_mask { - match self.entries[idx] { - None => { - entry.psl = psl - self.set_entry(entry, idx) - return - } - Some(curr) => - if psl > curr.psl { - self.push_away(idx, curr) - entry.psl = psl - self.set_entry(entry, idx) - return - } else { - continue psl + 1, (idx + 1) & self.capacity_mask - } + // SAFETY: same masked-index and initialized-key invariants as `add_with_hash`. + let curr_psl = self.psls.unsafe_get(idx) + if curr_psl == empty_psl { + self.set_slot(idx, psl, hash, key) + return + } else if psl > curr_psl { + self.push_away( + idx, + curr_psl, + self.hashes.unsafe_get(idx), + self.keys.unsafe_get(idx), + ) + self.set_slot(idx, psl, hash, key) + return + } else { + continue psl + 1, (idx + 1) & self.capacity_mask } } } @@ -309,9 +343,9 @@ pub fn[K] HashSet::each( self : HashSet[K], f : (K) -> Unit raise?, ) -> Unit raise? { - for entry in self.entries { - if entry is Some({ key, .. }) { - f(key) + for i in 0.. Unit raise?, ) -> Unit raise? { for i in 0.. Unit { - self.entries.fill(None) + // Reuse the existing buffers (keeps the allocated space) and only null the + // occupied key slots so their references can be reclaimed. + for i in 0.. Unit { #alias(iterator, deprecated) pub fn[K] HashSet::iter(self : HashSet[K]) -> Iter[K] { let mut i = 0 - let len = self.entries.length() + let len = self.capacity Iter::new( fn() { while i < len { - let entry = self.entries.unsafe_get(i) + let idx = i i += 1 - if entry is Some({ key, .. }) { - return Some(key) + if self.psls[idx] != empty_psl { + return Some(self.keys[idx]) } } nobreak { None @@ -365,9 +406,13 @@ pub fn[K] HashSet::iter(self : HashSet[K]) -> Iter[K] { ///| /// Converts the hash set to an array. pub fn[K] HashSet::to_array(self : HashSet[K]) -> Array[K] { - [ - for entry in self.entries if entry is Some({ key, .. }) => key - ] + let arr = Array::new(capacity=self.size) + for i in 0.. Bool) -> Unit { let size = self.size let mut j = 0 for i = 0; j < size; i = i + 1 { - while self.entries[i] is Some(entry) { + while self.psls[i] != empty_psl { j += 1 - if f(entry.key) { + if f(self.keys[i]) { break } else { self.shift_back(i) @@ -514,11 +559,12 @@ fn calc_grow_threshold(capacity : Int) -> Int { ///| fn[K : Show] HashSet::_debug_entries(self : HashSet[K]) -> String { - for i in 0.. 0 { s + "," } else { s } - continue match self.entries[i] { - None => s + "_" - Some({ psl, key, .. }) => s + "(\{psl},\{key})" + continue if self.psls[i] == empty_psl { + s + "_" + } else { + s + "(\{self.psls[i]},\{self.keys[i]})" } } nobreak { s @@ -526,7 +572,7 @@ fn[K : Show] HashSet::_debug_entries(self : HashSet[K]) -> String { } ///| -priv struct MyString(String) derive(Eq, @debug.Debug) +priv struct MyString(String) derive(Eq) ///| impl Hash for MyString with fn hash(self) { @@ -540,6 +586,26 @@ impl Hash for MyString with fn hash_combine(self, hasher) { hasher.combine_string(self) } +///| +test "contains handles collided slots across table wraparound" { + let m : HashSet[MyString] = new_hashset(8) + fn key(value) { + MyString::MyString(value) + } + + // All keys hash to 7, so the probe sequence starts at the final slot and + // wraps around to the start of the table. + m.add("aaaaaaa" |> key) + m.add("bbbbbbb" |> key) + m.add("ccccccc" |> key) + m.add("ddddddd" |> key) + m.add("eeeeeee" |> key) + + assert_true(m.contains("aaaaaaa" |> key)) + assert_true(m.contains("eeeeeee" |> key)) + assert_false(m.contains("fffffff" |> key)) +} + ///| test "set" { let m : HashSet[MyString] = new_hashset(default_init_capacity) @@ -625,12 +691,64 @@ test "grow" { ///| test "clear" { let m : HashSet[MyString] = new_hashset(default_init_capacity) + fn i(s) { + MyString::MyString(s) + } + + m.add("C" |> i) + m.add("Go" |> i) + m.add("C++" |> i) + m.add("Java" |> i) + let psls = m.psls + let hashes = m.hashes + let keys = m.keys + inspect(m.length(), content="4") m.clear() inspect(m.length(), content="0") inspect(m.capacity(), content="8") - for entry in m.entries { - @test.assert_same_object(entry, None) + inspect(physical_equal(m.psls, psls), content="true") + inspect(physical_equal(m.hashes, hashes), content="true") + inspect(physical_equal(m.keys, keys), content="true") + for i in 0.. i) + inspect(m.length(), content="1") + inspect(m.contains("MoonBit" |> i), content="true") +} + +///| +test "copy sparse set only copies occupied key slots" { + let m : HashSet[MyString] = new_hashset(default_init_capacity) + fn i(s) { + MyString::MyString(s) + } + + m.add("a" |> i) + m.add("ab" |> i) + m.add("bc" |> i) + m.add("cd" |> i) + m.add("abc" |> i) + m.add("abcdef" |> i) + m.remove("ab" |> i) + let copied = m.copy() + inspect(copied.length(), content="5") + inspect(copied.capacity(), content="8") + inspect(physical_equal(copied.psls, m.psls), content="false") + inspect(physical_equal(copied.hashes, m.hashes), content="false") + inspect(physical_equal(copied.keys, m.keys), content="false") + let mut occupied = 0 + for idx in 0.. i), content="false") + inspect(copied.contains("abcdef" |> i), content="true") } ///| @@ -699,21 +817,27 @@ pub fn[K : Hash + Eq] HashSet::remove_and_check( pub fn[K] HashSet::copy(self : HashSet[K]) -> HashSet[K] { let other = { capacity: self.capacity, - entries: FixedArray::make(self.capacity, None), + psls: FixedArray::make(self.capacity, empty_psl), + hashes: FixedArray::make(self.capacity, 0), + keys: UninitializedArray::make(self.capacity), size: self.size, capacity_mask: self.capacity_mask, grow_at: self.grow_at, } - self.entries.blit_to(other.entries, len=self.capacity) + self.psls.blit_to(other.psls, len=self.capacity) + self.hashes.blit_to(other.hashes, len=self.capacity) + for i in 0.. key - ] + self.to_array().to_json() } ///| diff --git a/hashset/hashset_bench_test.mbt b/hashset/hashset_bench_test.mbt new file mode 100644 index 000000000..dc90e2cd2 --- /dev/null +++ b/hashset/hashset_bench_test.mbt @@ -0,0 +1,44 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +let hashset_bench_n = 50000 + +///| +test "bench HashSet::add n=50000" (it : @bench.T) { + it.bench(fn() { + let s = @hashset.HashSet([]) + for i in 0..= 0`, so `-1` is a safe sentinel. +let empty_psl : Int = -1 -/// A mutable hash set implements with Robin Hood hashing. +///| +/// Package-local unchecked accessors for the key storage. These are kept +/// private here rather than exposed from `builtin`: `set_null` and +/// `unsafe_set` can corrupt memory, so the fewer packages that can name them +/// the better. +fn[K] UninitializedArray::unsafe_get( + self : UninitializedArray[K], + idx : Int, +) -> K = "%fixedarray.unsafe_get" + +///| +fn[K] UninitializedArray::unsafe_set( + self : UninitializedArray[K], + idx : Int, + key : K, +) -> Unit = "%fixedarray.unsafe_set" + +///| +/// Releases the key reference stored at `idx` so the collector can reclaim it. +fn[K] UninitializedArray::set_null( + self : UninitializedArray[K], + idx : Int, +) -> Unit = "%fixedarray.set_null" + +/// A mutable hash set implemented with Robin Hood hashing. /// /// reference: /// - @@ -29,6 +50,12 @@ priv struct Entry[K] { ///| /// Mutable hash set, not thread safe. /// +/// The table is stored as a struct-of-arrays (`psls` / `hashes` / `keys`) +/// rather than an array of boxed entries, so inserting a key does not allocate +/// a per-entry object. For an occupied slot `i`, `hashes[i]` caches the key's +/// hash and `keys[i]` holds the key; for an empty slot `psls[i] == empty_psl` +/// and `keys[i]` is unused. +/// /// # Example /// /// ```mbt check @@ -39,7 +66,9 @@ priv struct Entry[K] { /// } /// ``` struct HashSet[K] { - mut entries : FixedArray[Entry[K]?] + mut psls : FixedArray[Int] // probe sequence length, or empty_psl when empty + mut hashes : FixedArray[Int] // cached key hash for occupied slots + mut keys : UninitializedArray[K] // key storage for occupied slots mut size : Int // active key count mut capacity : Int // current capacity mut capacity_mask : Int // capacity_mask = capacity - 1, used to find idx