Repository navigation
Conversation
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
marked this pull request as ready for review
October 8, 2026 20:04
pixelpax
marked this pull request as draft
October 8, 2026 20:05
Owner
Author
|
Opened upstream as quickjs-ng#1815 — closing this fork draft (Gabriel approved opening upstream 2026-10-08 15:28). |
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.
map_hash_keyhashes a number as(lo ^ hi) * 3163over the bits of its double. For an integer below 2048 the lowword 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)) isthe same for every such key while the table has up to 1024 buckets: a Map keyed by
0..N-1is 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 or16 is used.
Bucket spread, stock vs this patch. Each row builds one
Map(orSet) ofndistinct keys and reads thetable'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)/2over the chains). A uniform hash at 2,000 keys in 1,024 buckets uses about 879 bucketsand needs about 3,952 comparisons.
i(0, 1, 2, …)iii * 1024i * 3i * 79192**32 + ii + 0.5i / 10[0, 2**31)2**53 + 2 * i{}made in a loopSymbol()made in a loop2**53 + 2 * iis 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_keyis thesame as on
master) and walks itsJSMapStatehash chains, once on the unmodified source and once with this patchapplied, 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 oddconstants are each invertible), so:
changes; equality is still decided by
js_same_value_zero, so-0/+0,1/1.0andNaNbehave 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
qjsandnode, 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_mapnow alsolooks up every integer key it inserted and checks
-0,1.0andNaNlookups, the semantics the change mustpreserve.
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.