Skip to content

perf(hashset): store entries as struct-of-arrays to avoid per-insert alloc - #3712

Open
mizchi wants to merge 4 commits into
moonbitlang:mainfrom
mizchi:perf/hashset-soa
Open

perf(hashset): store entries as struct-of-arrays to avoid per-insert alloc#3712
mizchi wants to merge 4 commits into
moonbitlang:mainfrom
mizchi:perf/hashset-soa

Conversation

@mizchi

@mizchi mizchi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Motivation

HashSet stored its table as FixedArray[Entry[K]?], where
Entry { psl, hash, key } is a heap-allocated struct. Every newly inserted key
therefore allocates one Entry object. Profiling HashSet::add (native, Time
Profiler) 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   : FixedArray[Int]        // probe sequence length; -1 marks an empty slot
hashes : FixedArray[Int]        // cached key hash for occupied slots
keys   : UninitializedArray[K]  // key storage for occupied slots

psls[i] == -1 is the empty-slot sentinel — a real probe-sequence length is
always >= 0, so no extra occupancy array is needed. Inserting a key now writes
three array slots with no allocation. Vacated slots (remove / shift-back / clear)
have their key set_null-ed so it stays collectable. UninitializedArray[K] is
the 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):

op before after
add 1.95 ms 1.38 ms ~29% faster
contains 657 µs 675 µs unchanged (read path does not allocate)

The add win exceeds the 11% the profiler attributed to malloc alone, because
the boxed-entry reference-counting churn disappears too. contains is on the
read path and neither layout allocates, so it is unchanged.

Validation

moon test -p hashset passes on native, wasm-gc, wasm and js (132 each).
moon check is clean and the package .mbti is unchanged (the struct fields
are private, so this is a pure internal change).

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings June 26, 2026 10:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]?] with psls: FixedArray[Int], hashes: FixedArray[Int], and keys: UninitializedArray[K] (using empty_psl = -1 as 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 in hashset/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.

Comment thread hashset/hashset.mbt
@mizchi
mizchi marked this pull request as draft June 26, 2026 10:47
@mizchi
mizchi force-pushed the perf/hashset-soa branch from 3014c7e to ff2705c Compare June 26, 2026 11:44
@mizchi

mizchi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the clear feedback: it now reuses the existing buffers and only nulls the occupied key slots (then resets their psls) instead of reallocating keys, matching the "keeps the allocated space" docstring. Tests pass on native/wasm-gc/wasm/js.

@mizchi
mizchi force-pushed the perf/hashset-soa branch 3 times, most recently from 1a4a67c to f6e67c6 Compare July 5, 2026 15:47
@mizchi
mizchi marked this pull request as ready for review July 5, 2026 16:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread hashset/hashset.mbt Outdated
@mizchi
mizchi force-pushed the perf/hashset-soa branch from f6e67c6 to 698a6d4 Compare July 6, 2026 03:19
@mizchi
mizchi force-pushed the perf/hashset-soa branch from 7958dae to 892880b Compare July 27, 2026 09:39
mizchi and others added 4 commits July 30, 2026 18:02
…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>
@mizchi
mizchi force-pushed the perf/hashset-soa branch from a4d4747 to 336f6c5 Compare July 30, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants