perf!: cut allocations across the operator surface + full public-API benchmark coverage#169
Merged
Merged
Conversation
…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.
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
ChrisPulman
approved these changes
May 20, 2026
…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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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
-warnaserroracross net8/9/10/462/472/481.Performance — operators improved (.NET 10.0, before → after)
(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>— syncIObservable<T>single-value factoryDisposableAsyncSlot— zero-allocref-field swap/assign/dispose helpers (0 B vs 24 B, ~2× faster than the wrapper types)ToHotValueTask/Continuation.LockValueTask— pooledValueTasksiblings (112 → 0 B, 21% faster)ObserveOn rewrite
The sync
ObserveOnSafe/ObserveOnIf/ForEachhelpers now use our own queue-drainObserveOnObservable<T>(single scheduled drain per burst, capture-free callback, struct payload, synchronous pass-through fast-path on the immediate scheduler) instead ofSystem.Reactive.Linq.Observable.ObserveOn— removing the last syncObservable.*dependency in production and fixing terminal/value ordering. Docs clarifyISchedulerandUnitare the deliberate exception to the don't-rebadge rule.Benchmarks created (31 new files)
DisposableAsyncSlotadd/set/clear/inspect/disposeToObservable(async→sync) per-emissionPlus 5 existing files extended (Filter regex, Throw/Start, GetMin, Multicast, ToReadOnlyBehavior).
Breaking change
Handler-accepting overloads of
SubscribeAsync,SubscribeSynchronousandDropIfBusynow takeFunc<T, ValueTask>instead ofFunc<T, Task>:Task.CompletedTask→defaultTask.FromException(ex)→ValueTask.FromException(ex)asynclambdas 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