diff --git a/.gitignore b/.gitignore index 7fe9da932b..1e724ffb9d 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,6 @@ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx + +# BenchmarkDotNet artifacts (extensionless files included; benchmarks/baselines is covered by benchmarks/.gitignore) +Rx.NET/Source/BenchmarkDotNet.Artifacts/ diff --git a/Rx.NET/Source/System.Reactive.slnx b/Rx.NET/Source/System.Reactive.slnx index 589326009d..db3a6971e5 100644 --- a/Rx.NET/Source/System.Reactive.slnx +++ b/Rx.NET/Source/System.Reactive.slnx @@ -17,9 +17,8 @@ - - - + + diff --git a/Rx.NET/Source/benchmarks/.gitignore b/Rx.NET/Source/benchmarks/.gitignore new file mode 100644 index 0000000000..70b25eb5d0 --- /dev/null +++ b/Rx.NET/Source/benchmarks/.gitignore @@ -0,0 +1,8 @@ +# Benchmark run outputs (results, baselines, traces) are NOT committed to the repo. +# Keep them locally or publish them as CI artifacts. See notes/benchmark-expansion-plan.md. +baselines/ +traces/ +BenchmarkDotNet.Artifacts/ + +# Local planning / guideline docs kept alongside the suite but not committed. +notes/ diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/AppendPrependBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/AppendPrependBenchmark.cs deleted file mode 100644 index 8623d1f225..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/AppendPrependBenchmark.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -#if (CURRENT) -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class AppendPrependBenchmark - { - [Params(1, 10, 100, 1000, 10000)] - public int N; - - private int _store; - - [Benchmark(Baseline = true)] - public void StartWithArray() - { - var array = new int[2 * N]; - var max = 2 * N - 1; - - for (var i = 0; i < N; i++) - { - array[i] = i; - array[max - i] = i; - } - - Observable - .Empty() - .StartWith(array) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void StartWithList() - { - var list = new List(); - - for (var i = 0; i < N; i++) - { - list.Insert(i, 0); - list.Add(i); - } - - Observable - .Empty() - .StartWith(list) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void StartWithLinkedList() - { - var list = new LinkedList(); - - for (var i = 0; i < N; i++) - { - list.AddFirst(i); - list.AddLast(i); - } - - Observable - .Empty() - .StartWith(list) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void AppendPrepend() - { - var obs = Observable.Empty(); - - for (var i = 0; i < N; i++) - { - obs = obs.Prepend(i); - obs = obs.Append(i); - } - - obs.Subscribe(v => Volatile.Write(ref _store, v)); - } - } -} -#endif diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Benchmarks.System.Reactive.csproj b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Benchmarks.System.Reactive.csproj index 85bffd9d23..2df158821d 100644 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Benchmarks.System.Reactive.csproj +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Benchmarks.System.Reactive.csproj @@ -1,18 +1,22 @@ - + Exe - net472 + net472;net8.0;net9.0;net10.0 true false - Current Sources;Rx.net 3.1.1;Rx.net 4.0 + + - 7.0-none 7.0-none @@ -23,41 +27,30 @@ - - - $(DefineConstants);RX3_1_1 - - - - $(DefineConstants);RX4_0 - - - - $(DefineConstants);CURRENT - - - - - - - - - - - - - - + + + - + + - + + + diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/BufferCountBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/BufferCountBenchmark.cs deleted file mode 100644 index 3eedca0887..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/BufferCountBenchmark.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class BufferCountBenchmark - { - private IList _store; - - [Benchmark] - public void Exact() - { - Observable.Range(1, 1000) - .Buffer(1) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Skip() - { - Observable.Range(1, 1000) - .Buffer(1, 2) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Overlap() - { - Observable.Range(1, 1000) - .Buffer(2, 1) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/CombineLatestBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/CombineLatestBenchmark.cs deleted file mode 100644 index 2babf600fd..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/CombineLatestBenchmark.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using BenchmarkDotNet.Attributes; -using ReactiveTests.Tests; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class CombineLatestBenchmark - { - private readonly CombineLatestTest _zipTest = new(); - - [Benchmark] - public void CombineLatest_Typical2() - { - _zipTest.CombineLatest_Typical2(); - } - - [Benchmark] - public void CombineLatest_Typical3() - { - _zipTest.CombineLatest_Typical3(); - } - - [Benchmark] - public void CombineLatest_Typical4() - { - _zipTest.CombineLatest_Typical4(); - } - - [Benchmark] - public void CombineLatest_Typical5() - { - _zipTest.CombineLatest_Typical5(); - } - - [Benchmark] - public void CombineLatest_Typical6() - { - _zipTest.CombineLatest_Typical6(); - } - - [Benchmark] - public void CombineLatest_Typical7() - { - _zipTest.CombineLatest_Typical7(); - } - - [Benchmark] - public void CombineLatest_Typical8() - { - _zipTest.CombineLatest_Typical8(); - } - - [Benchmark] - public void CombineLatest_Typical9() - { - _zipTest.CombineLatest_Typical9(); - } - - [Benchmark] - public void CombineLatest_Typical10() - { - _zipTest.CombineLatest_Typical10(); - } - - [Benchmark] - public void CombineLatest_Typical11() - { - _zipTest.CombineLatest_Typical11(); - } - - [Benchmark] - public void CombineLatest_Typical12() - { - _zipTest.CombineLatest_Typical12(); - } - - [Benchmark] - public void CombineLatest_Typical13() - { - _zipTest.CombineLatest_Typical13(); - } - - [Benchmark] - public void CombineLatest_Typical14() - { - _zipTest.CombineLatest_Typical14(); - } - - [Benchmark] - public void CombineLatest_Typical15() - { - _zipTest.CombineLatest_Typical15(); - } - - [Benchmark] - public void CombineLatest_Typical16() - { - _zipTest.CombineLatest_Typical16(); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ComparisonAsyncBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ComparisonAsyncBenchmark.cs deleted file mode 100644 index 24fb752f29..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ComparisonAsyncBenchmark.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Linq; -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class ComparisonAsyncBenchmark - { - [Params(1, 10, 100, 1000, 10000, 100000, 1000000)] - public int N; - private int _store; - - private IScheduler _scheduler1; - private IScheduler _scheduler2; - - [GlobalSetup] - public void Setup() - { - _scheduler1 = new EventLoopScheduler(); - _scheduler2 = new EventLoopScheduler(); - } - - [Benchmark] - public void ObserveOn() - { - var cde = new CountdownEvent(1); - - Observable.Range(1, N).ObserveOn(_scheduler1) - .Subscribe(v => Volatile.Write(ref _store, v), () => cde.Signal()); - - if (N <= 1000) - { - while (cde.CurrentCount != 0) ; - } - else - { - cde.Wait(); - } - } - - [Benchmark] - public void SubscribeOn() - { - var cde = new CountdownEvent(1); - - Observable.Range(1, N).SubscribeOn(_scheduler1) - .Subscribe(v => Volatile.Write(ref _store, v), () => cde.Signal()); - - if (N <= 1000) - { - while (cde.CurrentCount != 0) ; - } - else - { - cde.Wait(); - } - } - - [Benchmark] - public void SubscribeOnObserveOn() - { - var cde = new CountdownEvent(1); - - Observable.Range(1, N) - .SubscribeOn(_scheduler1) - .ObserveOn(_scheduler2) - .Subscribe(v => Volatile.Write(ref _store, v), () => cde.Signal()); - - if (N <= 1000) - { - while (cde.CurrentCount != 0) ; - } - else - { - cde.Wait(); - } - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ComparisonBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ComparisonBenchmark.cs deleted file mode 100644 index 7080c8e6ef..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ComparisonBenchmark.cs +++ /dev/null @@ -1,306 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class ComparisonBenchmark - { - [Params(1, 10, 100, 1000, 10000, 100000, 1000000)] - public int N; - private int _store; - - [Benchmark] - public void ForLoopBaseLine() - { - var n = N; - for (var i = 0; i < n; i++) - { - Volatile.Write(ref _store, i); - } - } - - [Benchmark] - public void EnumerableBaseLine() - { - foreach (var v in Enumerable.Range(1, N)) - { - Volatile.Write(ref _store, v); - } - } - - [Benchmark] - public void Return() - { - Observable.Return(1).Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Range() - { - Observable.Range(1, N).Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Select() - { - Observable.Range(1, N) - .Select(v => v + 1) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void SelectSelect() - { - Observable.Range(1, N) - .Select(v => v + 1) - .Select(v => v + 1) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Where() - { - Observable.Range(1, 2 * N) - .Where(v => (v & 1) != 0) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void WhereWhere() - { - Observable.Range(1, 4 * N) - .Where(v => (v & 1) != 0) - .Where(v => (v & 2) != 0) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Take() - { - Observable.Range(1, 2 * N) - .Take(N) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Skip() - { - Observable.Range(1, 2 * N) - .Skip(N) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void TakeUntil() - { - Observable.Range(1, N) - .TakeUntil(Observable.Never()) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void ToObservable() - { - Enumerable.Range(1, N) - .ToObservable() - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Concat() - { - var M = N - N / 2; - - Observable.Concat( - Observable.Range(1, N), - Observable.Range(1, M) - ) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void ConcatCrossMap() - { - var M = 1000 * 1000 / N; - - Observable.Concat(Observable.Range(1, N).Select(v => Observable.Range(v, M))) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void SelectManyCrossMap() - { - var M = 1000 * 1000 / N; - - Observable.Range(1, N).SelectMany(v => Observable.Range(v, M)) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void MergeCrossMap() - { - var M = 1000 * 1000 / N; - - Observable.Merge(Observable.Range(1, N) - .Select(v => Observable.Range(v, M)) - ) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void AsyncSubjectPush() - { - var subj = new AsyncSubject(); - subj.Subscribe(v => Volatile.Write(ref _store, v)); - - var n = N; - for (var i = 0; i < N; i++) - { - subj.OnNext(i); - } - subj.OnCompleted(); - } - - [Benchmark] - public void SubjectPush() - { - var subj = new Subject(); - subj.Subscribe(v => Volatile.Write(ref _store, v)); - - var n = N; - for (var i = 0; i < N; i++) - { - subj.OnNext(i); - } - subj.OnCompleted(); - } - - [Benchmark] - public void AmbTwo() - { - Observable.Never().Amb(Observable.Range(1, N)) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void AmbThree() - { - Observable.Amb(Observable.Never(), Observable.Never(), Observable.Range(1, N)) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Timeout() - { - Observable.Range(1, N) - .Timeout(TimeSpan.FromHours(1)) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - -#pragma warning disable CS0618 // Type or member is obsolete - [Benchmark] - public void First() - { - Volatile.Write(ref _store, Observable.Range(1, N) - .First()); - } - - [Benchmark] - public void Last() - { - Volatile.Write(ref _store, Observable.Range(1, N) - .Last()); - } -#pragma warning restore CS0618 // Type or member is obsolete - - private IList _bufferStore; - - [Benchmark] - public void Buffer_Exact() - { - Observable.Range(1, 1000) - .Buffer(1) - .Subscribe(v => Volatile.Write(ref _bufferStore, v)); - } - - [Benchmark] - public void Buffer_Skip() - { - Observable.Range(1, 1000) - .Buffer(1, 2) - .Subscribe(v => Volatile.Write(ref _bufferStore, v)); - } - - [Benchmark] - public void Buffer_Overlap() - { - Observable.Range(1, 1000) - .Buffer(2, 1) - .Subscribe(v => Volatile.Write(ref _bufferStore, v)); - } - - [Benchmark] - public void CurrentThreadSchedulerRepeated() - { - var n = N; - var scheduler = CurrentThreadScheduler.Instance; - for (var i = 0; i < n; i++) - { - scheduler.Schedule(i, (_, v) => - { - Volatile.Write(ref _store, v); - return Disposable.Empty; - }); - } - } - - [Benchmark] - public void TakeLast() - { - Observable.Range(1, 2 * N).TakeLast(N) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Repeat() - { - Observable.Repeat(1, N) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void ToList() - { - Observable.Repeat(1, N).ToList() - .Subscribe(v => Volatile.Write(ref _bufferStore, v)); - } - - [Benchmark] - public void Generate() - { - Observable.Generate(0, s => s < N, s => s + 1, s => s) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Collect() - { - foreach (var v in Observable.Range(1, N).Collect(() => new List(), (a, b) => { a.Add(b); return a; })) - { - Volatile.Write(ref _bufferStore, v); - } - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/GroupByCompletion.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/GroupByCompletion.cs deleted file mode 100644 index 9ecba76c5d..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/GroupByCompletion.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Reactive.Linq; - -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - /// - /// Completion of a wide fan-out/in scenario. - /// - /// - /// - /// This was added to address https://github.com/dotnet/reactive/issues/2005 in which completion - /// takes longer and longer to handle as the number of groups increases. - /// - /// - /// The queries in this benchmark represent the common 'fan out/in' pattern in Rx. It is often - /// useful to split a stream into groups to enable per-group processing, and then to recombine - /// the data back into a single stream. These benchmarks don't do any per-group processing, so - /// they might look pointless, but we're trying to measure the minimum unavoidable overhead - /// that any code using this technique will encounter. - /// - /// - [MemoryDiagnoser] - public class GroupByCompletion - { - private IObservable observable; - - [Params(200_000, 1_000_000)] - public int NumberOfSamples { get; set; } - - [Params(10, 100, 1_000, 10_000, 100_000, 150_000, 200_000)] - public int NumberOfGroups { get; set; } - - [GlobalSetup] - public void GlobalSetup() - { - var data = new int[NumberOfSamples]; - for (var i = 0; i < data.Length; ++i) - { - data[i] = i; - } - - observable = data.ToObservable(); - } - - [Benchmark] - public void GroupBySelectMany() - { - var numberOfGroups = NumberOfGroups; - - observable!.GroupBy(value => value % numberOfGroups) - .SelectMany(groupOfInts => groupOfInts) - .Subscribe(intValue => { }); - } - - [Benchmark] - public void GroupByMerge() - { - var numberOfGroups = NumberOfGroups; - - observable!.GroupBy(value => value % numberOfGroups) - .Merge() - .Subscribe(intValue => { }); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/BenchmarkBase.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/BenchmarkBase.cs new file mode 100644 index 0000000000..a00e85b24f --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/BenchmarkBase.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Engines; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// The shared root for the benchmark base classes: a single sink whose + /// dead-code-elimination contract is defined in one place. The throughput () + /// and temporal () families each add their own [Params] N sweep on + /// top — those sweeps deliberately differ (temporal is capped because every element is a scheduler-queue + /// entry), so they cannot share a single N. This type is abstract so auto-discovery skips it. + /// + public abstract class BenchmarkBase + { + /// The BenchmarkDotNet consumer used to defeat dead-code elimination. + protected readonly Consumer Consumer = new(); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/FaultingSource.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/FaultingSource.cs new file mode 100644 index 0000000000..76302833bc --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/FaultingSource.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// Faulting sources for exercising operator error pathways. A single shared exception instance is used + /// so the error itself never allocates per invocation. + /// + public static class FaultingSource + { + /// The single exception instance used by every faulting source. + public static readonly Exception BenchmarkError = new InvalidOperationException("benchmark fault"); + + /// Emits 1..n then terminates with (OnError). + public static IObservable Faulting(int n) => + Observable.Range(1, n).Concat(Observable.Throw(BenchmarkError)); + + /// + /// Faults the first subscriptions (each emitting 1..n first), then completes + /// normally — so Retry/RetryWhen genuinely resubscribe that many times before succeeding. + /// + public static IObservable FaultThenSucceed(int n, int faults) + { + var attempt = 0; + return Observable.Defer(() => attempt++ < faults ? Faulting(n) : Observable.Range(1, n)); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/OperatorBenchmarkBase.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/OperatorBenchmarkBase.cs new file mode 100644 index 0000000000..412b81c8c3 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/OperatorBenchmarkBase.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// Base class for throughput-style (S1) operator benchmarks: a standard element-count sweep and the + /// shared sink. BenchmarkDotNet honours [Params]/[Benchmark] + /// declared on base classes; this type is abstract so auto-discovery skips it. + /// + /// + /// N is not directly comparable across classes. For linear operators it is the element/source + /// count (work = O(N)); but the cross-map fan-out classes (SelectMany/Switch/Merge) hold total emitted work at + /// ~1,000,000 so N is the inner-subscription count, and the two-source combiners build two sources of N. + /// Read the Ratio/absolute numbers within a class, not across classes at the same N. + /// + public abstract class OperatorBenchmarkBase : BenchmarkBase + { + [Params(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000)] + public int N; + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/PerElementColumn.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/PerElementColumn.cs new file mode 100644 index 0000000000..618ad16e03 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/PerElementColumn.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// Display-only summary columns showing per-element cost: Mean/N (ns per element) and + /// AllocatedBytes/N (bytes per element), where N is the [Params] element count. + /// OperationsPerInvoke cannot express this because it must be a compile-time constant. + /// + /// + /// For fan-out classes (SelectMany/Switch/Merge and friends) N is the inner-subscription count + /// with total work pinned, so there the figure reads as per-subscription rather than per-element. + /// The columns never enter the JSON export — comparison tooling recomputes per-element values + /// from the raw Statistics.Mean and Memory.BytesAllocatedPerOperation fields. + /// + public sealed class PerElementColumn : IColumn + { + public static readonly IColumn Time = new PerElementColumn( + "TimePerElement", "ns/N", "Mean time divided by the N parameter (ns per element; per-subscription for fan-out classes)", isTime: true); + + public static readonly IColumn Allocated = new PerElementColumn( + "AllocatedPerElement", "B/N", "Allocated bytes per op divided by the N parameter (bytes per element; per-subscription for fan-out classes)", isTime: false); + + private readonly bool _isTime; + + private PerElementColumn(string id, string columnName, string legend, bool isTime) + { + Id = id; + ColumnName = columnName; + Legend = legend; + _isTime = isTime; + } + + public string Id { get; } + + public string ColumnName { get; } + + public string Legend { get; } + + public bool AlwaysShow => false; + + public ColumnCategory Category => ColumnCategory.Custom; + + public int PriorityInCategory => _isTime ? 0 : 1; + + public bool IsNumeric => true; + + // Dimensionless on purpose: the value is self-formatted with the unit in the column name, + // sidestepping BenchmarkDotNet's time-unit auto-scaling. + public UnitType UnitType => UnitType.Dimensionless; + + public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => false; + + public bool IsAvailable(Summary summary) + { + foreach (var benchmarkCase in summary.BenchmarksCases) + { + if (TryGetN(benchmarkCase, out _)) + { + return true; + } + } + + return false; + } + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase) => + GetValue(summary, benchmarkCase, summary.Style); + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) + { + if (!TryGetN(benchmarkCase, out var n) || n <= 0) + { + return "-"; + } + + var report = summary[benchmarkCase]; + if (report == null) + { + return "-"; + } + + double? total = _isTime + ? report.ResultStatistics?.Mean // ns + : report.GcStats.GetBytesAllocatedPerOperation(benchmarkCase); // null without MemoryDiagnoser + + return total is double value + ? (value / n).ToString("N2", CultureInfo.InvariantCulture) + : "-"; + } + + private static bool TryGetN(BenchmarkCase benchmarkCase, out int n) + { + // Scan Items rather than use the string indexer: the indexer's behaviour for a missing + // name is undocumented, and classes without an N param must yield "-" rather than throw. + foreach (var parameter in benchmarkCase.Parameters.Items) + { + if (parameter.Name == "N" && parameter.Value is int value) + { + n = value; + return true; + } + } + + n = 0; + return false; + } + + public override string ToString() => ColumnName; + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/PeriodicVirtualScheduler.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/PeriodicVirtualScheduler.cs new file mode 100644 index 0000000000..7a6976669c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/PeriodicVirtualScheduler.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// A that also implements , so the + /// periodic operators (Interval, Timer(dueTime, period), Sample(TimeSpan), + /// Buffer(TimeSpan), Window(TimeSpan)) exercise Rx's real SchedulePeriodic fast + /// path in virtual time, instead of the stopwatch-emulated fallback they hit on a plain + /// . + /// + /// + /// Scheduler.AsPeriodic() resolves the service via IServiceProvider.GetService, which + /// VirtualTimeSchedulerBase routes through its virtual GetService — implementing the + /// interface alone is never discovered, hence the override below. + /// + public sealed class PeriodicVirtualScheduler : HistoricalScheduler, ISchedulerPeriodic + { + protected override object GetService(Type serviceType) => + serviceType == typeof(ISchedulerPeriodic) ? this : base.GetService(serviceType); + + public IDisposable SchedulePeriodic(TState state, TimeSpan period, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + // Scheduler.SchedulePeriodic permits a zero period, but in virtual time that would make + // Start() spin forever at a fixed clock. Fail fast — this is benchmark infrastructure only. + if (period <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(period)); + } + + return new PeriodicallyScheduledWorkItem(this, state, period, action); + } + + private sealed class PeriodicallyScheduledWorkItem : IDisposable + { + private readonly PeriodicVirtualScheduler _scheduler; + private readonly TimeSpan _period; + private readonly Func _action; + private readonly SerialDisposable _run = new(); + private TState _state; + private DateTimeOffset _next; // absolute due time: exact tick spacing, immune to Sleep() + private bool _disposed; // virtual schedulers are single-threaded; no locking needed + + public PeriodicallyScheduledWorkItem(PeriodicVirtualScheduler scheduler, TState state, TimeSpan period, Func action) + { + _scheduler = scheduler; + _state = state; + _period = period; + _action = action; + + // ISchedulerPeriodic contract: the first tick fires one period after the call. + _next = scheduler.Now + period; + _run.Disposable = scheduler.ScheduleAbsolute(this, _next, static (_, self) => self.Tick()); + } + + private IDisposable Tick() + { + _state = _action(_state); + + // The action may have disposed us re-entrantly (e.g. Interval(...).Take(n) completing + // inside OnNext). Only reschedule afterwards, otherwise Start() would never terminate. + if (!_disposed) + { + _next += _period; + _run.Disposable = _scheduler.ScheduleAbsolute(this, _next, static (_, self) => self.Tick()); + } + + return Disposable.Empty; + } + + public void Dispose() + { + _disposed = true; + _run.Dispose(); // cancels the pending ScheduledItem so GetNext() skips it + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/RateWindowBenchmarkBase.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/RateWindowBenchmarkBase.cs new file mode 100644 index 0000000000..741e1d8047 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/RateWindowBenchmarkBase.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; + +using BenchmarkDotNet.Attributes; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// Relationship between an operator's time-window and the source's inter-arrival period. One knob + /// captures the hot-path variation for rate/window operators without exploding the benchmark matrix. + /// + public enum Regime + { + /// Window < period — elements are spaced further apart than the window (e.g. Throttle: all pass). + Sparse, + + /// Window ≈ period — the boundary case. + Boundary, + + /// Window ≫ period — elements arrive far faster than the window (e.g. Throttle: constant cancel+reschedule). + Dense, + } + + /// + /// Base class for rate/window temporal operators (Throttle, Sample, Buffer(time), Window(time)) whose hot + /// path depends on how the operator's relates to the source's . + /// Adds the knob and computes the window once per parameter combination. + /// + public abstract class RateWindowBenchmarkBase : TemporalBenchmarkBase + { + [Params(Regime.Sparse, Regime.Boundary, Regime.Dense)] + public Regime Density; + + /// The source inter-arrival period. + protected TimeSpan Period { get; private set; } + + /// The operator's time window, derived from relative to . + protected TimeSpan Window { get; private set; } + + [GlobalSetup] + public void RateWindowSetup() + { + Period = TimeSpan.FromTicks(PeriodTicks); + Window = Density switch + { + Regime.Sparse => TimeSpan.FromTicks(PeriodTicks / 2), // window < period → every element passes + Regime.Boundary => TimeSpan.FromTicks(PeriodTicks), // window ≈ period + Regime.Dense => TimeSpan.FromTicks(PeriodTicks * N), // window ≫ period → arrives far faster than the window + _ => TimeSpan.FromTicks(PeriodTicks), + }; + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/RxBenchmarkConfig.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/RxBenchmarkConfig.cs new file mode 100644 index 0000000000..f55d700745 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/RxBenchmarkConfig.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Order; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// The shared BenchmarkDotNet configuration for the whole suite. Wired once in Program, + /// so individual benchmark classes do not need to repeat [MemoryDiagnoser]. + /// + /// + /// Memory is a first-class output: surfaces Gen0/1/2 and Allocated + /// bytes/op, and the full JSON + CSV exporters emit the machine-readable artifacts the + /// performance-improvement plan is generated from. Opt-in profilers (ETW / EventPipe / disassembly / + /// threading) are enabled per-run via CLI switches, layered on top of this config. + /// + public static class RxBenchmarkConfig + { + public static IConfig Create() => + ManualConfig.Create(DefaultConfig.Instance) + .AddDiagnoser(MemoryDiagnoser.Default) + // DefaultConfig already provides the GitHub markdown + CSV exporters; only add the full JSON + // exporter, which is the machine-readable artifact the perf-improvement plan is generated from. + .AddExporter(JsonExporter.Full) + .AddColumn(PerElementColumn.Time, PerElementColumn.Allocated) + .WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest)); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/Sinks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/Sinks.cs new file mode 100644 index 0000000000..413a9641fa --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/Sinks.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; + +using BenchmarkDotNet.Engines; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// An that funnels every OnNext into a BenchmarkDotNet + /// so results can't be eliminated as dead code. OnError is consumed (not swallowed), so error-path + /// benchmarks measure real work and never rethrow into the benchmark. + /// + public sealed class ConsumingObserver : IObserver + { + private readonly Consumer _consumer; + + public ConsumingObserver(Consumer consumer) => _consumer = consumer; + + public void OnNext(T value) => _consumer.Consume(value); + + public void OnError(Exception error) => _consumer.Consume(error); + + public void OnCompleted() { } + } + + /// + /// A that also blocks until the sequence terminates — for asynchronous + /// pipelines (e.g. ObserveOn/SubscribeOn) whose completion happens on another thread. + /// + public sealed class BlockingObserver : IObserver, IDisposable + { + private readonly Consumer _consumer; + private readonly ManualResetEventSlim _done = new(false); + + public BlockingObserver(Consumer consumer) => _consumer = consumer; + + public void OnNext(T value) => _consumer.Consume(value); + + public void OnError(Exception error) + { + _consumer.Consume(error); + _done.Set(); + } + + public void OnCompleted() => _done.Set(); + + public void Wait() => _done.Wait(); + + public void Dispose() => _done.Dispose(); + } + + public static class SinkExtensions + { + /// Subscribes a that feeds every value (and any error) to . + public static IDisposable SubscribeConsume(this IObservable source, Consumer consumer) => + source.Subscribe(new ConsumingObserver(consumer)); + + /// Subscribes, consumes every value, and blocks until the sequence terminates. For async pipelines. + public static void SubscribeBlocking(this IObservable source, Consumer consumer) + { + using var observer = new BlockingObserver(consumer); + using (source.Subscribe(observer)) + { + observer.Wait(); + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/TemporalBenchmarkBase.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/TemporalBenchmarkBase.cs new file mode 100644 index 0000000000..ff2d6ab682 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/TemporalBenchmarkBase.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// Base class for temporal (S3) operator benchmarks driven by virtual time. is capped + /// well below the throughput sweep because every element becomes a scheduler-queue entry (cost ~N·logN). + /// This type is abstract so auto-discovery skips it. + /// + /// + /// Operators whose hot path depends on the window-vs-arrival relationship (Throttle, Sample, Buffer(time), + /// Window(time)) derive from instead, which adds the density knob. + /// + public abstract class TemporalBenchmarkBase : BenchmarkBase + { + /// Fixed source inter-arrival period, in virtual ticks. + protected const long PeriodTicks = 10; + + [Params(100, 1_000, 10_000, 100_000)] + public int N; + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/VirtualTimeSource.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/VirtualTimeSource.cs new file mode 100644 index 0000000000..8ae6539ad7 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Infrastructure/VirtualTimeSource.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +namespace Benchmarks.System.Reactive.Infrastructure +{ + /// + /// Builders that emit "N elements spread across a virtual timeline" for temporal (S3) benchmarks. + /// + /// + /// Uses + /// rather than the recording TestScheduler.CreateHotObservable, so the only allocations in the + /// measured region belong to the operator under test (Generate keeps a single self-rescheduling item). + /// Driven by a virtual scheduler (e.g. ), Start() drains the whole + /// timeline synchronously with zero wall-clock waiting. + /// + public static class VirtualTimeSource + { + /// + /// Emits ascending integers on , each spaced + /// of virtual time apart, followed by OnCompleted. + /// + public static IObservable Timed(IScheduler scheduler, int n, TimeSpan step) => + Observable.Generate(0, i => i < n, i => i + 1, i => i, _ => step, scheduler); + + /// + /// As but the first element is delayed by + /// (subsequent elements apart) — used to interleave two + /// sources on a shared clock. + /// + public static IObservable Timed(IScheduler scheduler, int n, TimeSpan step, TimeSpan offset) => + Observable.Generate(0, i => i < n, i => i + 1, i => i, i => i == 0 ? offset : step, scheduler); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/AggregateBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/AggregateBenchmarks.cs new file mode 100644 index 0000000000..4e89ee3ea9 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/AggregateBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// S1: Aggregate folds the whole stream into a single value emitted on completion. + [BenchmarkCategory("Aggregates")] + public class AggregateBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Aggregate() => Observable.Range(1, N).Aggregate(0L, static (acc, v) => acc + v).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/CountQuantifierBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/CountQuantifierBenchmarks.cs new file mode 100644 index 0000000000..ea5e533e01 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/CountQuantifierBenchmarks.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// S1: Count and the quantifiers. Predicates are chosen so each must scan the whole stream + /// (worst case): Any/Contains never match, All always holds. + /// + [BenchmarkCategory("Aggregates")] + public class CountQuantifierBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Count() => Observable.Range(1, N).Count().SubscribeConsume(Consumer); + + [Benchmark] + public void Any() => Observable.Range(1, N).Any(static v => v == int.MaxValue).SubscribeConsume(Consumer); + + [Benchmark] + public void All() => Observable.Range(1, N).All(static v => v > 0).SubscribeConsume(Consumer); + + [Benchmark] + public void Contains() => Observable.Range(1, N).Contains(int.MaxValue).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ElementAtBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ElementAtBenchmarks.cs new file mode 100644 index 0000000000..a06bef2919 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ElementAtBenchmarks.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// S1: ElementAt/ElementAtOrDefault the last index, counting through the whole stream first. + [BenchmarkCategory("Aggregates")] + public class ElementAtBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ElementAt() => Observable.Range(1, N).ElementAt(N - 1).SubscribeConsume(Consumer); + + [Benchmark] + public void ElementAtOrDefault() => Observable.Range(1, N).ElementAtOrDefault(N - 1).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/FirstLastSingleAsyncBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/FirstLastSingleAsyncBenchmarks.cs new file mode 100644 index 0000000000..203916cdb2 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/FirstLastSingleAsyncBenchmarks.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// S1: the non-blocking *Async element selectors (each returns a single-value observable). + /// First* short-circuits on the first element; Last* drains the stream; Single* uses a + /// predicate matching exactly one element (so it must scan the whole stream to confirm uniqueness). + /// + [BenchmarkCategory("Aggregates")] + public class FirstLastSingleAsyncBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void FirstAsync() => Observable.Range(1, N).FirstAsync().SubscribeConsume(Consumer); + + [Benchmark] + public void FirstOrDefaultAsync() => Observable.Range(1, N).FirstOrDefaultAsync().SubscribeConsume(Consumer); + + [Benchmark] + public void LastAsync() => Observable.Range(1, N).LastAsync().SubscribeConsume(Consumer); + + [Benchmark] + public void LastOrDefaultAsync() => Observable.Range(1, N).LastOrDefaultAsync().SubscribeConsume(Consumer); + + [Benchmark] + public void SingleAsync() => Observable.Range(1, N).SingleAsync(static v => v == 1).SubscribeConsume(Consumer); + + [Benchmark] + public void SingleOrDefaultAsync() => Observable.Range(1, N).SingleOrDefaultAsync(static v => v == 1).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/IsEmptyLongCountBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/IsEmptyLongCountBenchmarks.cs new file mode 100644 index 0000000000..47bbcaddce --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/IsEmptyLongCountBenchmarks.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// S1: IsEmpty (short-circuits on the first element) and LongCount (drains the stream). + [BenchmarkCategory("Aggregates")] + public class IsEmptyLongCountBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void IsEmpty() => Observable.Range(1, N).IsEmpty().SubscribeConsume(Consumer); + + [Benchmark] + public void LongCount() => Observable.Range(1, N).LongCount().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/MinByMaxByBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/MinByMaxByBenchmarks.cs new file mode 100644 index 0000000000..2ce3fee2c7 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/MinByMaxByBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// S1: MinBy/MaxBy keep a running list of the elements sharing the current best key (a keyed + /// key-selector plus list churn), returning an IList on completion. + /// + [BenchmarkCategory("Aggregates")] + public class MinByMaxByBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void MinBy() => Observable.Range(1, N).MinBy(static v => v % 256).SubscribeConsume(Consumer); + + [Benchmark] + public void MaxBy() => Observable.Range(1, N).MaxBy(static v => v % 256).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/MinMaxAverageBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/MinMaxAverageBenchmarks.cs new file mode 100644 index 0000000000..c4cf8f6747 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/MinMaxAverageBenchmarks.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// S1: the numeric aggregates. Streaming OnNext accumulators, so — as noted in the plan — they are + /// not SIMD candidates (vectorization would need a materialized array); a useful reference point. + /// + [BenchmarkCategory("Aggregates")] + public class MinMaxAverageBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Min() => Observable.Range(1, N).Min().SubscribeConsume(Consumer); + + [Benchmark] + public void Max() => Observable.Range(1, N).Max().SubscribeConsume(Consumer); + + [Benchmark] + public void Average() => Observable.Range(1, N).Average().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/SequenceEqualBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/SequenceEqualBenchmarks.cs new file mode 100644 index 0000000000..dc306e545a --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/SequenceEqualBenchmarks.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// S1: SequenceEqual compares two streams element-by-element; the equal inputs force a full scan. + [BenchmarkCategory("Aggregates")] + public class SequenceEqualBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void SequenceEqual() => + Observable.Range(1, N).SequenceEqual(Observable.Range(1, N)).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/SumBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/SumBenchmarks.cs new file mode 100644 index 0000000000..34a689ad8c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/SumBenchmarks.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// Aggregates-category exemplar (S1): Sum — a scalar streaming accumulate that emits on completion. + /// Projected to long to avoid int overflow at large N. (A reference point for the SIMD + /// discussion: streaming accumulators cannot be vectorized.) + /// + [BenchmarkCategory("Aggregates")] + public class SumBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Sum() => Observable.Range(1, N).Sum(x => (long)x).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ToDictionaryToLookupBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ToDictionaryToLookupBenchmarks.cs new file mode 100644 index 0000000000..5199c6dd86 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ToDictionaryToLookupBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// S1 materialization into keyed collections: ToDictionary (one entry per unique key) and + /// ToLookup (grouped by key). Both buffer the whole stream — allocation candidates. + /// + [BenchmarkCategory("Aggregates")] + public class ToDictionaryToLookupBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ToDictionary() => Observable.Range(1, N).ToDictionary(static v => v).SubscribeConsume(Consumer); + + [Benchmark] + public void ToLookup() => Observable.Range(1, N).ToLookup(static v => v % 8).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ToListToArrayBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ToListToArrayBenchmarks.cs new file mode 100644 index 0000000000..ad26a3c19b --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Aggregates/ToListToArrayBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Aggregates +{ + /// + /// S1 materialization: ToList and ToArray buffer the whole stream. Prime zero-allocation + /// candidates — ToArray in particular does List-doubling growth plus a final exact-size copy. + /// + [BenchmarkCategory("Aggregates")] + public class ToListToArrayBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ToList() => Observable.Range(1, N).ToList().SubscribeConsume(Consumer); + + [Benchmark] + public void ToArray() => Observable.Range(1, N).ToArray().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Async/AsyncFactoryBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Async/AsyncFactoryBenchmarks.cs new file mode 100644 index 0000000000..206b625770 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Async/AsyncFactoryBenchmarks.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Threading.Tasks; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Async +{ + /// + /// The async factory bridges (single-shot, so no N sweep — this measures the subscription + async-completion + /// cost): FromAsync, Start, StartAsync, ToAsync. (FromAsyncPattern relies on + /// delegate BeginInvoke, which is unsupported on modern .NET, so it is not benchmarked.) + /// + [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 3, iterationCount: 10)] // async completion latency → repeatable job, not the auto-tuned default + [BenchmarkCategory("Async")] + public class AsyncFactoryBenchmarks + { + private readonly Consumer _consumer = new(); + + [Benchmark] + public void FromAsync() => Observable.FromAsync(static () => Task.FromResult(1)).SubscribeBlocking(_consumer); + + [Benchmark] + public void Start() => Observable.Start(static () => 1).SubscribeBlocking(_consumer); + + [Benchmark] + public void StartAsync() => Observable.StartAsync(static () => Task.FromResult(1)).SubscribeBlocking(_consumer); + + [Benchmark] + public void ToAsync() => Observable.ToAsync(static () => 1)().SubscribeBlocking(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Async/FromAsyncPatternBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Async/FromAsyncPatternBenchmarks.cs new file mode 100644 index 0000000000..7de77f2bdd --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Async/FromAsyncPatternBenchmarks.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; +using System.Threading; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +#pragma warning disable CS0618 // FromAsyncPattern is obsolete; benchmarked intentionally with a hand-rolled IAsyncResult. + +namespace Benchmarks.System.Reactive.Operators.Async +{ + /// + /// FromAsyncPattern bridges the APM (Begin/End) pattern to an observable. The common + /// delegate-BeginInvoke form is unsupported on modern .NET, so this uses a hand-rolled, synchronously + /// completing (which works on every target framework). + /// + [BenchmarkCategory("Async")] + public class FromAsyncPatternBenchmarks + { + private readonly Consumer _consumer = new(); + private Func> _invoke = default!; + + [GlobalSetup] + public void Setup() => _invoke = Observable.FromAsyncPattern(BeginSynchronously, static _ => 1); + + [Benchmark] + public void FromAsyncPattern() => _invoke().SubscribeConsume(_consumer); + + private static IAsyncResult BeginSynchronously(AsyncCallback callback, object state) + { + var result = new ImmediateAsyncResult(state); + callback?.Invoke(result); + return result; + } + + private sealed class ImmediateAsyncResult : IAsyncResult + { + private ManualResetEvent _handle; + + public ImmediateAsyncResult(object state) => AsyncState = state; + + public object AsyncState { get; } + + public WaitHandle AsyncWaitHandle => _handle ??= new ManualResetEvent(true); + + public bool CompletedSynchronously => true; + + public bool IsCompleted => true; + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Awaiter/AwaiterBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Awaiter/AwaiterBenchmarks.cs new file mode 100644 index 0000000000..6733d1ccf5 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Awaiter/AwaiterBenchmarks.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Threading; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Awaiter +{ + /// + /// Await support: RunAsync and GetAwaiter both project the sequence to its final element via an + /// AsyncSubject; blocking on the result drains the whole stream. + /// + [BenchmarkCategory("Awaiter")] + public class AwaiterBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public int RunAsync() => Observable.Range(1, N).RunAsync(CancellationToken.None).GetAwaiter().GetResult(); + + [Benchmark] + public int GetAwaiter() => Observable.Range(1, N).GetAwaiter().GetResult(); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/LateSubscriberBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/LateSubscriberBenchmarks.cs new file mode 100644 index 0000000000..857a4c1a98 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/LateSubscriberBenchmarks.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Binding +{ + /// + /// Multicast scenarios the "all-subscribers-before-Connect" benchmarks miss: a Replay subscriber that + /// attaches after the buffer has filled (so it exercises catch-up replay — Replay's whole point), and a + /// RefCount subscribe/dispose/re-subscribe churn (0→1→0→1 connect/disconnect). + /// + [BenchmarkCategory("Binding")] + public class LateSubscriberBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Replay_LateSubscriber() + { + var source = new Subject(); + var replayed = source.Replay(); + using var connection = replayed.Connect(); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + source.OnNext(i); // fills the replay buffer while there are no subscribers + } + + using (replayed.SubscribeConsume(_consumer)) // late subscriber → replays the buffered half, then goes live + { + for (var i = half; i < N; i++) + { + source.OnNext(i); + } + + source.OnCompleted(); + } + } + + [Benchmark] + public void RefCount_Churn() + { + var source = new Subject(); + var shared = source.Publish().RefCount(); + + for (var round = 0; round < N; round++) + { + using (shared.SubscribeConsume(_consumer)) // first subscriber connects; dispose disconnects + { + source.OnNext(round); + } + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/MulticastAutoConnectBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/MulticastAutoConnectBenchmarks.cs new file mode 100644 index 0000000000..8de1cd4915 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/MulticastAutoConnectBenchmarks.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Binding +{ + /// + /// S2: Multicast shares a source through an explicit subject; AutoConnect connects a published + /// source automatically once the required number of subscribers attach. + /// + [BenchmarkCategory("Binding")] + public class MulticastAutoConnectBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Multicast() + { + var connectable = Observable.Range(1, N).Multicast(new Subject()); + using (connectable.SubscribeConsume(Consumer)) + using (connectable.Connect()) + { + } + } + + [Benchmark] + public void AutoConnect() => Observable.Range(1, N).Publish().AutoConnect(1).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/PublishBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/PublishBenchmarks.cs new file mode 100644 index 0000000000..632e18512b --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/PublishBenchmarks.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Binding +{ + /// + /// Multicast-category exemplar (S2): Publish fans a single cold source out to M subscribers via + /// Connect(). Sweeps subscriber count to expose the multicast dispatch cost. + /// + [BenchmarkCategory("Binding")] + public class PublishBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + [Params(1, 2, 5)] + public int Subscribers; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Publish() + { + var connectable = Observable.Range(1, N).Publish(); + + var subscriptions = new IDisposable[Subscribers]; + for (var i = 0; i < Subscribers; i++) + { + subscriptions[i] = connectable.SubscribeConsume(_consumer); + } + + using (connectable.Connect()) // drives the source synchronously to all subscribers + { + foreach (var subscription in subscriptions) + { + subscription.Dispose(); + } + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/PublishLastBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/PublishLastBenchmarks.cs new file mode 100644 index 0000000000..6034a798b3 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/PublishLastBenchmarks.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Binding +{ + /// S2: PublishLast (AsyncSubject-backed) multicasts only the final element on completion. + [BenchmarkCategory("Binding")] + public class PublishLastBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void PublishLast() + { + var connectable = Observable.Range(1, N).PublishLast(); + using (connectable.SubscribeConsume(Consumer)) + using (connectable.Connect()) + { + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/RefCountBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/RefCountBenchmarks.cs new file mode 100644 index 0000000000..677b892ca8 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/RefCountBenchmarks.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Binding +{ + /// + /// S2: Publish().RefCount() connects on the first subscription and disconnects when the last drops. + /// The single synchronous subscription connects, drains, and auto-disconnects on completion. + /// + [BenchmarkCategory("Binding")] + public class RefCountBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void RefCount() => Observable.Range(1, N).Publish().RefCount().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/ReplayBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/ReplayBenchmarks.cs new file mode 100644 index 0000000000..799014c84b --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Binding/ReplayBenchmarks.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Binding +{ + /// + /// S2 multicast with replay: Replay buffers past elements for late subscribers. A prime allocation + /// candidate (the replay buffer). BufferSize == 0 replays the entire stream; otherwise a bounded window. + /// + [BenchmarkCategory("Binding")] + public class ReplayBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + [Params(1, 5)] + public int Subscribers; + + [Params(0, 64)] + public int BufferSize; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Replay() + { + var source = Observable.Range(1, N); + var connectable = BufferSize == 0 ? source.Replay() : source.Replay(BufferSize); + + var subscriptions = new IDisposable[Subscribers]; + for (var i = 0; i < Subscribers; i++) + { + subscriptions[i] = connectable.SubscribeConsume(_consumer); + } + + using (connectable.Connect()) + { + foreach (var subscription in subscriptions) + { + subscription.Dispose(); + } + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/BlockingBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/BlockingBenchmarks.cs new file mode 100644 index 0000000000..580fa83ce9 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/BlockingBenchmarks.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Blocking +{ + /// + /// Blocking-category exemplar: Wait blocks the calling thread until the (finite) sequence completes + /// and returns its last element. Driven by a synchronous Range, so it completes without real waiting. + /// + [BenchmarkCategory("Blocking")] + public class BlockingBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public int Wait() => Observable.Range(1, N).Wait(); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/BlockingPullBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/BlockingPullBenchmarks.cs new file mode 100644 index 0000000000..f14155b145 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/BlockingPullBenchmarks.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +#pragma warning disable CS0618 // The blocking operators are obsolete (callers are steered to async); benchmarked intentionally. + +namespace Benchmarks.System.Reactive.Operators.Blocking +{ + /// + /// The remaining blocking gate/pull operators: Single (predicate matching exactly one, so it scans the + /// whole stream), FirstOrDefault/LastOrDefault, and the pull GetEnumerator. (Latest/ + /// MostRecent/Next/Chunkify are designed for hot sources and are exercised via the workload + /// harness rather than here.) + /// + [BenchmarkCategory("Blocking")] + public class BlockingPullBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public int Single() => Observable.Range(1, N).Single(static v => v == 1); + + [Benchmark] + public int SingleOrDefault() => Observable.Range(1, N).SingleOrDefault(static v => v == 1); + + [Benchmark] + public int FirstOrDefault() => Observable.Range(1, N).FirstOrDefault(); + + [Benchmark] + public int LastOrDefault() => Observable.Range(1, N).LastOrDefault(); + + [Benchmark] + public void GetEnumerator() + { + using var enumerator = Observable.Range(1, N).GetEnumerator(); + while (enumerator.MoveNext()) + { + Consumer.Consume(enumerator.Current); + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/FirstLastBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/FirstLastBenchmarks.cs new file mode 100644 index 0000000000..95d00e68b0 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/FirstLastBenchmarks.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +#pragma warning disable CS0618 // The blocking First/Last are obsolete (callers are steered to *Async); benchmarked intentionally. + +namespace Benchmarks.System.Reactive.Operators.Blocking +{ + /// + /// Blocking: First (returns as soon as the first element arrives) and Last (drains the whole + /// sequence). Both block the calling thread; the synchronous source resolves them without real waiting. + /// + [BenchmarkCategory("Blocking")] + public class FirstLastBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public int First() => Observable.Range(1, N).First(); + + [Benchmark] + public int Last() => Observable.Range(1, N).Last(); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/ForEachBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/ForEachBenchmarks.cs new file mode 100644 index 0000000000..a01a731c15 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/ForEachBenchmarks.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +#pragma warning disable CS0618 // Observable.ForEach is obsolete (callers are steered to async); benchmarked intentionally. + +namespace Benchmarks.System.Reactive.Operators.Blocking +{ + /// Blocking: ForEach invokes a callback per element and blocks until the sequence completes. + [BenchmarkCategory("Blocking")] + public class ForEachBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ForEach() => Observable.Range(1, N).ForEach(v => Consumer.Consume(v)); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/HotPullBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/HotPullBenchmarks.cs new file mode 100644 index 0000000000..97ab4b2a4a --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Blocking/HotPullBenchmarks.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +namespace Benchmarks.System.Reactive.Operators.Blocking +{ + /// + /// The hot-source blocking-pull operators, driven by a pumped alongside the pull. + /// Latest/MostRecent/Chunkify pull deterministically on the producing thread; Next + /// requires a concurrent producer (it only captures values that arrive while a pull is blocked). These are + /// inherently timing-dependent, so expect higher variance than the cold-source benchmarks. N is capped. + /// + [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 3, iterationCount: 10)] // timing-dependent (Next races a producer) → repeatable job + [BenchmarkCategory("Blocking")] + public class HotPullBenchmarks + { + [Params(1_000, 10_000)] + public int N; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Latest() + { + var subject = new Subject(); + using var enumerator = subject.Latest().GetEnumerator(); + for (var i = 0; i < N; i++) + { + subject.OnNext(i); + enumerator.MoveNext(); + _consumer.Consume(enumerator.Current); + } + + subject.OnCompleted(); + } + + [Benchmark] + public void MostRecent() + { + var subject = new Subject(); + using var enumerator = subject.MostRecent(0).GetEnumerator(); + for (var i = 0; i < N; i++) + { + subject.OnNext(i); + enumerator.MoveNext(); + _consumer.Consume(enumerator.Current); + } + + subject.OnCompleted(); + } + + [Benchmark] + public void Chunkify() + { + var subject = new Subject(); + using var enumerator = subject.Chunkify().GetEnumerator(); + for (var i = 0; i < N; i++) + { + subject.OnNext(i); + if ((i & 15) == 0) + { + enumerator.MoveNext(); + _consumer.Consume(enumerator.Current); + } + } + + subject.OnCompleted(); + } + + [Benchmark] + public void Next() + { + var subject = new Subject(); + var n = N; + var pump = Task.Run(() => + { + for (var i = 0; i < n; i++) + { + subject.OnNext(i); + } + + subject.OnCompleted(); + }); + + foreach (var value in subject.Next()) + { + _consumer.Consume(value); + } + + pump.Wait(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/ObserveOnBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/ObserveOnBenchmarks.cs new file mode 100644 index 0000000000..fb9a4e64bf --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/ObserveOnBenchmarks.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Concurrency +{ + /// + /// Concurrency-category exemplar (async S1): ObserveOn marshals every element onto another scheduler. + /// The benchmark blocks until completion via SubscribeBlocking. N is capped because each element + /// crosses a thread boundary. + /// + [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 3, iterationCount: 10)] // thread-crossing → repeatable job, not the auto-tuned default + [BenchmarkCategory("Concurrency")] + public class ObserveOnBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + private readonly Consumer _consumer = new(); + private EventLoopScheduler _scheduler = default!; + private EventLoopScheduler _scheduler2 = default!; + + [GlobalSetup] + public void Setup() + { + _scheduler = new EventLoopScheduler(); + _scheduler2 = new EventLoopScheduler(); + } + + [GlobalCleanup] + public void Cleanup() + { + _scheduler.Dispose(); + _scheduler2.Dispose(); + } + + [Benchmark] + public void ObserveOn() => Observable.Range(1, N).ObserveOn(_scheduler).SubscribeBlocking(_consumer); + + [Benchmark] + public void SubscribeOn_ObserveOn() => + Observable.Range(1, N).SubscribeOn(_scheduler).ObserveOn(_scheduler2).SubscribeBlocking(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/SubscribeOnBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/SubscribeOnBenchmarks.cs new file mode 100644 index 0000000000..97c2b6f574 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/SubscribeOnBenchmarks.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Concurrency +{ + /// + /// Concurrency (async S1): SubscribeOn moves the subscription (and thus the synchronous source's + /// execution) onto another scheduler. Blocks until completion via SubscribeBlocking. + /// + [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 3, iterationCount: 10)] // thread-crossing → repeatable job, not the auto-tuned default + [BenchmarkCategory("Concurrency")] + public class SubscribeOnBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + private readonly Consumer _consumer = new(); + private EventLoopScheduler _scheduler = default!; + + [GlobalSetup] + public void Setup() => _scheduler = new EventLoopScheduler(); + + [GlobalCleanup] + public void Cleanup() => _scheduler.Dispose(); + + [Benchmark] + public void SubscribeOn() => Observable.Range(1, N).SubscribeOn(_scheduler).SubscribeBlocking(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/SynchronizeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/SynchronizeBenchmarks.cs new file mode 100644 index 0000000000..74955d9b32 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Concurrency/SynchronizeBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Concurrency +{ + /// Concurrency (S1): Synchronize serializes notifications behind a gate — a per-notification lock cost. + [BenchmarkCategory("Concurrency")] + public class SynchronizeBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Synchronize() => Observable.Range(1, N).Synchronize().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToEnumerableBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToEnumerableBenchmarks.cs new file mode 100644 index 0000000000..80bf70617a --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToEnumerableBenchmarks.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Conversions +{ + /// + /// Conversion-category exemplar: ToEnumerable converts a push sequence to a blocking pull sequence; + /// the benchmark iterates it to completion. + /// + [BenchmarkCategory("Conversions")] + public class ToEnumerableBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ToEnumerable() + { + foreach (var value in Observable.Range(1, N).ToEnumerable()) + { + Consumer.Consume(value); + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToEventBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToEventBenchmarks.cs new file mode 100644 index 0000000000..5db14e6b2c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToEventBenchmarks.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Conversions +{ + /// + /// Conversions bridging to .NET events and pull sources: ToEvent / ToEventPattern expose an + /// observable as an event (attaching the handler drives the synchronous source), and the + /// IEnumerable.Subscribe overload pushes a pull sequence to an observer. + /// + [BenchmarkCategory("Conversions")] + public class ToEventBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ToEvent() + { + var source = Observable.Range(1, N).ToEvent(); + void Handler(int value) => Consumer.Consume(value); + source.OnNext += Handler; // attaching subscribes; the synchronous source runs to completion here + source.OnNext -= Handler; + } + + [Benchmark] + public void ToEventPattern() + { + var source = Observable.Range(1, N) + .Select(_ => new EventPattern(this, EventArgs.Empty)) + .ToEventPattern(); + void Handler(object sender, EventArgs e) => Consumer.Consume(e); + source.OnNext += Handler; + source.OnNext -= Handler; + } + + [Benchmark] + public void EnumerableSubscribe() => + Enumerable.Range(1, N).Subscribe(new ConsumingObserver(Consumer)); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToObservableBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToObservableBenchmarks.cs new file mode 100644 index 0000000000..dd6d4c157b --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToObservableBenchmarks.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Conversions +{ + /// S1: ToObservable bridges a pull sequence (IEnumerable) into a push sequence. + [BenchmarkCategory("Conversions")] + public class ToObservableBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ToObservable() => Enumerable.Range(1, N).ToObservable().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToTaskBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToTaskBenchmarks.cs new file mode 100644 index 0000000000..2d884e6464 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Conversions/ToTaskBenchmarks.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Conversions +{ + /// + /// S1: ToTask bridges a sequence to a Task of its final element. The synchronous source + /// completes immediately, so the task is already resolved when awaited. + /// + [BenchmarkCategory("Conversions")] + public class ToTaskBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public int ToTask() => Observable.Range(1, N).ToTask().GetAwaiter().GetResult(); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/CreateBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/CreateBenchmarks.cs new file mode 100644 index 0000000000..94f8144428 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/CreateBenchmarks.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// S1: Observable.Create — the primitive factory, pushing N elements from a user callback. + [BenchmarkCategory("Creation")] + public class CreateBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Create() + { + var n = N; + Observable.Create(observer => + { + for (var i = 0; i < n; i++) + { + observer.OnNext(i); + } + + observer.OnCompleted(); + return Disposable.Empty; + }).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/DeferAsyncBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/DeferAsyncBenchmarks.cs new file mode 100644 index 0000000000..d7bebd3339 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/DeferAsyncBenchmarks.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Threading.Tasks; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// S1: DeferAsync builds the sequence lazily via an async factory, once per subscription. + [BenchmarkCategory("Creation")] + public class DeferAsyncBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void DeferAsync() + { + var n = N; + Observable.DeferAsync(_ => Task.FromResult(Observable.Range(1, n))).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/DeferBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/DeferBenchmarks.cs new file mode 100644 index 0000000000..4e72673cfe --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/DeferBenchmarks.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// S1: Defer builds the underlying sequence lazily, once per subscription. + [BenchmarkCategory("Creation")] + public class DeferBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Defer() + { + var n = N; + Observable.Defer(() => Observable.Range(1, n)).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/GenerateBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/GenerateBenchmarks.cs new file mode 100644 index 0000000000..38b41141aa --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/GenerateBenchmarks.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// S1: the synchronous (untimed) Generate unfold. (The timed overload lives under Operators/Time.) + [BenchmarkCategory("Creation")] + public class GenerateBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Generate() + { + var n = N; + Observable.Generate(0, i => i < n, static i => i + 1, static i => i).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/RangeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/RangeBenchmarks.cs new file mode 100644 index 0000000000..be1e921581 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/RangeBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// Creation-category exemplar (S1): Observable.Range — the canonical synchronous source. + [BenchmarkCategory("Creation")] + public class RangeBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Range() => Observable.Range(1, N).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/RepeatBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/RepeatBenchmarks.cs new file mode 100644 index 0000000000..8ada78bf0f --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/RepeatBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// S1: Repeat(value, count) emits a constant N times. + [BenchmarkCategory("Creation")] + public class RepeatBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Repeat() => Observable.Repeat(1, N).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/ScalarCreationBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/ScalarCreationBenchmarks.cs new file mode 100644 index 0000000000..bcc875ba08 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/ScalarCreationBenchmarks.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// + /// Subscription-cost (S4) exemplars for the scalar factories, which ignore N: Return (one element), + /// Empty (immediate completion), Throw (immediate error). Measures the per-subscription + /// construct + terminate cost. + /// + [BenchmarkCategory("Creation")] + public class ScalarCreationBenchmarks + { + private readonly Consumer _consumer = new(); + private readonly Exception _error = new InvalidOperationException("benchmark"); + + [Benchmark] + public void Return() => Observable.Return(1).SubscribeConsume(_consumer); + + [Benchmark] + public void Empty() => Observable.Empty().SubscribeConsume(_consumer); + + [Benchmark] + public void Throw() => Observable.Throw(_error).SubscribeConsume(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/UsingBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/UsingBenchmarks.cs new file mode 100644 index 0000000000..f623bfe62c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Creation/UsingBenchmarks.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Disposables; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Creation +{ + /// + /// Using binds a disposable resource's lifetime to the subscription. Using_Error drives a faulting + /// source so the resource is disposed on the error path. + /// + [BenchmarkCategory("Creation")] + public class UsingBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Using() + { + var n = N; + Observable.Using(static () => Disposable.Empty, _ => Observable.Range(1, n)).SubscribeConsume(Consumer); + } + + [Benchmark] + public void Using_Error() + { + var n = N; + Observable.Using(static () => Disposable.Empty, _ => FaultingSource.Faulting(n)).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Events/FromEventBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Events/FromEventBenchmarks.cs new file mode 100644 index 0000000000..de45c15526 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Events/FromEventBenchmarks.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Events +{ + /// + /// Event bridging: FromEvent / FromEventPattern expose a .NET event as an observable. The + /// immediate scheduler is used so the handler is attached synchronously, then the event is raised N times. + /// + [BenchmarkCategory("Events")] + public class FromEventBenchmarks : OperatorBenchmarkBase + { + private event Action Plain; + + private event EventHandler Pattern; + + [Benchmark] + public void FromEvent() + { + using (Observable.FromEvent(h => Plain += h, h => Plain -= h, ImmediateScheduler.Instance).SubscribeConsume(Consumer)) + { + var n = N; + for (var i = 0; i < n; i++) + { + Plain?.Invoke(i); + } + } + } + + [Benchmark] + public void FromEventPattern() + { + using (Observable.FromEventPattern(h => Pattern += h, h => Pattern -= h, ImmediateScheduler.Instance).SubscribeConsume(Consumer)) + { + var n = N; + for (var i = 0; i < n; i++) + { + Pattern?.Invoke(this, i); + } + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Hot/HotSourceBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Hot/HotSourceBenchmarks.cs new file mode 100644 index 0000000000..218ebdc31b --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Hot/HotSourceBenchmarks.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Hot +{ + /// + /// Hot-push (S2) variants of the core transformation operators — driven by pumping a + /// rather than a cold Observable.Range. This exercises the real push path (per-element OnNext + /// dispatch, re-entrancy) with none of Range's synchronous fast-path. + /// + [BenchmarkCategory("Hot")] + public class HotSourceBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Select_Hot() + { + var source = new Subject(); + source.Select(static v => v + 1).SubscribeConsume(_consumer); + Pump(source); + } + + [Benchmark] + public void Where_Hot() + { + var source = new Subject(); + source.Where(static v => (v & 1) == 0).SubscribeConsume(_consumer); + Pump(source); + } + + [Benchmark] + public void SelectMany_Hot() + { + var source = new Subject(); + source.SelectMany(static v => Observable.Return(v)).SubscribeConsume(_consumer); + Pump(source); + } + + [Benchmark] + public void Scan_Hot() + { + var source = new Subject(); + source.Scan(0L, static (acc, v) => acc + v).SubscribeConsume(_consumer); + Pump(source); + } + + [Benchmark] + public void GroupBy_Hot() + { + var source = new Subject(); + source.GroupBy(static v => v % 8).SelectMany(static g => g).SubscribeConsume(_consumer); + Pump(source); + } + + [Benchmark] + public void Merge_Hot() + { + var a = new Subject(); + var b = new Subject(); + Observable.Merge(a, b).SubscribeConsume(_consumer); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + a.OnNext(i); + b.OnNext(i); + } + + a.OnCompleted(); + b.OnCompleted(); + } + + private void Pump(Subject source) + { + var n = N; + for (var i = 0; i < n; i++) + { + source.OnNext(i); + } + + source.OnCompleted(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Hot/SubjectBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Hot/SubjectBenchmarks.cs new file mode 100644 index 0000000000..80002df090 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Hot/SubjectBenchmarks.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Subjects; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Hot +{ + /// + /// S2: raw subject push across the subject variants, with M concurrent subscribers + /// (0 = dispatch to nobody, the cheapest path; 5 = the multi-subscriber array dispatch). + /// Complements , which pumps operators through a single subject. + /// + [BenchmarkCategory("Hot")] + public class SubjectBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + [Params(0, 1, 5)] + public int M; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Subject_Push() => Push(new Subject()); + + [Benchmark] + public void AsyncSubject_Push() => Push(new AsyncSubject()); + + [Benchmark] + public void BehaviorSubject_Push() => Push(new BehaviorSubject(-1)); + + [Benchmark] + public void ReplaySubject_Push() => Push(new ReplaySubject()); + + private void Push(TSubject subject) + where TSubject : ISubject + { + var m = M; + var subscriptions = new IDisposable[m]; + for (var i = 0; i < m; i++) + { + subscriptions[i] = subject.SubscribeConsume(_consumer); + } + + var n = N; + for (var i = 0; i < n; i++) + { + subject.OnNext(i); + } + + subject.OnCompleted(); + + for (var i = 0; i < m; i++) + { + subscriptions[i].Dispose(); + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/CaseForEachAsyncBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/CaseForEachAsyncBenchmarks.cs new file mode 100644 index 0000000000..7349daf072 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/CaseForEachAsyncBenchmarks.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Imperative +{ + /// + /// Imperative: Case selects a source from a key→source map at subscription time; ForEachAsync is + /// the Task-returning blocking iteration (which is why this class uses a repeatable Monitoring job). + /// + [SimpleJob(RunStrategy.Monitoring, launchCount: 1, warmupCount: 3, iterationCount: 10)] + [BenchmarkCategory("Imperative")] + public class CaseForEachAsyncBenchmarks : OperatorBenchmarkBase + { + private Dictionary> _cases = default!; + + [GlobalSetup] + public void Setup() => _cases = new Dictionary> { [0] = Observable.Range(1, N) }; + + [Benchmark] + public void Case() => Observable.Case(static () => 0, _cases).SubscribeConsume(Consumer); + + [Benchmark] + public void ForEachAsync() => Observable.Range(1, N).ForEachAsync(v => Consumer.Consume(v)).GetAwaiter().GetResult(); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/IfBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/IfBenchmarks.cs new file mode 100644 index 0000000000..6eb3f66d6f --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/IfBenchmarks.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Imperative +{ + /// + /// Imperative-combinator-category exemplar (S1): Observable.If chooses a source from a predicate at + /// subscription time, then relays it. + /// + [BenchmarkCategory("Imperative")] + public class IfBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void If() => Observable.If(static () => true, Observable.Range(1, N)).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/ImperativeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/ImperativeBenchmarks.cs new file mode 100644 index 0000000000..feb051f22f --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Imperative/ImperativeBenchmarks.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Imperative +{ + /// + /// S1 imperative combinators: For (concatenate a projected sequence per source item), and the + /// condition-driven loops While / DoWhile. (If is covered by IfBenchmarks.) + /// + [BenchmarkCategory("Imperative")] + public class ImperativeBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void For() => + Observable.For(Enumerable.Range(1, N), static i => Observable.Return(i)).SubscribeConsume(Consumer); + + [Benchmark] + public void While() + { + var i = 0; + var n = N; + Observable.While(() => i++ < n, Observable.Return(1)).SubscribeConsume(Consumer); + } + + [Benchmark] + public void DoWhile() + { + var i = 0; + var n = N; + Observable.DoWhile(Observable.Return(1), () => ++i < n).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Joins/AndThenWhenBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Joins/AndThenWhenBenchmarks.cs new file mode 100644 index 0000000000..370ffd2e12 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Joins/AndThenWhenBenchmarks.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +namespace Benchmarks.System.Reactive.Operators.Joins +{ + /// + /// Join-patterns-category exemplar (S1): And/Then/When (the Rx join calculus) pairs + /// elements from two sources. N is capped because unmatched elements are queued (O(N) memory). + /// + [BenchmarkCategory("Joins")] + public class AndThenWhenBenchmarks + { + [Params(100, 1_000, 10_000, 100_000)] + public int N; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void When() + { + var left = Observable.Range(1, N); + var right = Observable.Range(1, N); + + Observable.When(left.And(right).Then(static (a, b) => a + b)) + .Subscribe(new Infrastructure.ConsumingObserver(_consumer)); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/ConstructionBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/ConstructionBenchmarks.cs new file mode 100644 index 0000000000..2aa2c6e9a0 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/ConstructionBenchmarks.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Lifecycle +{ + /// + /// Decomposes a pipeline's cost into construction vs subscription+drain (the legacy Prepend_Create/_Subscribe + /// technique). _Full (baseline) does everything; _Create only builds the operator chain (returns + /// it unsubscribed); _Subscribe subscribes and drains a pipeline pre-built in [GlobalSetup]. The + /// Ratio isolates producer-construction from per-subscription sink allocation + per-element cost. + /// + [BenchmarkCategory("Lifecycle")] + public class ConstructionBenchmarks : OperatorBenchmarkBase + { + private IObservable _prebuilt = default!; + + [GlobalSetup] + public void Setup() => _prebuilt = Observable.Range(1, N).Select(static v => v + 1); + + [Benchmark(Baseline = true)] + public void Select_Full() => Observable.Range(1, N).Select(static v => v + 1).SubscribeConsume(Consumer); + + [Benchmark] + public IObservable Select_Create() => Observable.Range(1, N).Select(static v => v + 1); + + [Benchmark] + public void Select_Subscribe() => _prebuilt.SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/DisposeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/DisposeBenchmarks.cs new file mode 100644 index 0000000000..29fde6064a --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/DisposeBenchmarks.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Lifecycle +{ + /// + /// Lifecycle: tears down a still-live subscription mid-stream — the hot Subject sources never + /// complete, so the using Dispose() at method end lands on an active subscription. This exercises + /// the unsubscribe/cleanup path of operators with non-trivial disposal, which every cold-source benchmark + /// misses (their sources complete synchronously before Dispose is ever reached). + /// + [BenchmarkCategory("Lifecycle")] + public class DisposeBenchmarks + { + [Params(1_000, 10_000, 100_000)] + public int N; + + private readonly Consumer _consumer = new(); + + [Benchmark] + public void Merge_Dispose() + { + var a = new Subject(); + var b = new Subject(); + using var subscription = Observable.Merge(a, b).SubscribeConsume(_consumer); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + a.OnNext(i); + b.OnNext(i); + } + } + + [Benchmark] + public void CombineLatest_Dispose() + { + var a = new Subject(); + var b = new Subject(); + using var subscription = Observable.CombineLatest(a, b, static (x, y) => x + y).SubscribeConsume(_consumer); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + a.OnNext(i); + b.OnNext(i); + } + } + + [Benchmark] + public void Switch_Dispose() + { + var outer = new Subject>(); + var inner = new Subject(); + using var subscription = outer.Switch().SubscribeConsume(_consumer); + outer.OnNext(inner); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + inner.OnNext(i); + } + } + + [Benchmark] + public void GroupBy_Dispose() + { + var source = new Subject(); + using var subscription = source.GroupBy(static v => v % 8).SelectMany(static g => g).SubscribeConsume(_consumer); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + source.OnNext(i); + } + } + + [Benchmark] + public void Window_Dispose() + { + var source = new Subject(); + using var subscription = source.Window(16).SelectMany(static w => w).SubscribeConsume(_consumer); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + source.OnNext(i); + } + } + + [Benchmark] + public void Publish_Dispose() + { + var source = new Subject(); + var published = source.Publish(); + using var subscription = published.SubscribeConsume(_consumer); + using var connection = published.Connect(); + + var half = N / 2; + for (var i = 0; i < half; i++) + { + source.OnNext(i); + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/StableCompositeDisposableBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/StableCompositeDisposableBenchmarks.cs similarity index 81% rename from Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/StableCompositeDisposableBenchmark.cs rename to Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/StableCompositeDisposableBenchmarks.cs index 7bdd5e2b06..681c68ed98 100644 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/StableCompositeDisposableBenchmark.cs +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Lifecycle/StableCompositeDisposableBenchmarks.cs @@ -1,19 +1,23 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT License. // See the LICENSE file in the project root for more information. using System; using System.Collections.Generic; -using System.Linq; using System.Reactive.Disposables; -using System.Reactive.Linq; using System.Threading; + using BenchmarkDotNet.Attributes; -namespace Benchmarks.System.Reactive +namespace Benchmarks.System.Reactive.Operators.Lifecycle { - [MemoryDiagnoser] - public class StableCompositeDisposableBenchmark + /// + /// Construction and disposal cost of over array and list inputs. + /// N is the number of inner disposables (small values dominate real operator usage; 100 probes the + /// scaling of the copy). Benchmarks return the created object so it is consumed, not eliminated. + /// + [BenchmarkCategory("Lifecycle")] + public class StableCompositeDisposableBenchmarks { [Params(3, 4, 5, 6, 7, 8, 9, 10, 100)] public int N; @@ -30,6 +34,7 @@ public void Setup() { _array[i] = Disposable.Empty; } + _list = new List(_array); } @@ -68,7 +73,7 @@ public object Dispose_List() } [Benchmark] - public object Dispose_Trused_Array() + public object Dispose_Trusted_Array() { var scd = CreateTrusted(_array); scd.Dispose(); diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/AmbBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/AmbBenchmarks.cs new file mode 100644 index 0000000000..40025c5d0d --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/AmbBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// S1: Amb races two sources; the synchronous Range wins over Never. + [BenchmarkCategory("Multiple")] + public class AmbBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Amb() => Observable.Range(1, N).Amb(Observable.Never()).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CatchBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CatchBenchmarks.cs new file mode 100644 index 0000000000..11a90b658c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CatchBenchmarks.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// Error-continuation. The plain methods run the happy path (source completes, so the handler/second source is + /// never needed); the _Error methods drive an OnError through, so the handler is actually + /// subscribed — the path that matters for these operators. + /// + [BenchmarkCategory("Multiple")] + public class CatchBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Catch() => Observable.Range(1, N).Catch(Observable.Range(1, N)).SubscribeConsume(Consumer); + + [Benchmark] + public void OnErrorResumeNext() => + Observable.OnErrorResumeNext(Observable.Range(1, N), Observable.Range(1, N)).SubscribeConsume(Consumer); + + [Benchmark] + public void Catch_Error() => + FaultingSource.Faulting(N).Catch(Observable.Range(1, N)).SubscribeConsume(Consumer); + + [Benchmark] + public void OnErrorResumeNext_Error() => + Observable.OnErrorResumeNext(FaultingSource.Faulting(N), Observable.Range(1, N)).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CombineLatestBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CombineLatestBenchmarks.cs new file mode 100644 index 0000000000..01342108fa --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CombineLatestBenchmarks.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S1: CombineLatest emits the combination whenever either source produces, once both have a value. + /// (Reimplemented cleanly — the old benchmark delegated to a unit test and measured its assertions.) + /// + [BenchmarkCategory("Multiple")] + public class CombineLatestBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void CombineLatest() => + Observable.CombineLatest(Observable.Range(1, N), Observable.Range(1, N), static (a, b) => a + b).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CombineLatestManyBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CombineLatestManyBenchmarks.cs new file mode 100644 index 0000000000..620fdfcbcf --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/CombineLatestManyBenchmarks.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S1: N-ary CombineLatest across a varying number of cold sources — the variadic + /// IList-selector overload, whose sink tracks a per-source "has latest" array (a distinct code path + /// from the specialised binary overload in ). Total emitted work is held + /// ~constant (each source emits 1,000,000 / Sources elements) so the sweep isolates the per-source + /// bookkeeping cost rather than raw element volume. + /// + [BenchmarkCategory("Multiple")] + public class CombineLatestManyBenchmarks + { + [Params(2, 4, 8, 16)] + public int Sources; + + private readonly Consumer _consumer = new(); + private IObservable[] _sources = default!; + + [GlobalSetup] + public void Setup() + { + var per = 1_000_000 / Sources; + _sources = new IObservable[Sources]; + for (var i = 0; i < Sources; i++) + { + _sources[i] = Observable.Range(i * per, per); + } + } + + [Benchmark] + public void CombineLatest() => + Observable.CombineLatest(_sources, static (IList values) => values.Count).SubscribeConsume(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ConcatBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ConcatBenchmarks.cs new file mode 100644 index 0000000000..e5f4659a82 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ConcatBenchmarks.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// S1: Concat relays two sources one after the other. + [BenchmarkCategory("Multiple")] + public class ConcatBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Concat() => + Observable.Concat(Observable.Range(1, N), Observable.Range(1, N)).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/InterleavedCombiningBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/InterleavedCombiningBenchmarks.cs new file mode 100644 index 0000000000..cb4fde4fbf --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/InterleavedCombiningBenchmarks.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// The combining operators over two sources that genuinely alternate on one virtual clock (left at + /// step, 2·step, …; right offset by step/2), instead of the cold-Range versions elsewhere where one + /// source drains fully before the other emits. This exercises the real interleaving path — e.g. + /// CombineLatest actually re-combines on each side's change rather than overwriting an unused value. + /// + [BenchmarkCategory("Multiple")] + public class InterleavedCombiningBenchmarks : TemporalBenchmarkBase + { + private IObservable Left(IScheduler scheduler) => + VirtualTimeSource.Timed(scheduler, N, TimeSpan.FromTicks(PeriodTicks)); + + private IObservable Right(IScheduler scheduler) => + VirtualTimeSource.Timed(scheduler, N, TimeSpan.FromTicks(PeriodTicks), TimeSpan.FromTicks(PeriodTicks / 2)); + + [Benchmark] + public void Zip_Interleaved() + { + var scheduler = new HistoricalScheduler(); + Observable.Zip(Left(scheduler), Right(scheduler), static (a, b) => a + b).SubscribeConsume(Consumer); + scheduler.Start(); + } + + [Benchmark] + public void CombineLatest_Interleaved() + { + var scheduler = new HistoricalScheduler(); + Observable.CombineLatest(Left(scheduler), Right(scheduler), static (a, b) => a + b).SubscribeConsume(Consumer); + scheduler.Start(); + } + + [Benchmark] + public void WithLatestFrom_Interleaved() + { + var scheduler = new HistoricalScheduler(); + Left(scheduler).WithLatestFrom(Right(scheduler), static (a, b) => a + b).SubscribeConsume(Consumer); + scheduler.Start(); + } + + [Benchmark] + public void Merge_Interleaved() + { + var scheduler = new HistoricalScheduler(); + Observable.Merge(Left(scheduler), Right(scheduler)).SubscribeConsume(Consumer); + scheduler.Start(); + } + + [Benchmark] + public void Amb_Interleaved() + { + var scheduler = new HistoricalScheduler(); + Left(scheduler).Amb(Right(scheduler)).SubscribeConsume(Consumer); // both live; right (earlier offset) wins + scheduler.Start(); + } + + [Benchmark] + public void Switch_Interleaved() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var innerStep = TimeSpan.FromTicks(PeriodTicks / 2); + + // Each inner spans ~2 outer periods, so a new inner arrives while the previous is still emitting → + // Switch cancels it mid-flight (its defining behaviour), unlike the synchronous cold version. + VirtualTimeSource.Timed(scheduler, n, period) + .Select(_ => VirtualTimeSource.Timed(scheduler, 4, innerStep)) + .Switch() + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/MergeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/MergeBenchmarks.cs new file mode 100644 index 0000000000..7b73bb0c05 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/MergeBenchmarks.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// Combining-category exemplar (S1): Merge across a varying number of cold sources. Total emitted + /// work is held ~constant (each source emits 1,000,000 / Sources elements) so the sweep isolates the + /// per-source subscription/bookkeeping cost. + /// + [BenchmarkCategory("Multiple")] + public class MergeBenchmarks + { + [Params(2, 10, 100, 1000)] + public int Sources; + + private readonly Consumer _consumer = new(); + private IObservable[] _sources = default!; + + [GlobalSetup] + public void Setup() + { + var per = 1_000_000 / Sources; + _sources = new IObservable[Sources]; + for (var i = 0; i < Sources; i++) + { + _sources[i] = Observable.Range(i * per, per); + } + } + + [Benchmark] + public void Merge() => Observable.Merge(_sources).SubscribeConsume(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/MergeMaxConcurrencyBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/MergeMaxConcurrencyBenchmarks.cs new file mode 100644 index 0000000000..63ada5d64c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/MergeMaxConcurrencyBenchmarks.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S1: the Merge overload with a concurrency cap subscribes to at most 4 inner sequences at a time, + /// queueing the rest — a distinct code path from the unbounded array/nested Merge. Inner size precomputed in + /// [GlobalSetup] to avoid a per-invocation closure. + /// + [BenchmarkCategory("Multiple")] + public class MergeMaxConcurrencyBenchmarks : OperatorBenchmarkBase + { + private int _innerSize; + + [GlobalSetup] + public void Setup() => _innerSize = 1_000_000 / N; + + [Benchmark] + public void Merge_MaxConcurrency() => + Observable.Merge(Observable.Range(1, N).Select(v => Observable.Range(v, _innerSize)), 4).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/SkipUntilObservableBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/SkipUntilObservableBenchmarks.cs new file mode 100644 index 0000000000..c3128be4d0 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/SkipUntilObservableBenchmarks.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S3: the observable-triggered SkipUntil (skip until another sequence emits), complementing the + /// absolute-time overload under Operators/Time. The trigger fires at the stream midpoint under virtual time. + /// + [BenchmarkCategory("Multiple")] + public class SkipUntilObservableBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void SkipUntil_Observable() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var trigger = Observable.Timer(TimeSpan.FromTicks(PeriodTicks * (n / 2)), scheduler); + + VirtualTimeSource.Timed(scheduler, n, period) + .SkipUntil(trigger) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/SwitchBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/SwitchBenchmarks.cs new file mode 100644 index 0000000000..1c36ee8e80 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/SwitchBenchmarks.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S1: Switch flattens a stream of inner sequences, always following the most recent. Cross-mapped so + /// total emitted work stays ~constant across N (inner size precomputed to avoid a closure). Note the inners + /// here are synchronous; the mid-inner cancellation path is covered by InterleavedCombiningBenchmarks. + /// + [BenchmarkCategory("Multiple")] + public class SwitchBenchmarks : OperatorBenchmarkBase + { + private int _innerSize; + + [GlobalSetup] + public void Setup() => _innerSize = 1_000_000 / N; + + [Benchmark] + public void Switch() => + Observable.Range(1, N).Select(v => Observable.Range(v, _innerSize)).Switch().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/WithLatestFromBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/WithLatestFromBenchmarks.cs new file mode 100644 index 0000000000..a559364abc --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/WithLatestFromBenchmarks.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// S1: WithLatestFrom combines each element of the first source with the latest of the second. + [BenchmarkCategory("Multiple")] + public class WithLatestFromBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void WithLatestFrom() => + Observable.Range(1, N).WithLatestFrom(Observable.Range(1, N), static (a, b) => a + b).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ZipBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ZipBenchmarks.cs new file mode 100644 index 0000000000..5f91f22b4b --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ZipBenchmarks.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S1: Zip pairs elements positionally from two sources. (Reimplemented cleanly — the old benchmark + /// delegated to a unit test and measured its assertions.) The first source's elements queue until the + /// second produces its match, so this also exercises Zip's internal queueing. + /// + [BenchmarkCategory("Multiple")] + public class ZipBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Zip() => + Observable.Zip(Observable.Range(1, N), Observable.Range(1, N), static (a, b) => a + b).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ZipManyBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ZipManyBenchmarks.cs new file mode 100644 index 0000000000..4a23796aa1 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Multiple/ZipManyBenchmarks.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Multiple +{ + /// + /// S1: N-ary Zip across a varying number of cold sources — the variadic IList-selector overload, + /// whose sink keeps a per-source queue (a distinct code path from the specialised binary overload in + /// ). Each source emits the same count, so every position produces one combined + /// result; total emitted work is held ~constant (each source emits 1,000,000 / Sources elements) so the + /// sweep isolates the per-source queueing/bookkeeping cost. + /// + [BenchmarkCategory("Multiple")] + public class ZipManyBenchmarks + { + [Params(2, 4, 8, 16)] + public int Sources; + + private readonly Consumer _consumer = new(); + private IObservable[] _sources = default!; + + [GlobalSetup] + public void Setup() + { + var per = 1_000_000 / Sources; + _sources = new IObservable[Sources]; + for (var i = 0; i < Sources; i++) + { + _sources[i] = Observable.Range(i * per, per); + } + } + + [Benchmark] + public void Zip() => + Observable.Zip(_sources, static (IList values) => values.Count).SubscribeConsume(_consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Overhead/ComparisonBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Overhead/ComparisonBenchmarks.cs new file mode 100644 index 0000000000..d0075402a1 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Overhead/ComparisonBenchmarks.cs @@ -0,0 +1,258 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Overhead +{ + /// + /// The historic broad comparison grid (predates the per-operator suite), retained for baseline + /// continuity: a raw for loop and LINQ-to-Objects against a wide spread of Rx pipelines in + /// one table. For focused per-operator measurements prefer the dedicated classes; the cross-map + /// variants here (ConcatCrossMap/SelectManyCrossMap/MergeCrossMap) pin total + /// work at ~1M elements, so for those N reads as fan-out width, not element count. + /// + [BenchmarkCategory("Overhead")] + public class ComparisonBenchmarks : OperatorBenchmarkBase + { + [Benchmark(Baseline = true)] + public void ForLoopBaseLine() + { + var n = N; + for (var i = 0; i < n; i++) + { + Consumer.Consume(i); + } + } + + [Benchmark] + public void EnumerableBaseLine() + { + foreach (var v in Enumerable.Range(1, N)) + { + Consumer.Consume(v); + } + } + + [Benchmark] + public void Return() => Observable.Return(1).SubscribeConsume(Consumer); + + [Benchmark] + public void Range() => Observable.Range(1, N).SubscribeConsume(Consumer); + + [Benchmark] + public void Select() => + Observable.Range(1, N) + .Select(static v => v + 1) + .SubscribeConsume(Consumer); + + [Benchmark] + public void SelectSelect() => + Observable.Range(1, N) + .Select(static v => v + 1) + .Select(static v => v + 1) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Where() => + Observable.Range(1, 2 * N) + .Where(static v => (v & 1) != 0) + .SubscribeConsume(Consumer); + + [Benchmark] + public void WhereWhere() => + Observable.Range(1, 4 * N) + .Where(static v => (v & 1) != 0) + .Where(static v => (v & 2) != 0) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Take() => + Observable.Range(1, 2 * N) + .Take(N) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Skip() => + Observable.Range(1, 2 * N) + .Skip(N) + .SubscribeConsume(Consumer); + + [Benchmark] + public void TakeUntil() => + Observable.Range(1, N) + .TakeUntil(Observable.Never()) + .SubscribeConsume(Consumer); + + [Benchmark] + public void ToObservable() => + Enumerable.Range(1, N) + .ToObservable() + .SubscribeConsume(Consumer); + + [Benchmark] + public void Concat() + { + var m = N - N / 2; + + Observable.Concat( + Observable.Range(1, N), + Observable.Range(1, m)) + .SubscribeConsume(Consumer); + } + + [Benchmark] + public void ConcatCrossMap() + { + var m = 1000 * 1000 / N; + + Observable.Concat(Observable.Range(1, N).Select(v => Observable.Range(v, m))) + .SubscribeConsume(Consumer); + } + + [Benchmark] + public void SelectManyCrossMap() + { + var m = 1000 * 1000 / N; + + Observable.Range(1, N).SelectMany(v => Observable.Range(v, m)) + .SubscribeConsume(Consumer); + } + + [Benchmark] + public void MergeCrossMap() + { + var m = 1000 * 1000 / N; + + Observable.Merge(Observable.Range(1, N).Select(v => Observable.Range(v, m))) + .SubscribeConsume(Consumer); + } + + [Benchmark] + public void AsyncSubjectPush() + { + var subj = new AsyncSubject(); + subj.SubscribeConsume(Consumer); + + var n = N; + for (var i = 0; i < n; i++) + { + subj.OnNext(i); + } + + subj.OnCompleted(); + } + + [Benchmark] + public void SubjectPush() + { + var subj = new Subject(); + subj.SubscribeConsume(Consumer); + + var n = N; + for (var i = 0; i < n; i++) + { + subj.OnNext(i); + } + + subj.OnCompleted(); + } + + [Benchmark] + public void AmbTwo() => + Observable.Never().Amb(Observable.Range(1, N)) + .SubscribeConsume(Consumer); + + [Benchmark] + public void AmbThree() => + Observable.Amb(Observable.Never(), Observable.Never(), Observable.Range(1, N)) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Timeout() => + Observable.Range(1, N) + .Timeout(TimeSpan.FromHours(1)) + .SubscribeConsume(Consumer); + +#pragma warning disable CS0618 // Type or member is obsolete + [Benchmark] + public void First() => Consumer.Consume(Observable.Range(1, N).First()); + + [Benchmark] + public void Last() => Consumer.Consume(Observable.Range(1, N).Last()); +#pragma warning restore CS0618 // Type or member is obsolete + + [Benchmark] + public void Buffer_Exact() => + Observable.Range(1, 1000) + .Buffer(1) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Buffer_Skip() => + Observable.Range(1, 1000) + .Buffer(1, 2) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Buffer_Overlap() => + Observable.Range(1, 1000) + .Buffer(2, 1) + .SubscribeConsume(Consumer); + + [Benchmark] + public void CurrentThreadSchedulerRepeated() + { + var n = N; + var scheduler = CurrentThreadScheduler.Instance; + for (var i = 0; i < n; i++) + { + scheduler.Schedule(i, (_, v) => + { + Consumer.Consume(v); + return Disposable.Empty; + }); + } + } + + [Benchmark] + public void TakeLast() => + Observable.Range(1, 2 * N).TakeLast(N) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Repeat() => + Observable.Repeat(1, N) + .SubscribeConsume(Consumer); + + [Benchmark] + public void ToList() => + Observable.Repeat(1, N).ToList() + .SubscribeConsume(Consumer); + + [Benchmark] + public void Generate() => + Observable.Generate(0, s => s < N, static s => s + 1, static s => s) + .SubscribeConsume(Consumer); + + [Benchmark] + public void Collect() + { + foreach (var v in Observable.Range(1, N).Collect(static () => new List(), static (a, b) => { a.Add(b); return a; })) + { + Consumer.Consume(v); + } + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Overhead/OverheadBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Overhead/OverheadBenchmarks.cs new file mode 100644 index 0000000000..48a4ed7866 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Overhead/OverheadBenchmarks.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Overhead +{ + /// + /// The suite's reference point: the same "increment each element" work done as a raw for loop + /// (baseline), as LINQ-to-Objects, and as an Rx Select pipeline. The Ratio column then shows the + /// per-element cost of Rx relative to a hand loop and to IEnumerable — the "cost of Rx" figure the + /// per-operator absolute numbers can't give on their own. + /// + [BenchmarkCategory("Overhead")] + public class OverheadBenchmarks : OperatorBenchmarkBase + { + [Benchmark(Baseline = true)] + public void ForLoop() + { + var n = N; + for (var i = 1; i <= n; i++) + { + Consumer.Consume(i + 1); + } + } + + [Benchmark] + public void Enumerable_Select() + { + foreach (var v in Enumerable.Range(1, N).Select(static v => v + 1)) + { + Consumer.Consume(v); + } + } + + [Benchmark] + public void Rx_Select() => Observable.Range(1, N).Select(static v => v + 1).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/AppendPrependStartWithBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/AppendPrependStartWithBenchmarks.cs new file mode 100644 index 0000000000..f851849a4c --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/AppendPrependStartWithBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// S1: Append / Prepend a single element, and StartWith a leading value. + [BenchmarkCategory("Single")] + public class AppendPrependStartWithBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Append() => Observable.Range(1, N).Append(0).SubscribeConsume(Consumer); + + [Benchmark] + public void Prepend() => Observable.Range(1, N).Prepend(0).SubscribeConsume(Consumer); + + [Benchmark] + public void StartWith() => Observable.Range(1, N).StartWith(0).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/AsObservableBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/AsObservableBenchmarks.cs new file mode 100644 index 0000000000..bfdb04cef5 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/AsObservableBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// S1: AsObservable hides the source's concrete type behind a thin wrapper. + [BenchmarkCategory("Single")] + public class AsObservableBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void AsObservable() => Observable.Range(1, N).AsObservable().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/BufferCountBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/BufferCountBenchmarks.cs new file mode 100644 index 0000000000..6b12302839 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/BufferCountBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// S1: count-based Buffer — a List per batch (an allocation candidate). The skip variant keeps + /// multiple in-flight buffers. + /// + [BenchmarkCategory("Single")] + public class BufferCountBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Buffer_Count() => Observable.Range(1, N).Buffer(16).SubscribeConsume(Consumer); + + [Benchmark] + public void Buffer_Count_Skip() => Observable.Range(1, N).Buffer(16, 8).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/DistinctUntilChangedBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/DistinctUntilChangedBenchmarks.cs new file mode 100644 index 0000000000..d82fd892b0 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/DistinctUntilChangedBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// S1: DistinctUntilChanged compares adjacent elements; the ascending source keeps every element. + [BenchmarkCategory("Single")] + public class DistinctUntilChangedBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void DistinctUntilChanged() => Observable.Range(1, N).DistinctUntilChanged().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/DoFinallyBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/DoFinallyBenchmarks.cs new file mode 100644 index 0000000000..bafecd7acb --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/DoFinallyBenchmarks.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// Side-effect callbacks: Do (per-element / on-error) and Finally (on-termination). The + /// _Error variants drive a faulting source so the onError / error-termination callbacks fire. + /// + [BenchmarkCategory("Single")] + public class DoFinallyBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Do() => Observable.Range(1, N).Do(static _ => { }).SubscribeConsume(Consumer); + + [Benchmark] + public void Finally() => Observable.Range(1, N).Finally(static () => { }).SubscribeConsume(Consumer); + + [Benchmark] + public void Do_Error() => FaultingSource.Faulting(N).Do(static _ => { }, static _ => { }).SubscribeConsume(Consumer); + + [Benchmark] + public void Finally_Error() => FaultingSource.Faulting(N).Finally(static () => { }).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/IgnoreElementsBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/IgnoreElementsBenchmarks.cs new file mode 100644 index 0000000000..5aec0e07d1 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/IgnoreElementsBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// S1: IgnoreElements drops every value and forwards only the terminal notification. + [BenchmarkCategory("Single")] + public class IgnoreElementsBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void IgnoreElements() => Observable.Range(1, N).IgnoreElements().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/MaterializeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/MaterializeBenchmarks.cs new file mode 100644 index 0000000000..0a95393e46 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/MaterializeBenchmarks.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// Materialize wraps each notification in a reference-type Notification<T> — a per-element heap + /// allocation and a prime zero-allocation candidate. Dematerialize unwraps it. Materialize_Error + /// materializes a faulting source, so the OnErrorNotification.OnError path is exercised too. + /// + [BenchmarkCategory("Single")] + public class MaterializeBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Materialize() => Observable.Range(1, N).Materialize().SubscribeConsume(Consumer); + + [Benchmark] + public void MaterializeDematerialize() => + Observable.Range(1, N).Materialize().Dematerialize().SubscribeConsume(Consumer); + + [Benchmark] + public void Materialize_Error() => FaultingSource.Faulting(N).Materialize().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/RepeatWhenRetryWhenBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/RepeatWhenRetryWhenBenchmarks.cs new file mode 100644 index 0000000000..d2d32955c9 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/RepeatWhenRetryWhenBenchmarks.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// Signal-driven resubscription. RepeatWhen re-runs on the handler's signal (here once → source runs + /// twice). RetryWhen (happy path) never errors, so the identity handler drains the source exactly once; + /// RetryWhen_Error drives a source that faults 3× before succeeding, with a handler that permits 3 retries. + /// + [BenchmarkCategory("Single")] + public class RepeatWhenRetryWhenBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void RepeatWhen() => Observable.Range(1, N).RepeatWhen(static signals => signals.Take(1)).SubscribeConsume(Consumer); + + [Benchmark] + public void RetryWhen() => Observable.Range(1, N).RetryWhen(static errors => errors).SubscribeConsume(Consumer); + + [Benchmark] + public void RetryWhen_Error() => + FaultingSource.FaultThenSucceed(N, 3).RetryWhen(static errors => errors.Take(3)).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/ResetExceptionDispatchStateBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/ResetExceptionDispatchStateBenchmarks.cs new file mode 100644 index 0000000000..33d057502f --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/ResetExceptionDispatchStateBenchmarks.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// ResetExceptionDispatchState only acts on the OnError path (clearing accumulated dispatch state + /// so a re-thrown exception doesn't grow its stack trace). The plain method is a pass-through (wrapper cost); + /// _Error routes repeated faults through it across a Retry — the scenario it exists for. + /// + [BenchmarkCategory("Single")] + public class ResetExceptionDispatchStateBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void ResetExceptionDispatchState() => + Observable.Range(1, N).ResetExceptionDispatchState().SubscribeConsume(Consumer); + + [Benchmark] + public void ResetExceptionDispatchState_Error() => + FaultingSource.FaultThenSucceed(N, 3).ResetExceptionDispatchState().Retry().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/RetryRepeatBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/RetryRepeatBenchmarks.cs new file mode 100644 index 0000000000..a2a6b2aab6 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/RetryRepeatBenchmarks.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// Resubscription. Repeat(count) re-runs the source. Retry (happy path) never errors so runs once; + /// Retry_Error drives a source that faults 3× before succeeding, so Retry actually resubscribes. + /// + [BenchmarkCategory("Single")] + public class RetryRepeatBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Retry() => Observable.Range(1, N).Retry().SubscribeConsume(Consumer); + + [Benchmark] + public void Repeat() => Observable.Range(1, N).Repeat(2).SubscribeConsume(Consumer); + + [Benchmark] + public void Retry_Error() => FaultingSource.FaultThenSucceed(N, 3).Retry().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/ScanBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/ScanBenchmarks.cs new file mode 100644 index 0000000000..d6068aabdb --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/ScanBenchmarks.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// Single-sequence-category exemplar (S1): Scan — an O(1)-state running accumulate that emits per + /// element. A long accumulator avoids overflow at large N. + /// + [BenchmarkCategory("Single")] + public class ScanBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Scan() => Observable.Range(1, N).Scan(0L, static (acc, v) => acc + v).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/TakeLastSkipLastBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/TakeLastSkipLastBenchmarks.cs new file mode 100644 index 0000000000..c51d9b9220 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/TakeLastSkipLastBenchmarks.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// + /// S1: the count-based last-N family. Each keeps a fixed-capacity sliding Queue — a clean zero-allocation + /// candidate (the capacity is known up front, so a ring buffer / pooled array would eliminate the churn). + /// + [BenchmarkCategory("Single")] + public class TakeLastSkipLastBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void TakeLast_Count() => Observable.Range(1, N).TakeLast(16).SubscribeConsume(Consumer); + + [Benchmark] + public void SkipLast_Count() => Observable.Range(1, N).SkipLast(16).SubscribeConsume(Consumer); + + [Benchmark] + public void TakeLastBuffer_Count() => Observable.Range(1, N).TakeLastBuffer(16).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/WindowCountBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/WindowCountBenchmarks.cs new file mode 100644 index 0000000000..194506a230 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Single/WindowCountBenchmarks.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Single +{ + /// S1: count-based Window produces a sub-observable per batch; the inner windows are flattened. + [BenchmarkCategory("Single")] + public class WindowCountBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Window_Count() => + Observable.Range(1, N).Window(16).SelectMany(static w => w).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/BoxingBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/BoxingBenchmarks.cs new file mode 100644 index 0000000000..3288d935d8 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/BoxingBenchmarks.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// Isolates the cost of boxing: the same pipeline over value-type int (Unboxed, baseline) vs one + /// routed through IObservable<object> (Boxed — a box on the way in and an unbox on the way out + /// per element). The Ratio + Allocated columns quantify the per-element box/unbox tax directly. + /// + [BenchmarkCategory("StandardSequence")] + public class BoxingBenchmarks : OperatorBenchmarkBase + { + [Benchmark(Baseline = true)] + public void Unboxed() => Observable.Range(1, N).Select(static v => v + 1).SubscribeConsume(Consumer); + + [Benchmark] + public void Boxed() => + Observable.Range(1, N).Select(static v => (object)v).Select(static o => (int)o + 1).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/CastOfTypeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/CastOfTypeBenchmarks.cs new file mode 100644 index 0000000000..6986cca50a --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/CastOfTypeBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// S1: Cast and OfType over a boxed (object) source. The boxing in the source is + /// inherent to having an IObservable<object>; the benchmark measures the cast/type-test per element. + /// + [BenchmarkCategory("StandardSequence")] + public class CastOfTypeBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Cast() => Observable.Range(1, N).Select(static v => (object)v).Cast().SubscribeConsume(Consumer); + + [Benchmark] + public void OfType() => Observable.Range(1, N).Select(static v => (object)v).OfType().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/DefaultIfEmptyBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/DefaultIfEmptyBenchmarks.cs new file mode 100644 index 0000000000..21213f3f09 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/DefaultIfEmptyBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// S1: DefaultIfEmpty passes the stream through (the non-empty source substitutes nothing). + [BenchmarkCategory("StandardSequence")] + public class DefaultIfEmptyBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void DefaultIfEmpty() => Observable.Range(1, N).DefaultIfEmpty().SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/DistinctBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/DistinctBenchmarks.cs new file mode 100644 index 0000000000..41bd89cde5 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/DistinctBenchmarks.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// S1: Distinct over an all-unique stream, so its internal HashSet grows to N — a bounded + /// allocation candidate for the modernization spike. + /// + [BenchmarkCategory("StandardSequence")] + public class DistinctBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Distinct() => Observable.Range(1, N).Distinct().SubscribeConsume(Consumer); + + [Benchmark] + public void Distinct_KeySelector() => Observable.Range(1, N).Distinct(static v => v % 1000).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/GroupByBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/GroupByBenchmarks.cs new file mode 100644 index 0000000000..1ae57718d3 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/GroupByBenchmarks.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// S1: GroupBy partitions the stream into keyed sub-observables (allocating a group per key), then + /// the groups are flattened. Fixed at 8 keys so each group carries a meaningful share of the stream. + /// + [BenchmarkCategory("StandardSequence")] + public class GroupByBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void GroupBy() => + Observable.Range(1, N).GroupBy(static v => v % 8).SelectMany(static g => g).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/GroupByUntilBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/GroupByUntilBenchmarks.cs new file mode 100644 index 0000000000..e1e6dc218e --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/GroupByUntilBenchmarks.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// S1: GroupByUntil partitions into keyed groups that each close when a per-group duration fires + /// (here after a few elements), so groups are repeatedly opened and closed. Groups are flattened. + /// + [BenchmarkCategory("StandardSequence")] + public class GroupByUntilBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void GroupByUntil() => + Observable.Range(1, N) + .GroupByUntil(static v => v % 8, static g => g.Skip(4)) + .SelectMany(static g => g) + .SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/JoinGroupJoinBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/JoinGroupJoinBenchmarks.cs new file mode 100644 index 0000000000..59d325abf0 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/JoinGroupJoinBenchmarks.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// The window-join operators, run under virtual time so the overlap is controlled (each element's window + /// spans ~2 arrivals → ~O(N) pairs, not the O(N²) that open-ended windows on cold sources would produce). + /// + [BenchmarkCategory("StandardSequence")] + public class JoinGroupJoinBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Join() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var window = TimeSpan.FromTicks(PeriodTicks * 2); + + VirtualTimeSource.Timed(scheduler, n, period) + .Join( + VirtualTimeSource.Timed(scheduler, n, period), + _ => Observable.Timer(window, scheduler), + _ => Observable.Timer(window, scheduler), + static (l, r) => l + r) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void GroupJoin() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var window = TimeSpan.FromTicks(PeriodTicks * 2); + + VirtualTimeSource.Timed(scheduler, n, period) + .GroupJoin( + VirtualTimeSource.Timed(scheduler, n, period), + _ => Observable.Timer(window, scheduler), + _ => Observable.Timer(window, scheduler), + static (l, rights) => rights) + .SelectMany(static rights => rights) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/ReferenceTypeElementBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/ReferenceTypeElementBenchmarks.cs new file mode 100644 index 0000000000..7497bae085 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/ReferenceTypeElementBenchmarks.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// The suite is otherwise all int pipelines; this runs the GC-sensitive operators over a + /// reference-type (string) element, so behaviour with GC-tracked payloads — the internal + /// HashSet/List/Queue holding object references, equality via EqualityComparer — is + /// measured rather than the value-type fast paths. + /// + [BenchmarkCategory("StandardSequence")] + public class ReferenceTypeElementBenchmarks : OperatorBenchmarkBase + { + private string[] _strings = default!; + + [GlobalSetup] + public void Setup() => _strings = Enumerable.Range(1, N).Select(static i => (i % 1000).ToString()).ToArray(); + + [Benchmark] + public void Distinct() => _strings.ToObservable().Distinct().SubscribeConsume(Consumer); + + [Benchmark] + public void DistinctUntilChanged() => _strings.ToObservable().DistinctUntilChanged().SubscribeConsume(Consumer); + + [Benchmark] + public void GroupBy() => _strings.ToObservable().GroupBy(static s => s.Length).SelectMany(static g => g).SubscribeConsume(Consumer); + + [Benchmark] + public void Buffer() => _strings.ToObservable().Buffer(16).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/SelectBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/SelectBenchmarks.cs new file mode 100644 index 0000000000..f205142098 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/SelectBenchmarks.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// S1 (throughput) exemplar: pushes N elements synchronously through Select and consumes + /// them. Sweeps N from 1 to 1,000,000 so subscription cost (small N) and per-element cost + /// (large N) are both visible, with allocations reported by the shared MemoryDiagnoser. + /// + [BenchmarkCategory("StandardSequence")] + public class SelectBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Select() => + Observable.Range(1, N).Select(v => v + 1).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/SelectManyBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/SelectManyBenchmarks.cs new file mode 100644 index 0000000000..76dc7a0608 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/SelectManyBenchmarks.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// + /// S1 fan-out: SelectMany projects each element to an inner sequence and flattens. The cross-map inner + /// size (1,000,000 / N) holds total emitted work ~constant, isolating the per-inner-subscription cost. + /// The inner size is precomputed in [GlobalSetup] so the selector captures a field (no per-invocation + /// display-class closure). + /// + [BenchmarkCategory("StandardSequence")] + public class SelectManyBenchmarks : OperatorBenchmarkBase + { + private int _innerSize; + + [GlobalSetup] + public void Setup() => _innerSize = 1_000_000 / N; + + [Benchmark] + public void SelectMany() => + Observable.Range(1, N).SelectMany(v => Observable.Range(v, _innerSize)).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/TakeSkipBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/TakeSkipBenchmarks.cs new file mode 100644 index 0000000000..29621a3d96 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/TakeSkipBenchmarks.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// S1: the count/predicate-based Take/Skip family (the time-based variants live under Operators/Time). + [BenchmarkCategory("StandardSequence")] + public class TakeSkipBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Take() => Observable.Range(1, 2 * N).Take(N).SubscribeConsume(Consumer); + + [Benchmark] + public void Skip() => Observable.Range(1, 2 * N).Skip(N).SubscribeConsume(Consumer); + + [Benchmark] + public void TakeWhile() + { + var n = N; + Observable.Range(1, 2 * N).TakeWhile(v => v <= n).SubscribeConsume(Consumer); + } + + [Benchmark] + public void SkipWhile() + { + var n = N; + Observable.Range(1, 2 * N).SkipWhile(v => v <= n).SubscribeConsume(Consumer); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/WhereBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/WhereBenchmarks.cs new file mode 100644 index 0000000000..a151ef79b7 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/StandardSequence/WhereBenchmarks.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.StandardSequence +{ + /// S1: Where filters a stream; the predicate here keeps roughly half the elements. + [BenchmarkCategory("StandardSequence")] + public class WhereBenchmarks : OperatorBenchmarkBase + { + [Benchmark] + public void Where() => Observable.Range(1, N).Where(static v => (v & 1) == 0).SubscribeConsume(Consumer); + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/BufferTimeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/BufferTimeBenchmarks.cs new file mode 100644 index 0000000000..09de1e72ab --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/BufferTimeBenchmarks.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: time-based Buffer collects elements into an IList per window. A prime zero-allocation + /// candidate for the modernization spike (a List per window; the emitted list escapes to the + /// consumer). The Density knob controls how many elements land in each buffer. + /// + [BenchmarkCategory("Time")] + public class BufferTimeBenchmarks : RateWindowBenchmarkBase + { + [Benchmark] + public void Buffer_Time() + { + var scheduler = new PeriodicVirtualScheduler(); // Buffer(TimeSpan) runs on the real periodic path + var n = N; + var window = Window; + var period = Period; + + VirtualTimeSource.Timed(scheduler, n, period) + .Buffer(window, scheduler) + .SubscribeConsume(Consumer); // consumes each IList batch + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/BufferWindowBoundaryBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/BufferWindowBoundaryBenchmarks.cs new file mode 100644 index 0000000000..16a1aa4651 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/BufferWindowBoundaryBenchmarks.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: the observable-boundary Buffer / Window (boundaries close each batch), complementing the + /// count and time shapes. Boundaries fire every 16 periods under virtual time. + /// + [BenchmarkCategory("Time")] + public class BufferWindowBoundaryBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Buffer_Boundary() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var boundaries = Observable.Interval(TimeSpan.FromTicks(PeriodTicks * 16), scheduler); + + VirtualTimeSource.Timed(scheduler, n, period) + .Buffer(boundaries) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Window_Boundary() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var boundaries = Observable.Interval(TimeSpan.FromTicks(PeriodTicks * 16), scheduler); + + VirtualTimeSource.Timed(scheduler, n, period) + .Window(boundaries) + .SelectMany(static w => w) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/DelayBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/DelayBenchmarks.cs new file mode 100644 index 0000000000..d9505019ef --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/DelayBenchmarks.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: Delay time-shifts every element (density-independent, so N + a fixed delay). DelaySubscription + /// shifts only the subscription, then relays elements untouched. + /// + [BenchmarkCategory("Time")] + public class DelayBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Delay() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var delay = TimeSpan.FromTicks(PeriodTicks * 4); + + VirtualTimeSource.Timed(scheduler, n, period) + .Delay(delay, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void DelaySubscription() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var delay = TimeSpan.FromTicks(PeriodTicks * 4); + + VirtualTimeSource.Timed(scheduler, n, period) + .DelaySubscription(delay, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SampleBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SampleBenchmarks.cs new file mode 100644 index 0000000000..be3f8956fb --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SampleBenchmarks.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: Sample emits the most recent element on each tick of its sampling window. The Density + /// knob varies how many source elements fall into each sampling interval. + /// + [BenchmarkCategory("Time")] + public class SampleBenchmarks : RateWindowBenchmarkBase + { + [Benchmark] + public void Sample() + { + var scheduler = new PeriodicVirtualScheduler(); // Sample(TimeSpan) runs on the real periodic path + var n = N; + var window = Window; + var period = Period; + + VirtualTimeSource.Timed(scheduler, n, period) + .Sample(window, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SkipTakeTimeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SkipTakeTimeBenchmarks.cs new file mode 100644 index 0000000000..024a35b1a3 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SkipTakeTimeBenchmarks.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: the time-based Skip/Take family. The boundary is the midpoint of the stream, so each operator does + /// meaningful work over time. SkipLast/TakeLast keep a time-bounded queue — allocation + /// candidates for the modernization spike. Stays on plain : these operators + /// only use one-shot Schedule, never ISchedulerPeriodic. + /// + [BenchmarkCategory("Time")] + public class SkipTakeTimeBenchmarks : TemporalBenchmarkBase + { + private TimeSpan Midpoint => TimeSpan.FromTicks(PeriodTicks * (N / 2)); + + [Benchmark] + public void Skip_Time() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var duration = Midpoint; + + VirtualTimeSource.Timed(scheduler, n, period) + .Skip(duration, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Take_Time() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var duration = Midpoint; + + VirtualTimeSource.Timed(scheduler, n, period) + .Take(duration, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void SkipLast_Time() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var duration = Midpoint; + + VirtualTimeSource.Timed(scheduler, n, period) + .SkipLast(duration, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void TakeLast_Time() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var duration = Midpoint; + + VirtualTimeSource.Timed(scheduler, n, period) + .TakeLast(duration, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SkipUntilTakeUntilBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SkipUntilTakeUntilBenchmarks.cs new file mode 100644 index 0000000000..d855193506 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/SkipUntilTakeUntilBenchmarks.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: the absolute-time SkipUntil / TakeUntil (DateTimeOffset) overloads. The boundary is + /// the midpoint of the stream. The virtual is keyed on DateTimeOffset, + /// so the boundary is expressed relative to its MinValue start clock. + /// + [BenchmarkCategory("Time")] + public class SkipUntilTakeUntilBenchmarks : TemporalBenchmarkBase + { + private DateTimeOffset Midpoint => DateTimeOffset.MinValue + TimeSpan.FromTicks(PeriodTicks * (N / 2)); + + [Benchmark] + public void SkipUntil_Time() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var boundary = Midpoint; + + VirtualTimeSource.Timed(scheduler, n, period) + .SkipUntil(boundary, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void TakeUntil_Time() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var boundary = Midpoint; + + VirtualTimeSource.Timed(scheduler, n, period) + .TakeUntil(boundary, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/ThrottleBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/ThrottleBenchmarks.cs new file mode 100644 index 0000000000..65872da460 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/ThrottleBenchmarks.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3 (temporal / "data over time") flagship benchmark. N elements are emitted across a virtual + /// timeline (spaced PeriodTicks apart) and run to completion under a ; + /// Start() drains the whole timeline synchronously, so even a huge Dense window costs + /// microseconds of wall-clock. The Density knob varies the window-vs-arrival relationship that + /// drives Throttle's hot path (dense arrivals → constant cancel+reschedule). + /// + [BenchmarkCategory("Time")] + public class ThrottleBenchmarks : RateWindowBenchmarkBase + { + [Benchmark] + public void Throttle() + { + var scheduler = new HistoricalScheduler(); // fresh per iteration (Clock/queue are instance state) + var n = N; // hoist params into locals → no field-capture closures + var window = Window; + var period = Period; + + VirtualTimeSource.Timed(scheduler, n, period) + .Throttle(window, scheduler) // MUST pass the virtual scheduler, or it waits in real time + .SubscribeConsume(Consumer); // Consumer defeats DCE without allocating a closure + + scheduler.Start(); // drains the whole timeline synchronously + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimeOverloadGapsBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimeOverloadGapsBenchmarks.cs new file mode 100644 index 0000000000..fbd3d03716 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimeOverloadGapsBenchmarks.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: the remaining temporal overload shapes — Sample by observable sampler, Delay and + /// Timeout by per-element selector, and the timed TakeLastBuffer — complementing the primary + /// TimeSpan overloads elsewhere in Operators/Time. + /// + [BenchmarkCategory("Time")] + public class TimeOverloadGapsBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Sample_Observable() + { + // The sampler is Observable.Interval, which hits SchedulePeriodic — use the periodic scheduler. + var scheduler = new PeriodicVirtualScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var sampler = Observable.Interval(TimeSpan.FromTicks(PeriodTicks * 4), scheduler); + + VirtualTimeSource.Timed(scheduler, n, period) + .Sample(sampler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Delay_Selector() + { + // One-shot Timer only (no periodic path); same scheduler type keeps the file uniform. + var scheduler = new PeriodicVirtualScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var delay = TimeSpan.FromTicks(PeriodTicks * 4); + + VirtualTimeSource.Timed(scheduler, n, period) + .Delay(_ => Observable.Timer(delay, scheduler)) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Timeout_Selector() + { + var scheduler = new PeriodicVirtualScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var timeout = TimeSpan.FromTicks(PeriodTicks * 4); // longer than the gap → never fires + + VirtualTimeSource.Timed(scheduler, n, period) + .Timeout(_ => Observable.Timer(timeout, scheduler)) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void TakeLastBuffer_Time() + { + var scheduler = new PeriodicVirtualScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var duration = TimeSpan.FromTicks(PeriodTicks * (n / 2)); + + VirtualTimeSource.Timed(scheduler, n, period) + .TakeLastBuffer(duration, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimeoutBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimeoutBenchmarks.cs new file mode 100644 index 0000000000..42790fa243 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimeoutBenchmarks.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: Timeout in three modes. NeverTrips (timeout > period) measures per-element timer-reset + /// bookkeeping. Trips fires and switches to a fallback (completes). Throws fires with no fallback, + /// so a real TimeoutException is propagated as OnError — the error path. + /// + [BenchmarkCategory("Time")] + public class TimeoutBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Timeout_NeverTrips() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var timeout = TimeSpan.FromTicks(PeriodTicks * 4); // always longer than the gap → never fires + + VirtualTimeSource.Timed(scheduler, n, period) + .Timeout(timeout, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Timeout_Trips() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var timeout = TimeSpan.FromTicks(PeriodTicks * 2); // longer than the gap → all N elements reset the timer... + + // ...then the stream goes silent (Never), so the timer finally elapses after the last element and + // Timeout trips, switching to the fallback. A timeout shorter than the first element's arrival would + // instead fire at subscription and measure zero-element, N-independent work. + VirtualTimeSource.Timed(scheduler, n, period).Concat(Observable.Never()) + .Timeout(timeout, Observable.Empty(), scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Timeout_Throws() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + var timeout = TimeSpan.FromTicks(PeriodTicks * 2); // longer than the gap → all N elements reset the timer... + + // ...then silence trips the timer after the last element; with no fallback this surfaces as + // OnError(TimeoutException) — the error path — instead of firing at subscription on zero elements. + VirtualTimeSource.Timed(scheduler, n, period).Concat(Observable.Never()) + .Timeout(timeout, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimerIntervalBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimerIntervalBenchmarks.cs new file mode 100644 index 0000000000..1a9f977e9d --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimerIntervalBenchmarks.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: the timed source factories. Interval and Timer use ISchedulerPeriodic, which + /// implements, so they run Rx's real periodic fast path in virtual + /// time. Interval_EmulatedPeriodic keeps the plain to quantify the + /// stopwatch-emulated fallback's overhead. Generate (timed) is the self-rescheduling source the + /// suite uses to drive the other temporal operators. + /// + [BenchmarkCategory("Time")] + public class TimerIntervalBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Interval() + { + var scheduler = new PeriodicVirtualScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + + Observable.Interval(period, scheduler).Take(n) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Interval_EmulatedPeriodic() + { + // HistoricalScheduler has no ISchedulerPeriodic, so Rx falls back to the stopwatch-emulated + // periodic path (AutoResetEvent + host-lifecycle registration per subscription). + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + + Observable.Interval(period, scheduler).Take(n) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Timer() + { + var scheduler = new PeriodicVirtualScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + + Observable.Timer(period, period, scheduler).Take(n) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void Generate_Timed() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + + Observable.Generate(0, i => i < n, i => i + 1, i => i, _ => period, scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimestampTimeIntervalBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimestampTimeIntervalBenchmarks.cs new file mode 100644 index 0000000000..09dc9405cf --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/TimestampTimeIntervalBenchmarks.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: Timestamp and TimeInterval annotate each element with scheduler time. Both project into + /// value-type wrappers (Timestamped<T> / TimeInterval<T>), so — unlike the reference-type + /// Notification<T> path — they should not allocate per element; the benchmark confirms that. + /// + [BenchmarkCategory("Time")] + public class TimestampTimeIntervalBenchmarks : TemporalBenchmarkBase + { + [Benchmark] + public void Timestamp() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + + VirtualTimeSource.Timed(scheduler, n, period) + .Timestamp(scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + + [Benchmark] + public void TimeInterval() + { + var scheduler = new HistoricalScheduler(); + var n = N; + var period = TimeSpan.FromTicks(PeriodTicks); + + VirtualTimeSource.Timed(scheduler, n, period) + .TimeInterval(scheduler) + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/WindowTimeBenchmarks.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/WindowTimeBenchmarks.cs new file mode 100644 index 0000000000..055ac6c6a7 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Operators/Time/WindowTimeBenchmarks.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System.Reactive.Linq; + +using BenchmarkDotNet.Attributes; + +using Benchmarks.System.Reactive.Infrastructure; + +namespace Benchmarks.System.Reactive.Operators.Time +{ + /// + /// S3: time-based Window produces a sub-observable per window. The inner windows are flattened with + /// SelectMany so the benchmark measures element delivery, not just window-open cost. + /// + [BenchmarkCategory("Time")] + public class WindowTimeBenchmarks : RateWindowBenchmarkBase + { + [Benchmark] + public void Window_Time() + { + var scheduler = new PeriodicVirtualScheduler(); // Window(TimeSpan) runs on the real periodic path + var n = N; + var window = Window; + var period = Period; + + VirtualTimeSource.Timed(scheduler, n, period) + .Window(window, scheduler) + .SelectMany(static w => w) // realise inner elements, else only window-open cost is measured + .SubscribeConsume(Consumer); + + scheduler.Start(); + } + } +} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/PrependVsStartWtihBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/PrependVsStartWtihBenchmark.cs deleted file mode 100644 index 6d1cc93605..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/PrependVsStartWtihBenchmark.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -#if (CURRENT) -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class PrependVsStartWtihBenchmark - { - private int _store; -#pragma warning disable IDE0052 // (Remove unread private members.) We want to store results to prevent the benchmarked code from being optimized out of existence. - private IObservable _obsStore; -#pragma warning restore IDE0052 - - [Benchmark(Baseline = true)] - public void Prepend() - { - Observable - .Empty() - .Prepend(0) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Prepend_Create() - { - _obsStore = Observable - .Empty() - .Prepend(0); - } - - - private static readonly IObservable _prependObservable = Observable.Empty().Prepend(0); - [Benchmark] - public void Prepend_Subscribe() - { - _prependObservable - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void StartWith() - { - Observable - .Empty() - .StartWith(0) - .Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void StartWith_Create() - { - _obsStore = Observable - .Empty() - .StartWith(0); - } - - private static readonly IObservable _startWithObservable = Observable.Empty().StartWith(0); - [Benchmark] - public void StartWith_Subscribe() - { - _startWithObservable - .Subscribe(v => Volatile.Write(ref _store, v)); - } - } -} -#endif diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Program.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Program.cs index ca80b785c3..575ae65711 100644 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Program.cs +++ b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/Program.cs @@ -1,46 +1,28 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT License. // See the LICENSE file in the project root for more information. using System; using System.Reactive.Linq; -using BenchmarkDotNet.Running; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using BenchmarkDotNet.Running; -[assembly:DoNotParallelize] // Not really a test project, but the build tools think we are, and complain if we don't state our parallelization policy. +using Benchmarks.System.Reactive.Infrastructure; namespace Benchmarks.System.Reactive { internal class Program { - private static void Main() + private static void Main(string[] args) { Console.WriteLine("Effective Rx-version: " + typeof(Observable).Assembly.GetName().Version); - var switcher = new BenchmarkSwitcher([ - typeof(ZipBenchmark), - typeof(CombineLatestBenchmark), - typeof(SwitchBenchmark), - typeof(BufferCountBenchmark), - typeof(RangeBenchmark), - typeof(ToObservableBenchmark), - typeof(RepeatBenchmark), - typeof(ComparisonBenchmark), - typeof(ComparisonAsyncBenchmark), - typeof(ScalarScheduleBenchmark), - typeof(StableCompositeDisposableBenchmark), - typeof(SubjectBenchmark), - typeof(ComparisonAsyncBenchmark), - typeof(GroupByCompletion) -#if (CURRENT) - ,typeof(AppendPrependBenchmark) - ,typeof(PrependVsStartWtihBenchmark) -#endif - ]); - - switcher.Run(); - Console.ReadLine(); + // Auto-discover every public, non-abstract class with [Benchmark] methods in this assembly. + // Profiling / runtime selection is driven by CLI args (e.g. --filter, -f/--runtimes, + // --profiler ETW|EP, --disasm), layered on top of the shared RxBenchmarkConfig. + BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args, RxBenchmarkConfig.Create()); } } } diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/RangeBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/RangeBenchmark.cs deleted file mode 100644 index 320e3469e1..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/RangeBenchmark.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class RangeBenchmark - { - [Params(1, 10, 100, 1000, 10000, 100000, 1000000)] - public int N; - private int _store; - - [Benchmark] - public void Range() - { - Observable.Range(1, N).Subscribe(v => Volatile.Write(ref _store, v)); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/RepeatBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/RepeatBenchmark.cs deleted file mode 100644 index 229756df38..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/RepeatBenchmark.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class RepeatBenchmark - { - [Params(1, 10, 100, 1000, 10000, 100000, 1000000)] - public int N; - - public int _store; - - [Benchmark] - public void Repeat_Infinite() - { - Observable.Repeat(1).Take(N).Subscribe(v => Volatile.Write(ref _store, v)); - } - - [Benchmark] - public void Repeat_Finite() - { - Observable.Repeat(1, N).Subscribe(v => Volatile.Write(ref _store, v)); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ScalarScheduleBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ScalarScheduleBenchmark.cs deleted file mode 100644 index 5c6fa9578c..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ScalarScheduleBenchmark.cs +++ /dev/null @@ -1,179 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class ScalarScheduleBenchmark - { - private int _store; - private Exception _exceptionStore; - - private IScheduler _eventLoop; - - private Exception _exception; - - [GlobalSetup] - public void Setup() - { - _eventLoop = new EventLoopScheduler(); - _exception = new Exception(); - } - - private void BlockingConsume(IObservable source) - { - var cde = new CountdownEvent(1); - - source.Subscribe(v => Volatile.Write(ref _store, v), - e => - { - Volatile.Write(ref _exceptionStore, e); - cde.Signal(); - }, - () => cde.Signal() - ); - - // spin-wait will result in faster completion detection - // because it takes 5 microseconds to resume a blocked thread - // for me on Windows - while (cde.CurrentCount != 0) ; - } - - private void ConsumeSync(IObservable source) - { - source.Subscribe(v => Volatile.Write(ref _store, v), e => Volatile.Write(ref _exceptionStore, e)); - } - - [Benchmark] - public void Return_Immediate() - { - ConsumeSync(Observable.Return(1, ImmediateScheduler.Instance)); - } - - [Benchmark] - public void Return_CurrentThread() - { - ConsumeSync(Observable.Return(1, CurrentThreadScheduler.Instance)); - } - - [Benchmark] - public void Return_EventLoop() - { - BlockingConsume(Observable.Return(1, _eventLoop)); - } - - [Benchmark] - public void Return_TaskPool() - { - BlockingConsume(Observable.Return(1, TaskPoolScheduler.Default)); - } - - [Benchmark] - public void Return_ThreadPool() - { - BlockingConsume(Observable.Return(1, ThreadPoolScheduler.Instance)); - } - - [Benchmark] - public void Throw_Immediate() - { - ConsumeSync(Observable.Throw(_exception, ImmediateScheduler.Instance)); - } - - [Benchmark] - public void Throw_CurrentThread() - { - ConsumeSync(Observable.Throw(_exception, CurrentThreadScheduler.Instance)); - } - - [Benchmark] - public void Throw_EventLoop() - { - BlockingConsume(Observable.Throw(_exception, _eventLoop)); - } - - [Benchmark] - public void Throw_TaskPool() - { - BlockingConsume(Observable.Throw(_exception, TaskPoolScheduler.Default)); - } - - [Benchmark] - public void Throw_ThreadPool() - { - BlockingConsume(Observable.Throw(_exception, ThreadPoolScheduler.Instance)); - } - -#if CURRENT - - [Benchmark] - public void Prepend_Immediate() - { - ConsumeSync(Observable.Return(1, ImmediateScheduler.Instance).Prepend(0, ImmediateScheduler.Instance)); - } - - - [Benchmark] - public void Prepend_CurrentThread() - { - ConsumeSync(Observable.Return(1, CurrentThreadScheduler.Instance).Prepend(0, CurrentThreadScheduler.Instance)); - } - - [Benchmark] - public void Prepend_EventLoop() - { - BlockingConsume(Observable.Return(1, _eventLoop).Prepend(0, _eventLoop)); - } - - [Benchmark] - public void Prepend_TaskPool() - { - BlockingConsume(Observable.Return(1, TaskPoolScheduler.Default).Prepend(0, TaskPoolScheduler.Default)); - } - - [Benchmark] - public void Prepend_ThreadPool() - { - BlockingConsume(Observable.Return(1, ThreadPoolScheduler.Instance).Prepend(0, ThreadPoolScheduler.Instance)); - } - - [Benchmark] - public void Append_Immediate() - { - ConsumeSync(Observable.Return(1, ImmediateScheduler.Instance).Append(0, ImmediateScheduler.Instance)); - } - - - [Benchmark] - public void Append_CurrentThread() - { - ConsumeSync(Observable.Return(1, CurrentThreadScheduler.Instance).Append(0, CurrentThreadScheduler.Instance)); - } - - [Benchmark] - public void Append_EventLoop() - { - BlockingConsume(Observable.Return(1, _eventLoop).Append(0, _eventLoop)); - } - - [Benchmark] - public void Append_TaskPool() - { - BlockingConsume(Observable.Return(1, TaskPoolScheduler.Default).Append(0, TaskPoolScheduler.Default)); - } - - [Benchmark] - public void Append_ThreadPool() - { - BlockingConsume(Observable.Return(1, ThreadPoolScheduler.Instance).Append(0, ThreadPoolScheduler.Instance)); - } -#endif - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/SubjectBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/SubjectBenchmark.cs deleted file mode 100644 index 22b4317a53..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/SubjectBenchmark.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class SubjectBenchmark - { - [Params(1, 10, 100, 1000, 10000, 100000, 1000000)] - public int N; - - [Params(0, 1, 2, 3, 4, 5)] - public int M; - - private int _store; - - [Benchmark] - public object SubjectPush() - { - var subj = new Subject(); - var consumers = new IDisposable[M]; - var m = M; - for (var i = 0; i < m; i++) - { - consumers[i] = subj.Subscribe(v => Volatile.Write(ref _store, v)); - } - - var n = N; - for (var i = 0; i < n; i++) - { - subj.OnNext(i); - } - subj.OnCompleted(); - - return consumers; - } - - [Benchmark] - public object AsyncSubjectPush() - { - var subj = new AsyncSubject(); - var consumers = new IDisposable[M]; - var m = M; - for (var i = 0; i < m; i++) - { - consumers[i] = subj.Subscribe(v => Volatile.Write(ref _store, v)); - } - - var n = N; - for (var i = 0; i < n; i++) - { - subj.OnNext(i); - } - subj.OnCompleted(); - - return consumers; - } - - [Benchmark] - public object BehaviorSubjectPush() - { - var subj = new BehaviorSubject(-1); - var consumers = new IDisposable[M]; - var m = M; - for (var i = 0; i < m; i++) - { - consumers[i] = subj.Subscribe(v => Volatile.Write(ref _store, v)); - } - - var n = N; - for (var i = 0; i < n; i++) - { - subj.OnNext(i); - } - subj.OnCompleted(); - - return consumers; - } - - [Benchmark] - public object ReplaySubjectPush() - { - var subj = new ReplaySubject(); - var consumers = new IDisposable[M]; - var m = M; - for (var i = 0; i < m; i++) - { - consumers[i] = subj.Subscribe(v => Volatile.Write(ref _store, v)); - } - - var n = N; - for (var i = 0; i < n; i++) - { - subj.OnNext(i); - } - subj.OnCompleted(); - - return consumers; - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/SwitchBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/SwitchBenchmark.cs deleted file mode 100644 index 57258d7e21..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/SwitchBenchmark.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System.Reactive.Linq; -using BenchmarkDotNet.Attributes; -using System.Reactive.Threading.Tasks; -using System.Threading.Tasks; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class SwitchBenchmark - { - [Benchmark] - public async Task Switch_10000_Sources() - { - await Observable - .Range(1, 10000) - .Select(x => Observable.Return(x)) - .Switch() - .ToTask(); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ToObservableBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ToObservableBenchmark.cs deleted file mode 100644 index 9a5469659d..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ToObservableBenchmark.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class ToObservableBenchmark - { - [Params(1, 10, 100, 1000, 10000, 100000, 1000000)] - public int N; - - private int _store; - - [Benchmark] - public void Exact() - { - Enumerable.Range(1, N) - .ToObservable() - .Subscribe(v => Volatile.Write(ref _store, v)); - } - } -} diff --git a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ZipBenchmark.cs b/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ZipBenchmark.cs deleted file mode 100644 index a551b5b4af..0000000000 --- a/Rx.NET/Source/benchmarks/Benchmarks.System.Reactive/ZipBenchmark.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT License. -// See the LICENSE file in the project root for more information. - -using BenchmarkDotNet.Attributes; -using ReactiveTests.Tests; - -namespace Benchmarks.System.Reactive -{ - [MemoryDiagnoser] - public class ZipBenchmark - { - private readonly ZipTest _zipTest = new(); - - [Benchmark] - public void Zip_NAry_Asymmetric() - { - _zipTest.Zip_NAry_Asymmetric(); - } - - [Benchmark] - public void Zip_NAry_Asymmetric_Selector() - { - _zipTest.Zip_NAry_Asymmetric_Selector(); - } - - [Benchmark] - public void Zip_NAry_Symmetric() - { - _zipTest.Zip_NAry_Symmetric(); - } - - [Benchmark] - public void Zip_NAry_Symmetric_Selector() - { - _zipTest.Zip_NAry_Symmetric_Selector(); - } - - [Benchmark] - public void Zip_NAry_Enumerable_Simple() - { - _zipTest.Zip_NAry_Enumerable_Simple(); - } - - [Benchmark] - public void Zip_AllCompleted2() - { - _zipTest.Zip_AllCompleted2(); - } - - [Benchmark] - public void Zip_AllCompleted3() - { - _zipTest.Zip_AllCompleted3(); - } - - [Benchmark] - public void Zip_AllCompleted4() - { - _zipTest.Zip_AllCompleted4(); - } - - [Benchmark] - public void Zip_AllCompleted5() - { - _zipTest.Zip_AllCompleted5(); - } - - [Benchmark] - public void Zip_AllCompleted6() - { - _zipTest.Zip_AllCompleted6(); - } - - [Benchmark] - public void Zip_AllCompleted7() - { - _zipTest.Zip_AllCompleted7(); - } - - [Benchmark] - public void Zip_AllCompleted8() - { - _zipTest.Zip_AllCompleted8(); - } - - [Benchmark] - public void Zip_AllCompleted9() - { - _zipTest.Zip_AllCompleted9(); - } - - [Benchmark] - public void Zip_AllCompleted10() - { - _zipTest.Zip_AllCompleted10(); - } - - [Benchmark] - public void Zip_AllCompleted11() - { - _zipTest.Zip_AllCompleted11(); - } - - [Benchmark] - public void Zip_AllCompleted12() - { - _zipTest.Zip_AllCompleted12(); - } - - [Benchmark] - public void Zip_AllCompleted13() - { - _zipTest.Zip_AllCompleted13(); - } - - [Benchmark] - public void Zip_AllCompleted14() - { - _zipTest.Zip_AllCompleted14(); - } - - [Benchmark] - public void Zip_AllCompleted15() - { - _zipTest.Zip_AllCompleted15(); - } - - [Benchmark] - public void Zip_AllCompleted16() - { - _zipTest.Zip_AllCompleted16(); - } - } -} diff --git a/Rx.NET/Source/benchmarks/README.md b/Rx.NET/Source/benchmarks/README.md new file mode 100644 index 0000000000..cb16b583c7 --- /dev/null +++ b/Rx.NET/Source/benchmarks/README.md @@ -0,0 +1,106 @@ +# Rx.NET benchmark suite + +Infrastructure for measuring Rx.NET performance, built to support before/after comparisons during +performance work (e.g. the .NET 10 modernization spike). + +| Folder | Purpose | +|---|---| +| `Benchmarks.System.Reactive/` | The BenchmarkDotNet microbenchmark suite (~100 operator classes under `Operators//`, shared infrastructure under `Infrastructure/`). | +| `Rx.WorkloadHarness/` | A long-running console host for system-level telemetry (`dotnet-counters` / `dotnet-trace` / `dotnet-monitor`) — the steady-state GC/thread-pool view microbenchmarks can't give. | +| `tools/` | `capture-baseline.ps1` and `compare-results.ps1` — the A/B workflow (see below). | +| `baselines/`, `traces/` | Local run outputs. **Gitignored** — keep them locally or publish as CI artifacts. | + +## Scenario archetypes + +Every benchmark class follows one of four shapes: + +- **S1 throughput** — cold synchronous pipelines (`Observable.Range`), swept over `N` = 1…1,000,000 elements (`OperatorBenchmarkBase`). +- **S2 hot push** — a pumped `Subject` exercising the per-element `OnNext` dispatch path. +- **S3 temporal** — time-based operators driven in *virtual time* (`HistoricalScheduler` / + `PeriodicVirtualScheduler`), so even dense timelines complete in microseconds of wall clock with + zero timing noise. Operators with a periodic path (`Interval`, `Timer`, `Sample`, `Buffer(time)`, + `Window(time)`) use `PeriodicVirtualScheduler`, which implements `ISchedulerPeriodic` so Rx's real + periodic fast path is measured rather than the stopwatch-emulated fallback. +- **S4 subscription/lifecycle** — construction, subscription, and disposal costs in isolation. + +Results are consumed through a shared `Consumer` sink (defeating dead-code elimination); async +pipelines block on real completion (`SubscribeBlocking`). `MemoryDiagnoser` is applied globally, so +allocation is a first-class column everywhere, alongside per-element `ns/N` (mean time / N) and +`B/N` (allocated bytes / N) columns for classes with an `N` parameter. + +Reading the per-element columns: + +- Small-N rows are dominated by fixed subscription/setup cost, so `ns/N` and `B/N` only converge + to the true steady-state per-element cost as N grows (e.g. `Select`: 544 `B/N` at N=1 flattening + to a constant 64 `B/N` from N≈1,000 up). Read the large-N rows for per-element cost; read the + small-N rows for per-subscription overhead. +- A `B/N` that stays flat as N grows means the operator allocates *per element* — the primary + zero-allocation target the columns exist to surface. +- For fan-out classes (SelectMany/Merge/Switch cross-map variants), `N` is the fan-out width with + total work pinned, so the figures read as per-subscription rather than per-element. +- The columns are display-only; the exported JSON keeps raw statistics, and `compare-results.ps1` + recomputes per-element values itself. + +## Running + +```powershell +cd Benchmarks.System.Reactive + +# Everything (long!) on .NET 10 +dotnet run -c Release -f net10.0 -- --filter * + +# One class / one category +dotnet run -c Release -f net10.0 -- --filter *SelectBenchmarks* +dotnet run -c Release -f net10.0 -- --anyCategories Time + +# Cross-runtime comparison in one table +dotnet run -c Release -f net10.0 -- --filter *SelectBenchmarks* --runtimes net472 net8.0 net10.0 + +# Fast sanity pass (no statistics — just checks benchmarks execute) +dotnet run -c Release -f net10.0 -- --filter * --job Dry +``` + +Opt-in profiling (layered on the shared config per run): + +```powershell +dotnet run -c Release -f net10.0 -- --filter *Merge* --profiler ETW # Windows, elevated: ETW traces +dotnet run -c Release -f net10.0 -- --filter *Merge* --profiler EP # cross-platform EventPipe +dotnet run -c Release -f net10.0 -- --filter *Select* --disasm # JIT disassembly +``` + +Note: `System.Reactive` currently tops out at `net8.0`, so net9/net10 runs execute the net8-compiled +assembly on the newer runtime — they show JIT/runtime gains only. Product code-path changes need a +same-TFM before/after comparison, which is what the tools below provide. + +## Before/after workflow (A/B) + +```powershell +# 1. On the baseline commit: +tools\capture-baseline.ps1 -Filter * -Framework net10.0 + +# 2. Check out the candidate commit (or apply your change), then: +tools\capture-baseline.ps1 -Filter * -Framework net10.0 + +# 3. Compare the two stamped folders: +tools\compare-results.ps1 -Baseline baselines\2026-07-01-abc1234 -Candidate baselines\2026-07-02-def5678 +``` + +`capture-baseline.ps1` runs the suite, stamps results into `baselines/-[-label]/results/` +(full JSON + GitHub markdown + CSV per class), and records the environment (commit, branch, +`dotnet --info`) alongside. It refuses to stamp a dirty working tree unless `-AllowDirty` is passed. + +`compare-results.ps1` matches benchmarks by full name (including parameter values) across the two +result sets, classifies each as regression / improvement / unchanged using a ratio threshold +(default 2%) plus the reported confidence intervals, and writes a markdown report sorted +worst-regression-first. + +## Workload harness (steady-state telemetry) + +```powershell +cd Rx.WorkloadHarness +dotnet run -c Release -f net10.0 -- buffer --rate-ms 1 # runs until Ctrl+C, prints its PID +dotnet-counters monitor -p System.Runtime # from a second terminal +``` + +Workloads: `buffer` (default; allocates a `List` per batch as a visible GC signal), `select`, +`merge`, `groupby`. Use `--seconds N` for a bounded run. diff --git a/Rx.NET/Source/benchmarks/Rx.WorkloadHarness/Program.cs b/Rx.NET/Source/benchmarks/Rx.WorkloadHarness/Program.cs new file mode 100644 index 0000000000..c11ecf6031 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Rx.WorkloadHarness/Program.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading; + +namespace Rx.WorkloadHarness +{ + /// + /// A long-running host that runs a sustained Rx pipeline so live runtime telemetry can be collected + /// with dotnet-counters / dotnet-trace / dotnet-monitor — the system-level view + /// (steady-state GC, memory growth, thread-pool saturation, contention) that the microbenchmarks + /// cannot show. Prints its PID and runs until Ctrl+C. + /// + /// + /// Usage: dotnet run -c Release -f net10.0 -- [buffer|select|merge|groupby] [--rate-ms N]. + /// Then, in another shell: dotnet-counters monitor -p <pid> System.Runtime. + /// + internal static class Program + { + private static void Main(string[] args) + { + var workload = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal) + ? args[0].ToLowerInvariant() + : "buffer"; + var rateMs = GetIntOption(args, "--rate-ms", 1); + var seconds = GetIntOption(args, "--seconds", 0); // 0 = run until Ctrl+C; > 0 = auto-stop (handy for CI/smoke runs) + if (rateMs < 1) + { + Console.Error.WriteLine("--rate-ms must be >= 1."); + Environment.Exit(1); + } + + if (seconds < 0) + { + Console.Error.WriteLine("--seconds must be >= 0 (0 = run until Ctrl+C)."); + Environment.Exit(1); + } + + var period = TimeSpan.FromMilliseconds(rateMs); + + var pid = Environment.ProcessId; + Console.WriteLine($"Rx.WorkloadHarness PID={pid} workload='{workload}' rate={rateMs}ms"); + Console.WriteLine($" dotnet-counters monitor -p {pid} System.Runtime"); + Console.WriteLine($" dotnet-trace collect -p {pid}"); + Console.WriteLine(seconds > 0 ? $"Auto-stops after {seconds}s (or press Ctrl+C)." : "Press Ctrl+C to stop."); + + var pipeline = BuildPipeline(workload, Observable.Interval(period)); + + using var stop = new ManualResetEventSlim(false); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); }; + using var autoStop = seconds > 0 + ? new Timer(_ => stop.Set(), null, TimeSpan.FromSeconds(seconds), Timeout.InfiniteTimeSpan) + : null; + + var observed = 0L; + using (pipeline.Subscribe(_ => Interlocked.Increment(ref observed))) + { + stop.Wait(); + } + + Console.WriteLine($"Stopped. Observed {Volatile.Read(ref observed)} notifications."); + } + + // A handful of representative sustained pipelines. "buffer" allocates a list per batch, which is a + // deliberately visible GC signal; the others exercise transform / multicast / grouping paths. + private static IObservable BuildPipeline(string workload, IObservable source) => workload switch + { + "select" => source.Select(x => x + 1).Where(x => (x & 1L) == 0L).Select(x => (object)x), + "merge" => Observable.Merge(source, source.Select(x => x * 2), source.Select(x => x * 3)).Select(x => (object)x), + "groupby" => source.GroupBy(x => x % 8).SelectMany(g => g.Select(x => (object)x)), + _ => source.Buffer(64).Select(batch => (object)batch), + }; + + private static int GetIntOption(IReadOnlyList args, string name, int fallback) + { + for (var i = 0; i < args.Count - 1; i++) + { + if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase) && int.TryParse(args[i + 1], out var value)) + { + return value; + } + } + + return fallback; + } + } +} diff --git a/Rx.NET/Source/benchmarks/Rx.WorkloadHarness/Rx.WorkloadHarness.csproj b/Rx.NET/Source/benchmarks/Rx.WorkloadHarness/Rx.WorkloadHarness.csproj new file mode 100644 index 0000000000..2ae7256798 --- /dev/null +++ b/Rx.NET/Source/benchmarks/Rx.WorkloadHarness/Rx.WorkloadHarness.csproj @@ -0,0 +1,24 @@ + + + + Exe + + net8.0;net9.0;net10.0 + false + + + + 7.0-none + 7.0-none + $(NoWarn);IDE1006 + + + + + + + + + + + diff --git a/Rx.NET/Source/benchmarks/tools/capture-baseline.ps1 b/Rx.NET/Source/benchmarks/tools/capture-baseline.ps1 new file mode 100644 index 0000000000..ce9aaa5857 --- /dev/null +++ b/Rx.NET/Source/benchmarks/tools/capture-baseline.ps1 @@ -0,0 +1,85 @@ +<# +.SYNOPSIS +Runs the benchmark suite and stamps the results into baselines/-[-label]/. + +.DESCRIPTION +Wraps `dotnet run` over Benchmarks.System.Reactive so every capture lands in a dated, +commit-stamped folder together with the environment it was measured on (commit, branch, +dotnet --info). The stamped folders are the inputs to compare-results.ps1. + +Refuses to run with uncommitted changes (the stamped commit SHA would lie about what was +measured) unless -AllowDirty is passed. + +.EXAMPLE +tools\capture-baseline.ps1 -Filter *SelectBenchmarks* -Framework net10.0 + +.EXAMPLE +tools\capture-baseline.ps1 -Filter * -Label pre-spike -ExtraArgs @('--runtimes','net8.0','net10.0') +#> +[CmdletBinding()] +param( + [string] $Filter = '*', + [string] $Framework = 'net10.0', + [string] $Label, + [string[]] $ExtraArgs = @(), + [switch] $AllowDirty +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$benchmarksRoot = Split-Path -Parent $PSScriptRoot +$project = Join-Path $benchmarksRoot 'Benchmarks.System.Reactive' +if (-not (Test-Path (Join-Path $project 'Benchmarks.System.Reactive.csproj'))) { + throw "Benchmark project not found under $project" +} + +$dirty = git -C $benchmarksRoot status --porcelain +if ($dirty -and -not $AllowDirty) { + throw "Working tree is dirty - the stamped commit SHA would not describe what is measured. Commit/stash first, or pass -AllowDirty." +} + +$sha = (git -C $benchmarksRoot rev-parse --short HEAD).Trim() +$fullSha = (git -C $benchmarksRoot rev-parse HEAD).Trim() +$branch = (git -C $benchmarksRoot rev-parse --abbrev-ref HEAD).Trim() +$date = Get-Date -Format 'yyyy-MM-dd' + +$folderName = "$date-$sha" +if ($Label) { + # Sanitize to a safe filename segment so the label cannot contain path separators or '..'. + $safeLabel = ($Label -replace '[^A-Za-z0-9_.-]', '-').Trim('.') + if (-not $safeLabel) { throw "Label '$Label' contains no usable filename characters." } + $folderName += "-$safeLabel" +} +$dest = Join-Path (Join-Path $benchmarksRoot 'baselines') $folderName + +if (Test-Path (Join-Path $dest 'results')) { + Write-Warning "Destination $dest already has results; new results will be merged into it." +} +New-Item -ItemType Directory -Force $dest | Out-Null + +Write-Host "Capturing '$Filter' on $Framework at $sha ($branch) -> $dest" + +# BenchmarkDotNet puts logs in the artifacts root and the exported reports in results/. +& dotnet run -c Release -f $Framework --project $project -- ` + --filter $Filter --artifacts $dest @ExtraArgs +if ($LASTEXITCODE -ne 0) { + throw "Benchmark run failed with exit code $LASTEXITCODE" +} + +dotnet --info | Out-File -FilePath (Join-Path $dest 'dotnet-info.txt') -Encoding utf8 + +[ordered]@{ + commit = $fullSha + shortSha = $sha + branch = $branch + date = (Get-Date -Format 'o') + framework = $Framework + filter = $Filter + extraArgs = $ExtraArgs + dirty = [bool]$dirty + machine = $env:COMPUTERNAME + os = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription +} | ConvertTo-Json | Out-File -FilePath (Join-Path $dest 'environment.json') -Encoding utf8 + +Write-Host "Done. Results in $(Join-Path $dest 'results')" diff --git a/Rx.NET/Source/benchmarks/tools/compare-results.ps1 b/Rx.NET/Source/benchmarks/tools/compare-results.ps1 new file mode 100644 index 0000000000..d106e010a6 --- /dev/null +++ b/Rx.NET/Source/benchmarks/tools/compare-results.ps1 @@ -0,0 +1,210 @@ +<# +.SYNOPSIS +Compares two benchmark result sets (BenchmarkDotNet full-JSON exports) and reports +regressions and improvements. + +.DESCRIPTION +Matches benchmarks across the two folders by FullName (which includes parameter values, +e.g. 'SelectBenchmarks.Select(N: 1000)'). Each match is classified on mean time: + + - Unchanged : |ratio - 1| <= threshold, or the two confidence intervals overlap + - Regression : candidate slower beyond the threshold and outside the noise + - Improvement : candidate faster beyond the threshold and outside the noise + +Allocation changes (BytesAllocatedPerOperation) are reported separately - allocations are +near-deterministic, so any change there is signal even when timing is noisy. + +Writes a markdown report (worst regression first) and prints a summary. + +.EXAMPLE +tools\compare-results.ps1 -Baseline baselines\2026-07-01-abc1234 -Candidate baselines\2026-07-02-def5678 + +.EXAMPLE +tools\compare-results.ps1 -Baseline old -Candidate new -ThresholdPercent 5 -OutFile compare.md +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Baseline, + [Parameter(Mandatory)] [string] $Candidate, + [double] $ThresholdPercent = 2.0, + [string] $OutFile +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$inv = [System.Globalization.CultureInfo]::InvariantCulture + +function Load-Results([string] $folder) { + if (-not (Test-Path $folder)) { throw "Folder not found: $folder" } + $files = Get-ChildItem -Path $folder -Recurse -Filter '*-report-full.json' + if (-not $files) { throw "No *-report-full.json files under $folder" } + + $map = @{} + foreach ($file in $files) { + $doc = Get-Content $file.FullName -Raw | ConvertFrom-Json + foreach ($b in $doc.Benchmarks) { + $n = $null + if ($b.Parameters) { + # BDN joins multiple parameters with '&' (e.g. 'Density=Boundary&N=100') + foreach ($pair in ($b.Parameters -split '[&,]\s*')) { + $kv = $pair -split '=', 2 + if ($kv.Length -eq 2 -and $kv[0].Trim() -eq 'N') { + $parsed = 0L + if ([long]::TryParse($kv[1].Trim(), [ref]$parsed)) { $n = $parsed } + } + } + } + + $stats = $b.Statistics + if ($null -eq $stats) { continue } # failed/skipped case + + $allocated = $null + if ($b.PSObject.Properties['Memory'] -and $null -ne $b.Memory) { + $allocated = $b.Memory.BytesAllocatedPerOperation + } + + $map[$b.FullName] = [pscustomobject]@{ + FullName = $b.FullName + Mean = [double]$stats.Mean + CiLower = [double]$stats.ConfidenceInterval.Lower + CiUpper = [double]$stats.ConfidenceInterval.Upper + Allocated = $allocated + N = $n + } + } + } + + return $map +} + +function Format-Num([object] $value, [string] $format = 'N1') { + if ($null -eq $value) { return '-' } + return ([double]$value).ToString($format, $inv) +} + +$baseMap = Load-Results $Baseline +$candMap = Load-Results $Candidate +$threshold = $ThresholdPercent / 100.0 + +$regressions = [System.Collections.Generic.List[object]]::new() +$improvements = [System.Collections.Generic.List[object]]::new() +$unchanged = [System.Collections.Generic.List[object]]::new() +$allocChanges = [System.Collections.Generic.List[object]]::new() + +foreach ($name in ($baseMap.Keys | Where-Object { $candMap.ContainsKey($_) })) { + $b = $baseMap[$name] + $c = $candMap[$name] + if ($b.Mean -le 0) { continue } + + $ratio = $c.Mean / $b.Mean + $ciOverlap = ($c.CiLower -le $b.CiUpper) -and ($b.CiLower -le $c.CiUpper) + + $row = [pscustomobject]@{ + FullName = $name + BaseMean = $b.Mean + CandMean = $c.Mean + Ratio = $ratio + WithinNoise = $ciOverlap + BaseAllocated = $b.Allocated + CandAllocated = $c.Allocated + N = $c.N + } + + if ([math]::Abs($ratio - 1) -le $threshold -or $ciOverlap) { + $unchanged.Add($row) + } + elseif ($ratio -gt 1) { + $regressions.Add($row) + } + else { + $improvements.Add($row) + } + + if ($null -ne $b.Allocated -and $null -ne $c.Allocated -and $b.Allocated -ne $c.Allocated) { + $allocChanges.Add($row) + } +} + +$onlyBase = @($baseMap.Keys | Where-Object { -not $candMap.ContainsKey($_) } | Sort-Object) +$onlyCand = @($candMap.Keys | Where-Object { -not $baseMap.ContainsKey($_) } | Sort-Object) + +function Format-Table([object[]] $rows) { + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Benchmark | Base Mean (ns) | Cand Mean (ns) | Ratio | ns/N (cand) | Base B/op | Cand B/op |') + $lines.Add('|---|---:|---:|---:|---:|---:|---:|') + foreach ($r in $rows) { + $perElement = if ($null -ne $r.N -and $r.N -gt 0) { Format-Num ($r.CandMean / $r.N) 'N2' } else { '-' } + $lines.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} |' -f + $r.FullName, + (Format-Num $r.BaseMean), + (Format-Num $r.CandMean), + $r.Ratio.ToString('N3', $inv), + $perElement, + (Format-Num $r.BaseAllocated 'N0'), + (Format-Num $r.CandAllocated 'N0'))) + } + return $lines +} + +$report = [System.Collections.Generic.List[string]]::new() +$report.Add('# Benchmark comparison') +$report.Add('') +$report.Add("- **Baseline:** $Baseline") +$report.Add("- **Candidate:** $Candidate") +$report.Add("- **Threshold:** $ThresholdPercent% (plus confidence-interval overlap check)") +$report.Add("- Matched: $($regressions.Count + $improvements.Count + $unchanged.Count), regressions: $($regressions.Count), improvements: $($improvements.Count), unchanged: $($unchanged.Count)") +$report.Add('') + +$report.Add("## Regressions ($($regressions.Count))") +$report.Add('') +if ($regressions.Count -gt 0) { + $report.AddRange([string[]](Format-Table ($regressions | Sort-Object Ratio -Descending))) +} else { $report.Add('None.') } +$report.Add('') + +$report.Add("## Improvements ($($improvements.Count))") +$report.Add('') +if ($improvements.Count -gt 0) { + $report.AddRange([string[]](Format-Table ($improvements | Sort-Object Ratio))) +} else { $report.Add('None.') } +$report.Add('') + +$report.Add("## Allocation changes ($($allocChanges.Count))") +$report.Add('') +if ($allocChanges.Count -gt 0) { + $report.AddRange([string[]](Format-Table ($allocChanges | Sort-Object { [double]$_.CandAllocated - [double]$_.BaseAllocated } -Descending))) +} else { $report.Add('None.') } +$report.Add('') + +if ($onlyBase.Count -gt 0) { + $report.Add("## Only in baseline ($($onlyBase.Count))") + $report.Add('') + foreach ($name in $onlyBase) { $report.Add("- $name") } + $report.Add('') +} +if ($onlyCand.Count -gt 0) { + $report.Add("## Only in candidate ($($onlyCand.Count))") + $report.Add('') + foreach ($name in $onlyCand) { $report.Add("- $name") } + $report.Add('') +} + +if (-not $OutFile) { + $baseName = Split-Path -Leaf ($Baseline.TrimEnd('\', '/')) + $OutFile = Join-Path $Candidate "compare-vs-$baseName.md" +} +$report | Out-File -FilePath $OutFile -Encoding utf8 + +Write-Host ("Matched {0} benchmarks: {1} regressions, {2} improvements, {3} unchanged; {4} allocation changes; {5} only-in-baseline, {6} only-in-candidate." -f + ($regressions.Count + $improvements.Count + $unchanged.Count), + $regressions.Count, $improvements.Count, $unchanged.Count, + $allocChanges.Count, $onlyBase.Count, $onlyCand.Count) +Write-Host "Report: $OutFile" + +if ($regressions.Count -gt 0) { + Write-Host '' + Write-Host 'Worst regressions:' + $regressions | Sort-Object Ratio -Descending | Select-Object -First 10 | ForEach-Object { + Write-Host (" {0} x{1}" -f $_.FullName, $_.Ratio.ToString('N3', $inv)) + } +}