Skip to content

Commit f23efb2

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. `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

File tree

0 commit comments

Comments
 (0)