Skip to content

Optimize flat-state snapshot compaction and harden SortedMergeDictionary builds - #12864

Open
benaadams wants to merge 14 commits into
masterfrom
sortedmergedictionary
Open

Optimize flat-state snapshot compaction and harden SortedMergeDictionary builds#12864
benaadams wants to merge 14 commits into
masterfrom
sortedmergedictionary

Conversation

@benaadams

Copy link
Copy Markdown
Member

Changes

SortedMergeDictionary is the pooled sorted-array-plus-hash-bucket structure that flat-state snapshot compaction (SnapshotCompactor) builds from snapshot sources and merges into compacted snapshots. This PR removes measured dead work and per-entry overhead from the build and merge paths, and turns silent failure modes into loud ones.

Correctness and hardening

  • Builds fail closed: _count is zeroed at the start of every build, so a throwing comparer, keep filter, or source enumerator leaves the dictionary empty and reusable instead of in a mixed-generation state where lookups could return stale keys. This also fixes a pre-existing lookup hang when a torn unsorted build left Next = 0 self-loops.
  • TryGetValue chain walks are capped at the logical count. A valid chain descends, so the cap never binds on settled state; a reader racing a rebuild (in violation of the quiescent-source contract) now misses instead of spinning forever on a swapped-out, pool-cleared entry array.
  • Sources that yield more or fewer items than their reported Count now throw instead of being silently truncated or padded with unwritten sorted entries. Snapshot compaction requires quiescent sources; a mismatch means that contract was broken, and for consensus-relevant flat state a loud compaction abort is strictly better than silently dropping entries. Exception construction sits in [DoesNotReturn, StackTraceHidden] helpers off the hot path.

Build and merge performance

  • Salted bucket stamps replace per-build bucket clearing: bucket slots store entryIndex + 1 + salt and the salt advances every build, so slots written by earlier builds decode out of range and read as empty. This removes the O(bucketCount) Array.Clear previously paid twice per pooled reuse cycle. Pooled Entry arrays now clear exactly the written prefix (_entriesDirty) on return and skip clearing on rent.
  • Merge transients are built as bucketless sorted runs: MergeInto consumes them only through entries and count, so bucket stamping, bucket-array rent/clear, and salt advancement were dead work on that path. The internal Run view exposes no TryGetValue by construction, so a bucketless transient cannot be queried by accident; its pooled owner still clears reference-bearing entries before returning the buffer.
  • Small merges take fast paths: a sole non-empty run is copied directly, and two runs merge with two cursors, preserving source priority and keep filtering without constructing a loser tree. The compaction schedule is a ruler sequence, so k=2 is the modal merge size in production, not an edge case.
  • The loser tree's two new int[k] scratch arrays become one adaptive buffer: a fixed 256-int [SkipLocalsInit] stack buffer through 128 sources and a single pooled buffer above that, with the threshold set by the measured crossover.
  • Bucket sizing switches from the floating-point count / 0.7 to count * 10L / 7 before power-of-two rounding. An exhaustive sweep over all 2^31 non-negative counts found zero bucket-size mismatches with the previous double expression; the constant division lowers to multiply-shift, and the long arithmetic avoids int overflow above roughly 214M entries.
  • The entry dirty watermark is lowered to the written count after a successful filtered merge, so heavily filtered merges stop re-clearing never-written tail entries on every reset/dispose cycle. Abort cleanup keeps the pre-raised watermark, so a throwing build remains fully clearable.
  • Storage clear boundaries are cached per key group: storage and storage-node merges emit keys grouped by owning address/hash, so a generic stateful keep policy (StorageBoundaryKeep<TGroup, TSecondary>) probes the self-destruct boundary dictionary once per contiguous group instead of once per merged entry. The existing Func-based keep path remains for compatibility and tests.

Measured results

All benchmarks: BenchmarkDotNet 0.15.8 ShortRun, .NET 10.0.11 (SDK 10.0.400), Windows 11, Ryzen 9 9950X.

Workload Result
End-to-end compaction, k=2 (modal size), full sorted payload, 16 self-destructs Merge base 7.151 ms, intermediate branch points 7.615 / 7.930 ms, final 3.334 ms: a 2.1-2.4x end-to-end improvement
Same payload, k=32 Base 254.35 ms vs final 262.57 ms, with 16-48 ms standard deviations on the optimized points: noise-bound, no win or regression claimed
Transient build, 1M entries Run-only build ~1.3x faster; combined build+merge 1.10-1.13x faster
Loser-tree scratch (stack / pool / fresh ns) k=64: 9.53 / 27.04 / 41.20; k=128: 16.98 / 26.25 / 61.97; k=256: 34.40 / 28.50 / 120.52; k=2048: 279.70 / 98.29 / 1,196.45. Fresh allocates 560-16,432 B; the crossover between k=128 and k=256 sets the stack/pool threshold
Cached boundary keep vs pooled bit-mask pre-pass, production-scale 2048 sources, reduced payload Cached 79.43 / 81.80 ms vs mask 86.32 / 85.18 ms across paired runs: 4-8% faster with identical managed allocation (577.56 KB/op). At 32 sources with the full payload the mask was 5.3% faster in a single run; the production-scale result selected the cached policy

Code generation was verified with disassembly: TryGetValue compiles to 135 bytes FullOpts with the hop cap as three register-only instructions (dec eax / test eax,eax / jle), entries.Length cached in a register, and a single range check covering only the bucket index. The integer bucket sizing contains no floating-point operations (lea, magic-constant multiply/shift, lzcnt/shrx).

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

New tests cover: stale-key resurrection across pooled reuse cycles, rebuild-without-clear, torn builds from a throwing comparer/source enumerator/keep filter (dictionary left empty and reusable), Count/enumeration mismatch in both directions, salt-overflow reset, full-length single-bucket chains at the hop-cap boundary, one- and two-source fast-path semantics (duplicate-key priority, fully filtered keys, empty sources, original source indexes), and randomized merge equivalence that also exercises the pooled loser-tree path at 256 sources.

Focused runs pass 110/110 (SortedMergeDictionaryTests + SnapshotCompactorTests). The full Nethermind.State.Flat.Test project passes 971, skips 10, and fails 14; every failure is the pre-existing Windows teardown file-lock issue on arena files (ArenaManager/StorageLayer/LongFinality), unrelated to this change. dotnet format whitespace reports no changes.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Remarks

Invariants preserved by every change: key-sorted enumeration order (persistence depends on it), keep-filter semantics (highest-index source wins on equal keys; fully filtered keys omitted), pool hygiene (returned arrays are all-default), and torn-build atomicity.

The k=32 comparison is reported as noise-bound rather than claimed as a win. Further candidates (64-bit sort key prefixes, batched equal-key handling in the loser tree, SoA entry layout) are deliberately left out: they are unmeasured or change Entry layout, and none block this change.

…amps

Bucket slots now store entryIndex + 1 + salt; the salt grows every build,
so slots from earlier builds decode out of range and read as empty. This
removes the O(bucketCount) Array.Clear previously paid twice per pooled
reuse cycle (NoResizeClear + BuildBuckets). Bucket arrays are zeroed only
on rent ([0, size) plus a cleared watermark, as Rent only guarantees
length >= size) and on salt overflow.

Pooled Entry arrays are kept all-default: the Entry type is private to
the class, so its pool only sees this class's returns, which clear
exactly the written prefix (_entriesDirty); rents skip clearing entirely
(previously full-capacity clears on both rent and return).

TryGetValue caches _entries and bounds the chain walk on entries.Length,
removing the per-hop field reload and bounds check (JitAsm-verified);
the BuildBuckets fill loop gets the same span-based shape.

Add regression tests for stale-key resurrection across reuse cycles and
rebuild-without-clear.
Zero _count at the start of both build methods so a build that throws
(comparer or keep delegate) leaves the dictionary empty instead of a
mixed-generation state where lookups could return stale keys or cycle;
this also fixes a pre-existing hang when a torn unsorted build left
Next = 0 self-loops.

Cap the TryGetValue chain walk at count hops. A valid chain descends,
so the cap never binds on settled state (the descending-chain property
is now load-bearing for lookup results, not just liveness); a reader
that races a rebuild in violation of the lease now misses instead of
spinning forever on a swapped-out, pool-cleared entry array.

Clamp build loops to the counted capacity. ConcurrentDictionary-backed
snapshot sources can yield more or fewer items than Count if mutated;
other source types require the same quiescence lease and may instead
fail through enumerator invalidation. More items would write live refs
past the _entriesDirty watermark and return them to the pool uncleared;
fewer would sort unwritten default entries into the build.

Add tests for the throwing-keep torn build, Count/enumeration
mismatches in both directions, and full-length single-bucket chains
(hop-cap boundary).
Snapshot compaction requires quiescent sources. Throw on both over- and under-yield instead of silently truncating or sorting unwritten entries, aligning ConcurrentDictionary-backed inputs with the fail-loud behavior of the plain Dictionary sources.

Keep the exception construction and messages in StackTraceHidden, DoesNotReturn helpers so the build hot path carries neither throw construction nor cold string loads.
Replace the floating-point 0.7 load-factor calculation with count * 10L / 7 before power-of-two rounding. An exhaustive sweep over all 2^31 non-negative counts found zero bucket-size mismatches with the previous double expression. The proposed ceil form diverged at 23 counts (first at count=1), while long arithmetic also avoids int overflow above roughly 214 million entries.

The constant integer division lowers to multiply-shift instead of the previous floating-point conversion and divide sequence.
Mutable snapshot inputs are sorted into owned Run buffers without building lookup buckets that MergeInto never reads. A Run exposes only entries and count, so a bucketless transient cannot accidentally be queried; its owner still clears reference-bearing entries before returning the buffer.

The pre-implementation benchmark at c5ab148da7 measured the run-only build about 1.3x faster for a 1 million-entry transient and the combined build-plus-merge path about 1.10-1.13x faster. Bucket stamping, bucket-array rent/clear work, and salt advancement were therefore removed from this transient-only path.
Copy the sole non-empty run directly and merge two runs with two cursors, preserving source priority and keep filtering without constructing a loser tree. The compaction schedule is a ruler sequence, so k=2 is the modal compaction size.

The focused pre-implementation benchmark measured the two-cursor k=2 path about 2.6x faster, with k=1 near-free and k>=8 neutral.

A final BenchmarkDotNet 0.15.8 ShortRun cross-check on .NET 10.0.11 (SDK 10.0.400), Windows 11, Ryzen 9 9950X used the full sorted payload with 16 self-destructs. At k=2, merge base / salt-only / hardening / final measured 7.151 / 7.615 / 7.930 / 3.334 ms, a 2.1-2.4x end-to-end final-branch improvement. At k=32 the same points were 254.35 / 329.10 / 314.85 / 262.57 ms, but 16-48 ms standard deviations on the first three optimized points make that comparison noise-bound; it supports no k=32 win or regression claim.
BenchmarkDotNet 0.15.8 ShortRun on .NET 10.0.11, Windows 11, Ryzen 9 9950X. Each method included the required position clear and tree sentinel initialization. Fresh arrays allocated two int[k] arrays; the pool benchmark rented and returned two arrays, making its result conservative versus the single combined pooled buffer used by the implementation.

Representative means:

- k=64: stack 9.53 ns, pool 27.04 ns, fresh 41.20 ns / 560 B
- k=128: stack 16.98 ns, pool 26.25 ns, fresh 61.97 ns / 1,072 B
- k=256: stack 34.40 ns, pool 28.50 ns, fresh 120.52 ns / 2,096 B
- k=2048: stack 279.70 ns, pool 98.29 ns, fresh 1,196.45 ns / 16,432 B

The measured crossover is between k=128 and k=256, so use a fixed 256-int stack buffer through 128 sources and one ArrayPool buffer above that threshold. SkipLocalsInit avoids clearing the fixed stack buffer; LoserTree initializes the tree and position slices it consumes.
Replace the per-entry boundary dictionary closures with a generic, stateful keep policy. Storage and storage-node keys are ordered by their owning group, so each merge now probes the boundary dictionary once per contiguous group while retaining the existing Func path for compatibility.

BenchmarkDotNet 0.15.8 ShortRun on .NET 10.0.11 (SDK 10.0.400), Windows 11, Ryzen 9 9950X compared this cached policy with a pooled uint bit-mask pre-pass that also cached group boundaries while scanning each source.

At the full benchmark payload with 32 sorted snapshots and 16 self-destructs, cached measured 231.10 ms and the mask 218.87 ms (one run; mask 5.3% faster). At the production-scale source count with a reduced payload (2048 sorted snapshots; 16 accounts, 4 contracts x 8 slots, 32 state nodes, and 4 x 32 storage nodes per snapshot), the result reversed and replicated: cached 79.43/81.80 ms versus mask 86.32/85.18 ms, making cached 4.0-8.0% faster per paired run. Managed allocation was identical (577.56 KB/op). The large-k bandwidth gate therefore selects the zero-mask cached policy.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 9m 55s —— View job


Review: Optimize flat-state snapshot compaction and harden SortedMergeDictionary builds

  • Read the diff (SortedMergeDictionary.cs, SnapshotCompactor.cs, tests)
  • Analyze correctness / edge cases
  • Analyze performance & repo-rule adherence
  • Post review

Verdict: no Critical/High/Medium findings. Five Low items, none blocking. This is careful work — the invariants that matter are held, and the tests go after the right failure modes rather than the easy ones.


What I verified (rather than took on trust)

Salted bucket stamps — the scheme is airtight. Walked the arithmetic case by case:

  • A stale slot from build k decodes to i_k + salt_k − salt_c − 1. Since salt_c ≥ salt_k + count_k and i_k < count_k, that's always < 0. The invariant holds because the salt advances by exactly count, which is easy to break later — worth guarding in a comment if it isn't already clear enough.
  • No overflow anywhere: salt ≤ int.MaxValue − count is enforced before stamping, so i + 1 + salt ≤ int.MaxValue and _bucketSalt = salt + count can't wrap.
  • The reset path (SortedMergeDictionary.cs:407-412) clears the full _bucketsCleared prefix, and size ≤ _bucketsCleared is guaranteed by the clear immediately above it.
  • The grow path can't alias: Rent happens before Return(old), so the new array is never the old one. Region [_bucketsCleared, Length) is never touched.

_entriesDirty watermark tracking — correct, including the lowering. The subtle part is _entriesDirty = Math.Max(dirtyBefore, count) at :245. Confirmed the region [count, total) is genuinely never written by any of the three merge paths, and [dirtyBefore, total) was pool-default going in, so lowering doesn't strand non-default entries. Also confirmed the pool invariant is inductive: every Return site (Dispose, EnsureEntryCapacity grow, PooledRun.Dispose, BuildRunFromUnsorted catch) clears at least the written prefix.

Nice detail in the tests: FilteredMerge_TightensDirtyMarkWithoutWeakeningAbortCleanup uses (200, clear) and (10, noclear) specifically so both cases land on dirtyBefore == 0 (the 10-entry case grows out of the 16-slot bucket). That's not accidental, and it means the (200, noclear) case — where the watermark correctly stays at 200 — is the one path not asserted. Might be worth an explicit case.

Fast-path merge semantics match the loser tree. Checked equal-key priority (MergeTwoRuns prefers second, matching "highest index wins"), fully-filtered-key omission, empty-source handling, and that CopySingleRun receives the original index (nonEmptyIndex), not a compacted one. keep is invoked in ascending source order on all three paths, so a stateful policy sees a consistent call order.

StorageBoundaryKeep is correct for any call order, not just grouped input — it re-probes whenever the group differs from the memoized one. Contiguity from StorageKeyComparer/StorageNodeKeyComparer (compare Item1 first) is what makes it fast, not what makes it correct. No defensive-copy hazard: BuildFromMerge takes TKeep by value and passes it on by ref, so the mutable struct's state lives in exactly one place per merge.

Hop cap doesn't bind on settled state. Next strictly descends within a build, so a chain of length count needs exactly count iterations — the cap admits it and the new SingleBucketChain_WalksItsFullLength test pins the boundary at 400.

BucketSize overflow. The count * 10L / 7 form does avoid the int overflow above ~214M that the PR claims. The (int)RoundUpToPowerOf2(...) return still degenerates for counts near int.MaxValue, but that was equally true of count / 0.7 and the entry array can't get there — no regression.


Findings (all Low)

# Finding Where
1 Pooled loser-tree scratch isn't returned when the comparer or keep filter throws — Return is on the normal path, not in a finally SortedMergeDictionary.cs:227-240
2 _count is published before BuildBuckets(), so an OOM inside BuildBuckets leaves a non-zero count against previous-build bias/stamps — the one gap in the fail-closed invariant SortedMergeDictionary.cs:243-245
3 Run's distinct-key precondition is now load-bearing (the k≤2 paths don't dedupe within a run; the loser tree did) but undocumented SortedMergeDictionary.cs:47-51
4 StorageBoundaryKeep missing the <remarks> that explains why the memoization is safe SnapshotCompactor.cs:228
5 Pre-existing task-join race in CompactSnapshotBundle, described below SnapshotCompactor.cs:162-170

#5 in detail (GitHub wouldn't take an inline comment — those lines aren't in the diff): if content.SortedSelfDestructs.BuildFromUnsorted(...) on line 162 throws, Task.WaitAll is never reached, so the catch returns content to the resource pool and the using PooledDictionary declarations dispose slotClearBoundary/nodeClearBoundary — all while the four Task.Run merges are still writing into content and still calling boundaries.TryGetValue through StorageBoundaryKeep. Another thread can then GetSortedSnapshotContent and receive an object under concurrent mutation.

This predates the PR (the old slotKeep/nodeKeep lambdas captured the same dictionaries), and the InvalidOperationExceptions this PR adds aren't reachable from selfDestructMerged — a PooledDictionary's Count always matches its enumeration — so the trigger stays OOM-class. Raising it because the method is being touched and the fix is a one-liner: move Task.WaitAll(compactTask.AsSpan()) into a finally so the tasks are always joined before anything they touch is released.


Smaller notes, no action needed

  • Entry widened from private to internal. That makes ArrayPool<SortedMergeDictionary<K,V>.Entry>.Shared rentable from anywhere in Nethermind.State.Flat, so the "returned arrays are all-default" convention now has a wider blast radius than when Entry was private. The class <remarks> does call this out, which is the right mitigation.
  • The public BuildFromMerge(ReadOnlySpan<SortedMergeDictionary<…>>, …) overload allocates new Run[sources.Length] per call. Only tests and the Merge helper reach it now — production goes through the Run overload — so it's off the hot path.
  • Setting _count = 0 at the top of the public overload means a self-merge (this among sources) now silently sees itself as empty rather than producing garbage. Neither was ever supported; just noting the behavior changed.
  • The safety argument for TryGetValue now spans two fields that must agree (_buckets contents ↔ _bucketBias), where the old 1-based encoding was self-describing. Under a torn read the Key.Equals check still prevents returning a wrong value for a key, so the worst case is a stale value or a miss — same class as before, and the quiescent-source contract covers it. The comment at :97-99 documents this well.

Repo-rule adherence

Clean. No LINQ on the hot paths, no var, is null throughout, [DoesNotReturn, StackTraceHidden] throw helpers keep the exception construction off the hot path, no #region, comments explain why. The IMergeKeep<TKey> generic-struct approach adds an internal interface but no public surface, and it's the mechanism that gets the keep filter devirtualized — a reasonable trade against the "don't add interfaces" guidance. MergeInto's Func-based overload preserving the old call shape keeps the diff additive.

What I could not do

Build and test execution were not permitted in this environment (dotnet build requires approval), so I did not independently reproduce the 110/110 focused run or dotnet format whitespace. All findings above are from static analysis. Adding Bash(dotnet build:*) and Bash(dotnet test:*) to --allowedTools would let me verify next time.
· branch sortedmergedictionary

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 pull request optimizes the flat-state snapshot compaction merge pipeline by reducing per-build bucket clearing work, adding fast paths for common merge sizes, and hardening SortedMergeDictionary against torn/failed builds (fail-closed behavior and bounded lookup walks).

Changes:

  • Introduces Run/PooledRun views and typed keep policies (IMergeKeep) to avoid unnecessary bucket work for transient merge inputs and reduce delegate overhead.
  • Reworks bucket handling to use salted stamps (no per-build Array.Clear of buckets) and adds hop-capped TryGetValue to prevent infinite loops on torn state.
  • Updates snapshot compaction to use a cached boundary keep policy for storage/storage-node merges and expands tests to cover new failure/fast-path behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/Nethermind/Nethermind.State.Flat/SnapshotCompactor.cs Switches merge keep logic to typed keep policies and uses bucketless transient runs for mutable snapshot sources.
src/Nethermind/Nethermind.State.Flat/Collections/SortedMergeDictionary.cs Adds run abstractions, salted bucket stamps, fast merge paths, hop-capped lookups, and new hardening checks for build inputs.
src/Nethermind/Nethermind.State.Flat.Test/Collections/SortedMergeDictionaryTests.cs Adds/updates tests for reuse cycles, torn builds, count mismatches, hop-cap boundaries, and run-vs-dictionary merge equivalence.
Suppressed comments (1)

src/Nethermind/Nethermind.State.Flat/Collections/SortedMergeDictionary.cs:364

  • When renting a new Entry[] from ArrayPool in EnsureEntryCapacity, the array is not cleared. For reference-containing TKey/TValue instantiations this can retain foreign object references in the rented buffer (especially beyond the written prefix) for the lifetime of the dictionary, and it also breaks the class remark that pooled entry arrays are returned all-default. Clearing the rented array (conditionally on IsReferenceOrContainsReferences) when the backing buffer changes restores the invariant and avoids unintended memory retention.
        if (entries.Length < count)
        {
            _entries = ArrayPool<Entry>.Shared.Rent(count);
            if (entries.Length > 0)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Nethermind/Nethermind.State.Flat/Collections/SortedMergeDictionary.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat/Collections/SortedMergeDictionary.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotCompactor.cs
@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-sortedmergedictionary-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 909.48 929.12 -2.11%
MEDIAN (ms) 874.1 887.2 -1.48%
P90 (ms) 1073.6 1109.1 -3.20%
P95 (ms) 1156.4 1221.9 -5.36%
P99 (ms) 3016.2 3063.7 -1.55%
MIN (ms) 595.7 617.9 -3.59%
MAX (ms) 3016.2 3063.7 -1.55%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1571.32 1718.78 -8.58%
MEDIAN (ms) 1075.53 1180.59 -8.90%
P90 (ms) 3413.52 3787.31 -9.87%
P95 (ms) 3584.12 4209.34 -14.85%
P99 (ms) 3988.01 4522.44 -11.82%
MIN (ms) 698.56 708.62 -1.42%
MAX (ms) 5074.78 5315.79 -4.53%

realblocks

Scenario: nethermind-flat-realblocks-sortedmergedictionary-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 21.78 21.69 +0.41%
MEDIAN (ms) 18.9 18.8 +0.53%
P90 (ms) 37.0 36.1 +2.49%
P95 (ms) 42.8 43.1 -0.70%
P99 (ms) 64.8 64.1 +1.09%
MIN (ms) 0.2 0.3 -33.33%
MAX (ms) 190.1 196.6 -3.31%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 25.47 26.57 -4.14%
MEDIAN (ms) 22.22 22.24 -0.09%
P90 (ms) 40.56 41.14 -1.41%
P95 (ms) 47.00 48.85 -3.79%
P99 (ms) 75.51 85.33 -11.51%
MIN (ms) 0.63 1.20 -47.50%
MAX (ms) 386.65 431.27 -10.35%

fusaka

Scenario: nethermind-flat-fusaka-sortedmergedictionary-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 30.67 31.27 -1.92%
MEDIAN (ms) 28.2 28.2 +0.00%
P90 (ms) 47.9 48.5 -1.24%
P95 (ms) 57.1 58.2 -1.89%
P99 (ms) 85.9 84.4 +1.78%
MIN (ms) 4.4 4.3 +2.33%
MAX (ms) 325.9 334.5 -2.57%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 36.63 39.15 -6.44%
MEDIAN (ms) 33.62 33.80 -0.53%
P90 (ms) 56.93 58.06 -1.95%
P95 (ms) 64.40 66.85 -3.66%
P99 (ms) 97.62 111.19 -12.20%
MIN (ms) 5.64 6.21 -9.18%
MAX (ms) 378.85 600.51 -36.91%

Publish Count only after bucket construction succeeds so a failed build remains empty. Document the sorted/distinct Run precondition and cover preservation of an existing dirty watermark across successful and aborted filtered merges.

Tests: 111 SortedMergeDictionary and SnapshotCompactor tests passed.
Join every successfully scheduled merge task from a finally block before pooled snapshot content and boundary dictionaries can be returned. This prevents workers from continuing against resources that another compaction may already have reused when the main-thread build fails.

Tests: 111 SortedMergeDictionary and SnapshotCompactor tests passed.
@benaadams

Copy link
Copy Markdown
Member Author

Addressed the remaining actionable review notes in two focused commits:

  • 919d09d delays Count publication until bucket construction succeeds, documents the sorted/distinct Run invariant, and adds the missing dirty-watermark case where an uncleared 200-entry prefix must remain tracked across both successful and aborted smaller merges.
  • 8efbef5 joins all successfully scheduled compaction tasks before pooled snapshot content or boundary dictionaries can be released on failure.

Focused SortedMergeDictionaryTests + SnapshotCompactorTests: 111/111 passed. Formatting verification is clean.

@benaadams

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 5m 3s —— View job


Re-review: Optimize flat-state snapshot compaction and harden SortedMergeDictionary builds

  • Diff the two new commits (919d09d, 8efbef5) against my prior findings
  • Verify the deferred-_count-publication fix is actually fail-closed
  • Verify the Task.WaitAll-in-finally fix (join correctness, deadlock, exception surfacing)
  • Verify the new dirty-watermark test case
  • Fresh pass over the full diff
  • Post updated review

Verdict: no Critical / High / Medium findings. Both fixes are correct; the three declined items have sound rationale. Nothing blocking.


Fix verification

919d09d_count published after BuildBuckets(count). Correct, and it closes the gap completely.

BuildBuckets no longer reads _count, so the only remaining ordering question is what state a throw leaves behind. The sole throw point is ArrayPool<int>.Shared.Rent(size) at :391 — and it's evaluated before _buckets, _bucketsCleared, _bucketSalt, _bucketBias, or _bucketMask are touched, so a failed rent leaves every bucket field consistent with the previous build while _count stays 0. Reads are gated on _count, so the dictionary is genuinely empty rather than under-reporting. The two Array.Clear calls in that method cannot throw with the arguments given (size ≤ buckets.Length, and _bucketsCleared ≥ size in the overflow-reset branch because the grow-the-cleared-prefix block runs first).

In BuildFromMerge the new order is BuildBuckets(count)_entriesDirty = Math.Max(dirtyBefore, count)_count = count. That's the right order: on a BuildBuckets throw the pre-raised watermark survives, so abort cleanup still covers everything written. Fail-closed now holds on every path.

New test case (200, false, 200, 200) — asserts the one path that was previously unpinned. Traced it: _entriesDirty is 200 going in, EnsureEntryCapacity(100) returns dirtyBefore = 200 without raising, the filter keeps 3, and Math.Max(200, 3) = 200 correctly refuses to lower the mark below the still-dirty [3, 200) region from the earlier build. The abort case then also stays at 200. The closing entries.All(entry => entry.Key is null && entry.Value is null) runs over the whole rented array, not just the watermark prefix, so under-tracking would fail the assertion rather than pass silently. That's the assertion that gives the lowering its teeth.

Run doc. The distinct-key precondition is now stated at :47. That was the load-bearing part — the k≤2 fast paths don't dedupe within a run where the loser tree did. Skipping the PooledRun/IMergeKeep docs is fine: Dispose is visibly idempotent via _entries = [], and source priority is already in the BuildFromMerge <summary>.

8efbef5 — task join in finally. Correct. Details in an inline note; short version: unwind order is WaitAllcompactTask.Dispose() → outer catch/ReturnSortedSnapshotContent → the using PooledDictionary disposals, so no worker can still be writing into content or probing slotClearBoundary/nodeClearBoundary after either is released. AsSpan() spans exactly the added tasks, and with ArrayPoolListRef<Task>(4) pre-sized there's no scheduled-but-untracked window. One informational note there about the finally masking a primary exception in the double-failure case — my read is leave it as is.


Declined items — all reasonable

  • Pooled loser-tree scratch not returned on throw. Agreed with the rationale. An int[] carries no references, so a dropped rental cannot poison pool hygiene the way an un-cleared Entry[] would; the cost is one lost reuse on an already-aborting path, against an EH region around the central merge loop. Correct call.
  • StorageBoundaryKeep <remarks>. Fair — it's a private nested type and the memoize-last-group/re-probe-on-change logic is four lines of self-evident code. The null point was theoretical to begin with.
  • Not clearing the rented Entry[] tail (Copilot's note). Your reasoning holds and I re-checked the mechanism: over-yield is detected at :495 before the offending write, and merge output is bounded by count ≤ total (the count < total guard at :237 is belt-and-braces — it can't bind, since each emitted entry consumes at least one source position). So the never-written tail stays pool-default and clearing on rent would reintroduce exactly the full-capacity clear this PR removes.

Fresh pass — nothing new of substance

Re-checked with the reordering in place: bucket-salt arithmetic (_bucketsCleared is monotonic, so it's always the max size ever stamped into the current array, and the overflow reset clears a superset of the live prefix); count == 0 early return leaving _entriesDirty = dirtyBefore correct because nothing was written; LoserTree's ctor fully initialises both [SkipLocalsInit] scratch slices (_position.Clear() plus the _tree[i] = _k seed loop) so the uninitialised-stack/dirty-rent hazard doesn't exist; Adjust's index range stays within [0, k) for the supplied spans.

Fast-path equivalence against the loser tree, all four equal-key keep combinations: both kept → second wins (matches the tree's overwrite-by-ascending-index); first only → first; second only → second; neither → key omitted. Merge_RandomizedAgainstReference actually reaches all of these at sourceCount = 2KeepRandomized filters source 1 on key % 3 == 0 and both sources on key % 5 == 0 — so the else if (keepFirst) and fully-filtered branches are covered, not just reasoned about.

One micro-nit, not worth a commit: MergeTwoRuns at :298 evaluates keep.Keep(0, …) even when keepSecond turns out true and the result goes unused. Checking source 1 first and only falling back to source 0 would save one call per duplicate key, but it inverts keep-invocation order within the key, which is a semantic change for a stateful policy for no measurable gain on a memoized probe.

What I could not do

Build and test execution are still not permitted here (dotnet build needs approval), so I did not reproduce the 111/111 focused run or dotnet format whitespace. Everything above is static analysis. Adding Bash(dotnet build:*) and Bash(dotnet test:*) to --allowedTools would let me verify next time.
· branch sortedmergedictionary

Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotCompactor.cs
Remove the catch used solely to return a direct ArrayPool<Entry>.Shared rental when transient run construction aborts. Count mismatches detected by this code still clear and return the rental before the cold throw helper; unexpected enumeration, hashing, and comparer failures unwind without an exception region and leave the array for GC reclamation.

Tests: 111 SortedMergeDictionary and SnapshotCompactor tests passed.
Record that unexpected build failures intentionally abandon a direct ArrayPool<Entry>.Shared rental, while deliberate count-mismatch failures return it before throwing. This prevents future cleanup changes from reintroducing an exception region solely for Return.

Tests: not run (comment-only change).
@benaadams

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 2m 34s —— View job


Re-review: Optimize flat-state snapshot compaction and harden SortedMergeDictionary builds

  • Diff the two new commits (a6a64cb, cebfc20) since my last pass
  • Verify the removed BuildRunFromUnsorted cleanup against pool hygiene
  • Verify the Fill / FillAndSort split preserves both call sites' semantics
  • Fresh pass over the full diff
  • Post updated review

Verdict: no Critical / High / Medium findings. The catch removal is safe, and it makes the pool contract more consistent, not less. One Low (a test-coverage gap it introduces). Nothing blocking.


Scope of the new commits

git diff 8efbef5..HEAD touches one file and two methods — BuildFromUnsorted and BuildRunFromUnsorted, plus the FillAndSortFill split. Everything else in this PR was verified across my two prior passes and is byte-identical, so I re-checked only what moved.

Fix verification

a6a64cb — catch removed from BuildRunFromUnsorted. Correct.

The invariant that actually matters is "every array handed back to ArrayPool<Entry>.Shared is all-default", not "every rental is handed back". An abandoned rental was never returned, so it cannot leak TKey/TValue references into a future renter's buffer — it dies with the GC, taking its references with it. The class <remarks> therefore still holds verbatim after this change, and the two are not in tension.

@AnkushinDaniil's observation was right about the inconsistency, and resolving it by removing the guard rather than adding a second one is the better of the two directions — it leaves one rule ("abandon on unexpected failure, return on deliberate failure") instead of two paths disagreeing.

The deliberate path still cleans up exactly:

  • over-yield returns from Fill before the offending write, so the written prefix is exactly [0, count) in both mismatch directions, and Array.Clear(entries, 0, count) covers it before Return;
  • the count != 0 gate is equivalent to the old entries.Length > 0 gate since entries is [] iff count == 0, so a source that reports Count == 0 and then yields items OverYieldeds without a spurious Return([]);
  • ThrowSourceCountMismatch being [DoesNotReturn] is load-bearing, not decorative — it's the only thing keeping control flow from falling through to run.Sort(...) on an array that was just returned to the pool. It is annotated.

BuildFromUnsorted (instance path) — abort semantics unchanged. EnsureEntryCapacity raises _entriesDirty to the requested capacity before any write (:376-379), so a mismatch throw — which now happens after the fill has written into _entries rather than mid-fill — still leaves _count == 0 with a watermark covering everything written. The span is taken after EnsureEntryCapacity, so it can't point at the pre-grow array. Existing CountEnumerationMismatch_… and ThrowingComparer_… tests continue to pin this.

cebfc20 puts the rationale exactly where the next person would otherwise re-add the exception region. Good placement.

Incidental win worth noting: Fill no longer takes TComparer, so it's one JIT instantiation per closed TKey/TValue instead of one per comparer type — the "move methods that don't depend on the type parameter" rule, applied. The per-item i == entries.Length check is unchanged, so the hot loop is identical minus the EH region.

Finding

# Severity Finding Where
1 Low The deliberate count-mismatch path on BuildRunFromUnsorted is untested — only the instance BuildFromUnsorted is covered, and the run variant now has its own copy of clear-then-return-then-throw. Its bug mode is silent (a non-default array back in the shared pool, surfacing later as a stale key in an unrelated build). SortedMergeDictionary.cs:139-152

On the pool-return convention (#12865)

For what it's worth, the reasoning in #12865 is sound and I'd rather review against it than against my earlier instinct: ArrayPool Return is a reuse optimization, so its absence on an aborting path costs one reuse, whereas Dispose on a real resource is a correctness obligation. The line that keeps this from being a blanket "never bother" is the one the rule draws — reference-bearing buffers must still be cleared when they are returned, and finally blocks that already exist for another reason should still return. Both hold in this file.

Nothing new in the fresh pass

Re-confirmed against the current HEAD: salt arithmetic and the overflow reset (:404-418) are unchanged and still can't produce a stale in-range decode; BuildBuckets(count)_count = count ordering holds on both build paths; MergeInto's finally still disposes every transient that was successfully constructed, and a BuildRunFromUnsorted that throws mid-loop simply abandons its own rental while the earlier transients are disposed normally; StorageBoundaryKeep unchanged.

What I could not do

Build and test execution are still not permitted here — dotnet build came back as requiring approval — so I did not reproduce the 111/111 focused run or dotnet format whitespace. Everything above is static analysis. Adding Bash(dotnet build:*) and Bash(dotnet test:*) to --allowedTools would let me verify next time.
· branch sortedmergedictionary

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.

3 participants