Skip to content

Make hashtable::reserve() grow-only (#521) - #604

Open
devteamaegis wants to merge 1 commit into
electronicarts:masterfrom
devteamaegis:fix/hashtable-reserve-no-shrink
Open

devteamaegis wants to merge 1 commit into
electronicarts:masterfrom
devteamaegis:fix/hashtable-reserve-no-shrink

Conversation

@devteamaegis

Copy link
Copy Markdown

Fixes #521.

hashtable::reserve(n) (used by hash_map, hash_set, unordered_*, fixed_hash_*, string_hash_map) always calls rehash(GetBucketCount(n)), and rehash always runs DoRehash. As a result:

  • No-op reserves still reallocate. If the table already has the computed bucket count, it still allocates a new bucket array, re-buckets every node, and frees the old one.
  • Small reserves shrink the table below the max load factor. With 2000 elements, reserve(10) leaves 11 buckets (load factor 181.8, max 1.0). That breaks the standard's bucket_count() >= size()/max_load_factor() postcondition, and the next insert rehashes back up.

Change: rehash only when GetBucketCount(n) > bucket_count(). This is the same check rehash_policy() already uses, and MSVC's reserve/rehash behave the same way (if (_Buckets <= _Maxidx) return;). rehash(n) is unchanged, so it is still the explicit way to set or shrink the bucket count, and clear(true) still frees buckets.

Behavior change to note: reserve() no longer shrinks a table. If you'd rather match libc++/libstdc++, the alternative is to compute the count for max(n, size()) and rehash only when it differs from the current count. That still allows shrinking down to the load-factor floor. I can switch the PR to that. This is also relevant to #449.

Numbers (Apple clang 17, arm64, -O2): 1000 × reserve(size()) on a 100k-element hash_set<int>: 100 ms → 0.009 ms.

Test: TestHash.cpp uses CountingAllocator. It checks that repeated, equal and smaller reserve() calls allocate nothing and leave bucket_count() and the load factor within limits, that the next insert doesn't rehash, and that growing still makes exactly one bucket-array allocation. The test fails on master (6 checks) and passes with the fix. The full suite passes in Release and Debug.

Testing note: I ran the suite with Apple clang targeting x86_64. On arm64 macOS the test suite doesn't build on master because of #584.

This was investigated and written with an AI coding agent (Claude) working on my behalf.

reserve() unconditionally called rehash(GetBucketCount(n)), so it allocated
a new bucket array and re-bucketed every node even when the bucket count
would not change, and it shrank the table when n was smaller than what the
current buckets already allow. Shrinking below the element count leaves the
table above its max load factor (e.g. 2000 elements, reserve(10) -> 11
buckets, load factor 181), which also breaks the standard's postcondition
bucket_count() >= size() / max_load_factor(), and the next insert then
rehashes straight back up.

Only rehash when the computed bucket count is larger than the current one,
matching rehash_policy() and MSVC's unordered containers. rehash() keeps its
existing "use the bucket count as given" behaviour for callers who want to
shrink explicitly.

Adds a test using CountingAllocator that checks repeated and smaller
reserve() calls neither allocate nor change the bucket count, and that
growing still allocates exactly one new bucket array.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Question] Should eastl::unordered_set.reserve not check if a change is required?

1 participant