Skip to content

perf: stop retaining ConsistentHash ring's SortedDictionary (#8293) - #8324

Merged
Aaronontheweb merged 9 commits into
akkadotnet:devfrom
orange-dot:feature/8293-consistenthash-upstream
Aug 26, 2026
Merged

Aaronontheweb merged 9 commits into
akkadotnet:devfrom
orange-dot:feature/8293-consistenthash-upstream

Conversation

@orange-dot

@orange-dot orange-dot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #8293.

What

ConsistentHash<T> kept its ring in two structures at once: the SortedDictionary<int, T> it was built from and the parallel int[] / T[] arrays it lazily materialized to back Array.BinarySearch in NodeFor. Once the arrays exist the dictionary is dead weight — a live ring is served entirely by the arrays.

This is Option A from the design in akkadotnet/akka.net#8293 (the option the issue recommends): stop retaining the SortedDictionary. The ring is now materialized into the two sorted arrays once, in the constructor, and the dictionary is dropped. ConsistentHash.Create — the incremental tree build and its 32-bit collision probing (#8031 / #8294) — is completely unchanged, so the ring is byte-identical to before.

Why this is safe (review-my-own-PR)

The ring is byte-identical in the no-collision path. Create still builds the same SortedDictionary the same way; the constructor just reads Keys/Values out of it instead of holding onto it. The executable before/after proof ConsistentHashSpec.Create_must_produce_the_legacy_ring_whenever_the_legacy_algorithm_succeeds still passes, which is what keeps rolling upgrades safe.

Readers redirected off the dictionary, all onto the arrays:

  • NodeFor already read the arrays — no change.
  • IsEmpty now reads _nodeHashRing.Length == 0 instead of _nodes.Any().
  • operator + / operator - already rebuilt via Create; they now feed it the values array (Distinct() collapses the virtualNodesFactor repeats) instead of _nodes.Values.

Public API preserved (extend-only). The ConsistentHash(SortedDictionary<int,T>, int) constructor stays; it simply materializes the arrays from the passed dictionary and no longer retains it. Akka.API.Tests approvals are unchanged.

Two bug-adjacent improvements this drops out for free

  • Removes an unsynchronized lazy init. The old _ring ??= (...) wrote a multi-word (int[], T[])? with no synchronization. ConsistentHashingRoutingLogic.Select shares one ring across threads via an AtomicReference, so two threads racing the first NodeFor on a fresh ring could observe a torn read of that nullable tuple (has-value visible before the array fields) — an NRE on weak memory models (ARM64). Materializing the arrays in the constructor as readonly fields removes the race.
  • Removes a per-message allocation on net48 / netstandard2.0. IsEmpty ran _nodes.Any(), and NodeFor calls IsEmpty on every routed message; Enumerable.Any() boxes an enumerator each call. _nodeHashRing.Length == 0 is a field read.

Footprint (the point of the change)

Retained heap of a live ring (factor = 10), measured by rooting rings and reading GC.GetTotalMemory(true) before/after — the steady-state cost a router pays for the ring's whole lifetime:

routees × factor ring points retained/ring — before retained/ring — after saved
2000 × 10 20,000 1.30 MB 0.23 MB 1.07 MB (≈82%)
5000 × 10 50,000 3.24 MB 0.57 MB 2.67 MB (≈82%)

(Each figure is the mean over 20 rooted rings, isolated per process, GC.GetTotalMemory(true) before/after; i7-4600U, .NET 10, workstation GC. The "after" figures match the bare array cost — int[] + T[] — confirming nothing else is retained. Lines up with the issue's estimate of ~1.35 MB → ~0.24 MB and ~3.4 MB → ~0.6 MB.)

BenchmarkDotNet, ConsistentHashCreateBenchmarks (Create runs once per membership change, off the message path; NodeFor is the per-message hot path, included to confirm no regression):

NodeFor (per-message hot path), before → after — flat within noise, no regression:

routees × factor NodeFor before NodeFor after
10 × 10 60.96 ns 62.01 ns
100 × 10 87.72 ns 88.35 ns
1000 × 10 113.34 ns 108.64 ns
5000 × 10 131.97 ns 131.71 ns

Create (once per membership change, off the message path), before → after:

routees × factor Create before Create after Allocated before Allocated after
10 × 10 9.33 µs 12.27 µs 7,088 B 8,864 B
100 × 10 254.2 µs 278.1 µs 66,624 B 79,168 B
1000 × 10 3.68 ms 3.85 ms 661,600 B 782,272 B
5000 × 10 22.75 ms 28.60 ms 3,263,074 B 3,864,033 B

Both Create Mean and Allocated rise. The lookup arrays used to be built lazily on the first NodeFor; the Create benchmark returns a ring it never looks up in, so before this change it never paid for materializing them. They are now built eagerly in the constructor, so that ToArray cost (both the CPU and the ~600 KB at 50k points — note the Allocated delta matches the array size) moves into Create. In production the arrays were always going to be built on the first lookup anyway, so the total Create + first-lookup work is essentially unchanged — only its timing shifts earlier, onto a path that runs once per membership change. BDN's per-call Allocated cannot see the retained-footprint win (the table above), which is the whole point of the change — hence the separate measurement. (Numbers on a 2-core i7-4600U under load)

Behavioral note

Because the ring is now snapshotted in the constructor, mutating the SortedDictionary after construction no longer affects the instance. Before this change the aliasing was inconsistent anyway — IsEmpty and the operators read the dictionary live, while NodeFor froze it after the first lookup. A null dictionary now throws ArgumentNullException from the constructor instead of surfacing later as an NullReferenceException. ConsistentHash.Create already builds the dictionary fully before constructing, so no routing code is affected. Recorded in BREAKING_CHANGES_V1.6.md.

Tests

Existing ConsistentHashSpec (collision handling, ring identity, legacy-ring equality proof) is unchanged and green. Added:

  • Constructor_must_snapshot_the_dictionary_and_not_retain_it — pins the non-retention: build a dictionary, construct, clear the dictionary, assert the ring still routes and is unchanged.
  • Constructor_must_throw_ArgumentNullException_for_a_null_dictionary — pins the new null guard.

#nullable enable was added to both touched files per the contributor guidelines.

…et#8293)

ConsistentHash<T> kept the ring in both a SortedDictionary and the parallel
int[]/T[] arrays that back NodeFor's binary search. Once the arrays exist the
dictionary is dead weight. Materialize the arrays once in the constructor and
drop the dictionary (Option A from akkadotnet#8293); ConsistentHash.Create and its 32-bit
collision handling are unchanged, so the ring stays byte-identical.

Reclaims the retained SortedDictionary (~1.07 MB at 20k ring points, ~2.67 MB
at 50k) for a long-lived ring. Also removes an unsynchronized lazy array init
(a torn read of the nullable tuple under concurrent Select) and a per-message
enumerator allocation in IsEmpty (_nodes.Any()) on net48/netstandard2.0.

Adds constructor snapshot and null-guard tests; records the snapshot behavior
change in BREAKING_CHANGES_V1.6.md. Enables #nullable on both touched files.
@orange-dot
orange-dot force-pushed the feature/8293-consistenthash-upstream branch from c0c9285 to b7ebe0c Compare July 5, 2026 16:16

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

// </copyright>
//-----------------------------------------------------------------------

#nullable enable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@Aaronontheweb
Aaronontheweb enabled auto-merge (squash) August 26, 2026 21:47
@Aaronontheweb
Aaronontheweb merged commit 60a01d0 into akkadotnet:dev Aug 26, 2026
13 of 15 checks passed
Aaronontheweb added a commit to Aaronontheweb/akka.net that referenced this pull request Oct 2, 2026
…et#8293) (akkadotnet#8324)

ConsistentHash<T> kept the ring in both a SortedDictionary and the parallel
int[]/T[] arrays that back NodeFor's binary search. Once the arrays exist the
dictionary is dead weight. Materialize the arrays once in the constructor and
drop the dictionary (Option A from akkadotnet#8293); ConsistentHash.Create and its 32-bit
collision handling are unchanged, so the ring stays byte-identical.

Reclaims the retained SortedDictionary (~1.07 MB at 20k ring points, ~2.67 MB
at 50k) for a long-lived ring. Also removes an unsynchronized lazy array init
(a torn read of the nullable tuple under concurrent Select) and a per-message
enumerator allocation in IsEmpty (_nodes.Any()) on net48/netstandard2.0.

Adds constructor snapshot and null-guard tests; records the snapshot behavior
change in BREAKING_CHANGES_V1.6.md. Enables #nullable on both touched files.

Co-authored-by: Aaron Stannard <aaron@petabridge.com>
(cherry picked from commit 60a01d0)
Aaronontheweb added a commit to Aaronontheweb/akka.net that referenced this pull request Oct 3, 2026
…et#8293) (akkadotnet#8324)

ConsistentHash<T> kept the ring in both a SortedDictionary and the parallel
int[]/T[] arrays that back NodeFor's binary search. Once the arrays exist the
dictionary is dead weight. Materialize the arrays once in the constructor and
drop the dictionary (Option A from akkadotnet#8293); ConsistentHash.Create and its 32-bit
collision handling are unchanged, so the ring stays byte-identical.

Reclaims the retained SortedDictionary (~1.07 MB at 20k ring points, ~2.67 MB
at 50k) for a long-lived ring. Also removes an unsynchronized lazy array init
(a torn read of the nullable tuple under concurrent Select) and a per-message
enumerator allocation in IsEmpty (_nodes.Any()) on net48/netstandard2.0.

Adds constructor snapshot and null-guard tests; records the snapshot behavior
change in BREAKING_CHANGES_V1.6.md. Enables #nullable on both touched files.

Co-authored-by: Aaron Stannard <aaron@petabridge.com>
(cherry picked from commit 60a01d0)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Perf: stop retaining the ConsistentHash ring's SortedDictionary (optionally build straight into sorted arrays)

2 participants