Skip to content

perf!: cut allocations across the operator surface + full public-API benchmark coverage#169

Merged
glennawatson merged 2 commits into
mainfrom
perf/reduce-allocations-hotspots
May 20, 2026
Merged

perf!: cut allocations across the operator surface + full public-API benchmark coverage#169
glennawatson merged 2 commits into
mainfrom
perf/reduce-allocations-hotspots

Conversation

@glennawatson
Copy link
Copy Markdown
Contributor

@glennawatson glennawatson commented May 20, 2026

Summary

Allocation/throughput pass over the sync and async operator surface, plus benchmark coverage for the entire public API (verified by reflection over the built assembly). One breaking change (handler delegate type — see below). Full net10.0 suite (1901 tests) green; builds clean with -warnaserror across net8/9/10/462/472/481.

Performance — operators improved (.NET 10.0, before → after)

Operator Allocation Time Win
ObserverAsync (every async operator) 176 → 88 B/op Wrap 21.4 → 9.0 µs 58% faster
ObserveOn vs System.Reactive (immediate) 548 → 0 B 19.9 ms → 8.5 µs at 10k ~2,260×
SynchronizeAsync 547 KB → 3.13 KB at 100 ~38 → 13 µs at 1k 99% alloc
Shuffle 0 B 9,876 → 71.7 µs at 1k 138×
StatelessReplayLast subject 368 → 0 B 230 → 73 µs at 1k
SelectManyThen 56 → 0 B 15.3 → 6.6 µs at 1k 2.3×
RunAll (+ Observables.Return) 1024 → 80 B 203 → 39 µs at 1k
ConcurrencyLimiter 145 KB → ~0 B at 100 73 → 65 µs 11% faster
Conflate 528 → 312 B 11 → 7.2 µs at 100 35% faster
CompositeDisposable (add/remove) 25 → ~0 B 43.5 → 27.9 µs at 1k 36% faster
DetectStale 672 → 508 B 254 → 234 µs at 1k 24% alloc
CancelableTaskSubscription 992 → 872 B 433 → 377 µs at 1k 13% faster
Heartbeat 697 → 608 B 6% faster
Defer 856 → 750 B 12% alloc
FirstMatchFromCandidates 104 → 80 B 3.13 → 2.78 µs 11% faster
DoOnDispose 184 → 104 B 401 → 292 µs 27% faster
ScheduledSource 530 → 507 B/emit removed 47 MB at-10k GC spike
CurrentValueSubject 80 → 72 B/op

(Timings are per-emission/per-op BenchmarkDotNet means; "at 1k / at 10k / at 100" denote the emission-count parameter.)

New public APIs

  • Observables.Return<T> — sync IObservable<T> single-value factory
  • DisposableAsyncSlot — zero-alloc ref-field swap/assign/dispose helpers (0 B vs 24 B, ~2× faster than the wrapper types)
  • ToHotValueTask / Continuation.LockValueTask — pooled ValueTask siblings (112 → 0 B, 21% faster)

ObserveOn rewrite

The sync ObserveOnSafe / ObserveOnIf / ForEach helpers now use our own queue-drain ObserveOnObservable<T> (single scheduled drain per burst, capture-free callback, struct payload, synchronous pass-through fast-path on the immediate scheduler) instead of System.Reactive.Linq.Observable.ObserveOn — removing the last sync Observable.* dependency in production and fixing terminal/value ordering. Docs clarify IScheduler and Unit are the deliberate exception to the don't-rebadge rule.

Benchmarks created (31 new files)

  • AsyncDisposablesBenchmarks — Composite/Serial/SingleAssignment + DisposableAsyncSlot add/set/clear/inspect/dispose
  • AsyncFactoryBenchmarks — Defer / Create / CreateAsBackgroundJob / Empty / Never / Throw / Start / ToObservableAsync
  • AsyncMixinsBenchmarks — AsObserverAsync / MapValues / ToDisposableAsync
  • AsyncOperatorCoverageBenchmarks — ObserverAsync Wrap + WaitCompletionAsync core paths
  • AsyncPrimitivesBenchmarks — Result.Failure/TryThrow, AsyncContext.From/GetCurrent/SwitchContextAsync, Optional.TryGetValue, slot/handler primitives
  • AsyncSelectVariantsBenchmarks — SelectAsyncSequential / SelectLatestAsync / SelectAsyncConcurrent / SelectAsync
  • AsyncToSyncBridgeBenchmarksToObservable (async→sync) per-emission
  • BooleanAsyncBenchmarks — WhereFalse / CombineLatestValuesAreAllFalse
  • BufferUntilCharBenchmarks — BufferUntil(char) splitting
  • CatchAndIgnoreErrorResumeBenchmarks — CatchAndIgnoreErrorResume
  • CatchReturnBenchmarks — CatchReturn / CatchReturnUnit
  • ConcurrentFanOutBenchmarks — FastForEach + ForwardOn{Next,ErrorResume,Completed}Concurrently
  • ConcurrentReplayLatestSubjectBenchmarks — concurrent stateless replay subject broadcast
  • ContinuationLockBenchmarks — Continuation.Lock (Task) vs LockValueTask A/B
  • DistinctByAsyncBenchmarks — DistinctBy / DistinctUntilChangedBy
  • DoOnDisposeBenchmarks — DoOnDispose subscribe-dispose + steady-state
  • MiscSyncOperatorBenchmarks — SwitchIfEmpty / SampleLatest / ReplayLastOnSubscribe / ObserveOnIf / FromArray / RunAll
  • ObservableSubscriptionBenchmarks — SubscribeGetValue/GetError, SubscribeAndComplete, WaitFor{Value,Completion,Error}
  • ObserveOnComparisonBenchmarks — our ObserveOn vs System.Reactive (immediate + current-thread)
  • RetryAndBufferSubscribeBenchmarks — Retry* family / BufferUntilIdle/Inactive / DebounceImmediate / Throttle* / DetectStale / SyncTimer
  • ScheduledSourceAndObserveOnSafeBenchmarks — Schedule(source) + ObserveOnSafe per-emission
  • ScheduledValueBenchmarks — Schedule(value) variants + ScheduleSafe
  • SequentialMatchAndConcurrencyBenchmarks — FirstMatchFromCandidates + WithLimitedConcurrency
  • ShuffleBenchmarks — Shuffle per-emission
  • StatelessReplayLatestPublishBenchmarks — StatelessReplayLatestPublish multi-observer
  • StatelessReplayLatestSubjectBenchmarks — serial stateless replay subject broadcast
  • SubscribeSynchronousBenchmarks — SubscribeSynchronous handler shapes (ValueTask A/B)
  • SyncSelectFusionBenchmarks — SelectConstant / WhereSelect / TrySelect / SelectManyThen
  • SynchronizeSynchronousBenchmarks — SynchronizeSynchronous / SynchronizeAsync fast-dispose
  • TimedSyncOperatorSubscribeBenchmarks — Heartbeat / ThrottleFirst / Conflate / While subscribe-dispose
  • ToPropertyObservableBenchmarks — ToPropertyObservable per-change (INPC)

Plus 5 existing files extended (Filter regex, Throw/Start, GetMin, Multicast, ToReadOnlyBehavior).

Breaking change

Handler-accepting overloads of SubscribeAsync, SubscribeSynchronous and DropIfBusy now take Func<T, ValueTask> instead of Func<T, Task>:

  • handlers returning Task.CompletedTaskdefault
  • Task.FromException(ex)ValueTask.FromException(ex)
  • async lambdas need no change (compiler infers the new target type)

Test plan

  • dotnet build ReactiveUI.Extensions.slnx -c Release -warnaserror — clean (net8/9/10/462/472/481)
  • dotnet test --project tests/ReactiveUI.Extensions.Tests -c Release -f net10.0 — 1901 tests, 0 failed
  • Benchmarks run in isolation; figures above are .NET 10.0 BenchmarkDotNet means

…benchmark coverage

Allocation/throughput pass over the sync and async operator surface, plus
benchmark coverage for the entire public API. Per-operator before -> after
(.NET 10.0, BenchmarkDotNet):

  ObserverAsync (every async operator) 176 B -> 88 B/op, Wrap 21.4->9.0 us (58% faster)
  ObserveOn (vs System.Reactive)       immediate 548 B->0 B, 19.9 ms->8.5 us @10k (~2,260x)
  SynchronizeAsync                     547 KB->3.13 KB @100, ~38->13 us @1k
  Shuffle                              9876->71.7 us @1k (138x), 0 B
  StatelessReplayLast subject          368 B->0 B, 230->73 us @1k (3x)
  SelectManyThen                       56 B->0 B, 15.3->6.6 us @1k (2.3x)
  RunAll (+ Observables.Return)        1024 B->80 B, 203->39 us @1k (5x)
  ConcurrencyLimiter                   145 KB->~0 B @100, 73->65 us (11% faster)
  Conflate                             528 B->312 B, 11->7.2 us @100 (35% faster)
  CompositeDisposable (add/remove)     25 B->~0 B, 43.5->27.9 us @1k (36% faster)
  DetectStale                          672 B->508 B, 254->234 us @1k (24% alloc)
  CancelableTaskSubscription           992 B->872 B, 433->377 us @1k (13% faster)
  Heartbeat                            697 B->608 B/op (13% alloc, 6% faster)
  Defer                                856 B->750 B/op (12%)
  FirstMatchFromCandidates             104 B->80 B, 3.13->2.78 us (11% faster)
  DoOnDispose                          184 B->104 B, 401->292 us (27% faster)
  ScheduledSource                      530 B->507 B/emit, removed 47 MB @10k GC spike
  CurrentValueSubject                  80 B->72 B/op

New public APIs:
  Observables.Return<T>            sync IObservable<T> single-value factory
  DisposableAsyncSlot              zero-alloc ref-field swap/assign/dispose helpers
                                   (0 B vs 24 B and ~2x faster than the wrapper types)
  ToHotValueTask / LockValueTask   pooled ValueTask siblings (112 B->0 B, 21% faster)

ObserveOn now uses our own queue-drain ObserveOnObservable<T> with an immediate
pass-through fast-path, removing the last sync System.Reactive.Linq.Observable.*
dependency and fixing terminal/value ordering. Docs clarify IScheduler and Unit
are the deliberate exception to the don't-rebadge rule.

Benchmark coverage now spans the entire public API (verified by reflection over
the built assembly). All 1901 net10.0 tests pass.

BREAKING CHANGE: handler-accepting overloads of SubscribeAsync,
SubscribeSynchronous and DropIfBusy now take Func<T, ValueTask> instead of
Func<T, Task>. Handlers returning Task.CompletedTask become default;
Task.FromException(ex) becomes ValueTask.FromException(ex); async lambdas need
no change.
Comment thread src/ReactiveUI.Extensions/Operators/ScheduledSourceObservable.cs
@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (48f3376) to head (ab6e42a).

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #169    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          226       230     +4     
  Lines         6804      6949   +145     
  Branches       648       691    +43     
==========================================
+ Hits          6804      6949   +145     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…anch coverage

Follow-up to the allocation-reduction rewrite, which dropped coverage to
72% patch / 98.1% project and introduced new-code duplication between the
ObserveOn and Conflate operators.

Deduplication:
- Extract the shared queue-and-single-drain marshaller used by
  ObserveOnObservable and ConflateObservable into a composed
  ScheduledDrainState<T> helper (+ IDrainTarget, DrainNotificationKind),
  mirroring the existing TimerSinkState<T> composition pattern. No base
  class and no per-item virtual dispatch; the scheduled drain callback
  stays closure-free via IDrainTarget.

Dead / unreachable code removal:
- Delete the unused internal DelegateObservable<T> (no production caller)
  and drop its dangling doc reference in ConcurrencyLimiter.
- Remove SyncSignal.WaitForDisposeAsync's unreachable double-call guard;
  the producer calls it exactly once per signal, so the TCS is now
  published with a plain volatile write.

Coverage:
- Add behavioral tests across the previously-untested public surface and
  operator branches (ToHotValueTask/FirstAsValueTaskHelper, Observables.Return,
  DisposableAsyncSlot, Continuation, CompositeDisposableAsync collection/grow/
  compact/enumerator paths, ObserveOnObservable, DetectStale sync-error attach,
  DoOnDispose double-dispose, SyncSignal idempotent dispose, ObserverAsync
  self-link guard).
- Isolate genuinely race-only branches (CAS retries/losses, drain-vs-dispose
  flag races) into the smallest possible [ExcludeFromCodeCoverage] units,
  keeping the real logic counted.

Result: net8.0/net9.0/net10.0 all at 100% line and branch coverage; 1941
tests pass; full -warnaserror solution build clean across all target
frameworks.
@sonarqubecloud
Copy link
Copy Markdown

@glennawatson glennawatson merged commit a0d08b3 into main May 20, 2026
14 checks passed
@glennawatson glennawatson deleted the perf/reduce-allocations-hotspots branch May 20, 2026 11:53
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.

2 participants