Conversation
The old fixed-size table with per-bucket CollisionList objects degraded on large inner streams: long chains and scattered binary searches over heap-allocated buffers. Dynamic resizing did not help because cardinality estimates for table functions are fixed at 1000, so rehashing either never fired or fired too late.
Collect {hash, position} pairs without any bucket logic, then group them by bucket in a single counting-sort pass once the inner streams are fully read. Size the table from the actual number of entries, never rehash. After build(), a bucket is a contiguous range in a flat per-stream array, sorted by hash, with bucketStart[] holding the boundaries.
Up to 37% faster on large joins, no regressions on small ones, structural memory unchanged.
aafemt
reviewed
Sep 19, 2026
| total += s->entries.getCount(); | ||
|
|
||
| const ULONG desired = total / (m_streams.getCount() * HASH_LOAD_FACTOR); | ||
| m_tableSize = nextHashSize(desired); |
Contributor
There was a problem hiding this comment.
IMHO, there is no need to round up to the next prime. The main purpose of prime's usage is to flatten distribution of keys among array. Hash functions already aimed to even distribution, so usage of primes hardly can improve situation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replace hash join table with a flat bucket-array structure
Summary
The old
HashJoinimplementation used a fixed-size hash table of 1009 buckets, with a per-bucketCollisionListobject holding aSortedArrayof{hash, position}entries. On inner streams beyond a few hundred thousand records, average chain lengths reached thousands and lookups degenerated into scattered binary searches over heap-allocated buffers. Attempts to make the table grow dynamically did not help: cardinality estimates for table functions and stored procedures use a fixed default of 1000, so rehashing either never fired or fired when it was already too late, and each rehash added allocation and copying overhead on top of the lookup cost.This change replaces the table with a flat per-stream bucket array. During the build phase,
put()only appends raw{hash, position}pairs into a single contiguousArray<Entry>. Once all inner streams have been read,build()picks the final table size from the exact number of collected entries and performs a counting sort over the entries array: one pass to count bucket sizes, one prefix-sum pass to compute offsets, and one redistribution pass into a temporary buffer. Afterbuild(), records sharing a bucket occupy a contiguous range in a single flat array, sorted by hash. Lookup is a binary search inside that range.No
CollisionListobjects, no per-bucket allocations, no rehashing at any point.What changed
HashJoin::HashTableis rewritten around aStreaminner class holdingentries,bucketStart, and an iterator cursor.put()is a plain append; bucket assignment is deferred untilbuild().build()chooses the table size from the actual number of entries (HASH_SIZESup to 1000003) and performs the counting sort described above.bucketStarthastableSize + 1elements so that a bucket can be expressed as a half-open range without a special case for the last one.setup()checks that every stream has a non-empty bucket for the target hash, then primes each stream's cursor viareset().HashJoin::maxCapacity()now returnsMAX_HASH_SIZE * HASH_LOAD_FACTOR, which corresponds to the actual capacity of the flat layout.The lookup API (
setup/reset/iterate) and its usage fromfetchRecord()are unchanged, so no changes were needed in the rest ofHashJoinor inRecordSource.Test results
A test suite covering pure
GENERATE_SERIESjoins, integer keys with uniform / skewed / low-cardinality / NULL distributions, string keys, composite keys, and indexed variants is attached (hash-join-tests.sql). Both builds were run on the same machine, with the same database configuration (DefaultDbCachePages = 32K,TempCacheLimit = 1G) and freshly created data. AllCOUNT(*)results are identical between the two builds and match the expected values.Elapsed times, in seconds:
Summary: parity on inputs below ~10k records, 9–18% faster on 100k, 19–27% faster on 1M, 40–44% faster on 10M. No regressions in any test, including the duplicate-heavy and skewed cases that used to give the old layout trouble.
Query plans are also unchanged between the two builds, including the indexed variants where the optimizer may pick an index-based plan instead of a hash join.
Memory
Structural memory usage is identical between the two builds —
Delta memoryin the test output matches to the byte for every test. The only difference is the transient peak duringbuild(): the flat layout allocates a temporary buffer of the same size as the entries array for the redistribution pass, soMax memoryon the 10M test grows from ~749 MB to ~839 MB. The temporary buffer is released immediately afterbuild()returns; the additional allocation does not persist across queries.For deployments where a single query runs multiple hash joins concurrently, this peak is worth keeping in mind. A possible future improvement is an in-place redistribution using cycle-following and a bitmask of visited positions, which would eliminate the temporary buffer at the cost of more random memory access during build.
hash-join-tests.sql