Skip to content

Mix the Map/Set key hash so small integers don't share a bucket - #1

Closed
pixelpax wants to merge 2 commits into
masterfrom
map-hash-avalanche
Closed

pixelpax wants to merge 2 commits into
masterfrom
map-hash-avalanche

Conversation

@pixelpax

@pixelpax pixelpax commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

map_hash_key hashes a number as (lo ^ hi) * 3163 over the bits of its double. For an integer below 2048 the low
word is zero and the low ten bits of the high word are zero; multiplying by an odd constant keeps them zero, and the
seed and tag that are xor-ed in afterwards are the same for every key. So the bucket index (h & (hash_size - 1)) is
the same for every such key while the table has up to 1024 buckets: a Map keyed by 0..N-1 is one linked list.
Object and symbol keys hash as address * 3163; heap pointers are 8- or 16-byte aligned, so at most 1 bucket in 8 or
16 is used.

Bucket spread, stock vs this patch. Each row builds one Map (or Set) of n distinct keys and reads the
table's own bucket chains: buckets used, the longest chain, and the key comparisons needed to look every key up
once (the sum of L(L+1)/2 over the chains). A uniform hash at 2,000 keys in 1,024 buckets uses about 879 buckets
and needs about 3,952 comparisons.

keys n buckets stock: used / longest / comparisons patched: used / longest / comparisons
i (0, 1, 2, …) 500 256 1 / 500 / 125,250 218 / 6 / 991
i 2,000 1,024 1 / 2,000 / 2,001,000 869 / 6 / 3,945
i 100,000 65,536 4,096 / 217 / 2,319,584 51,336 / 9 / 176,270
i * 1024 2,000 1,024 1 / 2,000 / 2,001,000 887 / 8 / 3,957
i * 3 2,000 1,024 4 / 1,182 / 850,185 874 / 10 / 4,012
i * 7919 2,000 1,024 273 / 17 / 10,661 887 / 8 / 3,925
2**32 + i 10,000 8,192 3 / 4,096 / 18,416,648 5,772 / 8 / 16,121
i + 0.5 2,000 1,024 2 / 1,024 / 1,001,576 875 / 7 / 3,923
i / 10 2,000 1,024 3 / 1,199 / 880,203 879 / 8 / 3,930
random integers in [0, 2**31) 2,000 1,024 887 / 8 / 3,973 876 / 7 / 3,973
2**53 + 2 * i 2,000 1,024 1,024 / 2 / 2,976 879 / 7 / 3,958
objects {} made in a loop 2,000 1,024 32 / 79 / 64,508 889 / 7 / 3,917
Symbol() made in a loop 2,000 1,024 64 / 36 / 32,481 865 / 7 / 3,956

2**53 + 2 * i is the one family that gets worse: above 2^53 consecutive doubles differ only in their low word, and stock's multiplication by 3163 happens to send such a run to every bucket in turn (two keys each); the patch scatters them like any other keys, so they cost what a uniform hash costs, about a third more comparisons.

Measured by counting, not timing: a test builds each collection in QuickJS-ng 0.8.0 (whose map_hash_key is the
same as on master) and walks its JSMapState hash chains, once on the unmodified source and once with this patch
applied, both on macOS arm64 (Apple M4). Number rows are the same on every run and machine; object and symbol rows
hash addresses, so their exact values move between runs, but stock can never use more than 1 bucket in 16 for them
(aligned addresses times 3163 keep the low four bits zero), and the patched rows stay at a uniform hash's figures.

The fix runs the 32-bit hash through the lowbias32 finalizer (https://nullprogram.com/blog/2018/07/31/) before the
seed and tag are mixed in. The finalizer is a bijection on uint32_t (xor-shifts and multiplications by odd
constants are each invertible), so:

  • keys that hashed equal still hash equal, and distinct hashes stay distinct — only which bucket a key lands in
    changes; equality is still decided by js_same_value_zero, so -0/+0, 1/1.0 and NaN behave as before;
  • iteration order is unaffected: it follows the record list, not the buckets;
  • it doesn't change the collision hardening from 807f271: the seed is xor-ed in after the finalizer, as before.

The first commit covers number keys; the second does the same for object and symbol keys, as a separate commit so it
can be taken or dropped on its own.

Comparison with V8. V8 (read at 15.0.245.2; Node 22 and 24 have the same code) never lets a number's bits or an object's address reach the bucket index unmixed: small integers go through a 32-bit integer hash (ComputeUnseededHash), other doubles through a 64-bit one over their bits (ComputeLongHash), and objects and symbols carry a random identity hash stored in the object.
Lookups were timed with one script that runs under both qjs and node, on GitHub-hosted runners (Linux x64 and macOS arm64, fastest of three rounds): with 1,000 consecutive integer keys a lookup costs about 1,070 ns before this change and 61 ns after (Node: 5.5 ns); with 100,000 millisecond timestamps (1.7e12 + i) about 37,000 ns before and 87 ns after (Node: 31 ns).
After the change every key family, objects and symbols included, costs what random integer keys cost at each size, which is also what V8 shows; string keys are unchanged.
One family gets slower: keys whose double has a counting low word (2^53 + 2i) were spread perfectly by the multiplication and now cost the same as the rest (1.05–1.4× more on Linux, 1.5–1.8× on macOS).
Shared runners, so read ratios rather than nanoseconds; script, workflow and raw output: https://github.com/pixelpax/quickjs/actions/runs/37815258145

No new test of the speedup itself — like 807f271, anything that observes it depends on timing. test_map now also
looks up every integer key it inserted and checks -0, 1.0 and NaN lookups, the semantics the change must
preserve.

Fork CI: https://github.com/pixelpax/quickjs/actions (runs on this branch)

Prepared with AI assistance; the numbers were checked against counted bucket chains in our engine.

map_hash_key hashes a number as (lo ^ hi) * 3163 over the bits of its
double. For an integer below 2048 the low word is zero and the low ten
bits of the high word are zero, and multiplying by an odd constant keeps
them zero, so every such key lands in the same bucket while the table
has up to 1024 buckets. The ids 0..1999 all share one bucket of 1024 in
a Map, and looking each one up once takes 2,001,000 key comparisons.

Run the value through the lowbias32 finalizer before the seed and tag
are mixed in. It is a bijection on uint32_t, so keys that hashed equal
still do; only bucket placement changes. The same 2000 keys now use 869
of 1024 buckets, the longest chain is 6, and the lookups take 3,945
comparisons. Iteration order is unaffected; it follows the record list.
Object and symbol keys hash as their address times 3163. Heap pointers
are at least 8 or 16 byte aligned, and the odd multiplier keeps those
low bits zero, so a Map keyed by objects uses at most 1 in 8 or 1 in 16
of its buckets, and fewer when the objects are allocated at a regular
stride: 1000 objects 64 bytes apart use 8 of 512 buckets.

Run the value through the same finalizer as numeric keys. As before,
equal keys still hash equal and only bucket placement changes.
@pixelpax pixelpax closed this Oct 8, 2026
@pixelpax pixelpax reopened this Oct 8, 2026
@pixelpax pixelpax changed the title CI run: map-hash-avalanche (fork-internal, not for upstream) Mix the Map/Set key hash so small integers don't share a bucket Oct 8, 2026
@pixelpax
pixelpax marked this pull request as ready for review October 8, 2026 20:04
@pixelpax
pixelpax marked this pull request as draft October 8, 2026 20:05
@pixelpax

pixelpax commented Oct 8, 2026

Copy link
Copy Markdown
Owner Author

Opened upstream as quickjs-ng#1815 — closing this fork draft (Gabriel approved opening upstream 2026-10-08 15:28).

@pixelpax pixelpax closed this Oct 8, 2026
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.

1 participant