Skip to content

Commit a914521

Browse files
authored
perf: speed up async min/max on .NET 11 (#212)
- Use span min/max over the existing async snapshot buffer on .NET 11. - Add comparison regression tests and a reduction benchmark. - Use stable C# 15 while retaining the runtime async compiler switch.
1 parent 6dab874 commit a914521

6 files changed

Lines changed: 174 additions & 3 deletions

File tree

‎src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ public IObservableAsync GetMin(params IObservableAsync[] sources)
354354
var allSources = new IObservableAsync<T>[sources.Length + 1];
355355
allSources[0] = source;
356356
sources.CopyTo(allSources, 1);
357+
#if NET11_0_OR_GREATER
358+
// The coordinator supplies its reusable array without copying it.
359+
return new SyncLatestEnumerableSignal<T, T>(allSources, static values => ((ReadOnlySpan<T>)(T[])values).Min());
360+
#else
357361
return new SyncLatestEnumerableSignal<T, T>(allSources, static values =>
358362
{
359363
var min = values[0];
@@ -367,6 +371,7 @@ public IObservableAsync GetMin(params IObservableAsync[] sources)
367371

368372
return min;
369373
});
374+
#endif
370375
}
371376

372377
/// Returns the maximum of the latest values from the supplied source sequences.
@@ -380,6 +385,10 @@ public IObservableAsync GetMax(params IObservableAsync[] sources)
380385
var allSources = new IObservableAsync<T>[sources.Length + 1];
381386
allSources[0] = source;
382387
sources.CopyTo(allSources, 1);
388+
#if NET11_0_OR_GREATER
389+
// The coordinator supplies its reusable array without copying it.
390+
return new SyncLatestEnumerableSignal<T, T>(allSources, static values => ((ReadOnlySpan<T>)(T[])values).Max());
391+
#else
383392
return new SyncLatestEnumerableSignal<T, T>(allSources, static values =>
384393
{
385394
var max = values[0];
@@ -393,6 +402,7 @@ public IObservableAsync GetMax(params IObservableAsync[] sources)
393402

394403
return max;
395404
});
405+
#endif
396406
}
397407
}
398408

‎src/ReactiveUI.Primitives.Async.Core/ReactiveUI.Primitives.Async.Core.csproj‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
<TargetFrameworks>$(LibraryTargetFrameworks)TargetFrameworks>
77
<ImplicitUsings>enableImplicitUsings>
88
<Nullable>enableNullable>
9-
<LangVersion>previewLangVersion>
109
1110
<RootNamespace>ReactiveUI.Primitives.AsyncRootNamespace>
1211
<PackageDescription>Type-agnostic core of ReactiveUI.Primitives.Async, shared by the lean and Reactive leaves.PackageDescription>

‎src/ReactiveUI.Primitives.Async.Reactive/ReactiveUI.Primitives.Async.Reactive.csproj‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
<TargetFrameworks>$(LibraryTargetFrameworks)TargetFrameworks>
77
<ImplicitUsings>enableImplicitUsings>
88
<Nullable>enableNullable>
9-
<LangVersion>previewLangVersion>
109
<PackageDescription>ReactiveUI.Primitives.Async recompiled against System.Reactive's Unit and IScheduler for seamless interop with System.Reactive consumers.PackageDescription>
1110
PropertyGroup>
1211

‎src/ReactiveUI.Primitives.Async/ReactiveUI.Primitives.Async.csproj‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
<PropertyGroup>
44
<TargetFrameworks>$(LibraryTargetFrameworks)TargetFrameworks>
5-
<LangVersion>previewLangVersion>
65
PropertyGroup>
76

87
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System.Diagnostics.CodeAnalysis;
6+
using BenchmarkDotNet.Attributes;
7+
8+
namespace ReactiveUI.Primitives.Benchmarks;
9+
10+
/// Compares the latest-value reduction used by asynchronous minimum operators.
11+
[MemoryDiagnoser]
12+
[System.Diagnostics.DebuggerDisplay("MinMaxReductionBenchmarks: Sources = {SourceCount}")]
13+
public class MinMaxReductionBenchmarks
14+
{
15+
/// A stride coprime to every source count produces a repeatable permutation.
16+
private const int ValueStride = 17;
17+
18+
/// Offsets the permutation so its first value need not be the minimum.
19+
private const int ValueOffset = 42;
20+
21+
/// The coordinator exposes its reusable array through this interface.
22+
[SuppressMessage(
23+
"Performance",
24+
"CA1859:Use concrete types when possible for improved performance",
25+
Justification = "The baseline must measure the coordinator's IReadOnlyList dispatch; an array field would change the measured code.")]
26+
private IReadOnlyList<int> _values = [];
27+
28+
/// Gets or sets the number of latest values in the snapshot.
29+
[Params(3, 8, 32, 128, 1024)]
30+
public int SourceCount { get; set; }
31+
32+
/// Creates one snapshot outside the measured operations.
33+
[GlobalSetup]
34+
public void Setup()
35+
{
36+
var values = new int[SourceCount];
37+
for (var i = 0; i < values.Length; i++)
38+
{
39+
values[i] = ((i * ValueStride) + ValueOffset) % values.Length;
40+
}
41+
42+
_values = values;
43+
}
44+
45+
/// Reduces the snapshot through the existing scalar comparison loop.
46+
/// The minimum value.
47+
[Benchmark(Baseline = true)]
48+
public int ScalarMinimum()
49+
{
50+
var minimum = _values[0];
51+
for (var i = 1; i < _values.Count; i++)
52+
{
53+
if (Comparer<int>.Default.Compare(_values[i], minimum) < 0)
54+
{
55+
minimum = _values[i];
56+
}
57+
}
58+
59+
return minimum;
60+
}
61+
62+
#if NET11_0_OR_GREATER
63+
/// Reduces the same snapshot with the .NET 11 span implementation.
64+
/// The minimum value.
65+
[Benchmark]
66+
public int SpanMinimum() => ((ReadOnlySpan<int>)(int[])_values).Min();
67+
#endif
68+
}

‎src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,102 @@ public sealed class SignalAsyncExtensionsTests
1414
/// The second distinct value in an ordered sequence.
1515
private const int SecondValue = 2;
1616

17+
/// Minimum and maximum reductions include values beyond vector boundaries.
18+
/// The number of latest values to reduce.
19+
/// The test operation.
20+
[Test]
21+
[Arguments(3)]
22+
[Arguments(8)]
23+
[Arguments(17)]
24+
[Arguments(65)]
25+
public async Task GetMinMax_IntegerInputs_FindExtremesAcrossVectorBoundaries(int sourceCount)
26+
{
27+
var sources = new IObservableAsync<int>[sourceCount - 1];
28+
for (var i = 0; i < sources.Length; i++)
29+
{
30+
sources[i] = SignalAsync.Return(i);
31+
}
32+
33+
sources[^1] = SignalAsync.Return(int.MaxValue);
34+
var first = SignalAsync.Return(int.MinValue);
35+
36+
await Assert.That(await first.GetMin(sources).FirstAsync()).IsEqualTo(int.MinValue);
37+
await Assert.That(await first.GetMax(sources).FirstAsync()).IsEqualTo(int.MaxValue);
38+
}
39+
40+
/// Each update reduces the current values after all sources have emitted.
41+
/// Cancels the test's signal notifications.
42+
/// The test operation.
43+
[Test]
44+
public async Task GetMinMax_Updates_UseCurrentValues(CancellationToken cancellationToken)
45+
{
46+
const int InitialMinimum = 3;
47+
const int MiddleValue = 6;
48+
const int InitialMaximum = 9;
49+
const int UpdatedMaximum = 12;
50+
await using var first = Signal.Create<int>();
51+
await using var second = Signal.Create<int>();
52+
await using var third = Signal.Create<int>();
53+
List<int> minima = [];
54+
List<int> maxima = [];
55+
await using var minimum = await first.Values.GetMin(second.Values, third.Values).SubscribeAsync(minima.Add, cancellationToken);
56+
await using var maximum = await first.Values.GetMax(second.Values, third.Values).SubscribeAsync(maxima.Add, cancellationToken);
57+
58+
await first.OnNextAsync(InitialMinimum, cancellationToken);
59+
await second.OnNextAsync(MiddleValue, cancellationToken);
60+
await Assert.That(minima).IsEmpty();
61+
await Assert.That(maxima).IsEmpty();
62+
63+
await third.OnNextAsync(InitialMaximum, cancellationToken);
64+
await first.OnNextAsync(UpdatedMaximum, cancellationToken);
65+
await third.OnNextAsync(1, cancellationToken);
66+
67+
await Assert.That(minima).IsCollectionEqualTo([InitialMinimum, MiddleValue, 1]);
68+
await Assert.That(maxima).IsCollectionEqualTo([InitialMaximum, UpdatedMaximum, UpdatedMaximum]);
69+
}
70+
71+
/// Floating-point reductions retain the default comparer's NaN ordering.
72+
/// The test operation.
73+
[Test]
74+
public async Task GetMinMax_FloatingPointInputs_PreserveNaNOrdering()
75+
{
76+
var source = SignalAsync.Return(double.NaN);
77+
IObservableAsync<double>[] others = [SignalAsync.Return((double)SecondValue), SignalAsync.Return(1D)];
78+
79+
await Assert.That(double.IsNaN(await source.GetMin(others).FirstAsync())).IsTrue();
80+
await Assert.That(await source.GetMax(others).FirstAsync()).IsEqualTo((double)SecondValue);
81+
}
82+
83+
/// Equal floating-point values retain the first value's zero sign.
84+
/// The test operation.
85+
[Test]
86+
public async Task GetMinMax_EqualZeroValues_PreserveFirstSign()
87+
{
88+
const double NegativeZero = -0D;
89+
var source = SignalAsync.Return(NegativeZero);
90+
var second = SignalAsync.Return(0D);
91+
92+
var minimum = await source.GetMin(second).FirstAsync();
93+
var maximum = await source.GetMax(second).FirstAsync();
94+
95+
await Assert.That(BitConverter.DoubleToInt64Bits(minimum)).IsEqualTo(long.MinValue);
96+
await Assert.That(BitConverter.DoubleToInt64Bits(maximum)).IsEqualTo(long.MinValue);
97+
}
98+
99+
/// Non-numeric values continue to use their default comparison contract.
100+
/// The test operation.
101+
[Test]
102+
public async Task GetMinMax_DateInputs_UseDefaultComparer()
103+
{
104+
var earliest = DateTime.UnixEpoch;
105+
var latest = earliest.AddDays(1);
106+
var source = SignalAsync.Return(latest);
107+
var second = SignalAsync.Return(earliest);
108+
109+
await Assert.That(await source.GetMin(second).FirstAsync()).IsEqualTo(earliest);
110+
await Assert.That(await source.GetMax(second).FirstAsync()).IsEqualTo(latest);
111+
}
112+
17113
/// Chaining an enumerable preserves source order.
18114
/// The test operation.
19115
[Test]

0 commit comments

Comments
 (0)