diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs index 82db74c4..04f4e9bf 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs @@ -354,6 +354,10 @@ public IObservableAsync GetMin(params IObservableAsync[] sources) var allSources = new IObservableAsync[sources.Length + 1]; allSources[0] = source; sources.CopyTo(allSources, 1); +#if NET11_0_OR_GREATER + // The coordinator supplies its reusable array without copying it. + return new SyncLatestEnumerableSignal(allSources, static values => ((ReadOnlySpan)(T[])values).Min()); +#else return new SyncLatestEnumerableSignal(allSources, static values => { var min = values[0]; @@ -367,6 +371,7 @@ public IObservableAsync GetMin(params IObservableAsync[] sources) return min; }); +#endif } /// Returns the maximum of the latest values from the supplied source sequences. @@ -380,6 +385,10 @@ public IObservableAsync GetMax(params IObservableAsync[] sources) var allSources = new IObservableAsync[sources.Length + 1]; allSources[0] = source; sources.CopyTo(allSources, 1); +#if NET11_0_OR_GREATER + // The coordinator supplies its reusable array without copying it. + return new SyncLatestEnumerableSignal(allSources, static values => ((ReadOnlySpan)(T[])values).Max()); +#else return new SyncLatestEnumerableSignal(allSources, static values => { var max = values[0]; @@ -393,6 +402,7 @@ public IObservableAsync GetMax(params IObservableAsync[] sources) return max; }); +#endif } } diff --git a/src/ReactiveUI.Primitives.Async.Core/ReactiveUI.Primitives.Async.Core.csproj b/src/ReactiveUI.Primitives.Async.Core/ReactiveUI.Primitives.Async.Core.csproj index dbbe6f46..66db9c5e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ReactiveUI.Primitives.Async.Core.csproj +++ b/src/ReactiveUI.Primitives.Async.Core/ReactiveUI.Primitives.Async.Core.csproj @@ -6,7 +6,6 @@ $(LibraryTargetFrameworks) enable enable - preview ReactiveUI.Primitives.Async Type-agnostic core of ReactiveUI.Primitives.Async, shared by the lean and Reactive leaves. diff --git a/src/ReactiveUI.Primitives.Async.Reactive/ReactiveUI.Primitives.Async.Reactive.csproj b/src/ReactiveUI.Primitives.Async.Reactive/ReactiveUI.Primitives.Async.Reactive.csproj index e4f43e75..928ed1b2 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/ReactiveUI.Primitives.Async.Reactive.csproj +++ b/src/ReactiveUI.Primitives.Async.Reactive/ReactiveUI.Primitives.Async.Reactive.csproj @@ -6,7 +6,6 @@ $(LibraryTargetFrameworks) enable enable - preview ReactiveUI.Primitives.Async recompiled against System.Reactive's Unit and IScheduler for seamless interop with System.Reactive consumers. diff --git a/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj b/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj index 017fb865..8bc1a4f1 100644 --- a/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj +++ b/src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj @@ -2,7 +2,6 @@ $(LibraryTargetFrameworks) - preview diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/MinMaxReductionBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/MinMaxReductionBenchmarks.cs new file mode 100644 index 00000000..58fb0f5d --- /dev/null +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/MinMaxReductionBenchmarks.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; + +namespace ReactiveUI.Primitives.Benchmarks; + +/// Compares the latest-value reduction used by asynchronous minimum operators. +[MemoryDiagnoser] +[System.Diagnostics.DebuggerDisplay("MinMaxReductionBenchmarks: Sources = {SourceCount}")] +public class MinMaxReductionBenchmarks +{ + /// A stride coprime to every source count produces a repeatable permutation. + private const int ValueStride = 17; + + /// Offsets the permutation so its first value need not be the minimum. + private const int ValueOffset = 42; + + /// The coordinator exposes its reusable array through this interface. + [SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance", + Justification = "The baseline must measure the coordinator's IReadOnlyList dispatch; an array field would change the measured code.")] + private IReadOnlyList _values = []; + + /// Gets or sets the number of latest values in the snapshot. + [Params(3, 8, 32, 128, 1024)] + public int SourceCount { get; set; } + + /// Creates one snapshot outside the measured operations. + [GlobalSetup] + public void Setup() + { + var values = new int[SourceCount]; + for (var i = 0; i < values.Length; i++) + { + values[i] = ((i * ValueStride) + ValueOffset) % values.Length; + } + + _values = values; + } + + /// Reduces the snapshot through the existing scalar comparison loop. + /// The minimum value. + [Benchmark(Baseline = true)] + public int ScalarMinimum() + { + var minimum = _values[0]; + for (var i = 1; i < _values.Count; i++) + { + if (Comparer.Default.Compare(_values[i], minimum) < 0) + { + minimum = _values[i]; + } + } + + return minimum; + } + +#if NET11_0_OR_GREATER + /// Reduces the same snapshot with the .NET 11 span implementation. + /// The minimum value. + [Benchmark] + public int SpanMinimum() => ((ReadOnlySpan)(int[])_values).Min(); +#endif +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs index 43facb28..a2781332 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs @@ -14,6 +14,102 @@ public sealed class SignalAsyncExtensionsTests /// The second distinct value in an ordered sequence. private const int SecondValue = 2; + /// Minimum and maximum reductions include values beyond vector boundaries. + /// The number of latest values to reduce. + /// The test operation. + [Test] + [Arguments(3)] + [Arguments(8)] + [Arguments(17)] + [Arguments(65)] + public async Task GetMinMax_IntegerInputs_FindExtremesAcrossVectorBoundaries(int sourceCount) + { + var sources = new IObservableAsync[sourceCount - 1]; + for (var i = 0; i < sources.Length; i++) + { + sources[i] = SignalAsync.Return(i); + } + + sources[^1] = SignalAsync.Return(int.MaxValue); + var first = SignalAsync.Return(int.MinValue); + + await Assert.That(await first.GetMin(sources).FirstAsync()).IsEqualTo(int.MinValue); + await Assert.That(await first.GetMax(sources).FirstAsync()).IsEqualTo(int.MaxValue); + } + + /// Each update reduces the current values after all sources have emitted. + /// Cancels the test's signal notifications. + /// The test operation. + [Test] + public async Task GetMinMax_Updates_UseCurrentValues(CancellationToken cancellationToken) + { + const int InitialMinimum = 3; + const int MiddleValue = 6; + const int InitialMaximum = 9; + const int UpdatedMaximum = 12; + await using var first = Signal.Create(); + await using var second = Signal.Create(); + await using var third = Signal.Create(); + List minima = []; + List maxima = []; + await using var minimum = await first.Values.GetMin(second.Values, third.Values).SubscribeAsync(minima.Add, cancellationToken); + await using var maximum = await first.Values.GetMax(second.Values, third.Values).SubscribeAsync(maxima.Add, cancellationToken); + + await first.OnNextAsync(InitialMinimum, cancellationToken); + await second.OnNextAsync(MiddleValue, cancellationToken); + await Assert.That(minima).IsEmpty(); + await Assert.That(maxima).IsEmpty(); + + await third.OnNextAsync(InitialMaximum, cancellationToken); + await first.OnNextAsync(UpdatedMaximum, cancellationToken); + await third.OnNextAsync(1, cancellationToken); + + await Assert.That(minima).IsCollectionEqualTo([InitialMinimum, MiddleValue, 1]); + await Assert.That(maxima).IsCollectionEqualTo([InitialMaximum, UpdatedMaximum, UpdatedMaximum]); + } + + /// Floating-point reductions retain the default comparer's NaN ordering. + /// The test operation. + [Test] + public async Task GetMinMax_FloatingPointInputs_PreserveNaNOrdering() + { + var source = SignalAsync.Return(double.NaN); + IObservableAsync[] others = [SignalAsync.Return((double)SecondValue), SignalAsync.Return(1D)]; + + await Assert.That(double.IsNaN(await source.GetMin(others).FirstAsync())).IsTrue(); + await Assert.That(await source.GetMax(others).FirstAsync()).IsEqualTo((double)SecondValue); + } + + /// Equal floating-point values retain the first value's zero sign. + /// The test operation. + [Test] + public async Task GetMinMax_EqualZeroValues_PreserveFirstSign() + { + const double NegativeZero = -0D; + var source = SignalAsync.Return(NegativeZero); + var second = SignalAsync.Return(0D); + + var minimum = await source.GetMin(second).FirstAsync(); + var maximum = await source.GetMax(second).FirstAsync(); + + await Assert.That(BitConverter.DoubleToInt64Bits(minimum)).IsEqualTo(long.MinValue); + await Assert.That(BitConverter.DoubleToInt64Bits(maximum)).IsEqualTo(long.MinValue); + } + + /// Non-numeric values continue to use their default comparison contract. + /// The test operation. + [Test] + public async Task GetMinMax_DateInputs_UseDefaultComparer() + { + var earliest = DateTime.UnixEpoch; + var latest = earliest.AddDays(1); + var source = SignalAsync.Return(latest); + var second = SignalAsync.Return(earliest); + + await Assert.That(await source.GetMin(second).FirstAsync()).IsEqualTo(earliest); + await Assert.That(await source.GetMax(second).FirstAsync()).IsEqualTo(latest); + } + /// Chaining an enumerable preserves source order. /// The test operation. [Test]