Commit f23efb2
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.
`remove` and `shift_back` keep the unchecked probing that landed in
`71163e4f`. The layout work predated that commit and had rewritten those
two functions with checked access, which would have silently reverted it
for `remove`, `remove_and_check` and `retain`.
Also adds copy-isolated removal benchmarks, because the removal path had
no coverage at all -- which is exactly why the regression above went
unnoticed. `@bench.T` has no per-iteration setup hook, so a prebuilt set
is copied inside the timed closure and a companion benchmark measures the
copy alone, letting removal be read off by difference.
Measured against `main`, n=50000, interleaved in one session:
| backend | op | main | this | change |
| ------- | -- | ---- | ---- | ------ |
| native | `add` | 2.32 ms | 1.48 ms | 36% faster |
| native | `contains` | 896 us | 791 us | 12% faster |
| native | `copy` | 248 us | 157 us | 37% faster |
| native | removal | 932 us | 689 us | 26% faster |
| wasm-gc | `add` | 2.30 ms | 1.96 ms | 15% faster |
| wasm-gc | `contains` | 1.12 ms | 1.06 ms | 5% faster |
| wasm-gc | `copy` | 70 us | 235 us | 3.4x slower |
| wasm-gc | removal | 1.08 ms | 1.10 ms | unchanged |
| js | `add` | 2.49 ms | 3.00 ms | 20% slower |
| js | `contains` | 1.05 ms | 1.06 ms | unchanged |
| js | `copy` | 289 us | 968 us | 3.4x slower |
| js | removal | 981 us | 1.13 ms | 15% slower |
The pattern is allocation, not probing: native wins everywhere because
the per-entry malloc and reference-counting churn disappear, while both
garbage-collected backends lose on any operation that allocates the
table, because three backing stores cost more than one and there was no
per-entry allocation to save. `copy` shows this most starkly at 3.4x on
both, and `add` pays it through `grow`. Steady-state lookup is unchanged
everywhere.
Whether that trade is worth taking, or worth a `#cfg` split keeping the
boxed layout on the collected backends, is a judgement this commit does
not make on its own.
Co-Authored-By: mizchi <miz404@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent a8ba18b commit f23efb2
3 files changed
Lines changed: 308 additions & 126 deletions
0 commit comments