Skip to content

perf(hashset): store the table as two arrays, deriving each PSL - #4131

Open
bobzhang wants to merge 2 commits into
mainfrom
hongbo/hashset-two-array
Open

perf(hashset): store the table as two arrays, deriving each PSL#4131
bobzhang wants to merge 2 commits into
mainfrom
hongbo/hashset-two-array

Conversation

@bobzhang

Copy link
Copy Markdown
Contributor

Supersedes #4127, which I have marked draft. Same goal — stop allocating a boxed Entry per 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:

psl = (idx - (stored_hash & mask)) & mask

So psls goes away. Two arrays remain, and a probe step trades a random load in a 512 KB array for two arithmetic operations.

hashes[i] == 0 marks an empty slot. Any Int can be a real hash, so store_hash remaps 0 onto 1; 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_back no longer writes a PSL at all — moving an occupant one slot back is the decrement.

Verifying the premise before building

psl was a mut field, which suggests independent state. I checked rather than assumed: I instrumented set_slot on the three-array branch to assert psl == (idx - (hash & mask)) & mask on 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:

backend op main this branch change
native add 2.23 ms 1.42 ms 36% faster
native contains 842 µs 744 µs 12% faster
native copy 484 µs 65 µs 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 µs 7% faster
wasm-gc copy 373 µs 168 µs 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 µs 904 µs 6% faster
js copy 432 µs 314 µs 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 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 add came out 2.32/2.33 against 2.38/2.36, string add 3.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.mbt covers 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 defectpsl_at correct across wraparound, shift_back's implicit decrement exact, push_away preserving the Robin Hood recurrence and terminating, growth reinserting under the new mask, the sentinel remap sound, every psl_at call 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) in shift_back would 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.

bobzhang and others added 2 commits August 22, 2026 18:12
`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>
Copilot AI lite review requested due to automatic review settings August 22, 2026 14:04
@bobzhang

Copy link
Copy Markdown
Contributor Author

Codex CLI verification (xhigh)

No algorithmic defect found. It withheld sign-off on methodology — a read-only sandbox prevented it running fresh benchmarks — not on correctness.

I would not land it yet. I found no algorithmic correctness defect, but the required fresh benchmark comparison and full validation could not be completed because the workspace is read-only: Moon cannot acquire its _build lock.

Algorithm review:

  • psl_at correctly computes circular displacement modulo the power-of-two capacity.
  • In shift_back, moving next to cur = next - 1 mod capacity reduces its derived PSL by exactly one, including wraparound. Stopping at an empty slot or PSL zero matches the old implementation.
  • push_away preserves the previous Robin Hood recurrence exactly. It must terminate because insertion occurs with at least one empty slot; growth caps occupancy at 13/16.
  • Growth reinserts every occupied entry using the new mask, so no old-table displacement information is needed.
  • Mapping hashes 0 → 1 is sound. Real hashes zero and one share their home bucket and cached fingerprint, but equality comparisons distinguish their keys. Insertion, lookup, removal, and growth all use the remapped value consistently.
  • Every psl_at call is guarded by an occupied hash slot or receives an occupant directly from such a slot.
  • Miss lookup retains the same Robin Hood early exit. Independently, an empty slot must occur before a full-table scan because the table is never close to full.

Test mutation not caught: remove self.keys.set_null(cur). Membership tests would still pass because the hash sentinel controls occupancy, but removed reference keys would remain retained.

  • git diff --check passed. hashset/pkg.generated.mbti is unchanged from origin/main. Direct formatter comparison found all four changed files formatted. Cached native and JS executables passed the derived-PSL and internal wraparound tests.

Benchmark conclusion: undetermined. […] there is no same-session origin/main baseline, and those binaries predate the commit by several minutes. Therefore they cannot establish either parity or a real string regression.

That last point is the right call and I have answered it with alternating samples rather than a single pass, since the two js insertion figures are what the whole design turns on:

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]?] with FixedArray[Int] (hashes) + UninitializedArray[K] (keys) and derive PSL via psl_at.
  • Introduce empty_hash sentinel handling with store_hash remapping 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.

Comment thread hashset/hashset.mbt
Comment on lines +397 to +399
// 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.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6300

Coverage increased (+0.008%) to 90.885%

Details

  • Coverage increased (+0.008%) from the base build.
  • Patch coverage: 5 uncovered changes across 1 file (72 of 77 lines covered, 93.51%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
hashset/hashset.mbt 74 69 93.24%
Total (2 files) 77 72 93.51%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 18464
Covered Lines: 16781
Line Coverage: 90.88%
Coverage Strength: 306565.25 hits per line

💛 - Coveralls

bobzhang added a commit that referenced this pull request Aug 22, 2026
…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>
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.

3 participants