perf(hashset): store entries as struct-of-arrays to avoid per-insert alloc - #3712
perf(hashset): store entries as struct-of-arrays to avoid per-insert alloc#3712mizchi wants to merge 4 commits into
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".
…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>
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