Commit 82a5cf2
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
0 commit comments