Skip to content

feat: add refresh-aware and immutable Sum operators - #1167

Open
giard-alexandre wants to merge 1 commit into
reactivemarbles:mainfrom
giard-alexandre:feat/sum-operator-rewrite
Open

feat: add refresh-aware and immutable Sum operators#1167
giard-alexandre wants to merge 1 commit into
reactivemarbles:mainfrom
giard-alexandre:feat/sum-operator-rewrite

Conversation

@giard-alexandre

@giard-alexandre giard-alexandre commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Note

I did a bunch of AI-assisted coding for this, especially when it comes to the tests and benchmark edits as well as creating the comparison tables.

Follow-up for previous PRs that originated from: #1031

Sum operator rewrite

Summary

The Sum operator has been rewritten to remove the intermediate ForAggregation() and Accumulate() pipeline. The public API now offers two explicit behavior and performance choices:

Operator Implementation Refresh-aware Intended use
Sum Stateful direct scan Yes Default choice; supports mutable items and recalculates projections on refresh, SHOULD be perfectly backwards compatible.
SumImmutable Stateless direct scan No Higher-throughput, lower-allocation choice when projected values cannot mutate

The existing Sum signatures are unchanged. SumImmutable adds cache and list overloads for int, long, double, decimal, and float, including nullable selectors. The IAggregateChangeSet<T> overloads remain stateless because refresh information has already been discarded at that point in the pipeline.

Implementation changes

Stateful Sum

  • Cache change sets retain the last projected value in a per-subscription dictionary keyed by TKey.
  • List change sets retain the item and its last projected value in a per-subscription ordered list.
  • Refreshes subtract the stored projection, evaluate the current item, store the new projection, and add it to the sum.
  • List moves update the internal ordering so a later indexed refresh or removal addresses the correct state.
  • Indexed and indexless list removals and replacements are supported. Indexless lookup prefers reference identity before falling back to the default equality comparer.
  • Add, add-range, replace, remove, remove-range, clear, move, and refresh changes are handled directly without materializing aggregate change sets.
  • Mutable state is created inside Observable.Defer, keeping subscriptions isolated from one another.
  • Nullable selector results continue to contribute zero when they have no value.

See SumEx.cs.

Stateless SumImmutable

  • Direct, allocation-conscious scan.
  • Evaluates selectors only for adds, updates/replacements, and removals.
  • Refresh and move changes emit the unchanged accumulated value without re-evaluating items.
  • Avoids the dictionary or ordered projection state required by refresh-aware aggregation.

See SumEx.Immutable.cs.

Tests and API approval

  • Added cache and list tests proving that mutating and refreshing an item updates Sum.
  • Added nullable refresh coverage.
  • Added a list move-then-refresh test to verify internal state ordering.
  • Added tests documenting that SumImmutable does not re-evaluate refreshed items.
  • Added all new overloads to the .NET 9 API approval baseline.
  • The focused Sum suite passes all 65 tests, and the API approval test passes.

See SumFixture.ForCache.cs, SumFixture.ForList.cs, and the API baseline.

Performance results

Methodology

  • BenchmarkDotNet 0.15.8, ShortRun job: one launch, three warmup iterations, and three measured iterations.
  • Release build on .NET 9.0.4, x64 RyuJIT, Intel Core i9-9880H, macOS Sequoia 15.7.7.
  • Cache and list workloads cover add, replace, refresh, and remove streams at 100, 500, 1,000, and 10,000 changes.
  • The legacy operator is the implementation at commit dda39067. Only its benchmark seed construction was corrected to use isolated snapshots; its operator code was not changed.
  • All three implementations used the same corrected benchmark streams and pre-operation snapshots.
  • Timing ratios are old time / compared time; values above 1.00× are faster than the old implementation and values below 1.00× are slower.
  • Allocation percentages are relative to the old implementation; negative values mean fewer allocated bytes.
  • Summary figures are geometric means across the applicable operation and count combinations.

Refresh caveat: only the new stateful Sum computes a correct changed sum after an in-place mutation followed by refresh. Legacy Sum and SumImmutable treat refresh as a no-op, so their refresh timings represent less work and are included only to quantify the contract/performance tradeoff.

Aggregate comparison

Scope New Sum speed vs old New Sum allocation vs old SumImmutable speed vs old SumImmutable allocation vs old
Cache 0.99× -14.9% 1.55× -56.8%
List 1.58× -44.8% 2.66× -69.9%
Overall 1.25× -31.5% 2.03× -64.0%

The stateful implementation is essentially time-neutral for cache workloads overall, substantially faster for list workloads, and reduces allocation in both. SumImmutable retains the strongest performance when immutable-item semantics are valid.

Cache timing

Operation Count Old New Sum SumImmutable New vs old Immutable vs old
Adds 100 9.046 μs 8.195 μs 5.580 μs 1.10× 1.62×
Replaces 100 12.234 μs 11.228 μs 8.267 μs 1.09× 1.48×
Refreshes 100 10.405 μs 11.372 μs 8.043 μs 0.91× 1.29×
Removes 100 12.723 μs 10.184 μs 7.906 μs 1.25× 1.61×
Adds 500 43.891 μs 35.102 μs 28.317 μs 1.25× 1.55×
Replaces 500 59.807 μs 53.394 μs 39.083 μs 1.12× 1.53×
Refreshes 500 52.448 μs 49.763 μs 36.078 μs 1.05× 1.45×
Removes 500 60.778 μs 49.626 μs 37.637 μs 1.22× 1.61×
Adds 1,000 90.439 μs 78.420 μs 52.410 μs 1.15× 1.73×
Replaces 1,000 121.075 μs 127.929 μs 71.743 μs 0.95× 1.69×
Refreshes 1,000 102.496 μs 134.231 μs 66.955 μs 0.76× 1.53×
Removes 1,000 112.620 μs 145.130 μs 76.440 μs 0.78× 1.47×
Adds 10,000 899.592 μs 1,259.921 μs 540.972 μs 0.71× 1.66×
Replaces 10,000 1,223.615 μs 1,354.874 μs 784.386 μs 0.90× 1.56×
Refreshes 10,000 1,111.518 μs 1,259.388 μs 704.599 μs 0.88× 1.58×
Removes 10,000 1,176.302 μs 1,276.298 μs 762.237 μs 0.92× 1.54×

Cache allocation

Operation Count Old New Sum SumImmutable New vs old Immutable vs old
Adds 100 16.97 KB 14.84 KB 7.45 KB -12.6% -56.1%
Replaces 100 17.13 KB 14.91 KB 7.52 KB -13.0% -56.1%
Refreshes 100 17.13 KB 14.91 KB 7.52 KB -13.0% -56.1%
Removes 100 17.13 KB 14.91 KB 7.52 KB -13.0% -56.1%
Adds 500 82.59 KB 69.44 KB 35.58 KB -15.9% -56.9%
Replaces 500 82.76 KB 69.51 KB 35.65 KB -16.0% -56.9%
Refreshes 500 82.76 KB 69.51 KB 35.65 KB -16.0% -56.9%
Removes 500 82.76 KB 69.51 KB 35.65 KB -16.0% -56.9%
Adds 1,000 164.63 KB 142.36 KB 70.73 KB -13.5% -57.0%
Replaces 1,000 164.79 KB 142.43 KB 70.80 KB -13.6% -57.0%
Refreshes 1,000 164.79 KB 142.43 KB 70.80 KB -13.6% -57.0%
Removes 1,000 164.79 KB 142.43 KB 70.80 KB -13.6% -57.0%
Adds 10,000 1,641.19 KB 1,361.04 KB 703.55 KB -17.1% -57.1%
Replaces 10,000 1,641.35 KB 1,361.11 KB 703.62 KB -17.1% -57.1%
Refreshes 10,000 1,641.35 KB 1,361.10 KB 703.62 KB -17.1% -57.1%
Removes 10,000 1,641.35 KB 1,361.10 KB 703.62 KB -17.1% -57.1%

List timing

Operation Count Old New Sum SumImmutable New vs old Immutable vs old
Adds 100 8.012 μs 5.305 μs 3.061 μs 1.51× 2.62×
Replaces 100 10.542 μs 6.322 μs 4.245 μs 1.67× 2.48×
Refreshes 100 9.318 μs 6.339 μs 3.485 μs 1.47× 2.67×
Removes 100 10.403 μs 5.969 μs 3.792 μs 1.74× 2.74×
Adds 500 40.888 μs 21.989 μs 14.485 μs 1.86× 2.82×
Replaces 500 51.116 μs 26.407 μs 18.895 μs 1.94× 2.71×
Refreshes 500 42.975 μs 26.566 μs 15.772 μs 1.62× 2.72×
Removes 500 51.500 μs 26.158 μs 17.126 μs 1.97× 3.01×
Adds 1,000 81.179 μs 45.306 μs 29.511 μs 1.79× 2.75×
Replaces 1,000 107.900 μs 57.432 μs 39.703 μs 1.88× 2.72×
Refreshes 1,000 87.122 μs 58.452 μs 36.641 μs 1.49× 2.38×
Removes 1,000 95.665 μs 53.383 μs 43.870 μs 1.79× 2.18×
Adds 10,000 785.343 μs 738.310 μs 268.475 μs 1.06× 2.93×
Replaces 10,000 1,038.356 μs 798.241 μs 410.861 μs 1.30× 2.53×
Refreshes 10,000 965.673 μs 776.385 μs 345.074 μs 1.24× 2.80×
Removes 10,000 978.571 μs 772.720 μs 380.368 μs 1.27× 2.57×

List allocation

Operation Count Old New Sum SumImmutable New vs old Immutable vs old
Adds 100 13.84 KB 8.61 KB 4.33 KB -37.8% -68.7%
Replaces 100 14.02 KB 7.81 KB 4.41 KB -44.3% -68.5%
Refreshes 100 14.02 KB 7.81 KB 4.41 KB -44.3% -68.5%
Removes 100 14.02 KB 7.81 KB 4.41 KB -44.3% -68.5%
Adds 500 66.97 KB 36.28 KB 19.95 KB -45.8% -70.2%
Replaces 500 67.14 KB 35.94 KB 20.03 KB -46.5% -70.2%
Refreshes 500 67.14 KB 35.94 KB 20.03 KB -46.5% -70.2%
Removes 500 67.14 KB 35.94 KB 20.03 KB -46.5% -70.2%
Adds 1,000 133.38 KB 71.84 KB 39.48 KB -46.1% -70.4%
Replaces 1,000 133.55 KB 71.09 KB 39.56 KB -46.8% -70.4%
Refreshes 1,000 133.55 KB 71.09 KB 39.56 KB -46.8% -70.4%
Removes 1,000 133.55 KB 71.09 KB 39.56 KB -46.8% -70.4%
Adds 10,000 1,328.69 KB 903.55 KB 391.05 KB -32.0% -70.6%
Replaces 10,000 1,328.86 KB 703.93 KB 391.13 KB -47.0% -70.6%
Refreshes 10,000 1,328.86 KB 703.93 KB 391.13 KB -47.0% -70.6%
Removes 10,000 1,328.86 KB 703.93 KB 391.13 KB -47.0% -70.6%

As you can see, general speedups and reductions in memory allocations across the board. We have a slight slowdown for the new stateful sum operator vs the old implementation but we still get a non-insignificant allocation drop on those runs. I'm open to any recommendations to help improve things any further. I focused mainly on back-compat in this case.

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.

1 participant