perf(builtin): derive Map's probe-sequence length instead of storing it - #4132
perf(builtin): derive Map's probe-sequence length instead of storing it#4132bobzhang wants to merge 1 commit into
Map's probe-sequence length instead of storing it#4132Conversation
There was a problem hiding this comment.
Pull request overview
This PR optimizes builtin’s linked_hash_map by removing stored probe-sequence-length (PSL) state from Entry and deriving PSL on demand via Map::psl_at, reducing per-entry fields while preserving the existing boxed table layout.
Changes:
- Removed
Entry.psland introducedMap::psl_at(idx, entry)to compute PSL from(idx - (hash & mask)) & mask. - Updated probe/insert/remove/grow logic (
set_with_hash,push_away,get*,shift_back,rehash_place_entry, etc.) to use derived PSL. - Updated whitebox tests/debug helpers to compute PSL via
psl_at.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| builtin/linked_hash_map.mbt | Removes stored PSL from Entry and updates Robin Hood probing logic to derive PSL via psl_at. |
| builtin/linked_hash_map_wbtest.mbt | Updates tests and debug formatting to use derived PSL instead of the removed Entry.psl field. |
Suppressed comments (2)
builtin/linked_hash_map.mbt:421
- This loop still returns
pslin the break tuple, but the binding discards it (_). Since PSL isn’t stored anymore, the tuple can be simplified to avoid carrying an unused value through the control flow.
let (idx, _, push_away) = for psl = 0, idx = hash & self.capacity_mask {
builtin/linked_hash_map.mbt:1144
Map::updatestill returnspslin the break tuple (break (idx, psl, ...)) even though the binding now discards it (let (idx, _, ...)). Since PSL is derived, this extra tuple element is dead and makes the control flow noisier; consider simplifying the tuple to removepsl(and updating the destructuring and bothbreaksites accordingly).
let (idx, _, new_value, push_away) = for psl = 0, idx = hash &
self.capacity_mask {
match self.entries.unsafe_get(idx) {
Some(entry) => {
if entry.hash == hash && entry.key == key {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| assert_true(head.prev == -1) | ||
| assert_true(head.next is None) | ||
| assert_true(head.psl == 0) | ||
| assert_true(map.psl_at(head.hash & map.capacity_mask, head) == 0) |
| Some(curr_entry) => | ||
| if psl > curr_entry.psl { | ||
| entry.psl = psl | ||
| if psl > self.psl_at(idx, curr_entry) { | ||
| let displaced_psl = self.psl_at(idx, curr_entry) + 1 | ||
| self.set_entry(entry, idx) |
| let (idx, _, new_value, push_away) = for psl = 0, idx = hash & | ||
| self.capacity_mask { |
Coverage Report for CI Build 6302Coverage increased (+0.006%) to 90.883%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
…g it
`Entry` carried a `mut psl : Int` alongside `hash`, `key` and `value`.
That field is not independent state: an entry's probe-sequence length is
the distance between the slot it occupies and the slot its hash asked
for, so
psl = (idx - (hash & capacity_mask)) & capacity_mask
Every write to it happened as the entry landed in a slot and always
equalled that displacement, and every read happens inside a probe loop
that already knows the slot index. `psl_at` computes it where it is
needed and the field is gone, leaving five fields per entry instead of
six.
`shift_back` no longer decrements anything: moving an entry one slot back
lowers its derived PSL by exactly one. `push_away` and
`rehash_place_entry` likewise stop writing PSLs and just place entries.
`get`'s probe loop hoists `capacity_mask` into a local, since deriving a
PSL reads it twice more per step than the stored field did.
I checked the premise before relying on it. Instrumenting `set_entry` and
`add_entry_to_tail` -- every path that places an entry in a slot -- to
assert `psl == (idx - (hash & mask)) & mask` on each write, the full
suite passes on native, js and wasm-gc with that assertion live, no
violation.
No layout change: the table is still `FixedArray[Entry[K, V]?]`, so
nothing here shares the tradeoffs of the struct-of-arrays experiments on
#4127 and #4131.
This is a trade, not a free win. Measured against `main`, n=50000,
interleaved:
| backend | op | change |
| ------- | -- | ------ |
| js | `set` | 6.5% faster |
| js | `set+remove` | 2.8% faster |
| js | `get` hit | unchanged |
| js | `get` miss | unchanged |
| native | `get` hit | 2.7% faster |
| native | `set` | unchanged |
| native | `get` miss | ~2% slower |
| wasm-gc | `set` | 1.2% faster |
| wasm-gc | `get` hit | 1.4% faster |
| wasm-gc | `get` miss | ~3% slower |
Insertion gains where the smaller entry matters most, and lookups that
hit are unchanged or better. Lookups that *miss* are slower on the two
backends where the stored field was a plain in-object load: a miss walks
the probe sequence to its end, so it pays the derivation on every step.
Hoisting the mask halved that cost -- it was 4.8% on native and 3.4% on
wasm-gc before -- but did not remove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c25dd99 to
1bafbaa
Compare
Codex CLI verification (
|
| Backend | Operation | Base | PR | Change |
|---|---|---|---|---|
| native | set | 2.52 ms | 2.57 ms | 2.2% slower |
| native | get hit | 0.830 ms | 0.835 ms | 0.6% slower |
| native | get miss | 0.973 ms | 1.02 ms | 4.8% slower |
| JS | set | 3.09 ms | 2.89 ms | 6.5% faster |
| JS | set+remove | 4.40 ms | 4.27 ms | 2.8% faster |
| wasm-gc | set | 2.43 ms | 2.40 ms | 1.2% faster |
| wasm-gc | get hit | 1.08 ms | 1.06 ms | 1.4% faster |
| wasm-gc | get miss | 1.16 ms | 1.20 ms | 3.4% slower |
Correctness itself looks sound: every production psl_at call uses the slot from which that entry was read; push_away preserves the Robin Hood recurrence and computing displaced PSL before the store is correct; shift_back is equivalent across wraparound; rehashing reconstructs placement correctly, and copy preserves capacity and slot indices.
Both findings were right and both are fixed.
The vacuous assertion was a real bug in my test — I passed head.hash & map.capacity_mask, the entry's ideal index, where psl_at returns 0 by construction. It now passes map.tail, the actual slot, with a comment saying why.
The miss regression I should have predicted. My own reasoning on #4126 was that misses gain most from removing per-probe work because they walk the sequence to its end — the same logic says they lose most from adding it. Hoisting capacity_mask into a local in get's probe loop (deriving a PSL reads it twice more per step than the stored field did) halves the cost, confirmed over two paired rounds: native miss goes from 4.8% to about 2.2%. It does not remove it.
So the honest position, now in the commit message: this is a trade, not a free win.
| operation | js | native | wasm-gc |
|---|---|---|---|
set |
6.5% faster | unchanged | 1.2% faster |
set+remove |
2.8% faster | unchanged | unchanged |
get hit |
unchanged | 2.7% faster | 1.4% faster |
get miss |
unchanged | ~2% slower | ~3% slower |
Whether that is worth taking depends on the workload mix — miss-heavy lookups are common in JSON parsing and contains checks. I would rather put the tradeoff in front of a maintainer than land it as though it were free.
Entrycarried amut psl : Intalongsidehash,keyandvalue. That field is not independent state — an entry's probe-sequence length is the distance between the slot it occupies and the slot its hash asked for:Every write to it happened as the entry landed in a slot and always equalled that displacement; every read happens inside a probe loop that already knows the slot index. So
psl_atcomputes it where needed and the field goes away — five fields per entry instead of six.shift_backno longer decrements anything: moving an entry one slot back lowers its derived PSL by exactly one.push_awayandrehash_place_entrylikewise stop writing PSLs and just place entries.No layout change. The table is still
FixedArray[Entry[K, V]?], so none of the struct-of-arrays tradeoffs from #4127 / #4131 apply here. Two files, net −4 lines.Checking the premise first
pslbeingmutsuggests independent state, so I verified rather than argued. Instrumentingset_entryandadd_entry_to_tail— every path that places an entry in a slot — to assertpsl == (idx - (hash & mask)) & maskon each write, the full suite passes with that assertion live on all three backends: 7544 native, 7485 js, 7544 wasm-gc, no violation.Measurements
n=50000, against
main, interleaved on one machine in a single session:setgethitgetmissset+removesetgethitgetmissset+removesetgethitgetmissInsertion gains where the smaller entry matters most; lookups are flat, which fits — the derivation costs two arithmetic operations per probe step against a field read from an object already in cache. Nothing regresses.
I have left the
Map::eachfigures out: its wasm-gc timing varied between 57 µs, 190 µs and 64 µs across runs on a path this change does not touch, so I do not trust it either way.Provenance
The derivation is Codex CLI's, proposed while explaining the js regression on #4127. Its application to the boxed layout — where it needs no layout change at all — came out of verifying that the identity held independently of struct-of-arrays.
Codex CLI verification at
xhighis running; its verdict will be posted as a comment.