perf(hashset): store the table as two arrays, deriving each PSL - #4131
perf(hashset): store the table as two arrays, deriving each PSL#4131bobzhang wants to merge 2 commits into
Conversation
`HashSet` stored its table as `FixedArray[Entry[K]?]`, where
`Entry { psl, hash, key }` is heap-allocated, so every inserted key
allocated one object. Profiling `add` on native attributed ~11% of the
time to that malloc alone, on top of the reference-counting churn of the
boxed entries.
Replace it with three parallel arrays:
psls : FixedArray[Int] // -1 marks an empty slot
hashes : FixedArray[Int]
keys : UninitializedArray[K]
`psls[i] == -1` is the empty-slot sentinel, since a real probe sequence
length is always `>= 0`, so no separate occupancy array is needed.
Inserting writes three slots and allocates nothing. Vacated slots have
their key `set_null`-ed so it stays collectable. Same Robin Hood
algorithm, same observable behaviour, `.mbti` unchanged.
The unchecked accessors are declared package-locally as methods on
`UninitializedArray` in `hashset/types.mbt` rather than exposed from
`builtin`: `set_null` and `unsafe_set` can corrupt memory or resurrect a
freed slot, so the fewer packages able to name them, the better.
Every function that `main` probes without bounds checks does so here
too -- `add_with_hash`, `push_away`, `set_slot`, `contains`, `remove`,
`shift_back`, `rehash_place_entry` and `iter` -- plus `grow` and `copy`.
The layout work predates the unchecked-probing commit, so rewriting a
function meant writing its checked form back; `remove`, `shift_back` and
`iter` had each silently reverted and are restored, and the two sets are
now compared function by function rather than spot-checked.
`copy` builds its metadata arrays with `copy()` rather than make-then-
blit: one allocate-and-copy each instead of allocate, fill, then
overwrite, and on js it lowers to `Array.slice`.
Measured against `main`, n=50000, interleaved in one session:
| backend | op | main | this | change |
| ------- | -- | ---- | ---- | ------ |
| native | `add` | 2.28 ms | 1.41 ms | 38% faster |
| native | `contains` | 829 us | 745 us | 10% faster |
| native | `copy` | 472 us | 71 us | 85% faster |
| native | removal | 1.07 ms | 684 us | 36% faster |
| wasm-gc | `add` | 2.19 ms | 1.89 ms | 14% faster |
| wasm-gc | `contains` | 1.12 ms | 989 us | 12% faster |
| wasm-gc | `copy` | 368 us | 238 us | 35% faster |
| wasm-gc | removal | 1.12 ms | 1.06 ms | 5% faster |
| js | `add` | 2.46 ms | 2.84 ms | 15% slower |
| js | `contains` | 1.00 ms | 987 us | unchanged |
| js | `copy` | 434 us | 352 us | 19% faster |
| js | removal | 1.08 ms | 998 us | 7% faster |
Removal is isolated by difference, from a benchmark that copies a
prebuilt set inside the timed closure and a companion that times the copy
alone, since `@bench.T` has no per-iteration setup hook.
The remaining regression is js `add`. It is NOT growth-bound: with the
table pre-sized so no growth happens at all, the gap persists and widens
in relative terms (862 us -> 1.24 ms, +44%). The cause is the number of
random keyed stores into separate backing arrays. Instrumenting the
benchmark counts 53,586 slot stores for the boxed layout against 160,758
for this one, and on js an extra random store into a second array costs
far more than the extra fields on a freshly allocated object: patching
the generated boxed program to add one shadow-array store takes it from
0.90 ms to 1.20 ms, two to 1.40 ms, while adding two more in-object
fields costs 0.07 ms.
This is a consequence of `FixedArray[Int]` lowering to an ordinary JS
array of tagged words rather than anything inherent to the layout.
Patching the generated program to hold `psls` and `hashes` in `Int32Array`
brings pre-sized `add` to 0.60-0.64 ms, faster than the boxed layout, and
growing `add` to 2.24 ms against boxed's 2.42 ms.
Earlier revisions of this branch reported a large `copy` regression on
the collected backends. That comparison was against `HashSet::copy` as it
stood before a3d3ee1, which shared mutable entries rather than copying
them and so was not doing the same work.
Co-Authored-By: mizchi <miz404@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Supersedes the three-array layout on #4127, which regressed js `add` by 15-44% because a probe step touched three cache lines where the boxed layout touched one. A slot's probe-sequence length is not independent state: it is the distance between where the key sits and where its hash asked it to sit, so psl = (idx - (stored_hash & mask)) & mask Every write to a PSL happened as a key landed in a slot, always equal to that displacement, so the array only ever cached a derivable quantity. Dropping it leaves two arrays -- `hashes` and `keys` -- and replaces a random load in a 512 KB array with two arithmetic operations. `hashes[i] == 0` now marks an empty slot. Any `Int` can be a real hash, so `store_hash` remaps that one value onto `1`; the two share a bucket, which costs an occasional extra key comparison and nothing else, since lookups compare keys as well as hashes. Measured against `main`, n=50000, interleaved in one session: | backend | op | main | this | change | | ------- | -- | ---- | ---- | ------ | | native | `add` | 2.23 ms | 1.42 ms | 36% faster | | native | `contains` | 842 us | 744 us | 12% faster | | native | `copy` | 484 us | 65 us | 86% faster | | native | `add` string | 3.51 ms | 3.22 ms | 8% faster | | native | `contains` string | 2.16 ms | 1.84 ms | 15% faster | | wasm-gc | `add` | 2.20 ms | 1.78 ms | 19% faster | | wasm-gc | `contains` | 1.06 ms | 987 us | 7% faster | | wasm-gc | `copy` | 373 us | 168 us | 55% faster | | wasm-gc | `add` string | 3.36 ms | 3.00 ms | 11% faster | | wasm-gc | `contains` string | 2.61 ms | 2.41 ms | 8% faster | | js | `add` | 2.37 ms | 2.33 ms | unchanged | | js | `contains` | 962 us | 904 us | 6% faster | | js | `copy` | 432 us | 314 us | 27% faster | | js | `add` string | 3.41 ms | 3.58 ms | 5% slower | | js | `contains` string | 2.40 ms | 2.21 ms | 8% faster | Faster on every measurement except js insertion with reference keys. Integer insertion on js, which the three-array layout lost by 15-44%, is back to parity. Both js insertion figures are the mean of two alternating samples per side, since those are the two the design turns on. Because the derivation is now load-bearing rather than merely true, `derived_psl_test.mbt` exercises the paths that used to write a PSL: a four-bucket collision stress with removal and reinsertion across six growth rounds, keys whose hash is the remapped sentinel, and a differential run of 20000 randomized operations against a bitmap model. Co-Authored-By: mizchi <miz404@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex CLI verification (
|
| js, n=50000 | two-array (A1, A2) | main (B1, B2) |
|---|---|---|
add integer keys |
2.32, 2.33 ms | 2.38, 2.36 ms |
add string keys |
3.57, 3.59 ms | 3.42, 3.39 ms |
Integer insertion is at parity — marginally ahead, with ranges overlapping. String insertion is a real 5% regression, which is tighter than the 7% I first reported from a single sample.
The uncaught mutation is the same liveness gap discussed on #3712: asserting that a removed key became collectable needs weak references or finalization, which is not portable across the four backends. The guard is a comment at the line, since what actually threatens that call is someone reading shift_back and deleting a line that looks redundant beside the hashes write above it.
moon check --deny-warn clean on native, js and wasm-gc; moon test 7549/7549 and 7490/7490 on js; pkg.generated.mbti unchanged.
There was a problem hiding this comment.
Pull request overview
Refactors hashset’s internal table layout to eliminate per-entry allocations by switching from boxed Entry objects to two parallel arrays (hashes + keys) and deriving probe-sequence length (PSL) from (idx - home_idx) & mask. This targets better cache behavior and lower allocation overhead while preserving the Robin Hood hashing behavior.
Changes:
- Replace
FixedArray[Entry[K]?]withFixedArray[Int](hashes) +UninitializedArray[K](keys) and derive PSL viapsl_at. - Introduce
empty_hashsentinel handling withstore_hashremapping and update all core operations (add,contains,remove,shift_back,grow,copy) accordingly. - Add targeted regression tests for derived PSL behavior and extend benchmarks (copy/remove, string-key cases).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| hashset/types.mbt | Introduces sentinel hash scheme, store_hash, and package-local unchecked key-array accessors; updates HashSet storage fields. |
| hashset/hashset.mbt | Rewrites HashSet operations to use (hashes, keys) layout and derived PSL; adds/updates tests and optimizes copy/clear. |
| hashset/hashset_bench_test.mbt | Adds copy/remove benchmarks and string-key benchmarks to validate perf claims and cover removal path. |
| hashset/derived_psl_test.mbt | New stress + differential tests to validate derived PSL across collisions, removals, growth, copying, and sentinel remap. |
Suppressed comments (1)
hashset/hashset.mbt:836
- Safety comment still mentions a “non-empty PSL”, but PSLs are derived now. Use the actual occupancy predicate (
hashes[i] != empty_hash) so the invariant is accurate.
// SAFETY: `i < capacity`, the length of every backing array, and a
// non-empty PSL means the key slot was initialized.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // SAFETY: `idx < len`, which is the capacity captured when the | ||
| // iterator was created, and the table never shrinks; a non-empty PSL | ||
| // means the key slot was initialized. |
Coverage Report for CI Build 6300Coverage increased (+0.008%) to 90.885%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
…g it
`Entry` carried a `mut psl : Int` alongside `hash`, `key` and `value`.
That field is not independent state: an entry's probe-sequence length is
the distance between the slot it occupies and the slot its hash asked
for, so
psl = (idx - (hash & capacity_mask)) & capacity_mask
Every write to it happened as the entry landed in a slot and always
equalled that displacement, and every read happens inside a probe loop
that already knows the slot index. `psl_at` computes it where it is
needed and the field is gone, leaving five fields per entry instead of
six.
`shift_back` no longer decrements anything: moving an entry one slot back
lowers its derived PSL by exactly one. `push_away` and
`rehash_place_entry` likewise stop writing PSLs and just place entries.
`get`'s probe loop hoists `capacity_mask` into a local, since deriving a
PSL reads it twice more per step than the stored field did.
I checked the premise before relying on it. Instrumenting `set_entry` and
`add_entry_to_tail` -- every path that places an entry in a slot -- to
assert `psl == (idx - (hash & mask)) & mask` on each write, the full
suite passes on native, js and wasm-gc with that assertion live, no
violation.
No layout change: the table is still `FixedArray[Entry[K, V]?]`, so
nothing here shares the tradeoffs of the struct-of-arrays experiments on
#4127 and #4131.
This is a trade, not a free win. Measured against `main`, n=50000,
interleaved:
| backend | op | change |
| ------- | -- | ------ |
| js | `set` | 6.5% faster |
| js | `set+remove` | 2.8% faster |
| js | `get` hit | unchanged |
| js | `get` miss | unchanged |
| native | `get` hit | 2.7% faster |
| native | `set` | unchanged |
| native | `get` miss | ~2% slower |
| wasm-gc | `set` | 1.2% faster |
| wasm-gc | `get` hit | 1.4% faster |
| wasm-gc | `get` miss | ~3% slower |
Insertion gains where the smaller entry matters most, and lookups that
hit are unchanged or better. Lookups that *miss* are slower on the two
backends where the stored field was a plain in-object load: a miss walks
the probe sequence to its end, so it pays the derivation on every step.
Hoisting the mask halved that cost -- it was 4.8% on native and 3.4% on
wasm-gc before -- but did not remove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Supersedes #4127, which I have marked draft. Same goal — stop allocating a boxed
Entryper key — but without the js regression that made #4127 unlandable.The design is Codex CLI's, proposed while explaining that regression. I implemented and measured it.
What changed
#4127 stored three arrays:
psls,hashes,keys. A probe step read all three, at the same index in three arrays 512 KB apart, so it touched three cache lines where the boxed layout touched one — the entry object holds psl, hash and key together. That cost js insertion 15% growing and 44% pre-sized.A slot's probe-sequence length is not independent state. It is the distance between where the key sits and where its hash asked it to sit:
So
pslsgoes away. Two arrays remain, and a probe step trades a random load in a 512 KB array for two arithmetic operations.hashes[i] == 0marks an empty slot. AnyIntcan be a real hash, sostore_hashremaps0onto1; the two then share a bucket, which costs an occasional extra key comparison and nothing else, since lookups compare keys as well as hashes.shift_backno longer writes a PSL at all — moving an occupant one slot back is the decrement.Verifying the premise before building
pslwas amutfield, which suggests independent state. I checked rather than assumed: I instrumentedset_sloton the three-array branch to assertpsl == (idx - (hash & mask)) & maskon every write, then ran the full suite on three backends plus a four-bucket collision stress with removals, shift-back cascades, growth and copy. It never fired. Every PSL write happened as a key landed in a slot, always equal to its displacement.Measurements
n=50000, against
main, interleaved on one machine in a single session:addcontainscopyaddstringcontainsstringaddcontainscopyaddstringcontainsstringaddcontainscopyaddstringcontainsstringFaster on fourteen of fifteen. The two js insertion figures are the mean of two alternating samples per side, since they are what the design turns on: integer
addcame out 2.32/2.33 against 2.38/2.36, stringadd3.57/3.59 against 3.42/3.39.String keys are benchmarked because the boxed layout stores a pointer inside its entry where this one stores it directly in the key array — a difference integer benchmarks cannot show. That case is the single remaining regression, at 5%.
Tests
The derivation is now load-bearing rather than merely true, so
derived_psl_test.mbtcovers the paths that used to write a PSL: a four-bucket collision stress with removal and reinsertion across six growth rounds, keys whose hash is the remapped sentinel, and a 20000-operation differential run against a bitmap model.Review
Verified by Codex CLI at
xhigh. It found no algorithmic defect —psl_atcorrect across wraparound,shift_back's implicit decrement exact,push_awaypreserving the Robin Hood recurrence and terminating, growth reinserting under the new mask, the sentinel remap sound, everypsl_atcall guarded by an occupied slot, and miss lookup retaining its early exit. Full verdict in a comment below.It withheld a sign-off on methodology rather than correctness: its sandbox is read-only, so it could not run fresh interleaved benchmarks and would not certify numbers it had not reproduced. The alternating samples above are my response to that; the benchmark claims remain mine.
One gap it names and I have not closed: deleting
keys.set_null(cur)inshift_backwould leave every test passing while removed keys stayed reachable. That is the same liveness gap discussed on #3712 — no portable way to assert it across four backends — so the guard is a comment at the line explaining why it is not redundant.