Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors @hashset.HashSet’s internal table storage from an array of boxed Entry objects to a struct-of-arrays layout (psls / hashes / keys) to eliminate per-insert heap allocations while preserving the existing Robin Hood hashing behavior.
Changes:
- Replaces
entries: FixedArray[Entry[K]?]withpsls: FixedArray[Int],hashes: FixedArray[Int], andkeys: UninitializedArray[K](usingempty_psl = -1as the empty-slot sentinel). - Updates all core operations (
add,contains,remove, rehash/grow, iteration, copy, debug helpers) to operate on the new storage layout. - Adds benchmarks (
hashset_bench_test.mbt) and includes the bench dependency for tests inhashset/moon.pkg.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| hashset/types.mbt | Introduces the new table layout primitives (empty_psl, set_null) and updates HashSet’s internal fields to the struct-of-arrays design. |
| hashset/hashset.mbt | Rewrites all HashSet operations to use psls/hashes/keys storage, removing per-entry boxing/allocation. |
| hashset/moon.pkg | Adds the bench package dependency for test/benchmark support. |
| hashset/hashset_bench_test.mbt | Adds microbenchmarks for HashSet::add and HashSet::contains. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Addressed the |
1a4a67c to
f6e67c6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f6e67c63a1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
a4d4747 to
336f6c5
Compare
…alloc
HashSet stored its table as `FixedArray[Entry[K]?]`, so every newly
inserted key allocated a heap `Entry { psl, hash, key }` object.
Profiling `HashSet::add` showed ~11% of time in that per-entry malloc,
on top of the reference-counting churn of boxed entries.
Replace the array of boxed entries with a struct-of-arrays layout:
`psls` / `hashes : FixedArray[Int]` and `keys : UninitializedArray[K]`,
using `psls[i] == -1` as the empty-slot sentinel (a real probe-sequence
length is always >= 0). Inserting a key now writes three array slots
with no allocation; vacated slots have their key nulled so it stays
collectable.
Bench (native, n=50000):
add: 1.95 ms -> 1.38 ms (~29% faster)
contains: 657 µs -> 675 µs (unchanged; the read path does not allocate)
Adds hashset/hashset_bench_test.mbt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The struct-of-arrays layout replaces one checked array access per probe step with three, and on js -- where the malloc and reference-counting costs the layout removes do not exist -- those checks made `add` about 28% slower than the boxed layout it replaces. `contains` already probes with `unsafe_get` under a documented index invariant. The same invariant holds everywhere else the table is probed: the index starts as `hash & capacity_mask` and every step re-masks it, so it is in bounds for all three arrays, and a non-empty PSL guarantees the key slot was initialized. Apply it to `add_with_hash`, `push_away`, `rehash_place_entry`, `grow` and `set_slot`, each with the reasoning written out. `add`, n=50000, against the boxed layout on main: | backend | main | SoA | SoA + unchecked | | ------- | ---- | --- | --------------- | | native | 2.28 ms | 1.66 ms | 1.41 ms | | wasm-gc | 2.19 ms | 1.93 ms | 1.89 ms | | js | 2.78-2.93 ms | 3.68-3.72 ms | 2.88 ms | js returns to parity and native gains a further 15% on top of the layout change, for 38% against main overall. `contains` is unchanged by this commit on every backend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
336f6c5 to
855e773
Compare
|
Rebased onto RebaseFour conflicts, all from
The js regression
Two independent runs per side on js; the ranges do not overlap (main 2.57–3.17 ms, PR 3.55–3.84 ms), so the regression is real. The reason is structural rather than incidental. Struct-of-arrays replaces one checked array access per probe step with three — and on js the costs the layout exists to remove, per-entry I first guessed the cause was Fix:
|
| backend | main | SoA as opened | SoA + unchecked |
|---|---|---|---|
| native | 2.28 ms | 1.66 ms | 1.41 ms |
| wasm-gc | 2.19 ms | 1.93 ms | 1.89 ms |
| js | 2.78 / 2.93 ms | 3.68 / 3.72 ms | 2.88 ms |
js is back inside its own baseline range, and native picks up a further 15% — 38% against main overall. contains is untouched by that commit (native 760 µs, wasm-gc 1.01 ms, js 1.01 ms).
@mizchi — that last commit is mine, pushed here since the PR allows maintainer edits. Drop it if you would rather take a different route; the measurements above are the argument for it, not the code itself.
Codex CLI review (xhigh reasoning effort)
Two rounds. Round 1 raised the js regression as a P1; round 2, after the fix, approved.
Round 1 disproved my first explanation experimentally, which is why the fix is what it is:
The holey-array fact is right, but it is not the main explanation. On Node 25.8.2 / V8 14.1, the generated bare
new Array(n)is holey. However, I changed the generated benchmark in-memory to use.fill(null), verified that V8 then reported it packed, and ran it interleaved: original medians 3.60 ms, 3.62 ms; packed-key medians 3.66 ms, 3.68 ms. Packingkeysdid not recover performance. […] Conversely, removing generated bounds checks gave about 2.79 ms, close to your 2.78–2.93 ms main baseline. Removing only the three checks in generatedset_slotrecovered roughly 10%. That points to the multi-array checked access pattern, plus more expensive growth/rehashing, as the dominant cause.
It also noted the js contains win comes from the existing unsafe_get commit rather than from the layout, and corrected me on set_null: it does not compile away on js — it emits keys[i] = null and is required to release the reference. It was simply dead-code-eliminated out of the release bench, where remove/clear are never called.
Round 2, on the unchecked-access commit:
No blocking findings. I approve the PR.
- Every
set_slotindex is masked: directly from an insertion/rehash probe, frompush_away's re-masked loop, or fromshift_back, whose initial and subsequentcurvalues are masked.- During
grow, the new arrays, capacity, and mask are installed before rehashing begins. The old-array loop uses onlyi < old_capacity; rehash operations use only the new arrays and mask. There is no old/new index mismatch.add_with_hashreadsself.capacity_maskonly aftergrow()returns, so it cannot retain the old mask.- The initialized-key invariant survives
shift_back,clear,retain, andcopy. Slots are nullified only after their PSL becomes empty; copied occupied slots receive their key before the new set is returned.- Stale hashes in empty slots are harmless because every relevant hash read is dominated by a non-empty PSL check.
unsafe_set_key's signature exactly matches the builtinUninitializedArray::unsafe_setbinding to%fixedarray.unsafe_set.set_slotwrites PSL before key, but there is no callback or suspension between those stores, andHashSetexplicitly does not support concurrent access.Tests alone cannot establish unchecked safety. The meaningful evidence is the exhaustive caller/state-transition proof above. […]
The round-one P1 no longer stands: JS performance is back within baseline variance, while native improved further. The whole PR is landable.
Signed-off-by: Codex CLI codex@openai.com
Worth repeating its caveat: for unchecked indexing, a green test suite is weak evidence — an out-of-bounds access is undefined behaviour, not a failure. What justifies this commit is the caller/state-transition argument, not the 76/76.
One open P3 it raised against the PR as a whole, which I have not addressed: deleting both set_null calls would leave every test passing while removed keys stay reachable until the set itself dies. There is no liveness test for that.
|
Two follow-ups. 1. The unsafe primitives are methods now (
2. Corrected benchmark table. The numbers I posted above were collected across several sessions and the machine drifted about 5% between them. Re-measured back-to-back in one session,
The ratios are the same as before and every conclusion stands — the js regression is gone and native is 38% ahead — but these absolutes are the reproducible ones. I also A/B'd the refactor in commit 2 against commit 1 directly: 1.48 ms vs 1.49 ms, so moving to methods costs nothing. |
The unchecked key accessors were free functions taking the array as their
first argument, which read differently from the `FixedArray` accessors
beside them. Declare them as methods on `UninitializedArray` instead, so
every probe site reads uniformly:
self.keys.unsafe_get(idx)
self.keys.unsafe_set(idx, key)
self.keys.set_null(cur)
`builtin` already has package-private versions of all three, but they stay
private there deliberately: `unsafe_set` and `set_null` can corrupt memory
or resurrect a freed slot, so the fewer packages that can name them, the
better. These declarations are package-local to `hashset` and go no
further.
Also note at `shift_back`'s `set_null` why it is not redundant with the
`psls` write above it -- that is the one place the call looks removable,
and removing it would leave every test passing while retaining up to one
dead key per vacated slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b366863 to
8f58ca8
Compare
|
Correction to my previous comment: I have reverted the Exposing The OO style is kept without that cost: the accessors are declared as methods on fn[K] UninitializedArray::unsafe_get(self : UninitializedArray[K], idx : Int) -> K = "%fixedarray.unsafe_get"
fn[K] UninitializedArray::unsafe_set(self : UninitializedArray[K], idx : Int, key : K) -> Unit = "%fixedarray.unsafe_set"
fn[K] UninitializedArray::set_null(self : UninitializedArray[K], idx : Int) -> Unit = "%fixedarray.set_null"Call sites read the same as before and the same as the On the liveness P3: I do not think a test is the right answer there. A real liveness assertion needs weak references or finalization, which is not portable across the four backends, and the leak it guards against is bounded — at most one dead key per vacated slot, released at the next self.psls[cur] = empty_psl
// Not redundant with the line above: the PSL marks the slot free, but
// the key slot would still hold the removed key alive until something
// overwrites it, retaining up to one dead key per vacated slot.
self.keys.set_null(cur)
|
|
Superseded by a split, per review: this PR bundled two independent changes and each was masking the other's cost.
Splitting them changed the conclusion. Measured against @mizchi — thank you, the layout work and the benchmark file are both carried over with authorship credit, and the split is about making each half defensible on its own evidence rather than about the work itself. The remaining question on the layout half is whether native +35% is worth js -18%, or whether it wants a |
Motivation
HashSetstored its table asFixedArray[Entry[K]?], whereEntry { psl, hash, key }is a heap-allocated struct. Every newly inserted keytherefore allocates one
Entryobject. ProfilingHashSet::add(native, TimeProfiler) attributed ~11% of the time to the per-entry malloc, on top of the
reference-counting churn (incref/decref/drop) of the boxed entries.
Change
Replace the array of boxed entries with a struct-of-arrays layout:
psls[i] == -1is the empty-slot sentinel — a real probe-sequence length isalways
>= 0, so no extra occupancy array is needed. Inserting a key now writesthree array slots with no allocation. Vacated slots (remove / shift-back / clear)
have their key
set_null-ed so it stays collectable.UninitializedArray[K]isthe same primitive already used by
@deque.Same Robin Hood algorithm, same observable behavior — only the storage layout
changes.
Benchmark
hashset/hashset_bench_test.mbt(native, n=50000):addcontainsThe
addwin exceeds the 11% the profiler attributed to malloc alone, becausethe boxed-entry reference-counting churn disappears too.
containsis on theread path and neither layout allocates, so it is unchanged.
Validation
moon test -p hashsetpasses on native, wasm-gc, wasm and js (132 each).moon checkis clean and the package.mbtiis unchanged (the struct fieldsare private, so this is a pure internal change).
🤖 Generated with Claude Code