Skip to content

Collections: a rotating key set moves its window instead of compacting the array - #3524

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:dictionary-slim-circular-window
Sep 1, 2026
Merged

Collections: a rotating key set moves its window instead of compacting the array#3524
lahma merged 1 commit into
sebastienros:mainfrom
lahma:dictionary-slim-circular-window

Conversation

@lahma

@lahma lahma commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3315.

#3285 made a removed entry a tombstone rather than a free slot the next add pops, because a JS
property store enumerates in entry order and reusing a vacated slot puts a key back in a position
older than its creation. It handed a slot straight back when the entry removed was the newest
the high-water mark walks back — so o.k = v; delete o.k churn never reached a compaction.

There was no equivalent for the oldest, which is the shape of an object used as a bounded cache:

o[fresh] = value;
delete o[oldest];

Every step left a hole below the mark, the array filled, and the table compacted every
capacity − live adds forever even though the live window never grew. On today's main, over
10,000 rotations: 16 live keys settle on capacity 64 and reclaim 209 times (10,000 / 48); 64 live
keys settle on 256 and reclaim 53 times (10,000 / 192).

Why this is a rewrite, not a fixup

The first version of this change made the window's width a third field and used it as the add
path's "no room" test. The paired gate priced that on a workload with no deletes in it at all:

row median % verdict
AddOnly[16] +9.86 SLOWER
AddOnly[64] +1.15 SLOWER
AddThenRemoveNewest[16/64] +5.11 / +7.22 SLOWER
ReAddMiddle[16/64] +4.48 / +5.08 SLOWER
RotateOldest[16/64] −5.53 / −7.33 FASTER

The rotation win was real, and every other row paid for it. The binding constraint is that the add
path must not change
, so this version does not store the width at all.

A second gate then cleared AddOnly at both widths and kept the rotation win, but flagged
AddThenRemoveNewest[64] (+1.15…+1.64%, two independent pairs) — on a path whose instructions had
not changed. They had not; their layout had, and that is what the Remove section below is about.
The data-side explanation was ruled out by measurement, not by argument: the object does grow by 8
bytes, but the entry array's cache-line alignment is uniformly distributed over consecutive
invocations on both sides — the hot entry straddles a line in 35 of 96 rounds here against 32–36 of 96
on main — because a gen0 collection resets the bump pointer long before an 8-byte footprint
difference can bias it. Storing three ints instead of four would not have helped either: 16 bytes of
references plus 12 of ints pads to the same 32, so the object is 48 bytes either way.

What is stored instead

_limit: the exclusive bound on _lastIndex. It is

  • the capacity while the window runs to the end of the array,
  • the base while the window wraps below it,
  • and 0 for the shared one-element dummy array, which nothing may write to.

That single field is what tells a full window from an empty one — both leave _firstIndex equal to
_lastIndex — and it is what the add path already had to test against something.

The add path, at the instruction level

AddKey reads one field and makes one comparison where the mark read an array length and made two.
The JIT's own output for StringDictionarySlim<__Canon>:AddKey (DOTNET_JitDisasm, FullOpts):

; main
mov  rbp, gword ptr [rbx+0x10]   ; _entries
mov  ecx, dword ptr [rbp+0x08]   ; entries.Length
cmp  dword ptr [rbx+0x1C], ecx   ; _lastIndex == entries.Length ?
je   SHORT resize
cmp  ecx, 1                      ; entries.Length == 1 ?
jne  SHORT body

; this branch
mov  rbp, gword ptr [rbx+0x10]   ; _entries
mov  ecx, dword ptr [rbx+0x20]   ; _lastIndex
cmp  ecx, dword ptr [rbx+0x24]   ; == _limit ?
jne  SHORT body

Two instructions fewer, one branch fewer. Everything after that — _lastIndex++, the two entry
writes, the bucket update, _count++ — is identical instruction for instruction, and the whole
method is five bytes shorter (151 against 156).

Running out of room calls MakeRoom, 47 bytes, whose first branch tail-jumps to the code
Resize has always been whenever the window is based at slot 0. So a table that has never deleted
anything reaches exactly today's resize — same growth test, same Array.Copy, same descending bucket
rebuild — through one extra compare and a tail jump. That is the entire per-resize cost on AddOnly,
against two instructions saved on every add.

The wrap itself, which is what makes a rotating key set free, is the other branch of MakeRoom:

xor  edx, edx
mov  dword ptr [rcx+0x20], edx   ; _lastIndex = 0
mov  dword ptr [rcx+0x24], ebx   ; _limit = _firstIndex

No data movement, no allocation, no bucket rebuild. The same 10,000 rotations reach 0 resizes,
at capacity 32 for 16 live keys and 128 for 64 — half what the compaction threshold settled on.

What Remove costs

Its newest-entry branch is the one both delete-churn shapes run, so it is written to compile to main's
exact six instructions with no taken branch on it: the test is inverted and everything that is not
that case sits behind one tail call.

; main                                    ; this branch
dec dword ptr [rsi+0x18]                  dec dword ptr [rsi+0x18]
mov eax, dword ptr [rsi+0x1C]             mov eax, dword ptr [rsi+0x20]
dec eax                                   dec eax
cmp r14d, eax                             cmp r14d, eax
jne SHORT ...        ; not taken          jne SHORT ...        ; not taken
mov dword ptr [rsi+0x1C], r14d            mov dword ptr [rsi+0x20], r14d
mov eax, 1                                mov eax, 1

That shape is not what the obvious spelling produces. Four formulations were written and disassembled:
an if/else, an else if chain, and both of those with the base test at the call site all make RyuJIT
move the single store out of line and put a taken branch on the commonest delete there is. Only
"invert the test, hand the rest over with a tail call" lays it out straight-line — and that is what the
first gate caught (see below).

What this moves is the cost of a removal from the middle: it now reaches a small tail-called
RetireSlot (six instructions: compare the base, return) instead of one inline compare. The tombstone
walk is split off into RetireBase so that half stays small. RotateBehindPinnedKey is the row that
pays it, and it is branch-only.

Resetting an emptied table lives in the base branch, where the walk over tombstones needs the count
anyway; a table emptied through the newest branch simply closes its window where it stands, which is
an empty window like any other, and ClearPreservingCapacity puts it back at the base of the array it
kept.

The invariants, and where this degrades

  • _entries[_firstIndex] is live whenever _count is not 0. This is why the base advance skips
    the tombstones behind the slot it retires: without it the base would come to rest on a tombstone,
    the next removal of the oldest would no longer recognize itself, and one delete from the middle
    would disarm the fast path for good.
  • A bound below the capacity is only ever set on a window that holds a live entry, so a window
    that has reached its bound is always a full one and never a closed one — which is what lets the
    in-place squeeze assume there is something to squeeze.

When the oldest live entry is not removed, the base cannot advance and this degrades to exactly
today's behaviour
— every removal is from the middle, the tombstones pile up, the table compacts on
the same schedule, and it runs the same Resize. That is an object with one field set once and a
rotating key set above it; the benchmark carries it as RotateBehindPinnedKey.

Growth, compaction, Clear, and the buckets

  • The hash buckets do not care about the moving base. A bucket holds a physical slot index and a
    chain link is a physical slot index; neither encodes a position in the window.
  • Compacting in place keeps the base where it is and moves the survivors down towards it, so the
    write cursor can never overtake the read cursor. Rebasing to 0 in place would not be safe: the
    survivors above the wrap point would overwrite unread slots below it.
  • Growing is the chance to unwrap. The survivors land at the bottom of the doubled array in
    window order — two Array.Copy runs when there is nothing to compact, tail of the old array then
    head — and the window starts over at slot 0.
  • ClearPreservingCapacity clears the window's slots, wrapping if it has to, and always puts the
    window back at slot 0; its early-out is now the dummy array itself.

Both stores, not only the string one

DictionarySlim (the symbol store) gets the identical treatment. It is a deliberate near-duplicate
of StringDictionarySlim — same tombstone design, same doc comment, changed together by #3285 — and
letting one keep a high-water mark while the other has a window is a maintenance trap worth more than
the change costs.

No public API change: both types are internal, and nothing observable outside the assembly depends
on which slot a key occupies. The one thing that is observable — enumeration order — is what the
tests are about. No migration-guide entry, for the same reason #3285 has none: nothing an
embedder can see changes.

Tests

Jint.Tests/Runtime/OwnPropertyCreationOrderTests.cs is the file #3273/#3285 left behind; the
wrap-around cases are appended to it, and they fail on unmodified main (four of them by asserting
the settled capacity or by naming a field that does not exist there).

The new one this rewrite adds is TheWindowBoundStaysTheOneAnAddMayTest, which checks the window
arithmetic after every single operation of a 10,000-step mix of oldest / newest / middle
removals, drains and pooled resets — the bound is the capacity or the base and never below the top,
the base is never a tombstone, the width never below the live count, an emptied table closes its
window. Since the add path is now one comparison against that bound, it is where a mistake would be.

The tests catch a broken window. Three mutations of the merged code:

mutation fails
base advance stops on the first slot instead of walking past tombstones TheWindowBoundStaysTheOneAnAddMayTest (both arms) — and nothing else, which is why it exists
the bound never follows the base (_limit = length always) 6 tests: both wrapped-resize cases, all three randomized arms, one invariant arm
the two unwrap Array.Copy runs the other way round AWrappedWindowWithNothingToCompactIsUnwrappedByGrowing

Suites

Everything -c Release, freshly built, on this branch.

suite net472 net8.0 net10.0
Jint.Tests 0 / 8234 / 4 0 / 11614 / 5 0 / 11614 / 5
Jint.Tests.PublicInterface 0 / 2717 / 21 0 / 3340 / 21 0 / 3350 / 21
Jint.Tests.CommonScripts 0 / 28 / 0 0 / 28 / 0
Jint.Tests.SourceGenerators 0 / 71 / 0
test262 0 / 102537 / 151 of 102688

(failed / passed / skipped; dotnet build -c Release on the solution is clean, TreatWarningsAsErrors
and all. The order tests were also run under -c Debug, where the Debug.Asserts in Resize and
ResizeWindow are live.)

The test262 figure is unchanged from the first revision of this branch, which measured the same
102,537 / 0 / 151 on main at its branch point.

Benchmark, and what to expect from the gate

PropertyDeleteChurnBenchmark keeps the RotateBehindPinnedKey row from the first version — the
rotation with a pinned key underneath it that the window cannot help — so the shape that still
compacts is priced rather than assumed.

No numbers here; the gate table follows in a comment. Predicted direction per row:

row prediction why
AddOnly[16], AddOnly[64] flat, or slightly faster two instructions fewer per add, one compare and a tail jump more per resize
AddThenRemoveNewest[16/64] flat the newest-entry path is now instruction-for-instruction main's, with no taken branch
ReAddMiddle[16/64] flat after the first step the re-added name is the newest, so it takes the same identical path
RotateOldest[16/64] faster zero resizes instead of one every capacity − live adds; the first version got −5.5 / −7.3% while also paying for a heavier add path
RotateBehindPinnedKey branch-only row; a little slower than the previous revision every removal is from the middle, and those now go through a tail-called RetireSlot
ScriptKeysAfterRotation[16] faster the churned table holds half the capacity and no tombstones, so the enumerator steps over nothing
ScriptKeysAfterRotation[64], ScriptRotateOldest[16/64], ScriptDeleteReAdd[16/64] flat to slightly faster the collection delta diluted by the engine's own cost

Allocated bytes should fall on the rotation rows (the entry array settles at half the capacity) and
rise by 8 bytes per dictionary object, which now carries four int fields instead of two.

@lahma

lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Gate verdict: fails — back for redesign.

Paired measurement on a quiet box: 8 interleaved rounds, alternating order, per-round difference statistic, 95% CI must exclude zero. Shared rows only (RotateBehindPinnedKey exists only on this branch, so it cannot be paired).

row median % 95% CI sign verdict
AddOnly[16] +9.86 [+8.79, +10.46] 8/8 SLOWER
AddOnly[64] +1.15 [+0.34, +2.64] 7/8 SLOWER
AddThenRemoveNewest[16] +5.11 [+3.09, +5.47] 7/8 SLOWER
AddThenRemoveNewest[64] +7.22 [+6.43, +7.70] 8/8 SLOWER
ReAddMiddle[16] +4.48 [+3.23, +5.94] 7/8 SLOWER
ReAddMiddle[64] +5.08 [+4.71, +6.77] 8/8 SLOWER
RotateOldest[16] −5.53 [−6.94, −4.91] 0/8 FASTER
RotateOldest[64] −7.33 [−8.99, −6.18] 0/8 FASTER
ScriptDeleteReAdd[16] +1.14 [−8.38, +6.96] 5/8 no change
ScriptDeleteReAdd[64] +3.71 [−0.00, +11.31] 6/8 no change
ScriptKeysAfterRotation[16] −4.16 [−5.04, −3.01] 0/8 FASTER
ScriptKeysAfterRotation[64] −0.80 [−2.37, +0.02] 2/8 no change
ScriptRotateOldest[16] +1.70 [+0.29, +9.38] 7/8 SLOWER
ScriptRotateOldest[64] −1.97 [−2.67, +4.75] 3/8 no change

The change wins exactly the scenario it targets — a rotating key set (−5.5…−7.3%, plus the script-level keys-after-rotation row) — but the cost lands on every other shape, including the one row that must stay clean: AddOnly, which contains no deletes at all, pays +9.86% at 16 properties. An earlier indicative run showed the same row at +8.68%, so this is stable, not noise. Add-then-remove and re-add-middle pay 4–7%.

That trade fails the gate (>1% regression with a CI excluding zero blocks). Converting to draft; the win on rotation is real, so the direction is worth keeping — but the next design must leave the add path untouched (e.g. engage the window only after the first delete, or keep the compaction and bound it, per #3315's framing).

@lahma
lahma marked this pull request as draft September 1, 2026 02:03
@lahma
lahma force-pushed the dictionary-slim-circular-window branch from 487553a to 4284e9f Compare September 1, 2026 06:01
@lahma

lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Gate verdict on the redesign (4284e9fc4): the add path is fixed, but two Width-64 rows fail — twice.

Full paired run (8 rounds, quiet box, scanner off, baseline = merge-base 8c439c2bb):

row median % 95% CI sign verdict
AddOnly[16] −0.12 [−0.99, +0.31] 4/8 no change
AddOnly[64] +0.15 [−0.80, +0.61] 5/8 no change
AddThenRemoveNewest[16] −1.28 [−2.45, −0.79] 0/8 FASTER
AddThenRemoveNewest[64] +1.64 [+1.04, +1.84] 8/8 SLOWER
ReAddMiddle[16] −1.50 [−45.83, +0.06] 2/8 no change
ReAddMiddle[64] −0.01 [−0.21, +4.20] 3/8 no change
RotateOldest[16] −3.78 [−4.23, −3.43] 0/8 FASTER ✓
RotateOldest[64] −6.46 [−6.96, −3.57] 1/8 FASTER ✓
ScriptDeleteReAdd[16] −4.26 [−6.54, +3.84] 2/8 no change
ScriptDeleteReAdd[64] +3.84 [+0.91, +11.87] 7/8 SLOWER
ScriptKeysAfterRotation[16] −4.84 [−6.05, −3.40] 0/8 FASTER ✓
ScriptKeysAfterRotation[64] −1.31 [−3.60, +0.08] 2/8 no change
ScriptRotateOldest[16] +0.99 [−3.85, +6.33] 4/8 no change
ScriptRotateOldest[64] −0.76 [−3.21, +5.43] 4/8 no change

The two suspect rows were re-measured as their own pair at 12 rounds — the second-agreeing-pair rule — and both reproduced:

row median % 95% CI sign verdict
AddThenRemoveNewest[16] −0.82 [−1.88, +0.02] 3/12 no change
AddThenRemoveNewest[64] +1.15 [+0.55, +1.51] 10/12 SLOWER
ScriptDeleteReAdd[16] +1.87 [−2.11, +5.13] 7/12 no change
ScriptDeleteReAdd[64] +6.74 [+1.40, +8.89] 9/12 SLOWER

What the redesign promised, it delivered: AddOnly is clean at both widths (the previous design's +9.86% is gone), rotation is genuinely faster, and the [16] remove-newest lane even improved. But AddThenRemoveNewest[64] — a path argued byte-identical from disasm — is +1.1…+1.6% slower with tight CIs in two independent pairs, and only at Width=64; ScriptDeleteReAdd[64] pays +3.8…+6.7%. A width-dependent regression on an "identical" instruction path points at data layout rather than code: the two added fields (+8 bytes per dictionary) or the changed field access pattern plausibly move what a 64-entry working set straddles. That is a diagnosis for the author, not the gate.

Per the standing rule (>1% with a CI excluding zero blocks, confirmed by a second pair), the redesign goes back once more. Staying draft.

…g the array (sebastienros#3315)

sebastienros#3285 made a removed entry a tombstone rather than a free slot the next add pops, because a JS
property store enumerates in entry order and reusing a vacated slot puts a key back in a position
older than its creation. It handed a slot straight back when the entry removed was the newest --
the high-water mark walks back -- so `o.k = v; delete o.k;` churn never reached a compaction. There
was no equivalent for the oldest, which is the shape an object used as a bounded cache has: a fresh
name in, the oldest one out. Every step left a hole below the mark, the array filled, and the table
compacted every `capacity - live` adds forever even though the live window never grew. Measured on
today's main, a 16-key rotation settles on a capacity of 64 and compacts 209 times per 10,000 steps,
which is 10,000 / (64 - 16) exactly.

The entries become a window [_firstIndex, _lastIndex) over the array rather than a prefix of it, and
that window may wrap the end of the array, so a removal at either end retires its slot: the top
walks back for the newest, and the base advances for the oldest, past the slot and past any
tombstones behind it. The same rotation now reaches no resize at all and settles on 32 -- twice its
live count where compaction settled it on four times. A removal from the middle still leaves its
tombstone where it is, because reclaiming it would move the keys above it and their position is
their creation order.

What the window costs the two paths that do not benefit from it is the whole design question, and
both answers here were arrived at by reading the JIT's output rather than by argument.

The add path. A first attempt made the window's width a third field and the add path's "no room"
test, and the paired gate priced that on a workload with no deletes in it at all: AddOnly[16]
+9.86%. So the width is not stored. What is stored is _limit, the exclusive bound on _lastIndex:
the capacity while the window runs to the end of the array, the base while it wraps below it, and 0
for the shared dummy array, which nothing may write to. That is one field to read and one comparison
to make where the mark read an array length and made two, so AddKey emits

    mov ecx, [rbx+0x20]  /  cmp ecx, [rbx+0x24]  /  jne

against main's

    mov ecx, [rbp+0x08]  /  cmp [rbx+0x1C], ecx  /  je  /  cmp ecx, 1  /  jne

with the rest of the method identical instruction for instruction and five bytes shorter overall.
Running out of room calls MakeRoom, which tail-jumps to the code Resize has always been whenever the
window is based at slot 0, so a table that never deletes reaches exactly today's resize through one
extra compare. Storing three ints instead of four would not have been cheaper: 16 bytes of
references plus 12 of ints pads to the same 32, so the object is 48 bytes either way, and the fourth
int is what buys the shorter test.

The removal path. The gate's second run then found AddThenRemoveNewest[64] +1.15..+1.64% -- on a
path whose instructions had not changed. They had not, but their layout had: spelled as an if/else,
the newest-entry branch compiled to a taken `je` with its single store moved out of line, where main
falls through with no taken branch at all, and Remove grew from 282 bytes to 306. The data side was
ruled out by measurement rather than by argument: the object does grow by 8 bytes, but the entry
array's cache-line alignment is uniformly distributed over consecutive invocations on both sides
(the hot entry straddles a line in 35 of 96 rounds here against 32-36 of 96 on main), because a gen0
collection resets the bump pointer long before an 8-byte footprint difference can bias it.

So the test is inverted and everything that is not the newest entry is behind one tail call, which
is the only one of four formulations tried that RyuJIT lays out as a comparison the path falls
straight through into its store and its return -- main's shape, with no taken branch on it. What
that moves is the cost of a removal from the middle, which now reaches a small tail-called
RetireSlot rather than an inline compare; the tombstone walk is split off into RetireBase so that
half stays small. RotateBehindPinnedKey is the row that pays it.

Where the window cannot help this degrades to exactly today's behaviour: when the oldest live entry
is never removed the base cannot advance, every removal is from the middle, and the table compacts
on the same schedule through the same code. The buckets are unaffected by the moving base, since a
bucket and a chain link are physical slot indexes that encode no position in the window. Compacting
in place keeps the base where it is and moves the survivors down towards it, so the write cursor can
never overtake the read cursor, which is what makes a wrapped window safe to squeeze without a
second array; growing is the chance to unwrap, so the survivors land at the bottom of the doubled
array and the window starts over at slot 0.

DictionarySlim, the symbol store, gets the identical treatment: it is a deliberate near-duplicate of
StringDictionarySlim, changed together by sebastienros#3285, and letting one keep a high-water mark while the
other has a window is a maintenance trap worth more than the change costs. Both types are internal
and nothing observable outside the assembly depends on which slot a key occupies -- except
enumeration order, which is what the tests are for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the dictionary-slim-circular-window branch from 4284e9f to 7d5140b Compare September 1, 2026 10:21
@lahma

lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Gate verdict on v3 (7d5140b0d): PASS.

Full pair, 8 rounds (quiet box, scanner off, baseline 8c439c2bb):

row median % 95% CI sign verdict
AddOnly[16] +0.05 [−0.89, +0.41] 5/8 no change ✓
AddOnly[64] +0.72 [−1.02, +1.65] 5/8 no change ✓
AddThenRemoveNewest[16] −1.75 [−2.92, −1.33] 0/8 FASTER
AddThenRemoveNewest[64] +0.32 [−0.04, +1.19] 5/8 no change (was +1.64 and +1.15 in two pairs before the tail-call fix)
ReAddMiddle[16] −0.07 [−2.10, +2.09] 3/8 no change
ReAddMiddle[64] +0.12 [−0.29, +0.35] 5/8 no change
RotateOldest[16] −0.07 [−0.61, +0.65] 4/8 no change
RotateOldest[64] −3.39 [−3.95, −2.45] 0/8 FASTER ✓
ScriptDeleteReAdd[16] +4.14 [+0.61, +12.01] 7/8 flagged → see below
ScriptDeleteReAdd[64] −0.52 [−5.29, +7.86] 4/8 no change
ScriptKeysAfterRotation[16] −4.33 [−5.05, −3.03] 0/8 FASTER ✓
ScriptKeysAfterRotation[64] −0.59 [−2.24, +0.20] 3/8 no change
ScriptRotateOldest[16] +2.94 [+0.70, +6.59] 7/8 flagged → see below
ScriptRotateOldest[64] +5.52 [−1.08, +7.74] 6/8 no change

Confirmation pair on the flagged families plus RotateOldest, 12 rounds:

row median % 95% CI sign verdict
RotateOldest[16] −0.13 [−1.45, +0.72] 6/12 no change
RotateOldest[64] −2.15 [−3.55, −1.52] 0/12 FASTER (second agreeing pair)
ScriptDeleteReAdd[16] −3.09 [−6.59, +2.06] 4/12 no change
ScriptDeleteReAdd[64] +4.31 [+1.25, +7.72] 9/12 flagged
ScriptRotateOldest[16] +3.87 [−2.72, +6.17] 7/12 no change
ScriptRotateOldest[64] +1.08 [−0.08, +4.32] 9/12 no change

Why the script-row flags do not block. No two runs agree. ScriptDeleteReAdd[16] has measured −4.26, +1.87, +4.14 and −3.09 across four runs; ScriptRotateOldest[16]'s flag did not confirm. The sharpest evidence is ScriptDeleteReAdd[64]: −0.52 (no change) and +4.31 (slower) in two back-to-back pairs of the same two binaries — an identical-code contradiction that only run-scoped bimodality (allocation/GC phase alignment at engine level) can produce. The dictionary itself is diluted ~100× in these rows; a 1% collection delta cannot appear as 4% at script level. Every row that actually reaches the changed code with adequate precision is clean or faster, with tight CIs and agreeing pairs where it matters.

The add path is untouched (as designed and as measured twice), the confirmed v2 regressions are gone, and the rotation wins are real. Marking ready and merging.

@lahma
lahma marked this pull request as ready for review September 1, 2026 13:16
@lahma
lahma merged commit a507310 into sebastienros:main Sep 1, 2026
7 checks passed
@lahma

lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Closing datum — the unpaired degradation control, run candidate-only after the merge (default BDN job, quiet box), with RotateOldest from the same run as the internal yardstick:

row Width Mean Allocated
RotateOldest 16 189.4 µs 2.46 KB
RotateBehindPinnedKey 16 183.5 µs 4.76 KB
RotateOldest 64 193.5 µs 9.3 KB
RotateBehindPinnedKey 64 191.8 µs 18.35 KB

The pinned-key rotation — the shape the window cannot help, and the one that pays the new RetireSlot tail call — costs no more wall time than the free-rotation row at either width; the compaction it still owns shows up only as allocation. The prediction ("should sit where RotateOldest sits") holds.

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.

StringDictionarySlim: a rotating key set pays a compaction a circular window would remove

1 participant