diff --git a/spec/std/hash_spec.cr b/spec/std/hash_spec.cr index a6bd8199e6c0..338fb3bea5f8 100644 --- a/spec/std/hash_spec.cr +++ b/spec/std/hash_spec.cr @@ -290,6 +290,20 @@ describe "Hash" do a.delete(2).should be_nil end + it "deletes many in the beginning and then will need a resize" do + h = {} of Int32 => Int32 + 8.times do |i| + h[i] = i + end + 5.times do |i| + h.delete(i) + end + (9..12).each do |i| + h[i] = i + end + h.should eq({5 => 5, 6 => 6, 7 => 7, 9 => 9, 10 => 10, 11 => 11, 12 => 12}) + end + describe "with block" do it "returns the value if a key is found" do a = {1 => 2} @@ -341,11 +355,95 @@ describe "Hash" do h.to_h.should be(h) end - it "clones" do - h1 = {1 => 2, 3 => 4} - h2 = h1.clone - h1.should_not be(h2) - h1.should eq(h2) + describe "clone" do + it "clones with size = 1" do + h1 = {1 => 2} + h2 = h1.clone + h1.should_not be(h2) + h1.should eq(h2) + end + + it "clones empty hash" do + h1 = {} of Int32 => Int32 + h2 = h1.clone + h2.empty?.should be_true + end + + it "clones small hash" do + h1 = {} of Int32 => Array(Int32) + 4.times do |i| + h1[i] = [i] + end + h2 = h1.clone + h1.should_not be(h2) + h1.should eq(h2) + + 4.times do |i| + h1[i].should_not be(h2[i]) + end + + h1.delete(0) + h2[0].should eq([0]) + end + + it "clones big hash" do + h1 = {} of Int32 => Array(Int32) + 1_000.times do |i| + h1[i] = [i] + end + h2 = h1.clone + h1.should_not be(h2) + h1.should eq(h2) + + 1_000.times do |i| + h1[i].should_not be(h2[i]) + end + + h1.delete(0) + h2[0].should eq([0]) + end + end + + describe "dup" do + it "dups empty hash" do + h1 = {} of Int32 => Int32 + h2 = h1.dup + h2.empty?.should be_true + end + + it "dups small hash" do + h1 = {} of Int32 => Array(Int32) + 4.times do |i| + h1[i] = [i] + end + h2 = h1.dup + h1.should_not be(h2) + h1.should eq(h2) + + 4.times do |i| + h1[i].should be(h2[i]) + end + + h1.delete(0) + h2[0].should eq([0]) + end + + it "dups big hash" do + h1 = {} of Int32 => Array(Int32) + 1_000.times do |i| + h1[i] = [i] + end + h2 = h1.dup + h1.should_not be(h2) + h1.should eq(h2) + + 1_000.times do |i| + h1[i].should be(h2[i]) + end + + h1.delete(0) + h2[0].should eq([0]) + end end it "initializes with block" do @@ -557,32 +655,122 @@ describe "Hash" do h.first.should eq({1, 2}) end - it "gets first key" do - h = {1 => 2, 3 => 4} - h.first_key.should eq(1) + describe "first_key" do + it "gets first key" do + h = {1 => 2, 3 => 4} + h.first_key.should eq(1) + end + + it "raises on first key (nilable key)" do + h = {} of Int32? => Int32 + expect_raises(Exception, "Can't get first key of empty Hash") do + h.first_key + end + end + + it "doesn't raise on first key (nilable key)" do + h = {nil => 1} of Int32? => Int32 + h.first_key.should be_nil + end end - it "gets first value" do - h = {1 => 2, 3 => 4} - h.first_value.should eq(2) + describe "first_value" do + it "gets first value" do + h = {1 => 2, 3 => 4} + h.first_value.should eq(2) + end + + it "raises on first value (nilable value)" do + h = {} of Int32 => Int32? + expect_raises(Exception, "Can't get first value of empty Hash") do + h.first_value + end + end + + it "doesn't raise on first value (nilable value)" do + h = {1 => nil} of Int32 => Int32? + h.first_value.should be_nil + end end - it "gets last key" do - h = {1 => 2, 3 => 4} - h.last_key.should eq(3) + describe "last_key" do + it "gets last key" do + h = {1 => 2, 3 => 4} + h.last_key.should eq(3) + end + + it "raises on last key (nilable key)" do + h = {} of Int32? => Int32 + expect_raises(Exception, "Can't get last key of empty Hash") do + h.last_key + end + end + + it "doesn't raise on last key (nilable key)" do + h = {nil => 1} of Int32? => Int32 + h.last_key.should be_nil + end end - it "gets last value" do - h = {1 => 2, 3 => 4} - h.last_value.should eq(4) + describe "last_value" do + it "gets last value" do + h = {1 => 2, 3 => 4} + h.last_value.should eq(4) + end + + it "raises on last value (nilable value)" do + h = {} of Int32 => Int32? + expect_raises(Exception, "Can't get last value of empty Hash") do + h.last_value + end + end + + it "doesn't raise on last value (nilable value)" do + h = {1 => nil} of Int32 => Int32? + h.last_value.should be_nil + end end it "shifts" do h = {1 => 2, 3 => 4} + h.shift.should eq({1, 2}) h.should eq({3 => 4}) + h.first_key.should eq(3) + h.first_value.should eq(4) + h[1]?.should be_nil + h[3].should eq(4) + + h.each.to_a.should eq([{3, 4}]) + h.each_key.to_a.should eq([3]) + h.each_value.to_a.should eq([4]) + h.shift.should eq({3, 4}) h.empty?.should be_true + + expect_raises(IndexError) do + h.shift + end + + 20.times do |i| + h[i] = i + end + h.size.should eq(20) + + 20.times do |i| + h.shift.should eq({i, i}) + end + h.empty?.should be_true + end + + it "shifts: delete elements in the middle position and then in the first position" do + h = {1 => 'a', 2 => 'b', 3 => 'c', 4 => 'd'} + h.delete(2) + h.delete(3) + h.delete(1) + h.size.should eq(1) + h.should eq({4 => 'd'}) + h.first.should eq({4, 'd'}) end it "shifts?" do @@ -636,6 +824,18 @@ describe "Hash" do h.to_a.size.should eq(0) end + it "clears after shift" do + h = {1 => 2, 3 => 4} + h.shift + h.clear + h.empty?.should be_true + h.to_a.size.should eq(0) + h[5] = 6 + h.empty?.should be_false + h[5].should eq(6) + h.should eq({5 => 6}) + end + it "computes hash" do h1 = { {1 => 2} => {3 => 4} } h2 = { {1 => 2} => {3 => 4} } @@ -892,18 +1092,65 @@ describe "Hash" do it "creates with initial capacity" do hash = Hash(Int32, Int32).new(initial_capacity: 1234) - hash.@buckets_size.should eq(1234) + hash.@indices_size_pow2.should eq(11) end it "creates with initial capacity and default value" do hash = Hash(Int32, Int32).new(default_value: 3, initial_capacity: 1234) hash[1].should eq(3) - hash.@buckets_size.should eq(1234) + hash.@indices_size_pow2.should eq(11) end it "creates with initial capacity and block" do hash = Hash(Int32, Int32).new(initial_capacity: 1234) { |h, k| h[k] = 3 } hash[1].should eq(3) - hash.@buckets_size.should eq(1234) + hash.@indices_size_pow2.should eq(11) + end + + it "rehashes" do + a = [1] + h = {a => 0} + (10..20).each do |i| + h[[i]] = i + end + a << 2 + h[a]?.should be_nil + h.rehash + h[a].should eq(0) + end + + describe "some edge cases while changing the implementation to open addressing" do + it "edge case 1" do + h = {1 => 10} + h[1]?.should eq(10) + h.size.should eq(1) + + h.delete(1) + h[1]?.should be_nil + h.size.should eq(0) + + h[2] = 10 + h[2]?.should eq(10) + h.size.should eq(1) + + h[2] = 10 + h[2]?.should eq(10) + h.size.should eq(1) + end + + it "edge case 2" do + hash = Hash(Int32, Int32).new(initial_capacity: 0) + hash.@indices_size_pow2.should eq(0) + hash[1] = 2 + hash[1].should eq(2) + end + + it "edge case 3" do + h = {} of Int32 => Int32 + (1 << 17).times do |i| + h[i] = i + h[i].should eq(i) + end + end end end diff --git a/src/hash.cr b/src/hash.cr index 795026c3b10b..8bcf02c144af 100644 --- a/src/hash.cr +++ b/src/hash.cr @@ -36,12 +36,171 @@ class Hash(K, V) include Enumerable({K, V}) include Iterable({K, V}) - getter size : Int32 - @buckets_size : Int32 - @first : Entry(K, V)? - @last : Entry(K, V)? + # =========================================================================== + # Overall explanation of the algorithm + # =========================================================================== + # + # Hash implements an open addressing collision resolution method: + # https://en.wikipedia.org/wiki/Open_addressing + # + # The collision resolution is done using Linear Probing: + # https://en.wikipedia.org/wiki/Linear_probing + # + # The algorithm is partially based on Ruby's one but they are not exactly the same: + # https://github.com/ruby/ruby/blob/a4c09342a2219a8374240ef8d0ca86abe287f715/st.c#L1-L101 + # + # There are two main data structures: + # + # - @entries: + # A continguous buffer (Pointer) of hash entries (Entry) in the order + # they were inserted. This makes it possible for Hash to preserve + # order of insertion. + # An entry holds a key-value pair together with the key's hash code. + # An entry can also be marked as deleted. This is accomplished by using + # 0 as the hash code value. Because 0 is a valid hash code value, when + # computing the key's hash code if it's 0 then it's replaced by another + # value (UInt32::MAX). The alternative would be to use a boolean but + # that involves more memory allocated and worse performance. + # - @indices: + # A buffer of indices into the @entries buffer. + # An index might mean it's empty. We could use -1 for this but because + # of an optimization we'll explain later we use 0, and all other values + # represent indices which are 1 less than their actual value (so value + # 3 means index 2). + # When a key-value pair is inserted we first find the key's hash and + # then fit it (by modulo) into the indices buffer size. For example, + # assuming we are inserting a new key-value pair with key "hello", + # if the indices size is 128, the key is "hello" and its hash is + # 987 then fitting it into 128 is (987 % 128) gives 91. Lets also + # assume there are already 3 entries in @entries. We go ahead an add + # a new entry at index 3, and at position 91 in @indices we store 3 + # (well, actually 4 because we store 1 more than the actual index + # because 0 means empty, as explained above). + # + # Open addressing means that if, in the example above, we go and try to + # insert another key with a hash that will be placed in the same position + # in indices (let's say, 91 again), because it's occupied we will insert + # it into the next non-empty slot. We try with 92. If it's empty we again + # go and insert it intro `@entries` and store the index at 92 (continuing + # with the previous example we would store the value 4). + # + # If we keep the size of @indices the same as @entries it means that in the worse + # case @indices is full and when finding a match we have to traverse it all, + # which is bad. That's why we always make the size of @indices at least twice + # as big as the size of @entries, so the non-empty indices will tend to be + # spread apart with empty indices in the middle. + # + # Also, we always keep the sizes of `@indices` and `@entries` (`indices_size` / 2) + # powers of 2, with the smallest size of `@indices` being 8 (and thus of + # `@entries` being 4). + # + # The size of `@indices` is stored as a number that has to be powered by 2 in + # `@indices_size_pow2`. For example if `@indices_size_pow2` is 3 then the actual + # size is 2**3 = 8. + # + # Next comes the optimizations. + # + # The first one is that for an empty hash we don't allocate `@entries` + # nor `@indices`, and there are a few checks against these when adding + # and fetching elements. Sometimes hashes are created empty and remain empty + # for some time or for the duration of the program when not used, and this + # helps save some memory. + # + # The second optimization is that for small hashes (less or equal to 16 elements) + # we don't allocate `@indices` and just perform a linear scan on `@entries`. + # This is an heuristic but in practice it's faster to search linearly in small + # hashes. There's another heuristic here: if we have less than or equal to 8 + # elements we just compare values when doing the linear scan. If we have between + # 9 and 16 we first compute the hash code of the key and compare the hash codes + # first (at this point computing the hash code plus comparing them might become + # cheaper than doing a full comparison each time). This optimization also exists + # in the Ruby implementation (though it seems hash values are always compared). + # + # A third optimization is in the way `@indices` is allocated: when the number + # of entries is less than or equal to 128 (2 ** 8 / 2) the indexes values will range between + # 0 and 128. That means we can use `Pointer(UInt8)` as the type of `@indices`. + # (we can't do it for ranges between 0 and 256 because we need a value that means + # "empty"). Similarly, for ranges between 128 and 32768 (2 ** 16 / 2) we can use + # `Pointer(UInt16)`. This saves some memory (and the performance difference is + # noticeable). We store the bytesize of the `@indices` buffer in `@indices_bytesize` + # with values 1 (UInt8), 2 (UInt16) or 4 (UInt32). This optimization also exists + # in the Ruby implementation. + # + # Another optimization is, when fitting a value inside the range of `@indices`, + # to use masking (value & mask) instead of `remainder` or `%`, which apparently + # are much slower. This optimization also exists in the Ruby implementation. + # + # We also keep track of the number of deleted entries (`@deleted_count`). When an + # entry is deleted we just mark it as deleted by using the special hash value 0. + # Only when the hash needs to be resized we do something with this instance variable: + # if we have many deleted entries (at least as many as the number of non-deleted + # entries) we compact the map and avoid a resize. Otherwise we remove the non-deleted + # entries but also resize both the `@entries` and `@indices` buffer. This probably + # avoids an edge case where one deletes and inserts an element and there is a constant + # shift of the buffer (expensive). + # + # There might be other optimizations to try out, like not using Linear Probing, + # but for now this implementaton is much faster than the old one which used + # linked lists (closed addressing). + # + # All methods that deal with this implementation come after the constructors. + # Then all other methods use the internal methods, usually using other high-level + # methods. + + # The index of the first non-deleted entry in `@entries`. + # This is useful to support `shift`: instead of marking an entry + # as deleted and then always having to ignore it we just increment this + # variable and always start iterating from it. + # The invariant of `@first` always pointing to a non-deleted entry holds + # (unless `@size` is 0) and is guaranteed because of how + # `delete_and_update_counts` is implemented. + @first : Int32 = 0 + + # The buffer of entries. + # Might be null if the hash is empty at the very beginning. + # Has always the size of `indices_size` / 2. + @entries : Pointer(Entry(K, V)) + + # The buffer of indices into entries. Its size is given by `@indices_size_pow2`. + # Might be null if the hash is empty at the very beginning or when the hash + # size is less than or equal to 16. + # Could be a Slice but this way we might save a few bounds checking. + @indices : Pointer(UInt8) + + # The number of actual entries in the hash. + # Exposed to the user via the `size` getter. + @size : Int32 + + # The number of deleted entries. + # Resets to zero when the hash resizes. + @deleted_count : Int32 + + # The actual type of `@indices`: + # - 1 means `Pointer(UInt8)` + # - 2 means `Pointer(UInt16)` + # - 4 means `Pointer(UInt32)` + @indices_bytesize : Int8 + + # The size of `@indices` given as a power of 2. + # For example if it's 4 it means 2**4 so size 16. + # Can be zero when hash is totally empty. + # Otherwise guaranteed to be at least 3. + @indices_size_pow2 : UInt8 + + # The optional block that triggers on non-existing keys. @block : (self, K -> V)? + # Creates a new empty `Hash`. + def initialize + @entries = Pointer(Entry(K, V)).null + @indices = Pointer(UInt8).null + @indices_size_pow2 = 0 + @size = 0 + @deleted_count = 0 + @block = nil + @indices_bytesize = 1 + end + # Creates a new empty `Hash` with a *block* for handling missing keys. # # ``` @@ -60,13 +219,41 @@ class Hash(K, V) # a hash will hold is known, the hash should be initialized with that # capacity for improved performance. Otherwise, the default is 11 and inputs # less than 11 are ignored. - def initialize(block : (Hash(K, V), K -> V)? = nil, initial_capacity = nil) - initial_capacity ||= 11 - initial_capacity = 11 if initial_capacity < 11 - initial_capacity = initial_capacity.to_i - @buckets = Pointer(Entry(K, V)?).malloc(initial_capacity) - @buckets_size = initial_capacity + def initialize(block : (Hash(K, V), K -> V)? = nil, *, initial_capacity = nil) + initial_capacity = (initial_capacity || 0).to_i32 + + # Same as the empty hash case + # (but this constructor is a bit more expensive in terms of code execution). + if initial_capacity == 0 + @entries = Pointer(Entry(K, V)).null + @indices = Pointer(UInt8).null + @indices_size_pow2 = 0 + @indices_bytesize = 1 + else + # Translate initial capacity to the nearest power of 2, but keep it a minimum of 8. + if initial_capacity < 8 + initial_indices_size = 8 + else + initial_indices_size = Math.pw2ceil(initial_capacity) + end + + @entries = malloc_entries(initial_indices_size / 2) + + # Check if we can avoid allocating the `@indices` buffer for + # small hashes. + if initial_indices_size > MAX_INDICES_SIZE_LINEAR_SCAN + @indices_bytesize = compute_indices_bytesize(initial_indices_size) + @indices = malloc_indices(initial_indices_size) + else + @indices = Pointer(UInt8).null + @indices_bytesize = 1 + end + + @indices_size_pow2 = Math.log2(initial_indices_size).to_u8 + end + @size = 0 + @deleted_count = 0 @block = block end @@ -118,6 +305,601 @@ class Hash(K, V) new(initial_capacity: initial_capacity) { default_value } end + # =========================================================================== + # Internal implementation starts + # =========================================================================== + + # Maximum number of `indices_size` for which we do a linear scan + # (maximum of 16 entries in `@entries`) + private MAX_INDICES_SIZE_LINEAR_SCAN = 32 + + # Maximum number of `indices_size` for which we can represent `@indices` + # as Pointer(UInt8). + private MAX_INDICES_BYTESIZE_1 = 256 + + # Maximum number of `indices_size` for which we can represent `@indices` + # as Pointer(UInt16). + private MAX_INDICES_BYTESIZE_2 = 65536 + + # Inserts or updates a key-value pair. + private def upsert(key, value) : Nil + # Empty hash table so only initialize entries for now + if @entries.null? + @indices_size_pow2 = 3 + @entries = malloc_entries(4) + end + + hash = key_hash(key) + + # No indices allocated yet so try to do a linear scan + if @indices.null? + # Try to do an upsert by doing a linear scan + upserted = upsert_linear_scan(key, value, hash) + return if upserted + + # If we couldn't upsert it means the table was full + # so a resize might have been done. + # Now, it could happen that we are still with less than 16 elements + # and so `@indices` will be null, in which case we only need to + # add the key-value pair at the end of the `@entries` buffer. + if @indices.null? + add_entry_and_increment_size(hash, key, value) + return + end + + # Otherwise `@indices` became non-null which means we can't do + # a linear scan anymore. + end + + # Fit the hash value into an index in `@indices` + index = fit_in_indices(hash) + + while true + entry_index = get_index(index) + + # If the index entry is empty... + if entry_index == -1 + # If we reached the maximum in `@entries` it's time to resize + if entries_full? + resize + # We have to fit the hash into an index in `@indices` again, and try again + index = fit_in_indices(hash) + next + end + + # We have free space: store the index and then insert the entry + set_index(index, entries_size) + add_entry_and_increment_size(hash, key, value) + break + end + + # We found a non-empty slot, let's see if the key we have matches + entry = get_entry(entry_index) + if entry.matches?(hash, key) + # If it does we just update the entry + set_entry(entry_index, Entry(K, V).new(hash, key, value)) + break + else + # Otherwise we have to keep looking... + index = next_index(index) + end + end + end + + # Upserts the key-value-hash triplet by doing a linear scan + # first to see if the key already exists. + # Returns true if the key was updated or inserted without needing + # a resize. Returns false if a resize was needed and the key + # wasn't inserted. + private def upsert_linear_scan(key, value, hash) : Bool + # Just do a linear scan... + each_entry_with_index do |entry, index| + if entry.matches?(hash, key) + set_entry(index, Entry(K, V).new(entry.hash, entry.key, value)) + return true + end + end + + # If full, resize. Otherwise we have space so add as last. + if entries_full? + resize + false + else + add_entry_and_increment_size(hash, key, value) + true + end + end + + # Implementation of deleting a key. + # Returns the deleted Entry, if it existed, `nil` otherwise. + private def delete_impl(key) : Entry(K, V)? + # Empty hash table, nothing to do + if @indices_size_pow2 == 0 + return nil + end + + hash = key_hash(key) + + # No indices allocated yet so do linear scan + if @indices.null? + return delete_linear_scan(key, hash) + end + + # Fit hash into `@indices` size + index = fit_in_indices(hash) + while true + entry_index = get_index(index) + + # If we find an empty index slot, there's no such key + if entry_index == -1 + return nil + end + + # We found a non-empty slot, let's see if the key we have matches + entry = get_entry(entry_index) + if entry.matches?(hash, key) + delete_entry_and_update_counts(entry_index) + return entry + else + # If it doesn't, check the next index... + index = next_index(index) + end + end + end + + # Delete by doing a linear scan over `@entries`. + # Returns the deleted Entry, if it existed, `nil` otherwise. + private def delete_linear_scan(key, hash) : Entry(K, V)? + each_entry_with_index do |entry, index| + if entry.matches?(hash, key) + delete_entry_and_update_counts(index) + return entry + end + end + + nil + end + + # Finds an entry with the given key. + protected def find_entry(key) : Entry(K, V)? + # Empty hash table so there's no way it's there + if @indices_size_pow2 == 0 + return nil + end + + # No indices allocated yet so do linear scan + if @indices.null? + return find_entry_linear_scan(key) + end + + hash = key_hash(key) + + # Fit hash into `@indices` size + index = fit_in_indices(hash) + while true + entry_index = get_index(index) + + # If we find an empty index slot, there's no such key + if entry_index == -1 + return nil + end + + # We found a non-empty slot, let's see if the key we have matches + entry = get_entry(entry_index) + if entry.matches?(hash, key) + # It does! + return entry + else + # Nope, move on to the next slot + index = next_index(index) + end + end + end + + # Finds an Entry with the given key by doing a linear scan. + private def find_entry_linear_scan(key) : Entry(K, V)? + # If we have less than 8 elements we avoid computing the hash + # code and directly compare the keys (might be cheaper than + # computing a hash code of a complex structure). + if entries_size <= 8 + each_entry_with_index do |entry| + return entry if entry.key == key + end + else + hash = key_hash(key) + each_entry_with_index do |entry| + return entry if entry.matches?(hash, key) + end + end + + nil + end + + # Tries to resize the hash table in the condition that there are + # no more available entries to add. + # Might not result in a resize if there are many entries marked as + # deleted. In that case the entries table is simply compacted. + # However, in case of a resize deleted entries are also compcated. + private def resize : Nil + # Only do an actual resize (grow `@entries` buffer) if we don't + # have many deleted elements. + if @deleted_count < @size + # First grow `@entries` + realloc_entries(indices_size) + double_indices_size + + # If we didn't have `@indices` and we still don't have 16 entries + # we keep doing linear scans (not using `@indices`) + if @indices.null? && indices_size <= MAX_INDICES_SIZE_LINEAR_SCAN + return + end + + # Otherwise, we must either start using `@indices` + # or grow the ones we had. + @indices_bytesize = compute_indices_bytesize(indices_size) + if @indices.null? + @indices = malloc_indices(indices_size) + else + @indices = realloc_indices(indices_size) + end + end + + do_compaction + + # After compaction we no longer have deleted entries + @deleted_count = 0 + + # And the first valid entry is the first one + @first = 0 + end + + # Compacts `@entries` (only keeps non-deleted ones) and rebuilds `@indices.` + # If `rehash` is `true` then hash values inside each `Entry` will be recomputed. + private def do_compaction(rehash : Bool = false) : Nil + # `@indices` might still be null if we are compacting in the case where + # we are still doing a linear scan (and we had many deleted elements) + if @indices.null? + has_indices = false + else + # If we do have indices we must clear them because we'll rebuild + # them from scratch + has_indices = true + clear_indices + end + + # Here we traverse the `@entries` and compute their new index in `@indices` + # while moving non-deleted entries to the beginning (compaction). + new_entry_index = 0 + each_entry_with_index do |entry, entry_index| + if rehash + # When rehashing we always have to copy the entry + set_entry(new_entry_index, Entry(K, V).new(key_hash(entry.key), entry.key, entry.value)) + else + # First we move the entry to its new index (if we need to do that) + set_entry(new_entry_index, entry) if entry_index != new_entry_index + end + + if has_indices + # Then we try to find an empty index slot + # (we should find one now that we have more space) + index = fit_in_indices(entry.hash) + until get_index(index) == -1 + index = next_index(index) + end + set_index(index, new_entry_index) + end + + new_entry_index += 1 + end + + # We have to mark entries starting from the final new index + # as deleted so the GC can collect them. + entries_to_clear = entries_size - new_entry_index + if entries_to_clear > 0 + (entries + new_entry_index).clear(entries_to_clear) + end + end + + # After this it's 1 << 28, and with entries being Int32 + # (4 bytes) it's 1 << 30 of actual bytesize and the + # next value would be 1 << 31 which overflows `Int32`. + private MAXIMUM_INDICES_SIZE = 1 << 28 + + # Doubles the value of `@indices_size` but first checks + # whether the maximum hash size is reached. + private def double_indices_size : Nil + if indices_size == MAXIMUM_INDICES_SIZE + raise "Maximum Hash size reached" + end + + @indices_size_pow2 += 1 + end + + # Implementation of clearing the hash table. + private def clear_impl : Nil + # We _could_ set all buffers to null and start like in the + # empty case. + # However, it might happen that a user calls clear and then inserts + # elements in a loop. In that case each insert after clear will cause + # a new memory allocation and that's not good. + # Just clearing the buffers might retain some memory but it + # avoids a possible constant reallocation (which is slower). + clear_entries unless @entries.null? + clear_indices unless @indices.null? + @size = 0 + @deleted_count = 0 + @first = 0 + end + + # Initializes a `dup` copy from the contents of `other`. + protected def initialize_dup(other) + return if other.empty? + + initialize_dup_entries(other) + initialize_copy_non_entries_vars(other) + end + + # Initializes a `clone` copy from the contents of `other`. + protected def initialize_clone(other) + return if other.empty? + + initialize_clone_entries(other) + initialize_copy_non_entries_vars(other) + end + + # Initializes `@entries` for a dup copy. + # Here we only need tu duplicate the buffer. + private def initialize_dup_entries(other) + return if other.@entries.null? + + @entries = malloc_entries(other.entries_capacity) + + # Note that we only need to copy `entries_size` which + # are the effectives entries in use. + @entries.copy_from(other.@entries, other.entries_size) + end + + # Initializes `@entries` for a clone copy. + # Here we need to copy entries while cloning their values. + private def initialize_clone_entries(other) + return if other.@entries.null? + + @entries = malloc_entries(other.entries_capacity) + + other.each_entry_with_index do |entry, index| + set_entry(index, entry.clone) + end + end + + # Initializes all variables other than `@entries` for a copy. + private def initialize_copy_non_entries_vars(other) + @indices_bytesize = other.@indices_bytesize + @first = other.@first + @size = other.@size + @deleted_count = other.@deleted_count + @indices_size_pow2 = other.@indices_size_pow2 + @block = other.@block + + unless other.@indices.null? + @indices = malloc_indices(other.indices_size) + @indices.copy_from(other.@indices, indices_malloc_size(other.indices_size)) + end + end + + # Gets from `@indices` at the given `index`. + # Returns the index in `@entries` or `-1` if the slot is empty. + private def get_index(index : Int32) : Int32 + # Check what we have: UInt8, Int16 or UInt32 buckets + value = case @indices_bytesize + when 1 + @indices[index].to_i32! + when 2 + @indices.as(UInt16*)[index].to_i32! + else + @indices.as(UInt32*)[index].to_i32! + end + + # Because we increment the value by one when we store the value + # here we have to substract one + value - 1 + end + + # Sets `@indices` at `index` with the given value. + private def set_index(index, value) : Nil + # We actually store 1 more than the value because 0 means empty. + value += 1 + + # We also have to see what we have: UInt8, UInt16 or UInt32 buckets. + case @indices_bytesize + when 1 + @indices[index] = value.to_u8! + when 2 + @indices.as(UInt16*)[index] = value.to_u16! + else + @indices.as(UInt32*)[index] = value.to_u32! + end + end + + # Returns the capacity of `@indices`. + protected def indices_size + 1 << @indices_size_pow2 + end + + # Computes what bytesize we'll store in `@indices` according to its size + private def compute_indices_bytesize(size) : Int8 + case + when size <= MAX_INDICES_BYTESIZE_1 + 1_i8 + when size <= MAX_INDICES_BYTESIZE_2 + 2_i8 + else + 4_i8 + end + end + + # Allocates `size` number of indices for `@indices`. + private def malloc_indices(size) + Pointer(UInt8).malloc(indices_malloc_size(size)) + end + + # The actual number of bytes needed to allocate `@indices`. + private def indices_malloc_size(size) + size * @indices_bytesize + end + + # Reallocates `size` number of indices for `@indices`. + private def realloc_indices(size) + @indices.realloc(indices_malloc_size(size)) + end + + # Marks all existing indices as empty. + private def clear_indices : Nil + @indices.clear(indices_malloc_size(indices_size)) + end + + # Returns the entry in `@entries` at `index`. + private def get_entry(index) : Entry(K, V) + @entries[index] + end + + # Sets the entry in `@entries` at `index`. + private def set_entry(index, value) : Nil + @entries[index] = value + end + + # Adds an entry at the end and also increments this hash's size. + private def add_entry_and_increment_size(hash, key, value) : Nil + set_entry(entries_size, Entry(K, V).new(hash, key, value)) + @size += 1 + end + + # Marks an entry in `@entries` at `index` as deleted + # *without* modifying any counters (`@size` and `@deleted_count`). + private def delete_entry(index) : Nil + set_entry(index, Entry(K, V).deleted) + end + + # Marks an entry in `@entries` at `index` as deleted + # and updates the `@size` and `@deleted_count` counters. + private def delete_entry_and_update_counts(index) : Nil + delete_entry(index) + @size -= 1 + @deleted_count += 1 + + # If we are deleting the first entry there are some + # more optimizations we can do + return if index != @first + + # If the Hash is now empty then the first effective + # entry starts right after all the deleted ones. + if @size == 0 + @first = @deleted_count + else + # Otherwise, we bump `@first` and keep bumping it + # until we find a non-deleted entry. It's guaranteed + # that this loop will end because `@size != 0` so + # there will be a non-deleted entry. + # It's better to skip the deleted entries once here + # and not every next time someone accesses the Hash. + # With this we also keep the invariant that `@first` + # always points to the first non-deleted entry. + @first += 1 + while @entries[@first].deleted? + @first += 1 + end + end + end + + # Returns true if there's no place for new entries without doing a resize. + private def entries_full? : Bool + entries_size == entries_capacity + end + + # Yields each non-deleted Entry with its index inside `@entries`. + protected def each_entry_with_index : Nil + return if @size == 0 + + @first.upto(entries_size - 1) do |i| + entry = get_entry(i) + yield entry, i unless entry.deleted? + end + end + + # Allocates `size` number of entries for `@entries`. + private def malloc_entries(size) + Pointer(Entry(K, V)).malloc(size) + end + + private def realloc_entries(size) + @entries = @entries.realloc(size) + end + + # Marks all existing entries as deleted + private def clear_entries + @entries.clear(entries_capacity) + end + + # Computes the next index in `@indices`, needed when an index is not empty. + private def next_index(index : Int32) : Int32 + fit_in_indices(index + 1) + end + + # Fits a value inside the range of `@indices` + private def fit_in_indices(value) : Int32 + # We avoid doing modulo (`%` or `remainder`) because it's much + # slower than `<<` + `-` + `&`. + # For example if `@indices_size_pow2` is 8 then `indices_size` + # will be 256 (1 << 8) and the mask we use is 0xFF, which is 256 - 1. + (value & ((1_u32 << @indices_size_pow2) - 1)).to_i32! + end + + # Returns the first `Entry` or `nil` if non exists. + private def first_entry? + # We always make sure that `@first` points to the first + # non-deleted entry, so `@entries[@first]` is guaranteed + # to be non-deleted. + @size == 0 ? nil : @entries[@first] + end + + # Returns the first `Entry` or `nil` if non exists. + private def last_entry? + return nil if @size == 0 + + (entries_size - 1).downto(@first).each do |i| + entry = get_entry(i) + return entry unless entry.deleted? + end + + # Might happen if the Hash is modified concurrently + nil + end + + protected getter entries + + # Returns the total number of existing entries, including + # deleted and non-deleted ones. + protected def entries_size + @size + @deleted_count + end + + # Returns the capacity of `@entries`. + protected def entries_capacity + indices_size / 2 + end + + # Computes the hash of a key. + private def key_hash(key) + hash = key.hash.to_u32! + hash == 0 ? UInt32::MAX : hash + end + + # =========================================================================== + # Internal implementation ends + # =========================================================================== + + # Returns the number of elements in this Hash. + getter size : Int32 + # Sets the value of *key* to the given *value*. # # ``` @@ -126,21 +908,7 @@ class Hash(K, V) # h["foo"] # => "bar" # ``` def []=(key : K, value : V) - rehash if @size > 5 * @buckets_size - - index = bucket_index key - entry = insert_in_bucket index, key, value - return value unless entry - - @size += 1 - - if last = @last - last.fore = entry - entry.back = last - end - - @last = entry - @first = entry unless @first + upsert(key, value) value end @@ -342,43 +1110,8 @@ class Hash(K, V) # h.delete("baz") { |key| "#{key} not found" } # => "baz not found" # ``` def delete(key) - index = bucket_index(key) - entry = @buckets[index] - - previous_entry = nil - while entry - if entry.key == key - back_entry = entry.back - fore_entry = entry.fore - if fore_entry - if back_entry - back_entry.fore = fore_entry - fore_entry.back = back_entry - else - @first = fore_entry - fore_entry.back = nil - end - else - if back_entry - back_entry.fore = nil - @last = back_entry - else - @first = nil - @last = nil - end - end - if previous_entry - previous_entry.next = entry.next - else - @buckets[index] = entry.next - end - @size -= 1 - return entry.value - end - previous_entry = entry - entry = entry.next - end - yield key + entry = delete_impl(key) + entry ? entry.value : yield key end # Deletes each key-value pair for which the given block returns `true`. @@ -429,10 +1162,8 @@ class Hash(K, V) # # The enumeration follows the order the keys were inserted. def each : Nil - current = @first - while current - yield({current.key, current.value}) - current = current.fore + each_entry_with_index do |entry, i| + yield({entry.key, entry.value}) end end @@ -449,7 +1180,7 @@ class Hash(K, V) # # The enumeration follows the order the keys were inserted. def each - EntryIterator(K, V).new(self, @first) + EntryIterator(K, V).new(self) end # Calls the given block for each key-value pair and passes in the key. @@ -484,7 +1215,7 @@ class Hash(K, V) # # The enumeration follows the order the keys were inserted. def each_key - KeyIterator(K, V).new(self, @first) + KeyIterator(K, V).new(self) end # Calls the given block for each key-value pair and passes in the value. @@ -519,7 +1250,7 @@ class Hash(K, V) # # The enumeration follows the order the keys were inserted. def each_value - ValueIterator(K, V).new(self, @first) + ValueIterator(K, V).new(self) end # Returns a new `Array` with all the keys. @@ -759,11 +1490,11 @@ class Hash(K, V) # hash # => {:a => 2, :b => 3, :c => 4} # ``` def transform_values!(&block : V -> V) - current = @first - while current - current.value = yield(current.value) - current = current.fore + each_entry_with_index do |entry, i| + new_value = yield entry.value + set_entry(i, Entry(K, V).new(entry.hash, entry.key, new_value)) end + self end # Zips two arrays into a `Hash`, taking keys from *ary1* and values from *ary2*. @@ -782,7 +1513,8 @@ class Hash(K, V) # Returns the first key in the hash. def first_key - @first.not_nil!.key + entry = first_entry? + entry ? entry.key : raise "Can't get first key of empty Hash" end # Returns the first key if it exists, or returns `nil`. @@ -794,12 +1526,13 @@ class Hash(K, V) # hash.first_key? # => nil # ``` def first_key? - @first.try &.key + first_entry?.try &.key end # Returns the first value in the hash. def first_value - @first.not_nil!.value + entry = first_entry? + entry ? entry.value : raise "Can't get first value of empty Hash" end # Returns the first value if it exists, or returns `nil`. @@ -811,12 +1544,13 @@ class Hash(K, V) # hash.first_value? # => nil # ``` def first_value? - @first.try &.value + first_entry?.try &.value end # Returns the last key in the hash. def last_key - @last.not_nil!.key + entry = last_entry? + entry ? entry.key : raise "Can't get last key of empty Hash" end # Returns the last key if it exists, or returns `nil`. @@ -828,12 +1562,13 @@ class Hash(K, V) # hash.last_key? # => nil # ``` def last_key? - @last.try &.key + last_entry?.try &.key end # Returns the last value in the hash. def last_value - @last.not_nil!.value + entry = last_entry? + entry ? entry.value : raise "Can't get last value of empty Hash" end # Returns the last value if it exists, or returns `nil`. @@ -845,7 +1580,7 @@ class Hash(K, V) # hash.last_value? # => nil # ``` def last_value? - @last.try &.value + last_entry?.try &.value end # Deletes and returns the first key-value pair in the hash, @@ -890,10 +1625,10 @@ class Hash(K, V) # hash # => {} # ``` def shift - first = @first - if first - delete first.key - {first.key, first.value} + first_entry = first_entry? + if first_entry + delete_entry_and_update_counts(@first) + {first_entry.key, first_entry.value} else yield end @@ -906,12 +1641,7 @@ class Hash(K, V) # hash.clear # => {} # ``` def clear - @buckets_size.times do |i| - @buckets[i] = nil - end - @size = 0 - @first = nil - @last = nil + clear_impl self end @@ -950,10 +1680,8 @@ class Hash(K, V) # hash_a # => {"foo" => "bar"} # ``` def dup - hash = Hash(K, V).new(initial_capacity: @buckets_size) - each do |key, value| - hash[key] = value - end + hash = Hash(K, V).new + hash.initialize_dup(self) hash end @@ -966,10 +1694,8 @@ class Hash(K, V) # hash_a # => {"foobar" => {"foo" => "bar"}} # ``` def clone - hash = Hash(K, V).new(initial_capacity: @buckets_size) - each do |key, value| - hash[key] = value.clone - end + hash = Hash(K, V).new + hash.initialize_clone(self) hash end @@ -1027,17 +1753,7 @@ class Hash(K, V) # it was inserted into the `Hash` may lead to undefined behaviour. # This method re-indexes the hash using the current key values. def rehash : Nil - new_size = calculate_new_size(@size) - @buckets = @buckets.realloc(new_size) - new_size.times { |i| @buckets[i] = nil } - @buckets_size = new_size - entry = @last - while entry - index = bucket_index entry.key - entry.next = @buckets[index] - @buckets[index] = entry - entry = entry.back - end + do_compaction(rehash: true) end # Inverts keys and values. If there are duplicated values, the last key becomes the new value. @@ -1047,91 +1763,69 @@ class Hash(K, V) # {"foo" => "bar", "baz" => "bar"}.invert # => {"bar" => "baz"} # ``` def invert - hash = Hash(V, K).new(initial_capacity: @buckets_size) + hash = Hash(V, K).new(initial_capacity: @size) self.each do |k, v| hash[v] = k end hash end - protected def find_entry(key) - return nil if empty? - - index = bucket_index key - entry = @buckets[index] - find_entry_in_bucket entry, key - end + struct Entry(K, V) + getter key, value, hash - private def insert_in_bucket(index, key, value) - entry = @buckets[index] - if entry - while entry - if entry.key == key - entry.value = value - return nil - end - if entry.next - entry = entry.next - else - return entry.next = Entry(K, V).new(key, value) - end - end - else - return @buckets[index] = Entry(K, V).new(key, value) + def initialize(@hash : UInt32, @key : K, @value : V) end - end - private def find_entry_in_bucket(entry, key) - while entry - if entry.key == key - return entry - end - entry = entry.next + def self.deleted + key = uninitialized K + value = uninitialized V + new(0_u32, key, value) end - nil - end - - private def bucket_index(key) - key.hash.remainder(@buckets_size).to_i - end - private def calculate_new_size(size) - new_size = 8 - HASH_PRIMES.each do |hash_size| - return hash_size if new_size > size - new_size <<= 1 + def deleted? + @hash == 0_u32 end - raise "Hash table too big" - end - private class Entry(K, V) - getter key : K - property value : V - - # Next in the linked list of each bucket - property next : self? - - # Next in the ordered sense of hash - property fore : self? - - # Previous in the ordered sense of hash - property back : self? + def matches?(hash, key) + # Tiny optimization: for these primitive types it's faster to just + # compare the key instead of comparing the hash and the key. + # We still have to skip hashes with value 0 (means deleted). + {% if K == Bool || + K == Char || + K == Symbol || + K < Int::Primitive || + K < Float::Primitive || + K < Enum %} + @key == key && @hash != 0_u32 + {% else %} + @hash == hash && @key == key + {% end %} + end - def initialize(@key : K, @value : V) + def clone + Entry(K, V).new(hash, key, value.clone) end end private module BaseIterator - def initialize(@hash, @current) + def initialize(@hash) + @index = @hash.@first end def base_next - if current = @current - value = yield current - @current = current.fore - value - else - stop + while true + if @index < @hash.entries_size + entry = @hash.entries[@index] + if entry.deleted? + @index += 1 + else + value = yield entry + @index += 1 + return value + end + else + return stop + end end end end @@ -1141,7 +1835,7 @@ class Hash(K, V) include Iterator({K, V}) @hash : Hash(K, V) - @current : Entry(K, V)? + @index : Int32 def next base_next { |entry| {entry.key, entry.value} } @@ -1153,7 +1847,7 @@ class Hash(K, V) include Iterator(K) @hash : Hash(K, V) - @current : Entry(K, V)? + @index : Int32 def next base_next &.key @@ -1165,43 +1859,10 @@ class Hash(K, V) include Iterator(V) @hash : Hash(K, V) - @current : Entry(K, V)? + @index : Int32 def next base_next &.value end end - - # :nodoc: - HASH_PRIMES = [ - 8 + 3, - 16 + 3, - 32 + 5, - 64 + 3, - 128 + 3, - 256 + 27, - 512 + 9, - 1024 + 9, - 2048 + 5, - 4096 + 3, - 8192 + 27, - 16384 + 43, - 32768 + 3, - 65536 + 45, - 131072 + 29, - 262144 + 3, - 524288 + 21, - 1048576 + 7, - 2097152 + 17, - 4194304 + 15, - 8388608 + 9, - 16777216 + 43, - 33554432 + 35, - 67108864 + 15, - 134217728 + 29, - 268435456 + 3, - 536870912 + 11, - 1073741824 + 85, - 0, - ] end