Skip to content

Commit 82a5cf2

Browse files
bobzhangmizchiclaude
committed
perf(hashset): store entries as struct-of-arrays
`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 only remaining regression is js `add`, and it is growth-bound: the layout allocates three backing stores where the boxed one allocates a single array, and js has no per-entry malloc or reference-counting churn for the layout to remove in exchange. Everything else improves on every backend. 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>
1 parent aced026 commit 82a5cf2

3 files changed

Lines changed: 317 additions & 128 deletions

File tree

0 commit comments

Comments
 (0)